@worca/app 1.1.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
@@ -205,6 +219,14 @@ worca --project /path/to/your/project --prompt "demo task" --mock --yes
205
219
  Run `worca --help` for all subcommands (projects, plugins, marketplaces,
206
220
  config, doctor) and flags.
207
221
 
222
+ Exit codes, for scripts and CI wrappers: `0` the run finished (or an
223
+ interactive run paused and you can resume it); `1` a hard error, a stop, or an
224
+ interactive pause an error forced; `2` a usage error; `3` a `--yes` run that
225
+ parked itself — auth, quota, a usage or cost limit, exhausted retries, or a
226
+ step error — with nobody attached to resume it. Nothing is discarded on a
227
+ pause: `worca resume <pipelineId>` picks the run up where it stopped, and the
228
+ cause is printed with the pause block on stdout.
229
+
208
230
  ### `/worca` skill (inside Claude Code)
209
231
 
210
232
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/app",
3
- "version": "1.1.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,13 +4,16 @@
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), and --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),
9
+ // and -v/-V/--version (also the bare word `version`).
8
10
  //
9
11
  // ESM, no external dependencies.
10
12
 
11
13
  import { createInterface } from 'node:readline';
12
14
  import { spawn } from 'node:child_process';
13
15
  import { fstatSync } from 'node:fs';
16
+ import { createRequire } from 'node:module';
14
17
  import { fileURLToPath } from 'node:url';
15
18
  import { dirname, resolve, join, basename } from 'node:path';
16
19
  import process from 'node:process';
@@ -25,6 +28,11 @@ import {
25
28
  } from '../core/projects.mjs';
26
29
  import { projectKey } from '../core/store.mjs';
27
30
  import { formatExecLine, formatGateHeader, formatRunSummary } from './render.mjs';
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';
28
36
 
29
37
  // ── node:sqlite runtime guard + warning filter ──────────────────────────────────
30
38
  // Drop ONLY the one-time ExperimentalWarning emitted by node:sqlite (the module is
@@ -39,6 +47,18 @@ process.on('warning', (w) => {
39
47
  if (w && w.name === 'ExperimentalWarning' && /SQLite/i.test(w.message)) return;
40
48
  process.stderr.write(`${w?.stack || w?.message || w}\n`);
41
49
  });
50
+ // ── --version ──────────────────────────────────────────────────────────────────
51
+ // Answered BEFORE the Node preflight and before any flag validation: "which worca is
52
+ // this?" is the first question asked when something else is broken, so it must work
53
+ // on an unsupported Node and alongside an otherwise-bad command line. The bare word
54
+ // `version` is only honoured in the subcommand slot (like `help`); the flags anywhere.
55
+ // Output is the GNU/gh/go form, `<prog> <semver>`, on stdout, exit 0.
56
+ const PKG_VERSION = createRequire(import.meta.url)('../../package.json').version;
57
+ const VERSION_FLAGS = new Set(['-v', '-V', '--version']);
58
+ if (process.argv[2] === 'version' || process.argv.slice(2).some((a) => VERSION_FLAGS.has(a))) {
59
+ process.stdout.write(`worca ${PKG_VERSION}\n`);
60
+ process.exit(0);
61
+ }
42
62
  // Fail fast on an unsupported Node / missing node:sqlite BEFORE any DB is opened.
43
63
  preflightNode();
44
64
 
@@ -58,7 +78,8 @@ const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions',
58
78
 
59
79
  /**
60
80
  * Parse argv into a flags object. Supports "--flag value" and "--flag=value", plus the
61
- * boolean flags --mock, --yes/--non-interactive, --ui, -h/--help.
81
+ * boolean flags --mock, --yes/--non-interactive, --ui, -h/--help. (-v/-V/--version
82
+ * never reach here: they are answered at module top, before the Node preflight.)
62
83
  */
