@sayknow-cli/coding-agent 0.3.7 → 0.3.8

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 (248) hide show
  1. package/dist/types/append-only-mode.d.ts +31 -0
  2. package/dist/types/capability/rule.d.ts +8 -0
  3. package/dist/types/cli/completion-cli.d.ts +41 -0
  4. package/dist/types/cli/config-cli.d.ts +1 -0
  5. package/dist/types/cli/fixture-report.d.ts +20 -0
  6. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  7. package/dist/types/cli/nofile-limit.d.ts +11 -0
  8. package/dist/types/cli/notify-cli.d.ts +7 -0
  9. package/dist/types/cli/update-cli.d.ts +22 -0
  10. package/dist/types/cli.d.ts +124 -1
  11. package/dist/types/commands/completion.d.ts +29 -0
  12. package/dist/types/commands/config.d.ts +3 -0
  13. package/dist/types/commands/deep-interview.d.ts +3 -0
  14. package/dist/types/config/file-lock.d.ts +7 -6
  15. package/dist/types/config/keybindings.d.ts +2 -2
  16. package/dist/types/config/model-profile-activation.d.ts +6 -0
  17. package/dist/types/config/model-profiles.d.ts +1 -0
  18. package/dist/types/config/settings-schema.d.ts +105 -1
  19. package/dist/types/config/settings.d.ts +8 -0
  20. package/dist/types/discovery/index.d.ts +1 -0
  21. package/dist/types/export/html/template.generated.d.ts +1 -1
  22. package/dist/types/export/ttsr.d.ts +14 -3
  23. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  24. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  25. package/dist/types/goals/runtime.d.ts +1 -0
  26. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  27. package/dist/types/main.d.ts +4 -1
  28. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  29. package/dist/types/modes/components/model-selector.d.ts +4 -0
  30. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  31. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  32. package/dist/types/modes/components/status-line.d.ts +9 -0
  33. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  34. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  35. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  36. package/dist/types/modes/interactive-mode.d.ts +2 -0
  37. package/dist/types/modes/print-mode.d.ts +13 -1
  38. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  39. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  40. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  41. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  42. package/dist/types/modes/types.d.ts +1 -0
  43. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  44. package/dist/types/notifications/config.d.ts +14 -1
  45. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  46. package/dist/types/notifications/index.d.ts +6 -3
  47. package/dist/types/notifications/lifecycle-commands.d.ts +11 -3
  48. package/dist/types/notifications/telegram-daemon.d.ts +23 -3
  49. package/dist/types/notifications/telegram-reference.d.ts +0 -5
  50. package/dist/types/notifications/threaded-render.d.ts +6 -0
  51. package/dist/types/runtime-api-key.d.ts +7 -0
  52. package/dist/types/sdk.d.ts +3 -0
  53. package/dist/types/session/agent-session.d.ts +61 -6
  54. package/dist/types/session/agent-storage.d.ts +1 -0
  55. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  56. package/dist/types/session/messages.d.ts +4 -0
  57. package/dist/types/session/session-manager.d.ts +17 -1
  58. package/dist/types/skc-runtime/deep-interview-ambiguity.d.ts +39 -0
  59. package/dist/types/skc-runtime/deep-interview-recorder.d.ts +10 -1
  60. package/dist/types/skc-runtime/deep-interview-state.d.ts +17 -2
  61. package/dist/types/skc-runtime/session-state-sidecar.d.ts +7 -0
  62. package/dist/types/skc-runtime/state-writer.d.ts +3 -0
  63. package/dist/types/skc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  64. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +120 -13
  65. package/dist/types/skill-state/active-state.d.ts +18 -1
  66. package/dist/types/system-prompt.d.ts +18 -2
  67. package/dist/types/task/executor.d.ts +3 -0
  68. package/dist/types/task/fork-context-budget.d.ts +7 -0
  69. package/dist/types/task/token-log.d.ts +30 -0
  70. package/dist/types/task/token-log.test.d.ts +1 -0
  71. package/dist/types/tools/bisect.d.ts +81 -0
  72. package/dist/types/tools/computer.d.ts +2 -2
  73. package/dist/types/tools/index.d.ts +1 -0
  74. package/dist/types/tools/search.d.ts +2 -2
  75. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  76. package/dist/types/utils/file-mentions.d.ts +14 -0
  77. package/dist/types/utils/git.d.ts +32 -0
  78. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  79. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  80. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  81. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  82. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  83. package/package.json +8 -8
  84. package/src/append-only-mode.ts +58 -0
  85. package/src/autoresearch/prompt.md +1 -1
  86. package/src/autoresearch/tools/log-experiment.ts +1 -1
  87. package/src/capability/rule.ts +9 -0
  88. package/src/cli/completion-cli.ts +234 -0
  89. package/src/cli/config-cli.ts +61 -9
  90. package/src/cli/fast-help.ts +1 -0
  91. package/src/cli/fixture-report.test.ts +230 -0
  92. package/src/cli/fixture-report.ts +171 -0
  93. package/src/cli/nofile-limit.ts +48 -0
  94. package/src/cli/notify-cli.ts +92 -8
  95. package/src/cli/telegram-cli.ts +1 -1
  96. package/src/cli/update-cli.ts +85 -9
  97. package/src/cli.ts +29 -1
  98. package/src/commands/completion.ts +84 -0
  99. package/src/commands/config.ts +2 -0
  100. package/src/commands/contribution-prep.ts +1 -1
  101. package/src/commands/deep-interview.ts +2 -1
  102. package/src/config/file-lock.ts +73 -34
  103. package/src/config/keybindings.ts +2 -2
  104. package/src/config/model-profile-activation.ts +65 -0
  105. package/src/config/model-profiles.ts +31 -2
  106. package/src/config/settings-schema.ts +68 -1
  107. package/src/config/settings.ts +22 -0
  108. package/src/defaults/skc/skills/team/SKILL.md +17 -14
  109. package/src/defaults/skc/skills/ultragoal/SKILL.md +59 -4
  110. package/src/defaults/skc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  111. package/src/defaults/skc-defaults.ts +9 -0
  112. package/src/discovery/helpers.ts +10 -0
  113. package/src/discovery/index.ts +1 -0
  114. package/src/export/html/template.generated.ts +1 -1
  115. package/src/export/html/template.js +60 -2
  116. package/src/export/ttsr.ts +96 -18
  117. package/src/extensibility/extensions/runner.ts +183 -219
  118. package/src/extensibility/extensions/types.ts +9 -0
  119. package/src/extensibility/skc-plugins/runtime-adapters.ts +156 -44
  120. package/src/goals/runtime.ts +10 -2
  121. package/src/hindsight/backend.ts +1 -3
  122. package/src/hooks/native-skill-hook.ts +124 -4
  123. package/src/internal-urls/docs-index.generated.ts +14 -5
  124. package/src/main.ts +95 -43
  125. package/src/memories/index.ts +12 -1
  126. package/src/modes/components/assistant-message.ts +241 -82
  127. package/src/modes/components/custom-editor.ts +1 -0
  128. package/src/modes/components/model-selector.ts +54 -11
  129. package/src/modes/components/queued-message-selector.ts +92 -0
  130. package/src/modes/components/settings-selector.ts +4 -0
  131. package/src/modes/components/status-line.ts +167 -54
  132. package/src/modes/components/tool-execution.ts +44 -19
  133. package/src/modes/components/welcome.ts +181 -44
  134. package/src/modes/controllers/command-controller.ts +40 -4
  135. package/src/modes/controllers/event-controller.ts +66 -47
  136. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  137. package/src/modes/controllers/input-controller.ts +180 -18
  138. package/src/modes/controllers/selector-controller.ts +127 -24
  139. package/src/modes/interactive-mode.ts +51 -16
  140. package/src/modes/print-mode.ts +43 -4
  141. package/src/modes/prompt-action-autocomplete.ts +21 -20
  142. package/src/modes/rpc/rpc-mode.ts +29 -3
  143. package/src/modes/rpc/rpc-types.ts +9 -0
  144. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  145. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  146. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  147. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  148. package/src/modes/types.ts +1 -0
  149. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  150. package/src/modes/utils/ui-helpers.ts +20 -7
  151. package/src/notifications/config.ts +25 -5
  152. package/src/notifications/daemon-paths.ts +2 -0
  153. package/src/notifications/index.ts +117 -14
  154. package/src/notifications/lifecycle-commands.ts +37 -7
  155. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  156. package/src/notifications/telegram-daemon-control.ts +5 -3
  157. package/src/notifications/telegram-daemon.ts +302 -51
  158. package/src/notifications/telegram-reference.ts +0 -16
  159. package/src/notifications/threaded-render.ts +17 -1
  160. package/src/prompts/agents/architect.md +19 -10
  161. package/src/prompts/agents/critic.md +16 -18
  162. package/src/prompts/agents/executor.md +14 -11
  163. package/src/prompts/agents/planner.md +18 -13
  164. package/src/prompts/ci-green-request.md +3 -8
  165. package/src/prompts/goals/goal-continuation.md +0 -5
  166. package/src/prompts/goals/goal-mode-active.md +0 -5
  167. package/src/prompts/system/agent-creation-architect.md +2 -2
  168. package/src/prompts/system/plan-mode-active.md +1 -1
  169. package/src/prompts/system/plan-mode-subagent.md +3 -3
  170. package/src/prompts/system/project-prompt.md +0 -13
  171. package/src/prompts/system/subagent-system-prompt.md +3 -2
  172. package/src/prompts/system/system-prompt.md +29 -17
  173. package/src/prompts/system/volatile-project-context.md +10 -0
  174. package/src/prompts/system/web-search.md +4 -7
  175. package/src/prompts/tools/apply-patch.md +6 -0
  176. package/src/prompts/tools/ast-edit.md +1 -0
  177. package/src/prompts/tools/bash.md +1 -1
  178. package/src/prompts/tools/bisect.md +22 -0
  179. package/src/prompts/tools/browser.md +1 -1
  180. package/src/prompts/tools/computer.md +2 -2
  181. package/src/prompts/tools/cron.md +0 -2
  182. package/src/prompts/tools/eval.md +2 -2
  183. package/src/prompts/tools/find.md +1 -1
  184. package/src/prompts/tools/github.md +6 -6
  185. package/src/prompts/tools/goal.md +1 -3
  186. package/src/prompts/tools/image-gen.md +12 -5
  187. package/src/prompts/tools/irc.md +5 -4
  188. package/src/prompts/tools/job.md +2 -2
  189. package/src/prompts/tools/lsp.md +1 -1
  190. package/src/prompts/tools/monitor.md +2 -2
  191. package/src/prompts/tools/patch.md +2 -2
  192. package/src/prompts/tools/replace.md +2 -21
  193. package/src/prompts/tools/rewind.md +1 -1
  194. package/src/prompts/tools/search-tool-bm25.md +1 -11
  195. package/src/prompts/tools/search.md +1 -1
  196. package/src/prompts/tools/skill.md +3 -2
  197. package/src/prompts/tools/ssh.md +2 -1
  198. package/src/prompts/tools/subagent.md +2 -2
  199. package/src/prompts/tools/task.md +4 -5
  200. package/src/prompts/tools/todo-write.md +1 -1
  201. package/src/prompts/tools/vim.md +1 -0
  202. package/src/prompts/tools/web-search.md +2 -0
  203. package/src/prompts/tools/write.md +8 -2
  204. package/src/runtime-api-key.ts +16 -0
  205. package/src/sdk.ts +52 -28
  206. package/src/session/agent-session.ts +1011 -141
  207. package/src/session/agent-storage.ts +4 -0
  208. package/src/session/file-mention-pruning.ts +64 -0
  209. package/src/session/messages.ts +4 -0
  210. package/src/session/session-manager.ts +103 -4
  211. package/src/skc-runtime/deep-interview-ambiguity.ts +206 -0
  212. package/src/skc-runtime/deep-interview-recorder.ts +62 -8
  213. package/src/skc-runtime/deep-interview-state.ts +44 -2
  214. package/src/skc-runtime/session-state-sidecar.ts +263 -20
  215. package/src/skc-runtime/state-runtime.ts +12 -2
  216. package/src/skc-runtime/state-writer.ts +15 -0
  217. package/src/skc-runtime/ultragoal-guard.ts +55 -66
  218. package/src/skc-runtime/ultragoal-receipt-freshness.ts +315 -0
  219. package/src/skc-runtime/ultragoal-runtime.ts +1663 -159
  220. package/src/skc-runtime/workflow-manifest.generated.json +113 -2
  221. package/src/skc-runtime/workflow-manifest.ts +74 -1
  222. package/src/skill-state/active-state.ts +141 -5
  223. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  224. package/src/slash-commands/builtin-registry.ts +218 -71
  225. package/src/system-prompt.ts +57 -16
  226. package/src/task/executor.ts +132 -24
  227. package/src/task/fork-context-advisory.ts +2 -7
  228. package/src/task/fork-context-budget.ts +9 -0
  229. package/src/task/index.ts +13 -6
  230. package/src/task/output-manager.ts +20 -8
  231. package/src/task/receipt.ts +26 -7
  232. package/src/task/token-log.test.ts +188 -0
  233. package/src/task/token-log.ts +179 -0
  234. package/src/tools/bisect.ts +367 -0
  235. package/src/tools/computer.ts +246 -43
  236. package/src/tools/index.ts +3 -0
  237. package/src/tools/output-meta.ts +116 -4
  238. package/src/tools/search.ts +8 -2
  239. package/src/tools/skill.ts +15 -0
  240. package/src/tools/telegram-send.ts +6 -0
  241. package/src/tools/ultragoal-ask-guard.ts +39 -5
  242. package/src/utils/file-mentions.ts +39 -7
  243. package/src/utils/git.ts +73 -0
  244. package/src/web/scrapers/youtube.ts +24 -12
  245. package/src/workflow/workflow-intent-diff.ts +29 -6
  246. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  247. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  248. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -34,6 +34,7 @@ import type { AuthStorage } from "../session/auth-storage";
