@gajae-code/coding-agent 0.5.1 → 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 (98) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -1
  3. package/dist/types/cli/setup-cli.d.ts +8 -1
  4. package/dist/types/commands/setup.d.ts +7 -0
  5. package/dist/types/config/file-lock.d.ts +24 -2
  6. package/dist/types/config/model-registry.d.ts +4 -0
  7. package/dist/types/config/models-config-schema.d.ts +5 -0
  8. package/dist/types/config/settings-schema.d.ts +62 -0
  9. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  10. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  12. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  13. package/dist/types/modes/interactive-mode.d.ts +1 -1
  14. package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
  15. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  16. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  17. package/dist/types/modes/theme/theme.d.ts +1 -0
  18. package/dist/types/modes/types.d.ts +1 -1
  19. package/dist/types/session/history-storage.d.ts +2 -2
  20. package/dist/types/session/session-manager.d.ts +10 -1
  21. package/dist/types/setup/credential-import.d.ts +79 -0
  22. package/dist/types/task/executor.d.ts +1 -0
  23. package/dist/types/task/render.d.ts +1 -1
  24. package/dist/types/tools/subagent-render.d.ts +7 -1
  25. package/dist/types/tools/subagent.d.ts +21 -0
  26. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  27. package/dist/types/web/search/index.d.ts +4 -4
  28. package/dist/types/web/search/provider.d.ts +16 -20
  29. package/dist/types/web/search/providers/base.d.ts +2 -1
  30. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  31. package/dist/types/web/search/types.d.ts +14 -2
  32. package/package.json +7 -7
  33. package/scripts/build-binary.ts +7 -0
  34. package/src/cli/args.ts +2 -0
  35. package/src/cli/fast-help.ts +2 -0
  36. package/src/cli/setup-cli.ts +138 -3
  37. package/src/commands/setup.ts +5 -1
  38. package/src/commands/ultragoal.ts +3 -1
  39. package/src/config/file-lock-gc.ts +14 -2
  40. package/src/config/file-lock.ts +54 -12
  41. package/src/config/model-profile-activation.ts +15 -3
  42. package/src/config/model-profiles.ts +15 -15
  43. package/src/config/model-registry.ts +21 -1
  44. package/src/config/models-config-schema.ts +1 -0
  45. package/src/config/settings-schema.ts +62 -0
  46. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  47. package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
  48. package/src/gjc-runtime/launch-tmux.ts +3 -4
  49. package/src/gjc-runtime/ralplan-runtime.ts +174 -12
  50. package/src/gjc-runtime/state-runtime.ts +2 -1
  51. package/src/gjc-runtime/state-writer.ts +254 -7
  52. package/src/gjc-runtime/tmux-gc.ts +2 -1
  53. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  54. package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
  55. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  56. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  57. package/src/harness-control-plane/owner.ts +3 -2
  58. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  59. package/src/hooks/skill-state.ts +121 -2
  60. package/src/internal-urls/docs-index.generated.ts +13 -9
  61. package/src/lsp/defaults.json +1 -0
  62. package/src/main.ts +14 -4
  63. package/src/modes/acp/acp-agent.ts +4 -2
  64. package/src/modes/bridge/bridge-mode.ts +2 -1
  65. package/src/modes/components/history-search.ts +5 -2
  66. package/src/modes/components/model-selector.ts +26 -0
  67. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  68. package/src/modes/controllers/selector-controller.ts +80 -1
  69. package/src/modes/interactive-mode.ts +11 -1
  70. package/src/modes/rpc/rpc-mode.ts +132 -18
  71. package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
  72. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  73. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  74. package/src/modes/theme/defaults/claude-code.json +100 -0
  75. package/src/modes/theme/defaults/codex.json +100 -0
  76. package/src/modes/theme/defaults/index.ts +6 -0
  77. package/src/modes/theme/defaults/opencode.json +102 -0
  78. package/src/modes/theme/theme.ts +2 -2
  79. package/src/modes/types.ts +1 -1
  80. package/src/prompts/agents/executor.md +5 -2
  81. package/src/sdk.ts +12 -1
  82. package/src/session/agent-session.ts +22 -11
  83. package/src/session/history-storage.ts +32 -11
  84. package/src/session/session-manager.ts +70 -18
  85. package/src/setup/credential-import.ts +429 -0
  86. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  87. package/src/task/executor.ts +7 -1
  88. package/src/task/render.ts +18 -7
  89. package/src/tools/ask.ts +4 -2
  90. package/src/tools/cron.ts +1 -1
  91. package/src/tools/subagent-render.ts +119 -29
  92. package/src/tools/subagent.ts +147 -7
  93. package/src/tools/ultragoal-ask-guard.ts +39 -0
  94. package/src/web/search/index.ts +25 -25
  95. package/src/web/search/provider.ts +178 -87
  96. package/src/web/search/providers/base.ts +2 -1
  97. package/src/web/search/providers/openai-compatible.ts +151 -0
  98. package/src/web/search/types.ts +47 -22
