@gajae-code/coding-agent 0.4.4 → 0.5.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 (132) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/types/cli/fast-help.d.ts +1 -0
  3. package/dist/types/cli/setup-cli.d.ts +2 -0
  4. package/dist/types/commands/harness.d.ts +6 -0
  5. package/dist/types/commands/setup.d.ts +6 -0
  6. package/dist/types/config/model-profile-activation.d.ts +11 -2
  7. package/dist/types/config/model-profiles.d.ts +7 -0
  8. package/dist/types/config/model-registry.d.ts +6 -0
  9. package/dist/types/config/model-resolver.d.ts +2 -0
  10. package/dist/types/config/models-config-schema.d.ts +35 -0
  11. package/dist/types/config/settings-schema.d.ts +4 -3
  12. package/dist/types/coordinator/contract.d.ts +1 -1
  13. package/dist/types/coordinator-mcp/server.d.ts +8 -2
  14. package/dist/types/gjc-runtime/team-runtime.d.ts +0 -1
  15. package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
  16. package/dist/types/harness-control-plane/finalize.d.ts +5 -0
  17. package/dist/types/harness-control-plane/owner.d.ts +1 -1
  18. package/dist/types/harness-control-plane/phase-rollup.d.ts +23 -0
  19. package/dist/types/harness-control-plane/receipt-ingest.d.ts +19 -0
  20. package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
  21. package/dist/types/harness-control-plane/receipts.d.ts +46 -0
  22. package/dist/types/harness-control-plane/rpc-adapter.d.ts +3 -0
  23. package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
  24. package/dist/types/harness-control-plane/types.d.ts +13 -1
  25. package/dist/types/hindsight/mental-models.d.ts +5 -5
  26. package/dist/types/main.d.ts +2 -2
  27. package/dist/types/modes/components/model-selector.d.ts +1 -12
  28. package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
  29. package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
  30. package/dist/types/modes/utils/abort-message.d.ts +4 -0
  31. package/dist/types/sdk.d.ts +5 -0
  32. package/dist/types/session/agent-session.d.ts +2 -0
  33. package/dist/types/session/blob-store.d.ts +20 -1
  34. package/dist/types/session/session-manager.d.ts +32 -6
  35. package/dist/types/session/streaming-output.d.ts +3 -2
  36. package/dist/types/session/tool-choice-queue.d.ts +6 -0
  37. package/dist/types/setup/hermes-setup.d.ts +7 -0
  38. package/dist/types/task/fork-context-advisory.d.ts +13 -0
  39. package/dist/types/task/receipt.d.ts +2 -0
  40. package/dist/types/task/roi-reconciliation.d.ts +27 -0
  41. package/dist/types/task/types.d.ts +17 -0
  42. package/dist/types/thinking-metadata.d.ts +16 -0
  43. package/dist/types/thinking.d.ts +3 -12
  44. package/dist/types/tools/index.d.ts +2 -0
  45. package/dist/types/tools/resolve.d.ts +0 -10
  46. package/dist/types/utils/tool-choice.d.ts +14 -1
  47. package/package.json +8 -7
  48. package/scripts/build-binary.ts +4 -0
  49. package/src/cli/fast-help.ts +80 -0
  50. package/src/cli/setup-cli.ts +12 -3
  51. package/src/cli.ts +112 -17
  52. package/src/commands/coordinator.ts +44 -1
  53. package/src/commands/harness.ts +128 -11
  54. package/src/commands/launch.ts +2 -2
  55. package/src/commands/mcp-serve.ts +3 -2
  56. package/src/commands/session.ts +3 -1
  57. package/src/commands/setup.ts +4 -0
  58. package/src/config/model-profile-activation.ts +15 -3
  59. package/src/config/model-profiles.ts +255 -56
  60. package/src/config/model-resolver.ts +9 -6
  61. package/src/config/models-config-schema.ts +2 -0
  62. package/src/config/settings-schema.ts +6 -3
  63. package/src/coordinator/contract.ts +1 -0
  64. package/src/coordinator-mcp/server.ts +427 -193
  65. package/src/cursor.ts +46 -4
  66. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  67. package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
  68. package/src/export/html/index.ts +13 -9
  69. package/src/gjc-runtime/launch-worktree.ts +12 -1
  70. package/src/gjc-runtime/session-state-sidecar.ts +38 -0
  71. package/src/gjc-runtime/team-runtime.ts +33 -7
  72. package/src/gjc-runtime/tmux-common.ts +15 -0
  73. package/src/gjc-runtime/tmux-sessions.ts +19 -11
  74. package/src/gjc-runtime/ultragoal-runtime.ts +505 -41
  75. package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
  76. package/src/gjc-runtime/workflow-manifest.ts +16 -1
  77. package/src/harness-control-plane/finalize.ts +39 -5
  78. package/src/harness-control-plane/owner.ts +87 -28
  79. package/src/harness-control-plane/phase-rollup.ts +96 -0
  80. package/src/harness-control-plane/receipt-ingest.ts +127 -0
  81. package/src/harness-control-plane/receipt-spool.ts +128 -0
  82. package/src/harness-control-plane/receipts.ts +229 -1
  83. package/src/harness-control-plane/rpc-adapter.ts +8 -0
  84. package/src/harness-control-plane/state-machine.ts +27 -6
  85. package/src/harness-control-plane/storage.ts +23 -0
  86. package/src/harness-control-plane/types.ts +33 -1
  87. package/src/hindsight/mental-models.ts +17 -16
  88. package/src/internal-urls/docs-index.generated.ts +8 -7
  89. package/src/main.ts +7 -3
  90. package/src/modes/components/assistant-message.ts +26 -14
  91. package/src/modes/components/diff.ts +97 -0
  92. package/src/modes/components/model-selector.ts +353 -181
  93. package/src/modes/components/status-line.ts +6 -6
  94. package/src/modes/components/tool-execution.ts +30 -13
  95. package/src/modes/controllers/event-controller.ts +5 -4
  96. package/src/modes/controllers/selector-controller.ts +33 -42
  97. package/src/modes/interactive-mode.ts +4 -5
  98. package/src/modes/print-mode.ts +1 -1
  99. package/src/modes/rpc/rpc-client.ts +3 -2
  100. package/src/modes/rpc/rpc-mode.ts +44 -14
  101. package/src/modes/rpc/rpc-types.ts +5 -2
  102. package/src/modes/shared/agent-wire/command-dispatch.ts +10 -5
  103. package/src/modes/shared/agent-wire/command-validation.ts +11 -0
  104. package/src/modes/theme/theme.ts +2 -2
  105. package/src/modes/utils/abort-message.ts +41 -0
  106. package/src/modes/utils/context-usage.ts +15 -8
  107. package/src/modes/utils/ui-helpers.ts +5 -6
  108. package/src/sdk.ts +38 -6
  109. package/src/secrets/obfuscator.ts +102 -27
  110. package/src/session/agent-session.ts +121 -25
  111. package/src/session/blob-store.ts +89 -3
  112. package/src/session/session-manager.ts +328 -57
  113. package/src/session/streaming-output.ts +185 -122
  114. package/src/session/tool-choice-queue.ts +23 -0
  115. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -2
  116. package/src/setup/hermes-setup.ts +63 -8
  117. package/src/task/executor.ts +69 -6
  118. package/src/task/fork-context-advisory.ts +99 -0
  119. package/src/task/index.ts +31 -2
  120. package/src/task/receipt.ts +7 -0
  121. package/src/task/render.ts +21 -1
  122. package/src/task/roi-reconciliation.ts +90 -0
  123. package/src/task/types.ts +15 -0
  124. package/src/thinking-metadata.ts +51 -0
  125. package/src/thinking.ts +26 -46
  126. package/src/tools/bash.ts +1 -1
  127. package/src/tools/index.ts +4 -2
  128. package/src/tools/resolve.ts +93 -18
  129. package/src/tools/subagent-render.ts +10 -1
  130. package/src/utils/edit-mode.ts +1 -1
  131. package/src/utils/title-generator.ts +16 -2
  132. package/src/utils/tool-choice.ts +45 -16
