@animalabs/connectome-host 0.3.8 → 0.3.10

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.
package/src/tui.ts CHANGED
@@ -66,6 +66,17 @@ interface TokenUsage {
66
66
  output: number;
67
67
  cacheRead: number;
68
68
  cacheWrite: number;
69
+ /** Session cost estimate from the framework's UsageTracker, when priced. */
70
+ cost?: { total: number; currency: string };
71
+ }
72
+
73
+ /** One active operator alert from the ops:alert pipeline, keyed
74
+ * `${agent}:${kind}` (fleet-child alerts prefix the child name). */
75
+ interface OpsAlertEntry {
76
+ kind: string;
77
+ agent: string;
78
+ message: string;
79
+ count: number;
69
80
  }
70
81
 
71
82
  interface TuiState {
@@ -84,6 +95,10 @@ interface TuiState {
84
95
  peekTarget: string | null;
85
96
  /** Name of the child process being peeked at (peek-proc mode). */
86
97
  peekProcTarget: string | null;
98
+ /** When set, peek-proc is filtered to this one agent inside the child —
99
+ * the honest per-agent view for agents and sub-subagents living in a
100
+ * fleet child. Null = whole-process stream. */
101
+ peekProcAgent: string | null;
87
102
  /** True while we're waiting for the user to resolve a pending /quit with children still running. */
88
103
  pendingQuitConfirm: boolean;
89
104
  }
@@ -169,9 +184,35 @@ export async function runTui(app: AppContext): Promise<void> {
169
184
  tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
170
185
  peekTarget: null,
171
186
  peekProcTarget: null,
187
+ peekProcAgent: null,
172
188
  pendingQuitConfirm: false,
173
189
  };
174
190
 
191
+ /** Active ops alerts (quarantine klaxon, refusal streaks, …), keyed
192
+ * `${agent}:${kind}`. Drives the status-bar ⚠ segment; each firing also
193
+ * prints a chat line so the alert exists in scrollback. */
194
+ const opsAlerts = new Map<string, OpsAlertEntry>();
195
+
196
+ function handleOpsAlert(kind: string, agent: string, message: string): void {
197
+ // All-clears travel as a distinct `<kind>-clear` kind (the alarm kind's
198
+ // ops cooldown must never swallow the stand-down). Remove the base alert
199
+ // and announce the recovery instead of adding a row.
200
+ if (kind.endsWith('-clear')) {
201
+ const baseKey = `${agent}:${kind.slice(0, -'-clear'.length)}`;
202
+ if (opsAlerts.delete(baseKey)) {
203
+ addLine(`✓ [${agent}] ${kind}: ${message}`, CYAN);
204
+ updateStatus();
205
+ }
206
+ return;
207
+ }
208
+ const key = `${agent}:${kind}`;
209
+ const existing = opsAlerts.get(key);
210
+ opsAlerts.set(key, { kind, agent, message, count: (existing?.count ?? 0) + 1 });
211
+ const times = existing ? ` (×${existing.count + 1})` : '';
212
+ addLine(`⚠ [${agent}] ${kind}${times}: ${message}`, RED);
213
+ updateStatus();
214
+ }
215
+
175
216
  let streaming = false;
176
217
  let currentStreamText: TextRenderable | null = null;
177
218
  let backgrounded = false; // researcher pushed to background via Ctrl+B
@@ -370,7 +411,10 @@ export async function runTui(app: AppContext): Promise<void> {
370
411
  }
371
412
 
372
413
  function updateStatus() {
373
- statusLeft.content = formatStatusLeft(state, SPINNER[spinnerFrame], streamOutputTokens);
414
+ statusLeft.content = formatStatusLeft(state, SPINNER[spinnerFrame], streamOutputTokens, opsAlerts.size);
415
+ // An active ops alert repaints the whole left segment — a one-cell glyph
416
+ // in default gray is exactly the kind of signal that gets scrolled past.
417
+ statusLeft.fg = opsAlerts.size > 0 ? RED : GRAY;
374
418
  statusRight.content = formatTokens(state.tokens, verboseChat) + formatMemStats(getRootCM());
375
419
  }
376
420
 
@@ -849,9 +893,7 @@ export async function runTui(app: AppContext): Promise<void> {
849
893
  const fc = fleetMod?.getChildren().get(node.fleetChildName!);
850
894
  hints = fc?.status === 'ready' ? ' ⏎:fold p:peek Del:stop' : ' ⏎:fold';
851
895
  } else if (node.kind === 'fleet-child-agent') {
852
- // Per-agent peek isn't separately supported yet; peek opens the
853
- // owning child process's stream, which still contains this agent's events.
854
- hints = ' ⏎:fold p:peek-proc';
896
+ hints = ' ⏎:fold p:peek';
855
897
  } else {
856
898
  hints = ' ⏎:fold';
857
899
  }
@@ -976,12 +1018,36 @@ export async function runTui(app: AppContext): Promise<void> {
976
1018
  const name = state.peekProcTarget;
977
1019
  if (!name || !fleetMod) return;
978
1020
  const child = fleetMod.getChildren().get(name);
1021
+ const agentFilter = state.peekProcAgent ?? undefined;
1022
+ const key = procLogKey(name, agentFilter);
979
1023
 
980
1024
  const lines: FleetLine[] = [];
981
- lines.push({ text: `─── Peek proc: ${name} ──────────────── Esc:back ───`, color: GRAY });
1025
+ const title = agentFilter ? `Peek: ${name} ${agentFilter}` : `Peek proc: ${name}`;
1026
+ lines.push({ text: `─── ${title} ──────────────── Esc:back ───`, color: GRAY });
982
1027
  lines.push({ text: '', color: GRAY });
983
1028
 
984
- if (child) {
1029
+ if (child && agentFilter) {
1030
+ // Per-agent header: the reducer node carries the honest phase/tokens/
1031
+ // task for this one agent inside the child.
1032
+ const rn = treeAggregator?.getChildNodes(name).find(n => n.name === agentFilter);
1033
+ if (rn) {
1034
+ const elapsed = rn.startedAt ? Math.floor(((rn.completedAt ?? Date.now()) - rn.startedAt) / 1000) : 0;
1035
+ const phaseColor = rn.status === 'failed' ? RED
1036
+ : rn.phase === 'idle' || rn.phase === 'done' || rn.phase === 'cancelled' ? DIM_GRAY
1037
+ : PHASE_COLOR[rn.phase as SubagentPhase] ?? CYAN;
1038
+ const ctx = rn.tokens.input > 0 ? ` ${fmtK(rn.tokens.input)}ctx` : '';
1039
+ lines.push({ text: ` ${rn.phase} ${elapsed}s ${rn.toolCallsCount} tool calls${ctx}`, color: phaseColor });
1040
+ if (rn.task) {
1041
+ const task = rn.task.length > 70 ? rn.task.slice(0, 67) + '...' : rn.task;
1042
+ lines.push({ text: ` task: ${task}`, color: GRAY });
1043
+ }
1044
+ if (rn.parent) {
1045
+ lines.push({ text: ` parent: ${rn.parent} (in ${name})`, color: DIM_GRAY });
1046
+ }
1047
+ } else {
1048
+ lines.push({ text: ` (agent not currently tracked in ${name} — events stream as they arrive)`, color: DIM_GRAY });
1049
+ }
1050
+ } else if (child) {
985
1051
  const elapsed = Math.floor((Date.now() - child.startedAt) / 1000);
986
1052
  const min = Math.floor(elapsed / 60);
987
1053
  const sec = elapsed % 60;
@@ -1002,7 +1068,7 @@ export async function runTui(app: AppContext): Promise<void> {
1002
1068
 
1003
1069
  lines.push({ text: '', color: GRAY });
1004
1070
 
1005
- const log = procPeekLogs.get(name);
1071
+ const log = procPeekLogs.get(key);
1006
1072
  if (log && log.length > 0) {
1007
1073
  const maxLines = Math.max(10, renderer.terminalHeight - 10);
1008
1074
  const tail = log.slice(-maxLines);
@@ -1013,11 +1079,13 @@ export async function runTui(app: AppContext): Promise<void> {
1013
1079
  lines.push({ text: ` ${entry.text}`, color: entry.color });
1014
1080
  }
1015
1081
  } else {
1016
- lines.push({ text: ' (no events yet — child may be idle)', color: DIM_GRAY });
1082
+ lines.push({ text: agentFilter
1083
+ ? ' (no events from this agent yet)'
1084
+ : ' (no events yet — child may be idle)', color: DIM_GRAY });
1017
1085
  }
1018
1086
 
1019
1087
  // In-progress token line (not yet flushed to log) — shows live streaming output.
1020
- const pending = procPeekTokenLine.get(name);
1088
+ const pending = procPeekTokenLine.get(key);
1021
1089
  if (pending) {
1022
1090
  lines.push({ text: ` ${pending}`, color: WHITE });
1023
1091
  }
@@ -1032,19 +1100,32 @@ export async function runTui(app: AppContext): Promise<void> {
1032
1100
  }
1033
1101
  }
1034
1102
 
1035
- function enterPeekProc(name: string): void {
1103
+ /**
1104
+ * Peek a fleet child's live event stream. With `agentFilter` set, the view
1105
+ * narrows to that one agent inside the child — the honest per-agent peek
1106
+ * for fleet-child agents and their subagents (sub-subagents from the
1107
+ * parent's point of view). Events on the fleet IPC carry `agentName`
1108
+ * verbatim from the child's framework traces, so the filter sees exactly
1109
+ * what a local peek of that agent would.
1110
+ */
1111
+ function enterPeekProc(name: string, agentFilter?: string): void {
1036
1112
  if (!fleetMod) return;
1037
1113
  const child = fleetMod.getChildren().get(name);
1038
1114
  if (!child) return;
1039
1115
 
1040
1116
  state.peekProcTarget = name;
1117
+ state.peekProcAgent = agentFilter ?? null;
1118
+ const key = procLogKey(name, agentFilter);
1119
+ const matches = (evt: WireEvent): boolean =>
1120
+ !agentFilter || (evt as { agentName?: string }).agentName === agentFilter;
1041
1121
 
1042
1122
  // Seed the log from the child's existing event buffer so the user
1043
1123
  // doesn't have to wait for the next event to see history.
1044
- if (!procPeekLogs.has(name)) procPeekLogs.set(name, []);
1045
- const log = procPeekLogs.get(name)!;
1124
+ if (!procPeekLogs.has(key)) procPeekLogs.set(key, []);
1125
+ const log = procPeekLogs.get(key)!;
1046
1126
  if (log.length === 0) {
1047
1127
  for (const evt of child.events) {
1128
+ if (!matches(evt)) continue;
1048
1129
  const formatted = formatWireEvent(evt);
1049
1130
  if (formatted) log.push(formatted);
1050
1131
  }
@@ -1055,39 +1136,39 @@ export async function runTui(app: AppContext): Promise<void> {
1055
1136
  // than one log entry per token.
1056
1137
  if (procPeekUnsub) { procPeekUnsub(); procPeekUnsub = null; }
1057
1138
  procPeekUnsub = fleetMod.onChildEvent(name, (_childName, evt) => {
1139
+ if (!matches(evt)) return;
1058
1140
  const type = typeof evt.type === 'string' ? evt.type : '';
1141
+ const active = state.viewMode === 'peek-proc'
1142
+ && state.peekProcTarget === name
1143
+ && state.peekProcAgent === (agentFilter ?? null);
1059
1144
 
1060
1145
  if (type === 'inference:tokens') {
1061
1146
  const content = (evt as { content?: string }).content ?? '';
1062
1147
  if (!content) return;
1063
- const prev = procPeekTokenLine.get(name) ?? '';
1148
+ const prev = procPeekTokenLine.get(key) ?? '';
1064
1149
  const merged = prev + content;
1065
1150
  const parts = merged.split('\n');
1066
1151
  // Flush completed lines (everything except the last segment).
1067
1152
  for (let i = 0; i < parts.length - 1; i++) {
1068
- if (parts[i]!.trim()) appendProcPeekLog(name, parts[i]!, WHITE);
1069
- }
1070
- procPeekTokenLine.set(name, parts[parts.length - 1]!);
1071
- if (state.viewMode === 'peek-proc' && state.peekProcTarget === name) {
1072
- updatePeekProcView();
1153
+ if (parts[i]!.trim()) appendProcPeekLog(key, parts[i]!, WHITE);
1073
1154
  }
1155
+ procPeekTokenLine.set(key, parts[parts.length - 1]!);
1156
+ if (active) updatePeekProcView();
1074
1157
  return;
1075
1158
  }
1076
1159
 
1077
1160
  // Non-token event: flush any pending token line first so its text
1078
1161
  // doesn't get visually chopped by subsequent log entries.
1079
- const pending = procPeekTokenLine.get(name);
1162
+ const pending = procPeekTokenLine.get(key);
1080
1163
  if (pending?.trim()) {
1081
- appendProcPeekLog(name, pending, WHITE);
1164
+ appendProcPeekLog(key, pending, WHITE);
1082
1165
  }
1083
- procPeekTokenLine.delete(name);
1166
+ procPeekTokenLine.delete(key);
1084
1167
 
1085
1168
  const formatted = formatWireEvent(evt);
1086
1169
  if (!formatted) return;
1087
- appendProcPeekLog(name, formatted.text, formatted.color);
1088
- if (state.viewMode === 'peek-proc' && state.peekProcTarget === name) {
1089
- updatePeekProcView();
1090
- }
1170
+ appendProcPeekLog(key, formatted.text, formatted.color);
1171
+ if (active) updatePeekProcView();
1091
1172
  });
1092
1173
 
1093
1174
  switchView('peek-proc');
@@ -1098,13 +1179,15 @@ export async function runTui(app: AppContext): Promise<void> {
1098
1179
  // Flush any in-progress token line into the log before clearing it so
1099
1180
  // re-entering peek-proc doesn't lose the last few tokens mid-stream.
1100
1181
  if (state.peekProcTarget) {
1101
- const pending = procPeekTokenLine.get(state.peekProcTarget);
1182
+ const key = procLogKey(state.peekProcTarget, state.peekProcAgent ?? undefined);
1183
+ const pending = procPeekTokenLine.get(key);
1102
1184
  if (pending?.trim()) {
1103
- appendProcPeekLog(state.peekProcTarget, pending, WHITE);
1185
+ appendProcPeekLog(key, pending, WHITE);
1104
1186
  }
1105
- procPeekTokenLine.delete(state.peekProcTarget);
1187
+ procPeekTokenLine.delete(key);
1106
1188
  }
1107
1189
  state.peekProcTarget = null;
1190
+ state.peekProcAgent = null;
1108
1191
  }
1109
1192
 
1110
1193
  // ── Peek view ────────────────────────────────────────────────────────
@@ -1353,10 +1436,20 @@ export async function runTui(app: AppContext): Promise<void> {
1353
1436
  state.tokens.output = totals.outputTokens;
1354
1437
  state.tokens.cacheRead = totals.cacheReadTokens;
1355
1438
  state.tokens.cacheWrite = totals.cacheCreationTokens;
1439
+ if (totals.estimatedCost) state.tokens.cost = totals.estimatedCost;
1356
1440
  updateStatus();
1357
1441
  break;
1358
1442
  }
1359
1443
 
1444
+ case 'ops:alert': {
1445
+ handleOpsAlert(
1446
+ typeof event.kind === 'string' ? event.kind : 'unknown',
1447
+ typeof event.agentName === 'string' ? event.agentName : '?',
1448
+ typeof event.message === 'string' ? event.message : '',
1449
+ );
1450
+ break;
1451
+ }
1452
+
1360
1453
  case 'inference:failed': {
1361
1454
  if (agent === rootAgentName) {
1362
1455
  state.status = 'error';
@@ -1529,8 +1622,33 @@ export async function runTui(app: AppContext): Promise<void> {
1529
1622
  });
1530
1623
  }
1531
1624
 
1625
+ // Fleet-child ops alerts: a quarantine klaxon or refusal streak inside a
1626
+ // child process must be as loud here as a local one — the operator is
1627
+ // looking at THIS terminal, not the child's log. Default subscription is
1628
+ // '*' so ops:alert events flow over the IPC with agentName intact.
1629
+ // Re-bound on session switch alongside fleetMod itself.
1630
+ let fleetOpsUnsub: (() => void) | null = null;
1631
+ function subscribeFleetOps(): void {
1632
+ fleetOpsUnsub?.();
1633
+ fleetOpsUnsub = fleetMod?.onChildEvent('*', (childName, evt) => {
1634
+ if (evt.type !== 'ops:alert') return;
1635
+ const e = evt as Record<string, unknown>;
1636
+ handleOpsAlert(
1637
+ typeof e.kind === 'string' ? e.kind : 'unknown',
1638
+ `${childName}/${typeof e.agentName === 'string' ? e.agentName : '?'}`,
1639
+ typeof e.message === 'string' ? e.message : '',
1640
+ );
1641
+ }) ?? null;
1642
+ }
1643
+ subscribeFleetOps();
1644
+
1532
1645
  // Per-child event log (analogue of peekLogs but for child processes).
1646
+ // Keys come from procLogKey: bare child name for the whole-process stream,
1647
+ // `child#agent` for an agent-filtered stream — the two never mix.
1533
1648
  const procPeekLogs = new Map<string, FleetLine[]>();
1649
+ function procLogKey(child: string, agentFilter?: string): string {
1650
+ return agentFilter ? `${child}#${agentFilter}` : child;
1651
+ }
1534
1652
  // In-progress token line buffer per child (flushed to log on newline / next event).
1535
1653
  const procPeekTokenLine = new Map<string, string>();
1536
1654
  let procPeekUnsub: (() => void) | null = null;
@@ -1847,12 +1965,11 @@ export async function runTui(app: AppContext): Promise<void> {
1847
1965
  // Dispatch by node kind, not string-prefix surgery on the ID.
1848
1966
  if (node.kind === 'fleet-child') {
1849
1967
  enterPeekProc(node.fleetChildName!);
1850
- switchView('peek-proc');
1851
1968
  } else if (node.kind === 'fleet-child-agent') {
1852
- // Per-agent peek isn't separately supported yet; peek the
1853
- // owning process its event stream still includes this agent.
1854
- enterPeekProc(node.fleetChildName!);
1855
- switchView('peek-proc');
1969
+ // Honest per-agent peek: the child's event stream filtered to
1970
+ // this one agent (works for the child's root agent and for its
1971
+ // subagents — sub-subagents from where we stand).
1972
+ enterPeekProc(node.fleetChildName!, node.fullName);
1856
1973
  } else {
1857
1974
  // node.kind === 'subagent' — local in-process peek.
1858
1975
  enterPeek(nodeId!);
@@ -1985,12 +2102,18 @@ export async function runTui(app: AppContext): Promise<void> {
1985
2102
  // Rebind to new framework
1986
2103
  subMod = app.framework.getAllModules().find(m => m.name === 'subagent') as SubagentModule | undefined;
1987
2104
  fleetMod = app.framework.getAllModules().find(m => m.name === 'fleet') as FleetModule | undefined;
2105
+ subscribeFleetOps();
1988
2106
  app.framework.onTrace(onTrace as (e: unknown) => void);
1989
2107
 
1990
2108
  const session = app.sessionManager.getActiveSession();
1991
2109
  refreshFromStore();
1992
2110
  addLine(`Session: ${session?.name ?? 'unknown'}`, GRAY);
1993
2111
  state.tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
2112
+ // Alerts describe the OLD session's strategy/agents; the new
2113
+ // session's own klaxons re-fire within their alarm interval if the
2114
+ // condition still holds there. A stale alert with no reachable
2115
+ // all-clear would otherwise pin the status bar red forever.
2116
+ opsAlerts.clear();
1994
2117
  updateStatus();
1995
2118
  }).catch(err => {
1996
2119
  addLine(`Session switch failed: ${err}`, RED);
@@ -2061,6 +2184,7 @@ export async function runTui(app: AppContext): Promise<void> {
2061
2184
  function cleanup() {
2062
2185
  cleanupPeek();
2063
2186
  cleanupPeekProc();
2187
+ fleetOpsUnsub?.();
2064
2188
  treeAggregator?.dispose();
2065
2189
  for (const unsub of subagentStreamUnsubs) unsub();
2066
2190
  clearInterval(pollTimer);
@@ -2090,9 +2214,11 @@ function formatStatusLeft(
2090
2214
  state: TuiState,
2091
2215
  spinnerChar?: string,
2092
2216
  outputTokens?: number,
2217
+ alertCount = 0,
2093
2218
  ): string {
2094
2219
  const sColor = state.status === 'idle' ? '✓' : state.status === 'error' ? '✗' : state.status === 'background' ? '↓' : state.status === 'queued' ? '⏳' : '…';
2095
2220
  let bar = `[${sColor} ${state.status}`;
2221
+ if (alertCount > 0) bar += ` | ⚠ ${alertCount} alert${alertCount === 1 ? '' : 's'}`;
2096
2222
  if (spinnerChar !== undefined && state.status !== 'idle' && state.status !== 'error' && state.status !== 'background') {
2097
2223
  bar += ` ${spinnerChar}`;
2098
2224
  if (state.status === 'thinking' && outputTokens !== undefined && outputTokens > 0) {
@@ -2108,7 +2234,9 @@ function formatStatusLeft(
2108
2234
  if (state.viewMode === 'fleet' || state.viewMode === 'peek') {
2109
2235
  bar += state.viewMode === 'peek' ? ` | peek: ${state.peekTarget}` : ' | fleet view';
2110
2236
  } else if (state.viewMode === 'peek-proc') {
2111
- bar += ` | peek-proc: ${state.peekProcTarget}`;
2237
+ bar += state.peekProcAgent
2238
+ ? ` | peek: ${state.peekProcTarget}›${state.peekProcAgent}`
2239
+ : ` | peek-proc: ${state.peekProcTarget}`;
2112
2240
  } else if (state.viewMode === 'chat') {
2113
2241
  if (state.status === 'background') bar += ' Esc:stop';
2114
2242
  else if (state.status !== 'idle' && state.status !== 'error') bar += ' Ctrl+B:bg Esc:stop';
@@ -2126,6 +2254,9 @@ function formatTokens(tokens: TokenUsage, verbose: boolean): string {
2126
2254
  let s = `${fmtTokens(tokens.input)}in ${fmtTokens(tokens.output)}out`;
2127
2255
  if (tokens.cacheRead > 0) s += ` ${fmtTokens(tokens.cacheRead)}hit`;
2128
2256
  if (tokens.cacheWrite > 0) s += ` ${fmtTokens(tokens.cacheWrite)}write`;
2257
+ if (tokens.cost && tokens.cost.total > 0) {
2258
+ s += ` $${tokens.cost.total.toFixed(tokens.cost.total < 1 ? 3 : 2)}`;
2259
+ }
2129
2260
  parts.push(s);
2130
2261
  }
2131
2262
 
@@ -280,6 +280,31 @@ export interface BranchChangedMessage {
280
280
  branch: { id: string; name: string };
281
281
  }
282
282
 
283
+ /** One Chronicle branch with its lineage. `parentId`/`branchPoint` are
284
+ * undefined for the root branch; together they place the fork in the
285
+ * parent's sequence so the SPA can render a lineage tree. */
286
+ export interface BranchRow {
287
+ id: string;
288
+ name: string;
289
+ /** Head sequence number of the branch. */
290
+ head: number;
291
+ parentId?: string;
292
+ /** Sequence in the PARENT at which this branch forked. */
293
+ branchPoint?: number;
294
+ /** Epoch millis creation time. */
295
+ created: number;
296
+ }
297
+
298
+ /** Full branch listing with lineage — response to `request-branches`, routed
299
+ * only to the requesting client. The SPA refreshes it after every
300
+ * `branch-changed` while its branch panel is open. */
301
+ export interface BranchesListMessage {
302
+ type: 'branches-list';
303
+ branches: BranchRow[];
304
+ /** id of the branch the agent is currently on. */
305
+ currentId: string;
306
+ }
307
+
283
308
  /** Sent when the active session changes. Clients should soft-reconnect. */
284
309
  export interface SessionChangedMessage {
285
310
  type: 'session-changed';
@@ -442,6 +467,7 @@ export type WebUiServerMessage =
442
467
  | UsageMessage
443
468
  | CallLedgerMessage
444
469
  | BranchChangedMessage
470
+ | BranchesListMessage
445
471
  | SessionChangedMessage
446
472
  | PeekMessage
447
473
  | InboundTriggerMessage
@@ -576,6 +602,13 @@ export interface RequestMcplMessage {
576
602
  type: 'request-mcpl';
577
603
  }
578
604
 
605
+ /** Pull the Chronicle branch listing. Response is a `branches-list` envelope
606
+ * routed only to the requesting client. Read-only; switching branches goes
607
+ * through the `/checkout` command (full-auth clients only). */
608
+ export interface RequestBranchesMessage {
609
+ type: 'request-branches';
610
+ }
611
+
579
612
  /** Add or overwrite an MCPL server entry in mcpl-servers.json. Restart is
580
613
  * required for the host to pick up the change; the response is a fresh
581
614
  * `mcpl-list` so the SPA reflects the new state. */
@@ -669,6 +702,7 @@ export type WebUiClientMessage =
669
702
  | QuitConfirmMessage
670
703
  | RequestLessonsMessage
671
704
  | RequestMcplMessage
705
+ | RequestBranchesMessage
672
706
  | McplAddMessage
673
707
  | McplRemoveMessage
674
708
  | McplSetEnvMessage
@@ -696,6 +730,7 @@ export function isClientMessage(value: unknown): value is WebUiClientMessage {
696
730
  case 'ping':
697
731
  case 'interrupt':
698
732
  case 'request-mcpl':
733
+ case 'request-branches':
699
734
  return true;
700
735
  case 'user-message':
701
736
  return typeof v.content === 'string';
@@ -0,0 +1,226 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import type { ProviderRequest } from '@animalabs/membrane';
3
+ import {
4
+ CodexSubscriptionAdapter,
5
+ type CodexAuthProvider,
6
+ } from '../src/codex-subscription-adapter.js';
7
+
8
+ const originalFetch = globalThis.fetch;
9
+
10
+ afterEach(() => {
11
+ globalThis.fetch = originalFetch;
12
+ });
13
+
14
+ function request(extra: Record<string, unknown> = {}): ProviderRequest {
15
+ return {
16
+ model: 'gpt-5.4',
17
+ messages: [{ type: 'message', role: 'user', content: 'Hello' }],
18
+ maxTokens: 8192,
19
+ temperature: 0.2,
20
+ topP: 0.9,
21
+ topK: 20,
22
+ extra,
23
+ };
24
+ }
25
+
26
+ function completedResponse(): Response {
27
+ const item = {
28
+ type: 'message',
29
+ id: 'msg_test',
30
+ role: 'assistant',
31
+ content: [{ type: 'output_text', text: 'Hello back' }],
32
+ };
33
+ const events = [
34
+ { type: 'response.output_item.done', output_index: 0, item },
35
+ {
36
+ type: 'response.completed',
37
+ response: {
38
+ id: 'resp_test',
39
+ model: 'gpt-5.4',
40
+ status: 'completed',
41
+ output: [],
42
+ usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 },
43
+ },
44
+ },
45
+ ];
46
+ return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''), {
47
+ headers: { 'Content-Type': 'text/event-stream' },
48
+ });
49
+ }
50
+
51
+ describe('CodexSubscriptionAdapter', () => {
52
+ test('uses the subscription endpoint and enables Fast mode per request', async () => {
53
+ const requests: Array<{ url: string; headers: Headers; body: Record<string, unknown> }> = [];
54
+ globalThis.fetch = async (input, init) => {
55
+ requests.push({
56
+ url: String(input),
57
+ headers: new Headers(init?.headers),
58
+ body: JSON.parse(String(init?.body)),
59
+ });
60
+ return completedResponse();
61
+ };
62
+ const auth: CodexAuthProvider = {
63
+ getAccessToken: async () => 'subscription-token',
64
+ getAccountId: () => 'account-test',
65
+ };
66
+ const adapter = new CodexSubscriptionAdapter({
67
+ authProvider: auth,
68
+ baseURL: 'https://example.test/backend-api/codex/',
69
+ fastMode: true,
70
+ });
71
+
72
+ const response = await adapter.complete(request({ max_output_tokens: 999 }));
73
+
74
+ expect(response.stopReason).toBe('end_turn');
75
+ expect((response.content as Array<{ text?: string }>)[0]?.text).toBe('Hello back');
76
+ expect(requests).toHaveLength(1);
77
+ expect(requests[0]?.url).toBe('https://example.test/backend-api/codex/responses');
78
+ expect(requests[0]?.headers.get('authorization')).toBe('Bearer subscription-token');
79
+ expect(requests[0]?.headers.get('chatgpt-account-id')).toBe('account-test');
80
+ expect(requests[0]?.body.service_tier).toBe('priority');
81
+ expect(requests[0]?.body.max_output_tokens).toBeUndefined();
82
+ expect(requests[0]?.body.temperature).toBeUndefined();
83
+ expect(requests[0]?.body.top_p).toBeUndefined();
84
+ expect(requests[0]?.body.input).toEqual([{
85
+ type: 'message',
86
+ role: 'user',
87
+ content: [{ type: 'input_text', text: 'Hello' }],
88
+ }]);
89
+ });
90
+
91
+ test('normalizes maintenance text, images, and tool blocks at the transport boundary', async () => {
92
+ let body: Record<string, any> = {};
93
+ globalThis.fetch = async (_input, init) => {
94
+ body = JSON.parse(String(init?.body));
95
+ return completedResponse();
96
+ };
97
+ const adapter = new CodexSubscriptionAdapter({
98
+ authProvider: { getAccessToken: async () => 'subscription-token' },
99
+ baseURL: 'https://example.test/codex',
100
+ });
101
+
102
+ await adapter.complete({
103
+ model: 'gpt-5.4',
104
+ messages: [
105
+ {
106
+ type: 'message', role: 'user', content: [
107
+ { type: 'text', text: 'inspect' },
108
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'aGVsbG8=' } },
109
+ ],
110
+ },
111
+ {
112
+ type: 'message', role: 'assistant', content: [
113
+ { type: 'tool_use', id: 'call_1', name: 'lookup', input: { q: 'x' } },
114
+ ],
115
+ },
116
+ {
117
+ type: 'message', role: 'user', content: [
118
+ { type: 'tool_result', tool_use_id: 'call_1', content: 'found' },
119
+ ],
120
+ },
121
+ ] as any,
122
+ maxTokens: 1024,
123
+ });
124
+
125
+ expect(body.input).toEqual([
126
+ {
127
+ type: 'message', role: 'user', content: [
128
+ { type: 'input_text', text: 'inspect' },
129
+ { type: 'input_image', image_url: 'data:image/png;base64,aGVsbG8=' },
130
+ ],
131
+ },
132
+ { type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{"q":"x"}' },
133
+ { type: 'function_call_output', call_id: 'call_1', output: 'found' },
134
+ ]);
135
+ });
136
+
137
+ test('turns Fast mode off without reconstructing the adapter', async () => {
138
+ const bodies: Record<string, unknown>[] = [];
139
+ globalThis.fetch = async (_input, init) => {
140
+ bodies.push(JSON.parse(String(init?.body)));
141
+ return completedResponse();
142
+ };
143
+ const adapter = new CodexSubscriptionAdapter({
144
+ authProvider: { getAccessToken: async () => 'subscription-token' },
145
+ baseURL: 'https://example.test/codex',
146
+ fastMode: true,
147
+ });
148
+
149
+ await adapter.complete(request());
150
+ adapter.setFastMode(false);
151
+ await adapter.complete(request({ service_tier: 'priority' }));
152
+
153
+ expect(bodies[0]?.service_tier).toBe('priority');
154
+ expect(bodies[1]?.service_tier).toBeUndefined();
155
+ });
156
+
157
+ test('refreshes the ChatGPT token once after a 401', async () => {
158
+ const refreshFlags: boolean[] = [];
159
+ let calls = 0;
160
+ globalThis.fetch = async () => {
161
+ calls += 1;
162
+ if (calls === 1) return new Response('expired', { status: 401 });
163
+ return completedResponse();
164
+ };
165
+ const adapter = new CodexSubscriptionAdapter({
166
+ authProvider: {
167
+ getAccessToken: async (forceRefresh = false) => {
168
+ refreshFlags.push(forceRefresh);
169
+ return forceRefresh ? 'fresh-token' : 'expired-token';
170
+ },
171
+ },
172
+ baseURL: 'https://example.test/codex',
173
+ });
174
+
175
+ await adapter.complete(request());
176
+
177
+ expect(calls).toBe(2);
178
+ expect(refreshFlags).toEqual([false, true]);
179
+ });
180
+
181
+ test('reconstructs tool calls when the terminal event has an empty output', async () => {
182
+ const item = {
183
+ type: 'function_call',
184
+ id: 'fc_test',
185
+ call_id: 'call_test',
186
+ name: 'lookup',
187
+ arguments: '{"query":"connectome"}',
188
+ };
189
+ globalThis.fetch = async () => new Response([
190
+ `data: ${JSON.stringify({ type: 'response.output_item.done', output_index: 0, item })}\n\n`,
191
+ `data: ${JSON.stringify({
192
+ type: 'response.completed',
193
+ response: {
194
+ model: 'gpt-5.4', status: 'completed', output: [],
195
+ usage: { input_tokens: 2, output_tokens: 3 },
196
+ },
197
+ })}\n\n`,
198
+ ].join(''), { headers: { 'Content-Type': 'text/event-stream' } });
199
+ const adapter = new CodexSubscriptionAdapter({
200
+ authProvider: { getAccessToken: async () => 'subscription-token' },
201
+ baseURL: 'https://example.test/codex',
202
+ });
203
+
204
+ const response = await adapter.complete(request());
205
+
206
+ expect(response.stopReason).toBe('tool_use');
207
+ expect(response.content).toEqual([expect.objectContaining({
208
+ type: 'tool_use', id: 'call_test', name: 'lookup', input: { query: 'connectome' },
209
+ })]);
210
+ });
211
+
212
+ test('surfaces nested SSE error details', async () => {
213
+ globalThis.fetch = async () => new Response(
214
+ 'data: {"type":"error","error":{"type":"invalid_request_error","code":"context_length_exceeded","message":"input is too large"}}\n\n',
215
+ { headers: { 'Content-Type': 'text/event-stream' } },
216
+ );
217
+ const adapter = new CodexSubscriptionAdapter({
218
+ authProvider: { getAccessToken: async () => 'subscription-token' },
219
+ baseURL: 'https://example.test/codex',
220
+ });
221
+
222
+ await expect(adapter.complete(request())).rejects.toThrow(
223
+ /context_length_exceeded.*input is too large/,
224
+ );
225
+ });
226
+ });