@gotcos/glasses-server 6.61.7 → 6.63.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.
Files changed (70) hide show
  1. package/.env.example +6 -0
  2. package/CHANGELOG.md +39 -0
  3. package/README.md +5 -1
  4. package/bin/cli.cjs +50 -3
  5. package/bin/hooks/cos-cursor-observer.cjs +11 -4
  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 +4 -2
  10. package/server/index.ts +141 -17
  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 +22 -2
  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/continue-plan.ts +180 -0
  24. package/server/lib/continue-read-health.ts +26 -0
  25. package/server/lib/cursor-bridge.ts +26 -6
  26. package/server/lib/cursor-fork-context.ts +140 -0
  27. package/server/lib/cursor-model-catalog.ts +62 -5
  28. package/server/lib/cursor-observer-installer.ts +48 -7
  29. package/server/lib/cursor-session-model.ts +339 -0
  30. package/server/lib/cursor-spawn-env.ts +210 -0
  31. package/server/lib/fork-job-ledger.ts +194 -0
  32. package/server/lib/fork-thread.ts +169 -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 +57 -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 +32 -2
  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 +74 -5
  57. package/server/lib/session-stream-events.ts +32 -1
  58. package/server/lib/thread-attach-capability.ts +15 -1
  59. package/server/lib/thread-drain-kick.ts +4 -2
  60. package/server/lib/thread-occupancy.ts +44 -0
  61. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  62. package/server/lib/thread-turn-queue.ts +6 -0
  63. package/server/lib/turn-request.ts +274 -0
  64. package/server/routes/agent-session-bindings.ts +349 -26
  65. package/server/routes/agent-session-stream.ts +26 -7
  66. package/server/routes/agent-sessions.ts +155 -10
  67. package/server/routes/health.ts +27 -2
  68. package/server/routes/session-hooks.ts +56 -5
  69. package/server/routes/thread-turn-queue.ts +37 -9
  70. package/server/scripts/hooks-cli.ts +33 -7
@@ -29,6 +29,7 @@ import {
29
29
  } from './conversation.js'
30
30
  import {
31
31
  isTerminalQueryJobStatus,
32
+ type QueryJobProviderLinkage,
32
33
  type QueryJobRequest,
33
34
  type QueryJobSnapshot,
34
35
  } from './query-job-types.js'
@@ -179,6 +180,17 @@ export async function projectPublicConversationTerminal(
179
180
  flushConversationToDisk()
180
181
  }
181
182
 
183
+ /**
184
+ * 6.62.0 (plan 3.9, W11): a provider's early "this is my session" into the job's linkage.
185
+ * A Claude announcement carries no provider (its bridge is unchanged) and reads as Claude.
186
+ */
187
+ export function nativeSessionLinkage(linkage: { cliSessionId: string; provider?: 'claude' | 'codex' | 'cursor' }): QueryJobProviderLinkage {
188
+ const provider = linkage.provider ?? 'claude'
189
+ return provider === 'codex'
190
+ ? { provider, codexThreadId: linkage.cliSessionId }
191
+ : { provider, cliSessionId: linkage.cliSessionId }
192
+ }
193
+
182
194
  const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callbacks }) => {
183
195
  // Resolve ids again at execution time. This closes the admission/execution
184
196
  // TOCTOU window without ever putting paths or bytes in the journal.
@@ -235,7 +247,10 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
235
247
  : { codexRunId: metadata.runId }),
236
248
  }),
237
249
  // 6.58.2: the provider's own session id, as soon as it names it (Claude: first stream event).
238
- onNativeSession: linkage => { void callbacks.onLinkage?.({ provider: 'claude', cliSessionId: linkage.cliSessionId }) },
250
+ // 6.62.0 (plan 3.9, W11): provider-aware, so a Codex or Cursor job links to its native
251
+ // session as early as a Claude one. Codex names it as `codexThreadId`, the field its
252
+ // completion has always used. No naming for Codex or Cursor this release (uncanaried).
253
+ onNativeSession: linkage => { void callbacks.onLinkage?.(nativeSessionLinkage(linkage)) },
239
254
  onChunk: text => { callbacks.onChunk(text) },