@@ -13,7 +13,7 @@ import { type JsonSchemaValidationIssue, validateJsonSchemaValue } from "@gajae-
13
13
  import { logger, prompt, untilAborted } from "@gajae-code/utils";
14
14
  import { AsyncJobManager } from "../async";
15
15
  import { ModelRegistry } from "../config/model-registry";
16
- import { resolveModelOverrideWithAuthFallback } from "../config/model-resolver";
16
+ import { formatModelString, resolveModelOverrideWithAuthFallback } from "../config/model-resolver";
17
17
  import type { PromptTemplate } from "../config/prompt-templates";
18
18
  import { Settings } from "../config/settings";
19
19
  import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
@@ -38,7 +38,7 @@ import { jtdToJsonSchema, normalizeSchema } from "../tools/jtd-to-json-schema";
38
38
  import { type ReportFindingDetails, toReviewFinding } from "../tools/review";
39
39
  import { ToolAbortError } from "../tools/tool-errors";
40
40
  import type { EventBus } from "../utils/event-bus";
41
- import { buildNamedToolChoice } from "../utils/tool-choice";
41
+ import { buildNamedToolChoiceResult } from "../utils/tool-choice";
42
42
  import type { WorkspaceTree } from "../workspace-tree";
43
43
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
44
44
  import {
@@ -46,6 +46,7 @@ import {
46
46
  type AgentProgress,
47
47
  MAX_OUTPUT_BYTES,
48
48
  MAX_OUTPUT_LINES,
49
+ type ModelSubstitutionWarning,
49
50
  type ReviewFinding,
50
51
  type SingleResult,
51
52
  TASK_SUBAGENT_EVENT_CHANNEL,
@@ -627,6 +628,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
627
628
  let yieldCalled = false;
628
629
  let pauseRequested = false;
629
630
  let paused = false;
631
+ let modelSubstitutionWarning: ModelSubstitutionWarning | undefined;
632
+ let resolvedModelString: string | undefined;
633
+ let lastAssistantModelString: string | undefined;
634
+ let effectiveThinkingLevelForWarning: ThinkingLevel | undefined;
630
635
 
631
636
  // Accumulate usage incrementally from message_end events (no memory for streaming events)
632
637
  const accumulatedUsage = {
@@ -762,6 +767,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
762
767
  return undefined;
763
768
  };
764
769
 
770
+ const getMessageModelString = (message: unknown): string | undefined => {
771
+ if (!message || typeof message !== "object") return undefined;
772
+ const record = message as { provider?: unknown; model?: unknown };
773
+ return typeof record.provider === "string" && typeof record.model === "string"
774
+ ? `${record.provider}/${record.model}`
775
+ : undefined;
776
+ };
777
+
765
778
  const updateRecentOutputLines = () => {
766
779
  const lines = recentOutputTail.split("\n").filter(line => line.trim());
767
780
  progress.recentOutput = lines.slice(-8).reverse();
@@ -964,6 +977,29 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
964
977
  }
965
978
  }
966
979
  }
