@gajae-code/coding-agent 0.16.1 → 0.16.4

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 (146) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/types/cli/args.d.ts +2 -2
  3. package/dist/types/cli/root-flags.d.ts +4 -2
  4. package/dist/types/cli.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +1 -1
  6. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/model-resolver.d.ts +9 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  11. package/dist/types/discovery/helpers.d.ts +2 -0
  12. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  13. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  14. package/dist/types/extensibility/runtime-skill-discovery.d.ts +1 -1
  15. package/dist/types/extensibility/shared-events.d.ts +1 -1
  16. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  17. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  18. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  20. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  21. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  22. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  23. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  24. package/dist/types/modes/interactive-mode.d.ts +1 -0
  25. package/dist/types/modes/types.d.ts +1 -0
  26. package/dist/types/runtime-mcp/client.d.ts +9 -1
  27. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  28. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  29. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  30. package/dist/types/runtime-mcp/types.d.ts +22 -0
  31. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  32. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  33. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  34. package/dist/types/sdk/cli/rows.d.ts +7 -0
  35. package/dist/types/sdk/host/events.d.ts +1 -0
  36. package/dist/types/sdk/host/host.d.ts +2 -0
  37. package/dist/types/sdk/host/session-runtime.d.ts +23 -0
  38. package/dist/types/sdk/session-api-key.d.ts +26 -0
  39. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  40. package/dist/types/session/agent-session.d.ts +9 -0
  41. package/dist/types/setup/paseo/announce.d.ts +111 -0
  42. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  43. package/dist/types/system-prompt.d.ts +2 -0
  44. package/dist/types/task/executor.d.ts +2 -1
  45. package/dist/types/tools/fetch.d.ts +7 -0
  46. package/dist/types/tools/index.d.ts +8 -2
  47. package/dist/types/tools/path-utils.d.ts +2 -0
  48. package/dist/types/tools/search.d.ts +6 -0
  49. package/dist/types/utils/external-editor.d.ts +5 -0
  50. package/package.json +7 -7
  51. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  52. package/scripts/generate-sdk-operation-inventory.ts +9 -0
  53. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  54. package/src/cli/args.ts +20 -9
  55. package/src/cli/root-flags.ts +5 -3
  56. package/src/config/autorouting-tier-map.ts +1 -0
  57. package/src/config/file-lock.ts +181 -17
  58. package/src/config/model-preset-registry.ts +5 -3
  59. package/src/config/model-profiles.ts +40 -0
  60. package/src/config/model-registry.ts +57 -11
  61. package/src/config/model-resolver.ts +48 -0
  62. package/src/config/settings-schema.ts +33 -1
  63. package/src/config/settings.ts +1 -0
  64. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  65. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  66. package/src/discovery/builtin.ts +13 -4
  67. package/src/discovery/claude-plugins.ts +12 -4
  68. package/src/discovery/helpers.ts +10 -2
  69. package/src/extensibility/gjc-plugins/compiler.ts +208 -58
  70. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  71. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  72. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  73. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  74. package/src/extensibility/runtime-skill-discovery.ts +77 -151
  75. package/src/extensibility/shared-events.ts +1 -1
  76. package/src/extensibility/skills.ts +44 -16
  77. package/src/gjc-runtime/repository-binding.ts +1 -1
  78. package/src/gjc-runtime/session-state-lock.ts +94 -6
  79. package/src/internal-urls/docs-index.generated.ts +10 -10
  80. package/src/main.ts +72 -34
  81. package/src/modes/DESIGN.md +54 -0
  82. package/src/modes/acp/acp-agent.ts +29 -14
  83. package/src/modes/components/assistant-message.ts +22 -9
  84. package/src/modes/components/model-selector.ts +166 -33
  85. package/src/modes/components/settings-selector.ts +1075 -235
  86. package/src/modes/components/status-line/command.ts +190 -0
  87. package/src/modes/components/status-line/index.ts +1 -0
  88. package/src/modes/components/status-line/segments.ts +32 -5
  89. package/src/modes/components/status-line/types.ts +5 -0
  90. package/src/modes/components/tool-status-header.ts +393 -21
  91. package/src/modes/controllers/command-controller.ts +2 -0
  92. package/src/modes/controllers/event-controller.ts +148 -5
  93. package/src/modes/controllers/selector-controller.ts +21 -0
  94. package/src/modes/interactive-mode.ts +47 -1
  95. package/src/modes/types.ts +1 -0
  96. package/src/modes/utils/ui-helpers.ts +1 -0
  97. package/src/prompts/system/system-prompt.md +8 -0
  98. package/src/prompts/tools/bash.md +1 -1
  99. package/src/prompts/tools/job.md +1 -1
  100. package/src/prompts/tools/search.md +2 -0
  101. package/src/runtime-mcp/client.ts +60 -17
  102. package/src/runtime-mcp/manager.ts +171 -14
  103. package/src/runtime-mcp/pool.test.ts +209 -0
  104. package/src/runtime-mcp/pool.ts +224 -23
  105. package/src/runtime-mcp/transports/stdio.ts +158 -24
  106. package/src/runtime-mcp/types.ts +24 -0
  107. package/src/sdk/acp/adapter.ts +1 -1
  108. package/src/sdk/broker/broker.ts +5 -4
  109. package/src/sdk/bus/chat-daemon-control.ts +1 -1
  110. package/src/sdk/bus/chat-daemon-runtime.ts +44 -1
  111. package/src/sdk/bus/slack-daemon.ts +92 -2
  112. package/src/sdk/bus/slack-live-provider.ts +12 -0
  113. package/src/sdk/bus/slack-provider.ts +5 -0
  114. package/src/sdk/cli/rows.ts +47 -2
  115. package/src/sdk/cli/session-cli.ts +47 -23
  116. package/src/sdk/host/events.ts +25 -2
  117. package/src/sdk/host/host.ts +4 -1
  118. package/src/sdk/host/session-runtime.test.ts +36 -25
  119. package/src/sdk/host/session-runtime.ts +279 -43
  120. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  121. package/src/sdk/router/session-router.ts +60 -37
  122. package/src/sdk/session-api-key.ts +94 -0
  123. package/src/sdk/session.ts +73 -30
  124. package/src/sdk/transport/serve-cli.ts +3 -3
  125. package/src/session/agent-session.ts +123 -38
  126. package/src/session/contribution-prep.ts +214 -21
  127. package/src/setup/paseo/announce.ts +450 -0
  128. package/src/setup/paseo/setup-deps.ts +15 -3
  129. package/src/slash-commands/builtin-registry.ts +1 -0
  130. package/src/system-prompt.ts +4 -0
  131. package/src/task/executor.ts +2 -1
  132. package/src/task/index.ts +17 -4
  133. package/src/tools/bash.ts +50 -21
  134. package/src/tools/cron.ts +1 -1
  135. package/src/tools/fetch.ts +29 -1
  136. package/src/tools/index.ts +8 -2
  137. package/src/tools/job.ts +8 -2
  138. package/src/tools/monitor.ts +8 -2
  139. package/src/tools/path-utils.ts +7 -0
  140. package/src/tools/search.ts +116 -58
  141. package/src/tools/skill.ts +22 -2
  142. package/src/tools/subagent.ts +3 -1
  143. package/src/tools/tool-catalog.generated.ts +9 -2
  144. package/src/utils/external-editor.ts +16 -2
  145. package/src/web/insane/bridge.ts +113 -23
  146. package/src/web/search/providers/perplexity.ts +102 -41
