@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.
Files changed (72) hide show
  1. package/.env.example +6 -0
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +5 -1
  4. package/bin/cli.cjs +50 -3
  5. package/bin/hooks/cos-cursor-observer.cjs +36 -0
  6. package/bin/hooks/cos-session-hook +46 -11
  7. package/docs/provider-parity.md +43 -0
  8. package/managed-runtime-contract.json +1 -0
  9. package/package.json +5 -2
  10. package/server/index.ts +135 -16
  11. package/server/lib/agent-session-binding-store.ts +9 -3
  12. package/server/lib/agent-session-store.ts +7 -4
  13. package/server/lib/attached-provider-adapter.ts +158 -45
  14. package/server/lib/banned-permission-args.ts +42 -1
  15. package/server/lib/claude-bridge.ts +17 -8
  16. package/server/lib/claude-hooks-installer.ts +28 -5
  17. package/server/lib/codex-bridge.ts +17 -2
  18. package/server/lib/codex-hooks-installer.ts +538 -0
  19. package/server/lib/codex-live-queue.ts +1 -1
  20. package/server/lib/codex-model-catalog.ts +49 -2
  21. package/server/lib/codex-rollout-state.ts +90 -0
  22. package/server/lib/codex-session-posture.ts +528 -0
  23. package/server/lib/codex-subagent-activity.ts +119 -0
  24. package/server/lib/continue-plan.ts +180 -0
  25. package/server/lib/continue-read-health.ts +26 -0
  26. package/server/lib/cursor-bridge.ts +26 -6
  27. package/server/lib/cursor-fork-context.ts +140 -0
  28. package/server/lib/cursor-model-catalog.ts +62 -5
  29. package/server/lib/cursor-observer-installer.ts +79 -0
  30. package/server/lib/cursor-session-model.ts +339 -0
  31. package/server/lib/cursor-spawn-env.ts +210 -0
  32. package/server/lib/fork-thread.ts +143 -25
  33. package/server/lib/health-static-probes.ts +21 -11
  34. package/server/lib/lens-gist-engines.ts +15 -4
  35. package/server/lib/lens-gist.ts +1 -1
  36. package/server/lib/live-cues-cursor.ts +16 -6
  37. package/server/lib/occupancy-probes.ts +45 -1
  38. package/server/lib/permission-broker.ts +24 -9
  39. package/server/lib/provider-actions.ts +149 -0
  40. package/server/lib/provider-failure-text.ts +27 -0
  41. package/server/lib/provider-observe.ts +212 -0
  42. package/server/lib/provider-proof.ts +23 -14
  43. package/server/lib/query-job-runtime.ts +16 -1
  44. package/server/lib/query-job-types.ts +2 -1
  45. package/server/lib/session-act-fields.ts +61 -0
  46. package/server/lib/session-cancel.ts +95 -4
  47. package/server/lib/session-continue-facts-real.ts +74 -0
  48. package/server/lib/session-continue-facts.ts +267 -0
  49. package/server/lib/session-halt.ts +67 -6
  50. package/server/lib/session-hook-events.ts +60 -0
  51. package/server/lib/session-hook-ledger.ts +21 -3
  52. package/server/lib/session-hook-spool.ts +68 -6
  53. package/server/lib/session-hooks-runtime.ts +40 -3
  54. package/server/lib/session-peer-inbox-deps.ts +7 -6
  55. package/server/lib/session-recommendation.ts +3 -2
  56. package/server/lib/session-signal-store.ts +81 -3
  57. package/server/lib/session-stream-events.ts +32 -1
  58. package/server/lib/session-transcript-watcher.ts +25 -1
  59. package/server/lib/session-work-observation.ts +46 -0
  60. package/server/lib/thread-attach-capability.ts +15 -1
  61. package/server/lib/thread-drain-kick.ts +4 -2
  62. package/server/lib/thread-occupancy.ts +44 -0
  63. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  64. package/server/lib/thread-turn-queue.ts +6 -0
  65. package/server/lib/turn-request.ts +274 -0
  66. package/server/routes/agent-session-bindings.ts +224 -22
  67. package/server/routes/agent-session-stream.ts +26 -7
  68. package/server/routes/agent-sessions.ts +163 -10
  69. package/server/routes/health.ts +27 -2
  70. package/server/routes/session-hooks.ts +60 -3
  71. package/server/routes/thread-turn-queue.ts +37 -9
  72. package/server/scripts/hooks-cli.ts +38 -9
@@ -10,7 +10,11 @@
10
10
  // desk_run Claude Code at the desk (a Desktop tab, a terminal, a Continue delivered
11
11
  // live into the open window). A halt marker plus the synchronous PreToolUse
