@gotcos/glasses-server 6.61.7 → 6.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.env.example +6 -0
  2. package/CHANGELOG.md +39 -0
  3. package/README.md +5 -1
  4. package/bin/cli.cjs +50 -3
  5. package/bin/hooks/cos-cursor-observer.cjs +11 -4
  6. package/bin/hooks/cos-session-hook +46 -11
  7. package/docs/provider-parity.md +43 -0
  8. package/managed-runtime-contract.json +1 -0
  9. package/package.json +4 -2
  10. package/server/index.ts +141 -17
  11. package/server/lib/agent-session-binding-store.ts +9 -3
  12. package/server/lib/agent-session-store.ts +7 -4
  13. package/server/lib/attached-provider-adapter.ts +158 -45
  14. package/server/lib/banned-permission-args.ts +42 -1
  15. package/server/lib/claude-bridge.ts +17 -8
  16. package/server/lib/claude-hooks-installer.ts +22 -2
  17. package/server/lib/codex-bridge.ts +17 -2
  18. package/server/lib/codex-hooks-installer.ts +538 -0
  19. package/server/lib/codex-live-queue.ts +1 -1
  20. package/server/lib/codex-model-catalog.ts +49 -2
  21. package/server/lib/codex-rollout-state.ts +90 -0
  22. package/server/lib/codex-session-posture.ts +528 -0
  23. package/server/lib/continue-plan.ts +180 -0
  24. package/server/lib/continue-read-health.ts +26 -0
  25. package/server/lib/cursor-bridge.ts +26 -6
  26. package/server/lib/cursor-fork-context.ts +140 -0
  27. package/server/lib/cursor-model-catalog.ts +62 -5
  28. package/server/lib/cursor-observer-installer.ts +48 -7
  29. package/server/lib/cursor-session-model.ts +339 -0
  30. package/server/lib/cursor-spawn-env.ts +210 -0
  31. package/server/lib/fork-job-ledger.ts +194 -0
  32. package/server/lib/fork-thread.ts +169 -25
  33. package/server/lib/health-static-probes.ts +21 -11
  34. package/server/lib/lens-gist-engines.ts +15 -4
  35. package/server/lib/lens-gist.ts +1 -1
  36. package/server/lib/live-cues-cursor.ts +16 -6
  37. package/server/lib/occupancy-probes.ts +45 -1
  38. package/server/lib/permission-broker.ts +24 -9
  39. package/server/lib/provider-actions.ts +149 -0
  40. package/server/lib/provider-failure-text.ts +27 -0
  41. package/server/lib/provider-observe.ts +212 -0
  42. package/server/lib/provider-proof.ts +23 -14
  43. package/server/lib/query-job-runtime.ts +16 -1
  44. package/server/lib/query-job-types.ts +2 -1
  45. package/server/lib/session-act-fields.ts +61 -0
  46. package/server/lib/session-cancel.ts +95 -4
  47. package/server/lib/session-continue-facts-real.ts +74 -0
  48. package/server/lib/session-continue-facts.ts +267 -0
  49. package/server/lib/session-halt.ts +67 -6
  50. package/server/lib/session-hook-events.ts +57 -0
  51. package/server/lib/session-hook-ledger.ts +21 -3
  52. package/server/lib/session-hook-spool.ts +68 -6
  53. package/server/lib/session-hooks-runtime.ts +32 -2
  54. package/server/lib/session-peer-inbox-deps.ts +7 -6
  55. package/server/lib/session-recommendation.ts +3 -2
  56. package/server/lib/session-signal-store.ts +74 -5
  57. package/server/lib/session-stream-events.ts +32 -1
  58. package/server/lib/thread-attach-capability.ts +15 -1
  59. package/server/lib/thread-drain-kick.ts +4 -2
  60. package/server/lib/thread-occupancy.ts +44 -0
  61. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  62. package/server/lib/thread-turn-queue.ts +6 -0
  63. package/server/lib/turn-request.ts +274 -0
  64. package/server/routes/agent-session-bindings.ts +349 -26
  65. package/server/routes/agent-session-stream.ts +26 -7
  66. package/server/routes/agent-sessions.ts +155 -10
  67. package/server/routes/health.ts +27 -2
  68. package/server/routes/session-hooks.ts +56 -5
  69. package/server/routes/thread-turn-queue.ts +37 -9
  70. package/server/scripts/hooks-cli.ts +33 -7
