@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.
- package/README.md +21 -1
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +183 -8
- 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 +31 -4
- 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 +675 -23
- package/ui/public/ask-panel.mjs +242 -8
- 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 +151 -5
- package/ui/public/models-view.mjs +34 -3
- package/ui/public/style.css +117 -8
- package/ui/server.mjs +237 -77
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
|

|
|
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
|
|
|
@@ -214,10 +229,15 @@ worca resume <pipelineId>
|
|
|
214
229
|
|
|
215
230
|
# offline demo — full pipeline, no tokens
|
|
216
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
|
|
217
237
|
```
|
|
218
238
|
|
|
219
239
|
Run `worca --help` for all subcommands (projects, plugins, marketplaces,
|
|
220
|
-
config, doctor) and flags.
|
|
240
|
+
workflows, config, doctor) and flags.
|
|
221
241
|
|
|
222
242
|
Exit codes, for scripts and CI wrappers: `0` the run finished (or an
|
|
223
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
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
package/src/cli/worca-cc.mjs
CHANGED
|
@@ -225,6 +225,7 @@ Subcommands:
|
|
|
225
225
|
config [get|set|unset] Budget & cost-limit settings
|
|
226
226
|
ui [start|stop|restart|status]
|
|
227
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
|
|
228
229
|
help Print this help (same as --help).
|
|
229
230
|
version Print the version (same as --version).
|
|
230
231
|
|
|
@@ -1203,6 +1204,43 @@ working, including updates (install provenance lives in plugins.lock.json).
|
|
|
1203
1204
|
Exit codes: 0 ok, 1 failure, 2 usage/validation errors.
|
|
1204
1205
|
`;
|
|
1205
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
|
+
|
|
1206
1244
|
/** Tiny per-verb arg parser: positionals plus declared --value / --bool flags. */
|
|
1207
1245
|
function pluginArgs(argv, valueFlags = [], boolFlags = []) {
|
|
1208
1246
|
const out = { _: [] };
|
|
@@ -1220,7 +1258,7 @@ function pluginArgs(argv, valueFlags = [], boolFlags = []) {
|
|
|
1220
1258
|
out[a.slice(2)] = v;
|
|
1221
1259
|
} else if (boolFlags.includes(a)) {
|
|
1222
1260
|
out[a.slice(2)] = true;
|
|
1223
|
-
} else if (a.startsWith('-')) {
|
|
1261
|
+
} else if (a.startsWith('-') && a !== '-') { // a lone '-' is the stdin positional
|
|
1224
1262
|
fail(`Unknown flag: ${a}`);
|
|
1225
1263
|
} else {
|
|
1226
1264
|
out._.push(a);
|
|
@@ -1305,9 +1343,8 @@ async function pluginInit(rest) {
|
|
|
1305
1343
|
for (const part of withParts) {
|
|
1306
1344
|
if (!INIT_PARTS.includes(part)) fail(`unknown --with part "${part}" (known: ${INIT_PARTS.join(', ')})`);
|
|
1307
1345
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
}
|
|
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.
|
|
1311
1348
|
const target = resolve(process.cwd(), a.dir || name);
|
|
1312
1349
|
const { mkdir, writeFile, chmod, readdir } = await import('node:fs/promises');
|
|
1313
1350
|
try {
|
|
@@ -1416,19 +1453,24 @@ async function pluginInit(rest) {
|
|
|
1416
1453
|
}
|
|
1417
1454
|
if (withParts.includes('workflows')) {
|
|
1418
1455
|
// A v2 graph: the Task and End cards are mandatory (V20/V21) and every input
|
|
1419
|
-
// takes exactly one wire (V7).
|
|
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';
|
|
1420
1462
|
files.set('workflows/example-flow.json', JSON.stringify({
|
|
1421
1463
|
name: `${name} example flow`,
|
|
1422
1464
|
version: 2,
|
|
1423
1465
|
domain: 'general',
|
|
1424
1466
|
nodes: [
|
|
1425
1467
|
{ id: 'n_task', kind: 'task', x: 40, y: 200, config: {} },
|
|
1426
|
-
{ id: 'n_helper', kind: 'agent', key
|
|
1468
|
+
{ id: 'n_helper', kind: 'agent', key, x: 320, y: 200, config: {} },
|
|
1427
1469
|
{ id: 'n_end', kind: 'end', x: 600, y: 200, config: {} },
|
|
1428
1470
|
],
|
|
1429
1471
|
wires: [
|
|
1430
1472
|
{ id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_helper', port: 'task' } },
|
|
1431
|
-
{ id: 'w2', from: { node: 'n_helper', port:
|
|
1473
|
+
{ id: 'w2', from: { node: 'n_helper', port: outPort }, to: { node: 'n_end', port: 'result' } },
|
|
1432
1474
|
],
|
|
1433
1475
|
}, null, 2) + '\n');
|
|
1434
1476
|
}
|
|
@@ -1838,9 +1880,141 @@ async function cmdMarketplace(argv) {
|
|
|
1838
1880
|
}
|
|
1839
1881
|
}
|
|
1840
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
|
+
|
|
1841
2015
|
// ── main ──────────────────────────────────────────────────────────────────────────
|
|
1842
2016
|
|
|
1843
|
-
const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config', 'ui']);
|
|
2017
|
+
const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config', 'ui', 'workflow']);
|
|
1844
2018
|
|
|
1845
2019
|
/** Levenshtein distance, two-row. Only ever called on short argv tokens. */
|
|
1846
2020
|
function editDistance(a, b) {
|
|
@@ -1899,6 +2073,7 @@ async function main() {
|
|
|
1899
2073
|
if (sub === 'marketplace') return cmdMarketplace(rest);
|
|
1900
2074
|
if (sub === 'config') return cmdConfig(rest);
|
|
1901
2075
|
if (sub === 'ui') return cmdUi(rest);
|
|
2076
|
+
if (sub === 'workflow') return cmdWorkflow(rest);
|
|
1902
2077
|
}
|
|
1903
2078
|
// `worca --ui [...]` is the historical spelling of `worca ui start [...]`; hand the
|
|
1904
2079
|
// remaining tokens to the ui parser so --port/--open/--mock work with either.
|
package/src/core/ask/models.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/core/ask/turn.mjs
CHANGED
|
@@ -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"
|
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,
|