Merge origin/dev into drive

Resolve conflicts:
- apps/main/src/ipc.ts: import union (dev's gmail contacts/getAccountName +
  drive's google_docs/managed-picker imports).
- apps/renderer/src/App.tsx: import union (dev's CodingRunBlock/KnowledgeViewMode
  + drive's GoogleDocPickerDialog).
- apps/renderer/src/components/knowledge-view.tsx: keep the "Add Google Doc"
  button in the header next to the voice-note action; Search/Graph/New note are
  now dev's QuickActions / view-mode toggles.
This commit is contained in:
Gagancreates 2026-06-23 02:58:51 +05:30
commit 462023f4c2
142 changed files with 17080 additions and 1347 deletions

View file

@ -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",

View file

@ -0,0 +1,111 @@
// Build-time generator for the code-mode engine manifest.
//
// Code mode provisions each agent's native engine on demand by downloading the
// per-platform npm package AT THE EXACT VERSION OUR ACP ADAPTER DEPENDS ON, so the
// adapter <-> engine handshake is always in lockstep. This script reads those pinned
// versions from the installed adapter package.json files, queries the npm registry for
// each platform package's tarball URL + integrity, and writes them to
// `src/code-mode/acp/engine-manifest.ts` (committed; regenerate on a version bump).
//
// Usage: node scripts/gen-engine-manifest.mjs (run from packages/core)
//
// Why a committed .ts (not a fetched-at-build .json): it needs no network at app build
// time, works offline in dev, is reviewable in PRs, and esbuild inlines it into the
// packaged main bundle.
import { createRequire } from 'module';
import { writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import * as path from 'path';
const require = createRequire(import.meta.url);
const REGISTRY = 'https://registry.npmjs.org';
// Platform keys we publish for each agent. These mirror the optionalDependencies of the
// engine packages. claude ships musl variants; codex does not.
const CLAUDE_PLATFORMS = [
'darwin-arm64', 'darwin-x64',
'linux-x64', 'linux-arm64', 'linux-x64-musl', 'linux-arm64-musl',
'win32-x64', 'win32-arm64',
];
const CODEX_PLATFORMS = [
'darwin-arm64', 'darwin-x64',
'linux-x64', 'linux-arm64',
'win32-x64', 'win32-arm64',
];
// Read a pinned dependency version from an adapter's package.json, stripping any range
// prefix (^, ~). createRequire resolves the adapter from the installed node_modules.
function pinnedDep(adapterPkg, depName) {
const pj = require(`${adapterPkg}/package.json`);
const spec = (pj.dependencies || {})[depName] || (pj.optionalDependencies || {})[depName];
if (!spec) throw new Error(`${adapterPkg} has no dependency on ${depName}`);
return spec.replace(/^[\^~]/, '');
}
// Fetch a single version's manifest from the registry and return its dist coordinates.
async function distFor(pkg, version) {
const url = `${REGISTRY}/${pkg}/${version}`;
const res = await fetch(url);
if (!res.ok) {
if (res.status === 404) return null; // platform not published for this version
throw new Error(`registry ${res.status} for ${pkg}@${version}`);
}
const doc = await res.json();
return { tarball: doc.dist.tarball, integrity: doc.dist.integrity };
}
// Build one agent's manifest entry: { version, platforms: { <key>: {tarball, integrity} } }.
// pkgFor(platform) -> { pkg, version } gives the registry coordinates for each platform.
async function buildAgent(version, platforms, pkgFor) {
const out = { version, platforms: {} };
for (const key of platforms) {
const { pkg, version: pv } = pkgFor(key);
const dist = await distFor(pkg, pv);
if (!dist) {
console.warn(` ! ${pkg}@${pv} not found on registry — skipping ${key}`);
continue;
}
out.platforms[key] = { pkg, pkgVersion: pv, ...dist };
console.log(`${key}: ${pkg}@${pv}`);
}
return out;
}
async function main() {
// Pinned engine versions, read straight from the adapters we ship.
const claudeVersion = pinnedDep('@agentclientprotocol/claude-agent-acp', '@anthropic-ai/claude-agent-sdk');
const codexVersion = pinnedDep('@agentclientprotocol/codex-acp', '@openai/codex');
console.log(`claude engine: @anthropic-ai/claude-agent-sdk@${claudeVersion}`);
console.log(`codex engine: @openai/codex@${codexVersion}`);
const manifest = {
claude: await buildAgent(claudeVersion, CLAUDE_PLATFORMS, (key) => ({
pkg: `@anthropic-ai/claude-agent-sdk-${key}`,
version: claudeVersion,
})),
codex: await buildAgent(codexVersion, CODEX_PLATFORMS, (key) => ({
// codex publishes platform binaries as VERSIONS of @openai/codex
// (e.g. 0.128.0-darwin-arm64), not as separate package names.
pkg: '@openai/codex',
version: `${codexVersion}-${key}`,
})),
};
const outPath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..', 'src', 'code-mode', 'acp', 'engine-manifest.ts',
);
const banner =
'// AUTO-GENERATED by packages/core/scripts/gen-engine-manifest.mjs — do not edit by hand.\n' +
'// Regenerate after bumping the @agentclientprotocol/*-acp adapter (engine) versions.\n' +
'// Maps each agent + platform to the npm tarball + integrity of its native engine.\n\n';
const body = `export const ENGINE_MANIFEST = ${JSON.stringify(manifest, null, 4)} as const;\n`;
writeFileSync(outPath, banner + body);
console.log(`\nWrote ${outPath}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -17,6 +17,7 @@ import { isBlocked, extractCommandNames } from "../application/lib/command-execu
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
import { resolveFilePathForPermission } from "../filesystem/files.js";
import container from "../di/container.js";
import { notifyIfEnabled } from "../application/notification/notifier.js";
import { IModelConfigRepo } from "../models/repo.js";
import { createProvider } from "../models/models.js";
import { resolveProviderConfig } from "../models/defaults.js";
@ -36,6 +37,7 @@ import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js";
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
@ -376,6 +378,7 @@ export class AgentRuntime implements IAgentRuntime {
type: "run-processing-start",
subflow: [],
});
let totalEvents = 0;
while (true) {
// Check for abort before each iteration
if (signal.aborted) {
@ -416,6 +419,7 @@ export class AgentRuntime implements IAgentRuntime {
throw error;
}
totalEvents += eventCount;
// if no events, break
if (!eventCount) {
break;
@ -432,6 +436,27 @@ export class AgentRuntime implements IAgentRuntime {
};
await this.runsRepo.appendEvents(runId, [stoppedEvent]);
await this.bus.publish(stoppedEvent);
} else if (totalEvents > 0) {
// The run reached a natural stopping point and actually did
// something this cycle. Notify "chat completion" — unless it
// paused on a permission request, which surfaces its own
// notification (distinguish by inspecting the final state).
const finalRun = await this.runsRepo.fetch(runId);
if (finalRun) {
const finalState = new AgentState();
for (const event of finalRun.log) {
finalState.ingest(event);
}
if (finalState.getPendingPermissions().length === 0) {
void notifyIfEnabled("chat_completion", {
title: "Response ready",
message: "Your agent finished responding.",
link: `rowboat://open?type=chat&runId=${runId}`,
actionLabel: "Open",
onlyWhenBackground: true,
});
}
}
}
} catch (error) {
console.error(`Run ${runId} failed:`, error);
@ -901,6 +926,7 @@ export class AgentState {
agentName: string | null = null;
runModel: string | null = null;
runProvider: string | null = null;
permissionMode: "manual" | "auto" = "manual";
runUseCase: UseCase | null = null;
runSubUseCase: string | null = null;
messages: z.infer<typeof MessageList> = [];
@ -912,6 +938,8 @@ export class AgentState {
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
allowedToolCallIds: Record<string, true> = {};
deniedToolCallIds: Record<string, true> = {};
autoAllowedToolCalls: Record<string, { reason: string }> = {};
autoDeniedToolCalls: Record<string, { reason: string }> = {};
sessionAllowedCommands: Set<string> = new Set();
sessionAllowedFileAccess: FileAccessGrant[] = [];
@ -1019,6 +1047,7 @@ export class AgentState {
this.agentName = event.agentName;
this.runModel = event.model;
this.runProvider = event.provider;
this.permissionMode = event.permissionMode ?? "manual";
this.runUseCase = event.useCase ?? null;
this.runSubUseCase = event.subUseCase ?? null;
break;
@ -1031,6 +1060,7 @@ export class AgentState {
this.subflowStates[event.toolCallId].agentName = event.agentName;
this.subflowStates[event.toolCallId].runModel = this.runModel;
this.subflowStates[event.toolCallId].runProvider = this.runProvider;
this.subflowStates[event.toolCallId].permissionMode = this.permissionMode;
this.subflowStates[event.toolCallId].runUseCase = this.runUseCase;
this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase;
break;
@ -1081,10 +1111,22 @@ export class AgentState {
break;
case "deny":
this.deniedToolCallIds[event.toolCallId] = true;
delete this.autoDeniedToolCalls[event.toolCallId];
break;
}
delete this.pendingToolPermissionRequests[event.toolCallId];
break;
case "tool-permission-auto-decision":
switch (event.decision) {
case "allow":
this.allowedToolCallIds[event.toolCallId] = true;
this.autoAllowedToolCalls[event.toolCallId] = { reason: event.reason };
break;
case "deny":
this.autoDeniedToolCalls[event.toolCallId] = { reason: event.reason };
break;
}
break;
case "ask-human-request":
this.pendingAskHumanRequests[event.toolCallId] = event;
break;
@ -1163,6 +1205,8 @@ export async function* streamAgent({
let voiceOutput: 'summary' | 'full' | null = null;
let searchEnabled = false;
let codeMode: 'claude' | 'codex' | null = null;
let codeCwd: string | null = null;
let codePolicy: 'ask' | 'auto-approve-reads' | 'yolo' | null = null;
let middlePaneContext:
| { kind: 'note'; path: string; content: string }
| { kind: 'browser'; url: string; title: string }
@ -1190,13 +1234,19 @@ export async function* streamAgent({
// if tool has been denied, deny
if (state.deniedToolCallIds[toolCallId]) {
_logger.log('returning denied tool message, reason: tool has been denied');
const autoDenied = state.autoDeniedToolCalls[toolCallId];
yield* processEvent({
runId,
messageId: await idGenerator.next(),
type: "message",
message: {
role: "tool",
content: "Unable to execute this tool: Permission was denied.",
content: autoDenied
? JSON.stringify({
success: false,
error: `Auto-permission denied: ${autoDenied.reason}`,
})
: "Unable to execute this tool: Permission was denied.",
toolCallId: toolCallId,
toolName: toolCall.toolName,
},
@ -1255,6 +1305,9 @@ export async function* streamAgent({
signal,
abortRegistry,
publish: (event) => bus.publish(event),
codeMode,
codeCwd,
codePolicy,
});
}
} catch (error) {
@ -1314,6 +1367,8 @@ export async function* streamAgent({
// Code mode is per-message: latest message decides whether the assistant
// should route coding work through the code-with-agents skill / chosen agent.
codeMode = msg.codeMode ?? null;
codeCwd = msg.codeCwd ?? null;
codePolicy = msg.codePolicy ?? null;
if (msg.voiceOutput) {
voiceOutput = msg.voiceOutput;
}
@ -1402,44 +1457,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\`: ${codeCwd ? `\`${codeCwd}\` (always — this coding session is pinned to that directory; never use another path)` : `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.`;
}
@ -1493,6 +1523,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
// if there were any ask-human calls, emit those events
if (message.content instanceof Array) {
const permissionCandidates: AutoPermissionCandidate[] = [];
for (const part of message.content) {
if (part.type === "tool-call") {
const underlyingTool = agent.tools![part.toolName];
@ -1518,14 +1549,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
state.sessionAllowedFileAccess,
);
if (permission) {
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: part,
permission,
subflow: [],
});
permissionCandidates.push({ toolCall: part, permission });
}
if (underlyingTool.type === "agent" && underlyingTool.name) {
loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId);
@ -1549,6 +1573,100 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
}
}
}
if (permissionCandidates.length > 0) {
// Permission prompts block the run, so they surface even when the
// app is focused (no onlyWhenBackground gate).
const notifyPermissionPrompt = (toolCall: typeof permissionCandidates[number]["toolCall"]) => {
void notifyIfEnabled("agent_permission", {
title: "Permission needed",
message: `${agent.name} wants to run "${toolCall.toolName}". Review to continue.`,
link: `rowboat://open?type=chat&runId=${runId}`,
actionLabel: "Review",
});
};
if (state.permissionMode === "auto") {
let decisionsByToolCallId = new Map<string, { decision: "allow" | "deny"; reason: string }>();
try {
const decisions = await classifyToolPermissions({
runId,
agentName: state.agentName,
messages: convertFromMessages(state.messages),
candidates: permissionCandidates,
useCase: state.runUseCase ?? "copilot_chat",
subUseCase: state.runSubUseCase,
});
decisionsByToolCallId = new Map(decisions.map((decision) => [
decision.toolCallId,
{ decision: decision.decision, reason: decision.reason },
]));
} catch (error) {
loopLogger.log(
'auto-permission classifier failed:',
error instanceof Error ? error.message : String(error),
);
}
for (const candidate of permissionCandidates) {
const decision = decisionsByToolCallId.get(candidate.toolCall.toolCallId);
if (!decision) {
loopLogger.log('auto-permission missing decision, falling back to prompt:', candidate.toolCall.toolCallId);
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: candidate.toolCall,
permission: candidate.permission,
subflow: [],
});
notifyPermissionPrompt(candidate.toolCall);
continue;
}
loopLogger.log(
'emitting tool-permission-auto-decision, toolCallId:',
candidate.toolCall.toolCallId,
'decision:',
decision.decision,
);
yield* processEvent({
runId,
type: "tool-permission-auto-decision",
toolCallId: candidate.toolCall.toolCallId,
toolCall: candidate.toolCall,
permission: candidate.permission,
decision: decision.decision,
reason: decision.reason,
subflow: [],
});
if (decision.decision === "deny") {
loopLogger.log(
'auto-permission denied, falling back to prompt:',
candidate.toolCall.toolCallId,
);
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: candidate.toolCall,
permission: candidate.permission,
subflow: [],
});
notifyPermissionPrompt(candidate.toolCall);
}
}
} else {
for (const candidate of permissionCandidates) {
loopLogger.log('emitting tool-permission-request, toolCallId:', candidate.toolCall.toolCallId);
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: candidate.toolCall,
permission: candidate.permission,
subflow: [],
});
notifyPermissionPrompt(candidate.toolCall);
}
}
}
}
}
}

View file

@ -14,7 +14,7 @@ export async function identifyIfSignedIn(): Promise<void> {
if (!billing.userId) return;
identify(billing.userId, {
...(billing.userEmail ? { email: billing.userEmail } : {}),
plan: billing.subscriptionPlan,
plan: billing.subscriptionPlanId,
status: billing.subscriptionStatus,
});
} catch (err) {

View file

@ -1,6 +1,6 @@
import { AsyncLocalStorage } from 'node:async_hooks';
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'background_task_agent' | 'meeting_note' | 'knowledge_sync';
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'background_task_agent' | 'meeting_note' | 'knowledge_sync' | 'code_session';
export interface UseCaseContext {
useCase: UseCase;

View file

@ -5,6 +5,8 @@ import { isConfigured as isComposioConfigured } from "../../composio/client.js";
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
import container from "../../di/container.js";
import type { ICodeModeConfigRepo } from "../../code-mode/repo.js";
import type { ISlackConfigRepo } from "../../slack/repo.js";
import { knowledgeSourcesRepo } from "../../knowledge/sources/repo.js";
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
@ -12,7 +14,7 @@ const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
* Generate dynamic instructions section for Composio integrations.
* Lists connected toolkits and explains the meta-tool discovery flow.
*/
async function getComposioToolsPrompt(): Promise<string> {
async function getComposioToolsPrompt(slackConnected: boolean = false): Promise<string> {
if (!(await isComposioConfigured())) {
return '';
}
@ -22,28 +24,54 @@ async function getComposioToolsPrompt(): Promise<string> {
? `**Currently connected:** ${connectedToolkits.map(slug => CURATED_TOOLKITS.find(t => t.slug === slug)?.displayName ?? slug).join(', ')}`
: `**No services connected yet.** Load the \`composio-integration\` skill to help the user connect one.`;
// Slack is connected natively, so exclude it from the Composio catch-all.
const slackException = slackConnected
? ` Exception: **Slack is connected natively** — use the \`slack\` skill for Slack, not Composio.`
: '';
return `
## Composio Integrations
${connectedSection}
Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first.
Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first.${slackException}
`;
}
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true): string {
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true, slackConnected: boolean = false, slackChannelsHint: string = ''): string {
// Conditionally include Composio-related instruction sections
const emailDraftSuffix = composioEnabled
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
: ` Do NOT load this skill for reading, fetching, or checking emails.`;
// When Slack is connected natively (desktop/cURL auth, not Composio), keep it
// out of the Composio routing examples so the Copilot doesn't try to connect
// it through Composio and wrongly report it as unavailable.
const composioServiceExamples = slackConnected
? 'Gmail, GitHub, LinkedIn, Notion, Google Sheets, Jira, etc.'
: 'Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.';
const thirdPartyBlock = composioEnabled
? `\n**Third-Party Services:** When users ask to interact with any external service (Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
? `\n**Third-Party Services:** When users ask to interact with any external service (${composioServiceExamples}) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
: '';
// Slack is connected directly in Rowboat (agent-slack CLI), independent of
// Composio. Route every Slack request to the native \`slack\` skill so the
// Copilot never claims Slack isn't connected or sends it through Composio.
const slackChannelsLine = slackChannelsHint
? ` The user has selected these Slack channels to follow: ${slackChannelsHint}. For broad "what's on my Slack / catch me up / anything new" requests, query THESE channels directly with \`agent-slack message list "#channel" --workspace <url> --oldest <unix-seconds> --limit 100 --resolve-users\` (use \`--oldest\`/\`--latest\` to scope to today/yesterday). Do NOT rely on \`search messages\` or \`unreads\` to answer catch-up questions — they frequently return empty with desktop-imported auth even when channels have messages; direct \`message list\` is authoritative.`
: '';
const slackBlock = slackConnected
? `\n**Slack (connected):** Slack is connected directly in Rowboat (via the agent-slack CLI, not Composio). For ANY Slack request — summarizing or reading messages, catching up on channels or DMs, searching, listing users, or sending a message — your FIRST action MUST be \`loadSkill('slack')\`, then use the \`agent-slack\` commands it documents via \`executeCommand\` (the selected workspaces are in \`config/slack.json\`). NEVER tell the user Slack isn't connected, and NEVER route Slack through the \`composio-integration\` skill.${slackChannelsLine}\n`
: '';
const slackToolPriority = slackConnected
? ` For Slack specifically, load the \`slack\` skill and use the agent-slack CLI — Slack is connected natively, not via Composio.`
: '';
const toolPriority = composioEnabled
? `For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integration\` skill. For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.`;
? `For third-party services (GitHub, Gmail, etc.), load the \`composio-integration\` skill.${slackToolPriority} For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.${slackToolPriority}`;
const slackToolsLine = composioEnabled
? `- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.\n`
@ -76,7 +104,7 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first.${emailDraftSuffix}
${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
${thirdPartyBlock}${slackBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
@ -130,6 +158,8 @@ Unlike other AI assistants that start cold every session, you have access to a l
When a user asks you to prep them for a call with someone, you already know every prior decision, concerns they've raised, and commitments on both sides - because memory has been accumulating across every email and call, not reconstructed on demand.
## The Knowledge Graph
The knowledge graph is the user's **Brain**. If the user says "my brain", "the brain", "look into your brain", "check my brain", "Brain", or similar, they mean the knowledge graph stored in \`knowledge/\`. Treat "Brain" and "knowledge graph" as the same thing.
The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`knowledge/\` (inside the workspace). The folder is organized into these categories:
- **Notes/** - Default location for user-authored notes. Create new notes here unless the user specifies a different folder.
- **People/** - Notes on individuals, tracking relationships, decisions, and commitments
@ -332,14 +362,41 @@ export async function buildCopilotInstructions(): Promise<string> {
} catch {
// repo unavailable — default to disabled
}
let slackConnected = false;
let slackChannelsHint = '';
try {
const slackRepo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
const slackConfig = await slackRepo.getConfig();
slackConnected = slackConfig.enabled && slackConfig.workspaces.length > 0;
} catch {
// repo unavailable — default to not connected
}
if (slackConnected) {
try {
// Surface the channels the user selected for sync so the Copilot
// queries those directly instead of relying on workspace-wide search.
const slackSource = knowledgeSourcesRepo.getConfig().sources
.find(source => source.provider === 'slack' && source.enabled);
const channels = (slackSource?.scopes ?? []).filter(scope => scope.type === 'channel');
slackChannelsHint = channels
.map(scope => {
const raw = scope.name || scope.id;
const display = raw.startsWith('#') ? raw : `#${raw}`;
return scope.workspaceUrl ? `${display} (${scope.workspaceUrl})` : display;
})
.join(', ');
} catch {
// knowledge sources unavailable — fall back to no channel hint
}
}
const excludeIds: string[] = [];
if (!composioEnabled) excludeIds.push('composio-integration');
if (!codeModeEnabled) excludeIds.push('code-with-agents');
const catalog = excludeIds.length > 0
? buildSkillCatalog({ excludeIds })
: skillCatalog;
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled);
const composioPrompt = await getComposioToolsPrompt();
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled, slackConnected, slackChannelsHint);
const composioPrompt = await getComposioToolsPrompt(slackConnected);
cachedInstructions = composioPrompt
? baseInstructions + '\n' + composioPrompt
: baseInstructions;

View file