@@ -25,8 +25,8 @@ import { observeWork, type WorkObservation } from './session-work-observation.js
25
25
  // 3. `ended` is recorded here but RANKED in the deriver, which also sees the registry:
26
26
  // an ended signal with an alive registry record is a child that ended, not the tab.
27
27
 
28
- import type { HookEnvelope, HookEventName } from './session-hook-events.js'
29
- import { clipText, toolFingerprint, toolTarget } from './session-hook-events.js'
28
+ import type { HookEnvelope, HookEventName, HookProvider } from './session-hook-events.js'
29
+ import { AGENT_THOUGHT_MAX, clipText, toolFingerprint, toolTarget } from './session-hook-events.js'
30
30
  import { isKeepWarmSessionTitle } from './agent-session-store.js'
31
31
 
32
32
  export type WaitingKind = 'permission' | 'question' | 'plan' | 'mcp_input'
@@ -51,6 +51,21 @@ export interface FailureSignal {
51
51
  export interface SessionSignal {
52
52
  workObservation?: WorkObservation
53
53
  observationOnly?: boolean
54
+ /**
55
+ * 6.62.0: the engine whose hooks these are (`inferHookProvider`). Absent on a record built
56
+ * from envelopes that never carried one (an older ledger row), which is read as Claude.
57
+ */
58
+ provider?: HookProvider
59
+ /**
60
+ * 6.62.0: the newest agent thought the Cursor observer reported, capped at
61
+ * AGENT_THOUGHT_MAX. MEMORY ONLY: the ledger keeps no thought text, so a restart forgets it.
62
+ */
63
+ lastThought?: { text: string; at: number }
64
+ /**
65
+ * 6.62.0 (QA W2): the newest phase event (never an observer one) whose envelope the hook
66
+ * script stamped with its provider: proof a 6.62 script is running this session's hooks.
67
+ */
68
+ stampedAt?: number
54
69
  sessionId: string
55
70
  firstSeenAt: number
56
71
  lastEventAt: number
@@ -107,6 +122,7 @@ function toolFacts(p: Record<string, unknown>): { name: string; target: string;
107
122
 
108
123
  function fresh(env: HookEnvelope): SessionSignal {
109
124
  return {
125
+ ...(env.provider ? { provider: env.provider } : {}),
110
126
  sessionId: env.sessionId,
111
127
  firstSeenAt: env.ts,
112
128
  lastEventAt: env.ts,
@@ -136,6 +152,25 @@ function fresh(env: HookEnvelope): SessionSignal {
136
152
 
137
153
  const str = (v: unknown): string | null => (typeof v === 'string' && v.length > 0 ? v : null)
138
154
 
155
+ /**
156
+ * Observer evidence: never a phase, never a listener. 6.62.0: an AgentThought is display only
157
+ * whatever its payload says, so a thought can never open, close or reopen a turn.
158
+ */
159
+ export function isDisplayOnly(env: HookEnvelope): boolean {
160
+ return env.payload.display_only === true || env.event === 'AgentThought'
161
+ }
162
+
163
+ /**
164
+ * 6.62.0: an AgentThought with text becomes the record's `lastThought`, unless an older one
165
+ * arrived late. A replayed row carries no text (the ledger drops it) and changes nothing.
166
+ */
167
+ function thoughtOf(base: SessionSignal, env: HookEnvelope): Pick<SessionSignal, 'lastThought'> {
168
+ if (env.event !== 'AgentThought') return {}
169
+ const text = clipText(env.payload.text, AGENT_THOUGHT_MAX)
170
+ if (!text || (base.lastThought && base.lastThought.at > env.ts)) return {}
171
+ return { lastThought: { text, at: env.ts } }
172
+ }
173
+
139
174
  /**
140
175
  * 6.48.1. A tool runs only inside a turn, so a MAIN-THREAD tool event (no `agent_id`) is
141
176
  * evidence the turn is open even when its UserPromptSubmit was never seen: a tab adopted
@@ -181,10 +216,12 @@ function resolvesWaiting(waiting: WaitingSignal, event: HookEventName, toolName:
181
216
  */
182
217
  export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelope, ctx: ReducerContext, child?: boolean): SessionSignal {
183
218
  const base = prev ? { ...prev } : fresh(env)
219
+ if (env.provider) base.provider = env.provider
184
220
  const p = env.payload
185
- if (p.display_only === true) return { ...base, ...(prev?.observationOnly !== false ? { lastEventAt: Math.max(base.lastEventAt, env.ts) } : {}), observationOnly: prev?.observationOnly ?? !prev, workObservation: observeWork(base.workObservation, env) }
221
+ if (isDisplayOnly(env)) return { ...base, ...(prev?.observationOnly !== false ? { lastEventAt: Math.max(base.lastEventAt, env.ts) } : {}), observationOnly: prev?.observationOnly ?? !prev, workObservation: observeWork(base.workObservation, env), ...thoughtOf(base, env) }
186
222
  const common = {
187
223
  observationOnly: false,
224
+ ...(env.stamped ? { stampedAt: Math.max(base.stampedAt ?? 0, env.ts) } : {}),
188
225
  workObservation: observeWork(base.workObservation, env),
189
226
  cwd: str(p.cwd) ?? base.cwd,
190
227
  transcriptPath: str(p.transcript_path) ?? base.transcriptPath,
@@ -212,6 +249,11 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
212
249
  const prompt = clipText(p.prompt)
213
250
  return {
214
251
  ...next,
252
+ // 6.62.0: Codex and Cursor have no registry to say a session lives on after its
253
+ // SessionEnd (a composer the hooks saw end and then reopened; a Codex thread resumed
254
+ // in the app without a SessionStart). A prompt after the end is a new life. Claude's
255
+ // own resume fires SessionStart, so its rows keep the 6.61 rule.
256
+ ...(next.provider === 'codex' || next.provider === 'cursor' ? { ended: null } : {}),
215
257
  turnOpen: true,
216
258
  turnStartedAt: env.ts,
217
259
  promptId: str(p.prompt_id),
@@ -280,6 +322,11 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
280
322
  waiting: null,
281
323
  lastReply: clipText(p.last_assistant_message) || next.lastReply,
282
324
  }
325
+ // 6.62.0: Codex's Interrupt (Esc) ends the turn with no Stop and no reply.
326
+ case 'Interrupt':
327
+ return { ...next, turnOpen: false, stopAt: env.ts, waiting: null }
328
+ case 'AgentThought': // display only (`isDisplayOnly`); never reaches here
329
+ return next
283
330
  case 'StopFailure':
284
331
  return {
285
332
  ...next,
@@ -303,6 +350,8 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
303
350
  }
304
351
 
305
352
  export type SignalListener = (signal: SessionSignal, env: HookEnvelope, child: boolean) => void
353
+ /** 6.62.0: told of each new agent thought (display only; the drain, permission and halt listeners never are). */
354
+ export type ThoughtListener = (signal: SessionSignal, thought: { text: string; at: number }) => void
306
355
 
307
356
  /** Records older than this after a SessionEnd are dropped; Control keeps its own ledger. */
308
357
  export const SIGNAL_PRUNE_AFTER_END_MS = 6 * 60 * 60_000
@@ -312,6 +361,7 @@ export const SIGNAL_PRUNE_SILENT_MS = 24 * 60 * 60_000
312
361
  export class SessionSignalStore {
313
362
  private readonly signals = new Map<string, SessionSignal>()
314
363
  private readonly listeners = new Set<SignalListener>()
364
+ private readonly thoughtListeners = new Set<ThoughtListener>()
315
365
  private readonly ctx: ReducerContext
316
366
 
317
367
  constructor(ctx: ReducerContext) {
@@ -319,9 +369,21 @@ export class SessionSignalStore {
319
369
  }
320
370
 
321
371
  apply(env: HookEnvelope, child?: boolean): SessionSignal {
322
- const next = applyHookEvent(this.signals.get(env.sessionId), env, this.ctx, child)
372
+ const prev = this.signals.get(env.sessionId)
373
+ const next = applyHookEvent(prev, env, this.ctx, child)
323
374
  this.signals.set(env.sessionId, next)
324
- if (env.payload.display_only === true) return next // no drain, permission or halt listeners
375
+ if (isDisplayOnly(env)) {
376
+ // No drain, permission or halt listeners. A NEW thought reaches the live feed only.
377
+ const thought = next.lastThought
378
+ if (env.event === 'AgentThought' && thought && thought !== prev?.lastThought) {
379
+ for (const listener of this.thoughtListeners) {
380
+ try { listener(next, thought) } catch (error) {
381
+ console.error(`[session-signals] thought listener failed: ${error instanceof Error ? error.message : error}`)
382
+ }
383
+ }
384
+ }
385
+ return next
386
+ }
325
387
  const isChild = child ?? this.ctx.isCosSpawnedPid(env.ppid)
326
388
  for (const listener of this.listeners) {
327
389
  try { listener(next, env, isChild) } catch (error) {
@@ -392,6 +454,12 @@ export class SessionSignalStore {
392
454
  return () => { this.listeners.delete(listener) }
393
455
  }
394
456
 
457
+ /** 6.62.0: new agent thoughts, for the live feed. Display only. */
458
+ subscribeThoughts(listener: ThoughtListener): () => void {
459
+ this.thoughtListeners.add(listener)
460
+ return () => { this.thoughtListeners.delete(listener) }
461
+ }
462
+
395
463
  /** Drop records that ended long ago, and records silent for a day (a tab that died with no SessionEnd). */
396
464
  prune(nowMs = Date.now()): number {
397
465
  let dropped = 0
@@ -426,5 +494,6 @@ export class SessionSignalStore {
426
494
  __resetForTests(): void {
427
495
  this.signals.clear()
428
496
  this.listeners.clear()
497
+ this.thoughtListeners.clear()
429
498
  }
430
499
  }
@@ -565,6 +565,37 @@ export function promptDrafts(message: Record<string, unknown>): SessionStreamDra
565
565
  return [{ kind: 'prompt', text: flat }]
566
566
  }
567
567
 
568
+ /**
569
+ * Cursor (6.62.0, plan 1.5): the user's words are the inside of `<user_query>`; the rest of a
570
+ * Cursor user row is scaffolding (attached files, rules). Until 6.62.0 the whole row was
571
+ * dropped, so a Cursor feed never showed what it was working on. Everything else is the
572
+ * Claude-shaped content the two share.
573
+ */
574
+ function draftsFromCursorRecord(record: Record<string, unknown>): SessionStreamDraft[] {
575
+ if (record.role === 'user' && record.type === undefined) {
576
+ const message = asRecord(record.message)
577
+ if (!message) return []
578
+ const blocks = Array.isArray(message.content) ? message.content : typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : []
579
+ if (blocks.some(b => asRecord(b)?.type === 'tool_result')) return []
580
+ const text = blocks.map(b => asRecord(b)).filter(b => b && (b.type === 'text' || b.type === undefined) && typeof b.text === 'string').map(b => b!.text as string).join(' ')
581
+ const start = text.indexOf('<user_query>')
582
+ const end = text.indexOf('</user_query>')
583
+ const query = start >= 0 && end > start ? text.slice(start + '<user_query>'.length, end) : text
584
+ return promptDrafts({ content: [{ type: 'text', text: query }] })
585
+ }
586
+ return draftsFromClaudeRecord(record)
587
+ }
588
+
589
+ /**
590
+ * Cursor (6.62.0, plan 1.7): the observer's newest agent thought, as the reasoning line. The
591
+ * same shape Codex's reasoning headline rides (`CodexReasoningStatus`): an extra field on a
592
+ * `status: working` draft, one line, as long as a target.
593
+ */
594
+ export function cursorThoughtStatus(text: unknown): CodexReasoningStatus | null {
595
+ const reasoning = typeof text === 'string' ? oneLine(text, TARGET_MAX_CHARS) : ''
596
+ return reasoning ? { kind: 'status', state: 'working', reasoning } : null
597
+ }
598
+
568
599
  function draftsFromClaudeRecord(record: Record<string, unknown>): SessionStreamDraft[] {
569
600
  const type = typeof record.type === 'string' ? record.type : ''
570
601
 
@@ -1262,7 +1293,7 @@ export function draftsFromRecord(
1262
1293
  const obj = asRecord(record)
1263
1294
  if (!obj) return []
1264
1295
  try {
1265
- const drafts = provider === 'codex' ? draftsFromCodexRecord(obj) : draftsFromClaudeRecord(obj)
1296
+ const drafts = provider === 'codex' ? draftsFromCodexRecord(obj) : provider === 'cursor' ? draftsFromCursorRecord(obj) : draftsFromClaudeRecord(obj)
1266
1297
  return stampPromptTurnStart(drafts, obj)
1267
1298
  } catch {
1268
1299
  // A malformed record costs one line of the trail. It must never cost the stream.
@@ -44,7 +44,8 @@
44
44
  // registered."
45
45
  //
46
46
  // `providers` is sourced from BINDABLE_PROVIDERS for the same reason. Cursor is
47
- // Continue-capable once bindable; Fork stays on FORKABLE_PROVIDERS (claude/codex).
47
+ // Continue-capable once bindable; Fork reads FORKABLE_PROVIDERS (6.62.0: Cursor too, as a new
48
+ // read-only session with context).
48
49
  // The way to guarantee the published list matches the attach route is to read
49
50
  // the list that `isBindableProvider` is built from, rather than restating names
50
51
  // here and hoping.
@@ -64,8 +65,10 @@
64
65
 
65
66
  import {
66
67
  BINDABLE_PROVIDERS,
68
+ FORKABLE_PROVIDERS,
67
69
  isBindableProvider,
68
70
  type BindableProvider,
71
+ type ForkableProvider,
69
72
  } from './agent-session-binding-store.js'
70
73
  import { threadAttachEnabled } from '../routes/agent-session-bindings.js'
71
74
 
@@ -98,6 +101,13 @@ export interface ThreadAttachCapability {
98
101
  * incoherent default: every refusal recommends Fork while the route 404s.
99
102
  */
100
103
  forkSupported: boolean
104
+ /**
105
+ * 6.62.0 (plan 3.7, W10): WHICH providers the fork route serves. `forkSupported` stays a
106
+ * boolean for every reader that already checks it with `=== true`; this list is new.
107
+ * Sourced from FORKABLE_PROVIDERS, the list `isForkableProvider` is built from. Cursor's
108
+ * entry is "New session with this context" (a new read-only chat), not a native fork.
109
+ */
110
+ forkProviders?: ForkableProvider[]
101
111
  }
102
112
 
103
113
  /**
@@ -129,6 +139,7 @@ export function threadAttachCapability(
129
139
  // byte-identical, so the flag protecting an existing conversation does not
130
140
  // apply. Gating it made every refusal recommend a route that 404s.
131
141
  forkSupported: THREAD_ATTACH_SUPPORTED,
142
+ forkProviders: THREAD_ATTACH_SUPPORTED ? [...FORKABLE_PROVIDERS] : [],
132
143
  }
133
144
  }
134
145
 
@@ -139,6 +150,8 @@ export interface ThreadAttachHealthFields {
139
150
  /** Fork is available whenever the build supports it, gate or no gate. */
140
151
  threadForkSupported: boolean
141
152
  threadAttachProviders: BindableProvider[]
153
+ /** 6.62.0: the providers the fork route serves (`threadForkSupported` stays the boolean). */
154
+ threadForkProviders: ForkableProvider[]
142
155
  }
143
156
 
144
157
  /** Flatten the capability onto the health payload's key names. */
@@ -150,6 +163,7 @@ export function threadAttachHealthFields(
150
163
  threadAttachEnabled: capability.enabled,
151
164
  threadAttachProviders: capability.providers,
152
165
  threadForkSupported: capability.forkSupported,
166
+ threadForkProviders: capability.forkSupported ? [...(capability.forkProviders ?? [])] : [],
153
167
  }
154
168
  }
155
169
 
@@ -70,11 +70,13 @@ export type KickPlan = { kind: 'kick' } | { kind: 'kick_when_registry_idle'; sto
70
70
  * - a SubagentStop never kicks: a background sub-agent finishing after the Stop is
71
71
  * answered by the model in a turn of its own, whose Stop kicks, and neither the hook
72
72
  * short-circuit nor the B6 clause vouches for a session whose newest event is a
73
- * SubagentStop, so a kick there could only fall through to the 30 s backstop.
73
+ * SubagentStop, so a kick there could only fall through to the 30 s backstop;
74
+ * - 6.62.0: a Codex `Interrupt` ends a turn the way a Stop does (Esc in Codex fires no
75
+ * Stop), so it follows the same rule; no Claude or Cursor hook emits it.
74
76
  */
75
77
  export function kickPlanFor(signal: KickSignalFacts, event: string, eventTs: number, child: boolean): KickPlan {
76
78
  if (child) return event === 'SessionEnd' ? { kind: 'kick' } : null
77
- if (event !== 'Stop') return null
79
+ if (event !== 'Stop' && event !== 'Interrupt') return null
78
80
  if (signal.turnOpen || signal.subagentsOpen > 0 || signal.ended) return null
79
81
  return { kind: 'kick_when_registry_idle', stopAt: typeof signal.stopAt === 'number' ? signal.stopAt : eventTs }
80
82
  }
@@ -93,6 +93,14 @@ export interface Occupancy {
93
93
  * Absent (not false) on every other verdict, so `=== true` is the only test.
94
94
  */
95
95
  idleHolder?: true
96
+ /**
97
+ * 6.62.0 (plan 3.6, W8): attachable DESPITE a foreign owner that is WORKING, because the
98
+ * owner is the Codex app and the turn goes into that app's own queue (`codex queue`),
99
+ * which runs it at the end of the running turn. Never set by `threadOccupancy`: only the
100
+ * bindings route declares it, and only when the hop is wired, on, and COS holds no turn
101
+ * of its own for the thread. Same `=== true` contract as `idleHolder`.
102
+ */
103
+ busyHolder?: true
96
104
  }
97
105
 
98
106
  /**
@@ -191,8 +199,25 @@ export interface OccupancyProbes {
191
199
  * absent, null, or a throw keeps the 6.50 verdict (`unsupported_provider`).
192
200
  */
193
201
  cursorComposerTurnOpen?: (threadId: string) => boolean | null
202
+ /**
203
+ * 6.62.0 (W14): the newest write to a Cursor CLI chat (`store.db`, its WAL, or the agent
204
+ * transcript), epoch ms, or null. A `-p` run fires no turn hooks (canary C7), so file
205
+ * times are the only busy signal a CLI chat has, and a Run Everything Continue must not
206
+ * run at the same moment as the person's own terminal run. OPTIONAL; absent or null
207
+ * keeps the 6.61 verdict.
208
+ */
209
+ cursorChatLastWriteMs?: (chatDir: string, threadId: string) => number | null
210
+ /**
211
+ * 6.62.0 /qa (W7): does a process that is NOT COS hold this CLI chat's `store.db` open?
212
+ * True is positive evidence of a run (a terminal `agent`, a desk run quiet past 30 s).
213
+ * Null or a throw: cannot tell, and the 30 s write window alone decides.
214
+ */
215
+ cursorChatForeignHolder?: (chatDir: string) => boolean | null
194
216
  }
195
217
 
218
+ /** 6.62.0 (W14): a CLI chat written this recently is working; a turn for it queues. */
219
+ export const CURSOR_CHAT_BUSY_WINDOW_MS = 30_000
220
+
196
221
  /**
197
222
  * How recently the transcript must have been written for a held thread to read
198
223
  * as WORKING rather than merely OPEN.
@@ -637,6 +662,25 @@ export function threadOccupancy(
637
662
  }
638
663
  return { attachable: false, owners: [], reason: open === true ? 'native_thread_working' : 'unsupported_provider' }
639
664
  }
665
+ // 6.62.0 (W14): a CLI chat written in the last 30 s is mid-run, at the desk or by a
666
+ // COS child that just finished; a turn spoken at it waits rather than running
667
+ // alongside. A probe that throws or cannot tell keeps the 6.61 verdict.
668
+ let lastWrite: number | null = null
669
+ try {
670
+ lastWrite = typeof probes.cursorChatLastWriteMs === 'function' ? probes.cursorChatLastWriteMs(session.dir, threadId) : null
671
+ } catch {
672
+ lastWrite = null
673
+ }
674
+ if (typeof lastWrite === 'number' && Number.isFinite(lastWrite) && Date.now() - lastWrite < CURSOR_CHAT_BUSY_WINDOW_MS) {
675
+ return { attachable: false, owners: [], reason: 'native_thread_working' }
676
+ }
677
+ let held: boolean | null = null
678
+ try {
679
+ held = typeof probes.cursorChatForeignHolder === 'function' ? probes.cursorChatForeignHolder(session.dir) : null
680
+ } catch {
681
+ held = null
682
+ }
683
+ if (held === true) return { attachable: false, owners: [], reason: 'native_thread_working' }
640
684
  return { attachable: true, owners: [], reason: null }
641
685
  }
642
686
  if (provider !== 'claude' && provider !== 'codex') {
@@ -34,7 +34,7 @@ const DELIVER_TIMEOUT_MS = 15_000
34
34
 
35
35
  interface LoopbackReply { status: number; body: Record<string, unknown> }
36
36
 
37
- function post(port: number, token: string, path: string, payload: unknown): Promise<LoopbackReply> {
37
+ function post(port: number, token: string, path: string, payload: unknown, extraHeaders: Record<string, string> = {}): Promise<LoopbackReply> {
38
38
  return new Promise((resolve, reject) => {
39
39
  const data = JSON.stringify(payload)
40
40
  const req = request({
@@ -47,6 +47,7 @@ function post(port: number, token: string, path: string, payload: unknown): Prom
47
47
  'content-length': Buffer.byteLength(data),
48
48
  // The header the rest of COS authenticates with. NOT `Authorization: Bearer`.
49
49
  'X-Cos-Token': token,
50
+ ...extraHeaders,
50
51
  },
51
52
  timeout: DELIVER_TIMEOUT_MS,
52
53
  }, res => {
@@ -77,11 +78,14 @@ export async function deliverQueuedTurnOverLoopback(
77
78
  port: number,
78
79
  token: string,
79
80
  ): Promise<{ ok: boolean; reason?: string; serverRetryable?: boolean }> {
81
+ // 6.62.0 /qa (Q3): repeat the parking client's acknowledgement, and only that.
82
+ const noteHeaders: Record<string, string> = turn.continueNoteAck === true ? { 'X-COS-Continue-Note': '1' } : {}
80
83
  try {
81
84
  const attach = await post(
82
85
  port, token,
83
86
  `/api/agent-sessions/${encodeURIComponent(turn.provider)}/${encodeURIComponent(turn.threadId)}/attach`,
84
87
  { cosSessionId: turn.cosSessionId },
88
+ noteHeaders,
85
89
  )
86
90
  if (attach.status !== 200 && attach.status !== 201) {
87
91
  // THE KEY IS `reason`, NOT `error`. `refuseAttach` emits
@@ -117,6 +121,7 @@ export async function deliverQueuedTurnOverLoopback(
117
121
  targetKey: targetKey(turn.provider, turn.threadId),
118
122
  ...(boundTo ? { boundTo } : {}),
119
123
  },
124
+ noteHeaders,
120
125
  )
121
126
  if (sent.status === 202 || sent.status === 200) return { ok: true }
122
127
  // Same defect on the turn leg: `refuseTurn` emits `reason`/`reasonCopy`/`retryable`
@@ -87,6 +87,12 @@ export interface QueuedThreadTurn {
87
87
  /** The SERVER's reason for a terminal outcome. Never the client's wording. */
88
88
  reason?: string
89
89
  settledAt?: number
90
+ /**
91
+ * 6.62.0 /qa (Q3): the client that parked this turn sent `X-COS-Continue-Note: 1` (it shows
92
+ * `continue_note`). The drain repeats the header, so a Cursor CLI chat runs Run Everything
93
+ * only for a client that said it would show it. Absent: the 6.61 Ask posture.
94
+ */
95
+ continueNoteAck?: true
90
96
  }
91
97
 
92
98
  /**