@@ -525,6 +525,10 @@ function renderAgentProgress(
525
525
  expanded: boolean,
526
526
  theme: Theme,
527
527
  spinnerFrame?: number,
528
+ /** When true, omit wall-clock-derived displays (current-tool elapsed, retry
529
+ * countdown) so the output is a pure function of `progress` — required when the
530
+ * caller caches these lines (the `subagent` await panel). */
531
+ staticTime = false,
528
532
  ): string[] {
529
533
  const lines: string[] = [];
530
534
  const prefix = isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch);
@@ -587,7 +591,7 @@ function renderAgentProgress(
587
591
  if (toolDetail) {
588
592
  toolLine += `: ${theme.fg("dim", truncateToWidth(replaceTabs(toolDetail), 40))}`;
589
593
  }
590
- if (progress.currentToolStartMs) {
594
+ if (!staticTime && progress.currentToolStartMs) {
591
595
  const elapsed = Date.now() - progress.currentToolStartMs;
592
596
  if (elapsed > 5000) {
593
597
  toolLine += `${theme.sep.dot}${theme.fg("warning", formatDuration(elapsed))}`;
@@ -610,12 +614,17 @@ function renderAgentProgress(
610
614
  // long until the next attempt. Without this, the parent UI would just
611
615
  // keep spinning while a child sleeps on a 3-hour provider rate-limit.
612
616
  if (progress.retryState && progress.status === "running") {
613
- const remainingMs = Math.max(0, progress.retryState.startedAtMs + progress.retryState.delayMs - Date.now());
614
- const waitLabel = remainingMs > 0 ? `in ${formatDuration(remainingMs)}` : "now";
615
617
  const attemptLabel = progress.retryState.unbounded
616
618
  ? `attempt ${progress.retryState.attempt}`
617
619
  : `${progress.retryState.attempt}/${progress.retryState.maxAttempts}`;
618
- const summary = `retrying ${attemptLabel} ${waitLabel}: ${truncateToWidth(replaceTabs(progress.retryState.errorMessage), 60)}`;
620
+ // `staticTime` omits the wall-clock countdown so a cached await body stays a
621
+ // pure function of its key (the producer already drops time-only churn).
622
+ let waitLabel = "";
623
+ if (!staticTime) {
624
+ const remainingMs = Math.max(0, progress.retryState.startedAtMs + progress.retryState.delayMs - Date.now());
625
+ waitLabel = remainingMs > 0 ? ` in ${formatDuration(remainingMs)}` : " now";
626
+ }
627
+ const summary = `retrying ${attemptLabel}${waitLabel}: ${truncateToWidth(replaceTabs(progress.retryState.errorMessage), 60)}`;
619
628
  lines.push(`${continuePrefix}${theme.tree.hook} ${theme.fg("warning", summary)}`);
620
629
  } else if (progress.retryFailure && progress.status !== "running") {
621
630
  const summary = `auto-retry gave up after ${progress.retryFailure.attempt} attempt${
@@ -687,7 +696,7 @@ function renderAgentProgress(
687
696
  const inflight = progress.inflightTaskDetails;
688
697
  if (completedTaskCalls.length > 0 || inflight) {
689
698
  const snapshots = inflight ? [...completedTaskCalls, inflight] : completedTaskCalls;
690
- const nestedLines = renderNestedTaskTree(snapshots, expanded, theme, spinnerFrame);
699
+ const nestedLines = renderNestedTaskTree(snapshots, expanded, theme, spinnerFrame, staticTime);
691
700
  for (const line of nestedLines) {
692
701
  lines.push(`${continuePrefix}${line}`);
693
702
  }
@@ -712,8 +721,9 @@ export function renderSubagentLiveProgress(
712
721
  expanded: boolean,
713
722
  theme: Theme,
714
723
  spinnerFrame?: number,
724
+ staticTime = false,
715
725
  ): string[] {
716
- return renderAgentProgress(progress, true, expanded, theme, spinnerFrame);
726
+ return renderAgentProgress(progress, true, expanded, theme, spinnerFrame, staticTime);
717
727
  }
718
728
 
719
729
  /**
@@ -1051,6 +1061,7 @@ function renderNestedTaskTree(
1051
1061
  expanded: boolean,
1052
1062
  theme: Theme,
1053
1063
  spinnerFrame?: number,
1064
+ staticTime = false,
1054
1065
  ): string[] {
1055
1066
  const lines: string[] = [];
1056
1067
  for (const details of detailsList) {
@@ -1066,7 +1077,7 @@ function renderNestedTaskTree(
1066
1077
  if (inflight && inflight.length > 0) {
1067
1078
  inflight.forEach((prog, index) => {
1068
1079
  const isLast = index === inflight.length - 1;
1069
- lines.push(...renderAgentProgress(prog, isLast, expanded, theme, spinnerFrame));
1080
+ lines.push(...renderAgentProgress(prog, isLast, expanded, theme, spinnerFrame, staticTime));
1070
1081
  });
1071
1082
  }
1072
1083
  }
package/src/tools/ask.ts CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  visibleWidth,
27
27
  wrapTextWithAnsi,
28
28
  } from "@gajae-code/tui";
29
- import { prompt, untilAborted } from "@gajae-code/utils";
29
+ import { logger, prompt, untilAborted } from "@gajae-code/utils";
30
30
  import * as z from "zod/v4";
31
31
  import {
32
32
  formatDeepInterviewSelectorPrompt,
@@ -43,6 +43,7 @@ import { renderStatusLine } from "../tui";
43
43
  import type { ToolSession } from ".";
44
44
  import { formatErrorMessage, formatMeta, formatTitle } from "./render-utils";
45
45
  import { ToolAbortError } from "./tool-errors";
46
+ import { assertUltragoalAskAllowed } from "./ultragoal-ask-guard";
46
47
 
47
48
  // =============================================================================
48
49
  // Types
@@ -501,7 +502,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
501
502
  { sessionId },
502
503
  );
503
504
  } catch (error) {
504
- console.warn(
505
+ logger.warn(
505
506
  `ask: deep-interview round recording failed: ${error instanceof Error ? error.message : String(error)}`,
506
507
  );
507
508
  }
@@ -514,6 +515,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
514
515
  _onUpdate?: AgentToolUpdateCallback<AskToolDetails>,
515
516
  context?: AgentToolContext,
516
517
  ): Promise<AgentToolResult<AskToolDetails>> {
518
+ await assertUltragoalAskAllowed(this.session.cwd);
517
519
  const gateEmitter = this.session.getWorkflowGateEmitter?.();
518
520
  const canUseWorkflowGate = gateEmitter?.isUnattended() === true;
519
521
 
package/src/tools/cron.ts CHANGED
@@ -391,7 +391,7 @@ export function calculateCronFireTimeMs(params: {
391
391
  }
392
392
 
393
393
  function setCronTimeout(callback: () => void, delayMs: number): CronTimerHandle {
394
- let handle: ReturnType<typeof setTimeout> | undefined;
394
+ let handle: NodeJS.Timeout | undefined;
395
395
  let cleared = false;
396
396
  const schedule = (remainingMs: number) => {
397
397
  if (cleared) return;
@@ -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,7 +131,14 @@ 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}`)}`);
@@ -82,13 +161,13 @@ function renderSubagentSnapshot(
82
161
  for (const al of snapshot.assignment.split("\n")) lines.push(` ${theme.fg("toolOutput", replaceTabs(al))}`);
83
162
  }
84
163
 
85
- // Defense in depth: the producer only attaches `progress` when a live
86
- // producer exists (subagent.ts #liveProgressFields), but the renderer
87
- // also honors an explicit `liveProgressAvailable: false` so stale retained
88
- // 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.
89
169
  if (snapshot.progress && snapshot.liveProgressAvailable !== false) {
90
- // Live streaming panel (full task-panel parity), indented under the header.
91
- for (const pl of renderSubagentLiveProgress(snapshot.progress, expanded, theme, spinnerFrame)) {
170
+ for (const pl of renderSubagentLiveProgress(snapshot.progress, expanded, theme, undefined, true)) {
92
171
  lines.push(` ${pl}`);
93
172
  }
94
173
  } else if (snapshot.liveProgressAvailable && (snapshot.status === "running" || snapshot.status === "queued")) {
@@ -133,14 +212,17 @@ export const subagentToolRenderer = {
133
212
 
134
213
  const runningCount = subagents.filter(s => s.status === "running").length;
135
214
 
136
- 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;
137
219
  return {
138
220
  render(width: number): string[] {
139
221
  const expanded = options.expanded;
140
- const spinnerFrame = options.spinnerFrame ?? 0;
141
- const key = new Hasher().bool(expanded).u32(width).u32(spinnerFrame).digest();
142
- if (cached?.key === key) return cached.lines;
143
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.
144
226
  const header = renderStatusLine(
145
227
  {
146
228
  icon: runningCount > 0 ? "info" : "success",
@@ -153,23 +235,31 @@ export const subagentToolRenderer = {
153
235
  },
154
236
  theme,
155
237
  );
156
-
157
- const lines: string[] = [header];
238
+ const out: string[] = [truncateToWidth(header, width, Ellipsis.Omit)];
158
239
  // Discoverability: the inline panel is a bounded preview; the session
159
240
  // observer (ctrl+s) streams the full per-subagent message history.
160
241
  if (runningCount > 0) {
161
- lines.push(` ${theme.fg("dim", "(ctrl+s to observe sessions)")}`);
162
- }
163
- for (const snapshot of subagents) {
164
- lines.push(...renderSubagentSnapshot(snapshot, expanded, theme, options.spinnerFrame));
242
+ out.push(truncateToWidth(` ${theme.fg("dim", "(ctrl+s to observe sessions)")}`, width, Ellipsis.Omit));
165
243
  }
166
244
 
167
- const out = lines.map(l => (l.length > 0 ? truncateToWidth(l, width, Ellipsis.Omit) : ""));
168
- 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
+ });
169
257
  return out;
170
258
  },
171
259
  invalidate() {
172
- 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.
173
263
  },
174
264
  };
175
265
  },
@@ -4,7 +4,7 @@ import { prompt } from "@gajae-code/utils";
4
4
  import * as z from "zod/v4";
5
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";
@@ -330,12 +330,21 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
330
330
  );
331
331
  const watchedJobIds = runningJobs.map(job => job.id);
332
332
  manager.watchJobs(watchedJobIds);
333
- const progressTimer = onUpdate
334
- ? setInterval(() => {
335
- onUpdate(this.#progressResult(manager, records, true));
336
- }, 500)
337
- : undefined;
338
- 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);
339
348
 
340
349
  let timedOut = false;
341
350
  try {
@@ -720,3 +729,134 @@ function previewJobOutput(
720
729
  const preview = truncateToWidth(normalized, width, Ellipsis.Unicode);
721
730
  return { type: source.type, preview, truncated: preview !== normalized };
722
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";