Skip to content

Sonny: a guess at your next message, for Tab to take - #329

Merged
lydakis merged 2 commits into
mainfrom
claude/project-thread-3ditir
Oct 29, 2026
Merged

lydakis merged 2 commits into
mainfrom
claude/project-thread-3ditir

Conversation

@lydakis

@lydakis lydakis commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Requested by George · project thread

Rebased onto main on its own; it no longer depends on #315. Whichever of the two lands second lists sonny.suggest in /config.

What changes

When a turn you started from this window ends well, a copy of the agent guesses what you'll type next. The guess shows dim in the empty composer, and Tab puts it in the composer for you to edit or send. It never sends itself.

This follows the two harnesses that ship the feature:

  • Claude Code 2.1.312 (read from its shipped bundle). After a turn it forks the main loop with cache-safe params and lets the fork call no tool. It sends a [SUGGESTION MODE ...] prompt, filters the reply, and shows it dim in the empty composer for Tab to take. Enter does not submit it.
  • Codex "Composer predictions" (help article). A full suggested message appears in the composer, Tab copies it in, and accepting doesn't send. It's on by default, with a setting to turn it off.

How Sonny does it (sonny/src/suggest.rs):

  • Fork {bot}.suggest-… with allow: [] and expire_ms: 60s. The request keeps the bot's tool list (the daemon sends record.tools and only refuses calls), its cache key, and the window's turn model. So the request reads like the agent's next one and the prompt cache stays warm. In a live run on this head, the guess request carried the same 15 tools as the turn before it. The copy is deleted once it answers, and expire_ms covers a crash. Like the side chat, it's hidden from fleet, /status and the lookout.
  • Ask and filters: Claude Code's prompt in Sonny's words, and its filters ported without the regex crate. Wrapper tags and Suggestion: labels are stripped. It refuses: saying nothing, meta text, bracketed notes, Label: prefixes, fewer than 2 words (except yes, push and similar), more than 12 words, 100 characters or more, several sentences, formatting, evaluative words ("looks good"), and the agent's own voice ("Let me…").
  • When: only after a turn this window's person started that ended completed, and not when it ended on an ask. A message sent here counts (send with no origin); so does a steer, which counts for the turn it joins. Turns started by the lead, the lookout, a !command record or another window get no guess. So a task window or a task opened in the pane spends nothing.
  • Cost: one extra small call per turn of yours, on a warm cache. [sonny] suggest = false in ~/.agent/config.toml turns it off.
  • Keys: Tab takes the guess only while one shows: composer empty, nothing running, no ask, approval, picker or /status open, and keys not in the pane. Otherwise Tab does what it did before. This PR binds no other key; Shift+Tab, or Ctrl+\ after Sonny: full screen only, buttons, folds, quiet agent messages, tasks in the pane, pane keys and a little color #326, still moves into the pane. Agreed with the full-screen thread: once both land, the unfocused pane's title will read ctrl+\ instead of tab while a guess shows.
  • Every prompt box: the side chat and a task opened in the pane are each Sonny itself on a PTY. They share the composer, so they get the guess too (screenshot 3).

I left out some of Claude Code's gates: the terminal-focus check, the unused-streak back-off (it suggests every 10th turn after a run of ignored ones), and the cold-cache skip. Any of them is easy to add if the cost shows up in /usage.

Screenshots

The guess after a reply, and Tab taking it:

shown

taken

The same composer in a side chat in the pane:

side

These come from a real daemon (agent serve) with a scripted Responses model, Sonny in tmux, and tmux capture-pane -e rendered to PNG. After the rebase I ran the same check again on this head: the guess showed after the reply, and Tab took it.

Tests

cargo test -p sonny -p agent-client passes, and cargo clippy -p sonny -p agent-client --all-targets -D warnings is clean. New tests:

  • the filters: kept, unwrapped, silent and refused replies, plus labels before letters whose lowercase is longer
  • end to end against a stand-in daemon: the person's turn ends, the copy is forked with allow: [] and deleted, the composer row reads › run the tests tab, typing hides it, and Tab fills the composer without a send
  • a steered turn is the person's
  • no guess after a turn not started here, or one that failed or was interrupted
  • [sonny] suggest parses, defaults to true, and must be a boolean

Noticed, not fixed here

The pane reads a side chat's screen through vt100 0.15, which drops SGR 2 (dim). So every dim hint in a side chat or a task in the pane shows at full brightness. The guess is italic as well as dim so it still reads as a ghost there. Moving to vt100 ≥0.16 (which has dim()) clashes with ratatui 0.29's unicode-width pin.

🤖 Generated with Claude Code

https://claude.ai/code/session_013jkdGGhx2HU91as8RRz2mY

@lydakis lydakis self-assigned this Oct 29, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-29T04:25:18.515315Z d0670db New commits
🔒 Security Review ✅ Completed 2026-10-29T04:26:38.432891Z d0670db New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b67899bd12

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread sonny/src/suggest.rs Resolved
Comment thread sonny/src/main.rs Resolved
@lydakis
lydakis force-pushed the claude/project-thread-3ditir branch from 4c58ecd to bcd6b1b Compare October 29, 2026 04:13
@lydakis
lydakis changed the base branch from claude/project-thread-npa7di to main October 29, 2026 04:15
@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