12
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.
13
+ // 6.62.0: also Cursor (it runs the Claude hooks, so the same marker stops it,
14
+ // C10), and Codex once the COS hooks are installed in Codex AND Codex trusts
15
+ // them (its deny-only reply, C10/C12). A pure `js` Codex step is not a tool
16
+ // call, so a Codex run stops at its next COMMAND (W20).
17
+ // unsupported the Codex app or Cursor without hooks that can reach the run.
14
18
  // hooks_* a desk Claude run on a Mac whose hooks cannot stop it yet.
15
19
  //
16
20
  // `cancelTargetFor` is the ONE function that decides, and both the cancel route and the
@@ -33,6 +37,74 @@ export interface CancelFacts {
33
37
  hooksEnabled: boolean
34
38
  /** The hooks installed on this Mac carry the 6.53.0 script and subscription. */
35
39
  hooksReady: boolean
40
+ /**
41
+ * 6.62.0: the COS hooks in `~/.codex/hooks.json` can stop a desk Codex run: installed, trusted
42
+ * by Codex itself, and this package's script. Absent: the registered reader answers
43
+ * (`setCodexDeskCancelReady`), and with none registered, no.
44
+ */
45
+ codexHooksReady?: boolean
46
+ /**
47
+ * 6.62.0 (QA W1): the Claude hooks Cursor runs carry THIS package's script, not merely a
48
+ * halt-capable earlier one: the 6.61.7 script reads the first `session_id`, which a Cursor
49
+ * payload writes behind `tool_input`, so it misses or mis-stops Cursor runs. Absent: the
50
+ * registered reader answers (`setDeskCancelReaders`), and with none registered, no.
51
+ */
52
+ cursorHooksReady?: boolean
53
+ /** 6.62.0 (QA W2): the thread the cancel is for, so the hooks can be seen to fire for IT. */
54
+ threadId?: string
55
+ /**
56
+ * 6.62.0 (QA W2): a hook event from this engine has already reached COS for this thread:
57
+ * positive evidence the hooks run there (a freshly trusted Codex hook may not be loaded by a
58
+ * long-running app; a Cursor IDE halt was canaried in `-p` only). Absent: read through the
59
+ * registered reader for `threadId`; with neither, no.
60
+ */
61
+ threadHookSeen?: boolean
62
+ }
63
+
64
+ export interface DeskCancelReaders {
65
+ codexReady: () => boolean
66
+ cursorReady: () => boolean
67
+ threadHookSeen: (provider: 'codex' | 'cursor', threadId: string) => boolean
68
+ }
69
+
70
+ const NO_READERS: DeskCancelReaders = { codexReady: () => false, cursorReady: () => false, threadHookSeen: () => false }
71
+ let readers: DeskCancelReaders = { ...NO_READERS }
72
+
73
+ /**
74
+ * 6.62.0: the composition root registers how to read Codex and Cursor readiness and the
75
+ * per-thread hook evidence (`index.ts`, `provider-observe.ts`). Unregistered reads are "no".
76
+ */
77
+ export function setDeskCancelReaders(next: Partial<DeskCancelReaders>): void {
78
+ readers = { ...readers, ...next }
79
+ }
80
+
81
+ /** 6.62.0: the Codex half of `setDeskCancelReaders`, kept for its callers. */
82
+ export function setCodexDeskCancelReady(read: () => boolean): void {
83
+ setDeskCancelReaders({ codexReady: read })
84
+ }
85
+
86
+ export function __resetDeskCancelReadersForTests(): void {
87
+ readers = { ...NO_READERS }
88
+ }
89
+
90
+ function readerSays(read: () => boolean): boolean {
91
+ try { return read() === true } catch { return false }
92
+ }
93
+
94
+ function codexReady(facts: CancelFacts): boolean {
95
+ if (typeof facts.codexHooksReady === 'boolean') return facts.codexHooksReady
96
+ return readerSays(readers.codexReady)
97
+ }
98
+
99
+ function cursorReady(facts: CancelFacts): boolean {
100
+ if (typeof facts.cursorHooksReady === 'boolean') return facts.cursorHooksReady
101
+ return readerSays(readers.cursorReady)
102
+ }
103
+
104
+ function hookSeen(facts: CancelFacts, provider: 'codex' | 'cursor'): boolean {
105
+ if (typeof facts.threadHookSeen === 'boolean') return facts.threadHookSeen
106
+ const threadId = facts.threadId
107
+ return typeof threadId === 'string' && threadId.length > 0 && readerSays(() => readers.threadHookSeen(provider, threadId))
36
108
  }
37
109
 
