@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.
Files changed (143) hide show
  1. package/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -20,13 +20,17 @@ export const EFFORTS = ['medium', 'high', 'xhigh', 'max'];
20
20
  // Env keys a model entry may NOT set (§4.4): process fundamentals and worca's
21
21
  // own runtime knobs, any of which injection could otherwise subvert (mock
22
22
  // mode, the claude binary path, the effort flag name). Everything else —
23
- // including all ANTHROPIC_* / CLAUDE_* — is allowed: routing them is the
23
+ // including all other ANTHROPIC_* / CLAUDE_* — is allowed: routing them is the
24
24
  // point. CLAUDE_CODE_SUBPROCESS_ENV_SCRUB is the CLI-2.1.220 permission-mode
25
25
  // landmine documented at claude-runner.mjs#buildSpawnEnv.
26
+ // CLAUDE_CODE_SUBAGENT_MODEL is reserved too: the per-node `subagentModel`
27
+ // prompt policy is the only sanctioned wire for a child's model, and a catalog
28
+ // entry silently flooring every fan-out child would contradict the per-node
29
+ // control the UI shows.
26
30
  export const RESERVED_MODEL_ENV_KEYS = [
27
31
  'PATH', 'HOME', 'TMPDIR', 'SHELL', 'USER', 'LOGNAME', 'TERM',
28
32
  'NODE_OPTIONS', 'NODE_EXTRA_CA_CERTS',
29
- 'CLAUDECODE', 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB',
33
+ 'CLAUDECODE', 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB', 'CLAUDE_CODE_SUBAGENT_MODEL',
30
34
  ];
31
35
  export const RESERVED_MODEL_ENV_PREFIXES = ['WORCA_'];
32
36
 
