@worca/app 1.2.0-rc.2 → 1.2.0

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.
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { readFileSync, readdirSync, readlinkSync, existsSync } from 'node:fs';
7
7
  import { join, resolve, dirname, sep, isAbsolute } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
8
9
  import { WORCA_PLUGIN_API, WORCA_PLUGIN_APIS } from './plugin-api.mjs';
9
10
  import { EFFORTS, isReservedModelEnvKey, assertModelCost } from './model-env.mjs';
10
11
  import { validateMetaV2, normalizeAgentMeta, indexByKey } from '../shared/graph/agent-meta.mjs';
@@ -19,7 +20,37 @@ const KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
19
20
  const SOURCE_ID_RE = /^[a-z][a-z0-9-]{0,63}$/;
20
21
  const FIELD_TYPES = new Set(['text', 'select']);
21
22
  const INPUT_TYPES = new Set(['text', 'select', 'remote-select', 'task-browser']);
22
- const KNOWN_TOP = new Set(['name', 'version', 'description', 'author', 'homepage', 'license', 'engines', 'taskSources', 'chatChannels', 'setup', 'models', 'modelSecrets']);
23
+ // `worca` is a free-form tool-metadata block (the workflow exporter records
24
+ // `worca.exports` there — issue #421); the loader never reads it.
25
+ const KNOWN_TOP = new Set(['name', 'version', 'description', 'author', 'homepage', 'license', 'engines', 'taskSources', 'chatChannels', 'setup', 'models', 'modelSecrets', 'worca']);
26
+
27
+ /** The built-in agent layer (repo agents/). Same URL math as agent-registry's
28
+ * DEFAULT_AGENTS_DIR, duplicated because agent-registry imports THIS module
29
+ * (declaredApi) and a back-import would be a cycle. */
30
+ const BUILTIN_AGENTS_DIR = fileURLToPath(new URL('../../agents/', import.meta.url));
31
+
32
+ /**
33
+ * Normalized meta v2 sidecars of the built-in layer, for the plugin-template
34
+ * isolation rule (#421): a template may reference built-ins (immutable, always
35
+ * present on every host) plus the plugin's own agents — never the user layer
36
+ * or another plugin. A sidecar that fails the v2 gate is skipped: it ships no
37
+ * ports, so a template naming it gets the same V4 the runtime would raise.
38
+ * @param {string} [dir]
39
+ * @returns {object[]}
40
+ */
41
+ export function builtinAgentMetas(dir = BUILTIN_AGENTS_DIR) {
42
+ const out = [];
43
+ let files = [];
44
+ try { files = readdirSync(dir).filter((f) => f.endsWith('.meta.json')); } catch { return out; }
45
+ for (const f of files.sort()) {
46
+ let meta = null;
47
+ try { meta = JSON.parse(readFileSync(join(dir, f), 'utf8')); } catch { continue; }
48
+ if (Number(meta?.metaVersion) !== 2 || !KEY_RE.test(String(meta.key || ''))) continue;
49
+ if (validateMetaV2(meta).errors.length) continue;
50
+ out.push(normalizeAgentMeta(meta).meta);
51
+ }
52
+ return out;
53
+ }
23
54
  const KNOWN_SOURCE = new Set(['id', 'displayName', 'module', 'configSchema', 'inputs', 'multiProfile']);
24
55
  const KNOWN_CHANNEL = new Set(['id', 'displayName', 'platform', 'module', 'ingress', 'capabilities', 'configSchema']);
25
56
  const CHANNEL_INGRESS = new Set(['connect', 'webhook']);
