@gotcos/glasses-server 6.52.3 → 6.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/bin/cli.cjs +4 -2
- package/bin/hooks/cos-session-hook +50 -1
- package/package.json +1 -1
- package/server/index.ts +36 -2
- package/server/lib/attached-provider-adapter.ts +91 -7
- package/server/lib/claude-hooks-installer.ts +10 -1
- package/server/lib/cursor-model-catalog.ts +45 -12
- package/server/lib/permission-broker.ts +48 -0
- package/server/lib/session-cancel.ts +190 -0
- package/server/lib/session-halt.ts +156 -0
- package/server/lib/session-hooks-runtime.ts +45 -2
- package/server/lib/thread-turn-queue.ts +9 -0
- package/server/routes/agent-session-bindings.ts +322 -6
- package/server/routes/health.ts +6 -1
- package/server/routes/thread-turn-queue.ts +16 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Cancel a session run from the lens (6.53.0): the decisions, the copy, the ledger, and
|
|
2
|
+
// the per-thread cancel time the turn queue reads.
|
|
3
|
+
//
|
|
4
|
+
// Miles, 2026-09-21: "a cancel button ... If there's a current run that's going, it
|
|
5
|
+
// essentially clicks the cancel button ... similar to when they're at the desktop."
|
|
6
|
+
//
|
|
7
|
+
// WHO OWNS THE RUN DECIDES WHAT A CANCEL CAN DO (plan PLAN_g2_cancel_run_529_653):
|
|
8
|
+
// cos_turn a Continue COS itself spawned (`claude -p --resume`, `codex exec resume`,
|
|
9
|
+
// `cursor-agent`). The route aborts its own child: immediate, every provider.
|
|
10
|
+
// desk_run Claude Code at the desk (a Desktop tab, a terminal, a Continue delivered
|
|
11
|
+
// live into the open window). A halt marker plus the synchronous PreToolUse
|
|
12
|
+
// hook stops it at its NEXT tool call (canaries C1b, C5, C10).
|
|
13
|
+
// unsupported the Codex app or Cursor. Nothing outside the app can reach the run.
|
|
14
|
+
// hooks_* a desk Claude run on a Mac whose hooks cannot stop it yet.
|
|
15
|
+
//
|
|
16
|
+
// `cancelTargetFor` is the ONE function that decides, and both the cancel route and the
|
|
17
|
+
// attachability body's `cancel` field call it, so the row the lens shows and the answer
|
|
18
|
+
// the tap gets cannot disagree (plan validation B4).
|
|
19
|
+
|
|
20
|
+
import { appendFileSync, chmodSync, mkdirSync } from 'node:fs'
|
|
21
|
+
import { dirname } from 'node:path'
|
|
22
|
+
import { dataPath } from './data-dir.js'
|
|
23
|
+
|
|
24
|
+
export type CancelTarget = 'cos_turn' | 'desk_run' | 'unsupported' | 'hooks_outdated' | 'hooks_disabled'
|
|
25
|
+
|
|
26
|
+
export interface CancelFacts {
|
|
27
|
+
provider: string
|
|
28
|
+
/** A COS turn is in flight on this thread (the route's own in-flight map). */
|
|
29
|
+
cosTurnInFlight: boolean
|
|
30
|
+
/** A run outside COS is working on this thread right now. */
|
|
31
|
+
runningOutsideCos: boolean
|
|
32
|
+
/** `sessionHooksEnabled()`: the hook signal store is applied at all. */
|
|
33
|
+
hooksEnabled: boolean
|
|
34
|
+
/** The hooks installed on this Mac carry the 6.53.0 script and subscription. */
|
|
35
|
+
hooksReady: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* PURE, and the precedence is the point:
|
|
40
|
+
* 1. COS's own turn first. It is the one run COS can end at once, and a marker would
|
|
41
|
+
* also stop it only at its next tool (R3), so aborting the child is always better.
|
|
42
|
+
* 2. Nothing running: null. The lens shows no row at all.
|
|
43
|
+
* 3. Codex and Cursor cannot be stopped from outside their app.
|
|
44
|
+
* 4. A desk Claude run needs the hooks applied AND the new script installed; either
|
|
45
|
+
* missing is its own answer, because the fixes differ (a setting vs Install hooks).
|
|
46
|
+
*/
|
|
47
|
+
export function cancelTargetFor(facts: CancelFacts): CancelTarget | null {
|
|
48
|
+
if (facts.cosTurnInFlight === true) return 'cos_turn'
|
|
49
|
+
if (facts.runningOutsideCos !== true) return null
|
|
50
|
+
if (facts.provider !== 'claude') return 'unsupported'
|
|
51
|
+
if (facts.hooksEnabled !== true) return 'hooks_disabled'
|
|
52
|
+
if (facts.hooksReady !== true) return 'hooks_outdated'
|
|
53
|
+
return 'desk_run'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `/api/health` `features.sessionCancel` (6.53.0). `cosTurn` is true in every 6.53 build: the
|
|
58
|
+
* route aborts its own child. `deskClaude` says whether a desk Claude run can be stopped
|
|
59
|
+
* NOW: the hooks applied AND the 6.53 script and subscription installed. False until
|
|
60
|
+
* Install hooks after the update, which is the rollout step Control's banner asks for.
|
|
61
|
+
*/
|
|
62
|
+
export function sessionCancelFeature(hooksEnabled: boolean, hooksInstalled: boolean): { cosTurn: true; deskClaude: boolean } {
|
|
63
|
+
return { cosTurn: true, deskClaude: hooksEnabled === true && hooksInstalled === true }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Refusal codes on the cancel route, with the lens's own words (app 6.9.529 matches). */
|
|
67
|
+
export type CancelRefusal = 'not_running' | 'cancel_unsupported' | 'hooks_outdated' | 'hooks_disabled' | 'cancel_failed' | 'invalid_request'
|
|
68
|
+
|
|
69
|
+
export const CANCEL_REASON_COPY: Record<Exclude<CancelRefusal, 'cancel_unsupported'>, string> = {
|
|
70
|
+
not_running: 'Nothing running now.',
|
|
71
|
+
hooks_outdated: 'Install hooks in COS Control, then retry.',
|
|
72
|
+
hooks_disabled: 'Session hooks are off on the Mac.',
|
|
73
|
+
cancel_failed: 'Could not cancel. Check the Mac.',
|
|
74
|
+
invalid_request: 'That cancel was not something COS could read. Nothing was stopped.',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Where an unsupported run lives, named so the user knows where to stop it. */
|
|
78
|
+
export function cancelUnsupportedCopy(provider: string): string {
|
|
79
|
+
if (provider === 'codex') return 'Runs in Codex on your Mac. Stop it there.'
|
|
80
|
+
if (provider === 'cursor') return 'Runs in Cursor on your Mac. Stop it there.'
|
|
81
|
+
return 'Runs on your Mac. Stop it there.'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function cancelRefusalCopy(reason: CancelRefusal, provider: string): string {
|
|
85
|
+
return reason === 'cancel_unsupported' ? cancelUnsupportedCopy(provider) : CANCEL_REASON_COPY[reason]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Client-supplied idempotency key for one tap. The app mints `cc<time><random>`. */
|
|
89
|
+
export const CLIENT_CANCEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{3,127}$/
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// The queue hold (plan validation: "Parked turns hold for 2 minutes after a cancel")
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How long turns parked on a thread wait after a cancel before they send as normal. The
|
|
97
|
+
* person just said stop; a queued follow-up firing a second later would start the run
|
|
98
|
+
* they stopped all over again. Two minutes is long enough to decide, short enough that
|
|
99
|
+
* a turn they still want is not lost, and the lens says how many are held.
|
|
100
|
+
*/
|
|
101
|
+
export const CANCEL_QUEUE_HOLD_MS = 2 * 60_000
|
|
102
|
+
|
|
103
|
+
/** Past this a cancel time can no longer change a decision (the open-turn ceiling is 30 min). */
|
|
104
|
+
export const CANCEL_MEMORY_MS = 60 * 60_000
|
|
105
|
+
const MAX_REMEMBERED_CANCELS = 256
|
|
106
|
+
|
|
107
|
+
// In memory on purpose: every reader is a hold of minutes, and a restart that forgets
|
|
108
|
+
// one lets the queue drain by the gate's ordinary rules, which is the pre-6.53 behaviour.
|
|
109
|
+
const cancelledAtByThread = new Map<string, number>()
|
|
110
|
+
const cancelKey = (provider: string, threadId: string) => `${provider}:${threadId.toLowerCase()}`
|
|
111
|
+
|
|
112
|
+
export function noteThreadCancelled(provider: string, threadId: string, at: number): void {
|
|
113
|
+
if (!Number.isFinite(at)) return
|
|
114
|
+
const key = cancelKey(provider, threadId)
|
|
115
|
+
cancelledAtByThread.delete(key)
|
|
116
|
+
cancelledAtByThread.set(key, at)
|
|
117
|
+
if (cancelledAtByThread.size > MAX_REMEMBERED_CANCELS) {
|
|
118
|
+
for (const [k, when] of cancelledAtByThread) {
|
|
119
|
+
if (at - when > CANCEL_MEMORY_MS) cancelledAtByThread.delete(k)
|
|
120
|
+
}
|
|
121
|
+
while (cancelledAtByThread.size > MAX_REMEMBERED_CANCELS) {
|
|
122
|
+
const oldest = cancelledAtByThread.keys().next()
|
|
123
|
+
if (oldest.done) break
|
|
124
|
+
cancelledAtByThread.delete(oldest.value)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function threadCancelledAt(provider: string, threadId: string): number | null {
|
|
130
|
+
return cancelledAtByThread.get(cancelKey(provider, threadId)) ?? null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Until when the thread's parked turns hold, or null when no cancel is on record. */
|
|
134
|
+
export function cancelHoldUntil(provider: string, threadId: string): number | null {
|
|
135
|
+
const at = threadCancelledAt(provider, threadId)
|
|
136
|
+
return at === null ? null : at + CANCEL_QUEUE_HOLD_MS
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Does a cancel END the turn the transcript or the hooks still show open?
|
|
141
|
+
*
|
|
142
|
+
* Yes when the cancel is at least as new as the session's last row (or event): nothing
|
|
143
|
+
* has happened since the stop, so a dangling `tool_use` is the killed child's, not a live
|
|
144
|
+
* tool. Without this a cancelled COS turn's unanswered tool call reads `tool_pending` for
|
|
145
|
+
* the whole 30-minute open-turn ceiling and holds every parked turn behind it (plan R1).
|
|
146
|
+
* Anything written AFTER the cancel is live evidence again, and the ordinary rules decide.
|
|
147
|
+
*/
|
|
148
|
+
export function cancelEndsTurn(cancelledAt: number | null, lastRowAt: number | null): boolean {
|
|
149
|
+
if (cancelledAt === null || !Number.isFinite(cancelledAt)) return false
|
|
150
|
+
if (lastRowAt === null || !Number.isFinite(lastRowAt)) return true
|
|
151
|
+
return cancelledAt >= lastRowAt
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function __resetThreadCancelsForTests(): void {
|
|
155
|
+
cancelledAtByThread.clear()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// The ledger
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export interface SessionCancelLedgerRow {
|
|
163
|
+
at: string
|
|
164
|
+
provider: string
|
|
165
|
+
threadId: string
|
|
166
|
+
/** What the cancel was aimed at, or null when nothing was running. */
|
|
167
|
+
target: CancelTarget | null
|
|
168
|
+
/** `accepted`, or the refusal code. */
|
|
169
|
+
outcome: 'accepted' | CancelRefusal
|
|
170
|
+
clientCancelId: string
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function sessionCancelLedgerPath(): string {
|
|
174
|
+
return dataPath('session-cancel.jsonl')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* One line per cancel, appended (O_APPEND: a line either lands whole or not at all, as the
|
|
179
|
+
* other jsonl ledgers here). A failed write is logged and dropped: a ledger must never turn
|
|
180
|
+
* a cancel that worked into an error.
|
|
181
|
+
*/
|
|
182
|
+
export function appendSessionCancelLedger(row: SessionCancelLedgerRow, path = sessionCancelLedgerPath()): void {
|
|
183
|
+
try {
|
|
184
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
185
|
+
appendFileSync(path, `${JSON.stringify(row)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
186
|
+
chmodSync(path, 0o600)
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.warn(`[session-cancel] ledger write skipped: ${error instanceof Error ? (error as NodeJS.ErrnoException).code ?? error.message : error}`)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Halt markers: how a cancel from the lens stops a Claude run at the desk (6.53.0).
|
|
2
|
+
//
|
|
3
|
+
// Miles, 2026-09-21: "If there's a current run that's going, it essentially clicks the
|
|
4
|
+
// cancel button ... similar to when they're at the desktop."
|
|
5
|
+
//
|
|
6
|
+
// COS has no handle on a Desktop tab or a terminal `claude`: nothing it can signal, no
|
|
7
|
+
// socket that takes an interrupt. What it does have is the synchronous PreToolUse hook
|
|
8
|
+
// every Claude session on this Mac runs (`bin/hooks/cos-session-hook`). So a cancel is a
|
|
9
|
+
// FILE: `<data>/session-halt/<session id>`. The hook tests for it before every tool call
|
|
10
|
+
// and, when it is there, prints the deny plus `continue: false` that canary C1b proved
|
|
11
|
+
// stops the run. The run stops at its NEXT tool call; a reply that is pure text finishes
|
|
12
|
+
// first, and the lens copy says "at its next step" for that reason.
|
|
13
|
+
//
|
|
14
|
+
// WHERE. `dataPath('session-halt')`, beside the spool: the hook finds the folder as
|
|
15
|
+
// `$(dirname "$SPOOL")/session-halt`, exactly as it finds the Cursor queue folder, and the
|
|
16
|
+
// spool is `<data>/hook-spool` (claude-hooks-installer.ts `hookSpoolDir`). The two agree
|
|
17
|
+
// for the same reason the queue folder does.
|
|
18
|
+
//
|
|
19
|
+
// THE NAME IS THE LOWERCASE SESSION ID, validated. It becomes a path component here and a
|
|
20
|
+
// `[ -f ]` test in a shell script, and the script lowercases what it reads, so an upper-case
|
|
21
|
+
// file would never match and a malformed one must never be written at all.
|
|
22
|
+
//
|
|
23
|
+
// LIFETIME. A marker lives only within the run it was written for:
|
|
24
|
+
// - the hook deletes it itself on UserPromptSubmit (a new desk prompt, a Continue
|
|
25
|
+
// delivered into the open session, and a COS `-p` turn all fire it), so a new prompt
|
|
26
|
+
// is never stopped;
|
|
27
|
+
// - the server deletes it on that session's SessionEnd (session-hooks-runtime.ts);
|
|
28
|
+
// - a sweep deletes anything older than HALT_MARKER_TTL_MS.
|
|
29
|
+
// It is NOT deleted on Stop: a background subagent reports its parent's session id (C8),
|
|
30
|
+
// and it is exactly the work a cancel must also stop.
|
|
31
|
+
|
|
32
|
+
import { mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
33
|
+
import { join } from 'node:path'
|
|
34
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
35
|
+
import { dataPath } from './data-dir.js'
|
|
36
|
+
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How long a marker may outlive its cancel. An hour, not the plan's first 15 minutes: the
|
|
40
|
+
* hook deliberately ignores age (a long build must still be cancellable), so this sweep is
|
|
41
|
+
* the only thing that ends a marker whose session neither prompted again nor ended.
|
|
42
|
+
*/
|
|
43
|
+
export const HALT_MARKER_TTL_MS = 60 * 60_000
|
|
44
|
+
|
|
45
|
+
export interface HaltMarker {
|
|
46
|
+
at: number
|
|
47
|
+
clientCancelId: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function haltDir(): string {
|
|
51
|
+
return dataPath('session-halt')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The marker's path, or null for anything that is not a full native session id. */
|
|
55
|
+
export function haltMarkerPath(sessionId: string, dir = haltDir()): string | null {
|
|
56
|
+
const id = typeof sessionId === 'string' ? sessionId.toLowerCase() : ''
|
|
57
|
+
return isValidNativeThreadId(id) ? join(dir, id) : null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Write (or refresh) the marker for one session. Atomic: the hook must never read a torn
|
|
62
|
+
* file, though it only tests existence. False when the id is not a session id or the write
|
|
63
|
+
* failed; the caller must then not report the cancel as accepted.
|
|
64
|
+
*/
|
|
65
|
+
export function writeHaltMarker(sessionId: string, marker: HaltMarker, dir = haltDir()): boolean {
|
|
66
|
+
const path = haltMarkerPath(sessionId, dir)
|
|
67
|
+
if (path === null) return false
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
70
|
+
atomicWriteFileSync(path, `${JSON.stringify({ at: marker.at, clientCancelId: marker.clientCancelId })}\n`, { mode: 0o600 })
|
|
71
|
+
return true
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error(`[session-halt] write failed: ${error instanceof Error ? (error as NodeJS.ErrnoException).code ?? error.message : error}`)
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** True when a marker was removed. A missing one is not an error. */
|
|
79
|
+
export function clearHaltMarker(sessionId: string, dir = haltDir()): boolean {
|
|
80
|
+
const path = haltMarkerPath(sessionId, dir)
|
|
81
|
+
if (path === null) return false
|
|
82
|
+
try {
|
|
83
|
+
unlinkSync(path)
|
|
84
|
+
return true
|
|
85
|
+
} catch {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Clear the marker because the SESSION ended, but only a marker written at or before the
|
|
92
|
+
* event. A SessionEnd drained late from the spool must not remove a cancel written after
|
|
93
|
+
* it for the same session id (a resumed tab reuses its id).
|
|
94
|
+
*/
|
|
95
|
+
export function clearHaltMarkerOnSessionEnd(sessionId: string, endedAt: number, dir = haltDir()): boolean {
|
|
96
|
+
const path = haltMarkerPath(sessionId, dir)
|
|
97
|
+
if (path === null) return false
|
|
98
|
+
const at = markerAt(path)
|
|
99
|
+
if (at === null || !Number.isFinite(endedAt) || at > endedAt) return false
|
|
100
|
+
try {
|
|
101
|
+
unlinkSync(path)
|
|
102
|
+
return true
|
|
103
|
+
} catch {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function hasHaltMarker(sessionId: string, dir = haltDir()): boolean {
|
|
109
|
+
const path = haltMarkerPath(sessionId, dir)
|
|
110
|
+
if (path === null) return false
|
|
111
|
+
try {
|
|
112
|
+
return statSync(path).isFile()
|
|
113
|
+
} catch {
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The marker's own `at`, else the file's mtime; null when neither can be read. */
|
|
119
|
+
function markerAt(path: string): number | null {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { at?: unknown }
|
|
122
|
+
if (typeof parsed.at === 'number' && Number.isFinite(parsed.at)) return parsed.at
|
|
123
|
+
} catch { /* fall back to the clock the filesystem kept */ }
|
|
124
|
+
try {
|
|
125
|
+
return statSync(path).mtimeMs
|
|
126
|
+
} catch {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Remove every marker older than `ttlMs`, and any leftover the atomic write abandoned.
|
|
133
|
+
* Returns how many were removed. A marker dated in the future (a clock step) is judged by
|
|
134
|
+
* its mtime instead, so it cannot live for ever.
|
|
135
|
+
*/
|
|
136
|
+
export function sweepHaltMarkers(now: number, ttlMs = HALT_MARKER_TTL_MS, dir = haltDir()): number {
|
|
137
|
+
let names: string[]
|
|
138
|
+
try { names = readdirSync(dir) } catch { return 0 }
|
|
139
|
+
let removed = 0
|
|
140
|
+
for (const name of names) {
|
|
141
|
+
const path = join(dir, name)
|
|
142
|
+
let at = markerAt(path)
|
|
143
|
+
if (at !== null && at > now) {
|
|
144
|
+
try { at = statSync(path).mtimeMs } catch { at = null }
|
|
145
|
+
}
|
|
146
|
+
// A name the server would never write (a `.tmp` from a crashed write, anything else)
|
|
147
|
+
// is removed on sight: nothing reads it, and the hook's glob would keep forking for it.
|
|
148
|
+
const foreign = !isValidNativeThreadId(name)
|
|
149
|
+
if (!foreign && at !== null && now - at < ttlMs) continue
|
|
150
|
+
try {
|
|
151
|
+
unlinkSync(path)
|
|
152
|
+
removed++
|
|
153
|
+
} catch { /* already gone, or a directory: leave it */ }
|
|
154
|
+
}
|
|
155
|
+
return removed
|
|
156
|
+
}
|
|
@@ -16,7 +16,8 @@ import { dataPath } from './data-dir.js'
|
|
|
16
16
|
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
|
-
import { deriveSessionState, type DerivedSessionState, type RegistryFacts, type TranscriptFacts } from './session-state-derive.js'
|
|
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
21
|
import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
|
|
21
22
|
|
|
22
23
|
export function sessionHooksEnabled(): boolean {
|
|
@@ -162,6 +163,35 @@ export function registryIdleAfterStop(sessionId: string, stopAt: number, dir = c
|
|
|
162
163
|
return record.status === 'idle' && typeof record.statusUpdatedAt === 'number' && record.statusUpdatedAt >= stopAt
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
/** `kill(pid, 0)`: EPERM is a live process we may not signal; only ESRCH is gone. */
|
|
167
|
+
function pidAlive(pid: number): boolean {
|
|
168
|
+
try {
|
|
169
|
+
process.kill(pid, 0)
|
|
170
|
+
return true
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 6.53.0: is Claude working on this session AT THE DESK right now (a Desktop tab, a terminal,
|
|
178
|
+
* or a Continue delivered live into either)? The cancel route's `deskRunning`: a yes offers
|
|
179
|
+
* `desk_run`, which arms a halt marker. Two witnesses, either one is enough:
|
|
180
|
+
* - the hooks: the turn is open, the session has not ended, and the newest event is inside
|
|
181
|
+
* the open-turn ceiling (a tab that died mid-turn must not read running for ever);
|
|
182
|
+
* - the registry: the session's own record (never a COS child's: that run is the route's
|
|
183
|
+
* own in-flight turn) says `busy`, and its pid is alive.
|
|
184
|
+
* Canary C9 (2026-09-21): after a hook stop the registry read idle within a second, so a
|
|
185
|
+
* cancelled desk run stops reading as running at once.
|
|
186
|
+
*/
|
|
187
|
+
export function claudeDeskRunning(sessionId: string, now = Date.now(), dir = claudeSessionsDir()): boolean {
|
|
188
|
+
const signal = signalFor(sessionId)
|
|
189
|
+
if (signal && signal.turnOpen && signal.ended === null && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS) return true
|
|
190
|
+
const record = registryRecordSync(sessionId, dir)
|
|
191
|
+
if (!record || record.status !== 'busy' || record.pid === null || isCosSpawnedPid(record.pid, now)) return false
|
|
192
|
+
return pidAlive(record.pid)
|
|
193
|
+
}
|
|
194
|
+
|
|
165
195
|
let ledger: SessionHookLedger | null = null
|
|
166
196
|
let ingester: SpoolIngester | null = null
|
|
167
197
|
let replayed: { rows: number; applied: number } | null = null
|
|
@@ -197,6 +227,14 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
|
|
|
197
227
|
if (entrypoint) sessionSignalStore.setEntrypoint(env.sessionId, entrypoint)
|
|
198
228
|
} : undefined,
|
|
199
229
|
})
|
|
230
|
+
// 6.53.0: a halt marker dies with its session. SessionEnd only, never Stop: a background
|
|
231
|
+
// subagent reports its parent's session id (canary C8) and is work a cancel must stop.
|
|
232
|
+
// Subscribed AFTER the replay above, so an old SessionEnd in the replay window cannot
|
|
233
|
+
// clear a cancel written since; the TTL sweep covers anything this misses.
|
|
234
|
+
const unsubscribeHalt = enabled ? sessionSignalStore.subscribe((_signal, env, child) => {
|
|
235
|
+
if (env.event === 'SessionEnd' && !child) clearHaltMarkerOnSessionEnd(env.sessionId, env.ts)
|
|
236
|
+
}) : () => {}
|
|
237
|
+
sweepHaltMarkers(Date.now())
|
|
200
238
|
// Runtime files the script reads. The port can change per install; the token never does.
|
|
201
239
|
// The script is copied only when MISSING here: a boot must never downgrade what a newer
|
|
202
240
|
// `--hooks install` put at the stable path.
|
|
@@ -206,12 +244,17 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
|
|
|
206
244
|
} catch (error) {
|
|
207
245
|
console.error(`[session-hooks] runtime files: ${error instanceof Error ? error.message : error}`)
|
|
208
246
|
}
|
|
209
|
-
|
|
247
|
+
// The same ten-minute tick sweeps halt markers past their hour (6.53.0).
|
|
248
|
+
const pruneTimer = setInterval(() => {
|
|
249
|
+
sessionSignalStore.prune()
|
|
250
|
+
sweepHaltMarkers(Date.now())
|
|
251
|
+
}, 10 * 60_000)
|
|
210
252
|
pruneTimer.unref()
|
|
211
253
|
return {
|
|
212
254
|
store: sessionSignalStore,
|
|
213
255
|
stop() {
|
|
214
256
|
clearInterval(pruneTimer)
|
|
257
|
+
unsubscribeHalt()
|
|
215
258
|
ingester?.stop()
|
|
216
259
|
},
|
|
217
260
|
}
|
|
@@ -201,6 +201,12 @@ export interface DrainObservation {
|
|
|
201
201
|
/** The gate's reason when `attachable` is false. Carried ONLY so a fence -- the one
|
|
202
202
|
* hold a clock cannot end -- can be given a longer life than a busy thread. */
|
|
203
203
|
reason?: string | null
|
|
204
|
+
/**
|
|
205
|
+
* 6.53.0: until when a cancel on this thread holds its parked turns (epoch ms), or
|
|
206
|
+
* null. The person just said stop; a follow-up parked behind the run must not start it
|
|
207
|
+
* again a second later. It holds, then sends as normal (CANCEL_QUEUE_HOLD_MS, 2 min).
|
|
208
|
+
*/
|
|
209
|
+
cancelHoldUntil?: number | null
|
|
204
210
|
}
|
|
205
211
|
|
|
206
212
|
export type DrainDecision = 'deliver' | 'hold' | 'expire' | 'give_up'
|
|
@@ -225,6 +231,9 @@ export function drainDecision(
|
|
|
225
231
|
const effectiveTtl = seen.reason === 'native_target_fenced' ? FENCE_HELD_TURN_TTL_MS : ttlMs
|
|
226
232
|
if (now - turn.queuedAt >= effectiveTtl) return 'expire'
|
|
227
233
|
if (turn.attempts >= MAX_DELIVERY_ATTEMPTS) return 'give_up'
|
|
234
|
+
// 6.53.0: AFTER the two retirements, so a hold can never keep alive a turn that ran out
|
|
235
|
+
// of time or tries; BEFORE the gate, because it is a hold whatever the gate says.
|
|
236
|
+
if (typeof seen.cancelHoldUntil === 'number' && Number.isFinite(seen.cancelHoldUntil) && now < seen.cancelHoldUntil) return 'hold'
|
|
228
237
|
// THE GATE, unweakened. Everything below is about WHEN, never about whether.
|
|
229
238
|
if (!seen.attachable) return 'hold'
|
|
230
239
|
// Turn-ended is the precise signal; idle is the backstop for a holder that wrote no
|