@animalabs/connectome-host 0.3.9 → 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/.env.example +7 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
- package/.github/workflows/changelog.yml +32 -0
- package/.github/workflows/publish.yml +46 -0
- package/CHANGELOG.md +59 -0
- package/CONTRIBUTING.md +126 -0
- package/README.md +52 -5
- package/package.json +2 -1
- package/scripts/release-changelog.ts +32 -0
- package/src/codex-subscription-adapter.ts +938 -0
- package/src/commands.ts +45 -0
- package/src/extensions.ts +201 -0
- package/src/framework-agent-config.ts +17 -9
- package/src/framework-strategy.ts +21 -0
- package/src/index.ts +102 -19
- package/src/logging-bedrock-adapter.ts +145 -0
- package/src/modules/web-ui-module.ts +45 -1
- package/src/recipe.ts +138 -9
- package/src/tui.ts +165 -34
- package/src/web/protocol.ts +35 -0
- package/test/codex-subscription-adapter.test.ts +226 -0
- package/test/fast-command.test.ts +39 -0
- package/test/recipe-extensions.test.ts +295 -0
- package/test/recipe-provider.test.ts +14 -1
- package/test/web-ui-observers.test.ts +14 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/web/src/App.tsx +235 -20
- package/web/src/Branches.tsx +150 -0
- package/web/src/Context.tsx +21 -13
- package/web/src/Health.tsx +274 -0
- package/web/src/Lessons.tsx +2 -1
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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(
|
|
1045
|
-
const log = procPeekLogs.get(
|
|
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(
|
|
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(
|
|
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(
|
|
1162
|
+
const pending = procPeekTokenLine.get(key);
|
|
1080
1163
|
if (pending?.trim()) {
|
|
1081
|
-
appendProcPeekLog(
|
|
1164
|
+
appendProcPeekLog(key, pending, WHITE);
|
|
1082
1165
|
}
|
|
1083
|
-
procPeekTokenLine.delete(
|
|
1166
|
+
procPeekTokenLine.delete(key);
|
|
1084
1167
|
|
|
1085
1168
|
const formatted = formatWireEvent(evt);
|
|
1086
1169
|
if (!formatted) return;
|
|
1087
|
-
appendProcPeekLog(
|
|
1088
|
-
if (
|
|
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
|
|
1182
|
+
const key = procLogKey(state.peekProcTarget, state.peekProcAgent ?? undefined);
|
|
1183
|
+
const pending = procPeekTokenLine.get(key);
|
|
1102
1184
|
if (pending?.trim()) {
|
|
1103
|
-
appendProcPeekLog(
|
|
1185
|
+
appendProcPeekLog(key, pending, WHITE);
|
|
1104
1186
|
}
|
|
1105
|
-
procPeekTokenLine.delete(
|
|
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
|
-
//
|
|
1853
|
-
//
|
|
1854
|
-
|
|
1855
|
-
|
|
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 +=
|
|
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
|
|
package/src/web/protocol.ts
CHANGED
|
@@ -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
|
+
});
|