@@ -497,7 +528,7 @@ export function findEscapingSymlinks(root) {
497
528
  * strict: unknown-field warnings become errors.
498
529
  * @returns {{ok:boolean, manifest:object|null, problems:Array<{level:'error'|'warn', message:string}>}}
499
530
  */
500
- export function validatePluginDir(absDir, { strict = false } = {}) {
531
+ export function validatePluginDir(absDir, { strict = false, builtinMetas } = {}) {
501
532
  const problems = [];
502
533
  const push = (level, message) => problems.push({ level, message });
503
534
 
@@ -592,12 +623,18 @@ export function validatePluginDir(absDir, { strict = false } = {}) {
592
623
 
593
624
  // workflows/*.json are v2 GRAPHS (API 3), validated by the SAME shared
594
625
  // validator the composer and POST /api/workflows use, over a ports function
595
- // built from this plugin's OWN sidecars plus the engine's flow-card ports.
596
- // The isolation rule stays: a template may reference only keys this plugin
597
- // ships, so a host rename or a deleted user agent can never break it.
626
+ // built from the BUILT-IN sidecars plus this plugin's OWN, plus the engine's
627
+ // flow-card ports. The isolation rule (#421): a template may reference
628
+ // built-ins (immutable, present on every host) and keys this plugin ships —
629
+ // never the user layer or another plugin — so a host rename or a deleted
630
+ // user agent can never break it. The plugin's own sidecar wins the index on
631
+ // a key it shares with a built-in (the registry drops such a plugin agent at
632
+ // load; the template still validates against the ports it will run with).
598
633
  const wfDir = join(absDir, 'workflows');
599
634
  if (existsSync(wfDir)) {
600
- const portsFn = portsFnFor(indexByKey(ownMetas));
635
+ const hostMetas = Array.isArray(builtinMetas) ? builtinMetas : builtinAgentMetas();
636
+ const hostKeys = new Set(hostMetas.map((m) => m.key));
637
+ const portsFn = portsFnFor(indexByKey([...hostMetas, ...ownMetas]));
601
638
  for (const f of readdirSync(wfDir).filter((x) => x.endsWith('.json'))) {
602
639
  let tpl = null;
603
640
  try { tpl = JSON.parse(readFileSync(join(wfDir, f), 'utf8')); }
@@ -607,14 +644,14 @@ export function validatePluginDir(absDir, { strict = false } = {}) {
607
644
  const keys = nodes.filter((n) => n && n.kind === 'agent').map((n) => n.key).filter(Boolean);
608
645
  let unresolved = false;
609
646
  for (const k of new Set(keys)) {
610
- if (agentKeys.has(k)) continue;
647
+ if (agentKeys.has(k) || hostKeys.has(k)) continue;
611
648
  if (ungatedKeys.has(k)) {
612
649
  // The sidecar is this plugin's, it just did not pass. Report at the
613
650
  // DATA level so an API-1 plugin keeps installing (spec §9) instead of
614
651
  // being refused for a template that is fine.
615
652
  push(dataLevel, `workflows/${f}: references agent key "${k}" whose sidecar is not a valid meta v2 sidecar`);
616
653
  } else {
617
- push('error', `workflows/${f}: references agent key "${k}" which this plugin does not ship`);
654
+ push('error', `workflows/${f}: references agent key "${k}" which is neither a built-in nor shipped by this plugin`);
618
655
  }
619
656
  unresolved = true;
620
657
  }
@@ -3641,6 +3641,13 @@ export class RunHarness extends EventEmitter {
3641
3641
  signal: this.abort.signal,
3642
3642
  bin: this.claude.bin,
3643
3643
  mock: this.claude.mock,
3644
+ // The run's own model is the title default (#422, title.mjs#resolveTitleModel):
3645
+ // an install with no first-party model titles its runs with no setup.
3646
+ runModel: this.claude.model,
3647
+ // A failed title used to vanish into a kept provisional title. Say so in
3648
+ // the run log — once per run, there is only ever one title call.
3649
+ onError: ({ model, error }) => this._log('orchestrator', 'warn',
3650
+ `title generation failed (model ${model}): ${clipMiddle(error?.message || error, 300)} — keeping the provisional title`),
3644
3651
  // Same env policy as the pipeline nodes. Both undefined on an unconfigured
3645
3652
  // project ⇒ byte-identical spawn env (legacy parity).
3646
3653
  envScrub: this.guardrails?.envScrub || undefined,
@@ -543,8 +543,69 @@ export const SETTINGS_POST_KEYS = Object.freeze([
543
543
  'pipelineCostLimitUsd', 'totalCostLimitUsd', 'costLimitResetPeriod',
544
544
  'askMaxTurns', 'askMaxBudgetUsd',
545
545
  'debugSpawnEnabled',
546
+ 'titleModel', 'hideBuiltinModels',
546
547
  ]);
547
548
 
549
+ // ── Title-generation model + hidden built-ins (#422) ─────────────────────────
550
+ // `titleModel` is the catalog id every run/chat title is written with; absent
551
+ // means "the model of the run or chat that asked for the title" (title.mjs owns
552
+ // that precedence, and the catalog check — settings.mjs cannot import config.mjs).
553
+ // `hideBuiltinModels` drops the first-party built-ins from every model picker
554
+ // for an install with no first-party account; it is cosmetic + defaults only,
555
+ // a hidden id still resolves (config.mjs#composeCatalog). Both are read at use
556
+ // time like every other stored setting — a UI save reaches the next title call.
557
+ export const DEFAULT_HIDE_BUILTIN_MODELS = false;
558
+ const TITLE_MODEL_MAX_LEN = 200;
559
+
560
+ const isTitleModelId = (v) => typeof v === 'string' && v.trim().length > 0 && v.length <= TITLE_MODEL_MAX_LEN;
561
+
562
+ /** The STORED title model id (trimmed), or null when unset/invalid (loudly). */
563
+ export function titleModel() {
564
+ const v = readSettings().titleModel;
565
+ if (v === undefined) return null;
566
+ if (isTitleModelId(v)) return v.trim();
567
+ console.warn(`[worca] invalid titleModel ${JSON.stringify(v)} — titles use the run's model`);
568
+ return null;
569
+ }
570
+
571
+ /** @throws {Error} unless `input` is a non-empty model id (or empty, which clears). */
572
+ export function assertTitleModelInput(input) {
573
+ if (isClearInput(input)) return;
574
+ if (!isTitleModelId(input)) throw new Error(`titleModel must be a model id of at most ${TITLE_MODEL_MAX_LEN} characters, or empty to use the run's model`);
575
+ }
576
+
577
+ export async function setTitleModel(input) {
578
+ assertTitleModelInput(input);
579
+ const settings = readSettings();
580
+ if (isClearInput(input)) delete settings.titleModel;
581
+ else settings.titleModel = input.trim();
582
+ await persistSettings(settings);
583
+ return { titleModel: titleModel() };
584
+ }
585
+
586
+ /** Whether built-in (first-party) models are hidden from every picker. */
587
+ export function hideBuiltinModels() {
588
+ const v = readSettings().hideBuiltinModels;
589
+ if (v === undefined) return DEFAULT_HIDE_BUILTIN_MODELS;
590
+ if (typeof v === 'boolean') return v;
591
+ console.warn(`[worca] invalid hideBuiltinModels ${JSON.stringify(v)} — using the default (${DEFAULT_HIDE_BUILTIN_MODELS})`);
592
+ return DEFAULT_HIDE_BUILTIN_MODELS;
593
+ }
594
+
595
+ /** @throws {Error} unless `input` is a boolean. */
596
+ export function assertHideBuiltinModelsInput(input) {
597
+ if (typeof input !== 'boolean') throw new Error('hideBuiltinModels must be true or false');
598
+ }
599
+
600
+ export async function setHideBuiltinModels(input) {
601
+ assertHideBuiltinModelsInput(input);
602
+ const settings = readSettings();
603
+ if (input === DEFAULT_HIDE_BUILTIN_MODELS) delete settings.hideBuiltinModels;
604
+ else settings.hideBuiltinModels = input;
605
+ await persistSettings(settings);
606
+ return { hideBuiltinModels: hideBuiltinModels() };
607
+ }
608
+
548
609
  // ── Spawn-debug diagnostics toggle (the stored side of WORCA_DEBUG_SPAWN) ────
549
610
  // Like every other stored setting (skillMount, the cost caps, the ask caps) this
550
611
  // is READ AT USE TIME: claude-runner.mjs#debugSpawnEnabled calls
@@ -1,12 +1,62 @@
1
1
  // src/core/title.mjs
2
2
  import { runClaude } from './claude-runner.mjs';
3
- import { resolveModelEnv } from './config.mjs';
3
+ import { resolveModelEnv, catalogHasModel } from './config.mjs';
4
+ import { titleModel as storedTitleModel } from './settings.mjs';
5
+ import { AUX_EFFORT } from './model-env.mjs';
4
6
 
5
- // A fast, cheap model is enough for a one-line summary. Overridable for tests/cost tuning.
6
- const DEFAULT_TITLE_MODEL =
7
- process.env.WORCA_TITLE_MODEL || 'claude-haiku-4-5-20251001';
7
+ // The last-resort title model: the BUILT-IN Haiku id (config.mjs PREDEFINED_MODELS),
8
+ // not the dated API id it used to be — a global entry that shadows the built-in
9
+ // (to route it) must match, or its routing env would never reach a title call.
10
+ export const DEFAULT_TITLE_MODEL = 'claude-haiku-4-5';
8
11
  const MAX_LEN = 70;
9
12
 
13
+ /**
14
+ * Which model writes a title, decided PER CALL (#422) — nothing is captured at
15
+ * import, so a Settings save or an env change reaches the very next title:
16
+ * 1. `opts.model` — an explicit caller choice
17
+ * 2. WORCA_TITLE_MODEL — a non-empty env override (tests, cost tuning);
18
+ * verbatim, no catalog check: an operator escape hatch
19
+ * 3. the stored `titleModel` — only while it is still a catalog member; a stale
20
+ * id (plugin removed, entry deleted) is reported and skipped
21
+ * 4. `opts.runModel` — the model of the run / chat that asked for the title,
22
+ * which is what makes an install with NO first-party
23
+ * model produce titles with zero configuration
24
+ * 5. DEFAULT_TITLE_MODEL — the built-in Haiku
25
+ * Pure apart from the injectable readers. Exported for tests and for the
26
+ * settings API (describeTitleModel below).
27
+ * @param {{model?:string, runModel?:string}} [opts]
28
+ * @param {{env?:NodeJS.ProcessEnv, stored?:()=>string|null, inCatalog?:(id:string)=>boolean}} [deps]
29
+ * @returns {{model:string, source:'explicit'|'env'|'settings'|'run'|'builtin', stale:string|null}}
30
+ */
31
+ export function resolveTitleModel(opts = {}, { env = process.env, stored = storedTitleModel, inCatalog = catalogHasModel } = {}) {
32
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : '');
33
+ const explicit = str(opts.model);
34
+ if (explicit) return { model: explicit, source: 'explicit', stale: null };
35
+ const fromEnv = str(env.WORCA_TITLE_MODEL);
36
+ if (fromEnv) return { model: fromEnv, source: 'env', stale: null };
37
+ let stale = null;
38
+ const configured = str(stored());
39
+ if (configured) {
40
+ if (inCatalog(configured)) return { model: configured, source: 'settings', stale: null };
41
+ stale = configured;
42
+ }
43
+ const runModel = str(opts.runModel);
44
+ if (runModel) return { model: runModel, source: 'run', stale };
45
+ return { model: DEFAULT_TITLE_MODEL, source: 'builtin', stale };
46
+ }
47
+
48
+ /**
49
+ * What the Settings card shows: the stored id, whether the environment
50
+ * overrides it, and a stale stored id that no longer resolves. `model` is
51
+ * null when titles follow the run's model (the default).
52
+ * @returns {{model:string|null, source:'env'|'settings'|'run', stale:string|null}}
53
+ */
54
+ export function describeTitleModel(deps) {
55
+ const r = resolveTitleModel({}, deps);
56
+ if (r.source === 'env' || r.source === 'settings') return { model: r.model, source: r.source, stale: null };
57
+ return { model: null, source: 'run', stale: r.stale };
58
+ }
59
+
10
60
  const SYSTEM = [
11
61
  'You write a SHORT, human-readable title for a software task.',
12
62
  'Rules: 3–8 words, Title Case-ish, no trailing period, no quotes, no markdown,',
@@ -60,24 +110,34 @@ export function isRefusalTitle(t) {
60
110
  * failure/abort/empty input so the caller keeps the provisional title.
61
111
  * `permissionMode` (default 'acceptEdits') exists for Ask Worca: its title call
62
112
  * passes 'dontAsk' so the mock dispatcher can never reach a file-writing role.
113
+ * `runModel` is the model of the run/chat asking (resolveTitleModel step 4).
114
+ * `onError` fires ONCE when the call yields no usable title for any reason but
115
+ * an abort (a spawn failure, a refusal, an empty reply) — the caller logs it on
116
+ * its own channel; the return value stays '' so every existing caller is unchanged.
63
117
  * @param {string} prompt
64
- * @param {{cwd:string, signal?:AbortSignal, model?:string, bin?:string, mock?:boolean, envScrub?:boolean, envAllowlist?:string[], tools?:string[], strictMcpConfig?:boolean, settingSources?:string[], disableSlashCommands?:boolean, mcpConfigPath?:string, permissionMode?:string}} opts
118
+ * @param {{cwd:string, signal?:AbortSignal, model?:string, runModel?:string, onError?:(info:{model:string, error:Error})=>void, bin?:string, mock?:boolean, envScrub?:boolean, envAllowlist?:string[], tools?:string[], strictMcpConfig?:boolean, settingSources?:string[], disableSlashCommands?:boolean, mcpConfigPath?:string, permissionMode?:string}} opts
65
119
  * @returns {Promise<string>}
66
120
  */
67
121
  export async function generateTitle(prompt, opts = {}) {
68
122
  const text = String(prompt || '').trim();
69
123
  if (!text) return '';
124
+ const { model, stale } = resolveTitleModel(opts);
125
+ if (stale) console.warn(`[worca] titleModel ${JSON.stringify(stale)} is no longer in the catalog — titles use ${model}`);
126
+ const report = (error) => {
127
+ if (typeof opts.onError !== 'function') return;
128
+ try { opts.onError({ model, error }); } catch { /* a logging sink must never fail the caller */ }
129
+ };
70
130
  try {
71
131
  const { text: out } = await runClaude({
72
132
  cwd: opts.cwd || process.cwd(),
73
133
  systemPrompt: SYSTEM,
74
134
  prompt: `Write the title for this task:\n\n${text.slice(0, 4000)}`,
75
- model: opts.model || DEFAULT_TITLE_MODEL,
135
+ model,
76
136
  // Aux calls keep their model choice but still route through the catalog's
77
137
  // env (design §4.8) — a global entry matching this id carries its routing
78
138
  // env everywhere the id is used.
79
- modelEnv: resolveModelEnv(opts.model || DEFAULT_TITLE_MODEL),
80
- effort: 'low',
139
+ modelEnv: resolveModelEnv(model),
140
+ effort: AUX_EFFORT,
81
141
  permissionMode: opts.permissionMode || 'acceptEdits',
82
142
  allowedTools: [], // empty → no --allowedTools flag → claude defaults; pure text gen
83
143
  signal: opts.signal,
@@ -103,9 +163,12 @@ export async function generateTitle(prompt, opts = {}) {
103
163
  onEvent: () => {},
104
164
  });
105
165
  const title = sanitizeTitle(out);
106
- return isRefusalTitle(title) ? '' : title;
166
+ if (!title) { report(new Error('the model returned an empty reply')); return ''; }
167
+ if (isRefusalTitle(title)) { report(new Error(`the model did not write a title: ${title.slice(0, 120)}`)); return ''; }
168
+ return title;
107
169
  } catch (err) {
108
170
  if (err && err.name === 'AbortError') return ''; // run was stopped — caller keeps provisional
171
+ report(err instanceof Error ? err : new Error(String(err)));
109
172
  return '';
110
173
  }
111
174
  }
@@ -20,6 +20,7 @@
20
20
  // when the token is unavailable (file missing, or a server too old to have one).
21
21
 
22
22
  import fs from 'node:fs';
23
+ import http from 'node:http';
23
24
  import fsp from 'node:fs/promises';
24
25
  import process from 'node:process';
25
26
  import { join } from 'node:path';
@@ -106,7 +107,7 @@ function dialHost(host) {
106
107
  return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
107
108
  }
108
109
 
109
- /** Every error code nested in a fetch failure (Node wraps them in `cause`, sometimes an AggregateError). */
110
+ /** Every error code nested in a request failure (Node wraps them in `cause`, sometimes an AggregateError). */
110
111
  function errorCodes(err) {
111
112
  const out = new Set();
112
113
  const walk = (e, depth) => {
@@ -119,12 +120,38 @@ function errorCodes(err) {
119
120
  return out;
120
121
  }
121
122
 
123
+ /**
124
+ * One-shot HTTP call on a throwaway connection. NOT fetch(): the lifecycle verbs
125
+ * call process.exit() right after a probe, and undici's pooled keep-alive socket is
126
+ * still closing at that moment — on Windows libuv asserts on it
127
+ * (`!(handle->flags & UV_HANDLE_CLOSING)`, src/win/async.c) and the CLI dies with
128
+ * 0xC0000409 instead of its exit code, after printing the right message. A bare
129
+ * `agent: false` + `Connection: close` request leaves nothing behind to tear down.
130
+ * Rejects with the socket error (its `code` intact, e.g. ECONNREFUSED) or the
131
+ * signal's AbortError.
132
+ */
133
+ function request(url, { method = 'GET', headers = {}, signal } = {}) {
134
+ return new Promise((resolve, reject) => {
135
+ const req = http.request(url, { method, agent: false, signal, headers: { ...headers, connection: 'close' } }, (res) => {
136
+ const chunks = [];
137
+ res.on('data', (c) => chunks.push(c));
138
+ res.on('error', reject);
139
+ res.on('end', () => resolve({
140
+ status: res.statusCode, ok: res.statusCode >= 200 && res.statusCode < 300,
141
+ text: Buffer.concat(chunks).toString('utf8'),
142
+ }));
143
+ });
144
+ req.on('error', reject);
145
+ req.end();
146
+ });
147
+ }
148
+
122
149
  /** GET a JSON object from the UI, or null (non-2xx, non-JSON, non-object). Network errors propagate. */
123
150
  async function getJson(url, signal) {
124
- const res = await fetch(url, { signal, headers: { accept: 'application/json' } });
151
+ const res = await request(url, { signal, headers: { accept: 'application/json' } });
125
152
  if (!res.ok) return null;
126
153
  try {
127
- const data = await res.json();
154
+ const data = JSON.parse(res.text);
128
155
  return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
129
156
  } catch {
130
157
  return null;
@@ -212,7 +239,7 @@ export async function stopUi({ host = DEFAULT_UI_HOST, port = DEFAULT_UI_PORT, t
212
239
 
213
240
  if (bearer) {
214
241
  try {
215
- const res = await fetch(`http://${dialHost(host)}:${port}/api/shutdown`, {
242
+ const res = await request(`http://${dialHost(host)}:${port}/api/shutdown`, {
216
243
  method: 'POST',
217
244
  headers: { authorization: `Bearer ${bearer}`, accept: 'application/json' },
218
245
  signal: AbortSignal.timeout(3000),