@wrongstack/cli 1.0.33 → 1.0.36

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 (145) hide show
  1. package/dist/{acp-EDGUWU7N.js → acp-QLU7T5WX.js} +470 -466
  2. package/dist/{auth-SUHKFFO4.js → auth-AED7RW7D.js} +7 -7
  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/{automation-MCDFCIMV.js → automation-3E3AUPBF.js} +19 -4
  7. package/dist/boot/governance-evidence-candidate.d.ts +1 -2
  8. package/dist/boot/system-prompt-builder.d.ts +1 -1
  9. package/dist/boot/tui-project-spawn.d.ts +0 -1
  10. package/dist/boot/tui-session-resume-contracts.d.ts +95 -0
  11. package/dist/boot/tui-session-resume.d.ts +2 -88
  12. package/dist/boot/tui-settings-read.d.ts +3 -0
  13. package/dist/{boot-auto-thin-K2DHHYIW.js → boot-auto-thin-AGJ6TAWY.js} +3 -3
  14. package/dist/boot-launch-choices.d.ts +22 -0
  15. package/dist/boot-provider-gate.d.ts +28 -0
  16. package/dist/boot-subcommand-dispatch.d.ts +24 -0
  17. package/dist/boot.d.ts +1 -1
  18. package/dist/chimera-cascade-evidence.d.ts +1 -1
  19. package/dist/{chunk-TI7KLHYX.js → chunk-2CMMWRQH.js} +81 -80
  20. package/dist/{chunk-4BX2YDTQ.js → chunk-4JMAWKLA.js} +45 -4
  21. package/dist/{chunk-TWSQTQKR.js → chunk-4R6TVMYA.js} +2 -2
  22. package/dist/{chunk-HHRYIDOW.js → chunk-ACW64W4M.js} +34 -7
  23. package/dist/{chunk-5EA6OTLJ.js → chunk-AFOH6CHO.js} +2 -2
  24. package/dist/{chunk-WHEYHDIE.js → chunk-AKESWZH5.js} +2 -1
  25. package/dist/{chunk-JHUJUIOH.js → chunk-AT2A7RJZ.js} +4 -16
  26. package/dist/{chunk-LKFKO4JW.js → chunk-B6EHK772.js} +2 -2
  27. package/dist/{chunk-U3SS4NRH.js → chunk-BHXAKTBE.js} +1 -1
  28. package/dist/{chunk-4UWCMBPG.js → chunk-BJH44J7G.js} +3 -3
  29. package/dist/{chunk-MPQCHBDJ.js → chunk-CJSHS2K7.js} +5 -1
  30. package/dist/{chunk-RQ4UBKYH.js → chunk-GF7LUWQS.js} +5 -11
  31. package/dist/{chunk-TXAFIERF.js → chunk-L5NWFP5R.js} +2 -2
  32. package/dist/{chunk-24C6VVLM.js → chunk-LPGZ3NUF.js} +23 -23
  33. package/dist/{chunk-UQUYBDMG.js → chunk-M4G5FO7F.js} +1 -1
  34. package/dist/{chunk-P3GRLV47.js → chunk-NEWXDNF2.js} +212 -145
  35. package/dist/{chunk-3LEFXLAW.js → chunk-NOZJIN46.js} +36 -37
  36. package/dist/{chunk-4MDRHMX4.js → chunk-RE73FP2X.js} +4 -27
  37. package/dist/{chunk-5UZBUV2H.js → chunk-RKU66D2O.js} +6 -4
  38. package/dist/{chunk-SWOPJHXI.js → chunk-RYTSPBNM.js} +4 -4
  39. package/dist/{chunk-WTJDSTYD.js → chunk-T5KR6SNO.js} +2 -2
  40. package/dist/chunk-TGSPCFBT.js +1 -0
  41. package/dist/{chunk-ZYACRPLN.js → chunk-ULYAI7CP.js} +6 -3
  42. package/dist/{chunk-HLSHFXTO.js → chunk-WY74MMDB.js} +2 -2
  43. package/dist/{chunk-PGNERAZL.js → chunk-ZWBI7ORM.js} +3 -3
  44. package/dist/{cli-context-7HK67B2Z.js → cli-context-I6HJCVCV.js} +1607 -1495
  45. package/dist/cli-initial-tools.d.ts +54 -0
  46. package/dist/{cli-main-TQGLKB6D.js → cli-main-X7KD7IOC.js} +38223 -37136
  47. package/dist/cli-main-boot.d.ts +133 -0
  48. package/dist/cli-main-orchestration.d.ts +170 -0
  49. package/dist/cli-main-state.d.ts +19 -0
  50. package/dist/config-doctor.d.ts +1 -1
  51. package/dist/{config-transfer-UE37X4B5.js → config-transfer-YPD7HYXH.js} +7 -5
  52. package/dist/{execution-C2IECWF7.js → execution-M7D4KNBB.js} +171 -132
  53. package/dist/fleet/host-director-setup.d.ts +41 -0
  54. package/dist/fleet/host-spawn-types.d.ts +17 -0
  55. package/dist/fleet/host.d.ts +2 -1
  56. package/dist/goal-host-prompts.d.ts +5 -0
  57. package/dist/goal-host-types.d.ts +94 -0
  58. package/dist/goal-host.d.ts +2 -91
  59. package/dist/{hq-KBFDKTDD.js → hq-BBLGEEUH.js} +5 -6
  60. package/dist/hq-server/handle-types.d.ts +1 -1
  61. package/dist/hq-server/routes/auth-handlers.d.ts +1 -1
  62. package/dist/hq-server/routes.d.ts +1 -13
  63. package/dist/hq-server/utils.d.ts +1 -1
  64. package/dist/{hq-server-AVNKZ2PV.js → hq-server-5TR6NBGS.js} +3 -3
  65. package/dist/{hq-service-FGB7BY2D.js → hq-service-4Q64WFNT.js} +3 -3
  66. package/dist/{import-claude-code-AYG24RG2.js → import-claude-code-GHIS2F7Y.js} +3 -3
  67. package/dist/index.js +5 -5
  68. package/dist/live-settings-input.d.ts +2 -0
  69. package/dist/mailbox-bridge-bootstrap.d.ts +1 -4
  70. package/dist/{mailbox-serve-S536N66F.js → mailbox-serve-DRWJVMYU.js} +2 -2
  71. package/dist/{modeldiag-NEPTLFZW.js → modeldiag-F3LUF4VJ.js} +3 -3
  72. package/dist/picker-effort.d.ts +32 -0
  73. package/dist/picker-model-picker.d.ts +13 -2
  74. package/dist/picker-provider-list.d.ts +66 -0
  75. package/dist/picker.d.ts +12 -12
  76. package/dist/plugin-config.d.ts +6 -0
  77. package/dist/{plugin-usage-3TZGROK7.js → plugin-usage-EM752WHG.js} +3 -3
  78. package/dist/{plugins-ITH4SLO6.js → plugins-U6HQFJ7N.js} +4 -4
  79. package/dist/{providers-models-O75MKLN2.js → providers-models-S3S6VXME.js} +249 -239
  80. package/dist/repl-suggestions.d.ts +0 -5
  81. package/dist/{sage-FFKUOCQL.js → sage-BMRDJCDI.js} +9 -5
  82. package/dist/{sage-sync-ZMTHB477.js → sage-sync-PCWCNEUK.js} +2 -2
  83. package/dist/{sdd-runtime-PTOSPYI4.js → sdd-runtime-PF72WHLO.js} +3 -20
  84. package/dist/services/sdd/task-manager.d.ts +1 -12
  85. package/dist/services/sdd-runtime.d.ts +3 -4
  86. package/dist/services/statusline-config.d.ts +0 -2
  87. package/dist/{sessions-config-VE26SB56.js → sessions-config-RQRQDW5Q.js} +2 -2
  88. package/dist/settings-behavior-sections.d.ts +0 -1
  89. package/dist/short-circuit-flags-IEV3FEOT.js +10 -0
  90. package/dist/{skill-suggest-L2EVWB6R.js → skill-suggest-THANK3LV.js} +1 -1
  91. package/dist/slash-commands/command-context.d.ts +2 -0
  92. package/dist/slash-commands/doctor.d.ts +0 -9
  93. package/dist/slash-commands/fallback-chain-view.d.ts +3 -0
  94. package/dist/slash-commands/fallback-ref-validation.d.ts +34 -0
  95. package/dist/slash-commands/goal-refiner.d.ts +10 -2
  96. package/dist/slash-commands/goals.d.ts +6 -0
  97. package/dist/slash-commands/kanban-task-completion.d.ts +17 -0
  98. package/dist/slash-commands/mcp.d.ts +0 -1
  99. package/dist/slash-commands/memory-purge-domain-terms.d.ts +5 -0
  100. package/dist/slash-commands/nextsteps-mode.d.ts +13 -0
  101. package/dist/slash-commands/session-live-status.d.ts +21 -0
  102. package/dist/slash-commands/session.d.ts +1 -1
  103. package/dist/slash-commands/settings-feature-ui-mutations.d.ts +8 -0
  104. package/dist/slash-commands/settings-hq-autonomy-mutations.d.ts +8 -0
  105. package/dist/slash-commands/settings-persist-deps.d.ts +10 -0
  106. package/dist/slash-commands/settings-runtime-mutations.d.ts +12 -0
  107. package/dist/slash-commands/shadow.d.ts +0 -1
  108. package/dist/slash-commands/tool-auto-thin-commands.d.ts +15 -0
  109. package/dist/{standalone-auto-update-VQC4GYCM.js → standalone-auto-update-QUXOZ4B5.js} +13 -2
  110. package/dist/subcommands/handlers/acp-agents.d.ts +9 -0
  111. package/dist/subcommands/handlers/acp-server.d.ts +7 -0
  112. package/dist/subcommands/handlers/hq.d.ts +0 -1
  113. package/dist/subcommands/handlers/providers-models-custom.d.ts +33 -0
  114. package/dist/subcommands/handlers/providers-models-visibility.d.ts +29 -0
  115. package/dist/subcommands/handlers/skill-suggest.d.ts +0 -2
  116. package/dist/{subcommands-CLI6QTLU.js → subcommands-75IB23LS.js} +2 -2
  117. package/dist/{typesafe-NHC322W5.js → typesafe-KW2XHOG3.js} +2 -2
  118. package/dist/{update-J5WHAFPE.js → update-LHCWAWEZ.js} +3 -3
  119. package/dist/utils/win32-cmd.d.ts +1 -1
  120. package/dist/{version-help-ATOKGMTK.js → version-help-JQGRPDJW.js} +2 -2
  121. package/dist/webui-server/credential-watcher.d.ts +3 -1
  122. package/dist/webui-server/host-settings.d.ts +17 -0
  123. package/dist/webui-server/http-bridge.d.ts +32 -0
  124. package/dist/webui-server/instance-registration.d.ts +19 -0
  125. package/dist/webui-server/kanban-services.d.ts +9 -0
  126. package/dist/webui-server/message-router.d.ts +14 -0
  127. package/dist/webui-server/prefs-seeding.d.ts +1 -2
  128. package/dist/webui-server/provider-config.d.ts +0 -1
  129. package/dist/webui-server/provider-plane.d.ts +14 -0
  130. package/dist/webui-server/session-agents.d.ts +18 -0
  131. package/dist/webui-server/session-start-live.d.ts +11 -0
  132. package/dist/webui-server/stream-coalescer.d.ts +1 -1
  133. package/dist/webui-server/transport.d.ts +12 -0
  134. package/dist/{webui-server-GQOVLQS3.js → webui-server-CUIFA25W.js} +1472 -1335
  135. package/dist/webui-session-maintenance.d.ts +16 -0
  136. package/dist/wiring/brain-and-orchestration.d.ts +1 -1
  137. package/dist/wiring/cli-slash-commands-setup.d.ts +1 -0
  138. package/dist/wiring/command-host-state.d.ts +11 -2
  139. package/dist/wiring/dep-watcher-bridge.d.ts +8 -1
  140. package/dist/wiring/management-tools.d.ts +9 -1
  141. package/dist/wiring/session-command-handlers.d.ts +2 -1
  142. package/dist/worktree-command-host.d.ts +31 -0
  143. package/package.json +31 -31
  144. package/dist/chunk-WHOIR577.js +0 -1
  145. package/dist/short-circuit-flags-D6VAG47Y.js +0 -10
