@gotcos/glasses-server 6.77.0 → 6.78.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 (33) hide show
  1. package/.env.example +40 -0
  2. package/CHANGELOG.md +16 -0
  3. package/README.md +12 -1
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -1
  6. package/server/index.ts +92 -9
  7. package/server/lib/agent-session-binding-registry.ts +623 -9
  8. package/server/lib/boot-resume.ts +552 -0
  9. package/server/lib/claude-bridge.ts +152 -3
  10. package/server/lib/claude-cli-support.ts +39 -0
  11. package/server/lib/claude-steer.ts +259 -0
  12. package/server/lib/env-switch.ts +17 -0
  13. package/server/lib/managed-restart.ts +8 -0
  14. package/server/lib/provider-actions.ts +18 -0
  15. package/server/lib/query-job-coordinator.ts +300 -0
  16. package/server/lib/query-job-feature.ts +39 -0
  17. package/server/lib/query-job-resume.ts +269 -0
  18. package/server/lib/query-job-runtime.ts +23 -3
  19. package/server/lib/query-job-store.ts +516 -7
  20. package/server/lib/query-job-types.ts +210 -1
  21. package/server/lib/queue-turn-evidence.ts +213 -0
  22. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  23. package/server/lib/thread-turn-queue-store.ts +13 -4
  24. package/server/lib/thread-turn-queue.ts +151 -1
  25. package/server/routes/agent-session-bindings.ts +169 -11
  26. package/server/routes/health.ts +26 -1
  27. package/server/routes/maintenance.ts +34 -0
  28. package/server/routes/meeting.ts +27 -9
  29. package/server/routes/query-jobs.ts +107 -13
  30. package/server/routes/recovery.ts +4 -0
  31. package/server/routes/thread-turn-queue.ts +371 -10
  32. package/server/scripts/live-cues-v2-notes-mutation-gate.mjs +0 -170
  33. package/server/scripts/transcript-cleanup-mutation-gate.mjs +0 -219
@@ -62,6 +62,10 @@ 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
64
  import { applyTurnRequestEnv, createTurnRequestBinding, spawnWithTurnRequest } from './turn-request.js'
65
+ import { STEER_REAPER_MS, STEER_SETTLE_MS, SteerTracker, composeFirstContent, encodeUserLine, steerClock, type SteerPort } from './claude-steer.js'
66
+ import { randomUUID } from 'node:crypto'
67
+
68
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
65
69
 
66
70
  // Inactivity = no stdout data for this long → kill (catches stalls)
67
71
  const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
@@ -407,6 +411,12 @@ export interface CallOptions {
407
411
  dispatch?: { restricted: true; tools: readonly string[] }
408
412
  /** 6.58.2: `claude --name` for a session this COS session starts fresh (QueryJobRequest.sessionName). */
409
413
  sessionName?: string
414
+ /**
415
+ * 6.78.0 Messages steer: the job's steer port. Present only when the coordinator decided the
416
+ * run is steerable (lib/claude-steer.ts). Absent, every argument and every stdin byte is the
417
+ * 6.77.0 one. A dispatch never steers.
418
+ */
419
+ steer?: SteerPort
410
420
  }
411
421
 
