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

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
@@ -90,6 +90,15 @@ and durations, the clarify Q&A, agent transcripts, and logs:
90
90
  runs out). Flow cards — **Task** (the run's request), **End** (the result),
91
91
  **AND**, **OR**, **Combine** — express joins, choices and merges without any
92
92
  code. Saved pipelines appear in the New Pipeline picker.
93
+ - **Share a pipeline** — *Export…* on a saved pipeline offers three formats.
94
+ A *JSON file* passes it to another Worca user, who picks it up with
95
+ *Import…* (a taken name gets a ` (2)` suffix; nothing is ever overwritten).
96
+ A *Claude Code skill* turns it into a runnable `/command` under `.claude/`
97
+ so it runs without Worca. A *Worca plugin* folder bundles the pipeline with
98
+ your own agents it uses and the skills they need, so the recipient runs
99
+ `worca plugin link <folder>` and updates later with `worca plugin reimport`.
100
+ Built-in agents are never copied — a plugin pipeline may reference them
101
+ directly. The saved list is tabbed by domain; click a card to open it.
93
102
 
94
103
  ![Workflow Composer — drag agents into steps, groups, and feedback loops](docs/screenshots/composer.png)
95
104
 
@@ -143,6 +152,12 @@ and durations, the clarify Q&A, agent transcripts, and logs:
143
152
  per-model routing environment (e.g. `ANTHROPIC_BASE_URL`) that is merged
144
153
  into that model's agent spawns. Share a model catalog as a plugin, with
145
154
  secrets required at install time.
155
+ - **No first-party account needed** — run and chat titles are written by the
156
+ model the run or chat itself uses (Settings › General › Title generation picks
157
+ a fixed one instead), endpoint-routed models carry Claude Code's internal
158
+ haiku/sonnet/opus/fable tier keys so nothing falls back to the Anthropic API,
159
+ and *Hide built-in models* (Settings › Models) drops the built-ins from every
160
+ picker.
146
161
 
147
162
  ### Storage
148
163
 
@@ -177,7 +192,7 @@ Requirements:
177
192
  ### Web UI
178
193
 
179
194
  ```bash
180
- worca --ui
195
+ worca ui # start it (worca --ui does the same)
181
196
  ```
182
197
 
183
198
  Open the printed URL (default `http://localhost:4317`), add a project, and
@@ -186,6 +201,20 @@ a task from a plugin source like GitHub Issues), pick a workflow and
186
201
  guardrails, and run. Answer clarify questions and loop gates as they come —
187
202
  in the browser or from chat.
188
203
 
204
+ The UI is one process per machine. Starting it while it is already up is not
205
+ an error — Worca prints the URL and how to restart it, and exits 0:
206
+
207
+ ```bash
208
+ worca ui status # is it running? (exit 0 = yes, 1 = no)
209
+ worca ui restart # stop the running one, start it again
210
+ worca ui stop # stop it gracefully
211
+ worca ui --port 4318 --open # another port; open the browser when up
212
+ ```
213
+
214
+ `--port` (or the `PORT` env var) picks the port; `stop`, `restart` and
215
+ `status` remember the port of the last started UI, so they usually need no
216
+ flag. See `worca ui help`.
217
+
189
218
  ### CLI
190
219
 
191
220
  ```bash
@@ -200,10 +229,15 @@ worca resume <pipelineId>
200
229
 
201
230
  # offline demo — full pipeline, no tokens
202
231
  worca --project /path/to/your/project --prompt "demo task" --mock --yes
232
+
233
+ # share a saved pipeline: as JSON, or as a plugin folder bundling your agents + skills
234
+ worca workflow export wf_my-flow --format json --out my-flow.json
235
+ worca workflow import my-flow.json
236
+ worca workflow export wf_my-flow --format plugin --target ./my-flow-plugin
203
237
  ```
204
238
 
205
239
  Run `worca --help` for all subcommands (projects, plugins, marketplaces,
206
- config, doctor) and flags.
240
+ workflows, config, doctor) and flags.
207
241
 
