@gajae-code/coding-agent 0.13.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1345,7 +1345,11 @@ export class InteractiveMode implements InteractiveModeContext {
1345
1345
  this.editorContainer.clear();
1346
1346
  this.editorContainer.addChild(this.editor);
1347
1347
  }
1348
+ // Re-mounting after a cancelled action must invalidate the editor so the
1349
+ // inline prompt suggestion is rendered again on the restored composer.
1350
+ this.editor.invalidate();
1348
1351
  this.ui.setFocus(this.editor);
1352
+ this.ui.requestRender();
1349
1353
  }
1350
1354
 
1351
1355
  #createPetWidget(editor: CustomEditor): GajaePetWidget {
@@ -40,7 +40,12 @@ export interface ProcessIncarnationOptions {
40
40
 
41
41
  function runProcessIncarnationCommand(command: string, args: readonly string[]): ProcessIncarnationCommandResult {
42
42
  try {
43
- const result = Bun.spawnSync([command, ...args], { stdin: "ignore", stdout: "pipe", stderr: "ignore" });
43
+ const result = Bun.spawnSync([command, ...args], {
44
+ stdin: "ignore",
45
+ stdout: "pipe",
46
+ stderr: "ignore",
47
+ windowsHide: true,
48
+ });
44
49
  return { exitCode: result.exitCode, stdout: Buffer.from(result.stdout).toString("utf8") };
45
50
  } catch {
46
51
  return undefined;
@@ -105,8 +105,10 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
105
105
  * settles non-forum close responses. Generation 62 rejects replay-gap
106
106
  * authority claims that do not match the requested cursor, answer bounds,
107
107
  * and retained replay suffix.
108
+ * Generation 63 contains transient steady-heartbeat state and ownership-lock
109
+ * read failures and cleans every staged sidecar before retrying (#4200).
108
110
  */
109
- export const DAEMON_GENERATION = 62;
111
+ export const DAEMON_GENERATION = 63;
110
112
 
111
113
  /**
112
114
  * Serving-compatibility boundary for daemon lifecycle requests. Epoch 5
@@ -1419,38 +1419,38 @@ export async function renewOwnerHeartbeatSidecar(input: {
1419
1419
  logger.warn(`notifications: heartbeat sidecar staging failed: ${sanitizeDiagnostic(String(error))}`);
1420
1420
  return "publish_failed";
1421
1421
  }
1422
- const lock = await readOwnershipLock(fsImpl, paths.lock);
1423
- if (!ownershipLockMatchesState(lock, state)) {
1424
- await fsImpl.unlink(tmp).catch(() => undefined);
1425
- return "not_owner";
1426
- }
1427
- for (let attempt = 1; ; attempt++) {
1428
- // Revalidate the exact lock after the temporary sidecar exists — and again
1429
- // before every retry — so a stale writer cannot publish after ownership
1430
- // moved during its write or its backoff.
1431
- const finalLock = await readOwnershipLock(fsImpl, paths.lock);
1432
- if (!ownershipLockMatchesState(finalLock, state)) {
1433
- await fsImpl.unlink(tmp).catch(() => undefined);
1434
- return "not_owner";
1435
- }
1436
- try {
1437
- await fsImpl.rename(tmp, paths.heartbeat);
1438
- return "renewed";
1439
- } catch (error) {
1440
- // Windows: a transient external lock on the destination (antivirus,
1441
- // indexer) surfaces as EPERM on rename and used to escape as an
1442
- // uncaught exception that killed the whole daemon (#4200). Retry a
1443
- // bounded number of times under the lock fence, then keep the daemon
1444
- // alive and let the next heartbeat cycle publish.
1445
- if (attempt >= HEARTBEAT_SIDECAR_PUBLISH_ATTEMPTS || !isTransientSidecarPublishError(error)) {
1446
- await fsImpl.unlink(tmp).catch(() => undefined);
1447
- logger.warn(
1448
- `notifications: heartbeat sidecar publication failed after ${attempt} attempt(s): ${sanitizeDiagnostic(String(error))}`,
1449
- );
1450
- return "publish_failed";
1422
+ try {
1423
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
1424
+ if (!ownershipLockMatchesState(lock, state)) return "not_owner";
1425
+ for (let attempt = 1; ; attempt++) {
1426
+ // Revalidate the exact lock after the temporary sidecar exists — and again
1427
+ // before every retry — so a stale writer cannot publish after ownership
1428
+ // moved during its write or its backoff.
1429
+ const finalLock = await readOwnershipLock(fsImpl, paths.lock);
1430
+ if (!ownershipLockMatchesState(finalLock, state)) return "not_owner";
1431
+ try {
1432
+ await fsImpl.rename(tmp, paths.heartbeat);
1433
+ return "renewed";
1434
+ } catch (error) {
1435
+ // Windows: a transient external lock on the destination (antivirus,
1436
+ // indexer) surfaces as EPERM on rename and used to escape as an
1437
+ // uncaught exception that killed the whole daemon (#4200). Retry a
1438
+ // bounded number of times under the lock fence, then keep the daemon
1439
+ // alive and let the next heartbeat cycle publish.
1440
+ if (attempt >= HEARTBEAT_SIDECAR_PUBLISH_ATTEMPTS || !isTransientSidecarPublishError(error)) {
1441
+ logger.warn(
1442
+ `notifications: heartbeat sidecar publication failed after ${attempt} attempt(s): ${sanitizeDiagnostic(String(error))}`,
1443
+ );
1444
+ return "publish_failed";
1445
+ }
1446
+ await Bun.sleep(HEARTBEAT_SIDECAR_PUBLISH_BACKOFF_MS * attempt);
1451
1447
  }
1452
- await Bun.sleep(HEARTBEAT_SIDECAR_PUBLISH_BACKOFF_MS * attempt);
1453
1448
  }
1449
+ } finally {
1450
+ // A thrown state or ownership-lock read after the staging write must not
1451
+ // leak the live-PID staging file. After a successful rename the staging
1452
+ // path no longer exists and this unlink is a no-op (#4200).
1453
+ await fsImpl.unlink(tmp).catch(() => undefined);
1454
1454
  }
1455
1455
  }
1456
1456
 
@@ -12490,19 +12490,27 @@ export class TelegramNotificationDaemon {
12490
12490
  let idleSince = this.runtime.now();
12491
12491
  while (this.running) {
12492
12492
  if (await this.controlStopRequested()) break;
12493
- if (
12494
- (await renewOwnerHeartbeatSidecar({
12495
- settings: this.opts.settings,
12496
- ownerId: this.opts.ownerId,
12497
- acquisitionId: this.opts.ownerId,
12498
- fs: this.fsImpl,
12499
- now: this.opts.now,
12500
- pid: this.opts.pid ?? process.pid,
12501
- pidIncarnation: this.opts.pidIncarnation,
12502
- attachedEndpoints: this.attachedEndpointCount(),
12503
- })) === "not_owner"
12504
- )
12505
- break;
12493
+ // Transient Windows state/lock read failures must not terminate the
12494
+ // notification daemon. Only a proven ownership mismatch stops it.
12495
+ let ownerHeld = true;
12496
+ try {
12497
+ ownerHeld =
12498
+ (await renewOwnerHeartbeatSidecar({
12499
+ settings: this.opts.settings,
12500
+ ownerId: this.opts.ownerId,
12501
+ acquisitionId: this.opts.ownerId,
12502
+ fs: this.fsImpl,
12503
+ now: this.opts.now,
12504
+ pid: this.opts.pid ?? process.pid,
12505
+ pidIncarnation: this.opts.pidIncarnation,
12506
+ attachedEndpoints: this.attachedEndpointCount(),
12507
+ })) !== "not_owner";
12508
+ } catch (error) {
12509
+ logger.warn(
12510
+ `notifications: ownership heartbeat renewal threw; continuing: ${sanitizeDiagnostic(String(error))}`,
12511
+ );
12512
+ }
12513
+ if (!ownerHeld) break;
12506
12514
  await this.runScan();
12507
12515
  if (await this.controlStopRequested()) break;
12508
12516
  const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
@@ -9,6 +9,7 @@ import type {
9
9
  RecoveryFsIdentity,
10
10
  RecoveryFsRoot,
11
11
  } from "@gajae-code/natives";
12
+ import { isEnoent, logger } from "@gajae-code/utils";
12
13
  import type { SessionStorageRangeSnapshot, SessionStorageStat } from "../session-storage";
13
14
  import {
14
15
  classifyNativePublishOutcome,
@@ -25,8 +26,10 @@ type NativeManagedSessionStorage = Pick<
25
26
  | "exactReplacePath"
26
27
  | "exactUnlink"
27
28
  | "linkNoReplacePath"
29
+ | "linkNoReplacePathAsync"
28
30
  | "openRecoveryFsRoot"
29
31
  | "renameNoReplacePath"
32
+ | "renameNoReplacePathAsync"
30
33
  | "repairOwnerOnlyPathSecurityExpected"
31
34
  | "snapshotDirectoryTree"
32
35
  | "verifyOwnerOnlyFdSecurity"
@@ -497,6 +500,22 @@ const SCRUBBED_REMNANT_PREFIXES = [
497
500
  /** In-flight protocol steps complete in milliseconds; anything older is abandoned. */
498
501
  const SCRUBBED_REMNANT_MIN_AGE_MS = 15 * 60 * 1000;
499
502
 
503
+ /** Minimum interval between best-effort remnant reaps of one bound store directory. */
504
+ const SCRUBBED_REMNANT_REAP_INTERVAL_MS = 60_000;
505
+
506
+ export interface ScrubbedProtocolRemnantReapResult {
507
+ readonly reaped: number;
508
+ readonly failures: number;
509
+ }
510
+
511
+ function reportScrubbedProtocolRemnantReap(reaped: number, failures: number): ScrubbedProtocolRemnantReapResult {
512
+ if (failures > 0)
513
+ logger.warn("Managed session remnant reaping completed with failures", {
514
+ failureCount: failures,
515
+ reapedCount: reaped,
516
+ });
517
+ return { reaped, failures };
518
+ }
500
519
  /**
501
520
  * Best-effort removal of scrubbed write-protocol remnants from one managed
502
521
  * directory. Only zero-length, single-link, non-symlink regular files whose
@@ -535,6 +554,48 @@ export function reapScrubbedProtocolRemnantsSync(
535
554
  return reaped;
536
555
  }
537
556
 
557
+ /** Number of candidate remnants inspected between event-loop yields. */
558
+ const SCRUBBED_REMNANT_REAP_BATCH_SIZE = 256;
559
+
560
+ /**
561
+ * Async twin of {@link reapScrubbedProtocolRemnantsSync} with the same safety
562
+ * filters (terminal remnant prefix, zero-length, single-link, non-symlink,
563
+ * older than the age gate), yielding between bounded batches. Long-lived
564
+ * processes reap per-session descendant directories through this path so a
565
+ * legacy oversized directory cannot starve timers or sibling subagents while
566
+ * it is being drained (issue #4394).
567
+ */
568
+ export async function reapScrubbedProtocolRemnants(
569
+ directory: string,
570
+ minAgeMs: number = SCRUBBED_REMNANT_MIN_AGE_MS,
571
+ ): Promise<ScrubbedProtocolRemnantReapResult> {
572
+ let names: string[];
573
+ try {
574
+ names = await fsp.readdir(directory);
575
+ } catch (error) {
576
+ return reportScrubbedProtocolRemnantReap(0, isEnoent(error) ? 0 : 1);
577
+ }
578
+ const cutoff = Date.now() - minAgeMs;
579
+ let reaped = 0;
580
+ let failures = 0;
581
+ let scanned = 0;
582
+ for (const name of names) {
583
+ if (!SCRUBBED_REMNANT_PREFIXES.some(prefix => name.startsWith(prefix))) continue;
584
+ if (++scanned % SCRUBBED_REMNANT_REAP_BATCH_SIZE === 0) await Bun.sleep(0);
585
+ const pathname = path.join(directory, name);
586
+ try {
587
+ const named = await fsp.lstat(pathname);
588
+ if (!named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || named.size !== 0) continue;
589
+ if (named.mtimeMs > cutoff) continue;
590
+ await fsp.unlink(pathname);
591
+ reaped += 1;
592
+ } catch (error) {
593
+ if (!isEnoent(error)) failures += 1;
594
+ }
595
+ }
596
+ return reportScrubbedProtocolRemnantReap(reaped, failures);
597
+ }
598
+
538
599
  const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]);
539
600
  const ACL_CLEAR_EVIDENCE = new Set(["cleared", "already_absent", "unsupported", "not_run"]);
540
601
  const GENERAL_FAILURE_CODES = new Set([
@@ -895,6 +956,8 @@ export class ManagedSessionDescendantStore {
895
956
  #ownsAuthority = false;
896
957
  #closed = false;
897
958
  #reconcilingReplacementCleanup = false;
959
+ #remnantReapInFlight = false;
960
+ #lastRemnantReapAttempt = 0;
898
961
  readonly #authorityBaseDir: string;
899
962
  /** Logical profile root inherited by nested managed session destinations. */
900
963
  readonly #profileAgentDir: string;
@@ -1340,6 +1403,30 @@ export class ManagedSessionDescendantStore {
1340
1403
  this.#assertBound();
1341
1404
  this.#reconcileReplacementCleanupReceipts();
1342
1405
  this.#assertBound();
1406
+ this.#scheduleRemnantReap();
1407
+ }
1408
+
1409
+ /**
1410
+ * Best-effort asynchronous reaping of scrubbed write-protocol remnants in
1411
+ * this store's bound directory. Replacements leak zero-byte remnants on
1412
+ * platforms without retained authority (macOS), and scope-resolution reaping
1413
+ * never visits per-session descendant directories, so unbounded remnant
1414
+ * growth there degraded every namespace mutation and let one publication
1415
+ * stall the whole process (issue #4394). Reaping is throttled, serialized
1416
+ * per store, and never fails the triggering mutation.
1417
+ */
1418
+ #scheduleRemnantReap(): void {
1419
+ const now = Date.now();
1420
+ if (this.#remnantReapInFlight || now - this.#lastRemnantReapAttempt < SCRUBBED_REMNANT_REAP_INTERVAL_MS) return;
1421
+ this.#lastRemnantReapAttempt = now;
1422
+ this.#remnantReapInFlight = true;
1423
+ void reapScrubbedProtocolRemnants(this.#baseDir)
1424
+ .catch((error: unknown) => {
1425
+ logger.warn("Managed session remnant reaping failed", { error: String(error) });
1426
+ })
1427
+ .finally(() => {
1428
+ this.#remnantReapInFlight = false;
1429
+ });
1343
1430
  }
1344
1431
 
1345
1432
  ensureDirectory(relativePath = ""): ManagedDirectoryRoot {
@@ -2255,6 +2342,17 @@ function fsyncDirectory(pathname: string): void {
2255
2342
  }
2256
2343
  }
2257
2344
 
2345
+ /** Async twin of {@link fsyncDirectory} for the off-loop publication path. */
2346
+ async function fsyncDirectoryAsync(pathname: string): Promise<void> {
2347
+ if (!shouldFsyncManagedDirectory()) return;
2348
+ const handle = await fsp.open(pathname, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
2349
+ try {
2350
+ await handle.sync();
2351
+ } finally {
2352
+ await handle.close();
2353
+ }
2354
+ }
2355
+
2258
2356
  function bootId(): string | undefined {
2259
2357
  try {
2260
2358
  return fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
@@ -2536,7 +2634,7 @@ export async function publishManagedFileNoReplace(
2536
2634
  const parent = path.dirname(destination);
2537
2635
  ensureManagedDirectory(parent, root, policy);
2538
2636
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
2539
- let fd: number | undefined;
2637
+ let handle: fsp.FileHandle | undefined;
2540
2638
  let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
2541
2639
  let failure: unknown;
2542
2640
  let outcome: NativePublishOutcome | undefined;
@@ -2545,36 +2643,47 @@ export async function publishManagedFileNoReplace(
2545
2643
 
2546
2644
  try {
2547
2645
  assertOwned?.();
2548
- fd = fs.openSync(
2646
+ // The whole staging + publication chain is awaited off the resident event
2647
+ // loop: FileHandle operations run on the libuv pool and the no-replace
2648
+ // namespace publication crosses the async native boundary. A rename that
2649
+ // stalls in the kernel — e.g. into an oversized APFS directory namespace,
2650
+ // issue #4394 — blocks one pool thread instead of freezing the process:
2651
+ // await timeouts, sibling subagents, and watchdogs keep running, and a
2652
+ // hung publication degrades to one unresolved receipt.
2653
+ handle = await fsp.open(
2549
2654
  staging,
2550
2655
  fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
2551
2656
  0o600,
2552
2657
  );
2553
- secureFileDescriptor(staging, fd, "apply");
2658
+ secureFileDescriptor(staging, handle.fd, "apply");
2554
2659
  let offset = 0;
2555
- while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
2556
- fs.fsyncSync(fd);
2557
- secureFileDescriptor(staging, fd, "verify");
2558
- const staged = fs.fstatSync(fd, { bigint: true });
2660
+ while (offset < bytes.byteLength) {
2661
+ const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset);
2662
+ if (bytesWritten <= 0) throw new Error("managed_publish_failed");
2663
+ offset += bytesWritten;
2664
+ }
2665
+ await handle.sync();
2666
+ secureFileDescriptor(staging, handle.fd, "verify");
2667
+ const staged = await handle.stat({ bigint: true });
2559
2668
  stagingIdentity = { dev: staged.dev, ino: staged.ino };
2560
2669
  assertOwned?.();
2561
2670
 
2562
2671
  renameAttempted = true;
2563
- outcome = classifyNativePublishOutcome(nativeSessionStorage().renameNoReplacePath(staging, destination));
2672
+ outcome = classifyNativePublishOutcome(
2673
+ await nativeSessionStorage().renameNoReplacePathAsync(staging, destination),
2674
+ );
2564
2675
  if (renameFlagsUnsupported(outcome)) {
2565
- // linkat publishes the destination without consuming the staging name, so the
2566
- // secured staging descriptor stays authoritative across publication exactly as
2567
- // it does across a rename. The staging link is removed in the finally block
2568
- // below, after that descriptor is closed: unlinking a still-open name on NFS
2569
- // silly-renames it instead of removing it, which would leave a second link on
2570
- // the published inode.
2571
- outcome = classifyNativePublishOutcome(nativeSessionStorage().linkNoReplacePath(staging, destination));
2676
+ // See publishManagedFileNoReplaceSync: the staging link outlives this
2677
+ // publication and is removed only after the secured descriptor is closed.
2678
+ outcome = classifyNativePublishOutcome(
2679
+ await nativeSessionStorage().linkNoReplacePathAsync(staging, destination),
2680
+ );
2572
2681
  linkPublished = outcome.ok;
2573
2682
  }
2574
2683
 
2575
2684
  if (!outcome.ok) throw publishFailure(outcome);
2576
2685
 
2577
- const named = fs.lstatSync(destination, { bigint: true });
2686
+ const named = await fsp.lstat(destination, { bigint: true });
2578
2687
  if (
2579
2688
  !named.isFile() ||
2580
2689
  named.isSymbolicLink() ||
@@ -2583,15 +2692,21 @@ export async function publishManagedFileNoReplace(
2583
2692
  ) {
2584
2693
  throw new Error("destination_identity_changed");
2585
2694
  }
2586
- secureFileDescriptor(destination, fd, "verify");
2587
- fs.closeSync(fd);
2588
- fd = undefined;
2695
+ secureFileDescriptor(destination, handle.fd, "verify");
2696
+ await handle.close();
2697
+ handle = undefined;
2589
2698
 
2590
- fsyncDirectory(parent);
2699
+ await fsyncDirectoryAsync(parent);
2591
2700
  } catch (error) {
2592
2701
  failure = error;
2593
2702
  } finally {
2594
- if (fd !== undefined) fs.closeSync(fd);
2703
+ if (handle !== undefined) {
2704
+ try {
2705
+ await handle.close();
2706
+ } catch (error) {
2707
+ failure ??= error;
2708
+ }
2709
+ }
2595
2710
 
2596
2711
  if (stagingIdentity && (linkPublished || !renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
2597
2712
  await fsp
@@ -23,7 +23,7 @@ import {
23
23
  } from "../config/model-resolver";
24
24
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../discovery/helpers.js";
25
25
  import { DynamicBorder } from "../modes/components/dynamic-border";
26
- import { theme } from "../modes/theme/theme";
26
+ import { getAvailableThemes, getDetectedThemeSettingsPath, setTheme, theme } from "../modes/theme/theme";
27
27
  import {
28
28
  type ComposerSubmissionOptions,
29
29
  canApplyComposerSubmission,
@@ -709,10 +709,48 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
709
709
  },
710
710
  {
711
711
  name: "theme",
712
- description: "Open theme selector",
713
- handleTui: (_command, runtime) => {
714
- runtime.ctx.showThemeSelector();
715
- runtime.ctx.editor.setText("");
712
+ description: "Change theme immediately, or open the theme selector without args",
713
+ inlineHint: "[theme]",
714
+ allowArgs: true,
715
+ handleTui: async (command, runtime) => {
716
+ const ctx = runtime.ctx;
717
+ const name = command.args?.trim();
718
+ if (!name) {
719
+ ctx.showThemeSelector();
720
+ ctx.editor.setText("");
721
+ return;
722
+ }
723
+ const available = await getAvailableThemes();
724
+ if (!available.includes(name)) {
725
+ ctx.showError(`Unknown theme "${name}". Available themes: ${available.join(", ")}`);
726
+ ctx.editor.setText("");
727
+ return;
728
+ }
729
+ if (!ctx.settings.canWriteDurableConfig()) {
730
+ ctx.showError(
731
+ "Cannot change settings while config.yml has invalid YAML syntax. Repair config.yml and reload settings.",
732
+ );
733
+ ctx.editor.setText("");
734
+ return;
735
+ }
736
+ try {
737
+ ctx.settings.set(getDetectedThemeSettingsPath(), name);
738
+ } catch (error) {
739
+ ctx.showError(error instanceof Error ? error.message : String(error));
740
+ ctx.editor.setText("");
741
+ return;
742
+ }
743
+ const result = await setTheme(name, true, { shouldApply: () => !ctx.isStopped?.() });
744
+ if (ctx.isStopped?.()) return;
745
+ ctx.statusLine.invalidate();
746
+ ctx.updateEditorTopBorder();
747
+ ctx.ui.invalidate();
748
+ if (result.success) {
749
+ ctx.showStatus(`Theme changed to ${name}`);
750
+ } else {
751
+ ctx.showError(`Failed to load theme "${name}": ${result.error}\nFell back to dark theme.`);
752
+ }
753
+ ctx.editor.setText("");
716
754
  },
717
755
  },
718
756
  {
package/src/task/index.ts CHANGED
@@ -1900,6 +1900,9 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1900
1900
  file => path.basename(file.path).toLowerCase() !== "agents.md",
1901
1901
  );
1902
1902
  const promptTemplates = this.session.promptTemplates;
1903
+ const parentMcpManager = this.session.getMcpManager?.();
1904
+ // Exact-config tools-only managers belong to the top-level ACP session and cannot be reused by sub-sessions.
1905
+ const reusableParentMcpManager = parentMcpManager?.isToolsOnly() ? undefined : parentMcpManager;
1903
1906
 
1904
1907
  // Initialize progress for all tasks
1905
1908
  for (let i = 0; i < tasksWithUniqueIds.length; i++) {
@@ -2036,7 +2039,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
2036
2039
  parentArtifactManager,
2037
2040
  parentHindsightSessionState: this.session.getHindsightSessionState?.(),
2038
2041
  parentTelemetry: this.session.getTelemetry?.(),
2039
- parentMcpManager: this.session.getMcpManager?.(),
2042
+ parentMcpManager: reusableParentMcpManager,
2040
2043
  forkContextSeed,
2041
2044
  });
2042
2045
  return {
@@ -2113,7 +2116,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
2113
2116
  parentArtifactManager,
2114
2117
  parentHindsightSessionState: this.session.getHindsightSessionState?.(),
2115
2118
  parentTelemetry: this.session.getTelemetry?.(),
2116
- parentMcpManager: this.session.getMcpManager?.(),
2119
+ parentMcpManager: reusableParentMcpManager,
2117
2120
  forkContextSeed,
2118
2121
  });
2119
2122
  let capturedResult = {
package/src/tools/bash.ts CHANGED
@@ -862,26 +862,28 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
862
862
  return { kind: "aborted" };
863
863
  }
864
864
 
865
+ const threshold = Promise.withResolvers<{ kind: "running" }>();
866
+ const thresholdTimer = setTimeout(() => threshold.resolve({ kind: "running" }), Math.max(0, thresholdMs));
865
867
  const waiters: Array<Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }>> = [
866
868
  job.completion,
867
- Bun.sleep(thresholdMs).then(() => ({ kind: "running" as const })),
869
+ threshold.promise,
868
870
  ];
869
871
  if (backgroundRequest) {
870
872
  waiters.push(backgroundRequest.then(() => ({ kind: "running" as const })));
871
873
  }
872
874
 
873
- if (!signal) {
874
- return await Promise.race(waiters);
875
+ let onAbort: (() => void) | undefined;
876
+ if (signal) {
877
+ const aborted = Promise.withResolvers<{ kind: "aborted" }>();
878
+ onAbort = () => aborted.resolve({ kind: "aborted" });
879
+ signal.addEventListener("abort", onAbort, { once: true });
880
+ waiters.push(aborted.promise);
875
881
  }
876
-
877
- const { promise: abortedPromise, resolve: resolveAborted } = Promise.withResolvers<{ kind: "aborted" }>();
878
- const onAbort = () => resolveAborted({ kind: "aborted" });
879
- signal.addEventListener("abort", onAbort, { once: true });
880
- waiters.push(abortedPromise);
881
882
  try {
882
883
  return await Promise.race(waiters);
883
884
  } finally {
884
- signal.removeEventListener("abort", onAbort);
885
+ clearTimeout(thresholdTimer);
886
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
885
887
  }
886
888
  }
887
889