From c8ab07ca8c5fbd9352c0fc6dbb84644abf13924c Mon Sep 17 00:00:00 2001 From: Dayoooun Date: Wed, 12 Aug 2026 17:47:07 +0900 Subject: [PATCH 01/12] fix(sdk): hide the Windows process-incarnation PowerShell console The broker's process-liveness probe spawned powershell.exe without windowsHide. On Windows 11, where console delegation defaults to Windows Terminal, every probe therefore opened a real terminal window that took focus. The PowerShell path runs whenever the native reader cannot bind the target pid, so a single dead or inaccessible pid turned ~2s liveness polling into a continuous window flash for the life of the broker. Every other internal spawn in the repo already passed windowsHide: true. Lore-id: 7c41e9a2 Confidence: high Scope-risk: narrow Reversibility: safe Tested: spawn options carry windowsHide when the native reader cannot bind the pid Not-tested: real Windows Terminal window suppression, which has no automatable seam --- packages/coding-agent/CHANGELOG.md | 2 ++ .../src/sdk/broker/process-incarnation.ts | 7 +++++- .../test/sdk-broker-lifecycle-e2e.test.ts | 22 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f843cff533..1b7a60a69b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +### Fixed +- The SDK broker's Windows process-liveness probe no longer opens a console window on every poll. `runProcessIncarnationCommand` spawned `powershell.exe` without `windowsHide`, and on Windows 11 — where the default terminal delegation hands every new console to Windows Terminal — each probe therefore created a visible terminal window that stole focus. The PowerShell path runs whenever the native reader cannot bind the target pid, so one dead or inaccessible pid turned the broker's ~2s liveness polling into a continuous window flash for the life of the process. Every other internal spawn in the repo already passed `windowsHide: true`; this one did not. ## [0.13.1] - 2026-08-11 ### Added diff --git a/packages/coding-agent/src/sdk/broker/process-incarnation.ts b/packages/coding-agent/src/sdk/broker/process-incarnation.ts index da993f9ba5..52e3e61176 100644 --- a/packages/coding-agent/src/sdk/broker/process-incarnation.ts +++ b/packages/coding-agent/src/sdk/broker/process-incarnation.ts @@ -40,7 +40,12 @@ export interface ProcessIncarnationOptions { function runProcessIncarnationCommand(command: string, args: readonly string[]): ProcessIncarnationCommandResult { try { - const result = Bun.spawnSync([command, ...args], { stdin: "ignore", stdout: "pipe", stderr: "ignore" }); + const result = Bun.spawnSync([command, ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + windowsHide: true, + }); return { exitCode: result.exitCode, stdout: Buffer.from(result.stdout).toString("utf8") }; } catch { return undefined; diff --git a/packages/coding-agent/test/sdk-broker-lifecycle-e2e.test.ts b/packages/coding-agent/test/sdk-broker-lifecycle-e2e.test.ts index 0377c11a30..e7106bb0cf 100644 --- a/packages/coding-agent/test/sdk-broker-lifecycle-e2e.test.ts +++ b/packages/coding-agent/test/sdk-broker-lifecycle-e2e.test.ts @@ -814,6 +814,28 @@ test("broker reads Windows process incarnations as canonical FILETIME ticks with }), ).toBe("windows:133830291061234568"); }); +test("broker reads Windows process incarnations without surfacing a console window", () => { + // The PowerShell fallback runs whenever the native reader cannot bind the pid. Without + // windowsHide the broker's liveness polling flashes a console window on every probe. + const fromPid = vi.spyOn(native.Process, "fromPid").mockReturnValue(null); + const spawnSync = vi.spyOn(Bun, "spawnSync").mockReturnValue({ + exitCode: 0, + stdout: Buffer.from("4242\t133830291061234567\r\n", "utf8"), + stderr: Buffer.alloc(0), + success: true, + signalCode: null, + resourceUsage: undefined, + } as unknown as Bun.SyncSubprocess<"pipe", "ignore">); + try { + expect(processIncarnation(4_242, { platform: "win32" })).toBe("windows:133830291061234567"); + expect(spawnSync).toHaveBeenCalledTimes(1); + const options = spawnSync.mock.calls[0]?.[1] as { windowsHide?: boolean } | undefined; + expect(options?.windowsHide).toBe(true); + } finally { + spawnSync.mockRestore(); + fromPid.mockRestore(); + } +}); test("broker fails closed for failed or malformed Windows FILETIME process-incarnation output", () => { const options = { From ca4d88c6410af4b26122b7f3939d90dd98738c12 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Wed, 12 Aug 2026 22:32:35 +0000 Subject: [PATCH 02/12] fix(session): publish managed output off the event loop and reap per-session remnants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A leaf subagent's managed output publication ran its whole staging chain (create/write/fsync/no-replace rename) synchronously on the agent's main thread. When rename(2) stalls in the kernel — oversized APFS directory namespaces on macOS — the resident event loop froze: orchestrator, sibling subagents, and every Bun.sleep-based subagent await timeout starved, so long orchestration runs hung silently with no error and no receipt (#4394). The async publishManagedFileNoReplace chain now runs on the libuv blocking pool through async FileHandle operations and two new native boundaries, renameNoReplacePathAsync/linkNoReplacePathAsync, that wrap the existing checked no-replace primitives in the crate's established blocking-task pool. A kernel-blocked rename now occupies one pool thread while timers and siblings keep running; a hung publication degrades to one unresolved receipt with the child .jsonl transcript still the source of truth. The same unbounded remnant growth that made the kernel hang probable is now bounded where it grows: scrubbed write-protocol remnants (zero-byte, single-link, age-gated, terminal prefixes only) are reaped inside per-session descendant directories by a throttled, serialized, batch- yielding best-effort reaper scheduled from each bound ManagedSessionDescendantStore before mutations — previously reaping ran only at managed scope resolution and never visited per-session dirs. Atomic no-replace semantics, staging identity verification, owner-only security checks, replacement cleanup receipts, and crash recovery are unchanged; the synchronous publication path is byte-identical. Lore-id: 4394a01 Constraint: preserve atomic no-replace, receipt, provenance, and crash-recovery guarantees Constraint: no redesign of the retained-authority (Linux) publication path Rejected: worker-thread publication | authority/security context cannot transfer cheaply; broad redesign Rejected: wall-clock watchdog only | cannot fire while the loop is blocked in a syscall Confidence: high Scope-risk: narrow Reversibility: revert-clean Tested: async publication parity/conflict/liveness, remnant reaper filters/boundedness/store scheduling, managed output generation end-to-end, neighboring session/task suites, cargo path_identity suite, check:rs Not-tested: macOS kernel-block repro (platform-gated; contract evidence is platform-independent) --- crates/pi-natives/src/path_identity.rs | 31 +++ packages/coding-agent/CHANGELOG.md | 1 + .../internal/managed-session-storage.ts | 156 +++++++++-- .../managed-publication-event-loop.test.ts | 252 ++++++++++++++++++ packages/natives/CHANGELOG.md | 3 + packages/natives/native/index.d.ts | 16 ++ packages/natives/native/index.js | 2 + 7 files changed, 440 insertions(+), 21 deletions(-) create mode 100644 packages/coding-agent/test/managed-publication-event-loop.test.ts diff --git a/crates/pi-natives/src/path_identity.rs b/crates/pi-natives/src/path_identity.rs index e0213d3349..ad4dc73328 100644 --- a/crates/pi-natives/src/path_identity.rs +++ b/crates/pi-natives/src/path_identity.rs @@ -12,6 +12,7 @@ use napi_derive::napi; use parking_lot::Mutex; use sha2::{Digest, Sha256}; +use crate::task; /// Classification of a read-only retained-publication observation. #[napi(object)] pub struct NativeBrokerPublicationObservation { @@ -1013,6 +1014,36 @@ pub fn link_no_replace_path( )) } +/// Async variant of [`rename_no_replace_path`] scheduled on the libuv blocking +/// pool. +/// +/// Managed output publication awaits this boundary so a rename that stalls in +/// the kernel (oversized APFS directory namespaces, issue #4394) blocks one +/// pool thread instead of the agent's event loop: await timeouts, sibling +/// subagents, and watchdogs keep running, and a hung publication degrades to +/// one unresolved receipt rather than a frozen process. +#[napi] +pub fn rename_no_replace_path_async( + source_path: String, + destination_path: String, +) -> task::Promise { + task::blocking("rename_no_replace_path", (), move |_| { + Ok(rename_no_replace_path(source_path, destination_path)) + }) +} + +/// Async variant of [`link_no_replace_path`] scheduled on the libuv blocking +/// pool; see [`rename_no_replace_path_async`] for the rationale. +#[napi] +pub fn link_no_replace_path_async( + source_path: String, + destination_path: String, +) -> task::Promise { + task::blocking("link_no_replace_path", (), move |_| { + Ok(link_no_replace_path(source_path, destination_path)) + }) +} + /// Capture a deterministic, descriptor-relative snapshot of a regular-file and /// directory-only tree. Symlinks, special files, non-UTF-8 names, and topology /// changes are rejected rather than followed. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1b7a60a69b..28eb407c54 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Fixed +- Managed output publication no longer freezes the resident event loop when a no-replace rename stalls in the kernel (#4394). Async managed publication now uses async file operations and native blocking-pool rename/link boundaries, while per-session stores also reap scrubbed protocol remnants on a throttled, serialized schedule. Atomic no-replace semantics and the synchronous publication path are unchanged. - The SDK broker's Windows process-liveness probe no longer opens a console window on every poll. `runProcessIncarnationCommand` spawned `powershell.exe` without `windowsHide`, and on Windows 11 — where the default terminal delegation hands every new console to Windows Terminal — each probe therefore created a visible terminal window that stole focus. The PowerShell path runs whenever the native reader cannot bind the target pid, so one dead or inaccessible pid turned the broker's ~2s liveness polling into a continuous window flash for the life of the process. Every other internal spawn in the repo already passed `windowsHide: true`; this one did not. ## [0.13.1] - 2026-08-11 diff --git a/packages/coding-agent/src/session/internal/managed-session-storage.ts b/packages/coding-agent/src/session/internal/managed-session-storage.ts index dccd63be2b..5379b52c4d 100644 --- a/packages/coding-agent/src/session/internal/managed-session-storage.ts +++ b/packages/coding-agent/src/session/internal/managed-session-storage.ts @@ -25,8 +25,10 @@ type NativeManagedSessionStorage = Pick< | "exactReplacePath" | "exactUnlink" | "linkNoReplacePath" + | "linkNoReplacePathAsync" | "openRecoveryFsRoot" | "renameNoReplacePath" + | "renameNoReplacePathAsync" | "repairOwnerOnlyPathSecurityExpected" | "snapshotDirectoryTree" | "verifyOwnerOnlyFdSecurity" @@ -497,6 +499,22 @@ const SCRUBBED_REMNANT_PREFIXES = [ /** In-flight protocol steps complete in milliseconds; anything older is abandoned. */ const SCRUBBED_REMNANT_MIN_AGE_MS = 15 * 60 * 1000; +/** Minimum interval between best-effort remnant reaps of one bound store directory. */ +const SCRUBBED_REMNANT_REAP_INTERVAL_MS = 60_000; + +export interface ScrubbedProtocolRemnantReapResult { + readonly reaped: number; + readonly failures: number; +} + +function reportScrubbedProtocolRemnantReap(reaped: number, failures: number): ScrubbedProtocolRemnantReapResult { + if (failures > 0) + logger.warn("Managed session remnant reaping completed with failures", { + failureCount: failures, + reapedCount: reaped, + }); + return { reaped, failures }; +} /** * Best-effort removal of scrubbed write-protocol remnants from one managed * directory. Only zero-length, single-link, non-symlink regular files whose @@ -535,6 +553,48 @@ export function reapScrubbedProtocolRemnantsSync( return reaped; } +/** Number of candidate remnants inspected between event-loop yields. */ +const SCRUBBED_REMNANT_REAP_BATCH_SIZE = 256; + +/** + * Async twin of {@link reapScrubbedProtocolRemnantsSync} with the same safety + * filters (terminal remnant prefix, zero-length, single-link, non-symlink, + * older than the age gate), yielding between bounded batches. Long-lived + * processes reap per-session descendant directories through this path so a + * legacy oversized directory cannot starve timers or sibling subagents while + * it is being drained (issue #4394). + */ +export async function reapScrubbedProtocolRemnants( + directory: string, + minAgeMs: number = SCRUBBED_REMNANT_MIN_AGE_MS, +): Promise { + let names: string[]; + try { + names = await fsp.readdir(directory); + } catch (error) { + return reportScrubbedProtocolRemnantReap(0, isEnoent(error) ? 0 : 1); + } + const cutoff = Date.now() - minAgeMs; + let reaped = 0; + let failures = 0; + let scanned = 0; + for (const name of names) { + if (!SCRUBBED_REMNANT_PREFIXES.some(prefix => name.startsWith(prefix))) continue; + if (++scanned % SCRUBBED_REMNANT_REAP_BATCH_SIZE === 0) await Bun.sleep(0); + const pathname = path.join(directory, name); + try { + const named = await fsp.lstat(pathname); + if (!named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || named.size !== 0) continue; + if (named.mtimeMs > cutoff) continue; + await fsp.unlink(pathname); + reaped += 1; + } catch (error) { + if (!isEnoent(error)) failures += 1; + } + } + return reportScrubbedProtocolRemnantReap(reaped, failures); +} + const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]); const ACL_CLEAR_EVIDENCE = new Set(["cleared", "already_absent", "unsupported", "not_run"]); const GENERAL_FAILURE_CODES = new Set([ @@ -895,6 +955,8 @@ export class ManagedSessionDescendantStore { #ownsAuthority = false; #closed = false; #reconcilingReplacementCleanup = false; + #remnantReapInFlight = false; + #lastRemnantReapAttempt = 0; readonly #authorityBaseDir: string; /** Logical profile root inherited by nested managed session destinations. */ readonly #profileAgentDir: string; @@ -1340,6 +1402,30 @@ export class ManagedSessionDescendantStore { this.#assertBound(); this.#reconcileReplacementCleanupReceipts(); this.#assertBound(); + this.#scheduleRemnantReap(); + } + + /** + * Best-effort asynchronous reaping of scrubbed write-protocol remnants in + * this store's bound directory. Replacements leak zero-byte remnants on + * platforms without retained authority (macOS), and scope-resolution reaping + * never visits per-session descendant directories, so unbounded remnant + * growth there degraded every namespace mutation and let one publication + * stall the whole process (issue #4394). Reaping is throttled, serialized + * per store, and never fails the triggering mutation. + */ + #scheduleRemnantReap(): void { + const now = Date.now(); + if (this.#remnantReapInFlight || now - this.#lastRemnantReapAttempt < SCRUBBED_REMNANT_REAP_INTERVAL_MS) return; + this.#lastRemnantReapAttempt = now; + this.#remnantReapInFlight = true; + void reapScrubbedProtocolRemnants(this.#baseDir) + .catch((error: unknown) => { + logger.warn("Managed session remnant reaping failed", { error: String(error) }); + }) + .finally(() => { + this.#remnantReapInFlight = false; + }); } ensureDirectory(relativePath = ""): ManagedDirectoryRoot { @@ -2255,6 +2341,17 @@ function fsyncDirectory(pathname: string): void { } } +/** Async twin of {@link fsyncDirectory} for the off-loop publication path. */ +async function fsyncDirectoryAsync(pathname: string): Promise { + if (!shouldFsyncManagedDirectory()) return; + const handle = await fsp.open(pathname, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + function bootId(): string | undefined { try { return fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(); @@ -2536,7 +2633,7 @@ export async function publishManagedFileNoReplace( const parent = path.dirname(destination); ensureManagedDirectory(parent, root, policy); const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`); - let fd: number | undefined; + let handle: fsp.FileHandle | undefined; let stagingIdentity: { dev: bigint; ino: bigint } | undefined; let failure: unknown; let outcome: NativePublishOutcome | undefined; @@ -2545,36 +2642,47 @@ export async function publishManagedFileNoReplace( try { assertOwned?.(); - fd = fs.openSync( + // The whole staging + publication chain is awaited off the resident event + // loop: FileHandle operations run on the libuv pool and the no-replace + // namespace publication crosses the async native boundary. A rename that + // stalls in the kernel — e.g. into an oversized APFS directory namespace, + // issue #4394 — blocks one pool thread instead of freezing the process: + // await timeouts, sibling subagents, and watchdogs keep running, and a + // hung publication degrades to one unresolved receipt. + handle = await fsp.open( staging, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW, 0o600, ); - secureFileDescriptor(staging, fd, "apply"); + secureFileDescriptor(staging, handle.fd, "apply"); let offset = 0; - while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset); - fs.fsyncSync(fd); - secureFileDescriptor(staging, fd, "verify"); - const staged = fs.fstatSync(fd, { bigint: true }); + while (offset < bytes.byteLength) { + const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset); + if (bytesWritten <= 0) throw new Error("managed_publish_failed"); + offset += bytesWritten; + } + await handle.sync(); + secureFileDescriptor(staging, handle.fd, "verify"); + const staged = await handle.stat({ bigint: true }); stagingIdentity = { dev: staged.dev, ino: staged.ino }; assertOwned?.(); renameAttempted = true; - outcome = classifyNativePublishOutcome(nativeSessionStorage().renameNoReplacePath(staging, destination)); + outcome = classifyNativePublishOutcome( + await nativeSessionStorage().renameNoReplacePathAsync(staging, destination), + ); if (renameFlagsUnsupported(outcome)) { - // linkat publishes the destination without consuming the staging name, so the - // secured staging descriptor stays authoritative across publication exactly as - // it does across a rename. The staging link is removed in the finally block - // below, after that descriptor is closed: unlinking a still-open name on NFS - // silly-renames it instead of removing it, which would leave a second link on - // the published inode. - outcome = classifyNativePublishOutcome(nativeSessionStorage().linkNoReplacePath(staging, destination)); + // See publishManagedFileNoReplaceSync: the staging link outlives this + // publication and is removed only after the secured descriptor is closed. + outcome = classifyNativePublishOutcome( + await nativeSessionStorage().linkNoReplacePathAsync(staging, destination), + ); linkPublished = outcome.ok; } if (!outcome.ok) throw publishFailure(outcome); - const named = fs.lstatSync(destination, { bigint: true }); + const named = await fsp.lstat(destination, { bigint: true }); if ( !named.isFile() || named.isSymbolicLink() || @@ -2583,15 +2691,21 @@ export async function publishManagedFileNoReplace( ) { throw new Error("destination_identity_changed"); } - secureFileDescriptor(destination, fd, "verify"); - fs.closeSync(fd); - fd = undefined; + secureFileDescriptor(destination, handle.fd, "verify"); + await handle.close(); + handle = undefined; - fsyncDirectory(parent); + await fsyncDirectoryAsync(parent); } catch (error) { failure = error; } finally { - if (fd !== undefined) fs.closeSync(fd); + if (handle !== undefined) { + try { + await handle.close(); + } catch (error) { + failure ??= error; + } + } if (stagingIdentity && (linkPublished || !renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) { await fsp diff --git a/packages/coding-agent/test/managed-publication-event-loop.test.ts b/packages/coding-agent/test/managed-publication-event-loop.test.ts new file mode 100644 index 0000000000..68aa5993ec --- /dev/null +++ b/packages/coding-agent/test/managed-publication-event-loop.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as native from "@gajae-code/natives"; +import { ArtifactManager } from "../src/session/artifacts"; +import { + ManagedSessionDescendantStore, + managedDirectoryRoot, + publishManagedFileNoReplace, + publishManagedFileNoReplaceSync, + reapScrubbedProtocolRemnants, + reapScrubbedProtocolRemnantsSync, +} from "../src/session/internal/managed-session-storage"; + +const REMNANT_PREFIX = ".gjc-exact-unlink-placeholder-"; + +async function withTempDir(prefix: string, run: (dir: string) => Promise): Promise { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + return await run(dir); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +} + +async function seedRemnant( + dir: string, + name: string, + ageMs: number, + bytes: Uint8Array = new Uint8Array(), +): Promise { + const pathname = path.join(dir, name); + await fsp.writeFile(pathname, bytes, { mode: 0o600 }); + const stamp = new Date(Date.now() - ageMs); + await fsp.utimes(pathname, stamp, stamp); + return pathname; +} + +async function waitFor(condition: () => Promise, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) return; + await Bun.sleep(25); + } + throw new Error("condition not met before timeout"); +} + +describe("async native no-replace publication boundary (issue #4394)", () => { + it("renameNoReplacePathAsync publishes and never settles from a microtask", async () => { + await withTempDir("gjc-async-rename-", async dir => { + const staging = path.join(dir, "staging"); + const destination = path.join(dir, "published"); + await fsp.writeFile(staging, "payload"); + + let settled = false; + const pending = native.renameNoReplacePathAsync(staging, destination).then(result => { + settled = true; + return result; + }); + // A libuv blocking-pool completion is a macrotask: draining microtasks + // must never observe settlement, which is exactly the property that keeps + // the resident event loop unblocked during publication. + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + + const result = await pending; + expect(result.ok).toBe(true); + expect(await fsp.readFile(destination, "utf8")).toBe("payload"); + expect(fs.existsSync(staging)).toBe(false); + }); + }); + + it("renameNoReplacePathAsync refuses an existing destination without replacing it", async () => { + await withTempDir("gjc-async-rename-conflict-", async dir => { + const staging = path.join(dir, "staging"); + const destination = path.join(dir, "published"); + await fsp.writeFile(staging, "successor"); + await fsp.writeFile(destination, "predecessor"); + + const result = await native.renameNoReplacePathAsync(staging, destination); + expect(result.ok).toBe(false); + expect(result.mutationState).toBe("not_committed"); + expect(await fsp.readFile(destination, "utf8")).toBe("predecessor"); + }); + }); + + it("publishManagedFileNoReplace crosses the threadpool boundary and matches the sync twin", async () => { + await withTempDir("gjc-async-publish-", async dir => { + const destination = path.join(dir, "generation.output"); + const bytes = new TextEncoder().encode("managed-output"); + + let settled = false; + const pending = publishManagedFileNoReplace(destination, bytes).then(() => { + settled = true; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + await pending; + + expect(await fsp.readFile(destination)).toEqual(Buffer.from(bytes)); + // No staging object may survive a committed publication. + expect((await fsp.readdir(dir)).filter(name => name.includes(".staging"))).toEqual([]); + + // The sync twin publishes identical bytes under the same protocol. + const syncDestination = path.join(dir, "sync.output"); + publishManagedFileNoReplaceSync(syncDestination, bytes); + expect(await fsp.readFile(syncDestination)).toEqual(Buffer.from(bytes)); + }); + }); + + it("publishManagedFileNoReplace rejects an existing destination as destination_conflict", async () => { + await withTempDir("gjc-async-publish-conflict-", async dir => { + const destination = path.join(dir, "generation.output"); + publishManagedFileNoReplaceSync(destination, new TextEncoder().encode("first")); + await expect(publishManagedFileNoReplace(destination, new TextEncoder().encode("second"))).rejects.toThrow( + "destination_conflict", + ); + expect(await fsp.readFile(destination, "utf8")).toBe("first"); + }); + }); + + it("yields macrotask turns to the event loop while a publication is in flight", async () => { + await withTempDir("gjc-async-publish-liveness-", async dir => { + const bytes = new Uint8Array(4 * 1024 * 1024).fill(0x61); + let settled = 0; + const publications = Array.from({ length: 8 }, (_, index) => + publishManagedFileNoReplace(path.join(dir, `generation-${index}.output`), bytes).then(() => { + settled += 1; + }), + ); + // Each publication is a chain of sequential threadpool round trips, so a + // zero-delay timer (one macrotask turn) must fire before any of them can + // settle. The pre-fix chain ran synchronously and starved exactly these + // turns, which is what froze await timeouts in issue #4394. + await Bun.sleep(0); + expect(settled).toBe(0); + await Bun.sleep(0); + await Promise.all(publications); + expect(settled).toBe(8); + }); + }); +}); + +describe("scrubbed protocol remnant reaping (issue #4394)", () => { + it("reaps aged zero-byte remnants and retains everything else", async () => { + await withTempDir("gjc-remnant-reap-", async dir => { + const aged = await seedRemnant(dir, `${REMNANT_PREFIX}aged`, 60 * 60 * 1000); + const fresh = await seedRemnant(dir, `${REMNANT_PREFIX}fresh`, 0); + const payload = await seedRemnant(dir, `${REMNANT_PREFIX}payload`, 60 * 60 * 1000, new Uint8Array([1])); + const ordinary = path.join(dir, "session.jsonl"); + await fsp.writeFile(ordinary, "transcript"); + + const result = await reapScrubbedProtocolRemnants(dir); + + expect(result).toEqual({ reaped: 1, failures: 0 }); + expect(fs.existsSync(aged)).toBe(false); + expect(fs.existsSync(fresh)).toBe(true); + expect(fs.existsSync(payload)).toBe(true); + expect(fs.existsSync(ordinary)).toBe(true); + }); + }); + + it("matches the sync reaper's result on the same directory shape", async () => { + await withTempDir("gjc-remnant-parity-", async dir => { + for (let index = 0; index < 4; index++) { + await seedRemnant(dir, `${REMNANT_PREFIX}aged-${index}`, 60 * 60 * 1000); + } + await seedRemnant(dir, `${REMNANT_PREFIX}fresh`, 0); + const asyncResult = await reapScrubbedProtocolRemnants(dir); + for (let index = 0; index < 4; index++) { + await seedRemnant(dir, `${REMNANT_PREFIX}aged-${index}`, 60 * 60 * 1000); + } + const syncResult = reapScrubbedProtocolRemnantsSync(dir); + expect(asyncResult).toEqual(syncResult); + expect(asyncResult).toEqual({ reaped: 4, failures: 0 }); + }); + }); + + it("drains a directory larger than the yield batch without missing entries", async () => { + await withTempDir("gjc-remnant-bounded-", async dir => { + const count = 600; + for (let index = 0; index < count; index++) { + await seedRemnant(dir, `${REMNANT_PREFIX}${index.toString().padStart(4, "0")}`, 60 * 60 * 1000); + } + const result = await reapScrubbedProtocolRemnants(dir); + expect(result).toEqual({ reaped: count, failures: 0 }); + expect((await fsp.readdir(dir)).filter(name => name.startsWith(REMNANT_PREFIX))).toEqual([]); + }); + }); + + it("treats a missing directory as a benign no-op", async () => { + const missing = path.join(os.tmpdir(), `gjc-remnant-missing-${Date.now()}`); + expect(await reapScrubbedProtocolRemnants(missing)).toEqual({ reaped: 0, failures: 0 }); + }); + + it("store mutations schedule best-effort reaping of the bound per-session directory", async () => { + await withTempDir("gjc-remnant-store-", async dir => { + const sessionDir = path.join(dir, "session"); + await fsp.mkdir(sessionDir, { mode: 0o700 }); + const aged = await seedRemnant(sessionDir, `${REMNANT_PREFIX}aged`, 60 * 60 * 1000); + const fresh = await seedRemnant(sessionDir, `${REMNANT_PREFIX}fresh`, 0); + + const store = new ManagedSessionDescendantStore(managedDirectoryRoot(dir), sessionDir); + store.publishNoReplaceSync("session.jsonl", Buffer.from("transcript\n")); + + await waitFor(async () => !fs.existsSync(aged)); + // The age gate still protects in-flight protocol steps. + expect(fs.existsSync(fresh)).toBe(true); + expect(await fsp.readFile(path.join(sessionDir, "session.jsonl"), "utf8")).toBe("transcript\n"); + }); + }); +}); + +describe("managed output generation publication over the async boundary", () => { + it("publishes selector, output, and metadata through the async path", async () => { + await withTempDir("gjc-managed-generation-", async dir => { + const artifactsDir = path.join(dir, "artifacts"); + const store = new ManagedSessionDescendantStore(managedDirectoryRoot(dir), artifactsDir); + const manager = new ArtifactManager(store); + + const output = new TextEncoder().encode("leaf subagent output"); + const metadata = new TextEncoder().encode(JSON.stringify({ tool: "task", status: "complete" })); + await manager.publishManagedOutputGeneration("task-1.selector", "task-1", output, metadata); + + const selector = JSON.parse(await fsp.readFile(path.join(artifactsDir, "task-1.selector"), "utf8")) as { + outputFilename: string; + metadataFilename: string; + }; + expect(selector.outputFilename.startsWith("task-1.")).toBe(true); + expect(await fsp.readFile(path.join(artifactsDir, selector.outputFilename))).toEqual(Buffer.from(output)); + expect(await fsp.readFile(path.join(artifactsDir, selector.metadataFilename))).toEqual(Buffer.from(metadata)); + + // A second generation replaces the selector and retires the prior pair. + const secondOutput = new TextEncoder().encode("superseding output"); + await manager.publishManagedOutputGeneration("task-1.selector", "task-1", secondOutput, metadata); + const secondSelector = JSON.parse(await fsp.readFile(path.join(artifactsDir, "task-1.selector"), "utf8")) as { + outputFilename: string; + metadataFilename: string; + }; + expect(secondSelector.outputFilename).not.toBe(selector.outputFilename); + expect(await fsp.readFile(path.join(artifactsDir, secondSelector.outputFilename))).toEqual( + Buffer.from(secondOutput), + ); + expect(fs.existsSync(path.join(artifactsDir, selector.outputFilename))).toBe(false); + expect(fs.existsSync(path.join(artifactsDir, selector.metadataFilename))).toBe(false); + }); + }); +}); diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 3bc655aa27..2ba421727a 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Added + +- `renameNoReplacePathAsync` and `linkNoReplacePathAsync`, async variants of the checked no-replace namespace publication primitives, are scheduled on the native blocking-work pool so managed output publication can await the rename/link syscall boundary without blocking the host event loop (#4394). ## [0.13.1] - 2026-08-11 ### Fixed diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 83b0ad13a4..7121a0d9b6 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -1509,6 +1509,12 @@ export interface LineDiffPart { */ export declare function linkNoReplacePath(sourcePath: string, destinationPath: string): NativeNoReplaceResult +/** + * Async variant of [`link_no_replace_path`] scheduled on the libuv blocking + * pool; see [`rename_no_replace_path_async`] for the rationale. + */ +export declare function linkNoReplacePathAsync(sourcePath: string, destinationPath: string): Promise + /** * Walk the workspace once and return tree entries plus AGENTS.md candidates. * @@ -2120,6 +2126,16 @@ export interface RecoveryFsRetainedCleanupResult { export declare function renameNoReplacePath(sourcePath: string, destinationPath: string): NativeNoReplaceResult +/** + * Async variant of [`rename_no_replace_path`] scheduled on the libuv blocking + * pool. Managed output publication awaits this boundary so a rename that + * stalls in the kernel (oversized APFS directory namespaces, issue #4394) + * blocks one pool thread instead of the agent's event loop: await timeouts, + * sibling subagents, and watchdogs keep running, and a hung publication + * degrades to one unresolved receipt rather than a frozen process. + */ +export declare function renameNoReplacePathAsync(sourcePath: string, destinationPath: string): Promise + /** * Repair an owner-only ACL on a retained expected path. * diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index 9f71ad3588..6cf6962db9 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -69,6 +69,7 @@ export const isoResolve = nativeBindings.isoResolve; export const isoStart = nativeBindings.isoStart; export const isoStop = nativeBindings.isoStop; export const linkNoReplacePath = nativeBindings.linkNoReplacePath; +export const linkNoReplacePathAsync = nativeBindings.linkNoReplacePathAsync; export const listWorkspace = nativeBindings.listWorkspace; export const matchesKey = nativeBindings.matchesKey; export const matchesKittySequence = nativeBindings.matchesKittySequence; @@ -81,6 +82,7 @@ export const probeWindowsJobMemory = nativeBindings.probeWindowsJobMemory; export const ptyTimeoutCount = nativeBindings.ptyTimeoutCount; export const readImageFromClipboard = nativeBindings.readImageFromClipboard; export const renameNoReplacePath = nativeBindings.renameNoReplacePath; +export const renameNoReplacePathAsync = nativeBindings.renameNoReplacePathAsync; export const repairOwnerOnlyPathSecurityExpected = nativeBindings.repairOwnerOnlyPathSecurityExpected; export const retainBrokerPublication = nativeBindings.retainBrokerPublication; export const search = nativeBindings.search; From 7335e048d27c239c93a1c1dbae3c598bd5fa97a3 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 12 Aug 2026 01:28:58 +0900 Subject: [PATCH 03/12] fix(bash): cancel completed foreground deadline Foreground managed Bash raced completion against an uncancellable Bun.sleep. When the command won, the losing sleep retained the compiled process until the full command timeout, making print and subagent sessions appear hung after completion. Use an owned timer and clear it at every race exit. Lore-id: bash-deadline-exit-4256 Constraint: preserve explicit and automatic background transitions Rejected: force process exit | masks other live resources and breaks embedders Confidence: high Scope-risk: narrow Reversibility: easy Tested: 3 repeated compiled print-mode Bash exits and 2 subagent-path exits --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/tools/bash.ts | 20 ++++++++++--------- .../tools/bash-resource-lifecycle.test.ts | 18 +++++++++++++++++ 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 28eb407c54..10d9ffda91 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -15,6 +15,7 @@ - Custom OpenAI-compatible providers can opt into `/fast` forwarding with `compat.supportsServiceTier: true`; first-class OpenCodex discovery enables it automatically so client `service_tier: "priority"` reaches an OpenCodex proxy running in passthrough (`Auto`) mode. ### Fixed +- Foreground Bash calls that finish before their auto-background deadline now cancel that deadline instead of leaving an uncancellable `Bun.sleep()` behind. In print mode the losing sleep kept the compiled CLI alive for the full command timeout after Bash and subagent work had already completed, which appeared as a shutdown hang with no active Node handles. - A just-created or just-registered SDK session no longer reads as not-live (failing immediate `session.close`/`session.delete`, endpoint resolution, and chat-daemon attachment) for up to one heartbeat interval: the host-written registration now counts as initial liveness evidence, aging out exactly like a heartbeat, with the OS process-incarnation match still required. The broker's shutdown-escalation identity proof also accepts the incarnation the index auto-stamps at registration, and ACP `session/list` no longer advertises or re-adopts closed/unregistered (DR-1 terminal) rows — repeated ACP deletes answer already-gone instead of escalating against a host that no longer exists. - Insane public-route search now cancels and rejects response bodies larger than 1 MiB instead of buffering unbounded feed, HTML, or JSON payloads. - The Telegram notification daemon no longer terminates with an uncaught `EPERM` when publishing its heartbeat sidecar while an external Windows file lock (antivirus, indexer) briefly holds the destination. The rename is retried a bounded number of times under the ownership-lock fence, a still-failing publication is contained as a diagnostic-logged transient (the next heartbeat cycle republishes), and only a proven ownership loss stops the daemon; the stale-writer fence and staging-temp cleanup are preserved on every path (#4200). diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index a6871e66d8..1d0af01d09 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -862,26 +862,28 @@ export class BashTool implements AgentTool { return { kind: "aborted" }; } + const threshold = Promise.withResolvers<{ kind: "running" }>(); + const thresholdTimer = setTimeout(() => threshold.resolve({ kind: "running" }), Math.max(0, thresholdMs)); const waiters: Array> = [ job.completion, - Bun.sleep(thresholdMs).then(() => ({ kind: "running" as const })), + threshold.promise, ]; if (backgroundRequest) { waiters.push(backgroundRequest.then(() => ({ kind: "running" as const }))); } - if (!signal) { - return await Promise.race(waiters); + let onAbort: (() => void) | undefined; + if (signal) { + const aborted = Promise.withResolvers<{ kind: "aborted" }>(); + onAbort = () => aborted.resolve({ kind: "aborted" }); + signal.addEventListener("abort", onAbort, { once: true }); + waiters.push(aborted.promise); } - - const { promise: abortedPromise, resolve: resolveAborted } = Promise.withResolvers<{ kind: "aborted" }>(); - const onAbort = () => resolveAborted({ kind: "aborted" }); - signal.addEventListener("abort", onAbort, { once: true }); - waiters.push(abortedPromise); try { return await Promise.race(waiters); } finally { - signal.removeEventListener("abort", onAbort); + clearTimeout(thresholdTimer); + if (signal && onAbort) signal.removeEventListener("abort", onAbort); } } diff --git a/packages/coding-agent/test/tools/bash-resource-lifecycle.test.ts b/packages/coding-agent/test/tools/bash-resource-lifecycle.test.ts index 90573fa57e..cb84d58e4c 100644 --- a/packages/coding-agent/test/tools/bash-resource-lifecycle.test.ts +++ b/packages/coding-agent/test/tools/bash-resource-lifecycle.test.ts @@ -95,6 +95,24 @@ describe("bash resource lifecycle", () => { } }); + it("clears the foreground auto-background deadline after an early completion", async () => { + settings.set("bash.autoBackground.enabled", false); + const sleep = vi.spyOn(Bun, "sleep"); + const tool = new BashTool(makeToolSession(tempDir, settings)); + + try { + const result = await tool.execute("foreground-early-completion", { + command: "true", + timeout: 5, + }); + + expect(result.isError).not.toBe(true); + expect(sleep).not.toHaveBeenCalledWith(6_000); + } finally { + sleep.mockRestore(); + } + }); + it("repeated monitor jobs return native shell session count to baseline", async () => { const baseline = getShellSessionCount(); const tool = new BashTool(makeToolSession(tempDir, settings)); From e750429da5139df01f646a522d5a4a6d175f0c54 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Tue, 11 Aug 2026 14:21:46 +0000 Subject: [PATCH 04/12] fix(ai): drop stale signature from clear_thinking emptied thinking blocks (#4247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clear_thinking_20251015 strips thinking text server-side but leaves the original signature in the persisted block. Replaying that block sends {thinking: "", signature: ""} which Anthropic rejects with `thinking ... cannot be modified` on every subsequent turn — a deterministic 400, not an intermittent one. Two complementary fixes: 1. transform-messages.ts: gate the signed-empty thinking preservation on API. Only keep signed-empty blocks for non-anthropic-messages APIs (OpenAI encrypted reasoning). For anthropic-messages, drop them so they never reach the wire. 2. anthropic.ts latestAssistantThinkingIsUnreplayable: treat signed-empty thinking as unreplayable on signing endpoints. This makes the pre-emptive local degrade fire for the latest assistant turn, avoiding a 400 round trip to discover the condition. The fix does not strip valid signed thinking (non-empty text + signature) or weaken provider constraints. Non-signing endpoints (DeepSeek, Z.AI) are unaffected. Regression tests cover: clear_thinking emptied historical blocks, signed-empty on latest turn, valid signatures preserved, malformed blocks (unsigned-empty, whitespace-only), non-Anthropic API preservation (OpenAI Responses encrypted reasoning), and non-signing endpoints. Lore-id: issue-4247 Constraint: must not strip valid signed thinking with non-empty text Constraint: must not affect non-signing endpoints (DeepSeek, Z.AI) Rejected: strip all signatures from empty thinking | would break OpenAI encrypted reasoning Rejected: fix only transform-messages | latest-message case would still 400 once before repair Confidence: high Scope-risk: narrow Reversibility: trivial Tested: transform-messages clear_thinking, anthropic unreplayable thinking, full ai test suite Not-tested: live CPA stack replay (no access) Supersedes: none --- packages/ai/src/providers/anthropic.ts | 11 +- .../ai/src/providers/transform-messages.ts | 11 +- .../anthropic-unreplayable-thinking.test.ts | 51 +++- .../transform-messages-clear-thinking.test.ts | 224 ++++++++++++++++++ 4 files changed, 284 insertions(+), 13 deletions(-) create mode 100644 packages/ai/test/transform-messages-clear-thinking.test.ts diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index a654fcc1d2..b2b9f28e45 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -2396,12 +2396,15 @@ function latestAssistantThinkingIsUnreplayable(messages: Message[], model: Model if (block.type !== "thinking") return false; // A block with empty text and no signature cannot go back on the wire: // `convertAnthropicMessages` drops it, and Anthropic rejects the turn for - // arriving without it. A block with a valid signature is replayable even - // when the text is empty, and non-signing endpoints replay unsigned blocks - // verbatim, so only signing endpoints treat a missing signature as - // unreplayable. + // arriving without it. A block with a valid signature AND non-empty text is + // replayable. But a signed block whose text was emptied — e.g. by + // clear_thinking_20251015 — carries a stale signature that signing endpoints + // reject on replay (issue #4247). Non-signing endpoints replay unsigned + // blocks verbatim, so only they treat a missing signature as unreplayable. const hasSignature = !!block.thinkingSignature?.trim(); + const isEmpty = !block.thinking.trim(); if (!hasSignature) return requiresSignature; + if (isEmpty && requiresSignature) return true; return false; }); } diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index 8daab95a13..603c24ea52 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -98,8 +98,15 @@ export function transformMessages( if (dropAssistantThinkingForRepair && replaysAsNativeThinking) return []; if (mustPreserveLatestAnthropicThinking) return sanitized; // For same model: keep thinking blocks with signatures (needed for replay) - // even if the thinking text is empty (OpenAI encrypted reasoning) - if (isSameModel && sanitized.thinkingSignature) return sanitized; + // even if the thinking text is empty — but only for non-Anthropic APIs where + // the signature represents OpenAI encrypted reasoning. For anthropic-messages, + // a signed block with empty text means clear_thinking_20251015 stripped the + // content server-side while the stale signature remained; replaying it + // produces `thinking ... cannot be modified` 400s on every turn (#4247). + if (isSameModel && sanitized.thinkingSignature) { + if (sanitized.thinking.trim() === "" && model.api === "anthropic-messages") return []; + return sanitized; + } // Skip empty thinking blocks, convert others to plain text if (!sanitized.thinking || sanitized.thinking.trim() === "") return []; if (isSameModel) return sanitized; diff --git a/packages/ai/test/anthropic-unreplayable-thinking.test.ts b/packages/ai/test/anthropic-unreplayable-thinking.test.ts index f08b796719..6720898a5d 100644 --- a/packages/ai/test/anthropic-unreplayable-thinking.test.ts +++ b/packages/ai/test/anthropic-unreplayable-thinking.test.ts @@ -88,7 +88,8 @@ const user: UserMessage = { role: "user", content: "go", timestamp: Date.now() } const HOLLOW_THINKING = { type: "thinking" as const, thinking: "", thinkingSignature: "" }; const SIGNED_EARLY = { type: "thinking" as const, thinking: "early reasoning", thinkingSignature: "sig_early" }; const SIGNED_LATE = { type: "thinking" as const, thinking: "late reasoning", thinkingSignature: "sig_late" }; -/** A block with empty text but a valid signature — natively replayable via the signed-thinking path. */ +/** A block with empty text but a valid signature — stale after clear_thinking_20251015. + * Signing Anthropic endpoints must treat this as unreplayable (issue #4247). */ const SIGNED_EMPTY = { type: "thinking" as const, thinking: "", thinkingSignature: "sig_empty" }; function nativeThinkingCount(payload: { messages: unknown[] }): number { @@ -169,7 +170,7 @@ describe("Anthropic unreplayable latest-assistant thinking", () => { expect(JSON.stringify(payload.messages)).toContain("sig_early"); }); - it("does not degrade when the latest turn has signed-but-empty thinking", async () => { + it("degrades when the latest turn has signed-but-empty thinking (clear_thinking)", async () => { const payload = await capturePayload([ user, assistantTurn([SIGNED_EARLY], "toolu_a"), @@ -179,10 +180,46 @@ describe("Anthropic unreplayable latest-assistant thinking", () => { toolResult("toolu_b"), ]); - // A block with empty text but a valid signature is natively replayable — - // convertAnthropicMessages forwards it via the signed-thinking path — so - // both signed blocks are preserved. - expect(nativeThinkingCount(payload)).toBe(2); - expect(JSON.stringify(payload.messages)).toContain("sig_empty"); + // A signed block whose text was emptied by clear_thinking_20251015 carries + // a stale signature. Signing endpoints reject it, so the pre-emptive local + // degrade drops all native thinking from the replay (issue #4247). + expect(nativeThinkingCount(payload)).toBe(0); + expect(JSON.stringify(payload.messages)).not.toContain("sig_empty"); + expect(JSON.stringify(payload.messages)).not.toContain("sig_early"); + }); + + it("drops signed-empty historical thinking on signing endpoints (clear_thinking)", async () => { + const payload = await capturePayload([ + user, + assistantTurn([SIGNED_EMPTY], "toolu_a"), + toolResult("toolu_a"), + { ...user, content: "again", timestamp: Date.now() + 1 }, + assistantTurn([SIGNED_LATE], "toolu_b"), + toolResult("toolu_b"), + ]); + + // The historical signed-empty block is dropped by transform-messages, so it + // never reaches the wire. The latest turn's valid signed thinking survives. + expect(JSON.stringify(payload.messages)).not.toContain("sig_empty"); + expect(JSON.stringify(payload.messages)).toContain("sig_late"); + expect(nativeThinkingCount(payload)).toBe(1); + }); + + it("does not degrade a non-signing endpoint whose latest turn has signed-empty thinking", async () => { + const payload = await capturePayload( + [ + user, + assistantTurn([SIGNED_EARLY], "toolu_a", deepseekModel), + toolResult("toolu_a"), + { ...user, content: "again", timestamp: Date.now() + 1 }, + assistantTurn([SIGNED_EMPTY], "toolu_b", deepseekModel), + toolResult("toolu_b"), + ], + deepseekModel, + ); + + // DeepSeek does not sign thinking and does not validate thinking presence. + // Signed-empty blocks are harmless on non-signing endpoints. + expect(JSON.stringify(payload.messages)).toContain("sig_early"); }); }); diff --git a/packages/ai/test/transform-messages-clear-thinking.test.ts b/packages/ai/test/transform-messages-clear-thinking.test.ts new file mode 100644 index 0000000000..c8ab7bb55c --- /dev/null +++ b/packages/ai/test/transform-messages-clear-thinking.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "bun:test"; +import { transformMessages } from "@gajae-code/ai/providers/transform-messages"; +import type { AssistantMessage, Message, Model, ToolResultMessage, UserMessage } from "@gajae-code/ai/types"; + +// --------------------------------------------------------------------------- +// Issue #4247: replayed thinking blocks emptied by clear_thinking_20251015 +// keep their stale signature and 400 every request. These tests verify the +// transform-messages layer drops signed-empty thinking for anthropic-messages +// replay while preserving it for non-Anthropic APIs (OpenAI encrypted reasoning). +// --------------------------------------------------------------------------- + +const anthropicModel: Model<"anthropic-messages"> = { + api: "anthropic-messages", + provider: "anthropic", + id: "claude-opus-5", + name: "Claude Opus 5", + baseUrl: "https://api.anthropic.com", + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 8_192, + contextWindow: 200_000, + reasoning: true, +}; + +const openaiResponsesModel: Model<"openai-responses"> = { + api: "openai-responses", + provider: "openai", + id: "o3", + name: "o3", + baseUrl: "https://api.openai.com", + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 8_192, + contextWindow: 200_000, + reasoning: true, +}; + +const usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +const user: UserMessage = { role: "user", content: "go", timestamp: Date.now() }; + +function assistantTurn( + content: AssistantMessage["content"], + activeModel: Model, +): AssistantMessage { + return { + role: "assistant", + content: [...content, { type: "toolCall", id: "toolu_1", name: "bash", arguments: { command: "echo hi" } }], + api: activeModel.api, + provider: activeModel.provider, + model: activeModel.id, + usage, + stopReason: "toolUse", + timestamp: Date.now(), + }; +} + +function toolResult(): ToolResultMessage { + return { + role: "toolResult", + toolCallId: "toolu_1", + toolName: "bash", + content: [{ type: "text", text: "hi" }], + isError: false, + timestamp: Date.now(), + } as ToolResultMessage; +} + +const SIGNED_FULL = { type: "thinking" as const, thinking: "real reasoning", thinkingSignature: "sig_full" }; +const SIGNED_EMPTY = { type: "thinking" as const, thinking: "", thinkingSignature: "sig_empty" }; +const UNSIGNED_EMPTY = { type: "thinking" as const, thinking: "", thinkingSignature: undefined }; +const UNSIGNED_FULL = { type: "thinking" as const, thinking: "unsigned reasoning", thinkingSignature: undefined }; + +function thinkingBlocks(messages: Message[]): Array<{ thinking: string; signature?: string }> { + const blocks: Array<{ thinking: string; signature?: string }> = []; + for (const msg of messages) { + if (msg.role !== "assistant") continue; + for (const block of msg.content) { + if (block.type === "thinking") { + blocks.push({ thinking: block.thinking, signature: block.thinkingSignature }); + } + } + } + return blocks; +} + +describe("transform-messages clear_thinking signed-empty blocks (#4247)", () => { + it("drops signed-empty thinking for anthropic-messages historical replay", () => { + const messages: Message[] = [ + user, + assistantTurn([SIGNED_EMPTY], anthropicModel), + toolResult(), + { ...user, content: "again", timestamp: Date.now() + 1 }, + assistantTurn([SIGNED_FULL], anthropicModel), + toolResult(), + ]; + + const result = transformMessages(messages, anthropicModel); + const blocks = thinkingBlocks(result); + + // Historical signed-empty dropped; latest signed-full preserved. + expect(blocks).toHaveLength(1); + expect(blocks[0].signature).toBe("sig_full"); + }); + + it("preserves signed-empty thinking for openai-responses replay (encrypted reasoning)", () => { + const messages: Message[] = [ + user, + assistantTurn([SIGNED_EMPTY], openaiResponsesModel), + toolResult(), + { ...user, content: "again", timestamp: Date.now() + 1 }, + assistantTurn([SIGNED_FULL], openaiResponsesModel), + toolResult(), + ]; + + const result = transformMessages(messages, openaiResponsesModel); + const blocks = thinkingBlocks(result); + + // Both preserved: OpenAI encrypted reasoning allows signed-empty blocks. + expect(blocks).toHaveLength(2); + expect(blocks.some(b => b.signature === "sig_empty")).toBe(true); + expect(blocks.some(b => b.signature === "sig_full")).toBe(true); + }); + + it("preserves valid signed thinking with non-empty text for anthropic-messages", () => { + const messages: Message[] = [user, assistantTurn([SIGNED_FULL], anthropicModel), toolResult()]; + + const result = transformMessages(messages, anthropicModel); + const blocks = thinkingBlocks(result); + + expect(blocks).toHaveLength(1); + expect(blocks[0].thinking).toBe("real reasoning"); + expect(blocks[0].signature).toBe("sig_full"); + }); + + it("drops unsigned-empty thinking for anthropic-messages historical replay", () => { + const messages: Message[] = [ + user, + assistantTurn([UNSIGNED_EMPTY], anthropicModel), + toolResult(), + { ...user, content: "again", timestamp: Date.now() + 1 }, + assistantTurn([SIGNED_FULL], anthropicModel), + toolResult(), + ]; + + const result = transformMessages(messages, anthropicModel); + // Historical unsigned-empty is dropped; only latest SIGNED_FULL survives. + expect(thinkingBlocks(result)).toHaveLength(1); + }); + + it("converts unsigned non-empty thinking to text for cross-model anthropic replay", () => { + const messages: Message[] = [user, assistantTurn([UNSIGNED_FULL], openaiResponsesModel), toolResult()]; + + const result = transformMessages(messages, anthropicModel); + const blocks = thinkingBlocks(result); + + // Cross-model: unsigned thinking degrades to text, no native thinking block. + expect(blocks).toHaveLength(0); + expect( + result.some( + msg => + msg.role === "assistant" && + msg.content.some(b => b.type === "text" && (b as { text: string }).text === "unsigned reasoning"), + ), + ).toBe(true); + }); + + it("drops all signed-empty blocks across multiple historical turns for anthropic-messages", () => { + const messages: Message[] = [ + user, + assistantTurn([SIGNED_EMPTY], anthropicModel), + toolResult(), + { ...user, content: "turn 2", timestamp: Date.now() + 1 }, + assistantTurn([SIGNED_EMPTY, SIGNED_FULL], anthropicModel), + toolResult(), + { ...user, content: "turn 3", timestamp: Date.now() + 2 }, + assistantTurn([SIGNED_FULL], anthropicModel), + toolResult(), + ]; + + const result = transformMessages(messages, anthropicModel); + const blocks = thinkingBlocks(result); + + // Two SIGNED_FULL blocks survive (one historical, one latest); two SIGNED_EMPTY dropped. + expect(blocks).toHaveLength(2); + expect(blocks.every(b => b.signature === "sig_full")).toBe(true); + expect(blocks.some(b => b.signature === "sig_empty")).toBe(false); + }); + + it("handles whitespace-only signed thinking as empty for anthropic-messages historical replay", () => { + const whitespaceSigned = { type: "thinking" as const, thinking: " \n\t ", thinkingSignature: "sig_ws" }; + const messages: Message[] = [ + user, + assistantTurn([whitespaceSigned], anthropicModel), + toolResult(), + { ...user, content: "again", timestamp: Date.now() + 1 }, + assistantTurn([SIGNED_FULL], anthropicModel), + toolResult(), + ]; + + const result = transformMessages(messages, anthropicModel); + // Whitespace-only signed block dropped as empty; latest SIGNED_FULL survives. + expect(thinkingBlocks(result)).toHaveLength(1); + expect(thinkingBlocks(result)[0].signature).toBe("sig_full"); + }); + + it("preserves whitespace-only signed thinking for openai-responses (encrypted reasoning)", () => { + const whitespaceSigned = { type: "thinking" as const, thinking: " \n\t ", thinkingSignature: "sig_ws" }; + const messages: Message[] = [user, assistantTurn([whitespaceSigned], openaiResponsesModel), toolResult()]; + + const result = transformMessages(messages, openaiResponsesModel); + const blocks = thinkingBlocks(result); + + expect(blocks).toHaveLength(1); + expect(blocks[0].signature).toBe("sig_ws"); + }); +}); From 15ea1a2493e494e43334fba332bec3c6dbb6a89b Mon Sep 17 00:00:00 2001 From: Bellman <54757707+Yeachan-Heo@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:31:41 +0900 Subject: [PATCH 05/12] fix(tui): avoid modifyOtherKeys in Apple Terminal (#4297) * fix(tui): avoid modifyOtherKeys in Apple Terminal Apple Terminal does not support the Kitty keyboard protocol fallback path, and enabling modifyOtherKeys there breaks Hangul IME composition. Keep the harmless capability query but preserve the terminal default keyboard mode. Lore-id: 4d820f25 Confidence: medium Scope-risk: narrow Reversibility: straightforward Tested: bun test packages/tui/test/keyboard-protocol-optout.test.ts; bun run --cwd=packages/tui check:types Not-tested: physical Apple Terminal.app Korean IME reproduction * test(tui): isolate Apple Terminal protocol coverage Clear inherited terminal identity between cases and record the user-visible IME compatibility fix in the package changelog. Lore-id: e7b66c45 Confidence: high Scope-risk: narrow Reversibility: straightforward Tested: bun test packages/tui/test/keyboard-protocol-optout.test.ts; bun run --cwd=packages/tui check:types --------- Co-authored-by: gaebal-gajae (clawdbot) --- docs/environment-variables.md | 2 +- docs/tui-runtime-internals.md | 2 +- packages/tui/CHANGELOG.md | 4 ++++ packages/tui/src/terminal.ts | 20 ++++++++++------- .../tui/test/keyboard-protocol-optout.test.ts | 22 +++++++++++++++++++ 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index d89f71f4d1..5e5bf8e080 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -556,7 +556,7 @@ These are read as runtime signals; they are usually set by the terminal/OS rathe | `GJC_DEBUG_REDRAW` | If `1`, enables redraw debug logging | | `GJC_TUI_DEBUG` | If `1`, enables deep TUI debug dump path | | `GJC_FORCE_IMAGE_PROTOCOL` | Forces terminal image protocol detection (`kitty`, `iterm2`/`iterm`, `sixel`, `none`) | -| `GJC_TUI_KEYBOARD_PROTOCOL` | Enhanced keyboard input (Kitty keyboard protocol + xterm modifyOtherKeys). Enabled by default; set `0` / `false` to leave the keyboard in its default mode. Use this when a terminal (e.g. Android Termius) breaks IME/Hangul composition while these enhanced modes are active. | +| `GJC_TUI_KEYBOARD_PROTOCOL` | Enhanced keyboard input (Kitty keyboard protocol + xterm modifyOtherKeys). Enabled by default; set `0` / `false` to leave the keyboard in its default mode. GJC automatically skips the modifyOtherKeys fallback on Windows and Apple Terminal because it breaks CJK/Hangul IME composition there; use the full opt-out for other affected terminals such as Android Termius. | | `GJC_TUI_SYNCHRONIZED_OUTPUT` | Synchronized-output framing (`CSI ?2026h/l`) is enabled by default. Set `0` / `false` / `off` / `no` before starting or restarting GJC to remove that framing for terminal parsers that render it incorrectly. This is a process-wide compatibility and diagnostic switch, not tmux/Byobu client detection or per-client negotiation. Disabling it may expose visible tearing; return to the default after diagnosis unless the client requires the workaround. | --- diff --git a/docs/tui-runtime-internals.md b/docs/tui-runtime-internals.md index 159f83b0c0..b50e4cdb05 100644 --- a/docs/tui-runtime-internals.md +++ b/docs/tui-runtime-internals.md @@ -45,7 +45,7 @@ A forced render (`requestRender(true)`) resets previous-line caches and cursor b 1. Enables raw mode and bracketed paste. 2. Attaches resize handler. 3. Creates a `StdinBuffer` to split partial escape chunks into complete sequences. -4. Queries Kitty keyboard protocol support (`CSI ? u`), then enables protocol flags if supported; otherwise enables modifyOtherKeys fallback after a short timeout. +4. Queries Kitty keyboard protocol support (`CSI ? u`), then enables protocol flags if supported; otherwise enables the modifyOtherKeys fallback after a short timeout, except on Windows and Apple Terminal where that fallback breaks CJK/Hangul IME composition. 5. Queries OSC 11 background color and enables Mode 2031 appearance notifications for dark/light theme detection. 6. On Windows, attempts VT input enablement via `kernel32` mode flags. `StdinBuffer` behavior: diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d9e0aac6b8..c8c72e702b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Apple Terminal.app now retains its default keyboard mode when it does not support the Kitty keyboard protocol, avoiding the modifyOtherKeys fallback that breaks Korean/Hangul IME composition. + ## [0.13.1] - 2026-08-11 ### Added diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 8abc6a9455..85a902d6ec 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -51,6 +51,10 @@ export function keyboardEnhancementEnabled(): boolean { return $flag("GJC_TUI_KEYBOARD_PROTOCOL", true); } +function isAppleTerminal(): boolean { + return $env.TERM_PROGRAM === "Apple_Terminal"; +} + /** * Minimal terminal interface for TUI */ @@ -794,18 +798,18 @@ export class ProcessTerminal implements Terminal { } this.#safeWrite("\x1b[?u"); this.#stdinBuffer?.noteProbeIssued(); - // Windows Terminal and conhost do not implement the Kitty keyboard + // Windows Terminal and Apple Terminal do not implement the Kitty keyboard // protocol, so the query above never activates it there. They do honor the - // modifyOtherKeys fallback below — but that mode breaks Windows CJK/Hangul - // IME composition: Alt+Enter (and other chords) bypass the IME commit, so - // the syllable still being composed is never delivered to the app and the + // modifyOtherKeys fallback below — but that mode breaks CJK/Hangul IME + // composition: Alt+Enter (and other chords) bypass the IME commit, so the + // syllable still being composed is never delivered to the app and the // action fires on empty text (e.g. queue-message no-ops unless the user // types a trailing space to force a commit first). Skip the fallback on - // win32; legacy encodings still deliver Alt+Enter (ESC CR) and the newline - // chords, and IME composition works again. Opt back in with + // these terminals; legacy encodings still deliver Alt+Enter (ESC CR) and + // the newline chords, and IME composition works again. Opt back in with // GJC_TUI_KEYBOARD_PROTOCOL=0 disabling all enhancement, or force-enable - // elsewhere if a Kitty-capable Windows terminal appears. - if (process.platform === "win32") { + // elsewhere if a Kitty-capable terminal appears. + if (process.platform === "win32" || isAppleTerminal()) { return; } this.#modifyOtherKeysTimeout = setTimeout(() => { diff --git a/packages/tui/test/keyboard-protocol-optout.test.ts b/packages/tui/test/keyboard-protocol-optout.test.ts index c41419fd01..0c61dda69c 100644 --- a/packages/tui/test/keyboard-protocol-optout.test.ts +++ b/packages/tui/test/keyboard-protocol-optout.test.ts @@ -5,6 +5,7 @@ const stdinIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isT const stdoutIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); const stdinSetRawModeDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "setRawMode"); const originalKeyboardProtocolEnv = Bun.env.GJC_TUI_KEYBOARD_PROTOCOL; +const originalTermProgram = Bun.env.TERM_PROGRAM; const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); function setPlatform(platform: NodeJS.Platform): void { @@ -36,6 +37,7 @@ describe("ProcessTerminal keyboard-protocol opt-out (GJC_TUI_KEYBOARD_PROTOCOL)" Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); Object.defineProperty(process.stdin, "setRawMode", { value: vi.fn(), configurable: true }); + delete Bun.env.TERM_PROGRAM; }); afterEach(() => { @@ -45,6 +47,7 @@ describe("ProcessTerminal keyboard-protocol opt-out (GJC_TUI_KEYBOARD_PROTOCOL)" restoreProperty(process.stdout, "isTTY", stdoutIsTtyDescriptor); restoreProperty(process.stdin, "setRawMode", stdinSetRawModeDescriptor); restoreEnv("GJC_TUI_KEYBOARD_PROTOCOL", originalKeyboardProtocolEnv); + restoreEnv("TERM_PROGRAM", originalTermProgram); restoreProperty(process, "platform", platformDescriptor); }); @@ -119,6 +122,25 @@ describe("ProcessTerminal keyboard-protocol opt-out (GJC_TUI_KEYBOARD_PROTOCOL)" terminal.stop(); }); + it("skips only the modifyOtherKeys fallback in Apple Terminal to preserve Hangul IME composition", () => { + vi.useFakeTimers(); + setPlatform("darwin"); + delete Bun.env.GJC_TUI_KEYBOARD_PROTOCOL; + Bun.env.TERM_PROGRAM = "Apple_Terminal"; + expect(keyboardEnhancementEnabled()).toBe(true); + + const { terminal, writes } = setupTerminal(); + + // Apple Terminal does not answer the Kitty query. Its modifyOtherKeys mode + // breaks CJK/Hangul IME composition, so only the fallback is withheld. + expect(writes).toContain(KITTY_QUERY); + + vi.advanceTimersByTime(150); + expect(writes).not.toContain(MODIFY_OTHER_KEYS); + + terminal.stop(); + }); + it("still delivers keyboard input to the handler when disabled", () => { Bun.env.GJC_TUI_KEYBOARD_PROTOCOL = "0"; From 647495fea9d8454bdd02e0fa1df2538c9dd2b43c Mon Sep 17 00:00:00 2001 From: thegreatesthoneybee Date: Wed, 12 Aug 2026 18:04:11 +0900 Subject: [PATCH 06/12] fix: preserve prompt suggestions after cancelled actions --- packages/coding-agent/CHANGELOG.md | 2 ++ packages/coding-agent/src/modes/interactive-mode.ts | 4 ++++ .../test/interactive-mode-editor-component.test.ts | 10 ++++++++++ 3 files changed, 16 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 10d9ffda91..71d0af7e38 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -136,6 +136,8 @@ - Profile activation/materialization, memory maintenance, interactive selectors, slash-command assignment, model-profile previews, and SDK extension contexts now use the same explicit credential identity as request dispatch; canonical session IDs remain reserved for sticky routing and provider transport/cache identities remain isolated. - Task subagents and branch commit-message generation now inherit the parent's credential identity separately from logical/canonical state. Extension model preflight, custom tools (including image generation and web search), title/prompt maintenance, background ephemeral metadata, and profile materialization use the same credential scope. Preset landing auth now requires usable effective credentials rather than catalog presence alone. - SDK tool sessions, deferred SDK selectors, slash-command aliases, memory role resolution, and provider-order status now carry credential identity. Provider alias invalidation updates canonical storage/backoff/session state, and preset landing availability is derived from actual credential-scoped `getApiKey` results (including keyless providers) rather than credential-type metadata. +- Prompt suggestions now remain visible after cancelling another interactive action and returning to the composer. + - `todo_write` and `ask` no longer reject valid calls before the tool loads. Both tools carried two independent copies of their raw-argument rules — one in the loaded tool, one in the cold descriptor registry that runs first — and the deferred copies had drifted: `todo_write`'s dropped the `content` synonym for `task` and the `complete`/`completed` aliases for `done`, accepted targetless `complete` entries the loaded tool rejects, and returned every rejection without its correction code, so the model saw a bare "raw arguments rejected before coercion" with nothing to fix and retried the same shape until the turn died. Both also rejected the harness's own injected `_i` intent field, failing any call carrying it with an unknown-root-key error the model could not repair. `todo_write` validation now lives in a single shared contract module (`tools/todo-contract.ts`) used by both paths, and both tools tolerate `_i` at the root while still rejecting genuinely unknown keys. - A stalled ACP session is no longer unrecoverable. The SDK host drops a session whose client has not ponged within `HEARTBEAT_TTL_MS` (20s), but the ACP client inherited the transport's one-shot reconnect defaults — 3 attempts at a 25ms base backoff, a total budget of 175ms — so any event-loop stall long enough for the host to reap the session exceeded the client's entire retry window by two orders of magnitude and surfaced as a terminal `-32603 ACP session transport was lost: SDK WebSocket reconnect attempts exhausted`. Under machine load this killed long-running agent sessions outright while their processes stayed alive. The ACP adapter and the broker connection now share an explicit `ACP_SESSION_RECONNECT` budget derived from `HEARTBEAT_TTL_MS` rather than a magic number: backoff ramps 250ms → 500ms → 1s and holds at a 2s cap for 23 attempts (~41.75s), so the client outlives twice the host TTL while individual sleeps stay short enough to reattach promptly once the host answers again. - Managed scope prepare and legacy-local resume no longer report success while group/other-readable descendants remain on disk (e.g. mode `0o036`/`0o644`): prepare uses a mode-only walk (not `snapshotManagedTree("")`, which races concurrent writers and broke `/move`) to detect drift, re-secures with apply+verify, and retries once; legacy-local capture uses the same resecure helper. diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index f48e2af42c..f4c501e252 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -1345,7 +1345,11 @@ export class InteractiveMode implements InteractiveModeContext { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); } + // Re-mounting after a cancelled action must invalidate the editor so the + // inline prompt suggestion is rendered again on the restored composer. + this.editor.invalidate(); this.ui.setFocus(this.editor); + this.ui.requestRender(); } #createPetWidget(editor: CustomEditor): GajaePetWidget { diff --git a/packages/coding-agent/test/interactive-mode-editor-component.test.ts b/packages/coding-agent/test/interactive-mode-editor-component.test.ts index 7fd465fc7b..7237036433 100644 --- a/packages/coding-agent/test/interactive-mode-editor-component.test.ts +++ b/packages/coding-agent/test/interactive-mode-editor-component.test.ts @@ -751,6 +751,16 @@ describe("InteractiveMode.setEditorComponent", () => { expect(refreshSpy).toHaveBeenCalled(); }); + it("invalidates the restored composer after cancelling another action", () => { + const invalidate = vi.spyOn(mode.editor, "invalidate"); + const requestRender = vi.spyOn(mode.ui, "requestRender"); + + mode.restoreComposer(); + + expect(invalidate).toHaveBeenCalledTimes(1); + expect(requestRender).toHaveBeenCalled(); + }); + it("preserves a pending pet mode across editor replacement", () => { const originalProtocol = TERMINAL.imageProtocol; vi.spyOn(mode, "refreshSlashCommandState").mockResolvedValue(); From 359bf9503c08494a3f1919ec70b9c9d9072e2022 Mon Sep 17 00:00:00 2001 From: Bellman <54757707+Yeachan-Heo@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:10:11 +0900 Subject: [PATCH 07/12] fix(tui): emit coalesced multi-Esc chunks as individual presses (#4312) fix(tui): emit coalesced multi-Esc chunks as individual presses --- packages/tui/CHANGELOG.md | 5 + packages/tui/src/stdin-buffer.ts | 118 +++++++++++-- packages/tui/test/stdin-buffer.test.ts | 221 ++++++++++++++++++++++++- 3 files changed, 333 insertions(+), 11 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c8c72e702b..9cc42b940b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,6 +4,11 @@ ### Fixed +- A fast double-Esc (or triple-Esc) whose ESC bytes coalesce into one stdin chunk — which tmux always produces within its escape-time window, and SSH batching produces routinely — is now emitted as individual Escape key presses instead of a single `"\x1b\x1b"` sequence that parsed as the unbound `alt+escape` and silently swallowed both presses. This restores the double-Esc draft-clear and double-Esc selector gestures under tmux/SSH. Option-as-Meta sequences with a real continuation (e.g. Option+Up as `ESC ESC [ A`) remain atomic, and an ESC-cancelled incomplete sequence is still emitted whole. +- An ambiguous trailing run of Escape bytes now stays buffered until a continuation or the flush timeout resolves it, so `ESC ESC ESC` followed by `[A` in the next chunk still decodes as Escape then `alt+up` instead of two Escapes plus a plain Up that fired the destructive double-Escape gesture. +- Escape presses immediately followed by a bracketed paste in the same read are now emitted as individual Escape presses instead of one coalesced sequence that parsed as the unbound `alt+escape` and swallowed every press. +- A long run of Escape bytes arriving as many small reads no longer rescans the accumulated buffer on every read; only the two-byte ambiguous tail stays buffered, so 50,000 byte-by-byte Escape reads cost 50ms instead of 2.4s. +- A long run of Escape bytes followed by another key now decodes in linear time instead of rescanning the remaining input on every step, which blocked the event loop for over a second on a 50,000-byte run. - Apple Terminal.app now retains its default keyboard mode when it does not support the Kitty keyboard protocol, avoiding the modifyOtherKeys fallback that breaks Korean/Hangul IME composition. ## [0.13.1] - 2026-08-11 diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index 4f6ec7d793..300fc62251 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -278,12 +278,33 @@ function continuesAsStringTerminator(remaining: string, index: number): boolean return afterEsc === undefined || afterEsc === "\\"; } -function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } { +/** + * A buffered run of nothing but ESC bytes is N real Escape key presses, not an + * Option-as-Meta prefix. Emitting the run as one sequence parses as the unbound + * `alt+escape` and silently swallows every press, so any path that gives up on a + * continuation must split the run first. + */ +function splitResolvedEscapeRun(buffer: string): string[] { + return /^\x1b{2,}$/.test(buffer) ? buffer.split("") : [buffer]; +} + +/** + * `knownEscapeRunLength` is the number of leading ESC bytes a previous call + * already measured and returned as an all-Escape remainder. Resuming the scan + * there keeps a run delivered across many small reads linear overall instead of + * re-walking the whole accumulated prefix on every chunk. + */ +function extractCompleteSequences( + buffer: string, + knownEscapeRunLength = 0, +): { sequences: string[]; remainder: string; escapeRunRemainder: number } { const sequences: string[] = []; let pos = 0; while (pos < buffer.length) { - const remaining = buffer.slice(pos); + // Slicing at 0 would copy the whole buffer on every call, which is the + // dominant cost when a long Escape run arrives as many single-byte reads. + const remaining = pos === 0 ? buffer : buffer.slice(pos); // Try to extract a sequence starting at this position if (remaining.startsWith(ESC)) { @@ -299,6 +320,37 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain pos += 2; continue; } + // Measure the ESC run once. Testing the whole suffix on every iteration + // while the cut below advances only two bytes made a long run quadratic. + let runLength = pos === 0 ? Math.max(knownEscapeRunLength, 1) : 1; + while (runLength < remaining.length && remaining[runLength] === ESC) runLength++; + // A trailing run of nothing but ESC bytes is ambiguous: the next chunk + // may still deliver the continuation that turns its last ESC into a Meta + // prefix (ESC ESC ESC + "[A" is bare Escape then Option+Up). Splitting it + // now would emit an extra Escape and downgrade the wrapped key to a plain + // one, firing the destructive double-Escape gesture. Keep the whole run + // buffered; the flush timeout emits it as individual Escape presses. + if (runLength === remaining.length) { + // Only the last two bytes of the run are still ambiguous: a Meta prefix + // is at most ESC ESC, so any earlier ESC is already a settled press. + // Emitting them now keeps the retained buffer bounded; holding the whole + // run made every later read rescan it, which is quadratic for a long run + // delivered as many small chunks. Order of emitted presses is unchanged. + const settled = runLength - 2; + if (settled > 0) { + for (let index = 0; index < settled; index++) sequences.push(ESC); + return { sequences, remainder: remaining.slice(settled), escapeRunRemainder: 2 }; + } + return { sequences, remainder: remaining, escapeRunRemainder: runLength }; + } + // Only the final two ESC bytes can still form a Meta prefix for the + // continuation that follows the run; everything before them is a settled + // Escape press. Emitting them in one step keeps the walk linear. + if (runLength > 2) { + for (let index = 0; index < runLength - 2; index++) sequences.push(ESC); + pos += runLength - 2; + continue; + } // Find the end of this escape sequence let seqEnd = 1; while (seqEnd <= remaining.length) { @@ -315,7 +367,23 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain // here keeps an unterminated sequence from swallowing the next key. // seqEnd === 1 is excluded so Meta sequences (ESC ESC) still parse. if (remaining[seqEnd] === ESC && seqEnd >= 2 && !continuesAsStringTerminator(remaining, seqEnd)) { - sequences.push(candidate); + // A bare Escape may be followed in the same read by a Meta-wrapped + // sequence (ESC ESC ESC [ A). Keep the final Meta prefix intact; + // splitting all three ESC bytes would turn the wrapped arrow into a + // plain arrow after a destructive double-Escape gesture. + const trailing = remaining.slice(seqEnd); + if (/^\x1b+$/.test(candidate) && /^\x1b[^\x1b]/.test(trailing)) { + sequences.push(...candidate.slice(0, -1).split("")); + pos += seqEnd - 1; + break; + } + // A cut candidate of nothing but ESC bytes is real Escape key + // presses, not an Option-as-Meta prefix: a following ESC proves + // no continuation (like "[A") belongs to it. Emitting the pair + // as one sequence would parse as the unbound "alt+escape" and + // silently swallow both presses. + if (/^\x1b+$/.test(candidate)) sequences.push(...candidate.split("")); + else sequences.push(candidate); pos += seqEnd; break; } @@ -329,7 +397,7 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain } if (seqEnd > remaining.length) { - return { sequences, remainder: remaining }; + return { sequences, remainder: remaining, escapeRunRemainder: 0 }; } } else { // Not an escape sequence - take a single Unicode code point. Keep a @@ -337,7 +405,7 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain // complete it. const firstCodeUnit = remaining.charCodeAt(0); if (isHighSurrogate(firstCodeUnit)) { - if (remaining.length === 1) return { sequences, remainder: remaining }; + if (remaining.length === 1) return { sequences, remainder: remaining, escapeRunRemainder: 0 }; const secondCodeUnit = remaining.charCodeAt(1); if (isLowSurrogate(secondCodeUnit)) { sequences.push(remaining.slice(0, 2)); @@ -350,7 +418,7 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain } } - return { sequences, remainder: "" }; + return { sequences, remainder: "", escapeRunRemainder: 0 }; } export type StdinBufferOptions = { @@ -379,6 +447,9 @@ export type StdinBufferEventMap = { */ export class StdinBuffer extends EventEmitter { #buffer: string = ""; + // Length of the leading all-Escape run already measured in #buffer, so a run + // arriving as many small reads is scanned once overall instead of per chunk. + #bufferedEscapeRunLength = 0; #timeout?: NodeJS.Timeout; readonly #timeoutMs: number; #pasteMode: boolean = false; @@ -486,6 +557,7 @@ export class StdinBuffer extends EventEmitter { if (this.#pasteMode) { this.#pasteBuffer += this.#buffer; this.#buffer = ""; + this.#bufferedEscapeRunLength = 0; const endIndex = this.#pasteBuffer.indexOf(BRACKETED_PASTE_END); if (endIndex !== -1) { @@ -505,7 +577,11 @@ export class StdinBuffer extends EventEmitter { return; } - const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START); + // A known all-Escape prefix cannot contain the paste introducer, so start + // the scan just far enough back to catch a marker straddling the boundary. + // Rescanning the whole retained run on every read made a long run quadratic. + const pasteScanFrom = Math.max(0, this.#bufferedEscapeRunLength - BRACKETED_PASTE_START.length); + const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START, pasteScanFrom); if (startIndex !== -1) { if (startIndex > 0) { const beforePaste = this.#buffer.slice(0, startIndex); @@ -513,8 +589,12 @@ export class StdinBuffer extends EventEmitter { for (const sequence of result.sequences) { this.#emitDataSequence(sequence); } + // A bracketed paste start proves no Meta continuation is coming for a + // buffered Escape run, so resolve it into individual presses here too. if (result.remainder.length > 0) { - this.#emitDataSequence(result.remainder); + for (const sequence of splitResolvedEscapeRun(result.remainder)) { + this.#emitDataSequence(sequence); + } } } @@ -523,6 +603,7 @@ export class StdinBuffer extends EventEmitter { this.#pasteMode = true; this.#pasteBuffer = this.#buffer; this.#buffer = ""; + this.#bufferedEscapeRunLength = 0; const endIndex = this.#pasteBuffer.indexOf(BRACKETED_PASTE_END); if (endIndex !== -1) { @@ -542,8 +623,11 @@ export class StdinBuffer extends EventEmitter { return; } - const result = extractCompleteSequences(this.#buffer); + const result = extractCompleteSequences(this.#buffer, this.#bufferedEscapeRunLength); this.#buffer = result.remainder; + // Remember an all-Escape remainder so the next chunk resumes the run scan + // at its end rather than re-walking every byte received so far. + this.#bufferedEscapeRunLength = result.escapeRunRemainder; for (const sequence of result.sequences) { if (isSgrMousePrefix(sequence) && !isSgrMouseSequence(sequence)) continue; @@ -574,11 +658,13 @@ export class StdinBuffer extends EventEmitter { } const remainder = suffix.slice(index); this.#buffer = ""; + this.#bufferedEscapeRunLength = 0; this.#pendingKittyPrintableCodepoint = undefined; if (remainder) this.process(remainder); return; } this.#buffer = ""; + this.#bufferedEscapeRunLength = 0; this.#pendingKittyPrintableCodepoint = undefined; this.#sgrQuarantine = true; this.#sgrQuarantineBytes = suffix.length; @@ -709,12 +795,23 @@ export class StdinBuffer extends EventEmitter { if (isSgrMousePrefix(this.#buffer)) { this.#buffer = ""; + this.#bufferedEscapeRunLength = 0; this.#pendingKittyPrintableCodepoint = undefined; return pendingMeta === undefined ? [] : [pendingMeta]; } - const sequences = pendingMeta === undefined ? [this.#buffer] : [pendingMeta, this.#buffer]; + // A buffer of nothing but ESC bytes at flush time is N real Escape key + // presses that arrived faster than the flush window (tmux forwards a + // quick double-Esc as one "\x1b\x1b" chunk within escape-time). Keeping + // the pair atomic is only correct while a continuation can still turn it + // into an Option-as-Meta sequence (ESC ESC [ A); once the flush timeout + // fires, no continuation is coming, and emitting the pair as one + // sequence parses as the unbound "alt+escape" — silently swallowing + // both presses and breaking the double-Esc draft-clear gesture. + const flushedBuffer = splitResolvedEscapeRun(this.#buffer); + const sequences = pendingMeta === undefined ? flushedBuffer : [pendingMeta, ...flushedBuffer]; this.#buffer = ""; + this.#bufferedEscapeRunLength = 0; this.#pendingKittyPrintableCodepoint = undefined; return sequences; } @@ -725,6 +822,7 @@ export class StdinBuffer extends EventEmitter { this.#timeout = undefined; } this.#buffer = ""; + this.#bufferedEscapeRunLength = 0; this.#pasteMode = false; this.#pasteBuffer = ""; this.#pendingKittyPrintableCodepoint = undefined; diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index 59ee5a05b1..d066f48dbd 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -5,7 +5,7 @@ * MIT License - Copyright (c) 2025 opentui */ -import { beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { parseKey, setKittyProtocolActive } from "@gajae-code/tui/keys"; import { StdinBuffer } from "@gajae-code/tui/stdin-buffer"; @@ -23,6 +23,10 @@ describe("StdinBuffer", () => { }); }); + afterEach(() => { + vi.useRealTimers(); + }); + // Helper to process data through the buffer function processInput(data: string | Buffer): void { buffer.process(data); @@ -353,6 +357,221 @@ describe("StdinBuffer", () => { expect(flushed).toEqual(["\x1b"]); }); + it("emits a coalesced double-Esc chunk as two Escape presses after the flush timeout", async () => { + // tmux forwards a quick double-Esc as one "\x1b\x1b" chunk within + // escape-time; emitting it as a single sequence parses as the unbound + // "alt+escape" and swallows both presses (double-Esc draft clear). + processInput("\x1b\x1b"); + expect(emittedSequences).toEqual([]); + + await Bun.sleep(15); + expect(emittedSequences).toEqual(["\x1b", "\x1b"]); + }); + + it("emits two Esc presses split across chunks inside the flush window as two Escapes", async () => { + processInput("\x1b"); + await Bun.sleep(5); + processInput("\x1b"); + expect(emittedSequences).toEqual([]); + + await Bun.sleep(15); + expect(emittedSequences).toEqual(["\x1b", "\x1b"]); + }); + + it("emits a triple-Esc chunk as three Escape presses", async () => { + processInput("\x1b\x1b\x1b"); + + await Bun.sleep(15); + expect(emittedSequences).toEqual(["\x1b", "\x1b", "\x1b"]); + }); + + it("flushes a coalesced double-Esc explicitly as two Escapes", () => { + processInput("\x1b\x1b"); + expect(buffer.flush()).toEqual(["\x1b", "\x1b"]); + }); + + it("keeps Option-as-Meta ESC ESC sequences atomic when the continuation is present", () => { + // macOS Terminal "Use Option as Meta key": Option+Up arrives as ESC ESC [ A + // in one write and must stay one sequence. + processInput("\x1b\x1b[A"); + expect(emittedSequences).toEqual(["\x1b\x1b[A"]); + }); + + it("preserves a Meta-wrapped arrow after a preceding bare Escape", () => { + // macOS Terminal can batch bare Escape then Option+Up as ESC ESC ESC [ A. + // The bare Escape must not consume the Meta wrapper and turn this into + // a destructive double-Escape gesture followed by plain Up. + processInput("\x1b\x1b\x1b[A"); + expect(emittedSequences).toEqual(["\x1b", "\x1b\x1b[A"]); + expect(emittedSequences.map(parseKey)).toEqual(["escape", "alt+up"]); + }); + + it("preserves a Meta-wrapped arrow when the chunk splits after the third Escape", () => { + // Same ESC ESC ESC [ A bytes as above, but the read boundary falls after + // the Escape run. Only the final two bytes stay buffered as the ambiguous + // Meta candidate, so the continuation still forms the wrapper instead of a + // plain Up after a destructive double-Escape gesture. + vi.useFakeTimers(); + processInput("\x1b\x1b\x1b"); + expect(emittedSequences).toEqual(["\x1b"]); + + vi.advanceTimersByTime(9); + processInput("[A"); + + expect(emittedSequences).toEqual(["\x1b", "\x1b\x1b[A"]); + expect(emittedSequences.map(parseKey)).toEqual(["escape", "alt+up"]); + }); + + it("emits three Escapes when the split continuation arrives after the flush boundary", () => { + vi.useFakeTimers(); + processInput("\x1b\x1b\x1b"); + vi.advanceTimersByTime(10); + processInput("[A"); + + expect(emittedSequences).toEqual(["\x1b", "\x1b", "\x1b", "[", "A"]); + }); + + it("preserves a Meta-wrapped SS3 key after a preceding bare Escape", () => { + processInput("\x1b\x1b\x1bOP"); + expect(emittedSequences).toEqual(["\x1b", "\x1b\x1bOP"]); + expect(emittedSequences.map(parseKey)).toEqual(["escape", "alt+f1"]); + }); + + it("keeps a delayed Option continuation atomic when it arrives before the flush boundary", () => { + vi.useFakeTimers(); + processInput("\x1b\x1b"); + vi.advanceTimersByTime(9); + processInput("[A"); + + expect(emittedSequences).toEqual(["\x1b\x1b[A"]); + }); + + it("emits separate Escapes when an Option continuation arrives after the flush boundary", () => { + vi.useFakeTimers(); + processInput("\x1b\x1b"); + vi.advanceTimersByTime(10); + processInput("[A"); + + expect(emittedSequences).toEqual(["\x1b", "\x1b", "[", "A"]); + }); + + it("does not duplicate or lose Escapes across a cancellation cut and explicit flush", () => { + processInput("\x1b\x1b\x1b\x1b[1;"); + expect(emittedSequences).toEqual(["\x1b", "\x1b"]); + expect(buffer.flush()).toEqual(["\x1b\x1b[1;"]); + }); + + it("resolves an Escape run into individual presses when a bracketed paste follows", () => { + // A paste start proves no Meta continuation is coming for the buffered + // Escape run. Emitting the run as one sequence parses as the unbound + // alt+escape and swallows every press. + const pastes: string[] = []; + buffer.on("paste", text => pastes.push(text)); + + processInput("\x1b\x1b\x1b\x1b[200~hi\x1b[201~"); + + expect(emittedSequences).toEqual(["\x1b", "\x1b", "\x1b"]); + expect(emittedSequences.map(parseKey)).toEqual(["escape", "escape", "escape"]); + expect(pastes).toEqual(["hi"]); + }); + + it("resolves an even Escape run before a bracketed paste", () => { + const pastes: string[] = []; + buffer.on("paste", text => pastes.push(text)); + + processInput("\x1b\x1b\x1b\x1b\x1b[200~hi\x1b[201~"); + + expect(emittedSequences).toEqual(["\x1b", "\x1b", "\x1b", "\x1b"]); + expect(pastes).toEqual(["hi"]); + }); + + it("decodes an Escape run before a paste identically when the chunk splits", () => { + const pastes: string[] = []; + buffer.on("paste", text => pastes.push(text)); + + processInput("\x1b\x1b\x1b"); + processInput("\x1b[200~hi\x1b[201~"); + + expect(emittedSequences).toEqual(["\x1b", "\x1b", "\x1b"]); + expect(pastes).toEqual(["hi"]); + }); + + it("decodes an Escape run before a paste identically byte by byte", () => { + const pastes: string[] = []; + buffer.on("paste", text => pastes.push(text)); + + for (const byte of "\x1b\x1b\x1b\x1b[200~hi\x1b[201~") processInput(byte); + + expect(emittedSequences).toEqual(["\x1b", "\x1b", "\x1b"]); + expect(pastes).toEqual(["hi"]); + }); + + it("decodes a long Escape run followed by a key", () => { + // Measuring the run once keeps this linear; re-testing the whole suffix + // while the cut advanced two bytes at a time took over a second here. + const runLength = 50_000; + processInput(`${"\x1b".repeat(runLength)}A`); + + expect(emittedSequences.length).toBe(runLength - 1); + expect(emittedSequences.at(-1)).toBe("\x1b\x1bA"); + expect(emittedSequences.slice(0, -1).every(sequence => sequence === "\x1b")).toBe(true); + }); + + it("keeps only the ambiguous Meta candidate buffered across chunks", () => { + // Retaining the whole run made every later read rescan it. Only the final + // two bytes can still become a Meta prefix, so the rest settle immediately. + for (let index = 0; index < 64; index++) processInput("\x1b"); + + expect(emittedSequences).toEqual(Array(62).fill("\x1b")); + expect(buffer.flush()).toEqual(["\x1b", "\x1b"]); + }); + + it("scales linearly over Escape-run length delivered byte by byte", () => { + // The cross-chunk counterpart: rescanning the retained run on every read + // cost 2.4s at 50k bytes before the buffered run was bounded. + const decodePerByte = (runLength: number): number => { + const probe = new StdinBuffer(); + probe.on("data", () => {}); + const started = Bun.nanoseconds(); + for (let index = 0; index < runLength; index++) probe.process("\x1b"); + return Bun.nanoseconds() - started; + }; + decodePerByte(2_000); + + const small = decodePerByte(5_000); + const large = decodePerByte(50_000); + + expect(large / Math.max(small, 1)).toBeLessThan(30); + }); + + it("scales linearly rather than quadratically over Escape-run length", () => { + // Relative scaling, not an absolute wall-clock budget: a 10x longer run + // costs ~10x when the run boundary is measured once, but ~100x when every + // iteration rescans the remaining suffix. + const decode = (runLength: number): number => { + const probe = new StdinBuffer(); + const input = `${"\x1b".repeat(runLength)}A`; + const started = Bun.nanoseconds(); + probe.process(input); + return Bun.nanoseconds() - started; + }; + decode(2_000); + + const small = decode(5_000); + const large = decode(50_000); + + expect(large / Math.max(small, 1)).toBeLessThan(30); + }); + + it("still cuts an ESC-cancelled incomplete sequence without splitting it", async () => { + // An incomplete alt-CSI prefix cancelled by a new ESC is not a pure + // ESC run and must be emitted whole, exactly as before. + processInput("\x1b\x1b[1;\x1b"); + + await Bun.sleep(15); + expect(emittedSequences).toEqual(["\x1b\x1b[1;", "\x1b"]); + }); + it("should handle buffer input", () => { processInput(Buffer.from("\x1b[A")); expect(emittedSequences).toEqual(["\x1b[A"]); From b4e0e293b1d0bd9436769be02e724b456a3c7a44 Mon Sep 17 00:00:00 2001 From: Bellman <54757707+Yeachan-Heo@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:15:45 +0900 Subject: [PATCH 08/12] feat(tui): support /theme for immediate theme switching (#4429) --- docs/theme.md | 5 ++ packages/coding-agent/CHANGELOG.md | 4 ++ .../src/slash-commands/builtin-registry.ts | 48 ++++++++++++-- .../slash-command-builtin-registry.test.ts | 66 ++++++++++++++++++- 4 files changed, 117 insertions(+), 6 deletions(-) diff --git a/docs/theme.md b/docs/theme.md index e4b89aebbf..0f291cd0a7 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -172,6 +172,11 @@ Current defaults from settings schema: - `symbolPreset = "unicode"` - `colorBlindMode = false` +### Interactive switching (`/theme`) + +- `/theme` with no arguments opens the interactive theme selector with live preview. +- `/theme ` switches immediately: the name is validated against built-in and custom themes, persisted to the detected slot (`theme.dark` or `theme.light`), and applied to the running session (status line, editor border, and chat re-render at once). An unknown name is rejected with the list of available themes and changes nothing. + ### Explicit switching (`setTheme`) - loads selected theme diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 71d0af7e38..a815d0a98d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added +- `/theme ` now switches the theme immediately without opening the selector: the name is validated against built-in and custom themes, persisted to the detected `theme.dark`/`theme.light` slot, and applied to the running session in one step. Bare `/theme` keeps the existing live-preview selector, and an unknown name is rejected with the list of available themes. + +### Fixed ### Fixed - Managed output publication no longer freezes the resident event loop when a no-replace rename stalls in the kernel (#4394). Async managed publication now uses async file operations and native blocking-pool rename/link boundaries, while per-session stores also reap scrubbed protocol remnants on a throttled, serialized schedule. Atomic no-replace semantics and the synchronous publication path are unchanged. - The SDK broker's Windows process-liveness probe no longer opens a console window on every poll. `runProcessIncarnationCommand` spawned `powershell.exe` without `windowsHide`, and on Windows 11 — where the default terminal delegation hands every new console to Windows Terminal — each probe therefore created a visible terminal window that stole focus. The PowerShell path runs whenever the native reader cannot bind the target pid, so one dead or inaccessible pid turned the broker's ~2s liveness polling into a continuous window flash for the life of the process. Every other internal spawn in the repo already passed `windowsHide: true`; this one did not. diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index d051813688..2983547478 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -23,7 +23,7 @@ import { } from "../config/model-resolver"; import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../discovery/helpers.js"; import { DynamicBorder } from "../modes/components/dynamic-border"; -import { theme } from "../modes/theme/theme"; +import { getAvailableThemes, getDetectedThemeSettingsPath, setTheme, theme } from "../modes/theme/theme"; import { type ComposerSubmissionOptions, canApplyComposerSubmission, @@ -709,10 +709,48 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ }, { name: "theme", - description: "Open theme selector", - handleTui: (_command, runtime) => { - runtime.ctx.showThemeSelector(); - runtime.ctx.editor.setText(""); + description: "Change theme immediately, or open the theme selector without args", + inlineHint: "[theme]", + allowArgs: true, + handleTui: async (command, runtime) => { + const ctx = runtime.ctx; + const name = command.args?.trim(); + if (!name) { + ctx.showThemeSelector(); + ctx.editor.setText(""); + return; + } + const available = await getAvailableThemes(); + if (!available.includes(name)) { + ctx.showError(`Unknown theme "${name}". Available themes: ${available.join(", ")}`); + ctx.editor.setText(""); + return; + } + if (!ctx.settings.canWriteDurableConfig()) { + ctx.showError( + "Cannot change settings while config.yml has invalid YAML syntax. Repair config.yml and reload settings.", + ); + ctx.editor.setText(""); + return; + } + try { + ctx.settings.set(getDetectedThemeSettingsPath(), name); + } catch (error) { + ctx.showError(error instanceof Error ? error.message : String(error)); + ctx.editor.setText(""); + return; + } + const result = await setTheme(name, true, { shouldApply: () => !ctx.isStopped?.() }); + if (ctx.isStopped?.()) return; + ctx.statusLine.invalidate(); + ctx.updateEditorTopBorder(); + ctx.ui.invalidate(); + if (result.success) { + ctx.showStatus(`Theme changed to ${name}`); + } else { + ctx.showError(`Failed to load theme "${name}": ${result.error}\nFell back to dark theme.`); + } + ctx.editor.setText(""); }, }, { diff --git a/packages/coding-agent/test/slash-command-builtin-registry.test.ts b/packages/coding-agent/test/slash-command-builtin-registry.test.ts index ab12201163..e90a0daa5d 100644 --- a/packages/coding-agent/test/slash-command-builtin-registry.test.ts +++ b/packages/coding-agent/test/slash-command-builtin-registry.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import { BUILTIN_SLASH_COMMANDS } from "@gajae-code/coding-agent/extensibility/slash-commands"; -import { initTheme } from "@gajae-code/coding-agent/modes/theme/theme"; +import { getCurrentThemeName, initTheme } from "@gajae-code/coding-agent/modes/theme/theme"; import type { InteractiveModeContext } from "@gajae-code/coding-agent/modes/types"; import { BUILTIN_SLASH_COMMAND_DEFS, @@ -371,3 +371,67 @@ describe("builtin /handoff slash command", () => { expect(setText).toHaveBeenCalledWith(""); }); }); + +function createThemeTuiRuntime() { + const showThemeSelector = vi.fn(); + const showStatus = vi.fn(); + const showError = vi.fn(); + const setText = vi.fn(); + const settingsSet = vi.fn(); + const ctx = { + showThemeSelector, + showStatus, + showError, + editor: { setText }, + settings: { canWriteDurableConfig: () => true, set: settingsSet }, + statusLine: { invalidate: vi.fn() }, + updateEditorTopBorder: vi.fn(), + ui: { invalidate: vi.fn() }, + } as unknown as InteractiveModeContext; + + return { + runtime: { ctx, handleBackgroundCommand: () => undefined }, + showThemeSelector, + showStatus, + showError, + setText, + settingsSet, + }; +} + +describe("builtin /theme slash command", () => { + it("opens the theme selector when no theme name is given", async () => { + const { runtime, showThemeSelector, setText, settingsSet } = createThemeTuiRuntime(); + + const result = await executeBuiltinSlashCommand("/theme", runtime); + + expect(result).toBe(true); + expect(showThemeSelector).toHaveBeenCalledTimes(1); + expect(settingsSet).not.toHaveBeenCalled(); + expect(setText).toHaveBeenCalledWith(""); + }); + + it("changes the theme immediately when a valid theme name is given", async () => { + const { runtime, showThemeSelector, showStatus, settingsSet } = createThemeTuiRuntime(); + + const result = await executeBuiltinSlashCommand("/theme blue-crab", runtime); + + expect(result).toBe(true); + expect(showThemeSelector).not.toHaveBeenCalled(); + expect(settingsSet).toHaveBeenCalledTimes(1); + expect(settingsSet.mock.calls[0]?.[1]).toBe("blue-crab"); + expect(getCurrentThemeName()).toBe("blue-crab"); + expect(showStatus).toHaveBeenCalledWith("Theme changed to blue-crab"); + }); + + it("rejects an unknown theme name without touching settings", async () => { + const { runtime, showError, settingsSet } = createThemeTuiRuntime(); + + const result = await executeBuiltinSlashCommand("/theme not-a-theme", runtime); + + expect(result).toBe(true); + expect(settingsSet).not.toHaveBeenCalled(); + expect(showError).toHaveBeenCalledTimes(1); + expect(String(showError.mock.calls[0]?.[0])).toContain('Unknown theme "not-a-theme"'); + }); +}); From 51b357ed7598b725cc9a19f3cbaf3fe2bb964a7a Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Thu, 13 Aug 2026 14:30:38 +0900 Subject: [PATCH 09/12] fix(session): preserve remnant reaper diagnostics in backport The async publication backport depends on the structured reaper contract and shared filesystem diagnostics that landed earlier on dev. Preserve that contract on the 0.13.2 baseline so missing directories remain benign and real cleanup failures stay observable. Lore-id: release-0-13-2-remnant-reaper Confidence: high Scope-risk: narrow Reversibility: revert-clean Tested: bun test packages/coding-agent/test/managed-publication-event-loop.test.ts --- .../src/session/internal/managed-session-storage.ts | 1 + .../coding-agent/test/managed-publication-event-loop.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/session/internal/managed-session-storage.ts b/packages/coding-agent/src/session/internal/managed-session-storage.ts index 5379b52c4d..a9a828c7f2 100644 --- a/packages/coding-agent/src/session/internal/managed-session-storage.ts +++ b/packages/coding-agent/src/session/internal/managed-session-storage.ts @@ -9,6 +9,7 @@ import type { RecoveryFsIdentity, RecoveryFsRoot, } from "@gajae-code/natives"; +import { isEnoent, logger } from "@gajae-code/utils"; import type { SessionStorageRangeSnapshot, SessionStorageStat } from "../session-storage"; import { classifyNativePublishOutcome, diff --git a/packages/coding-agent/test/managed-publication-event-loop.test.ts b/packages/coding-agent/test/managed-publication-event-loop.test.ts index 68aa5993ec..2cea6bf52e 100644 --- a/packages/coding-agent/test/managed-publication-event-loop.test.ts +++ b/packages/coding-agent/test/managed-publication-event-loop.test.ts @@ -175,7 +175,7 @@ describe("scrubbed protocol remnant reaping (issue #4394)", () => { await seedRemnant(dir, `${REMNANT_PREFIX}aged-${index}`, 60 * 60 * 1000); } const syncResult = reapScrubbedProtocolRemnantsSync(dir); - expect(asyncResult).toEqual(syncResult); + expect(asyncResult.reaped).toBe(syncResult); expect(asyncResult).toEqual({ reaped: 4, failures: 0 }); }); }); From ef5112bb33238a078060c6600f96a2390fe666f2 Mon Sep 17 00:00:00 2001 From: Chaehyeon Lee Date: Thu, 13 Aug 2026 13:34:37 +0900 Subject: [PATCH 10/12] fix(task): exclude exact MCP managers from subagents (#4419) ACP exact-config sessions own tools-only MCP managers that canonical sub-sessions explicitly reject. Filter those managers at both task spawn paths while preserving reusable plugin MCP inheritance. Co-authored-by: Chaehyeon Lee --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/task/index.ts | 7 +- .../test/task/mcp-inheritance.test.ts | 123 ++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/test/task/mcp-inheritance.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a815d0a98d..4caf7cc533 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixed ### Fixed +- Task-launched canonical subagents no longer receive an ACP exact-config `toolsOnly` MCP manager that they are forbidden to reuse. Planner, Architect, Critic, and Ralplan consensus lanes now start normally while reusable plugin MCP managers continue to inherit across sub-sessions. - Managed output publication no longer freezes the resident event loop when a no-replace rename stalls in the kernel (#4394). Async managed publication now uses async file operations and native blocking-pool rename/link boundaries, while per-session stores also reap scrubbed protocol remnants on a throttled, serialized schedule. Atomic no-replace semantics and the synchronous publication path are unchanged. - The SDK broker's Windows process-liveness probe no longer opens a console window on every poll. `runProcessIncarnationCommand` spawned `powershell.exe` without `windowsHide`, and on Windows 11 — where the default terminal delegation hands every new console to Windows Terminal — each probe therefore created a visible terminal window that stole focus. The PowerShell path runs whenever the native reader cannot bind the target pid, so one dead or inaccessible pid turned the broker's ~2s liveness polling into a continuous window flash for the life of the process. Every other internal spawn in the repo already passed `windowsHide: true`; this one did not. ## [0.13.1] - 2026-08-11 diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index 64c69bcf7d..994a7204a1 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -1900,6 +1900,9 @@ export class TaskTool implements AgentTool path.basename(file.path).toLowerCase() !== "agents.md", ); const promptTemplates = this.session.promptTemplates; + const parentMcpManager = this.session.getMcpManager?.(); + // Exact-config tools-only managers belong to the top-level ACP session and cannot be reused by sub-sessions. + const reusableParentMcpManager = parentMcpManager?.isToolsOnly() ? undefined : parentMcpManager; // Initialize progress for all tasks for (let i = 0; i < tasksWithUniqueIds.length; i++) { @@ -2036,7 +2039,7 @@ export class TaskTool implements AgentTool null, + getSessionSpawns: () => "*", + getMcpManager: () => manager, + modelRegistry: { + authStorage: undefined, + refresh: async () => {}, + getAvailable: () => [], + getApiKey: async () => null, + } as unknown as ModelRegistry, + } as unknown as ToolSession; +} + +async function runTask(manager: MCPManager): Promise[0]> { + const runSubprocess = vi.spyOn(executorModule, "runSubprocess").mockResolvedValue(makeResult()); + const tool = await TaskTool.create(createSession(manager)); + const jobs = new AsyncJobManager({ onJobComplete: async () => {} }); + AsyncJobManager.setInstance(jobs); + + const started = await tool.execute("tool-call", { + agent: "planner", + tasks: [{ id: "McpProbe", description: "MCP probe", assignment: "Probe MCP inheritance." }], + }); + expect(started.details?.async?.jobId).toBeDefined(); + await jobs.waitForAll(); + await jobs.dispose({ timeoutMs: 100 }); + + expect(runSubprocess).toHaveBeenCalledTimes(1); + return runSubprocess.mock.calls[0]![0]; +} + +describe("task MCP inheritance", () => { + afterEach(() => { + AsyncJobManager.resetForTests(); + vi.restoreAllMocks(); + }); + + it("does not pass a tools-only parent manager into a sub-session", async () => { + vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ agents: [AGENT], projectAgentsDir: null }); + vi.spyOn(repositoryBindingModule, "resolveTaskRepositoryBinding").mockResolvedValue({ + schema: "gjc.repository_binding.v1", + worktreeRoot: "/repo", + commonDir: null, + displayPath: "/repo", + }); + vi.spyOn(repositoryBindingModule, "assertExecutionRootMatchesRepositoryBinding").mockResolvedValue({ + schema: "gjc.repository_binding.v1", + worktreeRoot: "/repo", + commonDir: null, + displayPath: "/repo", + }); + const toolsOnlyManager = { isToolsOnly: () => true } as unknown as MCPManager; + + const options = await runTask(toolsOnlyManager); + + expect(options.parentMcpManager).toBeUndefined(); + }); + + it("continues to pass a reusable parent manager into a sub-session", async () => { + vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ agents: [AGENT], projectAgentsDir: null }); + vi.spyOn(repositoryBindingModule, "resolveTaskRepositoryBinding").mockResolvedValue({ + schema: "gjc.repository_binding.v1", + worktreeRoot: "/repo", + commonDir: null, + displayPath: "/repo", + }); + vi.spyOn(repositoryBindingModule, "assertExecutionRootMatchesRepositoryBinding").mockResolvedValue({ + schema: "gjc.repository_binding.v1", + worktreeRoot: "/repo", + commonDir: null, + displayPath: "/repo", + }); + const reusableManager = { isToolsOnly: () => false } as unknown as MCPManager; + + const options = await runTask(reusableManager); + + expect(options.parentMcpManager).toBe(reusableManager); + }); +}); From 8a2b27e24020e8a48ad72a80775481f8cd143130 Mon Sep 17 00:00:00 2001 From: developjik <67889389+developjik@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:59:21 +0900 Subject: [PATCH 11/12] fix(coordinator-mcp): answer ping keepalive with empty result (#4412) handleJsonRpc handled initialize, tools/list, prompts/list, resources/list and tools/call but had no branch for the MCP `ping` method, so clients using ping as a liveness probe (e.g. Claude Code) received `-32601 unknown_method:ping` and kept reconnecting. Per the MCP spec, ping MUST return an empty result `{}`. The pump's dispatch already routes ping as a control frame bypassing the data-concurrency cap (and pump.test.ts already assumes ping returns an empty result), but the handler never actually answered it. Add the missing `ping` branch returning `{ result: {} }` and a focused regression test against the real handleJsonRpc. Co-authored-by: developjik --- .../src/coordinator-mcp/server.ts | 3 ++ .../test/coordinator-mcp-server.test.ts | 50 +++++++++++++++++++ .../test/coordinator-mcp/pump.test.ts | 10 ++++ 3 files changed, 63 insertions(+) diff --git a/packages/coding-agent/src/coordinator-mcp/server.ts b/packages/coding-agent/src/coordinator-mcp/server.ts index c08bd6b2f0..b63448ee11 100644 --- a/packages/coding-agent/src/coordinator-mcp/server.ts +++ b/packages/coding-agent/src/coordinator-mcp/server.ts @@ -5693,6 +5693,9 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions }, }; } + if (request.method === "ping") { + return { jsonrpc: "2.0", id, result: {} }; + } if (request.method === "tools/list") { return { jsonrpc: "2.0", id, result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) } }; } diff --git a/packages/coding-agent/test/coordinator-mcp-server.test.ts b/packages/coding-agent/test/coordinator-mcp-server.test.ts index 4a74c52038..46a3b3f56a 100644 --- a/packages/coding-agent/test/coordinator-mcp-server.test.ts +++ b/packages/coding-agent/test/coordinator-mcp-server.test.ts @@ -382,6 +382,56 @@ async function registerSdkSession(server: ReturnType { + async function pingServer(root: string) { + const server = createCoordinatorMcpServer({ + env: { + GJC_COORDINATOR_MCP_WORKDIR_ROOTS: root, + GJC_COORDINATOR_MCP_STATE_ROOT: path.join(root, ".gjc", "coordinator-state"), + GJC_COORDINATOR_MCP_PROFILE: "local", + GJC_COORDINATOR_MCP_REPO: "repo", + }, + services: { getAgentDir: () => path.join(root, "agent-global") }, + }); + return server; + } + + it("answers the MCP ping keepalive with an empty result instead of method-not-found", async () => { + const root = await tempRoot(); + const server = await pingServer(root); + const response = await server.handleJsonRpc({ jsonrpc: "2.0", id: 1, method: "ping" }); + expect(response).toEqual({ jsonrpc: "2.0", id: 1, result: {} }); + }); + + it("preserves a string request id in the ping response", async () => { + const root = await tempRoot(); + const server = await pingServer(root); + const response = await server.handleJsonRpc({ jsonrpc: "2.0", id: "keepalive-1", method: "ping" }); + expect(response).toEqual({ jsonrpc: "2.0", id: "keepalive-1", result: {} }); + }); + + it("answers ping with extra params by ignoring them (params carry no payload)", async () => { + const root = await tempRoot(); + const server = await pingServer(root); + const response = await server.handleJsonRpc({ + jsonrpc: "2.0", + id: 42, + method: "ping", + params: { unexpected: "ignored" }, + }); + expect(response).toEqual({ jsonrpc: "2.0", id: 42, result: {} }); + }); + + it("does not write any coordinator state files for a ping keepalive", async () => { + const root = await tempRoot(); + const stateRoot = path.join(root, ".gjc", "coordinator-state"); + const server = await pingServer(root); + await server.handleJsonRpc({ jsonrpc: "2.0", id: 1, method: "ping" }); + const exists = await fs + .stat(stateRoot) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); + }); it("uses agent-global SDK discovery and returns credential-free broker status", async () => { const root = await tempRoot(); const controls: SdkControl[] = []; diff --git a/packages/coding-agent/test/coordinator-mcp/pump.test.ts b/packages/coding-agent/test/coordinator-mcp/pump.test.ts index 0619f1d650..8b8f26d11a 100644 --- a/packages/coding-agent/test/coordinator-mcp/pump.test.ts +++ b/packages/coding-agent/test/coordinator-mcp/pump.test.ts @@ -352,4 +352,14 @@ describe("pumpCoordinatorMcpStream — frame handling", () => { await pumpCoordinatorMcpStream(handler as never, ch, l => void writes.push(JSON.parse(l))); expect(writes.map(w => w.id)).toEqual([7]); }); + it("answers a ping request but emits nothing for a ping notification (no id)", async () => { + const handler = async (req: Rpc): Promise => ({ jsonrpc: "2.0", id: req.id ?? null, result: {} }); + const writes: Rpc[] = []; + const ch = channel(); + ch.push(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" })}\n`); // request → answered + ch.push(`${JSON.stringify({ jsonrpc: "2.0", method: "ping" })}\n`); // notification (no id) → no response + ch.close(); + await pumpCoordinatorMcpStream(handler as never, ch, l => void writes.push(JSON.parse(l))); + expect(writes.map(w => w.id)).toEqual([1]); // only the request id gets a response + }); }); From fc601cddb6d0878c57e44401cb481f92afb34278 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Thu, 13 Aug 2026 14:41:37 +0900 Subject: [PATCH 12/12] fix(telegram): contain transient Windows heartbeat reads A transient Windows sharing violation while reading daemon state or the ownership lock escaped steady heartbeat renewal and terminated Telegram notifications. Keep the owner alive unless authority loss is proven, and always remove the staged sidecar before retrying. Lore-id: release-0-13-2-telegram-heartbeat Confidence: high Scope-risk: narrow Reversibility: revert-clean Tested: notifications Telegram heartbeat containment and staging cleanup --- packages/coding-agent/CHANGELOG.md | 5 +- .../src/sdk/bus/telegram-daemon-contract.ts | 4 +- .../src/sdk/bus/telegram-daemon.ts | 94 ++++++++++--------- .../notifications-telegram-daemon.test.ts | 39 ++++++++ .../telegram-daemon-generation-manifest.json | 4 +- 5 files changed, 99 insertions(+), 47 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4caf7cc533..0278184ba1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,10 +6,13 @@ - `/theme ` now switches the theme immediately without opening the selector: the name is validated against built-in and custom themes, persisted to the detected `theme.dark`/`theme.light` slot, and applied to the running session in one step. Bare `/theme` keeps the existing live-preview selector, and an unknown name is rejected with the list of available themes. ### Fixed -### Fixed +- The Telegram notification daemon now contains transient Windows state or ownership-lock read failures during steady heartbeat renewal instead of terminating its run loop. Staging files are cleaned in a `finally` block, and only a proven ownership mismatch stops the owner (#4200). +- Coordinator MCP now answers the standard `ping` keepalive with an empty result instead of `method not found`, preventing liveness clients from reconnecting repeatedly. - Task-launched canonical subagents no longer receive an ACP exact-config `toolsOnly` MCP manager that they are forbidden to reuse. Planner, Architect, Critic, and Ralplan consensus lanes now start normally while reusable plugin MCP managers continue to inherit across sub-sessions. - Managed output publication no longer freezes the resident event loop when a no-replace rename stalls in the kernel (#4394). Async managed publication now uses async file operations and native blocking-pool rename/link boundaries, while per-session stores also reap scrubbed protocol remnants on a throttled, serialized schedule. Atomic no-replace semantics and the synchronous publication path are unchanged. - The SDK broker's Windows process-liveness probe no longer opens a console window on every poll. `runProcessIncarnationCommand` spawned `powershell.exe` without `windowsHide`, and on Windows 11 — where the default terminal delegation hands every new console to Windows Terminal — each probe therefore created a visible terminal window that stole focus. The PowerShell path runs whenever the native reader cannot bind the target pid, so one dead or inaccessible pid turned the broker's ~2s liveness polling into a continuous window flash for the life of the process. Every other internal spawn in the repo already passed `windowsHide: true`; this one did not. +- Foreground Bash calls that finish before their auto-background deadline now cancel that deadline instead of leaving an uncancellable `Bun.sleep()` behind. In print mode the losing sleep kept the compiled CLI alive for the full command timeout after Bash and subagent work had already completed, which appeared as a shutdown hang with no active Node handles. +- Prompt suggestions now remain visible after cancelling another interactive action and returning to the composer. ## [0.13.1] - 2026-08-11 ### Added diff --git a/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts b/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts index 66246b05bc..903664f4e4 100644 --- a/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts +++ b/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts @@ -105,8 +105,10 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3; * settles non-forum close responses. Generation 62 rejects replay-gap * authority claims that do not match the requested cursor, answer bounds, * and retained replay suffix. + * Generation 63 contains transient steady-heartbeat state and ownership-lock + * read failures and cleans every staged sidecar before retrying (#4200). */ -export const DAEMON_GENERATION = 62; +export const DAEMON_GENERATION = 63; /** * Serving-compatibility boundary for daemon lifecycle requests. Epoch 5 diff --git a/packages/coding-agent/src/sdk/bus/telegram-daemon.ts b/packages/coding-agent/src/sdk/bus/telegram-daemon.ts index 6221b17662..4bdb3db05f 100644 --- a/packages/coding-agent/src/sdk/bus/telegram-daemon.ts +++ b/packages/coding-agent/src/sdk/bus/telegram-daemon.ts @@ -1419,38 +1419,38 @@ export async function renewOwnerHeartbeatSidecar(input: { logger.warn(`notifications: heartbeat sidecar staging failed: ${sanitizeDiagnostic(String(error))}`); return "publish_failed"; } - const lock = await readOwnershipLock(fsImpl, paths.lock); - if (!ownershipLockMatchesState(lock, state)) { - await fsImpl.unlink(tmp).catch(() => undefined); - return "not_owner"; - } - for (let attempt = 1; ; attempt++) { - // Revalidate the exact lock after the temporary sidecar exists — and again - // before every retry — so a stale writer cannot publish after ownership - // moved during its write or its backoff. - const finalLock = await readOwnershipLock(fsImpl, paths.lock); - if (!ownershipLockMatchesState(finalLock, state)) { - await fsImpl.unlink(tmp).catch(() => undefined); - return "not_owner"; - } - try { - await fsImpl.rename(tmp, paths.heartbeat); - return "renewed"; - } catch (error) { - // Windows: a transient external lock on the destination (antivirus, - // indexer) surfaces as EPERM on rename and used to escape as an - // uncaught exception that killed the whole daemon (#4200). Retry a - // bounded number of times under the lock fence, then keep the daemon - // alive and let the next heartbeat cycle publish. - if (attempt >= HEARTBEAT_SIDECAR_PUBLISH_ATTEMPTS || !isTransientSidecarPublishError(error)) { - await fsImpl.unlink(tmp).catch(() => undefined); - logger.warn( - `notifications: heartbeat sidecar publication failed after ${attempt} attempt(s): ${sanitizeDiagnostic(String(error))}`, - ); - return "publish_failed"; + try { + const lock = await readOwnershipLock(fsImpl, paths.lock); + if (!ownershipLockMatchesState(lock, state)) return "not_owner"; + for (let attempt = 1; ; attempt++) { + // Revalidate the exact lock after the temporary sidecar exists — and again + // before every retry — so a stale writer cannot publish after ownership + // moved during its write or its backoff. + const finalLock = await readOwnershipLock(fsImpl, paths.lock); + if (!ownershipLockMatchesState(finalLock, state)) return "not_owner"; + try { + await fsImpl.rename(tmp, paths.heartbeat); + return "renewed"; + } catch (error) { + // Windows: a transient external lock on the destination (antivirus, + // indexer) surfaces as EPERM on rename and used to escape as an + // uncaught exception that killed the whole daemon (#4200). Retry a + // bounded number of times under the lock fence, then keep the daemon + // alive and let the next heartbeat cycle publish. + if (attempt >= HEARTBEAT_SIDECAR_PUBLISH_ATTEMPTS || !isTransientSidecarPublishError(error)) { + logger.warn( + `notifications: heartbeat sidecar publication failed after ${attempt} attempt(s): ${sanitizeDiagnostic(String(error))}`, + ); + return "publish_failed"; + } + await Bun.sleep(HEARTBEAT_SIDECAR_PUBLISH_BACKOFF_MS * attempt); } - await Bun.sleep(HEARTBEAT_SIDECAR_PUBLISH_BACKOFF_MS * attempt); } + } finally { + // A thrown state or ownership-lock read after the staging write must not + // leak the live-PID staging file. After a successful rename the staging + // path no longer exists and this unlink is a no-op (#4200). + await fsImpl.unlink(tmp).catch(() => undefined); } } @@ -12490,19 +12490,27 @@ export class TelegramNotificationDaemon { let idleSince = this.runtime.now(); while (this.running) { if (await this.controlStopRequested()) break; - if ( - (await renewOwnerHeartbeatSidecar({ - settings: this.opts.settings, - ownerId: this.opts.ownerId, - acquisitionId: this.opts.ownerId, - fs: this.fsImpl, - now: this.opts.now, - pid: this.opts.pid ?? process.pid, - pidIncarnation: this.opts.pidIncarnation, - attachedEndpoints: this.attachedEndpointCount(), - })) === "not_owner" - ) - break; + // Transient Windows state/lock read failures must not terminate the + // notification daemon. Only a proven ownership mismatch stops it. + let ownerHeld = true; + try { + ownerHeld = + (await renewOwnerHeartbeatSidecar({ + settings: this.opts.settings, + ownerId: this.opts.ownerId, + acquisitionId: this.opts.ownerId, + fs: this.fsImpl, + now: this.opts.now, + pid: this.opts.pid ?? process.pid, + pidIncarnation: this.opts.pidIncarnation, + attachedEndpoints: this.attachedEndpointCount(), + })) !== "not_owner"; + } catch (error) { + logger.warn( + `notifications: ownership heartbeat renewal threw; continuing: ${sanitizeDiagnostic(String(error))}`, + ); + } + if (!ownerHeld) break; await this.runScan(); if (await this.controlStopRequested()) break; const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000); diff --git a/packages/coding-agent/test/notifications-telegram-daemon.test.ts b/packages/coding-agent/test/notifications-telegram-daemon.test.ts index 65cb06fcee..3b09db74cd 100644 --- a/packages/coding-agent/test/notifications-telegram-daemon.test.ts +++ b/packages/coding-agent/test/notifications-telegram-daemon.test.ts @@ -129,6 +129,45 @@ test("steady ownership heartbeat advances only the owner-tagged sidecar", async expect((await readOwnerFreshnessSnapshot({ settings: s })).effectiveHeartbeatAt).toBe(2); }); +test("heartbeat lock-read failures clean staging files for the live owner", async () => { + const agentDir = tempAgentDir(); + const s = setPrivateAgentDir(settings(agentDir), agentDir); + const paths = daemonPaths(agentDir); + await acquireDaemonOwnership({ + settings: s, + tokenFingerprint: "fp", + chatId: "42", + pid: process.pid, + randomId: () => "owner", + }); + const originalReadFile = fs.promises.readFile.bind(fs.promises); + let lockReads = 0; + const failingFs: TelegramDaemonFs = { + ...(fs.promises as unknown as TelegramDaemonFs), + readFile: (async (file: string, options?: BufferEncoding | { encoding?: BufferEncoding | null }) => { + if (file === paths.lock && ++lockReads >= 1) { + const error = new Error("simulated sharing violation") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return await originalReadFile(file, options as BufferEncoding); + }) as TelegramDaemonFs["readFile"], + }; + + await expect( + renewOwnerHeartbeatSidecar({ + settings: s, + ownerId: "owner", + acquisitionId: "owner", + pid: process.pid, + fs: failingFs, + }), + ).rejects.toMatchObject({ code: "EPERM" }); + + const stagingPrefix = `${paths.heartbeat}.`; + expect(fs.readdirSync(path.dirname(paths.heartbeat)).filter(name => path.join(path.dirname(paths.heartbeat), name).startsWith(stagingPrefix))).toEqual([]); +}); + test("stale-tag sidecars are inert and a stale writer cannot overwrite a successor heartbeat", async () => { const agentDir = tempAgentDir(); const s = setPrivateAgentDir(settings(agentDir), agentDir); diff --git a/scripts/telegram-daemon-generation-manifest.json b/scripts/telegram-daemon-generation-manifest.json index 6ff75aba42..01ec0792b6 100644 --- a/scripts/telegram-daemon-generation-manifest.json +++ b/scripts/telegram-daemon-generation-manifest.json @@ -531,7 +531,7 @@ "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:ownerPidFromOwnerId": "46691373b2bee01f28f3817a6aa6a7efffe880c2cea337c89155582c98d952bf", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:runDaemonInternal": "e535e82888898a2ace182b8c6fd1c5ef3b678348071ffa0a469c427c4c08ccaf", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:runDaemonSmoke": "6f085a667aa5c83de46d2d8945fb845c355fcbb43c46872342a44489203a5830", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts:DAEMON_GENERATION": "80e8e6cf6c21dfb4ec0098d7bf31bb4d5435de33bbd62b70b2787fc87af4b840", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts:DAEMON_GENERATION": "dd9b17a7956f0c8152a64400b9d8d7a4b7c29b25f8c3c38a184f38ba2184dc8a", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts:NOTIFICATION_PROTOCOL_VERSION": "b99289f651fedcf020d28dbaf6f07dd37e7e4a5f6dc1f5118b872112325f1e81", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-control.ts:DaemonProcessReference": "c3d13e3670a6245a1250c4ebfcd80a36dd8fc96c67ab64d9f979182bd117bc4e", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-control.ts:TelegramDaemonController": "7381b51cd968199876bfccd341ce79f1bcd895c9fb3899149394d6c54459f07f", @@ -616,7 +616,7 @@ "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:retireProvisionalDaemonOwnership": "d5f45044ea524f0694691bda67f47f9d74eaa5506926b718f40f2613a892d82f", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:revokeCallbackAliases": "42407ae6ce220a36e06f6b74a339b59dc1ec1efd44fa0e4c80fb2cf43d4fb5ba", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:rollbackOwnershipLockRebind": "7e9bc148e69268c393051e0b87417c20ce44824e777d4be050b9e91607c410a1", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:run": "f43058909794e1316408968ac0a179181bf49eaa594707648261da5f7efae0e6", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:run": "02dc86936e7de5bfdcd0385f5fe2cb6d39fbd66c2644de878852ce5a7e9d80fa", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:spawnTelegramDaemonOwner": "bbb56ea3a91bb24592fe8e7128261fd75424a1387b75b295a3e046fec1aba08a", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:startLifecycleControl": "237cf7c7881048e0e4329650567c8a47f597abe43206fe2f29376eb912cd6d1c", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:syncTelegramDirectory": "d056e2d84b39bd98a2c0b5a6f22ab0bb13adcd909c092b2f0636382dca488468",