@gajae-code/coding-agent 0.18.5 → 0.18.6

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 (91) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/dist/types/cli-commands.d.ts +10 -0
  3. package/dist/types/cli-ordinary.d.ts +13 -0
  4. package/dist/types/cli.d.ts +12 -3
  5. package/dist/types/commands/contribution-prep.d.ts +0 -3
  6. package/dist/types/commands/sdk.d.ts +3 -0
  7. package/dist/types/commands/update.d.ts +1 -0
  8. package/dist/types/config/model-profile-activation.d.ts +19 -0
  9. package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
  10. package/dist/types/edit/modes/replace.d.ts +3 -3
  11. package/dist/types/edit/streaming.d.ts +2 -2
  12. package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
  13. package/dist/types/sdk/broker/discovery.d.ts +8 -0
  14. package/dist/types/sdk/broker/ensure.d.ts +1 -0
  15. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
  16. package/dist/types/sdk/broker/runtime.d.ts +1 -1
  17. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  18. package/dist/types/sdk/bus/telegram-daemon.d.ts +1 -0
  19. package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
  20. package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
  21. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  22. package/dist/types/sdk/index.d.ts +1 -0
  23. package/dist/types/sdk/router/session-router.d.ts +2 -0
  24. package/dist/types/sdk/session.d.ts +13 -0
  25. package/dist/types/session/agent-session.d.ts +5 -1
  26. package/dist/types/tools/ask-contract.d.ts +75 -254
  27. package/dist/types/tools/bash.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/scripts/build-sdk-package-smoke.ts +5 -0
  30. package/src/cli/public-command-entry.ts +5 -0
  31. package/src/cli/ssh-cli.ts +6 -3
  32. package/src/cli/web-search-cli.ts +9 -3
  33. package/src/cli-commands.ts +14 -0
  34. package/src/cli-ordinary.ts +197 -0
  35. package/src/cli.ts +25 -182
  36. package/src/commands/contribution-prep.ts +1 -3
  37. package/src/commands/sdk.ts +59 -0
  38. package/src/commands/update.ts +15 -1
  39. package/src/config/file-lock.ts +91 -2
  40. package/src/config/model-profile-activation.ts +70 -4
  41. package/src/config/model-profile-contract.ts +4 -1
  42. package/src/config/model-profiles.ts +23 -31
  43. package/src/config/model-resolver.ts +9 -1
  44. package/src/coordinator-mcp/event-webhook.ts +19 -14
  45. package/src/coordinator-mcp/policy.ts +14 -4
  46. package/src/coordinator-mcp/server.ts +142 -7
  47. package/src/coordinator-mcp/session-reaper.ts +9 -1
  48. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  49. package/src/edit/modes/replace.ts +6 -6
  50. package/src/edit/streaming.ts +15 -15
  51. package/src/exec/bash-shell-supervisor.ts +60 -3
  52. package/src/internal-urls/docs-index.generated.ts +10 -9
  53. package/src/memories/index.ts +8 -2
  54. package/src/modes/acp/acp-agent.ts +649 -367
  55. package/src/modes/components/tool-execution.ts +1 -1
  56. package/src/prompts/tools/ask.md +2 -0
  57. package/src/sdk/broker/broker.ts +46 -0
  58. package/src/sdk/broker/daemon-entry.ts +67 -6
  59. package/src/sdk/broker/discovery.ts +8 -0
  60. package/src/sdk/broker/ensure.ts +156 -20
  61. package/src/sdk/broker/lifecycle-ledger.ts +110 -12
  62. package/src/sdk/broker/lifecycle.ts +225 -341
  63. package/src/sdk/broker/runtime.ts +5 -1
  64. package/src/sdk/broker/transport.ts +5 -1
  65. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  66. package/src/sdk/bus/telegram-daemon-control.ts +15 -12
  67. package/src/sdk/bus/telegram-daemon.ts +34 -1
  68. package/src/sdk/cli/diagnostics-cli.ts +247 -0
  69. package/src/sdk/diagnostics/observe-broker.ts +654 -0
  70. package/src/sdk/host/control/dispatch.ts +52 -4
  71. package/src/sdk/host/control/operations.ts +1 -1
  72. package/src/sdk/host/session-runtime.test.ts +40 -2
  73. package/src/sdk/host/session-runtime.ts +13 -5
  74. package/src/sdk/index.ts +1 -0
  75. package/src/sdk/lifecycle-session.ts +1 -0
  76. package/src/sdk/protocol/operation-registry.ts +1 -1
  77. package/src/sdk/router/session-router.ts +7 -1
  78. package/src/sdk/session.ts +37 -8
  79. package/src/session/agent-session.ts +114 -39
  80. package/src/session/blob-store.ts +78 -33
  81. package/src/session/internal/managed-session-storage.ts +96 -3
  82. package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
  83. package/src/setup/hermes-setup.ts +4 -1
  84. package/src/slash-commands/builtin-registry.ts +8 -5
  85. package/src/tools/ask-contract.test.ts +76 -1
  86. package/src/tools/ask-contract.ts +57 -6
  87. package/src/tools/ask.ts +16 -7
  88. package/src/tools/bash.ts +38 -29
  89. package/src/tools/descriptors.test.ts +29 -0
  90. package/src/tools/tool-catalog.generated.ts +7 -4
  91. package/src/web/search/index.ts +2 -2
