@worca/app 1.2.0-rc.1 → 1.2.0-rc.2

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/README.md CHANGED
@@ -177,7 +177,7 @@ Requirements:
177
177
  ### Web UI
178
178
 
179
179
  ```bash
180
- worca --ui
180
+ worca ui # start it (worca --ui does the same)
181
181
  ```
182
182
 
183
183
  Open the printed URL (default `http://localhost:4317`), add a project, and
@@ -186,6 +186,20 @@ a task from a plugin source like GitHub Issues), pick a workflow and
186
186
  guardrails, and run. Answer clarify questions and loop gates as they come —
187
187
  in the browser or from chat.
188
188
 
189
+ The UI is one process per machine. Starting it while it is already up is not
190
+ an error — Worca prints the URL and how to restart it, and exits 0:
191
+
192
+ ```bash
193
+ worca ui status # is it running? (exit 0 = yes, 1 = no)
194
+ worca ui restart # stop the running one, start it again
195
+ worca ui stop # stop it gracefully
196
+ worca ui --port 4318 --open # another port; open the browser when up
197
+ ```
198
+
199
+ `--port` (or the `PORT` env var) picks the port; `stop`, `restart` and
200
+ `status` remember the port of the last started UI, so they usually need no
201
+ flag. See `worca ui help`.
202
+
189
203
  ### CLI
190
204
 
191
205
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/app",
3
- "version": "1.2.0-rc.1",
3
+ "version": "1.2.0-rc.2",
4
4
  "description": "Worca — deterministic multi-agent pipeline that drives Claude Code (headless) through Plan -> Refine -> Implement -> Review, with a CLI, an installable /worca skill, and a web UI.",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -4,7 +4,8 @@
4
4
  // CLI entry point. Parses flags, creates a core orchestrator, subscribes to its events,
5
5
  // renders a phase tracker + streamed agent logs to the terminal, and drives interactive
6
6
  // Q&A (clarify) and loop gates via node:readline. Supports --yes (auto), --mock,
7
- // --install <dir> (delegates to scripts/install.mjs), --ui (spawns ui/server.mjs),
7
+ // --install <dir> (delegates to scripts/install.mjs), ui start|stop|restart|status
8
+ // (--ui is an alias of `ui start`; see cmdUi),
8
9
  // and -v/-V/--version (also the bare word `version`).
9
10
  //
10
11
  // ESM, no external dependencies.