34
34
  import { SKILL_PROMPT_MESSAGE_TYPE } from "../session/messages";
35
35
  import { SessionManager } from "../session/session-manager";
36
36
  import { truncateTail } from "../session/streaming-output";
37
+ import { sessionRoot } from "../skc-runtime/session-layout";
37
38
  import type { ContextFileEntry } from "../tools";
38
39
  import { jtdToJsonSchema, normalizeSchema } from "../tools/jtd-to-json-schema";
39
40
  import { type ReportFindingDetails, toReviewFinding } from "../tools/review";
@@ -42,6 +43,7 @@ import type { EventBus } from "../utils/event-bus";
42
43
  import { buildNamedToolChoiceResult } from "../utils/tool-choice";
43
44
  import type { WorkspaceTree } from "../workspace-tree";
44
45
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
46
+ import { persistTaskTokenLog, taskTokenLogFromUsage } from "./token-log";
45
47
  import {
46
48
  type AgentDefinition,
47
49
  type AgentProgress,
@@ -139,6 +141,8 @@ export interface ExecutorOptions {
139
141
  artifactsDir?: string;
140
142
  /** Path to parent conversation context file */
141
143
  contextFile?: string;
144
+ /** Whether the parent runtime actually exposes IRC coordination. */
145
+ ircAvailable?: boolean;
142
146
  eventBus?: EventBus;
143
147
  contextFiles?: ContextFileEntry[];
144
148
  skills?: Skill[];
@@ -243,6 +247,51 @@ function previewOffendingData(value: unknown, maxLength = 500): string {
243
247
  }
244
248
  return serialized.length > maxLength ? `${serialized.slice(0, maxLength)}…` : serialized;
245
249
  }
250
+ const PLACEHOLDER_YIELD_PATTERNS = [
251
+ /^see (?:the )?message body(?:\b|[\s—:.,-])/i,
252
+ /^(?:complete\s+\w+\s+)?returned inline(?:\b|[\s—:.,-])/i,
253
+ /^leader persists(?:\b|[\s—:.,-])/i,
254
+ /^caller persists(?:\b|[\s—:.,-])/i,
255
+ ];
256
+
257
+ const PLACEHOLDER_YIELD_FIELD_NAMES = new Set([
258
+ "artifactmarkdown",
259
+ "finalmarkdown",
260
+ "fullplan",
261
+ "markdown",
262
+ "planmarkdown",
263
+ ]);
264
+
265
+ function looksLikePlaceholderYieldString(value: string): boolean {
266
+ const trimmed = value.trim();
267
+ if (trimmed.length === 0 || trimmed.length > 500) return false;
268
+ return PLACEHOLDER_YIELD_PATTERNS.some(pattern => pattern.test(trimmed));
269
+ }
270
+
271
+ function normalizePlaceholderYieldFieldName(key: string): string {
272
+ return key.replace(/[^a-z0-9]/gi, "").toLowerCase();
273
+ }
274
+
275
+ function findPlaceholderYieldPath(value: unknown, path = "$", depth = 0, inspectStrings = true): string | undefined {
276
+ if (typeof value === "string") {
277
+ return inspectStrings && looksLikePlaceholderYieldString(value) ? path : undefined;
278
+ }
279
+ if (!value || typeof value !== "object" || depth > 4) return undefined;
280
+ if (Array.isArray(value)) {
281
+ for (let i = 0; i < value.length; i++) {
282
+ const found = findPlaceholderYieldPath(value[i], `${path}[${i}]`, depth + 1, false);
283
+ if (found) return found;
284
+ }
285
+ return undefined;
286
+ }
287
+ const record = value as Record<string, unknown>;
288
+ for (const [key, item] of Object.entries(record)) {
289
+ const shouldInspectString = PLACEHOLDER_YIELD_FIELD_NAMES.has(normalizePlaceholderYieldFieldName(key));
290
+ const found = findPlaceholderYieldPath(item, `${path}.${key}`, depth + 1, shouldInspectString);
291
+ if (found) return found;
292
+ }
293
+ return undefined;
294
+ }
246
295
 
247
296
  function tryParseJsonOutput(text: string): unknown | undefined {
248
297
  const trimmed = text.trim();
@@ -319,6 +368,8 @@ interface FinalizeSubprocessOutputResult {
319
368
  export const SUBAGENT_WARNING_NULL_YIELD = "SYSTEM WARNING: Subagent called yield with null data.";
320
369
  export const SUBAGENT_WARNING_MISSING_YIELD =
321
370
  "SYSTEM WARNING: Subagent exited without calling yield tool after 3 reminders.";
371
+ export const SUBAGENT_WARNING_PLACEHOLDER_YIELD =
372
+ "SYSTEM WARNING: Subagent yield data contains a placeholder instead of the actual result.";
322
373
 
323
374
  /** Build a schema_violation outcome — surfaced as a non-zero exit so callers treat it as a failure. */
324
375
  function buildSchemaViolationOutcome(
@@ -345,6 +396,21 @@ function buildSchemaViolationOutcome(
345
396
  return { rawOutput, stderr: headline, exitCode: 1 };
346
397
  }
347
398
 
399
+ function buildPlaceholderYieldOutcome(
400
+ placeholderPath: string,
401
+ data: unknown,
402
+ ): { rawOutput: string; stderr: string; exitCode: number } {
403
+ return buildSchemaViolationOutcome(
404
+ {
405
+ message:
406
+ `${SUBAGENT_WARNING_PLACEHOLDER_YIELD} Offending path: ${placeholderPath}. ` +
407
+ "Return the real payload in yield.result.data or persist a durable artifact receipt.",
408
+ missingRequired: [],
409
+ },
410
+ data,
411
+ );
412
+ }
413
+
348
414
  export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): FinalizeSubprocessOutputResult {
349
415
  let { rawOutput, exitCode, stderr } = args;
350
416
  const { yieldItems, reportFindings, doneAborted, signalAborted, outputSchema } = args;
@@ -374,21 +440,29 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
374
440
  stderr = `schema_violation: invalid output schema: ${schemaError}`;
375
441
  exitCode = 1;
376
442
  } else {
377
- const verdict = validator ? validator.validate(completeData) : { ok: true as const };
378
- if (!verdict.ok) {
379
- const outcome = buildSchemaViolationOutcome(verdict, completeData);
443
+ const placeholderPath = findPlaceholderYieldPath(completeData);
444
+ if (placeholderPath) {
445
+ const outcome = buildPlaceholderYieldOutcome(placeholderPath, completeData);
380
446
  rawOutput = outcome.rawOutput;
381
447
  stderr = outcome.stderr;
382
448
  exitCode = outcome.exitCode;
383
449
  } else {
384
- try {
385
- rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
386
- } catch (err) {
387
- const errorMessage = err instanceof Error ? err.message : String(err);
388
- rawOutput = `{"error":"Failed to serialize yield data: ${errorMessage}"}`;
450
+ const verdict = validator ? validator.validate(completeData) : { ok: true as const };
451
+ if (!verdict.ok) {
452
+ const outcome = buildSchemaViolationOutcome(verdict, completeData);
453
+ rawOutput = outcome.rawOutput;
454
+ stderr = outcome.stderr;
455
+ exitCode = outcome.exitCode;
456
+ } else {
457
+ try {
458
+ rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
459
+ } catch (err) {
460
+ const errorMessage = err instanceof Error ? err.message : String(err);
461
+ rawOutput = `{"error":"Failed to serialize yield data: ${errorMessage}"}`;
462
+ }
463
+ exitCode = 0;
464
+ stderr = "";
389
465
  }
390
- exitCode = 0;
391
- stderr = "";
392
466
  }
393
467
  }
394
468
  }
@@ -401,21 +475,29 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
401
475
  if (fallback) {
402
476
  const completeData = normalizeCompleteData(fallback.data, reportFindings);
403
477
  const { validator } = buildOutputValidator(outputSchema);
404
- const verdict = validator ? validator.validate(completeData) : { ok: true as const };
405
- if (!verdict.ok) {
406
- const outcome = buildSchemaViolationOutcome(verdict, completeData);
478
+ const placeholderPath = findPlaceholderYieldPath(completeData);
479
+ if (placeholderPath) {
480
+ const outcome = buildPlaceholderYieldOutcome(placeholderPath, completeData);
407
481
  rawOutput = outcome.rawOutput;
408
482
  stderr = outcome.stderr;
409
483
  exitCode = outcome.exitCode;
410
484
  } else {
411
- try {
412
- rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
413
- } catch (err) {
414
- const errorMessage = err instanceof Error ? err.message : String(err);
415
- rawOutput = `{"error":"Failed to serialize fallback completion: ${errorMessage}"}`;
485
+ const verdict = validator ? validator.validate(completeData) : { ok: true as const };
486
+ if (!verdict.ok) {
487
+ const outcome = buildSchemaViolationOutcome(verdict, completeData);
488
+ rawOutput = outcome.rawOutput;
489
+ stderr = outcome.stderr;
490
+ exitCode = outcome.exitCode;
491
+ } else {
492
+ try {
493
+ rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
494
+ } catch (err) {
495
+ const errorMessage = err instanceof Error ? err.message : String(err);
496
+ rawOutput = `{"error":"Failed to serialize fallback completion: ${errorMessage}"}`;
497
+ }
498
+ exitCode = 0;
499
+ stderr = "";
416
500
  }
417
- exitCode = 0;
418
- stderr = "";
419
501
  }
420
502
  } else if (!hasOutputSchema && allowFallback && rawOutput.trim().length > 0) {
421
503
  exitCode = 0;
@@ -623,7 +705,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
623
705
  : agent.spawns.join(",");
624
706
 
625
707
  const lspEnabled = enableLsp ?? true;
626
- const ircEnabled = subagentSettings.get("irc.enabled") === true;
708
+ const ircEnabled = options.ircAvailable === true;
627
709
  const contextFileForPrompt = ircEnabled ? undefined : options.contextFile;
628
710
  const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("eval");
629
711
 
@@ -1207,6 +1289,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1207
1289
  const subagentAgentIdentity: AgentIdentity | undefined = options.parentTelemetry
1208
1290
  ? { id, name: agent.name, description: agent.description }
1209
1291
  : undefined;
1292
+ let subagentTokenTurn = 0;
1293
+ const tokenLogDir = options.parentSessionId
1294
+ ? path.join(sessionRoot(cwd, options.parentSessionId), "token-logs")
1295
+ : undefined;
1210
1296
  const subagentTelemetry: AgentTelemetryConfig | undefined =
1211
1297
  options.parentTelemetry && subagentAgentIdentity
1212
1298
  ? {
@@ -1215,6 +1301,27 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1215
1301
  // Clear parent's conversationId; the child loop falls back to
1216
1302
  // its own AgentLoopConfig.sessionId.
1217
1303
  conversationId: undefined,
1304
+ // Intentionally REPLACES (does not chain) the parent's onChatUsage:
1305
+ // the parent handler attributes turns to subagentId "root", so
1306
+ // chaining it here would double-log every subagent turn under root.
1307
+ // Subagent turns are attributed to this child's id instead.
1308
+ onChatUsage: async event => {
1309
+ if (!tokenLogDir) return;
1310
+ subagentTokenTurn += 1;
1311
+ await persistTaskTokenLog(
1312
+ taskTokenLogFromUsage(event.usage, {
1313
+ subagentId: id,
1314
+ agent: agent.name,
1315
+ // Monotonic 1-based sequence per subagent session
1316
+ // (event.stepNumber is 0-based and -1 for oneshots).
1317
+ turn: subagentTokenTurn,
1318
+ at: new Date().toISOString(),
1319
+ model: event.model,
1320
+ cost: event.cost,
1321
+ }),
1322
+ { dir: tokenLogDir },
1323
+ );
1324
+ },
1218
1325
  }
1219
1326
  : undefined;
1220
1327
 
@@ -1231,9 +1338,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1231
1338
 
1232
1339
  const { normalized: normalizedOutputSchema } = normalizeSchema(outputSchema);
1233
1340
 
1234
- const forkContextNotice = options.forkContextSeed
1235
- ? `This subagent was started with a forked snapshot of the parent conversation. Included ${options.forkContextSeed.metadata.includedMessages} message(s), skipped ${options.forkContextSeed.metadata.skippedMessages}, approximately ${options.forkContextSeed.metadata.approximateTokens} tokens. The snapshot is not live; use IRC for live coordination when enabled.`
1236
- : "";
1341
+ const forkContextNotice =
1342
+ options.forkContextSeed && options.forkContextSeed.metadata.includedMessages > 0
1343
+ ? `This subagent was started with a forked snapshot of the parent conversation. Included ${options.forkContextSeed.metadata.includedMessages} message(s), skipped ${options.forkContextSeed.metadata.skippedMessages}, approximately ${options.forkContextSeed.metadata.approximateTokens} tokens. The snapshot is not live.${ircEnabled ? " Use IRC for live coordination." : " Rely on the explicit assignment and supplied context for coordination."}`
1344
+ : "";
1237
1345
 
1238
1346
  const agentSubskillBlock = await buildAgentSubskillInjection({
1239
1347
  cwd,
@@ -1,3 +1,4 @@
1
+ import { FORK_CONTEXT_TOKEN_BUDGET_BY_MODE } from "./fork-context-budget";
1
2
  import type { ForkContextMode } from "./types";
2
3
 
3
4
  export interface ForkContextAdvisory {
@@ -12,13 +13,7 @@ export interface ForkContextAdvisory {
12
13
  * many parent-context tokens each mode may clone into the child; the actual
13
14
  * cloned amount can never exceed the parent context itself.
14
15
  */
15
- const CLONE_BUDGET_BY_MODE = {
16
- none: 0,
17
- receipt: 2000,
18
- "last-turn": 4000,
19
- bounded: 8000,
20
- full: 15000,
21
- } as const satisfies Record<ForkContextMode, number>;
16
+ const CLONE_BUDGET_BY_MODE = FORK_CONTEXT_TOKEN_BUDGET_BY_MODE;
22
17
 
23
18
  const RECEIPT_TRIGGERS = [
24
19
  { pattern: /as discussed/i, reason: "prior-session-reference:as-discussed" },
@@ -0,0 +1,9 @@
1
+ import type { ForkContextMode } from "./types";
2
+
3
+ export const FORK_CONTEXT_TOKEN_BUDGET_BY_MODE = {
4
+ none: 0,
5
+ receipt: 2000,
6
+ "last-turn": 4000,
7
+ bounded: 8000,
8
+ full: 15000,
9
+ } as const satisfies Record<ForkContextMode, number>;
package/src/task/index.ts CHANGED
@@ -48,6 +48,7 @@ import * as git from "../utils/git";
48
48
  import { discoverAgents, filterVisibleAgents, getAgent } from "./discovery";
49
49
  import { runSubprocess } from "./executor";
50
50
  import { adviseForkContextMode } from "./fork-context-advisory";
51
+ import { FORK_CONTEXT_TOKEN_BUDGET_BY_MODE } from "./fork-context-budget";
51
52
  import { getTaskIdValidationError, validateAllocatedTaskId } from "./id";
52
53
  import { AgentOutputManager } from "./output-manager";
53
54
  import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
@@ -178,6 +179,10 @@ export {
178
179
  /**
179
180
  * Render the tool description from a cached agent list and current settings.
180
181
  */
182
+ function hasAvailableIrcTool(session: ToolSession): boolean {
183
+ return session.settings.get("irc.enabled") === true && session.getToolByName?.("irc") !== undefined;
184
+ }
185
+
181
186
  function renderDescription(
182
187
  agents: AgentDefinition[],
183
188
  maxConcurrency: number,
@@ -299,11 +304,11 @@ function resolveForkSeedParamsForMode(
299
304
  case "none":
300
305
  return undefined;
301
306
  case "receipt":
302
- return { maxMessages: 1, maxTokens: 64 };
307
+ return { maxMessages: 1, maxTokens: FORK_CONTEXT_TOKEN_BUDGET_BY_MODE.receipt };
303
308
  case "last-turn":
304
- return { maxMessages: 2, maxTokens: 250 };
309
+ return { maxMessages: 2, maxTokens: FORK_CONTEXT_TOKEN_BUDGET_BY_MODE["last-turn"] };
305
310
  case "bounded":
306
- return { maxMessages: capMessages(50), maxTokens: 250 };
311
+ return { maxMessages: capMessages(50), maxTokens: FORK_CONTEXT_TOKEN_BUDGET_BY_MODE.bounded };
307
312
  case "full":
308
313
  return { maxMessages: capMessages(500), maxTokens: resolveForkContextMaxTokens(configuredMaxTokens, model) };
309
314
  default:
@@ -381,7 +386,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
381
386
  true,
382
387
  disabledAgents,
383
388
  this.#getTaskSimpleMode(),
384
- this.session.settings.get("irc.enabled") === true,
389
+ hasAvailableIrcTool(this.session),
385
390
  this.session.getSessionSpawns() ?? "*",
386
391
  );
387
392
  }
@@ -814,7 +819,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
814
819
  ? ` Failed to schedule ${failedSchedules.length} task${failedSchedules.length === 1 ? "" : "s"}.`
815
820
  : "";
816
821
 
817
- const ircEnabled = this.session.settings.get("irc.enabled") === true;
822
+ const ircEnabled = hasAvailableIrcTool(this.session);
818
823
  const taskIdByItemId = new Map<string, string>();
819
824
  for (let i = 0; i < taskItems.length; i++) {
820
825
  taskIdByItemId.set(taskItems[i].id, uniqueIds[i]);
@@ -1171,7 +1176,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1171
1176
  // Write parent conversation context for subagents. When IRC is available,
1172
1177
  // subagents should ask live peers instead of reading a stale markdown dump.
1173
1178
  await fs.mkdir(effectiveArtifactsDir, { recursive: true });
1174
- const shouldWriteConversationContext = this.session.settings.get("irc.enabled") !== true;
1179
+ const shouldWriteConversationContext = !hasAvailableIrcTool(this.session);
1175
1180
  const compactContext = shouldWriteConversationContext ? this.session.getCompactContext?.() : undefined;
1176
1181
  let contextFilePath: string | undefined;
1177
1182
  if (compactContext) {
@@ -1298,6 +1303,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1298
1303
  persistArtifacts: !!artifactsDir,
1299
1304
  artifactsDir: effectiveArtifactsDir,
1300
1305
  contextFile: contextFilePath,
1306
+ ircAvailable: hasAvailableIrcTool(this.session),
1301
1307
  enableLsp: subagentLspEnabled,
1302
1308
  signal,
1303
1309
  eventBus: this.session.eventBus,
@@ -1360,6 +1366,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1360
1366
  persistArtifacts: !!artifactsDir,
1361
1367
  artifactsDir: effectiveArtifactsDir,
1362
1368
  contextFile: contextFilePath,
1369
+ ircAvailable: hasAvailableIrcTool(this.session),
1363
1370
  enableLsp: subagentLspEnabled,
1364
1371
  signal,
1365
1372
  eventBus: this.session.eventBus,
@@ -14,6 +14,8 @@ function escapeRegExp(value: string): string {
14
14
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15
15
  }
16
16
 
17
+ const RESERVED_OUTPUT_EXTENSIONS_PATTERN = "(?:md|jsonl|patch)";
18
+
17
19
  /**
18
20
  * Manages agent output ID allocation to ensure uniqueness.
19
21
  *
@@ -24,7 +26,7 @@ function escapeRegExp(value: string): string {
24
26
  */
25
27
  export class AgentOutputManager {
26
28
  #nextId = 0;
27
- #initialized = false;
29
+ #initPromise: Promise<void> | undefined;
28
30
  readonly #getArtifactsDir: () => string | null;
29
31
  readonly #parentPrefix: string | undefined;
30
32
 
@@ -33,14 +35,24 @@ export class AgentOutputManager {
33
35
  this.#parentPrefix = options?.parentPrefix;
34
36
  }
35
37
 
38
+ /**
39
+ * Memoize the in-flight scan so concurrent `allocate*`/`peekNextIndex`
40
+ * calls await the SAME `readdir` before `#nextId` is derived. Assigning the
41
+ * promise synchronously (before any `await`) closes the TOCTOU window where a
42
+ * boolean "initialized" flag, flipped ahead of the awaited scan, let a second
43
+ * caller allocate at `#nextId === 0` while the first was still scanning —
44
+ * producing duplicate indices that overwrite prior outputs on resume.
45
+ */
46
+ #ensureInitialized(): Promise<void> {
47
+ this.#initPromise ??= this.#scanExistingOutputs();
48
+ return this.#initPromise;
49
+ }
50
+
36
51
  /**
37
52
  * Scan existing agent output files to find the next available ID.
38
53
  * This ensures we don't overwrite outputs when resuming a session.
39
54
  */
40
- async #ensureInitialized(): Promise<void> {
41
- if (this.#initialized) return;
42
- this.#initialized = true;
43
-
55
+ async #scanExistingOutputs(): Promise<void> {
44
56
  const dir = this.#getArtifactsDir();
45
57
  if (!dir) return;
46
58
 
@@ -52,8 +64,8 @@ export class AgentOutputManager {
52
64
  }
53
65
 
54
66
  const pattern = this.#parentPrefix
55
- ? new RegExp(`^${escapeRegExp(this.#parentPrefix)}\\.(\\d+)-.*\\.md$`)
56
- : /^(\d+)-.*\.md$/;
67
+ ? new RegExp(`^${escapeRegExp(this.#parentPrefix)}\\.(\\d+)-.*\\.${RESERVED_OUTPUT_EXTENSIONS_PATTERN}$`)
68
+ : new RegExp(`^(\\d+)-.*\\.${RESERVED_OUTPUT_EXTENSIONS_PATTERN}$`);
57
69
 
58
70
  let maxId = -1;
59
71
  for (const file of files) {
@@ -103,6 +115,6 @@ export class AgentOutputManager {
103
115
  */
104
116
  reset(): void {
105
117
  this.#nextId = 0;
106
- this.#initialized = false;
118
+ this.#initPromise = undefined;
107
119
  }
108
120
  }
@@ -77,6 +77,28 @@ function truncateText(value: string | undefined, maxChars: number): string | und
77
77
  return value.length > maxChars ? value.slice(0, maxChars) : value;
78
78
  }
79
79
 
80
+ const SAFE_REVIEW_SEVERITIES = new Set(["blocker", "critical", "error", "high", "medium", "warning", "low", "info"]);
81
+ const SAFE_REVIEW_PRIORITIES = new Set(["P0", "P1", "P2", "P3"]);
82
+
83
+ function normalizeReviewFindingSeverity(severity: unknown, priority: unknown): string | undefined {
84
+ if (typeof severity === "string") {
85
+ const normalizedPriority = severity.toUpperCase();
86
+ if (SAFE_REVIEW_PRIORITIES.has(normalizedPriority)) return normalizedPriority;
87
+ const normalizedSeverity = severity.toLowerCase();
88
+ if (SAFE_REVIEW_SEVERITIES.has(normalizedSeverity)) return normalizedSeverity;
89
+ }
90
+ if (typeof priority === "string") {
91
+ const normalizedPriority = priority.toUpperCase();
92
+ if (SAFE_REVIEW_PRIORITIES.has(normalizedPriority)) return normalizedPriority;
93
+ const normalizedSeverity = priority.toLowerCase();
94
+ if (SAFE_REVIEW_SEVERITIES.has(normalizedSeverity)) return normalizedSeverity;
95
+ }
96
+ if (typeof priority === "number" && Number.isInteger(priority) && priority >= 0 && priority <= 3) {
97
+ return `P${priority}`;
98
+ }
99
+ return undefined;
100
+ }
101
+
80
102
  function buildSafeSynopsis(raw: SingleResult, outputRef: TaskResultReceipt["outputRef"]): string {
81
103
  const status = getStatus(raw);
82
104
  if (raw.modelSubstitutionWarning) {
@@ -117,19 +139,16 @@ function buildReview(raw: SingleResult): TaskResultReceipt["review"] | undefined
117
139
  const rawFindings = Array.isArray(data.report_finding) ? data.report_finding : [];
118
140
  const findings = rawFindings.slice(0, 20).map(item => {
119
141
  const value = item && typeof item === "object" ? (item as Record<string, unknown>) : {};
120
- const severity =
121
- typeof value.severity === "string"
122
- ? value.severity
123
- : typeof value.priority === "string"
124
- ? value.priority
125
- : undefined;
142
+ const severity = normalizeReviewFindingSeverity(value.severity, value.priority);
126
143
  const summaryValue = value.summary ?? value.title ?? value.message ?? value.body ?? "finding";
127
144
  return { severity, summary: truncateText(String(summaryValue), 200) ?? "finding" };
128
145
  });
129
146
  if (!reviewYield && findings.length === 0) return undefined;
130
147
  return {
131
- overallCorrectness:
148
+ overallCorrectness: truncateText(
132
149
  typeof reviewYield?.overall_correctness === "string" ? reviewYield.overall_correctness : undefined,
150
+ 200,
151
+ ),
133
152
  findingCount: rawFindings.length,
134
153
  findings: findings.length > 0 ? findings : undefined,
135
154
  };
@@ -0,0 +1,188 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { appendFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { SKC_SESSION_ACTIVITY_FILE, sessionRoot } from "../skc-runtime/session-layout";
6
+ import {
7
+ computeCacheHitRate,
8
+ persistTaskTokenLog,
9
+ readTaskTokenLogs,
10
+ resolveTaskTokenLogDir,
11
+ taskTokenLogFromChat,
12
+ taskTokenLogFromUsage,
13
+ } from "./token-log";
14
+
15
+ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
16
+ const dir = await mkdtemp(join(tmpdir(), "skc-token-log-"));
17
+ try {
18
+ return await fn(dir);
19
+ } finally {
20
+ await rm(dir, { recursive: true, force: true });
21
+ }
22
+ }
23
+
24
+ describe("task token log", () => {
25
+ it("maps raw chat buckets without folding cache into input", () => {
26
+ const log = taskTokenLogFromChat(
27
+ {
28
+ inputTokens: 100,
29
+ outputTokens: 20,
30
+ cachedInputTokens: 30,
31
+ cacheWriteTokens: 5,
32
+ costUsd: 0.25,
33
+ },
34
+ {
35
+ subagentId: "root",
36
+ agent: "main",
37
+ turn: 1,
38
+ at: "2026-01-01T00:00:00.000Z",
39
+ model: "model-a",
40
+ },
41
+ );
42
+
43
+ expect(log.input).toBe(100);
44
+ expect(log.output).toBe(20);
45
+ expect(log.cacheRead).toBe(30);
46
+ expect(log.cacheWrite).toBe(5);
47
+ expect(log.totalTokens).toBe(155);
48
+ expect(log.cost).toBe(0.25);
49
+ expect(log.model).toBe("model-a");
50
+ });
51
+
52
+ it("maps zero-cache chat buckets without inventing cache tokens", () => {
53
+ const log = taskTokenLogFromChat(
54
+ { inputTokens: 7, outputTokens: 3, cachedInputTokens: 0, cacheWriteTokens: 0 },
55
+ { subagentId: "root", turn: 2, at: "2026-01-01T00:00:02.000Z" },
56
+ );
57
+
58
+ expect(log.input).toBe(7);
59
+ expect(log.output).toBe(3);
60
+ expect(log.cacheRead).toBe(0);
61
+ expect(log.cacheWrite).toBe(0);
62
+ expect(log.totalTokens).toBe(10);
63
+ });
64
+
65
+ it("keeps large cache-only reads out of input and hits the cache-rate upper boundary", () => {
66
+ const log = taskTokenLogFromChat(
67
+ { inputTokens: 0, outputTokens: 2, cachedInputTokens: 1_000_000_000, cacheWriteTokens: 0 },
68
+ { subagentId: "root", turn: 3, at: "2026-01-01T00:00:03.000Z" },
69
+ );
70
+
71
+ expect(log.input).toBe(0);
72
+ expect(log.output).toBe(2);
73
+ expect(log.cacheRead).toBe(1_000_000_000);
74
+ expect(log.cacheWrite).toBe(0);
75
+ expect(log.totalTokens).toBe(1_000_000_002);
76
+ expect(computeCacheHitRate(log.input, log.cacheRead)).toBe(1);
77
+ });
78
+
79
+ it("persists and reads a jsonl round trip", async () => {
80
+ await withTempDir(async dir => {
81
+ const first = taskTokenLogFromChat(
82
+ { inputTokens: 1, outputTokens: 2, cachedInputTokens: 3, cacheWriteTokens: 4 },
83
+ { subagentId: "root", turn: 1, at: "2026-01-01T00:00:00.000Z" },
84
+ );
85
+ const second = taskTokenLogFromChat(
86
+ { inputTokens: 5, outputTokens: 6, cachedInputTokens: 7, cacheWriteTokens: 8 },
87
+ { subagentId: "child", agent: "executor", turn: 1, at: "2026-01-01T00:00:01.000Z" },
88
+ );
89
+
90
+ await persistTaskTokenLog(first, { dir });
91
+ await persistTaskTokenLog(second, { dir });
92
+
93
+ expect(await readTaskTokenLogs(dir)).toEqual([first, second]);
94
+ });
95
+ });
96
+
97
+ it("prefers the current session manager over a stale latest-active session", async () => {
98
+ await withTempDir(async cwd => {
99
+ const currentSessionId = "current-session";
100
+ const staleSessionId = "stale-session";
101
+ for (const [sessionId, updatedAt] of [
102
+ [currentSessionId, "2026-01-01T00:00:00.000Z"],
103
+ [staleSessionId, "2026-01-01T00:00:10.000Z"],
104
+ ] as const) {
105
+ const markerPath = join(sessionRoot(cwd, sessionId), SKC_SESSION_ACTIVITY_FILE);
106
+ await mkdir(join(markerPath, ".."), { recursive: true });
107
+ await writeFile(
108
+ markerPath,
109
+ `${JSON.stringify({ session_id: sessionId, updated_at: updatedAt })}\n`,
110
+ "utf-8",
111
+ );
112
+ }
113
+
114
+ await expect(resolveTaskTokenLogDir(cwd, undefined, "")).resolves.toBe(
115
+ join(sessionRoot(cwd, staleSessionId), "token-logs"),
116
+ );
117
+ await expect(resolveTaskTokenLogDir(cwd, { getSessionId: () => currentSessionId }, "")).resolves.toBe(
118
+ join(sessionRoot(cwd, currentSessionId), "token-logs"),
119
+ );
120
+ });
121
+ });
122
+
123
+ it("missing token-log file reads as empty", async () => {
124
+ await withTempDir(async dir => {
125
+ expect(await readTaskTokenLogs(dir)).toEqual([]);
126
+ });
127
+ });
128
+
129
+ it("subtracts cache from the aggregate ChatUsageSnapshot input bucket", () => {
130
+ // buildUsageSnapshot sets inputTokens = rawInput + cacheRead + cacheWrite.
131
+ // taskTokenLogFromUsage must recover the raw (cache-excluded) input.
132
+ const log = taskTokenLogFromUsage(
133
+ {
134
+ inputTokens: 135,
135
+ outputTokens: 20,
136
+ totalTokens: 155,
137
+ cachedInputTokens: 30,
138
+ cacheWriteTokens: 5,
139
+ reasoningOutputTokens: 0,
140
+ },
141
+ { subagentId: "root", turn: 1, at: "2026-01-01T00:00:00.000Z", cost: { usd: 0.5 } },
142
+ );
143
+
144
+ expect(log.input).toBe(100);
145
+ expect(log.cacheRead).toBe(30);
146
+ expect(log.cacheWrite).toBe(5);
147
+ expect(log.output).toBe(20);
148
+ expect(log.totalTokens).toBe(155);
149
+ expect(log.cost).toBe(0.5);
150
+ });
151
+
152
+ it("clamps aggregate input underflow to zero", () => {
153
+ const log = taskTokenLogFromUsage(
154
+ {
155
+ inputTokens: 10,
156
+ outputTokens: 1,
157
+ totalTokens: 10,
158
+ cachedInputTokens: 8,
159
+ cacheWriteTokens: 5,
160
+ reasoningOutputTokens: undefined,
161
+ },
162
+ { subagentId: "root", turn: 1, at: "2026-01-01T00:00:00.000Z" },
163
+ );
164
+
165
+ expect(log.input).toBe(0);
166
+ });
167
+
168
+ it("skips a torn or corrupt jsonl line instead of throwing", async () => {
169
+ await withTempDir(async dir => {
170
+ const valid = taskTokenLogFromChat(
171
+ { inputTokens: 1, outputTokens: 2, cachedInputTokens: 3, cacheWriteTokens: 4 },
172
+ { subagentId: "root", turn: 1, at: "2026-01-01T00:00:00.000Z" },
173
+ );
174
+ await persistTaskTokenLog(valid, { dir });
175
+ // A partially-flushed / corrupt trailing line must not abort the read.
176
+ await appendFile(join(dir, "token-log.jsonl"), '{"subagentId":"root","turn":2,', "utf-8");
177
+
178
+ expect(await readTaskTokenLogs(dir)).toEqual([valid]);
179
+ });
180
+ });
181
+
182
+ it("throws when a non-empty log has no valid entries", async () => {
183
+ await withTempDir(async dir => {
184
+ await appendFile(join(dir, "token-log.jsonl"), "not json\n{}\n", "utf-8");
185
+ await expect(readTaskTokenLogs(dir)).rejects.toThrow(/corrupt token-log/);
186
+ });
187
+ });
188
+ });