@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 +36 -2
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +358 -19
- package/src/core/ask/models.mjs +7 -1
- package/src/core/ask/turn.mjs +5 -0
- package/src/core/config.mjs +33 -4
- package/src/core/folder-dialog.mjs +21 -12
- package/src/core/marketplaces.mjs +13 -0
- package/src/core/model-env.mjs +41 -0
- package/src/core/model-test.mjs +2 -1
- package/src/core/plugin-manifest.mjs +45 -8
- package/src/core/run-harness.mjs +7 -0
- package/src/core/settings.mjs +61 -0
- package/src/core/title.mjs +72 -9
- package/src/core/ui-instance.mjs +262 -0
- package/src/core/workflow-export.mjs +1169 -0
- package/src/core/workflow-share.mjs +191 -0
- package/src/core/workflows.mjs +35 -4
- package/src/shared/graph/validate.mjs +5 -3
- package/ui/public/app.js +653 -22
- package/ui/public/ask-panel.mjs +4 -2
- package/ui/public/export-slug.mjs +30 -0
- package/ui/public/graph/composer.mjs +24 -3
- package/ui/public/graph/inspector.mjs +4 -1
- package/ui/public/index.html +127 -1
- package/ui/public/models-view.mjs +34 -3
- package/ui/public/style.css +65 -5
- package/ui/server.mjs +305 -90
package/src/core/config.mjs
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
import { getDb, prepare, tx } from './db.mjs';
|
|
16
16
|
import { projectKey } from './store.mjs';
|
|
17
17
|
import { loadAgentRegistry, registryToSteps } from './agent-registry.mjs';
|
|
18
|
-
import { EFFORTS, prepareModelEnv, isSubagentModelValue, subagentModelIssue } from './model-env.mjs';
|
|
19
|
-
import { listGlobalModels, addGlobalModel, removeGlobalModel } from './settings.mjs';
|
|
18
|
+
import { EFFORTS, prepareModelEnv, withTierModelEnv, isSubagentModelValue, subagentModelIssue } from './model-env.mjs';
|
|
19
|
+
import { listGlobalModels, addGlobalModel, removeGlobalModel, hideBuiltinModels } from './settings.mjs';
|
|
20
20
|
import { listPluginModels, allPluginModels, flattenPluginModelEnv } from './plugin-models.mjs';
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -184,6 +184,11 @@ function composeCatalog(projectCustom = []) {
|
|
|
184
184
|
const seen = new Set();
|
|
185
185
|
const unreliable = (lc) => (flagged.has(lc) ? { costUnreliable: true } : {});
|
|
186
186
|
const routedOf = (env) => !!(env && 'ANTHROPIC_BASE_URL' in env);
|
|
187
|
+
// "Hide built-in models" (#422): a cosmetic flag on the UNSHADOWED built-ins,
|
|
188
|
+
// read by every picker. The entry stays in the catalog — hiding an id must
|
|
189
|
+
// never stop it resolving, or a stored run / plugin reference that names it
|
|
190
|
+
// would break — so pickers skip `hidden`, validators ignore it.
|
|
191
|
+
const hidden = hideBuiltinModels() ? { hidden: true } : {};
|
|
187
192
|
const pluginShape = (id, m, lc) => ({
|
|
188
193
|
id, label: m.label, efforts: [...m.efforts], custom: 'plugin', plugin: m.plugin,
|
|
189
194
|
hasEnv: !!m.env, routed: routedOf(m.env), ...unreliable(lc),
|
|
@@ -196,7 +201,7 @@ function composeCatalog(projectCustom = []) {
|
|
|
196
201
|
? { id: m.id, label: shadow.label, efforts: [...shadow.efforts], custom: 'global', hasEnv: !!shadow.env, routed: routedOf(shadow.env), ...unreliable(lc) }
|
|
197
202
|
: pshadow
|
|
198
203
|
? pluginShape(m.id, pshadow, lc)
|
|
199
|
-
: { ...m, custom: false, hasEnv: false, routed: false });
|
|
204
|
+
: { ...m, custom: false, hasEnv: false, routed: false, ...hidden });
|
|
200
205
|
seen.add(lc);
|
|
201
206
|
}
|
|
202
207
|
for (const m of globals) {
|
|
@@ -486,10 +491,12 @@ export function resolveModelEnv(modelId) {
|
|
|
486
491
|
const lc = id.toLowerCase();
|
|
487
492
|
let rawEnv;
|
|
488
493
|
let who;
|
|
494
|
+
let canonicalId = id;
|
|
489
495
|
const entry = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
|
|
490
496
|
if (entry && entry.env) {
|
|
491
497
|
rawEnv = entry.env;
|
|
492
498
|
who = JSON.stringify(entry.id);
|
|
499
|
+
canonicalId = entry.id;
|
|
493
500
|
} else if (!entry) {
|
|
494
501
|
const pm = listPluginModels().find((m) => m.id.toLowerCase() === lc);
|
|
495
502
|
if (pm && pm.env) {
|
|
@@ -499,6 +506,7 @@ export function resolveModelEnv(modelId) {
|
|
|
499
506
|
}
|
|
500
507
|
rawEnv = env;
|
|
501
508
|
who = `${JSON.stringify(pm.id)} (plugin "${pm.plugin}")`;
|
|
509
|
+
canonicalId = pm.id;
|
|
502
510
|
}
|
|
503
511
|
}
|
|
504
512
|
if (!rawEnv) return undefined;
|
|
@@ -506,7 +514,27 @@ export function resolveModelEnv(modelId) {
|
|
|
506
514
|
for (const k of dropped) {
|
|
507
515
|
console.warn(`[worca] model ${who}: dropping env key ${JSON.stringify(k)} (reserved or unresolvable \${VAR} ref)`);
|
|
508
516
|
}
|
|
509
|
-
|
|
517
|
+
if (!Object.keys(env).length) return undefined;
|
|
518
|
+
// Endpoint-routed entries also carry the CLI's internal tier keys, pointed at
|
|
519
|
+
// this entry's own wire id (#422, model-env.mjs#withTierModelEnv) — so the
|
|
520
|
+
// CLI's session-title / alias / probe calls never fall back to a first-party
|
|
521
|
+
// id the endpoint has never heard of. Keys the entry sets itself win.
|
|
522
|
+
return withTierModelEnv(env, canonicalId);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Whether `modelId` names a catalog member — built-in, global, or plugin —
|
|
527
|
+
* regardless of the hide-built-ins flag (hidden entries still resolve).
|
|
528
|
+
* Case-insensitive like every other id lookup here. Synchronous; never throws.
|
|
529
|
+
* @param {string} modelId
|
|
530
|
+
*/
|
|
531
|
+
export function catalogHasModel(modelId) {
|
|
532
|
+
const id = typeof modelId === 'string' ? modelId.trim() : '';
|
|
533
|
+
if (!id) return false;
|
|
534
|
+
const lc = id.toLowerCase();
|
|
535
|
+
return PREDEFINED_MODELS.some((m) => m.id.toLowerCase() === lc)
|
|
536
|
+
|| listGlobalModels().some((m) => m.id.toLowerCase() === lc)
|
|
537
|
+
|| listPluginModels().some((m) => m.id.toLowerCase() === lc);
|
|
510
538
|
}
|
|
511
539
|
|
|
512
540
|
/**
|
|
@@ -990,6 +1018,7 @@ export async function setActiveWorkflow(projectDir, workflowId) {
|
|
|
990
1018
|
* @returns {Promise<{nodes:Record<string,object>,feedbacks:Record<string,{maxCycles:number}>}>}
|
|
991
1019
|
*/
|
|
992
1020
|
export async function resolveRunConfig(projectDir, workflowId) {
|
|
1021
|
+
if (!projectDir) return { nodes: {}, wires: {}, feedbacks: {} }; // defaults-only (global export); avoids projectKey(null). v2 shape incl. wires.
|
|
993
1022
|
const wf = readWorkflowsMap(projectKey(projectDir))[workflowId] || {};
|
|
994
1023
|
return {
|
|
995
1024
|
nodes: wf.nodes && typeof wf.nodes === 'object' ? wf.nodes : {},
|
|
@@ -20,7 +20,14 @@
|
|
|
20
20
|
|
|
21
21
|
import { spawn } from 'node:child_process';
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// The dialog title, by PURPOSE. A closed set, never caller text: the string is
|
|
24
|
+
// interpolated into an osascript / PowerShell command line.
|
|
25
|
+
const PROMPTS = Object.freeze({
|
|
26
|
+
project: 'Select a project folder',
|
|
27
|
+
export: 'Select the project folder to export into',
|
|
28
|
+
plugin: 'Select the plugin folder',
|
|
29
|
+
});
|
|
30
|
+
const promptFor = (purpose) => PROMPTS[purpose] || PROMPTS.project;
|
|
24
31
|
// A dialog waits on a human; give it a long leash, then kill the process so a
|
|
25
32
|
// forgotten dialog cannot pin server resources forever.
|
|
26
33
|
const DIALOG_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -75,20 +82,22 @@ export const _testing = {
|
|
|
75
82
|
/**
|
|
76
83
|
* Open the platform's native folder picker and wait for the user.
|
|
77
84
|
* Serialized: while one dialog is open, further calls resolve { status:'busy' }.
|
|
85
|
+
* @param {{purpose?: 'project'|'export'|'plugin'}} [opts] picks the dialog title
|
|
78
86
|
* @returns {Promise<{status:'picked', path:string} | {status:'canceled'}
|
|
79
87
|
* | {status:'unsupported'} | {status:'busy'}>}
|
|
80
88
|
*/
|
|
81
|
-
export async function pickFolderNative() {
|
|
89
|
+
export async function pickFolderNative({ purpose } = {}) {
|
|
82
90
|
if (_inFlight) return { status: 'busy' };
|
|
83
91
|
_inFlight = true;
|
|
84
92
|
try {
|
|
85
93
|
const platform = _ov.platform || process.platform;
|
|
86
94
|
const env = _ov.env || process.env;
|
|
87
95
|
const run = _ov.runner || defaultRun;
|
|
96
|
+
const prompt = promptFor(purpose);
|
|
88
97
|
if ((env.WORCA_NO_NATIVE_DIALOG || '') === '1') return { status: 'unsupported' };
|
|
89
|
-
if (platform === 'darwin') return await pickMac(run);
|
|
90
|
-
if (platform === 'win32') return await pickWindows(run);
|
|
91
|
-
if (platform === 'linux') return await pickLinux(run, env);
|
|
98
|
+
if (platform === 'darwin') return await pickMac(run, prompt);
|
|
99
|
+
if (platform === 'win32') return await pickWindows(run, prompt);
|
|
100
|
+
if (platform === 'linux') return await pickLinux(run, env, prompt);
|
|
92
101
|
return { status: 'unsupported' };
|
|
93
102
|
} finally {
|
|
94
103
|
_inFlight = false;
|
|
@@ -101,10 +110,10 @@ function pickedOrCanceled(stdoutRaw) {
|
|
|
101
110
|
return path ? { status: 'picked', path } : { status: 'canceled' };
|
|
102
111
|
}
|
|
103
112
|
|
|
104
|
-
async function pickMac(run) {
|
|
113
|
+
async function pickMac(run, prompt) {
|
|
105
114
|
const r = await run('osascript', [
|
|
106
115
|
'-e', 'tell application "System Events" to activate',
|
|
107
|
-
'-e', `POSIX path of (choose folder with prompt "${
|
|
116
|
+
'-e', `POSIX path of (choose folder with prompt "${prompt}")`,
|
|
108
117
|
]);
|
|
109
118
|
if (r.ok) return pickedOrCanceled(r.stdout);
|
|
110
119
|
// `choose folder` cancel: exit 1 + "execution error: User canceled. (-128)"
|
|
@@ -112,11 +121,11 @@ async function pickMac(run) {
|
|
|
112
121
|
return { status: 'unsupported' }; // no GUI session, automation denied, ...
|
|
113
122
|
}
|
|
114
123
|
|
|
115
|
-
async function pickWindows(run) {
|
|
124
|
+
async function pickWindows(run, prompt) {
|
|
116
125
|
const script =
|
|
117
126
|
'Add-Type -AssemblyName System.Windows.Forms | Out-Null; ' +
|
|
118
127
|
'$d = New-Object System.Windows.Forms.FolderBrowserDialog; ' +
|
|
119
|
-
`$d.Description = '${
|
|
128
|
+
`$d.Description = '${prompt}'; $d.ShowNewFolderButton = $true; ` +
|
|
120
129
|
"if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.WriteLine($d.SelectedPath) }";
|
|
121
130
|
const r = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script]);
|
|
122
131
|
if (!r.ok) return { status: 'unsupported' };
|
|
@@ -125,13 +134,13 @@ async function pickWindows(run) {
|
|
|
125
134
|
return path ? { status: 'picked', path } : { status: 'canceled' };
|
|
126
135
|
}
|
|
127
136
|
|
|
128
|
-
async function pickLinux(run, env) {
|
|
137
|
+
async function pickLinux(run, env, prompt) {
|
|
129
138
|
if (!env.DISPLAY && !env.WAYLAND_DISPLAY) return { status: 'unsupported' };
|
|
130
|
-
const zen = await run('zenity', ['--file-selection', '--directory', `--title=${
|
|
139
|
+
const zen = await run('zenity', ['--file-selection', '--directory', `--title=${prompt}`]);
|
|
131
140
|
if (zen.ok) return pickedOrCanceled(zen.stdout);
|
|
132
141
|
if (zen.code === 1 && !zen.timedOut) return { status: 'canceled' }; // user closed it
|
|
133
142
|
// zenity missing (spawn error -> code -1) or broken: try kdialog.
|
|
134
|
-
const kd = await run('kdialog', ['--title',
|
|
143
|
+
const kd = await run('kdialog', ['--title', prompt, '--getexistingdirectory', env.HOME || '/']);
|
|
135
144
|
if (kd.ok) return pickedOrCanceled(kd.stdout);
|
|
136
145
|
if (kd.code === 1 && !kd.timedOut) return { status: 'canceled' };
|
|
137
146
|
return { status: 'unsupported' };
|
|
@@ -122,6 +122,19 @@ async function syncEntry(entry, { exec } = {}) {
|
|
|
122
122
|
export async function addMarketplace(url, { exec } = {}) {
|
|
123
123
|
const norm = normalizeMarketplaceUrl(url);
|
|
124
124
|
if (!norm) throw Object.assign(new Error('marketplace url is required'), { code: 'BAD_REQUEST' });
|
|
125
|
+
// A plain PLUGIN folder — a worca-cc-plugin.json, no marketplace manifest, and
|
|
126
|
+
// NOT a git repository (a git repo whose root is a plugin IS a marketplace:
|
|
127
|
+
// the fallback scan discovers it at depth 0) — is not a marketplace. It is the
|
|
128
|
+
// natural thing to paste after `worca workflow export --format plugin`. Say
|
|
129
|
+
// so, name the fix, and carry the dir so the server route can link it on the
|
|
130
|
+
// user's behalf (code PLUGIN_FOLDER).
|
|
131
|
+
if (!/^[a-z+]+:\/\//i.test(norm) && !/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:/.test(norm)
|
|
132
|
+
&& existsSync(join(norm, 'worca-cc-plugin.json')) && !existsSync(join(norm, 'worca-cc-marketplace.json'))
|
|
133
|
+
&& !existsSync(join(norm, '.git'))) {
|
|
134
|
+
throw Object.assign(
|
|
135
|
+
new Error(`${norm} is a single plugin folder, not a marketplace — link it instead: worca plugin link ${norm}`),
|
|
136
|
+
{ code: 'PLUGIN_FOLDER', dir: norm });
|
|
137
|
+
}
|
|
125
138
|
const id = marketplaceId(norm);
|
|
126
139
|
if (readMarketplaces().marketplaces[id]) { // cheap pre-check: fail before any git work
|
|
127
140
|
throw Object.assign(new Error(`marketplace already added: ${norm}`), { code: 'EXISTS' });
|
package/src/core/model-env.mjs
CHANGED
|
@@ -17,6 +17,47 @@
|
|
|
17
17
|
* without importing the core graph. */
|
|
18
18
|
export const EFFORTS = ['medium', 'high', 'xhigh', 'max'];
|
|
19
19
|
|
|
20
|
+
// The effort worca's own auxiliary calls run at (title generation, the Models
|
|
21
|
+
// view Test button). Deliberately BELOW the pipeline list: the CLI accepts
|
|
22
|
+
// `--effort low` (claude --help, 2.1.259) and a one-line summary needs nothing
|
|
23
|
+
// more, so the cheapest tier is the right one. EFFORTS omits it on purpose —
|
|
24
|
+
// pipeline nodes are not offered `low` — which is why it lives here as its own
|
|
25
|
+
// constant instead of being clamped into that list (#422).
|
|
26
|
+
export const AUX_EFFORT = 'low';
|
|
27
|
+
|
|
28
|
+
// Claude Code resolves its OWN internal calls — session titles, the alias tiers
|
|
29
|
+
// a Task `model: haiku|sonnet|opus|fable` expands to, quota probes — through
|
|
30
|
+
// these keys. A catalog entry that routes to a custom endpoint (ANTHROPIC_BASE_URL)
|
|
31
|
+
// routinely sets ANTHROPIC_MODEL and nothing else, so the CLI falls back to
|
|
32
|
+
// first-party ids against an endpoint that does not serve them (the
|
|
33
|
+
// `unrecognized_model` noise in run logs). withTierModelEnv fills every one the
|
|
34
|
+
// entry left unset with the entry's own wire id (#422). ANTHROPIC_SMALL_FAST_MODEL
|
|
35
|
+
// is the pre-DEFAULT_HAIKU spelling older CLIs still read.
|
|
36
|
+
export const TIER_MODEL_ENV_KEYS = Object.freeze([
|
|
37
|
+
'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
38
|
+
'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_FABLE_MODEL',
|
|
39
|
+
'ANTHROPIC_SMALL_FAST_MODEL',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The tier keys an endpoint-routed model env should carry, synthesized from
|
|
44
|
+
* the env's wire id. Pure: a non-routed env (no ANTHROPIC_BASE_URL) comes back
|
|
45
|
+
* untouched, an explicit key in the env is never overwritten, and with no wire
|
|
46
|
+
* id to point at (no ANTHROPIC_MODEL and no model id) nothing is added.
|
|
47
|
+
* @param {Record<string,string>|undefined} env a PREPARED model env
|
|
48
|
+
* @param {string} [modelId] the catalog id — the wire id when ANTHROPIC_MODEL is unset (#374)
|
|
49
|
+
* @returns {Record<string,string>|undefined}
|
|
50
|
+
*/
|
|
51
|
+
export function withTierModelEnv(env, modelId) {
|
|
52
|
+
if (!env || typeof env !== 'object' || !('ANTHROPIC_BASE_URL' in env)) return env;
|
|
53
|
+
const wire = (typeof env.ANTHROPIC_MODEL === 'string' && env.ANTHROPIC_MODEL.trim())
|
|
54
|
+
|| (typeof modelId === 'string' ? modelId.trim() : '');
|
|
55
|
+
if (!wire) return env;
|
|
56
|
+
const out = { ...env };
|
|
57
|
+
for (const k of TIER_MODEL_ENV_KEYS) if (!(k in out)) out[k] = wire;
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
20
61
|
// Env keys a model entry may NOT set (§4.4): process fundamentals and worca's
|
|
21
62
|
// own runtime knobs, any of which injection could otherwise subvert (mock
|
|
22
63
|
// mode, the claude binary path, the effort flag name). Everything else —
|
package/src/core/model-test.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { runClaude } from './claude-runner.mjs';
|
|
9
9
|
import { resolveModelEnv } from './config.mjs';
|
|
10
|
+
import { AUX_EFFORT } from './model-env.mjs';
|
|
10
11
|
import { classifyError } from './recoverable-error.mjs';
|
|
11
12
|
|
|
12
13
|
const TEST_TIMEOUT_MS = 60_000;
|
|
@@ -52,7 +53,7 @@ export async function testModel(id, { signal, bin, run = runClaude } = {}) {
|
|
|
52
53
|
prompt: 'Reply with exactly OK.',
|
|
53
54
|
model: id,
|
|
54
55
|
modelEnv: resolveModelEnv(id),
|
|
55
|
-
effort:
|
|
56
|
+
effort: AUX_EFFORT,
|
|
56
57
|
permissionMode: 'acceptEdits',
|
|
57
58
|
allowedTools: [], // empty → no --allowedTools flag; pure text gen
|
|
58
59
|
signal: ctrl.signal,
|
|
@@ -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
|
-
|
|
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
|
|
596
|
-
// The isolation rule
|
|
597
|
-
//
|
|
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
|
|
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
|
|
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
|
}
|
package/src/core/run-harness.mjs
CHANGED
|
@@ -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,
|
package/src/core/settings.mjs
CHANGED
|
@@ -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
|
package/src/core/title.mjs
CHANGED
|
@@ -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
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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(
|
|
80
|
-
effort:
|
|
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
|
-
|
|
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
|
}
|