@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
@@ -21,6 +21,41 @@ const BARE_BANNED_PERMISSION_ARGS = new Set(
21
21
  BANNED_PERMISSION_ARGS.filter(flag => !flag.startsWith('-')),
22
22
  )
23
23
 
24
+ /** The two spellings of Codex's sandbox option. The allowed value must be the NEXT argv slot. */
25
+ const CODEX_SANDBOX_FLAGS = new Set(['--sandbox', '-s'])
26
+
27
+ /**
28
+ * 6.62.0 (plan 3.1, D1): the one exception to the ban, for a Continue that carries the
29
+ * session's OWN permissions. ONE object, built once per turn from the Continue plan and
30
+ * handed to BOTH checks in `attached-provider-adapter.ts` (the builder and the spawn
31
+ * boundary), so the two can never disagree about what was allowed.
32
+ *
33
+ * Narrow by construction:
34
+ * - `cursorForce` admits exactly the argv slot `--force` (Cursor's Run Everything). Not
35
+ * `--force=…`, not `-f`, and nothing else on the list.
36
+ * - `codexSandbox` admits the bare token `danger-full-access` only as the value right
37
+ * after `--sandbox` / `-s`. The `--flag=value` spelling and every other position stay
38
+ * banned.
39
+ *
40
+ * Fork (`fork-thread.ts`) and G2 extra-args (`codex-extra-args.ts`) never pass one, so the
41
+ * full ban holds there. Anything that is not exactly this shape counts as no allowance.
42
+ */
43
+ export interface PermissionAllowance {
44
+ path: 'attached_continue'
45
+ cursorForce?: true
46
+ codexSandbox?: 'danger-full-access'
47
+ }
48
+
49
+ function readAllowance(allowance: unknown): { cursorForce: boolean; codexDanger: boolean } {
50
+ if (!allowance || typeof allowance !== 'object') return { cursorForce: false, codexDanger: false }
51
+ const row = allowance as Record<string, unknown>
52
+ if (row.path !== 'attached_continue') return { cursorForce: false, codexDanger: false }
53
+ return {
54
+ cursorForce: row.cursorForce === true,
55
+ codexDanger: row.codexSandbox === 'danger-full-access',
56
+ }
57
+ }
58
+
24
59
  /**
25
60
  * Is this argv free of every flag plan 4.7 bans?
26
61
  *
@@ -29,12 +64,18 @@ const BARE_BANNED_PERMISSION_ARGS = new Set(
29
64
  * (`danger-full-access`) match only as their own argv slot. Values of
30
65
  * `--workspace` / `-C` are skipped: a cwd containing `--force` is a path,
31
66
  * not a permission flag.
67
+ *
68
+ * `allowance` (6.62.0) lifts exactly what `PermissionAllowance` names, and only for the
69
+ * attached Continue path. Omitted, it is the full ban, byte for byte the 6.61 check.
32
70
  */