38
110
  /**
@@ -40,13 +112,18 @@ export interface CancelFacts {
40
112
  * 1. COS's own turn first. It is the one run COS can end at once, and a marker would
41
113
  * also stop it only at its next tool (R3), so aborting the child is always better.
42
114
  * 2. Nothing running: null. The lens shows no row at all.
43
- * 3. Codex and Cursor cannot be stopped from outside their app.
115
+ * 3. 6.62.0: Cursor stops through the Claude hooks it runs, with THIS package's script
116
+ * (`cursorHooksReady`, QA W1), and Codex through its own installed, trusted, current hooks
117
+ * (`codexHooksReady`); either only once a hook event of that engine has been seen for the
118
+ * thread (`threadHookSeen`, QA W2). Without all of it, still only from their app.
44
119
  * 4. A desk Claude run needs the hooks applied AND the new script installed; either
45
120
  * missing is its own answer, because the fixes differ (a setting vs Install hooks).
46
121
  */
47
122
  export function cancelTargetFor(facts: CancelFacts): CancelTarget | null {
48
123
  if (facts.cosTurnInFlight === true) return 'cos_turn'
49
124
  if (facts.runningOutsideCos !== true) return null
125
+ if (facts.provider === 'cursor') return facts.hooksEnabled === true && cursorReady(facts) && hookSeen(facts, 'cursor') ? 'desk_run' : 'unsupported'
126
+ if (facts.provider === 'codex') return facts.hooksEnabled === true && codexReady(facts) && hookSeen(facts, 'codex') ? 'desk_run' : 'unsupported'
50
127
  if (facts.provider !== 'claude') return 'unsupported'
51
128
  if (facts.hooksEnabled !== true) return 'hooks_disabled'
52
129
  if (facts.hooksReady !== true) return 'hooks_outdated'
@@ -59,10 +136,24 @@ export function cancelTargetFor(facts: CancelFacts): CancelTarget | null {
59
136
  * NOW: the hooks applied AND the 6.53 script and subscription installed. False until
60
137
  * Install hooks after the update, which is the rollout step Control's banner asks for.
61
138
  */
62
- export function sessionCancelFeature(hooksEnabled: boolean, hooksInstalled: boolean): { cosTurn: true; deskClaude: boolean } {
63
- return { cosTurn: true, deskClaude: hooksEnabled === true && hooksInstalled === true }
139
+ export function sessionCancelFeature(hooksEnabled: boolean, hooksInstalled: boolean, codexHooksReady = false, cursorHooksReady = false): { cosTurn: true; deskClaude: boolean; deskCodex: boolean; deskCursor: boolean } {
140
+ return {
141
+ cosTurn: true,
142
+ deskClaude: hooksEnabled === true && hooksInstalled === true,
143
+ // 6.62.0: whether THIS MAC can stop a desk Codex or Cursor run at all: Codex through its own
144
+ // installed, trusted, current hooks; Cursor through the Claude hooks with the current script
145
+ // (QA W1). Each thread's `cancel` field also needs that engine's hooks seen for it (QA W2).
146
+ deskCodex: hooksEnabled === true && codexHooksReady === true,
147
+ deskCursor: hooksEnabled === true && cursorHooksReady === true,
148
+ }
64
149
  }
65
150
 
151
+ /**
152
+ * 6.62.0 (W20): what an accepted desk cancel does, in the lens's words. A Codex `js` step is not
153
+ * a tool call, so a run stops at its next command, not at once; so does every desk run.
154
+ */
155
+ export const DESK_RUN_EFFECT_COPY = 'Stops the run at its next command.'
156
+
66
157
  /** Refusal codes on the cancel route, with the lens's own words (app 6.9.529 matches). */
67
158
  export type CancelRefusal = 'not_running' | 'cancel_unsupported' | 'hooks_outdated' | 'hooks_disabled' | 'cancel_failed' | 'invalid_request'
68
159
 
@@ -0,0 +1,74 @@
1
+ // The production wiring for `session-continue-facts.ts`: live catalogs and live stores,
2
+ // read-only. A module of its own so the resolver's tests never import the live catalogs
3
+ // (which read ~/.codex and the repo's model cache at import).
4
+ //
5
+ // Roots are read PER CALL, not captured at construction, so `COS_AGENT_SESSIONS_HOME` (the
6
+ // session store's own override, which the route tests set) decides every path here too,
7
+ // the Codex config included.
8
+
9
+ import { join } from 'node:path'
10
+ import type { ContinueFactsDeps } from './session-continue-facts.js'
11
+ import { agentSessionRoots } from './agent-session-store.js'
12
+ import { getCodexTrustMode } from './codex-run-ledger.js'
13
+ import { codexModelDisplayName, isKnownCodexModel } from './codex-model-catalog.js'
14
+ import { cursorModelDisplayName, isKnownCursorModel } from './cursor-model-catalog.js'
15
+ import { resolveCursorAgentSession } from './cursor-agent-store.js'
16
+ import {
17
+ codexConfigPath,
18
+ codexFolderTrusted,
19
+ codexGitTrustRoot,
20
+ codexGitTrustRootSync,
21
+ readCodexConfigText,
22
+ type GitRunner,
23
+ type GitRunnerSync,
24
+ } from './codex-session-posture.js'
25
+ import { execFile, execFileSync } from 'node:child_process'
26
+ import { existsSync } from 'node:fs'
27
+
28
+ /** Homebrew's git first: `/usr/bin/git` without the developer tools opens an install dialog. */
29
+ const GIT_BIN = ['/opt/homebrew/bin/git', '/usr/local/bin/git', '/usr/bin/git'].find(path => existsSync(path)) ?? '/usr/bin/git'
30
+ const GIT_ENV = { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' }
31
+
32
+ export const realGitRunner: GitRunner = (args, cwd, timeoutMs) => new Promise((resolve, reject) => {
33
+ execFile(GIT_BIN, [...args], { cwd, timeout: timeoutMs, encoding: 'utf8', env: GIT_ENV, maxBuffer: 256 * 1024 }, (error, stdout) => {
34
+ if (error) reject(error)
35
+ else resolve(String(stdout))
36
+ })
37
+ })
38
+
39
+ export const realGitRunnerSync: GitRunnerSync = (args, cwd, timeoutMs) =>
40
+ String(execFileSync(GIT_BIN, [...args], { cwd, timeout: timeoutMs, encoding: 'utf8', env: GIT_ENV, maxBuffer: 256 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }))
41
+
42
+ function liveCodexConfigText(): string | null {
43
+ const home = process.env.COS_AGENT_SESSIONS_HOME?.trim()
44
+ return readCodexConfigText(home ? join(home, '.codex', 'config.toml') : codexConfigPath())
45
+ }
46
+
47
+ /**
48
+ * 6.62.0 /qa (W5, N1): the adapter's SPAWN-TIME re-check for a full-access Codex plan, read
49
+ * fresh from the config (the git root usually comes from the plan's cache seconds before).
50
+ */
51
+ export function codexFolderTrustedNow(cwd: string): boolean {
52
+ try {
53
+ return codexFolderTrusted(cwd, codexGitTrustRootSync(cwd, realGitRunnerSync), liveCodexConfigText())
54
+ } catch {
55
+ return false
56
+ }
57
+ }
58
+
59
+ /** The production wiring: live catalogs, live stores, read-only. */
60
+ export function realContinueFactsDeps(): ContinueFactsDeps {
61
+ return {
62
+ codexFallbackSandbox: () => getCodexTrustMode(),
63
+ isKnownCodexModel: id => isKnownCodexModel(id),
64
+ codexModelLabel: id => codexModelDisplayName(id),
65
+ codexConfigText: () => liveCodexConfigText(),
66
+ codexGitTrustRoot: cwd => codexGitTrustRoot(cwd, realGitRunner),
67
+ isKnownCursorModel: id => isKnownCursorModel(id),
68
+ cursorModelLabel: id => cursorModelDisplayName(id),
69
+ cursorChatDir: threadId => resolveCursorAgentSession(threadId, agentSessionRoots().cursorChats)?.dir ?? null,
70
+ get cursorComposerDb() {
71
+ return agentSessionRoots().cursorComposerDb
72
+ },
73
+ }
74
+ }
@@ -0,0 +1,267 @@
1
+ // One resolver for "what model is this session on, and what would a Continue run with"
2
+ // (6.62.0, plans 1.8, 3.2, 3.3, 3.12).
3
+ //
4
+ // These readers share the resolver; observations may change between reads:
5
+ // - the attached shim in index.ts (the plan that becomes argv),
6
+ // - the detail route (`reported_model`, `continue_note`); list rows skip these reads,
7
+ // - the attachability verdict (`continue_note`).
8
+ //
9
+ // Every read is bounded and fails to "unknown": `reported_model` is OMITTED on any failure
10
+ // (never blanked, never guessed), and a Continue whose session cannot be read falls back
11
+ // to the 6.61 posture with a note that says so.
12
+
13
+ import { open, stat } from 'node:fs/promises'
14
+ import {
15
+ codexReportedModel,
16
+ planCodexContinue,
17
+ readCodexConfigText,
18
+ readCodexSessionPosture,
19
+ type CodexSessionPosture,
20
+ } from './codex-session-posture.js'
21
+ import {
22
+ continueFullPermissionsEnabled,
23
+ killSwitchNote,
24
+ planCursorContinue,
25
+ CURSOR_ASK_UNACKED_NOTE,
26
+ CURSOR_COMPOSER_NOTE,
27
+ type AttachedContinuePlan,
28
+ } from './continue-plan.js'
29
+ import {
30
+ cursorSlugCandidates,
31
+ pickListedCursorModel,
32
+ readCliChatFacts,
33
+ readComposerFacts,
34
+ } from './cursor-session-model.js'
35
+
36
+ /** The lens's own filter for this field (session-model-identity.ts), applied here first. */
37
+ export const REPORTED_MODEL_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:/ -]*$/
38
+ export const REPORTED_MODEL_MAX = 100
39
+
40
+ export function sanitizeReportedModel(value: unknown): string | null {
41
+ if (typeof value !== 'string') return null
42
+ const trimmed = value.trim()
43
+ if (trimmed.length === 0 || trimmed.length > REPORTED_MODEL_MAX) return null
44
+ return REPORTED_MODEL_RE.test(trimmed) ? trimmed : null
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Claude: the transcript's newest `message.model`
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const CLAUDE_TAIL_WINDOWS = [1024 * 1024, 8 * 1024 * 1024]
52
+ const claudeModelMemo = new Map<string, { mtimeMs: number; size: number; model: string | null }>()
53
+ const MAX_MEMO = 512
54
+
55
+ /** Tests only. */
56
+ export function __resetSessionContinueFactsForTests(): void {
57
+ claudeModelMemo.clear()
58
+ fallbackLogged.clear()
59
+ }
60
+
61
+ function newestClaudeModel(text: string): string | null {
62
+ const lines = text.split('\n')
63
+ for (let i = lines.length - 1; i >= 0; i--) {
64
+ const line = lines[i]!
65
+ if (!line.includes('"assistant"') || !line.includes('"model"')) continue
66
+ let row: any
67
+ try { row = JSON.parse(line) } catch { continue }
68
+ if (row?.type !== 'assistant') continue
69
+ const model = row?.message?.model
70
+ // `<synthetic>` is Claude Code's own stand-in for a reply no model wrote (an API error,
71
+ // an interrupt). It is not what the session runs on.
72
+ if (typeof model === 'string' && model.trim() && model !== '<synthetic>') return model.trim()
73
+ }
74
+ return null
75
+ }
76
+
77
+ /** Claude's newest real model from the transcript tail, memoized on (mtime, size). */
78
+ export async function readClaudeReportedModel(path: string | null | undefined): Promise<string | null> {
79
+ if (typeof path !== 'string' || !path.startsWith('/')) return null
80
+ let mtimeMs: number
81
+ let size: number
82
+ try {
83
+ const st = await stat(path)
84
+ if (!st.isFile()) return null
85
+ mtimeMs = st.mtimeMs
86
+ size = st.size
87
+ } catch {
88
+ return null
89
+ }
90
+ const memo = claudeModelMemo.get(path)
91
+ if (memo && memo.mtimeMs === mtimeMs && memo.size === size) return memo.model
92
+ let model: string | null = null
93
+ try {
94
+ const handle = await open(path, 'r')
95
+ try {
96
+ for (const window of CLAUDE_TAIL_WINDOWS) {
97
+ const length = Math.min(window, size)
98
+ const buffer = Buffer.alloc(length)
99
+ const { bytesRead } = await handle.read(buffer, 0, length, size - length)
100
+ model = newestClaudeModel(buffer.subarray(0, bytesRead).toString('utf8'))
101
+ if (model !== null || length >= size) break
102
+ }
103
+ } finally {
104
+ await handle.close()
105
+ }
106
+ } catch {
107
+ return null
108
+ }
109
+ claudeModelMemo.delete(path)
110
+ claudeModelMemo.set(path, { mtimeMs, size, model })
111
+ while (claudeModelMemo.size > MAX_MEMO) {
112
+ const oldest = claudeModelMemo.keys().next()
113
+ if (oldest.done) break
114
+ claudeModelMemo.delete(oldest.value)
115
+ }
116
+ return model
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // 6.62.0 /qa (W17): every silent fallback gets a server log line, once per thread per window
121
+ // ---------------------------------------------------------------------------
122
+
123
+ export const FALLBACK_LOG_EVERY_MS = 10 * 60_000
124
+ const fallbackLogged = new Map<string, number>()
125
+
126
+ /** Logs `[continue-plan] <what> thread=<8> <detail>` unless the same pair logged recently. */
127
+ export function logContinueFallback(what: string, threadId: string, detail: string, now = Date.now()): boolean {
128
+ const key = `${what}:${threadId}`
129
+ const last = fallbackLogged.get(key)
130
+ if (last !== undefined && now - last < FALLBACK_LOG_EVERY_MS) return false
131
+ fallbackLogged.delete(key)
132
+ fallbackLogged.set(key, now)
133
+ while (fallbackLogged.size > 512) {
134
+ const oldest = fallbackLogged.keys().next()
135
+ if (oldest.done) break
136
+ fallbackLogged.delete(oldest.value)
137
+ }
138
+ console.warn(`[continue-plan] ${what} thread=${String(threadId).slice(0, 8)} ${detail}`)
139
+ return true
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // The resolver
144
+ // ---------------------------------------------------------------------------
145
+
146
+ export interface ContinueFactsDeps {
147
+ env?: NodeJS.ProcessEnv
148
+ /** The 6.61 Codex sandbox (`getCodexTrustMode`). */
149
+ codexFallbackSandbox: () => 'read-only' | 'workspace-write'
150
+ isKnownCodexModel: (id: string) => boolean
151
+ codexModelLabel: (id: string) => string | null
152
+ /** `~/.codex/config.toml` text. Defaults to the live, read-only reader. */
153
+ codexConfigText?: () => string | null
154
+ /** 6.62.0 /qa (W5): the cwd's git trust root (K7), bounded; absent means no git lookup. */
155
+ codexGitTrustRoot?: (cwd: string) => Promise<string | null>
156
+ isKnownCursorModel: (id: string) => boolean
157
+ cursorModelLabel: (id: string) => string | null
158
+ /** The CLI chat dir for this id (`~/.cursor/chats/<hash>/<id>`), or null for an IDE composer. */
159
+ cursorChatDir: (threadId: string) => string | null
160
+ /** `globalStorage/state.vscdb`. */
161
+ cursorComposerDb: string
162
+ }
163
+
164
+ export interface SessionContinueFacts {
165
+ /** For the attached shim only. Undefined: the 6.61 argv. */
166
+ plan?: AttachedContinuePlan
167
+ /** At most 60 characters, or null (Claude: unchanged, so no note). */
168
+ note: string | null
169
+ /** Sanitized to the lens rule, or null (then omitted from the wire). */
170
+ reportedModel: string | null
171
+ }
172
+
173
+ export interface ContinueFactsInput {
174
+ provider: string
175
+ threadId: string
176
+ /** The transcript or rollout, when the caller already holds it. */
177
+ transcriptPath: string | null
178
+ /** The Continue's real cwd (the shim's resolved workspace). Rows pass null. */
179
+ cwd: string | null
180
+ /** Pre-read composer facts for list pages (one query for the whole page). */
181
+ composerFacts?: Map<string, import('./cursor-session-model.js').ComposerFacts | null>
182
+ /**
183
+ * 6.62.0 /qa (Q3): the client sent `X-COS-Continue-Note: 1` (it shows this note). Without
184
+ * it a Cursor Continue gets NO plan (the 6.61 Ask posture) and a note that says so.
185
+ */
186
+ noteAck?: boolean
187
+ }
188
+
189
+ export async function resolveSessionContinueFacts(
190
+ input: ContinueFactsInput,
191
+ deps: ContinueFactsDeps,
192
+ ): Promise<SessionContinueFacts> {
193
+ const full = continueFullPermissionsEnabled(deps.env)
194
+ if (input.provider === 'claude') {
195
+ return { note: null, reportedModel: sanitizeReportedModel(await readClaudeReportedModel(input.transcriptPath)) }
196
+ }
197
+ if (input.provider === 'codex') {
198
+ let posture: CodexSessionPosture | null = null
199
+ try {
200
+ posture = await readCodexSessionPosture(input.transcriptPath)
201
+ } catch {
202
+ posture = null
203
+ }
204
+ const reportedModel = sanitizeReportedModel(codexReportedModel(posture))
205
+ let fallback: 'read-only' | 'workspace-write' = 'read-only'
206
+ try { fallback = deps.codexFallbackSandbox() === 'workspace-write' ? 'workspace-write' : 'read-only' } catch { fallback = 'read-only' }
207
+ if (!full) return { note: killSwitchNote('codex', fallback), reportedModel }
208
+ const cwd = input.cwd ?? posture?.cwd ?? ''
209
+ // Only full access needs the trust question, and only it pays for the git lookup.
210
+ let gitTrustRoot: string | null = null
211
+ if (posture?.sandboxType === 'danger-full-access' && typeof deps.codexGitTrustRoot === 'function') {
212
+ try { gitTrustRoot = await deps.codexGitTrustRoot(cwd) } catch { gitTrustRoot = null }
213
+ }
214
+ const plan = planCodexContinue(posture, cwd, {
215
+ fallbackSandbox: () => fallback,
216
+ configText: deps.codexConfigText ?? (() => readCodexConfigText()),
217
+ gitTrustRoot,
218
+ isKnownModel: deps.isKnownCodexModel,
219
+ modelLabel: deps.codexModelLabel,
220
+ })
221
+ if (plan.source === 'fallback') logContinueFallback('codex posture unreadable', input.threadId, `fallback=${plan.sandbox}`)
222
+ return { plan, note: plan.note, reportedModel }
223
+ }
224
+ if (input.provider === 'cursor') {
225
+ const id = input.threadId.toLowerCase()
226
+ let chatDir: string | null = null
227
+ try { chatDir = deps.cursorChatDir(id) } catch { chatDir = null }
228
+ if (chatDir) {
229
+ const chat = await readCliChatFacts(chatDir)
230
+ const reportedModel = sanitizeReportedModel(chat?.model ?? null)
231
+ if (!full) return { note: killSwitchNote('cursor', 'read-only'), reportedModel }
232
+ if (input.noteAck !== true) return { note: CURSOR_ASK_UNACKED_NOTE, reportedModel }
233
+ const plan = planCursorContinue({
234
+ candidates: chat?.model ? [chat.model] : [],
235
+ isKnown: deps.isKnownCursorModel,
236
+ label: deps.cursorModelLabel,
237
+ askModeChat: chat?.mode === 'ask',
238
+ })
239
+ if (!plan.matched) logContinueFallback('cursor model unmatched', input.threadId, `model=${chat?.model ?? 'unread'} fallback=${plan.model}`)
240
+ return { plan, note: plan.note, reportedModel }
241
+ }
242
+ // An IDE composer: no plan (it is never resumed from outside), and its model is read
243
+ // from the composer record in a query of its own.
244
+ let facts = input.composerFacts?.get(id)
245
+ if (facts === undefined) {
246
+ try {
247
+ facts = (await readComposerFacts([id], deps.cursorComposerDb)).get(id) ?? null
248
+ } catch {
249
+ facts = null
250
+ }
251
+ }
252
+ const candidates = cursorSlugCandidates(facts?.modelConfig ?? null)
253
+ const listed = pickListedCursorModel(candidates, deps.isKnownCursorModel)
254
+ return { note: CURSOR_COMPOSER_NOTE, reportedModel: sanitizeReportedModel(listed ?? candidates[0] ?? null) }
255
+ }
256
+ return { note: null, reportedModel: null }
257
+ }
258
+
259
+ /** The two wire keys, omitted when unknown, so an older client sees the same payload. */
260
+ export function continueFactsFields(facts: SessionContinueFacts | null | undefined): { reported_model?: string; continue_note?: string } {
261
+ if (!facts) return {}
262
+ return {
263
+ ...(facts.reportedModel ? { reported_model: facts.reportedModel } : {}),
264
+ ...(facts.note ? { continue_note: facts.note } : {}),
265
+ }
266
+ }
267
+
@@ -100,6 +100,44 @@ export function clearHaltMarker(sessionId: string, dir = haltDir()): boolean {
100
100
  }
101
101
  }
102
102
 
103
+ /**
104
+ * 6.62.0 /qa (Q4): COS is about to start its OWN child on this thread (a Continue, a queue
105
+ * drain, or a fork). A marker left by an earlier desk cancel must not deny it: Cursor `-p`
106
+ * fires neither UserPromptSubmit nor SessionEnd, so until this a cancelled Cursor CLI chat
107
+ * refused every Continue for the marker's hour. The callers only reach here once the
108
+ * occupancy gate (Continue, drain) or an explicit probe (fork) says no desk run is working,
109
+ * so no live desk cancel is undone. True when a marker was removed.
110
+ */
111
+ export function clearHaltForCosChild(sessionId: string, why: 'continue' | 'fork', dir = haltDir()): boolean {
112
+ const cleared = clearHaltMarker(sessionId, dir)
113
+ if (cleared) console.log(`[session-halt] marker cleared: COS starts its own ${why} child session=${String(sessionId).slice(0, 8)}`)
114
+ return cleared
115
+ }
116
+
117
+ /**
118
+ * The same deps with `spawn` clearing the thread's marker first, at the moment the child is
119
+ * created (after every gate and ban check), and only when `mayClear` says no desk run is
120
+ * working. A throwing `mayClear` clears nothing.
121
+ */
122
+ export function clearingHaltOnSpawn<T extends { spawn: (request: never) => unknown }>(
123
+ deps: T,
124
+ sessionId: string,
125
+ why: 'continue' | 'fork',
126
+ mayClear: () => boolean = () => true,
127
+ dir = haltDir(),
128
+ ): T {
129
+ const spawn = deps.spawn as (request: unknown) => unknown
130
+ return {
131
+ ...deps,
132
+ spawn: ((request: unknown) => {
133
+ let clear = false
134
+ try { clear = mayClear() === true } catch { clear = false }
135
+ if (clear) clearHaltForCosChild(sessionId, why, dir)
136
+ return spawn(request)
137
+ }) as T['spawn'],
138
+ }
139
+ }
140
+
103
141
  /**
104
142
  * Clear the marker because the SESSION ended, but only a marker written at or before the
105
143
  * event. A SessionEnd drained late from the spool must not remove a cancel written after
@@ -149,6 +187,11 @@ export function clearHaltMarkerOnSessionEnd(sessionId: string, endedAt: number,
149
187
  // a halted desk run is the worse error, and the lens already said "at its next step".
150
188
  // - LIMIT, stated: a desk prompt with exactly the delivered words, typed after the send and
151
189
  // taken first, is indistinguishable from the delivered turn and re-arms.
190
+ //
191
+ // 6.62.0 (plan 3.6, W8): a Codex turn queued into the Codex app (`codex queue`, from the busy
192
+ // fast path or the idle hop) is re-armed too. Codex hands the queued text to its own
193
+ // UserPromptSubmit unchanged, with no peer wrapper, so that comparison is VERBATIM (only the
194
+ // ends trimmed): no wrapper is taken off and no inner whitespace is collapsed.
152
195
 
153
196
  interface PendingRearm {
154
197
  marker: HaltMarker
@@ -157,6 +200,10 @@ interface PendingRearm {
157
200
  /** Prompts submitted before this are someone else's. */
158
201
  after: number
159
202
  until: number
203
+ /** 6.62.0: a Codex queued turn, compared verbatim (no peer unwrap). */
204
+ verbatim: boolean
205
+ /** The delivered text as sent, for the verbatim comparison. */
206
+ text: string
160
207
  }
161
208
 
162
209
  const pendingRearms = new Map<string, PendingRearm>()
@@ -171,12 +218,21 @@ const MAX_PENDING_REARMS = 64
171
218
  export function haltDeliveredTurn(
172
219
  sessionId: string,
173
220
  marker: HaltMarker,
174
- turn: { prompt: string; after: number; rearm: boolean; now?: number; windowMs?: number },
221
+ turn: { prompt: string; after: number; rearm: boolean; now?: number; windowMs?: number; verbatim?: boolean; deferMarker?: boolean },
175
222
  dir = haltDir(),
176
223
  ): boolean {
177
- if (!writeHaltMarker(sessionId, marker, dir)) return false
178
- const id = sessionId.toLowerCase()
224
+ const id = typeof sessionId === 'string' ? sessionId.toLowerCase() : ''
179
225
  const words = normalizePromptWords(turn.prompt)
226
+ // 6.62.0 /qa (W3): `deferMarker` (a busy hop) writes NOTHING now: the session is still on
227
+ // the person's own turn, and a marker would stop it. Only the re-arm is registered, and it
228
+ // writes the marker when the delivered turn's own prompt arrives. A delivered turn that has
229
+ // already started (`rearm` false) is ours, so it is written at once as before.
230
+ const deferred = turn.deferMarker === true && turn.rearm && words.length > 0
231
+ if (deferred) {
232
+ if (haltMarkerPath(id, dir) === null) return false
233
+ } else if (!writeHaltMarker(sessionId, marker, dir)) {
234
+ return false
235
+ }
180
236
  if (!turn.rearm || words.length === 0) {
181
237
  pendingRearms.delete(id)
182
238
  return true
@@ -186,7 +242,7 @@ export function haltDeliveredTurn(
186
242
  ? Math.min(turn.windowMs, HALT_MARKER_TTL_MS)
187
243
  : HALT_MARKER_TTL_MS
188
244
  pendingRearms.delete(id)
189
- pendingRearms.set(id, { marker, words, after: turn.after, until: now + windowMs })
245
+ pendingRearms.set(id, { marker, words, after: turn.after, until: now + windowMs, verbatim: turn.verbatim === true, text: turn.prompt })
190
246
  while (pendingRearms.size > MAX_PENDING_REARMS) {
191
247
  const oldest = pendingRearms.keys().next()
192
248
  if (oldest.done) break
@@ -204,7 +260,12 @@ export function normalizePromptWords(text: unknown): string {
204
260
  * Is `prompt` (a UserPromptSubmit's `prompt`) the delivered prompt? Equal words, compared
205
261
  * whole, after taking off Claude Code's peer wrapper when the prompt arrived in one.
206
262
  */
207
- export function isDeliveredPrompt(prompt: unknown, delivered: string): boolean {
263
+ export function isDeliveredPrompt(prompt: unknown, delivered: string, options: { verbatim?: boolean } = {}): boolean {
264
+ if (options.verbatim === true) {
265
+ // 6.62.0: Codex's queued turn arrives as its own text. No wrapper to take off.
266
+ const want = typeof delivered === 'string' ? delivered.trim() : ''
267
+ return want.length > 0 && typeof prompt === 'string' && prompt.trim() === want
268
+ }
208
269
  const want = normalizePromptWords(delivered)
209
270
  if (want.length === 0 || typeof prompt !== 'string') return false
210
271
  return normalizePromptWords(unwrapPeerMessage(prompt) ?? prompt) === want
@@ -226,7 +287,7 @@ export function rearmHaltOnPrompt(sessionId: string, promptAt: number, prompt: u
226
287
  }
227
288
  if (!Number.isFinite(promptAt) || promptAt < pending.after) return false
228
289
  pendingRearms.delete(id)
229
- if (!isDeliveredPrompt(prompt, pending.words)) {
290
+ if (!(pending.verbatim ? isDeliveredPrompt(prompt, pending.text, { verbatim: true }) : isDeliveredPrompt(prompt, pending.words))) {
230
291
  console.warn(`[session-halt] re-arm dropped: the first prompt after the hand-off was not the delivered turn session=${id.slice(0, 8)} clientCancelId=${pending.marker.clientCancelId}`)
231
292
  return false
232
293
  }