@wrongstack/cli 1.0.33 → 1.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/dist/{acp-EDGUWU7N.js → acp-T77EAEDB.js} +469 -465
  2. package/dist/{auth-SUHKFFO4.js → auth-M6LXZTDA.js} +4 -4
  3. package/dist/auth-menu/openai-codex-oauth.d.ts +1 -1
  4. package/dist/auth-menu/panel-provider-edits.d.ts +15 -0
  5. package/dist/auth-menu/panel-service.d.ts +1 -1
  6. package/dist/boot/governance-evidence-candidate.d.ts +1 -2
  7. package/dist/boot/system-prompt-builder.d.ts +1 -1
  8. package/dist/boot/tui-project-spawn.d.ts +0 -1
  9. package/dist/boot/tui-session-resume-contracts.d.ts +95 -0
  10. package/dist/boot/tui-session-resume.d.ts +2 -88
  11. package/dist/boot/tui-settings-read.d.ts +3 -0
  12. package/dist/{boot-auto-thin-K2DHHYIW.js → boot-auto-thin-YMAZI3QL.js} +2 -2
  13. package/dist/boot-launch-choices.d.ts +22 -0
  14. package/dist/boot-provider-gate.d.ts +28 -0
  15. package/dist/boot-subcommand-dispatch.d.ts +24 -0
  16. package/dist/boot.d.ts +1 -1
  17. package/dist/chimera-cascade-evidence.d.ts +1 -1
  18. package/dist/{chunk-TI7KLHYX.js → chunk-2CMMWRQH.js} +81 -80
  19. package/dist/{chunk-3LEFXLAW.js → chunk-6SIUTX75.js} +36 -36
  20. package/dist/{chunk-RQ4UBKYH.js → chunk-6Z6C6ZHH.js} +3 -3
  21. package/dist/{chunk-5EA6OTLJ.js → chunk-AFOH6CHO.js} +2 -2
  22. package/dist/{chunk-WHEYHDIE.js → chunk-AKESWZH5.js} +2 -1
  23. package/dist/{chunk-JHUJUIOH.js → chunk-AT2A7RJZ.js} +4 -16
  24. package/dist/{chunk-U3SS4NRH.js → chunk-BHXAKTBE.js} +1 -1
  25. package/dist/{chunk-MPQCHBDJ.js → chunk-CJSHS2K7.js} +5 -1
  26. package/dist/{chunk-P3GRLV47.js → chunk-GZSKLXYG.js} +208 -141
  27. package/dist/{chunk-4MDRHMX4.js → chunk-KMU7GF7X.js} +3 -26
  28. package/dist/{chunk-SWOPJHXI.js → chunk-LDV4ERNV.js} +4 -4
  29. package/dist/{chunk-24C6VVLM.js → chunk-LDXXMPMS.js} +12 -12
  30. package/dist/{chunk-UQUYBDMG.js → chunk-M4G5FO7F.js} +1 -1
  31. package/dist/{chunk-5UZBUV2H.js → chunk-RKU66D2O.js} +6 -4
  32. package/dist/{chunk-PGNERAZL.js → chunk-RWNI5OWT.js} +2 -2
  33. package/dist/chunk-TGSPCFBT.js +1 -0
  34. package/dist/{cli-context-7HK67B2Z.js → cli-context-II57EHD2.js} +1554 -1450
  35. package/dist/cli-initial-tools.d.ts +54 -0
  36. package/dist/{cli-main-TQGLKB6D.js → cli-main-YR2WJCPL.js} +38062 -37035
  37. package/dist/cli-main-boot.d.ts +133 -0
  38. package/dist/cli-main-orchestration.d.ts +170 -0
  39. package/dist/cli-main-state.d.ts +19 -0
  40. package/dist/config-doctor.d.ts +1 -1
  41. package/dist/{config-transfer-UE37X4B5.js → config-transfer-VOIFY7U4.js} +2 -2
  42. package/dist/{execution-C2IECWF7.js → execution-IBAZ2EIU.js} +138 -124
  43. package/dist/fleet/host-director-setup.d.ts +41 -0
  44. package/dist/fleet/host-spawn-types.d.ts +17 -0
  45. package/dist/fleet/host.d.ts +2 -1
  46. package/dist/goal-host-prompts.d.ts +5 -0
  47. package/dist/goal-host-types.d.ts +94 -0
  48. package/dist/goal-host.d.ts +2 -91
  49. package/dist/{hq-KBFDKTDD.js → hq-5OIEKB4V.js} +5 -6
  50. package/dist/hq-server/handle-types.d.ts +1 -1
  51. package/dist/hq-server/routes/auth-handlers.d.ts +1 -1
  52. package/dist/hq-server/routes.d.ts +1 -13
  53. package/dist/hq-server/utils.d.ts +1 -1
  54. package/dist/{hq-server-AVNKZ2PV.js → hq-server-DU3ZH3X7.js} +3 -3
  55. package/dist/{hq-service-FGB7BY2D.js → hq-service-LYZV7EK7.js} +3 -3
  56. package/dist/index.js +4 -4
  57. package/dist/live-settings-input.d.ts +2 -0
  58. package/dist/mailbox-bridge-bootstrap.d.ts +1 -4
  59. package/dist/picker-effort.d.ts +32 -0
  60. package/dist/picker-model-picker.d.ts +13 -2
  61. package/dist/picker-provider-list.d.ts +66 -0
  62. package/dist/picker.d.ts +12 -12
  63. package/dist/{providers-models-O75MKLN2.js → providers-models-37DUV2GW.js} +247 -237
  64. package/dist/repl-suggestions.d.ts +0 -5
  65. package/dist/{sdd-runtime-PTOSPYI4.js → sdd-runtime-PF72WHLO.js} +3 -20
  66. package/dist/services/sdd/task-manager.d.ts +1 -12
  67. package/dist/services/sdd-runtime.d.ts +3 -4
  68. package/dist/services/statusline-config.d.ts +0 -2
  69. package/dist/settings-behavior-sections.d.ts +0 -1
  70. package/dist/short-circuit-flags-EXJBJ5GT.js +10 -0
  71. package/dist/{skill-suggest-L2EVWB6R.js → skill-suggest-THANK3LV.js} +1 -1
  72. package/dist/slash-commands/command-context.d.ts +2 -0
  73. package/dist/slash-commands/fallback-chain-view.d.ts +3 -0
  74. package/dist/slash-commands/fallback-ref-validation.d.ts +34 -0
  75. package/dist/slash-commands/goal-refiner.d.ts +10 -2
  76. package/dist/slash-commands/goals.d.ts +6 -0
  77. package/dist/slash-commands/kanban-task-completion.d.ts +17 -0
  78. package/dist/slash-commands/mcp.d.ts +0 -1
  79. package/dist/slash-commands/memory-purge-domain-terms.d.ts +5 -0
  80. package/dist/slash-commands/nextsteps-mode.d.ts +13 -0
  81. package/dist/slash-commands/session-live-status.d.ts +21 -0
  82. package/dist/slash-commands/session.d.ts +1 -1
  83. package/dist/slash-commands/settings-feature-ui-mutations.d.ts +8 -0
  84. package/dist/slash-commands/settings-hq-autonomy-mutations.d.ts +8 -0
  85. package/dist/slash-commands/settings-persist-deps.d.ts +10 -0
  86. package/dist/slash-commands/settings-runtime-mutations.d.ts +12 -0
  87. package/dist/slash-commands/shadow.d.ts +0 -1
  88. package/dist/slash-commands/tool-auto-thin-commands.d.ts +15 -0
  89. package/dist/subcommands/handlers/acp-agents.d.ts +9 -0
  90. package/dist/subcommands/handlers/acp-server.d.ts +7 -0
  91. package/dist/subcommands/handlers/hq.d.ts +0 -1
  92. package/dist/subcommands/handlers/providers-models-custom.d.ts +33 -0
  93. package/dist/subcommands/handlers/providers-models-visibility.d.ts +29 -0
  94. package/dist/subcommands/handlers/skill-suggest.d.ts +0 -2
  95. package/dist/{subcommands-CLI6QTLU.js → subcommands-DTKBXLPU.js} +2 -2
  96. package/dist/{update-J5WHAFPE.js → update-6OEPAEUV.js} +3 -3
  97. package/dist/utils/win32-cmd.d.ts +1 -1
  98. package/dist/{version-help-ATOKGMTK.js → version-help-JQGRPDJW.js} +2 -2
  99. package/dist/webui-server/host-settings.d.ts +17 -0
  100. package/dist/webui-server/http-bridge.d.ts +32 -0
  101. package/dist/webui-server/instance-registration.d.ts +19 -0
  102. package/dist/webui-server/kanban-services.d.ts +9 -0
  103. package/dist/webui-server/message-router.d.ts +14 -0
  104. package/dist/webui-server/prefs-seeding.d.ts +1 -2
  105. package/dist/webui-server/provider-config.d.ts +0 -1
  106. package/dist/webui-server/provider-plane.d.ts +14 -0
  107. package/dist/webui-server/session-agents.d.ts +18 -0
  108. package/dist/webui-server/session-start-live.d.ts +11 -0
  109. package/dist/webui-server/stream-coalescer.d.ts +1 -1
  110. package/dist/webui-server/transport.d.ts +12 -0
  111. package/dist/{webui-server-GQOVLQS3.js → webui-server-6IC3SLZG.js} +1600 -1435
  112. package/dist/webui-session-maintenance.d.ts +16 -0
  113. package/dist/wiring/brain-and-orchestration.d.ts +1 -1
  114. package/dist/wiring/cli-slash-commands-setup.d.ts +1 -0
  115. package/dist/wiring/command-host-state.d.ts +11 -2
  116. package/dist/wiring/dep-watcher-bridge.d.ts +8 -1
  117. package/dist/wiring/session-command-handlers.d.ts +2 -1
  118. package/dist/worktree-command-host.d.ts +31 -0
  119. package/package.json +30 -30
  120. package/dist/chunk-WHOIR577.js +0 -1
  121. package/dist/short-circuit-flags-D6VAG47Y.js +0 -10
@@ -1,12 +1,12 @@
1
- import {
2
- WEBUI_SESSION_CHILD_CAPABILITIES
3
- } from "./chunk-P3Q3KMFH.js";
4
1
  import {
5
2
  createKanbanRunMirror
6
3
  } from "./chunk-VCYZ3WZV.js";
7
4
  import {
8
5
  createKanbanSupervisor
9
6
  } from "./chunk-R4QGL6GB.js";