package/src/tools/bash.ts CHANGED
@@ -1107,7 +1107,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1107
1107
  manager.failNow(jobId, jobGeneration, "Bash job owner was torn down.");
1108
1108
  });
1109
1109
  void completion.promise.finally(unregisterOwnerCleanup);
1110
- registerOwnedIfLineaged(manager, options.toolCallId, jobId, this.session.getSessionId?.() ?? undefined);
1110
+ registerOwnedIfLineaged(
1111
+ manager,
1112
+ options.toolCallId,
1113
+ jobId,
1114
+ this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? undefined,
1115
+ );
1111
1116
 
1112
1117
  return {
1113
1118
  jobId,
@@ -1132,27 +1137,29 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1132
1137
  * running (review thread P1).
1133
1138
  */
1134
1139
  #resolveOwnedJobManager(): AsyncJobManager | undefined {
1135
- const endpointId = this.session.getSessionId?.() ?? undefined;
1140
+ const endpointId = this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? undefined;
1136
1141
  if (this.session.getAsyncJobManager) return this.session.getAsyncJobManager();
1137
1142
  return AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance();
1138
1143
  }
1139
1144
 
1140
1145
  /**
1141
1146
  * Whether a queued user steer may fold the running foreground wait. Mirrors
1142
- * the loop's own steer admission: `busyPromptMode=queue` never admits a
1143
- * steer into the busy run, and `toolInterruptPolicy=finish_tools` explicitly
1144
- * lets the tool finish, so neither may fold. The
1145
- * auto-background setting is deliberately not consulted: like the chord,
1146
- * a steer fold is a user action.
1147
+ * the loop's steer admission: `busyPromptMode=queue` never admits a steer
1148
+ * into the busy run, so it never folds. `toolInterruptPolicy` is
1149
+ * deliberately NOT a gate: `finish_tools` means "do not kill the batch to
1150
+ * deliver a steer", and a fold kills nothing. It is the one way to deliver
1151
+ * the steer now AND let the command finish, so it applies under both
1152
+ * policies; the policy only decides what the loop does with sibling tools
1153
+ * after the fold returns. The auto-background setting is likewise not
1154
+ * consulted: like the chord, a steer fold is a user action.
1147
1155
  *
1148
- * Fails closed: a session that cannot prove its tool interrupt policy or
1149
- * report newly admitted steering is not steer-foldable.
1156
+ * Fails closed: a session that cannot report newly admitted steering or
1157
+ * accept a fold request is not steer-foldable.
1150
1158
  */
1151
1159
  #steerFoldEnabled(): boolean {