@ -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\`:

View file

@ -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)

View file

@ -202,6 +202,7 @@ Subject: Re: {original_subject}
**Drafting Guidelines:**
- Draft ONE email - do not offer multiple versions or options unless explicitly asked
- Be concise and professional
- If you include a sign-off name, use only the user's first name, never their full name
- For scheduling: propose specific times based on calendar availability
- For inquiries: answer directly or indicate what info is needed
- Reference any relevant context from memory naturally - show you remember past interactions

View file

@ -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,
},
{

View file

@ -5,12 +5,27 @@ You interact with Slack by running **agent-slack** commands through \`executeCom
---
## 1. Check Connection
## 1. Check Connection & Selected Channels
Before any Slack operation, read \`config/slack.json\` from the workspace root. If \`enabled\` is \`false\` or the \`workspaces\` array is empty, simply tell the user: "Slack is not enabled. You can enable it in the Connectors settings." Do not attempt any agent-slack commands.
If enabled, use the workspace URLs from the config for all commands.
**Which channels the user follows:** The user selects specific channels to sync in \`config/knowledge_sources.json\`. Read that file and find the source with \`"provider": "slack"\`; its \`scopes\` array (entries with \`"type": "channel"\`) lists the selected channels (each has a \`name\` like \`#general\` and an optional \`workspaceUrl\`). For broad "what's on my Slack / catch me up / anything new" requests where the user did NOT name a channel, query these selected channels directly — do not guess or run workspace-wide search.
---
## 1b. Catching Up ("what's new", "today", "yesterday")
For catch-up questions, list recent messages from each selected channel and filter by time with \`--oldest\` / \`--latest\` (Unix-epoch seconds):
\`\`\`
# Everything in #general since the start of today (compute the epoch for 00:00 local)
agent-slack message list "#general" --workspace https://team.slack.com --oldest 1718668800 --limit 100 --resolve-users
\`\`\`
**Do NOT use \`agent-slack unreads\` or \`agent-slack search messages\` to answer catch-up questions.** With desktop-imported auth those endpoints frequently return empty even when channels clearly have messages. Direct \`message list\` against the selected channels is the authoritative source. Run one \`message list\` per selected channel (batch them in a single \`executeCommand\` with \`;\` separators), then summarize across channels. Always pass \`--resolve-users\` so author names are readable.
---
## 2. Core Commands
@ -41,6 +56,8 @@ agent-slack message react remove "<target>" <emoji> --ts <ts>
### Search
Note: search is best for finding a *specific* message by keyword. It can return empty under desktop-imported auth, so never conclude "there's nothing on Slack" from an empty search fall back to \`message list\` on the selected channels (see section 1b).
\`\`\`
agent-slack search messages "query text" --limit 20
agent-slack search messages "query" --channel "#channel-name" --user "@username"

View file

@ -1,8 +1,9 @@
import { z, ZodType } from "zod";
import * as path from "path";
import * as os from "os";
import * as fs from "fs/promises";
import { existsSync, readFileSync } from "fs";
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
import { agentSlackShimEnv } from "../../slack/agent-slack-exec.js";
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
import container from "../../di/container.js";
@ -16,6 +17,12 @@ 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";
import type { ICodeProjectsRepo } from "../../code-mode/projects/repo.js";
import * as gitService from "../../code-mode/git/service.js";
// Inputs for the bg-task builtin tools. Reuse the canonical schema field
// descriptions; only `triggers` gets a tighter contextual override (the
@ -28,6 +35,9 @@ const CreateBackgroundTaskInput = BackgroundTaskSchema.pick({
provider: true,
}).extend({
triggers: TriggersSchema.optional().describe('All three sub-fields (cronExpr, windows, eventMatchCriteria) are independently optional — mix freely. No triggers at all = manual-only (user clicks Run).'),
projectDir: z.string().optional().describe(
"Set this ONLY when the user wants the task to WRITE CODE. An absolute path (or ~/…) to a LOCAL GIT REPOSITORY with at least one commit. It turns this into a *coding task*: each run scans the trigger source for actionable items and implements them autonomously in isolated git worktrees off this repo — never touching the user's checkout. Extract the directory from the user's request (e.g. 'use ~/Work/space/test as the work directory'). Omit for ordinary output/action tasks.",
),
});
const PatchBackgroundTaskInput = BackgroundTaskSchema.pick({
@ -40,7 +50,43 @@ const PatchBackgroundTaskInput = BackgroundTaskSchema.pick({
}).partial().extend({
slug: z.string().describe('The slug of the task to update (the folder name under bg-tasks/).'),
triggers: TriggersSchema.optional().describe('Replace the triggers object. To remove all triggers (make manual-only) pass an empty object.'),
projectDir: z.string().optional().describe("Point an existing task at a code repo (or change which one) to make it a coding task. Absolute path or ~/… to a local git repository with at least one commit. Same rules as on create."),
});
// Turn a user-supplied directory into a registered code project id. Reuses the
// same idempotent registry the Code-section picker writes to (add() validates the
// dir exists & is a directory, and dedupes by resolved path). Returns a soft
// `warning` — not an error — when the repo isn't yet worktree-ready, so the task
// still gets created and the copilot can tell the user what to fix.
function expandHome(p: string): string {
const t = p.trim();
if (t === '~') return os.homedir();
if (t.startsWith('~/') || t.startsWith(`~${path.sep}`)) return path.join(os.homedir(), t.slice(2));
return t;
}
async function resolveCodeProject(dirPath: string): Promise<
{ ok: true; projectId: string; path: string; warning?: string } | { ok: false; error: string }
> {
const abs = path.resolve(expandHome(dirPath));
const projectsRepo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
let project: Awaited<ReturnType<ICodeProjectsRepo['add']>>;
try {
project = await projectsRepo.add(abs);
} catch (err) {
return { ok: false, error: `Could not use '${dirPath}' as a code directory: ${err instanceof Error ? err.message : String(err)}` };
}
// Worktree isolation needs a real git repo with at least one commit
// (codeSessionService.create throws otherwise). Surface it now as a soft
// warning rather than letting the next run fail silently.
let warning: string | undefined;
try {
const info = await gitService.repoInfo(project.path);
if (!info.isGitRepo) warning = `${project.path} is not a git repository yet — run \`git init\` and make a commit, or the coding sessions will fail.`;
else if (!info.hasCommits) warning = `${project.path} has no commits yet — make an initial commit, or the coding sessions will fail.`;
} catch { /* best effort — worktree creation will surface it later */ }
return { ok: true, projectId: project.id, path: project.path, ...(warning ? { warning } : {}) };
}
import { ensureLoaded as ensureBrowserSkillsLoaded, readSkillContent as readBrowserSkillContent, refreshFromRemote as refreshBrowserSkills } from "../browser-skills/index.js";
import type { ToolContext } from "./exec-tool.js";
import { generateText } from "ai";
@ -90,69 +136,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: {
@ -800,6 +783,9 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
try {
const rootDir = path.resolve(WorkDir);
const workingDir = cwd ? path.resolve(rootDir, cwd) : rootDir;
// Make `agent-slack` resolvable for skill-authored shell
// commands; the shim forwards to the bundled CLI.
const env = agentSlackShimEnv(path.join(rootDir, 'bin'));
// TODO: Re-enable this check
// const rootPrefix = rootDir.endsWith(path.sep)
@ -814,14 +800,12 @@ 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,
env,
signal: ctx.signal,
env: envOverride,
onData: (chunk: string) => {
ctx.publish({
runId: ctx.runId,
@ -851,7 +835,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, env });
return {
success: result.exitCode === 0,
@ -871,6 +855,112 @@ 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;
// Code-section sessions pin the working directory — never trust the model's
// cwd argument over the session's.
const effectiveCwd = ctx.codeCwd ?? cwd;
const manager = container.resolve<CodeModeManager>('codeModeManager');
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
// Approval policy: the session's (Code section) wins, else global settings,
// else default to asking the user.
let policy: ApprovalPolicy = 'ask';
if (ctx.codePolicy) {
policy = ctx.codePolicy;
} else {
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: effectiveCwd,
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)
// ============================================================================
@ -1442,15 +1532,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
inputSchema: CreateBackgroundTaskInput,
execute: async (input: z.infer<typeof CreateBackgroundTaskInput>) => {
try {
let projectId: string | undefined;
let warning: string | undefined;
if (input.projectDir) {
const r = await resolveCodeProject(input.projectDir);
if (!r.ok) return { success: false, error: r.error };
projectId = r.projectId;
warning = r.warning;
}
const { createTask } = await import("../../background-tasks/fileops.js");
const result = await createTask({
name: input.name,
instructions: input.instructions,
...(input.triggers ? { triggers: input.triggers } : {}),
...(projectId ? { projectId } : {}),
...(input.model ? { model: input.model } : {}),
...(input.provider ? { provider: input.provider } : {}),
});
return { success: true, slug: result.slug };
return { success: true, slug: result.slug, ...(warning ? { warning } : {}) };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
@ -1463,9 +1562,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
execute: async (input: z.infer<typeof PatchBackgroundTaskInput>) => {
try {
const { patchTask } = await import("../../background-tasks/fileops.js");
const { slug, ...partial } = input;
const { slug, projectDir, ...partial } = input;
let warning: string | undefined;
if (projectDir) {
const r = await resolveCodeProject(projectDir);
if (!r.ok) return { success: false, error: r.error };
(partial as { projectId?: string }).projectId = r.projectId;
warning = r.warning;
}
const result = await patchTask(slug, partial);
return { success: true, task: result };
return { success: true, task: result, ...(warning ? { warning } : {}) };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
@ -1501,6 +1607,35 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'launch-code-task': {
description: "Launch an autonomous coding session that implements a unit of work in the bg-task's pinned code repo. ONLY usable from a coding background task (one with a configured code project). The session runs full-auto in its own isolated git worktree/branch — it never touches the user's checkout — and runs asynchronously: this returns as soon as the session is created, so you can launch several (one per group of related items) in the same run. The tool writes and later updates a row under a `## Code Sessions` section in the task's index.md — do NOT edit that section yourself. Write an excellent, fully self-contained `prompt`: the coding agent has no other context and no human to ask. Group related items into one call; split unrelated items into separate calls.",
inputSchema: z.object({
taskSlug: z.string().describe("The slug of THIS background task (it's in your run message, e.g. 'implement-meeting-items'). Used to find the pinned repo and to update index.md."),
meeting: z.string().min(1).describe("The name/title of the meeting these items came from (e.g. 'Eng Sync — 2026-06-18'). Sessions are grouped under this heading in index.md so the user can see which meeting each change came from."),
title: z.string().min(1).max(120).describe("Short human title for this unit of work — one line in index.md (e.g. 'Add retry to upload client')."),
items: z.string().min(1).describe("Brief description of the action item(s) this session implements, for the summary row (e.g. 'Fix flaky upload + add retry; raised in standup')."),
prompt: z.string().min(1).describe("The full, self-contained coding instruction. Include the concrete goal, relevant context from the meeting, any files/areas to look at, and what 'done' means. The agent runs autonomously with no human — be specific and complete."),
context: z.string().optional().describe("Optional extra context, e.g. the relevant excerpt from the meeting."),
}),
execute: async (input: { taskSlug: string; meeting: string; title: string; items: string; prompt: string; context?: string }, ctx?: ToolContext) => {
try {
const { launchCodeTask } = await import("../../background-tasks/code-sessions.js");
const result = await launchCodeTask({
taskSlug: input.taskSlug,
meeting: input.meeting,
title: input.title,
items: input.items,
prompt: input.prompt,
...(input.context ? { context: input.context } : {}),
...(ctx?.runId ? { runId: ctx.runId } : {}),
});
return result;
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
},
'notify-user': {
description: "Show a native OS notification to the user. Clicking the notification opens the provided link in the default browser, or focuses the Rowboat app if no link is given.",
inputSchema: z.object({

View file

@ -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 {

View file

@ -14,6 +14,15 @@ 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;
// Set for Code-section sessions in Rowboat mode: the session's working directory
// and approval policy. code_agent_run honors these over the model's cwd argument
// and the global approval policy.
codeCwd?: string | null;
codePolicy?: 'ask' | 'auto-approve-reads' | 'yolo' | null;
}
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> {

View file

@ -9,6 +9,7 @@ export type MiddlePaneContext =
| { kind: 'browser'; url: string; title: string };
export type CodeMode = 'claude' | 'codex';
export type CodePolicy = 'ask' | 'auto-approve-reads' | 'yolo';
type EnqueuedMessage = {
messageId: string;
@ -17,11 +18,16 @@ type EnqueuedMessage = {
voiceOutput?: VoiceOutputMode;
searchEnabled?: boolean;
codeMode?: CodeMode;
// Code-section sessions pin the coding agent's working directory and
// approval policy for the turn (code_agent_run honors these over its
// model-provided arguments / the global policy).
codeCwd?: string;
codePolicy?: CodePolicy;
middlePaneContext?: MiddlePaneContext;
};
export interface IMessageQueue {
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string>;
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode, codeCwd?: string, codePolicy?: CodePolicy): Promise<string>;
dequeue(runId: string): Promise<EnqueuedMessage | null>;
}
@ -37,7 +43,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator;
}
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string> {
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode, codeCwd?: string, codePolicy?: CodePolicy): Promise<string> {
if (!this.store[runId]) {
this.store[runId] = [];
}
@ -49,6 +55,8 @@ export class InMemoryMessageQueue implements IMessageQueue {
voiceOutput,
searchEnabled,
codeMode,
codeCwd,
codePolicy,
middlePaneContext,
});
return id;

View file

@ -0,0 +1,29 @@
import type { NotificationCategory } from '@x/shared/dist/notification-settings.js';
import { isNotificationCategoryEnabled } from '../../config/notification_config.js';
import type { INotificationService, NotifyInput } from './service.js';
/**
* Fire a notification for `category`, but only if the user has that category
* enabled and the platform supports notifications.
*
* Resolution of the notification service is done via a *dynamic* import of the
* DI container so that callers like the agent runtime which the container
* itself imports don't create a circular module dependency. The whole thing
* is wrapped so a missing service (very early startup), an unsupported
* platform, or a config read error can never disrupt the run/sync that
* triggered it. Callers should fire-and-forget (`void notifyIfEnabled(...)`).
*/
export async function notifyIfEnabled(
category: NotificationCategory,
input: NotifyInput,
): Promise<void> {
try {
if (!isNotificationCategoryEnabled(category)) return;
const { default: container } = await import('../../di/container.js');
const service = container.resolve<INotificationService>('notificationService');
if (!service.isSupported()) return;
service.notify(input);
} catch (err) {
console.error(`[notifier] failed to notify (category=${category}):`, err);
}
}

View file

@ -4,6 +4,14 @@ export interface NotifyInput {
link?: string;
actionLabel?: string;
secondaryActions?: Array<{ label: string; link: string }>;
/**
* When true, the notification is suppressed if the app is currently in the
* foreground (any window focused). Use for ambient notifications the user
* doesn't need while actively looking at the app (e.g. chat completion, new
* email). Leave unset/false for notifications that must always surface
* regardless of focus (e.g. an agent permission request that blocks a run).
*/
onlyWhenBackground?: boolean;
}
export interface INotificationService {

View file

@ -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 …"
@ -40,6 +47,12 @@ On every run: perform the action using the appropriate tool (Slack, email, web-f
If your instructions imply BOTH ("summarize and email it"), do both per run.
CODE MODE implement code via isolated sessions.
Only available when the run message contains a **"# Coding task"** block (the task is pinned to a code repository). In that case:
- Detect actionable coding items from the source (e.g. the meeting notes named in the trigger), conservatively. Only implement clearly-scoped, self-contained items. Ambiguous, large/architectural, or other-repo items list them in \`index.md\` as "needs review"; do not code them.
- Group related items, then call \`launch-code-task\` once per group (\`taskSlug\` is your own slug). It runs full-auto in an isolated worktree and **owns the \`## Code Sessions\` section of \`index.md\`** — never edit those rows yourself. Write a complete, self-contained \`prompt\`: the coding agent has no other context and no human to ask.
- If nothing is actionable, launch nothing and say so in your summary.
# Triggers
The run message tells you which trigger fired and how to interpret it:
@ -69,9 +82,21 @@ The workspace lives at \`${WorkDir}\`.
`;
export function buildBackgroundTaskAgent(): z.infer<typeof Agent> {
// A running bg-task must not manage bg-tasks: re-running itself risks a
// recursive cascade, and patch/create can clobber its own task.yaml (a weak
// model has done exactly this, dropping the pinned projectId). It implements
// code via `launch-code-task`, not by editing task specs.
const EXCLUDED = new Set([
'executeCommand', // headless: no interactive approval
'code_agent_run', // headless: needs interactive permission UI
'run-background-task-agent',
'create-background-task',
'patch-background-task',
]);
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
if (name === 'executeCommand') continue;
if (EXCLUDED.has(name)) continue;
tools[name] = { type: 'builtin', name };
}

View file

@ -0,0 +1,333 @@
import fs from 'fs/promises';
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
import type { GitStatusFile } from '@x/shared/dist/code-sessions.js';
import container from '../di/container.js';
import type { CodeSessionService } from '../code-mode/sessions/service.js';
import type { ICodeProjectsRepo } from '../code-mode/projects/repo.js';
import * as gitService from '../code-mode/git/service.js';
import { extractAgentResponse } from '../agents/utils.js';
import { withFileLock } from '../knowledge/file-lock.js';
import { fetchTask, taskIndexPath } from './fileops.js';
const log = new PrefixLogger('BgTask:Code');
// A code session that hangs (engine wedged, never settles) shouldn't pin a
// "running…" row forever. After this long we finalize from whatever the
// worktree shows and tell the user to check the session.
const MAX_WATCH_MS = 90 * 60 * 1000;
// A single bg-task run must not spawn an unbounded fleet of code sessions — a
// weak model has called this 11+ times in one run. Cap per agent run.
const MAX_LAUNCHES_PER_RUN = 5;
const launchesPerRun = new Map<string, number>();
export interface LaunchCodeTaskArgs {
/** The bg-task slug — used to find the pinned projectId and to write index.md. */
taskSlug: string;
/** The meeting these items came from — sessions are grouped under it in index.md. */
meeting: string;
/** Short human title for this unit of work (one row in index.md). */
title: string;
/** Short description of the item(s) being implemented (for the row). */
items: string;
/** The detailed, task-specific coding instruction written by the agent. */
prompt: string;
/** Optional extra context (e.g. the relevant meeting excerpt). */
context?: string;
/** The bg-task agent's runId — used to cap launches per run. */
runId?: string;
}
export interface LaunchCodeTaskResult {
success: boolean;
sessionId?: string;
branch?: string;
worktreePath?: string;
error?: string;
}
// Wrap the agent-authored task body in a robust autonomous-coding scaffold so
// every launch gets a strong, self-contained first message regardless of how
// the agent phrased its part. The session runs full-auto (yolo) with no human.
function buildCodePrompt(args: { prompt: string; branch: string; context?: string }): string {
const { prompt, branch, context } = args;
return `You are an autonomous coding agent. There is NO human present to answer questions, approve steps, or review mid-way — make reasonable decisions and drive the task to a complete, working result on your own.
${context ? `## Context\n${context}\n\n` : ''}## Task
${prompt}
## Operating rules
- You are on an isolated branch/worktree (\`${branch}\`). Work only within this repository; your changes never touch the user's main checkout.
- Implement the task end-to-end. Do not stop half-way, leave TODOs/stubs, or defer work back to the user.
- Before you start, briefly explore the repo to match its existing conventions, structure, and style.
- After implementing, VERIFY: run the project's build / typecheck / lint and any directly relevant tests. Fix anything you break.
- Make small, logically-scoped git commits with clear messages as you go.
- Stay in scope don't refactor unrelated code or make sweeping changes the task didn't ask for.
- If the task is genuinely ambiguous or blocked (missing dependency, contradictory requirement), make the safest reasonable partial progress and clearly flag what's blocked in your final summary never guess in a way that could be destructive.
## When done
Finish your response with a section titled exactly \`## Summary\` as the LAST thing you write — nothing after it. Under it, put 25 short bullet points only: what you changed, which files/areas, how you verified it, and any follow-ups or blockers. No narration or preamble inside the summary (no "I then…", "Let me…") — just the facts. This section is shown to the user verbatim, so keep it clean and self-contained.`;
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// The code agent's final message is mostly streamed narration ("Let me view it
// in context…"). We instruct it to end with a `## Summary` section — extract just
// that. Fall back to the last paragraph if it didn't comply.
const SUMMARY_MAX_CHARS = 900;
function cleanSummary(text: string): string {
if (!text) return '';
let body: string;
const idx = text.toLowerCase().lastIndexOf('## summary');
if (idx >= 0) {
body = text.slice(idx + '## summary'.length).trim();
} else {
const paras = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
body = paras.length ? paras[paras.length - 1] : text.trim();
}
// Drop empty lines and any leftover heading markers; keep bullet structure.
const lines = body.split('\n').map((l) => l.replace(/^#+\s*/, '').trimEnd()).filter((l) => l.trim() !== '');
let out = lines.join('\n').trim();
if (out.length > SUMMARY_MAX_CHARS) out = out.slice(0, SUMMARY_MAX_CHARS).trimEnd() + '…';
return out;
}
// Render a summary as a clean markdown blockquote, preserving its bullet lines.
function quoteSummary(summary: string): string[] {
const cleaned = cleanSummary(summary);
if (!cleaned) return [];
return ['', ...cleaned.split('\n').map((l) => (l.trim() ? `> ${l.trim()}` : '>'))];
}
const SECTION_HEADING = '## Code Sessions';
function startMarker(id: string): string { return `<!-- cs-start:${id} -->`; }
function endMarker(id: string): string { return `<!-- cs-end:${id} -->`; }
function meetingHeading(meeting: string): string {
return `### 📅 ${meeting}`;
}
function runningBlock(args: { sessionId: string; title: string; items: string; branch: string; worktreePath: string }): string {
const { sessionId, title, items, branch, worktreePath } = args;
return [
startMarker(sessionId),
`#### ⏳ ${title}`,
`- **Items:** ${items}`,
`- **Branch:** \`${branch}\``,
`- **Worktree:** \`${worktreePath}\``,
`- **Session:** \`${sessionId}\` _(running…)_`,
endMarker(sessionId),
].join('\n');
}
// Append a "running" block for a freshly launched session, grouped under its
// meeting's heading inside the Code Sessions section (creating section/heading as
// needed). Serialized via the index.md file lock so concurrent launches don't
// clobber each other.
async function appendRunningBlock(slug: string, meeting: string, block: string): Promise<void> {
const indexPath = taskIndexPath(slug);
await withFileLock(indexPath, async () => {
let content = '';
try {
content = await fs.readFile(indexPath, 'utf-8');
} catch {
content = '';
}
if (!content.includes(SECTION_HEADING)) {
const sep = content.endsWith('\n') || content === '' ? '' : '\n';
content += `${sep}\n${SECTION_HEADING}\n`;
}
const heading = meetingHeading(meeting);
const lines = content.split('\n');
const headingIdx = lines.findIndex((l) => l.trim() === heading);
if (headingIdx === -1) {
// New meeting group — append heading + block at the end.
if (!content.endsWith('\n')) content += '\n';
content += `\n${heading}\n\n${block}\n`;
} else {
// Existing meeting — insert this block right after the heading so
// sessions stay grouped (newest first within the group).
lines.splice(headingIdx + 1, 0, '', block);
content = lines.join('\n');
}
await fs.writeFile(indexPath, content, 'utf-8');
});
}
// Replace a session's block in place once its run settles.
async function finalizeBlock(slug: string, sessionId: string, block: string): Promise<void> {
const indexPath = taskIndexPath(slug);
await withFileLock(indexPath, async () => {
let content = '';
try {
content = await fs.readFile(indexPath, 'utf-8');
} catch {
return; // nothing to finalize against
}
const re = new RegExp(`${escapeRegExp(startMarker(sessionId))}[\\s\\S]*?${escapeRegExp(endMarker(sessionId))}`);
if (re.test(content)) {
content = content.replace(re, block);
} else {
// The running block went missing (manual edit?) — append the final one.
if (!content.endsWith('\n')) content += '\n';
content += `\n${block}\n`;
}
await fs.writeFile(indexPath, content, 'utf-8');
});
}
// Once the code turn settles, summarize from the worktree diff + the agent's
// final message and rewrite the row.
async function finalizeFromResult(
slug: string,
args: { sessionId: string; title: string; items: string; branch: string; worktreePath: string; baseBranch?: string; timedOut?: boolean; error?: string },
): Promise<void> {
const { sessionId, title, items, branch, worktreePath, baseBranch, timedOut, error } = args;
let summary = '';
try {
summary = (await extractAgentResponse(sessionId)) ?? '';
} catch { /* best effort */ }
// Count everything the session changed since it forked — including commits
// (the autonomous scaffold tells the agent to commit, so working-tree status
// alone would read as "no changes"). Fall back to working-tree status if we
// don't know the base.
let files: GitStatusFile[] = [];
try {
files = baseBranch
? await gitService.changedSinceBase(worktreePath, baseBranch)
: await gitService.status(worktreePath);
} catch { /* worktree may be gone */ }
const ins = files.reduce((a, f) => a + (f.insertions ?? 0), 0);
const del = files.reduce((a, f) => a + (f.deletions ?? 0), 0);
let heading: string;
let status: string;
if (error) {
heading = `#### ❌ ${title}`;
status = `Failed — ${error}`;
} else if (timedOut) {
heading = `#### ⌛ ${title}`;
status = `Timed out — open the session to check progress`;
} else if (files.length > 0) {
heading = `#### ✅ ${title}`;
status = `Implemented — ${files.length} file(s) changed (+${ins} / -${del})`;
} else {
heading = `#### ⚠️ ${title}`;
status = `No file changes — open the session for details`;
}
const fileLines = files.slice(0, 25).map((f) => ` - \`${f.path}\` (${f.state})`);
const more = files.length > 25 ? [` - …and ${files.length - 25} more`] : [];
const block = [
startMarker(sessionId),
heading,
`- **Items:** ${items}`,
`- **Branch:** \`${branch}\``,
`- **Session:** \`${sessionId}\``,
`- **Status:** ${status}`,
...(files.length > 0 ? ['- **Files:**', ...fileLines, ...more] : []),
...quoteSummary(summary),
endMarker(sessionId),
].join('\n');
await finalizeBlock(slug, sessionId, block);
}
/**
* Launch a coding session for a bg-task, asynchronously.
*
* Creates an isolated worktree session (yolo, direct, claude), fires the prompt
* without waiting, writes a "running" row into the task's index.md, and detaches
* a watcher that finalizes the row once the turn settles. Returns as soon as the
* session exists so the bg-task agent can launch more groups (or finish).
*/
export async function launchCodeTask(args: LaunchCodeTaskArgs): Promise<LaunchCodeTaskResult> {
const { taskSlug, meeting, title, items, prompt, context, runId } = args;
// Per-run launch cap — stop a runaway agent from spawning a session fleet.
if (runId) {
const used = launchesPerRun.get(runId) ?? 0;
if (used >= MAX_LAUNCHES_PER_RUN) {
return { success: false, error: `Launch cap reached (${MAX_LAUNCHES_PER_RUN} code sessions per run). Group remaining items instead of launching more.` };
}
launchesPerRun.set(runId, used + 1);
}
const task = await fetchTask(taskSlug);
if (!task) {
return { success: false, error: `Background task '${taskSlug}' not found.` };
}
if (!task.projectId) {
return { success: false, error: `Task '${taskSlug}' has no configured code project (repo). Set one to use launch-code-task.` };
}
const projectsRepo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
const project = await projectsRepo.get(task.projectId);
if (!project) {
return { success: false, error: `Configured code project '${task.projectId}' is no longer registered.` };
}
const codeSessionService = container.resolve<CodeSessionService>('codeSessionService');
let session;
try {
session = await codeSessionService.create({
projectId: project.id,
title,
agent: 'claude',
mode: 'direct',
policy: 'yolo',
isolation: 'worktree',
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: `Could not create code session: ${msg}` };
}
const branch = session.worktree?.branch ?? 'rowboat/' + session.id;
const baseBranch = session.worktree?.baseBranch ?? undefined;
const worktreePath = session.cwd;
await appendRunningBlock(taskSlug, meeting, runningBlock({
sessionId: session.id, title, items, branch, worktreePath,
}));
const wrapped = buildCodePrompt({ prompt, branch, ...(context ? { context } : {}) });
log.log(`${taskSlug} — launched session ${session.id} on ${branch}`);
// Detached: drive the turn to completion, then finalize the index.md row.
// `sendMessage` resolves when the turn settles (it awaits the engine and
// never rejects on engine errors), so we don't need a separate completion
// subscription — but we still cap it with a timeout so a wedged engine can't
// pin the row at "running" forever.
void (async () => {
let timedOut = false;
try {
await Promise.race([
codeSessionService.sendMessage(session.id, wrapped),
new Promise<void>((resolve) => setTimeout(() => { timedOut = true; resolve(); }, MAX_WATCH_MS)),
]);
} catch (err) {
log.log(`${taskSlug} — session ${session.id} errored: ${err instanceof Error ? err.message : String(err)}`);
}
try {
await finalizeFromResult(taskSlug, {
sessionId: session.id, title, items, branch, worktreePath, timedOut,
...(baseBranch ? { baseBranch } : {}),
});
} catch (err) {
log.log(`${taskSlug} — finalize failed for ${session.id}: ${err instanceof Error ? err.message : String(err)}`);
}
})();
return { success: true, sessionId: session.id, branch, worktreePath };
}

View file

@ -97,6 +97,7 @@ export interface CreateTaskInput {
name: string;
instructions: string;
triggers?: BackgroundTask['triggers'];
projectId?: string;
model?: string;
provider?: string;
}
@ -136,6 +137,7 @@ export async function createTask(input: CreateTaskInput): Promise<{ slug: string
instructions: input.instructions,
active: true,
...(input.triggers ? { triggers: input.triggers } : {}),
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.model ? { model: input.model } : {}),
...(input.provider ? { provider: input.provider } : {}),
createdAt: new Date().toISOString(),
@ -194,6 +196,7 @@ export async function listTasks(opts: ListTasksOptions = {}): Promise<ListTasksR
instructions: task.instructions,
active: task.active,
...(task.triggers ? { triggers: task.triggers } : {}),
...(task.projectId ? { projectId: task.projectId } : {}),
createdAt: task.createdAt,
...(task.lastAttemptAt ? { lastAttemptAt: task.lastAttemptAt } : {}),
...(task.lastRunId ? { lastRunId: task.lastRunId } : {}),

View file

@ -31,11 +31,31 @@ const BG_TASK_EVENT_DECISION_DIRECTIVE = '**Decision:** Determine whether this e
const BG_TASK_MANUAL_PAREN = 'user-triggered — either the Run button in the Background Task detail view or the `run-background-task-agent` tool';
function buildCodeBlock(slug: string, project: { id: string; path: string; name: string }): string {
return `
# Coding task
This is a **coding task**. It is pinned to a code repository:
- **Project:** ${project.name}
- **Path:** \`${project.path}\`
Your job this run:
1. Read the relevant source (e.g. the meeting notes named in the trigger below) and identify **actionable coding items** bugs to fix, features to build, concrete changes requested.
2. Be **conservative**: only implement items that are clearly scoped and self-contained. Items that are ambiguous, large/architectural, or about a different repository do NOT code them. List them briefly in \`index.md\` as "needs review" instead.
3. **Group** related items together; keep unrelated items separate.
4. For each group, call the \`launch-code-task\` tool with \`taskSlug: "${slug}"\`, the \`meeting\` name/title these items came from (so sessions are grouped by meeting), a short \`title\`, the \`items\` summary, and a **detailed, fully self-contained \`prompt\`** describing exactly what to implement (the coding agent has no other context and no human to ask). Put the relevant meeting excerpt in \`context\`.
5. \`launch-code-task\` runs asynchronously in an isolated git worktree (full-auto) and manages a \`## Code Sessions\` section in \`index.md\` itself — **do not edit that section.** You may add a short note ABOVE it summarizing what you detected.
If there are no actionable coding items, launch nothing and say so in your final summary.`;
}
function buildMessage(
slug: string,
task: BackgroundTask,
trigger: BackgroundTaskTriggerType,
context?: string,
codeProject?: { id: string; path: string; name: string },
): string {
const now = new Date();
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
@ -50,7 +70,7 @@ function buildMessage(
**Instructions:**
${task.instructions}
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).`;
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).${codeProject ? buildCodeBlock(slug, codeProject) : ''}`;
return baseMessage + buildTriggerBlock({
trigger,
@ -103,6 +123,20 @@ export async function runBackgroundTask(
// `||` not `??`: an empty-string `task.model` (occasionally synthesized
// by an LLM call to create-background-task) should fall through to the
// default just like undefined does.
// Coding tasks carry a pinned code project — resolve it so the run
// message can tell the agent which repo to work in.
let codeProject: { id: string; path: string; name: string } | undefined;
if (task.projectId) {
try {
const { default: container } = await import('../di/container.js');
const projectsRepo = container.resolve<import('../code-mode/projects/repo.js').ICodeProjectsRepo>('codeProjectsRepo');
const project = await projectsRepo.get(task.projectId);
if (project) codeProject = { id: project.id, path: project.path, name: project.name };
} catch (err) {
log.log(`${slug} — could not resolve code project ${task.projectId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
const model = task.model || await getBackgroundTaskAgentModel();
const agentRun = await createRun({
agentId: 'background-task-agent',
@ -129,9 +163,16 @@ export async function runBackgroundTask(
// we leave `lastRunAt` / `lastRunSummary` / `lastRunError` untouched —
// the previous successful run stays visible in the UI even while this
// new run is in-flight or fails.
// `projectId` is runtime-owned config the agent must never lose. A weak
// model can clobber task.yaml mid-run (despite "never touch this"), which
// would silently disable coding on later runs — so we re-assert it on
// every patch to self-heal.
const heal = task.projectId ? { projectId: task.projectId } : {};
await patchTask(slug, {
lastAttemptAt: startedAt,
lastRunId: runId,
...heal,
});
backgroundTaskBus.publish({
@ -142,7 +183,7 @@ export async function runBackgroundTask(
});
try {
await createMessage(runId, buildMessage(slug, task, trigger, context));
await createMessage(runId, buildMessage(slug, task, trigger, context, codeProject));
await waitForRunCompletion(runId, { throwOnError: true });
const summary = await extractAgentResponse(runId);
@ -151,6 +192,7 @@ export async function runBackgroundTask(
lastRunAt: new Date().toISOString(),
lastRunSummary: summary ?? undefined,
lastRunError: undefined,
...heal,
});
log.log(`${slug} — done summary="${truncate(summary)}"`);
@ -171,7 +213,7 @@ export async function runBackgroundTask(
// state; the scheduler's backoff (lastAttemptAt + 5min) prevents
// retry-storming.
try {
await patchTask(slug, { lastRunError: msg });
await patchTask(slug, { lastRunError: msg, ...heal });
} catch {
// don't mask the original error
}

View file

@ -1,8 +1,10 @@
import { getAccessToken } from '../auth/tokens.js';
import { API_URL } from '../config/env.js';
import type { BillingInfo, BillingPlan } from '@x/shared/dist/billing.js';
import type { BillingInfo, BillingPlanId } from '@x/shared/dist/billing.js';
import { getRowboatConfig } from '../config/rowboat.js';
export async function getBillingInfo(): Promise<BillingInfo> {
const config = await getRowboatConfig();
const accessToken = await getAccessToken();
const response = await fetch(`${API_URL}/v1/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
@ -16,7 +18,7 @@ export async function getBillingInfo(): Promise<BillingInfo> {
email: string;
};
billing: {
plan: BillingPlan | null;
planId: BillingPlanId | null;
status: string | null;
trialExpiresAt: string | null;
usage: {
@ -37,9 +39,10 @@ export async function getBillingInfo(): Promise<BillingInfo> {
return {
userEmail: body.user.email ?? null,
userId: body.user.id ?? null,
subscriptionPlan: body.billing.plan,
subscriptionPlanId: body.billing.planId,
subscriptionStatus: body.billing.status,
trialExpiresAt: body.billing.trialExpiresAt ?? null,
catalog: config.billing,
monthly: body.billing.usage.monthly,
daily: body.billing.usage.daily,
};

View file

@ -0,0 +1,102 @@
import { createRequire } from 'module';
import * as path from 'path';
import { fileURLToPath } from 'url';
import type { CodingAgent } from './types.js';
import { getProvisionedEnginePath } from './engine-provisioner.js';
import { loginShellPath } from './shell-env.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;
}
// Locate an adapter's package.json. In packaged builds Electron Forge strips the
// workspace node_modules, so the adapters (+ their dependency closure) are staged
// next to the bundle at `.package/acp/node_modules` by the generateAssets hook (see
// apps/main/forge.config.cjs). In dev they resolve normally via the pnpm symlink.
// Try the staged location first, then fall back to ordinary resolution.
function resolveAdapterPkgJson(pkg: string): string {
// The main process is esbuild-bundled to `.package/dist/main.cjs`, so the staged
// adapters live one level up at `.package/acp`. (import.meta.url is rewritten to
// the bundle path by bundle.mjs, so this holds in both dev and packaged builds.)
const stagedRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'acp');
for (const opts of [{ paths: [stagedRoot] }, undefined]) {
try {
return require.resolve(`${pkg}/package.json`, opts);
} catch {
// not here — try the next resolution strategy
}
}
throw new Error(
`ACP adapter '${pkg}' not found — expected it staged at ` +
`${path.join(stagedRoot, 'node_modules', pkg)} (packaged build) or resolvable ` +
`from node_modules (dev).`,
);
}
// 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>`.
function resolveAdapterEntry(pkg: string): string {
const pkgJsonPath = resolveAdapterPkgJson(pkg);
const pkgDir = path.dirname(pkgJsonPath);
const pkgJson = require(pkgJsonPath) 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 };
// Graft the user's login-shell PATH onto the engine's env. GUI (Finder) launches
// inherit launchd's stripped PATH, so tools the engine spawns — git, gh, rg, bash —
// would otherwise fail with "command not found" even though they work from a
// terminal. No-op on Windows / when the probe fails.
const shellPath = loginShellPath();
if (shellPath && shellPath !== env.PATH) {
const dirs = [...shellPath.split(path.delimiter), ...(env.PATH ?? '').split(path.delimiter)];
env.PATH = [...new Set(dirs.filter(Boolean))].join(path.delimiter);
}
// Point the adapter at the engine the user already enabled in Settings. We do NOT
// download here — getProvisionedEnginePath throws a clear "enable it in Settings"
// error if the engine isn't present, so code mode never triggers a surprise
// mid-chat download. The version is locked to what the adapter was built against, so
// the ACP handshake is always compatible. The adapters honor these env vars
// (claude: CLAUDE_CODE_EXECUTABLE, codex: CODEX_PATH).
const executablePath = getProvisionedEnginePath(agent);
if (agent === 'claude') {
env.CLAUDE_CODE_EXECUTABLE = executablePath;
// Make the claude-agent-sdk log the exact spawn command + claude's stderr to
// ~/.claude/debug/sdk-*.txt, so a failed/hung launch has a diagnosable trail
// instead of a silently dropped connection.
env.DEBUG_CLAUDE_AGENT_SDK = '1';
} else {
env.CODEX_PATH = executablePath;
}
// 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 };
}

View 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;
}

View file

@ -0,0 +1,346 @@
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;
}
// Deadline for the startup phases (initialize / session create+load). A healthy cold
// start — adapter boot, engine spawn, SDK handshake, MCP connects — takes seconds; only
// a wedged engine takes this long. Without a deadline that failure mode is an infinite
// "(pending...)" with zero feedback. Prompts are intentionally NOT time-limited: turns
// legitimately run for many minutes and may wait on user permission asks. Overridable
// via ROWBOAT_ACP_STARTUP_TIMEOUT_MS (CI smoke test; escape hatch for MCP-heavy setups).
const STARTUP_TIMEOUT_MS = Number(process.env.ROWBOAT_ACP_STARTUP_TIMEOUT_MS) > 0
? Number(process.env.ROWBOAT_ACP_STARTUP_TIMEOUT_MS)
: 60_000;
export interface CodeAgentOption { value: string; label: string }
export interface CodeAgentModelOptions { models: CodeAgentOption[]; efforts: CodeAgentOption[] }
// The agent advertises its model + effort choices on the session it opens (the
// same data that backs its `/model` picker), in one of two shapes:
// - `configOptions`: select options with id "model" / "effort" (Claude).
// - `models`: a SessionModelState { availableModels: [{ modelId, name }] }
// (Codex — which folds effort into the model id, so no separate effort).
// We read configOptions first and fall back to `models`, then prepend a
// synthetic "Default" so the user can always keep the engine default.
type RawSelectOption = { value?: unknown; name?: unknown; options?: Array<{ value?: unknown; name?: unknown }> };
type RawConfigOption = { id?: string; options?: RawSelectOption[] };
type RawModelState = { availableModels?: Array<{ modelId?: unknown; name?: unknown }> };
function withDefault(choices: CodeAgentOption[]): CodeAgentOption[] {
return choices.some((c) => c.value === 'default')
? choices
: [{ value: 'default', label: 'Default' }, ...choices];
}
function toChoices(option: RawConfigOption | undefined): CodeAgentOption[] {
const flat = (option?.options ?? []).flatMap((o) => (Array.isArray(o.options) ? o.options : [o]));
return flat
.filter((o): o is { value: string; name?: unknown } => typeof o.value === 'string')
.map((o) => ({ value: o.value, label: typeof o.name === 'string' && o.name ? o.name : o.value }));
}
function modelStateChoices(models: RawModelState | undefined): CodeAgentOption[] {
return (models?.availableModels ?? [])
.filter((m): m is { modelId: string; name?: unknown } => typeof m.modelId === 'string')
.map((m) => ({ value: m.modelId, label: typeof m.name === 'string' && m.name ? m.name : m.modelId }));
}
export function extractModelOptions(configOptions: unknown, models?: unknown): CodeAgentModelOptions {
const list = (Array.isArray(configOptions) ? configOptions : []) as RawConfigOption[];
const modelOpt = list.find((o) => o.id === 'model');
const effortOpt = list.find((o) => o.id === 'effort');
const modelChoices = toChoices(modelOpt);
return {
// configOptions is authoritative when present; otherwise fall back to the
// SessionModelState list (Codex reports models only there).
models: withDefault(modelChoices.length ? modelChoices : modelStateChoices(models as RawModelState)),
efforts: effortOpt ? withDefault(toChoices(effortOpt)) : [],
};
}
// Claude's `availableModels` exposes its top model only as "Default
// (recommended)" and omits an explicit "Opus" row (the interactive `/model`
// lists it, the ACP adapter dedupes it). Surface the canonical aliases
// explicitly for clarity — the adapter resolves "opus"/"sonnet"/"haiku" to the
// concrete model. Deduped against what the engine already returned, so in
// practice this only adds the missing "Opus" entry, placed right after Default.
const CLAUDE_ALIAS_ROWS: CodeAgentOption[] = [
{ value: 'opus', label: 'Opus' },
{ value: 'sonnet', label: 'Sonnet' },
{ value: 'haiku', label: 'Haiku' },
];
function withClaudeAliases(options: CodeAgentModelOptions): CodeAgentModelOptions {
const have = new Set(options.models.map((m) => m.value));
const extra = CLAUDE_ALIAS_ROWS.filter((r) => !have.has(r.value));
if (extra.length === 0) return options;
const at = options.models.findIndex((m) => m.value === 'default');
const models = [...options.models];
models.splice(at >= 0 ? at + 1 : 0, 0, ...extra);
return { ...options, models };
}
// 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.withStartupTimeout(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');
}
}
// Race a startup-phase request against the deadline so a wedged engine fails with a
// clear, enriched error instead of leaving the turn pending forever. Callers dispose
// the client on failure, which kills the spawned adapter.
private async withStartupTimeout<T>(work: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(
`timed out after ${STARTUP_TIMEOUT_MS / 1000}s — the ${this.agent} engine failed to ` +
`complete startup (it may be wedged or misconfigured)`,
));
}, STARTUP_TIMEOUT_MS);
timer.unref?.();
});
try {
return await Promise.race([work, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
async newSession(): Promise<string> {
try {
const res = await this.withStartupTimeout(this.conn().newSession({ cwd: this.cwd, mcpServers: [] }));
return res.sessionId;
} catch (e) {
throw this.enrich(e, 'newSession');
}
}
// Open a throwaway session purely to read the agent's advertised model +
// effort choices, then let the caller dispose this client. Used for the
// model picker before any real session exists.
async describeModelOptions(): Promise<CodeAgentModelOptions> {
try {
const res = await this.withStartupTimeout(this.conn().newSession({ cwd: this.cwd, mcpServers: [] }));
const r = res as { configOptions?: unknown; models?: unknown };
const options = extractModelOptions(r.configOptions, r.models);
return this.agent === 'claude' ? withClaudeAliases(options) : options;
} catch (e) {
throw this.enrich(e, 'describeModelOptions');
}
}
async loadSession(sessionId: string): Promise<void> {
try {
await this.withStartupTimeout(this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] }));
} catch (e) {
throw this.enrich(e, 'loadSession');
}
}
// Point the open session at a specific model. The adapter resolves aliases
// ("opus"/"sonnet"/…) to concrete ids. Throws if the model is unknown; the
// caller applies this best-effort so a bad value never blocks a turn.
async setModel(sessionId: string, modelId: string): Promise<void> {
await this.conn().unstable_setSessionModel({ sessionId, modelId });
}
// Set the reasoning-effort level via the agent's "effort" config option.
// The option only exists for models that support it, so this throws for
// others — again applied best-effort by the caller.
async setEffort(sessionId: string, value: string): Promise<void> {
await this.conn().setSessionConfigOption({ sessionId, configId: 'effort', value });
}
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 {};
},
};
}
}

View file

@ -0,0 +1,100 @@
// AUTO-GENERATED by packages/core/scripts/gen-engine-manifest.mjs — do not edit by hand.
// Regenerate after bumping the @agentclientprotocol/*-acp adapter (engine) versions.
// Maps each agent + platform to the npm tarball + integrity of its native engine.
export const ENGINE_MANIFEST = {
"claude": {
"version": "0.3.156",
"platforms": {
"darwin-arm64": {
"pkg": "@anthropic-ai/claude-agent-sdk-darwin-arm64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.156.tgz",
"integrity": "sha512-IkjcS9dqAUlD4Nb62L9AZtmAXCa+FV4ul8lIlyXXUprh3nlecbKsWOXVd/GORrzAhMmynJaX4+iV1JiutFKXUA=="
},
"darwin-x64": {
"pkg": "@anthropic-ai/claude-agent-sdk-darwin-x64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.156.tgz",
"integrity": "sha512-6PKi5fPmGRuzXu+Em/iwLmPG3mqg0hl92wcTU8fmChqyNtxhxsjCw7LTbdFqp/05o5NeZVVV4k3p7YUv5IFD6g=="
},
"linux-x64": {
"pkg": "@anthropic-ai/claude-agent-sdk-linux-x64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.156.tgz",
"integrity": "sha512-ymhrdlbWoYvTACUdaGdhrEv+ZMfwXLsf0BRLkr/IvY5aqybP7URzWmmZGOtDQpqkT/8xu/UCGqUYH3woJwUxfg=="
},
"linux-arm64": {
"pkg": "@anthropic-ai/claude-agent-sdk-linux-arm64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.156.tgz",
"integrity": "sha512-H0Nfd41iw5isto9uQI1FlVSZ0eaDttr8rBpJMR25oK/mj3egMO5EmZ6aAxeeUYSLn2mSU50HA5VNxlGUE118TQ=="
},
"linux-x64-musl": {
"pkg": "@anthropic-ai/claude-agent-sdk-linux-x64-musl",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.156.tgz",
"integrity": "sha512-/Q6WUizI6a+hqZZ6ElwRU0PEuFhOoN4v6CuU35HHbiZ/7uaocGht4A8ZIgK1Fw6wOGtZzGLbc00CA1OU1Zg8EA=="
},
"linux-arm64-musl": {
"pkg": "@anthropic-ai/claude-agent-sdk-linux-arm64-musl",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.156.tgz",
"integrity": "sha512-R7KEVjxkR4rYgIQoHGBzwPdUJYxRTO8I4vHjRbMLH1eW4FS7BJvVs7ogfKR/NnHFBvMVqtC+l6jHLQv8bobUiw=="
},
"win32-x64": {
"pkg": "@anthropic-ai/claude-agent-sdk-win32-x64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.156.tgz",
"integrity": "sha512-/PofeTWoiKgnWNSNk0wG4SsRn22GGLmnLhg2R94WcNhCRFOyOTmiZcYH2DBlWZBIRVTZDsSfa/Pl1DyPvYCGKw=="
},
"win32-arm64": {
"pkg": "@anthropic-ai/claude-agent-sdk-win32-arm64",
"pkgVersion": "0.3.156",
"tarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.156.tgz",
"integrity": "sha512-5sAeNObQQrMy4NF9HwxewrMnU7mVxZDHh+/MfJVQSz0GSTvXQ6gOuRH8helMlfspoU6VOdekPxVLRooX/3foEw=="
}
}
},
"codex": {
"version": "0.128.0",
"platforms": {
"darwin-arm64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-darwin-arm64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-darwin-arm64.tgz",
"integrity": "sha512-w+6zohfHx/kHBdles/CyFKaY57u9I3nK8QI9+NrdwMliKA0b7xn13yblRNkMpe09j6vL1oAWoxYsMOQ/vjBGug=="
},
"darwin-x64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-darwin-x64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-darwin-x64.tgz",
"integrity": "sha512-SDbn6fO22Puy8xmMIbZi4f2znMrUEPwABApke4mo+4ihaauwuVjeqzXvW5SPJz5ty/bG11/mSupQgReT7T8BBw=="
},
"linux-x64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-linux-x64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-linux-x64.tgz",
"integrity": "sha512-2lnSPA05CRRuKAzFW8BCmmNCSieDcToLwfC2ALLbBYilGLgzhRibjlDglK9F1BkEzfohSSWJu4PBbRu/aG60lQ=="
},
"linux-arm64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-linux-arm64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-linux-arm64.tgz",
"integrity": "sha512-+SvH73H60qvCXFuQGP/EsmR//s1hHMBR22PvJkXvM/hdnTIGucx+JqRUjAWdmmQ1IU6j3kgwVvdLW/6ICB+M6w=="
},
"win32-x64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-win32-x64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-win32-x64.tgz",
"integrity": "sha512-k3jmUAFrzkUtvjGTXvSKjQqJLLlzjxp/VoHJDYedgmXUn6j70HxK38IwapzmnYfiBiTuzETvGwjXHzZgzKjhoQ=="
},
"win32-arm64": {
"pkg": "@openai/codex",
"pkgVersion": "0.128.0-win32-arm64",
"tarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.128.0-win32-arm64.tgz",
"integrity": "sha512-ECJvsqmYFdA9pn42xxK3Odp/G16AjmBW0BglX8L0PwPjqbstbmlew9bfHf7xvL+SNfNl4NmyotW0+RNo1phgaA=="
}
}
}
} as const;

View file

@ -0,0 +1,306 @@
// Code-mode engine provisioner.
//
// Code mode drives Claude Code / Codex through their ACP adapters, which spawn a heavy
// native engine binary (~200 MB each). We do NOT bundle those engines into the installer
// (that would add ~400 MB). Instead we provision them on demand: the first time an agent
// is used we download the per-platform npm package AT THE EXACT VERSION OUR ADAPTER WAS
// BUILT AGAINST (see engine-manifest.ts), verify its integrity, and extract it into
// ~/.rowboat/engines/<agent>/<version>/. Subsequent runs reuse the cached copy.
//
// The adapters are then pointed at the provisioned binary via CLAUDE_CODE_EXECUTABLE /
// CODEX_PATH (see agents.ts). This keeps the installer small while making code mode work
// out of the box, with no dependency on the user having a global claude/codex install.
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as crypto from 'crypto';
import { spawnSync } from 'child_process';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { ENGINE_MANIFEST } from './engine-manifest.js';
import type { CodingAgent } from './types.js';
export const ENGINES_ROOT = path.join(os.homedir(), '.rowboat', 'engines');
interface PlatformEntry {
pkg: string;
pkgVersion: string;
tarball: string;
integrity: string;
}
export interface EngineProgress {
phase: 'check' | 'download' | 'verify' | 'extract' | 'done';
/** Bytes received so far (download phase). */
receivedBytes?: number;
/** Total bytes, when the server reports content-length. */
totalBytes?: number;
}
export interface EnsureEngineOptions {
onProgress?: (p: EngineProgress) => void;
signal?: AbortSignal;
}
export interface ProvisionedEngine {
executablePath: string;
version: string;
}
// Map this process's platform/arch (+ libc on linux) to a manifest platform key for the
// given agent. Returns null when no engine is published for this platform.
function platformKey(agent: CodingAgent): string | null {
const arch = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : null;
if (!arch) return null;
const plats = ENGINE_MANIFEST[agent].platforms as Record<string, PlatformEntry>;
const candidates: string[] = [];
if (process.platform === 'darwin') {
candidates.push(`darwin-${arch}`);
} else if (process.platform === 'win32') {
candidates.push(`win32-${arch}`);
} else if (process.platform === 'linux') {
// Prefer a musl build on musl systems (Alpine); fall back to the glibc build.
if (isMuslLibc()) candidates.push(`linux-${arch}-musl`);
candidates.push(`linux-${arch}`);
}
return candidates.find((c) => c in plats) ?? null;
}
// glibc builds expose a glibcVersionRuntime in the process report header; musl (Alpine)
// does not. Same heuristic Node's native-addon loaders use.
function isMuslLibc(): boolean {
try {
const report = (process as unknown as { report?: { getReport?: () => unknown } }).report?.getReport?.();
const header = (report as { header?: Record<string, unknown> } | undefined)?.header;
return !(header && 'glibcVersionRuntime' in header);
} catch {
return false;
}
}
// Locate the engine executable inside an extracted package root. We extract the whole npm
// package (so codex's bundled ripgrep travels with it), then find the binary.
function locateExecutable(agent: CodingAgent, root: string): string | null {
if (agent === 'claude') {
for (const name of ['claude', 'claude.exe']) {
const p = path.join(root, name);
if (fs.existsSync(p)) return p;
}
return null;
}
// codex: vendor/<target-triple>/codex/codex[.exe]
const vendor = path.join(root, 'vendor');
if (!fs.existsSync(vendor)) return null;
for (const triple of fs.readdirSync(vendor)) {
for (const name of ['codex', 'codex.exe']) {
const p = path.join(vendor, triple, 'codex', name);
if (fs.existsSync(p)) return p;
}
}
return null;
}
// True when this OS/arch has a published engine for `agent` — i.e. we can provision it.
// (Used for status: code mode no longer requires a user-installed CLI.)
export function isEngineSupported(agent: CodingAgent): boolean {
return platformKey(agent) !== null;
}
// True when the pinned engine for `agent` is already downloaded and intact locally.
export function isEngineProvisioned(agent: CodingAgent): boolean {
const version = ENGINE_MANIFEST[agent].version;
const versionDir = path.join(ENGINES_ROOT, agent, version);
const metaPath = path.join(ENGINES_ROOT, agent, '.meta', `${agent}-${version}.json`);
return locateExecutable(agent, versionDir) !== null && fs.existsSync(metaPath);
}
const AGENT_LABEL: Record<CodingAgent, string> = { claude: 'Claude Code', codex: 'Codex' };
// Return the provisioned engine's executable path, or throw a clear, user-facing error.
// The chat/run path uses this — we deliberately do NOT download here: the engine must be
// enabled up front in Settings → Code Mode, so the user never eats a surprise ~200 MB
// download mid-conversation. ensureEngine() (the downloading path) is driven only by the
// Settings "Enable" action.
export function getProvisionedEnginePath(agent: CodingAgent): string {
const version = ENGINE_MANIFEST[agent].version;
const exe = locateExecutable(agent, path.join(ENGINES_ROOT, agent, version));
if (!exe) {
throw new Error(
`${AGENT_LABEL[agent]} isn't enabled yet. Open Settings → Code Mode and click Enable to download it.`,
);
}
return exe;
}
// Remove every provisioned version of `agent` except `keepVersion`, plus its stale
// .meta entries. Called after a successful install so old engines don't pile up across
// version bumps. Best-effort — never throws (cleanup must not fail a good install).
function pruneOldVersions(agent: CodingAgent, keepVersion: string): void {
const agentRoot = path.join(ENGINES_ROOT, agent);
try {
for (const name of fs.readdirSync(agentRoot)) {
// Keep the active version, the meta dir, and any in-flight temp dirs.
if (name === keepVersion || name === '.meta' || name.startsWith('.tmp-')) continue;
const full = path.join(agentRoot, name);
try {
if (fs.statSync(full).isDirectory()) fs.rmSync(full, { recursive: true, force: true });
} catch { /* ignore a single stubborn entry */ }
}
const metaDir = path.join(agentRoot, '.meta');
if (fs.existsSync(metaDir)) {
for (const f of fs.readdirSync(metaDir)) {
if (f !== `${agent}-${keepVersion}.json`) {
try { fs.rmSync(path.join(metaDir, f), { force: true }); } catch { /* ignore */ }
}
}
}
} catch { /* agentRoot unreadable — nothing to prune */ }
}
async function downloadTo(url: string, dest: string, opts: EnsureEngineOptions): Promise<void> {
opts.onProgress?.({ phase: 'download', receivedBytes: 0 });
const res = await fetch(url, { signal: opts.signal });
if (!res.ok || !res.body) {
throw new Error(`Code mode: engine download failed (HTTP ${res.status}) — ${url}`);
}
const total = Number(res.headers.get('content-length')) || undefined;
let received = 0;
const body = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
body.on('data', (chunk: Buffer) => {
received += chunk.length;
opts.onProgress?.({ phase: 'download', receivedBytes: received, totalBytes: total });
});
await pipeline(body, fs.createWriteStream(dest));
}
// Verify the tarball against the npm Subresource Integrity string ("sha512-<base64>").
function verifyIntegrity(file: string, integrity: string): void {
const dash = integrity.indexOf('-');
const algo = integrity.slice(0, dash);
const expected = integrity.slice(dash + 1);
const actual = crypto.createHash(algo).update(fs.readFileSync(file)).digest('base64');
if (actual !== expected) {
throw new Error(`Code mode: engine integrity check failed (${algo}) — download may be corrupt.`);
}
}
// Extract an npm tarball, stripping its leading `package/` component so the package
// contents land directly in destDir. Uses the system tar (bsdtar on macOS/Windows 10+,
// GNU tar on Linux) — all support -xzf and --strip-components.
function extractTarball(tarPath: string, destDir: string): void {
let tarCmd = 'tar';
let tarArgs = ['-xzf', tarPath, '-C', destDir, '--strip-components=1'];
let spawnOpts: Parameters<typeof spawnSync>[2] = { stdio: 'pipe' };
// Windows: PATH `tar` may resolve to a GNU tar from Git/MSYS2, which misreads the
// absolute archive path "C:\...\engine.tgz" as a remote "host:path" spec and fails with
// "tar (child): Cannot connect to C: resolve failed" (then "gzip: stdin: unexpected end
// of file"). Pin to the bsdtar shipped in System32, which handles drive-letter paths
// natively — this is the tar this code was always meant to use on Windows 10+.
if (process.platform === 'win32') {
const sysTar = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe');
if (fs.existsSync(sysTar)) {
tarCmd = sysTar;
} else {
// No system bsdtar (very old/stripped Windows): fall back to PATH tar, but run
// from the archive's own directory and pass the bare filename so no drive-letter
// colon reaches tar's -f argument — works for both GNU tar and bsdtar.
tarArgs = ['-xzf', path.basename(tarPath), '-C', destDir, '--strip-components=1'];
spawnOpts = { stdio: 'pipe', cwd: path.dirname(tarPath) };
}
}
const r = spawnSync(tarCmd, tarArgs, spawnOpts);
if (r.status !== 0) {
const err = r.stderr?.toString().trim() || r.error?.message || `tar exited ${r.status}`;
throw new Error(`Code mode: failed to extract engine — ${err}`);
}
}
// Mark the engine (and codex's bundled ripgrep) executable on unix.
function makeExecutable(agent: CodingAgent, root: string, exe: string): void {
fs.chmodSync(exe, 0o755);
if (agent === 'codex') {
const vendor = path.join(root, 'vendor');
for (const triple of fs.existsSync(vendor) ? fs.readdirSync(vendor) : []) {
const rg = path.join(vendor, triple, 'path', 'rg');
if (fs.existsSync(rg)) fs.chmodSync(rg, 0o755);
}
}
}
/**
* Ensure the pinned engine for `agent` is provisioned locally, downloading it on first
* use. Returns the absolute path to the engine executable. Idempotent and cached.
*/
export async function ensureEngine(agent: CodingAgent, opts: EnsureEngineOptions = {}): Promise<ProvisionedEngine> {
const entry = ENGINE_MANIFEST[agent];
const version = entry.version;
const key = platformKey(agent);
if (!key) {
throw new Error(`Code mode: no ${agent} engine is available for ${process.platform}/${process.arch}.`);
}
const plat = (entry.platforms as Record<string, PlatformEntry>)[key];
const agentRoot = path.join(ENGINES_ROOT, agent);
const versionDir = path.join(agentRoot, version);
const metaDir = path.join(agentRoot, '.meta');
const metaPath = path.join(metaDir, `${agent}-${version}.json`);
opts.onProgress?.({ phase: 'check' });
// Fast path: already provisioned and intact.
const existing = locateExecutable(agent, versionDir);
if (existing && fs.existsSync(metaPath)) {
opts.onProgress?.({ phase: 'done' });
return { executablePath: existing, version };
}
// Download to a unique temp dir, verify, extract, then swap into place. Concurrent
// callers each use their own temp dir; the final rename is idempotent (same content).
fs.mkdirSync(agentRoot, { recursive: true });
const tmpRoot = fs.mkdtempSync(path.join(agentRoot, `.tmp-${version}-`));
try {
const tarPath = path.join(tmpRoot, 'engine.tgz');
await downloadTo(plat.tarball, tarPath, opts);
opts.onProgress?.({ phase: 'verify' });
verifyIntegrity(tarPath, plat.integrity);
opts.onProgress?.({ phase: 'extract' });
const extractDir = path.join(tmpRoot, 'pkg');
fs.mkdirSync(extractDir);
extractTarball(tarPath, extractDir);
const exe = locateExecutable(agent, extractDir);
if (!exe) {
throw new Error(`Code mode: ${agent} engine binary not found in the downloaded package.`);
}
if (process.platform !== 'win32') makeExecutable(agent, extractDir, exe);
// Swap the freshly extracted package into the versioned location.
if (fs.existsSync(versionDir)) fs.rmSync(versionDir, { recursive: true, force: true });
fs.renameSync(extractDir, versionDir);
const finalExe = locateExecutable(agent, versionDir);
if (!finalExe) {
throw new Error(`Code mode: ${agent} engine binary missing after install.`);
}
fs.mkdirSync(metaDir, { recursive: true });
fs.writeFileSync(metaPath, JSON.stringify({
version,
platform: key,
integrity: plat.integrity,
binRelPath: path.relative(versionDir, finalExe),
}, null, 2));
// A new version is in place — remove superseded versions so old engines
// (~200 MB each) don't accumulate after a bump. Best-effort.
pruneOldVersions(agent, version);
opts.onProgress?.({ phase: 'done' });
return { executablePath: finalExe, version };
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
}

View file

@ -0,0 +1,262 @@
import * as os from 'os';
import type { ApprovalPolicy, CodeRunEvent, CodingAgent, PermissionAsk, PermissionDecision, RunPromptResult } from './types.js';
import { AcpClient, type CodeAgentModelOptions } 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;
/** Coding-agent model alias/id (e.g. "opus"); applied to the ACP session
* before the prompt. Omitted / "default" leaves the engine default. */
model?: string;
/** Reasoning-effort level (e.g. "high"); applied alongside the model. */
effort?: string;
/** 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;
/**
* Drop the conversation replay that session/load streams on a cold resume.
* Direct sessions persist their own history (run JSONL) and render from it,
* so replaying through onEvent would duplicate every prior turn. When set,
* events only flow to onEvent once the session is open, right before prompt.
*/
suppressReplay?: boolean;
}
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>();
// Per-agent model/effort choices, discovered once from the engine and reused
// (the list only changes when the provider ships new models, and the app can
// be restarted to pick those up). Avoids cold-starting an adapter per picker.
private readonly modelOptionsCache = new Map<CodingAgent, CodeAgentModelOptions>();
// Discover a coding agent's available models + effort levels straight from
// the engine (what its `/model` picker would show). Spawns a short-lived
// adapter, opens a throwaway session to read its advertised options, and
// tears it down. Cached per agent for the lifetime of the process.
async listModelOptions(agent: CodingAgent): Promise<CodeAgentModelOptions> {
const cached = this.modelOptionsCache.get(agent);
if (cached) return cached;
const broker = new PermissionBroker({ policy: 'yolo', ask: async () => 'reject' });
const client = new AcpClient({ agent, cwd: os.homedir(), broker, onEvent: () => {} });
try {
await client.start();
const options = await client.describeModelOptions();
this.modelOptionsCache.set(agent, options);
return options;
} finally {
client.dispose();
}
}
async runPrompt(args: RunPromptArgs): Promise<RunPromptResult> {
const { runId, agent, cwd, prompt, policy, model, effort, ask, onEvent, signal, suppressReplay } = 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, suppressReplay ?? false);
// Re-apply the session's model + effort each turn (idempotent): a warm
// connection keeps the last selection, but a cold session/load resets it,
// and the user may have changed it from the header since the last turn.
await this.applyModelAndEffort(run, model, effort);
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);
}
}
// Best-effort: a model the engine doesn't know, or an effort level a model
// doesn't support, must not abort the turn — we log and proceed with the
// engine default rather than surfacing a hard error to the user.
private async applyModelAndEffort(run: ActiveRun, model?: string, effort?: string): Promise<void> {
if (model && model !== 'default') {
try {
await run.client.setModel(run.sessionId, model);
} catch (e) {
console.warn(`[code-mode] could not set model "${model}": ${e instanceof Error ? e.message : String(e)}`);
}
}
if (effort && effort !== 'default') {
try {
await run.client.setEffort(run.sessionId, effort);
} catch (e) {
console.warn(`[code-mode] could not set effort "${effort}": ${e instanceof Error ? e.message : String(e)}`);
}
}
}
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,
suppressReplay: boolean,
): 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
// With suppressReplay, the client starts with a muted event sink so a
// session/load replay of the prior conversation goes nowhere; the real
// sink is installed once the session is open (below).
const client = new AcpClient({
agent,
cwd,
broker,
onEvent: suppressReplay ? () => {} : onEvent,
});
// Dispose the client if startup fails (e.g. the startup-timeout fires) so the
// spawned adapter process doesn't leak.
try {
await client.start();
const sessionId = await this.openSession(runId, agent, cwd, client);
if (suppressReplay) client.setHandlers(broker, onEvent);
const run: ActiveRun = { client, sessionId, agent, cwd, inflight: 0 };
this.runs.set(runId, run);
return run;
} catch (e) {
client.dispose();
throw e;
}
}
// 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;
}
}

View 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);
}
}