@@ -28,6 +29,10 @@ import {
28
29
  import { projectKey } from '../core/store.mjs';
29
30
  import { formatExecLine, formatGateHeader, formatRunSummary } from './render.mjs';
30
31
  import { pauseExitCode, describePauseReason, promptOptions, REASON } from '../core/failure-policy.mjs';
32
+ import { effectiveDebugSpawn } from '../core/settings.mjs';
33
+ import {
34
+ DEFAULT_UI_HOST, DEFAULT_UI_PORT, probeUi, stopUi, readUiInstance, uiUrl, waitForUiState,
35
+ } from '../core/ui-instance.mjs';
31
36
 
32
37
  // ── node:sqlite runtime guard + warning filter ──────────────────────────────────
33
38
  // Drop ONLY the one-time ExperimentalWarning emitted by node:sqlite (the module is
@@ -204,7 +209,7 @@ Usage:
204
209
  worca --prompt "<task>" [--project <dir>] [options]
205
210
  worca --file <task.md> [--project <dir>] [options]
206
211
  worca "<task>" [--project <dir>] [options] (bare prompt; quote it)
207
- worca --ui
212
+ worca ui [start|stop|restart|status] [--port <n>] [--open]
208
213
  worca --install <targetDir> [--force]
209
214
 
210
215
  Subcommands:
@@ -218,6 +223,8 @@ Subcommands:
218
223
  disable|doctor|link|reimport|init|validate|exec. See: worca plugin help
219
224
  marketplace <cmd> [...] Manage plugin marketplaces: add|list|refresh|remove. See: worca marketplace help
220
225
  config [get|set|unset] Budget & cost-limit settings
226
+ ui [start|stop|restart|status]
227
+ Run the web UI (default http://localhost:4317). See: worca ui help
221
228
  help Print this help (same as --help).
222
229
  version Print the version (same as --version).
223
230
 
@@ -236,7 +243,7 @@ Options:
236
243
  --branch <name> Feature branch name (default: claude proposes one)
237
244
  --mock Offline mock mode (no claude, no tokens)
238
245
  --yes, --non-interactive Auto-answer clarify (first option) and gates (continue)
239
- --ui Launch the web UI (ui/server.mjs) and exit
246
+ --ui Same as "worca ui start" (accepts --port, --open, --mock)
240
247
  --install <targetDir> Copy agents + /worca skill into <targetDir>/.claude
241
248
  -h, --help Show this help
242
249
  -v, -V, --version Print the version (worca <semver>) and exit
@@ -629,11 +636,97 @@ async function attachAndDrive(orch, flags, start) {
629
636
 
630
637
  // ── subcommands ──────────────────────────────────────────────────────────────────
631
638
 
632
- /** Spawn the web UI server and inherit its stdio. Resolves when it exits. */
633
- function launchUi() {
639
+ // ── web UI lifecycle ─────────────────────────────────────────────────────────────
640
+ //
641
+ // The UI is a singleton over the machine-wide store, so `worca ui` never blindly
642
+ // binds: it probes the port first (src/core/ui-instance.mjs). A Worca UI already
643
+ // answering there is the EXPECTED state — print where it is and how to restart
644
+ // it, exit 0. Only a port held by some other program is an error (exit 1).
645
+
646
+ const UI_HELP = `worca ui — the web UI server
647
+
648
+ Usage:
649
+ worca ui [start] [--port <n>] [--open] [--mock] Start the UI (default http://localhost:${DEFAULT_UI_PORT})
650
+ worca ui stop [--port <n>] Stop the running UI gracefully
651
+ worca ui restart [--port <n>] [--open] [--mock] Stop it if running, then start it again
652
+ worca ui status [--port <n>] Report whether it is running (exit 0 = running, 1 = not)
653
+ worca ui help Show this help
654
+
655
+ Options:
656
+ --port <n> Port to bind (start) or to look at (stop/restart/status).
657
+ Default: the PORT env var, then ${DEFAULT_UI_PORT}. stop/restart/status
658
+ also read the port of the last started UI (<worca home>/ui.json).
659
+ --open Open the UI in your browser once it is up
660
+ --mock Start in offline mock mode (same as WORCA_MOCK=1)
661
+
662
+ \`worca --ui\` is an alias of \`worca ui start\`.
663
+ `;
664
+
665
+ /** Bind/probe port for a `worca ui` verb: --port > (instance file) > PORT env > default. */
666
+ function resolveUiPort(a, { preferInstanceFile = false } = {}) {
667
+ if (a.port !== undefined) {
668
+ const n = Number(a.port);
669
+ if (!Number.isInteger(n) || n < 1 || n > 65535) fail(`--port must be an integer between 1 and 65535, got: ${a.port}`);
670
+ return { port: n, host: process.env.WORCA_HOST || DEFAULT_UI_HOST };
671
+ }
672
+ if (preferInstanceFile) {
673
+ const inst = readUiInstance();
674
+ if (inst) return { port: inst.port, host: inst.host || process.env.WORCA_HOST || DEFAULT_UI_HOST };
675
+ }
676
+ const env = Number(process.env.PORT);
677
+ const port = Number.isInteger(env) && env > 0 ? env : DEFAULT_UI_PORT;
678
+ return { port, host: process.env.WORCA_HOST || DEFAULT_UI_HOST };
679
+ }
680
+
681
+ /** Best-effort `open`/`start`/`xdg-open`; never throws, never keeps the CLI alive. */
682
+ function openBrowser(url) {
683
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
684
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
685
+ : ['xdg-open', [url]];
686
+ try {
687
+ const p = spawn(cmd, args, { stdio: 'ignore', detached: true });
688
+ p.on('error', () => {});
689
+ p.unref();
690
+ } catch { /* no browser opener on this box — the URL is printed anyway */ }
691
+ }
692
+
693
+ /** Spawn ui/server.mjs on `port` and inherit its stdio. Resolves with its exit code. */
694
+ async function uiStart(a) {
695
+ const { port, host } = resolveUiPort(a);
696
+ const url = uiUrl({ host, port });
697
+ const probe = await probeUi({ host, port });
698
+ if (probe.state === 'worca') {
699
+ out(`Worca UI is already running at ${c('bold', url)}`);
700
+ out('');
701
+ out(` Open it: ${url}`);
702
+ out(` Restart it: ${c('bold', 'worca ui restart')}`);
703
+ out(` Another port: ${c('bold', `worca ui --port ${port + 1}`)}`);
704
+ if (a.open) openBrowser(url);
705
+ return 0;
706
+ }
707
+ if (probe.state === 'busy') {
708
+ process.stderr.write(`worca: port ${port} is in use by another program, so the Worca UI cannot start.\n\n`
709
+ + ` Pick a free port: worca ui --port ${port + 1} (or set PORT)\n`);
710
+ return 1;
711
+ }
712
+ // A UI started earlier on some OTHER port is worth a note, not a refusal.
713
+ const inst = readUiInstance();
714
+ if (inst && inst.port !== port) {
715
+ const other = await probeUi({ host: inst.host, port: inst.port });
716
+ if (other.state === 'worca') out(c('gray', `Note: another Worca UI is running at ${uiUrl({ host: inst.host, port: inst.port })}`));
717
+ }
718
+
634
719
  const server = join(REPO_ROOT, 'ui', 'server.mjs');
635
- out(c('cyan', `Launching web UI: node ${server}`));
636
- const child = spawn(process.execPath, [server], { stdio: 'inherit' });
720
+ out(c('cyan', `Starting Worca UI on ${url}`));
721
+ if (effectiveDebugSpawn().enabled) out(c('gray', ` node ${server}`));
722
+ const env = { ...process.env, PORT: String(port) };
723
+ if (a.mock) env.WORCA_MOCK = '1';
724
+ const child = spawn(process.execPath, [server], { stdio: 'inherit', env });
725
+ if (a.open) {
726
+ waitForUiState({ host, port, states: ['worca'], timeoutMs: 20000 })
727
+ .then((r) => { if (r) openBrowser(url); })
728
+ .catch(() => {});
729
+ }
637
730
  return new Promise((res) => {
638
731
  child.on('exit', (code) => res(code ?? 0));
639
732
  child.on('error', (err) => {
@@ -643,6 +736,75 @@ function launchUi() {
643
736
  });
644
737
  }
645
738
 
739
+ /** Stop the UI on the resolved port. Idempotent: "not running" exits 0. */
740
+ async function uiStop(a, { quiet = false } = {}) {
741
+ const { port, host } = resolveUiPort(a, { preferInstanceFile: true });
742
+ const r = await stopUi({ host, port });
743
+ switch (r.status) {
744
+ case 'stopped':
745
+ out(`Stopped Worca UI on port ${port}${r.pid ? ` (pid ${r.pid})` : ''}.`);
746
+ return 0;
747
+ case 'not-running':
748
+ if (!quiet) out(`Worca UI is not running on port ${port}.`);
749
+ return 0;
750
+ case 'busy':
751
+ process.stderr.write(`worca: port ${port} is in use by another program, not a Worca UI — nothing to stop.\n`);
752
+ return 1;
753
+ case 'timeout':
754
+ process.stderr.write(`worca: the Worca UI on port ${port}${r.pid ? ` (pid ${r.pid})` : ''} did not exit in time.\n`);
755
+ return 1;
756
+ default:
757
+ process.stderr.write(`worca: could not stop the Worca UI on port ${port}: ${r.reason || r.status}\n`);
758
+ return 1;
759
+ }
760
+ }
761
+
762
+ async function uiStatus(a) {
763
+ const { port, host } = resolveUiPort(a, { preferInstanceFile: true });
764
+ const probe = await probeUi({ host, port });
765
+ if (probe.state === 'worca') {
766
+ const info = probe.info || {};
767
+ const detail = [info.pid ? `pid ${info.pid}` : null, info.version ? `v${info.version}` : null].filter(Boolean).join(', ');
768
+ out(`Worca UI is running at ${c('bold', uiUrl({ host, port }))}${detail ? ` (${detail})` : ''}`);
769
+ return 0;
770
+ }
771
+ if (probe.state === 'busy') {
772
+ out(`Worca UI is not running on port ${port} (the port is in use by another program).`);
773
+ return 1;
774
+ }
775
+ out(`Worca UI is not running on port ${port}.`);
776
+ return 1;
777
+ }
778
+
779
+ /** `worca ui [start|stop|restart|status|help] [--port <n>] [--open] [--mock]` */
780
+ async function cmdUi(argv) {
781
+ const verbs = new Set(['start', 'stop', 'restart', 'status']);
782
+ let verb = 'start';
783
+ let rest = argv;
784
+ if (argv[0] === 'help' || argv[0] === '--help' || argv[0] === '-h') {
785
+ process.stdout.write(UI_HELP);
786
+ return 0;
787
+ }
788
+ if (argv[0] && !argv[0].startsWith('-')) {
789
+ if (!verbs.has(argv[0])) fail(`unknown ui command "${argv[0]}" — expected one of: start, stop, restart, status (see: worca ui help)`);
790
+ verb = argv[0];
791
+ rest = argv.slice(1);
792
+ }
793
+ const a = pluginArgs(rest, ['--port'], ['--open', '--mock']);
794
+ if (a._.length) fail(`unexpected argument: ${a._[0]} (see: worca ui help)`);
795
+ if (verb === 'stop') return uiStop(a);
796
+ if (verb === 'status') return uiStatus(a);
797
+ if (verb === 'restart') {
798
+ // Pin the port stop resolved (possibly from the instance file) so start reuses it.
799
+ const { port } = resolveUiPort(a, { preferInstanceFile: true });
800
+ a.port = String(port);
801
+ const code = await uiStop(a, { quiet: true });
802
+ if (code !== 0) return code;
803
+ return uiStart(a);
804
+ }
805
+ return uiStart(a);
806
+ }
807
+
646
808
  /** Delegate to scripts/install.mjs, forwarding the target dir and any passthrough args. */
647
809
  function runInstall(targetDir, passthrough) {
648
810
  const script = join(REPO_ROOT, 'scripts', 'install.mjs');
@@ -1678,7 +1840,7 @@ async function cmdMarketplace(argv) {
1678
1840
 
1679
1841
  // ── main ──────────────────────────────────────────────────────────────────────────
1680
1842
 
1681
- const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config']);
1843
+ const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config', 'ui']);
1682
1844
 
1683
1845
  /** Levenshtein distance, two-row. Only ever called on short argv tokens. */
1684
1846
  function editDistance(a, b) {
@@ -1736,6 +1898,12 @@ async function main() {
1736
1898
  if (sub === 'plugin') return cmdPlugin(rest);
1737
1899
  if (sub === 'marketplace') return cmdMarketplace(rest);
1738
1900
  if (sub === 'config') return cmdConfig(rest);
1901
+ if (sub === 'ui') return cmdUi(rest);
1902
+ }
1903
+ // `worca --ui [...]` is the historical spelling of `worca ui start [...]`; hand the
1904
+ // remaining tokens to the ui parser so --port/--open/--mock work with either.
1905
+ if (process.argv.slice(2).includes('--ui')) {
1906
+ return cmdUi(process.argv.slice(2).filter((t) => t !== '--ui'));
1739
1907
  }
1740
1908
 
1741
1909
  const flags = parseArgs(process.argv.slice(2));
@@ -1752,10 +1920,6 @@ async function main() {
1752
1920
  return runInstall(flags.install, passthrough);
1753
1921
  }
1754
1922
 
1755
- if (flags.ui) {
1756
- return launchUi();
1757
- }
1758
-
1759
1923
  if (flags.mock) {
1760
1924
  process.env.WORCA_MOCK = '1';
1761
1925
  }
@@ -0,0 +1,235 @@
1
+ // src/core/ui-instance.mjs
2
+ // The web UI is a singleton over the machine-wide store, so `worca ui` needs to
3
+ // know whether one is already up before it spawns another — and needs a way to
4
+ // stop the one that is. This module is the CLI side of that lifecycle; the server
5
+ // side (GET /api/health, POST /api/shutdown, the instance file) lives in
6
+ // ui/server.mjs.
7
+ //
8
+ // Discovery is the Jupyter runtime-file pattern: the server writes
9
+ // <worcaHome>/ui.json ({ pid, host, port, token, version, startedAt }) once it is
10
+ // listening and removes it on exit. The file is a HINT, never the truth — a
11
+ // crashed server leaves it behind, so every decision re-probes the port:
12
+ //
13
+ // probeUi({ port }) -> { state: 'worca', info } a Worca UI answered /api/health
14
+ // -> { state: 'busy' } something else owns the port
15
+ // -> { state: 'free' } nothing is listening
16
+ //
17
+ // Stopping goes through POST /api/shutdown with the file's bearer token so the
18
+ // server runs its graceful path (chat channel workers die cleanly) on every
19
+ // platform — a bare signal is a hard kill on Windows. The signal is the fallback
20
+ // when the token is unavailable (file missing, or a server too old to have one).
21
+
22
+ import fs from 'node:fs';
23
+ import fsp from 'node:fs/promises';
24
+ import process from 'node:process';
25
+ import { join } from 'node:path';
26
+ import { randomBytes } from 'node:crypto';
27
+
28
+ import { worcaHome } from './projects.mjs';
29
+
30
+ export const DEFAULT_UI_PORT = 4317;
31
+ export const DEFAULT_UI_HOST = '127.0.0.1';
32
+ /** What GET /api/health must report as `name` for the occupant to count as a Worca UI. */
33
+ export const UI_HEALTH_NAME = '@worca/app';
34
+
35
+ /** Absolute path of the instance file for the current worcaHome. */
36
+ export function uiInstanceFile() {
37
+ return join(worcaHome(), 'ui.json');
38
+ }
39
+
40
+ /** A fresh shutdown token (hex, 32 bytes of entropy). */
41
+ export function newUiToken() {
42
+ return randomBytes(32).toString('hex');
43
+ }
44
+
45
+ /**
46
+ * Persist the running instance's coordinates. Atomic (tmp + rename) and 0600:
47
+ * the token authorizes a shutdown, so it must not be world-readable.
48
+ */
49
+ export async function writeUiInstance({ pid, host, port, token, version, startedAt }) {
50
+ const file = uiInstanceFile();
51
+ await fsp.mkdir(join(file, '..'), { recursive: true });
52
+ const tmp = `${file}.${pid}.tmp`;
53
+ const body = JSON.stringify({ pid, host, port, token, version, startedAt }, null, 2) + '\n';
54
+ await fsp.writeFile(tmp, body, { mode: 0o600 });
55
+ await fsp.rename(tmp, file);
56
+ return file;
57
+ }
58
+
59
+ /** The instance file's contents, or null when missing/corrupt/not an object. */
60
+ export function readUiInstance() {
61
+ try {
62
+ const data = JSON.parse(fs.readFileSync(uiInstanceFile(), 'utf8'));
63
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
64
+ const port = Number(data.port);
65
+ if (!Number.isInteger(port) || port <= 0) return null;
66
+ return { ...data, port, pid: Number(data.pid) || null };
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Remove the instance file. With `ifPid`, only when the file still belongs to
74
+ * that process — an old server exiting late must not delete the file a newer
75
+ * one just wrote. Synchronous so it is usable from a process 'exit' handler.
76
+ */
77
+ export function removeUiInstance({ ifPid } = {}) {
78
+ const file = uiInstanceFile();
79
+ try {
80
+ if (ifPid !== undefined) {
81
+ const cur = readUiInstance();
82
+ if (cur && cur.pid && cur.pid !== ifPid) return false;
83
+ }
84
+ fs.unlinkSync(file);
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /** Host as it appears inside a URL (IPv6 literals need brackets). */
92
+ export function urlHost(host) {
93
+ if (!host) return 'localhost';
94
+ if (host === '127.0.0.1' || host === '::1' || host === '[::1]' || host === 'localhost') return 'localhost';
95
+ return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
96
+ }
97
+
98
+ /** The URL a browser should open for a UI bound to host:port. */
99
+ export function uiUrl({ host = DEFAULT_UI_HOST, port = DEFAULT_UI_PORT } = {}) {
100
+ return `http://${urlHost(host)}:${port}`;
101
+ }
102
+
103
+ /** Host to CONNECT to (bind-any addresses are not dialable). */
104
+ function dialHost(host) {
105
+ if (!host || host === '0.0.0.0' || host === '::') return DEFAULT_UI_HOST;
106
+ return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
107
+ }
108
+
109
+ /** Every error code nested in a fetch failure (Node wraps them in `cause`, sometimes an AggregateError). */
110
+ function errorCodes(err) {
111
+ const out = new Set();
112
+ const walk = (e, depth) => {
113
+ if (!e || depth > 4) return;
114
+ if (typeof e.code === 'string') out.add(e.code);
115
+ if (e.cause) walk(e.cause, depth + 1);
116
+ if (Array.isArray(e.errors)) for (const inner of e.errors) walk(inner, depth + 1);
117
+ };
118
+ walk(err, 0);
119
+ return out;
120
+ }
121
+
122
+ /** GET a JSON object from the UI, or null (non-2xx, non-JSON, non-object). Network errors propagate. */
123
+ async function getJson(url, signal) {
124
+ const res = await fetch(url, { signal, headers: { accept: 'application/json' } });
125
+ if (!res.ok) return null;
126
+ try {
127
+ const data = await res.json();
128
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Ask host:port whether a Worca UI is listening there.
136
+ *
137
+ * @returns {Promise<{state:'worca', info:object} | {state:'busy'} | {state:'free'}>}
138
+ * 'busy' covers every occupant that is not a Worca UI: a non-JSON answer, a
139
+ * different `name`, a hang (timeout) or a reset. Only a clean connection
140
+ * refusal is 'free'. A Worca UI from before /api/health existed is recognised
141
+ * by its settings route and reported with `info.legacy = true` (no pid, no
142
+ * token — it cannot be stopped from here, only from its own terminal).
143
+ */
144
+ export async function probeUi({ host = DEFAULT_UI_HOST, port = DEFAULT_UI_PORT, timeoutMs = 1500 } = {}) {
145
+ const ctl = new AbortController();
146
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
147
+ const base = `http://${dialHost(host)}:${port}`;
148
+ try {
149
+ const info = await getJson(`${base}/api/health`, ctl.signal);
150
+ if (info) return info.name === UI_HEALTH_NAME ? { state: 'worca', info } : { state: 'busy' };
151
+ const legacy = await getJson(`${base}/api/settings`, ctl.signal);
152
+ if (legacy && typeof legacy.projectsRootDefault === 'string' && 'askMaxTurns' in legacy) {
153
+ return { state: 'worca', info: { name: UI_HEALTH_NAME, legacy: true } };
154
+ }
155
+ return { state: 'busy' };
156
+ } catch (err) {
157
+ const codes = errorCodes(err);
158
+ if (codes.has('ECONNREFUSED')) return { state: 'free' };
159
+ return { state: 'busy' };
160
+ } finally {
161
+ clearTimeout(timer);
162
+ }
163
+ }
164
+
165
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
166
+
167
+ /** Poll until probeUi reports `state` (or any of `states`), or give up after timeoutMs. */
168
+ export async function waitForUiState({ host, port, states, timeoutMs = 10000, intervalMs = 100 } = {}) {
169
+ const want = new Set(Array.isArray(states) ? states : [states]);
170
+ const deadline = Date.now() + timeoutMs;
171
+ for (;;) {
172
+ const r = await probeUi({ host, port, timeoutMs: Math.min(1500, Math.max(200, deadline - Date.now())) });
173
+ if (want.has(r.state)) return r;
174
+ if (Date.now() >= deadline) return null;
175
+ await sleep(intervalMs);
176
+ }
177
+ }
178
+
179
+ /** True when a process with that pid exists (signal 0 probes without killing). */
180
+ export function processAlive(pid) {
181
+ if (!Number.isInteger(pid) || pid <= 0) return false;
182
+ try { process.kill(pid, 0); return true; } catch (err) { return err && err.code === 'EPERM'; }
183
+ }
184
+
185
+ /**
186
+ * Stop the Worca UI on host:port, gracefully when possible.
187
+ *
188
+ * Order: (1) POST /api/shutdown with the instance file's token — the server
189
+ * answers 202 and exits through its signal path; (2) if that is refused or no
190
+ * token is known, SIGTERM the pid the health probe reported; (3) wait for the
191
+ * port to free up. Idempotent: a port with no Worca UI is `notRunning`, not an
192
+ * error. The instance file is cleaned up whenever the port ends up free.
193
+ *
194
+ * @returns {Promise<{status:'stopped', method:'request'|'signal', pid:number|null}
195
+ * |{status:'not-running'}
196
+ * |{status:'busy'}
197
+ * |{status:'failed', pid:number|null, reason:string}
198
+ * |{status:'timeout', pid:number|null}>}
199
+ */
200
+ export async function stopUi({ host = DEFAULT_UI_HOST, port = DEFAULT_UI_PORT, token, timeoutMs = 10000 } = {}) {
201
+ const probe = await probeUi({ host, port });
202
+ if (probe.state === 'free') { removeUiInstance(); return { status: 'not-running' }; }
203
+ if (probe.state === 'busy') return { status: 'busy' };
204
+ if (probe.info.legacy) {
205
+ return { status: 'failed', pid: null, reason: 'it is an older Worca UI without shutdown support — stop it from its own terminal (Ctrl+C) and start again' };
206
+ }
207
+ const pid = Number(probe.info.pid) || null;
208
+
209
+ const file = readUiInstance();
210
+ const bearer = token || (file && file.port === port ? file.token : null);
211
+ let method = null;
212
+
213
+ if (bearer) {
214
+ try {
215
+ const res = await fetch(`http://${dialHost(host)}:${port}/api/shutdown`, {
216
+ method: 'POST',
217
+ headers: { authorization: `Bearer ${bearer}`, accept: 'application/json' },
218
+ signal: AbortSignal.timeout(3000),
219
+ });
220
+ if (res.status === 202 || res.status === 200) method = 'request';
221
+ } catch {
222
+ // The server may drop the connection while exiting — the wait below decides.
223
+ method = 'request';
224
+ }
225
+ }
226
+ if (!method && pid) {
227
+ try { process.kill(pid, 'SIGTERM'); method = 'signal'; } catch { /* already gone, or not ours */ }
228
+ }
229
+ if (!method) return { status: 'failed', pid, reason: 'no shutdown token in the instance file and no pid to signal' };
230
+
231
+ const freed = await waitForUiState({ host, port, states: ['free'], timeoutMs });
232
+ if (!freed) return { status: 'timeout', pid };
233
+ removeUiInstance();
234
+ return { status: 'stopped', method, pid };
235
+ }
package/ui/server.mjs CHANGED
@@ -93,6 +93,9 @@ import { listGlobalModels, addGlobalModel, updateGlobalModel } from '../src/core
93
93
  import { modelEnvRef, maskModelEnvValue, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
94
94
  import { listPluginModels, modelSecretsSchema, pluginModelSecretStatus } from '../src/core/plugin-models.mjs';
95
95
  import { testModel } from '../src/core/model-test.mjs';
96
+ import {
97
+ DEFAULT_UI_PORT, UI_HEALTH_NAME, newUiToken, writeUiInstance, removeUiInstance, uiUrl,
98
+ } from '../src/core/ui-instance.mjs';
96
99
  import { validateGuardrails } from '../src/core/guardrails.mjs';
97
100
  import {
98
101
  listBuiltinGuardrailSets, listGuardrailSets, readGuardrailSet,
@@ -174,6 +177,7 @@ const PUBLIC_DIR = path.join(__dirname, 'public');
174
177
  const AGENTS_DIR = path.join(PROJECT_ROOT, 'agents');
175
178
  const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
176
179
  const require = createRequire(import.meta.url);
180
+ const PKG_VERSION = require('../package.json').version;
177
181
  const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
178
182
  // Primaries plus the sub-language grammars their instances register
179
183
  // (hljs-loader.mjs); a shipped but unmapped grammar stays a plain 404.
@@ -212,7 +216,7 @@ const ASK_VENDOR_ASSETS = {
212
216
  dompurify: resolveEsmAsset('dompurify'),
213
217
  };
214
218
 
215
- const PORT = Number(process.env.PORT) || 4317;
219
+ const PORT = Number(process.env.PORT) || DEFAULT_UI_PORT;
216
220
  // Bind to loopback by default (S1). Power users who knowingly want LAN exposure
217
221
  // can set WORCA_HOST=0.0.0.0, but the localhost-only Host/Origin guard still
218
222
  // applies unless they also front it with auth.
@@ -275,6 +279,11 @@ const MAX_BUFFER = 5000;
275
279
  const app = express();
276
280
  const server = http.createServer(app);
277
281
  const wss = new WebSocketServer({ server, path: '/ws' });
282
+ // ws re-emits the http server's 'error' on the WebSocketServer. With no listener
283
+ // here, an EADDRINUSE on listen() became an unhandled 'error' event and a full
284
+ // stack trace; the http server's own handler (isMain below) is the one that
285
+ // reports it, so this side of the pair only has to not throw.
286
+ wss.on('error', () => {});
278
287
 
279
288
  /** All currently connected sockets. */
280
289
  const sockets = new Set();
@@ -2781,6 +2790,49 @@ const settingsState = () => ({
2781
2790
  debugSpawnEffective: effectiveDebugSpawn(), // what the next spawn will DO, and why
2782
2791
  });
2783
2792
 
2793
+ // ---------------------------------------------------------------------------
2794
+ // Instance lifecycle (`worca ui status|stop|restart`, src/core/ui-instance.mjs)
2795
+ // ---------------------------------------------------------------------------
2796
+ // `uiControl` is set by the boot block below when the server owns a port. Under
2797
+ // test (app imported, no bind) it stays empty: /api/health still answers, and
2798
+ // /api/shutdown refuses with 503 rather than exiting the test runner.
2799
+ const uiControl = { token: null, onShutdown: null, startedAt: null };
2800
+ const startedAtIso = () => uiControl.startedAt || null;
2801
+
2802
+ app.get('/api/health', (req, res) => {
2803
+ const addr = req.socket && req.socket.localPort;
2804
+ res.json({
2805
+ name: UI_HEALTH_NAME,
2806
+ version: PKG_VERSION,
2807
+ pid: process.pid,
2808
+ host: HOST,
2809
+ port: addr || PORT,
2810
+ startedAt: startedAtIso(),
2811
+ });
2812
+ });
2813
+
2814
+ /** Constant-time bearer check; `expected` is the boot-time token from ui.json. */
2815
+ function bearerMatches(header, expected) {
2816
+ if (!expected || typeof header !== 'string') return false;
2817
+ const m = /^Bearer\s+(\S+)$/i.exec(header.trim());
2818
+ if (!m) return false;
2819
+ const a = Buffer.from(m[1]);
2820
+ const b = Buffer.from(expected);
2821
+ return a.length === b.length && timingSafeEqual(a, b);
2822
+ }
2823
+
2824
+ app.post('/api/shutdown', (req, res) => {
2825
+ if (!uiControl.token || typeof uiControl.onShutdown !== 'function') {
2826
+ return res.status(503).json({ error: 'shutdown is only available on a server started with `worca ui`' });
2827
+ }
2828
+ if (!bearerMatches(req.headers.authorization, uiControl.token)) {
2829
+ return res.status(401).json({ error: 'shutdown requires the bearer token from the instance file' });
2830
+ }
2831
+ res.status(202).json({ ok: true, pid: process.pid });
2832
+ // Answer first, exit on the next tick so the 202 actually leaves the socket.
2833
+ setImmediate(() => uiControl.onShutdown('request'));
2834
+ });
2835
+
2784
2836
  app.get('/api/settings', (_req, res) => {
2785
2837
  res.json({ ...settingsState(), chat: chatPrefs() });
2786
2838
  });
@@ -5364,30 +5416,53 @@ if (isMain) {
5364
5416
  console.error(`[worca-ui] boot maintenance failed: ${err && err.message ? err.message : err}`);
5365
5417
  });
5366
5418
 
5419
+ // A port that is already taken is an EXPECTED state (the UI is usually already
5420
+ // up), not a crash: one line, no stack, exit 1. `worca ui` probes the port
5421
+ // before spawning this process and prints the friendlier "already running"
5422
+ // block itself; this branch is for `node ui/server.mjs` run by hand or a race.
5367
5423
  server.on('error', (err) => {
5424
+ if (err && err.code === 'EADDRINUSE') {
5425
+ console.error(`[worca-ui] port ${PORT} is already in use — is the UI already running?`);
5426
+ console.error(`[worca-ui] check with \`worca ui status\`, restart with \`worca ui restart\`, or pick a port: \`worca ui --port <n>\``);
5427
+ process.exit(1);
5428
+ }
5368
5429
  console.error(`[worca-ui] server error: ${err && err.message ? err.message : err}`);
5369
5430
  });
5370
5431
 
5432
+ // Channel workers must die with the server (design §9: persistent-process
5433
+ // hygiene). Graceful shutdown frame -> 5s grace -> SIGKILL, then exit. The
5434
+ // same path serves POST /api/shutdown (`worca ui stop`), which exits 0.
5435
+ let shuttingDown = false;
5436
+ let wroteInstanceFile = false;
5437
+ const exitCodeFor = (signal) => (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 0);
5438
+ const shutdown = (signal) => {
5439
+ if (shuttingDown) return;
5440
+ shuttingDown = true;
5441
+ channelHost.stop().finally(() => process.exit(exitCodeFor(signal)));
5442
+ };
5443
+ process.on('SIGINT', () => shutdown('SIGINT'));
5444
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
5445
+ // 'exit' handlers must be synchronous; removeUiInstance is. `ifPid` keeps an
5446
+ // old server exiting late from deleting the file a newer one just wrote.
5447
+ process.on('exit', () => { if (wroteInstanceFile) removeUiInstance({ ifPid: process.pid }); });
5448
+
5371
5449
  server.listen(PORT, HOST, () => {
5372
- const shown = HOST === '127.0.0.1' || HOST === '::1' ? 'localhost' : HOST;
5373
- const url = `http://${shown}:${PORT}`;
5450
+ const port = server.address().port;
5451
+ const url = uiUrl({ host: HOST, port });
5374
5452
  console.log(`[worca-ui] listening on ${url} (bound to ${HOST})`);
5375
- console.log(`[worca-ui] WebSocket on ws://${shown}:${PORT}/ws`);
5453
+ uiControl.token = newUiToken();
5454
+ uiControl.onShutdown = shutdown;
5455
+ uiControl.startedAt = new Date().toISOString();
5456
+ writeUiInstance({
5457
+ pid: process.pid, host: HOST, port, token: uiControl.token,
5458
+ version: PKG_VERSION, startedAt: uiControl.startedAt,
5459
+ }).then(() => { wroteInstanceFile = true; }, (err) => {
5460
+ console.error(`[worca-ui] could not write the instance file (\`worca ui stop\` will fall back to a signal): ${err && err.message ? err.message : err}`);
5461
+ });
5376
5462
  try { channelHost.start(); } catch (err) {
5377
5463
  console.error(`[worca-ui] chat channel host failed to start: ${err && err.message ? err.message : err}`);
5378
5464
  }
5379
5465
  });
5380
-
5381
- // Channel workers must die with the server (design §9: persistent-process
5382
- // hygiene). Graceful shutdown frame -> 5s grace -> SIGKILL, then exit.
5383
- let shuttingDown = false;
5384
- const shutdownChat = (signal) => {
5385
- if (shuttingDown) return;
5386
- shuttingDown = true;
5387
- channelHost.stop().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
5388
- };
5389
- process.on('SIGINT', () => shutdownChat('SIGINT'));
5390
- process.on('SIGTERM', () => shutdownChat('SIGTERM'));
5391
5466
  }
5392
5467
 
5393
5468
  export { app, server, runs };
@@ -5396,4 +5471,5 @@ export const _testing = {
5396
5471
  chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
5397
5472
  chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
5398
5473
  emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
5474
+ uiControl, bearerMatches,
5399
5475
  };