ennodia 0.3.0 → 0.4.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 (84) hide show
  1. package/CONTRIBUTING.md +2 -19
  2. package/README.md +51 -31
  3. package/docs/comparisons/agent-frameworks.md +5 -5
  4. package/docs/comparisons/autogen.md +4 -4
  5. package/docs/comparisons/chathub.md +4 -4
  6. package/docs/comparisons/index.md +4 -4
  7. package/docs/comparisons/langgraph.md +4 -4
  8. package/docs/comparisons/mixture-of-agents.md +5 -5
  9. package/docs/comparisons/model-merging.md +5 -5
  10. package/docs/comparisons/openrouter.md +4 -4
  11. package/docs/concepts/compositional-audits.md +8 -6
  12. package/docs/concepts/data-governance.md +5 -9
  13. package/docs/concepts/how-ennodia-works.md +2 -2
  14. package/docs/concepts/interfaces-and-core.md +1 -1
  15. package/docs/concepts/second-opinions.md +5 -5
  16. package/docs/evidence/index.md +6 -13
  17. package/docs/evidence/measurement.md +6 -5
  18. package/docs/getting-started.md +9 -6
  19. package/docs/guides/agent-skills.md +6 -5
  20. package/docs/guides/budgets-and-limits.md +8 -6
  21. package/docs/guides/pragmatic-mode.md +2 -0
  22. package/docs/guides/recipes.md +1 -4
  23. package/docs/guides/running-better-audits.md +7 -7
  24. package/docs/guides/troubleshooting.md +0 -2
  25. package/docs/guides/understand-results.md +0 -2
  26. package/docs/index.md +2 -4
  27. package/docs/install.md +11 -9
  28. package/docs/questions.md +56 -0
  29. package/docs/reference/controlled-english.md +1 -3
  30. package/docs/reference/ennodia-io.md +7 -7
  31. package/docs/reference/mcp-tools.md +69 -15
  32. package/docs/reference/supported-harnesses.md +9 -4
  33. package/docs/roadmap.md +0 -2
  34. package/package.json +4 -11
  35. package/skills/pragmatic/SKILL.md +2 -0
  36. package/src/cli.ts +31 -15
  37. package/src/compare.ts +37 -1
  38. package/src/core.ts +2 -4
  39. package/src/diagnosis.ts +48 -1
  40. package/src/harnesses.ts +54 -25
  41. package/src/index.ts +4 -0
  42. package/src/runs.ts +17 -8
  43. package/src/server.ts +55 -2
  44. package/src/tasks.ts +145 -37
  45. package/src/version.ts +1 -1
  46. package/bench/README.md +0 -34
  47. package/bench/bug-recall/run.ts +0 -339
  48. package/bench/bug-recall/scorer.ts +0 -424
  49. package/bench/fixtures/bug-recall/001-missing-await/input.ts +0 -33
  50. package/bench/fixtures/bug-recall/001-missing-await/oracle.json +0 -28
  51. package/bench/fixtures/bug-recall/001-missing-await/responses/claude-code-solo.md +0 -1
  52. package/bench/fixtures/bug-recall/001-missing-await/responses/codex-solo.md +0 -1
  53. package/bench/fixtures/bug-recall/001-missing-await/responses/ennodia-parallel-compare.md +0 -1
  54. package/bench/fixtures/bug-recall/002-timeout-drain/input.ts +0 -40
  55. package/bench/fixtures/bug-recall/002-timeout-drain/oracle.json +0 -28
  56. package/bench/fixtures/bug-recall/002-timeout-drain/responses/claude-code-solo.md +0 -1
  57. package/bench/fixtures/bug-recall/002-timeout-drain/responses/codex-solo.md +0 -1
  58. package/bench/fixtures/bug-recall/002-timeout-drain/responses/ennodia-parallel-compare.md +0 -1
  59. package/bench/fixtures/bug-recall/003-schema-contract/input.ts +0 -22
  60. package/bench/fixtures/bug-recall/003-schema-contract/oracle.json +0 -28
  61. package/bench/fixtures/bug-recall/003-schema-contract/responses/claude-code-solo.md +0 -1
  62. package/bench/fixtures/bug-recall/003-schema-contract/responses/codex-solo.md +0 -1
  63. package/bench/fixtures/bug-recall/003-schema-contract/responses/ennodia-parallel-compare.md +0 -1
  64. package/bench/fixtures/bug-recall/004-cancel-propagation/input.ts +0 -28
  65. package/bench/fixtures/bug-recall/004-cancel-propagation/oracle.json +0 -28
  66. package/bench/fixtures/bug-recall/004-cancel-propagation/responses/claude-code-solo.md +0 -1
  67. package/bench/fixtures/bug-recall/004-cancel-propagation/responses/codex-solo.md +0 -1
  68. package/bench/fixtures/bug-recall/004-cancel-propagation/responses/ennodia-parallel-compare.md +0 -1
  69. package/bench/results/.gitkeep +0 -1
  70. package/docs/evidence/building-ennodia.md +0 -66
  71. package/docs/evidence/docs-drift.md +0 -73
  72. package/docs/evidence/skill-trial.md +0 -68
  73. package/docs/reference/benchmarks.md +0 -64
  74. package/examples/docs-drift/README.md +0 -8
  75. package/examples/docs-drift/archive/migration.md +0 -4
  76. package/examples/docs-drift/check.ts +0 -7
  77. package/examples/docs-drift/config.ts +0 -8
  78. package/examples/docs-drift/demo.ts +0 -54
  79. package/examples/docs-drift/recorded-run.json +0 -16
  80. package/examples/skill-trial/README.md +0 -8
  81. package/examples/skill-trial/check.ts +0 -15
  82. package/examples/skill-trial/contract.md +0 -7
  83. package/examples/skill-trial/recorded-trial.json +0 -42
  84. package/examples/skill-trial/retry.ts +0 -4
