mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-15 20:05:16 +02:00
Merge branch 'dev' into slack3
This commit is contained in:
commit
4ed91e69e6
52 changed files with 3935 additions and 467 deletions
|
|
@ -56,6 +56,7 @@ module.exports = {
|
|||
description: 'AI coworker with memory',
|
||||
name: `Rowboat-win32-${arch}`,
|
||||
setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`,
|
||||
setupIcon: path.join(__dirname, 'icons/icon.ico'),
|
||||
})
|
||||
},
|
||||
{
|
||||
|
|
@ -66,7 +67,9 @@ module.exports = {
|
|||
bin: "rowboat",
|
||||
description: 'AI coworker with memory',
|
||||
maintainer: 'rowboatlabs',
|
||||
homepage: 'https://rowboatlabs.com'
|
||||
homepage: 'https://rowboatlabs.com',
|
||||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
}
|
||||
})
|
||||
},
|
||||
|
|
@ -77,10 +80,27 @@ module.exports = {
|
|||
name: `Rowboat-linux`,
|
||||
bin: "rowboat",
|
||||
description: 'AI coworker with memory',
|
||||
homepage: 'https://rowboatlabs.com'
|
||||
homepage: 'https://rowboatlabs.com',
|
||||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: require.resolve('./makers/maker-pacman.cjs'),
|
||||
platforms: ['linux'],
|
||||
config: {
|
||||
name: 'rowboat',
|
||||
bin: 'rowboat',
|
||||
executableName: 'rowboat',
|
||||
description: 'AI coworker with memory',
|
||||
maintainer: 'rowboatlabs',
|
||||
homepage: 'https://rowboatlabs.com',
|
||||
license: 'Apache',
|
||||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
platform: ["darwin", "win32", "linux"],
|
||||
|
|
|
|||
BIN
apps/x/apps/main/icons/icon.ico
Normal file
BIN
apps/x/apps/main/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
134
apps/x/apps/main/makers/maker-pacman.cjs
Normal file
134
apps/x/apps/main/makers/maker-pacman.cjs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// Custom Electron Forge maker that produces Arch Linux .pkg.tar.zst packages
|
||||
// via makepkg. Runs only on Linux with makepkg available (i.e. an Arch host).
|
||||
//
|
||||
// CJS on purpose: forge.config.cjs require()s us.
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const MakerBase = require('@electron-forge/maker-base').default;
|
||||
|
||||
const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64', ia32: 'i686', armv7l: 'armv7h' };
|
||||
|
||||
class MakerPacman extends MakerBase {
|
||||
name = 'pacman';
|
||||
defaultPlatforms = ['linux'];
|
||||
|
||||
isSupportedOnCurrentPlatform() {
|
||||
if (process.platform !== 'linux') return false;
|
||||
try {
|
||||
execSync('command -v makepkg', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async make({ dir, makeDir, targetArch, packageJSON, appName }) {
|
||||
const pkgArch = ARCH_MAP[targetArch] || targetArch;
|
||||
|
||||
const cfg = this.config || {};
|
||||
const pkgName = (cfg.name || appName || packageJSON.name).toLowerCase();
|
||||
// pacman pkgver disallows '-'; map prerelease tags through.
|
||||
const pkgVersion = String(packageJSON.version || '0.0.0').replace(/-/g, '_');
|
||||
const pkgDesc = (cfg.description || packageJSON.description || '').replace(/"/g, '\\"');
|
||||
const maintainer = cfg.maintainer || 'unknown';
|
||||
const homepage = cfg.homepage || packageJSON.homepage || '';
|
||||
const license = cfg.license || 'custom';
|
||||
const bin = cfg.bin || pkgName;
|
||||
const execName = cfg.executableName || appName || pkgName;
|
||||
const mimeTypes = cfg.mimeType || [];
|
||||
const depends = cfg.depends || [];
|
||||
const iconSrc = cfg.icon;
|
||||
|
||||
const outDir = path.resolve(path.join(makeDir, 'pacman', targetArch));
|
||||
await this.ensureDirectory(outDir);
|
||||
|
||||
// Clean prior contents so makepkg starts fresh each run.
|
||||
for (const f of fs.readdirSync(outDir)) {
|
||||
fs.rmSync(path.join(outDir, f), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Wrapper script — execs the packaged Electron binary, forwards args (incl. rowboat:// URLs).
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, bin),
|
||||
`#!/bin/sh\nexec "/opt/${pkgName}/${execName}" "$@"\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const desktop = [
|
||||
'[Desktop Entry]',
|
||||
`Name=${appName || pkgName}`,
|
||||
`Comment=${pkgDesc}`,
|
||||
`Exec=${bin} %U`,
|
||||
`Icon=${pkgName}`,
|
||||
'Type=Application',
|
||||
'Categories=Utility;',
|
||||
'Terminal=false',
|
||||
mimeTypes.length ? `MimeType=${mimeTypes.join(';')};` : null,
|
||||
'',
|
||||
].filter(Boolean).join('\n');
|
||||
fs.writeFileSync(path.join(outDir, `${pkgName}.desktop`), desktop);
|
||||
|
||||
const sources = [bin, `${pkgName}.desktop`];
|
||||
let iconInstall = '';
|
||||
if (iconSrc && fs.existsSync(iconSrc)) {
|
||||
fs.copyFileSync(iconSrc, path.join(outDir, 'icon.png'));
|
||||
sources.push('icon.png');
|
||||
iconInstall = ` install -Dm644 "$srcdir/icon.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/${pkgName}.png"`;
|
||||
}
|
||||
|
||||
const sumsLine = sources.map(() => "'SKIP'").join(' ');
|
||||
const sourceLine = sources.map((s) => `'${s}'`).join(' ');
|
||||
const dependsLine = depends.map((d) => `'${d}'`).join(' ');
|
||||
// Embed the packager output dir as a bash-safe literal.
|
||||
const appDirEscaped = dir.replace(/'/g, `'\\''`);
|
||||
|
||||
const pkgbuild = `# Maintainer: ${maintainer}
|
||||
# Auto-generated by maker-pacman.cjs — do not edit by hand.
|
||||
pkgname=${pkgName}
|
||||
pkgver=${pkgVersion}
|
||||
pkgrel=1
|
||||
pkgdesc="${pkgDesc}"
|
||||
arch=('${pkgArch}')
|
||||
url="${homepage}"
|
||||
license=('${license}')
|
||||
depends=(${dependsLine})
|
||||
options=('!strip' '!debug')
|
||||
source=(${sourceLine})
|
||||
sha256sums=(${sumsLine})
|
||||
|
||||
_appdir='${appDirEscaped}'
|
||||
|
||||
package() {
|
||||
install -dm755 "$pkgdir/opt/$pkgname"
|
||||
cp -a "$_appdir/." "$pkgdir/opt/$pkgname/"
|
||||
|
||||
# Electron's sandbox helper needs setuid root for the namespace sandbox.
|
||||
if [ -f "$pkgdir/opt/$pkgname/chrome-sandbox" ]; then
|
||||
chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox"
|
||||
fi
|
||||
|
||||
install -Dm755 "$srcdir/${bin}" "$pkgdir/usr/bin/${bin}"
|
||||
install -Dm644 "$srcdir/${pkgName}.desktop" "$pkgdir/usr/share/applications/${pkgName}.desktop"
|
||||
${iconInstall}
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(outDir, 'PKGBUILD'), pkgbuild);
|
||||
|
||||
execSync('makepkg -f --noconfirm --nodeps', {
|
||||
cwd: outDir,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, PKGEXT: '.pkg.tar.zst', CARCH: pkgArch },
|
||||
});
|
||||
|
||||
return fs
|
||||
.readdirSync(outDir)
|
||||
.filter((f) => f.endsWith('.pkg.tar.zst'))
|
||||
.map((f) => path.join(outDir, f));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MakerPacman;
|
||||
module.exports.default = MakerPacman;
|
||||
|
|
@ -13,6 +13,8 @@
|
|||
"make": "electron-forge make"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.39.0",
|
||||
"@agentclientprotocol/codex-acp": "^0.0.44",
|
||||
"@x/core": "workspace:*",
|
||||
"@x/shared": "workspace:*",
|
||||
"chokidar": "^4.0.3",
|
||||
|
|
@ -27,6 +29,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.10.2",
|
||||
"@electron-forge/maker-base": "^7.11.1",
|
||||
"@electron-forge/maker-deb": "^7.11.1",
|
||||
"@electron-forge/maker-dmg": "^7.10.2",
|
||||
"@electron-forge/maker-rpm": "^7.11.1",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
|||
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
||||
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
|
||||
import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js';
|
||||
import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
|
||||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||
|
|
@ -56,6 +57,8 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
|||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
|
||||
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js';
|
||||
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||
import { API_URL } from '@x/core/dist/config/env.js';
|
||||
|
|
@ -586,6 +589,13 @@ export function setupIpcHandlers() {
|
|||
// Forward knowledge commit events to renderer for panel refresh
|
||||
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
|
||||
|
||||
// Pre-warm the Gmail contact indices so the first compose-box keystroke is instant.
|
||||
// - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage).
|
||||
// - warmSentContacts(): kicks off a background Gmail API sync of the SENT label
|
||||
// for full historical coverage of people you've actually emailed.
|
||||
warmContactIndex();
|
||||
warmSentContacts();
|
||||
|
||||
registerIpcHandlers({
|
||||
'app:getVersions': async () => {
|
||||
// args is null for this channel (no request payload)
|
||||
|
|
@ -663,6 +673,22 @@ export function setupIpcHandlers() {
|
|||
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
||||
return {};
|
||||
},
|
||||
'gmail:searchContacts': async (_event, args) => {
|
||||
const query = args?.query ?? '';
|
||||
const limit = args?.limit;
|
||||
const excludeEmails = args?.excludeEmails;
|
||||
|
||||
// Primary source: people you've actually sent mail to (Gmail SENT label,
|
||||
// cached + refreshed via the Gmail API). Fallback: local-snapshot index
|
||||
// — used only when the SENT index hasn't been populated yet (very first
|
||||
// launch, before the background sync finishes).
|
||||
const sent = await searchSentContacts(query, { limit, excludeEmails }).catch(() => []);
|
||||
if (sent.length > 0) {
|
||||
return { contacts: sent };
|
||||
}
|
||||
const fallback = await searchGmailContacts(query, { limit, excludeEmails });
|
||||
return { contacts: fallback };
|
||||
},
|
||||
'mcp:listTools': async (_event, args) => {
|
||||
return mcpCore.listTools(args.serverName, args.cursor);
|
||||
},
|
||||
|
|
@ -679,6 +705,11 @@ export function setupIpcHandlers() {
|
|||
await runsCore.authorizePermission(args.runId, args.authorization);
|
||||
return { success: true };
|
||||
},
|
||||
'codeRun:resolvePermission': async (_event, args) => {
|
||||
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
|
||||
registry.resolve(args.requestId, args.decision);
|
||||
return { success: true };
|
||||
},
|
||||
'runs:provideHumanInput': async (_event, args) => {
|
||||
await runsCore.replyToHumanInputRequest(args.runId, args.reply);
|
||||
return { success: true };
|
||||
|
|
@ -780,11 +811,11 @@ export function setupIpcHandlers() {
|
|||
'codeMode:getConfig': async () => {
|
||||
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
return { enabled: config.enabled };
|
||||
return { enabled: config.enabled, approvalPolicy: config.approvalPolicy };
|
||||
},
|
||||
'codeMode:setConfig': async (_event, args) => {
|
||||
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||
await repo.setConfig({ enabled: args.enabled });
|
||||
await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy });
|
||||
invalidateCopilotInstructionsCache();
|
||||
return { success: true };
|
||||
},
|
||||
|
|
@ -1183,6 +1214,24 @@ export function setupIpcHandlers() {
|
|||
'voice:synthesize': async (_event, args) => {
|
||||
return voice.synthesizeSpeech(args.text);
|
||||
},
|
||||
'voice:ensureMicAccess': async () => {
|
||||
if (process.platform !== 'darwin') return { granted: true };
|
||||
const status = systemPreferences.getMediaAccessStatus('microphone');
|
||||
console.log('[voice] Microphone permission status:', status);
|
||||
if (status === 'granted') return { granted: true };
|
||||
// 'not-determined' shows the native TCC prompt and resolves once the
|
||||
// user responds; 'denied'/'restricted' resolve false without prompting.
|
||||
// Awaiting this here means the triggering mic click proceeds to
|
||||
// getUserMedia only after permission is settled — fixing the first
|
||||
// click silently failing while the prompt was still up.
|
||||
try {
|
||||
const granted = await systemPreferences.askForMediaAccess('microphone');
|
||||
console.log('[voice] Microphone permission after prompt:', granted);
|
||||
return { granted };
|
||||
} catch {
|
||||
return { granted: false };
|
||||
}
|
||||
},
|
||||
// Live-note handlers
|
||||
'live-note:run': async (_event, args) => {
|
||||
const result = await runLiveNoteAgent(args.filePath, 'manual', args.context);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ import started from "electron-squirrel-startup";
|
|||
import { execSync, exec, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||
import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
||||
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
||||
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
|
||||
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
||||
|
|
@ -220,6 +221,7 @@ function createWindow() {
|
|||
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined,
|
||||
webPreferences: {
|
||||
// IMPORTANT: keep Node out of renderer
|
||||
nodeIntegration: false,
|
||||
|
|
@ -251,14 +253,34 @@ function createWindow() {
|
|||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Handle navigation to external URLs (e.g., clicking a link without target="_blank")
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
// Handle navigation to external URLs (e.g., clicking a link without target="_blank").
|
||||
// Returns true when the URL was external and routed to the system browser.
|
||||
const routeExternalNavigation = (url: string): boolean => {
|
||||
const isInternal =
|
||||
url.startsWith("app://") || url.startsWith("http://localhost:5173");
|
||||
if (!isInternal) {
|
||||
event.preventDefault();
|
||||
shell.openExternal(url);
|
||||
}
|
||||
if (isInternal) return false;
|
||||
shell.openExternal(url);
|
||||
return true;
|
||||
};
|
||||
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
if (routeExternalNavigation(url)) event.preventDefault();
|
||||
});
|
||||
|
||||
// Subframe navigations (e.g. links clicked inside the sandboxed iframe that
|
||||
// renders a background-task / workspace `index.html`) fire `will-frame-navigate`,
|
||||
// not `will-navigate`. Route their external links to the system browser too,
|
||||
// so HTML reports behave like the markdown viewer. Main-frame navigations are
|
||||
// already handled by `will-navigate` above — skip them here to avoid double-open.
|
||||
//
|
||||
// Scope this to our own HTML viewer frames (identified by their app://workspace
|
||||
// document origin). Third-party note embeds (YouTube, Figma, Twitter via the
|
||||
// embed/iframe blocks) load from their own origins — leave their internal
|
||||
// navigation untouched so the embeds keep working.
|
||||
win.webContents.on("will-frame-navigate", (event) => {
|
||||
if (event.isMainFrame) return;
|
||||
if (!event.frame?.url.startsWith("app://workspace/")) return;
|
||||
if (routeExternalNavigation(event.url)) event.preventDefault();
|
||||
});
|
||||
|
||||
// Attach the embedded browser pane manager to this window.
|
||||
|
|
@ -416,6 +438,12 @@ app.on("before-quit", () => {
|
|||
stopWorkspaceWatcher();
|
||||
stopRunsWatcher();
|
||||
stopServicesWatcher();
|
||||
// Tear down any live ACP coding-agent adapter processes so they don't outlive the app.
|
||||
try {
|
||||
container.resolve<CodeModeManager>('codeModeManager').disposeAll();
|
||||
} catch {
|
||||
// nothing live to dispose
|
||||
}
|
||||
shutdownLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -800,6 +800,108 @@
|
|||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestions {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
width: max-content;
|
||||
min-width: 280px;
|
||||
max-width: min(440px, 100%);
|
||||
background: var(--gm-bg-elevated, #1e1e1e);
|
||||
border: 1px solid var(--gm-border);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.18),
|
||||
0 12px 32px rgba(0, 0, 0, 0.36);
|
||||
max-height: 296px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
transform-origin: top left;
|
||||
animation: gmail-recipient-suggestions-in 110ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes gmail-recipient-suggestions-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--gm-text);
|
||||
cursor: pointer;
|
||||
transition: background-color 80ms linear;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion.is-active {
|
||||
background: rgba(99, 142, 255, 0.18);
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-avatar {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-email {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11.5px;
|
||||
color: var(--gm-text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-match {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
|
|||
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||
import './App.css'
|
||||
import z from 'zod';
|
||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react';
|
||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
||||
import { ChatSidebar } from './components/chat-sidebar';
|
||||
|
|
@ -29,6 +29,7 @@ import { LiveNotesView } from '@/components/live-notes-view';
|
|||
import { BgTasksView } from '@/components/bg-tasks-view';
|
||||
import { EmailView } from '@/components/email-view';
|
||||
import { WorkspaceView } from '@/components/workspace-view';
|
||||
import { CodingRunBlock } from '@/components/coding-run';
|
||||
import { KnowledgeView } from '@/components/knowledge-view';
|
||||
import { ChatHistoryView } from '@/components/chat-history-view';
|
||||
import { HomeView } from '@/components/home-view';
|
||||
|
|
@ -117,6 +118,7 @@ import { useVoiceTTS } from '@/hooks/useVoiceTTS'
|
|||
import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
|
||||
import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
|
||||
type DirEntry = z.infer<typeof workspace.DirEntry>
|
||||
type RunEventType = z.infer<typeof RunEvent>
|
||||
|
|
@ -165,6 +167,7 @@ function AutoScrollPre({ className, children }: { className?: string; children:
|
|||
}
|
||||
|
||||
const DEFAULT_SIDEBAR_WIDTH = 256
|
||||
const DEFAULT_CHAT_PANE_WIDTH = 460
|
||||
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
|
||||
const graphPalette = [
|
||||
{ hue: 210, sat: 72, light: 52 },
|
||||
|
|
@ -736,6 +739,9 @@ function ContentHeader({
|
|||
}
|
||||
|
||||
function App() {
|
||||
const { chatPanePlacement, chatPaneSize } = useTheme()
|
||||
const isChatPaneInMiddle = chatPanePlacement === 'middle'
|
||||
|
||||
type ShortcutPane = 'left' | 'right'
|
||||
type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean }
|
||||
|
||||
|
|
@ -765,7 +771,7 @@ function App() {
|
|||
// Lives in ViewState so folder drill-down participates in back/forward history.
|
||||
const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState<string | null>(null)
|
||||
const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false)
|
||||
// Default landing view: Home in the middle with the chat docked on the right.
|
||||
// Default landing view: Home with the chat docked according to appearance settings.
|
||||
const [isHomeOpen, setIsHomeOpen] = useState(true)
|
||||
const [emailInitialThreadId, setEmailInitialThreadId] = useState<string | null>(null)
|
||||
const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
|
||||
|
|
@ -2193,19 +2199,6 @@ function App() {
|
|||
status: 'running',
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
// Detect acpx-driven coding-agent runs so the composer can retroactively
|
||||
// flip code mode on with the right agent (when the user reached the skill
|
||||
// via plain prompt rather than the explicit toggle).
|
||||
if (llmEvent.toolName === 'executeCommand') {
|
||||
const input = llmEvent.input as { command?: unknown } | undefined
|
||||
const cmd = typeof input?.command === 'string' ? input.command : ''
|
||||
const match = cmd.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b/)
|
||||
if (match) {
|
||||
window.dispatchEvent(new CustomEvent('code-mode-detected', {
|
||||
detail: { runId: event.runId, agent: match[1] as 'claude' | 'codex' },
|
||||
}))
|
||||
}
|
||||
}
|
||||
} else if (llmEvent.type === 'finish-step') {
|
||||
const nextUsage = normalizeUsage(llmEvent.usage)
|
||||
if (nextUsage) {
|
||||
|
|
@ -2303,6 +2296,8 @@ function App() {
|
|||
...item,
|
||||
result: event.result as ToolUIPart['output'],
|
||||
status: 'completed' as const,
|
||||
// a code_agent_run finished — drop any lingering permission card
|
||||
pendingCodePermission: null,
|
||||
}
|
||||
}
|
||||
return item
|
||||
|
|
@ -2383,6 +2378,33 @@ function App() {
|
|||
break
|
||||
}
|
||||
|
||||
case 'code-run-event': {
|
||||
if (!isActiveRun) return
|
||||
setConversation(prev => prev.map(item => {
|
||||
if (isToolCall(item) && item.id === event.toolCallId) {
|
||||
const existing = item.codeRunEvents ?? []
|
||||
if (existing.length === 0) {
|
||||
setToolOpenForTab(activeChatTabIdRef.current, item.id, true)
|
||||
}
|
||||
return { ...item, codeRunEvents: [...existing, event.event] }
|
||||
}
|
||||
return item
|
||||
}))
|
||||
break
|
||||
}
|
||||
|
||||
case 'code-run-permission-request': {
|
||||
if (!isActiveRun) return
|
||||
setConversation(prev => prev.map(item => {
|
||||
if (isToolCall(item) && item.id === event.toolCallId) {
|
||||
setToolOpenForTab(activeChatTabIdRef.current, item.id, true)
|
||||
return { ...item, pendingCodePermission: { requestId: event.requestId, ask: event.ask } }
|
||||
}
|
||||
return item
|
||||
}))
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-permission-auto-decision': {
|
||||
if (!isActiveRun) return
|
||||
setAutoPermissionDecisions(prev => {
|
||||
|
|
@ -2725,6 +2747,26 @@ function App() {
|
|||
}
|
||||
}, [runId])
|
||||
|
||||
// Answer a mid-run permission request from a code_agent_run coding turn. The
|
||||
// pending ask lives on the tool call itself, so we optimistically clear it and
|
||||
// tell main which decision the user picked (keyed by the request id).
|
||||
const handleCodePermissionResponse = useCallback(async (
|
||||
toolCallId: string,
|
||||
requestId: string,
|
||||
decision: 'allow_once' | 'allow_always' | 'reject',
|
||||
) => {
|
||||
setConversation(prev => prev.map(item =>
|
||||
isToolCall(item) && item.id === toolCallId
|
||||
? { ...item, pendingCodePermission: null }
|
||||
: item
|
||||
))
|
||||
try {
|
||||
await window.ipc.invoke('codeRun:resolvePermission', { requestId, decision })
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve code permission:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => {
|
||||
if (!runId) return
|
||||
try {
|
||||
|
|
@ -5142,6 +5184,21 @@ function App() {
|
|||
}
|
||||
|
||||
if (isToolCall(item)) {
|
||||
if (item.name === 'code_agent_run') {
|
||||
return (
|
||||
<CodingRunBlock
|
||||
key={item.id}
|
||||
item={item}
|
||||
open={isToolOpenForTab(tabId, item.id)}
|
||||
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
|
||||
onPermissionDecision={(decision) => {
|
||||
if (item.pendingCodePermission) {
|
||||
handleCodePermissionResponse(item.id, item.pendingCodePermission.requestId, decision)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const appActionData = getAppActionCardData(item)
|
||||
if (appActionData) {
|
||||
return <AppActionCard key={item.id} data={appActionData} status={item.status} />
|
||||
|
|
@ -5246,6 +5303,17 @@ function App() {
|
|||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen)
|
||||
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
||||
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||
const nonChatPaneStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
const style: React.CSSProperties = { maxWidth: insetMaxWidth }
|
||||
if (!isRightPaneContext || !isChatSidebarOpen || isRightPaneMaximized) return style
|
||||
if (chatPaneSize === 'chat-equal') {
|
||||
return { ...style, width: 0, flex: '1 1 0' }
|
||||
}
|
||||
if (chatPaneSize === 'chat-bigger') {
|
||||
return { ...style, width: DEFAULT_CHAT_PANE_WIDTH, flex: '0 0 auto' }
|
||||
}
|
||||
return style
|
||||
}, [chatPaneSize, insetMaxWidth, isChatSidebarOpen, isRightPaneContext, isRightPaneMaximized])
|
||||
// Collapsing: pin max-width to the snapshot px (no transition) for one frame so it's
|
||||
// binding immediately (no flex jump), then animate to 0. Expanding goes back to 100%
|
||||
// — its non-binding range lands at the end of the range, where it isn't visible.
|
||||
|
|
@ -5323,10 +5391,11 @@ function App() {
|
|||
<SidebarInset
|
||||
className={cn(
|
||||
"overflow-hidden! min-h-0 min-w-0",
|
||||
isRightPaneContext && isChatPaneInMiddle && "order-3",
|
||||
insetAnimateMaxWidth && "transition-[max-width] duration-200 ease-linear",
|
||||
shouldCollapseLeftPane && "pointer-events-none select-none"
|
||||
)}
|
||||
style={{ maxWidth: insetMaxWidth }}
|
||||
style={nonChatPaneStyle}
|
||||
aria-hidden={shouldCollapseLeftPane}
|
||||
onMouseDownCapture={() => setActiveShortcutPane('left')}
|
||||
onFocusCapture={() => setActiveShortcutPane('left')}
|
||||
|
|
@ -5438,7 +5507,11 @@ function App() {
|
|||
: (viewOpen && !isChatSidebarOpen)
|
||||
? { onClick: openChatSidePane, icon: <MessageSquare className="size-5" />, label: 'Open chat' }
|
||||
: (viewOpen && isChatSidebarOpen && !isRightPaneMaximized)
|
||||
? { onClick: () => setIsChatSidebarOpen(false), icon: <ArrowRight className="size-5" />, label: 'Expand pane' }
|
||||
? {
|
||||
onClick: () => setIsChatSidebarOpen(false),
|
||||
icon: isChatPaneInMiddle ? <ArrowLeft className="size-5" /> : <ArrowRight className="size-5" />,
|
||||
label: 'Expand pane'
|
||||
}
|
||||
: null
|
||||
return (
|
||||
<Tooltip>
|
||||
|
|
@ -5865,24 +5938,6 @@ function App() {
|
|||
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
||||
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
||||
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||
onSwitchAgent={async (newAgent) => {
|
||||
const runIdForSwitch = tab.runId
|
||||
await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')
|
||||
window.dispatchEvent(new CustomEvent('code-mode-detected', {
|
||||
detail: { runId: runIdForSwitch, agent: newAgent },
|
||||
}))
|
||||
if (runIdForSwitch) {
|
||||
try {
|
||||
await window.ipc.invoke('runs:createMessage', {
|
||||
runId: runIdForSwitch,
|
||||
message: `Use ${newAgent === 'claude' ? 'Claude Code' : 'Codex'} instead — rerun the same task with the same prompt, just swap the agent binary to \`${newAgent}\`.`,
|
||||
codeMode: newAgent,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to send swap-agent follow-up', err)
|
||||
}
|
||||
}
|
||||
}}
|
||||
isProcessing={isActive && isProcessing}
|
||||
response={response}
|
||||
/>
|
||||
|
|
@ -5989,10 +6044,13 @@ function App() {
|
|||
)}
|
||||
</SidebarInset>
|
||||
|
||||
{/* Chat sidebar - shown when viewing files/graph */}
|
||||
{/* Chat pane - shown when viewing files/graph */}
|
||||
{isRightPaneContext && (
|
||||
<ChatSidebar
|
||||
defaultWidth={460}
|
||||
placement={chatPanePlacement}
|
||||
paneSize={chatPaneSize}
|
||||
className={isChatPaneInMiddle ? "order-2" : undefined}
|
||||
defaultWidth={DEFAULT_CHAT_PANE_WIDTH}
|
||||
isOpen={isChatSidebarOpen}
|
||||
isMaximized={isRightPaneMaximized}
|
||||
chatTabs={chatTabs}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -9,7 +8,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, RefreshCwIcon, Terminal, XIcon } from "lucide-react";
|
||||
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
||||
|
|
@ -21,7 +20,6 @@ export type PermissionRequestProps = ComponentProps<"div"> & {
|
|||
onApproveSession?: () => void;
|
||||
onApproveAlways?: () => void;
|
||||
onDeny?: () => void;
|
||||
onSwitchAgent?: (newAgent: 'claude' | 'codex') => void;
|
||||
isProcessing?: boolean;
|
||||
response?: 'approve' | 'deny' | null;
|
||||
permission?: z.infer<typeof ToolPermissionMetadata>;
|
||||
|
|
@ -42,7 +40,6 @@ export const PermissionRequest = ({
|
|||
onApproveSession,
|
||||
onApproveAlways,
|
||||
onDeny,
|
||||
onSwitchAgent,
|
||||
isProcessing = false,
|
||||
response = null,
|
||||
permission,
|
||||
|
|
@ -56,17 +53,6 @@ export const PermissionRequest = ({
|
|||
: null;
|
||||
const filePermission = permission?.kind === "file" ? permission : null;
|
||||
|
||||
// Detect acpx coding-agent invocations so we can show the agent identity and
|
||||
// offer a one-click swap-and-retry.
|
||||
const acpxAgent: 'claude' | 'codex' | null = (() => {
|
||||
if (!command) return null;
|
||||
const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/);
|
||||
return match ? (match[1] as 'claude' | 'codex') : null;
|
||||
})();
|
||||
const otherAgent: 'claude' | 'codex' | null = acpxAgent === 'claude' ? 'codex' : acpxAgent === 'codex' ? 'claude' : null;
|
||||
const agentDisplay = acpxAgent === 'claude' ? 'Claude Code' : acpxAgent === 'codex' ? 'Codex' : null;
|
||||
const otherDisplay = otherAgent === 'claude' ? 'Claude Code' : otherAgent === 'codex' ? 'Codex' : null;
|
||||
|
||||
const isResponded = response !== null;
|
||||
const isApproved = response === 'approve';
|
||||
|
||||
|
|
@ -104,15 +90,6 @@ export const PermissionRequest = ({
|
|||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{isResponded ? "Requested:" : "The agent wants to execute:"} <span className="font-mono font-medium">{toolCall.toolName}</span>
|
||||
{agentDisplay && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-2 align-middle bg-secondary text-foreground"
|
||||
>
|
||||
<Terminal className="size-3 mr-1" />
|
||||
{agentDisplay}
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{isResponded && (
|
||||
|
|
@ -220,18 +197,6 @@ export const PermissionRequest = ({
|
|||
<XIcon className="size-4" />
|
||||
Deny
|
||||
</Button>
|
||||
{otherAgent && otherDisplay && onSwitchAgent && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onSwitchAgent(otherAgent)}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Use {otherDisplay} instead
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type { ConversationItem } from '@/lib/chat-conversation'
|
|||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
import { RichMarkdownViewer } from '@/components/rich-markdown-viewer'
|
||||
import { HtmlFileViewer } from '@/components/html-file-viewer'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trigger helpers (inlined; extract to shared <TriggersEditor> as a follow-up)
|
||||
|
|
@ -502,15 +503,22 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output pane — index.md (main pane content)
|
||||
// Output pane — index.html (preferred) or index.md (main pane content)
|
||||
//
|
||||
// Renders the task's `index.md` like a note: max-width 720px centered, same
|
||||
// typography (~16px, 1.5 line-height, generous padding) as the note editor's
|
||||
// ProseMirror rule in `editor.css`. No chrome above the body — just the
|
||||
// markdown, with a small floating Source ⇄ Rendered toggle in the top-right.
|
||||
// A task's agent-owned artifact is either:
|
||||
// - `index.html` — a self-contained, styled web page. Rendered full-bleed in
|
||||
// a sandboxed iframe (via `HtmlFileViewer` / the `app://workspace`
|
||||
// protocol) so CSS, layout, and scripts render faithfully. Preferred when
|
||||
// present and non-empty.
|
||||
// - `index.md` — a note. Rendered like the note editor: max-width 720px
|
||||
// centered, same typography as `editor.css`, via `RichMarkdownViewer`.
|
||||
//
|
||||
// In both cases a small floating Source ⇄ Rendered toggle in the top-right
|
||||
// swaps the rendered view for the raw file source.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: string; refreshKey: number }) {
|
||||
const [mode, setMode] = useState<'md' | 'html'>('md')
|
||||
const [body, setBody] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [viewSource, setViewSource] = useState(false)
|
||||
|
|
@ -519,21 +527,33 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st
|
|||
let cancelled = false
|
||||
setLoading(true)
|
||||
void (async () => {
|
||||
// Prefer index.html when it exists and has content; otherwise fall
|
||||
// back to index.md (the default seeded artifact).
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', {
|
||||
const html = await window.ipc.invoke('workspace:readFile', {
|
||||
path: `bg-tasks/${slug}/index.html`,
|
||||
})
|
||||
if (html.data.trim()) {
|
||||
if (!cancelled) { setMode('html'); setBody(html.data) }
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// No index.html — fall through to markdown.
|
||||
}
|
||||
try {
|
||||
const md = await window.ipc.invoke('workspace:readFile', {
|
||||
path: `bg-tasks/${slug}/index.md`,
|
||||
})
|
||||
if (!cancelled) setBody(result.data)
|
||||
if (!cancelled) { setMode('md'); setBody(md.data) }
|
||||
} catch {
|
||||
if (!cancelled) setBody('')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
if (!cancelled) { setMode('md'); setBody('') }
|
||||
}
|
||||
})()
|
||||
})().finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [slug, refreshKey])
|
||||
|
||||
const isEmpty = !body.trim() || body.trim() === `# ${taskName}`
|
||||
const isEmpty = mode === 'md' && (!body.trim() || body.trim() === `# ${taskName}`)
|
||||
const showHtml = mode === 'html' && !viewSource
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 overflow-hidden bg-background">
|
||||
|
|
@ -542,29 +562,35 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st
|
|||
type="button"
|
||||
onClick={() => setViewSource(v => !v)}
|
||||
className="absolute right-4 top-3 z-10 rounded-md bg-background/70 px-2 py-0.5 text-[11px] text-muted-foreground backdrop-blur hover:bg-accent hover:text-foreground"
|
||||
aria-label={viewSource ? 'Show rendered output' : 'Show source markdown'}
|
||||
aria-label={viewSource ? 'Show rendered output' : 'Show source'}
|
||||
>
|
||||
{viewSource ? 'Rendered' : 'Source'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mx-auto max-w-[720px] px-16 py-8">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
No output yet. Click <span className="font-medium text-foreground">Run now</span> in the sidebar, or wait for a trigger to fire.
|
||||
</p>
|
||||
) : viewSource ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[13px] leading-relaxed">{body}</pre>
|
||||
) : (
|
||||
<RichMarkdownViewer content={body} />
|
||||
)}
|
||||
{showHtml ? (
|
||||
// Full-bleed: the iframe fills the pane and scrolls internally.
|
||||
// Remount on refreshKey so a re-run's updated index.html reloads.
|
||||
<HtmlFileViewer key={`${slug}-${refreshKey}`} path={`bg-tasks/${slug}/index.html`} />
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mx-auto max-w-[720px] px-16 py-8">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
No output yet. Click <span className="font-medium text-foreground">Run now</span> in the sidebar, or wait for a trigger to fire.
|
||||
</p>
|
||||
) : viewSource ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[13px] leading-relaxed">{body}</pre>
|
||||
) : (
|
||||
<RichMarkdownViewer content={body} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1237,6 +1263,8 @@ function TaskDetail({
|
|||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [outputRefreshKey, setOutputRefreshKey] = useState(0)
|
||||
// Whether we've already chosen the initial sidebar state for this task.
|
||||
const sidebarInitialized = useRef(false)
|
||||
|
||||
const agentStatus = useBackgroundTaskAgentStatus()
|
||||
const liveStatus = agentStatus.get(slug)
|
||||
|
|
@ -1252,6 +1280,23 @@ function TaskDetail({
|
|||
if (result.success && result.task) {
|
||||
setTask(result.task)
|
||||
setDraft(result.task)
|
||||
// On first open, collapse the details sidebar when the agent
|
||||
// already has output — let the user read it without chrome.
|
||||
// Resolved before `loading` clears so the sidebar never flashes.
|
||||
if (!sidebarInitialized.current) {
|
||||
sidebarInitialized.current = true
|
||||
try {
|
||||
const out = await window.ipc.invoke('workspace:readFile', {
|
||||
path: `bg-tasks/${slug}/index.md`,
|
||||
})
|
||||
const body = (out.data ?? '').trim()
|
||||
if (body && body !== `# ${result.task.name}`) {
|
||||
setSidebarOpen(false)
|
||||
}
|
||||
} catch {
|
||||
// No output file yet — keep the sidebar open.
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
ArrowUp,
|
||||
|
|
@ -10,12 +10,16 @@ import {
|
|||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileVideo,
|
||||
FolderCheck,
|
||||
FolderClock,
|
||||
FolderCog,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
Headphones,
|
||||
ImagePlus,
|
||||
LoaderIcon,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
|
|
@ -26,10 +30,14 @@ import {
|
|||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
|
|
@ -61,6 +69,12 @@ export type StagedAttachment = {
|
|||
}
|
||||
|
||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
const MAX_VISIBLE_RECENT_WORK_DIRS = 3
|
||||
const MAX_STORED_RECENT_WORK_DIRS = 8
|
||||
// Stored in the workspace (~/.rowboat/config) so it travels with the workspace and
|
||||
// stays consistent with the other config/*.json files (e.g. coding-agents.json).
|
||||
const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json'
|
||||
const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed'
|
||||
|
||||
|
||||
const providerDisplayNames: Record<string, string> = {
|
||||
|
|
@ -81,6 +95,11 @@ interface ConfiguredModel {
|
|||
model: string
|
||||
}
|
||||
|
||||
type RecentWorkDir = {
|
||||
path: string
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
export interface SelectedModel {
|
||||
provider: string
|
||||
model: string
|
||||
|
|
@ -111,6 +130,84 @@ function getAttachmentIcon(kind: AttachmentIconKind) {
|
|||
}
|
||||
}
|
||||
|
||||
function normalizeRecentWorkDir(value: unknown): RecentWorkDir | null {
|
||||
if (typeof value === 'string') {
|
||||
const path = value.trim()
|
||||
return path ? { path, lastUsedAt: 0 } : null
|
||||
}
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const entry = value as Record<string, unknown>
|
||||
const path = typeof entry.path === 'string' ? entry.path.trim() : ''
|
||||
const lastUsedAt = typeof entry.lastUsedAt === 'number' && Number.isFinite(entry.lastUsedAt)
|
||||
? entry.lastUsedAt
|
||||
: 0
|
||||
return path ? { path, lastUsedAt } : null
|
||||
}
|
||||
|
||||
async function readRecentWorkDirs(): Promise<RecentWorkDir[]> {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: RECENT_WORK_DIRS_CONFIG_PATH })
|
||||
const parsed = JSON.parse(result.data)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
const seen = new Set<string>()
|
||||
const dirs: RecentWorkDir[] = []
|
||||
for (const value of parsed) {
|
||||
const entry = normalizeRecentWorkDir(value)
|
||||
if (!entry || seen.has(entry.path)) continue
|
||||
seen.add(entry.path)
|
||||
dirs.push(entry)
|
||||
if (dirs.length >= MAX_STORED_RECENT_WORK_DIRS) break
|
||||
}
|
||||
return dirs
|
||||
} catch {
|
||||
// File missing or invalid — no recents yet.
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRecentWorkDirs(dirs: RecentWorkDir[]) {
|
||||
try {
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: RECENT_WORK_DIRS_CONFIG_PATH,
|
||||
data: JSON.stringify(dirs.slice(0, MAX_STORED_RECENT_WORK_DIRS), null, 2),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to persist recent work directories', err)
|
||||
}
|
||||
// Notify other mounted chat inputs in this window to re-read.
|
||||
window.dispatchEvent(new CustomEvent(RECENT_WORK_DIRS_CHANGED_EVENT))
|
||||
}
|
||||
|
||||
function formatRecentWorkDirTime(lastUsedAt: number) {
|
||||
if (!lastUsedAt) return ''
|
||||
const now = Date.now()
|
||||
const diffMs = Math.max(0, now - lastUsedAt)
|
||||
const minute = 60 * 1000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
if (diffMs < minute) return 'now'
|
||||
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))}m ago`
|
||||
if (diffMs < day) return `${Math.floor(diffMs / hour)}h ago`
|
||||
|
||||
const used = new Date(lastUsedAt)
|
||||
const yesterday = new Date(now - day)
|
||||
if (
|
||||
used.getFullYear() === yesterday.getFullYear() &&
|
||||
used.getMonth() === yesterday.getMonth() &&
|
||||
used.getDate() === yesterday.getDate()
|
||||
) {
|
||||
return 'Yesterday'
|
||||
}
|
||||
if (diffMs < 7 * day) {
|
||||
return used.toLocaleDateString(undefined, { weekday: 'short' })
|
||||
}
|
||||
return used.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function compactWorkDirPath(path: string) {
|
||||
return path.replace(/^\/Users\/[^/]+/, '~')
|
||||
}
|
||||
|
||||
interface ChatInputInnerProps {
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||
onStop?: () => void
|
||||
|
|
@ -186,6 +283,52 @@ function ChatInputInner({
|
|||
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
||||
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto')
|
||||
const [recentWorkDirs, setRecentWorkDirs] = useState<RecentWorkDir[]>([])
|
||||
|
||||
// Responsive toolbar: measure real overflow and progressively collapse items
|
||||
// right→left until everything fits. Stages:
|
||||
// 1 code→icon · 2 perm→icon · 3 search label hidden · 4 workDir→icon
|
||||
// 5 code→menu · 6 perm→menu · 7 search→menu · 8 workDir→menu
|
||||
// Once items move into the "⋯" overflow menu (≥5) no icon is ever hidden.
|
||||
// overflow-hidden on the left group is the hard guarantee against any overlap.
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const leftGroupRef = useRef<HTMLDivElement>(null)
|
||||
const lastWidthRef = useRef(0)
|
||||
const [collapseLevel, setCollapseLevel] = useState(0)
|
||||
|
||||
// Re-evaluate from scratch (level 0) whenever the available width changes…
|
||||
useEffect(() => {
|
||||
const outer = toolbarRef.current
|
||||
if (!outer) return
|
||||
const ro = new ResizeObserver(() => {
|
||||
const w = outer.clientWidth
|
||||
if (w !== lastWidthRef.current) {
|
||||
lastWidthRef.current = w
|
||||
setCollapseLevel(0)
|
||||
}
|
||||
})
|
||||
ro.observe(outer)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// …or when the *set* of items changes (an item appears/disappears, or the model
|
||||
// name width changes). Deliberately excludes the in-place toggles (searchEnabled,
|
||||
// permissionMode, codeModeEnabled, codingAgent): those fire from the overflow menu
|
||||
// for items already inside it, so resetting here would unmount the open menu. The
|
||||
// no-dep effect below still re-collapses if any toggle happens to widen the row.
|
||||
useLayoutEffect(() => {
|
||||
setCollapseLevel(0)
|
||||
}, [workDir, searchAvailable, codeModeFeatureEnabled, lockedModel, activeModelKey])
|
||||
|
||||
// After each render, if the left group still overflows, collapse one more step.
|
||||
// Runs before paint, so the intermediate (overflowing) state is never visible.
|
||||
useLayoutEffect(() => {
|
||||
const el = leftGroupRef.current
|
||||
if (!el) return
|
||||
if (el.scrollWidth > el.clientWidth + 1 && collapseLevel < 8) {
|
||||
setCollapseLevel((l) => Math.min(8, l + 1))
|
||||
}
|
||||
})
|
||||
|
||||
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
||||
useEffect(() => {
|
||||
|
|
@ -205,6 +348,15 @@ function ChatInputInner({
|
|||
return () => { cancelled = true }
|
||||
}, [runId])
|
||||
|
||||
useEffect(() => {
|
||||
const syncRecentWorkDirs = () => { void readRecentWorkDirs().then(setRecentWorkDirs) }
|
||||
syncRecentWorkDirs()
|
||||
window.addEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
|
||||
return () => {
|
||||
window.removeEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check Rowboat sign-in state
|
||||
useEffect(() => {
|
||||
window.ipc.invoke('oauth:getState', null).then((result) => {
|
||||
|
|
@ -289,20 +441,6 @@ function ChatInputInner({
|
|||
}
|
||||
}, [codeModeFeatureEnabled, codeModeEnabled])
|
||||
|
||||
// Listen for coding-agent runs that were triggered without the explicit code-mode
|
||||
// toggle. App.tsx dispatches this when it sees an acpx executeCommand fire. We
|
||||
// flip the pill on with the detected agent so the UI reflects what's happening.
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent<{ runId?: string; agent?: 'claude' | 'codex' }>).detail
|
||||
if (!detail || !detail.agent) return
|
||||
if (runId && detail.runId && detail.runId !== runId) return
|
||||
setCodeModeEnabled(true)
|
||||
setCodingAgent(detail.agent)
|
||||
}
|
||||
window.addEventListener('code-mode-detected', handler)
|
||||
return () => window.removeEventListener('code-mode-detected', handler)
|
||||
}, [runId])
|
||||
|
||||
// Cross-platform basename — handles both / and \ separators.
|
||||
const basename = useCallback((p: string): string => {
|
||||
|
|
@ -311,6 +449,17 @@ function ChatInputInner({
|
|||
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
|
||||
}, [])
|
||||
|
||||
const rememberWorkDir = useCallback(async (dir: string) => {
|
||||
const trimmed = dir.trim()
|
||||
if (!trimmed) return
|
||||
const next = [
|
||||
{ path: trimmed, lastUsedAt: Date.now() },
|
||||
...(await readRecentWorkDirs()).filter((item) => item.path !== trimmed),
|
||||
].slice(0, MAX_STORED_RECENT_WORK_DIRS)
|
||||
setRecentWorkDirs(next)
|
||||
await writeRecentWorkDirs(next)
|
||||
}, [])
|
||||
|
||||
// Load coding-agent preference for a given workdir.
|
||||
// Storage: config/coding-agents.json — { [workDirPath]: 'claude' | 'codex' }
|
||||
const loadCodingAgentFor = useCallback(async (dir: string | null): Promise<'claude' | 'codex'> => {
|
||||
|
|
@ -327,7 +476,7 @@ function ChatInputInner({
|
|||
}, [])
|
||||
|
||||
const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => {
|
||||
let existing: Record<string, 'claude' | 'codex'> = {}
|
||||
const existing: Record<string, 'claude' | 'codex'> = {}
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
|
||||
const parsed = JSON.parse(result.data) as Record<string, unknown>
|
||||
|
|
@ -353,6 +502,10 @@ function ChatInputInner({
|
|||
return () => { cancelled = true }
|
||||
}, [workDir, loadCodingAgentFor])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && workDir) void rememberWorkDir(workDir)
|
||||
}, [isActive, workDir, rememberWorkDir])
|
||||
|
||||
const handleSetWorkDir = useCallback(async () => {
|
||||
try {
|
||||
let defaultPath: string | undefined = workDir ?? undefined
|
||||
|
|
@ -373,13 +526,21 @@ function ChatInputInner({
|
|||
})
|
||||
if (!chosen) return
|
||||
onWorkDirChange?.(chosen)
|
||||
await rememberWorkDir(chosen)
|
||||
setCodingAgent(await loadCodingAgentFor(chosen))
|
||||
toast.success(`Work directory set: ${chosen}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to set work directory', err)
|
||||
toast.error('Failed to set work directory')
|
||||
}
|
||||
}, [workDir, onWorkDirChange, loadCodingAgentFor])
|
||||
}, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
|
||||
|
||||
const handleSelectRecentWorkDir = useCallback(async (dir: string) => {
|
||||
onWorkDirChange?.(dir)
|
||||
await rememberWorkDir(dir)
|
||||
setCodingAgent(await loadCodingAgentFor(dir))
|
||||
toast.success(`Work directory set: ${dir}`)
|
||||
}, [onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
|
||||
|
||||
const handleClearWorkDir = useCallback(() => {
|
||||
onWorkDirChange?.(null)
|
||||
|
|
@ -533,6 +694,12 @@ function ChatInputInner({
|
|||
}
|
||||
}, [addFiles, isActive])
|
||||
|
||||
const visibleRecentWorkDirs = recentWorkDirs
|
||||
.filter((entry) => entry.path !== workDir)
|
||||
.slice(0, MAX_VISIBLE_RECENT_WORK_DIRS)
|
||||
const currentWorkDirLabel = workDir ? basename(workDir) || workDir : 'Not set'
|
||||
const currentWorkDirPath = workDir ? compactWorkDirPath(workDir) : ''
|
||||
|
||||
return (
|
||||
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
|
||||
{attachments.length > 0 && (
|
||||
|
|
@ -637,7 +804,8 @@ function ChatInputInner({
|
|||
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 pb-3">
|
||||
<div ref={toolbarRef} className="flex items-center gap-2 px-4 pb-3">
|
||||
<div ref={leftGroupRef} className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -651,39 +819,123 @@ function ChatInputInner({
|
|||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Add files or set work directory</TooltipContent>
|
||||
<TooltipContent side="top">
|
||||
{workDir ? 'Add files or change work directory' : 'Add files or set work directory'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" className="min-w-56">
|
||||
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()}>
|
||||
<ImagePlus className="size-4" />
|
||||
<span>Add files or photos</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}>
|
||||
<FolderCog className="size-4" />
|
||||
<span>{workDir ? 'Change work directory' : 'Set work directory'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuContent align="start" className="w-72 max-w-[calc(100vw-2rem)] p-2">
|
||||
<div className="rounded-[14px] border border-border/80 bg-background p-1">
|
||||
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()} className="h-9 rounded-[9px] px-2.5">
|
||||
<ImagePlus className="size-4" />
|
||||
<span>Add files or photos</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Working directory lives behind a submenu so the main menu stays to two
|
||||
items. One hover/click away for power users; out of the way otherwise. */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="h-9 rounded-[9px] px-2.5">
|
||||
<FolderCog className="size-4" />
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span>Set working directory</span>
|
||||
<span className="min-w-0 max-w-[110px] truncate text-xs text-muted-foreground">
|
||||
{currentWorkDirLabel}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-72 max-w-[calc(100vw-2rem)] p-1">
|
||||
{/* Current selection — shown for context only when one is set. */}
|
||||
{workDir && (
|
||||
<div
|
||||
title={workDir}
|
||||
className="mb-1 flex items-center gap-2 rounded-[9px] bg-blue-50/80 px-2.5 py-2 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300"
|
||||
>
|
||||
<FolderCheck className="size-4 shrink-0 text-blue-600 dark:text-blue-300" />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium">{currentWorkDirLabel}</span>
|
||||
<span className="truncate text-xs text-blue-700/70 dark:text-blue-300/70">
|
||||
{currentWorkDirPath}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary action: choose when unset, change when set. Always on top. */}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => { void handleSetWorkDir() }}
|
||||
className="h-9 rounded-[9px] px-2.5"
|
||||
>
|
||||
<FolderOpen className="size-4" />
|
||||
<span>{workDir ? 'Change folder…' : 'Choose a folder…'}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{visibleRecentWorkDirs.length > 0 && (
|
||||
<>
|
||||
<div className="px-2.5 pb-1 pt-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Recent
|
||||
</div>
|
||||
{visibleRecentWorkDirs.map((entry) => {
|
||||
const name = basename(entry.path) || entry.path
|
||||
const when = formatRecentWorkDirTime(entry.lastUsedAt)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entry.path}
|
||||
title={entry.path}
|
||||
onSelect={() => { void handleSelectRecentWorkDir(entry.path) }}
|
||||
className="h-8 rounded-[9px] px-2.5"
|
||||
>
|
||||
<FolderClock className="size-4" />
|
||||
<span className="min-w-0 flex-1 truncate">{name}</span>
|
||||
{when && <span className="shrink-0 text-xs text-muted-foreground">{when}</span>}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Clear — only meaningful once a directory is set. Kept at the bottom. */}
|
||||
{workDir && (
|
||||
<>
|
||||
<div className="my-1 h-px bg-border/60" />
|
||||
<DropdownMenuItem
|
||||
onSelect={handleClearWorkDir}
|
||||
className="h-8 rounded-[9px] px-2.5 text-red-600 focus:bg-red-50 focus:text-red-600 dark:text-red-400 dark:focus:bg-red-950/30"
|
||||
>
|
||||
<X className="size-4" />
|
||||
<span>Clear folder</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{workDir && (
|
||||
{workDir && collapseLevel < 8 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="group flex h-7 max-w-[180px] shrink-0 items-center rounded-full border border-border bg-muted/40 pl-2.5 pr-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
{/* Level 4: collapse to a square icon */}
|
||||
<div className={cn(
|
||||
"group flex h-7 shrink-0 items-center rounded-full border border-border bg-muted/40 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
|
||||
collapseLevel >= 4 ? "w-7 justify-center" : "max-w-[180px] pl-2.5 pr-2"
|
||||
)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetWorkDir}
|
||||
className="flex min-w-0 items-center gap-1.5"
|
||||
>
|
||||
<FolderCog className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{basename(workDir) || workDir}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearWorkDir}
|
||||
aria-label="Remove work directory"
|
||||
className="flex h-3.5 w-0 shrink-0 items-center justify-center overflow-hidden opacity-0 transition-all duration-150 ease-out hover:text-red-500 group-hover:ml-1 group-hover:w-3.5 group-hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 shrink-0" />
|
||||
{collapseLevel < 4 && <span className="truncate">{basename(workDir) || workDir}</span>}
|
||||
</button>
|
||||
{collapseLevel < 4 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearWorkDir}
|
||||
aria-label="Remove work directory"
|
||||
className="flex h-3.5 w-0 shrink-0 items-center justify-center overflow-hidden opacity-0 transition-all duration-150 ease-out hover:text-red-500 group-hover:ml-1 group-hover:w-3.5 group-hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 shrink-0" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
|
|
@ -691,7 +943,7 @@ function ChatInputInner({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{searchAvailable && (
|
||||
{searchAvailable && collapseLevel < 7 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchEnabled((v) => !v)}
|
||||
|
|
@ -705,16 +957,14 @@ function ChatInputInner({
|
|||
)}
|
||||
>
|
||||
<Globe className="h-4 w-4 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
'overflow-hidden whitespace-nowrap text-xs font-medium transition-all duration-150 ease-out',
|
||||
searchEnabled ? 'ml-1.5 max-w-[60px] opacity-100' : 'max-w-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
Search
|
||||
</span>
|
||||
{searchEnabled && collapseLevel < 3 && (
|
||||
<span className="ml-1.5 whitespace-nowrap text-xs font-medium">
|
||||
Search
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{collapseLevel < 6 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -725,7 +975,8 @@ function ChatInputInner({
|
|||
}}
|
||||
disabled={Boolean(runId)}
|
||||
className={cn(
|
||||
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors",
|
||||
"flex h-7 shrink-0 items-center gap-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
collapseLevel >= 2 ? "w-7 justify-center" : "px-2.5",
|
||||
permissionMode === 'auto'
|
||||
? "bg-secondary text-foreground hover:bg-secondary/70"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
|
|
@ -733,8 +984,8 @@ function ChatInputInner({
|
|||
)}
|
||||
aria-label="Permission mode"
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
<span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span>
|
||||
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
|
||||
{collapseLevel < 2 && <span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span>}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
|
|
@ -745,37 +996,54 @@ function ChatInputInner({
|
|||
: 'Manual approval prompts — click for auto-permission'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{codeModeFeatureEnabled && (codeModeEnabled ? (
|
||||
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
|
||||
)}
|
||||
{codeModeFeatureEnabled && collapseLevel < 5 && (codeModeEnabled ? (
|
||||
collapseLevel >= 1 ? (
|
||||
/* Level 1: collapse the pill to a single icon */
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCodeModeEnabled(false)}
|
||||
className="flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors hover:bg-secondary/70"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/70"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
<span>Code</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Code mode on — click to disable</TooltipContent>
|
||||
<TooltipContent side="top">Code mode on ({codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-foreground/30">·</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleCodingAgent}
|
||||
className="flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors hover:bg-secondary/70"
|
||||
>
|
||||
<span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCodeModeEnabled(false)}
|
||||
className="flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors hover:bg-secondary/70"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
<span>Code</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Code mode on — click to disable</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-foreground/30">·</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleCodingAgent}
|
||||
className="flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors hover:bg-secondary/70"
|
||||
>
|
||||
<span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -791,25 +1059,89 @@ function ChatInputInner({
|
|||
<TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
{collapseLevel >= 5 && (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="More options"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">More options</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" side="top" className="min-w-52">
|
||||
{workDir && collapseLevel >= 8 && (
|
||||
<DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}>
|
||||
<FolderCog className="size-4" />
|
||||
<span className="min-w-0 flex-1 truncate">{basename(workDir) || workDir}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{searchAvailable && collapseLevel >= 7 && (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={searchEnabled}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={(c) => setSearchEnabled(Boolean(c))}
|
||||
>
|
||||
Web search
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
{collapseLevel >= 6 && (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={permissionMode === 'auto'}
|
||||
disabled={Boolean(runId)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={(c) => setPermissionMode(c ? 'auto' : 'manual')}
|
||||
>
|
||||
Auto-approve actions
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
{codeModeFeatureEnabled && collapseLevel >= 5 && (
|
||||
<>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={codeModeEnabled}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={(c) => setCodeModeEnabled(Boolean(c))}
|
||||
>
|
||||
Code mode
|
||||
</DropdownMenuCheckboxItem>
|
||||
{codeModeEnabled && (
|
||||
<DropdownMenuItem onSelect={(e) => { e.preventDefault(); handleToggleCodingAgent() }}>
|
||||
<Terminal className="size-4" />
|
||||
<span className="min-w-0 flex-1">Coding agent</span>
|
||||
<span className="text-xs text-muted-foreground">{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{lockedModel ? (
|
||||
<span
|
||||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground"
|
||||
className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground"
|
||||
title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`}
|
||||
>
|
||||
<span className="max-w-[150px] truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
|
||||
<span className="min-w-0 truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
|
||||
</span>
|
||||
) : configuredModels.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<span className="max-w-[150px] truncate">
|
||||
<span className="min-w-0 truncate">
|
||||
{getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type
|
|||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
import { wikiLabel } from '@/lib/wiki-links'
|
||||
import type { ChatPaneSize } from '@/contexts/theme-context'
|
||||
import {
|
||||
type ChatViewportAnchorState,
|
||||
type ChatTabViewState,
|
||||
|
|
@ -125,6 +126,9 @@ interface ChatSidebarProps {
|
|||
defaultWidth?: number
|
||||
isOpen?: boolean
|
||||
isMaximized?: boolean
|
||||
placement?: 'middle' | 'right'
|
||||
paneSize?: ChatPaneSize
|
||||
className?: string
|
||||
chatTabs: ChatTab[]
|
||||
activeChatTabId: string
|
||||
getChatTabTitle: (tab: ChatTab) => string
|
||||
|
|
@ -183,6 +187,9 @@ export function ChatSidebar({
|
|||
defaultWidth = DEFAULT_WIDTH,
|
||||
isOpen = true,
|
||||
isMaximized = false,
|
||||
placement = 'right',
|
||||
paneSize = 'chat-smaller',
|
||||
className,
|
||||
chatTabs,
|
||||
activeChatTabId,
|
||||
getChatTabTitle,
|
||||
|
|
@ -246,6 +253,8 @@ export function ChatSidebar({
|
|||
const startWidthRef = useRef(0)
|
||||
const prevIsMaximizedRef = useRef(isMaximized)
|
||||
const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized
|
||||
const isMiddlePlacement = placement === 'middle'
|
||||
const isResizable = paneSize === 'chat-smaller'
|
||||
|
||||
const getMaxAllowedWidth = useCallback(() => {
|
||||
if (typeof window === 'undefined') return MAX_WIDTH
|
||||
|
|
@ -306,7 +315,9 @@ export function ChatSidebar({
|
|||
setIsResizing(true)
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const delta = startXRef.current - event.clientX
|
||||
const delta = isMiddlePlacement
|
||||
? event.clientX - startXRef.current
|
||||
: startXRef.current - event.clientX
|
||||
const maxAllowedWidth = getMaxAllowedWidth()
|
||||
setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
|
||||
}
|
||||
|
|
@ -319,7 +330,7 @@ export function ChatSidebar({
|
|||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [width, getMaxAllowedWidth])
|
||||
}, [width, getMaxAllowedWidth, isMiddlePlacement])
|
||||
|
||||
const activeTabState = useMemo<ChatTabViewState>(() => ({
|
||||
runId: runId ?? null,
|
||||
|
|
@ -501,8 +512,11 @@ export function ChatSidebar({
|
|||
// not add extra width to the right and overflow the app viewport.
|
||||
return { width: 0, flex: '1 1 auto' }
|
||||
}
|
||||
if (paneSize === 'chat-equal' || paneSize === 'chat-bigger') {
|
||||
return { width: 0, flex: '1 1 0' }
|
||||
}
|
||||
return { width, flex: '0 0 auto' }
|
||||
}, [isOpen, isMaximized, width])
|
||||
}, [isOpen, isMaximized, paneSize, width])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -511,16 +525,19 @@ export function ChatSidebar({
|
|||
onMouseDownCapture={onActivate}
|
||||
onFocusCapture={onActivate}
|
||||
className={cn(
|
||||
'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background',
|
||||
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear'
|
||||
'relative flex min-w-0 flex-col overflow-hidden bg-background',
|
||||
isMiddlePlacement ? 'border-r border-border' : 'border-l border-border',
|
||||
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear',
|
||||
className
|
||||
)}
|
||||
style={paneStyle}
|
||||
>
|
||||
{!isMaximized && (
|
||||
{!isMaximized && isResizable && (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn(
|
||||
'absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize',
|
||||
'absolute inset-y-0 z-20 w-4 cursor-col-resize',
|
||||
isMiddlePlacement ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2',
|
||||
'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors',
|
||||
'hover:after:bg-sidebar-border',
|
||||
isResizing && 'after:bg-primary'
|
||||
|
|
@ -587,7 +604,9 @@ export function ChatSidebar({
|
|||
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={isMaximized ? 'Dock chat to side pane' : 'Expand chat'}
|
||||
>
|
||||
{isMaximized ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />}
|
||||
{isMaximized
|
||||
? (isMiddlePlacement ? <ArrowLeft className="size-5" /> : <ArrowRight className="size-5" />)
|
||||
: (isMiddlePlacement ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{isMaximized ? 'Dock to side pane' : 'Expand chat'}</TooltipContent>
|
||||
|
|
|
|||
253
apps/x/apps/renderer/src/components/coding-run.tsx
Normal file
253
apps/x/apps/renderer/src/components/coding-run.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
CircleDot,
|
||||
Eye,
|
||||
FileText,
|
||||
Loader,
|
||||
Pencil,
|
||||
Search,
|
||||
ShieldQuestion,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
|
||||
import { toToolState, type ToolCall } from '@/lib/chat-conversation'
|
||||
|
||||
// ── Timeline reduction ──────────────────────────────────────────────
|
||||
// The raw ACP stream is a flat list of events; collapse it into ordered rows,
|
||||
// folding tool_call + tool_call_update (by id) and the latest plan in place.
|
||||
|
||||
type TextRow = { kind: 'text'; id: string; text: string }
|
||||
type ToolRow = { kind: 'tool'; id: string; title?: string; toolKind?: string; status?: string; diffs: string[] }
|
||||
type PlanRow = { kind: 'plan'; id: string; entries: { content: string; status?: string }[] }
|
||||
type PermRow = { kind: 'perm'; id: string; title: string; decision: string }
|
||||
type Row = TextRow | ToolRow | PlanRow | PermRow
|
||||
|
||||
function reduceEvents(events: CodeRunEvent[]): Row[] {
|
||||
const rows: Row[] = []
|
||||
const toolIdx = new Map<string, number>()
|
||||
let planIdx = -1
|
||||
|
||||
events.forEach((e, i) => {
|
||||
switch (e.type) {
|
||||
case 'message': {
|
||||
if (e.role !== 'agent' || !e.text) return
|
||||
const last = rows[rows.length - 1]
|
||||
if (last && last.kind === 'text') last.text += e.text
|
||||
else rows.push({ kind: 'text', id: `t${i}`, text: e.text })
|
||||
break
|
||||
}
|
||||
case 'tool_call': {
|
||||
const id = e.id ?? `tc${i}`
|
||||
const at = toolIdx.get(id)
|
||||
if (at != null) {
|
||||
const r = rows[at] as ToolRow
|
||||
r.title = e.title ?? r.title
|
||||
r.toolKind = e.kind ?? r.toolKind
|
||||
r.status = e.status ?? r.status
|
||||
} else {
|
||||
toolIdx.set(id, rows.length)
|
||||
rows.push({ kind: 'tool', id, title: e.title, toolKind: e.kind, status: e.status, diffs: [] })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool_call_update': {
|
||||
const id = e.id ?? `tu${i}`
|
||||
let at = toolIdx.get(id)
|
||||
if (at == null) {
|
||||
at = rows.length
|
||||
toolIdx.set(id, at)
|
||||
rows.push({ kind: 'tool', id, diffs: [] })
|
||||
}
|
||||
const r = rows[at] as ToolRow
|
||||
if (e.status) r.status = e.status
|
||||
for (const d of e.diffs) if (!r.diffs.includes(d)) r.diffs.push(d)
|
||||
break
|
||||
}
|
||||
case 'plan': {
|
||||
if (planIdx >= 0) (rows[planIdx] as PlanRow).entries = e.entries
|
||||
else {
|
||||
planIdx = rows.length
|
||||
rows.push({ kind: 'plan', id: 'plan', entries: e.entries })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'permission':
|
||||
rows.push({ kind: 'perm', id: `p${i}`, title: e.ask.title, decision: e.decision })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
function toolKindIcon(kind?: string) {
|
||||
switch (kind) {
|
||||
case 'read': return <Eye className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
case 'edit': return <Pencil className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
case 'delete': return <Trash2 className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
case 'search': return <Search className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
case 'execute': return <Terminal className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
case 'fetch': return <FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
default: return <Wrench className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
}
|
||||
|
||||
function planMarker(status?: string) {
|
||||
if (status === 'completed') return <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />
|
||||
if (status === 'in_progress') return <CircleDot className="size-3.5 shrink-0 text-blue-500" />
|
||||
return <Circle className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
|
||||
const basename = (p: string) => p.split(/[\\/]/).pop() || p
|
||||
|
||||
function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
|
||||
const rows = useMemo(() => reduceEvents(events), [events])
|
||||
if (rows.length === 0) {
|
||||
return <div className="px-4 py-3 text-xs text-muted-foreground">Starting the agent…</div>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-4 py-3">
|
||||
{rows.map((row) => {
|
||||
if (row.kind === 'text') {
|
||||
return (
|
||||
<p key={row.id} className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
|
||||
{row.text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'tool') {
|
||||
const running = row.status !== 'completed' && row.status !== 'failed'
|
||||
return (
|
||||
<div key={row.id} className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{running
|
||||
? <Loader className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
: <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />}
|
||||
{toolKindIcon(row.toolKind)}
|
||||
<span className="truncate text-foreground/90">{row.title ?? row.toolKind ?? 'Tool call'}</span>
|
||||
</div>
|
||||
{row.diffs.length > 0 && (
|
||||
<div className="ml-7 flex flex-col gap-0.5">
|
||||
{row.diffs.map((d) => (
|
||||
<span key={d} className="truncate font-mono text-xs text-muted-foreground" title={d}>
|
||||
{basename(d)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'plan') {
|
||||
return (
|
||||
<div key={row.id} className="flex flex-col gap-1 rounded-lg border bg-muted/30 p-2">
|
||||
{row.entries.map((entry, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm text-foreground/90">
|
||||
{planMarker(entry.status)}
|
||||
<span className={cn('truncate', entry.status === 'completed' && 'text-muted-foreground line-through')}>
|
||||
{entry.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// resolved permission
|
||||
const denied = row.decision === 'reject' || row.decision === 'cancelled'
|
||||
return (
|
||||
<div key={row.id} className={cn('flex items-center gap-2 text-xs', denied ? 'text-red-600' : 'text-green-600')}>
|
||||
{denied ? '✕' : '✓'}
|
||||
<span className="truncate">{denied ? 'Denied' : 'Allowed'}: {row.title}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── In-run permission card ──────────────────────────────────────────
|
||||
|
||||
export function CodeRunPermissionRequest({
|
||||
ask,
|
||||
onDecide,
|
||||
}: {
|
||||
ask: PermissionAsk
|
||||
onDecide: (decision: PermissionDecision) => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const decide = (d: PermissionDecision) => {
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
onDecide(d)
|
||||
}
|
||||
const btn = 'rounded-full px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50'
|
||||
return (
|
||||
<div className="mb-4 rounded-[20px] border border-amber-500/40 bg-amber-500/5 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<ShieldQuestion className="size-4 shrink-0 text-amber-600" />
|
||||
Permission needed
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The agent wants to: <span className="font-medium text-foreground">{ask.title}</span>
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button type="button" disabled={busy} onClick={() => decide('allow_once')}
|
||||
className={cn(btn, 'bg-foreground text-background hover:bg-foreground/90')}>
|
||||
Allow
|
||||
</button>
|
||||
<button type="button" disabled={busy} onClick={() => decide('allow_always')}
|
||||
className={cn(btn, 'border hover:bg-muted')}>
|
||||
Always allow{ask.kind ? ` (${ask.kind})` : ''}
|
||||
</button>
|
||||
<button type="button" disabled={busy} onClick={() => decide('reject')}
|
||||
className={cn(btn, 'border border-red-500/40 text-red-600 hover:bg-red-500/10')}>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Block wrapper (rendered in the chat for a code_agent_run tool call) ──
|
||||
|
||||
const AGENT_LABEL: Record<string, string> = { claude: 'Claude Code', codex: 'Codex' }
|
||||
|
||||
export function CodingRunBlock({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
onPermissionDecision,
|
||||
}: {
|
||||
item: ToolCall
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onPermissionDecision: (decision: PermissionDecision) => void
|
||||
}) {
|
||||
// Prefer the agent the backend actually ran (the chip) once the run returns; fall
|
||||
// back to the requested input agent while it's still in flight. Never trust only the
|
||||
// model's input — it can pass a stale agent the backend overrode with the chip.
|
||||
const agent =
|
||||
(item.result as { agent?: string } | undefined)?.agent ??
|
||||
(item.input as { agent?: string } | undefined)?.agent
|
||||
const title = AGENT_LABEL[agent ?? ''] ?? 'Coding agent'
|
||||
return (
|
||||
<>
|
||||
<Tool open={open} onOpenChange={onOpenChange}>
|
||||
<ToolHeader title={title} type="tool-code_agent_run" state={toToolState(item.status)} />
|
||||
<ToolContent>
|
||||
<CodingRunTimeline events={item.codeRunEvents ?? []} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
{item.pendingCodePermission && (
|
||||
<CodeRunPermissionRequest ask={item.pendingCodePermission.ask} onDecide={onPermissionDecision} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -612,6 +612,43 @@ function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: ()
|
|||
)
|
||||
}
|
||||
|
||||
type ContactSuggestion = {
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
function formatContactToken(c: ContactSuggestion): string {
|
||||
return c.name ? `${c.name} <${c.email}>` : c.email
|
||||
}
|
||||
|
||||
// Stable hue per email so the avatar circle keeps a consistent color.
|
||||
function contactHue(email: string): number {
|
||||
let h = 0
|
||||
for (let i = 0; i < email.length; i++) h = (h * 31 + email.charCodeAt(i)) >>> 0
|
||||
return h % 360
|
||||
}
|
||||
|
||||
function contactInitial(c: ContactSuggestion): string {
|
||||
const src = (c.name || c.email).trim()
|
||||
return (src[0] || '?').toUpperCase()
|
||||
}
|
||||
|
||||
// Renders a string with the matched substring wrapped in <mark>.
|
||||
function HighlightedText({ text, query }: { text: string; query: string }) {
|
||||
if (!query) return <>{text}</>
|
||||
const lower = text.toLowerCase()
|
||||
const q = query.toLowerCase()
|
||||
const idx = lower.indexOf(q)
|
||||
if (idx < 0) return <>{text}</>
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="gmail-recipient-suggestion-match">{text.slice(idx, idx + q.length)}</mark>
|
||||
{text.slice(idx + q.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RecipientField({
|
||||
label,
|
||||
value,
|
||||
|
|
@ -626,34 +663,123 @@ function RecipientField({
|
|||
trailing?: React.ReactNode
|
||||
}) {
|
||||
const [draft, setDraft] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<ContactSuggestion[]>([])
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [queryShown, setQueryShown] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const fieldRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
const queryTokenRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) inputRef.current?.focus()
|
||||
}, [autoFocus])
|
||||
|
||||
const excludeEmails = useMemo(
|
||||
() => value.map((token) => extractAddress(token).toLowerCase()).filter(Boolean),
|
||||
[value],
|
||||
)
|
||||
|
||||
// Debounced contact search — only runs when the user has actually typed
|
||||
// something. An empty draft (including the post-pick reset) closes the menu.
|
||||
useEffect(() => {
|
||||
const trimmed = draft.trim()
|
||||
if (!isFocused || !trimmed) {
|
||||
queryTokenRef.current++
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
const token = ++queryTokenRef.current
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const result = (await window.ipc.invoke('gmail:searchContacts', {
|
||||
query: draft,
|
||||
limit: 8,
|
||||
excludeEmails,
|
||||
})) as { contacts?: ContactSuggestion[] } | undefined
|
||||
if (token !== queryTokenRef.current) return
|
||||
setSuggestions(result?.contacts ?? [])
|
||||
setQueryShown(trimmed)
|
||||
setActiveIndex(0)
|
||||
} catch {
|
||||
if (token !== queryTokenRef.current) return
|
||||
setSuggestions([])
|
||||
}
|
||||
}, 60)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [draft, isFocused, excludeEmails])
|
||||
|
||||
// Keep the active row scrolled into view during keyboard navigation.
|
||||
useEffect(() => {
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
const node = list.children[activeIndex] as HTMLElement | undefined
|
||||
node?.scrollIntoView({ block: 'nearest' })
|
||||
}, [activeIndex, suggestions])
|
||||
|
||||
const commit = (raw: string) => {
|
||||
const additions = splitAddresses(raw)
|
||||
if (additions.length === 0) return
|
||||
onChange(dedupeRecipients([...value, ...additions], new Set()))
|
||||
setDraft('')
|
||||
setSuggestions([])
|
||||
}
|
||||
|
||||
const pickSuggestion = (c: ContactSuggestion) => {
|
||||
commit(formatContactToken(c))
|
||||
// Keep focus in the input so the user can keep typing more recipients.
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ',' || event.key === ';' || (event.key === 'Tab' && draft.trim())) {
|
||||
const hasSuggestions = suggestions.length > 0
|
||||
if (event.key === 'ArrowDown' && hasSuggestions) {
|
||||
event.preventDefault()
|
||||
setActiveIndex((i) => (i + 1) % suggestions.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp' && hasSuggestions) {
|
||||
event.preventDefault()
|
||||
setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape' && hasSuggestions) {
|
||||
event.preventDefault()
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' || (event.key === 'Tab' && hasSuggestions)) {
|
||||
// Prefer the highlighted suggestion when one is present.
|
||||
if (hasSuggestions) {
|
||||
event.preventDefault()
|
||||
pickSuggestion(suggestions[activeIndex])
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' && draft.trim()) {
|
||||
event.preventDefault()
|
||||
commit(draft)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (event.key === ',' || event.key === ';') {
|
||||
if (draft.trim()) {
|
||||
event.preventDefault()
|
||||
commit(draft)
|
||||
}
|
||||
} else if (event.key === 'Backspace' && !draft && value.length > 0) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Backspace' && !draft && value.length > 0) {
|
||||
onChange(value.slice(0, -1))
|
||||
}
|
||||
}
|
||||
|
||||
const showSuggestions = isFocused && suggestions.length > 0
|
||||
|
||||
return (
|
||||
<div className="gmail-recipient-row">
|
||||
<span className="gmail-recipient-label">{label}</span>
|
||||
<div className="gmail-recipient-field">
|
||||
<div className="gmail-recipient-field" ref={fieldRef}>
|
||||
{value.map((token, index) => (
|
||||
<span key={`${token}-${index}`} className="gmail-recipient-chip" title={extractAddress(token)}>
|
||||
<span className="gmail-recipient-chip-label">{recipientLabel(token)}</span>
|
||||
|
|
@ -674,7 +800,16 @@ function RecipientField({
|
|||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => { if (draft.trim()) commit(draft) }}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => {
|
||||
// Defer so a mousedown on a suggestion can pick it before the menu closes.
|
||||
window.setTimeout(() => {
|
||||
setIsFocused(false)
|
||||
if (inputRef.current && draft.trim() && document.activeElement !== inputRef.current) {
|
||||
commit(draft)
|
||||
}
|
||||
}, 80)
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
const text = event.clipboardData.getData('text')
|
||||
if (text && /[,;\n]/.test(text)) {
|
||||
|
|
@ -683,6 +818,45 @@ function RecipientField({
|
|||
}
|
||||
}}
|
||||
/>
|
||||
{showSuggestions && (
|
||||
<ul className="gmail-recipient-suggestions" role="listbox" ref={listRef}>
|
||||
{suggestions.map((c, idx) => {
|
||||
const hue = contactHue(c.email)
|
||||
return (
|
||||
<li
|
||||
key={c.email}
|
||||
role="option"
|
||||
aria-selected={idx === activeIndex}
|
||||
className={cn('gmail-recipient-suggestion', idx === activeIndex && 'is-active')}
|
||||
onMouseDown={(event) => {
|
||||
// Prevent input blur before click fires.
|
||||
event.preventDefault()
|
||||
pickSuggestion(c)
|
||||
}}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
>
|
||||
<span
|
||||
className="gmail-recipient-suggestion-avatar"
|
||||
style={{ background: `hsl(${hue}, 60%, 42%)` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{contactInitial(c)}
|
||||
</span>
|
||||
<span className="gmail-recipient-suggestion-text">
|
||||
<span className="gmail-recipient-suggestion-name">
|
||||
<HighlightedText text={c.name || c.email} query={queryShown} />
|
||||
</span>
|
||||
{c.name && (
|
||||
<span className="gmail-recipient-suggestion-email">
|
||||
<HighlightedText text={c.email} query={queryShown} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{trailing && <div className="gmail-recipient-trailing">{trailing}</div>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -110,7 +110,12 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
|
|||
<iframe
|
||||
key={path}
|
||||
src={iframeSrc}
|
||||
sandbox="allow-scripts"
|
||||
// `allow-popups` lets `target="_blank"` links reach the main process
|
||||
// window-open handler, which routes them to the system browser. Plain
|
||||
// links (same-frame navigations) are handled there via
|
||||
// `will-frame-navigate`. No `allow-same-origin` — the doc stays
|
||||
// origin-isolated.
|
||||
sandbox="allow-scripts allow-popups"
|
||||
className="h-full w-full border-0 bg-white"
|
||||
title="HTML preview"
|
||||
onLoad={() => setIframeLoaded(true)}
|
||||
|
|
|
|||
|
|
@ -1058,7 +1058,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(note.path)}
|
||||
className="min-w-0 text-left text-sm font-medium text-foreground hover:underline"
|
||||
className="block w-full min-w-0 text-left text-sm font-medium text-foreground hover:underline"
|
||||
>
|
||||
<span className="block truncate">{note.name}</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw } from "lucide-react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -25,6 +25,7 @@ import { useTheme } from "@/contexts/theme-context"
|
|||
import { toast } from "sonner"
|
||||
import { AccountSettings } from "@/components/settings/account-settings"
|
||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
||||
|
||||
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help"
|
||||
|
||||
|
|
@ -210,7 +211,7 @@ function ThemeOption({
|
|||
}
|
||||
|
||||
function AppearanceSettings() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -240,6 +241,50 @@ function AppearanceSettings() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Chat</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Choose where chat sits when another pane is open
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ThemeOption
|
||||
label="Chat right"
|
||||
icon={PanelRight}
|
||||
isSelected={chatPanePlacement === "right"}
|
||||
onClick={() => setChatPanePlacement("right")}
|
||||
/>
|
||||
<ThemeOption
|
||||
label="Chat middle"
|
||||
icon={MessageCircle}
|
||||
isSelected={chatPanePlacement === "middle"}
|
||||
onClick={() => setChatPanePlacement("middle")}
|
||||
/>
|
||||
</div>
|
||||
<h4 className="mt-6 text-sm font-medium mb-3">Chat size</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Choose how much width chat gets when another pane is open
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ThemeOption
|
||||
label="Chat smaller"
|
||||
icon={MessageCircle}
|
||||
isSelected={chatPaneSize === "chat-smaller"}
|
||||
onClick={() => setChatPaneSize("chat-smaller")}
|
||||
/>
|
||||
<ThemeOption
|
||||
label="Chat equal"
|
||||
icon={Monitor}
|
||||
isSelected={chatPaneSize === "chat-equal"}
|
||||
onClick={() => setChatPaneSize("chat-equal")}
|
||||
/>
|
||||
<ThemeOption
|
||||
label="Chat bigger"
|
||||
icon={PanelRight}
|
||||
isSelected={chatPaneSize === "chat-bigger"}
|
||||
onClick={() => setChatPaneSize("chat-bigger")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1761,6 +1806,7 @@ function AgentStatusRow({
|
|||
|
||||
function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [approvalPolicy, setApprovalPolicy] = useState<ApprovalPolicy>('ask')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [status, setStatus] = useState<CodeModeAgentStatus | null>(null)
|
||||
|
|
@ -1785,7 +1831,10 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
setLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("codeMode:getConfig", null)
|
||||
if (!cancelled) setEnabled(result.enabled)
|
||||
if (!cancelled) {
|
||||
setEnabled(result.enabled)
|
||||
setApprovalPolicy(result.approvalPolicy ?? 'ask')
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setEnabled(false)
|
||||
} finally {
|
||||
|
|
@ -1801,7 +1850,7 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
setSaving(true)
|
||||
setEnabled(next)
|
||||
try {
|
||||
await window.ipc.invoke("codeMode:setConfig", { enabled: next })
|
||||
await window.ipc.invoke("codeMode:setConfig", { enabled: next, approvalPolicy })
|
||||
window.dispatchEvent(new Event("code-mode-config-changed"))
|
||||
toast.success(next ? "Code mode enabled" : "Code mode disabled")
|
||||
} catch {
|
||||
|
|
@ -1810,7 +1859,22 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [])
|
||||
}, [approvalPolicy])
|
||||
|
||||
const handlePolicyChange = useCallback(async (next: ApprovalPolicy) => {
|
||||
const prev = approvalPolicy
|
||||
setSaving(true)
|
||||
setApprovalPolicy(next)
|
||||
try {
|
||||
await window.ipc.invoke("codeMode:setConfig", { enabled, approvalPolicy: next })
|
||||
window.dispatchEvent(new Event("code-mode-config-changed"))
|
||||
} catch {
|
||||
setApprovalPolicy(prev)
|
||||
toast.error("Failed to update approval policy")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [enabled, approvalPolicy])
|
||||
|
||||
const anyReady = status?.claude.installed && status?.claude.signedIn
|
||||
|| status?.codex.installed && status?.codex.signedIn
|
||||
|
|
@ -1830,9 +1894,8 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
<p>
|
||||
<strong className="text-foreground">Code mode</strong> lets the assistant delegate coding tasks
|
||||
to <strong className="text-foreground">Claude Code</strong> or <strong className="text-foreground">Codex</strong> running
|
||||
on your machine. Pick the agent inline from the composer; the assistant calls it via
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">acpx</code>
|
||||
and streams results back into chat.
|
||||
on your machine. Pick the agent inline from the composer; the assistant runs it on-device
|
||||
and streams its work — tool calls, file diffs, and approvals — back into chat.
|
||||
</p>
|
||||
<p>
|
||||
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
|
||||
|
|
@ -1882,6 +1945,35 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="rounded-md border px-3 py-3 space-y-2">
|
||||
<div className="text-sm font-medium">Approvals</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
How the coding agent checks in before changing files or running commands. You always see
|
||||
everything it does in the timeline — this only controls the prompts.
|
||||
</div>
|
||||
<Select
|
||||
value={approvalPolicy}
|
||||
onValueChange={(v) => handlePolicyChange(v as ApprovalPolicy)}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ask">Ask every time</SelectItem>
|
||||
<SelectItem value="auto-approve-reads">Auto-approve reads</SelectItem>
|
||||
<SelectItem value="yolo">Auto-approve everything (YOLO)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{approvalPolicy === 'ask' && 'You approve every file change and command the agent wants to run.'}
|
||||
{approvalPolicy === 'auto-approve-reads' && 'Reading and searching run automatically; you still approve writes, edits, and commands.'}
|
||||
{approvalPolicy === 'yolo' && 'The agent runs everything — writes, edits, and commands — without asking. Use only in folders you trust.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && status && !anyReady && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
|
||||
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||
|
|
|
|||
|
|
@ -3,16 +3,32 @@
|
|||
import * as React from "react"
|
||||
|
||||
export type Theme = "light" | "dark" | "system"
|
||||
export type ChatPanePlacement = "right" | "middle"
|
||||
export type ChatPaneSize = "chat-smaller" | "chat-equal" | "chat-bigger"
|
||||
|
||||
type ThemeContextProps = {
|
||||
theme: Theme
|
||||
resolvedTheme: "light" | "dark"
|
||||
setTheme: (theme: Theme) => void
|
||||
chatPanePlacement: ChatPanePlacement
|
||||
setChatPanePlacement: (placement: ChatPanePlacement) => void
|
||||
chatPaneSize: ChatPaneSize
|
||||
setChatPaneSize: (size: ChatPaneSize) => void
|
||||
}
|
||||
|
||||
const ThemeContext = React.createContext<ThemeContextProps | null>(null)
|
||||
|
||||
const STORAGE_KEY = "rowboat-theme"
|
||||
const CHAT_PANE_PLACEMENT_STORAGE_KEY = "rowboat-chat-pane-placement"
|
||||
const CHAT_PANE_SIZE_STORAGE_KEY = "rowboat-chat-pane-size"
|
||||
|
||||
function isChatPanePlacement(value: string | null): value is ChatPanePlacement {
|
||||
return value === "right" || value === "middle"
|
||||
}
|
||||
|
||||
function isChatPaneSize(value: string | null): value is ChatPaneSize {
|
||||
return value === "chat-smaller" || value === "chat-equal" || value === "chat-bigger"
|
||||
}
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "light"
|
||||
|
|
@ -39,6 +55,16 @@ export function ThemeProvider({
|
|||
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
|
||||
return stored || defaultTheme
|
||||
})
|
||||
const [chatPanePlacement, setChatPanePlacementState] = React.useState<ChatPanePlacement>(() => {
|
||||
if (typeof window === "undefined") return "right"
|
||||
const stored = localStorage.getItem(CHAT_PANE_PLACEMENT_STORAGE_KEY)
|
||||
return isChatPanePlacement(stored) ? stored : "right"
|
||||
})
|
||||
const [chatPaneSize, setChatPaneSizeState] = React.useState<ChatPaneSize>(() => {
|
||||
if (typeof window === "undefined") return "chat-smaller"
|
||||
const stored = localStorage.getItem(CHAT_PANE_SIZE_STORAGE_KEY)
|
||||
return isChatPaneSize(stored) ? stored : "chat-smaller"
|
||||
})
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
|
||||
if (theme === "system") return getSystemTheme()
|
||||
|
|
@ -76,13 +102,27 @@ export function ThemeProvider({
|
|||
setThemeState(newTheme)
|
||||
}, [])
|
||||
|
||||
const setChatPanePlacement = React.useCallback((placement: ChatPanePlacement) => {
|
||||
localStorage.setItem(CHAT_PANE_PLACEMENT_STORAGE_KEY, placement)
|
||||
setChatPanePlacementState(placement)
|
||||
}, [])
|
||||
|
||||
const setChatPaneSize = React.useCallback((size: ChatPaneSize) => {
|
||||
localStorage.setItem(CHAT_PANE_SIZE_STORAGE_KEY, size)
|
||||
setChatPaneSizeState(size)
|
||||
}, [])
|
||||
|
||||
const contextValue = React.useMemo<ThemeContextProps>(
|
||||
() => ({
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
chatPanePlacement,
|
||||
setChatPanePlacement,
|
||||
chatPaneSize,
|
||||
setChatPaneSize,
|
||||
}),
|
||||
[theme, resolvedTheme, setTheme]
|
||||
[theme, resolvedTheme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||
|
||||
|
|
@ -21,8 +22,37 @@ const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.
|
|||
// RMS threshold: system audio above this = "active" (speakers playing)
|
||||
const SYSTEM_AUDIO_GATE_THRESHOLD = 0.005;
|
||||
|
||||
// Auto-stop after 2 minutes of silence (no transcript from Deepgram)
|
||||
const SILENCE_AUTO_STOP_MS = 2 * 60 * 1000;
|
||||
// RMS threshold for "someone is talking" on either channel. Drives silence
|
||||
// detection — kept a touch above the gate threshold so faint room noise on the
|
||||
// mic doesn't read as speech and keep a finished recording alive.
|
||||
const SPEECH_RMS_THRESHOLD = 0.01;
|
||||
|
||||
// Silence handling. "Silence" = no audio above SPEECH_RMS_THRESHOLD on EITHER
|
||||
// the mic or the system-audio channel (i.e. nobody — local or remote — talking).
|
||||
// - After SILENCE_NUDGE_MS we ask the user (toast) whether to stop.
|
||||
// - After SILENCE_BACKSTOP_MS we stop unconditionally.
|
||||
// - Once past the linked calendar event's end time we use the shorter
|
||||
// POST_CALENDAR_END_SILENCE_MS, since a lull after the scheduled end is a
|
||||
// strong signal the meeting is actually over.
|
||||
const SILENCE_NUDGE_MS = 2 * 60 * 1000;
|
||||
const SILENCE_BACKSTOP_MS = 5 * 60 * 1000;
|
||||
const POST_CALENDAR_END_SILENCE_MS = 2 * 60 * 1000;
|
||||
// How often the silence checker runs.
|
||||
const SILENCE_CHECK_INTERVAL_MS = 5 * 1000;
|
||||
|
||||
// On macOS (ScreenCaptureKit) the system-audio track never fires "ended"/"mute"
|
||||
// when the meeting ends, and its readyState stays "live" — only track.muted flips
|
||||
// to true. But muted is ambiguous: it also goes true whenever no system audio is
|
||||
// playing (a quiet but live meeting), so muted alone can't safely trigger a stop.
|
||||
// See the poll in start() for how the muted signal is gated on the scheduled
|
||||
// calendar end so a quiet stretch never cuts a live meeting short.
|
||||
const TRACK_POLL_INTERVAL_MS = 3 * 1000;
|
||||
const MUTE_POLLS_TO_STOP = 3;
|
||||
|
||||
// The ScreenCaptureKit quirk above is macOS-only; on Windows the track's "ended"
|
||||
// event fires normally (handled by the listener in start()), so the poll below is
|
||||
// gated to macOS.
|
||||
const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Headphone detection
|
||||
|
|
@ -119,7 +149,17 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const interimRef = useRef<Map<number, { speaker: string; text: string }>>(new Map());
|
||||
const notePathRef = useRef<string>('');
|
||||
const writeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Silence detection: timestamp of the last speech-level audio on either
|
||||
// channel, plus the interval that checks it. calendarEndMsRef holds the
|
||||
// linked event's end time (null if none).
|
||||
const lastAudioActivityRef = useRef<number>(0);
|
||||
const silenceCheckRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const calendarEndMsRef = useRef<number | null>(null);
|
||||
const nudgeToastIdRef = useRef<string | number | null>(null);
|
||||
// On macOS (ScreenCaptureKit) the system-audio track doesn't reliably fire
|
||||
// "ended"/"mute" when the meeting ends, so we poll its readyState/muted
|
||||
// state instead.
|
||||
const trackPollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const onAutoStopRef = useRef(onAutoStop);
|
||||
onAutoStopRef.current = onAutoStop;
|
||||
const dateRef = useRef<string>('');
|
||||
|
|
@ -161,9 +201,17 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
clearTimeout(writeTimerRef.current);
|
||||
writeTimerRef.current = null;
|
||||
}
|
||||
if (silenceTimerRef.current) {
|
||||
clearTimeout(silenceTimerRef.current);
|
||||
silenceTimerRef.current = null;
|
||||
if (silenceCheckRef.current) {
|
||||
clearInterval(silenceCheckRef.current);
|
||||
silenceCheckRef.current = null;
|
||||
}
|
||||
if (nudgeToastIdRef.current !== null) {
|
||||
toast.dismiss(nudgeToastIdRef.current);
|
||||
nudgeToastIdRef.current = null;
|
||||
}
|
||||
if (trackPollingRef.current) {
|
||||
clearInterval(trackPollingRef.current);
|
||||
trackPollingRef.current = null;
|
||||
}
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
|
|
@ -279,13 +327,6 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const transcript = data.channel.alternatives[0].transcript;
|
||||
if (!transcript) return;
|
||||
|
||||
// Reset silence auto-stop timer on any transcript
|
||||
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
|
||||
silenceTimerRef.current = setTimeout(() => {
|
||||
console.log('[meeting] 2 minutes of silence — auto-stopping');
|
||||
onAutoStopRef.current?.();
|
||||
}, SILENCE_AUTO_STOP_MS);
|
||||
|
||||
const channelIndex = data.channel_index?.[0] ?? 0;
|
||||
const isMic = channelIndex === 0;
|
||||
|
||||
|
|
@ -325,6 +366,56 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const systemStream = systemResult.value;
|
||||
systemStreamRef.current = systemStream;
|
||||
|
||||
// If the shared source goes away (user closes the call window / clicks
|
||||
// "Stop sharing"), the track fires "ended" — treat that as the meeting
|
||||
// ending and stop. Our own cleanup() calls track.stop(), which does NOT
|
||||
// fire "ended", so this won't double-trigger on a manual stop.
|
||||
systemStream.getAudioTracks().forEach(track => {
|
||||
track.addEventListener('ended', () => {
|
||||
console.log('[meeting] system-audio track ended (shared source closed) — auto-stopping');
|
||||
onAutoStopRef.current?.();
|
||||
});
|
||||
});
|
||||
|
||||
// On macOS the system-audio track's "ended"/"mute" events don't fire when
|
||||
// the meeting ends, so poll its state instead. (On Windows the "ended"
|
||||
// listener above already covers this, so the poll is macOS-only.)
|
||||
//
|
||||
// - readyState === 'ended' is unambiguous (the source is gone) → stop now.
|
||||
// It never actually fires on macOS (readyState stays 'live'); it's just
|
||||
// a safety net should polling ever observe the track ending.
|
||||
// - muted is ambiguous on macOS: it flips true both when the meeting ends
|
||||
// AND when nothing is playing system audio (a quiet but live meeting).
|
||||
// So we only treat sustained mute as "meeting over" once we're past the
|
||||
// linked event's scheduled end — a dead audio track after the meeting
|
||||
// was due to finish is a strong signal. With no calendar event, or
|
||||
// before the scheduled end, we DON'T hard-stop on mute; the silence
|
||||
// checker's nudge + backstop handles it, so a quiet stretch can never
|
||||
// silently cut a live meeting short.
|
||||
const pollTrack = systemStream.getAudioTracks()[0];
|
||||
if (isMac && pollTrack) {
|
||||
let mutedPolls = 0;
|
||||
if (trackPollingRef.current) clearInterval(trackPollingRef.current);
|
||||
trackPollingRef.current = setInterval(() => {
|
||||
if (pollTrack.readyState === 'ended') {
|
||||
console.log('[meeting] system-audio track ended (poll) — auto-stopping');
|
||||
onAutoStopRef.current?.();
|
||||
return;
|
||||
}
|
||||
if (pollTrack.muted) {
|
||||
mutedPolls++;
|
||||
const endMs = calendarEndMsRef.current;
|
||||
const pastCalendarEnd = endMs != null && Date.now() > endMs;
|
||||
if (pastCalendarEnd && mutedPolls >= MUTE_POLLS_TO_STOP) {
|
||||
console.log('[meeting] system-audio track muted past scheduled end (poll) — auto-stopping');
|
||||
onAutoStopRef.current?.();
|
||||
}
|
||||
} else {
|
||||
mutedPolls = 0;
|
||||
}
|
||||
}, TRACK_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ----- Audio pipeline -----
|
||||
const audioCtx = new AudioContext({ sampleRate: 16000 });
|
||||
audioCtxRef.current = audioCtx;
|
||||
|
|
@ -345,24 +436,33 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const micRaw = e.inputBuffer.getChannelData(0);
|
||||
const sysRaw = e.inputBuffer.getChannelData(1);
|
||||
|
||||
// RMS of each channel, computed once per frame and reused for
|
||||
// silence detection and gating the mic in speaker mode.
|
||||
let micSum = 0;
|
||||
for (let i = 0; i < micRaw.length; i++) micSum += micRaw[i] * micRaw[i];
|
||||
const micRms = Math.sqrt(micSum / micRaw.length);
|
||||
let sysSum = 0;
|
||||
for (let i = 0; i < sysRaw.length; i++) sysSum += sysRaw[i] * sysRaw[i];
|
||||
const sysRms = Math.sqrt(sysSum / sysRaw.length);
|
||||
|
||||
// Reset the silence clock whenever EITHER channel has speech-level
|
||||
// audio. Uses the raw mic (pre-gating) so the user's own voice counts
|
||||
// even in speaker mode where the outgoing mic gets muted.
|
||||
if (micRms > SPEECH_RMS_THRESHOLD || sysRms > SPEECH_RMS_THRESHOLD) {
|
||||
lastAudioActivityRef.current = Date.now();
|
||||
}
|
||||
|
||||
// Mode 1 (headphones): pass both streams through unmodified
|
||||
// Mode 2 (speakers): gate/mute mic when system audio is active
|
||||
let micOut: Float32Array;
|
||||
if (usingHeadphones) {
|
||||
micOut = micRaw;
|
||||
} else if (sysRms > SYSTEM_AUDIO_GATE_THRESHOLD) {
|
||||
// System audio is playing — mute mic to prevent bleed
|
||||
micOut = new Float32Array(micRaw.length); // all zeros
|
||||
} else {
|
||||
// Compute system audio RMS to detect activity
|
||||
let sysSum = 0;
|
||||
for (let i = 0; i < sysRaw.length; i++) sysSum += sysRaw[i] * sysRaw[i];
|
||||
const sysRms = Math.sqrt(sysSum / sysRaw.length);
|
||||
|
||||
if (sysRms > SYSTEM_AUDIO_GATE_THRESHOLD) {
|
||||
// System audio is playing — mute mic to prevent bleed
|
||||
micOut = new Float32Array(micRaw.length); // all zeros
|
||||
} else {
|
||||
// System audio is silent — pass mic through
|
||||
micOut = micRaw;
|
||||
}
|
||||
// System audio is silent — pass mic through
|
||||
micOut = micRaw;
|
||||
}
|
||||
|
||||
// Interleave mic (ch0) + system audio (ch1) into stereo int16 PCM
|
||||
|
|
@ -391,6 +491,12 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}.md`;
|
||||
notePathRef.current = notePath;
|
||||
calendarEventRef.current = calendarEvent;
|
||||
|
||||
// Parse the linked event's end time (timed events only) so the silence
|
||||
// window can shorten once the meeting is past its scheduled end.
|
||||
const calEndMs = calendarEvent?.end?.dateTime ? Date.parse(calendarEvent.end.dateTime) : NaN;
|
||||
calendarEndMsRef.current = Number.isFinite(calEndMs) ? calEndMs : null;
|
||||
|
||||
const initialContent = formatTranscript([], dateStr, calendarEvent);
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePath,
|
||||
|
|
@ -398,6 +504,45 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
opts: { encoding: 'utf8', mkdirp: true },
|
||||
});
|
||||
|
||||
// Arm silence detection. Initialise the activity clock to "now" so the
|
||||
// checker is live from the very start of recording — a session that
|
||||
// never captures any audio still auto-stops at the backstop instead of
|
||||
// running forever.
|
||||
lastAudioActivityRef.current = Date.now();
|
||||
if (silenceCheckRef.current) clearInterval(silenceCheckRef.current);
|
||||
silenceCheckRef.current = setInterval(() => {
|
||||
const silentMs = Date.now() - lastAudioActivityRef.current;
|
||||
const endMs = calendarEndMsRef.current;
|
||||
const pastCalendarEnd = endMs != null && Date.now() > endMs;
|
||||
const hardStopMs = pastCalendarEnd ? POST_CALENDAR_END_SILENCE_MS : SILENCE_BACKSTOP_MS;
|
||||
|
||||
if (silentMs >= hardStopMs) {
|
||||
console.log(`[meeting] ${Math.round(silentMs / 1000)}s of silence${pastCalendarEnd ? ' (past scheduled end)' : ''} — auto-stopping`);
|
||||
onAutoStopRef.current?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (silentMs >= SILENCE_NUDGE_MS) {
|
||||
// Ask once; the toast persists until dismissed or acted on. Past
|
||||
// the scheduled end we skip straight to the hard stop above, so
|
||||
// the nudge only ever shows for an in-progress meeting.
|
||||
if (nudgeToastIdRef.current === null) {
|
||||
nudgeToastIdRef.current = toast('Still in a meeting?', {
|
||||
description: "It's been quiet for a couple of minutes.",
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: 'Stop recording',
|
||||
onClick: () => { onAutoStopRef.current?.(); },
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (nudgeToastIdRef.current !== null) {
|
||||
// Audio resumed before the backstop — retract the nudge.
|
||||
toast.dismiss(nudgeToastIdRef.current);
|
||||
nudgeToastIdRef.current = null;
|
||||
}
|
||||
}, SILENCE_CHECK_INTERVAL_MS);
|
||||
|
||||
setState('recording');
|
||||
return notePath;
|
||||
}, [state, cleanup, scheduleDebouncedWrite, refreshRowboatAccount]);
|
||||
|
|
|
|||
|
|
@ -151,6 +151,20 @@ export function useVoiceMode() {
|
|||
analytics.voiceInputStarted();
|
||||
posthog.people.set_once({ has_used_voice: true });
|
||||
|
||||
// Settle the OS-level microphone permission before capturing. On the
|
||||
// first-ever use (macOS) the permission is 'not-determined'; calling
|
||||
// getUserMedia directly would reject while the native prompt is up,
|
||||
// making the first mic click silently do nothing. Resolving it here
|
||||
// lets this same click proceed once the user grants access.
|
||||
const mic = await window.ipc
|
||||
.invoke('voice:ensureMicAccess', null)
|
||||
.catch(() => ({ granted: true }));
|
||||
if (!mic.granted) {
|
||||
console.error('Microphone access denied');
|
||||
stopAudioCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
// Kick off mic + WebSocket in parallel, don't await WebSocket
|
||||
const [stream] = await Promise.all([
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).catch((err) => {
|
||||
|
|
@ -161,7 +175,10 @@ export function useVoiceMode() {
|
|||
]);
|
||||
|
||||
if (!stream) {
|
||||
setState('idle');
|
||||
// connectWs() may have already opened a socket — tear everything
|
||||
// down (close WS, reset buffers, state) rather than only resetting
|
||||
// state, which would leak the socket into the next attempt.
|
||||
stopAudioCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -192,7 +209,7 @@ export function useVoiceMode() {
|
|||
|
||||
source.connect(processor);
|
||||
processor.connect(audioCtx.destination);
|
||||
}, [state, connectWs]);
|
||||
}, [state, connectWs, stopAudioCapture]);
|
||||
|
||||
/** Stop recording and return the full transcript (finalized + any current interim) */
|
||||
const submit = useCallback((): string => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { ToolUIPart } from 'ai'
|
|||
import z from 'zod'
|
||||
import { AskHumanRequestEvent, ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
||||
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
|
||||
import type { CodeRunEvent, PermissionAsk } from '@x/shared/src/code-mode.js'
|
||||
|
||||
export interface MessageAttachment {
|
||||
path: string
|
||||
|
|
@ -27,6 +28,9 @@ export interface ToolCall {
|
|||
streamingOutput?: string
|
||||
status: 'pending' | 'running' | 'completed' | 'error'
|
||||
timestamp: number
|
||||
// code_agent_run only: structured ACP stream items + the in-flight permission ask.
|
||||
codeRunEvents?: CodeRunEvent[]
|
||||
pendingCodePermission?: { requestId: string; ask: PermissionAsk } | null
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
|
|
@ -519,41 +523,9 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
|||
* For builtin tools, returns a static friendly name (e.g., "Reading file").
|
||||
* Falls back to the raw tool name if no mapping exists.
|
||||
*/
|
||||
// Phrases shown while a code-mode task is running. They advance over time (5s
|
||||
// each) to read as progress, then hold on the last one until the task finishes.
|
||||
const CODE_MODE_RUNNING_LABELS = [
|
||||
'Working on the task…',
|
||||
'Inspecting the project…',
|
||||
'Digging into the code…',
|
||||
'Figuring it out…',
|
||||
'Making the changes…',
|
||||
'Wiring things up…',
|
||||
'Putting it together…',
|
||||
]
|
||||
const CODE_MODE_LABEL_INTERVAL_MS = 5000
|
||||
|
||||
// Detect acpx coding-agent invocations (code mode) and produce a status-aware
|
||||
// label, e.g. "Working on the task…" → "Completed the task".
|
||||
export const getCodeModeCommandLabel = (tool: ToolCall): string | null => {
|
||||
if (tool.name !== 'executeCommand') return null
|
||||
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||
const command = typeof input?.command === 'string' ? input.command : ''
|
||||
const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/)
|
||||
if (!match) return null
|
||||
if (tool.status === 'error') return `Couldn't complete the task`
|
||||
if (tool.status === 'completed') return `Completed the task`
|
||||
// Advance through the phrases from the tool's start, holding on the last.
|
||||
const elapsed = Math.max(0, Date.now() - tool.timestamp)
|
||||
const step = Math.floor(elapsed / CODE_MODE_LABEL_INTERVAL_MS)
|
||||
const idx = Math.min(step, CODE_MODE_RUNNING_LABELS.length - 1)
|
||||
return CODE_MODE_RUNNING_LABELS[idx]
|
||||
}
|
||||
|
||||
export const getToolDisplayName = (tool: ToolCall): string => {
|
||||
const browserLabel = getBrowserControlLabel(tool)
|
||||
if (browserLabel) return browserLabel
|
||||
const codeModeLabel = getCodeModeCommandLabel(tool)
|
||||
if (codeModeLabel) return codeModeLabel
|
||||
const composioData = getComposioActionCardData(tool)
|
||||
if (composioData) return composioData.label
|
||||
return TOOL_DISPLAY_NAMES[tool.name] || tool.name
|
||||
|
|
@ -634,6 +606,7 @@ export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup =>
|
|||
|
||||
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
|
||||
if (!isToolCall(item)) return false
|
||||
if (item.name === 'code_agent_run') return false // rich standalone block, never grouped
|
||||
if (getWebSearchCardData(item)) return false
|
||||
if (getComposioConnectCardData(item)) return false
|
||||
if (getAppActionCardData(item)) return false
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.39.0",
|
||||
"@agentclientprotocol/codex-acp": "^0.0.44",
|
||||
"@agentclientprotocol/sdk": "^0.22.1",
|
||||
"@ai-sdk/anthropic": "^2.0.63",
|
||||
"@ai-sdk/google": "^2.0.53",
|
||||
"@ai-sdk/openai": "^2.0.91",
|
||||
|
|
|
|||
|
|
@ -1279,6 +1279,7 @@ export async function* streamAgent({
|
|||
signal,
|
||||
abortRegistry,
|
||||
publish: (event) => bus.publish(event),
|
||||
codeMode,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -1426,44 +1427,19 @@ Do not announce the work directory unless it's relevant. Just use it.`;
|
|||
if (codeMode) {
|
||||
loopLogger.log('code mode enabled, injecting coding-agent context', codeMode);
|
||||
const agentDisplay = codeMode === 'claude' ? 'Claude Code' : 'Codex';
|
||||
const otherAgent = codeMode === 'claude' ? 'codex' : 'claude';
|
||||
const otherDisplay = codeMode === 'claude' ? 'Codex' : 'Claude Code';
|
||||
// Deterministic, per-chat session name so the coding agent keeps
|
||||
// context across the user's requests within this chat. Reusing the
|
||||
// same -s <name> resumes the session; the first call creates it.
|
||||
const sessionName = `rowboat-${runId}`;
|
||||
instructionsWithDateTime += `\n\n# Code Mode (Active) — Default agent: ${agentDisplay}
|
||||
The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). Use this as the **default** agent for coding tasks in this turn.
|
||||
instructionsWithDateTime += `\n\n# Code Mode (Active) — Agent: ${agentDisplay}
|
||||
The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). For EVERY coding task this turn, use **${agentDisplay}**, and narrate that agent ("Using ${agentDisplay} to …").
|
||||
|
||||
**The user can override the agent at any time, two ways:**
|
||||
1. By toggling the chip in the composer (preferred).
|
||||
2. By asking you directly in chat ("use codex", "switch to claude", "do this with ${otherDisplay}", etc.). When the user explicitly asks to use a different agent in the current message, honor that — use \`${otherAgent}\` instead of \`${codeMode}\` for this turn, and briefly mention they can also toggle it via the chip for stickiness.
|
||||
The chip is the single source of truth for which agent runs:
|
||||
- Do NOT carry over a different agent from earlier in this thread — even if a previous run used the other agent, use **${agentDisplay}** now.
|
||||
- Do NOT switch agents based on an in-chat text request ("use codex", "switch to claude"). The agent only changes when the user toggles the chip; if they ask in chat, tell them to toggle the chip.
|
||||
|
||||
**Persistent session for this chat — session name: \`${sessionName}\`.** This chat uses one named agent session so the agent keeps context across your requests. The session must exist before it can be prompted (\`-s\` only resumes; it does not create).
|
||||
**How to run coding work — call the \`code_agent_run\` tool** with:
|
||||
- \`agent\`: \`${codeMode}\` (always — match the chip).
|
||||
- \`cwd\`: the absolute project/working directory (resolve it per the code-with-agents skill — a path the user named, the "# User Work Directory" block, or ask once).
|
||||
- \`prompt\`: a clear, self-contained coding instruction.
|
||||
|
||||
**1. First coding action in this chat — ensure the session exists:**
|
||||
|
||||
\`\`\`
|
||||
npx acpx@latest --approve-all --cwd <workdir> <agent> sessions ensure --name ${sessionName}
|
||||
\`\`\`
|
||||
|
||||
(\`ensure\` creates the session if missing and reuses it if it already exists — safe to call when reopening this chat later.)
|
||||
|
||||
**2. Then run the prompt:**
|
||||
|
||||
\`\`\`
|
||||
npx acpx@latest --approve-all --timeout 600 --cwd <workdir> <agent> -s ${sessionName} "<prompt>"
|
||||
\`\`\`
|
||||
|
||||
**3. Every follow-up coding request in this chat — reuse the same session (do NOT create again):**
|
||||
|
||||
\`\`\`
|
||||
npx acpx@latest --approve-all --timeout 600 --cwd <workdir> <agent> -s ${sessionName} "<prompt>"
|
||||
\`\`\`
|
||||
|
||||
Run these as **separate, sequential** \`executeCommand\` calls — issue the \`sessions ensure\` call first and WAIT for it to finish, then issue the prompt call. Do NOT fire both in the same turn / batch.
|
||||
|
||||
Where \`<agent>\` is either \`claude\` or \`codex\` — pick based on (in priority order): an explicit in-chat override → the chip setting (\`${codeMode}\`). Use \`${sessionName}\` exactly — do NOT invent a different name, and do NOT use \`exec\` (it is one-shot and forgets).
|
||||
The tool runs the agent on-device and streams its tool calls, file diffs, and plan into the chat; any action needing approval surfaces as an inline permission card, so you do NOT pre-confirm with an in-chat "reply yes". This chat keeps ONE persistent agent session, so follow-up coding requests automatically resume with full context — just call \`code_agent_run\` again. Do NOT shell out to \`acpx\` or \`executeCommand\` for coding, and do NOT fall back to your own file tools.
|
||||
|
||||
If the user's message is clearly NOT a coding request (small talk, an unrelated question), answer directly without invoking the coding agent. Code mode signals readiness, not that every message must route through the agent.`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ export const skill = String.raw`
|
|||
A *background task* is a persistent agent the user configures once and the framework keeps firing — on a schedule, inside time-of-day windows, and/or in response to matching incoming events (Gmail threads, calendar changes). Each task lives at \`bg-tasks/<slug>/\` and owns two artifacts:
|
||||
|
||||
- \`task.yaml\` — the spec (the user's **instructions**, triggers, runtime state). You and the user both treat this as the source of truth.
|
||||
- \`index.md\` — the agent-owned body. The runtime never writes here; the bg-task agent does, each run.
|
||||
- \`index.md\` — the agent-owned body (a note). The runtime never writes here; the bg-task agent does, each run.
|
||||
|
||||
For **visual** output — a dashboard, a styled report, a metrics table with conditional colors, a chart — the agent may instead write a self-contained \`index.html\`, which the task view renders full-screen in a sandboxed iframe with CSS and layout preserved. The agent picks the format per run from the instructions; you don't set it, but when the ask is inherently visual, say so in the instructions (e.g. "…rendered as a styled HTML dashboard") so the agent leans that way.
|
||||
|
||||
A task is one of two shapes — the agent decides per run from the verbs in \`instructions\`:
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ Use this skill whenever the user asks you to write code, build a project, create
|
|||
|
||||
Coding agents operate on **arbitrary file paths** (including paths outside the Rowboat workspace root, like \`G:/4th sem/CN\` or \`~/projects/foo\`). Do NOT raise "outside workspace" concerns, and do NOT fall back to your own \`executeCommand\` (PowerShell / bash) or workspace file tools to do code work yourself.
|
||||
|
||||
All coding work runs through the **\`code_agent_run\`** tool. It launches the selected on-device coding agent (Claude Code / Codex), streams its tool calls, file diffs, and plan into the chat, and surfaces any action needing approval as an inline permission card. One persistent session is kept per chat, so follow-up requests resume with full context automatically.
|
||||
|
||||
---
|
||||
|
||||
## STEP 1 — MANDATORY FIRST ACTION
|
||||
|
|
@ -39,96 +41,52 @@ This is non-negotiable. The user gets clickable buttons. Free-text "which agent?
|
|||
|
||||
---
|
||||
|
||||
## STEP 2 — Resolve workdir, confirm, execute
|
||||
## STEP 2 — Resolve workdir, then run
|
||||
|
||||
**Resolve the workdir** (in this priority order):
|
||||
1. A path the user named in their original message (e.g. \`G:/4th sem/CN\`).
|
||||
2. The path from a "# User Work Directory" block in your context.
|
||||
3. Ask once in plain text: "Which folder should I work in?"
|
||||
|
||||
**State your intent in one line, then execute immediately — do NOT wait for a "yes".** The \`executeCommand\` call surfaces a permission card that is itself the user's confirmation, so an extra in-chat "reply yes to proceed" is redundant friction. Say something like:
|
||||
**Pick the agent** (\`claude\` or \`codex\`): use the agent from the "# Code Mode (Active)" block (the composer chip) / the Step 1 choice. The chip is authoritative — do NOT carry over a different agent from earlier in this thread, and do NOT switch on an in-chat text request ("use codex"); tell the user to toggle the chip instead.
|
||||
|
||||
**State your intent in one line, then call the tool immediately — do NOT wait for a "yes".** The tool's own permission cards are the user's confirmation, so an extra in-chat "reply yes to proceed" is redundant friction. Say something like:
|
||||
|
||||
> Using [Claude Code / Codex] to [task description] in \`[folder]\`.
|
||||
|
||||
…and then immediately make the \`executeCommand\` call in the same turn.
|
||||
|
||||
**Execute** with the chosen agent using a **persistent named session** so follow-up coding requests in this chat resume the same agent and keep context.
|
||||
|
||||
Pick \`<agent>\` (\`claude\` or \`codex\`) by, in priority order:
|
||||
- An explicit in-chat override from the user this turn ("use codex", "switch to claude") — honor it.
|
||||
- The agent chosen in Step 1 / the "# Code Mode (Active)" block.
|
||||
|
||||
Pick \`<session-name>\` — **stable for this whole chat**:
|
||||
- If the "# Code Mode (Active)" block gives a session name (e.g. \`rowboat-<runId>\`), use that exact name.
|
||||
- Otherwise pick one short, kebab-case name and **reuse it for every coding call this turn and in follow-ups** — never a new name each time.
|
||||
|
||||
**\`-s\` resumes an existing session; it does NOT create one.** So ensure the session exists once at the start, then prompt:
|
||||
|
||||
**1. First coding action in this chat — ensure the session exists:**
|
||||
…and then immediately call:
|
||||
|
||||
\`\`\`
|
||||
npx acpx@latest --approve-all --cwd <folder> <agent> sessions ensure --name <session-name>
|
||||
code_agent_run({
|
||||
agent: "<claude|codex>",
|
||||
cwd: "<resolved absolute folder>",
|
||||
prompt: "<clear, self-contained coding instruction>"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
(\`ensure\` creates the session if missing and reuses it if it already exists — so reopening this chat later just resumes the same session instead of erroring.)
|
||||
|
||||
**2. Then run the prompt:**
|
||||
|
||||
\`\`\`
|
||||
npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"
|
||||
\`\`\`
|
||||
|
||||
**3. Every follow-up coding request in this chat — reuse the same session (do NOT create again):**
|
||||
|
||||
\`\`\`
|
||||
npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"
|
||||
\`\`\`
|
||||
|
||||
**Run steps 1 and 2 as separate, sequential \`executeCommand\` calls.** Issue the \`sessions ensure\` call FIRST, wait for it to finish, and only THEN issue the prompt call. Do NOT fire both in the same turn / batch — each must surface and complete its own permission + command block before the next begins.
|
||||
|
||||
Do NOT use \`exec\` — it is one-shot and forgets everything.
|
||||
|
||||
Concrete example:
|
||||
|
||||
\`\`\`
|
||||
# First coding message in the chat — ensure the session, then prompt:
|
||||
npx acpx@latest --approve-all --cwd "G:\\Blogging\\myblog" claude sessions ensure --name diskspace-check
|
||||
npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Check the system disk space and report total, used, and free space."
|
||||
|
||||
# Follow-up in the same chat — reuse the session, no create:
|
||||
npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Summarize what we did and the final findings."
|
||||
\`\`\`
|
||||
|
||||
### Critical: flag order
|
||||
|
||||
\`--approve-all\`, \`--timeout\`, and \`--cwd\` are GLOBAL flags and MUST appear BEFORE the agent name. \`sessions ensure --name <name>\` and \`-s <session-name>\` come AFTER the agent name:
|
||||
|
||||
- ✓ Correct: \`npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"\`
|
||||
- ✗ Wrong: \`npx acpx@latest <agent> --approve-all -s <name> "..."\` (will fail)
|
||||
|
||||
### Writing good prompts for the agent
|
||||
|
||||
**Writing good prompts for the agent:**
|
||||
- Be specific: file names, function signatures, expected behavior.
|
||||
- Mention constraints (language, framework, style).
|
||||
- Expand short user requests into clear, actionable prompts.
|
||||
- Expand short user requests into clear, actionable instructions.
|
||||
|
||||
**Follow-ups:** for every later coding request in this chat, just call \`code_agent_run\` again with the same \`cwd\` and the chip's current agent. The session resumes automatically — do NOT start over or re-explain prior context.
|
||||
|
||||
---
|
||||
|
||||
## STEP 3 — Report results
|
||||
|
||||
After the command finishes:
|
||||
- Pass through the coding agent's summary as-is. Do not rewrite.
|
||||
After \`code_agent_run\` returns:
|
||||
- Pass through the agent's \`summary\` as-is. Do not rewrite it.
|
||||
- Refer to file paths as plain text. Do NOT use \`\`\`file:path\`\`\` reference blocks. (This overrides the global "always wrap paths in filepath blocks" rule — for code-mode output, plain text.)
|
||||
- Only add your own explanation if the command failed (non-zero exit):
|
||||
- Exit code 5 — permissions were denied (shouldn't happen with \`--approve-all\`; flag it).
|
||||
- Exit code 4 / "No acpx session found" — the \`-s <session-name>\` session doesn't exist yet. Create it once with \`npx acpx@latest --approve-all --cwd <folder> <agent> sessions ensure --name <session-name>\`, then retry the prompt. (\`-s\` only resumes; it never creates.)
|
||||
- "command not found" / agent not installed, or an auth/sign-in error — the agent isn't set up. Tell the user to install or sign in to the agent via **Settings → Code Mode**, where Rowboat shows the install and sign-in status.
|
||||
- Only add your own explanation if it failed:
|
||||
- \`success: false\` with a message — surface the message. If it mentions the agent isn't installed or signed in, tell the user to install or sign in via **Settings → Code Mode**.
|
||||
- \`stopReason: "cancelled"\` — the run was stopped; acknowledge briefly and ask if they want to continue.
|
||||
|
||||
---
|
||||
|
||||
## Once delegating: delegate fully
|
||||
|
||||
After Step 2 fires, delegate ALL related coding tasks for this turn to the coding agent — writing, editing, reading, debugging, exploring structure, running tests. You are the coordinator; the agent does the work.
|
||||
After Step 2 fires, delegate ALL related coding tasks for this turn to \`code_agent_run\` — writing, editing, reading, debugging, exploring structure, running tests. You are the coordinator; the agent does the work.
|
||||
|
||||
## Prerequisites (informational)
|
||||
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ const definitions: SkillDefinition[] = [
|
|||
{
|
||||
id: "code-with-agents",
|
||||
title: "Code with Agents",
|
||||
summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex via acpx.",
|
||||
summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex.",
|
||||
content: codeWithAgentsSkill,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { z, ZodType } from "zod";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs/promises";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
|
||||
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
|
||||
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
|
||||
|
|
@ -16,6 +15,10 @@ import { executeAction as executeComposioAction, isConfigured as isComposioConfi
|
|||
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
|
||||
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
|
||||
import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js";
|
||||
import type { CodeModeManager } from "../../code-mode/acp/manager.js";
|
||||
import type { CodePermissionRegistry } from "../../code-mode/acp/permission-registry.js";
|
||||
import { ICodeModeConfigRepo } from "../../code-mode/repo.js";
|
||||
import type { ApprovalPolicy } from "@x/shared/dist/code-mode.js";
|
||||
|
||||
// Inputs for the bg-task builtin tools. Reuse the canonical schema field
|
||||
// descriptions; only `triggers` gets a tighter contextual override (the
|
||||
|
|
@ -90,69 +93,6 @@ const LLMPARSE_MIME_TYPES: Record<string, string> = {
|
|||
'.tiff': 'image/tiff',
|
||||
};
|
||||
|
||||
// Windows-only workaround: the Claude ACP bridge spawns CLAUDE_CODE_EXECUTABLE
|
||||
// without `shell: true`, and Node refuses to spawn .cmd files that way (EINVAL).
|
||||
// When the LLM invokes acpx via executeCommand, pre-resolve claude's real .exe
|
||||
// from the npm-shim layout and inject it via env so the bridge can spawn it.
|
||||
function resolveClaudeExeOnWindows(): string | undefined {
|
||||
// Candidate dirs = everything on PATH, plus well-known npm/pnpm/volta global
|
||||
// bin dirs. Electron's runtime PATH can omit these even when the user's shell
|
||||
// includes them, which would otherwise leave us unable to find claude.exe and
|
||||
// force a fallback to claude.cmd (which Node refuses to spawn — EINVAL).
|
||||
const home = process.env.USERPROFILE ?? '';
|
||||
const appData = process.env.APPDATA || (home && path.join(home, 'AppData', 'Roaming'));
|
||||
const localAppData = process.env.LOCALAPPDATA || (home && path.join(home, 'AppData', 'Local'));
|
||||
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
|
||||
const knownDirs = [
|
||||
appData && path.join(appData, 'npm'),
|
||||
localAppData && path.join(localAppData, 'npm'),
|
||||
appData && path.join(appData, 'pnpm'),
|
||||
localAppData && path.join(localAppData, 'pnpm'),
|
||||
home && path.join(home, '.volta', 'bin'),
|
||||
path.join(programFiles, 'nodejs'),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const pathDirs = (process.env.PATH ?? '').split(';').map((d) => d.trim()).filter(Boolean);
|
||||
const seen = new Set<string>();
|
||||
const candidates = [...pathDirs, ...knownDirs].filter((d) => {
|
||||
const key = d.toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const dir of candidates) {
|
||||
// Direct npm-shim layout: <dir>\node_modules\@anthropic-ai\claude-code\bin\claude.exe
|
||||
const exeFromLayout = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
|
||||
if (existsSync(exeFromLayout)) return exeFromLayout;
|
||||
|
||||
// Otherwise parse the claude.cmd shim for the real exe path.
|
||||
const cmdPath = path.join(dir, 'claude.cmd');
|
||||
if (!existsSync(cmdPath)) continue;
|
||||
try {
|
||||
const content = readFileSync(cmdPath, 'utf-8');
|
||||
const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i);
|
||||
if (absMatch && existsSync(absMatch[0])) return absMatch[0];
|
||||
const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i);
|
||||
if (relMatch) {
|
||||
const resolved = path.join(dir, relMatch[1]);
|
||||
if (existsSync(resolved)) return resolved;
|
||||
}
|
||||
} catch {
|
||||
// ignore shim parse failures
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function envForCommand(command: string): NodeJS.ProcessEnv | undefined {
|
||||
if (process.platform !== 'win32') return undefined;
|
||||
if (!/\bacpx\b/.test(command)) return undefined;
|
||||
if (process.env.CLAUDE_CODE_EXECUTABLE) return undefined;
|
||||
const exe = resolveClaudeExeOnWindows();
|
||||
if (!exe) return undefined;
|
||||
return { ...process.env, CLAUDE_CODE_EXECUTABLE: exe };
|
||||
}
|
||||
|
||||
export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||
loadSkill: {
|
||||
|
|
@ -814,14 +754,11 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
// };
|
||||
// }
|
||||
|
||||
const envOverride = envForCommand(command);
|
||||
|
||||
// Use abortable version when we have a signal
|
||||
if (ctx?.signal) {
|
||||
const { promise, process: proc } = executeCommandAbortable(command, {
|
||||
cwd: workingDir,
|
||||
signal: ctx.signal,
|
||||
env: envOverride,
|
||||
onData: (chunk: string) => {
|
||||
ctx.publish({
|
||||
runId: ctx.runId,
|
||||
|
|
@ -851,7 +788,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
}
|
||||
|
||||
// Fallback to original for backward compatibility
|
||||
const result = await executeCommand(command, { cwd: workingDir, env: envOverride });
|
||||
const result = await executeCommand(command, { cwd: workingDir });
|
||||
|
||||
return {
|
||||
success: result.exitCode === 0,
|
||||
|
|
@ -871,6 +808,104 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
code_agent_run: {
|
||||
description: 'Run a coding/software task with the selected on-device coding agent (Claude Code or Codex) inside a project folder. Streams the agent\'s tool calls, file diffs, and plan into the chat and surfaces permission requests inline. Use this for ALL code-mode work (writing/editing/reading code, running tests, debugging, exploring a repo). Reuses one persistent session per chat, so follow-up requests keep context.',
|
||||
inputSchema: z.object({
|
||||
agent: z.enum(['claude', 'codex']).describe('Which coding agent to use: "claude" (Claude Code) or "codex". Set this to the active code-mode chip agent. Note: when the chip is set, the backend uses the chip agent regardless of this value — this only takes effect in the ask-human flow where no chip is set.'),
|
||||
cwd: z.string().describe('Absolute path to the working directory / project folder the agent should operate in.'),
|
||||
prompt: z.string().describe('The full, self-contained coding instruction for the agent (file names, expected behavior, constraints).'),
|
||||
}),
|
||||
execute: async ({ agent, cwd, prompt }: { agent: 'claude' | 'codex', cwd: string, prompt: string }, ctx?: ToolContext) => {
|
||||
if (!ctx) {
|
||||
return { success: false, message: 'code_agent_run requires run context (runId / streaming).' };
|
||||
}
|
||||
// The composer chip is the source of truth for the agent. The model's `agent`
|
||||
// argument is only a fallback for the ask-human flow (code mode not active, no
|
||||
// chip set) — otherwise it can anchor on the thread's earlier agent and ignore a
|
||||
// chip change. Honor the chip so switching it deterministically switches agents.
|
||||
const effectiveAgent = ctx.codeMode ?? agent;
|
||||
const manager = container.resolve<CodeModeManager>('codeModeManager');
|
||||
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
|
||||
|
||||
// Approval policy from settings; default to asking the user.
|
||||
let policy: ApprovalPolicy = 'ask';
|
||||
try {
|
||||
const cfg = await container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo').getConfig();
|
||||
if (cfg.approvalPolicy) policy = cfg.approvalPolicy;
|
||||
} catch {
|
||||
// fall back to 'ask'
|
||||
}
|
||||
|
||||
// On stop, unblock any pending approval card so the broker stops waiting for
|
||||
// an answer that will never come. The ACP cancel + force-kill backstop that
|
||||
// actually ends the turn is handled inside manager.runPrompt via the signal
|
||||
// we pass below.
|
||||
const onAbort = () => registry.cancelRun(ctx.runId);
|
||||
if (ctx.signal.aborted) onAbort();
|
||||
else ctx.signal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
let finalText = '';
|
||||
const changedFiles = new Set<string>();
|
||||
try {
|
||||
const result = await manager.runPrompt({
|
||||
runId: ctx.runId,
|
||||
agent: effectiveAgent,
|
||||
cwd,
|
||||
prompt,
|
||||
policy,
|
||||
signal: ctx.signal,
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'message' && event.role === 'agent') finalText += event.text;
|
||||
if (event.type === 'tool_call_update') for (const f of event.diffs) changedFiles.add(f);
|
||||
void ctx.publish({
|
||||
runId: ctx.runId,
|
||||
type: 'code-run-event',
|
||||
toolCallId: ctx.toolCallId,
|
||||
event,
|
||||
subflow: [],
|
||||
});
|
||||
},
|
||||
ask: (permAsk) => registry.request(ctx.runId, (requestId) => {
|
||||
void ctx.publish({
|
||||
runId: ctx.runId,
|
||||
type: 'code-run-permission-request',
|
||||
toolCallId: ctx.toolCallId,
|
||||
requestId,
|
||||
ask: permAsk,
|
||||
subflow: [],
|
||||
});
|
||||
}),
|
||||
});
|
||||
return {
|
||||
success: result.stopReason === 'end_turn',
|
||||
stopReason: result.stopReason,
|
||||
// The agent that actually ran (the chip), so the UI can label the run
|
||||
// authoritatively rather than trusting the model's `agent` argument.
|
||||
agent: effectiveAgent,
|
||||
summary: finalText.trim(),
|
||||
changedFiles: [...changedFiles],
|
||||
};
|
||||
} catch (error) {
|
||||
// A stop mid-run isn't a failure — report it as a clean cancellation.
|
||||
if (ctx.signal.aborted) {
|
||||
return {
|
||||
success: false,
|
||||
stopReason: 'cancelled',
|
||||
agent: effectiveAgent,
|
||||
summary: finalText.trim(),
|
||||
changedFiles: [...changedFiles],
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: `Coding agent failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
} finally {
|
||||
ctx.signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Browser Skills (browser-use/browser-harness domain-skills cache)
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export async function executeCommand(
|
|||
cwd?: string;
|
||||
timeout?: number; // timeout in milliseconds
|
||||
maxBuffer?: number; // max buffer size in bytes
|
||||
env?: NodeJS.ProcessEnv; // override environment (e.g. CLAUDE_CODE_EXECUTABLE for acpx)
|
||||
env?: NodeJS.ProcessEnv; // override environment
|
||||
}
|
||||
): Promise<CommandResult> {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ export interface ToolContext {
|
|||
signal: AbortSignal;
|
||||
abortRegistry: IAbortRegistry;
|
||||
publish: (event: z.infer<typeof RunEvent>) => Promise<void>;
|
||||
// The composer code-mode chip for the message that triggered this turn. When set,
|
||||
// it is the authoritative coding agent — code_agent_run uses it rather than the
|
||||
// agent the model guessed, so switching the chip deterministically switches agents.
|
||||
codeMode?: 'claude' | 'codex' | null;
|
||||
}
|
||||
|
||||
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ You are running with **no user present** to clarify, approve, or watch.
|
|||
|
||||
Your task folder is \`bg-tasks/<slug>/\` (the path is given in the run message). It contains:
|
||||
- \`task.yaml\` — the spec. **Never touch this.** The runtime owns it.
|
||||
- \`index.md\` — agent-owned. You read and write this freely via \`file-readText\` / \`file-editText\`.
|
||||
- \`index.md\` — the default agent-owned artifact (a note). You read and write it freely via \`file-readText\` / \`file-editText\`.
|
||||
- \`index.html\` — optional agent-owned artifact for **visual** output (see OUTPUT MODE). When it exists and is non-empty it is shown to the user instead of \`index.md\`.
|
||||
- \`runs/\` — your own run logs (jsonl). You don't write to it directly; the runtime does.
|
||||
|
||||
You can also read and write anywhere else under the workspace (\`knowledge/\`, etc.) when your instructions call for it.
|
||||
|
|
@ -28,6 +29,12 @@ Use when instructions imply a **current state** artifact:
|
|||
- "Keep me posted on …" / "What's the latest on …"
|
||||
On every run: \`file-readText\` \`index.md\`, decide the smallest patch that brings it into alignment with the instructions, apply with \`file-editText\`. Patch-style discipline: edit one region, re-read, then edit the next. Avoid one-shot rewrites.
|
||||
|
||||
Pick the artifact format from what the output needs:
|
||||
- **\`index.md\`** (default) — prose, lists, summaries, digests, briefs. Rendered as a styled note. Use patch-style edits as above.
|
||||
- **\`index.html\`** — when the output is inherently **visual**: a dashboard, a metrics table with conditional colors, a chart, a styled report — anything where layout/CSS carry meaning that a plain note would lose. Write a single **self-contained** file with \`file-writeText\` (inline all CSS and JS; avoid external/CDN dependencies as they may be blocked; reference only assets you save next to it in the task folder — relative paths resolve against the folder). It renders full-screen in a sandboxed iframe. HTML is typically regenerated wholesale each run, so a one-shot \`file-writeText\` is fine here.
|
||||
|
||||
Use ONE format per task — don't maintain both. \`index.html\` wins when present and non-empty. If you move a task from HTML back to a plain note, blank out \`index.html\` (\`file-writeText\` with \`""\`) so \`index.md\` shows again.
|
||||
|
||||
ACTION MODE — perform a side-effect, append a journal entry.
|
||||
Use when instructions imply a **recurring action**:
|
||||
- "Send / draft / post / notify / file / reply / publish / call / forward …"
|
||||
|
|
@ -71,7 +78,9 @@ The workspace lives at \`${WorkDir}\`.
|
|||
export function buildBackgroundTaskAgent(): z.infer<typeof Agent> {
|
||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||
for (const name of Object.keys(BuiltinTools)) {
|
||||
if (name === 'executeCommand') continue;
|
||||
// code_agent_run requires an interactive UI for permission approvals — skip it
|
||||
// here (headless) so it can't hang on an approval no one can answer.
|
||||
if (name === 'executeCommand' || name === 'code_agent_run') continue;
|
||||
tools[name] = { type: 'builtin', name };
|
||||
}
|
||||
|
||||
|
|
|
|||
60
apps/x/packages/core/src/code-mode/acp/agents.ts
Normal file
60
apps/x/packages/core/src/code-mode/acp/agents.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { createRequire } from 'module';
|
||||
import * as path from 'path';
|
||||
import type { CodingAgent } from './types.js';
|
||||
import { resolveClaudeExecutable } from './claude-exec.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// The ACP adapter npm package that exposes each coding agent as an ACP server.
|
||||
const ADAPTER_PACKAGE: Record<CodingAgent, string> = {
|
||||
claude: '@agentclientprotocol/claude-agent-acp',
|
||||
codex: '@agentclientprotocol/codex-acp',
|
||||
};
|
||||
|
||||
export interface AgentLaunchSpec {
|
||||
/** Executable to spawn — always `node` so we never hit the Windows .cmd EINVAL. */
|
||||
command: string;
|
||||
/** Args = [adapter entry script]. */
|
||||
args: string[];
|
||||
/** Extra env merged over process.env (e.g. CLAUDE_CODE_EXECUTABLE on Windows). */
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
// Resolve the adapter's executable ENTRY (its `bin`, not its library `main`) to an
|
||||
// absolute path so we can spawn it directly with `node <entry>`. createRequire lets
|
||||
// us resolve workspace/pnpm-installed packages from this module's location.
|
||||
function resolveAdapterEntry(pkg: string): string {
|
||||
const pkgJsonPath = require.resolve(`${pkg}/package.json`);
|
||||
const pkgDir = path.dirname(pkgJsonPath);
|
||||
const pkgJson = require(`${pkg}/package.json`) as { bin?: string | Record<string, string> };
|
||||
const bin = pkgJson.bin;
|
||||
const rel = typeof bin === 'string' ? bin : bin ? Object.values(bin)[0] : undefined;
|
||||
if (!rel) {
|
||||
throw new Error(`ACP adapter ${pkg} has no bin entry to spawn`);
|
||||
}
|
||||
return path.join(pkgDir, rel);
|
||||
}
|
||||
|
||||
export function getAgentLaunchSpec(agent: CodingAgent): AgentLaunchSpec {
|
||||
const entry = resolveAdapterEntry(ADAPTER_PACKAGE[agent]);
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
|
||||
// Point the Claude adapter at the real claude executable. On Windows this is
|
||||
// mandatory (Node can't spawn the .cmd shim — EINVAL); on macOS/Linux it's a
|
||||
// PATH safety net for GUI launches. Resolver is a no-op when claude isn't found,
|
||||
// leaving the adapter to do its own lookup. (Codex relies on PATH for now — wire
|
||||
// an equivalent when we add Codex support.)
|
||||
if (agent === 'claude' && !env.CLAUDE_CODE_EXECUTABLE) {
|
||||
const exe = resolveClaudeExecutable();
|
||||
if (exe) env.CLAUDE_CODE_EXECUTABLE = exe;
|
||||
}
|
||||
|
||||
// We spawn the adapter with process.execPath. Inside Electron's main process
|
||||
// that is the Electron binary, NOT node — so set ELECTRON_RUN_AS_NODE=1 to make
|
||||
// it behave as a plain Node runtime. (Harmless under a real node process, which
|
||||
// ignores the var.) Without this the child never runs as node and the ACP stdio
|
||||
// stream closes immediately ("ACP connection closed").
|
||||
env.ELECTRON_RUN_AS_NODE = '1';
|
||||
|
||||
return { command: process.execPath, args: [entry], env };
|
||||
}
|
||||
91
apps/x/packages/core/src/code-mode/acp/claude-exec.ts
Normal file
91
apps/x/packages/core/src/code-mode/acp/claude-exec.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { execSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { commonInstallPaths } from '../status.js';
|
||||
|
||||
// Windows-only: Node refuses to spawn `.cmd` files without `shell: true` (EINVAL),
|
||||
// and the Claude ACP adapter spawns its executable directly. So we pre-resolve
|
||||
// claude's real `.exe` from the npm-shim layout. Used by resolveClaudeExecutable below.
|
||||
export function resolveClaudeExeOnWindows(): string | undefined {
|
||||
// Candidate dirs = everything on PATH, plus well-known npm/pnpm/volta global
|
||||
// bin dirs. Electron's runtime PATH can omit these even when the user's shell
|
||||
// includes them, which would otherwise leave us unable to find claude.exe and
|
||||
// force a fallback to claude.cmd (which Node refuses to spawn — EINVAL).
|
||||
const home = process.env.USERPROFILE ?? '';
|
||||
const appData = process.env.APPDATA || (home && path.join(home, 'AppData', 'Roaming'));
|
||||
const localAppData = process.env.LOCALAPPDATA || (home && path.join(home, 'AppData', 'Local'));
|
||||
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
|
||||
const knownDirs = [
|
||||
appData && path.join(appData, 'npm'),
|
||||
localAppData && path.join(localAppData, 'npm'),
|
||||
appData && path.join(appData, 'pnpm'),
|
||||
localAppData && path.join(localAppData, 'pnpm'),
|
||||
home && path.join(home, '.volta', 'bin'),
|
||||
path.join(programFiles, 'nodejs'),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const pathDirs = (process.env.PATH ?? '').split(';').map((d) => d.trim()).filter(Boolean);
|
||||
const seen = new Set<string>();
|
||||
const candidates = [...pathDirs, ...knownDirs].filter((d) => {
|
||||
const key = d.toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const dir of candidates) {
|
||||
// Direct npm-shim layout: <dir>\node_modules\@anthropic-ai\claude-code\bin\claude.exe
|
||||
const exeFromLayout = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
|
||||
if (existsSync(exeFromLayout)) return exeFromLayout;
|
||||
|
||||
// Otherwise parse the claude.cmd shim for the real exe path.
|
||||
const cmdPath = path.join(dir, 'claude.cmd');
|
||||
if (!existsSync(cmdPath)) continue;
|
||||
try {
|
||||
const content = readFileSync(cmdPath, 'utf-8');
|
||||
const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i);
|
||||
if (absMatch && existsSync(absMatch[0])) return absMatch[0];
|
||||
const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i);
|
||||
if (relMatch) {
|
||||
const resolved = path.join(dir, relMatch[1]);
|
||||
if (existsSync(resolved)) return resolved;
|
||||
}
|
||||
} catch {
|
||||
// ignore shim parse failures
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// macOS/Linux: find the real `claude` binary. Unlike Windows this isn't a spawn
|
||||
// requirement (no .cmd problem) — it's a PATH safety net. Electron apps launched
|
||||
// from the GUI (Dock/Finder) often don't inherit the login shell's PATH, so the
|
||||
// spawned adapter may fail to find `claude`. We resolve the path here so the adapter
|
||||
// can be pointed straight at it.
|
||||
function resolveClaudeBinaryUnix(): string | undefined {
|
||||
// Primary: a login shell sees the user's full PATH (~/.zprofile, nvm, homebrew, …).
|
||||
try {
|
||||
const out = execSync("/bin/sh -lc 'command -v claude'", { timeout: 5000, encoding: 'utf-8' }).trim();
|
||||
if (out && existsSync(out)) return out;
|
||||
} catch {
|
||||
// not found on the login-shell PATH
|
||||
}
|
||||
// Fallback: scan well-known install locations directly.
|
||||
for (const candidate of commonInstallPaths('claude')) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cached: string | undefined;
|
||||
|
||||
// Cross-platform: the real `claude` executable to hand the ACP adapter via
|
||||
// CLAUDE_CODE_EXECUTABLE (the adapter prefers this env var on every OS). Returns
|
||||
// undefined if it can't be found — callers then fall back to the adapter's own lookup.
|
||||
// Cached on first success so we don't re-probe the shell on every cold start.
|
||||
export function resolveClaudeExecutable(): string | undefined {
|
||||
if (cached) return cached;
|
||||
const resolved = process.platform === 'win32' ? resolveClaudeExeOnWindows() : resolveClaudeBinaryUnix();
|
||||
if (resolved) cached = resolved;
|
||||
return resolved;
|
||||
}
|
||||
219
apps/x/packages/core/src/code-mode/acp/client.ts
Normal file
219
apps/x/packages/core/src/code-mode/acp/client.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { Writable, Readable } from 'node:stream';
|
||||
import fs from 'fs/promises';
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type Client,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
type SessionUpdate,
|
||||
type PromptResponse,
|
||||
type ReadTextFileRequest,
|
||||
type ReadTextFileResponse,
|
||||
type WriteTextFileRequest,
|
||||
type WriteTextFileResponse,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { CodingAgent, CodeRunEvent } from './types.js';
|
||||
import type { PermissionBroker } from './permission-broker.js';
|
||||
import { getAgentLaunchSpec } from './agents.js';
|
||||
|
||||
export interface AcpClientOptions {
|
||||
agent: CodingAgent;
|
||||
cwd: string;
|
||||
broker: PermissionBroker;
|
||||
onEvent: (event: CodeRunEvent) => void;
|
||||
}
|
||||
|
||||
// Map a raw ACP session/update notification onto our small CodeRunEvent union.
|
||||
function toEvent(update: SessionUpdate): CodeRunEvent {
|
||||
switch (update.sessionUpdate) {
|
||||
case 'agent_message_chunk':
|
||||
case 'user_message_chunk': {
|
||||
const c = update.content;
|
||||
const role = update.sessionUpdate === 'user_message_chunk' ? 'user' : 'agent';
|
||||
return { type: 'message', role, text: c.type === 'text' ? c.text : `[${c.type}]` };
|
||||
}
|
||||
case 'agent_thought_chunk':
|
||||
return { type: 'thought' };
|
||||
case 'tool_call':
|
||||
return {
|
||||
type: 'tool_call',
|
||||
id: update.toolCallId,
|
||||
title: update.title,
|
||||
kind: update.kind ?? undefined,
|
||||
status: update.status ?? undefined,
|
||||
};
|
||||
case 'tool_call_update': {
|
||||
const diffs = (update.content ?? [])
|
||||
.filter((c): c is Extract<typeof c, { type: 'diff' }> => c.type === 'diff')
|
||||
.map((c) => c.path);
|
||||
return { type: 'tool_call_update', id: update.toolCallId, status: update.status ?? undefined, diffs };
|
||||
}
|
||||
case 'plan':
|
||||
return {
|
||||
type: 'plan',
|
||||
entries: (update.entries ?? []).map((e) => ({
|
||||
content: e.content,
|
||||
status: e.status ?? undefined,
|
||||
priority: e.priority ?? undefined,
|
||||
})),
|
||||
};
|
||||
default:
|
||||
return { type: 'other', sessionUpdate: update.sessionUpdate };
|
||||
}
|
||||
}
|
||||
|
||||
// Owns one spawned adapter process + ACP connection. Stateless about sessions —
|
||||
// the manager decides whether to newSession or loadSession.
|
||||
//
|
||||
// The connection is long-lived and reused across follow-up prompts, but each prompt
|
||||
// may stream to a different message's UI, so broker + onEvent are swappable via
|
||||
// setHandlers() rather than fixed at construction.
|
||||
export class AcpClient {
|
||||
readonly agent: CodingAgent;
|
||||
readonly cwd: string;
|
||||
private broker: PermissionBroker;
|
||||
private onEvent: (event: CodeRunEvent) => void;
|
||||
private child?: ChildProcess;
|
||||
private connection?: ClientSideConnection;
|
||||
private loadSession_ = false;
|
||||
// Diagnostics: the adapter's stderr/exit are captured so a dropped connection
|
||||
// reports WHY (e.g. a crash) instead of the SDK's bare "ACP connection closed".
|
||||
private stderrTail = '';
|
||||
private exitInfo: string | null = null;
|
||||
|
||||
constructor(opts: AcpClientOptions) {
|
||||
this.agent = opts.agent;
|
||||
this.cwd = opts.cwd;
|
||||
this.broker = opts.broker;
|
||||
this.onEvent = opts.onEvent;
|
||||
}
|
||||
|
||||
get loadSupported(): boolean {
|
||||
return this.loadSession_;
|
||||
}
|
||||
|
||||
// Re-point the live connection at a new prompt's broker / event sink.
|
||||
setHandlers(broker: PermissionBroker, onEvent: (event: CodeRunEvent) => void): void {
|
||||
this.broker = broker;
|
||||
this.onEvent = onEvent;
|
||||
}
|
||||
|
||||
// Spawn the adapter and negotiate the protocol. Returns once initialized.
|
||||
async start(): Promise<void> {
|
||||
const spec = getAgentLaunchSpec(this.agent);
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: this.cwd,
|
||||
env: spec.env,
|
||||
// Capture stderr (not inherit) so we can attribute a dropped connection.
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
this.child = child;
|
||||
child.stderr?.on('data', (d: Buffer) => {
|
||||
this.stderrTail = (this.stderrTail + d.toString()).slice(-4000);
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
this.exitInfo = `adapter exited (code ${code}${signal ? `, signal ${signal}` : ''})`;
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
this.stderrTail = (this.stderrTail + `\nspawn error: ${err.message}`).slice(-4000);
|
||||
});
|
||||
|
||||
const stream = ndJsonStream(
|
||||
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout!) as ReadableStream<Uint8Array>,
|
||||
);
|
||||
const client = this.buildClient();
|
||||
this.connection = new ClientSideConnection(() => client, stream);
|
||||
|
||||
try {
|
||||
const init = await this.connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
||||
});
|
||||
this.loadSession_ = init.agentCapabilities?.loadSession === true;
|
||||
} catch (e) {
|
||||
throw this.enrich(e, 'initialize');
|
||||
}
|
||||
}
|
||||
|
||||
async newSession(): Promise<string> {
|
||||
try {
|
||||
const res = await this.conn().newSession({ cwd: this.cwd, mcpServers: [] });
|
||||
return res.sessionId;
|
||||
} catch (e) {
|
||||
throw this.enrich(e, 'newSession');
|
||||
}
|
||||
}
|
||||
|
||||
async loadSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] });
|
||||
} catch (e) {
|
||||
throw this.enrich(e, 'loadSession');
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, text: string): Promise<PromptResponse> {
|
||||
try {
|
||||
return await this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] });
|
||||
} catch (e) {
|
||||
throw this.enrich(e, 'prompt');
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap a connection error with the adapter's exit/stderr so failures are
|
||||
// self-explanatory rather than the SDK's opaque "ACP connection closed".
|
||||
private enrich(err: unknown, phase: string): Error {
|
||||
const base = err instanceof Error ? err.message : String(err);
|
||||
const parts = [
|
||||
this.exitInfo,
|
||||
this.stderrTail.trim() ? `adapter output: ${this.stderrTail.trim().slice(-1200)}` : '',
|
||||
].filter(Boolean);
|
||||
return new Error(parts.length ? `${base} — ${parts.join(' | ')} [during ${phase}]` : `${base} [during ${phase}]`);
|
||||
}
|
||||
|
||||
async cancel(sessionId: string): Promise<void> {
|
||||
await this.conn().cancel({ sessionId });
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
try {
|
||||
this.child?.kill();
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
this.child = undefined;
|
||||
this.connection = undefined;
|
||||
}
|
||||
|
||||
private conn(): ClientSideConnection {
|
||||
if (!this.connection) throw new Error('AcpClient not started');
|
||||
return this.connection;
|
||||
}
|
||||
|
||||
// The client side of ACP: the agent calls these on us. These read the CURRENT
|
||||
// handlers off `self` so follow-up prompts can swap them via setHandlers().
|
||||
private buildClient(): Client {
|
||||
const self = this;
|
||||
return {
|
||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
return self.broker.resolve(params);
|
||||
},
|
||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
self.onEvent(toEvent(params.update));
|
||||
},
|
||||
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
const content = await fs.readFile(params.path, 'utf8');
|
||||
return { content };
|
||||
},
|
||||
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
await fs.writeFile(params.path, params.content);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
186
apps/x/packages/core/src/code-mode/acp/manager.ts
Normal file
186
apps/x/packages/core/src/code-mode/acp/manager.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import type { ApprovalPolicy, CodeRunEvent, CodingAgent, PermissionAsk, PermissionDecision, RunPromptResult } from './types.js';
|
||||
import { AcpClient } from './client.js';
|
||||
import { PermissionBroker } from './permission-broker.js';
|
||||
import { readStoredSession, writeStoredSession, clearStoredSession } from './session-store.js';
|
||||
|
||||
export interface RunPromptArgs {
|
||||
runId: string;
|
||||
agent: CodingAgent;
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
policy: ApprovalPolicy;
|
||||
/** Called when the policy needs the user to decide (the "ask" path). */
|
||||
ask: (ask: PermissionAsk) => Promise<PermissionDecision>;
|
||||
/** Stream sink for this prompt's run. */
|
||||
onEvent: (event: CodeRunEvent) => void;
|
||||
/** Aborts the turn on stop; the manager cancels then force-kills the adapter. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface ActiveRun {
|
||||
client: AcpClient;
|
||||
sessionId: string;
|
||||
agent: CodingAgent;
|
||||
cwd: string;
|
||||
// Prompts currently streaming on this connection. Disposal is deferred while
|
||||
// this is > 0 so we never tear down a connection mid-turn.
|
||||
inflight: number;
|
||||
// Pending grace-window teardown, cleared if the run is reused before it fires.
|
||||
disposeTimer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
// How long a connection stays warm after its last turn ends before we tear it down.
|
||||
// A coding "turn" is one code_agent_run tool call; we keep the adapter briefly so
|
||||
// back-to-back calls within one copilot turn (edit -> test -> fix) and quick user
|
||||
// follow-ups reuse the warm connection instead of cold-starting. Set to 0 for strict
|
||||
// per-turn teardown. Context is never lost either way: the next turn resumes the
|
||||
// persisted session via session/load.
|
||||
const DISPOSE_GRACE_MS = 60_000;
|
||||
|
||||
// On stop, how long to let the adapter cancel gracefully (ACP session/cancel) before
|
||||
// we force-kill it. The kill guarantees the turn unwinds even if the adapter ignores
|
||||
// cancel or is blocked — otherwise a hung prompt would lock the chat indefinitely.
|
||||
const CANCEL_GRACE_MS = 2_000;
|
||||
|
||||
// Drives ACP coding sessions. A connection's lifetime is scoped to the agent turn
|
||||
// (one code_agent_run): it is torn down a short grace window after the turn ends, so
|
||||
// idle chats hold no adapter processes. Turns that land within the grace window reuse
|
||||
// the warm connection; anything colder (grace elapsed, or after an app restart)
|
||||
// resumes the persisted session via session/load.
|
||||
export class CodeModeManager {
|
||||
private readonly runs = new Map<string, ActiveRun>();
|
||||
|
||||
async runPrompt(args: RunPromptArgs): Promise<RunPromptResult> {
|
||||
const { runId, agent, cwd, prompt, policy, ask, onEvent, signal } = args;
|
||||
|
||||
const broker = new PermissionBroker({
|
||||
policy,
|
||||
ask,
|
||||
onResolved: (a, decision, auto) => onEvent({ type: 'permission', ask: a, decision, auto }),
|
||||
});
|
||||
|
||||
const run = await this.ensureRun(runId, agent, cwd, broker, onEvent);
|
||||
run.inflight++;
|
||||
|
||||
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let onAbort: (() => void) | undefined;
|
||||
try {
|
||||
const promptP = run.client.prompt(run.sessionId, prompt);
|
||||
// We may stop awaiting this prompt below (force-kill on stop rejects it);
|
||||
// attach a no-op catch so the orphaned rejection isn't flagged.
|
||||
promptP.catch(() => {});
|
||||
|
||||
// Stop handling: on abort, ask the adapter to cancel; if it hasn't unwound
|
||||
// within the grace, force-kill it and resolve as cancelled. This guarantees
|
||||
// the turn ends even if the adapter ignores cancel or is wedged — a hung
|
||||
// prompt would otherwise lock the chat (no run-stopped, composer disabled).
|
||||
const cancelledP = new Promise<{ stopReason: string }>((resolve) => {
|
||||
if (!signal) return;
|
||||
onAbort = () => {
|
||||
run.client.cancel(run.sessionId).catch(() => {});
|
||||
graceTimer = setTimeout(() => {
|
||||
this.dispose(runId);
|
||||
resolve({ stopReason: 'cancelled' });
|
||||
}, CANCEL_GRACE_MS);
|
||||
graceTimer.unref?.();
|
||||
};
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
|
||||
const res = await Promise.race([promptP, cancelledP]);
|
||||
return { stopReason: res.stopReason, sessionId: run.sessionId };
|
||||
} catch (e) {
|
||||
// A kill-induced "connection closed" during a stop is an expected cancel.
|
||||
if (signal?.aborted) return { stopReason: 'cancelled', sessionId: run.sessionId };
|
||||
throw e;
|
||||
} finally {
|
||||
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
|
||||
if (graceTimer) clearTimeout(graceTimer);
|
||||
run.inflight--;
|
||||
this.scheduleDispose(runId);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(runId: string): void {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) return;
|
||||
this.cancelDispose(run);
|
||||
run.client.dispose();
|
||||
this.runs.delete(runId);
|
||||
}
|
||||
|
||||
// Tear down the connection a grace window after its last turn ends. Skipped while a
|
||||
// prompt is still streaming, and re-armed when each turn ends so the window measures
|
||||
// idle-since-last-activity. With grace 0 we dispose immediately (strict per-turn).
|
||||
private scheduleDispose(runId: string): void {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run || run.inflight > 0) return;
|
||||
this.cancelDispose(run);
|
||||
if (DISPOSE_GRACE_MS <= 0) {
|
||||
this.dispose(runId);
|
||||
return;
|
||||
}
|
||||
run.disposeTimer = setTimeout(() => {
|
||||
const r = this.runs.get(runId);
|
||||
if (r && r.inflight === 0) this.dispose(runId);
|
||||
}, DISPOSE_GRACE_MS);
|
||||
// A pending teardown timer must not keep the process alive at quit.
|
||||
run.disposeTimer.unref?.();
|
||||
}
|
||||
|
||||
private cancelDispose(run: ActiveRun): void {
|
||||
if (run.disposeTimer) {
|
||||
clearTimeout(run.disposeTimer);
|
||||
run.disposeTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
disposeAll(): void {
|
||||
for (const runId of [...this.runs.keys()]) this.dispose(runId);
|
||||
}
|
||||
|
||||
// Reuse the warm connection if it matches; otherwise (cold start, or the user
|
||||
// switched agent/cwd for this chat) build a fresh one and create-or-resume its session.
|
||||
private async ensureRun(
|
||||
runId: string,
|
||||
agent: CodingAgent,
|
||||
cwd: string,
|
||||
broker: PermissionBroker,
|
||||
onEvent: (event: CodeRunEvent) => void,
|
||||
): Promise<ActiveRun> {
|
||||
const existing = this.runs.get(runId);
|
||||
if (existing && existing.agent === agent && existing.cwd === cwd) {
|
||||
this.cancelDispose(existing); // reused before its grace window elapsed
|
||||
existing.client.setHandlers(broker, onEvent);
|
||||
return existing;
|
||||
}
|
||||
if (existing) this.dispose(runId); // agent/cwd changed — start over
|
||||
|
||||
const client = new AcpClient({ agent, cwd, broker, onEvent });
|
||||
await client.start();
|
||||
|
||||
const sessionId = await this.openSession(runId, agent, cwd, client);
|
||||
const run: ActiveRun = { client, sessionId, agent, cwd, inflight: 0 };
|
||||
this.runs.set(runId, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
// Resume the persisted session for this chat when possible; else start a new one
|
||||
// and persist its id so a later restart can resume it.
|
||||
private async openSession(runId: string, agent: CodingAgent, cwd: string, client: AcpClient): Promise<string> {
|
||||
const stored = await readStoredSession(runId);
|
||||
if (stored && stored.agent === agent && stored.cwd === cwd && client.loadSupported) {
|
||||
try {
|
||||
await client.loadSession(stored.sessionId);
|
||||
return stored.sessionId;
|
||||
} catch {
|
||||
// Stored session is stale/unloadable — fall through to a fresh one.
|
||||
await clearStoredSession(runId);
|
||||
}
|
||||
}
|
||||
const sessionId = await client.newSession();
|
||||
await writeStoredSession({ runId, agent, cwd, sessionId });
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
91
apps/x/packages/core/src/code-mode/acp/permission-broker.ts
Normal file
91
apps/x/packages/core/src/code-mode/acp/permission-broker.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import type {
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
PermissionOption,
|
||||
PermissionOptionKind,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { ApprovalPolicy, PermissionDecision, PermissionAsk } from './types.js';
|
||||
|
||||
// Tool kinds that don't mutate anything — eligible for `auto-approve-reads`.
|
||||
const READ_KINDS = new Set(['read', 'search', 'fetch', 'think']);
|
||||
|
||||
function toAsk(request: RequestPermissionRequest): PermissionAsk {
|
||||
const tc = request.toolCall;
|
||||
const kind = tc.kind ?? undefined;
|
||||
const title = tc.title ?? kind ?? 'Tool call';
|
||||
return {
|
||||
toolCallId: tc.toolCallId ?? undefined,
|
||||
title,
|
||||
kind,
|
||||
isRead: kind ? READ_KINDS.has(kind) : false,
|
||||
};
|
||||
}
|
||||
|
||||
// Map a desired decision to one of the options the agent actually offered.
|
||||
// Agents may offer only a subset (e.g. allow_once + reject_once, no allow_always),
|
||||
// so we fall back within the same allow/reject family before giving up.
|
||||
function pickOption(options: PermissionOption[], decision: PermissionDecision): PermissionOption | undefined {
|
||||
const order: Record<PermissionDecision, PermissionOptionKind[]> = {
|
||||
allow_always: ['allow_always', 'allow_once'],
|
||||
allow_once: ['allow_once', 'allow_always'],
|
||||
reject: ['reject_once', 'reject_always'],
|
||||
};
|
||||
for (const kind of order[decision]) {
|
||||
const found = options.find((o) => o.kind === kind);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function selected(optionId: string): RequestPermissionResponse {
|
||||
return { outcome: { outcome: 'selected', optionId } };
|
||||
}
|
||||
|
||||
// A request's identity for "always allow" memory: prefer tool kind, else title.
|
||||
function memoryKey(ask: PermissionAsk): string {
|
||||
return ask.kind ? `kind:${ask.kind}` : `title:${ask.title}`;
|
||||
}
|
||||
|
||||
export interface PermissionBrokerOptions {
|
||||
policy: ApprovalPolicy;
|
||||
// Called only when the policy can't decide on its own (the "ask" path).
|
||||
ask: (ask: PermissionAsk) => Promise<PermissionDecision>;
|
||||
// Notified of every resolved request so the engine can emit a stream event.
|
||||
onResolved?: (ask: PermissionAsk, decision: PermissionDecision, auto: boolean) => void;
|
||||
}
|
||||
|
||||
// Decides how to answer the agent's requestPermission calls. Holds per-session
|
||||
// "always allow" memory so a one-time approval sticks for the rest of the run.
|
||||
export class PermissionBroker {
|
||||
private readonly opts: PermissionBrokerOptions;
|
||||
private readonly alwaysAllow = new Set<string>();
|
||||
|
||||
constructor(opts: PermissionBrokerOptions) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
async resolve(request: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
const ask = toAsk(request);
|
||||
const key = memoryKey(ask);
|
||||
|
||||
const finish = (decision: PermissionDecision, auto: boolean): RequestPermissionResponse => {
|
||||
if (decision === 'allow_always') this.alwaysAllow.add(key);
|
||||
this.opts.onResolved?.(ask, decision, auto);
|
||||
const opt = pickOption(request.options, decision);
|
||||
// If the agent offered no matching option we fall back to its first one
|
||||
// (don't deadlock the turn); decision precedence above keeps this rare.
|
||||
return selected(opt?.optionId ?? request.options[0]?.optionId ?? '');
|
||||
};
|
||||
|
||||
// 1. Sticky "always allow" from earlier this session.
|
||||
if (this.alwaysAllow.has(key)) return finish('allow_always', true);
|
||||
|
||||
// 2. Policy-level auto decisions.
|
||||
if (this.opts.policy === 'yolo') return finish('allow_always', true);
|
||||
if (this.opts.policy === 'auto-approve-reads' && ask.isRead) return finish('allow_once', true);
|
||||
|
||||
// 3. Ask the user.
|
||||
const decision = await this.opts.ask(ask);
|
||||
return finish(decision, false);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { PermissionDecision } from './types.js';
|
||||
|
||||
interface Pending {
|
||||
runId: string;
|
||||
resolve: (decision: PermissionDecision) => void;
|
||||
}
|
||||
|
||||
// Holds in-flight mid-run permission asks. The agent (via the broker) calls
|
||||
// request() which BLOCKS the coding turn until the user answers; the renderer's
|
||||
// answer arrives over IPC and calls resolve(). This is separate from the LLM
|
||||
// tool-loop's pre-call permission gate, which can't model a mid-execution wait.
|
||||
export class CodePermissionRegistry {
|
||||
private readonly pending = new Map<string, Pending>();
|
||||
private counter = 0;
|
||||
|
||||
// Register a pending ask, hand the generated requestId to `emit` (so the caller
|
||||
// can publish the UI event), and resolve once the user answers.
|
||||
request(runId: string, emit: (requestId: string) => void): Promise<PermissionDecision> {
|
||||
const requestId = `cpr-${runId}-${++this.counter}`;
|
||||
return new Promise<PermissionDecision>((resolve) => {
|
||||
this.pending.set(requestId, { runId, resolve });
|
||||
emit(requestId);
|
||||
});
|
||||
}
|
||||
|
||||
// Called from the IPC handler when the user answers a card.
|
||||
resolve(requestId: string, decision: PermissionDecision): void {
|
||||
const entry = this.pending.get(requestId);
|
||||
if (!entry) return;
|
||||
this.pending.delete(requestId);
|
||||
entry.resolve(decision);
|
||||
}
|
||||
|
||||
// On run stop/cancel: reject anything still waiting so the turn can unwind.
|
||||
cancelRun(runId: string): void {
|
||||
for (const [id, entry] of [...this.pending]) {
|
||||
if (entry.runId === runId) {
|
||||
this.pending.delete(id);
|
||||
entry.resolve('reject');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
apps/x/packages/core/src/code-mode/acp/session-store.ts
Normal file
48
apps/x/packages/core/src/code-mode/acp/session-store.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
import type { CodingAgent } from './types.js';
|
||||
|
||||
// One ACP session is pinned per chat run. We persist its sessionId (plus the agent
|
||||
// and cwd it belongs to) so reopening the chat after an app restart can resume the
|
||||
// same agent context via session/load instead of starting over.
|
||||
export interface StoredSession {
|
||||
runId: string;
|
||||
agent: CodingAgent;
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// Per-run ACP session state lives in its own directory (not WorkDir/config): it's
|
||||
// runtime state that accumulates one file per chat run, so it's kept separate from
|
||||
// user/app config to be listed and cleaned up on its own.
|
||||
const SESSIONS_DIR = path.join(WorkDir, 'code-mode', 'sessions');
|
||||
|
||||
function sessionFile(runId: string): string {
|
||||
return path.join(SESSIONS_DIR, `${runId}.json`);
|
||||
}
|
||||
|
||||
export async function readStoredSession(runId: string): Promise<StoredSession | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(sessionFile(runId), 'utf8');
|
||||
const parsed = JSON.parse(raw) as StoredSession;
|
||||
if (parsed && parsed.sessionId && parsed.agent && parsed.cwd) return parsed;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeStoredSession(session: StoredSession): Promise<void> {
|
||||
const file = sessionFile(session.runId);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, JSON.stringify(session, null, 2));
|
||||
}
|
||||
|
||||
export async function clearStoredSession(runId: string): Promise<void> {
|
||||
try {
|
||||
await fs.rm(sessionFile(runId), { force: true });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
11
apps/x/packages/core/src/code-mode/acp/types.ts
Normal file
11
apps/x/packages/core/src/code-mode/acp/types.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Rowboat-facing types for the ACP code-mode engine. The schemas live in
|
||||
// @x/shared (so the IPC/renderer layers share them); we re-export the inferred
|
||||
// types here so the engine modules import from one local barrel.
|
||||
export type {
|
||||
CodingAgent,
|
||||
ApprovalPolicy,
|
||||
PermissionDecision,
|
||||
PermissionAsk,
|
||||
CodeRunEvent,
|
||||
RunPromptResult,
|
||||
} from '@x/shared/dist/code-mode.js';
|
||||
|
|
@ -12,7 +12,7 @@ const execAsync = promisify(exec);
|
|||
// We scan these directly because Electron's spawned shell sometimes doesn't
|
||||
// inherit the user's full PATH (especially on macOS GUI launches, and even on
|
||||
// Windows when global npm prefix isn't propagated to system PATH).
|
||||
function commonInstallPaths(binary: string): string[] {
|
||||
export function commonInstallPaths(binary: string): string[] {
|
||||
const home = os.homedir();
|
||||
if (process.platform === 'win32') {
|
||||
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import z from "zod";
|
||||
import { ApprovalPolicy } from "@x/shared/dist/code-mode.js";
|
||||
|
||||
export const CodeModeConfig = z.object({
|
||||
enabled: z.boolean(),
|
||||
// How the ACP engine answers the coding agent's permission requests.
|
||||
// Optional for back-compat; the tool defaults to "ask" when unset.
|
||||
approvalPolicy: ApprovalPolicy.optional(),
|
||||
});
|
||||
export type CodeModeConfig = z.infer<typeof CodeModeConfig>;
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js
|
|||
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
|
||||
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
||||
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
|
||||
import { CodeModeManager } from "../code-mode/acp/manager.js";
|
||||
import { CodePermissionRegistry } from "../code-mode/acp/permission-registry.js";
|
||||
import type { IBrowserControlService } from "../application/browser-control/service.js";
|
||||
import type { INotificationService } from "../application/notification/service.js";
|
||||
|
||||
|
|
@ -43,6 +45,12 @@ container.register({
|
|||
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
|
||||
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
|
||||
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
|
||||
|
||||
// ACP code-mode engine: the manager holds a live agent connection per chat only
|
||||
// around an active turn (torn down after a short idle grace; resumed via
|
||||
// session/load); the registry brokers mid-run approvals.
|
||||
codeModeManager: asClass(CodeModeManager).singleton(),
|
||||
codePermissionRegistry: asClass(CodePermissionRegistry).singleton(),
|
||||
});
|
||||
|
||||
export default container;
|
||||
|
|
|
|||
348
apps/x/packages/core/src/knowledge/gmail_contacts.ts
Normal file
348
apps/x/packages/core/src/knowledge/gmail_contacts.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
import fs from 'fs';
|
||||
import fsp from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import type { GmailThreadSnapshot } from './sync_gmail.js';
|
||||
import { getAccountEmail } from './sync_gmail.js';
|
||||
|
||||
const CACHE_DIR = path.join(WorkDir, 'inbox_lists');
|
||||
const INDEX_TTL_MS = 5 * 60 * 1000;
|
||||
const RECENCY_HALFLIFE_DAYS = 60;
|
||||
const READ_CONCURRENCY = 16;
|
||||
|
||||
export interface Contact {
|
||||
name: string;
|
||||
email: string;
|
||||
count: number;
|
||||
lastSeenMs: number;
|
||||
}
|
||||
|
||||
interface IndexEntry {
|
||||
name: string;
|
||||
email: string;
|
||||
count: number;
|
||||
lastSeenMs: number;
|
||||
nameCounts: Map<string, number>;
|
||||
}
|
||||
|
||||
let cachedIndex: Map<string, IndexEntry> | null = null;
|
||||
let cachedAt = 0;
|
||||
let pendingRebuild: Promise<Map<string, IndexEntry>> | null = null;
|
||||
|
||||
function parseAddressList(header: string): Array<{ name: string; email: string }> {
|
||||
if (!header) return [];
|
||||
const parts: string[] = [];
|
||||
let buf = '';
|
||||
let inQuotes = false;
|
||||
let inBrackets = 0;
|
||||
for (const ch of header) {
|
||||
if (ch === '"' && inBrackets === 0) inQuotes = !inQuotes;
|
||||
else if (ch === '<') inBrackets++;
|
||||
else if (ch === '>') inBrackets = Math.max(0, inBrackets - 1);
|
||||
if (ch === ',' && !inQuotes && inBrackets === 0) {
|
||||
if (buf.trim()) parts.push(buf.trim());
|
||||
buf = '';
|
||||
} else {
|
||||
buf += ch;
|
||||
}
|
||||
}
|
||||
if (buf.trim()) parts.push(buf.trim());
|
||||
|
||||
const result: Array<{ name: string; email: string }> = [];
|
||||
for (const part of parts) {
|
||||
const angled = part.match(/^(.*?)<\s*([^>]+?)\s*>\s*$/);
|
||||
if (angled) {
|
||||
const name = angled[1].trim().replace(/^"|"$/g, '').trim();
|
||||
const email = angled[2].trim().toLowerCase();
|
||||
if (email.includes('@')) result.push({ name, email });
|
||||
} else if (part.includes('@')) {
|
||||
result.push({ name: '', email: part.trim().toLowerCase() });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Local-part aliases that are almost always automated/role addresses you don't
|
||||
// compose a fresh message to. Matched as a whole segment of the local part
|
||||
// (segments split on . _ - +).
|
||||
const AUTOMATED_LOCAL_PARTS = new Set([
|
||||
'noreply', 'no-reply', 'donotreply', 'do-not-reply', 'reply',
|
||||
'notifications', 'notification', 'notify',
|
||||
'alerts', 'alert', 'updates', 'update',
|
||||
'news', 'newsletter', 'newsletters',
|
||||
'info', 'information', 'hello', 'hi', 'hey',
|
||||
'welcome', 'onboarding', 'getstarted',
|
||||
'team', 'marketing', 'promo', 'promos', 'promotions',
|
||||
'offer', 'offers', 'deals', 'deal',
|
||||
'accounts', 'account', 'billing', 'invoices', 'statements', 'statement',
|
||||
'learn', 'learning', 'courses',
|
||||
'mailer-daemon', 'mailerdaemon', 'postmaster', 'bounce', 'bounces',
|
||||
'automated', 'auto', 'autoconfirm',
|
||||
'support-bot', 'noticeboard', 'system',
|
||||
'contact', 'connect',
|
||||
'sender', 'broadcast', 'digest', 'campaign', 'campaigns',
|
||||
'support', 'service', 'help', 'helpdesk', 'feedback',
|
||||
'mailer', 'mailers', 'members', 'membership',
|
||||
'careers', 'jobs', 'recruit', 'recruiting',
|
||||
'tickets', 'orders', 'order', 'receipts', 'receipt',
|
||||
'applications', 'apply', 'admissions',
|
||||
'health', 'security', 'auth',
|
||||
]);
|
||||
|
||||
// Subdomain labels that flag a bulk/marketing infrastructure domain.
|
||||
const AUTOMATED_SUBDOMAIN_LABELS = new Set([
|
||||
'mail', 'mailer', 'mailers', 'mailing', 'mailgun', 'sendgrid', 'mta',
|
||||
'email', 'em', 'e', 'm',
|
||||
'news', 'newsletter', 'newsletters',
|
||||
'marketing', 'mkt', 'promo', 'promos', 'offers',
|
||||
'event', 'events', 'ecomm', 'commerce',
|
||||
'notifications', 'notification', 'notify', 'alerts', 'alert', 'updates',
|
||||
'messaging', 'message', 'msg',
|
||||
'noreply', 'donotreply',
|
||||
'creators', 'partners', 'team',
|
||||
'info', 'welcome', 'hi', 'hello',
|
||||
'bounces', 'bounce',
|
||||
'reply', 'user', 'usr', 'auto',
|
||||
]);
|
||||
|
||||
// Specific bulk-mail provider domains (substring match on full domain).
|
||||
const AUTOMATED_DOMAIN_KEYWORDS = [
|
||||
'facebookmail', 'kajabimail', 'substack', 'mailgun', 'sendgrid',
|
||||
'mcsv.net', 'mailchimp', 'mailerlite', 'createsend', 'cmail',
|
||||
'amazonses', 'sparkpost', 'sendinblue', 'brevo',
|
||||
'luma-mail', 'lumamail',
|
||||
'umusic-online', 'icloud-mail',
|
||||
];
|
||||
|
||||
function localSegments(local: string): string[] {
|
||||
return local.toLowerCase().split(/[._\-+]/).filter(Boolean);
|
||||
}
|
||||
|
||||
function isAutomatedAddress(email: string): boolean {
|
||||
if (!email) return true;
|
||||
const at = email.indexOf('@');
|
||||
if (at < 0) return true;
|
||||
const local = email.slice(0, at).toLowerCase();
|
||||
const domain = email.slice(at + 1).toLowerCase();
|
||||
|
||||
// Plus-aliased reply bots: `reply+abc123@…`
|
||||
if (/^reply\+/i.test(local)) return true;
|
||||
|
||||
// Whole-segment local-part matches.
|
||||
const segs = localSegments(local);
|
||||
for (const s of segs) {
|
||||
if (AUTOMATED_LOCAL_PARTS.has(s)) return true;
|
||||
}
|
||||
// Some senders pack noise into the local part with no separators
|
||||
// (e.g. `hdfcbanksmartstatement`). Catch the common ones.
|
||||
if (/(no.?reply|do.?not.?reply|notifications?|news.?letter|mailer.?daemon|postmaster|automated|broadcast|statement)/i.test(local)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Random-looking machine local parts: long, mostly hex/base32-ish.
|
||||
if (local.length >= 20 && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(local) && /[0-9]/.test(local)) {
|
||||
const digits = (local.match(/[0-9]/g) || []).length;
|
||||
if (digits / local.length >= 0.25) return true;
|
||||
}
|
||||
|
||||
// Subdomain-label check (everything except the registrable last two labels).
|
||||
const labels = domain.split('.');
|
||||
if (labels.length >= 3) {
|
||||
const subs = labels.slice(0, -2);
|
||||
for (const label of subs) {
|
||||
if (AUTOMATED_SUBDOMAIN_LABELS.has(label)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Provider keyword anywhere in the domain.
|
||||
for (const kw of AUTOMATED_DOMAIN_KEYWORDS) {
|
||||
if (domain.includes(kw)) return true;
|
||||
}
|
||||
|
||||
// Domain itself contains tell-tale tokens.
|
||||
if (/(^|\.)(mailers?|mailer|mailgun|sendgrid|mailchimp|mailerlite|bounces?|marketing|promo|notifications?|newsletter)(\.|$)/i.test(domain)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Marketing-style TLD / second-level domain (e.g. bookmyshow.email,
|
||||
// foo.marketing, bar.news). These domains exist almost exclusively for bulk.
|
||||
const sld = labels[labels.length - 1];
|
||||
if (['email', 'mail', 'marketing', 'promo', 'news', 'newsletter', 'click', 'link'].includes(sld)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Brand-identity addresses like `uber@uber.com`, `lenovo@lenovo.com` —
|
||||
// local part equals the first label of the domain. Almost always a
|
||||
// transactional/marketing sender.
|
||||
if (labels.length >= 2 && local === labels[0]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function ingestSnapshot(snapshot: GmailThreadSnapshot, selfEmail: string, map: Map<string, IndexEntry>): void {
|
||||
if (!snapshot?.messages) return;
|
||||
for (const msg of snapshot.messages) {
|
||||
const parsed = msg.date ? Date.parse(msg.date) : NaN;
|
||||
const ts = Number.isFinite(parsed) ? parsed : 0;
|
||||
const fromAddrs = msg.from ? parseAddressList(msg.from) : [];
|
||||
const sentBySelf = fromAddrs.some((a) => a.email === selfEmail);
|
||||
|
||||
// Collect candidate contacts. For outbound mail, take recipients (the
|
||||
// people *you* chose to write to — highest signal). For inbound mail,
|
||||
// take the sender, but only if it doesn't look like a no-reply bot.
|
||||
const candidates: Array<{ name: string; email: string }> = [];
|
||||
if (sentBySelf) {
|
||||
for (const h of [msg.to, msg.cc].filter(Boolean) as string[]) {
|
||||
candidates.push(...parseAddressList(h));
|
||||
}
|
||||
} else {
|
||||
for (const a of fromAddrs) candidates.push(a);
|
||||
}
|
||||
|
||||
for (const { name, email } of candidates) {
|
||||
if (!email || email === selfEmail) continue;
|
||||
if (isAutomatedAddress(email)) continue;
|
||||
let entry = map.get(email);
|
||||
if (!entry) {
|
||||
entry = { name, email, count: 0, lastSeenMs: 0, nameCounts: new Map() };
|
||||
map.set(email, entry);
|
||||
}
|
||||
// Sent-to addresses carry stronger signal than inbound senders.
|
||||
entry.count += sentBySelf ? 3 : 1;
|
||||
if (ts > entry.lastSeenMs) entry.lastSeenMs = ts;
|
||||
if (name) entry.nameCounts.set(name, (entry.nameCounts.get(name) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildIndex(): Promise<Map<string, IndexEntry>> {
|
||||
const map = new Map<string, IndexEntry>();
|
||||
if (!fs.existsSync(CACHE_DIR)) return map;
|
||||
|
||||
// Without a self email we can't tell which messages were sent by the user,
|
||||
// so the index stays empty until Gmail is connected.
|
||||
const selfRaw = await getAccountEmail().catch(() => null);
|
||||
if (!selfRaw) return map;
|
||||
const selfEmail = selfRaw.trim().toLowerCase();
|
||||
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fsp.readdir(CACHE_DIR);
|
||||
} catch {
|
||||
return map;
|
||||
}
|
||||
|
||||
const files = names.filter((n) => n.endsWith('.json'));
|
||||
// Cap concurrency so a huge inbox can't blow the FD table.
|
||||
for (let i = 0; i < files.length; i += READ_CONCURRENCY) {
|
||||
const slice = files.slice(i, i + READ_CONCURRENCY);
|
||||
const chunks = await Promise.all(
|
||||
slice.map(async (fname) => {
|
||||
try {
|
||||
return await fsp.readFile(path.join(CACHE_DIR, fname), 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const raw of chunks) {
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const wrapper = JSON.parse(raw) as { snapshot?: GmailThreadSnapshot };
|
||||
if (wrapper.snapshot) ingestSnapshot(wrapper.snapshot, selfEmail, map);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of map.values()) {
|
||||
let best = entry.name;
|
||||
let bestN = 0;
|
||||
for (const [n, c] of entry.nameCounts) {
|
||||
if (c > bestN) { best = n; bestN = c; }
|
||||
}
|
||||
entry.name = best;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function getIndex(): Promise<Map<string, IndexEntry>> {
|
||||
const now = Date.now();
|
||||
const fresh = cachedIndex && now - cachedAt <= INDEX_TTL_MS;
|
||||
if (fresh) return cachedIndex!;
|
||||
|
||||
// Serve stale cache while a refresh runs in the background; only block when
|
||||
// there's no cache at all.
|
||||
if (!pendingRebuild) {
|
||||
pendingRebuild = rebuildIndex().then((m) => {
|
||||
cachedIndex = m;
|
||||
cachedAt = Date.now();
|
||||
pendingRebuild = null;
|
||||
return m;
|
||||
}).catch((err) => {
|
||||
pendingRebuild = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
if (cachedIndex) return cachedIndex;
|
||||
return pendingRebuild;
|
||||
}
|
||||
|
||||
export function invalidateContactIndex(): void {
|
||||
cachedIndex = null;
|
||||
cachedAt = 0;
|
||||
}
|
||||
|
||||
// Warm the cache eagerly so the first user keystroke doesn't pay the cost.
|
||||
export function warmContactIndex(): void {
|
||||
void getIndex().catch(() => {});
|
||||
}
|
||||
|
||||
function score(entry: IndexEntry, nowMs: number): number {
|
||||
const days = Math.max(0, (nowMs - entry.lastSeenMs) / (1000 * 60 * 60 * 24));
|
||||
const recency = Math.pow(0.5, days / RECENCY_HALFLIFE_DAYS);
|
||||
return entry.count * (0.5 + 0.5 * recency);
|
||||
}
|
||||
|
||||
function matchTier(q: string, entry: IndexEntry): number {
|
||||
if (!q) return 3;
|
||||
const name = entry.name.toLowerCase();
|
||||
const email = entry.email;
|
||||
if (name && name.startsWith(q)) return 0;
|
||||
if (email.startsWith(q)) return 1;
|
||||
if (name && name.includes(' ' + q)) return 1;
|
||||
if (name && name.includes(q)) return 2;
|
||||
if (email.includes(q)) return 3;
|
||||
return -1;
|
||||
}
|
||||
|
||||
export interface SearchOpts {
|
||||
limit?: number;
|
||||
excludeEmails?: string[];
|
||||
}
|
||||
|
||||
export async function searchContacts(query: string, opts: SearchOpts = {}): Promise<Contact[]> {
|
||||
const q = query.trim().toLowerCase();
|
||||
const limit = Math.max(1, Math.min(50, opts.limit ?? 8));
|
||||
const excluded = new Set((opts.excludeEmails ?? []).map((e) => e.trim().toLowerCase()));
|
||||
|
||||
const index = await getIndex();
|
||||
const nowMs = Date.now();
|
||||
const matches: Array<{ entry: IndexEntry; tier: number; s: number }> = [];
|
||||
for (const entry of index.values()) {
|
||||
if (excluded.has(entry.email)) continue;
|
||||
const tier = matchTier(q, entry);
|
||||
if (tier < 0) continue;
|
||||
matches.push({ entry, tier, s: score(entry, nowMs) });
|
||||
}
|
||||
matches.sort((a, b) => (a.tier - b.tier) || (b.s - a.s));
|
||||
return matches.slice(0, limit).map(({ entry }) => ({
|
||||
name: entry.name,
|
||||
email: entry.email,
|
||||
count: entry.count,
|
||||
lastSeenMs: entry.lastSeenMs,
|
||||
}));
|
||||
}
|
||||
388
apps/x/packages/core/src/knowledge/gmail_sent_contacts.ts
Normal file
388
apps/x/packages/core/src/knowledge/gmail_sent_contacts.ts
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
import fs from 'fs';
|
||||
import fsp from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { google, gmail_v1 as gmail } from 'googleapis';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { GoogleClientFactory } from './google-client-factory.js';
|
||||
import { getUserEmail } from './classify_thread.js';
|
||||
|
||||
const STATE_FILE = path.join(WorkDir, 'contacts_sent.json');
|
||||
const RECENCY_HALFLIFE_DAYS = 60;
|
||||
const HEADER_FETCH_CONCURRENCY = 8;
|
||||
const REFRESH_INTERVAL_MS = 30 * 60 * 1000;
|
||||
|
||||
export interface Contact {
|
||||
name: string;
|
||||
email: string;
|
||||
count: number;
|
||||
lastSeenMs: number;
|
||||
}
|
||||
|
||||
interface StoredEntry {
|
||||
name: string;
|
||||
email: string;
|
||||
count: number;
|
||||
lastSeenMs: number;
|
||||
nameCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
interface StoredState {
|
||||
version: 1;
|
||||
historyId: string | null;
|
||||
selfEmail: string | null;
|
||||
lastFullSyncAt: number;
|
||||
entries: StoredEntry[];
|
||||
}
|
||||
|
||||
interface IndexEntry {
|
||||
name: string;
|
||||
email: string;
|
||||
count: number;
|
||||
lastSeenMs: number;
|
||||
nameCounts: Map<string, number>;
|
||||
}
|
||||
|
||||
let cachedIndex: Map<string, IndexEntry> | null = null;
|
||||
let lastRefreshAt = 0;
|
||||
let pendingSync: Promise<void> | null = null;
|
||||
|
||||
// Parses an address-list header value, respecting quoted display names and
|
||||
// angle brackets ("Last, First" <a@b>, …).
|
||||
function parseAddressList(header: string): Array<{ name: string; email: string }> {
|
||||
if (!header) return [];
|
||||
const parts: string[] = [];
|
||||
let buf = '';
|
||||
let inQuotes = false;
|
||||
let inBrackets = 0;
|
||||
for (const ch of header) {
|
||||
if (ch === '"' && inBrackets === 0) inQuotes = !inQuotes;
|
||||
else if (ch === '<') inBrackets++;
|
||||
else if (ch === '>') inBrackets = Math.max(0, inBrackets - 1);
|
||||
if (ch === ',' && !inQuotes && inBrackets === 0) {
|
||||
if (buf.trim()) parts.push(buf.trim());
|
||||
buf = '';
|
||||
} else {
|
||||
buf += ch;
|
||||
}
|
||||
}
|
||||
if (buf.trim()) parts.push(buf.trim());
|
||||
|
||||
const out: Array<{ name: string; email: string }> = [];
|
||||
for (const part of parts) {
|
||||
const angled = part.match(/^(.*?)<\s*([^>]+?)\s*>\s*$/);
|
||||
if (angled) {
|
||||
const name = angled[1].trim().replace(/^"|"$/g, '').trim();
|
||||
const email = angled[2].trim().toLowerCase();
|
||||
if (email.includes('@')) out.push({ name, email });
|
||||
} else if (part.includes('@')) {
|
||||
out.push({ name: '', email: part.trim().toLowerCase() });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadState(): StoredState | null {
|
||||
try {
|
||||
if (!fs.existsSync(STATE_FILE)) return null;
|
||||
const raw = fs.readFileSync(STATE_FILE, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as StoredState;
|
||||
if (parsed.version !== 1) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveState(state: StoredState): Promise<void> {
|
||||
const tmp = STATE_FILE + '.tmp';
|
||||
await fsp.mkdir(path.dirname(STATE_FILE), { recursive: true });
|
||||
await fsp.writeFile(tmp, JSON.stringify(state), 'utf-8');
|
||||
await fsp.rename(tmp, STATE_FILE);
|
||||
}
|
||||
|
||||
function indexFromStored(state: StoredState): Map<string, IndexEntry> {
|
||||
const map = new Map<string, IndexEntry>();
|
||||
for (const e of state.entries) {
|
||||
map.set(e.email, {
|
||||
name: e.name,
|
||||
email: e.email,
|
||||
count: e.count,
|
||||
lastSeenMs: e.lastSeenMs,
|
||||
nameCounts: new Map(Object.entries(e.nameCounts || {})),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function storedFromIndex(map: Map<string, IndexEntry>, historyId: string | null, selfEmail: string | null, lastFullSyncAt: number): StoredState {
|
||||
const entries: StoredEntry[] = [];
|
||||
for (const e of map.values()) {
|
||||
entries.push({
|
||||
name: e.name,
|
||||
email: e.email,
|
||||
count: e.count,
|
||||
lastSeenMs: e.lastSeenMs,
|
||||
nameCounts: Object.fromEntries(e.nameCounts),
|
||||
});
|
||||
}
|
||||
return { version: 1, historyId, selfEmail, lastFullSyncAt, entries };
|
||||
}
|
||||
|
||||
function promoteCanonicalNames(map: Map<string, IndexEntry>): void {
|
||||
for (const entry of map.values()) {
|
||||
let best = entry.name;
|
||||
let bestN = 0;
|
||||
for (const [n, c] of entry.nameCounts) {
|
||||
if (c > bestN) { best = n; bestN = c; }
|
||||
}
|
||||
entry.name = best;
|
||||
}
|
||||
}
|
||||
|
||||
// Pulls the To/Cc/Date headers for a single sent message and folds the parsed
|
||||
// recipients into the index.
|
||||
async function ingestMessage(
|
||||
client: gmail.Gmail,
|
||||
messageId: string,
|
||||
selfEmail: string,
|
||||
map: Map<string, IndexEntry>,
|
||||
): Promise<void> {
|
||||
const res = await client.users.messages.get({
|
||||
userId: 'me',
|
||||
id: messageId,
|
||||
format: 'metadata',
|
||||
metadataHeaders: ['To', 'Cc', 'Date'],
|
||||
});
|
||||
const headers = res.data.payload?.headers ?? [];
|
||||
const headerValue = (name: string) => headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? '';
|
||||
|
||||
const dateStr = headerValue('Date');
|
||||
const parsedDate = dateStr ? Date.parse(dateStr) : NaN;
|
||||
const ts = Number.isFinite(parsedDate) ? parsedDate : Date.now();
|
||||
|
||||
const recipients = [
|
||||
...parseAddressList(headerValue('To')),
|
||||
...parseAddressList(headerValue('Cc')),
|
||||
];
|
||||
for (const { name, email } of recipients) {
|
||||
if (!email || email === selfEmail) continue;
|
||||
let entry = map.get(email);
|
||||
if (!entry) {
|
||||
entry = { name, email, count: 0, lastSeenMs: 0, nameCounts: new Map() };
|
||||
map.set(email, entry);
|
||||
}
|
||||
entry.count++;
|
||||
if (ts > entry.lastSeenMs) entry.lastSeenMs = ts;
|
||||
if (name) entry.nameCounts.set(name, (entry.nameCounts.get(name) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function processInBatches<T>(items: T[], size: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
const slice = items.slice(i, i + size);
|
||||
await Promise.all(slice.map(async (item) => {
|
||||
try { await fn(item); }
|
||||
catch { /* skip failed individual messages */ }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function fullSync(auth: OAuth2Client, selfEmail: string): Promise<{ map: Map<string, IndexEntry>; historyId: string | null }> {
|
||||
const client = google.gmail({ version: 'v1', auth });
|
||||
|
||||
// Lock in the current historyId BEFORE we start listing, so any messages
|
||||
// sent during the sync get caught by the next incremental run.
|
||||
let startingHistoryId: string | null = null;
|
||||
try {
|
||||
const profile = await client.users.getProfile({ userId: 'me' });
|
||||
startingHistoryId = profile.data.historyId ?? null;
|
||||
} catch {
|
||||
startingHistoryId = null;
|
||||
}
|
||||
|
||||
const messageIds: string[] = [];
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
const res = await client.users.messages.list({
|
||||
userId: 'me',
|
||||
labelIds: ['SENT'],
|
||||
maxResults: 500,
|
||||
pageToken,
|
||||
});
|
||||
for (const m of res.data.messages ?? []) {
|
||||
if (m.id) messageIds.push(m.id);
|
||||
}
|
||||
pageToken = res.data.nextPageToken ?? undefined;
|
||||
} while (pageToken);
|
||||
|
||||
const map = new Map<string, IndexEntry>();
|
||||
await processInBatches(messageIds, HEADER_FETCH_CONCURRENCY, (id) => ingestMessage(client, id, selfEmail, map));
|
||||
promoteCanonicalNames(map);
|
||||
return { map, historyId: startingHistoryId };
|
||||
}
|
||||
|
||||
async function incrementalSync(
|
||||
auth: OAuth2Client,
|
||||
selfEmail: string,
|
||||
startHistoryId: string,
|
||||
map: Map<string, IndexEntry>,
|
||||
): Promise<{ historyId: string | null; added: number } | null> {
|
||||
const client = google.gmail({ version: 'v1', auth });
|
||||
const added: string[] = [];
|
||||
let pageToken: string | undefined;
|
||||
let latestHistoryId: string | null = null;
|
||||
try {
|
||||
do {
|
||||
const res = await client.users.history.list({
|
||||
userId: 'me',
|
||||
startHistoryId,
|
||||
labelId: 'SENT',
|
||||
historyTypes: ['messageAdded'],
|
||||
maxResults: 500,
|
||||
pageToken,
|
||||
});
|
||||
for (const h of res.data.history ?? []) {
|
||||
for (const m of h.messagesAdded ?? []) {
|
||||
const labels = m.message?.labelIds ?? [];
|
||||
const id = m.message?.id;
|
||||
if (id && labels.includes('SENT')) added.push(id);
|
||||
}
|
||||
}
|
||||
if (res.data.historyId) latestHistoryId = res.data.historyId;
|
||||
pageToken = res.data.nextPageToken ?? undefined;
|
||||
} while (pageToken);
|
||||
} catch (err: unknown) {
|
||||
// 404 means startHistoryId is too old — caller should fall back to full sync.
|
||||
const status = (err as { code?: number; status?: number })?.code ?? (err as { code?: number; status?: number })?.status;
|
||||
if (status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Dedupe in case the same message shows up in multiple history pages.
|
||||
const unique = Array.from(new Set(added));
|
||||
await processInBatches(unique, HEADER_FETCH_CONCURRENCY, (id) => ingestMessage(client, id, selfEmail, map));
|
||||
if (unique.length > 0) promoteCanonicalNames(map);
|
||||
|
||||
// If history.list returned no entries we have no fresh historyId; keep
|
||||
// using the watermark we started from so the next call retries the same window.
|
||||
return { historyId: latestHistoryId ?? startHistoryId, added: unique.length };
|
||||
}
|
||||
|
||||
async function performSync(): Promise<void> {
|
||||
const auth = await GoogleClientFactory.getClient();
|
||||
if (!auth) return;
|
||||
const selfRaw = await getUserEmail(auth).catch(() => null);
|
||||
if (!selfRaw) return;
|
||||
const selfEmail = selfRaw.trim().toLowerCase();
|
||||
|
||||
const stored = loadState();
|
||||
const sameAccount = stored?.selfEmail === selfEmail;
|
||||
|
||||
if (stored && sameAccount && stored.historyId) {
|
||||
const map = indexFromStored(stored);
|
||||
const result = await incrementalSync(auth, selfEmail, stored.historyId, map);
|
||||
if (result) {
|
||||
cachedIndex = map;
|
||||
await saveState(storedFromIndex(map, result.historyId, selfEmail, stored.lastFullSyncAt));
|
||||
lastRefreshAt = Date.now();
|
||||
return;
|
||||
}
|
||||
// history watermark too old → fall through to full sync.
|
||||
}
|
||||
|
||||
const { map, historyId } = await fullSync(auth, selfEmail);
|
||||
cachedIndex = map;
|
||||
await saveState(storedFromIndex(map, historyId, selfEmail, Date.now()));
|
||||
lastRefreshAt = Date.now();
|
||||
}
|
||||
|
||||
function ensureFresh(): void {
|
||||
if (pendingSync) return;
|
||||
if (Date.now() - lastRefreshAt < REFRESH_INTERVAL_MS) return;
|
||||
pendingSync = performSync()
|
||||
.catch((err) => {
|
||||
console.error('[gmail_sent_contacts] sync failed:', err instanceof Error ? err.message : err);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingSync = null;
|
||||
});
|
||||
}
|
||||
|
||||
// Public: kick off a sync on app startup. Subsequent calls within the refresh
|
||||
// window are no-ops.
|
||||
export function warmSentContacts(): void {
|
||||
if (!cachedIndex) {
|
||||
const stored = loadState();
|
||||
if (stored) cachedIndex = indexFromStored(stored);
|
||||
}
|
||||
ensureFresh();
|
||||
}
|
||||
|
||||
export function invalidateSentContacts(): void {
|
||||
cachedIndex = null;
|
||||
lastRefreshAt = 0;
|
||||
}
|
||||
|
||||
function score(entry: IndexEntry, nowMs: number): number {
|
||||
const days = Math.max(0, (nowMs - entry.lastSeenMs) / (1000 * 60 * 60 * 24));
|
||||
const recency = Math.pow(0.5, days / RECENCY_HALFLIFE_DAYS);
|
||||
return entry.count * (0.5 + 0.5 * recency);
|
||||
}
|
||||
|
||||
function matchTier(q: string, entry: IndexEntry): number {
|
||||
if (!q) return 3;
|
||||
const name = entry.name.toLowerCase();
|
||||
const email = entry.email;
|
||||
if (name && name.startsWith(q)) return 0;
|
||||
if (email.startsWith(q)) return 1;
|
||||
if (name && name.includes(' ' + q)) return 1;
|
||||
if (name && name.includes(q)) return 2;
|
||||
if (email.includes(q)) return 3;
|
||||
return -1;
|
||||
}
|
||||
|
||||
export interface SearchOpts {
|
||||
limit?: number;
|
||||
excludeEmails?: string[];
|
||||
}
|
||||
|
||||
// Public: typeahead search over sent-recipient history. Returns instantly from
|
||||
// the in-memory cache (or disk on first call) and triggers a background refresh.
|
||||
export async function searchSentContacts(query: string, opts: SearchOpts = {}): Promise<Contact[]> {
|
||||
if (!cachedIndex) {
|
||||
const stored = loadState();
|
||||
if (stored) cachedIndex = indexFromStored(stored);
|
||||
}
|
||||
// Kick off (or join) a background refresh; never block the user.
|
||||
ensureFresh();
|
||||
|
||||
if (!cachedIndex) {
|
||||
// First-ever launch: wait for the initial sync so we can return something
|
||||
// useful instead of an empty list.
|
||||
if (pendingSync) {
|
||||
try { await pendingSync; } catch { /* return whatever we have */ }
|
||||
}
|
||||
if (!cachedIndex) return [];
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const limit = Math.max(1, Math.min(50, opts.limit ?? 8));
|
||||
const excluded = new Set((opts.excludeEmails ?? []).map((e) => e.trim().toLowerCase()));
|
||||
const nowMs = Date.now();
|
||||
|
||||
const matches: Array<{ entry: IndexEntry; tier: number; s: number }> = [];
|
||||
for (const entry of cachedIndex.values()) {
|
||||
if (excluded.has(entry.email)) continue;
|
||||
const tier = matchTier(q, entry);
|
||||
if (tier < 0) continue;
|
||||
matches.push({ entry, tier, s: score(entry, nowMs) });
|
||||
}
|
||||
matches.sort((a, b) => (a.tier - b.tier) || (b.s - a.s));
|
||||
return matches.slice(0, limit).map(({ entry }) => ({
|
||||
name: entry.name,
|
||||
email: entry.email,
|
||||
count: entry.count,
|
||||
lastSeenMs: entry.lastSeenMs,
|
||||
}));
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import { BuiltinTools } from '../application/lib/builtin-tools.js';
|
||||
|
||||
export function getRaw(): string {
|
||||
// code_agent_run needs an interactive UI to answer its permission asks; exclude it
|
||||
// from this headless agent so it can't hang waiting on an approval no one can give.
|
||||
const toolEntries = Object.keys(BuiltinTools)
|
||||
.filter(name => name !== 'code_agent_run')
|
||||
.map(name => ` ${name}:\n type: builtin\n name: ${name}`)
|
||||
.join('\n');
|
||||
|
||||
|
|
|
|||
|
|
@ -152,7 +152,9 @@ Avoid: "I updated the note.", "Done!", "Here is the update:". The summary is a d
|
|||
export function buildLiveNoteAgent(): z.infer<typeof Agent> {
|
||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||
for (const name of Object.keys(BuiltinTools)) {
|
||||
if (name === 'executeCommand') continue;
|
||||
// code_agent_run requires an interactive UI for permission approvals — skip it
|
||||
// here (headless) so it can't hang on an approval no one can answer.
|
||||
if (name === 'executeCommand' || name === 'code_agent_run') continue;
|
||||
tools[name] = { type: 'builtin', name };
|
||||
}
|
||||
|
||||
|
|
|
|||
70
apps/x/packages/shared/src/code-mode.ts
Normal file
70
apps/x/packages/shared/src/code-mode.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import z from "zod";
|
||||
|
||||
// Shared zod schemas for the ACP code-mode engine. Single source of truth: the
|
||||
// core engine re-exports the inferred TS types, and runs.ts builds the RunEvent
|
||||
// variants that carry these to the renderer.
|
||||
|
||||
export const CodingAgent = z.enum(["claude", "codex"]);
|
||||
export type CodingAgent = z.infer<typeof CodingAgent>;
|
||||
|
||||
// How the permission broker answers the agent's requests before any per-tool
|
||||
// "always allow" memory is applied. `yolo` is the safe, scoped equivalent of
|
||||
// `claude --dangerously-skip-permissions` (our toggle, not a CLI flag).
|
||||
export const ApprovalPolicy = z.enum(["ask", "auto-approve-reads", "yolo"]);
|
||||
export type ApprovalPolicy = z.infer<typeof ApprovalPolicy>;
|
||||
|
||||
export const PermissionDecision = z.enum(["allow_once", "allow_always", "reject"]);
|
||||
export type PermissionDecision = z.infer<typeof PermissionDecision>;
|
||||
|
||||
// What the UI needs to render a permission card.
|
||||
export const PermissionAsk = z.object({
|
||||
toolCallId: z.string().optional(),
|
||||
title: z.string(),
|
||||
kind: z.string().optional(), // tool kind, e.g. "edit" | "execute" | "read"
|
||||
isRead: z.boolean(),
|
||||
});
|
||||
export type PermissionAsk = z.infer<typeof PermissionAsk>;
|
||||
|
||||
// Normalized per-run stream items. The engine maps raw ACP session/update
|
||||
// notifications onto this union; the renderer renders them.
|
||||
export const CodeRunEvent = z.discriminatedUnion("type", [
|
||||
// role distinguishes the agent's own output from replayed user turns
|
||||
// (loadSession streams the whole prior conversation back on resume).
|
||||
z.object({ type: z.literal("message"), role: z.enum(["agent", "user"]), text: z.string() }),
|
||||
z.object({ type: z.literal("thought") }),
|
||||
z.object({
|
||||
type: z.literal("tool_call"),
|
||||
id: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("tool_call_update"),
|
||||
id: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
diffs: z.array(z.string()),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("plan"),
|
||||
entries: z.array(z.object({
|
||||
content: z.string(),
|
||||
status: z.string().optional(),
|
||||
priority: z.string().optional(),
|
||||
})),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("permission"),
|
||||
ask: PermissionAsk,
|
||||
decision: z.union([PermissionDecision, z.literal("cancelled")]),
|
||||
auto: z.boolean(),
|
||||
}),
|
||||
z.object({ type: z.literal("other"), sessionUpdate: z.string() }),
|
||||
]);
|
||||
export type CodeRunEvent = z.infer<typeof CodeRunEvent>;
|
||||
|
||||
export const RunPromptResult = z.object({
|
||||
stopReason: z.string(),
|
||||
sessionId: z.string(),
|
||||
});
|
||||
export type RunPromptResult = z.infer<typeof RunPromptResult>;
|
||||
|
|
@ -19,6 +19,7 @@ import { ZListToolkitsResponse } from './composio.js';
|
|||
import { BrowserStateSchema } from './browser-control.js';
|
||||
import { BillingInfoSchema } from './billing.js';
|
||||
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
||||
import { PermissionDecision, ApprovalPolicy } from './code-mode.js';
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Validation Schemas (Single Source of Truth)
|
||||
|
|
@ -220,6 +221,21 @@ const ipcSchemas = {
|
|||
}),
|
||||
res: z.object({}),
|
||||
},
|
||||
'gmail:searchContacts': {
|
||||
req: z.object({
|
||||
query: z.string(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
excludeEmails: z.array(z.string()).optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
contacts: z.array(z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
count: z.number(),
|
||||
lastSeenMs: z.number(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
'mcp:listTools': {
|
||||
req: z.object({
|
||||
serverName: z.string(),
|
||||
|
|
@ -449,11 +465,23 @@ const ipcSchemas = {
|
|||
req: z.null(),
|
||||
res: z.object({
|
||||
enabled: z.boolean(),
|
||||
approvalPolicy: ApprovalPolicy.optional(),
|
||||
}),
|
||||
},
|
||||
'codeMode:setConfig': {
|
||||
req: z.object({
|
||||
enabled: z.boolean(),
|
||||
approvalPolicy: ApprovalPolicy.optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
// Answer a mid-run permission request from a code_agent_run coding turn.
|
||||
'codeRun:resolvePermission': {
|
||||
req: z.object({
|
||||
requestId: z.string(),
|
||||
decision: PermissionDecision,
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
|
|
@ -739,6 +767,16 @@ const ipcSchemas = {
|
|||
mimeType: z.string(),
|
||||
}),
|
||||
},
|
||||
// Ensures the OS-level microphone permission is settled before capturing.
|
||||
// On first-ever use (macOS) the permission is 'not-determined'; resolving
|
||||
// the native prompt up front prevents the in-flight getUserMedia from
|
||||
// rejecting on the first mic click.
|
||||
'voice:ensureMicAccess': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
granted: z.boolean(),
|
||||
}),
|
||||
},
|
||||
'meeting:checkScreenPermission': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { LlmStepStreamEvent } from "./llm-step-events.js";
|
||||
import { Message, ToolCallPart } from "./message.js";
|
||||
import { CodeRunEvent as CodeRunEventSchema, PermissionAsk } from "./code-mode.js";
|
||||
import z from "zod";
|
||||
|
||||
const BaseRunEvent = z.object({
|
||||
|
|
@ -111,6 +112,23 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({
|
|||
scope: z.enum(["once", "session", "always"]).optional(),
|
||||
});
|
||||
|
||||
// A structured item from a code_agent_run coding turn (tool call, diff, plan,
|
||||
// message chunk, resolved permission). Fire-and-forget — rendered live.
|
||||
export const CodeRunStreamEvent = BaseRunEvent.extend({
|
||||
type: z.literal("code-run-event"),
|
||||
toolCallId: z.string(),
|
||||
event: CodeRunEventSchema,
|
||||
});
|
||||
|
||||
// The coding agent is asking for permission mid-turn and the run is BLOCKED until
|
||||
// the user answers via `codeRun:resolvePermission` (keyed by requestId).
|
||||
export const CodeRunPermissionRequestEvent = BaseRunEvent.extend({
|
||||
type: z.literal("code-run-permission-request"),
|
||||
toolCallId: z.string(),
|
||||
requestId: z.string(),
|
||||
ask: PermissionAsk,
|
||||
});
|
||||
|
||||
export const ToolPermissionAutoDecisionEvent = BaseRunEvent.extend({
|
||||
type: z.literal("tool-permission-auto-decision"),
|
||||
toolCallId: z.string(),
|
||||
|
|
@ -144,6 +162,8 @@ export const RunEvent = z.union([
|
|||
AskHumanResponseEvent,
|
||||
ToolPermissionRequestEvent,
|
||||
ToolPermissionResponseEvent,
|
||||
CodeRunStreamEvent,
|
||||
CodeRunPermissionRequestEvent,
|
||||
ToolPermissionAutoDecisionEvent,
|
||||
RunErrorEvent,
|
||||
RunStoppedEvent,
|
||||
|
|
|
|||
15
apps/x/patches/@openai__codex@0.128.0.patch
Normal file
15
apps/x/patches/@openai__codex@0.128.0.patch
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
diff --git a/bin/codex.js b/bin/codex.js
|
||||
index 67ab3e2d95dfac1c91882578b5403916c3121484..f8030b6e1459e05161af99e152b2e7f65ea6c41d 100644
|
||||
--- a/bin/codex.js
|
||||
+++ b/bin/codex.js
|
||||
@@ -175,6 +175,10 @@ env[packageManagerEnvVar] = "1";
|
||||
const child = spawn(binaryPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
+ // Native console-subsystem binary: without this Windows pops a visible console
|
||||
+ // window when launched from a console-less (Electron GUI) parent. Closing that
|
||||
+ // window wedges the agent. CREATE_NO_WINDOW keeps the console hidden.
|
||||
+ windowsHide: true,
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
380
apps/x/pnpm-lock.yaml
generated
380
apps/x/pnpm-lock.yaml
generated
|
|
@ -10,6 +10,11 @@ catalogs:
|
|||
specifier: 4.1.7
|
||||
version: 4.1.7
|
||||
|
||||
patchedDependencies:
|
||||
'@openai/codex@0.128.0':
|
||||
hash: 9edd926108a95aaa788aa93870fd6b16d70eeccdf5740b503af5d34cc9f25e86
|
||||
path: patches/@openai__codex@0.128.0.patch
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
|
|
@ -47,6 +52,12 @@ importers:
|
|||
|
||||
apps/main:
|
||||
dependencies:
|
||||
'@agentclientprotocol/claude-agent-acp':
|
||||
specifier: ^0.39.0
|
||||
version: 0.39.0(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))
|
||||
'@agentclientprotocol/codex-acp':
|
||||
specifier: ^0.0.44
|
||||
version: 0.0.44(zod@4.2.1)
|
||||
'@x/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core
|
||||
|
|
@ -84,6 +95,9 @@ importers:
|
|||
'@electron-forge/cli':
|
||||
specifier: ^7.10.2
|
||||
version: 7.11.1(encoding@0.1.13)(esbuild@0.24.2)
|
||||
'@electron-forge/maker-base':
|
||||
specifier: ^7.11.1
|
||||
version: 7.11.1
|
||||
'@electron-forge/maker-deb':
|
||||
specifier: ^7.11.1
|
||||
version: 7.11.1
|
||||
|
|
@ -362,6 +376,15 @@ importers:
|
|||
|
||||
packages/core:
|
||||
dependencies:
|
||||
'@agentclientprotocol/claude-agent-acp':
|
||||
specifier: ^0.39.0
|
||||
version: 0.39.0(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))
|
||||
'@agentclientprotocol/codex-acp':
|
||||
specifier: ^0.0.44
|
||||
version: 0.0.44(zod@4.2.1)
|
||||
'@agentclientprotocol/sdk':
|
||||
specifier: ^0.22.1
|
||||
version: 0.22.1(zod@4.2.1)
|
||||
'@ai-sdk/anthropic':
|
||||
specifier: ^2.0.63
|
||||
version: 2.0.70(zod@4.2.1)
|
||||
|
|
@ -489,6 +512,24 @@ importers:
|
|||
|
||||
packages:
|
||||
|
||||
'@agentclientprotocol/claude-agent-acp@0.39.0':
|
||||
resolution: {integrity: sha512-+tCm5v32L0R3zE4qjZQowfO1L/zqvQ5FapmsMSIf4gawXfTf26CG5hgz99wARdo0zn20/1eP80gzx7PbZlSX9A==}
|
||||
hasBin: true
|
||||
|
||||
'@agentclientprotocol/codex-acp@0.0.44':
|
||||
resolution: {integrity: sha512-iHzFWKzJ0Z8I6yJCkuLZ+nb9mF2WYmfTcHFFvc7sU/awBsQmVBmpSOXOpZ+IK2Dy9cR3iRoML/B2/Wq2/zKBCA==}
|
||||
hasBin: true
|
||||
|
||||
'@agentclientprotocol/sdk@0.21.1':
|
||||
resolution: {integrity: sha512-ZTLH+o9QxcZDLX/9ww+W7C2iExnXFM+vD/uGFVSlR61Kzj9FaxUqBC6Rv/kwgA7qVWYUEI9c5ZNqCuO9PM4rKg==}
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
'@agentclientprotocol/sdk@0.22.1':
|
||||
resolution: {integrity: sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==}
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
'@ai-sdk/anthropic@2.0.70':
|
||||
resolution: {integrity: sha512-W3WjQlb0Ho+CVAQUvb8Rtk3hGS3Jlgy79ihY2H0yj2k4yU8XuxpQw0Oz+7JQsB47j+jlHhk7nUXtxhAeRg3S3Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -544,6 +585,67 @@ packages:
|
|||
'@antfu/install-pkg@1.1.0':
|
||||
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156':
|
||||
resolution: {integrity: sha512-IkjcS9dqAUlD4Nb62L9AZtmAXCa+FV4ul8lIlyXXUprh3nlecbKsWOXVd/GORrzAhMmynJaX4+iV1JiutFKXUA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.156':
|
||||
resolution: {integrity: sha512-6PKi5fPmGRuzXu+Em/iwLmPG3mqg0hl92wcTU8fmChqyNtxhxsjCw7LTbdFqp/05o5NeZVVV4k3p7YUv5IFD6g==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.156':
|
||||
resolution: {integrity: sha512-R7KEVjxkR4rYgIQoHGBzwPdUJYxRTO8I4vHjRbMLH1eW4FS7BJvVs7ogfKR/NnHFBvMVqtC+l6jHLQv8bobUiw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.156':
|
||||
resolution: {integrity: sha512-H0Nfd41iw5isto9uQI1FlVSZ0eaDttr8rBpJMR25oK/mj3egMO5EmZ6aAxeeUYSLn2mSU50HA5VNxlGUE118TQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.156':
|
||||
resolution: {integrity: sha512-/Q6WUizI6a+hqZZ6ElwRU0PEuFhOoN4v6CuU35HHbiZ/7uaocGht4A8ZIgK1Fw6wOGtZzGLbc00CA1OU1Zg8EA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-linux-x64@0.3.156':
|
||||
resolution: {integrity: sha512-ymhrdlbWoYvTACUdaGdhrEv+ZMfwXLsf0BRLkr/IvY5aqybP7URzWmmZGOtDQpqkT/8xu/UCGqUYH3woJwUxfg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.156':
|
||||
resolution: {integrity: sha512-5sAeNObQQrMy4NF9HwxewrMnU7mVxZDHh+/MfJVQSz0GSTvXQ6gOuRH8helMlfspoU6VOdekPxVLRooX/3foEw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-win32-x64@0.3.156':
|
||||
resolution: {integrity: sha512-/PofeTWoiKgnWNSNk0wG4SsRn22GGLmnLhg2R94WcNhCRFOyOTmiZcYH2DBlWZBIRVTZDsSfa/Pl1DyPvYCGKw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk@0.3.156':
|
||||
resolution: {integrity: sha512-6nM/Dj+VMds52UXJ2YaV4IKhYamlUqN0HtdDrFzYz5lvPMpDS935qD8YZDAUpy+ltdoD6PJMd1V/CKFY3/oWCQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
'@anthropic-ai/sdk': '>=0.93.0'
|
||||
'@modelcontextprotocol/sdk': ^1.29.0
|
||||
zod: ^4.0.0
|
||||
|
||||
'@anthropic-ai/sdk@0.100.1':
|
||||
resolution: {integrity: sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
|
@ -1743,6 +1845,47 @@ packages:
|
|||
resolution: {integrity: sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
'@openai/codex@0.128.0':
|
||||
resolution: {integrity: sha512-+xp6ODmFfBNnexIWRHApEaPXot2j6gyM8A5we/5IS/uY4eYHj4arETct4hQ5M4eO+MK7JY3ZU4xhuobhlysr0A==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
'@openai/codex@0.128.0-darwin-arm64':
|
||||
resolution: {integrity: sha512-w+6zohfHx/kHBdles/CyFKaY57u9I3nK8QI9+NrdwMliKA0b7xn13yblRNkMpe09j6vL1oAWoxYsMOQ/vjBGug==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@openai/codex@0.128.0-darwin-x64':
|
||||
resolution: {integrity: sha512-SDbn6fO22Puy8xmMIbZi4f2znMrUEPwABApke4mo+4ihaauwuVjeqzXvW5SPJz5ty/bG11/mSupQgReT7T8BBw==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@openai/codex@0.128.0-linux-arm64':
|
||||
resolution: {integrity: sha512-+SvH73H60qvCXFuQGP/EsmR//s1hHMBR22PvJkXvM/hdnTIGucx+JqRUjAWdmmQ1IU6j3kgwVvdLW/6ICB+M6w==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@openai/codex@0.128.0-linux-x64':
|
||||
resolution: {integrity: sha512-2lnSPA05CRRuKAzFW8BCmmNCSieDcToLwfC2ALLbBYilGLgzhRibjlDglK9F1BkEzfohSSWJu4PBbRu/aG60lQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@openai/codex@0.128.0-win32-arm64':
|
||||
resolution: {integrity: sha512-ECJvsqmYFdA9pn42xxK3Odp/G16AjmBW0BglX8L0PwPjqbstbmlew9bfHf7xvL+SNfNl4NmyotW0+RNo1phgaA==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@openai/codex@0.128.0-win32-x64':
|
||||
resolution: {integrity: sha512-k3jmUAFrzkUtvjGTXvSKjQqJLLlzjxp/VoHJDYedgmXUn6j70HxK38IwapzmnYfiBiTuzETvGwjXHzZgzKjhoQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@openrouter/ai-sdk-provider@1.5.4':
|
||||
resolution: {integrity: sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -3057,6 +3200,9 @@ packages:
|
|||
resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@stablelib/base64@1.0.1':
|
||||
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
|
|
@ -4060,6 +4206,10 @@ packages:
|
|||
buffer@6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
||||
bundle-name@4.1.0:
|
||||
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -4540,6 +4690,14 @@ packages:
|
|||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
default-browser-id@5.0.1:
|
||||
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
default-browser@5.5.0:
|
||||
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
defaults@1.0.4:
|
||||
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
|
||||
|
||||
|
|
@ -4551,6 +4709,10 @@ packages:
|
|||
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
define-lazy-prop@3.0.0:
|
||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
define-properties@1.2.1:
|
||||
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -4592,6 +4754,10 @@ packages:
|
|||
diff3@0.0.3:
|
||||
resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==}
|
||||
|
||||
diff@8.0.4:
|
||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dingbat-to-unicode@1.0.1:
|
||||
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
|
||||
|
||||
|
|
@ -4942,6 +5108,9 @@ packages:
|
|||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-sha256@1.3.0:
|
||||
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
|
|
@ -5541,6 +5710,11 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
is-docker@3.0.0:
|
||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
hasBin: true
|
||||
|
||||
is-extglob@2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -5560,6 +5734,15 @@ packages:
|
|||
is-hexadecimal@2.0.1:
|
||||
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
|
||||
|
||||
is-in-ssh@1.0.0:
|
||||
resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
|
||||
engines: {node: '>=14.16'}
|
||||
hasBin: true
|
||||
|
||||
is-interactive@1.0.0:
|
||||
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -5617,6 +5800,10 @@ packages:
|
|||
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-wsl@3.1.1:
|
||||
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
|
|
@ -5677,6 +5864,10 @@ packages:
|
|||
json-parse-even-better-errors@2.3.1:
|
||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||
|
||||
json-schema-to-ts@3.1.1:
|
||||
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
|
|
@ -6431,6 +6622,10 @@ packages:
|
|||
oniguruma-to-es@4.3.4:
|
||||
resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==}
|
||||
|
||||
open@11.0.0:
|
||||
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
open@7.4.2:
|
||||
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -6679,6 +6874,10 @@ packages:
|
|||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
powershell-utils@0.1.0:
|
||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
preact@10.28.2:
|
||||
resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==}
|
||||
|
||||
|
|
@ -7097,6 +7296,10 @@ packages:
|
|||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
run-applescript@7.1.0:
|
||||
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
|
|
@ -7305,6 +7508,9 @@ packages:
|
|||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -7523,6 +7729,9 @@ packages:
|
|||
trough@2.2.0:
|
||||
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
|
||||
|
||||
ts-algebra@2.0.0:
|
||||
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
|
||||
|
||||
ts-api-utils@2.1.0:
|
||||
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
|
||||
engines: {node: '>=18.12'}
|
||||
|
|
@ -7827,6 +8036,10 @@ packages:
|
|||
resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
vscode-jsonrpc@8.2.1:
|
||||
resolution: {integrity: sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
vscode-languageserver-protocol@3.17.5:
|
||||
resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==}
|
||||
|
||||
|
|
@ -7933,6 +8146,10 @@ packages:
|
|||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
x-is-array@0.1.0:
|
||||
resolution: {integrity: sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA==}
|
||||
|
||||
|
|
@ -8028,6 +8245,33 @@ packages:
|
|||
|
||||
snapshots:
|
||||
|
||||
'@agentclientprotocol/claude-agent-acp@0.39.0(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))':
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 0.22.1(zod@4.2.1)
|
||||
'@anthropic-ai/claude-agent-sdk': 0.3.156(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))(zod@4.2.1)
|
||||
zod: 4.2.1
|
||||
transitivePeerDependencies:
|
||||
- '@anthropic-ai/sdk'
|
||||
- '@modelcontextprotocol/sdk'
|
||||
|
||||
'@agentclientprotocol/codex-acp@0.0.44(zod@4.2.1)':
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 0.21.1(zod@4.2.1)
|
||||
'@openai/codex': 0.128.0(patch_hash=9edd926108a95aaa788aa93870fd6b16d70eeccdf5740b503af5d34cc9f25e86)
|
||||
diff: 8.0.4
|
||||
open: 11.0.0
|
||||
vscode-jsonrpc: 8.2.1
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
|
||||
'@agentclientprotocol/sdk@0.21.1(zod@4.2.1)':
|
||||
dependencies:
|
||||
zod: 4.2.1
|
||||
|
||||
'@agentclientprotocol/sdk@0.22.1(zod@4.2.1)':
|
||||
dependencies:
|
||||
zod: 4.2.1
|
||||
|
||||
'@ai-sdk/anthropic@2.0.70(zod@4.2.1)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.1
|
||||
|
|
@ -8089,6 +8333,52 @@ snapshots:
|
|||
package-manager-detector: 1.6.0
|
||||
tinyexec: 1.0.2
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156':
|
||||
optional: true
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.156':
|
||||
optional: true
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.156':
|
||||
optional: true
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.156':
|
||||
optional: true
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.156':
|
||||
optional: true
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-linux-x64@0.3.156':
|
||||
optional: true
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.156':
|
||||
optional: true
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-win32-x64@0.3.156':
|
||||
optional: true
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk@0.3.156(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))(zod@4.2.1)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.100.1(zod@4.2.1)
|
||||
'@modelcontextprotocol/sdk': 1.25.1(hono@4.11.3)(zod@4.2.1)
|
||||
zod: 4.2.1
|
||||
optionalDependencies:
|
||||
'@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.156
|
||||
'@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.156
|
||||
'@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.156
|
||||
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.156
|
||||
'@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.156
|
||||
'@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.156
|
||||
'@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.156
|
||||
'@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.156
|
||||
|
||||
'@anthropic-ai/sdk@0.100.1(zod@4.2.1)':
|
||||
dependencies:
|
||||
json-schema-to-ts: 3.1.1
|
||||
standardwebhooks: 1.0.0
|
||||
optionalDependencies:
|
||||
zod: 4.2.1
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
|
|
@ -9819,6 +10109,33 @@ snapshots:
|
|||
|
||||
'@oozcitak/util@8.3.4': {}
|
||||
|
||||
'@openai/codex@0.128.0(patch_hash=9edd926108a95aaa788aa93870fd6b16d70eeccdf5740b503af5d34cc9f25e86)':
|
||||
optionalDependencies:
|
||||
'@openai/codex-darwin-arm64': '@openai/codex@0.128.0-darwin-arm64'
|
||||
'@openai/codex-darwin-x64': '@openai/codex@0.128.0-darwin-x64'
|
||||
'@openai/codex-linux-arm64': '@openai/codex@0.128.0-linux-arm64'
|
||||
'@openai/codex-linux-x64': '@openai/codex@0.128.0-linux-x64'
|
||||
'@openai/codex-win32-arm64': '@openai/codex@0.128.0-win32-arm64'
|
||||
'@openai/codex-win32-x64': '@openai/codex@0.128.0-win32-x64'
|
||||
|
||||
'@openai/codex@0.128.0-darwin-arm64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.128.0-darwin-x64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.128.0-linux-arm64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.128.0-linux-x64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.128.0-win32-arm64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.128.0-win32-x64':
|
||||
optional: true
|
||||
|
||||
'@openrouter/ai-sdk-provider@1.5.4(ai@5.0.151(zod@4.2.1))(zod@4.2.1)':
|
||||
dependencies:
|
||||
'@openrouter/sdk': 0.1.27
|
||||
|
|
@ -11301,6 +11618,8 @@ snapshots:
|
|||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@stablelib/base64@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
|
@ -12431,6 +12750,10 @@ snapshots:
|
|||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
bundle-name@4.1.0:
|
||||
dependencies:
|
||||
run-applescript: 7.1.0
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
cacache@16.1.3:
|
||||
|
|
@ -12925,6 +13248,13 @@ snapshots:
|
|||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
default-browser-id@5.0.1: {}
|
||||
|
||||
default-browser@5.5.0:
|
||||
dependencies:
|
||||
bundle-name: 4.1.0
|
||||
default-browser-id: 5.0.1
|
||||
|
||||
defaults@1.0.4:
|
||||
dependencies:
|
||||
clone: 1.0.4
|
||||
|
|
@ -12937,6 +13267,8 @@ snapshots:
|
|||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
define-lazy-prop@3.0.0: {}
|
||||
|
||||
define-properties@1.2.1:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
|
|
@ -12971,6 +13303,8 @@ snapshots:
|
|||
|
||||
diff3@0.0.3: {}
|
||||
|
||||
diff@8.0.4: {}
|
||||
|
||||
dingbat-to-unicode@1.0.1: {}
|
||||
|
||||
dir-compare@4.2.0:
|
||||
|
|
@ -13473,6 +13807,8 @@ snapshots:
|
|||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-sha256@1.3.0: {}
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fast-xml-parser@5.2.5:
|
||||
|
|
@ -14248,6 +14584,8 @@ snapshots:
|
|||
|
||||
is-docker@2.2.1: {}
|
||||
|
||||
is-docker@3.0.0: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
|
@ -14260,6 +14598,12 @@ snapshots:
|
|||
|
||||
is-hexadecimal@2.0.1: {}
|
||||
|
||||
is-in-ssh@1.0.0: {}
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
dependencies:
|
||||
is-docker: 3.0.0
|
||||
|
||||
is-interactive@1.0.0: {}
|
||||
|
||||
is-lambda@1.0.1: {}
|
||||
|
|
@ -14310,6 +14654,10 @@ snapshots:
|
|||
dependencies:
|
||||
is-docker: 2.2.1
|
||||
|
||||
is-wsl@3.1.1:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isarray@2.0.5: {}
|
||||
|
|
@ -14378,6 +14726,11 @@ snapshots:
|
|||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
|
||||
json-schema-to-ts@3.1.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
ts-algebra: 2.0.0
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
|
@ -15367,6 +15720,15 @@ snapshots:
|
|||
regex: 6.1.0
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
open@11.0.0:
|
||||
dependencies:
|
||||
default-browser: 5.5.0
|
||||
define-lazy-prop: 3.0.0
|
||||
is-in-ssh: 1.0.0
|
||||
is-inside-container: 1.0.0
|
||||
powershell-utils: 0.1.0
|
||||
wsl-utils: 0.3.1
|
||||
|
||||
open@7.4.2:
|
||||
dependencies:
|
||||
is-docker: 2.2.1
|
||||
|
|
@ -15606,6 +15968,8 @@ snapshots:
|
|||
dependencies:
|
||||
commander: 9.5.0
|
||||
|
||||
powershell-utils@0.1.0: {}
|
||||
|
||||
preact@10.28.2: {}
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
|
@ -16189,6 +16553,8 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
run-applescript@7.1.0: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
|
@ -16424,6 +16790,11 @@ snapshots:
|
|||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
dependencies:
|
||||
'@stablelib/base64': 1.0.1
|
||||
fast-sha256: 1.3.0
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@4.1.0: {}
|
||||
|
|
@ -16664,6 +17035,8 @@ snapshots:
|
|||
|
||||
trough@2.2.0: {}
|
||||
|
||||
ts-algebra@2.0.0: {}
|
||||
|
||||
ts-api-utils@2.1.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
|
@ -16965,6 +17338,8 @@ snapshots:
|
|||
|
||||
vscode-jsonrpc@8.2.0: {}
|
||||
|
||||
vscode-jsonrpc@8.2.1: {}
|
||||
|
||||
vscode-languageserver-protocol@3.17.5:
|
||||
dependencies:
|
||||
vscode-jsonrpc: 8.2.0
|
||||
|
|
@ -17097,6 +17472,11 @@ snapshots:
|
|||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
dependencies:
|
||||
is-wsl: 3.1.1
|
||||
powershell-utils: 0.1.0
|
||||
|
||||
x-is-array@0.1.0: {}
|
||||
|
||||
x-is-string@0.1.0: {}
|
||||
|
|
|
|||
|
|
@ -13,3 +13,5 @@ onlyBuiltDependencies:
|
|||
- fs-xattr
|
||||
- macos-alias
|
||||
- protobufjs
|
||||
patchedDependencies:
|
||||
'@openai/codex@0.128.0': patches/@openai__codex@0.128.0.patch
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue