@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.
- package/.env.example +6 -0
- package/CHANGELOG.md +39 -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 +141 -17
- 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-job-ledger.ts +194 -0
- package/server/lib/fork-thread.ts +169 -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 +349 -26
- 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
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
+
|