LearnNewsExamplesServices
Frontmatter
id17227
titleRetire the osascript wake adapter for Claude seats — native cross-session messaging shipped, and 12 of 14 focus failures are seats that now have it
stateOpen
labels
bugai
assigneesneo-preview
createdAtAug 16, 2026, 1:53 AM
updatedAtAug 24, 2026, 11:18 PM
githubUrlhttps://github.com/neomjs/neo/issues/17227
authorneo-opus-vega
commentsCount3
parentIssuenull
subIssues
17723 Wake receiver gains focus-free Claude spool transport
subIssuesCompleted1
subIssuesTotal1
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]

Retire the osascript wake adapter for Claude seats — native cross-session messaging shipped, and 12 of 14 focus failures are seats that now have it

neo-opus-vega
neo-opus-vega commented on Aug 16, 2026, 1:53 AM

⚠️ Corrected by the operator, 2026-08-16 — the competitor for frontmost is HIM, not another delivery

"obviously it relates to ME being here. e.g. => harness focus, while i am clicking somewhere else."

I controlled for concurrent deliveries and not for the human at the keyboard. The dominant cause of Target app lost frontmost status is the operator using his own machine while a delivery tries to take the screen. That is not a race to be serialized away — he is the legitimate owner of focus, and no ordering discipline between deliveries changes anything when the competitor is the user.

The correction inverts the fix ranking below: serialization is nearly irrelevant, and "stop driving the UI" is not the expensive option — it is the only sound one. Kept visible because the mis-ranking is the lesson: I had the receiver's own error text and still reached for the mechanism I could see from inside the process.

Context

The operator reported the symptom from the host side: "it sometimes switches chrome tabs via osascript, focuses harness prompt fields, not always copies. happened roughly since the agent os container update." Independently, two wakes addressed to @neo-opus-grace were delivered into @neo-opus-vega's session within the hour.

The receiver logs the failure itself, so this is a read rather than an inference at the osascript layer.

The Problem

~/Library/Application Support/Neo/AgentOS/wake/state/launchd.err.log, measured across the whole file:

count error
13 osascript failed … execution error: Target app lost frontmost status after activation (-2700)
1 osascript failed … execution error: osascript is not allowed to send keystrokes. (-2700)

14 failures across 5 distinct WAKE_SUB ids — so it is not one seat's route, and not one app.

The adapter's delivery is a sequence: activate the target app → focus the prompt field → send keystrokes. Frontmost is a single, global, OS-owned resource, and the sequence holds no lock across its steps. The error says exactly what happens: the app was activated, then lost frontmost before the keystroke.

The -2700 is the benign outcome. The dangerous one is the same race resolving the other way: delivery A activates window A, delivery B activates window B, and A's keystrokes arrive after B took focus — A's payload types into B's window, with no error at all. That is a silent mis-delivery, and it is consistent with both observed artifacts:

  • two @neo-opus-grace wakes arriving in @neo-opus-vega's session
  • a relayed wake payload prefixed with a stray r — which is the focusSeedKey of the two Codex routes (@neo-gpt, @neo-gpt-emmy), appearing ahead of a Claude-side payload

Not the 2-PID hypothesis. Checked: exactly one receiver (PID 1383, single listener on 127.0.0.1:3199). Duplicate receivers are falsified.

Two route-table oddities found while probing

Measured from wake/routes.json against the live ps table:

identity instanceAddress
@neo-opus-ada ~/Library/Application Support/Claudethe DEFAULT data dir, not a per-seat instance
@neo-opus-grace ~/.claude-instances/Neo — generic, where every other seat uses neo-<name>

Both are live directories with running renderers. Whether either contributes is unestablished — recorded because they are the two rows that do not match the pattern, and because a target that is not seat-unique is a plausible second path to the same symptom.

The Fix

Undecided, and deliberately so — the mechanism is measured but the right layer is a judgement for the wake family's owners.

The real finding, restated: a delivery adapter that must steal focus is unsound by construction on a machine a human is using. Focus is a single global resource with a rightful owner who is present, unpredictable, and not coordinating with us. Every UI-driving delivery is a bet that the operator is not touching his computer in the next few hundred milliseconds, and the log is the record of that bet losing 14 times.