980
+ const assistantModel = getMessageModelString(event.message);
981
+ if (assistantModel) {
982
+ lastAssistantModelString = assistantModel;
983
+ if (resolvedModelString && assistantModel !== resolvedModelString && !modelSubstitutionWarning) {
984
+ modelSubstitutionWarning = {
985
+ requested: resolvedModelString,
986
+ effective: assistantModel,
987
+ reason: "assistant_model_mismatch",
988
+ };
989
+ progress.modelSubstitutionWarning = modelSubstitutionWarning;
990
+ activeSession?.sessionManager.appendModelChange(assistantModel, undefined, {
991
+ previousModel: resolvedModelString,
992
+ reason: modelSubstitutionWarning.reason,
993
+ thinkingLevel: effectiveThinkingLevelForWarning ?? null,
994
+ });
995
+ logger.warn("Subagent assistant response reported a substituted effective model", {
996
+ requested: resolvedModelString,
997
+ effective: assistantModel,
998
+ agent: agent.name,
999
+ id,
1000
+ });
1001
+ }
1002
+ }
967
1003
  }
968
1004
  // Extract and accumulate usage (prefer message.usage, fallback to event.usage)
969
1005
  const messageUsage = getMessageUsage(event.message) || (event as AgentEvent & { usage?: unknown }).usage;
@@ -1090,6 +1126,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1090
1126
  thinkingLevel: resolvedThinkingLevel,
1091
1127
  explicitThinkingLevel,
1092
1128
  authFallbackUsed,
1129
+ requestedModel,
1130
+ fallbackReason,
1093
1131
  } = await awaitAbortable(
1094
1132
  resolveModelOverrideWithAuthFallback(
1095
1133
  modelPatterns,
@@ -1099,9 +1137,18 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1099
1137
  options.parentSessionId,
1100
1138
  ),
1101
1139
  );