33
- export function findBannedPermissionArg(args: readonly string[]): string | null {
71
+ export function findBannedPermissionArg(args: readonly string[], allowance?: PermissionAllowance): string | null {
72
+ const allowed = readAllowance(allowance)
34
73
  for (let i = 0; i < args.length; i++) {
35
74
  const value = String(args[i])
36
75
  const prev = i > 0 ? String(args[i - 1]) : ''
37
76
  if (PATH_VALUED_FLAGS.has(prev)) continue
77
+ if (allowed.cursorForce && value === '--force') continue
78
+ if (allowed.codexDanger && value === 'danger-full-access' && CODEX_SANDBOX_FLAGS.has(prev)) continue
38
79
  if (BARE_BANNED_PERMISSION_ARGS.has(value)) return value
39
80
  if (!value.startsWith('-')) continue
40
81
  for (const banned of BANNED_PERMISSION_ARGS) {
@@ -61,6 +61,7 @@ import { claudePermissionArgs, getClaudeTrustMode } from './claude-permissions.j
61
61
  import { terminateProviderProcess } from './provider-process-lifecycle.js'
62
62
  import { teeJobTrail, type JobTrailDraft } from './job-trail.js'
63
63
  import { claudeSupportsSessionName } from './claude-cli-support.js'
64
+ import { applyTurnRequestEnv, createTurnRequestBinding, spawnWithTurnRequest } from './turn-request.js'
64
65
 
65
66
  // Inactivity = no stdout data for this long → kill (catches stalls)
66
67
  const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
@@ -220,6 +221,8 @@ export async function preWarmCLI(): Promise<void> {
220
221
  return new Promise<void>((resolve) => {
221
222
  const env = { ...process.env }
222
223
  delete env.CLAUDECODE
224
+ // 'ready' is not a user query: no turn-request file, and never an inherited path.
225
+ applyTurnRequestEnv(env, null)
223
226
 
224
227
  const proc = spawn('claude', [
225
228
  '-p',
@@ -346,7 +349,7 @@ export interface StreamCallbacks {
346
349
  * its session id in the first stream event; before this the id reached the job only with
347
350
  * the result, so COS Control could not show a New session from Work while it ran.
348
351
  */
349
- onNativeSession?: (linkage: { cliSessionId: string }) => void
352
+ onNativeSession?: (linkage: { cliSessionId: string; provider?: 'claude' | 'codex' | 'cursor' }) => void
350
353
  }
351
354
 
352
355
  /** Claude CLI can emit `subtype: success` with `is_error: true`; the boolean
@@ -622,12 +625,23 @@ export async function callClaudeStreaming(
622
625
  cliModelId: resolveClaudeCliModelId(resolvedModel),
623
626
  })
624
627
 
625
- const proc = spawn('claude', args, {
628
+ // Ultracode is a CLI-only orchestration keyword; history/chat keeps the
629
+ // original user text so the keyword never appears on the lens.
630
+ const cliQuery = resolvedEffort === 'ultracode'
631
+ ? `${fullQuery}\n\n${ULTRACODE_KEYWORD}`
632
+ : fullQuery
633
+ // The COS UserPromptSubmit hook sees `cliQuery` (image preamble, attachment block and
634
+ // all). This private file hands it the user's own words, bound by the hash of exactly
635
+ // what goes on stdin; the file dies with the child. See lib/turn-request.ts.
636
+ const turnRequest = createTurnRequestBinding({ userText: query, prompt: cliQuery, turnId: options?.turnId })
637
+ applyTurnRequestEnv(env, turnRequest)
638
+
639
+ const proc = spawnWithTurnRequest(turnRequest, () => spawn('claude', args, {
626
640
  stdio: ['pipe', 'pipe', 'pipe'],
627
641
  env,
628
642
  cwd: cliCwd,
629
643
  detached: true,
630
- })
644
+ }))
631
645
 
632
646
  let fullText = ''
633
647
  let stderr = ''
@@ -1061,11 +1075,6 @@ export async function callClaudeStreaming(
1061
1075
  options.abortSignal.addEventListener('abort', handleAbort, { once: true })
1062
1076
  }
1063
1077
 
1064
- // Ultracode is a CLI-only orchestration keyword; history/chat keeps the
1065
- // original user text so the keyword never appears on the lens.
1066
- const cliQuery = resolvedEffort === 'ultracode'
1067
- ? `${fullQuery}\n\n${ULTRACODE_KEYWORD}`
1068
- : fullQuery
1069
1078
  try {
1070
1079
  const providerOwned = await callbacks.onProviderProcess?.({
1071
1080
  provider: 'claude',
@@ -90,6 +90,7 @@ export const HOOK_SUBSCRIPTIONS: readonly HookSubscription[] = [
90
90
  { event: 'Notification', matcher: 'permission_prompt|idle_prompt|elicitation_dialog|agent_needs_input', async: true, timeout: 10 },
91
91
  { event: 'SubagentStart', async: true, timeout: 10 },
92
92
  { event: 'SubagentStop', async: true, timeout: 10 },
93
+ { event: 'PreCompact', async: true, timeout: 10 },
93
94
  { event: 'PostCompact', async: true, timeout: 10 },
94
95
  { event: 'PostModelSwitch', async: true, timeout: 10 },
95
96
  ]
@@ -157,7 +158,8 @@ export function currentHookPaths(): HookPaths {
157
158
 
158
159
  type HookBlock = { matcher?: unknown; hooks?: unknown }
159
160
 
160
- function hookIsOurs(h: unknown): boolean {
161
+ /** Exported (6.62.0) so the Codex installer names our hooks by the same script path. */
162
+ export function hookIsOurs(h: unknown): boolean {
161
163
  return !!h && typeof h === 'object' && typeof (h as { command?: unknown }).command === 'string'
162
164
  && ((h as { command: string }).command.includes(HOOK_SCRIPT_MARKER) || (h as { command: string }).command.includes(HOOK_SCRIPT_NAME + ' '))
163
165
  }
@@ -307,6 +309,7 @@ export interface HookStatus {
307
309
  * has not run Install hooks since. Questions are then held at most 120 s, as before.
308
310
  */
309
311
  priorWaitOnly: boolean
312
+ observerOnlyDrift?: boolean
310
313
  /** 6.60.0: our PermissionRequest hook's `timeout` in the settings file, or null (`permissionHookTimeoutS`). */
311
314
  permissionHookTimeoutS: number | null
312
315
  tokenPresent: boolean
@@ -362,7 +365,8 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
362
365
  else state = 'installed'
363
366
  const priorWaitOnly = state === 'drift' && events.missing.length === 0 && events.drifted.length > 0
364
367
  && events.drifted.every(event => events.priorWait.includes(event))
365
- return { state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, priorWaitOnly, permissionHookTimeoutS: permissionWaitS, tokenPresent }
368
+ const observerOnlyDrift = state === 'drift' && events.missing.length > 0 && events.missing.every(event => event === 'PreCompact') && events.drifted.every(event => events.priorWait.includes(event))
369
+ return { observerOnlyDrift, state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, priorWaitOnly, permissionHookTimeoutS: permissionWaitS, tokenPresent }
366
370
  }
367
371
 
368
372
  /**
@@ -378,12 +382,20 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
378
382
  * (7433b7f), and it carries the same halt check.
379
383
  * - df54677f...: the script 6.53.3 through 6.59.0 shipped. 6.60.0 changed it only for the
380
384
  * away hold (the PermissionRequest wait); its halt check is the same.
385
+ * - 0e1bbb98...: the script 6.60.0 through 6.61.7 shipped (B1). 6.62.0 changed it for Codex
386
+ * and Cursor (provider stamp, Codex deny-only reply, Cursor `conversation_id`); its Claude
387
+ * halt reply is byte for byte the same, so a Mac still on it stops desk Claude runs.
388
+ * - 1877c90c...: the 6.62.0 script as first built, before its /qa round taught it to recognise
389
+ * a Codex payload (QA W13). Never published, but installable from a local build of that
390
+ * branch, and its Claude halt check is the same.
381
391
  * Each is pinned against its bytes in `server/lib/__fixtures__/`.
382
392
  */
383
393
  export const HALT_CAPABLE_PRIOR_SCRIPT_SHAS: readonly string[] = [
384
394
  '1158bb06297550128f01fc47caa68806a5287d6da2d05b0d1c812e8ae20764e7',
385
395
  'c0b41bf8581cfea498e1e1ac5220fe4e148bd97448d13b60a88879ee5992cc51',
386
396
  'df54677f79908893509ee87608cfe3f4a53ced03f88f337f16a63741d6b3d547',
397
+ '0e1bbb9816c9475436f89f6326d5504eccc9aa6519ff5e466b949d53a73d8439',
398
+ '1877c90c5359571dfc74c6d03395104a73925868147e8a50cd9d7b4226b1b5ca',
387
399
  ]
388
400
 
389
401
  /**
@@ -397,16 +409,26 @@ export const HALT_CAPABLE_PRIOR_SCRIPT_SHAS: readonly string[] = [
397
409
  * check, so updating the server never turns cancel from the lens off. Any other drift
398
410
  * still is not halt-ready.
399
411
  */
400
- export function hookHaltReady(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha'> & Partial<Pick<HookStatus, 'priorWaitOnly' | 'packageScriptSha'>>): boolean {
412
+ export function hookHaltReady(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha'> & Partial<Pick<HookStatus, 'priorWaitOnly' | 'observerOnlyDrift' | 'packageScriptSha'>>): boolean {
401
413
  if (status.installed === true) return true
402
414
  const priorScript = typeof status.scriptSha === 'string' && HALT_CAPABLE_PRIOR_SCRIPT_SHAS.includes(status.scriptSha)
403
415
  if (status.state === 'script_outdated') return priorScript
404
- if (status.state === 'drift' && status.priorWaitOnly === true) {
416
+ if (status.state === 'drift' && (status.priorWaitOnly === true || status.observerOnlyDrift === true)) {
405
417
  return priorScript || (typeof status.scriptSha === 'string' && status.scriptSha === status.packageScriptSha)
406
418
  }
407
419
  return false
408
420
  }
409
421
 
422
+ /**
423
+ * 6.62.0 (QA W1): can these hooks stop a desk CURSOR run? Halt-ready, AND the installed script is
424
+ * this package's, byte for byte. Every earlier script reads the first `session_id`, which Cursor
425
+ * writes behind `tool_input`: a large tool call is missed, and one naming another session stops
426
+ * the wrong run. Claude's own readiness (`hookHaltReady`) is unchanged.
427
+ */
428
+ export function cursorDeskHaltReady(status: Parameters<typeof hookHaltReady>[0]): boolean {
429
+ return hookHaltReady(status) && typeof status.scriptSha === 'string' && status.scriptSha === status.packageScriptSha
430
+ }
431
+
410
432
  const INSTALL_COMMAND = 'npx --yes @gotcos/glasses-server@latest --hooks install'
411
433
 
412
434
  /**
@@ -468,7 +490,8 @@ export interface InstallResult {
468
490
 
469
491
  const BACKUPS_KEPT = 3
470
492
 
471
- function backupSettings(settingsPath: string): string | null {
493
+ /** Exported (6.62.0) for the Codex installer: the same backup, the same three kept. */
494
+ export function backupSettings(settingsPath: string): string | null {
472
495
  if (!existsSync(settingsPath)) return null
473
496
  const stamp = new Date().toISOString().replace(/[:.]/g, '-')
474
497
  const backup = `${settingsPath}.cos-backup-${stamp}`
@@ -85,6 +85,7 @@ import {
85
85
  import { terminalProviderAuthFailure } from './provider-terminal-error.js'
86
86
  import { terminateProviderProcess } from './provider-process-lifecycle.js'
87
87
  import { teeJobTrail } from './job-trail.js'
88
+ import { applyTurnRequestEnv, createTurnRequestBinding, spawnWithTurnRequest } from './turn-request.js'
88
89
 
89
90
  const INACTIVITY_MS = 180_000
90
91
  const WALL_MAX_MS = 900_000
@@ -481,12 +482,19 @@ export async function callCodexStreaming(
481
482
  )
482
483
  }
483
484
 
484
- const proc = spawn(resolvedCodex.path, args, {
485
+ // The COS UserPromptSubmit hook sees `prompt`, which is SYSTEM INSTRUCTIONS plus USER
486
+ // REQUEST in one text, on a fresh thread and on resume alike. This private file hands it
487
+ // the user's own words, bound by the hash of exactly what goes on stdin; the file dies
488
+ // with the child. See lib/turn-request.ts.
489
+ const turnRequest = createTurnRequestBinding({ userText: query, prompt, turnId: options?.turnId })
490
+ applyTurnRequestEnv(env, turnRequest)
491
+
492
+ const proc = spawnWithTurnRequest(turnRequest, () => spawn(resolvedCodex.path, args, {
485
493
  stdio: ['pipe', 'pipe', 'pipe'],
486
494
  env,
487
495
  cwd: codexCwd,
488
496
  detached: true,
489
- })
497
+ }))
490
498
 
491
499
  let fullText = ''
492
500
  let stderr = ''
@@ -770,12 +778,19 @@ export async function callCodexStreaming(
770
778
  })
771
779
  }, WALL_MAX_MS)
772
780
 
781
+ let nativeSessionAnnounced = false
773
782
  function handleEvent(event: any) {
774
783
  const nextThreadId = extractCodexThreadId(event)
775
784
  if (nextThreadId && nextThreadId !== codexThreadId) {
776
785
  codexThreadId = nextThreadId
777
786
  updateCodexRun(run.runId, { codexThreadId })
778
787
  }
788
+ // 6.62.0 (plan 3.9): the job links to its native thread at the first event naming it,
789
+ // not only at completion.
790
+ if (nextThreadId && !nativeSessionAnnounced) {
791
+ nativeSessionAnnounced = true
792
+ try { callbacks.onNativeSession?.({ provider: 'codex', cliSessionId: nextThreadId }) } catch { /* linkage is best effort */ }
793
+ }
779
794
 
780
795
  const status = toolStatus(event)
781
796
  if (status) callbacks.onToolStatus?.(status)