@gotcos/glasses-server 6.48.2 → 6.49.1
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 +27 -0
- package/README.md +25 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/index.ts +14 -5
- package/server/lib/agent-session-binding-registry.ts +78 -4
- package/server/lib/agent-session-store.ts +10 -3
- package/server/lib/session-peer-inbox-deps.ts +188 -0
- package/server/lib/session-peer-inbox.ts +498 -0
- package/server/lib/session-stream-events.ts +216 -9
- package/server/lib/thread-turn-queue-store.ts +11 -1
- package/server/routes/agent-session-bindings.ts +119 -1
- package/server/routes/agent-session-stream.ts +4 -2
- package/server/routes/health.ts +24 -0
|
@@ -0,0 +1,498 @@
|
|
|
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
|
+
* THE ACCEPTANCE WINDOW, NOT THE REPLY WINDOW. The enqueue row is written when the
|
|
72
|
+
* message is taken, not when the turn finishes, and the measured latency for that
|
|
73
|
+
* row was under a second on an idle session (257 ms on the first device delivery).
|
|
74
|
+
* Four seconds leaves room for a session busy at its own bookkeeping and still
|
|
75
|
+
* sits inside the phone's 10 s request budget with the gate, the head read and a
|
|
76
|
+
* 1.5 s connect in front of it. THE ONE SOURCE: the turn route passes this same
|
|
77
|
+
* constant (6.49.1; it used to carry its own 4 s while this file defaulted to 6 s,
|
|
78
|
+
* and a QA mutation deleting the route's field went unnoticed). */
|
|
79
|
+
export const PEER_VERIFY_TIMEOUT_MS = 4_000
|
|
80
|
+
|
|
81
|
+
/** Transcript poll interval while waiting for acceptance. */
|
|
82
|
+
export const PEER_VERIFY_POLL_MS = 250
|
|
83
|
+
|
|
84
|
+
/** Rows older than this before the send cannot be evidence of it.
|
|
85
|
+
*
|
|
86
|
+
* The transcript's ISO stamps and our clock are the SAME clock (one Mac, one
|
|
87
|
+
* kernel), so this is not skew cover. It is the width of the window in which a
|
|
88
|
+
* prompt typed at the desk with the same first 120 characters would be mistaken for
|
|
89
|
+
* ours, traded against a row stamped a beat before our `now()` by a writer that
|
|
90
|
+
* timestamps before it appends. Two seconds keeps the second and makes the first
|
|
91
|
+
* need a same-words prompt inside two seconds of the lens send. */
|
|
92
|
+
export const PEER_ACCEPTANCE_BACKDATE_MS = 2_000
|
|
93
|
+
|
|
94
|
+
/** Enough of the prompt to identify the row without depending on the whole of it
|
|
95
|
+
* surviving verbatim (a long paste may be elided in some writers). */
|
|
96
|
+
export const PEER_MARKER_CHARS = 120
|
|
97
|
+
|
|
98
|
+
/** Bytes read PAST the transcript's size at send time when looking for acceptance.
|
|
99
|
+
*
|
|
100
|
+
* The acceptance rows are small and land within a second of the send, but the rows
|
|
101
|
+
* written AFTER them are not: a single tool result can be over a megabyte (18 rows
|
|
102
|
+
* above 256 KiB in one real session, max 1.1 MB), and a fixed tail read would lose
|
|
103
|
+
* the evidence behind one of them on the memo's re-check. So the caller notes the
|
|
104
|
+
* file size before the send and every read covers from there to the end, plus this
|
|
105
|
+
* slack for a row already mid-write at the moment of the size read. */
|
|
106
|
+
export const PEER_ACCEPTANCE_READ_SLACK_BYTES = 64 * 1024
|
|
107
|
+
|
|
108
|
+
/** One live session as the registry describes it. */
|
|
109
|
+
export interface PeerInboxRecord {
|
|
110
|
+
pid: number
|
|
111
|
+
/** FULL session id. A prefix is never enough here; see the header. */
|
|
112
|
+
sessionId: string
|
|
113
|
+
socketPath: string | null
|
|
114
|
+
peerProtocol: number | null
|
|
115
|
+
entrypoint: string | null
|
|
116
|
+
status: string | null
|
|
117
|
+
/** Epoch ms the process started (registry `startedAt`), for the newest-window rule (6.49.1). */
|
|
118
|
+
startedAt?: number | null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type PeerDeliveryReason =
|
|
122
|
+
| 'delivered'
|
|
123
|
+
| 'disabled'
|
|
124
|
+
| 'not_claude'
|
|
125
|
+
| 'no_record'
|
|
126
|
+
| 'protocol_unsupported'
|
|
127
|
+
| 'connect_failed'
|
|
128
|
+
| 'write_failed'
|
|
129
|
+
| 'unverified'
|
|
130
|
+
| 'suspect_expired'
|
|
131
|
+
|
|
132
|
+
/** How the transcript proved it. `enqueue` is the fast path; `user_row` covers a
|
|
133
|
+
* session that admitted the turn straight into a running turn. */
|
|
134
|
+
export type PeerAcceptanceKind = 'enqueue' | 'user_row'
|
|
135
|
+
|
|
136
|
+
export interface PeerDeliveryResult {
|
|
137
|
+
ok: boolean
|
|
138
|
+
reason: PeerDeliveryReason
|
|
139
|
+
/** Null unless `ok`. */
|
|
140
|
+
verifiedBy: PeerAcceptanceKind | null
|
|
141
|
+
/** Wall clock from first connect attempt to verdict, for the health row. */
|
|
142
|
+
elapsedMs: number
|
|
143
|
+
/** The pid the frame was addressed to, for the audit line. Null when none was chosen. */
|
|
144
|
+
pid: number | null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Which live record, if any, should receive this turn.
|
|
149
|
+
*
|
|
150
|
+
* FULL-ID EQUALITY, AND THE NON-COS RECORD WINS. A COS-spawned resume child
|
|
151
|
+
* registers under the SAME session id as the tab it resumed (6.48.1 found this the
|
|
152
|
+
* hard way), so an id can name two live processes: the user's window and a child of
|
|
153
|
+
* ours. Delivering into our own short-lived child would paint nothing and race the
|
|
154
|
+
* child's own exit, so a record whose pid the ownership store claims is skipped
|
|
155
|
+
* whenever another record is available -- and skipped entirely rather than preferred,
|
|
156
|
+
* because a turn delivered to a process that is about to exit is worse than one that
|
|
157
|
+
* falls back to a fresh child.
|
|
158
|
+
*/
|
|
159
|
+
export function selectPeerInbox(
|
|
160
|
+
sessionId: string,
|
|
161
|
+
records: readonly PeerInboxRecord[],
|
|
162
|
+
isCosOwnedPid: (pid: number) => boolean = () => false,
|
|
163
|
+
): PeerInboxRecord | null {
|
|
164
|
+
const id = String(sessionId || '').trim()
|
|
165
|
+
if (id.length === 0) return null
|
|
166
|
+
const matches = records.filter(r =>
|
|
167
|
+
r.sessionId === id
|
|
168
|
+
&& typeof r.socketPath === 'string'
|
|
169
|
+
&& r.socketPath.length > 0
|
|
170
|
+
&& Number.isInteger(r.pid)
|
|
171
|
+
&& r.pid > 0)
|
|
172
|
+
if (matches.length === 0) return null
|
|
173
|
+
const theirs = matches.filter(r => !isCosOwnedPid(r.pid))
|
|
174
|
+
const pool = theirs.length > 0 ? theirs : []
|
|
175
|
+
if (pool.length === 0) return null
|
|
176
|
+
// Newest record wins when the user somehow has two windows on one session: the one
|
|
177
|
+
// started most recently is the one they are looking at. BY START TIME, not pid
|
|
178
|
+
// (6.49.1): macOS recycles pids, and this Mac's counter wrapped twice in one day
|
|
179
|
+
// (registry on 2026-09-16: 82615 started before 45962 before 10487). A record with
|
|
180
|
+
// no start time sorts last, then by pid as before.
|
|
181
|
+
const chosen = [...pool].sort((a, b) => {
|
|
182
|
+
const sa = typeof a.startedAt === 'number' && Number.isFinite(a.startedAt) ? a.startedAt : -1
|
|
183
|
+
const sb = typeof b.startedAt === 'number' && Number.isFinite(b.startedAt) ? b.startedAt : -1
|
|
184
|
+
return sb !== sa ? sb - sa : b.pid - a.pid
|
|
185
|
+
})[0]
|
|
186
|
+
return chosen ?? null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The two lines that go on the wire, in order.
|
|
191
|
+
*
|
|
192
|
+
* The auth line is omitted entirely when no token was readable, rather than sent
|
|
193
|
+
* empty: an empty token is a failed auth attempt, and a missing one is the
|
|
194
|
+
* unauthenticated path Claude Code documents as acceptable.
|
|
195
|
+
*/
|
|
196
|
+
export function peerInboxFrames(sessionId: string, prompt: string, token: string | null): string[] {
|
|
197
|
+
const frames: string[] = []
|
|
198
|
+
if (typeof token === 'string' && token.length > 0) {
|
|
199
|
+
frames.push(JSON.stringify({ type: 'auth', token }))
|
|
200
|
+
}
|
|
201
|
+
frames.push(JSON.stringify({
|
|
202
|
+
type: 'user',
|
|
203
|
+
session_id: sessionId,
|
|
204
|
+
message: { role: 'user', content: prompt },
|
|
205
|
+
}))
|
|
206
|
+
return frames
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The identifying slice of the prompt, as it will appear in the transcript row. */
|
|
210
|
+
export function peerAcceptanceMarker(prompt: string): string {
|
|
211
|
+
return String(prompt ?? '').slice(0, PEER_MARKER_CHARS)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A send whose acceptance was never seen.
|
|
216
|
+
*
|
|
217
|
+
* NEVER RESENT, NEVER SPAWNED OVER, until it expires. A frame that was written but not
|
|
218
|
+
* verified is in one of four places: dropped by the receiver, held behind its
|
|
219
|
+
* permission policy, lost with a dying process, or accepted with the transcript row
|
|
220
|
+
* still on its way. Three of those must not get a second copy, and the fourth turns
|
|
221
|
+
* into `delivered` on the next look. So the caller refuses the turn as retryable, and
|
|
222
|
+
* when the retry reaches this module (the route's occupancy gate runs first, so a
|
|
223
|
+
* session busy with the turn we sent answers `native_thread_working` until it is
|
|
224
|
+
* done) the transcript is checked from the ORIGINAL send time AND from the file size
|
|
225
|
+
* at the original send (a late row counts, however much was written after it),
|
|
226
|
+
* nothing is written to the socket again, and only once the memo is older than
|
|
227
|
+
* `SUSPECT_TTL_MS` does the turn go to the spawn path -- the one place a duplicate
|
|
228
|
+
* remains possible, and it is named in the health row when it does.
|
|
229
|
+
*/
|
|
230
|
+
export const SUSPECT_TTL_MS = 60_000
|
|
231
|
+
|
|
232
|
+
interface SuspectSend { marker: string; sentAtMs: number; sizeAtSend: number | null }
|
|
233
|
+
const suspects = new Map<string, SuspectSend>()
|
|
234
|
+
|
|
235
|
+
function suspectKey(sessionId: string, marker: string): string {
|
|
236
|
+
return `${sessionId}\u0000${marker}`
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Test seam. */
|
|
240
|
+
export function __resetPeerInboxSuspects(): void {
|
|
241
|
+
suspects.clear()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** One transcript row, reduced to what acceptance needs. */
|
|
245
|
+
export interface TranscriptRowLike {
|
|
246
|
+
type?: unknown
|
|
247
|
+
operation?: unknown
|
|
248
|
+
content?: unknown
|
|
249
|
+
message?: unknown
|
|
250
|
+
timestamp?: unknown
|
|
251
|
+
sessionId?: unknown
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function rowText(row: TranscriptRowLike): string {
|
|
255
|
+
if (typeof row.content === 'string') return row.content
|
|
256
|
+
const message = row.message
|
|
257
|
+
if (message && typeof message === 'object') {
|
|
258
|
+
const content = (message as { content?: unknown }).content
|
|
259
|
+
if (typeof content === 'string') return content
|
|
260
|
+
if (Array.isArray(content)) {
|
|
261
|
+
return content
|
|
262
|
+
.map(part => (part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string')
|
|
263
|
+
? (part as { text: string }).text
|
|
264
|
+
: '')
|
|
265
|
+
.join(' ')
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return ''
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function rowTimeMs(row: TranscriptRowLike): number | null {
|
|
272
|
+
if (typeof row.timestamp !== 'string') return null
|
|
273
|
+
const ms = Date.parse(row.timestamp)
|
|
274
|
+
return Number.isFinite(ms) ? ms : null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Did these rows accept our message?
|
|
279
|
+
*
|
|
280
|
+
* PURE, so the contract can drive it over recorded rows. An `enqueue` row is the
|
|
281
|
+
* proof Claude Code writes the moment it takes a message; a `user` row carrying the
|
|
282
|
+
* same text is the same proof one step later. Rows that predate the send cannot be
|
|
283
|
+
* evidence of it -- without that clause a retry of the same prompt would "verify"
|
|
284
|
+
* against the previous delivery's own row and report a send that never happened.
|
|
285
|
+
*/
|
|
286
|
+
export function peerAcceptanceIn(
|
|
287
|
+
rows: readonly TranscriptRowLike[],
|
|
288
|
+
marker: string,
|
|
289
|
+
sentAtMs: number,
|
|
290
|
+
): PeerAcceptanceKind | null {
|
|
291
|
+
if (!marker) return null
|
|
292
|
+
const floor = sentAtMs - PEER_ACCEPTANCE_BACKDATE_MS
|
|
293
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
294
|
+
const row = rows[i]
|
|
295
|
+
const at = rowTimeMs(row)
|
|
296
|
+
if (at !== null && at < floor) continue
|
|
297
|
+
if (!rowText(row).includes(marker)) continue
|
|
298
|
+
if (row.type === 'queue-operation' && row.operation === 'enqueue') return 'enqueue'
|
|
299
|
+
if (row.type === 'user') return 'user_row'
|
|
300
|
+
}
|
|
301
|
+
return null
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface PeerInboxDeps {
|
|
305
|
+
/** Live registry records. The caller owns the read so this file stays pure of paths. */
|
|
306
|
+
records: () => readonly PeerInboxRecord[] | Promise<readonly PeerInboxRecord[]>
|
|
307
|
+
/** The session's peer token, or null when unreadable. Absence is not a failure. */
|
|
308
|
+
token: (record: PeerInboxRecord) => string | null | Promise<string | null>
|
|
309
|
+
/** The tail of the session's transcript, newest last. `minBytes`, when given, is
|
|
310
|
+
* how far back the read must reach (the bytes appended since the send plus slack);
|
|
311
|
+
* the reader may read more, never less. */
|
|
312
|
+
transcriptTail: (sessionId: string, minBytes?: number) => readonly TranscriptRowLike[] | Promise<readonly TranscriptRowLike[]>
|
|
313
|
+
/** The transcript's size in bytes right now, or null when unknown. Optional: without
|
|
314
|
+
* it every read is the reader's default tail. */
|
|
315
|
+
transcriptSize?: (sessionId: string) => number | null | Promise<number | null>
|
|
316
|
+
/** Pids COS itself spawned, so a resume child never receives the turn meant for the window. */
|
|
317
|
+
isCosOwnedPid?: (pid: number) => boolean
|
|
318
|
+
now?: () => number
|
|
319
|
+
sleep?: (ms: number) => Promise<void>
|
|
320
|
+
/** Test seam. The real one opens the unix socket. */
|
|
321
|
+
send?: (socketPath: string, frames: readonly string[]) => Promise<void>
|
|
322
|
+
log?: (level: 'info' | 'warn', event: string, detail: Record<string, unknown>) => void
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export interface PeerInboxRequest {
|
|
326
|
+
provider: string
|
|
327
|
+
sessionId: string
|
|
328
|
+
prompt: string
|
|
329
|
+
enabled: boolean
|
|
330
|
+
verifyTimeoutMs?: number
|
|
331
|
+
/** The client's idempotency key for this draft (6.49.1). When given, the suspect memo
|
|
332
|
+
* is keyed by it, so two drafts sharing their first 120 characters cannot collide and
|
|
333
|
+
* the same words under a NEW key are a new send, not a retry. The marker is still
|
|
334
|
+
* what is searched for in the transcript. */
|
|
335
|
+
clientTurnId?: string
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function defaultSleep(ms: number): Promise<void> {
|
|
339
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Where a send failed. Before `connect` fired nothing was written, whatever the code. */
|
|
343
|
+
export type PeerSendPhase = 'connect' | 'write'
|
|
344
|
+
|
|
345
|
+
/** A rejection from `sendOverPeerSocket`, tagged with the phase it failed in. */
|
|
346
|
+
export interface PeerSendError extends Error {
|
|
347
|
+
phase: PeerSendPhase
|
|
348
|
+
code?: string
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function tagPhase(error: Error, phase: PeerSendPhase): PeerSendError {
|
|
352
|
+
const tagged = error as PeerSendError
|
|
353
|
+
tagged.phase = phase
|
|
354
|
+
return tagged
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Open the socket, write the frames, close. Rejects on anything that is not a clean
|
|
358
|
+
* write, and every rejection carries `phase`: `connect` means no byte left this
|
|
359
|
+
* process (a missing path, a refused or hung socket, a file that is not a socket, a
|
|
360
|
+
* permission refusal), `write` means some may have. */
|
|
361
|
+
export function sendOverPeerSocket(socketPath: string, frames: readonly string[]): Promise<void> {
|
|
362
|
+
return new Promise((resolve, reject) => {
|
|
363
|
+
let settled = false
|
|
364
|
+
let phase: PeerSendPhase = 'connect'
|
|
365
|
+
const finish = (error?: Error): void => {
|
|
366
|
+
if (settled) return
|
|
367
|
+
settled = true
|
|
368
|
+
try { socket.destroy() } catch { /* already gone */ }
|
|
369
|
+
if (error) reject(tagPhase(error, phase))
|
|
370
|
+
else resolve()
|
|
371
|
+
}
|
|
372
|
+
const socket = connect(socketPath)
|
|
373
|
+
const timer = setTimeout(() => finish(new Error('connect_timeout')), PEER_CONNECT_TIMEOUT_MS)
|
|
374
|
+
timer.unref?.()
|
|
375
|
+
socket.on('error', error => { clearTimeout(timer); finish(error instanceof Error ? error : new Error('socket_error')) })
|
|
376
|
+
socket.on('connect', () => {
|
|
377
|
+
clearTimeout(timer)
|
|
378
|
+
phase = 'write'
|
|
379
|
+
try {
|
|
380
|
+
// One write, so a half-written pair cannot leave an auth line without its
|
|
381
|
+
// message on a connection the receiver closes for silence.
|
|
382
|
+
socket.write(frames.map(line => `${line}\n`).join(''), error => {
|
|
383
|
+
if (error) { finish(error); return }
|
|
384
|
+
// The receiver reads to end-of-line and acts; nothing is written back on
|
|
385
|
+
// this protocol, so the send is complete once the bytes are flushed.
|
|
386
|
+
socket.end()
|
|
387
|
+
finish()
|
|
388
|
+
})
|
|
389
|
+
} catch (error) {
|
|
390
|
+
finish(error instanceof Error ? error : new Error('write_failed'))
|
|
391
|
+
}
|
|
392
|
+
})
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Try to put this turn into the running session. Never throws.
|
|
398
|
+
*
|
|
399
|
+
* The caller treats `ok: false` as "use the spawn path", which is what 6.48.2 does
|
|
400
|
+
* today for every turn, so a false here costs nothing that was not already the cost.
|
|
401
|
+
*/
|
|
402
|
+
export async function deliverOverPeerInbox(
|
|
403
|
+
request: PeerInboxRequest,
|
|
404
|
+
deps: PeerInboxDeps,
|
|
405
|
+
): Promise<PeerDeliveryResult> {
|
|
406
|
+
const now = deps.now ?? (() => Date.now())
|
|
407
|
+
const sleep = deps.sleep ?? defaultSleep
|
|
408
|
+
const send = deps.send ?? sendOverPeerSocket
|
|
409
|
+
const log = deps.log ?? (() => {})
|
|
410
|
+
const startedAt = now()
|
|
411
|
+
const done = (ok: boolean, reason: PeerDeliveryReason, verifiedBy: PeerAcceptanceKind | null, pid: number | null): PeerDeliveryResult => {
|
|
412
|
+
const result: PeerDeliveryResult = { ok, reason, verifiedBy, elapsedMs: Math.max(0, now() - startedAt), pid }
|
|
413
|
+
log(ok ? 'info' : 'warn', 'peer_inbox.delivery', { ...result, session: request.sessionId.slice(0, 8) })
|
|
414
|
+
return result
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!request.enabled) return done(false, 'disabled', null, null)
|
|
418
|
+
// Codex and Cursor sessions have no Claude Code inbox. They are not a failure here,
|
|
419
|
+
// they are simply not this transport's business.
|
|
420
|
+
if (request.provider !== 'claude') return done(false, 'not_claude', null, null)
|
|
421
|
+
if (typeof request.prompt !== 'string' || request.prompt.trim().length === 0) return done(false, 'no_record', null, null)
|
|
422
|
+
|
|
423
|
+
let records: readonly PeerInboxRecord[]
|
|
424
|
+
try {
|
|
425
|
+
records = await deps.records()
|
|
426
|
+
} catch {
|
|
427
|
+
return done(false, 'no_record', null, null)
|
|
428
|
+
}
|
|
429
|
+
const target = selectPeerInbox(request.sessionId, records, deps.isCosOwnedPid)
|
|
430
|
+
if (!target) return done(false, 'no_record', null, null)
|
|
431
|
+
// An unknown protocol is refused rather than attempted: the frames below are the
|
|
432
|
+
// ones protocol 1 documents, and guessing at a successor is how a turn gets eaten.
|
|
433
|
+
if (target.peerProtocol !== null && target.peerProtocol !== PEER_INBOX_PROTOCOL) {
|
|
434
|
+
return done(false, 'protocol_unsupported', null, target.pid)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const marker = peerAcceptanceMarker(request.prompt)
|
|
438
|
+
const key = suspectKey(request.sessionId, request.clientTurnId ? `id:${request.clientTurnId}` : marker)
|
|
439
|
+
|
|
440
|
+
// A retry of a send that was never verified: look before writing anything.
|
|
441
|
+
const suspect = suspects.get(key)
|
|
442
|
+
if (suspect) {
|
|
443
|
+
const rows = await readSince(request.sessionId, suspect.sizeAtSend)
|
|
444
|
+
const seen = peerAcceptanceIn(rows, marker, suspect.sentAtMs)
|
|
445
|
+
if (seen) {
|
|
446
|
+
suspects.delete(key)
|
|
447
|
+
return done(true, 'delivered', seen, target.pid)
|
|
448
|
+
}
|
|
449
|
+
if (now() - suspect.sentAtMs < SUSPECT_TTL_MS) return done(false, 'unverified', null, target.pid)
|
|
450
|
+
suspects.delete(key)
|
|
451
|
+
return done(false, 'suspect_expired', null, target.pid)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
let token: string | null = null
|
|
455
|
+
try { token = await deps.token(target) } catch { token = null }
|
|
456
|
+
|
|
457
|
+
// The file size BEFORE the frame goes out, so every later read can start there.
|
|
458
|
+
let sizeAtSend: number | null = null
|
|
459
|
+
try { sizeAtSend = (await deps.transcriptSize?.(request.sessionId)) ?? null } catch { sizeAtSend = null }
|
|
460
|
+
const sentAt = now()
|
|
461
|
+
try {
|
|
462
|
+
await send(target.socketPath!, peerInboxFrames(target.sessionId, request.prompt, token))
|
|
463
|
+
} catch (error) {
|
|
464
|
+
const phase = (error as Partial<PeerSendError> | undefined)?.phase
|
|
465
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
|
466
|
+
// Before `connect` fired nothing was written: a stale path (ENOENT), a dead
|
|
467
|
+
// listener (ECONNREFUSED), a hung one (connect_timeout), a file that is not a
|
|
468
|
+
// socket, a permission refusal. All ordinary, all mean "spawn the child" now. A
|
|
469
|
+
// failure AFTER connect may have written a partial frame, so it is a suspect.
|
|
470
|
+
// (The code check stays for a `send` seam that does not tag the phase.)
|
|
471
|
+
if (phase === 'connect' || code === 'ENOENT' || code === 'ECONNREFUSED') return done(false, 'connect_failed', null, target.pid)
|
|
472
|
+
suspects.set(key, { marker, sentAtMs: sentAt, sizeAtSend })
|
|
473
|
+
return done(false, 'write_failed', null, target.pid)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const deadline = sentAt + Math.max(0, request.verifyTimeoutMs ?? PEER_VERIFY_TIMEOUT_MS)
|
|
477
|
+
for (;;) {
|
|
478
|
+
const rows = await readSince(request.sessionId, sizeAtSend)
|
|
479
|
+
const seen = peerAcceptanceIn(rows, marker, sentAt)
|
|
480
|
+
if (seen) return done(true, 'delivered', seen, target.pid)
|
|
481
|
+
if (now() >= deadline) break
|
|
482
|
+
await sleep(PEER_VERIFY_POLL_MS)
|
|
483
|
+
}
|
|
484
|
+
suspects.set(key, { marker, sentAtMs: sentAt, sizeAtSend })
|
|
485
|
+
return done(false, 'unverified', null, target.pid)
|
|
486
|
+
|
|
487
|
+
/** The rows appended since the send (plus the reader's own default tail). Never throws. */
|
|
488
|
+
async function readSince(sessionId: string, since: number | null): Promise<readonly TranscriptRowLike[]> {
|
|
489
|
+
let minBytes: number | undefined
|
|
490
|
+
if (since !== null) {
|
|
491
|
+
try {
|
|
492
|
+
const size = (await deps.transcriptSize?.(sessionId)) ?? null
|
|
493
|
+
if (size !== null && size >= since) minBytes = size - since + PEER_ACCEPTANCE_READ_SLACK_BYTES
|
|
494
|
+
} catch { minBytes = undefined }
|
|
495
|
+
}
|
|
496
|
+
try { return await deps.transcriptTail(sessionId, minBytes) } catch { return [] }
|
|
497
|
+
}
|
|
498
|
+
}
|