@gotcos/glasses-server 6.48.0 → 6.48.2
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 +22 -0
- package/README.md +15 -4
- package/package.json +1 -1
- package/server/index.ts +70 -7
- package/server/lib/attached-workspace.ts +53 -18
- package/server/lib/claude-session-registry.ts +19 -0
- package/server/lib/occupancy-probes.ts +43 -0
- package/server/lib/session-hook-events.ts +5 -0
- package/server/lib/session-hook-ledger.ts +7 -3
- package/server/lib/session-hook-spool.ts +11 -3
- package/server/lib/session-hooks-runtime.ts +114 -5
- package/server/lib/session-signal-store.ts +33 -5
- package/server/lib/session-state-derive.ts +38 -0
- package/server/lib/session-stream-bus.ts +93 -9
- package/server/lib/session-stream-events.ts +144 -0
- package/server/lib/thread-drain-kick.ts +153 -0
- package/server/lib/thread-occupancy.ts +36 -1
- package/server/lib/thread-turn-queue-deliver.ts +17 -6
- package/server/lib/thread-turn-queue-store.ts +70 -23
- package/server/lib/thread-turn-queue.ts +19 -3
- package/server/routes/agent-session-stream.ts +169 -11
- package/server/routes/agent-sessions.ts +34 -5
- package/server/routes/claude-sessions.ts +11 -17
- package/server/routes/session-hooks.ts +10 -0
- package/server/routes/thread-turn-queue.ts +15 -0
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
// Update Server (main.swift `providerEnvironmentKeys`).
|
|
8
8
|
|
|
9
9
|
import { execFileSync } from 'node:child_process'
|
|
10
|
-
import { mkdirSync } from 'node:fs'
|
|
10
|
+
import { mkdirSync, readFileSync, readdirSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
11
12
|
import { cosSpawnedPids } from './agent-session-ownership-store.js'
|
|
13
|
+
import { REGISTRY_FILENAME, claudeSessionsDir } from './claude-session-registry.js'
|
|
14
|
+
import { MAX_REGISTRY_FILES } from './thread-occupancy.js'
|
|
12
15
|
import { dataPath } from './data-dir.js'
|
|
13
16
|
import { SessionHookLedger } from './session-hook-ledger.js'
|
|
14
17
|
import { startSpoolIngester, type SpoolIngester, type SpoolStats } from './session-hook-spool.js'
|
|
@@ -78,6 +81,87 @@ export function isCosSpawnedPid(pid: number | null, nowMs = Date.now()): boolean
|
|
|
78
81
|
|
|
79
82
|
export const sessionSignalStore = new SessionSignalStore({ isCosSpawnedPid })
|
|
80
83
|
|
|
84
|
+
// 6.48.1: which kind of process a session is, read off its registry record while it is alive.
|
|
85
|
+
// `claude -p` registers as `entrypoint: sdk-cli`; a Desktop tab as `claude-desktop`; a terminal
|
|
86
|
+
// as `cli`. The record may land a beat after the SessionStart hook, so the read is retried on
|
|
87
|
+
// the session's next few events (a synchronous, bounded registry scan each) and then given
|
|
88
|
+
// up. Once known it rides the LEDGER ROW (QA, 2026-09-15): a replay after a restart restores
|
|
89
|
+
// it, so a tab that ended inside the replay window is not listed as a print run for want of
|
|
90
|
+
// a registry record that the reaper has since removed.
|
|
91
|
+
const ENTRYPOINT_ATTEMPTS = 6
|
|
92
|
+
const entrypointAttempts = new Map<string, number>()
|
|
93
|
+
|
|
94
|
+
/** The entrypoint to write on this event's ledger row: the store's, else one registry read while attempts remain. */
|
|
95
|
+
function entrypointFor(sessionId: string, child: boolean): string | null {
|
|
96
|
+
const known = sessionSignalStore.get(sessionId)?.entrypoint ?? null
|
|
97
|
+
if (known || child) return known
|
|
98
|
+
const tried = entrypointAttempts.get(sessionId) ?? 0
|
|
99
|
+
if (tried >= ENTRYPOINT_ATTEMPTS) return null
|
|
100
|
+
entrypointAttempts.set(sessionId, tried + 1)
|
|
101
|
+
if (entrypointAttempts.size > 512) entrypointAttempts.clear()
|
|
102
|
+
return registryEntrypointSync(sessionId)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One synchronous pass over `~/.claude/sessions/<pid>.json`; null when no record names the session. */
|
|
106
|
+
export function registryEntrypointSync(sessionId: string, dir = claudeSessionsDir()): string | null {
|
|
107
|
+
return registryRecordSync(sessionId, dir)?.entrypoint ?? null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface RegistryRecordFacts {
|
|
111
|
+
pid: number | null
|
|
112
|
+
entrypoint: string | null
|
|
113
|
+
status: string | null
|
|
114
|
+
statusUpdatedAt: number | null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The registry record naming a session, read synchronously and bounded; null when none
|
|
119
|
+
* does. A COS Continue child (`claude -p --resume <id>`) registers under the SAME session
|
|
120
|
+
* id as the tab it resumes, so when two records name the session the one whose pid COS
|
|
121
|
+
* did not spawn is the tab's and wins; the child's is used only when it is alone.
|
|
122
|
+
*/
|
|
123
|
+
export function registryRecordSync(sessionId: string, dir = claudeSessionsDir()): RegistryRecordFacts | null {
|
|
124
|
+
let names: string[]
|
|
125
|
+
try { names = readdirSync(dir).filter(n => REGISTRY_FILENAME.test(n)).slice(0, MAX_REGISTRY_FILES) } catch { return null }
|
|
126
|
+
const wanted = sessionId.toLowerCase()
|
|
127
|
+
let childRecord: RegistryRecordFacts | null = null
|
|
128
|
+
for (const name of names) {
|
|
129
|
+
try {
|
|
130
|
+
const raw = JSON.parse(readFileSync(join(dir, name), 'utf-8')) as Record<string, unknown>
|
|
131
|
+
if (typeof raw.sessionId !== 'string' || raw.sessionId.toLowerCase() !== wanted) continue
|
|
132
|
+
const record: RegistryRecordFacts = {
|
|
133
|
+
pid: typeof raw.pid === 'number' ? raw.pid : null,
|
|
134
|
+
entrypoint: typeof raw.entrypoint === 'string' && raw.entrypoint ? raw.entrypoint : null,
|
|
135
|
+
status: typeof raw.status === 'string' ? raw.status : null,
|
|
136
|
+
statusUpdatedAt: typeof raw.statusUpdatedAt === 'number' ? raw.statusUpdatedAt : null,
|
|
137
|
+
}
|
|
138
|
+
if (isCosSpawnedPid(record.pid)) { childRecord ??= record; continue }
|
|
139
|
+
return record
|
|
140
|
+
} catch { /* a torn write; the next read retries */ }
|
|
141
|
+
}
|
|
142
|
+
return childRecord
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* THE ENGINE'S OWN END OF TURN (6.48.1, QA round). Claude Code does not end a turn at
|
|
147
|
+
* the `end_turn` row or at the Stop hook: it ends it when every Stop hook has RETURNED,
|
|
148
|
+
* and only then writes `stop_hook_summary`, dequeues anything typed meanwhile, and flips
|
|
149
|
+
* the registry to `idle`. On the release Mac the COS repo's own Stop hooks run 12-38 s
|
|
150
|
+
* (`transcript_summary_hook.py`), so "Stop + 2 s" was 13-38 s early: a prompt typed at
|
|
151
|
+
* the desk in that window is QUEUED and dequeues after the hooks, and a follow-up
|
|
152
|
+
* delivered at Stop + 2 s would have been a second writer. The registry's `idle` with
|
|
153
|
+
* `statusUpdatedAt` at or after the Stop is the only signal that says the hooks are done
|
|
154
|
+
* (measured: it flips 4-39 ms after the summary row).
|
|
155
|
+
*
|
|
156
|
+
* True only when a live record names the session AND says idle AND moved at or after
|
|
157
|
+
* `stopAt`. No record (a print run that already exited) is answered by the caller.
|
|
158
|
+
*/
|
|
159
|
+
export function registryIdleAfterStop(sessionId: string, stopAt: number, dir = claudeSessionsDir()): boolean | null {
|
|
160
|
+
const record = registryRecordSync(sessionId, dir)
|
|
161
|
+
if (!record) return null
|
|
162
|
+
return record.status === 'idle' && typeof record.statusUpdatedAt === 'number' && record.statusUpdatedAt >= stopAt
|
|
163
|
+
}
|
|
164
|
+
|
|
81
165
|
let ledger: SessionHookLedger | null = null
|
|
82
166
|
let ingester: SpoolIngester | null = null
|
|
83
167
|
let replayed: { rows: number; applied: number } | null = null
|
|
@@ -96,14 +180,22 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
|
|
|
96
180
|
ledger = new SessionHookLedger(dataPath('session-hook-events.jsonl'))
|
|
97
181
|
const enabled = sessionHooksEnabled()
|
|
98
182
|
const since = Date.now() - LEDGER_REPLAY_WINDOW_MS
|
|
99
|
-
const replay = ledger.replay(since, (env, _key, child) => {
|
|
183
|
+
const replay = ledger.replay(since, (env, _key, child, entrypoint) => {
|
|
184
|
+
if (!enabled) return
|
|
185
|
+
sessionSignalStore.apply(env, child)
|
|
186
|
+
if (entrypoint) sessionSignalStore.setEntrypoint(env.sessionId, entrypoint)
|
|
187
|
+
})
|
|
100
188
|
replayed = { rows: replay.rows, applied: replay.applied }
|
|
101
189
|
ingester = startSpoolIngester({
|
|
102
190
|
dir,
|
|
103
191
|
ledger,
|
|
104
192
|
seenKeys: replay.keys,
|
|
105
193
|
isChild: env => isCosSpawnedPid(env.ppid),
|
|
106
|
-
|
|
194
|
+
entrypointOf: enabled ? (env, child) => entrypointFor(env.sessionId, child) : undefined,
|
|
195
|
+
apply: enabled ? (env, child, entrypoint) => {
|
|
196
|
+
sessionSignalStore.apply(env, child)
|
|
197
|
+
if (entrypoint) sessionSignalStore.setEntrypoint(env.sessionId, entrypoint)
|
|
198
|
+
} : undefined,
|
|
107
199
|
})
|
|
108
200
|
// Runtime files the script reads. The port can change per install; the token never does.
|
|
109
201
|
// The script is copied only when MISSING here: a boot must never downgrade what a newer
|
|
@@ -125,8 +217,17 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
|
|
|
125
217
|
}
|
|
126
218
|
}
|
|
127
219
|
|
|
220
|
+
/** 6.48.1: the drain kick's counters, registered by the composition root when thread attach is on. */
|
|
221
|
+
type DrainKickStats = { passes: number; kicks: number; folded: number; inFlight: boolean; waiting: number }
|
|
222
|
+
let drainKickStats: (() => DrainKickStats) | null = null
|
|
223
|
+
export function registerDrainKickStats(read: () => DrainKickStats): void {
|
|
224
|
+
drainKickStats = read
|
|
225
|
+
}
|
|
226
|
+
|
|
128
227
|
export interface SessionHooksHealth {
|
|
129
228
|
enabled: boolean
|
|
229
|
+
/** The Stop-driven drain (6.48.1): null when thread attach is off. */
|
|
230
|
+
drain: DrainKickStats | null
|
|
130
231
|
/** The install state word; `installed` below is its boolean. */
|
|
131
232
|
state: HookStatus['state']
|
|
132
233
|
installed: boolean
|
|
@@ -169,6 +270,7 @@ export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth }
|
|
|
169
270
|
return {
|
|
170
271
|
sessionHooks: {
|
|
171
272
|
enabled: sessionHooksEnabled(),
|
|
273
|
+
drain: drainKickStats ? drainKickStats() : null,
|
|
172
274
|
state: status.state,
|
|
173
275
|
installed: status.installed,
|
|
174
276
|
scriptSha: status.scriptSha,
|
|
@@ -197,11 +299,11 @@ export function signalFor(sessionId: string): SessionSignal | undefined {
|
|
|
197
299
|
* Derive with the two-scan memory kept here, keyed by full id when known. Rows the
|
|
198
300
|
* caller has no facts for at all get nothing, so an older payload stays byte-identical.
|
|
199
301
|
*/
|
|
200
|
-
export function deriveForRow(input: { sessionId: string; registry?: RegistryFacts; transcript?: TranscriptFacts; now?: number }): DerivedSessionState | undefined {
|
|
302
|
+
export function deriveForRow(input: { sessionId: string; registry?: RegistryFacts; transcript?: TranscriptFacts; now?: number; remember?: boolean; attachedTurn?: boolean }): DerivedSessionState | undefined {
|
|
201
303
|
// Off means off: with the feature disabled every row is byte-identical to 6.47.0.
|
|
202
304
|
if (!sessionHooksEnabled()) return undefined
|
|
203
305
|
const signal = signalFor(input.sessionId)
|
|
204
|
-
if (!signal && !input.registry && !input.transcript) return undefined
|
|
306
|
+
if (!signal && !input.registry && !input.transcript && !input.attachedTurn) return undefined
|
|
205
307
|
const now = input.now ?? Date.now()
|
|
206
308
|
const key = signal?.sessionId ?? input.sessionId.toLowerCase()
|
|
207
309
|
const prev = deadById.get(key)
|
|
@@ -212,7 +314,12 @@ export function deriveForRow(input: { sessionId: string; registry?: RegistryFact
|
|
|
212
314
|
now,
|
|
213
315
|
prevDeadScans: prev?.scans ?? 0,
|
|
214
316
|
prevDeadSince: prev?.since ?? null,
|
|
317
|
+
attachedTurn: input.attachedTurn === true,
|
|
215
318
|
})
|
|
319
|
+
// A reader with no registry facts (the live feed derives from the signal alone) must
|
|
320
|
+
// not touch the two-scan memory the row readers keep: a derive that saw no registry
|
|
321
|
+
// would reset a dead pid's count between two list requests.
|
|
322
|
+
if (input.remember === false || !input.registry) return derived
|
|
216
323
|
if (deadById.size > 2_048) {
|
|
217
324
|
for (const [k, v] of deadById) if (now - v.at > DEAD_MEMORY_MS) deadById.delete(k)
|
|
218
325
|
}
|
|
@@ -225,5 +332,7 @@ export function __resetSessionHooksForTests(): void {
|
|
|
225
332
|
deadById.clear()
|
|
226
333
|
parentCache.clear()
|
|
227
334
|
recentCosPids.clear()
|
|
335
|
+
entrypointAttempts.clear()
|
|
336
|
+
drainKickStats = null
|
|
228
337
|
statusCache = null
|
|
229
338
|
}
|
|
@@ -60,7 +60,7 @@ export interface SessionSignal {
|
|
|
60
60
|
turnOpen: boolean
|
|
61
61
|
turnStartedAt: number | null
|
|
62
62
|
promptId: string | null
|
|
63
|
-
/** Stamped by the newest Stop: `state_since` for an idle row
|
|
63
|
+
/** Stamped by the newest Stop: `state_since` for an idle row, and the instant the B6 occupancy clause and the drain gate compare with the registry's `statusUpdatedAt` (6.48.1). */
|
|
64
64
|
stopAt: number | null
|
|
65
65
|
waiting: WaitingSignal | null
|
|
66
66
|
failure: FailureSignal | null
|
|
@@ -74,6 +74,12 @@ export interface SessionSignal {
|
|
|
74
74
|
compactions: number
|
|
75
75
|
/** Events from a COS-spawned child on this id, kept out of the phase. */
|
|
76
76
|
childEvents: number
|
|
77
|
+
/**
|
|
78
|
+
* The registry's `entrypoint` for this session (`claude-desktop`, `cli`, `sdk-cli`), read
|
|
79
|
+
* from `~/.claude/sessions` while the process is alive; null until seen. A `claude -p` job
|
|
80
|
+
* registers as `sdk-cli` (measured 2026-09-15), which is how `/runs` tells a job from a tab.
|
|
81
|
+
*/
|
|
82
|
+
entrypoint: string | null
|
|
77
83
|
/** Hooks have been seen for this session: the row may say `state_source: hook`. */
|
|
78
84
|
hooksSeen: true
|
|
79
85
|
}
|
|
@@ -120,12 +126,26 @@ function fresh(env: HookEnvelope): SessionSignal {
|
|
|
120
126
|
keepWarm: false,
|
|
121
127
|
compactions: 0,
|
|
122
128
|
childEvents: 0,
|
|
129
|
+
entrypoint: null,
|
|
123
130
|
hooksSeen: true,
|
|
124
131
|
}
|
|
125
132
|
}
|
|
126
133
|
|
|
127
134
|
const str = (v: unknown): string | null => (typeof v === 'string' && v.length > 0 ? v : null)
|
|
128
135
|
|
|
136
|
+
/**
|
|
137
|
+
* 6.48.1. A tool runs only inside a turn, so a MAIN-THREAD tool event (no `agent_id`) is
|
|
138
|
+
* evidence the turn is open even when its UserPromptSubmit was never seen: a tab adopted
|
|
139
|
+
* mid-turn when the hooks were installed (2.1.272 reloads hooks on the settings change,
|
|
140
|
+
* measured 2026-09-15 16:16) read `idle` from the hooks while the registry said busy. A
|
|
141
|
+
* sub-agent's tool events carry `agent_id` and say nothing about the main thread, and a
|
|
142
|
+
* tool event after SessionEnd is a child's, never the tab's.
|
|
143
|
+
*/
|
|
144
|
+
function turnOpenedByTool(prev: SessionSignal, p: Record<string, unknown>, at: number): Partial<SessionSignal> {
|
|
145
|
+
if (prev.turnOpen || prev.ended || str(p.agent_id)) return {}
|
|
146
|
+
return { turnOpen: true, turnStartedAt: prev.turnStartedAt ?? at }
|
|
147
|
+
}
|
|
148
|
+
|
|
129
149
|
/** Waiting kinds a PostToolUse of the same tool resolves without a fingerprint match. */
|
|
130
150
|
const TOOL_WAITING: Record<string, WaitingKind> = { AskUserQuestion: 'question', ExitPlanMode: 'plan' }
|
|
131
151
|
|
|
@@ -193,7 +213,7 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
|
|
|
193
213
|
waiting: { kind, detail: tool.target, toolName: tool.name, fingerprint: tool.fingerprint, since: env.ts, requestId: null },
|
|
194
214
|
}
|
|
195
215
|
}
|
|
196
|
-
return { ...next, lastTool: tool.name || next.lastTool, lastToolAt: env.ts }
|
|
216
|
+
return { ...next, ...turnOpenedByTool(next, p, env.ts), lastTool: tool.name || next.lastTool, lastToolAt: env.ts }
|
|
197
217
|
}
|
|
198
218
|
case 'PermissionRequest': {
|
|
199
219
|
const tool = toolFacts(p)
|
|
@@ -216,7 +236,7 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
|
|
|
216
236
|
const tool = toolFacts(p)
|
|
217
237
|
const waiting = next.waiting && resolvesWaiting(next.waiting, env.event, tool.name, tool.fingerprint) ? null : next.waiting
|
|
218
238
|
const ran = env.event !== 'PermissionDenied'
|
|
219
|
-
return { ...next, waiting, ...(ran ? { lastTool: tool.name || next.lastTool, lastToolAt: env.ts } : {}) }
|
|
239
|
+
return { ...next, ...turnOpenedByTool(next, p, env.ts), waiting, ...(ran ? { lastTool: tool.name || next.lastTool, lastToolAt: env.ts } : {}) }
|
|
220
240
|
}
|
|
221
241
|
case 'Notification': {
|
|
222
242
|
const type = str(p.notification_type) ?? ''
|
|
@@ -259,7 +279,7 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
|
|
|
259
279
|
}
|
|
260
280
|
}
|
|
261
281
|
|
|
262
|
-
export type SignalListener = (signal: SessionSignal, env: HookEnvelope) => void
|
|
282
|
+
export type SignalListener = (signal: SessionSignal, env: HookEnvelope, child: boolean) => void
|
|
263
283
|
|
|
264
284
|
/** Records older than this after a SessionEnd are dropped; Control keeps its own ledger. */
|
|
265
285
|
export const SIGNAL_PRUNE_AFTER_END_MS = 6 * 60 * 60_000
|
|
@@ -278,14 +298,22 @@ export class SessionSignalStore {
|
|
|
278
298
|
apply(env: HookEnvelope, child?: boolean): SessionSignal {
|
|
279
299
|
const next = applyHookEvent(this.signals.get(env.sessionId), env, this.ctx, child)
|
|
280
300
|
this.signals.set(env.sessionId, next)
|
|
301
|
+
const isChild = child ?? this.ctx.isCosSpawnedPid(env.ppid)
|
|
281
302
|
for (const listener of this.listeners) {
|
|
282
|
-
try { listener(next, env) } catch (error) {
|
|
303
|
+
try { listener(next, env, isChild) } catch (error) {
|
|
283
304
|
console.error(`[session-signals] listener failed: ${error instanceof Error ? error.message : error}`)
|
|
284
305
|
}
|
|
285
306
|
}
|
|
286
307
|
return next
|
|
287
308
|
}
|
|
288
309
|
|
|
310
|
+
/** The registry's entrypoint, once the runtime has read it; a no-op for an unknown session. */
|
|
311
|
+
setEntrypoint(sessionId: string, entrypoint: string | null): void {
|
|
312
|
+
const current = this.signals.get(sessionId)
|
|
313
|
+
if (!current || !entrypoint || current.entrypoint === entrypoint) return
|
|
314
|
+
this.signals.set(sessionId, { ...current, entrypoint })
|
|
315
|
+
}
|
|
316
|
+
|
|
289
317
|
/** RESERVED FOR 6.48.2 (the permission broker); no caller in 6.48.0. Attach the broker's
|
|
290
318
|
* minted id so rows can carry `pending_permission_id`. */
|
|
291
319
|
attachPermissionRequestId(sessionId: string, requestId: string | null): void {
|
|
@@ -64,6 +64,14 @@ export interface DeriveInput {
|
|
|
64
64
|
registry: RegistryFacts | undefined
|
|
65
65
|
transcript: TranscriptFacts | undefined
|
|
66
66
|
now: number
|
|
67
|
+
/**
|
|
68
|
+
* A COS-spawned turn (Continue, a queued follow-up, a job) is writing this session
|
|
69
|
+
* RIGHT NOW (`isAttachedTurnActive`). Its hook events are classified as a child's and
|
|
70
|
+
* kept out of the phase, so without this the tab's last state (idle after its Stop)
|
|
71
|
+
* would be reported while COS itself is generating (QA, 2026-09-15: the shipped lens
|
|
72
|
+
* drops the trail on `idle`). Running, source `transcript`: it is our own write.
|
|
73
|
+
*/
|
|
74
|
+
attachedTurn?: boolean
|
|
67
75
|
/** The previous derive's `deadScans`, so the two-scan rule survives between polls. */
|
|
68
76
|
prevDeadScans?: number
|
|
69
77
|
/** The previous derive's `deadSince`. */
|
|
@@ -100,6 +108,10 @@ export function deriveSessionState(input: DeriveInput): DerivedSessionState {
|
|
|
100
108
|
const hooksSilent = !!signal && now - signal.lastEventAt > HOOK_SILENCE_MS
|
|
101
109
|
const carry = { deadScans, deadSince }
|
|
102
110
|
|
|
111
|
+
if (input.attachedTurn === true) {
|
|
112
|
+
return { agent_state: 'running', state_source: 'transcript', state_since: iso(transcript?.lastActivityAt ?? signal?.lastEventAt ?? now), ...carry, ...replyOf(signal) }
|
|
113
|
+
}
|
|
114
|
+
|
|
103
115
|
// A dead pid on two scans at least DEAD_GRACE_MS apart ends the row whatever the hooks
|
|
104
116
|
// last said, unless a hook event is newer than the registry's last movement (a resumed
|
|
105
117
|
// tab under a new pid).
|
|
@@ -195,6 +207,32 @@ function waitStillStands(since: number, registry: RegistryFacts | undefined, tra
|
|
|
195
207
|
return true
|
|
196
208
|
}
|
|
197
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The fields a `status` draft carries (6.48.1): the row fields minus the permission id
|
|
212
|
+
* (slice 4's, never on the stream) and with `last_reply` only on an idle state, where a
|
|
213
|
+
* feed renders "Idle, last reply: …". A typed projection, so a new row field cannot reach
|
|
214
|
+
* the wire by accident.
|
|
215
|
+
*/
|
|
216
|
+
export function derivedStatusFields(derived: DerivedSessionState): {
|
|
217
|
+
agent_state: AgentState
|
|
218
|
+
state_source: StateSource
|
|
219
|
+
state_since: string
|
|
220
|
+
waiting_kind?: WaitingKind
|
|
221
|
+
waiting_detail?: string
|
|
222
|
+
failure?: string
|
|
223
|
+
last_reply?: string
|
|
224
|
+
} {
|
|
225
|
+
return {
|
|
226
|
+
agent_state: derived.agent_state,
|
|
227
|
+
state_source: derived.state_source,
|
|
228
|
+
state_since: derived.state_since,
|
|
229
|
+
...(derived.waiting_kind ? { waiting_kind: derived.waiting_kind } : {}),
|
|
230
|
+
...(derived.waiting_detail !== undefined ? { waiting_detail: derived.waiting_detail } : {}),
|
|
231
|
+
...(derived.failure ? { failure: derived.failure } : {}),
|
|
232
|
+
...(derived.agent_state === 'idle' && derived.last_reply ? { last_reply: derived.last_reply } : {}),
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
198
236
|
/** The additive row fields, ready to spread onto a list or detail entry. */
|
|
199
237
|
export function derivedRowFields(derived: DerivedSessionState | undefined): Record<string, unknown> {
|
|
200
238
|
if (!derived) return {}
|
|
@@ -21,16 +21,92 @@
|
|
|
21
21
|
//
|
|
22
22
|
// So: same transport, own keyspace. This module is deliberately small.
|
|
23
23
|
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
24
|
+
// A BOUNDED REPLAY RING PER STREAMED SESSION (6.48.1), and why the earlier "no ring"
|
|
25
|
+
// decision moved. `seq` stays per connection, so a shipped client still sees its gaps;
|
|
26
|
+
// a new client ALSO reads `cursor`, monotonic per session for this server's life, and
|
|
27
|
+
// reconnects with `?after=<cursor>` to be handed what it missed instead of a fresh
|
|
28
|
+
// seed. The ring holds the last RING_MAX published events, keeps the ACTIVE TURN whole
|
|
29
|
+
// (from its newest `prompt` draft onward, up to RING_HARD_MAX) and is dropped with the
|
|
30
|
+
// session's last subscriber plus a linger, so memory is bounded by streamed sessions,
|
|
31
|
+
// not by sessions ever opened. Even Terminal 0.10.4 ships the same shape (500-event
|
|
32
|
+
// ring, replay on reconnect, the active turn always retained).
|
|
29
33
|
|
|
30
34
|
import type { SessionStreamDraft } from './session-stream-events.js'
|
|
31
35
|
|
|
32
|
-
/** A draft with the publish instant stamped. `seq` stays per connection
|
|
33
|
-
export type PublishedSessionEvent = SessionStreamDraft & { at: number }
|
|
36
|
+
/** A draft with the publish instant stamped. `seq` stays per connection; `cursor` is per session within `epoch`. */
|
|
37
|
+
export type PublishedSessionEvent = SessionStreamDraft & { at: number; cursor?: number; epoch?: number }
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Cursors are meaningful only within one server life: a client holding a cursor from
|
|
41
|
+
* before a restart would otherwise be handed the wrong events from a fresh ring with no
|
|
42
|
+
* gap to notice (QA, 2026-09-15). Every event carries the epoch, and the SSE `id:` line
|
|
43
|
+
* is `<epoch>.<cursor>`; the route refuses to replay across epochs.
|
|
44
|
+
*/
|
|
45
|
+
export const RING_EPOCH = Date.now()
|
|
46
|
+
|
|
47
|
+
/** Events the ring keeps per session. */
|
|
48
|
+
export const RING_MAX = 500
|
|
49
|
+
/** The active turn is never cut short of this. */
|
|
50
|
+
export const RING_HARD_MAX = 2_000
|
|
51
|
+
/** A session's ring outlives its last subscriber this long, so a reconnect finds the gap. */
|
|
52
|
+
export const RING_LINGER_MS = 5 * 60_000
|
|
53
|
+
|
|
54
|
+
interface Ring {
|
|
55
|
+
events: Array<PublishedSessionEvent & { cursor: number }>
|
|
56
|
+
nextCursor: number
|
|
57
|
+
/** Index into `events` of the newest prompt draft, or -1. */
|
|
58
|
+
turnStart: number
|
|
59
|
+
emptySince: number | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const rings = new Map<string, Ring>()
|
|
63
|
+
|
|
64
|
+
function ringFor(key: string): Ring {
|
|
65
|
+
let ring = rings.get(key)
|
|
66
|
+
if (!ring) {
|
|
67
|
+
ring = { events: [], nextCursor: 1, turnStart: -1, emptySince: null }
|
|
68
|
+
rings.set(key, ring)
|
|
69
|
+
}
|
|
70
|
+
return ring
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function remember(key: string, event: PublishedSessionEvent): PublishedSessionEvent & { cursor: number } {
|
|
74
|
+
const ring = ringFor(key)
|
|
75
|
+
const stamped = { ...event, cursor: ring.nextCursor++, epoch: RING_EPOCH }
|
|
76
|
+
ring.events.push(stamped)
|
|
77
|
+
if (stamped.kind === 'prompt') ring.turnStart = ring.events.length - 1
|
|
78
|
+
// Trim the oldest, but never into the active turn until the hard cap.
|
|
79
|
+
while (ring.events.length > RING_MAX) {
|
|
80
|
+
if (ring.turnStart === 0 && ring.events.length <= RING_HARD_MAX) break
|
|
81
|
+
ring.events.shift()
|
|
82
|
+
if (ring.turnStart >= 0) ring.turnStart--
|
|
83
|
+
}
|
|
84
|
+
return stamped
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Events published to this session after `afterCursor`, oldest first. Empty for an unknown session. */
|
|
88
|
+
export function replaySessionStream(key: string, afterCursor: number): Array<PublishedSessionEvent & { cursor: number }> {
|
|
89
|
+
const ring = rings.get(key)
|
|
90
|
+
if (!ring) return []
|
|
91
|
+
const after = Number.isFinite(afterCursor) ? afterCursor : 0
|
|
92
|
+
return ring.events.filter(e => e.cursor > after)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The ring's oldest and newest cursors, so a client can tell a replay from a reseed. */
|
|
96
|
+
export function ringBounds(key: string): { oldest: number; newest: number } | null {
|
|
97
|
+
const ring = rings.get(key)
|
|
98
|
+
if (!ring || ring.events.length === 0) return null
|
|
99
|
+
return { oldest: ring.events[0].cursor, newest: ring.events[ring.events.length - 1].cursor }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Drop rings whose session has had no subscriber for the linger. Called by the route on release. */
|
|
103
|
+
export function sweepSessionRings(nowMs = Date.now()): number {
|
|
104
|
+
let dropped = 0
|
|
105
|
+
for (const [key, ring] of rings) {
|
|
106
|
+
if (ring.emptySince !== null && nowMs - ring.emptySince > RING_LINGER_MS) { rings.delete(key); dropped++ }
|
|
107
|
+
}
|
|
108
|
+
return dropped
|
|
109
|
+
}
|
|
34
110
|
|
|
35
111
|
export type SessionStreamListener = (event: PublishedSessionEvent) => void
|
|
36
112
|
|
|
@@ -78,6 +154,7 @@ export function subscribeSessionStream(key: string, listener: SessionStreamListe
|
|
|
78
154
|
const set = existing ?? new Set<SessionStreamListener>()
|
|
79
155
|
set.add(listener)
|
|
80
156
|
listeners.set(key, set)
|
|
157
|
+
ringFor(key).emptySince = null
|
|
81
158
|
|
|
82
159
|
let released = false
|
|
83
160
|
return () => {
|
|
@@ -88,7 +165,12 @@ export function subscribeSessionStream(key: string, listener: SessionStreamListe
|
|
|
88
165
|
const current = listeners.get(key)
|
|
89
166
|
if (!current) return
|
|
90
167
|
current.delete(listener)
|
|
91
|
-
if (current.size === 0)
|
|
168
|
+
if (current.size === 0) {
|
|
169
|
+
listeners.delete(key)
|
|
170
|
+
const ring = rings.get(key)
|
|
171
|
+
if (ring) ring.emptySince = Date.now()
|
|
172
|
+
sweepSessionRings()
|
|
173
|
+
}
|
|
92
174
|
}
|
|
93
175
|
}
|
|
94
176
|
|
|
@@ -103,7 +185,8 @@ export function subscribeSessionStream(key: string, listener: SessionStreamListe
|
|
|
103
185
|
export function publishSessionStream(key: string, draft: SessionStreamDraft, at: number = Date.now()): number {
|
|
104
186
|
const set = listeners.get(key)
|
|
105
187
|
if (!set || set.size === 0) return 0
|
|
106
|
-
|
|
188
|
+
// Remembered BEFORE fan-out, so a listener that reads the ring sees this event too.
|
|
189
|
+
const event: PublishedSessionEvent = remember(key, { ...draft, at })
|
|
107
190
|
let delivered = 0
|
|
108
191
|
for (const listener of [...set]) {
|
|
109
192
|
try {
|
|
@@ -147,4 +230,5 @@ export function isAttachedTurnActive(key: string): boolean {
|
|
|
147
230
|
export function __resetSessionStreamBusForTests(): void {
|
|
148
231
|
listeners.clear()
|
|
149
232
|
attachedTurns.clear()
|
|
233
|
+
rings.clear()
|
|
150
234
|
}
|
|
@@ -457,6 +457,150 @@ export function draftsFromLine(provider: SessionStreamProvider, line: string): S
|
|
|
457
457
|
return draftsFromRecord(provider, parsed)
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
+
/**
|
|
461
|
+
* Did the newest turn in this transcript tail END? (6.48.1)
|
|
462
|
+
*
|
|
463
|
+
* THE RULE THE HARNESS USES, read against real Desktop transcripts on this Mac
|
|
464
|
+
* (2026-09-15): a Desktop session writes no `type:'result'` row at all (0 of 30 newest
|
|
465
|
+
* transcripts), so the status-draft rule above can only ever say "ended" for a
|
|
466
|
+
* `claude -p` run. What every Claude transcript DOES carry is `message.stop_reason` on
|
|
467
|
+
* each assistant record: `tool_use` while tools run, `end_turn` when the reply is done.
|
|
468
|
+
*
|
|
469
|
+
* ended the newest assistant record's stop_reason is terminal (end_turn,
|
|
470
|
+
* stop_sequence, max_tokens, refusal) and no tool_use it issued is still
|
|
471
|
+
* waiting for its tool_result; or a `result` row; or the user interrupted
|
|
472
|
+
* (`[Request interrupted by user`), which ends the turn without a reply.
|
|
473
|
+
* open a user prompt newer than the last terminal reply (a `<task-notification>`
|
|
474
|
+
* included: the model answers it), or a tool_use with no result yet.
|
|
475
|
+
*
|
|
476
|
+
* Skipped: `isMeta` and `isCompactSummary` rows (injected context, not a prompt), tool
|
|
477
|
+
* results, and the bookkeeping rows (`system`, `last-prompt`, `attachment`, `mode`...).
|
|
478
|
+
* Codex keeps its event rule (task_complete / turn_complete end, task_started opens).
|
|
479
|
+
*
|
|
480
|
+
* PURE, like everything else here. The queue store reads the tail; this decides.
|
|
481
|
+
*/
|
|
482
|
+
export const TERMINAL_STOP_REASONS: ReadonlySet<string> = new Set(['end_turn', 'stop_sequence', 'max_tokens', 'refusal'])
|
|
483
|
+
|
|
484
|
+
export type TurnFromTail = { ended: boolean; reason: 'terminal_stop' | 'result_row' | 'interrupted' | 'codex_complete' | 'prompt_open' | 'prompt_queued' | 'tool_pending' | 'no_evidence' }
|
|
485
|
+
|
|
486
|
+
export function turnFromTail(provider: SessionStreamProvider, lines: readonly string[]): TurnFromTail {
|
|
487
|
+
let verdict: TurnFromTail = { ended: false, reason: 'no_evidence' }
|
|
488
|
+
const pendingToolUses = new Set<string>()
|
|
489
|
+
for (const line of lines) {
|
|
490
|
+
const trimmed = typeof line === 'string' ? line.trim() : ''
|
|
491
|
+
if (!trimmed || trimmed[0] !== '{') continue
|
|
492
|
+
let record: unknown
|
|
493
|
+
try { record = JSON.parse(trimmed) } catch { continue }
|
|
494
|
+
const r = asRecord(record)
|
|
495
|
+
if (!r) continue
|
|
496
|
+
|
|
497
|
+
if (provider === 'codex') {
|
|
498
|
+
for (const draft of draftsFromRecord('codex', r)) {
|
|
499
|
+
if (draft.kind === 'status') verdict = draft.state === 'done' ? { ended: true, reason: 'codex_complete' } : { ended: false, reason: 'prompt_open' }
|
|
500
|
+
}
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const type = typeof r.type === 'string' ? r.type : ''
|
|
505
|
+
if (type === 'result') { verdict = { ended: true, reason: 'result_row' }; pendingToolUses.clear(); continue }
|
|
506
|
+
// A prompt typed while the Stop hooks run is QUEUED, not submitted (Claude writes a
|
|
507
|
+
// `queue-operation` row); it dequeues once the hooks return and the model answers it.
|
|
508
|
+
// The turn is not over until then (QA, 2026-09-15). The dequeue is followed by the
|
|
509
|
+
// user row itself, which the next branch reads.
|
|
510
|
+
if (type === 'queue-operation') {
|
|
511
|
+
if (r.operation === 'enqueue') verdict = { ended: false, reason: 'prompt_queued' }
|
|
512
|
+
continue
|
|
513
|
+
}
|
|
514
|
+
// Injected context is not a prompt, except a cross-session message (`promptSource:
|
|
515
|
+
// 'system'`), which the model answers as a turn.
|
|
516
|
+
if (r.isCompactSummary === true) continue
|
|
517
|
+
if (r.isMeta === true && r.promptSource !== 'system') continue
|
|
518
|
+
const message = asRecord(r.message)
|
|
519
|
+
if (!message) continue
|
|
520
|
+
const content = Array.isArray(message.content) ? message.content : typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : []
|
|
521
|
+
const blocks = content.map(asRecord).filter((b): b is Record<string, unknown> => b !== null)
|
|
522
|
+
|
|
523
|
+
if (type === 'user' || message.role === 'user') {
|
|
524
|
+
const results = blocks.filter(b => b.type === 'tool_result')
|
|
525
|
+
if (results.length > 0) {
|
|
526
|
+
for (const b of results) if (typeof b.tool_use_id === 'string') pendingToolUses.delete(b.tool_use_id)
|
|
527
|
+
continue
|
|
528
|
+
}
|
|
529
|
+
const text = blocks.filter(b => b.type === 'text' || b.type === undefined).map(b => (typeof b.text === 'string' ? b.text : '')).join(' ')
|
|
530
|
+
if (/\[Request interrupted by user/.test(text)) { verdict = { ended: true, reason: 'interrupted' }; pendingToolUses.clear(); continue }
|
|
531
|
+
if (text.trim().length === 0) continue
|
|
532
|
+
verdict = { ended: false, reason: 'prompt_open' }
|
|
533
|
+
continue
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (type === 'assistant' || message.role === 'assistant') {
|
|
537
|
+
for (const b of blocks) if (b.type === 'tool_use' && typeof b.id === 'string') pendingToolUses.add(b.id)
|
|
538
|
+
const stop = typeof message.stop_reason === 'string' ? message.stop_reason : ''
|
|
539
|
+
if (TERMINAL_STOP_REASONS.has(stop)) {
|
|
540
|
+
verdict = pendingToolUses.size === 0 ? { ended: true, reason: 'terminal_stop' } : { ended: false, reason: 'tool_pending' }
|
|
541
|
+
} else if (stop === 'tool_use' || pendingToolUses.size > 0) {
|
|
542
|
+
verdict = { ended: false, reason: 'tool_pending' }
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return verdict
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The derived session state on a `status` draft (6.48.1).
|
|
551
|
+
*
|
|
552
|
+
* EXTRA FIELDS ON THE EXISTING KIND, NEVER A NEW KIND: every shipped EHPK drops an
|
|
553
|
+
* unknown `kind` before its `seq` accounting and paints a "missed N" gap per event
|
|
554
|
+
* (validation round 1, B2). The shipped parser keeps `seq` and strips unknown fields
|
|
555
|
+
* on a `status` draft (canary 8), so an old client renders exactly as before and a
|
|
556
|
+
* new one reads the state line off the same event.
|
|
557
|
+
*
|
|
558
|
+
* `state` keeps its closed vocabulary. The table:
|
|
559
|
+
* running -> working waiting -> working (+ waiting_kind/detail)
|
|
560
|
+
* idle -> idle failed -> idle (+ failure) ended -> done
|
|
561
|
+
*
|
|
562
|
+
* `done` on open flips the client to its digest body, which is intended for a session
|
|
563
|
+
* whose engine said SessionEnd. An ATTACHED COS TURN WINS (QA, 2026-09-15): the deriver
|
|
564
|
+
* is told `attachedTurn` and answers `running` for as long as the child is writing, so
|
|
565
|
+
* a Continue's trail is never handed off mid-stream by the tab's older Stop or by a
|
|
566
|
+
* child's own SessionEnd; the engine's state resumes the moment the turn detaches.
|
|
567
|
+
*
|
|
568
|
+
* `last_reply` rides only an idle state (the feed's "Idle, last reply: …" line).
|
|
569
|
+
*/
|
|
570
|
+
export interface DerivedStatusFields {
|
|
571
|
+
agent_state: 'running' | 'waiting' | 'idle' | 'failed' | 'ended'
|
|
572
|
+
state_source: 'hook' | 'registry' | 'transcript'
|
|
573
|
+
state_since: string
|
|
574
|
+
waiting_kind?: string
|
|
575
|
+
waiting_detail?: string
|
|
576
|
+
failure?: string
|
|
577
|
+
last_reply?: string
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export type StatusDraftWithDerived = { kind: 'status'; state: SessionStreamState } & Partial<DerivedStatusFields>
|
|
581
|
+
|
|
582
|
+
export function statusDraftWithDerived(
|
|
583
|
+
draft: { kind: 'status'; state: SessionStreamState },
|
|
584
|
+
derived: DerivedStatusFields | undefined,
|
|
585
|
+
): StatusDraftWithDerived {
|
|
586
|
+
if (!derived) return draft
|
|
587
|
+
const state: SessionStreamState =
|
|
588
|
+
derived.agent_state === 'running' || derived.agent_state === 'waiting' ? 'working'
|
|
589
|
+
: derived.agent_state === 'ended' ? 'done'
|
|
590
|
+
: 'idle'
|
|
591
|
+
return {
|
|
592
|
+
kind: 'status',
|
|
593
|
+
state,
|
|
594
|
+
agent_state: derived.agent_state,
|
|
595
|
+
state_source: derived.state_source,
|
|
596
|
+
state_since: derived.state_since,
|
|
597
|
+
...(derived.waiting_kind ? { waiting_kind: derived.waiting_kind } : {}),
|
|
598
|
+
...(derived.waiting_detail !== undefined ? { waiting_detail: derived.waiting_detail } : {}),
|
|
599
|
+
...(derived.failure ? { failure: derived.failure } : {}),
|
|
600
|
+
...(state === 'idle' && derived.last_reply ? { last_reply: derived.last_reply } : {}),
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
460
604
|
/** Draft plus transport stamps, in the field order the contract shows. */
|
|
461
605
|
export function stampSessionEvent(draft: SessionStreamDraft, seq: number, at: number): SessionStreamEvent {
|
|
462
606
|
return { seq, at, ...draft }
|