208
242
  Exit codes, for scripts and CI wrappers: `0` the run finished (or an
209
243
  interactive run paused and you can resume it); `1` a hard error, a stop, or an
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.3",
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,9 @@ 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
228
+ workflow <cmd> [...] Export a workflow (Claude Code skill, JSON, or plugin) / import JSON: list|export|import. See: worca workflow help
221
229
  help Print this help (same as --help).
222
230
  version Print the version (same as --version).
223
231
 
@@ -236,7 +244,7 @@ Options:
236
244
  --branch <name> Feature branch name (default: claude proposes one)
237
245
  --mock Offline mock mode (no claude, no tokens)
238
246
  --yes, --non-interactive Auto-answer clarify (first option) and gates (continue)
239
- --ui Launch the web UI (ui/server.mjs) and exit
247
+ --ui Same as "worca ui start" (accepts --port, --open, --mock)
240
248
  --install <targetDir> Copy agents + /worca skill into <targetDir>/.claude
241
249
  -h, --help Show this help
242
250
  -v, -V, --version Print the version (worca <semver>) and exit
@@ -629,11 +637,97 @@ async function attachAndDrive(orch, flags, start) {
629
637
 
630
638
  // ── subcommands ──────────────────────────────────────────────────────────────────
631
639
 
632
- /** Spawn the web UI server and inherit its stdio. Resolves when it exits. */
633
- function launchUi() {
640
+ // ── web UI lifecycle ─────────────────────────────────────────────────────────────
641
+ //
642
+ // The UI is a singleton over the machine-wide store, so `worca ui` never blindly
643
+ // binds: it probes the port first (src/core/ui-instance.mjs). A Worca UI already
644
+ // answering there is the EXPECTED state — print where it is and how to restart
645
+ // it, exit 0. Only a port held by some other program is an error (exit 1).
646
+
647
+ const UI_HELP = `worca ui — the web UI server
648
+
649
+ Usage:
650
+ worca ui [start] [--port <n>] [--open] [--mock] Start the UI (default http://localhost:${DEFAULT_UI_PORT})
651
+ worca ui stop [--port <n>] Stop the running UI gracefully
652
+ worca ui restart [--port <n>] [--open] [--mock] Stop it if running, then start it again
653
+ worca ui status [--port <n>] Report whether it is running (exit 0 = running, 1 = not)
654
+ worca ui help Show this help
655
+
656
+ Options:
657
+ --port <n> Port to bind (start) or to look at (stop/restart/status).
658
+ Default: the PORT env var, then ${DEFAULT_UI_PORT}. stop/restart/status
659
+ also read the port of the last started UI (<worca home>/ui.json).
660
+ --open Open the UI in your browser once it is up
661
+ --mock Start in offline mock mode (same as WORCA_MOCK=1)
662
+
663
+ \`worca --ui\` is an alias of \`worca ui start\`.
664
+ `;
665
+
666
+ /** Bind/probe port for a `worca ui` verb: --port > (instance file) > PORT env > default. */
667
+ function resolveUiPort(a, { preferInstanceFile = false } = {}) {
668
+ if (a.port !== undefined) {
669
+ const n = Number(a.port);
670
+ if (!Number.isInteger(n) || n < 1 || n > 65535) fail(`--port must be an integer between 1 and 65535, got: ${a.port}`);
671
+ return { port: n, host: process.env.WORCA_HOST || DEFAULT_UI_HOST };
672
+ }
673
+ if (preferInstanceFile) {
674
+ const inst = readUiInstance();
675
+ if (inst) return { port: inst.port, host: inst.host || process.env.WORCA_HOST || DEFAULT_UI_HOST };
676
+ }
677
+ const env = Number(process.env.PORT);
678
+ const port = Number.isInteger(env) && env > 0 ? env : DEFAULT_UI_PORT;
679
+ return { port, host: process.env.WORCA_HOST || DEFAULT_UI_HOST };
680
+ }
681
+
682
+ /** Best-effort `open`/`start`/`xdg-open`; never throws, never keeps the CLI alive. */
683
+ function openBrowser(url) {
684
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
685
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
686
+ : ['xdg-open', [url]];
687
+ try {
688
+ const p = spawn(cmd, args, { stdio: 'ignore', detached: true });
689
+ p.on('error', () => {});
690
+ p.unref();
691
+ } catch { /* no browser opener on this box — the URL is printed anyway */ }
692
+ }
693
+
694
+ /** Spawn ui/server.mjs on `port` and inherit its stdio. Resolves with its exit code. */
695
+ async function uiStart(a) {
696
+ const { port, host } = resolveUiPort(a);
697
+ const url = uiUrl({ host, port });
698
+ const probe = await probeUi({ host, port });
699
+ if (probe.state === 'worca') {
700
+ out(`Worca UI is already running at ${c('bold', url)}`);
701
+ out('');
702
+ out(` Open it: ${url}`);
703
+ out(` Restart it: ${c('bold', 'worca ui restart')}`);
704
+ out(` Another port: ${c('bold', `worca ui --port ${port + 1}`)}`);
705
+ if (a.open) openBrowser(url);
706
+ return 0;
707
+ }
708
+ if (probe.state === 'busy') {
709
+ process.stderr.write(`worca: port ${port} is in use by another program, so the Worca UI cannot start.\n\n`
710
+ + ` Pick a free port: worca ui --port ${port + 1} (or set PORT)\n`);
711
+ return 1;
712
+ }
713
+ // A UI started earlier on some OTHER port is worth a note, not a refusal.
714
+ const inst = readUiInstance();
715
+ if (inst && inst.port !== port) {
716
+ const other = await probeUi({ host: inst.host, port: inst.port });
717
+ if (other.state === 'worca') out(c('gray', `Note: another Worca UI is running at ${uiUrl({ host: inst.host, port: inst.port })}`));
718
+ }
719
+
634
720
  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' });
721
+ out(c('cyan', `Starting Worca UI on ${url}`));
722
+ if (effectiveDebugSpawn().enabled) out(c('gray', ` node ${server}`));
723
+ const env = { ...process.env, PORT: String(port) };
724
+ if (a.mock) env.WORCA_MOCK = '1';
725
+ const child = spawn(process.execPath, [server], { stdio: 'inherit', env });
726
+ if (a.open) {
727
+ waitForUiState({ host, port, states: ['worca'], timeoutMs: 20000 })
728
+ .then((r) => { if (r) openBrowser(url); })
729
+ .catch(() => {});
730
+ }
637
731
  return new Promise((res) => {
638
732
  child.on('exit', (code) => res(code ?? 0));
639
733
  child.on('error', (err) => {
@@ -643,6 +737,75 @@ function launchUi() {
643
737
  });
644
738
  }
645
739
 
740
+ /** Stop the UI on the resolved port. Idempotent: "not running" exits 0. */
741
+ async function uiStop(a, { quiet = false } = {}) {
742
+ const { port, host } = resolveUiPort(a, { preferInstanceFile: true });
743
+ const r = await stopUi({ host, port });
744
+ switch (r.status) {
745
+ case 'stopped':
746
+ out(`Stopped Worca UI on port ${port}${r.pid ? ` (pid ${r.pid})` : ''}.`);
747
+ return 0;
748
+ case 'not-running':
749
+ if (!quiet) out(`Worca UI is not running on port ${port}.`);
750
+ return 0;
751
+ case 'busy':
752
+ process.stderr.write(`worca: port ${port} is in use by another program, not a Worca UI — nothing to stop.\n`);
753
+ return 1;
754
+ case 'timeout':
755
+ process.stderr.write(`worca: the Worca UI on port ${port}${r.pid ? ` (pid ${r.pid})` : ''} did not exit in time.\n`);
756
+ return 1;
757
+ default:
758
+ process.stderr.write(`worca: could not stop the Worca UI on port ${port}: ${r.reason || r.status}\n`);
759
+ return 1;
760
+ }
761
+ }
762
+
763
+ async function uiStatus(a) {
764
+ const { port, host } = resolveUiPort(a, { preferInstanceFile: true });
765
+ const probe = await probeUi({ host, port });
766
+ if (probe.state === 'worca') {
767
+ const info = probe.info || {};
768
+ const detail = [info.pid ? `pid ${info.pid}` : null, info.version ? `v${info.version}` : null].filter(Boolean).join(', ');
769
+ out(`Worca UI is running at ${c('bold', uiUrl({ host, port }))}${detail ? ` (${detail})` : ''}`);
770
+ return 0;
771
+ }
772
+ if (probe.state === 'busy') {
773
+ out(`Worca UI is not running on port ${port} (the port is in use by another program).`);
774
+ return 1;
775
+ }
776
+ out(`Worca UI is not running on port ${port}.`);
777
+ return 1;
778
+ }
779
+
780
+ /** `worca ui [start|stop|restart|status|help] [--port <n>] [--open] [--mock]` */
781
+ async function cmdUi(argv) {
782
+ const verbs = new Set(['start', 'stop', 'restart', 'status']);
783
+ let verb = 'start';
784
+ let rest = argv;
785
+ if (argv[0] === 'help' || argv[0] === '--help' || argv[0] === '-h') {
786
+ process.stdout.write(UI_HELP);
787
+ return 0;
788
+ }
789
+ if (argv[0] && !argv[0].startsWith('-')) {
790
+ if (!verbs.has(argv[0])) fail(`unknown ui command "${argv[0]}" — expected one of: start, stop, restart, status (see: worca ui help)`);
791
+ verb = argv[0];
792
+ rest = argv.slice(1);
793
+ }
794
+ const a = pluginArgs(rest, ['--port'], ['--open', '--mock']);
795
+ if (a._.length) fail(`unexpected argument: ${a._[0]} (see: worca ui help)`);
796
+ if (verb === 'stop') return uiStop(a);
797
+ if (verb === 'status') return uiStatus(a);
798
+ if (verb === 'restart') {
799
+ // Pin the port stop resolved (possibly from the instance file) so start reuses it.
800
+ const { port } = resolveUiPort(a, { preferInstanceFile: true });
801
+ a.port = String(port);
802
+ const code = await uiStop(a, { quiet: true });
803
+ if (code !== 0) return code;
804
+ return uiStart(a);
805
+ }
806
+ return uiStart(a);
807
+ }
808
+
646
809
  /** Delegate to scripts/install.mjs, forwarding the target dir and any passthrough args. */
647
810
  function runInstall(targetDir, passthrough) {
648
811
  const script = join(REPO_ROOT, 'scripts', 'install.mjs');
@@ -1041,6 +1204,43 @@ working, including updates (install provenance lives in plugins.lock.json).
1041
1204
  Exit codes: 0 ok, 1 failure, 2 usage/validation errors.
1042
1205
  `;
1043
1206
 
1207
+ const WORKFLOW_HELP = `worca workflow — export a saved Composer workflow (as a Claude Code skill, as
1208
+ shareable JSON, or as a Worca plugin) and import one shared as JSON
1209
+
1210
+ Usage:
1211
+ worca workflow list List workflows (id, name, domain)
1212
+ worca workflow export <id> [options] Export a workflow (see --format)
1213
+ worca workflow import <file> [--name <name>] Import a JSON export into your library ('-' = stdin)
1214
+
1215
+ Export formats (--format):
1216
+ claude (default) A runnable Claude Code skill tree under <dest>/.claude/
1217
+ json The shareable v2 graph, to stdout (or --out <file>)
1218
+ plugin A Worca plugin folder (--target <dir>) bundling the workflow,
1219
+ your own agents it uses and the skills they require; the
1220
+ recipient runs: worca plugin link <dir>
1221
+
1222
+ Options (claude):
1223
+ --global Export to the home dir (~/.claude), not a project
1224
+ --target <dir> Export into <dir>/.claude (default: cwd)
1225
+ --slug <name> Skill slug (default: slugified workflow name)
1226
+ --dry-run Show the classification (create/update/no-op/conflict); write nothing
1227
+ --on-conflict <mode> skip (default) | overwrite | namespace conflicting files
1228
+ Options (json):
1229
+ --out <file> Write to <file> instead of stdout
1230
+ Options (plugin):
1231
+ --target <dir> The plugin folder (created, or updated in place)
1232
+ --name <plugin> Plugin name (default: an existing manifest's name, else the folder name)
1233
+ --keep-version Do not patch-bump the manifest version on an update
1234
+ --dry-run Show the classification; write nothing
1235
+
1236
+ Import: the id is minted from the name; a name already in use gets a " (2)" suffix —
1237
+ nothing is ever overwritten. A workflow that needs agents you do not have is refused;
1238
+ ask its author for the plugin export instead.
1239
+
1240
+ Export always prints the plan first, then applies (unless --dry-run). A re-export
1241
+ of an unchanged workflow is an all-no-op. Exit codes: 0 ok, 1 failure, 2 usage/validation errors.
1242
+ `;
1243
+
1044
1244
  /** Tiny per-verb arg parser: positionals plus declared --value / --bool flags. */
1045
1245
  function pluginArgs(argv, valueFlags = [], boolFlags = []) {
1046
1246
  const out = { _: [] };
@@ -1058,7 +1258,7 @@ function pluginArgs(argv, valueFlags = [], boolFlags = []) {
1058
1258
  out[a.slice(2)] = v;
1059
1259
  } else if (boolFlags.includes(a)) {
1060
1260
  out[a.slice(2)] = true;
1061
- } else if (a.startsWith('-')) {
1261
+ } else if (a.startsWith('-') && a !== '-') { // a lone '-' is the stdin positional
1062
1262
  fail(`Unknown flag: ${a}`);
1063
1263
  } else {
1064
1264
  out._.push(a);
@@ -1143,9 +1343,8 @@ async function pluginInit(rest) {
1143
1343
  for (const part of withParts) {
1144
1344
  if (!INIT_PARTS.includes(part)) fail(`unknown --with part "${part}" (known: ${INIT_PARTS.join(', ')})`);
1145
1345
  }
1146
- if (withParts.includes('workflows') && !withParts.includes('agents')) {
1147
- fail('--with workflows requires agents (templates may only reference the plugin\'s own agent keys)');
1148
- }
1346
+ // `--with workflows` no longer requires `agents`: a plugin template may reference
1347
+ // built-in agents (#421), so a workflow-only plugin scaffolds a built-in chain.
1149
1348
  const target = resolve(process.cwd(), a.dir || name);
1150
1349
  const { mkdir, writeFile, chmod, readdir } = await import('node:fs/promises');
1151
1350
  try {
@@ -1254,19 +1453,24 @@ async function pluginInit(rest) {
1254
1453
  }
1255
1454
  if (withParts.includes('workflows')) {
1256
1455
  // A v2 graph: the Task and End cards are mandatory (V20/V21) and every input
1257
- // takes exactly one wire (V7). Ports come from the sidecar above.
1456
+ // takes exactly one wire (V7). With agents, ports come from the sidecar above;
1457
+ // without, the chain runs the built-in planner (a plugin template may
1458
+ // reference built-ins — they are on every host).
1459
+ const withAgent = withParts.includes('agents');
1460
+ const key = withAgent ? agentKey : 'planner';
1461
+ const outPort = withAgent ? 'notes' : 'plan';
1258
1462
  files.set('workflows/example-flow.json', JSON.stringify({
1259
1463
  name: `${name} example flow`,
1260
1464
  version: 2,
1261
1465
  domain: 'general',
1262
1466
  nodes: [
1263
1467
  { id: 'n_task', kind: 'task', x: 40, y: 200, config: {} },
1264
- { id: 'n_helper', kind: 'agent', key: agentKey, x: 320, y: 200, config: {} },
1468
+ { id: 'n_helper', kind: 'agent', key, x: 320, y: 200, config: {} },
1265
1469
  { id: 'n_end', kind: 'end', x: 600, y: 200, config: {} },
1266
1470
  ],
1267
1471
  wires: [
1268
1472
  { id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_helper', port: 'task' } },
1269
- { id: 'w2', from: { node: 'n_helper', port: 'notes' }, to: { node: 'n_end', port: 'result' } },
1473
+ { id: 'w2', from: { node: 'n_helper', port: outPort }, to: { node: 'n_end', port: 'result' } },
1270
1474
  ],
1271
1475
  }, null, 2) + '\n');
1272
1476
  }
@@ -1676,9 +1880,141 @@ async function cmdMarketplace(argv) {
1676
1880
  }
1677
1881
  }
1678
1882
 
1883
+ async function cmdWorkflow(argv) {
1884
+ const verb = argv[0];
1885
+ const rest = argv.slice(1);
1886
+ if (!verb || verb === 'help') { process.stdout.write(WORKFLOW_HELP); return 0; }
1887
+ const wf = await import('../core/workflows.mjs');
1888
+ const xp = await import('../core/workflow-export.mjs');
1889
+ const share = await import('../core/workflow-share.mjs');
1890
+ const { formatIssue } = await import('../shared/graph/validate.mjs');
1891
+ try {
1892
+ switch (verb) {
1893
+ case 'list': {
1894
+ // GRAPH_DEFAULT_WORKFLOW (the built-in default) is not in the user store, so
1895
+ // prepend it — mirrors the server/UI, which always show it first.
1896
+ const items = [wf.GRAPH_DEFAULT_WORKFLOW, ...(await wf.listWorkflows())];
1897
+ for (const w of items) out(`${w.id}\t${w.name}\t${(w.domain || 'general')}`);
1898
+ return 0;
1899
+ }
1900
+ case 'import': {
1901
+ const a = pluginArgs(rest, ['--name'], []);
1902
+ const file = a._[0];
1903
+ if (!file) fail('Usage: worca workflow import <file> [--name <name>]');
1904
+ const { readFile } = await import('node:fs/promises');
1905
+ let text;
1906
+ try {
1907
+ text = file === '-'
1908
+ ? await new Promise((res, rej) => { let s = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => (s += c)); process.stdin.on('end', () => res(s)); process.stdin.on('error', rej); })
1909
+ : await readFile(resolve(process.cwd(), file), 'utf8');
1910
+ } catch (e) { process.stderr.write(`worca workflow import: cannot read ${file}: ${e.message}\n`); return 1; }
1911
+ let obj;
1912
+ try { obj = JSON.parse(text); } catch (e) { process.stderr.write(`worca workflow import: ${file} is not valid JSON (${e.message})\n`); return 2; }
1913
+ try {
1914
+ const r = await share.importGraphWorkflow(obj, { name: a.name });
1915
+ out(`imported\t${r.workflow.id}\t${r.workflow.name}`);
1916
+ if (r.renamed) out(c('yellow', `renamed: "${r.requestedName}" was already taken — saved as "${r.workflow.name}"`));
1917
+ for (const w of r.warnings || []) out(`${c('yellow', 'warn')}\t${formatIssue(w)}`);
1918
+ return 0;
1919
+ } catch (e) {
1920
+ if (e && e.code === 'INVALID_GRAPH') {
1921
+ process.stderr.write(`worca workflow import: ${e.summary || 'invalid graph'}\n`);
1922
+ for (const issue of e.errors || []) process.stderr.write(` - ${formatIssue(issue)}\n`);
1923
+ return 2;
1924
+ }
1925
+ if (e && (e.code === 'BAD_REQUEST' || e.code === 'RESERVED_NAME')) { process.stderr.write(`worca workflow import: ${e.message}\n`); return 2; }
1926
+ throw e;
1927
+ }
1928
+ }
1929
+ case 'export': {
1930
+ const a = pluginArgs(rest, ['--target', '--slug', '--on-conflict', '--format', '--out', '--name'], ['--global', '--dry-run', '--keep-version']);
1931
+ const id = a._[0];
1932
+ if (!id) fail('Usage: worca workflow export <id> [--format claude|json|plugin] [--global | --target <dir>] [--slug <name>] [--out <file>] [--name <plugin>] [--keep-version] [--dry-run] [--on-conflict=skip|overwrite|namespace]');
1933
+ const format = a.format || 'claude';
1934
+ if (!['claude', 'json', 'plugin'].includes(format)) fail(`--format must be claude|json|plugin (got ${format})`);
1935
+
1936
+ if (format === 'json') {
1937
+ const payload = await share.exportGraphJson(id);
1938
+ const text = JSON.stringify(payload, null, 2) + '\n';
1939
+ if (a.out) {
1940
+ const { writeFile } = await import('node:fs/promises');
1941
+ const dest = resolve(process.cwd(), a.out);
1942
+ await writeFile(dest, text, 'utf8');
1943
+ out(`${c('green', 'wrote')}\t${dest}`);
1944
+ } else {
1945
+ process.stdout.write(text);
1946
+ }
1947
+ return 0;
1948
+ }
1949
+
1950
+ if (format === 'plugin') {
1951
+ if (!a.target) fail('--format plugin requires --target <dir> (the plugin folder)');
1952
+ if (a.global) fail('--global does not apply to --format plugin');
1953
+ const r = await xp.exportWorkflowPlugin({
1954
+ workflowId: id, targetDir: resolve(process.cwd(), a.target), pluginName: a.name,
1955
+ keepVersion: !!a['keep-version'], dryRun: !!a['dry-run'],
1956
+ });
1957
+ for (const p of r.created) out(`${c('green', 'create')}\t${p}`);
1958
+ for (const p of r.updated) out(`${c('cyan', 'update')}\t${p}`);
1959
+ for (const p of r.noop) out(`${c('gray', 'no-op')}\t${p}`);
1960
+ for (const s of r.skipped) out(`${c('gray', 'skip')}\t${s.path}\t(${s.reason})`);
1961
+ for (const w of r.warnings || []) out(`${c('yellow', 'warn')}\t${w}`);
1962
+ for (const p of r.written) out(`${c('green', 'wrote')}\t${p}`);
1963
+ if (r.validation && !r.validation.ok) {
1964
+ out(c('yellow', `\nthe plugin folder does not validate — fix the errors above before sharing it`));
1965
+ return 1;
1966
+ }
1967
+ if (!a['dry-run']) {
1968
+ out(`\nplugin "${r.name}" v${r.version} at ${r.dir}`);
1969
+ out(`share the folder; the recipient runs: worca plugin link ${r.dir}`);
1970
+ }
1971
+ return 0;
1972
+ }
1973
+
1974
+ if (a.global && a.target) fail('--global and --target are mutually exclusive');
1975
+ const onConflict = a['on-conflict'] || 'skip';
1976
+ if (!xp.ON_CONFLICT_MODES.includes(onConflict)) fail(`--on-conflict must be ${xp.ON_CONFLICT_MODES.join('|')} (got ${onConflict})`);
1977
+ const destination = a.global ? 'global' : 'project';
1978
+ const projectDir = a.global ? undefined : (a.target || process.cwd());
1979
+ const common = { workflowId: id, destination, projectDir, slug: a.slug };
1980
+
1981
+ // Always show the classification first. A dry-run stops at planExport (writes nothing);
1982
+ // an apply reuses the buckets applyExport already computes, so the full resolve+classify
1983
+ // pipeline runs ONCE, not twice.
1984
+ const printPlan = (p) => {
1985
+ for (const path of p.created) out(`${c('green', 'create')}\t${path}`);
1986
+ for (const path of p.updated) out(`${c('cyan', 'update')}\t${path}`);
1987
+ for (const path of p.noop) out(`${c('gray', 'no-op')}\t${path}`);
1988
+ for (const cf of p.conflicts) out(`${c('yellow', 'conflict')}\t${cf.path}\t(${cf.reason})`);
1989
+ for (const w of p.warnings || []) out(`${c('yellow', 'warn')}\t${w}`);
1990
+ for (const o of p.orphans || []) out(`${c('gray', 'orphan')}\t${o}`);
1991
+ };
1992
+
1993
+ if (a['dry-run']) { printPlan(await xp.planExport(common)); return 0; }
1994
+
1995
+ const applied = await xp.applyExport({ ...common, onConflict });
1996
+ printPlan(applied);
1997
+ for (const p of applied.written) out(`${c('green', 'wrote')}\t${p}`);
1998
+ for (const p of applied.skipped) out(`${c('gray', 'skip')}\t${p}`);
1999
+ if (applied.conflicts.length) {
2000
+ // A partial export (conflicts skipped) can leave a non-runnable skill on disk. Warn AND
2001
+ // exit non-zero so a CI/script that only checks the exit code does not treat it as success.
2002
+ out(c('yellow', `\n${applied.conflicts.length} conflict(s) left unresolved (--on-conflict=${onConflict}). Re-run with --on-conflict=overwrite|namespace.`));
2003
+ return 1;
2004
+ }
2005
+ return 0;
2006
+ }
2007
+ default: fail(`unknown workflow verb "${verb}" — see: worca workflow help`);
2008
+ }
2009
+ } catch (err) {
2010
+ process.stderr.write(`worca workflow ${verb}: ${err && err.message ? err.message : err}\n`);
2011
+ return 1;
2012
+ }
2013
+ }
2014
+
1679
2015
  // ── main ──────────────────────────────────────────────────────────────────────────
1680
2016
 
1681
- const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config']);
2017
+ const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config', 'ui', 'workflow']);
1682
2018
 
1683
2019
  /** Levenshtein distance, two-row. Only ever called on short argv tokens. */
1684
2020
  function editDistance(a, b) {
@@ -1736,6 +2072,13 @@ async function main() {
1736
2072
  if (sub === 'plugin') return cmdPlugin(rest);
1737
2073
  if (sub === 'marketplace') return cmdMarketplace(rest);
1738
2074
  if (sub === 'config') return cmdConfig(rest);
2075
+ if (sub === 'ui') return cmdUi(rest);
2076
+ if (sub === 'workflow') return cmdWorkflow(rest);
2077
+ }
2078
+ // `worca --ui [...]` is the historical spelling of `worca ui start [...]`; hand the
2079
+ // remaining tokens to the ui parser so --port/--open/--mock work with either.
2080
+ if (process.argv.slice(2).includes('--ui')) {
2081
+ return cmdUi(process.argv.slice(2).filter((t) => t !== '--ui'));
1739
2082
  }
1740
2083
 
1741
2084
  const flags = parseArgs(process.argv.slice(2));
@@ -1752,10 +2095,6 @@ async function main() {
1752
2095
  return runInstall(flags.install, passthrough);
1753
2096
  }
1754
2097
 
1755
- if (flags.ui) {
1756
- return launchUi();
1757
- }
1758
-
1759
2098
  if (flags.mock) {
1760
2099
  process.env.WORCA_MOCK = '1';
1761
2100
  }
@@ -47,7 +47,12 @@ export function createAskModels({
47
47
  /** The D8 initial pick, validated against the live catalog (D5). */
48
48
  function pickDefault(models) {
49
49
  const want = String(defaults.defaultModel || '').toLowerCase();
50
- const hit = models.find((m) => m.id.toLowerCase() === want) || models[0] || null;
50
+ // Hidden built-ins (#422) are never the initial pick: on an install that
51
+ // hides them the default is the first model the user actually owns. They
52
+ // stay in `models` so a stored thread on one keeps validating.
53
+ const visible = models.filter((m) => !m.hidden);
54
+ const pool = visible.length ? visible : models;
55
+ const hit = pool.find((m) => m.id.toLowerCase() === want) || pool[0] || null;
51
56
  if (!hit) return null;
52
57
  const efforts = hit.efforts.length ? hit.efforts : [...EFFORTS];
53
58
  const effort = efforts.includes(defaults.defaultEffort)
@@ -83,6 +88,7 @@ export function createAskModels({
83
88
  hasEnv: m.hasEnv === true,
84
89
  };
85
90
  if (custom === 'plugin' && typeof m.plugin === 'string' && m.plugin) entry.plugin = m.plugin;
91
+ if (m.hidden === true) entry.hidden = true; // the picker skips it; validation does not (#422)
86
92
  // Only globals and plugin entries can arrive flagged: composeCatalog emits an
87
93
  // UNSHADOWED built-in as {...m, custom:false, hasEnv:false} with no
88
94
  // ...unreliable(lc) (src/core/config.mjs:200), so a built-in in model_cost_flags
@@ -439,6 +439,11 @@ class AskTurn extends EventEmitter {
439
439
  tools: [], strictMcpConfig: true, settingSources: ['project'],
440
440
  disableSlashCommands: true, envScrub: true, envAllowlist: [],
441
441
  permissionMode: 'dontAsk',
442
+ // The chat's own model is the title default (#422) — a chat on a
443
+ // custom endpoint titles itself there, not on a first-party Haiku.
444
+ runModel: this.model,
445
+ onError: ({ model, error }) => console.warn(
446
+ `[worca-ask] thread ${this.threadId}: title generation failed (model ${model}): ${error?.message || error} — keeping the fallback title`),
442
447
  }))
443
448
  .then((generated) => {
444
449
  // The route stamps NOTHING before the 202 (the header reads "Ask Worca"