From 2b39815fc10e1076ecfa21fd2d52cde54012d37a Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:00:14 +0000 Subject: [PATCH 01/74] Fix native clipboard editing in fields and plugin pages --- src/main/editContextMenu.ts | 36 ++++++++++++++++ src/main/index.ts | 9 ++++ src/main/ipc/registerIpc.ts | 9 +++- src/main/services/CanvasNavigationOverride.ts | 23 ++++++++++ src/preload/index.ts | 1 + src/renderer/src/App.tsx | 7 ++- src/renderer/src/lib/shortcuts.ts | 31 +++++++++++++ src/shared/contracts.ts | 3 ++ tests/app-shortcuts.test.mjs | 24 +++++++++++ tests/canvas-navigation-override.test.mjs | 43 ++++++++++++++++++- tests/edit-context-menu.test.mjs | 29 +++++++++++++ 11 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 src/main/editContextMenu.ts create mode 100644 tests/edit-context-menu.test.mjs diff --git a/src/main/editContextMenu.ts b/src/main/editContextMenu.ts new file mode 100644 index 00000000..acaeb8f6 --- /dev/null +++ b/src/main/editContextMenu.ts @@ -0,0 +1,36 @@ +import type { ContextMenuParams, MenuItemConstructorOptions, WebContents } from "electron"; +import type { LocaleId } from "../shared/contracts.ts"; + +type EditParams = Pick; + +/** + * The right-click menu for text: Cut/Copy/Paste/Select All in an editable field (an app field or a field in a plugin + * page), Copy for selected page text, nothing elsewhere. A page that handles its own right click (the canvas, a + * terminal) cancels the DOM event, and Electron then never asks for this menu. + */ +export function editContextMenuTemplate(params: EditParams, locale: LocaleId): MenuItemConstructorOptions[] { + const ru = locale === "ru"; + const label = { cut: ru ? "Вырезать" : "Cut", copy: ru ? "Скопировать" : "Copy", paste: ru ? "Вставить" : "Paste", selectAll: ru ? "Выбрать все" : "Select All" }; + if (params.isEditable) { + return [ + { role: "cut", label: label.cut, enabled: params.editFlags.canCut }, + { role: "copy", label: label.copy, enabled: params.editFlags.canCopy }, + { role: "paste", label: label.paste, enabled: params.editFlags.canPaste }, + { type: "separator" }, + { role: "selectAll", label: label.selectAll, enabled: params.editFlags.canSelectAll } + ]; + } + if (params.selectionText.trim()) return [{ role: "copy", label: label.copy, enabled: params.editFlags.canCopy }]; + return []; +} + +export function attachEditContextMenu( + contents: WebContents, + locale: () => LocaleId, + popup: (template: MenuItemConstructorOptions[], contents: WebContents) => void +): void { + contents.on("context-menu", (_event, params) => { + const template = editContextMenuTemplate(params, locale()); + if (template.length) popup(template, contents); + }); +} diff --git a/src/main/index.ts b/src/main/index.ts index e2fb63e2..9179be99 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -92,6 +92,7 @@ import { import { startupPageUrl } from "./startupPage"; import { mainWindowChromeOptions } from "./windowChrome"; import { markMainBoot, mainBootMarks } from "./bootMarks"; +import { attachEditContextMenu } from "./editContextMenu"; import { macApplicationMenuTemplate } from "./macApplicationMenu"; if (process.env.CANVASTTY_USER_DATA_DIR) { @@ -194,6 +195,8 @@ let appSurfaceReady = false; let pendingMenuUpdateCheck = false; let checkUpdatesFromMenu: (() => void) | null = null; let installMacMenu: (() => void) | null = null; +/** The locale of the text right-click menu; startApplication points it at the settings once they are loaded. */ +let editMenuLocale: () => LocaleId = () => (app.getLocale().toLowerCase().startsWith("ru") ? "ru" : "en"); let updateTimer: ReturnType | null = null; let updateInterval: ReturnType | null = null; let startupRunning = false; @@ -256,6 +259,8 @@ function createWindow(): BrowserWindow { preventMouseBindings: false, captureMacEditShortcuts: process.platform === "darwin" }); + attachEditContextMenu(window.webContents, () => editMenuLocale(), + (template, contents) => Menu.buildFromTemplate(template).popup({ window: BrowserWindow.fromWebContents(contents) ?? undefined })); // Crash recovery: a dead renderer must never leave the user staring at a // blank window. The application surface is reloaded in place — the same entry // startup loads — so services, sessions and their scrollback stay untouched @@ -327,6 +332,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { recoverKimiConfigurationOnStartup(kimiHomeDirectory); const userDataPath = app.getPath("userData"); const settings = new SettingsStore(userDataPath, app.getLocale(), process.platform, providerCliAvailability(providerClis)); + editMenuLocale = () => settings.get().locale; const terminalBorderSkins = new SkinRegistry(userDataPath); const pixelSkinPacks = new PixelSkinPackRegistry(userDataPath); pluginManager = new PluginManager(userDataPath); @@ -836,6 +842,9 @@ async function initializeServices(ipc: IpcRegistrar): Promise { if (active) browserService?.cancelCanvasNavigationGesture(); canvasNavigationInput?.setShortcutCaptureActive(active); }, + setCanvasNavigationTerminalEditFocus: (contents, active) => { + canvasNavigationInput?.setTerminalEditFocus(contents, active); + }, setCanvasNavigationPointerBinding: (input) => { canvasNavigationInput?.updatePointerBinding(input); }, diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 7c2277e9..9a360633 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -1,6 +1,6 @@ import { realpath } from "node:fs/promises"; import { app, BrowserWindow, clipboard, dialog, shell } from "electron"; -import type { IpcMainEvent, IpcMainInvokeEvent, OpenDialogOptions } from "electron"; +import type { IpcMainEvent, IpcMainInvokeEvent, OpenDialogOptions, WebContents } from "electron"; import type { AppSettings, AgentChatHistoryProviderId, @@ -74,6 +74,7 @@ interface Dependencies { getMainWindow(): BrowserWindow | null; applyBrowserSettings(settings: AppSettings): Promise | void; setCanvasNavigationShortcutCapture(active: boolean): void; + setCanvasNavigationTerminalEditFocus(contents: WebContents, active: boolean): void; setCanvasNavigationPointerBinding(input: CanvasNavigationPointerBindingInput): void; openPluginWindow(pluginId: string, contributionId: string): Promise; closePluginWindows(pluginId: string): void; @@ -210,6 +211,7 @@ export function registerIpc(ipcMain: IpcRegistrar, { getMainWindow, applyBrowserSettings, setCanvasNavigationShortcutCapture, + setCanvasNavigationTerminalEditFocus, setCanvasNavigationPointerBinding, openPluginWindow, closePluginWindows, @@ -260,6 +262,11 @@ export function registerIpc(ipcMain: IpcRegistrar, { if (typeof active !== "boolean") return; setCanvasNavigationShortcutCapture(active); }); + ipcMain.on(IPC.canvasNavigationTerminalEditFocus, (event, active: boolean) => { + assertMainRenderer(event, getMainWindow); + if (typeof active !== "boolean") return; + setCanvasNavigationTerminalEditFocus(event.sender, active); + }); ipcMain.on(IPC.canvasNavigationPointerBinding, (event, input: unknown) => { assertMainRenderer(event, getMainWindow); if (!isCanvasNavigationPointerBindingInput(input)) return; diff --git a/src/main/services/CanvasNavigationOverride.ts b/src/main/services/CanvasNavigationOverride.ts index 80048276..d0e1f40d 100644 --- a/src/main/services/CanvasNavigationOverride.ts +++ b/src/main/services/CanvasNavigationOverride.ts @@ -200,6 +200,8 @@ export class CanvasNavigationInputController { private readonly attachedContents = new Set(); private readonly onActiveChange: (state: CanvasNavigationOverrideState) => void; private menuShortcutContents: WebContents | null = null; + /** Contents whose renderer reported that a terminal surface has keyboard focus. */ + private readonly terminalEditFocus = new WeakSet(); constructor( bindings: CanvasNavigationBindings, @@ -233,6 +235,19 @@ export class CanvasNavigationInputController { }); } + /** + * The renderer reports whether a terminal surface (which implements its own Command+C/V and Codex Command+A) has + * keyboard focus. Only then are those shortcuts kept from the Edit menu; every other field, in the app or in a + * plugin page, keeps the menu's native Copy, Paste and Select All. + */ + setTerminalEditFocus(contents: WebContents, focused: boolean): void { + if (focused) this.terminalEditFocus.add(contents); + else { + this.terminalEditFocus.delete(contents); + if (this.menuShortcutContents === contents) this.releaseMenuShortcuts(); + } + } + setBindings(bindings: CanvasNavigationBindings): void { this.releaseMenuShortcuts(); const previous = this.state(); @@ -299,6 +314,7 @@ export class CanvasNavigationInputController { const wheelTransition = this.wheelTracker.update(keyboardInput); const navigationTransition = this.navigationTracker.update(keyboardInput); const macEditShortcut = options.captureMacEditShortcuts === true + && this.terminalEditFocus.has(contents) && topFrameFocused(contents) && input.type === "keyDown" && input.meta && !input.control && !input.alt && !input.shift && (input.code === "KeyA" || input.code === "KeyC" || input.code === "KeyV"); @@ -374,3 +390,10 @@ export class CanvasNavigationInputController { } } + +/** An iframe (a plugin page) with focus is never a terminal surface; contents without the property count as top. */ +function topFrameFocused(contents: WebContents): boolean { + const focused = (contents as { focusedFrame?: unknown }).focusedFrame; + const top = (contents as { mainFrame?: unknown }).mainFrame; + return focused === undefined || focused === null || top === undefined || focused === top; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 5e1f5c30..8fc45548 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -258,6 +258,7 @@ const api: CanvasTTYApi = { ipcRenderer.sendSync(IPC.canvasNavigationOwnerWheel, { clientX, clientY }); }, setShortcutCaptureActive: (active: boolean) => ipcRenderer.send(IPC.canvasNavigationShortcutCapture, active), + setTerminalEditFocus: (active: boolean) => ipcRenderer.send(IPC.canvasNavigationTerminalEditFocus, active), setPointerBindingState: (input: CanvasNavigationPointerBindingInput) => ( ipcRenderer.send(IPC.canvasNavigationPointerBinding, input) ), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 74a31805..d9ccfb58 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -77,7 +77,8 @@ import { isShortcutCaptureTarget, matchesPointerShortcut, matchesShortcut, - shouldKeepNativeKeyboardInput + shouldKeepNativeKeyboardInput, + trackTerminalEditFocus } from "./lib/shortcuts"; import { homeGridPixelSize, homeLayoutFitsGrid, placeHomeWidget } from "./features/home/homeLayout"; import { boundsInsideRegion, translateBounds } from "./features/workspace/canvasRegions"; @@ -1438,9 +1439,13 @@ export function App(): React.JSX.Element { window.addEventListener("keydown", handleShortcut, true); window.addEventListener("pointerdown", handlePointerShortcut, true); + const stopTerminalFocus = window.canvasTTY.window.isMacOS + ? trackTerminalEditFocus(document, (focused) => window.canvasTTY.canvasNavigation.setTerminalEditFocus(focused)) + : () => undefined; return () => { window.removeEventListener("keydown", handleShortcut, true); window.removeEventListener("pointerdown", handlePointerShortcut, true); + stopTerminalFocus(); }; }, [activeSessionId, fullscreenSessionId, goHome, homeEditDraft, launchProvider, pendingTerminalUrl, settings.locale, settings.shortcuts, settingsOpen, shortcutReferenceOpen, showToast, toggleSessionFullscreen]); diff --git a/src/renderer/src/lib/shortcuts.ts b/src/renderer/src/lib/shortcuts.ts index 85f3fc84..916a47b2 100644 --- a/src/renderer/src/lib/shortcuts.ts +++ b/src/renderer/src/lib/shortcuts.ts @@ -62,6 +62,37 @@ export function isRenameInputTarget(target: EventTarget | null): boolean { return target instanceof Element && Boolean(target.closest('[data-terminal-rename="true"]')); } +/** + * A terminal surface implements Command+C/V (and Codex Command+A) itself, so only while one has focus does the main + * process keep those keys from the Edit menu. Ordinary fields and plugin pages (an iframe is the active element) use + * the menu's native editing. + */ +export function isTerminalEditTarget(target: EventTarget | null): boolean { + return target instanceof Element && Boolean(target.closest(".terminal-card__surface, .xterm")); +} + +/** Calls `report` with each change of terminal keyboard focus in `doc` (macOS edit-shortcut routing). */ +export function trackTerminalEditFocus(doc: Document, report: (focused: boolean) => void): () => void { + let last: boolean | null = null; + const update = (): void => { + const focused = isTerminalEditTarget(doc.activeElement); + if (focused === last) return; + last = focused; + report(focused); + }; + // focusout runs before the next element is active: read it once the move has settled. + const settle = (): void => { queueMicrotask(update); }; + doc.addEventListener("focusin", update, true); + doc.addEventListener("focusout", settle, true); + doc.defaultView?.addEventListener("blur", settle); + update(); + return () => { + doc.removeEventListener("focusin", update, true); + doc.removeEventListener("focusout", settle, true); + doc.defaultView?.removeEventListener("blur", settle); + }; +} + export function shouldKeepNativeKeyboardInput( target: EventTarget | null, _isMacOS: boolean, diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index df5fec14..7828b81b 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1756,6 +1756,8 @@ export interface CanvasTTYApi { canvasNavigation: { armOwnerWheelSequence(clientX: number, clientY: number): void; setShortcutCaptureActive(active: boolean): void; + /** macOS: a terminal surface gained or lost keyboard focus (decides who handles Command+C/V/A). */ + setTerminalEditFocus(active: boolean): void; setPointerBindingState(input: CanvasNavigationPointerBindingInput): void; setPointerGestureActive(active: boolean): void; onOverrideState(listener: (event: CanvasNavigationOverrideStateEvent) => void): () => void; @@ -1920,6 +1922,7 @@ export const IPC = { browserCanvasPointer: "browser:canvas-pointer", browserCanvasNavigationPointer: "browser:canvas-navigation-pointer", canvasNavigationShortcutCapture: "canvas-navigation:shortcut-capture", + canvasNavigationTerminalEditFocus: "canvas-navigation:terminal-edit-focus", canvasNavigationPointerBinding: "canvas-navigation:pointer-binding", canvasNavigationOwnerWheel: "canvas-navigation:owner-wheel", canvasNavigationPointerGesture: "canvas-navigation:pointer-gesture", diff --git a/tests/app-shortcuts.test.mjs b/tests/app-shortcuts.test.mjs index 7d6ad297..05998bb2 100644 --- a/tests/app-shortcuts.test.mjs +++ b/tests/app-shortcuts.test.mjs @@ -11,6 +11,7 @@ import { matchesShortcut, shouldKeepNativeKeyboardInput, shortcutFromKeyboardEvent, + trackTerminalEditFocus, shortcutFromPointerEvent } from "../src/renderer/src/lib/shortcuts.ts"; @@ -205,3 +206,26 @@ test("App restores native Command+A before its native-input guard without interc assert.equal(otherPlatform.selectionStart, 4); assert.deepEqual(actions, []); }); + +test("terminal keyboard focus is reported on change only; a plugin iframe or app field is not a terminal", async (t) => { + const previousElement = globalThis.Element; + class FakeElement { constructor(selector) { this.selector = selector; } closest(query) { return this.selector && query.includes(this.selector) ? this : null; } } + globalThis.Element = FakeElement; + t.after(() => { if (previousElement === undefined) delete globalThis.Element; else globalThis.Element = previousElement; }); + const listeners = new Map(); + const doc = { + activeElement: new FakeElement(null), + addEventListener: (type, fn) => listeners.set(type, fn), + removeEventListener: (type) => listeners.delete(type), + defaultView: { addEventListener() {}, removeEventListener() {} } + }; + const reports = []; + const stop = trackTerminalEditFocus(doc, (focused) => reports.push(focused)); + doc.activeElement = new FakeElement(".xterm"); listeners.get("focusin")(); + doc.activeElement = new FakeElement(".xterm"); listeners.get("focusin")(); + doc.activeElement = new FakeElement(null); listeners.get("focusout")(); // e.g. the plugin iframe took focus + await new Promise((resolve) => queueMicrotask(resolve)); + assert.deepEqual(reports, [false, true, false]); + stop(); + assert.equal(listeners.size, 0); +}); diff --git a/tests/canvas-navigation-override.test.mjs b/tests/canvas-navigation-override.test.mjs index f3016aa6..ddad2e9b 100644 --- a/tests/canvas-navigation-override.test.mjs +++ b/tests/canvas-navigation-override.test.mjs @@ -230,7 +230,7 @@ test("modifier-only Meta full override does not swallow ordinary Command shortcu assert.equal(prevented, false); }); -test("macOS Select All, Copy and Paste reach the renderer without capturing other Command shortcuts", () => { +test("macOS Select All, Copy and Paste reach a focused terminal without capturing other Command shortcuts", () => { const contents = new EventEmitter(); const ignored = []; contents.isDestroyed = () => false; @@ -240,6 +240,7 @@ test("macOS Select All, Copy and Paste reach the renderer without capturing othe navigationBinding: null }, () => undefined); controller.attach(contents, { captureMacEditShortcuts: true }); + controller.setTerminalEditFocus(contents, true); let prevented = false; const event = { preventDefault: () => { prevented = true; } }; @@ -430,3 +431,43 @@ test("changing a binding resets pressed state instead of activating it retroacti }); assert.equal(tracker.active, false); }); + +test("macOS Command+C/V/A keep the Edit menu in ordinary fields and plugin pages; only a focused terminal captures them", () => { + const top = { name: "top" }; + const pluginFrame = { name: "plugin iframe" }; + const contents = new EventEmitter(); + const ignored = []; + contents.isDestroyed = () => false; + contents.setIgnoreMenuShortcuts = (active) => ignored.push(active); + contents.mainFrame = top; + contents.focusedFrame = top; + const controller = new CanvasNavigationInputController({ wheelBinding: null, navigationBinding: null }, () => undefined); + controller.attach(contents, { captureMacEditShortcuts: true }); + const event = { preventDefault: () => assert.fail("editing keys are never cancelled") }; + const pressV = () => { + contents.emit("before-input-event", event, input("keyDown", "v", { code: "KeyV", meta: true })); + contents.emit("before-input-event", event, input("keyUp", "v", { code: "KeyV", meta: true })); + }; + + // An app field (no terminal focus reported): the menu's native Paste must run. + pressV(); + assert.deepEqual(ignored, [], "an ordinary field keeps Edit > Paste"); + + // A plugin page's field: the iframe has focus, even if a terminal had focus before. + controller.setTerminalEditFocus(contents, true); + contents.focusedFrame = pluginFrame; + pressV(); + assert.deepEqual(ignored, [], "a plugin iframe keeps Edit > Paste"); + + // Back in the terminal: captured for xterm's own paste. + contents.focusedFrame = top; + pressV(); + assert.deepEqual(ignored, [true, false]); + + // Focus leaves the terminal while the capture is held: it is released at once. + contents.emit("before-input-event", event, input("keyDown", "c", { code: "KeyC", meta: true })); + controller.setTerminalEditFocus(contents, false); + assert.deepEqual(ignored, [true, false, true, false]); + pressV(); + assert.deepEqual(ignored, [true, false, true, false]); +}); diff --git a/tests/edit-context-menu.test.mjs b/tests/edit-context-menu.test.mjs new file mode 100644 index 00000000..97edc486 --- /dev/null +++ b/tests/edit-context-menu.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; +import { attachEditContextMenu, editContextMenuTemplate } from "../src/main/editContextMenu.ts"; + +const flags = { canCut: true, canCopy: true, canPaste: true, canSelectAll: true, canUndo: false, canRedo: false, canDelete: true, canEditRichly: false }; + +test("an editable field (app or plugin page) gets native Cut, Copy, Paste and Select All", () => { + const template = editContextMenuTemplate({ isEditable: true, selectionText: "", editFlags: flags }, "ru"); + assert.deepEqual(template.filter(item => item.role).map(item => [item.role, item.label]), + [["cut", "Вырезать"], ["copy", "Скопировать"], ["paste", "Вставить"], ["selectAll", "Выбрать все"]]); + const empty = editContextMenuTemplate({ isEditable: true, selectionText: "", editFlags: { ...flags, canCut: false, canCopy: false } }, "en"); + assert.deepEqual(empty.filter(item => item.role).map(item => [item.role, item.enabled]), + [["cut", false], ["copy", false], ["paste", true], ["selectAll", true]]); +}); + +test("selected page text gets Copy only; anything else gets no menu", () => { + assert.deepEqual(editContextMenuTemplate({ isEditable: false, selectionText: "abc", editFlags: flags }, "en").map(item => item.role), ["copy"]); + assert.deepEqual(editContextMenuTemplate({ isEditable: false, selectionText: " ", editFlags: flags }, "en"), []); +}); + +test("the context-menu event pops the menu only when there is something to show", () => { + const contents = new EventEmitter(); + const shown = []; + attachEditContextMenu(contents, () => "en", (template) => shown.push(template.map(item => item.role ?? item.type))); + contents.emit("context-menu", {}, { isEditable: true, selectionText: "", editFlags: flags }); + contents.emit("context-menu", {}, { isEditable: false, selectionText: "", editFlags: flags }); + assert.deepEqual(shown, [["cut", "copy", "paste", "separator", "selectAll"]]); +}); From 22620f37238d4693c064216a5ba61fbc5fe7251d Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:31:34 +0000 Subject: [PATCH 02/74] Preserve edit chords in shortcut recorders --- src/main/services/CanvasNavigationOverride.ts | 8 +-- src/renderer/src/lib/shortcuts.ts | 11 ++-- tests/canvas-navigation-override.test.mjs | 58 +++++++++++++++++++ 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/main/services/CanvasNavigationOverride.ts b/src/main/services/CanvasNavigationOverride.ts index d0e1f40d..a8f15c67 100644 --- a/src/main/services/CanvasNavigationOverride.ts +++ b/src/main/services/CanvasNavigationOverride.ts @@ -200,7 +200,7 @@ export class CanvasNavigationInputController { private readonly attachedContents = new Set(); private readonly onActiveChange: (state: CanvasNavigationOverrideState) => void; private menuShortcutContents: WebContents | null = null; - /** Contents whose renderer reported that a terminal surface has keyboard focus. */ + /** Contents whose renderer reported keyboard focus in a terminal surface or shortcut recorder. */ private readonly terminalEditFocus = new WeakSet(); constructor( @@ -236,9 +236,9 @@ export class CanvasNavigationInputController { } /** - * The renderer reports whether a terminal surface (which implements its own Command+C/V and Codex Command+A) has - * keyboard focus. Only then are those shortcuts kept from the Edit menu; every other field, in the app or in a - * plugin page, keeps the menu's native Copy, Paste and Select All. + * The renderer reports whether a terminal surface or shortcut recorder has keyboard focus. Both consume + * Command+C/V/A themselves, so those shortcuts bypass the Edit menu. The terminal-named API covers both; + * ordinary fields and plugin pages keep the menu's native Copy, Paste and Select All. */ setTerminalEditFocus(contents: WebContents, focused: boolean): void { if (focused) this.terminalEditFocus.add(contents); diff --git a/src/renderer/src/lib/shortcuts.ts b/src/renderer/src/lib/shortcuts.ts index 916a47b2..0b037c77 100644 --- a/src/renderer/src/lib/shortcuts.ts +++ b/src/renderer/src/lib/shortcuts.ts @@ -63,15 +63,16 @@ export function isRenameInputTarget(target: EventTarget | null): boolean { } /** - * A terminal surface implements Command+C/V (and Codex Command+A) itself, so only while one has focus does the main - * process keep those keys from the Edit menu. Ordinary fields and plugin pages (an iframe is the active element) use - * the menu's native editing. + * Renderer-owned editing includes terminal surfaces and shortcut recorders: both need Command+C/V/A delivered + * as keyboard events. The terminal-named focus API also covers these recorder controls. Ordinary fields and plugin + * pages (an iframe is the active element) use the menu's native editing. */ export function isTerminalEditTarget(target: EventTarget | null): boolean { - return target instanceof Element && Boolean(target.closest(".terminal-card__surface, .xterm")); + return isShortcutCaptureTarget(target) + || (target instanceof Element && Boolean(target.closest(".terminal-card__surface, .xterm"))); } -/** Calls `report` with each change of terminal keyboard focus in `doc` (macOS edit-shortcut routing). */ +/** Reports changes in renderer-owned edit focus, including shortcut recorders (macOS edit-shortcut routing). */ export function trackTerminalEditFocus(doc: Document, report: (focused: boolean) => void): () => void { let last: boolean | null = null; const update = (): void => { diff --git a/tests/canvas-navigation-override.test.mjs b/tests/canvas-navigation-override.test.mjs index ddad2e9b..d2985208 100644 --- a/tests/canvas-navigation-override.test.mjs +++ b/tests/canvas-navigation-override.test.mjs @@ -7,6 +7,8 @@ import { shouldPreventCanvasNavigationInput } from "../src/main/services/CanvasNavigationOverride.ts"; +import { trackTerminalEditFocus } from "../src/renderer/src/lib/shortcuts.ts"; + const input = (type, key, modifiers = {}) => ({ type, key, @@ -471,3 +473,59 @@ test("macOS Command+C/V/A keep the Edit menu in ordinary fields and plugin pages pressV(); assert.deepEqual(ignored, [true, false, true, false]); }); + + +test("focused shortcut recorders receive Command+A/C/V while ordinary fields keep native editing", (t) => { + const previousElement = globalThis.Element; + class FakeElement { + constructor(selector) { this.selector = selector; } + closest(query) { return query.includes(this.selector) ? this : null; } + } + globalThis.Element = FakeElement; + t.after(() => { + if (previousElement === undefined) delete globalThis.Element; + else globalThis.Element = previousElement; + }); + const top = {}; + const contents = new EventEmitter(); + const ignored = []; + contents.isDestroyed = () => false; + contents.setIgnoreMenuShortcuts = (active) => ignored.push(active); + contents.mainFrame = top; + contents.focusedFrame = top; + const controller = new CanvasNavigationInputController({ wheelBinding: "Meta", navigationBinding: "Alt" }, () => undefined); + controller.attach(contents, { captureMacEditShortcuts: true }); + // The canvas-navigation recorder suspends these bindings while listening for its new chord. + controller.setShortcutCaptureActive(true); + const listeners = new Map(); + const doc = { + activeElement: new FakeElement("input"), + addEventListener: (type, fn) => listeners.set(type, fn), + removeEventListener: (type) => listeners.delete(type), + defaultView: { addEventListener() {}, removeEventListener() {} } + }; + const stop = trackTerminalEditFocus(doc, (focused) => controller.setTerminalEditFocus(contents, focused)); + t.after(stop); + const pressEditKeys = () => { + for (const key of ["a", "c", "v"]) { + const event = { preventDefault: () => assert.fail("the renderer must receive recorder keyboard events") }; + contents.emit("before-input-event", event, input("keyDown", key, { code: `Key${key.toUpperCase()}`, meta: true })); + contents.emit("before-input-event", event, input("keyUp", key, { code: `Key${key.toUpperCase()}`, meta: true })); + } + }; + pressEditKeys(); + assert.deepEqual(ignored, [], "ordinary inputs retain the native Edit menu"); + doc.activeElement = new FakeElement('[data-shortcut-capture="true"]'); + listeners.get("focusin")(); + pressEditKeys(); + assert.deepEqual(ignored, [true, false, true, false, true, false], "all three chords bypass the menu for the recorder"); + ignored.length = 0; + contents.focusedFrame = {}; + pressEditKeys(); + assert.deepEqual(ignored, [], "a focused plugin frame still uses native editing even with a stale recorder report"); + contents.focusedFrame = top; + doc.activeElement = new FakeElement("textarea"); + listeners.get("focusin")(); + pressEditKeys(); + assert.deepEqual(ignored, [], "leaving the recorder restores native editing"); +}); From 4480245f921ba067a735981c2da838e793e0b414 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:12:26 +0000 Subject: [PATCH 03/74] Coordinate subagents with task graphs, reviews, budgets and retry --- electron.vite.config.ts | 2 +- native/canvastty-helper/catalog.json | 187 +++++- src/agent-browser/orchestration-catalog.mjs | 77 ++- src/main/index.ts | 121 +++- src/main/ipc/registerBacklogIpc.ts | 85 +++ src/main/services/AgentControlService.ts | 543 +++++++++++++++- src/main/services/DiffOnlyReviewWorkspace.ts | 66 ++ src/main/services/GitCheckpoints.ts | 71 ++ src/main/services/LaunchPipeline.ts | 43 +- .../services/OrchestrationBudgetService.ts | 397 +++++++++++ src/main/services/OrchestrationTaskBoard.ts | 376 +++++++++++ .../services/OrchestrationTemplateService.ts | 360 ++++++++++ src/main/services/OrchestrationUsageSync.ts | 100 +++ src/main/services/PluginManager.ts | 2 + src/main/services/ProcessTreePause.ts | 178 +++++ src/main/services/ProviderUsageSource.ts | 201 ++++++ .../services/SessionTimelineIndexWorker.ts | 8 + src/main/services/SessionTimelineService.ts | 615 ++++++++++++++++++ src/main/services/TerminalManager.ts | 520 +++++++++++++-- src/main/services/TerminalSessionStore.ts | 14 +- src/main/services/TimelineIndexScan.ts | 258 ++++++++ src/main/services/TimelineJournalReader.ts | 60 ++ src/main/services/TimelineSearchIndex.ts | 64 ++ src/main/services/UsagePrices.ts | 37 ++ src/main/services/accountHomeIsolation.ts | 80 +++ .../agent-browser/OrchestrationTools.ts | 195 +++++- .../agent-runtime/AgentRuntimeBridge.ts | 14 + src/main/services/configuredModel.ts | 20 + src/main/services/isolation/AgentIsolation.ts | 236 ++++++- src/main/services/isolation/bubblewrap.ts | 2 +- src/main/services/isolation/isolationPaths.ts | 98 ++- src/main/services/isolation/reviewerHome.ts | 61 ++ src/main/services/isolation/seatbelt.ts | 22 +- src/main/services/providerModels.ts | 23 +- src/preload/index.ts | 5 + src/renderer/src/App.tsx | 6 +- .../features/launcher/AgentLaunchDialog.tsx | 101 ++- .../src/features/terminal/TerminalCard.tsx | 30 +- .../workspace/BacklogSessionInspector.tsx | 369 +++++++++++ .../src/features/workspace/TaskEdgeLayer.tsx | 75 +++ .../src/features/workspace/TaskSummaryBar.tsx | 38 ++ .../features/workspace/WorkspaceCanvas.tsx | 46 +- .../features/workspace/backlogRendererApi.ts | 3 + .../features/workspace/inspectorLoadGate.ts | 31 + .../src/features/workspace/useDialogFocus.ts | 87 +++ .../src/features/workspace/visibleRefresh.ts | 83 +++ .../features/workspace/workspaceAsyncState.ts | 27 + .../workspace/workspaceBacklogText.ts | 54 ++ .../features/workspace/workspaceTaskGraph.ts | 80 +++ .../features/workspace/workspaceTaskLayout.ts | 120 ++++ src/renderer/src/styles/app.css | 1 + src/renderer/src/styles/orchestration.css | 97 +++ src/shared/backlog.ts | 55 ++ src/shared/contracts.ts | 7 + tests/account-home-isolation.test.mjs | 173 +++++ tests/agent-delegation-invariants.test.mjs | 2 +- tests/budget-process-pause.test.mjs | 211 ++++++ tests/diff-only-review-isolation.test.mjs | 293 +++++++++ tests/git-review-diff.test.mjs | 33 + tests/orchestration-backlog-core.test.mjs | 540 +++++++++++++++ tests/orchestration-flow-approval.test.mjs | 75 +++ tests/orchestration-gateway.test.mjs | 5 + tests/orchestration-launch.test.mjs | 9 +- tests/orchestration-ui.test.mjs | 65 ++ tests/process-tree-pause.test.mjs | 57 ++ tests/provider-usage-source.test.mjs | 49 ++ tests/reviewer-model-usage.test.mjs | 190 ++++++ tests/subagent-model-account.test.mjs | 119 ++++ tests/subagent-signal-exit.test.mjs | 99 +++ 69 files changed, 8219 insertions(+), 152 deletions(-) create mode 100644 src/main/ipc/registerBacklogIpc.ts create mode 100644 src/main/services/DiffOnlyReviewWorkspace.ts create mode 100644 src/main/services/GitCheckpoints.ts create mode 100644 src/main/services/OrchestrationBudgetService.ts create mode 100644 src/main/services/OrchestrationTaskBoard.ts create mode 100644 src/main/services/OrchestrationTemplateService.ts create mode 100644 src/main/services/OrchestrationUsageSync.ts create mode 100644 src/main/services/ProcessTreePause.ts create mode 100644 src/main/services/ProviderUsageSource.ts create mode 100644 src/main/services/SessionTimelineIndexWorker.ts create mode 100644 src/main/services/SessionTimelineService.ts create mode 100644 src/main/services/TimelineIndexScan.ts create mode 100644 src/main/services/TimelineJournalReader.ts create mode 100644 src/main/services/TimelineSearchIndex.ts create mode 100644 src/main/services/UsagePrices.ts create mode 100644 src/main/services/accountHomeIsolation.ts create mode 100644 src/main/services/configuredModel.ts create mode 100644 src/main/services/isolation/reviewerHome.ts create mode 100644 src/renderer/src/features/workspace/BacklogSessionInspector.tsx create mode 100644 src/renderer/src/features/workspace/TaskEdgeLayer.tsx create mode 100644 src/renderer/src/features/workspace/TaskSummaryBar.tsx create mode 100644 src/renderer/src/features/workspace/backlogRendererApi.ts create mode 100644 src/renderer/src/features/workspace/inspectorLoadGate.ts create mode 100644 src/renderer/src/features/workspace/useDialogFocus.ts create mode 100644 src/renderer/src/features/workspace/visibleRefresh.ts create mode 100644 src/renderer/src/features/workspace/workspaceAsyncState.ts create mode 100644 src/renderer/src/features/workspace/workspaceBacklogText.ts create mode 100644 src/renderer/src/features/workspace/workspaceTaskGraph.ts create mode 100644 src/renderer/src/features/workspace/workspaceTaskLayout.ts create mode 100644 src/renderer/src/styles/orchestration.css create mode 100644 src/shared/backlog.ts create mode 100644 tests/account-home-isolation.test.mjs create mode 100644 tests/budget-process-pause.test.mjs create mode 100644 tests/diff-only-review-isolation.test.mjs create mode 100644 tests/git-review-diff.test.mjs create mode 100644 tests/orchestration-backlog-core.test.mjs create mode 100644 tests/orchestration-flow-approval.test.mjs create mode 100644 tests/orchestration-ui.test.mjs create mode 100644 tests/process-tree-pause.test.mjs create mode 100644 tests/provider-usage-source.test.mjs create mode 100644 tests/reviewer-model-usage.test.mjs create mode 100644 tests/subagent-model-account.test.mjs create mode 100644 tests/subagent-signal-exit.test.mjs diff --git a/electron.vite.config.ts b/electron.vite.config.ts index eefc3536..7f45de7f 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()], build: { rollupOptions: { - input: resolve("src/main/index.ts") + input: {index: resolve("src/main/index.ts"), SessionTimelineIndexWorker: resolve("src/main/services/SessionTimelineIndexWorker.ts")} } } }, diff --git a/native/canvastty-helper/catalog.json b/native/canvastty-helper/catalog.json index d3ec39a2..10320a4b 100644 --- a/native/canvastty-helper/catalog.json +++ b/native/canvastty-helper/catalog.json @@ -964,7 +964,7 @@ }, { "name": "spawn_agent", - "description": "Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi, cursor, minimax, devin, antigravity); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. \"auto\" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.", + "description": "Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi, cursor, minimax, devin, antigravity); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); omit model to keep the selected account or provider default. effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. \"auto\" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. review: true asks CanvasTTY to run a read-only review after this agent finishes; reviewModel optionally selects a different known model for the reviewer. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. Without a model account in launchOptions the subagent runs on this session's own model account when this session has one; {\"canvastty-accounts\":{\"account\":\"none\"}} explicitly runs it on the CLI's own sign-in and default model. The answer's servedBy says which provider, account and model serve it. Refusals say why (a limit the person set, a folder outside this session's project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.", "inputSchema": { "type": "object", "properties": { @@ -1028,6 +1028,14 @@ "max" ] }, + "review": { + "type": "boolean" + }, + "reviewModel": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, "launchOptions": { "type": "object", "maxProperties": 16, @@ -1159,6 +1167,183 @@ "required": [], "additionalProperties": false } + }, + { + "name": "retry_agent", + "description": "Retry one failed or quiet subagent with its original prompt, launch profile, model and folder plus a short masked failure tail. CanvasTTY allows at most two retries per original agent.", + "inputSchema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + }, + { + "name": "list_tasks", + "description": "List the shared task board for this orchestration. Subagents see only tasks belonging to their own root orchestrator.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "claim_task", + "description": "Atomically claim one open task whose dependencies are all complete. If another agent owns it, the current owner is returned.", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + } + }, + { + "name": "update_task", + "description": "Update the task you own. Only the root orchestrator can reassign tasks or change dependencies; complete work with complete_task.", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 8000 + }, + "progress": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "status": { + "type": "string", + "enum": [ + "open", + "claimed" + ] + }, + "ownerSessionId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "ownerName": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "dependencies": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "maxItems": 64 + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + } + }, + { + "name": "complete_task", + "description": "Mark a task complete and record its result. Only its owner or the root orchestrator can complete it, and dependencies must be complete.", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "result": { + "type": "string", + "minLength": 1, + "maxLength": 8000 + } + }, + "required": [ + "taskId", + "result" + ], + "additionalProperties": false + } + }, + { + "name": "get_task_budget", + "description": "Read the budget for this orchestration tree. Unknown provider token or cost usage is returned as no data; only a person can change or remove limits.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "list_orchestration_templates", + "description": "List built-in and project orchestration flows from .canvastty/flows. Invalid files are returned as line-numbered errors.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "apply_orchestration_template", + "description": "Get the roles, expected number of subagents and final-step instructions for a flow, ready to apply to a task.", + "inputSchema": { + "type": "object", + "properties": { + "templateId": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "task": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + } + }, + "required": [ + "templateId", + "task" + ], + "additionalProperties": false + } } ], "providerIds": [ diff --git a/src/agent-browser/orchestration-catalog.mjs b/src/agent-browser/orchestration-catalog.mjs index ad7b53d2..e6e8b4fb 100644 --- a/src/agent-browser/orchestration-catalog.mjs +++ b/src/agent-browser/orchestration-catalog.mjs @@ -6,6 +6,7 @@ export const MAX_ORCHESTRATION_PAYLOAD_BYTES = 128 * 1024; const string = (options = {}) => ({ type: "string", ...options }); const boolean = () => ({ type: "boolean" }); const integer = (options = {}) => ({ type: "integer", ...options }); +const array = (items, options = {}) => ({ type: "array", items, ...options }); const object = (properties, required = []) => ({ type: "object", properties, @@ -58,7 +59,7 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ ), tool( "spawn_agent", - `Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: ${AGENT_PROVIDER_IDS.join(", ")}); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. "auto" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.`, + `Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: ${AGENT_PROVIDER_IDS.join(", ")}); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); omit model to keep the selected account or provider default. effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. "auto" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. review: true asks CanvasTTY to run a read-only review after this agent finishes; reviewModel optionally selects a different known model for the reviewer. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. Without a model account in launchOptions the subagent runs on this session's own model account when this session has one; {"canvastty-accounts":{"account":"none"}} explicitly runs it on the CLI's own sign-in and default model. The answer's servedBy says which provider, account and model serve it. Refusals say why (a limit the person set, a folder outside this session's project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.`, { provider, cwd: string({ minLength: 1, maxLength: 4_096 }), @@ -67,6 +68,8 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ profile: string({ enum: ["auto", "normal", "acceptEdits", "plan"] }), model: string({ minLength: 1, maxLength: 200 }), effort: string({ enum: [...REASONING_EFFORT_IDS] }), + review: boolean(), + reviewModel: string({ minLength: 1, maxLength: 200 }), launchOptions }, ["provider", "cwd"] @@ -103,6 +106,63 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ tool( "list_agents", "List this session's subagents with provider, status, and title." + ), + tool( + "retry_agent", + "Retry one failed or quiet subagent with its original prompt, launch profile, model and folder plus a short masked failure tail. CanvasTTY allows at most two retries per original agent.", + { sessionId, reason: string({ maxLength: 500 }) }, + ["sessionId"] + ), + tool( + "list_tasks", + "List the shared task board for this orchestration. Subagents see only tasks belonging to their own root orchestrator.", + {}, + [] + ), + tool( + "claim_task", + "Atomically claim one open task whose dependencies are all complete. If another agent owns it, the current owner is returned.", + { taskId: string({ minLength: 1, maxLength: 160 }) }, + ["taskId"] + ), + tool( + "update_task", + "Update the task you own. Only the root orchestrator can reassign tasks or change dependencies; complete work with complete_task.", + { + taskId: string({ minLength: 1, maxLength: 160 }), + title: string({ minLength: 1, maxLength: 240 }), + description: string({ minLength: 1, maxLength: 8_000 }), + progress: string({ minLength: 1, maxLength: 4_000 }), + status: string({ enum: ["open", "claimed"] }), + ownerSessionId: string({ minLength: 1, maxLength: 160 }), + ownerName: string({ minLength: 1, maxLength: 160 }), + dependencies: array(string({ minLength: 1, maxLength: 80 }), { maxItems: 64 }) + }, + ["taskId"] + ), + tool( + "complete_task", + "Mark a task complete and record its result. Only its owner or the root orchestrator can complete it, and dependencies must be complete.", + { taskId: string({ minLength: 1, maxLength: 160 }), result: string({ minLength: 1, maxLength: 8_000 }) }, + ["taskId", "result"] + ), + tool( + "get_task_budget", + "Read the budget for this orchestration tree. Unknown provider token or cost usage is returned as no data; only a person can change or remove limits.", + {}, + [] + ), + tool( + "list_orchestration_templates", + "List built-in and project orchestration flows from .canvastty/flows. Invalid files are returned as line-numbered errors.", + {}, + [] + ), + tool( + "apply_orchestration_template", + "Get the roles, expected number of subagents and final-step instructions for a flow, ready to apply to a task.", + { templateId: string({ minLength: 1, maxLength: 64 }), task: prompt }, + ["templateId", "task"] ) ]); @@ -173,6 +233,21 @@ export function validateOrchestrationArguments(toolName, args) { else if (property.minimum !== undefined && candidate < property.minimum) errors.push(`${key} is below the minimum.`); else if (property.maximum !== undefined && candidate > property.maximum) errors.push(`${key} is above the maximum.`); else value[key] = candidate; + } else if (property.type === "array") { + if (!Array.isArray(candidate) || candidate.length > (property.maxItems ?? Number.MAX_SAFE_INTEGER)) { + errors.push(`${key} must be an array of at most ${property.maxItems ?? "the allowed number of"} items.`); + } else { + const items = []; + for (let index = 0; index < candidate.length; index += 1) { + const item = candidate[index]; + if (property.items?.type === "string") { + if (typeof item !== "string") errors.push(`${key}[${index}] must be a string.`); + else if (item.length < (property.items.minLength ?? 0) || item.length > (property.items.maxLength ?? Number.MAX_SAFE_INTEGER)) errors.push(`${key}[${index}] has an invalid length.`); + else items.push(item); + } else errors.push(`${key} has an unsupported item type.`); + } + value[key] = items; + } } } for (const key of Object.keys(args)) { diff --git a/src/main/index.ts b/src/main/index.ts index 9179be99..7d94e3be 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,14 @@ +import { UsagePrices } from "./services/UsagePrices"; +import { ProviderUsageSource } from "./services/ProviderUsageSource"; +import { SessionTimelineService } from "./services/SessionTimelineService"; +import { configuredModel } from "./services/configuredModel"; +import { GitCheckpoints } from "./services/GitCheckpoints"; +import { OrchestrationBudgetService } from "./services/OrchestrationBudgetService"; +import { OrchestrationTaskBoard } from "./services/OrchestrationTaskBoard"; +import { OrchestrationTemplateService } from "./services/OrchestrationTemplateService"; +import { resolveAgentHistoryPaths } from "./services/agent-history/historyPaths"; +import { refreshOrchestrationUsageBatch, type OrchestrationUsageScope } from "./services/OrchestrationUsageSync"; +import { registerBacklogIpc } from "./ipc/registerBacklogIpc"; import "./stdio"; import appIcon from "../../build/icon.png?asset"; import appManifest from "../../package.json"; @@ -165,6 +176,9 @@ async function showCompanionBrowser():Promise<{title:string;url:string}> { const state=browserService.getState(), tab=state.tabs.find(tab=>tab.id===state.activeTabId); return {title:tab?.title||"Browser",url:tab?.url||""}; } +let disposeBudgetObservers:(()=>void)|null=null; +let flushBudgets:(()=>Promise)|null=null; +let sessionTimeline:SessionTimelineService|null=null; let terminalManager: TerminalManager | null = null; let agentControl: AgentControlGateway | null = null; let limitsService: LimitsService | null = null; @@ -358,6 +372,34 @@ async function initializeServices(ipc: IpcRegistrar): Promise { markMainBoot("criticalServicesReady"); // Secrets this app knows are masked in every text one agent reads from another (EP-8). const redaction = new SecretRedactionRegistry(); + const timeline = new SessionTimelineService(userDataPath,text => redaction.redact(text)); + await timeline.load(); + sessionTimeline=timeline; + const usagePrices=new UsagePrices(join(userDataPath,"usage-prices.json")); + await usagePrices.load(); + const usageSources=new Map(); + const usageSourceFor=(home:string):ProviderUsageSource=>{ + let source=usageSources.get(home); + if(!source){source=new ProviderUsageSource(home);usageSources.set(home,source);} + return source; + }; + const taskBoard=new OrchestrationTaskBoard(join(userDataPath,"task-boards")); + const templates=new OrchestrationTemplateService(join(userDataPath,"flow-approvals.json")); + let budgetInputGate:(id:string)=>void=()=>undefined; + const applyBudgetEnforcement=(row:ReturnType):void=>{ + if(!terminalManager)return; + const result=terminalManager.setBudgetPaused(row.rootSessionId,row.paused); + const failure=result.failed ? redaction.redact(result.failed) : undefined; + budgets.setEnforcementFailure(row.rootSessionId,failure); + if(failure){row.paused=true;row.reason=`${row.reason ?? "Task budget is paused."} ${failure}`;} + }; + const budgets=new OrchestrationBudgetService(join(userDataPath,"task-budgets.json"),{ + onPause:applyBudgetEnforcement, + onChange:applyBudgetEnforcement + }); + await budgets.load(); + flushBudgets=()=>budgets.flush(); + const checkpoints=new GitCheckpoints(text=>redaction.redact(text)); diagnostics.configureRedaction(text => redaction.redact(text)); diagnosticContext = () => ({ servicesReady, @@ -519,6 +561,13 @@ async function initializeServices(ipc: IpcRegistrar): Promise { runtimeDirectory: lifecycleRuntimeDirectory, windowsHostPath, onSignal: (terminalSessionId, signal) => { + const usageRow=terminalManager?.getMetadata(terminalSessionId); + if(usageRow?.provider==="codex" && signal.threadId && (!terminalManager?.pluginContext(terminalSessionId)?.environment || terminalManager.pluginContext(terminalSessionId)?.environment?.kind==="worktree")) { + const account=terminalManager!.usageAccount(terminalSessionId); + const source=usageSourceFor(account.home ?? resolveAgentHistoryPaths().codex); + void source.codexUsage(signal.threadId).then(usage=>usage===null || !terminalManager?.getMetadata(terminalSessionId) ? undefined : timeline.recordCumulativeUsage(terminalSessionId,usage,"codex-cli conversation counter",signal.threadId,{provider:"codex",accountId:account.id,taskId:agentControlService.taskRoot(terminalSessionId).id,...(usage.model ?? usageRow.model ? {model:usage.model ?? usageRow.model} : {})},{resumed:terminalManager!.resumedConversation(terminalSessionId,signal.threadId!)})).catch(console.warn); + } + terminalManager?.applyProviderSignal(terminalSessionId, { kind: "lifecycle", state: signal.state, @@ -539,7 +588,11 @@ async function initializeServices(ipc: IpcRegistrar): Promise { } }, onAnswerCaptureRevoked: (terminalSessionId) => evenG2?.clearAnswer(terminalSessionId), - onPermissionRequest: (terminalSessionId, request, signal) => decisionHooks.decide(terminalSessionId, request, signal), + onPermissionRequest: (terminalSessionId, request, signal) => { + try { budgetInputGate(terminalSessionId); } + catch(error) { return {behavior:"deny",message:redaction.redact(error instanceof Error ? error.message : "Task budget is paused.")}; } + return decisionHooks.decide(terminalSessionId, request, signal); + }, // Claude Code's lifecycle hooks go straight to a loopback listener where ClaudeHttpHookPolicy allows it. httpHooks: true }); @@ -579,6 +632,9 @@ async function initializeServices(ipc: IpcRegistrar): Promise { } // Output batches of every session flushed in one task leave as one IPC message. + let forgetOrchestrationSession=(_id:string):void=>undefined; + let scheduleUsageRefresh=():void=>undefined; + const usageMembership=new Map(); const rendererOutbox = new TerminalRendererOutbox((channel, payload) => { if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) mainWindow.webContents.send(channel, payload); }); @@ -619,11 +675,14 @@ async function initializeServices(ipc: IpcRegistrar): Promise { }).show(); } } else if (channel === IPC.terminalRemoved && "id" in payload) { + usageMembership.delete(payload.id);scheduleUsageRefresh(); + forgetOrchestrationSession(payload.id); diagnostics.record("info", "terminal", "session.closed", { id: payload.id }); notifiedAttentionStatus.delete(payload.id); } }, providerClis, agentBrowserBridge ?? undefined, agentRuntimeBridge ?? undefined, settings.get().agentLifecycleHooksEnabled); terminalManager.configureRedaction(redaction); + timeline.configureSessionContext(id=>{const row=terminalManager?.getMetadata(id);return row ? {taskId:terminalManager!.taskScopeFor(id).id,title:row.title || row.provider} : undefined;}); terminalManager.setKeyboardShortcuts(settings.get().shortcuts); // The operating-system isolation layer (Settings → Agents → Agent isolation) and YOLO only where the person // acknowledged it: both decided here, in the main process, for every launch whoever asks for it. @@ -665,7 +724,8 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // list_providers: the CLI registry resolved at startup, the last usage read (never started from here) and the // launch options trusted plugins declared. - const providerModels = new ProviderModelCatalog(providerClis); + const providerModels = new ProviderModelCatalog(providerClis,{codexHome:resolveAgentHistoryPaths().codex}); + await providerModels.refresh("codex"); // OpenCode with a model it does not list fails with only "Unexpected server error": refuse it up front. terminalManager.configureModelCheck((provider, model) => provider === "terminal" ? null : providerModels.unknownModelCached(provider, model)); const providerDirectorySources: ProviderDirectorySources = { @@ -684,8 +744,54 @@ async function initializeServices(ipc: IpcRegistrar): Promise { const managedTerminals = terminalManager; const agentControlService = new AgentControlService(managedTerminals, { limits: () => ({ maxDepth: settings.get().orchestrationMaxDepth, maxSubagents: settings.get().orchestrationMaxSubagents }), - containment: () => managedTerminals.containment() + containment: () => managedTerminals.containment(), + budget:budgets, + workerModel:async worker=>{ + const account=managedTerminals.usageAccount(worker.id); + const actual=worker.provider==="codex" && worker.threadId ? await usageSourceFor(account.home ?? resolveAgentHistoryPaths().codex).codexUsage(worker.threadId) : null; + return actual?.model ?? configuredModel(worker.provider as AgentProviderId,{codex:resolveAgentHistoryPaths().codex,claude:join(app.getPath("home"),".claude"),opencode:join(process.env.XDG_CONFIG_HOME ?? join(app.getPath("home"),".config"),"opencode")}); + }, + reviewCost:id=>timeline.usage([id],usagePrices.get()).cost, + reviewModel:(provider,model)=>model ? providerDirectorySources.models?.(provider)?.models.find(candidate=>candidate!==model) ?? null : null, + reviewDiff:worker=>checkpoints.workingDiff(managedTerminals.pluginContext(worker.id)?.workingDirectory ?? worker.cwd), + onReview:id=>managedTerminals.setTaskMetadata(id,{reviewRequested:true}) }); + budgetInputGate=id=>agentControlService.assertInputAllowed(id); + managedTerminals.configureInputGate(budgetInputGate); + let usageRefreshPending=false; + const refreshUsage=()=>{ + const rows=managedTerminals.listMetadata(),scopes=agentControlService.taskRoots(rows); + const prices=usagePrices.get(); + const roots=new Map(); + const members=new Map(),parents=new Map(); + for(const row of rows){ + if(row.provider==="terminal")continue; + const root=scopes.get(row.id);if(!root)continue; + const ids=members.get(root.id) ?? [];ids.push(row.id);members.set(root.id,ids); + if(!row.parentSessionId)parents.set(root.id,row.id); + if(row.exitCode===null)roots.set(root.id,root); + } + const usageScopes:OrchestrationUsageScope[]=[]; + for(const root of roots.values()) { + usageScopes.push({rootSessionId:root.id,rootStartedAt:root.startedAt,memberSessionIds:members.get(root.id) ?? [], + budgetEnabled:budgets.hasLimits(root.id)}); + } + refreshOrchestrationUsageBatch(budgets,timeline,usageScopes,prices,(scope,snapshot)=>{ + const parent=parents.get(scope.rootSessionId); + if(parent)managedTerminals.setTaskBudget(parent,snapshot ? {...snapshot.remaining,paused:snapshot.paused,warning:snapshot.warning, + ...(snapshot.paused && !managedTerminals.processSuspensionSupported() ? {processesKeepRunning:true} : {})} : undefined); + },scope=>{budgets.snapshot(scope.rootSessionId,scope.rootStartedAt);}); + }; + scheduleUsageRefresh=()=>{ + if(usageRefreshPending)return; + usageRefreshPending=true; + queueMicrotask(()=>{usageRefreshPending=false;refreshUsage();}); + }; + const offUsage=timeline.subscribeUsage(scheduleUsageRefresh),offPrices=usagePrices.subscribe(scheduleUsageRefresh),offBudgets=budgets.subscribe(scheduleUsageRefresh); + disposeBudgetObservers=()=>{offUsage();offPrices();offBudgets();budgets.dispose();scheduleUsageRefresh=()=>undefined;}; + scheduleUsageRefresh(); + const orchestrationHandler=new ScopedOrchestrationHandler(agentControlService, pluginTools, providerDirectorySources,{budget:budgets,taskBoard,templates}); + forgetOrchestrationSession=id=>agentControlService.forgetSession(id); orchestrationGateway = new OrchestrationGateway({ runtimeDirectory: join(userDataPath, "orchestration", "runtime"), windowsHostPath: process.platform === "win32" @@ -693,11 +799,11 @@ async function initializeServices(ipc: IpcRegistrar): Promise { ? join(process.resourcesPath, "agent-browser", WINDOWS_PIPE_HOST_FILENAME) : join(app.getAppPath(), "build", "windows-agent-pipe-host", WINDOWS_PIPE_HOST_FILENAME) : undefined, - handler: new ScopedOrchestrationHandler(agentControlService, pluginTools, providerDirectorySources) + handler: orchestrationHandler }); await orchestrationGateway.start(); terminalManager.configureOrchestration(new OrchestrationBridge(orchestrationGateway)); - terminalManager.configureAgentTools((role, provider) => pluginTools.names(role, provider)); + terminalManager.configureAgentTools((role, provider) => [...(provider!=="terminal" ? ["list_tasks","claim_task","update_task","complete_task","get_task_budget"] : []),...pluginTools.names(role, provider)]); const launchPipeline = new LaunchPipeline({ contributors: () => pluginManager!.launchContributors(), @@ -717,6 +823,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // launch options or an environment ask their plugin's service, so start services first. pluginServices.hostReady(); await pluginServicesStarted.catch(() => undefined); + for(const budget of budgets.snapshots())if(budget.paused)applyBudgetEnforcement(budget); await terminalManager.restorePersistedSessions(); // The agent-control endpoint follows Settings → Agents → "Agent orchestration // endpoint"; the start flag / env var force it on for one launch (CI smoke) @@ -791,6 +898,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { }); await materialService.load(); registerMaterialIpc(ipc, { materials: materialService, getMainWindow: () => mainWindow }); + registerBacklogIpc(ipc,{board:taskBoard,budgets,flows:templates,taskRoot:id=>agentControlService.taskRoot(id),terminals:managedTerminals,getMainWindow:()=>mainWindow}); registerIpc(ipc, { settings, recheckProviderClis: async () => { @@ -1223,6 +1331,9 @@ app.on("child-process-gone", (_event, details) => { void IPC.terminalData; async function shutdownServices(): Promise { + disposeBudgetObservers?.();disposeBudgetObservers=null; + await Promise.allSettled([flushBudgets?.(), sessionTimeline?.flush()]); + diagnostics.record("info", "application", "shutdown.started"); agentChatHistory?.dispose(); if (agentControl) await Promise.allSettled([agentControl.close()]); diff --git a/src/main/ipc/registerBacklogIpc.ts b/src/main/ipc/registerBacklogIpc.ts new file mode 100644 index 00000000..75aa96c4 --- /dev/null +++ b/src/main/ipc/registerBacklogIpc.ts @@ -0,0 +1,85 @@ +import type { BrowserWindow, IpcMainInvokeEvent } from "electron"; +import { BACKLOG_IPC, BACKLOG_EVENTS } from "../../shared/backlog.ts"; +import type { IpcRegistrar } from "./IpcReadinessGate.ts"; +import type { TerminalManager } from "../services/TerminalManager.ts"; + + +import type { OrchestrationTaskBoard, NewOrchestrationTask } from "../services/OrchestrationTaskBoard.ts"; +import type { OrchestrationBudgetService, OrchestrationBudgetLimits } from "../services/OrchestrationBudgetService.ts"; +import type { OrchestrationTemplateService } from "../services/OrchestrationTemplateService.ts"; + +interface Dependencies { + board:OrchestrationTaskBoard; budgets:OrchestrationBudgetService; flows:OrchestrationTemplateService; + taskRoot(id:string):{id:string;cwd:string;startedAt:number}; + terminals: TerminalManager; + getMainWindow(): BrowserWindow | null; +} +export function registerBacklogIpc(ipc: IpcRegistrar, deps: Dependencies): void { + const {terminals} = deps; + deps.board.subscribe(change=>{ + const window=deps.getMainWindow(); + if(!window || window.isDestroyed() || window.webContents.isDestroyed())return; + window.webContents.send(BACKLOG_EVENTS.taskBoardChanged,{rootSessionId:change.rootSessionId,revision:change.revision}); + }); + const trust = (event: IpcMainInvokeEvent) => { + const window = deps.getMainWindow(); + if (!window || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error("Untrusted backlog caller."); + }; + const session = (id: unknown) => { + if (typeof id !== "string") throw new Error("Session id is required."); + const row = terminals.getMetadata(id); if (!row) throw new Error("Session no longer exists."); return row; + }; + const handle = (channel: string, fn: (...args: any[]) => unknown) => ipc.handle(channel, (event, ...args) => {trust(event);return fn(...args);}); + const root=(id:string)=>{session(id);return deps.taskRoot(id);}; + const masked=(value:unknown):unknown=>typeof value==="string" ? terminals.redactSecrets(value) : Array.isArray(value) ? value.map(masked) : value && typeof value==="object" ? Object.fromEntries(Object.entries(value).map(([key,row])=>[key,masked(row)])) : value; + handle(BACKLOG_IPC.tasks,(id:string)=>{const task=root(id);return deps.board.listTasks(task.cwd,task.id);}); + handle(BACKLOG_IPC.addTask,async(id:string,input:NewOrchestrationTask)=>{ + const task=root(id); + if(input?.ownerSessionId && root(input.ownerSessionId).id!==task.id)throw new Error("Task owner must belong to this orchestration tree."); + const row=await deps.board.addTask(task.cwd,task.id,task.id,masked(input) as NewOrchestrationTask); + return row; + }); + handle(BACKLOG_IPC.assignTask,async(id:string,taskId:string,ownerId:string|null)=>{ + const task=root(id);if(ownerId!==null && root(ownerId).id!==task.id)throw new Error("Task owner must belong to this orchestration tree."); + const row=await deps.board.assignTask(task.cwd,task.id,taskId,ownerId,ownerId ? terminals.redactSecrets(session(ownerId).title) : null); + return row; + }); + handle(BACKLOG_IPC.closeTask,async(id:string,taskId:string)=>{ + const task=root(id),row=await deps.board.closeTask(task.cwd,task.id,taskId);return row; + }); + handle(BACKLOG_IPC.budget,(id:string)=>{const task=root(id);return deps.budgets.snapshot(task.id,task.startedAt);}); + handle(BACKLOG_IPC.setBudget,async(id:string,limits:OrchestrationBudgetLimits)=>{ + const task=root(id),row=await deps.budgets.setLimits(task.id,limits,task.startedAt);return row; + }); + handle(BACKLOG_IPC.clearBudget,async(id:string)=>{ + const task=root(id),row=await deps.budgets.clearLimits(task.id,task.startedAt);return row; + }); + handle(BACKLOG_IPC.saveTaskFlow,async(id:string,name:string)=>{ + const parent=session(id);if(parent.role!=="orchestrator")throw new Error("Choose an orchestrator to save its layout as a flow."); + if(typeof name!=="string" || !name.trim() || name.length>120)throw new Error("Flow name must contain 1–120 characters."); + const children=terminals.listMetadata().filter(row=>row.parentSessionId===id); + if(!children.length || children.length>16)throw new Error("A flow needs 1–16 direct subagents."); + const file=await deps.flows.save(root(id).cwd,{ + id:`task-${Date.now()}`,name:terminals.redactSecrets(name.trim()),description:"Saved from an orchestrator's card layout.", + roles:children.map((child,index)=>({id:`worker-${index+1}`,title:terminals.redactSecrets(child.title),count:1, + instruction:terminals.redactSecrets(`Work on the subtask ${child.title}. Report your result to the orchestrator.`), + ...(child.model ? {model:child.model} : {}),...(child.effort ? {effort:child.effort} : {})})), + finalStep:"Gather every worker's result, resolve disagreements, and deliver one final answer." + }); + return {file}; + }); + handle(BACKLOG_IPC.flows,async(projectRoot:string)=>masked(await deps.flows.list(projectRoot))); + handle(BACKLOG_IPC.previewFlow,async(projectRoot:string,flowId:string)=>masked(await deps.flows.preview(projectRoot,flowId))); + handle(BACKLOG_IPC.approveFlow,async(projectRoot:string,flowId:string,digest:string)=>deps.flows.approve(projectRoot,flowId,digest)); + handle(BACKLOG_IPC.flowInstructions,async(projectRoot:string,flowId:string)=>{ + const listing=await deps.flows.list(projectRoot),flow=listing.templates.find(row=>row.id===flowId);if(!flow)throw new Error("Flow no longer exists."); + return terminals.redactSecrets(deps.flows.instructions(flow)); + }); + handle(BACKLOG_IPC.sendInstructions, async (id:string,text:unknown) => { + session(id);if(typeof text!=="string" || text.length>32_000)throw new Error("Invalid instructions."); + const result=await terminals.deliverInput(id,`${terminals.redactSecrets(text)}\r`);if(!result.delivered)throw new Error("Agent did not accept the instructions."); + }); + handle(BACKLOG_IPC.redactText, (text: unknown) => { + if (typeof text !== "string" || text.length > 1_000_000) throw new Error("Text is invalid or too large."); return terminals.redactSecrets(text); + }); +} diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index 3b35fa78..5eaec35f 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -5,6 +5,7 @@ import type { SessionMetadata, SessionSnapshot } from "../../shared/contracts.ts"; +import { GitCheckpoints } from "./GitCheckpoints.ts"; import { realpathSync } from "node:fs"; import { isAbsolute, resolve } from "node:path"; import { PROVIDER_CAPABILITIES } from "../../shared/contracts.ts"; @@ -15,6 +16,9 @@ import { onDiskPath, otherSpellings } from "./onDiskPath.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { terminalFailureDetails } from "./terminalFailureDetails.ts"; import { RESULT_CAPTURE_PROVIDERS } from "./resultCapture.ts"; +import { ACCOUNTS_PLUGIN_ID, selectedAccountId } from "./accountHomeIsolation.ts"; +import type { OrchestrationBudgetService } from "./OrchestrationBudgetService.ts"; +import { createDiffOnlyReviewWorkspace, type DiffOnlyReviewWorkspace } from "./DiffOnlyReviewWorkspace.ts"; // Cards one parent may have in total, live or exited (the live limit is the person's setting). const MAX_CHILDREN_PER_PARENT = 16; @@ -32,6 +36,13 @@ const MAX_OBSERVE_CHARS = 8_192; const MAX_EXIT_WINDOW_CHARS = 16_384; const MAX_EXIT_LINES = 20; const CHILD_POSITION_STEP = { x: 60, y: 60 }; +const MAX_RETRY_COUNT = 2; +const MAX_RETRY_OUTPUT_BYTES = 4 * 1024; +const MAX_RETRY_OUTPUT_WINDOW_CHARS = 16 * 1024; +const MAX_REVIEW_DIFF_BYTES = 64 * 1024; +const MAX_REVIEW_ANSWER_CHARS = 16 * 1024; +const REVIEW_TIMEOUT_MS = 180_000; +const REVIEW_STARTUP_QUIET_MS = 90_000; export interface SpawnAgentRequest { parentSessionId: string; @@ -46,6 +57,11 @@ export interface SpawnAgentRequest { /** The CLI's --model and reasoning effort for this subagent (checked by the launch for its CLI). */ model?: string; effort?: CreateSessionRequest["effort"]; + review?: boolean; + /** Optional known model reserved for this worker's read-only reviewer. */ + reviewModel?: string; + /** Internal-only role: suppresses all agent/plugin tools for a Plan-profile reviewer. */ + readOnlyReview?: boolean; } export interface AgentObservation { @@ -75,6 +91,8 @@ export interface AgentWaitResult { exitLines?: string; /** The final answer of the turn that ended (Codex, OpenCode subagents), masked. */ answer?: AgentAnswer; + /** Present when spawn_agent requested a read-only second-agent review. */ + review?: AgentReviewResult; } export interface AgentWaitTiming { @@ -93,6 +111,19 @@ export interface AgentControlOptions { limits?: () => DelegationLimits; /** CanvasTTY's isolation layer can contain an agent on this computer now (a "contained" auto needs it). */ containment?: () => boolean; + /** Persistent task budgets. Only usage from a real provider/timeline source is counted. */ + budget?: Pick; + /** Returns a known model different from the worker's model. Null means a safe alternative is unavailable. */ + reviewModel?: (provider: AgentProviderId, workerModel: string | undefined) => string | null; + /** Actual CLI model metadata/configuration, used when the worker kept its CLI default. */ + workerModel?: (session:SessionMetadata) => string | null | Promise; + reviewCost?: (reviewerSessionId:string) => number|null; + /** Optional environment-aware diff reader for worktree plugins. */ + reviewDiff?: (session: SessionMetadata) => Promise; + reviewTimeoutMs?: number; + /** How long an OpenCode reviewer may take to report its first status before the prompt is typed anyway. */ + reviewStartupMs?: number; + onReview?: (sessionId: string, review: AgentReviewResult) => void; } /** The longest wait one call may ask for (wait_for_agent's timeoutSeconds maximum). */ @@ -110,6 +141,19 @@ export interface AgentResult { exitLines?: string; /** The last turn's final answer as the agent reported it (Codex, OpenCode subagents), masked; absent otherwise. */ answer?: AgentAnswer; + /** Present when spawn_agent requested a read-only second-agent review. */ + review?: AgentReviewResult; +} + +export interface AgentReviewResult { + status: "pending" | "accepted" | "revise" | "rejected" | "unavailable"; + verdict?: "accept" | "revise" | "reject"; + notes?: string; + reason?: string; + reviewerSessionId?: string; + model?: string; + /** F-15 usage integrations can replace null when the provider reports a separate review cost. */ + costUsd: number | null; } export interface AgentAnswer { @@ -132,6 +176,18 @@ export class PromptNotDeliveredError extends Error { export class AgentControlService { private readonly terminals: TerminalManager; private readonly options: AgentControlOptions; + private readonly launchRequests = new Map(); + private readonly retryOrigins = new Map(); + private readonly retryCounts = new Map(); + private readonly pendingRetries = new Map(); + private readonly retryableQuiet = new Set(); + private readonly reviewRequested = new Set(); + private readonly readOnlyReviewers = new Set(); + private readonly reviews = new Map(); + private readonly reviewPending = new Map>(); + private readonly reviewControllers = new Map(); + private readonly reviewAgents = new Map(); + private readonly reviewWatchers = new Map }>(); constructor(terminals: TerminalManager, options: AgentControlOptions = {}) { this.terminals = terminals; @@ -151,34 +207,38 @@ export class AgentControlService { const capabilities = PROVIDER_CAPABILITIES[request.provider]; if (!capabilities) throw new Error("Unknown agent provider."); if (!capabilities.send) throw new Error(`${request.provider} cannot receive prompts.`); + const account = this.subagentAccount(parent.id, request.provider, request.launchOptions); + if (account.launchOptions !== request.launchOptions) request = { ...request, launchOptions: account.launchOptions }; - const children = this.children(parent.id); - if (children.length >= MAX_CHILDREN_PER_PARENT) { - throw new DelegationRefusal(`Session ${parent.id} already has ${MAX_CHILDREN_PER_PARENT} subagent cards; cancel_agent the finished ones first.`); - } - const lineage = this.lineage(parent.id); - const root = lineage.at(-1)!; + const taskScope = this.taskRoot(parent.id); // What the request asks for first (its folder, its profile), then the person's limits. - const cwd = subagentFolder(root.cwd, parent.cwd, request.cwd); + const cwd = subagentFolder(taskScope.cwd, parent.cwd, request.cwd); if ("error" in cwd) throw new DelegationRefusal(cwd.error); + this.requireBudgetActive(parent.id); const profile = subagentProfile(parent.profile, request.provider, request.profile, this.containment()); if ("error" in profile) throw new DelegationRefusal(profile.error); - const limits = this.limits(); - // The parent is at level lineage.length - 1 below its top-level agent; the new card one further down. - if (lineage.length > limits.maxDepth) { - throw new DelegationRefusal(`Subagents may nest at most ${limits.maxDepth} level${limits.maxDepth === 1 ? "" : "s"} deep below the agent the person started; this one would be level ${lineage.length}. The person sets this limit in Settings → Agents.`); - } - const live = this.descendants(root.id).filter((session) => session.exitCode === null).length; - if (live >= limits.maxSubagents) { - throw new DelegationRefusal(`This orchestration already runs ${live} live subagent${live === 1 ? "" : "s"}, its limit (Settings → Agents, set by the person). Wait for one to finish or cancel_agent one first.`); - } + this.assertSpawnCapacity(parent.id); + return this.createSubagent(parent, request, cwd.cwd, profile.profile, signal); + } + + private createSubagent( + parent: SessionMetadata, + request: SpawnAgentRequest, + cwd: string, + profile: LaunchProfile, + signal?: AbortSignal + ): Promise { // A call cancelled before its agent starts launches nothing. if (signal?.aborted) return Promise.reject(spawnCanceled()); - const cascade = children.length; + // Recheck immediately before synchronous card creation, which reserves the live slot. + this.requireSession(parent.id); + this.requireBudgetActive(parent.id); + const { childrenCount } = this.assertSpawnCapacity(parent.id); + const cascade = childrenCount; const created = this.terminals.create({ provider: request.provider, - cwd: cwd.cwd, - profile: profile.profile, + cwd, + profile, position: { x: parent.position.x + CHILD_POSITION_STEP.x * (cascade + 1), y: parent.position.y + CHILD_POSITION_STEP.y * (cascade + 1) @@ -190,9 +250,16 @@ export class AgentControlService { ...(request.model !== undefined ? { model: request.model } : {}), ...(request.effort !== undefined ? { effort: request.effort } : {}) }, { ...(RESULT_CAPTURE_PROVIDERS.has(request.provider) ? { captureResult: true } : {}), origin: "subagent" }); + this.launchRequests.set(created.id, { ...request, cwd, profile }); + this.retryOrigins.set(created.id, created.id); + if (request.review === true) this.reviewRequested.add(created.id); + if (request.readOnlyReview === true) this.readOnlyReviewers.add(created.id); if (request.initialPrompt === undefined || request.initialPrompt.length === 0) return Promise.resolve(created); return this.deliver(created.id, `${request.initialPrompt}\r`, "prompt", signal) - .then(() => this.terminals.getMetadata(created.id) ?? created, (error: unknown) => { + .then(() => { + this.scheduleReview(created.id); + return this.terminals.getMetadata(created.id) ?? created; + }, (error: unknown) => { // Cancelled while it started: nobody receives its id, so the card is closed instead of left running. if (!signal?.aborted) throw error; try { this.terminals.dispose(created.id); } catch { /* it already ended */ } @@ -200,6 +267,32 @@ export class AgentControlService { }); } + /** + * Which model account a subagent of this parent runs on. A spawn that names no account inherits the orchestrator's + * own: it never falls back silently to the CLI's default model (for OpenCode that is OpenCode Zen, a third party). + * Naming one is explicit: "none" runs on the CLI's own sign-in, another account needs the plugin's delegable + * declaration (checked by the launch). An orchestrator without an account keeps the request as it is. + */ + subagentAccount(parentSessionId: string, provider: AgentProviderId, requested: SpawnAgentRequest["launchOptions"]): { + launchOptions: SpawnAgentRequest["launchOptions"]; + account: string | null; + source: "inherited" | "explicit" | "none"; + } { + const parent = this.requireSession(parentSessionId); + const named = requested?.[ACCOUNTS_PLUGIN_ID]; + if (named !== undefined) { + const account = selectedAccountId(requested); + return { launchOptions: requested, account: account === "default" ? null : account, source: "explicit" }; + } + const parentAccount = typeof this.terminals.modelAccountOf === "function" ? this.terminals.modelAccountOf(parent.id) : undefined; + if (!parentAccount) return { launchOptions: requested, account: null, source: "none" }; + if (parent.provider !== provider) { + throw new DelegationRefusal(`This orchestrator runs on model account ${parentAccount.slice(0, 40)} for ${parent.provider}; a ${provider} subagent cannot inherit it. ` + + `Pass launchOptions {"${ACCOUNTS_PLUGIN_ID}":{"account":""}} with a ${provider} account (list_accounts / pick_account), or {"account":"none"} to run it on ${provider}'s own sign-in and default model.`); + } + return { launchOptions: { ...(requested ?? {}), [ACCOUNTS_PLUGIN_ID]: { account: parentAccount } }, account: parentAccount, source: "inherited" }; + } + /** The profile a subagent of this parent gets for this request (what spawn will use), or why it gets none. */ profileFor(parentSessionId: string, provider: AgentProviderId, requested?: unknown): { profile: LaunchProfile; inherited: boolean } | { error: string } { return subagentProfile(this.requireSession(parentSessionId).profile, provider, requested, this.containment()); @@ -213,7 +306,12 @@ export class AgentControlService { if (!capabilities.send) throw new Error(`${session.provider} cannot receive prompts.`); if (typeof text !== "string" || text.length === 0) throw new Error("Prompt text is required."); if (session.exitCode !== null) throw new Error("Agent session has already exited."); - return this.deliver(sessionId, submit ? `${text}\r` : text, "text", signal); + this.assertInputAllowed(sessionId); + this.reviews.delete(sessionId); + this.retryableQuiet.delete(sessionId); + return this.deliver(sessionId, submit ? `${text}\r` : text, "text", signal).then(() => { + this.scheduleReview(sessionId); + }); } status(sessionId: string): SessionMetadata { @@ -239,6 +337,68 @@ export class AgentControlService { return chain; } + /** The top-level orchestrator and project folder governing this session's shared task scope. */ + taskRoot(sessionId: string): { id: string; cwd: string; startedAt: number } { + const root = this.lineage(sessionId).at(-1)!; + const taskScope = root.taskScope; + return taskScope ? { ...taskScope } : { id: root.id, cwd: root.cwd, startedAt: root.startedAt }; + } + + /** Resolve every card against one host snapshot, sharing parent traversal and preserving continuation scopes. */ + taskRoots(sessions: readonly SessionMetadata[]): Map { + const byId = new Map(sessions.map((session) => [session.id, session])); + const resolved = new Map(); + const visiting = new Set(); + const resolveTaskRoot = (sessionId: string): { id: string; cwd: string; startedAt: number } | null => { + const cached = resolved.get(sessionId); + if (cached) return cached; + const session = byId.get(sessionId); + if (!session || visiting.has(sessionId)) return null; + visiting.add(sessionId); + const scope = (session.parentSessionId ? resolveTaskRoot(session.parentSessionId) : null) + ?? session.taskScope + ?? { id: session.id, cwd: session.cwd, startedAt: session.startedAt }; + visiting.delete(sessionId); + resolved.set(sessionId, scope); + return scope; + }; + for (const session of sessions) resolveTaskRoot(session.id); + return resolved; + } + + /** Called when TerminalManager removes a card; retries belonging to a live replacement retain their shared count. */ + forgetSession(sessionId: string): void { + const activeReviewerId = this.reviewAgents.get(sessionId); + this.launchRequests.delete(sessionId); + this.retryableQuiet.delete(sessionId); + this.reviewRequested.delete(sessionId); + this.readOnlyReviewers.delete(sessionId); + this.reviews.delete(sessionId); + + const sourceId = this.retryOrigins.get(sessionId) ?? sessionId; + this.retryOrigins.delete(sessionId); + if (!this.pendingRetries.has(sourceId) && ![...this.retryOrigins.values()].includes(sourceId)) this.retryCounts.delete(sourceId); + + this.reviewWatchers.get(sessionId)?.controller.abort(); + this.reviewWatchers.delete(sessionId); + this.reviewControllers.get(sessionId)?.abort(); + this.reviewControllers.delete(sessionId); + this.reviewPending.delete(sessionId); + this.reviewAgents.delete(sessionId); + for (const [workerId, workerReviewerId] of this.reviewAgents) { + if (workerReviewerId !== sessionId) continue; + this.reviewAgents.delete(workerId); + } + if (activeReviewerId) { + try { this.terminals.dispose(activeReviewerId); } catch { /* it has already ended */ } + } + } + + /** Apply the terminal manager's existing secret masks to host-only task context before it leaves the core. */ + maskText(text: string, maxChars: number): string { + return this.redactTail(text, Math.max(0, Math.min(65_536, maxChars))); + } + /** Every card below this one, at any depth. */ descendants(sessionId: string): SessionMetadata[] { const all = this.terminals.listMetadata(); @@ -265,6 +425,24 @@ export class AgentControlService { return DEFAULT_DELEGATION_LIMITS; } + private assertSpawnCapacity(parentSessionId: string, replacingSessionId?: string): { live: number; childrenCount: number } { + const childrenCount = this.children(parentSessionId).filter(session => session.id !== replacingSessionId).length; + if (childrenCount >= MAX_CHILDREN_PER_PARENT) { + throw new DelegationRefusal(`Session ${parentSessionId} already has ${MAX_CHILDREN_PER_PARENT} subagent cards; cancel_agent the finished ones first.`); + } + const lineage = this.lineage(parentSessionId); + const limits = this.limits(); + if (lineage.length > limits.maxDepth) { + throw new DelegationRefusal(`Subagents may nest at most ${limits.maxDepth} level${limits.maxDepth === 1 ? "" : "s"} deep below the agent the person started; this one would be level ${lineage.length}. The person sets this limit in Settings → Agents.`); + } + const live = this.descendants(lineage.at(-1)!.id) + .filter(session => session.id !== replacingSessionId && session.exitCode === null).length; + if (live >= limits.maxSubagents) { + throw new DelegationRefusal(`This orchestration already runs ${live} live subagent${live === 1 ? "" : "s"}, its limit (Settings → Agents, set by the person). Wait for one to finish or cancel_agent one first.`); + } + return { live, childrenCount }; + } + private containment(): boolean { try { return this.options.containment?.() === true; } catch { return false; } } @@ -326,28 +504,124 @@ export class AgentControlService { }; } + /** Gets a result and, when requested, completes its second-agent review before returning it. */ + async resultWithReview(sessionId: string): Promise { + const current = this.result(sessionId); + if (!this.reviewRequested.has(sessionId)) return current; + if (current.status !== "idle" && current.status !== "done" && current.status !== "failed" && current.exitCode === null) { + return { ...current, review: this.reviewWithCost(this.reviews.get(sessionId) ?? { status: "pending", costUsd: null }) }; + } + const review = await this.ensureReview(sessionId); + return { ...this.result(sessionId), review:this.reviewWithCost(review) }; + } + + private reviewWithCost(review:AgentReviewResult):AgentReviewResult{ + try{ + const cost=review.reviewerSessionId ? this.options.reviewCost?.(review.reviewerSessionId) : null; + return typeof cost==="number" && Number.isFinite(cost) && cost>=0 ? {...review,costUsd:cost} : review; + }catch{return review;} + } + + /** Retry is restricted to a failed or observed quiet agent. */ + async retry(sessionId: string, reason?: string, signal?: AbortSignal): Promise { + const session = this.requireSession(sessionId); + if (session.provider === "terminal") throw new Error("Plain terminals are not agents."); + const capabilities = PROVIDER_CAPABILITIES[session.provider as AgentProviderId]; + if (!capabilities?.send) throw new Error(`${session.provider} cannot receive prompts.`); + const failed = (session.exitCode !== null && session.exitCode !== 0) || session.status === "failed"; + if (!failed && !this.retryableQuiet.has(sessionId)) { + throw new DelegationRefusal("retry_agent works only for a failed or quiet subagent; a running agent must finish or be canceled first."); + } + const sourceId = this.retryOrigins.get(sessionId) ?? sessionId; + const count = this.retryCounts.get(sourceId) ?? 0; + if (count >= MAX_RETRY_COUNT) throw new DelegationRefusal(`This agent has already used its limit of ${MAX_RETRY_COUNT} retries.`); + const original = this.launchRequests.get(sessionId) ?? this.launchRequests.get(sourceId); + if (!original) throw new DelegationRefusal("CanvasTTY no longer has the original launch request for this agent, so it cannot retry it safely."); + const raw = this.terminals.readBuffer(sessionId).buffer; + const output = utf8Tail(this.redactTail(raw, MAX_RETRY_OUTPUT_WINDOW_CHARS), MAX_RETRY_OUTPUT_BYTES); + const failureReason = (typeof reason === "string" && reason.trim().slice(0, 500)) + || session.failureDetails + || (session.exitCode !== null ? `process exited with code ${session.exitCode}` : "the agent stopped producing output"); + const context = [ + "\n\nCanvasTTY retry context:", + `Failure reason: ${this.redactTail(failureReason, 500)}`, + output ? `Masked output tail (up to ${MAX_RETRY_OUTPUT_BYTES} UTF-8 bytes):\n${output}` : "No terminal output was available." + ].join("\n"); + const retryPrompt = `${original.initialPrompt ?? ""}${context}`; + // Reserve synchronously: concurrent tool calls must share the same finite allowance. + this.retryCounts.set(sourceId, count + 1); + this.pendingRetries.set(sourceId, (this.pendingRetries.get(sourceId) ?? 0) + 1); + try { + if (signal?.aborted) throw spawnCanceled(); + const retried = await this.spawn({ + ...original, + parentSessionId: session.parentSessionId ?? original.parentSessionId, + initialPrompt: retryPrompt, + title: `${session.title} (retry ${count + 1})`.slice(0, 80) + }, signal); + this.retryOrigins.set(retried.id, sourceId); + // Each attempt starts from the original request, with only this attempt's masked failure context appended. + this.launchRequests.set(retried.id, original); + if (original.review === true) this.reviewRequested.add(retried.id); + return retried; + } catch (error) { + this.retryCounts.set(sourceId, Math.max(0, (this.retryCounts.get(sourceId) ?? 1) - 1)); + throw error; + } finally { + const pending = (this.pendingRetries.get(sourceId) ?? 1) - 1; + if (pending > 0) this.pendingRetries.set(sourceId, pending); + else { + this.pendingRetries.delete(sourceId); + if (![...this.retryOrigins.values()].includes(sourceId)) this.retryCounts.delete(sourceId); + } + } + } + + taskBudget(sessionId: string): ReturnType | null { + if (!this.options.budget) return null; + const root = this.taskRoot(sessionId); + return this.options.budget.snapshot(root.id, root.startedAt); + } + + isReadOnlyReviewer(sessionId: string): boolean { + return this.readOnlyReviewers.has(sessionId); + } + + /** App-level PTY input gate. A hard task pause blocks input without signaling or killing any process. */ + assertInputAllowed(sessionId: string): void { + this.requireSession(sessionId); + this.requireBudgetActive(sessionId); + } + /** * Waits until the agent is at rest (idle, needs_approval, exited, or quiet when it reports no status), its card * closed, or the timeout passed. Only reads metadata and the output offset while it waits; the tail is read and * masked once, as it returns. Rejects with an AbortError once `signal` aborts. */ - async waitFor(sessionId: string, request: { timeoutMs: number; signal?: AbortSignal }): Promise { + async waitFor(sessionId: string, request: { timeoutMs: number; signal?: AbortSignal; quietMs?: number }): Promise { const { signal } = request; signal?.throwIfAborted(); const first = this.requireSession(sessionId); if (first.provider === "terminal") throw new Error("Plain terminals are not agents."); if (typeof request.timeoutMs !== "number" || !Number.isFinite(request.timeoutMs)) throw new Error("A wait timeout is required."); - const timing = this.options.waitTiming ?? AGENT_WAIT_TIMING; + const base = this.options.waitTiming ?? AGENT_WAIT_TIMING; + const timing = request.quietMs !== undefined ? { ...base, quietMs: Math.max(base.quietMs, request.quietMs) } : base; const timeoutMs = Math.min(MAX_AGENT_WAIT_MS, Math.max(0, request.timeoutMs)); const started = Date.now(); - const answer = (session: SessionMetadata | null, reason: AgentWaitReason): AgentWaitResult => { + const answer = async (session: SessionMetadata | null, reason: AgentWaitReason): Promise => { const waitedMs = Date.now() - started; if (!session) return { sessionId, reason, exitCode: null, waitedMs, output: "" }; + if (reason === "quiet") this.retryableQuiet.add(sessionId); + else if (reason === "idle" || reason === "done" || reason === "failed") this.retryableQuiet.delete(sessionId); let observation: AgentObservation | null = null; try { observation = this.observe(sessionId); } catch { observation = null; } - const answer = reason === "timeout" || reason === "needs_approval" ? null : this.answer(sessionId); + const finalAnswer: AgentAnswer | null = reason === "timeout" || reason === "needs_approval" ? null : this.answer(sessionId); + let review: AgentReviewResult | undefined; + if (this.reviewRequested.has(sessionId) && (reason === "idle" || reason === "done" || reason === "failed" || reason === "quiet")) { + review = this.reviewWithCost(await this.ensureReview(sessionId)); + } return { sessionId, reason, status: session.status, exitCode: session.exitCode, waitedMs, output: observation?.output ?? "", - ...(observation?.exitLines ? { exitLines: observation.exitLines } : {}), ...(answer ? { answer } : {}) }; + ...(observation?.exitLines ? { exitLines: observation.exitLines } : {}), ...(finalAnswer ? { answer: finalAnswer } : {}), ...(review ? { review } : {}) }; }; let offset = this.outputOffset(sessionId); let changedAt = started; @@ -380,6 +654,195 @@ export class AgentControlService { this.terminals.dispose(sessionId); } + private async ensureReview(sessionId: string): Promise { + const cached = this.reviews.get(sessionId); + if (cached) return cached; + const active = this.reviewPending.get(sessionId); + if (active) return active; + const controller = new AbortController(); + this.reviewControllers.set(sessionId, controller); + const pending = this.performReview(sessionId, controller.signal).catch((error: unknown): AgentReviewResult => ({ + status: "unavailable", + reason: this.redactTail(error instanceof Error ? error.message : "The reviewer failed.", 500), + costUsd: null + })); + this.reviewPending.set(sessionId, pending); + try { + const result = await pending; + if (controller.signal.aborted) return result; + this.reviews.set(sessionId, result); + try { this.options.onReview?.(sessionId, result); } catch { /* review observers cannot affect the result */ } + return result; + } finally { + if (this.reviewPending.get(sessionId) === pending) this.reviewPending.delete(sessionId); + if (this.reviewControllers.get(sessionId) === controller) this.reviewControllers.delete(sessionId); + } + } + + private async performReview(sessionId: string, signal: AbortSignal): Promise { + const worker = this.requireSession(sessionId); + const request = this.launchRequests.get(sessionId); + if (!request) return { status: "unavailable", reason: "CanvasTTY no longer has the worker launch details.", costUsd: null }; + // A worker on a model account (Accounts plugin) is reviewed on that same account unless a review model was named: + // the agent's own sign-in may not exist, and only the account carries the key for this model. + const account = request.reviewModel ? null : selectedModelAccount(request.launchOptions); + if (!account && !request.reviewModel && !this.options.reviewModel) return { status: "unavailable", reason: "No different known reviewer model is available.", costUsd: null }; + let workerModel=worker.model ?? request.model; + if(!workerModel)try{workerModel=await this.options.workerModel?.(worker) ?? undefined;}catch{ /* Unknown remains unknown. */ } + if(signal.aborted)return {status:"unavailable",reason:"The worker session was removed before review.",costUsd:null}; + let model: string | null; + try { model = account ? `account:${account}` : request.reviewModel ?? this.options.reviewModel?.(worker.provider as AgentProviderId, workerModel) ?? null; } + catch { model = null; } + if (!model || (!account && workerModel && model === workerModel)) { + return { status: "unavailable", reason: "No known model different from the worker's model is available.", costUsd: null }; + } + const profile = this.profileFor(this.lineage(sessionId).at(-1)!.id, worker.provider as AgentProviderId, "plan"); + if ("error" in profile) return { status: "unavailable", reason: `A read-only Plan reviewer is unavailable: ${profile.error}`, costUsd: null }; + let diff: string; + try { + diff = this.options.reviewDiff ? await this.options.reviewDiff(worker) : await this.readReviewDiff(worker.cwd); + } catch (error) { + return { status: "unavailable", reason: `The worker diff could not be read: ${error instanceof Error ? error.message : "unknown error"}`, costUsd: null }; + } + if (signal.aborted) return { status: "unavailable", reason: "The worker session was removed before review.", costUsd: null }; + const maskedDiff = utf8Tail(this.redactTail(diff, MAX_REVIEW_DIFF_BYTES + 8_192), MAX_REVIEW_DIFF_BYTES); + const answer = this.answer(sessionId); + const maskedAnswer = answer ? this.redactTail(answer.text, MAX_REVIEW_ANSWER_CHARS) : "No final answer was available from this provider."; + const root = this.lineage(sessionId).at(-1)!; + const prompt = [ + "Review only the supplied answer and diff. The temporary review workspace also contains the same patch as review.diff. Do not inspect or modify project files; this is a read-only review.", + "Return one JSON object with exactly these fields: verdict (accept, revise, or reject) and findings (short actionable notes).", + "Use accept when no material issue is visible, revise when the author can address concrete issues, and reject when the result does not satisfy the request.", + "Worker answer:", maskedAnswer, + "Diff:", maskedDiff || "(No reviewable diff was found.)" + ].join("\n\n"); + let reviewer: SessionMetadata | undefined; + let keepReviewer = false; + let workspace: DiffOnlyReviewWorkspace; + try { + workspace = createDiffOnlyReviewWorkspace(maskedDiff); + } catch (error) { + return { status: "unavailable", reason: `The diff-only review workspace could not be prepared: ${error instanceof Error ? error.message : "unknown error"}`, costUsd: null, model }; + } + try { + try { + const reviewerAccount = account + ? await this.terminals.prepareReviewerAccount({ taskRootSessionId: root.id, provider: worker.provider as AgentProviderId, workspace, + launchOptions: { [MODEL_ACCOUNTS_PLUGIN_ID]: { account } } }) + : undefined; + if (signal.aborted) { void reviewerAccount?.contribution.cleanup().catch(() => undefined); throw new Error("The worker session was removed before review."); } + reviewer = this.terminals.createReadOnlyReviewer({ + taskRootSessionId: root.id, + provider: worker.provider as AgentProviderId, + workspace, + title: `Review: ${worker.title}`.slice(0, 80), + ...(reviewerAccount ? { account: reviewerAccount } : { model }) + }); + this.readOnlyReviewers.add(reviewer.id); + // OpenCode drops what is typed before its TUI is up: wait for its first lifecycle status (bounded). + if (worker.provider === "opencode") { + const readyBy = Date.now() + (this.options.reviewStartupMs ?? REVIEW_STARTUP_QUIET_MS); + while (Date.now() < readyBy && this.terminals.getMetadata(reviewer.id)?.status === "unavailable") await pause(250, signal); + } + await this.deliver(reviewer.id, `${prompt}\r`, "prompt", signal); + } catch (error) { + return { status: "unavailable", reason: `The read-only reviewer could not start: ${error instanceof Error ? error.message : "unknown error"}`, costUsd: null, model }; + } + const reviewerSession = reviewer!; + this.reviewAgents.set(sessionId, reviewerSession.id); + if (signal.aborted) { + return { status: "unavailable", reason: "The worker session was removed before review.", reviewerSessionId: reviewerSession.id, model, costUsd: null }; + } + const waited = await this.waitFor(reviewerSession.id, { + timeoutMs: this.options.reviewTimeoutMs ?? REVIEW_TIMEOUT_MS, + // A reviewer starts its CLI in a fresh, empty home (OpenCode may fetch its provider package first) and reports + // no turn until then: ten silent seconds are not yet "stopped responding". + quietMs: REVIEW_STARTUP_QUIET_MS, + signal + }).catch((error: unknown) => ({ + sessionId: reviewerSession.id, reason: "failed" as const, exitCode: 1, waitedMs: 0, output: "", + exitLines: error instanceof Error ? this.redactTail(error.message, 500) : "review failed" + })); + if (signal.aborted) { + return { status: "unavailable", reason: "The worker session was removed during review.", reviewerSessionId: reviewerSession.id, model, costUsd: null }; + } + if (waited.reason === "timeout" || waited.reason === "quiet" || waited.reason === "failed" || !waited.answer) { + return { + status: "unavailable", + reason: waited.reason === "timeout" ? "The reviewer timed out." : waited.reason === "quiet" ? "The reviewer stopped responding without a readable verdict." : waited.exitLines ? `The reviewer failed before returning a verdict: ${this.redactTail(waited.exitLines, 500)}` : "The reviewer failed before returning a verdict.", + reviewerSessionId: reviewerSession.id, + model, + costUsd: null + }; + } + const parsed = parseReview(waited.answer.text); + if (!parsed) { + return { status: "unavailable", reason: "The reviewer did not return a valid accept, revise, or reject verdict.", reviewerSessionId: reviewerSession.id, model, costUsd: null }; + } + keepReviewer = true; + return { + status: parsed.verdict === "accept" ? "accepted" : parsed.verdict === "revise" ? "revise" : "rejected", + verdict: parsed.verdict, + notes: [account ? "Reviewed on the worker's model account (the same model; name reviewModel for another)." : "", parsed.findings ? this.redactTail(parsed.findings, 8_000) : ""].filter(Boolean).join("\n") || undefined, + reviewerSessionId: reviewerSession.id, + model, + costUsd: null + }; + } finally { + if (reviewer && this.reviewAgents.get(sessionId) === reviewer.id) this.reviewAgents.delete(sessionId); + if (!keepReviewer) { + if (reviewer) try { this.terminals.dispose(reviewer.id); } catch { /* the task may already have removed it */ } + workspace.cleanup(); + if (reviewer) this.readOnlyReviewers.delete(reviewer.id); + } + } + } + + private async readReviewDiff(cwd: string): Promise { + return new GitCheckpoints(text => this.redactTail(text,MAX_REVIEW_DIFF_BYTES + 8192)).workingDiff(cwd); + } + + private requireBudgetActive(sessionId: string): void { + if (!this.options.budget) return; + let snapshot: ReturnType; + const root = this.taskRoot(sessionId); + try { snapshot = this.options.budget.snapshot(root.id, root.startedAt); } + catch (error) { throw new DelegationRefusal(`Task budget state is unavailable; new subagent activity is paused. ${error instanceof Error ? error.message : ""}`); } + if (snapshot.paused) throw new DelegationRefusal(snapshot.reason ?? "This task's budget is exhausted. Increase or clear it in CanvasTTY before continuing."); + } + + private scheduleReview(sessionId: string): void { + if (!this.reviewRequested.has(sessionId) || this.reviewWatchers.has(sessionId)) return; + const controller = new AbortController(); + let watcher: { controller: AbortController; promise: Promise }; + const promise = this.monitorReview(sessionId, controller.signal).catch(() => undefined).finally(() => { + if (this.reviewWatchers.get(sessionId) === watcher) this.reviewWatchers.delete(sessionId); + }); + watcher = { controller, promise }; + this.reviewWatchers.set(sessionId, watcher); + } + + /** Waits for the worker's next completed turn and starts its review without requiring a result poll. */ + private async monitorReview(sessionId: string, signal: AbortSignal): Promise { + for (;;) { + if (signal.aborted) return; + const session = this.terminals.getMetadata(sessionId); + if (!session || !this.reviewRequested.has(sessionId)) return; + if (session.exitCode !== null) { + await this.ensureReview(sessionId); + return; + } + const result = await this.waitFor(sessionId, { timeoutMs: MAX_AGENT_WAIT_MS, signal }); + if (result.reason === "idle" || result.reason === "done" || result.reason === "failed" || result.reason === "quiet") { + await this.ensureReview(sessionId); + return; + } + if (result.reason === "closed") return; + // Approval and quiet states need a later human input or another output sample; avoid spinning on either. + await pause(1_000); + } + } + /** Through the terminal manager's one delivery rule: exactly once, into the launch that is starting now. */ private async deliver(sessionId: string, data: string, what: "prompt" | "text", signal?: AbortSignal): Promise { const delivery = await this.terminals.deliverInput(sessionId, data, undefined, signal); @@ -518,6 +981,34 @@ function tail(text: string, maxChars: number): string { return text.slice(text.length - maxChars); } +function utf8Tail(text: string, maxBytes: number): string { + const bytes = Buffer.from(text, "utf8"); + if (bytes.byteLength <= maxBytes) return text; + let value = bytes.subarray(bytes.byteLength - maxBytes).toString("utf8"); + // A UTF-8 byte window can begin in the middle of a code point; drop that partial character. + if (value.startsWith("\uFFFD")) value = value.slice(1); + return value; +} + +function parseReview(text: string): { verdict: "accept" | "revise" | "reject"; findings: string } | null { + const match = text.match(/\{[\s\S]*\}/u); + if (!match) return null; + let value: unknown; + try { value = JSON.parse(match[0]); } catch { return null; } + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if (record.verdict !== "accept" && record.verdict !== "revise" && record.verdict !== "reject") return null; + const findings = typeof record.findings === "string" ? record.findings : typeof record.notes === "string" ? record.notes : ""; + return { verdict: record.verdict, findings: findings.slice(0, 8_000) }; +} + function spawnCanceled(): Error { return new DOMException("The spawn was canceled before its agent started.", "AbortError"); } + +const MODEL_ACCOUNTS_PLUGIN_ID = "canvastty-accounts"; +/** The model account a worker was launched on through the Accounts plugin, if any. */ +function selectedModelAccount(launchOptions: SpawnAgentRequest["launchOptions"]): string | null { + const account = launchOptions?.[MODEL_ACCOUNTS_PLUGIN_ID]?.account; + return typeof account === "string" && account && account !== "none" ? account : null; +} diff --git a/src/main/services/DiffOnlyReviewWorkspace.ts b/src/main/services/DiffOnlyReviewWorkspace.ts new file mode 100644 index 00000000..99ac3521 --- /dev/null +++ b/src/main/services/DiffOnlyReviewWorkspace.ts @@ -0,0 +1,66 @@ +import { chmodSync, mkdtempSync, realpathSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const MAX_REVIEW_DIFF_BYTES = 64 * 1024; +const ownedWorkspaces = new WeakSet(); + +/** + * POSIX keeps the exact owner-only modes. Windows has no owner/group/other bits: chmod only toggles the read-only + * attribute, which stat reports as the absence of every write bit, so only that part of the mode is checked there. + */ +function hasReviewMode(mode: number, expected: number, platform: NodeJS.Platform): boolean { + return platform === "win32" ? (mode & 0o222) === 0 : (mode & 0o777) === expected; +} + +/** A private, host-created directory whose only source file is the host-reviewed patch. */ +export interface DiffOnlyReviewWorkspace { + readonly directory: string; + readonly diffPath: string; + cleanup(): void; +} + +export function createDiffOnlyReviewWorkspace(diff: string): DiffOnlyReviewWorkspace { + if (typeof diff !== "string" || Buffer.byteLength(diff, "utf8") > MAX_REVIEW_DIFF_BYTES) { + throw new Error("The reviewed diff exceeds the temporary review workspace limit."); + } + const tempRoot = realpathSync(tmpdir()); + const directory = realpathSync(mkdtempSync(join(tempRoot, "canvastty-review-"))); + chmodSync(directory, 0o700); + const diffPath = join(directory, "review.diff"); + try { + writeFileSync(diffPath, diff, { encoding: "utf8", flag: "wx", mode: 0o400 }); + chmodSync(diffPath, 0o400); + chmodSync(directory, 0o500); + } catch (error) { + chmodSync(directory, 0o700); + rmSync(directory, { recursive: true, force: true }); + throw error; + } + const workspace: DiffOnlyReviewWorkspace = Object.freeze({ + directory, + diffPath, + cleanup() { + try { chmodSync(directory, 0o700); } catch { /* already removed */ } + rmSync(directory, { recursive: true, force: true }); + ownedWorkspaces.delete(workspace); + } + }); + ownedWorkspaces.add(workspace); + return workspace; +} + +/** TerminalManager accepts only the exact object created here, not paths forged through session metadata. */ +export function isHostOwnedDiffOnlyReviewWorkspace(value: unknown, platform: NodeJS.Platform = process.platform): value is DiffOnlyReviewWorkspace { + if (!value || typeof value !== "object" || !ownedWorkspaces.has(value)) return false; + const workspace = value as DiffOnlyReviewWorkspace; + try { + if (realpathSync(workspace.directory) !== workspace.directory || realpathSync(workspace.diffPath) !== workspace.diffPath + || workspace.diffPath !== join(workspace.directory, "review.diff")) return false; + const directory = statSync(workspace.directory); + const diff = statSync(workspace.diffPath); + return directory.isDirectory() && hasReviewMode(directory.mode, 0o500, platform) + && diff.isFile() && hasReviewMode(diff.mode, 0o400, platform) + && readdirSync(workspace.directory).length === 1 && readdirSync(workspace.directory)[0] === "review.diff"; + } catch { return false; } +} diff --git a/src/main/services/GitCheckpoints.ts b/src/main/services/GitCheckpoints.ts new file mode 100644 index 00000000..9d73a39d --- /dev/null +++ b/src/main/services/GitCheckpoints.ts @@ -0,0 +1,71 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { realpath, lstat, open, mkdir, mkdtemp, readdir, readFile, writeFile, rename, rm, type FileHandle } from "node:fs/promises"; +import { constants, createReadStream } from "node:fs"; +import { resolve, relative, isAbsolute, sep, dirname, join } from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +const exec = promisify(execFile); + +/** Bounded, redacted working diff supplied to an automatic reviewer. */ +export class GitCheckpoints { + private readonly redact:(text:string)=>string; + constructor(redact: (text:string)=>string) {this.redact=redact;} + private async git(cwd: string, args: string[]): Promise { + const safe = ["--no-replace-objects", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "diff.external="]; + // Snapshotting runs outside the agent process; never execute repository-provided clean filters. + const configured = await exec("git", [...safe, "-C", cwd, "config", "--get-regexp", "^filter\\..*\\.(clean|smudge|process|required)$"], {timeout: 5000}).catch((error: unknown) => { + if ((error as {code?: unknown}).code === 1) return {stdout: ""}; throw error; + }); + for (const line of configured.stdout.split("\n")) { + const key = line.split(/\s/, 1)[0]; + if (/^filter\..*\.(clean|smudge|process|required)$/.test(key)) safe.push("-c", `${key}=${key.endsWith(".required") ? "false" : ""}`); + } + return (await exec("git", [...safe, "-C", cwd, ...args], {timeout: 15_000, maxBuffer: 2 * 1024 * 1024})).stdout.trimEnd(); + } + async available(cwd: string): Promise { + try { return await realpath(await this.git(cwd, ["rev-parse", "--show-toplevel"])) === await realpath(cwd); } + catch { return false; } + } + async workingDiff(cwd:string):Promise { + const parts = [await this.git(cwd,["diff","--no-ext-diff","--no-textconv","--unified=3","HEAD","--"])]; + const root = await realpath(cwd); + const paths = (await this.git(cwd,["ls-files","--others","--exclude-standard","-z"])).split("\0").filter(Boolean); + let bytes = Buffer.byteLength(parts[0]), omitted = 0; + for (const path of paths) { + if (bytes >= 512 * 1024 || /[\u0000-\u001F\u007F]/u.test(path)) { omitted++; continue; } + let file; + try { + const full = resolve(root,path), target = await realpath(full), within = relative(root,target); + const metadata = await lstat(full); + if (isAbsolute(within) || within === ".." || within.startsWith(`..${sep}`) || !metadata.isFile() || metadata.size > 32 * 1024) { omitted++; continue; } + file = await open(full, constants.O_RDONLY | constants.O_NOFOLLOW); + const opened = await file.stat(); + if (!opened.isFile() || opened.ino !== metadata.ino || opened.dev !== metadata.dev || opened.size > 32 * 1024) { omitted++; continue; } + const raw = await readBoundedUntrackedFile(file); + const after = await file.stat(); + if (raw.includes(0) || raw.length > 32 * 1024 || after.size !== opened.size + || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) { omitted++; continue; } + const text = new TextDecoder("utf8", {fatal:true}).decode(raw), lines = text.endsWith("\n") ? text.slice(0,-1).split("\n") : text.split("\n"); + if (!text) lines.length = 0; + const quoted = JSON.stringify(path); + const hunk = `diff --git ${JSON.stringify(`a/${path}`)} ${JSON.stringify(`b/${path}`)}\nnew file mode ${opened.mode & 0o111 ? "100755" : "100644"}\n--- /dev/null\n+++ ${quoted}\n@@ -0,0 +${lines.length ? 1 : 0},${lines.length} @@\n${lines.map(line=>`+${line}`).join("\n")}\n${text && !text.endsWith("\n") ? "\\ No newline at end of file\n" : ""}`; + parts.push(hunk); bytes += Buffer.byteLength(hunk); + } catch { omitted++; } + finally { await file?.close(); } + } + if (omitted) parts.push(`[${omitted} untracked file(s) omitted: binary, oversized, unsafe, or unavailable.]`); + // Mask the complete collected text before taking the bounded tail consumed by the reviewer. + return this.redact(parts.join("\n")).slice(-512 * 1024); + } +} +/** A file can grow after stat; stop reading at the cap before allocating an oversized review input. */ +async function readBoundedUntrackedFile(file: FileHandle): Promise { + const buffer = Buffer.alloc(32 * 1024 + 1); + let length = 0; + while (length < buffer.length) { + const { bytesRead } = await file.read(buffer, length, buffer.length - length, length); + if (bytesRead === 0) break; + length += bytesRead; + } + return buffer.subarray(0, length); +} diff --git a/src/main/services/LaunchPipeline.ts b/src/main/services/LaunchPipeline.ts index 439370a5..d9608e3e 100644 --- a/src/main/services/LaunchPipeline.ts +++ b/src/main/services/LaunchPipeline.ts @@ -13,6 +13,7 @@ import { claudeCoreSettingsKey, coreOwnedLaunchArgument, parseInlineSettings } f import { MAX_PLUGIN_SLOT_BYTES } from "./TerminalSessionStore.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { MAX_INSPECTED_CONFIG_BYTES, parseJsonc, readInspectedFile } from "./inspectedConfig.ts"; +import { selectedAccountHome, selectedAccountId, ACCOUNTS_PLUGIN_ID } from "./accountHomeIsolation.ts"; /** A trusted plugin service that declared launch options (PluginManager.launchContributors). */ export interface LaunchContributor { @@ -20,6 +21,8 @@ export interface LaunchContributor { pluginName: string; serviceId: string; launch: PluginServiceLaunch; + /** Host-computed private data directory. Never sent to the plugin or accepted from its RPC response. */ + dataDir?: string; /** The plugin holds the `secrets` permission, so `secretEnv` may name its secrets. */ secrets: boolean; } @@ -41,6 +44,8 @@ export interface LaunchContext { profile: LaunchProfileId; role: SessionRole; cwd: string; + /** Original person-selected task project, retained when an environment relocates cwd. Host-owned. */ + projectRoot?: string; parentSessionId?: string; /** True when the app is bringing back a saved session. */ restoring: boolean; @@ -73,6 +78,9 @@ export type PreparedLaunch = envSources: Record; /** A contributor runs the agent on another model than its vendor's: "auto" becomes accept-edits. */ thirdPartyModel: boolean; + /** Host-derived selected Accounts CLI home, passed transiently to AgentIsolation for exact-path validation. */ + accountHome?: string; + accountId?:string; cleanup(): Promise; } | { ok: false; reason: string }; @@ -145,7 +153,8 @@ export class LaunchPipeline { * Checks launcher values against each plugin's declared fields and fills defaults. * Throws with a person-readable reason; returns undefined when nothing was chosen. */ - normalizeOptions(provider: ProviderId, candidate: unknown, context?: { delegated?: boolean }): Record | undefined { + normalizeOptions(provider: ProviderId, candidate: unknown, + context?: { delegated?: boolean; inherited?: Readonly>>> }): Record | undefined { if (candidate === undefined) return undefined; if (!isRecord(candidate) || Object.keys(candidate).length > MAX_OPTION_PLUGINS) throw new Error("Launch options are invalid."); if (Object.keys(candidate).length === 0) return undefined; @@ -156,7 +165,7 @@ export class LaunchPipeline { const contributor = contributors.get(pluginId); if (!contributor) throw new Error(unavailableReason(pluginId)); // An orchestrator picks options for its subagents only where the plugin said that is safe. - if (context?.delegated && contributor.launch.delegable !== true) { + if (context?.delegated && contributor.launch.delegable !== true && !sameAsInherited(raw, context.inherited?.[pluginId])) { throw new LaunchRefusal(`${contributor.pluginName} has not declared its launch options safe for an orchestrator to choose; only the person chooses them, in the launcher.`); } const { appliesTo, fields } = contributor.launch; @@ -267,12 +276,19 @@ export class LaunchPipeline { const args: string[] = []; const secrets: string[] = []; let thirdPartyModel = false; + let accountHome: string | undefined; + let accountId:string|undefined; for (const answer of answers) { if ("refuse" in answer) return refuse(answer.refuse); } for (const answer of answers as Array<{ contributor: LaunchContributor; contribution: Contribution }>) { const { contributor, contribution } = answer; const name = contributor.pluginName; + if(contribution.accountId!==undefined) { + if(contributor.pluginId!==ACCOUNTS_PLUGIN_ID)return refuse(`${name} cannot attribute the selected model account.`); + if(contribution.accountId!==selectedAccountId(context.options))return refuse(`${name} returned an account attribution that differs from the selected account.`); + accountId=contribution.accountId; + } if (context.provider === "terminal" && contribution.args.length > 0) { return refuse(`${name} added arguments to a plain terminal, which takes none.`); } @@ -312,7 +328,12 @@ export class LaunchPipeline { for (const [key, value] of Object.entries(contribution.env)) { const conflict = claim(key); if (conflict) return refuse(conflict); - env[key] = expand(value); + const expanded = expand(value); + env[key] = expanded; + // Only the host's Accounts service may reopen its selected provider home below plugin-data. This value is + // derived from the trusted contributor record, not a plugin-returned grant or an inherited environment. + accountHome = selectedAccountHome(context.provider, contributor.pluginId, contributor.dataDir, + context.options[contributor.pluginId]?.account, { [key]: expanded }) ?? accountHome; } for (const [key, secretKey] of Object.entries(contribution.secretEnv)) { const conflict = claim(key); @@ -327,7 +348,8 @@ export class LaunchPipeline { args.push(...contributedArgs.map(expand)); thirdPartyModel ||= contribution.thirdPartyModel === true; } - return { ok: true, env, args, secrets, envSources, thirdPartyModel, cleanup }; + return { ok: true, env, args, secrets, envSources, thirdPartyModel, ...(accountHome ? { accountHome } : {}), ...(accountId ? {accountId} : {}), + cleanup }; } private async ask( @@ -370,6 +392,7 @@ interface Contribution { files: Array<{ relPath: string; content: string }>; /** Only ever restricts (auto → accept-edits), so a launch policy may set it too. */ thirdPartyModel?: boolean; + accountId?:string; refuse?: string; } @@ -377,7 +400,7 @@ interface Contribution { function validContribution(value: unknown): Contribution | string { if (value === null) return { env: {}, secretEnv: {}, args: [], files: [] }; if (!isRecord(value)) return "not an object"; - const unknown = Object.keys(value).find((key) => !["env", "secretEnv", "args", "files", "thirdPartyModel", "refuse"].includes(key)); + const unknown = Object.keys(value).find((key) => !["env", "secretEnv", "args", "files", "thirdPartyModel", "refuse", "accountId"].includes(key)); if (unknown) return `unknown key ${unknown.slice(0, 40)}`; if (value.refuse !== undefined) { const reason = isRecord(value.refuse) ? value.refuse.reason : undefined; @@ -385,6 +408,7 @@ function validContribution(value: unknown): Contribution | string { return { env: {}, secretEnv: {}, args: [], files: [], refuse: reason.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, MAX_REASON) }; } if (value.thirdPartyModel !== undefined && typeof value.thirdPartyModel !== "boolean") return "thirdPartyModel must be true or false"; + if(value.accountId!==undefined && (typeof value.accountId!=="string" || !/^[\w-]{1,80}$/.test(value.accountId)))return "accountId must be a bounded account identifier"; const env = stringMap(value.env, MAX_ENV, "env"); if (typeof env === "string") return env; for (const [key, entry] of Object.entries(env)) { @@ -419,7 +443,7 @@ function validContribution(value: unknown): Contribution | string { if (bytes > MAX_FILES_BYTES) return "files exceed 256 KB"; files.push({ relPath: file.relPath, content: file.content }); } - return { env, secretEnv, args: args as string[], files, ...(value.thirdPartyModel === true ? { thirdPartyModel: true } : {}) }; + return { env, secretEnv, args: args as string[], files, ...(value.thirdPartyModel === true ? { thirdPartyModel: true } : {}),...(typeof value.accountId==="string" ? {accountId:value.accountId} : {}) }; } /** @@ -575,3 +599,10 @@ export function errorText(error: unknown): string { export function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } + +/** The orchestrator's own choice repeated for its subagent: only values it inherited, each one the same. */ +function sameAsInherited(raw: unknown, inherited: Readonly> | undefined): boolean { + if (!inherited || !isRecord(raw)) return false; + const keys = Object.keys(raw); + return keys.length > 0 && keys.every((key) => Object.hasOwn(inherited, key) && inherited[key] === raw[key]); +} diff --git a/src/main/services/OrchestrationBudgetService.ts b/src/main/services/OrchestrationBudgetService.ts new file mode 100644 index 00000000..cdb4da8f --- /dev/null +++ b/src/main/services/OrchestrationBudgetService.ts @@ -0,0 +1,397 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +export interface OrchestrationBudgetLimits { + tokens: number | null; + costUsd: number | null; + durationMs: number | null; +} + +export interface OrchestrationBudgetUsage { + /** Null means the provider or timeline source does not report token usage. */ + tokens: number | null; + /** Null means no cost data is available. It is never estimated from token counts. */ + costUsd: number | null; + /** Time is measured from the root orchestrator's start and includes every descendant. */ + durationMs: number | null; + reportedAt?: number; +} + +export interface OrchestrationBudgetSnapshot { + rootSessionId: string; + limits: OrchestrationBudgetLimits; + usage: OrchestrationBudgetUsage; + remaining: OrchestrationBudgetUsage; + warning: boolean; + paused: boolean; + reason?: string; + data: { tokens: "available" | "none"; costUsd: "available" | "partial" | "none"; duration: "available" | "none" }; +} + +interface StoredBudget { + startedAt?: number; + sessions?:Record; + limits: OrchestrationBudgetLimits; + tokens: number | null; + costUsd: number | null; + reportedAt: number | null; + usageSource?:"direct"|"session-ledger"; + warned: boolean; + paused: boolean; + pauseCause?:"limit"|"cost-data"; + enforcementFailure?:string; +} + +interface StoreState { version: 1; budgets: Record } +interface BudgetCostStatus { incomplete: boolean; provisional: boolean } +interface BudgetCallbacks { + onWarning?(snapshot: OrchestrationBudgetSnapshot): void; + onPause?(snapshot: OrchestrationBudgetSnapshot): void; + onChange?(snapshot: OrchestrationBudgetSnapshot): void; +} + +const EMPTY_LIMITS: OrchestrationBudgetLimits = { tokens: null, costUsd: null, durationMs: null }; +const EMPTY_STATE: StoreState = { version: 1, budgets: {} }; +const MAX_ROOTS = 1_024; + +/** + * Persistent task-wide budgets. Token and cost usage must be supplied by a real provider/timeline source; unknown + * values remain null and never masquerade as zero. Budget controls are intentionally absent from the agent tools. + */ +export class OrchestrationBudgetService { + private readonly path: string; + private readonly callbacks: BudgetCallbacks; + private state: StoreState = structuredClone(EMPTY_STATE); + private loaded = false; + private writeQueue: Promise = Promise.resolve(); + private readonly listeners = new Set<(snapshot: OrchestrationBudgetSnapshot) => void>(); + private readonly deadlines = new Map>(); + + constructor(storagePath: string, callbacks: BudgetCallbacks = {}) { + this.path = resolve(storagePath); + this.callbacks = callbacks; + } + + async load(): Promise { + try { + const text = await readFile(this.path, "utf8"); + if (Buffer.byteLength(text, "utf8") > 1024 * 1024) throw new Error("Budget store exceeds 1 MB."); + const candidate: unknown = JSON.parse(text); + if (!isStore(candidate)) throw new Error("Budget store has an invalid format."); + this.state = candidate; + } catch (error) { + if (!isMissing(error)) throw new Error(`Could not load orchestration budgets: ${errorText(error)}`); + this.state = structuredClone(EMPTY_STATE); + } + this.loaded = true; + for(const [id,record] of Object.entries(this.state.budgets))if(record.startedAt!==undefined)this.snapshot(id,record.startedAt); + } + + async flush():Promise{await this.writeQueue;} + dispose():void {for(const timer of this.deadlines.values())clearTimeout(timer);this.deadlines.clear();} + snapshots():OrchestrationBudgetSnapshot[] {this.assertLoaded();return Object.keys(this.state.budgets).map(id=>this.snapshot(id));} + hasLimits(rootSessionId:string):boolean { + this.assertLoaded();requireId(rootSessionId); + const limits=this.state.budgets[rootSessionId]?.limits ?? EMPTY_LIMITS; + return limits.tokens!==null || limits.costUsd!==null || limits.durationMs!==null; + } + setEnforcementFailure(rootSessionId:string,failure?:string):void { + const record=this.record(rootSessionId); + if(record.enforcementFailure===failure)return; + if(failure){record.enforcementFailure=failure.slice(0,500);record.paused=true;delete record.pauseCause;} + else delete record.enforcementFailure; + this.persistQuietly(); + } + + subscribe(listener: (snapshot: OrchestrationBudgetSnapshot) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** Called only by the host UI/controller after the person changes limits. */ + async setLimits(rootSessionId: string, limits: OrchestrationBudgetLimits, rootStartedAt?: number): Promise { + this.assertLoaded(); + requireId(rootSessionId); + const normalized = normalizeLimits(limits); + const record = this.record(rootSessionId); + delete record.enforcementFailure; + record.limits = normalized; + const current = this.snapshot(rootSessionId, rootStartedAt); + const overLimit=isOver(current),unknownCost=record.limits.costUsd!==null && budgetCostStatus(record).incomplete; + if (!overLimit && !unknownCost) { + record.paused = false; + delete record.pauseCause; + record.warned = false; + } else { + record.paused = true; + record.pauseCause=overLimit ? "limit" : "cost-data"; + } + await this.persist(); + const updated = this.snapshot(rootSessionId, rootStartedAt); + this.emit(updated); + return updated; + } + + /** A single action that removes every limit and releases the task from a budget pause. */ + async clearLimits(rootSessionId: string, rootStartedAt?: number): Promise { + return this.setLimits(rootSessionId, { ...EMPTY_LIMITS }, rootStartedAt); + } + + /** Host-only callback for real CLI usage. Values are cumulative for the whole orchestration tree. */ + recordUsage(rootSessionId: string, usage: Pick, rootStartedAt?: number): OrchestrationBudgetSnapshot { + return this.writeUsage(rootSessionId,usage,rootStartedAt,"direct"); + } + + private writeUsage(rootSessionId:string,usage:Pick,rootStartedAt:number|undefined,source:"direct"|"session-ledger"):OrchestrationBudgetSnapshot { + this.assertLoaded(); + requireId(rootSessionId); + const record = this.record(rootSessionId); + record.tokens = normalizeUsage(usage.tokens, "tokens"); + record.costUsd = normalizeUsage(usage.costUsd, "costUsd"); + record.reportedAt = Date.now(); + record.usageSource=source; + const updated = this.snapshot(rootSessionId, rootStartedAt); + this.persistQuietly(); + this.emit(updated); + return updated; + } + + /** Individual source counters remain in the task ledger even after a card closes or an account hands off. */ + recordSessionUsage(rootSessionId:string,sessionId:string,usage:{tokens:number|null;costUsd:number|null},options:{rootStartedAt?:number;replaceCounterIds?:readonly string[];provisional?:boolean}={}):OrchestrationBudgetSnapshot { + this.assertLoaded();requireId(rootSessionId);requireId(sessionId); + const {rootStartedAt}=options; + const record=this.record(rootSessionId),sessions=record.sessions ?? (record.sessions={}); + // Replace only the host's pre-report placeholder; real conversation counters, + // including unknown prices from earlier conversations, remain in the ledger. + let removedPending=false; + for(const id of options.replaceCounterIds ?? []){ + requireId(id); + if(id!==sessionId && Object.hasOwn(sessions,id)){delete sessions[id];removedPending=true;} + } + const tokens=normalizeUsage(usage.tokens,"tokens"),costUsd=normalizeUsage(usage.costUsd,"costUsd"),previous=sessions[sessionId],provisional=options.provisional===true; + if(provisional && (tokens!==null || costUsd!==null))throw new Error("Provisional task usage cannot include observed tokens or cost."); + const legacyPending=provisional && previous!==undefined && !previous.provisional && previous.tokens===null && previous.costUsd===null; + if(provisional && previous && !previous.provisional && !legacyPending)return this.snapshot(rootSessionId,rootStartedAt); + if(legacyPending && record.usageSource===undefined)record.usageSource="session-ledger"; + const next={tokens:tokens===null ? previous?.tokens ?? null : Math.max(previous?.tokens ?? 0,tokens), + costUsd,...(provisional ? {provisional:true} : {})}; + if(!removedPending && previous && previous.tokens===next.tokens && previous.costUsd===next.costUsd && (previous.provisional===true)===provisional)return this.snapshot(rootSessionId,rootStartedAt); + if(!previous && Object.keys(sessions).length>=2048)throw new Error("Task usage ledger holds at most 2048 agents."); + sessions[sessionId]=next; + let hasObserved=false,knownTokens=0,tokenSum=0,hasCost=false,costSum=0; + for(const row of Object.values(sessions))if(row.provisional!==true) { + hasObserved=true; + if(row.tokens!==null){knownTokens++;tokenSum+=row.tokens;} + if(row.costUsd!==null)hasCost=true; + costSum+=row.costUsd ?? 0; + } + if(!hasObserved){this.persistQuietly();const updated=this.snapshot(rootSessionId,rootStartedAt);this.emit(updated);return updated;} + return this.writeUsage(rootSessionId,{tokens:knownTokens ? tokenSum : null,costUsd:hasCost ? costSum : null},rootStartedAt,"session-ledger"); + } + + /** Called before each spawn/send; also trips a time limit even if the CLI has emitted no further usage. */ + snapshot(rootSessionId: string, rootStartedAt?: number): OrchestrationBudgetSnapshot { + this.assertLoaded(); + requireId(rootSessionId); + const record = this.state.budgets[rootSessionId] ?? emptyBudget(); + if(record.startedAt===undefined && Number.isFinite(rootStartedAt)) { + record.startedAt=Number(rootStartedAt); + if(this.state.budgets[rootSessionId])this.persistQuietly(); + } + const start=record.startedAt ?? rootStartedAt; + const durationMs = Number.isFinite(start) ? Math.max(0, Date.now() - Number(start)) : null; + const usage: OrchestrationBudgetUsage = { + tokens: record.tokens, + costUsd: record.costUsd, + durationMs, + ...(record.reportedAt !== null ? { reportedAt: record.reportedAt } : {}) + }; + const remaining: OrchestrationBudgetUsage = { + tokens: difference(record.limits.tokens, usage.tokens), + costUsd: difference(record.limits.costUsd, usage.costUsd), + durationMs: difference(record.limits.durationMs, usage.durationMs) + }; + const thresholdView = { limits: record.limits, usage } as OrchestrationBudgetSnapshot; + const costStatus=budgetCostStatus(record); + const costIncomplete=costStatus.incomplete; + const unknownCostLimit=record.limits.costUsd!==null && costIncomplete; + const provisionalCost=costStatus.provisional; + if(costIncomplete || provisionalCost)remaining.costUsd=null; + const overLimit=isOver(thresholdView); + const over = overLimit || unknownCostLimit; + const atWarning = isAtWarning(thresholdView); + let pauseStateChanged=false; + if(record.paused && record.pauseCause==="cost-data" && overLimit){record.pauseCause="limit";pauseStateChanged=true;} + if(record.paused && record.pauseCause==="cost-data" && !overLimit && !unknownCostLimit && !record.enforcementFailure){ + record.paused=false;delete record.pauseCause;pauseStateChanged=true; + } + const pauseNow = !record.paused && over; + const warnNow = !record.warned && atWarning; + if (pauseNow) {record.paused = true;record.pauseCause=overLimit ? "limit" : "cost-data";pauseStateChanged=true;} + if (warnNow) record.warned = true; + const paused = record.paused || over; + const warning = record.warned || atWarning; + const reason = overReasons(record.limits, usage); + const snapshot: OrchestrationBudgetSnapshot = { + rootSessionId, + limits: { ...record.limits }, + usage, + remaining, + warning, + paused, + ...(paused ? { reason: unknownCostLimit + ? "Cost budget cannot be enforced because some agent costs are unavailable. Task paused; configure prices or remove the cost limit." + : reason ? `Budget reached: ${reason}. New subagents and agent input are paused.` + : "This budget pause remains active. Change or clear the task limits to resume." } : {}), + data: { + tokens: usage.tokens === null ? "none" : "available", + costUsd: usage.costUsd === null ? "none" : costIncomplete || provisionalCost ? "partial" : "available", + duration: usage.durationMs === null ? "none" : "available" + } + }; + if (pauseStateChanged || warnNow) { + this.persistQuietly(); + if (warnNow) try { this.callbacks.onWarning?.(snapshot); } catch { /* an app notification cannot affect the budget */ } + if (pauseNow) try { this.callbacks.onPause?.(snapshot); } catch { /* a UI callback cannot undo a hard limit */ } + } + if(record.enforcementFailure && !snapshot.reason?.includes(record.enforcementFailure))snapshot.reason=`${snapshot.reason ?? "Task budget is paused."} ${record.enforcementFailure}`; + this.scheduleDeadline(rootSessionId,record); + return snapshot; + } + + private scheduleDeadline(id:string,record:StoredBudget):void { + const existing=this.deadlines.get(id); + if(existing)clearTimeout(existing); + this.deadlines.delete(id); + if(record.paused || record.limits.durationMs===null || record.startedAt===undefined)return; + const due=record.startedAt+record.limits.durationMs*(record.warned ? 1 : 0.8); + const timer=setTimeout(()=>{this.deadlines.delete(id);this.emit(this.snapshot(id,record.startedAt));},Math.max(1,Math.min(2_147_483_647,due-Date.now()))); + timer.unref();this.deadlines.set(id,timer); + } + + isPaused(rootSessionId: string, rootStartedAt?: number): boolean { + return this.snapshot(rootSessionId, rootStartedAt).paused; + } + + private record(rootSessionId: string): StoredBudget { + if (!this.state.budgets[rootSessionId]) { + if (Object.keys(this.state.budgets).length >= MAX_ROOTS) throw new Error("Too many orchestration budget records."); + this.state.budgets[rootSessionId] = emptyBudget(); + } + return this.state.budgets[rootSessionId]!; + } + + private async persist(): Promise { + const snapshot = JSON.stringify(this.state); + if(Buffer.byteLength(snapshot)>1024*1024)throw new Error("Task budget store exceeds 1 MB."); + const operation = this.writeQueue.catch(() => undefined).then(async () => { + await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); + const temporary = `${this.path}.${randomUUID()}.tmp`; + await writeFile(temporary, snapshot, { encoding: "utf8", mode: 0o600, flag: "wx" }); + try { await rename(temporary, this.path); } + catch (error) { + const { unlink } = await import("node:fs/promises"); + await unlink(temporary).catch(() => undefined); + throw error; + } + }); + this.writeQueue = operation; + await operation; + } + + private persistQuietly(): void { + void this.persist().catch(() => undefined); + } + + private emit(snapshot: OrchestrationBudgetSnapshot): void { + try { this.callbacks.onChange?.(snapshot); } catch { /* events do not affect accounting */ } + for (const listener of this.listeners) try { listener(snapshot); } catch { /* one UI listener cannot affect another */ } + } + + private assertLoaded(): void { if (!this.loaded) throw new Error("Orchestration budget service must be loaded before use."); } +} + +function emptyBudget(): StoredBudget { return { limits: { ...EMPTY_LIMITS }, tokens: null, costUsd: null, reportedAt: null, warned: false, paused: false }; } +function normalizeLimits(value: OrchestrationBudgetLimits): OrchestrationBudgetLimits { + if (!value || typeof value !== "object") throw new Error("Budget limits are invalid."); + return { tokens: normalizeLimit(value.tokens, "tokens"), costUsd: normalizeLimit(value.costUsd, "costUsd"), durationMs: normalizeLimit(value.durationMs, "durationMs") }; +} +function normalizeLimit(value: unknown, name: string): number | null { + if (value === null || value === undefined) return null; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`${name} limit must be a positive finite number or null.`); + return value; +} +function normalizeUsage(value: unknown, name: string): number | null { + if (value === null || value === undefined) return null; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(`${name} usage must be a nonnegative finite number or null.`); + return value; +} +function difference(limit: number | null, usage: number | null): number | null { return limit === null || usage === null ? null : Math.max(0, limit - usage); } +function reached(limit:number|null,usage:number|null,threshold=1):boolean { + return limit!==null && usage!==null && usage>=limit*threshold; +} +function isOver(value: Pick): boolean { + const {limits,usage}=value; + return reached(limits.tokens,usage.tokens) || reached(limits.costUsd,usage.costUsd) || reached(limits.durationMs,usage.durationMs); +} +function isAtWarning(value: Pick): boolean { + const {limits,usage}=value; + return reached(limits.tokens,usage.tokens,0.8) || reached(limits.costUsd,usage.costUsd,0.8) || reached(limits.durationMs,usage.durationMs,0.8); +} +function overReasons(limits: OrchestrationBudgetLimits, usage: OrchestrationBudgetUsage): string { + const reasons:string[]=[]; + if(reached(limits.tokens,usage.tokens))reasons.push("token limit"); + if(reached(limits.costUsd,usage.costUsd))reasons.push("cost limit"); + if(reached(limits.durationMs,usage.durationMs))reasons.push("time limit"); + return reasons.join(", "); +} +function requireId(value: unknown): asserts value is string { if (typeof value !== "string" || value.length < 1 || value.length > 160) throw new Error("Orchestration root session id is invalid."); } +function isMissing(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } +function errorText(error: unknown): string { return error instanceof Error ? error.message.slice(0, 200) : "file is unavailable"; } +function budgetCostStatus(record:StoredBudget):BudgetCostStatus { + let count=0,allProvisional=true,provisional=false,unknownObservedCost=false; + for(const session of Object.values(record.sessions ?? {})) { + count++; + if(session.provisional===true)provisional=true; + else {allProvisional=false;if(session.costUsd===null)unknownObservedCost=true;} + } + const onlyProvisionalLedger=record.usageSource==="session-ledger" && count>0 && allProvisional; + return { + incomplete:!onlyProvisionalLedger && record.reportedAt!==null && (record.costUsd===null || unknownObservedCost), + provisional + }; +} +function isStore(value: unknown): value is StoreState { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + if (record.version !== 1 || !record.budgets || typeof record.budgets !== "object" || Array.isArray(record.budgets)) return false; + const budgets = record.budgets as Record; + return Object.keys(budgets).length <= MAX_ROOTS && Object.values(budgets).every(isBudgetRecord); +} +function nonnegativeOrNull(value:unknown):boolean { + return value===null || typeof value==="number" && Number.isFinite(value) && value>=0; +} +function positiveOrNull(value:unknown):boolean { + return value===null || typeof value==="number" && Number.isFinite(value) && value>0; +} +function isUsageLedger(value:unknown):boolean { + if(!value || typeof value!=="object" || Array.isArray(value) || Object.keys(value).length>2048)return false; + return Object.values(value).every(row=>row && typeof row==="object" && nonnegativeOrNull(row.tokens) && nonnegativeOrNull(row.costUsd) + && (row.provisional===undefined || typeof row.provisional==="boolean")); +} +function isBudgetRecord(candidate:unknown):boolean { + if(!candidate || typeof candidate!=="object" || Array.isArray(candidate))return false; + const item=candidate as Record; + if(!item.limits || typeof item.limits!=="object" || Array.isArray(item.limits))return false; + if(item.sessions!==undefined && !isUsageLedger(item.sessions))return false; + if(item.startedAt!==undefined && !(typeof item.startedAt==="number" && Number.isFinite(item.startedAt) && item.startedAt>0))return false; + const limits=item.limits as Record; + return positiveOrNull(limits.tokens) && positiveOrNull(limits.costUsd) && positiveOrNull(limits.durationMs) + && nonnegativeOrNull(item.tokens) && nonnegativeOrNull(item.costUsd) + && (item.reportedAt===null || typeof item.reportedAt==="number" && Number.isFinite(item.reportedAt)) + && (item.usageSource===undefined || item.usageSource==="direct" || item.usageSource==="session-ledger") + && (item.pauseCause===undefined || item.pauseCause==="limit" || item.pauseCause==="cost-data") + && (item.enforcementFailure===undefined || typeof item.enforcementFailure==="string" && item.enforcementFailure.length<=500) + && typeof item.warned==="boolean" && typeof item.paused==="boolean"; +} diff --git a/src/main/services/OrchestrationTaskBoard.ts b/src/main/services/OrchestrationTaskBoard.ts new file mode 100644 index 00000000..7dc904c9 --- /dev/null +++ b/src/main/services/OrchestrationTaskBoard.ts @@ -0,0 +1,376 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; + +export type OrchestrationTaskStatus = "open" | "claimed" | "done" | "closed"; + +export interface OrchestrationTask { + id: string; + rootSessionId: string; + title: string; + description: string; + progress: string; + ownerSessionId: string | null; + ownerName: string | null; + status: OrchestrationTaskStatus; + dependencies: string[]; + result: string | null; + createdBySessionId: string; + createdAt: number; + updatedAt: number; +} + +export interface OrchestrationTaskBoardChange { + projectRoot: string; + rootSessionId: string; + tasks: OrchestrationTask[]; + revision: number; +} + +export interface NewOrchestrationTask { + title: string; + description?: string; + dependencies?: string[]; + ownerSessionId?: string | null; + ownerName?: string | null; +} + +export interface OrchestrationTaskPatch { + title?: string; + description?: string; + progress?: string; + status?: "open" | "claimed"; + ownerSessionId?: string | null; + ownerName?: string | null; + dependencies?: string[]; +} + +interface BoardState { + version: 1; + revision: number; + tasks: OrchestrationTask[]; +} + +const EMPTY_BOARD: BoardState = { version: 1, revision: 0, tasks: [] }; +const MAX_TASKS = 512; +const MAX_TITLE = 240; +const MAX_DESCRIPTION = 8_000; +const MAX_PROGRESS = 4_000; +const MAX_RESULT = 8_000; +const MAX_DEPENDENCIES = 64; +const locks = new Map>(); + +/** + * Atomic, project-keyed task storage outside the project tree. Agent shell tools can edit project files but cannot + * bypass these ownership checks by editing the board file. One root session owns one task group on the project board. + */ +export class OrchestrationTaskBoard { + private readonly storageRoot: string; + private readonly listeners = new Set<(change: OrchestrationTaskBoardChange) => void>(); + + constructor(storageRoot: string) { + this.storageRoot = resolve(storageRoot); + } + + subscribe(listener: (change: OrchestrationTaskBoardChange) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** Agent-facing list is always scoped to one root orchestrator. */ + async listTasks(projectRoot: string, rootSessionId: string): Promise<{ revision: number; tasks: OrchestrationTask[] }> { + requireId(rootSessionId, "rootSessionId"); + const state = await this.read(this.boardLocation(projectRoot).path); + return { revision: state.revision, tasks: state.tasks.filter((task) => task.rootSessionId === rootSessionId).map(copyTask) }; + } + + /** Human-facing project view groups tasks by orchestrator; it is not exposed through agent tools. */ + async listProjectTasks(projectRoot: string): Promise<{ revision: number; tasks: OrchestrationTask[] }> { + const state = await this.read(this.boardLocation(projectRoot).path); + return { revision: state.revision, tasks: state.tasks.map(copyTask) }; + } + + async addTask(projectRoot: string, rootSessionId: string, createdBySessionId: string, input: NewOrchestrationTask): Promise { + requireId(rootSessionId, "rootSessionId"); + requireId(createdBySessionId, "createdBySessionId"); + return this.mutate(projectRoot, rootSessionId, (state) => { + ensureCapacity(state); + const dependencies = validateDependencies(input.dependencies ?? []); + ensureDependenciesExist(state, rootSessionId, dependencies); + const now = Date.now(); + const next: OrchestrationTask = { + id: randomUUID(), rootSessionId, title: requiredText(input.title, "title", MAX_TITLE), + description: optionalText(input.description, "description", MAX_DESCRIPTION) ?? "", + progress: "", ownerSessionId: optionalId(input.ownerSessionId), ownerName: optionalName(input.ownerName), + status: input.ownerSessionId ? "claimed" : "open", dependencies, result: null, + createdBySessionId, createdAt: now, updatedAt: now + }; + state.tasks.push(next); + return copyTask(next); + }); + } + + /** Atomic claim: competing callers serialize through the store lock; the loser gets the current owner. */ + async claimTask(projectRoot: string, rootSessionId: string, actorSessionId: string, actorName: string, taskId: string): Promise { + requireId(rootSessionId, "rootSessionId"); + requireId(actorSessionId, "actorSessionId"); + requireId(taskId, "taskId"); + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + if (task.status === "claimed" && task.ownerSessionId !== actorSessionId) { + const owner = task.ownerName || task.ownerSessionId || "another agent"; + throw new Error(`Task ${task.id} is already claimed by ${owner}.`); + } + if (task.status !== "open" && !(task.status === "claimed" && task.ownerSessionId === actorSessionId)) { + throw new Error(`Task ${task.id} is ${task.status} and cannot be claimed.`); + } + requireDependenciesDone(state, rootSessionId, task); + task.ownerSessionId = actorSessionId; + task.ownerName = optionalName(actorName) ?? actorSessionId; + task.status = "claimed"; + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + /** Agent edits are limited to the task it owns; only the root orchestrator may reassign or alter dependencies. */ + async updateTask(projectRoot: string, rootSessionId: string, actorSessionId: string, taskId: string, patch: OrchestrationTaskPatch): Promise { + return this.editTask(projectRoot, rootSessionId, actorSessionId, taskId, patch, false); + } + + private async editTask(projectRoot: string, rootSessionId: string, actorSessionId: string, taskId: string, patch: OrchestrationTaskPatch, person: boolean): Promise { + requireId(rootSessionId, "rootSessionId"); + requireId(actorSessionId, "actorSessionId"); + requireId(taskId, "taskId"); + if (!isRecord(patch) || Object.keys(patch).length === 0) throw new Error("At least one task field is required."); + const allowed = new Set(["title", "description", "progress", "status", "ownerSessionId", "ownerName", "dependencies"]); + const extra = Object.keys(patch).find((key) => !allowed.has(key)); + if (extra) throw new Error(`Unknown task field "${extra}".`); + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + if (!person && (task.status === "closed" || task.status === "done")) { + throw new Error(`Task ${task.id} is ${task.status}; only the person may reopen or change it.`); + } + const isRoot = actorSessionId === rootSessionId; + if (!isRoot && task.ownerSessionId !== actorSessionId) throw new Error(`Task ${task.id} belongs to another agent.`); + if (!isRoot && (patch.ownerSessionId !== undefined || patch.ownerName !== undefined || patch.dependencies !== undefined)) { + throw new Error("Only the orchestrator or person may reassign a task or change its dependencies."); + } + if (patch.title !== undefined) task.title = requiredText(patch.title, "title", MAX_TITLE); + if (patch.description !== undefined) task.description = requiredText(patch.description, "description", MAX_DESCRIPTION); + if (patch.progress !== undefined) task.progress = requiredText(patch.progress, "progress", MAX_PROGRESS); + if (patch.dependencies !== undefined) { + task.dependencies = validateDependencies(patch.dependencies); + if (task.dependencies.includes(task.id)) throw new Error("A task cannot depend on itself."); + ensureDependenciesExist(state, rootSessionId, task.dependencies); + assertAcyclic(state.tasks.filter((item) => item.rootSessionId === rootSessionId), task.id); + } + if (patch.ownerSessionId !== undefined) { + task.ownerSessionId = optionalId(patch.ownerSessionId); + if (task.ownerSessionId === null) task.ownerName = null; + else if (patch.ownerName !== undefined) task.ownerName = optionalName(patch.ownerName) ?? task.ownerSessionId; + task.status = task.ownerSessionId ? "claimed" : "open"; + } else if (patch.ownerName !== undefined && isRoot) { + task.ownerName = optionalName(patch.ownerName); + } + if (patch.status !== undefined) { + if (patch.status === "claimed" && !task.ownerSessionId) throw new Error("A task must have an owner before it can be claimed."); + if (patch.status === "open") { + if (!isRoot && task.ownerSessionId !== actorSessionId) throw new Error("Only the task owner or orchestrator can release this task."); + task.ownerSessionId = null; + task.ownerName = null; + } + task.status = patch.status as "open" | "claimed"; + } + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + async completeTask(projectRoot: string, rootSessionId: string, actorSessionId: string, taskId: string, result: string): Promise { + requireId(rootSessionId, "rootSessionId"); + requireId(actorSessionId, "actorSessionId"); + requireId(taskId, "taskId"); + const text = requiredText(result, "result", MAX_RESULT); + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + if (actorSessionId !== rootSessionId && task.ownerSessionId !== actorSessionId) throw new Error(`Task ${task.id} belongs to another agent.`); + if (task.status === "done" || task.status === "closed") throw new Error(`Task ${task.id} is already ${task.status}.`); + requireDependenciesDone(state, rootSessionId, task); + task.status = "done"; + task.result = text; + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + /** Person/UI operations use explicit methods that bypass agent ownership while retaining the same atomic store. */ + async assignTask(projectRoot: string, rootSessionId: string, taskId: string, ownerSessionId: string | null, ownerName?: string | null): Promise { + return this.editTask(projectRoot, rootSessionId, rootSessionId, taskId, { ownerSessionId, ...(ownerName !== undefined ? { ownerName } : {}) }, true); + } + + async closeTask(projectRoot: string, rootSessionId: string, taskId: string): Promise { + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + task.status = "closed"; + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + async reopenTask(projectRoot: string, rootSessionId: string, taskId: string): Promise { + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + if (task.status === "done") throw new Error("A completed task cannot be reopened without being edited first."); + task.status = task.ownerSessionId ? "claimed" : "open"; + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + /** Host-only workspace import. All ids are remapped, and a malformed graph writes nothing. */ + async importGroup(projectRoot:string,rootSessionId:string,rows:unknown,sessionIds:Record):Promise { + requireId(rootSessionId,"rootSessionId"); + if(!Array.isArray(rows) || rows.length>MAX_TASKS || !rows.every(isOrchestrationTask))throw new Error("Invalid workspace task board."); + const ids=new Map(rows.map(row=>[row.id,randomUUID()])); + if(ids.size!==rows.length)throw new Error("Workspace tasks contain duplicate ids."); + const imported:OrchestrationTask[]=rows.map(row=>({ + id:ids.get(row.id)!,rootSessionId,title:requiredText(row.title,"title",MAX_TITLE), + description:optionalText(row.description,"description",MAX_DESCRIPTION) ?? "", + progress:optionalText(row.progress,"progress",MAX_PROGRESS) ?? "", + ownerSessionId:row.ownerSessionId ? sessionIds[row.ownerSessionId] ?? null : null, + ownerName:row.ownerSessionId && sessionIds[row.ownerSessionId] ? optionalText(row.ownerName,"ownerName",200) ?? null : null, + status:row.status==="claimed" && (!row.ownerSessionId || !sessionIds[row.ownerSessionId]) ? "open" : row.status, + dependencies:validateDependencies(row.dependencies).map(id=>{const mapped=ids.get(id);if(!mapped)throw new Error("Workspace task has a missing dependency.");return mapped;}), + result:optionalText(row.result,"result",MAX_RESULT) ?? null, + createdBySessionId:sessionIds[row.createdBySessionId] ?? rootSessionId,createdAt:row.createdAt,updatedAt:row.updatedAt + })); + assertAcyclic(imported); + await this.mutate(projectRoot,rootSessionId,state=>{if(state.tasks.length+imported.length>MAX_TASKS)throw new Error("Workspace task board exceeds capacity.");state.tasks.push(...imported);}); + } + + private async read(path: string): Promise { + try { + const text = await readFile(path, "utf8"); + if (Buffer.byteLength(text, "utf8") > 2 * 1024 * 1024) throw new Error("Task board exceeds 2 MB."); + const candidate: unknown = JSON.parse(text); + if (!isBoardState(candidate)) throw new Error("Task board has an invalid format."); + return candidate; + } catch (error) { + if (isMissing(error)) return structuredClone(EMPTY_BOARD); + throw new Error(`Could not read orchestration task board: ${errorText(error)}`); + } + } + + private async mutate(projectRoot: string, rootSessionId: string, mutation: (state: BoardState) => T): Promise { + const location = this.boardLocation(projectRoot); + return withLock(location.path, async () => { + const state = await this.read(location.path); + const result = mutation(state); + state.revision += 1; + const payload = JSON.stringify(state); + if (Buffer.byteLength(payload, "utf8") > 2 * 1024 * 1024) throw new Error("Task board exceeds 2 MB."); + await mkdir(dirname(location.path), { recursive: true, mode: 0o700 }); + const temporary = `${location.path}.${randomUUID()}.tmp`; + await writeFile(temporary, payload, { encoding: "utf8", mode: 0o600, flag: "wx" }); + try { await rename(temporary, location.path); } + catch (error) { + const { unlink } = await import("node:fs/promises"); + await unlink(temporary).catch(() => undefined); + throw error; + } + const change: OrchestrationTaskBoardChange = { + projectRoot: location.normalizedRoot, + rootSessionId, + revision: state.revision, + tasks: state.tasks.filter((task) => task.rootSessionId === rootSessionId).map(copyTask) + }; + for (const listener of this.listeners) { + try { listener(change); } catch { /* one renderer listener cannot prevent persistence or another listener */ } + } + return result; + }); + } + + private boardLocation(projectRoot: string): {path:string;normalizedRoot:string} { + const normalizedRoot=resolve(projectRoot); + const digest=createHash("sha256").update(normalizedRoot).digest("hex").slice(0,40); + return {path:join(this.storageRoot,"orchestration-tasks",`${digest}.json`),normalizedRoot}; + } +} + +function withLock(key: string, run: () => Promise): Promise { + const previous = locks.get(key) ?? Promise.resolve(); + let release!: () => void; + const next = new Promise((resolveLock) => { release = resolveLock; }); + locks.set(key, next); + return previous.catch(() => undefined).then(run).finally(() => { + release(); + if (locks.get(key) === next) locks.delete(key); + }); +} + +function ensureCapacity(state: BoardState): void { if (state.tasks.length >= MAX_TASKS) throw new Error(`The project task board holds at most ${MAX_TASKS} tasks.`); } +function findTask(state: BoardState, rootSessionId: string, taskId: string): OrchestrationTask { + const task = state.tasks.find((item) => item.rootSessionId === rootSessionId && item.id === taskId); + if (!task) throw new Error("Task does not exist in this orchestrator's task group."); + return task; +} +function ensureDependenciesExist(state: BoardState, rootSessionId: string, dependencies: string[]): void { + for (const id of dependencies) findTask(state, rootSessionId, id); +} +function requireDependenciesDone(state: BoardState, rootSessionId: string, task: OrchestrationTask): void { + const waiting = task.dependencies.map((id) => findTask(state, rootSessionId, id)).filter((dependency) => dependency.status !== "done"); + if (waiting.length > 0) throw new Error(`Task ${task.id} is waiting for dependencies: ${waiting.map((item) => item.title).join(", ")}.`); +} +function assertAcyclic(tasks: readonly OrchestrationTask[], startAt?: string): void { + const byId = new Map(tasks.map((task) => [task.id, task])), active = new Set(), checked = new Set(); + const visit = (id: string): void => { + if (active.has(id)) throw new Error("Task dependencies cannot form a cycle."); + if (checked.has(id)) return; + active.add(id); + for (const dependency of byId.get(id)?.dependencies ?? []) visit(dependency); + active.delete(id); + checked.add(id); + }; + for (const id of startAt ? [startAt] : tasks.map((task) => task.id)) visit(id); +} +function validateDependencies(value: unknown): string[] { + if (!Array.isArray(value) || value.length > MAX_DEPENDENCIES || value.some((item) => typeof item !== "string" || item.length > 80)) { + throw new Error(`dependencies must be an array of at most ${MAX_DEPENDENCIES} task ids.`); + } + const result = [...new Set(value as string[])]; + if (result.length !== value.length) throw new Error("dependencies cannot contain duplicates."); + return result; +} +function requiredText(value: unknown, name: string, max: number): string { + if (typeof value !== "string" || value.trim().length === 0 || value.length > max) throw new Error(`${name} must contain 1 to ${max} characters.`); + return value.trim(); +} +function optionalText(value: unknown, name: string, max: number): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string" || value.length > max) throw new Error(`${name} must be at most ${max} characters.`); + return value.trim(); +} +function requireId(value: unknown, name: string): asserts value is string { if (typeof value !== "string" || value.length < 1 || value.length > 160) throw new Error(`${name} is invalid.`); } +function optionalId(value: unknown): string | null { if (value === undefined || value === null || value === "") return null; requireId(value, "ownerSessionId"); return value; } +function optionalName(value: unknown): string | null { if (value === undefined || value === null || value === "") return null; if (typeof value !== "string" || value.length > 160) throw new Error("ownerName is invalid."); return value.trim(); } +function copyTask(task: OrchestrationTask): OrchestrationTask { return { ...task, dependencies: [...task.dependencies] }; } +function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function isBoardState(value: unknown): value is BoardState { + return isRecord(value) && value.version === 1 && Number.isInteger(value.revision) && Array.isArray(value.tasks) + && value.tasks.length <= MAX_TASKS && value.tasks.every(isOrchestrationTask); +} +function isOrchestrationTask(value: unknown): value is OrchestrationTask { + if (!isRecord(value)) return false; + return typeof value.id === "string" && typeof value.rootSessionId === "string" && typeof value.title === "string" + && typeof value.description === "string" && typeof value.progress === "string" + && (value.ownerSessionId === null || typeof value.ownerSessionId === "string") + && (value.ownerName === null || typeof value.ownerName === "string") + && ["open", "claimed", "done", "closed"].includes(value.status as string) + && Array.isArray(value.dependencies) && value.dependencies.every((dependency) => typeof dependency === "string") + && (value.result === null || typeof value.result === "string") && typeof value.createdBySessionId === "string" + && Number.isFinite(value.createdAt) && Number.isFinite(value.updatedAt); +} +function isMissing(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } +function errorText(error: unknown): string { return error instanceof Error ? error.message.slice(0, 240) : "file is unavailable"; } diff --git a/src/main/services/OrchestrationTemplateService.ts b/src/main/services/OrchestrationTemplateService.ts new file mode 100644 index 00000000..82c4637c --- /dev/null +++ b/src/main/services/OrchestrationTemplateService.ts @@ -0,0 +1,360 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, sep } from "node:path"; +import { lazyRequire } from "../lazyRequire.ts"; +const yaml = lazyRequire("yaml"); +import type { ReasoningEffort } from "../../shared/launchModel.ts"; + +const FLOW_DIRECTORY = join(".canvastty", "flows"); +const MAX_FLOW_BYTES = 64 * 1024; +const MAX_FLOW_FILES = 128; +const MAX_ROLES = 16; +const MAX_TEXT = 8_000; +const ALLOWED_KEYS = new Set(["id", "name", "description", "roles", "finalStep"]); +const ROLE_KEYS = new Set(["id", "title", "instruction", "count", "model", "effort"]); +const EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh"]); + +export interface OrchestrationTemplateRole { + id: string; + title: string; + instruction: string; + count: number; + model?: string; + effort?: ReasoningEffort; +} + +/** A flow can choose role names, prompts, a model and reasoning effort; it cannot change the caller's permissions. */ +export interface OrchestrationTemplate { + id: string; + name: string; + description: string; + roles: OrchestrationTemplateRole[]; + finalStep: string; + expectedSubagents: number; + builtIn: boolean; + source?: string; + trusted?: boolean; + digest?: string; +} + +export interface OrchestrationTemplateIssue { + file: string; + line: number; + message: string; +} + +export interface OrchestrationTemplateList { + templates: OrchestrationTemplate[]; + errors: OrchestrationTemplateIssue[]; +} + +const BUILT_IN_FLOWS: readonly Omit[] = [ + { + id: "split-parallel-synthesize", + name: "Split, work in parallel, synthesize", + description: "Break a task into independent pieces, run the pieces concurrently, then combine the results.", + roles: [ + { id: "planner", title: "Plan the split", instruction: "Break the requested work into independent, bounded parts. Do not edit files. Return the parts and dependencies.", count: 1 }, + { id: "worker", title: "Complete one part", instruction: "Complete only the assigned part. State changed files and the result clearly.", count: 3 } + ], + finalStep: "Review every result, resolve conflicts, and present one concise combined outcome." + }, + { + id: "executor-reviewer", + name: "Executor and reviewer", + description: "One agent implements; a second independently reviews the diff and reports concrete issues.", + roles: [ + { id: "executor", title: "Implement", instruction: "Implement the requested change and report the files changed and verification performed.", count: 1 }, + { id: "reviewer", title: "Review", instruction: "Read the implementation diff and report only actionable bugs, regressions, and missing checks. Do not modify files.", count: 1, effort: "high" } + ], + finalStep: "Consider the review findings, ask the executor to fix valid issues, then report the final change and remaining risks." + }, + { + id: "three-options-judge", + name: "Three options and a judge", + description: "Explore three independent solutions and compare them against the request before choosing.", + roles: [ + { id: "option", title: "Propose one solution", instruction: "Develop one distinct solution. Explain tradeoffs and how it meets the request.", count: 3 }, + { id: "judge", title: "Compare the options", instruction: "Compare the three proposals against the user's constraints. Choose one and explain why.", count: 1, effort: "high" } + ], + finalStep: "Present the selected option, its reason, and the main tradeoff. Do not implement without the user's request." + }, + { + id: "find-and-verify-bugs", + name: "Find bugs and verify findings", + description: "Search independently for defects, then have another agent validate each candidate against the source.", + roles: [ + { id: "finder", title: "Find candidate bugs", instruction: "Inspect the assigned area and return specific candidate bugs with file and line evidence. Do not edit files.", count: 2 }, + { id: "verifier", title: "Verify candidate bugs", instruction: "Check each candidate against source and call paths. Reject false positives and provide evidence for validated bugs.", count: 1, effort: "high" } + ], + finalStep: "Report only independently verified findings, with impact and source evidence." + } +]; + +/** Loads project flows on every call so a new file is visible without restarting CanvasTTY. */ +export class OrchestrationTemplateService { + private readonly approvedTemplates = new WeakSet(); + private approvalQueue: Promise = Promise.resolve(); + private readonly approvalPath?: string; + constructor(approvalPath?: string) { this.approvalPath = approvalPath; } + + async list(projectRoot: string): Promise { + const templates: OrchestrationTemplate[] = BUILT_IN_FLOWS.map((flow) => ({ + ...structuredClone(flow), + expectedSubagents: flow.roles.reduce((sum, role) => sum + role.count, 0), + builtIn: true, trusted: true + })); + for (const template of templates) this.approvedTemplates.add(template); + const errors: OrchestrationTemplateIssue[] = []; + const root = await realpath(projectRoot); + let approvals = new Map(); + try { approvals = await this.readApprovals(); } + catch { errors.push({ file: "flow approvals", line: 0, message: "Flow approvals could not be read. Project flows require approval again before use." }); } + let flowDirectory: string | null; + try { flowDirectory = await projectFlowDirectory(root, false); } + catch { return { templates, errors: [{ file: FLOW_DIRECTORY, line: 1, message: "The flow directory must stay inside the project." }] }; } + if (!flowDirectory) return { templates, errors }; + let files: string[]; + try { + files = (await readdir(flowDirectory)).filter((file) => /\.(?:yaml|yml)$/iu.test(file)).sort().slice(0, MAX_FLOW_FILES); + } catch (error) { + if (isMissing(error)) return { templates, errors }; + return { templates, errors: [{ file: FLOW_DIRECTORY, line: 1, message: "The flow directory could not be read." }] }; + } + for (const file of files) { + const display = `${FLOW_DIRECTORY}/${file}`; + try { + const path = join(flowDirectory, file); + const [actual, info] = await Promise.all([realpath(path), stat(path)]); + if (!info.isFile()) { + errors.push({ file: display, line: 1, message: "Flow files must be regular files inside .canvastty/flows." }); + continue; + } + try { assertInsideProject(flowDirectory, actual); } + catch { + errors.push({ file: display, line: 1, message: "Flow files must be regular files inside .canvastty/flows." }); + continue; + } + if (info.size > MAX_FLOW_BYTES) { + errors.push({ file: display, line: 1, message: `Flow file exceeds ${MAX_FLOW_BYTES} bytes.` }); + continue; + } + const source = await readFile(actual, "utf8"); + if (Buffer.byteLength(source, "utf8") > MAX_FLOW_BYTES) throw new Error("Flow changed beyond its size limit."); + const parsed = parseFlow(source, display); + if ("issue" in parsed) errors.push(parsed.issue); + else if (templates.some((item) => item.id === parsed.template.id)) { + errors.push({ file: display, line: 1, message: `Flow id "${parsed.template.id}" is already in use.` }); + } else { + const digest = createHash("sha256").update(source).digest("hex"); + const trusted = approvals.get(JSON.stringify([root, parsed.template.id])) === digest; + const template = { ...parsed.template, digest, trusted }; + templates.push(template); + if (trusted) this.approvedTemplates.add(template); + } + } catch { + errors.push({ file: display, line: 1, message: "Flow file could not be read." }); + } + } + return { templates, errors }; + } + + /** Writes a validated project flow with an atomic replace. Built-in ids cannot be shadowed. */ + async save(projectRoot: string, flow: Omit): Promise { + const source = yaml().stringify(flow); + if (Buffer.byteLength(source, "utf8") > MAX_FLOW_BYTES) throw new Error(`Flow file exceeds ${MAX_FLOW_BYTES} bytes.`); + const parsed = parseFlow(source, "flow.yaml"); + if ("issue" in parsed) throw new Error(formatIssue(parsed.issue)); + if (BUILT_IN_FLOWS.some((builtIn) => builtIn.id === parsed.template.id)) throw new Error("A built-in flow cannot be overwritten."); + const directory = await projectFlowDirectory(await realpath(projectRoot), true); + const safeId = parsed.template.id.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, ""); + if (!safeId) throw new Error("Flow id must include a letter or number."); + const target = join(directory, `${safeId}.yaml`); + const temporary = join(directory, `.${safeId}.${randomUUID()}.tmp`); + await writeFile(temporary, source, { encoding: "utf8", mode: 0o600, flag: "wx" }); + try { await rename(temporary, target); } + catch (error) { + const { unlink } = await import("node:fs/promises"); + await unlink(temporary).catch(() => undefined); + throw error; + } + return target; + } + + /** The string a launcher can append to the orchestrator's initial prompt. */ + instructions(template: OrchestrationTemplate): string { + if (!this.approvedTemplates.has(template)) throw new Error("This project flow requires the person's approval of its current instructions."); + return buildOrchestrationTemplateInstructions(template); + } + + /** Desktop-only preview: the agent tools never return unapproved prompt fields. */ + async preview(projectRoot: string, id: string): Promise<{ instructions: string; digest: string | null }> { + const template = (await this.list(projectRoot)).templates.find(row => row.id === id); + if (!template) throw new Error("Flow no longer exists."); + return { instructions: buildOrchestrationTemplateInstructions(template), digest: template.digest ?? null }; + } + + /** Bind approval to the exact bytes shown by the desktop, outside every agent-writable project. */ + approve(projectRoot: string, id: string, expectedDigest: string): Promise { + const work = this.approvalQueue.then(async () => { + if (!this.approvalPath) throw new Error("Flow approval storage is unavailable."); + const root = await realpath(projectRoot); + const template = (await this.list(root)).templates.find(row => row.id === id); + if (!template || template.builtIn || !/^[a-f0-9]{64}$/u.test(expectedDigest) || template.digest !== expectedDigest) + throw new Error("Flow changed since the preview. Review its current instructions before approval."); + const approvals = await this.readApprovals(), key = JSON.stringify([root, id]); + if (!approvals.has(key) && approvals.size >= 4096) throw new Error("Flow approval storage is full."); + approvals.set(key, expectedDigest); + await mkdir(dirname(this.approvalPath), { recursive: true, mode: 0o700 }); + const temporary = `${this.approvalPath}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, JSON.stringify([...approvals]), { mode: 0o600, flag: "wx" }); + await rename(temporary, this.approvalPath); + } finally { await rm(temporary, { force: true }); } + }); + this.approvalQueue = work.catch(() => undefined); + return work; + } + + private async readApprovals(): Promise> { + if (!this.approvalPath) return new Map(); + let text: string; + try { text = await readFile(this.approvalPath, "utf8"); } + catch (error) { if (isMissing(error)) return new Map(); throw error; } + if (Buffer.byteLength(text) > 4 * 1024 * 1024) throw new Error("Flow approvals are too large."); + const entries: unknown = JSON.parse(text); + if (!Array.isArray(entries) || entries.length > 4096) throw new Error("Invalid flow approvals."); + const map = new Map(); + for (const row of entries) { + if (!Array.isArray(row) || row.length !== 2 || typeof row[0] !== "string" || typeof row[1] !== "string" || !/^[a-f0-9]{64}$/u.test(row[1])) + throw new Error("Invalid flow approval."); + const key: unknown = JSON.parse(row[0]); + if (!Array.isArray(key) || key.length !== 2 || typeof key[0] !== "string" || !isAbsolute(key[0]) || typeof key[1] !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/u.test(key[1])) + throw new Error("Invalid flow approval scope."); + map.set(row[0], row[1]); + } + return map; + } +} + +/** Resolve flow paths inside the canonical project root, creating missing components only for saves. */ +async function projectFlowDirectory(root: string, createIfMissing: true): Promise; +async function projectFlowDirectory(root: string, createIfMissing: false): Promise; +async function projectFlowDirectory(root: string, createIfMissing: boolean): Promise { + let directory = root; + for (const name of [".canvastty", "flows"]) { + const path = join(directory, name); + try { directory = await realpath(path); } + catch (error) { + if (!isMissing(error)) throw error; + if (!createIfMissing) return null; + await mkdir(path, { mode: 0o700 }); + directory = await realpath(path); + } + assertInsideProject(root, directory); + } + return directory; +} + +function assertInsideProject(root: string, path: string): void { + const rel = relative(root, path); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new Error("Orchestration flow paths must stay inside the project."); + } +} + +export function buildOrchestrationTemplateInstructions(template: OrchestrationTemplate): string { + return [ + `Orchestration flow: ${template.name}`, + template.description, + `Expected subagents: ${template.expectedSubagents}.`, + "Follow the roles below in order; start independent roles in parallel when dependencies allow.", + ...template.roles.map((role) => { + const defaults = [role.model ? `model=${role.model}` : "", role.effort ? `effort=${role.effort}` : ""].filter(Boolean).join(", "); + return `- ${role.count} × ${role.title} (${role.id})${defaults ? `; suggested ${defaults}` : ""}: ${role.instruction}`; + }), + `Final step: ${template.finalStep}`, + "A flow only sets a work pattern and model defaults. Preserve the orchestration permissions, project-folder limits, launch profile ceiling, and budgets already enforced by CanvasTTY.", + "User task:", + "{{TASK}}" + ].join("\n"); +} + +function parseFlow(source: string, file: string): { template: OrchestrationTemplate } | { issue: OrchestrationTemplateIssue } { + let document; + const lines=new (yaml().LineCounter)(); + try { document = yaml().parseDocument(source, { uniqueKeys: true, prettyErrors: false, lineCounter:lines }); } + catch (error) { return { issue: { file, line: 1, message: `Invalid YAML: ${message(error)}` } }; } + if (document.errors.length > 0) { + const error = document.errors[0]!; + const line = error.pos ? lines.linePos(error.pos[0]).line : 1; + return { issue: { file, line, message: `Invalid YAML: ${cleanMessage(error.message)}` } }; + } + const value: unknown = document.toJS(); + const issue = validateFlow(value, file); + if (issue) { + const role=/^roles\[(\d+)\](?:\.([a-zA-Z]+))?/u.exec(issue.message); + const quoted=/unknown field "([^"]+)"/iu.exec(issue.message); + const field=/^(id|name|description|finalStep|roles)\b/u.exec(issue.message)?.[1]; + const unknown=/^Unknown flow field "([^"]+)"/u.exec(issue.message)?.[1]; + const path=role ? ["roles",Number(role[1]),...(role[2] || quoted?.[1] ? [role[2] ?? quoted![1]] : [])] : [unknown ?? field ?? "id"]; + const node=document.getIn(path,true) as {range?:[number,number,number]}|undefined; + if(node?.range)issue.line=lines.linePos(node.range[0]).line; + return { issue }; + } + const candidate = value as Record; + const roles = candidate.roles as OrchestrationTemplateRole[]; + const id = candidate.id as string; + return { + template: { + id, + name: candidate.name as string, + description: candidate.description as string, + roles, + finalStep: candidate.finalStep as string, + expectedSubagents: roles.reduce((sum, role) => sum + role.count, 0), + builtIn: false, + source: file + } + }; +} + +function validateFlow(value: unknown, file: string): OrchestrationTemplateIssue | null { + const fail = (message: string, line = 1): OrchestrationTemplateIssue => ({ file, line, message }); + if (!isRecord(value)) return fail("Flow must be a YAML object."); + const extra = Object.keys(value).find((key) => !ALLOWED_KEYS.has(key)); + if (extra) return fail(`Unknown flow field "${extra}"; flows cannot set launch permissions, profiles or folders.`); + if (typeof value.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/u.test(value.id)) return fail("id must be a lowercase slug of at most 64 characters."); + if (BUILT_IN_FLOWS.some((flow) => flow.id === value.id)) return fail("A project flow cannot replace a built-in flow."); + if (!nonEmpty(value.name, 120)) return fail("name must contain 1 to 120 characters."); + if (!nonEmpty(value.description, 500)) return fail("description must contain 1 to 500 characters."); + if (!nonEmpty(value.finalStep, MAX_TEXT)) return fail("finalStep must contain 1 to 8000 characters."); + if (!Array.isArray(value.roles) || value.roles.length < 1 || value.roles.length > MAX_ROLES) return fail(`roles must contain 1 to ${MAX_ROLES} entries.`); + let total = 0; + const ids = new Set(); + for (let index = 0; index < value.roles.length; index += 1) { + const role: unknown = value.roles[index]; + if (!isRecord(role)) return fail(`roles[${index}] must be an object.`); + const roleExtra = Object.keys(role).find((key) => !ROLE_KEYS.has(key)); + if (roleExtra) return fail(`roles[${index}] has unknown field "${roleExtra}"; role permissions are fixed by CanvasTTY.`); + if (typeof role.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,47}$/u.test(role.id) || ids.has(role.id)) return fail(`roles[${index}].id must be a unique lowercase slug.`); + ids.add(role.id); + if (!nonEmpty(role.title, 120)) return fail(`roles[${index}].title must contain 1 to 120 characters.`); + if (!nonEmpty(role.instruction, MAX_TEXT)) return fail(`roles[${index}].instruction must contain 1 to ${MAX_TEXT} characters.`); + if (!Number.isInteger(role.count) || (role.count as number) < 1 || (role.count as number) > 12) return fail(`roles[${index}].count must be an integer from 1 to 12.`); + if (role.model !== undefined && !nonEmpty(role.model, 160)) return fail(`roles[${index}].model must contain 1 to 160 characters.`); + if (role.effort !== undefined && !EFFORTS.has(role.effort as ReasoningEffort)) return fail(`roles[${index}].effort is not supported.`); + total += role.count as number; + } + if (total > 32) return fail("A flow may expect at most 32 subagents."); + return null; +} + +function formatIssue(issue: OrchestrationTemplateIssue): string { + return `${issue.file}:${issue.line}: ${issue.message}`; +} +function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function nonEmpty(value: unknown, max: number): value is string { return typeof value === "string" && value.trim().length > 0 && value.length <= max; } +function isMissing(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } +function message(error: unknown): string { return cleanMessage(error instanceof Error ? error.message : "document could not be parsed"); } +function cleanMessage(value: string): string { return value.replace(/[\r\n\t]+/gu, " ").slice(0, 240); } diff --git a/src/main/services/OrchestrationUsageSync.ts b/src/main/services/OrchestrationUsageSync.ts new file mode 100644 index 00000000..0a8eec2e --- /dev/null +++ b/src/main/services/OrchestrationUsageSync.ts @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import type { UsagePrice } from "../../shared/backlog.ts"; +import type { OrchestrationBudgetService, OrchestrationBudgetSnapshot } from "./OrchestrationBudgetService.ts"; +import type { SessionTimelineService, TimelineTaskBudgetData, TimelineUsageCounter } from "./SessionTimelineService.ts"; + +interface CounterTotal {tokens:number;costUsd:number;costKnown:boolean;replaceCounterIds:Set} +export interface OrchestrationUsageScope {rootSessionId:string;rootStartedAt:number;memberSessionIds:readonly string[];budgetEnabled?:boolean} + +/** Synchronize the timeline's indexed task deltas plus host-owned launch placeholders into the budget ledger. */ +export function refreshOrchestrationUsage( + budget: OrchestrationBudgetService, + timeline: SessionTimelineService, + rootSessionId: string, + rootStartedAt: number, + memberSessionIds: readonly string[], + prices: UsagePrice[] +): OrchestrationBudgetSnapshot { + return refreshOrchestrationUsageBatch(budget,timeline,[{rootSessionId,rootStartedAt,memberSessionIds}],prices).get(rootSessionId)!; +} + +/** Gather every active root's compact usage rows in shared timeline passes before updating its ledger. */ +export function refreshOrchestrationUsageBatch( + budget:OrchestrationBudgetService, + timeline:SessionTimelineService, + scopes:readonly OrchestrationUsageScope[], + prices:UsagePrice[], + onSnapshot?:(scope:OrchestrationUsageScope,snapshot:OrchestrationBudgetSnapshot|undefined)=>void, + beforeRefresh?:(scope:OrchestrationUsageScope)=>void +):Map { + if(scopes.length===0)return new Map(); + const budgetScopes=scopes.filter(scope=>scope.budgetEnabled!==false); + if(budgetScopes.length===0) { + for(const scope of scopes) {beforeRefresh?.(scope);onSnapshot?.(scope,undefined);} + return new Map(); + } + const dataByTask=timeline.taskBudgetUsageByTask(budgetScopes.map(scope=>({taskId:scope.rootSessionId, + sessionIds:[scope.rootSessionId,...scope.memberSessionIds]})),prices); + const snapshots=new Map(); + for(const scope of scopes) { + beforeRefresh?.(scope); + const snapshot=scope.budgetEnabled===false ? undefined : refreshOneTask(budget,timeline,scope,dataByTask.get(scope.rootSessionId)!); + if(snapshot)snapshots.set(scope.rootSessionId,snapshot); + onSnapshot?.(scope,snapshot); + } + return snapshots; +} + +function refreshOneTask( + budget:OrchestrationBudgetService, + timeline:SessionTimelineService, + scope:OrchestrationUsageScope, + data:TimelineTaskBudgetData +):OrchestrationBudgetSnapshot { + const {rootSessionId,rootStartedAt}=scope; + const scopeSessions=data.sessionIds; + const totals=new Map(); + for(const contribution of data.contributions) { + let total=totals.get(contribution.identity); + if(!total){total={tokens:0,costUsd:0,costKnown:true,replaceCounterIds:new Set()};totals.set(contribution.identity,total);} + total.tokens+=contribution.tokens; + if(contribution.costUsd===null)total.costKnown=false;else total.costUsd+=contribution.costUsd; + } + + const sessionsWithUsage=new Set(); + const aliasesByIdentity=new Map>(); + for(const sessionId of scopeSessions)for(const usage of timeline.usageCounters(sessionId)) { + sessionsWithUsage.add(sessionId); + const identity=counterIdentity(sessionId,usage),aliases=aliasesByIdentity.get(identity) ?? new Set(); + aliases.add(hashCounter(sessionId,usage.id)); + aliases.add(hashCounter(sessionId,usage.legacyId ?? sessionId)); + aliasesByIdentity.set(identity,aliases); + } + for(const [identity,total] of totals)for(const alias of aliasesByIdentity.get(identity) ?? [])total.replaceCounterIds.add(alias); + + const pendingIds=[...sessionsWithUsage].map(sessionId=>hashCounter(sessionId,sessionId)); + let first=true; + for(const [identity,total] of totals) { + const replaceCounterIds=[...total.replaceCounterIds,...(first ? pendingIds : [])];first=false; + budget.recordSessionUsage(rootSessionId,hashIdentity(identity),{ + tokens:total.tokens, + costUsd:total.costKnown ? total.costUsd : null + },{rootStartedAt,replaceCounterIds}); + } + for(const sessionId of scopeSessions)if(!sessionsWithUsage.has(sessionId)) { + budget.recordSessionUsage(rootSessionId,hashCounter(sessionId,sessionId),{tokens:null,costUsd:null},{rootStartedAt,provisional:true}); + } + return budget.snapshot(rootSessionId,rootStartedAt); +} + +function counterIdentity(sessionId:string,usage:TimelineUsageCounter):string { + return usage.cumulative && usage.counterId!==undefined + ? JSON.stringify([usage.provider ?? "",usage.accountId ?? "",usage.source,usage.counterId]) + : JSON.stringify([sessionId,usage.source,usage.counterId ?? ""]); +} +function hashCounter(sessionId:string,counterId:string):string { + return createHash("sha256").update(`${sessionId}:${counterId}`).digest("hex"); +} +function hashIdentity(identity:string):string { + return createHash("sha256").update(`task-usage:${identity}`).digest("hex"); +} diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index 927b553a..d873f71f 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -1,3 +1,4 @@ +import { ACCOUNTS_PLUGIN_ID } from "./accountHomeIsolation.ts"; import { createHash, randomUUID } from "node:crypto"; import { gunzipSync } from "node:zlib"; import { @@ -597,6 +598,7 @@ export class PluginManager { pluginName: manifest.name, serviceId: service.id, launch: structuredClone(service.launch), + ...(plugin.manifest.id === ACCOUNTS_PLUGIN_ID ? { dataDir: join(this.dataRoot, plugin.manifest.id) } : {}), secrets: manifest.permissions.includes("secrets") }); } diff --git a/src/main/services/ProcessTreePause.ts b/src/main/services/ProcessTreePause.ts new file mode 100644 index 00000000..893cb527 --- /dev/null +++ b/src/main/services/ProcessTreePause.ts @@ -0,0 +1,178 @@ +import { execFileSync } from "node:child_process"; + +export type ProcessGroupSignal = "SIGSTOP" | "SIGCONT"; +export interface OwnedProcessGroup { + readonly pid: number; +} +export interface ProcessTreePauseResult { + supported: boolean; + failed?: string; +} +export interface ProcessRow { pid: number; ppid: number } + +type SignalGroup = (groupId: number, signal: ProcessGroupSignal) => void; +/** + * Descendant tracking. `list` returns every process with its parent; `signal` delivers to one pid. A process that + * called setsid() (or otherwise left the PTY's process group) is still a descendant by parent chain, so it is found + * here even though the group signal cannot reach it. + */ +export interface ProcessTreeAccess { + list(): ProcessRow[]; + signal(pid: number, signal: ProcessGroupSignal): void; +} + +interface PausedTree { groupId: number; descendants: number[] } + +/** Bounded rescans: a running descendant may fork between one listing and its own SIGSTOP. */ +const MAX_SCAN_ROUNDS = 8; +const MAX_DESCENDANTS = 4096; + +/** + * Suspends a PTY process group whose leader was started and remains owned by this host, plus every process that + * descends from that leader by parent pid at pause time, including ones that moved to their own session. + * node-pty's forkpty child is the process-group/session leader, so a negative pid targets that child's group rather + * than a caller-selected pid. Callers still retain the precise PTY object; a PID alone is never accepted as a target. + * + * Known limit: a process whose parent already exited before the pause was re-parented to init (or a subreaper) and + * has no parent chain to the PTY any more, so it cannot be attributed to the task and keeps running. + */ +export class ProcessTreePause { + private readonly pausedGroups = new WeakMap(); + private readonly signalGroup: SignalGroup; + private readonly tree: ProcessTreeAccess | null; + readonly supported: boolean; + + /** + * With no arguments the real process table and signals are used. An injected `signalGroup` without `tree` + * disables descendant tracking, so a test double can never reach real processes through `ps`. + */ + constructor( + platform = process.platform, + signalGroup?: SignalGroup, + tree?: ProcessTreeAccess | null + ) { + this.supported = platform !== "win32"; + this.signalGroup = signalGroup ?? ((groupId, signal) => process.kill(-groupId, signal)); + this.tree = tree !== undefined ? tree : signalGroup ? null : (this.supported ? systemProcessTree() : null); + } + + isPaused(process: OwnedProcessGroup):boolean {return this.pausedGroups.has(process);} + + pause(process: OwnedProcessGroup): ProcessTreePauseResult { + if (!this.supported) return unsupportedResult(); + const groupId = process.pid; + if (!Number.isSafeInteger(groupId) || groupId <= 0) { + return { supported: true, failed: "The owned PTY did not provide a valid process-group identifier." }; + } + if (this.pausedGroups.has(process)) return { supported: true }; + try { + this.signalGroup(groupId, "SIGSTOP"); + } catch (error) { + if (signalCode(error) !== "ESRCH") { + return { supported: true, failed: `Could not suspend the owned PTY process group (${signalCode(error) ?? "unknown error"}).` }; + } + } + const scan = this.stopDescendants(groupId); + this.pausedGroups.set(process, { groupId, descendants: scan.stopped }); + return { supported: true, ...(scan.failed ? { failed: scan.failed } : {}) }; + } + + resume(process: OwnedProcessGroup): ProcessTreePauseResult { + // No group can be recorded on an unsupported platform, so cleanup is a no-op and must not + // manufacture a failure for every ordinary PTY exit. + if (!this.supported) return { supported: true }; + const paused = this.pausedGroups.get(process); + if (paused === undefined) return { supported: true }; + try { + this.signalGroup(paused.groupId, "SIGCONT"); + } catch (error) { + if (signalCode(error) !== "ESRCH") { + return { supported: true, failed: `Could not resume the owned PTY process group (${signalCode(error) ?? "unknown error"}).` }; + } + } + const remaining: number[] = []; + let failure: string | undefined; + for (const pid of paused.descendants) { + try { this.tree?.signal(pid, "SIGCONT"); } + catch (error) { + if (signalCode(error) === "ESRCH") continue; + remaining.push(pid); + failure = `Could not resume a detached descendant process (${signalCode(error) ?? "unknown error"}).`; + } + } + if (failure) { + // Keep ownership of the descendants that are still stopped so a later resume can retry them. + this.pausedGroups.set(process, { groupId: paused.groupId, descendants: remaining }); + return { supported: true, failed: failure }; + } + this.pausedGroups.delete(process); + return { supported: true }; + } + + /** Stops every descendant of `rootPid` by parent chain, rescanning until a pass finds nothing new. */ + private stopDescendants(rootPid: number): { stopped: number[]; failed?: string } { + if (!this.tree) return { stopped: [] }; + const stopped = new Set(), known = new Set([rootPid]); + const failures = new Set(); + for (let round = 0; round < MAX_SCAN_ROUNDS; round++) { + let rows: ProcessRow[]; + try { rows = this.tree.list(); } + catch { + failures.add("Could not list descendant processes; a process that left the PTY's process group may keep running."); + break; + } + const children = new Map(); + for (const row of rows) { + const list = children.get(row.ppid); + if (list) list.push(row.pid); else children.set(row.ppid, [row.pid]); + } + // Earlier-seen descendants stay roots, so a child whose parent exited between rounds is still reached + // when it was already known. + const queue = [...known]; + let found = false; + while (queue.length) { + const parent = queue.pop()!; + for (const pid of children.get(parent) ?? []) { + if (pid === process.pid || pid <= 1 || known.has(pid)) continue; + known.add(pid); + queue.push(pid); + if (known.size > MAX_DESCENDANTS) break; + try { this.tree.signal(pid, "SIGSTOP"); stopped.add(pid); found = true; } + catch (error) { + if (signalCode(error) !== "ESRCH") failures.add(`Could not suspend a descendant process (${signalCode(error) ?? "unknown error"}).`); + } + } + } + if (!found || known.size > MAX_DESCENDANTS) break; + } + if (known.size > MAX_DESCENDANTS) failures.add("The task has too many descendant processes to suspend individually."); + return { stopped: [...stopped], ...(failures.size ? { failed: [...failures].join(" ") } : {}) }; + } +} + +function systemProcessTree(): ProcessTreeAccess { + return { + list() { + const text = execFileSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8", timeout: 2_000, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }); + const rows: ProcessRow[] = []; + for (const line of text.split("\n")) { + const match = /^\s*(\d+)\s+(\d+)\s*$/u.exec(line); + if (match) rows.push({ pid: Number(match[1]), ppid: Number(match[2]) }); + } + return rows; + }, + signal(pid, signal) { process.kill(pid, signal); } + }; +} + +function signalCode(error: unknown): string | null { + if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") return error.code; + return null; +} + +function unsupportedResult(): ProcessTreePauseResult { + return { + supported: false, + failed: "Windows cannot safely suspend a PTY process tree; existing processes may continue while input and new launches are blocked." + }; +} diff --git a/src/main/services/ProviderUsageSource.ts b/src/main/services/ProviderUsageSource.ts new file mode 100644 index 00000000..6b1aac33 --- /dev/null +++ b/src/main/services/ProviderUsageSource.ts @@ -0,0 +1,201 @@ +import { readdir, realpath } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { Worker } from "node:worker_threads"; + +export interface ProviderUsage { + total: number; + input: number | null; + output: number | null; + model?: string; +} + +interface CachedUsage { signature: string; value: ProviderUsage } +interface WorkerUsageResult { usage?: ProviderUsage | null; signature?: string; unchanged?: boolean } + +const READ_TIMEOUT_MS = 2_000; +const MAX_ROLLOUT_BYTES = 1024 * 1024; +const MAX_ROLLOUT_LINE_CHARS = 256 * 1024; + +/** + * Read only published numeric counters from the local Codex index and rollout. + * Rollout JSON is parsed inside a bounded worker; message and transcript text + * never crosses the worker boundary. + */ +export class ProviderUsageSource { + private readonly home: string; + private readonly pending = new Map>(); + private readonly cache = new Map(); + + constructor(codexHome: string) { this.home = resolve(codexHome); } + + codexUsage(threadId: string): Promise { + if (!/^[\w-]{1,160}$/.test(threadId)) return Promise.resolve(null); + const current = this.pending.get(threadId); + if (current) return current; + + const cached = this.cache.get(threadId); + const read = this.read(threadId, cached?.signature).then((result) => { + if (result?.unchanged && cached) return cached.value; + if (!result?.usage || !result.signature) { + this.cache.delete(threadId); + return null; + } + this.cache.set(threadId, { signature: result.signature, value: result.usage }); + return result.usage; + }).catch(() => null).finally(() => this.pending.delete(threadId)); + this.pending.set(threadId, read); + return read; + } + + /** Compatibility for callers that only consume the CLI's aggregate count. */ + async codexTotal(threadId: string): Promise { + return (await this.codexUsage(threadId))?.total ?? null; + } + + private async read(threadId: string, previousSignature?: string): Promise { + const files = (await readdir(this.home)).filter((name) => /^state_\d+\.sqlite$/.test(name)) + .sort((a, b) => Number(b.match(/\d+/)![0]) - Number(a.match(/\d+/)![0])); + if (!files[0]) return null; + const database = join(this.home, files[0]); + const home = await realpath(this.home); + + return new Promise((resolveResult) => { + const worker = new Worker(CODEX_USAGE_WORKER, { + eval: true, + workerData: { database, home, threadId, previousSignature, maxRolloutBytes: MAX_ROLLOUT_BYTES, maxLineChars: MAX_ROLLOUT_LINE_CHARS } + }); + let finished = false; + const finish = (value: WorkerUsageResult | null): void => { + if (finished) return; + finished = true; + clearTimeout(timer); + void worker.terminate(); + resolveResult(value); + }; + const timer = setTimeout(() => finish(null), READ_TIMEOUT_MS); + worker.once("message", (value: unknown) => finish(isWorkerUsageResult(value) ? value : null)); + worker.once("error", () => finish(null)); + worker.once("exit", () => finish(null)); + }); + } +} + +/** Kept self-contained so every database and rollout operation stays off the main thread. */ +const CODEX_USAGE_WORKER = ` +(() => { + const { parentPort, workerData } = require("node:worker_threads"); + const { DatabaseSync } = require("node:sqlite"); + const fs = require("node:fs"); + const path = require("node:path"); + + function safeCount(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : null; + } + function safeModel(value) { + return typeof value === "string" && value.length > 0 && value.length <= 200 + && !/[\\u0000-\\u001f\\u007f]/u.test(value) ? value.trim() || null : null; + } + function pathInsideHome(file) { + return path.isAbsolute(file) ? path.resolve(file) : path.resolve(workerData.home, file); + } + function readLatestCounters(file) { + const descriptor = fs.openSync(file, "r"); + try { + const size = fs.fstatSync(descriptor).size; + const length = Math.min(size, workerData.maxRolloutBytes); + const buffer = Buffer.alloc(length); + if (length) fs.readSync(descriptor, buffer, 0, length, size - length); + const text = buffer.toString("utf8"); + const lines = text.split(String.fromCharCode(10)); + if (size > length) lines.shift(); // Do not parse a partial JSON record at the range boundary. + let model; + let usage = null; + for (const line of lines) { + if (line.length > workerData.maxLineChars || !line.includes("token_count") && !line.includes("turn_context")) continue; + let event; + try { event = JSON.parse(line); } catch { continue; } + const payload = event && typeof event === "object" ? event.payload : null; + if (event?.type === "turn_context" || payload?.type === "turn_context") { + const context = payload?.type === "turn_context" ? payload : payload ?? event; + model = safeModel(context.model ?? context.model_slug ?? context.modelName ?? event.model) ?? model; + } + if (event?.type !== "event_msg" && event?.type !== "token_count" && payload?.type !== "token_count") continue; + const info = payload?.info ?? event?.info; + const totalUsage = info?.total_token_usage ?? payload?.total_token_usage ?? event?.total_token_usage; + if (!totalUsage || typeof totalUsage !== "object") continue; + const input = safeCount(totalUsage.input_tokens); + const output = safeCount(totalUsage.output_tokens); + const reportedTotal = safeCount(totalUsage.total_tokens); + const total = reportedTotal ?? (input !== null && output !== null ? safeCount(input + output) : null); + if (total !== null) usage = { total, input, output }; + } + return { usage, size, modified: fs.fstatSync(descriptor).mtimeMs, model }; + } finally { fs.closeSync(descriptor); } + } + + let db; + try { + db = new DatabaseSync(workerData.database, { readOnly: true }); + db.exec("PRAGMA query_only = ON; PRAGMA busy_timeout = 500"); + const columns = new Set(db.prepare("PRAGMA table_info(threads)").all().map((row) => row.name)); + if (!columns.has("id")) throw new Error("Codex thread index is unavailable."); + const selected = ["id", "tokens_used", "model", "rollout_path", "updated_at", "updated_at_ms"] + .filter((column) => columns.has(column)).map((column) => '"' + column + '"'); + const row = db.prepare("SELECT " + selected.join(",") + " FROM threads WHERE id = ? LIMIT 1").get(workerData.threadId); + db.close(); + db = null; + if (!row) { parentPort.postMessage(null); return; } + + const databaseTotal = safeCount(row.tokens_used); + let rollout = null; + let fileSignature = ""; + if (typeof row.rollout_path === "string" && row.rollout_path.length <= 4096) { + const candidate = pathInsideHome(row.rollout_path); + if (candidate) { + try { + // Both sides use the OS resolver, as the host's realpath() does: the JavaScript resolver keeps + // Windows 8.3 short names (RUNNER~1) and drive-letter case, so a file inside the home looked outside it. + const realHome = fs.realpathSync.native(workerData.home); + const realFile = fs.realpathSync.native(candidate); + if (realFile.startsWith(realHome + path.sep)) { + const info = fs.statSync(realFile); + fileSignature = JSON.stringify([row.updated_at_ms ?? row.updated_at ?? null, databaseTotal, row.model ?? null, realFile, info.size, info.mtimeMs]); + if (workerData.previousSignature === fileSignature) { + parentPort.postMessage({ unchanged: true, signature: fileSignature }); + return; + } + rollout = readLatestCounters(realFile); + } + } catch { /* The aggregate SQLite counter remains usable if the rollout has moved or is unavailable. */ } + } + } + + const total = rollout?.usage?.total ?? databaseTotal; + if (total === null) { parentPort.postMessage(null); return; } + const model = safeModel(rollout?.model) ?? safeModel(row.model); + const value = { + total, + input: rollout?.usage?.input ?? null, + output: rollout?.usage?.output ?? null, + ...(model ? { model } : {}) + }; + const signature = fileSignature || JSON.stringify([row.updated_at_ms ?? row.updated_at ?? null, databaseTotal, row.model ?? null, row.rollout_path ?? null]); + parentPort.postMessage({ usage: value, signature }); + } catch { + try { db?.close(); } catch { /* The worker has already failed. */ } + parentPort.postMessage(null); + } +})(); +`; + +function isWorkerUsageResult(value: unknown): value is WorkerUsageResult { + if (!value || typeof value !== "object") return false; + const result = value as WorkerUsageResult; + if (result.unchanged === true) return typeof result.signature === "string"; + if (!result.usage || typeof result.signature !== "string") return false; + const { total, input, output, model } = result.usage; + return Number.isSafeInteger(total) && total >= 0 + && (input === null || Number.isSafeInteger(input) && input >= 0) + && (output === null || Number.isSafeInteger(output) && output >= 0) + && (model === undefined || typeof model === "string" && model.length <= 200); +} diff --git a/src/main/services/SessionTimelineIndexWorker.ts b/src/main/services/SessionTimelineIndexWorker.ts new file mode 100644 index 00000000..7453a25b --- /dev/null +++ b/src/main/services/SessionTimelineIndexWorker.ts @@ -0,0 +1,8 @@ +import { parentPort, workerData } from "node:worker_threads"; +import { scanTimelineDirectory } from "./TimelineIndexScan.ts"; + +if (!parentPort || typeof workerData?.directory !== "string") throw new Error("Session timeline index worker requires its journal directory."); +void scanTimelineDirectory(workerData.directory).then( + value => parentPort!.postMessage(value), + error => parentPort!.postMessage({ error: error instanceof Error ? error.message : "Timeline scan failed." }) +); diff --git a/src/main/services/SessionTimelineService.ts b/src/main/services/SessionTimelineService.ts new file mode 100644 index 00000000..c85a9976 --- /dev/null +++ b/src/main/services/SessionTimelineService.ts @@ -0,0 +1,615 @@ +import { randomUUID } from "node:crypto"; +import { appendFile, mkdir, readdir, stat, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; +import { Worker, type WorkerOptions } from "node:worker_threads"; +import { applyTimelineBudgetUsageEvent, budgetCounterIdentity, isTimelineCount, MAX_INDEXED_SEGMENTS, nextTimelineUsage, scanTimelineDirectory, timelineUsageContext, + indexTimelineTaskSession, type BudgetCounterState, type BudgetUsageContribution, type TimelineDirectoryIndex, type TimelineIndexedUsage, type TimelineTaskSessionIndex, + type TimelineUsageContext } from "./TimelineIndexScan.ts"; +import { forEachTimelineEvent } from "./TimelineJournalReader.ts"; +import type { TimelineEvent, UsageSummary, UsageBreakdown, UsagePrice } from "../../shared/backlog.ts"; +import { addTimelineSearchEvent, createTimelineSegmentSearchIndex, timelineSegmentMayMatch, type TimelineSegmentSearchIndex } from "./TimelineSearchIndex.ts"; + +const SEGMENT_BYTES = 2 * 1024 * 1024; +const DEFAULT_MAX_BYTES = 200 * 1024 * 1024; +const MAX_EVENT_CHARS = 16_384; +const MAX_REPORT_EVENTS = 10_000; +export interface UsageContext extends TimelineUsageContext {} +export interface CumulativeUsage { input:number|null; output:number|null; total:number; costUsd?:number|null } +export interface TimelineUsageCounter { + id:string;legacyId?:string;tokens:number|null;costUsd:number|null;inputTokens:number|null;outputTokens:number|null; + provider?:string;model?:string;accountId?:string;taskId?:string;source:string;counterId?:string;cumulative:boolean; + reportedCostUsd:number|null; +} +export interface TimelineTaskBudgetUsage {identity:string;sessionId:string;taskId?:string;tokens:number;costUsd:number|null} +export interface TimelineTaskBudgetScope {taskId:string;sessionIds:readonly string[]} +export interface TimelineTaskBudgetData {sessionIds:string[];contributions:TimelineTaskBudgetUsage[]} +type Usage = TimelineIndexedUsage & { timelineOrder?:number }; +type TimelinePageCandidate = { event: TimelineEvent; index: number }; +type TimelinePageScope={taskId:string;legacySessionIds:string[]}; + +function createTimelineRing(capacity:number):{push(value:T):void;newestFirst():T[]} { + const values:Array=new Array(capacity); + let size=0,next=0; + return { + push(value:T) { + values[next]=value; + next=(next+1)%capacity; + if(size):string {return JSON.stringify([sessionId,row.source,row.counterId ?? ""]);} +function usageSession(key:string):string {return (JSON.parse(key) as string[])[0];} + +function usageSummary(values:Usage[],prices:UsagePrice[]):UsageSummary { + if (!values.length) return { tokens: {input: null, output: null, total: null}, cost: null, currency: "USD", source: null }; + const input = values.every(row=>row.input!==null) ? values.reduce((sum,row)=>sum+row.input!,0) : null; + const output = values.every(row=>row.output!==null) ? values.reduce((sum,row)=>sum+row.output!,0) : null; + const totals=values.map(totalTokens); + const total=totals.every((value):value is number=>value!==null) ? totals.reduce((sum,value)=>sum+value,0) : null; + const costs=values.map(row=>pricedCost(row,prices)); + return { tokens: {input, output, total}, + cost: costs.every((value):value is number=>value!==null) ? costs.reduce((sum,value)=>sum+value,0) : null, + currency: "USD", source: [...new Set(values.map((row) => row.source))].join(", ") }; +} + +function pricedCost(value:Usage,prices:UsagePrice[]):number|null { + if(typeof value.cost==="number" && Number.isFinite(value.cost) && value.cost>=0)return value.cost; + if(value.input===null || value.output===null || !value.provider || !value.model)return null; + const price=priceForUsage(value,prices); + return price ? (value.input*price.inputPerMillion+value.output*price.outputPerMillion)/1_000_000 : null; +} +function priceForUsage(value:Usage,prices:UsagePrice[]):UsagePrice|undefined { + return value.provider && value.model ? prices.find(row=>row.provider===value.provider && row.model===value.model) : undefined; +} +function addKnown(previous:number|null|undefined,current:number|null):number|null { + return current===null ? previous ?? null : (previous ?? 0)+current; +} +function counterDelta(current:number|null,previous:number|null):number|null { + return current===null || previous===null ? null : current>=previous ? current-previous : current; +} + +/** Hook events only: terminal output is never copied into this journal. */ +export class SessionTimelineService { + private queue: Promise = Promise.resolve(); + private segment = ""; + private segmentBytes = 0; + private totalBytes = 0; + private readonly usageBySession = new Map(); + private readonly usageKeysBySession = new Map>(); + private nextUsageOrder=0; + private budgetUsageContributions=new Map(); + private budgetCounterBaselines=new Map(); + private readonly usageListeners = new Set<(sessionId: string) => void>(); + private segmentIndexes = new Map(); + private taskSessionsByTask = new Map>(); + private sessionContextResolver?: (sessionId:string)=>{taskId:string;title:string}|undefined; + readonly directory: string; + private readonly redact: (text:string)=>string; + private readonly maxBytes: number; + private readonly now: ()=>number; + private lastSegmentAt = 0; + private pageCache: {name: string; size: number; mtimeMs: number; events: TimelineEvent[]} | null = null; + private fileListCache: string[] | null = null; + constructor(userDataPath: string, redact: (text: string) => string, + maxBytes = DEFAULT_MAX_BYTES, now = Date.now) { + this.redact=redact;this.maxBytes=maxBytes;this.now=now; + this.directory = join(userDataPath, "session-timeline"); + } + + configureSessionContext(resolver:(sessionId:string)=>{taskId:string;title:string}|undefined):void { + this.sessionContextResolver=resolver; + } + + async load(): Promise { + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + this.fileListCache = null; + this.pageCache = null; + this.totalBytes = await this.prune(); + // Scan journals incrementally on a worker. Startup only receives compact usage + // totals and bounded segment indexes, never hundreds of megabytes of events. + const index = await scanTimelineIndex(this.directory).catch(async error => { + console.warn("CanvasTTY timeline index worker unavailable; using the bounded journal scanner.", error instanceof Error ? error.message : "Worker failed."); + return scanTimelineDirectory(this.directory); + }).catch((error): TimelineDirectoryIndex => { + // Startup must not depend on old journals: without an index, usage totals start empty and pages read + // segments directly, while new events are still recorded. + console.warn("CanvasTTY timeline is degraded: its journal index is unavailable, so earlier usage totals are not shown.", + error instanceof Error ? error.message : "Scan failed."); + return {lastSegmentAt:0,usage:new Map(),budgetUsage:new Map(),budgetCounters:new Map(),segments:new Map(),taskSessions:new Map()}; + }); + this.lastSegmentAt = Math.max(this.lastSegmentAt, index.lastSegmentAt); + this.usageBySession.clear(); + this.usageKeysBySession.clear(); + this.nextUsageOrder=0; + for (const [key, usage] of index.usage) this.storeUsage(key, usageSession(key), usage); + this.budgetUsageContributions=index.budgetUsage; + this.budgetCounterBaselines=index.budgetCounters; + for(const [taskId,sessions] of index.taskSessions) { + for(const [sessionId,descriptor] of sessions) { + if(typeof descriptor.id!=="string" || descriptor.id!==sessionId || descriptor.taskId!==taskId + || typeof descriptor.title!=="string" || typeof descriptor.lastSegment!=="string" || !(descriptor.types instanceof Map)) { + sessions.delete(sessionId); + continue; + } + descriptor.title=this.redact(descriptor.title).slice(0,200); + } + if(!sessions.size)index.taskSessions.delete(taskId); + } + this.taskSessionsByTask=index.taskSessions; + this.segmentIndexes=index.segments; + } + + append(sessionId: string, type: string, summary: string, detail?: string, source?: string): Promise { + let context:{taskId:string;title:string}|undefined; + try { context=this.sessionContextResolver?.(sessionId); } catch { /* A closed or transient card keeps its ordinary session-only event. */ } + const taskId=context && typeof context.taskId==="string" && context.taskId.length>0 && context.taskId.length<=200 + ? context.taskId : undefined; + const sessionTitle=context && typeof context.title==="string" && context.title.length>0 + ? this.redact(context.title).slice(0,200) : undefined; + const event: TimelineEvent = { + id: randomUUID(), sessionId, at: this.now(), type, + summary: this.redact(summary).slice(0, 1000), + ...(detail !== undefined ? { detail: this.redact(detail).slice(0, MAX_EVENT_CHARS) } : {}), + ...(source ? { source: this.redact(source).slice(0, 100) } : {}), + ...(taskId ? {taskId} : {}), + ...(taskId && sessionTitle ? {sessionTitle} : {}) + }; + this.applyUsageEvent(event); + applyTimelineBudgetUsageEvent(this.budgetUsageContributions,this.budgetCounterBaselines,event); + const work = this.queue.then(async () => { + const line = JSON.stringify(event) + "\n"; + const bytes = Buffer.byteLength(line); + if (!this.segment || this.segmentBytes + bytes > Math.min(SEGMENT_BYTES, this.maxBytes)) { + this.lastSegmentAt=Math.max(this.now(),this.lastSegmentAt+1); + this.segment = `${String(this.lastSegmentAt).padStart(16, "0")}-${randomUUID()}.ndjson`; + this.segmentBytes = 0; + this.fileListCache = null; + } + await appendFile(join(this.directory, this.segment), line, { mode: 0o600 }); + this.pageCache = null; + this.segmentBytes += bytes; + this.totalBytes += bytes; + let index = this.segmentIndexes.get(this.segment); + if (!index) { + index = createTimelineSegmentSearchIndex(); + this.segmentIndexes.set(this.segment, index); + while (this.segmentIndexes.size > MAX_INDEXED_SEGMENTS) this.segmentIndexes.delete(this.segmentIndexes.keys().next().value!); + } + addTimelineSearchEvent(index, event); + index.size += bytes; + indexTimelineTaskSession(this.taskSessionsByTask,event,this.segment); + if (this.totalBytes > this.maxBytes) this.totalBytes = await this.prune(); + }); + this.queue = work.catch(() => undefined); + return work; + } + + /** Usage is accepted only from an identified real provider source, never synthesized from limits. */ + recordUsage(sessionId: string, input: number, output: number, source: string, cost?: number, context:UsageContext={}): Promise { + if (!isTimelineCount(input) || !isTimelineCount(output) + || !source || cost !== undefined && (!Number.isFinite(cost) || cost < 0)) { + return Promise.reject(new Error("Invalid provider usage record.")); + } + return this.append(sessionId, "usage", "Provider usage", JSON.stringify({ input, output, cost: cost ?? null, source,...timelineUsageContext(context) }), source); + } + + recordTokenTotal(sessionId:string,total:number,source:string,counterId?:string,context:UsageContext={}):Promise { + return this.recordCumulativeUsage(sessionId,{input:null,output:null,total},source,counterId,context); + } + + /** + * Replace a provider's cumulative counter; repeated lifecycle signals never add the same tokens twice. `resumed` + * says the card resumed this conversation: when the journal holds no earlier sample of its counter, this one is + * stored as a baseline, so usage from before the resume is not counted as this period's or this task's. + */ + recordCumulativeUsage(sessionId:string,usage:CumulativeUsage,source:string,counterId?:string,context:UsageContext={},options:{resumed?:boolean}={}):Promise { + const {input,output,total,costUsd}=usage; + if(!isTimelineCount(total) || input!==null && !isTimelineCount(input) || output!==null && !isTimelineCount(output) + || !source || costUsd!==undefined && costUsd!==null && (!Number.isFinite(costUsd) || costUsd<0) + || counterId!==undefined && (typeof counterId!=="string" || counterId.length>200))return Promise.reject(new Error("Invalid provider token counter.")); + const previous=this.usageBySession.get(usageKey(sessionId,{source,counterId})); + const cost=costUsd ?? null,normalizedContext=timelineUsageContext(context); + if(previous?.cumulative && previous.input===input && previous.output===output && previous.total===total + && previous.cost===cost && previous.source===source && previous.counterId===counterId + && Object.keys(normalizedContext).every(key=>previous[key as keyof UsageContext]===normalizedContext[key as keyof UsageContext]))return Promise.resolve(); + const baseline=options.resumed===true + && !this.budgetCounterBaselines.has(budgetCounterIdentity(sessionId,{input,output,total,cost,source,...(counterId!==undefined ? {counterId} : {}),...normalizedContext})); + return this.append(sessionId,"usage","CLI cumulative token counter",JSON.stringify({input,output,total,cost,source,cumulative:true,...(counterId ? {counterId} : {}),...normalizedContext,...(baseline ? {baseline:true} : {})}),source); + } + + async page(sessionId: string, cursor?: string, limit = 100,filter?:{query?:string;types?:string[];sessionIds?:string[]},scope?:TimelinePageScope): Promise<{items: TimelineEvent[]; nextCursor: string | null}> { + await this.queue; + const take = Math.min(500, Math.max(1, Math.floor(limit) || 100)); + const result: TimelineEvent[] = []; + const ids=new Set(filter?.sessionIds ?? [sessionId]),types=filter?.types?.length ? new Set(filter.types) : null,needle=filter?.query?.toLocaleLowerCase(); + const legacySessionIds=scope ? new Set(scope.legacySessionIds) : null; + const matches=(event:TimelineEvent):boolean=>ids.has(event.sessionId) + && (!types || types.has(event.type)) + && (!needle || `${event.summary}\n${event.detail ?? ""}`.toLocaleLowerCase().includes(needle)) + && (!scope || event.taskId===scope.taskId || event.taskId===undefined && legacySessionIds!.has(event.sessionId)); + const position = cursor?.startsWith("v1:") ? /^v1:(\d{16}-[\w-]+\.ndjson):(\d+)$/u.exec(cursor) : null; + if (cursor?.startsWith("v1:") && (!position || !Number.isSafeInteger(Number(position[2])))) throw new Error("Invalid timeline cursor."); + let passed = !cursor || Boolean(position); + let lastPosition = ""; + for (const name of [...(await this.files())].reverse()) { + if (position && name > position[1]) continue; + if (position && name === position[1] && Number(position[2]) === 0) continue; + const cachedPageSegment=this.pageCache?.name===name ? this.pageCache : null; + const recordedSegmentIndex=this.segmentIndexes.get(name); + const metadata=await stat(join(this.directory,name)); + if(cachedPageSegment + && (cachedPageSegment.size!==metadata.size || cachedPageSegment.mtimeMs!==metadata.mtimeMs))this.pageCache=null; + if(recordedSegmentIndex + && (recordedSegmentIndex.size!==metadata.size + || recordedSegmentIndex.mtimeMs!==0 && recordedSegmentIndex.mtimeMs!==metadata.mtimeMs)) { + this.segmentIndexes.delete(name); + if(this.segment===name){this.segment="";this.segmentBytes=0;} + } + const segmentIndex = this.segmentIndexes.get(name); + if (passed && segmentIndex && !timelineSegmentMayMatch(segmentIndex, ids, types, needle)) continue; + const segmentSize=metadata.size; + if(segmentSize>SEGMENT_BYTES) { + const capacity=take-result.length+1; + if(!passed) { + // Legacy ID cursors resolve to the newest matching ID in the newest segment + // that contains it, as the old reverse array walk did. Find that valid-row + // ordinal first so duplicate IDs need no page-sized snapshots while scanning. + let validIndex=0,latestCursorIndex:number|null=null; + await this.scan(name,event=>{ + if(event.id===cursor)latestCursorIndex=validIndex; + validIndex++; + }); + if(latestCursorIndex===null)continue; + passed=true; + const window=await this.scanPageWindow(name,capacity,latestCursorIndex,matches); + for(const candidate of window.candidates) { + if(result.length===take)return {items:result,nextCursor:lastPosition}; + result.push(candidate.event); + lastPosition=`v1:${name}:${candidate.index}`; + } + continue; + } + const cursorIndex=position && name===position[1] ? Number(position[2]) : undefined; + const window=await this.scanPageWindow(name,capacity,cursorIndex,matches); + if(cursorIndex!==undefined && cursorIndex>=window.validCount)throw new Error("Invalid timeline cursor."); + for(const candidate of window.candidates) { + if(result.length===take)return {items:result,nextCursor:lastPosition}; + result.push(candidate.event); + lastPosition=`v1:${name}:${candidate.index}`; + } + continue; + } + const events = await this.readPageSegment(name); + if (position && name === position[1] && Number(position[2]) >= events.length) throw new Error("Invalid timeline cursor."); + const start = position && name === position[1] ? Number(position[2]) - 1 : events.length - 1; + for (let index = start; index >= 0; index--) { + const event = events[index]; + if (!passed) { if (event.id === cursor) passed = true; continue; } + if (!matches(event)) continue; + if (result.length === take) return { items: result, nextCursor: lastPosition }; + result.push(event); + lastPosition = `v1:${name}:${index}`; + } + } + return { items: result, nextCursor: null }; + } + + usage(sessionIds?: string[],prices:UsagePrice[]=[]): UsageSummary { + const scope=sessionIds ? new Set(sessionIds) : null; + const values:Usage[]=scope ? [] : [...this.usageBySession.values()]; + if (scope) { + for(const sessionId of scope)for(const key of this.usageKeysBySession.get(sessionId) ?? []) { + const value=this.usageBySession.get(key);if(value)values.push(value); + } + if(scope.size>1)values.sort((left,right)=>(left.timelineOrder ?? 0)-(right.timelineOrder ?? 0)); + } + return usageSummary(values,prices); + } + + usageCounters(sessionId:string,prices:UsagePrice[]=[]):TimelineUsageCounter[] { + return [...(this.usageKeysBySession.get(sessionId) ?? [])].map(key=>{ + const value=this.usageBySession.get(key)!; + return {id:key,legacyId:value.counterId ?? sessionId,tokens:totalTokens(value),costUsd:pricedCost(value,prices), + inputTokens:value.input,outputTokens:value.output,provider:value.provider,model:value.model,accountId:value.accountId,taskId:value.taskId, + source:value.source,counterId:value.counterId,cumulative:value.cumulative===true,reportedCostUsd:value.cost}; + }); + } + + /** One traversal supplies all active task roots while preserving legacy session-only rows. */ + taskBudgetUsageByTask(scopes:readonly TimelineTaskBudgetScope[],prices:UsagePrice[]=[]):Map { + const sessionsByTask=new Map>(),rowsByTask=new Map>(); + for(const scope of scopes) { + let sessions=sessionsByTask.get(scope.taskId); + if(!sessions){sessions=new Set();sessionsByTask.set(scope.taskId,sessions);rowsByTask.set(scope.taskId,new Map());} + for(const sessionId of scope.sessionIds)sessions.add(sessionId); + } + const taskIds=new Set(sessionsByTask.keys()); + // Match sessionIds(taskId)'s historical ordering: explicit current members first, + // then usage-bearing sessions in usage map insertion order. + for(const [key,value] of this.usageBySession) { + const sessionId=usageSession(key); + if(taskIds.has(sessionId))sessionsByTask.get(sessionId)!.add(sessionId); + if(value.taskId!==undefined && taskIds.has(value.taskId))sessionsByTask.get(value.taskId)!.add(sessionId); + } + const legacyTasksBySession=new Map(); + for(const [taskId,sessions] of sessionsByTask)for(const sessionId of sessions) { + let tasks=legacyTasksBySession.get(sessionId); + if(!tasks){tasks=[];legacyTasksBySession.set(sessionId,tasks);} + tasks.push(taskId); + } + const add=(taskId:string,contribution:BudgetUsageContribution):void=>{ + const rows=rowsByTask.get(taskId)!; + const key=JSON.stringify([contribution.sessionId,contribution.identity,contribution.taskId ?? ""]); + let row=rows.get(key); + if(!row){row={identity:contribution.identity,sessionId:contribution.sessionId,...(contribution.taskId ? {taskId:contribution.taskId} : {}),tokens:0,costUsd:0,costKnown:true};rows.set(key,row);} + row.tokens+=contribution.tokens; + const cost=contribution.mode==="covered" ? 0 + : contribution.mode==="reported" || contribution.mode==="reported-base" ? contribution.reportedCostUsd + : contribution.input!==null && contribution.output!==null && contribution.provider && contribution.model + ? (()=>{const price=prices.find(item=>item.provider===contribution.provider && item.model===contribution.model);return price ? (contribution.input!*price.inputPerMillion+contribution.output!*price.outputPerMillion)/1_000_000 : null;})() + : null; + if(cost===null)row.costKnown=false;else row.costUsd+=cost; + }; + for(const contribution of this.budgetUsageContributions.values()) { + if(contribution.taskId!==undefined) { + if(taskIds.has(contribution.taskId))add(contribution.taskId,contribution); + } else for(const taskId of legacyTasksBySession.get(contribution.sessionId) ?? [])add(taskId,contribution); + } + const result=new Map(); + for(const [taskId,sessions] of sessionsByTask) { + const rows=rowsByTask.get(taskId)!; + result.set(taskId,{sessionIds:[...sessions],contributions:[...rows.values()].map(row=>({identity:row.identity,sessionId:row.sessionId, + ...(row.taskId ? {taskId:row.taskId} : {}),tokens:row.tokens,costUsd:row.costKnown ? row.costUsd : null}))}); + } + return result; + } + + /** Latest sessions with real usage, including closed members whose counters still belong to a task. */ + sessionIds(taskId?:string):string[] { + return [...new Set([...this.usageBySession].filter(([key,value])=>taskId===undefined || usageSession(key)===taskId || value.taskId===taskId).map(([key])=>usageSession(key)))]; + } + + /** Session and event-type facets derived only from retained, host-stamped journal rows. */ + taskSessions(taskId:string):Array<{id:string;title:string;types:string[]}> { + return [...(this.taskSessionsByTask.get(taskId)?.values() ?? [])] + .map(row=>({id:row.id,title:this.redact(row.title).slice(0,200),types:[...row.types.keys()].sort()})) + .sort((left,right)=>left.id.localeCompare(right.id)); + } + + /** Periods measure observed increases; when no baseline was ever persisted, the first sample is the available real counter. */ + async breakdown(period:"all"|"day"|"week",taskId?:string,prices:UsagePrice[]=[]):Promise { + await this.queue; + const cutoff=period==="all" ? 0 : this.now()-(period==="day" ? 86400_000 : 7*86400_000); + const counters=new Map(); + const rows=new Map(); + for(const file of await this.files())await this.scan(file,event=>{ + if(event.type!=="usage" || !event.detail)return; + let value:Usage;try{value=JSON.parse(event.detail);}catch{return;} + if(typeof value.source!=="string")return; + // A resumed provider conversation can move to a new CanvasTTY card: share its baseline by counter identity, + // while keeping the displayed delta attributed to the card that produced it. + const counterKey=value.counterId===undefined + ? JSON.stringify([event.sessionId,value.source]) + : JSON.stringify([value.provider ?? "",value.accountId ?? "",value.source,value.counterId]); + const rowKey=JSON.stringify([event.sessionId,counterKey]); + const base:UsageBreakdown={sessionId:event.sessionId,provider:value.provider ?? null,model:value.model ?? null,accountId:value.accountId ?? null,taskId:value.taskId ?? null, + tokens:{input:null,output:null,total:null},costUsd:null,costSource:null,source:value.source,period}; + if(value.cumulative===true) { + if(!isTimelineCount(value.total))return; + const current={input:isTimelineCount(value.input)?value.input:null,output:isTimelineCount(value.output)?value.output:null,total:value.total, + cost:typeof value.cost==="number" && Number.isFinite(value.cost) && value.cost>=0 ? value.cost : null}; + const previous=counters.get(counterKey);counters.set(counterKey,current); + if(event.at=0; + const cost=pricedCost(value,prices); + rows.set(rowKey,{...base,tokens:{input,output,total:input+output},costUsd:cost!==null && (!previousRow || previousRow.costUsd!==null) ? (previousRow?.costUsd ?? 0)+cost : null, + costSource:reported ? "reported" : price ? "human-price" : null}); + }); + return [...rows.values()]; + } + + async report(sessionId: string): Promise { + await this.queue; + const events: TimelineEvent[] = []; + let truncated = false; + for (const name of [...(await this.files())].reverse()) { + const capacity=MAX_REPORT_EVENTS-events.length; + const recent=createTimelineRing(capacity); + let seen=0,overflow=false; + await this.scan(name,event=>{ + if(event.sessionId!==sessionId)return; + if(capacity===0){truncated=true;return true;} + if(seen++===capacity)overflow=true; + recent.push(event); + }); + events.push(...recent.newestFirst()); + if(overflow)truncated=true; + if(truncated)break; + } + const sections: Array<[string, string[]]> = [ + ["Turns", ["lifecycle", "status"]], ["Tools, commands and files", ["tool", "command", "file"]], + ["Protection and isolation", ["decision", "isolation", "git-risk", "budget", "network-policy"]], + ["Human decisions", ["human", "secret-request", "secret-decision", "secret-revoked", "checkpoint"]] + ]; + const lines = ["# CanvasTTY session report", "", `Session: ${sessionId}`, "", + "Network visibility is incomplete: only addresses explicitly reported by hooks are known.", ""]; + for (const [title, types] of sections) { + lines.push(`## ${title}`, ""); + const matching = events.filter((event) => types.includes(event.type)).reverse(); + lines.push(...(matching.length ? matching.map((event) => `- ${new Date(event.at).toISOString()} ${event.summary}${event.detail ? ` — ${event.detail}` : ""}`) : ["No events were reported by this provider."]), ""); + } + if (truncated) lines.push("Report limited to the latest 10,000 events."); + return this.redact(lines.join("\n")); + } + + async flush(): Promise { await this.queue; } + subscribeUsage(listener: (sessionId: string) => void): () => void { + this.usageListeners.add(listener); + return () => this.usageListeners.delete(listener); + } + private applyUsageEvent(event: TimelineEvent): void { + const update = nextTimelineUsage(this.usageBySession, event); + if (!update) return; + const previous = this.usageBySession.get(update.key); + this.storeUsage(update.key, event.sessionId, update.value); + if (usageCountersChanged(previous, update.value)) this.notifyUsageChanged(event.sessionId); + } + private storeUsage(key:string,sessionId:string,value:Usage):void { + const previous=this.usageBySession.get(key); + this.usageBySession.set(key,{...value,timelineOrder:previous?.timelineOrder ?? this.nextUsageOrder++}); + let keys=this.usageKeysBySession.get(sessionId); + if(!keys){keys=new Set();this.usageKeysBySession.set(sessionId,keys);} + keys.add(key); + } + private notifyUsageChanged(sessionId:string):void { + for(const listener of this.usageListeners)try{listener(sessionId);}catch{ /* A refresh listener cannot break timeline persistence. */ } + } + private async files(): Promise { + if (this.fileListCache) return this.fileListCache; + this.fileListCache = (await readdir(this.directory)).filter((name) => /^\d{16}-[\w-]+\.ndjson$/.test(name)).sort(); + return this.fileListCache; + } + private async read(name: string): Promise { + const events: TimelineEvent[] = []; + await this.scan(name, event => { events.push(event); }); + return events; + } + private scan(name:string,visit:(event:TimelineEvent)=>boolean|void):Promise { + return forEachTimelineEvent(join(this.directory,name),visit); + } + private async scanPageWindow(name:string,capacity:number,beforeIndex:number|undefined, + matches:(event:TimelineEvent)=>boolean):Promise<{validCount:number;candidates:TimelinePageCandidate[]}> { + const ring=createTimelineRing(capacity); + let validCount=0; + await this.scan(name,event=>{ + const index=validCount++; + if((beforeIndex===undefined || index { + const cached = this.pageCache; + if (cached?.name === name) return cached.events; + const metadata = await stat(join(this.directory, name)); + const events = await this.read(name); + // Keep only one bounded segment. Large imported journals still page without retaining a large cache. + this.pageCache = metadata.size <= SEGMENT_BYTES ? {name, size:metadata.size, mtimeMs:metadata.mtimeMs, events} : null; + return events; + } + private async prune(): Promise { + const files = await this.files(); + const sizes = await Promise.all(files.map((name) => stat(join(this.directory, name)).then((s) => s.size))); + let total = sizes.reduce((sum, size) => sum + size, 0); + for (let i = 0; i < files.length && total > this.maxBytes; i++) { + await unlink(join(this.directory, files[i])); total -= sizes[i]; + this.fileListCache = null; + if (this.pageCache?.name === files[i]) this.pageCache = null; + this.segmentIndexes.delete(files[i]); + this.removeTaskSessionSegment(files[i]); + if (files[i] === this.segment) { this.segment = ""; this.segmentBytes = 0; } + } + return total; + } + + private removeTaskSessionSegment(segment:string):void { + for(const [taskId,sessions] of this.taskSessionsByTask) { + for(const [sessionId,descriptor] of sessions) { + for(const [type,lastSegment] of descriptor.types)if(lastSegment===segment)descriptor.types.delete(type); + // Pruning removes segments oldest-first, so no retained row exists for this actor when its latest segment is removed. + if(descriptor.lastSegment===segment)sessions.delete(sessionId); + } + if(!sessions.size)this.taskSessionsByTask.delete(taskId); + } + } +} + +function scanTimelineIndex(directory: string): Promise { + const built = fileURLToPath(new URL("./SessionTimelineIndexWorker.js", import.meta.url)); + const source = fileURLToPath(new URL("./SessionTimelineIndexWorker.ts", import.meta.url)); + const path = existsSync(built) ? built : existsSync(source) ? source : null; + if (!path) return Promise.reject(new Error("Session timeline index worker entry is missing.")); + const options: WorkerOptions = path.endsWith(".ts") ? { execArgv: ["--experimental-strip-types"] } : {}; + return new Promise((resolveResult, rejectResult) => { + const worker = new Worker(path, { ...options, workerData: { directory } }); + let finished = false; + const finish = (error: Error | null, value?: unknown): void => { + if (finished) return; + finished = true; + void worker.terminate(); + if (error) rejectResult(error); + else if (!isTimelineDirectoryIndex(value)) { + rejectResult(new Error("Session timeline index worker returned invalid data.")); + } else resolveResult(value); + }; + worker.once("message", (value: unknown) => { + if (!value || typeof value !== "object" || "error" in value) { + const message = value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : "Session timeline index worker failed."; + finish(new Error(message)); + return; + } + finish(null, value); + }); + worker.once("error", error => finish(error instanceof Error ? error : new Error("Session timeline index worker failed."))); + worker.once("exit", code => { + if (!finished && code !== 0) finish(new Error(`Session timeline index worker exited (${code}).`)); + else if (!finished) finish(new Error("Session timeline index worker stopped before returning its index.")); + }); + }); +} + +function isTimelineDirectoryIndex(value: unknown): value is TimelineDirectoryIndex { + if (!value || typeof value !== "object") return false; + const index=value as Partial; + return Number.isSafeInteger(index.lastSegmentAt) && index.usage instanceof Map && index.budgetUsage instanceof Map + && index.budgetCounters instanceof Map && index.segments instanceof Map && index.taskSessions instanceof Map; +} diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 157be296..52d04ed2 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -1,3 +1,4 @@ +import { constants as osConstants } from "node:os"; import { randomUUID } from "node:crypto"; import { realpathSync, statSync } from "node:fs"; import { basename, dirname } from "node:path"; @@ -69,6 +70,7 @@ import { auditRepositories, neutralizeRepositories, type GitRiskRepository } fro import { LaunchRefusal } from "./launchRefusal.ts"; import { configuredMode } from "./configuredMode.ts"; import { envKey, RESERVED_ENV, type LaunchPipeline, type PreparedLaunch } from "./LaunchPipeline.ts"; +import { ACCOUNTS_PLUGIN_ID, selectedAccountId } from "./accountHomeIsolation.ts"; import type { EnvironmentRegistry } from "./EnvironmentRegistry.ts"; import { persistedTerminalSession, @@ -84,6 +86,8 @@ import { initialSessionStatus, type ProviderLifecycleParser } from "./providerLifecycle.ts"; +import { ProcessTreePause, type ProcessTreePauseResult } from "./ProcessTreePause.ts"; +import { isHostOwnedDiffOnlyReviewWorkspace, type DiffOnlyReviewWorkspace } from "./DiffOnlyReviewWorkspace.ts"; const MAX_SCROLLBACK_CHARS = 240_000; const OUTPUT_BATCH_MS = 16; @@ -101,6 +105,8 @@ const MIN_TERMINAL_SIZE = { width: 420, height: 260 }; const MAX_TERMINAL_SIZE = { width: 1_600, height: 1_100 }; interface ManagedSession { + accountId?:string; + accountHome?:string; metadata: SessionMetadata; process: IPty | null; cols: number; @@ -141,6 +147,8 @@ interface ManagedSession { environmentReady: boolean; /** Bumped by every launch the person or the app asks for (create, restart, restore); input waits for one. */ launchEpoch: number; + /** Initial measured-grid launch was requested while its task budget was paused. */ + budgetDeferredLaunch?: boolean; /** Input waiting for this launch to start (deliverInput): woken whenever the launch moves on. */ launchWaiters: Set<() => void>; /** Brought back from the saved sessions at startup (plugins see a "restored" event, not "created"). */ @@ -151,6 +159,16 @@ interface ManagedSession { hookSignals?: number; /** Set after the person answered a hooked Claude prompt; see settleAnsweredPrompt. */ answeredPromptTimer?: ReturnType; + /** Removed when a transient diff-only review PTY exits or its card is disposed. */ + reviewWorkspace?: DiffOnlyReviewWorkspace; +} + +interface ReadOnlyReviewerLaunch { + taskRootSessionId: string; + workspace: DiffOnlyReviewWorkspace; + deniedReadPaths: string[]; + /** A worker's model account, prepared for this reviewer before its card exists (prepareReviewerAccount). */ + account?: { id: string; contribution: LaunchContribution }; } type EnvironmentService = Pick(); + private readonly budgetResumeWaiters = new Map void>>(); + /** + * The conversation each card's latest launch resumed: its id, or null until the provider reports which one a + * "resume latest" launch picked. Usage counted before the resume belongs to earlier runs, not to this card. + */ + private readonly resumedThreads = new Map(); // Renderer-reported card visibility, keyed by session and holding the // outputOffset at the moment it was hidden: the last offset the card saw. // Output keeps flowing through emit while hidden, addressed to the observers @@ -272,8 +298,12 @@ export class TerminalManager { private readonly gitRisks = new Map(); /** When isolated launches started whose card did not exist yet (see wrapIsolated). */ private readonly isolationStarts = new Map(); + /** Host-owned platform restriction found while wrapping a launch, before its card may exist. */ + private readonly pendingIsolationReasons = new Map(); // Plugin owners of cards being created (before the card exists), so their first launch counts as delegated. private readonly startingOwners = new Map(); + /** Reviewer-only launch authority is keyed by the exact host-created request object. */ + private readonly readOnlyReviewerRequests = new WeakMap(); // What a card's first launch found in its CLI's own configuration, before the card exists. private readonly pendingConfiguredModes = new Map(); // Whether the person acknowledged YOLO for a CLI (Settings: acknowledgedDangerousProfiles); unset allows it. @@ -282,6 +312,7 @@ export class TerminalManager { // the main process reads it from its emit callback to tell a failure that is // merely re-derived state from one the user just caused. private emittingFailureOrigin: FailureOrigin | null = null; + private inputGate: ((id:string)=>void) | null = null; constructor( emit: Emit, @@ -289,13 +320,15 @@ export class TerminalManager { agentBrowser?: AgentBrowserLaunchCoordinator, agentRuntime?: AgentRuntimeLaunchCoordinator, lifecycleHooksEnabled = true, - spawnPty: typeof pty.spawn = pty.spawn + spawnPty: typeof pty.spawn = pty.spawn, + processTreePause = new ProcessTreePause() ) { this.emit = emit; this.providerClis = providerClis; this.agentBrowser = agentBrowser; this.agentRuntime = agentRuntime; this.spawnPty = spawnPty; + this.processTreePause = processTreePause; this.lifecycleHooksEnabled = lifecycleHooksEnabled; } @@ -341,7 +374,222 @@ export class TerminalManager { configureRedaction(registry: SecretRedactionRegistry): void { this.redaction = registry; } + configureInputGate(gate: ((id:string)=>void) | null): void {this.inputGate=gate;} + /** + * Host-only entry point for an automatic reviewer. Its launch authority is tied to an unforgeable temporary + * workspace object and never becomes a field on the renderer/control request schema. + */ + /** + * Prepares the worker's model account (only the Accounts plugin's delegable options) for a diff-only reviewer, so + * the reviewer runs on the same key and model when the agent has no sign-in of its own. The reviewer still gets no + * agent or plugin tools; only the account's environment, arguments and run files reach its launch. + */ + async prepareReviewerAccount(input: { + taskRootSessionId: string; + provider: ProviderId; + workspace: DiffOnlyReviewWorkspace; + launchOptions: NonNullable; + }): Promise<{ id: string; contribution: LaunchContribution }> { + const pipeline = this.launchPipeline; + if (!pipeline) throw new LaunchRefusal("The model account of the worker is not available for its reviewer."); + const accountOnly = Object.fromEntries(Object.entries(input.launchOptions).filter(([pluginId]) => pluginId === ACCOUNTS_PLUGIN_ID)); + const options = pipeline.normalizeOptions(input.provider, accountOnly, { delegated: true }); + if (!options) throw new LaunchRefusal("The worker has no model account for its reviewer."); + const id = randomUUID(); + const prepared = await pipeline.prepare({ + sessionId: id, + provider: input.provider, + profile: "plan", + role: "subagent", + cwd: realpathSync(input.workspace.directory), + projectRoot: this.taskScopeFor(input.taskRootSessionId).cwd, + parentSessionId: input.taskRootSessionId, + restoring: false, + resume: false, + options: structuredClone(options) as Record>, + environment: null + }); + if (!prepared.ok) throw new LaunchRefusal(prepared.reason); + return { id, contribution: prepared }; + } + + createReadOnlyReviewer(input: { + taskRootSessionId: string; + provider: ProviderId; + /** The reviewer's model; omitted when the worker's model account (prepareReviewerAccount) decides it. */ + model?: string; + account?: { id: string; contribution: LaunchContribution }; + title: string; + workspace: DiffOnlyReviewWorkspace; + }): SessionSnapshot { + if (input.provider === "terminal" || (!input.model && !input.account) || !isHostOwnedDiffOnlyReviewWorkspace(input.workspace)) { + throw new LaunchRefusal("The diff-only reviewer workspace is unavailable; the reviewer was not started."); + } + const parent = this.sessions.get(input.taskRootSessionId); + if (!parent) throw new LaunchRefusal("The review task ended before its isolated reviewer could start."); + const cwd = realpathSync(input.workspace.directory); + if (cwd !== input.workspace.directory) throw new LaunchRefusal("The diff-only reviewer workspace changed before launch."); + + const denied = new Set(); + const addDirectory = (candidate: string): void => { + let real: string; + try { + real = realpathSync(candidate); + if (!statSync(real).isDirectory()) throw new Error("not a directory"); + } catch { + throw new LaunchRefusal("A project or session root could not be verified for diff-only review."); + } + if (isPathInside(cwd, real) || isPathInside(real, cwd)) { + throw new LaunchRefusal("The diff-only reviewer workspace overlaps a project or session root."); + } + denied.add(real); + }; + const taskRoot = this.taskScopeFor(input.taskRootSessionId); + addDirectory(taskRoot.cwd); + for (const session of this.sessions.values()) { + addDirectory(session.metadata.cwd); + addDirectory(session.metadata.taskScope?.cwd ?? this.taskScopeFor(session.metadata.id).cwd); + } + if (denied.size === 0) throw new LaunchRefusal("No project roots were available to isolate the diff-only reviewer."); + + const request: CreateSessionRequest = { + provider: input.provider, + cwd, + profile: "plan", + title: input.title.slice(0, 80), + ...(input.model ? { model: input.model } : {}), + position: parent.metadata.position, + role: "subagent", + parentSessionId: input.taskRootSessionId + }; + this.readOnlyReviewerRequests.set(request, { + taskRootSessionId: input.taskRootSessionId, + workspace: input.workspace, + deniedReadPaths: [...denied], + ...(input.account ? { account: input.account } : {}) + }); + try { + return this.create(request, { origin: "subagent", continueTaskFrom: input.taskRootSessionId }); + } catch (error) { + this.readOnlyReviewerRequests.delete(request); + throw error; + } + } + + /** + * The model account this card was launched on (the Accounts plugin's selected account), or undefined when it runs on + * its CLI's own sign-in. A subagent inherits it unless its spawn names an account (AgentControlService). + */ + modelAccountOf(id: string): string | undefined { + const options = this.sessions.get(id)?.extras.options; + const account = selectedAccountId(options); + return account === "default" ? undefined : account; + } + + setTaskMetadata(id: string, patch: Pick): void { + const session=this.sessions.get(id);if (!session) return; + if (patch.reviewRequested !== undefined) session.metadata.reviewRequested=patch.reviewRequested; + this.schedulePersistence(); + this.emitSession(session.metadata); + } + setTaskBudget(id:string,value:SessionMetadata["taskBudget"]):void { + const session=this.sessions.get(id);if(!session || JSON.stringify(session.metadata.taskBudget)===JSON.stringify(value))return; + if(value)session.metadata.taskBudget={...value};else delete session.metadata.taskBudget; + this.emitSession(session.metadata); + } + + /** Host-only process suspension; Windows explicitly reports that an active process tree cannot be paused. */ + setBudgetPaused(taskRootId:string,paused:boolean):ProcessTreePauseResult { + if(paused)this.budgetPausedTaskRoots.add(taskRootId); + if(!paused && !this.processTreePause.supported) { + this.budgetPausedTaskRoots.delete(taskRootId); + this.wakeBudgetResumeWaiters(taskRootId); + this.launchDeferredBudgetSessions(taskRootId); + return {supported:true}; + } + const failures:string[]=[]; + let supported=!paused || this.processTreePause.supported; + const resumedGroups:IPty[]=[]; + for(const session of this.sessions.values()) { + if(this.taskScopeFor(session.metadata.id).id!==taskRootId || !session.process)continue; + const wasPaused=this.processTreePause.isPaused(session.process); + const result=paused ? this.processTreePause.pause(session.process) : this.processTreePause.resume(session.process); + supported=supported && result.supported; + if(result.failed)failures.push(result.failed); + else if(!paused && wasPaused)resumedGroups.push(session.process); + } + if(paused) { + if(!supported && !failures.length)failures.push("The platform cannot safely suspend an active PTY process tree."); + } else if(failures.length) { + // A failed SIGCONT must not make a stopped task appear runnable. Keep the host gate closed so input and + // child/restart launches cannot run ahead of a process group that may still be stopped. Re-stop every + // group already resumed in this batch, so one failed resume cannot leave the rest of the task running. + for(const process of resumedGroups) { + const rollback=this.processTreePause.pause(process); + if(rollback.failed)failures.push(`Could not re-suspend a process group after another group failed to resume: ${rollback.failed}`); + } + this.budgetPausedTaskRoots.add(taskRootId); + } else { + this.budgetPausedTaskRoots.delete(taskRootId); + this.wakeBudgetResumeWaiters(taskRootId); + this.launchDeferredBudgetSessions(taskRootId); + } + return {supported,...(failures.length ? {failed:[...new Set(failures)].join(" ")} : {})}; + } + + /** False on Windows: a budget pause there blocks input and launches but cannot stop running processes. */ + processSuspensionSupported():boolean {return this.processTreePause.supported;} + + /** Startup orchestration replays persisted paused roots before restoring PTYs. */ + isBudgetPaused(taskRootId:string):boolean {return this.budgetPausedTaskRoots.has(taskRootId);} + + private wakeBudgetResumeWaiters(taskRootId:string):void { + for(const wake of [...(this.budgetResumeWaiters.get(taskRootId) ?? [])])wake(); + this.budgetResumeWaiters.delete(taskRootId); + } + private launchDeferredBudgetSessions(taskRootId:string):void { + for(const session of this.sessions.values()) { + if(this.taskScopeFor(session.metadata.id).id===taskRootId && session.budgetDeferredLaunch) { + session.budgetDeferredLaunch=false; + this.launchAwaitingSession(session.metadata.id,session); + } + } + } + + taskScopeFor(sourceId:string):NonNullable { + const source=this.sessions.get(sourceId);if(!source)throw new Error("Handoff card is unavailable."); + let root=source; + const seen=new Set([sourceId]); + while(root.metadata.parentSessionId && !seen.has(root.metadata.parentSessionId)) { + seen.add(root.metadata.parentSessionId);const parent=this.sessions.get(root.metadata.parentSessionId);if(!parent)break;root=parent; + } + const scope=root.metadata.taskScope ?? {id:root.metadata.id,cwd:root.metadata.cwd,startedAt:root.metadata.startedAt}; + return {...scope}; + } + private isSessionBudgetPaused(session:ManagedSession):boolean { + try {return this.budgetPausedTaskRoots.has(this.taskScopeFor(session.metadata.id).id);} catch {return false;} + } + private async awaitBudgetResume(session:ManagedSession):Promise { + while(this.isSessionBudgetPaused(session)) { + if(this.sessions.get(session.metadata.id)!==session || session.metadata.exitCode!==null)return false; + const taskRootId=this.taskScopeFor(session.metadata.id).id; + await new Promise(resolve=>{ + const waiters=this.budgetResumeWaiters.get(taskRootId) ?? new Set<() => void>(); + this.budgetResumeWaiters.set(taskRootId,waiters); + const wake=()=>{ + waiters.delete(wake); + session.launchWaiters.delete(wake); + if(waiters.size===0)this.budgetResumeWaiters.delete(taskRootId); + resolve(); + }; + waiters.add(wake); + session.launchWaiters.add(wake); + if(!this.budgetPausedTaskRoots.has(taskRootId))wake(); + }); + } + return this.sessions.get(session.metadata.id)===session && session.metadata.exitCode===null; + } /** Masks known secrets and key shapes in text another agent reads (observe, result, control screen, failures). */ redactSecrets(text: T): T { return (text === null ? text : this.redaction.redact(text)) as T; @@ -384,6 +632,17 @@ export class TerminalManager { }; } + /** Host-only attribution from the accepted Accounts contribution; paths never enter plugin summaries. */ + /** Whether this card's latest launch resumed the provider conversation `threadId` (rather than starting it). */ + resumedConversation(id:string,threadId:string):boolean { + return this.resumedThreads.get(id)===threadId; + } + + usageAccount(id:string):{id:string;home?:string} { + const session=this.sessions.get(id); + return {id:session?.accountId ?? "default",...(session?.accountHome ? {home:session.accountHome} : {})}; + } + /** Records the plugin that started a card (EP-4); saved with the card so control survives a restore. */ setPluginOwner(id: string, pluginId: string): void { const session = this.sessions.get(id); @@ -605,8 +864,10 @@ export class TerminalManager { create( request: CreateSessionRequest, - control: { captureResult?: boolean; answerCaptureGrantExpiresAt?: number; origin?: LaunchOrigin; ownerPluginId?: string } = {} + control: { captureResult?: boolean; answerCaptureGrantExpiresAt?: number; origin?: LaunchOrigin; ownerPluginId?: string; continueTaskFrom?:string } = {} ): SessionSnapshot { + const reviewerControl = this.readOnlyReviewerRequests.get(request); + if (reviewerControl) this.readOnlyReviewerRequests.delete(request); assertCreateRequest(request, this.containment()); const origin: LaunchOrigin = request.role === "subagent" ? "subagent" : control.origin ?? "person"; if (request.profile === "yolo" && request.provider !== "terminal") { @@ -625,6 +886,11 @@ export class TerminalManager { } const resume: ResumeRequest = threadId ? { threadId } : null; const modelChoice = launchModelChoice(request.provider, request.model, request.effort); + // A model account passes its own --model; a second one from the request made OpenCode crash at start + // ("U.split is not a function"). The account decides the model: say so instead of starting a broken CLI. + if (modelChoice.model !== undefined && selectedAccountId(request.launchOptions) !== "default") { + throw new LaunchRefusal(`Model account ${selectedAccountId(request.launchOptions).slice(0, 40)} sets the model of this launch; omit model, or choose an account for the model you want (list_routes / pick_account).`); + } if (modelChoice.model !== undefined) { let unknown: string | null = null; try { unknown = this.modelCheck(request.provider, modelChoice.model); } catch { unknown = null; } @@ -641,19 +907,35 @@ export class TerminalManager { } const delegated = origin === "subagent" || control.ownerPluginId !== undefined; - const launchOptions = this.launchPipeline - ? this.launchPipeline.normalizeOptions(request.provider, request.launchOptions, origin === "subagent" ? { delegated: true } : undefined) + // The orchestrator's own account is the person's choice: its subagent may run on it even where the plugin did not + // declare its options delegable. Any other account needs the plugin's delegable declaration. + const parentAccount = origin === "subagent" && request.parentSessionId !== undefined ? this.modelAccountOf(request.parentSessionId) : undefined; + const launchOptions = reviewerControl ? undefined : this.launchPipeline + ? this.launchPipeline.normalizeOptions(request.provider, request.launchOptions, origin === "subagent" + ? { delegated: true, ...(parentAccount ? { inherited: { [ACCOUNTS_PLUGIN_ID]: { account: parentAccount } } } : {}) } : undefined) : request.launchOptions === undefined ? undefined : failWith("Plugin launch options are not available."); // The isolation layer decides before anything starts: a subagent without it runs in normal, a launch that // needs it and cannot have it is refused. - const decision = this.decideIsolation(request.provider, request.profile, delegated, null); + // Host consent supplies continuation identity before any process or capability is launched. + const continuationScope=control.continueTaskFrom ? this.taskScopeFor(control.continueTaskFrom) : undefined; + const inheritedTaskRootId=continuationScope?.id ?? (request.parentSessionId ? this.taskScopeFor(request.parentSessionId).id : undefined); + if(inheritedTaskRootId && this.budgetPausedTaskRoots.has(inheritedTaskRootId)) { + throw new LaunchRefusal("This task's usage budget is paused. Clear or raise the task budget before starting another agent."); + } + const taskProjectRoot = continuationScope?.cwd ?? this.taskProjectRoot(request.cwd, request.parentSessionId); + const decision = this.decideIsolation(request.provider, request.profile, delegated, null, taskProjectRoot); if (decision.refuse) throw new LaunchRefusal(decision.refuse); + if (reviewerControl && (!decision.apply || decision.profile !== "plan")) { + throw new LaunchRefusal("A diff-only reviewer requires the operating-system isolation layer and a read-only Plan profile. The reviewer was not started."); + } if (decision.profile !== request.profile) request = { ...request, profile: decision.profile }; - const environmentChoice = this.environments + const environmentChoice = reviewerControl ? null : this.environments ? this.environments.normalizeChoice(request.provider, request.environment) ?? null : request.environment === undefined ? null : failWith("Plugin environments are not available."); - const id = randomUUID(); + const id = reviewerControl?.account?.id ?? randomUUID(); + const startedAt=Date.now(); + const captureResult = control.captureResult === true || Boolean(reviewerControl); const metadata: SessionMetadata = { id, revision: 0, @@ -667,16 +949,17 @@ export class TerminalManager { role, ...(request.parentSessionId !== undefined ? { parentSessionId: request.parentSessionId } : {}), status: initialSessionStatus(request.provider), - startedAt: Date.now(), + startedAt, + ...(continuationScope ? {taskScope:continuationScope} : environmentChoice && !request.parentSessionId ? {taskScope:{id,cwd:request.cwd,startedAt}} : {}), exitCode: null, failureDetails: null, ...modelChoice, ...(decision.isolation ? { isolation: decision.isolation } : {}) }; - const awaitMeasuredGrid = request.provider === "grok" + const awaitMeasuredGrid = !reviewerControl && request.provider === "grok" && this.providerClis.get(request.provider).state === "available"; // With launch options, an environment or a launch policy the plugins answer first; the card waits and launches when they do. - const contributed = (Boolean(launchOptions) || Boolean(environmentChoice) || this.policyApplies(request.provider)) && !awaitMeasuredGrid; + const contributed = !reviewerControl && (Boolean(launchOptions) || Boolean(environmentChoice) || this.policyApplies(request.provider)) && !awaitMeasuredGrid; this.startingModels.set(id, modelChoice); if (control.ownerPluginId !== undefined) this.startingOwners.set(id, control.ownerPluginId); let launched: ReturnType | { process: null; agentBrowser: null; agentRuntime: null; agentOrchestration: null; failure: null }; @@ -684,8 +967,8 @@ export class TerminalManager { launched = awaitMeasuredGrid || contributed ? { process: null, agentBrowser: null, agentRuntime: null, agentOrchestration: null, failure: null } : this.spawnProcess(id, request.provider, request.profile, request.cwd, - INITIAL_TERMINAL_COLS, INITIAL_TERMINAL_ROWS, resume, control.captureResult, role, - control.answerCaptureGrantExpiresAt, null, request.parentSessionId); + INITIAL_TERMINAL_COLS, INITIAL_TERMINAL_ROWS, resume, captureResult, role, + control.answerCaptureGrantExpiresAt, reviewerControl?.account?.contribution ?? null, request.parentSessionId, taskProjectRoot, reviewerControl); } finally { this.startingModels.delete(id); this.startingOwners.delete(id); @@ -715,7 +998,8 @@ export class TerminalManager { awaitingInitialResize: awaitMeasuredGrid, resumeOnLaunch: resume, ...(threadId ? { threadId } : {}), - captureResult: control.captureResult === true, + captureResult, + ...(reviewerControl ? { reviewWorkspace: reviewerControl.workspace } : {}), extras: { ...(launchOptions ? { options: launchOptions } : {}), ...(environmentChoice ? { environmentChoice } : {}), @@ -729,6 +1013,13 @@ export class TerminalManager { launchWaiters: new Set() }; this.sessions.set(id, session); + const reviewerAccount = reviewerControl?.account?.contribution; + if (reviewerAccount) { + this.addLaunchSecrets(session, reviewerAccount.secrets); + session.accountId = reviewerAccount.accountId ?? reviewerControl!.account!.id; + session.accountHome = reviewerAccount.accountHome; + session.launchCleanup = reviewerAccount.cleanup; + } this.adoptIsolationStart(id, session); if (launched.process) this.bindProcess(id, session, launched.process); if (contributed) this.launchContributed(id, session, resume, null, control.answerCaptureGrantExpiresAt); @@ -751,6 +1042,8 @@ export class TerminalManager { restart(id: string, options: { resume?: boolean } = {}): SessionSnapshot { const session = this.sessions.get(id); if (!session) throw new Error("Terminal session does not exist."); + if (session.reviewWorkspace) throw new LaunchRefusal("A diff-only reviewer session cannot be restarted; request a new isolated review instead."); + if(this.isSessionBudgetPaused(session))throw new LaunchRefusal("This task's usage budget is paused. Clear or raise the task budget before restarting it."); if (session.metadata.exitCode === null) throw new Error("Terminal session is still running."); const environment = session.extras.environment; if (environment && !this.environmentUsable(environment)) { @@ -930,9 +1223,11 @@ export class TerminalManager { } inputChecked(id: string, data: string): boolean { + if (data !== "\x03") { try { this.inputGate?.(id); } catch { return false; } } if (typeof data !== "string" || data.length === 0) return false; const session = this.sessions.get(id); if (!session || session.metadata.exitCode !== null || !session.process) return false; + if(data!=="\x03" && this.isSessionBudgetPaused(session))return false; const process = session.process; const written = tryPtyOperation(() => process.write(data)); if (written && ANSWERS_PROMPT.test(data)) this.settleAnsweredPrompt(id, session); @@ -1013,6 +1308,7 @@ export class TerminalManager { const threadId = normalizeThreadId(session.metadata.provider, signal.threadId); const threadChanged = Boolean(threadId && threadId !== session.threadId); + if (threadId && this.resumedThreads.get(id) === null) this.resumedThreads.set(id, threadId); if (threadChanged) { session.threadId = threadId; this.schedulePersistence(); @@ -1131,7 +1427,11 @@ export class TerminalManager { this.flushOutput(id, session); this.sessions.delete(id); + this.resumedThreads.delete(id); + session.reviewWorkspace?.cleanup(); + delete session.reviewWorkspace; this.isolationStarts.delete(id); + this.pendingIsolationReasons.delete(id); this.wakeLaunchWaiters(session); // Closed: an open report goes to the app; a pending audit runs and reports there. if (session.metadata.gitRisk) { @@ -1153,6 +1453,8 @@ export class TerminalManager { session.agentRuntime?.cleanup(); session.agentOrchestration?.cleanup(); if (session.process) { + const resumed=this.processTreePause.resume(session.process); + if(resumed.failed)console.warn(`Budget-paused PTY ${id} could not be resumed before close.`,resumed.failed); try { session.process.kill(); } catch (error) { @@ -1162,7 +1464,7 @@ export class TerminalManager { const environment = session.extras.environment; if (environment && this.environments) { if (!this.quitting) { - void this.environments.release(environment, id, { keepData: options.keepEnvironmentData !== false, reason: "closed" }); + void this.environments.release(environment, id, { keepData: options.keepEnvironmentData ?? environment.kind!=="worktree", reason: "closed" }); } else if (this.sessionRestoreMode === "off") { // Nothing is saved, so the environment will not come back: stop its compute, keep its data. this.quitReleases.push(this.environments.release(environment, id, { @@ -1201,6 +1503,7 @@ export class TerminalManager { size: descriptor.size, role: descriptor.role, ...(descriptor.parentSessionId !== undefined ? { parentSessionId: descriptor.parentSessionId } : {}), + ...(descriptor.taskScope ? {taskScope:{...descriptor.taskScope}} : {}), status: initialSessionStatus(descriptor.provider), startedAt: Date.now(), exitCode: null, @@ -1208,7 +1511,8 @@ export class TerminalManager { ...(step.note ? { restoreNote: step.note } : {}), ...(descriptor.environment ? { environment: environmentBadge(descriptor.environment) } : {}), ...(descriptor.model !== undefined ? { model: descriptor.model } : {}), - ...(descriptor.effort !== undefined ? { effort: descriptor.effort } : {}) + ...(descriptor.effort !== undefined ? { effort: descriptor.effort } : {}), + ...(descriptor.reviewRequested !== undefined ? {reviewRequested:descriptor.reviewRequested} : {}) }; const extras: PersistedSessionExtras = { ...(descriptor.options ? { options: descriptor.options } : {}), @@ -1268,9 +1572,13 @@ export class TerminalManager { } } const resume: ResumeRequest = step.launch === "stopped" ? null : step.launch; + const restoreTaskRootId=descriptor.taskScope?.id + ?? (descriptor.parentSessionId && this.sessions.has(descriptor.parentSessionId) + ? this.taskScopeFor(descriptor.parentSessionId).id + : descriptor.id); + const deferForBudget=this.budgetPausedTaskRoots.has(restoreTaskRootId); const awaitMeasuredGrid = directoryReady - && descriptor.provider === "grok" - && this.providerClis.get(descriptor.provider).state === "available"; + && (deferForBudget || descriptor.provider === "grok" && this.providerClis.get(descriptor.provider).state === "available"); const contributed = directoryReady && !awaitMeasuredGrid && (Boolean(extras.options) || Boolean(extras.environment) || this.policyApplies(descriptor.provider)); @@ -1405,10 +1713,16 @@ export class TerminalManager { private adoptIsolationStart(id: string, session: ManagedSession): void { const started = this.isolationStarts.get(id); - if (started === undefined) return; - this.isolationStarts.delete(id); - session.extras.gitAuditSince ??= started; - this.schedulePersistence(); + const reason = this.pendingIsolationReasons.get(id); + if (started !== undefined) { + this.isolationStarts.delete(id); + session.extras.gitAuditSince ??= started; + } + if (reason !== undefined) { + this.pendingIsolationReasons.delete(id); + if (session.metadata.isolation) session.metadata.isolation = { ...session.metadata.isolation, reason }; + } + if (started !== undefined || reason !== undefined) this.schedulePersistence(); } /** The person's answer to a git risk report: neutralize removes what was found; keep leaves it. Both close it. */ @@ -1432,7 +1746,8 @@ export class TerminalManager { return Promise.resolve(); } return this.sessionStore.replace( - [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata, session.threadId, session.extras)) + [...this.sessions.values()].filter((session) => !session.reviewWorkspace) + .map((session) => persistedTerminalSession(session.metadata, session.threadId, session.extras)) ); } @@ -1470,7 +1785,12 @@ export class TerminalManager { private launchAwaitingSession(id: string, session: ManagedSession): void { if (!session.awaitingInitialResize) return; + if(this.isSessionBudgetPaused(session)) { + session.budgetDeferredLaunch=true; + return; + } session.awaitingInitialResize = false; + session.budgetDeferredLaunch=false; const resume = session.resumeOnLaunch; session.resumeOnLaunch = null; if (this.contributed(session)) { @@ -1531,7 +1851,9 @@ export class TerminalManager { role: SessionRole = "agent", answerCaptureGrantExpiresAt?: number, contribution: LaunchContribution | null = null, - parentSessionId?: string + parentSessionId?: string, + taskProjectRoot?: string, + reviewerControl?: ReadOnlyReviewerLaunch ): { process: IPty | null; agentBrowser: PreparedAgentBrowserPtyLaunch | null; @@ -1540,11 +1862,15 @@ export class TerminalManager { failure: LaunchFailure | null; } { const none = { process: null, agentBrowser: null, agentRuntime: null, agentOrchestration: null }; - const decision = this.launchIsolation(id, provider, profile, role, null); + const projectRoot = taskProjectRoot ?? this.taskProjectRoot(cwd, parentSessionId); + const decision = this.launchIsolation(id, provider, profile, role, null, projectRoot); if (decision.refuse) return { ...none, failure: { diagnostic: `Launch refused: ${decision.refuse}`, exitCode: 1 } }; + if (reviewerControl && (!decision.apply || decision.profile !== "plan")) { + return { ...none, failure: { diagnostic: "A diff-only reviewer requires the operating-system isolation layer and a read-only Plan profile. The reviewer was not started.", exitCode: 1 } }; + } profile = decision.profile; const planned = this.planSpawn(id, provider, profile, cwd, resume, captureResult, role, answerCaptureGrantExpiresAt, contribution, - this.personTrustedFolder(parentSessionId, cwd), false, decision.apply); + reviewerControl ? undefined : this.personTrustedFolder(parentSessionId, cwd), false, decision.apply, Boolean(reviewerControl)); if ("failure" in planned) { return { ...none, failure: planned.failure }; } @@ -1552,7 +1878,7 @@ export class TerminalManager { let spawn: { command: string; args: string[] | string; env: Record } = planned; if (decision.apply) { try { - spawn = this.wrapIsolated(id, provider, profile, planned); + spawn = this.wrapIsolated(id, provider, profile, planned, projectRoot, contribution?.accountHome, reviewerControl?.deniedReadPaths); } catch (error) { planned.cleanup(); return { ...none, failure: { diagnostic: `Launch refused: ${error instanceof Error ? error.message : String(error)}`, exitCode: 1 } }; @@ -1594,9 +1920,24 @@ export class TerminalManager { return role === "subagent" || Boolean(this.sessions.get(id)?.extras.ownerPluginId ?? this.startingOwners.get(id)); } - private decideIsolation(provider: ProviderId, profile: LaunchProfile, delegated: boolean, environment: { isolated: boolean; label: string } | null): IsolationDecision { + /** Resolve the original task project for account and reviewer launches. */ + private taskProjectRoot(cwd: string, parentSessionId?: string): string { + let current = parentSessionId ? this.sessions.get(parentSessionId) : undefined; + let root: ManagedSession | undefined; + const visited = new Set(); + while (current && !visited.has(current.metadata.id)) { + visited.add(current.metadata.id); + root = current; + current = current.metadata.parentSessionId ? this.sessions.get(current.metadata.parentSessionId) : undefined; + } + return root?.metadata.taskScope?.cwd ?? root?.metadata.cwd ?? cwd; + } + + private decideIsolation(provider: ProviderId, profile: LaunchProfile, delegated: boolean, environment: { isolated: boolean; label: string } | null, + cwd?: string): IsolationDecision { if (!this.isolation) return { apply: false, profile }; - return this.isolation.decide({ provider, profile, delegated, environment }); + const decision = this.isolation.decide({ provider, profile, delegated, environment, cwd }); + return decision; } /** @@ -1604,9 +1945,10 @@ export class TerminalManager { * and a subagent whose layer is gone runs in normal from now on. */ private launchIsolation(id: string, provider: ProviderId, profile: LaunchProfile, role: SessionRole, - environment: { isolated: boolean; label: string } | null): IsolationDecision { - const decision = this.decideIsolation(provider, profile, this.isDelegated(id, role), environment); + environment: { isolated: boolean; label: string } | null, projectRoot?: string): IsolationDecision { const metadata = this.sessions.get(id)?.metadata; + const taskProjectRoot = projectRoot ?? metadata?.taskScope?.cwd ?? this.taskProjectRoot(metadata?.cwd ?? "", metadata?.parentSessionId); + const decision = this.decideIsolation(provider, profile, this.isDelegated(id, role), environment, taskProjectRoot); if (metadata && !decision.refuse) { if (decision.isolation) metadata.isolation = decision.isolation; else delete metadata.isolation; @@ -1616,7 +1958,9 @@ export class TerminalManager { } /** Wraps a planned launch in the isolation layer (throws when the layer cannot start: the launch is refused). */ - private wrapIsolated(id: string, provider: ProviderId, profile: LaunchProfile, planned: { command: string; args: string[] | string; cwd: string; env: Record }): { command: string; args: string[]; env: Record } { + private wrapIsolated(id: string, provider: ProviderId, profile: LaunchProfile, + planned: { command: string; args: string[] | string; cwd: string; env: Record }, taskProjectRoot: string, + accountHome?: string, deniedReadPaths?: readonly string[]): { command: string; args: string[]; env: Record } { if (!this.isolation) throw new LaunchRefusal("agent isolation is not configured; the agent was not started without it."); if (typeof planned.args === "string") throw new LaunchRefusal("a Windows batch launcher cannot run inside agent isolation."); const grant = controlGrantFolder(planned.env); @@ -1628,10 +1972,24 @@ export class TerminalManager { args: planned.args, env: planned.env, profile, + ...(deniedReadPaths ? { deniedReadPaths } : {}), + ...(deniedReadPaths ? { restrictHomeReads:true, runtimeReadable:[planned.command, + ...(this.agentRuntime?.readableRuntimePaths?.() ?? [])] } : {}), + ...(accountHome ? { accountHome } : {}), ...(grant ? { grantedPrivate: [grant] } : {}) }); this.releaseIsolation(id); this.isolationCleanups.set(id, wrapped.cleanup); + if (wrapped.isolationReason) { + const session = this.sessions.get(id); + if (session) { + if (session.metadata.isolation) session.metadata.isolation = { ...session.metadata.isolation, reason: wrapped.isolationReason }; + this.schedulePersistence(); + this.emitSession(session.metadata); + } else { + this.pendingIsolationReasons.set(id, wrapped.isolationReason); + } + } // Its repositories are audited once it ends (and on close or restore): from the earliest session not audited yet. // A first launch wraps before its card exists: adoptIsolationStart records it then. const session = this.sessions.get(id); @@ -1666,33 +2024,37 @@ export class TerminalManager { contribution: LaunchContribution | null, trustedFolder?: string, environmentWrapped = false, - isolated = false + isolated = false, + reviewOnly = false ): PlannedSpawn | { failure: LaunchFailure } { const providerCli = provider === "terminal" ? undefined : this.providerClis.get(provider); if (providerCli?.state === "unavailable") return { failure: providerCli }; + if (resume === null || provider === "terminal") this.resumedThreads.delete(id); + else this.resumedThreads.set(id, typeof resume === "object" ? resume.threadId : null); // What decides whether Claude's lifecycle hooks may go over HTTP (ClaudeHttpHooks.ts): where and how it runs. - const claudeHttp = provider === "claude" && providerCli?.state === "available" ? { + const claudeHttp = !reviewOnly && provider === "claude" && providerCli?.state === "available" ? { executable: providerCli.executable, profile, environmentWrapped, env: { ...terminalEnvironment(), ...providerCli.environment, ...(contribution?.env ?? {}) }, args: contribution?.args ?? [], - cwd + cwd, } : undefined; const agentRuntime = provider === "terminal" ? null : this.agentRuntime?.prepareLaunch({ terminalSessionId: id, provider, cwd, - ...(captureResult ? { captureResult: true } : {}), + ...(captureResult || reviewOnly ? { captureResult: true } : {}), + ...(reviewOnly ? { decisions: false } : {}), ...(claudeHttp ? { claudeHttp } : {}), ...(answerCaptureGrantExpiresAt === undefined ? {} : { answerCaptureGrantExpiresAt }) }) ?? null; let pluginTools: string[] = []; try { - pluginTools = provider === "terminal" ? [] : this.pluginToolNames(role, provider); + pluginTools = provider === "terminal" || reviewOnly ? [] : this.pluginToolNames(role, provider); } catch { // Plugins never block a launch; the session simply gets no plugin tools. } const bridged = role === "orchestrator" || pluginTools.length > 0; - const agentOrchestration = bridged && this.agentOrchestration?.isEnabled + const agentOrchestration = !reviewOnly && bridged && this.agentOrchestration?.isEnabled ? this.agentOrchestration.prepareLaunch({ terminalSessionId: id }) : null; let agentBrowser: PreparedAgentBrowserPtyLaunch | null = null; @@ -1708,7 +2070,7 @@ export class TerminalManager { // and minimax until its MCP configuration is wired (plain PTY for now). // devin is cloud-session oriented and takes no browser adapter yet, // and antigravity keeps plain PTY integration for the same reason. - agentBrowser = provider === "terminal" || provider === "grok" || provider === "omp" || provider === "pi" || provider === "cursor" || provider === "minimax" || provider === "devin" || provider === "antigravity" + agentBrowser = reviewOnly || provider === "terminal" || provider === "grok" || provider === "omp" || provider === "pi" || provider === "cursor" || provider === "minimax" || provider === "devin" || provider === "antigravity" ? null : this.agentBrowser?.prepareLaunch({ terminalSessionId: id, @@ -1729,7 +2091,7 @@ export class TerminalManager { : { ...browserEnvironment, ...runtimeEnvironment }), ...orchestrationEnvironment, // Orchestrators alone learn where the control descriptor and CLI are. - ...controlEnvironment(role, this.controlConnection, id) + ...(!reviewOnly ? controlEnvironment(role, this.controlConnection, id) : {}) }; // OpenCode: the project folder in its other Unicode spelling is still this folder, not an external one. if (provider === "opencode") Object.assign(providerEnvironment, openCodeProjectFolderEnvironment({ ...baseEnvironment, ...providerEnvironment }, cwd)); @@ -1877,6 +2239,12 @@ export class TerminalManager { ): Promise { const { metadata } = session; const live = (): boolean => this.sessions.get(id) === session && session.launchToken === token && !session.process; + const waitIfBudgetPaused = async (): Promise => { + while(this.isSessionBudgetPaused(session)) { + if(!await this.awaitBudgetResume(session))return false; + } + return live(); + }; const refuse = (reason: string): LaunchOutcome => { // "Launch refused" leads, so the failure summary quotes the reason as the cause. metadata.failureDetails = this.redactSecrets(`Launch refused: ${reason}`); @@ -1884,6 +2252,9 @@ export class TerminalManager { }; const environments = this.environments; + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused())return "superseded"; + if(!live())return "superseded"; + // 1. Place a new session where the person chose. The choice stays saved with the card until the plugin has // prepared it, so a card whose preparation was cut short (quit, crash) or failed never restores locally. const choice = session.extras.environmentChoice; @@ -1896,6 +2267,14 @@ export class TerminalManager { if (placed.ok) void environments.release(placed.environment, id, { keepData: false, reason: "closed" }); return "superseded"; } + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused()) { + if(placed.ok)void environments.release(placed.environment,id,{keepData:false,reason:"closed"}); + return "superseded"; + } + if(!live()) { + if(placed.ok)void environments.release(placed.environment,id,{keepData:false,reason:"closed"}); + return "superseded"; + } if (!placed.ok) return refuse(placed.reason); delete session.extras.environmentChoice; session.environmentReady = true; @@ -1918,6 +2297,8 @@ export class TerminalManager { } const resumed = await environments.resume(environment, id); if (!live()) return "superseded"; + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused())return "superseded"; + if(!live())return "superseded"; if (!resumed.ok) return refuse(`environment stopped: ${resumed.reason}`); session.environmentReady = true; } @@ -1946,8 +2327,18 @@ export class TerminalManager { if (prepared.ok) void prepared.cleanup().catch(() => undefined); return "superseded"; } + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused()) { + if(prepared.ok)void prepared.cleanup().catch(()=>undefined); + return "superseded"; + } + if(!live()) { + if(prepared.ok)void prepared.cleanup().catch(()=>undefined); + return "superseded"; + } if (!prepared.ok) return refuse(prepared.reason); contribution = prepared; + session.accountId=prepared.accountId ?? selectedAccountId(session.extras.options); + session.accountHome=prepared.accountHome; this.addLaunchSecrets(session, prepared.secrets); } const dropContribution = (): void => { @@ -1960,8 +2351,9 @@ export class TerminalManager { dropContribution(); return refuse(`${environment.label} does not pass the launch on unchanged (the plugin does not declare it), so the ${metadata.profile} profile's settings and CanvasTTY's hooks would not reach the agent there. Launch it in normal, or use an environment that keeps them.`); } + const taskProjectRoot = metadata.taskScope?.cwd ?? this.taskProjectRoot(metadata.cwd, metadata.parentSessionId); const decision = this.launchIsolation(id, metadata.provider, metadata.profile, metadata.role, - environment ? { isolated: keeps.isolated === true, label: environment.label } : null); + environment ? { isolated: keeps.isolated === true, label: environment.label } : null, taskProjectRoot); if (decision.refuse) { dropContribution(); return refuse(decision.refuse); @@ -2014,6 +2406,14 @@ export class TerminalManager { abandon(); return "superseded"; } + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused()) { + abandon(); + return "superseded"; + } + if(!live()) { + abandon(); + return "superseded"; + } if (!wrapped.ok) { abandon(); return refuse(wrapped.reason); @@ -2024,12 +2424,20 @@ export class TerminalManager { } if (decision.apply) { try { - spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn), cwd: spawn.cwd }; + spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn, taskProjectRoot, contribution?.accountHome, undefined), cwd: spawn.cwd }; } catch (error) { abandon(); return refuse(error instanceof Error ? error.message : String(error)); } } + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused()) { + abandon(); + return "superseded"; + } + if(!live()) { + abandon(); + return "superseded"; + } let process: IPty; try { process = this.spawnPty(spawn.command, spawn.args, { @@ -2096,29 +2504,40 @@ export class TerminalManager { let exited!: () => void; this.liveProcesses.set(process, new Promise((resolve) => { exited = resolve; })); - process.onExit(({ exitCode }) => { + process.onExit(({ exitCode, signal }) => { this.liveProcesses.delete(process); exited(); const current = this.sessions.get(id); if (!current || current !== session || current.process !== process) return; // node-pty calls this from a native callback that aborts the whole app when JavaScript throws in it. try { - this.recordExit(id, current, exitCode); + const resumed=this.processTreePause.resume(process); + if(resumed.failed)console.warn(`Budget-paused PTY ${id} could not be resumed after exit.`,resumed.failed); + this.recordExit(id, current, exitCode, signal); } catch (error) { console.warn(`PTY ${id} exit could not be recorded.`, error); } }); } - private recordExit(id: string, current: ManagedSession, exitCode: number): void { + private recordExit(id: string, current: ManagedSession, reportedExitCode: number, signal?: number): void { + current.process = null; this.flushOutput(id, current); this.releaseIsolation(id); + current.reviewWorkspace?.cleanup(); if (current.extras.gitAuditSince !== undefined) void this.auditGit(id, current); + // node-pty reports a process killed by a signal as exitCode 0 plus the signal: that is a crash, not a clean finish + // (an OpenCode killed with SIGKILL showed "done, exit 0" and could not be retried). Shells report it as 128+signal. + const killedBy = typeof signal === "number" && signal > 0 ? signal : 0; + const exitCode = killedBy ? 128 + killedBy : reportedExitCode; current.metadata.exitCode = exitCode; current.metadata.status = exitCode === 0 ? "done" : "failed"; - current.metadata.failureDetails = exitCode === 0 + const details = exitCode === 0 ? null : terminalFailureDetails(this.redactSecrets(current.bufferChunks.slice(current.bufferStart).join(""))); + current.metadata.failureDetails = killedBy + ? `${signalExitText(killedBy)}${details ? `\n${details}` : ""}`.slice(0, 8_000) + : details; current.agentBrowser?.cleanup(); current.agentBrowser = null; current.agentRuntime?.cleanup(); @@ -2395,6 +2814,11 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } +function canonicalProjectPath(path: string): string { + try { return realpathSync(path).normalize("NFC"); } + catch { return onDiskPath(path).normalize("NFC"); } +} + function publicSessionMetadata(session: ManagedSession): SessionMetadata { const metadata = structuredClone(session.metadata); if (session.threadId) metadata.threadId = session.threadId; @@ -2452,3 +2876,9 @@ function appendScrollback(session: ManagedSession, data: string): void { session.bufferStart = 0; } } + +/** "Killed by signal SIGKILL (9)." from node-pty's signal number. */ +function signalExitText(signal: number): string { + const name = Object.entries(osConstants.signals).find(([, value]) => value === signal)?.[0]; + return `The process was killed by signal ${name ? `${name} (${signal})` : signal}.`; +} diff --git a/src/main/services/TerminalSessionStore.ts b/src/main/services/TerminalSessionStore.ts index 269c49bd..569c7574 100644 --- a/src/main/services/TerminalSessionStore.ts +++ b/src/main/services/TerminalSessionStore.ts @@ -25,6 +25,7 @@ export const MAX_PLUGIN_SLOT_BYTES = 4_096; const MAX_OPTION_PLUGINS = 16; export interface PersistedTerminalSession { + taskScope?:{id:string;cwd:string;startedAt:number}; id: string; provider: ProviderId; profile: LaunchProfileId; @@ -57,6 +58,7 @@ export interface PersistedTerminalSession { /** The model and reasoning effort its launches ask the CLI for (launchModel.ts). */ model?: string; effort?: ReasoningEffort; + reviewRequested?: boolean; /** An isolated session ran since then and its repositories were not audited yet, or a report is still open. */ gitAuditSince?: number; } @@ -242,6 +244,7 @@ export function persistedTerminalSession( position: { ...metadata.position }, size: { ...metadata.size }, ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), + ...(metadata.taskScope ? {taskScope:{...metadata.taskScope}} : {}), ...(normalizedThreadId !== undefined ? { threadId: normalizedThreadId } : {}), lastState, ...(lastState !== "running" ? { exitCode: metadata.exitCode } : {}), @@ -252,7 +255,8 @@ export function persistedTerminalSession( ...(extras.ownerPluginId ? { ownerPluginId: extras.ownerPluginId } : {}), ...(extras.gitAuditSince !== undefined ? { gitAuditSince: extras.gitAuditSince } : {}), ...(metadata.model !== undefined ? { model: metadata.model } : {}), - ...(metadata.effort !== undefined ? { effort: metadata.effort } : {}) + ...(metadata.effort !== undefined ? { effort: metadata.effort } : {}), + ...(metadata.reviewRequested !== undefined ? { reviewRequested: metadata.reviewRequested } : {}) }; } @@ -322,6 +326,7 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste height: clamp(session.size.height, 260, 1_100) }, ...(parentSessionId !== undefined ? { parentSessionId } : {}), + ...(isTaskScope(session.taskScope) ? {taskScope:{...session.taskScope}} : {}), ...(threadId !== undefined ? { threadId } : {}), lastState, ...(lastState !== "running" ? { exitCode } : {}), @@ -336,7 +341,8 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste ...(session.provider !== "terminal" && session.model !== undefined && launchModelProblem(session.provider as ProviderId, session.model) === null ? { model: session.model } : {}), ...(session.provider !== "terminal" && session.effort !== undefined && launchEffortProblem(session.provider as ProviderId, session.effort) === null - ? { effort: session.effort } : {}) + ? { effort: session.effort } : {}), + ...(typeof session.reviewRequested === "boolean" ? { reviewRequested: session.reviewRequested } : {}) }); ids.add(session.id); } @@ -427,3 +433,7 @@ function isReadableState(value: unknown): boolean { function isMissingFile(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } + +function isTaskScope(value:unknown):value is {id:string;cwd:string;startedAt:number} { + return isRecord(value) && typeof value.id==="string" && /^[\w-]{1,160}$/.test(value.id) && typeof value.cwd==="string" && value.cwd.length>0 && value.cwd.length<=4096 && typeof value.startedAt==="number" && Number.isFinite(value.startedAt) && value.startedAt>0; +} diff --git a/src/main/services/TimelineIndexScan.ts b/src/main/services/TimelineIndexScan.ts new file mode 100644 index 00000000..7fb055dd --- /dev/null +++ b/src/main/services/TimelineIndexScan.ts @@ -0,0 +1,258 @@ +import { createReadStream } from "node:fs"; +import { readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { addTimelineSearchEvent, createTimelineSegmentSearchIndex, type TimelineSegmentSearchIndex } from "./TimelineSearchIndex.ts"; +import type { TimelineEvent } from "../../shared/backlog.ts"; + +export interface TimelineUsageContext { provider?: string; model?: string; accountId?: string; taskId?: string } +export type TimelineIndexedUsage = TimelineUsageContext & { + input: number | null; + output: number | null; + total?: number; + cost: number | null; + source: string; + cumulative?: boolean; + counterId?: string; + /** A resumed conversation's first sample: its counter already held usage from earlier runs. */ + baseline?: boolean; +}; +type Usage=TimelineIndexedUsage; +export interface BudgetUsageContribution { + sessionId:string; + identity:string; + taskId?:string; + provider?:string; + model?:string; + mode:"reported"|"priced"|"covered"|"reported-base"; + tokens:number; + input:number|null; + output:number|null; + reportedCostUsd:number|null; +} +export interface BudgetCounterState {current:TimelineIndexedUsage;taskOwner:string|null;mixedOwners:boolean;reportedCostSeen:boolean; + /** Started from a resumed conversation's baseline: its total so far, cost included, predates this run. */ + fromBaseline?:boolean} +export interface TimelineTaskSessionIndex { + id:string; + taskId:string; + title:string; + types:Map; + lastSegment:string; +} + +export interface TimelineDirectoryIndex { + lastSegmentAt:number; + usage:Map; + budgetUsage:Map; + budgetCounters:Map; + segments:Map; + taskSessions:Map>; +} + +const MAX_EVENT_LINE_CHARS = 64 * 1024; +export const MAX_INDEXED_SEGMENTS = 256; +const NAME_PATTERN = /^\d{16}-[\w-]+\.ndjson$/u; +export async function scanTimelineDirectory(directory: string): Promise { + const names = (await readdir(directory)).filter((name: string) => NAME_PATTERN.test(name)).sort(); + const usage = new Map(); + const budgetUsage=new Map(); + const budgetCounters=new Map(); + const taskSessions=new Map>(); + const segments=new Map(); + let lastSegmentAt = 0; + for (const name of names) { + lastSegmentAt = Math.max(lastSegmentAt, Number(name.slice(0, 16)) || 0); + const metadata = await stat(join(directory, name)); + const index = createTimelineSegmentSearchIndex(metadata.size, metadata.mtimeMs); + for await (const line of boundedLines(join(directory, name))) { + if (line === null) { index.complete = false; continue; } + if (!line) continue; + let event: any; + try { event = JSON.parse(line); } catch { continue; } + if (!event || typeof event.id !== "string" || typeof event.sessionId !== "string" || typeof event.at !== "number") continue; + addTimelineSearchEvent(index, event); + indexTimelineTaskSession(taskSessions,event,name); + const usageUpdate = nextTimelineUsage(usage, event); + if (usageUpdate) usage.set(usageUpdate.key, usageUpdate.value); + applyTimelineBudgetUsageEvent(budgetUsage,budgetCounters,event); + } + segments.set(name,index); + if (segments.size > MAX_INDEXED_SEGMENTS) segments.delete(segments.keys().next().value!); + } + return {lastSegmentAt,usage,budgetUsage,budgetCounters,segments,taskSessions}; +} + +async function* boundedLines(path: string): AsyncGenerator { + let pending = ""; + let oversized = false; + for await (const chunk of createReadStream(path, { encoding: "utf8" })) { + let from = 0; + while (from < chunk.length) { + const newline = chunk.indexOf("\n", from); + const end = newline < 0 ? chunk.length : newline; + if (!oversized) { + const part = chunk.slice(from, end); + if (pending.length + part.length > MAX_EVENT_LINE_CHARS) { pending = ""; oversized = true; } + else pending += part; + } + if (newline < 0) break; + yield oversized ? null : pending.endsWith("\r") ? pending.slice(0, -1) : pending; + pending = ""; + oversized = false; + from = newline + 1; + } + } + if (oversized) yield null; + else if (pending) yield pending; +} + +export function nextTimelineUsage( + usage: ReadonlyMap, + event: TimelineEvent +): { key: string; value: Usage } | null { + if (event.type !== "usage" || typeof event.detail !== "string" || !event.detail) return null; + try { + const row = JSON.parse(event.detail) as Usage; + const key=JSON.stringify([event.sessionId,row.source,row.counterId ?? ""]); + if (row.cumulative === true) { + if (isTimelineCount(row.total) && typeof row.source === "string") { + const input = row.input === null || isTimelineCount(row.input) ? row.input : null; + const output = row.output === null || isTimelineCount(row.output) ? row.output : null; + const cost = typeof row.cost === "number" && Number.isFinite(row.cost) && row.cost >= 0 ? row.cost : null; + return { key, value: { input, output, total: row.total, cost, source: row.source, + cumulative: true, + ...(typeof row.counterId === "string" && row.counterId.length <= 200 ? { counterId: row.counterId } : {}), + ...timelineUsageContext(row) } }; + } + return null; + } + if (!isTimelineCount(row.input) || !isTimelineCount(row.output) || typeof row.source !== "string") return null; + const previous = usage.get(key); + return { key, value: { input: (previous?.input ?? 0) + row.input!, output: (previous?.output ?? 0) + row.output!, + cost: row.cost !== null && typeof row.cost === "number" && Number.isFinite(row.cost) && (!previous || previous.cost !== null) + ? (previous?.cost ?? 0) + row.cost : null, + source: row.source,...timelineUsageContext(row) } }; + } catch { /* An invalid or truncated journal line has no usage effect. */ return null; } +} + +export function timelineUsageContext(value: { provider?: unknown; model?: unknown; accountId?: unknown; taskId?: unknown }): TimelineUsageContext { + const context: TimelineUsageContext = {}; + for (const key of ["provider", "model", "accountId", "taskId"] as const) { + const entry = value[key]; + if (typeof entry === "string" && entry.length > 0 && entry.length <= 200) context[key] = entry; + } + return context; +} + +export function isTimelineCount(value: unknown): value is number { return Number.isSafeInteger(value) && (value as number) >= 0; } + +export function indexTimelineTaskSession( + taskSessions:Map>, + event:TimelineEvent, + segment:string +):void { + if(typeof event.taskId!=="string" || !event.taskId || event.taskId.length>200)return; + let sessions=taskSessions.get(event.taskId); + if(!sessions){sessions=new Map();taskSessions.set(event.taskId,sessions);} + let descriptor=sessions.get(event.sessionId); + if(!descriptor) { + descriptor={id:event.sessionId,taskId:event.taskId, + title:typeof event.sessionTitle==="string" && event.sessionTitle.length<=200 && event.sessionTitle ? event.sessionTitle : event.sessionId.slice(0,200), + types:new Map(),lastSegment:segment}; + sessions.set(event.sessionId,descriptor); + } + if(typeof event.type==="string" && event.type)descriptor.types.set(event.type,segment); + if(typeof event.sessionTitle==="string" && event.sessionTitle.length<=200 && event.sessionTitle)descriptor.title=event.sessionTitle; + descriptor.lastSegment=segment; +} + +export function applyTimelineBudgetUsageEvent( + contributions:Map, + baselines:Map, + event:any +):void { + if(event.type!=="usage" || typeof event.detail!=="string" || !event.detail)return; + try { + const row=JSON.parse(event.detail) as Usage; + if(typeof row.source!=="string")return; + const context=timelineUsageContext(row),identity=budgetCounterIdentity(event.sessionId,{...row,...context}); + if(row.cumulative===true) { + if(!isTimelineCount(row.total))return; + const current:Usage={input:row.input===null || isTimelineCount(row.input) ? row.input : null, + output:row.output===null || isTimelineCount(row.output) ? row.output : null,total:row.total, + cost:typeof row.cost==="number" && Number.isFinite(row.cost) && row.cost>=0 ? row.cost : null, + source:row.source,cumulative:true, + ...(typeof row.counterId==="string" && row.counterId.length<=200 ? {counterId:row.counterId} : {}),...context}; + const previousState=baselines.get(identity),previous=previousState?.current; + if(previous===undefined && row.baseline===true) { + // Usage before the resume belongs to earlier runs; later samples count from here. + baselines.set(identity,{current,taskOwner:taskOwner(event.sessionId,current),mixedOwners:false,reportedCostSeen:current.cost!==null,fromBaseline:true}); + return; + } + const reset=previous!==undefined && current.total!=0; + addBudgetContribution(contributions,{sessionId:event.sessionId,identity,...context, + mode:reported ? "reported" : "priced",tokens:row.input+row.output,input:row.input,output:row.output, + reportedCostUsd:reported ? row.cost! : null}); + } catch { /* An invalid or truncated journal line has no budget effect. */ } +} + +export function budgetCounterIdentity(sessionId:string,row:Usage):string { + return row.counterId===undefined + ? JSON.stringify([sessionId,row.source,""]) + : JSON.stringify([row.provider ?? "",row.accountId ?? "",row.source,row.counterId]); +} +function budgetCounterDelta(current:number|null,previous:number|null,reset:boolean):number|null { + if(current===null)return null; + if(previous===null || reset)return current; + return current>=previous ? current-previous : current; +} +function addBudgetContribution(map:Map,row:BudgetUsageContribution):void { + const key=budgetContributionKey(row); + const previous=map.get(key); + if(!previous){map.set(key,{...row});return;} + previous.tokens+=row.tokens; + previous.input=previous.input===null || row.input===null ? null : previous.input+row.input; + previous.output=previous.output===null || row.output===null ? null : previous.output+row.output; + if(row.mode==="reported")previous.reportedCostUsd=(previous.reportedCostUsd ?? 0)+(row.reportedCostUsd ?? 0); +} +function budgetContributionKey(row:BudgetUsageContribution):string { + return JSON.stringify([row.sessionId,row.identity,row.taskId ?? "",row.provider ?? "",row.model ?? "",row.mode]); +} +function taskOwner(sessionId:string,row:Usage):string {return row.taskId===undefined ? `session:${sessionId}` : `task:${row.taskId}`;} +function coverEarlierPricedCost(contributions:Map,identity:string,taskId:string|undefined,sessionId:string):void { + for(const [key,row] of [...contributions]) { + if(row.identity!==identity || row.mode!=="priced")continue; + if(taskId!==undefined ? row.taskId!==taskId : row.sessionId!==sessionId || row.taskId!==undefined)continue; + contributions.delete(key);row.mode="covered";contributions.set(budgetContributionKey(row),row); + } +} +function setReportedCostBase(contributions:Map,sessionId:string,identity:string,row:Usage,cost:number):void { + const base:BudgetUsageContribution={sessionId,identity,...timelineUsageContext(row),mode:"reported-base",tokens:0,input:null,output:null,reportedCostUsd:cost}; + const owner=row.taskId ?? `session:${sessionId}`; + contributions.set(JSON.stringify(["reported-base",identity,owner]),base); +} diff --git a/src/main/services/TimelineJournalReader.ts b/src/main/services/TimelineJournalReader.ts new file mode 100644 index 00000000..66152bea --- /dev/null +++ b/src/main/services/TimelineJournalReader.ts @@ -0,0 +1,60 @@ +import { createReadStream } from "node:fs"; +import type { TimelineEvent } from "../../shared/backlog.ts"; + +const STREAM_HIGH_WATER_MARK = 64 * 1024; +const EVENT_LOOP_YIELD_CHARS = 256 * 1024; + +/** + * Visits valid journal records without retaining a whole segment or an event array. + * Malformed records are ignored, as they are by SessionTimelineService.read(). + * A line is kept intact until JSON parsing so valid oversized imported records keep + * their existing behavior. + */ +export async function forEachTimelineEvent( + path: string, + visit: (event: TimelineEvent) => boolean | void +): Promise { + const stream = createReadStream(path, { encoding: "utf8", highWaterMark: STREAM_HIGH_WATER_MARK }); + let pendingParts: string[] = []; + let workSinceYield = 0; + let yieldCount = 0; + + const deliver = (line: string): boolean => { + let event: TimelineEvent; + try { event = JSON.parse(line) as TimelineEvent; } + catch { return false; } + if (!event || typeof event.id !== "string" || typeof event.sessionId !== "string" || typeof event.at !== "number") return false; + return visit(event) === true; + }; + const yieldToEventLoop = (): Promise => new Promise(resolve => setImmediate(resolve)); + + for await (const chunk of stream) { + let from = 0; + while (from < chunk.length) { + const newline = chunk.indexOf("\n", from); + const end = newline < 0 ? chunk.length : newline; + const part = chunk.slice(from, end); + if (part) { + pendingParts.push(part); + workSinceYield += part.length; + } + from = newline < 0 ? chunk.length : newline + 1; + if (newline >= 0) { + const line = pendingParts.length === 0 ? "" + : pendingParts.length === 1 ? pendingParts[0] + : pendingParts.join(""); + pendingParts.length = 0; + if (deliver(line)) return yieldCount; + workSinceYield++; + } + if (workSinceYield >= EVENT_LOOP_YIELD_CHARS) { + workSinceYield = 0; + await yieldToEventLoop(); + yieldCount++; + } + } + } + + if (pendingParts.length > 0) deliver(pendingParts.length === 1 ? pendingParts[0] : pendingParts.join("")); + return yieldCount; +} diff --git a/src/main/services/TimelineSearchIndex.ts b/src/main/services/TimelineSearchIndex.ts new file mode 100644 index 00000000..c418fbf0 --- /dev/null +++ b/src/main/services/TimelineSearchIndex.ts @@ -0,0 +1,64 @@ +export interface TimelineSegmentSearchIndex { + size: number; + mtimeMs: number; + sessionIds: Set; + types: Set; + /** False when a malformed oversized line could not be indexed; such a segment must be read. */ + complete: boolean; + /** Four 32K-bit bloom filters per segment, with no false negatives. */ + bloom: Uint8Array; +} + +const BLOOM_BYTES = 16 * 1024; +const BLOOM_BITS = BLOOM_BYTES * 8; +const BLOOM_MASK = BLOOM_BITS - 1; + +export function createTimelineSegmentSearchIndex(size = 0, mtimeMs = 0): TimelineSegmentSearchIndex { + return { size, mtimeMs, sessionIds: new Set(), types: new Set(), complete: true, bloom: new Uint8Array(BLOOM_BYTES) }; +} + +export function addTimelineSearchEvent( + index: TimelineSegmentSearchIndex, + event: { sessionId: string; type: string; summary?: string; detail?: string } +): void { + index.sessionIds.add(event.sessionId); + if (event.type) index.types.add(event.type); + // Match page()'s template interpolation even for legacy rows with malformed optional fields. + const text = `${String(event.summary)}\n${event.detail === null || event.detail === undefined ? "" : String(event.detail)}`.toLocaleLowerCase(); + for (let offset = 0; offset + 2 < text.length; offset++) { + const first = text.charCodeAt(offset); + const second = text.charCodeAt(offset + 1); + const third = text.charCodeAt(offset + 2); + const hash = (Math.imul(first, 0x1f1f1f1f) ^ Math.imul(second, 0x45d9f3b) ^ Math.imul(third, 0x119de1f3)) >>> 0; + setBit(index.bloom, hash & BLOOM_MASK); + setBit(index.bloom, Math.imul(hash, 0x9e3779b1) >>> 17 & BLOOM_MASK); + setBit(index.bloom, Math.imul(hash ^ 0x85ebca6b, 0xc2b2ae35) >>> 17 & BLOOM_MASK); + setBit(index.bloom, Math.imul(hash ^ 0x27d4eb2f, 0x165667b1) >>> 17 & BLOOM_MASK); + } +} + +export function timelineSegmentMayMatch( + index: Pick, + sessionIds: ReadonlySet, + types: ReadonlySet | null, + query: string | undefined +): boolean { + if (!index.complete) return true; + if (sessionIds.size === 0 || ![...sessionIds].some(id => index.sessionIds.has(id))) return false; + if (types && ![...types].some(type => index.types.has(type))) return false; + if (!query || query.length < 3) return true; + for (let offset = 0; offset + 2 < query.length; offset++) { + const first = query.charCodeAt(offset); + const second = query.charCodeAt(offset + 1); + const third = query.charCodeAt(offset + 2); + const hash = (Math.imul(first, 0x1f1f1f1f) ^ Math.imul(second, 0x45d9f3b) ^ Math.imul(third, 0x119de1f3)) >>> 0; + if (!hasBit(index.bloom, hash & BLOOM_MASK) + || !hasBit(index.bloom, Math.imul(hash, 0x9e3779b1) >>> 17 & BLOOM_MASK) + || !hasBit(index.bloom, Math.imul(hash ^ 0x85ebca6b, 0xc2b2ae35) >>> 17 & BLOOM_MASK) + || !hasBit(index.bloom, Math.imul(hash ^ 0x27d4eb2f, 0x165667b1) >>> 17 & BLOOM_MASK)) return false; + } + return true; +} + +function setBit(bits: Uint8Array, offset: number): void { bits[offset >>> 3] = bits[offset >>> 3]! | (1 << (offset & 7)); } +function hasBit(bits: Uint8Array, offset: number): boolean { return (bits[offset >>> 3]! & (1 << (offset & 7))) !== 0; } diff --git a/src/main/services/UsagePrices.ts b/src/main/services/UsagePrices.ts new file mode 100644 index 00000000..6e85a24e --- /dev/null +++ b/src/main/services/UsagePrices.ts @@ -0,0 +1,37 @@ +import {mkdir,readFile,rename,writeFile} from "node:fs/promises"; +import {dirname} from "node:path"; +import {randomUUID} from "node:crypto"; +import type {UsagePrice} from "../../shared/backlog.ts"; + +/** Human-entered prices. Empty by default; missing model/breakdown never produces a guessed cost. */ +export class UsagePrices { + private readonly path:string; + private rows:UsagePrice[]=[]; + private writes:Promise=Promise.resolve(); + private listeners=new Set<()=>void>(); + constructor(path:string){this.path=path;} + async load():Promise{ + try{const text=await readFile(this.path,"utf8");if(text.length>256_000)throw new Error("Price table is too large.");this.rows=validate(JSON.parse(text));} + catch(error){if(!(error && typeof error==='object' && 'code' in error && error.code==='ENOENT'))throw error;} + } + get():UsagePrice[]{return structuredClone(this.rows);} + subscribe(listener:()=>void):()=>void {this.listeners.add(listener);return ()=>this.listeners.delete(listener);} + async set(rows:UsagePrice[]):Promise{ + const next=validate(rows),payload=JSON.stringify(next); + const operation=this.writes.catch(()=>undefined).then(async()=>{ + await mkdir(dirname(this.path),{recursive:true,mode:0o700});const temporary=`${this.path}.${randomUUID()}.tmp`; + await writeFile(temporary,payload,{mode:0o600,flag:'wx'});await rename(temporary,this.path);this.rows=next; + });this.writes=operation;await operation;for(const listener of this.listeners)listener();return this.get(); + } +} +function validate(value:unknown):UsagePrice[]{ + if(!Array.isArray(value) || value.length>200)throw new Error("Price table accepts at most 200 models."); + const keys=new Set(); + return value.map(row=>{ + if(!row || typeof row!=='object' || typeof row.provider!=='string' || !/^[a-z][a-z0-9-]{0,39}$/.test(row.provider) + || typeof row.model!=='string' || !row.model.trim() || row.model.length>200 + || ![row.inputPerMillion,row.outputPerMillion].every(n=>typeof n==='number' && Number.isFinite(n) && n>=0 && n<=1_000_000))throw new Error("Invalid model price."); + const key=`${row.provider}:${row.model}`;if(keys.has(key))throw new Error("Duplicate model price.");keys.add(key); + return {provider:row.provider,model:row.model.trim(),inputPerMillion:row.inputPerMillion,outputPerMillion:row.outputPerMillion}; + }); +} diff --git a/src/main/services/accountHomeIsolation.ts b/src/main/services/accountHomeIsolation.ts new file mode 100644 index 00000000..fec140bd --- /dev/null +++ b/src/main/services/accountHomeIsolation.ts @@ -0,0 +1,80 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import type { ProviderId } from "../../shared/contracts.ts"; + +/** The Accounts service id is fixed by the host; plugin replies cannot nominate a private-data exception. */ +export const ACCOUNTS_PLUGIN_ID = "canvastty-accounts"; + +/** The account a launch is billed to: an empty or "none" selection means the provider's own default sign-in. */ +export function selectedAccountId(options: Readonly> | undefined): string { + const values = options?.[ACCOUNTS_PLUGIN_ID]; + const selected = values && typeof values === "object" ? (values as Record).account : undefined; + return typeof selected === "string" && selected !== "" && selected !== "none" ? selected : "default"; +} + +/** + * Returns a candidate account home only for the host's Accounts launch contributor and matching CLI provider. + * The data directory is populated by PluginManager, never by plugin RPC. Filesystem links and existence are checked + * again immediately before the operating-system sandbox is built. + */ +export function selectedAccountHome( + provider: ProviderId, + pluginId: string, + dataDir: string | undefined, + selectedAccountId: unknown, + contributedEnv: Readonly> +): string | undefined { + if (pluginId !== ACCOUNTS_PLUGIN_ID || !dataDir || !isAbsolute(dataDir)) return undefined; + if (typeof selectedAccountId !== "string" || !selectedAccountId || selectedAccountId === "none") return undefined; + const variable = provider === "codex" ? "CODEX_HOME" : provider === "claude" ? "CLAUDE_CONFIG_DIR" : undefined; + if (!variable) return undefined; + const value = contributedEnv[variable]; + if (!value || !isAbsolute(value)) return undefined; + + const accountData = resolve(dataDir); + const homes = join(accountData, "homes"); + const selected = resolve(value); + const prefix = `${provider}-`; + if (basename(accountData) !== ACCOUNTS_PLUGIN_ID || basename(dirname(accountData)) !== "plugin-data" + || selected !== value || dirname(selected) !== homes || basename(selected) !== `${prefix}${selectedAccountId}`) return undefined; + return selected; +} + +/** + * Checks the candidate against this app's own private data directory at wrap time. The plugin-data directory, + * Accounts directory, homes directory and selected child must all be real directories, not symlinks. + */ +export function validateSelectedAccountHome(userDataPath: string, provider: ProviderId, candidate: string): string { + if (provider !== "codex" && provider !== "claude") { + throw new Error("Only the Accounts Codex or Claude home can be reopened by isolation. The agent was not started."); + } + const userData = resolve(userDataPath); + const realUserData = realpathSync.native(userData); + const pluginData = join(userData, "plugin-data"); + const accountsData = join(pluginData, ACCOUNTS_PLUGIN_ID); + const homes = join(accountsData, "homes"); + const selected = resolve(candidate); + const prefix = `${provider}-`; + if (!isAbsolute(candidate) || candidate !== selected || dirname(selected) !== homes + || !basename(selected).startsWith(prefix) || basename(selected).length <= prefix.length) { + throw new Error("The selected Accounts CLI home must be a direct child for this provider. The agent was not started."); + } + + const expected = [ + [pluginData, join(realUserData, "plugin-data")], + [accountsData, join(realUserData, "plugin-data", ACCOUNTS_PLUGIN_ID)], + [homes, join(realUserData, "plugin-data", ACCOUNTS_PLUGIN_ID, "homes")], + [selected, join(realUserData, "plugin-data", ACCOUNTS_PLUGIN_ID, "homes", basename(selected))] + ] as const; + try { + for (const [path, canonical] of expected) { + const info = lstatSync(path); + if (!info.isDirectory() || info.isSymbolicLink() || realpathSync.native(path) !== canonical) { + throw new Error("not a real directory"); + } + } + } catch { + throw new Error("The selected Accounts CLI home is missing or crosses a symbolic link. The agent was not started."); + } + return expected.at(-1)![1]; +} diff --git a/src/main/services/agent-browser/OrchestrationTools.ts b/src/main/services/agent-browser/OrchestrationTools.ts index ba6afebf..2ff0d40d 100644 --- a/src/main/services/agent-browser/OrchestrationTools.ts +++ b/src/main/services/agent-browser/OrchestrationTools.ts @@ -1,8 +1,9 @@ import type { OrchestrationCommandHandler, OrchestrationRequest } from "./orchestration-protocol.ts"; +import { selectedAccountId } from "../accountHomeIsolation.ts"; import { orchestrationBridgeError } from "./orchestration-protocol.ts"; import type { AgentProviderId, ProviderId, SessionRole } from "../../../shared/contracts.ts"; import { launchEffortProblem, launchModelProblem } from "../../../shared/launchModel.ts"; -import { PromptNotDeliveredError, type AgentControlService, type SpawnAgentRequest } from "../AgentControlService.ts"; +import { PromptNotDeliveredError, type AgentControlService, type AgentResult, type AgentReviewResult, type SpawnAgentRequest } from "../AgentControlService.ts"; import { LaunchRefusal } from "../launchRefusal.ts"; import type { PluginAgentTools } from "../PluginAgentTools.ts"; import { @@ -15,6 +16,18 @@ import { import { AGENT_PROVIDERS } from "../../../shared/contracts.ts"; import { listProviderDirectory, type ProviderDirectorySources } from "../providerDirectory.ts"; import type { McpToolDefinition } from "../../../agent-browser/orchestration-catalog.mjs"; +import type { OrchestrationBudgetService } from "../OrchestrationBudgetService.ts"; +import type { OrchestrationTaskBoard, OrchestrationTaskPatch } from "../OrchestrationTaskBoard.ts"; +import type { OrchestrationTemplateService } from "../OrchestrationTemplateService.ts"; + +const TASK_TOOL_NAMES = new Set(["list_tasks", "claim_task", "update_task", "complete_task"]); +const ORCHESTRATOR_ONLY_ADDITIONAL_TOOLS = new Set(["retry_agent"]); + +export interface ScopedOrchestrationIntegrations { + taskBoard?: OrchestrationTaskBoard; + budget?: Pick; + templates?: OrchestrationTemplateService; +} /** * The only bridge between the orchestration MCP surface and session control. @@ -26,22 +39,33 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { private readonly control: AgentControlService; private readonly plugins: Pick | null; private readonly providers: ProviderDirectorySources; + private readonly integrations: ScopedOrchestrationIntegrations; constructor( control: AgentControlService, plugins: Pick | null = null, - providers: ProviderDirectorySources = { cli: () => null, limits: () => null } + providers: ProviderDirectorySources = { cli: () => null, limits: () => null }, + integrations: ScopedOrchestrationIntegrations = {} ) { this.control = control; this.plugins = plugins; this.providers = providers; + this.integrations = integrations; } - /** Orchestrators see the core tools; every role sees the plugin tools that list it (EP-6). */ + /** Orchestrators see core tools; agents also see task-board tools scoped to their own root. */ listTools(sessionId: string): McpToolDefinition[] { + if (this.control.isReadOnlyReviewer(sessionId)) return []; const session = this.control.status(sessionId); + const core = ORCHESTRATION_TOOL_DEFINITIONS.filter((tool) => { + if (TASK_TOOL_NAMES.has(tool.name)) return Boolean(this.integrations.taskBoard); + if (tool.name === "get_task_budget") return Boolean(this.integrations.budget); + if (tool.name === "list_orchestration_templates" || tool.name === "apply_orchestration_template") return Boolean(this.integrations.templates); + if (ORCHESTRATOR_ONLY_ADDITIONAL_TOOLS.has(tool.name)) return session.role === "orchestrator"; + return session.role === "orchestrator"; + }); return [ - ...(session.role === "orchestrator" ? ORCHESTRATION_TOOL_DEFINITIONS : []), + ...core, ...(this.plugins?.list(session.role, session.provider) ?? []) ]; } @@ -50,7 +74,20 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { try { if (signal?.aborted) throw canceledError(); const session = this.control.status(sessionId); + if (this.control.isReadOnlyReviewer(sessionId)) { + throw orchestrationBridgeError("INVALID_REQUEST", "Read-only reviewers cannot call agent or plugin tools.", false); + } if (isPluginOrchestrationTool(request.tool)) return await this.plugin(sessionId, session, request); + if (TASK_TOOL_NAMES.has(request.tool)) { + if (session.provider === "terminal") throw orchestrationBridgeError("INVALID_REQUEST", "Plain terminals cannot use the orchestration task board.", false); + return await this.taskTool(sessionId, request.tool, request.arguments); + } + if (request.tool === "get_task_budget") { + const budget = this.control.taskBudget(sessionId); + return budget ? ({ budget } as Record) : { available: false, reason: "No task budget is configured." }; + } + if (request.tool === "list_orchestration_templates") return await this.listTemplates(sessionId); + if (request.tool === "apply_orchestration_template") return await this.applyTemplate(sessionId, request.arguments); // Plugin tools may reach other roles' sessions through the same bridge; the core tools never do. if (session.role !== "orchestrator") { throw orchestrationBridgeError("INVALID_REQUEST", "Only orchestrator sessions can use CanvasTTY's agent tools.", false); @@ -67,11 +104,13 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { case "observe_agent": return this.observe(sessionId, request.arguments); case "get_agent_result": - return this.result(sessionId, request.arguments); + return await this.result(sessionId, request.arguments); case "cancel_agent": return this.cancel(sessionId, request.arguments); case "list_agents": return this.list(sessionId); + case "retry_agent": + return await this.retry(sessionId, request.arguments, signal); default: throw orchestrationBridgeError("INVALID_REQUEST", "Unsupported orchestration tool.", false); } @@ -98,6 +137,7 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { throw orchestrationBridgeError("INVALID_REQUEST", "That plugin tool is not available to this session.", false); } try { + this.control.assertInputAllowed(sessionId); const result = await this.plugins.call(sessionId, session.role, request.tool, request.arguments); return { pluginTool: true, text: result.content, isError: result.isError }; } catch (error) { @@ -110,6 +150,77 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { return listProviderDirectory(this.providers, pluginTools) as unknown as Record; } + private async taskTool(sessionId: string, tool: string, args: Record): Promise> { + const board = this.integrations.taskBoard; + if (!board) throw orchestrationBridgeError("INVALID_REQUEST", "The shared task board is not configured.", false); + const lineage = this.control.lineage(sessionId); + const root = this.control.taskRoot(sessionId); + const actor = lineage[0]!; + const authorityId=actor.role==="orchestrator" && !actor.parentSessionId ? root.id : sessionId; + try { + switch (tool) { + case "list_tasks": + return await board.listTasks(root.cwd, root.id); + case "claim_task": + return { task: await board.claimTask(root.cwd, root.id, sessionId, this.control.maskText(actor.title,65_536), args.taskId as string) }; + case "update_task": { + const patch: OrchestrationTaskPatch = {}; + for (const key of ["title", "description", "progress", "status", "ownerSessionId", "ownerName", "dependencies"]) { + if (Object.prototype.hasOwnProperty.call(args, key)) Object.assign(patch, { [key]: args[key] }); + } + if(patch.ownerSessionId && this.control.taskRoot(patch.ownerSessionId).id!==root.id)throw new Error("Task owner must belong to this orchestration tree."); + for(const key of ["title","description","progress","ownerName"] as const)if(typeof patch[key]==="string")patch[key]=this.control.maskText(patch[key]!,65_536); + return { task: await board.updateTask(root.cwd, root.id, authorityId, args.taskId as string, patch) }; + } + case "complete_task": + return { task: await board.completeTask(root.cwd, root.id, authorityId, args.taskId as string, this.control.maskText(args.result as string,65_536)) }; + default: + throw orchestrationBridgeError("INVALID_REQUEST", "Unsupported task-board tool.", false); + } + } catch (error) { + if (error && typeof error === "object" && "bridgeError" in error) throw error; + throw orchestrationBridgeError("INVALID_REQUEST", error instanceof Error ? error.message : "Task-board operation failed.", false); + } + } + + private async listTemplates(sessionId: string): Promise> { + const service = this.integrations.templates; + if (!service) throw orchestrationBridgeError("INVALID_REQUEST", "Orchestration templates are not configured.", false); + const root = this.control.taskRoot(sessionId); + const listing = await service.list(root.cwd); + return { ...listing, templates: listing.templates.filter(template => template.trusted === true) } as unknown as Record; + } + + private async applyTemplate(sessionId: string, args: Record): Promise> { + const service = this.integrations.templates; + if (!service) throw orchestrationBridgeError("INVALID_REQUEST", "Orchestration templates are not configured.", false); + const root = this.control.taskRoot(sessionId); + const available = await service.list(root.cwd); + const template = available.templates.find((item) => item.id === args.templateId); + if (!template) throw orchestrationBridgeError("INVALID_REQUEST", `Template "${String(args.templateId).slice(0, 80)}" is not available. Call list_orchestration_templates for the current choices.`, false); + return { + templateId: template.id, + name: template.name, + expectedSubagents: template.expectedSubagents, + instructions: service.instructions(template).replace("{{TASK}}", args.task as string), + ...(available.errors.length > 0 ? { templateErrors: available.errors } : {}) + }; + } + + private async retry(orchestratorId: string, args: Record, signal?: AbortSignal): Promise> { + const target = args.sessionId as string; + if (target === orchestratorId) throw orchestrationBridgeError("INVALID_REQUEST", "retry_agent retries a subagent, not this session.", false); + this.requireOwned(orchestratorId, target); + if (signal?.aborted) throw canceledError(); + const created = await this.control.retry(target, args.reason as string | undefined, signal); + if (signal?.aborted) { + try { this.control.cancel(created.id); } catch { /* already ended */ } + throw canceledError(); + } + return { sessionId: created.id, provider: created.provider, title: created.title, profile: created.profile, + ...(created.model !== undefined ? { model: created.model } : {}), ...(created.effort !== undefined ? { effort: created.effort } : {}) }; + } + private async wait(orchestratorId: string, args: Record, signal?: AbortSignal): Promise> { const target = args.sessionId as string; // Its own session is in its subtree, but waiting on itself would only ever time out. @@ -123,7 +234,9 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { timeoutMs: Math.min(MAX_AGENT_WAIT_SECONDS, Math.max(1, seconds)) * 1_000, ...(signal ? { signal } : {}) }); - return { ...result }; + if (result.reason === "closed") return { ...result }; + const final = await this.control.resultWithReview(target); + return { ...result, ...(final.review ? { review: final.review } : {}) }; } catch (error) { if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw canceledError(); throw error; @@ -135,16 +248,50 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { throw orchestrationBridgeError("INVALID_REQUEST", unknownProviderMessage(args.provider), false); } const provider = args.provider as AgentProviderId; - const problem = (args.model !== undefined ? launchModelProblem(provider, args.model) : null) + const hasExplicitModel = args.model !== undefined; + const problem = (hasExplicitModel ? launchModelProblem(provider, args.model) : null) ?? (args.effort !== undefined ? launchEffortProblem(provider, args.effort) : null); if (problem) throw orchestrationBridgeError("INVALID_REQUEST", `${problem} Call list_providers for what ${provider} takes.`, false); const profile = this.control.profileFor(orchestratorId, provider, args.profile); if ("error" in profile) throw orchestrationBridgeError("INVALID_REQUEST", profile.error, false); - if (args.model !== undefined) { + if (hasExplicitModel) { let unknown: string | null = null; try { unknown = await this.providers.checkModel?.(provider, args.model as string) ?? null; } catch { unknown = null; } if (unknown) throw orchestrationBridgeError("INVALID_REQUEST", unknown, false); } + if (args.reviewModel !== undefined) { + if (args.review !== true) throw orchestrationBridgeError("INVALID_REQUEST", "reviewModel can be set only when review:true.", false); + const reviewProblem = launchModelProblem(provider, args.reviewModel); + if (reviewProblem) throw orchestrationBridgeError("INVALID_REQUEST", `${reviewProblem} Call list_providers for the valid reviewer models.`, false); + const unknownReviewModel = await this.providers.checkModel?.(provider, args.reviewModel as string) ?? null; + if (unknownReviewModel) throw orchestrationBridgeError("INVALID_REQUEST", unknownReviewModel, false); + if (hasExplicitModel && args.reviewModel === args.model) { + throw orchestrationBridgeError("INVALID_REQUEST", "The review model must differ from the worker model.", false); + } + } + let model = hasExplicitModel ? args.model as string : undefined; + let effort = args.effort as SpawnAgentRequest["effort"] | undefined; + if (!hasExplicitModel && this.control.taskBudget(orchestratorId)?.paused) { + throw orchestrationBridgeError("INVALID_REQUEST", this.control.taskBudget(orchestratorId)?.reason ?? "This task's budget is exhausted.", false); + } + // A spawn without an account runs on the orchestrator's account (never silently on the CLI's default model). + let launchOptions = args.launchOptions as SpawnAgentRequest["launchOptions"]; + let accountSource: "inherited" | "explicit" | "none" = "none"; + if (typeof this.control.subagentAccount === "function") { + try { + const account = this.control.subagentAccount(orchestratorId, provider, launchOptions); + launchOptions = account.launchOptions; + accountSource = account.source; + } catch (error) { + throw orchestrationBridgeError("INVALID_REQUEST", error instanceof Error ? error.message : String(error), false); + } + } + const accountId = selectedAccountId(launchOptions as Record | undefined); + // A model account supplies its model and remains visible in the launch result. + const accountChosen = accountId !== "default"; + if (args.reviewModel !== undefined && model === args.reviewModel) { + throw orchestrationBridgeError("INVALID_REQUEST", "The review model must differ from the selected worker model.", false); + } if (signal?.aborted) throw canceledError(); const created = await this.control.spawn({ parentSessionId: orchestratorId, @@ -152,9 +299,11 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { cwd: args.cwd as string, ...(args.title !== undefined ? { title: args.title as string } : {}), ...(args.prompt !== undefined ? { initialPrompt: args.prompt as string } : {}), - ...(args.launchOptions !== undefined ? { launchOptions: args.launchOptions as SpawnAgentRequest["launchOptions"] } : {}), - ...(args.model !== undefined ? { model: args.model as string } : {}), - ...(args.effort !== undefined ? { effort: args.effort as SpawnAgentRequest["effort"] } : {}), + ...(launchOptions !== undefined ? { launchOptions } : {}), + ...(model !== undefined ? { model } : {}), + ...(effort !== undefined ? { effort } : {}), + ...(args.review === true ? { review: true } : {}), + ...(typeof args.reviewModel === "string" ? { reviewModel: args.reviewModel } : {}), profile: profile.profile }, signal).catch((error: unknown) => { if (signal?.aborted) throw canceledError(); @@ -169,6 +318,12 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { } throw canceledError(); } + const servedBy = { + provider, + account: accountChosen ? accountId : null, + accountSource: accountChosen ? accountSource : "none", + model: created.model ?? (accountChosen ? `set by model account ${accountId.slice(0, 40)}` : defaultModelText(provider)) + }; return { sessionId: created.id, provider: created.provider, @@ -177,7 +332,9 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { profile: created.profile, ...(profile.inherited ? { profileInherited: true } : {}), ...(created.model !== undefined ? { model: created.model } : {}), - ...(created.effort !== undefined ? { effort: created.effort } : {}) + ...(created.effort !== undefined ? { effort: created.effort } : {}), + servedBy, + ...(args.review === true ? { reviewRequested: true } : {}), }; } @@ -213,9 +370,9 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { }; } - private result(orchestratorId: string, args: Record): Record { + private async result(orchestratorId: string, args: Record): Promise> { this.requireOwned(orchestratorId, args.sessionId as string); - const result = this.control.result(args.sessionId as string); + const result = await this.control.resultWithReview(args.sessionId as string); return { sessionId: result.sessionId, state: result.state, @@ -223,7 +380,8 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { exitCode: result.exitCode, output: result.output, ...(result.exitLines ? { exitLines: result.exitLines } : {}), - ...(result.answer ? { answer: result.answer } : {}) + ...(result.answer ? { answer: result.answer } : {}), + ...(result.review ? { review: result.review } : {}) }; } @@ -260,3 +418,10 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { function canceledError(): Error { return orchestrationBridgeError("CANCELED", "Orchestration command was canceled.", true); } + +/** What serves a subagent that names neither a model nor a model account. */ +function defaultModelText(provider: AgentProviderId): string { + return provider === "opencode" + ? "OpenCode's default model (its own configuration decides; with none set OpenCode uses its free OpenCode Zen model on opencode.ai)" + : `${provider}'s default model`; +} diff --git a/src/main/services/agent-runtime/AgentRuntimeBridge.ts b/src/main/services/agent-runtime/AgentRuntimeBridge.ts index 18b6ef2d..40d1aec4 100644 --- a/src/main/services/agent-runtime/AgentRuntimeBridge.ts +++ b/src/main/services/agent-runtime/AgentRuntimeBridge.ts @@ -1,4 +1,5 @@ import type { ProviderId } from "../../../shared/contracts.ts"; +import { dirname, isAbsolute, join } from "node:path"; import { AGENT_RUNTIME_ENV, CAPTURE_ANSWER_ENV, @@ -38,6 +39,7 @@ export interface PreparedAgentRuntimePtyLaunch { export interface AgentRuntimeLaunchCoordinator { prepareLaunch(input: PrepareAgentRuntimeLaunchInput): PreparedAgentRuntimePtyLaunch; currentStatus(terminalSessionId: string): RuntimeLifecycleState | null; + readableRuntimePaths?(): readonly string[]; } export interface AgentRuntimeBridgeOptions extends ProviderRuntimeLaunchOptions { @@ -52,6 +54,7 @@ export interface AgentRuntimeBridgeOptions extends ProviderRuntimeLaunchOptions } export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { + private readonly runtimeReadPaths: readonly string[]; private readonly gateway: RuntimeGateway; private readonly providers: ProviderRuntimeLaunchAdapters; /** Running sessions and whether each got the decision hook. */ @@ -68,10 +71,21 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { this.decisionBudgetMs = options.decisionBudgetMs; this.claudeHttpHooks = options.claudeHttpHooks; this.providers = new ProviderRuntimeLaunchAdapters(options); + // File grants, never the containing source tree: a reviewer may review CanvasTTY itself. + const helpers = [options.helper, ...(options.permissionGate ? [options.permissionGate] : []), + ...(options.pluginHooks ? [options.pluginHooks.runner] : [])]; + const runtimeFiles = ["hook-helper.mjs", "permission-gate.mjs", "runtime-client.mjs", "runtime-protocol.mjs", + "ndjson.mjs", "path-inside.mjs", "opencode-plugin.mjs", "opencode-final-answer.mjs", "opencode-decisions.mjs", "omp-extension.mjs", + "plugin-hook-runner.mjs", "plugin-hook-dispatch.mjs"]; + this.runtimeReadPaths = Object.freeze([...new Set([ + ...helpers.flatMap(helper => [helper.command, ...helper.args.filter(isAbsolute)]), + ...runtimeFiles.map(file => join(dirname(options.openCodePluginPath), file))])]); this.coreHooksEnabled = options.coreHooksEnabled !== false; if (options.recoverOnStart) this.providers.recoverConfigurations(); } + readableRuntimePaths(): readonly string[] { return this.runtimeReadPaths; } + prepareLaunch(input: PrepareAgentRuntimeLaunchInput): PreparedAgentRuntimePtyLaunch { // The decision hook talks to the gateway over its own capability, with or without agent status hooks. const decisions = (input.decisions ?? this.wantsDecisions?.(input.provider) === true) diff --git a/src/main/services/configuredModel.ts b/src/main/services/configuredModel.ts new file mode 100644 index 00000000..3cc84d75 --- /dev/null +++ b/src/main/services/configuredModel.ts @@ -0,0 +1,20 @@ +import {readFileSync,statSync} from "node:fs"; +import {join} from "node:path"; +import type {AgentProviderId} from "../../shared/contracts.ts"; + +/** Reads a CLI's explicit default only. An absent/default alias is not guessed to be a catalog model. */ +export function configuredModel(provider:AgentProviderId,homes:{codex:string;claude:string;opencode:string}):string|null{ + try{ + let file:string; + switch(provider){ + case "codex":file=join(homes.codex,"config.toml");break; + case "claude":file=join(homes.claude,"settings.json");break; + case "opencode":file=join(homes.opencode,"opencode.json");break; + default:return null; + } + if(statSync(file).size>256_000)return null; + const text=readFileSync(file,"utf8"); + const model=provider==="codex" ? /^\s*model\s*=\s*["']([^"'\n]+)["']/mu.exec(text.split(/^\s*\[/mu)[0])?.[1] : JSON.parse(text).model; + return typeof model==="string" && /^[\w./:@+-]{1,200}$/u.test(model) ? model : null; + }catch{return null;} +} diff --git a/src/main/services/isolation/AgentIsolation.ts b/src/main/services/isolation/AgentIsolation.ts index 5d648c29..f209f9e3 100644 --- a/src/main/services/isolation/AgentIsolation.ts +++ b/src/main/services/isolation/AgentIsolation.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { delimiter, dirname, join } from "node:path"; +import { delimiter, dirname, isAbsolute, join, relative } from "node:path"; import type { ProviderId, SessionIsolation } from "../../../shared/contracts.ts"; import { autoKind, PROFILE_RANK, type LaunchProfile } from "../../../shared/autoMode.ts"; import { LaunchRefusal } from "../launchRefusal.ts"; @@ -9,6 +9,8 @@ import { isolationPaths } from "./isolationPaths.ts"; import { seatbeltProfile } from "./seatbelt.ts"; import { bubblewrapArguments, projectHooks } from "./bubblewrap.ts"; import { LinuxHostPaths } from "./linuxHostPaths.ts"; +import { validateSelectedAccountHome } from "../accountHomeIsolation.ts"; +import { prepareReviewerHome } from "./reviewerHome.ts"; export const SANDBOX_EXEC = "/usr/bin/sandbox-exec"; /** The prefix of a launch's own folder (its profile and TMPDIR) in the temporary folder. */ @@ -21,6 +23,132 @@ export const BUBBLEWRAP_USERNS_DOCS = "docs/installing-and-security.md#linux-whe const BUBBLEWRAP_PROBE_RETRY_MS = 60_000; export const ISOLATION_NOTE = "CanvasTTY agent isolation: files can be written only inside the project folder, $TMPDIR and this CLI's own folders; SSH/cloud keys, other agents' credentials and CanvasTTY's tokens cannot be read; other processes, apps and daemons are out of reach. \"Operation not permitted\" outside that is this rule: do the work inside the project, or tell the person what you need."; +/** Restricted reviewers keep HOME, TMPDIR and their profile together below CanvasTTY's private run directory. */ +function reviewerRunRoot(userDataPath: string): string { + const root = join(realpathSync(userDataPath), "launch-runs"); + try { mkdirSync(root, { mode: 0o700 }); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } + const rootInfo = lstatSync(root); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory() || realpathSync(root) !== root) { + throw new LaunchRefusal("The private launch-runs folder is not a safe directory. The reviewer was not started."); + } + chmodSync(root, 0o700); + return root; +} + +const REVIEWER_SYSTEM_READ_ROOTS = [ + "/bin", "/usr/bin", "/sbin", "/usr/sbin", "/usr/lib", "/usr/libexec", "/usr/share", + "/System/Library", "/System/Volumes/Preboot/Cryptexes/OS", "/dev", "/private/var/db/dyld" +]; +const MAX_REVIEWER_RUNTIME_FILES = 256; +const MAX_REVIEWER_RUNTIME_DEPTH = 16; + +/** Resolve only the selected runtime's exact Mach-O dependencies; never grant its containing directory. */ +function reviewerRuntimeDependencies(roots: readonly string[], env: Readonly>): string[] { + const runtimeFiles = new Set(); + const queue: Array<{ path: string; executablePath: string; runpaths: string[]; depth: number; selectedCommand: boolean }> = roots + .filter((path) => isAbsolute(path) && existsSync(path)) + .map((path, index) => ({ path, executablePath: realpathSync(path), runpaths: [], depth: 0, selectedCommand: index === 0 })); + const seen = new Set(); + const isSystemFile = (path: string): boolean => REVIEWER_SYSTEM_READ_ROOTS.some((root) => { + const rest = relative(root, path); + return rest === "" || (!rest.startsWith("..") && !isAbsolute(rest)); + }); + const resolveRunpath = (value: string, loader: string, executable: string): string | null => { + if (value.startsWith("@loader_path/")) return join(dirname(loader), value.slice("@loader_path/".length)); + if (value.startsWith("@executable_path/")) return join(dirname(executable), value.slice("@executable_path/".length)); + return isAbsolute(value) ? value : null; + }; + const runtimePaths = (file: string, executable: string): string[] => { + const result = spawnSync("/usr/bin/otool", ["-l", file], { encoding: "utf8", timeout: 5_000, maxBuffer: 1024 * 1024 }); + if (result.error || result.status !== 0) return []; + const lines = (result.stdout ?? "").split(/\r?\n/u); + const paths: string[] = []; + for (let index = 0; index < lines.length; index++) { + if (lines[index].trim() !== "cmd LC_RPATH") continue; + for (let next = index + 1; next < Math.min(lines.length, index + 5); next++) { + const match = /^\s*path\s+(.+?)\s+\(offset\s+\d+\)\s*$/u.exec(lines[next]); + if (!match) continue; + const path = resolveRunpath(match[1], file, executable); + if (path) paths.push(path); + break; + } + } + return paths; + }; + const shebangInterpreter = (file: string): string | null => { + const descriptor = openSync(file, "r"); + let firstLine: string; + try { + const buffer = Buffer.alloc(4096); + const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0); + firstLine = buffer.toString("utf8", 0, bytesRead).split(/\r?\n/u, 1)[0]; + } finally { + closeSync(descriptor); + } + if (!firstLine.startsWith("#!")) return null; + const words = firstLine.slice(2).trim().split(/\s+/u).filter(Boolean); + let interpreter = words.shift(); + if (!interpreter) throw new LaunchRefusal("The diff-only reviewer command has an invalid interpreter line."); + if (interpreter === "/usr/bin/env") { + if (words[0] === "-S") words.shift(); + if (words[0]?.startsWith("-")) throw new LaunchRefusal("The diff-only reviewer command uses an unsupported env interpreter option."); + const name = words[0]; + if (!name) throw new LaunchRefusal("The diff-only reviewer command has no env interpreter."); + const found = (env.PATH ?? "").split(delimiter).filter(Boolean).map((folder) => join(folder, name)).find((path) => existsSync(path)); + if (!found) throw new LaunchRefusal("The diff-only reviewer interpreter could not be resolved from PATH."); + interpreter = found; + } + if (!isAbsolute(interpreter) || !existsSync(interpreter)) throw new LaunchRefusal("The diff-only reviewer interpreter could not be verified."); + return realpathSync(interpreter); + }; + + while (queue.length > 0) { + const current = queue.shift()!; + const file = realpathSync(current.path); + if (isSystemFile(file)) continue; + if (seen.has(file)) continue; + if (seen.size >= MAX_REVIEWER_RUNTIME_FILES || current.depth > MAX_REVIEWER_RUNTIME_DEPTH) { + throw new LaunchRefusal("The diff-only reviewer runtime has too many linked libraries to verify safely."); + } + seen.add(file); + const stat = statSync(file); + if (!stat.isFile() || stat.nlink !== 1) throw new LaunchRefusal("A diff-only reviewer runtime file is not a single-link regular file."); + const linked = spawnSync("/usr/bin/otool", ["-L", file], { encoding: "utf8", timeout: 5_000, maxBuffer: 1024 * 1024 }); + if (linked.error || linked.status !== 0) { + if (!current.selectedCommand) continue; // AgentRuntimeBridge can also hand us exact text hooks and config files. + const interpreter = shebangInterpreter(file); + if (!interpreter) throw new LaunchRefusal("The diff-only reviewer command is not a verifiable macOS runtime."); + queue.push({ path: interpreter, executablePath: interpreter, runpaths: current.runpaths, depth: current.depth + 1, selectedCommand: false }); + continue; + } + const localRunpaths = runtimePaths(file, current.executablePath); + const availableRunpaths = [...new Set([...localRunpaths, ...current.runpaths])]; + const lines = (linked.stdout ?? "").split(/\r?\n/u).slice(1); + for (const line of lines) { + const entry = line.trim(); + if (!entry) continue; + const installName = entry.replace(/\s+\(compatibility version\s+.*$/u, "").replace(/\s+\(current version\s+.*$/u, ""); + const candidates = installName.startsWith("@rpath/") + ? availableRunpaths.map((path) => join(path, installName.slice("@rpath/".length))) + : [resolveRunpath(installName, file, current.executablePath)].filter((path): path is string => Boolean(path)); + const dependency = candidates.find((candidate) => existsSync(candidate)); + if (!dependency) { + if (installName.startsWith("@")) throw new LaunchRefusal("A diff-only reviewer runtime dependency could not be resolved safely."); + continue; // A Mach-O may list a dylib for another architecture that is not installed here. + } + const resolved = realpathSync(dependency); + if (isSystemFile(resolved)) continue; + const dependencyStat = statSync(resolved); + if (!dependencyStat.isFile() || dependencyStat.nlink !== 1) throw new LaunchRefusal("A diff-only reviewer runtime dependency is not a single-link regular file."); + runtimeFiles.add(dependency); + runtimeFiles.add(resolved); + queue.push({ path: resolved, executablePath: current.executablePath, runpaths: availableRunpaths, depth: current.depth + 1, selectedCommand: false }); + } + } + return [...runtimeFiles]; +} + export interface AgentIsolationOptions { /** CanvasTTY's userData folder (its private data is never readable inside the layer). */ userDataPath: string; @@ -48,6 +176,8 @@ export interface AgentIsolationOptions { export interface IsolationDecisionInput { provider: ProviderId; + /** Original task project, including launches in a separate reviewer workspace. */ + cwd?: string; profile: LaunchProfile; /** Not launched by the person: a subagent, or an agent a plugin started. */ delegated: boolean; @@ -75,6 +205,13 @@ export interface IsolationLaunch { env: Record; /** Folders under CanvasTTY's private data this launch was handed (its control grant, its account home). */ grantedPrivate?: readonly string[]; + /** Host-derived candidate from the selected Accounts contribution; validated against userData at wrap time. */ + accountHome?: string; + /** Host-derived project/session roots hidden from the diff-only reviewer. */ + deniedReadPaths?: readonly string[]; + /** A diff-only reviewer uses fresh CLI state and narrowly granted runtime files. */ + restrictHomeReads?: boolean; + runtimeReadable?: readonly string[]; /** The launch profile: in plan the project is not writable. */ profile?: LaunchProfile; } @@ -83,6 +220,8 @@ export interface WrappedLaunch { command: string; args: string[]; env: Record; + /** Host-owned summary for a safe platform-specific restriction (shown on the session card). */ + isolationReason?: string; /** Removes the launch's own folder (profile and temporary files); call when its process exited or was replaced. */ cleanup(): void; } @@ -100,7 +239,7 @@ export class AgentIsolation { private bubblewrap: string | null | undefined; private readonly linuxHostPaths: LinuxHostPaths; /** The last bubblewrap check: a working one is kept, a failed one is repeated after BUBBLEWRAP_PROBE_RETRY_MS. */ - private bubblewrapCheck: { path: string; failure: string | null; at: number } | null = null; + private readonly isolationChecks = new Map(); constructor(options: AgentIsolationOptions) { this.options = options; @@ -121,7 +260,7 @@ export class AgentIsolation { if (this.platform === "linux") { if (this.bubblewrap === undefined) this.bubblewrap = findOnPath("bwrap", exists); if (!this.bubblewrap) return { reason: "bubblewrap (bwrap) is not installed; install it to isolate agents on Linux." }; - const failure = this.bubblewrapFailure(this.bubblewrap); + const failure = this.cachedProbe(`bwrap:${this.bubblewrap}`, () => (this.options.bubblewrapProbe ?? probeBubblewrap)(this.bubblewrap!)); return failure === null ? { layer: "bubblewrap" } : { reason: `bubblewrap (bwrap) is installed but cannot create its sandbox here (${failure}); Ubuntu 24.04 and later block unprivileged user namespaces through AppArmor. ${BUBBLEWRAP_USERNS_DOCS} says how to allow it.` }; @@ -178,32 +317,81 @@ export class AgentIsolation { folder = null; }; try { - const root = realpathSync(this.options.tempRoot ?? tmpdir()); + const root = launch.restrictHomeReads + ? reviewerRunRoot(this.options.userDataPath) + : realpathSync(this.options.tempRoot ?? tmpdir()); folder = mkdtempSync(join(root, ISOLATION_FOLDER_PREFIX)); chmodSync(folder, 0o700); const temp = join(folder, "tmp"); mkdirSync(temp, { mode: 0o700 }); const cwd = realpathSync(launch.cwd); + // Avoid resolving an executable through a chain of HOME symlinks from inside the reviewer sandbox. The host + // resolves only the selected executable; its file grant already covers this canonical spelling. + const launchCommand = launch.restrictHomeReads ? realpathSync(launch.command) : launch.command; + if (launch.restrictHomeReads && (launch.profile !== "plan" || !launch.deniedReadPaths?.length)) + throw new LaunchRefusal("Restricted reviewer reads require a Plan profile and verified project roots."); + const deniedReadPaths = launch.deniedReadPaths?.map((path) => { + if (!isAbsolute(path)) throw new LaunchRefusal("A reviewer isolation root is not absolute."); + const denied = realpathSync(path); + const within = (path: string, folder: string): boolean => { + const rest = relative(folder, path); + return rest === "" || (!rest.startsWith("..") && !isAbsolute(rest)); + }; + if (!statSync(denied).isDirectory() || denied === dirname(denied) + || within(cwd, denied) || within(denied, cwd)) { + throw new LaunchRefusal("A reviewer isolation root is missing or overlaps its temporary workspace."); + } + return denied; + }); + if (launch.deniedReadPaths && deniedReadPaths?.length !== launch.deniedReadPaths.length) { + throw new LaunchRefusal("A reviewer isolation root could not be verified."); + } + const launchEnvironment = { ...launch.env }; + const accountHome = launch.accountHome === undefined + ? undefined + : validateSelectedAccountHome(this.options.userDataPath, launch.provider, launch.accountHome); + // A reviewer on the worker's model account: a copy of the account's run file in the reviewer's own temp folder + // (CanvasTTY's launch-runs stays unreadable to it). + const accountRunSource = launch.restrictHomeReads && launch.provider === "opencode" + ? accountLaunchRunFile(launchEnvironment.OPENCODE_CONFIG, this.options.userDataPath) : undefined; + let accountRunConfig: string | undefined; + if (accountRunSource) { + accountRunConfig = join(temp, "account-opencode.json"); + copyFileSync(accountRunSource, accountRunConfig, fsConstants.COPYFILE_EXCL); + chmodSync(accountRunConfig, 0o600); + } + if (launch.restrictHomeReads) prepareReviewerHome(launchEnvironment, launch.provider, temp, launch.runtimeReadable ?? [], accountRunConfig); + const reviewerRuntimeFiles = launch.restrictHomeReads && this.platform === "darwin" + ? reviewerRuntimeDependencies([launchCommand, ...(launch.runtimeReadable ?? [])], launchEnvironment) + : []; const paths = isolationPaths({ provider: launch.provider, cwd, sessionTemp: temp, - env: launch.env, + env: launchEnvironment, hostEnvironment: this.hostEnvironment, userDataPath: this.options.userDataPath, sessionId: launch.sessionId, - ...(launch.grantedPrivate ? { grantedPrivate: launch.grantedPrivate } : {}), + ...(launch.restrictHomeReads ? { privateRunDirectory: folder } : {}), + ...(deniedReadPaths ? { deniedReadPaths } : {}), + ...(launch.restrictHomeReads ? { restrictHomeReads: true, runtimeReadable: [...reviewerRuntimeFiles, launchCommand, + ...(launch.runtimeReadable ?? [])] } : {}), + ...((launch.grantedPrivate || accountHome) ? { + grantedPrivate: [...(launch.grantedPrivate ?? []), ...(accountHome && !launch.restrictHomeReads ? [accountHome] : [])] + } : {}), ...(launch.profile === "plan" ? { readOnlyProject: true } : {}) }); // `git init` and `git clone` copy git's template, sample hooks included: an empty one writes no hooks. const gitTemplate = join(folder, "git-template"); mkdirSync(gitTemplate, { mode: 0o500 }); // An agent that meets "Operation not permitted" can read why here, instead of trying other ways around it. - const env = { ...launch.env, TMPDIR: `${temp}/`, TMP: temp, TEMP: temp, GIT_TEMPLATE_DIR: gitTemplate, [ISOLATION_ENV]: ISOLATION_NOTE }; + const isolationNote = ISOLATION_NOTE; + const env: Record = { ...launchEnvironment, TMPDIR: `${temp}/`, TMP: temp, TEMP: temp, GIT_TEMPLATE_DIR: gitTemplate, [ISOLATION_ENV]: isolationNote }; if (available.layer === "seatbelt") { const profilePath = join(folder, "profile.sb"); writeFileSync(profilePath, seatbeltProfile(paths), { mode: 0o600, flag: "wx" }); - return { command: this.options.sandboxExecPath ?? SANDBOX_EXEC, args: ["-f", profilePath, launch.command, ...launch.args], env, cleanup }; + return { command: this.options.sandboxExecPath ?? SANDBOX_EXEC, args: ["-f", profilePath, launchCommand, ...launch.args], env, + cleanup }; } // bubblewrap mounts only what exists: the CLI's own missing folders are created and a missing protected file // gets a placeholder first (LinuxHostPaths), both undone by cleanup(). @@ -211,7 +399,12 @@ export class AgentIsolation { const kind = (path: string): "file" | "directory" | null => { try { const stat = statSync(path); return stat.isDirectory() ? "directory" : stat.isFile() ? "file" : null; } catch { return null; } }; - const args = bubblewrapArguments(paths, { command: launch.command, args: launch.args, cwd, ...(launch.env.XDG_RUNTIME_DIR ? { runtimeDir: launch.env.XDG_RUNTIME_DIR } : {}) }, kind); + let command = launchCommand; + let commandArgs = [...launch.args]; + const args = bubblewrapArguments(paths, { + command, args: commandArgs, cwd, + ...(launch.env.XDG_RUNTIME_DIR ? { runtimeDir: launch.env.XDG_RUNTIME_DIR } : {}), + }, kind); const hooks = projectHooks(cwd); const mountPoint = kind(dirname(hooks)) === null && args.includes(hooks); return { @@ -233,13 +426,13 @@ export class AgentIsolation { } /** Null when bubblewrap can start here; cached, so a launch costs one check at most once a minute. */ - private bubblewrapFailure(bwrap: string): string | null { + private cachedProbe(key: string, probe: () => string | null): string | null { const now = (this.options.now ?? Date.now)(); - const last = this.bubblewrapCheck; - if (last && last.path === bwrap && (last.failure === null || now - last.at < BUBBLEWRAP_PROBE_RETRY_MS)) return last.failure; + const last = this.isolationChecks.get(key); + if (last && (last.failure === null || now - last.at < BUBBLEWRAP_PROBE_RETRY_MS)) return last.failure; let failure: string | null; - try { failure = (this.options.bubblewrapProbe ?? probeBubblewrap)(bwrap); } catch (error) { failure = error instanceof Error ? error.message : String(error); } - this.bubblewrapCheck = { path: bwrap, failure, at: now }; + try { failure = probe(); } catch (error) { failure = error instanceof Error ? error.message : String(error); } + this.isolationChecks.set(key, { failure, at: now }); return failure; } @@ -285,8 +478,19 @@ function findOnPath(name: string, exists: (path: string) => boolean): string | n return null; } -/** The folder of a session's control grant, from the launch environment (it is readable inside the layer). */ export function controlGrantFolder(env: Readonly>): string | null { const connection = env.CANVASTTY_CONTROL_CONNECTION; return connection ? dirname(connection) : null; } + +/** A model account's OpenCode run file: a single-link regular file inside CanvasTTY's own launch-runs folder. */ +function accountLaunchRunFile(path: string | undefined, userDataPath: string): string | undefined { + if (!path || !isAbsolute(path)) return undefined; + try { + const root = realpathSync(join(userDataPath, "launch-runs")); + const file = realpathSync(path); + const rest = relative(root, file); + const stat = statSync(file); + return rest && !rest.startsWith("..") && !isAbsolute(rest) && stat.isFile() && stat.nlink === 1 ? file : undefined; + } catch { return undefined; } +} diff --git a/src/main/services/isolation/bubblewrap.ts b/src/main/services/isolation/bubblewrap.ts index bd0c47b2..e4aecca4 100644 --- a/src/main/services/isolation/bubblewrap.ts +++ b/src/main/services/isolation/bubblewrap.ts @@ -29,7 +29,7 @@ export function bubblewrapArguments( "--die-with-parent", "--unshare-pid", "--unshare-ipc", - "--ro-bind", "/", "/", + ...(paths.restrictReads ? ["--tmpfs","/",...paths.readableAgain.flatMap(path=>exists(path) ? ["--ro-bind",path,path] : [])] : ["--ro-bind", "/", "/"]), "--dev-bind", "/dev", "/dev", "--proc", "/proc" ]; diff --git a/src/main/services/isolation/isolationPaths.ts b/src/main/services/isolation/isolationPaths.ts index 5cdcb9a5..2945985f 100644 --- a/src/main/services/isolation/isolationPaths.ts +++ b/src/main/services/isolation/isolationPaths.ts @@ -1,4 +1,4 @@ -import { existsSync, realpathSync } from "node:fs"; +import { existsSync, lstatSync, realpathSync, statSync } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import type { ProviderId } from "../../../shared/contracts.ts"; import { AGENT_PROVIDERS } from "../../../shared/contracts.ts"; @@ -30,9 +30,18 @@ export interface IsolationPathInput { socketFolders?: readonly string[]; /** Plan: the project is readable only (the CLI's own folders stay writable). */ readOnlyProject?: boolean; + /** Additional host-selected trees that a diff-only reviewer must not read. */ + deniedReadPaths?: readonly string[]; + /** Host-only diff reviewer: hide the rest of HOME while keeping its CLI state and explicit runtime files. */ + restrictHomeReads?: boolean; + /** Exact host-created reviewer run folder; replaces the ordinary session launch-runs grant. */ + privateRunDirectory?: string; + runtimeReadable?: readonly string[]; } export interface IsolationPaths { + /** A diff reviewer reads only supplied files, fresh state, trusted runtime and OS libraries. */ + restrictReads?:boolean; /** Writable folders (subpaths), every spelling. */ writable: string[]; /** Writable single files (with their `.lock` / `.tmp` / backup siblings). */ @@ -144,7 +153,7 @@ function sensitiveHomeFolders(home: string, xdgConfig: string): string[] { * token-authenticated sockets and per-run hook settings the CLI must read. */ export function privateAppData(userDataPath: string): string[] { - return ["agent-control", "provider-secrets.bin", "plugin-secrets", "account-homes", "github-oauth.json", "launch-runs"] + return ["agent-control", "provider-secrets.bin", "plugin-secrets", "account-homes", "github-oauth.json", "launch-runs", "plugin-data", "task-budgets.json", "usage-prices.json", "flow-approvals.json", "session-timeline"] .map((name) => join(userDataPath, name)); } @@ -212,8 +221,36 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { // variable pointing at HOME or above the project would hide them, so it is not followed. .filter((folder) => !ownFolders.includes(folder) && !hides(folder)); const privateData = privateAppData(input.userDataPath); - const grants = [...(input.grantedPrivate ?? []), join(input.userDataPath, "launch-runs", safeSegment(input.sessionId))]; + if (input.privateRunDirectory !== undefined && (!input.restrictHomeReads || !isAbsolute(input.privateRunDirectory))) { + throw new Error("A private reviewer run grant must be an absolute path in restricted mode."); + } + let privateRunDirectory = input.privateRunDirectory; + if (privateRunDirectory) { + const runsRoot = join(realpathSync(input.userDataPath), "launch-runs"); + const rootInfo = lstatSync(runsRoot); + const runInfo = lstatSync(privateRunDirectory); + const canonicalRun = realpathSync(privateRunDirectory); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory() || realpathSync(runsRoot) !== runsRoot + || runInfo.isSymbolicLink() || !runInfo.isDirectory() || canonicalRun !== privateRunDirectory + || dirname(canonicalRun) !== runsRoot) { + throw new Error("A private reviewer run grant must be one exact directory below the canonical launch-runs folder."); + } + privateRunDirectory = canonicalRun; + } + const grants = [...(input.grantedPrivate ?? []), privateRunDirectory ?? join(input.userDataPath, "launch-runs", safeSegment(input.sessionId))]; const project = input.cwd; + if (input.readOnlyProject) { + const projectPaths = pathSpellings(project); + const otherWritePaths = all([ + ...ownFolders, ...own.files, input.sessionTemp, + join(home, ".npm"), join(home, ".bun"), join(xdg.cache, "npm"), join(xdg.cache, "bun") + ]); + for (const path of otherWritePaths) { + if (projectPaths.some((root) => isWithin(root, path) || isWithin(path, root))) { + throw new Error(`Writable CLI state ${path} overlaps the read-only project ${project}.`); + } + } + } const trustedOwn = providerFolders(input.provider, {}, hostHome); const trustedOthers = AGENT_PROVIDERS.filter((provider) => provider !== input.provider).flatMap((provider) => { const defaults = providerFolders(provider, {}, hostHome); @@ -240,10 +277,40 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { if (!overlaps(hidden.spellings)) continue; const withinGrant = candidates.every((candidate) => grantSpellings.some((grant) => isWithin(candidate, grant))); const ownAccount = hidden.path === accountRoot && candidates.every((candidate) => accountSpellings.some((base) => candidate !== base && isWithin(candidate, base))); - if (!withinGrant && !ownAccount) throw new Error(`CLI home ${folder} overlaps protected CanvasTTY data.`); + // A CLI home inside this exact project adds no visibility beyond the project already granted. A moved home in + // the plugin-data parent or a sibling is never enough to reopen that larger private tree. + const insideProject = candidates.every((candidate) => pathSpellings(project).some((base) => isWithin(candidate, base))); + if (!withinGrant && !ownAccount && !(hidden.path === join(input.userDataPath, "plugin-data") && insideProject)) { + throw new Error(`CLI home ${folder} overlaps protected CanvasTTY data.`); + } } } const configSources = input.provider === "opencode" ? openCodeConfigPaths(input.env, input.cwd) : { jsonFiles: [], agentDirectories: [] }; + if (input.restrictHomeReads) { + const forbidden = all([...sensitive, ...privateData, ...(input.deniedReadPaths ?? [])]); + const exceptions = [...ownFolders, ...own.files]; + for (const path of exceptions) { + if (!isAbsolute(path)) throw new Error("A reviewer read exception must be absolute."); + const candidates = pathSpellings(path); + const broad = candidates.some(candidate => pathSpellings(home).concat(pathSpellings(hostHome)) + .some(root => isWithin(root,candidate))); + const overlaps = candidates.some(candidate => forbidden.some(hidden => isWithin(candidate,hidden) || isWithin(hidden,candidate))); + const inGrant = candidates.every(candidate => grantSpellings.some(grant => isWithin(candidate,grant))); + if (broad || overlaps && !inGrant) throw new Error("A reviewer read exception overlaps a protected tree."); + } + // Trusted host executables and bundled hooks may be files in the reviewed repository. Grant exact files only; + // no runtime directory may reopen project contents or another provider's credentials. + const privateForbidden = all([...sensitive, ...others, ...privateData]); + for (const path of input.runtimeReadable ?? []) { + if (!isAbsolute(path)) throw new Error("A reviewer runtime file must be absolute."); + if (!existsSync(path)) continue; // A different provider's optional bundled adapter may be absent. + const stat = statSync(path); + if (!stat.isFile()) throw new Error("A reviewer runtime grant must be a file."); + if (stat.nlink !== 1) throw new Error("A reviewer runtime grant must be a single-link file."); + if (pathSpellings(path).some(candidate => privateForbidden.some(hidden => isWithin(candidate, hidden)))) + throw new Error("A reviewer runtime file overlaps protected data."); + } + } const socketFolders = [ ...Object.entries(input.env) .filter(([name, value]) => /^CANVASTTY_.*_ADDRESS$/u.test(name) && typeof value === "string" && isAbsolute(value)) @@ -255,6 +322,7 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { join(input.userDataPath, "orchestration", "runtime") ]; return { + ...(input.restrictHomeReads ? {restrictReads:true} : {}), writable: all([ ...(input.readOnlyProject ? [] : [project]), input.sessionTemp, @@ -262,7 +330,9 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { join(home, ".npm"), join(home, ".bun"), join(xdg.cache, "npm"), join(xdg.cache, "bun") ]), writableFiles: all(own.files), - creatableFolders: all([...ownFolders, join(home, ".npm"), join(home, ".bun")].flatMap((folder) => ancestorsBelow(home, folder))), + creatableFolders: all([ + ...[...ownFolders, join(home, ".npm"), join(home, ".bun")].flatMap((folder) => ancestorsBelow(home, folder)), + ]), protectedWrites: all([ // A repository that exists keeps its config (hooksPath, fsmonitor, filters run code when the person uses git // later, outside the layer); a new one may be created, which writes its config. @@ -271,15 +341,25 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { ...(input.provider === "claude" ? ownFolders.flatMap((folder) => [join(folder, "settings.json"), join(folder, "settings.local.json")]) : []), ...configSources.jsonFiles ]), - protectedDirectories: all(configSources.agentDirectories.flatMap((folder) => [join(folder, "agent"), join(folder, "agents")])), + protectedDirectories: all([ + ...configSources.agentDirectories.flatMap((folder) => [join(folder, "agent"), join(folder, "agents")]), + ]), gitHooks: all([join(project, ".git", "hooks")]), projectRoots: input.readOnlyProject ? [] : all([project]), - unreadable: all([...sensitive, ...others, ...privateData]), - readableAgain: all([...grants, ...movedHomes]), + unreadable: all([ + ...sensitive, ...others, ...privateData, + ...(input.restrictHomeReads ? [home, hostHome] : []), + ...(input.deniedReadPaths ?? []), + ]), + readableAgain: all([...grants, ...movedHomes, + ...(input.restrictHomeReads ? [...ownFolders, ...own.files, input.sessionTemp, input.cwd, + "/bin","/usr/bin","/sbin","/usr/sbin","/usr/lib","/usr/libexec","/usr/share","/System/Library","/System/Library/dyld", + "/System/Volumes/Preboot/Cryptexes/OS","/dev","/private/var/db/dyld","/private/var/select/sh", + join(home,".npm"),join(home,".bun"),join(xdg.cache,"npm"),join(xdg.cache,"bun"),...(input.runtimeReadable ?? [])] : [])]), socketFolders: all(socketFolders), + socketPrefixes: all([dirname(dirname(realish(input.sessionTemp))), "/private/tmp", "/tmp"].map((folder) => join(folder, "ctty-"))) // The temporary folder a launch's own folder lives in (sessionTemp is /ctty-iso-…/tmp), and /tmp: where // CanvasTTY's gateways put their sockets when the userData path is too long for one. - socketPrefixes: all([dirname(dirname(realish(input.sessionTemp))), "/private/tmp", "/tmp"].map((folder) => join(folder, "ctty-"))) }; } diff --git a/src/main/services/isolation/reviewerHome.ts b/src/main/services/isolation/reviewerHome.ts new file mode 100644 index 00000000..ac9ad58f --- /dev/null +++ b/src/main/services/isolation/reviewerHome.ts @@ -0,0 +1,61 @@ +import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { basename, isAbsolute, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { ProviderId } from "../../../shared/contracts.ts"; +import { LaunchRefusal } from "../launchRefusal.ts"; + +/** A reviewer gets fresh CLI state, never a shared agent-writable home or conversation history. */ +/** + * `keepOpenCodeConfig`: the host-verified run file of the worker's model account (CanvasTTY's own launch-runs + * folder). It holds only the account's provider and model (the key travels in the environment), so a reviewer on + * that account can reach its model; every other OpenCode config source is still dropped. + */ +export function prepareReviewerHome(env:Record,provider:ProviderId,temp:string,runtimeReadable:readonly string[]=[],keepOpenCodeConfig?:string):void { + const openCodeOverlay = env.OPENCODE_CONFIG_CONTENT; + if (provider === "opencode" && openCodeOverlay) { + const plugins = [...new Set(runtimeReadable.filter(path => basename(path) === "opencode-plugin.mjs"))]; + if (plugins.length !== 1 || !isAbsolute(plugins[0]) || !existsSync(plugins[0])) { + throw new LaunchRefusal("The diff-only OpenCode reviewer requires a verified CanvasTTY lifecycle plugin runtime file. The agent was not started."); + } + const pluginPath = realpathSync(plugins[0]); + const pluginStat = statSync(pluginPath); + if (!pluginStat.isFile() || pluginStat.nlink !== 1) { + throw new LaunchRefusal("The diff-only OpenCode lifecycle plugin is not a single-link regular file. The agent was not started."); + } + // The original merged inline config can contain arbitrary plugins, MCP servers, credentials and permissions. + // Keep only the host-verified lifecycle plugin required for CanvasTTY's reviewer hooks. + env.OPENCODE_CONFIG_CONTENT = JSON.stringify({plugin: [pathToFileURL(pluginPath).href]}); + } + const original=env.HOME,home=join(temp,"reviewer-home"),state=join(home,`.${provider}`); + mkdirSync(state,{recursive:true,mode:0o700}); + const authentication=provider==="codex" ? {source:join(env.CODEX_HOME||join(original,".codex"),"auth.json"),name:"auth.json",keys:["OPENAI_API_KEY","tokens","auth_mode","last_refresh"]} + : provider==="claude" ? {source:join(env.CLAUDE_CONFIG_DIR||join(original,".claude"),".credentials.json"),name:".credentials.json",keys:["claudeAiOauth"]} : null; + if(authentication) { + let file:number|undefined; + try { + file=openSync(authentication.source,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK|constants.O_NOCTTY); + const before=fstatSync(file); + if(!before.isFile() || before.nlink!==1 || before.size>1024*1024)throw new Error("Reviewer authentication must be a bounded file without links."); + const buffer=Buffer.alloc(1024*1024+1);let length=0; + while(length1024*1024 || before.size!==after.size || before.mtimeMs!==after.mtimeMs || before.ctimeMs!==after.ctimeMs) + throw new Error("Reviewer authentication changed during preparation."); + const value=JSON.parse(buffer.subarray(0,length).toString("utf8")); + if(!value || typeof value!=="object" || Array.isArray(value))throw new Error("Invalid reviewer authentication."); + const selected=Object.fromEntries(authentication.keys.filter(key=>Object.hasOwn(value,key)).map(key=>[key,value[key]])); + writeFileSync(join(state,authentication.name),JSON.stringify(selected),{mode:0o600,flag:"wx"}); + } catch(error) {if((error as NodeJS.ErrnoException).code!=="ENOENT")throw error;} + finally {if(file!==undefined)closeSync(file);} + } + env.HOME=home; + for(const name of ["CODEX_HOME","CLAUDE_CONFIG_DIR","GROK_HOME","HERMES_HOME","KIMI_HOME","OPENCODE_CONFIG_DIR","QWEN_HOME"]) + delete env[name]; + if (provider === "opencode" && keepOpenCodeConfig) env.OPENCODE_CONFIG = keepOpenCodeConfig; + else delete env.OPENCODE_CONFIG; + if (provider !== "opencode" || !openCodeOverlay) delete env.OPENCODE_CONFIG_CONTENT; + env.XDG_CONFIG_HOME=join(home,".config");env.XDG_DATA_HOME=join(home,".local","share"); + env.XDG_STATE_HOME=join(home,".local","state");env.XDG_CACHE_HOME=join(home,".cache"); + if(provider==="codex")env.CODEX_HOME=state; + if(provider==="claude")env.CLAUDE_CONFIG_DIR=state; +} diff --git a/src/main/services/isolation/seatbelt.ts b/src/main/services/isolation/seatbelt.ts index 1342435f..a470315a 100644 --- a/src/main/services/isolation/seatbelt.ts +++ b/src/main/services/isolation/seatbelt.ts @@ -12,10 +12,9 @@ import type { IsolationPaths } from "./isolationPaths.ts"; * - no other process may be signalled; no application opened through Launch Services (`open`), no Apple events * (`osascript` driving Terminal), no preference writes through cfprefsd (`defaults write`, which would otherwise * write outside the layer on the agent's behalf); - * - Unix sockets: only DNS (mDNSResponder), syslog, this launch's own temporary folder and CanvasTTY's own - * token-authenticated gateways; no Docker, tmux, SSH agent or other daemon of the person's. + * - Unix sockets: DNS (mDNSResponder), syslog, this launch's temporary folder and CanvasTTY's own gateways. * - * Network and the keychain's services stay as they are: the CLI talks to its provider and reads its own sign-in. + * The CLI keeps its provider network access. Diff-only reviewers receive narrowly scoped read grants. */ export function seatbeltProfile(paths: IsolationPaths): string { const lines: string[] = [ @@ -32,10 +31,23 @@ export function seatbeltProfile(paths: IsolationPaths): string { lines.push(`(allow file-write*${paths.writableFiles.map((path) => ` (regex ${regex(`^${escapeRegex(path)}(\\.[^/]*)?$`)})`).join("")})`); } if (paths.unreadable.length > 0) { - lines.push(`(deny file-read* file-write*${paths.unreadable.map((path) => ` (subpath ${quote(path)})`).join("")})`); + lines.push(`(deny${paths.restrictReads ? "" : " file-read*"} file-write*${paths.unreadable.map((path) => ` (subpath ${quote(path)})`).join("")})`); + } + if (paths.restrictReads) { + // Deny regular-file reads globally while allowing narrower exact runtime, OS and diff paths to reopen them. + // An unconditional `(deny file-read*)` wins over every exception in Seatbelt. Native macOS runtimes need directory + // entries while resolving resources, so allow directory vnodes and immediately re-hide HOME and project roots. + lines.push("(deny file-read* (vnode-type REGULAR-FILE))", "(allow file-read-metadata (vnode-type DIRECTORY))", "(allow file-read* (vnode-type DIRECTORY))"); + for (const path of paths.unreadable) lines.push(`(deny file-read* (subpath ${quote(path)}))`); } if (paths.readableAgain.length > 0) { - lines.push(`(allow file-read*${paths.readableAgain.map((path) => ` (subpath ${quote(path)})`).join("")})`); + if (paths.restrictReads) { + // Separate rules make each spelling an independent exception. Combining path predicates in one rule intersects + // them, so a caller opening `/var/...` would miss the grant even when its canonical `/private/var/...` was listed. + for (const path of paths.readableAgain) lines.push(`(allow file-read* (subpath ${quote(path)}))`); + } else { + lines.push(`(allow file-read*${paths.readableAgain.map((path) => ` (subpath ${quote(path)})`).join("")})`); + } } // The CLI's own home that sits in a hidden folder (an account home it was handed): writable again, like its other // folders (a sign-in refresh writes there). Its permission settings are denied again below. diff --git a/src/main/services/providerModels.ts b/src/main/services/providerModels.ts index 48808b7e..5b911a00 100644 --- a/src/main/services/providerModels.ts +++ b/src/main/services/providerModels.ts @@ -1,4 +1,6 @@ import { execFile } from "node:child_process"; +import {readFile,stat} from "node:fs/promises"; +import {join} from "node:path"; import type { AgentProviderId } from "../../shared/contracts.ts"; import { providerChildProcessLaunch, type ProviderChildProcessLaunch, type ProviderCliRegistry } from "./providerCliRegistry.ts"; @@ -30,16 +32,18 @@ export class ProviderModelCatalog { private readonly now: () => number; private readonly listings = new Map(); private readonly running = new Map>(); + private readonly codexHome:string|undefined; - constructor(registry: Pick, options: { run?: ModelListingRunner; now?: () => number } = {}) { + constructor(registry: Pick, options: { run?: ModelListingRunner; now?: () => number;codexHome?:string } = {}) { this.registry = registry; this.run = options.run ?? runListing; this.now = options.now ?? Date.now; + this.codexHome=options.codexHome; } /** Whether this provider's models can be listed at all. */ lists(provider: AgentProviderId): boolean { - return LISTING_COMMANDS[provider] !== undefined; + return LISTING_COMMANDS[provider] !== undefined || provider==="codex" && this.codexHome!==undefined; } /** The last listing, however old; refreshes it in the background when it is missing or old. */ @@ -55,13 +59,14 @@ export class ProviderModelCatalog { * `fresh` waits for a listing (at most the listing timeout) when none is cached yet. */ async unknownModel(provider: AgentProviderId, model: string, options: { fresh?: boolean } = {}): Promise { - if (!this.lists(provider)) return null; + if (provider!=="opencode") return null; if (options.fresh && !this.listings.has(provider)) await this.refresh(provider); return this.unknownModelCached(provider, model); } /** unknownModel from the cached listing only (never waits; a missing listing allows the model). */ unknownModelCached(provider: AgentProviderId, model: string): string | null { + if(provider!=="opencode")return null; const listing = this.peek(provider); if (!listing || listing.models.includes(model)) return null; const closest = closestModels(model, listing.models); @@ -72,6 +77,18 @@ export class ProviderModelCatalog { /** Lists once at a time per provider; resolves when the listing ended (tests and a warm-up may await it). */ refresh(provider: AgentProviderId): Promise { + if(provider==="codex" && this.codexHome){ + const pending=this.running.get(provider);if(pending)return pending; + const operation=(async()=>{ + try{ + const file=join(this.codexHome!,"models_cache.json");if((await stat(file)).size>MAX_LISTING_BYTES)return; + const value=JSON.parse(await readFile(file,"utf8")); + if(!Array.isArray(value.models))return; + const models=[...new Set(value.models.filter((row:unknown)=>row && typeof row==="object" && "slug" in row && typeof row.slug==="string" && /^[\w.-]{1,160}$/.test(row.slug) && (!("visibility" in row) || row.visibility!=="hide")).map((row:{slug:string})=>row.slug))].slice(0,MAX_MODELS); + if(models.length)this.listings.set(provider,{models,checkedAt:this.now()}); + }catch{ /* Missing CLI cache is unknown, not a guessed model list. */ } + })().finally(()=>this.running.delete(provider));this.running.set(provider,operation);return operation; + } const args = LISTING_COMMANDS[provider]; if (!args) return Promise.resolve(); const current = this.running.get(provider); diff --git a/src/preload/index.ts b/src/preload/index.ts index 8fc45548..5182ed6d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,3 +1,4 @@ +import { BACKLOG_IPC, BACKLOG_EVENTS, type BacklogApi } from "../shared/backlog"; import { contextBridge, ipcRenderer, webUtils } from "electron"; import type { AppSettings, @@ -289,6 +290,10 @@ const api: CanvasTTYApi = { resolveGitRisk: (reportId: string, action: "neutralize" | "keep") => ipcRenderer.invoke(IPC.terminalResolveGitRisk, reportId, action), onGitRisk: (listener: (report: GitRiskReport) => void) => subscribe(IPC.terminalGitRisk, listener) }, + backlog: { + ...Object.fromEntries(Object.entries(BACKLOG_IPC).map(([name, channel]) => [name, (...args: unknown[]) => ipcRenderer.invoke(channel, ...args)])), + onTaskBoardChanged:(listener)=>subscribe(BACKLOG_EVENTS.taskBoardChanged,listener) + } as BacklogApi, window: { isMacOS: process.platform === "darwin", platform: process.platform, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d9ccfb58..a9bb4690 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -678,9 +678,11 @@ export function App(): React.JSX.Element { cwd: string, role: LaunchRole, launchOptions?: Record, - environment?: SessionEnvironmentChoice + environment?: SessionEnvironmentChoice, + initialPrompt?: string ): Promise => { - await createSession(provider, profile, cwd, launchPosition ?? undefined, role, launchOptions, environment); + const session = await createSession(provider, profile, cwd, launchPosition ?? undefined, role, launchOptions, environment); + if (initialPrompt) await window.canvasTTY.backlog.sendInstructions(session.id, initialPrompt); setLaunchPosition(null); showToast(provider === "terminal" ? t(settings.locale, "terminalStarted") : `${t(settings.locale, "sessionStarted")}: ${provider}`); }, [createSession, launchPosition, settings.locale, showToast]); diff --git a/src/renderer/src/features/launcher/AgentLaunchDialog.tsx b/src/renderer/src/features/launcher/AgentLaunchDialog.tsx index 3c743b2e..b4d8dd59 100644 --- a/src/renderer/src/features/launcher/AgentLaunchDialog.tsx +++ b/src/renderer/src/features/launcher/AgentLaunchDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { AgentProviderId, AppSettings, @@ -13,6 +13,7 @@ import { UiIcon } from "../../components/UiIcon"; import { t } from "../../lib/i18n"; import { PROVIDERS } from "../../lib/providers"; import { directoryPathFromClipboard } from "../../lib/directoryPathFromClipboard"; +import { backlogApi } from "../workspace/backlogRendererApi"; import { LaunchOptionsSection } from "./LaunchOptionsSection"; import { autoKind, @@ -59,7 +60,8 @@ interface AgentLaunchDialogProps { cwd: string, role: LaunchRole, launchOptions?: Record, - environment?: SessionEnvironmentChoice + environment?: SessionEnvironmentChoice, + initialPrompt?: string ): Promise; } @@ -81,6 +83,13 @@ export function AgentLaunchDialog({ const [launchOptions, setLaunchOptions] = useState>({}); const changeLaunchOptions = useCallback((options: Record) => setLaunchOptions(options), []); const [environment, setEnvironment] = useState(null); + const [flowId, setFlowId] = useState(""); + const [flows, setFlows] = useState["flows"]>>["templates"][number]>>([]); + const [flowErrors, setFlowErrors] = useState>([]); + const [flowLoading, setFlowLoading] = useState(false); + const [flowRefresh, setFlowRefresh] = useState(0); + const [flowPreview, setFlowPreview] = useState<{projectRoot:string;flowId:string;instructions:string;digest:string|null} | null>(null); + const backlog = useMemo(() => backlogApi(), []); const changeEnvironment = useCallback((choice: SessionEnvironmentChoice | null) => setEnvironment(choice), []); const locale = settings.locale; @@ -88,11 +97,55 @@ export function AgentLaunchDialog({ if (!provider) return; setProfile(initialProfile(provider, settings, platform)); setRole("agent"); + setFlowId(""); + setFlows([]); + setFlowErrors([]); setCwd(settings.lastDirectory); setConfirmDanger(false); setError(null); }, [provider, settings.lastDirectory]); + useEffect(() => { + if (!provider || provider === "terminal") return; + let alive = true; + setFlowLoading(true); + void backlog.flows(cwd).then((result) => { + if (!alive) return; + setFlows(result.templates); + setFlowErrors(result.errors); + setFlowId((current) => current && !result.templates.some((flow) => flow.id === current) ? "" : current); + }).catch((reason: unknown) => { + if (!alive) return; + setFlows([]); + setFlowErrors([{ file: "flows", line: 0, message: reason instanceof Error ? reason.message : String(reason) }]); + }).finally(() => { if (alive) setFlowLoading(false); }); + return () => { alive = false; }; + }, [backlog, cwd, flowRefresh, provider]); + + const selectedFlow = flows.find(flow => flow.id === flowId); + useEffect(() => { + setFlowPreview(null); + if (!flowId || selectedFlow?.trusted === true) return; + let alive = true; + void backlog.previewFlow(cwd, flowId).then(preview => { + if (alive) setFlowPreview({ ...preview, projectRoot: cwd, flowId }); + }).catch((reason: unknown) => { + if (alive) setError(reason instanceof Error ? reason.message : String(reason)); + }); + return () => { alive = false; }; + }, [backlog, cwd, flowId, selectedFlow?.digest, selectedFlow?.trusted]); + + const approveFlow = async (): Promise => { + if (!flowPreview?.digest || flowPreview.projectRoot !== cwd || flowPreview.flowId !== flowId) return; + setBusy(true); + try { + await backlog.approveFlow(cwd, flowId, flowPreview.digest); + setFlowRefresh(value => value + 1); + setError(null); + } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); } + finally { setBusy(false); } + }; + useEffect(() => { if (!provider) return; const onKeyDown = (event: KeyboardEvent): void => { @@ -109,7 +162,7 @@ export function AgentLaunchDialog({ const dangerKey = PROVIDERS[provider].dangerKey ?? "confirmLaunch"; // An orchestrator without the endpoint would be a plain session with a // misleading badge, so the launch waits for the explicit enable button. - const endpointMissing = role === "orchestrator" && !settings.agentControlEnabled; + const endpointMissing = (role === "orchestrator" || Boolean(flowId)) && !settings.agentControlEnabled; const enableEndpoint = async (): Promise => { setBusy(true); @@ -156,8 +209,11 @@ export function AgentLaunchDialog({ setError(null); try { if (profile === "yolo" && !acknowledged && !isTerminal) await onAcknowledge(provider); - await onLaunch(provider, profile, cwd, role, Object.keys(launchOptions).length > 0 ? launchOptions : undefined, - environment ?? undefined); + const initialPrompt = flowId + ? await backlog.redactText(await backlog.flowInstructions(cwd, flowId)) + : undefined; + await onLaunch(provider, profile, cwd, flowId ? "orchestrator" : role, Object.keys(launchOptions).length > 0 ? launchOptions : undefined, + environment ?? undefined, initialPrompt); onClose(); } catch (reason) { setError(reason instanceof Error ? reason.message : t(locale, "launchFailed")); @@ -194,10 +250,41 @@ export function AgentLaunchDialog({ {!isTerminal &&
- +
} + {!isTerminal &&
+ +
+ + +
+ {flowId && {locale === "ru" ? "Ожидается подагентов" : "Expected subagents"}: {flows.find((flow) => flow.id === flowId)?.expectedSubagents ?? 0}} + {flowId && selectedFlow?.trusted !== true &&
+

{locale === "ru" ? "Просмотрите инструкции из файла проекта перед их использованием. Изменённые инструкции потребуют нового одобрения." : "Review the project file's instructions before using them. Changed instructions require approval again."}

+ {flowPreview &&
{flowPreview.instructions}
} + +
} + {flowErrors.length > 0 &&
    + {flowErrors.map((issue, index) =>
  • + {issue.file}{issue.line > 0 ? `:${issue.line}` : ""}: {issue.message} +
  • )} +
} +
} +
{!isTerminal && availableProfiles(provider, isolationAvailable(settings, platform)).map((mode) => ( ))} -
diff --git a/src/renderer/src/features/terminal/TerminalCard.tsx b/src/renderer/src/features/terminal/TerminalCard.tsx index 63dd8d1d..83d40665 100644 --- a/src/renderer/src/features/terminal/TerminalCard.tsx +++ b/src/renderer/src/features/terminal/TerminalCard.tsx @@ -1,3 +1,6 @@ +import { taskCardState } from "../workspace/workspaceTaskGraph"; +import { TaskSummaryBar } from "../workspace/TaskSummaryBar"; +import { backlogText } from "../workspace/workspaceBacklogText"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { summaryScaleForZoom, useCameraSelector, type CameraStore } from "../workspace/cameraStore"; import { FitAddon } from "@xterm/addon-fit"; @@ -105,12 +108,16 @@ interface TerminalCardProps { onRename(id: string, title: string): Promise; onRenameEnd(): void; onBoundsChange(id: string, bounds: SessionBounds): void; + onBoundsPreview?(id: string, bounds: SessionBounds | null): void; onRestart(id: string, resume?: boolean): Promise; /** `keepEnvironmentData` is the answer to "Keep environment data?" for a card in a plugin environment. */ onDispose(id: string, keepEnvironmentData?: boolean): void; /** Saving sessions is on, so the per-card "Don't restore" choice applies. */ restoreEnabled?: boolean; onOpenUrl(url: string): void; + taskChildren?: readonly SessionSnapshot[]; + onOpenInspector?(id: string): void; + onGatherTask?(id: string): void; } interface DragState { @@ -179,9 +186,13 @@ function TerminalCardView({ onRename, onRenameEnd, onBoundsChange, + onBoundsPreview, onRestart, onDispose, onOpenUrl, + taskChildren = [], + onOpenInspector, + onGatherTask, restoreEnabled = false }: TerminalCardProps): React.JSX.Element { const borderSkin = terminalBorderSkinFallback(selectedBorderSkin); @@ -239,7 +250,7 @@ function TerminalCardView({ const pluginDecorations = usePluginCardDecorations(session); const [actionRunning, setActionRunning] = useState(false); const [actionToast, setActionToast] = useState(null); - const hasOptions = restoreEnabled || pluginDecorations.actions.length > 0; + const hasOptions = Boolean(onOpenInspector) || restoreEnabled || pluginDecorations.actions.length > 0; const runPluginAction = (action: PluginCardActionEntry): void => { setOptionsOpen(false); setActionRunning(true); @@ -374,6 +385,11 @@ function TerminalCardView({ const visibleTitle = visibleTerminalTitle({ ...titleSource, cwdLabel: compactPath(session.cwd) }); // Same precedence, but the tooltip shows the full path when the cwd is the label. const visibleTitleTooltip = visibleTerminalTitle({ ...titleSource, cwdLabel: session.cwd }); + const taskState = session.parentSessionId ? taskCardState(session) : null; + const taskStateLabel = taskState === "working" ? "taskStateWorking" + : taskState === "waiting" ? "taskStateWaiting" + : taskState === "waiting-response" ? "taskStateWaitingResponse" + : taskState === "done" ? "taskStateDone" : "taskStateFailed"; const visibleTitleRef = useRef(visibleTitle); visibleTitleRef.current = visibleTitle; @@ -911,6 +927,7 @@ function TerminalCardView({ if (!dragState.current || dragState.current.pointerId !== event.pointerId) return; dragState.current = null; onBoundsChange(session.id, liveBounds.current); + onBoundsPreview?.(session.id, null); }; // A group drag takes pointer capture without a pointerup; drop local state so a @@ -921,12 +938,14 @@ function TerminalCardView({ if (!dragState.current) return; dragState.current = null; applyLiveBounds({ position: session.position, size: session.size }); + onBoundsPreview?.(session.id, null); }; const cancelResize = (): void => { if (!resizeState.current) return; resizeState.current = null; applyLiveBounds({ position: session.position, size: session.size }); + onBoundsPreview?.(session.id, null); }; const startResize = (event: React.PointerEvent, direction: ResizeDirection): void => { @@ -974,12 +993,14 @@ function TerminalCardView({ event.stopPropagation(); resizeState.current = null; onBoundsChange(session.id, liveBounds.current); + onBoundsPreview?.(session.id, null); }; const applyLiveBounds = (bounds: SessionBounds): void => { liveBounds.current = bounds; setPosition(bounds.position); setSize(bounds.size); + onBoundsPreview?.(session.id, bounds); }; const activateSummary = (event: React.MouseEvent): void => { @@ -1122,7 +1143,7 @@ function TerminalCardView({ ); return (
0 || Boolean(session.taskBudget)) ? "terminal-card--with-task-summary" : ""} terminal-card--${session.provider} ${summaryMode ? "terminal-card--summary" : ""} ${selected || groupSelected ? "terminal-card--selected" : ""} ${session.status === "needs_approval" || session.status === "failed" ? "terminal-card--attention" : ""} ${fullscreen ? "terminal-card--fullscreen" : ""}`} data-interactive="true" data-canvas-layer-id={`terminal:${session.id}`} data-canvas-widget-id={terminalCanvasWidgetId(session.id)} @@ -1236,6 +1257,8 @@ function TerminalCardView({ {session.role === "orchestrator" && ( {t(locale, "roleOrchestrator")} )} + {taskState && {backlogText(locale, taskStateLabel)}} {session.profile === "auto" && ( {t(locale, session.autoDowngraded ? "autoDowngraded" : "autoProfile")} @@ -1258,6 +1281,7 @@ function TerminalCardView({ {t(locale, "configuredModeBadge")}: {session.configuredMode.mode} )} + {session.reviewRequested && {backlogText(locale, "reviewRequested")}} {session.environment && ( {session.environment.label} @@ -1272,6 +1296,7 @@ function TerminalCardView({ {!pixelControls && terminalActions} + onGatherTask?.(session.id)} />
{pixelSkinTheme && ( { if (event.key === "Escape") setOptionsOpen(false); }}> + {onOpenInspector && } {restoreEnabled && (