@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.
Files changed (69) hide show
  1. package/.env.example +6 -0
  2. package/CHANGELOG.md +34 -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 +135 -16
  11. package/server/lib/agent-session-binding-store.ts +9 -3
  12. package/server/lib/agent-session-store.ts +7 -4
  13. package/server/lib/attached-provider-adapter.ts +158 -45
  14. package/server/lib/banned-permission-args.ts +42 -1
  15. package/server/lib/claude-bridge.ts +17 -8
  16. package/server/lib/claude-hooks-installer.ts +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-thread.ts +143 -25
  32. package/server/lib/health-static-probes.ts +21 -11
  33. package/server/lib/lens-gist-engines.ts +15 -4
  34. package/server/lib/lens-gist.ts +1 -1
  35. package/server/lib/live-cues-cursor.ts +16 -6
  36. package/server/lib/occupancy-probes.ts +45 -1
  37. package/server/lib/permission-broker.ts +24 -9
  38. package/server/lib/provider-actions.ts +149 -0
  39. package/server/lib/provider-failure-text.ts +27 -0
  40. package/server/lib/provider-observe.ts +212 -0
  41. package/server/lib/provider-proof.ts +23 -14
  42. package/server/lib/query-job-runtime.ts +16 -1
  43. package/server/lib/query-job-types.ts +2 -1
  44. package/server/lib/session-act-fields.ts +61 -0
  45. package/server/lib/session-cancel.ts +95 -4
  46. package/server/lib/session-continue-facts-real.ts +74 -0
  47. package/server/lib/session-continue-facts.ts +267 -0
  48. package/server/lib/session-halt.ts +67 -6
  49. package/server/lib/session-hook-events.ts +57 -0
  50. package/server/lib/session-hook-ledger.ts +21 -3
  51. package/server/lib/session-hook-spool.ts +68 -6
  52. package/server/lib/session-hooks-runtime.ts +32 -2
  53. package/server/lib/session-peer-inbox-deps.ts +7 -6
  54. package/server/lib/session-recommendation.ts +3 -2
  55. package/server/lib/session-signal-store.ts +74 -5
  56. package/server/lib/session-stream-events.ts +32 -1
  57. package/server/lib/thread-attach-capability.ts +15 -1
  58. package/server/lib/thread-drain-kick.ts +4 -2
  59. package/server/lib/thread-occupancy.ts +44 -0
  60. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  61. package/server/lib/thread-turn-queue.ts +6 -0
  62. package/server/lib/turn-request.ts +274 -0
  63. package/server/routes/agent-session-bindings.ts +224 -22
  64. package/server/routes/agent-session-stream.ts +26 -7
  65. package/server/routes/agent-sessions.ts +155 -10
  66. package/server/routes/health.ts +27 -2
  67. package/server/routes/session-hooks.ts +56 -5
  68. package/server/routes/thread-turn-queue.ts +37 -9
  69. package/server/scripts/hooks-cli.ts +33 -7
package/server/index.ts CHANGED
@@ -24,9 +24,10 @@ import { agentSessionStreamRouter } from './routes/agent-session-stream.js'
24
24
  import { createAttachedTurnStream } from './lib/session-stream-producer.js'
25
25
  import { claudeSessionsRouter } from './routes/claude-sessions.js'
26
26
  import { createSessionHooksRouter } from './routes/session-hooks.js'