1152
- const { waitForUserSteering, getToolInterruptPolicy, requestForegroundBashBackground } = this.session;
1153
- if (!waitForUserSteering || !getToolInterruptPolicy || !requestForegroundBashBackground) return false;
1154
- if (this.session.settings.get("busyPromptMode") !== "steer") return false;
1155
- return getToolInterruptPolicy() === "abort_tools";
1160
+ const { waitForUserSteering, requestForegroundBashBackground } = this.session;
1161
+ if (!waitForUserSteering || !requestForegroundBashBackground) return false;
1162
+ return this.session.settings.get("busyPromptMode") === "steer";
1156
1163
  }
1157
1164
 
1158
1165
  /**
@@ -1161,8 +1168,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1161
1168
  * when the wait starts, or one that arrives inside the grace window, never
1162
1169
  * folds: the command finishes normally and that steer is consumed at the
1163
1170
  * ordinary tool boundary. The watcher keeps observing so a later qualifying
1164
- * steer still folds, and re-checks the gates at that moment so a
1165
- * `busyPromptMode`/`toolInterruptPolicy` change during the command is honored.
1171
+ * steer still folds, and re-checks the gate at that moment so a
1172
+ * `busyPromptMode` change during the command is honored.
1166
1173
  * Returns a stop function; call it when the wait settles. No-op when steer
1167
1174
  * folding is gated off at start.
1168
1175
  */
@@ -1679,7 +1686,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1679
1686
  // Monitor jobs are exact owned background work of the turn that started
1680
1687
  // them: register the five-tuple so scope:"owned" terminal abort stops the
1681
1688
  // monitor too (review thread P2).
1682
- registerOwnedIfLineaged(manager, opts.toolCallId, jobId, this.session.getSessionId?.() ?? undefined);
1689
+ registerOwnedIfLineaged(
1690
+ manager,
1691
+ opts.toolCallId,
1692
+ jobId,
1693
+ this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? undefined,
1694
+ );
1683
1695
  currentJobId = jobId;
1684
1696
  return { jobId, label, commandCwd: prepared.commandCwd };
1685
1697
  }
@@ -1881,19 +1893,31 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1881
1893
  }
1882
1894
  if (waitResult.kind === "completed") {
1883
1895
  ownedManager.acknowledgeDeliveries([job.jobId]);
1884
- unregisterForegroundOwnedBash(ownedManager, job.jobId, this.session.getSessionId?.() ?? "local");
1896
+ unregisterForegroundOwnedBash(
1897
+ ownedManager,
1898
+ job.jobId,
1899
+ this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? "local",
1900
+ );
1885
1901
  return waitResult.result;
1886
1902
  }
1887
1903
  if (waitResult.kind === "failed") {
1888
1904
  ownedManager.acknowledgeDeliveries([job.jobId]);
1889
- unregisterForegroundOwnedBash(ownedManager, job.jobId, this.session.getSessionId?.() ?? "local");
1905
+ unregisterForegroundOwnedBash(
1906
+ ownedManager,
1907
+ job.jobId,
1908
+ this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? "local",
1909
+ );
1890
1910
  throw waitResult.error;
1891
1911
  }
