@gajae-code/coding-agent 0.5.1 → 0.5.3

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 (165) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +6 -0
  4. package/dist/types/cli/setup-cli.d.ts +8 -1
  5. package/dist/types/commands/setup.d.ts +7 -0
  6. package/dist/types/config/file-lock.d.ts +24 -2
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/models-config-schema.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +62 -0
  10. package/dist/types/dap/client.d.ts +2 -1
  11. package/dist/types/edit/read-file.d.ts +6 -0
  12. package/dist/types/eval/js/context-manager.d.ts +3 -0
  13. package/dist/types/eval/js/executor.d.ts +1 -0
  14. package/dist/types/exec/bash-executor.d.ts +2 -0
  15. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  16. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  19. package/dist/types/lsp/types.d.ts +2 -0
  20. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  21. package/dist/types/modes/components/model-selector.d.ts +2 -0
  22. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  23. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  24. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  25. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  26. package/dist/types/modes/interactive-mode.d.ts +1 -1
  27. package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
  28. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  29. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  30. package/dist/types/modes/theme/theme.d.ts +1 -0
  31. package/dist/types/modes/types.d.ts +1 -1
  32. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  33. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  34. package/dist/types/runtime-mcp/types.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +17 -1
  36. package/dist/types/session/artifacts.d.ts +4 -1
  37. package/dist/types/session/history-storage.d.ts +2 -2
  38. package/dist/types/session/session-manager.d.ts +10 -1
  39. package/dist/types/session/streaming-output.d.ts +5 -0
  40. package/dist/types/setup/credential-import.d.ts +79 -0
  41. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  42. package/dist/types/task/executor.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +1 -1
  44. package/dist/types/tools/bash.d.ts +1 -0
  45. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  46. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  47. package/dist/types/tools/subagent-render.d.ts +7 -1
  48. package/dist/types/tools/subagent.d.ts +21 -0
  49. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  50. package/dist/types/web/search/index.d.ts +4 -4
  51. package/dist/types/web/search/provider.d.ts +16 -20
  52. package/dist/types/web/search/providers/base.d.ts +2 -1
  53. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  54. package/dist/types/web/search/types.d.ts +14 -2
  55. package/package.json +7 -7
  56. package/scripts/build-binary.ts +7 -0
  57. package/src/async/job-manager.ts +153 -39
  58. package/src/cli/args.ts +2 -0
  59. package/src/cli/fast-help.ts +2 -0
  60. package/src/cli/setup-cli.ts +138 -3
  61. package/src/commands/setup.ts +5 -1
  62. package/src/commands/ultragoal.ts +3 -1
  63. package/src/config/file-lock-gc.ts +14 -2
  64. package/src/config/file-lock.ts +63 -13
  65. package/src/config/model-profile-activation.ts +15 -3
  66. package/src/config/model-profiles.ts +15 -15
  67. package/src/config/model-registry.ts +21 -1
  68. package/src/config/models-config-schema.ts +1 -0
  69. package/src/config/settings-schema.ts +62 -0
  70. package/src/dap/client.ts +105 -64
  71. package/src/dap/session.ts +44 -7
  72. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  73. package/src/edit/read-file.ts +19 -1
  74. package/src/eval/js/context-manager.ts +228 -65
  75. package/src/eval/js/executor.ts +2 -0
  76. package/src/eval/js/index.ts +1 -0
  77. package/src/eval/js/worker-core.ts +10 -6
  78. package/src/eval/py/executor.ts +68 -19
  79. package/src/eval/py/kernel.ts +46 -22
  80. package/src/eval/py/runner.py +68 -14
  81. package/src/exec/bash-executor.ts +49 -13
  82. package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
  83. package/src/gjc-runtime/launch-tmux.ts +3 -4
  84. package/src/gjc-runtime/ralplan-runtime.ts +174 -12
  85. package/src/gjc-runtime/state-runtime.ts +2 -1
  86. package/src/gjc-runtime/state-writer.ts +254 -7
  87. package/src/gjc-runtime/tmux-gc.ts +88 -38
  88. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  89. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  90. package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
  91. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  92. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  93. package/src/harness-control-plane/owner.ts +3 -2
  94. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  95. package/src/hooks/skill-state.ts +121 -2
  96. package/src/internal-urls/artifact-protocol.ts +10 -1
  97. package/src/internal-urls/docs-index.generated.ts +14 -10
  98. package/src/lsp/client.ts +64 -26
  99. package/src/lsp/defaults.json +1 -0
  100. package/src/lsp/index.ts +2 -1
  101. package/src/lsp/lspmux.ts +33 -9
  102. package/src/lsp/types.ts +2 -0
  103. package/src/main.ts +14 -4
  104. package/src/modes/acp/acp-agent.ts +4 -2
  105. package/src/modes/bridge/bridge-mode.ts +23 -1
  106. package/src/modes/components/assistant-message.ts +10 -2
  107. package/src/modes/components/bash-execution.ts +5 -1
  108. package/src/modes/components/eval-execution.ts +5 -1
  109. package/src/modes/components/history-search.ts +5 -2
  110. package/src/modes/components/model-selector.ts +60 -2
  111. package/src/modes/components/oauth-selector.ts +5 -0
  112. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  113. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  114. package/src/modes/components/skill-message.ts +24 -16
  115. package/src/modes/components/tool-execution.ts +6 -0
  116. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  117. package/src/modes/controllers/input-controller.ts +5 -0
  118. package/src/modes/controllers/selector-controller.ts +86 -2
  119. package/src/modes/interactive-mode.ts +11 -1
  120. package/src/modes/rpc/rpc-mode.ts +132 -18
  121. package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
  122. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  123. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  124. package/src/modes/theme/defaults/claude-code.json +100 -0
  125. package/src/modes/theme/defaults/codex.json +100 -0
  126. package/src/modes/theme/defaults/index.ts +6 -0
  127. package/src/modes/theme/defaults/opencode.json +102 -0
  128. package/src/modes/theme/theme.ts +2 -2
  129. package/src/modes/types.ts +1 -1
  130. package/src/modes/utils/ui-helpers.ts +5 -2
  131. package/src/prompts/agents/executor.md +5 -2
  132. package/src/runtime/process-lifecycle.ts +400 -0
  133. package/src/runtime-mcp/manager.ts +164 -50
  134. package/src/runtime-mcp/transports/http.ts +12 -11
  135. package/src/runtime-mcp/transports/stdio.ts +64 -38
  136. package/src/runtime-mcp/types.ts +3 -0
  137. package/src/sdk.ts +39 -1
  138. package/src/session/agent-session.ts +190 -33
  139. package/src/session/artifacts.ts +17 -2
  140. package/src/session/blob-store.ts +36 -2
  141. package/src/session/history-storage.ts +32 -11
  142. package/src/session/session-manager.ts +99 -31
  143. package/src/session/streaming-output.ts +54 -3
  144. package/src/setup/credential-import.ts +429 -0
  145. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  146. package/src/slash-commands/builtin-registry.ts +30 -3
  147. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  148. package/src/task/executor.ts +7 -1
  149. package/src/task/render.ts +18 -7
  150. package/src/tools/archive-reader.ts +10 -1
  151. package/src/tools/ask.ts +4 -2
  152. package/src/tools/bash.ts +11 -4
  153. package/src/tools/browser/tab-supervisor.ts +22 -0
  154. package/src/tools/browser.ts +38 -4
  155. package/src/tools/cron.ts +1 -1
  156. package/src/tools/read.ts +11 -12
  157. package/src/tools/sqlite-reader.ts +19 -5
  158. package/src/tools/subagent-render.ts +119 -29
  159. package/src/tools/subagent.ts +147 -7
  160. package/src/tools/ultragoal-ask-guard.ts +39 -0
  161. package/src/web/search/index.ts +25 -25
  162. package/src/web/search/provider.ts +178 -87
  163. package/src/web/search/providers/base.ts +2 -1
  164. package/src/web/search/providers/openai-compatible.ts +151 -0
  165. 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
  }