agent/sonny/src/mcp.rs

Lines 342 to 343 in bcd6b1b

fn fingerprint(config: &McpServer) -> String {
format!("{config:?}")

P1 Badge Hash MCP configuration instead of caching credential values

When an MCP server has credentials in env or http_headers, this debug-formatted fingerprint contains their literal values. Hub::keep writes it into ~/.agent/sonny/mcp/<server>.json, and the cache writer uses default file permissions rather than restricting access. With a normal 022 umask and traversable home directory, this exposes credentials to other local users even if the original configuration is private. Use a non-reversible configuration digest and restrict cache permissions instead of persisting the credential-bearing debug string. AGENTS.md explicitly prohibits logging credential values.

AGENTS.md reference: AGENTS.md:L98-L99


agent/sonny/src/exec.rs

Lines 743 to 744 in bcd6b1b

Function::new(ctx.clone(), move |text: String| {
said.lock().unwrap().push(text)

P1 Badge Bound script output while collecting it

A script that repeatedly calls text() can exhaust the lookout's memory despite the advertised 256 MiB script limit: each call copies its string into this Rust-owned vector, which QuickJS's memory accounting does not cover. For example, repeatedly printing the same 1 MiB string grows host memory indefinitely while keeping the JavaScript heap small. max_output_tokens is only applied after execution, when joining the collected strings creates another large allocation. Bound the collection during execution or stream full output to disk with a bounded preview so one script cannot terminate the shared lookout and disrupt other agents' tool calls.


agent/sonny/src/exec.rs

Lines 482 to 486 in bcd6b1b

let timeout = script
.timeout_ms
.map_or(TIMEOUT, Duration::from_millis)
.min(TIMEOUT)
.min(left);

P2 Badge Include MCP discovery in the execution deadline

When an enabled server starts slowly or fails to initialize, the preceding hub.tools() loop can consume its full startup timeout for each server before this timeout is calculated. Consequently, an exec with timeout_ms: 1000 can take tens of seconds even if its script only prints text, and the remaining call lifetime in left is stale because discovery time was not deducted. Establish an absolute deadline before admission and discovery, apply it to those waits, and use only the remaining duration for JavaScript execution.


agent/sonny/src/acp.rs

Lines 299 to 301 in bcd6b1b

Some(id) => match t3::recall(id) {
Some(bot) => session.bind(self, Some(bot), false).await,
None => Err(format!("Sonny does not know thread {id}")),

P1 Badge Verify the persisted bot identity when resuming an ACP thread

If a thread's bot is deleted and another bot is created with the same name, resuming that thread silently attaches it to the replacement conversation. t3::remember persists only the bot name, and this resume path passes that name to bind, whose inspection and follow requests never check the original bot_id. The mapping is also shared across stores, so switching --store can attach an existing thread to an unrelated same-named bot. Persist the store identity and bot_id with the name and reject a resume when either differs, preserving AGENTS.md's current-format resumption invariant.

AGENTS.md reference: AGENTS.md:L32-L32


agent/sonny/src/exec.rs

Lines 469 to 473 in bcd6b1b

nested.insert(
id,
Nested {
server: server.clone(),
tool: name.to_owned(),

P2 Badge Reject collisions in normalized MCP tool identifiers

When two enabled servers or tools normalize to the same JavaScript identifier, this insertion silently overwrites the earlier dispatch target while both declarations remain advertised. For example, the configuration accepts servers named my-server and my_server; their respective search tools both become mcp__my_server__search, so a script intending to call the first server actually calls the second. This can send arguments to the wrong service and makes the overwritten tool inaccessible. Detect duplicate normalized identifiers and reject the ambiguity, or assign distinct identifiers consistently across declarations, discovery, and dispatch.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

lydakis commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

The five findings above are in mcp.rs, exec.rs and acp.rs, which this PR doesn't touch; they're code already on main. I logged them in #347 rather than fixing them here. Rebased onto the latest main (7b7b8bd), since the last head got no Test run.


Generated by Claude Code

After a turn the person started from this window ends well, a copy of
the agent, forked with its tools and model but allowed no call, guesses
the next message in 2 to 12 words, as Claude Code's prompt suggestions
and Codex's composer predictions do. The empty composer shows it dim;
Tab takes it in, unsent. Claude Code's ask and filters, in Sonny's words.
`[sonny] suggest` (default true) turns it off, listed in /config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jkdGGhx2HU91as8RRz2mY
…t's own bytes

A steer is its own submission that ends as steered, so the turn the
person's words go to is the one it joins. A label is matched on the
original text's bytes, since a lowercase can change a letter's length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jkdGGhx2HU91as8RRz2mY
@lydakis
lydakis force-pushed the claude/project-thread-3ditir branch from 7b7b8bd to d0670db Compare October 29, 2026 04:23
@lydakis
lydakis merged commit 073965d into main Oct 29, 2026
3 checks passed
@lydakis lydakis mentioned this pull request Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants