@wrongstack/webui 0.275.1 → 0.276.3

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.
@@ -1,9 +1,214 @@
1
1
  import { WebSocket } from 'ws';
2
- import { Agent, EventBus, SessionStore, ToolRegistry, ModelsRegistry, ConfigStore, SecretVault, JournalEntry, Logger, Provider, Tool, Context, MemoryStore, ProviderConfig, ProviderApiKey, SddInterviewDriver, AgentFactory, BrainArbiter, SkillLoader, PromptLoader, PromptUsageStore } from '@wrongstack/core';
2
+ import { Agent, Context, Logger, EventBus, Provider, Tool, SessionStore, ToolRegistry, ModelsRegistry, ConfigStore, SecretVault, JournalEntry, MemoryStore, PromptLoader, PromptUsageStore, ProviderConfig, ProviderApiKey, SddInterviewDriver, AgentFactory, BrainArbiter, SkillLoader } from '@wrongstack/core';
3
3
  import * as http from 'node:http';
4
4
  import { MCPRegistry } from '@wrongstack/mcp';
5
5
  import { SkillInstaller } from '@wrongstack/core/skills';
6
6
 
7
+ interface AutoPhaseWSMessage {
8
+ type: string;
9
+ payload?: Record<string, unknown>;
10
+ }
11
+ /**
12
+ * AutoPhaseWebSocketHandler — WebSocket-based AutoPhase control.
13
+ *
14
+ * Message types:
15
+ * autophase.start → { title, phases?, autonomous? }
16
+ * autophase.pause → {}
17
+ * autophase.resume → {}
18
+ * autophase.stop → {}
19
+ * autophase.status → {}
20
+ * autophase.selectPhase → { phaseId }
21
+ * autophase.taskStatus → { taskId, status }
22
+ */
23
+ declare class AutoPhaseWebSocketHandler {
24
+ private agent;
25
+ private context;
26
+ private logger;
27
+ private events?;
28
+ private projectRoot?;
29
+ private orchestrator;
30
+ private graph;
31
+ private store;
32
+ private clients;
33
+ private broadcastInterval;
34
+ /** Aborts in-flight task agents AND the planning turn when the run is stopped. */
35
+ private abort;
36
+ /** Set the instant a stop/clear/revert is requested, so a planning turn that
37
+ * resolves afterwards never launches the orchestrator (the abort alone can't
38
+ * cover the window between the LLM call resolving and the orchestrator start). */
39
+ private stopping;
40
+ /** Optional per-phase git-worktree isolation (lazily created at start). */
41
+ private worktrees;
42
+ /** Base branch + tip SHA captured at run start so a revert can git-revert the
43
+ * run's squash commits (history-preserving) instead of a destructive reset. */
44
+ private runBase;
45
+ /** Per-run worker identities so the board can show "who is on what". */
46
+ private usedNicknames;
47
+ constructor(agent: Agent, context: Context, logger: Logger, storeDir: string, events?: EventBus | undefined, projectRoot?: string | undefined);
48
+ addClient(ws: WebSocket): void;
49
+ handleMessage(msg: AutoPhaseWSMessage): Promise<void>;
50
+ private handleStart;
51
+ /**
52
+ * Halt the run NOW — at any phase. Sets `stopping` (so a planning turn that
53
+ * resolves afterwards bails), aborts in-flight agents, stops the orchestrator
54
+ * tick, and ends the live broadcast. The board is kept for review; use
55
+ * `autophase.clear` to reset or `autophase.revert` to undo the changes.
56
+ */
57
+ private handleStop;
58
+ /**
59
+ * Stop + wipe: tear down phase worktrees and reset to an empty board so the UI
60
+ * returns to the start screen ("new one"). Does NOT touch already-merged commits
61
+ * on the base branch — that is `autophase.revert`.
62
+ */
63
+ private handleClear;
64
+ /**
65
+ * Stop + undo: remove phase worktrees, then history-preservingly `git revert`
66
+ * every commit this run landed on the base branch (captured `runBase`..HEAD),
67
+ * then reset to an empty board. Refuses (reports a reason) on a dirty tree or a
68
+ * conflicting revert rather than leaving the tree half-reverted.
69
+ */
70
+ private handleRevert;
71
+ /** Generic fallback phases when the LLM planner produces nothing usable. */
72
+ private defaultPhases;
73
+ /** Plan phases+todos for the goal via the LLM; fall back to defaults on failure.
74
+ * The caller passes the run's abort signal so a stop during planning cancels
75
+ * the LLM turn (the previous fresh, never-aborted controller made planning
76
+ * uninterruptible). */
77
+ private planPhases;
78
+ private executeTaskWithAgent;
79
+ /** Persist + broadcast after an interactive board mutation. */
80
+ private afterBoardMutation;
81
+ private handleTaskStatusChange;
82
+ private startBroadcast;
83
+ private stopBroadcast;
84
+ private broadcastState;
85
+ private buildState;
86
+ private sendState;
87
+ private broadcast;
88
+ private send;
89
+ }
90
+
91
+ /**
92
+ * Context-aware editor completion for the WebUI Monaco surface.
93
+ *
94
+ * The handler combines fast symbol-index hits with a short, JSON-only LLM call.
95
+ * It is intentionally side-effect free: it never writes files and only reads the
96
+ * existing codebase index when available.
97
+ */
98
+
99
+ type CompletionItemKind = 'text' | 'method' | 'function' | 'constructor' | 'field' | 'variable' | 'class' | 'interface' | 'module' | 'property' | 'unit' | 'value' | 'enum' | 'keyword' | 'snippet' | 'file' | 'reference';
100
+ interface CompletionSuggestion {
101
+ label: string;
102
+ insertText: string;
103
+ kind?: CompletionItemKind | undefined;
104
+ detail?: string | undefined;
105
+ documentation?: string | undefined;
106
+ sortText?: string | undefined;
107
+ source?: 'llm' | 'index' | 'lsp' | undefined;
108
+ }
109
+ interface CompletionHandlerOptions {
110
+ projectRoot: string;
111
+ provider?: Provider | undefined;
112
+ model?: string | undefined;
113
+ indexDir?: string | undefined;
114
+ lspCompletion?: LspCompletionSource | undefined;
115
+ timeoutMs?: number | undefined;
116
+ }
117
+ interface LspCompletionSourceRequest {
118
+ filePath: string;
119
+ lineNumber: number;
120
+ column: number;
121
+ content?: string | undefined;
122
+ triggerCharacter?: string | undefined;
123
+ signal: AbortSignal;
124
+ }
125
+ type LspCompletionSource = (request: LspCompletionSourceRequest) => Promise<CompletionSuggestion[]>;
126
+ declare function handleCompletionRequest(ws: WebSocket, msg: unknown, opts: CompletionHandlerOptions): Promise<void>;
127
+ declare function createToolLspCompletionSource(tool: Tool | undefined, ctx: Context): LspCompletionSource | undefined;
128
+
129
+ /**
130
+ * Custom context modes — user-defined presets that are loaded from disk,
131
+ * merged with the built-in modes, and managed via WebSocket CRUD handlers.
132
+ *
133
+ * Stored in: ~/.wrongstack/custom-context-modes.json
134
+ * Format: { "modes": ContextWindowMode[] }
135
+ */
136
+ interface CustomContextMode {
137
+ id: string;
138
+ name: string;
139
+ description: string;
140
+ thresholds: {
141
+ warn: number;
142
+ soft: number;
143
+ hard: number;
144
+ };
145
+ aggressiveOn: string;
146
+ preserveK: number;
147
+ eliseThreshold: number;
148
+ targetLoad: number;
149
+ /** Whether this is a user-defined (custom) or built-in mode. */
150
+ custom: boolean;
151
+ }
152
+ interface CustomModeStore {
153
+ modes: Map<string, CustomContextMode>;
154
+ load: () => Promise<void>;
155
+ save: () => Promise<void>;
156
+ create: (mode: CustomContextMode) => {
157
+ ok: boolean;
158
+ error?: string | undefined;
159
+ };
160
+ update: (id: string, patch: Partial<CustomContextMode>) => {
161
+ ok: boolean;
162
+ error?: string | undefined;
163
+ };
164
+ remove: (id: string) => {
165
+ ok: boolean;
166
+ error?: string | undefined;
167
+ };
168
+ list: () => CustomContextMode[];
169
+ }
170
+ declare function createCustomModeStore(wrongstackDir: string): CustomModeStore;
171
+
172
+ /**
173
+ * Shared Design Studio WebSocket handlers for both the standalone WebUI server
174
+ * (`packages/webui/src/server/index.ts`) and the CLI's `--webui` embedded
175
+ * server (`packages/cli/src/webui-server.ts`). One source of truth keeps the two
176
+ * servers at parity (enforced by ws-handler-parity.test.ts).
177
+ *
178
+ * case 'design.list': return handleDesignList(ws, designCtx);
179
+ * case 'design.use': return handleDesignUse(ws, designCtx, msg);
180
+ * case 'design.state': return handleDesignState(ws, designCtx);
181
+ * case 'design.set': return handleDesignSet(ws, designCtx, msg);
182
+ * case 'design.materialize': return handleDesignMaterialize(ws, designCtx, msg);
183
+ *
184
+ * Browsing + customization of curated UI design kits; `design.use` pins the
185
+ * active kit, `design.set` records color/token overrides, `design.materialize`
186
+ * writes the (override-applied) tokens to a real theme file on disk.
187
+ */
188
+
189
+ interface DesignContext {
190
+ projectRoot: string;
191
+ /** Live agent context whose `meta.designStudio` we read/pin. Optional. */
192
+ agentMeta?: {
193
+ meta: Record<string, unknown>;
194
+ } | undefined;
195
+ }
196
+ declare function handleDesignList(ws: WebSocket, ctx: DesignContext): Promise<void>;
197
+ declare function handleDesignState(ws: WebSocket, ctx: DesignContext): Promise<void>;
198
+ declare function handleDesignUse(ws: WebSocket, ctx: DesignContext, msg: {
199
+ payload?: unknown;
200
+ }): Promise<void>;
201
+ /** Record structured color/token overrides without changing the pinned kit. */
202
+ declare function handleDesignSet(ws: WebSocket, ctx: DesignContext, msg: {
203
+ payload?: unknown;
204
+ }): Promise<void>;
205
+ /** Write the active kit's (override-applied) tokens to a real theme file. */
206
+ declare function handleDesignMaterialize(ws: WebSocket, ctx: DesignContext, msg: {
207
+ payload?: unknown;
208
+ }): Promise<void>;
209
+ /** Scan project UI files for off-palette colors against the active kit. */
210
+ declare function handleDesignVerify(ws: WebSocket, ctx: DesignContext): Promise<void>;
211
+
7
212
  interface WSServerMessage {
8
213
  type: string;
9
214
  payload: unknown;
@@ -92,53 +297,144 @@ interface ConnectedClient {
92
297
  connId: string;
93
298
  }
94
299
 
95
- /** Metrics for the file watcher that watches status.json files. */
96
- interface FileWatcherMetrics {
97
- fileChangesDetected: number;
98
- filesProcessed: number;
99
- broadcastsSent: number;
100
- debounceResets: number;
101
- totalDebounceDelayMs: number;
102
- activeProjects: number;
103
- /** Average debounce delay in ms across all broadcasts. */
104
- averageDebounceDelayMs: number;
105
- /** Whether the file watcher is currently active. */
106
- watcherActive: boolean;
300
+ type EternalSubscribe = (fn: (entry: JournalEntry) => void) => () => void;
301
+ type EternalBroadcast<C> = (clients: Map<WebSocket, C>, msg: WSServerMessage) => void;
302
+ interface EternalSubscription {
303
+ /** Tear down the underlying engine subscription. Idempotent. */
304
+ dispose: () => void;
107
305
  }
306
+ declare function createEternalSubscription<C>(subscribe: EternalSubscribe, broadcast: EternalBroadcast<C>, clientsRef: () => Map<WebSocket, C>): EternalSubscription;
108
307
 
109
- interface CreateHttpServerOptions {
110
- /** Port to listen on. Defaults to 3456 (or the `PORT` env var). */
111
- port?: number | undefined;
112
- /** Host/interface to bind. Typically the loopback for the WebUI. */
113
- host: string;
114
- /** Resolved path to the directory containing the built React assets. */
115
- distDir: string;
116
- /**
117
- * WS port appears in the CSP `connect-src` directive so the browser
118
- * is allowed to open a WebSocket back to the local server.
119
- */
120
- wsPort: number;
121
- /**
122
- * Public WebSocket URL injected into the frontend. Use this behind tunnels or
123
- * reverse proxies where the browser-facing WS URL differs from host:wsPort.
124
- */
125
- publicWsUrl?: string | undefined;
126
- /**
127
- * Path to the global WrongStack root (~/.wrongstack). Used by the
128
- * /api/sessions and /api/sessions/:id/agents endpoints to read the
129
- * cross-process SessionRegistry.
130
- */
131
- globalRoot?: string | undefined;
132
- /**
133
- * Shared auth token for HTTP and WS access. Required for non-loopback
134
- * binds (LAN exposure). Loopback binds accept local browser access without
135
- * a token (the WS path's loopback-bootstrap policy see ws-auth.ts).
136
- */
137
- apiToken?: string | undefined;
138
- /** Force HTTP token auth even on loopback binds, useful behind public tunnels. */
139
- requireToken?: boolean | undefined;
140
- /**
141
- * If true, the `/ws-auth` endpoint exchanges a `?token=` query param (or
308
+ /**
309
+ * Shared file-operation WebSocket handlers for both the standalone WebUI
310
+ * server and the CLI's `--webui` embedded server. Extracted from the
311
+ * duplicated switch cases in `index.ts` and `cli/src/webui-server.ts`.
312
+ *
313
+ * Each function handles the full request→response cycle for one message
314
+ * type. Callers drop them into their switch statement:
315
+ *
316
+ * case 'files.tree': return handleFilesTree(ws, msg, projectRoot);
317
+ */
318
+
319
+ interface FilesWriteOptions {
320
+ onWritten?: ((filePath: string) => void | Promise<void>) | undefined;
321
+ }
322
+ /**
323
+ * Build and send a nested directory tree for the File Explorer.
324
+ *
325
+ * Walks `projectRoot` to depth 10 max, skipping heavyweight dirs
326
+ * (node_modules, .git, dist, …) and dot-entries. Responds with
327
+ * `{ type: 'files.tree', payload: { root, tree } }`.
328
+ */
329
+ declare function handleFilesTree(ws: WebSocket, msg: unknown, projectRoot: string): Promise<void>;
330
+ /**
331
+ * Read a file's content for the Monaco editor.
332
+ *
333
+ * Guards against path traversal (`../` escapes). Responds with
334
+ * `{ type: 'files.read', payload: { filePath, content } }`.
335
+ */
336
+ declare function handleFilesRead(ws: WebSocket, msg: unknown, projectRoot: string): Promise<void>;
337
+ /**
338
+ * Write file content back to disk (atomic write via tmp + rename).
339
+ *
340
+ * Guards against path traversal. Responds with
341
+ * `{ type: 'files.written', payload: { filePath, success } }`.
342
+ */
343
+ declare function handleFilesWrite(ws: WebSocket, msg: unknown, projectRoot: string, opts?: FilesWriteOptions): Promise<void>;
344
+ /**
345
+ * Lightweight project file picker for the chat `@` mention popup.
346
+ *
347
+ * Walks `projectRoot` (max depth 8), skipping hidden and heavyweight
348
+ * dirs, then fuzzy-ranks results against `query`. Responds with
349
+ * `{ type: 'files.list', payload: { files } }`.
350
+ */
351
+ declare function handleFilesList(ws: WebSocket, msg: unknown, projectRoot: string): Promise<void>;
352
+
353
+ /**
354
+ * Shared `git.info` WebSocket handler for both the standalone WebUI server and
355
+ * the CLI's `--webui` embedded server. Extracted from the duplicated switch
356
+ * cases in `index.ts` and `cli/src/webui-server.ts`, which had drifted (the
357
+ * standalone copy transposed ahead/behind and never matched deletions). One
358
+ * implementation here keeps both surfaces in lockstep.
359
+ *
360
+ * case 'git.info': return handleGitInfo(ws, projectRoot);
361
+ */
362
+
363
+ /**
364
+ * Read git branch, change stats, and upstream sync status from `projectRoot`
365
+ * and broadcast a `git.info` message. Never throws — a non-repo / missing-git
366
+ * directory yields an empty-but-valid payload.
367
+ */
368
+ declare function handleGitInfo(ws: WebSocket, projectRoot: string): Promise<void>;
369
+ /**
370
+ * Read the working-tree change set (everything that differs from HEAD:
371
+ * staged, unstaged, and untracked) and broadcast a `git.changes` message.
372
+ *
373
+ * The file list comes from `git status --porcelain -z` (NUL-delimited so
374
+ * paths with spaces/unicode survive intact, and renames are unambiguous).
375
+ * Per-file line counts come from `--numstat` of both the unstaged and the
376
+ * staged diff, summed. Untracked files intentionally report 0/0 here so the
377
+ * list view does not read every untracked file; `git.diff` loads a selected
378
+ * file lazily on demand.
379
+ * Never throws — a non-repo yields an empty list.
380
+ */
381
+ declare function handleGitChanges(ws: WebSocket, projectRoot: string): Promise<void>;
382
+ /**
383
+ * Resolve the before/after text for a single file and broadcast a `git.diff`
384
+ * message. `oldText` is the file at HEAD (`git show HEAD:<path>`), `newText`
385
+ * is the current working-tree content. New/untracked files have empty
386
+ * `oldText`; deleted files have empty `newText`. Binary or oversized files
387
+ * are reported with a flag instead of content so the client can show a notice.
388
+ */
389
+ declare function handleGitDiff(ws: WebSocket, projectRoot: string, path: string): Promise<void>;
390
+
391
+ /** Metrics for the file watcher that watches status.json files. */
392
+ interface FileWatcherMetrics {
393
+ fileChangesDetected: number;
394
+ filesProcessed: number;
395
+ broadcastsSent: number;
396
+ debounceResets: number;
397
+ totalDebounceDelayMs: number;
398
+ activeProjects: number;
399
+ /** Average debounce delay in ms across all broadcasts. */
400
+ averageDebounceDelayMs: number;
401
+ /** Whether the file watcher is currently active. */
402
+ watcherActive: boolean;
403
+ }
404
+
405
+ interface CreateHttpServerOptions {
406
+ /** Port to listen on. Defaults to 3456 (or the `PORT` env var). */
407
+ port?: number | undefined;
408
+ /** Host/interface to bind. Typically the loopback for the WebUI. */
409
+ host: string;
410
+ /** Resolved path to the directory containing the built React assets. */
411
+ distDir: string;
412
+ /**
413
+ * WS port — appears in the CSP `connect-src` directive so the browser
414
+ * is allowed to open a WebSocket back to the local server.
415
+ */
416
+ wsPort: number;
417
+ /**
418
+ * Public WebSocket URL injected into the frontend. Use this behind tunnels or
419
+ * reverse proxies where the browser-facing WS URL differs from host:wsPort.
420
+ */
421
+ publicWsUrl?: string | undefined;
422
+ /**
423
+ * Path to the global WrongStack root (~/.wrongstack). Used by the
424
+ * /api/sessions and /api/sessions/:id/agents endpoints to read the
425
+ * cross-process SessionRegistry.
426
+ */
427
+ globalRoot?: string | undefined;
428
+ /**
429
+ * Shared auth token for HTTP and WS access. Required for non-loopback
430
+ * binds (LAN exposure). Loopback binds accept local browser access without
431
+ * a token (the WS path's loopback-bootstrap policy — see ws-auth.ts).
432
+ */
433
+ apiToken?: string | undefined;
434
+ /** Force HTTP token auth even on loopback binds, useful behind public tunnels. */
435
+ requireToken?: boolean | undefined;
436
+ /**
437
+ * If true, the `/ws-auth` endpoint exchanges a `?token=` query param (or
142
438
  * `X-WS-Token` header) for an `HttpOnly` auth cookie. The cookie is then
143
439
  * sent automatically on the WS upgrade, closing the C-598 query-string
144
440
  * token exposure class. Default: true. Set to false to keep the legacy
@@ -180,231 +476,163 @@ declare function buildCspHeader(wsPort: number, requestHost?: string | undefined
180
476
  declare function createHttpServer(opts: CreateHttpServerOptions): http.Server;
181
477
 
182
478
  /**
183
- * Free-port discovery for the standalone WebUI server.
479
+ * Running-instance registry for the standalone WebUI server.
184
480
  *
185
- * When a user runs several instances, the default ports (HTTP 3456 / WS 3457)
186
- * are taken by the first one. Rather than make the user hand-pick `PORT` /
187
- * `WS_PORT` for every extra instance, the server probes upward from the
188
- * requested port and binds the first free one then stamps that real port into
189
- * the served HTML and the instance registry so everything stays consistent.
481
+ * Every live `wstackui` process records itself in a single JSON file under the
482
+ * wstack home dir (`~/.wrongstack/webui-instances.json`) so a user running
483
+ * several instances (one per project, or several per project on different
484
+ * ports) can see at a glance which ports are open for which path.
190
485
  *
191
- * The probe binds a throwaway `net.Server`, then closes it, so there is a tiny
192
- * TOCTOU window between "found free" and "the real server binds it". For local
193
- * single-user multi-instance use that race is negligible; if it ever loses, the
194
- * real bind fails loudly with EADDRINUSE exactly as before.
486
+ * Design notes:
487
+ * - **Self-healing**: every register/unregister/list prunes entries whose PID
488
+ * is no longer alive (`process.kill(pid, 0)`), so a crashed instance that
489
+ * never got to unregister doesn't leave a ghost behind.
490
+ * - **Atomic writes**: the file is rewritten via `atomicWrite` (tmp + rename),
491
+ * so a concurrent reader never sees a half-written file. Two instances
492
+ * starting at the *exact* same millisecond could still race the
493
+ * read-modify-write — acceptable for a best-effort tracking file, and the
494
+ * next register() heals any dropped entry.
495
+ * - **Best-effort**: a failure to read/write the registry must NEVER take the
496
+ * server down. Callers wrap these in `.catch()`.
195
497
  */
196
- /** Resolve true when `port` can be bound on `host`, false on EADDRINUSE/EACCES. */
197
- declare function isPortFree(host: string, port: number): Promise<boolean>;
198
- interface FindFreePortOptions {
199
- /** Ports to skip even if free (e.g. one already chosen for the sibling server). */
200
- exclude?: Set<number> | undefined;
201
- /** How many consecutive ports to try before giving up. Default 200. */
202
- maxTries?: number | undefined;
498
+ /** One running WebUI process. */
499
+ interface WebUIInstanceRecord {
500
+ /** OS process id — also the liveness key. */
501
+ pid: number;
502
+ /** HTTP port serving the React frontend. */
503
+ httpPort: number;
504
+ /** WebSocket port for the agent backend. */
505
+ wsPort: number;
506
+ /** Bind host (e.g. 127.0.0.1 or 0.0.0.0). */
507
+ host: string;
508
+ /** Absolute project root the instance booted against. */
509
+ projectRoot: string;
510
+ /** Display name (basename of projectRoot). */
511
+ projectName: string;
512
+ /** ISO timestamp when the instance registered. */
513
+ startedAt: string;
514
+ /** Convenience open-in-browser URL. */
515
+ url: string;
203
516
  }
517
+ /** Default wstack home dir (`~/.wrongstack`). Callers may override the base. */
518
+ declare function defaultBaseDir(): string;
519
+ /** Resolve the registry file path for a given base dir. */
520
+ declare function registryPath(baseDir?: string): string;
204
521
  /**
205
- * Find the first free port at or above `startPort` on `host`, skipping any in
206
- * `exclude`. Throws if nothing is free within `maxTries` steps.
522
+ * Register (or refresh) this instance. Prunes dead entries and any stale entry
523
+ * for our own PID before adding the current record. Best-effort — rejects only
524
+ * on a hard fs error, which callers swallow.
207
525
  */
208
- declare function findFreePort(host: string, startPort: number, opts?: FindFreePortOptions): Promise<number>;
526
+ declare function registerInstance(record: WebUIInstanceRecord, baseDir?: string): Promise<void>;
527
+ /** Remove this instance (called on graceful shutdown). Also prunes dead pids. */
528
+ declare function unregisterInstance(pid: number, baseDir?: string): Promise<void>;
529
+ /** List live instances, pruning any dead entries encountered. */
530
+ declare function listInstances(baseDir?: string): Promise<WebUIInstanceRecord[]>;
531
+ /** Human-readable table of running instances for `wstackui --list`. */
532
+ declare function formatInstances(instances: WebUIInstanceRecord[]): string;
209
533
 
210
534
  /**
211
- * Best-effort "open this URL in the default browser" for `--webui --open`.
535
+ * MCP management handlers for the WebUI server (both the standalone
536
+ * `wstackui` server and the CLI's embedded `--webui` server).
212
537
  *
213
- * Cross-platform via the OS opener (`start` / `open` / `xdg-open`). Fully
214
- * fire-and-forget: a missing opener, a headless box, or a spawn failure must
215
- * NEVER take the server down the URL is always also printed to the console.
538
+ * These are thin WebSocket translators over the shared, surface-agnostic
539
+ * management core in `@wrongstack/mcp` (`manage.ts`) the SAME core the REPL
540
+ * `/mcp` command writes against (same config.json, same MCPRegistry). All the
541
+ * config IO, url/header persistence, and live registry start/stop logic lives
542
+ * there; here we only map structured results to WS events the browser expects.
216
543
  */
217
- /** Resolve the platform's URL-opener command + args. */
218
- declare function browserOpenCommand(url: string, platform?: NodeJS.Platform): {
219
- command: string;
220
- args: string[];
221
- };
222
- /** Spawn the OS browser-opener for `url` and register it as a protected
223
- * process so it survives kill/killAll. Never throws. */
224
- declare function openBrowser(url: string, platform?: NodeJS.Platform): void;
225
544
 
226
- interface WorktreeManagementDeps {
227
- projectRoot: string;
228
- /** Board snapshot dir powers the cross-process liveness guard on cleanup. */
229
- boardsDir: string;
230
- }
545
+ /** mcp.list — configured servers merged with live registry status + tools. */
546
+ declare function handleMcpList(ws: WebSocket, _msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
547
+ /** mcp.add persist a new server (incl. url/headers) and start it if enabled. */
548
+ declare function handleMcpAdd(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
549
+ /** mcp.update — re-persist config (incl. url/headers) and re-apply to registry. */
550
+ declare function handleMcpUpdate(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
551
+ /** mcp.remove — stop the server and delete it from config. */
552
+ declare function handleMcpRemove(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
553
+ /** mcp.enable — flip enabled:true in config and start the server. */
554
+ declare function handleMcpEnable(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
555
+ /** mcp.disable — stop the server and flip enabled:false in config. */
556
+ declare function handleMcpDisable(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
557
+ /** mcp.sleep — stop a running server (config stays enabled). */
558
+ declare function handleMcpSleep(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
559
+ /** mcp.wake — restart a sleeping/stopped server from config. */
560
+ declare function handleMcpWake(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
561
+ /** mcp.restart — stop + start a server. */
562
+ declare function handleMcpRestart(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
563
+ /** mcp.discover — ensure the server is running and report its live tools. */
564
+ declare function handleMcpDiscover(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
565
+
231
566
  /**
232
- * WorktreeWebSocketHandler mirrors AutoPhaseWebSocketHandler. Subscribes to
233
- * the shared EventBus `worktree.*` lifecycle events, keeps a live snapshot of
234
- * every worktree, and broadcasts:
235
- * - `worktree.event` incrementally (drives the flowing activity strip)
236
- * - `worktree.state` on connect + on a 2s timer (drives swim-lanes/DAG)
567
+ * Shared memory-operation WebSocket handlers for both the standalone WebUI
568
+ * server and the CLI's `--webui` embedded server. Extracted from the
569
+ * duplicated switch cases in `index.ts` and `cli/src/webui-server.ts`.
570
+ *
571
+ * Each function handles the full request→response cycle for one message
572
+ * type. Callers drop them into their switch statement:
573
+ *
574
+ * case 'memory.list': return handleMemoryList(ws, memoryStore);
237
575
  */
238
- declare class WorktreeWebSocketHandler {
239
- private readonly events;
240
- private readonly logger;
241
- private readonly management?;
242
- private readonly clients;
243
- private readonly handles;
244
- private baseBranch;
245
- private broadcastInterval;
246
- private readonly offs;
247
- constructor(events: EventBus, logger: Logger, management?: WorktreeManagementDeps | undefined);
248
- addClient(ws: WebSocket): void;
249
- /** Handle worktree-panel control messages (scan / clean / per-row ops). */
250
- handleMessage(msg: {
251
- type: string;
252
- payload?: Record<string, unknown>;
253
- }): Promise<boolean>;
254
- dispose(): void;
255
- /** Absolute managed-worktrees root for this project. */
256
- private worktreesRoot;
257
- /** True iff `dir` resolves strictly inside the managed worktrees root. */
258
- private underRoot;
259
- /** Branches of worktrees a live in-session run currently owns. */
260
- private liveActiveBranches;
261
- /**
262
- * Scan the disk for managed worktrees/branches NOT owned by a live in-session
263
- * run and broadcast them as orphans, with whether it is safe to clean now.
264
- * No-op (empty inventory) when management deps were not wired.
265
- */
266
- private scanAndBroadcast;
267
- /**
268
- * Force-remove every orphaned worktree + branch. Refused while a run is live —
269
- * in this session (active handles) OR another process (the SDD board liveness
270
- * guard inside cleanupStaleSddWorktrees). Best-effort; reports the outcome.
271
- */
272
- private cleanupOrphans;
273
- /** Remove/discard ONE worktree + branch. Refused while a live run owns it. */
274
- private removeOne;
275
- /** Squash-merge ONE branch into base. Refused while a live run owns it. */
276
- private mergeBranch;
277
- /** Compact change summary for one worktree checkout. */
278
- private diffOne;
279
- private subscribe;
280
- private upsert;
281
- private patch;
282
- private activity;
283
- private stateMessage;
284
- private broadcastState;
285
- private ensureBroadcast;
286
- private stopBroadcast;
287
- private broadcast;
288
- private send;
289
- }
290
576
 
291
577
  /**
292
- * Per-section context-window token estimate for the `context.debug` command.
293
- *
294
- * Uses the simple 4-chars-per-token heuristic — not exact, but close enough to
295
- * spot which section (system prompt, tool schemas, or message history) is
296
- * eating the context window. Tool schemas in particular are easy to overlook:
297
- * each tool ships its full JSON schema to the model every turn, so 20+ builtins
298
- * can cost 10-20k tokens on their own.
578
+ * List all memory entries across all scopes.
579
+ * Responds with `{ type: 'memory.list', payload: { text } }`.
580
+ */
581
+ declare function handleMemoryList(ws: WebSocket, memoryStore: MemoryStore): Promise<void>;
582
+ /**
583
+ * Persist a new memory entry.
584
+ * Responds with `{ type: 'key.operation_result', payload: { success, message } }`.
585
+ */
586
+ declare function handleMemoryRemember(ws: WebSocket, msg: unknown, memoryStore: MemoryStore): Promise<void>;
587
+ /**
588
+ * Remove memory entries matching the given text.
589
+ * Responds with `{ type: 'key.operation_result', payload: { success, message } }`.
590
+ */
591
+ declare function handleMemoryForget(ws: WebSocket, msg: unknown, memoryStore: MemoryStore): Promise<void>;
592
+
593
+ /**
594
+ * Best-effort "open this URL in the default browser" for `--webui --open`.
299
595
  *
300
- * Extracted from `index.ts` as a pure function so the breakdown maths can be
301
- * unit tested without standing up a Context/ToolRegistry.
596
+ * Cross-platform via the OS opener (`start` / `open` / `xdg-open`). Fully
597
+ * fire-and-forget: a missing opener, a headless box, or a spawn failure must
598
+ * NEVER take the server down — the URL is always also printed to the console.
302
599
  */
303
- /** 4-chars-per-token heuristic estimate for a string. */
304
- declare function estimateTokens(s: string): number;
305
- /** Stringify arbitrary content for length estimation (JSON, with fallbacks). */
306
- declare function stringifyContent(c: unknown): string;
307
- interface ToolTokenEntry {
308
- name: string;
309
- tokens: number;
310
- }
311
- interface MessageTokenEntry {
312
- index: number;
313
- role: string;
314
- tokens: number;
315
- preview: string;
316
- }
317
- interface ContextBreakdown {
318
- total: number;
319
- systemPrompt: number;
320
- tools: {
321
- total: number;
322
- count: number;
323
- breakdown: ToolTokenEntry[];
324
- };
325
- messages: {
326
- total: number;
327
- count: number;
328
- breakdown: MessageTokenEntry[];
329
- };
330
- }
331
- declare function messageTokens(content: unknown): number;
332
- declare function messagePreview(content: unknown): string;
600
+ /** Resolve the platform's URL-opener command + args. */
601
+ declare function browserOpenCommand(url: string, platform?: NodeJS.Platform): {
602
+ command: string;
603
+ args: string[];
604
+ };
605
+ /** Spawn the OS browser-opener for `url` and register it as a protected
606
+ * process so it survives kill/killAll. Never throws. */
607
+ declare function openBrowser(url: string, platform?: NodeJS.Platform): void;
333
608
 
334
609
  /**
335
- * Running-instance registry for the standalone WebUI server.
610
+ * Free-port discovery for the standalone WebUI server.
336
611
  *
337
- * Every live `wstackui` process records itself in a single JSON file under the
338
- * wstack home dir (`~/.wrongstack/webui-instances.json`) so a user running
339
- * several instances (one per project, or several per project on different
340
- * ports) can see at a glance which ports are open for which path.
612
+ * When a user runs several instances, the default ports (HTTP 3456 / WS 3457)
613
+ * are taken by the first one. Rather than make the user hand-pick `PORT` /
614
+ * `WS_PORT` for every extra instance, the server probes upward from the
615
+ * requested port and binds the first free one then stamps that real port into
616
+ * the served HTML and the instance registry so everything stays consistent.
341
617
  *
342
- * Design notes:
343
- * - **Self-healing**: every register/unregister/list prunes entries whose PID
344
- * is no longer alive (`process.kill(pid, 0)`), so a crashed instance that
345
- * never got to unregister doesn't leave a ghost behind.
346
- * - **Atomic writes**: the file is rewritten via `atomicWrite` (tmp + rename),
347
- * so a concurrent reader never sees a half-written file. Two instances
348
- * starting at the *exact* same millisecond could still race the
349
- * read-modify-write — acceptable for a best-effort tracking file, and the
350
- * next register() heals any dropped entry.
351
- * - **Best-effort**: a failure to read/write the registry must NEVER take the
352
- * server down. Callers wrap these in `.catch()`.
618
+ * The probe binds a throwaway `net.Server`, then closes it, so there is a tiny
619
+ * TOCTOU window between "found free" and "the real server binds it". For local
620
+ * single-user multi-instance use that race is negligible; if it ever loses, the
621
+ * real bind fails loudly with EADDRINUSE exactly as before.
353
622
  */
354
- /** One running WebUI process. */
355
- interface WebUIInstanceRecord {
356
- /** OS process id — also the liveness key. */
357
- pid: number;
358
- /** HTTP port serving the React frontend. */
359
- httpPort: number;
360
- /** WebSocket port for the agent backend. */
361
- wsPort: number;
362
- /** Bind host (e.g. 127.0.0.1 or 0.0.0.0). */
363
- host: string;
364
- /** Absolute project root the instance booted against. */
365
- projectRoot: string;
366
- /** Display name (basename of projectRoot). */
367
- projectName: string;
368
- /** ISO timestamp when the instance registered. */
369
- startedAt: string;
370
- /** Convenience open-in-browser URL. */
371
- url: string;
623
+ /** Resolve true when `port` can be bound on `host`, false on EADDRINUSE/EACCES. */
624
+ declare function isPortFree(host: string, port: number): Promise<boolean>;
625
+ interface FindFreePortOptions {
626
+ /** Ports to skip even if free (e.g. one already chosen for the sibling server). */
627
+ exclude?: Set<number> | undefined;
628
+ /** How many consecutive ports to try before giving up. Default 200. */
629
+ maxTries?: number | undefined;
372
630
  }
373
- /** Default wstack home dir (`~/.wrongstack`). Callers may override the base. */
374
- declare function defaultBaseDir(): string;
375
- /** Resolve the registry file path for a given base dir. */
376
- declare function registryPath(baseDir?: string): string;
377
631
  /**
378
- * Register (or refresh) this instance. Prunes dead entries and any stale entry
379
- * for our own PID before adding the current record. Best-effort — rejects only
380
- * on a hard fs error, which callers swallow.
632
+ * Find the first free port at or above `startPort` on `host`, skipping any in
633
+ * `exclude`. Throws if nothing is free within `maxTries` steps.
381
634
  */
382
- declare function registerInstance(record: WebUIInstanceRecord, baseDir?: string): Promise<void>;
383
- /** Remove this instance (called on graceful shutdown). Also prunes dead pids. */
384
- declare function unregisterInstance(pid: number, baseDir?: string): Promise<void>;
385
- /** List live instances, pruning any dead entries encountered. */
386
- declare function listInstances(baseDir?: string): Promise<WebUIInstanceRecord[]>;
387
- /** Human-readable table of running instances for `wstackui --list`. */
388
- declare function formatInstances(instances: WebUIInstanceRecord[]): string;
389
-
390
- type EternalSubscribe = (fn: (entry: JournalEntry) => void) => () => void;
391
- type EternalBroadcast<C> = (clients: Map<WebSocket, C>, msg: WSServerMessage) => void;
392
- interface EternalSubscription {
393
- /** Tear down the underlying engine subscription. Idempotent. */
394
- dispose: () => void;
395
- }
396
- declare function createEternalSubscription<C>(subscribe: EternalSubscribe, broadcast: EternalBroadcast<C>, clientsRef: () => Map<WebSocket, C>): EternalSubscription;
397
-
398
- type ShellOpenTarget = 'terminal' | 'file-manager';
399
- interface ShellOpenRequest {
400
- path: string;
401
- target: ShellOpenTarget;
402
- }
403
- interface ShellOpenResult {
404
- success: boolean;
405
- message: string;
406
- }
407
- declare function handleShellOpen(req: ShellOpenRequest, logger: Logger): Promise<ShellOpenResult>;
635
+ declare function findFreePort(host: string, startPort: number, opts?: FindFreePortOptions): Promise<number>;
408
636
 
409
637
  /**
410
638
  * Send a JSON message to a single WebSocket client.
@@ -443,290 +671,65 @@ declare function buildWebUIAccessUrl(opts: {
443
671
  declare function envFlag(name: string): boolean;
444
672
 
445
673
  /**
446
- * Shared file-operation WebSocket handlers for both the standalone WebUI
447
- * server and the CLI's `--webui` embedded server. Extracted from the
448
- * duplicated switch cases in `index.ts` and `cli/src/webui-server.ts`.
449
- *
450
- * Each function handles the full request→response cycle for one message
451
- * type. Callers drop them into their switch statement:
452
- *
453
- * case 'files.tree': return handleFilesTree(ws, msg, projectRoot);
454
- */
455
-
456
- interface FilesWriteOptions {
457
- onWritten?: ((filePath: string) => void | Promise<void>) | undefined;
458
- }
459
- /**
460
- * Build and send a nested directory tree for the File Explorer.
461
- *
462
- * Walks `projectRoot` to depth 10 max, skipping heavyweight dirs
463
- * (node_modules, .git, dist, …) and dot-entries. Responds with
464
- * `{ type: 'files.tree', payload: { root, tree } }`.
465
- */
466
- declare function handleFilesTree(ws: WebSocket, msg: unknown, projectRoot: string): Promise<void>;
467
- /**
468
- * Read a file's content for the Monaco editor.
469
- *
470
- * Guards against path traversal (`../` escapes). Responds with
471
- * `{ type: 'files.read', payload: { filePath, content } }`.
472
- */
473
- declare function handleFilesRead(ws: WebSocket, msg: unknown, projectRoot: string): Promise<void>;
474
- /**
475
- * Write file content back to disk (atomic write via tmp + rename).
476
- *
477
- * Guards against path traversal. Responds with
478
- * `{ type: 'files.written', payload: { filePath, success } }`.
479
- */
480
- declare function handleFilesWrite(ws: WebSocket, msg: unknown, projectRoot: string, opts?: FilesWriteOptions): Promise<void>;
481
- /**
482
- * Lightweight project file picker for the chat `@` mention popup.
483
- *
484
- * Walks `projectRoot` (max depth 8), skipping hidden and heavyweight
485
- * dirs, then fuzzy-ranks results against `query`. Responds with
486
- * `{ type: 'files.list', payload: { files } }`.
487
- */
488
- declare function handleFilesList(ws: WebSocket, msg: unknown, projectRoot: string): Promise<void>;
489
-
490
- /**
491
- * Context-aware editor completion for the WebUI Monaco surface.
492
- *
493
- * The handler combines fast symbol-index hits with a short, JSON-only LLM call.
494
- * It is intentionally side-effect free: it never writes files and only reads the
495
- * existing codebase index when available.
496
- */
497
-
498
- type CompletionItemKind = 'text' | 'method' | 'function' | 'constructor' | 'field' | 'variable' | 'class' | 'interface' | 'module' | 'property' | 'unit' | 'value' | 'enum' | 'keyword' | 'snippet' | 'file' | 'reference';
499
- interface CompletionSuggestion {
500
- label: string;
501
- insertText: string;
502
- kind?: CompletionItemKind | undefined;
503
- detail?: string | undefined;
504
- documentation?: string | undefined;
505
- sortText?: string | undefined;
506
- source?: 'llm' | 'index' | 'lsp' | undefined;
507
- }
508
- interface CompletionHandlerOptions {
509
- projectRoot: string;
510
- provider?: Provider | undefined;
511
- model?: string | undefined;
512
- indexDir?: string | undefined;
513
- lspCompletion?: LspCompletionSource | undefined;
514
- timeoutMs?: number | undefined;
515
- }
516
- interface LspCompletionSourceRequest {
517
- filePath: string;
518
- lineNumber: number;
519
- column: number;
520
- content?: string | undefined;
521
- triggerCharacter?: string | undefined;
522
- signal: AbortSignal;
523
- }
524
- type LspCompletionSource = (request: LspCompletionSourceRequest) => Promise<CompletionSuggestion[]>;
525
- declare function handleCompletionRequest(ws: WebSocket, msg: unknown, opts: CompletionHandlerOptions): Promise<void>;
526
- declare function createToolLspCompletionSource(tool: Tool | undefined, ctx: Context): LspCompletionSource | undefined;
527
-
528
- /**
529
- * Shared `git.info` WebSocket handler for both the standalone WebUI server and
530
- * the CLI's `--webui` embedded server. Extracted from the duplicated switch
531
- * cases in `index.ts` and `cli/src/webui-server.ts`, which had drifted (the
532
- * standalone copy transposed ahead/behind and never matched deletions). One
533
- * implementation here keeps both surfaces in lockstep.
534
- *
535
- * case 'git.info': return handleGitInfo(ws, projectRoot);
536
- */
537
-
538
- /**
539
- * Read git branch, change stats, and upstream sync status from `projectRoot`
540
- * and broadcast a `git.info` message. Never throws — a non-repo / missing-git
541
- * directory yields an empty-but-valid payload.
542
- */
543
- declare function handleGitInfo(ws: WebSocket, projectRoot: string): Promise<void>;
544
- /**
545
- * Read the working-tree change set (everything that differs from HEAD:
546
- * staged, unstaged, and untracked) and broadcast a `git.changes` message.
547
- *
548
- * The file list comes from `git status --porcelain -z` (NUL-delimited so
549
- * paths with spaces/unicode survive intact, and renames are unambiguous).
550
- * Per-file line counts come from `--numstat` of both the unstaged and the
551
- * staged diff, summed. Untracked files intentionally report 0/0 here so the
552
- * list view does not read every untracked file; `git.diff` loads a selected
553
- * file lazily on demand.
554
- * Never throws — a non-repo yields an empty list.
555
- */
556
- declare function handleGitChanges(ws: WebSocket, projectRoot: string): Promise<void>;
557
- /**
558
- * Resolve the before/after text for a single file and broadcast a `git.diff`
559
- * message. `oldText` is the file at HEAD (`git show HEAD:<path>`), `newText`
560
- * is the current working-tree content. New/untracked files have empty
561
- * `oldText`; deleted files have empty `newText`. Binary or oversized files
562
- * are reported with a flag instead of content so the client can show a notice.
563
- */
564
- declare function handleGitDiff(ws: WebSocket, projectRoot: string, path: string): Promise<void>;
565
-
566
- /**
567
- * Shared memory-operation WebSocket handlers for both the standalone WebUI
568
- * server and the CLI's `--webui` embedded server. Extracted from the
569
- * duplicated switch cases in `index.ts` and `cli/src/webui-server.ts`.
570
- *
571
- * Each function handles the full request→response cycle for one message
572
- * type. Callers drop them into their switch statement:
573
- *
574
- * case 'memory.list': return handleMemoryList(ws, memoryStore);
575
- */
576
-
577
- /**
578
- * List all memory entries across all scopes.
579
- * Responds with `{ type: 'memory.list', payload: { text } }`.
580
- */
581
- declare function handleMemoryList(ws: WebSocket, memoryStore: MemoryStore): Promise<void>;
582
- /**
583
- * Persist a new memory entry.
584
- * Responds with `{ type: 'key.operation_result', payload: { success, message } }`.
585
- */
586
- declare function handleMemoryRemember(ws: WebSocket, msg: unknown, memoryStore: MemoryStore): Promise<void>;
587
- /**
588
- * Remove memory entries matching the given text.
589
- * Responds with `{ type: 'key.operation_result', payload: { success, message } }`.
590
- */
591
- declare function handleMemoryForget(ws: WebSocket, msg: unknown, memoryStore: MemoryStore): Promise<void>;
592
-
593
- /**
594
- * MCP management handlers for the WebUI server (both the standalone
595
- * `wstackui` server and the CLI's embedded `--webui` server).
596
- *
597
- * These are thin WebSocket translators over the shared, surface-agnostic
598
- * management core in `@wrongstack/mcp` (`manage.ts`) — the SAME core the REPL
599
- * `/mcp` command writes against (same config.json, same MCPRegistry). All the
600
- * config IO, url/header persistence, and live registry start/stop logic lives
601
- * there; here we only map structured results to WS events the browser expects.
602
- */
603
-
604
- /** mcp.list — configured servers merged with live registry status + tools. */
605
- declare function handleMcpList(ws: WebSocket, _msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
606
- /** mcp.add — persist a new server (incl. url/headers) and start it if enabled. */
607
- declare function handleMcpAdd(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
608
- /** mcp.update — re-persist config (incl. url/headers) and re-apply to registry. */
609
- declare function handleMcpUpdate(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
610
- /** mcp.remove — stop the server and delete it from config. */
611
- declare function handleMcpRemove(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
612
- /** mcp.enable — flip enabled:true in config and start the server. */
613
- declare function handleMcpEnable(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
614
- /** mcp.disable — stop the server and flip enabled:false in config. */
615
- declare function handleMcpDisable(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
616
- /** mcp.sleep — stop a running server (config stays enabled). */
617
- declare function handleMcpSleep(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
618
- /** mcp.wake — restart a sleeping/stopped server from config. */
619
- declare function handleMcpWake(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
620
- /** mcp.restart — stop + start a server. */
621
- declare function handleMcpRestart(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
622
- /** mcp.discover — ensure the server is running and report its live tools. */
623
- declare function handleMcpDiscover(ws: WebSocket, msg: WSClientMessage, globalConfigPath: string, mcpRegistry?: MCPRegistry): Promise<void>;
624
-
625
- /**
626
- * Custom context modes — user-defined presets that are loaded from disk,
627
- * merged with the built-in modes, and managed via WebSocket CRUD handlers.
628
- *
629
- * Stored in: ~/.wrongstack/custom-context-modes.json
630
- * Format: { "modes": ContextWindowMode[] }
631
- */
632
- interface CustomContextMode {
633
- id: string;
634
- name: string;
635
- description: string;
636
- thresholds: {
637
- warn: number;
638
- soft: number;
639
- hard: number;
640
- };
641
- aggressiveOn: string;
642
- preserveK: number;
643
- eliseThreshold: number;
644
- targetLoad: number;
645
- /** Whether this is a user-defined (custom) or built-in mode. */
646
- custom: boolean;
647
- }
648
- interface CustomModeStore {
649
- modes: Map<string, CustomContextMode>;
650
- load: () => Promise<void>;
651
- save: () => Promise<void>;
652
- create: (mode: CustomContextMode) => {
653
- ok: boolean;
654
- error?: string | undefined;
655
- };
656
- update: (id: string, patch: Partial<CustomContextMode>) => {
657
- ok: boolean;
658
- error?: string | undefined;
659
- };
660
- remove: (id: string) => {
661
- ok: boolean;
662
- error?: string | undefined;
663
- };
664
- list: () => CustomContextMode[];
674
+ * Shared prompt-library WebSocket handlers for BOTH the standalone WebUI server
675
+ * (`packages/webui/src/server/index.ts`) and the CLI's `--webui` embedded server
676
+ * (`packages/cli/src/webui-server.ts`). One source of truth so the two servers
677
+ * never drift (the lesson from skills-handlers).
678
+ *
679
+ * Each function handles one request→response cycle; callers drop them into their
680
+ * switch:
681
+ *
682
+ * case 'prompts.search': return handlePromptsSearch(ws, promptsCtx, msg);
683
+ *
684
+ * The prompt library is read across three layers (builtin + user + project) by
685
+ * the injected `PromptLoader`; writes (create/favorite) go to the user layer
686
+ * with copy-on-write for builtins. Treat synced/builtin content as DATA — these
687
+ * handlers never execute it; the client inserts a chosen prompt into the chat
688
+ * input as an ordinary user turn.
689
+ */
690
+
691
+ interface PromptsContext {
692
+ /** Backs all prompt ops. Absent feature unavailable. */
693
+ promptLoader: PromptLoader | undefined;
694
+ /** Records per-slug insert counts (shared with CLI `/prompt recent`). */
695
+ promptUsage?: PromptUsageStore | undefined;
665
696
  }
666
- declare function createCustomModeStore(wrongstackDir: string): CustomModeStore;
697
+ declare function handlePromptsList(ws: WSLike, ctx: PromptsContext): Promise<void>;
698
+ declare function handlePromptsSearch(ws: WSLike, ctx: PromptsContext, msg: unknown): Promise<void>;
699
+ declare function handlePromptsContent(ws: WSLike, ctx: PromptsContext, msg: unknown): Promise<void>;
700
+ declare function handlePromptsFavorite(ws: WSLike, ctx: PromptsContext, msg: unknown): Promise<void>;
701
+ declare function handlePromptsCreate(ws: WSLike, ctx: PromptsContext, msg: unknown): Promise<void>;
702
+ /** Record that a prompt was inserted (best-effort; feeds CLI `/prompt recent`). */
703
+ declare function handlePromptsUsed(ws: WSLike, ctx: PromptsContext, msg: unknown): Promise<void>;
704
+ /** Return recently-inserted prompt slugs (most-recent first) for the modal's Recent view. */
705
+ declare function handlePromptsRecent(ws: WSLike, ctx: PromptsContext): Promise<void>;
706
+ /** Minimal structural type for the ws.send sink (matches `ws` WebSocket). */
707
+ type WSLike = Parameters<typeof send>[0];
667
708
 
668
- /** A hostname that refers to the local machine. */
669
- declare function isLoopbackHostname(hostname: string): boolean;
670
- /** True when the server is bound to a loopback interface (vs. LAN/0.0.0.0). */
671
- declare function isLoopbackBind(wsHost: string): boolean;
672
709
  /**
673
- * Constant-time comparison of a provided token against the expected one.
674
- * A length mismatch short-circuits (lengths aren't secret); equal-length
675
- * inputs are compared with `timingSafeEqual` so the token can't be recovered
676
- * byte-by-byte via response timing.
710
+ * Read the `providers` section from the global config, decrypting
711
+ * secret-bearing fields. Returns an empty record when the config file
712
+ * doesn't exist or has no `providers` key.
677
713
  */
678
- declare function tokenMatches(provided: string | undefined, expected: string): boolean;
679
- /** Pull the `token` query param out of a request URL (`/?token=…`). */
680
- declare function extractToken(url: string): string | undefined;
714
+ declare function loadSavedProviders(configPath: string, vault: SecretVault): Promise<Record<string, ProviderConfig>>;
681
715
  /**
682
- * DNS-rebinding defense. On a loopback bind, the `Host` header must resolve to
683
- * a loopback name. When the operator deliberately exposes the socket (wsHost is
684
- * a LAN/0.0.0.0 address) the Host is legitimately non-loopback, so the guard is
685
- * skipped and connection auth falls to the token check.
716
+ * Write `providers` back into the global config, encrypting secrets first.
717
+ * Refuses to overwrite a corrupt-but-existing config file (the operator
718
+ * should fix it manually). When the config file is missing (ENOENT), starts
719
+ * from an empty object.
686
720
  */
687
- declare function hostHeaderOk(input: {
688
- hostHeader: string | undefined;
689
- wsHost: string;
690
- allowedHostnames?: readonly string[] | undefined;
691
- }): boolean;
692
- interface VerifyClientInput {
693
- /** Browser `Origin` header, or undefined for non-browser clients. */
694
- origin?: string | undefined;
695
- /** Request URL (`req.url`) — carries the `?token=…` query param. */
696
- url: string;
697
- /** `Host` header (`req.headers.host`). */
698
- hostHeader?: string | undefined;
699
- /** Peer address (`req.socket.remoteAddress`). */
700
- remoteAddress?: string | undefined;
701
- /** `Cookie` header (`req.headers.cookie`). Carries `ws_token=…` when the
702
- * browser went through `/ws-auth` to set the HttpOnly auth cookie. */
703
- cookieHeader?: string | string[] | undefined;
704
- /** Host/interface the WS server is bound to. */
705
- wsHost: string;
706
- /** The server's generated auth token. */
707
- expectedToken: string;
708
- /** Force token auth even for loopback binds, useful behind public tunnels. */
709
- requireToken?: boolean | undefined;
710
- /** Extra Host header names allowed on loopback binds, e.g. a tunnel hostname. */
711
- allowedHostnames?: readonly string[] | undefined;
712
- /** Allow browser WS URL tokens for explicit public WS URLs where cookies cannot cross hostnames. */
713
- allowBrowserUrlToken?: boolean | undefined;
714
- }
721
+ declare function saveProviders(configPath: string, vault: SecretVault, providers: Record<string, ProviderConfig>): Promise<void>;
715
722
  /**
716
- * Decide whether to accept an incoming WebSocket handshake. Pure mirror of the
717
- * closure previously inlined in `index.ts`; see the module doc for the layered
718
- * policy. Returns `true` to accept, `false` to reject.
719
- *
720
- * Token sources, in priority order:
721
- * 1. `Cookie: ws_token=…` (browser clients that went through `/ws-auth`)
722
- * 2. `?token=…` URL query param (non-browser clients: curl, scripts)
723
- *
724
- * Browser clients (with an `Origin` header) are restricted to the cookie path —
725
- * URL token is rejected for them, closing the C-598 query-string token
726
- * exposure class. Non-browser clients keep the URL-token fallback so curl
727
- * and tests continue to work.
723
+ * Small helper for the standalone WebUI entry point: create a
724
+ * `{ load, save }` pair from a config path alone (uses the
725
+ * config-directory-relative `.key` file for the vault). The `--webui`
726
+ * CLI mode and the standalone server both need to read/write the
727
+ * `providers` map identically.
728
728
  */
729
- declare function verifyClient(input: VerifyClientInput): boolean;
729
+ declare function createProviderConfigIO(configPath: string): {
730
+ load: () => Promise<Record<string, ProviderConfig>>;
731
+ save: (providers: Record<string, ProviderConfig>) => Promise<void>;
732
+ };
730
733
 
731
734
  /**
732
735
  * Pure provider/API-key record transforms for the WebUI server's `key.*` and
@@ -781,147 +784,6 @@ declare function addProvider(providers: ProvidersRecord, payload: {
781
784
  /** Remove an entire provider and all its keys. */
782
785
  declare function removeProvider(providers: ProvidersRecord, providerId: string): KeyOpResult;
783
786
 
784
- /**
785
- * Read the `providers` section from the global config, decrypting
786
- * secret-bearing fields. Returns an empty record when the config file
787
- * doesn't exist or has no `providers` key.
788
- */
789
- declare function loadSavedProviders(configPath: string, vault: SecretVault): Promise<Record<string, ProviderConfig>>;
790
- /**
791
- * Write `providers` back into the global config, encrypting secrets first.
792
- * Refuses to overwrite a corrupt-but-existing config file (the operator
793
- * should fix it manually). When the config file is missing (ENOENT), starts
794
- * from an empty object.
795
- */
796
- declare function saveProviders(configPath: string, vault: SecretVault, providers: Record<string, ProviderConfig>): Promise<void>;
797
- /**
798
- * Small helper for the standalone WebUI entry point: create a
799
- * `{ load, save }` pair from a config path alone (uses the
800
- * config-directory-relative `.key` file for the vault). The `--webui`
801
- * CLI mode and the standalone server both need to read/write the
802
- * `providers` map identically.
803
- */
804
- declare function createProviderConfigIO(configPath: string): {
805
- load: () => Promise<Record<string, ProviderConfig>>;
806
- save: (providers: Record<string, ProviderConfig>) => Promise<void>;
807
- };
808
-
809
- interface AutoPhaseWSMessage {
810
- type: string;
811
- payload?: Record<string, unknown>;
812
- }
813
- /**
814
- * AutoPhaseWebSocketHandler — WebSocket-based AutoPhase control.
815
- *
816
- * Message types:
817
- * autophase.start → { title, phases?, autonomous? }
818
- * autophase.pause → {}
819
- * autophase.resume → {}
820
- * autophase.stop → {}
821
- * autophase.status → {}
822
- * autophase.selectPhase → { phaseId }
823
- * autophase.taskStatus → { taskId, status }
824
- */
825
- declare class AutoPhaseWebSocketHandler {
826
- private agent;
827
- private context;
828
- private logger;
829
- private events?;
830
- private projectRoot?;
831
- private orchestrator;
832
- private graph;
833
- private store;
834
- private clients;
835
- private broadcastInterval;
836
- /** Aborts in-flight task agents AND the planning turn when the run is stopped. */
837
- private abort;
838
- /** Set the instant a stop/clear/revert is requested, so a planning turn that
839
- * resolves afterwards never launches the orchestrator (the abort alone can't
840
- * cover the window between the LLM call resolving and the orchestrator start). */
841
- private stopping;
842
- /** Optional per-phase git-worktree isolation (lazily created at start). */
843
- private worktrees;
844
- /** Base branch + tip SHA captured at run start so a revert can git-revert the
845
- * run's squash commits (history-preserving) instead of a destructive reset. */
846
- private runBase;
847
- /** Per-run worker identities so the board can show "who is on what". */
848
- private usedNicknames;
849
- constructor(agent: Agent, context: Context, logger: Logger, storeDir: string, events?: EventBus | undefined, projectRoot?: string | undefined);
850
- addClient(ws: WebSocket): void;
851
- handleMessage(msg: AutoPhaseWSMessage): Promise<void>;
852
- private handleStart;
853
- /**
854
- * Halt the run NOW — at any phase. Sets `stopping` (so a planning turn that
855
- * resolves afterwards bails), aborts in-flight agents, stops the orchestrator
856
- * tick, and ends the live broadcast. The board is kept for review; use
857
- * `autophase.clear` to reset or `autophase.revert` to undo the changes.
858
- */
859
- private handleStop;
860
- /**
861
- * Stop + wipe: tear down phase worktrees and reset to an empty board so the UI
862
- * returns to the start screen ("new one"). Does NOT touch already-merged commits
863
- * on the base branch — that is `autophase.revert`.
864
- */
865
- private handleClear;
866
- /**
867
- * Stop + undo: remove phase worktrees, then history-preservingly `git revert`
868
- * every commit this run landed on the base branch (captured `runBase`..HEAD),
869
- * then reset to an empty board. Refuses (reports a reason) on a dirty tree or a
870
- * conflicting revert rather than leaving the tree half-reverted.
871
- */
872
- private handleRevert;
873
- /** Generic fallback phases when the LLM planner produces nothing usable. */
874
- private defaultPhases;
875
- /** Plan phases+todos for the goal via the LLM; fall back to defaults on failure.
876
- * The caller passes the run's abort signal so a stop during planning cancels
877
- * the LLM turn (the previous fresh, never-aborted controller made planning
878
- * uninterruptible). */
879
- private planPhases;
880
- private executeTaskWithAgent;
881
- /** Persist + broadcast after an interactive board mutation. */
882
- private afterBoardMutation;
883
- private handleTaskStatusChange;
884
- private startBroadcast;
885
- private stopBroadcast;
886
- private broadcastState;
887
- private buildState;
888
- private sendState;
889
- private broadcast;
890
- private send;
891
- }
892
-
893
- interface SpecsWSMessage {
894
- type: string;
895
- payload?: Record<string, unknown>;
896
- }
897
- /**
898
- * SpecsWebSocketHandler — read-only-ish browser of persisted SDD specs and their
899
- * task graphs, rendered as a FORGE-style dependency board (topological phase
900
- * columns + dependency refs). Shared by both webui servers via specs-routes.
901
- *
902
- * Message types:
903
- * specs.list → all specs + progress
904
- * specs.get { specId } → one spec's dependency board
905
- * specs.taskStatus { graphId, taskId, status } → update + rebroadcast
906
- */
907
- declare class SpecsWebSocketHandler {
908
- private specStore;
909
- private graphStore;
910
- private clients;
911
- constructor(specsDir: string, taskGraphsDir: string);
912
- addClient(ws: WebSocket): void;
913
- handleMessage(msg: SpecsWSMessage): Promise<void>;
914
- private buildList;
915
- private broadcastList;
916
- private sendList;
917
- private broadcastDetail;
918
- private findGraphForSpec;
919
- private buildDetail;
920
- private updateTaskStatus;
921
- private broadcast;
922
- private send;
923
- }
924
-
925
787
  interface SddBoardWSMessage {
926
788
  type: string;
927
789
  payload?: Record<string, unknown>;
@@ -1061,7 +923,18 @@ interface SddWizardWiringOptions {
1061
923
  projectDir: string;
1062
924
  };
1063
925
  }
1064
- declare function buildSddWizardDeps(opts: SddWizardWiringOptions): SddWizardDeps;
926
+ declare function buildSddWizardDeps(opts: SddWizardWiringOptions): SddWizardDeps;
927
+
928
+ type ShellOpenTarget = 'terminal' | 'file-manager';
929
+ interface ShellOpenRequest {
930
+ path: string;
931
+ target: ShellOpenTarget;
932
+ }
933
+ interface ShellOpenResult {
934
+ success: boolean;
935
+ message: string;
936
+ }
937
+ declare function handleShellOpen(req: ShellOpenRequest, logger: Logger): Promise<ShellOpenResult>;
1065
938
 
1066
939
  /**
1067
940
  * Shared skills WebSocket handlers for both the standalone WebUI server
@@ -1128,81 +1001,208 @@ declare function handleSkillsEdit(ws: WebSocket, ctx: SkillsContext, msg: unknow
1128
1001
  */
1129
1002
  declare function handleSkillsExport(ws: WebSocket, ctx: SkillsContext): Promise<void>;
1130
1003
 
1004
+ interface SpecsWSMessage {
1005
+ type: string;
1006
+ payload?: Record<string, unknown>;
1007
+ }
1131
1008
  /**
1132
- * Shared prompt-library WebSocket handlers for BOTH the standalone WebUI server
1133
- * (`packages/webui/src/server/index.ts`) and the CLI's `--webui` embedded server
1134
- * (`packages/cli/src/webui-server.ts`). One source of truth so the two servers
1135
- * never drift (the lesson from skills-handlers).
1136
- *
1137
- * Each function handles one request→response cycle; callers drop them into their
1138
- * switch:
1139
- *
1140
- * case 'prompts.search': return handlePromptsSearch(ws, promptsCtx, msg);
1009
+ * SpecsWebSocketHandler — read-only-ish browser of persisted SDD specs and their
1010
+ * task graphs, rendered as a FORGE-style dependency board (topological phase
1011
+ * columns + dependency refs). Shared by both webui servers via specs-routes.
1141
1012
  *
1142
- * The prompt library is read across three layers (builtin + user + project) by
1143
- * the injected `PromptLoader`; writes (create/favorite) go to the user layer
1144
- * with copy-on-write for builtins. Treat synced/builtin content as DATA these
1145
- * handlers never execute it; the client inserts a chosen prompt into the chat
1146
- * input as an ordinary user turn.
1013
+ * Message types:
1014
+ * specs.list → all specs + progress
1015
+ * specs.get { specId } → one spec's dependency board
1016
+ * specs.taskStatus { graphId, taskId, status } update + rebroadcast
1147
1017
  */
1148
-
1149
- interface PromptsContext {
1150
- /** Backs all prompt ops. Absent ⇒ feature unavailable. */
1151
- promptLoader: PromptLoader | undefined;
1152
- /** Records per-slug insert counts (shared with CLI `/prompt recent`). */
1153
- promptUsage?: PromptUsageStore | undefined;
1018
+ declare class SpecsWebSocketHandler {
1019
+ private specStore;
1020
+ private graphStore;
1021
+ private clients;
1022
+ constructor(specsDir: string, taskGraphsDir: string);
1023
+ addClient(ws: WebSocket): void;
1024
+ handleMessage(msg: SpecsWSMessage): Promise<void>;
1025
+ private buildList;
1026
+ private broadcastList;
1027
+ private sendList;
1028
+ private broadcastDetail;
1029
+ private findGraphForSpec;
1030
+ private buildDetail;
1031
+ private updateTaskStatus;
1032
+ private broadcast;
1033
+ private send;
1154
1034
  }
1155
- declare function handlePromptsList(ws: WSLike, ctx: PromptsContext): Promise<void>;
1156
- declare function handlePromptsSearch(ws: WSLike, ctx: PromptsContext, msg: unknown): Promise<void>;
1157
- declare function handlePromptsContent(ws: WSLike, ctx: PromptsContext, msg: unknown): Promise<void>;
1158
- declare function handlePromptsFavorite(ws: WSLike, ctx: PromptsContext, msg: unknown): Promise<void>;
1159
- declare function handlePromptsCreate(ws: WSLike, ctx: PromptsContext, msg: unknown): Promise<void>;
1160
- /** Record that a prompt was inserted (best-effort; feeds CLI `/prompt recent`). */
1161
- declare function handlePromptsUsed(ws: WSLike, ctx: PromptsContext, msg: unknown): Promise<void>;
1162
- /** Return recently-inserted prompt slugs (most-recent first) for the modal's Recent view. */
1163
- declare function handlePromptsRecent(ws: WSLike, ctx: PromptsContext): Promise<void>;
1164
- /** Minimal structural type for the ws.send sink (matches `ws` WebSocket). */
1165
- type WSLike = Parameters<typeof send>[0];
1166
1035
 
1167
1036
  /**
1168
- * Shared Design Studio WebSocket handlers for both the standalone WebUI server
1169
- * (`packages/webui/src/server/index.ts`) and the CLI's `--webui` embedded
1170
- * server (`packages/cli/src/webui-server.ts`). One source of truth keeps the two
1171
- * servers at parity (enforced by ws-handler-parity.test.ts).
1037
+ * Per-section context-window token estimate for the `context.debug` command.
1172
1038
  *
1173
- * case 'design.list': return handleDesignList(ws, designCtx);
1174
- * case 'design.use': return handleDesignUse(ws, designCtx, msg);
1175
- * case 'design.state': return handleDesignState(ws, designCtx);
1176
- * case 'design.set': return handleDesignSet(ws, designCtx, msg);
1177
- * case 'design.materialize': return handleDesignMaterialize(ws, designCtx, msg);
1039
+ * Uses the simple 4-chars-per-token heuristic — not exact, but close enough to
1040
+ * spot which section (system prompt, tool schemas, or message history) is
1041
+ * eating the context window. Tool schemas in particular are easy to overlook:
1042
+ * each tool ships its full JSON schema to the model every turn, so 20+ builtins
1043
+ * can cost 10-20k tokens on their own.
1178
1044
  *
1179
- * Browsing + customization of curated UI design kits; `design.use` pins the
1180
- * active kit, `design.set` records color/token overrides, `design.materialize`
1181
- * writes the (override-applied) tokens to a real theme file on disk.
1045
+ * Extracted from `index.ts` as a pure function so the breakdown maths can be
1046
+ * unit tested without standing up a Context/ToolRegistry.
1182
1047
  */
1048
+ /** 4-chars-per-token heuristic estimate for a string. */
1049
+ declare function estimateTokens(s: string): number;
1050
+ /** Stringify arbitrary content for length estimation (JSON, with fallbacks). */
1051
+ declare function stringifyContent(c: unknown): string;
1052
+ interface ToolTokenEntry {
1053
+ name: string;
1054
+ tokens: number;
1055
+ }
1056
+ interface MessageTokenEntry {
1057
+ index: number;
1058
+ role: string;
1059
+ tokens: number;
1060
+ preview: string;
1061
+ }
1062
+ interface ContextBreakdown {
1063
+ total: number;
1064
+ systemPrompt: number;
1065
+ tools: {
1066
+ total: number;
1067
+ count: number;
1068
+ breakdown: ToolTokenEntry[];
1069
+ };
1070
+ messages: {
1071
+ total: number;
1072
+ count: number;
1073
+ breakdown: MessageTokenEntry[];
1074
+ };
1075
+ }
1076
+ declare function messageTokens(content: unknown): number;
1077
+ declare function messagePreview(content: unknown): string;
1183
1078
 
1184
- interface DesignContext {
1079
+ interface WorktreeManagementDeps {
1185
1080
  projectRoot: string;
1186
- /** Live agent context whose `meta.designStudio` we read/pin. Optional. */
1187
- agentMeta?: {
1188
- meta: Record<string, unknown>;
1189
- } | undefined;
1081
+ /** Board snapshot dir powers the cross-process liveness guard on cleanup. */
1082
+ boardsDir: string;
1190
1083
  }
1191
- declare function handleDesignList(ws: WebSocket, ctx: DesignContext): Promise<void>;
1192
- declare function handleDesignState(ws: WebSocket, ctx: DesignContext): Promise<void>;
1193
- declare function handleDesignUse(ws: WebSocket, ctx: DesignContext, msg: {
1194
- payload?: unknown;
1195
- }): Promise<void>;
1196
- /** Record structured color/token overrides without changing the pinned kit. */
1197
- declare function handleDesignSet(ws: WebSocket, ctx: DesignContext, msg: {
1198
- payload?: unknown;
1199
- }): Promise<void>;
1200
- /** Write the active kit's (override-applied) tokens to a real theme file. */
1201
- declare function handleDesignMaterialize(ws: WebSocket, ctx: DesignContext, msg: {
1202
- payload?: unknown;
1203
- }): Promise<void>;
1204
- /** Scan project UI files for off-palette colors against the active kit. */
1205
- declare function handleDesignVerify(ws: WebSocket, ctx: DesignContext): Promise<void>;
1084
+ /**
1085
+ * WorktreeWebSocketHandler mirrors AutoPhaseWebSocketHandler. Subscribes to
1086
+ * the shared EventBus `worktree.*` lifecycle events, keeps a live snapshot of
1087
+ * every worktree, and broadcasts:
1088
+ * - `worktree.event` incrementally (drives the flowing activity strip)
1089
+ * - `worktree.state` on connect + on a 2s timer (drives swim-lanes/DAG)
1090
+ */
1091
+ declare class WorktreeWebSocketHandler {
1092
+ private readonly events;
1093
+ private readonly logger;
1094
+ private readonly management?;
1095
+ private readonly clients;
1096
+ private readonly handles;
1097
+ private baseBranch;
1098
+ private broadcastInterval;
1099
+ private readonly offs;
1100
+ constructor(events: EventBus, logger: Logger, management?: WorktreeManagementDeps | undefined);
1101
+ addClient(ws: WebSocket): void;
1102
+ /** Handle worktree-panel control messages (scan / clean / per-row ops). */
1103
+ handleMessage(msg: {
1104
+ type: string;
1105
+ payload?: Record<string, unknown>;
1106
+ }): Promise<boolean>;
1107
+ dispose(): void;
1108
+ /** Absolute managed-worktrees root for this project. */
1109
+ private worktreesRoot;
1110
+ /** True iff `dir` resolves strictly inside the managed worktrees root. */
1111
+ private underRoot;
1112
+ /** Branches of worktrees a live in-session run currently owns. */
1113
+ private liveActiveBranches;
1114
+ /**
1115
+ * Scan the disk for managed worktrees/branches NOT owned by a live in-session
1116
+ * run and broadcast them as orphans, with whether it is safe to clean now.
1117
+ * No-op (empty inventory) when management deps were not wired.
1118
+ */
1119
+ private scanAndBroadcast;
1120
+ /**
1121
+ * Force-remove every orphaned worktree + branch. Refused while a run is live —
1122
+ * in this session (active handles) OR another process (the SDD board liveness
1123
+ * guard inside cleanupStaleSddWorktrees). Best-effort; reports the outcome.
1124
+ */
1125
+ private cleanupOrphans;
1126
+ /** Remove/discard ONE worktree + branch. Refused while a live run owns it. */
1127
+ private removeOne;
1128
+ /** Squash-merge ONE branch into base. Refused while a live run owns it. */
1129
+ private mergeBranch;
1130
+ /** Compact change summary for one worktree checkout. */
1131
+ private diffOne;
1132
+ private subscribe;
1133
+ private upsert;
1134
+ private patch;
1135
+ private activity;
1136
+ private stateMessage;
1137
+ private broadcastState;
1138
+ private ensureBroadcast;
1139
+ private stopBroadcast;
1140
+ private broadcast;
1141
+ private send;
1142
+ }
1143
+
1144
+ /** A hostname that refers to the local machine. */
1145
+ declare function isLoopbackHostname(hostname: string): boolean;
1146
+ /** True when the server is bound to a loopback interface (vs. LAN/0.0.0.0). */
1147
+ declare function isLoopbackBind(wsHost: string): boolean;
1148
+ /**
1149
+ * Constant-time comparison of a provided token against the expected one.
1150
+ * A length mismatch short-circuits (lengths aren't secret); equal-length
1151
+ * inputs are compared with `timingSafeEqual` so the token can't be recovered
1152
+ * byte-by-byte via response timing.
1153
+ */
1154
+ declare function tokenMatches(provided: string | undefined, expected: string): boolean;
1155
+ /** Pull the `token` query param out of a request URL (`/?token=…`). */
1156
+ declare function extractToken(url: string): string | undefined;
1157
+ /**
1158
+ * DNS-rebinding defense. On a loopback bind, the `Host` header must resolve to
1159
+ * a loopback name. When the operator deliberately exposes the socket (wsHost is
1160
+ * a LAN/0.0.0.0 address) the Host is legitimately non-loopback, so the guard is
1161
+ * skipped and connection auth falls to the token check.
1162
+ */
1163
+ declare function hostHeaderOk(input: {
1164
+ hostHeader: string | undefined;
1165
+ wsHost: string;
1166
+ allowedHostnames?: readonly string[] | undefined;
1167
+ }): boolean;
1168
+ interface VerifyClientInput {
1169
+ /** Browser `Origin` header, or undefined for non-browser clients. */
1170
+ origin?: string | undefined;
1171
+ /** Request URL (`req.url`) — carries the `?token=…` query param. */
1172
+ url: string;
1173
+ /** `Host` header (`req.headers.host`). */
1174
+ hostHeader?: string | undefined;
1175
+ /** Peer address (`req.socket.remoteAddress`). */
1176
+ remoteAddress?: string | undefined;
1177
+ /** `Cookie` header (`req.headers.cookie`). Carries `ws_token=…` when the
1178
+ * browser went through `/ws-auth` to set the HttpOnly auth cookie. */
1179
+ cookieHeader?: string | string[] | undefined;
1180
+ /** Host/interface the WS server is bound to. */
1181
+ wsHost: string;
1182
+ /** The server's generated auth token. */
1183
+ expectedToken: string;
1184
+ /** Force token auth even for loopback binds, useful behind public tunnels. */
1185
+ requireToken?: boolean | undefined;
1186
+ /** Extra Host header names allowed on loopback binds, e.g. a tunnel hostname. */
1187
+ allowedHostnames?: readonly string[] | undefined;
1188
+ /** Allow browser WS URL tokens for explicit public WS URLs where cookies cannot cross hostnames. */
1189
+ allowBrowserUrlToken?: boolean | undefined;
1190
+ }
1191
+ /**
1192
+ * Decide whether to accept an incoming WebSocket handshake. Pure mirror of the
1193
+ * closure previously inlined in `index.ts`; see the module doc for the layered
1194
+ * policy. Returns `true` to accept, `false` to reject.
1195
+ *
1196
+ * Token sources, in priority order:
1197
+ * 1. `Cookie: ws_token=…` (browser clients that went through `/ws-auth`)
1198
+ * 2. `?token=…` URL query param (non-browser clients: curl, scripts)
1199
+ *
1200
+ * Browser clients (with an `Origin` header) are restricted to the cookie path —
1201
+ * URL token is rejected for them, closing the C-598 query-string token
1202
+ * exposure class. Non-browser clients keep the URL-token fallback so curl
1203
+ * and tests continue to work.
1204
+ */
1205
+ declare function verifyClient(input: VerifyClientInput): boolean;
1206
1206
 
1207
1207
  declare function startWebUI(opts?: WebUIOptions & {
1208
1208
  wsPort?: number | undefined;