@gotcos/glasses-server 6.48.1 → 6.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +14 -0
- package/package.json +1 -1
- package/server/index.ts +5 -0
- package/server/lib/attached-workspace.ts +53 -18
- package/server/lib/session-peer-inbox-deps.ts +156 -0
- package/server/lib/session-peer-inbox.ts +423 -0
- package/server/lib/session-stream-events.ts +128 -5
- package/server/lib/thread-turn-queue-store.ts +11 -1
- package/server/routes/agent-session-bindings.ts +78 -0
- package/server/routes/health.ts +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
## 6.49.0
|
|
2
|
+
|
|
3
|
+
A Continue lands in the session you left open, and the live feed says what every step produced.
|
|
4
|
+
|
|
5
|
+
- **The running session takes the turn.** Two of Miles's Continues from the lens on 2026-09-15 were delivered, answered, and invisible: the resume child appended them to the transcript, and the Desktop tab that was open on that session never re-reads its file, so the desk showed nothing until he resumed. The server now writes the turn into the session's own inbox first. Claude Code publishes one per live process in `~/.claude/sessions/<pid>.json` (`messagingSocketPath`, `peerProtocol: 1`) and documents the two-line format in its help text; a Desktop tab and a `claude` in iTerm carry the same record, so there is one path, not two. Measured on this Mac: an idle session took the message as a real turn and answered in about three seconds, painted in its own window. Off until `COS_CONTINUE_LIVE=1`; every refusal takes the 6.48.2 path.
|
|
6
|
+
- **Delivered means the transcript says so.** Bytes on a socket prove nothing: the receiver drops a frame whose `session_id` is not its own, may hold one behind its permission policy, or may be shutting down. So the turn is `delivered` only when the session's transcript shows the `queue-operation: enqueue` row (or the user row) carrying it, which Claude Code writes the instant it accepts a message and before the turn runs. The frame is pinned to the full session id (a pid is recycled; a prefix is ambiguous), a COS-spawned resume child sharing the id is never the target, and a wrong token is not a failure because Claude Code accepts unauthenticated peers on this platform (measured) and the pin is the protection.
|
|
7
|
+
- **Never twice.** A frame written but not seen accepted is a suspect, not a failure: the route answers `live_unverified` (retryable, not ledgered), the retry re-reads the transcript from the ORIGINAL send before doing anything, the socket is never written twice for one turn, and only after 60 s without an acceptance row does the resume child run. The one duplicate this leaves open is a session whose permission mode holds peer messages for approval, approved by hand after the child already answered; that is a Deny, and it is named here rather than hidden. The attach gate runs in full before the live hop: a busy Desktop holder is still refused first and queued, and the queue drains into the live session when the turn ends.
|
|
8
|
+
- **Outcomes on the live feed.** Each tool result in the transcript now emits a `status` draft carrying `tool_outcome: { ok, detail }`, derived from the result's own shape and never from its content: `235 lines`, `+14 -2`, `41 lines`, `3 files`, `17 hits`, `200 · 13 KB`, `exit 1`, `denied`, `interrupted`. A client that predates it takes a repeated `status: working` as a no-op (nothing appended, no step counted, no gap), so 6.9.485 renders unchanged; the next EHPK and Control read it to show the result at the end of each step line and the step still running as the live row.
|
|
9
|
+
- `/api/health` gains `continueLive` (enabled, attempts, delivered, fallbacks by reason, last reason). Control's env allowlist carries `COS_CONTINUE_LIVE` from its next build so Update Server keeps it.
|
|
10
|
+
- Tests: the pure inbox module (full-id selection, the COS-child skip, the pinned frames, acceptance from the enqueue and user rows with the time floor, the suspect memo's three rules, every refusal never sends), the route (live completes without a spawn and replays; nothing-sent reasons spawn exactly as 6.48.2; unverified holds and never spawns; a throwing transport spawns; the gate runs first; absent dep is the 6.48.2 route), the outcome tokens from twenty-one recorded result shapes, the derived-status stamp keeping the outcome. Mutation gate: twelve new cases, all killed on a green baseline.
|
|
11
|
+
- Requires nothing new on the clients. Claude Code 2.1.273 measured; the inbox protocol is read from the registry record and refused when it is not `1`.
|
|
12
|
+
|
|
13
|
+
## 6.48.2
|
|
14
|
+
|
|
15
|
+
Continue and Fork work again on a session whose transcript opens with a big row. Reported from the lens on 6.9.482 (Miles, 2026-09-15): Continue on a Claude session answered "Can't continue. Fork thread instead" while the row said the session was attachable.
|
|
16
|
+
|
|
17
|
+
- The attach route reads the session's working directory out of the transcript's first rows. It read a fixed 512 KiB, and a session whose first prompt carried pasted screenshots opens with one row of about 1 MB, so the window held four bookkeeping rows and a torn fifth, no `cwd`, and every Continue and Fork on that session was refused `target_unresolvable`. The read now widens (512 KiB, 2 MiB, 8 MiB, 16 MiB) while the window keeps ending inside a row with no `cwd` seen, and stops as soon as the whole file has been read or a row boundary shows the transcript records none.
|
|
18
|
+
- The first `cwd` in the transcript is the workspace. Two different values used to refuse the attach as a disagreement; on the release Mac every such transcript was one session whose shell had `cd`-ed into a subfolder inside a turn, and Claude stamps each row with the process cwd of the moment. The directory the session started in keys its project folder and is the only place `--resume` finds it, so it is the answer. On the release Mac 738 of 758 transcripts resolve (before: 703); the 20 that do not are scratch sessions whose temporary directory has since been deleted, which is a refusal by design.
|
|
19
|
+
- Tests: the first-cwd rule for Claude and Codex, the torn-window signal, the widening sequence against the production shape (four bookkeeping rows, a 950 KB image row, then the cwd), a short file read once, a first row past the cap refused at the cap. Mutation gate: the widening and the first-cwd rule.
|
|
20
|
+
|
|
1
21
|
## 6.48.1
|
|
2
22
|
|
|
3
23
|
One derivation everywhere, a Desktop turn ends when the engine says it ended, a queued follow-up drains then instead of on the 30 s idle window, and the live feed carries the same state every row does. No client change is required: the next Control and EHPK builds read the new keys, and today's consumers ignore them.
|
package/README.md
CHANGED
|
@@ -200,6 +200,20 @@ after a reconnect and seeds afresh when it holds nothing, `?seed=turn` opens at
|
|
|
200
200
|
current prompt). `COS_SESSION_HOOK_SSE=0` omits the extra status fields and the live
|
|
201
201
|
state drafts; the `cursor`, `epoch` and `id:` line stay.
|
|
202
202
|
|
|
203
|
+
Since 6.49.0 a Continue can land in the running session itself, so the Desktop tab or
|
|
204
|
+
the `claude` in a terminal that you left open shows the turn and answers it with its own
|
|
205
|
+
context, instead of a resume child writing to the transcript behind that window. It is
|
|
206
|
+
off until `COS_CONTINUE_LIVE=1`. The server writes the turn to the session's own inbox
|
|
207
|
+
(the socket Claude Code publishes in `~/.claude/sessions/<pid>.json`, in the line format
|
|
208
|
+
its help text documents), pinned to the full session id, and calls it delivered only
|
|
209
|
+
when the session's transcript shows the message accepted; every other outcome takes the
|
|
210
|
+
6.48.2 path unchanged. A frame written but not seen accepted holds the turn as
|
|
211
|
+
`live_unverified` (retryable) and is never written twice; only after 60 s without an
|
|
212
|
+
acceptance row does the resume child run. `/api/health` reports `continueLive`
|
|
213
|
+
(enabled, attempts, delivered, fallbacks by reason). The live stream also carries a
|
|
214
|
+
`tool_outcome` on the status draft that follows each tool result (`+14 -2`,
|
|
215
|
+
`235 lines`, `41 passed`, `exit 1`), which a client that predates it takes as a no-op.
|
|
216
|
+
|
|
203
217
|
## Configuration
|
|
204
218
|
|
|
205
219
|
Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.49.0",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { cosSpawnedPids } from './lib/agent-session-ownership-store.js'
|
|
|
36
36
|
import { buildOccupancyProbes, realOccupancyDirs, withHookTurnClock } from './lib/occupancy-probes.js'
|
|
37
37
|
import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './lib/attached-workspace.js'
|
|
38
38
|
import { deliverAttachedTurn, realAttachedTurnDeps } from './lib/attached-provider-adapter.js'
|
|
39
|
+
import { makeLiveTurnDeliverer } from './lib/session-peer-inbox-deps.js'
|
|
39
40
|
import { forkThread, realForkDeps } from './lib/fork-thread.js'
|
|
40
41
|
import { nativeHead, realNativeHeadDeps } from './lib/native-head.js'
|
|
41
42
|
import { threadOccupancy, holderActivity } from './lib/thread-occupancy.js'
|
|
@@ -749,6 +750,10 @@ app.use('/api', createAgentSessionBindingsRouter({
|
|
|
749
750
|
},
|
|
750
751
|
nativeHead: (provider, threadId) => nativeHead(provider, threadId, nativeHeadDeps),
|
|
751
752
|
deliverAttachedTurn: deliverAttachedTurnForRoute as never,
|
|
753
|
+
// 6.49.0: the running session first (Desktop tab or iTerm, same registry record),
|
|
754
|
+
// the resume child second. Reads `COS_CONTINUE_LIVE` per call, so the flag reports
|
|
755
|
+
// on /api/health without a restart and every refusal falls back to the 6.48.2 path.
|
|
756
|
+
deliverLiveTurn: makeLiveTurnDeliverer(nativeHeadDeps),
|
|
752
757
|
forkThread: forkThreadForRoute,
|
|
753
758
|
// The fork's real spawn directory. Separate from `resolveTarget` above, which
|
|
754
759
|
// deliberately yields only fingerprints because plan 3.3 keeps a filesystem path
|
|
@@ -61,9 +61,20 @@ export interface AttachedWorkspaceDeps {
|
|
|
61
61
|
cursorSpawnWorkspace?: (threadId: string) => string | null
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* The first window read for the cwd: enough to reach a Claude message row or the Codex
|
|
66
|
+
* session meta row in the common case. NOT enough on its own (6.48.2): a session whose
|
|
67
|
+
* first prompt carries pasted screenshots opens with a single row of ~1 MB, so 512 KiB
|
|
68
|
+
* held four bookkeeping rows and a torn fifth, no cwd, and every Continue on that
|
|
69
|
+
* session was refused `target_unresolvable` (Miles, 2026-09-15, from the lens; 55 of
|
|
70
|
+
* 758 transcripts on the release Mac). When a window ends inside a row before any cwd
|
|
71
|
+
* was seen, the read widens up to WORKSPACE_SCAN_BYTES_MAX.
|
|
72
|
+
*/
|
|
65
73
|
export const WORKSPACE_SCAN_BYTES = 512 * 1024
|
|
66
74
|
|
|
75
|
+
/** The widest head read: a first prompt with ten screenshots fits; a 13 GB rollout does not get read. */
|
|
76
|
+
export const WORKSPACE_SCAN_BYTES_MAX = 16 * 1024 * 1024
|
|
77
|
+
|
|
67
78
|
/** Rows to inspect before giving up. Bounds a pathological single-line file. */
|
|
68
79
|
export const WORKSPACE_SCAN_ROWS = 400
|
|
69
80
|
|
|
@@ -72,19 +83,37 @@ export function fingerprint(value: string): string {
|
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
/**
|
|
75
|
-
* Pull the recorded cwd out of transcript text.
|
|
86
|
+
* Pull the recorded cwd out of transcript text: the FIRST row that records one.
|
|
76
87
|
*
|
|
77
88
|
* Claude puts it at the row's top level; Codex puts it in the session meta row's
|
|
78
89
|
* `payload`. A torn trailing line is NORMAL — these files are appended to — so a
|
|
79
90
|
* parse failure on any single row is skipped rather than fatal.
|
|
80
91
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
92
|
+
* THE FIRST cwd IS THE WORKSPACE (6.48.2). Until 6.48.1 two different values in the
|
|
93
|
+
* window refused the attach as a disagreement. On the release Mac every such
|
|
94
|
+
* transcript was one session whose shell `cd`-ed inside a turn (`operations/scripts`,
|
|
95
|
+
* a weekly folder): Claude stamps each row with the process cwd of the moment, all
|
|
96
|
+
* under the directory the session was started in. That starting directory is the
|
|
97
|
+
* workspace: it keys the transcript's project folder, it is the only place
|
|
98
|
+
* `claude --resume` finds the session, and it is what the first row records.
|
|
99
|
+
*
|
|
100
|
+
* Returns null only when no row in the window records one.
|
|
83
101
|
*/
|
|
84
102
|
export function cwdFromTranscript(text: string): string | null {
|
|
85
|
-
|
|
103
|
+
return scanForCwd(text).cwd
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The scan with its outcome: the cwd, or whether the window ended INSIDE a row before
|
|
108
|
+
* any cwd was seen (a first prompt of pasted screenshots is one row of ~1 MB), which
|
|
109
|
+
* tells the resolver that a wider read can still answer.
|
|
110
|
+
*/
|
|
111
|
+
export function scanForCwd(text: string): { cwd: string | null; endedMidRow: boolean; rows: number } {
|
|
86
112
|
let rows = 0
|
|
87
|
-
|
|
113
|
+
const lines = text.split('\n')
|
|
114
|
+
// A window that does not end with a newline ended inside whatever row came last.
|
|
115
|
+
const endedMidRow = text.length > 0 && !text.endsWith('\n')
|
|
116
|
+
for (const line of lines) {
|
|
88
117
|
if (rows >= WORKSPACE_SCAN_ROWS) break
|
|
89
118
|
const trimmed = line.trim()
|
|
90
119
|
if (!trimmed) continue
|
|
@@ -94,18 +123,14 @@ export function cwdFromTranscript(text: string): string | null {
|
|
|
94
123
|
if (!row || typeof row !== 'object' || Array.isArray(row)) continue
|
|
95
124
|
const record = row as Record<string, unknown>
|
|
96
125
|
const direct = record.cwd
|
|
97
|
-
if (typeof direct === 'string' && direct.length > 0)
|
|
126
|
+
if (typeof direct === 'string' && direct.length > 0) return { cwd: direct, endedMidRow: false, rows }
|
|
98
127
|
const payload = record.payload
|
|
99
128
|
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
100
129
|
const nested = (payload as Record<string, unknown>).cwd
|
|
101
|
-
if (typeof nested === 'string' && nested.length > 0)
|
|
130
|
+
if (typeof nested === 'string' && nested.length > 0) return { cwd: nested, endedMidRow: false, rows }
|
|
102
131
|
}
|
|
103
|
-
// One consistent answer found early is enough; keep scanning only while it is
|
|
104
|
-
// still the only one, so a disagreement is detected rather than short-circuited.
|
|
105
|
-
if (seen.size > 1) return null
|
|
106
132
|
}
|
|
107
|
-
|
|
108
|
-
return [...seen][0]!
|
|
133
|
+
return { cwd: null, endedMidRow, rows }
|
|
109
134
|
}
|
|
110
135
|
|
|
111
136
|
/**
|
|
@@ -145,17 +170,27 @@ export function resolveAttachedWorkspace(
|
|
|
145
170
|
}
|
|
146
171
|
if (provider !== 'claude' && provider !== 'codex') return null
|
|
147
172
|
let path: string | null
|
|
148
|
-
let
|
|
173
|
+
let cwd: string | null = null
|
|
149
174
|
try {
|
|
150
175
|
path = deps.transcriptPath(provider, threadId)
|
|
151
176
|
if (path === null) return null
|
|
152
|
-
|
|
177
|
+
// Widen the head read while the window keeps ending inside a row with no cwd seen:
|
|
178
|
+
// 512 KiB, 2 MiB, 8 MiB, then the cap. A window that ended on a row boundary with
|
|
179
|
+
// no cwd is a transcript that records none, and widening would not change that.
|
|
180
|
+
let window = WORKSPACE_SCAN_BYTES
|
|
181
|
+
for (;;) {
|
|
182
|
+
const text = deps.readHead(path, window)
|
|
183
|
+
if (text === null) return null
|
|
184
|
+
const scan = scanForCwd(text)
|
|
185
|
+
cwd = scan.cwd
|
|
186
|
+
// The whole file came back (shorter than the window): there is nothing wider to read.
|
|
187
|
+
const wholeFile = Buffer.byteLength(text, 'utf8') < window
|
|
188
|
+
if (cwd !== null || wholeFile || !scan.endedMidRow || scan.rows >= WORKSPACE_SCAN_ROWS || window >= WORKSPACE_SCAN_BYTES_MAX) break
|
|
189
|
+
window = Math.min(window * 4, WORKSPACE_SCAN_BYTES_MAX)
|
|
190
|
+
}
|
|
153
191
|
} catch {
|
|
154
192
|
return null
|
|
155
193
|
}
|
|
156
|
-
if (text === null) return null
|
|
157
|
-
|
|
158
|
-
const cwd = cwdFromTranscript(text)
|
|
159
194
|
if (cwd === null) return null
|
|
160
195
|
// A relative cwd would resolve against the SERVER's working directory, which is
|
|
161
196
|
// not a location we control and is never what the user meant.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The real world behind `session-peer-inbox.ts`: the registry directory, the key
|
|
2
|
+
// file beside each record, the session's transcript, and the flag.
|
|
3
|
+
//
|
|
4
|
+
// Kept apart from the pure module so the contract there never touches a path, and so
|
|
5
|
+
// this file is the ONE place that knows where Claude Code keeps these things. All of
|
|
6
|
+
// it is read-only: nothing here writes to `~/.claude`.
|
|
7
|
+
|
|
8
|
+
import { lstatSync, readdirSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { claudeSessionsDir, REGISTRY_FILENAME } from './claude-session-registry.js'
|
|
11
|
+
import { transcriptPathFor, type NativeHeadDeps } from './native-head.js'
|
|
12
|
+
import { cosSpawnedPids } from './agent-session-ownership-store.js'
|
|
13
|
+
import { readTranscriptTailLines } from './thread-turn-queue-store.js'
|
|
14
|
+
import {
|
|
15
|
+
deliverOverPeerInbox,
|
|
16
|
+
type PeerDeliveryResult,
|
|
17
|
+
type PeerInboxRecord,
|
|
18
|
+
type TranscriptRowLike,
|
|
19
|
+
} from './session-peer-inbox.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `COS_CONTINUE_LIVE`. OFF by default in 6.49.0.
|
|
23
|
+
*
|
|
24
|
+
* The transport rides a protocol Claude Code documents in a help string, not a
|
|
25
|
+
* contract, and the first days are for reading the health row on one Mac. A literal
|
|
26
|
+
* '1' turns it on; anything else, including absence, keeps every Continue on the
|
|
27
|
+
* 6.48.2 path. Control's env allowlist carries the key so Update Server keeps it.
|
|
28
|
+
*/
|
|
29
|
+
export function continueLiveEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
30
|
+
return env.COS_CONTINUE_LIVE === '1'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Registry files are small; anything larger is not one. */
|
|
34
|
+
const MAX_RECORD_BYTES = 64 * 1024
|
|
35
|
+
/** `<pid>.<64 hex>.key`, the sibling of `<pid>.json`. */
|
|
36
|
+
const KEY_FILENAME = /^(\d+)\.[0-9a-f]{64}\.key$/
|
|
37
|
+
/** Enough of the tail to hold the acceptance row for any prompt we could have sent. */
|
|
38
|
+
const ACCEPTANCE_TAIL_BYTES = 256 * 1024
|
|
39
|
+
|
|
40
|
+
/** One pass over `~/.claude/sessions`, records only. Never throws. */
|
|
41
|
+
export function readPeerInboxRecords(dir: string = claudeSessionsDir()): PeerInboxRecord[] {
|
|
42
|
+
let names: string[]
|
|
43
|
+
try { names = readdirSync(dir) } catch { return [] }
|
|
44
|
+
const out: PeerInboxRecord[] = []
|
|
45
|
+
for (const name of names) {
|
|
46
|
+
if (!REGISTRY_FILENAME.test(name)) continue
|
|
47
|
+
const full = join(dir, name)
|
|
48
|
+
try {
|
|
49
|
+
// lstat, not stat: a symlink here would let anything on the filesystem be read.
|
|
50
|
+
const link = lstatSync(full)
|
|
51
|
+
if (!link.isFile() || link.size > MAX_RECORD_BYTES) continue
|
|
52
|
+
const raw = JSON.parse(readFileSync(full, 'utf-8')) as Record<string, unknown>
|
|
53
|
+
const pid = Number(raw.pid)
|
|
54
|
+
const sessionId = typeof raw.sessionId === 'string' ? raw.sessionId.trim().toLowerCase() : ''
|
|
55
|
+
if (!Number.isInteger(pid) || pid <= 0 || !sessionId) continue
|
|
56
|
+
out.push({
|
|
57
|
+
pid,
|
|
58
|
+
sessionId,
|
|
59
|
+
socketPath: typeof raw.messagingSocketPath === 'string' && raw.messagingSocketPath.length > 0 ? raw.messagingSocketPath : null,
|
|
60
|
+
peerProtocol: typeof raw.peerProtocol === 'number' && Number.isInteger(raw.peerProtocol) ? raw.peerProtocol : null,
|
|
61
|
+
entrypoint: typeof raw.entrypoint === 'string' ? raw.entrypoint : null,
|
|
62
|
+
status: typeof raw.status === 'string' ? raw.status : null,
|
|
63
|
+
})
|
|
64
|
+
} catch {
|
|
65
|
+
// A torn read or a record the reaper just unlinked. Ordinary.
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The session's peer token, or null.
|
|
74
|
+
*
|
|
75
|
+
* Auth is optional on this platform (see the pure module's header), so null is the
|
|
76
|
+
* common, acceptable answer whenever the file is absent, unreadable, or not ours.
|
|
77
|
+
* The value is returned to the caller and never logged.
|
|
78
|
+
*/
|
|
79
|
+
export function readPeerToken(record: PeerInboxRecord, dir: string = claudeSessionsDir()): string | null {
|
|
80
|
+
let names: string[]
|
|
81
|
+
try { names = readdirSync(dir) } catch { return null }
|
|
82
|
+
for (const name of names) {
|
|
83
|
+
const m = KEY_FILENAME.exec(name)
|
|
84
|
+
if (!m || Number(m[1]) !== record.pid) continue
|
|
85
|
+
try {
|
|
86
|
+
const full = join(dir, name)
|
|
87
|
+
const link = lstatSync(full)
|
|
88
|
+
if (!link.isFile() || link.size > 4096) return null
|
|
89
|
+
const parsed = JSON.parse(readFileSync(full, 'utf-8')) as { peerToken?: unknown }
|
|
90
|
+
return typeof parsed.peerToken === 'string' && parsed.peerToken.length > 0 ? parsed.peerToken : null
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The newest rows of the session's transcript, parsed; [] when there is no file. */
|
|
99
|
+
export function readAcceptanceTail(sessionId: string, nativeHeadDeps: NativeHeadDeps): TranscriptRowLike[] {
|
|
100
|
+
const path = transcriptPathFor('claude', sessionId, nativeHeadDeps)
|
|
101
|
+
const lines = readTranscriptTailLines(path, ACCEPTANCE_TAIL_BYTES)
|
|
102
|
+
if (!lines) return []
|
|
103
|
+
const rows: TranscriptRowLike[] = []
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
if (!line.startsWith('{')) continue
|
|
106
|
+
try { rows.push(JSON.parse(line) as TranscriptRowLike) } catch { /* torn tail line */ }
|
|
107
|
+
}
|
|
108
|
+
return rows
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface LiveDeliveryStats {
|
|
112
|
+
attempts: number
|
|
113
|
+
delivered: number
|
|
114
|
+
/** By reason, so the health row says WHY the spawn path was taken. */
|
|
115
|
+
fallbacks: Record<string, number>
|
|
116
|
+
lastReason: string | null
|
|
117
|
+
lastAt: number | null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const stats: LiveDeliveryStats = { attempts: 0, delivered: 0, fallbacks: {}, lastReason: null, lastAt: null }
|
|
121
|
+
|
|
122
|
+
/** For `/api/health`. A copy, never the live object. */
|
|
123
|
+
export function liveDeliveryStats(): LiveDeliveryStats {
|
|
124
|
+
return { ...stats, fallbacks: { ...stats.fallbacks } }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The route's dep: try the running session, report honestly. `disabled` is not
|
|
129
|
+
* counted as an attempt, so the health row stays quiet while the flag is off.
|
|
130
|
+
*/
|
|
131
|
+
export function makeLiveTurnDeliverer(nativeHeadDeps: NativeHeadDeps) {
|
|
132
|
+
return async (request: { provider: string; sessionId: string; prompt: string; verifyTimeoutMs?: number }): Promise<PeerDeliveryResult> => {
|
|
133
|
+
const result = await deliverOverPeerInbox(
|
|
134
|
+
{ ...request, enabled: continueLiveEnabled() },
|
|
135
|
+
{
|
|
136
|
+
records: () => readPeerInboxRecords(),
|
|
137
|
+
token: record => readPeerToken(record),
|
|
138
|
+
transcriptTail: id => readAcceptanceTail(id, nativeHeadDeps),
|
|
139
|
+
isCosOwnedPid: pid => cosSpawnedPids().has(pid),
|
|
140
|
+
log: (level, event, detail) => {
|
|
141
|
+
const line = `[${event}] ${JSON.stringify(detail)}`
|
|
142
|
+
if (level === 'warn') console.warn(line)
|
|
143
|
+
else console.log(line)
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
if (result.reason !== 'disabled') {
|
|
148
|
+
stats.attempts += 1
|
|
149
|
+
if (result.ok) stats.delivered += 1
|
|
150
|
+
else stats.fallbacks[result.reason] = (stats.fallbacks[result.reason] ?? 0) + 1
|
|
151
|
+
stats.lastReason = result.reason
|
|
152
|
+
stats.lastAt = Date.now()
|
|
153
|
+
}
|
|
154
|
+
return result
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// Delivering a queued turn INTO the running Claude session, instead of only into
|
|
2
|
+
// its transcript (6.49.0).
|
|
3
|
+
//
|
|
4
|
+
// THE PROBLEM THIS SOLVES. A Continue from the glasses reaches the session by
|
|
5
|
+
// spawning `claude -p --resume <id>`, which appends the turn and its reply to the
|
|
6
|
+
// session file and exits. That is correct and it is durable, but the window Miles
|
|
7
|
+
// left open -- a Desktop tab, or a `claude` running in iTerm -- read that file when
|
|
8
|
+
// it opened and never reads it again, so the turn he sent from the lens is invisible
|
|
9
|
+
// at the desk until he resumes the session. Two of his Continues on 2026-09-15 landed
|
|
10
|
+
// exactly that way: delivered, answered, and not painted anywhere he was looking.
|
|
11
|
+
//
|
|
12
|
+
// WHAT CLAUDE CODE ACTUALLY OFFERS. Every live session publishes an inbox in its
|
|
13
|
+
// registry record (`~/.claude/sessions/<pid>.json`): `messagingSocketPath`,
|
|
14
|
+
// `peerProtocol`, `peerFeatures`. The wire format is not reverse-engineered here --
|
|
15
|
+
// Claude Code documents it in its own help text, which is where these two lines come
|
|
16
|
+
// from verbatim:
|
|
17
|
+
//
|
|
18
|
+
// { echo '{"type":"auth","token":"'"$CLAUDE_CODE_MESSAGING_TOKEN"'"}';
|
|
19
|
+
// echo '{"type":"user","message":{"role":"user","content":"hello"}}'; } | socat - UNIX-CONNECT:<sock>
|
|
20
|
+
//
|
|
21
|
+
// Line-delimited JSON, one connection per message. Measured on this Mac
|
|
22
|
+
// (2026-09-16 13:10, throwaway session `ec2e55b0` in a scratch cwd): the message
|
|
23
|
+
// landed as a real user turn, the idle session woke, answered in about three
|
|
24
|
+
// seconds, and the terminal it was running in painted the whole exchange. That is
|
|
25
|
+
// the continuity this module is for.
|
|
26
|
+
//
|
|
27
|
+
// ONE MECHANISM, BOTH SURFACES. The registry census that afternoon showed ten live
|
|
28
|
+
// records -- `claude-desktop`, `cli` and `sdk-cli` -- carrying the SAME fields and
|
|
29
|
+
// the same `peerProtocol: 1`. Claude Code does not distinguish a Desktop tab from a
|
|
30
|
+
// terminal for messaging, so neither does this file. There is no Desktop branch and
|
|
31
|
+
// no iTerm branch to drift apart.
|
|
32
|
+
//
|
|
33
|
+
// AUTH IS OPTIONAL, AND THAT IS A REASON FOR THE PIN, NOT AGAINST IT. A canary with
|
|
34
|
+
// a deliberately wrong token still delivered; Claude Code says so itself ("peers will
|
|
35
|
+
// send unauthenticated (accepted: auth is optional on this platform)"). So the token
|
|
36
|
+
// is sent when the key file is readable -- forward-compatible if that ever tightens --
|
|
37
|
+
// and its absence is not a failure. What actually protects the delivery is the
|
|
38
|
+
// `session_id` pin: the receiver drops any frame whose `session_id` is not its own
|
|
39
|
+
// (`[uds-messaging] Dropping ... message: session_id mismatch`). Pids are recycled by
|
|
40
|
+
// the OS and a socket path is just a pid; the pin is what stops a turn meant for a
|
|
41
|
+
// dead session landing in whatever now owns that number. Every frame carries it, and
|
|
42
|
+
// selection matches the FULL session id -- never the 8-char prefix the wire uses
|
|
43
|
+
// elsewhere, because a prefix is exactly the ambiguity the pin exists to remove.
|
|
44
|
+
//
|
|
45
|
+
// A SEND IS NOT A DELIVERY. Writing bytes into a socket proves nothing: the receiver
|
|
46
|
+
// may drop the frame on the pin, hold it behind its own permission policy, or be
|
|
47
|
+
// shutting down. So this module reports `delivered` only when the session's own
|
|
48
|
+
// transcript shows it accepted the message. Claude Code writes a `queue-operation`
|
|
49
|
+
// row with `operation: 'enqueue'` and the verbatim content the instant it accepts one
|
|
50
|
+
// -- before the turn runs -- so acceptance is provable in milliseconds even when the
|
|
51
|
+
// session is mid-turn and will not answer for minutes. Anything else is `unverified`,
|
|
52
|
+
// and unverified falls back to the spawn path that works today.
|
|
53
|
+
//
|
|
54
|
+
// FAILS TOWARD THE PATH THAT ALREADY WORKS. Every refusal in this file -- flag off,
|
|
55
|
+
// no record, no socket, unknown protocol, connect error, write error, no acceptance
|
|
56
|
+
// row -- returns `ok: false` with a reason, and the caller spawns the resume child
|
|
57
|
+
// exactly as 6.48.2 does. This module can only ever make delivery BETTER; it has no
|
|
58
|
+
// branch that can make it worse.
|
|
59
|
+
|
|
60
|
+
import { connect } from 'node:net'
|
|
61
|
+
|
|
62
|
+
/** The only protocol this file knows how to speak. Anything else falls back. */
|
|
63
|
+
export const PEER_INBOX_PROTOCOL = 1
|
|
64
|
+
|
|
65
|
+
/** How long to wait for the connection itself. A live socket accepts immediately;
|
|
66
|
+
* a stale path fails with ENOENT/ECONNREFUSED in microseconds. */
|
|
67
|
+
export const PEER_CONNECT_TIMEOUT_MS = 1_500
|
|
68
|
+
|
|
69
|
+
/** How long to wait for the session's transcript to show acceptance.
|
|
70
|
+
*
|
|
71
|
+
* SIX SECONDS IS THE ACCEPTANCE WINDOW, NOT THE REPLY WINDOW. The enqueue row is
|
|
72
|
+
* written when the message is taken, not when the turn finishes, and the measured
|
|
73
|
+
* latency for that row was under a second on an idle session. Six leaves room for a
|
|
74
|
+
* session busy enough to be slow at its own bookkeeping without leaving the queue
|
|
75
|
+
* blocked on a socket that is never going to answer. */
|
|
76
|
+
export const PEER_VERIFY_TIMEOUT_MS = 6_000
|
|
77
|
+
|
|
78
|
+
/** Transcript poll interval while waiting for acceptance. */
|
|
79
|
+
export const PEER_VERIFY_POLL_MS = 250
|
|
80
|
+
|
|
81
|
+
/** Rows older than this before the send cannot be evidence of it. Covers clock skew
|
|
82
|
+
* between the transcript's ISO stamps and our own clock. */
|
|
83
|
+
export const PEER_ACCEPTANCE_BACKDATE_MS = 2_000
|
|
84
|
+
|
|
85
|
+
/** Enough of the prompt to identify the row without depending on the whole of it
|
|
86
|
+
* surviving verbatim (a long paste may be elided in some writers). */
|
|
87
|
+
export const PEER_MARKER_CHARS = 120
|
|
88
|
+
|
|
89
|
+
/** One live session as the registry describes it. */
|
|
90
|
+
export interface PeerInboxRecord {
|
|
91
|
+
pid: number
|
|
92
|
+
/** FULL session id. A prefix is never enough here; see the header. */
|
|
93
|
+
sessionId: string
|
|
94
|
+
socketPath: string | null
|
|
95
|
+
peerProtocol: number | null
|
|
96
|
+
entrypoint: string | null
|
|
97
|
+
status: string | null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type PeerDeliveryReason =
|
|
101
|
+
| 'delivered'
|
|
102
|
+
| 'disabled'
|
|
103
|
+
| 'not_claude'
|
|
104
|
+
| 'no_record'
|
|
105
|
+
| 'no_socket'
|
|
106
|
+
| 'protocol_unsupported'
|
|
107
|
+
| 'connect_failed'
|
|
108
|
+
| 'write_failed'
|
|
109
|
+
| 'unverified'
|
|
110
|
+
| 'suspect_expired'
|
|
111
|
+
|
|
112
|
+
/** How the transcript proved it. `enqueue` is the fast path; `user_row` covers a
|
|
113
|
+
* session that admitted the turn straight into a running turn. */
|
|
114
|
+
export type PeerAcceptanceKind = 'enqueue' | 'user_row'
|
|
115
|
+
|
|
116
|
+
export interface PeerDeliveryResult {
|
|
117
|
+
ok: boolean
|
|
118
|
+
reason: PeerDeliveryReason
|
|
119
|
+
/** Null unless `ok`. */
|
|
120
|
+
verifiedBy: PeerAcceptanceKind | null
|
|
121
|
+
/** Wall clock from first connect attempt to verdict, for the health row. */
|
|
122
|
+
elapsedMs: number
|
|
123
|
+
/** The pid the frame was addressed to, for the audit line. Null when none was chosen. */
|
|
124
|
+
pid: number | null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Which live record, if any, should receive this turn.
|
|
129
|
+
*
|
|
130
|
+
* FULL-ID EQUALITY, AND THE NON-COS RECORD WINS. A COS-spawned resume child
|
|
131
|
+
* registers under the SAME session id as the tab it resumed (6.48.1 found this the
|
|
132
|
+
* hard way), so an id can name two live processes: the user's window and a child of
|
|
133
|
+
* ours. Delivering into our own short-lived child would paint nothing and race the
|
|
134
|
+
* child's own exit, so a record whose pid the ownership store claims is skipped
|
|
135
|
+
* whenever another record is available -- and skipped entirely rather than preferred,
|
|
136
|
+
* because a turn delivered to a process that is about to exit is worse than one that
|
|
137
|
+
* falls back to a fresh child.
|
|
138
|
+
*/
|
|
139
|
+
export function selectPeerInbox(
|
|
140
|
+
sessionId: string,
|
|
141
|
+
records: readonly PeerInboxRecord[],
|
|
142
|
+
isCosOwnedPid: (pid: number) => boolean = () => false,
|
|
143
|
+
): PeerInboxRecord | null {
|
|
144
|
+
const id = String(sessionId || '').trim()
|
|
145
|
+
if (id.length === 0) return null
|
|
146
|
+
const matches = records.filter(r =>
|
|
147
|
+
r.sessionId === id
|
|
148
|
+
&& typeof r.socketPath === 'string'
|
|
149
|
+
&& r.socketPath.length > 0
|
|
150
|
+
&& Number.isInteger(r.pid)
|
|
151
|
+
&& r.pid > 0)
|
|
152
|
+
if (matches.length === 0) return null
|
|
153
|
+
const theirs = matches.filter(r => !isCosOwnedPid(r.pid))
|
|
154
|
+
const pool = theirs.length > 0 ? theirs : []
|
|
155
|
+
if (pool.length === 0) return null
|
|
156
|
+
// Newest record wins when the user somehow has two windows on one session: the
|
|
157
|
+
// higher pid is the one started most recently, which is the one they are looking at.
|
|
158
|
+
const chosen = [...pool].sort((a, b) => b.pid - a.pid)[0]
|
|
159
|
+
return chosen ?? null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The two lines that go on the wire, in order.
|
|
164
|
+
*
|
|
165
|
+
* The auth line is omitted entirely when no token was readable, rather than sent
|
|
166
|
+
* empty: an empty token is a failed auth attempt, and a missing one is the
|
|
167
|
+
* unauthenticated path Claude Code documents as acceptable.
|
|
168
|
+
*/
|
|
169
|
+
export function peerInboxFrames(sessionId: string, prompt: string, token: string | null): string[] {
|
|
170
|
+
const frames: string[] = []
|
|
171
|
+
if (typeof token === 'string' && token.length > 0) {
|
|
172
|
+
frames.push(JSON.stringify({ type: 'auth', token }))
|
|
173
|
+
}
|
|
174
|
+
frames.push(JSON.stringify({
|
|
175
|
+
type: 'user',
|
|
176
|
+
session_id: sessionId,
|
|
177
|
+
message: { role: 'user', content: prompt },
|
|
178
|
+
}))
|
|
179
|
+
return frames
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The identifying slice of the prompt, as it will appear in the transcript row. */
|
|
183
|
+
export function peerAcceptanceMarker(prompt: string): string {
|
|
184
|
+
return String(prompt ?? '').slice(0, PEER_MARKER_CHARS)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A send whose acceptance was never seen.
|
|
189
|
+
*
|
|
190
|
+
* NEVER RESENT, NEVER SPAWNED OVER, until it expires. A frame that was written but not
|
|
191
|
+
* verified is in one of four places: dropped by the receiver, held behind its
|
|
192
|
+
* permission policy, lost with a dying process, or accepted with the transcript row
|
|
193
|
+
* still on its way. Three of those must not get a second copy, and the fourth turns
|
|
194
|
+
* into `delivered` on the next look. So the caller refuses the turn as retryable, and
|
|
195
|
+
* the retry comes back HERE first: the transcript is checked from the ORIGINAL send
|
|
196
|
+
* time (a late row counts), nothing is written to the socket again, and only once the
|
|
197
|
+
* memo is older than `SUSPECT_TTL_MS` does the turn go to the spawn path -- the one
|
|
198
|
+
* place a duplicate remains possible, and it is named in the health row when it does.
|
|
199
|
+
*/
|
|
200
|
+
export const SUSPECT_TTL_MS = 60_000
|
|
201
|
+
|
|
202
|
+
interface SuspectSend { marker: string; sentAtMs: number }
|
|
203
|
+
const suspects = new Map<string, SuspectSend>()
|
|
204
|
+
|
|
205
|
+
function suspectKey(sessionId: string, marker: string): string {
|
|
206
|
+
return `${sessionId}\u0000${marker}`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Test seam. */
|
|
210
|
+
export function __resetPeerInboxSuspects(): void {
|
|
211
|
+
suspects.clear()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** One transcript row, reduced to what acceptance needs. */
|
|
215
|
+
export interface TranscriptRowLike {
|
|
216
|
+
type?: unknown
|
|
217
|
+
operation?: unknown
|
|
218
|
+
content?: unknown
|
|
219
|
+
message?: unknown
|
|
220
|
+
timestamp?: unknown
|
|
221
|
+
sessionId?: unknown
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function rowText(row: TranscriptRowLike): string {
|
|
225
|
+
if (typeof row.content === 'string') return row.content
|
|
226
|
+
const message = row.message
|
|
227
|
+
if (message && typeof message === 'object') {
|
|
228
|
+
const content = (message as { content?: unknown }).content
|
|
229
|
+
if (typeof content === 'string') return content
|
|
230
|
+
if (Array.isArray(content)) {
|
|
231
|
+
return content
|
|
232
|
+
.map(part => (part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string')
|
|
233
|
+
? (part as { text: string }).text
|
|
234
|
+
: '')
|
|
235
|
+
.join(' ')
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return ''
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function rowTimeMs(row: TranscriptRowLike): number | null {
|
|
242
|
+
if (typeof row.timestamp !== 'string') return null
|
|
243
|
+
const ms = Date.parse(row.timestamp)
|
|
244
|
+
return Number.isFinite(ms) ? ms : null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Did these rows accept our message?
|
|
249
|
+
*
|
|
250
|
+
* PURE, so the contract can drive it over recorded rows. An `enqueue` row is the
|
|
251
|
+
* proof Claude Code writes the moment it takes a message; a `user` row carrying the
|
|
252
|
+
* same text is the same proof one step later. Rows that predate the send cannot be
|
|
253
|
+
* evidence of it -- without that clause a retry of the same prompt would "verify"
|
|
254
|
+
* against the previous delivery's own row and report a send that never happened.
|
|
255
|
+
*/
|
|
256
|
+
export function peerAcceptanceIn(
|
|
257
|
+
rows: readonly TranscriptRowLike[],
|
|
258
|
+
marker: string,
|
|
259
|
+
sentAtMs: number,
|
|
260
|
+
): PeerAcceptanceKind | null {
|
|
261
|
+
if (!marker) return null
|
|
262
|
+
const floor = sentAtMs - PEER_ACCEPTANCE_BACKDATE_MS
|
|
263
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
264
|
+
const row = rows[i]
|
|
265
|
+
const at = rowTimeMs(row)
|
|
266
|
+
if (at !== null && at < floor) continue
|
|
267
|
+
if (!rowText(row).includes(marker)) continue
|
|
268
|
+
if (row.type === 'queue-operation' && row.operation === 'enqueue') return 'enqueue'
|
|
269
|
+
if (row.type === 'user') return 'user_row'
|
|
270
|
+
}
|
|
271
|
+
return null
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface PeerInboxDeps {
|
|
275
|
+
/** Live registry records. The caller owns the read so this file stays pure of paths. */
|
|
276
|
+
records: () => readonly PeerInboxRecord[] | Promise<readonly PeerInboxRecord[]>
|
|
277
|
+
/** The session's peer token, or null when unreadable. Absence is not a failure. */
|
|
278
|
+
token: (record: PeerInboxRecord) => string | null | Promise<string | null>
|
|
279
|
+
/** The tail of the session's transcript, newest last. */
|
|
280
|
+
transcriptTail: (sessionId: string) => readonly TranscriptRowLike[] | Promise<readonly TranscriptRowLike[]>
|
|
281
|
+
/** Pids COS itself spawned, so a resume child never receives the turn meant for the window. */
|
|
282
|
+
isCosOwnedPid?: (pid: number) => boolean
|
|
283
|
+
now?: () => number
|
|
284
|
+
sleep?: (ms: number) => Promise<void>
|
|
285
|
+
/** Test seam. The real one opens the unix socket. */
|
|
286
|
+
send?: (socketPath: string, frames: readonly string[]) => Promise<void>
|
|
287
|
+
log?: (level: 'info' | 'warn', event: string, detail: Record<string, unknown>) => void
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface PeerInboxRequest {
|
|
291
|
+
provider: string
|
|
292
|
+
sessionId: string
|
|
293
|
+
prompt: string
|
|
294
|
+
enabled: boolean
|
|
295
|
+
verifyTimeoutMs?: number
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function defaultSleep(ms: number): Promise<void> {
|
|
299
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Open the socket, write the frames, close. Rejects on anything that is not a clean write. */
|
|
303
|
+
export function sendOverPeerSocket(socketPath: string, frames: readonly string[]): Promise<void> {
|
|
304
|
+
return new Promise((resolve, reject) => {
|
|
305
|
+
let settled = false
|
|
306
|
+
const finish = (error?: Error): void => {
|
|
307
|
+
if (settled) return
|
|
308
|
+
settled = true
|
|
309
|
+
try { socket.destroy() } catch { /* already gone */ }
|
|
310
|
+
if (error) reject(error)
|
|
311
|
+
else resolve()
|
|
312
|
+
}
|
|
313
|
+
const socket = connect(socketPath)
|
|
314
|
+
const timer = setTimeout(() => finish(new Error('connect_timeout')), PEER_CONNECT_TIMEOUT_MS)
|
|
315
|
+
timer.unref?.()
|
|
316
|
+
socket.on('error', error => { clearTimeout(timer); finish(error instanceof Error ? error : new Error('socket_error')) })
|
|
317
|
+
socket.on('connect', () => {
|
|
318
|
+
clearTimeout(timer)
|
|
319
|
+
try {
|
|
320
|
+
// One write, so a half-written pair cannot leave an auth line without its
|
|
321
|
+
// message on a connection the receiver closes for silence.
|
|
322
|
+
socket.write(frames.map(line => `${line}\n`).join(''), error => {
|
|
323
|
+
if (error) { finish(error); return }
|
|
324
|
+
// The receiver reads to end-of-line and acts; nothing is written back on
|
|
325
|
+
// this protocol, so the send is complete once the bytes are flushed.
|
|
326
|
+
socket.end()
|
|
327
|
+
finish()
|
|
328
|
+
})
|
|
329
|
+
} catch (error) {
|
|
330
|
+
finish(error instanceof Error ? error : new Error('write_failed'))
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Try to put this turn into the running session. Never throws.
|
|
338
|
+
*
|
|
339
|
+
* The caller treats `ok: false` as "use the spawn path", which is what 6.48.2 does
|
|
340
|
+
* today for every turn, so a false here costs nothing that was not already the cost.
|
|
341
|
+
*/
|
|
342
|
+
export async function deliverOverPeerInbox(
|
|
343
|
+
request: PeerInboxRequest,
|
|
344
|
+
deps: PeerInboxDeps,
|
|
345
|
+
): Promise<PeerDeliveryResult> {
|
|
346
|
+
const now = deps.now ?? (() => Date.now())
|
|
347
|
+
const sleep = deps.sleep ?? defaultSleep
|
|
348
|
+
const send = deps.send ?? sendOverPeerSocket
|
|
349
|
+
const log = deps.log ?? (() => {})
|
|
350
|
+
const startedAt = now()
|
|
351
|
+
const done = (ok: boolean, reason: PeerDeliveryReason, verifiedBy: PeerAcceptanceKind | null, pid: number | null): PeerDeliveryResult => {
|
|
352
|
+
const result: PeerDeliveryResult = { ok, reason, verifiedBy, elapsedMs: Math.max(0, now() - startedAt), pid }
|
|
353
|
+
log(ok ? 'info' : 'warn', 'peer_inbox.delivery', { ...result, session: request.sessionId.slice(0, 8) })
|
|
354
|
+
return result
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!request.enabled) return done(false, 'disabled', null, null)
|
|
358
|
+
// Codex and Cursor sessions have no Claude Code inbox. They are not a failure here,
|
|
359
|
+
// they are simply not this transport's business.
|
|
360
|
+
if (request.provider !== 'claude') return done(false, 'not_claude', null, null)
|
|
361
|
+
if (typeof request.prompt !== 'string' || request.prompt.trim().length === 0) return done(false, 'no_record', null, null)
|
|
362
|
+
|
|
363
|
+
let records: readonly PeerInboxRecord[]
|
|
364
|
+
try {
|
|
365
|
+
records = await deps.records()
|
|
366
|
+
} catch {
|
|
367
|
+
return done(false, 'no_record', null, null)
|
|
368
|
+
}
|
|
369
|
+
const target = selectPeerInbox(request.sessionId, records, deps.isCosOwnedPid)
|
|
370
|
+
if (!target) return done(false, 'no_record', null, null)
|
|
371
|
+
if (!target.socketPath) return done(false, 'no_socket', null, target.pid)
|
|
372
|
+
// An unknown protocol is refused rather than attempted: the frames below are the
|
|
373
|
+
// ones protocol 1 documents, and guessing at a successor is how a turn gets eaten.
|
|
374
|
+
if (target.peerProtocol !== null && target.peerProtocol !== PEER_INBOX_PROTOCOL) {
|
|
375
|
+
return done(false, 'protocol_unsupported', null, target.pid)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const marker = peerAcceptanceMarker(request.prompt)
|
|
379
|
+
const key = suspectKey(request.sessionId, marker)
|
|
380
|
+
|
|
381
|
+
// A retry of a send that was never verified: look before writing anything.
|
|
382
|
+
const suspect = suspects.get(key)
|
|
383
|
+
if (suspect) {
|
|
384
|
+
let rows: readonly TranscriptRowLike[] = []
|
|
385
|
+
try { rows = await deps.transcriptTail(request.sessionId) } catch { rows = [] }
|
|
386
|
+
const seen = peerAcceptanceIn(rows, marker, suspect.sentAtMs)
|
|
387
|
+
if (seen) {
|
|
388
|
+
suspects.delete(key)
|
|
389
|
+
return done(true, 'delivered', seen, target.pid)
|
|
390
|
+
}
|
|
391
|
+
if (now() - suspect.sentAtMs < SUSPECT_TTL_MS) return done(false, 'unverified', null, target.pid)
|
|
392
|
+
suspects.delete(key)
|
|
393
|
+
return done(false, 'suspect_expired', null, target.pid)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let token: string | null = null
|
|
397
|
+
try { token = await deps.token(target) } catch { token = null }
|
|
398
|
+
|
|
399
|
+
const sentAt = now()
|
|
400
|
+
try {
|
|
401
|
+
await send(target.socketPath, peerInboxFrames(target.sessionId, request.prompt, token))
|
|
402
|
+
} catch (error) {
|
|
403
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
|
404
|
+
// ENOENT and ECONNREFUSED are the stale-socket shapes: the pid is gone and the
|
|
405
|
+
// file outlived it. Both are ordinary, nothing was written, and both mean "spawn
|
|
406
|
+
// the child". Anything else may have written a partial frame, so it is a suspect.
|
|
407
|
+
if (code === 'ENOENT' || code === 'ECONNREFUSED') return done(false, 'connect_failed', null, target.pid)
|
|
408
|
+
suspects.set(key, { marker, sentAtMs: sentAt })
|
|
409
|
+
return done(false, 'write_failed', null, target.pid)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const deadline = sentAt + Math.max(0, request.verifyTimeoutMs ?? PEER_VERIFY_TIMEOUT_MS)
|
|
413
|
+
for (;;) {
|
|
414
|
+
let rows: readonly TranscriptRowLike[] = []
|
|
415
|
+
try { rows = await deps.transcriptTail(request.sessionId) } catch { rows = [] }
|
|
416
|
+
const seen = peerAcceptanceIn(rows, marker, sentAt)
|
|
417
|
+
if (seen) return done(true, 'delivered', seen, target.pid)
|
|
418
|
+
if (now() >= deadline) break
|
|
419
|
+
await sleep(PEER_VERIFY_POLL_MS)
|
|
420
|
+
}
|
|
421
|
+
suspects.set(key, { marker, sentAtMs: sentAt })
|
|
422
|
+
return done(false, 'unverified', null, target.pid)
|
|
423
|
+
}
|
|
@@ -34,9 +34,29 @@ export type SessionStreamDraft =
|
|
|
34
34
|
// predates this renders exactly as it did before rather than breaking.
|
|
35
35
|
| { kind: 'prompt'; text: string }
|
|
36
36
|
| { kind: 'prose'; text: string }
|
|
37
|
-
|
|
37
|
+
// 6.49.0: `tool_outcome` rides on a status draft, never on a new kind. A shipped
|
|
38
|
+
// client drops an unknown kind BEFORE seq accounting and paints a gap row for it
|
|
39
|
+
// (the B2 finding); the same client takes a repeated `status: working` as a no-op
|
|
40
|
+
// (`event.state !== status` is false, nothing appended, no step counted). So the
|
|
41
|
+
// result of a tool arrives on the one channel every client already survives.
|
|
42
|
+
| { kind: 'status'; state: SessionStreamState; tool_outcome?: ToolOutcome }
|
|
38
43
|
| { kind: 'heartbeat' }
|
|
39
44
|
|
|
45
|
+
/**
|
|
46
|
+
* What a finished tool produced, in the words a lens line has room for.
|
|
47
|
+
*
|
|
48
|
+
* `ok` says whether the tool itself reported failure; `detail` is the token the
|
|
49
|
+
* client shows at the end of the step line (`+14 -2`, `41 passed`, `17 hits`,
|
|
50
|
+
* `exit 1`). Derived from the transcript's own `toolUseResult`, which is shape-keyed
|
|
51
|
+
* (a Read result carries `file.numLines`, a Bash result `stdout`), so no tool name
|
|
52
|
+
* and no hook is needed: the same row that announces the call carries its answer a
|
|
53
|
+
* few lines later, in order. Nothing here is prompt text or file content.
|
|
54
|
+
*/
|
|
55
|
+
export interface ToolOutcome {
|
|
56
|
+
ok: boolean
|
|
57
|
+
detail: string
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
/** A draft plus the transport's stamps. This is the JSON on the wire. */
|
|
41
61
|
export type SessionStreamEvent = SessionStreamDraft & { seq: number; at: number }
|
|
42
62
|
|
|
@@ -243,6 +263,103 @@ export function detailForTool(name: unknown, input: unknown): string {
|
|
|
243
263
|
return ''
|
|
244
264
|
}
|
|
245
265
|
|
|
266
|
+
/**
|
|
267
|
+
* The outcome token for one tool result, from the transcript row that carries it.
|
|
268
|
+
*
|
|
269
|
+
* SHAPE-KEYED, because the user row that holds a result names no tool: only the
|
|
270
|
+
* `tool_use_id` it answers. The structured `toolUseResult` is distinctive enough per
|
|
271
|
+
* tool (measured across this Mac's transcripts, 2026-09-16) that the shape is the
|
|
272
|
+
* name. Every branch yields a token from a COUNT or a STATUS, never from content:
|
|
273
|
+
* the client has 40 characters for it and the ledger keeps no output.
|
|
274
|
+
*
|
|
275
|
+
* Nothing worth saying yields `ok` with an empty detail, which the client renders
|
|
276
|
+
* as nothing. A result the model marked `is_error` is `ok: false` with the first
|
|
277
|
+
* words of the error, since `exit 1` or `not found` is the whole point of looking.
|
|
278
|
+
*/
|
|
279
|
+
export function outcomeForToolResult(
|
|
280
|
+
isError: boolean,
|
|
281
|
+
resultText: string,
|
|
282
|
+
structured: unknown,
|
|
283
|
+
): ToolOutcome {
|
|
284
|
+
const text = typeof resultText === 'string' ? resultText : ''
|
|
285
|
+
if (isError) {
|
|
286
|
+
const exit = /^\s*Exit code (\d+)/.exec(text)
|
|
287
|
+
if (exit) return { ok: false, detail: `exit ${exit[1]}` }
|
|
288
|
+
const tag = /<tool_use_error>\s*([^<\n]{1,40})/.exec(text)
|
|
289
|
+
if (tag) return { ok: false, detail: oneLine(tag[1], DETAIL_MAX_CHARS) }
|
|
290
|
+
const rejected = /rejected|doesn't want to proceed|denied/i.test(text)
|
|
291
|
+
return { ok: false, detail: rejected ? 'denied' : oneLine(text, DETAIL_MAX_CHARS) || 'error' }
|
|
292
|
+
}
|
|
293
|
+
const r = asRecord(structured)
|
|
294
|
+
if (r) {
|
|
295
|
+
// Read: `{ type: 'text', file: { numLines, totalLines } }`; an image `{ type: 'image' }`.
|
|
296
|
+
const file = asRecord(r.file)
|
|
297
|
+
if (file && typeof file.numLines === 'number') {
|
|
298
|
+
const total = typeof file.totalLines === 'number' && file.totalLines > file.numLines ? ` of ${file.totalLines}` : ''
|
|
299
|
+
return { ok: true, detail: `${file.numLines}${total} lines` }
|
|
300
|
+
}
|
|
301
|
+
if (r.type === 'image') return { ok: true, detail: 'image' }
|
|
302
|
+
// Write: `{ type: 'create' | 'update', filePath }`.
|
|
303
|
+
if (r.type === 'create') return { ok: true, detail: 'created' }
|
|
304
|
+
if (r.type === 'update' && typeof r.filePath === 'string') return { ok: true, detail: 'written' }
|
|
305
|
+
// Edit: `{ oldString, newString, structuredPatch }` and no `type`.
|
|
306
|
+
if (Array.isArray(r.structuredPatch) && typeof r.oldString === 'string') {
|
|
307
|
+
const removed = countLines(r.oldString)
|
|
308
|
+
const added = countLines(r.newString)
|
|
309
|
+
return { ok: true, detail: removed === 0 && added === 0 ? 'edited' : `+${added} -${removed}` }
|
|
310
|
+
}
|
|
311
|
+
// Bash: `{ stdout, stderr, interrupted }`.
|
|
312
|
+
if (typeof r.stdout === 'string' && typeof r.stderr === 'string') {
|
|
313
|
+
if (r.interrupted === true) return { ok: false, detail: 'interrupted' }
|
|
314
|
+
const out = countLines(r.stdout)
|
|
315
|
+
const err = countLines(r.stderr)
|
|
316
|
+
if (out === 0 && err === 0) return { ok: true, detail: 'no output' }
|
|
317
|
+
if (out === 0) return { ok: true, detail: `stderr ${err} line${err === 1 ? '' : 's'}` }
|
|
318
|
+
return { ok: true, detail: `${out} line${out === 1 ? '' : 's'}` }
|
|
319
|
+
}
|
|
320
|
+
// Grep / Glob: counts when the tool gives them.
|
|
321
|
+
if (typeof r.numFiles === 'number') return { ok: true, detail: `${r.numFiles} file${r.numFiles === 1 ? '' : 's'}` }
|
|
322
|
+
if (typeof r.numMatches === 'number') return { ok: true, detail: `${r.numMatches} hit${r.numMatches === 1 ? '' : 's'}` }
|
|
323
|
+
if (Array.isArray(r.filenames)) return { ok: true, detail: `${r.filenames.length} file${r.filenames.length === 1 ? '' : 's'}` }
|
|
324
|
+
// WebFetch: `{ code, bytes, durationMs }`. WebSearch: `{ searchCount, results }`.
|
|
325
|
+
if (typeof r.code === 'number' && typeof r.bytes === 'number') {
|
|
326
|
+
return { ok: r.code < 400, detail: `${r.code} · ${Math.max(1, Math.round(r.bytes / 1024))} KB` }
|
|
327
|
+
}
|
|
328
|
+
if (Array.isArray(r.results) && typeof r.searchCount === 'number') {
|
|
329
|
+
return { ok: true, detail: `${r.results.length} result${r.results.length === 1 ? '' : 's'}` }
|
|
330
|
+
}
|
|
331
|
+
// Agent / teammate spawn.
|
|
332
|
+
if (r.status === 'teammate_spawned' || typeof r.agent_id === 'string') return { ok: true, detail: 'spawned' }
|
|
333
|
+
}
|
|
334
|
+
// Grep's plain-text answer, when the structured form did not carry the count.
|
|
335
|
+
const found = /Found (\d+) (files?|matches?)/i.exec(text)
|
|
336
|
+
if (found) return { ok: true, detail: `${found[1]} ${found[2].startsWith('file') ? 'file' : 'hit'}${found[1] === '1' ? '' : 's'}` }
|
|
337
|
+
if (/^No (files|matches) found/i.test(text)) return { ok: true, detail: 'none' }
|
|
338
|
+
return { ok: true, detail: '' }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The result blocks of a user record, paired with the row's structured result. */
|
|
342
|
+
export function outcomeDrafts(record: Record<string, unknown>, message: Record<string, unknown>): SessionStreamDraft[] {
|
|
343
|
+
const content = message.content
|
|
344
|
+
if (!Array.isArray(content)) return []
|
|
345
|
+
const out: SessionStreamDraft[] = []
|
|
346
|
+
for (const raw of content) {
|
|
347
|
+
const block = asRecord(raw)
|
|
348
|
+
if (!block || block.type !== 'tool_result') continue
|
|
349
|
+
const inner = block.content
|
|
350
|
+
const text = typeof inner === 'string'
|
|
351
|
+
? inner
|
|
352
|
+
: Array.isArray(inner)
|
|
353
|
+
? inner.map(part => (asRecord(part)?.text as string | undefined) ?? '').join(' ')
|
|
354
|
+
: ''
|
|
355
|
+
const outcome = outcomeForToolResult(block.is_error === true, text, record.toolUseResult)
|
|
356
|
+
// A tool result means the turn is working by definition: the model called a
|
|
357
|
+
// tool and is about to read what came back.
|
|
358
|
+
out.push({ kind: 'status', state: 'working', tool_outcome: outcome })
|
|
359
|
+
}
|
|
360
|
+
return out
|
|
361
|
+
}
|
|
362
|
+
|
|
246
363
|
function toolDraft(name: unknown, input: unknown): SessionStreamDraft {
|
|
247
364
|
const verb = verbForToolName(name)
|
|
248
365
|
const target = targetForTool(name, input)
|
|
@@ -351,10 +468,14 @@ function draftsFromClaudeRecord(record: Record<string, unknown>): SessionStreamD
|
|
|
351
468
|
// the glasses have never seen it. Dropping it is what made the live view a blank
|
|
352
469
|
// slate that said WORKING and nothing else.
|
|
353
470
|
//
|
|
354
|
-
//
|
|
471
|
+
// A tool result is not a prompt, but since 6.49.0 it is not dropped either: it
|
|
472
|
+
// becomes the OUTCOME of the call announced a few rows earlier, on a status draft
|
|
473
|
+
// (see `ToolOutcome`). The prompt path still sees nothing for it.
|
|
355
474
|
if (type === 'user') {
|
|
356
475
|
const message = asRecord(record.message)
|
|
357
|
-
|
|
476
|
+
if (!message) return []
|
|
477
|
+
const outcomes = outcomeDrafts(record, message)
|
|
478
|
+
return outcomes.length > 0 ? outcomes : promptDrafts(message)
|
|
358
479
|
}
|
|
359
480
|
|
|
360
481
|
const role = typeof record.role === 'string' ? record.role : ''
|
|
@@ -577,10 +698,10 @@ export interface DerivedStatusFields {
|
|
|
577
698
|
last_reply?: string
|
|
578
699
|
}
|
|
579
700
|
|
|
580
|
-
export type StatusDraftWithDerived = { kind: 'status'; state: SessionStreamState } & Partial<DerivedStatusFields>
|
|
701
|
+
export type StatusDraftWithDerived = { kind: 'status'; state: SessionStreamState; tool_outcome?: ToolOutcome } & Partial<DerivedStatusFields>
|
|
581
702
|
|
|
582
703
|
export function statusDraftWithDerived(
|
|
583
|
-
draft: { kind: 'status'; state: SessionStreamState },
|
|
704
|
+
draft: { kind: 'status'; state: SessionStreamState; tool_outcome?: ToolOutcome },
|
|
584
705
|
derived: DerivedStatusFields | undefined,
|
|
585
706
|
): StatusDraftWithDerived {
|
|
586
707
|
if (!derived) return draft
|
|
@@ -591,6 +712,8 @@ export function statusDraftWithDerived(
|
|
|
591
712
|
return {
|
|
592
713
|
kind: 'status',
|
|
593
714
|
state,
|
|
715
|
+
// 6.49.0: the outcome is the draft's own fact, not the deriver's; it rides through.
|
|
716
|
+
...(draft.tool_outcome ? { tool_outcome: draft.tool_outcome } : {}),
|
|
594
717
|
agent_state: derived.agent_state,
|
|
595
718
|
state_source: derived.state_source,
|
|
596
719
|
state_since: derived.state_since,
|
|
@@ -153,12 +153,22 @@ export function transcriptTurnEnded(provider: SessionStreamProvider, path: strin
|
|
|
153
153
|
|
|
154
154
|
/** The bounded tail as lines, with the same two open flags; null when it cannot be read. */
|
|
155
155
|
function readTail(path: string | null): string[] | null {
|
|
156
|
+
return readTranscriptTailLines(path, TURN_END_TAIL_BYTES)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The last `maxBytes` of a transcript as lines, null when unreadable. Exported (6.49.0)
|
|
161
|
+
* so the live-delivery verifier reads the transcript the same way the drain does: the
|
|
162
|
+
* same open flags, the same fragment rule. Two readers with different rules would
|
|
163
|
+
* disagree about the same file on the day it mattered.
|
|
164
|
+
*/
|
|
165
|
+
export function readTranscriptTailLines(path: string | null, maxBytes: number): string[] | null {
|
|
156
166
|
if (!path || !existsSync(path)) return null
|
|
157
167
|
try {
|
|
158
168
|
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
159
169
|
try {
|
|
160
170
|
const size = fstatSync(fd).size
|
|
161
|
-
const start = Math.max(0, size -
|
|
171
|
+
const start = Math.max(0, size - maxBytes)
|
|
162
172
|
const buf = Buffer.alloc(size - start)
|
|
163
173
|
readSync(fd, buf, 0, buf.length, start)
|
|
164
174
|
const lines = buf.toString('utf-8').split('\n')
|
|
@@ -333,6 +333,16 @@ export interface AgentSessionBindingsDeps {
|
|
|
333
333
|
/** `deliverAttachedTurn` from `server/lib/attached-provider-adapter.ts`. */
|
|
334
334
|
deliverAttachedTurn?: (request: AttachedTurnRequest) => Promise<unknown> | unknown
|
|
335
335
|
|
|
336
|
+
/**
|
|
337
|
+
* 6.49.0: put the turn INTO the running session before spawning a child for it.
|
|
338
|
+
*
|
|
339
|
+
* `makeLiveTurnDeliverer` from `server/lib/session-peer-inbox-deps.ts`. Optional so
|
|
340
|
+
* every existing test and every older wiring is byte-for-byte the 6.48.2 route; when
|
|
341
|
+
* absent, or when it answers anything but `ok`, the spawn path below runs unchanged.
|
|
342
|
+
* Only `reason` is read here; the vocabulary is the pure module's.
|
|
343
|
+
*/
|
|
344
|
+
deliverLiveTurn?: (request: { provider: string; sessionId: string; prompt: string; verifyTimeoutMs?: number }) => Promise<{ ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null }>
|
|
345
|
+
|
|
336
346
|
/**
|
|
337
347
|
* The self-recursion ledger. Defaults to the real process-wide one.
|
|
338
348
|
*
|
|
@@ -483,6 +493,10 @@ export type WriteRefusal =
|
|
|
483
493
|
| 'provider_never_opened'
|
|
484
494
|
| 'delivery_ambiguous'
|
|
485
495
|
| 'turn_failed'
|
|
496
|
+
// 6.49.0: the live transport wrote a frame it could not see accepted. The turn is
|
|
497
|
+
// held as retryable and the retry re-reads before doing anything (see
|
|
498
|
+
// `session-peer-inbox.ts`); it is never a fence, because nothing here ran a child.
|
|
499
|
+
| 'live_unverified'
|
|
486
500
|
// ------------------------------------------------------------------- fork
|
|
487
501
|
//
|
|
488
502
|
// Fork gets its OWN members rather than reusing the ones above, and the reason
|
|
@@ -555,6 +569,8 @@ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, s
|
|
|
555
569
|
'The assistant never opened the thread, so nothing was sent. You can try again.',
|
|
556
570
|
delivery_ambiguous:
|
|
557
571
|
'COS lost track of this turn after sending it. Open the thread on your Mac and check before sending again.',
|
|
572
|
+
live_unverified:
|
|
573
|
+
'Sent to the open session, but not yet confirmed. COS will check again in a moment.',
|
|
558
574
|
turn_failed:
|
|
559
575
|
'COS could not run this turn. Nothing was sent. You can try again.',
|
|
560
576
|
|
|
@@ -1265,6 +1281,18 @@ function plainBody(req: Request): Record<string, unknown> | null {
|
|
|
1265
1281
|
|
|
1266
1282
|
export const ATTACHED_COPY = 'Attached. COS is driving the original thread.'
|
|
1267
1283
|
export const TURN_SENT_COPY = 'Sent to the original thread.'
|
|
1284
|
+
/** 6.49.0: the turn went into the running session, where the desk will see it. */
|
|
1285
|
+
export const TURN_SENT_LIVE_COPY = 'Sent to the open session. It will show where you left it.'
|
|
1286
|
+
/**
|
|
1287
|
+
* How long the route waits for the session's transcript to show acceptance.
|
|
1288
|
+
*
|
|
1289
|
+
* FOUR SECONDS, INSIDE THE CLIENT'S TEN. The phone's `fetchJSON` aborts at 10 s; the
|
|
1290
|
+
* gate ahead of this point costs about a second, the connect up to 1.5 s, so four
|
|
1291
|
+
* leaves margin. Measured acceptance on an idle session was under one second, so the
|
|
1292
|
+
* budget only bites when the receiver is holding or dying, and those end as
|
|
1293
|
+
* `live_unverified`, which the client may retry.
|
|
1294
|
+
*/
|
|
1295
|
+
export const LIVE_VERIFY_BUDGET_MS = 4_000
|
|
1268
1296
|
|
|
1269
1297
|
/**
|
|
1270
1298
|
* The 202 copy. Says QUEUED and not sent, because at this instant the provider has
|
|
@@ -2125,6 +2153,56 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2125
2153
|
if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
|
|
2126
2154
|
pinnedBindingId = bindingId
|
|
2127
2155
|
|
|
2156
|
+
// 6.49.0: LIVE FIRST. If the session's own process is running -- a Desktop tab,
|
|
2157
|
+
// a `claude` in iTerm, the same registry record either way -- the turn goes into
|
|
2158
|
+
// it and paints where the user left the window, instead of into a resume child
|
|
2159
|
+
// whose reply that window never shows. Before the 202 on purpose: a live delivery
|
|
2160
|
+
// is a `completed` turn (terminal, ledgered, replay-safe), and a frame that could
|
|
2161
|
+
// not be verified is a PRE-202 refusal, which the ledger does not record, so the
|
|
2162
|
+
// client's retry re-evaluates rather than replaying a stale "no". The gate above
|
|
2163
|
+
// still ran in full: this is the same admission, with a different last hop.
|
|
2164
|
+
//
|
|
2165
|
+
// The head is NOT acknowledged here. The session will move it when it answers,
|
|
2166
|
+
// on its own clock; the next interactive turn reads that as a change and asks,
|
|
2167
|
+
// which is the conservative side. Queued turns attach fresh and never notice.
|
|
2168
|
+
if (typeof deps.deliverLiveTurn === 'function' && binding.provider === 'claude') {
|
|
2169
|
+
let live: { ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null } | null = null
|
|
2170
|
+
try {
|
|
2171
|
+
live = await deps.deliverLiveTurn({
|
|
2172
|
+
provider: binding.provider,
|
|
2173
|
+
sessionId: binding.nativeThreadId,
|
|
2174
|
+
prompt,
|
|
2175
|
+
verifyTimeoutMs: LIVE_VERIFY_BUDGET_MS,
|
|
2176
|
+
})
|
|
2177
|
+
} catch (error) {
|
|
2178
|
+
// A throw is a bug in the transport, not a fact about the session; the
|
|
2179
|
+
// spawn path is the answer to both.
|
|
2180
|
+
console.error(`[agent-session-bindings] live delivery threw: ${error instanceof Error ? error.message : error}`)
|
|
2181
|
+
live = null
|
|
2182
|
+
}
|
|
2183
|
+
if (live?.ok) {
|
|
2184
|
+
console.log(`[agent-session-bindings] turn delivered live provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} pid=${live.pid ?? 'null'}`)
|
|
2185
|
+
respond(200, {
|
|
2186
|
+
turnId,
|
|
2187
|
+
outcome: 'completed',
|
|
2188
|
+
deliveryState: 'delivered',
|
|
2189
|
+
via: 'live',
|
|
2190
|
+
retryable: false,
|
|
2191
|
+
changed: false,
|
|
2192
|
+
revision: null,
|
|
2193
|
+
reason: null,
|
|
2194
|
+
reasonCopy: TURN_SENT_LIVE_COPY,
|
|
2195
|
+
})
|
|
2196
|
+
return
|
|
2197
|
+
}
|
|
2198
|
+
if (live && (live.reason === 'unverified' || live.reason === 'write_failed')) {
|
|
2199
|
+
// Something may be in the session. Hold, never spawn over it.
|
|
2200
|
+
return refuseTurn('live_unverified', { retryable: true, deliveryState: 'unknown' })
|
|
2201
|
+
}
|
|
2202
|
+
// disabled / not_claude / no_record / no_socket / protocol_unsupported /
|
|
2203
|
+
// connect_failed / suspect_expired: nothing reached a session. Spawn as always.
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2128
2206
|
// THE QUEUE POINT. Every gate is now behind us — body, replay, queued-prompt,
|
|
2129
2207
|
// lease, target, epoch, fence, claim, occupancy, head baseline, pin — so a
|
|
2130
2208
|
// refusal still reaches the user immediately and precisely. Only the spawn is
|
package/server/routes/health.ts
CHANGED
|
@@ -80,6 +80,29 @@ import {
|
|
|
80
80
|
threadAttachHealthFields,
|
|
81
81
|
} from '../lib/thread-attach-capability.js'
|
|
82
82
|
import { sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
|
|
83
|
+
import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 6.49.0: which last hop a Continue takes, and how often each fell back.
|
|
87
|
+
*
|
|
88
|
+
* `enabled` is the flag as read RIGHT NOW; the counters are this process's. A
|
|
89
|
+
* reader who sees `enabled: true` and `delivered: 0` with fallbacks under
|
|
90
|
+
* `no_record` has a session registry the server cannot see; under `unverified`,
|
|
91
|
+
* a session that is holding or dying. Both are diagnoses, not guesses.
|
|
92
|
+
*/
|
|
93
|
+
export function continueLiveHealthFields(): Record<string, unknown> {
|
|
94
|
+
const stats = liveDeliveryStats()
|
|
95
|
+
return {
|
|
96
|
+
continueLive: {
|
|
97
|
+
enabled: continueLiveEnabled(),
|
|
98
|
+
attempts: stats.attempts,
|
|
99
|
+
delivered: stats.delivered,
|
|
100
|
+
fallbacks: stats.fallbacks,
|
|
101
|
+
lastReason: stats.lastReason,
|
|
102
|
+
lastAt: stats.lastAt,
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
83
106
|
|
|
84
107
|
export const healthRouter = Router()
|
|
85
108
|
|
|
@@ -351,6 +374,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
351
374
|
server_version: managedServerVersion(),
|
|
352
375
|
...threadAttachHealthFields(threadAttach),
|
|
353
376
|
...sessionHooksHealthFields(),
|
|
377
|
+
...continueLiveHealthFields(),
|
|
354
378
|
server_instance_id: getServerInstanceId(),
|
|
355
379
|
boot_id: serverMetrics.bootId,
|
|
356
380
|
generation_id: getServerGenerationId(),
|