@gotcos/glasses-server 6.61.6 → 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 (72) hide show
  1. package/.env.example +6 -0
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +5 -1
  4. package/bin/cli.cjs +50 -3
  5. package/bin/hooks/cos-cursor-observer.cjs +36 -0
  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 +5 -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 +28 -5
  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/codex-subagent-activity.ts +119 -0
  24. package/server/lib/continue-plan.ts +180 -0
  25. package/server/lib/continue-read-health.ts +26 -0
  26. package/server/lib/cursor-bridge.ts +26 -6
  27. package/server/lib/cursor-fork-context.ts +140 -0
  28. package/server/lib/cursor-model-catalog.ts +62 -5
  29. package/server/lib/cursor-observer-installer.ts +79 -0
  30. package/server/lib/cursor-session-model.ts +339 -0
  31. package/server/lib/cursor-spawn-env.ts +210 -0
  32. package/server/lib/fork-thread.ts +143 -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 +60 -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 +40 -3
  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 +81 -3
  57. package/server/lib/session-stream-events.ts +32 -1
  58. package/server/lib/session-transcript-watcher.ts +25 -1
  59. package/server/lib/session-work-observation.ts +46 -0
  60. package/server/lib/thread-attach-capability.ts +15 -1
  61. package/server/lib/thread-drain-kick.ts +4 -2
  62. package/server/lib/thread-occupancy.ts +44 -0
  63. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  64. package/server/lib/thread-turn-queue.ts +6 -0
  65. package/server/lib/turn-request.ts +274 -0
  66. package/server/routes/agent-session-bindings.ts +224 -22
  67. package/server/routes/agent-session-stream.ts +26 -7
  68. package/server/routes/agent-sessions.ts +163 -10
  69. package/server/routes/health.ts +27 -2
  70. package/server/routes/session-hooks.ts +60 -3
  71. package/server/routes/thread-turn-queue.ts +37 -9
  72. package/server/scripts/hooks-cli.ts +38 -9
@@ -243,6 +243,7 @@ export function createTranscriptTailer(options: TranscriptTailerOptions): Transc
243
243
  let state: 'working' | 'idle' | 'done' | null = null
244
244
  let lastActivity = now()
245
245
  let degraded = false
246
+ let skippingCompaction = false
246
247
 