1892
1912
  if (waitResult.kind === "aborted") {
1893
1913
  ownedManager.cancel(job.jobId);
1894
1914
  const terminal = await job.completion;
1895
1915
  ownedManager.acknowledgeDeliveries([job.jobId]);
1896
- unregisterForegroundOwnedBash(ownedManager, job.jobId, this.session.getSessionId?.() ?? "local");
1916
+ unregisterForegroundOwnedBash(
1917
+ ownedManager,
1918
+ job.jobId,
1919
+ this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? "local",
1920
+ );
1897
1921
  if (terminal.kind === "failed") {
1898
1922
  throw new ToolAbortError(
1899
1923
  formatManagedAbortFailure(terminal.error, terminal.result, job.getLatestText()),
@@ -2355,7 +2379,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2355
2379
  // already ran above, so exactly ONE remote handle exists and is retained
2356
2380
  // across the fold.
2357
2381
  const bridgeManager = ownedManager;
2358
- const bridgeEndpointId = this.session.getSessionId?.() ?? "local";
2382
+ const bridgeEndpointId = this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? "local";
2359
2383
  const bridgeLabel = command.length > 120 ? `${command.slice(0, 117)}...` : command;
2360
2384
  const bridgeCompletion = Promise.withResolvers<ManagedBashJobCompletion>();
2361
2385
  let bridgeForegroundSettled = false;
@@ -2619,7 +2643,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2619
2643
  throw error;
2620
2644
  }
2621
2645
  const ptyGeneration = ptyManager.getJob(ptyJobId)?.generation ?? ptyJobId;
2622
- registerOwnedIfLineaged(ptyManager, toolCallId, ptyJobId, this.session.getSessionId?.() ?? undefined);
2646
+ registerOwnedIfLineaged(
2647
+ ptyManager,
2648
+ toolCallId,
2649
+ ptyJobId,
2650
+ this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? undefined,
2651
+ );
2623
2652
  const unregisterPtyOwnerCleanup = ptyManager.registerOwnerCleanup(ptyOwnerId, () => {
2624
2653
  killPtyOnce();
2625
2654
  ptyManager.failNow(ptyJobId, ptyGeneration, ownerTeardownText());
package/src/tools/cron.ts CHANGED
@@ -592,7 +592,7 @@ export class CronTool implements AgentTool<typeof cronSchema, CronToolDetails> {
592
592
  }
593
593
 
594
594
  #resolveOwnedJobManager(): AsyncJobManager | undefined {
595
- const endpointId = this.session.getSessionId?.() ?? undefined;
595
+ const endpointId = this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? undefined;
596
596
  return (
597
597
  this.session.getAsyncJobManager?.() ?? AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance()
598
598
  );
@@ -1167,13 +1167,36 @@ interface ReadUrlCacheEntry {
1167
1167
  wrappedPreambleChars?: number;
1168
1168
  }
1169
1169
 
1170
+ // Entries hold whole-page outputs and image payloads, so this map must stay
1171
+ // bounded in long-lived hosts (TUI, SDK broker). Each fetch caches one entry
1172
+ // under up to two keys (requested + final URL). FIFO eviction is sufficient:
1173
+ // a miss simply refetches, so correctness never depends on a hit.
1174
+ export const READ_URL_CACHE_MAX_KEYS = 64;
1175
+
1170
1176
  const readUrlCache = new Map<string, ReadUrlCacheEntry>();
1171
1177
 
1172
1178
  function getReadUrlCacheKey(session: ToolSession, requestedUrl: string, raw: boolean): string {
1173
- const scope = session.getSessionFile() ?? session.cwd;
1179
+ const sessionFile = session.getSessionFile();
1180
+ const sessionId = session.getSessionId?.();
1181
+ const scope = sessionFile ? `file:${sessionFile}` : sessionId ? `session:${sessionId}` : `cwd:${session.cwd}`;
1174
1182
  return `${scope}::${raw ? "raw" : "rendered"}::${normalizeUrl(requestedUrl)}`;
1175
1183
  }
1176
1184
 
1185
+ /** Test-only seam for deterministic module-global cache assertions. */
1186
+ export const readUrlCacheTestHooks = {
1187
+ get size(): number {
1188
+ return readUrlCache.size;
1189
+ },
1190
+ get retainedOutputChars(): number {
1191
+ let total = 0;
1192
+ for (const entry of new Set(readUrlCache.values())) total += entry.output.length;
1193
+ return total;
1194
+ },
1195
+ reset(): void {
1196
+ readUrlCache.clear();
1197
+ },
1198
+ };
1199
+
1177
1200
  async function readArtifactOutput(session: ToolSession, artifactId: string): Promise<string | null> {
1178
1201
  const artifactsDir = session.getArtifactsDir?.();
1179
1202
  if (!artifactsDir) return null;
@@ -1224,6 +1247,11 @@ async function ensureReadUrlCacheArtifact(session: ToolSession, entry: ReadUrlCa
1224
1247
  function cacheReadUrlEntry(session: ToolSession, requestedUrl: string, raw: boolean, entry: ReadUrlCacheEntry): void {
1225
1248
  readUrlCache.set(getReadUrlCacheKey(session, requestedUrl, raw), entry);
1226
1249
  readUrlCache.set(getReadUrlCacheKey(session, entry.details.finalUrl, raw), entry);
1250
+ while (readUrlCache.size > READ_URL_CACHE_MAX_KEYS) {
1251
+ const oldest = readUrlCache.keys().next().value;
1252
+ if (oldest === undefined) break;
1253
+ readUrlCache.delete(oldest);
1254
+ }
1227
1255
  }
1228
1256
 
1229
1257
  async function buildReadUrlCacheEntry(
@@ -242,8 +242,6 @@ export interface ToolSession {
242
242
  hasForegroundBashBackgroundRequestHandler?: () => boolean;
243
243
  /** Request that a foreground wait fold into a background job, if supported. `reason` names the trigger; `adapter` targets a specific wait instead of the newest registration. */
244
244
  requestForegroundBashBackground?: (reason?: FoldReason, adapter?: FoldAdapter) => Promise<boolean>;
245
- /** Current tool interrupt policy; `finish_tools` lets the running tool finish, so a steer must not fold it. */
246
- getToolInterruptPolicy?: () => "abort_tools" | "finish_tools";
247
245
 
248
246
  /** Get the session-owned or inherited async job manager. */
249
247
  getAsyncJobManager?: () => AsyncJobManager | undefined;
@@ -251,6 +249,14 @@ export interface ToolSession {
251
249
  waitForUserSteering?: (signal: AbortSignal) => Promise<void>;
252
250
  /** Get session ID */
253
251
  getSessionId?: () => string | null;
252
+ /**
253
+ * Opaque async-job ownership/lookup endpoint key. This is not a workflow or
254
+ * path identity: workflow/session consumers must use getSessionId(). Only
255
+ * AsyncJobManager ownership and endpoint-first lookup may use this accessor
256
+ * (subagent-inherited managers are registered under the parent's endpoint;
257
+ * review thread P1).
258
+ */
259
+ getAsyncEndpointId?: () => string | null;
254
260
  /** Get credential-selection session identity. */
255
261
  getCredentialSessionId?: () => string | null;
256
262
  /** Scope-held MCP facade for mcp:// resolution. */
package/src/tools/job.ts CHANGED
@@ -105,7 +105,9 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
105
105
  ): Promise<AgentToolResult<JobToolDetails>> {
106
106
  const manager =
107
107
  this.session.getAsyncJobManager?.() ??
108
- AsyncJobManager.forEndpoint(this.session.getSessionId?.() ?? undefined) ??
108
+ AsyncJobManager.forEndpoint(
109
+ this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? undefined,
110
+ ) ??
109
111
  AsyncJobManager.instance();
110
112
  if (!manager) {
111
113
  return {
@@ -396,7 +398,11 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
396
398
  const generation = manager.getJob?.(job.id)?.generation;
397
399
  if (!generation) continue;
398
400
  // The endpoint disambiguates concurrent sessions' same job ids (review P1).
399
- const registration = lookupOwnedRegistration(job.id, generation, this.session.getSessionId?.() ?? "local");
401
+ const registration = lookupOwnedRegistration(
402
+ job.id,
403
+ generation,
404
+ this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? "local",
405
+ );
400
406
  if (registration) unregisterOwnedRegistration(registration);
401
407
  }
402
408
 
@@ -119,7 +119,9 @@ export class MonitorTool implements AgentTool<typeof monitorSchema, MonitorToolD
119
119
  // different concurrent top-level session (review thread P1).
120
120
  const manager =
121
121
  this.session.getAsyncJobManager?.() ??
122
- AsyncJobManager.forEndpoint(this.session.getSessionId?.() ?? undefined) ??
122
+ AsyncJobManager.forEndpoint(
123
+ this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? undefined,
124
+ ) ??
123
125
  AsyncJobManager.instance();
124
126
  if (!manager) {
125
127
  throw new ToolError("Async execution is disabled; the monitor tool is unavailable in this session.");
@@ -197,7 +199,11 @@ export class MonitorTool implements AgentTool<typeof monitorSchema, MonitorToolD
197
199
  // endpoint-less fallback scan could attach a foreign session's
198
200
  // registration (review thread P2).
199
201
  const registration = generation
200
- ? lookupOwnedRegistration(jobId, generation, this.session.getSessionId?.() ?? "local")
202
+ ? lookupOwnedRegistration(
203
+ jobId,
204
+ generation,
205
+ this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? "local",
206
+ )
201
207
  : undefined;
202
208
  const ownedCompletion = registration
203
209
  ? {
@@ -696,6 +696,8 @@ export interface ToolScopeOptions {
696
696
  surfaceExactFilePaths?: boolean;
697
697
  /** Extra hint appended to "Path not found" when stat fails and the user supplied multiple paths. */
698
698
  multipathStatHint?: string;
699
+ /** Cancels internal-URL resolution and prevents further scope work. */
700
+ signal?: AbortSignal;
699
701
  }
700
702
 
701
703
  export interface ToolScopeResolution {
@@ -719,6 +721,7 @@ export interface ToolScopeResolution {
719
721
  */
720
722
  export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<ToolScopeResolution> {
721
723
  const { rawPaths: inputs, cwd, internalUrlAction, getArtifactsDir, getAuthorizedArtifactsDirs } = opts;
724
+ opts.signal?.throwIfAborted();
722
725
  const rawPaths = inputs.map(normalizePathLikeInput);
723
726
  if (rawPaths.some(rawPath => rawPath.length === 0)) {
724
727
  throw new ToolError("`paths` must contain non-empty paths or globs");
@@ -727,6 +730,7 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
727
730
  const resolvedPathInputs: string[] = [];
728
731
  const immutableSourcePaths = new Set<string>();
729
732
  for (const rawPath of rawPaths) {
733
+ opts.signal?.throwIfAborted();
730
734
  if (!internalRouter.canHandle(rawPath)) {
731
735
  resolvedPathInputs.push(rawPath);
732
736
  continue;
@@ -739,7 +743,9 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
739
743
  getArtifactsDir,
740
744
  getAuthorizedArtifactsDirs,
741
745
  mcpManager: opts.mcpManager,
746
+ signal: opts.signal,
742
747
  });
748
+ opts.signal?.throwIfAborted();
743
749
  if (!resource.sourcePath) {
744
750
  throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`);
745
751
  }
@@ -753,6 +759,7 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
753
759
  let effectivePaths = resolvedPathInputs;
754
760
  if (resolvedPathInputs.length > 1) {
755
761
  const partition = await partitionExistingPaths(resolvedPathInputs, cwd, parseSearchPath);
762
+ opts.signal?.throwIfAborted();
756
763
  if (partition.valid.length === 0) {
757
764
  throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
758
765
  }
@@ -5,7 +5,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
5
5
  import type { GrepMatch, GrepResult, grep as grepFn } from "@gajae-code/natives";
6
6
  import type { Component } from "@gajae-code/tui";
7
7
  import { Text } from "@gajae-code/tui";
8
- import { prompt, untilAborted } from "@gajae-code/utils";
8
+ import { prompt } from "@gajae-code/utils";
9
9
  import * as z from "zod/v4";
10
10
  import { getFileReadCache } from "../edit/file-read-cache";
11
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -35,7 +35,7 @@ import {
35
35
  PREVIEW_LIMITS,
36
36
  splitGroupsByBlankLine,
37
37
  } from "./render-utils";
38
- import { ToolError } from "./tool-errors";
38
+ import { ToolAbortError, ToolError } from "./tool-errors";
39
39
  import { toolResult } from "./tool-result";
40
40
 
41
41
  let searchNativesLoad:
@@ -71,6 +71,7 @@ const searchSchema = z
71
71
  .number()
72
72
  .optional()
73
73
  .describe("files to skip before collecting results — use to paginate when the prior call hit the file limit"),
74
+ timeout: z.number().min(0.5).max(60).default(5).describe("timeout in seconds (0.5–60)").optional(),
74
75
  })
75
76
  .strict();
76
77
 
@@ -90,6 +91,9 @@ export const SINGLE_FILE_MATCHES = 200;
90
91
  * (DEFAULT_FILE_LIMIT files × MULTI_FILE_PER_FILE_MATCHES matches) plus
91
92
  * pagination headroom so the caller can see total file count. */
92
93
  const INTERNAL_TOTAL_CAP = 2000;
94
+ const DEFAULT_SEARCH_TIMEOUT_MS = 5000;
95
+ const MIN_SEARCH_TIMEOUT_MS = 500;
96
+ const MAX_SEARCH_TIMEOUT_MS = 60_000;
93
97
 
94
98
  /**
95
99
  * Detect a `,` that is not inside a `{…}` brace expansion. Used to catch
@@ -130,6 +134,7 @@ function containsTopLevelComma(entry: string): boolean {
130
134
  async function resolveArchiveSearchPaths(
131
135
  paths: string[],
132
136
  cwd: string,
137
+ signal?: AbortSignal,
133
138
  ): Promise<{
134
139
  resolvedPaths: string[];
135
140
  displayMap: Map<string, string>;
@@ -143,64 +148,71 @@ async function resolveArchiveSearchPaths(
143
148
  const unreadable: string[] = [];
144
149
  let tempDir: string | undefined;
145
150
  const archiveCache = new Map<string, ArchiveReader>();
151
+ const cleanup = async () => {
152
+ const directory = tempDir;
153
+ tempDir = undefined;
154
+ if (directory) await rm(directory, { recursive: true, force: true }).catch(() => {});
155
+ };
146
156
 
147
- for (let idx = 0; idx < paths.length; idx++) {
148
- const entry = paths[idx];
149
- const candidates = parseArchivePathCandidates(entry);
150
- // Longest archive prefix first; we want the one whose member portion is non-empty.
151
- const member = candidates.find(c => c.subPath !== "" && c.archivePath !== entry);
152
- if (!member) continue;
157
+ try {
158
+ for (let idx = 0; idx < paths.length; idx++) {
159
+ signal?.throwIfAborted();
160
+ const entry = paths[idx];
161
+ const candidates = parseArchivePathCandidates(entry);
162
+ // Longest archive prefix first; we want the one whose member portion is non-empty.
163
+ const member = candidates.find(c => c.subPath !== "" && c.archivePath !== entry);
164
+ if (!member) continue;
165
+
166
+ const archiveAbs = resolveReadPath(member.archivePath, cwd);
167
+ let archive = archiveCache.get(archiveAbs);
168
+ if (!archive) {
169
+ try {
170
+ archive = await openArchive(archiveAbs);
171
+ } catch (err) {
172
+ unreadable.push(`${entry} (cannot open archive: ${(err as Error).message})`);
173
+ continue;
174
+ }
175
+ archiveCache.set(archiveAbs, archive);
176
+ }
153
177
 
154
- const archiveAbs = resolveReadPath(member.archivePath, cwd);
155
- let archive = archiveCache.get(archiveAbs);
156
- if (!archive) {
178
+ let extracted: ExtractedArchiveFile;
157
179
  try {
158
- archive = await openArchive(archiveAbs);
180
+ extracted = await archive.readFile(member.subPath);
159
181
  } catch (err) {
160
- unreadable.push(`${entry} (cannot open archive: ${(err as Error).message})`);
182
+ unreadable.push(`${entry} (${(err as Error).message})`);
183
+ continue;
184
+ }
185
+ signal?.throwIfAborted();
186
+ // UTF-8 only — binary members would just produce noise through ripgrep.
187
+ if (extracted.bytes.some(byte => byte === 0)) {
188
+ unreadable.push(`${entry} (binary archive entry)`);
189
+ continue;
190
+ }
191
+ let text: string;
192
+ try {
193
+ text = new TextDecoder("utf-8", { fatal: true }).decode(extracted.bytes);
194
+ } catch {
195
+ unreadable.push(`${entry} (non-UTF-8 archive entry)`);
161
196
  continue;
162
197
  }
163
- archiveCache.set(archiveAbs, archive);
164
- }
165
-
166
- let extracted: ExtractedArchiveFile;
167
- try {
168
- extracted = await archive.readFile(member.subPath);
169
- } catch (err) {
170
- unreadable.push(`${entry} (${(err as Error).message})`);
171
- continue;
172
- }
173
- // UTF-8 only — binary members would just produce noise through ripgrep.
174
- if (extracted.bytes.some(byte => byte === 0)) {
175
- unreadable.push(`${entry} (binary archive entry)`);
176
- continue;
177
- }
178
- let text: string;
179
- try {
180
- text = new TextDecoder("utf-8", { fatal: true }).decode(extracted.bytes);
181
- } catch {
182
- unreadable.push(`${entry} (non-UTF-8 archive entry)`);
183
- continue;
184
- }
185
198
 
186
- if (!tempDir) {
187
- tempDir = await mkdtemp(path.join(tmpdir(), "gjc-search-archive-"));
199
+ if (!tempDir) {
200
+ tempDir = await mkdtemp(path.join(tmpdir(), "gjc-search-archive-"));
201
+ }
202
+ // Per-entry filename keeps the scratch path unique even when two selectors
203
+ // resolve to members with the same basename.
204
+ const safeBase = path.basename(member.subPath).replace(/[^\w.-]+/g, "_") || "entry";
205
+ const tempPath = path.join(tempDir, `${idx}-${safeBase}`);
206
+ await writeFile(tempPath, text);
207
+ resolvedPaths[idx] = tempPath;
208
+ displayMap.set(tempPath, entry);
209
+ displaySet.add(entry);
188
210
  }
189
- // Per-entry filename keeps the scratch path unique even when two selectors
190
- // resolve to members with the same basename.
191
- const safeBase = path.basename(member.subPath).replace(/[^\w.-]+/g, "_") || "entry";
192
- const tempPath = path.join(tempDir, `${idx}-${safeBase}`);
193
- await writeFile(tempPath, text);
194
- resolvedPaths[idx] = tempPath;
195
- displayMap.set(tempPath, entry);
196
- displaySet.add(entry);
211
+ } catch (error) {
212
+ await cleanup();
213
+ throw error;
197
214
  }
198
215
 
199
- const cleanup = async () => {
200
- if (tempDir) {
201
- await rm(tempDir, { recursive: true, force: true }).catch(() => {});
202
- }
203
- };
204
216
  return { resolvedPaths, displayMap, displaySet, unreadable, cleanup };
205
217
  }
206
218
 
@@ -235,6 +247,8 @@ export interface SearchToolOptions {
235
247
  context?: number;
236
248
  /** Per-call total match cap used by Cursor's native Pi grep frame. */
237
249
  totalMatchLimit?: number;
250
+ /** Test or remote override for the native grep implementation. */
251
+ grep?: typeof grepFn;
238
252
  }
239
253
 
240
254
  type SearchParams = z.infer<typeof searchSchema>;
@@ -266,12 +280,23 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
266
280
  _onUpdate?: AgentToolUpdateCallback<SearchToolDetails>,
267
281
  _toolContext?: AgentToolContext,
268
282
  ): Promise<AgentToolResult<SearchToolDetails>> {
269
- const { pattern, i, gitignore, skip } = params;
283
+ const { pattern, i, gitignore, skip, timeout } = params;
270
284
  // Recurring real-world failure mode: models omit `paths` for repo-wide
271
285
  // searches. Default to the working directory instead of failing the call.
272
286
  const paths = params.paths ?? ["."];
287
+ const requestedTimeoutMs = timeout != null ? Math.round(timeout * 1000) : DEFAULT_SEARCH_TIMEOUT_MS;
288
+ const timeoutMs = Math.min(MAX_SEARCH_TIMEOUT_MS, Math.max(MIN_SEARCH_TIMEOUT_MS, requestedTimeoutMs));
289
+ const deadlineAt = Date.now() + timeoutMs;
290
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
291
+ const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
292
+ const throwIfSearchCancelled = (): void => {
293
+ signal?.throwIfAborted();
294
+ if (Date.now() >= deadlineAt) throw new Error("Aborted: Timeout");
295
+ combinedSignal.throwIfAborted();
296
+ };
273
297
 
274
- return untilAborted(signal, async () => {
298
+ try {
299
+ throwIfSearchCancelled();
275
300
  const normalizedPattern = pattern.trim();
276
301
  if (!normalizedPattern) {
277
302
  throw new ToolError("Pattern must not be empty");
@@ -292,8 +317,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
292
317
  displaySet: archiveDisplaySet,
293
318
  unreadable: archiveUnreadable,
294
319
  cleanup: cleanupArchiveScratch,
295
- } = await resolveArchiveSearchPaths(paths, this.session.cwd);
320
+ } = await resolveArchiveSearchPaths(paths, this.session.cwd, combinedSignal);
321
+ let completedResult: AgentToolResult<SearchToolDetails> | undefined;
296
322
  try {
323
+ throwIfSearchCancelled();
297
324
  if (archiveUnreadable.length > 0 && resolvedPaths.length === archiveUnreadable.length) {
298
325
  // All inputs were archive selectors we couldn't materialize; surface the
299
326
  // reason instead of a downstream "path not found" from the scope resolver.
@@ -324,7 +351,9 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
324
351
  trackImmutableSources: true,
325
352
  surfaceExactFilePaths: true,
326
353
  multipathStatHint: " (`paths` entries must each exist relative to cwd)",
354
+ signal: combinedSignal,
327
355
  });
356
+ throwIfSearchCancelled();
328
357
  const { searchPath, isDirectory, multiTargets, exactFilePaths, missingPaths, immutableSourcePaths } = scope;
329
358
  // When the only input was an archive selector, surface that selector instead
330
359
  // of the temp scratch path the resolver substituted in.
@@ -345,7 +374,8 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
345
374
  const baseDisplayMode = resolveFileDisplayMode(this.session);
346
375
  const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true });
347
376
 
348
- const { GrepOutputMode, grep } = await searchNatives();
377
+ const { GrepOutputMode, grep: nativeGrep } = await searchNatives();
378
+ const grep = this.options.grep ?? nativeGrep;
349
379
  const effectiveOutputMode = GrepOutputMode.Content;
350
380
  // Multi-scope = more than one file may match. We fetch up to
351
381
  // INTERNAL_TOTAL_CAP matches from native grep, then in JS group by
@@ -363,7 +393,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
363
393
  let totalMatches = 0;
364
394
  let filesSearched = 0;
365
395
  const targets = exactFilePaths
366
- ? exactFilePaths.map(filePath => ({ basePath: filePath, glob: undefined as string | undefined }))
396
+ ? exactFilePaths.map(filePath => ({
397
+ basePath: filePath,
398
+ glob: undefined as string | undefined,
399
+ }))
367
400
  : (multiTargets ?? []);
368
401
  for (const target of targets) {
369
402
  const targetResult = await grep(
@@ -381,9 +414,12 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
381
414
  contextAfter: normalizedContextAfter,
382
415
  maxColumns: DEFAULT_MAX_COLUMN,
383
416
  mode: effectiveOutputMode,
417
+ signal: combinedSignal,
418
+ timeoutMs,
384
419
  },
385
420
  undefined,
386
421
  );
422
+ throwIfSearchCancelled();
387
423
  limitReached = limitReached || Boolean(targetResult.limitReached);
388
424
  totalMatches += targetResult.totalMatches;
389
425
  filesSearched += targetResult.filesSearched;
@@ -416,9 +452,12 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
416
452
  contextAfter: normalizedContextAfter,
417
453
  maxColumns: DEFAULT_MAX_COLUMN,
418
454
  mode: effectiveOutputMode,
455
+ signal: combinedSignal,
456
+ timeoutMs,
419
457
  },
420
458
  undefined,
421
459
  );
460
+ throwIfSearchCancelled();
422
461
  }
423
462
  } catch (err) {
424
463
  if (err instanceof Error && err.message.startsWith("regex parse error")) {
@@ -518,7 +557,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
518
557
  missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
519
558
  };
520
559
  const text = warningNote ? `No matches found\n${warningNote}` : "No matches found";
521
- return toolResult(details).text(text).done();
560
+ const noMatchResult = toolResult(details).text(text).done();
561
+ await cleanupArchiveScratch();
562
+ throwIfSearchCancelled();
563
+ return noMatchResult;
522
564
  }
523
565
  const outputLines: string[] = [];
524
566
  let linesTruncated = false;
@@ -644,11 +686,25 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
644
686
  if (truncation.truncated) {
645
687
  resultBuilder.truncation(truncation, { direction: "head" });
646
688
  }
647
- return resultBuilder.done();
689
+ throwIfSearchCancelled();
690
+ completedResult = resultBuilder.done();
648
691
  } finally {
649
692
  await cleanupArchiveScratch();
650
693
  }
651
- });
694
+ throwIfSearchCancelled();
695
+ if (!completedResult) throw new Error("Search result assembly did not complete");
696
+ return completedResult;
697
+ } catch (error) {
698
+ const nativeTimedOut = error instanceof Error && error.message === "Aborted: Timeout";
699
+ if ((timeoutSignal.aborted || nativeTimedOut) && !signal?.aborted) {
700
+ const seconds = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}` : (timeoutMs / 1000).toFixed(1);
701
+ throw new ToolError(`Search timed out after ${seconds}s; increase timeout or narrow paths/pattern`);
702
+ }
703
+ if (combinedSignal.aborted || (error instanceof Error && error.name === "AbortError")) {
704
+ throw new ToolAbortError();
705
+ }
706
+ throw error;
707
+ }
652
708
  }
653
709
  }
654
710
 
@@ -662,6 +718,7 @@ interface SearchRenderArgs {
662
718
  i?: boolean;
663
719
  gitignore?: boolean;
664
720
  skip?: number;
721
+ timeout?: number;
665
722
  }
666
723
 
667
724
  const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
@@ -674,6 +731,7 @@ export const searchToolRenderer = {
674
731
  if (args.i) meta.push("case:insensitive");
675
732
  if (args.gitignore === false) meta.push("gitignore:false");
676
733
  if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
734
+ if (args.timeout !== undefined && args.timeout !== 5) meta.push(`timeout:${args.timeout}s`);
677
735
 
678
736
  const text = renderStatusLine(
679
737
  { icon: "pending", title: "Search", description: args.pattern || "?", meta },