@worca/app 1.0.0 → 1.2.0-rc.1
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 +30 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +386 -56
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +80 -17
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +311 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -34,14 +34,116 @@
|
|
|
34
34
|
|
|
35
35
|
import { spawn } from 'node:child_process';
|
|
36
36
|
import { createInterface } from 'node:readline';
|
|
37
|
-
import { prepareModelEnv } from './model-env.mjs';
|
|
37
|
+
import { prepareModelEnv, envFlag, describeModelEnv } from './model-env.mjs';
|
|
38
|
+
import { effectiveDebugSpawn } from './settings.mjs';
|
|
38
39
|
import { classifyError, strongestClass } from './recoverable-error.mjs';
|
|
40
|
+
import { explainUnspawnableClaude, resolveClaudeBin } from './preflight.mjs';
|
|
41
|
+
import { hostGuardEnabled, hostGuardHookEntry, hostGuardSystemPrompt } from './host-guard.mjs';
|
|
39
42
|
import { writeFile, mkdir, appendFile, readFile, access } from 'node:fs/promises';
|
|
40
|
-
import { constants as FS } from 'node:fs';
|
|
43
|
+
import { constants as FS, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
41
44
|
import { dirname, join } from 'node:path';
|
|
45
|
+
import { tmpdir } from 'node:os';
|
|
42
46
|
|
|
43
47
|
const DEFAULT_BIN = process.env.WORCA_CLAUDE_BIN || process.env.ORCH_CLAUDE_BIN || 'claude';
|
|
44
48
|
|
|
49
|
+
// Grace between the abort SIGTERM and the SIGKILL escalation. Claude Code shuts
|
|
50
|
+
// down synchronously (fsync'd ~/.claude.json saves); a SIGKILL that lands inside
|
|
51
|
+
// such a write strands ~/.claude.json.tmp.<pid>.<hex> (2026-08-30: 2099 files,
|
|
52
|
+
// 4.4 GB, all from test runs under IO load). 5 s is generous on an idle disk
|
|
53
|
+
// (SIGTERM exits in ~0.5 s) and only delays a stop when the child is wedged.
|
|
54
|
+
export const DEFAULT_SIGKILL_GRACE_MS = 5000;
|
|
55
|
+
export function sigkillGraceMs() {
|
|
56
|
+
const n = Number(process.env.WORCA_SIGKILL_GRACE_MS);
|
|
57
|
+
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_SIGKILL_GRACE_MS;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What `--settings` carries, or null when there is nothing to carry (no hook
|
|
61
|
+
* telemetry, no permission rules, no host guard) — then the flag is omitted
|
|
62
|
+
* entirely. `hostGuard` (set by runReal, gated by hostGuardEnabled) merges the
|
|
63
|
+
* host-process-protection PreToolUse hook into the SAME single payload; the
|
|
64
|
+
* returned `hook` flag stays telemetry-only (it drives --include-hook-events,
|
|
65
|
+
* which the guard does not need). */
|
|
66
|
+
export function buildSettingsPayload(permissionRules, { hostGuard = false } = {}) {
|
|
67
|
+
const hook = buildHookSettings();
|
|
68
|
+
const guard = hostGuard && hostGuardEnabled() ? hostGuardHookEntry() : null;
|
|
69
|
+
const hasRules = !!permissionRules && Object.values(permissionRules).some((a) => Array.isArray(a) && a.length);
|
|
70
|
+
// Present-but-malformed rules (e.g. `{deny: 'Bash(curl:*)'}`) make the object
|
|
71
|
+
// truthy while hasRules stays false, so the whole policy would drop out of
|
|
72
|
+
// argv silently. Say it once, then take the same no-rules path (fail-open,
|
|
73
|
+
// matching the guardrail-set read path) — the empty/absent cases ({}, {deny: []}, null)
|
|
74
|
+
// are normal and stay quiet.
|
|
75
|
+
if (!hasRules && permissionRules && typeof permissionRules === 'object'
|
|
76
|
+
&& Object.values(permissionRules).some((a) => a != null && !Array.isArray(a))) {
|
|
77
|
+
console.warn('[worca] guardrails: permissionRules is malformed (deny/allow/ask must be arrays of strings) — ignoring it; this spawn carries NO permission rules');
|
|
78
|
+
}
|
|
79
|
+
if (!hook && !hasRules && !guard) return null;
|
|
80
|
+
const settings = {};
|
|
81
|
+
if (hook) settings.hooks = { ...hook.hooks };
|
|
82
|
+
if (guard) {
|
|
83
|
+
settings.hooks = settings.hooks ?? {};
|
|
84
|
+
settings.hooks.PreToolUse = [...(settings.hooks.PreToolUse ?? []), guard];
|
|
85
|
+
}
|
|
86
|
+
if (hasRules) settings.permissions = permissionRules;
|
|
87
|
+
return { hook: !!hook, settings };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Largest command line we hand to spawn() inline (GH #380). Windows caps the
|
|
92
|
+
* whole CreateProcess command line at 32,767 chars and Linux caps a single
|
|
93
|
+
* argument at 128 KiB, and a real task prompt (a 1000-line markdown plus the
|
|
94
|
+
* rendered channel artifacts) sails past both — `spawn ENAMETOOLONG` / E2BIG at
|
|
95
|
+
* the first node. Above this limit the prompt travels on stdin and the system
|
|
96
|
+
* prompt / settings as files (planClaudeInvocation); below it the argv is
|
|
97
|
+
* byte-identical to what it always was. The figure leaves ~12K of headroom
|
|
98
|
+
* under the Windows cap for the exe path, quoting, and flags this measure
|
|
99
|
+
* cannot see, and is deliberately platform-independent so the offload path is
|
|
100
|
+
* exercised (and testable) everywhere, not only on Windows.
|
|
101
|
+
*
|
|
102
|
+
* Inline JSON, or the path of a file holding that same JSON when the invocation
|
|
103
|
+
* is staged (GH #380 — the CLI accepts either).
|
|
104
|
+
*/
|
|
105
|
+
export const ARGV_INLINE_LIMIT = 20000;
|
|
106
|
+
|
|
107
|
+
/** Conservative size of the command line spawn() would build: every argument
|
|
108
|
+
* quoted and space-separated, after the binary. Over-counts slightly on
|
|
109
|
+
* purpose (a prompt with embedded quotes grows under Windows escaping). */
|
|
110
|
+
export function argvLength(bin, args) {
|
|
111
|
+
return String(bin || '').length + args.reduce((n, a) => n + String(a).length + 3, 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const ARGV_VALUE_PREVIEW = 64;
|
|
115
|
+
|
|
116
|
+
/** A copy of `args` safe to log: EVERY token longer than ARGV_VALUE_PREVIEW is
|
|
117
|
+
* shortened to a 64-char prefix + "…(<N> chars)". Token-level, not flag-aware, on
|
|
118
|
+
* purpose: an inline prompt, the `--settings` JSON (uncapped for a custom rule
|
|
119
|
+
* set), `--allowedTools`, `--mcp-config` — any free-text value buildClaudeArgs
|
|
120
|
+
* adds later — is capped without this list having to track it. Flags and short
|
|
121
|
+
* values pass through verbatim, so argv order is always preserved. Pure. */
|
|
122
|
+
export function redactArgvForLog(args) {
|
|
123
|
+
return args.map((a) => {
|
|
124
|
+
const v = String(a);
|
|
125
|
+
return v.length > ARGV_VALUE_PREVIEW ? `${v.slice(0, ARGV_VALUE_PREVIEW)}…(${v.length} chars)` : v;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Log each npm-shim resolution once per process, not once per spawn. */
|
|
130
|
+
const _resolveNoted = new Set();
|
|
131
|
+
|
|
132
|
+
/** The spawn-failure Error for `bin`: the OS message, plus the Windows npm-shim
|
|
133
|
+
* explanation when that is what actually went wrong (ENOENT on a bare name
|
|
134
|
+
* whose only PATH hit is claude.cmd; EINVAL on an explicit .cmd). */
|
|
135
|
+
function spawnFailure(bin, err, prefix) {
|
|
136
|
+
const unspawnable = /ENOENT|EINVAL/.test(String(err && err.code || err && err.message || ''));
|
|
137
|
+
const hint = unspawnable ? explainUnspawnableClaude(bin) : null;
|
|
138
|
+
const out = new Error(`${prefix}: ${err.message}${hint ? ` — ${hint}` : ''}`);
|
|
139
|
+
// An unspawnable CLI (not installed / not on PATH) is user-fixable, not a
|
|
140
|
+
// pipeline bug: stamp the recovery class so the orchestrator's gate pauses
|
|
141
|
+
// the run for manual resume instead of hard-failing it (ENOENT matches no
|
|
142
|
+
// message-sniff pattern, so without the stamp it would classify null).
|
|
143
|
+
if (unspawnable) out.errorClass = 'network';
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
45
147
|
// Cap for the stderr detail embedded in a non-zero-exit Error message. The
|
|
46
148
|
// audit trail and the UI error banner consume that message; an uncapped
|
|
47
149
|
// stderrBuf (hundreds of KB of MCP/retry chatter) must not ride into them when
|
|
@@ -80,10 +182,30 @@ export function buildEffortArgs(effort) {
|
|
|
80
182
|
* and the baseline sub-agent lifecycle (tool_use/tool_result) is unaffected.
|
|
81
183
|
*/
|
|
82
184
|
export function subagentHooksEnabled() {
|
|
83
|
-
|
|
84
|
-
return !!v && v !== '0' && v.toLowerCase() !== 'false';
|
|
185
|
+
return envFlag('WORCA_SUBAGENT_HOOKS');
|
|
85
186
|
}
|
|
86
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Opt-in spawn diagnostics, DEFAULT OFF. A NON-EMPTY WORCA_DEBUG_SPAWN in the
|
|
190
|
+
* environment wins (envFlag rule: any value but "0"/"false" turns it on, so an
|
|
191
|
+
* exported "0" is an explicit OFF); otherwise the stored `debugSpawnEnabled`
|
|
192
|
+
* setting applies — read fresh per spawn (settings.mjs#effectiveDebugSpawn, the
|
|
193
|
+
* one precedence rule the settings API also reports), so the UI checkbox reaches
|
|
194
|
+
* the next spawn in this process AND in a CLI run with no restart and no env
|
|
195
|
+
* mutation. OFF ⇒ runReal emits NO spawn-debug event and does not touch
|
|
196
|
+
* argv/env, so the spawn path is byte-identical to today. (The once-per-process
|
|
197
|
+
* "routing env applied" confirmation below is a separate, always-on line: it
|
|
198
|
+
* fires only for a model env that carries an ANTHROPIC_* routing key, once per
|
|
199
|
+
* distinct model + env, never per spawn.) Read directly in runReal (not a
|
|
200
|
+
* runClaude option) so it bypasses the runClaude→runReal gate by construction.
|
|
201
|
+
*/
|
|
202
|
+
export function debugSpawnEnabled() {
|
|
203
|
+
return effectiveDebugSpawn().enabled;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Once per process per distinct (model, described env): see runReal. */
|
|
207
|
+
const _routingNoted = new Set();
|
|
208
|
+
|
|
87
209
|
// ── Sub-agent telemetry + the --settings seam ────────────────────────────────
|
|
88
210
|
// Telemetry is GATED (subagentHooksEnabled) and OFF by default. When on it adds
|
|
89
211
|
// `--include-hook-events` (surfaces hook lifecycle on the SAME stdout stream)
|
|
@@ -108,27 +230,16 @@ export function buildHookSettings() {
|
|
|
108
230
|
* flags would be last-wins at the CLI, silently dropping one payload.
|
|
109
231
|
* [] when there is nothing to say, so the baseline argv is byte-identical.
|
|
110
232
|
* @param {{deny?:string[],allow?:string[],ask?:string[]}|null|undefined} permissionRules
|
|
233
|
+
* @param {string|null} [settingsFile] staged path (GH #380): `--settings <path>` carries the same JSON
|
|
234
|
+
* @param {{hostGuard?:boolean}} [opts] host-process guard (runReal sets it; see buildSettingsPayload)
|
|
111
235
|
* @returns {string[]}
|
|
112
236
|
*/
|
|
113
|
-
export function buildSettingsArgs(permissionRules) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
// Present-but-malformed rules (e.g. `{deny: 'Bash(curl:*)'}`) make the object
|
|
117
|
-
// truthy while hasRules stays false, so the whole policy would drop out of
|
|
118
|
-
// argv silently. Say it once, then take the same no-rules path (fail-open,
|
|
119
|
-
// matching the guardrail-set read path) — the empty/absent cases ({}, {deny: []}, null)
|
|
120
|
-
// are normal and stay quiet.
|
|
121
|
-
if (!hasRules && permissionRules && typeof permissionRules === 'object'
|
|
122
|
-
&& Object.values(permissionRules).some((a) => a != null && !Array.isArray(a))) {
|
|
123
|
-
console.warn('[worca] guardrails: permissionRules is malformed (deny/allow/ask must be arrays of strings) — ignoring it; this spawn carries NO permission rules');
|
|
124
|
-
}
|
|
125
|
-
if (!hook && !hasRules) return [];
|
|
126
|
-
const settings = {};
|
|
127
|
-
if (hook) settings.hooks = hook.hooks;
|
|
128
|
-
if (hasRules) settings.permissions = permissionRules;
|
|
237
|
+
export function buildSettingsArgs(permissionRules, settingsFile = null, { hostGuard = false } = {}) {
|
|
238
|
+
const payload = buildSettingsPayload(permissionRules, { hostGuard });
|
|
239
|
+
if (!payload) return [];
|
|
129
240
|
const args = [];
|
|
130
|
-
if (hook) args.push('--include-hook-events');
|
|
131
|
-
args.push('--settings', JSON.stringify(settings));
|
|
241
|
+
if (payload.hook) args.push('--include-hook-events');
|
|
242
|
+
args.push('--settings', settingsFile || JSON.stringify(payload.settings));
|
|
132
243
|
return args;
|
|
133
244
|
}
|
|
134
245
|
|
|
@@ -185,10 +296,9 @@ export function buildSpawnEnv(envScrub, envAllowlist) {
|
|
|
185
296
|
* passed through by the orchestrator (handled by caller mapping mock->env or
|
|
186
297
|
* by passing systemPrompt/prompt markers; we also honor a `mock` field).
|
|
187
298
|
*/
|
|
188
|
-
function mockEnabled(opts) {
|
|
299
|
+
export function mockEnabled(opts) {
|
|
189
300
|
if (opts && opts.mock) return true;
|
|
190
|
-
|
|
191
|
-
return !!v && v !== '0' && v.toLowerCase() !== 'false';
|
|
301
|
+
return envFlag('WORCA_MOCK', 'ORCH_MOCK');
|
|
192
302
|
}
|
|
193
303
|
|
|
194
304
|
/**
|
|
@@ -217,10 +327,23 @@ function mockEnabled(opts) {
|
|
|
217
327
|
* @param {string[]} [o.envAllowlist] guardrail: extra env var names to keep under scrub
|
|
218
328
|
* @param {Record<string,string>} [o.modelEnv] per-model routing env (design §4.4), merged
|
|
219
329
|
* LAST over the spawn env (it survives scrub and wins collisions — explicit operator
|
|
220
|
-
* config outranks ambient-env hygiene); reserved keys are re-dropped here defensively
|
|
330
|
+
* config outranks ambient-env hygiene); reserved keys are re-dropped here defensively.
|
|
331
|
+
* An ANTHROPIC_MODEL key is the WIRE id (#374): it replaces `model` in the spawned
|
|
332
|
+
* `--model` flag, while `model` (the catalog id) stays worca's handle everywhere else
|
|
221
333
|
* @param {string[]} [o.workspaceWriteTargets] §8.10 MOCK-ONLY member checkouts the mock
|
|
222
334
|
* implementer writes into instead of `cwd` (empty/absent => today's cwd behavior).
|
|
223
335
|
* Never reaches argv: `runReal` ignores it by construction.
|
|
336
|
+
* @param {string[]} [o.tools] --tools <list>: the built-in tool allowlist ([] ⇒ `--tools ""`,
|
|
337
|
+
* no built-ins at all; MCP tools are unaffected). Absent ⇒ flag omitted (claude defaults).
|
|
338
|
+
* @param {boolean} [o.strictMcpConfig] --strict-mcp-config: only --mcp-config servers load
|
|
339
|
+
* @param {string[]} [o.settingSources] --setting-sources <list> (e.g. ['project'] drops user hooks/plugins/skills)
|
|
340
|
+
* @param {boolean} [o.disableSlashCommands] --disable-slash-commands
|
|
341
|
+
* @param {boolean} [o.includePartialMessages] --include-partial-messages (stream_event text deltas)
|
|
342
|
+
* @param {number} [o.maxTurns] --max-turns <n> (positive safe integer; else omitted)
|
|
343
|
+
* @param {number|null} [o.maxBudgetUsd] --max-budget-usd <n> (finite > 0; null/else omitted)
|
|
344
|
+
* @param {string} [o.appendSubagentSystemPrompt] --append-subagent-system-prompt <text> (Task children only)
|
|
345
|
+
* All eight are Ask Worca sandbox options (ask-worca-design.md §6.3) and default-off.
|
|
346
|
+
* @param {number} [o.argvInlineLimit] override ARGV_INLINE_LIMIT (GH #380; tests force the staged path)
|
|
224
347
|
* @returns {Promise<{text:string, exitCode:number}>}
|
|
225
348
|
*/
|
|
226
349
|
export async function runClaude(o = {}) {
|
|
@@ -247,6 +370,17 @@ export async function runClaude(o = {}) {
|
|
|
247
370
|
modelEnv,
|
|
248
371
|
workspaceWriteTargets,
|
|
249
372
|
resumeSessionId,
|
|
373
|
+
// Ask Worca sandbox hardening (ask-worca-design.md §6.3/§6.8). All default-off:
|
|
374
|
+
// undefined here ⇒ nothing emitted ⇒ every legacy argv stays byte-identical.
|
|
375
|
+
tools,
|
|
376
|
+
strictMcpConfig,
|
|
377
|
+
settingSources,
|
|
378
|
+
disableSlashCommands,
|
|
379
|
+
includePartialMessages,
|
|
380
|
+
maxTurns,
|
|
381
|
+
maxBudgetUsd,
|
|
382
|
+
appendSubagentSystemPrompt,
|
|
383
|
+
argvInlineLimit,
|
|
250
384
|
bin = DEFAULT_BIN,
|
|
251
385
|
} = o;
|
|
252
386
|
|
|
@@ -261,7 +395,7 @@ export async function runClaude(o = {}) {
|
|
|
261
395
|
// workspaceWriteTargets is the one option that is mock-ONLY (§8.10) and it must be
|
|
262
396
|
// named HERE too, or the mock implementer never sees it (this call is a gate, not
|
|
263
397
|
// a pass-through; test/spawn-args.test.mjs asserts the forwarding end to end).
|
|
264
|
-
return runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets });
|
|
398
|
+
return runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets, permissionMode });
|
|
265
399
|
}
|
|
266
400
|
|
|
267
401
|
return runReal({
|
|
@@ -282,6 +416,15 @@ export async function runClaude(o = {}) {
|
|
|
282
416
|
envScrub,
|
|
283
417
|
envAllowlist,
|
|
284
418
|
modelEnv,
|
|
419
|
+
tools,
|
|
420
|
+
strictMcpConfig,
|
|
421
|
+
settingSources,
|
|
422
|
+
disableSlashCommands,
|
|
423
|
+
includePartialMessages,
|
|
424
|
+
maxTurns,
|
|
425
|
+
maxBudgetUsd,
|
|
426
|
+
appendSubagentSystemPrompt,
|
|
427
|
+
argvInlineLimit,
|
|
285
428
|
});
|
|
286
429
|
}
|
|
287
430
|
|
|
@@ -308,11 +451,23 @@ export async function runClaude(o = {}) {
|
|
|
308
451
|
export function buildClaudeArgs({
|
|
309
452
|
prompt, systemPrompt, permissionMode, model, effort, allowedTools, resumeSessionId,
|
|
310
453
|
mcpConfigPath, mcpServerGrants, permissionRules,
|
|
311
|
-
|
|
312
|
-
|
|
454
|
+
// Ask Worca hardening options (ask-worca-design.md §6.3). `tools` is renamed on the
|
|
455
|
+
// way in because the legacy body below already owns a local `tools` (the
|
|
456
|
+
// --allowedTools union).
|
|
457
|
+
tools: builtinTools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages,
|
|
458
|
+
maxTurns, maxBudgetUsd, appendSubagentSystemPrompt, hostGuard,
|
|
459
|
+
}, delivery = {}) {
|
|
460
|
+
// delivery (GH #380, set only by planClaudeInvocation's staged branch):
|
|
461
|
+
// promptViaStdin -> bare `-p`; the prompt is written to the child's stdin
|
|
462
|
+
// systemPromptFile -> `--append-system-prompt-file <path>` instead of the text
|
|
463
|
+
// settingsFile -> `--settings <path>` instead of the inline JSON
|
|
464
|
+
const { promptViaStdin = false, systemPromptFile = null, settingsFile = null } = delivery;
|
|
465
|
+
const args = promptViaStdin ? ['-p'] : ['-p', prompt];
|
|
466
|
+
args.push('--output-format', 'stream-json', '--verbose', '--permission-mode', permissionMode);
|
|
313
467
|
if (resumeSessionId) args.push('--resume', resumeSessionId);
|
|
314
468
|
if (systemPrompt) {
|
|
315
|
-
args.push('--append-system-prompt',
|
|
469
|
+
if (systemPromptFile) args.push('--append-system-prompt-file', systemPromptFile);
|
|
470
|
+
else args.push('--append-system-prompt', systemPrompt);
|
|
316
471
|
}
|
|
317
472
|
if (model) {
|
|
318
473
|
args.push('--model', model);
|
|
@@ -323,7 +478,7 @@ export function buildClaudeArgs({
|
|
|
323
478
|
// SINGLE inline JSON (two --settings flags would be last-wins at the CLI). [] when
|
|
324
479
|
// there is neither, so the baseline argv is unchanged; a CLI that rejects these
|
|
325
480
|
// flags would only ever fail when the operator opted in.
|
|
326
|
-
for (const a of buildSettingsArgs(permissionRules)) args.push(a);
|
|
481
|
+
for (const a of buildSettingsArgs(permissionRules, settingsFile, { hostGuard })) args.push(a);
|
|
327
482
|
if (mcpConfigPath) args.push('--mcp-config', mcpConfigPath);
|
|
328
483
|
const tools = Array.isArray(allowedTools) ? allowedTools.slice() : [];
|
|
329
484
|
for (const s of (Array.isArray(mcpServerGrants) ? mcpServerGrants : [])) {
|
|
@@ -332,43 +487,231 @@ export function buildClaudeArgs({
|
|
|
332
487
|
if (tools.length) {
|
|
333
488
|
args.push('--allowedTools', tools.join(','));
|
|
334
489
|
}
|
|
490
|
+
// ── Ask Worca hardening flags (ask-worca-design.md §6.3 / §6.8) ──────────────
|
|
491
|
+
// Every one is default-off: absent / false / invalid ⇒ NOTHING is emitted, so
|
|
492
|
+
// every legacy argv stays byte-identical (test/spawn-args.test.mjs). Appended
|
|
493
|
+
// AFTER the legacy block so the baseline prefix never moves. Probed on 2.1.239:
|
|
494
|
+
// `--tools ""` = no built-in tools (MCP tools survive); the hidden `--max-turns`
|
|
495
|
+
// and `--append-subagent-system-prompt` are accepted and enforced.
|
|
496
|
+
// Filter to usable names FIRST, then decide: testing the RAW list while emitting
|
|
497
|
+
// the FILTERED join made `settingSources: [1]` emit `--setting-sources ""` (where
|
|
498
|
+
// `[]` emits nothing) and `['Read', '']` emit a trailing comma.
|
|
499
|
+
const names = (v) => (Array.isArray(v) ? v.filter((s) => typeof s === 'string' && s) : []);
|
|
500
|
+
// --tools is the one list whose empty value is meaningful (`--tools ""` = no
|
|
501
|
+
// built-in tools at all, §6.3), so the ARRAY decides whether the flag is emitted.
|
|
502
|
+
if (Array.isArray(builtinTools)) {
|
|
503
|
+
args.push('--tools', names(builtinTools).join(','));
|
|
504
|
+
}
|
|
505
|
+
if (strictMcpConfig === true) args.push('--strict-mcp-config');
|
|
506
|
+
const sources = names(settingSources);
|
|
507
|
+
if (sources.length) {
|
|
508
|
+
args.push('--setting-sources', sources.join(','));
|
|
509
|
+
}
|
|
510
|
+
if (disableSlashCommands === true) args.push('--disable-slash-commands');
|
|
511
|
+
if (includePartialMessages === true) args.push('--include-partial-messages');
|
|
512
|
+
if (Number.isSafeInteger(maxTurns) && maxTurns > 0) args.push('--max-turns', String(maxTurns));
|
|
513
|
+
if (typeof maxBudgetUsd === 'number' && Number.isFinite(maxBudgetUsd) && maxBudgetUsd > 0) {
|
|
514
|
+
args.push('--max-budget-usd', String(maxBudgetUsd));
|
|
515
|
+
}
|
|
516
|
+
if (typeof appendSubagentSystemPrompt === 'string' && appendSubagentSystemPrompt) {
|
|
517
|
+
args.push('--append-subagent-system-prompt', appendSubagentSystemPrompt);
|
|
518
|
+
}
|
|
335
519
|
return args;
|
|
336
520
|
}
|
|
337
521
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
522
|
+
/**
|
|
523
|
+
* Decide how ONE invocation reaches the CLI (GH #380). Pure: no I/O.
|
|
524
|
+
* - inline (the common case): `args` is exactly buildClaudeArgs(opts); `stdin`
|
|
525
|
+
* null; `files` empty.
|
|
526
|
+
* - staged (argv over `limit`): the prompt goes on stdin (`-p` reads it — the
|
|
527
|
+
* model sees the exact text, unlike a "read this file" instruction), the
|
|
528
|
+
* system prompt and the settings JSON become files under `dir`, and no
|
|
529
|
+
* argument carries free text any more, so the argv is short by construction.
|
|
530
|
+
* @param {object} opts the buildClaudeArgs options
|
|
531
|
+
* @param {{bin?:string, dir?:string|(() => string), limit?:number}} [o] `dir` may be a
|
|
532
|
+
* factory, called only when staging is actually needed (so the caller creates
|
|
533
|
+
* a temp dir exactly when one will be used)
|
|
534
|
+
* @returns {{args:string[], stdin:string|null, files:{path:string,content:string}[], staged:boolean, inlineLength:number}}
|
|
535
|
+
*/
|
|
536
|
+
export function planClaudeInvocation(opts, { bin = DEFAULT_BIN, dir = null, limit = ARGV_INLINE_LIMIT } = {}) {
|
|
537
|
+
const inline = buildClaudeArgs(opts);
|
|
538
|
+
const inlineLength = argvLength(bin, inline);
|
|
539
|
+
if (inlineLength <= limit) return { args: inline, stdin: null, files: [], staged: false, inlineLength };
|
|
540
|
+
if (!dir) throw new Error('planClaudeInvocation: a staging dir is required when the argv is over the limit');
|
|
541
|
+
if (typeof dir === 'function') dir = dir();
|
|
542
|
+
const files = [];
|
|
543
|
+
const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
|
|
544
|
+
const promptViaStdin = prompt.length > 0; // an empty prompt stays `-p ''` — nothing to pipe
|
|
545
|
+
let systemPromptFile = null;
|
|
546
|
+
if (opts.systemPrompt) {
|
|
547
|
+
systemPromptFile = join(dir, 'system-prompt.md');
|
|
548
|
+
files.push({ path: systemPromptFile, content: opts.systemPrompt });
|
|
549
|
+
}
|
|
550
|
+
let settingsFile = null;
|
|
551
|
+
const payload = buildSettingsPayload(opts.permissionRules, { hostGuard: opts.hostGuard });
|
|
552
|
+
if (payload) {
|
|
553
|
+
settingsFile = join(dir, 'settings.json');
|
|
554
|
+
files.push({ path: settingsFile, content: JSON.stringify(payload.settings) });
|
|
555
|
+
}
|
|
556
|
+
const args = buildClaudeArgs(opts, { promptViaStdin, systemPromptFile, settingsFile });
|
|
557
|
+
return { args, stdin: promptViaStdin ? prompt : null, files, staged: true, inlineLength };
|
|
558
|
+
}
|
|
344
559
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
560
|
+
/** planClaudeInvocation + the I/O: a private temp dir is created and the files
|
|
561
|
+
* written ONLY on the staged branch (`dir` is null otherwise, so the caller has
|
|
562
|
+
* nothing to clean up). Synchronous on purpose — a few hundred KB once per
|
|
563
|
+
* spawn, and it keeps the spawn sequence in runReal linear. */
|
|
564
|
+
export function stageClaudeInvocation(opts, { bin = DEFAULT_BIN, limit = ARGV_INLINE_LIMIT } = {}) {
|
|
565
|
+
let dir = null;
|
|
566
|
+
const plan = planClaudeInvocation(opts, { bin, limit, dir: () => (dir = mkdtempSync(join(tmpdir(), 'worca-claude-'))) });
|
|
567
|
+
for (const file of plan.files) writeFileSync(file.path, file.content, 'utf8');
|
|
568
|
+
return { ...plan, dir };
|
|
569
|
+
}
|
|
348
570
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
let
|
|
571
|
+
function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, model, effort, onEvent, signal, bin, resumeSessionId, mcpConfigPath, mcpServerGrants, permissionRules, envScrub, envAllowlist, modelEnv, tools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages, maxTurns, maxBudgetUsd, appendSubagentSystemPrompt, argvInlineLimit }) {
|
|
572
|
+
return new Promise((resolveP, rejectP) => {
|
|
573
|
+
// Per-model routing env (design §4.4), prepared BEFORE argv: reserved keys
|
|
574
|
+
// are re-dropped here defensively — the write path already rejects them, so
|
|
575
|
+
// a drop means a hand-edited settings file — and the surviving map is also
|
|
576
|
+
// where the wire id (below) is read from.
|
|
577
|
+
let safeModelEnv = null;
|
|
578
|
+
let wireModelDropped = false;
|
|
357
579
|
if (modelEnv && Object.keys(modelEnv).length) {
|
|
358
580
|
const { env: safe, dropped } = prepareModelEnv(modelEnv);
|
|
359
581
|
for (const k of dropped) {
|
|
360
582
|
console.warn(`[worca] modelEnv: dropping reserved/invalid key ${JSON.stringify(k)}`);
|
|
361
583
|
}
|
|
362
|
-
|
|
584
|
+
// A configured wire id that didn't survive (unresolvable ${VAR}, empty, or
|
|
585
|
+
// whitespace-only) fell into `dropped`: we silently fall back to the catalog
|
|
586
|
+
// id below, so warn specifically — the generic drop line above doesn't say
|
|
587
|
+
// the argv model changed, and the wire-model line never fires (ids match).
|
|
588
|
+
wireModelDropped = 'ANTHROPIC_MODEL' in modelEnv && dropped.includes('ANTHROPIC_MODEL');
|
|
589
|
+
if (Object.keys(safe).length) safeModelEnv = safe;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Wire id (#374): ANTHROPIC_MODEL in the resolved model env names the id the
|
|
593
|
+
// ENDPOINT should see; the catalog id stays worca's handle (config refs, cost
|
|
594
|
+
// flags). Passed as an explicit --model — self-documenting in logs and immune
|
|
595
|
+
// to CLI flag/env precedence — so the env var alone would otherwise be dead.
|
|
596
|
+
const wireModel = safeModelEnv?.ANTHROPIC_MODEL || model;
|
|
597
|
+
if (wireModelDropped && wireModel === model) {
|
|
598
|
+
console.warn(`[worca] model ${JSON.stringify(model ?? '')}: configured wire model was dropped (unresolved/empty) — using the catalog id`);
|
|
599
|
+
} else if (wireModel !== model) {
|
|
600
|
+
console.warn(`[worca] model ${JSON.stringify(model ?? '')}: wire model ${JSON.stringify(wireModel)}`);
|
|
601
|
+
}
|
|
602
|
+
// Confirm a resolved card's routing env actually reached a spawn — even when
|
|
603
|
+
// the wire id equals the catalog id, the case the wire-model line above stays
|
|
604
|
+
// silent for (that silence is exactly what hid a gateway card whose
|
|
605
|
+
// ANTHROPIC_MODEL matched its catalog id). Fires only for an env that carries
|
|
606
|
+
// an ANTHROPIC_* routing key (Ask Worca merges a CLAUDE_CODE_* knob into
|
|
607
|
+
// EVERY turn's env, which is not routing) and once per process per distinct
|
|
608
|
+
// line, like _resolveNoted — never per spawn. describeModelEnv prints the
|
|
609
|
+
// routing keys readable (endpoint, wire id — the diagnostic) and every other
|
|
610
|
+
// key as `<set, N chars>`: ANTHROPIC_AUTH_TOKEN and plugin {secret} values live
|
|
611
|
+
// in this map and no part of them may reach a log. Worded WITHOUT the
|
|
612
|
+
// substrings "wire model"/"modelEnv" — test/spawn-args.test.mjs counts by those.
|
|
613
|
+
const routingApplied = safeModelEnv && Object.keys(safeModelEnv).some((k) => k.startsWith('ANTHROPIC_'))
|
|
614
|
+
? describeModelEnv(safeModelEnv) : null;
|
|
615
|
+
if (routingApplied) {
|
|
616
|
+
const line = `[worca] model ${JSON.stringify(model ?? '')}: routing env applied: ${routingApplied}`;
|
|
617
|
+
if (!_routingNoted.has(line)) { _routingNoted.add(line); console.warn(line); }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Windows + npm-installed Claude Code: the bare name is a .cmd shim Node
|
|
621
|
+
// cannot spawn; resolveClaudeBin swaps in the package's native claude.exe.
|
|
622
|
+
// Everywhere else this is `bin` unchanged. Resolved BEFORE the argv plan so
|
|
623
|
+
// the command-line measure below counts the path that is actually spawned.
|
|
624
|
+
const resolved = resolveClaudeBin(bin);
|
|
625
|
+
if (resolved.note && !_resolveNoted.has(resolved.bin)) {
|
|
626
|
+
_resolveNoted.add(resolved.bin);
|
|
627
|
+
console.warn(`[worca] ${resolved.note}`);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// GH #380: inline argv when it fits, else prompt on stdin + files (see
|
|
631
|
+
// ARGV_INLINE_LIMIT). The staging dir, when any, is removed on every
|
|
632
|
+
// terminal path below (finish) and on a failed spawn.
|
|
633
|
+
const limit = Number.isFinite(argvInlineLimit) && argvInlineLimit > 0 ? argvInlineLimit : ARGV_INLINE_LIMIT;
|
|
634
|
+
// Host guard (host-guard.mjs, 2026-08-31 incident): every REAL spawn — any
|
|
635
|
+
// role, custom agent, plugin agent, ask chat — carries the protection
|
|
636
|
+
// preamble, the PreToolUse hook (hostGuard -> the --settings payload), and
|
|
637
|
+
// WORCA_HOST_PID (below). One kill-switch: WORCA_HOST_GUARD=0. Mock spawns
|
|
638
|
+
// nothing, so runMock stays untouched.
|
|
639
|
+
const guardOn = hostGuardEnabled();
|
|
640
|
+
const guardedSystemPrompt = guardOn
|
|
641
|
+
? [hostGuardSystemPrompt(process.pid), systemPrompt].filter(Boolean).join('\n\n')
|
|
642
|
+
: systemPrompt;
|
|
643
|
+
let plan;
|
|
644
|
+
try {
|
|
645
|
+
plan = stageClaudeInvocation({
|
|
646
|
+
prompt, systemPrompt: guardedSystemPrompt, hostGuard: guardOn,
|
|
647
|
+
permissionMode, model: wireModel, effort, allowedTools, resumeSessionId,
|
|
648
|
+
mcpConfigPath, mcpServerGrants, permissionRules,
|
|
649
|
+
tools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages,
|
|
650
|
+
maxTurns, maxBudgetUsd, appendSubagentSystemPrompt,
|
|
651
|
+
}, { bin: resolved.bin, limit });
|
|
652
|
+
} catch (err) {
|
|
653
|
+
rejectP(new Error(`Failed to stage the claude prompt files: ${err.message}`));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const { args } = plan;
|
|
657
|
+
const cleanupStaged = () => {
|
|
658
|
+
if (!plan.dir) return;
|
|
659
|
+
try { rmSync(plan.dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
660
|
+
};
|
|
661
|
+
if (plan.staged) {
|
|
662
|
+
console.warn(`[worca] claude argv would be ${plan.inlineLength} chars (limit ${limit}): prompt on stdin, system prompt/settings as files`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// undefined when the guardrail is off, and the spread then adds NO `env` key —
|
|
666
|
+
// spawn inherits process.env exactly as it did before guardrails existed.
|
|
667
|
+
const guardrailEnv = buildSpawnEnv(envScrub, envAllowlist);
|
|
668
|
+
|
|
669
|
+
// Model env merges LAST: it survives scrub and wins collisions (explicit
|
|
670
|
+
// operator config outranks ambient-env hygiene). With no modelEnv (or
|
|
671
|
+
// nothing surviving the filter) the spawn env is byte-identical to the
|
|
672
|
+
// pre-feature behavior, including the undefined -> inherit-process.env case.
|
|
673
|
+
let spawnEnv = guardrailEnv;
|
|
674
|
+
if (safeModelEnv) spawnEnv = { ...(guardrailEnv ?? process.env), ...safeModelEnv };
|
|
675
|
+
|
|
676
|
+
// WORCA_HOST_PID rides every guarded spawn (the hook reads it; scrub would
|
|
677
|
+
// drop it — WORCA_ is not an allowlisted prefix — so it is added AFTER).
|
|
678
|
+
if (guardOn) spawnEnv = { ...(spawnEnv ?? process.env), WORCA_HOST_PID: String(process.pid) };
|
|
679
|
+
|
|
680
|
+
// Opt-in spawn diagnostics (WORCA_DEBUG_SPAWN, default off — byte-identical spawn
|
|
681
|
+
// path when unset). Everything here is derived from values already computed above
|
|
682
|
+
// (safeModelEnv is null or non-empty, so the routing field is either the described
|
|
683
|
+
// env — secrets as `<set, N chars>` — or "(none)"). Emitted right before spawn so
|
|
684
|
+
// it reflects the exact bin/argv/env handed to the child, ONCE, as the same
|
|
685
|
+
// `stderr` event the child's own stderr rides (run-harness logs it at `warn`
|
|
686
|
+
// into the run stream and live-log.ndjson; nothing here also console.warns, so
|
|
687
|
+
// a run never prints the line twice). Field is `routingEnv`, not `modelEnv`:
|
|
688
|
+
// test/spawn-args.test.mjs counts "modelEnv" warnings for the dropped-key path.
|
|
689
|
+
if (debugSpawnEnabled()) {
|
|
690
|
+
const summary =
|
|
691
|
+
`[worca] spawn-debug: bin=${JSON.stringify(resolved.bin)} `
|
|
692
|
+
+ `argv=${JSON.stringify(redactArgvForLog(args))} `
|
|
693
|
+
+ `promptViaStdin=${plan.stdin != null} staged=${plan.staged} `
|
|
694
|
+
+ `envScrub=${guardrailEnv ? 'on' : 'off'} childEnvKeys=${Object.keys(spawnEnv ?? process.env).length} `
|
|
695
|
+
+ `routingEnv=[${safeModelEnv ? describeModelEnv(safeModelEnv) : '(none)'}]`;
|
|
696
|
+
safeEmit(onEvent, { type: 'stderr', stream: 'err', text: summary });
|
|
363
697
|
}
|
|
364
698
|
|
|
365
699
|
let child;
|
|
366
700
|
try {
|
|
367
|
-
child = spawn(bin, args, {
|
|
701
|
+
child = spawn(resolved.bin, args, {
|
|
702
|
+
cwd, stdio: [plan.stdin != null ? 'pipe' : 'ignore', 'pipe', 'pipe'], ...(spawnEnv ? { env: spawnEnv } : {}),
|
|
703
|
+
});
|
|
368
704
|
} catch (err) {
|
|
369
|
-
|
|
705
|
+
cleanupStaged();
|
|
706
|
+
rejectP(spawnFailure(bin, err, `Failed to spawn ${bin}`));
|
|
370
707
|
return;
|
|
371
708
|
}
|
|
709
|
+
if (plan.stdin != null) {
|
|
710
|
+
// A child that dies before draining stdin (bad flag, ENOENT surfaced
|
|
711
|
+
// late) raises EPIPE here; the 'error'/'close' handlers own the real cause.
|
|
712
|
+
child.stdin.on('error', () => {});
|
|
713
|
+
child.stdin.end(plan.stdin, 'utf8');
|
|
714
|
+
}
|
|
372
715
|
|
|
373
716
|
let resultText = '';
|
|
374
717
|
let assistantText = '';
|
|
@@ -396,7 +739,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
396
739
|
} catch {
|
|
397
740
|
/* ignore */
|
|
398
741
|
}
|
|
399
|
-
},
|
|
742
|
+
}, sigkillGraceMs()).unref?.();
|
|
400
743
|
};
|
|
401
744
|
if (signal) {
|
|
402
745
|
if (signal.aborted) onAbort();
|
|
@@ -407,6 +750,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
407
750
|
if (settled) return;
|
|
408
751
|
settled = true;
|
|
409
752
|
if (signal) signal.removeEventListener?.('abort', onAbort);
|
|
753
|
+
cleanupStaged();
|
|
410
754
|
fn(arg);
|
|
411
755
|
};
|
|
412
756
|
|
|
@@ -486,7 +830,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
486
830
|
});
|
|
487
831
|
|
|
488
832
|
child.on('error', (err) => {
|
|
489
|
-
finish(rejectP,
|
|
833
|
+
finish(rejectP, spawnFailure(bin, err, `${bin} error`));
|
|
490
834
|
});
|
|
491
835
|
|
|
492
836
|
child.on('close', (code) => {
|
|
@@ -611,6 +955,24 @@ async function emitLog(onEvent, text) {
|
|
|
611
955
|
await new Promise((r) => setTimeout(r, 0));
|
|
612
956
|
}
|
|
613
957
|
|
|
958
|
+
/**
|
|
959
|
+
* The roles the offline mock runner can SERVE — one per arm of the role switch
|
|
960
|
+
* below (the `ask` arm is the Ask-Worca assistant, not a writer role). Exported
|
|
961
|
+
* because three consumers need the vocabulary and none of them may hard-code it:
|
|
962
|
+
* meta v2 validation (an unknown `mockRole` is a warning + drop), GET /api/agents
|
|
963
|
+
* (the Agents view's role picker) and the graph executor's mock-role chain.
|
|
964
|
+
* test/mock-writer-roles.test.mjs parses the switch and pins the lockstep.
|
|
965
|
+
*/
|
|
966
|
+
export const MOCK_WRITER_ROLES = new Set([
|
|
967
|
+
'clarify', 'planner-plan', 'refiner', 'decomposer', 'implementer', 'reviewer', 'plan-review',
|
|
968
|
+
'workspace-scan', 'agent-gen', 'workspace-reviewer', 'manual-tests-checklist', 'manual-web-ui-testing',
|
|
969
|
+
'generic-producer', 'generic-verifier',
|
|
970
|
+
]);
|
|
971
|
+
|
|
972
|
+
/** Named so the executor's mock-role chain and the switch cannot drift apart. */
|
|
973
|
+
export const MOCK_ROLE_CLARIFY = 'clarify';
|
|
974
|
+
export const MOCK_ROLE_DECOMPOSER = 'decomposer';
|
|
975
|
+
|
|
614
976
|
/**
|
|
615
977
|
* The mock-fan-out roles (mirror the orchestrator's FANOUT_ELIGIBLE intent): the
|
|
616
978
|
* roles whose real runs may spawn sub-agents. Keyed by the MOCK_ROLE strings.
|
|
@@ -675,6 +1037,109 @@ async function emitMockSubAgents(role, onEvent, signal) {
|
|
|
675
1037
|
}
|
|
676
1038
|
}
|
|
677
1039
|
|
|
1040
|
+
// ── Ask Worca mock role (ask-worca-design.md §6.7) ───────────────────────────
|
|
1041
|
+
|
|
1042
|
+
/** Emit a raw stream-json frame through the SAME envelope runReal uses (the rl 'line' handler above). */
|
|
1043
|
+
function emitRaw(onEvent, raw) {
|
|
1044
|
+
const cost = extractResultCost(raw);
|
|
1045
|
+
const text = extractText(raw);
|
|
1046
|
+
safeEmit(onEvent, { type: raw.type, raw, text: text || undefined, ...(cost != null ? { costUsd: cost } : {}) });
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const ASK_CONTEXT_BLOCK_RE = /\[worca context\][\s\S]*?\[\/worca context\]\s*/;
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* The offline Ask Worca assistant: frames in the shapes probed on claude 2.1.239
|
|
1053
|
+
* (system/init → message_start → text deltas → assistant blocks → tool_use /
|
|
1054
|
+
* tool_result pairs → message_delta → result), chosen from the USER text so
|
|
1055
|
+
* tests control the scenario. Never touches the filesystem, never reads prompt
|
|
1056
|
+
* markers, never spawns the MCP child. The limit / failure scenarios emit their
|
|
1057
|
+
* `result` frame and then REJECT exactly like the real CLI (exit 1, empty stderr).
|
|
1058
|
+
*/
|
|
1059
|
+
async function mockAsk({ markers, prompt, cwd, onEvent, signal, resumeSessionId }) {
|
|
1060
|
+
const userText = String(prompt ?? '').replace(ASK_CONTEXT_BLOCK_RE, '');
|
|
1061
|
+
let card = {};
|
|
1062
|
+
try { card = markers.MOCK_ASK_CARD ? JSON.parse(markers.MOCK_ASK_CARD) : {}; } catch { card = {}; }
|
|
1063
|
+
if (!card || typeof card !== 'object' || Array.isArray(card)) card = {};
|
|
1064
|
+
const fail = /\bMOCK_FAIL\b/.test(userText);
|
|
1065
|
+
const maxTurns = /\bMOCK_MAX_TURNS\b/.test(userText);
|
|
1066
|
+
const maxBudget = /\bMOCK_MAX_BUDGET\b/.test(userText);
|
|
1067
|
+
const slow = /\bMOCK_SLOW\b/.test(userText);
|
|
1068
|
+
const agents = /\bagents?\b/i.test(userText);
|
|
1069
|
+
const propose = /\b(propose|start|run)\b/i.test(userText);
|
|
1070
|
+
|
|
1071
|
+
const SID = resumeSessionId || 'mock-session-ask-1';
|
|
1072
|
+
const USAGE = { input_tokens: 10, output_tokens: 20, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
|
|
1073
|
+
const firstLine = userText.split(/\r?\n/).map((l) => l.trim()).find(Boolean) || '';
|
|
1074
|
+
const ANSWER = `[mock] ${firstLine.slice(0, 200)}`;
|
|
1075
|
+
const init = { type: 'system', subtype: 'init', session_id: SID, cwd, model: 'mock', permissionMode: 'dontAsk',
|
|
1076
|
+
tools: ['Task', 'mcp__worca__list_runs', 'mcp__worca__get_run', 'mcp__worca__propose_run'],
|
|
1077
|
+
mcp_servers: [{ name: 'worca', status: 'connected' }], plugins: [], skills: [], slash_commands: [], agents: [], uuid: 'mock-uuid-init' };
|
|
1078
|
+
const mstart = (id) => ({ type: 'stream_event', event: { type: 'message_start', message: { id, model: 'mock', role: 'assistant', content: [], usage: USAGE } }, parent_tool_use_id: null, session_id: SID });
|
|
1079
|
+
const delta = (t) => ({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: t } }, parent_tool_use_id: null, session_id: SID });
|
|
1080
|
+
const mdelta = { type: 'stream_event', event: { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: USAGE }, parent_tool_use_id: null, session_id: SID };
|
|
1081
|
+
const atext = (id, t) => ({ type: 'assistant', message: { id, model: 'mock', role: 'assistant', content: [{ type: 'text', text: t }], usage: USAGE }, parent_tool_use_id: null, session_id: SID });
|
|
1082
|
+
const atool = (id, toolId, name, input, ptu = null) => ({ type: 'assistant', message: { id, model: 'mock', role: 'assistant', content: [{ type: 'tool_use', id: toolId, name, input, caller: { type: 'direct' } }], usage: USAGE }, parent_tool_use_id: ptu, session_id: SID });
|
|
1083
|
+
const uresult = (toolId, text, ptu = null, extra = {}) => ({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolId, content: [{ type: 'text', text }] }] }, parent_tool_use_id: ptu, session_id: SID, ...extra });
|
|
1084
|
+
const result = (over = {}) => ({ type: 'result', subtype: 'success', is_error: false, duration_ms: 10, duration_api_ms: 8, num_turns: 1, session_id: SID, total_cost_usd: 0,
|
|
1085
|
+
usage: USAGE, modelUsage: {}, permission_denials: [], terminal_reason: 'completed', result: ANSWER, ...over });
|
|
1086
|
+
const MSG1 = 'msg_mock_ask_1';
|
|
1087
|
+
const MSG2 = 'msg_mock_ask_2';
|
|
1088
|
+
|
|
1089
|
+
const frames = [init, mstart(MSG1)];
|
|
1090
|
+
if (fail) {
|
|
1091
|
+
frames.push(result({ subtype: 'error_during_execution', is_error: true, errors: ['mock failure'], terminal_reason: 'api_error', result: 'mock failure', num_turns: 0 }));
|
|
1092
|
+
} else if (maxTurns || maxBudget) {
|
|
1093
|
+
frames.push(delta('[mock] '), delta('partial'), atext(MSG1, '[mock] partial'),
|
|
1094
|
+
atool(MSG1, 'toolu_mock_1', 'mcp__worca__list_runs', {}), uresult('toolu_mock_1', '[]'));
|
|
1095
|
+
frames.push(maxTurns
|
|
1096
|
+
? result({ subtype: 'error_max_turns', is_error: true, errors: ['Reached maximum number of turns (1)'], terminal_reason: 'max_turns', num_turns: 2, stop_reason: 'tool_use', result: undefined })
|
|
1097
|
+
: result({ subtype: 'error_max_budget_usd', is_error: true, errors: ['Reached maximum budget ($0.0001)'], terminal_reason: 'budget_exhausted', result: undefined }));
|
|
1098
|
+
} else {
|
|
1099
|
+
let answerMsg = MSG1;
|
|
1100
|
+
if (agents) {
|
|
1101
|
+
frames.push(
|
|
1102
|
+
atool(MSG1, 'toolu_mock_task', 'Agent', { description: 'count runs', subagent_type: 'general-purpose', prompt: 'count the runs' }),
|
|
1103
|
+
atool('msg_mock_child_1', 'toolu_mock_child_1', 'mcp__worca__list_runs', {}, 'toolu_mock_task'),
|
|
1104
|
+
uresult('toolu_mock_child_1', '[]', 'toolu_mock_task'),
|
|
1105
|
+
uresult('toolu_mock_task', 'count: 0', null, { tool_use_result: {
|
|
1106
|
+
status: 'completed', agentId: 'mock-agent-1', agentType: 'general-purpose', content: [{ type: 'text', text: 'count: 0' }],
|
|
1107
|
+
resolvedModel: 'mock-haiku', totalDurationMs: 10, totalTokens: 1234, totalToolUseCount: 1,
|
|
1108
|
+
usage: { input_tokens: 1000, output_tokens: 234, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
|
|
1109
|
+
} }),
|
|
1110
|
+
);
|
|
1111
|
+
answerMsg = MSG2;
|
|
1112
|
+
}
|
|
1113
|
+
if (propose) {
|
|
1114
|
+
frames.push(delta('[mock] '), delta('preparing '), delta('a run'), atext(MSG1, 'Preparing a run card.'),
|
|
1115
|
+
atool(MSG1, 'toolu_mock_propose', 'mcp__worca__propose_run', card), uresult('toolu_mock_propose', JSON.stringify({ ok: true })));
|
|
1116
|
+
answerMsg = MSG2;
|
|
1117
|
+
}
|
|
1118
|
+
if (answerMsg !== MSG1) frames.push(mstart(answerMsg));
|
|
1119
|
+
frames.push(delta('[mock] '), delta(firstLine.slice(0, 200)), atext(answerMsg, ANSWER), mdelta);
|
|
1120
|
+
frames.push(result(agents
|
|
1121
|
+
? { modelUsage: { 'mock-haiku': { inputTokens: 1000, outputTokens: 234, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, costUSD: 0, canonicalModel: 'mock-haiku' } } }
|
|
1122
|
+
: {}));
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
safeEmit(onEvent, { type: 'session', sessionId: SID });
|
|
1126
|
+
for (const f of frames) {
|
|
1127
|
+
abortIfNeeded(signal);
|
|
1128
|
+
emitRaw(onEvent, f);
|
|
1129
|
+
await new Promise((r) => setTimeout(r, slow ? 300 : 0));
|
|
1130
|
+
}
|
|
1131
|
+
abortIfNeeded(signal);
|
|
1132
|
+
if (fail || maxTurns || maxBudget) {
|
|
1133
|
+
// Probed on 2.1.239: these subtypes exit 1 with EMPTY stderr, so runReal rejects with the
|
|
1134
|
+
// stdout `result` text (MOCK_FAIL) or 'no stderr' (the limits). turn.mjs (P2) reads the
|
|
1135
|
+
// reducer's resultSubtype before classifying the rejection.
|
|
1136
|
+
const err = new Error(`claude exited with code 1: ${fail ? 'mock failure' : 'no stderr'}`);
|
|
1137
|
+
err.errorClass = null;
|
|
1138
|
+
throw err;
|
|
1139
|
+
}
|
|
1140
|
+
return { text: ANSWER, exitCode: 0 };
|
|
1141
|
+
}
|
|
1142
|
+
|
|
678
1143
|
function abortIfNeeded(signal) {
|
|
679
1144
|
if (signal?.aborted) {
|
|
680
1145
|
const err = new Error('aborted');
|
|
@@ -686,8 +1151,20 @@ function abortIfNeeded(signal) {
|
|
|
686
1151
|
/**
|
|
687
1152
|
* Offline mock: emits a few log lines and performs role-appropriate writes.
|
|
688
1153
|
*/
|
|
689
|
-
async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets }) {
|
|
1154
|
+
async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets, permissionMode }) {
|
|
690
1155
|
abortIfNeeded(signal);
|
|
1156
|
+
// Ask Worca mock role (ask-worca-design.md §6.7): detected from the SYSTEM PROMPT
|
|
1157
|
+
// ONLY and dispatched before any prompt-sourced marker is honoured — a chat
|
|
1158
|
+
// message containing `MOCK_ASK: /x.json` (or any MOCK_* line) must never reach
|
|
1159
|
+
// the MOCK_ASK file-write arm below, and the user text can never pick the role.
|
|
1160
|
+
// `dontAsk` is the ask recipe's permission mode and has no legacy caller
|
|
1161
|
+
// (spawn.mjs:20), so it takes the ask arm markers or not: a P2 turn that forgot
|
|
1162
|
+
// `turn.mock` must not fall through to parseMarkers(prompt)/inferRole, where the
|
|
1163
|
+
// chat text alone picks a role that writes to the scratch cwd.
|
|
1164
|
+
const sysMarkers = parseMarkers('', systemPrompt);
|
|
1165
|
+
if (sysMarkers.MOCK_ROLE === 'ask' || permissionMode === 'dontAsk') {
|
|
1166
|
+
return mockAsk({ markers: sysMarkers, prompt, cwd, onEvent, signal, resumeSessionId });
|
|
1167
|
+
}
|
|
691
1168
|
const m = parseMarkers(prompt, systemPrompt);
|
|
692
1169
|
const role = m.MOCK_ROLE || inferRole(prompt, systemPrompt);
|
|
693
1170
|
const cycle = Number(m.MOCK_CYCLE || '1') || 1;
|
|
@@ -704,7 +1181,7 @@ async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessi
|
|
|
704
1181
|
// Ask-then-resume (spec 2026-07-11): asking replaces the role side effects
|
|
705
1182
|
// for this invocation; the orchestrator gates the user and resumes. The
|
|
706
1183
|
// session event above already fired, so the resume has a session id.
|
|
707
|
-
if (m.MOCK_ASK) {
|
|
1184
|
+
if (m.MOCK_ASK && permissionMode !== 'dontAsk') { // belt and braces: dontAsk already took the ask arm above
|
|
708
1185
|
await ensureDir(m.MOCK_ASK);
|
|
709
1186
|
await writeFile(m.MOCK_ASK, JSON.stringify({
|
|
710
1187
|
questions: [{ id: 'q1', question: `Mock question from ${role}?`, options: ['Option A', 'Option B'], allowFreeText: true }],
|
|
@@ -717,7 +1194,7 @@ async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessi
|
|
|
717
1194
|
|
|
718
1195
|
let text = `[mock] role ${role} complete`;
|
|
719
1196
|
switch (role) {
|
|
720
|
-
case
|
|
1197
|
+
case MOCK_ROLE_CLARIFY:
|
|
721
1198
|
text = await mockClarify(m, cycle, onEvent);
|
|
722
1199
|
break;
|
|
723
1200
|
case 'planner-plan':
|
|
@@ -726,7 +1203,7 @@ async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessi
|
|
|
726
1203
|
case 'refiner':
|
|
727
1204
|
text = await mockRefiner(m, cycle, onEvent);
|
|
728
1205
|
break;
|
|
729
|
-
case
|
|
1206
|
+
case MOCK_ROLE_DECOMPOSER:
|
|
730
1207
|
text = await mockDecomposer(m, onEvent);
|
|
731
1208
|
break;
|
|
732
1209
|
case 'implementer':
|
|
@@ -1210,13 +1687,15 @@ async function mockAgentGen(m, onEvent) {
|
|
|
1210
1687
|
? words[0] + words.slice(1).map((w) => w[0].toUpperCase() + w.slice(1)).join('')
|
|
1211
1688
|
: 'customAgent';
|
|
1212
1689
|
const meta = {
|
|
1213
|
-
key, displayName: name, description: `mock-generated agent for ${name}`,
|
|
1214
|
-
color: 'amber', runnerType: 'producer',
|
|
1215
|
-
asksQuestions: true, questionsLocked: false, questionsDefault: false,
|
|
1216
|
-
|
|
1690
|
+
metaVersion: 2, key, displayName: name, description: `mock-generated agent for ${name}`,
|
|
1691
|
+
color: 'amber', runnerType: 'producer', domain: 'general', fanOut: false,
|
|
1692
|
+
asksQuestions: true, questionsLocked: false, questionsDefault: false, order: 99,
|
|
1693
|
+
inputs: [{ id: 'plan', type: 'md', label: 'Plan' }],
|
|
1694
|
+
outputs: [{ id: 'review', type: 'md', filename: 'review-{cycle}.md' }],
|
|
1217
1695
|
};
|
|
1218
1696
|
if (m.MOCK_OUT) {
|
|
1219
|
-
const md = `# Agent: ${name}\n\nYou are ${name} (deterministic mock body).\n\n
|
|
1697
|
+
const md = `# Agent: ${name}\n\nYou are ${name} (deterministic mock body).\n\n`
|
|
1698
|
+
+ '## Ports\n\n- `plan` (in, md) — the plan to review.\n- `review` (out, md) — the review this agent writes.\n';
|
|
1220
1699
|
await ensureDir(m.MOCK_OUT);
|
|
1221
1700
|
await writeFile(m.MOCK_OUT, md, 'utf8');
|
|
1222
1701
|
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${m.MOCK_OUT}`, raw: { mock: true, file: m.MOCK_OUT } });
|