247
248
  const emit = (draft: SessionStreamDraft) => {
248
249
  try {
@@ -275,17 +276,40 @@ export function createTranscriptTailer(options: TranscriptTailerOptions): Transc
275
276
  // this design exists to avoid, so we rejoin at the new end and let the poll
276
277
  // fallback carry whatever the rotation took with it.
277
278
  cursor = { offset: size }
279
+ skippingCompaction = false
278
280
  return
279
281
  }
280
282
 
281
283
  if (size > cursor.offset) {
282
284
  const pending = size - cursor.offset
283
285
  const length = Math.min(pending, maxRead)
284
- const chunk = await readRangeAt(options.path, cursor.offset, length)
286
+ let chunk = await readRangeAt(options.path, cursor.offset, length)
285
287
  if (chunk !== null && chunk.length > 0) {
288
+ if (skippingCompaction) {
289
+ const end = chunk.indexOf(10)
290
+ if (end < 0) { cursor.offset += chunk.length; lastActivity = now(); return }
291
+ cursor.offset += end + 1
292
+ chunk = chunk.subarray(end + 1)
293
+ skippingCompaction = false
294
+ lastActivity = now()
295
+ if (!suppressed(options.key)) emit({ kind: 'status', state: 'working' })
296
+ if (!chunk.length) return
297
+ }
286
298
  const result = consumeTranscriptChunk(cursor, chunk, chunk.length >= maxRead)
287
299
  cursor = result.cursor
288
300
  if (result.tooLarge) {
301
+ // A Codex context replacement is internal bookkeeping, not a reply. Its
302
+ // replacement_history can exceed 4 MiB. Discard just this recognized
303
+ // top-level record incrementally, retaining heartbeats and later output.
304
+ const head = chunk.subarray(0, 512).toString('utf8')
305
+ if (options.provider === 'codex' && /^\s*\{\s*(?:"timestamp"\s*:\s*"[^"\r\n]*"\s*,\s*)?"type"\s*:\s*"compacted"\s*[,}]/.test(head)) {
306
+ skippingCompaction = true
307
+ cursor.offset += chunk.length
308
+ lastActivity = now()
309
+ state = 'working'
310
+ if (!suppressed(options.key)) emit({ kind: 'status', state: 'working', reasoning: 'Updating context' } as SessionStreamDraft)
311
+ return
312
+ }
289
313
  // HAND OFF, do not skip. `done` is the same terminal status a finished turn
290
314
  // sends, so the client already knows to close the stream and resume its
291
315
  // 5s/15s/60s poll -- no new client vocabulary, no frozen screen.
@@ -0,0 +1,46 @@
1
+ // Display-only evidence. Never decides whether a thread can be written or dequeued.
2
+ import type { HookEnvelope } from './session-hook-events.js'
3
+ import type { SubagentActivity } from './codex-subagent-activity.js'
4
+ export interface WorkObservation {
5
+ agents: Array<{id:string;at:number;state:'active'|'completed'}>
6
+ partial: boolean
7
+ compactingAt: number | null
8
+ phaseAt?: number
9
+ }
10
+ export function observeWork(previous: WorkObservation | undefined, env: HookEnvelope): WorkObservation {
11
+ const prev=previous ?? {agents:[],partial:false,compactingAt:null}
12
+ if (!Number.isFinite(env.ts) || env.ts <= 0 || env.ts > Date.now()+5000) return prev
13
+ const p=env.payload, id=typeof p.agent_id==='string' && /^[a-zA-Z0-9_-]{1,160}$/.test(p.agent_id) ? p.agent_id : null
14
+ if(env.event==='SubagentStart'||env.event==='SubagentStop') {
15
+ if(!id)return {...prev,partial:true}
16
+ const existing=prev.agents.find(a=>a.id===id)
17
+ if(existing && (existing.at>env.ts || (existing.at===env.ts && (existing.state==='completed' || env.event==='SubagentStart'))))return prev
18
+ if(!existing && prev.agents.length>=512)return {...prev,partial:true}
19
+ const row={id,at:env.ts,state:env.event==='SubagentStart'?'active' as const:'completed' as const}
20
+ return {...prev,agents:[...prev.agents.filter(a=>a.id!==id),row]}
21
+ }
22
+ if(id && ['PreToolUse','PostToolUse','PostToolUseFailure'].includes(env.event)) {
23
+ const existing=prev.agents.find(a=>a.id===id)
24
+ // Tool output may arrive after Stop; it must never reopen a completed child.
25
+ if(existing?.state==='active' && env.ts>existing.at)return {...prev,agents:prev.agents.map(a=>a.id===id?{...a,at:env.ts}:a)}
26
+ return prev
27
+ }
28
+ if(id)return prev // Child compaction must not label the parent as compacting.
29
+ if(env.ts<(prev.phaseAt??0))return prev
30
+ if(env.event==='PreCompact')return {...prev,compactingAt:env.ts,phaseAt:env.ts}
31
+ const resumes=['PostCompact','UserPromptSubmit','Stop','StopFailure','SessionEnd','PostToolUse','PostToolUseFailure'].includes(env.event)
32
+ || (env.event==='SessionStart' && p.source==='compact')
33
+ if(resumes)return {...prev,compactingAt:null,phaseAt:env.ts}
34
+ return prev
35
+ }
36
+ export function workObservationFields(observation: WorkObservation | undefined, now=Date.now()): {subagent_activity?:SubagentActivity;compaction?:{state:'compacting'|'unknown';started_at:number;checked_at:number}} {
37
+ if(!observation)return {}
38
+ const fields:ReturnType<typeof workObservationFields>={}
39
+ if(observation.agents.length||observation.partial){
40
+ const summary:SubagentActivity={active:0,completed:0,unknown:0,total:observation.agents.length,checked_at:now,partial:observation.partial}
41
+ for(const a of observation.agents)summary[a.state==='completed'?'completed':now-a.at<=300_000&&a.at<=now+5000?'active':'unknown']++
42
+ fields.subagent_activity=summary
43
+ }
44
+ if(observation.compactingAt!==null)fields.compaction={state:now-observation.compactingAt<=600_000?'compacting':'unknown',started_at:observation.compactingAt,checked_at:now}
45
+ return fields
46
+ }
@@ -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
  /**
@@ -0,0 +1,274 @@
1
+ // The current-request binding for provider CLI spawns (P0 from the 2026-09-27 memory audit).
2
+ //
3
+ // WHY THIS EXISTS. The COS UserPromptSubmit hook (a Python script in the COS repo, run by
4
+ // the Claude Code and Codex CLIs) builds its memory query from the prompt text the CLI hands
5
+ // it. On a glasses turn that text is not the user's question: the Codex and Cursor bridges
6
+ // send one concatenated "SYSTEM INSTRUCTIONS ... USER REQUEST ..." prompt, and the Claude
7
+ // bridge still wraps the question in image preambles and attachment JSON. The hook then
8
+ // embedded display rules, the calendar and standing tasks, and injected unrelated memories.
9
+ //
10
+ // THE CONTRACT (04_MVP_BUILD_SPEC.md, "Current-request contract", shared with the hook).
11
+ // Per CLI spawn with a genuine user query, write
12
+ //
13
+ // <data dir>/turn-requests/<turnId or uuid>.json directory 0700, file 0600
14
+ // {"schema_version":1,"turn_id":"...","current_user_text":"...",
15
+ // "full_prompt_sha256":"...","created_at":"ISO"}
16
+ //
17
+ // and pass its absolute path to the child as COS_TURN_REQUEST_FILE. The hook uses
18
+ // `current_user_text` only when sha256(hook_prompt.strip()) equals `full_prompt_sha256` and
19
+ // the stripped text is a substring of the hook prompt; otherwise it uses the prompt exactly
20
+ // as before. So a file that does not bind is harmless, and nothing here may fail a turn.
21
+ //
22
+ // THE HASH IS PYTHON'S strip(), NOT JS trim(). The two whitespace sets differ: Python also
23
+ // strips U+001C..U+001F and U+0085, and JS also strips U+FEFF. A prompt ending in one of
24
+ // those would never bind if the server hashed `prompt.trim()`. `stripLikePython` uses the
25
+ // exact `str.isspace()` set (checked against python3 over every code point, 2026-09-29).
26
+ //
27
+ // PRIVACY. The user's text lives only in this private file, never in argv or the child env
28
+ // (argv is world-readable through `ps`; env is readable by every descendant). The file is
29
+ // deleted when the child exits, and anything older than an hour is swept.
30
+
31
+ import { createHash, randomUUID } from 'node:crypto'
32
+ import {
33
+ closeSync,
34
+ constants as fsConstants,
35
+ fchmodSync,
36
+ lstatSync,
37
+ mkdirSync,
38
+ chmodSync,
39
+ openSync,
40
+ readdirSync,
41
+ unlinkSync,
42
+ writeSync,
43
+ } from 'node:fs'
44
+ import { join } from 'node:path'
45
+ import { dataPath } from './data-dir.js'
46
+
47
+ /** The child env key that carries the file path. The hook reads exactly this name. */
48
+ export const TURN_REQUEST_ENV = 'COS_TURN_REQUEST_FILE'
49
+ export const TURN_REQUEST_SCHEMA_VERSION = 1
50
+ /** Files older than this are swept. No provider run outlives it: the longest wall cap is 20 min. */
51
+ export const TURN_REQUEST_STALE_MS = 60 * 60_000
52
+ /** The hook ignores a file of 64 KB or more, so one that large is never written. */
53
+ export const TURN_REQUEST_MAX_BYTES = 64 * 1024
54
+ const SWEEP_INTERVAL_MS = 10 * 60_000
55
+ const DIR_NAME = 'turn-requests'
56
+ /** A turn id is used as a file name only when it cannot escape or collide with the directory. */
57
+ const SAFE_TURN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
58
+
59
+ /** Exactly the code points for which Python's `str.isspace()` is true. */
60
+ const PY_SPACE = '\\u0009-\\u000d\\u001c-\\u0020\\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000'
61
+ const PY_STRIP = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, 'gu')
62
+
63
+ /** Python's `str.strip()` with no arguments. */
64
+ export function stripLikePython(text: string): string {
65
+ return text.replace(PY_STRIP, '')
66
+ }
67
+
68
+ /** Lowercase hex sha256 of the prompt as the hook will see it: UTF-8, Python-stripped. */
69
+ export function turnPromptSha256(prompt: string): string {
70
+ return createHash('sha256').update(stripLikePython(prompt), 'utf8').digest('hex')
71
+ }
72
+
73
+ export function turnRequestDir(): string {
74
+ return dataPath(DIR_NAME)
75
+ }
76
+
77
+ export interface TurnRequestBinding {
78
+ /** Absolute path of the private file. */
79
+ readonly path: string
80
+ readonly turnId: string
81
+ /** Delete the file. Idempotent and never throws. */
82
+ release(): void
83
+ }
84
+
85
+ let lastSweepAt = 0
86
+
87
+ /**
88
+ * Delete turn-request files older than `TURN_REQUEST_STALE_MS`. Runs at server startup and
89
+ * again from `createTurnRequestBinding` at most every ten minutes, so a file whose child
90
+ * never reported an exit (or a crash between write and spawn) cannot outlive an hour and a
91
+ * turn. Returns how many files it removed. Never throws.
92
+ */
93
+ export function sweepStaleTurnRequests(nowMs: number = Date.now()): number {
94
+ lastSweepAt = nowMs
95
+ const dir = turnRequestDir()
96
+ let names: string[]
97
+ try {
98
+ names = readdirSync(dir)
99
+ } catch {
100
+ return 0
101
+ }
102
+ let removed = 0
103
+ for (const name of names) {
104
+ if (!name.endsWith('.json')) continue
105
+ const file = join(dir, name)
106
+ try {
107
+ const st = lstatSync(file)
108
+ if (!st.isFile() && !st.isSymbolicLink()) continue
109
+ if (nowMs - st.mtimeMs <= TURN_REQUEST_STALE_MS) continue
110
+ unlinkSync(file)
111
+ removed += 1
112
+ } catch { /* raced with a release, or unreadable: leave it */ }
113
+ }
114
+ return removed
115
+ }
116
+
117
+ /** Ensure the private directory: a real directory (never a symlink), mode 0700. */
118
+ function ensurePrivateDir(dir: string): void {
119
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
120
+ const st = lstatSync(dir)
121
+ if (!st.isDirectory() || st.isSymbolicLink()) throw new Error('turn-requests is not a directory')
122
+ if ((st.mode & 0o777) !== 0o700) chmodSync(dir, 0o700)
123
+ }
124
+
125
+ /**
126
+ * Create `path` exclusively with mode 0600 and write `body`. O_EXCL refuses an existing path
127
+ * of any kind, a planted symlink or FIFO included (POSIX: with O_CREAT|O_EXCL a symlink is
128
+ * never followed), so nothing is ever written through a link or over another turn's file.
129
+ * O_NOFOLLOW and O_NONBLOCK repeat that on the open line itself, as the server's hazard
130
+ * invariants require of every synchronous open (hazard-invariants.test.ts).
131
+ */
132
+ function writePrivateFile(path: string, body: string): void {
133
+ const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK, 0o600)
134
+ let ok = false
135
+ try {
136
+ // The umask can only remove bits, but set it explicitly so the contract does not rest on it.
137
+ fchmodSync(fd, 0o600)
138
+ const bytes = Buffer.from(body, 'utf8')
139
+ let written = 0
140
+ while (written < bytes.length) written += writeSync(fd, bytes, written, bytes.length - written)
141
+ ok = true
142
+ } finally {
143
+ closeSync(fd)
144
+ if (!ok) {
145
+ try { unlinkSync(path) } catch { /* best effort */ }
146
+ }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Write the turn-request file for ONE provider spawn, or return null.
152
+ *
153
+ * `userText` is the raw user query as the bridge received it, before any header, context,
154
+ * image preamble or attachment block. `prompt` is the exact text the CLI will receive as the
155
+ * user message (stdin), byte for byte.
156
+ *
157
+ * Null (and no file) when there is no genuine user query (empty or whitespace only, as for a
158
+ * pre-warm or an image with no words), when the text is not inside the prompt (the hook
159
+ * could never bind it), when the file would reach the hook's 64 KB limit, or on any
160
+ * filesystem failure. The binding is an optimisation for memory retrieval: it must never
161
+ * fail or delay the turn, so every failure is logged once and swallowed.
162
+ */
163
+ export function createTurnRequestBinding(input: {
164
+ userText: string
165
+ prompt: string
166
+ turnId?: string
167
+ now?: () => Date
168
+ }): TurnRequestBinding | null {
169
+ const userText = typeof input.userText === 'string' ? input.userText : ''
170
+ const stripped = stripLikePython(userText)
171
+ if (!stripped) return null
172
+ if (typeof input.prompt !== 'string' || !input.prompt.includes(stripped)) return null
173
+
174
+ const now = input.now?.() ?? new Date()
175
+ const turnId = typeof input.turnId === 'string' && SAFE_TURN_ID.test(input.turnId)
176
+ ? input.turnId
177
+ : randomUUID()
178
+ const record = {
179
+ schema_version: TURN_REQUEST_SCHEMA_VERSION,
180
+ turn_id: turnId,
181
+ current_user_text: userText,
182
+ full_prompt_sha256: turnPromptSha256(input.prompt),
183
+ created_at: now.toISOString(),
184
+ }
185
+ const body = JSON.stringify(record)
186
+ if (Buffer.byteLength(body, 'utf8') >= TURN_REQUEST_MAX_BYTES) return null
187
+
188
+ try {
189
+ const dir = turnRequestDir()
190
+ ensurePrivateDir(dir)
191
+ if (lastSweepAt === 0 || now.getTime() - lastSweepAt >= SWEEP_INTERVAL_MS) {
192
+ sweepStaleTurnRequests(now.getTime())
193
+ }
194
+ let path = join(dir, `${turnId}.json`)
195
+ try {
196
+ writePrivateFile(path, body)
197
+ } catch (error: any) {
198
+ // A retried turn can reuse its id while the earlier child still holds its file.
199
+ // Never overwrite it: that child's exit would delete this one's file.
200
+ if (error?.code !== 'EEXIST') throw error
201
+ path = join(dir, `${randomUUID()}.json`)
202
+ writePrivateFile(path, body)
203
+ }
204
+ let released = false
205
+ return {
206
+ path,
207
+ turnId,
208
+ release() {
209
+ if (released) return
210
+ released = true
211
+ try { unlinkSync(path) } catch { /* already gone */ }
212
+ },
213
+ }
214
+ } catch (error) {
215
+ console.error('[turn-request] binding unavailable:', error instanceof Error ? error.message : error)
216
+ return null
217
+ }
218
+ }
219
+
220
+ /** The minimal child surface this needs; a real ChildProcess satisfies it. */
221
+ interface ExitObservable {
222
+ readonly pid?: number
223
+ once(event: 'exit' | 'close', listener: (...args: any[]) => void): unknown
224
+ once(event: 'error', listener: (error: Error) => void): unknown
225
+ }
226
+
227
+ /**
228
+ * Delete the binding's file when the child is gone, on every path: a normal exit, a kill
229
+ * (timeout, abort, lost ownership), and a spawn failure. Node reports a spawn failure as
230
+ * 'error' with no pid, then 'close'; an exited child always reports 'exit' and 'close'. An
231
+ * 'error' on a child that DID start (a failed kill) is not an exit, so it keeps the file.
232
+ */
233
+ export function releaseTurnRequestOnExit(child: ExitObservable, binding: TurnRequestBinding | null): void {
234
+ if (!binding) return
235
+ child.once('exit', () => binding.release())
236
+ child.once('close', () => binding.release())
237
+ child.once('error', () => {
238
+ if (child.pid === undefined) binding.release()
239
+ })
240
+ }
241
+
242
+ /**
243
+ * Spawn through `start` and tie the binding's file to the child's lifetime. A synchronous
244
+ * spawn throw deletes the file before rethrowing, so no path can leave it behind.
245
+ */
246
+ export function spawnWithTurnRequest<T extends ExitObservable>(
247
+ binding: TurnRequestBinding | null,
248
+ start: () => T,
249
+ ): T {
250
+ let child: T
251
+ try {
252
+ child = start()
253
+ } catch (error) {
254
+ binding?.release()
255
+ throw error
256
+ }
257
+ releaseTurnRequestOnExit(child, binding)
258
+ return child
259
+ }
260
+
261
+ /**
262
+ * Point the child env at this spawn's file, and never at an inherited one. A server started
263
+ * from inside an agent session could carry the variable, and a stale path must not reach a
264
+ * hook even though the hash check would reject it.
265
+ */
266
+ export function applyTurnRequestEnv(env: NodeJS.ProcessEnv, binding: TurnRequestBinding | null): void {
267
+ delete env[TURN_REQUEST_ENV]
268
+ if (binding) env[TURN_REQUEST_ENV] = binding.path
269
+ }
270
+
271
+ /** Test seam: forget the sweep clock so a test can observe the first-use sweep. */
272
+ export function resetTurnRequestSweepForTests(): void {
273
+ lastSweepAt = 0
274
+ }