@gajae-code/coding-agent 0.12.18 → 0.12.20

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 (100) hide show
  1. package/CHANGELOG.md +26 -9
  2. package/dist/types/commands/gc.d.ts +4 -0
  3. package/dist/types/commands/sdk.d.ts +66 -0
  4. package/dist/types/config/settings-schema.d.ts +34 -0
  5. package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
  6. package/dist/types/modes/acp/acp-agent.d.ts +33 -1
  7. package/dist/types/modes/components/bash-execution.d.ts +7 -0
  8. package/dist/types/modes/components/eval-execution.d.ts +7 -0
  9. package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +9 -17
  11. package/dist/types/sdk/acp/index.d.ts +1 -1
  12. package/dist/types/sdk/broker/broker.d.ts +93 -1
  13. package/dist/types/sdk/broker/ensure.d.ts +17 -0
  14. package/dist/types/sdk/broker/index.d.ts +1 -0
  15. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
  16. package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
  17. package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
  18. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  19. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
  20. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +20 -9
  22. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
  23. package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -2
  25. package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
  26. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  27. package/dist/types/sdk/lifecycle-session.d.ts +13 -2
  28. package/dist/types/sdk/prompt-failure.d.ts +13 -0
  29. package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
  30. package/dist/types/sdk/session-reconnect.d.ts +20 -0
  31. package/dist/types/sdk/startup-capability.d.ts +1 -1
  32. package/dist/types/session/agent-session.d.ts +14 -4
  33. package/dist/types/session/blob-store.d.ts +32 -0
  34. package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
  35. package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
  36. package/dist/types/session/session-manager.d.ts +301 -27
  37. package/dist/types/session/session-storage.d.ts +170 -0
  38. package/dist/types/task/executor.d.ts +2 -1
  39. package/dist/types/tools/bash.d.ts +1 -0
  40. package/dist/types/tui/output-block.d.ts +6 -0
  41. package/package.json +8 -8
  42. package/src/cli/args.ts +75 -34
  43. package/src/commands/acp.ts +7 -1
  44. package/src/commands/gc.ts +31 -2
  45. package/src/commands/sdk.ts +141 -7
  46. package/src/config/settings-schema.ts +43 -0
  47. package/src/coordinator-mcp/server.ts +6 -1
  48. package/src/export/html/index.ts +83 -39
  49. package/src/gjc-runtime/gc-runtime.ts +1283 -4
  50. package/src/internal-urls/docs-index.generated.ts +1 -1
  51. package/src/internal-urls/local-protocol.ts +14 -0
  52. package/src/main.ts +40 -9
  53. package/src/modes/acp/acp-agent.ts +564 -37
  54. package/src/modes/acp/terminal-auth.ts +2 -2
  55. package/src/modes/components/bash-execution.ts +14 -0
  56. package/src/modes/components/eval-execution.ts +14 -0
  57. package/src/modes/controllers/command-controller.ts +63 -8
  58. package/src/modes/utils/ui-helpers.ts +81 -11
  59. package/src/sdk/acp/adapter.ts +46 -45
  60. package/src/sdk/acp/index.ts +1 -1
  61. package/src/sdk/broker/broker.ts +392 -11
  62. package/src/sdk/broker/ensure.ts +131 -61
  63. package/src/sdk/broker/index.ts +1 -0
  64. package/src/sdk/broker/lifecycle-ledger.ts +59 -5
  65. package/src/sdk/broker/lifecycle.ts +278 -63
  66. package/src/sdk/broker/startup-budget.ts +103 -0
  67. package/src/sdk/bus/chat-daemon-control.ts +9 -3
  68. package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
  69. package/src/sdk/bus/conversation-store.ts +1 -1
  70. package/src/sdk/bus/discord-daemon.ts +2 -1
  71. package/src/sdk/bus/discord-live-provider.ts +34 -6
  72. package/src/sdk/bus/index.ts +118 -20
  73. package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
  74. package/src/sdk/bus/prompt-reconciliation.ts +4 -9
  75. package/src/sdk/bus/slack-daemon.ts +91 -18
  76. package/src/sdk/bus/telegram-daemon-contract.ts +4 -2
  77. package/src/sdk/bus/topic-registry.ts +7 -1
  78. package/src/sdk/cli/session-cli.ts +8 -1
  79. package/src/sdk/host/query/handlers.ts +1 -1
  80. package/src/sdk/host/query/revision-store.ts +48 -12
  81. package/src/sdk/host/session-runtime.test.ts +50 -0
  82. package/src/sdk/host/session-runtime.ts +12 -2
  83. package/src/sdk/lifecycle-session.ts +26 -3
  84. package/src/sdk/mcp/server.ts +6 -1
  85. package/src/sdk/prompt-failure.ts +42 -0
  86. package/src/sdk/prompt-watchdog.ts +162 -0
  87. package/src/sdk/session-reconnect.ts +43 -0
  88. package/src/sdk/startup-capability.ts +13 -4
  89. package/src/session/agent-session.ts +525 -222
  90. package/src/session/blob-store.ts +89 -0
  91. package/src/session/internal/managed-session-scope.ts +36 -26
  92. package/src/session/internal/managed-session-storage.ts +303 -32
  93. package/src/session/internal/session-memory-sidecar.ts +2160 -0
  94. package/src/session/session-manager.ts +7027 -394
  95. package/src/session/session-storage.ts +1134 -11
  96. package/src/task/executor.ts +17 -4
  97. package/src/tools/bash.ts +109 -7
  98. package/src/tools/find.ts +55 -26
  99. package/src/tools/todo-contract.ts +40 -8
  100. package/src/tui/output-block.ts +11 -1