63
84
  function parseArgs(argv) {
64
85
  const out = {
@@ -188,7 +209,7 @@ Usage:
188
209
  worca --prompt "<task>" [--project <dir>] [options]
189
210
  worca --file <task.md> [--project <dir>] [options]
190
211
  worca "<task>" [--project <dir>] [options] (bare prompt; quote it)
191
- worca --ui
212
+ worca ui [start|stop|restart|status] [--port <n>] [--open]
192
213
  worca --install <targetDir> [--force]
193
214
 
194
215
  Subcommands:
@@ -202,7 +223,10 @@ Subcommands:
202
223
  disable|doctor|link|reimport|init|validate|exec. See: worca plugin help
203
224
  marketplace <cmd> [...] Manage plugin marketplaces: add|list|refresh|remove. See: worca marketplace help
204
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
205
228
  help Print this help (same as --help).
229
+ version Print the version (same as --version).
206
230
 
207
231
  Options:
208
232
  --project <dir> Target project directory (default: cwd)
@@ -219,9 +243,10 @@ Options:
219
243
  --branch <name> Feature branch name (default: claude proposes one)
220
244
  --mock Offline mock mode (no claude, no tokens)
221
245
  --yes, --non-interactive Auto-answer clarify (first option) and gates (continue)
222
- --ui Launch the web UI (ui/server.mjs) and exit
246
+ --ui Same as "worca ui start" (accepts --port, --open, --mock)
223
247
  --install <targetDir> Copy agents + /worca skill into <targetDir>/.claude
224
248
  -h, --help Show this help
249
+ -v, -V, --version Print the version (worca <semver>) and exit
225
250
  `;
226
251
 
227
252
  // ── terminal rendering ───────────────────────────────────────────────────────────
@@ -334,22 +359,27 @@ async function askGate(rl, issues, header) {
334
359
 
335
360
  /**
336
361
  * Ask the user how to handle a recoverable error (auth / rate-limit / quota /
337
- * network). Shows the cause and waits for retry / abort. Returns { decision }.
362
+ * network). Shows the cause and the row's options (failure-policy.mjs: Retry, plus
363
+ * what giving up does — pause or abort). Returns { decision } with the chosen
364
+ * option's id as the wire value.
338
365
  */
339
366
  async function askRecovery(rl, recovery) {
340
367
  const rec = recovery || {};
368
+ const options = Array.isArray(rec.options) && rec.options.length ? rec.options : promptOptions({ outcome: 'pause' });
341
369
  out('');
342
370
  out(c('yellow', c('bold', `Recoverable ${String(rec.cls || 'error').replace('_', ' ')} error — the pipeline could not reach the model.`)));
343
371
  if (rec.message) out(c('gray', ` ${rec.message}`));
344
372
  if (rec.cls === 'auth') out(c('gray', ' Fix: re-authenticate (claude setup-token or /login) in another terminal, then retry.'));
345
373
  else out(c('gray', ' Fix: wait out the limit / restore connectivity / top up credit, then retry.'));
346
- out(' 1) Retry');
347
- out(' 2) Abort the run');
374
+ options.forEach((o, i) => out(` ${i + 1}) ${o.label}`));
348
375
  let decision = '';
349
376
  while (!decision) {
350
- const raw = (await question(rl, c('cyan', 'Choose [1-2]: '))).trim();
351
- if (raw === '1' || /^retry/i.test(raw)) decision = 'retry';
352
- else if (raw === '2' || /^abort/i.test(raw)) decision = 'abort';
377
+ const raw = (await question(rl, c('cyan', `Choose [1-${options.length}]: `))).trim();
378
+ const byNumber = options[Number(raw) - 1];
379
+ if (byNumber) decision = byNumber.id;
380
+ else if (/^retry/i.test(raw)) decision = 'retry';
381
+ // 'pause' and 'abort' both mean give up; the option offered names the verdict.
382
+ else if (/^(pause|abort)/i.test(raw)) decision = options.find((o) => o.id !== 'retry')?.id || 'pause';
353
383
  }
354
384
  return { decision };
355
385
  }
@@ -380,7 +410,16 @@ function stdinCanAnswer() {
380
410
  /**
381
411
  * Wire readline Q&A, log/phase rendering, and SIGINT pause/stop onto an
382
412
  * orchestrator, then drive it. `start` launches run() or resume(). Returns the
383
- * process exit code (0 for done/paused, 1 otherwise).
413
+ * process exit code (pauseExitCode, failure-policy.mjs):
414
+ * 0 done — and an INTERACTIVE pause the user chose or a limit/cap forced (they
415
+ * witnessed it and can resume);
416
+ * 1 a terminal error (a launch failure, an unrecoverable resume, a stop) and an
417
+ * INTERACTIVE pause an error forced;
418
+ * 2 a usage error (fail());
419
+ * 3 any pause under --yes — the run parked itself (auth/quota/usage limit,
420
+ * exhausted retries, an error) with nobody attached to resume it, so a
421
+ * wrapper must not read success. Under --yes a parked run's cause prints
422
+ * on STDOUT with the pause block; only a terminal error reaches stderr.
384
423
  */
385
424
  async function attachAndDrive(orch, flags, start) {
386
425
  // Refuse an unanswerable interactive run BEFORE start(). The orchestrator
@@ -557,7 +596,22 @@ async function attachAndDrive(orch, flags, start) {
557
596
  for (const line of summary.slice(1)) out(line);
558
597
  }
559
598
  } else if (result?.status === 'paused') {
560
- out(c('yellow', result?.reason ? `Pipeline paused: ${result.reason}` : 'Pipeline paused.'));
599
+ // An error-pause reads as a failure the user can pick up again: the cause on
600
+ // its own line, then the reassurance that nothing was thrown away.
601
+ if (result.reason === REASON.ERROR) {
602
+ out(c('red', c('bold', 'Pipeline paused after an error.')));
603
+ if (result.detail) out(c('red', ` ${result.detail}`));
604
+ out(c('yellow', 'Nothing was discarded: the worktree and the run position are kept.'));
605
+ } else if (result.reason === REASON.RECOVERABLE) {
606
+ out(c('yellow', c('bold', 'Pipeline paused on a recoverable error — resume once it clears.')));
607
+ if (result.detail) out(c('yellow', ` ${result.detail}`));
608
+ out(c('yellow', 'Nothing was discarded: the worktree and the run position are kept.'));
609
+ } else if (result?.reason) {
610
+ const label = describePauseReason(result.reason) || result.reason;
611
+ out(c('yellow', `Pipeline paused: ${label}${result.detail ? ` — ${result.detail}` : ''}`));
612
+ } else {
613
+ out(c('yellow', 'Pipeline paused.'));
614
+ }
561
615
  out(`Resume with: ${c('bold', `worca resume ${orch.state.id}`)}`);
562
616
  } else if (result?.status === 'stopped') {
563
617
  out(c('yellow', 'Pipeline stopped.'));
@@ -569,16 +623,110 @@ async function attachAndDrive(orch, flags, start) {
569
623
  }
570
624
  // An unanswered question is a failure even if the run somehow settled `done`.
571
625
  if (answerFailure) return 1;
572
- return result?.status === 'done' || result?.status === 'paused' ? 0 : 1;
626
+ if (result?.status === 'done') return 0;
627
+ // The exit code for a pause is a consequence of its reason (failure-policy.mjs):
628
+ // 0 only when someone is attached to resume it (interactive — pinned by the
629
+ // MAJ-7 Ctrl+C pitfall test) and no error forced it; 1 for an interactive
630
+ // error-pause; 3 under --yes, where every pause is the run parking ITSELF with
631
+ // nobody left to resume (0 would let a CI job go green on a run that did no
632
+ // work; 2 is fail()'s usage-error code).
633
+ if (result?.status === 'paused') return pauseExitCode(result.reason, flags.auto);
634
+ return 1;
573
635
  }
574
636
 
575
637
  // ── subcommands ──────────────────────────────────────────────────────────────────
576
638
 
577
- /** Spawn the web UI server and inherit its stdio. Resolves when it exits. */
578
- 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
+
579
719
  const server = join(REPO_ROOT, 'ui', 'server.mjs');
580
- out(c('cyan', `Launching web UI: node ${server}`));
581
- 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
+ }
582
730
  return new Promise((res) => {
583
731
  child.on('exit', (code) => res(code ?? 0));
584
732
  child.on('error', (err) => {
@@ -588,6 +736,75 @@ function launchUi() {
588
736
  });
589
737
  }
590
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
+
591
808
  /** Delegate to scripts/install.mjs, forwarding the target dir and any passthrough args. */
592
809
  function runInstall(targetDir, passthrough) {
593
810
  const script = join(REPO_ROOT, 'scripts', 'install.mjs');
@@ -1623,7 +1840,7 @@ async function cmdMarketplace(argv) {
1623
1840
 
1624
1841
  // ── main ──────────────────────────────────────────────────────────────────────────
1625
1842
 
1626
- 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']);
1627
1844
 
1628
1845
  /** Levenshtein distance, two-row. Only ever called on short argv tokens. */
1629
1846
  function editDistance(a, b) {
@@ -1649,15 +1866,16 @@ function editDistance(a, b) {
1649
1866
  */
1650
1867
  function nearestSubcommand(token) {
1651
1868
  if (!token || /\s/.test(token)) return null;
1652
- // 'help' is spliced into both loops: it is a real CLI arm (the head of main())
1653
- // but deliberately absent from the dispatch table, so without it a typo of help
1654
- // itself (`worca hlep`) is distance >= 4 from everything and runs as a PROMPT.
1655
- for (const name of [...SUBCOMMANDS, 'help']) {
1869
+ // 'help' and 'version' are spliced into both loops: they are real CLI arms (the
1870
+ // head of main() / the module top) but deliberately absent from the dispatch
1871
+ // table, so without them a typo of either (`worca hlep`, `worca versoin`) is
1872
+ // distance >= 3 from everything and runs as a PROMPT.
1873
+ for (const name of [...SUBCOMMANDS, 'help', 'version']) {
1656
1874
  if (token.length >= 3 && name.length > token.length && name.startsWith(token)) return name;
1657
1875
  }
1658
1876
  let best = null;
1659
1877
  let bestD = 3; // strictly less than 3 == distance <= 2
1660
- for (const name of [...SUBCOMMANDS, 'help']) {
1878
+ for (const name of [...SUBCOMMANDS, 'help', 'version']) {
1661
1879
  const d = editDistance(token, name);
1662
1880
  if (d < bestD) { bestD = d; best = name; }
1663
1881
  }
@@ -1680,6 +1898,12 @@ async function main() {
1680
1898
  if (sub === 'plugin') return cmdPlugin(rest);
1681
1899
  if (sub === 'marketplace') return cmdMarketplace(rest);
1682
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'));
1683
1907
  }
1684
1908
 
1685
1909
  const flags = parseArgs(process.argv.slice(2));
@@ -1696,10 +1920,6 @@ async function main() {
1696
1920
  return runInstall(flags.install, passthrough);
1697
1921
  }
1698
1922
 
1699
- if (flags.ui) {
1700
- return launchUi();
1701
- }
1702
-
1703
1923
  if (flags.mock) {
1704
1924
  process.env.WORCA_MOCK = '1';
1705
1925
  }
@@ -1519,6 +1519,7 @@ async function rowToHistoryEntry(row, repoDir = null, opts = {}) {
1519
1519
  sourceBranch: source,
1520
1520
  guardrailsId: row.guardrails_id ?? null,
1521
1521
  pauseReason: row.pause_reason ?? null,
1522
+ pauseDetail: row.pause_detail ?? null,
1522
1523
  retainedWork: retainedWorkFor(row),
1523
1524
  survived,
1524
1525
  added,
@@ -1574,7 +1575,8 @@ export async function listPipelines(projectDir, opts = {}, workspaceKey) {
1574
1575
  const rows = getDb().prepare(`
1575
1576
  SELECT id, project_key, target, title, status, started_at, updated_at, total_cost_usd, total_active_ms,
1576
1577
  branch, workspace_meta, guardrails_id,
1577
- json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason
1578
+ json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
1579
+ json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
1578
1580
  FROM pipelines
1579
1581
  WHERE ${workspaceKey ? 'workspace_key = ?' : 'project_key = ?'} AND archived_at IS NULL
1580
1582
  ORDER BY started_at DESC
@@ -1602,7 +1604,8 @@ export async function listAllPipelines(opts = {}, { batchSize = 16 } = {}) {
1602
1604
  const rows = getDb().prepare(`
1603
1605
  SELECT id, project_key, workspace_key, target, title, status, started_at, updated_at,
1604
1606
  total_cost_usd, total_active_ms, branch, workspace_meta, guardrails_id,
1605
- json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason
1607
+ json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
1608
+ json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
1606
1609
  FROM pipelines
1607
1610
  WHERE archived_at IS NULL
1608
1611
  ORDER BY COALESCE(updated_at, started_at) DESC, project_key, id
@@ -1785,6 +1788,11 @@ function rowToState(row) {
1785
1788
  `).all(row.id).map(stepRowToStep),
1786
1789
  subAgents: listSubAgents(row.id),
1787
1790
  };
1791
+ // The pause cause rides resume_point (no column): expose it on the DETAIL payload
1792
+ // too, so a deep-linked History detail no longer waits for the LIST row.
1793
+ const rp = j(row.resume_point, null);
1794
+ state.pauseReason = typeof rp?.pauseReason === 'string' ? rp.pauseReason : null;
1795
+ state.pauseDetail = typeof rp?.pauseDetail === 'string' ? rp.pauseDetail : null;
1788
1796
  const outcome = j(row.outcome, null);
1789
1797
  if (outcome) {
1790
1798
  state.engine = 2;
@@ -0,0 +1,95 @@
1
+ // src/core/ask/attachment-kind.mjs
2
+ // Attachment typing for the Ask Worca chat (issue #398): which extensions are
3
+ // accepted, what kind/mime each maps to, and content sniffing for the binary
4
+ // ones. Pure and synchronous — the single source of truth for the type table;
5
+ // limits.mjs re-exports the two extension lists and ui/server.mjs validates
6
+ // uploads with classifyExtension + sniffMime.
7
+ //
8
+ // Kinds: 'text' (UTF-8, inlineable into the turn prompt, redactable),
9
+ // 'image' (fed to the model via its Read tool on the stored file) and
10
+ // 'binary' (today only PDF — same Read-tool path, never inlined).
11
+ //
12
+ // The extension names the CLAIMED type; for binary kinds the claim is verified
13
+ // against the leading bytes (magic number) so a mislabeled body is refused at
14
+ // upload rather than stored wrong. SVG is deliberately absent: it is scriptable
15
+ // markup, and the download route serves attachment bodies with their real mime.
16
+
17
+ /** Extension -> {kind, mime} for the text kinds (the pre-#398 allowlist). */
18
+ const TEXT_TYPES = Object.freeze({
19
+ '.md': 'text/markdown',
20
+ '.markdown': 'text/markdown',
21
+ '.txt': 'text/plain',
22
+ '.json': 'application/json',
23
+ '.csv': 'text/csv',
24
+ '.log': 'text/plain',
25
+ });
26
+
27
+ /** Extension -> mime for the binary kinds. Every mime here MUST be sniffable. */
28
+ const BINARY_TYPES = Object.freeze({
29
+ '.png': 'image/png',
30
+ '.jpg': 'image/jpeg',
31
+ '.jpeg': 'image/jpeg',
32
+ '.gif': 'image/gif',
33
+ '.webp': 'image/webp',
34
+ '.pdf': 'application/pdf',
35
+ });
36
+
37
+ export const TEXT_EXTENSIONS = Object.freeze(Object.keys(TEXT_TYPES));
38
+ export const BINARY_EXTENSIONS = Object.freeze(Object.keys(BINARY_TYPES));
39
+
40
+ const kindForMime = (mime) => (mime.startsWith('image/') ? 'image' : 'binary');
41
+
42
+ /** ISO 32000-1 §7.5.2 (implementation note 13): the `%PDF-` header may be
43
+ * preceded by up to 1024 bytes of junk (a UTF-8 BOM, print-driver or mail-
44
+ * gateway preamble). Acrobat and pdf.js accept such files, so the sniff does too. */
45
+ const PDF_HEADER_WINDOW = 1024;
46
+
47
+ /**
48
+ * Classify a lower-cased extension (with the leading dot) into {kind, mime},
49
+ * or null when it is not on either allowlist.
50
+ */
51
+ export function classifyExtension(ext) {
52
+ if (typeof ext !== 'string') return null;
53
+ const e = ext.toLowerCase();
54
+ if (Object.prototype.hasOwnProperty.call(TEXT_TYPES, e)) return { kind: 'text', mime: TEXT_TYPES[e] };
55
+ if (Object.prototype.hasOwnProperty.call(BINARY_TYPES, e)) return { kind: kindForMime(BINARY_TYPES[e]), mime: BINARY_TYPES[e] };
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * Sniff the real mime of a binary body from its magic number, or null when the
61
+ * bytes match none of the accepted binary types. Text kinds are validated by
62
+ * UTF-8 decoding instead (ui/server.mjs), never sniffed here.
63
+ */
64
+ export function sniffMime(buf) {
65
+ if (!Buffer.isBuffer(buf) || buf.length < 3) return null;
66
+ if (buf.length >= 8
67
+ && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47
68
+ && buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a) return 'image/png';
69
+ // SOI (FF D8) followed by the first marker's FF and its marker byte (>= 0xC0:
70
+ // APPn/DQT/SOFn/…) — a bare 3-byte FF D8 FF stub is not a JPEG anything can open.
71
+ if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff && buf[3] >= 0xc0) return 'image/jpeg';
72
+ if (buf.length >= 6) {
73
+ const head6 = buf.toString('latin1', 0, 6);
74
+ if (head6 === 'GIF87a' || head6 === 'GIF89a') return 'image/gif';
75
+ }
76
+ if (buf.length >= 12
77
+ && buf.toString('latin1', 0, 4) === 'RIFF'
78
+ && buf.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
79
+ if (buf.length >= 5) {
80
+ const at = buf.toString('latin1', 0, Math.min(buf.length, PDF_HEADER_WINDOW + 5)).indexOf('%PDF-');
81
+ if (at !== -1 && at <= PDF_HEADER_WINDOW) return 'application/pdf';
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /** The on-disk extension for a stored body: derived from the SNIFFED mime (or
87
+ * '.txt' for text kinds), never from the user-supplied name — the path stays a
88
+ * function of row data the store minted (store.mjs traversal guard). */
89
+ export function extensionForAttachment(kind, mime) {
90
+ if (kind === 'text' || kind == null) return '.txt';
91
+ for (const [ext, m] of Object.entries(BINARY_TYPES)) {
92
+ if (m === mime) return ext; // first match: '.jpg' wins over '.jpeg' for image/jpeg
93
+ }
94
+ return '.bin';
95
+ }