@animalabs/connectome-host 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +32 -0
|
@@ -0,0 +1,2595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebUiModule — serves a single-page web admin UI plus a JSON-over-WebSocket
|
|
3
|
+
* control plane. Mirrors what TuiModule provides for the terminal: a way to
|
|
4
|
+
* see the conversation, the agent tree, and to issue user messages and slash
|
|
5
|
+
* commands. Designed for remote admin over a VPN, fronted by a reverse proxy
|
|
6
|
+
* for TLS and outer auth.
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle:
|
|
9
|
+
* - Module's `start()` opens a Bun.serve HTTP+WS server.
|
|
10
|
+
* - `setApp()` (called from index.ts after framework creation) plugs in the
|
|
11
|
+
* full AppContext so slash commands, sessions, and branch state work.
|
|
12
|
+
* - `start()` is intentionally tolerant of `setApp` being called late: WS
|
|
13
|
+
* clients that connect before app-binding are parked until binding lands.
|
|
14
|
+
*
|
|
15
|
+
* Decoupled transport: the module speaks plain HTTP. TLS / external auth /
|
|
16
|
+
* fan-out across many VMs are the reverse-proxy's job; an optional Basic-Auth
|
|
17
|
+
* check is available as defense-in-depth.
|
|
18
|
+
*
|
|
19
|
+
* See WEBUI-PLAN.md and src/web/protocol.ts for the wire shape.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { CURVE_PAGE_HTML } from './web-ui-curve-page.js';
|
|
23
|
+
import type {
|
|
24
|
+
AgentFramework,
|
|
25
|
+
Module,
|
|
26
|
+
ModuleContext,
|
|
27
|
+
ProcessEvent,
|
|
28
|
+
ProcessState,
|
|
29
|
+
EventResponse,
|
|
30
|
+
ToolDefinition,
|
|
31
|
+
ToolCall,
|
|
32
|
+
ToolResult,
|
|
33
|
+
TraceEvent,
|
|
34
|
+
SessionUsageSnapshot,
|
|
35
|
+
} from '@animalabs/agent-framework';
|
|
36
|
+
import type { ServerWebSocket } from 'bun';
|
|
37
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
38
|
+
import { join, resolve, normalize, dirname, sep as pathSep } from 'node:path';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
41
|
+
import type { Recipe } from '../recipe.js';
|
|
42
|
+
import type { SessionManager } from '../session-manager.js';
|
|
43
|
+
import type { BranchState } from '../commands.js';
|
|
44
|
+
import { handleCommand } from '../commands.js';
|
|
45
|
+
import { AgentTreeReducer, type AgentTreeSnapshot } from '../state/agent-tree-reducer.js';
|
|
46
|
+
import { FleetTreeAggregator } from '../state/fleet-tree-aggregator.js';
|
|
47
|
+
import type { FleetModule } from './fleet-module.js';
|
|
48
|
+
import type { WireEvent } from './fleet-types.js';
|
|
49
|
+
import {
|
|
50
|
+
WEB_PROTOCOL_VERSION,
|
|
51
|
+
isClientMessage,
|
|
52
|
+
type WebUiServerMessage,
|
|
53
|
+
type WelcomeMessage,
|
|
54
|
+
type WelcomeMessageEntry,
|
|
55
|
+
type RequestHistoryMessage,
|
|
56
|
+
type TokenUsage,
|
|
57
|
+
type McplListMessage,
|
|
58
|
+
type LessonsListMessage,
|
|
59
|
+
} from '../web/protocol.js';
|
|
60
|
+
import {
|
|
61
|
+
readMcplServersFile,
|
|
62
|
+
saveMcplServers,
|
|
63
|
+
DEFAULT_CONFIG_PATH,
|
|
64
|
+
} from '../mcpl-config.js';
|
|
65
|
+
import { loadRecipe } from '../recipe.js';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Minimal slice of AppContext the module needs. Defined locally to avoid
|
|
69
|
+
* importing the full type from index.ts (which would create a cycle).
|
|
70
|
+
*/
|
|
71
|
+
export interface WebUiAppRef {
|
|
72
|
+
framework: AgentFramework;
|
|
73
|
+
sessionManager: SessionManager;
|
|
74
|
+
recipe: Recipe;
|
|
75
|
+
branchState: BranchState;
|
|
76
|
+
switchSession(id: string): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface WebUiModuleConfig {
|
|
80
|
+
/** TCP port to bind. Default: 7340. */
|
|
81
|
+
port?: number;
|
|
82
|
+
/**
|
|
83
|
+
* Host to bind. Default: 0.0.0.0 — connectome deployments are remote, not
|
|
84
|
+
* local. Any non-loopback bind (which includes the default) hard-requires
|
|
85
|
+
* `basicAuth`; the server refuses to start otherwise. Set explicitly to
|
|
86
|
+
* `127.0.0.1` for local development, which skips the auth requirement.
|
|
87
|
+
*/
|
|
88
|
+
host?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Basic-Auth credentials. Mandatory for any non-loopback bind (the default).
|
|
91
|
+
* Sourced from `${VAR}` substitution at recipe load time — never commit
|
|
92
|
+
* literal credentials to a recipe.
|
|
93
|
+
*/
|
|
94
|
+
basicAuth?: { username: string; password: string };
|
|
95
|
+
/** Path to the SPA build output. Default: `<cwd>/dist/web`. */
|
|
96
|
+
staticDir?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Origin allowlist for the WebSocket upgrade. Browsers do not enforce
|
|
99
|
+
* same-origin on `new WebSocket(...)` the way they do on fetch, so without
|
|
100
|
+
* an explicit Origin check any page the operator opens in another tab
|
|
101
|
+
* could connect to a localhost-bound /ws and drive the host. Default:
|
|
102
|
+
* `http://127.0.0.1:<port>`, `http://localhost:<port>`, plus the matching
|
|
103
|
+
* `https://` forms. Override when fronted by a reverse proxy that rewrites
|
|
104
|
+
* Origin (e.g. `["https://admin.example.com"]`).
|
|
105
|
+
*
|
|
106
|
+
* Set explicitly to `[]` to allow any Origin (or none) — only sensible
|
|
107
|
+
* when the host is behind a proxy that already enforces Origin or when
|
|
108
|
+
* the entire host is firewalled off from browsers.
|
|
109
|
+
*/
|
|
110
|
+
allowedOrigins?: string[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Per-connection state. */
|
|
114
|
+
interface ClientState {
|
|
115
|
+
/** Stable id matching ws.data.id; used for routing fleet IPC responses. */
|
|
116
|
+
id: number;
|
|
117
|
+
ws: ServerWebSocket<{ id: number }>;
|
|
118
|
+
/** True after we've sent the welcome message. */
|
|
119
|
+
welcomed: boolean;
|
|
120
|
+
/** Open peek subscriptions for this client, keyed by scope. Each entry
|
|
121
|
+
* carries its detacher so unsubscribe and disconnect both clean up
|
|
122
|
+
* without the framework leaking listeners. */
|
|
123
|
+
peeks: Map<string, () => void>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Default port — picked to be memorable and unlikely to collide. */
|
|
127
|
+
const DEFAULT_PORT = 7340;
|
|
128
|
+
|
|
129
|
+
/** Messages shipped in the welcome frame (the tail window). */
|
|
130
|
+
const WELCOME_HISTORY_LIMIT = 200;
|
|
131
|
+
/** Default / max page size for request-history. */
|
|
132
|
+
const HISTORY_PAGE_DEFAULT = 200;
|
|
133
|
+
const HISTORY_PAGE_MAX = 500;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Structural view of the windowed-read facade added to
|
|
137
|
+
* @animalabs/context-manager alongside this protocol version. Typed
|
|
138
|
+
* structurally (not imported) so the host keeps running against older
|
|
139
|
+
* context-manager copies on boxes — call sites feature-detect.
|
|
140
|
+
*/
|
|
141
|
+
interface WindowCapableCm {
|
|
142
|
+
getMessageCount(): number;
|
|
143
|
+
getMessageWindow(
|
|
144
|
+
offset: number,
|
|
145
|
+
limit: number,
|
|
146
|
+
opts?: { resolveBlobs?: boolean; alignToBodyGroups?: boolean },
|
|
147
|
+
): { messages: unknown[]; startIndex: number; totalCount: number };
|
|
148
|
+
onMessage?(listener: (event: { type: string; message?: unknown }) => void): () => void;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Default bind host. Connectome deployments are remote (none are local), so
|
|
153
|
+
* we bind all interfaces by default. This is a non-loopback bind, so it
|
|
154
|
+
* hard-requires `basicAuth` via `assertSafeBind` — the server refuses to
|
|
155
|
+
* start without credentials. Override with `host: '127.0.0.1'` for local dev.
|
|
156
|
+
*/
|
|
157
|
+
const DEFAULT_HOST = '0.0.0.0';
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Process-level singleton state. The HTTP server, WS clients, and accumulated
|
|
161
|
+
* usage snapshot must outlive any single framework instance — session-switch
|
|
162
|
+
* rebuilds the framework (and thus the WebUiModule), but the open WebSocket
|
|
163
|
+
* connections need to stay up. Module instances bind to the singleton on
|
|
164
|
+
* `start()` and rebind their AppContext on `setApp()`; the server itself
|
|
165
|
+
* never restarts within a process lifetime.
|
|
166
|
+
*/
|
|
167
|
+
interface SharedServerState {
|
|
168
|
+
server: ReturnType<typeof Bun.serve>;
|
|
169
|
+
port: number;
|
|
170
|
+
host: string;
|
|
171
|
+
staticRoot: string;
|
|
172
|
+
basicAuth?: { username: string; password: string };
|
|
173
|
+
/** Resolved origin allowlist. Empty array means "no Origin check". */
|
|
174
|
+
allowedOrigins: string[];
|
|
175
|
+
clients: Map<number, ClientState>;
|
|
176
|
+
nextClientId: number;
|
|
177
|
+
/** Aggregate session usage published to clients: parent's own totals plus
|
|
178
|
+
* the most-recent `usage:updated` totals reported by each fleet child.
|
|
179
|
+
* Recomputed from `parentUsage` + `childUsage` on every relevant event. */
|
|
180
|
+
latestUsage: TokenUsage;
|
|
181
|
+
/** Parent process' own session totals (from local `usage:updated`). Kept
|
|
182
|
+
* separately so child-side updates don't clobber the parent's number when
|
|
183
|
+
* recomputing the aggregate. */
|
|
184
|
+
parentUsage: TokenUsage;
|
|
185
|
+
/** Most-recent `usage:updated` totals per fleet child, keyed by childName.
|
|
186
|
+
* Each entry overwrites on the next event from that child — children's
|
|
187
|
+
* UsageTrackers already emit cumulative session totals, so summing across
|
|
188
|
+
* the map gives the fleet-wide total without double-counting rounds. */
|
|
189
|
+
childUsage: Map<string, TokenUsage>;
|
|
190
|
+
/** Per-agent cost breakdown captured alongside latestUsage. Re-derived on
|
|
191
|
+
* every usage:updated event so the welcome and live UsageMessage frames
|
|
192
|
+
* carry consistent values. */
|
|
193
|
+
latestPerAgentCost: import('../web/protocol.js').PerAgentCost[];
|
|
194
|
+
/** Currently-bound app, refreshed on every setApp() call. WS handlers read
|
|
195
|
+
* from here so the singleton always points at the live framework regardless
|
|
196
|
+
* of which WebUiModule instance is "active". */
|
|
197
|
+
app: WebUiAppRef | null;
|
|
198
|
+
/** Per-bind aggregator and fleet detacher. Re-created in setApp; cleared
|
|
199
|
+
* in stop(). Lives on the singleton so old WebUiModule instances don't
|
|
200
|
+
* retain handles to dead frameworks. */
|
|
201
|
+
treeAggregator: FleetTreeAggregator | null;
|
|
202
|
+
fleetEventDetacher: (() => void) | null;
|
|
203
|
+
/** Detacher for the message-store 'add' listener driving message-appended
|
|
204
|
+
* pushes. Re-installed on every setApp (fresh ContextManager per session)
|
|
205
|
+
* and cleared in stop(). */
|
|
206
|
+
messageListenerDetacher: (() => void) | null;
|
|
207
|
+
/** Cached child recipe summaries keyed by recipe path. Recipes are
|
|
208
|
+
* static-ish per host run (re-spawn doesn't change the file), so we
|
|
209
|
+
* parse once and reuse on every welcome. Cleared on session switch. */
|
|
210
|
+
childRecipeCache: Map<string, { name: string; description?: string; version?: string; agentModel?: string }>;
|
|
211
|
+
/** corrId → originating client + request kind, for routing scoped panel
|
|
212
|
+
* query responses (lessons / workspace) back to the requesting client.
|
|
213
|
+
* Entries are deleted on response or pruned by TTL. */
|
|
214
|
+
pendingFleetRequests: Map<string, { clientId: number; kind: string; expiresAt: number }>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let sharedServer: SharedServerState | null = null;
|
|
218
|
+
|
|
219
|
+
export class WebUiModule implements Module {
|
|
220
|
+
readonly name = 'webui';
|
|
221
|
+
|
|
222
|
+
private readonly config: WebUiModuleConfig;
|
|
223
|
+
|
|
224
|
+
/** Serialized SPA bundle path resolved from staticDir at construction. */
|
|
225
|
+
private readonly staticRoot: string;
|
|
226
|
+
|
|
227
|
+
constructor(config: WebUiModuleConfig = {}) {
|
|
228
|
+
this.config = config;
|
|
229
|
+
// Default static root: <package-root>/dist/web. Derived from the module's
|
|
230
|
+
// own file location so the resolution is stable regardless of process cwd.
|
|
231
|
+
// This file lives at <package>/src/modules/web-ui-module.ts, so going up
|
|
232
|
+
// two levels from its directory gets us the package root.
|
|
233
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
234
|
+
const packageRoot = resolve(moduleDir, '..', '..');
|
|
235
|
+
this.staticRoot = resolve(config.staticDir ?? join(packageRoot, 'dist', 'web'));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// -------------------------------------------------------------------------
|
|
239
|
+
// Module interface
|
|
240
|
+
// -------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
async start(_ctx: ModuleContext): Promise<void> {
|
|
243
|
+
if (sharedServer) {
|
|
244
|
+
// Server already up from a previous framework lifetime. Reuse it. Config
|
|
245
|
+
// collisions (e.g. a different port across recipes) are out of scope —
|
|
246
|
+
// recipes within one process should declare consistent webui config.
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const port = this.config.port ?? DEFAULT_PORT;
|
|
251
|
+
const host = this.config.host ?? DEFAULT_HOST;
|
|
252
|
+
this.assertSafeBind(host);
|
|
253
|
+
|
|
254
|
+
const state: SharedServerState = {
|
|
255
|
+
server: undefined as unknown as ReturnType<typeof Bun.serve>,
|
|
256
|
+
port,
|
|
257
|
+
host,
|
|
258
|
+
staticRoot: this.staticRoot,
|
|
259
|
+
basicAuth: this.config.basicAuth,
|
|
260
|
+
allowedOrigins: this.config.allowedOrigins ?? defaultAllowedOrigins(port),
|
|
261
|
+
clients: new Map(),
|
|
262
|
+
nextClientId: 1,
|
|
263
|
+
latestUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
264
|
+
parentUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
265
|
+
childUsage: new Map(),
|
|
266
|
+
latestPerAgentCost: [],
|
|
267
|
+
pendingFleetRequests: new Map(),
|
|
268
|
+
childRecipeCache: new Map(),
|
|
269
|
+
app: null,
|
|
270
|
+
treeAggregator: null,
|
|
271
|
+
fleetEventDetacher: null,
|
|
272
|
+
messageListenerDetacher: null,
|
|
273
|
+
};
|
|
274
|
+
state.server = Bun.serve({
|
|
275
|
+
port,
|
|
276
|
+
hostname: host,
|
|
277
|
+
fetch: (req, server) => this.handleHttp(req, server),
|
|
278
|
+
websocket: {
|
|
279
|
+
open: (ws) => this.onWsOpen(ws as ServerWebSocket<{ id: number }>),
|
|
280
|
+
message: (ws, msg) => this.onWsMessage(ws as ServerWebSocket<{ id: number }>, msg),
|
|
281
|
+
close: (ws) => this.onWsClose(ws as ServerWebSocket<{ id: number }>),
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
// When port=0 is passed (test setups, ephemeral binds), the OS picks a
|
|
285
|
+
// free port and Bun.serve exposes it via `.port`. Re-read so the cached
|
|
286
|
+
// port and the default Origin allowlist match the actual listener.
|
|
287
|
+
// Bun's typings widen `port` to `number | undefined` for some socket
|
|
288
|
+
// listener types; fall back to the requested port if the runtime didn't
|
|
289
|
+
// expose one.
|
|
290
|
+
const boundPort = state.server.port ?? port;
|
|
291
|
+
state.port = boundPort;
|
|
292
|
+
if (this.config.allowedOrigins === undefined) {
|
|
293
|
+
state.allowedOrigins = defaultAllowedOrigins(boundPort);
|
|
294
|
+
}
|
|
295
|
+
sharedServer = state;
|
|
296
|
+
|
|
297
|
+
console.log(`[webui] listening on http://${host}:${boundPort}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async stop(): Promise<void> {
|
|
301
|
+
// Tear down framework-bound state only. The HTTP server and WS clients
|
|
302
|
+
// belong to the process-level singleton and survive across session
|
|
303
|
+
// switches; closing them here would drop active admin connections every
|
|
304
|
+
// time the operator switches sessions or the framework restarts.
|
|
305
|
+
if (!sharedServer) return;
|
|
306
|
+
sharedServer.fleetEventDetacher?.();
|
|
307
|
+
sharedServer.fleetEventDetacher = null;
|
|
308
|
+
sharedServer.messageListenerDetacher?.();
|
|
309
|
+
sharedServer.messageListenerDetacher = null;
|
|
310
|
+
sharedServer.treeAggregator?.dispose();
|
|
311
|
+
sharedServer.treeAggregator = null;
|
|
312
|
+
sharedServer.app = null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
getTools(): ToolDefinition[] { return []; }
|
|
316
|
+
|
|
317
|
+
async handleToolCall(_call: ToolCall): Promise<ToolResult> {
|
|
318
|
+
return { success: false, error: 'WebUiModule has no tools', isError: true };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
|
|
322
|
+
return {};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// -------------------------------------------------------------------------
|
|
326
|
+
// Post-creation wiring (called from index.ts, mirrors ActivityModule.setFramework)
|
|
327
|
+
// -------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
setApp(app: WebUiAppRef): void {
|
|
330
|
+
if (!sharedServer) return;
|
|
331
|
+
const ss = sharedServer;
|
|
332
|
+
ss.app = app;
|
|
333
|
+
|
|
334
|
+
// Tear down any previous aggregator (session-switch path).
|
|
335
|
+
ss.fleetEventDetacher?.();
|
|
336
|
+
ss.fleetEventDetacher = null;
|
|
337
|
+
ss.messageListenerDetacher?.();
|
|
338
|
+
ss.messageListenerDetacher = null;
|
|
339
|
+
ss.treeAggregator?.dispose();
|
|
340
|
+
ss.treeAggregator = null;
|
|
341
|
+
|
|
342
|
+
// Live message pushes: assistant turns and tool results never surface as
|
|
343
|
+
// `message:added` traces (those fire only for external/MCPL sources), so
|
|
344
|
+
// subscribe to the message store itself. Feature-detected — older
|
|
345
|
+
// context-manager copies without onMessage simply don't get live pushes
|
|
346
|
+
// (clients still see streamed text via traces).
|
|
347
|
+
const cm0 = app.framework.getAllAgents()[0]?.getContextManager() as unknown as WindowCapableCm | undefined;
|
|
348
|
+
if (cm0 && typeof cm0.onMessage === 'function' && typeof cm0.getMessageCount === 'function') {
|
|
349
|
+
ss.messageListenerDetacher = cm0.onMessage((ev) => {
|
|
350
|
+
if (ev.type !== 'add' || !ev.message) return;
|
|
351
|
+
try {
|
|
352
|
+
const entry = toWireEntry(ev.message as MessageLike, cm0.getMessageCount() - 1);
|
|
353
|
+
const push: WebUiServerMessage = { type: 'message-appended', entry };
|
|
354
|
+
for (const c of ss.clients.values()) {
|
|
355
|
+
if (c.welcomed) this.send(c, push);
|
|
356
|
+
}
|
|
357
|
+
} catch (err) {
|
|
358
|
+
// Never let a viewer-side projection error break the store's
|
|
359
|
+
// listener chain (ContextManager subscribes on the same path).
|
|
360
|
+
console.error('[webui] message-appended projection failed:', err);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Re-derive cost snapshot for the new framework. Without this the
|
|
366
|
+
// welcome of the first connecting client (or all clients after a
|
|
367
|
+
// session switch) would carry stale or empty per-agent costs until the
|
|
368
|
+
// next inference completes.
|
|
369
|
+
ss.latestPerAgentCost = this.collectPerAgentCost();
|
|
370
|
+
|
|
371
|
+
// Session switch rebuilds the framework with a fresh UsageTracker; any
|
|
372
|
+
// tally accumulated on the previous framework would be misleading carried
|
|
373
|
+
// forward. Children's `usage:updated` events repopulate childUsage live;
|
|
374
|
+
// resetting here keeps the header total in lockstep with the new session.
|
|
375
|
+
// Seed parentUsage from the framework's restored snapshot so a session
|
|
376
|
+
// reopened from disk shows its historical totals immediately rather than
|
|
377
|
+
// appearing reset until the next inference event lands.
|
|
378
|
+
ss.parentUsage = this.snapshotParentUsage();
|
|
379
|
+
ss.childUsage.clear();
|
|
380
|
+
ss.latestUsage = aggregateFleetUsage(ss);
|
|
381
|
+
// Recipe cache is keyed by file path; on session switch the framework
|
|
382
|
+
// is fresh but children may carry over, so the cache is still valid.
|
|
383
|
+
// Only clear if the entire app reference changed in a way that matters —
|
|
384
|
+
// for now, retain across setApp (keeps welcome fast).
|
|
385
|
+
|
|
386
|
+
// Single fan-out listener. The framework's `onTrace` does not return a
|
|
387
|
+
// detacher, so per-client subscriptions would leak across reconnects.
|
|
388
|
+
// Instead, one listener iterates the live client set and the WS lifecycle
|
|
389
|
+
// owns membership. Cheap as long as the client count stays small (admin UI).
|
|
390
|
+
app.framework.onTrace((event: TraceEvent) => this.fanOutTrace(event));
|
|
391
|
+
|
|
392
|
+
// Fleet integration: if FleetModule is mounted, spin up a private
|
|
393
|
+
// FleetTreeAggregator and start forwarding child events to clients. The
|
|
394
|
+
// aggregator's per-child reducers are populated via the `describe`/snapshot
|
|
395
|
+
// protocol, exactly as the TUI uses them — see UNIFIED-TREE-PLAN.md §3.
|
|
396
|
+
const fleetMod = app.framework
|
|
397
|
+
.getAllModules()
|
|
398
|
+
.find((m) => m.name === 'fleet') as FleetModule | undefined;
|
|
399
|
+
|
|
400
|
+
if (fleetMod) {
|
|
401
|
+
const agg = new FleetTreeAggregator(fleetMod);
|
|
402
|
+
ss.treeAggregator = agg;
|
|
403
|
+
// Register existing children up front. autoStart launches finish before
|
|
404
|
+
// setApp() runs, so this catches everything currently up.
|
|
405
|
+
for (const childName of fleetMod.getChildren().keys()) {
|
|
406
|
+
agg.registerChild(childName);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// One subscription on '*' — fan out to clients AND register newly-seen
|
|
410
|
+
// children with the aggregator. This avoids a polling loop and keeps the
|
|
411
|
+
// late-attach path correct.
|
|
412
|
+
ss.fleetEventDetacher = fleetMod.onChildEvent('*', (childName, event) =>
|
|
413
|
+
this.handleFleetEvent(childName, event),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Welcome any client that's currently connected. Two cases land here:
|
|
418
|
+
// - First setApp(): fresh page-loads parked at onWsOpen are flushed.
|
|
419
|
+
// - Post-session-switch: every previously-welcomed client gets a fresh
|
|
420
|
+
// welcome reflecting the new framework / messages / agents / branch.
|
|
421
|
+
for (const client of sharedServer!.clients.values()) {
|
|
422
|
+
// Force a re-welcome by clearing the flag and resending.
|
|
423
|
+
client.welcomed = false;
|
|
424
|
+
void this.sendWelcome(client);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private handleFleetEvent(childName: string, event: WireEvent): void {
|
|
429
|
+
// Auto-register on first sight so the aggregator picks up children that
|
|
430
|
+
// launched after setApp() ran.
|
|
431
|
+
if (sharedServer?.treeAggregator) {
|
|
432
|
+
const known = new Set(sharedServer?.treeAggregator.getAllChildNames());
|
|
433
|
+
if (!known.has(childName)) {
|
|
434
|
+
sharedServer?.treeAggregator.registerChild(childName);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Snapshot responses to scoped panel queries — route to the requesting
|
|
439
|
+
// client only. The corrId came from us; we know which client to send
|
|
440
|
+
// back to without leaking child-internal data to every connected client.
|
|
441
|
+
const eType = (event as { type?: unknown }).type;
|
|
442
|
+
const corrId = (event as { corrId?: unknown }).corrId;
|
|
443
|
+
if (
|
|
444
|
+
typeof eType === 'string'
|
|
445
|
+
&& typeof corrId === 'string'
|
|
446
|
+
&& (eType === 'lessons-snapshot'
|
|
447
|
+
|| eType === 'workspace-mounts-snapshot'
|
|
448
|
+
|| eType === 'workspace-tree-snapshot'
|
|
449
|
+
|| eType === 'workspace-file-snapshot'
|
|
450
|
+
|| eType === 'cancel-subagent-result')
|
|
451
|
+
) {
|
|
452
|
+
this.routeChildSnapshotResponse(eType, corrId, event as Record<string, unknown>);
|
|
453
|
+
return; // don't fan out — these are private replies, not telemetry
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Roll up fleet-child session usage so the header total reflects every
|
|
457
|
+
// process the operator is paying for, not just the parent. Children emit
|
|
458
|
+
// their own `usage:updated` events; we cache the last `totals` for each
|
|
459
|
+
// and sum into the aggregate that goes out as latestUsage. Drop the
|
|
460
|
+
// child's entry on graceful exit so its stale totals don't keep counting
|
|
461
|
+
// after fleet--stop. SIGKILL'd / crashed children never emit
|
|
462
|
+
// `lifecycle:exiting`, so their last reported totals stay in the map
|
|
463
|
+
// until the next session reset — accepted trade-off, the map is bounded
|
|
464
|
+
// by total-children-ever-spawned-this-session and crashes are rare.
|
|
465
|
+
let usageChanged = false;
|
|
466
|
+
if (eType === 'usage:updated') {
|
|
467
|
+
const totalsObj = (event as unknown as { totals?: unknown }).totals;
|
|
468
|
+
const parsed = parseUsageTotals(totalsObj);
|
|
469
|
+
if (parsed) {
|
|
470
|
+
sharedServer!.childUsage.set(childName, parsed);
|
|
471
|
+
usageChanged = true;
|
|
472
|
+
}
|
|
473
|
+
} else if (eType === 'lifecycle') {
|
|
474
|
+
const phase = (event as { phase?: unknown }).phase;
|
|
475
|
+
if (phase === 'exiting' || phase === 'exited') {
|
|
476
|
+
if (sharedServer!.childUsage.delete(childName)) usageChanged = true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
let usageMsg: WebUiServerMessage | null = null;
|
|
481
|
+
if (usageChanged) {
|
|
482
|
+
sharedServer!.latestUsage = aggregateFleetUsage(sharedServer!);
|
|
483
|
+
usageMsg = {
|
|
484
|
+
type: 'usage',
|
|
485
|
+
usage: sharedServer!.latestUsage,
|
|
486
|
+
...(sharedServer!.latestPerAgentCost.length > 0
|
|
487
|
+
? { perAgentCost: sharedServer!.latestPerAgentCost }
|
|
488
|
+
: {}),
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (sharedServer!.clients.size === 0) return;
|
|
493
|
+
// Forward the verbatim event so the SPA can fold it into its own
|
|
494
|
+
// per-child AgentTreeReducer for live updates.
|
|
495
|
+
const msg: WebUiServerMessage = {
|
|
496
|
+
type: 'child-event',
|
|
497
|
+
childName,
|
|
498
|
+
event: event as unknown as { type: string; [k: string]: unknown },
|
|
499
|
+
};
|
|
500
|
+
for (const client of sharedServer!.clients.values()) {
|
|
501
|
+
if (!client.welcomed) continue;
|
|
502
|
+
this.send(client, msg);
|
|
503
|
+
if (usageMsg) this.send(client, usageMsg);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Translate a child snapshot event back into the matching wire message
|
|
508
|
+
* type and forward to the originating client. The pendingFleetRequests
|
|
509
|
+
* map is the source of truth for which client asked. */
|
|
510
|
+
private routeChildSnapshotResponse(
|
|
511
|
+
eType: string,
|
|
512
|
+
corrId: string,
|
|
513
|
+
event: Record<string, unknown>,
|
|
514
|
+
): void {
|
|
515
|
+
if (!sharedServer) return;
|
|
516
|
+
const entry = sharedServer.pendingFleetRequests.get(corrId);
|
|
517
|
+
if (!entry) return; // stale or foreign corrId; ignore
|
|
518
|
+
sharedServer.pendingFleetRequests.delete(corrId);
|
|
519
|
+
const client = sharedServer.clients.get(entry.clientId);
|
|
520
|
+
if (!client) return;
|
|
521
|
+
|
|
522
|
+
if (eType === 'lessons-snapshot') {
|
|
523
|
+
this.send(client, {
|
|
524
|
+
type: 'lessons-list',
|
|
525
|
+
loaded: Boolean(event.loaded),
|
|
526
|
+
lessons: (event.lessons as LessonsListMessage['lessons']) ?? [],
|
|
527
|
+
});
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (eType === 'workspace-mounts-snapshot') {
|
|
531
|
+
this.send(client, {
|
|
532
|
+
type: 'workspace-mounts',
|
|
533
|
+
loaded: Boolean(event.loaded),
|
|
534
|
+
mounts: (event.mounts as Array<{ name: string; path: string; mode: string }>) ?? [],
|
|
535
|
+
});
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (eType === 'workspace-tree-snapshot') {
|
|
539
|
+
this.send(client, {
|
|
540
|
+
type: 'workspace-tree',
|
|
541
|
+
mount: String(event.mount ?? ''),
|
|
542
|
+
entries: (event.entries as Array<{ path: string; size: number }>) ?? [],
|
|
543
|
+
});
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (eType === 'workspace-file-snapshot') {
|
|
547
|
+
const errStr = typeof event.error === 'string' ? event.error : undefined;
|
|
548
|
+
if (errStr) {
|
|
549
|
+
this.send(client, { type: 'error', message: `read failed: ${errStr}` });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
this.send(client, {
|
|
553
|
+
type: 'workspace-file',
|
|
554
|
+
path: String(event.path ?? ''),
|
|
555
|
+
totalLines: Number(event.totalLines ?? 0),
|
|
556
|
+
fromLine: Number(event.fromLine ?? 1),
|
|
557
|
+
toLine: Number(event.toLine ?? 0),
|
|
558
|
+
content: String(event.content ?? ''),
|
|
559
|
+
truncated: Boolean(event.truncated),
|
|
560
|
+
});
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (eType === 'cancel-subagent-result') {
|
|
564
|
+
const name = String(event.name ?? '');
|
|
565
|
+
const cancelled = Boolean(event.cancelled);
|
|
566
|
+
const reason = typeof event.reason === 'string' ? event.reason : undefined;
|
|
567
|
+
this.send(client, {
|
|
568
|
+
type: 'command-result',
|
|
569
|
+
lines: [{
|
|
570
|
+
text: cancelled
|
|
571
|
+
? `cancelled subagent ${name}`
|
|
572
|
+
: `subagent ${name} not cancelled${reason ? ` (${reason})` : ''}`,
|
|
573
|
+
style: cancelled ? 'system' : 'tool',
|
|
574
|
+
}],
|
|
575
|
+
});
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private fanOutTrace(event: TraceEvent): void {
|
|
581
|
+
// Update cached usage snapshot first so welcomes for late-connecting
|
|
582
|
+
// clients get a current value.
|
|
583
|
+
if (event.type === 'usage:updated') {
|
|
584
|
+
const totalsObj = (event as unknown as { totals?: unknown }).totals;
|
|
585
|
+
const parsed = parseUsageTotals(totalsObj);
|
|
586
|
+
if (parsed) sharedServer!.parentUsage = parsed;
|
|
587
|
+
sharedServer!.latestUsage = aggregateFleetUsage(sharedServer!);
|
|
588
|
+
// Re-derive the per-agent slice from the framework's snapshot. This
|
|
589
|
+
// is the only place we reach into framework internals on the trace
|
|
590
|
+
// hot path; the call is O(agents) and guarded by the cached snapshot.
|
|
591
|
+
sharedServer!.latestPerAgentCost = this.collectPerAgentCost();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// External-trigger surfacing — turn `message:added` traces from MCPL
|
|
595
|
+
// sources into a typed wire message so the SPA can show an attribution
|
|
596
|
+
// box. Fire-and-forget; lookup may fail mid-modification.
|
|
597
|
+
if (event.type === 'message:added') {
|
|
598
|
+
const e = event as unknown as { messageId: string; source: string };
|
|
599
|
+
void this.maybeEmitTrigger(e.messageId, e.source);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (sharedServer!.clients.size === 0) return;
|
|
603
|
+
const traceMsg: WebUiServerMessage = {
|
|
604
|
+
type: 'trace',
|
|
605
|
+
event: event as unknown as { type: string; [k: string]: unknown },
|
|
606
|
+
};
|
|
607
|
+
const usageMsg: WebUiServerMessage | null = event.type === 'usage:updated'
|
|
608
|
+
? {
|
|
609
|
+
type: 'usage',
|
|
610
|
+
usage: sharedServer!.latestUsage,
|
|
611
|
+
...(sharedServer!.latestPerAgentCost.length > 0
|
|
612
|
+
? { perAgentCost: sharedServer!.latestPerAgentCost }
|
|
613
|
+
: {}),
|
|
614
|
+
}
|
|
615
|
+
: null;
|
|
616
|
+
for (const client of sharedServer!.clients.values()) {
|
|
617
|
+
if (!client.welcomed) continue;
|
|
618
|
+
this.send(client, traceMsg);
|
|
619
|
+
if (usageMsg) this.send(client, usageMsg);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// -------------------------------------------------------------------------
|
|
624
|
+
/** Surface MCPL-sourced `message:added` traces as `inbound-trigger`
|
|
625
|
+
* envelopes so the SPA can show "incoming from zulip#X" boxes. WebUI-typed
|
|
626
|
+
* user messages are excluded — those are already optimistically rendered
|
|
627
|
+
* on the originating client. */
|
|
628
|
+
/** Load a child's recipe metadata (name, description, agent model) from
|
|
629
|
+
* its recipe file path. Cached per-path on the singleton; failures
|
|
630
|
+
* resolve to undefined so the SPA can fall back to displaying the child
|
|
631
|
+
* name only. */
|
|
632
|
+
private async loadChildRecipeInfo(
|
|
633
|
+
fleet: FleetModule,
|
|
634
|
+
childName: string,
|
|
635
|
+
): Promise<{ name: string; description?: string; version?: string; agentModel?: string } | undefined> {
|
|
636
|
+
const child = fleet.getChildren().get(childName);
|
|
637
|
+
if (!child) return undefined;
|
|
638
|
+
const path = child.recipePath;
|
|
639
|
+
if (!path) return undefined;
|
|
640
|
+
const cache = sharedServer?.childRecipeCache;
|
|
641
|
+
if (cache?.has(path)) return cache.get(path);
|
|
642
|
+
try {
|
|
643
|
+
const recipe = await loadRecipe(path);
|
|
644
|
+
const info = {
|
|
645
|
+
name: recipe.name,
|
|
646
|
+
...(recipe.description ? { description: recipe.description } : {}),
|
|
647
|
+
...(recipe.version ? { version: recipe.version } : {}),
|
|
648
|
+
...(recipe.agent?.model ? { agentModel: recipe.agent.model } : {}),
|
|
649
|
+
};
|
|
650
|
+
cache?.set(path, info);
|
|
651
|
+
return info;
|
|
652
|
+
} catch {
|
|
653
|
+
return undefined;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Pull a per-agent cost snapshot from the framework's UsageTracker.
|
|
658
|
+
* Returns [] if the framework isn't bound or no agents have been billed
|
|
659
|
+
* yet. Used by both the welcome payload and live UsageMessage frames. */
|
|
660
|
+
private collectPerAgentCost(): import('../web/protocol.js').PerAgentCost[] {
|
|
661
|
+
const snap = this.readSessionUsage();
|
|
662
|
+
if (!snap) return [];
|
|
663
|
+
const out: import('../web/protocol.js').PerAgentCost[] = [];
|
|
664
|
+
for (const agent of snap.byAgent) {
|
|
665
|
+
const c = agent.usage.estimatedCost;
|
|
666
|
+
if (!c) continue;
|
|
667
|
+
out.push({ name: agent.agentName, cost: { total: c.total, currency: c.currency }, inferenceCount: agent.inferenceCount });
|
|
668
|
+
}
|
|
669
|
+
return out;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Pull the parent UsageTracker's running totals so the header reflects a
|
|
673
|
+
* restored session's prior spend immediately on connect, instead of
|
|
674
|
+
* reading 0 until the next inference event fires. */
|
|
675
|
+
private snapshotParentUsage(): TokenUsage {
|
|
676
|
+
const empty: TokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
677
|
+
const snap = this.readSessionUsage();
|
|
678
|
+
return snap ? (parseUsageTotals(snap.totals) ?? empty) : empty;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Read a defensive snapshot of the bound framework's UsageTracker. Returns
|
|
682
|
+
* null if no app is bound or the call throws. The try/catch survives the
|
|
683
|
+
* trace hot path: a thrown UsageTracker shouldn't take the WebUI down. */
|
|
684
|
+
private readSessionUsage(): SessionUsageSnapshot | null {
|
|
685
|
+
if (!sharedServer?.app) return null;
|
|
686
|
+
try {
|
|
687
|
+
return sharedServer.app.framework.getSessionUsage();
|
|
688
|
+
} catch {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
private async maybeEmitTrigger(messageId: string, source: string): Promise<void> {
|
|
694
|
+
if (!source.startsWith('mcpl:')) return;
|
|
695
|
+
if (!sharedServer?.app) return;
|
|
696
|
+
type Stored = { participant: string; content: ReadonlyArray<unknown>; metadata?: Record<string, unknown>; timestamp: Date };
|
|
697
|
+
let storedMsg: Stored | null;
|
|
698
|
+
try {
|
|
699
|
+
const cm = sharedServer.app.framework.getAllAgents()[0]?.getContextManager();
|
|
700
|
+
if (!cm) return;
|
|
701
|
+
storedMsg = (cm.getMessage(messageId) as Stored | null) ?? null;
|
|
702
|
+
} catch {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (!storedMsg) return;
|
|
706
|
+
if (storedMsg.participant !== 'user') return;
|
|
707
|
+
|
|
708
|
+
const md = storedMsg.metadata ?? {};
|
|
709
|
+
const origin = describeTriggerOrigin(source, md);
|
|
710
|
+
const author = extractAuthorName(md);
|
|
711
|
+
const text = extractText(storedMsg.content).slice(0, 500);
|
|
712
|
+
const triggered = Boolean(md.triggered);
|
|
713
|
+
|
|
714
|
+
const msg: WebUiServerMessage = {
|
|
715
|
+
type: 'inbound-trigger',
|
|
716
|
+
source,
|
|
717
|
+
origin,
|
|
718
|
+
triggered,
|
|
719
|
+
...(author ? { author } : {}),
|
|
720
|
+
text,
|
|
721
|
+
timestamp: storedMsg.timestamp.getTime(),
|
|
722
|
+
};
|
|
723
|
+
for (const client of sharedServer.clients.values()) {
|
|
724
|
+
if (!client.welcomed) continue;
|
|
725
|
+
this.send(client, msg);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// -------------------------------------------------------------------------
|
|
730
|
+
// HTTP — static SPA + WS upgrade
|
|
731
|
+
// -------------------------------------------------------------------------
|
|
732
|
+
|
|
733
|
+
private async handleHttp(req: Request, server: ReturnType<typeof Bun.serve>): Promise<Response> {
|
|
734
|
+
const url = new URL(req.url);
|
|
735
|
+
|
|
736
|
+
// WebSocket upgrade
|
|
737
|
+
if (url.pathname === '/ws') {
|
|
738
|
+
// Origin check FIRST — drive-by CSRF on a localhost-bound WS is the
|
|
739
|
+
// failure mode this guards. Browsers do not enforce same-origin on
|
|
740
|
+
// `new WebSocket(...)` the way they do on fetch, so without an
|
|
741
|
+
// explicit check, any tab the operator opens could connect here.
|
|
742
|
+
if (!this.checkOrigin(req)) return new Response('Forbidden', { status: 403 });
|
|
743
|
+
if (!this.checkAuth(req)) return this.unauthorized();
|
|
744
|
+
const id = sharedServer!.nextClientId++;
|
|
745
|
+
const ok = server.upgrade(req, { data: { id } });
|
|
746
|
+
if (!ok) return new Response('Upgrade failed', { status: 400 });
|
|
747
|
+
// Bun returns undefined on success; the response is taken over by the upgrade.
|
|
748
|
+
return new Response(null, { status: 101 });
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (!this.checkAuth(req)) return this.unauthorized();
|
|
752
|
+
|
|
753
|
+
// Debug: the membrane-normalized request that WOULD be emitted if the
|
|
754
|
+
// agent were activated right now — no inference, no state mutation.
|
|
755
|
+
// GET /debug/context[?agent=<name>][&hooks=false][&pretty=1]
|
|
756
|
+
if (url.pathname === '/debug/context/makeup') {
|
|
757
|
+
return this.handleContextMakeup(url);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Context curve: per-entry provenance of the live compiled window —
|
|
761
|
+
// cumulative raw-history tokens vs rendered tokens, by fold level.
|
|
762
|
+
// JSON at /debug/context/curve; the visualization page at /curve.
|
|
763
|
+
if (url.pathname === '/debug/context/curve') {
|
|
764
|
+
return this.handleContextCurve(url);
|
|
765
|
+
}
|
|
766
|
+
if (url.pathname === '/curve') {
|
|
767
|
+
return new Response(CURVE_PAGE_HTML, {
|
|
768
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (url.pathname === '/debug/context') {
|
|
773
|
+
return this.handleDebugContext(url);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Liveness/health JSON for connectome-doctor and the fleet hub. Behind
|
|
777
|
+
// the same basic auth as everything else (checked above).
|
|
778
|
+
if (url.pathname === '/healthz') {
|
|
779
|
+
const app = sharedServer?.app;
|
|
780
|
+
if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
|
|
781
|
+
const fw = app.framework as unknown as { healthSnapshot?: () => Record<string, unknown> };
|
|
782
|
+
if (typeof fw.healthSnapshot !== 'function') {
|
|
783
|
+
return Response.json({ error: 'framework lacks healthSnapshot()' }, { status: 501 });
|
|
784
|
+
}
|
|
785
|
+
return Response.json(fw.healthSnapshot());
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Workspace file passthrough: /files/<mount>/<path...>
|
|
789
|
+
// Resolves through WorkspaceModule.resolveAbsolutePath, which enforces
|
|
790
|
+
// mount-relative containment and the mount's read-permission. We never
|
|
791
|
+
// serve a path the agent framework's mount layer wouldn't itself serve.
|
|
792
|
+
if (url.pathname.startsWith('/files/')) {
|
|
793
|
+
return this.serveWorkspaceFile(url.pathname.slice('/files/'.length));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Static SPA
|
|
797
|
+
const requested = url.pathname === '/' ? '/index.html' : url.pathname;
|
|
798
|
+
return this.serveStatic(requested);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Debug endpoint: return the membrane-normalized request the framework would
|
|
803
|
+
* hand to the model if the agent were activated right now. Delegates to
|
|
804
|
+
* `framework.previewActivation`. Auth is already enforced by the caller
|
|
805
|
+
* (`handleHttp`).
|
|
806
|
+
*
|
|
807
|
+
* Transparent by default: no inference, no Chronicle writes, no external
|
|
808
|
+
* MCPL calls — the preview leaves the system state untouched. This omits the
|
|
809
|
+
* dynamically-gathered injections (lessons/retrieval/MCPL context). Pass
|
|
810
|
+
* `?injections=1` to gather them for full fidelity, which is NOT transparent:
|
|
811
|
+
* it can run inference (e.g. RetrievalModule's Haiku calls) and fire MCPL
|
|
812
|
+
* `beforeInference` hooks (whose paired `afterInference` is never sent).
|
|
813
|
+
*/
|
|
814
|
+
private async handleDebugContext(url: URL): Promise<Response> {
|
|
815
|
+
const app = sharedServer?.app;
|
|
816
|
+
if (!app) return new Response('Not ready', { status: 503 });
|
|
817
|
+
|
|
818
|
+
const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
|
|
819
|
+
// Default OFF: keep the preview transparent to system state. Opt in to
|
|
820
|
+
// dynamic injection gathering (and its side effects) with ?injections=1.
|
|
821
|
+
const injParam = url.searchParams.get('injections');
|
|
822
|
+
const injections = injParam !== null && injParam !== 'false' && injParam !== '0';
|
|
823
|
+
const pretty = url.searchParams.get('pretty') !== null && url.searchParams.get('pretty') !== '0';
|
|
824
|
+
|
|
825
|
+
if (!app.framework.getAgent(agentName)) {
|
|
826
|
+
return new Response(
|
|
827
|
+
JSON.stringify({ error: `Agent not found: ${agentName}` }),
|
|
828
|
+
{ status: 404, headers: { 'content-type': 'application/json' } },
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
try {
|
|
833
|
+
const request = await app.framework.previewActivation(agentName, { injections });
|
|
834
|
+
// `transparent` reflects whether this call was side-effect-free.
|
|
835
|
+
const body = JSON.stringify(
|
|
836
|
+
{ agent: agentName, injections, transparent: !injections, request },
|
|
837
|
+
null,
|
|
838
|
+
pretty ? 2 : undefined,
|
|
839
|
+
);
|
|
840
|
+
return new Response(body, { headers: { 'content-type': 'application/json' } });
|
|
841
|
+
} catch (error) {
|
|
842
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
843
|
+
return new Response(
|
|
844
|
+
JSON.stringify({ error: message }),
|
|
845
|
+
{ status: 500, headers: { 'content-type': 'application/json' } },
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Context curve (GET /debug/context/curve[?agent=<name>]): compile the
|
|
852
|
+
* agent's window and return one record per compiled entry with its
|
|
853
|
+
* provenance — kind (raw / L1..Ln summary), rendered token estimate, the
|
|
854
|
+
* raw-history tokens it covers (leaf messages, recursively through the
|
|
855
|
+
* summary tree), date span, and full text. The /curve page plots the
|
|
856
|
+
* cumulative raw→rendered curve from this; slope = local compression rate.
|
|
857
|
+
*
|
|
858
|
+
* Same side-effect class as previewActivation / makeup: the compile may
|
|
859
|
+
* commit resolution updates, exactly as the agent's own next turn would.
|
|
860
|
+
* No inference, no message writes.
|
|
861
|
+
*/
|
|
862
|
+
private async handleContextCurve(url: URL): Promise<Response> {
|
|
863
|
+
const app = sharedServer?.app;
|
|
864
|
+
if (!app) return new Response('Not ready', { status: 503 });
|
|
865
|
+
const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
|
|
866
|
+
const agent = app.framework.getAgent(agentName);
|
|
867
|
+
if (!agent) {
|
|
868
|
+
return new Response(JSON.stringify({ error: `Agent not found: ${agentName}` }), {
|
|
869
|
+
status: 404, headers: { 'content-type': 'application/json' },
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
try {
|
|
873
|
+
const cm = (agent as unknown as { getContextManager: () => any }).getContextManager();
|
|
874
|
+
const maxTokens = app.recipe.agent.contextBudgetTokens ?? 200_000;
|
|
875
|
+
const reserveForResponse = app.recipe.agent.maxTokens ?? 16_384;
|
|
876
|
+
const compiled = await cm.compile({ maxTokens, reserveForResponse });
|
|
877
|
+
|
|
878
|
+
// Curve inspection only needs text and source metadata. Resolving every
|
|
879
|
+
// historical blob here re-inlines all base64 media and can expand a
|
|
880
|
+
// few-hundred-MB Chronicle into several GB of JS heap. Use the windowed
|
|
881
|
+
// reader with blob resolution disabled so production diagnostics stay
|
|
882
|
+
// bounded by text history rather than the media archive.
|
|
883
|
+
const messageCount = cm.getMessageCount();
|
|
884
|
+
const messages: Array<{ id: string; timestamp?: unknown; content?: unknown[] }> =
|
|
885
|
+
cm.getMessageWindow(0, messageCount, { resolveBlobs: false }).messages;
|
|
886
|
+
const msgById = new Map(messages.map((mm) => [mm.id, mm]));
|
|
887
|
+
const estimate = (mm: { content?: unknown[] }): number => {
|
|
888
|
+
let t = 0;
|
|
889
|
+
for (const b of (mm.content ?? []) as Array<Record<string, unknown>>) {
|
|
890
|
+
if (b?.type === 'text') t += Math.ceil(String(b.text ?? '').length / 4);
|
|
891
|
+
else if (b?.type === 'image') t += 1600;
|
|
892
|
+
else if (b?.type === 'tool_result') t += Math.ceil(JSON.stringify(b.content ?? '').length / 4);
|
|
893
|
+
else if (b?.type === 'tool_use') t += Math.ceil(JSON.stringify(b.input ?? {}).length / 4);
|
|
894
|
+
else if (b?.type === 'thinking') t += Math.ceil(String(b.thinking ?? '').length / 4);
|
|
895
|
+
}
|
|
896
|
+
return t;
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
type Summary = { id: string; level: number; content: string; sourceLevel: number; sourceIds: string[] };
|
|
900
|
+
const strategy = cm.getStrategy() as { summaries?: Summary[] };
|
|
901
|
+
const sums: Summary[] = strategy.summaries ?? [];
|
|
902
|
+
const sumById = new Map(sums.map((x) => [x.id, x]));
|
|
903
|
+
const headOf = (txt: string): string => txt.replace(/\s+/g, ' ').slice(0, 100);
|
|
904
|
+
const byHead = new Map(sums.map((x) => [headOf(x.content), x]));
|
|
905
|
+
const leaves = (x: Summary, seen = new Set<string>()): string[] => {
|
|
906
|
+
if (seen.has(x.id)) return [];
|
|
907
|
+
seen.add(x.id);
|
|
908
|
+
if (x.sourceLevel === 0) return x.sourceIds;
|
|
909
|
+
const out: string[] = [];
|
|
910
|
+
for (const cid of x.sourceIds) {
|
|
911
|
+
const c = sumById.get(cid);
|
|
912
|
+
if (c) out.push(...leaves(c, seen));
|
|
913
|
+
}
|
|
914
|
+
return out;
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
const entries = [];
|
|
918
|
+
let i = 0;
|
|
919
|
+
for (const e of compiled.messages as Array<{ participant: string; content?: unknown[]; sourceMessageId?: string }>) {
|
|
920
|
+
const blocks = (e.content ?? []) as Array<Record<string, unknown>>;
|
|
921
|
+
const text = blocks.filter((b) => b?.type === 'text').map((b) => String(b.text ?? '')).join('\n');
|
|
922
|
+
const nImages = blocks.filter((b) => b?.type === 'image').length;
|
|
923
|
+
const rendered = Math.ceil(text.length / 4) + nImages * 1600 +
|
|
924
|
+
blocks.filter((b) => b?.type === 'tool_result' || b?.type === 'tool_use')
|
|
925
|
+
.reduce((a, b) => a + Math.ceil(JSON.stringify(b.input ?? b.content ?? '').length / 4), 0);
|
|
926
|
+
const sum = byHead.get(headOf(text));
|
|
927
|
+
if (sum) {
|
|
928
|
+
const leafIds = leaves(sum).filter((id) => msgById.has(id));
|
|
929
|
+
const rawCovered = leafIds.reduce((a, id) => a + estimate(msgById.get(id)!), 0);
|
|
930
|
+
const dates = leafIds.map((id) => msgById.get(id)!.timestamp).filter(Boolean).sort();
|
|
931
|
+
entries.push({
|
|
932
|
+
i: i++, kind: `L${sum.level}`, id: sum.id, participant: e.participant,
|
|
933
|
+
rendered, rawCovered, msgCount: leafIds.length, nImages,
|
|
934
|
+
dateFirst: dates[0] ?? null, dateLast: dates[dates.length - 1] ?? null, text,
|
|
935
|
+
});
|
|
936
|
+
} else {
|
|
937
|
+
const src = e.sourceMessageId ? msgById.get(e.sourceMessageId) : null;
|
|
938
|
+
entries.push({
|
|
939
|
+
i: i++, kind: 'raw', id: e.sourceMessageId ?? null, participant: e.participant,
|
|
940
|
+
rendered, rawCovered: src ? estimate(src) : rendered, msgCount: 1, nImages,
|
|
941
|
+
dateFirst: src?.timestamp ?? null, dateLast: src?.timestamp ?? null, text,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return Response.json({
|
|
946
|
+
agent: agentName,
|
|
947
|
+
generatedAt: new Date().toISOString(),
|
|
948
|
+
branch: cm.currentBranch().name,
|
|
949
|
+
budget: { maxTokens, reserveForResponse },
|
|
950
|
+
totals: {
|
|
951
|
+
entries: entries.length,
|
|
952
|
+
rendered: entries.reduce((a, e) => a + e.rendered, 0),
|
|
953
|
+
rawCovered: entries.reduce((a, e) => a + e.rawCovered, 0),
|
|
954
|
+
},
|
|
955
|
+
entries,
|
|
956
|
+
});
|
|
957
|
+
} catch (error) {
|
|
958
|
+
return new Response(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), {
|
|
959
|
+
status: 500, headers: { 'content-type': 'application/json' },
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Context makeup: the segment breakdown of the agent's current compiled
|
|
966
|
+
* context — head window, raw middle, summaries by level (L1/L2/L3), and the
|
|
967
|
+
* recent verbatim tail — from the strategy's RenderStats, plus an exact
|
|
968
|
+
* total token count via the model's count_tokens endpoint. Transparent:
|
|
969
|
+
* previewActivation + count_tokens only; no inference, no Chronicle writes.
|
|
970
|
+
*
|
|
971
|
+
* GET /debug/context/makeup[?agent=<name>]
|
|
972
|
+
*/
|
|
973
|
+
private async handleContextMakeup(url: URL): Promise<Response> {
|
|
974
|
+
const app = sharedServer?.app;
|
|
975
|
+
if (!app) return new Response('Not ready', { status: 503 });
|
|
976
|
+
const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
|
|
977
|
+
const agent = app.framework.getAgent(agentName);
|
|
978
|
+
if (!agent) {
|
|
979
|
+
return new Response(JSON.stringify({ error: `Agent not found: ${agentName}` }), {
|
|
980
|
+
status: 404, headers: { 'content-type': 'application/json' },
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
try {
|
|
984
|
+
// Populates the strategy's render stats as a side-effect of compiling.
|
|
985
|
+
const request = await app.framework.previewActivation(agentName);
|
|
986
|
+
const cm = (agent as { getContextManager: () => { getRenderStats: () => unknown } }).getContextManager();
|
|
987
|
+
const stats = cm.getRenderStats();
|
|
988
|
+
|
|
989
|
+
// Build an Anthropic-faithful payload for an exact count_tokens: map
|
|
990
|
+
// participants to roles (the agent's own -> assistant, others -> user
|
|
991
|
+
// with a "Name:" prefix) and merge consecutive same-role runs, mirroring
|
|
992
|
+
// what the NativeFormatter sends.
|
|
993
|
+
const textOf = (c: unknown): string =>
|
|
994
|
+
Array.isArray(c)
|
|
995
|
+
? c.map((b) => (b && typeof b === 'object' && (b as { type?: string }).type === 'text' ? (b as { text: string }).text : '')).join('')
|
|
996
|
+
: String(c ?? '');
|
|
997
|
+
const merged: Array<{ role: 'user' | 'assistant'; text: string }> = [];
|
|
998
|
+
for (const m of ((request as { messages?: Array<{ participant?: string; role?: string; content: unknown }> }).messages ?? [])) {
|
|
999
|
+
const who = m.participant ?? m.role ?? 'user';
|
|
1000
|
+
const role: 'user' | 'assistant' = who === agentName ? 'assistant' : 'user';
|
|
1001
|
+
let t = textOf(m.content);
|
|
1002
|
+
if (role === 'user' && who && who !== 'user') t = `${who}: ${t}`;
|
|
1003
|
+
const last = merged[merged.length - 1];
|
|
1004
|
+
if (last && last.role === role) last.text += '\n' + t;
|
|
1005
|
+
else merged.push({ role, text: t });
|
|
1006
|
+
}
|
|
1007
|
+
const anthMessages = merged.filter((m) => m.text.trim().length > 0).map((m) => ({ role: m.role, content: m.text }));
|
|
1008
|
+
const sysRaw = (request as { system?: unknown }).system;
|
|
1009
|
+
const systemStr = Array.isArray(sysRaw)
|
|
1010
|
+
? sysRaw.map((b) => (b && typeof b === 'object' ? (b as { text?: string }).text ?? '' : String(b))).join('\n')
|
|
1011
|
+
: (typeof sysRaw === 'string' ? sysRaw : undefined);
|
|
1012
|
+
|
|
1013
|
+
let exactTotalTokens: number | null = null;
|
|
1014
|
+
const countModel = process.env.COUNT_TOKENS_MODEL || 'anthropic/claude-opus-4.5';
|
|
1015
|
+
let countSource = 'count_tokens';
|
|
1016
|
+
try {
|
|
1017
|
+
const base = (process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '');
|
|
1018
|
+
const res = await fetch(base + '/v1/messages/count_tokens', {
|
|
1019
|
+
method: 'POST',
|
|
1020
|
+
headers: {
|
|
1021
|
+
// Mirror the main adapter's auth: OAuth Bearer (subscription) when
|
|
1022
|
+
// ANTHROPIC_AUTH_TOKEN is set, x-api-key otherwise.
|
|
1023
|
+
...(process.env.ANTHROPIC_AUTH_TOKEN
|
|
1024
|
+
? {
|
|
1025
|
+
authorization: `Bearer ${process.env.ANTHROPIC_AUTH_TOKEN}`,
|
|
1026
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
1027
|
+
}
|
|
1028
|
+
: { 'x-api-key': process.env.ANTHROPIC_API_KEY ?? '' }),
|
|
1029
|
+
'anthropic-version': '2023-06-01',
|
|
1030
|
+
'content-type': 'application/json',
|
|
1031
|
+
'user-agent': 'conhost/1.0',
|
|
1032
|
+
},
|
|
1033
|
+
body: JSON.stringify({ model: countModel, ...(systemStr ? { system: systemStr } : {}), messages: anthMessages }),
|
|
1034
|
+
});
|
|
1035
|
+
if (res.ok) {
|
|
1036
|
+
const j = (await res.json()) as { input_tokens?: number };
|
|
1037
|
+
exactTotalTokens = j.input_tokens ?? null;
|
|
1038
|
+
} else {
|
|
1039
|
+
countSource = `count_tokens_failed_${res.status}`;
|
|
1040
|
+
}
|
|
1041
|
+
} catch {
|
|
1042
|
+
countSource = 'count_tokens_error';
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
return new Response(
|
|
1046
|
+
JSON.stringify({ agent: agentName, stats, exactTotalTokens, countModel, countSource }, null, 2),
|
|
1047
|
+
{ headers: { 'content-type': 'application/json' } },
|
|
1048
|
+
);
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
return new Response(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), {
|
|
1051
|
+
status: 500, headers: { 'content-type': 'application/json' },
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private async serveWorkspaceFile(rest: string): Promise<Response> {
|
|
1057
|
+
if (!sharedServer?.app) return new Response('Not ready', { status: 503 });
|
|
1058
|
+
const decoded = decodeURIComponent(rest);
|
|
1059
|
+
const slash = decoded.indexOf('/');
|
|
1060
|
+
if (slash < 0) return new Response('Bad request', { status: 400 });
|
|
1061
|
+
const mount = decoded.slice(0, slash);
|
|
1062
|
+
const inMountPath = decoded.slice(slash + 1);
|
|
1063
|
+
const mountPrefixed = `${mount}/${inMountPath}`;
|
|
1064
|
+
|
|
1065
|
+
const ws = sharedServer.app.framework.getModule('workspace');
|
|
1066
|
+
if (!ws || !('resolveAbsolutePath' in ws)) {
|
|
1067
|
+
return new Response('Workspace not mounted', { status: 503 });
|
|
1068
|
+
}
|
|
1069
|
+
const abs = (ws as { resolveAbsolutePath: (p: string) => string | null }).resolveAbsolutePath(mountPrefixed);
|
|
1070
|
+
if (!abs) return new Response('Forbidden', { status: 403 });
|
|
1071
|
+
|
|
1072
|
+
try {
|
|
1073
|
+
const data = await readFile(abs);
|
|
1074
|
+
return new Response(data, { headers: { 'content-type': mimeFor(abs) } });
|
|
1075
|
+
} catch {
|
|
1076
|
+
return new Response('Not found', { status: 404 });
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
private async serveStatic(requestedPath: string): Promise<Response> {
|
|
1081
|
+
// Path containment: resolve and verify the result is still under staticRoot.
|
|
1082
|
+
// Plain startsWith without a separator is unsafe — both `<root>` and
|
|
1083
|
+
// `<root>-evil/...` pass `startsWith('<root>')`. The current callers
|
|
1084
|
+
// pass relative paths so this is unreachable today, but a future
|
|
1085
|
+
// refactor that lets absolute paths slip through would turn it into a
|
|
1086
|
+
// real escape; require either an exact match or a trailing separator.
|
|
1087
|
+
const root = sharedServer!.staticRoot;
|
|
1088
|
+
const safePath = normalize(join(root, requestedPath));
|
|
1089
|
+
if (safePath !== root && !safePath.startsWith(root + pathSep)) {
|
|
1090
|
+
return new Response('Forbidden', { status: 403 });
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
try {
|
|
1094
|
+
const s = await stat(safePath);
|
|
1095
|
+
if (s.isDirectory()) {
|
|
1096
|
+
return this.serveStatic(join(requestedPath, 'index.html'));
|
|
1097
|
+
}
|
|
1098
|
+
const data = await readFile(safePath);
|
|
1099
|
+
return new Response(data, { headers: { 'content-type': mimeFor(safePath) } });
|
|
1100
|
+
} catch {
|
|
1101
|
+
// Fall back to index.html so the SPA can handle client-side routing.
|
|
1102
|
+
try {
|
|
1103
|
+
const indexPath = join(sharedServer!.staticRoot, 'index.html');
|
|
1104
|
+
const data = await readFile(indexPath);
|
|
1105
|
+
return new Response(data, { headers: { 'content-type': 'text/html' } });
|
|
1106
|
+
} catch {
|
|
1107
|
+
return new Response(
|
|
1108
|
+
`WebUI bundle not found at ${sharedServer!.staticRoot}. Run \`npm run build:web\` (or postinstall) to produce it.`,
|
|
1109
|
+
{ status: 503, headers: { 'content-type': 'text/plain' } },
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// -------------------------------------------------------------------------
|
|
1116
|
+
// WebSocket lifecycle
|
|
1117
|
+
// -------------------------------------------------------------------------
|
|
1118
|
+
|
|
1119
|
+
private onWsOpen(ws: ServerWebSocket<{ id: number }>): void {
|
|
1120
|
+
const id = ws.data.id;
|
|
1121
|
+
const client: ClientState = { id, ws, welcomed: false, peeks: new Map() };
|
|
1122
|
+
sharedServer!.clients.set(id, client);
|
|
1123
|
+
|
|
1124
|
+
if (sharedServer?.app) void this.sendWelcome(client);
|
|
1125
|
+
// Else: park until setApp() flushes welcomes.
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
private onWsMessage(ws: ServerWebSocket<{ id: number }>, raw: string | Buffer): void {
|
|
1129
|
+
const id = ws.data.id;
|
|
1130
|
+
const client = sharedServer!.clients.get(id);
|
|
1131
|
+
if (!client) return;
|
|
1132
|
+
|
|
1133
|
+
let parsed: unknown;
|
|
1134
|
+
try {
|
|
1135
|
+
parsed = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf-8'));
|
|
1136
|
+
} catch {
|
|
1137
|
+
this.send(client, { type: 'error', message: 'invalid JSON' });
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
if (!isClientMessage(parsed)) {
|
|
1141
|
+
this.send(client, { type: 'error', message: 'unknown message shape' });
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
if (!sharedServer?.app) {
|
|
1146
|
+
this.send(client, { type: 'error', message: 'host not ready' });
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
switch (parsed.type) {
|
|
1151
|
+
case 'ping':
|
|
1152
|
+
// No reply — round-trip already confirmed by the message arriving.
|
|
1153
|
+
return;
|
|
1154
|
+
|
|
1155
|
+
case 'user-message':
|
|
1156
|
+
sharedServer?.app.framework.pushEvent({
|
|
1157
|
+
type: 'external-message',
|
|
1158
|
+
source: 'tui',
|
|
1159
|
+
content: parsed.content,
|
|
1160
|
+
metadata: {},
|
|
1161
|
+
triggerInference: true,
|
|
1162
|
+
});
|
|
1163
|
+
return;
|
|
1164
|
+
|
|
1165
|
+
case 'command': {
|
|
1166
|
+
void this.dispatchCommand(client, parsed.command, parsed.corrId);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
case 'request-history': {
|
|
1171
|
+
this.handleRequestHistory(client, parsed);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
case 'route-to-child': {
|
|
1176
|
+
void this.handleRouteToChild(client, parsed.childName, parsed.content);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
case 'interrupt': {
|
|
1181
|
+
if (!sharedServer?.app) return;
|
|
1182
|
+
const fw = sharedServer.app.framework;
|
|
1183
|
+
// Cancel any in-process subagents so their results propagate.
|
|
1184
|
+
const subMod = fw.getAllModules().find((m) => m.name === 'subagent') as
|
|
1185
|
+
| { cancelAll(): number }
|
|
1186
|
+
| undefined;
|
|
1187
|
+
const cancelled = subMod?.cancelAll() ?? 0;
|
|
1188
|
+
for (const agent of fw.getAllAgents()) {
|
|
1189
|
+
try { agent.cancelStream(); } catch { /* idempotent */ }
|
|
1190
|
+
}
|
|
1191
|
+
this.send(client, {
|
|
1192
|
+
type: 'command-result',
|
|
1193
|
+
lines: [{
|
|
1194
|
+
text: cancelled > 0 ? `interrupted — ${cancelled} subagent(s) stopped` : 'interrupted',
|
|
1195
|
+
style: 'system',
|
|
1196
|
+
}],
|
|
1197
|
+
});
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
case 'subscribe-peek':
|
|
1202
|
+
this.handleSubscribePeek(client, parsed.scope, parsed.active);
|
|
1203
|
+
return;
|
|
1204
|
+
|
|
1205
|
+
case 'cancel-subagent': {
|
|
1206
|
+
if (!sharedServer?.app) return;
|
|
1207
|
+
// Route to the owning fleet child if the WUI told us which one to
|
|
1208
|
+
// target. Subagents inside a fleet child (e.g. Clerk-spawned forks)
|
|
1209
|
+
// can't be cancelled locally — the conductor's framework doesn't have
|
|
1210
|
+
// their SubagentModule.
|
|
1211
|
+
if (parsed.childName) {
|
|
1212
|
+
this.routeFleetRequest(client, parsed.childName, 'cancel-subagent',
|
|
1213
|
+
(corrId, fleet) => fleet.cancelSubagentOnChild(parsed.childName!, parsed.name, corrId));
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
const subMod = sharedServer.app.framework
|
|
1217
|
+
.getAllModules()
|
|
1218
|
+
.find((m) => m.name === 'subagent') as
|
|
1219
|
+
| { cancelSubagent(name: string): boolean }
|
|
1220
|
+
| undefined;
|
|
1221
|
+
if (!subMod) {
|
|
1222
|
+
this.send(client, { type: 'error', message: 'subagent module not loaded' });
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const ok = subMod.cancelSubagent(parsed.name);
|
|
1226
|
+
this.send(client, {
|
|
1227
|
+
type: 'command-result',
|
|
1228
|
+
lines: [{
|
|
1229
|
+
text: ok ? `cancelled subagent ${parsed.name}` : `subagent ${parsed.name} not running`,
|
|
1230
|
+
style: ok ? 'system' : 'tool',
|
|
1231
|
+
}],
|
|
1232
|
+
});
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
case 'fleet-stop':
|
|
1237
|
+
case 'fleet-restart': {
|
|
1238
|
+
void this.handleFleetControl(client, parsed.type, parsed.name);
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
case 'quit-confirm': {
|
|
1243
|
+
void this.handleQuitConfirm(parsed.action);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
case 'request-lessons': {
|
|
1248
|
+
if (parsed.scope && parsed.scope !== 'local') {
|
|
1249
|
+
this.routeFleetRequest(client, parsed.scope, 'lessons',
|
|
1250
|
+
(corrId, fleet) => fleet.requestLessons(parsed.scope!, corrId));
|
|
1251
|
+
} else {
|
|
1252
|
+
this.sendLessonsList(client);
|
|
1253
|
+
}
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
case 'request-mcpl': {
|
|
1258
|
+
this.sendMcplList(client);
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
case 'mcpl-add': {
|
|
1263
|
+
try {
|
|
1264
|
+
const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
|
|
1265
|
+
servers[parsed.id] = {
|
|
1266
|
+
command: parsed.command,
|
|
1267
|
+
...(parsed.args && parsed.args.length > 0 ? { args: parsed.args } : {}),
|
|
1268
|
+
...(parsed.env && Object.keys(parsed.env).length > 0 ? { env: parsed.env } : {}),
|
|
1269
|
+
...(parsed.toolPrefix ? { toolPrefix: parsed.toolPrefix } : {}),
|
|
1270
|
+
};
|
|
1271
|
+
saveMcplServers(DEFAULT_CONFIG_PATH, servers);
|
|
1272
|
+
} catch (err) {
|
|
1273
|
+
this.send(client, { type: 'error', message: `mcpl-add failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
this.sendMcplList(client);
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
case 'mcpl-remove': {
|
|
1281
|
+
try {
|
|
1282
|
+
const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
|
|
1283
|
+
if (!(parsed.id in servers)) {
|
|
1284
|
+
this.send(client, { type: 'error', message: `server '${parsed.id}' not found` });
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
delete servers[parsed.id];
|
|
1288
|
+
saveMcplServers(DEFAULT_CONFIG_PATH, servers);
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
this.send(client, { type: 'error', message: `mcpl-remove failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
this.sendMcplList(client);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
case 'mcpl-set-env': {
|
|
1298
|
+
try {
|
|
1299
|
+
const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
|
|
1300
|
+
const entry = servers[parsed.id];
|
|
1301
|
+
if (!entry) {
|
|
1302
|
+
this.send(client, { type: 'error', message: `server '${parsed.id}' not found` });
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
// Replace env wholesale; empty object clears it.
|
|
1306
|
+
if (Object.keys(parsed.env).length === 0) delete entry.env;
|
|
1307
|
+
else entry.env = parsed.env;
|
|
1308
|
+
saveMcplServers(DEFAULT_CONFIG_PATH, servers);
|
|
1309
|
+
} catch (err) {
|
|
1310
|
+
this.send(client, { type: 'error', message: `mcpl-set-env failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
this.sendMcplList(client);
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
case 'request-workspace-mounts': {
|
|
1318
|
+
if (parsed.scope && parsed.scope !== 'local') {
|
|
1319
|
+
this.routeFleetRequest(client, parsed.scope, 'workspace-mounts',
|
|
1320
|
+
(corrId, fleet) => fleet.requestWorkspaceMounts(parsed.scope!, corrId));
|
|
1321
|
+
} else {
|
|
1322
|
+
void this.sendWorkspaceMounts(client);
|
|
1323
|
+
}
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
case 'request-workspace-tree': {
|
|
1328
|
+
if (parsed.scope && parsed.scope !== 'local') {
|
|
1329
|
+
this.routeFleetRequest(client, parsed.scope, 'workspace-tree',
|
|
1330
|
+
(corrId, fleet) => fleet.requestWorkspaceTree(parsed.scope!, parsed.mount, corrId));
|
|
1331
|
+
} else {
|
|
1332
|
+
void this.sendWorkspaceTree(client, parsed.mount);
|
|
1333
|
+
}
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
case 'request-workspace-file': {
|
|
1338
|
+
if (parsed.scope && parsed.scope !== 'local') {
|
|
1339
|
+
this.routeFleetRequest(client, parsed.scope, 'workspace-file',
|
|
1340
|
+
(corrId, fleet) => fleet.requestWorkspaceFile(parsed.scope!, parsed.path, corrId));
|
|
1341
|
+
} else {
|
|
1342
|
+
void this.sendWorkspaceFileRead(client, parsed.path);
|
|
1343
|
+
}
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** Generate a corrId, register the requesting client, and dispatch a
|
|
1350
|
+
* request to the fleet child. The reply lands in handleFleetEvent which
|
|
1351
|
+
* looks the corrId up in pendingFleetRequests to find the client. */
|
|
1352
|
+
private routeFleetRequest(
|
|
1353
|
+
client: ClientState,
|
|
1354
|
+
childName: string,
|
|
1355
|
+
kind: string,
|
|
1356
|
+
dispatch: (corrId: string, fleet: FleetModule) => boolean,
|
|
1357
|
+
): void {
|
|
1358
|
+
if (!sharedServer?.app) return;
|
|
1359
|
+
// Sweep expired entries before we add a new one. Without this the map
|
|
1360
|
+
// grows unbounded whenever a child is wedged — every "refresh files"
|
|
1361
|
+
// click pins another corrId until process exit. The sweep notifies the
|
|
1362
|
+
// originating client of the timeout instead of swallowing it.
|
|
1363
|
+
this.pruneExpiredFleetRequests();
|
|
1364
|
+
const fleet = sharedServer.app.framework.getAllModules().find((m) => m.name === 'fleet') as
|
|
1365
|
+
| FleetModule | undefined;
|
|
1366
|
+
if (!fleet) {
|
|
1367
|
+
this.send(client, { type: 'error', message: `fleet module not loaded` });
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
const corrId = `webui-${kind}-${client.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1371
|
+
sharedServer.pendingFleetRequests.set(corrId, {
|
|
1372
|
+
clientId: client.id,
|
|
1373
|
+
kind,
|
|
1374
|
+
// 30s TTL — lessons/workspace queries are quick; if the child is wedged,
|
|
1375
|
+
// we don't want pending entries piling up forever.
|
|
1376
|
+
expiresAt: Date.now() + 30_000,
|
|
1377
|
+
});
|
|
1378
|
+
const ok = dispatch(corrId, fleet);
|
|
1379
|
+
if (!ok) {
|
|
1380
|
+
sharedServer.pendingFleetRequests.delete(corrId);
|
|
1381
|
+
this.send(client, { type: 'error', message: `child '${childName}' is not available` });
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** Drop expired entries from `pendingFleetRequests` and notify the
|
|
1386
|
+
* originating client of each one. Idempotent — handlers tolerate the
|
|
1387
|
+
* late-arriving real reply (entry just won't be in the map anymore). */
|
|
1388
|
+
private pruneExpiredFleetRequests(): void {
|
|
1389
|
+
if (!sharedServer) return;
|
|
1390
|
+
const now = Date.now();
|
|
1391
|
+
for (const [corrId, entry] of sharedServer.pendingFleetRequests) {
|
|
1392
|
+
if (entry.expiresAt > now) continue;
|
|
1393
|
+
sharedServer.pendingFleetRequests.delete(corrId);
|
|
1394
|
+
const client = sharedServer.clients.get(entry.clientId);
|
|
1395
|
+
if (!client) continue;
|
|
1396
|
+
this.send(client, {
|
|
1397
|
+
type: 'error',
|
|
1398
|
+
message: `${entry.kind} request timed out (child unresponsive after 30s)`,
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/** Workspace surface — three small wrappers over the WorkspaceModule's
|
|
1404
|
+
* public tools (`ls`, `read`). Going through tools instead of internals
|
|
1405
|
+
* keeps the SPA decoupled from module implementation details. */
|
|
1406
|
+
|
|
1407
|
+
private async workspaceMod(): Promise<
|
|
1408
|
+
| { handleToolCall(call: { name: string; input: unknown; id?: string }): Promise<{ success: boolean; data?: unknown; error?: string }> }
|
|
1409
|
+
| undefined
|
|
1410
|
+
> {
|
|
1411
|
+
if (!sharedServer?.app) return undefined;
|
|
1412
|
+
return sharedServer.app.framework.getAllModules().find((m) => m.name === 'workspace') as
|
|
1413
|
+
| { handleToolCall(call: { name: string; input: unknown; id?: string }): Promise<{ success: boolean; data?: unknown; error?: string }> }
|
|
1414
|
+
| undefined;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
private async sendWorkspaceMounts(client: ClientState): Promise<void> {
|
|
1418
|
+
const mod = await this.workspaceMod();
|
|
1419
|
+
if (!mod) {
|
|
1420
|
+
this.send(client, { type: 'workspace-mounts', loaded: false, mounts: [] });
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
try {
|
|
1424
|
+
const result = await mod.handleToolCall({ name: 'ls', input: {}, id: `webui-ls-${Date.now()}` });
|
|
1425
|
+
const data = (result.data ?? {}) as { mounts?: Array<{ name: string; path: string; mode: string }> };
|
|
1426
|
+
this.send(client, {
|
|
1427
|
+
type: 'workspace-mounts',
|
|
1428
|
+
loaded: true,
|
|
1429
|
+
mounts: data.mounts ?? [],
|
|
1430
|
+
});
|
|
1431
|
+
} catch (err) {
|
|
1432
|
+
this.send(client, { type: 'error', message: `workspace mounts failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
private async sendWorkspaceTree(client: ClientState, mount: string): Promise<void> {
|
|
1437
|
+
const mod = await this.workspaceMod();
|
|
1438
|
+
if (!mod) {
|
|
1439
|
+
this.send(client, { type: 'error', message: 'workspace module not loaded' });
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
try {
|
|
1443
|
+
const result = await mod.handleToolCall({
|
|
1444
|
+
name: 'ls',
|
|
1445
|
+
input: { path: mount, recursive: true },
|
|
1446
|
+
id: `webui-tree-${Date.now()}`,
|
|
1447
|
+
});
|
|
1448
|
+
if (!result.success) {
|
|
1449
|
+
this.send(client, { type: 'error', message: `workspace ls failed: ${result.error ?? 'unknown'}` });
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const data = (result.data ?? {}) as { entries?: Array<{ path: string; size: number }> };
|
|
1453
|
+
this.send(client, {
|
|
1454
|
+
type: 'workspace-tree',
|
|
1455
|
+
mount,
|
|
1456
|
+
entries: data.entries ?? [],
|
|
1457
|
+
});
|
|
1458
|
+
} catch (err) {
|
|
1459
|
+
this.send(client, { type: 'error', message: `workspace ls failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
private async sendWorkspaceFileRead(client: ClientState, path: string): Promise<void> {
|
|
1464
|
+
const mod = await this.workspaceMod();
|
|
1465
|
+
if (!mod) {
|
|
1466
|
+
this.send(client, { type: 'error', message: 'workspace module not loaded' });
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
// Cap responses by both lines AND bytes. Lines alone don't bound the
|
|
1470
|
+
// wire frame: a minified bundle, JSON-on-one-line, or infolog.txt with
|
|
1471
|
+
// embedded base64 can run 5k lines and still be hundreds of MB. The
|
|
1472
|
+
// byte cap (256 KB) is the actual safety net — operators reading
|
|
1473
|
+
// larger files should drop into a shell on the host.
|
|
1474
|
+
const LINE_LIMIT = 5000;
|
|
1475
|
+
const BYTE_LIMIT = 256 * 1024;
|
|
1476
|
+
try {
|
|
1477
|
+
const result = await mod.handleToolCall({
|
|
1478
|
+
name: 'read',
|
|
1479
|
+
input: { path, limit: LINE_LIMIT },
|
|
1480
|
+
id: `webui-read-${Date.now()}`,
|
|
1481
|
+
});
|
|
1482
|
+
if (!result.success) {
|
|
1483
|
+
this.send(client, { type: 'error', message: `read ${path} failed: ${result.error ?? 'unknown'}` });
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
const data = (result.data ?? {}) as {
|
|
1487
|
+
path?: string;
|
|
1488
|
+
totalLines?: number;
|
|
1489
|
+
fromLine?: number;
|
|
1490
|
+
toLine?: number;
|
|
1491
|
+
content?: string;
|
|
1492
|
+
};
|
|
1493
|
+
const totalLines = data.totalLines ?? 0;
|
|
1494
|
+
const reportedToLine = data.toLine ?? totalLines;
|
|
1495
|
+
let content = data.content ?? '';
|
|
1496
|
+
let toLine = reportedToLine;
|
|
1497
|
+
let truncatedByBytes = false;
|
|
1498
|
+
if (Buffer.byteLength(content, 'utf-8') > BYTE_LIMIT) {
|
|
1499
|
+
// Truncate at a UTF-8 boundary at-or-before BYTE_LIMIT bytes, then
|
|
1500
|
+
// adjust toLine to the last full line in the truncated content so
|
|
1501
|
+
// the SPA doesn't draw a half-line at the bottom.
|
|
1502
|
+
const truncated = sliceUtf8(content, BYTE_LIMIT);
|
|
1503
|
+
const lastNl = truncated.lastIndexOf('\n');
|
|
1504
|
+
content = lastNl >= 0 ? truncated.slice(0, lastNl) : truncated;
|
|
1505
|
+
const fromLine = data.fromLine ?? 1;
|
|
1506
|
+
toLine = fromLine + content.split('\n').length - 1;
|
|
1507
|
+
truncatedByBytes = true;
|
|
1508
|
+
}
|
|
1509
|
+
this.send(client, {
|
|
1510
|
+
type: 'workspace-file',
|
|
1511
|
+
path: data.path ?? path,
|
|
1512
|
+
totalLines,
|
|
1513
|
+
fromLine: data.fromLine ?? 1,
|
|
1514
|
+
toLine,
|
|
1515
|
+
content,
|
|
1516
|
+
truncated: truncatedByBytes || toLine < totalLines,
|
|
1517
|
+
});
|
|
1518
|
+
} catch (err) {
|
|
1519
|
+
this.send(client, { type: 'error', message: `read ${path} failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/** Read mcpl-servers.json and ship the list to a single client. The bound
|
|
1524
|
+
* config path is whatever the host's mcpl-config module resolves at
|
|
1525
|
+
* module-load time — usually `<cwd>/mcpl-servers.json`. */
|
|
1526
|
+
private sendMcplList(client: ClientState): void {
|
|
1527
|
+
let servers: ReturnType<typeof readMcplServersFile> = {};
|
|
1528
|
+
try { servers = readMcplServersFile(DEFAULT_CONFIG_PATH); }
|
|
1529
|
+
catch { /* missing or malformed file → empty list */ }
|
|
1530
|
+
const out: McplListMessage = {
|
|
1531
|
+
type: 'mcpl-list',
|
|
1532
|
+
configPath: DEFAULT_CONFIG_PATH,
|
|
1533
|
+
servers: Object.entries(servers).map(([id, entry]) => ({
|
|
1534
|
+
id,
|
|
1535
|
+
command: entry.command,
|
|
1536
|
+
...(entry.args ? { args: entry.args } : {}),
|
|
1537
|
+
...(entry.env ? { env: entry.env } : {}),
|
|
1538
|
+
...(entry.toolPrefix ? { toolPrefix: entry.toolPrefix } : {}),
|
|
1539
|
+
...(entry.reconnect !== undefined ? { reconnect: entry.reconnect } : {}),
|
|
1540
|
+
...(entry.enabledFeatureSets ? { enabledFeatureSets: entry.enabledFeatureSets } : {}),
|
|
1541
|
+
...(entry.disabledFeatureSets ? { disabledFeatureSets: entry.disabledFeatureSets } : {}),
|
|
1542
|
+
})),
|
|
1543
|
+
};
|
|
1544
|
+
this.send(client, out);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/** Build a LessonsListMessage from the bound LessonsModule, if present. */
|
|
1548
|
+
private sendLessonsList(client: ClientState): void {
|
|
1549
|
+
if (!sharedServer?.app) return;
|
|
1550
|
+
const lessonsMod = sharedServer.app.framework.getAllModules().find((m) => m.name === 'lessons') as
|
|
1551
|
+
| { getLessons(): Array<{ id: string; content: string; confidence: number; tags: string[]; deprecated: boolean; deprecationReason?: string; created?: number; updated?: number }> }
|
|
1552
|
+
| undefined;
|
|
1553
|
+
if (!lessonsMod) {
|
|
1554
|
+
this.send(client, { type: 'lessons-list', loaded: false, lessons: [] });
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
const lessons = lessonsMod.getLessons().map(l => ({
|
|
1558
|
+
id: l.id,
|
|
1559
|
+
content: l.content,
|
|
1560
|
+
confidence: l.confidence,
|
|
1561
|
+
tags: l.tags,
|
|
1562
|
+
deprecated: l.deprecated,
|
|
1563
|
+
...(l.deprecationReason ? { deprecationReason: l.deprecationReason } : {}),
|
|
1564
|
+
...(typeof l.created === 'number' ? { created: l.created } : {}),
|
|
1565
|
+
...(typeof l.updated === 'number' ? { updated: l.updated } : {}),
|
|
1566
|
+
}));
|
|
1567
|
+
this.send(client, { type: 'lessons-list', loaded: true, lessons });
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/** Names of fleet children currently running. Empty when no fleet module
|
|
1571
|
+
* is mounted or every child has stopped. */
|
|
1572
|
+
private runningFleetChildren(): string[] {
|
|
1573
|
+
if (!sharedServer?.app) return [];
|
|
1574
|
+
const fleetMod = sharedServer.app.framework.getAllModules().find((m) => m.name === 'fleet') as
|
|
1575
|
+
| { getChildren(): ReadonlyMap<string, { status: string }> }
|
|
1576
|
+
| undefined;
|
|
1577
|
+
if (!fleetMod) return [];
|
|
1578
|
+
const out: string[] = [];
|
|
1579
|
+
for (const [name, child] of fleetMod.getChildren()) {
|
|
1580
|
+
if (child.status === 'ready' || child.status === 'starting') out.push(name);
|
|
1581
|
+
}
|
|
1582
|
+
return out;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/** Defer SIGTERM so the WS frame flushes, then trigger the existing
|
|
1586
|
+
* graceful-shutdown handler. process.exit fallback covers the case where
|
|
1587
|
+
* no SIGTERM listener is registered (e.g. TUI mode). */
|
|
1588
|
+
private scheduleShutdown(): void {
|
|
1589
|
+
setTimeout(() => {
|
|
1590
|
+
try { process.kill(process.pid, 'SIGTERM'); }
|
|
1591
|
+
catch { process.exit(0); }
|
|
1592
|
+
}, 150);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/** Honor the operator's response to a quit-confirm-required prompt.
|
|
1596
|
+
* kill-children: stop them gracefully, then SIGTERM. Detach: SIGTERM
|
|
1597
|
+
* immediately and let them orphan. Cancel: keep the host running. */
|
|
1598
|
+
private async handleQuitConfirm(action: 'kill-children' | 'detach' | 'cancel'): Promise<void> {
|
|
1599
|
+
if (action === 'cancel') return;
|
|
1600
|
+
if (action === 'detach') {
|
|
1601
|
+
this.scheduleShutdown();
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
// kill-children: dispatch fleet kills in parallel and wait briefly.
|
|
1605
|
+
const running = this.runningFleetChildren();
|
|
1606
|
+
const fleetMod = sharedServer?.app?.framework.getAllModules().find((m) => m.name === 'fleet') as
|
|
1607
|
+
| { handleToolCall(call: { name: string; input: unknown; id?: string }): Promise<{ success: boolean; error?: string }> }
|
|
1608
|
+
| undefined;
|
|
1609
|
+
if (fleetMod) {
|
|
1610
|
+
await Promise.allSettled(running.map(name => fleetMod.handleToolCall({
|
|
1611
|
+
name: 'kill',
|
|
1612
|
+
input: { name },
|
|
1613
|
+
id: `webui-quit-${Date.now()}-${name}`,
|
|
1614
|
+
})));
|
|
1615
|
+
}
|
|
1616
|
+
this.scheduleShutdown();
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
private async handleFleetControl(client: ClientState, op: 'fleet-stop' | 'fleet-restart', name: string): Promise<void> {
|
|
1620
|
+
if (!sharedServer?.app) return;
|
|
1621
|
+
const fleetMod = sharedServer.app.framework
|
|
1622
|
+
.getAllModules()
|
|
1623
|
+
.find((m) => m.name === 'fleet') as
|
|
1624
|
+
| { handleToolCall(call: { name: string; input: unknown; id?: string }): Promise<{ success: boolean; data?: unknown; error?: string }> }
|
|
1625
|
+
| undefined;
|
|
1626
|
+
if (!fleetMod) {
|
|
1627
|
+
this.send(client, { type: 'error', message: 'fleet module not loaded' });
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
const tool = op === 'fleet-stop' ? 'kill' : 'restart';
|
|
1631
|
+
try {
|
|
1632
|
+
const result = await fleetMod.handleToolCall({
|
|
1633
|
+
name: tool,
|
|
1634
|
+
input: { name },
|
|
1635
|
+
id: `webui-${op}-${Date.now()}`,
|
|
1636
|
+
});
|
|
1637
|
+
const text = result.success
|
|
1638
|
+
? `${op === 'fleet-stop' ? 'stopped' : 'restarted'} ${name}`
|
|
1639
|
+
: `${op} ${name} failed: ${result.error ?? 'unknown'}`;
|
|
1640
|
+
this.send(client, {
|
|
1641
|
+
type: 'command-result',
|
|
1642
|
+
lines: [{ text, style: result.success ? 'system' : 'tool' }],
|
|
1643
|
+
});
|
|
1644
|
+
} catch (err) {
|
|
1645
|
+
this.send(client, {
|
|
1646
|
+
type: 'error',
|
|
1647
|
+
message: `${op} ${name} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Open or close a peek window for a subagent or fleet child.
|
|
1654
|
+
*
|
|
1655
|
+
* For in-process subagents we hook SubagentModule.onPeekStream(name) and
|
|
1656
|
+
* forward each event as a `peek` message scoped to the subagent name.
|
|
1657
|
+
*
|
|
1658
|
+
* For fleet children we don't need a separate subscription — child events
|
|
1659
|
+
* already flow to all welcomed clients via handleFleetEvent. Returning
|
|
1660
|
+
* "fleet child" here is enough to confirm the panel can rely on the
|
|
1661
|
+
* existing stream.
|
|
1662
|
+
*/
|
|
1663
|
+
private handleSubscribePeek(client: ClientState, scope: string, active: boolean): void {
|
|
1664
|
+
if (!active) {
|
|
1665
|
+
const detach = client.peeks.get(scope);
|
|
1666
|
+
if (detach) {
|
|
1667
|
+
try { detach(); } catch { /* ignore */ }
|
|
1668
|
+
client.peeks.delete(scope);
|
|
1669
|
+
}
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Idempotent: re-subscribing to an already-open scope is a no-op.
|
|
1674
|
+
if (client.peeks.has(scope)) return;
|
|
1675
|
+
|
|
1676
|
+
if (!sharedServer?.app) return;
|
|
1677
|
+
|
|
1678
|
+
// Fleet child path — events already arrive via child-event; no separate
|
|
1679
|
+
// subscription is needed. Mark the slot so unsubscribe-peek symmetry
|
|
1680
|
+
// works without special-casing.
|
|
1681
|
+
const fleetMod = sharedServer.app.framework
|
|
1682
|
+
.getAllModules()
|
|
1683
|
+
.find((m) => m.name === 'fleet') as { getChildren(): ReadonlyMap<string, unknown> } | undefined;
|
|
1684
|
+
if (fleetMod && fleetMod.getChildren().has(scope)) {
|
|
1685
|
+
client.peeks.set(scope, () => { /* no-op detach */ });
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// In-process subagent path — register on SubagentModule.onPeekStream.
|
|
1690
|
+
const subMod = sharedServer.app.framework
|
|
1691
|
+
.getAllModules()
|
|
1692
|
+
.find((m) => m.name === 'subagent') as
|
|
1693
|
+
| {
|
|
1694
|
+
onPeekStream(name: string, cb: (ev: { type: string; [k: string]: unknown }) => void): () => void;
|
|
1695
|
+
peek(name?: string): Promise<Array<{
|
|
1696
|
+
name: string;
|
|
1697
|
+
status: string;
|
|
1698
|
+
messageCount: number;
|
|
1699
|
+
lastMessageSnippet: string;
|
|
1700
|
+
currentStream: string;
|
|
1701
|
+
pendingToolCalls: Array<{ name: string; input?: unknown }>;
|
|
1702
|
+
elapsedMs: number;
|
|
1703
|
+
isZombie: boolean;
|
|
1704
|
+
}>>;
|
|
1705
|
+
}
|
|
1706
|
+
| undefined;
|
|
1707
|
+
if (!subMod) {
|
|
1708
|
+
this.send(client, { type: 'error', message: `subscribe-peek: scope '${scope}' not found` });
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
// Backfill: send a one-shot summary derived from the subagent's current
|
|
1712
|
+
// peek snapshot before live events start. Operators opening a peek panel
|
|
1713
|
+
// shouldn't see "Waiting for events…" when the agent is mid-task — the
|
|
1714
|
+
// peek already knows what's in flight.
|
|
1715
|
+
void this.sendPeekBackfill(client, subMod, scope);
|
|
1716
|
+
const detach = subMod.onPeekStream(scope, (event) => {
|
|
1717
|
+
this.send(client, {
|
|
1718
|
+
type: 'peek',
|
|
1719
|
+
scope,
|
|
1720
|
+
event: event as { type: string; [k: string]: unknown },
|
|
1721
|
+
});
|
|
1722
|
+
});
|
|
1723
|
+
client.peeks.set(scope, detach);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/** Push a synthetic backfill bundle for a subagent peek subscription so the
|
|
1727
|
+
* client renders something meaningful immediately rather than waiting for
|
|
1728
|
+
* the next live event. Best-effort — peek may fail mid-modification. */
|
|
1729
|
+
private async sendPeekBackfill(
|
|
1730
|
+
client: ClientState,
|
|
1731
|
+
subMod: {
|
|
1732
|
+
peek(name?: string): Promise<Array<{
|
|
1733
|
+
name: string;
|
|
1734
|
+
status: string;
|
|
1735
|
+
messageCount: number;
|
|
1736
|
+
lastMessageSnippet: string;
|
|
1737
|
+
currentStream: string;
|
|
1738
|
+
pendingToolCalls: Array<{ name: string; input?: unknown }>;
|
|
1739
|
+
elapsedMs: number;
|
|
1740
|
+
isZombie: boolean;
|
|
1741
|
+
}>>;
|
|
1742
|
+
},
|
|
1743
|
+
scope: string,
|
|
1744
|
+
): Promise<void> {
|
|
1745
|
+
let snap: Awaited<ReturnType<typeof subMod.peek>>[number] | undefined;
|
|
1746
|
+
try {
|
|
1747
|
+
const snaps = await subMod.peek(scope);
|
|
1748
|
+
snap = snaps[0];
|
|
1749
|
+
} catch {
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
if (!snap) return;
|
|
1753
|
+
|
|
1754
|
+
// Header line — gives operators an at-a-glance read on what they're
|
|
1755
|
+
// looking at without scrolling for context.
|
|
1756
|
+
const headerBits: string[] = [
|
|
1757
|
+
`status=${snap.status}`,
|
|
1758
|
+
`msgs=${snap.messageCount}`,
|
|
1759
|
+
`elapsed=${Math.round(snap.elapsedMs / 1000)}s`,
|
|
1760
|
+
];
|
|
1761
|
+
if (snap.isZombie) headerBits.push('zombie');
|
|
1762
|
+
this.send(client, {
|
|
1763
|
+
type: 'peek',
|
|
1764
|
+
scope,
|
|
1765
|
+
event: { type: 'lifecycle', phase: `peek opened — ${headerBits.join(' ')}` },
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
if (snap.lastMessageSnippet) {
|
|
1769
|
+
this.send(client, {
|
|
1770
|
+
type: 'peek',
|
|
1771
|
+
scope,
|
|
1772
|
+
event: { type: 'lifecycle', phase: `last: ${snap.lastMessageSnippet.slice(-200)}` },
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
if (snap.currentStream) {
|
|
1777
|
+
// Replay accumulated stream tokens as a single tokens event; the
|
|
1778
|
+
// client folds tokens by newline so this renders as the most recent
|
|
1779
|
+
// few stream lines in cyan.
|
|
1780
|
+
this.send(client, {
|
|
1781
|
+
type: 'peek',
|
|
1782
|
+
scope,
|
|
1783
|
+
event: { type: 'tokens', content: snap.currentStream },
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
for (const call of snap.pendingToolCalls) {
|
|
1788
|
+
this.send(client, {
|
|
1789
|
+
type: 'peek',
|
|
1790
|
+
scope,
|
|
1791
|
+
event: { type: 'tool:started', tool: call.name },
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
private async handleRouteToChild(client: ClientState, childName: string, content: string): Promise<void> {
|
|
1797
|
+
if (!sharedServer?.app) return;
|
|
1798
|
+
const fleetMod = sharedServer.app.framework
|
|
1799
|
+
.getAllModules()
|
|
1800
|
+
.find((m) => m.name === 'fleet') as
|
|
1801
|
+
| { handleToolCall(call: { name: string; input: unknown; id?: string }): Promise<{ success: boolean; data?: unknown; error?: string }> }
|
|
1802
|
+
| undefined;
|
|
1803
|
+
if (!fleetMod) {
|
|
1804
|
+
this.send(client, { type: 'error', message: 'fleet module not loaded' });
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
try {
|
|
1808
|
+
const result = await fleetMod.handleToolCall({
|
|
1809
|
+
name: 'send',
|
|
1810
|
+
input: { name: childName, content },
|
|
1811
|
+
id: `webui-route-${Date.now()}`,
|
|
1812
|
+
});
|
|
1813
|
+
const text = result.success
|
|
1814
|
+
? `→ @${childName}: ${content}`
|
|
1815
|
+
: `route failed: ${result.error ?? 'unknown'}`;
|
|
1816
|
+
this.send(client, {
|
|
1817
|
+
type: 'command-result',
|
|
1818
|
+
lines: [{ text, style: result.success ? 'system' : 'tool' }],
|
|
1819
|
+
});
|
|
1820
|
+
} catch (err) {
|
|
1821
|
+
this.send(client, {
|
|
1822
|
+
type: 'error',
|
|
1823
|
+
message: `route to ${childName} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
private onWsClose(ws: ServerWebSocket<{ id: number }>): void {
|
|
1829
|
+
const id = ws.data.id;
|
|
1830
|
+
const client = sharedServer?.clients.get(id);
|
|
1831
|
+
if (client) {
|
|
1832
|
+
for (const detach of client.peeks.values()) {
|
|
1833
|
+
try { detach(); } catch { /* ignore */ }
|
|
1834
|
+
}
|
|
1835
|
+
client.peeks.clear();
|
|
1836
|
+
}
|
|
1837
|
+
sharedServer?.clients.delete(id);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
/**
|
|
1841
|
+
* Run a slash command and surface its CommandResult plus any side effects
|
|
1842
|
+
* (workspace materialization on branch change, session switch on
|
|
1843
|
+
* switchToSessionId, async follow-up). All clients see fresh welcomes after
|
|
1844
|
+
* branch / session changes since those affect framework-wide state, not
|
|
1845
|
+
* just the issuing client.
|
|
1846
|
+
*/
|
|
1847
|
+
private async dispatchCommand(client: ClientState, command: string, corrId?: string): Promise<void> {
|
|
1848
|
+
if (!sharedServer?.app) return;
|
|
1849
|
+
let result;
|
|
1850
|
+
try {
|
|
1851
|
+
result = handleCommand(command, sharedServer?.app);
|
|
1852
|
+
} catch (err) {
|
|
1853
|
+
this.send(client, {
|
|
1854
|
+
type: 'error',
|
|
1855
|
+
corrId,
|
|
1856
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1857
|
+
});
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
this.send(client, {
|
|
1862
|
+
type: 'command-result',
|
|
1863
|
+
corrId,
|
|
1864
|
+
lines: result.lines,
|
|
1865
|
+
quit: result.quit,
|
|
1866
|
+
branchChanged: result.branchChanged,
|
|
1867
|
+
switchToSessionId: result.switchToSessionId,
|
|
1868
|
+
pending: result.asyncWork !== undefined,
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
// /quit handling. If the recipe has running fleet children, hold the
|
|
1872
|
+
// shutdown and ask the operator how to handle them — same three-way
|
|
1873
|
+
// prompt as the TUI. Otherwise fall through to immediate SIGTERM.
|
|
1874
|
+
if (result.quit) {
|
|
1875
|
+
const running = this.runningFleetChildren();
|
|
1876
|
+
if (running.length > 0) {
|
|
1877
|
+
this.send(client, { type: 'quit-confirm-required', children: running });
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
this.scheduleShutdown();
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// Branch-change side effects parity with TUI / runPiped: materialize the
|
|
1884
|
+
// _config mount so gate.json etc. stay in sync, then refresh every
|
|
1885
|
+
// welcomed client by re-sending welcome with the new branch's messages.
|
|
1886
|
+
if (result.branchChanged) {
|
|
1887
|
+
await this.materializeConfigMount();
|
|
1888
|
+
this.broadcastBranchChanged();
|
|
1889
|
+
this.refreshAllWelcomes();
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// Session switch — destroys + recreates the framework. setApp() is called
|
|
1893
|
+
// by index.ts after the switch lands, which re-welcomes everyone.
|
|
1894
|
+
if (result.switchToSessionId) {
|
|
1895
|
+
try {
|
|
1896
|
+
await sharedServer?.app.switchSession(result.switchToSessionId);
|
|
1897
|
+
// setApp() re-flushes welcomes; nothing more to do here.
|
|
1898
|
+
} catch (err) {
|
|
1899
|
+
this.send(client, {
|
|
1900
|
+
type: 'error',
|
|
1901
|
+
corrId,
|
|
1902
|
+
message: `session switch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// Async follow-up (e.g. /newtopic Haiku summarization).
|
|
1908
|
+
if (result.asyncWork) {
|
|
1909
|
+
try {
|
|
1910
|
+
const follow = await result.asyncWork;
|
|
1911
|
+
this.send(client, {
|
|
1912
|
+
type: 'command-result',
|
|
1913
|
+
corrId,
|
|
1914
|
+
lines: follow.lines,
|
|
1915
|
+
quit: follow.quit,
|
|
1916
|
+
branchChanged: follow.branchChanged,
|
|
1917
|
+
switchToSessionId: follow.switchToSessionId,
|
|
1918
|
+
});
|
|
1919
|
+
if (follow.branchChanged) {
|
|
1920
|
+
await this.materializeConfigMount();
|
|
1921
|
+
this.broadcastBranchChanged();
|
|
1922
|
+
this.refreshAllWelcomes();
|
|
1923
|
+
}
|
|
1924
|
+
} catch (err) {
|
|
1925
|
+
this.send(client, {
|
|
1926
|
+
type: 'error',
|
|
1927
|
+
corrId,
|
|
1928
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
private async materializeConfigMount(): Promise<void> {
|
|
1935
|
+
if (!sharedServer?.app) return;
|
|
1936
|
+
const ws = sharedServer?.app.framework.getModule('workspace');
|
|
1937
|
+
if (!ws || !('materializeMount' in ws)) return;
|
|
1938
|
+
try {
|
|
1939
|
+
await (ws as { materializeMount: (name: string) => Promise<unknown> }).materializeMount('_config');
|
|
1940
|
+
} catch {
|
|
1941
|
+
// Materialization is best-effort; failures here shouldn't break the UI.
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
private broadcastBranchChanged(): void {
|
|
1946
|
+
if (!sharedServer?.app) return;
|
|
1947
|
+
const cm = sharedServer?.app.framework.getAllAgents()[0]?.getContextManager();
|
|
1948
|
+
if (!cm) return;
|
|
1949
|
+
const branch = cm.currentBranch();
|
|
1950
|
+
const msg: WebUiServerMessage = {
|
|
1951
|
+
type: 'branch-changed',
|
|
1952
|
+
branch: { id: branch.id, name: branch.name },
|
|
1953
|
+
};
|
|
1954
|
+
for (const c of sharedServer!.clients.values()) {
|
|
1955
|
+
if (c.welcomed) this.send(c, msg);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
private refreshAllWelcomes(): void {
|
|
1960
|
+
for (const c of sharedServer!.clients.values()) {
|
|
1961
|
+
c.welcomed = false;
|
|
1962
|
+
void this.sendWelcome(c);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// -------------------------------------------------------------------------
|
|
1967
|
+
// Outgoing — welcome, traces, etc.
|
|
1968
|
+
// -------------------------------------------------------------------------
|
|
1969
|
+
|
|
1970
|
+
private async sendWelcome(client: ClientState): Promise<void> {
|
|
1971
|
+
if (!sharedServer?.app) return;
|
|
1972
|
+
|
|
1973
|
+
const welcome = await this.buildWelcome();
|
|
1974
|
+
this.send(client, welcome);
|
|
1975
|
+
client.welcomed = true;
|
|
1976
|
+
// Live trace forwarding is driven by the single fan-out listener
|
|
1977
|
+
// installed in setApp(); membership is implicit in `sharedServer!.clients`.
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/**
|
|
1981
|
+
* Serve a page of older history ending just before `beforeIndex`. Windowed
|
|
1982
|
+
* read with bodyGroup alignment; the reply carries the same corrId so the
|
|
1983
|
+
* SPA can match it to its in-flight scroll request.
|
|
1984
|
+
*/
|
|
1985
|
+
private handleRequestHistory(client: ClientState, req: RequestHistoryMessage): void {
|
|
1986
|
+
const cm = sharedServer?.app?.framework.getAllAgents()[0]?.getContextManager();
|
|
1987
|
+
if (!cm) {
|
|
1988
|
+
this.send(client, { type: 'error', corrId: req.corrId, message: 'no context manager' });
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
const cmw = cm as unknown as WindowCapableCm;
|
|
1992
|
+
if (typeof cmw.getMessageWindow !== 'function') {
|
|
1993
|
+
this.send(client, {
|
|
1994
|
+
type: 'error', corrId: req.corrId,
|
|
1995
|
+
message: 'history paging unavailable (context-manager without windowed reads)',
|
|
1996
|
+
});
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
const limit = Math.min(req.limit ?? HISTORY_PAGE_DEFAULT, HISTORY_PAGE_MAX);
|
|
2000
|
+
const beforeIndex = Math.min(req.beforeIndex, cmw.getMessageCount());
|
|
2001
|
+
const offset = Math.max(0, beforeIndex - limit);
|
|
2002
|
+
const win = cmw.getMessageWindow(offset, beforeIndex - offset, {
|
|
2003
|
+
resolveBlobs: false,
|
|
2004
|
+
alignToBodyGroups: true,
|
|
2005
|
+
});
|
|
2006
|
+
this.send(client, {
|
|
2007
|
+
type: 'history-page',
|
|
2008
|
+
corrId: req.corrId,
|
|
2009
|
+
entries: coalesceAndFlatten(win.messages as unknown as MessageLike[], win.startIndex),
|
|
2010
|
+
startIndex: win.startIndex,
|
|
2011
|
+
totalCount: win.totalCount,
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
private async buildWelcome(): Promise<WelcomeMessage> {
|
|
2016
|
+
const app = sharedServer?.app!;
|
|
2017
|
+
const fw = app.framework;
|
|
2018
|
+
const agents = fw.getAllAgents();
|
|
2019
|
+
const session = app.sessionManager.getActiveSession();
|
|
2020
|
+
if (!session) {
|
|
2021
|
+
throw new Error('cannot build welcome: no active session');
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
// Conversation snapshot via the first agent's context manager — TAIL
|
|
2025
|
+
// WINDOW only. The full-history welcome was the 51–108s render incident
|
|
2026
|
+
// (lena, 2026-07-02): 4.6k messages materialized, flattened, and
|
|
2027
|
+
// JSON.stringify'd per client per (re)connect. Clients page older
|
|
2028
|
+
// history on demand via request-history.
|
|
2029
|
+
const cm = agents[0]?.getContextManager();
|
|
2030
|
+
let messages: WelcomeMessageEntry[] = [];
|
|
2031
|
+
let history = { startIndex: 0, totalCount: 0 };
|
|
2032
|
+
if (cm) {
|
|
2033
|
+
const cmw = cm as unknown as WindowCapableCm;
|
|
2034
|
+
if (typeof cmw.getMessageWindow === 'function' && typeof cmw.getMessageCount === 'function') {
|
|
2035
|
+
const total = cmw.getMessageCount();
|
|
2036
|
+
const start = Math.max(0, total - WELCOME_HISTORY_LIMIT);
|
|
2037
|
+
const win = cmw.getMessageWindow(start, total - start, {
|
|
2038
|
+
resolveBlobs: false,
|
|
2039
|
+
alignToBodyGroups: true,
|
|
2040
|
+
});
|
|
2041
|
+
messages = coalesceAndFlatten(win.messages as unknown as MessageLike[], win.startIndex);
|
|
2042
|
+
history = { startIndex: win.startIndex, totalCount: win.totalCount };
|
|
2043
|
+
} else {
|
|
2044
|
+
// Facade skew (older @animalabs/context-manager without windowed
|
|
2045
|
+
// reads): fall back to the historical full walk, sliced locally.
|
|
2046
|
+
const all = cm.getAllMessages() as unknown as MessageLike[];
|
|
2047
|
+
const start = Math.max(0, all.length - WELCOME_HISTORY_LIMIT);
|
|
2048
|
+
messages = coalesceAndFlatten(all.slice(start), start);
|
|
2049
|
+
history = { startIndex: start, totalCount: all.length };
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// Parent-local snapshot via a transient reducer fed by the framework's
|
|
2054
|
+
// current trace history. We have no replayable past traces, so the
|
|
2055
|
+
// initial snapshot just registers the agents — the live trace stream
|
|
2056
|
+
// takes over from there. Future: persist a parent-local reducer if
|
|
2057
|
+
// cold-attach state recovery becomes important.
|
|
2058
|
+
const localReducer = new AgentTreeReducer();
|
|
2059
|
+
localReducer.seedFrameworkAgents(agents.map(a => a.name));
|
|
2060
|
+
const localSnap: AgentTreeSnapshot = localReducer.getSnapshot();
|
|
2061
|
+
|
|
2062
|
+
// Per-child snapshots from the FleetTreeAggregator (if mounted). Each
|
|
2063
|
+
// child's reducer was either freshly seeded by `describe` on the most
|
|
2064
|
+
// recent lifecycle:ready, or empty if the child hasn't responded yet —
|
|
2065
|
+
// either way the live event stream keeps it current.
|
|
2066
|
+
const childTrees: WelcomeMessage['childTrees'] = [];
|
|
2067
|
+
if (sharedServer?.treeAggregator) {
|
|
2068
|
+
const fleetMod = sharedServer.app?.framework.getAllModules().find((m) => m.name === 'fleet') as
|
|
2069
|
+
| FleetModule | undefined;
|
|
2070
|
+
for (const name of sharedServer?.treeAggregator.getAllChildNames()) {
|
|
2071
|
+
const nodes = sharedServer?.treeAggregator.getChildNodes(name);
|
|
2072
|
+
const recipeInfo = fleetMod ? await this.loadChildRecipeInfo(fleetMod, name) : undefined;
|
|
2073
|
+
childTrees.push({
|
|
2074
|
+
name,
|
|
2075
|
+
asOfTs: Date.now(),
|
|
2076
|
+
nodes: nodes as unknown as Array<Record<string, unknown>>,
|
|
2077
|
+
callIdIndex: {},
|
|
2078
|
+
...(recipeInfo ? { recipe: recipeInfo } : {}),
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
const branch = cm?.currentBranch();
|
|
2084
|
+
|
|
2085
|
+
return {
|
|
2086
|
+
type: 'welcome',
|
|
2087
|
+
protocolVersion: WEB_PROTOCOL_VERSION,
|
|
2088
|
+
recipe: {
|
|
2089
|
+
name: app.recipe.name,
|
|
2090
|
+
description: app.recipe.description,
|
|
2091
|
+
version: app.recipe.version,
|
|
2092
|
+
},
|
|
2093
|
+
agents: agents.map(a => ({ name: a.name, model: a.model })),
|
|
2094
|
+
session: {
|
|
2095
|
+
id: session.id,
|
|
2096
|
+
name: session.name,
|
|
2097
|
+
autoNamed: !session.manuallyNamed,
|
|
2098
|
+
},
|
|
2099
|
+
branch: {
|
|
2100
|
+
id: branch?.id ?? '',
|
|
2101
|
+
name: branch?.name ?? '',
|
|
2102
|
+
},
|
|
2103
|
+
messages,
|
|
2104
|
+
history,
|
|
2105
|
+
localTree: {
|
|
2106
|
+
asOfTs: localSnap.asOfTs,
|
|
2107
|
+
nodes: localSnap.nodes as unknown as Array<Record<string, unknown>>,
|
|
2108
|
+
callIdIndex: localSnap.callIdIndex,
|
|
2109
|
+
},
|
|
2110
|
+
childTrees,
|
|
2111
|
+
usage: sharedServer!.latestUsage,
|
|
2112
|
+
...(sharedServer!.latestPerAgentCost.length > 0
|
|
2113
|
+
? { perAgentCost: sharedServer!.latestPerAgentCost }
|
|
2114
|
+
: {}),
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
private send(client: ClientState, msg: WebUiServerMessage): void {
|
|
2119
|
+
try {
|
|
2120
|
+
client.ws.send(JSON.stringify(msg));
|
|
2121
|
+
} catch {
|
|
2122
|
+
// Connection dropped between send attempts; close handler will clean up.
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// -------------------------------------------------------------------------
|
|
2127
|
+
// Auth / safety
|
|
2128
|
+
// -------------------------------------------------------------------------
|
|
2129
|
+
|
|
2130
|
+
private assertSafeBind(host: string): void {
|
|
2131
|
+
const isLoopback = host === '127.0.0.1' || host === '::1' || host === 'localhost';
|
|
2132
|
+
if (isLoopback) return;
|
|
2133
|
+
if (this.config.basicAuth) return;
|
|
2134
|
+
throw new Error(
|
|
2135
|
+
`WebUiModule refuses to bind ${host} without auth. The default bind is ` +
|
|
2136
|
+
`0.0.0.0, so any recipe that enables webui must supply basicAuth ` +
|
|
2137
|
+
`(username/password, ideally via \${ENV_VAR} substitution). Set ` +
|
|
2138
|
+
`host: '127.0.0.1' to bind loopback-only for local development, which ` +
|
|
2139
|
+
`skips the auth requirement.`,
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
/**
|
|
2144
|
+
* Validate the Origin header against the configured allowlist. An empty
|
|
2145
|
+
* allowlist means "no Origin check" — only sensible behind a proxy that
|
|
2146
|
+
* enforces it for us. Same-origin native clients (curl, custom MCP
|
|
2147
|
+
* tooling) typically send no Origin at all; we accept those as well, since
|
|
2148
|
+
* the threat model here is browsers cross-origin connecting from another
|
|
2149
|
+
* tab. Auth still gates anything sensitive.
|
|
2150
|
+
*/
|
|
2151
|
+
private checkOrigin(req: Request): boolean {
|
|
2152
|
+
if (!sharedServer) return false;
|
|
2153
|
+
const allow = sharedServer.allowedOrigins;
|
|
2154
|
+
if (allow.length === 0) return true;
|
|
2155
|
+
const origin = req.headers.get('origin');
|
|
2156
|
+
// No Origin header → not a browser cross-origin attempt. (Browsers
|
|
2157
|
+
// always set Origin on WebSocket upgrades; non-browser clients usually
|
|
2158
|
+
// don't.)
|
|
2159
|
+
if (!origin) return true;
|
|
2160
|
+
if (allow.includes(origin)) return true;
|
|
2161
|
+
// Same-origin: the page making this upgrade was served by this very
|
|
2162
|
+
// server, just via a hostname the static default list can't predict —
|
|
2163
|
+
// Tailscale IP, MagicDNS name, LAN hostname. A hostile page on another
|
|
2164
|
+
// origin cannot forge its Origin header, so "Origin host equals the
|
|
2165
|
+
// request's Host header" is safe to allow and is exactly the case the
|
|
2166
|
+
// localhost-only default was wrongly rejecting (page loads over HTTP,
|
|
2167
|
+
// then every ws:// upgrade 403s).
|
|
2168
|
+
try {
|
|
2169
|
+
const originHost = new URL(origin).host;
|
|
2170
|
+
const host = req.headers.get('host');
|
|
2171
|
+
if (host && originHost === host) return true;
|
|
2172
|
+
} catch {
|
|
2173
|
+
// Malformed Origin — fall through to reject.
|
|
2174
|
+
}
|
|
2175
|
+
return false;
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
private checkAuth(req: Request): boolean {
|
|
2179
|
+
if (!this.config.basicAuth) return true;
|
|
2180
|
+
const header = req.headers.get('authorization');
|
|
2181
|
+
if (!header || !header.toLowerCase().startsWith('basic ')) return false;
|
|
2182
|
+
let decoded: string;
|
|
2183
|
+
try {
|
|
2184
|
+
decoded = Buffer.from(header.slice(6).trim(), 'base64').toString('utf-8');
|
|
2185
|
+
} catch {
|
|
2186
|
+
return false;
|
|
2187
|
+
}
|
|
2188
|
+
const idx = decoded.indexOf(':');
|
|
2189
|
+
if (idx < 0) return false;
|
|
2190
|
+
const user = decoded.slice(0, idx);
|
|
2191
|
+
const pass = decoded.slice(idx + 1);
|
|
2192
|
+
// Use SHA-256 digests so the timing-safe compare runs over fixed-length
|
|
2193
|
+
// buffers regardless of credential length, and a wrong-length input
|
|
2194
|
+
// doesn't bail early via the length-mismatch path. Both halves are
|
|
2195
|
+
// always compared so a mismatch in `user` doesn't short-circuit `pass`.
|
|
2196
|
+
const userOk = constantTimeStringEq(user, this.config.basicAuth.username);
|
|
2197
|
+
const passOk = constantTimeStringEq(pass, this.config.basicAuth.password);
|
|
2198
|
+
return userOk && passOk;
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
private unauthorized(): Response {
|
|
2202
|
+
return new Response('Unauthorized', {
|
|
2203
|
+
status: 401,
|
|
2204
|
+
headers: { 'www-authenticate': 'Basic realm="connectome-host"' },
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// ---------------------------------------------------------------------------
|
|
2210
|
+
// Helpers
|
|
2211
|
+
// ---------------------------------------------------------------------------
|
|
2212
|
+
|
|
2213
|
+
/**
|
|
2214
|
+
* Default Origin allowlist for the WebSocket upgrade. Covers the recommended
|
|
2215
|
+
* deployment (loopback bind, page served from the same Bun.serve), plus the
|
|
2216
|
+
* `https://` form so a Caddy/nginx terminating TLS in front of this still
|
|
2217
|
+
* works without overriding `allowedOrigins` explicitly.
|
|
2218
|
+
*/
|
|
2219
|
+
function defaultAllowedOrigins(port: number): string[] {
|
|
2220
|
+
return [
|
|
2221
|
+
`http://127.0.0.1:${port}`,
|
|
2222
|
+
`http://localhost:${port}`,
|
|
2223
|
+
`https://127.0.0.1:${port}`,
|
|
2224
|
+
`https://localhost:${port}`,
|
|
2225
|
+
];
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
/**
|
|
2229
|
+
* Constant-time string equality. Hashes both inputs with SHA-256 first so the
|
|
2230
|
+
* underlying compare runs on fixed-length 32-byte buffers — `timingSafeEqual`
|
|
2231
|
+
* itself throws on length mismatch, which leaks length, and direct buffer
|
|
2232
|
+
* compares of the raw strings would leak length too. Two HMAC-style
|
|
2233
|
+
* comparisons (same input through SHA-256 twice) is a standard pattern.
|
|
2234
|
+
*/
|
|
2235
|
+
function constantTimeStringEq(a: string, b: string): boolean {
|
|
2236
|
+
const ha = createHash('sha256').update(a, 'utf-8').digest();
|
|
2237
|
+
const hb = createHash('sha256').update(b, 'utf-8').digest();
|
|
2238
|
+
return timingSafeEqual(ha, hb);
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
/**
|
|
2242
|
+
* Slice a string to at most `maxBytes` UTF-8 bytes without splitting a
|
|
2243
|
+
* multi-byte sequence. Buffer.from + slice + toString is the standard idiom;
|
|
2244
|
+
* if the cut would land mid-codepoint, walk back to the last lead byte.
|
|
2245
|
+
*/
|
|
2246
|
+
function sliceUtf8(s: string, maxBytes: number): string {
|
|
2247
|
+
const buf = Buffer.from(s, 'utf-8');
|
|
2248
|
+
if (buf.length <= maxBytes) return s;
|
|
2249
|
+
let end = maxBytes;
|
|
2250
|
+
// Continuation bytes match 10xxxxxx (0x80..0xbf). Step back until we land
|
|
2251
|
+
// on either ASCII (0x00..0x7f) or a lead byte (0xc0..0xff).
|
|
2252
|
+
while (end > 0 && (buf[end] !== undefined && (buf[end]! & 0xc0) === 0x80)) end--;
|
|
2253
|
+
return buf.subarray(0, end).toString('utf-8');
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
interface MessageLike {
|
|
2257
|
+
id?: string;
|
|
2258
|
+
participant: string;
|
|
2259
|
+
content: ReadonlyArray<unknown>;
|
|
2260
|
+
timestamp?: number | Date;
|
|
2261
|
+
bodyGroupId?: string;
|
|
2262
|
+
shardIndex?: number;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
// Per-block size caps for wire frames. A single pathological tool result or
|
|
2266
|
+
// pasted document must not re-bloat the windowed welcome back into the
|
|
2267
|
+
// megaframe territory this protocol version exists to eliminate.
|
|
2268
|
+
const TEXT_CAP = 64 * 1024;
|
|
2269
|
+
const THINKING_CAP = 32 * 1024;
|
|
2270
|
+
const TOOL_INPUT_CAP = 16 * 1024;
|
|
2271
|
+
const TOOL_RESULT_CAP = 16 * 1024;
|
|
2272
|
+
|
|
2273
|
+
function capText(s: string, cap: number): { text: string; truncated?: boolean } {
|
|
2274
|
+
const sliced = sliceUtf8(s, cap);
|
|
2275
|
+
return sliced === s ? { text: s } : { text: sliced, truncated: true };
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
/**
|
|
2279
|
+
* Project a stored message into its wire form: ordered MessageBlocks carrying
|
|
2280
|
+
* the full internal life (thinking, tool calls AND results), with media
|
|
2281
|
+
* reduced to type placeholders and per-block size caps applied.
|
|
2282
|
+
*
|
|
2283
|
+
* `index` is the message's store slot index — the client's paging cursor.
|
|
2284
|
+
*/
|
|
2285
|
+
function toWireEntry(msg: MessageLike, index: number): WelcomeMessageEntry {
|
|
2286
|
+
const blocks: import('../web/protocol.js').MessageBlock[] = [];
|
|
2287
|
+
const textParts: string[] = [];
|
|
2288
|
+
let toolResults = 0;
|
|
2289
|
+
let conversational = 0; // text/thinking blocks — used for participant fixup
|
|
2290
|
+
|
|
2291
|
+
for (const block of msg.content) {
|
|
2292
|
+
const b = block as {
|
|
2293
|
+
type?: string; text?: unknown; thinking?: unknown; data?: unknown;
|
|
2294
|
+
id?: unknown; name?: unknown; input?: unknown;
|
|
2295
|
+
toolUseId?: unknown; content?: unknown; isError?: unknown;
|
|
2296
|
+
source?: { mediaType?: unknown }; mediaType?: unknown;
|
|
2297
|
+
ref?: { mediaType?: unknown };
|
|
2298
|
+
};
|
|
2299
|
+
switch (b.type) {
|
|
2300
|
+
case 'text':
|
|
2301
|
+
if (typeof b.text === 'string') {
|
|
2302
|
+
const capped = capText(b.text, TEXT_CAP);
|
|
2303
|
+
blocks.push({ kind: 'text', ...capped });
|
|
2304
|
+
textParts.push(capped.text);
|
|
2305
|
+
conversational++;
|
|
2306
|
+
}
|
|
2307
|
+
break;
|
|
2308
|
+
case 'thinking':
|
|
2309
|
+
if (typeof b.thinking === 'string') {
|
|
2310
|
+
blocks.push({ kind: 'thinking', ...capText(b.thinking, THINKING_CAP) });
|
|
2311
|
+
conversational++;
|
|
2312
|
+
}
|
|
2313
|
+
break;
|
|
2314
|
+
case 'redacted_thinking':
|
|
2315
|
+
blocks.push({
|
|
2316
|
+
kind: 'redacted_thinking',
|
|
2317
|
+
bytes: typeof b.data === 'string' ? b.data.length : 0,
|
|
2318
|
+
});
|
|
2319
|
+
conversational++;
|
|
2320
|
+
break;
|
|
2321
|
+
case 'tool_use':
|
|
2322
|
+
if (typeof b.id === 'string' && typeof b.name === 'string') {
|
|
2323
|
+
let inputJson: string;
|
|
2324
|
+
try {
|
|
2325
|
+
inputJson = JSON.stringify(b.input, null, 2) ?? 'null';
|
|
2326
|
+
} catch {
|
|
2327
|
+
inputJson = '[unserializable input]';
|
|
2328
|
+
}
|
|
2329
|
+
const capped = capText(inputJson, TOOL_INPUT_CAP);
|
|
2330
|
+
blocks.push({
|
|
2331
|
+
kind: 'tool_use', id: b.id, name: b.name,
|
|
2332
|
+
inputJson: capped.text,
|
|
2333
|
+
...(capped.truncated ? { truncated: true } : {}),
|
|
2334
|
+
});
|
|
2335
|
+
}
|
|
2336
|
+
break;
|
|
2337
|
+
case 'tool_result':
|
|
2338
|
+
if (typeof b.toolUseId === 'string') {
|
|
2339
|
+
blocks.push({
|
|
2340
|
+
kind: 'tool_result',
|
|
2341
|
+
toolUseId: b.toolUseId,
|
|
2342
|
+
...capText(flattenToolResultContent(b.content), TOOL_RESULT_CAP),
|
|
2343
|
+
...(b.isError === true ? { isError: true } : {}),
|
|
2344
|
+
});
|
|
2345
|
+
toolResults++;
|
|
2346
|
+
}
|
|
2347
|
+
break;
|
|
2348
|
+
case 'image':
|
|
2349
|
+
case 'document':
|
|
2350
|
+
case 'audio':
|
|
2351
|
+
case 'video':
|
|
2352
|
+
blocks.push({
|
|
2353
|
+
kind: 'media',
|
|
2354
|
+
mediaType:
|
|
2355
|
+
typeof b.source?.mediaType === 'string' ? b.source.mediaType
|
|
2356
|
+
: typeof b.mediaType === 'string' ? b.mediaType
|
|
2357
|
+
: b.type,
|
|
2358
|
+
});
|
|
2359
|
+
break;
|
|
2360
|
+
case 'blob_ref':
|
|
2361
|
+
// Un-inflated media placeholder (welcome/history are read with
|
|
2362
|
+
// resolveBlobs: false so multi-MB base64 never hits the wire).
|
|
2363
|
+
blocks.push({
|
|
2364
|
+
kind: 'media',
|
|
2365
|
+
mediaType: typeof b.ref?.mediaType === 'string' ? b.ref.mediaType : 'blob',
|
|
2366
|
+
});
|
|
2367
|
+
break;
|
|
2368
|
+
default:
|
|
2369
|
+
break; // unknown block types are skipped, not errored
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
// Messages that are purely tool results are turn-plumbing, not prose;
|
|
2374
|
+
// surface them as participant 'tool' so the client can pair/hide them.
|
|
2375
|
+
const participant = toolResults > 0 && conversational === 0
|
|
2376
|
+
? 'tool'
|
|
2377
|
+
: normalizeParticipant(msg.participant);
|
|
2378
|
+
|
|
2379
|
+
const entry: WelcomeMessageEntry = {
|
|
2380
|
+
index,
|
|
2381
|
+
participant,
|
|
2382
|
+
blocks,
|
|
2383
|
+
text: textParts.join('\n'),
|
|
2384
|
+
};
|
|
2385
|
+
if (msg.id) entry.id = msg.id;
|
|
2386
|
+
const ts = msg.timestamp instanceof Date ? msg.timestamp.getTime() : msg.timestamp;
|
|
2387
|
+
if (ts) entry.timestamp = ts;
|
|
2388
|
+
return entry;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
/** tool_result content is `string | ContentBlock[]` — flatten nested blocks
|
|
2392
|
+
* to text with placeholders for media. */
|
|
2393
|
+
function flattenToolResultContent(content: unknown): string {
|
|
2394
|
+
if (typeof content === 'string') return content;
|
|
2395
|
+
if (!Array.isArray(content)) return content == null ? '' : String(content);
|
|
2396
|
+
const parts: string[] = [];
|
|
2397
|
+
for (const block of content) {
|
|
2398
|
+
const b = block as { type?: string; text?: unknown };
|
|
2399
|
+
if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
|
|
2400
|
+
else if (b.type === 'image') parts.push('[image]');
|
|
2401
|
+
else if (typeof b.type === 'string') parts.push(`[${b.type}]`);
|
|
2402
|
+
}
|
|
2403
|
+
return parts.join('\n');
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
/**
|
|
2407
|
+
* Coalesce consecutive shards of a bodyGroup (one large message chunked at
|
|
2408
|
+
* ingestion) into a single wire entry, then flatten everything. Shards are
|
|
2409
|
+
* contiguous by construction; within a run they're ordered by shardIndex.
|
|
2410
|
+
* The coalesced entry's `index` is the FIRST shard's slot index so paging
|
|
2411
|
+
* cursors stay aligned with store slots.
|
|
2412
|
+
*/
|
|
2413
|
+
function coalesceAndFlatten(messages: readonly MessageLike[], startIndex: number): WelcomeMessageEntry[] {
|
|
2414
|
+
const out: WelcomeMessageEntry[] = [];
|
|
2415
|
+
let i = 0;
|
|
2416
|
+
while (i < messages.length) {
|
|
2417
|
+
const m = messages[i]!;
|
|
2418
|
+
if (!m.bodyGroupId) {
|
|
2419
|
+
out.push(toWireEntry(m, startIndex + i));
|
|
2420
|
+
i++;
|
|
2421
|
+
continue;
|
|
2422
|
+
}
|
|
2423
|
+
const runStart = i;
|
|
2424
|
+
while (i < messages.length && messages[i]!.bodyGroupId === m.bodyGroupId) i++;
|
|
2425
|
+
const shards = messages.slice(runStart, i)
|
|
2426
|
+
.map((shard, k) => ({ shard, k }))
|
|
2427
|
+
.sort((a, b) => (a.shard.shardIndex ?? a.k) - (b.shard.shardIndex ?? b.k))
|
|
2428
|
+
.map(({ shard }) => shard);
|
|
2429
|
+
// Merge adjacent text blocks with NO separator — shard cuts land
|
|
2430
|
+
// mid-text, so reassembly must be byte-faithful concatenation.
|
|
2431
|
+
const mergedContent: unknown[] = [];
|
|
2432
|
+
for (const blk of shards.flatMap((s) => [...s.content])) {
|
|
2433
|
+
const prev = mergedContent[mergedContent.length - 1] as { type?: string; text?: string } | undefined;
|
|
2434
|
+
const cur = blk as { type?: string; text?: unknown };
|
|
2435
|
+
if (prev?.type === 'text' && cur.type === 'text'
|
|
2436
|
+
&& typeof prev.text === 'string' && typeof cur.text === 'string') {
|
|
2437
|
+
mergedContent[mergedContent.length - 1] = { type: 'text', text: prev.text + cur.text };
|
|
2438
|
+
} else {
|
|
2439
|
+
mergedContent.push(blk);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
const merged: MessageLike = { ...shards[0]!, content: mergedContent };
|
|
2443
|
+
out.push(toWireEntry(merged, startIndex + runStart));
|
|
2444
|
+
}
|
|
2445
|
+
return out;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/** The framework stores assistant turns under the agent's name (e.g.
|
|
2449
|
+
* "commander", "miner") rather than the literal "assistant" string. The TUI
|
|
2450
|
+
* treats anything not 'user' as agent output; mirror that here so the WebUI
|
|
2451
|
+
* doesn't render restored agent turns as user messages on session resume. */
|
|
2452
|
+
function normalizeParticipant(raw: string): WelcomeMessageEntry['participant'] {
|
|
2453
|
+
if (raw === 'user' || raw === 'system' || raw === 'tool') return raw;
|
|
2454
|
+
return 'assistant';
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
/** Build a human label for an MCPL trigger origin. The exact metadata shape
|
|
2458
|
+
* varies by MCPL flavor (channel-incoming carries channelId; push-event has
|
|
2459
|
+
* serverId + featureSet) — surface what's most informative without
|
|
2460
|
+
* over-fitting to one server's schema. */
|
|
2461
|
+
function describeTriggerOrigin(source: string, md: Record<string, unknown>): string {
|
|
2462
|
+
const serverId = typeof md.serverId === 'string' ? md.serverId : '?';
|
|
2463
|
+
if (source === 'mcpl:channel-incoming') {
|
|
2464
|
+
const channelId = typeof md.channelId === 'string' ? md.channelId : '';
|
|
2465
|
+
return channelId ? `${serverId}#${channelId}` : serverId;
|
|
2466
|
+
}
|
|
2467
|
+
if (source === 'mcpl:push-event') {
|
|
2468
|
+
const featureSet = typeof md.featureSet === 'string' ? md.featureSet : '';
|
|
2469
|
+
return featureSet ? `${serverId}/${featureSet}` : serverId;
|
|
2470
|
+
}
|
|
2471
|
+
return source;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
/** MCPL channel-incoming carries `author: { id, name }` in metadata; push
|
|
2475
|
+
* events sometimes do via origin spread. Best-effort extraction. */
|
|
2476
|
+
function extractAuthorName(md: Record<string, unknown>): string | undefined {
|
|
2477
|
+
const author = md.author;
|
|
2478
|
+
if (author && typeof author === 'object' && 'name' in author) {
|
|
2479
|
+
const name = (author as { name?: unknown }).name;
|
|
2480
|
+
if (typeof name === 'string') return name;
|
|
2481
|
+
}
|
|
2482
|
+
return undefined;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
/** Pull a flat-text excerpt out of a content-block array. Mirrors what
|
|
2486
|
+
* flattenMessage does for assistant turns, scoped down to a single string. */
|
|
2487
|
+
function extractText(content: ReadonlyArray<unknown>): string {
|
|
2488
|
+
const parts: string[] = [];
|
|
2489
|
+
for (const block of content) {
|
|
2490
|
+
const b = block as { type?: unknown; text?: unknown };
|
|
2491
|
+
if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
|
|
2492
|
+
}
|
|
2493
|
+
return parts.join('\n');
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
/** Parse a `usage:updated` `totals` payload into the wire `TokenUsage` shape.
|
|
2497
|
+
* Returns null if the input doesn't look like a SessionUsage object — the
|
|
2498
|
+
* caller leaves cached state untouched in that case, which is safer than
|
|
2499
|
+
* zeroing on every malformed frame. */
|
|
2500
|
+
function parseUsageTotals(raw: unknown): TokenUsage | null {
|
|
2501
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
2502
|
+
const t = raw as {
|
|
2503
|
+
inputTokens?: unknown;
|
|
2504
|
+
outputTokens?: unknown;
|
|
2505
|
+
cacheReadTokens?: unknown;
|
|
2506
|
+
cacheCreationTokens?: unknown;
|
|
2507
|
+
estimatedCost?: unknown;
|
|
2508
|
+
};
|
|
2509
|
+
const usage: TokenUsage = {
|
|
2510
|
+
input: typeof t.inputTokens === 'number' ? t.inputTokens : 0,
|
|
2511
|
+
output: typeof t.outputTokens === 'number' ? t.outputTokens : 0,
|
|
2512
|
+
cacheRead: typeof t.cacheReadTokens === 'number' ? t.cacheReadTokens : 0,
|
|
2513
|
+
cacheWrite: typeof t.cacheCreationTokens === 'number' ? t.cacheCreationTokens : 0,
|
|
2514
|
+
};
|
|
2515
|
+
const cost = t.estimatedCost as { total?: unknown; currency?: unknown } | undefined;
|
|
2516
|
+
if (cost && typeof cost.total === 'number' && typeof cost.currency === 'string') {
|
|
2517
|
+
usage.cost = { total: cost.total, currency: cost.currency };
|
|
2518
|
+
}
|
|
2519
|
+
return usage;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
/** Combine parent + per-child session totals into the single number shown in
|
|
2523
|
+
* the header. Cost folds when every contributor reports the same currency;
|
|
2524
|
+
* mismatched currencies drop cost entirely (better than silently summing
|
|
2525
|
+
* USD + EUR). */
|
|
2526
|
+
function aggregateFleetUsage(ss: SharedServerState): TokenUsage {
|
|
2527
|
+
const out: TokenUsage = {
|
|
2528
|
+
input: ss.parentUsage.input,
|
|
2529
|
+
output: ss.parentUsage.output,
|
|
2530
|
+
cacheRead: ss.parentUsage.cacheRead,
|
|
2531
|
+
cacheWrite: ss.parentUsage.cacheWrite,
|
|
2532
|
+
};
|
|
2533
|
+
let costTotal = 0;
|
|
2534
|
+
let costCurrency: string | null = null;
|
|
2535
|
+
let costAbandoned = false;
|
|
2536
|
+
const accumulateCost = (c: { total: number; currency: string } | undefined): void => {
|
|
2537
|
+
if (costAbandoned) return;
|
|
2538
|
+
if (!c) return;
|
|
2539
|
+
if (costCurrency === null) { costCurrency = c.currency; costTotal = c.total; return; }
|
|
2540
|
+
if (costCurrency !== c.currency) { costAbandoned = true; return; }
|
|
2541
|
+
costTotal += c.total;
|
|
2542
|
+
};
|
|
2543
|
+
accumulateCost(ss.parentUsage.cost);
|
|
2544
|
+
for (const u of ss.childUsage.values()) {
|
|
2545
|
+
out.input += u.input;
|
|
2546
|
+
out.output += u.output;
|
|
2547
|
+
out.cacheRead += u.cacheRead;
|
|
2548
|
+
out.cacheWrite += u.cacheWrite;
|
|
2549
|
+
accumulateCost(u.cost);
|
|
2550
|
+
}
|
|
2551
|
+
if (!costAbandoned && costCurrency !== null) {
|
|
2552
|
+
out.cost = { total: costTotal, currency: costCurrency };
|
|
2553
|
+
}
|
|
2554
|
+
return out;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
function mimeFor(path: string): string {
|
|
2558
|
+
if (path.endsWith('.html')) return 'text/html; charset=utf-8';
|
|
2559
|
+
if (path.endsWith('.js')) return 'text/javascript; charset=utf-8';
|
|
2560
|
+
if (path.endsWith('.mjs')) return 'text/javascript; charset=utf-8';
|
|
2561
|
+
if (path.endsWith('.css')) return 'text/css; charset=utf-8';
|
|
2562
|
+
if (path.endsWith('.json')) return 'application/json; charset=utf-8';
|
|
2563
|
+
if (path.endsWith('.svg')) return 'image/svg+xml';
|
|
2564
|
+
if (path.endsWith('.png')) return 'image/png';
|
|
2565
|
+
if (path.endsWith('.ico')) return 'image/x-icon';
|
|
2566
|
+
if (path.endsWith('.woff2')) return 'font/woff2';
|
|
2567
|
+
if (path.endsWith('.woff')) return 'font/woff';
|
|
2568
|
+
return 'application/octet-stream';
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// ---------------------------------------------------------------------------
|
|
2572
|
+
// Test helpers
|
|
2573
|
+
// ---------------------------------------------------------------------------
|
|
2574
|
+
//
|
|
2575
|
+
// The HTTP server lives at module scope (process-level singleton). Tests need
|
|
2576
|
+
// to read the actual bound port when they pass `port: 0`, and they need to
|
|
2577
|
+
// shut the server down between files even though normal lifecycle keeps it
|
|
2578
|
+
// running across session switches. These helpers exist solely for tests; they
|
|
2579
|
+
// are not part of the public module API.
|
|
2580
|
+
|
|
2581
|
+
/** Return the bound listener port, or null if the singleton hasn't started. */
|
|
2582
|
+
export function __getSharedServerPortForTests(): number | null {
|
|
2583
|
+
return sharedServer?.port ?? null;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
/** Forcibly tear down the shared HTTP server and clear the singleton, so a
|
|
2587
|
+
* subsequent `start()` boots a fresh one. Tests only. */
|
|
2588
|
+
export async function __resetSharedServerForTests(): Promise<void> {
|
|
2589
|
+
if (!sharedServer) return;
|
|
2590
|
+
try { sharedServer.server.stop(true); } catch { /* ignore */ }
|
|
2591
|
+
// Detach any fleet listener / aggregator so the next start runs clean.
|
|
2592
|
+
sharedServer.fleetEventDetacher?.();
|
|
2593
|
+
sharedServer.treeAggregator?.dispose();
|
|
2594
|
+
sharedServer = null;
|
|
2595
|
+
}
|