@gotcos/glasses-server 6.61.6 → 6.62.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/.env.example +6 -0
- package/CHANGELOG.md +41 -0
- package/README.md +5 -1
- package/bin/cli.cjs +50 -3
- package/bin/hooks/cos-cursor-observer.cjs +36 -0
- package/bin/hooks/cos-session-hook +46 -11
- package/docs/provider-parity.md +43 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +5 -2
- package/server/index.ts +135 -16
- package/server/lib/agent-session-binding-store.ts +9 -3
- package/server/lib/agent-session-store.ts +7 -4
- package/server/lib/attached-provider-adapter.ts +158 -45
- package/server/lib/banned-permission-args.ts +42 -1
- package/server/lib/claude-bridge.ts +17 -8
- package/server/lib/claude-hooks-installer.ts +28 -5
- package/server/lib/codex-bridge.ts +17 -2
- package/server/lib/codex-hooks-installer.ts +538 -0
- package/server/lib/codex-live-queue.ts +1 -1
- package/server/lib/codex-model-catalog.ts +49 -2
- package/server/lib/codex-rollout-state.ts +90 -0
- package/server/lib/codex-session-posture.ts +528 -0
- package/server/lib/codex-subagent-activity.ts +119 -0
- package/server/lib/continue-plan.ts +180 -0
- package/server/lib/continue-read-health.ts +26 -0
- package/server/lib/cursor-bridge.ts +26 -6
- package/server/lib/cursor-fork-context.ts +140 -0
- package/server/lib/cursor-model-catalog.ts +62 -5
- package/server/lib/cursor-observer-installer.ts +79 -0
- package/server/lib/cursor-session-model.ts +339 -0
- package/server/lib/cursor-spawn-env.ts +210 -0
- package/server/lib/fork-thread.ts +143 -25
- package/server/lib/health-static-probes.ts +21 -11
- package/server/lib/lens-gist-engines.ts +15 -4
- package/server/lib/lens-gist.ts +1 -1
- package/server/lib/live-cues-cursor.ts +16 -6
- package/server/lib/occupancy-probes.ts +45 -1
- package/server/lib/permission-broker.ts +24 -9
- package/server/lib/provider-actions.ts +149 -0
- package/server/lib/provider-failure-text.ts +27 -0
- package/server/lib/provider-observe.ts +212 -0
- package/server/lib/provider-proof.ts +23 -14
- package/server/lib/query-job-runtime.ts +16 -1
- package/server/lib/query-job-types.ts +2 -1
- package/server/lib/session-act-fields.ts +61 -0
- package/server/lib/session-cancel.ts +95 -4
- package/server/lib/session-continue-facts-real.ts +74 -0
- package/server/lib/session-continue-facts.ts +267 -0
- package/server/lib/session-halt.ts +67 -6
- package/server/lib/session-hook-events.ts +60 -0
- package/server/lib/session-hook-ledger.ts +21 -3
- package/server/lib/session-hook-spool.ts +68 -6
- package/server/lib/session-hooks-runtime.ts +40 -3
- package/server/lib/session-peer-inbox-deps.ts +7 -6
- package/server/lib/session-recommendation.ts +3 -2
- package/server/lib/session-signal-store.ts +81 -3
- package/server/lib/session-stream-events.ts +32 -1
- package/server/lib/session-transcript-watcher.ts +25 -1
- package/server/lib/session-work-observation.ts +46 -0
- package/server/lib/thread-attach-capability.ts +15 -1
- package/server/lib/thread-drain-kick.ts +4 -2
- package/server/lib/thread-occupancy.ts +44 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue.ts +6 -0
- package/server/lib/turn-request.ts +274 -0
- package/server/routes/agent-session-bindings.ts +224 -22
- package/server/routes/agent-session-stream.ts +26 -7
- package/server/routes/agent-sessions.ts +163 -10
- package/server/routes/health.ts +27 -2
- package/server/routes/session-hooks.ts +60 -3
- package/server/routes/thread-turn-queue.ts +37 -9
- package/server/scripts/hooks-cli.ts +38 -9
|
@@ -30,12 +30,53 @@ export const HOOK_EVENT_NAMES = [
|
|
|
30
30
|
'Notification',
|
|
31
31
|
'SubagentStart',
|
|
32
32
|
'SubagentStop',
|
|
33
|
+
'PreCompact',
|
|
33
34
|
'PostCompact',
|
|
34
35
|
'PostModelSwitch',
|
|
36
|
+
// 6.62.0: Codex fires this when a turn is interrupted (Esc). It closes the turn, as a Stop
|
|
37
|
+
// does. Never in Claude's subscription list: Claude Code has no such hook.
|
|
38
|
+
'Interrupt',
|
|
39
|
+
// 6.62.0: the Cursor observer's `afterAgentThought`. Display only, like every observer
|
|
40
|
+
// event: its text lives in memory and on the live feed, never in the ledger.
|
|
41
|
+
'AgentThought',
|
|
35
42
|
] as const
|
|
36
43
|
|
|
37
44
|
export type HookEventName = typeof HOOK_EVENT_NAMES[number]
|
|
38
45
|
|
|
46
|
+
/**
|
|
47
|
+
* 6.62.0: events the reducer understands that no Claude Code hook fires. They stay OUT of
|
|
48
|
+
* Claude's `HOOK_SUBSCRIPTIONS` (a new Claude block would turn every install into `drift`):
|
|
49
|
+
* `Interrupt` is Codex's, and `AgentThought` is the Cursor observer's.
|
|
50
|
+
*/
|
|
51
|
+
export const NON_CLAUDE_HOOK_EVENTS: readonly HookEventName[] = ['Interrupt', 'AgentThought']
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 6.62.0: which engine ran the hook. The Codex command carries `COS_HOOK_PROVIDER=codex`,
|
|
55
|
+
* which the script writes into the envelope; Claude Code and Cursor share the
|
|
56
|
+
* `~/.claude/settings.json` command and are told apart by the payload (`inferHookProvider`).
|
|
57
|
+
*/
|
|
58
|
+
export type HookProvider = 'claude' | 'codex' | 'cursor'
|
|
59
|
+
|
|
60
|
+
export function isHookProvider(value: unknown): value is HookProvider {
|
|
61
|
+
return value === 'claude' || value === 'codex' || value === 'cursor'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The provider of one envelope, in this order (plan 2.1): the envelope's own stamp (the Codex
|
|
66
|
+
* command and the Cursor observer write one); else Cursor's `cursor_version`, which every
|
|
67
|
+
* Cursor payload carries and no other engine's does; else a transcript under
|
|
68
|
+
* `/.codex/sessions/` (a COS Claude block Codex imported runs without the stamp); else Claude.
|
|
69
|
+
*/
|
|
70
|
+
export function inferHookProvider(stamped: unknown, payload: Record<string, unknown>): HookProvider {
|
|
71
|
+
if (isHookProvider(stamped)) return stamped
|
|
72
|
+
if ('cursor_version' in payload) return 'cursor'
|
|
73
|
+
if (typeof payload.transcript_path === 'string' && payload.transcript_path.includes('/.codex/sessions/')) return 'codex'
|
|
74
|
+
return 'claude'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 6.62.0: the cap on an agent thought kept in memory and sent on the live feed. */
|
|
78
|
+
export const AGENT_THOUGHT_MAX = 280
|
|
79
|
+
|
|
39
80
|
const EVENT_SET: ReadonlySet<string> = new Set(HOOK_EVENT_NAMES)
|
|
40
81
|
|
|
41
82
|
export function isHookEventName(value: unknown): value is HookEventName {
|
|
@@ -56,6 +97,13 @@ export interface HookEnvelope {
|
|
|
56
97
|
event: HookEventName
|
|
57
98
|
sessionId: string
|
|
58
99
|
payload: Record<string, unknown>
|
|
100
|
+
/** 6.62.0: set by `parseHookEnvelope` and a 6.62 ledger row; absent on older rows and hand-built envelopes. */
|
|
101
|
+
provider?: HookProvider
|
|
102
|
+
/**
|
|
103
|
+
* 6.62.0 (QA W2): the envelope itself named the provider: a 6.62 hook script (from the Codex
|
|
104
|
+
* command, or from a Codex payload) or the Cursor observer wrote it. Inferred is not stamped.
|
|
105
|
+
*/
|
|
106
|
+
stamped?: true
|
|
59
107
|
}
|
|
60
108
|
|
|
61
109
|
/**
|
|
@@ -110,6 +158,8 @@ export function parseHookEnvelope(text: string): ParsedHookEnvelope {
|
|
|
110
158
|
event: r.event,
|
|
111
159
|
sessionId,
|
|
112
160
|
payload: p,
|
|
161
|
+
provider: inferHookProvider(r.provider, p),
|
|
162
|
+
...(isHookProvider(r.provider) ? { stamped: true as const } : {}),
|
|
113
163
|
},
|
|
114
164
|
}
|
|
115
165
|
}
|
|
@@ -154,10 +204,14 @@ export function projectHookPayload(event: HookEventName, payload: Record<string,
|
|
|
154
204
|
const str = (k: string) => (typeof payload[k] === 'string' ? payload[k] as string : undefined)
|
|
155
205
|
const base: Record<string, unknown> = {
|
|
156
206
|
session_id: str('session_id'),
|
|
207
|
+
...(payload.display_only === true ? { display_only: true } : {}),
|
|
157
208
|
...(str('transcript_path') ? { transcript_path: str('transcript_path') } : {}),
|
|
158
209
|
...(str('cwd') ? { cwd: str('cwd') } : {}),
|
|
159
210
|
...(str('permission_mode') ? { permission_mode: str('permission_mode') } : {}),
|
|
160
211
|
...(str('prompt_id') ? { prompt_id: str('prompt_id') } : {}),
|
|
212
|
+
// 6.62.0: Codex stamps every turn-scoped event with its turn id and model.
|
|
213
|
+
...(str('turn_id') ? { turn_id: str('turn_id') } : {}),
|
|
214
|
+
...(event !== 'SessionStart' && str('model') ? { model: clipText(payload.model, 100) } : {}),
|
|
161
215
|
}
|
|
162
216
|
switch (event) {
|
|
163
217
|
case 'SessionStart':
|
|
@@ -199,7 +253,13 @@ export function projectHookPayload(event: HookEventName, payload: Record<string,
|
|
|
199
253
|
return { ...base, agent_id: str('agent_id'), agent_type: str('agent_type') }
|
|
200
254
|
case 'PostModelSwitch':
|
|
201
255
|
return { ...base, from_model: str('from_model'), to_model: str('to_model') }
|
|
256
|
+
case 'PreCompact':
|
|
202
257
|
case 'PostCompact':
|
|
258
|
+
case 'Interrupt':
|
|
259
|
+
return base
|
|
260
|
+
// The thought's TEXT is never kept: the ledger is a 10 MB ring replayed at boot, and a
|
|
261
|
+
// thought can quote anything the agent read. A replayed AgentThought carries nothing.
|
|
262
|
+
case 'AgentThought':
|
|
203
263
|
return base
|
|
204
264
|
}
|
|
205
265
|
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// applying the same event twice.
|
|
8
8
|
|
|
9
9
|
import { appendFileSync, existsSync, readFileSync, renameSync, statSync } from 'node:fs'
|
|
10
|
-
import type { HookEnvelope, HookEventName } from './session-hook-events.js'
|
|
11
|
-
import { projectHookPayload, isHookEventName, SESSION_ID_RE } from './session-hook-events.js'
|
|
10
|
+
import type { HookEnvelope, HookEventName, HookProvider } from './session-hook-events.js'
|
|
11
|
+
import { projectHookPayload, isHookEventName, isHookProvider, SESSION_ID_RE } from './session-hook-events.js'
|
|
12
12
|
|
|
13
13
|
export const LEDGER_ROTATE_BYTES = 10 * 1024 * 1024
|
|
14
14
|
|
|
@@ -22,6 +22,14 @@ export interface LedgerRow {
|
|
|
22
22
|
child?: boolean
|
|
23
23
|
/** The registry's `entrypoint` for the session, once read (6.48.1); a replay restores it. */
|
|
24
24
|
entrypoint?: string
|
|
25
|
+
/**
|
|
26
|
+
* 6.62.0: the engine, decided at ingest from the full payload (`inferHookProvider`). The
|
|
27
|
+
* projection drops `cursor_version`, so a replay could not tell a Cursor row again without
|
|
28
|
+
* it. A row from an older server has none and replays exactly as it did.
|
|
29
|
+
*/
|
|
30
|
+
provider?: HookProvider
|
|
31
|
+
/** 6.62.0 (QA W2): the envelope carried the provider stamp; a replay keeps the evidence. */
|
|
32
|
+
stamped?: true
|
|
25
33
|
payload: Record<string, unknown>
|
|
26
34
|
}
|
|
27
35
|
|
|
@@ -67,6 +75,8 @@ export class SessionHookLedger {
|
|
|
67
75
|
session_id: env.sessionId,
|
|
68
76
|
...(child ? { child: true } : {}),
|
|
69
77
|
...(entrypoint ? { entrypoint } : {}),
|
|
78
|
+
...(env.provider ? { provider: env.provider } : {}),
|
|
79
|
+
...(env.stamped ? { stamped: true as const } : {}),
|
|
70
80
|
payload: projectHookPayload(env.event, env.payload),
|
|
71
81
|
}
|
|
72
82
|
} catch (error) {
|
|
@@ -119,7 +129,15 @@ export class SessionHookLedger {
|
|
|
119
129
|
if (r.ts < sinceMs) continue
|
|
120
130
|
const payload = r.payload && typeof r.payload === 'object' ? r.payload as Record<string, unknown> : {}
|
|
121
131
|
const entrypoint = typeof r.entrypoint === 'string' && r.entrypoint ? r.entrypoint : null
|
|
122
|
-
apply({
|
|
132
|
+
apply({
|
|
133
|
+
ts: r.ts,
|
|
134
|
+
ppid: typeof r.ppid === 'number' ? r.ppid : null,
|
|
135
|
+
event: r.event,
|
|
136
|
+
sessionId: r.session_id.toLowerCase(),
|
|
137
|
+
payload: { session_id: r.session_id, ...payload },
|
|
138
|
+
...(isHookProvider(r.provider) ? { provider: r.provider } : {}),
|
|
139
|
+
...(r.stamped === true && isHookProvider(r.provider) ? { stamped: true as const } : {}),
|
|
140
|
+
}, r.key, r.child === true, entrypoint)
|
|
123
141
|
applied++
|
|
124
142
|
}
|
|
125
143
|
return { rows, applied, keys }
|
|
@@ -27,7 +27,13 @@ export const REJECTED_KEEP = 1_000
|
|
|
27
27
|
export const REJECTED_MAX_AGE_MS = 7 * 24 * 60 * 60_000
|
|
28
28
|
export const LAST_DRAIN_STAMP = '.last-drain'
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* `<ms>-<pid>-<Event>.json` from the hook script; 6.62.0: also `<ms>-<pid>-<12 hex>-<Event>.json`,
|
|
32
|
+
* the Cursor observer's name since it shipped (6.61.7). The nonce keeps two observer runs in one
|
|
33
|
+
* millisecond apart; until 6.62.0 every one of its files was moved aside as unrecognized.
|
|
34
|
+
* Ordering reads groups 1 and 2 only.
|
|
35
|
+
*/
|
|
36
|
+
export const SPOOL_FILE_RE = /^(\d{10,16})-(\d+)(?:-[0-9a-f]{12})?-([A-Za-z]+)\.json$/
|
|
31
37
|
|
|
32
38
|
/** A code, never a message: raw fs messages carry the home path and health is public. */
|
|
33
39
|
export function errorCode(error: unknown): string {
|
|
@@ -38,6 +44,17 @@ export function errorCode(error: unknown): string {
|
|
|
38
44
|
|
|
39
45
|
/** A hook killed mid-write leaves `.tmp.*`; reap those older than this. */
|
|
40
46
|
export const TMP_MAX_AGE_MS = 10 * 60_000
|
|
47
|
+
/** 6.62.0: the observer's `.<name>` temp file, left by a run killed between write and rename. */
|
|
48
|
+
export const DOT_TEMP_MAX_AGE_MS = 60 * 60_000
|
|
49
|
+
/**
|
|
50
|
+
* 6.62.0: observer files 6.61.x moved aside as `.unrecognized` are put back ONCE, and only those
|
|
51
|
+
* from the last hour: older ones describe sub-agents and compactions long over (and canary
|
|
52
|
+
* throwaways), and age out with the rest of `rejected/` as before.
|
|
53
|
+
*/
|
|
54
|
+
export const REINGEST_MAX_AGE_MS = 60 * 60_000
|
|
55
|
+
export const REINGEST_MARKER = '.reingested-6.62.0'
|
|
56
|
+
/** The window `rejected24h` counts over. */
|
|
57
|
+
export const REJECTED_WINDOW_MS = 24 * 60 * 60_000
|
|
41
58
|
/** The startup drain never loops more times than a full spool needs. */
|
|
42
59
|
export const STARTUP_MAX_PASSES = Math.ceil(2_000 / SPOOL_BATCH) + 1
|
|
43
60
|
|
|
@@ -63,7 +80,12 @@ export interface SpoolIngesterOptions {
|
|
|
63
80
|
|
|
64
81
|
export interface SpoolStats {
|
|
65
82
|
ingested: number
|
|
83
|
+
/** Since the process started (cumulative). */
|
|
66
84
|
rejected: number
|
|
85
|
+
/** 6.62.0: in the last 24 hours, so a fixed cause reads as fixed without a restart. */
|
|
86
|
+
rejected24h: number
|
|
87
|
+
/** 6.62.0: rejected observer files put back into the spool at boot (once per Mac). */
|
|
88
|
+
reingested: number
|
|
67
89
|
salvaged: number
|
|
68
90
|
duplicates: number
|
|
69
91
|
/** Files a sweep visited and could not remove (ledger refused, unreadable). */
|
|
@@ -90,7 +112,13 @@ export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester
|
|
|
90
112
|
const batch = options.batch ?? SPOOL_BATCH
|
|
91
113
|
const seen = options.seenKeys ?? new Set<string>()
|
|
92
114
|
const rejectedDir = join(dir, 'rejected')
|
|
93
|
-
const stats: SpoolStats = { ingested: 0, rejected: 0, salvaged: 0, duplicates: 0, stuck: 0, foreign: 0, applyErrors: 0, backlog: 0, lastSweepAt: null, lastEventAt: null, lastError: null }
|
|
115
|
+
const stats: SpoolStats = { ingested: 0, rejected: 0, rejected24h: 0, reingested: 0, salvaged: 0, duplicates: 0, stuck: 0, foreign: 0, applyErrors: 0, backlog: 0, lastSweepAt: null, lastEventAt: null, lastError: null }
|
|
116
|
+
const rejectedAt: number[] = []
|
|
117
|
+
const countRejected = () => {
|
|
118
|
+
stats.rejected++
|
|
119
|
+
rejectedAt.push(now())
|
|
120
|
+
if (rejectedAt.length > 10_000) rejectedAt.splice(0, rejectedAt.length - 10_000)
|
|
121
|
+
}
|
|
94
122
|
let ticking = false
|
|
95
123
|
let stopped = false
|
|
96
124
|
let watcher: FSWatcher | null = null
|
|
@@ -134,16 +162,22 @@ export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester
|
|
|
134
162
|
try { if (now() - statSync(join(dir, n)).mtimeMs > TMP_MAX_AGE_MS) unlinkSync(join(dir, n)) } catch { /* fine */ }
|
|
135
163
|
continue
|
|
136
164
|
}
|
|
165
|
+
// 6.62.0: the observer writes `.<spool name>` and renames it; one killed in between left
|
|
166
|
+
// it here for good. Only that exact shape, and only once it is an hour old.
|
|
167
|
+
if (n.startsWith('.') && SPOOL_FILE_RE.test(n.slice(1))) {
|
|
168
|
+
try { if (now() - statSync(join(dir, n)).mtimeMs > DOT_TEMP_MAX_AGE_MS) unlinkSync(join(dir, n)) } catch { /* fine */ }
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
137
171
|
if (n.startsWith('.')) continue
|
|
138
172
|
if (!n.endsWith('.json')) { foreign++; continue }
|
|
139
|
-
try { renameSync(join(dir, n), join(rejectedDir, `${n}.unrecognized`)) ;
|
|
173
|
+
try { renameSync(join(dir, n), join(rejectedDir, `${n}.unrecognized`)) ; countRejected() } catch { /* fine */ }
|
|
140
174
|
}
|
|
141
175
|
stats.foreign = foreign
|
|
142
176
|
return names.filter(n => SPOOL_FILE_RE.test(n)).sort(spoolOrder)
|
|
143
177
|
}
|
|
144
178
|
|
|
145
179
|
const reject = (name: string, reason: string) => {
|
|
146
|
-
|
|
180
|
+
countRejected()
|
|
147
181
|
try { renameSync(join(dir, name), join(rejectedDir, `${name}.${reason}`)) } catch {
|
|
148
182
|
try { unlinkSync(join(dir, name)) } catch { /* gone already */ }
|
|
149
183
|
}
|
|
@@ -203,7 +237,8 @@ export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester
|
|
|
203
237
|
|
|
204
238
|
const pruneRejected = () => {
|
|
205
239
|
let names: string[]
|
|
206
|
-
|
|
240
|
+
// The re-ingest marker is not a rejected file: it is never pruned, so the re-ingest stays once.
|
|
241
|
+
try { names = readdirSync(rejectedDir).filter(n => n !== REINGEST_MARKER).sort() } catch { return }
|
|
207
242
|
const cutoff = now() - REJECTED_MAX_AGE_MS
|
|
208
243
|
const excess = Math.max(0, names.length - REJECTED_KEEP)
|
|
209
244
|
names.forEach((n, index) => {
|
|
@@ -242,6 +277,29 @@ export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester
|
|
|
242
277
|
return removed
|
|
243
278
|
}
|
|
244
279
|
|
|
280
|
+
// 6.62.0: put back the observer files 6.61.x could not read (see REINGEST_MAX_AGE_MS), once.
|
|
281
|
+
// Moved, never copied: the normal drain below ingests and deletes them like any other file.
|
|
282
|
+
const reingest = () => {
|
|
283
|
+
const marker = join(rejectedDir, REINGEST_MARKER)
|
|
284
|
+
if (existsSync(marker)) return
|
|
285
|
+
let names: string[] = []
|
|
286
|
+
try { names = readdirSync(rejectedDir) } catch { return }
|
|
287
|
+
const suffix = '.unrecognized'
|
|
288
|
+
for (const n of names) {
|
|
289
|
+
if (!n.endsWith(suffix)) continue
|
|
290
|
+
const original = n.slice(0, -suffix.length)
|
|
291
|
+
if (!SPOOL_FILE_RE.test(original)) continue
|
|
292
|
+
try {
|
|
293
|
+
if (now() - statSync(join(rejectedDir, n)).mtimeMs > REINGEST_MAX_AGE_MS) continue
|
|
294
|
+
if (existsSync(join(dir, original))) continue
|
|
295
|
+
renameSync(join(rejectedDir, n), join(dir, original))
|
|
296
|
+
stats.reingested++
|
|
297
|
+
} catch { /* left where it is; it ages out */ }
|
|
298
|
+
}
|
|
299
|
+
try { writeFileSync(marker, `${new Date(now()).toISOString()} ${stats.reingested}\n`, { mode: 0o600 }) } catch { /* the next boot tries once more */ }
|
|
300
|
+
}
|
|
301
|
+
reingest()
|
|
302
|
+
|
|
245
303
|
// Startup drain: the backlog, in bounded passes, before anyone asks. Bounded twice: a pass
|
|
246
304
|
// that removed nothing ends it (a refusing ledger must never spin the boot), and no
|
|
247
305
|
// backlog needs more passes than a full spool.
|
|
@@ -267,6 +325,10 @@ export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester
|
|
|
267
325
|
clearInterval(timer)
|
|
268
326
|
try { watcher?.close() } catch { /* fine */ }
|
|
269
327
|
},
|
|
270
|
-
stats: () =>
|
|
328
|
+
stats: () => {
|
|
329
|
+
const since = now() - REJECTED_WINDOW_MS
|
|
330
|
+
while (rejectedAt.length > 0 && rejectedAt[0]! < since) rejectedAt.shift()
|
|
331
|
+
return { ...stats, rejected24h: rejectedAt.length }
|
|
332
|
+
},
|
|
271
333
|
}
|
|
272
334
|
}
|
|
@@ -20,6 +20,7 @@ import { OPEN_TURN_CEILING_MS, deriveSessionState, type DerivedSessionState, typ
|
|
|
20
20
|
import { HALT_MARKER_TTL_MS, HALT_REARM_UNVERIFIED_MS, clearHaltMarkerOnSessionEnd, dropHaltRearm, haltDeliveredTurn, rearmHaltOnPrompt, sweepHaltMarkers, type HaltMarker } from './session-halt.js'
|
|
21
21
|
import { cancelVoidsOpenTurn, threadCancel } from './session-cancel.js'
|
|
22
22
|
import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
|
|
23
|
+
import { codexHooksHealthField, cursorObserverHealthField, type CodexHooksHealth, type CursorObserverHealth } from './provider-observe.js'
|
|
23
24
|
|
|
24
25
|
export function sessionHooksEnabled(): boolean {
|
|
25
26
|
const raw = process.env.COS_SESSION_HOOKS
|
|
@@ -235,7 +236,7 @@ export function claudeDeskRunning(sessionId: string, now = Date.now(), dir = cla
|
|
|
235
236
|
* hooks do not yet show the turn started (its UserPromptSubmit at or after `sentAt`), it is
|
|
236
237
|
* re-armed once when that prompt's own UserPromptSubmit deletes it (`haltDeliveredTurn`).
|
|
237
238
|
*/
|
|
238
|
-
export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: { prompt: string; sentAt: number; unverified?: boolean }, now = Date.now()): boolean {
|
|
239
|
+
export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: { prompt: string; sentAt: number; unverified?: boolean; verbatim?: boolean; deferMarker?: boolean }, now = Date.now()): boolean {
|
|
239
240
|
const signal = signalFor(sessionId)
|
|
240
241
|
const started = !!signal && typeof signal.turnStartedAt === 'number' && signal.turnStartedAt >= turn.sentAt
|
|
241
242
|
return haltDeliveredTurn(sessionId, marker, {
|
|
@@ -245,6 +246,10 @@ export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: {
|
|
|
245
246
|
now,
|
|
246
247
|
// 6.53.3 /qa: an unverified hand-off (`maybe`) holds its re-arm two minutes, not an hour.
|
|
247
248
|
windowMs: turn.unverified === true ? HALT_REARM_UNVERIFIED_MS : HALT_MARKER_TTL_MS,
|
|
249
|
+
// 6.62.0 (plan 3.6): a Codex turn queued into the app is matched verbatim.
|
|
250
|
+
verbatim: turn.verbatim === true,
|
|
251
|
+
// 6.62.0 /qa (W3): a busy hop's marker waits for the delivered turn's own prompt.
|
|
252
|
+
deferMarker: turn.deferMarker === true,
|
|
248
253
|
})
|
|
249
254
|
}
|
|
250
255
|
|
|
@@ -277,7 +282,8 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
|
|
|
277
282
|
ledger,
|
|
278
283
|
seenKeys: replay.keys,
|
|
279
284
|
isChild: env => isCosSpawnedPid(env.ppid),
|
|
280
|
-
|
|
285
|
+
// 6.62.0: only a Claude session has a `~/.claude/sessions` record to read.
|
|
286
|
+
entrypointOf: enabled ? (env, child) => (env.provider === undefined || env.provider === 'claude' ? entrypointFor(env.sessionId, child) : null) : undefined,
|
|
281
287
|
apply: enabled ? (env, child, entrypoint) => {
|
|
282
288
|
sessionSignalStore.apply(env, child)
|
|
283
289
|
if (entrypoint) sessionSignalStore.setEntrypoint(env.sessionId, entrypoint)
|
|
@@ -354,6 +360,14 @@ export interface SessionHooksHealth {
|
|
|
354
360
|
signals: number
|
|
355
361
|
replayed: { rows: number; applied: number } | null
|
|
356
362
|
spool: SpoolStats | null
|
|
363
|
+
/**
|
|
364
|
+
* 6.62.0: the Codex hooks, APART from the Claude fields above (B2): `state`, `installed` and
|
|
365
|
+
* `hookHaltReady` stay Claude's alone, because Control 0.5.254 keys its banner and its
|
|
366
|
+
* Install check off them. No Codex banner this release.
|
|
367
|
+
*/
|
|
368
|
+
codex: CodexHooksHealth
|
|
369
|
+
/** 6.62.0: the native Cursor observer (`~/.cursor/hooks.json`). */
|
|
370
|
+
cursorObserver: CursorObserverHealth
|
|
357
371
|
}
|
|
358
372
|
|
|
359
373
|
// Health is polled by three clients every few seconds; the status read (settings parse,
|
|
@@ -397,6 +411,8 @@ export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth }
|
|
|
397
411
|
signals: sessionSignalStore.size(),
|
|
398
412
|
replayed,
|
|
399
413
|
spool,
|
|
414
|
+
codex: codexHooksHealthField(),
|
|
415
|
+
cursorObserver: cursorObserverHealthField(),
|
|
400
416
|
},
|
|
401
417
|
}
|
|
402
418
|
}
|
|
@@ -404,7 +420,28 @@ export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth }
|
|
|
404
420
|
/** Signal for a row id that may be the registry's eight-character form. */
|
|
405
421
|
export function signalFor(sessionId: string): SessionSignal | undefined {
|
|
406
422
|
if (!sessionHooksEnabled()) return undefined
|
|
407
|
-
|
|
423
|
+
const signal = sessionId.length >= 36 ? sessionSignalStore.get(sessionId) : sessionSignalStore.getByPrefix(sessionId)
|
|
424
|
+
return signal?.observationOnly ? undefined : signal
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* 6.62.0 (QA W2): has a hook event of this engine reached COS for this thread? The positive
|
|
429
|
+
* evidence a desk cancel needs before it may be offered for Codex or Cursor. Never an observer
|
|
430
|
+
* event (those prove the observer runs, not the halt check):
|
|
431
|
+
* codex an event the Codex command's script STAMPED (a 6.62 script, whose halt reply Codex
|
|
432
|
+
* honours; an inferred-only Codex event came from an older script that fails open)
|
|
433
|
+
* cursor an event from the Claude-compatible hook Cursor runs (its own `cursor_version`)
|
|
434
|
+
*/
|
|
435
|
+
export function providerHookSeen(provider: 'codex' | 'cursor', threadId: string): boolean {
|
|
436
|
+
const signal = signalFor(threadId)
|
|
437
|
+
if (!signal || signal.provider !== provider) return false
|
|
438
|
+
return provider === 'codex' ? typeof signal.stampedAt === 'number' : true
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Display observers do not create hook occupancy or permission evidence. */
|
|
442
|
+
export function workObservationFor(sessionId: string) {
|
|
443
|
+
if (!sessionHooksEnabled()) return undefined
|
|
444
|
+
return (sessionId.length >= 36 ? sessionSignalStore.get(sessionId) : sessionSignalStore.getByPrefix(sessionId))?.workObservation
|
|
408
445
|
}
|
|
409
446
|
|
|
410
447
|
/** 6.60.0: told when a row derives `ended`, so the broker stops saying the Mac is waiting on it. */
|
|
@@ -19,12 +19,13 @@ import {
|
|
|
19
19
|
} from './session-peer-inbox.js'
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* `COS_CONTINUE_LIVE`.
|
|
22
|
+
* `COS_CONTINUE_LIVE`. ON unless exactly '0' (6.62.0, D3).
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
* contract, and the first days
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* 6.49.0 shipped it off: the transport rides a protocol Claude Code documents in a help
|
|
25
|
+
* string, not a contract, and the first days were for reading the health row on one Mac.
|
|
26
|
+
* Those days are behind it (Miles's Mac has run it on since 6.49; measured on Claude Code
|
|
27
|
+
* 2.1.289), and a Continue that lands in the open session is the desk behaviour. '0' keeps
|
|
28
|
+
* every Continue on the 6.48.2 spawn path; every refusal still falls back there anyway.
|
|
28
29
|
*
|
|
29
30
|
* WHERE TO SET IT. `~/.cos-glasses/.env` (read at boot by `env.ts`, the plist wins
|
|
30
31
|
* when both carry it). Control's `providerEnvironmentKeys` allowlist does NOT carry
|
|
@@ -32,7 +33,7 @@ import {
|
|
|
32
33
|
* by the next Install/Repair/Update Server; Control 0.5.234 is to add it.
|
|
33
34
|
*/
|
|
34
35
|
export function continueLiveEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
35
|
-
return env.COS_CONTINUE_LIVE
|
|
36
|
+
return env.COS_CONTINUE_LIVE !== '0'
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
/** Registry files are small; anything larger is not one. */
|
|
@@ -27,8 +27,9 @@ const EXACT = "Which one of the user's agent sessions is already doing this exac
|
|
|
27
27
|
+ 'A session on the same project doing different work does not count. Choose none if no session is doing this task.'
|
|
28
28
|
|
|
29
29
|
/** Providers a Work handoff can fork natively (Control 0.5.243 applies a Fork suggestion as a same-platform fork).
|
|
30
|
-
*
|
|
31
|
-
|
|
30
|
+
* 6.62.0 (W9): the fork route also serves Cursor ("New session with this context"), but Control 0.5.254 has no
|
|
31
|
+
* Cursor fork plan, so the recommender keeps to Claude and Codex until the Control release. */
|
|
32
|
+
export const FORKABLE = new Set<string>(FORKABLE_PROVIDERS.filter(provider => provider !== 'cursor'))
|
|
32
33
|
const clip = (text: string | undefined, max: number) => (text ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
|
|
33
34
|
const round = (n: number) => Math.round(n * 100) / 100
|
|
34
35
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { observeWork, type WorkObservation } from './session-work-observation.js'
|
|
1
2
|
// The signal store: what the hooks have said about each Claude session, reduced to
|
|
2
3
|
// one record per full session id.
|
|
3
4
|
//
|
|
@@ -24,8 +25,8 @@
|
|
|
24
25
|
// 3. `ended` is recorded here but RANKED in the deriver, which also sees the registry:
|
|
25
26
|
// an ended signal with an alive registry record is a child that ended, not the tab.
|
|
26
27
|
|
|
27
|
-
import type { HookEnvelope, HookEventName } from './session-hook-events.js'
|
|
28
|
-
import { clipText, toolFingerprint, toolTarget } from './session-hook-events.js'
|
|
28
|
+
import type { HookEnvelope, HookEventName, HookProvider } from './session-hook-events.js'
|
|
29
|
+
import { AGENT_THOUGHT_MAX, clipText, toolFingerprint, toolTarget } from './session-hook-events.js'
|
|
29
30
|
import { isKeepWarmSessionTitle } from './agent-session-store.js'
|
|
30
31
|
|
|
31
32
|
export type WaitingKind = 'permission' | 'question' | 'plan' | 'mcp_input'
|
|
@@ -48,6 +49,23 @@ export interface FailureSignal {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
export interface SessionSignal {
|
|
52
|
+
workObservation?: WorkObservation
|
|
53
|
+
observationOnly?: boolean
|
|
54
|
+
/**
|
|
55
|
+
* 6.62.0: the engine whose hooks these are (`inferHookProvider`). Absent on a record built
|
|
56
|
+
* from envelopes that never carried one (an older ledger row), which is read as Claude.
|
|
57
|
+
*/
|
|
58
|
+
provider?: HookProvider
|
|
59
|
+
/**
|
|
60
|
+
* 6.62.0: the newest agent thought the Cursor observer reported, capped at
|
|
61
|
+
* AGENT_THOUGHT_MAX. MEMORY ONLY: the ledger keeps no thought text, so a restart forgets it.
|
|
62
|
+
*/
|
|
63
|
+
lastThought?: { text: string; at: number }
|
|
64
|
+
/**
|
|
65
|
+
* 6.62.0 (QA W2): the newest phase event (never an observer one) whose envelope the hook
|
|
66
|
+
* script stamped with its provider: proof a 6.62 script is running this session's hooks.
|
|
67
|
+
*/
|
|
68
|
+
stampedAt?: number
|
|
51
69
|
sessionId: string
|
|
52
70
|
firstSeenAt: number
|
|
53
71
|
lastEventAt: number
|
|
@@ -104,6 +122,7 @@ function toolFacts(p: Record<string, unknown>): { name: string; target: string;
|
|
|
104
122
|
|
|
105
123
|
function fresh(env: HookEnvelope): SessionSignal {
|
|
106
124
|
return {
|
|
125
|
+
...(env.provider ? { provider: env.provider } : {}),
|
|
107
126
|
sessionId: env.sessionId,
|
|
108
127
|
firstSeenAt: env.ts,
|
|
109
128
|
lastEventAt: env.ts,
|
|
@@ -133,6 +152,25 @@ function fresh(env: HookEnvelope): SessionSignal {
|
|
|
133
152
|
|
|
134
153
|
const str = (v: unknown): string | null => (typeof v === 'string' && v.length > 0 ? v : null)
|
|
135
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Observer evidence: never a phase, never a listener. 6.62.0: an AgentThought is display only
|
|
157
|
+
* whatever its payload says, so a thought can never open, close or reopen a turn.
|
|
158
|
+
*/
|
|
159
|
+
export function isDisplayOnly(env: HookEnvelope): boolean {
|
|
160
|
+
return env.payload.display_only === true || env.event === 'AgentThought'
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 6.62.0: an AgentThought with text becomes the record's `lastThought`, unless an older one
|
|
165
|
+
* arrived late. A replayed row carries no text (the ledger drops it) and changes nothing.
|
|
166
|
+
*/
|
|
167
|
+
function thoughtOf(base: SessionSignal, env: HookEnvelope): Pick<SessionSignal, 'lastThought'> {
|
|
168
|
+
if (env.event !== 'AgentThought') return {}
|
|
169
|
+
const text = clipText(env.payload.text, AGENT_THOUGHT_MAX)
|
|
170
|
+
if (!text || (base.lastThought && base.lastThought.at > env.ts)) return {}
|
|
171
|
+
return { lastThought: { text, at: env.ts } }
|
|
172
|
+
}
|
|
173
|
+
|
|
136
174
|
/**
|
|
137
175
|
* 6.48.1. A tool runs only inside a turn, so a MAIN-THREAD tool event (no `agent_id`) is
|
|
138
176
|
* evidence the turn is open even when its UserPromptSubmit was never seen: a tab adopted
|
|
@@ -178,8 +216,13 @@ function resolvesWaiting(waiting: WaitingSignal, event: HookEventName, toolName:
|
|
|
178
216
|
*/
|
|
179
217
|
export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelope, ctx: ReducerContext, child?: boolean): SessionSignal {
|
|
180
218
|
const base = prev ? { ...prev } : fresh(env)
|
|
219
|
+
if (env.provider) base.provider = env.provider
|
|
181
220
|
const p = env.payload
|
|
221
|
+
if (isDisplayOnly(env)) return { ...base, ...(prev?.observationOnly !== false ? { lastEventAt: Math.max(base.lastEventAt, env.ts) } : {}), observationOnly: prev?.observationOnly ?? !prev, workObservation: observeWork(base.workObservation, env), ...thoughtOf(base, env) }
|
|
182
222
|
const common = {
|
|
223
|
+
observationOnly: false,
|
|
224
|
+
...(env.stamped ? { stampedAt: Math.max(base.stampedAt ?? 0, env.ts) } : {}),
|
|
225
|
+
workObservation: observeWork(base.workObservation, env),
|
|
183
226
|
cwd: str(p.cwd) ?? base.cwd,
|
|
184
227
|
transcriptPath: str(p.transcript_path) ?? base.transcriptPath,
|
|
185
228
|
permissionMode: str(p.permission_mode) ?? base.permissionMode,
|
|
@@ -206,6 +249,11 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
|
|
|
206
249
|
const prompt = clipText(p.prompt)
|
|
207
250
|
return {
|
|
208
251
|
...next,
|
|
252
|
+
// 6.62.0: Codex and Cursor have no registry to say a session lives on after its
|
|
253
|
+
// SessionEnd (a composer the hooks saw end and then reopened; a Codex thread resumed
|
|
254
|
+
// in the app without a SessionStart). A prompt after the end is a new life. Claude's
|
|
255
|
+
// own resume fires SessionStart, so its rows keep the 6.61 rule.
|
|
256
|
+
...(next.provider === 'codex' || next.provider === 'cursor' ? { ended: null } : {}),
|
|
209
257
|
turnOpen: true,
|
|
210
258
|
turnStartedAt: env.ts,
|
|
211
259
|
promptId: str(p.prompt_id),
|
|
@@ -274,6 +322,11 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
|
|
|
274
322
|
waiting: null,
|
|
275
323
|
lastReply: clipText(p.last_assistant_message) || next.lastReply,
|
|
276
324
|
}
|
|
325
|
+
// 6.62.0: Codex's Interrupt (Esc) ends the turn with no Stop and no reply.
|
|
326
|
+
case 'Interrupt':
|
|
327
|
+
return { ...next, turnOpen: false, stopAt: env.ts, waiting: null }
|
|
328
|
+
case 'AgentThought': // display only (`isDisplayOnly`); never reaches here
|
|
329
|
+
return next
|
|
277
330
|
case 'StopFailure':
|
|
278
331
|
return {
|
|
279
332
|
...next,
|
|
@@ -285,6 +338,8 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
|
|
|
285
338
|
return { ...next, subagentsOpen: next.subagentsOpen + 1 }
|
|
286
339
|
case 'SubagentStop':
|
|
287
340
|
return { ...next, subagentsOpen: Math.max(0, next.subagentsOpen - 1) }
|
|
341
|
+
case 'PreCompact':
|
|
342
|
+
return next
|
|
288
343
|
case 'PostCompact':
|
|
289
344
|
return { ...next, compactions: next.compactions + 1 }
|
|
290
345
|
case 'PostModelSwitch':
|
|
@@ -295,6 +350,8 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
|
|
|
295
350
|
}
|
|
296
351
|
|
|
297
352
|
export type SignalListener = (signal: SessionSignal, env: HookEnvelope, child: boolean) => void
|
|
353
|
+
/** 6.62.0: told of each new agent thought (display only; the drain, permission and halt listeners never are). */
|
|
354
|
+
export type ThoughtListener = (signal: SessionSignal, thought: { text: string; at: number }) => void
|
|
298
355
|
|
|
299
356
|
/** Records older than this after a SessionEnd are dropped; Control keeps its own ledger. */
|
|
300
357
|
export const SIGNAL_PRUNE_AFTER_END_MS = 6 * 60 * 60_000
|
|
@@ -304,6 +361,7 @@ export const SIGNAL_PRUNE_SILENT_MS = 24 * 60 * 60_000
|
|
|
304
361
|
export class SessionSignalStore {
|
|
305
362
|
private readonly signals = new Map<string, SessionSignal>()
|
|
306
363
|
private readonly listeners = new Set<SignalListener>()
|
|
364
|
+
private readonly thoughtListeners = new Set<ThoughtListener>()
|
|
307
365
|
private readonly ctx: ReducerContext
|
|
308
366
|
|
|
309
367
|
constructor(ctx: ReducerContext) {
|
|
@@ -311,8 +369,21 @@ export class SessionSignalStore {
|
|
|
311
369
|
}
|
|
312
370
|
|
|
313
371
|
apply(env: HookEnvelope, child?: boolean): SessionSignal {
|
|
314
|
-
const
|
|
372
|
+
const prev = this.signals.get(env.sessionId)
|
|
373
|
+
const next = applyHookEvent(prev, env, this.ctx, child)
|
|
315
374
|
this.signals.set(env.sessionId, next)
|
|
375
|
+
if (isDisplayOnly(env)) {
|
|
376
|
+
// No drain, permission or halt listeners. A NEW thought reaches the live feed only.
|
|
377
|
+
const thought = next.lastThought
|
|
378
|
+
if (env.event === 'AgentThought' && thought && thought !== prev?.lastThought) {
|
|
379
|
+
for (const listener of this.thoughtListeners) {
|
|
380
|
+
try { listener(next, thought) } catch (error) {
|
|
381
|
+
console.error(`[session-signals] thought listener failed: ${error instanceof Error ? error.message : error}`)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return next
|
|
386
|
+
}
|
|
316
387
|
const isChild = child ?? this.ctx.isCosSpawnedPid(env.ppid)
|
|
317
388
|
for (const listener of this.listeners) {
|
|
318
389
|
try { listener(next, env, isChild) } catch (error) {
|
|
@@ -383,6 +454,12 @@ export class SessionSignalStore {
|
|
|
383
454
|
return () => { this.listeners.delete(listener) }
|
|
384
455
|
}
|
|
385
456
|
|
|
457
|
+
/** 6.62.0: new agent thoughts, for the live feed. Display only. */
|
|
458
|
+
subscribeThoughts(listener: ThoughtListener): () => void {
|
|
459
|
+
this.thoughtListeners.add(listener)
|
|
460
|
+
return () => { this.thoughtListeners.delete(listener) }
|
|
461
|
+
}
|
|
462
|
+
|
|
386
463
|
/** Drop records that ended long ago, and records silent for a day (a tab that died with no SessionEnd). */
|
|
387
464
|
prune(nowMs = Date.now()): number {
|
|
388
465
|
let dropped = 0
|
|
@@ -417,5 +494,6 @@ export class SessionSignalStore {
|
|
|
417
494
|
__resetForTests(): void {
|
|
418
495
|
this.signals.clear()
|
|
419
496
|
this.listeners.clear()
|
|
497
|
+
this.thoughtListeners.clear()
|
|
420
498
|
}
|
|
421
499
|
}
|
|
@@ -565,6 +565,37 @@ export function promptDrafts(message: Record<string, unknown>): SessionStreamDra
|
|
|
565
565
|
return [{ kind: 'prompt', text: flat }]
|
|
566
566
|
}
|
|
567
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Cursor (6.62.0, plan 1.5): the user's words are the inside of `<user_query>`; the rest of a
|
|
570
|
+
* Cursor user row is scaffolding (attached files, rules). Until 6.62.0 the whole row was
|
|
571
|
+
* dropped, so a Cursor feed never showed what it was working on. Everything else is the
|
|
572
|
+
* Claude-shaped content the two share.
|
|
573
|
+
*/
|
|
574
|
+
function draftsFromCursorRecord(record: Record<string, unknown>): SessionStreamDraft[] {
|
|
575
|
+
if (record.role === 'user' && record.type === undefined) {
|
|
576
|
+
const message = asRecord(record.message)
|
|
577
|
+
if (!message) return []
|
|
578
|
+
const blocks = Array.isArray(message.content) ? message.content : typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : []
|
|
579
|
+
if (blocks.some(b => asRecord(b)?.type === 'tool_result')) return []
|
|
580
|
+
const text = blocks.map(b => asRecord(b)).filter(b => b && (b.type === 'text' || b.type === undefined) && typeof b.text === 'string').map(b => b!.text as string).join(' ')
|
|
581
|
+
const start = text.indexOf('<user_query>')
|
|
582
|
+
const end = text.indexOf('</user_query>')
|
|
583
|
+
const query = start >= 0 && end > start ? text.slice(start + '<user_query>'.length, end) : text
|
|
584
|
+
return promptDrafts({ content: [{ type: 'text', text: query }] })
|
|
585
|
+
}
|
|
586
|
+
return draftsFromClaudeRecord(record)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Cursor (6.62.0, plan 1.7): the observer's newest agent thought, as the reasoning line. The
|
|
591
|
+
* same shape Codex's reasoning headline rides (`CodexReasoningStatus`): an extra field on a
|
|
592
|
+
* `status: working` draft, one line, as long as a target.
|
|
593
|
+
*/
|
|
594
|
+
export function cursorThoughtStatus(text: unknown): CodexReasoningStatus | null {
|
|
595
|
+
const reasoning = typeof text === 'string' ? oneLine(text, TARGET_MAX_CHARS) : ''
|
|
596
|
+
return reasoning ? { kind: 'status', state: 'working', reasoning } : null
|
|
597
|
+
}
|
|
598
|
+
|
|
568
599
|
function draftsFromClaudeRecord(record: Record<string, unknown>): SessionStreamDraft[] {
|
|
569
600
|
const type = typeof record.type === 'string' ? record.type : ''
|
|
570
601
|
|
|
@@ -1262,7 +1293,7 @@ export function draftsFromRecord(
|
|
|
1262
1293
|
const obj = asRecord(record)
|
|
1263
1294
|
if (!obj) return []
|
|
1264
1295
|
try {
|
|
1265
|
-
const drafts = provider === 'codex' ? draftsFromCodexRecord(obj) : draftsFromClaudeRecord(obj)
|
|
1296
|
+
const drafts = provider === 'codex' ? draftsFromCodexRecord(obj) : provider === 'cursor' ? draftsFromCursorRecord(obj) : draftsFromClaudeRecord(obj)
|
|
1266
1297
|
return stampPromptTurnStart(drafts, obj)
|
|
1267
1298
|
} catch {
|
|
1268
1299
|
// A malformed record costs one line of the trail. It must never cost the stream.
|