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/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/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/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/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"); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f843cff533..0278184ba1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,17 @@ ## [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 +- 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 @@ -12,6 +23,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). @@ -132,6 +144,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/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/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/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/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/src/session/internal/managed-session-storage.ts b/packages/coding-agent/src/session/internal/managed-session-storage.ts index dccd63be2b..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, @@ -25,8 +26,10 @@ type NativeManagedSessionStorage = Pick< | "exactReplacePath" | "exactUnlink" | "linkNoReplacePath" + | "linkNoReplacePathAsync" | "openRecoveryFsRoot" | "renameNoReplacePath" + | "renameNoReplacePathAsync" | "repairOwnerOnlyPathSecurityExpected" | "snapshotDirectoryTree" | "verifyOwnerOnlyFdSecurity" @@ -497,6 +500,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 +554,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 +956,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 +1403,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 +2342,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 +2634,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 +2643,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 +2692,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/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/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 { 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/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 + }); }); 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(); 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..2cea6bf52e --- /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.reaped).toBe(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/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/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 = { 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"'); + }); +}); diff --git a/packages/coding-agent/test/task/mcp-inheritance.test.ts b/packages/coding-agent/test/task/mcp-inheritance.test.ts new file mode 100644 index 0000000000..510733cd27 --- /dev/null +++ b/packages/coding-agent/test/task/mcp-inheritance.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { AsyncJobManager } from "../../src/async"; +import type { ModelRegistry } from "../../src/config/model-registry"; +import { Settings } from "../../src/config/settings"; +import * as repositoryBindingModule from "../../src/gjc-runtime/repository-binding"; +import type { MCPManager } from "../../src/runtime-mcp/manager"; +import { TaskTool } from "../../src/task"; +import * as discoveryModule from "../../src/task/discovery"; +import * as executorModule from "../../src/task/executor"; +import type { AgentDefinition, SingleResult } from "../../src/task/types"; +import type { ToolSession } from "../../src/tools"; + +const AGENT: AgentDefinition = { + name: "planner", + description: "test planner", + systemPrompt: "test", + source: "bundled", +}; + +function makeResult(): SingleResult { + return { + index: 0, + id: "McpProbe", + agent: "planner", + agentSource: "bundled", + task: "Probe MCP inheritance.", + assignment: "Probe MCP inheritance.", + description: "MCP probe", + exitCode: 0, + output: "OK", + stderr: "", + truncated: false, + durationMs: 1, + tokens: 0, + }; +} + +function createSession(manager: MCPManager): ToolSession { + return { + cwd: "/repo", + hasUI: false, + settings: Settings.isolated({ + "async.enabled": true, + "task.isolation.mode": "off", + }), + getSessionFile: () => 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); + }); +}); 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)); 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; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d9e0aac6b8..9cc42b940b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### 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 ### Added 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/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"; 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"]); 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",