View file

@ -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');
}
}
}
}

View 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
}
}

View file

@ -0,0 +1,40 @@
import { execSync } from 'child_process';
import * as path from 'path';
let cached: string | null = null;
// The user's login-shell PATH (macOS/Linux; undefined on Windows or probe failure).
// GUI-launched Electron apps inherit launchd's stripped PATH (/usr/bin:/bin:...), so
// anything the engines spawn off process.env.PATH misses Homebrew/nvm/npm-global tools.
// The provisioned engine itself runs from an absolute path, but claude/codex spawn
// git, gh, rg, bash, etc. themselves — without this they fail with "command not found"
// on a Finder launch even though they work from a terminal.
export function loginShellPath(): string | undefined {
if (process.platform === 'win32') return undefined;
if (cached !== null) return cached || undefined;
// Prefer the user's own shell when it's POSIX-flavored, so its login profile
// (~/.zprofile for zsh — macOS default — ~/.profile for bash/sh) is the one that
// builds the PATH. fish et al. are skipped: their `echo $PATH` is space-joined.
const userShell = process.env.SHELL;
const shellOk = userShell && ['sh', 'bash', 'zsh', 'dash', 'ksh'].includes(path.basename(userShell));
const shells = [...new Set([...(shellOk ? [userShell] : []), '/bin/sh'])];
for (const shell of shells) {
try {
const out = execSync(`${shell} -lc 'echo $PATH'`, { timeout: 5000, encoding: 'utf-8' });
// Profile scripts may echo their own lines; our `echo $PATH` runs last,
// so take the last non-empty line and sanity-check it looks like a PATH.
const lines = out.split('\n').map((l) => l.trim()).filter(Boolean);
const last = lines[lines.length - 1];
if (last && last.includes('/')) {
cached = last;
return last;
}
} catch {
// probe failed — try the next shell
}
}
cached = ''; // remember the failure so we don't re-pay the probe every spawn
return undefined;
}

View 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';

View file

@ -0,0 +1,337 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
import path from 'path';
import type { GitRepoInfo, GitStatusFile, GitFileState } from '@x/shared/dist/code-sessions.js';
const execFileAsync = promisify(execFile);
// Plain shell-outs to the system git. isomorphic-git (already in core) doesn't
// support worktrees, and these calls are simple enough that wrapping the CLI is
// both lighter and more faithful to what the user's own git would do.
const MAX_BUFFER = 32 * 1024 * 1024;
// Diff/file payloads above this are not worth shipping to the renderer.
const MAX_TEXT_BYTES = 1024 * 1024;
async function git(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: MAX_BUFFER });
return stdout;
}
let gitAvailable: Promise<boolean> | null = null;
export function isGitAvailable(): Promise<boolean> {
if (!gitAvailable) {
gitAvailable = execFileAsync('git', ['--version'], { timeout: 5000 })
.then(() => true)
.catch(() => false);
}
return gitAvailable;
}
export async function repoInfo(cwd: string): Promise<GitRepoInfo> {
const none: GitRepoInfo = { isGitRepo: false, branch: null, hasCommits: false, dirtyCount: 0 };
if (!await isGitAvailable()) return none;
try {
const inside = (await git(cwd, ['rev-parse', '--is-inside-work-tree'])).trim();
if (inside !== 'true') return none;
} catch {
return none;
}
let branch: string | null = null;
try {
branch = (await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || null;
} catch {
// unborn branch (no commits) — symbolic-ref still knows the name
try {
const ref = (await git(cwd, ['symbolic-ref', 'HEAD'])).trim();
branch = ref.replace(/^refs\/heads\//, '') || null;
} catch {
branch = null;
}
}
let hasCommits = false;
try {
await git(cwd, ['rev-parse', '--verify', 'HEAD']);
hasCommits = true;
} catch {
hasCommits = false;
}
let dirtyCount = 0;
try {
const out = await git(cwd, ['status', '--porcelain=v1', '-z']);
dirtyCount = out.split('\0').filter((l) => l.trim() !== '').length;
} catch {
dirtyCount = 0;
}
return { isGitRepo: true, branch, hasCommits, dirtyCount };
}
// git status/diff report paths relative to the REPO ROOT, which is not the
// session cwd when the user opened a subdirectory of a repo as their project.
// Disk reads must resolve against the root, not cwd.
async function repoToplevel(cwd: string): Promise<string> {
try {
return (await git(cwd, ['rev-parse', '--show-toplevel'])).trim() || cwd;
} catch {
return cwd;
}
}
async function mergeBase(cwd: string, baseRef: string): Promise<string> {
try {
return (await git(cwd, ['merge-base', baseRef, 'HEAD'])).trim() || baseRef;
} catch {
return baseRef;
}
}
function stateFromPorcelain(xy: string): GitFileState {
if (xy === '??') return 'untracked';
if (xy.includes('R')) return 'renamed';
if (xy.includes('A')) return 'added';
if (xy.includes('D')) return 'deleted';
return 'modified';
}
// Working-tree changes vs HEAD with insertion/deletion counts, scoped to the
// session directory's subtree (`-- .`): a project opened inside a bigger repo
// only shows its own changes. Result paths are repo-root-relative (git's
// porcelain format). Untracked files get their line count from disk (capped)
// since numstat doesn't cover them.
export async function status(cwd: string): Promise<GitStatusFile[]> {
const root = await repoToplevel(cwd);
const out = await git(cwd, ['status', '--porcelain=v1', '-z', '--', '.']);
const entries: Array<{ path: string; state: GitFileState }> = [];
// -z format: "XY path\0" and for renames "XY newPath\0oldPath\0"
const parts = out.split('\0');
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part || part.length < 4) continue;
const xy = part.slice(0, 2);
const filePath = part.slice(3);
const state = stateFromPorcelain(xy);
if (state === 'renamed') i++; // skip the old path that follows
entries.push({ path: filePath, state });
}
const counts = new Map<string, { insertions: number | null; deletions: number | null }>();
try {
const numstat = await git(cwd, ['diff', 'HEAD', '--numstat', '-z', '--', '.']);
// -z numstat rows: "ins\tdel\tpath\0" (renames: "ins\tdel\0old\0new\0")
const rows = numstat.split('\0');
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row) continue;
const m = row.match(/^(\d+|-)\t(\d+|-)\t?(.*)$/);
if (!m) continue;
const insertions = m[1] === '-' ? null : Number(m[1]);
const deletions = m[2] === '-' ? null : Number(m[2]);
let filePath = m[3];
if (!filePath) {
// rename form: old and new paths follow as separate tokens
i += 2;
filePath = rows[i] ?? '';
}
if (filePath) counts.set(filePath, { insertions, deletions });
}
} catch {
// no HEAD yet (no commits) — leave counts empty
}
const result: GitStatusFile[] = [];
for (const entry of entries) {
let insertions: number | null = null;
let deletions: number | null = null;
const counted = counts.get(entry.path);
if (counted) {
insertions = counted.insertions;
deletions = counted.deletions;
} else if (entry.state === 'untracked') {
try {
const full = path.join(root, entry.path);
const stat = await fs.stat(full);
if (stat.isFile() && stat.size <= MAX_TEXT_BYTES) {
const content = await fs.readFile(full, 'utf8');
if (!content.includes('\0')) {
insertions = content.length === 0
? 0
: content.split('\n').length - (content.endsWith('\n') ? 1 : 0);
deletions = 0;
}
}
} catch {
// unreadable — leave counts null
}
}
result.push({ path: entry.path, state: entry.state, insertions, deletions });
}
return result;
}
// Everything this worktree's branch changed since it forked from `baseRef` —
// committed AND uncommitted. `status()` only sees the working tree (uncommitted),
// so it misses work an agent committed; this is what you want for a session
// summary. Counts come from numstat, states from name-status, merged by path.
export async function changedSinceBase(cwd: string, baseRef: string): Promise<GitStatusFile[]> {
const forkPoint = await mergeBase(cwd, baseRef);
const stateByPath = new Map<string, GitFileState>();
try {
const ns = await git(cwd, ['diff', '--name-status', '-z', forkPoint]);
const parts = ns.split('\0');
for (let i = 0; i < parts.length; i++) {
const code = parts[i];
if (!code) continue;
const letter = code[0];
if (letter === 'R' || letter === 'C') {
// rename/copy: "<code>\0<old>\0<new>"
const newPath = parts[i + 2];
i += 2;
if (newPath) stateByPath.set(newPath, 'renamed');
} else {
const p = parts[i + 1];
i += 1;
if (p) stateByPath.set(p, letter === 'A' ? 'added' : letter === 'D' ? 'deleted' : 'modified');
}
}
} catch {
// bad ref / no commits — leave states empty
}
const result: GitStatusFile[] = [];
try {
const numstat = await git(cwd, ['diff', '--numstat', '-z', forkPoint]);
const rows = numstat.split('\0');
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row) continue;
const m = row.match(/^(\d+|-)\t(\d+|-)\t?(.*)$/);
if (!m) continue;
const insertions = m[1] === '-' ? null : Number(m[1]);
const deletions = m[2] === '-' ? null : Number(m[2]);
let filePath = m[3];
if (!filePath) {
// rename form: old and new paths follow as separate tokens
i += 2;
filePath = rows[i] ?? '';
}
if (!filePath) continue;
result.push({ path: filePath, state: stateByPath.get(filePath) ?? 'modified', insertions, deletions });
}
} catch {
// bad ref / no commits — nothing to report
}
return result;
}
export interface FileDiff {
oldText: string;
newText: string;
isBinary: boolean;
tooLarge: boolean;
}
export async function fileDiff(cwd: string, relPath: string, opts: { baseRef?: string | null } = {}): Promise<FileDiff> {
// Paths from `status` are repo-root-relative; paths clicked in the chat
// timeline are cwd-relative. Resolve whichever interpretation points at a
// real file (deleted files fall back to the root interpretation, which is
// also what `git show` uses).
const root = await repoToplevel(cwd);
let gitPath = relPath;
let full = path.join(root, relPath);
const existsAt = async (p: string) => fs.stat(p).then((s) => s.isFile()).catch(() => false);
if (!await existsAt(full)) {
const cwdFull = path.join(cwd, relPath);
if (await existsAt(cwdFull)) {
full = cwdFull;
// Realpath both sides — git reports the real toplevel, while the
// session cwd may reach it through a symlink (e.g. /tmp on macOS).
const realFull = await fs.realpath(cwdFull).catch(() => cwdFull);
gitPath = path.relative(root, realFull).split(path.sep).join('/');
}
}
let oldText = '';
try {
const oldRef = opts.baseRef ? await mergeBase(cwd, opts.baseRef) : 'HEAD';
oldText = await git(cwd, ['show', `${oldRef}:${gitPath}`]);
} catch {
// untracked / newly added / no commits — diff against empty
oldText = '';
}
let newText = '';
try {
const stat = await fs.stat(full);
if (stat.size > MAX_TEXT_BYTES) {
return { oldText: '', newText: '', isBinary: false, tooLarge: true };
}
newText = await fs.readFile(full, 'utf8');
} catch {
// deleted from working tree
newText = '';
}
if (oldText.length > MAX_TEXT_BYTES) {
return { oldText: '', newText: '', isBinary: false, tooLarge: true };
}
if (oldText.includes('\0') || newText.includes('\0')) {
return { oldText: '', newText: '', isBinary: true, tooLarge: false };
}
return { oldText, newText, isBinary: false, tooLarge: false };
}
export async function worktreeAdd(repoPath: string, worktreePath: string, branch: string): Promise<void> {
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
await git(repoPath, ['worktree', 'add', '-b', branch, worktreePath, 'HEAD']);
}
export async function worktreeRemove(
repoPath: string,
worktreePath: string,
opts: { force?: boolean; deleteBranch?: string } = {},
): Promise<void> {
try {
const args = ['worktree', 'remove'];
if (opts.force) args.push('--force');
args.push(worktreePath);
await git(repoPath, args);
} catch {
// The worktree dir may have been deleted by hand — prune the registration.
await git(repoPath, ['worktree', 'prune']).catch(() => {});
}
if (opts.deleteBranch) {
await git(repoPath, ['branch', '-D', opts.deleteBranch]).catch(() => {});
}
}
export interface MergeBackResult {
ok: boolean;
conflict?: boolean;
message: string;
}
// Merge the session branch into whatever the original checkout currently has
// checked out. Refuses on a dirty checkout; aborts cleanly on conflicts.
export async function mergeBack(repoPath: string, branch: string): Promise<MergeBackResult> {
const info = await repoInfo(repoPath);
if (!info.isGitRepo) {
return { ok: false, message: 'The project folder is not a git repository.' };
}
if (info.dirtyCount > 0) {
return {
ok: false,
message: `The repository at ${repoPath} has ${info.dirtyCount} uncommitted change(s). Commit or stash them, then merge again — or merge manually with: git merge ${branch}`,
};
}
try {
await git(repoPath, ['merge', '--no-edit', branch]);
return { ok: true, message: `Merged ${branch} into ${info.branch ?? 'the current branch'}.` };
} catch (e) {
await git(repoPath, ['merge', '--abort']).catch(() => {});
const detail = e instanceof Error ? e.message : String(e);
return {
ok: false,
conflict: true,
message: `Merge of ${branch} hit conflicts and was aborted. Resolve manually with: git merge ${branch}\n\n${detail.slice(0, 600)}`,
};
}
}