@@ -1,16 +1,16 @@
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
13
- } from "./chunk-ZYACRPLN.js";
13
+ } from "./chunk-ULYAI7CP.js";
14
14
  import {
15
15
  createHqCommandDispatcher,
16
16
  createProjectKanbanAssignHandler,
@@ -18,826 +18,278 @@ import {
18
18
  } from "./chunk-NRXWOMK7.js";
19
19
  import {
20
20
  startCliHqConnection
21
- } from "./chunk-4UWCMBPG.js";
21
+ } from "./chunk-BJH44J7G.js";
22
22
  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,37 +355,305 @@ 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
- });
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;
925
378
  try {
926
- const res = await result.agent.run([{ type: "text", text: prompt }]);
927
- return res.finalText ?? "";
928
- } finally {
929
- await result.dispose?.();
379
+ return new URL(value).hostname;
380
+ } catch {
381
+ return void 0;
930
382
  }
931
- } : void 0;
383
+ }).filter((value) => Boolean(value));
932
384
  return {
933
- meta: async (ws) => {
934
- const tools = (opts.agent.ctx.tools ?? []).map((tool) => ({
935
- name: tool.name,
936
- description: tool.description ?? ""
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 {
653
+ meta: async (ws) => {
654
+ const tools = (opts.agent.ctx.tools ?? []).map((tool) => ({
655
+ name: tool.name,
656
+ description: tool.description ?? ""
937
657
  }));
938
658
  const skills = opts.skillLoader ? (await opts.skillLoader.list()).map((skill) => ({
939
659
  name: skill.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;
1091
- }
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;
1101
- }
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);
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 };
823
+ }
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);
855
+ }
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,8 +1049,110 @@ function createPrefsSeeding(opts) {
1228
1049
  };
1229
1050
  }
1230
1051
 
1052
+ // src/webui-server/provider-plane.ts
1053
+ import {
1054
+ createEmbeddedProviderOperations
1055
+ } from "@wrongstack/webui-server";
1056
+
1057
+ // src/webui-server/credential-watcher.ts
1058
+ import { TOKENS } from "@wrongstack/core/kernel";
1059
+ import { watchProviderConfig } from "@wrongstack/core/storage";
1060
+ function startWebuiCredentialWatcher({
1061
+ opts,
1062
+ profileConfigPath,
1063
+ broadcast,
1064
+ broadcastSaved
1065
+ }) {
1066
+ const watchConfigPath = profileConfigPath;
1067
+ if (!watchConfigPath || process.env["WRONGSTACK_DISABLE_CONFIG_WATCH"] === "1") {
1068
+ return void 0;
1069
+ }
1070
+ let lastUiLocale = opts.appConfig?.uiLocale;
1071
+ const watcher = watchProviderConfig(
1072
+ watchConfigPath,
1073
+ // Vault key lives at ~/.wrongstack/.key (derived from globalConfigPath),
1074
+ // not inside the profile directory — resolve via global root to stay correct.
1075
+ getVault(opts.globalConfigPath),
1076
+ (snapshot) => {
1077
+ const hadFallbackBridge = Boolean(opts.appConfig?.fallbackBridge?.trim());
1078
+ try {
1079
+ if (opts.appConfig && !Object.isFrozen(opts.appConfig)) {
1080
+ opts.appConfig.providers = snapshot.providers;
1081
+ if (snapshot.uiLocale) opts.appConfig.uiLocale = snapshot.uiLocale;
1082
+ if (snapshot.fallbackBridge !== void 0) {
1083
+ opts.appConfig.fallbackBridge = snapshot.fallbackBridge;
1084
+ } else {
1085
+ delete opts.appConfig.fallbackBridge;
1086
+ }
1087
+ }
1088
+ } catch {
1089
+ }
1090
+ 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;
1091
+ if (routingChanged) {
1092
+ const configStore = opts.agent.container?.safeResolve?.(TOKENS.ConfigStore);
1093
+ configStore?.update({
1094
+ ...snapshot.fallbackModels !== void 0 ? { fallbackModels: snapshot.fallbackModels } : {},
1095
+ fallbackBridge: snapshot.fallbackBridge ?? "",
1096
+ ...snapshot.fallbackProfiles !== void 0 ? { fallbackProfiles: snapshot.fallbackProfiles } : {},
1097
+ ...snapshot.favoriteModels !== void 0 ? { favoriteModels: snapshot.favoriteModels } : {},
1098
+ ...snapshot.disabledModels !== void 0 ? { disabledModels: snapshot.disabledModels } : {},
1099
+ ...snapshot.disabledProviders !== void 0 ? { disabledProviders: snapshot.disabledProviders } : {},
1100
+ ...snapshot.favoriteModelsOnly !== void 0 ? { favoriteModelsOnly: snapshot.favoriteModelsOnly } : {},
1101
+ ...snapshot.modelMatrix !== void 0 ? { modelMatrix: snapshot.modelMatrix } : {},
1102
+ ...snapshot.fallbackAuto !== void 0 ? { fallbackAuto: snapshot.fallbackAuto } : {}
1103
+ });
1104
+ }
1105
+ broadcastSaved(snapshot.providers);
1106
+ if (snapshot.uiLocale !== lastUiLocale) {
1107
+ lastUiLocale = snapshot.uiLocale;
1108
+ if (snapshot.uiLocale) {
1109
+ opts.agent.ctx.meta["uiLocale"] = snapshot.uiLocale;
1110
+ broadcast({ type: "prefs.updated", payload: { uiLocale: snapshot.uiLocale } });
1111
+ }
1112
+ }
1113
+ },
1114
+ {
1115
+ warn: (m) => console.warn(
1116
+ JSON.stringify({
1117
+ level: "warn",
1118
+ event: "webui.config_watcher",
1119
+ message: m,
1120
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1121
+ })
1122
+ )
1123
+ }
1124
+ );
1125
+ return watcher.close;
1126
+ }
1127
+
1128
+ // src/webui-server/provider-plane.ts
1129
+ function createEmbeddedProviderPlane(input) {
1130
+ const { opts, profileConfigPath, send, broadcast } = input;
1131
+ const wsHandlerCtx = {
1132
+ providerStore: createProviderConfigStore(
1133
+ profileConfigPath,
1134
+ () => opts.appConfig?.providers ?? {}
1135
+ ),
1136
+ modelsRegistry: opts.modelsRegistry,
1137
+ providerAuthRegistry: opts.providerAuthRegistry,
1138
+ getDisabledModels: () => opts.appConfig?.disabledModels ?? [],
1139
+ getDisabledProviders: () => opts.appConfig?.disabledProviders ?? [],
1140
+ send,
1141
+ broadcast,
1142
+ log: (m) => console.log(m)
1143
+ };
1144
+ const embeddedProviderOperations = createEmbeddedProviderOperations(wsHandlerCtx);
1145
+ const credentialWatcherClose = startWebuiCredentialWatcher({
1146
+ opts,
1147
+ profileConfigPath,
1148
+ broadcast,
1149
+ broadcastSaved: (providers) => embeddedProviderOperations.broadcastSaved(providers)
1150
+ });
1151
+ return { wsHandlerCtx, credentialWatcherClose };
1152
+ }
1153
+
1231
1154
  // src/webui-server/route-contexts.ts
1232
- import * as path5 from "node:path";
1155
+ import * as path6 from "node:path";
1233
1156
  import {
1234
1157
  normalizeSubagentModelPlan,
1235
1158
  seedSessionSubagentPolicy,
@@ -1317,9 +1240,9 @@ function createWebuiRouteContexts({
1317
1240
  const skillsCtx = {
1318
1241
  skillLoader: opts.skillLoader,
1319
1242
  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"),
1243
+ manifestPath: path6.join(skillsPaths?.configDir ?? profileDir, "installed-skills.json"),
1244
+ projectSkillsDir: skillsPaths?.inProjectSkills ?? path6.join(skillsProjectRoot, ".wrongstack", "skills"),
1245
+ globalSkillsDir: skillsPaths?.globalSkills ?? path6.join(profileDir, "skills"),
1323
1246
  projectHash: skillsPaths?.projectHash ?? "",
1324
1247
  skillLoader: opts.skillLoader
1325
1248
  }) : void 0,
@@ -1330,7 +1253,7 @@ function createWebuiRouteContexts({
1330
1253
  const promptsCtx = {
1331
1254
  promptLoader: opts.promptLoader,
1332
1255
  promptUsage: new PromptUsageStore(
1333
- skillsPaths?.promptUsage ?? path5.join(profileDir, "prompt-usage.json")
1256
+ skillsPaths?.promptUsage ?? path6.join(profileDir, "prompt-usage.json")
1334
1257
  )
1335
1258
  };
1336
1259
  const designCtx = (sessionId) => ({
@@ -1458,7 +1381,7 @@ function createWebuiRouteContexts({
1458
1381
  };
1459
1382
  const mailboxRoutes = createMailboxRouteHandlers({
1460
1383
  getProjectRoot: () => opts.projectRoot ?? opts.agent.ctx.projectRoot ?? "",
1461
- getGlobalRoot: () => opts.globalConfigPath ? path5.dirname(opts.globalConfigPath) : "",
1384
+ getGlobalRoot: () => opts.globalConfigPath ? path6.dirname(opts.globalConfigPath) : "",
1462
1385
  events: opts.events
1463
1386
  });
1464
1387
  const chimeraRoutes = createChimeraRouteHandlers({
@@ -1467,7 +1390,7 @@ function createWebuiRouteContexts({
1467
1390
  // lives. A project switch re-roots it, hence the getter.
1468
1391
  projectDir: () => resolveWstackPaths3({
1469
1392
  projectRoot: opts.projectRoot ?? opts.agent.ctx.projectRoot ?? "",
1470
- globalRoot: opts.globalConfigPath ? path5.dirname(opts.globalConfigPath) : ""
1393
+ globalRoot: opts.globalConfigPath ? path6.dirname(opts.globalConfigPath) : ""
1471
1394
  }).projectDir,
1472
1395
  send,
1473
1396
  log: (m) => console.warn(m)
@@ -1540,8 +1463,29 @@ function createWebuiRouteContexts({
1540
1463
  };
1541
1464
  }
1542
1465
 
1466
+ // src/webui-server/session-agents.ts
1467
+ import { createSessionAgentRegistry } from "@wrongstack/webui-server";
1468
+ function createEmbeddedSessionAgents(opts, clients, abortControllers) {
1469
+ const isSessionDisplayed = (sessionId) => {
1470
+ for (const client of clients.values()) {
1471
+ if (client.sessionId === sessionId) return true;
1472
+ if (client.sessionIds?.has(sessionId) === true) return true;
1473
+ }
1474
+ return false;
1475
+ };
1476
+ const sessionAgents = createSessionAgentRegistry({
1477
+ template: opts.agent,
1478
+ ...opts.modelsRegistry ? { modelsRegistry: opts.modelsRegistry } : {},
1479
+ isRunActive: (sessionId) => abortControllers.has(sessionId),
1480
+ // A tab that is still on screen must outlive one that was closed, whatever
1481
+ // order their agents were created in.
1482
+ isDisplayed: isSessionDisplayed
1483
+ });
1484
+ return { sessionAgents, isSessionDisplayed };
1485
+ }
1486
+
1543
1487
  // src/webui-server/session-start-payload.ts
1544
- import * as path6 from "node:path";
1488
+ import * as path7 from "node:path";
1545
1489
  import { DEFAULT_CONTEXT_WINDOW_MODE_ID } from "@wrongstack/core/types";
1546
1490
  import { catalogProviderIdFor } from "@wrongstack/providers";
1547
1491
  import { protocolAdvertisement } from "@wrongstack/webui-protocol";
@@ -1579,220 +1523,635 @@ function createSessionStartPayloadBuilder(deps) {
1579
1523
  } else {
1580
1524
  maxContext = ctx.provider.capabilities?.maxContext ?? 0;
1581
1525
  }
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
- };
1526
+ } catch {
1527
+ }
1528
+ const lastInputTokens = typeof ctx.lastRequestTokens === "number" && ctx.lastRequestTokens > 0 ? ctx.lastRequestTokens : 0;
1529
+ return {
1530
+ sessionId: targetSessionId,
1531
+ ...startedAt ? { startedAt } : {},
1532
+ model: ctx.model,
1533
+ provider: ctx.provider.id,
1534
+ mode: deps.modeId ?? "default",
1535
+ projectName: deps.projectRoot ? path7.basename(deps.projectRoot) : void 0,
1536
+ // Frontend reads `projectRoot` from session.start (ws-handlers setEnv) —
1537
+ // omitting it left the store's projectRoot empty after a project switch.
1538
+ projectRoot: deps.projectRoot ?? ctx.projectRoot ?? "",
1539
+ cwd: deps.projectRoot ?? ctx.projectRoot ?? "",
1540
+ needsSetup,
1541
+ // true when provider/model not configured and running in --webui mode
1542
+ contextMode: String(ctx.meta?.["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID),
1543
+ maxContext,
1544
+ inputCost,
1545
+ outputCost,
1546
+ cacheReadCost,
1547
+ lastInputTokens,
1548
+ providerStatuses: deps.statusTracker?.getAllStatuses() ?? [],
1549
+ appVersion: updateInfo?.current,
1550
+ latestVersion: updateInfo?.latest,
1551
+ updateAvailable: updateInfo?.outdated ?? false,
1552
+ updateCheckFailed: updateInfo?.checkFailed ?? false,
1553
+ ...protocolAdvertisement(),
1554
+ ...overrides
1555
+ };
1556
+ };
1557
+ }
1558
+
1559
+ // src/webui-server/session-start-live.ts
1560
+ function createLiveSessionStartPayloadBuilder(opts, getSessionAgents) {
1561
+ return createSessionStartPayloadBuilder({
1562
+ ...opts,
1563
+ // Read through to the live `opts`, do NOT snapshot: `projects.select`
1564
+ // re-roots the host by assigning `opts.projectRoot` / `opts.session` on
1565
+ // this very object. A spread copy froze both at boot, so every
1566
+ // `session.start` broadcast after a project switch still announced the
1567
+ // previous project's root — the switch looked like it had not happened.
1568
+ get projectRoot() {
1569
+ return opts.projectRoot;
1570
+ },
1571
+ get session() {
1572
+ return opts.session;
1573
+ },
1574
+ get appConfig() {
1575
+ return opts.appConfig;
1576
+ },
1577
+ // `peek`, never `get`: building a payload must not materialise an agent
1578
+ // for a session id that arrived from a stale browser tab.
1579
+ getSessionContext: (sessionId) => getSessionAgents()?.peek(sessionId)?.ctx
1580
+ });
1581
+ }
1582
+
1583
+ // src/webui-server/setup-events.ts
1584
+ import { mailboxSessionTag as mailboxSessionTag2 } from "@wrongstack/core/coordination";
1585
+ import {
1586
+ createEternalSubscription,
1587
+ setupEvents as setupCanonicalEvents
1588
+ } from "@wrongstack/webui-server";
1589
+ function createSetupEvents(deps) {
1590
+ let fleetConcurrency = 0;
1591
+ let fleetConcurrencyMax = 4;
1592
+ const emitConcurrency = () => {
1593
+ const budget = deps.getFleetBudget?.() ?? null;
1594
+ deps.broadcast({
1595
+ type: "fleet.concurrency_update",
1596
+ // Deliberately UNSTAMPED. The spawn/concurrency budget is one ceiling
1597
+ // for the whole process — every tab draws from it and the client keeps
1598
+ // it in a single global store — so this is a project-wide fact, not a
1599
+ // conversation's. Stamping it with the runtime's session made it a
1600
+ // conversation's by accident: `clientWantsSession` then dropped it for
1601
+ // any page whose declared tabs did not include that session, and the
1602
+ // fleet gauges froze the moment the boot tab was closed.
1603
+ payload: {
1604
+ fleetConcurrency: budget?.activeAgents ?? fleetConcurrency,
1605
+ fleetConcurrencyMax: budget?.maxConcurrent ?? fleetConcurrencyMax,
1606
+ ...budget ? {
1607
+ maxSpawns: budget.maxSpawns,
1608
+ usedSpawns: budget.usedSpawns,
1609
+ remainingSpawns: budget.remainingSpawns,
1610
+ maxSpawnsSource: budget.maxSpawnsSource,
1611
+ maxConcurrentSource: budget.maxConcurrentSource,
1612
+ effectiveSource: budget.effectiveSource,
1613
+ checkpointMaxSpawns: budget.checkpointMaxSpawns,
1614
+ ceilingMismatch: budget.ceilingMismatch
1615
+ } : {}
1616
+ }
1617
+ });
1618
+ };
1619
+ return function setupEvents() {
1620
+ for (const unsubscribe of deps.eventUnsubscribers) unsubscribe();
1621
+ deps.eventUnsubscribers.length = 0;
1622
+ const maxIterations = deps.agent.ctx?.meta?.["maxIterations"];
1623
+ const disposeCanonical = setupCanonicalEvents({
1624
+ events: deps.events,
1625
+ // The third argument is not decoration: it names the tab that OWNS a
1626
+ // subagent, and the frames that pass it carry the subagent's own
1627
+ // session on the payload. Dropping it here routed every subagent
1628
+ // codemap frame at a session no tab subscribes to.
1629
+ broadcast: (_clients, message, targetSessionId) => targetSessionId !== void 0 ? deps.broadcast(message, targetSessionId) : deps.broadcast(message),
1630
+ clients: deps.getClients(),
1631
+ config: {
1632
+ tools: {
1633
+ ...typeof maxIterations === "number" ? { maxIterations } : {}
1634
+ }
1635
+ },
1636
+ context: deps.agent.ctx,
1637
+ ...deps.sessionContext ? { sessionContext: deps.sessionContext } : {},
1638
+ pendingConfirms: deps.pendingConfirms,
1639
+ globalConfigPath: deps.globalConfigPath,
1640
+ onFleetBroadcaster: deps.onFleetBroadcaster,
1641
+ projection: {
1642
+ scrubObject: (value) => deps.secretScrubber.scrubObject(value),
1643
+ queueTextDelta: deps.queueTextDelta,
1644
+ queueThinkingDelta: deps.queueThinkingDelta,
1645
+ queueToolProgress: (payload) => deps.queueToolProgress(payload),
1646
+ flushThinkingDelta: deps.flushThinkingDelta,
1647
+ flushAllStreamBuffers: deps.flushAllStreamBuffers
1648
+ }
1649
+ });
1650
+ deps.eventUnsubscribers.push(disposeCanonical);
1651
+ deps.broadcast({
1652
+ type: "subagent.event",
1653
+ payload: deps.sessionPayload({
1654
+ kind: "leader_updated",
1655
+ subagentId: `leader@${mailboxSessionTag2(deps.currentSessionId())}`,
1656
+ isLeader: true,
1657
+ name: "Leader",
1658
+ status: "running"
1659
+ })
1660
+ });
1661
+ emitConcurrency();
1662
+ deps.eventUnsubscribers.push(
1663
+ deps.events.on("concurrency.changed", (event) => {
1664
+ fleetConcurrencyMax = Math.max(1, event.n);
1665
+ emitConcurrency();
1666
+ }),
1667
+ deps.events.on("subagent.spawned", () => {
1668
+ fleetConcurrency += 1;
1669
+ emitConcurrency();
1670
+ }),
1671
+ deps.events.on("subagent.task_completed", () => {
1672
+ fleetConcurrency = Math.max(0, fleetConcurrency - 1);
1673
+ emitConcurrency();
1674
+ })
1675
+ );
1676
+ if (deps.subscribeEternalIteration) {
1677
+ const subscription = createEternalSubscription(
1678
+ deps.subscribeEternalIteration,
1679
+ (_clients, message) => deps.broadcast(message),
1680
+ deps.getClients
1681
+ );
1682
+ deps.eventUnsubscribers.push(() => subscription.dispose());
1683
+ }
1684
+ };
1685
+ }
1686
+
1687
+ // src/webui-server/stream-coalescer.ts
1688
+ import {
1689
+ createStreamCoalescer
1690
+ } from "@wrongstack/webui-server";
1691
+
1692
+ // src/webui-server/transport.ts
1693
+ import {
1694
+ clientWantsSession,
1695
+ sendSerialized,
1696
+ stampDispatchSession,
1697
+ webuiSessionFrameLog
1698
+ } from "@wrongstack/webui-server";
1699
+ function createWebuiTransport(clients) {
1700
+ function send(ws, msg) {
1701
+ sendSerialized(ws, JSON.stringify(stampDispatchSession(msg)));
1702
+ }
1703
+ function broadcast(msg, targetSessionId) {
1704
+ const payload = msg.payload;
1705
+ const sessionId = targetSessionId ?? (payload && typeof payload === "object" && typeof payload.sessionId === "string" ? payload.sessionId : void 0);
1706
+ const data = sessionId ? webuiSessionFrameLog().sequence(sessionId, msg) : JSON.stringify(msg);
1707
+ for (const [ws, client] of clients) {
1708
+ if (clientWantsSession(client, sessionId)) sendSerialized(ws, data);
1709
+ }
1710
+ }
1711
+ function broadcastEveryone(msg) {
1712
+ const data = JSON.stringify(msg);
1713
+ for (const [ws] of clients) sendSerialized(ws, data);
1714
+ }
1715
+ function sendResult(ws, success, message) {
1716
+ send(ws, { type: "key.operation_result", payload: { success, message } });
1717
+ }
1718
+ return { send, broadcast, broadcastEveryone, sendResult };
1719
+ }
1720
+
1721
+ // src/webui-server-lifecycle.ts
1722
+ import { toErrorMessage } from "@wrongstack/core/utils/error";
1723
+
1724
+ // src/webui-server/connection-handler.ts
1725
+ import { deriveSessionAgents } from "@wrongstack/core/storage";
1726
+ import { buildReplayPayload, decodeProtocolFrame } from "@wrongstack/webui-protocol";
1727
+ import {
1728
+ createConnectionLifecycle,
1729
+ verifyClient as verifyWsClient2
1730
+ } from "@wrongstack/webui-server";
1731
+ function createConnectionHandler(deps) {
1732
+ const clientHandlers = [
1733
+ deps.goalHandler,
1734
+ deps.specsHandler,
1735
+ deps.sddBoardHandler,
1736
+ ...deps.sddWizardHandler ? [deps.sddWizardHandler] : [],
1737
+ deps.worktreeHandler,
1738
+ deps.terminalHandler
1739
+ ];
1740
+ return createConnectionLifecycle({
1741
+ clients: deps.clients,
1742
+ pendingConfirms: deps.pendingConfirms,
1743
+ // ── Auth trust model ────────────────────────────────────────────────
1744
+ // Three layered defenses (see verifyClient in @wrongstack/webui-server/ws-auth):
1745
+ // 1. DNS-rebinding guard (Host header must be loopback on loopback bind)
1746
+ // 2. Shared-token auth via Cookie (ws_token) or URL query param (?token=)
1747
+ // 3. Loopback bootstrap: when requireToken=false (default) AND the server
1748
+ // is bound to a loopback interface, browser clients bypass token auth
1749
+ // entirely — the Origin header being http://localhost is sufficient.
1750
+ //
1751
+ // COOKIE PERSISTENCE AFTER TOKEN ROTATION:
1752
+ // The HttpOnly ws_token cookie, once set by /ws-auth, survives a process
1753
+ // restart. On the next process the expectedToken is a fresh random value,
1754
+ // so the old cookie alone would fail the token check. However, on a
1755
+ // loopback bind with requireToken=false (default), the loopback-bootstrap
1756
+ // path at layer 3 accepts the connection before the cookie is ever
1757
+ // compared — the old cookie is harmless because no token is needed.
1758
+ //
1759
+ // This means a token rotation (e.g. the operator sets a new accessToken
1760
+ // in config) does NOT invalidate already-distributed cookies for loopback
1761
+ // clients. The mitigating factors:
1762
+ // - Loopback bind + requireToken=false already trusts any local process
1763
+ // that can reach 127.0.0.1 — a trivial perimeter.
1764
+ // - A LAN/0.0.0.0 bind with requireToken=true DOES enforce cookie
1765
+ // matching against the new expectedToken on every reconnect.
1766
+ // If stronger isolation is needed, set requireToken=true or rebind the
1767
+ // server to a non-loopback interface with token enforcement.
1768
+ authenticate: (ws, req) => {
1769
+ const allowed = verifyWsClient2({
1770
+ origin: req.headers.origin,
1771
+ url: req.url ?? "/",
1772
+ hostHeader: req.headers.host,
1773
+ remoteAddress: req.socket.remoteAddress,
1774
+ cookieHeader: req.headers.cookie,
1775
+ wsHost: deps.host,
1776
+ expectedToken: deps.wsToken,
1777
+ requireToken: deps.requireToken,
1778
+ allowedHostnames: deps.publicHostnames,
1779
+ allowBrowserUrlToken: Boolean(deps.publicWsUrl)
1780
+ });
1781
+ if (!allowed) ws.close(4003, "Forbidden");
1782
+ return allowed;
1783
+ },
1784
+ createClient: (ws) => ({ ws, sessionId: deps.currentSessionId() }),
1785
+ // Enrich parse-failure / unknown-type logs with per-connection identity
1786
+ // so operators can identify which client sent a malformed frame. The
1787
+ // `connectionId`/`sessionId` fields are read from the `client` argument
1788
+ // directly inside `logRejection`; the CLI's `ConnectedClient` only has
1789
+ // `sessionId` (no per-socket `connId`), so the verbose payload carries
1790
+ // `sessionId` only. Server-wide identity (`agentId`/`projectRoot`) isn't
1791
+ // in scope at this handler and is omitted.
1792
+ verboseWsLogging: true,
1793
+ registerClient: (ws) => {
1794
+ for (const handler of clientHandlers) handler.addClient(ws);
1795
+ },
1796
+ decode: (raw) => decodeProtocolFrame(raw, "client"),
1797
+ dispatch: deps.handleMessage,
1798
+ send: deps.send,
1799
+ sessionPayload: deps.sessionPayload,
1800
+ rateLimitMax: deps.rateLimitMax,
1801
+ // NOTE — a closed SOCKET is not a stopped run.
1802
+ //
1803
+ // This used to `abortControllers.delete(client.sessionId)` — a delete
1804
+ // WITHOUT an abort, and only for the tab this connection last acted on.
1805
+ // Both halves were wrong. The run kept going while `isRunActive` reported
1806
+ // `false`, so `session.delete` would happily unlink the journal a live run
1807
+ // was still appending to, and the next `user_message` claimed a second
1808
+ // lock for the same session and hit `Agent.run() is already in progress`.
1809
+ // The other three declared tabs were never touched at all.
1810
+ //
1811
+ // Doing nothing is the correct behaviour: a background run outlives the
1812
+ // tab that started it (the same invariant `retireUndisplayedSessions`
1813
+ // honours), and the run's own `finally` releases the lock when it unwinds.
1814
+ // A page that comes back is told what is still running by
1815
+ // `session.run_state`; a browser that never comes back drains its
1816
+ // unanswerable permission prompts through the last-client drain in
1817
+ // `connection-lifecycle.ts`, which lets a wedged run finish and release.
1818
+ //
1819
+ // So there is deliberately NO `onClose` here.
1820
+ buildInitialPayload: async () => {
1821
+ const payload = { ...await deps.buildSessionStartPayload({}, deps.needsSetup) };
1822
+ try {
1823
+ const open = deps.openSessionIds?.();
1824
+ if (open) payload["openSessionIds"] = [.../* @__PURE__ */ new Set([...open, deps.currentSessionId()])];
1825
+ } catch {
1826
+ console.debug("[WebUI] Failed to list open sessions");
1827
+ }
1828
+ let replayEvents;
1829
+ try {
1830
+ const replay = await deps.loadReplay?.();
1831
+ if (replay) {
1832
+ Object.assign(payload, buildReplayPayload(replay));
1833
+ replayEvents = replay.events;
1834
+ }
1835
+ } catch {
1836
+ console.debug("[WebUI] Failed to load replay");
1837
+ }
1838
+ try {
1839
+ const roster = deriveSessionAgents(replayEvents ?? []);
1840
+ const sessions = roster.length > 0 ? await deps.loadAgentSessions?.(roster.map((agent) => agent.agentId)) : [];
1841
+ if (sessions?.length) payload["agentSessions"] = sessions;
1842
+ } catch {
1843
+ console.debug("[WebUI] Failed to load agent sessions");
1844
+ }
1845
+ return payload;
1846
+ }
1847
+ });
1848
+ }
1849
+
1850
+ // src/webui-server-lifecycle.ts
1851
+ function runWebuiServerLifecycle({
1852
+ host,
1853
+ httpPort,
1854
+ setupEvents,
1855
+ opts,
1856
+ wsPort,
1857
+ accessUrl,
1858
+ wsToken,
1859
+ webuiInstanceRegistered,
1860
+ wss,
1861
+ httpServer,
1862
+ requireToken,
1863
+ publicHostnames,
1864
+ publicWsUrl,
1865
+ clients,
1866
+ currentSessionId,
1867
+ goalHandler,
1868
+ specsHandler,
1869
+ sddBoardHandler,
1870
+ sddWizardHandler,
1871
+ worktreeHandler,
1872
+ terminalHandler,
1873
+ rateLimitMax,
1874
+ send,
1875
+ sessionPayload,
1876
+ handleMessage,
1877
+ pendingConfirms,
1878
+ buildSessionStartPayload,
1879
+ sessionAgents,
1880
+ lifecycleState,
1881
+ abortControllers,
1882
+ flushAllStreamBuffers,
1883
+ eventUnsubscribers,
1884
+ releaseSessionSalvage,
1885
+ autoWakeHost,
1886
+ stopEmptySessionCleanup,
1887
+ credentialWatcherClose,
1888
+ kanbanRunMirror,
1889
+ kanbanSupervisor,
1890
+ stopKanbanSupervisorMemoryStats,
1891
+ unregisterWebuiClient,
1892
+ ipv6LoopbackServer,
1893
+ registryBaseDir,
1894
+ stopLiveStatusLogger,
1895
+ terminalLogView
1896
+ }) {
1897
+ const stopped = new Promise((resolve) => {
1898
+ let listeningAnnounced = false;
1899
+ const announceListening = () => {
1900
+ if (listeningAnnounced) return;
1901
+ listeningAnnounced = true;
1902
+ console.log(`[WebUI] WebSocket server running on ws://${host}:${httpPort}`);
1903
+ try {
1904
+ setupEvents();
1905
+ opts.onListening?.({
1906
+ httpPort,
1907
+ wsPort,
1908
+ host,
1909
+ url: accessUrl,
1910
+ authToken: wsToken,
1911
+ webuiInstanceRegistered
1912
+ });
1913
+ } catch (err) {
1914
+ consoleLogger.error("setup_events_failed", { message: toErrorMessage(err) });
1915
+ }
1916
+ };
1917
+ wss.on("listening", announceListening);
1918
+ if (httpServer?.server.listening || wss.address()) queueMicrotask(announceListening);
1919
+ wss.on(
1920
+ "connection",
1921
+ createConnectionHandler({
1922
+ host,
1923
+ wsToken,
1924
+ requireToken,
1925
+ publicHostnames,
1926
+ publicWsUrl,
1927
+ clients,
1928
+ currentSessionId,
1929
+ goalHandler,
1930
+ specsHandler,
1931
+ sddBoardHandler,
1932
+ sddWizardHandler,
1933
+ worktreeHandler,
1934
+ terminalHandler,
1935
+ rateLimitMax,
1936
+ send,
1937
+ sessionPayload,
1938
+ handleMessage,
1939
+ pendingConfirms,
1940
+ buildSessionStartPayload,
1941
+ loadReplay: async () => {
1942
+ const activeSession = opts.agent.ctx.session ?? opts.session;
1943
+ await activeSession.flush();
1944
+ if (opts.sessionStore) {
1945
+ const data = await opts.sessionStore.load(activeSession.id);
1946
+ return { messages: data.messages, events: data.events, usage: data.usage };
1947
+ }
1948
+ const usage = opts.agent.ctx.tokenCounter.total();
1949
+ return { messages: opts.agent.ctx.messages, usage };
1950
+ },
1951
+ loadAgentSessions: async (subagentIds) => await opts.agentTranscripts?.loadSessionsFromDisk(subagentIds) ?? [],
1952
+ // What this process is actually holding right now. The browser
1953
+ // reconciles its persisted tab strip against it, so a restarted server
1954
+ // is not dressed in the previous run's tabs.
1955
+ openSessionIds: () => sessionAgents.ids(),
1956
+ needsSetup: opts.needsSetup ?? false
1957
+ })
1958
+ );
1959
+ lifecycleState.signalShutdown = createWebuiShutdown({
1960
+ abortInFlight: () => {
1961
+ void lifecycleState.embeddedAutoHealDispose?.();
1962
+ for (const c of abortControllers.values()) c.abort();
1963
+ abortControllers.clear();
1964
+ },
1965
+ unsubscribeEvents: () => {
1966
+ flushAllStreamBuffers();
1967
+ for (const unsub of eventUnsubscribers) unsub();
1968
+ },
1969
+ stopOwnedChildren: async () => {
1970
+ try {
1971
+ const { getProcessRegistry } = await import("@wrongstack/tools");
1972
+ getProcessRegistry().killAll({ force: true, includeProtected: true });
1973
+ } catch (err) {
1974
+ console.debug(
1975
+ `[webui-server] process-registry killAll failed: ${err instanceof Error ? err.message : String(err)}`
1976
+ );
1977
+ }
1978
+ if (opts.mcpRegistry) {
1979
+ try {
1980
+ await opts.mcpRegistry.stopAll();
1981
+ } catch (err) {
1982
+ console.debug(
1983
+ `[webui-server] mcpRegistry.stopAll failed: ${err instanceof Error ? err.message : String(err)}`
1984
+ );
1985
+ }
1986
+ }
1987
+ },
1988
+ disposeResources: async () => {
1989
+ releaseSessionSalvage();
1990
+ autoWakeHost?.dispose();
1991
+ await stopEmptySessionCleanup?.dispose();
1992
+ await sessionAgents.closeAll().catch(() => void 0);
1993
+ credentialWatcherClose?.();
1994
+ credentialWatcherClose = void 0;
1995
+ goalHandler.dispose();
1996
+ sddBoardHandler.dispose();
1997
+ worktreeHandler.dispose();
1998
+ terminalHandler.dispose();
1999
+ kanbanRunMirror?.dispose();
2000
+ kanbanSupervisor?.dispose();
2001
+ void stopKanbanSupervisorMemoryStats?.();
2002
+ await lifecycleState.embeddedAutoHealDispose?.();
2003
+ lifecycleState.embeddedAutoHealDispose = null;
2004
+ unregisterWebuiClient();
2005
+ },
2006
+ closeClients: () => {
2007
+ for (const [ws] of clients) ws.close();
2008
+ clients.clear();
2009
+ },
2010
+ closeHttpServer: () => {
2011
+ ipv6LoopbackServer?.close();
2012
+ httpServer?.server.close();
2013
+ },
2014
+ wss,
2015
+ pid: process.pid,
2016
+ registryBaseDir,
2017
+ onStopped: () => {
2018
+ stopLiveStatusLogger();
2019
+ const muted = terminalLogView.mutedCount;
2020
+ terminalLogView.stop();
2021
+ if (muted > 0) {
2022
+ console.log(
2023
+ `[WebUI] ${muted} progress line(s) kept out of this terminal \u2014 set WEBUI_LOGS=1 to stream them.`
2024
+ );
2025
+ }
2026
+ opts.onExit?.();
2027
+ resolve();
2028
+ }
2029
+ });
2030
+ registerWebuiSignalHandlers(lifecycleState.signalShutdown);
2031
+ });
2032
+ return { stopped };
1613
2033
  }
1614
2034
 
1615
- // src/webui-server/setup-events.ts
1616
- import { mailboxSessionTag as mailboxSessionTag2 } from "@wrongstack/core/coordination";
2035
+ // src/webui-session-maintenance.ts
1617
2036
  import {
1618
- createEternalSubscription,
1619
- setupEvents as setupCanonicalEvents
2037
+ collectDisplayedSessionIds,
2038
+ scheduleOwnerlessEmptySessionCleanup,
2039
+ toSessionHistoryEntries
1620
2040
  } 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
- }
2041
+ function setupWebuiSessionMaintenance({
2042
+ opts,
2043
+ broadcastEveryone,
2044
+ getForegroundSession,
2045
+ clients,
2046
+ abortControllers
2047
+ }) {
2048
+ const refreshSessionHistory = async () => {
2049
+ const list = await opts.sessionStore?.list(200);
2050
+ if (!list) return;
2051
+ broadcastEveryone({
2052
+ type: "sessions.list",
2053
+ payload: { sessions: toSessionHistoryEntries(list, getForegroundSession().id) }
1649
2054
  });
1650
2055
  };
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());
2056
+ const emptySessionCleanup = opts.sessionStore ? scheduleOwnerlessEmptySessionCleanup({
2057
+ getSessionStore: () => opts.sessionStore,
2058
+ getActiveSessionId: () => getForegroundSession().id,
2059
+ getActiveSessionIds: () => collectDisplayedSessionIds({ getSession: getForegroundSession, clients }),
2060
+ // A run outlives the tab that started it, and a turn that has not
2061
+ // written its first record yet looks exactly like a session nobody
2062
+ // ever used.
2063
+ hasParticipants: (sessionId) => abortControllers.has(sessionId),
2064
+ refreshSessions: refreshSessionHistory,
2065
+ logger: consoleLogger
2066
+ }) : null;
2067
+ const offSessionRenamed = opts.sessionStore ? opts.events.on("session.renamed", () => {
2068
+ void refreshSessionHistory().catch(() => void 0);
2069
+ }) : void 0;
2070
+ const stopEmptySessionCleanup = emptySessionCleanup && {
2071
+ runNow: emptySessionCleanup.runNow,
2072
+ dispose: async () => {
2073
+ offSessionRenamed?.();
2074
+ await emptySessionCleanup.dispose();
1715
2075
  }
1716
2076
  };
2077
+ return { stopEmptySessionCleanup };
1717
2078
  }
1718
2079
 
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";
2080
+ // src/webui-session-retirement.ts
2081
+ import { resolvePendingConfirmsForSession } from "@wrongstack/webui-server";
2082
+ function createWebuiSessionRetirement(inputs) {
2083
+ const { opts, getForegroundSession, pendingConfirms, abortControllers, sessionAgents } = inputs;
2084
+ const retireUndisplayedSessions = (sessionIds) => {
2085
+ for (const sessionId of sessionIds) {
2086
+ if (!sessionId || sessionId === opts.session.id) continue;
2087
+ if (sessionId === getForegroundSession().id) continue;
2088
+ const orphaned = resolvePendingConfirmsForSession(pendingConfirms, sessionId);
2089
+ if (orphaned > 0) {
2090
+ console.log(
2091
+ JSON.stringify({
2092
+ level: "warn",
2093
+ event: "webui.confirm_orphaned_by_tab_close",
2094
+ sessionId,
2095
+ count: orphaned,
2096
+ message: `Denied ${orphaned} unanswerable permission prompt(s) for closed session ${sessionId}.`
2097
+ })
2098
+ );
2099
+ }
2100
+ if (abortControllers.has(sessionId)) continue;
2101
+ opts.onSessionRetired?.(sessionId);
2102
+ const agent = sessionAgents.peek(sessionId);
2103
+ if (!agent || agent === opts.agent) continue;
2104
+ void sessionAgents.endAndClose(sessionId);
2105
+ }
2106
+ };
2107
+ return { retireUndisplayedSessions };
2108
+ }
1730
2109
 
1731
2110
  // src/webui-server.ts
1732
2111
  async function runWebUI(opts) {
1733
2112
  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;
2113
+ const {
2114
+ host,
2115
+ publicUrl,
2116
+ publicWsUrl,
2117
+ requireToken,
2118
+ surface,
2119
+ requestedHttpPort,
2120
+ strictPort,
2121
+ globalRoot,
2122
+ profileConfigPath,
2123
+ profileDir,
2124
+ rateLimitMax,
2125
+ publicHostnames
2126
+ } = resolveWebuiHostSettings(opts);
1741
2127
  const terminalLogView = startTerminalDashboard({
1742
2128
  title: surface === "simpleui" ? "SimpleUI" : "WebUI",
1743
- quiet: !envFlag("WEBUI_LOGS"),
2129
+ quiet: !envFlag2("WEBUI_LOGS"),
1744
2130
  // The dashboard is operator-owned terminal output, so retain the full
1745
2131
  // authenticated access URL here even though startup logs remain redacted.
1746
2132
  getUrl: () => accessUrl
1747
2133
  });
1748
- const strictPort = opts.strictPort ?? isStrictPort();
1749
2134
  let httpPort = requestedHttpPort;
1750
2135
  if (!strictPort) {
1751
2136
  httpPort = await findFreePort(host, requestedHttpPort);
1752
2137
  }
1753
2138
  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
2139
  const clients = /* @__PURE__ */ new Map();
2140
+ const { send, broadcast, broadcastEveryone, sendResult } = createWebuiTransport(clients);
1760
2141
  const pendingConfirms = /* @__PURE__ */ new Map();
1761
2142
  const secretScrubber = new DefaultSecretScrubber();
1762
2143
  const abortControllers = /* @__PURE__ */ new Map();
1763
- const profileDir = path7.dirname(profileConfigPath);
1764
2144
  let customModeStoreP = null;
1765
2145
  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;
2146
+ customModeStoreP ??= (async () => {
2147
+ const store = createCustomModeStore(profileDir);
2148
+ await store.load();
2149
+ return store;
2150
+ })();
2151
+ return customModeStoreP;
2152
+ };
2153
+ const { kanbanRunMirror, kanbanSupervisor, stopKanbanSupervisorMemoryStats } = createWebuiKanbanServices(opts, broadcast);
2154
+ const domainHandlers = createWebuiDomainHandlers(opts, trustBoundary, kanbanRunMirror);
1796
2155
  const {
1797
2156
  goalHandler,
1798
2157
  worktreeHandler,
@@ -1800,7 +2159,7 @@ async function runWebUI(opts) {
1800
2159
  specsHandler,
1801
2160
  sddBoardHandler,
1802
2161
  sddWizardHandler
1803
- } = createWebuiDomainHandlers(opts, trustBoundary, kanbanRunMirror);
2162
+ } = domainHandlers;
1804
2163
  await seedConfigToMeta(opts);
1805
2164
  if (typeof opts.agent.ctx?.meta?.["yolo"] === "boolean") {
1806
2165
  opts.onYoloSwitch?.(opts.agent.ctx.meta["yolo"]);
@@ -1808,37 +2167,13 @@ async function runWebUI(opts) {
1808
2167
  const { prefSnapshot, persistPrefs } = createPrefsSeeding(opts);
1809
2168
  const sessionStartedAt = Date.now();
1810
2169
  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
- });
2170
+ const buildSessionStartPayload = createLiveSessionStartPayloadBuilder(
2171
+ opts,
2172
+ () => sessionAgentsRef
2173
+ );
1831
2174
  const { register: registerWebuiClient, unregister: unregisterWebuiClient } = createEmbeddedClientRegistration(opts, clients, abortControllers, () => sessionAgentsRef);
1832
2175
  registerWebuiClient();
1833
2176
  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
2177
  let accessUrl = buildWebUIAccessUrl({
1843
2178
  host,
1844
2179
  port: httpPort,
@@ -1846,147 +2181,41 @@ async function runWebUI(opts) {
1846
2181
  publicUrl
1847
2182
  });
1848
2183
  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),
2184
+ const { httpServer, wss, ipv6LoopbackServer } = await startWebuiHttpBridge({
2185
+ opts,
2186
+ surface,
1851
2187
  host,
1852
2188
  httpPort,
2189
+ strictPort,
1853
2190
  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,
2191
+ wsToken,
2192
+ publicUrl,
1873
2193
  publicWsUrl,
1874
- apiToken: wsToken,
1875
2194
  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;
2195
+ getSessionAgents: () => sessionAgentsRef,
2196
+ onFleetPing: () => {
2197
+ void fleetBroadcastCli?.();
1885
2198
  },
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;
2199
+ broadcast,
2200
+ onPortRebound: (port) => {
2201
+ httpPort = port;
2202
+ wsPort = port;
1924
2203
  accessUrl = buildWebUIAccessUrl({ host, port: httpPort, token: wsToken, publicUrl });
1925
2204
  }
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
- }
2205
+ });
1951
2206
  let foregroundSession = opts.agent.ctx.session ?? opts.session;
1952
2207
  const currentSessionId = () => foregroundSession.id;
1953
2208
  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
- }
2209
+ const webuiInstanceRegistered = await registerEmbeddedWebuiInstance({
2210
+ opts,
2211
+ surface,
2212
+ host,
2213
+ httpPort,
2214
+ publicUrl,
2215
+ registryBaseDir,
2216
+ wsToken,
2217
+ currentSessionId
2218
+ });
1990
2219
  const eventUnsubscribers = [];
1991
2220
  const sessionPayload = (payload) => {
1992
2221
  const provided = payload["sessionId"];
@@ -2017,54 +2246,24 @@ async function runWebUI(opts) {
2017
2246
  secretScrubber,
2018
2247
  getClients: () => clients,
2019
2248
  eventUnsubscribers,
2020
- globalConfigPath: path7.join(globalRoot, "config.json"),
2249
+ globalConfigPath: path8.join(globalRoot, "config.json"),
2021
2250
  onFleetBroadcaster: (fn) => {
2022
2251
  fleetBroadcastCli = fn;
2023
2252
  },
2024
2253
  ...opts.getFleetBudget ? { getFleetBudget: opts.getFleetBudget } : {}
2025
2254
  });
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({
2255
+ const { wsHandlerCtx, credentialWatcherClose } = createEmbeddedProviderPlane({
2041
2256
  opts,
2042
2257
  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
- }
2258
+ send,
2259
+ broadcast
2059
2260
  });
2261
+ const { sessionAgents, isSessionDisplayed } = createEmbeddedSessionAgents(
2262
+ opts,
2263
+ clients,
2264
+ abortControllers
2265
+ );
2060
2266
  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
2267
  const autoWakeHost = opts.leaderAutoWake ? createWebuiLeaderAutoWakeHost({
2069
2268
  controller: opts.leaderAutoWake,
2070
2269
  events: opts.events,
@@ -2095,35 +2294,13 @@ async function runWebUI(opts) {
2095
2294
  abortControllers,
2096
2295
  sessionAgents
2097
2296
  });
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
- };
2297
+ const { stopEmptySessionCleanup } = setupWebuiSessionMaintenance({
2298
+ opts,
2299
+ broadcastEveryone,
2300
+ getForegroundSession: () => foregroundSession,
2301
+ clients,
2302
+ abortControllers
2303
+ });
2127
2304
  const routeContexts = createWebuiRouteContexts({
2128
2305
  opts,
2129
2306
  profileConfigPath,
@@ -2163,43 +2340,21 @@ async function runWebUI(opts) {
2163
2340
  let signalShutdown;
2164
2341
  const shutdown = () => signalShutdown?.();
2165
2342
  let embeddedAutoHealDispose = null;
2166
- const handleMessage = createEmbeddedMessageRouter({
2167
- jevVault: getVault(opts.globalConfigPath ?? opts.profileConfigPath),
2343
+ const handleMessage = createCliEmbeddedMessageRouter({
2168
2344
  trustBoundary,
2169
2345
  opts,
2170
- logger: consoleLogger,
2171
2346
  send,
2172
2347
  sendResult,
2173
2348
  sessionPayload,
2174
2349
  currentSessionId,
2175
2350
  shutdown,
2176
- // Auto-heal watchdog disposer — `disposeResources` awaits it (bounded) so
2177
- // an in-flight daemon restart drains before the host exits.
2178
2351
  onDispose: (dispose) => {
2179
2352
  embeddedAutoHealDispose = dispose;
2180
2353
  },
2181
2354
  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
2355
+ routeContexts,
2356
+ domainHandlers,
2357
+ kanbanHostRoutes
2203
2358
  });
2204
2359
  const { stopped } = runWebuiServerLifecycle({
2205
2360
  host,
@@ -2260,27 +2415,9 @@ async function runWebUI(opts) {
2260
2415
  stopLiveStatusLogger,
2261
2416
  terminalLogView
2262
2417
  });
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
2418
  return stopped.finally(() => terminalLogView.stop());
2282
2419
  }
2283
2420
  export {
2284
2421
  runWebUI
2285
2422
  };
2286
- //# sourceMappingURL=webui-server-GQOVLQS3.js.map
2423
+ //# sourceMappingURL=webui-server-CUIFA25W.js.map