@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.
- package/dist/{acp-EDGUWU7N.js → acp-QLU7T5WX.js} +470 -466
- package/dist/{auth-SUHKFFO4.js → auth-AED7RW7D.js} +7 -7
- package/dist/auth-menu/openai-codex-oauth.d.ts +1 -1
- package/dist/auth-menu/panel-provider-edits.d.ts +15 -0
- package/dist/auth-menu/panel-service.d.ts +1 -1
- package/dist/{automation-MCDFCIMV.js → automation-3E3AUPBF.js} +19 -4
- package/dist/boot/governance-evidence-candidate.d.ts +1 -2
- package/dist/boot/system-prompt-builder.d.ts +1 -1
- package/dist/boot/tui-project-spawn.d.ts +0 -1
- package/dist/boot/tui-session-resume-contracts.d.ts +95 -0
- package/dist/boot/tui-session-resume.d.ts +2 -88
- package/dist/boot/tui-settings-read.d.ts +3 -0
- package/dist/{boot-auto-thin-K2DHHYIW.js → boot-auto-thin-AGJ6TAWY.js} +3 -3
- package/dist/boot-launch-choices.d.ts +22 -0
- package/dist/boot-provider-gate.d.ts +28 -0
- package/dist/boot-subcommand-dispatch.d.ts +24 -0
- package/dist/boot.d.ts +1 -1
- package/dist/chimera-cascade-evidence.d.ts +1 -1
- package/dist/{chunk-TI7KLHYX.js → chunk-2CMMWRQH.js} +81 -80
- package/dist/{chunk-4BX2YDTQ.js → chunk-4JMAWKLA.js} +45 -4
- package/dist/{chunk-TWSQTQKR.js → chunk-4R6TVMYA.js} +2 -2
- package/dist/{chunk-HHRYIDOW.js → chunk-ACW64W4M.js} +34 -7
- package/dist/{chunk-5EA6OTLJ.js → chunk-AFOH6CHO.js} +2 -2
- package/dist/{chunk-WHEYHDIE.js → chunk-AKESWZH5.js} +2 -1
- package/dist/{chunk-JHUJUIOH.js → chunk-AT2A7RJZ.js} +4 -16
- package/dist/{chunk-LKFKO4JW.js → chunk-B6EHK772.js} +2 -2
- package/dist/{chunk-U3SS4NRH.js → chunk-BHXAKTBE.js} +1 -1
- package/dist/{chunk-4UWCMBPG.js → chunk-BJH44J7G.js} +3 -3
- package/dist/{chunk-MPQCHBDJ.js → chunk-CJSHS2K7.js} +5 -1
- package/dist/{chunk-RQ4UBKYH.js → chunk-GF7LUWQS.js} +5 -11
- package/dist/{chunk-TXAFIERF.js → chunk-L5NWFP5R.js} +2 -2
- package/dist/{chunk-24C6VVLM.js → chunk-LPGZ3NUF.js} +23 -23
- package/dist/{chunk-UQUYBDMG.js → chunk-M4G5FO7F.js} +1 -1
- package/dist/{chunk-P3GRLV47.js → chunk-NEWXDNF2.js} +212 -145
- package/dist/{chunk-3LEFXLAW.js → chunk-NOZJIN46.js} +36 -37
- package/dist/{chunk-4MDRHMX4.js → chunk-RE73FP2X.js} +4 -27
- package/dist/{chunk-5UZBUV2H.js → chunk-RKU66D2O.js} +6 -4
- package/dist/{chunk-SWOPJHXI.js → chunk-RYTSPBNM.js} +4 -4
- package/dist/{chunk-WTJDSTYD.js → chunk-T5KR6SNO.js} +2 -2
- package/dist/chunk-TGSPCFBT.js +1 -0
- package/dist/{chunk-ZYACRPLN.js → chunk-ULYAI7CP.js} +6 -3
- package/dist/{chunk-HLSHFXTO.js → chunk-WY74MMDB.js} +2 -2
- package/dist/{chunk-PGNERAZL.js → chunk-ZWBI7ORM.js} +3 -3
- package/dist/{cli-context-7HK67B2Z.js → cli-context-I6HJCVCV.js} +1607 -1495
- package/dist/cli-initial-tools.d.ts +54 -0
- package/dist/{cli-main-TQGLKB6D.js → cli-main-X7KD7IOC.js} +38223 -37136
- package/dist/cli-main-boot.d.ts +133 -0
- package/dist/cli-main-orchestration.d.ts +170 -0
- package/dist/cli-main-state.d.ts +19 -0
- package/dist/config-doctor.d.ts +1 -1
- package/dist/{config-transfer-UE37X4B5.js → config-transfer-YPD7HYXH.js} +7 -5
- package/dist/{execution-C2IECWF7.js → execution-M7D4KNBB.js} +171 -132
- package/dist/fleet/host-director-setup.d.ts +41 -0
- package/dist/fleet/host-spawn-types.d.ts +17 -0
- package/dist/fleet/host.d.ts +2 -1
- package/dist/goal-host-prompts.d.ts +5 -0
- package/dist/goal-host-types.d.ts +94 -0
- package/dist/goal-host.d.ts +2 -91
- package/dist/{hq-KBFDKTDD.js → hq-BBLGEEUH.js} +5 -6
- package/dist/hq-server/handle-types.d.ts +1 -1
- package/dist/hq-server/routes/auth-handlers.d.ts +1 -1
- package/dist/hq-server/routes.d.ts +1 -13
- package/dist/hq-server/utils.d.ts +1 -1
- package/dist/{hq-server-AVNKZ2PV.js → hq-server-5TR6NBGS.js} +3 -3
- package/dist/{hq-service-FGB7BY2D.js → hq-service-4Q64WFNT.js} +3 -3
- package/dist/{import-claude-code-AYG24RG2.js → import-claude-code-GHIS2F7Y.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/live-settings-input.d.ts +2 -0
- package/dist/mailbox-bridge-bootstrap.d.ts +1 -4
- package/dist/{mailbox-serve-S536N66F.js → mailbox-serve-DRWJVMYU.js} +2 -2
- package/dist/{modeldiag-NEPTLFZW.js → modeldiag-F3LUF4VJ.js} +3 -3
- package/dist/picker-effort.d.ts +32 -0
- package/dist/picker-model-picker.d.ts +13 -2
- package/dist/picker-provider-list.d.ts +66 -0
- package/dist/picker.d.ts +12 -12
- package/dist/plugin-config.d.ts +6 -0
- package/dist/{plugin-usage-3TZGROK7.js → plugin-usage-EM752WHG.js} +3 -3
- package/dist/{plugins-ITH4SLO6.js → plugins-U6HQFJ7N.js} +4 -4
- package/dist/{providers-models-O75MKLN2.js → providers-models-S3S6VXME.js} +249 -239
- package/dist/repl-suggestions.d.ts +0 -5
- package/dist/{sage-FFKUOCQL.js → sage-BMRDJCDI.js} +9 -5
- package/dist/{sage-sync-ZMTHB477.js → sage-sync-PCWCNEUK.js} +2 -2
- package/dist/{sdd-runtime-PTOSPYI4.js → sdd-runtime-PF72WHLO.js} +3 -20
- package/dist/services/sdd/task-manager.d.ts +1 -12
- package/dist/services/sdd-runtime.d.ts +3 -4
- package/dist/services/statusline-config.d.ts +0 -2
- package/dist/{sessions-config-VE26SB56.js → sessions-config-RQRQDW5Q.js} +2 -2
- package/dist/settings-behavior-sections.d.ts +0 -1
- package/dist/short-circuit-flags-IEV3FEOT.js +10 -0
- package/dist/{skill-suggest-L2EVWB6R.js → skill-suggest-THANK3LV.js} +1 -1
- package/dist/slash-commands/command-context.d.ts +2 -0
- package/dist/slash-commands/doctor.d.ts +0 -9
- package/dist/slash-commands/fallback-chain-view.d.ts +3 -0
- package/dist/slash-commands/fallback-ref-validation.d.ts +34 -0
- package/dist/slash-commands/goal-refiner.d.ts +10 -2
- package/dist/slash-commands/goals.d.ts +6 -0
- package/dist/slash-commands/kanban-task-completion.d.ts +17 -0
- package/dist/slash-commands/mcp.d.ts +0 -1
- package/dist/slash-commands/memory-purge-domain-terms.d.ts +5 -0
- package/dist/slash-commands/nextsteps-mode.d.ts +13 -0
- package/dist/slash-commands/session-live-status.d.ts +21 -0
- package/dist/slash-commands/session.d.ts +1 -1
- package/dist/slash-commands/settings-feature-ui-mutations.d.ts +8 -0
- package/dist/slash-commands/settings-hq-autonomy-mutations.d.ts +8 -0
- package/dist/slash-commands/settings-persist-deps.d.ts +10 -0
- package/dist/slash-commands/settings-runtime-mutations.d.ts +12 -0
- package/dist/slash-commands/shadow.d.ts +0 -1
- package/dist/slash-commands/tool-auto-thin-commands.d.ts +15 -0
- package/dist/{standalone-auto-update-VQC4GYCM.js → standalone-auto-update-QUXOZ4B5.js} +13 -2
- package/dist/subcommands/handlers/acp-agents.d.ts +9 -0
- package/dist/subcommands/handlers/acp-server.d.ts +7 -0
- package/dist/subcommands/handlers/hq.d.ts +0 -1
- package/dist/subcommands/handlers/providers-models-custom.d.ts +33 -0
- package/dist/subcommands/handlers/providers-models-visibility.d.ts +29 -0
- package/dist/subcommands/handlers/skill-suggest.d.ts +0 -2
- package/dist/{subcommands-CLI6QTLU.js → subcommands-75IB23LS.js} +2 -2
- package/dist/{typesafe-NHC322W5.js → typesafe-KW2XHOG3.js} +2 -2
- package/dist/{update-J5WHAFPE.js → update-LHCWAWEZ.js} +3 -3
- package/dist/utils/win32-cmd.d.ts +1 -1
- package/dist/{version-help-ATOKGMTK.js → version-help-JQGRPDJW.js} +2 -2
- package/dist/webui-server/credential-watcher.d.ts +3 -1
- package/dist/webui-server/host-settings.d.ts +17 -0
- package/dist/webui-server/http-bridge.d.ts +32 -0
- package/dist/webui-server/instance-registration.d.ts +19 -0
- package/dist/webui-server/kanban-services.d.ts +9 -0
- package/dist/webui-server/message-router.d.ts +14 -0
- package/dist/webui-server/prefs-seeding.d.ts +1 -2
- package/dist/webui-server/provider-config.d.ts +0 -1
- package/dist/webui-server/provider-plane.d.ts +14 -0
- package/dist/webui-server/session-agents.d.ts +18 -0
- package/dist/webui-server/session-start-live.d.ts +11 -0
- package/dist/webui-server/stream-coalescer.d.ts +1 -1
- package/dist/webui-server/transport.d.ts +12 -0
- package/dist/{webui-server-GQOVLQS3.js → webui-server-CUIFA25W.js} +1472 -1335
- package/dist/webui-session-maintenance.d.ts +16 -0
- package/dist/wiring/brain-and-orchestration.d.ts +1 -1
- package/dist/wiring/cli-slash-commands-setup.d.ts +1 -0
- package/dist/wiring/command-host-state.d.ts +11 -2
- package/dist/wiring/dep-watcher-bridge.d.ts +8 -1
- package/dist/wiring/management-tools.d.ts +9 -1
- package/dist/wiring/session-command-handlers.d.ts +2 -1
- package/dist/worktree-command-host.d.ts +31 -0
- package/package.json +31 -31
- package/dist/chunk-WHOIR577.js +0 -1
- 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-
|
|
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-
|
|
21
|
+
} from "./chunk-BJH44J7G.js";
|
|
22
22
|
import {
|
|
23
23
|
CLI_VERSION
|
|
24
24
|
} from "./chunk-F5DCFAKI.js";
|
|
25
25
|
|
|
26
|
-
// src/webui-server
|
|
27
|
-
import
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
34
|
-
|
|
31
|
+
buildWebUIAccessUrl,
|
|
32
|
+
createCustomModeStore,
|
|
33
|
+
createWebuiLeaderAutoWakeHost,
|
|
34
|
+
envFlag as envFlag2,
|
|
35
|
+
findFreePort,
|
|
36
|
+
resolveAuthToken,
|
|
37
|
+
startTerminalDashboard
|
|
35
38
|
} from "@wrongstack/webui-server";
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
409
|
-
import
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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/
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
508
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
568
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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 :
|
|
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 ??
|
|
863
|
-
specsPaths?.projectTaskGraphs ??
|
|
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 ??
|
|
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/
|
|
907
|
-
import
|
|
908
|
-
import {
|
|
909
|
-
import {
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
await result.dispose?.();
|
|
379
|
+
return new URL(value).hostname;
|
|
380
|
+
} catch {
|
|
381
|
+
return void 0;
|
|
930
382
|
}
|
|
931
|
-
}
|
|
383
|
+
}).filter((value) => Boolean(value));
|
|
932
384
|
return {
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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/
|
|
1077
|
-
import
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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
|
|
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 ?
|
|
1203
|
-
const vault = new DefaultSecretVault2({ keyFile:
|
|
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
|
|
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:
|
|
1321
|
-
projectSkillsDir: skillsPaths?.inProjectSkills ??
|
|
1322
|
-
globalSkillsDir: skillsPaths?.globalSkills ??
|
|
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 ??
|
|
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 ?
|
|
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 ?
|
|
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
|
|
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 ?
|
|
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-
|
|
1616
|
-
import { mailboxSessionTag as mailboxSessionTag2 } from "@wrongstack/core/coordination";
|
|
2035
|
+
// src/webui-session-maintenance.ts
|
|
1617
2036
|
import {
|
|
1618
|
-
|
|
1619
|
-
|
|
2037
|
+
collectDisplayedSessionIds,
|
|
2038
|
+
scheduleOwnerlessEmptySessionCleanup,
|
|
2039
|
+
toSessionHistoryEntries
|
|
1620
2040
|
} from "@wrongstack/webui-server";
|
|
1621
|
-
function
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
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
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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-
|
|
1720
|
-
import {
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
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
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
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: !
|
|
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
|
|
1774
|
-
|
|
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
|
-
} =
|
|
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 =
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
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
|
|
1850
|
-
|
|
2184
|
+
const { httpServer, wss, ipv6LoopbackServer } = await startWebuiHttpBridge({
|
|
2185
|
+
opts,
|
|
2186
|
+
surface,
|
|
1851
2187
|
host,
|
|
1852
2188
|
httpPort,
|
|
2189
|
+
strictPort,
|
|
1853
2190
|
globalRoot,
|
|
1854
|
-
|
|
1855
|
-
|
|
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
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
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:
|
|
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
|
-
|
|
2044
|
-
|
|
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
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
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 =
|
|
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
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
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-
|
|
2423
|
+
//# sourceMappingURL=webui-server-CUIFA25W.js.map
|