package/src/index.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  export { pragmaticSchema, type PragmaticOptions } from "./pragmatic";
2
2
  export {
3
+ CORE_TOOL_NAMES,
4
+ TOOL_SETS,
3
5
  createEnnodiaServer,
4
6
  shutdownEnnodia,
7
+ type EnnodiaServerOptions,
5
8
  type EnnodiaShutdownOptions,
9
+ type ToolSet,
6
10
  } from "./server";
7
11
  export {
8
12
  EnnodiaCore,
package/src/runs.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  type BudgetCheck,
7
7
  type BudgetLimits,
8
8
  } from "./budget";
9
- import type { CompareManager, CompareView } from "./compare";
9
+ import { MAX_PROMPT_CANDIDATE_CHARS, type CompareManager, type CompareView } from "./compare";
10
10
  import { diagnoseTasks, type TaskDiagnosis } from "./diagnosis";
11
11
  import type {
12
12
  DiscoverHarnessesOptions,
@@ -14,7 +14,7 @@ import type {
14
14
  HarnessDiscovery,
15
15
  ReasoningEffort,
16
16
  } from "./harnesses";
17
- import { assertNativeSandboxSupported, assertNativeSubagentsSupported, assertReasoningEffortSupported } from "./harnesses";
17
+ import { assertHarnessSettingsSupported } from "./harnesses";
18
18
  import {
19
19
  noopHistorySink,
20
20
  type HistorySink,
@@ -264,9 +264,7 @@ export class RunManager {
264
264
  for (const harnessId of selectedHarnessIds) {
265
265
  const adapter = this.dependencies.findHarnessAdapter(harnessId);
266
266
  if (adapter) {
267
- assertReasoningEffortSupported(adapter, input.reasoningEffort);
268
- assertNativeSubagentsSupported(adapter, input.nativeSubagents);
269
- assertNativeSandboxSupported(adapter, input.nativeSandbox);
267
+ assertHarnessSettingsSupported(adapter, input);
270
268
  }
271
269
  }
272
270
 
@@ -561,13 +559,21 @@ export class RunManager {
561
559
  return;
562
560
  }
563
561
 
564
- const finalAnswer = result.advisor?.answer.trim() ||
562
+ const advice = result.advisor?.answer.trim() ||
565
563
  result.synthesis?.text.trim();
564
+ // When the Advisor chose a candidate to use unchanged, the answer carries
565
+ // that candidate's own text after the Advisor's reason.
566
+ const chosen = result.candidates.find((candidate) => candidate.id === result.advisor?.chosenSourceId);
567
+ const finalAnswer = advice && chosen
568
+ ? `${advice}\n\nChosen answer from ${chosen.label ?? chosen.id}, unchanged:\n\n${chosen.content}`
569
+ : advice;
566
570
  if (result.status === "succeeded" && finalAnswer) {
567
571
  this.pushEvent(run, {
568
572
  type: "compare-succeeded",
569
573
  compareId: compare.id,
570
- message: "Compare returned the Advisor recommendation.",
574
+ message: chosen
575
+ ? `Compare returned the Advisor recommendation and the chosen answer from ${chosen.label ?? chosen.id}.`
576
+ : "Compare returned the Advisor recommendation.",
571
577
  });
572
578
  this.succeedRun(run, finalAnswer);
573
579
  return;
@@ -589,7 +595,10 @@ export class RunManager {
589
595
  compareId,
590
596
  undefined,
591
597
  {
592
- includeCandidates: false,
598
+ // A chosen candidate fits in the Advisor's evidence, so this cap
599
+ // keeps it whole.
600
+ includeCandidates: true,
601
+ maxCandidateChars: MAX_PROMPT_CANDIDATE_CHARS,
593
602
  includeEvents: true,
594
603
  maxEvents: 25,
595
604
  },
package/src/server.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { pragmaticSchema } from "./pragmatic";
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { McpServer, type RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { z } from "zod";
4
4
  import { compositionalSliceSchema } from "./compositional";
5
5
  import {
@@ -50,12 +50,58 @@ const budgetSchema = z
50
50
 
51
51
  export type EnnodiaShutdownOptions = EnnodiaCoreShutdownOptions;
52
52
 
53
- export function createEnnodiaServer(core: EnnodiaCore = defaultCore): McpServer {
53
+ /** Every tool definition is loaded into each session of every agent that
54
+ * has Ennodia configured, even when Ennodia is never called. The core set
55
+ * covers handing work to one agent or a team, Compare, waiting, and
56
+ * cancelling. The full set adds raw tasks, compositional slices, Plan
57
+ * Advisor, budget estimates, and skill installation. */
58
+ export const CORE_TOOL_NAMES: readonly string[] = [
59
+ "ennodia_run",
60
+ "ennodia_get_run",
61
+ "ennodia_cancel_run",
62
+ "ennodia_list_harnesses",
63
+ "ennodia_list_skills",
64
+ "ennodia_history",
65
+ ];
66
+
67
+ /** Clients such as Codex run read-only tools without an approval prompt,
68
+ * so polling a run never stops for approval. Tools that start agents reach
69
+ * model providers, and their workers can change files when the caller allows
70
+ * it, so they claim neither read-only nor harmless. */
71
+ export function toolAnnotations(name: string): { readOnlyHint: boolean; openWorldHint: boolean; destructiveHint?: boolean; idempotentHint?: boolean } {
72
+ if (/^ennodia_(?:list_|get_|history$|plan$|estimate_)/.test(name)) return { readOnlyHint: true, openWorldHint: false };
73
+ if (name.startsWith("ennodia_cancel_")) return { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
74
+ if (name === "ennodia_install_skills") return { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
75
+ return { readOnlyHint: false, openWorldHint: true };
76
+ }
77
+
78
+ export const TOOL_SETS = ["core", "all"] as const;
79
+ export type ToolSet = (typeof TOOL_SETS)[number];
80
+
81
+ export type EnnodiaServerOptions = {
82
+ /** Defaults to "core". */
83
+ tools?: ToolSet;
84
+ };
85
+
86
+ export function createEnnodiaServer(
87
+ core: EnnodiaCore = defaultCore,
88
+ options: EnnodiaServerOptions = {},
89
+ ): McpServer {
54
90
  const server = new McpServer({
55
91
  name: "ennodia",
56
92
  version: ENNODIA_VERSION,
57
93
  });
58
94
 
95
+ // Keep each registration, so the core set can drop the others below, and
96
+ // add the annotations that tell clients which calls only read.
97
+ const registered = new Map<string, RegisteredTool>();
98
+ const registerTool = server.registerTool.bind(server) as (name: string, config: { annotations?: object }, ...rest: unknown[]) => RegisteredTool;
99
+ server.registerTool = ((name: string, config: { annotations?: object }, ...rest: unknown[]) => {
100
+ const tool = registerTool(name, { ...config, annotations: { ...toolAnnotations(name), ...config.annotations } }, ...rest);
101
+ registered.set(name, tool);
102
+ return tool;
103
+ }) as McpServer["registerTool"];
104
+
59
105
  server.registerTool(
60
106
  "ennodia_list_harnesses",
61
107
  {
@@ -1218,6 +1264,13 @@ export function createEnnodiaServer(core: EnnodiaCore = defaultCore): McpServer
1218
1264
  async ({ taskId }) => jsonResult(core.cancelTask(taskId)),
1219
1265
  );
1220
1266
 
1267
+ // Removing before connect sends no list-changed notification.
1268
+ if ((options.tools ?? "core") === "core") {
1269
+ for (const [name, tool] of registered) {
1270
+ if (!CORE_TOOL_NAMES.includes(name)) tool.remove();
1271
+ }
1272
+ }
1273
+
1221
1274
  return server;
1222
1275
  }
1223
1276
 
package/src/tasks.ts CHANGED
@@ -10,7 +10,7 @@ import type {
10
10
  HarnessRunInput,
11
11
  HarnessUsage,
12
12
  } from "./harnesses";
13
- import { assertNativeSandboxSupported, assertNativeSubagentsSupported, assertReasoningEffortSupported, DEFAULT_TASK_TIMEOUT_MS, withInputGuidance } from "./harnesses";
13
+ import { assertHarnessSettingsSupported, DEFAULT_TASK_TIMEOUT_MS, inputGuidanceNotes, withInputGuidance } from "./harnesses";
14
14
  import { preview, tailItems } from "./internal";
15
15
  import { signalOwnedProcess } from "./process";
16
16
  import {
@@ -19,11 +19,14 @@ import {
19
19
  type AppliedSkillInfo,
20
20
  } from "./skills";
21
21
 
22
+ /** Ennodia's own instructions are tagged, so workers do not read them as task content. */
23
+ export const EXECUTION_NOTICE_TAG = "ennodia-execution-notice";
24
+
22
25
  export type TaskStatus = "running" | "succeeded" | "failed" | "cancelled";
23
26
 
24
27
  export type TaskEvent = {
25
28
  at: string;
26
- type: "started" | "input-guidance" | "stdout" | "stderr" | "exit" | "cancel" | "error";
29
+ type: "started" | "input-guidance" | "restart" | "stdout" | "stderr" | "exit" | "cancel" | "error";
27
30
  message?: string;
28
31
  };
29
32
 
@@ -83,6 +86,9 @@ export type TaskView = {
83
86
  /** Set when this task ran against an isolated copy of the requested cwd
84
87
  * (see HarnessRunInput.isolateCwd), naming the original directory. */
85
88
  isolatedFrom?: string;
89
+ /** Starts repeated because another instance of the same CLI held shared
90
+ * local state. Present only when a restart happened. */
91
+ startupRestarts?: number;
86
92
  };
87
93
 
88
94
  export type TaskViewOptions = {
@@ -138,6 +144,11 @@ type InternalTask = Omit<
138
144
  isolatedCwd?: string;
139
145
  extractUsage?: HarnessAdapter["extractUsage"];
140
146
  failureReason?: HarnessAdapter["failureReason"];
147
+ /** The exact spawn, kept so a start that met a startup conflict can run again. */
148
+ spawnInput: TaskSpawnInput;
149
+ stdinText?: string;
150
+ startupConflict?: RegExp;
151
+ attemptStartedAtMs: number;
141
152
  };
142
153
 
143
154
  export type StartTaskResult = {
@@ -146,6 +157,8 @@ export type StartTaskResult = {
146
157
 
147
158
  export type TaskManagerOptions = {
148
159
  drainTimeoutMs?: number;
160
+ /** Base delay before restarting a start that met a startup conflict. */
161
+ startupRestartDelayMs?: number;
149
162
  maxTasks?: number;
150
163
  spawn?: TaskSpawn;
151
164
  removeIsolatedCwd?: (path: string) => void;
@@ -161,10 +174,16 @@ const DEFAULT_MAX_TASKS = 200;
161
174
  const MAX_CAPTURE_CHARS = 200_000;
162
175
  const MAX_EVENT_MESSAGE_CHARS = 4_000;
163
176
  const MAX_EVENTS = 300;
177
+ // A startup conflict shows up within moments of launch. Later failures are
178
+ // not restarted, because the worker may have started real work.
179
+ const STARTUP_CONFLICT_WINDOW_MS = 10_000;
180
+ const MAX_STARTUP_RESTARTS = 3;
181
+ const STARTUP_RESTART_DELAY_MS = 500;
164
182
 
165
183
  export class TaskManager {
166
184
  private readonly tasks = new Map<string, InternalTask>();
167
185
  private readonly drainTimeoutMs: number;
186
+ private readonly startupRestartDelayMs: number;
168
187
  private readonly maxTasks: number;
169
188
  private readonly spawn: TaskSpawn;
170
189
  private readonly removeIsolatedCwd: (path: string) => void;
@@ -175,6 +194,7 @@ export class TaskManager {
175
194
 
176
195
  constructor(options: TaskManagerOptions = {}) {
177
196
  this.drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
197
+ this.startupRestartDelayMs = options.startupRestartDelayMs ?? STARTUP_RESTART_DELAY_MS;
178
198
  this.maxTasks = Math.max(1, options.maxTasks ?? DEFAULT_MAX_TASKS);
179
199
  this.spawn = options.spawn ?? ((input) => Bun.spawn(input));
180
200
  this.ownsProcessGroups = !options.spawn && process.platform !== "win32";
@@ -214,9 +234,7 @@ export class TaskManager {
214
234
  if (input.persistSession && (!adapter.supportsSessionContinuation || input.isolateCwd)) {
215
235
  throw new Error("Persistent sessions require a supported harness and a persistent working directory.");
216
236
  }
217
- assertReasoningEffortSupported(adapter, input.reasoningEffort);
218
- assertNativeSubagentsSupported(adapter, input.nativeSubagents);
219
- assertNativeSandboxSupported(adapter, input.nativeSandbox);
237
+ assertHarnessSettingsSupported(adapter, input);
220
238
  if (adapter.supportsNativeSandbox) input = { ...input, nativeSandbox: input.nativeSandbox ?? "read-only" };
221
239
 
222
240
  const sourceCwd = input.cwd ?? process.cwd();
@@ -244,26 +262,46 @@ export class TaskManager {
244
262
  const now = Date.now();
245
263
  const deadlineMs = now + timeoutMs;
246
264
  const deadlineAt = new Date(deadlineMs).toISOString();
247
- const guidedPrompt = withInputGuidance(input.prompt, adapter);
248
- const executionPrompt = `${guidedPrompt}
249
-
250
- Ennodia execution notice:
251
- Execution allowance: ${timeoutMs} ms from task launch (${input.timeoutMs === undefined ? "Ennodia default" : "caller supplied"}). Deadline (UTC): ${deadlineAt}. This is a hard cutoff, not a completion estimate or a target duration.
252
- Check actual UTC time before attributing incomplete work to the deadline. Do not report a cutoff merely because the task is difficult or the answer is getting long. Continue within the remaining allowance unless complete or blocked by a demonstrated access or tool failure.
253
- Configured native sandbox: ${input.nativeSandbox ?? "harness-managed; check available permissions"}. Report any mismatch between the assignment and available access before attempting the work.
254
- Before the cutoff, return useful partial findings, verification performed, remaining work, and a justified continuation budget if needed. Only the caller can authorize a follow-up.
255
- Use only tools and permissions actually available in this session; the caller's tools are not automatically inherited. If access is missing, report the exact tool, file, or permission and its impact. Try a permitted alternative when useful; do not repeat an unchanged failed attempt or claim success without evidence.`;
265
+ const guidance = inputGuidanceNotes(input.prompt, adapter);
266
+ const noticeLines = [
267
+ `Execution allowance: ${timeoutMs} ms from task launch (${input.timeoutMs === undefined ? "Ennodia default" : "caller supplied"}). Deadline (UTC): ${deadlineAt}. This is a hard cutoff, not a completion estimate or a target duration.`,
268
+ "Check actual UTC time before attributing incomplete work to the deadline. Do not report a cutoff merely because the task is difficult or the answer is getting long. Continue within the remaining allowance unless complete or blocked by a demonstrated access or tool failure.",
269
+ `Configured native sandbox: ${input.nativeSandbox ?? "harness-managed; check available permissions"}. Report any mismatch between the assignment and available access before attempting the work.`,
270
+ "Before the cutoff, return useful partial findings, verification performed, remaining work, and a justified continuation budget if needed. Only the caller can authorize a follow-up.",
271
+ "Use only tools and permissions actually available in this session; the caller's tools are not automatically inherited. If access is missing, report the exact tool, file, or permission and its impact. Try a permitted alternative when useful; do not repeat an unchanged failed attempt or claim success without evidence.",
272
+ ];
273
+ // An adapter that takes operator instructions gets Ennodia's notices
274
+ // there. Other workers get them as tagged blocks after the task.
275
+ let systemNotice: string | undefined;
276
+ let executionPrompt: string;
277
+ if (adapter.systemNotices) {
278
+ executionPrompt = input.prompt;
279
+ systemNotice = [
280
+ "Ennodia, the local tool that started this session for its caller, sets these limits for the task.",
281
+ ...noticeLines,
282
+ ...(guidance ? ["When the task needs native media inspection:", ...guidance.map((note) => `- ${note}`)] : []),
283
+ ].join("\n");
284
+ } else {
285
+ executionPrompt = `${withInputGuidance(input.prompt, adapter)}
286
+
287
+ <${EXECUTION_NOTICE_TAG}>
288
+ From Ennodia, not part of the task above.
289
+ ${noticeLines.join("\n")}
290
+ </${EXECUTION_NOTICE_TAG}>`;
291
+ }
256
292
  const augmentedPrompt = input.skills && input.skills.length > 0
257
293
  ? augmentPrompt(executionPrompt, input.skills, adapter.id)
258
294
  : executionPrompt;
259
295
  let commandSpec: CommandSpec;
260
296
  let cwd: string;
297
+ let spawnInput: TaskSpawnInput;
261
298
  let child: TaskProcess;
262
299
  try {
263
300
  commandSpec = adapter.buildCommand(discovery.commandPath, {
264
301
  ...input,
265
302
  cwd: resolvedInputCwd,
266
303
  prompt: augmentedPrompt,
304
+ systemNotice,
267
305
  finalMessagePath,
268
306
  nativeSessionId: previous?.nativeSessionId,
269
307
  });
@@ -273,7 +311,7 @@ Use only tools and permissions actually available in this session; the caller's
273
311
  throw new Error(`Working directory does not exist: ${cwd}`);
274
312
  }
275
313
 
276
- child = this.spawn({
314
+ spawnInput = {
277
315
  cmd: [commandSpec.command, ...commandSpec.args],
278
316
  cwd,
279
317
  env: { ...process.env, ...commandSpec.env },
@@ -281,7 +319,8 @@ Use only tools and permissions actually available in this session; the caller's
281
319
  stdout: "pipe",
282
320
  stderr: "pipe",
283
321
  detached: process.platform !== "win32",
284
- });
322
+ };
323
+ child = this.spawn(spawnInput);
285
324
  } catch (error) {
286
325
  cleanupFailedStartArtifacts(
287
326
  finalMessagePath,
@@ -307,7 +346,7 @@ Use only tools and permissions actually available in this session; the caller's
307
346
  isolatedFrom,
308
347
  command: [
309
348
  basename(commandSpec.command),
310
- ...commandSpec.args.map((arg) => arg === augmentedPrompt ? "<prompt>" : arg),
349
+ ...commandSpec.args.map((arg) => arg === augmentedPrompt ? "<prompt>" : arg === systemNotice ? "<ennodia-notice>" : arg),
311
350
  ...(commandSpec.stdin === undefined ? [] : ["<stdin-prompt>"]),
312
351
  ],
313
352
  promptPreview: preview(input.prompt),
@@ -328,6 +367,10 @@ Use only tools and permissions actually available in this session; the caller's
328
367
  isolatedCwd,
329
368
  extractUsage: adapter.extractUsage,
330
369
  failureReason: adapter.failureReason,
370
+ spawnInput,
371
+ stdinText: commandSpec.stdin,
372
+ startupConflict: adapter.startupConflict,
373
+ attemptStartedAtMs: Date.now(),
331
374
  };
332
375
 
333
376
  task.process = child;
@@ -339,7 +382,7 @@ Use only tools and permissions actually available in this session; the caller's
339
382
  if (previous) previous.continuationTaskId = task.id;
340
383
  this.pruneTasks(task.id);
341
384
  this.pushEvent(task, { type: "started", message: `Task started. Execution limit: ${timeoutMs}ms (${task.timeoutSource}); deadline: ${task.deadlineAt}. This is not a completion estimate.` });
342
- if (guidedPrompt !== input.prompt) {
385
+ if (guidance) {
343
386
  this.pushEvent(task, {
344
387
  type: "input-guidance",
345
388
  message: "Media input guidance was included in the worker prompt. Native inspection remains unverified until the worker reports evidence.",
@@ -366,24 +409,7 @@ Use only tools and permissions actually available in this session; the caller's
366
409
 
367
410
  task.settled = this.watchExit(task, child);
368
411
  void task.settled;
369
-
370
- if (commandSpec.stdin !== undefined) {
371
- try {
372
- const childStdin = child.stdin;
373
- if (!childStdin) {
374
- throw new Error("Child stdin pipe was not available.");
375
- }
376
-
377
- childStdin.write(commandSpec.stdin);
378
- childStdin.end();
379
- } catch (error) {
380
- this.pushEvent(task, {
381
- type: "error",
382
- message: error instanceof Error ? error.message : String(error),
383
- });
384
- this.terminate(task);
385
- }
386
- }
412
+ this.writeStdin(task, child);
387
413
 
388
414
  return { task: this.toView(task) };
389
415
  }
@@ -495,9 +521,16 @@ Use only tools and permissions actually available in this session; the caller's
495
521
  ): Promise<void> {
496
522
  let terminalStatus: TaskStatus = "failed";
497
523
  try {
498
- const exitCode = await child.exited;
524
+ let exitCode = await child.exited;
499
525
  if (task.killTimeout) this.signal(task, "SIGKILL");
500
526
  await this.waitForOutputDrain(task);
527
+ while (exitCode !== 0 && this.metStartupConflict(task)) {
528
+ const restarted = await this.restartAfterConflict(task);
529
+ if (!restarted) break;
530
+ exitCode = await restarted.exited;
531
+ if (task.killTimeout) this.signal(task, "SIGKILL");
532
+ await this.waitForOutputDrain(task);
533
+ }
501
534
  task.exitCode = exitCode;
502
535
 
503
536
  if (task.cancelRequested) {
@@ -553,6 +586,80 @@ Use only tools and permissions actually available in this session; the caller's
553
586
  }
554
587
  }
555
588
 
589
+ private writeStdin(task: InternalTask, child: TaskProcess): void {
590
+ if (task.stdinText === undefined) {
591
+ return;
592
+ }
593
+
594
+ try {
595
+ const childStdin = child.stdin;
596
+ if (!childStdin) {
597
+ throw new Error("Child stdin pipe was not available.");
598
+ }
599
+
600
+ childStdin.write(task.stdinText);
601
+ childStdin.end();
602
+ } catch (error) {
603
+ this.pushEvent(task, {
604
+ type: "error",
605
+ message: error instanceof Error ? error.message : String(error),
606
+ });
607
+ this.terminate(task);
608
+ }
609
+ }
610
+
611
+ /** A start that lost a race for the CLI's shared local state did no work,
612
+ * so running the same command again is safe. */
613
+ private metStartupConflict(task: InternalTask): boolean {
614
+ return Boolean(task.startupConflict?.test(task.stderr)) &&
615
+ !task.stdout.trim() &&
616
+ !task.cancelRequested && !task.timedOut && !task.drainTimedOut && !this.shuttingDown &&
617
+ (task.startupRestarts ?? 0) < MAX_STARTUP_RESTARTS &&
618
+ Date.now() - task.attemptStartedAtMs < STARTUP_CONFLICT_WINDOW_MS;
619
+ }
620
+
621
+ private async restartAfterConflict(task: InternalTask): Promise<TaskProcess | undefined> {
622
+ const restart = (task.startupRestarts ?? 0) + 1;
623
+ // Jitter spreads out tasks that met the same conflict together.
624
+ const delayMs = Math.round(this.startupRestartDelayMs * restart * (1 + Math.random()));
625
+ this.pushEvent(task, {
626
+ type: "restart",
627
+ message: `The CLI stopped during startup because another instance held shared local state. Restart ${restart} of ${MAX_STARTUP_RESTARTS} in ${delayMs}ms. stderr: ${tail(task.stderr, 500).trim()}`,
628
+ });
629
+ await Bun.sleep(delayMs);
630
+ if (task.cancelRequested || task.timedOut || this.shuttingDown) {
631
+ return undefined;
632
+ }
633
+
634
+ // Clear anything the failed start left in its process group.
635
+ this.signal(task, "SIGKILL");
636
+ let child: TaskProcess;
637
+ try {
638
+ child = this.spawn(task.spawnInput);
639
+ } catch (error) {
640
+ this.pushEvent(task, {
641
+ type: "error",
642
+ message: `Restart failed: ${error instanceof Error ? error.message : String(error)}`,
643
+ });
644
+ return undefined;
645
+ }
646
+
647
+ task.forceSignalled = false;
648
+ task.startupRestarts = restart;
649
+ task.stdout = "";
650
+ task.stderr = "";
651
+ task.streamCaptureTruncated = false;
652
+ task.process = child;
653
+ task.pid = child.pid;
654
+ task.attemptStartedAtMs = Date.now();
655
+ task.streamsDone = Promise.all([
656
+ this.pipeStreamSafely(task, "stdout", child.stdout),
657
+ this.pipeStreamSafely(task, "stderr", child.stderr),
658
+ ]).then(() => undefined);
659
+ this.writeStdin(task, child);
660
+ return child;
661
+ }
662
+
556
663
  private async collectFinalMessage(task: InternalTask): Promise<void> {
557
664
  if (!task.finalMessagePath) {
558
665
  return;
@@ -865,6 +972,7 @@ Use only tools and permissions actually available in this session; the caller's
865
972
  task.stderr.length > (includeOutput ? maxOutputChars : 0)),
866
973
  usage: task.usage,
867
974
  isolatedFrom: task.isolatedFrom,
975
+ startupRestarts: task.startupRestarts || undefined,
868
976
  };
869
977
  }
870
978
 
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const ENNODIA_VERSION = "0.3.0";
1
+ export const ENNODIA_VERSION = "0.4.1";
package/bench/README.md DELETED
@@ -1,34 +0,0 @@
1
- # Benchmarks
2
-
3
- Ennodia's first release-candidate benchmark is `multi-model-bug-recall`: small
4
- TypeScript review fixtures with committed oracles. It measures if
5
- parallel peer review plus Compare recovers more required findings than a solo
6
- harness answer.
7
-
8
- The default command is deterministic and uses committed reference outputs:
9
-
10
- ```sh
11
- bun run bench:bug-recall
12
- ```
13
-
14
- The live command starts a fresh Ennodia Model Context Protocol (MCP) server and
15
- runs real harnesses. It is intentionally not part of `bun run verify`.
16
-
17
- ```sh
18
- bun run bench:bug-recall:live -- --fixture 001-missing-await
19
- ```
20
-
21
- Useful flags:
22
-
23
- - `--fixture <id>` runs one fixture. Repeat it to select multiple fixtures.
24
- - `--harness <id>` adds a solo baseline harness in live mode. Defaults to
25
- `codex` and `claude-code`.
26
- - `--judge-harness <id>` and `--advisor-harness <id>` select Compare roles.
27
- - `--synthesizer-harness <id>` is a deprecated alias for
28
- `--advisor-harness <id>`.
29
- - `--timeout-ms <ms>` sets the per-run timeout for live harness calls.
30
- - `--out <dir>` writes results to a specific directory.
31
-
32
- Results are written under `bench/results/bug-recall/`. Raw benchmark output is
33
- gitignored by default because live runs include model output and environment
34
- metadata.