@gotcos/glasses-server 6.53.0 → 6.53.3
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 +34 -0
- package/bin/hooks/cos-session-hook +74 -38
- package/package.json +1 -1
- package/server/index.ts +32 -61
- package/server/lib/attached-provider-adapter.ts +599 -49
- package/server/lib/claude-hooks-installer.ts +52 -0
- package/server/lib/queue-turn-evidence.ts +137 -0
- package/server/lib/session-cancel.ts +70 -12
- package/server/lib/session-halt.ts +94 -0
- package/server/lib/session-hooks-runtime.ts +55 -2
- package/server/routes/agent-session-bindings.ts +271 -20
- package/server/routes/health.ts +2 -1
- package/server/routes/meeting.ts +13 -4
- package/server/routes/transcribe-stream.ts +29 -1
- package/server/scripts/hooks-cli.ts +6 -2
|
@@ -284,6 +284,58 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
|
|
|
284
284
|
return { state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, tokenPresent }
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
/**
|
|
288
|
+
* 6.53.3: sha256 of every EARLIER script that already carries the halt check: the one script
|
|
289
|
+
* 6.53.0, 6.53.1 and 6.53.2 all shipped. 6.53.3 rewrote the script for speed (review A,
|
|
290
|
+
* option a), so an install that has not run Install hooks since reads `script_outdated`
|
|
291
|
+
* and Control shows its banner. Its script still stops a desk run exactly as before,
|
|
292
|
+
* though, so a desk cancel is not refused `hooks_outdated` in the meantime
|
|
293
|
+
* (`hookHaltReady`). A script older than 6.53.0 has no halt check and never qualifies.
|
|
294
|
+
*/
|
|
295
|
+
export const HALT_CAPABLE_PRIOR_SCRIPT_SHAS: readonly string[] = [
|
|
296
|
+
'1158bb06297550128f01fc47caa68806a5287d6da2d05b0d1c812e8ae20764e7',
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 6.53.3: can the hooks on this Mac stop a desk Claude run now? `installed`, or the only
|
|
301
|
+
* difference is a halt-capable earlier script (above). What the cancel route's
|
|
302
|
+
* `hooksReady` and `/api/health` `features.sessionCancel.deskClaude` read.
|
|
303
|
+
*/
|
|
304
|
+
export function hookHaltReady(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha'>): boolean {
|
|
305
|
+
if (status.installed === true) return true
|
|
306
|
+
return status.state === 'script_outdated' && typeof status.scriptSha === 'string'
|
|
307
|
+
&& HALT_CAPABLE_PRIOR_SCRIPT_SHAS.includes(status.scriptSha)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const INSTALL_COMMAND = 'npx --yes @gotcos/glasses-server@latest --hooks install'
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 6.53.3: one plain sentence for `--hooks status` saying what the state means and what fixes
|
|
314
|
+
* it. Null when installed. Carries no path: the status JSON beside it already names them.
|
|
315
|
+
*/
|
|
316
|
+
export function hookStatusAdvice(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha' | 'packageScriptSha'>): string | null {
|
|
317
|
+
const short = (sha: string | null): string => (sha ? sha.slice(0, 12) : 'none')
|
|
318
|
+
switch (status.state) {
|
|
319
|
+
case 'installed':
|
|
320
|
+
return null
|
|
321
|
+
case 'script_outdated':
|
|
322
|
+
return `The installed hook script is older than this package's (installed ${short(status.scriptSha)}, package ${short(status.packageScriptSha)}). `
|
|
323
|
+
+ 'Every Claude session keeps running the old script until the hooks are reinstalled: '
|
|
324
|
+
+ `run \`${INSTALL_COMMAND}\`, or press Install hooks in COS Control. `
|
|
325
|
+
+ (hookHaltReady(status)
|
|
326
|
+
? 'Cancel from the lens still stops desk runs with the installed script meanwhile.'
|
|
327
|
+
: 'Until then a desk run cannot be cancelled from the lens.')
|
|
328
|
+
case 'drift':
|
|
329
|
+
case 'missing':
|
|
330
|
+
return `The hook subscriptions in the Claude settings file are ${status.state === 'missing' ? 'not installed' : 'not the ones this package installs'}. `
|
|
331
|
+
+ `Run \`${INSTALL_COMMAND}\`, or press Install hooks in COS Control.`
|
|
332
|
+
case 'disabled_by_settings':
|
|
333
|
+
return 'The Claude settings file sets disableAllHooks: no hook runs, installed or not.'
|
|
334
|
+
default:
|
|
335
|
+
return 'The Claude settings file could not be read as it is; nothing was changed. Fix or restore the file, then install again.'
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
287
339
|
export interface InstallOptions {
|
|
288
340
|
settingsPath?: string
|
|
289
341
|
scriptPath?: string
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// The thread-turn queue's two turn questions, `turnEnded` and `turnOpen` (6.53.3).
|
|
2
|
+
//
|
|
3
|
+
// Moved out of the composition root (index.ts) unchanged in shape, so the one rule that
|
|
4
|
+
// changed in 6.53.3 can be driven by a test against the real `drainDecision`: WHAT a
|
|
5
|
+
// cancel on the thread may conclude about a turn that still reads open depends on what the
|
|
6
|
+
// cancel was aimed at (review A1, `cancelVoidsOpenTurn` in session-cancel.ts).
|
|
7
|
+
//
|
|
8
|
+
// cos_turn COS killed its own child: nothing written since the cancel ends the turn.
|
|
9
|
+
// desk_run the run stops at its NEXT tool call, so the hooks' and the transcript's
|
|
10
|
+
// open-turn evidence stands until the desk's own engine says the run is over
|
|
11
|
+
// at or after the cancel (a Stop, StopFailure or SessionEnd, or the registry's
|
|
12
|
+
// idle flip). Until 6.53.2 a desk run inside a long tool read "over" 30 s after
|
|
13
|
+
// the cancel, and a parked follow-up was delivered live into it.
|
|
14
|
+
//
|
|
15
|
+
// Every read is injected; index.ts wires the real ones. Both answers refuse on doubt: a
|
|
16
|
+
// throw is `false` for each, which is what the composition root did before the move.
|
|
17
|
+
|
|
18
|
+
import { cancelVoidsOpenTurn, type ThreadCancel } from './session-cancel.js'
|
|
19
|
+
|
|
20
|
+
/** The hook signal fields these questions read (a `SessionSignal` satisfies it). */
|
|
21
|
+
export interface TurnEvidenceSignal {
|
|
22
|
+
turnOpen: boolean
|
|
23
|
+
lastEvent: string
|
|
24
|
+
lastEventAt: number
|
|
25
|
+
turnStartedAt: number | null
|
|
26
|
+
stopAt: number | null
|
|
27
|
+
subagentsOpen: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The transcript tail's own verdict (`transcriptTurnVerdict`). */
|
|
31
|
+
export interface TurnEvidenceVerdict {
|
|
32
|
+
ended: boolean
|
|
33
|
+
reason?: string | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface QueueTurnEvidenceDeps {
|
|
37
|
+
/** The hooks' signal for a Claude session; undefined when the hooks are off or silent. */
|
|
38
|
+
signalFor: (threadId: string) => TurnEvidenceSignal | undefined
|
|
39
|
+
/** `registryIdleAfterStop`: true only when the live record flipped idle at or after `stopAt`. */
|
|
40
|
+
registryIdleAfterStop: (threadId: string, stopAt: number) => boolean | null
|
|
41
|
+
/** `deskTurnEndedAt`: when the desk session's engine said its run is over, as it stands now. */
|
|
42
|
+
deskEndedAt: (threadId: string) => number | null
|
|
43
|
+
/** The transcript's mtime, or null when unmeasurable. */
|
|
44
|
+
transcriptMtimeMs: (provider: string, threadId: string) => number | null
|
|
45
|
+
/** The transcript tail's verdict, or null when unreadable. */
|
|
46
|
+
transcriptVerdict: (provider: string, threadId: string) => TurnEvidenceVerdict | null
|
|
47
|
+
/** The cancel on record for the thread (`threadCancel`). */
|
|
48
|
+
threadCancel: (provider: string, threadId: string) => ThreadCancel | null
|
|
49
|
+
/** Past this age a signal or a tail is not evidence any more (OPEN_TURN_CEILING_MS). */
|
|
50
|
+
openTurnCeilingMs: number
|
|
51
|
+
now: () => number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface QueueTurnEvidence {
|
|
55
|
+
turnEnded: (provider: string, threadId: string) => boolean
|
|
56
|
+
turnOpen: (provider: string, threadId: string) => boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function makeQueueTurnEvidence(deps: QueueTurnEvidenceDeps): QueueTurnEvidence {
|
|
60
|
+
const mtime = (provider: string, threadId: string): number | null => {
|
|
61
|
+
const value = deps.transcriptMtimeMs(provider, threadId)
|
|
62
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
63
|
+
}
|
|
64
|
+
/** Read once per question, and only when a cancel is on record (it scans the registry). */
|
|
65
|
+
const deskEnded = (provider: string, threadId: string): (() => number | null) => {
|
|
66
|
+
let cached: number | null | undefined
|
|
67
|
+
return () => {
|
|
68
|
+
if (cached === undefined) cached = provider === 'claude' ? deps.deskEndedAt(threadId) : null
|
|
69
|
+
return cached
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const turnEnded = (provider: string, threadId: string): boolean => {
|
|
74
|
+
if (provider !== 'claude' && provider !== 'codex') return false
|
|
75
|
+
try {
|
|
76
|
+
const signal = provider === 'claude' ? deps.signalFor(threadId) : undefined
|
|
77
|
+
// 6.53.0: a cancel ends the turn when nothing was written since it (a killed COS
|
|
78
|
+
// child leaves its last tool_use unanswered, and no Stop or terminal record follows).
|
|
79
|
+
// 6.53.3: only a cos_turn cancel concludes that; a desk_run cancel needs the desk's
|
|
80
|
+
// own end, stamped at or after the cancel.
|
|
81
|
+
const cancel = deps.threadCancel(provider, threadId)
|
|
82
|
+
if (cancel !== null && cancelVoidsOpenTurn({
|
|
83
|
+
cancel,
|
|
84
|
+
lastActivityAt: mtime(provider, threadId),
|
|
85
|
+
turnStartedAt: signal?.turnStartedAt ?? null,
|
|
86
|
+
deskEndedAt: deskEnded(provider, threadId)(),
|
|
87
|
+
})) return true
|
|
88
|
+
// 6.48.1: the engine's own end of turn: a Stop hook newer than the turn's prompt
|
|
89
|
+
// AND the registry flipped idle after it (the Stop hooks have returned; a prompt
|
|
90
|
+
// typed during them is queued and dequeues only then). The transcript tail
|
|
91
|
+
// answers for everything the hooks did not see. Both refuse on doubt.
|
|
92
|
+
if (provider === 'claude') {
|
|
93
|
+
if (signal && !signal.turnOpen && signal.lastEvent === 'Stop' && typeof signal.stopAt === 'number'
|
|
94
|
+
&& signal.stopAt >= (signal.turnStartedAt ?? 0) && signal.subagentsOpen === 0
|
|
95
|
+
&& deps.registryIdleAfterStop(threadId, signal.stopAt) === true) return true
|
|
96
|
+
}
|
|
97
|
+
return deps.transcriptVerdict(provider, threadId)?.ended === true
|
|
98
|
+
} catch {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 6.48.1: positive evidence the holder's turn is OPEN, which outranks the 30 s idle
|
|
104
|
+
// backstop (a long tool leaves the transcript untouched while it runs). Bounded: a
|
|
105
|
+
// signal or a tail older than the ceiling is not evidence any more, so a crashed
|
|
106
|
+
// mid-tool session still drains by the backstop rather than holding for the TTL.
|
|
107
|
+
const turnOpen = (provider: string, threadId: string): boolean => {
|
|
108
|
+
if (provider !== 'claude' && provider !== 'codex') return false
|
|
109
|
+
try {
|
|
110
|
+
const now = deps.now()
|
|
111
|
+
const cancel = deps.threadCancel(provider, threadId)
|
|
112
|
+
const ended = deskEnded(provider, threadId)
|
|
113
|
+
if (provider === 'claude') {
|
|
114
|
+
const signal = deps.signalFor(threadId)
|
|
115
|
+
if (signal && signal.turnOpen && now - signal.lastEventAt <= deps.openTurnCeilingMs
|
|
116
|
+
&& !(cancel !== null && cancelVoidsOpenTurn({
|
|
117
|
+
cancel,
|
|
118
|
+
lastActivityAt: signal.lastEventAt,
|
|
119
|
+
turnStartedAt: signal.turnStartedAt,
|
|
120
|
+
deskEndedAt: ended(),
|
|
121
|
+
}))) return true
|
|
122
|
+
}
|
|
123
|
+
const verdict = deps.transcriptVerdict(provider, threadId)
|
|
124
|
+
if (!verdict || verdict.ended) return false
|
|
125
|
+
if (verdict.reason !== 'tool_pending' && verdict.reason !== 'prompt_open') return false
|
|
126
|
+
const last = mtime(provider, threadId)
|
|
127
|
+
// 6.53.0 / 6.53.3: a cos_turn cancel with nothing written since discounts a dangling
|
|
128
|
+
// tool_use (the killed child's); a desk_run cancel only once the desk said it ended.
|
|
129
|
+
if (cancel !== null && cancelVoidsOpenTurn({ cancel, lastActivityAt: last, turnStartedAt: null, deskEndedAt: ended() })) return false
|
|
130
|
+
return last !== null && now - last <= deps.openTurnCeilingMs
|
|
131
|
+
} catch {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { turnEnded, turnOpen }
|
|
137
|
+
}
|
|
@@ -104,30 +104,47 @@ export const CANCEL_QUEUE_HOLD_MS = 2 * 60_000
|
|
|
104
104
|
export const CANCEL_MEMORY_MS = 60 * 60_000
|
|
105
105
|
const MAX_REMEMBERED_CANCELS = 256
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* One cancel on record for a thread (6.53.3): WHEN, and WHAT it was aimed at. The target
|
|
109
|
+
* decides what the cancel may conclude about a turn that still reads open (see
|
|
110
|
+
* `cancelVoidsOpenTurn`). Null is an older caller that did not say; it is read as a desk
|
|
111
|
+
* run, the reading that concludes the least.
|
|
112
|
+
*/
|
|
113
|
+
export interface ThreadCancel {
|
|
114
|
+
at: number
|
|
115
|
+
target: CancelTarget | null
|
|
116
|
+
}
|
|
117
|
+
|
|
107
118
|
// In memory on purpose: every reader is a hold of minutes, and a restart that forgets
|
|
108
119
|
// one lets the queue drain by the gate's ordinary rules, which is the pre-6.53 behaviour.
|
|
109
|
-
const
|
|
120
|
+
const cancelsByThread = new Map<string, ThreadCancel>()
|
|
110
121
|
const cancelKey = (provider: string, threadId: string) => `${provider}:${threadId.toLowerCase()}`
|
|
111
122
|
|
|
112
|
-
export function noteThreadCancelled(provider: string, threadId: string, at: number): void {
|
|
123
|
+
export function noteThreadCancelled(provider: string, threadId: string, at: number, target: CancelTarget | null = null): void {
|
|
113
124
|
if (!Number.isFinite(at)) return
|
|
114
125
|
const key = cancelKey(provider, threadId)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
for (const [k, when] of
|
|
119
|
-
if (at - when > CANCEL_MEMORY_MS)
|
|
126
|
+
cancelsByThread.delete(key)
|
|
127
|
+
cancelsByThread.set(key, { at, target })
|
|
128
|
+
if (cancelsByThread.size > MAX_REMEMBERED_CANCELS) {
|
|
129
|
+
for (const [k, when] of cancelsByThread) {
|
|
130
|
+
if (at - when.at > CANCEL_MEMORY_MS) cancelsByThread.delete(k)
|
|
120
131
|
}
|
|
121
|
-
while (
|
|
122
|
-
const oldest =
|
|
132
|
+
while (cancelsByThread.size > MAX_REMEMBERED_CANCELS) {
|
|
133
|
+
const oldest = cancelsByThread.keys().next()
|
|
123
134
|
if (oldest.done) break
|
|
124
|
-
|
|
135
|
+
cancelsByThread.delete(oldest.value)
|
|
125
136
|
}
|
|
126
137
|
}
|
|
127
138
|
}
|
|
128
139
|
|
|
129
140
|
export function threadCancelledAt(provider: string, threadId: string): number | null {
|
|
130
|
-
return
|
|
141
|
+
return cancelsByThread.get(cancelKey(provider, threadId))?.at ?? null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 6.53.3: the cancel on record for the thread, with its target; null when none. A copy. */
|
|
145
|
+
export function threadCancel(provider: string, threadId: string): ThreadCancel | null {
|
|
146
|
+
const found = cancelsByThread.get(cancelKey(provider, threadId))
|
|
147
|
+
return found ? { at: found.at, target: found.target } : null
|
|
131
148
|
}
|
|
132
149
|
|
|
133
150
|
/** Until when the thread's parked turns hold, or null when no cancel is on record. */
|
|
@@ -151,8 +168,49 @@ export function cancelEndsTurn(cancelledAt: number | null, lastRowAt: number | n
|
|
|
151
168
|
return cancelledAt >= lastRowAt
|
|
152
169
|
}
|
|
153
170
|
|
|
171
|
+
/** What `cancelVoidsOpenTurn` weighs for one piece of open-turn evidence. */
|
|
172
|
+
export interface OpenTurnEvidence {
|
|
173
|
+
/** The cancel on record for the thread (`threadCancel`), or null. */
|
|
174
|
+
cancel: ThreadCancel | null
|
|
175
|
+
/** When the evidence was last written: the hook's newest event, or the transcript mtime. */
|
|
176
|
+
lastActivityAt: number | null
|
|
177
|
+
/** When the turn the evidence belongs to STARTED (the hook's UserPromptSubmit); null when unknown. */
|
|
178
|
+
turnStartedAt: number | null
|
|
179
|
+
/**
|
|
180
|
+
* When the desk session's own engine said the run is over, as it stands NOW: the
|
|
181
|
+
* registry's `idle` flip (`statusUpdatedAt`), or the hooks' Stop / StopFailure /
|
|
182
|
+
* SessionEnd while no turn is open. Null when nothing says so.
|
|
183
|
+
*/
|
|
184
|
+
deskEndedAt: number | null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 6.53.3 (review A1): may the cancel on record discount evidence that the thread's turn
|
|
189
|
+
* is still OPEN?
|
|
190
|
+
*
|
|
191
|
+
* cos_turn COS killed its own child. Nothing written since the cancel means the turn
|
|
192
|
+
* is over: the dangling `tool_use` is the dead child's (6.53.0, `cancelEndsTurn`).
|
|
193
|
+
* desk_run A halt marker stops a desk run only at its NEXT tool call, so a run inside a
|
|
194
|
+
* long tool writes nothing for minutes and is still running. Silence proves
|
|
195
|
+
* nothing here. Only the engine's own end, stamped at or after the cancel and
|
|
196
|
+
* after the turn began, closes it. Until 6.53.2 the cos_turn rule applied to
|
|
197
|
+
* both, and a turn parked behind a cancelled desk run was delivered live into
|
|
198
|
+
* the run the person had just stopped (and its UserPromptSubmit then deleted
|
|
199
|
+
* the marker, so the cancel was lost).
|
|
200
|
+
* null An older caller that did not say: read as desk_run, which concludes least.
|
|
201
|
+
*/
|
|
202
|
+
export function cancelVoidsOpenTurn(evidence: OpenTurnEvidence): boolean {
|
|
203
|
+
const { cancel } = evidence
|
|
204
|
+
if (cancel === null || !Number.isFinite(cancel.at)) return false
|
|
205
|
+
if (cancel.target === 'cos_turn') return cancelEndsTurn(cancel.at, evidence.lastActivityAt)
|
|
206
|
+
const ended = evidence.deskEndedAt
|
|
207
|
+
if (ended === null || !Number.isFinite(ended) || ended < cancel.at) return false
|
|
208
|
+
const started = evidence.turnStartedAt
|
|
209
|
+
return started === null || !Number.isFinite(started) || ended >= started
|
|
210
|
+
}
|
|
211
|
+
|
|
154
212
|
export function __resetThreadCancelsForTests(): void {
|
|
155
|
-
|
|
213
|
+
cancelsByThread.clear()
|
|
156
214
|
}
|
|
157
215
|
|
|
158
216
|
// ---------------------------------------------------------------------------
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
// - a sweep deletes anything older than HALT_MARKER_TTL_MS.
|
|
29
29
|
// It is NOT deleted on Stop: a background subagent reports its parent's session id (C8),
|
|
30
30
|
// and it is exactly the work a cancel must also stop.
|
|
31
|
+
//
|
|
32
|
+
// 6.53.3: ONE exception to "a new prompt clears it": a turn COS itself handed into the open
|
|
33
|
+
// session while a cancel was on its way (`haltDeliveredTurn`). That turn's own prompt must
|
|
34
|
+
// not be the one that frees it, so the server writes the marker back, once, when it sees it.
|
|
31
35
|
|
|
32
36
|
import { mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
33
37
|
import { join } from 'node:path'
|
|
@@ -105,6 +109,96 @@ export function clearHaltMarkerOnSessionEnd(sessionId: string, endedAt: number,
|
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// 6.53.3 (review A6): a cancel that landed while COS was HANDING a turn to the open session
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
//
|
|
116
|
+
// The live hop writes the turn into the running session's inbox, where it waits its turn
|
|
117
|
+
// (a busy session) or starts at once (an idle one). Once it has landed, the only stop is
|
|
118
|
+
// this marker. But the hook deletes the marker on UserPromptSubmit, and the delivered
|
|
119
|
+
// turn's OWN UserPromptSubmit may come after the marker is written: a busy session takes
|
|
120
|
+
// the message only when its current run ends (which the marker itself brings about). A
|
|
121
|
+
// plain write would then be deleted by the very turn it was written to stop.
|
|
122
|
+
//
|
|
123
|
+
// So the marker is written, and when the delivered turn has not visibly started yet, it is
|
|
124
|
+
// written AGAIN, once, on the first UserPromptSubmit for that session whose prompt carries
|
|
125
|
+
// the delivered text. Matching the text keeps the person's own next desk prompt from ever
|
|
126
|
+
// being the one that re-arms it; the window is the marker's own hour; SessionEnd drops it.
|
|
127
|
+
// In memory on purpose: a restart forgets it, like every other hold here.
|
|
128
|
+
|
|
129
|
+
interface PendingRearm {
|
|
130
|
+
marker: HaltMarker
|
|
131
|
+
/** Text the delivered prompt carries (the peer-inbox acceptance marker). */
|
|
132
|
+
promptMarker: string
|
|
133
|
+
/** Prompts submitted before this are someone else's. */
|
|
134
|
+
after: number
|
|
135
|
+
until: number
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const pendingRearms = new Map<string, PendingRearm>()
|
|
139
|
+
const MAX_PENDING_REARMS = 64
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Write the marker for a turn COS just put into the open session. `rearm` is false when
|
|
143
|
+
* the turn has visibly started already (its UserPromptSubmit ran before this write, so
|
|
144
|
+
* nothing will delete the marker but the next prompt). False when the write failed; the
|
|
145
|
+
* caller must then not report the cancel as armed.
|
|
146
|
+
*/
|
|
147
|
+
export function haltDeliveredTurn(
|
|
148
|
+
sessionId: string,
|
|
149
|
+
marker: HaltMarker,
|
|
150
|
+
turn: { promptMarker: string; after: number; rearm: boolean; now?: number },
|
|
151
|
+
dir = haltDir(),
|
|
152
|
+
): boolean {
|
|
153
|
+
if (!writeHaltMarker(sessionId, marker, dir)) return false
|
|
154
|
+
const id = sessionId.toLowerCase()
|
|
155
|
+
if (!turn.rearm || typeof turn.promptMarker !== 'string' || turn.promptMarker.trim().length === 0) {
|
|
156
|
+
pendingRearms.delete(id)
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
159
|
+
const now = turn.now ?? Date.now()
|
|
160
|
+
pendingRearms.delete(id)
|
|
161
|
+
pendingRearms.set(id, { marker, promptMarker: turn.promptMarker, after: turn.after, until: now + HALT_MARKER_TTL_MS })
|
|
162
|
+
while (pendingRearms.size > MAX_PENDING_REARMS) {
|
|
163
|
+
const oldest = pendingRearms.keys().next()
|
|
164
|
+
if (oldest.done) break
|
|
165
|
+
pendingRearms.delete(oldest.value)
|
|
166
|
+
}
|
|
167
|
+
return true
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A UserPromptSubmit from the session itself (never a COS child): the hook has just deleted
|
|
172
|
+
* the session's marker. When it was the delivered turn's own prompt, write the marker back.
|
|
173
|
+
* One shot. True when the marker was re-armed.
|
|
174
|
+
*/
|
|
175
|
+
export function rearmHaltOnPrompt(sessionId: string, promptAt: number, prompt: unknown, dir = haltDir(), now = Date.now()): boolean {
|
|
176
|
+
const id = typeof sessionId === 'string' ? sessionId.toLowerCase() : ''
|
|
177
|
+
const pending = pendingRearms.get(id)
|
|
178
|
+
if (!pending) return false
|
|
179
|
+
if (now > pending.until) {
|
|
180
|
+
pendingRearms.delete(id)
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
if (!Number.isFinite(promptAt) || promptAt < pending.after) return false
|
|
184
|
+
if (typeof prompt !== 'string' || !prompt.includes(pending.promptMarker)) return false
|
|
185
|
+
pendingRearms.delete(id)
|
|
186
|
+
return writeHaltMarker(id, pending.marker, dir)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The session ended: nothing is left to stop. */
|
|
190
|
+
export function dropHaltRearm(sessionId: string): void {
|
|
191
|
+
if (typeof sessionId === 'string') pendingRearms.delete(sessionId.toLowerCase())
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function hasPendingHaltRearm(sessionId: string): boolean {
|
|
195
|
+
return typeof sessionId === 'string' && pendingRearms.has(sessionId.toLowerCase())
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function __resetHaltRearmsForTests(): void {
|
|
199
|
+
pendingRearms.clear()
|
|
200
|
+
}
|
|
201
|
+
|
|
108
202
|
export function hasHaltMarker(sessionId: string, dir = haltDir()): boolean {
|
|
109
203
|
const path = haltMarkerPath(sessionId, dir)
|
|
110
204
|
if (path === null) return false
|
|
@@ -17,7 +17,8 @@ import { SessionHookLedger } from './session-hook-ledger.js'
|
|
|
17
17
|
import { startSpoolIngester, type SpoolIngester, type SpoolStats } from './session-hook-spool.js'
|
|
18
18
|
import { SessionSignalStore, type SessionSignal } from './session-signal-store.js'
|
|
19
19
|
import { OPEN_TURN_CEILING_MS, deriveSessionState, type DerivedSessionState, type RegistryFacts, type TranscriptFacts } from './session-state-derive.js'
|
|
20
|
-
import { clearHaltMarkerOnSessionEnd, sweepHaltMarkers } from './session-halt.js'
|
|
20
|
+
import { clearHaltMarkerOnSessionEnd, dropHaltRearm, haltDeliveredTurn, rearmHaltOnPrompt, sweepHaltMarkers, type HaltMarker } from './session-halt.js'
|
|
21
|
+
import { cancelVoidsOpenTurn, threadCancel } from './session-cancel.js'
|
|
21
22
|
import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
|
|
22
23
|
|
|
23
24
|
export function sessionHooksEnabled(): boolean {
|
|
@@ -173,6 +174,29 @@ function pidAlive(pid: number): boolean {
|
|
|
173
174
|
}
|
|
174
175
|
}
|
|
175
176
|
|
|
177
|
+
/**
|
|
178
|
+
* 6.53.3: when the desk session's own engine said its run is over, AS IT STANDS NOW: the
|
|
179
|
+
* registry's `idle` flip (`statusUpdatedAt`), or the hooks' Stop / StopFailure / SessionEnd
|
|
180
|
+
* while no turn is open. Null when neither says so (a busy record, an open hook turn, no
|
|
181
|
+
* record at all). What a desk_run cancel waits for before it may discount the evidence
|
|
182
|
+
* that the run is still going (`cancelVoidsOpenTurn`, review A1).
|
|
183
|
+
*/
|
|
184
|
+
export function deskTurnEndedAt(sessionId: string, dir = claudeSessionsDir()): number | null {
|
|
185
|
+
let ended: number | null = null
|
|
186
|
+
const later = (at: unknown): void => {
|
|
187
|
+
if (typeof at === 'number' && Number.isFinite(at) && (ended === null || at > ended)) ended = at
|
|
188
|
+
}
|
|
189
|
+
const signal = signalFor(sessionId)
|
|
190
|
+
if (signal && !signal.turnOpen) {
|
|
191
|
+
later(signal.stopAt)
|
|
192
|
+
later(signal.failure?.at)
|
|
193
|
+
later(signal.ended?.at)
|
|
194
|
+
}
|
|
195
|
+
const record = registryRecordSync(sessionId, dir)
|
|
196
|
+
if (record && record.status === 'idle') later(record.statusUpdatedAt)
|
|
197
|
+
return ended
|
|
198
|
+
}
|
|
199
|
+
|
|
176
200
|
/**
|
|
177
201
|
* 6.53.0: is Claude working on this session AT THE DESK right now (a Desktop tab, a terminal,
|
|
178
202
|
* or a Continue delivered live into either)? The cancel route's `deskRunning`: a yes offers
|
|
@@ -183,15 +207,40 @@ function pidAlive(pid: number): boolean {
|
|
|
183
207
|
* own in-flight turn) says `busy`, and its pid is alive.
|
|
184
208
|
* Canary C9 (2026-09-21): after a hook stop the registry read idle within a second, so a
|
|
185
209
|
* cancelled desk run stops reading as running at once.
|
|
210
|
+
*
|
|
211
|
+
* 6.53.3 (review A5): the hooks' open turn is weighed against the cancel on record, by the
|
|
212
|
+
* rule the turn queue uses. A `claude -p` COS child killed by a cancel fires no Stop or
|
|
213
|
+
* SessionEnd; its open turn with nothing since the cancel is the dead child's, and must not
|
|
214
|
+
* put a phantom "Cancel run" (desk_run) on a thread where nothing runs.
|
|
186
215
|
*/
|
|
187
216
|
export function claudeDeskRunning(sessionId: string, now = Date.now(), dir = claudeSessionsDir()): boolean {
|
|
188
217
|
const signal = signalFor(sessionId)
|
|
189
|
-
if (signal && signal.turnOpen && signal.ended === null && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS)
|
|
218
|
+
if (signal && signal.turnOpen && signal.ended === null && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS) {
|
|
219
|
+
const cancel = threadCancel('claude', sessionId)
|
|
220
|
+
if (cancel === null || !cancelVoidsOpenTurn({
|
|
221
|
+
cancel,
|
|
222
|
+
lastActivityAt: signal.lastEventAt,
|
|
223
|
+
turnStartedAt: signal.turnStartedAt,
|
|
224
|
+
deskEndedAt: deskTurnEndedAt(sessionId, dir),
|
|
225
|
+
})) return true
|
|
226
|
+
}
|
|
190
227
|
const record = registryRecordSync(sessionId, dir)
|
|
191
228
|
if (!record || record.status !== 'busy' || record.pid === null || isCosSpawnedPid(record.pid, now)) return false
|
|
192
229
|
return pidAlive(record.pid)
|
|
193
230
|
}
|
|
194
231
|
|
|
232
|
+
/**
|
|
233
|
+
* 6.53.3 (review A6): stop a turn COS has just handed into the open session, because a
|
|
234
|
+
* cancel arrived while the hand-off was in flight. The marker is written now; when the
|
|
235
|
+
* hooks do not yet show the turn started (its UserPromptSubmit at or after `sentAt`), it is
|
|
236
|
+
* re-armed once when that prompt's own UserPromptSubmit deletes it (`haltDeliveredTurn`).
|
|
237
|
+
*/
|
|
238
|
+
export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: { promptMarker: string; sentAt: number }, now = Date.now()): boolean {
|
|
239
|
+
const signal = signalFor(sessionId)
|
|
240
|
+
const started = !!signal && typeof signal.turnStartedAt === 'number' && signal.turnStartedAt >= turn.sentAt
|
|
241
|
+
return haltDeliveredTurn(sessionId, marker, { promptMarker: turn.promptMarker, after: turn.sentAt, rearm: !started, now })
|
|
242
|
+
}
|
|
243
|
+
|
|
195
244
|
let ledger: SessionHookLedger | null = null
|
|
196
245
|
let ingester: SpoolIngester | null = null
|
|
197
246
|
let replayed: { rows: number; applied: number } | null = null
|
|
@@ -233,6 +282,10 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
|
|
|
233
282
|
// clear a cancel written since; the TTL sweep covers anything this misses.
|
|
234
283
|
const unsubscribeHalt = enabled ? sessionSignalStore.subscribe((_signal, env, child) => {
|
|
235
284
|
if (env.event === 'SessionEnd' && !child) clearHaltMarkerOnSessionEnd(env.sessionId, env.ts)
|
|
285
|
+
// 6.53.3 (review A6): a turn COS handed into the open session while a cancel was on its
|
|
286
|
+
// way. Its own UserPromptSubmit just deleted the marker; write it back (once).
|
|
287
|
+
if (env.event === 'UserPromptSubmit' && !child) rearmHaltOnPrompt(env.sessionId, env.ts, env.payload.prompt)
|
|
288
|
+
if (env.event === 'SessionEnd' && !child) dropHaltRearm(env.sessionId)
|
|
236
289
|
}) : () => {}
|
|
237
290
|
sweepHaltMarkers(Date.now())
|
|
238
291
|
// Runtime files the script reads. The port can change per install; the token never does.
|