ennodia 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +76 -119
  2. package/docs/evidence/building-ennodia.md +66 -0
  3. package/docs/evidence/docs-drift.md +73 -0
  4. package/docs/evidence/index.md +18 -0
  5. package/docs/evidence/measurement.md +40 -0
  6. package/docs/evidence/skill-trial.md +68 -0
  7. package/docs/getting-started.md +55 -32
  8. package/docs/guides/agent-skills.md +22 -8
  9. package/docs/guides/budgets-and-limits.md +1 -1
  10. package/docs/guides/pragmatic-mode.md +122 -0
  11. package/docs/guides/recipes.md +56 -0
  12. package/docs/guides/troubleshooting.md +48 -0
  13. package/docs/guides/understand-results.md +41 -0
  14. package/docs/index.md +27 -90
  15. package/docs/install.md +49 -16
  16. package/docs/reference/controlled-english.md +2 -3
  17. package/docs/reference/ennodia-io.md +27 -4
  18. package/docs/reference/mcp-tools.md +167 -10
  19. package/docs/reference/supported-harnesses.md +40 -1
  20. package/docs/roadmap.md +25 -0
  21. package/examples/docs-drift/README.md +8 -0
  22. package/examples/docs-drift/archive/migration.md +4 -0
  23. package/examples/docs-drift/check.ts +7 -0
  24. package/examples/docs-drift/config.ts +8 -0
  25. package/examples/docs-drift/demo.ts +54 -0
  26. package/examples/docs-drift/recorded-run.json +16 -0
  27. package/examples/skill-trial/README.md +8 -0
  28. package/examples/skill-trial/check.ts +15 -0
  29. package/examples/skill-trial/contract.md +7 -0
  30. package/examples/skill-trial/recorded-trial.json +42 -0
  31. package/examples/skill-trial/retry.ts +4 -0
  32. package/package.json +5 -2
  33. package/skills/pragmatic/SKILL.md +40 -0
  34. package/src/advisor.ts +5 -0
  35. package/src/budget.ts +16 -10
  36. package/src/cli.ts +3 -0
  37. package/src/compare.ts +18 -12
  38. package/src/compositional.ts +22 -4
  39. package/src/core.ts +77 -47
  40. package/src/diagnosis.ts +44 -7
  41. package/src/harnesses.ts +286 -33
  42. package/src/history.ts +4 -1
  43. package/src/index.ts +3 -0
  44. package/src/plan-advice.ts +5 -2
  45. package/src/planner.ts +24 -1
  46. package/src/pragmatic.ts +52 -0
  47. package/src/process.ts +18 -0
  48. package/src/runs.ts +85 -10
  49. package/src/server.ts +47 -17
  50. package/src/tasks.ts +191 -25
  51. package/src/version.ts +1 -1