1102
- if (authFallbackUsed && model) {
1140
+ if (model) {
1141
+ resolvedModelString = formatModelString(model);
1142
+ }
1143
+ if (authFallbackUsed && model && requestedModel) {
1144
+ modelSubstitutionWarning = {
1145
+ requested: formatModelString(requestedModel),
1146
+ effective: formatModelString(model),
1147
+ reason: fallbackReason ?? "auth_unavailable",
1148
+ };
1149
+ progress.modelSubstitutionWarning = modelSubstitutionWarning;
1103
1150
  logger.warn("Subagent model has no working credentials; falling back to parent session model", {
1104
- requested: modelPatterns,
1151
+ requested: modelSubstitutionWarning.requested,
1105
1152
  parentModel: options.parentActiveModelPattern,
1106
1153
  resolvedProvider: model.provider,
1107
1154
  resolvedModel: model.id,
@@ -1113,6 +1160,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1113
1160
  const effectiveThinkingLevel = explicitThinkingLevel
1114
1161
  ? resolvedThinkingLevel
1115
1162
  : (thinkingLevel ?? resolvedThinkingLevel);
1163
+ effectiveThinkingLevelForWarning = effectiveThinkingLevel;
1116
1164
 
1117
1165
  const sessionManager = sessionFile
1118
1166
  ? await awaitAbortable(SessionManager.open(sessionFile))
@@ -1174,6 +1222,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1174
1222
  settings: subagentSettings,
1175
1223
  model,
1176
1224
  thinkingLevel: effectiveThinkingLevel,
1225
+ modelSubstitution:
1226
+ modelSubstitutionWarning?.reason === "auth_unavailable" && requestedModel
1227
+ ? { requestedModel, reason: modelSubstitutionWarning.reason }
1228
+ : undefined,
1177
1229
  toolNames,
1178
1230
  outputSchema,
1179
1231
  requireYieldTool: true,
@@ -1412,7 +1464,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1412
1464
  await awaitAbortable(session.waitForIdle());
1413
1465
  }
1414
1466
 
1415
- const reminderToolChoice = buildNamedToolChoice("yield", session.model);
1467
+ const reminderToolChoiceResult = buildNamedToolChoiceResult("yield", session.model);
1416
1468
 
1417
1469
  let retryCount = 0;
1418
1470
  while (!paused && !yieldCalled && retryCount < MAX_YIELD_RETRIES && !abortSignal.aborted) {
@@ -1433,7 +1485,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1433
1485
  await awaitAbortable(
1434
1486
  session.prompt(reminder, {
1435
1487
  attribution: "agent",
1436
- ...(isFinalRetry && reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
1488
+ ...(isFinalRetry && reminderToolChoiceResult.exactNamed && reminderToolChoiceResult.choice
1489
+ ? { toolChoice: reminderToolChoiceResult.choice }
1490
+ : {}),
1437
1491
  }),
1438
1492
  );
1439
1493
  await awaitAbortable(session.waitForIdle());
@@ -1466,6 +1520,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1466
1520
  error = undefined;
1467
1521
  }
1468
1522
  }
1523
+ if (lastAssistantModelString && resolvedModelString && lastAssistantModelString !== resolvedModelString) {
1524
+ modelSubstitutionWarning ??= {
1525
+ requested: resolvedModelString,
1526
+ effective: lastAssistantModelString,
1527
+ reason: "assistant_model_mismatch",
1528
+ };
1529
+ progress.modelSubstitutionWarning = modelSubstitutionWarning;
1530
+ }
1469
1531
  } catch (err) {
1470
1532
  exitCode = 1;
1471
1533
  if (!abortSignal.aborted) {
@@ -1642,6 +1704,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1642
1704
  contextTokens: progress.contextTokens,
1643
1705
  contextWindow: progress.contextWindow,
1644
1706
  modelOverride,
1707
+ modelSubstitutionWarning,
1645
1708
  error: exitCode !== 0 && stderr ? stderr : undefined,
1646
1709
  aborted: wasAborted,
1647
1710
  abortReason: finalAbortReason,
@@ -0,0 +1,99 @@
1
+ import type { ForkContextMode } from "./types";
2
+
3
+ export interface ForkContextAdvisory {
4
+ recommendedMode: ForkContextMode;
5
+ reasons: string[];
6
+ estimatedClonedTokens: Record<ForkContextMode, number>;
7
+ callerModeRespected: true;
8
+ }
9
+
10
+ /**
11
+ * Per-mode clone budget ceilings (advisory estimates only). These bound how
12
+ * many parent-context tokens each mode may clone into the child; the actual
13
+ * cloned amount can never exceed the parent context itself.
14
+ */
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>;
22
+
23
+ const RECEIPT_TRIGGERS = [
24
+ { pattern: /as discussed/i, reason: "prior-session-reference:as-discussed" },
25
+ { pattern: /as decided/i, reason: "prior-session-reference:as-decided" },
26
+ { pattern: /earlier in this session/i, reason: "prior-session-reference:earlier-in-this-session" },
27
+ { pattern: /per the plan above/i, reason: "prior-session-reference:per-the-plan-above" },
28
+ { pattern: /the previous review/i, reason: "prior-session-reference:the-previous-review" },
29
+ { pattern: /\.gjc\/plans\//i, reason: "prior-session-reference:gjc-plans-path" },
30
+ { pattern: /\.gjc\/specs\//i, reason: "prior-session-reference:gjc-specs-path" },
31
+ ] as const;
32
+
33
+ const LAST_TURN_TRIGGERS = [
34
+ { pattern: /the last message/i, reason: "last-turn-reference:the-last-message" },
35
+ { pattern: /the previous turn/i, reason: "last-turn-reference:the-previous-turn" },
36
+ { pattern: /see above/i, reason: "last-turn-reference:see-above" },
37
+ ] as const;
38
+
39
+ /**
40
+ * Estimated tokens cloned into the child per mode: the per-mode budget
41
+ * ceiling, capped by the actual parent context (you can never clone more
42
+ * than exists). Negative parent contexts are normalized to 0.
43
+ */
44
+ function estimateClonedTokens(parentContextTokens: number): Record<ForkContextMode, number> {
45
+ const parent = Math.max(0, parentContextTokens);
46
+ return {
47
+ none: Math.min(parent, CLONE_BUDGET_BY_MODE.none),
48
+ receipt: Math.min(parent, CLONE_BUDGET_BY_MODE.receipt),
49
+ "last-turn": Math.min(parent, CLONE_BUDGET_BY_MODE["last-turn"]),
50
+ bounded: Math.min(parent, CLONE_BUDGET_BY_MODE.bounded),
51
+ full: Math.min(parent, CLONE_BUDGET_BY_MODE.full),
52
+ };
53
+ }
54
+
55
+ export function adviseForkContextMode(input: {
56
+ assignment: string;
57
+ context?: string;
58
+ explicitMode?: ForkContextMode;
59
+ parentContextTokens?: number;
60
+ }): ForkContextAdvisory {
61
+ const parentContextTokens = input.parentContextTokens ?? 0;
62
+ const estimatedClonedTokens = estimateClonedTokens(parentContextTokens);
63
+
64
+ if (input.explicitMode !== undefined) {
65
+ return {
66
+ recommendedMode: input.explicitMode,
67
+ reasons: ["explicit-caller-mode"],
68
+ estimatedClonedTokens,
69
+ callerModeRespected: true,
70
+ };
71
+ }
72
+
73
+ const text = `${input.assignment}\n${input.context ?? ""}`;
74
+ const reasons: string[] = [];
75
+ let recommendedMode: ForkContextMode = "none";
76
+
77
+ for (const trigger of LAST_TURN_TRIGGERS) {
78
+ if (trigger.pattern.test(text)) {
79
+ reasons.push(trigger.reason);
80
+ recommendedMode = "last-turn";
81
+ }
82
+ }
83
+
84
+ for (const trigger of RECEIPT_TRIGGERS) {
85
+ if (trigger.pattern.test(text)) {
86
+ reasons.push(trigger.reason);
87
+ if (recommendedMode === "none") {
88
+ recommendedMode = "receipt";
89
+ }
90
+ }
91
+ }
92
+
93
+ return {
94
+ recommendedMode,
95
+ reasons,
96
+ estimatedClonedTokens,
97
+ callerModeRespected: true,
98
+ };
99
+ }
package/src/task/index.ts CHANGED
@@ -47,11 +47,13 @@ import { generateCommitMessage } from "../utils/commit-message-generator";
47
47
  import * as git from "../utils/git";
48
48
  import { discoverAgents, filterVisibleAgents, getAgent } from "./discovery";
49
49
  import { runSubprocess } from "./executor";
50
+ import { adviseForkContextMode } from "./fork-context-advisory";
50
51
  import { getTaskIdValidationError, validateAllocatedTaskId } from "./id";
51
52
  import { AgentOutputManager } from "./output-manager";
52
53
  import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
53
54
  import { assertNoRawTaskFields, buildTaskReceipt, buildTaskRoiSummary } from "./receipt";
54
55
  import { renderResult, renderCall as renderTaskCall } from "./render";
56
+ import { reconcileSpawnRoi } from "./roi-reconciliation";
55
57
  import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
56
58
  import { DEFAULT_SPAWN_THRESHOLD, evaluateReviewerExploreGate, evaluateSpawnGate } from "./spawn-gate";
57
59
  import {
@@ -578,6 +580,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
578
580
  runMode: message ? "message" : "resume",
579
581
  resumeMessage: message,
580
582
  sessionFiles: new Map([[descriptor.task.id, descriptor.sessionFile]]),
583
+ suppressRoiReconciliation: true,
581
584
  },
582
585
  );
583
586
  const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)";
@@ -675,6 +678,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
675
678
  frozenForkSeeds,
676
679
  {
677
680
  sessionFiles: new Map([[uniqueId, subtaskSessionFile]]),
681
+ suppressRoiReconciliation: true,
678
682
  },
679
683
  );
680
684
  const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)";
@@ -874,6 +878,12 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
874
878
  runMode?: "initial" | "resume" | "message";
875
879
  resumeMessage?: string;
876
880
  sessionFiles?: ReadonlyMap<string, string | null>;
881
+ /**
882
+ * Set for per-child async runs: the spawnPlan is carried for gate
883
+ * consistency, but batch-level ROI reconciliation must not be computed
884
+ * against a single child's receipts.
885
+ */
886
+ suppressRoiReconciliation?: boolean;
877
887
  },
878
888
  ): Promise<AgentToolResult<TaskToolDetails>> {
879
889
  const startTime = Date.now();
@@ -1293,6 +1303,17 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1293
1303
  const forkContext = requestsForkContext(task)
1294
1304
  ? { mode: task.inheritContext, clonedTokens: forkContextSeed?.metadata.approximateTokens ?? 0 }
1295
1305
  : undefined;
1306
+ // Advisory-only recommendation (logged on the receipt); never overrides
1307
+ // the caller's explicit inheritContext mode.
1308
+ const advisory = adviseForkContextMode({
1309
+ assignment: task.assignment,
1310
+ context: sharedContext,
1311
+ explicitMode: task.inheritContext,
1312
+ });
1313
+ const forkContextAdvisory = {
1314
+ recommendedMode: advisory.recommendedMode,
1315
+ reasons: advisory.reasons,
1316
+ };
1296
1317
  const taskSessionFile = overrides?.sessionFile ?? executionOverrides?.sessionFiles?.get(task.id) ?? null;
1297
1318
  if (!isIsolated) {
1298
1319
  const result = await runSubprocess({
@@ -1341,7 +1362,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1341
1362
  parentTelemetry: this.session.getTelemetry?.(),
1342
1363
  forkContextSeed,
1343
1364
  });
1344
- return forkContext ? { ...result, forkContext } : result;
1365
+ return { ...result, ...(forkContext ? { forkContext } : {}), forkContextAdvisory };
1345
1366
  }
1346
1367
 
1347
1368
  const taskStart = Date.now();
@@ -1402,7 +1423,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1402
1423
  parentTelemetry: this.session.getTelemetry?.(),
1403
1424
  forkContextSeed,
1404
1425
  });