View file

@ -0,0 +1,83 @@
import fs from 'fs/promises';
import path from 'path';
// Contained file browsing for the Code section. Session cwds are arbitrary
// user directories (outside the Rowboat workspace), so every access resolves
// against the session root and is validated to stay inside it — realpath on
// the containing directory defeats both `..` traversal and symlink escapes.
const MAX_FILE_BYTES = 1024 * 1024;
async function resolveContained(root: string, relPath: string): Promise<string> {
if (path.isAbsolute(relPath)) {
throw new Error('Absolute paths are not allowed');
}
const realRoot = await fs.realpath(root);
const resolved = path.resolve(realRoot, relPath);
// Realpath the parent so symlinked ancestors can't escape...
const realParent = await fs.realpath(path.dirname(resolved)).catch(() => null);
if (realParent === null) {
throw new Error(`No such directory: ${relPath}`);
}
// ...and the target itself, so the final component being a symlink
// (e.g. a link to /etc) can't either. A missing target keeps its own path.
const joined = path.join(realParent, path.basename(resolved));
const realTarget = await fs.realpath(joined).catch(() => joined);
if (realTarget !== realRoot && !realTarget.startsWith(realRoot + path.sep)) {
throw new Error('Path escapes the session directory');
}
return realTarget;
}
export interface ProjectDirEntry {
name: string;
kind: 'file' | 'dir';
size?: number;
}
// One level at a time — the tree lazily expands, so node_modules costs nothing
// until the user opens it. `.git` is always hidden.
export async function readProjectDir(root: string, relPath: string): Promise<ProjectDirEntry[]> {
const target = await resolveContained(root, relPath || '.');
const dirents = await fs.readdir(target, { withFileTypes: true });
const entries: ProjectDirEntry[] = [];
for (const d of dirents) {
if (d.name === '.git') continue;
if (d.isDirectory()) {
entries.push({ name: d.name, kind: 'dir' });
} else if (d.isFile()) {
let size: number | undefined;
try {
size = (await fs.stat(path.join(target, d.name))).size;
} catch {
size = undefined;
}
entries.push({ name: d.name, kind: 'file', size });
}
// symlinks and other entry kinds are skipped
}
entries.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1));
return entries;
}
export interface ProjectFileContent {
content: string;
isBinary: boolean;
tooLarge: boolean;
}
export async function readProjectFile(root: string, relPath: string): Promise<ProjectFileContent> {
const target = await resolveContained(root, relPath);
const stat = await fs.stat(target);
if (!stat.isFile()) {
throw new Error(`Not a file: ${relPath}`);
}
if (stat.size > MAX_FILE_BYTES) {
return { content: '', isBinary: false, tooLarge: true };
}
const buf = await fs.readFile(target);
if (buf.includes(0)) {
return { content: '', isBinary: true, tooLarge: false };
}
return { content: buf.toString('utf8'), isBinary: false, tooLarge: false };
}

View file

@ -0,0 +1,69 @@
import fs from 'fs/promises';
import path from 'path';
import { WorkDir } from '../../config/config.js';
import z from 'zod';
import { CodeProject } from '@x/shared/dist/code-sessions.js';
const ProjectsFile = z.object({
projects: z.array(CodeProject),
});
export interface ICodeProjectsRepo {
list(): Promise<CodeProject[]>;
get(projectId: string): Promise<CodeProject | null>;
add(dirPath: string): Promise<CodeProject>;
remove(projectId: string): Promise<void>;
}
// Registered project directories for the Code section. One small JSON file —
// same pattern as the other config repos.
export class FSCodeProjectsRepo implements ICodeProjectsRepo {
private readonly configPath = path.join(WorkDir, 'config', 'code-projects.json');
private async read(): Promise<CodeProject[]> {
try {
const raw = await fs.readFile(this.configPath, 'utf8');
return ProjectsFile.parse(JSON.parse(raw)).projects;
} catch {
return [];
}
}
private async write(projects: CodeProject[]): Promise<void> {
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
await fs.writeFile(this.configPath, JSON.stringify({ projects }, null, 2));
}
async list(): Promise<CodeProject[]> {
return this.read();
}
async get(projectId: string): Promise<CodeProject | null> {
const projects = await this.read();
return projects.find((p) => p.id === projectId) ?? null;
}
async add(dirPath: string): Promise<CodeProject> {
const resolved = path.resolve(dirPath);
const stat = await fs.stat(resolved);
if (!stat.isDirectory()) {
throw new Error(`Not a directory: ${resolved}`);
}
const projects = await this.read();
const existing = projects.find((p) => p.path === resolved);
if (existing) return existing;
const project: CodeProject = {
id: `proj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
path: resolved,
name: path.basename(resolved),
addedAt: new Date().toISOString(),
};
await this.write([...projects, project]);
return project;
}
async remove(projectId: string): Promise<void> {
const projects = await this.read();
await this.write(projects.filter((p) => p.id !== projectId));
}
}

View file

@ -0,0 +1,63 @@
import fs from 'fs/promises';
import path from 'path';
import { WorkDir } from '../../config/config.js';
import { CodeSession } from '@x/shared/dist/code-sessions.js';
// Mutable metadata for Code-section sessions, one JSON file per session
// (keyed by the session/run id). The immutable conversation itself lives in
// the run JSONL; the ACP resume state lives in code-mode/sessions/.
const META_DIR = path.join(WorkDir, 'code-mode', 'sessions-meta');
function metaFile(sessionId: string): string {
return path.join(META_DIR, `${sessionId}.json`);
}
export interface ICodeSessionsRepo {
list(): Promise<CodeSession[]>;
get(sessionId: string): Promise<CodeSession | null>;
save(session: CodeSession): Promise<void>;
remove(sessionId: string): Promise<void>;
}
export class FSCodeSessionsRepo implements ICodeSessionsRepo {
async list(): Promise<CodeSession[]> {
let names: string[] = [];
try {
names = (await fs.readdir(META_DIR)).filter((n) => n.endsWith('.json'));
} catch {
return [];
}
const sessions: CodeSession[] = [];
for (const name of names) {
try {
const raw = await fs.readFile(path.join(META_DIR, name), 'utf8');
sessions.push(CodeSession.parse(JSON.parse(raw)));
} catch {
// skip malformed files
}
}
// Newest activity first; session ids are time-sortable as a tiebreaker.
sessions.sort((a, b) =>
(b.lastActivityAt ?? b.createdAt).localeCompare(a.lastActivityAt ?? a.createdAt));
return sessions;
}
async get(sessionId: string): Promise<CodeSession | null> {
try {
const raw = await fs.readFile(metaFile(sessionId), 'utf8');
return CodeSession.parse(JSON.parse(raw));
} catch {
return null;
}
}
async save(session: CodeSession): Promise<void> {
const validated = CodeSession.parse(session);
await fs.mkdir(META_DIR, { recursive: true });
await fs.writeFile(metaFile(validated.id), JSON.stringify(validated, null, 2));
}
async remove(sessionId: string): Promise<void> {
await fs.rm(metaFile(sessionId), { force: true }).catch(() => {});
}
}

View file

@ -0,0 +1,369 @@
import path from 'path';
import fs from 'fs/promises';
import z from 'zod';
import { WorkDir } from '../../config/config.js';
import type { CodeSession, CodeSessionMode } from '@x/shared/dist/code-sessions.js';
import type { CodingAgent, ApprovalPolicy } from '@x/shared/dist/code-mode.js';
import { RunEvent, MessageEvent } from '@x/shared/dist/runs.js';
import type { IRunsRepo } from '../../runs/repo.js';
import type { IRunsLock } from '../../runs/lock.js';
import type { IBus } from '../../application/lib/bus.js';
import type { IMonotonicallyIncreasingIdGenerator } from '../../application/lib/id-gen.js';
import type { IAbortRegistry } from '../../runs/abort-registry.js';
import type { CodeModeManager } from '../acp/manager.js';
import type { CodePermissionRegistry } from '../acp/permission-registry.js';
import type { ICodeSessionsRepo } from './repo.js';
import type { ICodeProjectsRepo } from '../projects/repo.js';
import { clearStoredSession } from '../acp/session-store.js';
import * as gitService from '../git/service.js';
export interface CreateSessionArgs {
projectId: string;
title?: string;
agent: CodingAgent;
mode: CodeSessionMode;
policy: ApprovalPolicy;
isolation: 'in-repo' | 'worktree';
// LLM for Rowboat-mode turns; unset falls through to the configured default.
model?: string;
provider?: string;
// The coding agent's own model + reasoning effort (ACP engine); unset leaves
// the engine default. Re-applied to the ACP session on every turn.
agentModel?: string;
agentEffort?: string;
}
export interface SendMessageResult {
accepted: boolean;
error?: string;
}
function worktreeRoot(projectId: string, sessionId: string): string {
return path.join(WorkDir, 'code-mode', 'worktrees', projectId, sessionId);
}
// The per-run work directory the copilot anchors its general context to
// (same file the chat composer writes for regular chats). Keeping it in sync
// with the session cwd means Rowboat-mode turns see the right "# User Work
// Directory" even for tools other than code_agent_run.
async function persistRunWorkDir(runId: string, cwd: string): Promise<void> {
try {
const file = path.join(WorkDir, 'config', `workdir-${runId}.json`);
await fs.writeFile(file, JSON.stringify({ path: cwd }, null, 2));
} catch {
// best effort — the session meta still pins cwd for code_agent_run
}
}
// Drives Code-section sessions. A session is a run (same id) whose JSONL holds
// both modes' history: Rowboat turns are written by the agent runtime; direct
// turns are written here. The direct path talks straight to the ACP engine —
// no copilot LLM in between — but mirrors the runtime's lifecycle contract
// (runs lock, abort registry, processing-start/end, run-stopped) so the rest
// of the app (stop IPC, status tracking, event forwarding) needs no special
// casing.
export class CodeSessionService {
private readonly runsRepo: IRunsRepo;
private readonly runsLock: IRunsLock;
private readonly bus: IBus;
private readonly idGenerator: IMonotonicallyIncreasingIdGenerator;
private readonly abortRegistry: IAbortRegistry;
private readonly codeModeManager: CodeModeManager;
private readonly codePermissionRegistry: CodePermissionRegistry;
private readonly codeSessionsRepo: ICodeSessionsRepo;
private readonly codeProjectsRepo: ICodeProjectsRepo;
// Session ids with a direct prompt currently streaming (the runs lock also
// guards this, but we keep our own set to give a precise "busy" error).
private readonly inflight = new Set<string>();
constructor({
runsRepo,
runsLock,
bus,
idGenerator,
abortRegistry,
codeModeManager,
codePermissionRegistry,
codeSessionsRepo,
codeProjectsRepo,
}: {
runsRepo: IRunsRepo;
runsLock: IRunsLock;
bus: IBus;
idGenerator: IMonotonicallyIncreasingIdGenerator;
abortRegistry: IAbortRegistry;
codeModeManager: CodeModeManager;
codePermissionRegistry: CodePermissionRegistry;
codeSessionsRepo: ICodeSessionsRepo;
codeProjectsRepo: ICodeProjectsRepo;
}) {
this.runsRepo = runsRepo;
this.runsLock = runsLock;
this.bus = bus;
this.idGenerator = idGenerator;
this.abortRegistry = abortRegistry;
this.codeModeManager = codeModeManager;
this.codePermissionRegistry = codePermissionRegistry;
this.codeSessionsRepo = codeSessionsRepo;
this.codeProjectsRepo = codeProjectsRepo;
}
async create(args: CreateSessionArgs): Promise<CodeSession> {
const project = await this.codeProjectsRepo.get(args.projectId);
if (!project) throw new Error(`Unknown project: ${args.projectId}`);
// The session is a real run so Rowboat mode (agent runtime) works on it
// directly and the existing runs plumbing (fetch/events/stop) applies.
const { createRun } = await import('../../runs/runs.js');
const run = await createRun({
agentId: 'copilot',
useCase: 'code_session',
...(args.model ? { model: args.model } : {}),
...(args.provider ? { provider: args.provider } : {}),
});
const sessionId = run.id;
let cwd = project.path;
let worktree: CodeSession['worktree'];
if (args.isolation === 'worktree') {
const info = await gitService.repoInfo(project.path);
if (!info.isGitRepo || !info.hasCommits) {
throw new Error('Worktree isolation needs a git repository with at least one commit.');
}
const branch = `rowboat/${sessionId}`;
const wtPath = worktreeRoot(project.id, sessionId);
await gitService.worktreeAdd(project.path, wtPath, branch);
worktree = { path: wtPath, branch, baseBranch: info.branch };
cwd = wtPath;
}
const session: CodeSession = {
id: sessionId,
projectId: project.id,
title: args.title?.trim() || `${project.name} session`,
agent: args.agent,
mode: args.mode,
policy: args.policy,
cwd,
...(worktree ? { worktree } : {}),
...(args.agentModel ? { agentModel: args.agentModel } : {}),
...(args.agentEffort ? { agentEffort: args.agentEffort } : {}),
createdAt: new Date().toISOString(),
};
await this.codeSessionsRepo.save(session);
await persistRunWorkDir(sessionId, cwd);
return session;
}
async update(sessionId: string, patch: Partial<Pick<CodeSession, 'title' | 'mode' | 'policy' | 'agent' | 'agentModel' | 'agentEffort'>>): Promise<CodeSession> {
const session = await this.codeSessionsRepo.get(sessionId);
if (!session) throw new Error(`Unknown session: ${sessionId}`);
const updated: CodeSession = { ...session, ...patch };
await this.codeSessionsRepo.save(updated);
return updated;
}
isBusy(sessionId: string): boolean {
return this.inflight.has(sessionId);
}
// Direct drive: send the user's text straight to the session's ACP agent.
// Returns once the turn fully settles (the renderer streams via runs:events).
async sendMessage(sessionId: string, text: string): Promise<SendMessageResult> {
const session = await this.codeSessionsRepo.get(sessionId);
if (!session) return { accepted: false, error: `Unknown session: ${sessionId}` };
if (this.inflight.has(sessionId)) {
return { accepted: false, error: 'The agent is still working on the previous message.' };
}
// The runs lock is shared with the agent runtime, so a Rowboat-mode turn
// in flight blocks direct sends (and vice versa) — the run JSONL never
// interleaves two writers.
if (!await this.runsLock.lock(sessionId)) {
return { accepted: false, error: 'The session is busy with a Rowboat-driven turn.' };
}
this.inflight.add(sessionId);
const signal = this.abortRegistry.createForRun(sessionId);
const turnId = await this.idGenerator.next();
const toolCallId = `direct-${turnId}`;
const appendAndPublish = async (event: z.infer<typeof RunEvent>) => {
await this.runsRepo.appendEvents(sessionId, [event]);
await this.bus.publish(event);
};
try {
await this.bus.publish({ runId: sessionId, type: 'run-processing-start', subflow: [] });
const userEvent: z.infer<typeof MessageEvent> = {
runId: sessionId,
type: 'message',
messageId: await this.idGenerator.next(),
message: { role: 'user', content: text },
subflow: [],
ts: new Date().toISOString(),
};
await appendAndPublish(userEvent);
await this.touch(session);
// Stream events live; persist the structural ones (tool calls, plan,
// resolved permissions). Streaming `message` chunks are NOT persisted —
// the agent's full text lands as one assistant MessageEvent below, which
// is also what lets a later Rowboat-mode turn see this conversation.
let finalText = '';
const persistQueue: Array<z.infer<typeof RunEvent>> = [];
const onAbort = () => this.codePermissionRegistry.cancelRun(sessionId);
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
let stopReason = 'cancelled';
try {
const result = await this.codeModeManager.runPrompt({
runId: sessionId,
agent: session.agent,
cwd: session.cwd,
prompt: text,
policy: session.policy,
...(session.agentModel ? { model: session.agentModel } : {}),
...(session.agentEffort ? { effort: session.agentEffort } : {}),
signal,
suppressReplay: true,
onEvent: (event) => {
if (event.type === 'message' && event.role === 'agent') finalText += event.text;
const streamEvent: z.infer<typeof RunEvent> = {
runId: sessionId,
type: 'code-run-event',
toolCallId,
event,
subflow: [],
};
void this.bus.publish(streamEvent);
if (event.type === 'tool_call' || event.type === 'tool_call_update'
|| event.type === 'plan' || event.type === 'permission') {
persistQueue.push({ ...streamEvent, ts: new Date().toISOString() });
}
},
ask: (permAsk) => this.codePermissionRegistry.request(sessionId, (requestId) => {
void this.bus.publish({
runId: sessionId,
type: 'code-run-permission-request',
toolCallId,
requestId,
ask: permAsk,
subflow: [],
});
}),
});
stopReason = result.stopReason;
} catch (error) {
if (!signal.aborted) {
const message = error instanceof Error ? (error.message || error.name) : String(error);
await appendAndPublish({ runId: sessionId, type: 'error', error: message, subflow: [] });
}
} finally {
signal.removeEventListener('abort', onAbort);
}
if (persistQueue.length > 0) {
await this.runsRepo.appendEvents(sessionId, persistQueue);
}
if (finalText.trim()) {
await appendAndPublish({
runId: sessionId,
type: 'message',
messageId: await this.idGenerator.next(),
message: { role: 'assistant', content: finalText },
subflow: [],
ts: new Date().toISOString(),
});
}
if (signal.aborted || stopReason === 'cancelled') {
await appendAndPublish({
runId: sessionId,
type: 'run-stopped',
reason: 'user-requested',
subflow: [],
});
}
await this.touch(session);
return { accepted: true };
} finally {
this.inflight.delete(sessionId);
this.abortRegistry.cleanup(sessionId);
await this.runsLock.release(sessionId);
await this.bus.publish({ runId: sessionId, type: 'run-processing-end', subflow: [] });
}
}
// Unblocks a stuck permission card immediately; the manager's signal handling
// (ACP cancel -> grace -> force-kill) actually unwinds the prompt.
async stop(sessionId: string): Promise<void> {
this.abortRegistry.abort(sessionId);
this.codePermissionRegistry.cancelRun(sessionId);
}
async mergeBack(sessionId: string): Promise<gitService.MergeBackResult> {
const session = await this.codeSessionsRepo.get(sessionId);
if (!session?.worktree) {
return { ok: false, message: 'This session has no isolated worktree to merge.' };
}
const project = await this.codeProjectsRepo.get(session.projectId);
if (!project) {
return { ok: false, message: 'The session\'s project is no longer registered.' };
}
const result = await gitService.mergeBack(project.path, session.worktree.branch);
if (result.ok) {
await this.codeSessionsRepo.save({
...session,
worktree: { ...session.worktree, mergedAt: new Date().toISOString() },
});
}
return result;
}
async cleanupWorktree(sessionId: string, deleteBranch: boolean): Promise<void> {
const session = await this.codeSessionsRepo.get(sessionId);
if (!session?.worktree || session.worktree.removedAt) return;
const project = await this.codeProjectsRepo.get(session.projectId);
// Drop any live agent connection on the worktree before deleting it.
this.codeModeManager.dispose(sessionId);
if (project) {
await gitService.worktreeRemove(project.path, session.worktree.path, {
force: true,
...(deleteBranch ? { deleteBranch: session.worktree.branch } : {}),
});
}
const nextCwd = project?.path ?? session.cwd;
await this.codeSessionsRepo.save({
...session,
// The worktree is gone — fall back to working directly in the repo.
cwd: nextCwd,
worktree: { ...session.worktree, removedAt: new Date().toISOString() },
});
await persistRunWorkDir(sessionId, nextCwd);
}
async delete(sessionId: string, opts: { removeWorktree?: boolean; deleteBranch?: boolean } = {}): Promise<void> {
await this.stop(sessionId);
this.codeModeManager.dispose(sessionId);
const session = await this.codeSessionsRepo.get(sessionId);
if (opts.removeWorktree && session?.worktree && !session.worktree.removedAt) {
const project = await this.codeProjectsRepo.get(session.projectId);
if (project) {
await gitService.worktreeRemove(project.path, session.worktree.path, {
force: true,
...(opts.deleteBranch ? { deleteBranch: session.worktree.branch } : {}),
});
}
}
await clearStoredSession(sessionId);
await this.codeSessionsRepo.remove(sessionId);
await this.runsRepo.delete(sessionId).catch(() => {});
await fs.rm(path.join(WorkDir, 'config', `workdir-${sessionId}.json`), { force: true }).catch(() => {});
}
private async touch(session: CodeSession): Promise<void> {
const current = await this.codeSessionsRepo.get(session.id);
if (!current) return;
await this.codeSessionsRepo.save({ ...current, lastActivityAt: new Date().toISOString() });
}
}

View file

@ -0,0 +1,136 @@
import z from 'zod';
import { RunEvent } from '@x/shared/dist/runs.js';
import type { IBus } from '../../application/lib/bus.js';
import type { ICodeSessionsRepo } from './repo.js';
import type { INotificationService } from '../../application/notification/service.js';
import type { CodeSessionStatus, CodeSession } from '@x/shared/dist/code-sessions.js';
import container from '../../di/container.js';
export type StatusListener = (sessionId: string, status: CodeSessionStatus) => void;
// Authoritative live status for Code-section sessions, derived in the main
// process from the run event stream. Works for both modes uniformly because
// direct turns and Rowboat-mode code_agent_run turns publish the same event
// types on the bus. The renderer just renders what this pushes.
export class CodeSessionStatusTracker {
private readonly bus: IBus;
private readonly codeSessionsRepo: ICodeSessionsRepo;
private readonly statuses = new Map<string, CodeSessionStatus>();
private readonly busySince = new Map<string, number>();
private readonly listeners = new Set<StatusListener>();
private unsubscribe: (() => void) | null = null;
// Session ids known to be code sessions; refreshed lazily on unknown ids so
// sessions created after start() are picked up without explicit wiring.
private knownSessions = new Set<string>();
// Ids confirmed NOT to be sessions (regular chat runs). Safe to cache
// permanently: a session's meta file is written before its first turn, so
// an id that misses the refresh can never become a session later.
private readonly knownNonSessions = new Set<string>();
constructor({ bus, codeSessionsRepo }: { bus: IBus; codeSessionsRepo: ICodeSessionsRepo }) {
this.bus = bus;
this.codeSessionsRepo = codeSessionsRepo;
}
async start(): Promise<void> {
if (this.unsubscribe) return;
await this.refreshKnownSessions();
this.unsubscribe = await this.bus.subscribe('*', async (event) => {
await this.handle(event);
});
}
stop(): void {
this.unsubscribe?.();
this.unsubscribe = null;
}
onTransition(listener: StatusListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
getStatuses(): Record<string, CodeSessionStatus> {
return Object.fromEntries(this.statuses);
}
private async refreshKnownSessions(): Promise<void> {
const sessions = await this.codeSessionsRepo.list().catch(() => [] as CodeSession[]);
this.knownSessions = new Set(sessions.map((s) => s.id));
}
private async isCodeSession(runId: string): Promise<boolean> {
if (this.knownSessions.has(runId)) return true;
if (this.knownNonSessions.has(runId)) return false;
// Unknown id — maybe a session created since the last refresh.
await this.refreshKnownSessions();
if (this.knownSessions.has(runId)) return true;
this.knownNonSessions.add(runId);
return false;
}
private async handle(event: z.infer<typeof RunEvent>): Promise<void> {
const relevant = event.type === 'run-processing-start'
|| event.type === 'run-processing-end'
|| event.type === 'run-stopped'
|| event.type === 'error'
|| event.type === 'code-run-permission-request'
|| (event.type === 'code-run-event' && event.event.type === 'permission');
if (!relevant) return;
if (!await this.isCodeSession(event.runId)) return;
const previous = this.statuses.get(event.runId) ?? 'idle';
let next: CodeSessionStatus = previous;
switch (event.type) {
case 'run-processing-start':
next = 'working';
break;
case 'code-run-permission-request':
next = 'needs-you';
break;
case 'code-run-event':
// A permission resolution while the turn is still running.
if (previous === 'needs-you') next = 'working';
break;
case 'run-processing-end':
case 'run-stopped':
case 'error':
next = 'idle';
break;
}
if (next === previous) return;
if (previous === 'idle' && next !== 'idle') this.busySince.set(event.runId, Date.now());
this.statuses.set(event.runId, next);
for (const listener of this.listeners) listener(event.runId, next);
await this.notify(event.runId, previous, next);
if (next === 'idle') this.busySince.delete(event.runId);
}
private async notify(sessionId: string, previous: CodeSessionStatus, next: CodeSessionStatus): Promise<void> {
let notificationService: INotificationService;
try {
notificationService = container.resolve<INotificationService>('notificationService');
} catch {
return; // not registered (e.g. tests)
}
if (!notificationService.isSupported()) return;
const session = await this.codeSessionsRepo.get(sessionId);
const title = session?.title ?? 'Coding session';
if (next === 'needs-you') {
notificationService.notify({
title,
message: 'The coding agent needs your approval.',
});
} else if (next === 'idle' && previous === 'working') {
// Only worth interrupting for if the agent worked long enough that
// the user has plausibly moved on to something else.
const since = this.busySince.get(sessionId);
if (since !== undefined && Date.now() - since > 30_000) {
notificationService.notify({
title,
message: 'The coding agent finished its turn.',
});
}
}
}
}

View file

@ -3,8 +3,8 @@ import { promisify } from 'util';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import { existsSync } from 'fs';
import { CodeModeAgentStatus } from './types.js';
import { isEngineProvisioned } from './acp/engine-provisioner.js';
const execAsync = promisify(exec);
@ -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');
@ -40,30 +40,6 @@ function commonInstallPaths(binary: string): string[] {
].map(dir => path.join(dir, binary));
}
async function probeShell(binary: string): Promise<boolean> {
try {
if (process.platform === 'win32') {
const { stdout } = await execAsync(`where ${binary}`, { timeout: 5000 });
return stdout.trim().length > 0;
}
// Login shell so ~/.zprofile / ~/.bashrc PATH additions are visible —
// essential for Homebrew, nvm, asdf, volta installs on macOS GUI launches.
const { stdout } = await execAsync(`/bin/sh -lc 'command -v ${binary}'`, { timeout: 5000 });
return stdout.trim().length > 0;
} catch {
return false;
}
}
async function isInstalled(binary: string): Promise<boolean> {
if (await probeShell(binary)) return true;
// Fallback: scan well-known install locations directly.
for (const candidate of commonInstallPaths(binary)) {
if (existsSync(candidate)) return true;
}
return false;
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
@ -186,14 +162,15 @@ async function checkCodexSignedIn(): Promise<boolean> {
export { decodeJwtPayload };
export async function checkCodeModeAgentStatus(): Promise<CodeModeAgentStatus> {
const [claudeInstalled, codexInstalled, claudeSignedIn, codexSignedIn] = await Promise.all([
isInstalled('claude'),
isInstalled('codex'),
const [claudeSignedIn, codexSignedIn] = await Promise.all([
checkClaudeSignedIn(),
checkCodexSignedIn(),
]);
// `installed` means the engine is provisioned (downloaded) locally — the user has
// clicked Enable in Settings → Code Mode. We no longer look for a global claude/codex
// CLI on PATH; code mode runs our own pinned engine from ~/.rowboat/engines.
return {
claude: { installed: claudeInstalled, signedIn: claudeSignedIn },
codex: { installed: codexInstalled, signedIn: codexSignedIn },
claude: { installed: isEngineProvisioned('claude'), signedIn: claudeSignedIn },
codex: { installed: isEngineProvisioned('codex'), signedIn: codexSignedIn },
};
}

View file

@ -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>;

View file

@ -0,0 +1,52 @@
import fs from 'fs';
import path from 'path';
import {
NotificationSettingsSchema,
DEFAULT_NOTIFICATION_SETTINGS,
type NotificationSettings,
type NotificationCategory,
} from '@x/shared/dist/notification-settings.js';
import { WorkDir } from './config.js';
const NOTIFICATION_CONFIG_PATH = path.join(WorkDir, 'config', 'notification_settings.json');
/**
* Load notification settings, merging any persisted values over the defaults.
*
* Merging (rather than a strict parse) keeps the file forward/backward
* compatible: a category added in a newer build is filled in from defaults
* when an older file omits it, and a malformed file falls back to defaults
* instead of disabling notifications entirely.
*/
export function loadNotificationSettings(): NotificationSettings {
try {
if (fs.existsSync(NOTIFICATION_CONFIG_PATH)) {
const content = fs.readFileSync(NOTIFICATION_CONFIG_PATH, 'utf-8');
const parsed = JSON.parse(content);
const categories = parsed?.categories ?? {};
return NotificationSettingsSchema.parse({
categories: {
...DEFAULT_NOTIFICATION_SETTINGS.categories,
...categories,
},
});
}
} catch (error) {
console.error('[NotificationConfig] Error loading notification settings:', error);
}
return DEFAULT_NOTIFICATION_SETTINGS;
}
export function saveNotificationSettings(settings: NotificationSettings): void {
const dir = path.dirname(NOTIFICATION_CONFIG_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const validated = NotificationSettingsSchema.parse(settings);
fs.writeFileSync(NOTIFICATION_CONFIG_PATH, JSON.stringify(validated, null, 2));
}
/** Convenience: is a single notification category currently enabled? */
export function isNotificationCategoryEnabled(category: NotificationCategory): boolean {
return loadNotificationSettings().categories[category];
}

View file

@ -16,6 +16,12 @@ 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 { FSCodeProjectsRepo, ICodeProjectsRepo } from "../code-mode/projects/repo.js";
import { FSCodeSessionsRepo, ICodeSessionsRepo } from "../code-mode/sessions/repo.js";
import { CodeSessionService } from "../code-mode/sessions/service.js";
import { CodeSessionStatusTracker } from "../code-mode/sessions/status-tracker.js";
import type { IBrowserControlService } from "../application/browser-control/service.js";
import type { INotificationService } from "../application/notification/service.js";
@ -43,6 +49,19 @@ 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(),
// Code section: project registry, session metadata, the direct-drive
// session service, and the live status tracker.
codeProjectsRepo: asClass<ICodeProjectsRepo>(FSCodeProjectsRepo).singleton(),
codeSessionsRepo: asClass<ICodeSessionsRepo>(FSCodeSessionsRepo).singleton(),
codeSessionService: asClass(CodeSessionService).singleton(),
codeSessionStatusTracker: asClass(CodeSessionStatusTracker).singleton(),
});
export default container;

View file

@ -18,6 +18,9 @@ import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js'
import { limitEventItems } from './limit_event_items.js';
import { commitAll } from './version_history.js';
import { getTagDefinitions } from './tag_system.js';
import { knowledgeSourcesRepo } from './sources/repo.js';
import { syncSlackKnowledgeSources } from './sources/sync_slack.js';
import type { KnowledgeSourceConfig } from './sources/types.js';
/**
* Build obsidian-style knowledge graph by running topic extraction
@ -35,12 +38,11 @@ const LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_PATH = path.join(WorkDir, 'knowledge', '
// Configuration for the graph builder service
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
const SOURCE_FOLDERS = [
'gmail_sync',
path.join('knowledge', 'Meetings', 'fireflies'),
path.join('knowledge', 'Meetings', 'granola'),
path.join('knowledge', 'Meetings', 'rowboat'),
];
function getEnabledFileSources(): KnowledgeSourceConfig[] {
return knowledgeSourcesRepo
.listEnabledSources()
.filter(source => source.provider !== 'voice_memo');
}
// Voice memos are now created directly in knowledge/Voice Memos/<date>/
const VOICE_MEMOS_KNOWLEDGE_DIR = path.join(NOTES_OUTPUT_DIR, 'Voice Memos');
@ -643,6 +645,15 @@ export async function processAllSources(): Promise<void> {
let anyFilesProcessed = false;
try {
const slackFiles = await syncSlackKnowledgeSources();
if (slackFiles.length > 0) {
console.log(`[GraphBuilder] Slack sync wrote ${slackFiles.length} artifact files`);
}
} catch (error) {
console.error('[GraphBuilder] Error syncing Slack knowledge sources:', error);
}
// Process voice memos first (they get moved to knowledge/)
try {
const voiceMemosProcessed = await processVoiceMemosForKnowledge();
@ -654,12 +665,13 @@ export async function processAllSources(): Promise<void> {
}
const state = loadState();
const folderChanges: { folder: string; sourceDir: string; files: string[] }[] = [];
const folderChanges: { source: KnowledgeSourceConfig; sourceDir: string; files: string[] }[] = [];
const countsByFolder: Record<string, number> = {};
const allFiles: string[] = [];
const fileSources = getEnabledFileSources();
for (const folder of SOURCE_FOLDERS) {
const sourceDir = path.join(WorkDir, folder);
for (const source of fileSources) {
const sourceDir = path.join(WorkDir, source.artifactDir);
// Skip if folder doesn't exist
if (!fs.existsSync(sourceDir)) {
@ -671,7 +683,7 @@ export async function processAllSources(): Promise<void> {
let filesToProcess = getFilesToProcess(sourceDir, state);
// For gmail_sync, only process emails that have been labeled AND don't have noise filter tags
if (folder === 'gmail_sync') {
if (source.provider === 'gmail') {
filesToProcess = filesToProcess.filter(filePath => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
@ -690,13 +702,13 @@ export async function processAllSources(): Promise<void> {
}
if (filesToProcess.length > 0) {
console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${folder}`);
folderChanges.push({ folder, sourceDir, files: filesToProcess });
countsByFolder[folder] = filesToProcess.length;
console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${source.id}`);
folderChanges.push({ source, sourceDir, files: filesToProcess });
countsByFolder[source.id] = filesToProcess.length;
allFiles.push(...filesToProcess);
}
} catch (error) {
console.error(`[GraphBuilder] Error processing ${folder}:`, error);
console.error(`[GraphBuilder] Error processing ${source.id}:`, error);
// Continue with other folders even if one fails
}
}
@ -706,7 +718,7 @@ export async function processAllSources(): Promise<void> {
service: 'graph',
message: 'Syncing knowledge graph',
trigger: 'timer',
config: { sources: SOURCE_FOLDERS },
config: { sources: fileSources.map(source => source.id) },
});
const relativeFiles = allFiles.map(filePath => path.relative(WorkDir, filePath));
@ -770,7 +782,8 @@ export async function processAllSources(): Promise<void> {
*/
export async function init() {
console.log('[GraphBuilder] Starting Knowledge Graph Builder Service...');
console.log(`[GraphBuilder] Monitoring folders: ${SOURCE_FOLDERS.join(', ')}, knowledge/Voice Memos`);
const sourceFolders = getEnabledFileSources().map(source => source.artifactDir);
console.log(`[GraphBuilder] Monitoring folders: ${sourceFolders.join(', ')}, knowledge/Voice Memos`);
console.log(`[GraphBuilder] Will check for new content every ${SYNC_INTERVAL_MS / 1000} seconds`);
// Initial run

View file

@ -109,7 +109,7 @@ export interface Classification {
const ClassificationSchema = z.object({
importance: z.enum(['important', 'other']).describe('important = real correspondence, action-required, or content worth referencing later. other = newsletters, marketing, automated notifications, transactional receipts, cold outreach.'),
summary: z.string().optional().describe('One or two sentences capturing what the thread is about and any implied action. Required when importance is important. Omit when other.'),
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text with real line breaks (\\n): greeting on its own line, a blank line between paragraphs, and the sign-off on its own line(s) — e.g. "Hi Tyrone,\\n\\nThanks for the follow-up.\\n\\nBest,\\nJohn". Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text with real line breaks (\\n): greeting on its own line, a blank line between paragraphs, and the sign-off on its own line(s) — e.g. "Hi Tyrone,\\n\\nThanks for the follow-up.\\n\\nBest,\\nJohn". If a sign-off name is included, use only the user\'s first name. Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
});
const SYSTEM_PROMPT = `You classify a Gmail thread for a personal inbox view and, when appropriate, draft a reply on behalf of the user.
@ -139,6 +139,8 @@ Could you resend it with a bit more context so I can get back to you properly?
Best,
John
If you include the user's name in the sign-off, use only their first name, never their full name.
When an email-style guide is provided below, it takes precedence: follow it for greeting, tone, sign-off, length, and phrasing patterns (while keeping the line-break structure shown above). If no style guide is provided, default to a brief, warm, professional voice.
For scheduling-related threads (where the sender proposes meeting times, asks for the user's availability, or follows up on a meeting request), look at the user's upcoming calendar (provided below) and either:

View file

@ -0,0 +1,114 @@
// 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);
}
export 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;
// Encoded VERP/list aliases, e.g. long-token-arjun=rowboat...@domain.
if (local.includes('=') && /^[a-z0-9]{16,}[-+].*=/.test(local)) return true;
const segs = localSegments(local);
for (const s of segs) {
if (AUTOMATED_LOCAL_PARTS.has(s)) return true;
}
if (/(no.?reply|do.?not.?reply|notifications?|news.?letter|mailer.?daemon|postmaster|automated|broadcast|statement)/i.test(local)) {
return true;
}
if (local.length >= 20 && /^[a-z0-9=._\-+]+$/.test(local) && /[0-9]/.test(local)) {
const digits = (local.match(/[0-9]/g) || []).length;
const letters = (local.match(/[a-z]/g) || []).length;
if (digits / local.length >= 0.2 || (digits >= 3 && letters >= 12 && !local.includes('.'))) return true;
}
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;
}
}
for (const kw of AUTOMATED_DOMAIN_KEYWORDS) {
if (domain.includes(kw)) return true;
}
if (/(^|\.)(mailers?|mailer|mailgun|sendgrid|mailchimp|mailerlite|bounces?|marketing|promo|notifications?|newsletter)(\.|$)/i.test(domain)) {
return true;
}
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;
}