package/src/tasks.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { cpSync, existsSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
- import { basename, join } from "node:path";
3
+ import { basename, join, resolve } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import type {
@@ -10,7 +10,9 @@ 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
14
  import { preview, tailItems } from "./internal";
15
+ import { signalOwnedProcess } from "./process";
14
16
  import {
15
17
  augmentPrompt,
16
18
  toAppliedSkillInfo,
@@ -21,7 +23,7 @@ export type TaskStatus = "running" | "succeeded" | "failed" | "cancelled";
21
23
 
22
24
  export type TaskEvent = {
23
25
  at: string;
24
- type: "started" | "stdout" | "stderr" | "exit" | "cancel" | "error";
26
+ type: "started" | "input-guidance" | "stdout" | "stderr" | "exit" | "cancel" | "error";
25
27
  message?: string;
26
28
  };
27
29
 
@@ -29,6 +31,14 @@ export type TaskView = {
29
31
  id: string;
30
32
  harnessId: string;
31
33
  harnessName: string;
34
+ model?: string;
35
+ reasoningEffort?: HarnessRunInput["reasoningEffort"];
36
+ nativeSubagents?: "disabled";
37
+ nativeSandbox?: "read-only" | "workspace-write";
38
+ persistSession?: boolean;
39
+ continueTaskId?: string;
40
+ continuationTaskId?: string;
41
+ canContinue?: boolean;
32
42
  status: TaskStatus;
33
43
  cancelRequested: boolean;
34
44
  pid?: number;
@@ -40,6 +50,9 @@ export type TaskView = {
40
50
  endedAt?: string;
41
51
  elapsedMs: number;
42
52
  timeoutMs: number;
53
+ /** Execution cutoff, not an estimate of task completion. */
54
+ deadlineAt?: string;
55
+ timeoutSource?: "caller" | "task-manager-default";
43
56
  remainingMs: number | null;
44
57
  etaConfidence: "timeout-budget" | "unknown" | "complete";
45
58
  lastOutputAt?: string;
@@ -58,6 +71,13 @@ export type TaskView = {
58
71
  * over stdout for cross-harness comparisons: some adapters' stdout is a
59
72
  * full session transcript, others' is just the final reply. */
60
73
  finalMessage?: string;
74
+ finalMessageChars?: number;
75
+ /** Availability of non-whitespace answer evidence, even in compact views. */
76
+ hasOutput?: boolean;
77
+ /** The preferred answer was truncated during capture or in this view. */
78
+ outputTruncated?: boolean;
79
+ /** True when preferred answer bytes were lost during stream capture, independent of view limits. */
80
+ captureTruncated?: boolean;
61
81
  /** Best-effort usage metrics parsed from the adapter's own output. */
62
82
  usage?: HarnessUsage;
63
83
  /** Set when this task ran against an isolated copy of the requested cwd
@@ -80,6 +100,7 @@ export type TaskSpawnInput = {
80
100
  stdin: "ignore" | "pipe";
81
101
  stdout: "pipe";
82
102
  stderr: "pipe";
103
+ detached: boolean;
83
104
  };
84
105
  export type TaskSpawn = (input: TaskSpawnInput) => TaskProcess;
85
106
 
@@ -95,7 +116,11 @@ type InternalTask = Omit<
95
116
  | "stdoutChars"
96
117
  | "stderrChars"
97
118
  | "eventCount"
119
+ | "finalMessageChars"
98
120
  > & {
121
+ nativeSessionId?: string;
122
+ extractSessionId?: HarnessAdapter["extractSessionId"];
123
+ streamCaptureTruncated?: boolean;
99
124
  createdAtMs: number;
100
125
  updatedAtMs: number;
101
126
  endedAtMs?: number;
@@ -105,11 +130,14 @@ type InternalTask = Omit<
105
130
  settled?: Promise<void>;
106
131
  streamReaders: Set<ReadableStreamDefaultReader<Uint8Array>>;
107
132
  timeout?: Timer;
133
+ killTimeout?: Timer;
134
+ forceSignalled?: boolean;
108
135
  finalMessagePath?: string;
109
136
  /** Exact temporary directory allocated by TaskManager. Keep this separate
110
137
  * from cwd because an adapter can override the command working directory. */
111
138
  isolatedCwd?: string;
112
139
  extractUsage?: HarnessAdapter["extractUsage"];
140
+ failureReason?: HarnessAdapter["failureReason"];
113
141
  };
114
142
 
115
143
  export type StartTaskResult = {
@@ -127,7 +155,6 @@ export type TaskManagerShutdownOptions = {
127
155
  deadlineMs?: number;
128
156
  };
129
157
 
130
- const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
131
158
  const DEFAULT_DRAIN_TIMEOUT_MS = 1_000;
132
159
  const DEFAULT_SHUTDOWN_DEADLINE_MS = 5_000;
133
160
  const DEFAULT_MAX_TASKS = 200;
@@ -141,6 +168,8 @@ export class TaskManager {
141
168
  private readonly maxTasks: number;
142
169
  private readonly spawn: TaskSpawn;
143
170
  private readonly removeIsolatedCwd: (path: string) => void;
171
+ private readonly ownsProcessGroups: boolean;
172
+ private readonly retainedTasks = new Map<string, number>();
144
173
  private shuttingDown = false;
145
174
  private shutdownPromise?: Promise<void>;
146
175
 
@@ -148,6 +177,7 @@ export class TaskManager {
148
177
  this.drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
149
178
  this.maxTasks = Math.max(1, options.maxTasks ?? DEFAULT_MAX_TASKS);
150
179
  this.spawn = options.spawn ?? ((input) => Bun.spawn(input));
180
+ this.ownsProcessGroups = !options.spawn && process.platform !== "win32";
151
181
  this.removeIsolatedCwd = options.removeIsolatedCwd ?? ((path) => {
152
182
  rmSync(path, { recursive: true, force: true });
153
183
  });
@@ -166,6 +196,29 @@ export class TaskManager {
166
196
  throw new Error(`${adapter.name} is not runnable through Ennodia yet.`);
167
197
  }
168
198
 
199
+ if (input.nativeSessionId) throw new Error("Use continueTaskId, not a native session ID.");
200
+ let previous: InternalTask | undefined;
201
+ if (input.continueTaskId) {
202
+ previous = this.tasks.get(input.continueTaskId);
203
+ if (!previous || previous.status === "running" || !previous.nativeSessionId || !previous.persistSession) {
204
+ throw new Error("Continuation requires a settled persistent task owned by this TaskManager.");
205
+ }
206
+ if (previous.continuationTaskId) throw new Error("Continue the latest task in this session, not an earlier turn.");
207
+ if (previous.harnessId !== adapter.id) throw new Error("Continuation must use the same harness.");
208
+ if (input.cwd && resolve(input.cwd) !== resolve(previous.cwd)) throw new Error("Continuation must use the same working directory.");
209
+ if (input.persistSession === false) throw new Error("Continuation requires session persistence.");
210
+ input = { ...input, cwd: previous.cwd, model: input.model ?? previous.model,
211
+ reasoningEffort: input.reasoningEffort ?? previous.reasoningEffort,
212
+ nativeSubagents: input.nativeSubagents ?? previous.nativeSubagents, persistSession: true };
213
+ }
214
+ if (input.persistSession && (!adapter.supportsSessionContinuation || input.isolateCwd)) {
215
+ throw new Error("Persistent sessions require a supported harness and a persistent working directory.");
216
+ }
217
+ assertReasoningEffortSupported(adapter, input.reasoningEffort);
218
+ assertNativeSubagentsSupported(adapter, input.nativeSubagents);
219
+ assertNativeSandboxSupported(adapter, input.nativeSandbox);
220
+ if (adapter.supportsNativeSandbox) input = { ...input, nativeSandbox: input.nativeSandbox ?? "read-only" };
221
+
169
222
  const sourceCwd = input.cwd ?? process.cwd();
170
223
  if (!existsSync(sourceCwd)) {
171
224
  throw new Error(`Working directory does not exist: ${sourceCwd}`);
@@ -187,10 +240,22 @@ export class TaskManager {
187
240
  // as a no-op.
188
241
  const finalMessagePath = join(tmpdir(), `ennodia-final-${randomUUID()}.txt`);
189
242
 
190
- const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
243
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS;
244
+ const now = Date.now();
245
+ const deadlineMs = now + timeoutMs;
246
+ 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.`;
191
256
  const augmentedPrompt = input.skills && input.skills.length > 0
192
- ? augmentPrompt(input.prompt, input.skills, adapter.id)
193
- : input.prompt;
257
+ ? augmentPrompt(executionPrompt, input.skills, adapter.id)
258
+ : executionPrompt;
194
259
  let commandSpec: CommandSpec;
195
260
  let cwd: string;
196
261
  let child: TaskProcess;
@@ -200,6 +265,7 @@ export class TaskManager {
200
265
  cwd: resolvedInputCwd,
201
266
  prompt: augmentedPrompt,
202
267
  finalMessagePath,
268
+ nativeSessionId: previous?.nativeSessionId,
203
269
  });
204
270
  cwd = commandSpec.cwd ?? process.cwd();
205
271
 
@@ -214,6 +280,7 @@ export class TaskManager {
214
280
  stdin: commandSpec.stdin === undefined ? "ignore" : "pipe",
215
281
  stdout: "pipe",
216
282
  stderr: "pipe",
283
+ detached: process.platform !== "win32",
217
284
  });
218
285
  } catch (error) {
219
286
  cleanupFailedStartArtifacts(
@@ -223,11 +290,18 @@ export class TaskManager {
223
290
  throw error;
224
291
  }
225
292
 
226
- const now = Date.now();
227
293
  const task: InternalTask = {
228
294
  id: randomUUID(),
229
295
  harnessId: adapter.id,
230
296
  harnessName: adapter.name,
297
+ model: input.model,
298
+ reasoningEffort: input.reasoningEffort,
299
+ nativeSubagents: input.nativeSubagents,
300
+ nativeSandbox: input.nativeSandbox,
301
+ persistSession: input.persistSession,
302
+ continueTaskId: input.continueTaskId,
303
+ nativeSessionId: previous?.nativeSessionId,
304
+ extractSessionId: input.persistSession ? adapter.extractSessionId : undefined,
231
305
  status: "running",
232
306
  cwd,
233
307
  isolatedFrom,
@@ -240,6 +314,8 @@ export class TaskManager {
240
314
  createdAtMs: now,
241
315
  updatedAtMs: now,
242
316
  timeoutMs,
317
+ timeoutSource: input.timeoutMs === undefined ? "task-manager-default" : "caller",
318
+ deadlineAt,
243
319
  cancelRequested: false,
244
320
  drainTimedOut: false,
245
321
  timedOut: false,
@@ -251,6 +327,7 @@ export class TaskManager {
251
327
  finalMessagePath,
252
328
  isolatedCwd,
253
329
  extractUsage: adapter.extractUsage,
330
+ failureReason: adapter.failureReason,
254
331
  };
255
332
 
256
333
  task.process = child;
@@ -259,8 +336,15 @@ export class TaskManager {
259
336
  // Publish a task only after the process exists. A synchronous spawn
260
337
  // failure must not leave a phantom running task that cannot settle.
261
338
  this.tasks.set(task.id, task);
339
+ if (previous) previous.continuationTaskId = task.id;
262
340
  this.pruneTasks(task.id);
263
- this.pushEvent(task, { type: "started", message: "Task started." });
341
+ 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) {
343
+ this.pushEvent(task, {
344
+ type: "input-guidance",
345
+ message: "Media input guidance was included in the worker prompt. Native inspection remains unverified until the worker reports evidence.",
346
+ });
347
+ }
264
348
 
265
349
  task.streamsDone = Promise.all([
266
350
  this.pipeStreamSafely(task, "stdout", child.stdout),
@@ -269,7 +353,6 @@ export class TaskManager {
269
353
 
270
354
  task.timeout = setTimeout(() => {
271
355
  if (task.cancelRequested) {
272
- child.kill();
273
356
  return;
274
357
  }
275
358
 
@@ -278,8 +361,8 @@ export class TaskManager {
278
361
  type: "error",
279
362
  message: `Timed out after ${timeoutMs}ms.`,
280
363
  });
281
- child.kill();
282
- }, timeoutMs);
364
+ this.terminate(task);
365
+ }, Math.max(0, deadlineMs - Date.now()));
283
366
 
284
367
  task.settled = this.watchExit(task, child);
285
368
  void task.settled;
@@ -298,7 +381,7 @@ export class TaskManager {
298
381
  type: "error",
299
382
  message: error instanceof Error ? error.message : String(error),
300
383
  });
301
- child.kill();
384
+ this.terminate(task);
302
385
  }
303
386
  }
304
387
 
@@ -323,6 +406,19 @@ export class TaskManager {
323
406
  return task ? this.toView(task, options) : undefined;
324
407
  }
325
408
 
409
+ /** Keep evidence alive until its owning operation has recorded its result. */
410
+ retain(taskId: string): void {
411
+ if (!this.tasks.has(taskId)) throw new Error(`Unknown task: ${taskId}`);
412
+ this.retainedTasks.set(taskId, (this.retainedTasks.get(taskId) ?? 0) + 1);
413
+ }
414
+
415
+ release(taskId: string): void {
416
+ const count = this.retainedTasks.get(taskId) ?? 0;
417
+ if (count > 1) this.retainedTasks.set(taskId, count - 1);
418
+ else this.retainedTasks.delete(taskId);
419
+ this.pruneTasks();
420
+ }
421
+
326
422
  cancel(taskId: string): TaskView {
327
423
  const task = this.tasks.get(taskId);
328
424
  if (!task) {
@@ -380,7 +476,7 @@ export class TaskManager {
380
476
  type: "error",
381
477
  message: `Shutdown deadline exceeded after ${deadlineMs}ms; force-killing task.`,
382
478
  });
383
- task.process?.kill("SIGKILL");
479
+ this.signal(task, "SIGKILL");
384
480
  }
385
481
 
386
482
  await Promise.allSettled(
@@ -397,21 +493,22 @@ export class TaskManager {
397
493
  task: InternalTask,
398
494
  child: TaskProcess,
399
495
  ): Promise<void> {
496
+ let terminalStatus: TaskStatus = "failed";
400
497
  try {
401
498
  const exitCode = await child.exited;
499
+ if (task.killTimeout) this.signal(task, "SIGKILL");
402
500
  await this.waitForOutputDrain(task);
403
501
  task.exitCode = exitCode;
404
502
 
405
503
  if (task.cancelRequested) {
406
- task.status = "cancelled";
407
- } else if (task.status === "running") {
408
- task.status =
504
+ terminalStatus = "cancelled";
505
+ } else {
506
+ terminalStatus =
409
507
  exitCode === 0 && !task.timedOut && !task.drainTimedOut
410
508
  ? "succeeded"
411
509
  : "failed";
412
510
  }
413
511
 
414
- task.endedAtMs = Date.now();
415
512
  this.pushEvent(task, {
416
513
  type: "exit",
417
514
  message: `Process exited with code ${exitCode}.`,
@@ -420,21 +517,37 @@ export class TaskManager {
420
517
  await this.waitForOutputDrain(task);
421
518
 
422
519
  if (task.cancelRequested) {
423
- task.status = "cancelled";
424
- } else if (task.status === "running") {
425
- task.status = "failed";
520
+ terminalStatus = "cancelled";
521
+ } else {
522
+ terminalStatus = "failed";
426
523
  }
427
524
 
428
- task.endedAtMs = Date.now();
429
525
  this.pushEvent(task, {
430
526
  type: "error",
431
527
  message: error instanceof Error ? error.message : String(error),
432
528
  });
433
529
  } finally {
530
+ // A CLI can exit before its build/shell children. Clean up the owned
531
+ // group before deleting an isolated checkout or reporting completion.
532
+ this.signal(task, "SIGKILL");
434
533
  await this.collectFinalMessage(task);
534
+ if (terminalStatus === "succeeded" && task.failureReason) {
535
+ try {
536
+ const reason = task.failureReason(task.finalMessage || task.stdout, task.stderr);
537
+ if (reason) {
538
+ terminalStatus = "failed";
539
+ this.pushEvent(task, { type: "error", message: reason });
540
+ }
541
+ } catch (error) {
542
+ terminalStatus = "failed";
543
+ this.pushEvent(task, { type: "error", message: `Adapter result check failed: ${String(error)}` });
544
+ }
545
+ }
435
546
  this.collectUsage(task);
436
547
  this.cleanupIsolatedCwd(task);
437
548
  this.clearTimers(task);
549
+ task.status = task.cancelRequested ? "cancelled" : terminalStatus;
550
+ task.endedAtMs = Date.now();
438
551
  this.touch(task);
439
552
  this.pruneTasks(task.id);
440
553
  }
@@ -464,7 +577,7 @@ export class TaskManager {
464
577
  }
465
578
 
466
579
  private collectUsage(task: InternalTask): void {
467
- if (!task.extractUsage) {
580
+ if (!task.extractUsage || task.streamCaptureTruncated) {
468
581
  return;
469
582
  }
470
583
 
@@ -502,7 +615,8 @@ export class TaskManager {
502
615
  }
503
616
 
504
617
  const removable = [...this.tasks.values()]
505
- .filter((task) => task.id !== protectedTaskId && task.status !== "running")
618
+ .filter((task) => task.id !== protectedTaskId && task.status !== "running" &&
619
+ !this.retainedTasks.has(task.id))
506
620
  .sort((a, b) => a.createdAtMs - b.createdAtMs);
507
621
 
508
622
  let removed = 0;
@@ -524,7 +638,26 @@ export class TaskManager {
524
638
  this.pushEvent(task, { type: "cancel", message });
525
639
  }
526
640
 
527
- task.process?.kill();
641
+ this.terminate(task);
642
+ }
643
+
644
+ private signal(task: InternalTask, signal: "SIGTERM" | "SIGKILL"): void {
645
+ if (!task.process || task.forceSignalled) return;
646
+ try {
647
+ signalOwnedProcess(task.process, signal, this.ownsProcessGroups);
648
+ if (signal === "SIGKILL") task.forceSignalled = true;
649
+ } catch (error) {
650
+ this.pushEvent(task, { type: "error", message: `Process cleanup failed: ${String(error)}` });
651
+ }
652
+ }
653
+
654
+ private terminate(task: InternalTask): void {
655
+ if (task.killTimeout) return;
656
+ this.signal(task, "SIGTERM");
657
+ task.killTimeout = setTimeout(() => {
658
+ this.pushEvent(task, { type: "error", message: "Termination grace period exceeded; force-killing task." });
659
+ this.signal(task, "SIGKILL");
660
+ }, 300);
528
661
  }
529
662
 
530
663
  private async waitForSettled(
@@ -638,6 +771,15 @@ export class TaskManager {
638
771
  }
639
772
 
640
773
  const chunk = decoder.decode(value, { stream: true });
774
+ if (streamName === "stdout" && task.extractSessionId && !task.nativeSessionId) {
775
+ try {
776
+ task.nativeSessionId = task.extractSessionId(task.stdout + chunk);
777
+ } catch (error) {
778
+ task.extractSessionId = undefined;
779
+ this.pushEvent(task, { type: "error", message: `Session ID extraction failed: ${String(error)}` });
780
+ }
781
+ }
782
+ task.streamCaptureTruncated ||= task[streamName].length + chunk.length > MAX_CAPTURE_CHARS;
641
783
  task[streamName] = appendLimited(task[streamName], chunk);
642
784
  task.lastOutputAtMs = Date.now();
643
785
  this.pushEvent(task, { type: streamName, message: chunk });
@@ -646,6 +788,7 @@ export class TaskManager {
646
788
 
647
789
  const finalChunk = decoder.decode();
648
790
  if (finalChunk) {
791
+ task.streamCaptureTruncated ||= task[streamName].length + finalChunk.length > MAX_CAPTURE_CHARS;
649
792
  task[streamName] = appendLimited(task[streamName], finalChunk);
650
793
  task.lastOutputAtMs = Date.now();
651
794
  this.pushEvent(task, { type: streamName, message: finalChunk });
@@ -674,6 +817,14 @@ export class TaskManager {
674
817
  id: task.id,
675
818
  harnessId: task.harnessId,
676
819
  harnessName: task.harnessName,
820
+ model: task.model,
821
+ reasoningEffort: task.reasoningEffort,
822
+ nativeSubagents: task.nativeSubagents,
823
+ nativeSandbox: task.nativeSandbox,
824
+ persistSession: task.persistSession,
825
+ continueTaskId: task.continueTaskId,
826
+ continuationTaskId: task.continuationTaskId,
827
+ canContinue: Boolean(!running && task.persistSession && task.nativeSessionId && !task.continuationTaskId),
677
828
  status: task.status,
678
829
  cancelRequested: task.cancelRequested,
679
830
  pid: task.pid,
@@ -685,6 +836,8 @@ export class TaskManager {
685
836
  endedAt: task.endedAtMs ? new Date(task.endedAtMs).toISOString() : undefined,
686
837
  elapsedMs,
687
838
  timeoutMs: task.timeoutMs,
839
+ timeoutSource: task.timeoutSource,
840
+ deadlineAt: task.deadlineAt,
688
841
  remainingMs,
689
842
  etaConfidence: running ? "timeout-budget" : "complete",
690
843
  lastOutputAt: task.lastOutputAtMs
@@ -700,7 +853,16 @@ export class TaskManager {
700
853
  stderr: includeOutput ? tail(task.stderr, maxOutputChars) : "",
701
854
  events: includeEvents ? tailItems(task.events, maxEvents) : [],
702
855
  appliedSkills: task.appliedSkills,
703
- finalMessage: includeOutput ? task.finalMessage : undefined,
856
+ finalMessage: includeOutput && task.finalMessage !== undefined
857
+ ? tail(task.finalMessage, maxOutputChars) : undefined,
858
+ finalMessageChars: task.finalMessage?.length ?? 0,
859
+ hasOutput: Boolean(task.finalMessage?.trim() || task.stdout.trim() || task.stderr.trim()),
860
+ captureTruncated: !task.finalMessage?.trim() && Boolean(task.streamCaptureTruncated),
861
+ outputTruncated: task.finalMessage?.trim()
862
+ ? !includeOutput || task.finalMessage.length > maxOutputChars
863
+ : Boolean(task.streamCaptureTruncated ||
864
+ task.stdout.length > (includeOutput ? maxOutputChars : 0) ||
865
+ task.stderr.length > (includeOutput ? maxOutputChars : 0)),
704
866
  usage: task.usage,
705
867
  isolatedFrom: task.isolatedFrom,
706
868
  };
@@ -726,6 +888,10 @@ export class TaskManager {
726
888
  }
727
889
 
728
890
  private clearTimers(task: InternalTask): void {
891
+ if (task.killTimeout) {
892
+ clearTimeout(task.killTimeout);
893
+ task.killTimeout = undefined;
894
+ }
729
895
  if (task.timeout) {
730
896
  clearTimeout(task.timeout);
731
897
  task.timeout = undefined;
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const ENNODIA_VERSION = "0.2.0";
1
+ export const ENNODIA_VERSION = "0.3.0";