@gotcos/glasses-server 6.61.7 → 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 +34 -0
- package/README.md +5 -1
- package/bin/cli.cjs +50 -3
- package/bin/hooks/cos-cursor-observer.cjs +11 -4
- 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 +4 -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 +22 -2
- 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/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 +48 -7
- 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 +57 -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 +32 -2
- 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 +74 -5
- package/server/lib/session-stream-events.ts +32 -1
- 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 +155 -10
- package/server/routes/health.ts +27 -2
- package/server/routes/session-hooks.ts +56 -5
- package/server/routes/thread-turn-queue.ts +37 -9
- package/server/scripts/hooks-cli.ts +33 -7
|
@@ -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
|
-
//
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|