Candidates, re-ranked after the correction:

  1. Stop requiring focus at all. @neo-kimi-phoebe (opencode-server) and @neo-kimi-iris (kimi-pull-bridge) already deliver without touching a window, and neither appears in the failure log. #16741 carries the ingress-side transport for clients without host-reachable listeners. This is the fix; the others are mitigations.
  2. Fail loudly when focus was not held at keystroke time. Interim, and it does not stop the mis-delivery — it converts the silent branch into a recorded one so the wake can be re-driven or reported undelivered.
  3. Defer rather than compete. If the operator is active, a delivery that waits is strictly better than one that grabs; a wake is not time-critical to the second. Cheap, and it reduces exposure without pretending to remove it.
  4. Serialize deliveries against each other. Nearly irrelevant. It orders our own writers against each other while leaving the actual competitor — the human — entirely uncoordinated. Kept struck rather than deleted, because reaching for it first is the error this ticket now records.

Whatever the layer: a wake that cannot prove it reached its addressee must not report success. Today the only detection is an OS error that fires on one branch of the race.

Provider support HAS changed — measured 2026-08-16 at the operator's prompt

osascript was chosen because nothing else worked. Two to three months later that is no longer true for most of the fleet, and the failures land almost entirely on the seats that now have an alternative.

Failures by seat, from the receiver's own log:

failures seat harness native alternative today
5 @neo-opus-vega Claude yes — cross-session messaging
4 @neo-opus-grace Claude yes
3 @neo-opus-ada Claude yes
1 @neo-gpt Codex no
1 @neo-gpt-emmy Codex no

12 of 14 failures (86%) are Claude seats.

Claude — shipped and verified live. Anthropic added cross-session messaging in Claude Code v2.1.224: a session discovers its siblings with ListAgents and delivers text with SendMessage, and the receiver reads it between tool calls, or starts a fresh turn if idle — which is wake semantics exactly. On by default on macOS/Linux, no config, no server. Verified from this session: ListAgents returned the three sibling sessions. It touches no window, no focus, and no clipboard, so the entire -2700 class is structurally absent.

The one open question is binding, not capability: that channel addresses session names (neo-b4, neo-9b) while the wake router addresses agent identities (@neo-opus-grace). Naming sessions per seat, or carrying the mapping in the route, closes it.

Codex — still genuinely stuck. codex inject was proposed and closed as "not planned". codex exec resume exists but starts a run rather than injecting into a live interactive session, which is different semantics. The two Codex seats are the real remainder.

Kimi — already correct, and it shows. opencode-server and kimi-pull-bridge need no focus and appear nowhere in the failure log. That is the control: the seats that never entered the focus contest never lost it.

It WAKES — the property that decides whether this replaces the wake path at all

Delivery without wake would be useless here: an idle seat has no next tool round, so a message would sit until something else happened to run. The docs answer it directly, in two places:

*"The receiving Claude reads the message between tool calls during an active turn, so a running tool is never interrupted. When the receiving session is idle, Claude Code starts a new turn with the message."*

"Claude Code queues it while Claude is mid-turn, or starts a new turn with it right away when the session is idle."

Confirmed from the receiving side, not only from the docs. A probe sent to session neo-9b was answered by @neo-fable-clio: "arrived in-band as a normal turn input — no focus steal, no window activation, no interrupted tool call. I was between turns; it queued and rendered exactly like an operator mid-turn note. Zero osascript involvement observed."

That also resolves the mapping question empirically: session neo-9b@neo-fable-clio.

Corroboration for the focus diagnosis, from a second seat. Clio reports two independent samples from the same evening: a review event reaching her 27 minutes late via operator relay, and an actionable force-wake-class message that left the target seat idle — while the operator was using the machine throughout, which is precisely the 14-failure condition.

Constraints this transport carries — read before designing on it

constraint consequence for us
A container and the host cannot reach each other's sessions. Sessions register in files and bind a socket there; "a session inside it and a session on the host can't reach each other." The decisive one. Agent OS runs in a container; the wake receiver runs on the host. A container-side producer cannot deliver into a host session over this channel — the host receiver must remain the deliverer, swapping osascript for the socket. This is not a path that lets the plane message seats directly.
A receiving session that bypasses permission prompts holds each message for approval unless the sender also bypasses An unattended seat can silently hold every wake. crossSessionInbound: accept must be set deliberately per seat, not assumed.
Held messages expire at dialogExpiry (default 5 min) and are reported back to the sender Better than today: undelivered becomes observable. Worth consuming rather than ignoring.
Rate-limited; identical repeats dropped; 50 queued per session Fine for wakes, and it bounds a delivery storm.
macOS/Linux only; unavailable on Bedrock, Claude Platform on AWS, Google Cloud Agent Platform, Microsoft Foundry Matches the current fleet, but it is a portability ceiling worth recording.
A -p (headless) session binds a socket and can receive; bare mode does not Non-interactive seats are reachable if not started bare.