View file

@ -0,0 +1,230 @@
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';
import { isAutomatedAddress } from './contact_filters.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;
}
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,
}));
}

View file

@ -0,0 +1,392 @@
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';
import { isAutomatedAddress } from './contact_filters.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) {
if (isAutomatedAddress(e.email)) continue;
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;
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);
}
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;
if (isAutomatedAddress(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,
}));
}

View file

@ -6,6 +6,7 @@ import container from '../../di/container.js';
import { IGranolaConfigRepo } from './repo.js';
import { serviceLogger } from '../../services/service_logger.js';
import { limitEventItems } from '../limit_event_items.js';
import { publishMeetingNotesReadyEvent } from '../meeting-events.js';
import {
GetDocumentsResponse,
SyncState,
@ -439,6 +440,14 @@ async function syncNotes(): Promise<void> {
} else {
console.log(`[Granola] Saved: ${filename}`);
newCount++;
// First-time write only — don't re-fire on later edits to the
// same note (Granola notes update live).
await publishMeetingNotesReadyEvent({
source: 'granola',
title: docTitle,
filePath,
when: docDate.toISOString(),
});
}
// Update state

View file

@ -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');
@ -163,7 +166,7 @@ If there are events, include them:
1. Use \`file-list\` with path \`gmail_sync\` to list files (skip \`sync_state.json\` and \`attachments/\`)
2. Use \`file-readText\` to read the email markdown files (e.g. \`gmail_sync/threadid123.md\`)
3. Check the frontmatter \`action\` field — emails with \`action: reply\` or \`action: respond\` need a response
4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. Example:
4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. If a draft includes a sign-off name, use only the user's first name, never their full name. Example:
\`\`\`
\\\`\\\`\\\`emails

View file

@ -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 };
}

View file

@ -0,0 +1,37 @@
import { createEvent } from '../events/producer.js';
// Emitted when a meeting note/transcript is first written to disk (Fireflies,
// Granola, …). This is the natural "the meeting is over and we have content"
// signal — unlike a calendar end-time, the notes actually exist now. Coding
// background tasks subscribe to it (via eventMatchCriteria) to scan freshly
// landed notes for actionable coding items.
//
// Fire ONCE per meeting (on first write), not on every re-sync/edit, so a note
// that keeps updating doesn't re-trigger downstream agents.
export async function publishMeetingNotesReadyEvent(args: {
source: string;
title: string;
filePath: string;
when?: string;
}): Promise<void> {
const { source, title, filePath, when } = args;
try {
await createEvent({
source,
type: 'meeting.notes_ready',
createdAt: new Date().toISOString(),
payload: [
`# Meeting notes ready`,
``,
`**Title:** ${title}`,
when ? `**When:** ${when}` : ``,
`**Source:** ${source}`,
`**Notes file:** \`${filePath}\``,
``,
`The full meeting notes/transcript are at the path above. They may contain coding action items (bugs to fix, features to build, changes requested). Read the file to decide whether to act.`,
].filter(Boolean).join('\n'),
});
} catch (err) {
console.error(`[${source}] Failed to publish meeting.notes_ready event:`, err);
}
}

View file

@ -30,16 +30,16 @@ tools:
**Current date and time:** ${new Date().toISOString()}
Sources (emails, meetings, voice memos) are processed in roughly chronological order. This means:
Sources (emails, meetings, voice memos, Slack messages, and connected-tool artifacts) are processed in roughly chronological order. This means:
- Earlier sources may reference events that have since occurred later sources will provide updates.
- If a source mentions a future meeting or deadline, it may already be in the past by now. Use the current date above to reason about what is past vs. upcoming.
- Don't treat old commitments as still "open" if later sources or the current date suggest they've likely been resolved.
# Task
You are a memory agent. You are given one or more source files (emails, meeting transcripts, or voice memos) to process. **The files in a request are independent of each other** they are batched together only for efficiency, not because they are related. Process each source file on its own terms (see "Source Scoping" below). For each source file you will:
You are a memory agent. You are given one or more source files (emails, meeting transcripts, voice memos, Slack messages, or other connected-tool artifacts) to process. **The files in a request are independent of each other** they are batched together only for efficiency, not because they are related. Process each source file on its own terms (see "Source Scoping" below). For each source file you will:
1. **Determine source type (meeting or email)**
1. **Determine source type (meeting, email, voice memo, Slack, or connected-tool artifact)**
2. **Evaluate if the source is worth processing**
3. **Search for all existing related notes**
4. **Resolve entities to canonical names**
@ -48,8 +48,9 @@ You are a memory agent. You are given one or more source files (emails, meeting
7. **Detect state changes (status updates, resolved items, role changes)**
8. Create new notes or update existing notes
9. **Apply state changes to existing notes**
10. **Maintain assistant-facing notes for every canonical note you create or update**
The core rule: **Both meetings and emails can create notes, but emails require personalized content and a new People/Organization note from an email also requires the user to have replied at least once in the thread (the Email Reply Gate). Emails can always update existing notes regardless.**
The core rule: **Meetings and voice memos can create notes freely. Emails require personalized content and a new People/Organization note from an email also requires the user to have replied at least once in the thread (the Email Reply Gate). Slack and connected-tool artifacts can update existing notes when they carry clear state changes, decisions, commitments, or project facts; they should create new notes only when the artifact clearly identifies a durable person, organization, project, or topic worth tracking.**
# Source Scoping (Batch Isolation) READ FIRST
@ -75,7 +76,7 @@ You have full read access to the existing knowledge directory. Use this extensiv
# Inputs
1. **source_file**: Path to a single file to process (email or meeting transcript)
1. **source_file**: Path to a single file to process (email, meeting transcript, voice memo, Slack message, or connected-tool artifact)
2. **knowledge_folder**: Path to Obsidian vault (read/write access)
3. **user**: Information about the owner of this memory
- name: e.g., "Arj"
@ -170,7 +171,7 @@ ${renderNoteEffectRules()}
# Step 0: Determine Source Type
Read the source file and determine if it's a meeting or email.
Read the source file and determine its source type.
\`\`\`
file-readText({ path: "{source_file}" })
\`\`\`
@ -191,10 +192,22 @@ file-readText({ path: "{source_file}" })
- Has frontmatter \`path:\` field like \`Voice Memos/YYYY-MM-DD/...\`
- Has \`## Transcript\` section
**Slack indicators:**
- YAML frontmatter has \`source: slack\`
- Source file path is under \`knowledge_sources/slack/\`
- Contains fields like \`Workspace:\`, \`Channel:\`, \`Author:\`, \`Thread TS:\`, or a \`## Message\` section
**Connected-tool artifact indicators:**
- YAML frontmatter has \`source:\` set to a provider like \`github\`, \`linear\`, \`jira\`, \`notion\`, etc.
- Source file path is under \`knowledge_sources/<provider>/\`
- Contains issue, PR, task, ticket, comment, status, or project metadata
**Set processing mode:**
- \`source_type = "meeting"\` → Can create new notes
- \`source_type = "email"\` → Can create notes if personalized and relevant
- \`source_type = "voice_memo"\` → Can create new notes (treat like meetings)
- \`source_type = "slack"\` → Prefer updating existing project/person/topic notes; create new notes only for clear durable entities
- \`source_type = "connected_tool"\` → Prefer updating existing project/topic notes; create new notes only for durable projects, organizations, repositories, issues, or initiatives
---
@ -240,6 +253,22 @@ Emails containing calendar invites (\`.ics\` attachments or inline calendar data
## For Meetings and Voice Memos
Always process no filtering needed.
## For Slack Messages
Process Slack messages only when they contain durable knowledge:
- Decisions, approvals, changes in project status, blockers, owners, deadlines, handoffs, or commitments
- Facts about people, organizations, projects, customers, product areas, repositories, issues, or incidents
- Meaningful summaries in long threads
Skip Slack messages that are only acknowledgements, greetings, jokes, reactions, short coordination with no durable outcome, or vague statements that cannot be resolved to a known entity. For ambiguous updates like "x is done", update an existing note only if \`x\` resolves clearly from the message, channel, thread, or existing knowledge index. If it does not resolve clearly, skip rather than inventing a fact.
## For Connected-Tool Artifacts
Process artifacts from GitHub, Linear, Jira, and similar tools when they carry project or work-state changes:
- Issue/PR/task created, assigned, closed, merged, reopened, blocked, or reprioritized
- Status, owner, milestone, deadline, release, incident, customer, or decision changes
- Comments that clarify requirements, decisions, blockers, or commitments
Skip routine metadata churn and duplicated notifications unless they change durable knowledge.
## For Emails Read YAML Frontmatter
Emails have YAML frontmatter with labels prepended by the labeling agent:
@ -949,6 +978,38 @@ One line summarizing this source's relevance to the entity:
**2025-01-15** (email): Sarah shared the contract draft.
\`\`\`
## Assistant Notes
Every canonical People, Organizations, Projects, or Topics note you create or update must include a bottom section:
\`\`\`markdown
## Assistant notes
- [2026-02-03T14:25:00.000Z] Prefers concise technical detail before pricing discussion.
\`\`\`
These notes are for future assistant context, not for user-facing summaries.
**Rules:**
- Add assistant note lines only when the source contains durable, entity-specific context worth preserving for future assistant use.
- Add one line for a single clear observation; add more only when there are multiple distinct durable observations.
- Do not add filler. If the source has no useful entity-specific observation beyond what the activity entry already captures, ensure the section exists but leave it without a new bullet.
- Use the current ISO timestamp from the Context section, not just the source date.
- Keep each line concise and specific: one durable observation about who or what the note is about.
- For people, capture subtle useful context when evidenced: working style, preferences, role changes, current company, interests, constraints, or relationship context.
- For organizations, capture useful context about relationship status, decision process, interests, constraints, or how they prefer to work.
- For projects and topics, capture current state, constraints, recurring patterns, or what the assistant should remember when helping with that project/topic.
- Prefer useful but non-obvious observations over restating the activity entry.
- Do not add guesses.
- If the note already has \`## Assistant notes\`, append new lines at the top of that section so it is reverse chronological.
- If the note lacks \`## Assistant notes\`, add the section at the very bottom.
- Deduplicate within the section: do not add the same observation again if an equivalent line already exists; refresh or update the timestamp only when the source reconfirms the same durable observation.
- Do not put user-wide preferences here; those belong in \`knowledge/Agent Notes/\`. This section is scoped to the entity note itself.
**Examples:**
- \`- [2026-02-03T14:25:00.000Z] Sarah prefers pricing options framed with implementation risk called out explicitly.\`
- \`- [2026-02-03T14:25:00.000Z] Rahul just joined Acme as VP Engineering and is still learning their vendor review process.\`
- \`- [2026-02-03T14:25:00.000Z] Acme's team tends to route security questions through procurement before engineering review.\`
---
# Step 7: Detect State Changes
@ -1103,11 +1164,13 @@ If you discovered new name variants during resolution, add them to Aliases field
- **Always use absolute paths** with format \`[[Folder/Name]]\` for all links
- Use YYYY-MM-DD format for dates
- Use ISO timestamp format for assistant notes
- Be concise: one line per activity entry
- Note state changes with \`[Field → value]\` in activity
- Escape quotes properly in shell commands
- Write only one file per response (notes and \`suggested-topics.md\` follow the same rule)
- **Always set \`Last update\`** in the Info section to the YYYY-MM-DD date of the source email or meeting. When updating an existing note, update this field to the new source event's date.
- **Keep \`## Assistant notes\` at the very bottom** for canonical People, Organizations, Projects, or Topics notes, and update it only when there is durable entity-specific context worth preserving.
- Keep \`suggested-topics.md\` curated, deduped, and capped to the strongest 8-12 cards
---
@ -1220,6 +1283,7 @@ Before completing, verify:
- [ ] Key facts are substantive (no filler)
- [ ] Open items are commitments/next steps only
- [ ] Empty sections left empty rather than filled with placeholders
- [ ] Canonical entity notes keep \`## Assistant notes\` at the bottom, with new timestamped lines only for durable entity-specific context
**State Changes:**
- [ ] Detected project status changes

View file

@ -40,7 +40,10 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
{Substantive facts only. Leave empty if none.}
## Open items
{Commitments and next steps only. Leave empty if none.}`,
{Commitments and next steps only. Leave empty if none.}
## Assistant notes
- [{ISO_TIMESTAMP}] {One concise assistant-facing observation about this person.}`,
extractionGuide:
"Look for: name, role, organization, email, aliases, relationship context",
},
@ -77,7 +80,10 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
{Substantive facts only. Leave empty if none.}
## Open items
{Commitments and next steps only. Leave empty if none.}`,
{Commitments and next steps only. Leave empty if none.}
## Assistant notes
- [{ISO_TIMESTAMP}] {One concise assistant-facing observation about this organization.}`,
extractionGuide:
"Look for: organization name, type, industry, relationship, domain, key people, projects",
},
@ -116,7 +122,10 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
{Commitments and next steps only. Leave empty if none.}
## Key facts
{Substantive facts only. Leave empty if none.}`,
{Substantive facts only. Leave empty if none.}
## Assistant notes
- [{ISO_TIMESTAMP}] {One concise assistant-facing observation about this project.}`,
extractionGuide:
"Look for: project name, type, status, people involved, organizations, timeline, decisions",
},
@ -149,7 +158,10 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
{Commitments and next steps only. Leave empty if none.}
## Key facts
{Substantive facts only. Leave empty if none.}`,
{Substantive facts only. Leave empty if none.}
## Assistant notes
- [{ISO_TIMESTAMP}] {One concise assistant-facing observation about this topic.}`,
extractionGuide:
"Look for: topic name, keywords, related people/orgs/projects, decisions, key facts",
},
@ -199,11 +211,39 @@ export function getNoteTypeDefinitions(): NoteTypeDefinition[] {
// ── Render helper ────────────────────────────────────────────────────────
function assistantNotesPlaceholder(type: string): string {
const normalized = type.toLowerCase();
if (normalized === "people") {
return "person";
}
if (normalized === "organizations") {
return "organization";
}
if (normalized === "projects") {
return "project";
}
if (normalized === "topics") {
return "topic";
}
return "entity";
}
function renderTemplateWithAssistantNotes(def: NoteTypeDefinition): string {
if (!["People", "Organizations", "Projects", "Topics"].includes(def.type)) {
return def.template;
}
if (/^## Assistant notes\b/im.test(def.template)) {
return def.template;
}
const target = assistantNotesPlaceholder(def.type);
return `${def.template.trimEnd()}\n\n## Assistant notes\n- [{ISO_TIMESTAMP}] {One concise assistant-facing observation about this ${target}.}`;
}
export function renderNoteTypesBlock(): string {
const defs = getNoteTypeDefinitions();
const sections = defs.map(
(d) =>
`## ${d.type}\n\`\`\`markdown\n${d.template}\n\`\`\``,
`## ${d.type}\n\`\`\`markdown\n${renderTemplateWithAssistantNotes(d)}\n\`\`\``,
);
return `# Note Templates\n\n${sections.join("\n\n")}`;
}

View file

@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import {
filterSlackHomeCandidatesForRelevance,
rankSlackHomeMessages,
SlackHomeRankCandidate,
} from './rank_slack_home.js';
function slackTs(dateMs: number): string {
return `${Math.floor(dateMs / 1000)}.000000`;
}
const NOW = Date.parse('2026-06-04T18:00:00Z');
const recent = (text: string, id = text): SlackHomeRankCandidate => ({
id,
channelName: 'general',
text,
ts: slackTs(NOW - 5 * 60 * 1000),
});
function keptIds(candidates: SlackHomeRankCandidate[]): string[] {
return filterSlackHomeCandidatesForRelevance(candidates, NOW).map(c => c.id);
}
describe('filterSlackHomeCandidatesForRelevance', () => {
describe('routine standup logistics', () => {
it('drops stale standup logistics but keeps recent ones and durable updates', () => {
const nineHoursAgo = NOW - 9 * 60 * 60 * 1000;
const twelveHoursAgo = NOW - 12 * 60 * 60 * 1000;
const thirtyMinutesAgo = NOW - 30 * 60 * 1000;
const candidates: SlackHomeRankCandidate[] = [
{ id: 'stale-standup-schedule', channelName: 'general', text: 'standup at 4pm possible?', ts: slackTs(nineHoursAgo) },
{ id: 'stale-standup-sick', channelName: 'general', text: 'ill skip todays standup I am having stomach ache and not feeling well', ts: slackTs(twelveHoursAgo) },
{ id: 'durable-issue-update', channelName: 'general', text: 'is the icon issue fixed for windows?', ts: slackTs(twelveHoursAgo) },
{ id: 'recent-standup-schedule', channelName: 'general', text: 'standup at 4pm possible?', ts: slackTs(thirtyMinutesAgo) },
];
expect(keptIds(candidates)).toEqual(['durable-issue-update', 'recent-standup-schedule']);
});
});
describe('system / automated messages', () => {
it('drops channel join/leave, topic, rename and call notices', () => {
const candidates = [
recent('Alex has joined the channel', 'join'),
recent('Sam has left the channel', 'leave'),
recent('Alex set the channel topic: Q3 planning', 'topic'),
recent('Sam renamed the channel to design-team', 'rename'),
recent('Alex started a huddle', 'huddle'),
recent('Real question: can someone review my PR?', 'real'),
];
expect(keptIds(candidates)).toEqual(['real']);
});
it('keeps a system-shaped message that carries a durable signal', () => {
const candidates = [recent('Priya set the channel topic: incident response war room', 'topic-incident')];
expect(keptIds(candidates)).toEqual(['topic-incident']);
});
});
describe('emoji / reaction-only', () => {
it('drops emoji-only, shortcode-only and punctuation-only posts', () => {
const candidates = [
recent('👍', 'thumbs'),
recent('🎉🎉🎉', 'party'),
recent(':tada: :rocket:', 'shortcodes'),
recent('!!!', 'punct'),
recent('🚀 shipping the new pricing page today', 'real'),
];
expect(keptIds(candidates)).toEqual(['real']);
});
});
describe('greetings / acknowledgements', () => {
it('drops bare greetings and acks but keeps anything with content', () => {
const candidates = [
recent('thanks!', 'thanks'),
recent('gm', 'gm'),
recent('lgtm', 'lgtm'),
recent('+1', 'plus1'),
recent('sounds good', 'sg'),
recent('ok', 'ok'),
recent('ok, the deploy is blocked on the migration', 'ok-with-content'),
recent('thanks for fixing the outage', 'thanks-durable'),
];
// 'ok-with-content' kept (has content); 'thanks-durable' kept (durable signal).
expect(keptIds(candidates)).toEqual(['ok-with-content', 'thanks-durable']);
});
});
it('drops empty-text candidates', () => {
expect(keptIds([recent(' ', 'blank'), recent('a real message here', 'real')])).toEqual(['real']);
});
});
describe('rankSlackHomeMessages (deterministic)', () => {
it('orders surviving candidates newest-first and caps at the limit', async () => {
const mk = (id: string, minutesAgo: number): SlackHomeRankCandidate => ({
id, channelName: 'general', text: `update ${id}`, ts: slackTs(NOW - minutesAgo * 60 * 1000),
});
const candidates = [mk('old', 50), mk('newest', 1), mk('mid', 20)];
expect(await rankSlackHomeMessages(candidates, 5)).toEqual(['newest', 'mid', 'old']);
expect(await rankSlackHomeMessages(candidates, 2)).toEqual(['newest', 'mid']);
});
it('filters noise before ranking', async () => {
const candidates = [
recent('👍', 'emoji'),
recent('Alex has joined the channel', 'join'),
recent('can you review the pricing proposal?', 'real'),
];
expect(await rankSlackHomeMessages(candidates, 5)).toEqual(['real']);
});
it('handles a high-volume batch: caps output and preserves recency order', async () => {
const candidates: SlackHomeRankCandidate[] = Array.from({ length: 150 }, (_, i) => ({
id: `m${i}`,
channelName: 'general',
// i=0 is newest; larger i is older.
text: `status update number ${i}`,
ts: slackTs(NOW - i * 60 * 1000),
}));
const ranked = await rankSlackHomeMessages(candidates, 5);
expect(ranked).toEqual(['m0', 'm1', 'm2', 'm3', 'm4']);
});
});

View file

