@gotcos/glasses-server 6.48.0 → 6.48.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 +14 -0
- package/README.md +15 -4
- package/package.json +1 -1
- package/server/index.ts +70 -7
- 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
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { request } from 'node:http'
|
|
28
28
|
import type { QueuedThreadTurn } from './thread-turn-queue.js'
|
|
29
|
+
import { targetKey } from './agent-session-binding-store.js'
|
|
29
30
|
|
|
30
31
|
/** Per-request ceiling. Attach and turn both answer immediately; the turn route
|
|
31
32
|
* admits with 202 and does the long work in the background. */
|
|
@@ -97,16 +98,26 @@ export async function deliverQueuedTurnOverLoopback(
|
|
|
97
98
|
}
|
|
98
99
|
const bindingId = typeof attach.body.bindingId === 'string' ? attach.body.bindingId : ''
|
|
99
100
|
if (!bindingId) return { ok: false, reason: 'attach_no_binding' }
|
|
100
|
-
|
|
101
|
+
// 6.48.1: the turns route has required `epoch` and `targetKey` since the binding
|
|
102
|
+
// hardening, and refused this body as `invalid_request` (retryable, so the turn was
|
|
103
|
+
// refunded and held forever: every drained follow-up since then never landed; found by
|
|
104
|
+
// the 6.48.1 live proof, 2026-09-15). The epoch is the one the attach just minted and
|
|
105
|
+
// the target key is the server's own function of (provider, native id), exactly what
|
|
106
|
+
// the phone sends.
|
|
107
|
+
const epoch = typeof attach.body.epoch === 'number' && Number.isInteger(attach.body.epoch) && attach.body.epoch >= 1 ? attach.body.epoch : null
|
|
108
|
+
if (epoch === null) return { ok: false, reason: 'attach_no_epoch' }
|
|
109
|
+
const boundTo = typeof attach.body.boundTo === 'string' ? attach.body.boundTo : undefined
|
|
101
110
|
const sent = await post(
|
|
102
111
|
port, token,
|
|
103
112
|
`/api/agent-sessions/bindings/${encodeURIComponent(bindingId)}/turns`,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
113
|
+
{
|
|
114
|
+
clientTurnId: turn.clientTurnId,
|
|
115
|
+
prompt: turn.prompt,
|
|
116
|
+
epoch,
|
|
117
|
+
targetKey: targetKey(turn.provider, turn.threadId),
|
|
118
|
+
...(boundTo ? { boundTo } : {}),
|
|
119
|
+
},
|
|
108
120
|
)
|
|
109
|
-
// 202 is the success shape: admitted, delivered in the background, poll the ledger.
|
|
110
121
|
if (sent.status === 202 || sent.status === 200) return { ok: true }
|
|
111
122
|
// Same defect on the turn leg: `refuseTurn` emits `reason`/`reasonCopy`/`retryable`
|
|
112
123
|
// and no `error`. `retryable` is the server's OWN judgement about this refusal --
|
|
@@ -13,11 +13,17 @@ import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readd
|
|
|
13
13
|
import { join } from 'node:path'
|
|
14
14
|
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
15
15
|
import { dataPath } from './data-dir.js'
|
|
16
|
-
import {
|
|
16
|
+
import { turnFromTail, type SessionStreamProvider, type TurnFromTail } from './session-stream-events.js'
|
|
17
17
|
import { pruneQueue, type QueuedThreadTurn } from './thread-turn-queue.js'
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Bytes of transcript tail read to decide whether the last turn ended. 512 KiB, not 64:
|
|
21
|
+
* on the release Mac 3 of the 10 newest Desktop transcripts end in a ~76 KB bookkeeping
|
|
22
|
+
* row (a `prompt_snapshot` attachment) AFTER the terminal record, and a 64 KiB tail read
|
|
23
|
+
* only that row, saw no conversation record, and held the queue for the 30 s backstop
|
|
24
|
+
* (QA, 2026-09-15). The read is one `pread` per decision; nothing here streams.
|
|
25
|
+
*/
|
|
26
|
+
export const TURN_END_TAIL_BYTES = 512 * 1024
|
|
21
27
|
|
|
22
28
|
function queueDir(): string {
|
|
23
29
|
const dir = dataPath('thread-turn-queue')
|
|
@@ -62,6 +68,46 @@ export function writeQueue(provider: string, threadId: string, queue: readonly Q
|
|
|
62
68
|
atomicWriteFileSync(queuePath(provider, threadId), `${JSON.stringify(queue, null, 2)}\n`)
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
/**
|
|
72
|
+
* How many WAITING follow-ups a list/detail row should show (6.48.1).
|
|
73
|
+
*
|
|
74
|
+
* Queue files are named with the native thread id (often a full UUID). Claude
|
|
75
|
+
* list rows stay on the registry's 8-character form until a transcript match
|
|
76
|
+
* expands them. An exact match always wins; an 8-character id is allowed to
|
|
77
|
+
* count a unique prefix among waiting queues of that provider, and an
|
|
78
|
+
* ambiguous prefix counts as zero rather than guessing.
|
|
79
|
+
*
|
|
80
|
+
* PURE: the store builds the input from disk; this decides the number.
|
|
81
|
+
*/
|
|
82
|
+
export function queuedWaitingForSession(
|
|
83
|
+
queues: ReadonlyArray<{ provider: string; threadId: string; waiting: number }>,
|
|
84
|
+
provider: string, sessionId: string,
|
|
85
|
+
): number {
|
|
86
|
+
const id = sessionId.toLowerCase()
|
|
87
|
+
const rows = queues.filter(q => q.provider === provider && q.waiting > 0)
|
|
88
|
+
const exact = rows.find(q => q.threadId.toLowerCase() === id)
|
|
89
|
+
if (exact) return exact.waiting
|
|
90
|
+
if (id.length !== 8) return 0
|
|
91
|
+
const prefixed = rows.filter(q => q.threadId.toLowerCase().startsWith(id))
|
|
92
|
+
const owners = new Set(prefixed.map(q => q.threadId.toLowerCase()))
|
|
93
|
+
if (owners.size !== 1) return 0
|
|
94
|
+
return prefixed[0]!.waiting
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One lookup for a whole list/search request: one directory read, then O(1) per row. */
|
|
98
|
+
export function queuedWaitingLookup(now: number): (provider: string, sessionId: string) => number {
|
|
99
|
+
const queues = queuedThreadKeys().map(({ provider, threadId }) => ({
|
|
100
|
+
provider,
|
|
101
|
+
threadId,
|
|
102
|
+
waiting: readQueue(provider, threadId, now).filter(t => t.status === 'waiting').length,
|
|
103
|
+
}))
|
|
104
|
+
return (provider, sessionId) => queuedWaitingForSession(queues, provider, sessionId)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function queuedTurnsFields(count: number): { queued_turns?: number } {
|
|
108
|
+
return count > 0 ? { queued_turns: count } : {}
|
|
109
|
+
}
|
|
110
|
+
|
|
65
111
|
/** Every thread with a queue file, for the drain sweep. */
|
|
66
112
|
export function queuedThreadKeys(): Array<{ provider: string; threadId: string }> {
|
|
67
113
|
try {
|
|
@@ -83,24 +129,32 @@ export function queuedThreadKeys(): Array<{ provider: string; threadId: string }
|
|
|
83
129
|
/**
|
|
84
130
|
* Did the holder's last turn END?
|
|
85
131
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
132
|
+
* 6.48.1: decided by `turnFromTail`, the transcript's own rule (a terminal
|
|
133
|
+
* `stop_reason` with no tool_use awaiting its result, a `result` row, or a user
|
|
134
|
+
* interrupt). It replaced the status-draft loop here because a Desktop transcript
|
|
135
|
+
* writes no `result` row, so the old rule could only ever say "ended" for a
|
|
136
|
+
* `claude -p` run and every Desktop Continue waited the 30 s idle backstop (measured
|
|
137
|
+
* 2026-09-15: 0 of 30 newest transcripts carry one). The live feed still narrates
|
|
138
|
+
* from `draftsFromLine`; the two are pinned to agree on recorded tails.
|
|
89
139
|
*
|
|
90
140
|
* Reads a bounded tail, newest record wins. Returns false on any doubt -- an
|
|
91
141
|
* unreadable transcript is not evidence a turn finished, and false only means the
|
|
92
142
|
* queue HOLDS, which is always the safe answer.
|
|
93
143
|
*/
|
|
144
|
+
/** The full tail verdict, for callers that need to know OPEN as well as ended. Null on any doubt. */
|
|
145
|
+
export function transcriptTurnVerdict(provider: SessionStreamProvider, path: string | null): TurnFromTail | null {
|
|
146
|
+
const lines = readTail(path)
|
|
147
|
+
return lines === null ? null : turnFromTail(provider, lines)
|
|
148
|
+
}
|
|
149
|
+
|
|
94
150
|
export function transcriptTurnEnded(provider: SessionStreamProvider, path: string | null): boolean {
|
|
95
|
-
|
|
151
|
+
return transcriptTurnVerdict(provider, path)?.ended === true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The bounded tail as lines, with the same two open flags; null when it cannot be read. */
|
|
155
|
+
function readTail(path: string | null): string[] | null {
|
|
156
|
+
if (!path || !existsSync(path)) return null
|
|
96
157
|
try {
|
|
97
|
-
// BOTH flags, and both are load-bearing -- hazard-invariants.test.ts enforces
|
|
98
|
-
// them and each is right on its own terms. O_NOFOLLOW: a symlinked `<id>.jsonl`
|
|
99
|
-
// could point at any file on disk and would be parsed here as a transcript.
|
|
100
|
-
// O_NONBLOCK: `openSync` on a FIFO with no writer NEVER RETURNS, and it is a
|
|
101
|
-
// synchronous syscall on Node's single thread, so one planted path would stop
|
|
102
|
-
// health, meeting save and transcribe-stream along with this drain. That one is
|
|
103
|
-
// recorded in the repo as three reproductions of the same bug, >34s to SIGKILL.
|
|
104
158
|
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
105
159
|
try {
|
|
106
160
|
const size = fstatSync(fd).size
|
|
@@ -110,18 +164,11 @@ export function transcriptTurnEnded(provider: SessionStreamProvider, path: strin
|
|
|
110
164
|
const lines = buf.toString('utf-8').split('\n')
|
|
111
165
|
// The first line of a tail read is almost always a fragment.
|
|
112
166
|
if (start > 0) lines.shift()
|
|
113
|
-
|
|
114
|
-
for (const line of lines) {
|
|
115
|
-
if (!line.trim()) continue
|
|
116
|
-
for (const draft of draftsFromLine(provider, line)) {
|
|
117
|
-
if (draft.kind === 'status') ended = draft.state === 'done'
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return ended
|
|
167
|
+
return lines
|
|
121
168
|
} finally {
|
|
122
169
|
closeSync(fd)
|
|
123
170
|
}
|
|
124
171
|
} catch {
|
|
125
|
-
return
|
|
172
|
+
return null
|
|
126
173
|
}
|
|
127
174
|
}
|
|
@@ -53,11 +53,15 @@
|
|
|
53
53
|
// ---------------------------------------------------------------------------
|
|
54
54
|
// Thirty seconds of transcript silence is the wrong trigger on its own: a long tool
|
|
55
55
|
// call goes quiet mid-turn, and draining there would inject a message into the middle
|
|
56
|
-
// of someone's reasoning. The precise signal is the turn ENDING
|
|
57
|
-
//
|
|
56
|
+
// of someone's reasoning. The precise signal is the turn ENDING. For Claude that is the
|
|
57
|
+
// engine's own Stop hook followed by the registry flipping `idle` (6.48.1; a Desktop
|
|
58
|
+
// transcript carries no `result` record, and the Stop alone is 12-38 s early while the
|
|
59
|
+
// Stop hooks run), else the transcript's newest assistant record with a terminal
|
|
60
|
+
// `stop_reason` and no pending tool (`turnFromTail`); Codex writes `task_complete` /
|
|
58
61
|
// `turn_complete`. The idle clock stays as a BACKSTOP for a holder that dies or a
|
|
59
62
|
// provider that writes no terminal record, so a queue cannot wedge forever on a missing
|
|
60
|
-
// event
|
|
63
|
+
// event, and positive evidence of an OPEN turn outranks it. Same reasoning as the
|
|
64
|
+
// session-trail handoff, one layer down.
|
|
61
65
|
|
|
62
66
|
/** Terminal-ish states a queued turn can reach. `waiting` is the only live one. */
|
|
63
67
|
export type QueuedTurnStatus =
|
|
@@ -186,6 +190,14 @@ export interface DrainObservation {
|
|
|
186
190
|
turnEnded: boolean
|
|
187
191
|
/** The 30s transcript clock. `idle` is the backstop when no terminal record lands. */
|
|
188
192
|
activity: 'working' | 'idle' | 'unknown'
|
|
193
|
+
/**
|
|
194
|
+
* 6.48.1: POSITIVE evidence the turn is still open (a tool_use awaiting its result, a
|
|
195
|
+
* prompt newer than the last reply, or the hooks saying the turn has not stopped),
|
|
196
|
+
* bounded by the caller to a recent window. It outranks the idle backstop: a 40 s tool
|
|
197
|
+
* leaves the transcript untouched for 40 s, and "idle for 30 s" used to deliver a
|
|
198
|
+
* follow-up straight into that live turn (measured 2026-09-15 on the 6.48.1 live proof).
|
|
199
|
+
*/
|
|
200
|
+
turnOpen?: boolean
|
|
189
201
|
/** The gate's reason when `attachable` is false. Carried ONLY so a fence -- the one
|
|
190
202
|
* hold a clock cannot end -- can be given a longer life than a busy thread. */
|
|
191
203
|
reason?: string | null
|
|
@@ -218,6 +230,10 @@ export function drainDecision(
|
|
|
218
230
|
// Turn-ended is the precise signal; idle is the backstop for a holder that wrote no
|
|
219
231
|
// terminal record. `working` holds even when attachable, because attachable only says
|
|
220
232
|
// no one else owns it -- it does not say a turn is not mid-flight.
|
|
233
|
+
// Positive evidence the turn is OPEN outranks a terminal record older than it: a prompt
|
|
234
|
+
// queued at the desk during the Stop hooks dequeues after them, and the tail's newest
|
|
235
|
+
// terminal record cannot see it (QA, 2026-09-15).
|
|
236
|
+
if (seen.turnOpen === true) return 'hold'
|
|
221
237
|
if (seen.turnEnded) return 'deliver'
|
|
222
238
|
return seen.activity === 'idle' ? 'deliver' : 'hold'
|
|
223
239
|
}
|
|
@@ -2,15 +2,24 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Server-sent events for ONE agent session. Each `data:` line is one JSON object:
|
|
4
4
|
//
|
|
5
|
-
// {"seq":1,"at":1786890000000,"kind":"tool","verb":"read","target":"x.ts","detail":""}
|
|
6
|
-
// {"seq":2,"at":1786890001000,"kind":"prose","text":"..."}
|
|
7
|
-
// {"seq":3,"at":1786890002000,"kind":"status","state":"working"}
|
|
5
|
+
// {"seq":1,"at":1786890000000,"kind":"tool","verb":"read","target":"x.ts","detail":"","cursor":41,"epoch":1789...}
|
|
6
|
+
// {"seq":2,"at":1786890001000,"kind":"prose","text":"...","cursor":42,"epoch":1789...}
|
|
7
|
+
// {"seq":3,"at":1786890002000,"kind":"status","state":"working","agent_state":"running",...}
|
|
8
8
|
// {"seq":4,"at":1786890003000,"kind":"heartbeat"}
|
|
9
9
|
//
|
|
10
10
|
// `seq` is monotonic PER CONNECTION from 1, so a client detects loss from a gap. There
|
|
11
11
|
// are no named SSE events and no comment keepalives: one shape, so a client needs one
|
|
12
12
|
// handler and can never miss a keepalive it was not parsing.
|
|
13
13
|
//
|
|
14
|
+
// 6.48.1 adds, all ignorable by a client that reads `data:` lines and known fields:
|
|
15
|
+
// - `cursor` and `epoch` on every event the per-session ring remembers, and an SSE
|
|
16
|
+
// `id: <epoch>.<cursor>` line before it. `?after=<epoch>.<cursor>` (or the standard
|
|
17
|
+
// `Last-Event-ID` header) replays what that ring holds after the cursor, from the
|
|
18
|
+
// same server life only; anything else, and an empty replay, seeds as a fresh open.
|
|
19
|
+
// - `?seed=turn` seeds from the current turn's prompt instead of the last 7 steps.
|
|
20
|
+
// - the derived state fields on every `status` draft (`COS_SESSION_HOOK_SSE=0` omits
|
|
21
|
+
// them), and a `status` line on every change of that state.
|
|
22
|
+
//
|
|
14
23
|
// ---------------------------------------------------------------------------
|
|
15
24
|
// A DEAD STREAM MUST DEGRADE TO THE POLL, NEVER TO A FROZEN SCREEN
|
|
16
25
|
// ---------------------------------------------------------------------------
|
|
@@ -70,8 +79,13 @@ import {
|
|
|
70
79
|
transcriptWatcherDegraded,
|
|
71
80
|
readTranscriptSeedLines,
|
|
72
81
|
} from '../lib/session-transcript-watcher.js'
|
|
73
|
-
import { draftsFromLine } from '../lib/session-stream-events.js'
|
|
82
|
+
import { draftsFromLine, statusDraftWithDerived, type DerivedStatusFields } from '../lib/session-stream-events.js'
|
|
74
83
|
import type { SessionStreamState } from '../lib/session-stream-events.js'
|
|
84
|
+
import { RING_EPOCH, replaySessionStream, ringBounds } from '../lib/session-stream-bus.js'
|
|
85
|
+
import { claudeSessionsDir, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
|
|
86
|
+
import type { RegistryFacts } from '../lib/session-state-derive.js'
|
|
87
|
+
import { deriveForRow, sessionHooksEnabled, sessionSignalStore } from '../lib/session-hooks-runtime.js'
|
|
88
|
+
import { derivedStatusFields } from '../lib/session-state-derive.js'
|
|
75
89
|
|
|
76
90
|
export const agentSessionStreamRouter = Router()
|
|
77
91
|
|
|
@@ -85,18 +99,92 @@ export function sessionStreamEnabled(env: NodeJS.ProcessEnv = process.env): bool
|
|
|
85
99
|
return env.COS_SESSION_STREAM_ENABLED !== '0'
|
|
86
100
|
}
|
|
87
101
|
|
|
102
|
+
/**
|
|
103
|
+
* 6.48.1: the derived session state rides every `status` draft as extra fields (see
|
|
104
|
+
* `statusDraftWithDerived`). `COS_SESSION_HOOK_SSE=0` keeps the drafts exactly as 6.48.0
|
|
105
|
+
* wrote them; the hooks themselves are unaffected. Absent means on.
|
|
106
|
+
*/
|
|
107
|
+
export function sessionHookSseEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
108
|
+
return env.COS_SESSION_HOOK_SSE !== '0' && sessionHooksEnabled()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The derived state for a Claude session the stream is showing, or nothing.
|
|
113
|
+
*
|
|
114
|
+
* SAME INPUTS AS THE ROWS (QA, 2026-09-15): the registry facts (so an Esc-interrupted
|
|
115
|
+
* turn and a stray SessionEnd are read the way the list reads them) and whether a COS
|
|
116
|
+
* turn is attached (which is `running`, whatever the tab's hooks last said). The
|
|
117
|
+
* registry is read once per connection and again at most every REGISTRY_REFRESH_MS.
|
|
118
|
+
*/
|
|
119
|
+
export const REGISTRY_REFRESH_MS = 5_000
|
|
120
|
+
|
|
121
|
+
type RegistryReader = () => Promise<RegistryFacts | undefined>
|
|
122
|
+
|
|
123
|
+
function derivedForStream(provider: AgentProvider, sessionId: string, key: string, registry: RegistryFacts | undefined): DerivedStatusFields | undefined {
|
|
124
|
+
if (provider !== 'claude' || !sessionHookSseEnabled()) return undefined
|
|
125
|
+
try {
|
|
126
|
+
const derived = deriveForRow({ sessionId, registry, remember: false, attachedTurn: isAttachedTurnActive(key) })
|
|
127
|
+
return derived ? derivedStatusFields(derived) : undefined
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The registry record for a session, cached briefly; undefined when none names it. */
|
|
134
|
+
function registryReaderFor(sessionId: string): RegistryReader {
|
|
135
|
+
const wanted = sessionId.toLowerCase()
|
|
136
|
+
let cached: { at: number; facts: RegistryFacts | undefined } | null = null
|
|
137
|
+
return async () => {
|
|
138
|
+
if (cached && Date.now() - cached.at < REGISTRY_REFRESH_MS) return cached.facts
|
|
139
|
+
let facts: RegistryFacts | undefined
|
|
140
|
+
try {
|
|
141
|
+
const record = (await readClaudePeerRecords(claudeSessionsDir())).find(p => p.sessionId === wanted || p.sessionId.startsWith(wanted))
|
|
142
|
+
facts = record ? registryFacts(record) : undefined
|
|
143
|
+
} catch {
|
|
144
|
+
facts = undefined
|
|
145
|
+
}
|
|
146
|
+
cached = { at: Date.now(), facts }
|
|
147
|
+
return facts
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether a cursor can be replayed from a ring with these bounds: only when the ring
|
|
153
|
+
* still holds the event right after it (an evicted cursor would replay the whole ring
|
|
154
|
+
* with the gap unnoticed) and there IS one (a cursor at the newest has nothing to
|
|
155
|
+
* replay, and an empty replay must seed, never open an empty screen).
|
|
156
|
+
*/
|
|
157
|
+
export function cursorReplayable(after: number | null, bounds: { oldest: number; newest: number } | null): boolean {
|
|
158
|
+
if (after === null || bounds === null) return false
|
|
159
|
+
return bounds.oldest <= after + 1 && after < bounds.newest
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** `?after=<epoch>.<cursor>` (the `id:` line's shape) or a bare cursor from this epoch; null when absent or unusable. */
|
|
163
|
+
export function parseAfterCursor(raw: unknown, epoch: number = RING_EPOCH): number | null {
|
|
164
|
+
if (typeof raw !== 'string') return null
|
|
165
|
+
const m = /^(?:(\d{1,16})\.)?(\d{1,12})$/.exec(raw.trim())
|
|
166
|
+
if (!m) return null
|
|
167
|
+
if (m[1] !== undefined && Number(m[1]) !== epoch) return null
|
|
168
|
+
return Number(m[2])
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A turn-anchored seed (`?seed=turn`): the newest prompt and EVERY step after it, bounded. */
|
|
172
|
+
export const TURN_SEED_MAX_STEPS = 500
|
|
173
|
+
|
|
88
174
|
function asProvider(value: string): AgentProvider | null {
|
|
89
175
|
if (value === 'claude' || value === 'codex' || value === 'cursor') return value
|
|
90
176
|
return null
|
|
91
177
|
}
|
|
92
178
|
|
|
93
179
|
/**
|
|
94
|
-
* The state
|
|
180
|
+
* The TRANSPORT's opening state, before the derived state is stamped over it.
|
|
95
181
|
*
|
|
96
182
|
* `working` when a COS turn is writing right now, or when the transcript was touched
|
|
97
183
|
* inside the same 30s window the session list already uses to call a thread active.
|
|
98
|
-
* Otherwise `idle`. Never `done
|
|
99
|
-
* not start
|
|
184
|
+
* Otherwise `idle`. Never `done` from HERE: this function cannot observe the end of a
|
|
185
|
+
* turn it did not start. Since 6.48.1 `write()` restates `state` from the deriver when
|
|
186
|
+
* the hooks are on, and an engine that said SessionEnd (with no live registry record
|
|
187
|
+
* behind the session) opens as `done`, which is the client's digest body and intended.
|
|
100
188
|
*/
|
|
101
189
|
export async function openingState(
|
|
102
190
|
key: string,
|
|
@@ -186,6 +274,12 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
186
274
|
|
|
187
275
|
const state = await openingState(key, path, Date.now())
|
|
188
276
|
|
|
277
|
+
// The registry facts the feed derives with: read in the same pre-header window as the
|
|
278
|
+
// opening state (nothing awaits once the headers are out, so a close can never slip
|
|
279
|
+
// between a write and the `close` listener), refreshed at most every REGISTRY_REFRESH_MS.
|
|
280
|
+
const readRegistry = registryReaderFor(sessionId)
|
|
281
|
+
let registry: RegistryFacts | undefined = await readRegistry()
|
|
282
|
+
|
|
189
283
|
res.writeHead(200, {
|
|
190
284
|
'Content-Type': 'text/event-stream',
|
|
191
285
|
'Cache-Control': 'no-cache',
|
|
@@ -205,19 +299,34 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
205
299
|
let seq = 0
|
|
206
300
|
let closed = false
|
|
207
301
|
|
|
302
|
+
let releaseSignals: (() => void) | null = null
|
|
303
|
+
|
|
208
304
|
const teardown = (): void => {
|
|
209
305
|
if (closed) return
|
|
210
306
|
closed = true
|
|
211
307
|
if (heartbeat !== null) clearInterval(heartbeat)
|
|
212
308
|
unsubscribe()
|
|
213
309
|
releaseWatcher?.()
|
|
310
|
+
releaseSignals?.()
|
|
214
311
|
try { res.end() } catch { /* already gone */ }
|
|
215
312
|
}
|
|
216
313
|
|
|
314
|
+
let lastStamp = ''
|
|
315
|
+
const stampOf = (d: DerivedStatusFields | undefined) => d ? `${d.agent_state}|${d.waiting_kind ?? ''}|${d.waiting_detail ?? ''}|${d.failure ?? ''}` : ''
|
|
316
|
+
|
|
217
317
|
const write = (event: PublishedSessionEvent): void => {
|
|
218
318
|
if (closed) return
|
|
219
319
|
try {
|
|
220
|
-
|
|
320
|
+
// ONE stamping point for the derived state: every status draft this connection
|
|
321
|
+
// writes, opening or live, seeded or published, carries the same extra fields.
|
|
322
|
+
let out: PublishedSessionEvent = event
|
|
323
|
+
if (event.kind === 'status') {
|
|
324
|
+
const derived = derivedForStream(provider, sessionId, key, registry)
|
|
325
|
+
lastStamp = stampOf(derived)
|
|
326
|
+
out = { ...event, ...statusDraftWithDerived(event, derived) }
|
|
327
|
+
}
|
|
328
|
+
const idLine = typeof out.cursor === 'number' ? `id: ${out.epoch ?? RING_EPOCH}.${out.cursor}\n` : ''
|
|
329
|
+
res.write(`${idLine}data: ${JSON.stringify({ seq: ++seq, ...out })}\n\n`)
|
|
221
330
|
} catch {
|
|
222
331
|
// A failed write means the socket is gone. Close rather than swallow, so the
|
|
223
332
|
// watcher and the subscription are released instead of leaking behind a dead
|
|
@@ -257,7 +366,25 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
257
366
|
//
|
|
258
367
|
// NEVER FATAL. A session whose history cannot be read still streams; it just starts
|
|
259
368
|
// empty, exactly as it did before this existed.
|
|
260
|
-
|
|
369
|
+
// 6.48.1 RECONNECT: `?after=<cursor>` replays from the session's ring instead of
|
|
370
|
+
// seeding, when the ring still reaches back that far. A ring that does not (server
|
|
371
|
+
// restarted, linger expired) falls through to the seed, and the client sees a cursor
|
|
372
|
+
// jump it treats as a reseed.
|
|
373
|
+
// The standard SSE header is honoured as the fallback for `?after=`; the shipped lens
|
|
374
|
+
// and Control both pass the query and read `data:` lines only.
|
|
375
|
+
const after = parseAfterCursor(req.query.after) ?? parseAfterCursor(req.get('last-event-id'))
|
|
376
|
+
const bounds = ringBounds(key)
|
|
377
|
+
// Replayable only when the cursor is from THIS epoch, the ring reaches back to it, it
|
|
378
|
+
// is not past the ring, and the ring actually holds something after it. A ring that
|
|
379
|
+
// holds nothing after the cursor (the sole client was away, so nothing was published
|
|
380
|
+
// while it was gone) seeds instead: an empty replay must never be an empty screen
|
|
381
|
+
// (QA, 2026-09-15).
|
|
382
|
+
const replay = after !== null && cursorReplayable(after, bounds) ? replaySessionStream(key, after) : []
|
|
383
|
+
const replayable = replay.length > 0
|
|
384
|
+
for (const event of replay) write(event)
|
|
385
|
+
const seedMode = String(req.query.seed ?? '') === 'turn' ? 'turn' : 'window'
|
|
386
|
+
|
|
387
|
+
if (!replayable && path !== null && startOffset > 0) {
|
|
261
388
|
try {
|
|
262
389
|
const lines = await readTranscriptSeedLines(path, startOffset)
|
|
263
390
|
const drafts = lines.flatMap(line => draftsFromLine(provider, line))
|
|
@@ -275,11 +402,17 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
275
402
|
// So the newest prompt in the whole read window is emitted FIRST, unconditionally,
|
|
276
403
|
// and the step budget is spent entirely on steps. The client pins it rather than
|
|
277
404
|
// listing it, so it costs nothing in the scrolling window.
|
|
278
|
-
const
|
|
405
|
+
const lastPromptIndex = drafts.map(d => d.kind).lastIndexOf('prompt')
|
|
406
|
+
const lastPrompt = lastPromptIndex >= 0 ? drafts[lastPromptIndex] : undefined
|
|
279
407
|
if (lastPrompt) write({ ...lastPrompt, at: Date.now() })
|
|
280
408
|
|
|
409
|
+
// 6.48.1 `?seed=turn`: the whole current turn, oldest first, so a client opening
|
|
410
|
+
// mid-turn reads it top to bottom (Terminal V2's turn-anchored open). The default
|
|
411
|
+
// stays the last SEED_EVENTS steps, which is what shipped lenses were built for.
|
|
281
412
|
const steps = drafts.filter(d => d.kind === 'tool' || d.kind === 'prose')
|
|
282
|
-
|
|
413
|
+
const turnSteps = lastPromptIndex >= 0 ? drafts.slice(lastPromptIndex + 1).filter(d => d.kind === 'tool' || d.kind === 'prose') : steps
|
|
414
|
+
const seeded = seedMode === 'turn' ? turnSteps.slice(-TURN_SEED_MAX_STEPS) : steps.slice(-SEED_EVENTS)
|
|
415
|
+
for (const draft of seeded) {
|
|
283
416
|
// NOT tagged as seeded. A replayed step is a step that really happened, and a
|
|
284
417
|
// second rendering style for it would be a distinction without a use. The one
|
|
285
418
|
// consequence is that the client's "N ago" clock starts at open rather than at
|
|
@@ -298,6 +431,29 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
298
431
|
for (const event of pending.splice(0)) write(event)
|
|
299
432
|
deliver = write
|
|
300
433
|
|
|
434
|
+
// 6.48.1: every change to this session's hook signal is a live `status` draft, so the
|
|
435
|
+
// client's state line moves on the engine's own events (prompt, permission prompt,
|
|
436
|
+
// Stop, end) rather than on the transcript clock. Filtered to this session (the
|
|
437
|
+
// client may have addressed it by the registry's 8-character form).
|
|
438
|
+
if (provider === 'claude' && sessionHookSseEnabled() && !closed) {
|
|
439
|
+
const wanted = sessionId.toLowerCase()
|
|
440
|
+
// `lastStamp` is whatever the OPENING status actually wrote (set inside `write`), so a
|
|
441
|
+
// change during the seed read is emitted as the first live line, not lost.
|
|
442
|
+
releaseSignals = sessionSignalStore.subscribe(signal => {
|
|
443
|
+
if (closed) return
|
|
444
|
+
if (signal.sessionId !== wanted && !signal.sessionId.startsWith(wanted)) return
|
|
445
|
+
// The registry may have moved with the hooks (an Esc flips it idle); refresh it
|
|
446
|
+
// off the hot path and let the next event read the new facts.
|
|
447
|
+
void readRegistry().then(facts => { registry = facts })
|
|
448
|
+
const derived = derivedForStream(provider, sessionId, key, registry)
|
|
449
|
+
if (!derived) return
|
|
450
|
+
// Only a CHANGE of state is a line; tool events inside a running turn are narrated
|
|
451
|
+
// by the transcript tail and must not each repaint the state.
|
|
452
|
+
if (stampOf(derived) === lastStamp) return
|
|
453
|
+
write({ kind: 'status', state: 'working', at: Date.now() })
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
|
|
301
457
|
releaseWatcher = path === null
|
|
302
458
|
? null
|
|
303
459
|
: acquireTranscriptWatcher({ key, path, provider, offset: startOffset })
|
|
@@ -306,6 +462,8 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
306
462
|
if (closed) {
|
|
307
463
|
releaseWatcher?.()
|
|
308
464
|
releaseWatcher = null
|
|
465
|
+
releaseSignals?.()
|
|
466
|
+
releaseSignals = null
|
|
309
467
|
return
|
|
310
468
|
}
|
|
311
469
|
|
|
@@ -41,6 +41,8 @@ import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, re
|
|
|
41
41
|
import type { ClaudePeerRecord } from '../lib/claude-session-registry.js'
|
|
42
42
|
import { deriveForRow } from '../lib/session-hooks-runtime.js'
|
|
43
43
|
import { derivedRowFields, type DerivedSessionState } from '../lib/session-state-derive.js'
|
|
44
|
+
import { queuedTurnsFields, queuedWaitingLookup } from '../lib/thread-turn-queue-store.js'
|
|
45
|
+
import { isAttachedTurnActive, sessionStreamKey } from '../lib/session-stream-bus.js'
|
|
44
46
|
import { workspaceFromCwd } from '../lib/claude-session-registry.js'
|
|
45
47
|
import {
|
|
46
48
|
occupiedThreads,
|
|
@@ -78,9 +80,9 @@ function asSort(value: unknown): AgentSessionSort {
|
|
|
78
80
|
return String(value ?? '').toLowerCase() === 'opened' ? 'opened' : 'updated'
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
function toSearchHit(row: AgentSessionSearchHit) {
|
|
83
|
+
function toSearchHit(row: AgentSessionSearchHit, queuedTurns = 0, derived?: DerivedSessionState) {
|
|
82
84
|
return {
|
|
83
|
-
...toEntry(row),
|
|
85
|
+
...toEntry(row, undefined, derived, queuedTurns),
|
|
84
86
|
snippet: row.snippet,
|
|
85
87
|
keywordScore: row.keywordScore,
|
|
86
88
|
semanticScore: row.semanticScore,
|
|
@@ -264,7 +266,7 @@ function runningForThread(provider: AgentProvider, threadId: string, mtimeMs: nu
|
|
|
264
266
|
}
|
|
265
267
|
}
|
|
266
268
|
|
|
267
|
-
function toEntry(row: AgentSessionRow, activity?: SessionActivity | null, derived?: DerivedSessionState) {
|
|
269
|
+
function toEntry(row: AgentSessionRow, activity?: SessionActivity | null, derived?: DerivedSessionState, queuedTurns = 0) {
|
|
268
270
|
return {
|
|
269
271
|
session_id: row.session_id,
|
|
270
272
|
provider: row.provider,
|
|
@@ -293,6 +295,9 @@ function toEntry(row: AgentSessionRow, activity?: SessionActivity | null, derive
|
|
|
293
295
|
// Under a NEW key: `state` above is `running|recent` and Control renders any other
|
|
294
296
|
// value there as "Running". Omitted entirely when nothing is known, like the two above.
|
|
295
297
|
...derivedRowFields(derived),
|
|
298
|
+
// 6.48.1: waiting follow-ups on this thread. Omitted at 0 so an older client
|
|
299
|
+
// and a quiet row look the same as 6.48.0.
|
|
300
|
+
...queuedTurnsFields(queuedTurns),
|
|
296
301
|
}
|
|
297
302
|
}
|
|
298
303
|
|
|
@@ -378,10 +383,12 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
378
383
|
lastActivityAt: activityById.get(row.session_id)?.lastActivityAt ? Date.parse(activityById.get(row.session_id)!.lastActivityAt!) : null,
|
|
379
384
|
},
|
|
380
385
|
now,
|
|
386
|
+
attachedTurn: isAttachedTurnActive(sessionStreamKey('claude', row.session_id)),
|
|
381
387
|
}))
|
|
382
388
|
}
|
|
389
|
+
const queuedOf = queuedWaitingLookup(now)
|
|
383
390
|
res.json({
|
|
384
|
-
sessions: sessions.map((row, index) => withRunning(toEntry(row, activity[index], derivedById.get(row.session_id)), running)),
|
|
391
|
+
sessions: sessions.map((row, index) => withRunning(toEntry(row, activity[index], derivedById.get(row.session_id), queuedOf(row.provider, row.session_id)), running)),
|
|
385
392
|
total: sessions.length,
|
|
386
393
|
windowHours: AGENT_SESSION_WINDOW_HOURS,
|
|
387
394
|
sort,
|
|
@@ -410,9 +417,29 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
|
|
|
410
417
|
try {
|
|
411
418
|
const limit = boundedInteger(req.query.limit, 20, 1, 50)
|
|
412
419
|
const result = await searchAgentSessions({ query, limit })
|
|
420
|
+
const queuedOf = queuedWaitingLookup(Date.now())
|
|
421
|
+
// 6.48.1: search hits carry the same derived state as list rows (no list/search shape
|
|
422
|
+
// drift), from the registry and the hook signal; a hit has no transcript walk.
|
|
423
|
+
const peers = await liveClaudePeerRecords()
|
|
424
|
+
const peersByPrefix = new Map<string, ClaudePeerRecord>()
|
|
425
|
+
for (const peer of peers) {
|
|
426
|
+
const prefix = peer.sessionId.slice(0, 8)
|
|
427
|
+
if (!peersByPrefix.has(prefix)) peersByPrefix.set(prefix, peer)
|
|
428
|
+
}
|
|
429
|
+
const derivedFor = (hit: AgentSessionSearchHit): DerivedSessionState | undefined => {
|
|
430
|
+
if (hit.provider !== 'claude') return undefined
|
|
431
|
+
const peer = peersByPrefix.get(hit.session_id.slice(0, 8).toLowerCase())
|
|
432
|
+
return deriveForRow({
|
|
433
|
+
sessionId: hit.session_id,
|
|
434
|
+
registry: peer ? registryFacts(peer) : undefined,
|
|
435
|
+
transcript: hit.modified ? { inFlight: false, lastActivityAt: Date.parse(hit.modified) } : undefined,
|
|
436
|
+
attachedTurn: isAttachedTurnActive(sessionStreamKey('claude', hit.session_id)),
|
|
437
|
+
remember: false,
|
|
438
|
+
})
|
|
439
|
+
}
|
|
413
440
|
res.json({
|
|
414
441
|
...result,
|
|
415
|
-
hits: result.hits.map(toSearchHit),
|
|
442
|
+
hits: result.hits.map(hit => toSearchHit(hit, queuedOf(hit.provider, hit.session_id), derivedFor(hit))),
|
|
416
443
|
})
|
|
417
444
|
} catch (error) {
|
|
418
445
|
console.error(`[agent-sessions] search failed: ${error instanceof Error ? error.message : error}`)
|
|
@@ -464,11 +491,13 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
464
491
|
sessionId: parsed.session_id,
|
|
465
492
|
registry: peer ? registryFacts(peer) : undefined,
|
|
466
493
|
transcript: { inFlight: running.occupied.get(parsed.session_id)?.activeRecently === true, lastActivityAt: activity.lastActivityAt ? Date.parse(activity.lastActivityAt) : null },
|
|
494
|
+
attachedTurn: isAttachedTurnActive(sessionStreamKey('claude', parsed.session_id)),
|
|
467
495
|
})
|
|
468
496
|
}
|
|
469
497
|
res.json({
|
|
470
498
|
...withRunning({ session_id: parsed.session_id }, running),
|
|
471
499
|
...derivedRowFields(derived),
|
|
500
|
+
...queuedTurnsFields(queuedWaitingLookup(Date.now())(provider, parsed.session_id)),
|
|
472
501
|
// The client must be able to tell "this server stamped nothing" from "this
|
|
473
502
|
// server stamped false", because the two demand opposite behaviour: an old
|
|
474
503
|
// server's silence means keep using the hint borrowed from the list row, and
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
import { Router } from 'express'
|
|
19
19
|
import { existsSync } from 'node:fs'
|
|
20
20
|
import { lstat, readdir, readFile, stat } from 'node:fs/promises'
|
|
21
|
-
import {
|
|
22
|
-
import { join, resolve } from 'node:path'
|
|
21
|
+
import { join } from 'node:path'
|
|
23
22
|
import {
|
|
24
23
|
REGISTRY_FILENAME,
|
|
25
24
|
countPeers,
|
|
@@ -31,9 +30,12 @@ import {
|
|
|
31
30
|
type ClaudePeerRecord,
|
|
32
31
|
peerRecordFacts,
|
|
33
32
|
toWirePeer,
|
|
33
|
+
claudeSessionsDir,
|
|
34
34
|
} from '../lib/claude-session-registry.js'
|
|
35
35
|
import { deriveForRow } from '../lib/session-hooks-runtime.js'
|
|
36
36
|
import { derivedRowFields, type RegistryFacts } from '../lib/session-state-derive.js'
|
|
37
|
+
import { queuedTurnsFields, queuedWaitingLookup } from '../lib/thread-turn-queue-store.js'
|
|
38
|
+
import { isAttachedTurnActive, sessionStreamKey } from '../lib/session-stream-bus.js'
|
|
37
39
|
|
|
38
40
|
export const claudeSessionsRouter = Router()
|
|
39
41
|
|
|
@@ -61,20 +63,8 @@ export function claudeSessionNamesVisible(): boolean {
|
|
|
61
63
|
return process.env.COS_CLAUDE_SESSIONS_SHOW_NAMES === '1'
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
*
|
|
67
|
-
* `COS_CLAUDE_SESSIONS_DIR` first because it is both the override for a non-standard
|
|
68
|
-
* install AND the test seam — `homedir()` is not mockable, so without an env hook the
|
|
69
|
-
* only testable path would be the real one. Then CLAUDE_CONFIG_DIR, which real
|
|
70
|
-
* installs do set; hardcoding ~/.claude breaks those.
|
|
71
|
-
*/
|
|
72
|
-
export function claudeSessionsDir(): string {
|
|
73
|
-
const explicit = process.env.COS_CLAUDE_SESSIONS_DIR
|
|
74
|
-
if (explicit) return resolve(explicit)
|
|
75
|
-
const configDir = process.env.CLAUDE_CONFIG_DIR
|
|
76
|
-
return join(configDir ? resolve(configDir) : join(homedir(), '.claude'), 'sessions')
|
|
77
|
-
}
|
|
66
|
+
/** Where the registry lives; the definition moved to the registry lib in 6.48.1. */
|
|
67
|
+
export { claudeSessionsDir }
|
|
78
68
|
|
|
79
69
|
const realProbes: PeerProbes = {
|
|
80
70
|
isAlive: pid => {
|
|
@@ -170,13 +160,17 @@ claudeSessionsRouter.get('/claude-sessions', async (req, res) => {
|
|
|
170
160
|
const limit = boundedInteger(req.query.limit, 30, 1, 100)
|
|
171
161
|
const records = await readClaudePeerRecords(claudeSessionsDir())
|
|
172
162
|
const peers = records.map(toWirePeer)
|
|
163
|
+
const queuedOf = queuedWaitingLookup(Date.now())
|
|
173
164
|
// 6.48.0: the same derived state every surface reads, stamped ADDITIVELY on the wire
|
|
174
165
|
// peer. `toPeer` stays byte-identical (its key set is pinned); the eight extra keys
|
|
175
166
|
// come from the signal store and the registry facts an older client never sees.
|
|
167
|
+
// 6.48.1: queued_turns joins here too so a peer-only row (no agent-sessions hit)
|
|
168
|
+
// still shows a follow-up waiting.
|
|
176
169
|
res.json({
|
|
177
170
|
peers: records.slice(0, limit).map(record => ({
|
|
178
171
|
...toWirePeer(record),
|
|
179
|
-
...derivedRowFields(deriveForRow({ sessionId: record.sessionId, registry: registryFacts(record) })),
|
|
172
|
+
...derivedRowFields(deriveForRow({ sessionId: record.sessionId, registry: registryFacts(record), attachedTurn: isAttachedTurnActive(sessionStreamKey('claude', record.sessionId)) })),
|
|
173
|
+
...queuedTurnsFields(queuedOf('claude', record.sessionId)),
|
|
180
174
|
})),
|
|
181
175
|
counts: countPeers(peers),
|
|
182
176
|
enabled: true,
|