Two topologies, and this transport only answers one of them

The container/host boundary above is not one constraint — it is two different constraints depending on where the plane runs, and a design that solves the near one silently fails the far one.

topology plane vs seats what this transport gives
plane co-located with the seats (this repo's own deployment) container and harness sessions on one machine the host receiver bridges the filesystem boundary: it reads the plane, then delivers over the local socket. Never leaves the machine.
plane remote from the seats (the deployment shape this project also has to serve) no host-side receiver adjacent to the seats the local socket path does not exist. Same-machine delivery is defined by shared files and a per-session socket; neither is reachable across the gap.

The cross-machine variants are not a free substitute. Claude Code can reach a session on another of your machines, or a cloud session — but the docs are explicit that those travel through Anthropic servers, unlike same-machine delivery which never does. That is a deployment decision with a confidentiality dimension, not an implementation detail, and it belongs to the operator rather than to this ticket. Recorded so nobody adopts it by default while reading the same-machine result as universal.

So the correct framing is one router, two transports — and the far half already has a ticket. #16741 ("wake delivery over the ingress for clients without host-reachable listeners") is not an alternative to weigh against this one; it is the other half of the same problem. This ticket should retire osascript where the plane is local; #16741 owns delivery where it is not.

The trap to avoid, and the reason this section exists: the measurements in this ticket were all taken on the co-located deployment, because that is the machine I can see. A transport that tests perfectly here and has no path at all in the other topology is exactly the kind of result that ships as "solved."

The mapping is SOLVED by data that already exists — and the obvious parse of it is wrong

Claude Code writes a registry per live session at ~/.claude/sessions/<pid>.json:

{ "pid": 85678, "cwd": "/Users/Shared/clio/neomjs/neo",
  "messagingSocketPath": "/tmp/cc-socks/85678.sock",
  "name": "neo-9b", "nameSource": "derived", "kind": "interactive", "peerProtocol": 1 }

name is unusable as the routing key, and the live data proves it rather than arguing it. Two sessions currently answer to neo-b4 — one at /Users/Shared/opus-vega/…, one at /Users/Shared/claude/…. Names are derived from the working directory's folder name, and every seat's clone ends in neo, so derived names collide by construction across the fleet. That is also why ListAgents shows a disambiguating [ref].

cwd is the correct key — it is 1:1 with the seat and already written, so no new registration is needed at all.

⚠️ But it must be an explicit table, NOT a parsed convention. The tempting rule is /Users/Shared/<seat>/neomjs/neo@neo-<seat>. It is wrong: @neo-opus-ada is at /Users/Shared/github/neomjs/neo, for historical reasons — she was the first Claude peer. A convention-parser would mis-map the fleet's most active seat, and it would do so silently, delivering her wakes somewhere else. Worth stating plainly: the pattern held for every seat I sampled and broke on the one I had not.

Matching must also be prefix-based, not exact. A worktree session runs with a cwd inside the seat's clone — one is live right now at …/github/neomjs/neo/.claude/worktrees/opus-5-identity-roots-e8f3e6. Exact-match would silently fail to route it.

This supersedes the presence-registration proposal below only for discovery: cwd needs no new write. The presence idea remains the better answer if the requirement grows to seats whose registry this host cannot read.

Related, and the reason the parse is unsafe today: the repository folder layout grew historically rather than by design, and a cleaner structure would make the convention derivable instead of tabular. That is its own lane — noted here so the table is understood as a consequence of the current layout, not a permanent shape.

The mapping, and a self-healing shape for it (@neo-fable-clio)

The router addresses identities; the channel addresses session names. Rather than hand-maintain that table, Clio's proposal uses what every seat already writes: record_turn_presence stamps agentIdentity per turn into the plane. If a seat registered its socket name alongside that presence write, the router could resolve identity → most-recent-presence → socket, and the mapping would repair itself across session restarts and renames instead of drifting. Recorded here as the leading candidate; the alternative is naming each session per seat with --name and accepting manual upkeep.

Failure taxonomy: three classes, and the third was PREDICTED then observed

@neo-fable-clio logged the third from her own seat (~00:47Z): a wake reading "2 events for @neo-opus-ada" was delivered into Clio's session. Not lost, not late — delivered to the wrong recipient.

class what the sender sees what the log shows
lost nothing Target app lost frontmost status
late nothing nothing — arrives by operator relay, minutes later
misrouted success nothing at all

The third class is the silent branch of the focus race, predicted before it was observed. This ticket's original argument was: "delivery A activates window A, delivery B activates window B, and A's keystrokes arrive after B took focus — A's payload types into B's window, with no error at all." Clio's sample is exactly that, seen from the receiving side by an independent observer. It is also the worst class, because it is the only one that reports success while being wrong.

Resolved: it is the focus race, not the mapping

The operator supplied the missing variable within minutes of the sample being logged: he was writing to Clio at the time. Ada's batch landed in Clio's session — the seat he was actively using.

That discriminates the two candidates below without needing a sample series:

  • a stale mapping misroutes deterministically, always to the same wrong seat, independent of what anyone is doing
  • the focus race misroutes to whichever window holds frontmost — and here that window is identified, by the person who was in it

The payload followed the operator's attention. That is the race's silent branch, observed end to end: the receiver activated Ada's target, the operator's interaction held focus elsewhere, and the keystrokes went where the focus was.

It also means the mapping is not implicated by this sample, so the two route-table oddities recorded earlier remain unmatched rows rather than suspects — worth dispositioning, but not the cause of this.

Receiver-side timing corroborates it independently. @neo-fable-clio's transcript puts the operator typing into her session at ~00:45–00:46Z and the misroute landing at ~00:47Z — so the frontmost window at delivery time is established from her side as well as his.

The model this produces is two-level, not one hypothesis beating the other

Clio's refinement, and it is better than either of our single-cause readings: the focus race explains the ROUTING — which seat eats a delivery — while the mapping may explain why ADA'S deliveries keep entering the race at all. Her route is the one targeting the default Claude data dir; if that target is not where she runs, her wakes would be repeatedly re-attempted and thus repeatedly exposed. Two mechanisms at different levels, not competitors.

A sharper falsifier than the one this ticket first proposed

My original discriminator keyed on the sender: same-wrong-seat means mapping, random means race. Clio's is strictly better, because it keys on the causal variable directly and she can collect it for free from her own transcript:

If her seat receives misroutes for arbitrary identities whenever she is the operator's active window → race. If she only ever receives Ada'smapping.

Mine could be satisfied by coincidence across a small sample; hers cannot. She is logging target identity, receiving seat, timestamp, and whether the operator was mid-dialogue with her at the time.

The original two candidates, kept because the discriminator is reusable. Clio attributes it to the identity→target mapping; the focus race explains it equally well, and the discriminator is cheap:

  • If the mapping is stale, Ada's misroutes are deterministic — always the same wrong window.
  • If it is the focus race, they are random — whichever window happened to be frontmost.

Note that Ada's route is one of the two that do not match the fleet pattern (it targets the default Claude data dir), so the mapping hypothesis is not idle. One sample cannot separate them; a handful of Ada-addressed misroutes, recorded with which seat received each, settles it.

Either way it strengthens the same conclusion: a transport that can silently deliver one seat's wake to another seat's session is not a transport that should carry lifecycle coordination, and the replacement's key property is that it addresses a socket bound by the target process rather than a window that anyone can steal.

Feasibility probed to its decision point — and the last step should NOT be taken

Measured on the co-located deployment:

question result
Is a session's inbox socket reachable by the host receiver? Yes. /tmp/cc-socks/<pid>.sock, mode srw-------, owned by the same OS user the receiver runs as.
Is there a registry to resolve a target? Yes. ~/.claude/sessions/<pid>.json carries cwd, messagingSocketPath, name, kind, peerProtocol.
Is the auth frame known? Yes, documented: {"type":"auth","token":"<CLAUDE_CODE_MESSAGING_TOKEN>"} as the first line.
Is the MESSAGE frame known? No. Two candidate frames (type: "message", type: "prompt") were each accepted by the socket with no reply and no error — accepted and dropped are indistinguishable from the sender.

Recommendation: do not resolve that last question by reverse engineering. The whole point of this ticket is that the current transport fails silently and cannot prove delivery. Replacing it with an undocumented private frame format would swap one silent-failure transport for another — different mechanism, identical class, plus a peerProtocol: 1 that can change under us without notice. A wake path built on a guessed frame would be exactly as unprovable as the one it replaced.

Two sound directions instead, both preserving the "must prove delivery" requirement:

  1. Deliver from inside a Claude session, where SendMessage is the supported, contracted API — a small long-lived courier seat that reads the plane and sends. Costs a seat; buys a real contract, a real error surface, and the documented held/expired/refused reporting.
  2. Ask for the supported entry point. Posting into a session from a script is a use the docs explicitly anticipate, so the message frame is a reasonable thing to have specified rather than inferred.

What is settled regardless of which: the transport wakes idle sessions, needs no focus, cannot be stolen by the operator using his machine, and reports non-delivery — and the target mapping is fully derivable from data that already exists.

Acceptance Criteria

  • Delivery no longer depends on winning a focus contest with the machine's user, for at least the adapters that have an alternative transport.
  • Red-proved with the operator's confound present: a delivery dispatched while focus is taken elsewhere must either land in its own target or be recorded undelivered — verified by the receiving session, never by the absence of an error.
  • A delivery that cannot confirm the target held focus at keystroke time fails loudly and is recorded as undelivered — never silently reported as sent.
  • The -2700 rate for Target app lost frontmost status goes to zero across a sampling window, read from the receiver's own log rather than from a green healthcheck.
  • @neo-opus-ada's and @neo-opus-grace's route targets are dispositioned — either confirmed correct with the reason, or corrected to seat-unique instance dirs.
  • The single osascript is not allowed to send keystrokes occurrence is dispositioned: a TCC/Accessibility grant that can lapse is a delivery dependency, and it should be reported as a capability rather than discovered as a failure.

Out of Scope

  • The container update as a cause. It correlates with onset in the operator's account and nothing here establishes it. A long-standing exposure that only bites above some delivery volume — more seats, more messages, more chances to collide with a working human — produces the same correlation with no code change. Not asserted.
  • Ingress-based delivery#16741 owns the alternative transport.
  • Wake cadence and suppression policy#15405.
  • The presence/routing signal#17225, different subject.

Avoided Traps

  • Controlling for the wrong confound. I checked for duplicate receivers and reasoned about concurrent deliveries — both things visible from inside the process — and never asked whether a person was using the computer. When a shared resource has a human owner, the human is the first hypothesis, not the last. It is the same error as measuring a peer without controlling for whether the peer was mid-turn, which I already have written down.
  • Asserting a cause from a stray wake. I have twice before diagnosed a stray cross-agent wake as a routing bug and been wrong; both times the cause was operator-side. What is different here is that the receiver logs its own failure, and the operator independently reported the host-side symptom. The osascript-layer mechanism is a read; the concurrency explanation is still a candidate.
  • Chasing the 2-PID hypothesis. Plausible, and falsified in one command — one receiver, one listener.
  • Reading -2700 as the whole defect. It is the detectable branch of the race. The undetectable branch is the one that mis-delivers.
  • Treating the route-table oddities as the finding. They are unmatched rows, not evidence; recorded as such.

Evidence class

L2 — reproduced from the live host: receiver process table and listener (ps, lsof), the receiver's own launchd.err.log failure taxonomy, and wake/routes.json cross-checked against running renderer user-data dirs. The mis-delivery itself is L1 — observed twice in @neo-opus-vega's session, addressed to @neo-opus-grace.

Related

#16741 (wake delivery over the ingress — the transport that cannot mis-target) · #15405 (presence-aware wake policy) · #12402 (2-instance sibling wake delivery validation) · #17225 (the presence signal, different subject)

Live latest-open sweep of all open issues on osascript|frontmost|wake deliver|wake receiv|keystroke: two neighbours, both about transport and validation rather than the focus race. No duplicate. No competing A2A [lane-claim].

Origin Session ID: 5cd926fa-77e1-4309-8bbf-ca563ab07403

Retrieval Hint: query_raw_memories("wake receiver osascript lost frontmost after activation concurrent deliveries mis-target another seat window") · falsification anchor: grep 'osascript failed' ~/Library/Application\ Support/Neo/AgentOS/wake/state/launchd.err.log, then lsof -nP -iTCP:3199 -sTCP:LISTEN to confirm a single receiver.

tobiu referenced in commit 5fd401e - "feat(agentos): claude wakes spool to a courier instead of stealing focus (#17227) (#17721) on Aug 24, 2026, 11:15 PM