27
- import { cachedHookStatus, claudeDeskRunning, onSessionRowEnded, deskIdleSeconds, deskTurnEndedAt, haltHandedOffTurn, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
28
- import { writeHaltMarker } from './lib/session-halt.js'
29
- import { appendSessionCancelLedger, cancelHoldUntil as cancelHoldUntilFor, noteThreadCancelled, threadCancel } from './lib/session-cancel.js'
27
+ import { cachedHookStatus, claudeDeskRunning, onSessionRowEnded, deskIdleSeconds, deskTurnEndedAt, haltHandedOffTurn, providerHookSeen, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
28
+ import { clearingHaltOnSpawn, writeHaltMarker } from './lib/session-halt.js'
29
+ import { startProviderObserveRefresh } from './lib/provider-observe.js'
30
+ import { appendSessionCancelLedger, cancelHoldUntil as cancelHoldUntilFor, noteThreadCancelled, setDeskCancelReaders, threadCancel } from './lib/session-cancel.js'
30
31
  import { makeQueueTurnEvidence } from './lib/queue-turn-evidence.js'
31
32
  import {
32
33
  PermissionBroker,
@@ -55,6 +56,14 @@ import { readCodexTurnEndedAtMs } from './lib/codex-turn-clock.js'
55
56
  import { codexLiveQueueEnabled } from './lib/codex-live-queue.js'
56
57
  import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './lib/attached-workspace.js'
57
58
  import { deliverAttachedTurn, realAttachedTurnDeps } from './lib/attached-provider-adapter.js'
59
+ import { resolveSessionContinueFacts } from './lib/session-continue-facts.js'
60
+ import { fallbackContinueNote } from './lib/continue-plan.js'
61
+ import { codexFolderTrustedNow, realContinueFactsDeps } from './lib/session-continue-facts-real.js'
62
+ import { prepareCursorFork, resolveCursorForkWorkspace } from './lib/cursor-fork-context.js'
63
+ import { readComposerFacts } from './lib/cursor-session-model.js'
64
+ import { MAX_PROMPT_CHARS } from './lib/attached-provider-adapter.js'
65
+ import { readRecentSessionTurns } from './lib/agent-session-turns.js'
66
+ import { parseAgentSession } from './lib/agent-session-store.js'
58
67
  import { makeLiveTurnDeliverer } from './lib/session-peer-inbox-deps.js'
59
68
  import { makeCodexLiveDeliverer } from './lib/codex-live-queue-deps.js'
60
69
  import { forkThread, realForkDeps } from './lib/fork-thread.js'
@@ -105,6 +114,7 @@ import { skillsRouter } from './routes/skills.js'
105
114
  import { shutdownLiveCues } from './lib/live-cues-engine.js'
106
115
  import { prewarmContext } from './lib/context-builder.js'
107
116
  import { preWarmCLI } from './lib/claude-bridge.js'
117
+ import { sweepStaleTurnRequests } from './lib/turn-request.js'
108
118
  import { getCodexRunConfig } from './lib/codex-run-ledger.js'
109
119
  import {
110
120
  startCodexModelCatalogRefresh,
@@ -166,7 +176,7 @@ const app = express()
166
176
  import { createThreadTurnQueueRouter, drainAllThreads } from './routes/thread-turn-queue.js'
167
177
  import { createCursorStopFollowupRouter } from './routes/cursor-stop-followup.js'
168
178
  import { queuedThreadKeys, readQueue, transcriptTurnVerdict, writeQueue } from './lib/thread-turn-queue-store.js'
169
- import { hookHaltReady, readHookToken } from './lib/claude-hooks-installer.js'
179
+ import { cursorDeskHaltReady, hookHaltReady, readHookToken } from './lib/claude-hooks-installer.js'
170
180
  import { OPEN_TURN_CEILING_MS } from './lib/session-state-derive.js'
171
181
  import { createDrainKick, kickPlanFor } from './lib/thread-drain-kick.js'
172
182
  import { transcriptPathFor } from './lib/native-head.js'
@@ -461,6 +471,8 @@ bindingReapTimer.unref()
461
471
  const occupancyDirs = realOccupancyDirs()
462
472
  const nativeHeadDeps = realNativeHeadDeps()
463
473
  const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
474
+ // 6.62.0: one set of readers for the Continue plan, rows, detail and the attach verdict.
475
+ const continueFactsDeps = realContinueFactsDeps()
464
476
  /**
465
477
  * THE ONE PLACE the idle-holder relaxation is switched on, on the ONE flag
466
478
  * that also registers the write routes (6.33.0).
@@ -507,6 +519,15 @@ const occupancyProbes = withCursorComposerTurn(withCodexTurnClock(sessionHooksEn
507
519
  // before any router is registered so the first list request already sees the replayed
508
520
  // ledger. With COS_SESSION_HOOKS off it still drains and stamps the spool (see the module).
509
521
  const sessionHooksRuntime = startSessionHooksRuntime({ port: PORT })
522
+ // 6.62.0: the Codex hooks, their trust and the Cursor observer, read in the background for
523
+ // health and the desk cancel (never on a request path; see lib/provider-observe.ts).
524
+ startProviderObserveRefresh()
525
+ // 6.62.0 (QA W1, W2): a desk Cursor cancel needs the CURRENT script, and a Codex or Cursor one
526
+ // needs that engine's hooks already seen for the thread (see lib/session-cancel.ts).
527
+ setDeskCancelReaders({
528
+ cursorReady: () => cursorDeskHaltReady(cachedHookStatus()),
529
+ threadHookSeen: (provider, threadId) => providerHookSeen(provider, threadId),
530
+ })
510
531
 
511
532
  /**
512
533
  * The shim between the route's request shape and the adapter's.
@@ -540,6 +561,8 @@ const deliverAttachedTurnForRoute = async (request: {
540
561
  onSpawn: (pid: number) => boolean
541
562
  /** 6.53.0: the cancel from the lens, passed straight through to the adapter. */
542
563
  abortSignal?: AbortSignal
564
+ /** 6.62.0 /qa (Q3): the client shows `continue_note`; only then Cursor Run Everything. */
565
+ continueNoteAck?: boolean
543
566
  }): Promise<unknown> => {
544
567
  // Resolved here, not stored on the binding: the cwd is read from the transcript,
545
568
  // which records it verbatim. Decoding the project slug is lossy — this Mac's own
@@ -568,6 +591,27 @@ const deliverAttachedTurnForRoute = async (request: {
568
591
  // turn sees `working` rather than silence, and closed in a `finally` because the
569
592
  // duplicate-suppression gate it holds must be released on EVERY exit path -- a stuck
570
593
  // gate would silence Phase 2 for that session permanently.
594
+ // 6.62.0 (D1): what this Continue runs with. Claude: no plan, unchanged. Codex: the
595
+ // session's own sandbox, model and effort. Cursor: Run Everything on its own model. A
596
+ // read that fails plans the 6.61 posture with a note; never anything broader. With
597
+ // COS_CONTINUE_FULL_PERMISSIONS=0 no plan is resolved and the adapter builds the 6.61 argv.
598
+ let plan: unknown
599
+ if (request.provider !== 'claude') {
600
+ try {
601
+ const facts = await resolveSessionContinueFacts({
602
+ provider: request.provider,
603
+ threadId: request.nativeThreadId,
604
+ transcriptPath: attachedWorkspaceDeps.transcriptPath(request.provider, request.nativeThreadId),
605
+ cwd: workspace.path,
606
+ noteAck: request.continueNoteAck === true,
607
+ }, continueFactsDeps)
608
+ plan = facts.plan
609
+ } catch (error) {
610
+ console.error(`[attached] continue plan failed provider=${request.provider}: ${error instanceof Error ? error.message : error}`)
611
+ plan = undefined
612
+ }
613
+ }
614
+
571
615
  const live = createAttachedTurnStream({
572
616
  provider: request.provider,
573
617
  sessionId: request.nativeThreadId,
@@ -579,12 +623,18 @@ const deliverAttachedTurnForRoute = async (request: {
579
623
  nativeThreadId: request.nativeThreadId,
580
624
  prompt: request.prompt,
581
625
  cwd: workspace.path,
582
- // The only policy this build accepts. The adapter refuses anything else and
583
- // asserts no bypass/always-approve flag reaches the argv (plan 4.7).
584
- policy: 'read_only',
626
+ // 6.62.0: the session's own posture, carried by `plan`. The adapter refuses any other
627
+ // policy word, validates the plan, and still bans every bypass flag but the one the
628
+ // plan's allowance names, at both of its checks (plan 3.1).
629
+ policy: 'session_posture',
630
+ plan,
585
631
  abortSignal: request.abortSignal,
586
632
  deps: {
587
- ...base,
633
+ // 6.62.0 /qa (Q4): the child is about to start, every gate passed (a Continue or a
634
+ // queue drain): no earlier desk-cancel marker may deny COS's own turn.
635
+ ...clearingHaltOnSpawn(base, request.nativeThreadId, 'continue'),
636
+ // 6.62.0 /qa (W5): full access is re-checked against Codex's own trust at the spawn.
637
+ codexFolderTrusted: cwd => codexFolderTrustedNow(cwd),
588
638
  // `startMs` is deliberately unused: the adapter already probed it as a GATE
589
639
  // (a null there aborts before this is reached), and the route probes again
590
640
  // as the recorder. One record, one authority.
@@ -630,16 +680,55 @@ const deliverAttachedTurnForRoute = async (request: {
630
680
  * ever report `unverified`, and it would report that honestly rather than
631
681
  * claiming a verification it never performed.
632
682
  */
633
- const forkThreadForRoute = (request: {
634
- provider: 'claude' | 'codex'
683
+ const forkThreadForRoute = async (request: {
684
+ provider: 'claude' | 'codex' | 'cursor'
635
685
  nativeThreadId: string
636
686
  prompt: string
637
687
  cwd: string
638
688
  policy: 'read_only'
639
- }): Promise<unknown> => forkThread({
640
- ...request,
641
- deps: realForkDeps((provider, threadId) => nativeHead(provider, threadId, nativeHeadDeps)),
642
- })
689
+ }): Promise<unknown> => {
690
+ // 6.62.0 (D4, plan 3.7): Cursor has no native fork, so its "fork" is a NEW read-only chat
691
+ // whose first prompt carries a bounded bundle of the source, then the message, on the
692
+ // session's own model. Claude and Codex are unchanged.
693
+ let prompt = request.prompt
694
+ let cursorModel: string | null = null
695
+ if (request.provider === 'cursor') {
696
+ const prepared = await prepareCursorFork(request.nativeThreadId, request.prompt, {
697
+ transcriptPath: threadId => attachedWorkspaceDeps.transcriptPath('cursor', threadId),
698
+ readTurns: async (path, limit) => (await readRecentSessionTurns('cursor', path, limit)).turns,
699
+ readTitle: async path => {
700
+ const parsed = await parseAgentSession('cursor', path)
701
+ return { title: parsed.display_label || null, firstPrompt: parsed.first_prompt || null }
702
+ },
703
+ sessionModel: async (threadId, path) => {
704
+ const facts = await resolveSessionContinueFacts({ provider: 'cursor', threadId, transcriptPath: path, cwd: request.cwd }, continueFactsDeps)
705
+ if (facts.plan?.provider === 'cursor' && facts.plan.matched) return facts.plan.model
706
+ return facts.reportedModel && continueFactsDeps.isKnownCursorModel(facts.reportedModel) ? facts.reportedModel : null
707
+ },
708
+ maxChars: MAX_PROMPT_CHARS,
709
+ })
710
+ prompt = prepared.prompt
711
+ cursorModel = prepared.cursorModel
712
+ }
713
+ return forkThread({
714
+ ...request,
715
+ prompt,
716
+ ...(cursorModel ? { cursorModel } : {}),
717
+ deps: forkDepsClearingIdleHalt(request.provider, request.nativeThreadId),
718
+ })
719
+ }
720
+
721
+ /**
722
+ * 6.62.0 /qa (Q4): the fork deps, with the source thread's halt marker cleared at the spawn,
723
+ * but ONLY when no desk run is working there. A fork has no occupancy gate (it may run while
724
+ * the desk works), and clearing then would undo the person's own cancel.
725
+ */
726
+ function forkDepsClearingIdleHalt(provider: 'claude' | 'codex' | 'cursor', threadId: string) {
727
+ return clearingHaltOnSpawn(realForkDeps((p, id) => nativeHead(p, id, nativeHeadDeps)), threadId, 'fork', () => {
728
+ const reason = threadOccupancy(provider, threadId, occupancyProbes, occupancyDirs).reason
729
+ return reason !== 'native_thread_working' && reason !== 'live_desktop_process'
730
+ })
731
+ }
643
732
 
644
733
  // API routes
645
734
  app.use('/api', healthRouter)
@@ -802,6 +891,8 @@ if (threadAttachEnabled()) {
802
891
  deliver: (turn: QueuedThreadTurn) => deliverQueuedTurnOverLoopback(turn, PORT, API_TOKEN),
803
892
  // 6.53.0: parked turns hold for two minutes after a cancel on their thread.
804
893
  cancelHoldUntil: (provider: string, threadId: string) => cancelHoldUntilFor(provider, threadId),
894
+ // 6.62.0 (W14): a busy Agent CLI chat's parked turn drains here (no Stop hook fires for `-p`).
895
+ cursorCliChat: (threadId: string) => continueFactsDeps.cursorChatDir(threadId) !== null,
805
896
  now: () => Date.now(),
806
897
  }
807
898
  app.use('/api', createThreadTurnQueueRouter(queueDeps))
@@ -882,13 +973,36 @@ app.use('/api', createAgentSessionBindingsRouter({
882
973
  // (`codex queue`), never into a resume child that cannot open a held thread.
883
974
  // `COS_CODEX_LIVE_QUEUE=0` restores the 6.50 path.
884
975
  deliverCodexLiveTurn: makeCodexLiveDeliverer(),
976
+ // 6.62.0 (plan 3.6, W8): a BUSY thread the Codex app holds takes the same hop, but only
977
+ // while the COS queue holds nothing for it, so a new turn never jumps a parked one. The
978
+ // turn route's clientTurnId ledger is the one idempotency key per turn.
979
+ busyCodexHop: (threadId: string) => codexLiveQueueEnabled()
980
+ && readQueue('codex', threadId, Date.now()).every(turn => turn.status !== 'waiting' && turn.status !== 'delivering'),
981
+ // 6.62.0 (plan 3.12): the attach verdict says what a Continue would run with.
982
+ continueNote: async (provider, threadId, options) => (await resolveSessionContinueFacts({
983
+ provider,
984
+ threadId,
985
+ transcriptPath: attachedWorkspaceDeps.transcriptPath(provider, threadId),
986
+ cwd: resolveAttachedWorkspace(provider, threadId, attachedWorkspaceDeps)?.path ?? null,
987
+ noteAck: options?.noteAck === true,
988
+ }, continueFactsDeps)).note,
989
+ // 6.62.0 /qa (W9): the fixed note when the read misses its budget.
990
+ continueNoteFallback: (provider, options) => fallbackContinueNote(provider, { noteAck: options.noteAck, codexFallbackSandbox: continueFactsDeps.codexFallbackSandbox() }),
885
991
  forkThread: forkThreadForRoute,
886
992
  // The fork's real spawn directory. Separate from `resolveTarget` above, which
887
993
  // deliberately yields only fingerprints because plan 3.3 keeps a filesystem path
888
994
  // off anything client-visible. Null refuses: never fall back to the server's own
889
995
  // working directory, which is wherever the LaunchAgent happened to start.
890
- resolveForkWorkspace: (provider, threadId) =>
891
- resolveAttachedWorkspace(provider, threadId, attachedWorkspaceDeps)?.path ?? null,
996
+ // 6.62.0 (W9): a Cursor IDE composer has no spawn spelling; its fork runs in the hook
997
+ // signal's cwd, else the composer's own recorded folder, else the CLI chat's spelling.
998
+ resolveForkWorkspace: (provider, threadId) => provider === 'cursor'
999
+ ? resolveCursorForkWorkspace(threadId, {
1000
+ signalCwd: id => signalFor(id)?.cwd ?? null,
1001
+ composerWorkspace: async id => (await readComposerFacts([id], continueFactsDeps.cursorComposerDb)).get(id)?.workspace ?? null,
1002
+ spawnWorkspace: id => attachedWorkspaceDeps.cursorSpawnWorkspace?.(id) ?? null,
1003
+ dirExists: path => attachedWorkspaceDeps.dirExists(path),
1004
+ })
1005
+ : resolveAttachedWorkspace(provider, threadId, attachedWorkspaceDeps)?.path ?? null,
892
1006
  // One instance per process. The epoch high-water mark is only monotonic if a
893
1007
  // single reader owns the durable store, so this must never be constructed twice.
894
1008
  // `open()` never throws: an unreadable store yields a DEGRADED registry whose
@@ -1227,6 +1341,11 @@ listenRequiredServers(listeners).then(() => {
1227
1341
  })
1228
1342
  .catch(error => console.warn('[meeting-merge] pending re-drive failed', error))
1229
1343
 
1344
+ // Turn-request files (lib/turn-request.ts) carry a user's words for the COS memory hook
1345
+ // and die with their child. One left by a crash or a kill must not outlive an hour.
1346
+ const staleTurnRequests = sweepStaleTurnRequests()
1347
+ if (staleTurnRequests > 0) console.log(`[turn-request] removed ${staleTurnRequests} stale file(s)`)
1348
+
1230
1349
  void initQueryJobRuntime().then(health => {
1231
1350
  if (process.env.COS_DURABLE_QUERY_JOBS !== '0') {
1232
1351
  console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained · ${health.store.requeuedOnBoot ?? 0} requeued from the last boot`)
@@ -39,12 +39,18 @@ import type { AgentProvider } from './agent-session-store.js'
39
39
 
40
40
  export type BindingState = 'staging' | 'active' | 'detaching' | 'detached'
41
41
 
42
- /** Providers that can carry a Continue binding. Cursor is bindable, not forkable. */
42
+ /** Providers that can carry a Continue binding. */
43
43
  export const BINDABLE_PROVIDERS = ['claude', 'codex', 'cursor'] as const
44
44
  export type BindableProvider = (typeof BINDABLE_PROVIDERS)[number]
45
45
 
46
- /** Providers that can fork. Cursor Agent has no `--fork-session` equivalent. */
47
- export const FORKABLE_PROVIDERS = ['claude', 'codex'] as const
46
+ /**
47
+ * Providers the fork route serves. 6.62.0 (D4, plan 3.7): Cursor Agent has no
48
+ * `--fork-session`, so a Cursor "fork" is a NEW read-only chat seeded with a bounded
49
+ * context bundle ("New session with this context"). Work's own fork list
50
+ * (`work-activity.ts` FORK_PROVIDERS) and the Jev recommender stay Claude and Codex until
51
+ * the Control release (W9).
52
+ */
53
+ export const FORKABLE_PROVIDERS = ['claude', 'codex', 'cursor'] as const
48
54
  export type ForkableProvider = (typeof FORKABLE_PROVIDERS)[number]
49
55
 
50
56
  export interface NativeBinding {
@@ -1270,10 +1270,12 @@ export async function listCursorSessions(
1270
1270
  const candidate = candidates[i]
1271
1271
  const peek = await peekCursorDiscussion(candidate.file)
1272
1272
  const firstPrompt = await firstCursorUserTitle(candidate.file) ?? peek.lastUser ?? ''
1273
+ // 6.62.0 (plan 1.6): the composer's name, else the FIRST query, as the detail titles it.
1274
+ // The newest query is what changes turn to turn; a title that moved with every prompt
1275
+ // never named the session.
1273
1276
  const title = composerNames.get(candidate.sessionDir)
1274
- ?? peek.lastUser
1275
- ?? firstPrompt
1276
- ?? 'Cursor session'
1277
+ || firstPrompt
1278
+ || 'Cursor session'
1277
1279
  if (isKeepWarmSessionTitle(title)) continue
1278
1280
  const alive = now.getTime() - candidate.mtimeMs < 180_000
1279
1281
  rows.push(row({
@@ -1835,7 +1837,8 @@ export async function parseAgentSession(
1835
1837
  const query = cursorUserTitle(text, true) ?? cursorUserTitle(text, false)
1836
1838
  collectTurn(query ?? text, tail)
1837
1839
  if (query) {
1838
- title = query
1840
+ // 6.62.0 (plan 1.6): the FIRST query titles the session, as on its list row.
1841
+ if (!title) title = query
1839
1842
  if (!firstPrompt) firstPrompt = query
1840
1843
  }
1841
1844
  } else {
@@ -90,6 +90,7 @@
90
90
  // process and lets a RECYCLED pid inherit our self-ownership claim, which
91
91
  // is the one input that can turn a live foreign owner into `attachable`.
92
92
 
93
+ import { PROVIDER_QUOTA_RE } from './provider-failure-text.js'
93
94
  import { accessSync, constants as fsConstants, statSync } from 'node:fs'
94
95
  import { delimiter, isAbsolute, join } from 'node:path'
95
96
  import { homedir } from 'node:os'
@@ -101,19 +102,30 @@ import { recordCosSpawn, releaseCosSpawn } from './agent-session-ownership-store
101
102
  import { interpretPsLstart, processStartMs as realProcessStartMs } from './occupancy-probes.js'
102
103
  import { getCodexTrustMode } from './codex-run-ledger.js'
103
104
  import { CURSOR_SLOT_MODEL_IDS } from './cursor-model-catalog.js'
105
+ import { cursorIsolationUnavailableError, cursorSpawnEnv, isCursorIsolationUnavailable, releaseCursorSpawnOnExit } from './cursor-spawn-env.js'
106
+ import type { PermissionAllowance } from './banned-permission-args.js'
107
+ import {
108
+ continueAllowanceFor,
109
+ validateContinuePlan,
110
+ type AttachedContinuePlan,
111
+ type CodexContinuePlan,
112
+ type CursorContinuePlan,
113
+ } from './continue-plan.js'
104
114
 
105
115
  /** Providers with a certified attached path. Cursor is bindable, not forkable. */
106
116
  export type AttachedProvider = BindableProvider
107
117
 
108
118
  /**
109
- * Protocol 1 ships text-only read-only continuation (plan 4.7). The type has
110
- * one member on purpose: `agent` is not "not implemented yet", it is a value
111
- * this module must refuse until Control gates it per binding.
119
+ * 6.62.0 (D1): a Continue runs with the SESSION'S OWN posture, described by the request's
120
+ * `plan` (`continue-plan.ts`). Until 6.61 this was `'read_only'`, a name that had stopped
121
+ * being true for Claude on 2026-08-16. The type still has one member on purpose: `agent`
122
+ * and anything else is a value this module refuses, and the plan, not the policy word,
123
+ * is what may ever widen the argv.
112
124
  */
113
- export type AttachedPermissionPolicy = 'read_only'
125
+ export type AttachedPermissionPolicy = 'session_posture'
114
126
 
115
127
  export function isAttachedPermissionPolicy(value: unknown): value is AttachedPermissionPolicy {
116
- return value === 'read_only'
128
+ return value === 'session_posture'
117
129
  }
118
130
 
119
131
  /** Terminal reason on failure. Never carries prompt or transcript text. */
@@ -284,6 +296,11 @@ export interface AttachedSpawnRequest {
284
296
  args: readonly string[]
285
297
  cwd: string
286
298
  env: NodeJS.ProcessEnv
299
+ /**
300
+ * 6.62.0: which CLI this is, so the real spawn can isolate a Cursor child's config dir
301
+ * (`cursor-spawn-env.ts`). Optional so every test double keeps its shape.
302
+ */
303
+ provider?: AttachedProvider
287
304
  }
288
305
 
289
306
  /** Result of the final occupancy re-check (plan 4.3 step 6). */
@@ -341,7 +358,7 @@ export interface AttachedTurnDeps {
341
358
  * Exists ONLY so a test can hand back an argv carrying a banned permission flag
342
359
  * and prove the turn is refused with zero spawns. Production never sets it.
343
360
  */
344
- buildArgs?: (provider: AttachedProvider, nativeThreadId: string, cwd: string) => string[]
361
+ buildArgs?: (provider: AttachedProvider, nativeThreadId: string, cwd: string, plan?: AttachedContinuePlan) => string[]
345
362
  /**
346
363
  * A PASSIVE reader of the child's stdout. Optional; omitted changes nothing.
347
364
  *
@@ -361,6 +378,13 @@ export interface AttachedTurnDeps {
361
378
  * future edit can let it modify what the scan sees.
362
379
  */
363
380
  observeStdout?: (chunk: string) => void
381
+ /**
382
+ * 6.62.0 /qa (W5, N1): does Codex itself trust this cwd RIGHT NOW (the cwd or its git
383
+ * root is a trusted `[projects]` entry)? Asked at the spawn for a full-access plan only;
384
+ * absent, false, or a throw refuses that plan. The plan was built from the same rule
385
+ * seconds earlier; this is the second check, so the allowance never outlives the trust.
386
+ */
387
+ codexFolderTrusted?: (cwd: string) => boolean
364
388
  }
365
389
 
366
390
  export interface AttachedTurnRequest {
@@ -369,6 +393,12 @@ export interface AttachedTurnRequest {
369
393
  prompt: unknown
370
394
  cwd: unknown
371
395
  policy: unknown
396
+ /**
397
+ * 6.62.0: what this Continue runs with (`continue-plan.ts`). Absent: the 6.61 argv for
398
+ * every provider. Validated here, structurally, before any argv is built; a plan for
399
+ * another provider or with an unusable value refuses the turn rather than being ignored.
400
+ */
401
+ plan?: unknown
372
402
  deps: AttachedTurnDeps
373
403
  /** Wall-clock budget for the provider run. Omitted uses the default. */
374
404
  timeoutMs?: number
@@ -506,7 +536,13 @@ export function buildClaudeAttachedArgs(nativeThreadId: string): string[] {
506
536
  }
507
537
 
508
538
  /**
509
- * Codex: `codex exec -s read-only -C <cwd> resume --json <id> -`.
539
+ * Codex: `codex exec [-m <model>] [-c model_reasoning_effort=…] --sandbox <s> [-c
540
+ * sandbox_workspace_write.network_access=…] --cd <cwd> resume --json <id> -`.
541
+ *
542
+ * 6.62.0 (D1, canary C11): with a plan, the session's OWN model, effort and sandbox are
543
+ * passed, because `exec resume` otherwise takes the config defaults for all three. Every
544
+ * one of them is an option of `exec` and precedes `resume`. With no plan (the kill switch,
545
+ * or an older caller) the argv is the 6.61 one, byte for byte.
510
546
  *
511
547
  * Verified against codex-cli 0.148.0-alpha.9 on 2026-08-15:
512
548
  * `codex exec resume [OPTIONS] [SESSION_ID] [PROMPT]`. `--sandbox` and `--cd`
@@ -518,33 +554,61 @@ export function buildClaudeAttachedArgs(nativeThreadId: string): string[] {
518
554
  * `--ephemeral` is deliberately absent: the whole point is that the turn
519
555
  * persists into the user's rollout.
520
556
  */
521
- export function buildCodexAttachedArgs(nativeThreadId: string, cwd: string): string[] {
522
- return [
523
- 'exec',
524
- // The posture ordinary Codex runs use on this host, not a stricter one
525
- // invented here. `COS_CODEX_SANDBOX=workspace-write` opts in; absent stays
526
- // read-only, so this is never MORE permissive than the rest of the server.
527
- '--sandbox', getCodexTrustMode(),
528
- '--cd', cwd,
529
- 'resume',
530
- '--json',
531
- '--skip-git-repo-check',
532
- nativeThreadId,
533
- '-',
534
- ]
557
+ export function buildCodexAttachedArgs(nativeThreadId: string, cwd: string, plan?: CodexContinuePlan): string[] {
558
+ if (!plan) {
559
+ return [
560
+ 'exec',
561
+ // The posture ordinary Codex runs use on this host, not a stricter one
562
+ // invented here. `COS_CODEX_SANDBOX=workspace-write` opts in; absent stays
563
+ // read-only, so this is never MORE permissive than the rest of the server.
564
+ '--sandbox', getCodexTrustMode(),
565
+ '--cd', cwd,
566
+ 'resume',
567
+ '--json',
568
+ '--skip-git-repo-check',
569
+ nativeThreadId,
570
+ '-',
571
+ ]
572
+ }
573
+ const args = ['exec']
574
+ if (plan.model) args.push('-m', plan.model)
575
+ if (plan.effort) args.push('-c', `model_reasoning_effort="${plan.effort}"`)
576
+ args.push('--sandbox', plan.sandbox)
577
+ if (plan.sandbox === 'workspace-write' && typeof plan.networkAccess === 'boolean') {
578
+ args.push('-c', `sandbox_workspace_write.network_access=${plan.networkAccess}`)
579
+ }
580
+ args.push('--cd', cwd, 'resume', '--json', '--skip-git-repo-check', nativeThreadId, '-')
581
+ return args
535
582
  }
536
583
 
537
584
  /**
538
- * Cursor Agent CLI: ask-mode resume into an existing thread.
585
+ * Cursor Agent CLI: resume into an existing thread.
586
+ *
587
+ * 6.62.0 (D1, canaries C4/C5): with a plan, Run Everything (`--force`) on the session's
588
+ * own model. Agent mode alone is not full permissions (C4: the CLI's allowlist still
589
+ * blocked `touch`), and `--resume` without `--model` runs the GLOBAL default (C5). `--force`
590
+ * stays on the ban list: only the plan's allowance lets this one argv slot through, at
591
+ * both checks. With no plan (the kill switch) the argv is the 6.61 ask-mode one.
539
592
  *
540
- * `--force` is the agent-mode bypass and is banned here. Workspace is the
541
- * spawn spelling from `spawnWorkspace`, never realpath of `meta.json.cwd`.
593
+ * Workspace is the spawn spelling from `spawnWorkspace`, never realpath of `meta.json.cwd`.
542
594
  */
543
- export function buildCursorAttachedArgs(nativeThreadId: string, cwd: string): string[] {
595
+ export function buildCursorAttachedArgs(nativeThreadId: string, cwd: string, plan?: CursorContinuePlan): string[] {
596
+ if (!plan) {
597
+ return [
598
+ '-p',
599
+ '--mode', 'ask',
600
+ '--model', CURSOR_SLOT_MODEL_IDS['cursor-composer'],
601
+ '--output-format', 'stream-json',
602
+ '--stream-partial-output',
603
+ '--trust',
604
+ '--workspace', cwd,
605
+ '--resume', nativeThreadId,
606
+ ]
607
+ }
544
608
  return [
545
609
  '-p',
546
- '--mode', 'ask',
547
- '--model', CURSOR_SLOT_MODEL_IDS['cursor-composer'],
610
+ '--force',
611
+ '--model', plan.model,
548
612
  '--output-format', 'stream-json',
549
613
  '--stream-partial-output',
550
614
  '--trust',
@@ -567,13 +631,20 @@ export function buildCursorAttachedArgs(nativeThreadId: string, cwd: string): st
567
631
  * than returning a sentinel: there is no safe degraded argv, and the caller's
568
632
  * outer catch already maps a throw to a terminal refusal before any spawn.
569
633
  */
570
- function buildArgs(provider: AttachedProvider, nativeThreadId: string, cwd: string): string[] {
634
+ export function buildAttachedArgs(
635
+ provider: AttachedProvider,
636
+ nativeThreadId: string,
637
+ cwd: string,
638
+ plan?: AttachedContinuePlan,
639
+ allowance?: PermissionAllowance,
640
+ ): string[] {
571
641
  const args = provider === 'claude'
572
642
  ? buildClaudeAttachedArgs(nativeThreadId)
573
643
  : provider === 'codex'
574
- ? buildCodexAttachedArgs(nativeThreadId, cwd)
575
- : buildCursorAttachedArgs(nativeThreadId, cwd)
576
- const banned = findBannedPermissionArg(args)
644
+ ? buildCodexAttachedArgs(nativeThreadId, cwd, plan?.provider === 'codex' ? plan : undefined)
645
+ : buildCursorAttachedArgs(nativeThreadId, cwd, plan?.provider === 'cursor' ? plan : undefined)
646
+ // 6.62.0: the SAME allowance object the spawn boundary below receives (plan 3.1).
647
+ const banned = findBannedPermissionArg(args, allowance)
577
648
  if (banned !== null) {
578
649
  // The flag name only. Never the argv, which carries the thread id and cwd.
579
650
  throw new Error(`attached argv carries a banned permission flag: ${banned}`)
@@ -644,7 +715,8 @@ export function classifyStderr(text: string): AttachedStderrClass {
644
715
  return 'thread_not_found'
645
716
  }
646
717
  if (/unauthor|forbidden|not logged in|sign in|login|auth|credential|token expired/.test(sample)) return 'auth'
647
- if (/rate limit|too many requests|quota|overloaded/.test(sample)) return 'rate_limit'
718
+ // 6.62.1: the shared pattern, so "hit your session limit" / "usage limit" class as a limit here too.
719
+ if (/rate limit|too many requests|quota|overloaded/.test(sample) || PROVIDER_QUOTA_RE.test(sample)) return 'rate_limit'
648
720
  if (/permission|denied|read-only|sandbox|operation not permitted|eacces/.test(sample)) return 'permission'
649
721
  return 'unclassified'
650
722
  }
@@ -757,6 +829,17 @@ async function run(
757
829
  return fail('unsupported_policy', 'not_attempted', { ...base, durationMs: duration() })
758
830
  }
759
831
 
832
+ // 6.62.0: the plan, validated before anything is built from it. A wrong-provider or
833
+ // malformed plan is a wiring bug, and a wiring bug refuses rather than falling back to
834
+ // a different argv than the caller meant.
835
+ const checkedPlan = validateContinuePlan(provider, request.plan)
836
+ if (checkedPlan === 'invalid') {
837
+ return fail('unsupported_policy', 'not_attempted', { ...base, detail: 'invalid_plan', durationMs: duration() })
838
+ }
839
+ const plan: AttachedContinuePlan | undefined = checkedPlan ?? undefined
840
+ // ONE allowance, built once, handed to the builder AND the spawn-boundary recheck.
841
+ const allowance = continueAllowanceFor(plan)
842
+
760
843
  if (typeof request.prompt !== 'string' || request.prompt.length === 0) {
761
844
  return fail('invalid_prompt', 'not_attempted', { ...base, detail: 'empty', durationMs: duration() })
762
845
  }
@@ -824,6 +907,21 @@ async function run(
824
907
  return fail('native_owner_appeared', 'not_attempted', { ...base, detail: reasonText, durationMs: duration() })
825
908
  }
826
909
 
910
+ // 6.62.0 /qa (W5, N1): a full-access Codex plan is re-checked against Codex's own trust at
911
+ // the spawn. A folder that stopped being trusted (or never was) refuses, never downgrades:
912
+ // the note promised full access, and a quieter posture would make the note a lie.
913
+ if (plan?.provider === 'codex' && plan.sandbox === 'danger-full-access') {
914
+ let trusted = false
915
+ try {
916
+ trusted = typeof deps.codexFolderTrusted === 'function' && deps.codexFolderTrusted(cwd) === true
917
+ } catch {
918
+ trusted = false
919
+ }
920
+ if (!trusted) {
921
+ return fail('unsupported_policy', 'not_attempted', { ...base, detail: 'codex_folder_untrusted', durationMs: duration() })
922
+ }
923
+ }
924
+
827
925
  // --- 4. Spawn ---------------------------------------------------------------
828
926
  // Built through an injectable seam so the banned-flag check below is reachable
829
927
  // by a test. Without the seam no test could make the builders emit a banned
@@ -832,14 +930,16 @@ async function run(
832
930
  // nowhere.
833
931
  let args: string[]
834
932
  try {
835
- args = (deps.buildArgs ?? buildArgs)(provider, nativeThreadId, cwd)
933
+ args = deps.buildArgs
934
+ ? deps.buildArgs(provider, nativeThreadId, cwd, plan)
935
+ : buildAttachedArgs(provider, nativeThreadId, cwd, plan, allowance)
836
936
  } catch {
837
937
  return fail('unsupported_policy', 'not_attempted', { ...base, detail: 'argv_build_failed', durationMs: duration() })
838
938
  }
839
939
  // Plan 4.7, enforced against the argv that is about to be spawned rather than
840
940
  // against the source that builds it. No safe degraded argv exists, so this is a
841
941
  // terminal refusal before any process is created.
842
- const bannedArg = findBannedPermissionArg(args)
942
+ const bannedArg = findBannedPermissionArg(args, allowance)
843
943
  if (bannedArg !== null) {
844
944
  return fail('unsupported_policy', 'not_attempted', { ...base, detail: `banned_arg:${bannedArg}`, durationMs: duration() })
845
945
  }
@@ -852,10 +952,11 @@ async function run(
852
952
  }
853
953
  let child: AttachedChildProcess
854
954
  try {
855
- child = deps.spawn({ binaryPath, args, cwd, env: buildAttachedEnv() })
856
- } catch {
955
+ child = deps.spawn({ binaryPath, args, cwd, env: buildAttachedEnv(), provider })
956
+ } catch (error) {
857
957
  // Includes ENOENT. No process exists, so no turn can have landed.
858
- return fail('spawn_failed', 'not_attempted', { ...base, detail: 'threw', durationMs: duration() })
958
+ const detail = isCursorIsolationUnavailable(error) ? 'cursor_isolation_unavailable' : 'threw'
959
+ return fail('spawn_failed', 'not_attempted', { ...base, detail, durationMs: duration() })
859
960
  }
860
961
  if (!child || typeof child !== 'object' || typeof child.on !== 'function') {
861
962
  return fail('spawn_failed', 'not_attempted', { ...base, detail: 'no_child', durationMs: duration() })
@@ -1764,14 +1865,26 @@ export function realAttachedTurnDeps(preflight: () => AttachedPreflightVerdict):
1764
1865
  // after it. This is the floor that keeps group ownership (rule 4 above) from ever
1765
1866
  // claiming a process that predates the turn.
1766
1867
  const floor = Date.now() - OWNED_START_SLACK_MS
1767
- const child = nodeSpawn(request.binaryPath, [...request.args], {
1768
- stdio: ['pipe', 'pipe', 'pipe'],
1769
- cwd: request.cwd,
1770
- env: request.env,
1771
- // Group leader, so the whole provider tree can be signalled on timeout —
1772
- // matching what both ordinary bridges do.
1773
- detached: true,
1774
- })
1868
+ // 6.62.0 (plan 3.4): a Cursor child gets a config dir of its own, so `--model` cannot
1869
+ // rewrite the person's CLI default (C9). Child env only; removed when it exits.
1870
+ const isolation = request.provider === 'cursor' ? cursorSpawnEnv({ baseEnv: request.env }) : null
1871
+ // 6.62.0 /qa (W6): a Continue never falls back to the shared Cursor config.
1872
+ if (isolation && !isolation.isolated) throw cursorIsolationUnavailableError()
1873
+ let child: ReturnType<typeof nodeSpawn>
1874
+ try {
1875
+ child = nodeSpawn(request.binaryPath, [...request.args], {
1876
+ stdio: ['pipe', 'pipe', 'pipe'],
1877
+ cwd: request.cwd,
1878
+ env: isolation ? isolation.env : request.env,
1879
+ // Group leader, so the whole provider tree can be signalled on timeout —
1880
+ // matching what both ordinary bridges do.
1881
+ detached: true,
1882
+ })
1883
+ } catch (error) {
1884
+ isolation?.release()
1885
+ throw error
1886
+ }
1887
+ if (isolation) releaseCursorSpawnOnExit(child, isolation)
1775
1888
  const pid = child.pid
1776
1889
  if (typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0) treeMemory.floorByRoot.set(pid, floor)
1777
1890
  return child as unknown as AttachedChildProcess