1405
- const resultWithForkContext = forkContext ? { ...result, forkContext } : result;
1426
+ const resultWithForkContext = {
1427
+ ...result,
1428
+ ...(forkContext ? { forkContext } : {}),
1429
+ forkContextAdvisory,
1430
+ };
1406
1431
  if (mergeMode === "branch" && resultWithForkContext.exitCode === 0) {
1407
1432
  try {
1408
1433
  const commitMsg =
@@ -1697,6 +1722,9 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1697
1722
 
1698
1723
  const receipts = results.map(buildTaskReceipt);
1699
1724
  const roiSummary = buildTaskRoiSummary(receipts);
1725
+ const roiReconciliation = executionOverrides?.suppressRoiReconciliation
1726
+ ? undefined
1727
+ : reconcileSpawnRoi(params.spawnPlan, receipts);
1700
1728
  const summaries = receipts.map(r => {
1701
1729
  const status = r.status === "merge_failed" ? "merge failed" : r.status;
1702
1730
  return {
@@ -1739,6 +1767,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1739
1767
  usage: hasAggregatedUsage ? aggregatedUsage : undefined,
1740
1768
  forkContextClonedTokens: forkContextClonedTokens > 0 ? forkContextClonedTokens : undefined,
1741
1769
  roiSummary,
1770
+ roiReconciliation,
1742
1771
  };
1743
1772
  assertNoRawTaskFields(details, "task.return.details");
1744
1773
  return {
@@ -29,6 +29,7 @@ export interface TaskResultReceipt {
29
29
  contextTokens?: number;
30
30
  contextWindow?: number;
31
31
  modelOverride?: string | string[];
32
+ modelSubstitutionWarning?: SingleResult["modelSubstitutionWarning"];
32
33
  usage?: SingleResult["usage"];
33
34
  cost?: number;
34
35
  branchName?: string;
@@ -46,6 +47,7 @@ export interface TaskResultReceipt {
46
47
  };
47
48
  extractedToolCounts?: Record<string, number>;
48
49
  forkContext?: SingleResult["forkContext"];
50
+ forkContextAdvisory?: SingleResult["forkContextAdvisory"];
49
51
  roi?: TaskRoi;
50
52
  }
51
53
 
@@ -77,6 +79,9 @@ function truncateText(value: string | undefined, maxChars: number): string | und
77
79
 
78
80
  function buildSafeSynopsis(raw: SingleResult, outputRef: TaskResultReceipt["outputRef"]): string {
79
81
  const status = getStatus(raw);
82
+ if (raw.modelSubstitutionWarning) {
83
+ return `Task ${status}; requested model substituted from ${raw.modelSubstitutionWarning.requested} to ${raw.modelSubstitutionWarning.effective}.`;
84
+ }
80
85
  if (raw.retryFailure) {
81
86
  return `Task ${status}; retry stopped after attempt ${raw.retryFailure.attempt}.`;
82
87
  }
@@ -219,6 +224,7 @@ export function buildTaskReceipt(raw: SingleResult): TaskResultReceipt {
219
224
  contextTokens: raw.contextTokens,
220
225
  contextWindow: raw.contextWindow,
221
226
  modelOverride: raw.modelOverride,
227
+ modelSubstitutionWarning: raw.modelSubstitutionWarning,
222
228
  usage: raw.usage,
223
229
  cost: raw.usage?.cost.total,
224
230
  branchName: raw.branchName,
@@ -234,6 +240,7 @@ export function buildTaskReceipt(raw: SingleResult): TaskResultReceipt {
234
240
  review: buildReview(raw),
235
241
  extractedToolCounts,
236
242
  forkContext: raw.forkContext,
243
+ forkContextAdvisory: raw.forkContextAdvisory,
237
244
  roi: buildTaskRoi(raw),
238
245
  };
239
246
  }
@@ -119,6 +119,10 @@ function normalizeReportFindings(value: unknown): ReportFindingDetails[] {
119
119
  return findings;
120
120
  }
121
121
 
122
+ function formatModelSubstitutionWarning(warning: { requested: string; effective: string }): string {
123
+ return `Requested model substituted: ${warning.requested} -> ${warning.effective}`;
124
+ }
125
+
122
126
  function formatJsonScalar(value: unknown, _theme: Theme): string {
123
127
  if (value === null) return "null";
124
128
  if (typeof value === "string") {
@@ -566,6 +570,14 @@ function renderAgentProgress(
566
570
  lines.push(statusLine);
567
571
 
568
572
  lines.push(...renderTaskSection(progress.assignment ?? progress.task, continuePrefix, expanded, theme));
573
+ if (progress.modelSubstitutionWarning) {
574
+ lines.push(
575
+ `${continuePrefix}${theme.fg(
576
+ "warning",
577
+ truncateToWidth(replaceTabs(formatModelSubstitutionWarning(progress.modelSubstitutionWarning)), 90),
578
+ )}`,
579
+ );
580
+ }
569
581
 
570
582
  // Current tool (if running) or most recent completed tool
571
583
  if (progress.status === "running") {
@@ -862,9 +874,17 @@ function renderAgentResult(result: TaskResultReceipt, isLast: boolean, expanded:
862
874
  }
863
875
  }
864
876
  }
865
- } else {
877
+ } else if (!result.modelSubstitutionWarning) {
866
878
  lines.push(...renderOutputSection(result.preview, continuePrefix, expanded, theme, 3, 12));
867
879
  }
880
+ if (result.modelSubstitutionWarning) {
881
+ lines.push(
882
+ `${continuePrefix}${theme.fg(
883
+ "warning",
884
+ truncateToWidth(replaceTabs(formatModelSubstitutionWarning(result.modelSubstitutionWarning)), 90),
885
+ )}`,
886
+ );
887
+ }
868
888
  if (result.roi?.lowRoi) {
869
889
  lines.push(`${continuePrefix}${theme.fg("warning", "low ROI: produced no material contribution")}`);
870
890
  }
@@ -0,0 +1,90 @@
1
+ import type { TaskResultReceipt } from "./receipt";
2
+ import type { SpawnPlanReceipt } from "./spawn-gate";
3
+
4
+ /**
5
+ * Pure, advisory-only reconciliation between a spawn plan's inline-token promise
6
+ * and receipt-safe child outputs. These signals never change task success/failure
7
+ * semantics or runtime behavior; they only describe budget/ROI observations for
8
+ * model-facing summaries.
9
+ */
10
+ export interface SpawnRoiChildReconciliation {
11
+ id: string;
12
+ inlineTokens: number;
13
+ maxInlineTokens: number;
14
+ overBudget: boolean;
15
+ overageTokens: number;
16
+ lowRoi: boolean;
17
+ }
18
+
19
+ export interface SpawnRoiReconciliation {
20
+ childCount: number;
21
+ promisedMaxInlineTokens: number;
22
+ children: SpawnRoiChildReconciliation[];
23
+ overBudgetChildIds: string[];
24
+ lowRoiChildIds: string[];
25
+ totalInlineTokens: number;
26
+ totalOverageTokens: number;
27
+ advisoryFlags: string[];
28
+ }
29
+
30
+ function estimateTextTokens(text: string | undefined): number {
31
+ if (!text) return 0;
32
+ return Math.ceil(text.length / 4);
33
+ }
34
+
35
+ /**
36
+ * Estimate model-facing inline cost from receipt-safe fields only. The proxy uses
37
+ * one token per four characters, rounded up independently for the preview and
38
+ * review summaries that may be inlined for the parent model.
39
+ */
40
+ function estimateInlineTokens(receipt: TaskResultReceipt): number {
41
+ const review = receipt.review;
42
+ const reviewSummaryChars =
43
+ (review?.overallCorrectness?.length ?? 0) +
44
+ (review?.findings?.reduce((total, finding) => total + finding.summary.length, 0) ?? 0);
45
+ return estimateTextTokens(receipt.preview) + Math.ceil(reviewSummaryChars / 4);
46
+ }
47
+
48
+ export function reconcileSpawnRoi(
49
+ plan: SpawnPlanReceipt | undefined,
50
+ receipts: readonly TaskResultReceipt[],
51
+ ): SpawnRoiReconciliation | undefined {
52
+ if (!plan) return undefined;
53
+
54
+ const children = receipts.map(receipt => {
55
+ const inlineTokens = estimateInlineTokens(receipt);
56
+ const overageTokens = Math.max(0, inlineTokens - plan.maxInlineTokens);
57
+ return {
58
+ id: receipt.id,
59
+ inlineTokens,
60
+ maxInlineTokens: plan.maxInlineTokens,
61
+ overBudget: overageTokens > 0,
62
+ overageTokens,
63
+ lowRoi: Boolean(receipt.roi?.lowRoi),
64
+ };
65
+ });
66
+ const overBudgetChildIds = children
67
+ .filter(child => child.overBudget)
68
+ .map(child => child.id)
69
+ .toSorted();
70
+ const lowRoiChildIds = children
71
+ .filter(child => child.lowRoi)
72
+ .map(child => child.id)
73
+ .toSorted();
74
+ const advisoryFlags = [
75
+ ...(overBudgetChildIds.length > 0 ? ["over-inline-budget"] : []),
76
+ ...(lowRoiChildIds.length > 0 ? ["low-roi-children"] : []),
77
+ ...(children.length > 0 && lowRoiChildIds.length === children.length ? ["all-children-low-roi"] : []),
78
+ ];
79
+
80
+ return {
81
+ childCount: children.length,
82
+ promisedMaxInlineTokens: plan.maxInlineTokens,
83
+ children,
84
+ overBudgetChildIds,
85
+ lowRoiChildIds,
86
+ totalInlineTokens: children.reduce((total, child) => total + child.inlineTokens, 0),
87
+ totalOverageTokens: children.reduce((total, child) => total + child.overageTokens, 0),
88
+ advisoryFlags,
89
+ };
90
+ }
package/src/task/types.ts CHANGED
@@ -4,6 +4,7 @@ import { $env } from "@gajae-code/utils";
4
4
  import * as z from "zod/v4";
5
5
  import { isValidTaskId, TASK_ID_DESCRIPTION } from "./id";
6
6
  import type { TaskResultReceipt } from "./receipt";
7
+ import type { SpawnRoiReconciliation } from "./roi-reconciliation";
7
8
  import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
8
9
  import type { SpawnPlanReceipt } from "./spawn-gate";
9
10
  import type { NestedRepoPatch } from "./worktree";
@@ -214,6 +215,12 @@ export interface AgentDefinition {
214
215
  filePath?: string;
215
216
  }
216
217
 
218
+ export interface ModelSubstitutionWarning {
219
+ requested: string;
220
+ effective: string;
221
+ reason: "auth_unavailable" | "assistant_model_mismatch";
222
+ }
223
+
217
224
  /** Progress tracking for a single agent */
218
225
  export interface AgentProgress {
219
226
  index: number;
@@ -246,6 +253,7 @@ export interface AgentProgress {
246
253
  cost: number;
247
254
  durationMs: number;
248
255
  modelOverride?: string | string[];
256
+ modelSubstitutionWarning?: ModelSubstitutionWarning;
249
257
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
250
258
  extractedToolData?: Record<string, unknown[]>;
251
259
  /**
@@ -305,6 +313,7 @@ export interface SingleResult {
305
313
  /** Model's context window in tokens, when known. */
306
314
  contextWindow?: number;
307
315
  modelOverride?: string | string[];
316
+ modelSubstitutionWarning?: ModelSubstitutionWarning;
308
317
  error?: string;
309
318
  aborted?: boolean;
310
319
  abortReason?: string;
@@ -337,6 +346,11 @@ export interface SingleResult {
337
346
  outputMeta?: { lineCount: number; charCount: number; byteSize?: number; sha256?: string };
338
347
  /** Fork-context seed accounting for this subagent, when inherited parent context was cloned. */
339
348
  forkContext?: { mode: ForkContextMode; clonedTokens: number };
349
+ /**
350
+ * Advisory fork-context mode recommendation for this task (logged only;
351
+ * never changes the actual mode selection).
352
+ */
353
+ forkContextAdvisory?: { recommendedMode: ForkContextMode; reasons: string[] };
340
354
  }
341
355
 
342
356
  /** Tool details for TUI rendering */
@@ -356,6 +370,7 @@ export interface TaskToolDetails {
356
370
  /** Advisory ids for terminal children that spent tokens without detectable output/review/changes. */
357
371
  lowRoiChildIds: string[];
358
372
  };
373
+ roiReconciliation?: SpawnRoiReconciliation;
359
374
  progress?: AgentProgress[];
360
375
  async?: {
361
376
  state: "running" | "paused" | "queued" | "completed" | "failed";
@@ -0,0 +1,51 @@
1
+ export type ThinkingLevelValue = "inherit" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
2
+
3
+ /**
4
+ * Metadata used to render thinking selector values in the coding-agent UI.
5
+ *
6
+ * This module is intentionally provider/native-free so schema generation can
7
+ * import settings metadata before native addons have been built in CI.
8
+ */
9
+ export interface ThinkingLevelMetadata {
10
+ value: ThinkingLevelValue;
11
+ label: string;
12
+ description: string;
13
+ }
14
+
15
+ const THINKING_LEVEL_METADATA: Record<ThinkingLevelValue, ThinkingLevelMetadata> = {
16
+ inherit: {
17
+ value: "inherit",
18
+ label: "inherit",
19
+ description: "Inherit session default",
20
+ },
21
+ off: { value: "off", label: "off", description: "No reasoning" },
22
+ minimal: {
23
+ value: "minimal",
24
+ label: "min",
25
+ description: "Very brief reasoning (~1k tokens)",
26
+ },
27
+ low: { value: "low", label: "low", description: "Light reasoning (~2k tokens)" },
28
+ medium: {
29
+ value: "medium",
30
+ label: "medium",
31
+ description: "Moderate reasoning (~8k tokens)",
32
+ },
33
+ high: { value: "high", label: "high", description: "Deep reasoning (~16k tokens)" },
34
+ xhigh: {
35
+ value: "xhigh",
36
+ label: "xhigh",
37
+ description: "Maximum reasoning (~32k tokens)",
38
+ },
39
+ max: {
40
+ value: "max",
41
+ label: "max",
42
+ description: "Opus maximum reasoning",
43
+ },
44
+ };
45
+
46
+ /**
47
+ * Returns display metadata for a thinking selector.
48
+ */
49
+ export function getThinkingLevelMetadata(level: ThinkingLevelValue): ThinkingLevelMetadata {
50
+ return THINKING_LEVEL_METADATA[level];
51
+ }