@ -0,0 +1,92 @@
export type SlackHomeRankCandidate = {
id: string;
workspaceName?: string;
channelName?: string;
author?: string;
text: string;
ts: string;
};
const EXPIRED_ROUTINE_AGE_MS = 2 * 60 * 60 * 1000;
const ROUTINE_EVENT_RE = /\b(stand[-\s]?up|daily\s+(sync|scrum|standup)|scrum|check[-\s]?in)\b/i;
const ROUTINE_LOGISTICS_RE = /\b(skip|skipping|miss|missing|can't|cannot|cant|won't|wont|join|attend|possible|move|reschedule|shift|late|running\s+late|stomach|sick|not\s+feeling|headache|doctor|appointment|today|todays|today's|tomorrow|at\s+\d{1,2}(:\d{2})?\s*(am|pm)?)\b/i;
// Durable signals always win: a message matching any of these is kept even if
// it would otherwise look like noise (a system message, a "done", etc.).
const DURABLE_SIGNAL_RE = /\b(blocker|blocked|decision|decided|owner|deadline|shipped|fixed|done|launched|deployed|merged|bug|issue|incident|outage|customer|contract|pricing|proposal|launch|release|handoff|review|approval|approved)\b/i;
// Slack system / automated messages render as plain narration like
// "<name> has joined the channel". They carry no human content, so drop them.
const SYSTEM_MESSAGE_RE = /\b(has joined the channel|has left the channel|was added to|has been added|set the channel (topic|purpose|description)|cleared the channel (topic|purpose)|renamed the channel|archived the channel|un-?archived the channel|pinned a message|joined the (call|huddle)|started a (call|huddle)|set up a call)\b/i;
// Greetings / acknowledgements with no informational content. Anchored to the
// whole (trimmed) message so "ok" drops but "ok, the deploy is blocked" stays.
const TRIVIAL_RE = /^(hi|hello+|hey+|yo|gm|gn|good\s*(morning|night|evening|afternoon)|morning|thanks?|thank\s*you|ty|thx|tysm|np|no\s*problem|ok(ay)?|k|got\s*it|gotcha|lgtm|\+1|nice|cool|great|awesome|perfect|done|yes+|yep|yup|no+|nope|sure|sounds?\s*good|sg|welcome|congrats?|congratulations)[\s.!?]*$/i;
const EMOJI_SHORTCODE_RE = /:[a-z0-9_+-]+:/gi;
function slackTsToMs(ts: string): number | null {
const seconds = Number(ts.split('.')[0]);
if (!Number.isFinite(seconds)) return null;
return seconds * 1000;
}
// Newest-first recency ordering, capped at limit. The Home card shows "latest
// messages", so recency is the ordering once noise is filtered out.
function timeRank(candidates: SlackHomeRankCandidate[], limit: number): string[] {
return [...candidates]
.sort((a, b) => Number(b.ts) - Number(a.ts))
.slice(0, limit)
.map(candidate => candidate.id);
}
// What remains after removing :shortcodes:, unicode emoji/symbols, punctuation
// and whitespace. Empty ⇒ the message was emoji/reaction-only.
function strippedToCore(text: string): string {
return text
.replace(EMOJI_SHORTCODE_RE, '')
.replace(/[\s\p{P}\p{S}]/gu, '')
.trim();
}
function isExpiredRoutineLogistics(candidate: SlackHomeRankCandidate, nowMs: number): boolean {
const sentAtMs = slackTsToMs(candidate.ts);
if (sentAtMs === null) return false;
if (nowMs - sentAtMs < EXPIRED_ROUTINE_AGE_MS) return false;
const text = candidate.text.replace(/\s+/g, ' ').trim();
if (!ROUTINE_EVENT_RE.test(text)) return false;
if (DURABLE_SIGNAL_RE.test(text)) return false;
return ROUTINE_LOGISTICS_RE.test(text);
}
// Low-value classes that never belong on Home: empty bodies, Slack system
// messages, emoji/reaction-only posts, and bare greetings/acks. A durable
// signal overrides all of these.
function isLowValueNoise(candidate: SlackHomeRankCandidate): boolean {
const text = candidate.text.replace(/\s+/g, ' ').trim();
if (!text) return true;
if (DURABLE_SIGNAL_RE.test(text)) return false;
if (SYSTEM_MESSAGE_RE.test(text)) return true;
if (TRIVIAL_RE.test(text)) return true;
return strippedToCore(text).length === 0;
}
export function filterSlackHomeCandidatesForRelevance(
candidates: SlackHomeRankCandidate[],
nowMs = Date.now(),
): SlackHomeRankCandidate[] {
return candidates.filter(candidate =>
!isExpiredRoutineLogistics(candidate, nowMs) && !isLowValueNoise(candidate));
}
// Deterministic Home feed: drop noise, then order by recency and cap. No LLM
// call — the filter does the de-noising and recency does the ordering.
// (kept async so the IPC caller's contract is unchanged.)
export async function rankSlackHomeMessages(
candidates: SlackHomeRankCandidate[],
limit: number,
): Promise<string[]> {
return timeRank(filterSlackHomeCandidatesForRelevance(candidates), limit);
}

View file

@ -0,0 +1,113 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../../config/config.js';
import {
KnowledgeSourceConfig,
KnowledgeSourcesFile,
type KnowledgeSourcesFile as KnowledgeSourcesFileType,
} from './types.js';
const CONFIG_FILE = path.join(WorkDir, 'config', 'knowledge_sources.json');
const BUILTIN_SOURCES: KnowledgeSourceConfig[] = [
{
id: 'gmail',
provider: 'gmail',
enabled: true,
artifactDir: 'gmail_sync',
syncMode: 'file',
scopes: [],
},
{
id: 'fireflies-meetings',
provider: 'meeting',
enabled: true,
artifactDir: path.join('knowledge', 'Meetings', 'fireflies'),
syncMode: 'file',
scopes: [],
},
{
id: 'granola-meetings',
provider: 'meeting',
enabled: true,
artifactDir: path.join('knowledge', 'Meetings', 'granola'),
syncMode: 'file',
scopes: [],
},
{
id: 'rowboat-meetings',
provider: 'meeting',
enabled: true,
artifactDir: path.join('knowledge', 'Meetings', 'rowboat'),
syncMode: 'file',
scopes: [],
},
];
function ensureConfigDir(): void {
fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true });
}
function mergeBuiltinSources(config: KnowledgeSourcesFileType): KnowledgeSourcesFileType {
const byId = new Map(config.sources.map(source => [source.id, source]));
for (const builtin of BUILTIN_SOURCES) {
if (!byId.has(builtin.id)) {
byId.set(builtin.id, builtin);
}
}
return { sources: Array.from(byId.values()) };
}
export interface IKnowledgeSourcesRepo {
getConfig(): KnowledgeSourcesFileType;
setConfig(config: KnowledgeSourcesFileType): void;
listEnabledSources(): KnowledgeSourceConfig[];
upsertSource(source: KnowledgeSourceConfig): KnowledgeSourcesFileType;
}
export class FSKnowledgeSourcesRepo implements IKnowledgeSourcesRepo {
getConfig(): KnowledgeSourcesFileType {
try {
if (!fs.existsSync(CONFIG_FILE)) {
const config = { sources: BUILTIN_SOURCES };
this.setConfig(config);
return config;
}
const parsed = KnowledgeSourcesFile.parse(JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')));
const merged = mergeBuiltinSources(parsed);
if (merged.sources.length !== parsed.sources.length) {
this.setConfig(merged);
}
return merged;
} catch (error) {
console.error('[KnowledgeSources] Failed to load config:', error);
return { sources: BUILTIN_SOURCES };
}
}
setConfig(config: KnowledgeSourcesFileType): void {
const validated = KnowledgeSourcesFile.parse(mergeBuiltinSources(config));
ensureConfigDir();
fs.writeFileSync(CONFIG_FILE, JSON.stringify(validated, null, 2), 'utf-8');
}
listEnabledSources(): KnowledgeSourceConfig[] {
return this.getConfig().sources.filter(source => source.enabled);
}
upsertSource(source: KnowledgeSourceConfig): KnowledgeSourcesFileType {
const validated = KnowledgeSourceConfig.parse(source);
const config = this.getConfig();
const existingIndex = config.sources.findIndex(item => item.id === validated.id);
if (existingIndex >= 0) {
config.sources[existingIndex] = validated;
} else {
config.sources.push(validated);
}
this.setConfig(config);
return this.getConfig();
}
}
export const knowledgeSourcesRepo = new FSKnowledgeSourcesRepo();

View file

@ -0,0 +1,182 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { KnowledgeSourceConfig } from './types.js';
// WorkDir is resolved when config.js loads, so the env override must be in
// place before sync_slack.js (which imports it) is loaded — hence the
// dynamic imports in beforeAll.
const tmpWorkDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slack-sync-test-'));
process.env.ROWBOAT_WORKDIR = tmpWorkDir;
const sourceA: KnowledgeSourceConfig = {
id: 'slack-a',
provider: 'slack',
enabled: true,
artifactDir: 'knowledge_sources/slack',
syncMode: 'poll',
intervalMs: 5 * 60 * 1000,
scopes: [{ type: 'channel', id: 'C-AAA', name: '#alpha' }],
};
const sourceB: KnowledgeSourceConfig = {
...sourceA,
id: 'slack-b',
scopes: [{ type: 'channel', id: 'C-BBB', name: '#beta' }],
};
vi.mock('./repo.js', () => ({
knowledgeSourcesRepo: {
listEnabledSources: vi.fn(() => [sourceA, sourceB]),
getConfig: vi.fn(() => ({ sources: [sourceA, sourceB] })),
},
}));
vi.mock('../../services/service_logger.js', () => ({
serviceLogger: {
startRun: vi.fn(async () => ({ service: 'slack', runId: 'test-run', startedAt: Date.now() })),
log: vi.fn(async () => { }),
},
}));
vi.mock('../../events/producer.js', () => ({
createEvent: vi.fn(async () => { }),
}));
vi.mock('../../slack/agent-slack-exec.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../slack/agent-slack-exec.js')>();
return { ...actual, runAgentSlack: vi.fn() };
});
type SyncModule = typeof import('./sync_slack.js');
type ExecModule = typeof import('../../slack/agent-slack-exec.js');
let sync: SyncModule;
let execMock: ReturnType<typeof vi.mocked<ExecModule['runAgentSlack']>>;
const stateFile = path.join(tmpWorkDir, 'slack_knowledge_sync_state.json');
function readState() {
return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
}
/** Rewind a source's lastSyncAt so it counts as due again. */
function rewindSource(sourceId: string, ms: number) {
const state = readState();
state.sources[sourceId].lastSyncAt = new Date(Date.now() - ms).toISOString();
fs.writeFileSync(stateFile, JSON.stringify(state), 'utf-8');
}
const okEmpty = { ok: true as const, stdout: '[]', data: [] };
const rateLimited = {
ok: false as const, kind: 'rate_limited' as const, stderr: 'ratelimited',
message: 'A rate-limit has been reached, you may retry this request in 30 seconds',
};
const badChannel = {
ok: false as const, kind: 'bad_channel' as const, stderr: '',
message: 'Could not resolve channel name: #alpha',
};
beforeAll(async () => {
sync = await import('./sync_slack.js');
const exec = await import('../../slack/agent-slack-exec.js');
execMock = vi.mocked(exec.runAgentSlack);
});
beforeEach(() => {
execMock.mockReset();
fs.rmSync(stateFile, { force: true });
});
afterAll(() => {
fs.rmSync(tmpWorkDir, { recursive: true, force: true });
});
describe('syncSlackKnowledgeSources status persistence', () => {
it('records ok status and lastSyncAt per source', async () => {
execMock.mockResolvedValue(okEmpty);
await sync.syncSlackKnowledgeSources();
const state = readState();
for (const id of ['slack-a', 'slack-b']) {
expect(state.sources[id].lastStatus).toBe('ok');
expect(Date.parse(state.sources[id].lastSyncAt)).toBeGreaterThan(Date.now() - 60_000);
expect(state.sources[id].lastError).toBeUndefined();
}
});
it('persists lastError and lets other sources continue past a bad one', async () => {
execMock.mockImplementation(async (args: string[]) =>
args.includes('C-AAA') ? badChannel : okEmpty);
await sync.syncSlackKnowledgeSources();
const state = readState();
expect(state.sources['slack-a']).toMatchObject({
lastStatus: 'error',
lastError: { kind: 'bad_channel', message: 'Could not resolve channel name: #alpha' },
});
// slack-b synced despite slack-a failing
expect(state.sources['slack-b'].lastStatus).toBe('ok');
expect(execMock.mock.calls.some(call => call[0].includes('C-BBB'))).toBe(true);
});
it('stops the run on rate limit without touching later sources', async () => {
execMock.mockResolvedValue(rateLimited);
await sync.syncSlackKnowledgeSources();
const state = readState();
expect(state.sources['slack-a'].lastError.kind).toBe('rate_limited');
expect(state.sources['slack-b']).toBeUndefined();
expect(execMock.mock.calls.every(call => !call[0].includes('C-BBB'))).toBe(true);
});
it('grows backoff on consecutive rate limits and resets it on success', async () => {
execMock.mockResolvedValue(rateLimited);
await sync.syncSlackKnowledgeSources();
expect(readState().sources['slack-a'].backoffMultiplier).toBe(2);
rewindSource('slack-a', 60 * 60 * 1000);
await sync.syncSlackKnowledgeSources();
expect(readState().sources['slack-a'].backoffMultiplier).toBe(4);
rewindSource('slack-a', 60 * 60 * 1000);
execMock.mockResolvedValue(okEmpty);
await sync.syncSlackKnowledgeSources();
expect(readState().sources['slack-a'].backoffMultiplier).toBeUndefined();
expect(readState().sources['slack-a'].lastStatus).toBe('ok');
});
it('does not re-sync a rate-limited source before its backed-off interval elapses', async () => {
execMock.mockResolvedValue(rateLimited);
// First run rate-limits slack-a and breaks before slack-b.
await sync.syncSlackKnowledgeSources();
execMock.mockClear();
// Second run: slack-a is backed off (not due) but slack-b never ran, so
// it's still due. slack-a must not be retried; slack-b may be.
await sync.syncSlackKnowledgeSources();
expect(execMock.mock.calls.every(call => !call[0].includes('C-AAA'))).toBe(true);
});
});
describe('effectiveIntervalMs', () => {
it('multiplies the base interval by the backoff and caps at 30 minutes', () => {
expect(sync.effectiveIntervalMs(sourceA, undefined)).toBe(5 * 60 * 1000);
expect(sync.effectiveIntervalMs(sourceA, { backoffMultiplier: 2 })).toBe(10 * 60 * 1000);
expect(sync.effectiveIntervalMs(sourceA, { backoffMultiplier: 4 })).toBe(20 * 60 * 1000);
expect(sync.effectiveIntervalMs(sourceA, { backoffMultiplier: 8 })).toBe(30 * 60 * 1000);
expect(sync.effectiveIntervalMs(sourceA, { backoffMultiplier: 1024 })).toBe(30 * 60 * 1000);
});
});
describe('getSlackKnowledgeSyncStatus', () => {
it('reports per-source status with nextDueAt from interval + backoff', async () => {
execMock.mockImplementation(async (args: string[]) =>
args.includes('C-AAA') ? badChannel : okEmpty);
await sync.syncSlackKnowledgeSources();
const statuses = sync.getSlackKnowledgeSyncStatus();
const a = statuses.find(s => s.id === 'slack-a');
const b = statuses.find(s => s.id === 'slack-b');
expect(a).toMatchObject({ enabled: true, lastStatus: 'error', lastError: { kind: 'bad_channel' } });
expect(b).toMatchObject({ enabled: true, lastStatus: 'ok' });
// nextDueAt ≈ lastSyncAt + 5 min
expect(Date.parse(b!.nextDueAt!)).toBeCloseTo(Date.parse(b!.lastSyncAt!) + 5 * 60 * 1000, -3);
});
});

View file