7
+ import {
8
+ WEBUI_SESSION_CHILD_CAPABILITIES
9
+ } from "./chunk-P3Q3KMFH.js";
10
10
  import {
11
11
  loadConfigProviders,
12
12
  mutateConfigProviders
@@ -23,821 +23,273 @@ import {
23
23
  CLI_VERSION
24
24
  } from "./chunk-F5DCFAKI.js";
25
25
 
26
- // src/webui-server-lifecycle.ts
27
- import { toErrorMessage } from "@wrongstack/core/utils/error";
28
-
29
- // src/webui-server/connection-handler.ts
30
- import { deriveSessionAgents } from "@wrongstack/core/storage";
31
- import { buildReplayPayload, decodeProtocolFrame } from "@wrongstack/webui-protocol";
26
+ // src/webui-server.ts
27
+ import * as path8 from "node:path";
28
+ import { createCompatibilityTrustBoundary, DefaultSecretScrubber } from "@wrongstack/core/security";
29
+ import { addFatalSalvageHook } from "@wrongstack/core/utils";
32
30
  import {
33
- createConnectionLifecycle,
34
- verifyClient as verifyWsClient
31
+ buildWebUIAccessUrl,
32
+ createCustomModeStore,
33
+ createWebuiLeaderAutoWakeHost,
34
+ envFlag as envFlag2,
35
+ findFreePort,
36
+ resolveAuthToken,
37
+ startTerminalDashboard
35
38
  } from "@wrongstack/webui-server";
36
- function createConnectionHandler(deps) {
37
- const clientHandlers = [
38
- deps.goalHandler,
39
- deps.specsHandler,
40
- deps.sddBoardHandler,
41
- ...deps.sddWizardHandler ? [deps.sddWizardHandler] : [],
42
- deps.worktreeHandler,
43
- deps.terminalHandler
44
- ];
45
- return createConnectionLifecycle({
46
- clients: deps.clients,
47
- pendingConfirms: deps.pendingConfirms,
48
- // ── Auth trust model ────────────────────────────────────────────────
49
- // Three layered defenses (see verifyClient in @wrongstack/webui-server/ws-auth):
50
- // 1. DNS-rebinding guard (Host header must be loopback on loopback bind)
51
- // 2. Shared-token auth via Cookie (ws_token) or URL query param (?token=)
52
- // 3. Loopback bootstrap: when requireToken=false (default) AND the server
53
- // is bound to a loopback interface, browser clients bypass token auth
54
- // entirely — the Origin header being http://localhost is sufficient.
55
- //
56
- // COOKIE PERSISTENCE AFTER TOKEN ROTATION:
57
- // The HttpOnly ws_token cookie, once set by /ws-auth, survives a process
58
- // restart. On the next process the expectedToken is a fresh random value,
59
- // so the old cookie alone would fail the token check. However, on a
60
- // loopback bind with requireToken=false (default), the loopback-bootstrap
61
- // path at layer 3 accepts the connection before the cookie is ever
62
- // compared — the old cookie is harmless because no token is needed.
63
- //
64
- // This means a token rotation (e.g. the operator sets a new accessToken
65
- // in config) does NOT invalidate already-distributed cookies for loopback
66
- // clients. The mitigating factors:
67
- // - Loopback bind + requireToken=false already trusts any local process
68
- // that can reach 127.0.0.1 — a trivial perimeter.
69
- // - A LAN/0.0.0.0 bind with requireToken=true DOES enforce cookie
70
- // matching against the new expectedToken on every reconnect.
71
- // If stronger isolation is needed, set requireToken=true or rebind the
72
- // server to a non-loopback interface with token enforcement.
73
- authenticate: (ws, req) => {
74
- const allowed = verifyWsClient({
75
- origin: req.headers.origin,
76
- url: req.url ?? "/",
77
- hostHeader: req.headers.host,
78
- remoteAddress: req.socket.remoteAddress,
79
- cookieHeader: req.headers.cookie,
80
- wsHost: deps.host,
81
- expectedToken: deps.wsToken,
82
- requireToken: deps.requireToken,
83
- allowedHostnames: deps.publicHostnames,
84
- allowBrowserUrlToken: Boolean(deps.publicWsUrl)
85
- });
86
- if (!allowed) ws.close(4003, "Forbidden");
87
- return allowed;
88
- },
89
- createClient: (ws) => ({ ws, sessionId: deps.currentSessionId() }),
90
- // Enrich parse-failure / unknown-type logs with per-connection identity
91
- // so operators can identify which client sent a malformed frame. The
92
- // `connectionId`/`sessionId` fields are read from the `client` argument
93
- // directly inside `logRejection`; the CLI's `ConnectedClient` only has
94
- // `sessionId` (no per-socket `connId`), so the verbose payload carries
95
- // `sessionId` only. Server-wide identity (`agentId`/`projectRoot`) isn't
96
- // in scope at this handler and is omitted.
97
- verboseWsLogging: true,
98
- registerClient: (ws) => {
99
- for (const handler of clientHandlers) handler.addClient(ws);
100
- },
101
- decode: (raw) => decodeProtocolFrame(raw, "client"),
102
- dispatch: deps.handleMessage,
103
- send: deps.send,
104
- sessionPayload: deps.sessionPayload,
105
- rateLimitMax: deps.rateLimitMax,
106
- // NOTE — a closed SOCKET is not a stopped run.
107
- //
108
- // This used to `abortControllers.delete(client.sessionId)` — a delete
109
- // WITHOUT an abort, and only for the tab this connection last acted on.
110
- // Both halves were wrong. The run kept going while `isRunActive` reported
111
- // `false`, so `session.delete` would happily unlink the journal a live run
112
- // was still appending to, and the next `user_message` claimed a second
113
- // lock for the same session and hit `Agent.run() is already in progress`.
114
- // The other three declared tabs were never touched at all.
115
- //
116
- // Doing nothing is the correct behaviour: a background run outlives the
117
- // tab that started it (the same invariant `retireUndisplayedSessions`
118
- // honours), and the run's own `finally` releases the lock when it unwinds.
119
- // A page that comes back is told what is still running by
120
- // `session.run_state`; a browser that never comes back drains its
121
- // unanswerable permission prompts through the last-client drain in
122
- // `connection-lifecycle.ts`, which lets a wedged run finish and release.
123
- //
124
- // So there is deliberately NO `onClose` here.
125
- buildInitialPayload: async () => {
126
- const payload = { ...await deps.buildSessionStartPayload({}, deps.needsSetup) };
127
- try {
128
- const open = deps.openSessionIds?.();
129
- if (open) payload["openSessionIds"] = [.../* @__PURE__ */ new Set([...open, deps.currentSessionId()])];
130
- } catch {
131
- console.debug("[WebUI] Failed to list open sessions");
132
- }
133
- let replayEvents;
134
- try {
135
- const replay = await deps.loadReplay?.();
136
- if (replay) {
137
- Object.assign(payload, buildReplayPayload(replay));
138
- replayEvents = replay.events;
139
- }
140
- } catch {
141
- console.debug("[WebUI] Failed to load replay");
142
- }
143
- try {
144
- const roster = deriveSessionAgents(replayEvents ?? []);
145
- const sessions = roster.length > 0 ? await deps.loadAgentSessions?.(roster.map((agent) => agent.agentId)) : [];
146
- if (sessions?.length) payload["agentSessions"] = sessions;
147
- } catch {
148
- console.debug("[WebUI] Failed to load agent sessions");
149
- }
150
- return payload;
151
- }
39
+
40
+ // src/webui-client-observability.ts
41
+ import { startWebUILiveStatusLogger } from "@wrongstack/webui-server";
42
+
43
+ // src/webui-server/client-registration.ts
44
+ import { mailboxSessionTag } from "@wrongstack/core/coordination";
45
+ import { createWebuiClientPresence } from "@wrongstack/webui-server";
46
+ function createWebuiClientRegistration(deps) {
47
+ const control = deps.hqControl;
48
+ return createWebuiClientPresence({
49
+ projectRoot: deps.projectRoot,
50
+ appConfig: deps.appConfig,
51
+ events: deps.events,
52
+ hqSessionId: deps.hqSessionId,
53
+ getSessionId: deps.getSessionId,
54
+ clientVersion: CLI_VERSION,
55
+ ...deps.listSessions ? { listSessions: deps.listSessions } : {},
56
+ ...deps.isSessionOwnedElsewhere ? { isSessionOwnedElsewhere: deps.isSessionOwnedElsewhere } : {},
57
+ ...deps.getSessionWriter ? { getSessionWriter: deps.getSessionWriter } : {},
58
+ startHqConnection: (options) => startCliHqConnection({
59
+ ...options,
60
+ capabilities: options.capabilities,
61
+ // Embedded in the CLI host, whose own HQ connection already syncs
62
+ // this project's Kanban boards; a second sync is a second writer.
63
+ ownKanbanSync: false
64
+ }),
65
+ ...control ? {
66
+ createCommandHandler: (mailbox, approvals) => createHqCommandDispatcher({
67
+ steerMailbox: mailbox,
68
+ interruptLeader: control.interruptLeader,
69
+ allowRunCommand: control.allowRunCommand,
70
+ // Same registry the approval bridge publishes from, so every
71
+ // prompt HQ shows is one this handler can actually answer.
72
+ resolveApproval: (toolUseId, decision, sessionId) => approvals.resolve(toolUseId, decision, sessionId),
73
+ // Same registry again: it publishes `user_input.requested`, so
74
+ // it has to be the one that answers `answer-input`.
75
+ resolveUserInput: (requestId, response, sessionId) => approvals.resolveUserInput?.(requestId, response, sessionId) ?? false,
76
+ ...deps.projectRoot ? {
77
+ kanbanTransition: createProjectKanbanTransitionHandler(deps.projectRoot),
78
+ kanbanAssign: createProjectKanbanAssignHandler(deps.projectRoot)
79
+ } : {},
80
+ // The session HQ actually SHOWS for this client — the boot
81
+ // session the telemetry bridge publishes under and the only one
82
+ // `interruptLeader` speaks for. `getSessionId` follows whichever
83
+ // browser tab is in front, which is a different session HQ has
84
+ // no view of and cannot control.
85
+ sessionTag: () => mailboxSessionTag(deps.hqSessionId),
86
+ // The session HQ falls back to when a command names none — the
87
+ // boot tab. A command that DOES name one is honoured (and
88
+ // refused when this host no longer has it) via `ownsSession`.
89
+ sessionId: () => deps.hqSessionId,
90
+ ...control.ownsSession ? { ownsSession: control.ownsSession } : {},
91
+ ...control.spawnAgent ? { spawnAgent: control.spawnAgent } : {},
92
+ ...control.killFleet ? { killFleet: control.killFleet } : {},
93
+ ...control.terminateAgent ? { terminateAgent: control.terminateAgent } : {}
94
+ })
95
+ } : {}
152
96
  });
153
97
  }
154
98
 
155
- // src/webui-server/lifecycle.ts
156
- import {
157
- announceWebuiReady,
158
- createWebuiShutdown,
159
- registerWebuiInstance,
160
- registerWebuiSignalHandlers
161
- } from "@wrongstack/webui-server";
162
-
163
- // src/webui-server/logger-shim.ts
164
- var structuredLine = (level, message) => JSON.stringify({
165
- level,
166
- event: "webui.goal",
167
- message,
168
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
169
- });
170
- var consoleLogger = {
171
- level: "debug",
172
- error(msg, _ctx) {
173
- console.error(structuredLine("error", msg));
174
- },
175
- warn(msg, _ctx) {
176
- console.warn(structuredLine("warn", msg));
177
- },
178
- info(msg, _ctx) {
179
- console.log(structuredLine("info", msg));
180
- },
181
- debug(msg, _ctx) {
182
- console.debug(structuredLine("debug", msg));
183
- },
184
- trace(msg, _ctx) {
185
- console.debug(structuredLine("trace", msg));
186
- },
187
- child(_bindings) {
188
- return this;
99
+ // src/webui-client-observability.ts
100
+ function createEmbeddedClientRegistration(opts, clients, abortControllers, getSessionAgents) {
101
+ const interruptSession = (sessionId) => {
102
+ const leaderId = sessionId ?? opts.session.id;
103
+ const controller = abortControllers.get(leaderId);
104
+ if (!controller) return false;
105
+ controller.abort();
106
+ try {
107
+ void Promise.resolve(opts.stopSessionFleet?.(leaderId)).catch(() => void 0);
108
+ } catch {
109
+ }
110
+ return true;
111
+ };
112
+ if (opts.interruptController) {
113
+ opts.interruptController.abortLeader = () => interruptSession(opts.agent.ctx.session?.id ?? opts.session.id);
189
114
  }
190
- };
191
-
192
- // src/webui-server-lifecycle.ts
193
- function runWebuiServerLifecycle({
194
- host,
195
- httpPort,
196
- setupEvents,
197
- opts,
198
- wsPort,
199
- accessUrl,
200
- wsToken,
201
- webuiInstanceRegistered,
202
- wss,
203
- httpServer,
204
- requireToken,
205
- publicHostnames,
206
- publicWsUrl,
207
- clients,
208
- currentSessionId,
209
- goalHandler,
210
- specsHandler,
211
- sddBoardHandler,
212
- sddWizardHandler,
213
- worktreeHandler,
214
- terminalHandler,
215
- rateLimitMax,
216
- send,
217
- sessionPayload,
218
- handleMessage,
219
- pendingConfirms,
220
- buildSessionStartPayload,
221
- sessionAgents,
222
- lifecycleState,
223
- abortControllers,
224
- flushAllStreamBuffers,
225
- eventUnsubscribers,
226
- releaseSessionSalvage,
227
- autoWakeHost,
228
- stopEmptySessionCleanup,
229
- credentialWatcherClose,
230
- kanbanRunMirror,
231
- kanbanSupervisor,
232
- stopKanbanSupervisorMemoryStats,
233
- unregisterWebuiClient,
234
- ipv6LoopbackServer,
235
- registryBaseDir,
236
- stopLiveStatusLogger,
237
- terminalLogView
238
- }) {
239
- const stopped = new Promise((resolve) => {
240
- let listeningAnnounced = false;
241
- const announceListening = () => {
242
- if (listeningAnnounced) return;
243
- listeningAnnounced = true;
244
- console.log(`[WebUI] WebSocket server running on ws://${host}:${httpPort}`);
245
- try {
246
- setupEvents();
247
- opts.onListening?.({
248
- httpPort,
249
- wsPort,
250
- host,
251
- url: accessUrl,
252
- authToken: wsToken,
253
- webuiInstanceRegistered
254
- });
255
- } catch (err) {
256
- consoleLogger.error("setup_events_failed", { message: toErrorMessage(err) });
257
- }
258
- };
259
- wss.on("listening", announceListening);
260
- if (httpServer?.server.listening || wss.address()) queueMicrotask(announceListening);
261
- wss.on(
262
- "connection",
263
- createConnectionHandler({
264
- host,
265
- wsToken,
266
- requireToken,
267
- publicHostnames,
268
- publicWsUrl,
269
- clients,
270
- currentSessionId,
271
- goalHandler,
272
- specsHandler,
273
- sddBoardHandler,
274
- sddWizardHandler,
275
- worktreeHandler,
276
- terminalHandler,
277
- rateLimitMax,
278
- send,
279
- sessionPayload,
280
- handleMessage,
281
- pendingConfirms,
282
- buildSessionStartPayload,
283
- loadReplay: async () => {
284
- const activeSession = opts.agent.ctx.session ?? opts.session;
285
- await activeSession.flush();
286
- if (opts.sessionStore) {
287
- const data = await opts.sessionStore.load(activeSession.id);
288
- return { messages: data.messages, events: data.events, usage: data.usage };
289
- }
290
- const usage = opts.agent.ctx.tokenCounter.total();
291
- return { messages: opts.agent.ctx.messages, usage };
292
- },
293
- loadAgentSessions: async (subagentIds) => await opts.agentTranscripts?.loadSessionsFromDisk(subagentIds) ?? [],
294
- // What this process is actually holding right now. The browser
295
- // reconciles its persisted tab strip against it, so a restarted server
296
- // is not dressed in the previous run's tabs.
297
- openSessionIds: () => sessionAgents.ids(),
298
- needsSetup: opts.needsSetup ?? false
299
- })
300
- );
301
- lifecycleState.signalShutdown = createWebuiShutdown({
302
- abortInFlight: () => {
303
- void lifecycleState.embeddedAutoHealDispose?.();
304
- for (const c of abortControllers.values()) c.abort();
305
- abortControllers.clear();
306
- },
307
- unsubscribeEvents: () => {
308
- flushAllStreamBuffers();
309
- for (const unsub of eventUnsubscribers) unsub();
310
- },
311
- stopOwnedChildren: async () => {
312
- try {
313
- const { getProcessRegistry } = await import("@wrongstack/tools");
314
- getProcessRegistry().killAll({ force: true, includeProtected: true });
315
- } catch (err) {
316
- console.debug(
317
- `[webui-server] process-registry killAll failed: ${err instanceof Error ? err.message : String(err)}`
318
- );
319
- }
320
- if (opts.mcpRegistry) {
321
- try {
322
- await opts.mcpRegistry.stopAll();
323
- } catch (err) {
324
- console.debug(
325
- `[webui-server] mcpRegistry.stopAll failed: ${err instanceof Error ? err.message : String(err)}`
326
- );
327
- }
328
- }
329
- },
330
- disposeResources: async () => {
331
- releaseSessionSalvage();
332
- autoWakeHost?.dispose();
333
- await stopEmptySessionCleanup?.dispose();
334
- await sessionAgents.closeAll().catch(() => void 0);
335
- credentialWatcherClose?.();
336
- credentialWatcherClose = void 0;
337
- goalHandler.dispose();
338
- sddBoardHandler.dispose();
339
- worktreeHandler.dispose();
340
- terminalHandler.dispose();
341
- kanbanRunMirror?.dispose();
342
- kanbanSupervisor?.dispose();
343
- void stopKanbanSupervisorMemoryStats?.();
344
- await lifecycleState.embeddedAutoHealDispose?.();
345
- lifecycleState.embeddedAutoHealDispose = null;
346
- unregisterWebuiClient();
347
- },
348
- closeClients: () => {
349
- for (const [ws] of clients) ws.close();
350
- clients.clear();
351
- },
352
- closeHttpServer: () => {
353
- ipv6LoopbackServer?.close();
354
- httpServer?.server.close();
355
- },
356
- wss,
357
- pid: process.pid,
358
- registryBaseDir,
359
- onStopped: () => {
360
- stopLiveStatusLogger();
361
- const muted = terminalLogView.mutedCount;
362
- terminalLogView.stop();
363
- if (muted > 0) {
364
- console.log(
365
- `[WebUI] ${muted} progress line(s) kept out of this terminal \u2014 set WEBUI_LOGS=1 to stream them.`
366
- );
367
- }
368
- opts.onExit?.();
369
- resolve();
115
+ return createWebuiClientRegistration({
116
+ projectRoot: opts.projectRoot,
117
+ appConfig: opts.appConfig,
118
+ events: opts.events,
119
+ hqSessionId: opts.session.id,
120
+ getSessionId: () => opts.agent.ctx.session?.id ?? opts.session.id,
121
+ // One HQ session per open tab. The set is the same one the terminal
122
+ // panel lists: every session a connected browser is displaying.
123
+ listSessions: () => {
124
+ const ids = /* @__PURE__ */ new Set();
125
+ for (const client of clients.values()) {
126
+ if (client.sessionId) ids.add(client.sessionId);
127
+ for (const id of client.sessionIds ?? []) ids.add(id);
370
128
  }
371
- });
372
- registerWebuiSignalHandlers(lifecycleState.signalShutdown);
129
+ return [...ids];
130
+ },
131
+ // `cli-main` already runs a tracker and a bridge for the root
132
+ // conversation; announcing it here too would put two trackers on one bus
133
+ // flushing the same agent list. Compared against the LIVE root writer:
134
+ // the CLI host's telemetry follows `ctx.session`, so once the root moves
135
+ // (session.new / resume) the old boot id is an ordinary tab this client
136
+ // must publish, and the new root id must not be published twice.
137
+ isSessionOwnedElsewhere: (sessionId) => sessionId === (opts.agent.ctx.session?.id ?? opts.session.id),
138
+ // The tab's own journal, so HQ takes its turns from the write path
139
+ // instead of tailing the file.
140
+ getSessionWriter: (sessionId) => getSessionAgents()?.peek(sessionId)?.ctx.session,
141
+ hqControl: {
142
+ interruptLeader: interruptSession,
143
+ ...opts.hqFleetControl ? {
144
+ killFleet: opts.hqFleetControl.killFleet,
145
+ terminateAgent: opts.hqFleetControl.terminateAgent,
146
+ spawnAgent: opts.hqFleetControl.spawnAgent
147
+ } : {},
148
+ allowRunCommand: () => opts.hqAllowExec === true,
149
+ // A command naming a tab this process no longer holds is refused, not
150
+ // redirected onto the boot session: the operator picked a terminal,
151
+ // and steering a different one is worse than not steering at all.
152
+ ownsSession: (sessionId) => sessionId === opts.session.id || getSessionAgents()?.has(sessionId) === true
153
+ }
373
154
  });
374
- return { stopped };
375
155
  }
376
-
377
- // src/webui-session-retirement.ts
378
- import { resolvePendingConfirmsForSession } from "@wrongstack/webui-server";
379
- function createWebuiSessionRetirement(inputs) {
380
- const { opts, getForegroundSession, pendingConfirms, abortControllers, sessionAgents } = inputs;
381
- const retireUndisplayedSessions = (sessionIds) => {
382
- for (const sessionId of sessionIds) {
383
- if (!sessionId || sessionId === opts.session.id) continue;
384
- if (sessionId === getForegroundSession().id) continue;
385
- const orphaned = resolvePendingConfirmsForSession(pendingConfirms, sessionId);
386
- if (orphaned > 0) {
387
- console.log(
388
- JSON.stringify({
389
- level: "warn",
390
- event: "webui.confirm_orphaned_by_tab_close",
391
- sessionId,
392
- count: orphaned,
393
- message: `Denied ${orphaned} unanswerable permission prompt(s) for closed session ${sessionId}.`
394
- })
395
- );
156
+ function startEmbeddedLiveStatusLogger(opts, clients, abortControllers, getSessionAgents, terminalLogView) {
157
+ return startWebUILiveStatusLogger({
158
+ title: opts.surface === "simpleui" ? "SimpleUI" : "WebUI",
159
+ events: opts.events,
160
+ dashboard: terminalLogView,
161
+ getSessionList: () => {
162
+ const ids = /* @__PURE__ */ new Set();
163
+ for (const client of clients.values()) {
164
+ if (client.sessionId) ids.add(client.sessionId);
165
+ for (const id of client.sessionIds ?? []) ids.add(id);
396
166
  }
397
- if (abortControllers.has(sessionId)) continue;
398
- opts.onSessionRetired?.(sessionId);
399
- const agent = sessionAgents.peek(sessionId);
400
- if (!agent || agent === opts.agent) continue;
401
- void sessionAgents.endAndClose(sessionId);
167
+ const currentId = opts.agent.ctx.session?.id ?? opts.session.id;
168
+ if (ids.size === 0 && currentId) ids.add(currentId);
169
+ return Array.from(ids).map((id) => {
170
+ const ctx = getSessionAgents()?.peek(id)?.ctx;
171
+ return {
172
+ id,
173
+ model: ctx?.model ?? opts.agent.ctx.model ?? "",
174
+ provider: ctx?.provider?.id ?? opts.agent.ctx.provider?.id ?? "",
175
+ isRunning: abortControllers.has(id)
176
+ };
177
+ });
402
178
  }
403
- };
404
- return { retireUndisplayedSessions };
179
+ });
405
180
  }
406
181
 
407
- // src/webui-server.ts
408
- import * as path7 from "node:path";
409
- import { createCompatibilityTrustBoundary, DefaultSecretScrubber } from "@wrongstack/core/security";
410
- import {
411
- addFatalSalvageHook,
412
- startSharedHeapWatchdog,
413
- wstackGlobalRoot
414
- } from "@wrongstack/core/utils";
182
+ // src/webui-server/domain-handlers.ts
183
+ import * as os from "node:os";
184
+ import * as path2 from "node:path";
185
+ import { resolveWstackPaths } from "@wrongstack/core/utils";
415
186
  import {
416
- buildWebUIAccessUrl,
417
- clientWantsSession,
418
- collectDisplayedSessionIds,
419
- createCustomModeStore,
420
- createDefaultFileWatcherMetrics,
421
- createEmbeddedMessageRouter,
422
- createEmbeddedProviderOperations,
423
- createSessionAgentRegistry,
424
- createWebuiLeaderAutoWakeHost,
425
- envFlag,
426
- findFreePort,
427
- findInstalledPackageJson,
428
- isStrictPort,
429
- resolveAuthToken,
430
- scheduleOwnerlessEmptySessionCleanup,
431
- sendSerialized,
432
- stampDispatchSession,
433
- startTerminalDashboard,
434
- toSessionHistoryEntries,
435
- webuiSessionFrameLog
187
+ buildSddWizardDeps,
188
+ GoalWebSocketHandler,
189
+ SddBoardWebSocketHandler,
190
+ SddWizardWebSocketHandler,
191
+ SpecsWebSocketHandler,
192
+ TerminalWebSocketHandler,
193
+ WorktreeWebSocketHandler
436
194
  } from "@wrongstack/webui-server";
437
- import { verifyClient as verifyWsClient2 } from "@wrongstack/webui-server/server/ws-auth";
438
- import { WebSocketServer } from "ws";
439
-
440
- // src/webui-client-observability.ts
441
- import { startWebUILiveStatusLogger } from "@wrongstack/webui-server";
442
195
 
443
- // src/webui-server/client-registration.ts
444
- import { mailboxSessionTag } from "@wrongstack/core/coordination";
445
- import { createWebuiClientPresence } from "@wrongstack/webui-server";
446
- function createWebuiClientRegistration(deps) {
447
- const control = deps.hqControl;
448
- return createWebuiClientPresence({
449
- projectRoot: deps.projectRoot,
450
- appConfig: deps.appConfig,
451
- events: deps.events,
452
- hqSessionId: deps.hqSessionId,
453
- getSessionId: deps.getSessionId,
454
- clientVersion: CLI_VERSION,
455
- ...deps.listSessions ? { listSessions: deps.listSessions } : {},
456
- ...deps.isSessionOwnedElsewhere ? { isSessionOwnedElsewhere: deps.isSessionOwnedElsewhere } : {},
457
- ...deps.getSessionWriter ? { getSessionWriter: deps.getSessionWriter } : {},
458
- startHqConnection: (options) => startCliHqConnection({
459
- ...options,
460
- capabilities: options.capabilities,
461
- // Embedded in the CLI host, whose own HQ connection already syncs
462
- // this project's Kanban boards; a second sync is a second writer.
463
- ownKanbanSync: false
464
- }),
465
- ...control ? {
466
- createCommandHandler: (mailbox, approvals) => createHqCommandDispatcher({
467
- steerMailbox: mailbox,
468
- interruptLeader: control.interruptLeader,
469
- allowRunCommand: control.allowRunCommand,
470
- // Same registry the approval bridge publishes from, so every
471
- // prompt HQ shows is one this handler can actually answer.
472
- resolveApproval: (toolUseId, decision, sessionId) => approvals.resolve(toolUseId, decision, sessionId),
473
- // Same registry again: it publishes `user_input.requested`, so
474
- // it has to be the one that answers `answer-input`.
475
- resolveUserInput: (requestId, response, sessionId) => approvals.resolveUserInput?.(requestId, response, sessionId) ?? false,
476
- ...deps.projectRoot ? {
477
- kanbanTransition: createProjectKanbanTransitionHandler(deps.projectRoot),
478
- kanbanAssign: createProjectKanbanAssignHandler(deps.projectRoot)
479
- } : {},
480
- // The session HQ actually SHOWS for this client — the boot
481
- // session the telemetry bridge publishes under and the only one
482
- // `interruptLeader` speaks for. `getSessionId` follows whichever
483
- // browser tab is in front, which is a different session HQ has
484
- // no view of and cannot control.
485
- sessionTag: () => mailboxSessionTag(deps.hqSessionId),
486
- // The session HQ falls back to when a command names none — the
487
- // boot tab. A command that DOES name one is honoured (and
488
- // refused when this host no longer has it) via `ownsSession`.
489
- sessionId: () => deps.hqSessionId,
490
- ...control.ownsSession ? { ownsSession: control.ownsSession } : {},
491
- ...control.spawnAgent ? { spawnAgent: control.spawnAgent } : {},
492
- ...control.killFleet ? { killFleet: control.killFleet } : {},
493
- ...control.terminateAgent ? { terminateAgent: control.terminateAgent } : {}
494
- })
495
- } : {}
496
- });
497
- }
196
+ // src/webui-server/logger-shim.ts
197
+ var structuredLine = (level, message) => JSON.stringify({
198
+ level,
199
+ event: "webui.goal",
200
+ message,
201
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
202
+ });
203
+ var consoleLogger = {
204
+ level: "debug",
205
+ error(msg, _ctx) {
206
+ console.error(structuredLine("error", msg));
207
+ },
208
+ warn(msg, _ctx) {
209
+ console.warn(structuredLine("warn", msg));
210
+ },
211
+ info(msg, _ctx) {
212
+ console.log(structuredLine("info", msg));
213
+ },
214
+ debug(msg, _ctx) {
215
+ console.debug(structuredLine("debug", msg));
216
+ },
217
+ trace(msg, _ctx) {
218
+ console.debug(structuredLine("trace", msg));
219
+ },
220
+ child(_bindings) {
221
+ return this;
222
+ }
223
+ };
498
224
 
499
- // src/webui-client-observability.ts
500
- function createEmbeddedClientRegistration(opts, clients, abortControllers, getSessionAgents) {
501
- const interruptSession = (sessionId) => {
502
- const leaderId = sessionId ?? opts.session.id;
503
- const controller = abortControllers.get(leaderId);
504
- if (!controller) return false;
505
- controller.abort();
225
+ // src/webui-server/node-pty-loader.ts
226
+ import { existsSync } from "node:fs";
227
+ import { createRequire } from "node:module";
228
+ import * as path from "node:path";
229
+ import { fileURLToPath } from "node:url";
230
+ import { isStandaloneBinary } from "@wrongstack/core/utils";
231
+ import { repairNodePtySpawnHelper } from "@wrongstack/webui-server";
232
+ function createNodePtyLoader(importMetaUrl, logger) {
233
+ const requireFromCli = createRequire(importMetaUrl);
234
+ let cachedNodePty;
235
+ const loaded = (req, via) => {
236
+ logger.debug?.(`[terminal] node-pty loaded via ${via}`);
237
+ repairNodePtySpawnHelper(req, logger);
238
+ return cachedNodePty;
239
+ };
240
+ return () => {
241
+ if (cachedNodePty !== void 0) return cachedNodePty;
242
+ if (isStandaloneBinary()) {
243
+ cachedNodePty = null;
244
+ return null;
245
+ }
506
246
  try {
507
- void Promise.resolve(opts.stopSessionFleet?.(leaderId)).catch(() => void 0);
508
- } catch {
247
+ const webuiEntry = requireFromCli.resolve("@wrongstack/webui");
248
+ const webuiDir = webuiEntry.replace(/[\\/]dist.*$/, "");
249
+ const webuiRequire = createRequire(path.join(webuiDir, "package.json"));
250
+ cachedNodePty = webuiRequire("node-pty");
251
+ if (cachedNodePty) return loaded(webuiRequire, `webui package (${webuiDir})`);
252
+ } catch (err) {
253
+ logger.debug?.(`[terminal] webui-route failed: ${err.message}`);
509
254
  }
510
- return true;
511
- };
512
- if (opts.interruptController) {
513
- opts.interruptController.abortLeader = () => interruptSession(opts.agent.ctx.session?.id ?? opts.session.id);
514
- }
515
- return createWebuiClientRegistration({
516
- projectRoot: opts.projectRoot,
517
- appConfig: opts.appConfig,
518
- events: opts.events,
519
- hqSessionId: opts.session.id,
520
- getSessionId: () => opts.agent.ctx.session?.id ?? opts.session.id,
521
- // One HQ session per open tab. The set is the same one the terminal
522
- // panel lists: every session a connected browser is displaying.
523
- listSessions: () => {
524
- const ids = /* @__PURE__ */ new Set();
525
- for (const client of clients.values()) {
526
- if (client.sessionId) ids.add(client.sessionId);
527
- for (const id of client.sessionIds ?? []) ids.add(id);
528
- }
529
- return [...ids];
530
- },
531
- // `cli-main` already runs a tracker and a bridge for the root
532
- // conversation; announcing it here too would put two trackers on one bus
533
- // flushing the same agent list. Compared against the LIVE root writer:
534
- // the CLI host's telemetry follows `ctx.session`, so once the root moves
535
- // (session.new / resume) the old boot id is an ordinary tab this client
536
- // must publish, and the new root id must not be published twice.
537
- isSessionOwnedElsewhere: (sessionId) => sessionId === (opts.agent.ctx.session?.id ?? opts.session.id),
538
- // The tab's own journal, so HQ takes its turns from the write path
539
- // instead of tailing the file.
540
- getSessionWriter: (sessionId) => getSessionAgents()?.peek(sessionId)?.ctx.session,
541
- hqControl: {
542
- interruptLeader: interruptSession,
543
- ...opts.hqFleetControl ? {
544
- killFleet: opts.hqFleetControl.killFleet,
545
- terminateAgent: opts.hqFleetControl.terminateAgent,
546
- spawnAgent: opts.hqFleetControl.spawnAgent
547
- } : {},
548
- allowRunCommand: () => opts.hqAllowExec === true,
549
- // A command naming a tab this process no longer holds is refused, not
550
- // redirected onto the boot session: the operator picked a terminal,
551
- // and steering a different one is worse than not steering at all.
552
- ownsSession: (sessionId) => sessionId === opts.session.id || getSessionAgents()?.has(sessionId) === true
255
+ try {
256
+ cachedNodePty = requireFromCli("node-pty");
257
+ return loaded(requireFromCli, "direct requireFromCli");
258
+ } catch (err) {
259
+ logger.debug?.(`[terminal] direct require failed: ${err.message}`);
553
260
  }
554
- });
555
- }
556
- function startEmbeddedLiveStatusLogger(opts, clients, abortControllers, getSessionAgents, terminalLogView) {
557
- return startWebUILiveStatusLogger({
558
- title: opts.surface === "simpleui" ? "SimpleUI" : "WebUI",
559
- events: opts.events,
560
- dashboard: terminalLogView,
561
- getSessionList: () => {
562
- const ids = /* @__PURE__ */ new Set();
563
- for (const client of clients.values()) {
564
- if (client.sessionId) ids.add(client.sessionId);
565
- for (const id of client.sessionIds ?? []) ids.add(id);
261
+ try {
262
+ const cliPkgJson = path.join(
263
+ path.dirname(fileURLToPath(importMetaUrl)),
264
+ "..",
265
+ "package.json"
266
+ );
267
+ let dir = path.dirname(cliPkgJson);
268
+ for (let i = 0; i < 6; i++) {
269
+ const candidate = path.join(dir, "node_modules", "node-pty");
270
+ if (existsSync(candidate)) {
271
+ const candidateRequire = createRequire(candidate);
272
+ cachedNodePty = candidateRequire("node-pty");
273
+ if (cachedNodePty) return loaded(candidateRequire, `workspace root (${candidate})`);
274
+ }
275
+ const parent = path.dirname(dir);
276
+ if (parent === dir) break;
277
+ dir = parent;
566
278
  }
567
- const currentId = opts.agent.ctx.session?.id ?? opts.session.id;
568
- if (ids.size === 0 && currentId) ids.add(currentId);
569
- return Array.from(ids).map((id) => {
570
- const ctx = getSessionAgents()?.peek(id)?.ctx;
571
- return {
572
- id,
573
- model: ctx?.model ?? opts.agent.ctx.model ?? "",
574
- provider: ctx?.provider?.id ?? opts.agent.ctx.provider?.id ?? "",
575
- isRunning: abortControllers.has(id)
576
- };
577
- });
279
+ } catch (err) {
280
+ logger.debug?.(`[terminal] workspace-root walk failed: ${err.message}`);
578
281
  }
579
- });
580
- }
581
-
582
- // src/webui-server/credential-watcher.ts
583
- import { TOKENS } from "@wrongstack/core/kernel";
584
- import { watchProviderConfig } from "@wrongstack/core/storage";
585
- import { toErrorMessage as toErrorMessage2 } from "@wrongstack/core/utils/error";
586
- import { makeProviderFromConfig } from "@wrongstack/providers";
587
-
588
- // src/webui-server/provider-config.ts
589
- import * as path from "node:path";
590
- import { DefaultSecretVault } from "@wrongstack/core/security";
591
- import { ProviderConfigSnapshots } from "@wrongstack/providers";
592
- var snapshots = new ProviderConfigSnapshots();
593
- function getVault(globalConfigPath) {
594
- const configDir = path.dirname(globalConfigPath ?? "");
595
- const parentDir = path.dirname(configDir);
596
- const globalRoot = path.basename(parentDir) === "profiles" ? path.dirname(parentDir) : configDir;
597
- const keyFile = path.join(globalRoot, ".key");
598
- return new DefaultSecretVault({ keyFile });
599
- }
600
- async function loadSavedProviders(globalConfigPath) {
601
- if (!globalConfigPath) return {};
602
- return snapshots.track(await loadConfigProviders(globalConfigPath, getVault(globalConfigPath)));
603
- }
604
- async function saveProviders(globalConfigPath, providers) {
605
- if (!globalConfigPath) return;
606
- await mutateConfigProviders(
607
- globalConfigPath,
608
- getVault(globalConfigPath),
609
- (existing) => {
610
- const merged = snapshots.merge(existing, providers);
611
- for (const key of Object.keys(existing)) delete existing[key];
612
- Object.assign(existing, merged);
613
- for (const key of Object.keys(providers)) delete providers[key];
614
- Object.assign(providers, merged);
615
- }
616
- );
617
- snapshots.track(providers);
618
- }
619
- function createProviderConfigStore(globalConfigPath, configProvidersRef) {
620
- if (configProvidersRef && globalConfigPath) {
621
- const views = /* @__PURE__ */ new WeakMap();
622
- const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
623
- return {
624
- load: async () => {
625
- const disk = await loadSavedProviders(globalConfigPath);
626
- const providers = structuredClone(configProvidersRef());
627
- views.set(providers, { view: structuredClone(providers), disk });
628
- return providers;
629
- },
630
- save: async (providers) => {
631
- const baseline = views.get(providers);
632
- if (!baseline) {
633
- throw new Error("Load the provider list before saving. Refresh and try again.");
634
- }
635
- const pending = structuredClone(baseline.disk);
636
- snapshots.track(pending);
637
- for (const id of /* @__PURE__ */ new Set([...Object.keys(baseline.view), ...Object.keys(providers)])) {
638
- if (equal(baseline.view[id], providers[id])) continue;
639
- if (!equal(baseline.view[id], baseline.disk[id])) {
640
- throw new Error(
641
- `Provider "${id}" comes from another config file. Edit its source config or create a new auth profile alias.`
642
- );
643
- }
644
- if (Object.hasOwn(providers, id)) pending[id] = structuredClone(providers[id]);
645
- else delete pending[id];
646
- }
647
- await saveProviders(globalConfigPath, pending);
648
- for (const id of Object.keys(providers)) {
649
- if (equal(baseline.view[id], baseline.disk[id])) delete providers[id];
650
- }
651
- for (const [id, record] of Object.entries(pending)) {
652
- if (equal(baseline.view[id], baseline.disk[id])) providers[id] = record;
653
- }
654
- views.set(providers, { view: structuredClone(providers), disk: structuredClone(pending) });
655
- }
656
- };
657
- }
658
- return {
659
- load: () => configProvidersRef ? Promise.resolve(snapshots.track(structuredClone(configProvidersRef()))) : loadSavedProviders(globalConfigPath),
660
- save: (providers) => saveProviders(globalConfigPath, providers)
661
- };
662
- }
663
-
664
- // src/webui-server/credential-watcher.ts
665
- function startWebuiCredentialWatcher({
666
- opts,
667
- profileConfigPath,
668
- broadcast,
669
- broadcastSaved
670
- }) {
671
- const watchConfigPath = profileConfigPath;
672
- if (!watchConfigPath || process.env["WRONGSTACK_DISABLE_CONFIG_WATCH"] === "1") {
673
- return void 0;
674
- }
675
- let lastActiveCfg = JSON.stringify(
676
- opts.appConfig?.providers?.[opts.agent.ctx.provider.id] ?? null
677
- );
678
- let lastUiLocale = opts.appConfig?.uiLocale;
679
- const watcher = watchProviderConfig(
680
- watchConfigPath,
681
- // Vault key lives at ~/.wrongstack/.key (derived from globalConfigPath),
682
- // not inside the profile directory — resolve via global root to stay correct.
683
- getVault(opts.globalConfigPath),
684
- (snapshot) => {
685
- const hadFallbackBridge = Boolean(opts.appConfig?.fallbackBridge?.trim());
686
- try {
687
- if (opts.appConfig && !Object.isFrozen(opts.appConfig)) {
688
- opts.appConfig.providers = snapshot.providers;
689
- if (snapshot.uiLocale) opts.appConfig.uiLocale = snapshot.uiLocale;
690
- if (snapshot.fallbackBridge !== void 0) {
691
- opts.appConfig.fallbackBridge = snapshot.fallbackBridge;
692
- } else {
693
- delete opts.appConfig.fallbackBridge;
694
- }
695
- }
696
- } catch {
697
- }
698
- const routingChanged = snapshot.fallbackModels !== void 0 || snapshot.fallbackBridge !== void 0 || hadFallbackBridge || snapshot.fallbackProfiles !== void 0 || snapshot.favoriteModels !== void 0 || snapshot.disabledModels !== void 0 || snapshot.disabledProviders !== void 0 || snapshot.favoriteModelsOnly !== void 0 || snapshot.modelMatrix !== void 0 || snapshot.fallbackAuto !== void 0;
699
- if (routingChanged) {
700
- const configStore = opts.agent.container?.safeResolve?.(TOKENS.ConfigStore);
701
- configStore?.update({
702
- ...snapshot.fallbackModels !== void 0 ? { fallbackModels: snapshot.fallbackModels } : {},
703
- fallbackBridge: snapshot.fallbackBridge ?? "",
704
- ...snapshot.fallbackProfiles !== void 0 ? { fallbackProfiles: snapshot.fallbackProfiles } : {},
705
- ...snapshot.favoriteModels !== void 0 ? { favoriteModels: snapshot.favoriteModels } : {},
706
- ...snapshot.disabledModels !== void 0 ? { disabledModels: snapshot.disabledModels } : {},
707
- ...snapshot.disabledProviders !== void 0 ? { disabledProviders: snapshot.disabledProviders } : {},
708
- ...snapshot.favoriteModelsOnly !== void 0 ? { favoriteModelsOnly: snapshot.favoriteModelsOnly } : {},
709
- ...snapshot.modelMatrix !== void 0 ? { modelMatrix: snapshot.modelMatrix } : {},
710
- ...snapshot.fallbackAuto !== void 0 ? { fallbackAuto: snapshot.fallbackAuto } : {}
711
- });
712
- }
713
- broadcastSaved(snapshot.providers);
714
- if (snapshot.uiLocale !== lastUiLocale) {
715
- lastUiLocale = snapshot.uiLocale;
716
- if (snapshot.uiLocale) {
717
- opts.agent.ctx.meta["uiLocale"] = snapshot.uiLocale;
718
- broadcast({ type: "prefs.updated", payload: { uiLocale: snapshot.uiLocale } });
719
- }
720
- }
721
- const activeId = opts.agent.ctx.provider.id;
722
- const newCfgStr = JSON.stringify(snapshot.providers[activeId] ?? null);
723
- if (newCfgStr === lastActiveCfg) return;
724
- lastActiveCfg = newCfgStr;
725
- try {
726
- const newCfg = snapshot.providers[activeId] ?? {
727
- type: activeId,
728
- ...snapshot.apiKey !== void 0 ? { apiKey: snapshot.apiKey } : {},
729
- ...snapshot.baseUrl !== void 0 ? { baseUrl: snapshot.baseUrl } : {}
730
- };
731
- const oldMax = opts.agent.ctx.provider.capabilities?.maxContext;
732
- const prov = makeProviderFromConfig(activeId, {
733
- ...newCfg,
734
- type: newCfg.type ?? activeId
735
- });
736
- if (oldMax != null && prov.capabilities) prov.capabilities.maxContext = oldMax;
737
- opts.agent.ctx.provider = prov;
738
- console.log(`[WebUI] Provider credentials reloaded from config.json (${activeId})`);
739
- } catch (err) {
740
- console.warn(
741
- `[WebUI] Credential hot-reload failed for ${activeId}: ${toErrorMessage2(err)}`
742
- );
743
- }
744
- },
745
- {
746
- warn: (m) => console.warn(
747
- JSON.stringify({
748
- level: "warn",
749
- event: "webui.config_watcher",
750
- message: m,
751
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
752
- })
753
- )
754
- }
755
- );
756
- return watcher.close;
757
- }
758
-
759
- // src/webui-server/domain-handlers.ts
760
- import * as os from "node:os";
761
- import * as path3 from "node:path";
762
- import { resolveWstackPaths } from "@wrongstack/core/utils";
763
- import {
764
- buildSddWizardDeps,
765
- GoalWebSocketHandler,
766
- SddBoardWebSocketHandler,
767
- SddWizardWebSocketHandler,
768
- SpecsWebSocketHandler,
769
- TerminalWebSocketHandler,
770
- WorktreeWebSocketHandler
771
- } from "@wrongstack/webui-server";
772
-
773
- // src/webui-server/node-pty-loader.ts
774
- import { existsSync } from "node:fs";
775
- import { createRequire } from "node:module";
776
- import * as path2 from "node:path";
777
- import { fileURLToPath } from "node:url";
778
- import { isStandaloneBinary } from "@wrongstack/core/utils";
779
- import { repairNodePtySpawnHelper } from "@wrongstack/webui-server";
780
- function createNodePtyLoader(importMetaUrl, logger) {
781
- const requireFromCli = createRequire(importMetaUrl);
782
- let cachedNodePty;
783
- const loaded = (req, via) => {
784
- logger.debug?.(`[terminal] node-pty loaded via ${via}`);
785
- repairNodePtySpawnHelper(req, logger);
786
- return cachedNodePty;
787
- };
788
- return () => {
789
- if (cachedNodePty !== void 0) return cachedNodePty;
790
- if (isStandaloneBinary()) {
791
- cachedNodePty = null;
792
- return null;
793
- }
794
- try {
795
- const webuiEntry = requireFromCli.resolve("@wrongstack/webui");
796
- const webuiDir = webuiEntry.replace(/[\\/]dist.*$/, "");
797
- const webuiRequire = createRequire(path2.join(webuiDir, "package.json"));
798
- cachedNodePty = webuiRequire("node-pty");
799
- if (cachedNodePty) return loaded(webuiRequire, `webui package (${webuiDir})`);
800
- } catch (err) {
801
- logger.debug?.(`[terminal] webui-route failed: ${err.message}`);
802
- }
803
- try {
804
- cachedNodePty = requireFromCli("node-pty");
805
- return loaded(requireFromCli, "direct requireFromCli");
806
- } catch (err) {
807
- logger.debug?.(`[terminal] direct require failed: ${err.message}`);
808
- }
809
- try {
810
- const cliPkgJson = path2.join(
811
- path2.dirname(fileURLToPath(importMetaUrl)),
812
- "..",
813
- "package.json"
814
- );
815
- let dir = path2.dirname(cliPkgJson);
816
- for (let i = 0; i < 6; i++) {
817
- const candidate = path2.join(dir, "node_modules", "node-pty");
818
- if (existsSync(candidate)) {
819
- const candidateRequire = createRequire(candidate);
820
- cachedNodePty = candidateRequire("node-pty");
821
- if (cachedNodePty) return loaded(candidateRequire, `workspace root (${candidate})`);
822
- }
823
- const parent = path2.dirname(dir);
824
- if (parent === dir) break;
825
- dir = parent;
826
- }
827
- } catch (err) {
828
- logger.debug?.(`[terminal] workspace-root walk failed: ${err.message}`);
829
- }
830
- cachedNodePty = null;
831
- logger.debug?.(
832
- '[terminal] node-pty resolution failed; terminal panel will report "unavailable"'
833
- );
834
- return cachedNodePty;
835
- };
282
+ cachedNodePty = null;
283
+ logger.debug?.(
284
+ '[terminal] node-pty resolution failed; terminal panel will report "unavailable"'
285
+ );
286
+ return cachedNodePty;
287
+ };
836
288
  }
837
289
 
838
290
  // src/webui-server/domain-handlers.ts
839
291
  function createWebuiDomainHandlers(opts, trustBoundary, kanbanRunMirror) {
840
- const goalStoreDir = opts.projectRoot ? resolveWstackPaths({ projectRoot: opts.projectRoot }).projectAutophase : path3.join(os.tmpdir(), ".wrongstack", "goal");
292
+ const goalStoreDir = opts.projectRoot ? resolveWstackPaths({ projectRoot: opts.projectRoot }).projectAutophase : path2.join(os.tmpdir(), ".wrongstack", "goal");
841
293
  const goalHandler = new GoalWebSocketHandler(
842
294
  opts.agent,
843
295
  opts.agent.ctx,
@@ -859,11 +311,11 @@ function createWebuiDomainHandlers(opts, trustBoundary, kanbanRunMirror) {
859
311
  );
860
312
  const specsPaths = opts.projectRoot ? resolveWstackPaths({ projectRoot: opts.projectRoot }) : null;
861
313
  const specsHandler = new SpecsWebSocketHandler(
862
- specsPaths?.projectSpecs ?? path3.join(os.tmpdir(), ".wrongstack", "specs"),
863
- specsPaths?.projectTaskGraphs ?? path3.join(os.tmpdir(), ".wrongstack", "task-graphs")
314
+ specsPaths?.projectSpecs ?? path2.join(os.tmpdir(), ".wrongstack", "specs"),
315
+ specsPaths?.projectTaskGraphs ?? path2.join(os.tmpdir(), ".wrongstack", "task-graphs")
864
316
  );
865
317
  const sddBoardHandler = new SddBoardWebSocketHandler(
866
- specsPaths?.projectSddBoards ?? path3.join(os.tmpdir(), ".wrongstack", "sdd-boards"),
318
+ specsPaths?.projectSddBoards ?? path2.join(os.tmpdir(), ".wrongstack", "sdd-boards"),
867
319
  opts.events,
868
320
  specsPaths && opts.projectRoot ? {
869
321
  projectRoot: opts.projectRoot,
@@ -903,33 +355,301 @@ function createWebuiDomainHandlers(opts, trustBoundary, kanbanRunMirror) {
903
355
  };
904
356
  }
905
357
 
906
- // src/webui-server/kanban-host-adapter.ts
907
- import { PhaseGraphBuilder } from "@wrongstack/core/goal";
908
- import { resolveWstackPaths as resolveWstackPaths2 } from "@wrongstack/core/utils";
909
- import { exportBoardToTaskGraph } from "@wrongstack/kanban";
910
- import { TaskGraphStore } from "@wrongstack/sdd";
911
- import {
912
- startSddRunFromGraph
913
- } from "@wrongstack/webui-server";
914
- function createCliKanbanHostRoutes(deps) {
915
- const { opts, send, goalHandler } = deps;
916
- let isolatedSeq = 0;
917
- const runIsolatedTurn = opts.sddSubagentFactory ? async (prompt, name) => {
918
- const result = await opts.sddSubagentFactory({
919
- id: `sdd-kanban-${name.toLowerCase().replace(/\s+/g, "-")}-${isolatedSeq++}`,
920
- role: "executor",
921
- name,
922
- disabledTools: ["delegate", "write", "edit", "patch", "bash", "exec"],
923
- allowedCapabilities: ["fs.read", "net.outbound"]
924
- });
925
- try {
926
- const res = await result.agent.run([{ type: "text", text: prompt }]);
927
- return res.finalText ?? "";
928
- } finally {
929
- await result.dispose?.();
930
- }
931
- } : void 0;
932
- return {
358
+ // src/webui-server/host-settings.ts
359
+ import * as path3 from "node:path";
360
+ import { wstackGlobalRoot } from "@wrongstack/core/utils";
361
+ import { envFlag, isStrictPort } from "@wrongstack/webui-server";
362
+ function resolveWebuiHostSettings(opts) {
363
+ const host = opts.host ?? process.env["WEBUI_HOST"] ?? process.env["WS_HOST"] ?? "127.0.0.1";
364
+ const publicUrl = opts.publicUrl ?? process.env["WEBUI_PUBLIC_URL"];
365
+ const publicWsUrl = opts.publicWsUrl ?? process.env["WEBUI_PUBLIC_WS_URL"];
366
+ const requireToken = opts.requireToken ?? envFlag("WEBUI_REQUIRE_TOKEN");
367
+ const surface = opts.surface ?? "webui";
368
+ const surfaceDefaults = surface === "simpleui" ? { http: 3466 } : { http: 3456 };
369
+ const requestedHttpPort = opts.httpPort ?? opts.port ?? surfaceDefaults.http;
370
+ const strictPort = opts.strictPort ?? isStrictPort();
371
+ const globalRoot = opts.globalConfigPath ? path3.dirname(opts.globalConfigPath) : wstackGlobalRoot();
372
+ const profileConfigPath = opts.profileConfigPath ?? opts.globalConfigPath ?? path3.join(globalRoot, "config.json");
373
+ const rawRateLimit = process.env["WEBUI_RATE_LIMIT"]?.trim() ?? "";
374
+ const parsedRateLimit = /^\d+$/.test(rawRateLimit) ? Number(rawRateLimit) : Number.NaN;
375
+ const rateLimitMax = Number.isSafeInteger(parsedRateLimit) && parsedRateLimit >= 0 ? parsedRateLimit : 600;
376
+ const publicHostnames = [publicUrl, publicWsUrl].map((value) => {
377
+ if (!value) return void 0;
378
+ try {
379
+ return new URL(value).hostname;
380
+ } catch {
381
+ return void 0;
382
+ }
383
+ }).filter((value) => Boolean(value));
384
+ return {
385
+ host,
386
+ publicUrl,
387
+ publicWsUrl,
388
+ requireToken,
389
+ surface,
390
+ requestedHttpPort,
391
+ strictPort,
392
+ globalRoot,
393
+ profileConfigPath,
394
+ profileDir: path3.dirname(profileConfigPath),
395
+ rateLimitMax,
396
+ publicHostnames
397
+ };
398
+ }
399
+
400
+ // src/webui-server/http-bridge.ts
401
+ import {
402
+ createDefaultFileWatcherMetrics,
403
+ findInstalledPackageJson
404
+ } from "@wrongstack/webui-server";
405
+ import { verifyClient as verifyWsClient } from "@wrongstack/webui-server/server/ws-auth";
406
+ import { WebSocketServer } from "ws";
407
+
408
+ // src/webui-server/lifecycle.ts
409
+ import {
410
+ announceWebuiReady,
411
+ createWebuiShutdown,
412
+ registerWebuiInstance,
413
+ registerWebuiSignalHandlers
414
+ } from "@wrongstack/webui-server";
415
+
416
+ // src/webui-server/listen-helpers.ts
417
+ import * as http from "node:http";
418
+ import { listenWithRetry } from "@wrongstack/webui-server";
419
+ async function startDeferredHttpListen(args) {
420
+ const { server, host, httpPort, logger, strictPort } = args;
421
+ const boundPort = await listenWithRetry(server, host, httpPort, {
422
+ maxTries: strictPort ? 1 : 10
423
+ });
424
+ server.on("error", (err) => {
425
+ logger.error("http_server_error", {
426
+ message: err.message,
427
+ port: boundPort
428
+ });
429
+ });
430
+ return boundPort;
431
+ }
432
+ async function startIpv6LoopbackProxy(args) {
433
+ const { primary, httpPort, logger } = args;
434
+ const v6 = http.createServer();
435
+ v6.on("request", (req, res) => primary.emit("request", req, res));
436
+ v6.on("upgrade", (req, socket, head) => primary.emit("upgrade", req, socket, head));
437
+ const logIpv6Error = (err) => {
438
+ if (err.code === "EAFNOSUPPORT" || err.code === "EADDRNOTAVAIL" || err.code === "EADDRINUSE") {
439
+ logger.warn("ipv6_loopback_unavailable", { code: err.code, port: httpPort });
440
+ return;
441
+ }
442
+ logger.error("http_server_error", { message: err.message, port: httpPort });
443
+ };
444
+ v6.on("error", logIpv6Error);
445
+ const ipv6Listening = await new Promise((resolveListen) => {
446
+ const onListening = () => {
447
+ v6.off("error", onInitialError);
448
+ resolveListen(true);
449
+ };
450
+ const onInitialError = () => {
451
+ v6.off("listening", onListening);
452
+ resolveListen(false);
453
+ };
454
+ v6.once("listening", onListening);
455
+ v6.once("error", onInitialError);
456
+ v6.listen(httpPort, "::1");
457
+ });
458
+ if (!ipv6Listening) return null;
459
+ console.log(`[WebUI] Also listening on http://[::1]:${httpPort}`);
460
+ return v6;
461
+ }
462
+
463
+ // src/webui-server/static-serve.ts
464
+ import {
465
+ ensureDistDir,
466
+ resolveDistDir,
467
+ startStaticServe
468
+ } from "@wrongstack/webui-server";
469
+
470
+ // src/webui-server/http-bridge.ts
471
+ async function startWebuiHttpBridge(input) {
472
+ const {
473
+ opts,
474
+ surface,
475
+ host,
476
+ strictPort,
477
+ globalRoot,
478
+ wsToken,
479
+ publicUrl,
480
+ publicWsUrl,
481
+ requireToken,
482
+ getSessionAgents,
483
+ onFleetPing,
484
+ broadcast,
485
+ onPortRebound
486
+ } = input;
487
+ let httpPort = input.httpPort;
488
+ const httpServer = await startStaticServe({
489
+ getSessionProjectRoot: (sessionId) => getSessionAgents()?.peek(sessionId)?.ctx.projectRoot ?? (opts.agent.ctx.session?.id === sessionId ? opts.agent.ctx.projectRoot : void 0),
490
+ host,
491
+ httpPort,
492
+ globalRoot,
493
+ distDir: opts.frontendDistDir,
494
+ ensureDistDeps: {
495
+ resolvePackageJson: (id) => {
496
+ const packageJson = findInstalledPackageJson(id, import.meta.url);
497
+ if (!packageJson) throw new Error(`Package not found: ${id}`);
498
+ return packageJson;
499
+ }
500
+ },
501
+ onFleetPing,
502
+ // Without this, /debug/watcher-metrics 503s and the Debug Dashboard has to
503
+ // report the watcher as "Unavailable". The CLI host runs no status watcher
504
+ // of its own, so this object stays all-zero and watcherActive reads false —
505
+ // which is the truthful state for an embedded host.
506
+ watcherMetrics: createDefaultFileWatcherMetrics(),
507
+ onTechStackEvent: (event) => broadcast(event),
508
+ getLlm: () => opts.agent.ctx.provider && opts.agent.ctx.model ? { provider: opts.agent.ctx.provider, model: opts.agent.ctx.model } : void 0,
509
+ projectRoot: opts.projectRoot,
510
+ publicWsUrl,
511
+ apiToken: wsToken,
512
+ requireToken,
513
+ deferListen: surface === "simpleui",
514
+ strictPort,
515
+ // Resolve the same-origin health probes from live preferences, including
516
+ // changes made after boot, just like the standalone WebUI host.
517
+ getIntegrationTarget: (kind) => {
518
+ const meta = opts.agent.ctx.meta;
519
+ const enabledKey = kind === "hq" ? "hqEnabled" : "wrongProxyEnabled";
520
+ const urlKey = kind === "hq" ? "hqUrl" : "wrongProxyUrl";
521
+ return meta[enabledKey] === true && typeof meta[urlKey] === "string" ? meta[urlKey] : void 0;
522
+ },
523
+ ...opts.getVectorMemoryStore ? { getVectorMemoryStore: opts.getVectorMemoryStore } : {},
524
+ ...opts.vectorMemoryModelCacheDir ? { vectorMemoryModelCacheDir: opts.vectorMemoryModelCacheDir } : {}
525
+ });
526
+ const verifyClient = (info) => verifyWsClient({
527
+ origin: info.origin,
528
+ url: info.req.url ?? "",
529
+ hostHeader: info.req.headers.host,
530
+ remoteAddress: info.req.socket.remoteAddress,
531
+ cookieHeader: info.req.headers.cookie,
532
+ wsHost: host,
533
+ expectedToken: wsToken,
534
+ requireToken,
535
+ allowedHostnames: [publicUrl, publicWsUrl].filter((value) => Boolean(value)),
536
+ allowBrowserUrlToken: Boolean(publicWsUrl),
537
+ allowCrossPortLoopbackCookie: process.env["WRONGSTACK_WEBUI_DEV_CROSS_PORT_WS"] === "1"
538
+ });
539
+ const wss = httpServer ? new WebSocketServer({ server: httpServer.server, verifyClient, maxPayload: 20 * 1024 * 1024 }) : new WebSocketServer({ port: httpPort, host, verifyClient, maxPayload: 20 * 1024 * 1024 });
540
+ wss.on("error", (err) => {
541
+ console.error(
542
+ JSON.stringify({
543
+ level: "error",
544
+ event: "webui_server.error",
545
+ message: err instanceof Error ? err.message : String(err),
546
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
547
+ })
548
+ );
549
+ });
550
+ if (httpServer) {
551
+ const boundPort = surface === "simpleui" ? await startDeferredHttpListen({
552
+ server: httpServer.server,
553
+ host,
554
+ httpPort,
555
+ logger: consoleLogger,
556
+ strictPort
557
+ }) : httpServer.port;
558
+ if (boundPort !== httpPort) {
559
+ httpPort = boundPort;
560
+ onPortRebound(boundPort);
561
+ }
562
+ }
563
+ let ipv6LoopbackServer = null;
564
+ if (httpServer && host === "127.0.0.1") {
565
+ ipv6LoopbackServer = await startIpv6LoopbackProxy({
566
+ primary: httpServer.server,
567
+ httpPort,
568
+ logger: consoleLogger
569
+ });
570
+ }
571
+ console.log(`[WebUI] WebSocket server starting on ws://${host}:${httpPort}`);
572
+ if (httpServer) {
573
+ announceWebuiReady({
574
+ surface,
575
+ server: httpServer.server,
576
+ host,
577
+ httpPort,
578
+ open: !!opts.open,
579
+ wsToken,
580
+ publicUrl
581
+ });
582
+ } else {
583
+ console.warn(
584
+ `[WebUI] Frontend not served (run \`pnpm --filter @wrongstack/webui build\`). WS bridge still active on ws://${host}:${httpPort}.`
585
+ );
586
+ }
587
+ return { httpServer, wss, ipv6LoopbackServer };
588
+ }
589
+
590
+ // src/webui-server/instance-registration.ts
591
+ async function registerEmbeddedWebuiInstance(input) {
592
+ const { opts, surface, host, httpPort, publicUrl, registryBaseDir, wsToken, currentSessionId } = input;
593
+ if (!opts.projectRoot) return false;
594
+ const registration = Promise.resolve(
595
+ registerWebuiInstance({
596
+ pid: process.pid,
597
+ surface,
598
+ host,
599
+ httpPort,
600
+ publicUrl,
601
+ projectRoot: opts.projectRoot,
602
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
603
+ registryBaseDir,
604
+ authToken: wsToken,
605
+ ...opts.webuiSessionChild ? {
606
+ role: "session-child",
607
+ sessionId: currentSessionId(),
608
+ parentPid: opts.webuiSessionChild.parentPid,
609
+ parentShellId: opts.webuiSessionChild.parentShellId,
610
+ runtimeId: opts.webuiSessionChild.runtimeId,
611
+ attachable: opts.webuiSessionChild.attachable,
612
+ lastReadyAt: (/* @__PURE__ */ new Date()).toISOString(),
613
+ protocolVersion: opts.webuiSessionChild.protocolVersion,
614
+ capabilities: [...WEBUI_SESSION_CHILD_CAPABILITIES]
615
+ } : {}
616
+ })
617
+ ).then(
618
+ (value) => value !== false,
619
+ () => false
620
+ );
621
+ if (opts.webuiSessionChild) return await registration;
622
+ void registration;
623
+ return true;
624
+ }
625
+
626
+ // src/webui-server/kanban-host-adapter.ts
627
+ import { PhaseGraphBuilder } from "@wrongstack/core/goal";
628
+ import { resolveWstackPaths as resolveWstackPaths2 } from "@wrongstack/core/utils";
629
+ import { exportBoardToTaskGraph } from "@wrongstack/kanban";
630
+ import { TaskGraphStore } from "@wrongstack/sdd";
631
+ import {
632
+ startSddRunFromGraph
633
+ } from "@wrongstack/webui-server";
634
+ function createCliKanbanHostRoutes(deps) {
635
+ const { opts, send, goalHandler } = deps;
636
+ let isolatedSeq = 0;
637
+ const runIsolatedTurn = opts.sddSubagentFactory ? async (prompt, name) => {
638
+ const result = await opts.sddSubagentFactory({
639
+ id: `sdd-kanban-${name.toLowerCase().replace(/\s+/g, "-")}-${isolatedSeq++}`,
640
+ role: "executor",
641
+ name,
642
+ disabledTools: ["delegate", "write", "edit", "patch", "bash", "exec"],
643
+ allowedCapabilities: ["fs.read", "net.outbound"]
644
+ });
645
+ try {
646
+ const res = await result.agent.run([{ type: "text", text: prompt }]);
647
+ return res.finalText ?? "";
648
+ } finally {
649
+ await result.dispose?.();
650
+ }
651
+ } : void 0;
652
+ return {
933
653
  meta: async (ws) => {
934
654
  const tools = (opts.agent.ctx.tools ?? []).map((tool) => ({
935
655
  name: tool.name,
@@ -1073,59 +793,160 @@ function createCliKanbanHostRoutes(deps) {
1073
793
  };
1074
794
  }
1075
795
 
1076
- // src/webui-server/listen-helpers.ts
1077
- import * as http from "node:http";
1078
- import { listenWithRetry } from "@wrongstack/webui-server";
1079
- async function startDeferredHttpListen(args) {
1080
- const { server, host, httpPort, logger, strictPort } = args;
1081
- const boundPort = await listenWithRetry(server, host, httpPort, {
1082
- maxTries: strictPort ? 1 : 10
1083
- });
1084
- server.on("error", (err) => {
1085
- logger.error("http_server_error", {
1086
- message: err.message,
1087
- port: boundPort
1088
- });
1089
- });
1090
- return boundPort;
796
+ // src/webui-server/kanban-services.ts
797
+ import { startSharedHeapWatchdog } from "@wrongstack/core/utils";
798
+ function createWebuiKanbanServices(opts, broadcast) {
799
+ const kanbanRunMirror = opts.projectRoot ? createKanbanRunMirror({
800
+ projectRoot: opts.projectRoot,
801
+ events: opts.events,
802
+ broadcast,
803
+ log: (m) => consoleLogger.info(m)
804
+ }) : null;
805
+ const kanbanSupervisor = opts.projectRoot ? createKanbanSupervisor({
806
+ projectRoot: opts.projectRoot,
807
+ broadcast,
808
+ ...opts.onKanbanDispatch ? { dispatchTask: opts.onKanbanDispatch } : {},
809
+ log: (message) => consoleLogger.info(message)
810
+ }) : null;
811
+ const stopKanbanSupervisorMemoryStats = kanbanSupervisor ? startSharedHeapWatchdog({
812
+ collectStats: () => {
813
+ const stats = kanbanSupervisor.getStats();
814
+ return {
815
+ kanbanSupervisorSnapshots: stats.snapshots,
816
+ kanbanSupervisorScheduledBoards: stats.scheduledBoards,
817
+ kanbanSupervisorAgentCooldowns: stats.agentCooldowns,
818
+ kanbanSupervisorRunningAgents: stats.runningAgents
819
+ };
820
+ }
821
+ }) : void 0;
822
+ return { kanbanRunMirror, kanbanSupervisor, stopKanbanSupervisorMemoryStats };
1091
823
  }
1092
- async function startIpv6LoopbackProxy(args) {
1093
- const { primary, httpPort, logger } = args;
1094
- const v6 = http.createServer();
1095
- v6.on("request", (req, res) => primary.emit("request", req, res));
1096
- v6.on("upgrade", (req, socket, head) => primary.emit("upgrade", req, socket, head));
1097
- const logIpv6Error = (err) => {
1098
- if (err.code === "EAFNOSUPPORT" || err.code === "EADDRNOTAVAIL" || err.code === "EADDRINUSE") {
1099
- logger.warn("ipv6_loopback_unavailable", { code: err.code, port: httpPort });
1100
- return;
824
+
825
+ // src/webui-server/message-router.ts
826
+ import { createEmbeddedMessageRouter } from "@wrongstack/webui-server";
827
+
828
+ // src/webui-server/provider-config.ts
829
+ import * as path4 from "node:path";
830
+ import { DefaultSecretVault } from "@wrongstack/core/security";
831
+ import { ProviderConfigSnapshots } from "@wrongstack/providers";
832
+ var snapshots = new ProviderConfigSnapshots();
833
+ function getVault(globalConfigPath) {
834
+ const configDir = path4.dirname(globalConfigPath ?? "");
835
+ const parentDir = path4.dirname(configDir);
836
+ const globalRoot = path4.basename(parentDir) === "profiles" ? path4.dirname(parentDir) : configDir;
837
+ const keyFile = path4.join(globalRoot, ".key");
838
+ return new DefaultSecretVault({ keyFile });
839
+ }
840
+ async function loadSavedProviders(globalConfigPath) {
841
+ if (!globalConfigPath) return {};
842
+ return snapshots.track(await loadConfigProviders(globalConfigPath, getVault(globalConfigPath)));
843
+ }
844
+ async function saveProviders(globalConfigPath, providers) {
845
+ if (!globalConfigPath) return;
846
+ await mutateConfigProviders(
847
+ globalConfigPath,
848
+ getVault(globalConfigPath),
849
+ (existing) => {
850
+ const merged = snapshots.merge(existing, providers);
851
+ for (const key of Object.keys(existing)) delete existing[key];
852
+ Object.assign(existing, merged);
853
+ for (const key of Object.keys(providers)) delete providers[key];
854
+ Object.assign(providers, merged);
1101
855
  }
1102
- logger.error("http_server_error", { message: err.message, port: httpPort });
1103
- };
1104
- v6.on("error", logIpv6Error);
1105
- const ipv6Listening = await new Promise((resolveListen) => {
1106
- const onListening = () => {
1107
- v6.off("error", onInitialError);
1108
- resolveListen(true);
1109
- };
1110
- const onInitialError = () => {
1111
- v6.off("listening", onListening);
1112
- resolveListen(false);
856
+ );
857
+ snapshots.track(providers);
858
+ }
859
+ function createProviderConfigStore(globalConfigPath, configProvidersRef) {
860
+ if (configProvidersRef && globalConfigPath) {
861
+ const views = /* @__PURE__ */ new WeakMap();
862
+ const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
863
+ return {
864
+ load: async () => {
865
+ const disk = await loadSavedProviders(globalConfigPath);
866
+ const providers = structuredClone(configProvidersRef());
867
+ views.set(providers, { view: structuredClone(providers), disk });
868
+ return providers;
869
+ },
870
+ save: async (providers) => {
871
+ const baseline = views.get(providers);
872
+ if (!baseline) {
873
+ throw new Error("Load the provider list before saving. Refresh and try again.");
874
+ }
875
+ const pending = structuredClone(baseline.disk);
876
+ snapshots.track(pending);
877
+ for (const id of /* @__PURE__ */ new Set([...Object.keys(baseline.view), ...Object.keys(providers)])) {
878
+ if (equal(baseline.view[id], providers[id])) continue;
879
+ if (!equal(baseline.view[id], baseline.disk[id])) {
880
+ throw new Error(
881
+ `Provider "${id}" comes from another config file. Edit its source config or create a new auth profile alias.`
882
+ );
883
+ }
884
+ if (Object.hasOwn(providers, id)) pending[id] = structuredClone(providers[id]);
885
+ else delete pending[id];
886
+ }
887
+ await saveProviders(globalConfigPath, pending);
888
+ for (const id of Object.keys(providers)) {
889
+ if (equal(baseline.view[id], baseline.disk[id])) delete providers[id];
890
+ }
891
+ for (const [id, record] of Object.entries(pending)) {
892
+ if (equal(baseline.view[id], baseline.disk[id])) providers[id] = record;
893
+ }
894
+ views.set(providers, { view: structuredClone(providers), disk: structuredClone(pending) });
895
+ }
1113
896
  };
1114
- v6.once("listening", onListening);
1115
- v6.once("error", onInitialError);
1116
- v6.listen(httpPort, "::1");
897
+ }
898
+ return {
899
+ load: () => configProvidersRef ? Promise.resolve(snapshots.track(structuredClone(configProvidersRef()))) : loadSavedProviders(globalConfigPath),
900
+ save: (providers) => saveProviders(globalConfigPath, providers)
901
+ };
902
+ }
903
+
904
+ // src/webui-server/message-router.ts
905
+ function createCliEmbeddedMessageRouter(input) {
906
+ const { opts, routeContexts, domainHandlers } = input;
907
+ return createEmbeddedMessageRouter({
908
+ jevVault: getVault(opts.globalConfigPath ?? opts.profileConfigPath),
909
+ trustBoundary: input.trustBoundary,
910
+ opts,
911
+ logger: consoleLogger,
912
+ send: input.send,
913
+ sendResult: input.sendResult,
914
+ sessionPayload: input.sessionPayload,
915
+ currentSessionId: input.currentSessionId,
916
+ shutdown: input.shutdown,
917
+ // Auto-heal watchdog disposer — `disposeResources` awaits it (bounded) so
918
+ // an in-flight daemon restart drains before the host exits.
919
+ onDispose: input.onDispose,
920
+ providerCtx: input.providerCtx,
921
+ brainCtx: routeContexts.brainCtx,
922
+ introspectionCtx: routeContexts.introspectionCtx,
923
+ skillsCtx: routeContexts.skillsCtx,
924
+ promptsCtx: routeContexts.promptsCtx,
925
+ designCtx: routeContexts.designCtx,
926
+ agentConfigCtx: routeContexts.agentConfigCtx,
927
+ prefsCtx: routeContexts.prefsCtx,
928
+ projectCtx: routeContexts.projectsCtx,
929
+ mailboxRoutes: routeContexts.mailboxRoutes,
930
+ chimeraRoutes: routeContexts.chimeraRoutes,
931
+ codeAssistRoutes: routeContexts.codeAssistRoutes,
932
+ sessionCtx: routeContexts.sessionsCtx,
933
+ conversationCtx: routeContexts.connectionCtx,
934
+ goalHandler: domainHandlers.goalHandler,
935
+ specsHandler: domainHandlers.specsHandler,
936
+ sddBoardHandler: domainHandlers.sddBoardHandler,
937
+ sddWizardHandler: domainHandlers.sddWizardHandler,
938
+ worktreeHandler: domainHandlers.worktreeHandler,
939
+ terminalHandler: domainHandlers.terminalHandler,
940
+ kanbanHostRoutes: input.kanbanHostRoutes,
941
+ statusTracker: opts.statusTracker
1117
942
  });
1118
- if (!ipv6Listening) return null;
1119
- console.log(`[WebUI] Also listening on http://[::1]:${httpPort}`);
1120
- return v6;
1121
943
  }
1122
944
 
1123
945
  // src/webui-server/prefs-seeding.ts
1124
946
  import * as fs from "node:fs/promises";
1125
- import * as path4 from "node:path";
947
+ import * as path5 from "node:path";
1126
948
  import { DefaultSecretVault as DefaultSecretVault2 } from "@wrongstack/core/security";
1127
949
  import {
1128
- PREF_KEYS,
1129
950
  persistPrefsToConfig,
1130
951
  seedContextMeta,
1131
952
  prefSnapshot as snapshotPrefs
@@ -1199,8 +1020,8 @@ function createPrefsSeeding(opts) {
1199
1020
  patchLiveAppConfig({ modelTiers: payload["modelTiers"] });
1200
1021
  }
1201
1022
  if (!opts.profileConfigPath) return;
1202
- const globalRoot = opts.globalConfigPath ? path4.dirname(opts.globalConfigPath) : path4.dirname(opts.profileConfigPath);
1203
- const vault = new DefaultSecretVault2({ keyFile: path4.join(globalRoot, ".key") });
1023
+ const globalRoot = opts.globalConfigPath ? path5.dirname(opts.globalConfigPath) : path5.dirname(opts.profileConfigPath);
1024
+ const vault = new DefaultSecretVault2({ keyFile: path5.join(globalRoot, ".key") });
1204
1025
  await persistPrefsToConfig(
1205
1026
  {
1206
1027
  profileConfigPath: opts.profileConfigPath,
@@ -1228,122 +1049,252 @@ function createPrefsSeeding(opts) {
1228
1049
  };
1229
1050
  }
1230
1051
 
1231
- // src/webui-server/route-contexts.ts
1232
- import * as path5 from "node:path";
1052
+ // src/webui-server/provider-plane.ts
1233
1053
  import {
1234
- normalizeSubagentModelPlan,
1235
- seedSessionSubagentPolicy,
1236
- setSessionSubagentModelPlan,
1237
- setSessionSubagentPolicy,
1238
- subagentPolicyModeFrom
1239
- } from "@wrongstack/core/coordination";
1240
- import { TOKENS as TOKENS2 } from "@wrongstack/core/kernel";
1241
- import { SkillInstaller } from "@wrongstack/core/skills";
1242
- import { PromptUsageStore } from "@wrongstack/core/storage";
1243
- import { resolveTokenSavingTier } from "@wrongstack/core/types";
1244
- import { isOutsideProject, resolveWstackPaths as resolveWstackPaths3 } from "@wrongstack/core/utils";
1245
- import {
1246
- createChimeraRouteHandlers,
1247
- createCodeAssistRouteHandlers,
1248
- createMailboxRouteHandlers,
1249
- rebuildSystemPrompt
1054
+ createEmbeddedProviderOperations
1250
1055
  } from "@wrongstack/webui-server";
1251
- function createWebuiRouteContexts({
1056
+
1057
+ // src/webui-server/credential-watcher.ts
1058
+ import { TOKENS } from "@wrongstack/core/kernel";
1059
+ import { watchProviderConfig } from "@wrongstack/core/storage";
1060
+ import { toErrorMessage } from "@wrongstack/core/utils/error";
1061
+ import { makeProviderFromConfig } from "@wrongstack/providers";
1062
+ function startWebuiCredentialWatcher({
1252
1063
  opts,
1253
1064
  profileConfigPath,
1254
- profileDir,
1255
- globalRoot,
1256
- sessionStartedAt,
1257
- currentSessionId,
1258
- getCustomModeStore,
1259
- buildSessionStartPayload,
1260
- prefSnapshot,
1261
- persistPrefs,
1262
- pendingConfirms,
1263
- abortControllers,
1264
- getSessionAgent,
1265
- peekSessionAgent,
1266
- isSessionLive,
1267
- getForegroundSession,
1268
- setForegroundSession,
1269
- clients,
1270
- onSessionsUndisplayed,
1271
- autoWake,
1272
- stopSessionFleet,
1273
- send,
1274
1065
  broadcast,
1275
- broadcastEveryone
1066
+ broadcastSaved
1276
1067
  }) {
1277
- const brainCtx = {
1278
- brainSettings: opts.brainSettings,
1279
- brainRuntime: opts.brainRuntime,
1280
- getBrainLog: opts.getBrainLog,
1281
- resolveArbiter: () => opts.brain ?? (opts.agent.container.has(TOKENS2.BrainArbiter) ? opts.agent.container.resolve(TOKENS2.BrainArbiter) : void 0),
1282
- getSessionId: currentSessionId,
1283
- send
1284
- };
1285
- const introspectionConfigStore = opts.agent.container?.safeResolve?.(TOKENS2.ConfigStore);
1286
- const introspectionCtx = {
1287
- agent: opts.agent,
1288
- // Answer `diag.get` / `stats.get` / `side_effects.list` from the agent that
1289
- // owns the ASKING session. `opts.agent` is the leader, i.e. whichever tab
1290
- // the runtime last switched to, so a background tab asking for its own
1291
- // stats got the foreground's numbers stamped with the foreground's id —
1292
- // which the browser's session filter then dropped, leaving the panel blank.
1293
- // Read path, so `peek` first: `getAgent` creates, and materialising an
1294
- // agent for a stale id can evict a live tab's.
1295
- getAgent: (sessionId) => peekSessionAgent?.(sessionId) ?? (sessionId ? getSessionAgent(sessionId) : opts.agent),
1296
- modelsRegistry: opts.modelsRegistry,
1297
- configStore: introspectionConfigStore,
1298
- getConfig: () => {
1299
- const cfg = introspectionConfigStore?.get() ?? opts.appConfig;
1300
- if (!cfg)
1301
- throw new Error(
1302
- "Introspection route requires a config but neither ConfigStore nor opts.appConfig is available"
1303
- );
1304
- return cfg;
1305
- },
1306
- getProjectRoot: () => opts.projectRoot ?? opts.agent.ctx.projectRoot ?? "",
1307
- getSessionId: currentSessionId,
1308
- getSessionStartedAt: () => sessionStartedAt,
1309
- getModeId: () => opts.modeId ?? "default",
1310
- send
1311
- };
1312
- const skillsProjectRoot = opts.projectRoot ?? opts.agent.ctx.projectRoot ?? "";
1313
- const skillsPaths = skillsProjectRoot ? resolveWstackPaths3({
1314
- projectRoot: skillsProjectRoot,
1315
- globalRoot
1316
- }) : void 0;
1317
- const skillsCtx = {
1318
- skillLoader: opts.skillLoader,
1319
- skillInstaller: opts.skillLoader ? new SkillInstaller({
1320
- manifestPath: path5.join(skillsPaths?.configDir ?? profileDir, "installed-skills.json"),
1321
- projectSkillsDir: skillsPaths?.inProjectSkills ?? path5.join(skillsProjectRoot, ".wrongstack", "skills"),
1322
- globalSkillsDir: skillsPaths?.globalSkills ?? path5.join(profileDir, "skills"),
1323
- projectHash: skillsPaths?.projectHash ?? "",
1324
- skillLoader: opts.skillLoader
1325
- }) : void 0,
1326
- projectRoot: skillsProjectRoot,
1327
- projectSkillsDir: skillsPaths?.inProjectSkills,
1328
- globalSkillsDir: skillsPaths?.globalSkills
1329
- };
1330
- const promptsCtx = {
1331
- promptLoader: opts.promptLoader,
1332
- promptUsage: new PromptUsageStore(
1333
- skillsPaths?.promptUsage ?? path5.join(profileDir, "prompt-usage.json")
1334
- )
1335
- };
1336
- const designCtx = (sessionId) => ({
1337
- projectRoot: skillsProjectRoot,
1338
- agentMeta: (sessionId ? getSessionAgent(sessionId)?.ctx : void 0) ?? opts.agent.ctx
1339
- });
1340
- const agentConfigCtx = {
1341
- agent: opts.agent,
1342
- modeStore: opts.modeStore,
1343
- loadSavedProviders: () => loadSavedProviders(profileConfigPath),
1344
- buildSessionStart: (overrides) => buildSessionStartPayload(overrides),
1345
- modelsRegistry: opts.modelsRegistry,
1346
- memoryStore: opts.memoryStore,
1068
+ const watchConfigPath = profileConfigPath;
1069
+ if (!watchConfigPath || process.env["WRONGSTACK_DISABLE_CONFIG_WATCH"] === "1") {
1070
+ return void 0;
1071
+ }
1072
+ let lastActiveCfg = JSON.stringify(
1073
+ opts.appConfig?.providers?.[opts.agent.ctx.provider.id] ?? null
1074
+ );
1075
+ let lastUiLocale = opts.appConfig?.uiLocale;
1076
+ const watcher = watchProviderConfig(
1077
+ watchConfigPath,
1078
+ // Vault key lives at ~/.wrongstack/.key (derived from globalConfigPath),
1079
+ // not inside the profile directory — resolve via global root to stay correct.
1080
+ getVault(opts.globalConfigPath),
1081
+ (snapshot) => {
1082
+ const hadFallbackBridge = Boolean(opts.appConfig?.fallbackBridge?.trim());
1083
+ try {
1084
+ if (opts.appConfig && !Object.isFrozen(opts.appConfig)) {
1085
+ opts.appConfig.providers = snapshot.providers;
1086
+ if (snapshot.uiLocale) opts.appConfig.uiLocale = snapshot.uiLocale;
1087
+ if (snapshot.fallbackBridge !== void 0) {
1088
+ opts.appConfig.fallbackBridge = snapshot.fallbackBridge;
1089
+ } else {
1090
+ delete opts.appConfig.fallbackBridge;
1091
+ }
1092
+ }
1093
+ } catch {
1094
+ }
1095
+ const routingChanged = snapshot.fallbackModels !== void 0 || snapshot.fallbackBridge !== void 0 || hadFallbackBridge || snapshot.fallbackProfiles !== void 0 || snapshot.favoriteModels !== void 0 || snapshot.disabledModels !== void 0 || snapshot.disabledProviders !== void 0 || snapshot.favoriteModelsOnly !== void 0 || snapshot.modelMatrix !== void 0 || snapshot.fallbackAuto !== void 0;
1096
+ if (routingChanged) {
1097
+ const configStore = opts.agent.container?.safeResolve?.(TOKENS.ConfigStore);
1098
+ configStore?.update({
1099
+ ...snapshot.fallbackModels !== void 0 ? { fallbackModels: snapshot.fallbackModels } : {},
1100
+ fallbackBridge: snapshot.fallbackBridge ?? "",
1101
+ ...snapshot.fallbackProfiles !== void 0 ? { fallbackProfiles: snapshot.fallbackProfiles } : {},
1102
+ ...snapshot.favoriteModels !== void 0 ? { favoriteModels: snapshot.favoriteModels } : {},
1103
+ ...snapshot.disabledModels !== void 0 ? { disabledModels: snapshot.disabledModels } : {},
1104
+ ...snapshot.disabledProviders !== void 0 ? { disabledProviders: snapshot.disabledProviders } : {},
1105
+ ...snapshot.favoriteModelsOnly !== void 0 ? { favoriteModelsOnly: snapshot.favoriteModelsOnly } : {},
1106
+ ...snapshot.modelMatrix !== void 0 ? { modelMatrix: snapshot.modelMatrix } : {},
1107
+ ...snapshot.fallbackAuto !== void 0 ? { fallbackAuto: snapshot.fallbackAuto } : {}
1108
+ });
1109
+ }
1110
+ broadcastSaved(snapshot.providers);
1111
+ if (snapshot.uiLocale !== lastUiLocale) {
1112
+ lastUiLocale = snapshot.uiLocale;
1113
+ if (snapshot.uiLocale) {
1114
+ opts.agent.ctx.meta["uiLocale"] = snapshot.uiLocale;
1115
+ broadcast({ type: "prefs.updated", payload: { uiLocale: snapshot.uiLocale } });
1116
+ }
1117
+ }
1118
+ const activeId = opts.agent.ctx.provider.id;
1119
+ const newCfgStr = JSON.stringify(snapshot.providers[activeId] ?? null);
1120
+ if (newCfgStr === lastActiveCfg) return;
1121
+ lastActiveCfg = newCfgStr;
1122
+ try {
1123
+ const newCfg = snapshot.providers[activeId] ?? {
1124
+ type: activeId,
1125
+ ...snapshot.apiKey !== void 0 ? { apiKey: snapshot.apiKey } : {},
1126
+ ...snapshot.baseUrl !== void 0 ? { baseUrl: snapshot.baseUrl } : {}
1127
+ };
1128
+ const oldMax = opts.agent.ctx.provider.capabilities?.maxContext;
1129
+ const prov = makeProviderFromConfig(activeId, {
1130
+ ...newCfg,
1131
+ type: newCfg.type ?? activeId
1132
+ });
1133
+ if (oldMax != null && prov.capabilities) prov.capabilities.maxContext = oldMax;
1134
+ opts.agent.ctx.provider = prov;
1135
+ console.log(`[WebUI] Provider credentials reloaded from config.json (${activeId})`);
1136
+ } catch (err) {
1137
+ console.warn(
1138
+ `[WebUI] Credential hot-reload failed for ${activeId}: ${toErrorMessage(err)}`
1139
+ );
1140
+ }
1141
+ },
1142
+ {
1143
+ warn: (m) => console.warn(
1144
+ JSON.stringify({
1145
+ level: "warn",
1146
+ event: "webui.config_watcher",
1147
+ message: m,
1148
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1149
+ })
1150
+ )
1151
+ }
1152
+ );
1153
+ return watcher.close;
1154
+ }
1155
+
1156
+ // src/webui-server/provider-plane.ts
1157
+ function createEmbeddedProviderPlane(input) {
1158
+ const { opts, profileConfigPath, send, broadcast } = input;
1159
+ const wsHandlerCtx = {
1160
+ providerStore: createProviderConfigStore(
1161
+ profileConfigPath,
1162
+ () => opts.appConfig?.providers ?? {}
1163
+ ),
1164
+ modelsRegistry: opts.modelsRegistry,
1165
+ providerAuthRegistry: opts.providerAuthRegistry,
1166
+ getDisabledModels: () => opts.appConfig?.disabledModels ?? [],
1167
+ getDisabledProviders: () => opts.appConfig?.disabledProviders ?? [],
1168
+ send,
1169
+ broadcast,
1170
+ log: (m) => console.log(m)
1171
+ };
1172
+ const embeddedProviderOperations = createEmbeddedProviderOperations(wsHandlerCtx);
1173
+ const credentialWatcherClose = startWebuiCredentialWatcher({
1174
+ opts,
1175
+ profileConfigPath,
1176
+ broadcast,
1177
+ broadcastSaved: (providers) => embeddedProviderOperations.broadcastSaved(providers)
1178
+ });
1179
+ return { wsHandlerCtx, credentialWatcherClose };
1180
+ }
1181
+
1182
+ // src/webui-server/route-contexts.ts
1183
+ import * as path6 from "node:path";
1184
+ import {
1185
+ normalizeSubagentModelPlan,
1186
+ seedSessionSubagentPolicy,
1187
+ setSessionSubagentModelPlan,
1188
+ setSessionSubagentPolicy,
1189
+ subagentPolicyModeFrom
1190
+ } from "@wrongstack/core/coordination";
1191
+ import { TOKENS as TOKENS2 } from "@wrongstack/core/kernel";
1192
+ import { SkillInstaller } from "@wrongstack/core/skills";
1193
+ import { PromptUsageStore } from "@wrongstack/core/storage";
1194
+ import { resolveTokenSavingTier } from "@wrongstack/core/types";
1195
+ import { isOutsideProject, resolveWstackPaths as resolveWstackPaths3 } from "@wrongstack/core/utils";
1196
+ import {
1197
+ createChimeraRouteHandlers,
1198
+ createCodeAssistRouteHandlers,
1199
+ createMailboxRouteHandlers,
1200
+ rebuildSystemPrompt
1201
+ } from "@wrongstack/webui-server";
1202
+ function createWebuiRouteContexts({
1203
+ opts,
1204
+ profileConfigPath,
1205
+ profileDir,
1206
+ globalRoot,
1207
+ sessionStartedAt,
1208
+ currentSessionId,
1209
+ getCustomModeStore,
1210
+ buildSessionStartPayload,
1211
+ prefSnapshot,
1212
+ persistPrefs,
1213
+ pendingConfirms,
1214
+ abortControllers,
1215
+ getSessionAgent,
1216
+ peekSessionAgent,
1217
+ isSessionLive,
1218
+ getForegroundSession,
1219
+ setForegroundSession,
1220
+ clients,
1221
+ onSessionsUndisplayed,
1222
+ autoWake,
1223
+ stopSessionFleet,
1224
+ send,
1225
+ broadcast,
1226
+ broadcastEveryone
1227
+ }) {
1228
+ const brainCtx = {
1229
+ brainSettings: opts.brainSettings,
1230
+ brainRuntime: opts.brainRuntime,
1231
+ getBrainLog: opts.getBrainLog,
1232
+ resolveArbiter: () => opts.brain ?? (opts.agent.container.has(TOKENS2.BrainArbiter) ? opts.agent.container.resolve(TOKENS2.BrainArbiter) : void 0),
1233
+ getSessionId: currentSessionId,
1234
+ send
1235
+ };
1236
+ const introspectionConfigStore = opts.agent.container?.safeResolve?.(TOKENS2.ConfigStore);
1237
+ const introspectionCtx = {
1238
+ agent: opts.agent,
1239
+ // Answer `diag.get` / `stats.get` / `side_effects.list` from the agent that
1240
+ // owns the ASKING session. `opts.agent` is the leader, i.e. whichever tab
1241
+ // the runtime last switched to, so a background tab asking for its own
1242
+ // stats got the foreground's numbers stamped with the foreground's id —
1243
+ // which the browser's session filter then dropped, leaving the panel blank.
1244
+ // Read path, so `peek` first: `getAgent` creates, and materialising an
1245
+ // agent for a stale id can evict a live tab's.
1246
+ getAgent: (sessionId) => peekSessionAgent?.(sessionId) ?? (sessionId ? getSessionAgent(sessionId) : opts.agent),
1247
+ modelsRegistry: opts.modelsRegistry,
1248
+ configStore: introspectionConfigStore,
1249
+ getConfig: () => {
1250
+ const cfg = introspectionConfigStore?.get() ?? opts.appConfig;
1251
+ if (!cfg)
1252
+ throw new Error(
1253
+ "Introspection route requires a config but neither ConfigStore nor opts.appConfig is available"
1254
+ );
1255
+ return cfg;
1256
+ },
1257
+ getProjectRoot: () => opts.projectRoot ?? opts.agent.ctx.projectRoot ?? "",
1258
+ getSessionId: currentSessionId,
1259
+ getSessionStartedAt: () => sessionStartedAt,
1260
+ getModeId: () => opts.modeId ?? "default",
1261
+ send
1262
+ };
1263
+ const skillsProjectRoot = opts.projectRoot ?? opts.agent.ctx.projectRoot ?? "";
1264
+ const skillsPaths = skillsProjectRoot ? resolveWstackPaths3({
1265
+ projectRoot: skillsProjectRoot,
1266
+ globalRoot
1267
+ }) : void 0;
1268
+ const skillsCtx = {
1269
+ skillLoader: opts.skillLoader,
1270
+ skillInstaller: opts.skillLoader ? new SkillInstaller({
1271
+ manifestPath: path6.join(skillsPaths?.configDir ?? profileDir, "installed-skills.json"),
1272
+ projectSkillsDir: skillsPaths?.inProjectSkills ?? path6.join(skillsProjectRoot, ".wrongstack", "skills"),
1273
+ globalSkillsDir: skillsPaths?.globalSkills ?? path6.join(profileDir, "skills"),
1274
+ projectHash: skillsPaths?.projectHash ?? "",
1275
+ skillLoader: opts.skillLoader
1276
+ }) : void 0,
1277
+ projectRoot: skillsProjectRoot,
1278
+ projectSkillsDir: skillsPaths?.inProjectSkills,
1279
+ globalSkillsDir: skillsPaths?.globalSkills
1280
+ };
1281
+ const promptsCtx = {
1282
+ promptLoader: opts.promptLoader,
1283
+ promptUsage: new PromptUsageStore(
1284
+ skillsPaths?.promptUsage ?? path6.join(profileDir, "prompt-usage.json")
1285
+ )
1286
+ };
1287
+ const designCtx = (sessionId) => ({
1288
+ projectRoot: skillsProjectRoot,
1289
+ agentMeta: (sessionId ? getSessionAgent(sessionId)?.ctx : void 0) ?? opts.agent.ctx
1290
+ });
1291
+ const agentConfigCtx = {
1292
+ agent: opts.agent,
1293
+ modeStore: opts.modeStore,
1294
+ loadSavedProviders: () => loadSavedProviders(profileConfigPath),
1295
+ buildSessionStart: (overrides) => buildSessionStartPayload(overrides),
1296
+ modelsRegistry: opts.modelsRegistry,
1297
+ memoryStore: opts.memoryStore,
1347
1298
  getConfig: () => opts.appConfig,
1348
1299
  onMaxContextResolved: opts.onModelContextResolved,
1349
1300
  persistPrefs,
@@ -1458,7 +1409,7 @@ function createWebuiRouteContexts({
1458
1409
  };
1459
1410
  const mailboxRoutes = createMailboxRouteHandlers({
1460
1411
  getProjectRoot: () => opts.projectRoot ?? opts.agent.ctx.projectRoot ?? "",
1461
- getGlobalRoot: () => opts.globalConfigPath ? path5.dirname(opts.globalConfigPath) : "",
1412
+ getGlobalRoot: () => opts.globalConfigPath ? path6.dirname(opts.globalConfigPath) : "",
1462
1413
  events: opts.events
1463
1414
  });
1464
1415
  const chimeraRoutes = createChimeraRouteHandlers({
@@ -1467,7 +1418,7 @@ function createWebuiRouteContexts({
1467
1418
  // lives. A project switch re-roots it, hence the getter.
1468
1419
  projectDir: () => resolveWstackPaths3({
1469
1420
  projectRoot: opts.projectRoot ?? opts.agent.ctx.projectRoot ?? "",
1470
- globalRoot: opts.globalConfigPath ? path5.dirname(opts.globalConfigPath) : ""
1421
+ globalRoot: opts.globalConfigPath ? path6.dirname(opts.globalConfigPath) : ""
1471
1422
  }).projectDir,
1472
1423
  send,
1473
1424
  log: (m) => console.warn(m)
@@ -1540,8 +1491,29 @@ function createWebuiRouteContexts({
1540
1491
  };
1541
1492
  }
1542
1493
 
1494
+ // src/webui-server/session-agents.ts
1495
+ import { createSessionAgentRegistry } from "@wrongstack/webui-server";
1496
+ function createEmbeddedSessionAgents(opts, clients, abortControllers) {
1497
+ const isSessionDisplayed = (sessionId) => {
1498
+ for (const client of clients.values()) {
1499
+ if (client.sessionId === sessionId) return true;
1500
+ if (client.sessionIds?.has(sessionId) === true) return true;
1501
+ }
1502
+ return false;
1503
+ };
1504
+ const sessionAgents = createSessionAgentRegistry({
1505
+ template: opts.agent,
1506
+ ...opts.modelsRegistry ? { modelsRegistry: opts.modelsRegistry } : {},
1507
+ isRunActive: (sessionId) => abortControllers.has(sessionId),
1508
+ // A tab that is still on screen must outlive one that was closed, whatever
1509
+ // order their agents were created in.
1510
+ isDisplayed: isSessionDisplayed
1511
+ });
1512
+ return { sessionAgents, isSessionDisplayed };
1513
+ }
1514
+
1543
1515
  // src/webui-server/session-start-payload.ts
1544
- import * as path6 from "node:path";
1516
+ import * as path7 from "node:path";
1545
1517
  import { DEFAULT_CONTEXT_WINDOW_MODE_ID } from "@wrongstack/core/types";
1546
1518
  import { catalogProviderIdFor } from "@wrongstack/providers";
1547
1519
  import { protocolAdvertisement } from "@wrongstack/webui-protocol";
@@ -1579,220 +1551,635 @@ function createSessionStartPayloadBuilder(deps) {
1579
1551
  } else {
1580
1552
  maxContext = ctx.provider.capabilities?.maxContext ?? 0;
1581
1553
  }
1582
- } catch {
1583
- }
1584
- const lastInputTokens = typeof ctx.lastRequestTokens === "number" && ctx.lastRequestTokens > 0 ? ctx.lastRequestTokens : 0;
1585
- return {
1586
- sessionId: targetSessionId,
1587
- ...startedAt ? { startedAt } : {},
1588
- model: ctx.model,
1589
- provider: ctx.provider.id,
1590
- mode: deps.modeId ?? "default",
1591
- projectName: deps.projectRoot ? path6.basename(deps.projectRoot) : void 0,
1592
- // Frontend reads `projectRoot` from session.start (ws-handlers setEnv) —
1593
- // omitting it left the store's projectRoot empty after a project switch.
1594
- projectRoot: deps.projectRoot ?? ctx.projectRoot ?? "",
1595
- cwd: deps.projectRoot ?? ctx.projectRoot ?? "",
1596
- needsSetup,
1597
- // true when provider/model not configured and running in --webui mode
1598
- contextMode: String(ctx.meta?.["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID),
1599
- maxContext,
1600
- inputCost,
1601
- outputCost,
1602
- cacheReadCost,
1603
- lastInputTokens,
1604
- providerStatuses: deps.statusTracker?.getAllStatuses() ?? [],
1605
- appVersion: updateInfo?.current,
1606
- latestVersion: updateInfo?.latest,
1607
- updateAvailable: updateInfo?.outdated ?? false,
1608
- updateCheckFailed: updateInfo?.checkFailed ?? false,
1609
- ...protocolAdvertisement(),
1610
- ...overrides
1611
- };
1612
- };
1554
+ } catch {
1555
+ }
1556
+ const lastInputTokens = typeof ctx.lastRequestTokens === "number" && ctx.lastRequestTokens > 0 ? ctx.lastRequestTokens : 0;
1557
+ return {
1558
+ sessionId: targetSessionId,
1559
+ ...startedAt ? { startedAt } : {},
1560
+ model: ctx.model,
1561
+ provider: ctx.provider.id,
1562
+ mode: deps.modeId ?? "default",
1563
+ projectName: deps.projectRoot ? path7.basename(deps.projectRoot) : void 0,
1564
+ // Frontend reads `projectRoot` from session.start (ws-handlers setEnv) —
1565
+ // omitting it left the store's projectRoot empty after a project switch.
1566
+ projectRoot: deps.projectRoot ?? ctx.projectRoot ?? "",
1567
+ cwd: deps.projectRoot ?? ctx.projectRoot ?? "",
1568
+ needsSetup,
1569
+ // true when provider/model not configured and running in --webui mode
1570
+ contextMode: String(ctx.meta?.["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID),
1571
+ maxContext,
1572
+ inputCost,
1573
+ outputCost,
1574
+ cacheReadCost,
1575
+ lastInputTokens,
1576
+ providerStatuses: deps.statusTracker?.getAllStatuses() ?? [],
1577
+ appVersion: updateInfo?.current,
1578
+ latestVersion: updateInfo?.latest,
1579
+ updateAvailable: updateInfo?.outdated ?? false,
1580
+ updateCheckFailed: updateInfo?.checkFailed ?? false,
1581
+ ...protocolAdvertisement(),
1582
+ ...overrides
1583
+ };
1584
+ };
1585
+ }
1586
+
1587
+ // src/webui-server/session-start-live.ts
1588
+ function createLiveSessionStartPayloadBuilder(opts, getSessionAgents) {
1589
+ return createSessionStartPayloadBuilder({
1590
+ ...opts,
1591
+ // Read through to the live `opts`, do NOT snapshot: `projects.select`
1592
+ // re-roots the host by assigning `opts.projectRoot` / `opts.session` on
1593
+ // this very object. A spread copy froze both at boot, so every
1594
+ // `session.start` broadcast after a project switch still announced the
1595
+ // previous project's root — the switch looked like it had not happened.
1596
+ get projectRoot() {
1597
+ return opts.projectRoot;
1598
+ },
1599
+ get session() {
1600
+ return opts.session;
1601
+ },
1602
+ get appConfig() {
1603
+ return opts.appConfig;
1604
+ },
1605
+ // `peek`, never `get`: building a payload must not materialise an agent
1606
+ // for a session id that arrived from a stale browser tab.
1607
+ getSessionContext: (sessionId) => getSessionAgents()?.peek(sessionId)?.ctx
1608
+ });
1609
+ }
1610
+
1611
+ // src/webui-server/setup-events.ts
1612
+ import { mailboxSessionTag as mailboxSessionTag2 } from "@wrongstack/core/coordination";
1613
+ import {
1614
+ createEternalSubscription,
1615
+ setupEvents as setupCanonicalEvents
1616
+ } from "@wrongstack/webui-server";
1617
+ function createSetupEvents(deps) {
1618
+ let fleetConcurrency = 0;
1619
+ let fleetConcurrencyMax = 4;
1620
+ const emitConcurrency = () => {
1621
+ const budget = deps.getFleetBudget?.() ?? null;
1622
+ deps.broadcast({
1623
+ type: "fleet.concurrency_update",
1624
+ // Deliberately UNSTAMPED. The spawn/concurrency budget is one ceiling
1625
+ // for the whole process — every tab draws from it and the client keeps
1626
+ // it in a single global store — so this is a project-wide fact, not a
1627
+ // conversation's. Stamping it with the runtime's session made it a
1628
+ // conversation's by accident: `clientWantsSession` then dropped it for
1629
+ // any page whose declared tabs did not include that session, and the
1630
+ // fleet gauges froze the moment the boot tab was closed.
1631
+ payload: {
1632
+ fleetConcurrency: budget?.activeAgents ?? fleetConcurrency,
1633
+ fleetConcurrencyMax: budget?.maxConcurrent ?? fleetConcurrencyMax,
1634
+ ...budget ? {
1635
+ maxSpawns: budget.maxSpawns,
1636
+ usedSpawns: budget.usedSpawns,
1637
+ remainingSpawns: budget.remainingSpawns,
1638
+ maxSpawnsSource: budget.maxSpawnsSource,
1639
+ maxConcurrentSource: budget.maxConcurrentSource,
1640
+ effectiveSource: budget.effectiveSource,
1641
+ checkpointMaxSpawns: budget.checkpointMaxSpawns,
1642
+ ceilingMismatch: budget.ceilingMismatch
1643
+ } : {}
1644
+ }
1645
+ });
1646
+ };
1647
+ return function setupEvents() {
1648
+ for (const unsubscribe of deps.eventUnsubscribers) unsubscribe();
1649
+ deps.eventUnsubscribers.length = 0;
1650
+ const maxIterations = deps.agent.ctx?.meta?.["maxIterations"];
1651
+ const disposeCanonical = setupCanonicalEvents({
1652
+ events: deps.events,
1653
+ // The third argument is not decoration: it names the tab that OWNS a
1654
+ // subagent, and the frames that pass it carry the subagent's own
1655
+ // session on the payload. Dropping it here routed every subagent
1656
+ // codemap frame at a session no tab subscribes to.
1657
+ broadcast: (_clients, message, targetSessionId) => targetSessionId !== void 0 ? deps.broadcast(message, targetSessionId) : deps.broadcast(message),
1658
+ clients: deps.getClients(),
1659
+ config: {
1660
+ tools: {
1661
+ ...typeof maxIterations === "number" ? { maxIterations } : {}
1662
+ }
1663
+ },
1664
+ context: deps.agent.ctx,
1665
+ ...deps.sessionContext ? { sessionContext: deps.sessionContext } : {},
1666
+ pendingConfirms: deps.pendingConfirms,
1667
+ globalConfigPath: deps.globalConfigPath,
1668
+ onFleetBroadcaster: deps.onFleetBroadcaster,
1669
+ projection: {
1670
+ scrubObject: (value) => deps.secretScrubber.scrubObject(value),
1671
+ queueTextDelta: deps.queueTextDelta,
1672
+ queueThinkingDelta: deps.queueThinkingDelta,
1673
+ queueToolProgress: (payload) => deps.queueToolProgress(payload),
1674
+ flushThinkingDelta: deps.flushThinkingDelta,
1675
+ flushAllStreamBuffers: deps.flushAllStreamBuffers
1676
+ }
1677
+ });
1678
+ deps.eventUnsubscribers.push(disposeCanonical);
1679
+ deps.broadcast({
1680
+ type: "subagent.event",
1681
+ payload: deps.sessionPayload({
1682
+ kind: "leader_updated",
1683
+ subagentId: `leader@${mailboxSessionTag2(deps.currentSessionId())}`,
1684
+ isLeader: true,
1685
+ name: "Leader",
1686
+ status: "running"
1687
+ })
1688
+ });
1689
+ emitConcurrency();
1690
+ deps.eventUnsubscribers.push(
1691
+ deps.events.on("concurrency.changed", (event) => {
1692
+ fleetConcurrencyMax = Math.max(1, event.n);
1693
+ emitConcurrency();
1694
+ }),
1695
+ deps.events.on("subagent.spawned", () => {
1696
+ fleetConcurrency += 1;
1697
+ emitConcurrency();
1698
+ }),
1699
+ deps.events.on("subagent.task_completed", () => {
1700
+ fleetConcurrency = Math.max(0, fleetConcurrency - 1);
1701
+ emitConcurrency();
1702
+ })
1703
+ );
1704
+ if (deps.subscribeEternalIteration) {
1705
+ const subscription = createEternalSubscription(
1706
+ deps.subscribeEternalIteration,
1707
+ (_clients, message) => deps.broadcast(message),
1708
+ deps.getClients
1709
+ );
1710
+ deps.eventUnsubscribers.push(() => subscription.dispose());
1711
+ }
1712
+ };
1713
+ }
1714
+
1715
+ // src/webui-server/stream-coalescer.ts
1716
+ import {
1717
+ createStreamCoalescer
1718
+ } from "@wrongstack/webui-server";
1719
+
1720
+ // src/webui-server/transport.ts
1721
+ import {
1722
+ clientWantsSession,
1723
+ sendSerialized,
1724
+ stampDispatchSession,
1725
+ webuiSessionFrameLog
1726
+ } from "@wrongstack/webui-server";
1727
+ function createWebuiTransport(clients) {
1728
+ function send(ws, msg) {
1729
+ sendSerialized(ws, JSON.stringify(stampDispatchSession(msg)));
1730
+ }
1731
+ function broadcast(msg, targetSessionId) {
1732
+ const payload = msg.payload;
1733
+ const sessionId = targetSessionId ?? (payload && typeof payload === "object" && typeof payload.sessionId === "string" ? payload.sessionId : void 0);
1734
+ const data = sessionId ? webuiSessionFrameLog().sequence(sessionId, msg) : JSON.stringify(msg);
1735
+ for (const [ws, client] of clients) {
1736
+ if (clientWantsSession(client, sessionId)) sendSerialized(ws, data);
1737
+ }
1738
+ }
1739
+ function broadcastEveryone(msg) {
1740
+ const data = JSON.stringify(msg);
1741
+ for (const [ws] of clients) sendSerialized(ws, data);
1742
+ }
1743
+ function sendResult(ws, success, message) {
1744
+ send(ws, { type: "key.operation_result", payload: { success, message } });
1745
+ }
1746
+ return { send, broadcast, broadcastEveryone, sendResult };
1747
+ }
1748
+
1749
+ // src/webui-server-lifecycle.ts
1750
+ import { toErrorMessage as toErrorMessage2 } from "@wrongstack/core/utils/error";
1751
+
1752
+ // src/webui-server/connection-handler.ts
1753
+ import { deriveSessionAgents } from "@wrongstack/core/storage";
1754
+ import { buildReplayPayload, decodeProtocolFrame } from "@wrongstack/webui-protocol";
1755
+ import {
1756
+ createConnectionLifecycle,
1757
+ verifyClient as verifyWsClient2
1758
+ } from "@wrongstack/webui-server";
1759
+ function createConnectionHandler(deps) {
1760
+ const clientHandlers = [
1761
+ deps.goalHandler,
1762
+ deps.specsHandler,
1763
+ deps.sddBoardHandler,
1764
+ ...deps.sddWizardHandler ? [deps.sddWizardHandler] : [],
1765
+ deps.worktreeHandler,
1766
+ deps.terminalHandler
1767
+ ];
1768
+ return createConnectionLifecycle({
1769
+ clients: deps.clients,
1770
+ pendingConfirms: deps.pendingConfirms,
1771
+ // ── Auth trust model ────────────────────────────────────────────────
1772
+ // Three layered defenses (see verifyClient in @wrongstack/webui-server/ws-auth):
1773
+ // 1. DNS-rebinding guard (Host header must be loopback on loopback bind)
1774
+ // 2. Shared-token auth via Cookie (ws_token) or URL query param (?token=)
1775
+ // 3. Loopback bootstrap: when requireToken=false (default) AND the server
1776
+ // is bound to a loopback interface, browser clients bypass token auth
1777
+ // entirely — the Origin header being http://localhost is sufficient.
1778
+ //
1779
+ // COOKIE PERSISTENCE AFTER TOKEN ROTATION:
1780
+ // The HttpOnly ws_token cookie, once set by /ws-auth, survives a process
1781
+ // restart. On the next process the expectedToken is a fresh random value,
1782
+ // so the old cookie alone would fail the token check. However, on a
1783
+ // loopback bind with requireToken=false (default), the loopback-bootstrap
1784
+ // path at layer 3 accepts the connection before the cookie is ever
1785
+ // compared — the old cookie is harmless because no token is needed.
1786
+ //
1787
+ // This means a token rotation (e.g. the operator sets a new accessToken
1788
+ // in config) does NOT invalidate already-distributed cookies for loopback
1789
+ // clients. The mitigating factors:
1790
+ // - Loopback bind + requireToken=false already trusts any local process
1791
+ // that can reach 127.0.0.1 — a trivial perimeter.
1792
+ // - A LAN/0.0.0.0 bind with requireToken=true DOES enforce cookie
1793
+ // matching against the new expectedToken on every reconnect.
1794
+ // If stronger isolation is needed, set requireToken=true or rebind the
1795
+ // server to a non-loopback interface with token enforcement.
1796
+ authenticate: (ws, req) => {
1797
+ const allowed = verifyWsClient2({
1798
+ origin: req.headers.origin,
1799
+ url: req.url ?? "/",
1800
+ hostHeader: req.headers.host,
1801
+ remoteAddress: req.socket.remoteAddress,
1802
+ cookieHeader: req.headers.cookie,
1803
+ wsHost: deps.host,
1804
+ expectedToken: deps.wsToken,
1805
+ requireToken: deps.requireToken,
1806
+ allowedHostnames: deps.publicHostnames,
1807
+ allowBrowserUrlToken: Boolean(deps.publicWsUrl)
1808
+ });
1809
+ if (!allowed) ws.close(4003, "Forbidden");
1810
+ return allowed;
1811
+ },
1812
+ createClient: (ws) => ({ ws, sessionId: deps.currentSessionId() }),
1813
+ // Enrich parse-failure / unknown-type logs with per-connection identity
1814
+ // so operators can identify which client sent a malformed frame. The
1815
+ // `connectionId`/`sessionId` fields are read from the `client` argument
1816
+ // directly inside `logRejection`; the CLI's `ConnectedClient` only has
1817
+ // `sessionId` (no per-socket `connId`), so the verbose payload carries
1818
+ // `sessionId` only. Server-wide identity (`agentId`/`projectRoot`) isn't
1819
+ // in scope at this handler and is omitted.
1820
+ verboseWsLogging: true,
1821
+ registerClient: (ws) => {
1822
+ for (const handler of clientHandlers) handler.addClient(ws);
1823
+ },
1824
+ decode: (raw) => decodeProtocolFrame(raw, "client"),
1825
+ dispatch: deps.handleMessage,
1826
+ send: deps.send,
1827
+ sessionPayload: deps.sessionPayload,
1828
+ rateLimitMax: deps.rateLimitMax,
1829
+ // NOTE — a closed SOCKET is not a stopped run.
1830
+ //
1831
+ // This used to `abortControllers.delete(client.sessionId)` — a delete
1832
+ // WITHOUT an abort, and only for the tab this connection last acted on.
1833
+ // Both halves were wrong. The run kept going while `isRunActive` reported
1834
+ // `false`, so `session.delete` would happily unlink the journal a live run
1835
+ // was still appending to, and the next `user_message` claimed a second
1836
+ // lock for the same session and hit `Agent.run() is already in progress`.
1837
+ // The other three declared tabs were never touched at all.
1838
+ //
1839
+ // Doing nothing is the correct behaviour: a background run outlives the
1840
+ // tab that started it (the same invariant `retireUndisplayedSessions`
1841
+ // honours), and the run's own `finally` releases the lock when it unwinds.
1842
+ // A page that comes back is told what is still running by
1843
+ // `session.run_state`; a browser that never comes back drains its
1844
+ // unanswerable permission prompts through the last-client drain in
1845
+ // `connection-lifecycle.ts`, which lets a wedged run finish and release.
1846
+ //
1847
+ // So there is deliberately NO `onClose` here.
1848
+ buildInitialPayload: async () => {
1849
+ const payload = { ...await deps.buildSessionStartPayload({}, deps.needsSetup) };
1850
+ try {
1851
+ const open = deps.openSessionIds?.();
1852
+ if (open) payload["openSessionIds"] = [.../* @__PURE__ */ new Set([...open, deps.currentSessionId()])];
1853
+ } catch {
1854
+ console.debug("[WebUI] Failed to list open sessions");
1855
+ }
1856
+ let replayEvents;
1857
+ try {
1858
+ const replay = await deps.loadReplay?.();
1859
+ if (replay) {
1860
+ Object.assign(payload, buildReplayPayload(replay));
1861
+ replayEvents = replay.events;
1862
+ }
1863
+ } catch {
1864
+ console.debug("[WebUI] Failed to load replay");
1865
+ }
1866
+ try {
1867
+ const roster = deriveSessionAgents(replayEvents ?? []);
1868
+ const sessions = roster.length > 0 ? await deps.loadAgentSessions?.(roster.map((agent) => agent.agentId)) : [];
1869
+ if (sessions?.length) payload["agentSessions"] = sessions;
1870
+ } catch {
1871
+ console.debug("[WebUI] Failed to load agent sessions");
1872
+ }
1873
+ return payload;
1874
+ }
1875
+ });
1876
+ }
1877
+
1878
+ // src/webui-server-lifecycle.ts
1879
+ function runWebuiServerLifecycle({
1880
+ host,
1881
+ httpPort,
1882
+ setupEvents,
1883
+ opts,
1884
+ wsPort,
1885
+ accessUrl,
1886
+ wsToken,
1887
+ webuiInstanceRegistered,
1888
+ wss,
1889
+ httpServer,
1890
+ requireToken,
1891
+ publicHostnames,
1892
+ publicWsUrl,
1893
+ clients,
1894
+ currentSessionId,
1895
+ goalHandler,
1896
+ specsHandler,
1897
+ sddBoardHandler,
1898
+ sddWizardHandler,
1899
+ worktreeHandler,
1900
+ terminalHandler,
1901
+ rateLimitMax,
1902
+ send,
1903
+ sessionPayload,
1904
+ handleMessage,
1905
+ pendingConfirms,
1906
+ buildSessionStartPayload,
1907
+ sessionAgents,
1908
+ lifecycleState,
1909
+ abortControllers,
1910
+ flushAllStreamBuffers,
1911
+ eventUnsubscribers,
1912
+ releaseSessionSalvage,
1913
+ autoWakeHost,
1914
+ stopEmptySessionCleanup,
1915
+ credentialWatcherClose,
1916
+ kanbanRunMirror,
1917
+ kanbanSupervisor,
1918
+ stopKanbanSupervisorMemoryStats,
1919
+ unregisterWebuiClient,
1920
+ ipv6LoopbackServer,
1921
+ registryBaseDir,
1922
+ stopLiveStatusLogger,
1923
+ terminalLogView
1924
+ }) {
1925
+ const stopped = new Promise((resolve) => {
1926
+ let listeningAnnounced = false;
1927
+ const announceListening = () => {
1928
+ if (listeningAnnounced) return;
1929
+ listeningAnnounced = true;
1930
+ console.log(`[WebUI] WebSocket server running on ws://${host}:${httpPort}`);
1931
+ try {
1932
+ setupEvents();
1933
+ opts.onListening?.({
1934
+ httpPort,
1935
+ wsPort,
1936
+ host,
1937
+ url: accessUrl,
1938
+ authToken: wsToken,
1939
+ webuiInstanceRegistered
1940
+ });
1941
+ } catch (err) {
1942
+ consoleLogger.error("setup_events_failed", { message: toErrorMessage2(err) });
1943
+ }
1944
+ };
1945
+ wss.on("listening", announceListening);
1946
+ if (httpServer?.server.listening || wss.address()) queueMicrotask(announceListening);
1947
+ wss.on(
1948
+ "connection",
1949
+ createConnectionHandler({
1950
+ host,
1951
+ wsToken,
1952
+ requireToken,
1953
+ publicHostnames,
1954
+ publicWsUrl,
1955
+ clients,
1956
+ currentSessionId,
1957
+ goalHandler,
1958
+ specsHandler,
1959
+ sddBoardHandler,
1960
+ sddWizardHandler,
1961
+ worktreeHandler,
1962
+ terminalHandler,
1963
+ rateLimitMax,
1964
+ send,
1965
+ sessionPayload,
1966
+ handleMessage,
1967
+ pendingConfirms,
1968
+ buildSessionStartPayload,
1969
+ loadReplay: async () => {
1970
+ const activeSession = opts.agent.ctx.session ?? opts.session;
1971
+ await activeSession.flush();
1972
+ if (opts.sessionStore) {
1973
+ const data = await opts.sessionStore.load(activeSession.id);
1974
+ return { messages: data.messages, events: data.events, usage: data.usage };
1975
+ }
1976
+ const usage = opts.agent.ctx.tokenCounter.total();
1977
+ return { messages: opts.agent.ctx.messages, usage };
1978
+ },
1979
+ loadAgentSessions: async (subagentIds) => await opts.agentTranscripts?.loadSessionsFromDisk(subagentIds) ?? [],
1980
+ // What this process is actually holding right now. The browser
1981
+ // reconciles its persisted tab strip against it, so a restarted server
1982
+ // is not dressed in the previous run's tabs.
1983
+ openSessionIds: () => sessionAgents.ids(),
1984
+ needsSetup: opts.needsSetup ?? false
1985
+ })
1986
+ );
1987
+ lifecycleState.signalShutdown = createWebuiShutdown({
1988
+ abortInFlight: () => {
1989
+ void lifecycleState.embeddedAutoHealDispose?.();
1990
+ for (const c of abortControllers.values()) c.abort();
1991
+ abortControllers.clear();
1992
+ },
1993
+ unsubscribeEvents: () => {
1994
+ flushAllStreamBuffers();
1995
+ for (const unsub of eventUnsubscribers) unsub();
1996
+ },
1997
+ stopOwnedChildren: async () => {
1998
+ try {
1999
+ const { getProcessRegistry } = await import("@wrongstack/tools");
2000
+ getProcessRegistry().killAll({ force: true, includeProtected: true });
2001
+ } catch (err) {
2002
+ console.debug(
2003
+ `[webui-server] process-registry killAll failed: ${err instanceof Error ? err.message : String(err)}`
2004
+ );
2005
+ }
2006
+ if (opts.mcpRegistry) {
2007
+ try {
2008
+ await opts.mcpRegistry.stopAll();
2009
+ } catch (err) {
2010
+ console.debug(
2011
+ `[webui-server] mcpRegistry.stopAll failed: ${err instanceof Error ? err.message : String(err)}`
2012
+ );
2013
+ }
2014
+ }
2015
+ },
2016
+ disposeResources: async () => {
2017
+ releaseSessionSalvage();
2018
+ autoWakeHost?.dispose();
2019
+ await stopEmptySessionCleanup?.dispose();
2020
+ await sessionAgents.closeAll().catch(() => void 0);
2021
+ credentialWatcherClose?.();
2022
+ credentialWatcherClose = void 0;
2023
+ goalHandler.dispose();
2024
+ sddBoardHandler.dispose();
2025
+ worktreeHandler.dispose();
2026
+ terminalHandler.dispose();
2027
+ kanbanRunMirror?.dispose();
2028
+ kanbanSupervisor?.dispose();
2029
+ void stopKanbanSupervisorMemoryStats?.();
2030
+ await lifecycleState.embeddedAutoHealDispose?.();
2031
+ lifecycleState.embeddedAutoHealDispose = null;
2032
+ unregisterWebuiClient();
2033
+ },
2034
+ closeClients: () => {
2035
+ for (const [ws] of clients) ws.close();
2036
+ clients.clear();
2037
+ },
2038
+ closeHttpServer: () => {
2039
+ ipv6LoopbackServer?.close();
2040
+ httpServer?.server.close();
2041
+ },
2042
+ wss,
2043
+ pid: process.pid,
2044
+ registryBaseDir,
2045
+ onStopped: () => {
2046
+ stopLiveStatusLogger();
2047
+ const muted = terminalLogView.mutedCount;
2048
+ terminalLogView.stop();
2049
+ if (muted > 0) {
2050
+ console.log(
2051
+ `[WebUI] ${muted} progress line(s) kept out of this terminal \u2014 set WEBUI_LOGS=1 to stream them.`
2052
+ );
2053
+ }
2054
+ opts.onExit?.();
2055
+ resolve();
2056
+ }
2057
+ });
2058
+ registerWebuiSignalHandlers(lifecycleState.signalShutdown);
2059
+ });
2060
+ return { stopped };
1613
2061
  }
1614
2062
 
1615
- // src/webui-server/setup-events.ts
1616
- import { mailboxSessionTag as mailboxSessionTag2 } from "@wrongstack/core/coordination";
2063
+ // src/webui-session-maintenance.ts
1617
2064
  import {
1618
- createEternalSubscription,
1619
- setupEvents as setupCanonicalEvents
2065
+ collectDisplayedSessionIds,
2066
+ scheduleOwnerlessEmptySessionCleanup,
2067
+ toSessionHistoryEntries
1620
2068
  } from "@wrongstack/webui-server";
1621
- function createSetupEvents(deps) {
1622
- let fleetConcurrency = 0;
1623
- let fleetConcurrencyMax = 4;
1624
- const emitConcurrency = () => {
1625
- const budget = deps.getFleetBudget?.() ?? null;
1626
- deps.broadcast({
1627
- type: "fleet.concurrency_update",
1628
- // Deliberately UNSTAMPED. The spawn/concurrency budget is one ceiling
1629
- // for the whole process — every tab draws from it and the client keeps
1630
- // it in a single global store — so this is a project-wide fact, not a
1631
- // conversation's. Stamping it with the runtime's session made it a
1632
- // conversation's by accident: `clientWantsSession` then dropped it for
1633
- // any page whose declared tabs did not include that session, and the
1634
- // fleet gauges froze the moment the boot tab was closed.
1635
- payload: {
1636
- fleetConcurrency: budget?.activeAgents ?? fleetConcurrency,
1637
- fleetConcurrencyMax: budget?.maxConcurrent ?? fleetConcurrencyMax,
1638
- ...budget ? {
1639
- maxSpawns: budget.maxSpawns,
1640
- usedSpawns: budget.usedSpawns,
1641
- remainingSpawns: budget.remainingSpawns,
1642
- maxSpawnsSource: budget.maxSpawnsSource,
1643
- maxConcurrentSource: budget.maxConcurrentSource,
1644
- effectiveSource: budget.effectiveSource,
1645
- checkpointMaxSpawns: budget.checkpointMaxSpawns,
1646
- ceilingMismatch: budget.ceilingMismatch
1647
- } : {}
1648
- }
2069
+ function setupWebuiSessionMaintenance({
2070
+ opts,
2071
+ broadcastEveryone,
2072
+ getForegroundSession,
2073
+ clients,
2074
+ abortControllers
2075
+ }) {
2076
+ const refreshSessionHistory = async () => {
2077
+ const list = await opts.sessionStore?.list(200);
2078
+ if (!list) return;
2079
+ broadcastEveryone({
2080
+ type: "sessions.list",
2081
+ payload: { sessions: toSessionHistoryEntries(list, getForegroundSession().id) }
1649
2082
  });
1650
2083
  };
1651
- return function setupEvents() {
1652
- for (const unsubscribe of deps.eventUnsubscribers) unsubscribe();
1653
- deps.eventUnsubscribers.length = 0;
1654
- const maxIterations = deps.agent.ctx?.meta?.["maxIterations"];
1655
- const disposeCanonical = setupCanonicalEvents({
1656
- events: deps.events,
1657
- // The third argument is not decoration: it names the tab that OWNS a
1658
- // subagent, and the frames that pass it carry the subagent's own
1659
- // session on the payload. Dropping it here routed every subagent
1660
- // codemap frame at a session no tab subscribes to.
1661
- broadcast: (_clients, message, targetSessionId) => targetSessionId !== void 0 ? deps.broadcast(message, targetSessionId) : deps.broadcast(message),
1662
- clients: deps.getClients(),
1663
- config: {
1664
- tools: {
1665
- ...typeof maxIterations === "number" ? { maxIterations } : {}
1666
- }
1667
- },
1668
- context: deps.agent.ctx,
1669
- ...deps.sessionContext ? { sessionContext: deps.sessionContext } : {},
1670
- pendingConfirms: deps.pendingConfirms,
1671
- globalConfigPath: deps.globalConfigPath,
1672
- onFleetBroadcaster: deps.onFleetBroadcaster,
1673
- projection: {
1674
- scrubObject: (value) => deps.secretScrubber.scrubObject(value),
1675
- queueTextDelta: deps.queueTextDelta,
1676
- queueThinkingDelta: deps.queueThinkingDelta,
1677
- queueToolProgress: (payload) => deps.queueToolProgress(payload),
1678
- flushThinkingDelta: deps.flushThinkingDelta,
1679
- flushAllStreamBuffers: deps.flushAllStreamBuffers
1680
- }
1681
- });
1682
- deps.eventUnsubscribers.push(disposeCanonical);
1683
- deps.broadcast({
1684
- type: "subagent.event",
1685
- payload: deps.sessionPayload({
1686
- kind: "leader_updated",
1687
- subagentId: `leader@${mailboxSessionTag2(deps.currentSessionId())}`,
1688
- isLeader: true,
1689
- name: "Leader",
1690
- status: "running"
1691
- })
1692
- });
1693
- emitConcurrency();
1694
- deps.eventUnsubscribers.push(
1695
- deps.events.on("concurrency.changed", (event) => {
1696
- fleetConcurrencyMax = Math.max(1, event.n);
1697
- emitConcurrency();
1698
- }),
1699
- deps.events.on("subagent.spawned", () => {
1700
- fleetConcurrency += 1;
1701
- emitConcurrency();
1702
- }),
1703
- deps.events.on("subagent.task_completed", () => {
1704
- fleetConcurrency = Math.max(0, fleetConcurrency - 1);
1705
- emitConcurrency();
1706
- })
1707
- );
1708
- if (deps.subscribeEternalIteration) {
1709
- const subscription = createEternalSubscription(
1710
- deps.subscribeEternalIteration,
1711
- (_clients, message) => deps.broadcast(message),
1712
- deps.getClients
1713
- );
1714
- deps.eventUnsubscribers.push(() => subscription.dispose());
2084
+ const emptySessionCleanup = opts.sessionStore ? scheduleOwnerlessEmptySessionCleanup({
2085
+ getSessionStore: () => opts.sessionStore,
2086
+ getActiveSessionId: () => getForegroundSession().id,
2087
+ getActiveSessionIds: () => collectDisplayedSessionIds({ getSession: getForegroundSession, clients }),
2088
+ // A run outlives the tab that started it, and a turn that has not
2089
+ // written its first record yet looks exactly like a session nobody
2090
+ // ever used.
2091
+ hasParticipants: (sessionId) => abortControllers.has(sessionId),
2092
+ refreshSessions: refreshSessionHistory,
2093
+ logger: consoleLogger
2094
+ }) : null;
2095
+ const offSessionRenamed = opts.sessionStore ? opts.events.on("session.renamed", () => {
2096
+ void refreshSessionHistory().catch(() => void 0);
2097
+ }) : void 0;
2098
+ const stopEmptySessionCleanup = emptySessionCleanup && {
2099
+ runNow: emptySessionCleanup.runNow,
2100
+ dispose: async () => {
2101
+ offSessionRenamed?.();
2102
+ await emptySessionCleanup.dispose();
1715
2103
  }
1716
2104
  };
2105
+ return { stopEmptySessionCleanup };
1717
2106
  }
1718
2107
 
1719
- // src/webui-server/static-serve.ts
1720
- import {
1721
- ensureDistDir,
1722
- resolveDistDir,
1723
- startStaticServe
1724
- } from "@wrongstack/webui-server";
1725
-
1726
- // src/webui-server/stream-coalescer.ts
1727
- import {
1728
- createStreamCoalescer
1729
- } from "@wrongstack/webui-server";
2108
+ // src/webui-session-retirement.ts
2109
+ import { resolvePendingConfirmsForSession } from "@wrongstack/webui-server";
2110
+ function createWebuiSessionRetirement(inputs) {
2111
+ const { opts, getForegroundSession, pendingConfirms, abortControllers, sessionAgents } = inputs;
2112
+ const retireUndisplayedSessions = (sessionIds) => {
2113
+ for (const sessionId of sessionIds) {
2114
+ if (!sessionId || sessionId === opts.session.id) continue;
2115
+ if (sessionId === getForegroundSession().id) continue;
2116
+ const orphaned = resolvePendingConfirmsForSession(pendingConfirms, sessionId);
2117
+ if (orphaned > 0) {
2118
+ console.log(
2119
+ JSON.stringify({
2120
+ level: "warn",
2121
+ event: "webui.confirm_orphaned_by_tab_close",
2122
+ sessionId,
2123
+ count: orphaned,
2124
+ message: `Denied ${orphaned} unanswerable permission prompt(s) for closed session ${sessionId}.`
2125
+ })
2126
+ );
2127
+ }
2128
+ if (abortControllers.has(sessionId)) continue;
2129
+ opts.onSessionRetired?.(sessionId);
2130
+ const agent = sessionAgents.peek(sessionId);
2131
+ if (!agent || agent === opts.agent) continue;
2132
+ void sessionAgents.endAndClose(sessionId);
2133
+ }
2134
+ };
2135
+ return { retireUndisplayedSessions };
2136
+ }
1730
2137
 
1731
2138
  // src/webui-server.ts
1732
2139
  async function runWebUI(opts) {
1733
2140
  const trustBoundary = opts.trustBoundary ?? createCompatibilityTrustBoundary({ policyId: "cli-webui-trusted-host-compat-v1" });
1734
- const host = opts.host ?? process.env["WEBUI_HOST"] ?? process.env["WS_HOST"] ?? "127.0.0.1";
1735
- const publicUrl = opts.publicUrl ?? process.env["WEBUI_PUBLIC_URL"];
1736
- const publicWsUrl = opts.publicWsUrl ?? process.env["WEBUI_PUBLIC_WS_URL"];
1737
- const requireToken = opts.requireToken ?? envFlag("WEBUI_REQUIRE_TOKEN");
1738
- const surface = opts.surface ?? "webui";
1739
- const surfaceDefaults = surface === "simpleui" ? { http: 3466 } : { http: 3456 };
1740
- const requestedHttpPort = opts.httpPort ?? opts.port ?? surfaceDefaults.http;
2141
+ const {
2142
+ host,
2143
+ publicUrl,
2144
+ publicWsUrl,
2145
+ requireToken,
2146
+ surface,
2147
+ requestedHttpPort,
2148
+ strictPort,
2149
+ globalRoot,
2150
+ profileConfigPath,
2151
+ profileDir,
2152
+ rateLimitMax,
2153
+ publicHostnames
2154
+ } = resolveWebuiHostSettings(opts);
1741
2155
  const terminalLogView = startTerminalDashboard({
1742
2156
  title: surface === "simpleui" ? "SimpleUI" : "WebUI",
1743
- quiet: !envFlag("WEBUI_LOGS"),
2157
+ quiet: !envFlag2("WEBUI_LOGS"),
1744
2158
  // The dashboard is operator-owned terminal output, so retain the full
1745
2159
  // authenticated access URL here even though startup logs remain redacted.
1746
2160
  getUrl: () => accessUrl
1747
2161
  });
1748
- const strictPort = opts.strictPort ?? isStrictPort();
1749
2162
  let httpPort = requestedHttpPort;
1750
2163
  if (!strictPort) {
1751
2164
  httpPort = await findFreePort(host, requestedHttpPort);
1752
2165
  }
1753
2166
  let wsPort = httpPort;
1754
- const globalRoot = opts.globalConfigPath ? path7.dirname(opts.globalConfigPath) : wstackGlobalRoot();
1755
- const profileConfigPath = opts.profileConfigPath ?? opts.globalConfigPath ?? path7.join(globalRoot, "config.json");
1756
- const rawRateLimit = process.env["WEBUI_RATE_LIMIT"]?.trim() ?? "";
1757
- const parsedRateLimit = /^\d+$/.test(rawRateLimit) ? Number(rawRateLimit) : Number.NaN;
1758
- const rateLimitMax = Number.isSafeInteger(parsedRateLimit) && parsedRateLimit >= 0 ? parsedRateLimit : 600;
1759
2167
  const clients = /* @__PURE__ */ new Map();
2168
+ const { send, broadcast, broadcastEveryone, sendResult } = createWebuiTransport(clients);
1760
2169
  const pendingConfirms = /* @__PURE__ */ new Map();
1761
2170
  const secretScrubber = new DefaultSecretScrubber();
1762
2171
  const abortControllers = /* @__PURE__ */ new Map();
1763
- const profileDir = path7.dirname(profileConfigPath);
1764
2172
  let customModeStoreP = null;
1765
2173
  const getCustomModeStore = () => {
1766
- customModeStoreP ??= (async () => {
1767
- const store = createCustomModeStore(profileDir);
1768
- await store.load();
1769
- return store;
1770
- })();
1771
- return customModeStoreP;
1772
- };
1773
- const kanbanRunMirror = opts.projectRoot ? createKanbanRunMirror({
1774
- projectRoot: opts.projectRoot,
1775
- events: opts.events,
1776
- broadcast,
1777
- log: (m) => consoleLogger.info(m)
1778
- }) : null;
1779
- const kanbanSupervisor = opts.projectRoot ? createKanbanSupervisor({
1780
- projectRoot: opts.projectRoot,
1781
- broadcast,
1782
- ...opts.onKanbanDispatch ? { dispatchTask: opts.onKanbanDispatch } : {},
1783
- log: (message) => consoleLogger.info(message)
1784
- }) : null;
1785
- const stopKanbanSupervisorMemoryStats = kanbanSupervisor ? startSharedHeapWatchdog({
1786
- collectStats: () => {
1787
- const stats = kanbanSupervisor.getStats();
1788
- return {
1789
- kanbanSupervisorSnapshots: stats.snapshots,
1790
- kanbanSupervisorScheduledBoards: stats.scheduledBoards,
1791
- kanbanSupervisorAgentCooldowns: stats.agentCooldowns,
1792
- kanbanSupervisorRunningAgents: stats.runningAgents
1793
- };
1794
- }
1795
- }) : void 0;
2174
+ customModeStoreP ??= (async () => {
2175
+ const store = createCustomModeStore(profileDir);
2176
+ await store.load();
2177
+ return store;
2178
+ })();
2179
+ return customModeStoreP;
2180
+ };
2181
+ const { kanbanRunMirror, kanbanSupervisor, stopKanbanSupervisorMemoryStats } = createWebuiKanbanServices(opts, broadcast);
2182
+ const domainHandlers = createWebuiDomainHandlers(opts, trustBoundary, kanbanRunMirror);
1796
2183
  const {
1797
2184
  goalHandler,
1798
2185
  worktreeHandler,
@@ -1800,7 +2187,7 @@ async function runWebUI(opts) {
1800
2187
  specsHandler,
1801
2188
  sddBoardHandler,
1802
2189
  sddWizardHandler
1803
- } = createWebuiDomainHandlers(opts, trustBoundary, kanbanRunMirror);
2190
+ } = domainHandlers;
1804
2191
  await seedConfigToMeta(opts);
1805
2192
  if (typeof opts.agent.ctx?.meta?.["yolo"] === "boolean") {
1806
2193
  opts.onYoloSwitch?.(opts.agent.ctx.meta["yolo"]);
@@ -1808,37 +2195,13 @@ async function runWebUI(opts) {
1808
2195
  const { prefSnapshot, persistPrefs } = createPrefsSeeding(opts);
1809
2196
  const sessionStartedAt = Date.now();
1810
2197
  let sessionAgentsRef;
1811
- const buildSessionStartPayload = createSessionStartPayloadBuilder({
1812
- ...opts,
1813
- // Read through to the live `opts`, do NOT snapshot: `projects.select`
1814
- // re-roots the host by assigning `opts.projectRoot` / `opts.session` on
1815
- // this very object. A spread copy froze both at boot, so every
1816
- // `session.start` broadcast after a project switch still announced the
1817
- // previous project's root — the switch looked like it had not happened.
1818
- get projectRoot() {
1819
- return opts.projectRoot;
1820
- },
1821
- get session() {
1822
- return opts.session;
1823
- },
1824
- get appConfig() {
1825
- return opts.appConfig;
1826
- },
1827
- // `peek`, never `get`: building a payload must not materialise an agent
1828
- // for a session id that arrived from a stale browser tab.
1829
- getSessionContext: (sessionId) => sessionAgentsRef?.peek(sessionId)?.ctx
1830
- });
2198
+ const buildSessionStartPayload = createLiveSessionStartPayloadBuilder(
2199
+ opts,
2200
+ () => sessionAgentsRef
2201
+ );
1831
2202
  const { register: registerWebuiClient, unregister: unregisterWebuiClient } = createEmbeddedClientRegistration(opts, clients, abortControllers, () => sessionAgentsRef);
1832
2203
  registerWebuiClient();
1833
2204
  const wsToken = resolveAuthToken(opts.accessToken);
1834
- const publicHostnames = [publicUrl, publicWsUrl].map((value) => {
1835
- if (!value) return void 0;
1836
- try {
1837
- return new URL(value).hostname;
1838
- } catch {
1839
- return void 0;
1840
- }
1841
- }).filter((value) => Boolean(value));
1842
2205
  let accessUrl = buildWebUIAccessUrl({
1843
2206
  host,
1844
2207
  port: httpPort,
@@ -1846,147 +2209,41 @@ async function runWebUI(opts) {
1846
2209
  publicUrl
1847
2210
  });
1848
2211
  let fleetBroadcastCli = null;
1849
- const httpServer = await startStaticServe({
1850
- getSessionProjectRoot: (sessionId) => sessionAgentsRef?.peek(sessionId)?.ctx.projectRoot ?? (opts.agent.ctx.session?.id === sessionId ? opts.agent.ctx.projectRoot : void 0),
2212
+ const { httpServer, wss, ipv6LoopbackServer } = await startWebuiHttpBridge({
2213
+ opts,
2214
+ surface,
1851
2215
  host,
1852
2216
  httpPort,
2217
+ strictPort,
1853
2218
  globalRoot,
1854
- distDir: opts.frontendDistDir,
1855
- ensureDistDeps: {
1856
- resolvePackageJson: (id) => {
1857
- const packageJson = findInstalledPackageJson(id, import.meta.url);
1858
- if (!packageJson) throw new Error(`Package not found: ${id}`);
1859
- return packageJson;
1860
- }
1861
- },
1862
- onFleetPing: () => {
1863
- void fleetBroadcastCli?.();
1864
- },
1865
- // Without this, /debug/watcher-metrics 503s and the Debug Dashboard has to
1866
- // report the watcher as "Unavailable". The CLI host runs no status watcher
1867
- // of its own, so this object stays all-zero and watcherActive reads false —
1868
- // which is the truthful state for an embedded host.
1869
- watcherMetrics: createDefaultFileWatcherMetrics(),
1870
- onTechStackEvent: (event) => broadcast(event),
1871
- getLlm: () => opts.agent.ctx.provider && opts.agent.ctx.model ? { provider: opts.agent.ctx.provider, model: opts.agent.ctx.model } : void 0,
1872
- projectRoot: opts.projectRoot,
2219
+ wsToken,
2220
+ publicUrl,
1873
2221
  publicWsUrl,
1874
- apiToken: wsToken,
1875
2222
  requireToken,
1876
- deferListen: surface === "simpleui",
1877
- strictPort,
1878
- // Resolve the same-origin health probes from live preferences, including
1879
- // changes made after boot, just like the standalone WebUI host.
1880
- getIntegrationTarget: (kind) => {
1881
- const meta = opts.agent.ctx.meta;
1882
- const enabledKey = kind === "hq" ? "hqEnabled" : "wrongProxyEnabled";
1883
- const urlKey = kind === "hq" ? "hqUrl" : "wrongProxyUrl";
1884
- return meta[enabledKey] === true && typeof meta[urlKey] === "string" ? meta[urlKey] : void 0;
2223
+ getSessionAgents: () => sessionAgentsRef,
2224
+ onFleetPing: () => {
2225
+ void fleetBroadcastCli?.();
1885
2226
  },
1886
- ...opts.getVectorMemoryStore ? { getVectorMemoryStore: opts.getVectorMemoryStore } : {},
1887
- ...opts.vectorMemoryModelCacheDir ? { vectorMemoryModelCacheDir: opts.vectorMemoryModelCacheDir } : {}
1888
- });
1889
- const verifyClient = (info) => verifyWsClient2({
1890
- origin: info.origin,
1891
- url: info.req.url ?? "",
1892
- hostHeader: info.req.headers.host,
1893
- remoteAddress: info.req.socket.remoteAddress,
1894
- cookieHeader: info.req.headers.cookie,
1895
- wsHost: host,
1896
- expectedToken: wsToken,
1897
- requireToken,
1898
- allowedHostnames: [publicUrl, publicWsUrl].filter((value) => Boolean(value)),
1899
- allowBrowserUrlToken: Boolean(publicWsUrl),
1900
- allowCrossPortLoopbackCookie: process.env["WRONGSTACK_WEBUI_DEV_CROSS_PORT_WS"] === "1"
1901
- });
1902
- const wss = httpServer ? new WebSocketServer({ server: httpServer.server, verifyClient, maxPayload: 20 * 1024 * 1024 }) : new WebSocketServer({ port: httpPort, host, verifyClient, maxPayload: 20 * 1024 * 1024 });
1903
- wss.on("error", (err) => {
1904
- console.error(
1905
- JSON.stringify({
1906
- level: "error",
1907
- event: "webui_server.error",
1908
- message: err instanceof Error ? err.message : String(err),
1909
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1910
- })
1911
- );
1912
- });
1913
- if (httpServer) {
1914
- const boundPort = surface === "simpleui" ? await startDeferredHttpListen({
1915
- server: httpServer.server,
1916
- host,
1917
- httpPort,
1918
- logger: consoleLogger,
1919
- strictPort
1920
- }) : httpServer.port;
1921
- if (boundPort !== httpPort) {
1922
- httpPort = boundPort;
1923
- wsPort = boundPort;
2227
+ broadcast,
2228
+ onPortRebound: (port) => {
2229
+ httpPort = port;
2230
+ wsPort = port;
1924
2231
  accessUrl = buildWebUIAccessUrl({ host, port: httpPort, token: wsToken, publicUrl });
1925
2232
  }
1926
- }
1927
- let ipv6LoopbackServer = null;
1928
- if (httpServer && host === "127.0.0.1") {
1929
- ipv6LoopbackServer = await startIpv6LoopbackProxy({
1930
- primary: httpServer.server,
1931
- httpPort,
1932
- logger: consoleLogger
1933
- });
1934
- }
1935
- console.log(`[WebUI] WebSocket server starting on ws://${host}:${httpPort}`);
1936
- if (httpServer) {
1937
- announceWebuiReady({
1938
- surface,
1939
- server: httpServer.server,
1940
- host,
1941
- httpPort,
1942
- open: !!opts.open,
1943
- wsToken,
1944
- publicUrl
1945
- });
1946
- } else {
1947
- console.warn(
1948
- `[WebUI] Frontend not served (run \`pnpm --filter @wrongstack/webui build\`). WS bridge still active on ws://${host}:${httpPort}.`
1949
- );
1950
- }
2233
+ });
1951
2234
  let foregroundSession = opts.agent.ctx.session ?? opts.session;
1952
2235
  const currentSessionId = () => foregroundSession.id;
1953
2236
  const registryBaseDir = globalRoot;
1954
- let webuiInstanceRegistered = false;
1955
- if (opts.projectRoot) {
1956
- const registration = Promise.resolve(
1957
- registerWebuiInstance({
1958
- pid: process.pid,
1959
- surface,
1960
- host,
1961
- httpPort,
1962
- publicUrl,
1963
- projectRoot: opts.projectRoot,
1964
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1965
- registryBaseDir,
1966
- authToken: wsToken,
1967
- ...opts.webuiSessionChild ? {
1968
- role: "session-child",
1969
- sessionId: currentSessionId(),
1970
- parentPid: opts.webuiSessionChild.parentPid,
1971
- parentShellId: opts.webuiSessionChild.parentShellId,
1972
- runtimeId: opts.webuiSessionChild.runtimeId,
1973
- attachable: opts.webuiSessionChild.attachable,
1974
- lastReadyAt: (/* @__PURE__ */ new Date()).toISOString(),
1975
- protocolVersion: opts.webuiSessionChild.protocolVersion,
1976
- capabilities: [...WEBUI_SESSION_CHILD_CAPABILITIES]
1977
- } : {}
1978
- })
1979
- ).then(
1980
- (value) => value !== false,
1981
- () => false
1982
- );
1983
- if (opts.webuiSessionChild) {
1984
- webuiInstanceRegistered = await registration;
1985
- } else {
1986
- void registration;
1987
- webuiInstanceRegistered = true;
1988
- }
1989
- }
2237
+ const webuiInstanceRegistered = await registerEmbeddedWebuiInstance({
2238
+ opts,
2239
+ surface,
2240
+ host,
2241
+ httpPort,
2242
+ publicUrl,
2243
+ registryBaseDir,
2244
+ wsToken,
2245
+ currentSessionId
2246
+ });
1990
2247
  const eventUnsubscribers = [];
1991
2248
  const sessionPayload = (payload) => {
1992
2249
  const provided = payload["sessionId"];
@@ -2017,54 +2274,24 @@ async function runWebUI(opts) {
2017
2274
  secretScrubber,
2018
2275
  getClients: () => clients,
2019
2276
  eventUnsubscribers,
2020
- globalConfigPath: path7.join(globalRoot, "config.json"),
2277
+ globalConfigPath: path8.join(globalRoot, "config.json"),
2021
2278
  onFleetBroadcaster: (fn) => {
2022
2279
  fleetBroadcastCli = fn;
2023
2280
  },
2024
2281
  ...opts.getFleetBudget ? { getFleetBudget: opts.getFleetBudget } : {}
2025
2282
  });
2026
- const wsHandlerCtx = {
2027
- providerStore: createProviderConfigStore(
2028
- profileConfigPath,
2029
- () => opts.appConfig?.providers ?? {}
2030
- ),
2031
- modelsRegistry: opts.modelsRegistry,
2032
- providerAuthRegistry: opts.providerAuthRegistry,
2033
- getDisabledModels: () => opts.appConfig?.disabledModels ?? [],
2034
- getDisabledProviders: () => opts.appConfig?.disabledProviders ?? [],
2035
- send,
2036
- broadcast,
2037
- log: (m) => console.log(m)
2038
- };
2039
- const embeddedProviderOperations = createEmbeddedProviderOperations(wsHandlerCtx);
2040
- const credentialWatcherClose = startWebuiCredentialWatcher({
2283
+ const { wsHandlerCtx, credentialWatcherClose } = createEmbeddedProviderPlane({
2041
2284
  opts,
2042
2285
  profileConfigPath,
2043
- broadcast,
2044
- broadcastSaved: (providers) => embeddedProviderOperations.broadcastSaved(providers)
2045
- });
2046
- const sessionAgents = createSessionAgentRegistry({
2047
- template: opts.agent,
2048
- ...opts.modelsRegistry ? { modelsRegistry: opts.modelsRegistry } : {},
2049
- isRunActive: (sessionId) => abortControllers.has(sessionId),
2050
- // A tab that is still on screen must outlive one that was closed, whatever
2051
- // order their agents were created in.
2052
- isDisplayed: (sessionId) => {
2053
- for (const client of clients.values()) {
2054
- if (client.sessionId === sessionId) return true;
2055
- if (client.sessionIds?.has(sessionId) === true) return true;
2056
- }
2057
- return false;
2058
- }
2286
+ send,
2287
+ broadcast
2059
2288
  });
2289
+ const { sessionAgents, isSessionDisplayed } = createEmbeddedSessionAgents(
2290
+ opts,
2291
+ clients,
2292
+ abortControllers
2293
+ );
2060
2294
  sessionAgentsRef = sessionAgents;
2061
- const isSessionDisplayed = (sessionId) => {
2062
- for (const client of clients.values()) {
2063
- if (client.sessionId === sessionId) return true;
2064
- if (client.sessionIds?.has(sessionId) === true) return true;
2065
- }
2066
- return false;
2067
- };
2068
2295
  const autoWakeHost = opts.leaderAutoWake ? createWebuiLeaderAutoWakeHost({
2069
2296
  controller: opts.leaderAutoWake,
2070
2297
  events: opts.events,
@@ -2095,35 +2322,13 @@ async function runWebUI(opts) {
2095
2322
  abortControllers,
2096
2323
  sessionAgents
2097
2324
  });
2098
- const refreshSessionHistory = async () => {
2099
- const list = await opts.sessionStore?.list(200);
2100
- if (!list) return;
2101
- broadcastEveryone({
2102
- type: "sessions.list",
2103
- payload: { sessions: toSessionHistoryEntries(list, foregroundSession.id) }
2104
- });
2105
- };
2106
- const emptySessionCleanup = opts.sessionStore ? scheduleOwnerlessEmptySessionCleanup({
2107
- getSessionStore: () => opts.sessionStore,
2108
- getActiveSessionId: () => foregroundSession.id,
2109
- getActiveSessionIds: () => collectDisplayedSessionIds({ getSession: () => foregroundSession, clients }),
2110
- // A run outlives the tab that started it, and a turn that has not
2111
- // written its first record yet looks exactly like a session nobody
2112
- // ever used.
2113
- hasParticipants: (sessionId) => abortControllers.has(sessionId),
2114
- refreshSessions: refreshSessionHistory,
2115
- logger: consoleLogger
2116
- }) : null;
2117
- const offSessionRenamed = opts.sessionStore ? opts.events.on("session.renamed", () => {
2118
- void refreshSessionHistory().catch(() => void 0);
2119
- }) : void 0;
2120
- const stopEmptySessionCleanup = emptySessionCleanup && {
2121
- runNow: emptySessionCleanup.runNow,
2122
- dispose: async () => {
2123
- offSessionRenamed?.();
2124
- await emptySessionCleanup.dispose();
2125
- }
2126
- };
2325
+ const { stopEmptySessionCleanup } = setupWebuiSessionMaintenance({
2326
+ opts,
2327
+ broadcastEveryone,
2328
+ getForegroundSession: () => foregroundSession,
2329
+ clients,
2330
+ abortControllers
2331
+ });
2127
2332
  const routeContexts = createWebuiRouteContexts({
2128
2333
  opts,
2129
2334
  profileConfigPath,
@@ -2163,43 +2368,21 @@ async function runWebUI(opts) {
2163
2368
  let signalShutdown;
2164
2369
  const shutdown = () => signalShutdown?.();
2165
2370
  let embeddedAutoHealDispose = null;
2166
- const handleMessage = createEmbeddedMessageRouter({
2167
- jevVault: getVault(opts.globalConfigPath ?? opts.profileConfigPath),
2371
+ const handleMessage = createCliEmbeddedMessageRouter({
2168
2372
  trustBoundary,
2169
2373
  opts,
2170
- logger: consoleLogger,
2171
2374
  send,
2172
2375
  sendResult,
2173
2376
  sessionPayload,
2174
2377
  currentSessionId,
2175
2378
  shutdown,
2176
- // Auto-heal watchdog disposer — `disposeResources` awaits it (bounded) so
2177
- // an in-flight daemon restart drains before the host exits.
2178
2379
  onDispose: (dispose) => {
2179
2380
  embeddedAutoHealDispose = dispose;
2180
2381
  },
2181
2382
  providerCtx: wsHandlerCtx,
2182
- brainCtx: routeContexts.brainCtx,
2183
- introspectionCtx: routeContexts.introspectionCtx,
2184
- skillsCtx: routeContexts.skillsCtx,
2185
- promptsCtx: routeContexts.promptsCtx,
2186
- designCtx: routeContexts.designCtx,
2187
- agentConfigCtx: routeContexts.agentConfigCtx,
2188
- prefsCtx: routeContexts.prefsCtx,
2189
- projectCtx: routeContexts.projectsCtx,
2190
- mailboxRoutes: routeContexts.mailboxRoutes,
2191
- chimeraRoutes: routeContexts.chimeraRoutes,
2192
- codeAssistRoutes: routeContexts.codeAssistRoutes,
2193
- sessionCtx: routeContexts.sessionsCtx,
2194
- conversationCtx: routeContexts.connectionCtx,
2195
- goalHandler,
2196
- specsHandler,
2197
- sddBoardHandler,
2198
- sddWizardHandler,
2199
- worktreeHandler,
2200
- terminalHandler,
2201
- kanbanHostRoutes,
2202
- statusTracker: opts.statusTracker
2383
+ routeContexts,
2384
+ domainHandlers,
2385
+ kanbanHostRoutes
2203
2386
  });
2204
2387
  const { stopped } = runWebuiServerLifecycle({
2205
2388
  host,
@@ -2260,27 +2443,9 @@ async function runWebUI(opts) {
2260
2443
  stopLiveStatusLogger,
2261
2444
  terminalLogView
2262
2445
  });
2263
- function send(ws, msg) {
2264
- sendSerialized(ws, JSON.stringify(stampDispatchSession(msg)));
2265
- }
2266
- function broadcast(msg, targetSessionId) {
2267
- const payload = msg.payload;
2268
- const sessionId = targetSessionId ?? (payload && typeof payload === "object" && typeof payload.sessionId === "string" ? payload.sessionId : void 0);
2269
- const data = sessionId ? webuiSessionFrameLog().sequence(sessionId, msg) : JSON.stringify(msg);
2270
- for (const [ws, client] of clients) {
2271
- if (clientWantsSession(client, sessionId)) sendSerialized(ws, data);
2272
- }
2273
- }
2274
- function broadcastEveryone(msg) {
2275
- const data = JSON.stringify(msg);
2276
- for (const [ws] of clients) sendSerialized(ws, data);
2277
- }
2278
- function sendResult(ws, success, message) {
2279
- send(ws, { type: "key.operation_result", payload: { success, message } });
2280
- }
2281
2446
  return stopped.finally(() => terminalLogView.stop());
2282
2447
  }
2283
2448
  export {
2284
2449
  runWebUI
2285
2450
  };
2286
- //# sourceMappingURL=webui-server-GQOVLQS3.js.map
2451
+ //# sourceMappingURL=webui-server-6IC3SLZG.js.map