@gajae-code/coding-agent 0.5.0 → 0.5.2

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 (194) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +26 -0
  4. package/dist/types/cli/args.d.ts +1 -0
  5. package/dist/types/cli/list-models.d.ts +6 -0
  6. package/dist/types/cli/setup-cli.d.ts +8 -1
  7. package/dist/types/commands/gc.d.ts +26 -0
  8. package/dist/types/commands/setup.d.ts +7 -0
  9. package/dist/types/config/file-lock-gc.d.ts +5 -0
  10. package/dist/types/config/file-lock.d.ts +29 -0
  11. package/dist/types/config/model-registry.d.ts +4 -0
  12. package/dist/types/config/models-config-schema.d.ts +5 -0
  13. package/dist/types/config/settings-schema.d.ts +62 -0
  14. package/dist/types/coordinator/contract.d.ts +1 -1
  15. package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
  16. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
  17. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
  18. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
  19. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
  21. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
  22. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
  23. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
  24. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
  25. package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
  26. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  27. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
  28. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
  29. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
  30. package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
  31. package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
  32. package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
  33. package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
  34. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  35. package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
  36. package/dist/types/gjc-runtime/team-runtime.d.ts +5 -0
  37. package/dist/types/gjc-runtime/tmux-common.d.ts +11 -0
  38. package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
  39. package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
  40. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
  41. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  42. package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
  43. package/dist/types/harness-control-plane/owner.d.ts +7 -0
  44. package/dist/types/harness-control-plane/storage.d.ts +20 -0
  45. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  46. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  47. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  48. package/dist/types/modes/interactive-mode.d.ts +1 -1
  49. package/dist/types/modes/rpc/rpc-mode.d.ts +72 -2
  50. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
  51. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
  52. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
  53. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  54. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  55. package/dist/types/modes/theme/theme.d.ts +1 -0
  56. package/dist/types/modes/types.d.ts +1 -1
  57. package/dist/types/session/agent-session.d.ts +1 -1
  58. package/dist/types/session/blob-store.d.ts +39 -3
  59. package/dist/types/session/history-storage.d.ts +2 -2
  60. package/dist/types/session/session-manager.d.ts +10 -1
  61. package/dist/types/setup/credential-import.d.ts +79 -0
  62. package/dist/types/skill-state/workflow-hud.d.ts +14 -0
  63. package/dist/types/task/executor.d.ts +1 -0
  64. package/dist/types/task/render.d.ts +1 -1
  65. package/dist/types/tools/ask.d.ts +15 -1
  66. package/dist/types/tools/subagent-render.d.ts +7 -1
  67. package/dist/types/tools/subagent.d.ts +27 -0
  68. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  69. package/dist/types/web/search/index.d.ts +4 -4
  70. package/dist/types/web/search/provider.d.ts +16 -20
  71. package/dist/types/web/search/providers/base.d.ts +2 -1
  72. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  73. package/dist/types/web/search/types.d.ts +14 -2
  74. package/package.json +7 -7
  75. package/scripts/build-binary.ts +7 -0
  76. package/src/async/job-manager.ts +52 -0
  77. package/src/cli/args.ts +5 -0
  78. package/src/cli/auth-broker-cli.ts +1 -0
  79. package/src/cli/fast-help.ts +2 -0
  80. package/src/cli/list-models.ts +13 -1
  81. package/src/cli/setup-cli.ts +138 -3
  82. package/src/cli.ts +1 -0
  83. package/src/commands/gc.ts +22 -0
  84. package/src/commands/harness.ts +7 -3
  85. package/src/commands/setup.ts +5 -1
  86. package/src/commands/ultragoal.ts +3 -1
  87. package/src/config/file-lock-gc.ts +193 -0
  88. package/src/config/file-lock.ts +66 -10
  89. package/src/config/model-profile-activation.ts +15 -3
  90. package/src/config/model-profiles.ts +39 -30
  91. package/src/config/model-registry.ts +21 -1
  92. package/src/config/models-config-schema.ts +1 -0
  93. package/src/config/settings-schema.ts +62 -0
  94. package/src/coordinator/contract.ts +1 -0
  95. package/src/coordinator-mcp/server.ts +459 -3
  96. package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
  97. package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
  98. package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
  99. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
  100. package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
  101. package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
  102. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
  103. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
  104. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
  105. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
  106. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
  107. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
  108. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
  109. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
  110. package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
  111. package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
  112. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  113. package/src/defaults/gjc-defaults.ts +7 -0
  114. package/src/defaults/gjc-grok-cli.ts +22 -0
  115. package/src/extensibility/extensions/index.ts +1 -0
  116. package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
  117. package/src/gjc-runtime/deep-interview-recorder.ts +457 -0
  118. package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
  119. package/src/gjc-runtime/deep-interview-state.ts +324 -0
  120. package/src/gjc-runtime/gc-render.ts +70 -0
  121. package/src/gjc-runtime/gc-runtime.ts +403 -0
  122. package/src/gjc-runtime/launch-tmux.ts +3 -4
  123. package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
  124. package/src/gjc-runtime/ralplan-runtime.ts +232 -19
  125. package/src/gjc-runtime/state-renderer.ts +12 -3
  126. package/src/gjc-runtime/state-runtime.ts +48 -30
  127. package/src/gjc-runtime/state-writer.ts +254 -7
  128. package/src/gjc-runtime/team-gc.ts +49 -0
  129. package/src/gjc-runtime/team-runtime.ts +179 -2
  130. package/src/gjc-runtime/tmux-common.ts +14 -0
  131. package/src/gjc-runtime/tmux-gc.ts +177 -0
  132. package/src/gjc-runtime/tmux-sessions.ts +49 -1
  133. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  134. package/src/gjc-runtime/ultragoal-runtime.ts +1239 -31
  135. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  136. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  137. package/src/harness-control-plane/gc-adapter.ts +184 -0
  138. package/src/harness-control-plane/owner.ts +14 -2
  139. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  140. package/src/harness-control-plane/storage.ts +70 -0
  141. package/src/hooks/skill-state.ts +121 -2
  142. package/src/internal-urls/docs-index.generated.ts +22 -12
  143. package/src/lsp/defaults.json +1 -0
  144. package/src/main.ts +18 -3
  145. package/src/modes/acp/acp-agent.ts +4 -2
  146. package/src/modes/bridge/bridge-mode.ts +2 -1
  147. package/src/modes/components/history-search.ts +5 -2
  148. package/src/modes/components/hook-selector.ts +19 -0
  149. package/src/modes/components/model-selector.ts +51 -8
  150. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  151. package/src/modes/components/status-line/segments.ts +1 -1
  152. package/src/modes/controllers/command-controller.ts +25 -6
  153. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  154. package/src/modes/controllers/selector-controller.ts +81 -1
  155. package/src/modes/interactive-mode.ts +11 -1
  156. package/src/modes/rpc/rpc-mode.ts +266 -34
  157. package/src/modes/shared/agent-wire/command-dispatch.ts +281 -261
  158. package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
  159. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  160. package/src/modes/shared/agent-wire/session-registry.ts +109 -0
  161. package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
  162. package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
  163. package/src/modes/shared/agent-wire/unattended-session.ts +32 -2
  164. package/src/modes/theme/defaults/claude-code.json +100 -0
  165. package/src/modes/theme/defaults/codex.json +100 -0
  166. package/src/modes/theme/defaults/index.ts +6 -0
  167. package/src/modes/theme/defaults/opencode.json +102 -0
  168. package/src/modes/theme/theme.ts +2 -2
  169. package/src/modes/types.ts +1 -1
  170. package/src/prompts/agents/executor.md +5 -2
  171. package/src/sdk.ts +29 -4
  172. package/src/session/agent-session.ts +99 -19
  173. package/src/session/blob-store.ts +59 -3
  174. package/src/session/history-storage.ts +32 -11
  175. package/src/session/session-manager.ts +72 -20
  176. package/src/setup/credential-import.ts +429 -0
  177. package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
  178. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  179. package/src/skill-state/workflow-hud.ts +106 -10
  180. package/src/slash-commands/builtin-registry.ts +3 -2
  181. package/src/task/executor.ts +16 -1
  182. package/src/task/render.ts +18 -7
  183. package/src/tools/ask.ts +59 -2
  184. package/src/tools/cron.ts +1 -1
  185. package/src/tools/job.ts +3 -2
  186. package/src/tools/monitor.ts +36 -1
  187. package/src/tools/subagent-render.ts +128 -29
  188. package/src/tools/subagent.ts +173 -9
  189. package/src/tools/ultragoal-ask-guard.ts +39 -0
  190. package/src/web/search/index.ts +25 -25
  191. package/src/web/search/provider.ts +178 -87
  192. package/src/web/search/providers/base.ts +2 -1
  193. package/src/web/search/providers/openai-compatible.ts +151 -0
  194. package/src/web/search/types.ts +47 -22