412
422
  export async function callClaudeStreaming(
@@ -599,6 +609,10 @@ export async function callClaudeStreaming(
599
609
  if (startsOwnCliSession && !options?.dispatch && options?.sessionName && await claudeSupportsSessionName()) {
600
610
  args.push(`--name=${options.sessionName}`)
601
611
  }
612
+ // 6.78.0 steer: the prompt becomes one stream-json line and stdin stays open for steers.
613
+ // These two flags and nothing else (plan P4); the CLI probe passed before the port existed.
614
+ const steerPort = options?.dispatch ? undefined : options?.steer
615
+ if (steerPort) args.push('--input-format', 'stream-json', '--replay-user-messages')
602
616
 
603
617
  // Strip CLAUDECODE env var so claude -p doesn't think it's nested
604
618
  const env = { ...process.env }
@@ -612,6 +626,9 @@ export async function callClaudeStreaming(
612
626
  : defaultWallMax
613
627
  const wallMax = isExtendedQuery(query) ? Math.max(WALL_MAX_EXTENDED_MS, effortWallMax) : effortWallMax
614
628
  const startTime = Date.now()
629
+ // 6.78.0 steer (P7 S7): the wall clock's start on the monotonic steer clock, next to the
630
+ // wall timer it describes, so the coordinator's cut-off reads the same clock after a sleep.
631
+ const steerStartedAt = steerClock()
615
632
  const run = startClaudeRun({
616
633
  cosSessionId: sid,
617
634
  model: resolvedModel,
@@ -627,9 +644,12 @@ export async function callClaudeStreaming(
627
644
 
628
645
  // Ultracode is a CLI-only orchestration keyword; history/chat keeps the
629
646
  // original user text so the keyword never appears on the lens.
630
- const cliQuery = resolvedEffort === 'ultracode'
647
+ const promptText = resolvedEffort === 'ultracode'
631
648
  ? `${fullQuery}\n\n${ULTRACODE_KEYWORD}`
632
649
  : fullQuery
650
+ // 6.78.0 steer: steers that arrived before the first line ride in it (never in request.query
651
+ // or the fingerprint). They are journaled `included` once the line is written.
652
+ const cliQuery = steerPort ? composeFirstContent(promptText, steerPort.drainForCompose()) : promptText
633
653
  // The COS UserPromptSubmit hook sees `cliQuery` (image preamble, attachment block and
634
654
  // all). This private file hands it the user's own words, bound by the hash of exactly
635
655
  // what goes on stdin; the file dies with the child. See lib/turn-request.ts.
@@ -651,6 +671,57 @@ export async function callClaudeStreaming(
651
671
  let terminalTextError: string | null = null
652
672
  let lastActivity = Date.now() // Tracks last stdout data for inactivity timeout
653
673
  let receivedStreamEvents = false // Track if CLI emits stream_event (vs older assistant-only format)
674
+ // 6.78.0 steer state (all inert without a port).
675
+ // The prompt line's uuid is the job's turn id when it is one (the plan's wire shape), so a
676
+ // result's user_message_uuids names the turn; anything else gets a fresh uuid.
677
+ const steerTracker = steerPort
678
+ ? new SteerTracker(typeof options?.turnId === 'string' && UUID_RE.test(options.turnId) ? options.turnId.toLowerCase() : randomUUID())
679
+ : null
680
+ let steerSettling = false // a result left a steer uncovered: the settle window is open
681
+ let steerSettleTimer: ReturnType<typeof setTimeout> | null = null
682
+ let steerNeedsSeparator = false // text after such a result is a second turn
683
+ let steerCheckpoint: string | null = null
684
+ let steerStdinEnded = false
685
+ let steerReaper: ReturnType<typeof setTimeout> | null = null
686
+ let steerKilled = false
687
+ let childClosed = false
688
+ function endSteerStdin() {
689
+ if (!steerPort || steerStdinEnded) return
690
+ steerStdinEnded = true
691
+ steerPort.close()
692
+ try { proc.stdin.end() } catch { /* the child is gone */ }
693
+ }
694
+ /**
695
+ * 6.78.0 (P7 S2): the run ended with steer lines written that no result covered. Ending
696
+ * stdin would let the CLI run those queued lines unwatched (up to the 15 s reaper), and a
697
+ * later --resume of its session would inherit them while the job reads them not_applied,
698
+ * so the client sends them again. Instead: the port closes, the child is killed, and the
699
+ * CLI session mapping is dropped so the next Ask starts a fresh session.
700
+ */
701
+ function abandonUncoveredSteers() {
702
+ steerStdinEnded = true
703
+ steerPort!.close()
704
+ if (cliSessionMap.delete(resolvedCliKey)) scheduleCliSessionSave()
705
+ if (childClosed || terminationRequested || steerKilled) return
706
+ steerKilled = true
707
+ void terminateProviderProcess(proc)
708
+ }
709
+ /** A finished steer-mode run whose child has not exited by now is terminated (stdin is closed). */
710
+ function armSteerReaper() {
711
+ if (!steerPort || steerReaper || childClosed || steerKilled) return
712
+ steerReaper = setTimeout(() => {
713
+ if (!childClosed && !terminationRequested) void terminateProviderProcess(proc)
714
+ }, STEER_REAPER_MS)
715
+ steerReaper.unref?.()
716
+ }
717
+ /** Second-turn text keeps the partial a prefix of the answer: one separator first. */
718
+ function steerSeparator() {
719
+ if (!steerNeedsSeparator) return
720
+ steerNeedsSeparator = false
721
+ if (!fullText) return
722
+ fullText += '\n\n'
723
+ callbacks.onChunk('\n\n')
724
+ }
654
725
  const toolInputs = new Map<number, { name: string; json: string }>()
655
726
 
656
727
  function cleanupImages() {
@@ -683,6 +754,7 @@ export async function callClaudeStreaming(
683
754
  }
684
755
  finalized = true
685
756
  cleanup()
757
+ armSteerReaper()
686
758
  cleanupImages()
687
759
 
688
760
  // The coordinator persists the final provider text before conversation
@@ -796,6 +868,7 @@ export async function callClaudeStreaming(
796
868
  if (finalized) return
797
869
  finalized = true
798
870
  cleanup()
871
+ armSteerReaper()
799
872
  cleanupImages()
800
873
  outputImagePublisher?.cleanup()
801
874
  removeExchange(sid, pendingUserExchange)
@@ -860,6 +933,11 @@ export async function callClaudeStreaming(
860
933
  function resetInactivity() {
861
934
  lastActivity = Date.now()
862
935
  clearTimeout(inactivityTimer)
936
+ // 6.78.0 steer: the inactivity timer pauses during the settle window; output resets the settle.
937
+ if (steerSettling) {
938
+ armSteerSettle()
939
+ return
940
+ }
863
941
  inactivityTimer = setTimeout(() => {
864
942
  const elapsed = Math.round((Date.now() - startTime) / 1000)
865
943
  void terminateForTerminal('inactivity timeout', result => finalizeError(
@@ -869,10 +947,33 @@ export async function callClaudeStreaming(
869
947
  }, inactivityMs)
870
948
  }
871
949
 
950
+ /**
951
+ * 6.78.0 steer: a result covered the prompt but not every steer. Wait for the next result,
952
+ * at most STEER_SETTLE_MS of quiet; then close stdin (the CLI runs anything queued and
953
+ * exits) and let the close finish the run with what it has.
954
+ */
955
+ function armSteerSettle() {
956
+ if (steerSettleTimer) clearTimeout(steerSettleTimer)
957
+ steerSettleTimer = setTimeout(() => {
958
+ steerSettleTimer = null
959
+ steerSettling = false
960
+ endSteerStdin()
961
+ if (!finalized) resetInactivity()
962
+ }, STEER_SETTLE_MS)
963
+ steerSettleTimer.unref?.()
964
+ }
965
+ function enterSteerSettle() {
966
+ steerSettling = true
967
+ clearTimeout(inactivityTimer)
968
+ armSteerSettle()
969
+ }
970
+
872
971
  // ─── Wall clock max: absolute cap ───
873
972
 
874
973
  const wallTimer = setTimeout(() => {
875
974
  void terminateForTerminal('wall timeout', result => {
975
+ // 6.78.0 steer: a steered run past its first whole answer completes with that answer.
976
+ if (steerCheckpoint !== null) return finalize(steerCheckpoint)
876
977
  if (fullText) {
877
978
  // Got partial output — deliver only after the process tree is closed.
878
979
  return finalize(fullText)
@@ -886,6 +987,15 @@ export async function callClaudeStreaming(
886
987
  clearTimeout(inactivityTimer)
887
988
  clearTimeout(wallTimer)
888
989
  options?.abortSignal?.removeEventListener('abort', handleAbort)
990
+ // 6.78.0 steer: every terminal path closes the port and stdin.
991
+ if (steerSettleTimer) clearTimeout(steerSettleTimer)
992
+ steerSettleTimer = null
993
+ steerSettling = false
994
+ if (steerTracker && steerTracker.uncovered().length > 0) {
995
+ abandonUncoveredSteers()
996
+ return
997
+ }
998
+ endSteerStdin()
889
999
  }
890
1000
 
891
1001
  // ─── Process stdout ───
@@ -924,6 +1034,7 @@ export async function callClaudeStreaming(
924
1034
  const inner = event.event
925
1035
  if (inner?.type === 'content_block_delta' && inner.delta?.type === 'text_delta' && inner.delta.text) {
926
1036
  phase = 'generating'
1037
+ steerSeparator()
927
1038
  fullText += inner.delta.text
928
1039
  callbacks.onChunk(inner.delta.text)
929
1040
  } else if (inner?.type === 'content_block_start' && inner.content_block?.type === 'thinking') {
@@ -982,12 +1093,16 @@ export async function callClaudeStreaming(
982
1093
  if (authFailure) terminalTextError = authFailure
983
1094
  else {
984
1095
  phase = 'generating'
1096
+ steerSeparator()
985
1097
  fullText += text
986
1098
  callbacks.onChunk(text)
987
1099
  }
988
1100
  }
989
1101
  }
990
1102
  } else if (event.type === 'user') {
1103
+ // 6.78.0 steer: the CLI replaying a steer line means it has it.
1104
+ const delivered = steerTracker?.onUserEvent(event)
1105
+ if (delivered) steerPort!.events.onDelivered?.(delivered)
991
1106
  for (const preview of claudeToolResultPreviewLines(event)) callbacks.onActivityLine?.(preview)
992
1107
  } else if (event.type === 'result') {
993
1108
  const resultError = claudeResultErrorMessage(event)
@@ -1001,6 +1116,25 @@ export async function callClaudeStreaming(
1001
1116
  scheduleCliSessionSave()
1002
1117
  updateClaudeRun(run.runId, { cliSessionId: event.session_id })
1003
1118
  }
1119
+ // 6.78.0 steer: a result finishes the run only when it (with the ones before it)
1120
+ // covers every line written. A result that leaves a steer uncovered checkpoints the
1121
+ // answer the first time it covers the prompt, and the run waits.
1122
+ if (steerTracker && !finalized) {
1123
+ const verdict = steerTracker.onResult(event)
1124
+ if (verdict.folded.length > 0) steerPort!.events.onFolded?.(verdict.folded)
1125
+ if (verdict.action === 'wait') {
1126
+ if (verdict.checkpoint) {
1127
+ const checkpoint: string = fullText || event.result || ''
1128
+ steerCheckpoint = checkpoint
1129
+ steerPort!.events.onCheckpoint?.(checkpoint)
1130
+ }
1131
+ steerNeedsSeparator = true
1132
+ enterSteerSettle()
1133
+ continue
1134
+ }
1135
+ // finish, or finish with steering off (the CLI named no uuids): no more steers.
1136
+ steerPort!.close()
1137
+ }
1004
1138
  // Final result — use accumulated text (more reliable than result.result)
1005
1139
  void finalize(fullText || event.result || '')
1006
1140
  }
@@ -1025,6 +1159,9 @@ export async function callClaudeStreaming(
1025
1159
  })
1026
1160
 
1027
1161
  proc.on('close', (code) => {
1162
+ childClosed = true
1163
+ if (steerReaper) clearTimeout(steerReaper)
1164
+ steerPort?.close()
1028
1165
  if (terminationRequested) return
1029
1166
  // Process any remaining buffer
1030
1167
  if (buffer.trim()) {
@@ -1090,8 +1227,20 @@ export async function callClaudeStreaming(
1090
1227
  return sid
1091
1228
  }
1092
1229
  if (finalized) return sid
1093
- proc.stdin.write(cliQuery)
1094
- proc.stdin.end()
1230
+ if (steerPort && steerTracker) {
1231
+ // 6.78.0 steer: the prompt is one stream-json line; stdin stays open. Steers offered
1232
+ // after the compose are written right behind it, then every later one as it comes.
1233
+ proc.stdin.write(encodeUserLine(steerTracker.promptUuid, cliQuery))
1234
+ steerPort.open((line, steerId) => {
1235
+ if (finalized || steerStdinEnded || !proc.stdin.writable) return false
1236
+ proc.stdin.write(line)
1237
+ steerTracker.noteWritten(steerId)
1238
+ return true
1239
+ }, { startedAt: steerStartedAt, wallMaxMs: wallMax })
1240
+ } else {
1241
+ proc.stdin.write(cliQuery)
1242
+ proc.stdin.end()
1243
+ }
1095
1244
  } catch (err) {
1096
1245
  const message = err instanceof Error ? err.message : String(err)
1097
1246
  if (!finalized) {
@@ -32,6 +32,45 @@ export function helpListsNameOption(help: string): boolean {
32
32
  return /(^|[\s,])--name[\s=<[]/m.test(help)
33
33
  }
34
34
 
35
+ /**
36
+ * 6.78.0 Messages steer: does the installed Claude CLI take `--input-format stream-json` and
37
+ * `--replay-user-messages` (probed on 2.1.302)? Asked once, like `--name`. Any failure is a no,
38
+ * and a run then streams exactly as before. `claudeSteerSupportKnown()` is the answer once it is
39
+ * in, for the synchronous /api/models capability (null until the first probe finishes).
40
+ */
41
+ let steerSupport: Promise<boolean> | null = null
42
+ let steerSupportKnown: boolean | null = null
43
+
44
+ export function claudeSupportsSteer(): Promise<boolean> {
45
+ if (!steerSupport) {
46
+ steerSupport = new Promise<boolean>(resolve => {
47
+ try {
48
+ execFile('claude', ['--help'], { timeout: HELP_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
49
+ const supported = !error && helpListsSteerOptions(`${stdout}\n${stderr}`)
50
+ if (!supported) console.warn('[claude-bridge] this Claude CLI does not list --input-format and --replay-user-messages; Messages steer is off')
51
+ steerSupportKnown = supported
52
+ resolve(supported)
53
+ })
54
+ } catch {
55
+ steerSupportKnown = false
56
+ resolve(false)
57
+ }
58
+ })
59
+ }
60
+ return steerSupport
61
+ }
62
+
63
+ export function claudeSteerSupportKnown(): boolean | null {
64
+ return steerSupportKnown
65
+ }
66
+
67
+ /** Both options, as Claude Code 2.1.302 prints them (`--input-format <format>`, `--replay-user-messages`). */
68
+ export function helpListsSteerOptions(help: string): boolean {
69
+ return /(^|[\s,])--input-format[\s=<[]/m.test(help) && /(^|[\s,])--replay-user-messages([\s=<[]|$)/m.test(help)
70
+ }
71
+
35
72
  export function resetClaudeCliSupportForTests(): void {
36
73
  sessionNameSupport = null
74
+ steerSupport = null
75
+ steerSupportKnown = null
37
76
  }
@@ -0,0 +1,259 @@
1
+ // 6.78.0 Messages steer (plan v3.1 P4): everything about steering a running Claude answer that
2
+ // can be decided without a process, a file or the network. The bridge holds thin wiring only;
3
+ // the coordinator owns the journal. Imports nothing that reaches a provider, so tests and the
4
+ // Pi Durable canaries load it directly.
5
+ //
6
+ // THE PROTOCOL (probed on Claude Code 2.1.302, pi_durable_build/probes). With
7
+ // `-p --input-format stream-json --replay-user-messages`, the prompt is one stream-json user
8
+ // line and stdin stays open. A later user line at the DEFAULT priority (never "now") folds into
9
+ // the running turn at the next tool boundary: the CLI replays it (`type: user`, its own uuid,
10
+ // `isReplay: true`), and the turn's one `result` lists every folded line in
11
+ // `user_message_uuids`. A line that arrives after the turn's last step starts a second turn,
12
+ // which ends in a second result. The run is finished only when the results cover every line
13
+ // written; the CLI exits about 300 ms after stdin closes.
14
+ //
15
+ // WHAT THIS MODULE DECIDES:
16
+ // encodeUserLine one stdin line
17
+ // composeFirstContent the first prompt plus steers that arrived before it was written
18
+ // (pending ones, and ones carried from an interrupted generation)
19
+ // SteerPort one per job, created before the runner starts: a pending buffer the
20
+ // first line drains, then direct writes, then closed (too late)
21
+ // SteerTracker sent and covered uuids; what a result means (finish, wait, steering off)
22
+ // steerCutoffMs how close to a deadline a steer is refused as too late
23
+ // resolveRunModel the model a run will use, exactly as model-router resolves it
24
+ import { DEFAULT_MODEL, isCodexModel, isCursorModel, isOllamaModel, normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
25
+
26
+ /** Quiet after a result that left a steer uncovered, before stdin is closed (plan P4). */
27
+ export const STEER_SETTLE_MS = 20_000
28
+ /** After a steer-mode run is finalized, a child that has not exited by now is terminated. */
29
+ export const STEER_REAPER_MS = 15_000
30
+ const CUTOFF_FLOOR_MS = 10_000
31
+ const CUTOFF_FRACTION = 0.2
32
+
33
+ /**
34
+ * 6.78.0 (P7 S7): the clock every steer cut-off is measured on, the bridge's wall clock and the
35
+ * coordinator's deadline alike. Monotonic, like the timers that actually end the run
36
+ * (setTimeout runs on libuv's monotonic clock), so a Mac that slept does not make a steer read
37
+ * too late against a timer that did not advance. `Date.now()` jumps across a sleep.
38
+ */
39
+ export function steerClock(): number {
40
+ return performance.now()
41
+ }
42
+
43
+ /** A steer arriving with less than this left on the wall clock or the deadline is too late. */
44
+ export function steerCutoffMs(wallMaxMs: number): number {
45
+ return Math.max(CUTOFF_FLOOR_MS, Math.round(CUTOFF_FRACTION * Math.max(0, wallMaxMs)))
46
+ }
47
+
48
+ /** One stdin line of `--input-format stream-json`: a user message with its own uuid. */
49
+ export function encodeUserLine(uuid: string, content: string): string {
50
+ return `${JSON.stringify({ type: 'user', uuid, parent_tool_use_id: null, message: { role: 'user', content } })}\n`
51
+ }
52
+
53
+ /**
54
+ * The first prompt, with the steers that are already known when it is written. Never part of
55
+ * `request.query` or the fingerprint (Resume and rollback hash the request): composed here, at
56
+ * execution time only. Pending steers (sent before the run started) and steers carried from an
57
+ * earlier generation read alike.
58
+ */
59
+ export function composeFirstContent(prompt: string, steers: ReadonlyArray<{ text: string }>): string {
60
+ const lines = steers.map(steer => steer.text.trim()).filter(Boolean)
61
+ if (lines.length === 0) return prompt
62
+ return `${prompt}\n\nThe user added this while the answer was being prepared:\n${lines.map(line => `- ${line}`).join('\n')}`
63
+ }
64
+
65
+ export interface PendingSteer {
66
+ steerId: string
67
+ text: string
68
+ /** The generation it was first sent to, when it is carried into a later one. */
69
+ carriedFrom?: number
70
+ }
71
+
72
+ /** What the port tells its owner (the coordinator journals each). */
73
+ export interface SteerPortEvents {
74
+ /** Composed into the first line (pending before the start, or carried). */
75
+ onIncluded?: (steers: PendingSteer[]) => void
76
+ /** The CLI replayed the line: it has it. */
77
+ onDelivered?: (steerId: string) => void
78
+ /** A result covered it: the answer includes it. */
79
+ onFolded?: (steerIds: string[]) => void
80
+ /** The first result covered the prompt while a steer was still uncovered: the answer so far. */
81
+ onCheckpoint?: (text: string) => void
82
+ }
83
+
84
+ export type SteerOffer = 'pending' | 'written' | 'closed' | 'duplicate'
85
+
86
+ /**
87
+ * One per job, on the coordinator's ActiveRun BEFORE the runner starts, so no steer can fall
88
+ * between "accepted" and a sink that does not exist yet.
89
+ *
90
+ * before the first line: `offer` buffers; the bridge drains the buffer into the first line
91
+ * after `open(sink)`: `offer` writes a line at once (the sink must say it is writable)
92
+ * after `close()`: `offer` is refused, and the caller journals too_late
93
+ */
94
+ export class SteerPort {
95
+ readonly events: SteerPortEvents
96
+ private readonly pending: PendingSteer[] = []
97
+ private readonly known = new Set<string>()
98
+ private sink: ((line: string, steerId: string) => boolean) | null = null
99
+ private composed: PendingSteer[] = []
100
+ private closed = false
101
+ private wallDeadlineAt: number | null = null
102
+ private wallMaxMs: number | null = null
103
+ /** Every steer uuid written to stdin, in order (the tracker's `sent`, less the prompt). */
104
+ readonly written: string[] = []
105
+
106
+ constructor(events: SteerPortEvents = {}) {
107
+ this.events = events
108
+ }
109
+
110
+ get isClosed(): boolean { return this.closed }
111
+ get isOpen(): boolean { return this.sink !== null && !this.closed }
112
+
113
+ /** A steer to apply. Idempotent by id. */
114
+ offer(steer: PendingSteer): SteerOffer {
115
+ if (this.known.has(steer.steerId)) return 'duplicate'
116
+ if (this.closed) return 'closed'
117
+ this.known.add(steer.steerId)
118
+ if (!this.sink) {
119
+ this.pending.push(steer)
120
+ return 'pending'
121
+ }
122
+ if (!this.sink(encodeUserLine(steer.steerId, steer.text), steer.steerId)) {
123
+ this.closed = true
124
+ this.sink = null
125
+ return 'closed'
126
+ }
127
+ this.written.push(steer.steerId)
128
+ return 'written'
129
+ }
130
+
131
+ /** The bridge, composing the first line: every pending steer, in order, once. They are
132
+ * reported `onIncluded` when the line is actually written (`open`). */
133
+ drainForCompose(): PendingSteer[] {
134
+ this.composed = this.pending.splice(0)
135
+ return [...this.composed]
136
+ }
137
+
138
+ /**
139
+ * The first line is written: later steers go straight to stdin. Steers offered after the
140
+ * compose and before this call are written now, in order. Returns the ones written here.
141
+ */
142
+ open(sink: (line: string, steerId: string) => boolean, timing: { startedAt: number; wallMaxMs: number }): string[] {
143
+ if (this.closed) return []
144
+ this.wallMaxMs = timing.wallMaxMs
145
+ this.wallDeadlineAt = timing.startedAt + timing.wallMaxMs
146
+ const composed = this.composed.splice(0)
147
+ if (composed.length > 0) this.events.onIncluded?.(composed)
148
+ this.sink = sink
149
+ const late = this.pending.splice(0)
150
+ const out: string[] = []
151
+ for (const steer of late) {
152
+ if (!sink(encodeUserLine(steer.steerId, steer.text), steer.steerId)) {
153
+ this.closed = true
154
+ this.sink = null
155
+ break
156
+ }
157
+ this.written.push(steer.steerId)
158
+ out.push(steer.steerId)
159
+ }
160
+ return out
161
+ }
162
+
163
+ /** Steers that were offered and never written (they read not_applied when the run ends). */
164
+ unwritten(): PendingSteer[] {
165
+ return [...this.composed, ...this.pending]
166
+ }
167
+
168
+ /** No more writes: the run is finishing or finished. */
169
+ close(): void {
170
+ this.closed = true
171
+ this.sink = null
172
+ }
173
+
174
+ /** Time left on the bridge's wall clock, or null before the first line. */
175
+ wallRemainingMs(now: number): number | null {
176
+ return this.wallDeadlineAt === null ? null : this.wallDeadlineAt - now
177
+ }
178
+
179
+ wallMax(): number | null {
180
+ return this.wallMaxMs
181
+ }
182
+ }
183
+
184
+ export type ResultVerdict =
185
+ /** Every line written is covered: finish now with the whole answer. */
186
+ | { action: 'finish'; folded: string[] }
187
+ /** Some line is not covered yet: keep stdin open and wait (settle timer). */
188
+ | { action: 'wait'; folded: string[]; checkpoint: boolean }
189
+ /** The CLI did not say which lines a result covers: steering is off for this job, finish now. */
190
+ | { action: 'finish_steering_off'; folded: string[] }
191
+
192
+ /**
193
+ * Sent and covered uuids for one run. The prompt's uuid is sent first; each steer written is
194
+ * sent after it. A replay echo marks a steer delivered; a result adds its `user_message_uuids`
195
+ * to `covered`.
196
+ */
197
+ export class SteerTracker {
198
+ private readonly sent: string[]
199
+ private readonly covered = new Set<string>()
200
+ private readonly delivered = new Set<string>()
201
+ private promptCovered = false
202
+
203
+ constructor(readonly promptUuid: string) {
204
+ this.sent = [promptUuid]
205
+ }
206
+
207
+ noteWritten(uuid: string): void {
208
+ if (!this.sent.includes(uuid)) this.sent.push(uuid)
209
+ }
210
+
211
+ /** A `type: user` stream event. Returns a steer id the first time the CLI replays it. */
212
+ onUserEvent(event: { uuid?: unknown; isReplay?: unknown }): string | null {
213
+ const uuid = typeof event.uuid === 'string' ? event.uuid : null
214
+ if (!uuid || uuid === this.promptUuid || !this.sent.includes(uuid) || this.delivered.has(uuid)) return null
215
+ if (event.isReplay !== true) return null
216
+ this.delivered.add(uuid)
217
+ return uuid
218
+ }
219
+
220
+ /** A successful `result`. Errors are decided by the bridge before this is called. */
221
+ onResult(event: { user_message_uuids?: unknown }): ResultVerdict {
222
+ const uuids = event.user_message_uuids
223
+ if (!Array.isArray(uuids)) {
224
+ return { action: 'finish_steering_off', folded: [] }
225
+ }
226
+ const folded: string[] = []
227
+ for (const uuid of uuids) {
228
+ if (typeof uuid !== 'string' || this.covered.has(uuid)) continue
229
+ this.covered.add(uuid)
230
+ if (uuid !== this.promptUuid && this.sent.includes(uuid)) folded.push(uuid)
231
+ }
232
+ if (this.sent.every(uuid => this.covered.has(uuid))) return { action: 'finish', folded }
233
+ const firstCover = !this.promptCovered && this.covered.has(this.promptUuid)
234
+ if (firstCover) this.promptCovered = true
235
+ return { action: 'wait', folded, checkpoint: firstCover }
236
+ }
237
+
238
+ /** Steers written and not covered (they will read not_applied when the run ends). */
239
+ uncovered(): string[] {
240
+ return this.sent.filter(uuid => uuid !== this.promptUuid && !this.covered.has(uuid))
241
+ }
242
+ }
243
+
244
+ export type QueryProvider = 'claude' | 'codex' | 'cursor' | 'ollama'
245
+
246
+ /**
247
+ * The model a run will use, EXACTLY as model-router.ts resolves it (a test pins the two):
248
+ * the request's model, else the session's, else COS_G2_DEFAULT_MODEL, else the default.
249
+ */
250
+ export function resolveRunModel(model: unknown, sessionModel: ModelPreference | null | undefined, envDefault: unknown): ModelPreference {
251
+ return normalizeModelPreference(model) ?? sessionModel ?? normalizeModelPreference(envDefault) ?? DEFAULT_MODEL
252
+ }
253
+
254
+ /** The provider of a resolved model, with model-router's fall-through: anything else is Claude. */
255
+ export function providerOfModel(model: ModelPreference): QueryProvider {
256
+ if (isOllamaModel(model)) return 'ollama'
257
+ if (isCursorModel(model)) return 'cursor'
258
+ return isCodexModel(model) ? 'codex' : 'claude'
259
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 6.78.0: the ONE reader for every 6.78.0 switch (P7 S3). Before it, the Sessions switches
3
+ * took only an exact `1` or `0` while the Messages and crash-loop switches also took
4
+ * `true`/`false`/`on`/`off`, so `COS_QUEUE_RECONCILE=false` read ON.
5
+ *
6
+ * `1`, `true` or `on` force a switch on; `0`, `false` or `off` force it off; case and
7
+ * surrounding space are ignored. Anything else (unset or empty included) is the switch's
8
+ * DEFAULT. Callers read it at call time, so a restart applies a change.
9
+ *
10
+ * Pure, no imports: the Pi Durable canaries load modules that use it.
11
+ */
12
+ export function readSwitch(raw: string | undefined, fallback: boolean): boolean {
13
+ const value = (raw ?? '').trim().toLowerCase()
14
+ if (value === '1' || value === 'true' || value === 'on') return true
15
+ if (value === '0' || value === 'false' || value === 'off') return false
16
+ return fallback
17
+ }
@@ -15,6 +15,13 @@ export function scheduleManagedServerRestart(options?: {
15
15
  kickstart?: (args: string[]) => void
16
16
  exitProcess?: (code: number) => void
17
17
  onError?: (error: unknown) => void
18
+ /**
19
+ * 6.78.0 (P7 S4): runs right before the kickstart, after the self-restart switch is read.
20
+ * The phone's Restart marks a CLEAN exit here: if kickstart -k does not land a SIGTERM (so
21
+ * gracefulShutdown never runs) the 2 s `exit(1)` below would otherwise read as a crashed
22
+ * boot and count toward crash-loop parking. Never throws upward.
23
+ */
24
+ beforeRestart?: () => void
18
25
  }): void {
19
26
  const delayMs = options?.delayMs ?? 350
20
27
  const label = (options?.label ?? process.env.COS_LAUNCHD_LABEL?.trim()) || DEFAULT_LAUNCHD_LABEL
@@ -29,6 +36,7 @@ export function scheduleManagedServerRestart(options?: {
29
36
 
30
37
  const timer = setTimeout(() => {
31
38
  if (process.env.COS_DISABLE_SELF_RESTART === '1') return
39
+ try { options?.beforeRestart?.() } catch (error) { onError(error) }
32
40
  try {
33
41
  const uid = userInfo().uid
34
42
  kickstart(['kickstart', '-k', `gui/${uid}/${label}`])