240
255
  onToolStatus: toolName => {
241
256
  const message = request.activityToolMode === 'off'
@@ -1,3 +1,4 @@
1
+ import { providerFailureCode } from './provider-failure-text.js'
1
2
  import { createHash } from 'node:crypto'
2
3
  import {
3
4
  parseMediaAttachmentRefs,
@@ -587,7 +588,7 @@ export function normalizeQueryJobError(error: unknown, fallbackCode = 'query_job
587
588
  : typeof error === 'string' ? error : fallbackCode
588
589
  const safe = sanitizeQueryJobActivity(rawMessage)
589
590
  return {
590
- code,
591
+ code: providerFailureCode(code, typeof rawMessage === 'string' ? rawMessage : ''),
591
592
  message: safe.text,
592
593
  ...(candidate.retryable === true ? { retryable: true } : {}),
593
594
  ...(typeof candidate.retryAfterMs === 'number' && Number.isFinite(candidate.retryAfterMs)
@@ -0,0 +1,61 @@
1
+ // `reported_model` and `continue_note` for the DETAIL route (6.62.0, plans 1.8 and 3.12).
2
+ // The route calls these two helpers and nothing else, so its own row shape (owned by the
3
+ // observation work) is untouched.
4
+ //
5
+ // 6.62.0 /qa (W11): list rows carry neither field. The lens reads both from the detail and
6
+ // the attach verdict only, and the list used to wait on these reads (transcript tails,
7
+ // rollout scans, sqlite) with no budget. Every failure here is an omitted field.
8
+
9
+ import { mapWithConcurrency, ACTIVITY_READ_CONCURRENCY } from './agent-session-activity.js'
10
+ import { readComposerFacts, type ComposerFacts } from './cursor-session-model.js'
11
+ import { continueFactsFields, resolveSessionContinueFacts, type ContinueFactsDeps, type SessionContinueFacts } from './session-continue-facts.js'
12
+ import { realContinueFactsDeps } from './session-continue-facts-real.js'
13
+
14
+ let deps: ContinueFactsDeps | null = null
15
+ function liveDeps(): ContinueFactsDeps {
16
+ deps ??= realContinueFactsDeps()
17
+ return deps
18
+ }
19
+
20
+ /** Tests only: drive the helpers with fakes. */
21
+ export function __setSessionActDepsForTests(next: ContinueFactsDeps | null): void {
22
+ deps = next
23
+ }
24
+
25
+ export interface SessionActRow {
26
+ provider: string
27
+ session_id: string
28
+ file?: string | null
29
+ }
30
+
31
+ /** The facts for each row, in order. Composer models for the whole page come from ONE query. */
32
+ export async function sessionActFactsFor(rows: readonly SessionActRow[], options: { noteAck?: boolean } = {}): Promise<Array<SessionContinueFacts | null>> {
33
+ const d = liveDeps()
34
+ let composerFacts: Map<string, ComposerFacts | null> | undefined
35
+ const cursorIds = rows.filter(row => row.provider === 'cursor').map(row => row.session_id)
36
+ if (cursorIds.length > 0) {
37
+ try {
38
+ composerFacts = await readComposerFacts(cursorIds, d.cursorComposerDb)
39
+ } catch {
40
+ composerFacts = undefined
41
+ }
42
+ }
43
+ return mapWithConcurrency(rows, ACTIVITY_READ_CONCURRENCY, async row => {
44
+ try {
45
+ return await resolveSessionContinueFacts({
46
+ provider: row.provider,
47
+ threadId: row.session_id,
48
+ transcriptPath: row.file ?? null,
49
+ cwd: null,
50
+ composerFacts,
51
+ noteAck: options.noteAck === true,
52
+ }, d)
53
+ } catch {
54
+ return null
55
+ }
56
+ })
57
+ }
58
+
59
+ export function sessionActFields(facts: SessionContinueFacts | null | undefined): { reported_model?: string; continue_note?: string } {
60
+ return continueFactsFields(facts)
61
+ }
@@ -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
+