@worca/app 1.0.0 → 1.1.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 +22 -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 +319 -45
- 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 +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -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 +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- 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/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 +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- 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 +77 -14
- 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 +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -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 +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -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 +269 -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 +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- 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
|
@@ -36,12 +36,82 @@ import { spawn } from 'node:child_process';
|
|
|
36
36
|
import { createInterface } from 'node:readline';
|
|
37
37
|
import { prepareModelEnv } from './model-env.mjs';
|
|
38
38
|
import { classifyError, strongestClass } from './recoverable-error.mjs';
|
|
39
|
+
import { explainUnspawnableClaude, resolveClaudeBin } from './preflight.mjs';
|
|
39
40
|
import { writeFile, mkdir, appendFile, readFile, access } from 'node:fs/promises';
|
|
40
|
-
import { constants as FS } from 'node:fs';
|
|
41
|
+
import { constants as FS, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
41
42
|
import { dirname, join } from 'node:path';
|
|
43
|
+
import { tmpdir } from 'node:os';
|
|
42
44
|
|
|
43
45
|
const DEFAULT_BIN = process.env.WORCA_CLAUDE_BIN || process.env.ORCH_CLAUDE_BIN || 'claude';
|
|
44
46
|
|
|
47
|
+
// Grace between the abort SIGTERM and the SIGKILL escalation. Claude Code shuts
|
|
48
|
+
// down synchronously (fsync'd ~/.claude.json saves); a SIGKILL that lands inside
|
|
49
|
+
// such a write strands ~/.claude.json.tmp.<pid>.<hex> (2026-08-30: 2099 files,
|
|
50
|
+
// 4.4 GB, all from test runs under IO load). 5 s is generous on an idle disk
|
|
51
|
+
// (SIGTERM exits in ~0.5 s) and only delays a stop when the child is wedged.
|
|
52
|
+
export const DEFAULT_SIGKILL_GRACE_MS = 5000;
|
|
53
|
+
export function sigkillGraceMs() {
|
|
54
|
+
const n = Number(process.env.WORCA_SIGKILL_GRACE_MS);
|
|
55
|
+
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_SIGKILL_GRACE_MS;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What `--settings` carries, or null when there is nothing to carry (no hook
|
|
59
|
+
* telemetry, no permission rules) — then the flag is omitted entirely. */
|
|
60
|
+
export function buildSettingsPayload(permissionRules) {
|
|
61
|
+
const hook = buildHookSettings();
|
|
62
|
+
const hasRules = !!permissionRules && Object.values(permissionRules).some((a) => Array.isArray(a) && a.length);
|
|
63
|
+
// Present-but-malformed rules (e.g. `{deny: 'Bash(curl:*)'}`) make the object
|
|
64
|
+
// truthy while hasRules stays false, so the whole policy would drop out of
|
|
65
|
+
// argv silently. Say it once, then take the same no-rules path (fail-open,
|
|
66
|
+
// matching the guardrail-set read path) — the empty/absent cases ({}, {deny: []}, null)
|
|
67
|
+
// are normal and stay quiet.
|
|
68
|
+
if (!hasRules && permissionRules && typeof permissionRules === 'object'
|
|
69
|
+
&& Object.values(permissionRules).some((a) => a != null && !Array.isArray(a))) {
|
|
70
|
+
console.warn('[worca] guardrails: permissionRules is malformed (deny/allow/ask must be arrays of strings) — ignoring it; this spawn carries NO permission rules');
|
|
71
|
+
}
|
|
72
|
+
if (!hook && !hasRules) return null;
|
|
73
|
+
const settings = {};
|
|
74
|
+
if (hook) settings.hooks = hook.hooks;
|
|
75
|
+
if (hasRules) settings.permissions = permissionRules;
|
|
76
|
+
return { hook: !!hook, settings };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Largest command line we hand to spawn() inline (GH #380). Windows caps the
|
|
81
|
+
* whole CreateProcess command line at 32,767 chars and Linux caps a single
|
|
82
|
+
* argument at 128 KiB, and a real task prompt (a 1000-line markdown plus the
|
|
83
|
+
* rendered channel artifacts) sails past both — `spawn ENAMETOOLONG` / E2BIG at
|
|
84
|
+
* the first node. Above this limit the prompt travels on stdin and the system
|
|
85
|
+
* prompt / settings as files (planClaudeInvocation); below it the argv is
|
|
86
|
+
* byte-identical to what it always was. The figure leaves ~12K of headroom
|
|
87
|
+
* under the Windows cap for the exe path, quoting, and flags this measure
|
|
88
|
+
* cannot see, and is deliberately platform-independent so the offload path is
|
|
89
|
+
* exercised (and testable) everywhere, not only on Windows.
|
|
90
|
+
*
|
|
91
|
+
* Inline JSON, or the path of a file holding that same JSON when the invocation
|
|
92
|
+
* is staged (GH #380 — the CLI accepts either).
|
|
93
|
+
*/
|
|
94
|
+
export const ARGV_INLINE_LIMIT = 20000;
|
|
95
|
+
|
|
96
|
+
/** Conservative size of the command line spawn() would build: every argument
|
|
97
|
+
* quoted and space-separated, after the binary. Over-counts slightly on
|
|
98
|
+
* purpose (a prompt with embedded quotes grows under Windows escaping). */
|
|
99
|
+
export function argvLength(bin, args) {
|
|
100
|
+
return String(bin || '').length + args.reduce((n, a) => n + String(a).length + 3, 0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Log each npm-shim resolution once per process, not once per spawn. */
|
|
104
|
+
const _resolveNoted = new Set();
|
|
105
|
+
|
|
106
|
+
/** The spawn-failure Error for `bin`: the OS message, plus the Windows npm-shim
|
|
107
|
+
* explanation when that is what actually went wrong (ENOENT on a bare name
|
|
108
|
+
* whose only PATH hit is claude.cmd; EINVAL on an explicit .cmd). */
|
|
109
|
+
function spawnFailure(bin, err, prefix) {
|
|
110
|
+
const hint = /ENOENT|EINVAL/.test(String(err && err.code || err && err.message || ''))
|
|
111
|
+
? explainUnspawnableClaude(bin) : null;
|
|
112
|
+
return new Error(`${prefix}: ${err.message}${hint ? ` — ${hint}` : ''}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
45
115
|
// Cap for the stderr detail embedded in a non-zero-exit Error message. The
|
|
46
116
|
// audit trail and the UI error banner consume that message; an uncapped
|
|
47
117
|
// stderrBuf (hundreds of KB of MCP/retry chatter) must not ride into them when
|
|
@@ -108,27 +178,15 @@ export function buildHookSettings() {
|
|
|
108
178
|
* flags would be last-wins at the CLI, silently dropping one payload.
|
|
109
179
|
* [] when there is nothing to say, so the baseline argv is byte-identical.
|
|
110
180
|
* @param {{deny?:string[],allow?:string[],ask?:string[]}|null|undefined} permissionRules
|
|
181
|
+
* @param {string|null} [settingsFile] staged path (GH #380): `--settings <path>` carries the same JSON
|
|
111
182
|
* @returns {string[]}
|
|
112
183
|
*/
|
|
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;
|
|
184
|
+
export function buildSettingsArgs(permissionRules, settingsFile = null) {
|
|
185
|
+
const payload = buildSettingsPayload(permissionRules);
|
|
186
|
+
if (!payload) return [];
|
|
129
187
|
const args = [];
|
|
130
|
-
if (hook) args.push('--include-hook-events');
|
|
131
|
-
args.push('--settings', JSON.stringify(settings));
|
|
188
|
+
if (payload.hook) args.push('--include-hook-events');
|
|
189
|
+
args.push('--settings', settingsFile || JSON.stringify(payload.settings));
|
|
132
190
|
return args;
|
|
133
191
|
}
|
|
134
192
|
|
|
@@ -185,7 +243,7 @@ export function buildSpawnEnv(envScrub, envAllowlist) {
|
|
|
185
243
|
* passed through by the orchestrator (handled by caller mapping mock->env or
|
|
186
244
|
* by passing systemPrompt/prompt markers; we also honor a `mock` field).
|
|
187
245
|
*/
|
|
188
|
-
function mockEnabled(opts) {
|
|
246
|
+
export function mockEnabled(opts) {
|
|
189
247
|
if (opts && opts.mock) return true;
|
|
190
248
|
const v = process.env.WORCA_MOCK ?? process.env.ORCH_MOCK;
|
|
191
249
|
return !!v && v !== '0' && v.toLowerCase() !== 'false';
|
|
@@ -217,10 +275,23 @@ function mockEnabled(opts) {
|
|
|
217
275
|
* @param {string[]} [o.envAllowlist] guardrail: extra env var names to keep under scrub
|
|
218
276
|
* @param {Record<string,string>} [o.modelEnv] per-model routing env (design §4.4), merged
|
|
219
277
|
* 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
|
|
278
|
+
* config outranks ambient-env hygiene); reserved keys are re-dropped here defensively.
|
|
279
|
+
* An ANTHROPIC_MODEL key is the WIRE id (#374): it replaces `model` in the spawned
|
|
280
|
+
* `--model` flag, while `model` (the catalog id) stays worca's handle everywhere else
|
|
221
281
|
* @param {string[]} [o.workspaceWriteTargets] §8.10 MOCK-ONLY member checkouts the mock
|
|
222
282
|
* implementer writes into instead of `cwd` (empty/absent => today's cwd behavior).
|
|
223
283
|
* Never reaches argv: `runReal` ignores it by construction.
|
|
284
|
+
* @param {string[]} [o.tools] --tools <list>: the built-in tool allowlist ([] ⇒ `--tools ""`,
|
|
285
|
+
* no built-ins at all; MCP tools are unaffected). Absent ⇒ flag omitted (claude defaults).
|
|
286
|
+
* @param {boolean} [o.strictMcpConfig] --strict-mcp-config: only --mcp-config servers load
|
|
287
|
+
* @param {string[]} [o.settingSources] --setting-sources <list> (e.g. ['project'] drops user hooks/plugins/skills)
|
|
288
|
+
* @param {boolean} [o.disableSlashCommands] --disable-slash-commands
|
|
289
|
+
* @param {boolean} [o.includePartialMessages] --include-partial-messages (stream_event text deltas)
|
|
290
|
+
* @param {number} [o.maxTurns] --max-turns <n> (positive safe integer; else omitted)
|
|
291
|
+
* @param {number|null} [o.maxBudgetUsd] --max-budget-usd <n> (finite > 0; null/else omitted)
|
|
292
|
+
* @param {string} [o.appendSubagentSystemPrompt] --append-subagent-system-prompt <text> (Task children only)
|
|
293
|
+
* All eight are Ask Worca sandbox options (ask-worca-design.md §6.3) and default-off.
|
|
294
|
+
* @param {number} [o.argvInlineLimit] override ARGV_INLINE_LIMIT (GH #380; tests force the staged path)
|
|
224
295
|
* @returns {Promise<{text:string, exitCode:number}>}
|
|
225
296
|
*/
|
|
226
297
|
export async function runClaude(o = {}) {
|
|
@@ -247,6 +318,17 @@ export async function runClaude(o = {}) {
|
|
|
247
318
|
modelEnv,
|
|
248
319
|
workspaceWriteTargets,
|
|
249
320
|
resumeSessionId,
|
|
321
|
+
// Ask Worca sandbox hardening (ask-worca-design.md §6.3/§6.8). All default-off:
|
|
322
|
+
// undefined here ⇒ nothing emitted ⇒ every legacy argv stays byte-identical.
|
|
323
|
+
tools,
|
|
324
|
+
strictMcpConfig,
|
|
325
|
+
settingSources,
|
|
326
|
+
disableSlashCommands,
|
|
327
|
+
includePartialMessages,
|
|
328
|
+
maxTurns,
|
|
329
|
+
maxBudgetUsd,
|
|
330
|
+
appendSubagentSystemPrompt,
|
|
331
|
+
argvInlineLimit,
|
|
250
332
|
bin = DEFAULT_BIN,
|
|
251
333
|
} = o;
|
|
252
334
|
|
|
@@ -261,7 +343,7 @@ export async function runClaude(o = {}) {
|
|
|
261
343
|
// workspaceWriteTargets is the one option that is mock-ONLY (§8.10) and it must be
|
|
262
344
|
// named HERE too, or the mock implementer never sees it (this call is a gate, not
|
|
263
345
|
// a pass-through; test/spawn-args.test.mjs asserts the forwarding end to end).
|
|
264
|
-
return runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets });
|
|
346
|
+
return runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets, permissionMode });
|
|
265
347
|
}
|
|
266
348
|
|
|
267
349
|
return runReal({
|
|
@@ -282,6 +364,15 @@ export async function runClaude(o = {}) {
|
|
|
282
364
|
envScrub,
|
|
283
365
|
envAllowlist,
|
|
284
366
|
modelEnv,
|
|
367
|
+
tools,
|
|
368
|
+
strictMcpConfig,
|
|
369
|
+
settingSources,
|
|
370
|
+
disableSlashCommands,
|
|
371
|
+
includePartialMessages,
|
|
372
|
+
maxTurns,
|
|
373
|
+
maxBudgetUsd,
|
|
374
|
+
appendSubagentSystemPrompt,
|
|
375
|
+
argvInlineLimit,
|
|
285
376
|
});
|
|
286
377
|
}
|
|
287
378
|
|
|
@@ -308,11 +399,23 @@ export async function runClaude(o = {}) {
|
|
|
308
399
|
export function buildClaudeArgs({
|
|
309
400
|
prompt, systemPrompt, permissionMode, model, effort, allowedTools, resumeSessionId,
|
|
310
401
|
mcpConfigPath, mcpServerGrants, permissionRules,
|
|
311
|
-
|
|
312
|
-
|
|
402
|
+
// Ask Worca hardening options (ask-worca-design.md §6.3). `tools` is renamed on the
|
|
403
|
+
// way in because the legacy body below already owns a local `tools` (the
|
|
404
|
+
// --allowedTools union).
|
|
405
|
+
tools: builtinTools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages,
|
|
406
|
+
maxTurns, maxBudgetUsd, appendSubagentSystemPrompt,
|
|
407
|
+
}, delivery = {}) {
|
|
408
|
+
// delivery (GH #380, set only by planClaudeInvocation's staged branch):
|
|
409
|
+
// promptViaStdin -> bare `-p`; the prompt is written to the child's stdin
|
|
410
|
+
// systemPromptFile -> `--append-system-prompt-file <path>` instead of the text
|
|
411
|
+
// settingsFile -> `--settings <path>` instead of the inline JSON
|
|
412
|
+
const { promptViaStdin = false, systemPromptFile = null, settingsFile = null } = delivery;
|
|
413
|
+
const args = promptViaStdin ? ['-p'] : ['-p', prompt];
|
|
414
|
+
args.push('--output-format', 'stream-json', '--verbose', '--permission-mode', permissionMode);
|
|
313
415
|
if (resumeSessionId) args.push('--resume', resumeSessionId);
|
|
314
416
|
if (systemPrompt) {
|
|
315
|
-
args.push('--append-system-prompt',
|
|
417
|
+
if (systemPromptFile) args.push('--append-system-prompt-file', systemPromptFile);
|
|
418
|
+
else args.push('--append-system-prompt', systemPrompt);
|
|
316
419
|
}
|
|
317
420
|
if (model) {
|
|
318
421
|
args.push('--model', model);
|
|
@@ -323,7 +426,7 @@ export function buildClaudeArgs({
|
|
|
323
426
|
// SINGLE inline JSON (two --settings flags would be last-wins at the CLI). [] when
|
|
324
427
|
// there is neither, so the baseline argv is unchanged; a CLI that rejects these
|
|
325
428
|
// flags would only ever fail when the operator opted in.
|
|
326
|
-
for (const a of buildSettingsArgs(permissionRules)) args.push(a);
|
|
429
|
+
for (const a of buildSettingsArgs(permissionRules, settingsFile)) args.push(a);
|
|
327
430
|
if (mcpConfigPath) args.push('--mcp-config', mcpConfigPath);
|
|
328
431
|
const tools = Array.isArray(allowedTools) ? allowedTools.slice() : [];
|
|
329
432
|
for (const s of (Array.isArray(mcpServerGrants) ? mcpServerGrants : [])) {
|
|
@@ -332,43 +435,181 @@ export function buildClaudeArgs({
|
|
|
332
435
|
if (tools.length) {
|
|
333
436
|
args.push('--allowedTools', tools.join(','));
|
|
334
437
|
}
|
|
438
|
+
// ── Ask Worca hardening flags (ask-worca-design.md §6.3 / §6.8) ──────────────
|
|
439
|
+
// Every one is default-off: absent / false / invalid ⇒ NOTHING is emitted, so
|
|
440
|
+
// every legacy argv stays byte-identical (test/spawn-args.test.mjs). Appended
|
|
441
|
+
// AFTER the legacy block so the baseline prefix never moves. Probed on 2.1.239:
|
|
442
|
+
// `--tools ""` = no built-in tools (MCP tools survive); the hidden `--max-turns`
|
|
443
|
+
// and `--append-subagent-system-prompt` are accepted and enforced.
|
|
444
|
+
// Filter to usable names FIRST, then decide: testing the RAW list while emitting
|
|
445
|
+
// the FILTERED join made `settingSources: [1]` emit `--setting-sources ""` (where
|
|
446
|
+
// `[]` emits nothing) and `['Read', '']` emit a trailing comma.
|
|
447
|
+
const names = (v) => (Array.isArray(v) ? v.filter((s) => typeof s === 'string' && s) : []);
|
|
448
|
+
// --tools is the one list whose empty value is meaningful (`--tools ""` = no
|
|
449
|
+
// built-in tools at all, §6.3), so the ARRAY decides whether the flag is emitted.
|
|
450
|
+
if (Array.isArray(builtinTools)) {
|
|
451
|
+
args.push('--tools', names(builtinTools).join(','));
|
|
452
|
+
}
|
|
453
|
+
if (strictMcpConfig === true) args.push('--strict-mcp-config');
|
|
454
|
+
const sources = names(settingSources);
|
|
455
|
+
if (sources.length) {
|
|
456
|
+
args.push('--setting-sources', sources.join(','));
|
|
457
|
+
}
|
|
458
|
+
if (disableSlashCommands === true) args.push('--disable-slash-commands');
|
|
459
|
+
if (includePartialMessages === true) args.push('--include-partial-messages');
|
|
460
|
+
if (Number.isSafeInteger(maxTurns) && maxTurns > 0) args.push('--max-turns', String(maxTurns));
|
|
461
|
+
if (typeof maxBudgetUsd === 'number' && Number.isFinite(maxBudgetUsd) && maxBudgetUsd > 0) {
|
|
462
|
+
args.push('--max-budget-usd', String(maxBudgetUsd));
|
|
463
|
+
}
|
|
464
|
+
if (typeof appendSubagentSystemPrompt === 'string' && appendSubagentSystemPrompt) {
|
|
465
|
+
args.push('--append-subagent-system-prompt', appendSubagentSystemPrompt);
|
|
466
|
+
}
|
|
335
467
|
return args;
|
|
336
468
|
}
|
|
337
469
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
470
|
+
/**
|
|
471
|
+
* Decide how ONE invocation reaches the CLI (GH #380). Pure: no I/O.
|
|
472
|
+
* - inline (the common case): `args` is exactly buildClaudeArgs(opts); `stdin`
|
|
473
|
+
* null; `files` empty.
|
|
474
|
+
* - staged (argv over `limit`): the prompt goes on stdin (`-p` reads it — the
|
|
475
|
+
* model sees the exact text, unlike a "read this file" instruction), the
|
|
476
|
+
* system prompt and the settings JSON become files under `dir`, and no
|
|
477
|
+
* argument carries free text any more, so the argv is short by construction.
|
|
478
|
+
* @param {object} opts the buildClaudeArgs options
|
|
479
|
+
* @param {{bin?:string, dir?:string|(() => string), limit?:number}} [o] `dir` may be a
|
|
480
|
+
* factory, called only when staging is actually needed (so the caller creates
|
|
481
|
+
* a temp dir exactly when one will be used)
|
|
482
|
+
* @returns {{args:string[], stdin:string|null, files:{path:string,content:string}[], staged:boolean, inlineLength:number}}
|
|
483
|
+
*/
|
|
484
|
+
export function planClaudeInvocation(opts, { bin = DEFAULT_BIN, dir = null, limit = ARGV_INLINE_LIMIT } = {}) {
|
|
485
|
+
const inline = buildClaudeArgs(opts);
|
|
486
|
+
const inlineLength = argvLength(bin, inline);
|
|
487
|
+
if (inlineLength <= limit) return { args: inline, stdin: null, files: [], staged: false, inlineLength };
|
|
488
|
+
if (!dir) throw new Error('planClaudeInvocation: a staging dir is required when the argv is over the limit');
|
|
489
|
+
if (typeof dir === 'function') dir = dir();
|
|
490
|
+
const files = [];
|
|
491
|
+
const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
|
|
492
|
+
const promptViaStdin = prompt.length > 0; // an empty prompt stays `-p ''` — nothing to pipe
|
|
493
|
+
let systemPromptFile = null;
|
|
494
|
+
if (opts.systemPrompt) {
|
|
495
|
+
systemPromptFile = join(dir, 'system-prompt.md');
|
|
496
|
+
files.push({ path: systemPromptFile, content: opts.systemPrompt });
|
|
497
|
+
}
|
|
498
|
+
let settingsFile = null;
|
|
499
|
+
const payload = buildSettingsPayload(opts.permissionRules);
|
|
500
|
+
if (payload) {
|
|
501
|
+
settingsFile = join(dir, 'settings.json');
|
|
502
|
+
files.push({ path: settingsFile, content: JSON.stringify(payload.settings) });
|
|
503
|
+
}
|
|
504
|
+
const args = buildClaudeArgs(opts, { promptViaStdin, systemPromptFile, settingsFile });
|
|
505
|
+
return { args, stdin: promptViaStdin ? prompt : null, files, staged: true, inlineLength };
|
|
506
|
+
}
|
|
344
507
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
508
|
+
/** planClaudeInvocation + the I/O: a private temp dir is created and the files
|
|
509
|
+
* written ONLY on the staged branch (`dir` is null otherwise, so the caller has
|
|
510
|
+
* nothing to clean up). Synchronous on purpose — a few hundred KB once per
|
|
511
|
+
* spawn, and it keeps the spawn sequence in runReal linear. */
|
|
512
|
+
export function stageClaudeInvocation(opts, { bin = DEFAULT_BIN, limit = ARGV_INLINE_LIMIT } = {}) {
|
|
513
|
+
let dir = null;
|
|
514
|
+
const plan = planClaudeInvocation(opts, { bin, limit, dir: () => (dir = mkdtempSync(join(tmpdir(), 'worca-claude-'))) });
|
|
515
|
+
for (const file of plan.files) writeFileSync(file.path, file.content, 'utf8');
|
|
516
|
+
return { ...plan, dir };
|
|
517
|
+
}
|
|
348
518
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
let
|
|
519
|
+
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 }) {
|
|
520
|
+
return new Promise((resolveP, rejectP) => {
|
|
521
|
+
// Per-model routing env (design §4.4), prepared BEFORE argv: reserved keys
|
|
522
|
+
// are re-dropped here defensively — the write path already rejects them, so
|
|
523
|
+
// a drop means a hand-edited settings file — and the surviving map is also
|
|
524
|
+
// where the wire id (below) is read from.
|
|
525
|
+
let safeModelEnv = null;
|
|
526
|
+
let wireModelDropped = false;
|
|
357
527
|
if (modelEnv && Object.keys(modelEnv).length) {
|
|
358
528
|
const { env: safe, dropped } = prepareModelEnv(modelEnv);
|
|
359
529
|
for (const k of dropped) {
|
|
360
530
|
console.warn(`[worca] modelEnv: dropping reserved/invalid key ${JSON.stringify(k)}`);
|
|
361
531
|
}
|
|
362
|
-
|
|
532
|
+
// A configured wire id that didn't survive (unresolvable ${VAR}, empty, or
|
|
533
|
+
// whitespace-only) fell into `dropped`: we silently fall back to the catalog
|
|
534
|
+
// id below, so warn specifically — the generic drop line above doesn't say
|
|
535
|
+
// the argv model changed, and the wire-model line never fires (ids match).
|
|
536
|
+
wireModelDropped = 'ANTHROPIC_MODEL' in modelEnv && dropped.includes('ANTHROPIC_MODEL');
|
|
537
|
+
if (Object.keys(safe).length) safeModelEnv = safe;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Wire id (#374): ANTHROPIC_MODEL in the resolved model env names the id the
|
|
541
|
+
// ENDPOINT should see; the catalog id stays worca's handle (config refs, cost
|
|
542
|
+
// flags). Passed as an explicit --model — self-documenting in logs and immune
|
|
543
|
+
// to CLI flag/env precedence — so the env var alone would otherwise be dead.
|
|
544
|
+
const wireModel = safeModelEnv?.ANTHROPIC_MODEL || model;
|
|
545
|
+
if (wireModelDropped && wireModel === model) {
|
|
546
|
+
console.warn(`[worca] model ${JSON.stringify(model ?? '')}: configured wire model was dropped (unresolved/empty) — using the catalog id`);
|
|
547
|
+
} else if (wireModel !== model) {
|
|
548
|
+
console.warn(`[worca] model ${JSON.stringify(model ?? '')}: wire model ${JSON.stringify(wireModel)}`);
|
|
363
549
|
}
|
|
364
550
|
|
|
551
|
+
// Windows + npm-installed Claude Code: the bare name is a .cmd shim Node
|
|
552
|
+
// cannot spawn; resolveClaudeBin swaps in the package's native claude.exe.
|
|
553
|
+
// Everywhere else this is `bin` unchanged. Resolved BEFORE the argv plan so
|
|
554
|
+
// the command-line measure below counts the path that is actually spawned.
|
|
555
|
+
const resolved = resolveClaudeBin(bin);
|
|
556
|
+
if (resolved.note && !_resolveNoted.has(resolved.bin)) {
|
|
557
|
+
_resolveNoted.add(resolved.bin);
|
|
558
|
+
console.warn(`[worca] ${resolved.note}`);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// GH #380: inline argv when it fits, else prompt on stdin + files (see
|
|
562
|
+
// ARGV_INLINE_LIMIT). The staging dir, when any, is removed on every
|
|
563
|
+
// terminal path below (finish) and on a failed spawn.
|
|
564
|
+
const limit = Number.isFinite(argvInlineLimit) && argvInlineLimit > 0 ? argvInlineLimit : ARGV_INLINE_LIMIT;
|
|
565
|
+
let plan;
|
|
566
|
+
try {
|
|
567
|
+
plan = stageClaudeInvocation({
|
|
568
|
+
prompt, systemPrompt, permissionMode, model: wireModel, effort, allowedTools, resumeSessionId,
|
|
569
|
+
mcpConfigPath, mcpServerGrants, permissionRules,
|
|
570
|
+
tools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages,
|
|
571
|
+
maxTurns, maxBudgetUsd, appendSubagentSystemPrompt,
|
|
572
|
+
}, { bin: resolved.bin, limit });
|
|
573
|
+
} catch (err) {
|
|
574
|
+
rejectP(new Error(`Failed to stage the claude prompt files: ${err.message}`));
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const { args } = plan;
|
|
578
|
+
const cleanupStaged = () => {
|
|
579
|
+
if (!plan.dir) return;
|
|
580
|
+
try { rmSync(plan.dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
581
|
+
};
|
|
582
|
+
if (plan.staged) {
|
|
583
|
+
console.warn(`[worca] claude argv would be ${plan.inlineLength} chars (limit ${limit}): prompt on stdin, system prompt/settings as files`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// undefined when the guardrail is off, and the spread then adds NO `env` key —
|
|
587
|
+
// spawn inherits process.env exactly as it did before guardrails existed.
|
|
588
|
+
const guardrailEnv = buildSpawnEnv(envScrub, envAllowlist);
|
|
589
|
+
|
|
590
|
+
// Model env merges LAST: it survives scrub and wins collisions (explicit
|
|
591
|
+
// operator config outranks ambient-env hygiene). With no modelEnv (or
|
|
592
|
+
// nothing surviving the filter) the spawn env is byte-identical to the
|
|
593
|
+
// pre-feature behavior, including the undefined -> inherit-process.env case.
|
|
594
|
+
let spawnEnv = guardrailEnv;
|
|
595
|
+
if (safeModelEnv) spawnEnv = { ...(guardrailEnv ?? process.env), ...safeModelEnv };
|
|
596
|
+
|
|
365
597
|
let child;
|
|
366
598
|
try {
|
|
367
|
-
child = spawn(bin, args, {
|
|
599
|
+
child = spawn(resolved.bin, args, {
|
|
600
|
+
cwd, stdio: [plan.stdin != null ? 'pipe' : 'ignore', 'pipe', 'pipe'], ...(spawnEnv ? { env: spawnEnv } : {}),
|
|
601
|
+
});
|
|
368
602
|
} catch (err) {
|
|
369
|
-
|
|
603
|
+
cleanupStaged();
|
|
604
|
+
rejectP(spawnFailure(bin, err, `Failed to spawn ${bin}`));
|
|
370
605
|
return;
|
|
371
606
|
}
|
|
607
|
+
if (plan.stdin != null) {
|
|
608
|
+
// A child that dies before draining stdin (bad flag, ENOENT surfaced
|
|
609
|
+
// late) raises EPIPE here; the 'error'/'close' handlers own the real cause.
|
|
610
|
+
child.stdin.on('error', () => {});
|
|
611
|
+
child.stdin.end(plan.stdin, 'utf8');
|
|
612
|
+
}
|
|
372
613
|
|
|
373
614
|
let resultText = '';
|
|
374
615
|
let assistantText = '';
|
|
@@ -396,7 +637,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
396
637
|
} catch {
|
|
397
638
|
/* ignore */
|
|
398
639
|
}
|
|
399
|
-
},
|
|
640
|
+
}, sigkillGraceMs()).unref?.();
|
|
400
641
|
};
|
|
401
642
|
if (signal) {
|
|
402
643
|
if (signal.aborted) onAbort();
|
|
@@ -407,6 +648,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
407
648
|
if (settled) return;
|
|
408
649
|
settled = true;
|
|
409
650
|
if (signal) signal.removeEventListener?.('abort', onAbort);
|
|
651
|
+
cleanupStaged();
|
|
410
652
|
fn(arg);
|
|
411
653
|
};
|
|
412
654
|
|
|
@@ -486,7 +728,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
486
728
|
});
|
|
487
729
|
|
|
488
730
|
child.on('error', (err) => {
|
|
489
|
-
finish(rejectP,
|
|
731
|
+
finish(rejectP, spawnFailure(bin, err, `${bin} error`));
|
|
490
732
|
});
|
|
491
733
|
|
|
492
734
|
child.on('close', (code) => {
|
|
@@ -611,6 +853,24 @@ async function emitLog(onEvent, text) {
|
|
|
611
853
|
await new Promise((r) => setTimeout(r, 0));
|
|
612
854
|
}
|
|
613
855
|
|
|
856
|
+
/**
|
|
857
|
+
* The roles the offline mock runner can SERVE — one per arm of the role switch
|
|
858
|
+
* below (the `ask` arm is the Ask-Worca assistant, not a writer role). Exported
|
|
859
|
+
* because three consumers need the vocabulary and none of them may hard-code it:
|
|
860
|
+
* meta v2 validation (an unknown `mockRole` is a warning + drop), GET /api/agents
|
|
861
|
+
* (the Agents view's role picker) and the graph executor's mock-role chain.
|
|
862
|
+
* test/mock-writer-roles.test.mjs parses the switch and pins the lockstep.
|
|
863
|
+
*/
|
|
864
|
+
export const MOCK_WRITER_ROLES = new Set([
|
|
865
|
+
'clarify', 'planner-plan', 'refiner', 'decomposer', 'implementer', 'reviewer', 'plan-review',
|
|
866
|
+
'workspace-scan', 'agent-gen', 'workspace-reviewer', 'manual-tests-checklist', 'manual-web-ui-testing',
|
|
867
|
+
'generic-producer', 'generic-verifier',
|
|
868
|
+
]);
|
|
869
|
+
|
|
870
|
+
/** Named so the executor's mock-role chain and the switch cannot drift apart. */
|
|
871
|
+
export const MOCK_ROLE_CLARIFY = 'clarify';
|
|
872
|
+
export const MOCK_ROLE_DECOMPOSER = 'decomposer';
|
|
873
|
+
|
|
614
874
|
/**
|
|
615
875
|
* The mock-fan-out roles (mirror the orchestrator's FANOUT_ELIGIBLE intent): the
|
|
616
876
|
* roles whose real runs may spawn sub-agents. Keyed by the MOCK_ROLE strings.
|
|
@@ -675,6 +935,109 @@ async function emitMockSubAgents(role, onEvent, signal) {
|
|
|
675
935
|
}
|
|
676
936
|
}
|
|
677
937
|
|
|
938
|
+
// ── Ask Worca mock role (ask-worca-design.md §6.7) ───────────────────────────
|
|
939
|
+
|
|
940
|
+
/** Emit a raw stream-json frame through the SAME envelope runReal uses (the rl 'line' handler above). */
|
|
941
|
+
function emitRaw(onEvent, raw) {
|
|
942
|
+
const cost = extractResultCost(raw);
|
|
943
|
+
const text = extractText(raw);
|
|
944
|
+
safeEmit(onEvent, { type: raw.type, raw, text: text || undefined, ...(cost != null ? { costUsd: cost } : {}) });
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const ASK_CONTEXT_BLOCK_RE = /\[worca context\][\s\S]*?\[\/worca context\]\s*/;
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* The offline Ask Worca assistant: frames in the shapes probed on claude 2.1.239
|
|
951
|
+
* (system/init → message_start → text deltas → assistant blocks → tool_use /
|
|
952
|
+
* tool_result pairs → message_delta → result), chosen from the USER text so
|
|
953
|
+
* tests control the scenario. Never touches the filesystem, never reads prompt
|
|
954
|
+
* markers, never spawns the MCP child. The limit / failure scenarios emit their
|
|
955
|
+
* `result` frame and then REJECT exactly like the real CLI (exit 1, empty stderr).
|
|
956
|
+
*/
|
|
957
|
+
async function mockAsk({ markers, prompt, cwd, onEvent, signal, resumeSessionId }) {
|
|
958
|
+
const userText = String(prompt ?? '').replace(ASK_CONTEXT_BLOCK_RE, '');
|
|
959
|
+
let card = {};
|
|
960
|
+
try { card = markers.MOCK_ASK_CARD ? JSON.parse(markers.MOCK_ASK_CARD) : {}; } catch { card = {}; }
|
|
961
|
+
if (!card || typeof card !== 'object' || Array.isArray(card)) card = {};
|
|
962
|
+
const fail = /\bMOCK_FAIL\b/.test(userText);
|
|
963
|
+
const maxTurns = /\bMOCK_MAX_TURNS\b/.test(userText);
|
|
964
|
+
const maxBudget = /\bMOCK_MAX_BUDGET\b/.test(userText);
|
|
965
|
+
const slow = /\bMOCK_SLOW\b/.test(userText);
|
|
966
|
+
const agents = /\bagents?\b/i.test(userText);
|
|
967
|
+
const propose = /\b(propose|start|run)\b/i.test(userText);
|
|
968
|
+
|
|
969
|
+
const SID = resumeSessionId || 'mock-session-ask-1';
|
|
970
|
+
const USAGE = { input_tokens: 10, output_tokens: 20, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
|
|
971
|
+
const firstLine = userText.split(/\r?\n/).map((l) => l.trim()).find(Boolean) || '';
|
|
972
|
+
const ANSWER = `[mock] ${firstLine.slice(0, 200)}`;
|
|
973
|
+
const init = { type: 'system', subtype: 'init', session_id: SID, cwd, model: 'mock', permissionMode: 'dontAsk',
|
|
974
|
+
tools: ['Task', 'mcp__worca__list_runs', 'mcp__worca__get_run', 'mcp__worca__propose_run'],
|
|
975
|
+
mcp_servers: [{ name: 'worca', status: 'connected' }], plugins: [], skills: [], slash_commands: [], agents: [], uuid: 'mock-uuid-init' };
|
|
976
|
+
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 });
|
|
977
|
+
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 });
|
|
978
|
+
const mdelta = { type: 'stream_event', event: { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: USAGE }, parent_tool_use_id: null, session_id: SID };
|
|
979
|
+
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 });
|
|
980
|
+
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 });
|
|
981
|
+
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 });
|
|
982
|
+
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,
|
|
983
|
+
usage: USAGE, modelUsage: {}, permission_denials: [], terminal_reason: 'completed', result: ANSWER, ...over });
|
|
984
|
+
const MSG1 = 'msg_mock_ask_1';
|
|
985
|
+
const MSG2 = 'msg_mock_ask_2';
|
|
986
|
+
|
|
987
|
+
const frames = [init, mstart(MSG1)];
|
|
988
|
+
if (fail) {
|
|
989
|
+
frames.push(result({ subtype: 'error_during_execution', is_error: true, errors: ['mock failure'], terminal_reason: 'api_error', result: 'mock failure', num_turns: 0 }));
|
|
990
|
+
} else if (maxTurns || maxBudget) {
|
|
991
|
+
frames.push(delta('[mock] '), delta('partial'), atext(MSG1, '[mock] partial'),
|
|
992
|
+
atool(MSG1, 'toolu_mock_1', 'mcp__worca__list_runs', {}), uresult('toolu_mock_1', '[]'));
|
|
993
|
+
frames.push(maxTurns
|
|
994
|
+
? 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 })
|
|
995
|
+
: result({ subtype: 'error_max_budget_usd', is_error: true, errors: ['Reached maximum budget ($0.0001)'], terminal_reason: 'budget_exhausted', result: undefined }));
|
|
996
|
+
} else {
|
|
997
|
+
let answerMsg = MSG1;
|
|
998
|
+
if (agents) {
|
|
999
|
+
frames.push(
|
|
1000
|
+
atool(MSG1, 'toolu_mock_task', 'Agent', { description: 'count runs', subagent_type: 'general-purpose', prompt: 'count the runs' }),
|
|
1001
|
+
atool('msg_mock_child_1', 'toolu_mock_child_1', 'mcp__worca__list_runs', {}, 'toolu_mock_task'),
|
|
1002
|
+
uresult('toolu_mock_child_1', '[]', 'toolu_mock_task'),
|
|
1003
|
+
uresult('toolu_mock_task', 'count: 0', null, { tool_use_result: {
|
|
1004
|
+
status: 'completed', agentId: 'mock-agent-1', agentType: 'general-purpose', content: [{ type: 'text', text: 'count: 0' }],
|
|
1005
|
+
resolvedModel: 'mock-haiku', totalDurationMs: 10, totalTokens: 1234, totalToolUseCount: 1,
|
|
1006
|
+
usage: { input_tokens: 1000, output_tokens: 234, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
|
|
1007
|
+
} }),
|
|
1008
|
+
);
|
|
1009
|
+
answerMsg = MSG2;
|
|
1010
|
+
}
|
|
1011
|
+
if (propose) {
|
|
1012
|
+
frames.push(delta('[mock] '), delta('preparing '), delta('a run'), atext(MSG1, 'Preparing a run card.'),
|
|
1013
|
+
atool(MSG1, 'toolu_mock_propose', 'mcp__worca__propose_run', card), uresult('toolu_mock_propose', JSON.stringify({ ok: true })));
|
|
1014
|
+
answerMsg = MSG2;
|
|
1015
|
+
}
|
|
1016
|
+
if (answerMsg !== MSG1) frames.push(mstart(answerMsg));
|
|
1017
|
+
frames.push(delta('[mock] '), delta(firstLine.slice(0, 200)), atext(answerMsg, ANSWER), mdelta);
|
|
1018
|
+
frames.push(result(agents
|
|
1019
|
+
? { modelUsage: { 'mock-haiku': { inputTokens: 1000, outputTokens: 234, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, costUSD: 0, canonicalModel: 'mock-haiku' } } }
|
|
1020
|
+
: {}));
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
safeEmit(onEvent, { type: 'session', sessionId: SID });
|
|
1024
|
+
for (const f of frames) {
|
|
1025
|
+
abortIfNeeded(signal);
|
|
1026
|
+
emitRaw(onEvent, f);
|
|
1027
|
+
await new Promise((r) => setTimeout(r, slow ? 300 : 0));
|
|
1028
|
+
}
|
|
1029
|
+
abortIfNeeded(signal);
|
|
1030
|
+
if (fail || maxTurns || maxBudget) {
|
|
1031
|
+
// Probed on 2.1.239: these subtypes exit 1 with EMPTY stderr, so runReal rejects with the
|
|
1032
|
+
// stdout `result` text (MOCK_FAIL) or 'no stderr' (the limits). turn.mjs (P2) reads the
|
|
1033
|
+
// reducer's resultSubtype before classifying the rejection.
|
|
1034
|
+
const err = new Error(`claude exited with code 1: ${fail ? 'mock failure' : 'no stderr'}`);
|
|
1035
|
+
err.errorClass = null;
|
|
1036
|
+
throw err;
|
|
1037
|
+
}
|
|
1038
|
+
return { text: ANSWER, exitCode: 0 };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
678
1041
|
function abortIfNeeded(signal) {
|
|
679
1042
|
if (signal?.aborted) {
|
|
680
1043
|
const err = new Error('aborted');
|
|
@@ -686,8 +1049,20 @@ function abortIfNeeded(signal) {
|
|
|
686
1049
|
/**
|
|
687
1050
|
* Offline mock: emits a few log lines and performs role-appropriate writes.
|
|
688
1051
|
*/
|
|
689
|
-
async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets }) {
|
|
1052
|
+
async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets, permissionMode }) {
|
|
690
1053
|
abortIfNeeded(signal);
|
|
1054
|
+
// Ask Worca mock role (ask-worca-design.md §6.7): detected from the SYSTEM PROMPT
|
|
1055
|
+
// ONLY and dispatched before any prompt-sourced marker is honoured — a chat
|
|
1056
|
+
// message containing `MOCK_ASK: /x.json` (or any MOCK_* line) must never reach
|
|
1057
|
+
// the MOCK_ASK file-write arm below, and the user text can never pick the role.
|
|
1058
|
+
// `dontAsk` is the ask recipe's permission mode and has no legacy caller
|
|
1059
|
+
// (spawn.mjs:20), so it takes the ask arm markers or not: a P2 turn that forgot
|
|
1060
|
+
// `turn.mock` must not fall through to parseMarkers(prompt)/inferRole, where the
|
|
1061
|
+
// chat text alone picks a role that writes to the scratch cwd.
|
|
1062
|
+
const sysMarkers = parseMarkers('', systemPrompt);
|
|
1063
|
+
if (sysMarkers.MOCK_ROLE === 'ask' || permissionMode === 'dontAsk') {
|
|
1064
|
+
return mockAsk({ markers: sysMarkers, prompt, cwd, onEvent, signal, resumeSessionId });
|
|
1065
|
+
}
|
|
691
1066
|
const m = parseMarkers(prompt, systemPrompt);
|
|
692
1067
|
const role = m.MOCK_ROLE || inferRole(prompt, systemPrompt);
|
|
693
1068
|
const cycle = Number(m.MOCK_CYCLE || '1') || 1;
|
|
@@ -704,7 +1079,7 @@ async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessi
|
|
|
704
1079
|
// Ask-then-resume (spec 2026-07-11): asking replaces the role side effects
|
|
705
1080
|
// for this invocation; the orchestrator gates the user and resumes. The
|
|
706
1081
|
// session event above already fired, so the resume has a session id.
|
|
707
|
-
if (m.MOCK_ASK) {
|
|
1082
|
+
if (m.MOCK_ASK && permissionMode !== 'dontAsk') { // belt and braces: dontAsk already took the ask arm above
|
|
708
1083
|
await ensureDir(m.MOCK_ASK);
|
|
709
1084
|
await writeFile(m.MOCK_ASK, JSON.stringify({
|
|
710
1085
|
questions: [{ id: 'q1', question: `Mock question from ${role}?`, options: ['Option A', 'Option B'], allowFreeText: true }],
|
|
@@ -717,7 +1092,7 @@ async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessi
|
|
|
717
1092
|
|
|
718
1093
|
let text = `[mock] role ${role} complete`;
|
|
719
1094
|
switch (role) {
|
|
720
|
-
case
|
|
1095
|
+
case MOCK_ROLE_CLARIFY:
|
|
721
1096
|
text = await mockClarify(m, cycle, onEvent);
|
|
722
1097
|
break;
|
|
723
1098
|
case 'planner-plan':
|
|
@@ -726,7 +1101,7 @@ async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessi
|
|
|
726
1101
|
case 'refiner':
|
|
727
1102
|
text = await mockRefiner(m, cycle, onEvent);
|
|
728
1103
|
break;
|
|
729
|
-
case
|
|
1104
|
+
case MOCK_ROLE_DECOMPOSER:
|
|
730
1105
|
text = await mockDecomposer(m, onEvent);
|
|
731
1106
|
break;
|
|
732
1107
|
case 'implementer':
|
|
@@ -1210,13 +1585,15 @@ async function mockAgentGen(m, onEvent) {
|
|
|
1210
1585
|
? words[0] + words.slice(1).map((w) => w[0].toUpperCase() + w.slice(1)).join('')
|
|
1211
1586
|
: 'customAgent';
|
|
1212
1587
|
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
|
-
|
|
1588
|
+
metaVersion: 2, key, displayName: name, description: `mock-generated agent for ${name}`,
|
|
1589
|
+
color: 'amber', runnerType: 'producer', domain: 'general', fanOut: false,
|
|
1590
|
+
asksQuestions: true, questionsLocked: false, questionsDefault: false, order: 99,
|
|
1591
|
+
inputs: [{ id: 'plan', type: 'md', label: 'Plan' }],
|
|
1592
|
+
outputs: [{ id: 'review', type: 'md', filename: 'review-{cycle}.md' }],
|
|
1217
1593
|
};
|
|
1218
1594
|
if (m.MOCK_OUT) {
|
|
1219
|
-
const md = `# Agent: ${name}\n\nYou are ${name} (deterministic mock body).\n\n
|
|
1595
|
+
const md = `# Agent: ${name}\n\nYou are ${name} (deterministic mock body).\n\n`
|
|
1596
|
+
+ '## Ports\n\n- `plan` (in, md) — the plan to review.\n- `review` (out, md) — the review this agent writes.\n';
|
|
1220
1597
|
await ensureDir(m.MOCK_OUT);
|
|
1221
1598
|
await writeFile(m.MOCK_OUT, md, 'utf8');
|
|
1222
1599
|
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${m.MOCK_OUT}`, raw: { mock: true, file: m.MOCK_OUT } });
|