@@ -315,7 +315,16 @@ export async function openArchive(filePath: string): Promise<ArchiveReader> {
315
315
  throw new ToolError(`Unsupported archive format: ${filePath}`);
316
316
  }
317
317
 
318
- const bytes = await Bun.file(filePath).bytes();
318
+ // F20: cap the compressed archive read so opening a multi-GB archive cannot buffer it
319
+ // whole into memory. (Zip-bomb expanded-size bounding would need a streaming inflate.)
320
+ const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024;
321
+ const file = Bun.file(filePath);
322
+ if (file.size > MAX_ARCHIVE_BYTES) {
323
+ throw new ToolError(
324
+ `Archive too large to open: ${filePath} is ${file.size} bytes (limit ${MAX_ARCHIVE_BYTES}). Extract a subset with a dedicated tool.`,
325
+ );
326
+ }
327
+ const bytes = await file.bytes();
319
328
  const entries = format === "zip" ? await readZipEntries(bytes) : await readTarEntries(bytes);
320
329
  return new ArchiveReader(format, entries);
321
330
  }
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/bash.ts CHANGED
@@ -37,8 +37,13 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
37
37
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
38
38
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
39
39
 
40
- async function saveBashOriginalArtifact(session: ToolSession, originalText: string): Promise<string | undefined> {
40
+ export async function saveBashOriginalArtifactForTests(
41
+ session: ToolSession,
42
+ originalText: string,
43
+ ): Promise<string | undefined> {
41
44
  try {
45
+ const manager = session.getArtifactManager?.();
46
+ if (manager) return await manager.save(originalText, "bash-original");
42
47
  const alloc = await session.allocateOutputArtifact?.("bash-original");
43
48
  if (!alloc?.path || !alloc.id) return undefined;
44
49
  await Bun.write(alloc.path, originalText);
@@ -375,6 +380,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
375
380
  env: options.resolvedEnv,
376
381
  artifactPath,
377
382
  artifactId,
383
+ oneShot: true,
378
384
  onChunk: chunk => {
379
385
  tailBuffer.append(chunk);
380
386
  latestText = tailBuffer.text();
@@ -387,7 +393,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
387
393
  // path above.
388
394
  manager.appendOutput(jobId, chunk);
389
395
  },
390
- onMinimizedSave: originalText => saveBashOriginalArtifact(this.session, originalText),
396
+ onMinimizedSave: originalText => saveBashOriginalArtifactForTests(this.session, originalText),
391
397
  });
392
398
  const finalResult = this.#buildCompletedResult(result, options.timeoutSec, {
393
399
  requestedTimeoutSec: options.requestedTimeoutSec,
@@ -675,6 +681,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
675
681
  env: prepared.resolvedEnv,
676
682
  artifactPath,
677
683
  artifactId,
684
+ oneShot: true,
678
685
  onChunk: chunk => {
679
686
  tailBuffer.append(chunk);
680
687
  void reportProgress(tailBuffer.text(), {
@@ -688,7 +695,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
688
695
  cursorOffset = slice.nextOffset;
689
696
  dispatchLines(slice.text);
690
697
  },
691
- onMinimizedSave: originalText => saveBashOriginalArtifact(this.session, originalText),
698
+ onMinimizedSave: originalText => saveBashOriginalArtifactForTests(this.session, originalText),
692
699
  });
693
700
  flushTrailingLine();
694
701
  this.#buildResultText(result, prepared.timeoutSec, result.output || "(no output)");
@@ -996,7 +1003,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
996
1003
  artifactPath,
997
1004
  artifactId,
998
1005
  onChunk: streamTailUpdates(tailBuffer, onUpdate),
999
- onMinimizedSave: originalText => saveBashOriginalArtifact(this.session, originalText),
1006
+ onMinimizedSave: originalText => saveBashOriginalArtifactForTests(this.session, originalText),
1000
1007
  });