@@ -49,9 +53,12 @@ export function modelEnvRef(value) {
49
53
  /**
50
54
  * Defensive spawn-time pass over a model entry's env (§4.4): drops reserved
51
55
  * keys and non-string values, and expands whole-value ${VAR} refs from
52
- * `sourceEnv` (an unset or empty var drops the key). Write-time validation
53
- * already rejects reserved keys, so a drop here means a hand-edited settings
54
- * file the caller owns warning about `dropped`.
56
+ * `sourceEnv` (an unset or empty var drops the key). Values are trimmed and a
57
+ * value that is empty after trimming drops the key an ANTHROPIC_MODEL wire id
58
+ * (#374) reaches `--model` verbatim, so a stray-whitespace or empty value must
59
+ * not survive. The user catalog rejects reserved keys at write time and plugin
60
+ * manifests strip them at normalize time, so a drop here means a hand-edited
61
+ * settings file — the caller owns warning about `dropped`.
55
62
  * @param {Record<string,*>|undefined} modelEnv
56
63
  * @param {Record<string,string|undefined>} [sourceEnv]
57
64
  * @returns {{env: Record<string,string>, dropped: string[]}}
@@ -64,11 +71,176 @@ export function prepareModelEnv(modelEnv, sourceEnv = process.env) {
64
71
  const ref = modelEnvRef(v);
65
72
  if (ref !== null) {
66
73
  const resolved = sourceEnv ? sourceEnv[ref] : undefined;
67
- if (resolved === undefined || resolved === '') { dropped.push(k); continue; }
68
- env[k] = resolved;
74
+ const t = typeof resolved === 'string' ? resolved.trim() : '';
75
+ if (!t) { dropped.push(k); continue; }
76
+ env[k] = t;
69
77
  } else {
70
- env[k] = v;
78
+ const t = v.trim();
79
+ if (!t) { dropped.push(k); continue; }
80
+ env[k] = t;
71
81
  }
72
82
  }
73
83
  return { env, dropped };
74
84
  }
85
+
86
+ // ── env flags + masking (shared by claude-runner.mjs, plugin-shim.mjs, ui/server.mjs)
87
+
88
+ /**
89
+ * The ONE "is this env flag on" rule for worca's own knobs (WORCA_MOCK,
90
+ * WORCA_SUBAGENT_HOOKS, WORCA_DEBUG_SPAWN, …): a denylist — anything but unset,
91
+ * "", "0" and "false" (any case) is on. Several names may be given; the first
92
+ * one that is set wins (WORCA_MOCK ?? ORCH_MOCK). Lives in this zero-import leaf
93
+ * so every gate shares it instead of hand-copying the comparison.
94
+ * @param {...string} names
95
+ */
96
+ export function envFlag(...names) {
97
+ let v;
98
+ for (const n of names) { v = process.env[n]; if (v !== undefined) break; }
99
+ return !!v && v !== '0' && v.toLowerCase() !== 'false';
100
+ }
101
+
102
+ /**
103
+ * Mask a model-env VALUE for an operator-facing display (the Models editor):
104
+ * six bullets + the last 4 chars when longer than 8, else six bullets. The
105
+ * `••` prefix is what ui/server.mjs#isMaskedEcho keys on to treat an echoed
106
+ * value as "keep", so the shape is a contract — change both together. For LOG
107
+ * lines use describeModelEnvEntry: a per-spawn log must not carry a suffix.
108
+ */
109
+ export function maskModelEnvValue(v) {
110
+ const s = String(v ?? '');
111
+ return s.length > 8 ? `••••••${s.slice(-4)}` : '••••••';
112
+ }
113
+
114
+ // Keys whose value is routing configuration, not a credential, and therefore
115
+ // SAFE to print in a spawn log: which endpoint / which wire id a spawn used is
116
+ // exactly the diagnostic question, and masking them makes two gateway cards
117
+ // indistinguishable. Everything else (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY,
118
+ // ANTHROPIC_CUSTOM_HEADERS, plugin {secret} values, …) is treated as a secret.
119
+ const READABLE_MODEL_ENV_KEYS = new Set([
120
+ 'ANTHROPIC_MODEL', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL',
121
+ ]);
122
+ const READABLE_MODEL_ENV_KEY_RES = [/^ANTHROPIC_DEFAULT_[A-Z0-9]+_MODEL$/, /^CLAUDE_CODE_USE_[A-Z0-9]+$/];
123
+
124
+ /** Whether a model-env key's value may be printed verbatim in a log line. */
125
+ export function isReadableModelEnvKey(key) {
126
+ return typeof key === 'string'
127
+ && (READABLE_MODEL_ENV_KEYS.has(key) || READABLE_MODEL_ENV_KEY_RES.some((re) => re.test(key)));
128
+ }
129
+
130
+ /**
131
+ * One `KEY=value` fragment for a log line. Readable keys print their value
132
+ * (a URL with userinfo has the credentials stripped; an unparsable URL is
133
+ * treated as a secret); every other key prints `<set, N chars>` — presence and
134
+ * length prove the env reached the spawn without leaking any part of it.
135
+ */
136
+ export function describeModelEnvEntry(key, value) {
137
+ const s = String(value ?? '');
138
+ const secret = `<set, ${s.length} chars>`;
139
+ if (!isReadableModelEnvKey(key)) return `${key}=${secret}`;
140
+ if (key === 'ANTHROPIC_BASE_URL') {
141
+ let u;
142
+ try { u = new URL(s); } catch { return `${key}=${secret}`; }
143
+ if (u.username || u.password) { u.username = ''; u.password = ''; }
144
+ return `${key}=${u.href}`;
145
+ }
146
+ return `${key}=${s}`;
147
+ }
148
+
149
+ /** The sorted, log-safe `KEY=value, …` rendering of a whole model env. */
150
+ export function describeModelEnv(env) {
151
+ return Object.keys(env || {}).sort().map((k) => describeModelEnvEntry(k, env[k])).join(', ');
152
+ }
153
+
154
+ // ── per-model cost override (opt-in pricing, config.mjs resolveModelCost) ─────
155
+ // Lives HERE for the same reason the env policy does: BOTH catalog layers must
156
+ // validate it against one rule. settings.mjs owns the user's global catalog and
157
+ // plugin-manifest.mjs owns a plugin's models — neither may import the other, and
158
+ // this leaf imports nothing. A plugin that ships a model routed at its own
159
+ // endpoint is exactly the case that needs a price pinned, so its manifest
160
+ // carries `cost` with the same shape and the same validation as a global entry.
161
+
162
+ /** Allowed per-million-token rate keys for a model's `cost.perMtok` table. */
163
+ export const COST_RATE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite', 'cacheWrite1h'];
164
+
165
+ /**
166
+ * Validate a model `cost` override. Returns the normalized shape or undefined;
167
+ * THROWS on malformed input (callers that must not throw catch and drop).
168
+ * { free: true } → recorded spend is always $0
169
+ * { perMtok: { input, output, cacheRead, … } } → USD per million tokens, >= 0
170
+ * `{ free: false }` / `{}` mean "no override" → undefined.
171
+ * @param {*} cost
172
+ * @returns {{free:true}|{perMtok:Record<string,number>}|undefined}
173
+ * @throws {Error}
174
+ */
175
+ export function assertModelCost(cost) {
176
+ if (cost === undefined || cost === null) return undefined;
177
+ if (typeof cost !== 'object' || Array.isArray(cost)) throw new Error('cost must be an object');
178
+ if (cost.free !== undefined && typeof cost.free !== 'boolean') throw new Error('cost.free must be a boolean');
179
+ if (cost.free === true) return { free: true };
180
+ if (cost.perMtok !== undefined) {
181
+ const p = cost.perMtok;
182
+ if (!p || typeof p !== 'object' || Array.isArray(p)) {
183
+ throw new Error('cost.perMtok must be an object of USD-per-million-token rates');
184
+ }
185
+ const rates = {};
186
+ for (const [k, v] of Object.entries(p)) {
187
+ if (!COST_RATE_KEYS.includes(k)) {
188
+ throw new Error(`unknown cost.perMtok rate ${JSON.stringify(k)} — allowed: ${COST_RATE_KEYS.join(', ')}`);
189
+ }
190
+ const n = Number(v);
191
+ if (!Number.isFinite(n) || n < 0) throw new Error(`cost.perMtok.${k} must be a finite number >= 0`);
192
+ rates[k] = n;
193
+ }
194
+ if (!Object.keys(rates).length) throw new Error('cost.perMtok must define at least one rate');
195
+ return { perMtok: rates };
196
+ }
197
+ return undefined; // { free: false } or {} — no override
198
+ }
199
+
200
+ // ── sub-agent model policy (per-node `subagentModel`) ─────────────────────────
201
+ // What a fan-out node's Task/Agent children run on. ONE wire — a prompt block
202
+ // (phases.mjs#subagentModelDirective) that tells the agent to pass `model` on
203
+ // every Task call — because the CLI resolves a child's model as Task-call
204
+ // `model` > the agent definition's own `model:` frontmatter > env default >
205
+ // parent: only the explicit Task-level value reliably binds every child. (The
206
+ // earlier CLAUDE_CODE_SUBAGENT_MODEL env floor was removed for exactly that
207
+ // reason — it bound only agents with no model key — and the key is reserved
208
+ // above so a catalog entry cannot resurrect it.)
209
+ //
210
+ // The vocabulary is deliberately NOT the worca catalog: the CLI's Task tool
211
+ // accepts an ALIAS enum, so a catalog id (or an ANTHROPIC_MODEL wire id) would
212
+ // be rejected at spawn time. Haiku is excluded by product decision.
213
+ export const SUBAGENT_MODELS = ['sonnet', 'opus', 'fable'];
214
+
215
+ /** "the agent picks per Task call" — a choice rubric in the prompt. */
216
+ export const SUBAGENT_AUTO = 'auto';
217
+
218
+ /** "children ride the CLI's own resolution" (an agent definition's frontmatter,
219
+ * else the parent's model) — the pre-feature prompt. Stored explicitly,
220
+ * because the DEFAULT for an unset node is `auto`, not this. */
221
+ export const SUBAGENT_INHERIT = 'inherit';
222
+
223
+ /** Every storable `subagentModel`. '' / absent are NOT storable — they mean
224
+ * "unset", which the runtime resolves to SUBAGENT_DEFAULT. */
225
+ export const SUBAGENT_MODEL_VALUES = [...SUBAGENT_MODELS, SUBAGENT_AUTO, SUBAGENT_INHERIT];
226
+
227
+ /** What an unset node resolves to at run time: agents choose BY DEFAULT. */
228
+ export const SUBAGENT_DEFAULT = SUBAGENT_AUTO;
229
+
230
+ /** Whether `v` is a storable subagentModel. */
231
+ export function isSubagentModelValue(v) {
232
+ return typeof v === 'string' && SUBAGENT_MODEL_VALUES.includes(v);
233
+ }
234
+
235
+ /** The policy a raw stored value puts in force: a legal value is itself; '' /
236
+ * absent / anything that escaped validation is the auto default. */
237
+ export function effectiveSubagentModel(v) {
238
+ return isSubagentModelValue(v) ? v : SUBAGENT_DEFAULT;
239
+ }
240
+
241
+ /** The one validation message every writer shares, '' when `v` is acceptable
242
+ * ('' and null/undefined mean clear/absent and are always fine). */
243
+ export function subagentModelIssue(v) {
244
+ if (v == null || v === '' || isSubagentModelValue(v)) return '';
245
+ return `unknown sub-agent model ${JSON.stringify(String(v))}`;
246
+ }
@@ -0,0 +1,79 @@
1
+ // src/core/model-test.mjs
2
+ // One minimal live claude spawn to verify a catalog model actually works —
3
+ // the Models-view Test button (POST /api/models/:id/test). Mirrors the cheap
4
+ // aux-run pattern of title.mjs: the model's catalog routing env travels with
5
+ // the id via resolveModelEnv, no tools, low effort, hard timeout. Never
6
+ // throws — the outcome is a result object either way.
7
+
8
+ import { runClaude } from './claude-runner.mjs';
9
+ import { resolveModelEnv } from './config.mjs';
10
+ import { classifyError } from './recoverable-error.mjs';
11
+
12
+ const TEST_TIMEOUT_MS = 60_000;
13
+ const REPLY_CAP = 100;
14
+
15
+ const SYSTEM = 'You are a connectivity check. Reply with exactly OK.';
16
+
17
+ /** Actionable hint for a recovery class ('' when there is nothing to add —
18
+ * the raw runner message already carries the detail). Pure, for tests. */
19
+ export function hintFor(errorClass) {
20
+ switch (errorClass) {
21
+ case 'auth': return 'authentication failed — check the token/secret for this model';
22
+ case 'network': return 'endpoint unreachable — check ANTHROPIC_BASE_URL';
23
+ case 'rate_limit': return 'the endpoint is rate-limiting or overloaded — try again shortly';
24
+ case 'quota': return 'quota/billing problem — check the account behind this endpoint';
25
+ case 'usage_limit': return 'usage limit reached on the account behind this endpoint';
26
+ case 'timeout': return `no reply — the test timed out after ${TEST_TIMEOUT_MS / 1000}s`;
27
+ default: return '';
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Live connectivity check for a catalog model id (global or plugin — the
33
+ * resolution precedence is resolveModelEnv's). Explicit user action only.
34
+ * @param {string} id catalog model id
35
+ * @param {{signal?:AbortSignal, bin?:string, run?:typeof runClaude}} [opts]
36
+ * `run` is injectable for unit tests.
37
+ * @returns {Promise<{ok:true, text:string}|{ok:false, errorClass:(string|null), message:string, hint?:string}>}
38
+ */
39
+ export async function testModel(id, { signal, bin, run = runClaude } = {}) {
40
+ const ctrl = new AbortController();
41
+ const onOuterAbort = () => ctrl.abort();
42
+ if (signal) {
43
+ if (signal.aborted) ctrl.abort();
44
+ else signal.addEventListener('abort', onOuterAbort, { once: true });
45
+ }
46
+ const timer = setTimeout(() => ctrl.abort(), TEST_TIMEOUT_MS);
47
+ timer.unref?.();
48
+ try {
49
+ const { text } = await run({
50
+ cwd: process.cwd(),
51
+ systemPrompt: SYSTEM,
52
+ prompt: 'Reply with exactly OK.',
53
+ model: id,
54
+ modelEnv: resolveModelEnv(id),
55
+ effort: 'low',
56
+ permissionMode: 'acceptEdits',
57
+ allowedTools: [], // empty → no --allowedTools flag; pure text gen
58
+ signal: ctrl.signal,
59
+ bin,
60
+ onEvent: () => {},
61
+ });
62
+ const reply = String(text || '').split(/\r?\n/).map((l) => l.trim()).find((l) => l) || '';
63
+ if (!reply) {
64
+ return { ok: false, errorClass: null, message: 'the model returned an empty reply' };
65
+ }
66
+ return { ok: true, text: reply.slice(0, REPLY_CAP) };
67
+ } catch (err) {
68
+ if (err && err.name === 'AbortError') {
69
+ return { ok: false, errorClass: 'timeout', message: `Timed out after ${TEST_TIMEOUT_MS / 1000}s`, hint: hintFor('timeout') };
70
+ }
71
+ const message = err && err.message ? err.message : String(err);
72
+ const errorClass = (err && err.errorClass) || classifyError(message);
73
+ const hint = hintFor(errorClass);
74
+ return { ok: false, errorClass, message, ...(hint ? { hint } : {}) };
75
+ } finally {
76
+ clearTimeout(timer);
77
+ if (signal) signal.removeEventListener?.('abort', onOuterAbort);
78
+ }
79
+ }