@@ -12,7 +12,7 @@ import { Text } from "@gajae-code/tui";
12
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import type { Theme } from "../modes/theme/theme";
14
14
  import { renderSubagentLiveProgress } from "../task/render";
15
- import { Ellipsis, Hasher, type RenderCache, renderStatusLine } from "../tui";
15
+ import { Ellipsis, Hasher, renderStatusLine } from "../tui";
16
16
  import {
17
17
  formatDuration,
18
18
  formatStatusIcon,
@@ -21,12 +21,87 @@ import {
21
21
  type ToolUIStatus,
22
22
  truncateToWidth,
23
23
  } from "./render-utils";
24
- import type { SubagentSnapshot, SubagentToolDetails } from "./subagent";
24
+ import { type SubagentSnapshot, type SubagentToolDetails, subagentAwaitRenderedStateSignature } from "./subagent";
25
25
 
26
26
  const PREVIEW_LINES_COLLAPSED = 1;
27
27
  const PREVIEW_LINES_EXPANDED = 4;
28
28
  const PREVIEW_LINE_WIDTH = 80;
29
29
 
30
+ /**
31
+ * Bounded, content-addressed cache for each subagent's heavy body lines (the
32
+ * indented receipt fields + `renderSubagentLiveProgress` -> `renderAgentProgress`
33
+ * output). It is module-level so it survives the built-in renderer recreating the
34
+ * result component on every partial update (`tool-execution.ts` clears the content
35
+ * box and re-invokes `renderResult`), which a per-component `let cached` cannot.
36
+ *
37
+ * The cached body is a PURE function of its key: the per-subagent rendered-state
38
+ * signature (reused from the producer; excludes time-derived churn), expanded
39
+ * state, width, and the actual Theme instance identity. `spinnerFrame` and all
40
+ * wall-clock displays are deliberately kept OUT of the cached body — the animated
41
+ * spinner and the fresh duration live in the cheap per-subagent status line, and
42
+ * `renderSubagentLiveProgress` is invoked with `staticTime` so current-tool elapsed
43
+ * and retry countdowns are never baked into cached lines.
44
+ */
45
+ const SUBAGENT_BODY_CACHE_MAX = 128;
46
+ const subagentBodyCache = new Map<bigint, string[]>();
47
+ let subagentBodyRenderCount = 0;
48
+
49
+ // Stable identity per Theme instance so a theme change (preview, symbol preset,
50
+ // color-blind reload, custom-theme reload, or in-memory swap) never reuses stale
51
+ // ANSI/glyph strings — distinct Theme objects get distinct ids even when the theme
52
+ // name is unchanged (e.g. the "<in-memory>" name).
53
+ const themeIdentity = new WeakMap<Theme, number>();
54
+ let nextThemeId = 1;
55
+ function themeIdentityId(theme: Theme): number {
56
+ let id = themeIdentity.get(theme);
57
+ if (id === undefined) {
58
+ id = nextThemeId++;
59
+ themeIdentity.set(theme, id);
60
+ }
61
+ return id;
62
+ }
63
+
64
+ /** Test-only seam (PR3 deterministic cache-hit assertions). */
65
+ export const subagentBodyCacheTestHooks = {
66
+ get bodyRenders(): number {
67
+ return subagentBodyRenderCount;
68
+ },
69
+ get size(): number {
70
+ return subagentBodyCache.size;
71
+ },
72
+ reset(): void {
73
+ subagentBodyRenderCount = 0;
74
+ subagentBodyCache.clear();
75
+ },
76
+ };
77
+
78
+ function renderCachedSubagentBody(
79
+ snapshot: SubagentSnapshot,
80
+ signature: string,
81
+ expanded: boolean,
82
+ width: number,
83
+ theme: Theme,
84
+ ): string[] {
85
+ const key = new Hasher().str(signature).bool(expanded).u32(width).u32(themeIdentityId(theme)).digest();
86
+ const hit = subagentBodyCache.get(key);
87
+ if (hit) {
88
+ // Refresh LRU recency.
89
+ subagentBodyCache.delete(key);
90
+ subagentBodyCache.set(key, hit);
91
+ return hit;
92
+ }
93
+ const lines = renderSubagentSnapshotBody(snapshot, expanded, theme).map(line =>
94
+ line.length > 0 ? truncateToWidth(line, width, Ellipsis.Omit) : "",
95
+ );
96
+ subagentBodyRenderCount += 1;
97
+ subagentBodyCache.set(key, lines);
98
+ if (subagentBodyCache.size > SUBAGENT_BODY_CACHE_MAX) {
99
+ const oldest = subagentBodyCache.keys().next().value;
100
+ if (oldest !== undefined) subagentBodyCache.delete(oldest);
101
+ }
102
+ return lines;
103
+ }
104
+
30
105
  function statusIconKind(status: SubagentSnapshot["status"]): ToolUIStatus {
31
106
  switch (status) {
32
107
  case "completed":
@@ -44,13 +119,10 @@ function statusIconKind(status: SubagentSnapshot["status"]): ToolUIStatus {
44
119
  }
45
120
  }
46
121
 
47
- function renderSubagentSnapshot(
48
- snapshot: SubagentSnapshot,
49
- expanded: boolean,
50
- theme: Theme,
51
- spinnerFrame: number | undefined,
52
- ): string[] {
53
- const lines: string[] = [];
122
+ // Cheap, dynamic per-subagent status line: the spinner may animate and the duration
123
+ // is the snapshot's own (fresh) value, so this line is rebuilt every frame and is
124
+ // NOT part of the cached body.
125
+ function renderSubagentStatusLine(snapshot: SubagentSnapshot, theme: Theme, spinnerFrame: number | undefined): string {
54
126
  const icon = formatStatusIcon(
55
127
  statusIconKind(snapshot.status),
56
128
  theme,
@@ -59,13 +131,29 @@ function renderSubagentSnapshot(
59
131
  const id = theme.fg("muted", snapshot.id);
60
132
  const status = theme.fg("dim", snapshot.status);
61
133
  const duration = theme.fg("dim", formatDuration(snapshot.durationMs));
62
- lines.push(`${icon} ${id} ${status} ${duration}`);
134
+ return `${icon} ${id} ${status} ${duration}`;
135
+ }
136
+
137
+ // Heavy, cacheable per-subagent body: a pure function of (snapshot content, expanded,
138
+ // theme). No spinner frame and no wall-clock displays leak in (live progress uses
139
+ // `staticTime`), so the module body cache can never serve stale or frozen-ticking lines.
140
+ function renderSubagentSnapshotBody(snapshot: SubagentSnapshot, expanded: boolean, theme: Theme): string[] {
141
+ const lines: string[] = [];
63
142
 
64
143
  // Static receipt fields (parity with the markdown content for non-await actions).
65
144
  if (snapshot.jobId !== snapshot.id) lines.push(` ${theme.fg("dim", `Job: ${snapshot.jobId}`)}`);
66
145
  if (snapshot.agent && snapshot.agent !== "unknown") {
67
146
  lines.push(` ${theme.fg("dim", `Agent: ${snapshot.agent} (${snapshot.agentSource})`)}`);
68
147
  }
148
+ if (snapshot.effectiveModel) {
149
+ if (snapshot.modelFellBack && snapshot.requestedModel) {
150
+ lines.push(
151
+ ` ${theme.fg("warning", `Model: ${snapshot.effectiveModel} (requested ${snapshot.requestedModel}, fell back — no credentials)`)}`,
152
+ );
153
+ } else {
154
+ lines.push(` ${theme.fg("dim", `Model: ${snapshot.effectiveModel}`)}`);
155
+ }
156
+ }
69
157
  if (snapshot.description) lines.push(` ${theme.fg("dim", `Description: ${snapshot.description}`)}`);
70
158
  if (snapshot.outputRef) lines.push(` ${theme.fg("dim", `Output: ${snapshot.outputRef}`)}`);
71
159
  if (snapshot.assignment) {
@@ -73,13 +161,13 @@ function renderSubagentSnapshot(
73
161
  for (const al of snapshot.assignment.split("\n")) lines.push(` ${theme.fg("toolOutput", replaceTabs(al))}`);
74
162
  }
75
163
 
76
- // Defense in depth: the producer only attaches `progress` when a live
77
- // producer exists (subagent.ts #liveProgressFields), but the renderer
78
- // also honors an explicit `liveProgressAvailable: false` so stale retained
79
- // progress can never resurrect a live panel (AC5).
164
+ // Defense in depth: the producer only attaches `progress` when a live producer
165
+ // exists (subagent.ts #liveProgressFields), but the renderer also honors an
166
+ // explicit `liveProgressAvailable: false` so stale retained progress can never
167
+ // resurrect a live panel (AC5). `staticTime` keeps wall-clock displays out of
168
+ // these cached lines.
80
169
  if (snapshot.progress && snapshot.liveProgressAvailable !== false) {
81
- // Live streaming panel (full task-panel parity), indented under the header.
82
- for (const pl of renderSubagentLiveProgress(snapshot.progress, expanded, theme, spinnerFrame)) {
170
+ for (const pl of renderSubagentLiveProgress(snapshot.progress, expanded, theme, undefined, true)) {
83
171
  lines.push(` ${pl}`);
84
172
  }
85
173
  } else if (snapshot.liveProgressAvailable && (snapshot.status === "running" || snapshot.status === "queued")) {
@@ -124,14 +212,17 @@ export const subagentToolRenderer = {
124
212
 
125
213
  const runningCount = subagents.filter(s => s.status === "running").length;
126
214
 
127
- let cached: RenderCache | undefined;
215
+ // Each snapshot's rendered-state signature is constant for this component
216
+ // instance, so compute them at most once; the heavy per-subagent bodies are
217
+ // cached module-side and keyed by that signature.
218
+ let snapshotSignatures: string[] | undefined;
128
219
  return {
129
220
  render(width: number): string[] {
130
221
  const expanded = options.expanded;
131
- const spinnerFrame = options.spinnerFrame ?? 0;
132
- const key = new Hasher().bool(expanded).u32(width).u32(spinnerFrame).digest();
133
- if (cached?.key === key) return cached.lines;
134
222
 
223
+ // Cheap dynamic header: may animate with `spinnerFrame` and is rebuilt
224
+ // every frame, but it is a single status line plus an optional hint, so
225
+ // it is never gated by the heavy body cache.
135
226
  const header = renderStatusLine(
136
227
  {
137
228
  icon: runningCount > 0 ? "info" : "success",
@@ -144,23 +235,31 @@ export const subagentToolRenderer = {
144
235
  },
145
236
  theme,
146
237
  );
147
-
148
- const lines: string[] = [header];
238
+ const out: string[] = [truncateToWidth(header, width, Ellipsis.Omit)];
149
239
  // Discoverability: the inline panel is a bounded preview; the session
150
240
  // observer (ctrl+s) streams the full per-subagent message history.
151
241
  if (runningCount > 0) {
152
- lines.push(` ${theme.fg("dim", "(ctrl+s to observe sessions)")}`);
153
- }
154
- for (const snapshot of subagents) {
155
- lines.push(...renderSubagentSnapshot(snapshot, expanded, theme, options.spinnerFrame));
242
+ out.push(truncateToWidth(` ${theme.fg("dim", "(ctrl+s to observe sessions)")}`, width, Ellipsis.Omit));
156
243
  }
157
244
 
158
- const out = lines.map(l => (l.length > 0 ? truncateToWidth(l, width, Ellipsis.Omit) : ""));
159
- cached = { key, lines: out };
245
+ snapshotSignatures ??= subagents.map(snapshot => subagentAwaitRenderedStateSignature([snapshot]));
246
+ subagents.forEach((snapshot, index) => {
247
+ // Fresh per-subagent status line (cheap), then the cached heavy body.
248
+ out.push(
249
+ truncateToWidth(
250
+ renderSubagentStatusLine(snapshot, theme, options.spinnerFrame),
251
+ width,
252
+ Ellipsis.Omit,
253
+ ),
254
+ );
255
+ out.push(...renderCachedSubagentBody(snapshot, snapshotSignatures![index]!, expanded, width, theme));
256
+ });
160
257
  return out;
161
258
  },
162
259
  invalidate() {
163
- cached = undefined;
260
+ // The heavy body cache is content-addressed (keyed by the rendered-state
261
+ // signature, width, expanded, and theme), so there is no instance-local
262
+ // state to clear here.
164
263
  },
165
264
  };
166
265
  },
@@ -2,9 +2,9 @@ import * as path from "node:path";
2
2
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core";
3
3
  import { prompt } from "@gajae-code/utils";
4
4
  import * as z from "zod/v4";
5
- import { type AsyncJob, AsyncJobManager, type SubagentRecord } from "../async";
5
+ import { type AsyncJob, AsyncJobManager, jobElapsedMs, type SubagentRecord } from "../async";
6
6
  import subagentDescription from "../prompts/tools/subagent.md" with { type: "text" };
7
- import type { AgentProgress, AgentSource } from "../task/types";
7
+ import type { AgentProgress, AgentSource, TaskToolDetails } from "../task/types";
8
8
  import { Ellipsis, truncateToWidth } from "../tui";
9
9
  import type { ToolSession } from "./index";
10
10
  import { replaceTabs } from "./render-utils";
@@ -67,6 +67,12 @@ export interface SubagentSnapshot {
67
67
  progress?: AgentProgress;
68
68
  /** True when a live in-session progress producer exists for this subagent. */
69
69
  liveProgressAvailable?: boolean;
70
+ /** Model the subagent actually runs on (after any auth fallback). */
71
+ effectiveModel?: string;
72
+ /** Model originally requested via role/preset mapping; differs from effective on fallback. */
73
+ requestedModel?: string;
74
+ /** True when the requested model lacked credentials and fell back to the parent model. */
75
+ modelFellBack?: boolean;
70
76
  }
71
77
 
72
78
  export interface SubagentToolDetails {
@@ -324,12 +330,21 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
324
330
  );
325
331
  const watchedJobIds = runningJobs.map(job => job.id);
326
332
  manager.watchJobs(watchedJobIds);
327
- const progressTimer = onUpdate
328
- ? setInterval(() => {
329
- onUpdate(this.#progressResult(manager, records, true));
330
- }, 500)
331
- : undefined;
332
- onUpdate?.(this.#progressResult(manager, records, true));
333
+ let lastEmittedSignature: string | undefined;
334
+ const emitIfChanged = (force: boolean): void => {
335
+ if (!onUpdate) return;
336
+ const result = this.#progressResult(manager, records, true);
337
+ const signature = subagentAwaitRenderedStateSignature(result.details?.subagents ?? []);
338
+ if (!force && signature === lastEmittedSignature) return;
339
+ lastEmittedSignature = signature;
340
+ onUpdate(result);
341
+ };
342
+ const progressTimer = onUpdate ? setInterval(() => emitIfChanged(false), 500) : undefined;
343
+ // Initial emission so the panel appears immediately; later idle ticks are
344
+ // gated on a value-based rendered-state signature so unchanged progress no
345
+ // longer rebuilds the renderer component or mutates transcript lines above
346
+ // the viewport (the source of the await-panel repaint storms).
347
+ emitIfChanged(true);
333
348
 
334
349
  let timedOut = false;
335
350
  try {
@@ -508,6 +523,13 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
508
523
  lines.push(`### ${snapshot.id} — ${snapshot.status}`);
509
524
  if (snapshot.jobId !== snapshot.id) lines.push(`Job: ${snapshot.jobId}`);
510
525
  if (snapshot.agent) lines.push(`Agent: ${snapshot.agent} (${snapshot.agentSource})`);
526
+ if (snapshot.effectiveModel) {
527
+ lines.push(
528
+ snapshot.modelFellBack && snapshot.requestedModel
529
+ ? `Model: ${snapshot.effectiveModel} (requested ${snapshot.requestedModel}, fell back — no credentials)`
530
+ : `Model: ${snapshot.effectiveModel}`,
531
+ );
532
+ }
511
533
  if (snapshot.description) lines.push(`Description: ${snapshot.description}`);
512
534
  if (snapshot.outputRef) lines.push(`Output: ${snapshot.outputRef}`);
513
535
  if (snapshot.assignment) lines.push("Assignment:", "```", snapshot.assignment, "```");
@@ -584,9 +606,19 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
584
606
  durationMs: 0,
585
607
  ...(verifiedOutputIds.has(record.subagentId) ? { outputRef: `agent://${record.subagentId}` } : {}),
586
608
  ...liveFields,
609
+ ...this.#modelFields(record),
587
610
  };
588
611
  }
589
612
 
613
+ #modelFields(record?: SubagentRecord): Partial<SubagentSnapshot> {
614
+ if (!record) return {};
615
+ const fields: Partial<SubagentSnapshot> = {};
616
+ if (record.effectiveModel) fields.effectiveModel = record.effectiveModel;
617
+ if (record.requestedModel) fields.requestedModel = record.requestedModel;
618
+ if (record.modelFellBack) fields.modelFellBack = true;
619
+ return fields;
620
+ }
621
+
590
622
  #snapshot(
591
623
  job: AsyncJob,
592
624
  timedOut = false,
@@ -608,7 +640,7 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
608
640
  label: sanitizeText(job.label, RECEIPT_PREVIEW_WIDTH),
609
641
  agent: subagent?.agent ?? "unknown",
610
642
  agentSource: subagent?.agentSource ?? "bundled",
611
- durationMs: Math.max(0, Date.now() - job.startTime),
643
+ durationMs: jobElapsedMs(job),
612
644
  ...(subagent?.description ? { description: sanitizeText(subagent.description, RECEIPT_PREVIEW_WIDTH) } : {}),
613
645
  ...(verbosity === "full" && subagent?.assignment
614
646
  ? { assignment: sanitizeText(subagent.assignment, FULL_PREVIEW_WIDTH) }
@@ -622,6 +654,7 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
622
654
  : {}),
623
655
  ...(outputRef ? { outputRef } : {}),
624
656
  ...(runningTimeoutGuidance ? { guidance: runningTimeoutGuidance } : {}),
657
+ ...this.#modelFields(record),
625
658
  };
626
659
  }
627
660
 
@@ -696,3 +729,134 @@ function previewJobOutput(
696
729
  const preview = truncateToWidth(normalized, width, Ellipsis.Unicode);
697
730
  return { type: source.type, preview, truncated: preview !== normalized };
698
731
  }
732
+
733
+ /**
734
+ * Canonical, value-based rendered-state signature for the `subagent` await panel.
735
+ *
736
+ * Producer-side await gating compares this signature against the last emitted one
737
+ * and only fires `onUpdate` when the *rendered* state actually changed. Unchanged
738
+ * idle ticks therefore stop rebuilding the renderer component and stop mutating
739
+ * transcript lines above the viewport, which is what triggers TUI full-redraw
740
+ * storms (`tui.ts` `firstChanged < viewportTop`).
741
+ *
742
+ * It is deliberately value-based, never object identity: `AsyncJobManager.record-
743
+ * SubagentProgress` stores a `structuredClone` but `getSubagentProgress` returns
744
+ * the retained object by reference, so identity comparison would be both noisy and
745
+ * unsafe.
746
+ *
747
+ * Time-derived fields are intentionally excluded so the panel does not churn while
748
+ * idle: raw durations (`durationMs`), current-tool elapsed (`currentToolStartMs`),
749
+ * and retry countdowns (`retryState.startedAtMs`) are omitted. Idle duration and
750
+ * countdown ticking is sacrificed by design; every real transition still changes
751
+ * the signature.
752
+ */
753
+ export function subagentAwaitRenderedStateSignature(subagents: readonly SubagentSnapshot[]): string {
754
+ return JSON.stringify(subagents.map(canonicalizeSnapshotForSignature));
755
+ }
756
+
757
+ function canonicalizeSnapshotForSignature(snapshot: SubagentSnapshot): unknown {
758
+ return {
759
+ id: snapshot.id,
760
+ jobId: snapshot.jobId,
761
+ status: snapshot.status,
762
+ label: snapshot.label,
763
+ agent: snapshot.agent,
764
+ agentSource: snapshot.agentSource,
765
+ description: snapshot.description ?? null,
766
+ assignment: snapshot.assignment ?? null,
767
+ resultText: snapshot.resultText ?? null,
768
+ errorText: snapshot.errorText ?? null,
769
+ resultPreview: snapshot.resultPreview ?? null,
770
+ outputRef: snapshot.outputRef ?? null,
771
+ truncated: snapshot.truncated ?? false,
772
+ guidance: snapshot.guidance ?? null,
773
+ liveProgressAvailable: snapshot.liveProgressAvailable ?? null,
774
+ effectiveModel: snapshot.effectiveModel ?? null,
775
+ requestedModel: snapshot.requestedModel ?? null,
776
+ modelFellBack: snapshot.modelFellBack ?? false,
777
+ // durationMs intentionally excluded (time-derived; would defeat idle gating).
778
+ progress: snapshot.progress ? canonicalizeProgressForSignature(snapshot.progress) : null,
779
+ };
780
+ }
781
+
782
+ function canonicalizeProgressForSignature(progress: AgentProgress): unknown {
783
+ return {
784
+ id: progress.id,
785
+ agent: progress.agent,
786
+ agentSource: progress.agentSource,
787
+ status: progress.status,
788
+ task: progress.task,
789
+ assignment: progress.assignment ?? null,
790
+ description: progress.description ?? null,
791
+ lastIntent: progress.lastIntent ?? null,
792
+ currentTool: progress.currentTool ?? null,
793
+ currentToolArgs: progress.currentToolArgs ?? null,
794
+ // currentToolStartMs intentionally excluded (only drives elapsed rendering).
795
+ recentTools: progress.recentTools.map(tool => ({ tool: tool.tool, args: tool.args })),
796
+ recentOutput: progress.recentOutput,
797
+ toolCount: progress.toolCount,
798
+ tokens: progress.tokens,
799
+ contextTokens: progress.contextTokens ?? null,
800
+ contextWindow: progress.contextWindow ?? null,
801
+ cost: progress.cost,
802
+ modelOverride: progress.modelOverride ?? null,
803
+ modelSubstitutionWarning: progress.modelSubstitutionWarning ?? null,
804
+ // durationMs intentionally excluded (time-derived).
805
+ extractedToolData: progress.extractedToolData
806
+ ? canonicalizeExtractedToolDataForSignature(progress.extractedToolData)
807
+ : null,
808
+ retryState: progress.retryState
809
+ ? {
810
+ attempt: progress.retryState.attempt,
811
+ maxAttempts: progress.retryState.maxAttempts,
812
+ unbounded: progress.retryState.unbounded ?? false,
813
+ delayMs: progress.retryState.delayMs,
814
+ errorMessage: progress.retryState.errorMessage,
815
+ // startedAtMs intentionally excluded (drives countdown only).
816
+ }
817
+ : null,
818
+ retryFailure: progress.retryFailure ?? null,
819
+ inflightTaskDetails: progress.inflightTaskDetails
820
+ ? canonicalizeTaskDetailsForSignature(progress.inflightTaskDetails)
821
+ : null,
822
+ };
823
+ }
824
+
825
+ /**
826
+ * Nested `task` data (`extractedToolData.task` and `inflightTaskDetails`) is the
827
+ * one place the await signature reaches into a live, ticking structure: nested
828
+ * `AgentProgress` carries the same time-derived fields excluded above, and
829
+ * `TaskToolDetails` adds `totalDurationMs` / per-result `durationMs`. Signing it
830
+ * wholesale would defeat idle gating whenever an awaited subagent is itself inside
831
+ * a live `task` call, so these helpers canonicalize the rendered, non-time subset
832
+ * recursively (mutually recursive with `canonicalizeProgressForSignature`).
833
+ */
834
+ function canonicalizeExtractedToolDataForSignature(data: Record<string, unknown[]>): Record<string, unknown> {
835
+ const out: Record<string, unknown> = {};
836
+ for (const key of Object.keys(data)) {
837
+ // Only the `task` key holds time-ticking `TaskToolDetails`; other handler
838
+ // data (yield/report_finding/generic) is stable and passes through as-is.
839
+ out[key] = key === "task" ? (data[key] as TaskToolDetails[]).map(canonicalizeTaskDetailsForSignature) : data[key];
840
+ }
841
+ return out;
842
+ }
843
+
844
+ function canonicalizeTaskDetailsForSignature(details: TaskToolDetails): unknown {
845
+ // `extractedToolData` is an untyped boundary (`Record<string, unknown[]>`), so
846
+ // guard each field instead of trusting the `TaskToolDetails` cast.
847
+ return {
848
+ // totalDurationMs intentionally excluded (time-derived).
849
+ results: Array.isArray(details.results) ? details.results.map(canonicalizeTaskResultForSignature) : null,
850
+ progress: Array.isArray(details.progress) ? details.progress.map(canonicalizeProgressForSignature) : null,
851
+ async: details.async
852
+ ? { state: details.async.state, jobId: details.async.jobId, type: details.async.type }
853
+ : null,
854
+ };
855
+ }
856
+
857
+ function canonicalizeTaskResultForSignature(result: TaskToolDetails["results"][number]): unknown {
858
+ // Completed results do not tick, but drop `durationMs` so the only time-derived
859
+ // field in the receipt can never reintroduce idle churn.
860
+ const { durationMs: _durationMs, ...rest } = result;
861
+ return rest;
862
+ }
@@ -0,0 +1,39 @@
1
+ import type { AgentTool } from "@gajae-code/agent-core";
2
+ import { isUltragoalAskBlocked, type UltragoalAskBlockDiagnostic } from "../gjc-runtime/ultragoal-guard";
3
+ import { ToolError } from "./tool-errors";
4
+
5
+ const ULTRAGOAL_ASK_GUARD = Symbol.for("gajae-code.ultragoalAskGuard");
6
+
7
+ type GuardedTool = AgentTool & { [ULTRAGOAL_ASK_GUARD]?: true };
8
+
9
+ export function formatUltragoalAskBlockMessage(diagnostic: UltragoalAskBlockDiagnostic): string {
10
+ return [
11
+ diagnostic.message,
12
+ `Ultragoal ask guard blocked ask (source: ${diagnostic.source}; reason: ${diagnostic.reason}).`,
13
+ "Use `gjc ultragoal record-review-blockers` to record the blocker instead of asking the user.",
14
+ ].join("\n");
15
+ }
16
+
17
+ export async function assertUltragoalAskAllowed(cwd: string): Promise<void> {
18
+ const diagnostic = await isUltragoalAskBlocked(cwd);
19
+ if (!diagnostic.active) return;
20
+ throw new ToolError(formatUltragoalAskBlockMessage(diagnostic));
21
+ }
22
+
23
+ export function guardToolForUltragoalAsk<T extends AgentTool>(tool: T, getCwd: () => string): T {
24
+ if (tool.name !== "ask") return tool;
25
+ const candidate = tool as GuardedTool;
26
+ if (candidate[ULTRAGOAL_ASK_GUARD]) return tool;
27
+ const wrapped = new Proxy(tool, {
28
+ get(target, prop, receiver) {
29
+ if (prop === ULTRAGOAL_ASK_GUARD) return true;
30
+ if (prop !== "execute") return Reflect.get(target, prop, receiver);
31
+ return async (...args: unknown[]): Promise<unknown> => {
32
+ await assertUltragoalAskAllowed(getCwd());
33
+ return Reflect.apply(target.execute, target, args);
34
+ };
35
+ },
36
+ }) as T & GuardedTool;
37
+ wrapped[ULTRAGOAL_ASK_GUARD] = true;
38
+ return wrapped as T;
39
+ }
@@ -8,7 +8,6 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
8
8
  import type { AuthStorage } from "@gajae-code/ai";
9
9
  import { prompt } from "@gajae-code/utils";
10
10
  import * as z from "zod/v4";
11
- import { parseModelString } from "../../config/model-resolver";
12
11
  import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../extensibility/custom-tools/types";
13
12
  import type { Theme } from "../../modes/theme/theme";
14
13
  import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { type: "text" };
@@ -19,7 +18,7 @@ import { formatAge } from "../../tools/render-utils";
19
18
  import { throwIfAborted } from "../../tools/tool-errors";
20
19
  import { getSearchProviderLabel, resolveProviderChain, type SearchProvider } from "./provider";
21
20
  import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
22
- import type { SearchProviderId, SearchResponse } from "./types";
21
+ import type { ActiveSearchModelContext, SearchProviderId, SearchResponse } from "./types";
23
22
  import { SearchProviderError } from "./types";
24
23
 
25
24
  /** Web search tool parameters schema */
@@ -116,21 +115,11 @@ function formatForLLM(response: SearchResponse): string {
116
115
  return parts.join("\n");
117
116
  }
118
117
 
119
- /** Best-effort active model provider: prefer the resolved Model, fall back to parsing the model string. */
120
- function resolveActiveModelProvider(
121
- modelProvider: string | undefined,
122
- modelString: string | undefined,
123
- ): string | undefined {
124
- if (modelProvider) return modelProvider;
125
- if (modelString) return parseModelString(modelString)?.provider;
126
- return undefined;
127
- }
128
-
129
118
  interface ExecuteSearchOptions {
130
119
  authStorage: AuthStorage;
131
120
  sessionId?: string;
132
121
  signal?: AbortSignal;
133
- activeModelProvider?: string;
122
+ activeModelContext?: ActiveSearchModelContext;
134
123
  }
135
124
 
136
125
  /** Execute web search */
@@ -139,11 +128,17 @@ async function executeSearch(
139
128
  params: SearchQueryParams,
140
129
  options: ExecuteSearchOptions,
141
130
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
142
- const { authStorage, sessionId, signal, activeModelProvider } = options;
131
+ const { authStorage, sessionId, signal, activeModelContext } = options;
143
132
  // Pass `params.provider` straight through: when omitted (the normal model-facing
144
133
  // path) it is `undefined`, so `resolveProviderChain` applies the settings-configured
145
134
  // preferred provider. Coalescing to "auto" here would silently bypass that preference.
146
- const providers = await resolveProviderChain(authStorage, params.provider, activeModelProvider);
135
+ const providers = await resolveProviderChain({
136
+ authStorage,
137
+ sessionId,
138
+ signal,
139
+ preferredProvider: params.provider,
140
+ activeModelContext,
141
+ });
147
142
 
148
143
  const failures: Array<{ provider: SearchProvider; error: unknown }> = [];
149
144
  let lastProvider = providers[0];
@@ -161,6 +156,7 @@ async function executeSearch(
161
156
  signal,
162
157
  authStorage,
163
158
  sessionId,
159
+ activeModelContext,
164
160
  });
165
161
 
166
162
  const text = formatForLLM(response);
@@ -210,14 +206,19 @@ async function executeSearch(
210
206
  */
211
207
  export async function runSearchQuery(
212
208
  params: SearchQueryParams,
213
- options: { authStorage?: AuthStorage; sessionId?: string; signal?: AbortSignal; activeModelProvider?: string } = {},
209
+ options: {
210
+ authStorage?: AuthStorage;
211
+ sessionId?: string;
212
+ signal?: AbortSignal;
213
+ activeModelContext?: ActiveSearchModelContext;
214
+ } = {},
214
215
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
215
216
  const authStorage = options.authStorage ?? (await discoverAuthStorage());
216
217
  return executeSearch("cli-web-search", params, {
217
218
  authStorage,
218
219
  sessionId: options.sessionId,
219
220
  signal: options.signal,
220
- activeModelProvider: options.activeModelProvider,
221
+ activeModelContext: options.activeModelContext,
221
222
  });
222
223
  }
223
224
 
@@ -251,11 +252,10 @@ export class WebSearchTool implements AgentTool<typeof webSearchSchema, SearchRe
251
252
  ): Promise<AgentToolResult<SearchRenderDetails>> {
252
253
  const authStorage = this.#session.authStorage ?? (await discoverAuthStorage());
253
254
  const sessionId = this.#session.getSessionId?.() ?? undefined;
254
- const activeModelProvider = resolveActiveModelProvider(
255
- this.#session.model?.provider,
256
- this.#session.getActiveModelString?.(),
257
- );
258
- return executeSearch(_toolCallId, params, { authStorage, sessionId, signal, activeModelProvider });
255
+ const activeModelContext = this.#session.model
256
+ ? this.#session.modelRegistry?.getActiveSearchModelContext(this.#session.model)
257
+ : undefined;
258
+ return executeSearch(_toolCallId, params, { authStorage, sessionId, signal, activeModelContext });
259
259
  }
260
260
  }
261
261
 
@@ -279,7 +279,7 @@ export const webSearchCustomTool: CustomTool<typeof webSearchSchema, SearchRende
279
279
  authStorage,
280
280
  sessionId,
281
281
  signal,
282
- activeModelProvider: ctx.model?.provider,
282
+ activeModelContext: ctx.model ? ctx.modelRegistry?.getActiveSearchModelContext(ctx.model) : undefined,
283
283
  });
284
284
  },
285
285
 
@@ -296,6 +296,6 @@ export function getSearchTools(): CustomTool<any, any>[] {
296
296
  return [webSearchCustomTool];
297
297
  }
298
298
 
299
- export { getSearchProvider, setPreferredSearchProvider } from "./provider";
299
+ export { getSearchProvider, setPreferredSearchProvider, setSearchFallbackProviders } from "./provider";
300
300
  export type { SearchProviderId as SearchProvider, SearchResponse } from "./types";
301
- export { isSearchProviderPreference } from "./types";
301
+ export { isConfigurableSearchProviderId, isSearchProviderPreference } from "./types";