@ -0,0 +1,479 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../../config/config.js';
import { AgentSlackRunError, runAgentSlack as execAgentSlack } from '../../slack/agent-slack-exec.js';
import type { AgentSlackErrorKind } from '../../slack/agent-slack-exec.js';
import { serviceLogger } from '../../services/service_logger.js';
import { limitEventItems } from '../limit_event_items.js';
import { createEvent } from '../../events/producer.js';
import { knowledgeSourcesRepo } from './repo.js';
import type { KnowledgeArtifact, KnowledgeSourceConfig, KnowledgeSourceScope } from './types.js';
const DEFAULT_LIMIT = 100;
const DEFAULT_SYNC_INTERVAL_MS = 5 * 60 * 1000;
const DEFAULT_RECENT_BACKFILL_SECONDS = 6 * 60 * 60;
const STATE_FILE = path.join(WorkDir, 'slack_knowledge_sync_state.json');
const ARTIFACT_ROOT = path.join(WorkDir, 'knowledge_sources', 'slack');
export type SlackSourceSyncState = {
/** Time of the last sync attempt (success or failure). */
lastSyncAt?: string;
lastStatus?: 'ok' | 'error';
lastError?: { kind: AgentSlackErrorKind | 'unknown'; message: string };
/** Rate-limit backoff: multiplies the source interval; reset on success. */
backoffMultiplier?: number;
};
type SlackSyncState = {
lastSyncAt?: string;
sources?: Record<string, SlackSourceSyncState>;
channels: Record<string, { lastSeenTs?: string }>;
};
type SlackMessage = {
ts?: string;
thread_ts?: string;
user?: string;
username?: string;
text?: string;
body?: string;
content?: string;
channel?: string;
channel_id?: string;
channel_name?: string;
permalink?: string;
url?: string;
edited?: { ts?: string; user?: string };
reply_count?: number;
replies?: SlackMessage[];
};
function loadState(): SlackSyncState {
try {
if (fs.existsSync(STATE_FILE)) {
const parsed = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as Partial<SlackSyncState>;
return { channels: {}, ...parsed };
}
} catch (error) {
console.error('[SlackKnowledge] Failed to load state:', error);
}
return { channels: {} };
}
function saveState(state: SlackSyncState): void {
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf-8');
}
const MAX_SOURCE_SYNC_INTERVAL_MS = 30 * 60 * 1000;
/** Source interval with rate-limit backoff applied, capped at 30 minutes. */
export function effectiveIntervalMs(source: KnowledgeSourceConfig, sourceState?: SlackSourceSyncState): number {
const base = source.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS;
const multiplier = Math.max(1, sourceState?.backoffMultiplier ?? 1);
return Math.min(base * multiplier, MAX_SOURCE_SYNC_INTERVAL_MS);
}
function isSourceDue(source: KnowledgeSourceConfig, state: SlackSyncState): boolean {
const sourceState = state.sources?.[source.id];
if (!sourceState?.lastSyncAt) return true;
const lastSyncMs = Date.parse(sourceState.lastSyncAt);
return !Number.isFinite(lastSyncMs) || Date.now() - lastSyncMs >= effectiveIntervalMs(source, sourceState);
}
function safeSegment(value: string): string {
return value
.replace(/^https?:\/\//, '')
.replace(/[\\/*?:"<>|#\s]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 120) || 'unknown';
}
function slackTsToDate(ts: string): string {
const seconds = Number(ts.split('.')[0]);
if (!Number.isFinite(seconds)) {
return new Date().toISOString();
}
return new Date(seconds * 1000).toISOString();
}
function subtractSlackTs(ts: string | undefined, seconds: number): string | undefined {
if (!ts) return undefined;
const value = Number(ts);
if (!Number.isFinite(value)) return undefined;
return Math.max(0, value - seconds).toFixed(6);
}
function compareSlackTs(a: string | undefined, b: string | undefined): number {
const an = Number(a);
const bn = Number(b);
if (!Number.isFinite(an) && !Number.isFinite(bn)) return 0;
if (!Number.isFinite(an)) return -1;
if (!Number.isFinite(bn)) return 1;
return an - bn;
}
function extractMessages(raw: unknown): SlackMessage[] {
if (Array.isArray(raw)) return raw as SlackMessage[];
if (raw && typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
const candidates = [obj.messages, obj.items, obj.results, obj.data];
for (const candidate of candidates) {
if (Array.isArray(candidate)) return candidate as SlackMessage[];
}
}
return [];
}
function getMessageText(message: SlackMessage): string {
return message.text ?? message.body ?? message.content ?? '';
}
function getMessageAuthor(message: SlackMessage): string {
return message.username ?? message.user ?? 'unknown';
}
async function runAgentSlack(args: string[]): Promise<unknown> {
const result = await execAgentSlack(args, { timeoutMs: 30_000, maxBuffer: 2 * 1024 * 1024 });
if (!result.ok) {
throw new AgentSlackRunError(result.kind, result.message);
}
return result.data ?? [];
}
async function listMessages(source: KnowledgeSourceConfig, scope: KnowledgeSourceScope, oldest?: string): Promise<SlackMessage[]> {
const target = scope.id;
const args = [
'message',
'list',
target,
'--limit',
String(source.filters?.limit ?? DEFAULT_LIMIT),
'--max-body-chars',
String(source.filters?.maxBodyChars ?? 4000),
];
if (scope.workspaceUrl) {
args.push('--workspace', scope.workspaceUrl);
}
if (oldest) {
args.push('--oldest', oldest);
}
const raw = await runAgentSlack(args);
return extractMessages(raw)
.filter(message => message.ts && getMessageText(message).trim().length > 0)
.sort((a, b) => compareSlackTs(a.ts, b.ts));
}
function artifactForMessage(source: KnowledgeSourceConfig, scope: KnowledgeSourceScope, message: SlackMessage): KnowledgeArtifact | null {
if (!message.ts) return null;
const channelName = scope.name ?? message.channel_name ?? message.channel ?? message.channel_id ?? scope.id;
const workspaceName = scope.workspaceUrl ?? 'Slack';
const version = message.edited?.ts ?? message.ts;
const url = message.permalink ?? message.url;
const title = `Slack message in ${channelName}`;
const occurredAt = slackTsToDate(message.ts);
const author = getMessageAuthor(message);
const body = getMessageText(message).trim();
const bodyMarkdown = [
`# ${title}`,
``,
`**Workspace:** ${workspaceName}`,
`**Channel:** ${channelName}`,
`**Author:** ${author}`,
`**Timestamp:** ${occurredAt}`,
message.thread_ts ? `**Thread TS:** ${message.thread_ts}` : '',
url ? `**Link:** ${url}` : '',
``,
`## Message`,
``,
body,
].filter(line => line !== '').join('\n');
return {
sourceId: source.id,
provider: 'slack',
externalId: `${scope.workspaceUrl ?? 'workspace'}:${scope.id}:${message.ts}`,
version,
occurredAt,
title,
bodyMarkdown,
url,
metadata: {
workspaceUrl: scope.workspaceUrl,
channelId: scope.id,
channelName,
author,
ts: message.ts,
threadTs: message.thread_ts,
editedTs: message.edited?.ts,
},
};
}
function writeArtifact(source: KnowledgeSourceConfig, scope: KnowledgeSourceScope, artifact: KnowledgeArtifact): string | null {
const workspace = safeSegment(scope.workspaceUrl ?? 'workspace');
const channel = safeSegment(scope.name ?? scope.id);
const ts = safeSegment(artifact.metadata.ts as string);
const dir = path.join(WorkDir, source.artifactDir || path.join('knowledge_sources', 'slack'), workspace, channel);
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, `${ts}.md`);
const frontmatter = [
'---',
`source: ${artifact.provider}`,
`source_id: ${artifact.sourceId}`,
`external_id: ${JSON.stringify(artifact.externalId)}`,
`version: ${JSON.stringify(artifact.version)}`,
`occurred_at: ${JSON.stringify(artifact.occurredAt)}`,
artifact.url ? `url: ${JSON.stringify(artifact.url)}` : '',
'---',
'',
].filter(Boolean).join('\n');
const content = `${frontmatter}${artifact.bodyMarkdown}\n`;
if (fs.existsSync(filePath)) {
try {
if (fs.readFileSync(filePath, 'utf-8') === content) {
return null;
}
} catch {
// Fall through and rewrite the artifact.
}
}
fs.writeFileSync(filePath, content, 'utf-8');
return filePath;
}
async function publishSlackSyncEvent(files: string[]): Promise<void> {
if (files.length === 0) return;
const relativeFiles = files.map(file => path.relative(WorkDir, file));
await createEvent({
source: 'slack',
type: 'slack.synced',
createdAt: new Date().toISOString(),
payload: [
'# Slack knowledge sync update',
'',
`${files.length} new/updated message artifact${files.length === 1 ? '' : 's'}.`,
'',
...relativeFiles.slice(0, 20).map(file => `- ${file}`),
].join('\n'),
});
}
/**
* Sync one source's channels into artifact files. Mutates state.channels as
* it goes; throws AgentSlackRunError on CLI failure (status bookkeeping is
* the caller's job).
*/
async function syncSource(source: KnowledgeSourceConfig, state: SlackSyncState): Promise<string[]> {
if (source.scopes.length === 0) {
console.log(`[SlackKnowledge] Source ${source.id} has no channel scopes; skipping`);
return [];
}
const writtenFiles: string[] = [];
for (const scope of source.scopes.filter(scope => scope.type === 'channel')) {
const key = `${source.id}:${scope.workspaceUrl ?? ''}:${scope.id}`;
const channelState = state.channels[key] ?? {};
const recentBackfillSeconds = Number(source.filters?.recentBackfillSeconds ?? DEFAULT_RECENT_BACKFILL_SECONDS);
const oldest = subtractSlackTs(channelState.lastSeenTs, recentBackfillSeconds);
const messages = await listMessages(source, scope, oldest);
let newestTs = channelState.lastSeenTs;
for (const message of messages) {
if (compareSlackTs(message.ts, channelState.lastSeenTs) <= 0 && !message.edited?.ts) {
continue;
}
const artifact = artifactForMessage(source, scope, message);
if (!artifact) continue;
const writtenFile = writeArtifact(source, scope, artifact);
if (writtenFile) {
writtenFiles.push(writtenFile);
}
if (compareSlackTs(message.ts, newestTs) > 0) {
newestTs = message.ts;
}
}
state.channels[key] = { lastSeenTs: newestTs };
}
return writtenFiles;
}
function recordSourceResult(state: SlackSyncState, sourceId: string, error?: { kind: AgentSlackErrorKind | 'unknown'; message: string }): void {
const previous = state.sources?.[sourceId];
const now = new Date().toISOString();
const next: SlackSourceSyncState = { lastSyncAt: now };
if (error) {
next.lastStatus = 'error';
next.lastError = error;
if (error.kind === 'rate_limited') {
// Doubles each consecutive rate limit; effectiveIntervalMs caps
// the resulting interval at 30 min, the clamp keeps the stored
// value sane in the state file.
next.backoffMultiplier = Math.min(Math.max(2, (previous?.backoffMultiplier ?? 1) * 2), 1024);
}
} else {
next.lastStatus = 'ok';
}
state.lastSyncAt = now;
state.sources = { ...(state.sources ?? {}), [sourceId]: next };
}
export async function syncSlackKnowledgeSources(): Promise<string[]> {
const state = loadState();
const sources = knowledgeSourcesRepo
.listEnabledSources()
.filter(source => source.provider === 'slack' && source.syncMode === 'poll')
.filter(source => isSourceDue(source, state));
if (sources.length === 0) return [];
const run = await serviceLogger.startRun({
service: 'slack',
message: 'Syncing Slack knowledge sources',
trigger: 'timer',
});
const writtenFiles: string[] = [];
let hadError = false;
for (const source of sources) {
let rateLimited = false;
try {
const files = await syncSource(source, state);
writtenFiles.push(...files);
recordSourceResult(state, source.id);
} catch (error) {
// One failing source must not abort the others.
hadError = true;
const kind = error instanceof AgentSlackRunError ? error.kind : 'unknown';
const message = error instanceof Error ? error.message : String(error);
recordSourceResult(state, source.id, { kind, message });
rateLimited = kind === 'rate_limited';
console.error(`[SlackKnowledge] Sync failed for source ${source.id} (${kind}):`, message);
await serviceLogger.log({
type: 'error',
service: run.service,
runId: run.runId,
level: 'error',
message: `Slack knowledge sync error for source ${source.id} (${kind})`,
error: message,
});
}
// Persist after every source so progress and status survive a crash.
saveState(state);
// Rate limits are per-token, so the remaining sources would hit the
// same wall — end this run; they stay due for the next tick.
if (rateLimited) break;
}
if (writtenFiles.length > 0) {
try {
const relativeFiles = writtenFiles.map(file => path.relative(WorkDir, file));
const limitedFiles = limitEventItems(relativeFiles);
await serviceLogger.log({
type: 'changes_identified',
service: run.service,
runId: run.runId,
level: 'info',
message: `Slack updates: ${writtenFiles.length} message artifact${writtenFiles.length === 1 ? '' : 's'}`,
counts: { messages: writtenFiles.length },
items: limitedFiles.items,
truncated: limitedFiles.truncated,
});
await publishSlackSyncEvent(writtenFiles);
} catch (error) {
hadError = true;
console.error('[SlackKnowledge] Failed to publish sync results:', error);
}
}
await serviceLogger.log({
type: 'run_complete',
service: run.service,
runId: run.runId,
level: hadError ? 'error' : 'info',
message: `Slack sync complete: ${writtenFiles.length} artifact${writtenFiles.length === 1 ? '' : 's'}`,
durationMs: Date.now() - run.startedAt,
outcome: hadError ? 'error' : 'ok',
summary: { artifacts: writtenFiles.length },
});
return writtenFiles;
}
export function getSlackKnowledgeArtifactRoot(): string {
return ARTIFACT_ROOT;
}
export type SlackKnowledgeSourceStatus = {
id: string;
enabled: boolean;
lastSyncAt?: string;
lastStatus?: 'ok' | 'error';
lastError?: { kind: string; message: string };
/** When the source next becomes due, given interval + backoff. */
nextDueAt?: string;
};
/** Per-source sync status for the slack:knowledgeStatus IPC channel. */
export function getSlackKnowledgeSyncStatus(): SlackKnowledgeSourceStatus[] {
const state = loadState();
return knowledgeSourcesRepo
.getConfig()
.sources
.filter(source => source.provider === 'slack')
.map(source => {
const sourceState = state.sources?.[source.id];
const lastMs = sourceState?.lastSyncAt ? Date.parse(sourceState.lastSyncAt) : NaN;
return {
id: source.id,
enabled: source.enabled,
lastSyncAt: sourceState?.lastSyncAt,
lastStatus: sourceState?.lastStatus,
lastError: sourceState?.lastError,
nextDueAt: Number.isFinite(lastMs)
? new Date(lastMs + effectiveIntervalMs(source, sourceState)).toISOString()
: undefined,
};
});
}
let wakeResolve: (() => void) | null = null;
export function triggerSync(): void {
if (wakeResolve) {
wakeResolve();
wakeResolve = null;
}
}
function interruptibleSleep(ms: number): Promise<void> {
return new Promise(resolve => {
const timeout = setTimeout(() => {
wakeResolve = null;
resolve();
}, ms);
wakeResolve = () => {
clearTimeout(timeout);
resolve();
};
});
}
export async function init(): Promise<void> {
console.log(`[SlackKnowledge] Starting Slack knowledge sync. Polling every ${DEFAULT_SYNC_INTERVAL_MS / 1000}s`);
while (true) {
await syncSlackKnowledgeSources();
await interruptibleSleep(DEFAULT_SYNC_INTERVAL_MS);
}
}

View file

@ -0,0 +1,49 @@
import { z } from 'zod';
export const KnowledgeSourceProvider = z.enum([
'gmail',
'meeting',
'voice_memo',
'slack',
'github',
'linear',
]);
export type KnowledgeSourceProvider = z.infer<typeof KnowledgeSourceProvider>;
export const KnowledgeSourceScope = z.object({
type: z.string(),
id: z.string(),
name: z.string().optional(),
workspaceUrl: z.string().optional(),
});
export type KnowledgeSourceScope = z.infer<typeof KnowledgeSourceScope>;
export const KnowledgeSourceConfig = z.object({
id: z.string(),
provider: KnowledgeSourceProvider,
enabled: z.boolean(),
artifactDir: z.string(),
syncMode: z.enum(['file', 'poll', 'event', 'manual']).default('file'),
intervalMs: z.number().int().positive().optional(),
scopes: z.array(KnowledgeSourceScope).default([]),
instructions: z.string().optional(),
filters: z.record(z.string(), z.unknown()).optional(),
});
export type KnowledgeSourceConfig = z.infer<typeof KnowledgeSourceConfig>;
export const KnowledgeSourcesFile = z.object({
sources: z.array(KnowledgeSourceConfig),
});
export type KnowledgeSourcesFile = z.infer<typeof KnowledgeSourcesFile>;
export interface KnowledgeArtifact {
sourceId: string;
provider: KnowledgeSourceProvider;
externalId: string;
version: string;
occurredAt: string;
title: string;
bodyMarkdown: string;
url?: string;
metadata: Record<string, unknown>;
}

View file

@ -4,6 +4,7 @@ import { WorkDir } from '../config/config.js';
import { FirefliesClientFactory } from './fireflies-client-factory.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import { publishMeetingNotesReadyEvent } from './meeting-events.js';
// Configuration
const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'fireflies');
@ -583,6 +584,15 @@ async function syncMeetings() {
fs.writeFileSync(filePath, markdown);
console.log(`[Fireflies] Saved: ${filename}`);
// First-time write for this meeting (guarded by syncedIds above) —
// signal that fresh notes are available for downstream agents.
await publishMeetingNotesReadyEvent({
source: 'fireflies',
title: meetingData.title || 'untitled',
filePath,
...(meetingData.dateString ? { when: meetingData.dateString } : {}),
});
syncedIds.add(meetingId);
newCount++;
processedInBatch++;

View file

@ -9,6 +9,7 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
import { limitEventItems } from './limit_event_items.js';
import { createEvent } from '../events/producer.js';
import { classifyThread, getUserEmail } from './classify_thread.js';
import { notifyIfEnabled } from '../application/notification/notifier.js';
// Configuration
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
@ -220,6 +221,26 @@ function summarizeGmailSync(threads: SyncedThread[]): string {
return lines.join('\n');
}
/**
* Fire one OS notification per genuinely-new email thread. Only ever called
* from the partial-sync (incremental) path, so the first-time connect which
* goes through fullSync never notifies. Suppressed while the app is focused.
*/
function notifyNewEmails(threads: SyncedThread[]): void {
for (const { threadId } of threads) {
const snapshot = readCachedSnapshot(threadId)?.snapshot;
const subject = snapshot?.subject?.trim() || '(no subject)';
const from = snapshot?.from?.trim();
void notifyIfEnabled('new_email', {
title: from ? `New email from ${from}` : 'New email',
message: subject,
link: 'rowboat://open?type=chat',
actionLabel: 'Open',
onlyWhenBackground: true,
});
}
}
async function publishGmailSyncEvent(threads: SyncedThread[]): Promise<void> {
if (threads.length === 0) return;
try {
@ -1260,6 +1281,9 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
const result = await processThread(auth, tid, syncDir, attachmentsDir);
if (result) synced.push(result);
}
// Notify for the history-derived new threads only — before the older
// backfilled threads are merged in below, so backfill stays silent.
notifyNewEmails(synced);
const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
synced.push(...backfilled);
@ -1382,6 +1406,8 @@ export interface SendReplyOptions {
bodyText: string;
inReplyTo?: string;
references?: string;
/** Files to attach. contentBase64 is the raw (unwrapped) base64 of the file bytes. */
attachments?: Array<{ filename: string; mimeType: string; contentBase64: string }>;
}
export interface SendReplyResult {
@ -1403,6 +1429,44 @@ export async function getAccountEmail(): Promise<string | null> {
return getUserEmail(auth);
}
let cachedAccountName: string | null | undefined;
/**
* The connected account's display name, parsed from the `From` header of a
* recent SENT message (which is the user themselves). Cached for the process
* lifetime. Uses only the existing gmail.modify scope no profile/userinfo
* scope, so it never triggers a re-consent. Used by the composer to sign off
* AI-generated emails with the real name.
*/
export async function getAccountName(): Promise<string | null> {
if (cachedAccountName !== undefined) return cachedAccountName;
try {
const auth = await GoogleClientFactory.getClient();
if (!auth) return null;
const gmailClient = google.gmail({ version: 'v1', auth });
const list = await gmailClient.users.messages.list({ userId: 'me', labelIds: ['SENT'], maxResults: 1 });
const id = list.data.messages?.[0]?.id;
if (!id) {
cachedAccountName = null;
return null;
}
const msg = await gmailClient.users.messages.get({
userId: 'me',
id,
format: 'metadata',
metadataHeaders: ['From'],
});
const from = msg.data.payload?.headers?.find((h) => h.name?.toLowerCase() === 'from')?.value || '';
// Pull the display name out of `"Name" <email>` / `Name <email>`.
const name = from.match(/^\s*"?([^"<]+?)"?\s*</)?.[1]?.trim() || null;
cachedAccountName = name;
return name;
} catch (err) {
console.warn('[Gmail] getAccountName failed:', err);
return null;
}
}
export async function getConnectionStatus(): Promise<GmailConnectionStatus> {
const status = await GoogleClientFactory.getCredentialStatus(REQUIRED_SCOPE);
let email: string | null = null;
@ -1443,6 +1507,17 @@ function encodeMimeBase64(text: string): string {
?.join('\r\n') ?? '';
}
// Re-wrap an already-base64 string into 76-char lines (RFC 2045) and strip any
// whitespace the renderer may have included.
function wrapBase64(base64: string): string {
return base64.replace(/\s+/g, '').match(/.{1,76}/g)?.join('\r\n') ?? '';
}
// Quote a filename for a MIME header, dropping characters that would break it.
function sanitizeAttachmentName(name: string): string {
return (name || 'attachment').replace(/[\r\n"\\]/g, '_').trim() || 'attachment';
}
export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReplyResult> {
try {
const auth = await GoogleClientFactory.getClient();
@ -1462,7 +1537,10 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
: { bodyHtml: opts.bodyHtml.trim(), bodyText: opts.bodyText.trim() };
if (!replyBody.bodyText.trim()) return { error: 'Draft is empty.' };
const boundary = `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const seed = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const altBoundary = `alt_${seed}`;
const attachments = (opts.attachments ?? []).filter((a) => a.contentBase64);
const headers: string[] = [];
headers.push(`From: ${requireSafeHeaderValue('From', userEmail)}`);
headers.push(`To: ${safeTo}`);
@ -1472,24 +1550,52 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
if (safeInReplyTo) headers.push(`In-Reply-To: ${safeInReplyTo}`);
if (safeReferences) headers.push(`References: ${safeReferences}`);
headers.push('MIME-Version: 1.0');
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
const parts: string[] = [];
parts.push(`--${boundary}`);
parts.push('Content-Type: text/plain; charset="UTF-8"');
parts.push('Content-Transfer-Encoding: base64');
parts.push('');
parts.push(encodeMimeBase64(replyBody.bodyText));
parts.push('');
parts.push(`--${boundary}`);
parts.push('Content-Type: text/html; charset="UTF-8"');
parts.push('Content-Transfer-Encoding: base64');
parts.push('');
parts.push(encodeMimeBase64(replyBody.bodyHtml));
parts.push('');
parts.push(`--${boundary}--`);
// The text+html body as a self-contained multipart/alternative block.
const altParts: string[] = [];
altParts.push(`--${altBoundary}`);
altParts.push('Content-Type: text/plain; charset="UTF-8"');
altParts.push('Content-Transfer-Encoding: base64');
altParts.push('');
altParts.push(encodeMimeBase64(replyBody.bodyText));
altParts.push('');
altParts.push(`--${altBoundary}`);
altParts.push('Content-Type: text/html; charset="UTF-8"');
altParts.push('Content-Transfer-Encoding: base64');
altParts.push('');
altParts.push(encodeMimeBase64(replyBody.bodyHtml));
altParts.push('');
altParts.push(`--${altBoundary}--`);
const message = `${headers.join('\r\n')}\r\n\r\n${parts.join('\r\n')}`;
let body: string;
if (attachments.length) {
// Wrap the alternative body plus each attachment in a multipart/mixed.
const mixedBoundary = `mixed_${seed}`;
headers.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
const mixed: string[] = [];
mixed.push(`--${mixedBoundary}`);
mixed.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
mixed.push('');
mixed.push(altParts.join('\r\n'));
for (const att of attachments) {
const name = sanitizeAttachmentName(att.filename);
const mime = sanitizeAttachmentName(att.mimeType) || 'application/octet-stream';
mixed.push(`--${mixedBoundary}`);
mixed.push(`Content-Type: ${mime}; name="${name}"`);
mixed.push('Content-Transfer-Encoding: base64');
mixed.push(`Content-Disposition: attachment; filename="${name}"`);
mixed.push('');
mixed.push(wrapBase64(att.contentBase64));
mixed.push('');
}
mixed.push(`--${mixedBoundary}--`);
body = mixed.join('\r\n');
} else {
headers.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
body = altParts.join('\r\n');
}
const message = `${headers.join('\r\n')}\r\n\r\n${body}`;
const raw = Buffer.from(message, 'utf8')
.toString('base64')
.replace(/\+/g, '-')

View file

@ -4,10 +4,11 @@ import { IModelConfigRepo } from "./repo.js";
import { isSignedIn } from "../account/account.js";
import container from "../di/container.js";
const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
const SIGNED_IN_DEFAULT_MODEL = "anthropic/claude-opus-4.7";
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite";
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite";
const SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite";
/**
* The single source of truth for "what model+provider should we use when
@ -76,6 +77,17 @@ export async function getLiveNoteAgentModel(): Promise<string> {
return cfg.liveNoteAgentModel ?? cfg.model;
}
/**
* Model used by the auto-permission classifier.
* Signed-in: curated default. BYOK: user override
* (`autoPermissionDecisionModel`) or assistant model.
*/
export async function getAutoPermissionDecisionModel(): Promise<string> {
if (await isSignedIn()) return SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL;
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
return cfg.autoPermissionDecisionModel ?? cfg.model;
}
/**
* Model used by the meeting-notes summarizer. No special signed-in default
* historically meetings used the assistant model. BYOK: user override

View file

@ -9,6 +9,8 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js";
import z from "zod";
import { getGatewayProvider } from "./gateway.js";
import { getDefaultModelAndProvider, resolveProviderConfig } from "./defaults.js";
import { withUseCase } from "../analytics/use_case.js";
export const Provider = LlmProvider;
export const ModelConfig = LlmModelConfig;
@ -96,3 +98,112 @@ export async function testModelConnection(
clearTimeout(timeout);
}
}
export async function listModelsForProvider(
providerConfig: z.infer<typeof Provider>,
timeoutMs = 8000,
): Promise<string[]> {
const { flavor, apiKey, baseURL } = providerConfig;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
let url = "";
const headers: Record<string, string> = {};
switch (flavor) {
case "openai":
url = "https://api.openai.com/v1/models";
headers["Authorization"] = `Bearer ${apiKey}`;
break;
case "anthropic":
url = "https://api.anthropic.com/v1/models";
headers["x-api-key"] = apiKey ?? "";
headers["anthropic-version"] = "2023-06-01";
break;
case "google":
url = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey ?? ""}`;
break;
case "openrouter":
url = "https://openrouter.ai/api/v1/models";
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
break;
case "ollama":
url = `${(baseURL ?? "http://localhost:11434").replace(/\/$/, "")}/api/tags`;
break;
case "openai-compatible":
case "aigateway":
url = `${(baseURL ?? "").replace(/\/$/, "")}/models`;
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
break;
default:
throw new Error(`Unsupported provider flavor: ${flavor}`);
}
const res = await fetch(url, { headers, signal: controller.signal });
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Failed to list models (${res.status}): ${body.slice(0, 200)}`);
}
const data = await res.json();
// Normalize each provider's response shape into a flat list of model id strings.
let ids: string[] = [];
if (flavor === "google") {
// { models: [{ name: "models/gemini-..." }] }
ids = (data.models ?? []).map((m: { name: string }) => m.name.replace(/^models\//, ""));
} else if (flavor === "ollama") {
// { models: [{ name: "llama3:latest" }] }
ids = (data.models ?? []).map((m: { name: string }) => m.name);
} else {
// OpenAI-shaped: { data: [{ id: "..." }] }
ids = (data.data ?? []).map((m: { id: string }) => m.id);
}
return ids.filter((id: string) => typeof id === "string" && id.length > 0);
} finally {
clearTimeout(timeout);
}
}
export interface GenerateTextOptions {
prompt: string;
system?: string;
/** Model id. Falls back to the active default when omitted. */
model?: string;
/** Provider name (e.g. "rowboat", "openai"). Falls back to the active default. */
provider?: string;
}
export interface GenerateTextResult {
text?: string;
/** The model/provider actually used (after resolving defaults). */
model?: string;
provider?: string;
error?: string;
}
/**
* One-shot text generation for lightweight UI features (e.g. the email
* composer's "write with AI"). Resolves the requested model+provider, falling
* back to the active default, and returns the generated text. Never throws
* errors are returned in the result so the renderer can surface them.
*/
export async function generateOneShot(opts: GenerateTextOptions): Promise<GenerateTextResult> {
try {
const def = await getDefaultModelAndProvider();
const modelId = opts.model || def.model;
const providerName = opts.provider || def.provider;
const providerConfig = await resolveProviderConfig(providerName);
const languageModel = createProvider(providerConfig).languageModel(modelId);
const result = await withUseCase(
{ useCase: "copilot_chat", subUseCase: "email_compose" },
() => generateText({
model: languageModel,
...(opts.system ? { system: opts.system } : {}),
prompt: opts.prompt,
}),
);
return { text: result.text.trim(), model: modelId, provider: providerName };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}

View file

@ -53,6 +53,7 @@ export class FSModelConfigRepo implements IModelConfigRepo {
knowledgeGraphModel: config.knowledgeGraphModel,
meetingNotesModel: config.meetingNotesModel,
liveNoteAgentModel: config.liveNoteAgentModel,
autoPermissionDecisionModel: config.autoPermissionDecisionModel,
};
const toWrite = { ...config, providers: existingProviders };

View file

@ -166,6 +166,7 @@ Subject: Re: {original_subject}
**Drafting Guidelines:**
- Be concise and professional
- If you include a sign-off name, use only the user's first name, never their full name
- For scheduling: propose specific times based on calendar availability
- For inquiries: answer directly or indicate what info is needed
- Reference any relevant context from memory naturally

View file

@ -35,6 +35,7 @@ export type CreateRunRepoOptions = {
agentId: string;
model: string;
provider: string;
permissionMode: "manual" | "auto";
useCase: z.infer<typeof UseCase>;
subUseCase?: string;
};
@ -204,6 +205,7 @@ export class FSRunsRepo implements IRunsRepo {
agentName: options.agentId,
model: options.model,
provider: options.provider,
permissionMode: options.permissionMode,
useCase: options.useCase,
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
subflow: [],
@ -216,6 +218,7 @@ export class FSRunsRepo implements IRunsRepo {
agentId: options.agentId,
model: options.model,
provider: options.provider,
permissionMode: options.permissionMode,
useCase: options.useCase,
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
log: [start],
@ -251,6 +254,7 @@ export class FSRunsRepo implements IRunsRepo {
agentId: start.agentName,
model: start.model,
provider: start.provider,
permissionMode: start.permissionMode ?? "manual",
...(start.useCase ? { useCase: start.useCase } : {}),
...(start.subUseCase ? { subUseCase: start.subUseCase } : {}),
log: events,
@ -294,15 +298,19 @@ export class FSRunsRepo implements IRunsRepo {
for (const name of selected) {
const runId = name.slice(0, -'.jsonl'.length);
const metadata = await this.readRunMetadata(path.join(runsDir, name));
const filePath = path.join(runsDir, name);
const metadata = await this.readRunMetadata(filePath);
if (!metadata) {
continue;
}
const stat = await fsp.stat(filePath);
runs.push({
id: runId,
title: metadata.title,
createdAt: metadata.start.ts!,
modifiedAt: stat.mtime.toISOString(),
agentId: metadata.start.agentName,
...(metadata.start.useCase ? { useCase: metadata.start.useCase } : {}),
});
}
@ -320,4 +328,4 @@ export class FSRunsRepo implements IRunsRepo {
async delete(id: string): Promise<void> {
await fsp.unlink(runLogPath(id));
}
}
}

View file

@ -3,6 +3,7 @@ import container from "../di/container.js";
import { IMessageQueue, UserMessageContentType, VoiceOutputMode, MiddlePaneContext } from "../application/lib/message-queue.js";
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
import { IRunsRepo } from "./repo.js";
import { ICodeSessionsRepo } from "../code-mode/sessions/repo.js";
import { IAgentRuntime } from "../agents/runtime.js";
import { IBus } from "../application/lib/bus.js";
import { IAbortRegistry } from "./abort-registry.js";
@ -32,6 +33,7 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
agentId: opts.agentId,
model,
provider,
permissionMode: opts.permissionMode ?? "manual",
useCase,
...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}),
});
@ -39,9 +41,23 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
return run;
}
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex'): Promise<string> {
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex', codeCwd?: string, codePolicy?: 'ask' | 'auto-approve-reads' | 'yolo'): Promise<string> {
// Code-section sessions carry their coding context in the session meta.
// Pin it here — not in the composer — so EVERY path into the run (assistant
// chat pane, voice, palette) drives the session's agent in its directory,
// and the session header stays the single source of truth.
try {
const sessionMeta = await container.resolve<ICodeSessionsRepo>('codeSessionsRepo').get(runId);
if (sessionMeta) {
codeMode = sessionMeta.agent;
codeCwd = sessionMeta.cwd;
codePolicy = sessionMeta.policy;
}
} catch {
// sessions repo unavailable — treat as a regular chat run
}
const queue = container.resolve<IMessageQueue>('messageQueue');
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext, codeMode);
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext, codeMode, codeCwd, codePolicy);
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
runtime.trigger(runId);
return id;

View file

@ -0,0 +1,112 @@
import { generateObject, type ModelMessage } from "ai";
import z from "zod";
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import { ToolCallPart } from "@x/shared/dist/message.js";
import { captureLlmUsage } from "../analytics/usage.js";
import { withUseCase, type UseCase } from "../analytics/use_case.js";
import { getAutoPermissionDecisionModel, getDefaultModelAndProvider, resolveProviderConfig } from "../models/defaults.js";
import { createProvider } from "../models/models.js";
const DecisionSchema = z.object({
decisions: z.array(z.object({
toolCallId: z.string(),
decision: z.enum(["allow", "deny"]),
reason: z.string().min(1),
})),
});
export type AutoPermissionCandidate = {
toolCall: z.infer<typeof ToolCallPart>;
permission: z.infer<typeof ToolPermissionMetadata>;
};
export type AutoPermissionDecision = {
toolCallId: string;
decision: "allow" | "deny";
reason: string;
};
const SYSTEM_PROMPT = `You decide whether a personal productivity app may run tool calls without interrupting the user.
You only receive tool calls that already require permission under deterministic rules.
Allow a tool call only when it is clearly consistent with the user's request and low risk.
Deny tool calls that are destructive, credential-sensitive, privacy-sensitive, broad in scope, likely irreversible, or not clearly requested.
Command examples to deny unless explicitly requested: deleting data, force pushing, deploying, running migrations, changing permissions, reading secrets, exfiltrating tokens, or modifying files outside the user's workspace.
File examples to deny unless explicitly requested: deleting paths, writing outside the workspace, reading secrets or credentials, or broad access to private directories.
Return one decision for every toolCallId. Use the exact toolCallId values provided.`;
function compact(value: unknown, max = 8_000): string {
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
if (text.length <= max) return text;
return `${text.slice(0, max)}\n...<truncated>`;
}
function recentContext(messages: ModelMessage[]): unknown[] {
return messages.slice(-8).map((message) => {
if (typeof message.content === "string") {
return { role: message.role, content: compact(message.content, 2_000) };
}
return { role: message.role, content: compact(message.content, 3_000) };
});
}
function buildPrompt(input: {
agentName: string | null;
messages: ModelMessage[];
candidates: AutoPermissionCandidate[];
}) {
return compact({
agentName: input.agentName,
recentConversation: recentContext(input.messages),
toolCalls: input.candidates.map(({ toolCall, permission }) => ({
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
arguments: toolCall.arguments,
permission,
})),
}, 24_000);
}
export async function classifyToolPermissions(input: {
runId: string;
agentName: string | null;
messages: ModelMessage[];
candidates: AutoPermissionCandidate[];
useCase: UseCase;
subUseCase?: string | null;
}): Promise<AutoPermissionDecision[]> {
if (input.candidates.length === 0) return [];
const modelId = await getAutoPermissionDecisionModel();
const { provider: providerName } = await getDefaultModelAndProvider();
const providerConfig = await resolveProviderConfig(providerName);
const model = createProvider(providerConfig).languageModel(modelId);
const result = await withUseCase(
{
useCase: input.useCase,
subUseCase: "auto_permission_classifier",
...(input.agentName ? { agentName: input.agentName } : {}),
},
() => generateObject({
model,
system: SYSTEM_PROMPT,
prompt: buildPrompt(input),
schema: DecisionSchema,
}),
);
captureLlmUsage({
useCase: input.useCase,
subUseCase: "auto_permission_classifier",
model: modelId,
provider: providerName,
usage: result.usage,
});
const allowedIds = new Set(input.candidates.map((candidate) => candidate.toolCall.toolCallId));
return result.object.decisions.filter((decision) => allowedIds.has(decision.toolCallId));
}

View file

@ -0,0 +1,190 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { exec } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import { agentSlackShimEnv, classifyAgentSlackStderr, resolveAgentSlackCli, runAgentSlack } from './agent-slack-exec.js';
const execAsync = promisify(exec);
// Fixture CLI scripts spawned via process.execPath (real node under vitest),
// exercising the same spawn path the app uses.
let fixtureDir: string;
let jsonCli: string;
let garbageCli: string;
let sleepCli: string;
let failingCli: string;
let stdinCli: string;
function writeFixture(name: string, code: string): string {
const file = path.join(fixtureDir, name);
fs.writeFileSync(file, code, 'utf-8');
return file;
}
beforeAll(() => {
fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-slack-exec-test-'));
jsonCli = writeFixture('json.cjs', `process.stdout.write(JSON.stringify({ args: process.argv.slice(2) }));`);
stdinCli = writeFixture('stdin.cjs', `let s = ''; process.stdin.on('data', c => s += c); process.stdin.on('end', () => process.stdout.write(s.trim()));`);
garbageCli = writeFixture('garbage.cjs', `process.stdout.write('definitely: not json');`);
sleepCli = writeFixture('sleep.cjs', `setTimeout(() => {}, 60_000);`);
failingCli = writeFixture('fail.cjs', `process.stderr.write('boom'); process.exit(2);`);
});
afterAll(() => {
fs.rmSync(fixtureDir, { recursive: true, force: true });
});
const missing = path.join('/nonexistent', 'agent-slack.cjs');
describe('resolveAgentSlackCli', () => {
it('prefers the bundled bin over global and PATH', () => {
const resolved = resolveAgentSlackCli({
bundledCandidates: [jsonCli],
globalCandidates: [garbageCli],
pathProbe: () => garbageCli,
});
expect(resolved).toEqual({ entry: jsonCli, source: 'bundled' });
});
it('falls back to a global install when the bundled bin is missing', () => {
const resolved = resolveAgentSlackCli({
bundledCandidates: [missing],
globalCandidates: [jsonCli],
pathProbe: () => garbageCli,
});
expect(resolved).toEqual({ entry: jsonCli, source: 'global' });
});
it('falls back to PATH last', () => {
const resolved = resolveAgentSlackCli({
bundledCandidates: [missing],
globalCandidates: [missing],
pathProbe: () => jsonCli,
});
expect(resolved).toEqual({ entry: jsonCli, source: 'path' });
});
it('returns null when nothing is found', () => {
const resolved = resolveAgentSlackCli({
bundledCandidates: [missing],
globalCandidates: [missing],
pathProbe: () => null,
});
expect(resolved).toBeNull();
});
});
describe('runAgentSlack', () => {
const via = (entry: string) => ({
bundledCandidates: [entry],
globalCandidates: [],
pathProbe: () => null,
});
it('returns parsed JSON stdout and forwards args', async () => {
const result = await runAgentSlack(['auth', 'whoami'], { resolve: via(jsonCli) });
expect(result).toMatchObject({ ok: true, data: { args: ['auth', 'whoami'] } });
});
it('returns raw stdout when parseJson is false', async () => {
const result = await runAgentSlack([], { resolve: via(garbageCli), parseJson: false });
expect(result.ok).toBe(true);
if (result.ok) expect(result.stdout).toBe('definitely: not json');
});
it('writes opts.input to the child stdin (parse-curl path)', async () => {
const result = await runAgentSlack([], { resolve: via(stdinCli), parseJson: false, input: "curl 'https://team.slack.com'" });
expect(result.ok).toBe(true);
if (result.ok) expect(result.stdout).toBe("curl 'https://team.slack.com'");
});
it('reports not_installed when no binary resolves', async () => {
const result = await runAgentSlack(['--version'], {
resolve: { bundledCandidates: [missing], globalCandidates: [missing], pathProbe: () => null },
});
expect(result).toMatchObject({ ok: false, kind: 'not_installed' });
});
it('reports parse_error on malformed JSON stdout', async () => {
const result = await runAgentSlack([], { resolve: via(garbageCli) });
expect(result).toMatchObject({ ok: false, kind: 'parse_error' });
});
it('kills a hung CLI and reports timeout', async () => {
const result = await runAgentSlack([], { resolve: via(sleepCli), timeoutMs: 300 });
expect(result).toMatchObject({ ok: false, kind: 'timeout' });
}, 10_000);
it('classifies stderr on non-zero exit (unrecognized → unknown)', async () => {
const result = await runAgentSlack([], { resolve: via(failingCli) });
expect(result).toMatchObject({ ok: false, kind: 'unknown', stderr: 'boom', message: 'boom' });
});
});
describe('classifyAgentSlackStderr', () => {
// Fixture corpus: strings marked (captured) are real stderr induced on a
// machine with no Slack auth; the rest are taken verbatim from the
// agent-slack 0.9.3 / @slack/web-api 7.17 sources.
const cases: Array<[string, ReturnType<typeof classifyAgentSlackStderr>]> = [
// not_authed — empty credential store (auth auto-import cascade)
['Firefox extraction is not supported on win32.', 'not_authed'], // (captured)
['Slack Desktop data not found. Checked:\n - C:\\Users\\X\\AppData\\Roaming\\Slack\\Local Storage\\leveldb', 'not_authed'], // (captured)
// not_authed — Slack API codes, both client flavors
['invalid_auth', 'not_authed'],
['token_expired', 'not_authed'],
['An API error occurred: invalid_auth', 'not_authed'],
['account_inactive', 'not_authed'],
// rate_limited
['ratelimited', 'rate_limited'],
['A rate-limit has been reached, you may retry this request in 30 seconds', 'rate_limited'],
['Slack HTTP 429 calling conversations.history', 'rate_limited'],
// network
['A request error occurred: getaddrinfo ENOTFOUND slack.com', 'network'],
['fetch failed', 'network'],
['connect ECONNREFUSED 127.0.0.1:443', 'network'],
['Slack HTTP 503 calling conversations.list', 'network'],
// bad_channel
['channel_not_found', 'bad_channel'],
['An API error occurred: channel_not_found', 'bad_channel'],
['Could not resolve channel name: #nonexistent-channel', 'bad_channel'],
['not_in_channel', 'bad_channel'],
// unknown
['Ambiguous channel name across multiple workspaces. Pass --workspace "<url>"', 'unknown'],
['', 'unknown'],
];
it.each(cases)('%j → %s', (stderr, expected) => {
expect(classifyAgentSlackStderr(stderr)).toBe(expected);
});
it('does not misread substrings of longer identifiers', () => {
// "speedratelimitedness" style false positives guarded by boundaries
expect(classifyAgentSlackStderr('field xratelimitedx in payload')).toBe('unknown');
expect(classifyAgentSlackStderr('saved to channel_not_found_archive.txt')).toBe('unknown');
});
});
describe('agentSlackShimEnv', () => {
it('returns the base env unchanged when no CLI resolves', () => {
const base = { PATH: '/usr/bin' };
const env = agentSlackShimEnv(path.join(fixtureDir, 'bin'), base, {
bundledCandidates: [missing], globalCandidates: [missing], pathProbe: () => null,
});
expect(env).toBe(base);
});
it('makes `agent-slack` runnable by name through a shell', async () => {
const shimDir = path.join(fixtureDir, 'bin');
const env = agentSlackShimEnv(shimDir, process.env, {
bundledCandidates: [jsonCli], globalCandidates: [], pathProbe: () => null,
});
const pathKey = Object.keys(env).find(key => key.toUpperCase() === 'PATH') ?? 'PATH';
expect(env[pathKey]!.startsWith(`${shimDir}${path.delimiter}`)).toBe(true);
// Same spawn shape as executeCommand: command string through a shell.
const { stdout } = await execAsync('agent-slack hello world', { env });
expect(JSON.parse(stdout)).toEqual({ args: ['hello', 'world'] });
});
});

View file

@ -0,0 +1,315 @@
import { execFile, execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
/**
* Single shared executor for the agent-slack CLI.
*
* Every agent-slack invocation in the app must go through runAgentSlack()
* never execFile('agent-slack', ...) directly. Spawning the bare command
* requires it on PATH (we no longer auto-install it) and on Windows hits the
* .cmd-shim EINVAL bug. Instead we resolve a JS entry file and spawn it with
* process.execPath, which works without Node/npm on the user's machine.
*/
export type AgentSlackSource = 'bundled' | 'global' | 'path';
export interface ResolvedAgentSlack {
/** Absolute path to a JS entry file runnable via `node <entry>`. */
entry: string;
source: AgentSlackSource;
}
export type AgentSlackErrorKind =
// Structural failures (detected without running / from the spawn itself)
| 'not_installed' | 'timeout' | 'parse_error'
// CLI failures classified from stderr (exit code is always 1)
| 'not_authed' | 'rate_limited' | 'network' | 'bad_channel' | 'unknown';
// agent-slack prints `err.message` to stderr and exits 1 for every failure, so
// stderr text is the only classification signal. Patterns cover both Slack
// client flavors the CLI uses: the browser-token client throws bare Slack
// error codes ("invalid_auth"), @slack/web-api wraps them ("An API error
// occurred: invalid_auth") — plus the CLI's own messages. Word-ish boundaries
// match the CLI's own auth-detection regex.
const SLACK_CODE = (codes: string) => new RegExp(`(?:^|[^a-z_])(?:${codes})(?:$|[^a-z_])`, 'i');
const NOT_AUTHED_RE = SLACK_CODE('invalid_auth|token_expired|token_revoked|account_inactive|not_authed');
// Empty credential store surfaces as the auth auto-import cascade failing,
// e.g. "Slack Desktop data not found." / "Firefox extraction is not supported
// on win32." (real stderr captured on Windows with no Slack installed).
const AUTH_IMPORT_RE = /Slack Desktop data not found|extraction is not supported/i;
const RATE_LIMITED_RE = /(?:^|[^a-z_])ratelimited(?:$|[^a-z_])|A rate-?limit has been reached|Slack HTTP 429/i;
const NETWORK_RE = /A request error occurred|fetch failed|socket hang up|ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EPIPE|Slack HTTP 5\d\d/i;
const BAD_CHANNEL_RE = new RegExp(
`${SLACK_CODE('channel_not_found|not_in_channel|is_archived').source}|Could not resolve channel name`, 'i');
/** Classify an agent-slack failure from its stderr. Exported for tests. */
export function classifyAgentSlackStderr(stderr: string): Exclude<AgentSlackErrorKind, 'not_installed' | 'timeout' | 'parse_error'> {
if (RATE_LIMITED_RE.test(stderr)) return 'rate_limited';
if (BAD_CHANNEL_RE.test(stderr)) return 'bad_channel';
if (NOT_AUTHED_RE.test(stderr) || AUTH_IMPORT_RE.test(stderr)) return 'not_authed';
if (NETWORK_RE.test(stderr)) return 'network';
return 'unknown';
}
export type AgentSlackResult =
| { ok: true; stdout: string; data: unknown }
| { ok: false; kind: AgentSlackErrorKind; message: string; stderr: string };
/** Throwable wrapper for callers with throw-based control flow (sync loop). */
export class AgentSlackRunError extends Error {
constructor(public readonly kind: AgentSlackErrorKind, message: string) {
super(message);
this.name = 'AgentSlackRunError';
}
}
export interface ResolveOptions {
/** Re-probe even if a previous resolution succeeded. */
refresh?: boolean;
/** Test hooks — override the default probe locations. */
bundledCandidates?: string[];
globalCandidates?: string[];
pathProbe?: () => string | null;
}
export interface RunAgentSlackOptions {
timeoutMs?: number;
maxBuffer?: number;
/** Set false for commands with non-JSON output (e.g. --version). */
parseJson?: boolean;
/** Written to the child's stdin then closed (e.g. `auth parse-curl`). */
input?: string;
/** Test hook — bypass the default resolver. */
resolve?: ResolveOptions;
}
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_BUFFER = 2 * 1024 * 1024;
// The CLI is bundled by apps/main/bundle.mjs to agent-slack.cjs next to
// main.cjs. At runtime import.meta.url is rewritten by esbuild to point at
// main.cjs, so the sibling lookup works in dev and packaged builds alike.
// (Under vitest/tsc output the sibling doesn't exist and we fall through.)
function defaultBundledCandidates(): string[] {
return [path.join(path.dirname(fileURLToPath(import.meta.url)), 'agent-slack.cjs')];
}
const GLOBAL_BIN_REL = path.join('node_modules', 'agent-slack', 'bin', 'agent-slack.js');
function defaultGlobalCandidates(): string[] {
if (process.platform === 'win32') {
const appData = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming');
return [path.join(appData, 'npm', GLOBAL_BIN_REL)];
}
return [
path.join('/usr/local/lib', GLOBAL_BIN_REL),
path.join('/opt/homebrew/lib', GLOBAL_BIN_REL),
];
}
/** Map a PATH hit (symlink, npm .cmd/.ps1/sh shim) to the underlying JS bin. */
function jsEntryFromPathHit(hit: string): string | null {
try {
const real = fs.realpathSync(hit);
if (/\.(c|m)?js$/.test(real)) return real;
// npm shims live next to the global node_modules tree.
const sibling = path.join(path.dirname(real), GLOBAL_BIN_REL);
if (fs.existsSync(sibling)) return sibling;
} catch {
// Broken symlink or unreadable shim — treat as no hit.
}
return null;
}
function defaultPathProbe(): string | null {
const lookup = process.platform === 'win32' ? 'where.exe' : 'which';
let output: string;
try {
output = execFileSync(lookup, ['agent-slack'], {
timeout: 5_000,
encoding: 'utf-8',
windowsHide: true,
});
} catch {
return null;
}
for (const line of output.split(/\r?\n/)) {
const hit = line.trim();
if (!hit) continue;
const entry = jsEntryFromPathHit(hit);
if (entry) return entry;
}
return null;
}
let cachedResolution: ResolvedAgentSlack | null = null;
export function resolveAgentSlackCli(opts: ResolveOptions = {}): ResolvedAgentSlack | null {
if (cachedResolution && !opts.refresh
&& !opts.bundledCandidates && !opts.globalCandidates && !opts.pathProbe) {
return cachedResolution;
}
let resolved: ResolvedAgentSlack | null = null;
for (const candidate of opts.bundledCandidates ?? defaultBundledCandidates()) {
if (fs.existsSync(candidate)) {
resolved = { entry: candidate, source: 'bundled' };
break;
}
}
if (!resolved) {
for (const candidate of opts.globalCandidates ?? defaultGlobalCandidates()) {
if (fs.existsSync(candidate)) {
resolved = { entry: candidate, source: 'global' };
break;
}
}
}
if (!resolved) {
const entry = (opts.pathProbe ?? defaultPathProbe)();
if (entry) resolved = { entry, source: 'path' };
}
// Only cache the default probe — test overrides must not leak, and a
// failed probe should retry next call (the user may install meanwhile).
if (resolved && !opts.bundledCandidates && !opts.globalCandidates && !opts.pathProbe) {
cachedResolution = resolved;
}
return resolved;
}
export async function runAgentSlack(args: string[], opts: RunAgentSlackOptions = {}): Promise<AgentSlackResult> {
const resolved = resolveAgentSlackCli(opts.resolve ?? {});
if (!resolved) {
return {
ok: false,
kind: 'not_installed',
message: 'agent-slack CLI not found (bundled copy missing and no global install)',
stderr: '',
};
}
const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
let stdout: string;
try {
// process.execPath inside Electron's main process is the Electron
// binary, not node — ELECTRON_RUN_AS_NODE makes it behave as plain
// node (and is ignored when we already run under real node).
const promise = execFileAsync(process.execPath, [resolved.entry, ...args], {
timeout,
maxBuffer: opts.maxBuffer ?? DEFAULT_MAX_BUFFER,
encoding: 'utf-8',
windowsHide: true,
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
});
// promisify(execFile) exposes the ChildProcess as `.child`, letting us
// feed stdin for commands that read it (e.g. `auth parse-curl`). Close
// stdin so those commands stop waiting for more input.
if (opts.input != null) {
promise.child.stdin?.end(opts.input);
}
const result = await promise;
stdout = result.stdout;
} catch (error) {
const err = error as NodeJS.ErrnoException & { killed?: boolean; signal?: string; stderr?: string };
const stderr = typeof err.stderr === 'string' ? err.stderr : '';
if (err.code === 'ENOENT') {
return { ok: false, kind: 'not_installed', message: `agent-slack entry vanished: ${resolved.entry}`, stderr };
}
if (err.killed || err.signal === 'SIGTERM') {
return { ok: false, kind: 'timeout', message: `agent-slack timed out after ${timeout}ms`, stderr };
}
return { ok: false, kind: classifyAgentSlackStderr(stderr), message: stderr.trim() || err.message || 'agent-slack failed', stderr };
}
if (opts.parseJson === false) {
return { ok: true, stdout, data: undefined };
}
const trimmed = stdout.trim();
try {
return { ok: true, stdout, data: trimmed ? JSON.parse(trimmed) : undefined };
} catch {
return {
ok: false,
kind: 'parse_error',
message: `agent-slack returned non-JSON output: ${trimmed.slice(0, 200)}`,
stderr: '',
};
}
}
export type AgentSlackCliStatus =
| { available: true; version: string; source: AgentSlackSource }
| { available: false };
/** Availability probe backing the slack:cliStatus IPC channel. */
export async function getAgentSlackCliStatus(): Promise<AgentSlackCliStatus> {
const resolved = resolveAgentSlackCli({ refresh: true });
if (!resolved) return { available: false };
const result = await runAgentSlack(['--version'], { timeoutMs: 10_000, parseJson: false });
if (!result.ok) return { available: false };
return { available: true, version: result.stdout.trim(), source: resolved.source };
}
// --- PATH shim for shell consumers (Copilot skill via executeCommand) -------
//
// The Copilot Slack skill runs literal `agent-slack ...` shell commands. Those
// used to rely on the startup `npm install -g` that this module replaced, so
// without help they'd only work on machines with a manual global install.
// We generate a tiny launcher script that forwards to the resolved CLI entry
// and prepend its directory to PATH for executeCommand children.
let shimmedFor: string | null = null;
function ensureAgentSlackShim(shimDir: string, entry: string): void {
const cacheKey = `${process.execPath}${entry}${shimDir}`;
if (shimmedFor === cacheKey) return;
fs.mkdirSync(shimDir, { recursive: true });
if (process.platform === 'win32') {
const cmd = `@echo off\r\nset ELECTRON_RUN_AS_NODE=1\r\n"${process.execPath}" "${entry}" %*\r\n`;
const cmdPath = path.join(shimDir, 'agent-slack.cmd');
if (!fs.existsSync(cmdPath) || fs.readFileSync(cmdPath, 'utf-8') !== cmd) {
fs.writeFileSync(cmdPath, cmd, 'utf-8');
}
} else {
const sh = `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec "${process.execPath}" "${entry}" "$@"\n`;
const shPath = path.join(shimDir, 'agent-slack');
if (!fs.existsSync(shPath) || fs.readFileSync(shPath, 'utf-8') !== sh) {
fs.writeFileSync(shPath, sh, { encoding: 'utf-8', mode: 0o755 });
}
fs.chmodSync(shPath, 0o755);
}
shimmedFor = cacheKey;
}
/**
* Environment for shell commands that may invoke `agent-slack` by name.
* Prepends a shim directory to PATH so the resolved CLI (bundled first) wins
* over or substitutes for a global npm install. Returns the base env
* unchanged when no CLI can be resolved.
*/
export function agentSlackShimEnv(
shimDir: string,
base: NodeJS.ProcessEnv = process.env,
resolve?: ResolveOptions,
): NodeJS.ProcessEnv {
const resolved = resolveAgentSlackCli(resolve ?? {});
if (!resolved) return base;
try {
ensureAgentSlackShim(shimDir, resolved.entry);
} catch (error) {
console.warn('[Slack] Failed to write agent-slack PATH shim:', error);
return base;
}
// Windows env vars are case-insensitive; reuse the existing key ('Path')
// rather than introducing a duplicate 'PATH'.
const pathKey = Object.keys(base).find(key => key.toUpperCase() === 'PATH') ?? 'PATH';
return { ...base, [pathKey]: `${shimDir}${path.delimiter}${base[pathKey] ?? ''}` };
}

View file

@ -1,5 +1,6 @@
import chokidar, { type FSWatcher } from 'chokidar';
import fs from 'node:fs/promises';
import path from 'node:path';
import { ensureWorkspaceRoot, absToRelPosix } from './workspace.js';
import { WorkDir } from '../config/config.js';
import { WorkspaceChangeEvent } from 'packages/shared/dist/workspace.js';
@ -21,8 +22,15 @@ export async function createWorkspaceWatcher(
): Promise<FSWatcher> {
await ensureWorkspaceRoot();
// Code-section session worktrees are full repo checkouts (thousands of files,
// possibly node_modules) living under WorkDir — watching them would flood the
// event stream and burn file handles, and nothing in the app renders them
// from workspace events.
const codeModeDir = path.join(WorkDir, 'code-mode');
const watcher = chokidar.watch(WorkDir, {
ignoreInitial: true,
ignored: (watchedPath: string) =>
watchedPath === codeModeDir || watchedPath.startsWith(codeModeDir + path.sep),
awaitWriteFinish: {
stabilityThreshold: 150,
pollInterval: 50,

View file

@ -27,6 +27,10 @@ export type BackgroundTask = {
instructions: string;
active: boolean;
triggers?: Triggers;
// When set, this is a *coding* task: it implements code in the pinned code
// project (a registered repo) via the `launch-code-task` tool, each launch
// running in its own isolated worktree. Omit for ordinary OUTPUT/ACTION tasks.
projectId?: string;
model?: string;
provider?: string;
createdAt: string;
@ -48,6 +52,7 @@ export type BackgroundTaskSummary = {
instructions: string;
active: boolean;
triggers?: Triggers;
projectId?: string;
createdAt: string;
lastAttemptAt?: string;
lastRunId?: string;
@ -56,11 +61,14 @@ export type BackgroundTaskSummary = {
lastRunError?: string;
};
// NOTE: keep `BackgroundTaskSummary` (above) and `BackgroundTask` (top) in sync.
export const BackgroundTaskSchema = z.object({
name: z.string().min(1).describe('User-facing display name.'),
instructions: z.string().min(1).describe('A persistent instruction in the user\'s words — what should this task keep doing? E.g. "Summarize my unread emails every morning into a brief digest." The agent re-reads instructions on every run and decides whether to rewrite index.md (OUTPUT mode) or perform a side-effect and journal it (ACTION mode) based on the verbs.'),
active: z.boolean().default(true).describe('Set false to pause without deleting.'),
triggers: TriggersSchema.optional().describe('When the agent fires. Omit for manual-only.'),
projectId: z.string().optional().describe('When set, marks this as a coding task pinned to a registered code project (repo). The agent implements detected work via the launch-code-task tool, each launch in its own isolated worktree.'),
model: z.string().optional().describe('ADVANCED — leave unset. Per-task model override.'),
provider: z.string().optional().describe('ADVANCED — leave unset. Per-task provider name override.'),
createdAt: z.string().describe('ISO timestamp set once at create-time.'),
@ -77,6 +85,7 @@ export const BackgroundTaskSummarySchema = z.object({
instructions: z.string(),
active: z.boolean(),
triggers: TriggersSchema.optional(),
projectId: z.string().optional(),
createdAt: z.string(),
lastAttemptAt: z.string().optional(),
lastRunId: z.string().optional(),

View file

@ -1,7 +1,26 @@
import { z } from 'zod';
export const BillingPlanSchema = z.enum(['free', 'starter', 'pro']);
export type BillingPlan = z.infer<typeof BillingPlanSchema>;
export const BillingPlanCategorySchema = z.enum(['free', 'starter', 'pro']);
export type BillingPlanCategory = z.infer<typeof BillingPlanCategorySchema>;
export const BillingPlanIdSchema = z.string().min(1);
export type BillingPlanId = z.infer<typeof BillingPlanIdSchema>;
export const BillingCatalogPlanSchema = z.object({
id: BillingPlanIdSchema,
category: BillingPlanCategorySchema,
displayName: z.string(),
monthlyCredits: z.number(),
dailyCredits: z.number(),
monthlyPriceCents: z.number().nullable(),
archived: z.boolean().optional(),
});
export type BillingCatalogPlan = z.infer<typeof BillingCatalogPlanSchema>;
export const BillingCatalogSchema = z.object({
plans: z.array(BillingCatalogPlanSchema),
});
export type BillingCatalog = z.infer<typeof BillingCatalogSchema>;
export const BillingUsageBucketSchema = z.object({
sanctionedCredits: z.number(),
@ -13,12 +32,21 @@ export type BillingUsageBucket = z.infer<typeof BillingUsageBucketSchema>;
export const BillingInfoSchema = z.object({
userEmail: z.string().nullable(),
userId: z.string().nullable(),
subscriptionPlan: BillingPlanSchema.nullable(),
subscriptionPlanId: BillingPlanIdSchema.nullable(),
subscriptionStatus: z.string().nullable(),
trialExpiresAt: z.string().nullable(),
catalog: BillingCatalogSchema,
monthly: BillingUsageBucketSchema,
daily: BillingUsageBucketSchema.extend({
usageDay: z.string(),
}),
});
export type BillingInfo = z.infer<typeof BillingInfoSchema>;
export function getBillingPlanData(
catalog: BillingCatalog,
planId: string | null | undefined,
): BillingCatalogPlan | null {
if (!planId) return null;
return catalog.plans.find((plan) => plan.id === planId) ?? null;
}

View 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>;

View file

@ -0,0 +1,94 @@
import z from "zod";
import { CodingAgent, ApprovalPolicy } from "./code-mode.js";
// Shared zod schemas for the Code section: registered projects and coding
// sessions. A coding session is backed by a run (session id == run id); the
// mutable metadata below lives in its own per-session file.
export const CodeProject = z.object({
id: z.string(),
path: z.string(),
name: z.string(),
addedAt: z.iso.datetime(),
});
export type CodeProject = z.infer<typeof CodeProject>;
// Git facts about a project path, used to gate worktree creation in the UI.
export const GitRepoInfo = z.object({
isGitRepo: z.boolean(),
branch: z.string().nullable(),
hasCommits: z.boolean(),
dirtyCount: z.number(),
});
export type GitRepoInfo = z.infer<typeof GitRepoInfo>;
// 'direct': the user's messages go straight to the ACP coding agent.
// 'rowboat': Rowboat's copilot LLM orchestrates the agent via code_agent_run.
export const CodeSessionMode = z.enum(["direct", "rowboat"]);
export type CodeSessionMode = z.infer<typeof CodeSessionMode>;
// Derived live in the main process from the run event stream; not persisted.
export const CodeSessionStatus = z.enum(["working", "needs-you", "idle"]);
export type CodeSessionStatus = z.infer<typeof CodeSessionStatus>;
export const CodeWorktree = z.object({
path: z.string(),
branch: z.string(),
// Branch the original checkout was on when the worktree was created;
// merge-back targets whatever the checkout is on at merge time, this is
// informational.
baseBranch: z.string().nullable(),
mergedAt: z.iso.datetime().optional(),
removedAt: z.iso.datetime().optional(),
});
export type CodeWorktree = z.infer<typeof CodeWorktree>;
export const CodeSession = z.object({
id: z.string(), // == runId
projectId: z.string(),
title: z.string(),
agent: CodingAgent,
mode: CodeSessionMode,
policy: ApprovalPolicy,
// Where the agent works: the project path, or the worktree path.
cwd: z.string(),
worktree: CodeWorktree.optional(),
// The coding agent's own model + reasoning effort (applied to the ACP engine,
// not the Rowboat-mode LLM). Values come from CODE_AGENT_MODELS /
// CODE_AGENT_EFFORTS; unset (or 'default') leaves the engine's own default.
agentModel: z.string().optional(),
agentEffort: z.string().optional(),
createdAt: z.iso.datetime(),
lastActivityAt: z.iso.datetime().optional(),
});
export type CodeSession = z.infer<typeof CodeSession>;
// Model + effort choices for the ACP coding agents are discovered live from the
// engine (the same list `/model` shows), not hardcoded — so they always reflect
// whatever the provider currently offers. See the `codeMode:listModelOptions`
// IPC and CodeModeManager.listModelOptions. 'default' is a synthetic sentinel
// meaning "don't override the engine default".
//
// Claude exposes model and effort as two independent options; Codex folds the
// reasoning effort into the model id ("gpt-5-codex[high]") and so reports no
// separate effort list. The UI renders whatever each agent advertises.
export const CodeAgentOption = z.object({ value: z.string(), label: z.string() });
export type CodeAgentOption = z.infer<typeof CodeAgentOption>;
export const CodeAgentModelOptions = z.object({
models: z.array(CodeAgentOption),
efforts: z.array(CodeAgentOption),
});
export type CodeAgentModelOptions = z.infer<typeof CodeAgentModelOptions>;
export const GitFileState = z.enum(["modified", "added", "deleted", "untracked", "renamed"]);
export type GitFileState = z.infer<typeof GitFileState>;
export const GitStatusFile = z.object({
path: z.string(),
state: GitFileState,
// Null when git can't compute line counts (binary files).
insertions: z.number().nullable(),
deletions: z.number().nullable(),
});
export type GitStatusFile = z.infer<typeof GitStatusFile>;

View file

@ -17,4 +17,6 @@ export * as frontmatter from './frontmatter.js';
export * as bases from './bases.js';
export * as browserControl from './browser-control.js';
export * as billing from './billing.js';
export * as notificationSettings from './notification-settings.js';
export * as codeSessions from './code-sessions.js';
export { PrefixLogger };

View file

@ -2,7 +2,7 @@ import { z } from 'zod';
import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js';
import { ListToolsResponse } from './mcp.js';
import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js';
import { LlmModelConfig } from './models.js';
import { LlmModelConfig, LlmProvider } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js';
@ -19,11 +19,41 @@ 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, CodingAgent } from './code-mode.js';
import { NotificationSettingsSchema } from './notification-settings.js';
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile, CodeAgentModelOptions } from './code-sessions.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
// ============================================================================
const KnowledgeSourceScopeSchema = z.object({
type: z.string(),
id: z.string(),
name: z.string().optional(),
workspaceUrl: z.string().optional(),
});
// Mirrors AgentSlackErrorKind in @x/core/slack/agent-slack-exec. Kept as a
// standalone enum so the renderer can branch on failure cause without
// importing core.
const SlackErrorKindSchema = z.enum([
'not_installed', 'timeout', 'parse_error',
'not_authed', 'rate_limited', 'network', 'bad_channel', 'unknown',
]);
const KnowledgeSourceConfigSchema = z.object({
id: z.string(),
provider: z.enum(['gmail', 'meeting', 'voice_memo', 'slack', 'github', 'linear']),
enabled: z.boolean(),
artifactDir: z.string(),
syncMode: z.enum(['file', 'poll', 'event', 'manual']).default('file'),
intervalMs: z.number().int().positive().optional(),
scopes: z.array(KnowledgeSourceScopeSchema).default([]),
instructions: z.string().optional(),
filters: z.record(z.string(), z.unknown()).optional(),
});
const ipcSchemas = {
'app:getVersions': {
req: z.null(),
@ -160,6 +190,15 @@ const ipcSchemas = {
bodyText: z.string(),
inReplyTo: z.string().optional(),
references: z.string().optional(),
attachments: z
.array(
z.object({
filename: z.string(),
mimeType: z.string(),
contentBase64: z.string(),
}),
)
.optional(),
}),
res: z.object({
messageId: z.string().optional(),
@ -181,6 +220,12 @@ const ipcSchemas = {
email: z.string().nullable(),
}),
},
'gmail:getAccountName': {
req: z.object({}),
res: z.object({
name: z.string().nullable(),
}),
},
'gmail:archiveThread': {
req: z.object({ threadId: z.string().min(1) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
@ -201,6 +246,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(),
@ -230,6 +290,10 @@ const ipcSchemas = {
voiceOutput: z.enum(['summary', 'full']).optional(),
searchEnabled: z.boolean().optional(),
codeMode: z.enum(['claude', 'codex']).optional(),
// Code-section sessions pin the coding agent's working directory and
// approval policy for the whole turn (see code_agent_run overrides).
codeCwd: z.string().optional(),
codePolicy: ApprovalPolicy.optional(),
middlePaneContext: z.discriminatedUnion('kind', [
z.object({
kind: z.literal('note'),
@ -339,6 +403,37 @@ const ipcSchemas = {
error: z.string().optional(),
}),
},
'models:listForProvider': {
req: z.object({
provider: LlmProvider,
}),
res: z.object({
success: z.boolean(),
models: z.array(z.string()).optional(),
error: z.string().optional(),
}),
},
'llm:getDefaultModel': {
req: z.null(),
res: z.object({
model: z.string(),
provider: z.string(),
}),
},
'llm:generate': {
req: z.object({
prompt: z.string().min(1),
system: z.string().optional(),
model: z.string().optional(),
provider: z.string().optional(),
}),
res: z.object({
text: z.string().optional(),
model: z.string().optional(),
provider: z.string().optional(),
error: z.string().optional(),
}),
},
'models:saveConfig': {
req: LlmModelConfig,
res: z.object({
@ -430,11 +525,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),
@ -447,6 +554,244 @@ const ipcSchemas = {
codex: z.object({ installed: z.boolean(), signedIn: z.boolean() }),
}),
},
// Download + install an agent's native engine (the Settings "Enable" action).
// Streams progress over the 'codeMode:engineProgress' push channel while it runs.
'codeMode:provisionEngine': {
req: z.object({ agent: z.enum(['claude', 'codex']) }),
res: z.object({ success: z.boolean(), error: z.string().optional() }),
},
// Push (main -> renderer): engine provisioning progress for the Settings UI.
'codeMode:engineProgress': {
req: z.object({
agent: z.enum(['claude', 'codex']),
phase: z.enum(['download', 'verify', 'extract', 'done']),
receivedBytes: z.number().optional(),
totalBytes: z.number().optional(),
}),
res: z.null(),
},
// ==========================================================================
// Code section: project registry + coding sessions
// ==========================================================================
'codeProject:add': {
req: z.object({
path: z.string(),
}),
res: z.object({
project: CodeProject,
git: GitRepoInfo,
}),
},
'codeProject:remove': {
req: z.object({
projectId: z.string(),
}),
res: z.object({
success: z.literal(true),
}),
},
'codeProject:list': {
req: z.null(),
res: z.object({
projects: z.array(z.object({
project: CodeProject,
git: GitRepoInfo,
})),
}),
},
'codeSession:create': {
req: z.object({
projectId: z.string(),
title: z.string().optional(),
agent: CodingAgent,
mode: CodeSessionMode,
policy: ApprovalPolicy,
isolation: z.enum(['in-repo', 'worktree']),
// LLM for Rowboat-mode turns. Unset = the configured default. Like any
// chat, the model is fixed once the session's run exists.
model: z.string().optional(),
provider: z.string().optional(),
// The coding agent's own model + reasoning effort (ACP engine). Unlike the
// Rowboat model these are re-applied each turn, so they stay editable.
agentModel: z.string().optional(),
agentEffort: z.string().optional(),
}),
res: z.object({
session: CodeSession,
}),
},
'codeSession:list': {
req: z.null(),
res: z.object({
sessions: z.array(CodeSession),
statuses: z.record(z.string(), CodeSessionStatus),
}),
},
'codeSession:update': {
req: z.object({
sessionId: z.string(),
patch: CodeSession.pick({ title: true, mode: true, policy: true, agent: true, agentModel: true, agentEffort: true }).partial(),
}),
res: z.object({
session: CodeSession,
}),
},
// Live model + effort choices for a coding agent, discovered from the engine
// (cached per agent in the main process). Mirrors what `/model` would show.
'codeMode:listModelOptions': {
req: z.object({ agent: CodingAgent }),
res: CodeAgentModelOptions,
},
'codeSession:delete': {
req: z.object({
sessionId: z.string(),
removeWorktree: z.boolean().optional(),
deleteBranch: z.boolean().optional(),
}),
res: z.object({
success: z.literal(true),
}),
},
// Direct-drive: send the user's message straight to the session's ACP agent
// (no copilot LLM in between). Streams back over `runs:events`.
'codeSession:sendMessage': {
req: z.object({
sessionId: z.string(),
text: z.string().min(1),
}),
res: z.object({
accepted: z.boolean(),
error: z.string().optional(),
}),
},
'codeSession:stop': {
req: z.object({
sessionId: z.string(),
}),
res: z.object({
success: z.literal(true),
}),
},
'codeSession:gitStatus': {
req: z.object({
sessionId: z.string(),
}),
res: z.object({
isRepo: z.boolean(),
branch: z.string().nullable(),
hasCommits: z.boolean(),
files: z.array(GitStatusFile),
}),
},
'codeSession:fileDiff': {
req: z.object({
sessionId: z.string(),
path: z.string(),
}),
res: z.object({
oldText: z.string(),
newText: z.string(),
isBinary: z.boolean(),
tooLarge: z.boolean(),
}),
},
'codeSession:readdir': {
req: z.object({
sessionId: z.string(),
relPath: z.string(),
}),
res: z.object({
entries: z.array(z.object({
name: z.string(),
kind: z.enum(['file', 'dir']),
size: z.number().optional(),
})),
}),
},
'codeSession:readFile': {
req: z.object({
sessionId: z.string(),
relPath: z.string(),
}),
res: z.object({
content: z.string(),
isBinary: z.boolean(),
tooLarge: z.boolean(),
}),
},
'codeSession:mergeBack': {
req: z.object({
sessionId: z.string(),
}),
res: z.object({
ok: z.boolean(),
conflict: z.boolean().optional(),
message: z.string(),
}),
},
'codeSession:cleanupWorktree': {
req: z.object({
sessionId: z.string(),
deleteBranch: z.boolean(),
}),
res: z.object({
success: z.boolean(),
error: z.string().optional(),
}),
},
// main → renderer: live session status transitions from the status tracker.
'codeSession:status': {
req: z.object({
sessionId: z.string(),
status: CodeSessionStatus,
}),
res: z.null(),
},
// ==========================================================================
// Embedded terminal (Code section): one PTY per coding session
// ==========================================================================
// Create-or-attach. Returns the scrollback backlog so a remounted view can
// repaint what happened while it was closed.
'terminal:ensure': {
req: z.object({
id: z.string(),
cwd: z.string(),
cols: z.number().int().positive(),
rows: z.number().int().positive(),
}),
res: z.object({
backlog: z.string(),
running: z.boolean(),
}),
},
'terminal:input': {
req: z.object({
id: z.string(),
data: z.string(),
}),
res: z.object({ success: z.literal(true) }),
},
'terminal:resize': {
req: z.object({
id: z.string(),
cols: z.number().int().positive(),
rows: z.number().int().positive(),
}),
res: z.object({ success: z.literal(true) }),
},
'terminal:dispose': {
req: z.object({ id: z.string() }),
res: z.object({ success: z.literal(true) }),
},
// main → renderer streams
'terminal:data': {
req: z.object({ id: z.string(), data: z.string() }),
res: z.null(),
},
'terminal:exit': {
req: z.object({ id: z.string(), exitCode: z.number() }),
res: z.null(),
},
'granola:setConfig': {
req: z.object({
enabled: z.boolean(),
@ -471,11 +816,112 @@ const ipcSchemas = {
success: z.literal(true),
}),
},
'slack:cliStatus': {
req: z.null(),
res: z.object({
available: z.boolean(),
version: z.string().optional(),
source: z.enum(['bundled', 'global', 'path']).optional(),
}),
},
'slack:listWorkspaces': {
req: z.null(),
res: z.object({
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
error: z.string().optional(),
errorKind: SlackErrorKindSchema.optional(),
}),
},
'slack:importDesktopAuth': {
req: z.null(),
res: z.object({
ok: z.boolean(),
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
error: z.string().optional(),
errorKind: SlackErrorKindSchema.optional(),
}),
},
'slack:quitAndImportDesktop': {
req: z.null(),
res: z.object({
ok: z.boolean(),
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
error: z.string().optional(),
errorKind: SlackErrorKindSchema.optional(),
}),
},
'slack:parseCurlAuth': {
req: z.object({ curl: z.string() }),
res: z.object({
ok: z.boolean(),
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
error: z.string().optional(),
errorKind: SlackErrorKindSchema.optional(),
}),
},
'slack:knowledgeStatus': {
req: z.null(),
res: z.object({
cli: z.object({
available: z.boolean(),
version: z.string().optional(),
source: z.enum(['bundled', 'global', 'path']).optional(),
}),
sources: z.array(z.object({
id: z.string(),
enabled: z.boolean(),
lastSyncAt: z.string().optional(),
lastStatus: z.enum(['ok', 'error']).optional(),
lastError: z.object({ kind: z.string(), message: z.string() }).optional(),
nextDueAt: z.string().optional(),
})),
}),
},
'slack:listChannels': {
req: z.object({
workspaceUrl: z.string(),
}),
res: z.object({
channels: z.array(z.object({
id: z.string(),
name: z.string(),
isPrivate: z.boolean().optional(),
isMember: z.boolean().optional(),
})),
error: z.string().optional(),
}),
},
'slack:getRecentMessages': {
req: z.object({
limit: z.number().int().positive().max(20).optional(),
}),
res: z.object({
enabled: z.boolean(),
messages: z.array(z.object({
id: z.string(),
workspaceName: z.string().optional(),
workspaceUrl: z.string().optional(),
channelId: z.string().optional(),
channelName: z.string().optional(),
author: z.string().optional(),
text: z.string(),
ts: z.string(),
url: z.string().optional(),
})),
error: z.string().optional(),
errorKind: SlackErrorKindSchema.optional(),
}),
},
'knowledgeSources:getConfig': {
req: z.null(),
res: z.object({
sources: z.array(KnowledgeSourceConfigSchema),
}),
},
'knowledgeSources:upsert': {
req: KnowledgeSourceConfigSchema,
res: z.object({
sources: z.array(KnowledgeSourceConfigSchema),
}),
},
'onboarding:getStatus': {
@ -617,6 +1063,15 @@ const ipcSchemas = {
path: z.string().nullable(),
}),
},
'dialog:openFiles': {
req: z.object({
defaultPath: z.string().optional(),
title: z.string().optional(),
}),
res: z.object({
paths: z.array(z.string()),
}),
},
// Knowledge version history channels
'knowledge:history': {
req: z.object({ path: RelPath }),
@ -757,6 +1212,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({
@ -937,6 +1402,7 @@ const ipcSchemas = {
name: z.string(),
instructions: z.string(),
triggers: TriggersSchema.optional(),
projectId: z.string().optional(),
model: z.string().optional(),
provider: z.string().optional(),
}),
@ -1073,6 +1539,17 @@ const ipcSchemas = {
req: z.null(),
res: BillingInfoSchema,
},
// Notification settings channels
'notifications:getSettings': {
req: z.null(),
res: NotificationSettingsSchema,
},
'notifications:setSettings': {
req: NotificationSettingsSchema,
res: z.object({
success: z.literal(true),
}),
},
} as const;
// ============================================================================

View file

@ -17,10 +17,15 @@ export const LlmModelConfig = z.object({
headers: z.record(z.string(), z.string()).optional(),
model: z.string().optional(),
models: z.array(z.string()).optional(),
knowledgeGraphModel: z.string().optional(),
meetingNotesModel: z.string().optional(),
liveNoteAgentModel: z.string().optional(),
autoPermissionDecisionModel: z.string().optional(),
})).optional(),
// Per-category model overrides (BYOK only — signed-in users always get
// the curated gateway defaults). Read by helpers in core/models/defaults.ts.
knowledgeGraphModel: z.string().optional(),
meetingNotesModel: z.string().optional(),
liveNoteAgentModel: z.string().optional(),
autoPermissionDecisionModel: z.string().optional(),
});

View file

@ -0,0 +1,36 @@
import { z } from 'zod';
/**
* Notification categories the user can independently toggle.
*
* - chat_completion: an agent finished generating a response
* - new_email: a new email arrived during incremental Gmail sync
* - agent_permission: an agent is requesting permission to run a tool
*/
export const NotificationCategorySchema = z.enum([
'chat_completion',
'new_email',
'agent_permission',
]);
export const NotificationCategoriesSchema = z.object({
chat_completion: z.boolean(),
new_email: z.boolean(),
agent_permission: z.boolean(),
});
export const NotificationSettingsSchema = z.object({
categories: NotificationCategoriesSchema,
});
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
categories: {
chat_completion: true,
new_email: true,
agent_permission: true,
},
};
export type NotificationCategory = z.infer<typeof NotificationCategorySchema>;
export type NotificationCategories = z.infer<typeof NotificationCategoriesSchema>;
export type NotificationSettings = z.infer<typeof NotificationSettingsSchema>;

View file

@ -1,7 +1,9 @@
import { z } from 'zod';
import { BillingCatalogSchema } from './billing.js';
export const RowboatApiConfig = z.object({
appUrl: z.string(),
websocketApiUrl: z.string(),
supabaseUrl: z.string(),
billing: BillingCatalogSchema,
});

View file

@ -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({
@ -21,6 +22,7 @@ export const StartEvent = BaseRunEvent.extend({
agentName: z.string(),
model: z.string(),
provider: z.string(),
permissionMode: z.enum(["manual", "auto"]).optional(),
// useCase/subUseCase tag the run for analytics. Optional on read so legacy
// run files written before these fields existed still parse cleanly.
useCase: z.enum([
@ -29,6 +31,7 @@ export const StartEvent = BaseRunEvent.extend({
"background_task_agent",
"meeting_note",
"knowledge_sync",
"code_session",
]).optional(),
subUseCase: z.string().optional(),
});
@ -110,6 +113,32 @@ 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(),
toolCall: ToolCallPart,
permission: ToolPermissionMetadata.optional(),
decision: z.enum(["allow", "deny"]),
reason: z.string(),
});
export const RunErrorEvent = BaseRunEvent.extend({
type: z.literal("error"),
error: z.string(),
@ -134,6 +163,9 @@ export const RunEvent = z.union([
AskHumanResponseEvent,
ToolPermissionRequestEvent,
ToolPermissionResponseEvent,
CodeRunStreamEvent,
CodeRunPermissionRequestEvent,
ToolPermissionAutoDecisionEvent,
RunErrorEvent,
RunStoppedEvent,
]);
@ -157,6 +189,7 @@ export const UseCase = z.enum([
"background_task_agent",
"meeting_note",
"knowledge_sync",
"code_session",
]);
export const Run = z.object({
@ -166,6 +199,7 @@ export const Run = z.object({
agentId: z.string(),
model: z.string(),
provider: z.string(),
permissionMode: z.enum(["manual", "auto"]).optional(),
useCase: UseCase.optional(),
subUseCase: z.string().optional(),
log: z.array(RunEvent),
@ -177,6 +211,11 @@ export const ListRunsResponse = z.object({
title: true,
createdAt: true,
agentId: true,
useCase: true,
}).extend({
// Last-modified time of the run's log file (mtime), used to order the
// chat history by recent activity rather than creation time.
modifiedAt: z.iso.datetime(),
})),
nextCursor: z.string().optional(),
});
@ -185,6 +224,7 @@ export const CreateRunOptions = z.object({
agentId: z.string(),
model: z.string().optional(),
provider: z.string().optional(),
permissionMode: z.enum(["manual", "auto"]).optional(),
useCase: UseCase.optional(),
subUseCase: z.string().optional(),
});

View file

@ -6,6 +6,7 @@ export const ServiceName = z.enum([
'calendar',
'fireflies',
'granola',
'slack',
'voice_memo',
'email_labeling',
'note_tagging',