@@ -740,13 +740,18 @@ function toolSchema(
740
740
  return {
741
741
  name,
742
742
  description:
743
- "Create a durable turn and deliver a bounded follow-up prompt for a selected coordinator bridge session.",
743
+ "Create a durable turn, or set steer:true with the active turn_id to deliver requested input to that running turn. queue:true is only for a separate later task; it is not consumption acknowledgement.",
744
744
  inputSchema: {
745
745
  type: "object",
746
746
  properties: {
747
747
  session_id: sessionId,
748
748
  prompt: { type: "string" },
749
749
  queue: { type: "boolean" },
750
+ steer: { type: "boolean" },
751
+ turn_id: {
752
+ type: "string",
753
+ description: "Required with steer:true; the existing active coordinator turn.",
754
+ },
750
755
  force: { type: "boolean" },
751
756
  idempotency_key: idempotencyKey,
752
757
  allow_mutation: allowMutation,
@@ -829,7 +834,12 @@ function toolSchema(
829
834
  summary: { type: "string" },
830
835
  blocker: { type: "string" },
831
836
  pr_url: { type: "string" },
832
- evidence_paths: { type: "array", items: { type: "string" } },
837
+ evidence_paths: {
838
+ type: "array",
839
+ items: { type: "string" },
840
+ description:
841
+ "Evidence must be inside configured artifact roots. Agent session logs are not automatically allowed.",
842
+ },
833
843
  idempotency_key: idempotencyKey,
834
844
  allow_mutation: allowMutation,
835
845
  },
@@ -1250,8 +1260,10 @@ const COORDINATOR_IDEMPOTENCY_RESPONSE_BYTE_CAP = 64 * 1024;
1250
1260
  const COORDINATOR_IDEMPOTENCY_STRING_BYTE_CAP = 8 * 1024;
1251
1261
  const PUBLIC_ERROR_MESSAGES: Record<string, string> = {
1252
1262
  invalid_input: "Coordinator request input is invalid.",
1263
+ turn_not_active: "Feedback requires the selected active turn; no new coordinator turn was created.",
1253
1264
  invalid_request: "Coordinator request is invalid.",
1254
1265
  invalid_session_id: "Coordinator session id is invalid.",
1266
+ invalid_turn_id: "Coordinator turn id is invalid.",
1255
1267
  unknown_operation: "Coordinator operation is unsupported.",
1256
1268
  not_found: "Coordinator resource was not found.",
1257
1269
  resource_gone: "Coordinator resource is no longer available.",
@@ -2608,8 +2620,20 @@ export async function appendCoordinatorEventForTest(
2608
2620
  return appendCoordinatorEvent(namespaceDir, input);
2609
2621
  }
2610
2622
 
2623
+ // Tool arguments and env values arrive untyped. `Number.parseInt(String(v))`
2624
+ // reads a leading digit prefix ("1e4" -> 1, "5s" -> 5) and flattens arrays
2625
+ // ([50] -> "50"), so a malformed value silently became a 1 ms timeout or a
2626
+ // one-row limit. Numbers pass through; strings must be plain decimal digits;
2627
+ // anything else is NaN so each caller falls back to its default.
2628
+ function integerArg(value: unknown): number {
2629
+ if (typeof value === "number") return value;
2630
+ if (typeof value !== "string") return Number.NaN;
2631
+ const trimmed = value.trim();
2632
+ return /^\d+$/.test(trimmed) ? Number.parseInt(trimmed, 10) : Number.NaN;
2633
+ }
2634
+
2611
2635
  function boundedEventLimit(value: unknown): number {
2612
- const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
2636
+ const parsed = integerArg(value);
2613
2637
  if (!Number.isFinite(parsed) || parsed <= 0) return 100;
2614
2638
  return Math.min(parsed, 100);
2615
2639
  }
@@ -3178,7 +3202,7 @@ export const COORDINATOR_EVENT_WATCH_TIMEOUT_MAX_MS = 30_000;
3178
3202
  export const COORDINATOR_POLL_INTERVAL_MAX_MS = 10_000;
3179
3203
 
3180
3204
  function parsePositiveIntegerMs(value: unknown, fallback: number): number {
3181
- const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
3205
+ const parsed = integerArg(value);
3182
3206
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
3183
3207
  }
3184
3208
 
@@ -3194,7 +3218,7 @@ export function boundedRuntimePromptAckTimeoutMs(value: unknown): number {
3194
3218
  }
3195
3219
 
3196
3220
  export function boundedEventWatchTimeoutMs(value: unknown): number {
3197
- const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
3221
+ const parsed = integerArg(value);
3198
3222
  if (parsed === 0) return 0;
3199
3223
  return Math.min(Number.isFinite(parsed) && parsed > 0 ? parsed : 1000, COORDINATOR_EVENT_WATCH_TIMEOUT_MAX_MS);
3200
3224
  }
@@ -3204,7 +3228,7 @@ export function boundedPollIntervalMs(value: unknown): number {
3204
3228
  }
3205
3229
 
3206
3230
  function boundedLineCount(value: unknown): number {
3207
- const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
3231
+ const parsed = integerArg(value);
3208
3232
  if (!Number.isFinite(parsed) || parsed <= 0) return 80;
3209
3233
  return Math.min(parsed, 400);
3210
3234
  }
@@ -5178,7 +5202,20 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
5178
5202
  }
5179
5203
 
5180
5204
  async function queryLastAssistant(session: Record<string, unknown>): Promise<string | null> {
5181
- const item = sdkQueryPageItem(await querySession(session, "session.last_assistant"), "session.last_assistant");
5205
+ let item: unknown;
5206
+ try {
5207
+ item = sdkQueryPageItem(await querySession(session, "session.last_assistant"), "session.last_assistant");
5208
+ } catch (error) {
5209
+ // Compatibility with running older hosts: this exact Q17 diagnostic
5210
+ // denotes an absent text payload, not a missing session or closed store.
5211
+ if (
5212
+ error instanceof SdkClientError &&
5213
+ error.code === "resource_gone" &&
5214
+ error.message === "snapshot payload is unavailable"
5215
+ )
5216
+ return null;
5217
+ throw error;
5218
+ }
5182
5219
  if (typeof item === "string") return item;
5183
5220
  const message = asRecord(item);
5184
5221
  return typeof message?.text === "string"
@@ -5227,6 +5264,19 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
5227
5264
  function publicError(error: unknown): Record<string, unknown> {
5228
5265
  if (error instanceof Error && error.message.startsWith("coordinator_mutation_call_not_allowed:"))
5229
5266
  return { ok: false, reason: error.message };
5267
+ if (error instanceof Error) {
5268
+ const reason = error.message.startsWith("coordinator_artifact_outside_allowed_roots:")
5269
+ ? "artifact_outside_allowed_roots"
5270
+ : error.message === "coordinator_artifact_state_root_denied"
5271
+ ? "artifact_state_root_denied"
5272
+ : undefined;
5273
+ if (reason)
5274
+ return {
5275
+ ok: false,
5276
+ reason,
5277
+ error: { code: "invalid_input", message: PUBLIC_ERROR_MESSAGES.invalid_input },
5278
+ };
5279
+ }
5230
5280
  const directCode = error instanceof SdkClientError ? error.code : sinkErrorCode(error);
5231
5281
  const messageCode =
5232
5282
  error instanceof Error && Object.hasOwn(PUBLIC_ERROR_MESSAGES, error.message) ? error.message : undefined;
@@ -10354,6 +10404,91 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
10354
10404
  const idempotencyKey = requiredIdempotencyKey(args);
10355
10405
  const sessionId = safeExternalId("session", args.session_id);
10356
10406
  const prompt = args.prompt;
10407
+ if (args.steer === true) {
10408
+ if (args.queue === true || args.force === true)
10409
+ return {
10410
+ ok: false,
10411
+ error: { code: "invalid_input", message: "steer cannot be combined with queue or force." },
10412
+ };
10413
+ const turnId = safeTurnId(args.turn_id);
10414
+ return await withToolIdempotency(
10415
+ name,
10416
+ idempotencyKey,
10417
+ { session_id: sessionId, turn_id: turnId, prompt, steer: true, allow_mutation: true },
10418
+ async () =>
10419
+ await withSessionTransition(sessionId, async () => {
10420
+ const transaction = await readSessionTransaction(questionPaths, sessionId);
10421
+ const turn = transaction?.canonical.turns[turnId];
10422
+ if (
10423
+ !transaction ||
10424
+ !turn ||
10425
+ transaction.canonical.queue.active_turn_id !== turnId ||
10426
+ (turn.status !== "active" && turn.status !== "waiting_for_answer")
10427
+ )
10428
+ return {
10429
+ ok: false,
10430
+ error: {
10431
+ code: "turn_not_active",
10432
+ message: "Feedback requires the selected active turn; no new turn was created.",
10433
+ },
10434
+ };
10435
+ const runtimeCommandId = turn.delivery.runtime_command_id;
10436
+ const runtimeTurnId = turn.delivery.runtime_turn_id;
10437
+ if (
10438
+ typeof runtimeCommandId !== "string" ||
10439
+ runtimeCommandId.length === 0 ||
10440
+ typeof runtimeTurnId !== "string" ||
10441
+ runtimeTurnId.length === 0
10442
+ )
10443
+ return {
10444
+ ok: false,
10445
+ error: {
10446
+ code: "turn_not_active",
10447
+ message:
10448
+ "Feedback requires the selected active turn's runtime identity; no new turn was created.",
10449
+ },
10450
+ };
10451
+ const session = asRecord(await readJsonFile(sessionFile(sessionId)));
10452
+ if (!session) throw new Error("resource_gone");
10453
+ await assertPersistedSessionAuthority(transaction.canonical.session);
10454
+ const clientRef = `coordinator-feedback:${createHash("sha256")
10455
+ .update(JSON.stringify([sessionId, turnId, idempotencyKey]))
10456
+ .digest("hex")}`;
10457
+ const expectedSdkRunToken = `${runtimeCommandId}:${runtimeTurnId}`;
10458
+ const response = asRecord(
10459
+ await controlSession(
10460
+ session,
10461
+ "turn.steer",
10462
+ { text: prompt, clientRef, expectedSdkRunToken },
10463
+ idempotencyKey,
10464
+ ),
10465
+ );
10466
+ const result = asRecord(response?.result);
10467
+ return {
10468
+ ok: result?.accepted === true,
10469
+ session_id: sessionId,
10470
+ turn_id: turnId,
10471
+ delivery: "steer",
10472
+ queued: false,
10473
+ consumption_verified: false,
10474
+ steer_client_ref: clientRef,
10475
+ result,
10476
+ };
10477
+ }),
10478
+ true,
10479
+ response => {
10480
+ const result = asRecord(response.result);
10481
+ const resultError = asRecord(result?.error);
10482
+ return (
10483
+ ["ambiguous", "uncertain_after_send", "connection_closed", "timeout"].includes(
10484
+ String(asRecord(response.error)?.code),
10485
+ ) ||
10486
+ result?.status === "uncertain" ||
10487
+ resultError?.code === "delivery_uncertain"
10488
+ );
10489
+ },
10490
+ );
10491
+ }
10357
10492
  return await withToolIdempotency(
10358
10493
  name,
10359
10494
  idempotencyKey,
@@ -22,6 +22,11 @@ export const DEFAULT_SESSION_IDLE_TTL_MS = 30 * 60_000; // 30 min idle → reap
22
22
  export const DEFAULT_SESSION_SWEEP_INTERVAL_MS = 5 * 60_000; // sweep every 5 min
23
23
  export const MIN_SESSION_IDLE_TTL_MS = 60_000; // never reap a <1min-idle session
24
24
  export const MIN_SESSION_SWEEP_INTERVAL_MS = 30_000;
25
+ /**
26
+ * Longest delay setTimeout honors. Bun, like Node, treats any larger delay as 1 ms,
27
+ * so an operator's very long sweep interval would otherwise re-sweep back to back.
28
+ */
29
+ export const MAX_SESSION_SWEEP_INTERVAL_MS = 2 ** 31 - 1;
25
30
  /** After this many consecutive reap failures the session is force-evicted from the index. */
26
31
  export const MAX_REAP_FAILURES = 3;
27
32
 
@@ -92,7 +97,10 @@ export interface SessionReaper {
92
97
 
93
98
  export function createSessionReaper(deps: SessionReaperDeps, policy: SessionReaperPolicy): SessionReaper {
94
99
  const idleTtlMs = Math.max(MIN_SESSION_IDLE_TTL_MS, policy.idleTtlMs);
95
- const sweepIntervalMs = Math.max(MIN_SESSION_SWEEP_INTERVAL_MS, policy.sweepIntervalMs);
100
+ const sweepIntervalMs = Math.min(
101
+ MAX_SESSION_SWEEP_INTERVAL_MS,
102
+ Math.max(MIN_SESSION_SWEEP_INTERVAL_MS, policy.sweepIntervalMs),
103
+ );
96
104
  let timer: ReturnType<typeof setTimeout> | null = null;
97
105
  let generation = 0;
98
106
  let inProgress = false;
@@ -265,7 +265,7 @@ Is that topology and locked intent right? Should any component or intent be adde
265
265
 
266
266
  Options should include contextually relevant choices such as **Looks right**, **Add/remove/merge components**, **Defer one or more components**, plus free-text, translated/localized according to `language.instruction` when present. This is the only pre-scoring question and preserves the one-question-per-round rule.
267
267
 
268
- The Round 0 `ask` call MUST include `deepInterview.round = 0`, `deepInterview.component = "review-topology"`, `deepInterview.dimension = "topology"`, `deepInterview.ambiguity` (the observed 0..1 ambiguity at ask time; `1` before Round 1 scoring), `deepInterview.intent_contract.items` containing the exact displayed locked-intent items, and `deepInterview.intent_contract.confirmation_options` listing only the displayed affirmative labels that lock the proposal (normally **Looks right**). The runtime recorder canonicalizes and locks this contract only when the user selects one of those labels; correction, deferral, free-text, and clarification answers never lock the pre-question proposal. Do not manually copy raw free text into intent evidence, and do not continue if this required recorder write fails. An incomplete Round 0 object (for example missing `deepInterview.ambiguity` or the whole `deepInterview.intent_contract`) is rejected before coercion with a correction naming the omitted fields; retry once with every required field populated from the visible payload context, never by re-sending the incomplete object or inventing contract items — the recorder never locks intent without an affirmative user answer.
268
+ While state is in the `interviewing` phase, every `ask` MUST contain exactly one non-empty question with structured `deepInterview` metadata on that question; never add an empty `metadata` or `ignore` question. The entire `deepInterview` object is required, not merely the prose round header or `workflowGate`. After native final-spec persistence transitions to `handoff`, Phase 5 next-workflow/approval asks are ordinary asks without interview round metadata. The Round 0 `ask` call MUST include `deepInterview.round = 0`, `deepInterview.component = "review-topology"`, `deepInterview.dimension = "topology"`, `deepInterview.ambiguity` (the observed 0..1 ambiguity at ask time; `1` before Round 1 scoring), `deepInterview.intent_contract.items` containing the exact displayed locked-intent items, and `deepInterview.intent_contract.confirmation_options` listing only the displayed affirmative labels that lock the proposal (normally **Looks right**). The runtime recorder canonicalizes and locks this contract only when the user selects one of those labels; correction, deferral, free-text, and clarification answers never lock the pre-question proposal. Do not manually copy raw free text into intent evidence, and do not continue if this required recorder write fails. An incomplete Round 0 object (for example missing `deepInterview.ambiguity` or the whole `deepInterview.intent_contract`) is rejected before coercion with a correction naming the omitted fields; retry once with every required field populated from the visible payload context, never by re-sending the incomplete object or inventing contract items — the recorder never locks intent without an affirmative user answer.
269
269
 
270
270
  3. **Lock topology into state** after the answer. Store a normalized component list and confirmation timestamp:
271
271
 
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Fuzzy matching utilities for the edit tool.
3
3
  *
4
- * Provides both character-level and line-level fuzzy matching with progressive
5
- * fallback strategies for finding text in files.
4
+ * Provides both character-level and line-level fuzzy matching with staged
5
+ * strategies for finding text in files.
6
6
  */
7
7
  import type { AgentToolResult } from "@gajae-code/agent-core";
8
8
  import { markDesignedError } from "@gajae-code/utils/error-classification";
@@ -430,7 +430,7 @@ export function findContextLine(
430
430
  lines: string[],
431
431
  context: string,
432
432
  startFrom: number,
433
- options?: { allowFuzzy?: boolean; skipFunctionFallback?: boolean },
433
+ options?: { allowFuzzy?: boolean; skipParenRetry?: boolean },
434
434
  ): ContextLineResult {
435
435
  const allowFuzzy = options?.allowFuzzy ?? true;
436
436
  const trimmedContext = context.trim();
@@ -570,14 +570,14 @@ export function findContextLine(
570
570
  };
571
571
  }
572
572
 
573
- if (!options?.skipFunctionFallback && trimmedContext.endsWith("()")) {
573
+ if (!options?.skipParenRetry && trimmedContext.endsWith("()")) {
574
574
  const withParen = trimmedContext.replace(/\(\)\s*$/u, "(");
575
575
  const withoutParen = trimmedContext.replace(/\(\)\s*$/u, "");
576
- const parenResult = findContextLine(lines, withParen, startFrom, { allowFuzzy, skipFunctionFallback: true });
576
+ const parenResult = findContextLine(lines, withParen, startFrom, { allowFuzzy, skipParenRetry: true });
577
577
  if (parenResult.index !== undefined || (parenResult.matchCount ?? 0) > 0) {
578
578
  return parenResult;
579
579
  }
580
- return findContextLine(lines, withoutParen, startFrom, { allowFuzzy, skipFunctionFallback: true });
580
+ return findContextLine(lines, withoutParen, startFrom, { allowFuzzy, skipParenRetry: true });
581
581
  }
582
582
 
583
583
  return { index: undefined, confidence: bestScore };
@@ -7,7 +7,7 @@
7
7
  * - compute unified diff previews for the in-flight args
8
8
  * (`computeDiffPreview`), and
9
9
  * - render a text placeholder while no diff exists yet
10
- * (`renderStreamingFallback`).
10
+ * (`renderStreamingPlaceholder`).
11
11
  *
12
12
  * The shared renderer / `ToolExecutionComponent` consult the strategy via
13
13
  * the injected `editMode` rather than probing argument shape.
@@ -72,7 +72,7 @@ export interface EditStreamingStrategy<Args = unknown> {
72
72
  * Rendered inline while the diff hasn't been computed yet (or when the
73
73
  * compute returned `null` because args are still too partial).
74
74
  */
75
- renderStreamingFallback(args: Args, uiTheme: Theme): string;
75
+ renderStreamingPlaceholder(args: Args, uiTheme: Theme): string;
76
76
  }
77
77
  export interface EditRequestTargetInventory {
78
78
  paths: string[];
@@ -169,7 +169,7 @@ export function getEditRequestTargetInventory(
169
169
  if (editMode === "hashline") {
170
170
  const input = typeof values.input === "string" ? values.input : "";
171
171
  const headerPaths = getHashlineTargetPaths(input);
172
- // `path` is only a fallback for headerless input; explicit §PATH headers are the full target set.
172
+ // `path` is only the target for headerless input; explicit §PATH headers are the full target set.
173
173
  if (headerPaths.length > 0) return { paths: orderedDistinctPaths(headerPaths) };
174
174
  return { paths: orderedDistinctPaths([topLevelPath]) };
175
175
  }
@@ -197,8 +197,8 @@ export function getEditRequestTargetInventory(
197
197
  return { paths: orderedDistinctPaths([topLevelPath, ...editPaths, partialPath]) };
198
198
  }
199
199
 
200
- const STREAMING_FALLBACK_LINES = 12;
201
- const STREAMING_FALLBACK_WIDTH = 80;
200
+ const STREAMING_PLACEHOLDER_LINES = 12;
201
+ const STREAMING_PLACEHOLDER_WIDTH = 80;
202
202
 
203
203
  function isHashlineHeaderLine(line: string): boolean {
204
204
  return line.trimEnd().startsWith(HL_FILE_PREFIX);
@@ -236,15 +236,15 @@ function trimHashlineStreamingSyntax(lines: string[]): string[] {
236
236
  return lines.slice(index).filter(line => !isHashlineEnvelopeMarkerLine(line));
237
237
  }
238
238
 
239
- function renderHashlineInputFallback(input: string, uiTheme: Theme): string {
239
+ function renderHashlineInputPlaceholder(input: string, uiTheme: Theme): string {
240
240
  const lines = trimHashlineStreamingSyntax(sanitizeText(input).split("\n"));
241
241
  if (!lines.some(line => line.trim().length > 0)) return "";
242
242
 
243
- const displayLines = lines.slice(-STREAMING_FALLBACK_LINES);
243
+ const displayLines = lines.slice(-STREAMING_PLACEHOLDER_LINES);
244
244
  const hidden = lines.length - displayLines.length;
245
245
  let text = "\n\n";
246
246
  text += displayLines
247
- .map(line => uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), STREAMING_FALLBACK_WIDTH)))
247
+ .map(line => uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), STREAMING_PLACEHOLDER_WIDTH)))
248
248
  .join("\n");
249
249
  if (hidden > 0) {
250
250
  text += uiTheme.fg("dim", `\n… (streaming +${hidden} lines)`);
@@ -376,7 +376,7 @@ const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
376
376
  ctx.signal.throwIfAborted();
377
377
  return [toPerFilePreview(args.path, result)];
378
378
  },
379
- renderStreamingFallback() {
379
+ renderStreamingPlaceholder() {
380
380
  return "";
381
381
  },
382
382
  };
@@ -405,7 +405,7 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
405
405
  ctx.signal.throwIfAborted();
406
406
  return [toPerFilePreview(args.path, result)];
407
407
  },
408
- renderStreamingFallback() {
408
+ renderStreamingPlaceholder() {
409
409
  return "";
410
410
  },
411
411
  };
@@ -552,7 +552,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
552
552
  try {
553
553
  sections = splitHashlineInputs(input, { cwd: ctx.cwd, path: args.path });
554
554
  } catch {
555
- // Single-section fallback keeps the original error rendering for the
555
+ // Single-section handling keeps the original error rendering for the
556
556
  // "haven't typed `§ PATH` yet" case.
557
557
  const result = await computeHashlineDiff({ input, path: args.path }, ctx.cwd, {
558
558
  autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
@@ -590,8 +590,8 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
590
590
  }
591
591
  return previews.length > 0 ? previews : null;
592
592
  },
593
- renderStreamingFallback(args, uiTheme) {
594
- return typeof args.input === "string" ? renderHashlineInputFallback(args.input, uiTheme) : "";
593
+ renderStreamingPlaceholder(args, uiTheme) {
594
+ return typeof args.input === "string" ? renderHashlineInputPlaceholder(args.input, uiTheme) : "";
595
595
  },
596
596
  };
597
597
 
@@ -642,7 +642,7 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
642
642
  }
643
643
  return previews.length > 0 ? previews : null;
644
644
  },
645
- renderStreamingFallback() {
645
+ renderStreamingPlaceholder() {
646
646
  return "";
647
647
  },
648
648
  };
@@ -656,7 +656,7 @@ const vimStrategy: EditStreamingStrategy<unknown> = {
656
656
  async computeDiffPreview() {
657
657
  return null;
658
658
  },
659
- renderStreamingFallback() {
659
+ renderStreamingPlaceholder() {
660
660
  return "";
661
661
  },
662
662
  };
@@ -1,6 +1,7 @@
1
1
  import { dlopen, FFIType, ptr } from "bun:ffi";
2
2
  import * as childProcess from "node:child_process";
3
3
  import { createHmac } from "node:crypto";
4
+ import type { EventEmitter } from "node:events";
4
5
  import * as fs from "node:fs/promises";
5
6
  import * as os from "node:os";
6
7
  import * as path from "node:path";
@@ -121,6 +122,63 @@ async function reapLinuxAdoptedZombies(excludedPid: number | undefined): Promise
121
122
  }
122
123
  }
123
124
 
125
+ /**
126
+ * Reap adopted zombies only when the kernel reports a child state change.
127
+ *
128
+ * As the Linux child subreaper, the supervisor inherits orphaned descendants
129
+ * and must reap them, but a full `/proc` sweep is expensive. Polling it on a
130
+ * short interval kept every idle supervisor busy (issue #5972), so sweeps are
131
+ * driven by SIGCHLD instead. At most one sweep runs at a time, and a SIGCHLD that
132
+ * arrives mid-sweep schedules exactly one follow-up so a zombie created after
133
+ * its `/proc` entry was passed is never missed. Returns the unsubscribe hook.
134
+ */
135
+ export function startLinuxAdoptedZombieReaper(
136
+ excludedPid: number | undefined,
137
+ reap: (excludedPid: number | undefined) => Promise<void> = reapLinuxAdoptedZombies,
138
+ signals: Pick<EventEmitter, "on" | "off"> = process,
139
+ ): () => void {
140
+ if (process.platform !== "linux") return () => undefined;
141
+ let running = false;
142
+ let rerun = false;
143
+ let stopped = false;
144
+ let sweepPending = false;
145
+ const sweep = (): void => {
146
+ if (stopped) return;
147
+ if (running) {
148
+ rerun = true;
149
+ return;
150
+ }
151
+ if (sweepPending) return;
152
+ sweepPending = true;
153
+ running = true;
154
+ void reap(excludedPid)
155
+ .catch(() => undefined)
156
+ .finally(() => {
157
+ running = false;
158
+ sweepPending = false;
159
+ if (!rerun || stopped) return;
160
+ rerun = false;
161
+ // Defer recursive call to avoid stack overflow
162
+ setImmediate(() => {
163
+ if (!stopped && !running) sweep();
164
+ });
165
+ });
166
+ };
167
+ try {
168
+ signals.on("SIGCHLD", sweep);
169
+ } catch {
170
+ // Signal handler installation may fail in some environments
171
+ }
172
+ return () => {
173
+ stopped = true;
174
+ try {
175
+ signals.off("SIGCHLD", sweep);
176
+ } catch {
177
+ // Signal handler removal may fail in some environments
178
+ }
179
+ };
180
+ }
181
+
124
182
  function signalFromExit(code: number | null, signal: NodeJS.Signals | null): NodeJS.Signals | null {
125
183
  if (signal) return signal;
126
184
  if (code === null || code <= 128) return null;
@@ -210,8 +268,7 @@ export async function runBashShellSupervisor(): Promise<void> {
210
268
  stdio: ["pipe", "pipe", "pipe"],
211
269
  windowsHide: true,
212
270
  });
213
- const zombieReaper = setInterval(() => void reapLinuxAdoptedZombies(worker.pid), 25);
214
- zombieReaper.unref();
271
+ const stopZombieReaper = startLinuxAdoptedZombieReaper(worker.pid);
215
272
  const pendingRuns: number[] = [];
216
273
  let terminating = false;
217
274
  let protocolToken: string | undefined;
@@ -315,7 +372,7 @@ export async function runBashShellSupervisor(): Promise<void> {
315
372
  );
316
373
  });
317
374
  worker.once("exit", async (code, signal) => {
318
- clearInterval(zombieReaper);
375
+ stopZombieReaper();
319
376
  const resultSignal = signalFromExit(code, signal);
320
377
  if (code === 0 && resultSignal === null) {
321
378
  await reapLinuxSubreaperChildren();