1001
1008
  if (result.cancelled) {
1002
1009
  if (signal?.aborted) {
@@ -55,6 +55,8 @@ export interface TabSession {
55
55
  pending: Map<string, PendingRun>;
56
56
  dialogPolicy?: DialogPolicy;
57
57
  kindTag: BrowserKindTag;
58
+ /** Session that acquired this tab; used for session-scoped teardown (F13). */
59
+ ownerId?: string;
58
60
  }
59
61
 
60
62
  export interface AcquireTabOptions {
@@ -65,6 +67,8 @@ export interface AcquireTabOptions {
65
67
  signal?: AbortSignal;
66
68
  timeoutMs: number;
67
69
  dialogs?: DialogPolicy;
70
+ /** Owning session id so dispose can release only this session's tabs (F13). */
71
+ ownerId?: string;
68
72
  }
69
73
 
70
74
  export interface AcquireTabResult {
@@ -161,6 +165,7 @@ export async function acquireTab(
161
165
  pending: new Map(),
162
166
  dialogPolicy: opts.dialogs,
163
167
  kindTag: browser.kind.kind,
168
+ ownerId: opts.ownerId,
164
169
  };
165
170
  worker.onMessage(msg => handleTabMessage(tab, msg));
166
171
  tabs.set(name, tab);
@@ -254,6 +259,23 @@ export async function releaseAllTabs(opts: ReleaseTabOptions = {}): Promise<numb
254
259
  return count;
255
260
  }
256
261
 
262
+ /**
263
+ * Release only the tabs owned by `ownerId` (F13 session-scoped teardown). Tabs acquired
264
+ * by other sessions (or with no owner) are left untouched. No-op for a null/empty owner.
265
+ */
266
+ export async function releaseTabsForOwner(
267
+ ownerId: string | null | undefined,
268
+ opts: ReleaseTabOptions = {},
269
+ ): Promise<number> {
270
+ if (!ownerId) return 0;
271
+ const names = [...tabs.entries()].filter(([, tab]) => tab.ownerId === ownerId).map(([name]) => name);
272
+ let count = 0;
273
+ for (const name of names) {
274
+ if (await releaseTab(name, opts)) count++;
275
+ }
276
+ return count;
277
+ }
278
+
257
279
  export async function dropHeadlessTabs(): Promise<void> {
258
280
  const names = [...tabs.values()].filter(tab => tab.kindTag === "headless").map(tab => tab.name);
259
281
  for (const name of names) await releaseTab(name);
@@ -213,6 +213,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
213
213
 
214
214
  const result = await untilAborted(signal, () =>
215
215
  acquireTab(name, browser, {
216
+ ownerId: this.session.getSessionId?.() ?? undefined,
216
217
  url: params.url,
217
218
  waitUntil: params.wait_until,
218
219
  viewport: params.viewport
@@ -381,11 +382,44 @@ function sameBrowserKind(a: BrowserKind, b: BrowserKind): boolean {
381
382
  return false;
382
383
  }
383
384
 
385
+ /** Max chars of a browser return value surfaced into the tool result (F22). */
386
+ const MAX_BROWSER_RETURN_CHARS = 256 * 1024;
387
+
388
+ const BROWSER_RETURN_BUDGET_EXCEEDED = Symbol("browser-return-budget-exceeded");
389
+
390
+ /** Hard-cap any surfaced browser return string at the byte/char limit with a notice. */
391
+ function capBrowserReturn(text: string): string {
392
+ if (text.length <= MAX_BROWSER_RETURN_CHARS) return text;
393
+ return `${text.slice(0, MAX_BROWSER_RETURN_CHARS)}\n\n[Browser return value truncated: ${text.length} chars exceeds the ${MAX_BROWSER_RETURN_CHARS}-char cap.]`;
394
+ }
395
+
384
396
  function stringifyReturnValue(value: unknown): string {
385
- if (typeof value === "string") return value;
397
+ if (typeof value === "string") return capBrowserReturn(value);
398
+ // F22: bound the serialization itself — the replacer tracks running size and aborts early so a
399
+ // huge object/array cannot build megabytes before truncation — AND hard-cap the final string,
400
+ // since pretty-print structural overhead (indent/braces/commas) is not counted by the budget.
401
+ let budget = MAX_BROWSER_RETURN_CHARS;
386
402
  try {
387
- return JSON.stringify(value, null, 2) ?? String(value);
388
- } catch {
389
- return String(value);
403
+ const text = JSON.stringify(
404
+ value,
405
+ (_key, val) => {
406
+ if (typeof val === "string") budget -= val.length + 4;
407
+ else if (typeof val === "number" || typeof val === "boolean") budget -= 8;
408
+ else budget -= 2;
409
+ if (budget < 0) throw BROWSER_RETURN_BUDGET_EXCEEDED;
410
+ return val;
411
+ },
412
+ 2,
413
+ );
414
+ return text === undefined ? capBrowserReturn(String(value)) : capBrowserReturn(text);
415
+ } catch (error) {
416
+ if (error === BROWSER_RETURN_BUDGET_EXCEEDED) {
417
+ return `[Browser return value too large to serialize (exceeds the ${MAX_BROWSER_RETURN_CHARS}-char cap). Return a smaller or summarized value from the page script.]`;
418
+ }
419
+ try {
420
+ return capBrowserReturn(String(value));
421
+ } catch {
422
+ return "[unserializable browser return value]";
423
+ }
390
424
  }
391
425
  }
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;
package/src/tools/read.ts CHANGED
@@ -1270,19 +1270,18 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1270
1270
  }
1271
1271
  case "raw": {
1272
1272
  const result = executeReadQuery(db, selector.sql);
1273
+ const table = renderTable(result.columns, result.rows, {
1274
+ totalCount: result.rows.length,
1275
+ offset: 0,
1276
+ limit: result.rows.length || DEFAULT_MAX_LINES,
1277
+ table: "query",
1278
+ dbPath: resolvedSqlitePath.absolutePath,
1279
+ });
1280
+ const body = result.truncated
1281
+ ? `${table}\n\n[Output truncated to the first ${result.rows.length} rows; add a LIMIT clause to the query to bound or page the result.]`
1282
+ : table;
1273
1283
  return toolResult<ReadToolDetails>(details)
1274
- .text(
1275
- prependSuffixResolutionNotice(
1276
- renderTable(result.columns, result.rows, {
1277
- totalCount: result.rows.length,
1278
- offset: 0,
1279
- limit: result.rows.length || DEFAULT_MAX_LINES,
1280
- table: "query",
1281
- dbPath: resolvedSqlitePath.absolutePath,
1282
- }),
1283
- resolvedSqlitePath.suffixResolution,
1284
- ),
1285
- )
1284
+ .text(prependSuffixResolutionNotice(body, resolvedSqlitePath.suffixResolution))
1286
1285
  .sourcePath(resolvedSqlitePath.absolutePath)
1287
1286
  .done();
1288
1287
  }
@@ -590,15 +590,29 @@ export function getRowByRowId(db: Database, table: string, key: string): Record<
590
590
  .get(binding);
591
591
  }
592
592
 
593
- export function executeReadQuery(db: Database, sql: string): { columns: string[]; rows: Record<string, unknown>[] } {
593
+ const MAX_RAW_QUERY_ROWS = 1000;
594
+
595
+ export function executeReadQuery(
596
+ db: Database,
597
+ sql: string,
598
+ maxRows: number = MAX_RAW_QUERY_ROWS,
599
+ ): { columns: string[]; rows: Record<string, unknown>[]; truncated: boolean } {
594
600
  const statement = db.prepare<SqliteRow, []>(sql);
595
601
  if (statement.paramsCount > 0) {
596
602
  throw new ToolError("SQLite raw queries do not support bound parameters");
597
603
  }
598
- return {
599
- columns: [...statement.columnNames],
600
- rows: statement.all(),
601
- };
604
+ // Stream rows and stop at the cap (F20): a raw `q=SELECT ...` over a huge table
605
+ // must not materialize every row into memory via statement.all().
606
+ const rows: Record<string, unknown>[] = [];
607
+ let truncated = false;
608
+ for (const row of statement.iterate()) {
609
+ if (rows.length >= maxRows) {
610
+ truncated = true;
611
+ break;
612
+ }
613
+ rows.push(row as Record<string, unknown>);
614
+ }
615
+ return { columns: [...statement.columnNames], rows, truncated };
602
616
  }
603
617
 
604
618
  export function insertRow(db: Database, table: string, data: Record<string, unknown>): void {
@@ -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
  },