@@ -41,7 +41,10 @@ import type { ArtifactManager } from "../session/artifacts";
41
41
  import type { AuthStorage } from "../session/auth-storage";
42
42
  import { SKILL_PROMPT_MESSAGE_TYPE } from "../session/messages";
43
43
  import { SessionManager } from "../session/session-manager";
44
+ import { FileSessionStorage } from "../session/session-storage";
44
45
  import { truncateTail } from "../session/streaming-output";
46
+ // Ensure mandatory subagent result extraction is available even when a session is mocked.
47
+ import "../tools/yield";
45
48
  import type { ContextFileEntry } from "../tools";
46
49
  import { jtdToJsonSchema, normalizeSchema } from "../tools/jtd-to-json-schema";
47
50
  import type { ReportFindingDetails } from "../tools/review";
@@ -285,7 +288,7 @@ export class ManagedTaskPersistence {
285
288
  throw new Error("Managed task persistence authority is unavailable");
286
289
  }
287
290
 
288
- async openSession(cwd: string): Promise<SessionManager> {
291
+ async openSession(cwd: string, sessionMemoryMode: "off" | "shadow" | "enabled" = "shadow"): Promise<SessionManager> {
289
292
  const store = this.#artifacts.getManagedStore();
290
293
  if (!store) throw new Error("Managed task persistence authority is unavailable");
291
294
  this.#artifacts.assertManagedBinding();
@@ -296,6 +299,7 @@ export class ManagedTaskPersistence {
296
299
  store,
297
300
  undefined,
298
301
  cwd,
302
+ sessionMemoryMode,
299
303
  );
300
304
  this.#artifacts.assertManagedBinding();
301
305
  return session;
@@ -1509,7 +1513,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1509
1513
  options.parentActiveModelPattern,
1510
1514
  modelRegistry,
1511
1515
  settings,
1512
- options.parentSessionId,
1516
+ canonicalChildScope,
1513
1517
  { managedFallback: true },
1514
1518
  canonicalChildScope,
1515
1519
  ),
@@ -1555,10 +1559,18 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1555
1559
  effectiveThinkingLevelForWarning = effectiveThinkingLevel;
1556
1560
 
1557
1561
  const sessionManager = options.managedPersistence
1558
- ? await awaitAbortable(options.managedPersistence.openSession(worktree ?? cwd))
1562
+ ? await awaitAbortable(
1563
+ options.managedPersistence.openSession(worktree ?? cwd, subagentSettings.get("sessionMemory.mode")),
1564
+ )
1559
1565
  : sessionFile
1560
1566
  ? await awaitAbortable(
1561
- SessionManager.open(sessionFile, SessionManager.explicitDestination(path.dirname(sessionFile))),
1567
+ SessionManager.open(
1568
+ sessionFile,
1569
+ SessionManager.explicitDestination(path.dirname(sessionFile)),
1570
+ new FileSessionStorage(),
1571
+ subagentSettings.get("session.directoryMigration") === "disabled" ? "disabled" : "copy-retain",
1572
+ subagentSettings.get("sessionMemory.mode"),
1573
+ ),
1562
1574
  )
1563
1575
  : SessionManager.inMemory(worktree ?? cwd);
1564
1576
  if (options.parentArtifactManager) {
@@ -1649,6 +1661,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1649
1661
  authStorage,
1650
1662
  modelRegistry,
1651
1663
  settings: subagentSettings,
1664
+ providerSessionId: canonicalChildScope,
1652
1665
  model,
1653
1666
  thinkingLevel: effectiveThinkingLevel,
1654
1667
  modelSubstitution:
package/src/tools/bash.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core";
3
3
  import type { Component } from "@gajae-code/tui";
4
- import { ImageProtocol, TERMINAL, Text } from "@gajae-code/tui";
4
+ import { getKeybindings, ImageProtocol, TERMINAL, Text, visibleWidth } from "@gajae-code/tui";
5
5
  import { getProjectDir, isEnoent, logger, prompt } from "@gajae-code/utils";
6
6
  import * as z from "zod/v4";
7
7
  import { AsyncJobManager } from "../async";
@@ -32,7 +32,8 @@ import {
32
32
  } from "../session/streaming-output";
33
33
 
34
34
  import { renderStatusLine } from "../tui";
35
- import { CachedOutputBlock } from "../tui/output-block";
35
+ import { CachedOutputBlock, getOutputBlockContentWidth } from "../tui/output-block";
36
+ import { truncateToWidth } from "../tui/utils";
36
37
  import { getSixelLineMask } from "../utils/sixel";
37
38
  import type { ToolSession } from ".";
38
39
  import { checkBashAllowedPrefixes, normalizeReadOnlyBashCommand } from "./bash-allowed-prefixes";
@@ -1635,6 +1636,7 @@ export interface ShellRendererConfig<TArgs> {
1635
1636
  resolveCommand?: (args: TArgs | undefined) => string | undefined;
1636
1637
  resolveCwd?: (args: TArgs | undefined) => string | undefined;
1637
1638
  resolveEnv?: (args: TArgs | undefined) => Record<string, string> | undefined;
1639
+ compactCommand?: boolean;
1638
1640
  }
1639
1641
 
1640
1642
  function getPartialJson<TArgs>(args: TArgs | undefined): string | undefined {
@@ -1682,6 +1684,85 @@ export function formatBashCommandLines(args: BashRenderArgs, uiTheme: Theme): st
1682
1684
  return highlightedLines.map((line, i) => (i === 0 ? `${prefix}${line}` : line));
1683
1685
  }
1684
1686
 
1687
+ const BASH_COLLAPSED_COMMAND_MAX_VISUAL_ROWS = 5;
1688
+ const BASH_COMMAND_SENTINEL_MAX_VISUAL_ROWS = 3;
1689
+
1690
+ interface BashCommandProjection {
1691
+ lines: string[];
1692
+ }
1693
+
1694
+ function resolveBashExpandKeyLabel(): string | undefined {
1695
+ return getKeybindings().getKeys("app.tools.expand")[0];
1696
+ }
1697
+
1698
+ function formatBashExpandActionHint(maxWidth?: number): string {
1699
+ const keyLabel = resolveBashExpandKeyLabel();
1700
+ const actionHint = keyLabel ? `${keyLabel} to expand` : "expand tools";
1701
+ return maxWidth !== undefined && visibleWidth(actionHint) > Math.max(1, maxWidth) ? "expand tools" : actionHint;
1702
+ }
1703
+
1704
+ function renderBashCommandProjection(
1705
+ commandLines: string[],
1706
+ width: number,
1707
+ expanded: boolean,
1708
+ actionHint: string,
1709
+ uiTheme: Theme,
1710
+ ): BashCommandProjection {
1711
+ const renderWidth = Math.max(1, width);
1712
+ const visualLines = new Text(commandLines.join("\n"), 0, 0).render(renderWidth);
1713
+ if (expanded || visualLines.length <= BASH_COLLAPSED_COMMAND_MAX_VISUAL_ROWS) {
1714
+ return { lines: visualLines };
1715
+ }
1716
+
1717
+ const retained = visualLines.slice(0, BASH_COLLAPSED_COMMAND_MAX_VISUAL_ROWS);
1718
+ const omittedVisualRows = visualLines.length - retained.length;
1719
+ let sentinel = new Text(
1720
+ uiTheme.fg("dim", `… ${omittedVisualRows} command rows omitted\n${actionHint}`),
1721
+ 0,
1722
+ 0,
1723
+ ).render(renderWidth);
1724
+
1725
+ if (sentinel.length > BASH_COMMAND_SENTINEL_MAX_VISUAL_ROWS) {
1726
+ sentinel = new Text(uiTheme.fg("dim", `… ${omittedVisualRows} rows omitted\nexpand tools`), 0, 0).render(
1727
+ renderWidth,
1728
+ );
1729
+ }
1730
+ if (sentinel.length > BASH_COMMAND_SENTINEL_MAX_VISUAL_ROWS) {
1731
+ sentinel = [
1732
+ uiTheme.fg("dim", truncateToWidth(`… +${omittedVisualRows}`, renderWidth)),
1733
+ uiTheme.fg("dim", truncateToWidth("expand tools", renderWidth)),
1734
+ ];
1735
+ }
1736
+
1737
+ return { lines: [...retained, ...sentinel] };
1738
+ }
1739
+
1740
+ function createBashCommandProjector(commandLines: string[], uiTheme: Theme) {
1741
+ let cached:
1742
+ | {
1743
+ width: number;
1744
+ expanded: boolean;
1745
+ actionHint: string;
1746
+ projection: BashCommandProjection;
1747
+ }
1748
+ | undefined;
1749
+
1750
+ return {
1751
+ render(width: number, expanded: boolean): BashCommandProjection {
1752
+ const actionHint = formatBashExpandActionHint(width);
1753
+ if (cached?.width === width && cached.expanded === expanded && cached.actionHint === actionHint) {
1754
+ return cached.projection;
1755
+ }
1756
+ const projection = renderBashCommandProjection(commandLines, width, expanded, actionHint, uiTheme);
1757
+ cached = { width, expanded, actionHint, projection };
1758
+ return projection;
1759
+ },
1760
+ invalidate(): void {
1761
+ cached = undefined;
1762
+ },
1763
+ };
1764
+ }
1765
+
1685
1766
  function toBashRenderArgs<TArgs>(args: TArgs | undefined, config: ShellRendererConfig<TArgs>): BashRenderArgs {
1686
1767
  return {
1687
1768
  command: config.resolveCommand?.(args),
@@ -1695,10 +1776,23 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1695
1776
  return {
1696
1777
  renderCall(args: TArgs, options: RenderResultOptions, uiTheme: Theme): Component {
1697
1778
  const renderArgs = toBashRenderArgs(args, config);
1698
- const cmdText = formatBashCommand(renderArgs);
1699
1779
  const title = config.resolveTitle(args, options);
1700
- const text = renderStatusLine({ icon: "pending", title, description: cmdText }, uiTheme);
1701
- return new Text(text, 0, 0);
1780
+ if (!config.compactCommand) {
1781
+ const cmdText = formatBashCommand(renderArgs);
1782
+ const text = renderStatusLine({ icon: "pending", title, description: cmdText }, uiTheme);
1783
+ return new Text(text, 0, 0);
1784
+ }
1785
+
1786
+ const header = renderStatusLine({ icon: "pending", title }, uiTheme);
1787
+ const projectCommand = createBashCommandProjector(formatBashCommandLines(renderArgs, uiTheme), uiTheme);
1788
+ const renderOptions = options as RenderResultOptions & { renderContext?: BashRenderContext };
1789
+ return {
1790
+ render: (width: number): string[] => {
1791
+ const expanded = renderOptions.renderContext?.expanded ?? renderOptions.expanded;
1792
+ return [header, ...projectCommand.render(width, expanded).lines];
1793
+ },
1794
+ invalidate: () => projectCommand.invalidate(),
1795
+ };
1702
1796
  },
1703
1797
 
1704
1798
  renderResult(
@@ -1713,6 +1807,8 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1713
1807
  ): Component {
1714
1808
  const renderArgs = toBashRenderArgs(args, config);
1715
1809
  const cmdLines = args ? formatBashCommandLines(renderArgs, uiTheme) : undefined;
1810
+ const projectCommand =
1811
+ config.compactCommand && cmdLines ? createBashCommandProjector(cmdLines, uiTheme) : undefined;
1716
1812
  const isError = result.isError === true;
1717
1813
  const icon = options.isPartial ? "pending" : isError ? "error" : "success";
1718
1814
  const title = config.resolveTitle(args, options);
@@ -1726,6 +1822,10 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1726
1822
  const { renderContext } = options;
1727
1823
  const expanded = renderContext?.expanded ?? options.expanded;
1728
1824
  const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES;
1825
+ const commandLines =
1826
+ cmdLines && projectCommand
1827
+ ? projectCommand.render(getOutputBlockContentWidth(width, uiTheme), expanded).lines
1828
+ : (cmdLines ?? []);
1729
1829
 
1730
1830
  // Get output from context (preferred) or fall back to result content.
1731
1831
  // Strip the LLM-facing notice appended by wrappedExecute so we don't
@@ -1778,7 +1878,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1778
1878
  outputLines.push(
1779
1879
  uiTheme.fg(
1780
1880
  "dim",
1781
- `… (${result.skippedCount} earlier lines, showing ${result.visualLines.length} of ${result.skippedCount + result.visualLines.length}) (ctrl+o to expand)`,
1881
+ `… (${result.skippedCount} earlier lines, showing ${result.visualLines.length} of ${result.skippedCount + result.visualLines.length}) (${formatBashExpandActionHint(getOutputBlockContentWidth(width, uiTheme))})`,
1782
1882
  ),
1783
1883
  );
1784
1884
  }
@@ -1793,7 +1893,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1793
1893
  header,
1794
1894
  state: options.isPartial ? "pending" : isError ? "error" : "success",
1795
1895
  sections: [
1796
- { lines: cmdLines ?? [] },
1896
+ { lines: commandLines },
1797
1897
  { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
1798
1898
  ],
1799
1899
  width,
@@ -1803,6 +1903,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1803
1903
  },
1804
1904
  invalidate: () => {
1805
1905
  outputBlock.invalidate();
1906
+ projectCommand?.invalidate();
1806
1907
  },
1807
1908
  };
1808
1909
  },
@@ -1816,4 +1917,5 @@ export const bashToolRenderer = createShellRenderer<BashRenderArgs>({
1816
1917
  resolveCommand: args => args?.command,
1817
1918
  resolveCwd: args => args?.cwd,
1818
1919
  resolveEnv: args => args?.env,
1920
+ compactCommand: true,
1819
1921
  });
package/src/tools/find.ts CHANGED
@@ -59,6 +59,25 @@ const DEFAULT_GLOB_TIMEOUT_MS = 5000;
59
59
  const MIN_GLOB_TIMEOUT_MS = 500;
60
60
  const MAX_GLOB_TIMEOUT_MS = 60_000;
61
61
 
62
+ function comparePaths(left: string, right: string): number {
63
+ let leftIndex = 0;
64
+ let rightIndex = 0;
65
+ while (leftIndex < left.length && rightIndex < right.length) {
66
+ const leftCodePoint = left.codePointAt(leftIndex);
67
+ const rightCodePoint = right.codePointAt(rightIndex);
68
+ if (leftCodePoint === undefined || rightCodePoint === undefined) break;
69
+ if (leftCodePoint !== rightCodePoint) return leftCodePoint - rightCodePoint;
70
+ leftIndex += leftCodePoint > 0xffff ? 2 : 1;
71
+ rightIndex += rightCodePoint > 0xffff ? 2 : 1;
72
+ }
73
+ return left.length - right.length;
74
+ }
75
+
76
+ function compareMtimePath(left: { path: string; mtime?: number }, right: { path: string; mtime?: number }): number {
77
+ const mtimeOrder = (right.mtime ?? 0) - (left.mtime ?? 0);
78
+ return mtimeOrder || comparePaths(left.path, right.path);
79
+ }
80
+
62
81
  /**
63
82
  * Reject comma-separated path lists packed into a single array element
64
83
  * (`["a.py,b.py"]`). The schema is array-of-string; agents that pass a
@@ -317,29 +336,43 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
317
336
  }
318
337
 
319
338
  let matches: natives.GlobMatch[];
320
- const onUpdateMatches: string[] = [];
339
+ const onUpdateMatches = new Map<string, number>();
340
+ const rankedUpdateMatches = (): Array<{ path: string; mtime: number }> =>
341
+ Array.from(onUpdateMatches, ([matchPath, mtime]) => ({ path: matchPath, mtime })).sort(compareMtimePath);
342
+ const retainUpdateMatch = (matchPath: string, mtime: number) => {
343
+ onUpdateMatches.set(matchPath, mtime);
344
+ if (onUpdateMatches.size <= effectiveLimit) return;
345
+ let worst: { path: string; mtime: number } | undefined;
346
+ for (const [candidatePath, candidateMtime] of onUpdateMatches) {
347
+ const candidate = { path: candidatePath, mtime: candidateMtime };
348
+ if (!worst || compareMtimePath(candidate, worst) > 0) worst = candidate;
349
+ }
350
+ if (worst) onUpdateMatches.delete(worst.path);
351
+ };
321
352
  const updateIntervalMs = 200;
322
353
  let lastUpdate = 0;
354
+ let terminal = false;
323
355
  const emitUpdate = () => {
324
356
  if (!onUpdate) return;
325
357
  const now = Date.now();
326
358
  if (now - lastUpdate < updateIntervalMs) return;
327
359
  lastUpdate = now;
360
+ const ranked = rankedUpdateMatches().map(entry => entry.path);
328
361
  const details: FindToolDetails = {
329
362
  scopePath,
330
- fileCount: onUpdateMatches.length,
331
- files: onUpdateMatches.slice(),
363
+ fileCount: ranked.length,
364
+ files: ranked,
332
365
  truncated: false,
333
366
  };
334
367
  onUpdate({
335
- content: [{ type: "text", text: onUpdateMatches.join("\n") }],
368
+ content: [{ type: "text", text: ranked.join("\n") }],
336
369
  details,
337
370
  });
338
371
  };
339
372
  const onMatch = (err: Error | null, match: natives.GlobMatch | null) => {
340
- if (err || signal?.aborted || !match?.path) return;
373
+ if (err || terminal || combinedSignal.aborted || !match?.path) return;
341
374
  const relativePath = formatMatchPath(match.path, match.fileType);
342
- onUpdateMatches.push(relativePath);
375
+ retainUpdateMatch(relativePath, match.mtime ?? 0);
343
376
  emitUpdate();
344
377
  };
345
378
 
@@ -355,6 +388,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
355
388
  sortByMtime: true,
356
389
  gitignore: useGitignore,
357
390
  signal: combinedSignal,
391
+ timeoutMs,
358
392
  },
359
393
  onMatch,
360
394
  ),
@@ -363,34 +397,29 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
363
397
  let timedOut = false;
364
398
  try {
365
399
  const result = await doGlob(useGitignore);
366
- // Sort by mtime descending (most recent first) in JS instead of native.
367
- // This allows native glob to early-terminate at maxResults.
368
- result.matches.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
400
+ // Native uncached glob retains a bounded newest set; keep the same tie-break after callback delivery.
401
+ result.matches.sort(compareMtimePath);
369
402
  matches = result.matches;
370
403
  } catch (error) {
371
- if (error instanceof Error && error.name === "AbortError") {
372
- if (timeoutSignal.aborted && !signal?.aborted) {
373
- timedOut = true;
374
- matches = [];
375
- } else {
376
- throw new ToolAbortError();
377
- }
404
+ const nativeTimedOut = error instanceof Error && error.message === "Aborted: Timeout";
405
+ if (
406
+ (error instanceof Error && error.name === "AbortError" && timeoutSignal.aborted && !signal?.aborted) ||
407
+ (nativeTimedOut && !signal?.aborted)
408
+ ) {
409
+ timedOut = true;
410
+ matches = [];
411
+ } else if (error instanceof Error && error.name === "AbortError") {
412
+ throw new ToolAbortError();
378
413
  } else {
379
414
  throw error;
380
415
  }
416
+ } finally {
417
+ terminal = true;
381
418
  }
382
419
 
383
420
  if (timedOut) {
384
- // Drain the partial matches accumulated during streaming and return them
385
- // instead of throwing — empty results after a multi-second wait force the
386
- // caller to retry blind, which is the worst possible outcome.
387
- const seen = new Set<string>();
388
- const partial: string[] = [];
389
- for (const entry of onUpdateMatches) {
390
- if (seen.has(entry)) continue;
391
- seen.add(entry);
392
- partial.push(entry);
393
- }
421
+ // Return the bounded, deterministically ranked candidates observed before timeout.
422
+ const partial = rankedUpdateMatches().map(entry => entry.path);
394
423
  const seconds = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}` : (timeoutMs / 1000).toFixed(1);
395
424
  const notice = `find timed out after ${seconds}s; returning ${partial.length} partial matches — increase timeout or narrow pattern`;
396
425
  return buildResult(partial, { notice, forceTruncated: true });
@@ -8,7 +8,7 @@
8
8
  * tool ever loaded, with no correction hint to recover from.
9
9
  */
10
10
  import { INTENT_FIELD } from "@gajae-code/agent-core";
11
- import type { RawArgumentValidationResult } from "@gajae-code/ai/types";
11
+ import type { RawArgumentRejectionDetail, RawArgumentValidationResult } from "@gajae-code/ai/types";
12
12
 
13
13
  /**
14
14
  * Models reliably reach for `complete`/`completed` because the status an op
@@ -34,8 +34,27 @@ const TODO_INIT_ENTRY_KEYS = new Set(["phase", "items"]);
34
34
  /** `content` is accepted only as a synonym the schema rewrites to `task`. */
35
35
  const TODO_OP_KEYS_WITH_ALIASES = new Set([...TODO_OP_KEYS, "content"]);
36
36
 
37
- function hasUnknownKeys(value: object, allowed: Set<string>): boolean {
38
- return Object.keys(value).some(key => !allowed.has(key));
37
+ /**
38
+ * Exact key corrections. `note` is the one repeatable confusion: it is an `op`
39
+ * value, and the body of a note op goes in `text`, so a caller who wrote
40
+ * `note: "..."` on an entry can be told exactly which fields a note op takes.
41
+ * The correction names both required fields rather than a literal rewrite,
42
+ * because a rewrite that omits `task` is rejected again on the retry. Only add
43
+ * an entry here when the replacement is unambiguous — never from fuzzy or
44
+ * edit-distance matching, because a wrong suggestion costs more turns than no
45
+ * suggestion.
46
+ */
47
+ const TODO_KEY_CORRECTIONS = new Map<string, string>([
48
+ ["note", 'note is an op, not a key; note operations require both "task" and "text"'],
49
+ ]);
50
+
51
+ function unknownKeys(value: object, allowed: Set<string>): string[] {
52
+ return Object.keys(value).filter(key => !allowed.has(key));
53
+ }
54
+
55
+ function keyRejectionDetail(keys: readonly string[]): RawArgumentRejectionDetail {
56
+ const hints = keys.map(key => TODO_KEY_CORRECTIONS.get(key)).filter((hint): hint is string => hint !== undefined);
57
+ return hints.length > 0 ? { rejectedKeys: keys, hint: hints.join("; ") } : { rejectedKeys: keys };
39
58
  }
40
59
 
41
60
  function isPlainRecord(value: unknown): value is Record<string, unknown> {
@@ -48,14 +67,21 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
48
67
  * instead of a bare "raw arguments rejected" message.
49
68
  */
50
69
  export function validateRawTodoArguments(arguments_: Record<string, unknown>): RawArgumentValidationResult {
51
- if (hasUnknownKeys(arguments_, TODO_WRITE_KEYS)) return { outcome: "reject", code: "todo-write-unknown-root-key" };
70
+ const unknownRootKeys = unknownKeys(arguments_, TODO_WRITE_KEYS);
71
+ if (unknownRootKeys.length > 0)
72
+ return { outcome: "reject", code: "todo-write-unknown-root-key", detail: keyRejectionDetail(unknownRootKeys) };
52
73
  if (!Array.isArray(arguments_.ops)) return { outcome: "passthrough" };
53
74
  for (const entry of arguments_.ops) {
54
75
  if (!isPlainRecord(entry)) continue;
55
76
  // `content` is normalized to `task` by the schema preprocessor, so it must
56
77
  // not be rejected here as an unknown key first.
57
- if (hasUnknownKeys(entry, TODO_OP_KEYS_WITH_ALIASES))
58
- return { outcome: "reject", code: "todo-write-unknown-op-entry-key" };
78
+ const unknownEntryKeys = unknownKeys(entry, TODO_OP_KEYS_WITH_ALIASES);
79
+ if (unknownEntryKeys.length > 0)
80
+ return {
81
+ outcome: "reject",
82
+ code: "todo-write-unknown-op-entry-key",
83
+ detail: keyRejectionDetail(unknownEntryKeys),
84
+ };
59
85
  const op = typeof entry.op === "string" && isDoneAlias(entry.op) ? "done" : entry.op;
60
86
  const target = entry.task ?? entry.content;
61
87
  if ((op === "done" || op === "drop") && !target && !entry.phase) {
@@ -64,8 +90,14 @@ export function validateRawTodoArguments(arguments_: Record<string, unknown>): R
64
90
  const list = entry.list;
65
91
  if (!Array.isArray(list)) continue;
66
92
  for (const item of list) {
67
- if (isPlainRecord(item) && hasUnknownKeys(item, TODO_INIT_ENTRY_KEYS))
68
- return { outcome: "reject", code: "todo-write-unknown-init-entry-key" };
93
+ if (!isPlainRecord(item)) continue;
94
+ const unknownItemKeys = unknownKeys(item, TODO_INIT_ENTRY_KEYS);
95
+ if (unknownItemKeys.length > 0)
96
+ return {
97
+ outcome: "reject",
98
+ code: "todo-write-unknown-init-entry-key",
99
+ detail: keyRejectionDetail(unknownItemKeys),
100
+ };
69
101
  }
70
102
  }
71
103
  return { outcome: "passthrough" };
@@ -23,6 +23,16 @@ export interface OutputBlockOptions {
23
23
  width: number;
24
24
  applyBg?: boolean;
25
25
  }
26
+ /**
27
+ * Return the width available to section content after the output block's
28
+ * visible left and right borders. Keep callers on the same layout contract as
29
+ * renderOutputBlock so width-aware projections are not wrapped a second time.
30
+ */
31
+ export function getOutputBlockContentWidth(width: number, theme: Theme): number {
32
+ const lineWidth = Math.max(0, width);
33
+ const borderWidth = visibleWidth(`${theme.boxSharp.vertical} `) + visibleWidth(theme.boxSharp.vertical);
34
+ return Math.max(0, lineWidth - borderWidth);
35
+ }
26
36
 
27
37
  export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] {
28
38
  const { header, headerMeta, state, sections = [], width, applyBg = true } = options;
@@ -70,7 +80,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
70
80
 
71
81
  const contentPrefix = border(`${v} `);
72
82
  const contentSuffix = border(v);
73
- const contentWidth = Math.max(0, lineWidth - visibleWidth(contentPrefix) - visibleWidth(contentSuffix));
83
+ const contentWidth = getOutputBlockContentWidth(lineWidth, theme);
74
84
  const lines: string[] = [];
75
85
 
76
86
  lines.push(