From 9bc62108239187965556111a84c066ecbc83343f Mon Sep 17 00:00:00 2001 From: Samatar Date: Tue, 19 May 2026 02:02:48 +0100 Subject: [PATCH 01/14] fix(terminal): send SIGINT for hardware Ctrl+C on iPad Fixes #1049 --- .../runtime/terminal-emulator-runtime.ts | 10 ++ packages/app/src/utils/terminal-keys.test.ts | 142 ++++++++++++++++++ packages/app/src/utils/terminal-keys.ts | 35 +++++ 3 files changed, 187 insertions(+) diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 1feff96e3..b844c6125 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -15,6 +15,7 @@ import { } from "@server/shared/terminal-input-mode"; import { type PendingTerminalModifiers, + isAppleHandheldPlatform, isTerminalModifierDomKey, mergeTerminalModifiers, normalizeDomTerminalKey, @@ -84,6 +85,14 @@ const isMac = (/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") || /Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? "")); +const isAppleHandheld = + typeof navigator !== "undefined" && + isAppleHandheldPlatform({ + userAgent: navigator.userAgent, + platform: (navigator as Navigator & { platform?: string }).platform, + maxTouchPoints: navigator.maxTouchPoints, + }); + const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18; const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000]; const OUTPUT_OPERATION_TIMEOUT_MS = 5_000; @@ -365,6 +374,7 @@ export class TerminalEmulatorRuntime { metaKey: event.metaKey, pendingModifiers: this.pendingModifiers, enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(), + isAppleHandheld, }) ) { return true; diff --git a/packages/app/src/utils/terminal-keys.test.ts b/packages/app/src/utils/terminal-keys.test.ts index 07dff7b1c..5d39d55cf 100644 --- a/packages/app/src/utils/terminal-keys.test.ts +++ b/packages/app/src/utils/terminal-keys.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { hasPendingTerminalModifiers, + isAppleHandheldPlatform, isTerminalModifierDomKey, mapTerminalDataToKey, mergeTerminalModifiers, @@ -10,6 +11,13 @@ import { shouldInterceptDomTerminalKey, } from "./terminal-keys"; +const IPAD_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15"; +const MAC_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; +const IPHONE_UA = + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"; + describe("terminal key helpers", () => { it("normalizes supported DOM keys", () => { expect(normalizeDomTerminalKey("Esc")).toBe("Escape"); @@ -157,6 +165,140 @@ describe("terminal key helpers", () => { ).toBe(false); }); + it("intercepts plain Ctrl+C on iPad so xterm's keyCode-13 quirk never reaches the PTY (#1049)", () => { + // See COMPAT(xterm-ipad-ctrl-c) in terminal-keys.ts. + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(true); + // Uppercase variant in case Caps Lock is on. + expect( + shouldInterceptDomTerminalKey({ + key: "C", + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(true); + }); + + it("does not intercept other Ctrl+letter combos on iPad (xterm handles them correctly)", () => { + for (const key of ["b", "d", "z", "a", "r", "l"]) { + expect( + shouldInterceptDomTerminalKey({ + key, + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(false); + } + }); + + it("does not intercept Ctrl+C on real macOS / Windows / Linux", () => { + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: false, + }), + ).toBe(false); + }); + + it("does not intercept Cmd+C on iPad (Cmd-based shortcuts stay with the OS)", () => { + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: true, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(false); + }); + + it("does not intercept Ctrl+Shift+C / Ctrl+Alt+C on iPad", () => { + // Only bare Ctrl+C is affected by the WebKit quirk; modified variants stay with xterm. + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: true, + shiftKey: true, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(false); + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: true, + shiftKey: false, + altKey: true, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(false); + }); + + it("detects iPad masquerading as macOS via maxTouchPoints", () => { + expect( + isAppleHandheldPlatform({ userAgent: IPAD_UA, platform: "MacIntel", maxTouchPoints: 5 }), + ).toBe(true); + }); + + it("detects iPhone/iPod by UA", () => { + expect( + isAppleHandheldPlatform({ userAgent: IPHONE_UA, platform: "iPhone", maxTouchPoints: 5 }), + ).toBe(true); + }); + + it("does not flag real macOS desktop as a handheld", () => { + expect( + isAppleHandheldPlatform({ userAgent: MAC_UA, platform: "MacIntel", maxTouchPoints: 0 }), + ).toBe(false); + }); + + it("does not flag macOS when maxTouchPoints == 1 (some trackpad contexts)", () => { + expect( + isAppleHandheldPlatform({ userAgent: MAC_UA, platform: "MacIntel", maxTouchPoints: 1 }), + ).toBe(false); + }); + + it("tolerates null/undefined navigator-style inputs", () => { + expect(isAppleHandheldPlatform({ userAgent: null, platform: null, maxTouchPoints: null })).toBe( + false, + ); + expect( + isAppleHandheldPlatform({ + userAgent: undefined, + platform: undefined, + maxTouchPoints: undefined, + }), + ).toBe(false); + }); + it("detects pending modifier state", () => { expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false); expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true); diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts index 4a70cb312..a25d4e08a 100644 --- a/packages/app/src/utils/terminal-keys.ts +++ b/packages/app/src/utils/terminal-keys.ts @@ -82,6 +82,27 @@ export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers) return modifiers.ctrl || modifiers.shift || modifiers.alt; } +interface AppleHandheldDetectionInput { + userAgent: string | null | undefined; + platform: string | null | undefined; + maxTouchPoints: number | null | undefined; +} + +// iPadOS 13+ WKWebView reports navigator.platform="MacIntel" and a Mac UA string. Distinguish +// iPad/iPhone from real macOS via maxTouchPoints, which is 0 on macOS and >1 on iPadOS/iOS. +export function isAppleHandheldPlatform(input: AppleHandheldDetectionInput): boolean { + const userAgent = input.userAgent ?? ""; + const platform = input.platform ?? ""; + const touchPoints = input.maxTouchPoints ?? 0; + if (/iPad|iPhone|iPod/.test(userAgent)) { + return true; + } + if (/Mac/i.test(platform) && touchPoints > 1) { + return true; + } + return false; +} + export function shouldInterceptDomTerminalKey(args: { key: string; ctrlKey: boolean; @@ -90,6 +111,7 @@ export function shouldInterceptDomTerminalKey(args: { metaKey: boolean; pendingModifiers: PendingTerminalModifiers; enhancedInputActive?: boolean; + isAppleHandheld?: boolean; }): boolean { if (hasPendingTerminalModifiers(args.pendingModifiers)) { return true; @@ -97,6 +119,19 @@ export function shouldInterceptDomTerminalKey(args: { if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) { return Boolean(args.enhancedInputActive); } + // COMPAT(xterm-ipad-ctrl-c): WebKit sends keyCode=13 for hardware-kbd Ctrl+C on iPad, so + // xterm.js emits \r instead of \x03. Upstream: xtermjs/xterm.js#5721, targeting xterm.js 7.0.0. + // Drop this block and the isAppleHandheld plumbing once @xterm/xterm is bumped past it. + if ( + args.isAppleHandheld && + args.ctrlKey && + !args.metaKey && + !args.altKey && + !args.shiftKey && + (args.key === "c" || args.key === "C") + ) { + return true; + } return false; } From 5a1c7f266c99d287dcb6a709ba02796c2eae184c Mon Sep 17 00:00:00 2001 From: Samatar Date: Tue, 19 May 2026 02:16:33 +0100 Subject: [PATCH 02/14] fix(server): render non-ASCII filenames in git output Fixes #436 --- packages/server/src/utils/checkout-git-batching.test.ts | 6 +++++- packages/server/src/utils/run-git-command.ts | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/server/src/utils/checkout-git-batching.test.ts b/packages/server/src/utils/checkout-git-batching.test.ts index 1fc062657..6141109e2 100644 --- a/packages/server/src/utils/checkout-git-batching.test.ts +++ b/packages/server/src/utils/checkout-git-batching.test.ts @@ -16,8 +16,12 @@ vi.mock("child_process", async () => { const [command, commandArgs] = args; if (command === "git" && Array.isArray(commandArgs)) { const normalizedArgs = commandArgs.map((arg) => String(arg)); + // `runGitCommand` always prepends `-c core.quotepath=false`; skip it to + // find the actual git subcommand. + const subcommandIndex = + normalizedArgs[0] === "-c" && normalizedArgs[1] === "core.quotepath=false" ? 2 : 0; const isTrackedTextDiff = - normalizedArgs[0] === "diff" && + normalizedArgs[subcommandIndex] === "diff" && normalizedArgs.includes("HEAD") && !normalizedArgs.includes("--numstat") && !normalizedArgs.includes("--no-index") && diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index 2ff2dd14c..bef9b73be 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -77,7 +77,9 @@ export function runGitCommand( logger.trace(traceContext, "Spawning git command"); } - const child = spawnProcess("git", args, { + // `core.quotepath=false` makes git emit raw UTF-8 paths instead of + // octal-escaping non-ASCII bytes (e.g. `测试文件.txt` vs `"\346\265\213..."`). + const child = spawnProcess("git", ["-c", "core.quotepath=false", ...args], { cwd: options.cwd, envOverlay, shell: false, From aaabadb04bf10c70ff7e57b734d17c0d8d50f6fb Mon Sep 17 00:00:00 2001 From: Yurui Zhou Date: Tue, 19 May 2026 10:41:55 +0800 Subject: [PATCH 03/14] fix: prevent macOS desktop unlock freeze after display sleep (#745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Restart the GPU process to recover from macOS compositor freezes macOS display sleep can leave Chromium's GPU-process display link stuck on a stale display, so the compositor stops producing frames and the window looks frozen — unresponsive to clicks and keys — even though the renderer stays alive. setupDarwinCompositorWatchdog polls the renderer for frame production and, on a sustained stall while the window is visible and unlocked, restarts the GPU process so Chromium rebuilds the display link. This replaces setupDarwinPaintRefresh, whose invalidate/resize nudges did not address the dead display link. Co-Authored-By: Claude Opus 4.7 (1M context) * Give compositor watchdog a module home --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Mohamed Boudra --- docs/development.md | 16 ++ packages/desktop/src/main.ts | 4 +- .../window/compositor-watchdog/index.test.ts | 36 ++++ .../src/window/compositor-watchdog/index.ts | 158 ++++++++++++++++++ packages/desktop/src/window/window-manager.ts | 53 ------ 5 files changed, 212 insertions(+), 55 deletions(-) create mode 100644 packages/desktop/src/window/compositor-watchdog/index.test.ts create mode 100644 packages/desktop/src/window/compositor-watchdog/index.ts diff --git a/docs/development.md b/docs/development.md index b0f824396..861dbe782 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,6 +43,22 @@ In any worktree-style or portless setup, never assume default ports. `http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP. Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy. +### Desktop macOS compositor watchdog + +macOS display sleep can leave Chromium's GPU-process display link — the vsync +source that drives frame production — stuck on a stale display. The compositor +then stops producing frames and the window looks frozen: unresponsive to clicks +and keys even though the renderer and every process stay alive. It self-recovers +after a few minutes, which is too long for a foreground app. + +`setupDarwinCompositorWatchdog` +(`packages/desktop/src/window/compositor-watchdog/index.ts`) guards against +this. It polls the renderer for frame production every couple of seconds and, +after a sustained stall while the window is visible and unlocked, restarts the +GPU process so Chromium rebuilds the display link. The probe is skipped while +the screen is locked or the window is hidden or minimized, since a window +legitimately stops producing frames then. + ### Daemon logs Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 5efd7a534..7fcc9a2f6 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -19,12 +19,12 @@ import { getMainWindowChromeOptions, getWindowBackgroundColor, resolveSystemWindowTheme, - setupDarwinPaintRefresh, setupWindowResizeEvents, setupDefaultContextMenu, setupDragDropPrevention, buildStandardContextMenuItems, } from "./window/window-manager.js"; +import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js"; import { registerDialogHandlers } from "./features/dialogs.js"; import { registerNotificationHandlers, @@ -398,7 +398,7 @@ async function createMainWindow(): Promise { app.dock?.setBadge(devWorktreeName); } - setupDarwinPaintRefresh(mainWindow); + setupDarwinCompositorWatchdog(mainWindow); setupWindowResizeEvents(mainWindow); setupDefaultContextMenu(mainWindow); setupDragDropPrevention(mainWindow); diff --git a/packages/desktop/src/window/compositor-watchdog/index.test.ts b/packages/desktop/src/window/compositor-watchdog/index.test.ts new file mode 100644 index 000000000..3dc4b524d --- /dev/null +++ b/packages/desktop/src/window/compositor-watchdog/index.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { shouldRecoverFromFrameStall } from "."; + +describe("compositor-watchdog", () => { + describe("shouldRecoverFromFrameStall", () => { + const recoverable = { + stalledChecks: 3, + recovering: false, + msSinceLastRecovery: 120_000, + consecutiveRecoveries: 0, + }; + + it("recovers once the stall threshold is reached", () => { + expect(shouldRecoverFromFrameStall(recoverable)).toBe(true); + }); + + it("waits until the stall threshold is reached", () => { + expect(shouldRecoverFromFrameStall({ ...recoverable, stalledChecks: 2 })).toBe(false); + }); + + it("does not recover while a recovery is already in progress", () => { + expect(shouldRecoverFromFrameStall({ ...recoverable, recovering: true })).toBe(false); + }); + + it("respects the cooldown between recoveries", () => { + expect(shouldRecoverFromFrameStall({ ...recoverable, msSinceLastRecovery: 30_000 })).toBe( + false, + ); + }); + + it("stops recovering after the consecutive-recovery cap", () => { + expect(shouldRecoverFromFrameStall({ ...recoverable, consecutiveRecoveries: 3 })).toBe(false); + }); + }); +}); diff --git a/packages/desktop/src/window/compositor-watchdog/index.ts b/packages/desktop/src/window/compositor-watchdog/index.ts new file mode 100644 index 000000000..654abde39 --- /dev/null +++ b/packages/desktop/src/window/compositor-watchdog/index.ts @@ -0,0 +1,158 @@ +import { app, type BrowserWindow, powerMonitor } from "electron"; + +// COMPAT(darwinCompositorWatchdog): added in v0.1.78, target removal after +// 2026-11-19. Workaround for Electron/Chromium macOS display-sleep compositor +// stalls; re-test when Electron/Chromium is upgraded. + +// How often the main process probes the renderer for frame production. +const FRAME_PROBE_INTERVAL_MS = 2000; +// A probed frame must arrive within this window or the probe counts as stalled. +const FRAME_PROBE_DEADLINE_MS = 300; +// Consecutive stalled probes before the watchdog restarts the GPU process (~6 s). +const FRAME_STALL_CHECKS_TO_RECOVER = 3; +// Minimum gap between GPU-process restarts. +const COMPOSITOR_RECOVERY_COOLDOWN_MS = 60_000; +// Grace period for Chromium to relaunch the GPU process before probing resumes. +const GPU_RELAUNCH_GRACE_MS = 5_000; +// Stop restarting the GPU process after this many tries without frames returning. +const MAX_CONSECUTIVE_RECOVERIES = 3; + +// Resolves { producedFrame, visibilityState } for the renderer. The frame is +// requested with requestAnimationFrame; setTimeout (not vsync-driven) bounds the +// wait so the probe always resolves even when frame production has stopped. +const FRAME_PROBE_SOURCE = `new Promise((resolve) => { + let settled = false; + const finish = (producedFrame) => { + if (settled) return; + settled = true; + resolve({ producedFrame, visibilityState: document.visibilityState }); + }; + requestAnimationFrame(() => finish(true)); + setTimeout(() => finish(false), ${FRAME_PROBE_DEADLINE_MS}); +})`; + +interface FrameStallState { + stalledChecks: number; + recovering: boolean; + msSinceLastRecovery: number; + consecutiveRecoveries: number; +} + +export function shouldRecoverFromFrameStall(state: FrameStallState): boolean { + return ( + state.stalledChecks >= FRAME_STALL_CHECKS_TO_RECOVER && + !state.recovering && + state.msSinceLastRecovery >= COMPOSITOR_RECOVERY_COOLDOWN_MS && + state.consecutiveRecoveries < MAX_CONSECUTIVE_RECOVERIES + ); +} + +function findGpuProcessPid(): number | null { + for (const metric of app.getAppMetrics()) { + if (metric.type === "GPU") { + return metric.pid; + } + } + return null; +} + +// macOS display sleep can leave Chromium's GPU-process display link (the vsync +// source that drives frame production) stuck on a stale display. The compositor +// then stops producing frames and the window looks frozen: unresponsive to +// clicks and keys even though the renderer and every process stay alive. This +// watchdog polls the renderer for frame production and, on a sustained stall, +// restarts the GPU process so Chromium rebuilds the display link. +export function setupDarwinCompositorWatchdog(win: BrowserWindow): void { + if (process.platform !== "darwin") { + return; + } + + // Keep producing frames while occluded so the probe is not fooled by throttling. + win.webContents.setBackgroundThrottling(false); + + let stalledChecks = 0; + let recovering = false; + let lastRecoveryAt = 0; + let consecutiveRecoveries = 0; + let screenLocked = false; + + const recoverCompositor = async () => { + recovering = true; + lastRecoveryAt = Date.now(); + consecutiveRecoveries += 1; + stalledChecks = 0; + const gpuPid = findGpuProcessPid(); + console.warn( + `[compositor-watchdog] Desktop window stopped producing frames; restarting GPU process ` + + `(pid=${gpuPid ?? "unknown"}, attempt ${consecutiveRecoveries}) to recover`, + ); + if (gpuPid !== null) { + try { + process.kill(gpuPid, "SIGKILL"); + } catch (error) { + console.warn("[compositor-watchdog] Could not restart GPU process", error); + } + } + await new Promise((resolve) => setTimeout(resolve, GPU_RELAUNCH_GRACE_MS)); + recovering = false; + }; + + const probeFrameProduction = async () => { + if (win.isDestroyed() || recovering) { + return; + } + // A freeze is only meaningful, and only distinguishable from a normal idle + // window, while the window is actually on screen. A locked screen, a + // minimized window, or a hidden one legitimately stops producing frames. + if (screenLocked || !win.isVisible() || win.isMinimized()) { + stalledChecks = 0; + return; + } + + let result: { producedFrame?: unknown; visibilityState?: unknown } | null; + try { + result = await win.webContents.executeJavaScript(FRAME_PROBE_SOURCE); + } catch { + return; + } + if (!result || result.visibilityState !== "visible") { + stalledChecks = 0; + return; + } + if (result.producedFrame === true) { + stalledChecks = 0; + consecutiveRecoveries = 0; + return; + } + + stalledChecks += 1; + if ( + shouldRecoverFromFrameStall({ + stalledChecks, + recovering, + msSinceLastRecovery: Date.now() - lastRecoveryAt, + consecutiveRecoveries, + }) + ) { + void recoverCompositor(); + } + }; + + const probeTimer = setInterval(() => void probeFrameProduction(), FRAME_PROBE_INTERVAL_MS); + const handleScreenLocked = () => { + screenLocked = true; + stalledChecks = 0; + }; + const handleScreenUnlocked = () => { + screenLocked = false; + stalledChecks = 0; + }; + powerMonitor.on("lock-screen", handleScreenLocked); + powerMonitor.on("unlock-screen", handleScreenUnlocked); + + win.once("closed", () => { + clearInterval(probeTimer); + powerMonitor.off("lock-screen", handleScreenLocked); + powerMonitor.off("unlock-screen", handleScreenUnlocked); + }); +} diff --git a/packages/desktop/src/window/window-manager.ts b/packages/desktop/src/window/window-manager.ts index 176ef6c90..56697685d 100644 --- a/packages/desktop/src/window/window-manager.ts +++ b/packages/desktop/src/window/window-manager.ts @@ -229,59 +229,6 @@ export function setupWindowResizeEvents(win: BrowserWindow): void { }); } -function refreshChromiumSurface(win: BrowserWindow): void { - if (win.isDestroyed()) { - return; - } - - win.webContents.invalidate(); - if (win.isMaximized() || win.isFullScreen()) { - return; - } - - const [width, height] = win.getSize(); - win.setSize(width + 1, height); - setTimeout(() => { - if (!win.isDestroyed()) { - win.setSize(width, height); - } - }, 32); -} - -export function setupDarwinPaintRefresh(win: BrowserWindow): void { - if (process.platform !== "darwin") { - return; - } - - win.webContents.setBackgroundThrottling(false); - - const requestSurfaceRefresh = () => { - if (!win.isDestroyed()) { - win.webContents.invalidate(); - } - }; - const handleChildProcessGone = ( - _event: Electron.Event, - details: { type?: string; reason?: string }, - ) => { - if (details.type !== "GPU") { - return; - } - - console.warn("[window] GPU process gone:", details.reason); - refreshChromiumSurface(win); - }; - - win.on("restore", requestSurfaceRefresh); - win.on("show", requestSurfaceRefresh); - app.on("child-process-gone", handleChildProcessGone); - win.once("closed", () => { - win.off("restore", requestSurfaceRefresh); - win.off("show", requestSurfaceRefresh); - app.off("child-process-gone", handleChildProcessGone); - }); -} - export function buildStandardContextMenuItems( contents: WebContents, params: Electron.ContextMenuParams, From 8ff63a6d715ade95f293205c8b6a54b58018349c Mon Sep 17 00:00:00 2001 From: xy-plus <40733434+xy-plus@users.noreply.github.com> Date: Tue, 19 May 2026 10:57:55 +0800 Subject: [PATCH 04/14] fix(server): keep Codex sub-agent running on transient child error state Fixes #1071 --- .../providers/codex-app-server-agent.test.ts | 34 +++++++++++ .../providers/codex/tool-call-mapper.test.ts | 58 +++++++++++++++++++ .../agent/providers/codex/tool-call-mapper.ts | 17 +++++- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 89ec1ccbe..8abafe00c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1352,6 +1352,40 @@ describe("Codex app-server provider", () => { }); }); + test("does not synthesize a parent sub-agent failure from child error state alone", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "collabAgentToolCall", + id: "call-sub-agent-transient-child-error", + tool: "spawnAgent", + status: "completed", + prompt: "Validate the child agent result.", + receiverThreadIds: ["child-thread-1"], + agentsStates: { + "child-thread-1": { status: "error", message: "Sub-agent failed" }, + }, + }, + }); + + expect(events.at(-1)?.item).toMatchObject({ + type: "tool_call", + callId: "call-sub-agent-transient-child-error", + name: "Sub-agent", + status: "running", + error: null, + detail: { + type: "sub_agent", + subAgentType: "Sub-agent", + description: "Validate the child agent result.", + }, + }); + }); + test("loads Codex persisted history from the app-server thread", async () => { const session = createSession(); const requests: Array<{ method: string; params: unknown }> = []; diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index ac14899fd..4271f4059 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -212,6 +212,64 @@ describe("codex tool-call mapper", () => { }); }); + it("does not fail a collabAgentToolCall from child error state alone", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "collabAgentToolCall", + id: "call-sub-agent-transient-child-error", + tool: "spawnAgent", + status: "completed", + prompt: "Inspect the Codex stream path.", + receiverThreadIds: ["child-thread-1"], + agentsStates: { + "child-thread-1": { status: "error", message: "Sub-agent failed" }, + }, + }); + + expect(item).toEqual({ + type: "tool_call", + callId: "call-sub-agent-transient-child-error", + name: "Sub-agent", + status: "running", + error: null, + detail: { + type: "sub_agent", + subAgentType: "Sub-agent", + description: "Inspect the Codex stream path.", + log: "", + actions: [], + }, + }); + }); + + it("still fails a collabAgentToolCall from an explicitly failed child state", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "collabAgentToolCall", + id: "call-sub-agent-child-failed", + tool: "spawnAgent", + status: "completed", + prompt: "Inspect the Codex stream path.", + receiverThreadIds: ["child-thread-1"], + agentsStates: { + "child-thread-1": { status: "failed", message: "Child failed" }, + }, + }); + + expect(item).toEqual({ + type: "tool_call", + callId: "call-sub-agent-child-failed", + name: "Sub-agent", + status: "failed", + error: { message: "Sub-agent failed" }, + detail: { + type: "sub_agent", + subAgentType: "Sub-agent", + description: "Inspect the Codex stream path.", + log: "", + actions: [], + }, + }); + }); + it("maps mcp read_file completion with detail", () => { const item = mapCodexToolCallFromThreadItem( { diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 2c21288b7..cc6e0d4ca 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -544,6 +544,14 @@ function readStatus(value: unknown): string | undefined { return typeof value.status === "string" ? value.status : undefined; } +function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] { + const normalized = status.trim().toLowerCase(); + if (normalized === "error" || normalized === "errored") { + return "running"; + } + return normalizeToolCallStatus(status, null, null); +} + function resolveCollabAgentStatus( item: z.infer, ): ToolCallTimelineItem["status"] { @@ -551,10 +559,15 @@ function resolveCollabAgentStatus( return "failed"; } + const parentStatus = normalizeToolCallStatus(item.status, null, null); + if (parentStatus === "failed") { + return "failed"; + } + const childStatuses = Object.values(item.agentsStates ?? {}) .map(readStatus) .filter((status): status is string => typeof status === "string" && status.trim().length > 0) - .map((status) => normalizeToolCallStatus(status, null, null)); + .map(normalizeCollabAgentChildStatus); if (childStatuses.some((status) => status === "failed")) { return "failed"; @@ -566,7 +579,7 @@ function resolveCollabAgentStatus( return childStatuses.every((status) => status === "completed") ? "completed" : "running"; } - return normalizeToolCallStatus(item.status, item.error ?? null, null); + return parentStatus; } function buildMcpToolName(server: string | undefined, tool: string): string { From 383b380d8a4fd8b94d5a15231833b6ee8af772a1 Mon Sep 17 00:00:00 2001 From: Zexin Yuan Date: Tue, 19 May 2026 11:15:52 +0800 Subject: [PATCH 05/14] fix(cli): query daemon for status and pairing offer over RPC Closes #1081 --- docs/rpc-namespacing.md | 2 +- .../cli/src/commands/daemon/local-daemon.ts | 6 + packages/cli/src/commands/daemon/pair.ts | 38 +++++++ packages/cli/src/commands/daemon/status.ts | 70 +++++++++--- packages/server/src/client/daemon-client.ts | 26 +++++ packages/server/src/server/bootstrap.ts | 10 ++ packages/server/src/server/session.ts | 107 ++++++++++++++++++ .../server/src/server/websocket-server.ts | 28 +++++ packages/server/src/shared/messages.ts | 62 ++++++++++ 9 files changed, 334 insertions(+), 15 deletions(-) diff --git a/docs/rpc-namespacing.md b/docs/rpc-namespacing.md index cf1b9e1d9..cd949ffdb 100644 --- a/docs/rpc-namespacing.md +++ b/docs/rpc-namespacing.md @@ -11,7 +11,7 @@ The namespace reads left to right: - Domain: `checkout` - Provider or subsystem: `github` -- Operation: `set_auto_merge` +- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead. - Direction: `request` or `response` Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing. diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 943752e4e..f153df13f 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -392,9 +392,15 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD const env: NodeJS.ProcessEnv = { ...envWithHome(options.home), // Status should reflect local persisted config + pid file, not inherited daemon env overrides. + // This is CLI-side defensive scrubbing; the daemon RPC is authoritative when available. PASEO_LISTEN: undefined, PASEO_HOSTNAMES: undefined, PASEO_ALLOWED_HOSTS: undefined, + PASEO_RELAY_ENABLED: undefined, + PASEO_RELAY_ENDPOINT: undefined, + PASEO_RELAY_PUBLIC_ENDPOINT: undefined, + PASEO_RELAY_USE_TLS: undefined, + PASEO_RELAY_PUBLIC_USE_TLS: undefined, }; const home = resolvePaseoHome(env); const config = loadConfig(home, { env }); diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index 14aced0cc..cd238fce7 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -1,6 +1,8 @@ import { Command } from "commander"; import chalk from "chalk"; import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpaseo/server"; +import { tryConnectToDaemon } from "../../utils/client.js"; +import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; import { addJsonOption } from "../../utils/command-options.js"; interface PairOptions { @@ -22,6 +24,35 @@ export async function runPairCommand(options: PairOptions): Promise { } const paseoHome = resolvePaseoHome(); + const state = resolveLocalDaemonState({ home: paseoHome }); + const host = resolveTcpHostFromListen(state.listen); + + // Try to get the pairing offer from the running daemon first. + if (host) { + const client = await tryConnectToDaemon({ host, timeout: 1500 }); + if (client) { + const supportsDaemonStatusRpc = + client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true; + if (supportsDaemonStatusRpc) { + try { + const offer = await client.getDaemonPairingOffer(); + await client.close().catch(() => {}); + outputPairingResult( + { relayEnabled: offer.relayEnabled, url: offer.url, qr: offer.qr ?? null }, + options, + ); + return; + } catch { + // COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while + // old daemons lack daemonStatusRpc. Remove once the daemon floor is past + // v0.1.76; pairing should come from daemon.get_pairing_offer. + } + } + await client.close().catch(() => {}); + } + } + + // Fall back to local pairing offer generation. const config = loadConfig(paseoHome); const pairing = await generateLocalPairingOffer({ paseoHome, @@ -34,6 +65,13 @@ export async function runPairCommand(options: PairOptions): Promise { includeQr: true, }); + outputPairingResult(pairing, options); +} + +function outputPairingResult( + pairing: { relayEnabled: boolean; url: string | null; qr: string | null }, + options: PairOptions, +): void { if (!pairing.relayEnabled || !pairing.url) { console.error(chalk.red("Relay pairing is disabled for this daemon config.")); console.error(chalk.yellow("Enable relay and run this command again.")); diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index dbc41c423..6752099be 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -10,6 +10,7 @@ interface ProviderBinaryStatus { label: string; path: string | null; version: string | null; + source?: "daemon" | "local"; } interface DaemonStatus { @@ -99,7 +100,7 @@ function createStatusSchema(status: DaemonStatus): OutputSchema { return "red"; } if (item.key.startsWith(" ")) { - if (item.value === "not found") return "red"; + if (item.value === "not found" || item.value === "not found (daemon)") return "red"; if (item.value.endsWith("(--version failed)")) return "yellow"; return "green"; } @@ -149,7 +150,13 @@ function toStatusRows(status: DaemonStatus): StatusRow[] { rows.push({ key: "", value: "" }); rows.push({ key: "Providers", value: "" }); for (const provider of status.providers) { - if (!provider.path) { + if (provider.source === "daemon") { + if (!provider.path) { + rows.push({ key: ` ${provider.label}`, value: "not found (daemon)" }); + } else { + rows.push({ key: ` ${provider.label}`, value: `${provider.path} (daemon)` }); + } + } else if (!provider.path) { rows.push({ key: ` ${provider.label}`, value: "not found" }); } else if (!provider.version) { rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` }); @@ -210,6 +217,7 @@ interface DaemonProbeResult { runningAgents?: number; idleAgents?: number; daemonNodeOverride?: string; + daemonProviders?: ProviderBinaryStatus[]; note?: string; } @@ -231,12 +239,32 @@ async function probeDaemonOverWebsocket(args: { } const daemonVersion = client.getLastServerInfoMessage()?.version ?? null; + const supportsDaemonStatusRpc = + client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true; try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); const agents = agentsPayload.entries.map((entry) => entry.agent); const runningAgents = agents.filter((a) => a.status === "running").length; const idleAgents = agents.filter((a) => a.status === "idle").length; + let daemonProviders: ProviderBinaryStatus[] | undefined; + if (supportsDaemonStatusRpc) { + try { + const statusPayload = await client.getDaemonStatus(); + const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label])); + daemonProviders = statusPayload.providers.map((p) => ({ + label: labelMap.get(p.provider) ?? p.provider, + path: p.available ? "available" : null, + version: p.available ? null : (p.error ?? null), + source: "daemon" as const, + })); + } catch { + // COMPAT(daemon-rpc-rollout): fall back to CLI-side provider resolution while + // old daemons lack daemonStatusRpc. Remove once the daemon floor is past + // v0.1.76; status should come from daemon.get_status. + } + } + if (!state.running) { return { connectedDaemon: "reachable", @@ -244,6 +272,7 @@ async function probeDaemonOverWebsocket(args: { runningAgents, idleAgents, daemonNodeOverride: "unknown (API reachable, PID unresolved)", + daemonProviders, note: state.pidInfo ? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale` : `Connected daemon is reachable at ${host} but no local daemon PID file was found`, @@ -255,6 +284,7 @@ async function probeDaemonOverWebsocket(args: { daemonVersion, runningAgents, idleAgents, + daemonProviders, }; } catch { return { @@ -278,6 +308,7 @@ interface ProbeMergeState { daemonVersion: string | null; runningAgents: number | null; idleAgents: number | null; + daemonProviders: ProviderBinaryStatus[] | undefined; note: string | undefined; } @@ -290,6 +321,7 @@ function applyProbeToStatus(input: ProbeMergeState): Omit { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "daemon.get_status.request", + }, + responseType: "daemon.get_status.response", + timeout: 10000, + }); + } + + async getDaemonPairingOffer(requestId?: string): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "daemon.get_pairing_offer.request", + }, + responseType: "daemon.get_pairing_offer.response", + timeout: 10000, + }); + } + async patchDaemonConfig( config: MutableDaemonConfigPatch, requestId?: string, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index a4554bb5e..4f509e40a 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -936,6 +936,16 @@ export async function createPaseoDaemon( workspaceGitService, github, config.pushNotificationSender, + { + listen: formatListenTarget(boundListenTarget ?? listenTarget), + relay: { + enabled: relayEnabled, + endpoint: relayEndpoint, + publicEndpoint: relayPublicEndpoint, + useTls: relayUseTls, + publicUseTls: relayPublicUseTls, + }, + }, ); if (relayEnabled) { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2e6a383d6..04ee2264b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -47,6 +47,8 @@ import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js" import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js"; import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js"; import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js"; +import { getPidLockInfo } from "./pid-lock.js"; +import { generateLocalPairingOffer } from "./pairing-offer.js"; import { DictationStreamManager, type DictationStreamOutboundMessage, @@ -595,6 +597,18 @@ export interface SessionOptions { agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap; providerOverrides?: Record; isDev?: boolean; + serverId?: string; + daemonVersion?: string; + daemonRuntimeConfig?: { + listen: string | null; + relay: { + enabled: boolean; + endpoint: string; + publicEndpoint: string; + useTls: boolean; + publicUseTls: boolean; + } | null; + }; } export type SessionLifecycleIntent = @@ -805,6 +819,9 @@ export class Session { private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; private readonly providerOverrides: Record | undefined; private readonly isDev: boolean; + private readonly serverId: string | undefined; + private readonly daemonVersion: string | undefined; + private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"]; private voiceModeAgentId: string | null = null; private voiceModeBaseConfig: VoiceModeBaseConfig | null = null; @@ -850,6 +867,9 @@ export class Session { agentProviderRuntimeSettings, providerOverrides, isDev, + serverId, + daemonVersion, + daemonRuntimeConfig, } = options; this.clientId = clientId; this.appVersion = appVersion ?? null; @@ -901,6 +921,9 @@ export class Session { this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.providerOverrides = providerOverrides; this.isDev = isDev === true; + this.serverId = serverId; + this.daemonVersion = daemonVersion; + this.daemonRuntimeConfig = daemonRuntimeConfig; this.abortController = new AbortController(); this.workspaceDirectory = new WorkspaceDirectory({ logger: this.sessionLogger, @@ -1864,6 +1887,10 @@ export class Session { payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() }, }); return undefined; + case "daemon.get_status.request": + return this.handleDaemonGetStatusRequest(msg); + case "daemon.get_pairing_offer.request": + return this.handleDaemonGetPairingOfferRequest(msg); case "set_daemon_config_request": this.emit({ type: "set_daemon_config_response", @@ -3813,6 +3840,86 @@ export class Session { } } + private async handleDaemonGetStatusRequest( + msg: Extract, + ): Promise { + try { + const pidInfo = await getPidLockInfo(this.paseoHome); + const providers = (await this.agentManager.listProviderAvailability()).map((p) => ({ + provider: p.provider, + available: p.available, + error: p.error ?? null, + })); + this.emit({ + type: "daemon.get_status.response", + payload: { + requestId: msg.requestId, + serverId: this.serverId ?? "", + version: this.daemonVersion ?? null, + pid: process.pid, + nodePath: process.execPath, + startedAt: pidInfo?.startedAt ?? null, + listen: this.daemonRuntimeConfig?.listen ?? null, + relay: this.daemonRuntimeConfig?.relay ?? null, + providers, + }, + }); + } catch (error) { + this.sessionLogger.error({ err: error }, "Failed to handle daemon status request"); + this.emit({ + type: "daemon.get_status.response", + payload: { + requestId: msg.requestId, + serverId: this.serverId ?? "", + version: this.daemonVersion ?? null, + pid: process.pid, + nodePath: process.execPath, + startedAt: null, + listen: null, + relay: null, + providers: [], + }, + }); + } + } + + private async handleDaemonGetPairingOfferRequest( + msg: Extract, + ): Promise { + try { + const relay = this.daemonRuntimeConfig?.relay; + const pairing = await generateLocalPairingOffer({ + paseoHome: this.paseoHome, + relayEnabled: relay?.enabled ?? true, + relayEndpoint: relay?.endpoint, + relayPublicEndpoint: relay?.publicEndpoint, + relayUseTls: relay?.useTls, + relayPublicUseTls: relay?.publicUseTls, + includeQr: true, + logger: this.sessionLogger, + }); + this.emit({ + type: "daemon.get_pairing_offer.response", + payload: { + requestId: msg.requestId, + url: pairing.url ?? "", + qr: pairing.qr ?? null, + relayEnabled: pairing.relayEnabled, + }, + }); + } catch (error) { + this.sessionLogger.error({ err: error }, "Failed to handle daemon pairing offer request"); + this.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: "daemon.get_pairing_offer.request", + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + private async handleListAvailableProvidersRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index f81438e9f..93088f5e6 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -332,6 +332,18 @@ export class VoiceAssistantWebSocketServer { private readonly externalSessionsByKey: Map = new Map(); private readonly serverId: string; private readonly daemonVersion: string; + private readonly daemonRuntimeConfig: + | { + listen: string | null; + relay: { + enabled: boolean; + endpoint: string; + publicEndpoint: string; + useTls: boolean; + publicUseTls: boolean; + }; + } + | undefined; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; @@ -416,6 +428,16 @@ export class VoiceAssistantWebSocketServer { workspaceGitService?: WorkspaceGitService, github?: GitHubService, pushNotificationSender?: PushNotificationSender, + daemonRuntimeConfig?: { + listen: string | null; + relay: { + enabled: boolean; + endpoint: string; + publicEndpoint: string; + useTls: boolean; + publicUseTls: boolean; + }; + }, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -423,6 +445,7 @@ export class VoiceAssistantWebSocketServer { throw new MissingDaemonVersionError(); } this.daemonVersion = daemonVersion.trim(); + this.daemonRuntimeConfig = daemonRuntimeConfig; this.agentManager = agentManager; this.agentStorage = agentStorage; this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); @@ -921,6 +944,9 @@ export class VoiceAssistantWebSocketServer { agentProviderRuntimeSettings: this.agentProviderRuntimeSettings, providerOverrides: this.providerOverrides, isDev: this.isDev, + serverId: this.serverId, + daemonVersion: this.daemonVersion, + daemonRuntimeConfig: this.daemonRuntimeConfig, }); connection = { @@ -1053,6 +1079,8 @@ export class VoiceAssistantWebSocketServer { providersSnapshot: true, // COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13. checkoutGithubSetAutoMerge: true, + // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. + daemonStatusRpc: true, }, }; } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index fc4eeafaf..8c18fe2ef 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -979,6 +979,16 @@ export const WaitForFinishRequestSchema = z.object({ timeoutMs: z.number().int().positive().optional(), }); +export const DaemonGetStatusRequestSchema = z.object({ + type: z.literal("daemon.get_status.request"), + requestId: z.string(), +}); + +export const DaemonGetPairingOfferRequestSchema = z.object({ + type: z.literal("daemon.get_pairing_offer.request"), + requestId: z.string(), +}); + export const GetDaemonConfigRequestMessageSchema = z.object({ type: z.literal("get_daemon_config_request"), requestId: z.string(), @@ -1764,6 +1774,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetVoiceModeMessageSchema, SendAgentMessageRequestSchema, WaitForFinishRequestSchema, + DaemonGetStatusRequestSchema, + DaemonGetPairingOfferRequestSchema, GetDaemonConfigRequestMessageSchema, SetDaemonConfigRequestMessageSchema, ReadProjectConfigRequestMessageSchema, @@ -2026,6 +2038,8 @@ export const ServerInfoStatusPayloadSchema = z .object({ providersSnapshot: z.boolean().optional(), checkoutGithubSetAutoMerge: z.boolean().optional(), + // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. + daemonStatusRpc: z.boolean().optional(), }) .optional(), }) @@ -2586,6 +2600,50 @@ export const GetDaemonConfigResponseMessageSchema = z.object({ .passthrough(), }); +export const DaemonGetStatusResponseSchema = z.object({ + type: z.literal("daemon.get_status.response"), + payload: z + .object({ + requestId: z.string(), + serverId: z.string(), + version: z.string().nullable().optional(), + pid: z.number(), + nodePath: z.string(), + startedAt: z.string().nullable().optional(), + listen: z.string().nullable(), + relay: z + .object({ + enabled: z.boolean(), + endpoint: z.string(), + publicEndpoint: z.string(), + useTls: z.boolean(), + publicUseTls: z.boolean(), + }) + .nullable() + .optional(), + providers: z.array( + z.object({ + provider: z.string(), + available: z.boolean(), + error: z.string().nullable().optional(), + }), + ), + }) + .passthrough(), +}); + +export const DaemonGetPairingOfferResponseSchema = z.object({ + type: z.literal("daemon.get_pairing_offer.response"), + payload: z + .object({ + requestId: z.string(), + url: z.string(), + qr: z.string().nullable().optional(), + relayEnabled: z.boolean(), + }) + .passthrough(), +}); + export const SetDaemonConfigResponseMessageSchema = z.object({ type: z.literal("set_daemon_config_response"), payload: z @@ -3481,6 +3539,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ClearAgentAttentionResponseMessageSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, + DaemonGetStatusResponseSchema, + DaemonGetPairingOfferResponseSchema, GetDaemonConfigResponseMessageSchema, SetDaemonConfigResponseMessageSchema, ReadProjectConfigResponseMessageSchema, @@ -3643,6 +3703,8 @@ export type ListProviderFeaturesResponseMessage = z.infer< typeof ListProviderFeaturesResponseMessageSchema >; export type ListAvailableProvidersResponse = z.infer; +export type DaemonGetStatusResponse = z.infer; +export type DaemonGetPairingOfferResponse = z.infer; export type GetProvidersSnapshotResponseMessage = z.infer< typeof GetProvidersSnapshotResponseMessageSchema >; From 9ef7230417427f8872929f90c66eb118f723da30 Mon Sep 17 00:00:00 2001 From: Mingyang Sun Date: Tue, 19 May 2026 11:23:57 +0800 Subject: [PATCH 06/14] Add Kiro CLI to ACP provider catalog Adds Kiro CLI as an opt-in ACP provider catalog entry, plus a generic ACP extension-notification sink so unknown notifications no longer fail JSON-RPC dispatch. --- packages/app/src/components/provider-icons.ts | 3 ++- packages/app/src/data/acp-provider-catalog.ts | 9 +++++++++ .../hooks/use-acp-provider-catalog.test.ts | 3 ++- .../server/agent/providers/acp-agent.test.ts | 20 +++++++++++++++++++ .../src/server/agent/providers/acp-agent.ts | 13 ++++++++++++ 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 6c8a689c5..1a85cf97f 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -1,4 +1,4 @@ -import { Bot } from "lucide-react-native"; +import { Bot, PackagePlus } from "lucide-react-native"; import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; import { CopilotIcon } from "@/components/icons/copilot-icon"; @@ -9,6 +9,7 @@ const PROVIDER_ICONS: Record = { claude: ClaudeIcon as unknown as typeof Bot, codex: CodexIcon as unknown as typeof Bot, copilot: CopilotIcon as unknown as typeof Bot, + kiro: PackagePlus, opencode: OpenCodeIcon as unknown as typeof Bot, pi: PiIcon as unknown as typeof Bot, }; diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index 74c819598..85895109c 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -240,6 +240,15 @@ const CATALOG_DATA = [ installLink: "https://kilo.ai/docs/code-with-ai/platforms/cli", command: ["kilo", "acp"], }, + { + id: "kiro", + title: "Kiro CLI", + description: "Amazon's AI coding agent with native ACP support", + version: "manual", + iconId: null, + installLink: "https://kiro.dev/docs/cli/acp/", + command: ["kiro-cli", "acp"], + }, { id: "kimi", title: "Kimi CLI", diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts index d96fcc5bf..13ff91a1a 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -26,7 +26,7 @@ describe("ACP provider catalog", () => { }); it("bundles SVG icons for catalog entries that declare an icon", () => { - const entriesWithIcons = ACP_PROVIDER_CATALOG.filter((entry) => entry.id !== "hermes"); + const entriesWithIcons = ACP_PROVIDER_CATALOG.filter((entry) => entry.iconSvg !== null); expect(entriesWithIcons.length).toBeGreaterThan(0); for (const entry of entriesWithIcons) { @@ -39,6 +39,7 @@ describe("ACP provider catalog", () => { expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]); expect(findProvider("goose").command).toEqual(["goose", "acp"]); expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]); + expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]); expect(findProvider("poolside").command).toEqual(["pool", "acp"]); }); diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 0cbc2a4df..1f3e9fe30 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1438,6 +1438,26 @@ describe("ACPAgentSession slash commands", () => { }); describe("ACPAgentSession", () => { + test("accepts ACP extension notifications without failing the JSON-RPC connection", async () => { + const logger = createTestLogger(); + const trace = vi.spyOn(logger, "trace"); + const session = createSessionWithConfig({ provider: "kiro" }, logger); + + await expect( + session.extNotification("_kiro.dev/session/initialized", { + sessionId: "session-1", + }), + ).resolves.toBeUndefined(); + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "kiro", + method: "_kiro.dev/session/initialized", + sessionId: "session-1", + }), + "provider.acp.extension_notification", + ); + }); + test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => { const session = createSession(); const events: Array<{ type: string; item?: { type: string; text?: string } }> = []; diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index a9f7924c5..dbf2e3d02 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1667,6 +1667,19 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } + async extNotification(method: string, params: Record): Promise { + this.logger.trace( + { + agentId: this.agentId, + provider: this.provider, + sessionId: typeof params.sessionId === "string" ? params.sessionId : undefined, + method, + rawEvent: params, + }, + "provider.acp.extension_notification", + ); + } + async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> { const raw = await fs.readFile(params.path, "utf8"); if (!params.line && !params.limit) { From bc32b16b02f4425cd14c1211a707ceb7c5df476a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Sousa=20Andrade?= <659718+joaosa@users.noreply.github.com> Date: Tue, 19 May 2026 05:38:55 +0100 Subject: [PATCH 07/14] Reject relay re-handshake key changes (#1037) Closes #366 Closes #368 --- packages/relay/src/encrypted-channel.test.ts | 77 +++++++++++++++++++- packages/relay/src/encrypted-channel.ts | 25 ++++--- 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/packages/relay/src/encrypted-channel.test.ts b/packages/relay/src/encrypted-channel.test.ts index 8cd863570..ea8970115 100644 --- a/packages/relay/src/encrypted-channel.test.ts +++ b/packages/relay/src/encrypted-channel.test.ts @@ -35,6 +35,10 @@ function createMockTransportPair(): [Transport, Transport] { return [transportA, transportB]; } +async function waitForAsyncDelivery(): Promise { + await new Promise((resolve) => setTimeout(resolve, 50)); +} + describe("EncryptedChannel", () => { it("establishes encrypted channel between daemon and client", async () => { const [daemonTransport, clientTransport] = createMockTransportPair(); @@ -96,7 +100,7 @@ describe("EncryptedChannel", () => { await clientChannel.send("Second message from client"); // Wait for async delivery - await new Promise((r) => setTimeout(r, 50)); + await waitForAsyncDelivery(); expect(daemonMessages).toEqual(["Hello from client", "Second message from client"]); expect(clientMessages).toEqual(["Hello from daemon"]); @@ -194,4 +198,75 @@ describe("EncryptedChannel", () => { await expect(daemonChannelPromise).rejects.toThrow("Invalid hello message"); }); + + it("accepts duplicate hello from the same client without re-keying", async () => { + const [daemonTransport, clientTransport] = createMockTransportPair(); + + const daemonKeyPair = generateKeyPair(); + const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey); + const daemonMessages: (string | ArrayBuffer)[] = []; + + let clientOpenedResolve: (() => void) | null = null; + const clientOpened = new Promise((resolve) => { + clientOpenedResolve = resolve; + }); + + const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, { + onmessage: (data) => daemonMessages.push(data), + }); + + const clientChannel = await createClientChannel(clientTransport, daemonPubKeyB64, { + onopen: () => clientOpenedResolve?.(), + }); + + await daemonChannelPromise; + await clientOpened; + + const firstHello = (clientTransport.send as ReturnType).mock.calls.find( + ([data]) => typeof data === "string" && data.includes('"type":"e2ee_hello"'), + )?.[0]; + expect(typeof firstHello).toBe("string"); + + daemonTransport.onmessage?.(firstHello as string); + await waitForAsyncDelivery(); + + expect(daemonTransport.close).not.toHaveBeenCalled(); + + await clientChannel.send("still encrypted with original key"); + await waitForAsyncDelivery(); + + expect(daemonMessages).toEqual(["still encrypted with original key"]); + }); + + it("closes an open daemon channel when a different client key sends hello", async () => { + const [daemonTransport, clientTransport] = createMockTransportPair(); + + const daemonKeyPair = generateKeyPair(); + const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey); + + let clientOpenedResolve: (() => void) | null = null; + const clientOpened = new Promise((resolve) => { + clientOpenedResolve = resolve; + }); + + const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair); + + await createClientChannel(clientTransport, daemonPubKeyB64, { + onopen: () => clientOpenedResolve?.(), + }); + + await daemonChannelPromise; + await clientOpened; + + const attackerKeyPair = generateKeyPair(); + const attackerHello = JSON.stringify({ + type: "e2ee_hello", + key: exportPublicKey(attackerKeyPair.publicKey), + }); + + daemonTransport.onmessage?.(attackerHello); + await waitForAsyncDelivery(); + + expect(daemonTransport.close).toHaveBeenCalledWith(1008, "E2EE re-handshake key mismatch"); + }); }); diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts index 2d2c59f1b..c9baa07e2 100644 --- a/packages/relay/src/encrypted-channel.ts +++ b/packages/relay/src/encrypted-channel.ts @@ -92,6 +92,8 @@ function buildInvalidHelloError(rawText: string, parsed?: unknown): Error { const HANDSHAKE_RETRY_MS = 1000; const MAX_PENDING_SENDS = 200; +const REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE = 1008; +const REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON = "E2EE re-handshake key mismatch"; interface TimeoutWithUnref { unref(): void; @@ -420,16 +422,14 @@ export class EncryptedChannel { return; } - // Different key implies a new client connection (common with relays - // where the daemon's socket stays open while the client reconnects). - // Re-key and re-send "ready". Drop any queued sends to avoid leaking - // messages between logical client sessions. - this.state = "handshaking"; - this.sharedKey = nextSharedKey; - this.pendingSends = []; - this.transport.send(JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage)); - this.state = "open"; - await this.flushPendingSends(); + // A different key on an already-open encrypted channel is not an + // authenticated reconnect. Close and require a fresh transport instead of + // allowing the relay to switch this channel to an attacker-chosen key. + this.state = "closed"; + this.transport.close( + REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE, + REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON, + ); } close(code = 1000, reason = "Normal closure"): void { @@ -452,8 +452,9 @@ export class EncryptedChannel { function keysEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.byteLength !== b.byteLength) return false; + let difference = 0; for (let i = 0; i < a.byteLength; i += 1) { - if (a[i] !== b[i]) return false; + difference |= a[i] ^ b[i]; } - return true; + return difference === 0; } From e38d0e0fa973de5e75f1142846e55c1b02c0df48 Mon Sep 17 00:00:00 2001 From: Bolun Zhang Date: Tue, 19 May 2026 12:56:03 +0800 Subject: [PATCH 08/14] feat(mcp): consolidate provider settings tools (#1011) Closes #984 --- .../src/server/agent/mcp-parity.e2e.test.ts | 28 +-- .../src/server/agent/mcp-server.test.ts | 164 ++++++++++--- .../server/src/server/agent/mcp-server.ts | 218 ++++++++++++------ public-docs/mcp.md | 37 ++- skills/paseo/SKILL.md | 16 +- 5 files changed, 319 insertions(+), 144 deletions(-) diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 7370e6082..9ee49887e 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -154,7 +154,7 @@ async function createTopLevelAgent(args?: Partial): Promise { title: "MCP parity parent", provider: "claude/claude-test-model", initialPrompt: "say done and stop", - mode: "bypassPermissions", + settings: { modeId: "bypassPermissions" }, background: true, }); parentAgentId = str(parentPayload.agentId); @@ -339,7 +339,7 @@ describe("Suite A: Core Fixes", () => { test("create_agent accepts provider features over MCP", async () => { let agentId: string | null = null; try { - agentId = await createTopLevelAgent({ features: { test_feature: true } }); + agentId = await createTopLevelAgent({ settings: { features: { test_feature: true } } }); const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId); expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true }); @@ -356,7 +356,7 @@ describe("Suite A: Core Fixes", () => { try { agentId = await createChildAgent({ provider: "claude/claude-test-model", - features: { test_feature: true }, + settings: { features: { test_feature: true } }, }); const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId); expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true }); @@ -369,14 +369,13 @@ describe("Suite A: Core Fixes", () => { } }); - test("set_agent_feature updates provider features over MCP", async () => { + test("update_agent updates provider features over MCP", async () => { let agentId: string | null = null; try { - agentId = await createTopLevelAgent({ features: { test_feature: false } }); - const updated = await callToolStructured(topLevelClient, "set_agent_feature", { + agentId = await createTopLevelAgent({ settings: { features: { test_feature: false } } }); + const updated = await callToolStructured(topLevelClient, "update_agent", { agentId, - featureId: "test_feature", - value: true, + settings: { features: { test_feature: true } }, }); expect(updated.success).toBe(true); const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId); @@ -390,15 +389,18 @@ describe("Suite A: Core Fixes", () => { } }); - test("list_provider_features returns draft provider features over MCP", async () => { - const payload = await callToolStructured(topLevelClient, "list_provider_features", { + test("inspect_provider returns draft provider features over MCP", async () => { + const payload = await callToolStructured(topLevelClient, "inspect_provider", { provider: "claude", cwd: parentAgentCwd, - model: "claude-test-model", - featureValues: { test_feature: true }, + settings: { + model: "claude-test-model", + features: { test_feature: true }, + }, }); expect(payload.provider).toBe("claude"); + expect(payload.selectedModel).toBe("claude-test-model"); expect(recordArr(payload.features)).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index ab27332e5..c1f59cc8f 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -117,7 +117,9 @@ function buildAgentManagerSpies() { createAgent: vi.fn(), waitForAgentEvent: vi.fn(), recordUserMessage: vi.fn(), - setAgentMode: vi.fn(), + setAgentMode: vi.fn().mockResolvedValue(undefined), + setAgentModel: vi.fn().mockResolvedValue(undefined), + setAgentThinkingOption: vi.fn().mockResolvedValue(undefined), setAgentFeature: vi.fn().mockResolvedValue(undefined), setLabels: vi.fn().mockResolvedValue(undefined), setTitle: vi.fn().mockResolvedValue(undefined), @@ -446,7 +448,7 @@ describe("create_agent MCP tool", () => { const missingTitle = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, provider: "codex/gpt-5.4", initialPrompt: "test", }); @@ -455,7 +457,7 @@ describe("create_agent MCP tool", () => { const tooLong = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, provider: "codex/gpt-5.4", title: "x".repeat(61), initialPrompt: "test", @@ -465,7 +467,7 @@ describe("create_agent MCP tool", () => { const ok = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, provider: "codex/gpt-5.4", title: "Short title", initialPrompt: "test", @@ -479,7 +481,7 @@ describe("create_agent MCP tool", () => { const tool = registeredTool(server, "create_agent"); const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, provider: "codex/gpt-5.4", title: "Short title", }); @@ -510,7 +512,7 @@ describe("create_agent MCP tool", () => { provider: "codex/gpt-5.4", initialPrompt: "Do work", background: true, - features: { fast_mode: true }, + settings: { features: { fast_mode: true } }, }; const parsed = await tool.inputSchema.safeParseAsync(input); @@ -536,7 +538,7 @@ describe("create_agent MCP tool", () => { const missingProvider = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, title: "Short title", initialPrompt: "test", }); @@ -549,7 +551,7 @@ describe("create_agent MCP tool", () => { const providerWithoutModel = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, title: "Short title", provider: "codex", initialPrompt: "test", @@ -558,7 +560,7 @@ describe("create_agent MCP tool", () => { const providerWithEmptyModel = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, title: "Short title", provider: "codex/", initialPrompt: "test", @@ -567,7 +569,7 @@ describe("create_agent MCP tool", () => { const providerWithEmptyProvider = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, title: "Short title", provider: "/gpt-5.4", initialPrompt: "test", @@ -577,7 +579,7 @@ describe("create_agent MCP tool", () => { await expect( tool.handler({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, title: "Short title", provider: "codex/gpt-5.4", model: "gpt-5.4", @@ -722,10 +724,9 @@ describe("create_agent MCP tool", () => { await tool.handler({ cwd: existingCwd, title: "Config test", - mode: "auto", initialPrompt: "Do work", provider: "codex/gpt-5.4", - thinking: "think-hard", + settings: { modeId: "auto", thinkingOptionId: "think-hard" }, labels: { source: "mcp" }, }); @@ -1269,7 +1270,7 @@ describe("create_agent MCP tool", () => { const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, title: "Custom provider agent", - mode: "default", + settings: { modeId: "default" }, provider: "zai/custom-model", initialPrompt: "Do work", }); @@ -1360,7 +1361,7 @@ describe("create_agent MCP tool", () => { provider: "codex/gpt-5.4", initialPrompt: "Do work", background: true, - features: { fast_mode: true }, + settings: { features: { fast_mode: true } }, }; const parsed = await tool.inputSchema.safeParseAsync(input); @@ -1403,7 +1404,7 @@ describe("create_agent MCP tool", () => { await tool.handler({ cwd: existingCwd, title: "Injected config test", - mode: "auto", + settings: { modeId: "auto" }, provider: "codex/gpt-5.4", initialPrompt: "Do work", }); @@ -1428,7 +1429,7 @@ describe("create_agent MCP tool", () => { cwd: existingCwd, title: "Bad mode", provider: "opencode/gpt-5.4", - mode: "bypassPermissions", + settings: { modeId: "bypassPermissions" }, initialPrompt: "Do work", }), ).rejects.toThrow( @@ -1567,7 +1568,7 @@ describe("create_agent MCP tool", () => { await tool.handler({ title: "Child", provider: "opencode/gpt-5.4", - mode: "build", + settings: { modeId: "build" }, initialPrompt: "Do work", }); @@ -1579,17 +1580,31 @@ describe("create_agent MCP tool", () => { }); }); -describe("set_agent_feature MCP tool", () => { +describe("update_agent MCP tool", () => { const logger = createTestLogger(); - it("sets a provider feature on an existing agent", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); + it("does not register the replaced feature-specific MCP tool", async () => { + const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); - const tool = registeredTool(server, "set_agent_feature"); + + expect(lookupTool(server, "set_agent_feature")).toBeUndefined(); + }); + + it("updates runtime settings before metadata", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentStorage.get.mockResolvedValue(createStoredRecord({ id: "agent-1" })); + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const tool = registeredTool(server, "update_agent"); const input = { agentId: "agent-1", - featureId: "fast_mode", - value: true, + name: "Updated agent", + labels: { role: "worker" }, + settings: { + modeId: "full-access", + model: "gpt-5.4", + thinkingOptionId: "high", + features: { fast_mode: true }, + }, }; const parsed = await tool.inputSchema.safeParseAsync(input); @@ -1597,9 +1612,39 @@ describe("set_agent_feature MCP tool", () => { const response = await tool.handler(input); + expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access"); + expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4"); + expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high"); expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true); + expect(spies.agentStorage.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: "agent-1", + title: "Updated agent", + }), + ); + expect(spies.agentManager.setLabels).toHaveBeenCalledWith("agent-1", { role: "worker" }); expect(response.structuredContent).toEqual({ success: true }); }); + + it("does not update metadata when runtime settings fail", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature")); + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const tool = registeredTool(server, "update_agent"); + + await expect( + tool.handler({ + agentId: "agent-1", + name: "Should not persist", + labels: { role: "worker" }, + settings: { features: { fast_mode: true } }, + }), + ).rejects.toThrow("unsupported feature"); + + expect(spies.agentStorage.get).not.toHaveBeenCalled(); + expect(spies.agentStorage.upsert).not.toHaveBeenCalled(); + expect(spies.agentManager.setLabels).not.toHaveBeenCalled(); + }); }); describe("create_schedule MCP tool", () => { @@ -2033,10 +2078,17 @@ describe("provider listing MCP tool", () => { }); }); -describe("model listing MCP tool", () => { +describe("provider MCP tools", () => { const logger = createTestLogger(); - it("lists provider features for a draft agent configuration", async () => { + it("does not register the replaced feature-specific provider discovery MCP tool", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + + expect(lookupTool(server, "list_provider_features")).toBeUndefined(); + }); + + it("inspects provider features for a draft agent configuration", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.listDraftFeatures.mockResolvedValue([ { @@ -2046,19 +2098,29 @@ describe("model listing MCP tool", () => { value: false, }, ]); + const providerRegistry = { + codex: createProviderDefinition({ + id: "codex", + label: "Codex", + description: "OpenAI coding agent", + modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }], + }), + }; const server = await createAgentMcpServer({ agentManager, agentStorage, + providerRegistry, logger, }); - const tool = registeredTool(server, "list_provider_features"); + const tool = registeredTool(server, "inspect_provider"); const input = { - provider: "codex", + provider: "codex/gpt-5.4", cwd: "~/repo", - modeId: "full-access", - model: "gpt-5.4", - thinkingOptionId: "high", - featureValues: { fast_mode: true }, + settings: { + modeId: "full-access", + thinkingOptionId: "high", + features: { fast_mode: true }, + }, }; const parsed = await tool.inputSchema.safeParseAsync(input); @@ -2076,6 +2138,12 @@ describe("model listing MCP tool", () => { }); expect(response.structuredContent).toEqual({ provider: "codex", + label: "Codex", + description: "OpenAI coding agent", + enabled: true, + status: "available", + modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }], + selectedModel: "gpt-5.4", features: [ { type: "toggle", @@ -2118,6 +2186,38 @@ describe("model listing MCP tool", () => { ); expect(fetchModels).not.toHaveBeenCalled(); }); + + it("inspect_provider rejects disabled providers without fetching models", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const fetchModels = vi.fn().mockResolvedValue([ + { + provider: "codex", + id: "gpt-5.4", + label: "GPT-5.4", + }, + ]); + const providerRegistry = { + codex: createProviderDefinition({ + id: "codex", + label: "Codex", + enabled: false, + fetchModels, + }), + }; + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerRegistry, + logger, + }); + const tool = registeredTool(server, "inspect_provider"); + + await expect(tool.handler({ provider: "codex", cwd: "~/repo" })).rejects.toThrow( + "Provider 'codex' is disabled", + ); + expect(fetchModels).not.toHaveBeenCalled(); + }); }); describe("speak MCP tool", () => { diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 89387363d..577e9f495 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -62,6 +62,7 @@ import { AgentModelSchema, AgentProviderEnum, AgentStatusEnum, + ProviderModeSchema, ProviderSummarySchema, parseDurationString, resolveRequiredProviderModel, @@ -587,6 +588,55 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, { message: "provider must be provider/model, for example codex/gpt-5.4" }, ); + const ProviderOrProviderModelInputSchema = AgentProviderEnum.trim() + .min(1, "provider is required") + .refine( + (value) => { + if (!value.includes("/")) { + return true; + } + try { + resolveRequiredProviderModel(value); + return true; + } catch { + return false; + } + }, + { message: "provider must be provider or provider/model, for example codex/gpt-5.4" }, + ); + const CreateAgentSettingsInputSchema = z + .object({ + modeId: z.string().optional().describe("Session mode to configure before the first run."), + thinkingOptionId: z.string().optional().describe("Thinking option ID."), + features: z + .record(z.unknown()) + .optional() + .describe("Provider-specific feature values, for example { fast_mode: true } for Codex."), + }) + .strict(); + const UpdateAgentSettingsInputSchema = z + .object({ + modeId: z.string().optional().describe("Session mode ID."), + model: z.string().nullable().optional().describe("Model ID. Pass null to clear."), + thinkingOptionId: z + .string() + .nullable() + .optional() + .describe("Thinking option ID. Pass null to clear."), + features: z + .record(z.unknown()) + .optional() + .describe("Provider-specific feature values, for example { fast_mode: true } for Codex."), + }) + .strict(); + const InspectProviderSettingsInputSchema = z + .object({ + modeId: z.string().optional().describe("Draft session mode ID."), + model: z.string().optional().describe("Draft model ID."), + thinkingOptionId: z.string().optional().describe("Draft thinking option ID."), + features: z.record(z.unknown()).optional().describe("Draft provider feature values."), + }) + .strict(); const agentToAgentInputSchema = { cwd: z .string() @@ -601,23 +651,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom provider: ProviderModelInputSchema.describe( "Required provider/model pair, for example codex/gpt-5.4.", ), - thinking: z.string().optional().describe("Thinking option ID"), - features: z - .record(z.unknown()) - .optional() - .describe("Provider-specific feature values, for example { fast_mode: true } for Codex."), labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), + settings: CreateAgentSettingsInputSchema.optional().describe( + "Initial runtime settings for the new agent.", + ), initialPrompt: z .string() .trim() .min(1, "initialPrompt is required") .describe("Required first task to run immediately after creation."), - mode: z - .string() - .optional() - .describe( - "Optional session mode for the new agent. Required when the new agent uses a different provider than the caller agent.", - ), background: z .boolean() .optional() @@ -647,21 +689,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom provider: ProviderModelInputSchema.describe( "Required provider/model pair, for example codex/gpt-5.4.", ), - thinking: z.string().optional().describe("Thinking option ID"), - features: z - .record(z.unknown()) - .optional() - .describe("Provider-specific feature values, for example { fast_mode: true } for Codex."), labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), + settings: CreateAgentSettingsInputSchema.optional().describe( + "Initial runtime settings for the new agent.", + ), initialPrompt: z .string() .trim() .min(1, "initialPrompt is required") .describe("Required first task to run immediately after creation."), - mode: z - .string() - .optional() - .describe("Optional session mode to configure before the first run."), worktreeName: z .string() .optional() @@ -700,13 +736,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema; const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict(); const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict(); - const listProviderFeaturesInputSchema = { - provider: AgentProviderEnum, - cwd: z.string().describe("Working directory used to resolve provider feature availability."), - modeId: z.string().optional(), - model: z.string().optional(), - thinkingOptionId: z.string().optional(), - featureValues: z.record(z.unknown()).optional(), + const inspectProviderInputSchema = { + provider: ProviderOrProviderModelInputSchema.describe( + "Provider ID, optionally with a model ID (for example codex or codex/gpt-5.4).", + ), + cwd: z + .string() + .optional() + .describe("Working directory used to resolve provider feature availability."), + settings: InspectProviderSettingsInputSchema.optional().describe( + "Draft provider settings used to compute available features.", + ), }; if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) { @@ -758,7 +798,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom background: boolean; normalizedTitle: string | null; model: string | undefined; - thinking: string | undefined; + thinkingOptionId: string | undefined; features: Record | undefined; labels: Record | undefined; notifyOnFinish: boolean; @@ -805,6 +845,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom throw new Error(`Parent agent ${parentAgentId} not found`); } const provider = resolvedProviderModel.provider; + const settings = callerArgs.settings; const resolvedCwd = resolveChildAgentCwd({ parentCwd: parentAgent.cwd, requestedCwd: callerArgs.cwd, @@ -812,7 +853,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom allowCustomCwd: callerContext?.allowCustomCwd ?? true, }); const resolvedMode = resolveAndValidateCreateAgentMode({ - requestedMode: callerArgs.mode, + requestedMode: settings?.modeId, targetProvider: provider, parent: { provider: parentAgent.provider, @@ -828,8 +869,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom background: callerArgs.background ?? false, normalizedTitle: callerArgs.title.trim(), model: resolvedProviderModel.model, - thinking: callerArgs.thinking, - features: callerArgs.features, + thinkingOptionId: settings?.thinkingOptionId, + features: settings?.features, labels: callerArgs.labels, notifyOnFinish: callerArgs.notifyOnFinish ?? false, resolvedCwd, @@ -843,9 +884,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom ): Promise => { const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args); const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider); - const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs; + const { cwd, settings, worktreeName, baseBranch, refName, action, githubPrNumber } = + topLevelArgs; const resolvedMode = resolveAndValidateCreateAgentMode({ - requestedMode: mode, + requestedMode: settings?.modeId, targetProvider: resolvedProviderModel.provider, parent: null, availableModes: getAvailableModeIds(resolvedProviderModel.provider), @@ -902,8 +944,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom background: topLevelArgs.background ?? false, normalizedTitle: topLevelArgs.title.trim(), model: resolvedProviderModel.model, - thinking: topLevelArgs.thinking, - features: topLevelArgs.features, + thinkingOptionId: settings?.thinkingOptionId, + features: settings?.features, labels: topLevelArgs.labels, notifyOnFinish: topLevelArgs.notifyOnFinish ?? false, resolvedCwd, @@ -946,7 +988,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom background, normalizedTitle, model, - thinking, + thinkingOptionId, features, labels, notifyOnFinish, @@ -968,7 +1010,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom modeId: resolvedMode, title: normalizedTitle ?? undefined, model, - thinkingOptionId: thinking, + thinkingOptionId, featureValues: features, }, undefined, @@ -1057,29 +1099,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( - "set_agent_feature", - { - title: "Set agent feature", - description: "Set a provider-specific feature on an existing agent, such as Codex fast_mode.", - inputSchema: { - agentId: z.string(), - featureId: z.string().trim().min(1), - value: z.unknown(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId, featureId, value }) => { - await agentManager.setAgentFeature(agentId, featureId, value); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - server.registerTool( "wait_for_agent", { @@ -1440,17 +1459,35 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom "update_agent", { title: "Update agent", - description: "Update an agent name and/or labels.", + description: "Update an agent name, labels, and/or runtime settings.", inputSchema: { agentId: z.string(), name: z.string().optional(), labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), + settings: UpdateAgentSettingsInputSchema.optional().describe( + "Runtime settings to apply to the agent.", + ), }, outputSchema: { success: z.boolean(), }, }, - async ({ agentId, name, labels }) => { + async ({ agentId, name, labels, settings }) => { + if (settings?.modeId !== undefined) { + await agentManager.setAgentMode(agentId, settings.modeId); + } + if (settings?.model !== undefined) { + await agentManager.setAgentModel(agentId, settings.model); + } + if (settings?.thinkingOptionId !== undefined) { + await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId); + } + if (settings?.features) { + for (const [featureId, value] of Object.entries(settings.features)) { + await agentManager.setAgentFeature(agentId, featureId, value); + } + } + const trimmedName = name?.trim(); if (trimmedName) { const record = await agentStorage.get(agentId); @@ -2022,30 +2059,63 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom ); server.registerTool( - "list_provider_features", + "inspect_provider", { - title: "List provider features", + title: "Inspect provider", description: - "List provider-specific features available for a draft agent configuration, such as Codex fast_mode.", - inputSchema: listProviderFeaturesInputSchema, + "Inspect compact provider capabilities for orchestration, including modes and draft feature settings. Use list_models for the full model list.", + inputSchema: inspectProviderInputSchema, outputSchema: { provider: AgentProviderEnum, + label: z.string().nullable().optional(), + description: z.string().nullable().optional(), + enabled: z.boolean(), + status: z.string(), + modes: z.array(ProviderModeSchema).nullish(), + selectedModel: z.string().nullable(), features: z.array(AgentFeatureSchema), }, }, - async ({ provider, cwd, modeId, model, thinkingOptionId, featureValues }) => { - const features = await agentManager.listDraftFeatures({ + async ({ provider, cwd, settings }) => { + const resolvedProviderModel = resolveScheduleProviderAndModel({ provider, - cwd: expandUserPath(cwd), - ...(modeId ? { modeId } : {}), - ...(model ? { model } : {}), - ...(thinkingOptionId ? { thinkingOptionId } : {}), - ...(featureValues ? { featureValues } : {}), + defaultProvider: provider, + }); + const providerId = resolvedProviderModel.provider; + if (!providerRegistry) { + throw new Error("Provider registry is not configured"); + } + const definition = providerRegistry[providerId]; + if (!definition) { + throw new Error(`Provider ${providerId} is not configured`); + } + const summary = await resolveProviderSummary(definition, childLogger); + if (!definition.enabled) { + throw new Error(`Provider '${providerId}' is disabled`); + } + if (summary.status !== "available") { + throw new Error(summary.error ?? `Provider '${providerId}' is unavailable`); + } + const resolvedCwd = resolveScopedCwd(cwd, { required: true }); + const selectedModel = settings?.model ?? resolvedProviderModel.model; + const features = await agentManager.listDraftFeatures({ + provider: providerId, + cwd: resolvedCwd, + ...(settings?.modeId ? { modeId: settings.modeId } : {}), + ...(selectedModel ? { model: selectedModel } : {}), + ...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}), + ...(settings?.features ? { featureValues: settings.features } : {}), }); return { content: [], structuredContent: ensureValidJson({ - provider, + provider: providerId, + label: summary.label, + description: summary.description, + enabled: summary.enabled, + status: summary.status, + modes: summary.modes, + selectedModel: selectedModel ?? null, features, }), }; diff --git a/public-docs/mcp.md b/public-docs/mcp.md index 29d747329..54f000800 100644 --- a/public-docs/mcp.md +++ b/public-docs/mcp.md @@ -15,20 +15,19 @@ The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may ### Agents -| Tool | Function | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| `create_agent` | Create an agent tied to a working directory, optionally with an initial prompt, provider features, or a new git worktree. | -| `wait_for_agent` | Block until an agent requests permission or finishes its current run. | -| `send_agent_prompt` | Send a task to a running agent. | -| `get_agent_status` | Return the latest snapshot for an agent. | -| `list_agents` | List recent agents as compact metadata. | -| `cancel_agent` | Abort an agent's current run but keep the agent alive. | -| `archive_agent` | Soft-delete an agent and remove it from the active list. | -| `kill_agent` | Terminate an agent session permanently. | -| `update_agent` | Update an agent name or labels. | -| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | -| `set_agent_mode` | Switch an agent's session mode. | -| `set_agent_feature` | Set a provider-specific feature on an existing agent, for example Codex `fast_mode`. | +| Tool | Function | +| -------------------- | ---------------------------------------------------------------------------------------------------- | +| `create_agent` | Create an agent tied to a working directory, optionally with initial settings or a new git worktree. | +| `wait_for_agent` | Block until an agent requests permission or finishes its current run. | +| `send_agent_prompt` | Send a task to a running agent. | +| `get_agent_status` | Return the latest snapshot for an agent. | +| `list_agents` | List recent agents as compact metadata. | +| `cancel_agent` | Abort an agent's current run but keep the agent alive. | +| `archive_agent` | Soft-delete an agent and remove it from the active list. | +| `kill_agent` | Terminate an agent session permanently. | +| `update_agent` | Update an agent name, labels, or runtime settings such as mode/model/thinking/features. | +| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | +| `set_agent_mode` | Switch an agent's session mode. | ### Terminals @@ -53,11 +52,11 @@ The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may ### Providers -| Tool | Function | -| ------------------------ | ------------------------------------------------------------------------------------------- | -| `list_providers` | List configured agent providers, availability, and modes. | -| `list_models` | List models for an agent provider. | -| `list_provider_features` | List provider-specific features for a draft agent configuration, such as Codex `fast_mode`. | +| Tool | Function | +| ------------------ | ----------------------------------------------------------------- | +| `list_providers` | List configured agent providers, availability, and modes. | +| `list_models` | List models for an agent provider. | +| `inspect_provider` | Inspect compact provider capabilities and draft feature settings. | ### Worktrees diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 1115202a9..c40be9f72 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -20,25 +20,29 @@ Returns `{ branchName, worktreePath }`. Pass `cwd` to target a specific repo. ## Agents -**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `background` (default `false` — blocks until completion or permission), `notifyOnFinish`, `features`. Returns `{ agentId, … }`. +**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `background` (default `false` — blocks until completion or permission), `notifyOnFinish`, `settings`. Returns `{ agentId, … }`. -Provider features are provider-specific. For Codex fast mode, pass `features: { "fast_mode": true }` when creating the agent. +Initial runtime settings live under `settings`: `modeId`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }` when creating the agent. Compose: call `create_worktree` first, then `create_agent` with `cwd` set to the returned `worktreePath`. **`send_agent_prompt`** — `{ agentId, prompt }`. Blocks by default; pass `background: true` to fire-and-forget. -**`set_agent_feature`** — `{ agentId, featureId, value }`. Use for provider-specific toggles on an existing agent, for example `{ agentId, featureId: "fast_mode", value: true }` for Codex. +**`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`. **`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`. **`archive_agent`** — `{ agentId }`. Interrupts if running, removes from active list. -## Provider features +## Provider discovery -**`list_provider_features`** — query provider-specific features before setting them. Required: `provider`, `cwd`. Optional: `model`, `modeId`, `thinkingOptionId`, `featureValues`. +**`list_providers`** — compact provider availability and modes. -Only set feature IDs returned by `list_provider_features`. For Codex fast mode, look for `fast_mode` and pass `features: { "fast_mode": true }` to `create_agent`. +**`list_models`** — full model list for one provider. Use only when you need model IDs or thinking options; the list can be large. + +**`inspect_provider`** — compact provider capability and feature inspection. Required: `provider`; pass `cwd` when you are not in an agent-scoped session. Optional: `settings` with draft `model`, `modeId`, `thinkingOptionId`, and `features`. + +Only set feature IDs returned by `inspect_provider`. For Codex fast mode, look for `fast_mode` and pass `settings: { features: { "fast_mode": true } }` to `create_agent` or `update_agent`. ## Heartbeats From ce822f989feb90962dacc5b1b3f2181923a08ad9 Mon Sep 17 00:00:00 2001 From: ezra Date: Tue, 19 May 2026 13:12:55 +0800 Subject: [PATCH 09/14] Fix Codex Microsoft Store binary detection on Windows (#1020) Fixes #1013 --- .../agent/providers/codex-app-server-agent.ts | 63 +++++++++++++++++-- .../providers/provider-availability.test.ts | 62 ++++++++++++++++-- 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index bdac82a7d..ffdb13c81 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -52,7 +52,7 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailable, probeExecutable } from "../../../utils/executable.js"; import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; @@ -375,8 +375,61 @@ function mergeCodexConfiguredDefaults( }; } +function codexMicrosoftStorePackageRoot(): string | null { + const localAppData = process.env.LOCALAPPDATA; + if (!localAppData) { + return null; + } + return path.join(localAppData, "Packages"); +} + +async function findCodexMicrosoftStoreBinary(): Promise { + if (process.platform !== "win32") { + return null; + } + + const packageRoot = codexMicrosoftStorePackageRoot(); + if (!packageRoot) { + return null; + } + + let entries: Dirent[]; + try { + entries = await fs.readdir(packageRoot, { withFileTypes: true }); + } catch { + return null; + } + + const codexPackages = entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith("OpenAI.Codex_")) + .map((entry) => entry.name) + .sort(); + + for (const packageName of codexPackages) { + const candidate = path.join( + packageRoot, + packageName, + "LocalCache", + "Local", + "OpenAI", + "Codex", + "bin", + "codex.exe", + ); + if (await probeExecutable(candidate)) { + return candidate; + } + } + + return null; +} + +async function findDefaultCodexBinary(): Promise { + return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary()); +} + async function resolveCodexBinary(): Promise { - const found = await findExecutable("codex"); + const found = await findDefaultCodexBinary(); if (found) { return found; } @@ -5299,13 +5352,13 @@ export class CodexAppServerAgentClient implements AgentClient { if (command?.mode === "replace") { return await isCommandAvailable(command.argv[0]); } - return await isCommandAvailable("codex"); + return (await findDefaultCodexBinary()) !== null; } async getDiagnostic(): Promise<{ diagnostic: string }> { try { const available = await this.isAvailable(); - const resolvedBinary = await findExecutable("codex"); + const resolvedBinary = await findDefaultCodexBinary(); const entries: Array<{ label: string; value: string }> = [ { label: "Binary", @@ -5448,6 +5501,8 @@ export const __codexAppServerInternals = { CodexAppServerClient, codexModelSupportsFastMode, CodexAppServerAgentSession, + findCodexMicrosoftStoreBinary, + findDefaultCodexBinary, formatCodexQuestionPrompts, mapCodexQuestionRequestToToolCall, mapCodexPatchNotificationToToolCall, diff --git a/packages/server/src/server/agent/providers/provider-availability.test.ts b/packages/server/src/server/agent/providers/provider-availability.test.ts index 3f371e542..d5f2c9564 100644 --- a/packages/server/src/server/agent/providers/provider-availability.test.ts +++ b/packages/server/src/server/agent/providers/provider-availability.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; @@ -9,10 +9,11 @@ import { AgentManager } from "../agent-manager.js"; import { AgentStorage } from "../agent-storage.js"; import { ClaudeAgentClient } from "./claude/agent.js"; -import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; +import { __codexAppServerInternals, CodexAppServerAgentClient } from "./codex-app-server-agent.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; const originalEnv = { + LOCALAPPDATA: process.env.LOCALAPPDATA, PATH: process.env.PATH, PATHEXT: process.env.PATHEXT, }; @@ -31,7 +32,19 @@ function isolatePathTo(dir: string): void { } } +function isolateCodexDefaultDiscoveryTo(dir: string): void { + isolatePathTo(dir); + if (process.platform === "win32") { + process.env.LOCALAPPDATA = dir; + } +} + afterEach(() => { + if (originalEnv.LOCALAPPDATA === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalEnv.LOCALAPPDATA; + } process.env.PATH = originalEnv.PATH; process.env.PATHEXT = originalEnv.PATHEXT; for (const dir of tempDirs.splice(0)) { @@ -42,12 +55,51 @@ afterEach(() => { describe("default provider availability", () => { test("Codex reports unavailable when the default command cannot be resolved", async () => { const binDir = makeTempDir("provider-availability-codex-"); - isolatePathTo(binDir); + isolateCodexDefaultDiscoveryTo(binDir); const client = new CodexAppServerAgentClient(createTestLogger()); await expect(client.isAvailable()).resolves.toBe(false); }); + test("Codex reports available from a Microsoft Store install path when PATH misses codex", async () => { + const originalPlatform = process.platform; + const originalLocalAppData = process.env.LOCALAPPDATA; + const root = makeTempDir("provider-availability-codex-store-"); + const emptyPathDir = join(root, "empty-path"); + const codexBinDir = join( + root, + "Packages", + "OpenAI.Codex_abc123", + "LocalCache", + "Local", + "OpenAI", + "Codex", + "bin", + ); + const codexExe = join(codexBinDir, "codex.exe"); + mkdirSync(emptyPathDir, { recursive: true }); + mkdirSync(codexBinDir, { recursive: true }); + copyFileSync(process.execPath, codexExe); + Object.defineProperty(process, "platform", { value: "win32", writable: true }); + process.env.LOCALAPPDATA = root; + isolatePathTo(emptyPathDir); + process.env.PATHEXT = ".EXE"; + + try { + const client = new CodexAppServerAgentClient(createTestLogger()); + + await expect(__codexAppServerInternals.findDefaultCodexBinary()).resolves.toBe(codexExe); + await expect(client.isAvailable()).resolves.toBe(true); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, writable: true }); + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + } + }); + test("Claude reports unavailable when the default command cannot be resolved", async () => { const binDir = makeTempDir("provider-availability-claude-"); isolatePathTo(binDir); @@ -66,7 +118,7 @@ describe("default provider availability", () => { test("AgentManager reports Codex unavailable without throwing", async () => { const binDir = makeTempDir("provider-availability-manager-bin-"); - isolatePathTo(binDir); + isolateCodexDefaultDiscoveryTo(binDir); const workdir = makeTempDir("provider-availability-manager-work-"); const storage = new AgentStorage(join(workdir, "agents"), createTestLogger()); const manager = new AgentManager({ @@ -88,7 +140,7 @@ describe("default provider availability", () => { test("resumeAgentFromPersistence stops before provider spawn when Codex is unavailable", async () => { const binDir = makeTempDir("provider-availability-resume-bin-"); - isolatePathTo(binDir); + isolateCodexDefaultDiscoveryTo(binDir); const workdir = makeTempDir("provider-availability-resume-work-"); const storage = new AgentStorage(join(workdir, "agents"), createTestLogger()); const manager = new AgentManager({ From cb964850359267d6b312aa1bce76d90c22d20d91 Mon Sep 17 00:00:00 2001 From: Yurui Zhou Date: Tue, 19 May 2026 13:56:32 +0800 Subject: [PATCH 10/14] feat(server): upgrade embedded Pi SDK (#1087) * feat(server): upgrade embedded Pi SDK * fix: sync package-lock.json with package.json The Pi SDK upgrade commit (63e18a9d) regenerated package-lock.json in a way that dropped packages/website's react@19.2.6, react-dom@19.2.6, and scheduler@0.27.0 (website depends on react ^19.1.4, which resolves to 19.2.6). This broke `npm ci` in CI, failing all 14 checks at the install step with EUSAGE "package.json and package-lock.json not in sync". Regenerated with `npm install`. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- docs/providers.md | 2 + package-lock.json | 2459 ++++------------- packages/server/package.json | 6 +- .../agent/providers/pi-direct-agent.test.ts | 2 +- .../server/agent/providers/pi-direct-agent.ts | 6 +- .../providers/pi-session-recovery-policy.ts | 2 +- 6 files changed, 577 insertions(+), 1900 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 1c02c41df..6e3c39150 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -16,6 +16,8 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types. Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct. +Pi direct embeds Pi's SDK through the `@earendil-works/pi-*` packages. Keep those dependencies in sync with the Pi package-manager behavior expected by current user installs: Pi 0.75+ loads user-scoped npm packages from `~/.pi/agent/npm/`, while older `@mariozechner/pi-coding-agent` releases looked in npm's global package root and can miss or load stale extensions. + Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. --- diff --git a/package-lock.json b/package-lock.json index 5a36be6c2..d165c1f36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -212,9 +212,9 @@ ] }, "node_modules/@anthropic-ai/sdk": { - "version": "0.90.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.90.0.tgz", - "integrity": "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==", + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1" @@ -260,44 +260,6 @@ "tslib": "^2.6.2" } }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aws-crypto/sha256-js": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", @@ -332,96 +294,25 @@ "tslib": "^2.6.2" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1045.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1045.0.tgz", - "integrity": "sha512-aPC6gAz9uKRiwfnKB7peTs6yD0FpSzmVnSkx0f2QtJfosFM6J6KtBvR1lMKby050K4C4PAyEScwA5YTsGfTcGA==", + "version": "3.1049.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1049.0.tgz", + "integrity": "sha512-YM8b2baoRY8ul47b4amQW2VlUthLmM8DnqdlGO20LJmmmRpjnT91SaQJai3OMehA6uE0Gig88VyDCT1vEACSww==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/credential-provider-node": "^3.972.39", - "@aws-sdk/eventstream-handler-node": "^3.972.14", - "@aws-sdk/middleware-eventstream": "^3.972.10", - "@aws-sdk/middleware-host-header": "^3.972.10", - "@aws-sdk/middleware-logger": "^3.972.10", - "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.38", - "@aws-sdk/middleware-websocket": "^3.972.16", - "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/token-providers": "3.1045.0", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/credential-provider-node": "^3.972.43", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.20", + "@aws-sdk/token-providers": "3.1049.0", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.24", - "@smithy/config-resolver": "^4.4.17", - "@smithy/core": "^3.23.17", - "@smithy/eventstream-serde-browser": "^4.2.14", - "@smithy/eventstream-serde-config-resolver": "^4.3.14", - "@smithy/eventstream-serde-node": "^4.2.14", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/hash-node": "^4.2.14", - "@smithy/invalid-dependency": "^4.2.14", - "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.7", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.49", - "@smithy/util-defaults-mode-node": "^4.2.54", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -429,24 +320,18 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.8.tgz", - "integrity": "sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==", + "version": "3.974.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.12.tgz", + "integrity": "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.22", - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/util-utf8": "^4.2.2", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { @@ -454,14 +339,14 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz", - "integrity": "sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.38.tgz", + "integrity": "sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", + "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -470,20 +355,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.36.tgz", - "integrity": "sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==", + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.40.tgz", + "integrity": "sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", + "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", - "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -491,23 +373,22 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.38.tgz", - "integrity": "sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==", + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.42.tgz", + "integrity": "sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/credential-provider-env": "^3.972.34", - "@aws-sdk/credential-provider-http": "^3.972.36", - "@aws-sdk/credential-provider-login": "^3.972.38", - "@aws-sdk/credential-provider-process": "^3.972.34", - "@aws-sdk/credential-provider-sso": "^3.972.38", - "@aws-sdk/credential-provider-web-identity": "^3.972.38", - "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/credential-provider-env": "^3.972.38", + "@aws-sdk/credential-provider-http": "^3.972.40", + "@aws-sdk/credential-provider-login": "^3.972.42", + "@aws-sdk/credential-provider-process": "^3.972.38", + "@aws-sdk/credential-provider-sso": "^3.972.42", + "@aws-sdk/credential-provider-web-identity": "^3.972.42", + "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", - "@smithy/credential-provider-imds": "^4.2.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -516,17 +397,15 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.38.tgz", - "integrity": "sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==", + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.42.tgz", + "integrity": "sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -535,21 +414,20 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.39.tgz", - "integrity": "sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==", + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.43.tgz", + "integrity": "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.34", - "@aws-sdk/credential-provider-http": "^3.972.36", - "@aws-sdk/credential-provider-ini": "^3.972.38", - "@aws-sdk/credential-provider-process": "^3.972.34", - "@aws-sdk/credential-provider-sso": "^3.972.38", - "@aws-sdk/credential-provider-web-identity": "^3.972.38", + "@aws-sdk/credential-provider-env": "^3.972.38", + "@aws-sdk/credential-provider-http": "^3.972.40", + "@aws-sdk/credential-provider-ini": "^3.972.42", + "@aws-sdk/credential-provider-process": "^3.972.38", + "@aws-sdk/credential-provider-sso": "^3.972.42", + "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/types": "^3.973.8", - "@smithy/credential-provider-imds": "^4.2.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -558,15 +436,14 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.34.tgz", - "integrity": "sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.38.tgz", + "integrity": "sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", + "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -575,35 +452,16 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.38.tgz", - "integrity": "sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==", + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.42.tgz", + "integrity": "sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", - "@aws-sdk/token-providers": "3.1041.0", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/nested-clients": "^3.997.10", + "@aws-sdk/token-providers": "3.1049.0", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1041.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1041.0.tgz", - "integrity": "sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -612,16 +470,15 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.38.tgz", - "integrity": "sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==", + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.42.tgz", + "integrity": "sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -630,13 +487,13 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.14.tgz", - "integrity": "sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg==", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", - "@smithy/eventstream-codec": "^4.2.14", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -645,13 +502,13 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.10.tgz", - "integrity": "sha512-QUqLs7Af1II9X4fCRAu+EGHG3KHyOp4RkuLhRKoA3NuFlh6TL8i+zXBl8w2LUxqm44B/Kom45hgSlwA1SpTsXQ==", + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -659,112 +516,18 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz", - "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz", - "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz", - "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.37.tgz", - "integrity": "sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.38.tgz", - "integrity": "sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@smithy/core": "^3.23.17", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-retry": "^4.3.6", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.16.tgz", - "integrity": "sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg==", + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.20.tgz", + "integrity": "sha512-LM6P0i+Lu6pi25oNw2nqxjRxiEOtLgPB7xIvHfa+FxHTRLg8wcgqu3qg2COl4QaT7Es2yCxYdeRLVYazKAwL8g==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-format-url": "^3.972.10", - "@smithy/eventstream-codec": "^4.2.14", - "@smithy/eventstream-serde-browser": "^4.2.14", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -772,64 +535,19 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.6.tgz", - "integrity": "sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==", + "version": "3.997.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.10.tgz", + "integrity": "sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/middleware-host-header": "^3.972.10", - "@aws-sdk/middleware-logger": "^3.972.10", - "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.38", - "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.24", - "@smithy/config-resolver": "^4.4.17", - "@smithy/core": "^3.23.17", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/hash-node": "^4.2.14", - "@smithy/invalid-dependency": "^4.2.14", - "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.7", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.49", - "@smithy/util-defaults-mode-node": "^4.2.54", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz", - "integrity": "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/config-resolver": "^4.4.17", - "@smithy/node-config-provider": "^4.3.14", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -838,15 +556,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz", - "integrity": "sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==", + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.37", "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -855,16 +572,15 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1045.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1045.0.tgz", - "integrity": "sha512-/o4qcty0DmQola0DBniRVeBakYY6ALOvKEFo1AtJpTmMn/cJ+Fk3RWGe5ieT/f/eYbHG9k5E7poKge/E+WGv4Q==", + "version": "3.1049.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1049.0.tgz", + "integrity": "sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -885,49 +601,6 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", - "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", - "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-endpoints": "^3.4.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.10.tgz", - "integrity": "sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/querystring-builder": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.965.5", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", @@ -940,52 +613,15 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz", - "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.24.tgz", - "integrity": "sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.38", - "@aws-sdk/types": "^3.973.8", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", - "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", "license": "Apache-2.0", "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.2", + "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" }, "engines": { @@ -3427,16 +3063,6 @@ "node": ">=6.9.0" } }, - "node_modules/@borewit/text-codec": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", - "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/@clack/core": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.1.0.tgz", @@ -3731,6 +3357,279 @@ "react": ">=16.8.0" } }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.75.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.3.tgz", + "integrity": "sha512-azg09GSrckQa3ffbH09YEZC7DyHgmNSX+vmWEoEhQvp4icbzqbqLfIeMayMNEK/aGusm1SghZC4bPlDdagDALg==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.75.3", + "ignore": "^7.0.5", + "typebox": "^1.1.24", + "yaml": "^2.8.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-agent-core/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.75.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.3.tgz", + "integrity": "sha512-UKccS+ADlkSVJ49a00346jUfXmUi6zzzB+pPWotsyA6SxhKr2ejjkGQksGyR1DyNVrsEP/WWlsOSTUUwVlzNaA==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.91.1", + "@aws-sdk/client-bedrock-runtime": "^3.1030.0", + "@google/genai": "^1.40.0", + "@mistralai/mistralai": "^2.2.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "openai": "6.26.0", + "partial-json": "^0.1.7", + "typebox": "^1.1.24" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.75.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.75.3.tgz", + "integrity": "sha512-LIi5/CdUBfcLp3BAtpLx1BfnHDLmDOQVdzYfS1H9fjjCw2dcPr9voSI5ncrhvZdgyFSnfHck4BCbNcfZk+TEHQ==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.75.3", + "@earendil-works/pi-ai": "^0.75.3", + "@earendil-works/pi-tui": "^0.75.3", + "@silvia-odwyer/photon-node": "^0.3.4", + "chalk": "^5.5.0", + "cross-spawn": "^7.0.6", + "diff": "^8.0.2", + "glob": "^13.0.1", + "highlight.js": "^10.7.3", + "hosted-git-info": "^9.0.2", + "ignore": "^7.0.5", + "jiti": "^2.7.0", + "minimatch": "^10.2.3", + "proper-lockfile": "^4.1.2", + "typebox": "^1.1.24", + "undici": "^8.3.0", + "yaml": "^2.8.2" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "^0.3.6" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", + "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.75.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.3.tgz", + "integrity": "sha512-UbhtCsae+b3Y8/ZxtBPhiOrkD66gOHvJbfvLZwhBBsNtQuvUkZY5t9MQwmb8QcDYkFRnXHaq3FcEy1hjRSfj6w==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "marked": "^15.0.12" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "koffi": "^2.9.0" + } + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -7244,9 +7143,10 @@ "link": true }, "node_modules/@google/genai": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.50.1.tgz", - "integrity": "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "google-auth-library": "^10.3.0", @@ -8681,31 +8581,31 @@ } }, "node_modules/@mariozechner/clipboard": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.3.tgz", - "integrity": "sha512-e7jASirzfm+ROiOGFh843+cFZTy3DfzP+jldCvh8RnEk0C3QihDTn7dd7Yh7KAJydwIJ18FJSZ2swHvCJhk18g==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", + "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", "license": "MIT", "optional": true, "engines": { "node": ">= 10" }, "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.3", - "@mariozechner/clipboard-darwin-universal": "0.3.3", - "@mariozechner/clipboard-darwin-x64": "0.3.3", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.3", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.3", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.3", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.3", - "@mariozechner/clipboard-linux-x64-musl": "0.3.3", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.3", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.3" + "@mariozechner/clipboard-darwin-arm64": "0.3.6", + "@mariozechner/clipboard-darwin-universal": "0.3.6", + "@mariozechner/clipboard-darwin-x64": "0.3.6", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-x64-musl": "0.3.6", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" } }, "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.3.tgz", - "integrity": "sha512-+zhuZGXqVrdkbIRdnwiZNbTJ7V3elq/A+C5d5laJoyhJgWs41eO5NUMkBkj6f23F2L4PRXEhdn5/ktlPx+bG3Q==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", + "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", "cpu": [ "arm64" ], @@ -8719,9 +8619,9 @@ } }, "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.3.tgz", - "integrity": "sha512-x9aRfTyndVqpEQ44LNNCK/EXZd9y8rWkLQgNhmWpby9PXrjPhNxfjUc2Db4mt4nJjU/4zzO8F5v/XyzlUGSdhQ==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", + "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", "license": "MIT", "optional": true, "os": [ @@ -8732,9 +8632,9 @@ } }, "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.3.tgz", - "integrity": "sha512-6ut/NawB0KiYPCwrirgNp6Br62LntL978q7G6d/Rs2pmPvQb53bP96eUMYl+Y3a7Qk13bGZ4w9rVPFxRE9m9ag==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", + "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", "cpu": [ "x64" ], @@ -8748,9 +8648,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.3.tgz", - "integrity": "sha512-gf3dH4kBddU1AOyHVB53mjLUFfJAKlTmxTMw51jdeg7eE7IjfEBXVvM4bifMtBxbWkT0eA0FUZ1C0KQ6Z5l6pw==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", + "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", "cpu": [ "arm64" ], @@ -8764,9 +8664,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.3.tgz", - "integrity": "sha512-o1paj2+zmAQ/LaPS85XJCxhNowNQpxYM2cGY6pWvB5Kqmz6hZjl6CzDg5tbf1hZkn/Em6jpOaE2UtMxKdELBDA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", + "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", "cpu": [ "arm64" ], @@ -8780,9 +8680,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.3.tgz", - "integrity": "sha512-dkEhE4ekePJwMbBq9HP1//CFMNmDzA/iV9AXqBfvL5CWmmDIRXqh4A3YZt3tWO/HdMerX+xNCEiR7WiOsIG+UA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", + "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", "cpu": [ "riscv64" ], @@ -8796,9 +8696,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.3.tgz", - "integrity": "sha512-lT2yANtTLlEtFBIH3uGoRa/CQas/eBoLNi3qr9axQFoRgF4RGPSJ66yHOSnMECBneTIb1Iqv3UxokTfX27CdoQ==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", + "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", "cpu": [ "x64" ], @@ -8812,9 +8712,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.3.tgz", - "integrity": "sha512-saq/MCB0QHK/7ZZLjAZ0QkbY944dyjOsur8gneGCfMitt+GOiE1CU4OUipHC4b6x8UDY9bRLsR4aBaxu22OFPA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", + "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", "cpu": [ "x64" ], @@ -8828,9 +8728,9 @@ } }, "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.3.tgz", - "integrity": "sha512-cGuvSj0/2X2w983yEcKw+i+r1EBej6ZZIN+fXG3eY2G/HaIQpbXpLvMxKyZ9LKtbZx+Z6q/gELEoSBMLML6BaQ==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", + "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", "cpu": [ "arm64" ], @@ -8844,9 +8744,9 @@ } }, "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.3.tgz", - "integrity": "sha512-5hvaEq/bgYovTIGx43O/S7loIHYV3ue90WcV1dz0wdMXroVKZKeU/yfwM0PALQA1OcrEHiGXGySFReXr72lGtA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", + "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", "cpu": [ "x64" ], @@ -8859,332 +8759,6 @@ "node": ">= 10" } }, - "node_modules/@mariozechner/jiti": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@mariozechner/jiti/-/jiti-2.6.5.tgz", - "integrity": "sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==", - "license": "MIT", - "dependencies": { - "std-env": "^3.10.0", - "yoctocolors": "^2.1.2" - }, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/@mariozechner/pi-agent-core": { - "version": "0.70.2", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.70.2.tgz", - "integrity": "sha512-g1hIdKyDwmQOoBGO0R4OhpemKeMENeK0vE5FJtuQKqEcsdCAkVBgZAK6aZUARYZVxMA718JS6WPLFWoddzjD7g==", - "license": "MIT", - "dependencies": { - "@mariozechner/pi-ai": "^0.70.2", - "typebox": "^1.1.24" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@mariozechner/pi-ai": { - "version": "0.70.2", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.70.2.tgz", - "integrity": "sha512-+30LRPjXsXF+oI96DvGWMbdPGeqoLJvadh6UPev7wx2DzhC9FEqXkQcoMZ0usbCm7E9pl8ua8a9s/pQ5ikaUbg==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "^0.90.0", - "@aws-sdk/client-bedrock-runtime": "^3.1030.0", - "@google/genai": "^1.40.0", - "@mistralai/mistralai": "^2.2.0", - "chalk": "^5.6.2", - "openai": "6.26.0", - "partial-json": "^0.1.7", - "proxy-agent": "^6.5.0", - "typebox": "^1.1.24", - "undici": "^7.19.1", - "zod-to-json-schema": "^3.24.6" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@mariozechner/pi-ai/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@mariozechner/pi-ai/node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@mariozechner/pi-coding-agent": { - "version": "0.70.2", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.70.2.tgz", - "integrity": "sha512-asfNqV89HKAmKvJ1wENBY/UQMIf77kLtkzBrvXnMQV4YbH7D/6KT+VeVzPG6zm5PAZP2UtdLY9B9Cge7IxH37w==", - "license": "MIT", - "dependencies": { - "@mariozechner/jiti": "^2.6.2", - "@mariozechner/pi-agent-core": "^0.70.2", - "@mariozechner/pi-ai": "^0.70.2", - "@mariozechner/pi-tui": "^0.70.2", - "@silvia-odwyer/photon-node": "^0.3.4", - "chalk": "^5.5.0", - "cli-highlight": "^2.1.11", - "diff": "^8.0.2", - "extract-zip": "^2.0.1", - "file-type": "^21.1.1", - "glob": "^13.0.1", - "hosted-git-info": "^9.0.2", - "ignore": "^7.0.5", - "marked": "^15.0.12", - "minimatch": "^10.2.3", - "proper-lockfile": "^4.1.2", - "strip-ansi": "^7.1.0", - "typebox": "^1.1.24", - "undici": "^7.19.1", - "uuid": "^14.0.0", - "yaml": "^2.8.2" - }, - "bin": { - "pi": "dist/cli.js" - }, - "engines": { - "node": ">=20.6.0" - }, - "optionalDependencies": { - "@mariozechner/clipboard": "^0.3.3" - } - }, - "node_modules/@mariozechner/pi-coding-agent/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@mariozechner/pi-coding-agent/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@mariozechner/pi-coding-agent/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@mariozechner/pi-coding-agent/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@mariozechner/pi-coding-agent/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@mariozechner/pi-coding-agent/node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@mariozechner/pi-coding-agent/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@mariozechner/pi-coding-agent/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@mariozechner/pi-coding-agent/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@mariozechner/pi-coding-agent/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@mariozechner/pi-coding-agent/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@mariozechner/pi-coding-agent/node_modules/uuid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", - "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/@mariozechner/pi-tui": { - "version": "0.70.2", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.70.2.tgz", - "integrity": "sha512-PtKC0NepnrYcqMx6MXkWTrBzC9tI62KeC6w940oT46lCbfvgmfqXciR15+9BZpxxc1H4jd3CMrKsmOPVeUqZ0A==", - "license": "MIT", - "dependencies": { - "@types/mime-types": "^2.1.4", - "chalk": "^5.5.0", - "get-east-asian-width": "^1.3.0", - "marked": "^15.0.12", - "mime-types": "^3.0.1" - }, - "engines": { - "node": ">=20.0.0" - }, - "optionalDependencies": { - "koffi": "^2.9.0" - } - }, - "node_modules/@mariozechner/pi-tui/node_modules/@types/mime-types": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", - "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", - "license": "MIT" - }, - "node_modules/@mariozechner/pi-tui/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@mariozechner/pi-tui/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/@mistralai/mistralai": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", @@ -10716,9 +10290,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { @@ -10728,13 +10302,12 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -10744,9 +10317,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -10762,9 +10335,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@radix-ui/primitive": { @@ -11699,8 +11272,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.59.0", @@ -11713,8 +11285,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.59.0", @@ -11727,8 +11298,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.59.0", @@ -11741,8 +11311,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.59.0", @@ -11755,8 +11324,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.59.0", @@ -11769,8 +11337,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.59.0", @@ -11783,8 +11350,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.59.0", @@ -11797,8 +11363,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.59.0", @@ -11811,8 +11376,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.59.0", @@ -11825,8 +11389,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.59.0", @@ -11839,8 +11402,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.59.0", @@ -11853,8 +11415,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.59.0", @@ -11867,8 +11428,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.59.0", @@ -11881,8 +11441,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.59.0", @@ -11895,8 +11454,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.59.0", @@ -11909,8 +11467,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.59.0", @@ -11923,8 +11480,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.59.0", @@ -11937,8 +11493,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.59.0", @@ -11951,8 +11506,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.59.0", @@ -11965,8 +11519,7 @@ "optional": true, "os": [ "openbsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.59.0", @@ -11979,8 +11532,7 @@ "optional": true, "os": [ "openharmony" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.59.0", @@ -11993,8 +11545,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.59.0", @@ -12007,8 +11558,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.59.0", @@ -12021,8 +11571,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.59.0", @@ -12035,8 +11584,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -12241,38 +11789,14 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@smithy/config-resolver": { - "version": "4.4.17", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.17.tgz", - "integrity": "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/core": { - "version": "3.23.17", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz", - "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==", + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -12280,85 +11804,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz", - "integrity": "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", - "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.1", - "@smithy/util-hex-encoding": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.14.tgz", - "integrity": "sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.14.tgz", - "integrity": "sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.14.tgz", - "integrity": "sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.14.tgz", - "integrity": "sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-codec": "^4.2.14", - "@smithy/types": "^4.14.1", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -12366,43 +11818,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.17", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz", - "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/querystring-builder": "^4.2.14", - "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-node": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.14.tgz", - "integrity": "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz", - "integrity": "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -12410,201 +11832,25 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", - "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz", - "integrity": "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.32", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz", - "integrity": "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-middleware": "^4.2.14", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.7.tgz", - "integrity": "sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/service-error-classification": "^4.3.1", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.2.20", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.20.tgz", - "integrity": "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz", - "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.3.14", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz", - "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" } }, "node_modules/@smithy/node-http-handler": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz", - "integrity": "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==", + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/querystring-builder": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.14.tgz", - "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.14.tgz", - "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz", - "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "@smithy/util-uri-escape": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz", - "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.1.tgz", - "integrity": "sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.9", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz", - "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -12612,36 +11858,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", - "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-uri-escape": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.12.13", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.13.tgz", - "integrity": "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-stream": "^4.5.25", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -12649,61 +11872,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", - "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.14.tgz", - "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/querystring-parser": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-base64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", - "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", - "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", - "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -12713,170 +11884,29 @@ } }, "node_modules/@smithy/util-buffer-from": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", - "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", + "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", - "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.49", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz", - "integrity": "sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.54", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz", - "integrity": "sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/config-resolver": "^4.4.17", - "@smithy/credential-provider-imds": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz", - "integrity": "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", - "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.14.tgz", - "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.8.tgz", - "integrity": "sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/service-error-classification": "^4.3.1", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "4.5.25", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.25.tgz", - "integrity": "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", - "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" } }, "node_modules/@smithy/util-utf8": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", - "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/uuid": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", - "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" } }, "node_modules/@speed-highlight/core": { @@ -14074,29 +13104,6 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@tokenizer/inflate": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", - "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "token-types": "^6.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", - "license": "MIT" - }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -14107,12 +13114,6 @@ "node": ">= 10" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "license": "MIT" - }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -14701,6 +13702,7 @@ "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -17155,15 +16157,6 @@ "node": ">=6.0.0" } }, - "node_modules/basic-ftp": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.0.tgz", - "integrity": "sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -17443,6 +16436,7 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, "license": "MIT", "engines": { "node": "*" @@ -18135,77 +17129,6 @@ "node": ">=8" } }, - "node_modules/cli-highlight": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", - "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", - "license": "ISC", - "dependencies": { - "chalk": "^4.0.0", - "highlight.js": "^10.7.1", - "mz": "^2.4.0", - "parse5": "^5.1.1", - "parse5-htmlparser2-tree-adapter": "^6.0.0", - "yargs": "^16.0.0" - }, - "bin": { - "highlight": "bin/highlight" - }, - "engines": { - "node": ">=8.0.0", - "npm": ">=5.0.0" - } - }, - "node_modules/cli-highlight/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/cli-highlight/node_modules/parse5": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", - "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", - "license": "MIT" - }, - "node_modules/cli-highlight/node_modules/parse5-htmlparser2-tree-adapter": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", - "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", - "license": "MIT", - "dependencies": { - "parse5": "^6.0.1" - } - }, - "node_modules/cli-highlight/node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", - "license": "MIT" - }, - "node_modules/cli-highlight/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/cli-progress": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", @@ -18862,12 +17785,12 @@ "license": "MIT" }, "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 12" } }, "node_modules/data-urls": { @@ -19247,32 +18170,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/degenerator/node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -20498,6 +19395,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "esprima": "^4.0.1", @@ -20519,6 +19417,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "optional": true, "engines": { @@ -21479,6 +20378,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -21508,6 +20408,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -23911,6 +22812,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "debug": "^4.1.1", @@ -23931,6 +22833,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0" @@ -24048,9 +22951,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", - "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", "funding": [ { "type": "github", @@ -24060,7 +22963,7 @@ "license": "MIT", "dependencies": { "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.5", + "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, @@ -24131,6 +23034,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -24244,24 +23148,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-type": { - "version": "21.3.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", - "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", - "license": "MIT", - "dependencies": { - "@tokenizer/inflate": "^0.4.1", - "strtok3": "^10.3.4", - "token-types": "^6.1.1", - "uint8array-extras": "^1.4.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -24854,15 +23740,6 @@ "node": ">= 14" } }, - "node_modules/gaxios/node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/gaxios/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -24936,9 +23813,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "license": "MIT", "engines": { "node": ">=18" @@ -25075,20 +23952,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/getenv": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/getenv/-/getenv-1.0.0.tgz", @@ -28130,9 +26993,9 @@ } }, "node_modules/koffi": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.1.tgz", - "integrity": "sha512-0Ie6CfD026dNfWSosDw9dPxPzO9Rlyo0N8m5r05S8YjytIpuilzMFDMY4IDy/8xQsTwpuVinhncD+S8n3bcYZQ==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz", + "integrity": "sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -30959,15 +29822,6 @@ "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", "license": "MIT" }, - "node_modules/netmask": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", - "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/node-abi": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", @@ -32036,73 +30890,6 @@ "node": ">=6" } }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-proxy-agent/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-proxy-agent/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-proxy-agent/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -32429,6 +31216,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -32959,24 +31747,24 @@ } }, "node_modules/protobufjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", - "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.0.tgz", + "integrity": "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", + "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -32995,73 +31783,11 @@ "node": ">= 0.10" } }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, "license": "MIT" }, "node_modules/psl": { @@ -35663,6 +34389,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6.0.0", @@ -35686,6 +34413,7 @@ "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, "license": "MIT", "dependencies": { "ip-address": "^10.0.1", @@ -35700,6 +34428,7 @@ "version": "8.0.5", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -35714,6 +34443,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -35988,6 +34718,7 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -36332,22 +35063,6 @@ ], "license": "MIT" }, - "node_modules/strtok3": { - "version": "10.3.5", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", - "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", - "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/structured-headers": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", @@ -36994,24 +35709,6 @@ "node": ">=0.6" } }, - "node_modules/token-types": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", - "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.2.1", - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -37417,9 +36114,9 @@ } }, "node_modules/typebox": { - "version": "1.1.33", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.33.tgz", - "integrity": "sha512-+/MWwlQ1q2GSVwoxi/+u5JsHkgLQKcCN2Nsjree9c+K7GJu40qbaHrFETmfV1i9Fs1TcOVfynW+jJvIWcXtvjw==", + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", "license": "MIT" }, "node_modules/typed-array-buffer": { @@ -37552,18 +36249,6 @@ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "license": "MIT" }, - "node_modules/uint8array-extras": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", - "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/unbash": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/unbash/-/unbash-2.2.0.tgz", @@ -39111,6 +37796,7 @@ "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -39129,6 +37815,7 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", @@ -39157,18 +37844,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", @@ -39593,12 +38268,12 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.2.133", + "@earendil-works/pi-agent-core": "^0.75.3", + "@earendil-works/pi-ai": "^0.75.3", + "@earendil-works/pi-coding-agent": "^0.75.3", "@getpaseo/highlight": "0.1.78", "@getpaseo/relay": "0.1.78", "@isaacs/ttlcache": "^2.1.4", - "@mariozechner/pi-agent-core": "^0.70.2", - "@mariozechner/pi-ai": "^0.70.2", - "@mariozechner/pi-coding-agent": "^0.70.2", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", "@sctg/sentencepiece-js": "^1.1.0", diff --git a/packages/server/package.json b/packages/server/package.json index 42e30123a..542758ffa 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -58,12 +58,12 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.2.133", + "@earendil-works/pi-agent-core": "^0.75.3", + "@earendil-works/pi-ai": "^0.75.3", + "@earendil-works/pi-coding-agent": "^0.75.3", "@getpaseo/highlight": "0.1.78", "@getpaseo/relay": "0.1.78", "@isaacs/ttlcache": "^2.1.4", - "@mariozechner/pi-agent-core": "^0.70.2", - "@mariozechner/pi-ai": "^0.70.2", - "@mariozechner/pi-coding-agent": "^0.70.2", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", "@sctg/sentencepiece-js": "^1.1.0", diff --git a/packages/server/src/server/agent/providers/pi-direct-agent.test.ts b/packages/server/src/server/agent/providers/pi-direct-agent.test.ts index b75bdf2b0..92dc2b982 100644 --- a/packages/server/src/server/agent/providers/pi-direct-agent.test.ts +++ b/packages/server/src/server/agent/providers/pi-direct-agent.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { describe, expect, test, vi } from "vitest"; -import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai"; +import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai"; import pino from "pino"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; diff --git a/packages/server/src/server/agent/providers/pi-direct-agent.ts b/packages/server/src/server/agent/providers/pi-direct-agent.ts index 79a6528d9..edaee537d 100644 --- a/packages/server/src/server/agent/providers/pi-direct-agent.ts +++ b/packages/server/src/server/agent/providers/pi-direct-agent.ts @@ -25,9 +25,9 @@ import { type ResolvedCommand, type Skill, type WriteToolInput, -} from "@mariozechner/pi-coding-agent"; -import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai"; +} from "@earendil-works/pi-coding-agent"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai"; import { z } from "zod"; import { diff --git a/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts b/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts index c52ff093c..140eb6b09 100644 --- a/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts +++ b/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts @@ -30,7 +30,7 @@ export interface PiSessionRecoveryResult { } // COMPAT(piCopilot413): added 2026-05-13 for Pi <= 0.73.1; target removal -// 2026-11-13, once upstream @mariozechner/pi-ai recognizes this overflow. +// 2026-11-13, once upstream @earendil-works/pi-ai recognizes this overflow. const PI_COPILOT_SHORT_413_OVERFLOW_PATTERN = /^413\s+failed to parse request$/i; const PI_SESSION_RECOVERY_POLICIES: readonly PiSessionRecoveryPolicy[] = [ From b41cb72da0b53edc5bf7aba44939335ae1b77d6b Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 06:00:14 +0000 Subject: [PATCH 11/14] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 00b9214ca..40fc0dfd5 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-sRHRfPuhsoOx+wtEb+VYfs29wz3BdKu6vDYhaHr7LQE= +sha256-t6vqegESlQGlbkOfjpjOHbf6ufiDeBl/ApFtQHvOeLc= From 0f6641c8c0a4828edc27c51edd85a5cbef6cb390 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 19 May 2026 15:32:48 +0800 Subject: [PATCH 12/14] Show resolved file paths in agent file-link tooltips (#1088) * fix: show resolved file paths in agent file-link tooltips Bare filenames in agent messages now resolve to their full workspace path on hover. Also raises the daemon's directory-suggestion scan depth so files in deeper package layouts are reachable. * fix(app): keep file-link wrapper stable to avoid layout shift on resolve * fix(app): resolve file links on hover instead of at render time useQuery was firing the daemon RPC for every ambiguous file reference the moment a message rendered, fanning out a wave of requests on chat scroll. Switch to enabled: false + prefetchQuery on hover so RPCs are driven by user intent. Sync-resolvable refs (directFile, external) still seed via initialData and render with no RPC. Also memo on source primitives rather than identity so identical- content sources constructed inline upstream don't bust the memo every render. * Redesign assistant file link resolution * Stabilize assistant file link handlers --- .../app/src/assistant-file-links/index.ts | 7 +- .../app/src/assistant-file-links/link.tsx | 272 +++---- .../app/src/assistant-file-links/provider.tsx | 87 +++ .../src/assistant-file-links/resolver.test.ts | 678 +++++------------- .../app/src/assistant-file-links/resolver.ts | 305 +++----- .../use-file-link.test.tsx | 328 +++++++++ .../src/assistant-file-links/use-file-link.ts | 347 +++++++++ .../src/assistant-file-links/use-resolver.ts | 90 --- .../app/src/components/agent-stream-view.tsx | 52 +- packages/app/src/components/message.tsx | 74 +- .../server/src/utils/directory-suggestions.ts | 4 +- 11 files changed, 1165 insertions(+), 1079 deletions(-) create mode 100644 packages/app/src/assistant-file-links/provider.tsx create mode 100644 packages/app/src/assistant-file-links/use-file-link.test.tsx create mode 100644 packages/app/src/assistant-file-links/use-file-link.ts delete mode 100644 packages/app/src/assistant-file-links/use-resolver.ts diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts index 7b1a93aeb..0a0e19207 100644 --- a/packages/app/src/assistant-file-links/index.ts +++ b/packages/app/src/assistant-file-links/index.ts @@ -1,6 +1,5 @@ export { AssistantInlineCodePathLink, - AssistantInlinePathLink, AssistantMarkdownCodeLink, AssistantMarkdownLink, } from "./link"; @@ -9,5 +8,9 @@ export { normalizeInlinePathTarget, type InlinePathTarget, } from "./parse"; +export { + AssistantFileLinkResolverProvider, + type AssistantFileLinkResolverProviderProps, +} from "./provider"; export type { AssistantFileLinkSource } from "./resolver"; -export { useAssistantFileLinkResolver } from "./use-resolver"; +export { useAssistantFileLinkActions } from "./use-file-link"; diff --git a/packages/app/src/assistant-file-links/link.tsx b/packages/app/src/assistant-file-links/link.tsx index 046ba15ea..b4395c353 100644 --- a/packages/app/src/assistant-file-links/link.tsx +++ b/packages/app/src/assistant-file-links/link.tsx @@ -1,11 +1,4 @@ -import { - useCallback, - useMemo, - useState, - type CSSProperties, - type ReactNode, - type MouseEvent, -} from "react"; +import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"; import { Pressable, Text, @@ -16,128 +9,66 @@ import { } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { isNative, isWeb } from "@/constants/platform"; -import type { OpenFileDisposition } from "@/workspace/file-open"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { classifyAssistantFileLink, type InlinePathTarget } from "./parse"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { useAssistantFileLinkResolverContext } from "./provider"; import type { AssistantFileLinkSource } from "./resolver"; - -interface AssistantInlinePathLinkProps { - content: string; - parsed: InlinePathTarget; - onPress: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; - workspaceRoot?: string; - style: StyleProp; -} - -export function AssistantInlinePathLink({ - content, - parsed, - onPress, - workspaceRoot, - style, -}: AssistantInlinePathLinkProps) { - const handlePress = useCallback(() => onPress(parsed, "main"), [onPress, parsed]); - const handleAnchorClickCapture = useCallback( - (event: MouseEvent) => { - event.preventDefault(); - if (!isModifiedOpenEvent(event)) { - return; - } - event.stopPropagation(); - onPress(parsed, "side"); - }, - [onPress, parsed], - ); - - if (!isNative) { - return ( - - - - {content} - - - - ); - } - - return ( - - {content} - - ); -} +import { useFileLink } from "./use-file-link"; interface AssistantMarkdownLinkProps { source: AssistantFileLinkSource; style: StyleProp; - onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; - onPrefetch: (source: AssistantFileLinkSource) => void; - workspaceRoot?: string; children: ReactNode; } -export function AssistantMarkdownLink({ - source, - style, - onPress, - onPrefetch, - workspaceRoot, - children, -}: AssistantMarkdownLinkProps) { +export function AssistantMarkdownLink({ source, style, children }: AssistantMarkdownLinkProps) { const [hovered, setHovered] = useState(false); - const href = source.href; - const handlePress = useCallback(() => onPress(source, "main"), [onPress, source]); - const handleAnchorClickCapture = useCallback( - (event: MouseEvent) => { - event.preventDefault(); - if (!isModifiedOpenEvent(event)) { - return; - } - event.stopPropagation(); - onPress(source, "side"); - }, - [onPress, source], + const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source); + const { configRef } = useAssistantFileLinkResolverContext(); + const workspaceRoot = configRef.current.workspaceRoot; + const tooltipPath = useMemo( + () => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null), + [target, workspaceRoot], ); - const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]); - const handleHoverIn = useCallback(() => { + const handleAnchorClickCapture = useStableEvent((event: MouseEvent) => { + event.preventDefault(); + if (!isModifiedOpenEvent(event)) { + return; + } + event.stopPropagation(); + onAuxPress(); + }); + const handleHoverIn = useStableEvent(() => { setHovered(true); - handlePrefetch(); - }, [handlePrefetch]); - const handleHoverOut = useCallback(() => setHovered(false), []); + onHoverIn(); + }); + const handleHoverOut = useStableEvent(() => setHovered(false)); const hoveredTextStyle = useMemo>( () => [style, hovered && { textDecorationLine: "underline" as const }], [style, hovered], ); - const tooltipFilePath = useMemo( - () => getMarkdownLinkTooltipFilePath(source.href, workspaceRoot), - [source.href, workspaceRoot], - ); + if (isNative) { return ( - - {children} - + + + {children} + + ); } const anchor = ( @@ -146,10 +77,7 @@ export function AssistantMarkdownLink({ ); - if (tooltipFilePath) { - return {anchor}; - } - return anchor; + return {anchor}; } interface AssistantMarkdownCodeLinkProps { @@ -157,9 +85,6 @@ interface AssistantMarkdownCodeLinkProps { inheritedStyles: TextStyle; codeInlineStyle: TextStyle; linkStyle: TextStyle; - onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; - onPrefetch: (source: AssistantFileLinkSource) => void; - workspaceRoot?: string; children: ReactNode; } @@ -168,9 +93,6 @@ export function AssistantMarkdownCodeLink({ inheritedStyles, codeInlineStyle, linkStyle, - onPress, - onPrefetch, - workspaceRoot, children, }: AssistantMarkdownCodeLinkProps) { const style = useMemo( @@ -178,74 +100,14 @@ export function AssistantMarkdownCodeLink({ [inheritedStyles, codeInlineStyle, linkStyle], ); return ( - + {children} ); } -interface AssistantInlineCodePathLinkProps { - content: string; - inheritedStyles: TextStyle; - codeInlineStyle: TextStyle; - linkStyle: TextStyle; - onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; - onPrefetch: (source: AssistantFileLinkSource) => void; - workspaceRoot?: string; -} - -export function AssistantInlineCodePathLink({ - content, - inheritedStyles, - codeInlineStyle, - linkStyle, - onPress, - onPrefetch, - workspaceRoot, -}: AssistantInlineCodePathLinkProps) { - const source = useMemo( - () => ({ - href: content, - text: content, - sourceType: "inline-code", - }), - [content], - ); - - return ( - - {content} - - ); -} - -function getMarkdownLinkTooltipFilePath( - href: string, - workspaceRoot: string | undefined, -): string | null { - const classification = classifyAssistantFileLink(href, { workspaceRoot }); - if (classification?.kind !== "directFile") { - return null; - } - return formatInlinePathTargetForTooltip(classification.target, workspaceRoot); -} - function formatInlinePathTargetForTooltip( - target: InlinePathTarget, + target: { path: string; lineStart?: number; lineEnd?: number }, workspaceRoot: string | undefined, ): string { let result = relativizePathToWorkspace(target.path, workspaceRoot); @@ -276,6 +138,40 @@ function relativizePathToWorkspace(filePath: string, workspaceRoot: string | und return filePath; } +interface AssistantInlineCodePathLinkProps { + content: string; + inheritedStyles: TextStyle; + codeInlineStyle: TextStyle; + linkStyle: TextStyle; +} + +export function AssistantInlineCodePathLink({ + content, + inheritedStyles, + codeInlineStyle, + linkStyle, +}: AssistantInlineCodePathLinkProps) { + const source = useMemo( + () => ({ + href: content, + text: content, + sourceType: "inline-code", + }), + [content], + ); + + return ( + + {content} + + ); +} + const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = { // RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps // the tooltip wrapper from breaking inline link flow. @@ -284,7 +180,13 @@ const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = { const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"]; -function FileLinkHoverTooltip({ filePath, children }: { filePath: string; children: ReactNode }) { +function FileLinkHoverTooltip({ + filePath, + children, +}: { + filePath: string | null; + children: ReactNode; +}) { if (!isWeb) { return children; } @@ -293,19 +195,21 @@ function FileLinkHoverTooltip({ filePath, children }: { filePath: string; childr {children} - - - - {filePath} - - - - - click for side pane + {filePath ? ( + + + + {filePath} + + + + click for side pane + + - - + + ) : null} ); } diff --git a/packages/app/src/assistant-file-links/provider.tsx b/packages/app/src/assistant-file-links/provider.tsx new file mode 100644 index 000000000..77d0b2e03 --- /dev/null +++ b/packages/app/src/assistant-file-links/provider.tsx @@ -0,0 +1,87 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + type MutableRefObject, + type ReactNode, +} from "react"; +import React from "react"; +import type { ToastApi } from "@/components/toast-host"; +import type { OpenFileDisposition } from "@/workspace/file-open"; +import type { InlinePathTarget } from "./parse"; +import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver"; + +export interface AssistantFileLinkDaemonClient { + getDirectorySuggestions: GetDirectorySuggestions; +} + +export interface AssistantFileLinkResolverConfig { + client?: AssistantFileLinkDaemonClient | null; + serverId?: string; + workspaceRoot?: string; + onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; + toast?: ToastApi | null; +} + +export interface AssistantFileLinkResolverProviderProps extends AssistantFileLinkResolverConfig { + children: ReactNode; +} + +export interface AssistantFileLinkResolverContextValue { + configRef: MutableRefObject; + getDirectorySuggestions: GetDirectorySuggestions; +} + +const AssistantFileLinkResolverContext = + createContext(null); + +export function AssistantFileLinkResolverProvider({ + client, + serverId, + workspaceRoot, + onOpenWorkspaceFile, + toast, + children, +}: AssistantFileLinkResolverProviderProps) { + const configRef = useRef({ + client, + serverId, + workspaceRoot, + onOpenWorkspaceFile, + toast, + }); + configRef.current = { client, serverId, workspaceRoot, onOpenWorkspaceFile, toast }; + + const getDirectorySuggestions = useCallback(async (input) => { + const activeClient = configRef.current.client; + if (!activeClient) { + return { entries: [], error: null }; + } + + const result = await activeClient.getDirectorySuggestions(input); + return { entries: result.entries, error: result.error }; + }, []); + + const value = useMemo( + () => ({ configRef, getDirectorySuggestions }), + [getDirectorySuggestions], + ); + + return ( + + {children} + + ); +} + +export function useAssistantFileLinkResolverContext(): AssistantFileLinkResolverContextValue { + const context = useContext(AssistantFileLinkResolverContext); + if (!context) { + throw new Error("AssistantFileLinkResolverProvider is required for assistant file links."); + } + return context; +} + +export type { AssistantFileLinkContext }; diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index d085611e0..865e95e09 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -1,16 +1,15 @@ import { describe, expect, it, vi } from "vitest"; import { - createAssistantFileLinkResolver, + classifyForResolution, + fetchDaemonResolution, getAssistantFileLinkToken, + UnresolvedFileLinkError, type AssistantFileLinkContext, type DirectorySuggestionEntry, type DirectorySuggestionResult, } from "./resolver"; -import type { OpenFileDisposition } from "@/workspace/file-open"; -import type { InlinePathTarget } from "./parse"; const CONTEXT: AssistantFileLinkContext = { - serverId: "server-1", workspaceRoot: "/Users/test/project", }; @@ -20,554 +19,201 @@ function resolvedSuggestions( return { entries, error: null }; } -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve; - reject = promiseReject; - }); - - return { promise, resolve, reject }; +function suggestionsFromMap(entriesByQuery: Record): { + getDirectorySuggestions: ReturnType; + searches: Array<{ + query: string; + cwd: string; + matchMode: "suffix"; + limit: number; + }>; +} { + const searches: Array<{ + query: string; + cwd: string; + matchMode: "suffix"; + limit: number; + }> = []; + const getDirectorySuggestions = vi.fn( + async (input: { + query: string; + cwd: string; + includeFiles: true; + includeDirectories: false; + matchMode: "suffix"; + limit: number; + }) => { + searches.push({ + query: input.query, + cwd: input.cwd, + matchMode: input.matchMode, + limit: input.limit, + }); + return resolvedSuggestions(entriesByQuery[input.query] ?? []); + }, + ); + return { getDirectorySuggestions, searches }; } -interface DirectorySearch { - query: string; - cwd: string; - includeFiles: true; - includeDirectories: false; - matchMode: "suffix"; - limit: number; -} +describe("classifyForResolution", () => { + it("returns the directFile target synchronously", () => { + const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT); -interface OpenedFile { - target: InlinePathTarget; - disposition: OpenFileDisposition; -} - -class FakeWorkspaceFiles { - readonly searches: DirectorySearch[] = []; - readonly openedFiles: OpenedFile[] = []; - readonly unresolvedTokens: string[] = []; - - constructor(private readonly entriesByQuery: Record) {} - - createResolver() { - return createAssistantFileLinkResolver({ - getDirectorySuggestions: this.getDirectorySuggestions, - openWorkspaceFile: this.openWorkspaceFile, - openExternalUrl: async () => {}, - onUnresolvedFileCandidate: this.onUnresolvedFileCandidate, + expect(result).toEqual({ + kind: "resolved", + value: { + kind: "file", + target: { + raw: "src/components/message.tsx#L33", + path: "/Users/test/project/src/components/message.tsx", + lineStart: 33, + lineEnd: undefined, + }, + }, }); - } + }); - private getDirectorySuggestions = async ( - search: DirectorySearch, - ): Promise => { - this.searches.push(search); - return resolvedSuggestions(this.entriesByQuery[search.query] ?? []); - }; + it("preserves line ranges on direct workspace files", () => { + const result = classifyForResolution({ href: "src/components/message.tsx:33-40" }, CONTEXT); - private openWorkspaceFile = ( - target: InlinePathTarget, - disposition: OpenFileDisposition, - ): void => { - this.openedFiles.push({ target, disposition }); - }; + expect(result).toEqual({ + kind: "resolved", + value: { + kind: "file", + target: { + raw: "src/components/message.tsx:33-40", + path: "/Users/test/project/src/components/message.tsx", + lineStart: 33, + lineEnd: 40, + }, + }, + }); + }); - private onUnresolvedFileCandidate = (token: string): void => { - this.unresolvedTokens.push(token); - }; -} - -describe("assistant file link resolver", () => { - it("dedupes in-flight prefetches and serves the click from cache", async () => { - const suggestions = vi.fn(async () => - resolvedSuggestions([{ path: "src/dumm.md", kind: "file" }]), + it("flags basename inline-code as a daemon lookup keyed by suggestion query", () => { + const result = classifyForResolution( + { href: "file.ts:12", text: "file.ts:12", sourceType: "inline-code" }, + CONTEXT, ); - const openWorkspaceFile = vi.fn(); - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl, - }); - const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; - await Promise.all([ - resolver.prefetch({ context: CONTEXT, source }), - resolver.prefetch({ context: CONTEXT, source }), - ]); - const result = await resolver.open({ context: CONTEXT, source, disposition: "main" }); - - expect(suggestions).toHaveBeenCalledTimes(1); - expect(suggestions).toHaveBeenCalledWith({ - query: "dumm.md", - cwd: "/Users/test/project", - includeFiles: true, - includeDirectories: false, - matchMode: "suffix", - limit: 1, - }); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "dumm.md", - path: "/Users/test/project/src/dumm.md", - lineStart: undefined, + expect(result).toEqual({ + kind: "needsLookup", + ambiguousQuery: "file.ts", + token: "file.ts:12", + target: { + raw: "file.ts:12", + path: "/Users/test/project/file.ts", + lineStart: 12, lineEnd: undefined, }, - "main", + }); + }); + + it("keeps explicit external URLs external", () => { + const result = classifyForResolution({ href: "http://dumm.md", text: "dumm.md" }, CONTEXT); + + expect(result).toEqual({ + kind: "resolved", + value: { kind: "external", url: "http://dumm.md" }, + }); + }); + + it("keeps auto-linkified normal domains external", () => { + const result = classifyForResolution( + { href: "http://google.com", text: "google.com", markup: "linkify" }, + CONTEXT, ); - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(result.opened).toBe(true); + + expect(result).toEqual({ + kind: "resolved", + value: { kind: "external", url: "http://google.com" }, + }); }); - it("click consumes an in-flight hover resolution", async () => { - const deferred = createDeferred(); - const suggestions = vi.fn(() => deferred.promise); - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" }; + it("returns ignored for non-file-looking content", () => { + const result = classifyForResolution({ href: "" }, CONTEXT); - const prefetch = resolver.prefetch({ context: CONTEXT, source }); - const opened = resolver.open({ context: CONTEXT, source, disposition: "main" }); - deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); - - await prefetch; - const result = await opened; - - expect(suggestions).toHaveBeenCalledTimes(1); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "dumm.md", - path: "/Users/test/project/docs/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }, - "main", - ); - expect(result.opened).toBe(true); + expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } }); }); +}); - it("retries a click after hover prefetch fails to query suggestions", async () => { - const suggestions = vi - .fn() - .mockRejectedValueOnce(new Error("daemon unavailable")) - .mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); - const openWorkspaceFile = vi.fn(); - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl, - }); - const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; - - const prefetchResult = await resolver.prefetch({ context: CONTEXT, source }); - const openResult = await resolver.open({ context: CONTEXT, source, disposition: "main" }); - - expect(prefetchResult).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - }); - expect(suggestions).toHaveBeenCalledTimes(2); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "dumm.md", - path: "/Users/test/project/docs/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }, - "main", - ); - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(openResult.opened).toBe(true); - }); - - it("does not cache unresolved candidates", async () => { - const suggestions = vi - .fn() - .mockResolvedValueOnce(resolvedSuggestions([])) - .mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); - const openWorkspaceFile = vi.fn(); - const openExternalUrl = vi.fn(); - const onUnresolvedFileCandidate = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl, - onUnresolvedFileCandidate, - }); - const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; - - const first = await resolver.open({ context: CONTEXT, source, disposition: "main" }); - const second = await resolver.open({ context: CONTEXT, source, disposition: "main" }); - - expect(first).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - opened: false, - }); - expect(second.opened).toBe(true); - expect(suggestions).toHaveBeenCalledTimes(2); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "dumm.md", - path: "/Users/test/project/docs/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }, - "main", - ); - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1); - }); - - it("keys cache entries by server, workspace, and token", async () => { - const suggestions = vi - .fn() - .mockResolvedValueOnce(resolvedSuggestions([{ path: "one/dumm.md", kind: "file" }])) - .mockResolvedValueOnce(resolvedSuggestions([{ path: "two/dumm.md", kind: "file" }])); - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; - - await resolver.open({ context: CONTEXT, source, disposition: "main" }); - await resolver.open({ - context: { serverId: "server-1", workspaceRoot: "/Users/test/other" }, - source, - disposition: "main", - }); - - expect(suggestions).toHaveBeenCalledTimes(2); - expect(openWorkspaceFile).toHaveBeenLastCalledWith( - { - raw: "dumm.md", - path: "/Users/test/other/two/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }, - "main", - ); - }); - - it("does not apply stale async results after the active context changes", async () => { - const deferred = createDeferred(); - let isCurrent = true; - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(() => deferred.promise), - openWorkspaceFile, - openExternalUrl: vi.fn(), - isCurrentContext: () => isCurrent, - }); - - const opened = resolver.open({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }, - disposition: "main", - }); - isCurrent = false; - deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }])); - const result = await opened; - - expect(openWorkspaceFile).not.toHaveBeenCalled(); - expect(result.opened).toBe(false); - expect(result.kind).toBe("file"); - }); - - it("opens direct workspace file links without querying suggestions", async () => { - const suggestions = vi.fn(async () => resolvedSuggestions([])); - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "src/components/message.tsx#L33" }, - disposition: "main", - }); - - expect(suggestions).not.toHaveBeenCalled(); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "src/components/message.tsx#L33", - path: "/Users/test/project/src/components/message.tsx", - lineStart: 33, - lineEnd: undefined, - }, - "main", - ); - expect(result.opened).toBe(true); - }); - - it("preserves direct workspace file line ranges", async () => { - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])), - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - - await resolver.open({ - context: CONTEXT, - source: { href: "src/components/message.tsx:33-40" }, - disposition: "main", - }); - - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "src/components/message.tsx:33-40", - path: "/Users/test/project/src/components/message.tsx", - lineStart: 33, - lineEnd: 40, - }, - "main", - ); - }); - - it("opens basename line refs when the daemon returns that exact filename", async () => { - const workspaceFiles = new FakeWorkspaceFiles({ +describe("fetchDaemonResolution", () => { + it("resolves daemon suggestions into workspace file targets", async () => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ "file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }], }); - const resolver = workspaceFiles.createResolver(); - const result = await resolver.open({ - context: { ...CONTEXT, workspaceRoot: "/Users/test/project" }, - source: { - href: "file.ts:12", - text: "file.ts:12", - sourceType: "inline-code", + const result = await fetchDaemonResolution({ + ambiguousQuery: "file.ts", + token: "file.ts:12", + target: { + raw: "file.ts:12", + path: "/Users/test/project/file.ts", + lineStart: 12, + lineEnd: undefined, }, - disposition: "main", + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, }); - expect(workspaceFiles.searches).toEqual([ + expect(searches).toEqual([ { query: "file.ts", cwd: "/Users/test/project", - includeFiles: true, - includeDirectories: false, matchMode: "suffix", limit: 1, }, ]); - expect(workspaceFiles.openedFiles).toEqual([ - { + expect(result).toEqual({ + raw: "file.ts:12", + path: "/Users/test/project/packages/app/src/file.ts", + lineStart: 12, + lineEnd: undefined, + }); + }); + + it("throws a typed unresolved error when the daemon finds no match", async () => { + const { getDirectorySuggestions } = suggestionsFromMap({}); + + await expect( + fetchDaemonResolution({ + ambiguousQuery: "src/file.ts", + token: "src/file.ts", target: { - raw: "file.ts:12", - path: "/Users/test/project/packages/app/src/file.ts", - lineStart: 12, + raw: "src/file.ts", + path: "/Users/test/project/src/file.ts", + lineStart: undefined, lineEnd: undefined, }, - disposition: "main", - }, - ]); - expect(result.opened).toBe(true); - }); - - it("reports inline-code subpaths as unresolved when suffix suggestions find no file", async () => { - const workspaceFiles = new FakeWorkspaceFiles({}); - const resolver = workspaceFiles.createResolver(); - - const result = await resolver.open({ - context: { ...CONTEXT, workspaceRoot: "/Users/test/project" }, - source: { - href: "src/file.ts", - text: "src/file.ts", - sourceType: "inline-code", - }, - disposition: "main", - }); - - expect(workspaceFiles.searches).toEqual([ - { - query: "src/file.ts", - cwd: "/Users/test/project", - includeFiles: true, - includeDirectories: false, - matchMode: "suffix", - limit: 1, - }, - ]); - expect(workspaceFiles.openedFiles).toEqual([]); - expect(workspaceFiles.unresolvedTokens).toEqual(["src/file.ts"]); - expect(result).toEqual({ - kind: "unresolvedFileCandidate", - token: "src/file.ts", - opened: false, - }); - }); - - it("passes side open disposition to workspace file links", async () => { - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])), - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - - await resolver.open({ - context: CONTEXT, - source: { href: "src/components/message.tsx#L33" }, - disposition: "side", - }); - - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "src/components/message.tsx#L33", - path: "/Users/test/project/src/components/message.tsx", - lineStart: 33, - lineEnd: undefined, - }, - "side", - ); - }); - - it("keeps explicit external URLs external", async () => { - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])), - openWorkspaceFile: vi.fn(), - openExternalUrl, - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md" }, - disposition: "main", - }); - - expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md"); - expect(result).toEqual({ - kind: "external", - url: "http://dumm.md", - opened: true, - }); - }); - - it("keeps auto-linkified normal domains external", async () => { - const suggestions = vi.fn(async () => resolvedSuggestions([])); - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile: vi.fn(), - openExternalUrl, - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://google.com", text: "google.com", markup: "linkify" }, - disposition: "main", - }); - - expect(suggestions).not.toHaveBeenCalled(); - expect(openExternalUrl).toHaveBeenCalledWith("http://google.com"); - expect(result).toEqual({ - kind: "external", - url: "http://google.com", - opened: true, - }); - }); - - it("keeps auto-linkified normal domain paths external", async () => { - const suggestions = vi.fn(async () => resolvedSuggestions([])); - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile: vi.fn(), - openExternalUrl, - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" }, - disposition: "main", - }); - - expect(suggestions).not.toHaveBeenCalled(); - expect(openExternalUrl).toHaveBeenCalledWith("http://openai.com/path"); - expect(result).toEqual({ - kind: "external", - url: "http://openai.com/path", - opened: true, - }); - }); - - it("does not open unresolved linkified markdown filenames in the browser", async () => { - const openWorkspaceFile = vi.fn(); - const openExternalUrl = vi.fn(); - const onUnresolvedFileCandidate = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])), - openWorkspaceFile, - openExternalUrl, - onUnresolvedFileCandidate, - }); - - const prefetchResult = await resolver.prefetch({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" }, - }); - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" }, - disposition: "main", - }); - - expect(openWorkspaceFile).not.toHaveBeenCalled(); - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(prefetchResult).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - }); - expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1); - expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md"); - expect(result).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - opened: false, - }); - }); - - it("keeps failed ambiguous resolution out of the browser", async () => { - const openExternalUrl = vi.fn(); - const onUnresolvedFileCandidate = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => { - throw new Error("daemon unavailable"); + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, }), - openWorkspaceFile: vi.fn(), - openExternalUrl, - onUnresolvedFileCandidate, - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }, - disposition: "main", - }); - - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md"); - expect(result).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - opened: false, - }); + ).rejects.toEqual(new UnresolvedFileLinkError("src/file.ts")); }); + it("throws a typed unresolved error when the daemon throws", async () => { + const getDirectorySuggestions = vi.fn(async () => { + throw new Error("daemon unavailable"); + }); + + await expect( + fetchDaemonResolution({ + ambiguousQuery: "dumm.md", + token: "dumm.md", + target: { + raw: "dumm.md", + path: "/Users/test/project/dumm.md", + lineStart: undefined, + lineEnd: undefined, + }, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }), + ).rejects.toEqual(new UnresolvedFileLinkError("dumm.md")); + }); +}); + +describe("getAssistantFileLinkToken", () => { it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => { expect( getAssistantFileLinkToken({ diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts index f1310c642..a43fbb46e 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -4,12 +4,6 @@ import { type AssistantFileLinkClassification, type InlinePathTarget, } from "./parse"; -import type { OpenFileDisposition } from "@/workspace/file-open"; - -export interface AssistantFileLinkContext { - serverId?: string; - workspaceRoot?: string; -} export interface AssistantFileLinkSource { href: string; @@ -19,6 +13,10 @@ export interface AssistantFileLinkSource { sourceType?: "inline-code"; } +export interface AssistantFileLinkContext { + workspaceRoot?: string; +} + export interface DirectorySuggestionEntry { path: string; kind: "file" | "directory"; @@ -29,152 +27,121 @@ export interface DirectorySuggestionResult { error: string | null; } -export interface AssistantFileLinkResolverDependencies { - getDirectorySuggestions: (input: { - query: string; - cwd: string; - includeFiles: true; - includeDirectories: false; - matchMode: "suffix"; - limit: number; - }) => Promise; - openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; - openExternalUrl: (url: string) => void | Promise; - onUnresolvedFileCandidate?: (token: string) => void; - isCurrentContext?: (context: AssistantFileLinkContext) => boolean; -} - -export interface AssistantFileLinkResolver { - prefetch(input: AssistantFileLinkPrefetchInput): Promise; - open(input: AssistantFileLinkOpenInput): Promise; -} - -export interface AssistantFileLinkPrefetchInput { - context: AssistantFileLinkContext; - source: AssistantFileLinkSource; -} - -export interface AssistantFileLinkOpenInput extends AssistantFileLinkPrefetchInput { - disposition: OpenFileDisposition; -} +export type GetDirectorySuggestions = (input: { + query: string; + cwd: string; + includeFiles: true; + includeDirectories: false; + matchMode: "suffix"; + limit: number; +}) => Promise; export type ResolvedAssistantFileLink = + | { kind: "external"; url: string } + | { kind: "file"; target: InlinePathTarget } + | { kind: "ignored" }; + +export type AssistantFileLinkResolution = + | { kind: "resolved"; value: ResolvedAssistantFileLink } | { - kind: "external"; - url: string; - } - | { - kind: "file"; - target: InlinePathTarget; - } - | { - kind: "unresolvedFileCandidate"; + kind: "needsLookup"; + ambiguousQuery: string; token: string; - } - | { - kind: "ignored"; + target: InlinePathTarget; }; -export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & { - opened: boolean; -}; - -type CachedAssistantFileLink = Exclude; - -interface ParsedAssistantFileLinkInteraction { +export interface FetchDaemonResolutionInput { + ambiguousQuery: string; token: string; - classification: AssistantFileLinkClassification; + target: InlinePathTarget; + workspaceRoot?: string; + getDirectorySuggestions: GetDirectorySuggestions; } -export function createAssistantFileLinkResolver( - dependencies: AssistantFileLinkResolverDependencies, -): AssistantFileLinkResolver { - const cache = new Map(); - const inFlight = new Map>(); +export class UnresolvedFileLinkError extends Error { + constructor(readonly token: string) { + super(`No file found for ${token}`); + this.name = "UnresolvedFileLinkError"; + } +} - async function resolve( - input: AssistantFileLinkPrefetchInput, - ): Promise { - const parsed = parseInteraction(input); - if (!parsed) { - return { kind: "ignored" }; - } +export async function fetchDaemonResolution({ + ambiguousQuery, + token, + target, + workspaceRoot, + getDirectorySuggestions, +}: FetchDaemonResolutionInput): Promise { + const trimmedRoot = workspaceRoot?.trim(); + if (!trimmedRoot) { + throw new UnresolvedFileLinkError(token); + } - if (parsed.classification.kind === "external") { - return { kind: "external", url: parsed.classification.raw }; - } + let suggestions: DirectorySuggestionResult; + try { + suggestions = await getDirectorySuggestions({ + query: ambiguousQuery, + cwd: trimmedRoot, + includeFiles: true, + includeDirectories: false, + matchMode: "suffix", + limit: 1, + }); + } catch { + throw new UnresolvedFileLinkError(token); + } - if ( - parsed.classification.kind === "directFile" && - !shouldResolveDirectFileThroughSuggestions({ - context: input.context, - source: input.source, - token: parsed.token, - target: parsed.classification.target, - }) - ) { - return { kind: "file", target: parsed.classification.target }; - } - - const key = getResolutionKey(input.context, parsed.token); - const cached = cache.get(key); - if (cached) { - return cached; - } - - const active = inFlight.get(key); - if (active) { - return active; - } - - const request = resolveAmbiguousCandidate({ - context: input.context, - token: parsed.token, - target: parsed.classification.target, - getDirectorySuggestions: dependencies.getDirectorySuggestions, - }) - .then((result) => { - if (result.kind === "file") { - cache.set(key, result); - } - inFlight.delete(key); - return result; - }) - .catch((): CachedAssistantFileLink => { - inFlight.delete(key); - return { kind: "unresolvedFileCandidate", token: parsed.token }; - }); - - inFlight.set(key, request); - return request; + const match = suggestions.entries.find((entry) => entry.kind === "file"); + if (!match || suggestions.error) { + throw new UnresolvedFileLinkError(token); } return { - prefetch(input) { - return resolve(input); - }, - async open(input) { - const resolved = await resolve(input); - if (!canApplyResult(input.context, dependencies.isCurrentContext)) { - return { ...resolved, opened: false }; - } + ...target, + path: joinWorkspacePath(trimmedRoot, match.path), + }; +} - if (resolved.kind === "file") { - dependencies.openWorkspaceFile(resolved.target, input.disposition); - return { ...resolved, opened: true }; - } +export function classifyForResolution( + source: AssistantFileLinkSource, + context: AssistantFileLinkContext, +): AssistantFileLinkResolution { + const token = getAssistantFileLinkToken(source).trim(); + if (!token) { + return { kind: "resolved", value: { kind: "ignored" } }; + } - if (resolved.kind === "external") { - await dependencies.openExternalUrl(resolved.url); - return { ...resolved, opened: true }; - } + const classification = classifyAssistantFileLink(token, { + workspaceRoot: context.workspaceRoot, + }); + if (!classification) { + return { kind: "resolved", value: { kind: "ignored" } }; + } + if (classification.kind === "external") { + return { kind: "resolved", value: { kind: "external", url: classification.raw } }; + } + if ( + classification.kind === "directFile" && + !shouldResolveDirectFileThroughSuggestions({ + context, + source, + token, + target: classification.target, + }) + ) { + return { kind: "resolved", value: { kind: "file", target: classification.target } }; + } - if (resolved.kind === "unresolvedFileCandidate") { - dependencies.onUnresolvedFileCandidate?.(resolved.token); - } + const workspaceRoot = context.workspaceRoot?.trim(); + if (!workspaceRoot) { + return { kind: "resolved", value: { kind: "ignored" } }; + } - return { ...resolved, opened: false }; - }, + return { + kind: "needsLookup", + ambiguousQuery: getAmbiguousSuggestionQuery(classification.target, workspaceRoot), + token, + target: classification.target, }; } @@ -189,59 +156,10 @@ export function getAssistantFileLinkToken(source: AssistantFileLinkSource): stri return source.href; } -function parseInteraction( - input: AssistantFileLinkPrefetchInput, -): ParsedAssistantFileLinkInteraction | null { - const token = getAssistantFileLinkToken(input.source).trim(); - if (!token) { - return null; - } - - const classification = classifyAssistantFileLink(token, { - workspaceRoot: input.context.workspaceRoot, - }); - if (!classification) { - return null; - } - - return { token, classification }; -} - -async function resolveAmbiguousCandidate(input: { - context: AssistantFileLinkContext; - token: string; - target: InlinePathTarget; - getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"]; -}): Promise { - const workspaceRoot = input.context.workspaceRoot?.trim(); - if (!workspaceRoot) { - return { kind: "unresolvedFileCandidate", token: input.token }; - } - - const query = getAmbiguousSuggestionQuery(input.target, workspaceRoot); - const suggestions = await input.getDirectorySuggestions({ - query, - cwd: workspaceRoot, - includeFiles: true, - includeDirectories: false, - matchMode: "suffix", - limit: 1, - }); - const match = suggestions.entries.find((entry) => entry.kind === "file"); - if (!match || suggestions.error) { - return { kind: "unresolvedFileCandidate", token: input.token }; - } - - return { - kind: "file", - target: { - ...input.target, - path: joinWorkspacePath(workspaceRoot, match.path), - }, - }; -} - -function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: string): string { +export function getAmbiguousSuggestionQuery( + target: InlinePathTarget, + workspaceRoot: string, +): string { const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, ""); const normalizedPath = target.path.replace(/\\/g, "/"); const prefix = `${normalizedRoot}/`; @@ -253,7 +171,7 @@ function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: st return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath; } -function shouldResolveDirectFileThroughSuggestions(input: { +export function shouldResolveDirectFileThroughSuggestions(input: { context: AssistantFileLinkContext; source: AssistantFileLinkSource; token: string; @@ -285,23 +203,14 @@ function isAbsoluteInlineCodeToken(token: string): boolean { ); } -function getResolutionKey(context: AssistantFileLinkContext, token: string): string { - return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0"); -} - function isLinkifiedSource(source: AssistantFileLinkSource): boolean { return source.markup === "linkify" || source.sourceInfo === "auto"; } -function canApplyResult( - context: AssistantFileLinkContext, - isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"], -): boolean { - return isCurrentContext ? isCurrentContext(context) : true; -} - function joinWorkspacePath(workspaceRoot: string, relativePath: string): string { const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, ""); const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, ""); return root ? `${root}/${child}` : child; } + +export type { AssistantFileLinkClassification }; diff --git a/packages/app/src/assistant-file-links/use-file-link.test.tsx b/packages/app/src/assistant-file-links/use-file-link.test.tsx new file mode 100644 index 000000000..e35dff98e --- /dev/null +++ b/packages/app/src/assistant-file-links/use-file-link.test.tsx @@ -0,0 +1,328 @@ +/** + * @vitest-environment jsdom + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import React, { useCallback, useMemo, useState, type ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import type { InlinePathTarget } from "./parse"; +import { AssistantFileLinkResolverProvider } from "./provider"; +import type { DirectorySuggestionResult } from "./resolver"; +import { useFileLink } from "./use-file-link"; +import type { OpenFileDisposition } from "@/workspace/file-open"; + +vi.mock("@/utils/open-external-url", () => ({ + openExternalUrl: vi.fn(async () => {}), +})); + +const SOURCE = { + href: "http://dumm.md", + text: "dumm.md", + markup: "linkify", +}; + +function resolvedSuggestions( + entries: DirectorySuggestionResult["entries"], +): DirectorySuggestionResult { + return { entries, error: null }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + return { promise, resolve, reject }; +} + +interface OpenedFile { + target: InlinePathTarget; + disposition: OpenFileDisposition; +} + +interface TestClient { + getDirectorySuggestions: (input: { + query: string; + cwd: string; + includeFiles: true; + includeDirectories: false; + matchMode: "suffix"; + limit: number; + }) => Promise; +} + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function createWrapper(input: { + client: TestClient; + openedFiles: OpenedFile[]; + toast?: { + show: ReturnType; + copied: ReturnType; + error: ReturnType; + }; +}) { + const queryClient = createQueryClient(); + return function Wrapper({ children }: { children: ReactNode }) { + const openWorkspaceFile = useCallback( + (target: InlinePathTarget, disposition: OpenFileDisposition) => { + input.openedFiles.push({ target, disposition }); + }, + [], + ); + + return ( + + + {children} + + + ); + }; +} + +describe("useFileLink", () => { + it("returns the same object across no-op parent rerenders", () => { + const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([])); + const queryClient = createQueryClient(); + const Provider = AssistantFileLinkResolverProvider as React.ComponentType< + Omit, "children"> & { + children?: ReactNode; + } + >; + + function ChurningProviderWrapper({ children }: { children: ReactNode }) { + return React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + Provider, + { + client: { getDirectorySuggestions }, + serverId: "server-1", + workspaceRoot: "/Users/test/project", + onOpenWorkspaceFile: () => {}, + toast: { show: vi.fn(), copied: vi.fn(), error: vi.fn() }, + }, + children, + ), + ); + } + + const { result, rerender } = renderHook(() => useFileLink({ ...SOURCE }), { + wrapper: ChurningProviderWrapper, + }); + const first = result.current; + + rerender(); + + expect(result.current).toBe(first); + expect(result.current.onHoverIn).toBe(first.onHoverIn); + expect(result.current.onPress).toBe(first.onPress); + expect(result.current.onAuxPress).toBe(first.onAuxPress); + expect(result.current.open).toBe(first.open); + }); + + it("does not cache unresolved lookups forever", async () => { + const getDirectorySuggestions = vi + .fn() + .mockResolvedValueOnce(resolvedSuggestions([])) + .mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); + const openedFiles: OpenedFile[] = []; + const toast = { show: vi.fn(), copied: vi.fn(), error: vi.fn() }; + const { result } = renderHook(() => useFileLink(SOURCE), { + wrapper: createWrapper({ + client: { getDirectorySuggestions }, + openedFiles, + toast, + }), + }); + + act(() => { + result.current.onPress(); + }); + await waitFor(() => { + expect(toast.show).toHaveBeenCalledWith("No file found for dumm.md", { + variant: "error", + testID: "assistant-file-link-not-found-toast", + }); + }); + + act(() => { + result.current.onPress(); + }); + await waitFor(() => { + expect(openedFiles).toEqual([ + { + target: { + raw: "dumm.md", + path: "/Users/test/project/docs/dumm.md", + lineStart: undefined, + lineEnd: undefined, + }, + disposition: "main", + }, + ]); + }); + expect(getDirectorySuggestions).toHaveBeenCalledTimes(2); + }); + + it("click retries after hover prefetch fails", async () => { + const getDirectorySuggestions = vi + .fn() + .mockRejectedValueOnce(new Error("daemon unavailable")) + .mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); + const openedFiles: OpenedFile[] = []; + const { result } = renderHook(() => useFileLink(SOURCE), { + wrapper: createWrapper({ + client: { getDirectorySuggestions }, + openedFiles, + }), + }); + + act(() => { + result.current.onHoverIn(); + }); + await waitFor(() => { + expect(getDirectorySuggestions).toHaveBeenCalledTimes(1); + }); + + act(() => { + result.current.onPress(); + }); + await waitFor(() => { + expect(openedFiles).toHaveLength(1); + }); + expect(getDirectorySuggestions).toHaveBeenCalledTimes(2); + }); + + it("dedupes two links pointing at the same source", async () => { + const deferred = createDeferred(); + const getDirectorySuggestions = vi.fn(() => deferred.promise); + const openedFiles: OpenedFile[] = []; + const { result } = renderHook( + () => ({ + first: useFileLink(SOURCE), + second: useFileLink(SOURCE), + }), + { + wrapper: createWrapper({ + client: { getDirectorySuggestions }, + openedFiles, + }), + }, + ); + + act(() => { + result.current.first.onHoverIn(); + result.current.second.onHoverIn(); + }); + await waitFor(() => { + expect(getDirectorySuggestions).toHaveBeenCalledTimes(1); + }); + deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); + await waitFor(() => { + expect(result.current.first.target?.path).toBe("/Users/test/project/docs/dumm.md"); + expect(result.current.second.target?.path).toBe("/Users/test/project/docs/dumm.md"); + }); + }); + + it("hover then click uses the prefetched result", async () => { + const getDirectorySuggestions = vi.fn(async () => + resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]), + ); + const openedFiles: OpenedFile[] = []; + const { result } = renderHook(() => useFileLink(SOURCE), { + wrapper: createWrapper({ + client: { getDirectorySuggestions }, + openedFiles, + }), + }); + + act(() => { + result.current.onHoverIn(); + }); + await waitFor(() => { + expect(result.current.target?.path).toBe("/Users/test/project/docs/dumm.md"); + }); + + act(() => { + result.current.onPress(); + }); + await waitFor(() => { + expect(openedFiles).toHaveLength(1); + }); + expect(getDirectorySuggestions).toHaveBeenCalledTimes(1); + }); + + it("does not open a stale result after the workspace changes", async () => { + const deferred = createDeferred(); + const getDirectorySuggestions = vi.fn(() => deferred.promise); + const openedFiles: OpenedFile[] = []; + const queryClient = createQueryClient(); + + function Wrapper({ children }: { children: ReactNode }) { + const [workspaceRoot, setWorkspaceRoot] = useState("/Users/test/project"); + const client = useMemo(() => ({ getDirectorySuggestions }), []); + const openWorkspaceFile = useCallback( + (target: InlinePathTarget, disposition: OpenFileDisposition) => { + openedFiles.push({ target, disposition }); + }, + [], + ); + return ( + + + + {children} + + + + ); + } + + const { result } = renderHook( + () => ({ + link: useFileLink(SOURCE), + setWorkspaceRoot: React.useContext(WorkspaceSwitchContext), + }), + { wrapper: Wrapper }, + ); + + act(() => { + result.current.link.onPress(); + }); + act(() => { + result.current.setWorkspaceRoot("/Users/test/other"); + }); + deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); + + await waitFor(() => { + expect(getDirectorySuggestions).toHaveBeenCalledTimes(1); + }); + expect(openedFiles).toEqual([]); + }); +}); + +const WorkspaceSwitchContext = React.createContext<(workspaceRoot: string) => void>(() => {}); diff --git a/packages/app/src/assistant-file-links/use-file-link.ts b/packages/app/src/assistant-file-links/use-file-link.ts new file mode 100644 index 000000000..9f129011d --- /dev/null +++ b/packages/app/src/assistant-file-links/use-file-link.ts @@ -0,0 +1,347 @@ +import { useCallback, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import type { OpenFileDisposition } from "@/workspace/file-open"; +import { openExternalUrl } from "@/utils/open-external-url"; +import type { InlinePathTarget } from "./parse"; +import { + useAssistantFileLinkResolverContext, + type AssistantFileLinkResolverContextValue, +} from "./provider"; +import { + classifyForResolution, + fetchDaemonResolution, + UnresolvedFileLinkError, + type AssistantFileLinkResolution, + type AssistantFileLinkSource, +} from "./resolver"; + +export interface UseFileLinkResult { + target: InlinePathTarget | null; + onHoverIn: () => void; + onPress: () => void; + onAuxPress: () => void; + open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; +} + +export interface AssistantFileLinkActions { + open(source: AssistantFileLinkSource, disposition: OpenFileDisposition): void; + canOpen(source: AssistantFileLinkSource): boolean; + canResolveFile(source: AssistantFileLinkSource): boolean; +} + +type AssistantFileLinkQueryKey = readonly [ + "assistantFileLink", + string | null, + string | null, + string, +]; + +const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const; + +export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult { + const context = useAssistantFileLinkResolverContext(); + const queryClient = useQueryClient(); + const stableSource = useStableSource(source); + const activeConfig = context.configRef.current; + const workspaceRoot = activeConfig.workspaceRoot; + const serverId = activeConfig.serverId; + const resolution = useMemo( + () => + classifyForResolution(stableSource, { + workspaceRoot, + }), + [stableSource, workspaceRoot], + ); + const queryKey = useMemo( + () => + resolution.kind === "needsLookup" + ? assistantFileLinkQueryKey({ + serverId, + workspaceRoot, + ambiguousQuery: resolution.ambiguousQuery, + }) + : DISABLED_QUERY_KEY, + [resolution, serverId, workspaceRoot], + ); + + const query = useQuery({ + queryKey, + queryFn: () => { + if (resolution.kind !== "needsLookup") { + throw new Error("Assistant file link lookup requested for a sync link."); + } + return fetchDaemonResolution({ + ambiguousQuery: resolution.ambiguousQuery, + token: resolution.token, + target: resolution.target, + workspaceRoot, + getDirectorySuggestions: context.getDirectorySuggestions, + }); + }, + enabled: false, + retry: 0, + staleTime: Infinity, + }); + + const open = useStableEvent( + (nextSource: AssistantFileLinkSource, disposition: OpenFileDisposition) => { + openAssistantFileLink({ + source: nextSource, + disposition, + context, + queryClient, + }); + }, + ); + + const onHoverIn = useStableEvent(() => { + if (resolution.kind !== "needsLookup") { + return; + } + + void queryClient.prefetchQuery({ + queryKey, + queryFn: () => + fetchDaemonResolution({ + ambiguousQuery: resolution.ambiguousQuery, + token: resolution.token, + target: resolution.target, + workspaceRoot, + getDirectorySuggestions: context.getDirectorySuggestions, + }), + retry: 0, + staleTime: Infinity, + }); + }); + + const onPress = useStableEvent(() => { + open(stableSource, "main"); + }); + const onAuxPress = useStableEvent(() => { + open(stableSource, "side"); + }); + + const target = useMemo(() => { + if (resolution.kind === "resolved") { + return resolution.value.kind === "file" ? resolution.value.target : null; + } + return query.data ?? null; + }, [query.data, resolution]); + + return useMemo( + () => ({ target, onHoverIn, onPress, onAuxPress, open }), + [target, onHoverIn, onPress, onAuxPress, open], + ); +} + +export function useAssistantFileLinkActions(): AssistantFileLinkActions { + const context = useAssistantFileLinkResolverContext(); + const actionLink = useFileLink(ACTION_LINK_SOURCE); + + const open = useStableEvent( + (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => { + actionLink.open(source, disposition); + }, + ); + const canOpen = useCallback( + (source: AssistantFileLinkSource) => + canOpenAssistantFileLink(source, context.configRef.current.workspaceRoot), + [context.configRef], + ); + const canResolveFile = useCallback( + (source: AssistantFileLinkSource) => + canResolveAssistantFileLinkToFile(source, context.configRef.current.workspaceRoot), + [context.configRef], + ); + + return useMemo(() => ({ open, canOpen, canResolveFile }), [open, canOpen, canResolveFile]); +} + +function openAssistantFileLink(input: { + source: AssistantFileLinkSource; + disposition: OpenFileDisposition; + context: AssistantFileLinkResolverContextValue; + queryClient: ReturnType; +}): void { + const capturedConfig = input.context.configRef.current; + const capturedResolution = classifyForResolution(input.source, { + workspaceRoot: capturedConfig.workspaceRoot, + }); + + if (capturedResolution.kind === "resolved") { + void dispatchResolvedLink({ + resolution: capturedResolution, + disposition: input.disposition, + capturedServerId: capturedConfig.serverId, + capturedWorkspaceRoot: capturedConfig.workspaceRoot, + context: input.context, + }); + return; + } + + const capturedQueryKey = assistantFileLinkQueryKey({ + serverId: capturedConfig.serverId, + workspaceRoot: capturedConfig.workspaceRoot, + ambiguousQuery: capturedResolution.ambiguousQuery, + }); + + const run = async () => { + try { + const target = await input.queryClient.fetchQuery({ + queryKey: capturedQueryKey, + queryFn: () => + fetchDaemonResolution({ + ambiguousQuery: capturedResolution.ambiguousQuery, + token: capturedResolution.token, + target: capturedResolution.target, + workspaceRoot: capturedConfig.workspaceRoot, + getDirectorySuggestions: input.context.getDirectorySuggestions, + }), + retry: 0, + staleTime: Infinity, + }); + await dispatchFileTarget({ + target, + disposition: input.disposition, + capturedServerId: capturedConfig.serverId, + capturedWorkspaceRoot: capturedConfig.workspaceRoot, + context: input.context, + }); + } catch (error) { + await dispatchUnresolvedError({ + error, + fallbackToken: capturedResolution.token, + capturedServerId: capturedConfig.serverId, + capturedWorkspaceRoot: capturedConfig.workspaceRoot, + context: input.context, + }); + } + }; + + void run(); +} + +function canOpenAssistantFileLink( + source: AssistantFileLinkSource, + workspaceRoot: string | undefined, +): boolean { + const resolution = classifyForResolution(source, { workspaceRoot }); + return resolution.kind === "needsLookup" || resolution.value.kind !== "ignored"; +} + +function canResolveAssistantFileLinkToFile( + source: AssistantFileLinkSource, + workspaceRoot: string | undefined, +): boolean { + const resolution = classifyForResolution(source, { workspaceRoot }); + return resolution.kind === "needsLookup" || resolution.value.kind === "file"; +} + +function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSource { + const { href, text, markup, sourceInfo, sourceType } = source; + return useMemo( + () => ({ href, text, markup, sourceInfo, sourceType }), + [href, text, markup, sourceInfo, sourceType], + ); +} + +function assistantFileLinkQueryKey(input: { + serverId?: string; + workspaceRoot?: string; + ambiguousQuery: string; +}): AssistantFileLinkQueryKey { + return [ + "assistantFileLink", + input.serverId ?? null, + input.workspaceRoot ?? null, + input.ambiguousQuery, + ]; +} + +async function dispatchResolvedLink(input: { + resolution: Extract; + disposition: OpenFileDisposition; + capturedServerId?: string; + capturedWorkspaceRoot?: string; + context: AssistantFileLinkResolverContextValue; +}) { + const { value } = input.resolution; + if (value.kind === "file") { + await dispatchFileTarget({ + target: value.target, + disposition: input.disposition, + capturedServerId: input.capturedServerId, + capturedWorkspaceRoot: input.capturedWorkspaceRoot, + context: input.context, + }); + return; + } + if (value.kind === "external") { + await dispatchExternalUrl({ + url: value.url, + capturedServerId: input.capturedServerId, + capturedWorkspaceRoot: input.capturedWorkspaceRoot, + context: input.context, + }); + } +} + +async function dispatchFileTarget(input: { + target: InlinePathTarget; + disposition: OpenFileDisposition; + capturedServerId?: string; + capturedWorkspaceRoot?: string; + context: AssistantFileLinkResolverContextValue; +}) { + const current = input.context.configRef.current; + if ( + current.serverId !== input.capturedServerId || + current.workspaceRoot !== input.capturedWorkspaceRoot + ) { + return; + } + current.onOpenWorkspaceFile?.(input.target, input.disposition); +} + +async function dispatchExternalUrl(input: { + url: string; + capturedServerId?: string; + capturedWorkspaceRoot?: string; + context: AssistantFileLinkResolverContextValue; +}) { + const current = input.context.configRef.current; + if ( + current.serverId !== input.capturedServerId || + current.workspaceRoot !== input.capturedWorkspaceRoot + ) { + return; + } + await openExternalUrl(input.url); +} + +async function dispatchUnresolvedError(input: { + error: unknown; + fallbackToken: string; + capturedServerId?: string; + capturedWorkspaceRoot?: string; + context: AssistantFileLinkResolverContextValue; +}) { + const current = input.context.configRef.current; + if ( + current.serverId !== input.capturedServerId || + current.workspaceRoot !== input.capturedWorkspaceRoot + ) { + return; + } + const token = + input.error instanceof UnresolvedFileLinkError ? input.error.token : input.fallbackToken; + current.toast?.show(`No file found for ${token}`, { + variant: "error", + testID: "assistant-file-link-not-found-toast", + }); +} + +const ACTION_LINK_SOURCE: AssistantFileLinkSource = { + href: "", +}; diff --git a/packages/app/src/assistant-file-links/use-resolver.ts b/packages/app/src/assistant-file-links/use-resolver.ts deleted file mode 100644 index c3facded4..000000000 --- a/packages/app/src/assistant-file-links/use-resolver.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { useMemo, useRef } from "react"; -import type { DaemonClient } from "@server/client/daemon-client"; -import type { ToastApi } from "@/components/toast-host"; -import { - createAssistantFileLinkResolver, - type AssistantFileLinkContext, - type AssistantFileLinkOpenInput, - type AssistantFileLinkPrefetchInput, -} from "./resolver"; -import type { InlinePathTarget } from "./parse"; -import type { OpenFileDisposition } from "@/workspace/file-open"; -import { openExternalUrl } from "@/utils/open-external-url"; - -export interface UseAssistantFileLinkResolverOptions { - client?: DaemonClient | null; - serverId?: string; - workspaceRoot?: string; - onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; - toast?: ToastApi | null; -} - -export interface AssistantFileLinkActions { - prefetch(input: Omit): void; - open(input: Omit): void; -} - -export function useAssistantFileLinkResolver({ - client, - serverId, - workspaceRoot, - onOpenWorkspaceFile, - toast, -}: UseAssistantFileLinkResolverOptions): AssistantFileLinkActions { - const context: AssistantFileLinkContext = useMemo( - () => ({ - serverId, - workspaceRoot, - }), - [serverId, workspaceRoot], - ); - const latestContextRef = useRef(context); - latestContextRef.current = context; - - const resolver = useMemo( - () => - createAssistantFileLinkResolver({ - async getDirectorySuggestions(input) { - if (!client) { - return { entries: [], error: null }; - } - - const result = await client.getDirectorySuggestions(input); - return { - entries: result.entries, - error: result.error, - }; - }, - openWorkspaceFile(target, disposition) { - onOpenWorkspaceFile?.(target, disposition); - }, - openExternalUrl, - onUnresolvedFileCandidate(token) { - toast?.show(`No file found for ${token}`, { - variant: "error", - testID: "assistant-file-link-not-found-toast", - }); - }, - isCurrentContext(candidate) { - const current = latestContextRef.current; - return ( - current.serverId === candidate.serverId && - current.workspaceRoot === candidate.workspaceRoot - ); - }, - }), - [client, onOpenWorkspaceFile, toast], - ); - - return useMemo( - () => ({ - prefetch(input) { - void resolver.prefetch({ ...input, context }); - }, - open(input) { - void resolver.open({ ...input, context }); - }, - }), - [context, resolver], - ); -} diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 2826f8a8c..8db233236 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -74,7 +74,10 @@ import { type BottomAnchorLocalRequest, type BottomAnchorRouteRequest, } from "./use-bottom-anchor-controller"; -import { normalizeInlinePathTarget } from "@/assistant-file-links"; +import { + AssistantFileLinkResolverProvider, + normalizeInlinePathTarget, +} from "@/assistant-file-links"; import { createWorkspaceFileTabTarget, normalizeWorkspaceFileLocation, @@ -83,6 +86,7 @@ import { } from "@/workspace/file-open"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; +import { useStableEvent } from "@/hooks/use-stable-event"; import { isWeb } from "@/constants/platform"; import type { Theme } from "@/styles/theme"; @@ -322,7 +326,7 @@ const AgentStreamViewComponent = forwardRef { if (!target.path) { return; @@ -377,25 +381,11 @@ const AgentStreamViewComponent = forwardRef { - handleInlinePathPress({ raw: filePath, path: filePath }, "main"); - }, - [handleInlinePathPress], - ); + const handleToolCallOpenFile = useStableEvent((filePath: string) => { + handleInlinePathPress({ raw: filePath, path: filePath }, "main"); + }); const baseRenderModel = useMemo(() => { return buildAgentStreamRenderModel({ @@ -510,19 +500,25 @@ const AgentStreamViewComponent = forwardRef + > + + ); }, - [handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client, toast], + [client, handleInlinePathPress, resolvedServerId, streamRenderStrategy, toast, workspaceRoot], ); const renderThoughtItem = useCallback( diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 18015ad60..5cf4f99a0 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -68,9 +68,8 @@ import type { AgentAttachment } from "@server/shared/messages"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildToolCallPresentation } from "@/tool-calls/presentation"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; -import type { OpenFileDisposition } from "@/workspace/file-open"; import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list"; -import type { ToastApi } from "@/components/toast-host"; +import { useStableEvent } from "@/hooks/use-stable-event"; import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; @@ -92,12 +91,11 @@ import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; import { AssistantInlineCodePathLink, - classifyAssistantFileLink, type AssistantFileLinkSource, AssistantMarkdownCodeLink, AssistantMarkdownLink, type InlinePathTarget, - useAssistantFileLinkResolver, + useAssistantFileLinkActions, } from "@/assistant-file-links"; import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; @@ -717,11 +715,9 @@ export const LiveElapsed = memo(function LiveElapsed({ interface AssistantMessageProps { message: string; timestamp: number; - onInlinePathPress?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; workspaceRoot?: string; serverId?: string; client?: DaemonClient | null; - toast?: ToastApi | null; spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth"; } @@ -1563,11 +1559,9 @@ function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListVie export const AssistantMessage = memo(function AssistantMessage({ message, timestamp: _timestamp, - onInlinePathPress, workspaceRoot, serverId, client, - toast, spacing = "default", }: AssistantMessageProps) { const markdownParser = useMemo(() => { @@ -1583,36 +1577,14 @@ export const AssistantMessage = memo(function AssistantMessage({ return parser; }, []); - const fileLinkResolver = useAssistantFileLinkResolver({ - client, - serverId, - workspaceRoot, - onOpenWorkspaceFile: onInlinePathPress, - toast, + const fileLinkActions = useAssistantFileLinkActions(); + const handleMarkdownLinkPress = useStableEvent((url: string) => { + fileLinkActions.open({ href: url }, "main"); + // react-native-markdown-display opens the link itself when this returns true. + // We already handled it above, so return false to avoid duplicate opens. + return false; }); - const handleLinkPress = useCallback( - (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => { - fileLinkResolver.open({ source, disposition }); - }, - [fileLinkResolver], - ); - const handleLinkPrefetch = useCallback( - (source: AssistantFileLinkSource) => { - fileLinkResolver.prefetch({ source }); - }, - [fileLinkResolver], - ); - const handleMarkdownLinkPress = useCallback( - (url: string) => { - fileLinkResolver.open({ source: { href: url }, disposition: "main" }); - // react-native-markdown-display opens the link itself when this returns true. - // We already handled it above, so return false to avoid duplicate opens. - return false; - }, - [fileLinkResolver], - ); - const markdownRules = useMemo(() => { return { text: ( @@ -1684,12 +1656,13 @@ export const AssistantMessage = memo(function AssistantMessage({ ) => { const content = node.content ?? ""; const isLinkedInlineCode = nodeHasParentType(parent, "link"); - const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot }); + const inlineCodeSource: AssistantFileLinkSource = { + href: content, + text: content, + sourceType: "inline-code", + }; const shouldResolveInlinePath = - onInlinePathPress && - !isLinkedInlineCode && - inlineCodeFileLink && - inlineCodeFileLink.kind !== "external"; + !isLinkedInlineCode && fileLinkActions.canResolveFile(inlineCodeSource); if (shouldResolveInlinePath) { return ( @@ -1699,9 +1672,6 @@ export const AssistantMessage = memo(function AssistantMessage({ inheritedStyles={inheritedStyles} codeInlineStyle={styles.code_inline} linkStyle={styles.link} - onPress={handleLinkPress} - onPrefetch={handleLinkPrefetch} - workspaceRoot={workspaceRoot} /> ); } @@ -1719,9 +1689,6 @@ export const AssistantMessage = memo(function AssistantMessage({ inheritedStyles={inheritedStyles} codeInlineStyle={styles.code_inline} linkStyle={styles.link} - onPress={handleLinkPress} - onPrefetch={handleLinkPrefetch} - workspaceRoot={workspaceRoot} > {content} @@ -1800,9 +1767,6 @@ export const AssistantMessage = memo(function AssistantMessage({ key={node.key} source={getMarkdownLinkSource(node)} style={styles.link} - onPress={handleLinkPress} - onPrefetch={handleLinkPrefetch} - workspaceRoot={workspaceRoot} > {Children.map(children, (child) => { if (!isValidElement(child)) return child; @@ -1841,15 +1805,7 @@ export const AssistantMessage = memo(function AssistantMessage({ ); }, }; - }, [ - client, - handleLinkPrefetch, - handleLinkPress, - markdownParser, - onInlinePathPress, - serverId, - workspaceRoot, - ]); + }, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]); const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); const keyedBlocks = useMemo( diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 383f6d770..b889b8f08 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -32,8 +32,8 @@ export type WorkspaceMatchMode = "fuzzy" | "suffix"; const DEFAULT_LIMIT = 30; const MAX_LIMIT = 100; -const DEFAULT_MAX_DEPTH = 6; -const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000; +const DEFAULT_MAX_DEPTH = 12; +const DEFAULT_MAX_DIRECTORIES_SCANNED = 20000; const DIRECTORY_LIST_CACHE_TTL_MS = 8_000; const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000; From 0a2307d199740e0e43567a47843434504867708f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 19 May 2026 17:43:26 +0800 Subject: [PATCH 13/14] Avoid duplicate Claude result text (#1095) --- .../agent/providers/claude/agent.test.ts | 60 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 21 +++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index be2fec564..8f4ae15a1 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1461,4 +1461,64 @@ describe("ClaudeAgentSession context window usage", () => { expect(timelineEvents).toEqual([]); expect(events.some((event) => event.type === "turn_completed")).toBe(true); }); + + test("result.result is not duplicated when assistant text already streamed with zero token usage", async () => { + const queryFactory = createQueryFactoryForTurns([ + [ + { + type: "system", + subtype: "init", + session_id: "session-third-party", + permissionMode: "default", + }, + { + type: "assistant", + message: { + id: "assistant-third-party-1", + role: "assistant", + content: [{ type: "text", text: "Here is the answer." }], + usage: { + input_tokens: 0, + output_tokens: 0, + }, + }, + session_id: "session-third-party", + uuid: "assistant-third-party-event-1", + }, + { + type: "result", + subtype: "success", + result: "Here is the answer.", + is_error: false, + duration_ms: 100, + duration_api_ms: 80, + num_turns: 1, + stop_reason: null, + total_cost_usd: 0.01, + usage: { + input_tokens: 10, + cache_read_input_tokens: 0, + output_tokens: 0, + }, + permission_denials: [], + uuid: "result-third-party-1", + session_id: "session-third-party", + }, + ], + ]); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + const result = await session.run("turn"); + await session.close(); + + expect(result.timeline).toEqual([{ type: "assistant_message", text: "Here is the answer." }]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index f84f66ea9..8d88d7929 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1568,6 +1568,7 @@ class ClaudeAgentSession implements AgentSession { private pendingInterruptAbort = false; private lastForegroundPromptText: string | null = null; private foregroundHasVisibleActivity = false; + private activeTurnHasAssistantText = false; private lastContextWindowUsedTokens: number | undefined; private lastContextWindowMaxTokens: number | undefined; private lastStreamRequestInputTokens: number | undefined; @@ -1692,6 +1693,7 @@ class ClaudeAgentSession implements AgentSession { const turnId = this.createTurnId("foreground"); this.activeForegroundTurnId = turnId; this.foregroundHasVisibleActivity = false; + this.activeTurnHasAssistantText = false; this.transitionTurnState("foreground", "foreground turn started"); this.clearRecentStderr(); @@ -2630,6 +2632,7 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundTurnId = null; this.lastForegroundPromptText = null; this.cancelCurrentTurn = null; + this.activeTurnHasAssistantText = false; this.syncTurnState("foreground turn terminal"); } @@ -2645,9 +2648,11 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundTurnId = null; this.lastForegroundPromptText = null; this.cancelCurrentTurn = null; + this.activeTurnHasAssistantText = false; this.syncTurnState("foreground turn terminal"); } else if (this.autonomousTurn) { this.autonomousTurn = null; + this.activeTurnHasAssistantText = false; this.syncTurnState("autonomous turn terminal"); } } @@ -2660,6 +2665,7 @@ class ClaudeAgentSession implements AgentSession { this.autonomousTurn = { id: this.createTurnId("autonomous"), }; + this.activeTurnHasAssistantText = false; this.notifySubscribers({ type: "turn_started", provider: "claude" }); this.syncTurnState("autonomous turn started"); } @@ -2670,6 +2676,7 @@ class ClaudeAgentSession implements AgentSession { } this.notifySubscribers({ type: "turn_completed", provider: "claude" }); this.autonomousTurn = null; + this.activeTurnHasAssistantText = false; this.syncTurnState("autonomous turn completed"); } @@ -2898,7 +2905,6 @@ class ClaudeAgentSession implements AgentSession { if (events.length === 0) { return; } - if ( this.pendingInterruptAbort && message.type === "result" && @@ -2909,6 +2915,11 @@ class ClaudeAgentSession implements AgentSession { this.logger.debug("Suppressing stale Claude interrupt terminal result"); return; } + if ( + events.some((event) => event.type === "timeline" && event.item.type === "assistant_message") + ) { + this.activeTurnHasAssistantText = true; + } if ( this.activeForegroundTurnId && events.some( @@ -3231,12 +3242,12 @@ class ClaudeAgentSession implements AgentSession { if (message.subtype === "success") { // Built-in slash commands (e.g. /voice, /usage, "Unknown command: …") // run client-side in the Claude CLI with no model turn — output_tokens - // is 0 and the user-visible text is carried in `result`. Surface it as - // an assistant message so the turn doesn't end silently. Normal turns - // have output_tokens > 0 and their text is already in the stream. + // is 0 and the user-visible text is carried in `result`. Surface it only + // when the turn has not already emitted assistant text so zero-token + // accounting from provider gateways does not duplicate streamed output. const resultText = typeof message.result === "string" ? message.result.trim() : ""; const outputTokens = message.usage?.output_tokens; - if (resultText.length > 0 && outputTokens === 0) { + if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) { events.push({ type: "timeline", provider: "claude", From 3743df09e6ec260713e5839cb3f7ce9888eb26ff Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 19 May 2026 18:05:45 +0800 Subject: [PATCH 14/14] Add rename for workspaces, terminals, and agent tabs (#531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add rename for workspaces, terminals, and agent tabs Surfaces rename via the sidebar workspace kebab (git branch rename with client-side slugify), the terminal tab context menu (stops OSC 2 auto-title overrides), and the agent tab context menu (locks the title against the metadata generator). A shared RenameModal wraps AdaptiveModalSheet and replaces the inline rename on the host page. Auto-title races are closed from both sides: AgentManager gains setGeneratedTitleIfUnset with an atomic per-agent write queue, and TerminalSession replaces lockedTitle with a titleMode discriminated union so user rename flips to manual and disposes the OSC subscription. New WebSocket messages are additive: rename_terminal_request/response and checkout_rename_branch_request/response (branch rename uses the CheckoutError family). A new @getpaseo/server/utils/branch-slug subpath export shares slugify + validateBranchSlug between server and app. * Tighten rename feature and restore lost rebase wiring Unslop pass on the rename commit plus two rebase artifacts: - session.ts: collapse handleRenameTerminalRequest to a respond helper with early returns; restore workspaceGitWatchTargets.set() in syncWorkspaceGitObserver (lost during rebase onto main, which broke onBranchChanged firing on branch rename). - terminal.ts: remove DA1 query handler accidentally re-introduced by the rebase (main intentionally removed it); restore conditional onTitleChange registration under titleMode === "auto". - rename-modal.tsx: drop unconfident optional-call on setNativeProps and the unknown-cast HTMLInputElement narrowing dance. - sidebar-workspace-list.tsx: remove unused branch field from rename result; flatten validateRenameSlug into early returns. - host-page.tsx: drop ?? "" fallback on a typed-string field. * Fix typecheck after rebase: pass parsed config to getScriptConfigs main refactored getScriptConfigs to take the parsed paseo.json config instead of a repo path. spawnWorktreeScripts (added in the rename feature) was still passing repoRoot. Read and parse the config first, matching how spawnWorkspaceScript already does it. * Resolve lint regressions and restore lost rebase fixes Post-rebase cleanup: drop the await on syncWorkspaceGitObservers so fetch_workspaces emits the response before any cold registration- triggered git work fires (workspace.id is already on the descriptor — no registry lookup needed). Restore the DA1 CSI handler that answers \x1b[?62;4;22c on the daemon-side xterm so foreground apps like nvim get a reply on stdin. Extract WorkspaceTabRenameModal/useWorkspaceTabRename to drop WorkspaceScreenContent below the cyclomatic-complexity ceiling. Switch session test internals to handleMessage so we exercise the public dispatcher and avoid casting through any. Convert literal 'type' aliases to interfaces and stop spawning callbacks inline so the codebase passes the post-rebase oxlint rules. * Fix typecheck after rebase: wire workspaceGitService and worker setTitle Bootstrap was missing workspaceGitService when constructing CreatePaseoWorktreeWorkflowDependencies after main's worktree workflow refactor. Worker terminal manager needed setTitle on the session and setTerminalTitle on the manager to satisfy the rename additions to TerminalSession/TerminalManager interfaces. * Format session.test.ts after rebase * Remove stray auto-spawn of workspace scripts after bootstrap The rebase brought in a spawnWorktreeScripts helper and a call inside runWorktreeSetupInBackground that auto-started every configured workspace script after worktree setup completed. main never auto-started scripts — this regressed the workspace-setup-streaming Playwright test, which expects the "web" script to be idle so the user can click Run. Drops the helper, the call, and the workspaceGitService dep that only existed to feed it. * Fix checkout branch rename tests * Fix sidebar checkout action store import * Skip POSIX terminal tests on Windows * Unslop the rename-entities feature Six audit findings closed and ~450 net lines trimmed from the branch: - Remove the duplicate "rename-branch" union member in GitMutationRefreshReason. - Fold dispatchStashMessage back into handleSessionMessage; the split was a feature-first artifact of adding checkout_rename_branch_request, with no documented rationale. - Drop the protected beforeGeneratedTitleIfUnsetWrite test seam from AgentStorage; rewrite the race test to exercise real Promise.all concurrency against the existing per-agent write queue. - Reshape useWorkspaceTabRename so the hook returns state and handlers only; promote WorkspaceTabRenameModal to an exported component the consumer renders directly. - Rename RenameModal to AdaptiveRenameModal so it reads as a generic primitive next to AdaptiveModalSheet, and update host-page, sidebar-workspace-list, and the workspace tab rename hook to import it by its new name. - Trim duplicate matrix coverage and Zod self-tests across rename-modal.test.tsx, terminal.test.ts, session.test.ts, agent-storage.test.ts, messages.rename-entities.test.ts and the three rename e2e specs; introduce packages/app/e2e/helpers/rename.ts to share setup across the e2e specs without merging coverage. Behavior of the rename feature is unchanged; targeted vitest, branch-wide typecheck, and lint all green. * Restore agentMetadataMocks dropped during rebase 5e6aeb2d removed the agentMetadataMocks hoisted definition and its vi.mock wiring when it deleted the import describe block. The block was kept (it belongs to main's import feature) but the mock support was left behind. * Fix terminal-manager tests using hardcoded /tmp on Windows setTerminalTitle tests used cwd: "/tmp" which is not a valid directory on Windows, causing node-pty error code 267 (ERROR_DIRECTORY). Use realpathSync(tmpdir()) like the rest of the test file. * Fix typecheck and lint after rebase onto main AdaptiveModalSheet moved from a `title` string prop to a structured `header: SheetHeader`; update AdaptiveRenameModal to memoize and pass a SheetHeader. invalidateCheckoutGitQueriesForClient moved from git/actions-store to git/query-keys. useWorkspaceTerminals now owns the terminal query, so workspace-screen pulls queryKey from the hook and re-declares queryClient via useQueryClient. * Update agent metadata test mock to match setGeneratedTitleIfUnset rename The branch renamed AgentManager.setTitle to setGeneratedTitleIfUnset for the rename feature; the generateTitlePromptWithConfig helper still mocked the old method, so eight prompt-byte tests crashed with "setGeneratedTitleIfUnset is not a function" on both ubuntu and windows server-tests jobs. Sister mocks in the same file were already on the new name. * Fix rename modal showing empty input by using controlled TextInput AdaptiveTextInput (introduced on main by 29ce6653f) is intentionally uncontrolled: it drops the `value` prop and seeds the native input once via `initialValue`. AdaptiveRenameModal still passed `value= draft` from the pre-rebase shape, so every rename modal opened with an empty textbox and a disabled Save button. Three playwright e2e specs (settings-host-page, sidebar-workspace-rename, workspace-agent- tab-rename) failed for this reason. Switch the rename modal to a plain controlled TextInput so the input seeds with the current label and the slug transform (used by sidebar workspace rename) reflects live in the textbox as the user types. The unit test's old AdaptiveTextInput mock is replaced with a react-native mock providing a controlled TextInput shim that captures the same onChangeText/onSubmitEditing handlers. * Revert "Fix rename modal showing empty input by using controlled TextInput" This reverts commit 6283deae842b8a5fed63cb8bf06c78029f98386e. * Seed rename modal input via AdaptiveTextInput's initialValue + resetKey AdaptiveTextInput is intentionally uncontrolled — it drops `value` and seeds the native input once with `initialValue`. The rename modal was passing `value={draft}` from the pre-rebase shape, so every rename modal opened with an empty textbox (three playwright specs failing). Pass `initialValue={draft}` and bump `resetKey` only when the transform rewrote what the user typed. That seeds the native input with the current label on open, lets the user keep typing inside text without cursor jumps (no remount when transform is a no-op), and remounts the native input with the slug when the transform diverges so live slugification keeps working in sidebar workspace rename. Keeping AdaptiveTextInput preserves the BottomSheetTextInput swap on mobile so the keyboard stays above the sheet — using a plain TextInput would break that. The unit test mock is updated to read `initialValue`/`resetKey` so it mirrors production behavior instead of pretending the input is controlled. * Slugify branch rename at submit only, drop live transform The rename modal's transform prop remounted the native input every time the slug diverged from what the user typed, which lost focus mid-edit on every uppercase letter or space in the sidebar workspace rename. Live-rewriting what the user typed was also surprising — the expectation is that you type a name, the daemon stores a slug. Drop the transform prop from AdaptiveRenameModal entirely. Sidebar workspace rename now slugifies once in handleSubmitRename before calling renameBranch, and validateRenameSlug runs validateBranchSlug against the slugified value so the user sees inline errors. The playwright spec drops the live-slug assertion; it still verifies the post-submit branch on disk and the rename request payload. * Rename new rename RPCs to dotted convention docs/rpc-namespacing.md says new RPCs use dotted names with the direction as the final segment, and explicitly bans new flat snake_ case names. This PR introduced two flat ones; rename them in place before the protocol ships: - checkout_rename_branch_request → checkout.rename_branch.request - checkout_rename_branch_response → checkout.rename_branch.response - rename_terminal_request → terminal.rename.request - rename_terminal_response → terminal.rename.response Touched: the Zod literals in messages.ts, the discriminated union entries, the session dispatcher and its tests, the daemon client wrappers and their tests, the terminal session controller, and the message-parsing rename-entities test. No callers exist outside the server package — app and CLI go through the daemon-client wrappers, which now emit the dotted names. * Match rename modal Save button and input focus to app conventions Save uses Button variant=default so it gets the same accent background + white text as every other primary action in the app (open-project, settings host save, pair-link confirm, etc). The input previously fell back to the browser's blue focus outline on web because the rename modal never set outlineStyle: none — every other AdaptiveTextInput call site that wants accent feedback already does this. Kill the browser outline, track focus state, and switch borderColor to accent while focused. * Theme the focus-visible outline color via AdaptiveTextInput public/index.html paints a 2px :focus-visible outline on every web element with a hard-coded #20744a (Paseo green). On the Claude theme, the rename modal's input got that green ring instead of the theme's brown accent — visible mismatch against every other accent-colored control (primary buttons, etc). Add an outlineColor entry to AdaptiveTextInput's stylesheet sourced from theme.colors.accent. Unistyles' Babel plugin tracks the read and updates the native ShadowTree on theme switch — no React re-render, no useUnistyles() call (forbidden on this hot path per docs/unistyles.md). The inline outline-color on the rendered DOM input overrides the selector-level color from index.html; outline-width/style/offset still come from the global rule. Consumer style merges in after, so existing callers that pass outlineColor (message-input, review/surface, question-form-card) still win. Also drop the half-baked isFocused/focusedBorderStyle local state I added to rename-modal earlier — the AdaptiveTextInput fix removes the need for it. * Drop useUnistyles() from rename modal The rename modal only read theme.colors.foregroundMuted to pass as the TextInput's placeholderTextColor prop. The rest already went through StyleSheet.create((theme) => ...). Per docs/unistyles.md the hook is forbidden when an alternative exists — and this is exactly the alternative the rest of the codebase uses (project-settings-screen, add-host-modal, pair-link-modal, command-center): put the muted color in a tiny StyleSheet entry and read .color off it for the prop. * Default placeholderTextColor in AdaptiveTextInput placeholderTextColor was being duplicated at every AdaptiveTextInput call site (add-host-modal, command-center, pair-link-modal, project-picker-modal, provider-diagnostic-sheet, combobox, the sheet's own search inputs, etc.) — all passing the same theme.colors.foregroundMuted. The shared input should own this. Move it into AdaptiveTextInput as a default sourced from the same StyleSheet.create(theme => ...) block as the accent outline. Consumers still override via the prop if they need a different color. Strip the redundant placeholderTextColor and local placeholderColor style entry from rename-modal; other call sites can be cleaned up in a follow-up. * Update rename e2e specs to use dotted RPC type strings Earlier commit (5d5624943) renamed the new rename RPCs to the dotted convention but missed the two e2e specs that capture the WebSocket frames by type. captureWsSessionFrames matched the old flat names, so renameRequests / renameFrames stayed empty even though the rename went through end to end — the sidebar updated and the branch was renamed on disk. The toBeGreaterThan(0) assertion fired and the test failed in playwright CI. --- packages/app/e2e/helpers/rename.ts | 40 ++ packages/app/e2e/helpers/settings.ts | 8 +- .../app/e2e/sidebar-workspace-rename.spec.ts | 138 ++++ .../e2e/workspace-agent-tab-rename.spec.ts | 88 +++ .../e2e/workspace-terminal-tab-rename.spec.ts | 67 ++ .../src/components/adaptive-modal-sheet.tsx | 21 +- .../app/src/components/rename-modal.test.tsx | 368 ++++++++++ packages/app/src/components/rename-modal.tsx | 195 ++++++ .../src/components/sidebar-workspace-list.tsx | 127 +++- .../app/src/components/split-container.tsx | 8 + .../app/src/screens/settings/host-page.tsx | 164 +---- .../workspace/use-workspace-tab-rename.tsx | 125 ++++ .../workspace/workspace-desktop-tabs-row.tsx | 12 + .../screens/workspace/workspace-screen.tsx | 36 +- .../workspace/workspace-tab-menu.test.ts | 134 +++- .../screens/workspace/workspace-tab-menu.ts | 28 +- packages/server/package.json | 5 + .../server/src/client/daemon-client.test.ts | 113 ++++ packages/server/src/client/daemon-client.ts | 40 ++ .../src/server/agent/agent-manager.test.ts | 107 ++- .../server/src/server/agent/agent-manager.ts | 17 + .../server/agent/agent-metadata-generator.ts | 2 +- .../agent-metadata-generator.unit.test.ts | 30 +- .../src/server/agent/agent-storage.test.ts | 36 + .../server/src/server/agent/agent-storage.ts | 99 ++- packages/server/src/server/session.test.ts | 214 ++++++ packages/server/src/server/session.ts | 111 ++- .../session.workspace-git-watch.test.ts | 93 ++- .../shared/messages.rename-entities.test.ts | 140 ++++ packages/server/src/shared/messages.ts | 42 ++ .../src/terminal/terminal-manager.test.ts | 32 + .../server/src/terminal/terminal-manager.ts | 11 + .../terminal/terminal-session-controller.ts | 33 +- packages/server/src/terminal/terminal.test.ts | 640 +++++++++++++++++- packages/server/src/terminal/terminal.ts | 47 +- .../src/terminal/worker-terminal-manager.ts | 19 + packages/server/src/utils/branch-slug.test.ts | 66 ++ packages/server/src/utils/branch-slug.ts | 61 ++ .../server/src/utils/checkout-git.test.ts | 45 +- packages/server/src/utils/worktree.ts | 69 +- 40 files changed, 3337 insertions(+), 294 deletions(-) create mode 100644 packages/app/e2e/helpers/rename.ts create mode 100644 packages/app/e2e/sidebar-workspace-rename.spec.ts create mode 100644 packages/app/e2e/workspace-agent-tab-rename.spec.ts create mode 100644 packages/app/e2e/workspace-terminal-tab-rename.spec.ts create mode 100644 packages/app/src/components/rename-modal.test.tsx create mode 100644 packages/app/src/components/rename-modal.tsx create mode 100644 packages/app/src/screens/workspace/use-workspace-tab-rename.tsx create mode 100644 packages/server/src/shared/messages.rename-entities.test.ts create mode 100644 packages/server/src/utils/branch-slug.test.ts create mode 100644 packages/server/src/utils/branch-slug.ts diff --git a/packages/app/e2e/helpers/rename.ts b/packages/app/e2e/helpers/rename.ts new file mode 100644 index 000000000..f2b18949f --- /dev/null +++ b/packages/app/e2e/helpers/rename.ts @@ -0,0 +1,40 @@ +import { type Page } from "@playwright/test"; + +/** + * Listens for outbound WebSocket "session" frames of a given inner message type + * and accumulates them. The returned array is populated in-place as frames arrive. + */ +export function captureWsSessionFrames>( + page: Page, + messageType: string, + extract: (inner: Record) => T, +): T[] { + const captured: T[] = []; + page.on("websocket", (ws) => { + ws.on("framesent", (frame) => { + const raw = frame.payload; + const text = typeof raw === "string" ? raw : raw.toString("utf8"); + try { + const outer = JSON.parse(text) as { type?: string; message?: Record }; + if (outer.type === "session" && outer.message?.type === messageType) { + captured.push(extract(outer.message)); + } + } catch { + // Ignore non-JSON and binary frames. + } + }); + }); + return captured; +} + +export function renameModalInput(page: Page, testIdPrefix: string) { + return page.getByTestId(`${testIdPrefix}-input`); +} + +export function renameModalSubmit(page: Page, testIdPrefix: string) { + return page.getByTestId(`${testIdPrefix}-submit`); +} + +export function renameModalError(page: Page, testIdPrefix: string) { + return page.getByTestId(`${testIdPrefix}-error`); +} diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index aa16c1a25..7302cebbf 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -168,7 +168,7 @@ export async function expectGeneralContent(page: Page): Promise { export async function expectHostLabelDisplayed(page: Page): Promise { await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible(); - await expect(page.getByTestId("host-page-label-input")).toHaveCount(0); + await expect(page.getByTestId("host-page-rename-modal-input")).toHaveCount(0); } export async function clickEditHostLabel(page: Page): Promise { @@ -176,9 +176,9 @@ export async function clickEditHostLabel(page: Page): Promise { } export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise { - await expect(page.getByTestId("host-page-label-input")).toBeVisible(); - await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel); - await expect(page.getByTestId("host-page-label-save")).toBeVisible(); + await expect(page.getByTestId("host-page-rename-modal-input")).toBeVisible(); + await expect(page.getByTestId("host-page-rename-modal-input")).toHaveValue(expectedLabel); + await expect(page.getByTestId("host-page-rename-modal-submit")).toBeVisible(); } export async function expectHostConnectionsCard(page: Page, port: string): Promise { diff --git a/packages/app/e2e/sidebar-workspace-rename.spec.ts b/packages/app/e2e/sidebar-workspace-rename.spec.ts new file mode 100644 index 000000000..b51d0f13c --- /dev/null +++ b/packages/app/e2e/sidebar-workspace-rename.spec.ts @@ -0,0 +1,138 @@ +import { execSync } from "node:child_process"; +import { test, expect, type Page } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { createTempGitRepo } from "./helpers/workspace"; +import { connectWorkspaceSetupClient } from "./helpers/workspace-setup"; +import { captureWsSessionFrames } from "./helpers/rename"; + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); + } + return serverId; +} + +function workspaceRowTestId(workspaceId: string): string { + return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; +} + +function workspaceRenameModalTestId(workspaceId: string, suffix: string): string { + return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`; +} + +async function openProjectViaDaemon( + client: Awaited>, + cwd: string, +): Promise<{ id: string; name: string; workspaceDirectory: string }> { + const result = await client.openProject(cwd); + if (!result.workspace || result.error) { + throw new Error(result.error ?? `Failed to open project ${cwd}`); + } + return { + id: String(result.workspace.id), + name: result.workspace.name, + workspaceDirectory: result.workspace.workspaceDirectory, + }; +} + +async function openRenameModal(page: Page, workspaceId: string) { + const serverId = getServerId(); + const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.hover(); + + const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`); + await expect(kebab).toBeVisible({ timeout: 10_000 }); + await kebab.click(); + + const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`); + await expect(renameItem).toBeVisible({ timeout: 10_000 }); + await renameItem.click(); + + const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input")); + await expect(input).toBeVisible({ timeout: 10_000 }); + return input; +} + +test.describe("Sidebar workspace rename", () => { + test("renaming via kebab updates the branch name on disk and in the sidebar", async ({ + page, + }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("sidebar-rename-"); + + try { + const workspace = await openProjectViaDaemon(client, repo.path); + expect(workspace.name).toBe("main"); + + const renameRequests = captureWsSessionFrames( + page, + "checkout.rename_branch.request", + (inner) => ({ + branch: String(inner.branch ?? ""), + cwd: String(inner.cwd ?? ""), + }), + ); + + await gotoAppShell(page); + await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toBeVisible({ + timeout: 30_000, + }); + + const input = await openRenameModal(page, workspace.id); + await expect(input).toHaveValue("main"); + await input.fill("Feature Rename 2"); + + await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click(); + + await expect(input).toHaveCount(0, { timeout: 15_000 }); + await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText( + "feature-rename-2", + { timeout: 15_000 }, + ); + + expect(renameRequests.length).toBeGreaterThan(0); + expect(renameRequests.at(-1)).toEqual({ + branch: "feature-rename-2", + cwd: workspace.workspaceDirectory, + }); + + const currentBranchOnDisk = execSync("git branch --show-current", { + cwd: repo.path, + stdio: "pipe", + }) + .toString() + .trim(); + expect(currentBranchOnDisk).toBe("feature-rename-2"); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("sidebar-rename-error-", { branches: ["taken"] }); + + try { + const workspace = await openProjectViaDaemon(client, repo.path); + + await gotoAppShell(page); + const input = await openRenameModal(page, workspace.id); + await expect(input).toHaveValue("main"); + + await input.fill("taken"); + await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click(); + + const errorNode = page.getByTestId(workspaceRenameModalTestId(workspace.id, "error")); + await expect(errorNode).toBeVisible({ timeout: 15_000 }); + await expect(errorNode).toContainText(/already exists|branch/i); + await expect(input).toBeVisible(); + await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText("main"); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/workspace-agent-tab-rename.spec.ts b/packages/app/e2e/workspace-agent-tab-rename.spec.ts new file mode 100644 index 000000000..91d04613d --- /dev/null +++ b/packages/app/e2e/workspace-agent-tab-rename.spec.ts @@ -0,0 +1,88 @@ +import { randomUUID } from "node:crypto"; +import { test, expect, type Page } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + connectArchiveTabDaemonClient, + createIdleAgent, + expectWorkspaceTabVisible, +} from "./helpers/archive-tab"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename"; + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); + } + return serverId; +} + +async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) { + await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd)); + await page.waitForURL( + (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), + { timeout: 60_000 }, + ); + await waitForWorkspaceTabsVisible(page); + await expectWorkspaceTabVisible(page, agent.id); +} + +test.describe("Workspace agent tab rename", () => { + test("right-click rename sends update_agent_request and updates the tab label", async ({ + page, + }) => { + test.setTimeout(120_000); + + const client = await connectArchiveTabDaemonClient(); + const repo = await createTempGitRepo("workspace-agent-rename-"); + + try { + const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`; + const agent = await createIdleAgent(client, { + cwd: repo.path, + title: initialTitle, + }); + + const updateFrames = captureWsSessionFrames(page, "update_agent_request", (inner) => ({ + agentId: String(inner.agentId ?? ""), + name: String(inner.name ?? ""), + requestId: String(inner.requestId ?? ""), + })); + + await openAgentInWorkspace(page, agent); + + const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first(); + await expect(tab).toContainText(initialTitle, { timeout: 15_000 }); + + await tab.click({ button: "right" }); + await expect(page.getByTestId(`workspace-tab-context-agent_${agent.id}`)).toBeVisible({ + timeout: 10_000, + }); + const renameItem = page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`); + await expect(renameItem).toBeVisible({ timeout: 10_000 }); + await renameItem.click(); + + const modalPrefix = `workspace-tab-rename-modal-agent-${agent.id}`; + const input = renameModalInput(page, modalPrefix); + await expect(input).toBeVisible({ timeout: 10_000 }); + await expect(input).toHaveValue(initialTitle); + + const renamed = "My Renamed Agent"; + await input.fill(renamed); + await renameModalSubmit(page, modalPrefix).click(); + + await expect(input).toHaveCount(0, { timeout: 15_000 }); + await expect(tab).toContainText(renamed, { timeout: 15_000 }); + + expect(updateFrames.length).toBeGreaterThan(0); + const lastFrame = updateFrames.at(-1)!; + expect(lastFrame.agentId).toBe(agent.id); + expect(lastFrame.name).toBe(renamed); + expect(lastFrame.requestId.length).toBeGreaterThan(0); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/workspace-terminal-tab-rename.spec.ts b/packages/app/e2e/workspace-terminal-tab-rename.spec.ts new file mode 100644 index 000000000..54a485fa8 --- /dev/null +++ b/packages/app/e2e/workspace-terminal-tab-rename.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { connectTerminalClient, navigateToTerminal } from "./helpers/terminal-perf"; +import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename"; + +test.describe("Workspace terminal tab rename", () => { + test("right-click rename sends terminal.rename.request and updates the tab label", async ({ + page, + }) => { + test.setTimeout(60_000); + + const client = await connectTerminalClient(); + const repo = await createTempGitRepo("workspace-terminal-rename-"); + + try { + const seeded = await client.openProject(repo.path); + if (!seeded.workspace) { + throw new Error(seeded.error ?? "Failed to seed workspace"); + } + const workspaceId = seeded.workspace.id; + + const created = await client.createTerminal(repo.path); + if (!created.terminal) { + throw new Error(created.error ?? "Failed to create terminal"); + } + const terminalId = created.terminal.id; + + const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({ + terminalId: String(inner.terminalId ?? ""), + title: String(inner.title ?? ""), + requestId: String(inner.requestId ?? ""), + })); + + await navigateToTerminal(page, { workspaceId, terminalId }); + + const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first(); + await expect(tab).toBeVisible({ timeout: 15_000 }); + + await tab.click({ button: "right" }); + await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({ + timeout: 10_000, + }); + const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`); + await expect(renameItem).toBeVisible({ timeout: 10_000 }); + await renameItem.click(); + + const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`; + const input = renameModalInput(page, modalPrefix); + await expect(input).toBeVisible({ timeout: 10_000 }); + + await input.fill("My Renamed Terminal"); + await renameModalSubmit(page, modalPrefix).click(); + + await expect(input).toHaveCount(0, { timeout: 15_000 }); + await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 }); + + expect(renameFrames.length).toBeGreaterThan(0); + const lastFrame = renameFrames.at(-1)!; + expect(lastFrame.terminalId).toBe(terminalId); + expect(lastFrame.title).toBe("My Renamed Terminal"); + expect(lastFrame.requestId.length).toBeGreaterThan(0); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index d1ae7f5e7..054d13f24 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -202,6 +202,12 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], gap: theme.spacing[4], }, + adaptiveInputOutline: { + outlineColor: theme.colors.accent, + }, + adaptiveInputPlaceholder: { + color: theme.colors.foregroundMuted, + }, })); const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }]; @@ -234,10 +240,23 @@ export type AdaptiveTextInputProps = TextInputProps & { export const AdaptiveTextInput = forwardRef( function AdaptiveTextInputInner(props, ref) { const isMobile = useIsCompactFormFactor(); - const { value: _value, initialValue, resetKey, defaultValue, ...inputProps } = props; + const { + value: _value, + initialValue, + resetKey, + defaultValue, + style, + placeholderTextColor, + ...inputProps + } = props; + // Recolor the browser's :focus-visible outline (defined in public/index.html) + // so it matches the active theme's accent instead of its hard-coded fallback. + // Consumer style wins if it sets outlineColor explicitly. const textInputProps = { ...inputProps, defaultValue: initialValue ?? defaultValue, + placeholderTextColor: placeholderTextColor ?? styles.adaptiveInputPlaceholder.color, + style: [styles.adaptiveInputOutline, style], }; if (isMobile && isNative) { diff --git a/packages/app/src/components/rename-modal.test.tsx b/packages/app/src/components/rename-modal.test.tsx new file mode 100644 index 000000000..f8d8ffbbe --- /dev/null +++ b/packages/app/src/components/rename-modal.test.tsx @@ -0,0 +1,368 @@ +import { JSDOM } from "jsdom"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AdaptiveRenameModal } from "./rename-modal"; + +const { theme, adaptiveInputState } = vi.hoisted(() => ({ + adaptiveInputState: { + latestProps: null as { + onChangeText?: (next: string) => void; + onSubmitEditing?: () => void; + } | null, + }, + theme: { + spacing: { 2: 8, 3: 12 }, + fontSize: { sm: 13, base: 15 }, + borderRadius: { md: 6 }, + colors: { + surface0: "#000", + foreground: "#fff", + foregroundMuted: "#aaa", + border: "#555", + palette: { red: { 300: "#f87171" } }, + }, + }, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("@/constants/platform", () => ({ + isWeb: true, + isNative: false, +})); + +vi.mock("@/components/adaptive-modal-sheet", async () => { + const ReactModule = await import("react"); + const AdaptiveModalSheet = ({ + visible, + title, + children, + onClose, + testID, + }: { + visible: boolean; + title: string; + children: React.ReactNode; + onClose: () => void; + testID?: string; + }) => { + if (!visible) return null; + return ReactModule.createElement( + "div", + { "data-testid": testID ?? "adaptive-modal-sheet", "data-modal-title": title }, + ReactModule.createElement( + "button", + { + type: "button", + "data-testid": "adaptive-modal-sheet-close", + onClick: onClose, + }, + "Close", + ), + children, + ); + }; + // Mirrors production AdaptiveTextInput: native-owned input seeded by + // initialValue, remounted (via key) when resetKey changes so the new + // initialValue takes effect. + const AdaptiveTextInput = ReactModule.forwardRef>( + (props, ref) => { + const p = props as { + initialValue?: string; + defaultValue?: string; + editable?: boolean; + maxLength?: number; + testID?: string; + onChangeText?: (next: string) => void; + onSubmitEditing?: () => void; + }; + adaptiveInputState.latestProps = { + onChangeText: p.onChangeText, + onSubmitEditing: p.onSubmitEditing, + }; + return ReactModule.createElement("input", { + ref, + defaultValue: p.initialValue ?? p.defaultValue ?? "", + disabled: p.editable === false, + maxLength: p.maxLength, + "data-testid": p.testID, + onChange: (e: { target: { value: string } }) => p.onChangeText?.(e.target.value), + onKeyDown: (e: { key: string; preventDefault: () => void }) => { + if (e.key === "Enter") { + e.preventDefault(); + p.onSubmitEditing?.(); + } + }, + }); + }, + ); + return { AdaptiveModalSheet, AdaptiveTextInput }; +}); + +vi.mock("@/components/ui/button", async () => { + const ReactModule = await import("react"); + return { + Button: ({ + children, + onPress, + disabled, + testID, + }: { + children?: React.ReactNode; + onPress?: () => void; + disabled?: boolean; + testID?: string; + }) => + ReactModule.createElement( + "button", + { + type: "button", + "data-testid": testID, + disabled: disabled || undefined, + onClick: () => !disabled && onPress?.(), + }, + children, + ), + }; +}); + +let root: Root | null = null; +let container: HTMLElement | null = null; + +beforeEach(() => { + const dom = new JSDOM(""); + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", dom.window); + vi.stubGlobal("document", dom.window.document); + vi.stubGlobal("HTMLElement", dom.window.HTMLElement); + vi.stubGlobal("HTMLInputElement", dom.window.HTMLInputElement); + vi.stubGlobal("KeyboardEvent", dom.window.KeyboardEvent); + vi.stubGlobal("Node", dom.window.Node); + vi.stubGlobal("navigator", dom.window.navigator); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + adaptiveInputState.latestProps = null; +}); + +afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +interface RenderOptions { + visible?: boolean; + initialValue?: string; + title?: string; + placeholder?: string; + submitLabel?: string; + onClose?: () => void; + onSubmit?: (value: string) => Promise | void; + validate?: (value: string) => string | null; + maxLength?: number; +} + +function renderModal(options: RenderOptions = {}): void { + const { + visible = true, + initialValue = "", + title = "Rename", + placeholder, + submitLabel, + onClose = vi.fn(), + onSubmit = vi.fn(), + validate, + maxLength, + } = options; + act(() => { + root?.render( + , + ); + }); +} + +function queryInput(): HTMLInputElement | null { + return document.querySelector('[data-testid="rename-modal-input"]'); +} + +function querySubmit(): HTMLButtonElement | null { + return document.querySelector('[data-testid="rename-modal-submit"]'); +} + +function queryCancel(): HTMLButtonElement | null { + return document.querySelector('[data-testid="rename-modal-cancel"]'); +} + +function queryError(): HTMLElement | null { + return document.querySelector('[data-testid="rename-modal-error"]'); +} + +function click(element: Element | null): void { + if (!element) throw new Error("Cannot click null element"); + act(() => { + element.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + }); +} + +function typeInto(value: string): void { + act(() => { + adaptiveInputState.latestProps?.onChangeText?.(value); + }); +} + +function pressEnter(): void { + act(() => { + adaptiveInputState.latestProps?.onSubmitEditing?.(); + }); +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("RenameModal", () => { + it("renders with the initial value pre-filled and selects it after open", async () => { + vi.useFakeTimers(); + renderModal({ initialValue: "main" }); + const input = queryInput(); + expect(input).not.toBeNull(); + expect(input?.value).toBe("main"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + + const focused = document.activeElement as HTMLInputElement | null; + expect(focused).toBe(input); + expect(focused?.selectionStart).toBe(0); + expect(focused?.selectionEnd).toBe("main".length); + }); + + it("submits on Enter keypress in the input when the value has changed", async () => { + const onSubmit = vi.fn(); + const onClose = vi.fn(); + renderModal({ initialValue: "feature", onSubmit, onClose }); + + typeInto("feature-2"); + pressEnter(); + await flush(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith("feature-2"); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when AdaptiveModalSheet's close prop fires (cancel button / backdrop delegated)", () => { + const onClose = vi.fn(); + const onSubmit = vi.fn(); + renderModal({ initialValue: "main", onClose, onSubmit }); + + click(document.querySelector('[data-testid="adaptive-modal-sheet-close"]')); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("disables submit when draft equals initialValue and re-enables after a change", async () => { + const onSubmit = vi.fn(); + renderModal({ initialValue: "main", onSubmit }); + + expect(querySubmit()?.disabled).toBe(true); + + pressEnter(); + await flush(); + expect(onSubmit).not.toHaveBeenCalled(); + + typeInto("main-v2"); + await flush(); + expect(querySubmit()?.disabled).toBe(false); + + typeInto("main"); + await flush(); + expect(querySubmit()?.disabled).toBe(true); + }); + + it("surfaces validate errors inline and blocks submission", async () => { + const onSubmit = vi.fn(); + const validate = vi.fn((value: string) => (value === "bad" ? "Invalid name" : null)); + renderModal({ initialValue: "ok", validate, onSubmit }); + + typeInto("bad"); + const submit = querySubmit()!; + expect(submit.disabled).toBe(true); + + pressEnter(); + await flush(); + + expect(onSubmit).not.toHaveBeenCalled(); + const errorNode = queryError(); + expect(errorNode?.textContent).toContain("Invalid name"); + }); + + it("disables the submit button while onSubmit is pending", async () => { + let resolve: () => void = () => {}; + const onSubmit = vi.fn( + () => + new Promise((r) => { + resolve = r; + }), + ); + renderModal({ initialValue: "main", onSubmit }); + + typeInto("main-renamed"); + click(querySubmit()); + await flush(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(querySubmit()?.disabled).toBe(true); + expect(queryCancel()?.disabled).toBe(true); + + await act(async () => { + resolve(); + await Promise.resolve(); + }); + }); + + it("keeps the modal open with an error when onSubmit rejects", async () => { + const onSubmit = vi.fn().mockRejectedValue(new Error("Server said no")); + const onClose = vi.fn(); + renderModal({ initialValue: "main", onSubmit, onClose }); + + typeInto("main-renamed"); + click(querySubmit()); + await flush(); + + expect(onClose).not.toHaveBeenCalled(); + expect(queryError()?.textContent).toContain("Server said no"); + expect(querySubmit()?.disabled).toBe(false); + }); +}); diff --git a/packages/app/src/components/rename-modal.tsx b/packages/app/src/components/rename-modal.tsx new file mode 100644 index 000000000..c0cfa7de1 --- /dev/null +++ b/packages/app/src/components/rename-modal.tsx @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Text, TextInput, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { + AdaptiveModalSheet, + AdaptiveTextInput, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { isWeb } from "@/constants/platform"; + +export interface AdaptiveRenameModalProps { + visible: boolean; + title: string; + initialValue: string; + placeholder?: string; + submitLabel?: string; + onClose: () => void; + onSubmit: (value: string) => Promise | void; + validate?: (value: string) => string | null; + maxLength?: number; + testID?: string; +} + +export function AdaptiveRenameModal({ + visible, + title, + initialValue, + placeholder, + submitLabel = "Rename", + onClose, + onSubmit, + validate, + maxLength, + testID, +}: AdaptiveRenameModalProps) { + const [draft, setDraft] = useState(initialValue); + const [error, setError] = useState(null); + const [isPending, setIsPending] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (!visible) return; + setDraft(initialValue); + setError(null); + setIsPending(false); + }, [visible, initialValue]); + + useEffect(() => { + if (!visible) return; + const length = initialValue.length; + const timeout = setTimeout(() => { + const node = inputRef.current; + if (!node) return; + node.focus(); + if (isWeb && node instanceof HTMLInputElement) { + node.setSelectionRange(0, length); + } else if (!isWeb && length > 0) { + node.setNativeProps({ selection: { start: 0, end: length } }); + } + }, 50); + return () => clearTimeout(timeout); + }, [visible, initialValue]); + + const computeError = useCallback( + (value: string): string | null => { + if (!value.trim()) return "Name is required"; + return validate ? validate(value) : null; + }, + [validate], + ); + + const handleChange = useCallback((value: string) => { + setDraft(value); + setError(null); + }, []); + + const handleSubmit = useCallback(async () => { + if (isPending) return; + const value = draft; + if (value === initialValue) return; + const validationError = computeError(value); + if (validationError) { + setError(validationError); + return; + } + try { + setIsPending(true); + await onSubmit(value); + setIsPending(false); + onClose(); + } catch (err) { + setIsPending(false); + const message = err instanceof Error && err.message ? err.message : "Unable to save"; + setError(message); + } + }, [isPending, draft, initialValue, computeError, onSubmit, onClose]); + + const handleCancel = useCallback(() => { + if (isPending) return; + onClose(); + }, [isPending, onClose]); + + const handleSubmitVoid = useCallback(() => { + void handleSubmit(); + }, [handleSubmit]); + + const submitDisabled = isPending || draft === initialValue || computeError(draft) !== null; + const inputTestID = testID ? `${testID}-input` : undefined; + const errorTestID = testID ? `${testID}-error` : undefined; + const submitTestID = testID ? `${testID}-submit` : undefined; + const cancelTestID = testID ? `${testID}-cancel` : undefined; + const sheetHeader = useMemo(() => ({ title }), [title]); + + return ( + + + + {error ? ( + + {error} + + ) : null} + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + body: { + gap: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + input: { + backgroundColor: theme.colors.surface0, + color: theme.colors.foreground, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.base, + }, + errorText: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.sm, + }, + actions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + actionButton: { + flex: 1, + }, +})); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index b386e9a4d..72e3330a6 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -12,7 +12,10 @@ import { type ViewStyle, } from "react-native"; import * as Haptics from "expo-haptics"; -import { useQueries } from "@tanstack/react-query"; +import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; +import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@server/utils/branch-slug"; +import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { useCallback, useMemo, @@ -45,6 +48,7 @@ import { SquareTerminal, Monitor, MoreVertical, + Pencil, Plus, Trash2, } from "lucide-react-native"; @@ -147,6 +151,7 @@ const ThemedTrash2 = withUnistyles(Trash2); const ThemedSettings = withUnistyles(Settings); const ThemedCopy = withUnistyles(Copy); const ThemedArchive = withUnistyles(Archive); +const ThemedPencil = withUnistyles(Pencil); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -232,6 +237,7 @@ interface WorkspaceRowInnerProps { onArchive?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; + onRename?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; } @@ -565,6 +571,7 @@ const trash2LeadingIcon = ; const copyLeadingIcon = ; const archiveLeadingIcon = ; +const renameLeadingIcon = ; function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { return ( @@ -640,6 +647,7 @@ function WorkspaceRowRightGroup({ onArchive, onCopyBranchName, onCopyPath, + onRename, }: { workspace: SidebarWorkspaceEntry; isHovered: boolean; @@ -656,6 +664,7 @@ function WorkspaceRowRightGroup({ onArchive?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; + onRename?: () => void; }) { const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); return ( @@ -675,6 +684,7 @@ function WorkspaceRowRightGroup({ workspaceKey={workspace.workspaceKey} onCopyPath={onCopyPath} onCopyBranchName={onCopyBranchName} + onRename={onRename} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -701,6 +711,7 @@ function WorkspaceKebabMenu({ workspaceKey, onCopyPath, onCopyBranchName, + onRename, onArchive, archiveLabel, archiveStatus, @@ -710,6 +721,7 @@ function WorkspaceKebabMenu({ workspaceKey: string; onCopyPath?: () => void; onCopyBranchName?: () => void; + onRename?: () => void; onArchive: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; @@ -750,6 +762,15 @@ function WorkspaceKebabMenu({ Copy branch name ) : null} + {onRename ? ( + + Rename workspace + + ) : null} {prHint ? ( @@ -1469,7 +1492,9 @@ function WorkspaceRowWithMenu({ const toast = useToast(); const activeWorkspaceSelection = useActiveWorkspaceSelection(); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); + const queryClient = useQueryClient(); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); + const [isRenameOpen, setIsRenameOpen] = useState(false); const workspaceDirectory = resolveWorkspaceExecutionDirectory({ workspaceDirectory: workspace.workspaceDirectory, }); @@ -1599,6 +1624,51 @@ function WorkspaceRowWithMenu({ toast.copied("Branch name copied"); }, [toast, workspace.name]); + const renameMutation = useMutation({ + mutationFn: async (branch: string) => { + const client = getHostRuntimeStore().getClient(workspace.serverId); + if (!client) { + throw new Error("Host is not connected"); + } + const targetCwd = requireWorkspaceExecutionDirectory({ + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + }); + const payload = await client.renameBranch({ cwd: targetCwd, branch }); + if (!payload.success || payload.error) { + throw new Error(payload.error?.message ?? "Failed to rename branch"); + } + return { targetCwd }; + }, + onSuccess: async ({ targetCwd }) => { + await invalidateCheckoutGitQueriesForClient(queryClient, { + serverId: workspace.serverId, + cwd: targetCwd, + }); + }, + }); + + const handleOpenRename = useCallback(() => { + setIsRenameOpen(true); + }, []); + + const handleCloseRename = useCallback(() => { + setIsRenameOpen(false); + }, []); + + const handleSubmitRename = useCallback( + async (value: string) => { + await renameMutation.mutateAsync(slugify(value)); + }, + [renameMutation], + ); + + const validateRenameSlug = useCallback((value: string): string | null => { + const result = validateBranchSlug(slugify(value)); + if (result.valid) return null; + return result.error ?? "Invalid branch name"; + }, []); + const archiveShortcutKeys = useShortcutKeys("archive-worktree"); useKeyboardActionHandler({ @@ -1617,26 +1687,41 @@ function WorkspaceRowWithMenu({ }); return ( - + <> + + + ); } diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index ba6111bf8..9657c09e6 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -86,6 +86,7 @@ interface SplitContainerProps { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; @@ -362,6 +363,7 @@ export function SplitContainer({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -577,6 +579,7 @@ export function SplitContainer({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -716,6 +719,7 @@ function SplitNodeView({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -768,6 +772,7 @@ function SplitNodeView({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -813,6 +818,7 @@ function SplitNodeView({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -864,6 +870,7 @@ function SplitPaneView({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -1004,6 +1011,7 @@ function SplitPaneView({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={handleCloseTabsToLeft} onCloseTabsToRight={handleCloseTabsToRight} onCloseOtherTabs={handleCloseOtherTabs} diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 1469c769c..f7894079d 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -1,30 +1,31 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Alert, Pressable, Text, TextInput, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronRight, Globe, Monitor, Pencil, RotateCw, Trash2 } from "lucide-react-native"; -import type { HostConnection, HostProfile } from "@/types/host-connection"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Alert, Pressable, Text, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; +import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { getHostRuntimeStore, isHostRuntimeConnected, + useHostMutations, useHostRuntimeClient, useHostRuntimeIsConnected, useHostRuntimeSnapshot, - useHostMutations, useHosts, } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; -import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; -import { confirmDialog } from "@/utils/confirm-dialog"; -import { settingsStyles } from "@/styles/settings"; -import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; -import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; -import { useDaemonConfig } from "@/hooks/use-daemon-config"; -import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; -import { SettingsSection } from "@/screens/settings/settings-section"; import { ProvidersSection } from "@/screens/settings/providers-section"; -import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; -import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { useSessionStore } from "@/stores/session-store"; +import { settingsStyles } from "@/styles/settings"; +import type { HostConnection, HostProfile } from "@/types/host-connection"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; const RESTART_CONFIRMATION_MESSAGE = "This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically."; @@ -68,7 +69,6 @@ function formatDaemonVersionBadge(version: string | null): string | null { return trimmed.startsWith("v") ? trimmed : `v${trimmed}`; } -const RENAME_HOST_HEADER: SheetHeader = { title: "Rename host" }; const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" }; const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" }; @@ -178,64 +178,23 @@ export function HostRenameButton({ host }: { host: HostProfile }) { const { theme } = useUnistyles(); const { renameHost } = useHostMutations(); const [isEditing, setIsEditing] = useState(false); - const [draftLabel, setDraftLabel] = useState(host.label ?? ""); - const [isSaving, setIsSaving] = useState(false); - const inputRef = useRef(null); - useEffect(() => { - setDraftLabel(host.label ?? ""); - }, [host.serverId, host.label]); - - useEffect(() => { - if (isEditing) { - const timeout = setTimeout(() => inputRef.current?.focus(), 50); - return () => clearTimeout(timeout); - } - return undefined; - }, [isEditing]); - - const handleSave = useCallback(async () => { - const nextLabel = draftLabel.trim(); - if (!nextLabel) { - Alert.alert("Label required", "Enter a label for this host."); - return; - } - if (isSaving) return; - if (nextLabel === host.label.trim()) { - setIsEditing(false); - return; - } - try { - setIsSaving(true); + const handleSubmit = useCallback( + async (value: string) => { + const nextLabel = value.trim(); + if (nextLabel === host.label.trim()) return; await renameHost(host.serverId, nextLabel); - setIsEditing(false); - } catch (error) { - console.error("[HostPage] Failed to rename host", error); - Alert.alert("Error", "Unable to save host"); - } finally { - setIsSaving(false); - } - }, [draftLabel, host.label, host.serverId, isSaving, renameHost]); + }, + [host.label, host.serverId, renameHost], + ); - const handleCancel = useCallback(() => { - if (isSaving) return; - setDraftLabel(host.label ?? ""); - setIsEditing(false); - }, [host.label, isSaving]); - - const handleStartEdit = useCallback(() => { - setDraftLabel(host.label ?? ""); - setIsEditing(true); - }, [host.label]); - - const handleSavePress = useCallback(() => { - void handleSave(); - }, [handleSave]); + const openEditor = useCallback(() => setIsEditing(true), []); + const closeEditor = useCallback(() => setIsEditing(false), []); return ( <> - - - - - - - - - + /> ); } @@ -836,25 +763,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - renameBody: { - gap: theme.spacing[3], - paddingBottom: theme.spacing[2], - }, - renameInput: { - backgroundColor: theme.colors.surface0, - color: theme.colors.foreground, - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.border, - fontSize: theme.fontSize.base, - }, - renameActions: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, })); const FLEX_1_STYLE = { flex: 1 }; diff --git a/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx b/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx new file mode 100644 index 000000000..958329cf7 --- /dev/null +++ b/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx @@ -0,0 +1,125 @@ +import { useCallback, useState } from "react"; +import { type QueryClient } from "@tanstack/react-query"; +import type { DaemonClient } from "@server/client/daemon-client"; +import type { ListTerminalsResponse } from "@server/shared/messages"; +import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { useSessionStore } from "@/stores/session-store"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +interface RenamingTabState { + kind: "terminal" | "agent"; + id: string; + currentTitle: string; +} + +interface UseWorkspaceTabRenameInput { + client: DaemonClient | null; + normalizedServerId: string; + queryClient: QueryClient; + terminalsData: ListTerminalsResponse["payload"] | undefined; + terminalsQueryKey: readonly unknown[]; +} + +interface UseWorkspaceTabRenameResult { + renamingTab: RenamingTabState | null; + handleRenameTab: (tab: WorkspaceTabDescriptor) => void; + handleRenameModalSubmit: (nextTitle: string) => Promise; + handleRenameModalClose: () => void; +} + +export function useWorkspaceTabRename( + input: UseWorkspaceTabRenameInput, +): UseWorkspaceTabRenameResult { + const { client, normalizedServerId, queryClient, terminalsData, terminalsQueryKey } = input; + const [renamingTab, setRenamingTab] = useState(null); + + const handleRenameTab = useCallback( + (tab: WorkspaceTabDescriptor) => { + if (tab.target.kind === "terminal") { + const { terminalId } = tab.target; + const terminal = terminalsData?.terminals.find((entry) => entry.id === terminalId) ?? null; + const currentTitle = terminal?.title ?? terminal?.name ?? ""; + setRenamingTab({ kind: "terminal", id: terminalId, currentTitle }); + return; + } + if (tab.target.kind === "agent") { + const { agentId } = tab.target; + const agent = + useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + const currentTitle = agent?.title ?? ""; + setRenamingTab({ kind: "agent", id: agentId, currentTitle }); + } + }, + [normalizedServerId, terminalsData], + ); + + const handleRenameModalSubmit = useCallback( + async (nextTitle: string) => { + if (!renamingTab) return; + if (!client) { + throw new Error("Host is not connected"); + } + const trimmed = nextTitle.trim(); + if (renamingTab.kind === "terminal") { + const result = await client.renameTerminal({ + terminalId: renamingTab.id, + title: trimmed, + }); + if (!result.success) { + throw new Error(result.error ?? "Failed to rename terminal"); + } + void queryClient.invalidateQueries({ queryKey: terminalsQueryKey }); + return; + } + await client.updateAgent(renamingTab.id, { name: trimmed }); + void queryClient.invalidateQueries({ + queryKey: ["sidebarAgentsList", normalizedServerId], + }); + void queryClient.invalidateQueries({ + queryKey: ["allAgents", normalizedServerId], + }); + }, + [client, normalizedServerId, queryClient, renamingTab, terminalsQueryKey], + ); + + const handleRenameModalClose = useCallback(() => { + setRenamingTab(null); + }, []); + + return { + renamingTab, + handleRenameTab, + handleRenameModalSubmit, + handleRenameModalClose, + }; +} + +export interface WorkspaceTabRenameModalProps { + renamingTab: RenamingTabState | null; + onClose: () => void; + onSubmit: (nextTitle: string) => Promise; +} + +export function WorkspaceTabRenameModal({ + renamingTab, + onClose, + onSubmit, +}: WorkspaceTabRenameModalProps) { + const title = renamingTab?.kind === "terminal" ? "Rename terminal" : "Rename agent"; + const initialValue = renamingTab?.currentTitle ?? ""; + const testID = renamingTab + ? `workspace-tab-rename-modal-${renamingTab.kind}-${renamingTab.id}` + : undefined; + return ( + + ); +} diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 5b75de7a9..f55ea4b68 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -22,6 +22,7 @@ import { ArrowRightToLine, Columns2, Copy, + Pencil, RotateCw, Rows2, Globe, @@ -71,6 +72,7 @@ const ThemedRotateCw = withUnistyles(RotateCw); const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); const ThemedCopyX = withUnistyles(CopyX); +const ThemedPencil = withUnistyles(Pencil); const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedGlobe = withUnistyles(Globe); @@ -101,6 +103,8 @@ function TabContextMenuItem({ return ; case "copy-x": return ; + case "pencil": + return ; case "x": return ; default: @@ -150,6 +154,7 @@ interface WorkspaceDesktopTabsRowProps { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; @@ -467,6 +472,7 @@ export function WorkspaceDesktopTabsRow({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -599,6 +605,7 @@ export function WorkspaceDesktopTabsRow({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -630,6 +637,7 @@ export function WorkspaceDesktopTabsRow({ onCopyResumeCommand, onNavigateTab, onReloadAgent, + onRenameTab, setHoveredCloseTabKey, setHoveredTabKey, tabDropPreviewIndex, @@ -791,6 +799,7 @@ function ResolvedDesktopTabChip({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -815,6 +824,7 @@ function ResolvedDesktopTabChip({ onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; @@ -838,6 +848,7 @@ function ResolvedDesktopTabChip({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsToLeft, onCloseTabsToRight, @@ -853,6 +864,7 @@ function ResolvedDesktopTabChip({ onCopyAgentId, onCopyResumeCommand, onReloadAgent, + onRenameTab, tabCount, ], ); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 25a61f089..33fe3b300 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -11,7 +11,7 @@ import { import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter, type Href } from "expo-router"; import * as Clipboard from "expo-clipboard"; import { DiffStat } from "@/components/diff-stat"; @@ -26,6 +26,7 @@ import { Globe, Import as ImportIcon, PanelRight, + Pencil, RotateCw, Settings, SquarePen, @@ -109,6 +110,10 @@ import { WorkspaceTabOptionRow, type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; +import { + useWorkspaceTabRename, + WorkspaceTabRenameModal, +} from "@/screens/workspace/use-workspace-tab-rename"; import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, @@ -181,6 +186,7 @@ const ThemedRotateCw = withUnistyles(RotateCw); const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); const ThemedCopyX = withUnistyles(CopyX); +const ThemedPencil = withUnistyles(Pencil); const ThemedX = withUnistyles(X); const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquareTerminal = withUnistyles(SquareTerminal); @@ -297,6 +303,7 @@ interface MobileWorkspaceTabSwitcherProps { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsAbove: (tabId: string) => Promise | void; onCloseTabsBelow: (tabId: string) => Promise | void; @@ -436,6 +443,8 @@ function MobileTabDropdownMenuItem({ return ; case "copy-x": return ; + case "pencil": + return ; case "x": return ; default: @@ -473,6 +482,7 @@ function MobileWorkspaceTabOption({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsAbove, onCloseTabsBelow, @@ -489,6 +499,7 @@ function MobileWorkspaceTabOption({ onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsAbove: (tabId: string) => Promise | void; onCloseTabsBelow: (tabId: string) => Promise | void; @@ -504,6 +515,7 @@ function MobileWorkspaceTabOption({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsBefore: onCloseTabsAbove, onCloseTabsAfter: onCloseTabsBelow, @@ -558,6 +570,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsAbove, onCloseTabsBelow, @@ -611,6 +624,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTab={onCloseTab} onCloseTabsAbove={onCloseTabsAbove} onCloseTabsBelow={onCloseTabsBelow} @@ -627,6 +641,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsAbove, onCloseTabsBelow, @@ -1532,6 +1547,7 @@ function WorkspaceScreenContent({ openWorkspaceTabFocused, toast, }); + const queryClient = useQueryClient(); const { createMutation: createTerminalMutation, createTerminal, @@ -1543,6 +1559,7 @@ function WorkspaceScreenContent({ liveTerminalIds, pendingCreateInput: pendingTerminalCreateInput, query: terminalsQuery, + queryKey: terminalsQueryKey, removeTerminalFromCache, standaloneTerminalIds, terminals, @@ -2125,6 +2142,14 @@ function WorkspaceScreenContent({ const [_hoveredTabKey, setHoveredTabKey] = useState(null); const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState(null); + const { handleRenameTab, renamingTab, handleRenameModalSubmit, handleRenameModalClose } = + useWorkspaceTabRename({ + client, + normalizedServerId, + queryClient, + terminalsData: terminalsQuery.data, + terminalsQueryKey, + }); const tabByKey = useMemo(() => { const map = new Map(); @@ -3152,6 +3177,7 @@ function WorkspaceScreenContent({ onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} onReloadAgent={handleReloadAgent} + onRenameTab={handleRenameTab} onCloseTabsToLeft={handleCloseTabsToLeftInPane} onCloseTabsToRight={handleCloseTabsToRightInPane} onCloseOtherTabs={handleCloseOtherTabsInPane} @@ -3186,6 +3212,7 @@ function WorkspaceScreenContent({ handleCopyResumeCommand, handleCopyAgentId, handleReloadAgent, + handleRenameTab, handleCloseTabsToLeftInPane, handleCloseTabsToRightInPane, handleCloseOtherTabsInPane, @@ -3268,6 +3295,7 @@ function WorkspaceScreenContent({ onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} onReloadAgent={handleReloadAgent} + onRenameTab={handleRenameTab} onCloseTab={handleCloseTabById} onCloseTabsAbove={handleCloseTabsToLeft} onCloseTabsBelow={handleCloseTabsToRight} @@ -3289,6 +3317,7 @@ function WorkspaceScreenContent({ onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} onReloadAgent={handleReloadAgent} + onRenameTab={handleRenameTab} onCloseTabsToLeft={handleCloseTabsToLeft} onCloseTabsToRight={handleCloseTabsToRight} onCloseOtherTabs={handleCloseOtherTabs} @@ -3337,6 +3366,11 @@ function WorkspaceScreenContent({ onClose={closeImportSheet} onImportedAgent={handleImportedAgent} /> + ) diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts index 43bf39d3a..6c1c2e108 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts @@ -16,6 +16,7 @@ describe("buildWorkspaceTabMenuEntries", () => { const onCopyResumeCommand = vi.fn(); const onCopyAgentId = vi.fn(); const onReloadAgent = vi.fn(); + const onRenameTab = vi.fn(); const onCloseTab = vi.fn(); const onCloseTabsBefore = vi.fn(); const onCloseTabsAfter = vi.fn(); @@ -30,6 +31,7 @@ describe("buildWorkspaceTabMenuEntries", () => { onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsBefore, onCloseTabsAfter, @@ -39,6 +41,7 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([ "Copy resume command", "Copy agent id", + "Rename", "Close to the left", "Close to the right", "Close other tabs", @@ -57,6 +60,7 @@ describe("buildWorkspaceTabMenuEntries", () => { onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), onCloseTab: vi.fn(), onCloseTabsBefore: vi.fn(), onCloseTabsAfter: vi.fn(), @@ -66,6 +70,7 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([ "Copy resume command", "Copy agent id", + "Rename", "Close tabs above", "Close tabs below", "Close other tabs", @@ -74,7 +79,7 @@ describe("buildWorkspaceTabMenuEntries", () => { ]); }); - it("omits agent copy actions for non-agent tabs", () => { + it("omits agent copy actions and rename for draft tabs", () => { const entries = buildWorkspaceTabMenuEntries({ surface: "mobile", tab: { @@ -89,6 +94,7 @@ describe("buildWorkspaceTabMenuEntries", () => { onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), onCloseTab: vi.fn(), onCloseTabsBefore: vi.fn(), onCloseTabsAfter: vi.fn(), @@ -101,6 +107,7 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(entries.some((entry) => entry.kind === "item" && entry.label === "Reload agent")).toBe( false, ); + expect(entries.some((entry) => entry.kind === "item" && entry.label === "Rename")).toBe(false); expect(entries.some((entry) => entry.kind === "separator")).toBe(false); }); @@ -114,6 +121,7 @@ describe("buildWorkspaceTabMenuEntries", () => { onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), onCloseTab: vi.fn(), onCloseTabsBefore: vi.fn(), onCloseTabsAfter: vi.fn(), @@ -128,4 +136,128 @@ describe("buildWorkspaceTabMenuEntries", () => { }), ); }); + + it("invokes onRenameTab when the rename entry is selected for agent tabs", () => { + const onRenameTab = vi.fn(); + const tab = createAgentTab(); + const entries = buildWorkspaceTabMenuEntries({ + surface: "desktop", + tab, + index: 0, + tabCount: 1, + menuTestIDBase: "workspace-tab-context-agent_123", + onCopyResumeCommand: vi.fn(), + onCopyAgentId: vi.fn(), + onReloadAgent: vi.fn(), + onRenameTab, + onCloseTab: vi.fn(), + onCloseTabsBefore: vi.fn(), + onCloseTabsAfter: vi.fn(), + onCloseOtherTabs: vi.fn(), + }); + + const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename"); + if (!renameEntry || renameEntry.kind !== "item") { + throw new Error("Rename entry missing"); + } + renameEntry.onSelect(); + + expect(onRenameTab).toHaveBeenCalledWith(tab); + }); + + it("includes rename as the first entry for terminal tabs", () => { + const onRenameTab = vi.fn(); + const terminalTab: WorkspaceTabDescriptor = { + key: "terminal_abc", + tabId: "terminal_abc", + kind: "terminal", + target: { kind: "terminal", terminalId: "terminal-abc" }, + }; + const entries = buildWorkspaceTabMenuEntries({ + surface: "desktop", + tab: terminalTab, + index: 0, + tabCount: 1, + menuTestIDBase: "workspace-tab-context-terminal_abc", + onCopyResumeCommand: vi.fn(), + onCopyAgentId: vi.fn(), + onReloadAgent: vi.fn(), + onRenameTab, + onCloseTab: vi.fn(), + onCloseTabsBefore: vi.fn(), + onCloseTabsAfter: vi.fn(), + onCloseOtherTabs: vi.fn(), + }); + + const labels = entries.filter((entry) => entry.kind === "item").map((entry) => entry.label); + expect(labels[0]).toBe("Rename"); + expect(labels).not.toContain("Copy resume command"); + expect(labels).not.toContain("Copy agent id"); + expect(labels).not.toContain("Reload agent"); + + const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename"); + if (!renameEntry || renameEntry.kind !== "item") { + throw new Error("Rename entry missing"); + } + renameEntry.onSelect(); + expect(onRenameTab).toHaveBeenCalledWith(terminalTab); + }); + + it("uses the same rename entry shape for agent and terminal tabs", () => { + const terminalTab: WorkspaceTabDescriptor = { + key: "terminal_abc", + tabId: "terminal_abc", + kind: "terminal", + target: { kind: "terminal", terminalId: "terminal-abc" }, + }; + const menuTestIDBase = "workspace-tab-context"; + const sharedInput = { + surface: "desktop" as const, + index: 0, + tabCount: 1, + menuTestIDBase, + onCopyResumeCommand: vi.fn(), + onCopyAgentId: vi.fn(), + onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), + onCloseTab: vi.fn(), + onCloseTabsBefore: vi.fn(), + onCloseTabsAfter: vi.fn(), + onCloseOtherTabs: vi.fn(), + }; + + const agentEntries = buildWorkspaceTabMenuEntries({ ...sharedInput, tab: createAgentTab() }); + const terminalEntries = buildWorkspaceTabMenuEntries({ ...sharedInput, tab: terminalTab }); + + const agentRename = agentEntries.find( + (entry) => entry.kind === "item" && entry.key === "rename", + ); + const terminalRename = terminalEntries.find( + (entry) => entry.kind === "item" && entry.key === "rename", + ); + if (!agentRename || agentRename.kind !== "item") throw new Error("Agent rename missing"); + if (!terminalRename || terminalRename.kind !== "item") + throw new Error("Terminal rename missing"); + + expect({ + key: agentRename.key, + label: agentRename.label, + icon: agentRename.icon, + testID: agentRename.testID, + }).toEqual({ + key: terminalRename.key, + label: terminalRename.label, + icon: terminalRename.icon, + testID: terminalRename.testID, + }); + + const agentSeparator = agentEntries + .slice(agentEntries.indexOf(agentRename) + 1) + .find((entry) => entry.kind === "separator"); + const terminalSeparator = terminalEntries + .slice(terminalEntries.indexOf(terminalRename) + 1) + .find((entry) => entry.kind === "separator"); + expect(agentSeparator?.key).toBe("rename-separator"); + expect(terminalSeparator?.key).toBe("rename-separator"); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index da57221a5..123135763 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -8,7 +8,14 @@ export type WorkspaceTabMenuEntry = kind: "item"; key: string; label: string; - icon?: "copy" | "rotate-cw" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x"; + icon?: + | "copy" + | "rotate-cw" + | "arrow-left-to-line" + | "arrow-right-to-line" + | "copy-x" + | "pencil" + | "x"; hint?: string; tooltip?: string; disabled?: boolean; @@ -30,6 +37,7 @@ interface BuildWorkspaceTabMenuEntriesInput { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsBefore: (tabId: string) => Promise | void; onCloseTabsAfter: (tabId: string) => Promise | void; @@ -43,6 +51,7 @@ interface BuildWorkspaceDesktopTabActionsInput { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; @@ -102,6 +111,7 @@ export function buildWorkspaceTabMenuEntries( onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsBefore, onCloseTabsAfter, @@ -135,9 +145,22 @@ export function buildWorkspaceTabMenuEntries( void onCopyAgentId(agentId); }, }); + } + + if (tab.target.kind === "agent" || tab.target.kind === "terminal") { + entries.push({ + kind: "item", + key: "rename", + label: "Rename", + icon: "pencil", + testID: `${menuTestIDBase}-rename`, + onSelect: () => { + onRenameTab(tab); + }, + }); entries.push({ kind: "separator", - key: "copy-separator", + key: "rename-separator", }); } @@ -217,6 +240,7 @@ export function buildWorkspaceDesktopTabActions( onCopyResumeCommand: input.onCopyResumeCommand, onCopyAgentId: input.onCopyAgentId, onReloadAgent: input.onReloadAgent, + onRenameTab: input.onRenameTab, onCloseTab: input.onCloseTab, onCloseTabsBefore: input.onCloseTabsToLeft, onCloseTabsAfter: input.onCloseTabsToRight, diff --git a/packages/server/package.json b/packages/server/package.json index 542758ffa..d3832ba9a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -22,6 +22,11 @@ "types": "./dist/server/utils/tool-call-parsers.d.ts", "source": "./src/utils/tool-call-parsers.ts", "default": "./dist/server/utils/tool-call-parsers.js" + }, + "./utils/branch-slug": { + "types": "./dist/server/utils/branch-slug.d.ts", + "source": "./src/utils/branch-slug.ts", + "default": "./dist/server/utils/branch-slug.js" } }, "publishConfig": { diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index f79d7014f..6fb456990 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -1750,6 +1750,119 @@ test("requests checkout pull via RPC", async () => { }); }); +test("renames a branch via RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.renameBranch({ + cwd: "/tmp/project", + branch: "feature/new-name", + requestId: "req-rename-branch", + }); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "checkout.rename_branch.request"; + cwd: string; + branch: string; + requestId: string; + }; + }; + expect(request.message.type).toBe("checkout.rename_branch.request"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.branch).toBe("feature/new-name"); + expect(request.message.requestId).toBe("req-rename-branch"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "checkout.rename_branch.response", + payload: { + requestId: "req-rename-branch", + success: true, + cwd: "/tmp/project", + currentBranch: "feature/new-name", + error: null, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + requestId: "req-rename-branch", + success: true, + cwd: "/tmp/project", + currentBranch: "feature/new-name", + error: null, + }); +}); + +test("returns renameBranch business failures", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.renameBranch({ + cwd: "/tmp/project", + branch: "already-exists", + requestId: "req-rename-branch-fail", + }); + + expect(mock.sent).toHaveLength(1); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "checkout.rename_branch.response", + payload: { + requestId: "req-rename-branch-fail", + success: false, + cwd: "/tmp/project", + currentBranch: null, + error: { code: "NOT_ALLOWED", message: "Branch already exists" }, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + requestId: "req-rename-branch-fail", + success: false, + cwd: "/tmp/project", + currentBranch: null, + error: { code: "NOT_ALLOWED", message: "Branch already exists" }, + }); +}); + test("resubscribes checkout diff streams after reconnect", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 9468c1134..9ce4a06ea 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -5,7 +5,9 @@ import { AgentCreatedStatusPayloadSchema, AgentRefreshedStatusPayloadSchema, AgentResumedStatusPayloadSchema, + CheckoutRenameBranchResponseSchema, parseServerInfoStatusPayload, + RenameTerminalResponseSchema, RestartRequestedStatusPayloadSchema, ShutdownRequestedStatusPayloadSchema, SessionInboundMessageSchema, @@ -287,6 +289,7 @@ type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["pay type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"]; type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"]; +export type RenameBranchResult = z.infer["payload"]; type StashSavePayload = StashSaveResponse["payload"]; type StashPopPayload = StashPopResponse["payload"]; type StashListPayload = StashListResponse["payload"]; @@ -355,6 +358,7 @@ type DictationFinishAcceptedPayload = Extract< type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"]; type ListTerminalsPayload = ListTerminalsResponse["payload"]; type CreateTerminalPayload = CreateTerminalResponse["payload"]; +export type RenameTerminalResult = z.infer["payload"]; type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"]; type CloseItemsPayload = CloseItemsResponse["payload"]; type KillTerminalPayload = KillTerminalResponse["payload"]; @@ -618,6 +622,16 @@ export interface UpdateScheduleOptions { expiresAt?: string | null; requestId?: string; } +export interface RenameBranchInput { + cwd: string; + branch: string; + requestId?: string; +} +export interface RenameTerminalInput { + terminalId: string; + title: string; + requestId?: string; +} type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"]; type OpenInEditorPayload = OpenInEditorResponseMessage["payload"]; type OpenProjectPayload = OpenProjectResponseMessage["payload"]; @@ -2933,6 +2947,19 @@ export class DaemonClient { }); } + async renameBranch(input: RenameBranchInput): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: input.requestId, + message: { + type: "checkout.rename_branch.request", + cwd: input.cwd, + branch: input.branch, + }, + responseType: "checkout.rename_branch.response", + timeout: 30000, + }); + } + async stashSave( cwd: string, options?: { branch?: string }, @@ -3661,6 +3688,19 @@ export class DaemonClient { }); } + async renameTerminal(input: RenameTerminalInput): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: input.requestId, + message: { + type: "terminal.rename.request", + terminalId: input.terminalId, + title: input.title, + }, + responseType: "terminal.rename.response", + timeout: 10000, + }); + } + async subscribeTerminal( terminalId: string, requestId?: string, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index c4e28641d..2c5a2aae8 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { createTestLogger } from "../../test-utils/test-logger.js"; -import { AgentManager } from "./agent-manager.js"; +import { AgentManager, type ManagedAgent } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js"; import type { StoredAgentRecord } from "./agent-storage.js"; @@ -1457,6 +1457,111 @@ test("setTitle bumps updatedAt and persists title in the same snapshot write", a expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt)); }); +test("setGeneratedTitleIfUnset preserves an existing user title", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-preserve-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000128", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.setTitle(snapshot.id, "User title"); + await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title"); + + const after = await storage.get(snapshot.id); + expect(after?.title).toBe("User title"); +}); + +test("setGeneratedTitleIfUnset persists generated title when no title exists", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-empty-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000129", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title"); + + const after = await storage.get(snapshot.id); + expect(after?.title).toBe("Generated title"); +}); + +test("setGeneratedTitleIfUnset ignores blank generated titles", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-blank-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000130", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + const before = await storage.get(snapshot.id); + expect(before).not.toBeNull(); + + const stateEvents: ManagedAgent[] = []; + manager.subscribe( + (event) => { + if (event.type === "agent_state") { + stateEvents.push(event.agent); + } + }, + { agentId: snapshot.id, replayState: false }, + ); + + await manager.setGeneratedTitleIfUnset(snapshot.id, " "); + + const after = await storage.get(snapshot.id); + expect(after?.title).toBeNull(); + expect(after?.updatedAt).toBe(before?.updatedAt); + expect(manager.getAgent(snapshot.id)?.updatedAt.toISOString()).toBe(before?.updatedAt); + expect(stateEvents).toEqual([]); +}); + +test("setGeneratedTitleIfUnset throws for an unknown agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-unknown-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + }); + + await expect( + manager.setGeneratedTitleIfUnset("00000000-0000-4000-8000-000000000999", "Generated title"), + ).rejects.toThrow("Unknown agent '00000000-0000-4000-8000-000000000999'"); +}); + test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-persist-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 9ab21b890..570ecbbab 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1228,6 +1228,23 @@ export class AgentManager { this.emitState(agent, { persist: false }); } + async setGeneratedTitleIfUnset(agentId: string, title: string): Promise { + const agent = this.requireAgent(agentId); + const normalizedTitle = title.trim(); + if (!normalizedTitle) { + return; + } + + const registry = this.requireRegistry(); + const persisted = await registry.setGeneratedTitleIfUnset(agent.id, normalizedTitle); + if (!persisted) { + return; + } + + agent.updatedAt = new Date(persisted.updatedAt); + this.emitState(agent, { persist: false }); + } + async setLabels(agentId: string, labels: Record): Promise { const agent = this.requireAgent(agentId); agent.labels = { ...agent.labels, ...labels }; diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts index 016c2e821..f71da3348 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -158,7 +158,7 @@ export async function generateAndApplyAgentMetadata( if (needs.needsTitle && typeof result.title === "string") { const normalizedTitle = normalizeAutoTitle(result.title); if (normalizedTitle) { - await options.agentManager.setTitle(options.agentId, normalizedTitle); + await options.agentManager.setGeneratedTitleIfUnset(options.agentId, normalizedTitle); } } } diff --git a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts index 1959510c3..2e075f675 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts @@ -39,8 +39,8 @@ function createDeps( describe("agent metadata generator auto-title", () => { it("caps generated auto titles at 40 characters before persisting", async () => { - const setTitle = vi.fn().mockResolvedValue(undefined); - const manager = { setTitle } as unknown as AgentManager; + const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined); + const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager; const generatedTitle = "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS + 25); const generateStructured = vi.fn().mockResolvedValue({ title: generatedTitle }) as NonNullable< AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] @@ -56,13 +56,16 @@ describe("agent metadata generator auto-title", () => { deps: createDeps(generateStructured), }); - expect(setTitle).toHaveBeenCalledTimes(1); - expect(setTitle).toHaveBeenCalledWith("agent-1", "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS)); + expect(setGeneratedTitleIfUnset).toHaveBeenCalledTimes(1); + expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith( + "agent-1", + "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS), + ); }); it("does not generate an auto title when an explicit title is provided", async () => { - const setTitle = vi.fn().mockResolvedValue(undefined); - const manager = { setTitle } as unknown as AgentManager; + const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined); + const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager; const generateStructured = vi.fn().mockResolvedValue({ title: "Generated" }) as NonNullable< AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] >; @@ -78,12 +81,12 @@ describe("agent metadata generator auto-title", () => { }); expect(generateStructured).not.toHaveBeenCalled(); - expect(setTitle).not.toHaveBeenCalled(); + expect(setGeneratedTitleIfUnset).not.toHaveBeenCalled(); }); it("generates titles independently from workspace branch naming", async () => { - const setTitle = vi.fn().mockResolvedValue(undefined); - const manager = { setTitle } as unknown as AgentManager; + const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined); + const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager; const generateStructured = vi .fn() .mockResolvedValue({ title: "Generated title" }) as NonNullable< @@ -108,7 +111,10 @@ describe("agent metadata generator auto-title", () => { persistSession: false, }), ); - expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title"); + expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith( + "agent-suppressed-branch", + "Generated title", + ); }); it.each([ @@ -170,8 +176,8 @@ async function generateTitlePromptWithConfig(config: unknown): Promise<{ prompt: writeFileSync(path.join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`); } - const setTitle = vi.fn().mockResolvedValue(undefined); - const manager = { setTitle } as unknown as AgentManager; + const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined); + const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager; const generateStructured = vi.fn().mockResolvedValue({ title: "Generated title" }) as NonNullable< AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] >; diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index f1db9e77a..61e56e435 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -321,6 +321,42 @@ describe("AgentStorage", () => { ); }); + test("setGeneratedTitleIfUnset aborts when a user title is already set", async () => { + const agentId = "agent-generated-title-race"; + await storage.applySnapshot(createManagedAgent({ id: agentId })); + await storage.setTitle(agentId, "User title"); + + const result = await storage.setGeneratedTitleIfUnset(agentId, "Generated title"); + + expect(result).toBeNull(); + const record = await storage.get(agentId); + expect(record?.title).toBe("User title"); + }); + + test("setGeneratedTitleIfUnset with concurrent writes does not corrupt state", async () => { + const agentId = "agent-generated-title-concurrent"; + await storage.applySnapshot(createManagedAgent({ id: agentId })); + + await Promise.all([ + storage.setGeneratedTitleIfUnset(agentId, "Title A"), + storage.setGeneratedTitleIfUnset(agentId, "Title B"), + ]); + + const record = await storage.get(agentId); + expect(["Title A", "Title B"]).toContain(record?.title); + }); + + test("setGeneratedTitleIfUnset writes the generated title only when title is empty", async () => { + const agentId = "agent-generated-title-empty"; + await storage.applySnapshot(createManagedAgent({ id: agentId })); + + const written = await storage.setGeneratedTitleIfUnset(agentId, "Generated title"); + + expect(written?.title).toBe("Generated title"); + const record = await storage.get(agentId); + expect(record?.title).toBe("Generated title"); + }); + test("applySnapshot accepts explicit title overrides", async () => { const agentId = "agent-override"; await storage.applySnapshot(createManagedAgent({ id: agentId }), { title: "Provided Title" }); diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 6c05f61a0..c747395e5 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -115,44 +115,51 @@ export class AgentStorage { async upsert(record: StoredAgentRecord): Promise { await this.load(); + await this.queueRecordWrite(record); + } + + private queueRecordWrite(record: StoredAgentRecord): Promise { const agentId = record.id; const prev = this.pendingWrites.get(agentId) ?? Promise.resolve(); const next = prev.then(async () => { if (this.deleting.has(agentId)) { - return; + return undefined; } - const nextPath = this.buildRecordPath(record); - const previousPath = this.pathById.get(agentId); - - await fs.mkdir(path.dirname(nextPath), { recursive: true }); - await writeFileAtomically(nextPath, JSON.stringify(record, null, 2)); - this.addIndexedPath(agentId, nextPath); - - if (previousPath && previousPath !== nextPath) { - try { - await fs.unlink(previousPath); - } catch { - // ignore cleanup errors - } - this.removeIndexedPath(agentId, previousPath); - } - - this.cache.set(agentId, record); - this.pathById.set(agentId, nextPath); - return; + await this.writeRecord(record); + return undefined; }); - this.pendingWrites.set( - agentId, - next.finally(() => { - if (this.pendingWrites.get(agentId) === next) { - this.pendingWrites.delete(agentId); - } - }), - ); + const tracked = next.finally(() => { + if (this.pendingWrites.get(agentId) === tracked) { + this.pendingWrites.delete(agentId); + } + }); - await next; + this.pendingWrites.set(agentId, tracked); + return tracked; + } + + private async writeRecord(record: StoredAgentRecord): Promise { + const agentId = record.id; + const nextPath = this.buildRecordPath(record); + const previousPath = this.pathById.get(agentId); + + await fs.mkdir(path.dirname(nextPath), { recursive: true }); + await writeFileAtomically(nextPath, JSON.stringify(record, null, 2)); + this.addIndexedPath(agentId, nextPath); + + if (previousPath && previousPath !== nextPath) { + try { + await fs.unlink(previousPath); + } catch { + // ignore cleanup errors + } + this.removeIndexedPath(agentId, previousPath); + } + + this.cache.set(agentId, record); + this.pathById.set(agentId, nextPath); } beginDelete(agentId: string): void { @@ -225,6 +232,40 @@ export class AgentStorage { await this.upsert({ ...record, title }); } + async setGeneratedTitleIfUnset( + agentId: string, + title: string, + ): Promise { + await this.load(); + await this.waitForPendingWrite(agentId); + const record = this.cache.get(agentId) ?? null; + if (!record) { + throw new Error(`Agent ${agentId} not found`); + } + if (record.title) { + return null; + } + + // Re-drain pending writes: a concurrent setTitle may have queued between the + // first drain and here. After waiting, re-read the cache before writing. + await this.waitForPendingWrite(agentId); + const latestRecord = this.cache.get(agentId) ?? null; + if (!latestRecord) { + throw new Error(`Agent ${agentId} not found`); + } + if (latestRecord.title) { + return null; + } + + const nextRecord = { + ...latestRecord, + title, + updatedAt: new Date().toISOString(), + }; + await this.queueRecordWrite(nextRecord); + return nextRecord; + } + async flush(): Promise { await this.load().catch(() => undefined); const writes = Array.from(this.pendingWrites.values()); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 4f32ab8e1..82d7fd94d 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -112,6 +112,7 @@ const checkoutGitMocks = vi.hoisted(() => ({ mergeToBase: vi.fn(), pullCurrentBranch: vi.fn(), pushCurrentBranch: vi.fn(), + renameCurrentBranch: vi.fn(), resolveBranchCheckout: vi.fn(), warmCheckoutShortstatInBackground: vi.fn(), })); @@ -203,6 +204,7 @@ vi.mock("../utils/checkout-git.js", async (importOriginal) => { mergeToBase: checkoutGitMocks.mergeToBase, pullCurrentBranch: checkoutGitMocks.pullCurrentBranch, pushCurrentBranch: checkoutGitMocks.pushCurrentBranch, + renameCurrentBranch: checkoutGitMocks.renameCurrentBranch, resolveBranchCheckout: checkoutGitMocks.resolveBranchCheckout, warmCheckoutShortstatInBackground: checkoutGitMocks.warmCheckoutShortstatInBackground, }; @@ -927,6 +929,16 @@ function createWorkspaceGitSnapshot( }; } +function createTerminalManagerStub(options?: { setTerminalTitle?: ReturnType }): { + setTerminalTitle: ReturnType; + subscribeTerminalsChanged: ReturnType; +} { + return { + setTerminalTitle: options?.setTerminalTitle ?? vi.fn(), + subscribeTerminalsChanged: vi.fn(() => () => {}), + }; +} + afterEach(() => { vi.clearAllMocks(); }); @@ -3158,6 +3170,208 @@ describe("session checkout switch branch handling", () => { }); }); +describe("session checkout rename branch handling", () => { + test("rejects invalid branch slugs without renaming", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + getSnapshot: vi.fn(), + peekSnapshot: vi.fn(), + }; + const session = createSessionForTest({ workspaceGitService, messages }); + + await session.handleMessage({ + type: "checkout.rename_branch.request", + cwd: "/tmp/repo", + branch: "Feature Name", + requestId: "request-rename-invalid", + }); + + expect(checkoutGitMocks.renameCurrentBranch).not.toHaveBeenCalled(); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "checkout.rename_branch.response", + payload: { + cwd: "/tmp/repo", + success: false, + currentBranch: null, + error: { + code: "UNKNOWN", + message: + "Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes", + }, + requestId: "request-rename-invalid", + }, + }); + }); + + test("reports null current branch when branch rename fails", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + getSnapshot: vi.fn(), + peekSnapshot: vi.fn(), + }; + const session = createSessionForTest({ workspaceGitService, messages }); + checkoutGitMocks.renameCurrentBranch.mockRejectedValue(new Error("branch already exists")); + + await session.handleMessage({ + type: "checkout.rename_branch.request", + cwd: "/tmp/repo", + branch: "feature/new-name", + requestId: "request-rename-failure", + }); + + expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith( + "/tmp/repo", + "feature/new-name", + ); + expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled(); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "checkout.rename_branch.response", + payload: { + cwd: "/tmp/repo", + success: false, + currentBranch: null, + error: { + code: "UNKNOWN", + message: "branch already exists", + }, + requestId: "request-rename-failure", + }, + }); + }); + + test("forces workspace git refresh after renaming the current branch", async () => { + const messages: unknown[] = []; + const github = { invalidate: vi.fn() }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue( + createWorkspaceGitSnapshot("/tmp/repo", { + git: { + currentBranch: "feature/new-name", + isDirty: false, + }, + }), + ), + peekSnapshot: vi.fn(() => + createWorkspaceGitSnapshot("/tmp/repo", { + git: { currentBranch: "feature/old-name" }, + }), + ), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + checkoutGitMocks.renameCurrentBranch.mockResolvedValue({ + previousBranch: "feature/old-name", + currentBranch: "feature/new-name", + }); + + await session.handleMessage({ + type: "checkout.rename_branch.request", + cwd: "/tmp/repo", + branch: "feature/new-name", + requestId: "request-rename-success", + }); + + expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith( + "/tmp/repo", + "feature/new-name", + ); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { + force: true, + reason: "rename-branch", + }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" }); + expect(messages).toContainEqual({ + type: "checkout.rename_branch.response", + payload: { + cwd: "/tmp/repo", + success: true, + currentBranch: "feature/new-name", + error: null, + requestId: "request-rename-success", + }, + }); + }); +}); + +describe("session terminal rename handling", () => { + test("rejects an empty terminal title without calling the terminal manager", async () => { + const messages: unknown[] = []; + const terminalManager = createTerminalManagerStub(); + const session = createSessionForTest({ terminalManager, messages }); + + await session.handleMessage({ + type: "terminal.rename.request", + terminalId: "terminal-1", + title: " ", + requestId: "request-empty-title", + }); + + expect(terminalManager.setTerminalTitle).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "terminal.rename.response", + payload: { + requestId: "request-empty-title", + success: false, + error: "Title is required", + }, + }); + }); + + test("reports when the terminal manager cannot find the terminal", async () => { + const messages: unknown[] = []; + const terminalManager = createTerminalManagerStub({ + setTerminalTitle: vi.fn(() => false), + }); + const session = createSessionForTest({ terminalManager, messages }); + + await session.handleMessage({ + type: "terminal.rename.request", + terminalId: "missing-terminal", + title: "Renamed terminal", + requestId: "request-missing-terminal", + }); + + expect(terminalManager.setTerminalTitle).toHaveBeenCalledWith( + "missing-terminal", + "Renamed terminal", + ); + expect(messages).toContainEqual({ + type: "terminal.rename.response", + payload: { + requestId: "request-missing-terminal", + success: false, + error: "Terminal not found", + }, + }); + }); + + test("trims and sets a valid terminal title", async () => { + const messages: unknown[] = []; + const terminalManager = createTerminalManagerStub({ + setTerminalTitle: vi.fn(() => true), + }); + const session = createSessionForTest({ terminalManager, messages }); + + await session.handleMessage({ + type: "terminal.rename.request", + terminalId: "terminal-1", + title: " Renamed terminal ", + requestId: "request-title-success", + }); + + expect(terminalManager.setTerminalTitle).toHaveBeenCalledWith("terminal-1", "Renamed terminal"); + expect(messages).toContainEqual({ + type: "terminal.rename.response", + payload: { + requestId: "request-title-success", + success: true, + error: null, + }, + }); + }); +}); + describe("session branch suggestions handling", () => { test("lists branch suggestions through the workspace git service", async () => { const messages: unknown[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 04ee2264b..46a0978cc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -20,6 +20,7 @@ import { type FileExplorerRequest, type FileDownloadTokenRequest, type GitSetupOptions, + type CheckoutRenameBranchRequest, type StartWorkspaceScriptRequest, type CloseItemsRequest, type SubscribeCheckoutDiffRequest, @@ -194,7 +195,9 @@ import { pullCurrentBranch, pushCurrentBranch, createPullRequest, + renameCurrentBranch, } from "../utils/checkout-git.js"; +import { validateBranchSlug } from "../utils/branch-slug.js"; import { getProjectIcon } from "../utils/project-icon.js"; import { expandTilde } from "../utils/path.js"; import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js"; @@ -308,8 +311,8 @@ type GitMutationRefreshReason = | "disable-pr-auto-merge" | "create-pr" | "switch-branch" - | "create-branch" | "rename-branch" + | "create-branch" | "stash-push" | "stash-pop" | "create-worktree"; @@ -431,6 +434,7 @@ type ProcessingPhase = "idle" | "transcribing"; interface WorkspaceGitWatchTarget { cwd: string; + workspaceId: string; watchers: FSWatcher[]; debounceTimer: ReturnType | null; refreshPromise: Promise | null; @@ -2046,12 +2050,8 @@ export class Session { return undefined; case "checkout_switch_branch_request": return this.handleCheckoutSwitchBranchRequest(msg); - case "stash_save_request": - return this.handleStashSaveRequest(msg); - case "stash_pop_request": - return this.handleStashPopRequest(msg); - case "stash_list_request": - return this.handleStashListRequest(msg); + case "checkout.rename_branch.request": + return this.handleCheckoutRenameBranchRequest(msg); case "checkout_commit_request": return this.handleCheckoutCommitRequest(msg); case "checkout_merge_request": @@ -2074,6 +2074,12 @@ export class Session { return this.handlePullRequestTimelineRequest(msg); case "github_search_request": return this.handleGitHubSearchRequest(msg); + case "stash_save_request": + return this.handleStashSaveRequest(msg); + case "stash_pop_request": + return this.handleStashPopRequest(msg); + case "stash_list_request": + return this.handleStashListRequest(msg); default: return undefined; } @@ -4933,16 +4939,35 @@ export class Session { target.lastBranchName = workspace?.name ?? null; } + private handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void { + const target = this.workspaceGitWatchTargets.get(normalizePersistedWorkspaceId(cwd)); + if (!target) { + return; + } + + const previousBranchName = target.lastBranchName; + if (branchName === previousBranchName) { + return; + } + + target.lastBranchName = branchName; + this.onBranchChanged?.(target.workspaceId, previousBranchName, branchName); + } + private syncWorkspaceGitObservers(workspaces: Iterable): void { for (const workspace of workspaces) { this.syncWorkspaceGitObserver(workspace.workspaceDirectory, { isGit: workspace.projectKind === "git", + workspaceId: workspace.id, }); this.rememberWorkspaceGitDescriptorState(workspace.workspaceDirectory, workspace); } } - private syncWorkspaceGitObserver(cwd: string, options: { isGit: boolean }): void { + private syncWorkspaceGitObserver( + cwd: string, + options: { isGit: boolean; workspaceId: string }, + ): void { const normalizedCwd = normalizePersistedWorkspaceId(cwd); if (!options.isGit) { this.removeWorkspaceGitSubscription(normalizedCwd); @@ -4953,9 +4978,22 @@ export class Session { return; } + const target: WorkspaceGitWatchTarget = { + cwd: normalizedCwd, + workspaceId: options.workspaceId, + watchers: [], + debounceTimer: null, + refreshPromise: null, + refreshQueued: false, + latestDescriptorStateKey: null, + lastBranchName: null, + }; + this.workspaceGitWatchTargets.set(normalizedCwd, target); + const subscription = this.workspaceGitService.registerWorkspace( { cwd: normalizedCwd }, (snapshot) => { + this.handleWorkspaceGitBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null); void this.emitWorkspaceUpdateForCwd(normalizedCwd); this.emitCheckoutStatusUpdate(normalizedCwd, snapshot); }, @@ -5062,6 +5100,58 @@ export class Session { } } + private async handleCheckoutRenameBranchRequest(msg: CheckoutRenameBranchRequest): Promise { + const { cwd, branch, requestId } = msg; + const validation = validateBranchSlug(branch); + + if (!validation.valid) { + this.emit({ + type: "checkout.rename_branch.response", + payload: { + cwd, + success: false, + currentBranch: null, + error: toCheckoutError(new Error(validation.error ?? "Invalid branch name")), + requestId, + }, + }); + return; + } + + try { + const result = await renameCurrentBranch(cwd, branch); + await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true }); + this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch); + + // Push a workspace_update immediately so the sidebar/header reflect + // the new branch name without waiting for the background git watcher. + await this.emitWorkspaceUpdateForCwd(cwd); + + this.emit({ + type: "checkout.rename_branch.response", + payload: { + cwd, + success: true, + currentBranch: result.currentBranch, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout.rename_branch.response", + payload: { + cwd, + success: false, + currentBranch: null, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + // --------------------------------------------------------------------------- // Stash handlers // --------------------------------------------------------------------------- @@ -6779,7 +6869,10 @@ export class Session { private async emitWorkspaceUpdateForCwd( cwd: string, - options?: { skipReconcile?: boolean; dedupeGitState?: boolean }, + options?: { + skipReconcile?: boolean; + dedupeGitState?: boolean; + }, ): Promise { const workspaces = await this.workspaceRegistry.list(); const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 38e47afd0..be7cec138 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test, vi } from "vitest"; import path from "node:path"; import type pino from "pino"; +import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; +import { ScriptRouteStore } from "./script-proxy.js"; import { Session, type SessionOptions } from "./session.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; +import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { WorkspaceGitListener, WorkspaceGitRuntimeSnapshot, @@ -24,7 +27,7 @@ interface SessionInternals { lastEmittedByWorkspaceId: Map; }; buildWorkspaceDescriptorMap: () => Promise>; - syncWorkspaceGitObserver(cwd: string, details: { isGit: boolean }): void; + syncWorkspaceGitObserver(cwd: string, details: { isGit: boolean; workspaceId: string }): void; listAgentPayloads: () => Promise; } @@ -92,7 +95,15 @@ function createWorkspaceRuntimeSnapshot( }; } -function createSessionForWorkspaceGitWatchTests(): { +function createSessionForWorkspaceGitWatchTests(options?: { + onBranchChanged?: ( + workspaceId: string, + oldBranch: string | null, + newBranch: string | null, + ) => void; + scriptRouteStore?: ScriptRouteStore; + scriptRuntimeStore?: WorkspaceScriptRuntimeStore; +}): { session: Session; emitted: Array<{ type: string; payload: unknown }>; projects: Map>; @@ -221,6 +232,10 @@ function createSessionForWorkspaceGitWatchTests(): { stt: null, tts: null, terminalManager: null, + scriptRouteStore: options?.scriptRouteStore, + scriptRuntimeStore: options?.scriptRuntimeStore, + onBranchChanged: options?.onBranchChanged, + getDaemonTcpPort: () => 6767, }); asInternals(session).listAgentPayloads = async () => []; @@ -305,7 +320,7 @@ describe("workspace git watch targets", () => { sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]); - sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true }); + sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" }); expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith( { cwd: REPO_CWD }, @@ -364,7 +379,7 @@ describe("workspace git watch targets", () => { lastEmittedByWorkspaceId: new Map(), }; - sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true }); + sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" }); emitted.length = 0; subscriptions[0]?.listener( @@ -407,6 +422,74 @@ describe("workspace git watch targets", () => { await session.cleanup(); }); + test("updates running service script URLs when the git branch changes", async () => { + const routeStore = new ScriptRouteStore(); + routeStore.registerRoute({ + hostname: "app.old-branch.paseo.localhost", + port: 4321, + workspaceId: "ws-10", + projectSlug: "paseo", + scriptName: "app", + }); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + runtimeStore.set({ + workspaceId: "ws-10", + scriptName: "app", + type: "service", + lifecycle: "running", + terminalId: "term-app", + exitCode: null, + }); + + const handleBranchChange = createBranchChangeRouteHandler({ + routeStore, + onRoutesChanged: vi.fn(), + }); + const { session, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests( + { + scriptRouteStore: routeStore, + scriptRuntimeStore: runtimeStore, + onBranchChanged: handleBranchChange, + }, + ); + const sessionAny = session as unknown as SessionInternals; + seedGitWorkspace({ + projects, + workspaces, + projectId: "proj-1", + workspaceId: "ws-10", + cwd: "/tmp/repo", + name: "old-branch", + }); + + sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true, workspaceId: "ws-10" }); + + subscriptions[0]?.listener( + createWorkspaceRuntimeSnapshot("/tmp/repo", { + git: { + currentBranch: "new-branch", + }, + }), + ); + + expect(routeStore.listRoutesForWorkspace("ws-10")).toEqual([ + expect.objectContaining({ + hostname: "app.new-branch.paseo.localhost", + projectSlug: "paseo", + scriptName: "app", + }), + ]); + expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([ + expect.objectContaining({ + scriptName: "app", + hostname: "app.new-branch.paseo.localhost", + proxyUrl: "http://app.new-branch.paseo.localhost:6767", + }), + ]); + + await session.cleanup(); + }); + test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => { const { session, emitted, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(); @@ -427,7 +510,7 @@ describe("workspace git watch targets", () => { lastEmittedByWorkspaceId: new Map(), }; - sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true }); + sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" }); emitted.length = 0; subscriptions[0]?.listener( diff --git a/packages/server/src/shared/messages.rename-entities.test.ts b/packages/server/src/shared/messages.rename-entities.test.ts new file mode 100644 index 000000000..f032f553b --- /dev/null +++ b/packages/server/src/shared/messages.rename-entities.test.ts @@ -0,0 +1,140 @@ +import { z } from "zod"; +import { describe, expect, test } from "vitest"; +import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js"; + +type SessionMessageOption = z.ZodDiscriminatedUnionOption<"type">; + +function schemaWithoutMessageTypes( + schema: { options: SessionMessageOption[] }, + excludedTypes: string[], +) { + const excluded = new Set(excludedTypes); + const options = schema.options.filter((option) => !excluded.has(option.shape.type.value)); + + return z.discriminatedUnion("type", options as [SessionMessageOption, ...SessionMessageOption[]]); +} + +describe("rename entity message schemas", () => { + test("new client schema still parses old daemon checkout and terminal responses", () => { + const checkoutResponse = SessionOutboundMessageSchema.parse({ + type: "checkout_switch_branch_response", + payload: { + cwd: "/tmp/repo", + success: true, + branch: "main", + source: "local", + error: null, + requestId: "request-switch", + }, + }); + const terminalResponse = SessionOutboundMessageSchema.parse({ + type: "kill_terminal_response", + payload: { + terminalId: "terminal-1", + success: true, + requestId: "request-kill", + }, + }); + + expect(checkoutResponse).toEqual({ + type: "checkout_switch_branch_response", + payload: { + cwd: "/tmp/repo", + success: true, + branch: "main", + source: "local", + error: null, + requestId: "request-switch", + }, + }); + expect(terminalResponse).toEqual({ + type: "kill_terminal_response", + payload: { + terminalId: "terminal-1", + success: true, + requestId: "request-kill", + }, + }); + }); + + test("old unions without rename variants reject rename messages and still parse existing messages", () => { + const legacyInboundSchema = schemaWithoutMessageTypes(SessionInboundMessageSchema, [ + "terminal.rename.request", + "checkout.rename_branch.request", + ]); + const legacyOutboundSchema = schemaWithoutMessageTypes(SessionOutboundMessageSchema, [ + "terminal.rename.response", + "checkout.rename_branch.response", + ]); + + expect( + legacyInboundSchema.safeParse({ + type: "terminal.rename.request", + terminalId: "terminal-1", + title: "Server logs", + requestId: "request-terminal-rename", + }).success, + ).toBe(false); + expect( + legacyInboundSchema.safeParse({ + type: "checkout.rename_branch.request", + cwd: "/tmp/repo", + branch: "feature/new-name", + requestId: "request-branch-rename", + }).success, + ).toBe(false); + expect( + legacyOutboundSchema.safeParse({ + type: "terminal.rename.response", + payload: { + requestId: "request-terminal-rename", + success: true, + error: null, + }, + }).success, + ).toBe(false); + expect( + legacyOutboundSchema.safeParse({ + type: "checkout.rename_branch.response", + payload: { + requestId: "request-branch-rename", + success: true, + cwd: "/tmp/repo", + currentBranch: "feature/new-name", + error: null, + }, + }).success, + ).toBe(false); + + expect( + legacyInboundSchema.parse({ + type: "checkout_switch_branch_request", + cwd: "/tmp/repo", + branch: "main", + requestId: "request-switch", + }), + ).toEqual({ + type: "checkout_switch_branch_request", + cwd: "/tmp/repo", + branch: "main", + requestId: "request-switch", + }); + expect( + legacyOutboundSchema.parse({ + type: "kill_terminal_response", + payload: { + terminalId: "terminal-1", + success: true, + requestId: "request-kill", + }, + }), + ).toEqual({ + type: "kill_terminal_response", + payload: { + terminalId: "terminal-1", + success: true, + requestId: "request-kill", + }, + }); + }); +}); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 8c18fe2ef..76207e2f4 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1389,6 +1389,13 @@ export const CheckoutSwitchBranchRequestSchema = z.object({ requestId: z.string(), }); +export const CheckoutRenameBranchRequestSchema = z.object({ + type: z.literal("checkout.rename_branch.request"), + cwd: z.string(), + branch: z.string(), + requestId: z.string(), +}); + export const StashSaveRequestSchema = z.object({ type: z.literal("stash_save_request"), cwd: z.string(), @@ -1706,6 +1713,13 @@ export const CreateTerminalRequestSchema = z.object({ requestId: z.string(), }); +export const RenameTerminalRequestSchema = z.object({ + type: z.literal("terminal.rename.request"), + terminalId: z.string(), + title: z.string(), + requestId: z.string(), +}); + export const StartWorkspaceScriptRequestSchema = z.object({ type: z.literal("start_workspace_script_request"), workspaceId: z.string(), @@ -1818,6 +1832,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrStatusRequestSchema, PullRequestTimelineRequestSchema, CheckoutSwitchBranchRequestSchema, + CheckoutRenameBranchRequestSchema, StashSaveRequestSchema, StashPopRequestSchema, StashListRequestSchema, @@ -1845,6 +1860,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SubscribeTerminalsRequestSchema, UnsubscribeTerminalsRequestSchema, CreateTerminalRequestSchema, + RenameTerminalRequestSchema, StartWorkspaceScriptRequestSchema, SubscribeTerminalRequestSchema, UnsubscribeTerminalRequestSchema, @@ -3105,6 +3121,17 @@ export const CheckoutSwitchBranchResponseSchema = z.object({ }), }); +export const CheckoutRenameBranchResponseSchema = z.object({ + type: z.literal("checkout.rename_branch.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + cwd: z.string(), + currentBranch: z.string().nullable(), + error: CheckoutErrorSchema.nullable(), + }), +}); + const StashEntrySchema = z.object({ index: z.number().int().min(0), message: z.string(), @@ -3459,6 +3486,15 @@ export const CreateTerminalResponseSchema = z.object({ }), }); +export const RenameTerminalResponseSchema = z.object({ + type: z.literal("terminal.rename.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + error: z.string().nullable(), + }), +}); + export const SubscribeTerminalResponseSchema = z.object({ type: z.literal("subscribe_terminal_response"), payload: z.union([ @@ -3572,6 +3608,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrStatusResponseSchema, PullRequestTimelineResponseSchema, CheckoutSwitchBranchResponseSchema, + CheckoutRenameBranchResponseSchema, StashSaveResponseSchema, StashPopResponseSchema, StashListResponseSchema, @@ -3597,6 +3634,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ListTerminalsResponseSchema, TerminalsChangedSchema, CreateTerminalResponseSchema, + RenameTerminalResponseSchema, SubscribeTerminalResponseSchema, KillTerminalResponseSchema, CaptureTerminalResponseSchema, @@ -3844,6 +3882,8 @@ export type PullRequestTimelineItem = z.infer; export type CheckoutSwitchBranchRequest = z.infer; export type CheckoutSwitchBranchResponse = z.infer; +export type CheckoutRenameBranchRequest = z.infer; +export type CheckoutRenameBranchResponse = z.infer; export type StashSaveRequest = z.infer; export type StashSaveResponse = z.infer; export type StashPopRequest = z.infer; @@ -3897,6 +3937,8 @@ export type UnsubscribeTerminalsRequest = z.infer; export type CreateTerminalRequest = z.infer; export type CreateTerminalResponse = z.infer; +export type RenameTerminalRequest = z.infer; +export type RenameTerminalResponse = z.infer; export type StartWorkspaceScriptRequest = z.infer; export type StartWorkspaceScriptResponse = z.infer< typeof StartWorkspaceScriptResponseMessageSchema diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 689340bd3..6104e3937 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -384,3 +384,35 @@ it("emits empty snapshot when last terminal is removed", async () => { unsubscribe(); }); + +it("setTerminalTitle returns false for unknown terminal ids without changing existing terminals", async () => { + manager = createTerminalManager(); + const session = await manager.createTerminal({ + cwd: realpathSync(tmpdir()), + title: "Existing title", + }); + const snapshots: Array> = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push( + input.terminals.map((terminal) => ({ + id: terminal.id, + ...(terminal.title ? { title: terminal.title } : {}), + })), + ); + }); + + expect(manager.setTerminalTitle("unknown-id", "x")).toBe(false); + expect(session.getTitle()).toBe("Existing title"); + expect(session.getState().title).toBe("Existing title"); + expect(snapshots).toEqual([]); + + unsubscribe(); +}); + +it("setTerminalTitle returns true and updates the terminal title for existing terminals", async () => { + manager = createTerminalManager(); + const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) }); + + expect(manager.setTerminalTitle(session.id, "x")).toBe(true); + expect(session.getTitle()).toBe("x"); +}); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index 2a01b2252..dad8a1e4e 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -30,6 +30,7 @@ export interface TerminalManager { registerCwdEnv(options: { cwd: string; env: Record }): void; getTerminal(id: string): TerminalSession | undefined; getTerminalState(id: string): Promise; + setTerminalTitle(id: string, title: string): boolean; killTerminal(id: string): void; killTerminalAndWait( id: string, @@ -211,6 +212,16 @@ export function createTerminalManager(): TerminalManager { return terminalsById.get(id)?.getStateSnapshot() ?? null; }, + setTerminalTitle(id: string, title: string): boolean { + const session = terminalsById.get(id); + if (!session) { + return false; + } + + session.setTitle(title); + return true; + }, + killTerminal(id: string): void { removeSessionById(id, { kill: true }); }, diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 103a6b4b3..c2e226b88 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -4,6 +4,7 @@ import type { CreateTerminalRequest, KillTerminalRequest, ListTerminalsRequest, + RenameTerminalRequest, SessionInboundMessage, SessionOutboundMessage, SubscribeTerminalRequest, @@ -64,7 +65,8 @@ type TerminalDispatchableMessage = | UnsubscribeTerminalRequest | TerminalInput | KillTerminalRequest - | CaptureTerminalRequest; + | CaptureTerminalRequest + | RenameTerminalRequest; const TERMINAL_MESSAGE_TYPES: ReadonlySet = new Set([ "subscribe_terminals_request", @@ -76,6 +78,7 @@ const TERMINAL_MESSAGE_TYPES: ReadonlySet = "terminal_input", "kill_terminal_request", "capture_terminal_request", + "terminal.rename.request", ]); export class TerminalSessionController { @@ -145,6 +148,8 @@ export class TerminalSessionController { return this.handleKillTerminalRequest(msg); case "capture_terminal_request": return this.handleCaptureTerminalRequest(msg); + case "terminal.rename.request": + return this.handleRenameTerminalRequest(msg); default: return undefined; } @@ -430,6 +435,32 @@ export class TerminalSessionController { } } + private async handleRenameTerminalRequest(msg: RenameTerminalRequest): Promise { + const respond = (success: boolean, error: string | null): void => { + this.emit({ + type: "terminal.rename.response", + payload: { requestId: msg.requestId, success, error }, + }); + }; + + const title = msg.title.trim(); + if (title.length === 0) { + respond(false, "Title is required"); + return; + } + if (title.length > 200) { + respond(false, "Title is too long"); + return; + } + if (!this.terminalManager) { + respond(false, "Terminal manager not available"); + return; + } + + const renamed = this.terminalManager.setTerminalTitle(msg.terminalId, title); + respond(renamed, renamed ? null : "Terminal not found"); + } + private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise { if (!this.terminalManager) { this.emit({ diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index f20258ca1..b6b49c691 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -1,15 +1,19 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; import { + buildTerminalEnvironment, createTerminal, ensureNodePtySpawnHelperExecutableForCurrentPlatform, resolveDefaultTerminalShell, humanizeProcessTitle, normalizeProcessTitle, + resolveZshShellIntegrationDir, type TerminalSession, } from "./terminal.js"; import { chmodSync, + cpSync, + existsSync, mkdtempSync, mkdirSync, realpathSync, @@ -17,8 +21,92 @@ import { statSync, writeFileSync, } from "node:fs"; +import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { setImmediate as waitForImmediate } from "node:timers/promises"; + +const hasZsh = existsSync("/bin/zsh"); + +type TerminalRow = ReturnType["grid"][number]; + +function rowToText(row: TerminalRow): string { + return row + .map((cell) => cell.char) + .join("") + .trimEnd(); +} + +// Extract text from a single row +function getRowText(state: ReturnType, rowIndex: number): string { + return rowToText(state.grid[rowIndex]); +} + +// Extract all visible lines as array (trimmed, empty lines included) +function getLines(state: ReturnType): string[] { + return state.grid.map(rowToText); +} + +// Wait for terminal state to match expected lines +async function waitForLines( + session: TerminalSession, + expectedLines: string[], + timeoutMs = 5000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const lines = getLines(session.getState()); + let matches = true; + for (let i = 0; i < expectedLines.length; i++) { + if (lines[i] !== expectedLines[i]) { + matches = false; + break; + } + } + if (matches) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + const actual = getLines(session.getState()).slice(0, expectedLines.length); + throw new Error( + `Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`, + ); +} + +async function waitForState( + session: TerminalSession, + predicate: (state: ReturnType) => boolean, + timeoutMs = 5000, +): Promise> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const state = session.getState(); + if (predicate(state)) { + return state; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + throw new Error("Timeout waiting for terminal state predicate to match"); +} + +async function waitForTitle( + session: TerminalSession, + predicate: (title: string | undefined) => boolean, + timeoutMs = 5000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const title = session.getTitle(); + if (predicate(title)) { + return title; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + throw new Error("Timeout waiting for terminal title predicate to match"); +} if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) { process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe"; @@ -28,6 +116,7 @@ const sessions: TerminalSession[] = []; const temporaryDirs: string[] = []; afterEach(async () => { + vi.useRealTimers(); for (const session of sessions) { session.kill(); } @@ -45,6 +134,17 @@ function trackSession(session: TerminalSession): TerminalSession { return session; } +async function waitForScheduledTimers(expectedTimerCount: number): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + if (vi.getTimerCount() === expectedTimerCount) { + return; + } + await waitForImmediate(); + } + + throw new Error(`Expected ${expectedTimerCount} scheduled timers, got ${vi.getTimerCount()}`); +} + describe("createTerminal", () => { it("keeps full process titles while stripping path prefixes", () => { expect(normalizeProcessTitle(" /usr/local/bin/npm run dev ")).toBe("npm run dev"); @@ -205,6 +305,544 @@ describe("createTerminal", () => { }); }); +describe.skipIf(isPlatform("win32"))("send input", () => { + it("executes a simple echo command", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + // Wait for initial prompt, then send command + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "echo hello\r" }); + + // After running "echo hello", terminal should show: + // Line 0: "$ echo hello" + // Line 1: "hello" + // Line 2: "$" + await waitForLines(session, ["$ echo hello", "hello", "$"]); + + const state = session.getState(); + expect(getRowText(state, 0)).toBe("$ echo hello"); + expect(getRowText(state, 1)).toBe("hello"); + expect(getRowText(state, 2)).toBe("$"); + }); + + it("captures output from pwd in specified cwd", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "pwd\r" }); + + await waitForLines(session, ["$ pwd", "/tmp", "$"]); + + const state = session.getState(); + expect(getRowText(state, 0)).toBe("$ pwd"); + expect(getRowText(state, 1)).toBe("/tmp"); + expect(getRowText(state, 2)).toBe("$"); + }); +}); + +describe.skipIf(isPlatform("win32"))("terminal title", () => { + it.skipIf(!hasZsh)("restores the user's ZDOTDIR through the zsh wrapper", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + mkdirSync(realZdotdir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n"); + + const session = trackSession( + await createTerminal({ + cwd: homeDir, + command: "/bin/zsh", + args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'], + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + + const exitInfo = await new Promise>>( + (resolve) => { + session.onExit((info) => resolve(info)); + }, + ); + + expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]); + }); + + it("emits the initial title from command args to title listeners", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "npm-cli.js"); + writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n"); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + command: process.execPath, + args: [scriptPath, "run", "dev"], + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForTitle(session, (title) => title === "npm run dev"); + await waitForState(session, (state) => state.title === "npm run dev"); + + expect(seenTitles).toContain("npm run dev"); + expect(session.getTitle()).toBe("npm run dev"); + expect(session.getState().title).toBe("npm run dev"); + + unsubscribeTitle(); + }); + + it("emits OSC title updates to title listeners", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + + await waitForTitle(session, (title) => title === "Build Log"); + + expect(seenTitles).toContain("Build Log"); + expect(session.getTitle()).toBe("Build Log"); + expect(session.getState().title).toBe("Build Log"); + + unsubscribeTitle(); + }); + + it("keeps preset titles instead of applying OSC title updates", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + title: "typecheck", + }), + ); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(session.getTitle()).toBe("typecheck"); + expect(session.getState().title).toBe("typecheck"); + }); + + it("emits command completion from VS Code OSC 633 without visible output", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "emit-command-finished.sh"); + writeFileSync(scriptPath, "#!/bin/sh\nprintf '\\033]633;D;7\\007'\n"); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "./emit-command-finished.sh\r" }); + + await waitForState(session, () => commandCompletions.length === 1); + + expect(commandCompletions).toEqual([7]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7"); + + unsubscribeCommandFinished(); + }); + + it("ignores malformed VS Code OSC 633 command completion payloads", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-malformed-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "emit-malformed-command-finished.sh"); + writeFileSync( + scriptPath, + "#!/bin/sh\nprintf '\\033]633;D;garbage\\007\\033]633;D;8;extra\\007\\033]633;D;3\\007'\n", + ); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "./emit-malformed-command-finished.sh\r" }); + + await waitForState(session, () => commandCompletions.length === 1); + + expect(commandCompletions).toEqual([3]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage"); + + unsubscribeCommandFinished(); + }); + + it("debounces rapid title changes and emits only the final title", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const seenMessages: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + const unsubscribeMessages = session.subscribe((message) => { + if (message.type === "titleChange") { + seenMessages.push(message.title); + } + }); + + await waitForLines(session, ["$"]); + session.send({ + type: "input", + data: "printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r", + }); + + await waitForTitle(session, (title) => title === "Final"); + + expect(seenTitles).toEqual(["Final"]); + expect(seenMessages).toEqual(["Final"]); + + unsubscribeMessages(); + unsubscribeTitle(); + }); + + it.skipIf(!hasZsh)("emits zsh shell integration titles for commands and prompts", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + const workingDir = join(homeDir, "dev", "faro"); + mkdirSync(realZdotdir, { recursive: true }); + mkdirSync(workingDir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), ""); + writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); + + const session = trackSession( + await createTerminal({ + cwd: workingDir, + shell: "/bin/zsh", + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + + await waitForLines(session, ["$"]); + await waitForTitle(session, (title) => title === "~/dev/faro"); + + session.send({ type: "input", data: "sleep 1\r" }); + + await waitForTitle(session, (title) => title === "sleep 1"); + await waitForTitle(session, (title) => title === "~/dev/faro", 4000); + }); + + it.skipIf(!hasZsh)("loads the user's zsh prompt when the integration dir is packaged", () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-packaged-home-")); + temporaryDirs.push(homeDir); + writeFileSync(join(homeDir, ".zshrc"), "PS1='PASEO_CUSTOM_PROMPT> '\n"); + + const fakeAppRoot = join(homeDir, "Paseo.app", "Contents", "Resources"); + const inaccessiblePackagedIntegrationDir = join( + fakeAppRoot, + "app.asar", + "node_modules", + "@getpaseo", + "server", + "dist", + "server", + "terminal", + "shell-integration", + "zsh", + ); + const unpackedIntegrationDir = join( + fakeAppRoot, + "app.asar.unpacked", + "node_modules", + "@getpaseo", + "server", + "dist", + "server", + "terminal", + "shell-integration", + "zsh", + ); + mkdirSync(unpackedIntegrationDir, { recursive: true }); + cpSync(resolveZshShellIntegrationDir(), unpackedIntegrationDir, { recursive: true }); + writeFileSync(join(fakeAppRoot, "app.asar"), "asar archive placeholder"); + + const env = buildTerminalEnvironment({ + shell: "/bin/zsh", + env: { + HOME: homeDir, + }, + zshShellIntegrationDir: inaccessiblePackagedIntegrationDir, + }); + + const result = spawnSync("/bin/zsh", ["-i", "-c", "print -r -- ${PROMPT}"], { + cwd: homeDir, + env, + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout.split(/\r?\n/)).toContain("PASEO_CUSTOM_PROMPT> "); + }); + + it.skipIf(!hasZsh)("emits zsh shell integration command completion", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-command-finished-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + const workingDir = join(homeDir, "dev", "faro"); + mkdirSync(realZdotdir, { recursive: true }); + mkdirSync(workingDir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), ""); + writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); + + const session = trackSession( + await createTerminal({ + cwd: workingDir, + shell: "/bin/zsh", + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "false\r" }); + + await waitForState(session, () => commandCompletions.includes(1)); + + expect(commandCompletions).toEqual([1]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1"); + + unsubscribeCommandFinished(); + }); + + it("clears already scheduled OSC title debounce timers when setting a user title", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + + await waitForTitle(session, (title) => title === "Build Log"); + + vi.useFakeTimers(); + session.send({ type: "input", data: "printf '\\033]0;Pending Shell Title\\007'\r" }); + await waitForScheduledTimers(1); + + session.setTitle("User terminal"); + await vi.advanceTimersByTimeAsync(250); + vi.useRealTimers(); + + expect(seenTitles).toEqual(["Build Log", "User terminal"]); + expect(session.getTitle()).toBe("User terminal"); + expect(session.getState().title).toBe("User terminal"); + + unsubscribeTitle(); + }); + + it("ignores later OSC title updates after setting a user title", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + + await waitForTitle(session, (title) => title === "Build Log"); + + session.setTitle("User terminal"); + session.send({ type: "input", data: "printf '\\033]0;Later Shell Title\\007'\r" }); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(seenTitles).toEqual(["Build Log", "User terminal"]); + expect(session.getTitle()).toBe("User terminal"); + expect(session.getState().title).toBe("User terminal"); + + unsubscribeTitle(); + }); + + it("trims user-set titles and treats empty titles as no-ops", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + + session.setTitle(" "); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + await waitForTitle(session, (title) => title === "Build Log"); + + session.setTitle(" User terminal "); + + expect(seenTitles).toEqual(["Build Log", "User terminal"]); + expect(session.getTitle()).toBe("User terminal"); + expect(session.getState().title).toBe("User terminal"); + + unsubscribeTitle(); + }); +}); + +describe.skipIf(isPlatform("win32"))("colors", () => { + it("captures ANSI 16 color codes (mode 1)", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[31m = ANSI red (color 1) + session.send({ type: "input", data: "printf '\\033[31mRED\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + expect(outputRow[0].char).toBe("R"); + expect(outputRow[0].fg).toBe(1); // ANSI red = 1 + expect(outputRow[0].fgMode).toBe(1); // Mode 1 = 16 ANSI colors + + // The "$" after RED should have default color + expect(outputRow[3].char).toBe("$"); + expect(outputRow[3].fg).toBe(undefined); + expect(outputRow[3].fgMode).toBe(undefined); + }); + + it("captures true color RGB (mode 3)", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[38;2;255;128;64m = true color RGB(255, 128, 64) + session.send({ type: "input", data: "printf '\\033[38;2;255;128;64mRGB\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + // Check R cell + expect(outputRow[0].char).toBe("R"); + expect(outputRow[0].fgMode).toBe(3); // Mode 3 = true color + + // The color value should be packed RGB: (255 << 16) | (128 << 8) | 64 + const expectedPacked = (255 << 16) | (128 << 8) | 64; + expect(outputRow[0].fg).toBe(expectedPacked); + }); + + it("captures background colors", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[41m = ANSI red background + session.send({ type: "input", data: "printf '\\033[41mBG\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + expect(outputRow[0].char).toBe("B"); + expect(outputRow[0].bg).toBe(1); // ANSI red = 1 + expect(outputRow[0].bgMode).toBe(1); // Mode 1 = 16 ANSI colors + }); +}); + describe("resize", () => { it("updates terminal dimensions on resize", async () => { const session = trackSession( diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 56f54697c..4cd54e0cd 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -57,6 +57,7 @@ export interface TerminalSession { getStateSnapshot(): TerminalStateSnapshot; getReplayPreamble(): string; getTitle(): string | undefined; + setTitle(title: string): void; getExitInfo(): TerminalExitInfo | null; kill(): void; killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise; @@ -549,12 +550,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise | null = null; let pendingInput = ""; let inputFlushImmediate: ReturnType | null = null; let stateRevision = 0; const inputModeTracker = new TerminalInputModeTracker(); + let titleChangeSubscription: { dispose(): void } | null = null; // Create xterm.js headless terminal const terminal = new Terminal({ @@ -598,14 +601,40 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { if (params.length === 0 || (params.length === 1 && params[0] === 0)) { ptyProcess.write("\x1b[?62;4;22c"); @@ -637,9 +666,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { + if (titleMode === "auto") { + titleChangeSubscription = terminal.onTitleChange((nextTitle) => { if (disposed || killed) { return; } @@ -650,6 +678,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { titleDebounceTimer = null; emitTitleChange(pendingTitle); + pendingTitle = undefined; }, TERMINAL_TITLE_DEBOUNCE_MS); }); } @@ -712,11 +741,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { // no-op; kill is intentionally best-effort and synchronous in the public interface. diff --git a/packages/server/src/utils/branch-slug.test.ts b/packages/server/src/utils/branch-slug.test.ts new file mode 100644 index 000000000..343bbd16b --- /dev/null +++ b/packages/server/src/utils/branch-slug.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { slugify, validateBranchSlug } from "./branch-slug.js"; + +describe("branch slug utilities", () => { + it("normalizes display names to lowercase branch slugs", () => { + expect(slugify("My Feature")).toBe("my-feature"); + }); + + it("collapses punctuation and whitespace to a single hyphen", () => { + expect(slugify("My___Feature! @#$ Next")).toBe("my-feature-next"); + }); + + it("trims leading and trailing hyphens", () => { + expect(slugify(" --- My Feature !!! ")).toBe("my-feature"); + }); + + it("enforces the 50 character slug limit", () => { + const slug = slugify("a".repeat(60)); + + expect(slug).toBe("a".repeat(50)); + }); + + it("validates branch slugs with clear messages", () => { + expect(validateBranchSlug("my-feature")).toEqual({ valid: true }); + expect(validateBranchSlug("")).toEqual({ + valid: false, + error: "Branch name cannot be empty", + }); + expect(validateBranchSlug("My Feature")).toEqual({ + valid: false, + error: + "Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes", + }); + expect(validateBranchSlug("-my-feature")).toEqual({ + valid: false, + error: "Branch name cannot start or end with a hyphen", + }); + }); + + it("is exported through the package subpath", () => { + const currentDir = dirname(fileURLToPath(import.meta.url)); + const packageJson = JSON.parse( + readFileSync(join(currentDir, "..", "..", "package.json"), "utf8"), + ) as { + exports?: Record; + }; + + expect(packageJson.exports?.["./utils/branch-slug"]).toEqual({ + types: "./dist/server/utils/branch-slug.d.ts", + source: "./src/utils/branch-slug.ts", + default: "./dist/server/utils/branch-slug.js", + }); + }); + + it("does not import server-only modules", () => { + const currentDir = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(join(currentDir, "branch-slug.ts"), "utf8"); + + expect(source).not.toMatch( + /from\s+["'](?:node:)?(?:fs|path|child_process)["']|from\s+["']node:/, + ); + }); +}); diff --git a/packages/server/src/utils/branch-slug.ts b/packages/server/src/utils/branch-slug.ts new file mode 100644 index 000000000..1713372fe --- /dev/null +++ b/packages/server/src/utils/branch-slug.ts @@ -0,0 +1,61 @@ +/** + * Validate that a string is a valid git branch name slug. + * Must be lowercase alphanumeric with hyphens and forward slashes only. + */ +export function validateBranchSlug(slug: string): { + valid: boolean; + error?: string; +} { + if (!slug || slug.length === 0) { + return { valid: false, error: "Branch name cannot be empty" }; + } + + if (slug.length > 100) { + return { valid: false, error: "Branch name too long (max 100 characters)" }; + } + + const validPattern = /^[a-z0-9-/]+$/; + if (!validPattern.test(slug)) { + return { + valid: false, + error: + "Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes", + }; + } + + if (slug.startsWith("-") || slug.endsWith("-")) { + return { + valid: false, + error: "Branch name cannot start or end with a hyphen", + }; + } + + if (slug.includes("--")) { + return { valid: false, error: "Branch name cannot have consecutive hyphens" }; + } + + return { valid: true }; +} + +export const MAX_SLUG_LENGTH = 50; + +/** + * Convert a string to kebab-case for branch names. + */ +export function slugify(input: string): string { + const slug = input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + if (slug.length <= MAX_SLUG_LENGTH) { + return slug; + } + + const truncated = slug.slice(0, MAX_SLUG_LENGTH); + const lastHyphen = truncated.lastIndexOf("-"); + if (lastHyphen > MAX_SLUG_LENGTH / 2) { + return truncated.slice(0, lastHyphen); + } + return truncated.replace(/-+$/, ""); +} diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 44c5d08cf..a5a8db146 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { execFileSync } from "child_process"; +import { execFileSync, execSync } from "child_process"; import { existsSync, mkdtempSync, @@ -34,6 +34,7 @@ import { resolveBranchCheckout, resolveRepositoryDefaultBranch, parseWorktreeList, + renameCurrentBranch, isPaseoWorktreePath, isDescendantPath, warmCheckoutShortstatInBackground, @@ -263,6 +264,48 @@ describe("checkout git utilities", () => { expect(branch).toBe("feature/rebase-test"); }); + it("renames the checked out branch and returns concrete branch names", async () => { + execSync("git checkout -b feature/old-name", { cwd: repoDir }); + + const result = await renameCurrentBranch(repoDir, "feature/new-name"); + + const currentBranch = execSync("git branch --show-current", { cwd: repoDir }).toString().trim(); + expect(currentBranch).toBe("feature/new-name"); + expect(result).toEqual({ + previousBranch: "feature/old-name", + currentBranch: "feature/new-name", + }); + expect(() => + execSync("git show-ref --verify refs/heads/feature/old-name", { cwd: repoDir }), + ).toThrow(); + expect( + execSync("git show-ref --verify refs/heads/feature/new-name", { cwd: repoDir }) + .toString() + .trim(), + ).toContain("refs/heads/feature/new-name"); + }); + + it("fails when renaming the checked out branch to an existing branch", async () => { + execSync("git branch feature/new-name", { cwd: repoDir }); + execSync("git checkout -b feature/old-name", { cwd: repoDir }); + + await expect(renameCurrentBranch(repoDir, "feature/new-name")).rejects.toThrow(); + + expect(execSync("git branch --show-current", { cwd: repoDir }).toString().trim()).toBe( + "feature/old-name", + ); + expect( + execSync("git show-ref --verify refs/heads/feature/old-name", { cwd: repoDir }) + .toString() + .trim(), + ).toContain("refs/heads/feature/old-name"); + expect( + execSync("git show-ref --verify refs/heads/feature/new-name", { cwd: repoDir }) + .toString() + .trim(), + ).toContain("refs/heads/feature/new-name"); + }); + it("handles status/diff/commit in a normal repo", async () => { writeFileSync(join(repoDir, "file.txt"), "updated\n"); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 80aefb2d0..24538a603 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -30,6 +30,9 @@ import { spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; +import { validateBranchSlug } from "./branch-slug.js"; + +export { slugify, validateBranchSlug } from "./branch-slug.js"; const execFileAsync = promisify(execFile); const READ_ONLY_GIT_ENV = { @@ -740,72 +743,6 @@ export async function getGitCommonDir(cwd: string): Promise { return commonDir; } -/** - * Validate that a string is a valid git branch name slug - * Must be lowercase, alphanumeric, hyphens only - */ -export function validateBranchSlug(slug: string): { - valid: boolean; - error?: string; -} { - if (!slug || slug.length === 0) { - return { valid: false, error: "Branch name cannot be empty" }; - } - - if (slug.length > 100) { - return { valid: false, error: "Branch name too long (max 100 characters)" }; - } - - // Check for valid characters: lowercase letters, numbers, hyphens, forward slashes - const validPattern = /^[a-z0-9-/]+$/; - if (!validPattern.test(slug)) { - return { - valid: false, - error: - "Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes", - }; - } - - // Cannot start or end with hyphen - if (slug.startsWith("-") || slug.endsWith("-")) { - return { - valid: false, - error: "Branch name cannot start or end with a hyphen", - }; - } - - // Cannot have consecutive hyphens - if (slug.includes("--")) { - return { valid: false, error: "Branch name cannot have consecutive hyphens" }; - } - - return { valid: true }; -} - -const MAX_SLUG_LENGTH = 50; - -/** - * Convert string to kebab-case for branch names - */ -export function slugify(input: string): string { - const slug = input - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - - if (slug.length <= MAX_SLUG_LENGTH) { - return slug; - } - - // Truncate at word boundary (hyphen) if possible - const truncated = slug.slice(0, MAX_SLUG_LENGTH); - const lastHyphen = truncated.lastIndexOf("-"); - if (lastHyphen > MAX_SLUG_LENGTH / 2) { - return truncated.slice(0, lastHyphen); - } - return truncated.replace(/-+$/, ""); -} - const WORKTREE_PROJECT_HASH_LENGTH = 8; function deriveShortAlphanumericHash(value: string): string {