Merge branch 'storage-terminal-ui-dev' into dev

# Conflicts:
#	nix/package.nix
#	packages/app/src/app/_layout.tsx
#	packages/app/src/components/sidebar-workspace-list.tsx
#	packages/app/src/hooks/use-command-center.ts
#	packages/app/src/screens/agent/draft-agent-screen.tsx
#	packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
#	packages/app/src/screens/workspace/workspace-screen.tsx
#	packages/server/src/server/session.ts
#	packages/server/src/server/session.workspaces.test.ts
#	packages/server/src/terminal/terminal.test.ts
#	packages/server/src/terminal/terminal.ts
This commit is contained in:
Mohamed Boudra
2026-04-01 21:51:29 +07:00
217 changed files with 21076 additions and 4944 deletions

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./packages/server/src/server/db/schema.ts",
out: "./packages/server/src/server/db/migrations",
dialect: "sqlite",
strict: true,
verbose: true,
});

View File

@@ -35,7 +35,7 @@
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
"dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx');\"",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true});\"",
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
"prepack": "npm run build",
"start": "NODE_ENV=production node dist/server/server/index.js",
@@ -45,6 +45,7 @@
"speech:download": "tsx scripts/download-speech-models.ts",
"speech:tts:matrix": "tsx scripts/generate-sherpa-tts-matrix.ts",
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"db:query": "tsx scripts/db-query.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 --minWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
@@ -73,6 +74,8 @@
"ai": "5.0.78",
"ajv": "^8.17.1",
"dotenv": "^17.2.3",
"better-sqlite3": "^12.8.0",
"drizzle-orm": "^0.45.1",
"express": "^4.18.2",
"express-basic-auth": "^1.2.1",
"fast-uri": "^3.1.0",
@@ -96,6 +99,8 @@
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/better-sqlite3": "^7.6.13",
"drizzle-kit": "^0.31.10",
"@types/express": "^4.17.20",
"@types/node": "^20.9.0",
"@types/qrcode": "^1.5.6",

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env npx tsx
/**
* Run arbitrary SQL against the Paseo SQLite database.
*
* Usage:
* npx tsx packages/server/scripts/db-query.ts "SELECT * FROM agent_snapshots"
* npx tsx packages/server/scripts/db-query.ts --db ~/.paseo/db "SELECT count(*) FROM agent_timeline_rows"
*
* Without args, shows table row counts.
*/
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import Database from "better-sqlite3";
function resolveHomeDirectory(value: string): string {
if (value === "~") {
return os.homedir();
}
if (value.startsWith("~/")) {
return path.join(os.homedir(), value.slice(2));
}
return value;
}
function parseListenPort(listen: unknown): number | null {
if (typeof listen !== "string") {
return null;
}
const portMatch = listen.match(/:(\d+)$/);
return portMatch ? parseInt(portMatch[1]!, 10) : null;
}
function findDevDatabaseDirectory(): string | null {
const tmpDir = os.tmpdir();
for (const entry of fs.readdirSync(tmpDir)) {
if (entry.startsWith("paseo-dev.")) {
const configPath = path.join(tmpDir, entry, "config.json");
if (fs.existsSync(configPath)) {
try {
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
const dbDir = config.paseoHome ? path.join(config.paseoHome, "db") : null;
const port = parseListenPort(config.daemon?.listen);
if (dbDir && port === 6767) {
return dbDir;
}
} catch {}
}
}
}
return null;
}
function resolveDatabasePath(explicitPath?: string): string {
if (explicitPath) {
const resolvedPath = path.resolve(resolveHomeDirectory(explicitPath));
return fs.statSync(resolvedPath).isDirectory()
? path.join(resolvedPath, "paseo.sqlite")
: resolvedPath;
}
const detectedDevDir = findDevDatabaseDirectory();
if (detectedDevDir) {
return path.join(detectedDevDir, "paseo.sqlite");
}
const paseoHome = process.env.PASEO_HOME
? path.resolve(resolveHomeDirectory(process.env.PASEO_HOME))
: path.join(os.homedir(), ".paseo");
return path.join(paseoHome, "db", "paseo.sqlite");
}
async function main() {
const args = process.argv.slice(2);
let dbPath: string | undefined;
const queries: string[] = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === "--db" && args[i + 1]) {
dbPath = args[++i];
} else {
queries.push(args[i]!);
}
}
if (queries.length === 0) {
queries.push(
"SELECT 'agent_snapshots' AS table_name, count(*) AS rows FROM agent_snapshots UNION ALL " +
"SELECT 'agent_timeline_rows', count(*) FROM agent_timeline_rows UNION ALL " +
"SELECT 'projects', count(*) FROM projects UNION ALL " +
"SELECT 'workspaces', count(*) FROM workspaces " +
"ORDER BY table_name",
);
}
let databasePath = "";
let client: Database.Database | null = null;
try {
if (dbPath) {
const resolvedDbPath = path.resolve(resolveHomeDirectory(dbPath));
databasePath =
fs.existsSync(resolvedDbPath) && fs.statSync(resolvedDbPath).isDirectory()
? path.join(resolvedDbPath, "paseo.sqlite")
: resolvedDbPath;
} else {
databasePath = resolveDatabasePath();
}
client = new Database(databasePath, { readonly: true, fileMustExist: true });
for (const sql of queries) {
const statement = client.prepare(sql);
if (statement.reader) {
const rows = statement.all();
if (rows.length === 0) {
console.log("(0 rows)\n");
} else {
console.table(rows);
}
continue;
}
const result = statement.run();
console.log(`OK (${result.changes} changes)\n`);
}
} catch (err: any) {
console.error(`Error: ${err.message}\nDatabase: ${databasePath}`);
process.exitCode = 1;
} finally {
client?.close();
}
}
main();

View File

@@ -1704,24 +1704,15 @@ describe("DaemonClient", () => {
agentId: "agent_cli",
agent: null,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 1 },
startSeq: 1,
endSeq: 1,
hasOlder: false,
hasNewer: false,
entries: [
{
timestamp: "2026-02-08T20:20:00.000Z",
provider: "codex",
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
seq: 1,
item: {
type: "tool_call",
callId: "call_cli_snapshot",
@@ -1798,24 +1789,15 @@ describe("DaemonClient", () => {
agentId: "agent_cli",
agent: null,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 1 },
startSeq: 1,
endSeq: 1,
hasOlder: false,
hasNewer: false,
entries: [
{
timestamp: "2026-02-08T20:20:00.000Z",
provider: "codex",
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
seq: 1,
item: {
type: "tool_call",
callId: "call_cli_invalid",

View File

@@ -119,9 +119,9 @@ export type DaemonEvent =
agentId: string;
payload: Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
}
| {
| {
type: "workspace_update";
workspaceId: string;
workspaceId: number;
payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
}
| {
@@ -130,7 +130,6 @@ export type DaemonEvent =
event: AgentStreamEventPayload;
timestamp: string;
seq?: number;
epoch?: string;
}
| { type: "status"; payload: { status: string } & Record<string, unknown> }
| { type: "agent_deleted"; agentId: string }
@@ -182,6 +181,7 @@ export type CreateAgentRequestOptions = {
config?: AgentSessionConfig;
provider?: AgentProvider;
cwd?: string;
workspaceId?: number;
initialPrompt?: string;
clientMessageId?: string;
outputSchema?: Record<string, unknown>;
@@ -320,13 +320,13 @@ type ScheduleDeletePayload = Extract<
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"];
export type FetchAgentTimelineCursor = NonNullable<FetchAgentTimelinePayload["startCursor"]>;
export type FetchAgentTimelineCursor = NonNullable<
Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>["cursor"]
>;
export type FetchAgentTimelineOptions = {
direction?: FetchAgentTimelineDirection;
cursor?: FetchAgentTimelineCursor;
limit?: number;
projection?: FetchAgentTimelineProjection;
requestId?: string;
};
@@ -1301,7 +1301,7 @@ export class DaemonClient {
}
async archiveWorkspace(
workspaceId: string,
workspaceId: number,
requestId?: string,
): Promise<ArchiveWorkspacePayload> {
return this.sendCorrelatedSessionRequest({
@@ -1386,6 +1386,7 @@ export class DaemonClient {
type: "create_agent_request",
requestId,
config,
...(typeof options.workspaceId === "number" ? { workspaceId: options.workspaceId } : {}),
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
@@ -1576,7 +1577,6 @@ export class DaemonClient {
...(options.direction ? { direction: options.direction } : {}),
...(options.cursor ? { cursor: options.cursor } : {}),
...(typeof options.limit === "number" ? { limit: options.limit } : {}),
...(options.projection ? { projection: options.projection } : {}),
});
const payload = await this.sendRequest({
@@ -2733,12 +2733,16 @@ export class DaemonClient {
cwd: string,
name?: string,
requestId?: string,
options?: { agentId?: string; command?: string; args?: string[] },
): Promise<CreateTerminalPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "create_terminal_request",
cwd,
name,
agentId: options?.agentId,
command: options?.command,
args: options?.args,
requestId: resolvedRequestId,
});
return this.sendCorrelatedRequest({
@@ -3552,7 +3556,6 @@ export class DaemonClient {
event: msg.payload.event,
timestamp: msg.payload.timestamp,
...(typeof msg.payload.seq === "number" ? { seq: msg.payload.seq } : {}),
...(typeof msg.payload.epoch === "string" ? { epoch: msg.payload.epoch } : {}),
};
case "status":
return { type: "status", payload: msg.payload };
@@ -3654,6 +3657,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
config,
provider,
cwd,
workspaceId: _workspaceId,
initialPrompt: _initialPrompt,
images: _images,
git: _git,

View File

@@ -0,0 +1,128 @@
import type pino from "pino";
import type { ManagedAgent } from "./agent/agent-manager.js";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentPersistenceHandle, AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import {
buildConfigOverrides,
buildSessionConfig,
extractTimestamps,
toAgentPersistenceHandle,
} from "./persistence-hooks.js";
const pendingAgentBootstrapLoads = new Map<string, Promise<ManagedAgent>>();
export type AgentLoadingServiceOptions = {
agentManager: Pick<
AgentManager,
| "createAgent"
| "getAgent"
| "reloadAgentSession"
| "resumeAgentFromPersistence"
>;
agentStorage: Pick<AgentSnapshotStore, "get">;
logger: pino.Logger;
};
// Coordinates cold loads, explicit resumes, and refreshes for persisted agents.
export class AgentLoadingService {
private readonly agentManager: AgentLoadingServiceOptions["agentManager"];
private readonly agentStorage: AgentLoadingServiceOptions["agentStorage"];
private readonly logger: pino.Logger;
constructor(options: AgentLoadingServiceOptions) {
this.agentManager = options.agentManager;
this.agentStorage = options.agentStorage;
this.logger = options.logger.child({ component: "agent-loading" });
}
async ensureAgentLoaded(options: { agentId: string }): Promise<ManagedAgent> {
const existing = this.agentManager.getAgent(options.agentId);
if (existing) {
return existing;
}
const inflight = pendingAgentBootstrapLoads.get(options.agentId);
if (inflight) {
return inflight;
}
const initPromise = this.loadStoredAgent(options);
pendingAgentBootstrapLoads.set(options.agentId, initPromise);
try {
return await initPromise;
} finally {
const current = pendingAgentBootstrapLoads.get(options.agentId);
if (current === initPromise) {
pendingAgentBootstrapLoads.delete(options.agentId);
}
}
}
async resumeAgent(options: {
handle: AgentPersistenceHandle;
overrides?: Partial<AgentSessionConfig>;
}): Promise<ManagedAgent> {
return this.agentManager.resumeAgentFromPersistence(options.handle, options.overrides);
}
async refreshAgent(options: { agentId: string }): Promise<ManagedAgent> {
const existing = this.agentManager.getAgent(options.agentId);
if (existing) {
return existing.persistence
? await this.agentManager.reloadAgentSession(options.agentId)
: existing;
}
const record = await this.agentStorage.get(options.agentId);
if (!record) {
throw new Error(`Agent not found: ${options.agentId}`);
}
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
if (!handle) {
throw new Error(`Agent ${options.agentId} cannot be refreshed because it lacks persistence`);
}
return this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
options.agentId,
extractTimestamps(record),
);
}
private async loadStoredAgent(options: { agentId: string }): Promise<ManagedAgent> {
const record = await this.agentStorage.get(options.agentId);
if (!record) {
throw new Error(`Agent not found: ${options.agentId}`);
}
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
let snapshot: ManagedAgent;
if (handle) {
snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
options.agentId,
extractTimestamps(record),
);
this.logger.info(
{ agentId: options.agentId, provider: record.provider },
"Agent resumed from persistence",
);
} else {
snapshot = await this.agentManager.createAgent(buildSessionConfig(record), options.agentId, {
labels: record.labels,
});
this.logger.info(
{ agentId: options.agentId, provider: record.provider },
"Agent created from stored config",
);
}
return this.agentManager.getAgent(options.agentId) ?? snapshot;
}
}

View File

@@ -37,7 +37,7 @@ import {
import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
@@ -51,7 +51,7 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-boot
export interface AgentManagementMcpOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null;
paseoHome?: string;
logger: Logger;
@@ -178,7 +178,7 @@ function sanitizePermissionRequest(
}
async function resolveAgentTitle(
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
agentId: string,
logger: Logger,
): Promise<string | null> {
@@ -192,7 +192,7 @@ async function resolveAgentTitle(
}
async function serializeSnapshotWithMetadata(
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
snapshot: ManagedAgent,
logger: Logger,
) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,7 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
config: { ...baseConfig, ...configOverrides },
lifecycle,

View File

@@ -62,6 +62,8 @@ export function toStoredAgentRecord(
config: config ?? null,
runtimeInfo,
persistence,
lastError: agent.lastError ?? undefined,
terminalExit: agent.terminalExit ?? undefined,
requiresAttention: agent.attention.requiresAttention,
attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null,
attentionTimestamp: agent.attention.requiresAttention
@@ -86,6 +88,7 @@ export function toAgentPayload(
id: agent.id,
provider: agent.provider,
cwd: agent.cwd,
terminal: agent.terminal,
model: agent.config.model ?? null,
thinkingOptionId,
effectiveThinkingOptionId,
@@ -112,6 +115,10 @@ export function toAgentPayload(
payload.lastError = agent.lastError;
}
if (agent.terminalExit) {
payload.terminalExit = agent.terminalExit;
}
// Handle attention state
payload.requiresAttention = agent.attention.requiresAttention;
if (agent.attention.requiresAttention) {
@@ -127,6 +134,9 @@ export function toAgentPayload(
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
const serializable: SerializableAgentConfig = {};
if (config.terminal !== undefined) {
serializable.terminal = config.terminal;
}
if (Object.prototype.hasOwnProperty.call(config, "title")) {
serializable.title = config.title ?? null;
}

View File

@@ -71,6 +71,7 @@ export type AgentCapabilityFlags = {
supportsMcpServers: boolean;
supportsReasoningStream: boolean;
supportsToolInvocations: boolean;
supportsTerminalMode: boolean;
};
export type AgentPersistenceHandle = {
@@ -356,9 +357,16 @@ export type PersistedAgentDescriptor = {
timeline: AgentTimelineItem[];
};
export type TerminalCommand = {
command: string;
args: string[];
env?: Record<string, string>;
};
export type AgentSessionConfig = {
provider: AgentProvider;
cwd: string;
terminal?: boolean;
/**
* Provider-agnostic system/developer instruction string.
* Mapped by each provider to its native instruction field.
@@ -428,6 +436,12 @@ export interface AgentClient {
): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
buildTerminalCreateCommand?(
config: AgentSessionConfig,
handle: AgentPersistenceHandle,
initialPrompt?: string,
): TerminalCommand;
buildTerminalResumeCommand?(handle: AgentPersistenceHandle): TerminalCommand;
/**
* Check if this provider is available (CLI binary is installed).
* Returns true if available, false otherwise.

View File

@@ -0,0 +1,19 @@
import type { ManagedAgent } from "./agent-manager.js";
import type { StoredAgentRecord } from "./agent-storage.js";
export interface AgentSnapshotStore {
list(): Promise<StoredAgentRecord[]>;
get(agentId: string): Promise<StoredAgentRecord | null>;
upsert(record: StoredAgentRecord): Promise<void>;
remove(agentId: string): Promise<void>;
applySnapshot(
agent: ManagedAgent,
options?: { title?: string | null; internal?: boolean },
): Promise<void>;
applySnapshot(
agent: ManagedAgent,
workspaceId: number,
options?: { title?: string | null; internal?: boolean },
): Promise<void>;
setTitle(agentId: string, title: string): Promise<void>;
}

View File

@@ -57,6 +57,7 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
config,
lifecycle,

View File

@@ -7,10 +7,12 @@ import type { Logger } from "pino";
import { AgentStatusSchema } from "../messages.js";
import { toStoredAgentRecord } from "./agent-projections.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import type { AgentSessionConfig } from "./agent-sdk-types.js";
const SERIALIZABLE_CONFIG_SCHEMA = z
.object({
terminal: z.boolean().optional(),
title: z.string().nullable().optional(),
modeId: z.string().nullable().optional(),
model: z.string().nullable().optional(),
@@ -56,6 +58,16 @@ const STORED_AGENT_SCHEMA = z.object({
})
.optional(),
persistence: PERSISTENCE_HANDLE_SCHEMA,
lastError: z.string().nullable().optional(),
terminalExit: z
.object({
command: z.string(),
message: z.string(),
exitCode: z.number().nullable(),
signal: z.number().nullable(),
outputLines: z.array(z.string()),
})
.optional(),
requiresAttention: z.boolean().optional(),
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
attentionTimestamp: z.string().nullable().optional(),
@@ -65,12 +77,22 @@ const STORED_AGENT_SCHEMA = z.object({
export type SerializableAgentConfig = Pick<
AgentSessionConfig,
"title" | "modeId" | "model" | "thinkingOptionId" | "extra" | "systemPrompt" | "mcpServers"
| "terminal"
| "title"
| "modeId"
| "model"
| "thinkingOptionId"
| "extra"
| "systemPrompt"
| "mcpServers"
>;
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
return STORED_AGENT_SCHEMA.parse(value);
}
export class AgentStorage {
export class AgentStorage implements AgentSnapshotStore {
private cache: Map<string, StoredAgentRecord> = new Map();
private pathById: Map<string, string> = new Map();
private pathsById: Map<string, Set<string>> = new Map();
@@ -168,19 +190,22 @@ export class AgentStorage {
async applySnapshot(
agent: ManagedAgent,
workspaceIdOrOptions?: number | { title?: string | null; internal?: boolean },
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
const nextOptions =
typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions;
await this.load();
await this.waitForPendingWrite(agent.id);
const existing = (await this.get(agent.id)) ?? null;
const hasTitleOverride =
options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "title");
const hasInternalOverride =
options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "internal");
const record = toStoredAgentRecord(agent, {
title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null),
title: hasTitleOverride ? (nextOptions?.title ?? null) : (existing?.title ?? null),
createdAt: existing?.createdAt,
internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
internal: hasInternalOverride ? nextOptions?.internal : (agent.internal ?? existing?.internal),
});
// Preserve soft-delete/archive status across snapshot flushes.
@@ -300,7 +325,7 @@ export class AgentStorage {
try {
const content = await fs.readFile(filePath, "utf8");
const parsed = JSON.parse(content);
return STORED_AGENT_SCHEMA.parse(parsed);
return parseStoredAgentRecord(parsed);
} catch (error) {
this.logger.error({ err: error, filePath }, "Skipping invalid agent record");
return null;

View File

@@ -0,0 +1,60 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
export type AgentTimelineRow = {
seq: number;
timestamp: string;
item: AgentTimelineItem;
};
export type AgentTimelineCursor = {
seq: number;
};
export type AgentTimelineFetchDirection = "tail" | "before" | "after";
export type AgentTimelineFetchOptions = {
direction?: AgentTimelineFetchDirection;
cursor?: AgentTimelineCursor;
/**
* Number of canonical rows to return.
* - undefined: store default
* - 0: all rows in the selected window
*/
limit?: number;
};
export type AgentTimelineWindow = {
minSeq: number;
maxSeq: number;
nextSeq: number;
};
export type AgentTimelineFetchResult = {
direction: AgentTimelineFetchDirection;
window: AgentTimelineWindow;
hasOlder: boolean;
hasNewer: boolean;
rows: AgentTimelineRow[];
};
export interface AgentTimelineStore {
appendCommitted(
agentId: string,
item: AgentTimelineItem,
options?: { timestamp?: string },
): Promise<AgentTimelineRow>;
fetchCommitted(
agentId: string,
options?: AgentTimelineFetchOptions,
): Promise<AgentTimelineFetchResult>;
getLatestCommittedSeq(agentId: string): Promise<number>;
getCommittedRows(agentId: string): Promise<AgentTimelineRow[]>;
getLastItem(agentId: string): Promise<AgentTimelineItem | null>;
getLastAssistantMessage(agentId: string): Promise<string | null>;
hasCommittedUserMessage(
agentId: string,
options: { messageId: string; text: string },
): Promise<boolean>;
deleteAgent(agentId: string): Promise<void>;
bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void>;
}

View File

@@ -0,0 +1,244 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type {
AgentTimelineFetchOptions,
AgentTimelineFetchResult,
AgentTimelineRow,
} from "./agent-timeline-store-types.js";
export type SeedAgentTimelineOptions = {
items?: readonly AgentTimelineItem[];
rows?: readonly AgentTimelineRow[];
nextSeq?: number;
timestamp?: string;
};
type AgentTimelineState = {
rows: AgentTimelineRow[];
nextSeq: number;
};
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
function cloneRow(row: AgentTimelineRow): AgentTimelineRow {
return { ...row };
}
function normalizeTimelineMessageId(messageId: string | undefined): string | undefined {
if (typeof messageId !== "string") {
return undefined;
}
const normalized = messageId.trim();
return normalized.length > 0 ? normalized : undefined;
}
export class InMemoryAgentTimelineStore {
private readonly states = new Map<string, AgentTimelineState>();
has(agentId: string): boolean {
return this.states.has(agentId);
}
initialize(agentId: string, options?: SeedAgentTimelineOptions): void {
const timestamp = options?.timestamp ?? new Date().toISOString();
const rows = options?.rows?.length
? options.rows.map(cloneRow)
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
const nextSeq =
options?.nextSeq ?? (rows.length ? rows[rows.length - 1]!.seq + 1 : 1);
this.states.set(agentId, {
rows,
nextSeq,
});
}
delete(agentId: string): void {
this.states.delete(agentId);
}
getItems(agentId: string): AgentTimelineItem[] {
return this.requireState(agentId).rows.map((row) => row.item);
}
getRows(agentId: string): AgentTimelineRow[] {
return this.requireState(agentId).rows.map(cloneRow);
}
fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
const state = this.requireState(agentId);
const direction = options?.direction ?? "tail";
const requestedLimit = options?.limit;
const limit =
requestedLimit === undefined
? DEFAULT_TIMELINE_FETCH_LIMIT
: Math.max(0, Math.floor(requestedLimit));
const cursor = options?.cursor;
const minSeq = state.rows.length ? state.rows[0]!.seq : 0;
const maxSeq = state.rows.length ? state.rows[state.rows.length - 1]!.seq : 0;
const selectAll = limit === 0;
const window = {
minSeq,
maxSeq,
nextSeq: state.nextSeq,
};
if (state.rows.length === 0) {
return {
direction,
window,
hasOlder: false,
hasNewer: false,
rows: [],
};
}
if (direction === "tail") {
const selected =
selectAll || limit >= state.rows.length ? state.rows : state.rows.slice(state.rows.length - limit);
return {
direction,
window,
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
hasNewer: false,
rows: selected.map(cloneRow),
};
}
if (direction === "after") {
const baseSeq = cursor?.seq ?? 0;
const startIdx = state.rows.findIndex((row) => row.seq > baseSeq);
if (startIdx < 0) {
return {
direction,
window,
hasOlder: baseSeq >= minSeq,
hasNewer: false,
rows: [],
};
}
const selected = selectAll
? state.rows.slice(startIdx)
: state.rows.slice(startIdx, startIdx + limit);
const lastSelected = selected[selected.length - 1];
return {
direction,
window,
hasOlder: selected[0]!.seq > minSeq,
hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq),
rows: selected.map(cloneRow),
};
}
const beforeSeq = cursor?.seq ?? state.nextSeq;
const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq);
const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive);
const selected =
selectAll || limit >= boundedRows.length
? boundedRows
: boundedRows.slice(boundedRows.length - limit);
return {
direction,
window,
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
hasNewer: endExclusive >= 0,
rows: selected.map(cloneRow),
};
}
append(
agentId: string,
item: AgentTimelineItem,
options?: { timestamp?: string },
): AgentTimelineRow {
const state = this.requireState(agentId);
const row: AgentTimelineRow = {
seq: state.nextSeq,
timestamp: options?.timestamp ?? new Date().toISOString(),
item,
};
state.nextSeq += 1;
state.rows.push(row);
return cloneRow(row);
}
getLastItem(agentId: string): AgentTimelineItem | null {
const state = this.requireState(agentId);
return state.rows[state.rows.length - 1]?.item ?? null;
}
getLastAssistantMessage(agentId: string): string | null {
const rows = this.requireState(agentId).rows;
const chunks: string[] = [];
for (let i = rows.length - 1; i >= 0; i -= 1) {
const item = rows[i]!.item;
if (item.type !== "assistant_message") {
if (chunks.length > 0) {
break;
}
continue;
}
chunks.push(item.text);
}
if (chunks.length === 0) {
return null;
}
return chunks.reverse().join("");
}
getCanonicalUserMessagesById(agentId: string): Map<string, string> {
const entries = this.requireState(agentId).rows.flatMap<[string, string]>((row) => {
if (row.item.type !== "user_message") {
return [];
}
const messageId = normalizeTimelineMessageId(row.item.messageId);
if (!messageId) {
return [];
}
return [[messageId, row.item.text]];
});
return new Map(entries);
}
hasCommittedUserMessage(agentId: string, options: { messageId: string; text: string }): boolean {
const messageId = normalizeTimelineMessageId(options.messageId);
if (!messageId) {
return false;
}
return this.requireState(agentId).rows.some((row) => {
if (row.item.type !== "user_message") {
return false;
}
const rowMessageId = normalizeTimelineMessageId(row.item.messageId);
return rowMessageId === messageId && row.item.text === options.text;
});
}
private requireState(agentId: string): AgentTimelineState {
const state = this.states.get(agentId);
if (!state) {
throw new Error(`Unknown agent '${agentId}'`);
}
return state;
}
private buildRowsFromItems(
items: readonly AgentTimelineItem[],
startSeq: number,
timestamp: string,
): AgentTimelineRow[] {
let nextSeq = startSeq;
return items.map((item) => {
const row: AgentTimelineRow = {
seq: nextSeq,
timestamp,
item,
};
nextSeq += 1;
return row;
});
}
}

View File

@@ -6,11 +6,11 @@ import { tmpdir } from "node:os";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { createAgentMcpServer } from "./mcp-server.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
type TestDeps = {
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
spies: {
agentManager: Record<string, any>;
agentStorage: Record<string, any>;
@@ -41,7 +41,7 @@ function createTestDeps(): TestDeps {
return {
agentManager: agentManagerSpies as unknown as AgentManager,
agentStorage: agentStorageSpies as unknown as AgentStorage,
agentStorage: agentStorageSpies as unknown as AgentSnapshotStore,
spies: {
agentManager: agentManagerSpies,
agentStorage: agentStorageSpies,

View File

@@ -16,7 +16,7 @@ import {
import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
@@ -31,7 +31,7 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-boot
export interface AgentMcpServerOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null;
paseoHome?: string;
/**
@@ -240,7 +240,7 @@ function sanitizePermissionRequest(
}
async function resolveAgentTitle(
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
agentId: string,
logger: Logger,
): Promise<string | null> {
@@ -254,7 +254,7 @@ async function resolveAgentTitle(
}
async function serializeSnapshotWithMetadata(
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
snapshot: ManagedAgent,
logger: Logger,
) {

View File

@@ -180,6 +180,14 @@ export function applyProviderEnv(
return merged;
}
export function sanitizeTerminalEnv(
env: Record<string, string | undefined>,
): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
);
}
/**
* Resolve an executable name to its absolute path the way the user's shell would.
*

View File

@@ -122,6 +122,27 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModel: "gpt-5.1-codex-mini",
},
},
{
id: "gemini",
label: "Gemini CLI",
description: "Google's terminal-based coding agent",
defaultModeId: null,
modes: [],
},
{
id: "amp",
label: "AMP",
description: "Sourcegraph's terminal-based coding agent",
defaultModeId: null,
modes: [],
},
{
id: "aider",
label: "Aider",
description: "Paul Gauthier's terminal-based coding assistant",
defaultModeId: null,
modes: [],
},
{
id: "opencode",
label: "OpenCode",

View File

@@ -9,6 +9,9 @@ import type { Logger } from "pino";
import { ClaudeAgentClient } from "./providers/claude-agent.js";
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
import { GeminiAgentClient } from "./providers/gemini-agent.js";
import { AmpAgentClient } from "./providers/amp-agent.js";
import { AiderAgentClient } from "./providers/aider-agent.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
import {
@@ -40,6 +43,9 @@ export function buildProviderRegistry(
runtimeSettings: runtimeSettings?.claude,
});
const codexClient = new CodexAppServerAgentClient(logger, runtimeSettings?.codex);
const geminiClient = new GeminiAgentClient(runtimeSettings?.gemini);
const ampClient = new AmpAgentClient(runtimeSettings?.amp);
const aiderClient = new AiderAgentClient(runtimeSettings?.aider);
const opencodeClient = new OpenCodeAgentClient(logger, runtimeSettings?.opencode);
return {
@@ -55,6 +61,21 @@ export function buildProviderRegistry(
new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
fetchModels: (options) => codexClient.listModels(options),
},
gemini: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "gemini")!,
createClient: () => new GeminiAgentClient(runtimeSettings?.gemini),
fetchModels: (options) => geminiClient.listModels(options),
},
amp: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "amp")!,
createClient: () => new AmpAgentClient(runtimeSettings?.amp),
fetchModels: (options) => ampClient.listModels(options),
},
aider: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "aider")!,
createClient: () => new AiderAgentClient(runtimeSettings?.aider),
fetchModels: (options) => aiderClient.listModels(options),
},
opencode: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!,
createClient: (logger: Logger) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
@@ -74,6 +95,9 @@ export function createAllClients(
return {
claude: registry.claude.createClient(logger),
codex: registry.codex.createClient(logger),
gemini: registry.gemini.createClient(logger),
amp: registry.amp.createClient(logger),
aider: registry.aider.createClient(logger),
opencode: registry.opencode.createClient(logger),
};
}

View File

@@ -0,0 +1,110 @@
import { existsSync } from "node:fs";
import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentModelDefinition,
AgentPersistenceHandle,
AgentSession,
AgentSessionConfig,
ListModelsOptions,
TerminalCommand,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
findExecutable,
isProviderCommandAvailable,
resolveProviderCommandPrefix,
sanitizeTerminalEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
const AIDER_PROVIDER = "aider" as const;
const AIDER_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsTerminalMode: true,
};
type AiderAgentConfig = AgentSessionConfig & { provider: "aider" };
function resolveAiderBinary(): string {
const found = findExecutable("aider");
if (found) {
return found;
}
throw new Error(
"Aider binary not found. Install Aider and ensure 'aider' is available in your shell PATH.",
);
}
function createUnsupportedSessionError(): Error {
return new Error("Aider currently supports terminal mode only in Paseo.");
}
export class AiderAgentClient implements AgentClient {
readonly provider = AIDER_PROVIDER;
readonly capabilities = AIDER_CAPABILITIES;
constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {}
async createSession(
_config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
throw createUnsupportedSessionError();
}
async resumeSession(
_handle: AgentPersistenceHandle,
_overrides?: Partial<AgentSessionConfig>,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
throw createUnsupportedSessionError();
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
return [];
}
buildTerminalCreateCommand(
config: AgentSessionConfig,
_handle: AgentPersistenceHandle,
_initialPrompt?: string,
): TerminalCommand {
this.assertConfig(config);
const launchPrefix = resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveAiderBinary,
);
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
// Aider uses positional arguments for file paths, not interactive prompts.
args: [...launchPrefix.args, "--no-auto-commits"],
env: terminalEnv,
};
}
async isAvailable(): Promise<boolean> {
if (this.runtimeSettings?.command?.mode === "replace") {
return existsSync(this.runtimeSettings.command.argv[0]);
}
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAiderBinary);
}
private assertConfig(config: AgentSessionConfig): AiderAgentConfig {
if (config.provider !== AIDER_PROVIDER) {
throw new Error(`AiderAgentClient received config for provider '${config.provider}'`);
}
return { ...config, provider: AIDER_PROVIDER };
}
}

View File

@@ -0,0 +1,109 @@
import { existsSync } from "node:fs";
import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentModelDefinition,
AgentPersistenceHandle,
AgentSession,
AgentSessionConfig,
ListModelsOptions,
TerminalCommand,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
findExecutable,
isProviderCommandAvailable,
resolveProviderCommandPrefix,
sanitizeTerminalEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
const AMP_PROVIDER = "amp" as const;
const AMP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsTerminalMode: true,
};
type AmpAgentConfig = AgentSessionConfig & { provider: "amp" };
function resolveAmpBinary(): string {
const found = findExecutable("amp");
if (found) {
return found;
}
throw new Error(
"AMP binary not found. Install AMP and ensure 'amp' is available in your shell PATH.",
);
}
function createUnsupportedSessionError(): Error {
return new Error("AMP currently supports terminal mode only in Paseo.");
}
export class AmpAgentClient implements AgentClient {
readonly provider = AMP_PROVIDER;
readonly capabilities = AMP_CAPABILITIES;
constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {}
async createSession(
_config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
throw createUnsupportedSessionError();
}
async resumeSession(
_handle: AgentPersistenceHandle,
_overrides?: Partial<AgentSessionConfig>,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
throw createUnsupportedSessionError();
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
return [];
}
buildTerminalCreateCommand(
config: AgentSessionConfig,
_handle: AgentPersistenceHandle,
_initialPrompt?: string,
): TerminalCommand {
this.assertConfig(config);
const launchPrefix = resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveAmpBinary,
);
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
args: [...launchPrefix.args],
env: terminalEnv,
};
}
async isAvailable(): Promise<boolean> {
if (this.runtimeSettings?.command?.mode === "replace") {
return existsSync(this.runtimeSettings.command.argv[0]);
}
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAmpBinary);
}
private assertConfig(config: AgentSessionConfig): AmpAgentConfig {
if (config.provider !== AMP_PROVIDER) {
throw new Error(`AmpAgentClient received config for provider '${config.provider}'`);
}
return { ...config, provider: AMP_PROVIDER };
}
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import type { ModelInfo, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
@@ -357,4 +357,52 @@ describe("ClaudeAgentClient.listModels", () => {
]);
expect(queryMock.return).toHaveBeenCalledTimes(1);
});
test("keeps the Claude control-plane query open until supportedModels resolves", async () => {
const queryMock = createSupportedModelsQueryMock([
{
value: "default",
displayName: "Default (recommended)",
description: "Sonnet 4.6 · Best for everyday tasks",
},
] satisfies ModelInfo[]);
let promptIterator: AsyncIterator<SDKUserMessage, void> | null = null;
let promptNextPromise: Promise<IteratorResult<SDKUserMessage, void>> | null = null;
let promptClosedBeforeModelsResolved = false;
queryMock.supportedModels = vi.fn(async () => {
promptNextPromise = promptIterator?.next() ?? null;
if (!promptNextPromise) {
throw new Error("Prompt iterator not captured");
}
promptNextPromise.then(() => {
promptClosedBeforeModelsResolved = true;
});
await Promise.resolve();
expect(promptClosedBeforeModelsResolved).toBe(false);
return [
{
value: "default",
displayName: "Default (recommended)",
description: "Sonnet 4.6 · Best for everyday tasks",
},
] satisfies ModelInfo[];
});
const queryFactory = vi.fn(({ prompt }) => {
promptIterator = prompt[Symbol.asyncIterator]();
return queryMock;
});
const client = new ClaudeAgentClient({
logger,
queryFactory: queryFactory as never,
});
const models = await client.listModels({ cwd: process.cwd() });
expect(models).toHaveLength(1);
expect(promptNextPromise).not.toBeNull();
await expect(promptNextPromise).resolves.toEqual({ done: true, value: undefined });
expect(queryMock.return).toHaveBeenCalledTimes(1);
});
});

View File

@@ -66,10 +66,12 @@ import type {
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
TerminalCommand,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
findExecutable,
sanitizeTerminalEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
@@ -102,6 +104,7 @@ const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: true,
};
const DEFAULT_MODES: AgentMode[] = [
@@ -233,10 +236,6 @@ function applyRuntimeSettingsToClaudeOptions(
};
}
function createEmptyClaudePrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
return (async function* empty() {})();
}
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
return value === "low" || value === "medium" || value === "high" || value === "max";
}
@@ -1045,8 +1044,9 @@ export class ClaudeAgentClient implements AgentClient {
}
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
const input = createAsyncMessageInput<SDKUserMessage>();
const claudeQuery = this.queryFactory({
prompt: createEmptyClaudePrompt(),
prompt: input.iterable,
options: applyRuntimeSettingsToClaudeOptions(
{
cwd: options?.cwd ?? process.cwd(),
@@ -1065,13 +1065,13 @@ export class ClaudeAgentClient implements AgentClient {
this.logger.warn({ err: error }, "Failed to query Claude supportedModels()");
throw error;
} finally {
input.end();
try {
await claudeQuery.return?.();
} catch {
// ignore control-plane shutdown errors
}
}
}
async listPersistedAgents(
@@ -1099,6 +1099,73 @@ export class ClaudeAgentClient implements AgentClient {
return descriptors;
}
buildTerminalCreateCommand(
config: AgentSessionConfig,
handle: AgentPersistenceHandle,
initialPrompt?: string,
): TerminalCommand {
const claudeConfig = this.assertConfig(config);
const baseCommand = findExecutable("claude") ?? "claude";
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
const spawnCommand = resolveClaudeSpawnCommand(
{
command: baseCommand,
args: [],
cwd: claudeConfig.cwd,
env: terminalEnv,
signal: new AbortController().signal,
},
this.runtimeSettings,
);
const args = [...spawnCommand.args, "--session-id", handle.sessionId];
if (claudeConfig.modeId === "bypassPermissions") {
args.push("--dangerously-skip-permissions");
} else if (claudeConfig.modeId) {
args.push("--permission-mode", claudeConfig.modeId);
}
if (claudeConfig.model) {
args.push("--model", claudeConfig.model);
}
if (claudeConfig.thinkingOptionId && claudeConfig.thinkingOptionId !== "default") {
args.push("--effort", claudeConfig.thinkingOptionId);
}
if (claudeConfig.systemPrompt?.trim()) {
args.push("--append-system-prompt", claudeConfig.systemPrompt.trim());
}
if (initialPrompt?.trim()) {
args.push(initialPrompt.trim());
}
return {
command: spawnCommand.command,
args,
env: terminalEnv,
};
}
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
const baseCommand = findExecutable("claude") ?? "claude";
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
const spawnCommand = resolveClaudeSpawnCommand(
{
command: baseCommand,
args: [],
cwd: process.cwd(),
env: terminalEnv,
signal: new AbortController().signal,
},
this.runtimeSettings,
);
return {
command: spawnCommand.command,
args: [...spawnCommand.args, "--resume", handle.sessionId],
env: terminalEnv,
};
}
async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command;
if (command?.mode === "replace") {

View File

@@ -19,9 +19,11 @@ import type {
AgentTimelineItem,
ToolCallTimelineItem,
AgentUsage,
AgentPersistenceHandle,
ListModelsOptions,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
TerminalCommand,
} from "../agent-sdk-types.js";
import type { Logger } from "pino";
@@ -43,6 +45,7 @@ import {
applyProviderEnv,
findExecutable,
resolveProviderCommandPrefix,
sanitizeTerminalEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
@@ -59,6 +62,7 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: true,
};
const CODEX_MODES: AgentMode[] = [
@@ -3532,6 +3536,59 @@ export class CodexAppServerAgentClient implements AgentClient {
}
}
buildTerminalCreateCommand(
config: AgentSessionConfig,
handle: AgentPersistenceHandle,
initialPrompt?: string,
): TerminalCommand {
const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings);
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
const modeId = sessionConfig.modeId ?? DEFAULT_CODEX_MODE_ID;
validateCodexMode(modeId);
const preset = MODE_PRESETS[modeId] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
const approvalPolicy = sessionConfig.approvalPolicy ?? preset.approvalPolicy;
const sandbox = sessionConfig.sandboxMode ?? preset.sandbox;
const args = [...launchPrefix.args, "-c", `sessionId=\"${handle.sessionId}\"`];
if (sessionConfig.model) {
args.push("--model", sessionConfig.model);
}
args.push("--ask-for-approval", approvalPolicy, "--sandbox", sandbox);
if (
typeof sessionConfig.networkAccess === "boolean"
? sessionConfig.networkAccess
: preset.networkAccess === true
) {
args.push("--search");
}
if (initialPrompt?.trim()) {
args.push(initialPrompt.trim());
}
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
args,
env: terminalEnv,
};
}
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings);
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
args: [
...launchPrefix.args,
"resume",
handle.nativeHandle ?? handle.sessionId,
],
env: terminalEnv,
};
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
const child = this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);

View File

@@ -0,0 +1,128 @@
import { existsSync } from "node:fs";
import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentModelDefinition,
AgentPersistenceHandle,
AgentSession,
AgentSessionConfig,
ListModelsOptions,
TerminalCommand,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
findExecutable,
isProviderCommandAvailable,
resolveProviderCommandPrefix,
sanitizeTerminalEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
const GEMINI_PROVIDER = "gemini" as const;
const GEMINI_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsTerminalMode: true,
};
type GeminiAgentConfig = AgentSessionConfig & { provider: "gemini" };
function resolveGeminiBinary(): string {
const found = findExecutable("gemini");
if (found) {
return found;
}
throw new Error(
"Gemini CLI binary not found. Install Gemini CLI and ensure 'gemini' is available in your shell PATH.",
);
}
function createUnsupportedSessionError(): Error {
return new Error("Gemini CLI currently supports terminal mode only in Paseo.");
}
export class GeminiAgentClient implements AgentClient {
readonly provider = GEMINI_PROVIDER;
readonly capabilities = GEMINI_CAPABILITIES;
constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {}
async createSession(
_config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
throw createUnsupportedSessionError();
}
async resumeSession(
_handle: AgentPersistenceHandle,
_overrides?: Partial<AgentSessionConfig>,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
throw createUnsupportedSessionError();
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
return [];
}
buildTerminalCreateCommand(
config: AgentSessionConfig,
_handle: AgentPersistenceHandle,
initialPrompt?: string,
): TerminalCommand {
this.assertConfig(config);
const launchPrefix = resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveGeminiBinary,
);
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
const args = [...launchPrefix.args];
if (initialPrompt?.trim()) {
args.push("-i", initialPrompt.trim());
}
return {
command: launchPrefix.command,
args,
env: terminalEnv,
};
}
buildTerminalResumeCommand(_handle: AgentPersistenceHandle): TerminalCommand {
const launchPrefix = resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveGeminiBinary,
);
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
args: [...launchPrefix.args, "--resume"],
env: terminalEnv,
};
}
async isAvailable(): Promise<boolean> {
if (this.runtimeSettings?.command?.mode === "replace") {
return existsSync(this.runtimeSettings.command.argv[0]);
}
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveGeminiBinary);
}
private assertConfig(config: AgentSessionConfig): GeminiAgentConfig {
if (config.provider !== GEMINI_PROVIDER) {
throw new Error(`GeminiAgentClient received config for provider '${config.provider}'`);
}
return { ...config, provider: GEMINI_PROVIDER };
}
}

View File

@@ -28,11 +28,13 @@ import type {
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
TerminalCommand,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
findExecutable,
resolveProviderCommandPrefix,
sanitizeTerminalEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
@@ -44,6 +46,7 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: true,
};
const DEFAULT_MODES: AgentMode[] = [
@@ -559,6 +562,53 @@ export class OpenCodeAgentClient implements AgentClient {
return [];
}
buildTerminalCreateCommand(
config: AgentSessionConfig,
handle: AgentPersistenceHandle,
initialPrompt?: string,
): TerminalCommand {
const launchPrefix = resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveOpenCodeBinary,
);
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
const args = [...launchPrefix.args, "--session", handle.nativeHandle ?? handle.sessionId];
if (config.cwd) {
args.push(config.cwd);
}
if (config.model) {
args.push("--model", config.model);
}
if (config.modeId) {
args.push("--agent", config.modeId);
}
if (initialPrompt?.trim()) {
args.push(initialPrompt.trim());
}
return {
command: launchPrefix.command,
args,
env: terminalEnv,
};
}
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
const launchPrefix = resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveOpenCodeBinary,
);
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
args: [...launchPrefix.args, "--session", handle.nativeHandle ?? handle.sessionId],
env: terminalEnv,
};
}
async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command;
if (command?.mode === "replace") {

View File

@@ -0,0 +1,103 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import type { AgentSessionConfig } from "../agent-sdk-types.js";
import { AiderAgentClient } from "./aider-agent.js";
import { AmpAgentClient } from "./amp-agent.js";
import { GeminiAgentClient } from "./gemini-agent.js";
function createExecutable(): string {
const dir = mkdtempSync(path.join(os.tmpdir(), "terminal-provider-test-"));
const file = path.join(dir, "provider-bin");
writeFileSync(file, "#!/bin/sh\nexit 0\n");
chmodSync(file, 0o755);
return file;
}
const buildConfig = (provider: "gemini" | "amp" | "aider"): AgentSessionConfig => ({
provider,
cwd: "/tmp/worktree",
terminal: true,
});
describe("terminal-only providers", () => {
test("Gemini builds an interactive prompt command without injecting cwd flags", () => {
const executable = createExecutable();
try {
const client = new GeminiAgentClient({
command: { mode: "replace", argv: [executable] },
});
const command = client.buildTerminalCreateCommand(
buildConfig("gemini"),
{ provider: "gemini", sessionId: "session-1" },
"Fix the bug",
);
expect(command.command).toBe(executable);
expect(command.args).toEqual(["-i", "Fix the bug"]);
} finally {
rmSync(path.dirname(executable), { recursive: true, force: true });
}
});
test("AMP launches without unsupported cwd flags", () => {
const executable = createExecutable();
try {
const client = new AmpAgentClient({
command: { mode: "replace", argv: [executable] },
});
const command = client.buildTerminalCreateCommand(buildConfig("amp"), {
provider: "amp",
sessionId: "session-1",
});
expect(command.command).toBe(executable);
expect(command.args).toEqual([]);
} finally {
rmSync(path.dirname(executable), { recursive: true, force: true });
}
});
test("Aider does not treat initial prompts as positional CLI arguments", () => {
const executable = createExecutable();
try {
const client = new AiderAgentClient({
command: { mode: "replace", argv: [executable] },
});
const command = client.buildTerminalCreateCommand(
buildConfig("aider"),
{ provider: "aider", sessionId: "session-1" },
"Refactor the parser",
);
expect(command.command).toBe(executable);
expect(command.args).toEqual(["--no-auto-commits"]);
} finally {
rmSync(path.dirname(executable), { recursive: true, force: true });
}
});
test("provider availability respects missing replacement binaries", async () => {
const missingPath = path.join(os.tmpdir(), "missing-terminal-provider");
await expect(
new GeminiAgentClient({
command: { mode: "replace", argv: [missingPath] },
}).isAvailable(),
).resolves.toBe(false);
await expect(
new AmpAgentClient({
command: { mode: "replace", argv: [missingPath] },
}).isAvailable(),
).resolves.toBe(false);
await expect(
new AiderAgentClient({
command: { mode: "replace", argv: [missingPath] },
}).isAvailable(),
).resolves.toBe(false);
});
});

View File

@@ -1,5 +1,6 @@
import os from "node:os";
import path from "node:path";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { Writable } from "node:stream";
import pino from "pino";
@@ -8,6 +9,8 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { openPaseoDatabase } from "./db/sqlite-database.js";
import { agentSnapshots, projects, workspaces } from "./db/schema.js";
describe("paseo daemon bootstrap", () => {
afterEach(() => {
@@ -199,4 +202,395 @@ describe("paseo daemon bootstrap", () => {
await rm(staticDir, { recursive: true, force: true });
}
});
test("imports legacy project and workspace JSON into the DB on first bootstrap", async () => {
const { config, cleanup } = await createBootstrapConfig();
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspaces: [
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try {
await daemon.start();
await daemon.stop();
expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true);
const database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
try {
const projectRows = await database.db.select().from(projects);
expect(projectRows).toHaveLength(1);
expect(projectRows[0]).toMatchObject({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
});
const workspaceRows = await database.db.select().from(workspaces);
expect(workspaceRows).toHaveLength(1);
expect(workspaceRows[0]).toMatchObject({
projectId: projectRows[0]!.id,
directory: "/tmp/project-1",
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
});
} finally {
await database.close();
}
} finally {
await cleanup();
}
});
test("does not duplicate imported legacy JSON across daemon restarts", async () => {
const { config, cleanup } = await createBootstrapConfig();
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspaces: [
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
try {
const firstDaemon = await createPaseoDaemon(config, pino({ level: "silent" }));
await firstDaemon.start();
await firstDaemon.stop();
const secondDaemon = await createPaseoDaemon(config, pino({ level: "silent" }));
await secondDaemon.start();
await secondDaemon.stop();
expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true);
const database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
try {
expect(await database.db.select().from(projects)).toHaveLength(1);
expect(await database.db.select().from(workspaces)).toHaveLength(1);
} finally {
await database.close();
}
} finally {
await cleanup();
}
});
test("imports legacy project, workspace, and agent JSON into one SQLite bootstrap without duplicating records", async () => {
const { config, cleanup } = await createBootstrapConfig();
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspaces: [
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
writeLegacyAgentJson(config.paseoHome, "agents/agent-1.json", {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project-1",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
lastUserMessageAt: null,
title: "Imported Agent",
labels: {},
lastStatus: "idle",
lastModeId: "plan",
config: { model: "gpt-5.1-codex-mini", modeId: "plan" },
runtimeInfo: {
provider: "codex",
sessionId: "session-123",
model: "gpt-5.1-codex-mini",
modeId: "plan",
},
persistence: null,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
});
try {
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
await daemon.start();
await daemon.stop();
expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true);
const database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
try {
const projectRows = await database.db.select().from(projects);
const workspaceRows = await database.db.select().from(workspaces);
const agentRows = await database.db.select().from(agentSnapshots);
expect(projectRows).toHaveLength(1);
expect(workspaceRows).toHaveLength(1);
expect(agentRows).toEqual([
expect.objectContaining({
agentId: "agent-1",
cwd: "/tmp/project-1",
workspaceId: workspaceRows[0]!.id,
title: "Imported Agent",
requiresAttention: false,
internal: false,
}),
]);
} finally {
await database.close();
}
} finally {
await cleanup();
}
});
test("imports large legacy agent JSON batches during SQLite bootstrap", async () => {
const { config, cleanup } = await createBootstrapConfig();
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspaces: [
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
for (let index = 0; index < 150; index += 1) {
writeLegacyAgentJson(config.paseoHome, `agents/project-1/agent-${index}.json`, {
id: `agent-${index}`,
provider: "codex",
cwd: "/tmp/project-1",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
lastUserMessageAt: null,
title: `Imported Agent ${index}`,
labels: {},
lastStatus: "idle",
lastModeId: "plan",
config: { model: "gpt-5.1-codex-mini", modeId: "plan" },
runtimeInfo: {
provider: "codex",
sessionId: `session-${index}`,
model: "gpt-5.1-codex-mini",
modeId: "plan",
},
persistence: null,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
});
}
try {
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
await daemon.start();
await daemon.stop();
expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true);
const database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
try {
const projectRows = await database.db.select().from(projects);
const workspaceRows = await database.db.select().from(workspaces);
const agentRows = await database.db.select().from(agentSnapshots);
expect(projectRows).toHaveLength(1);
expect(workspaceRows).toHaveLength(1);
expect(agentRows).toHaveLength(150);
expect(agentRows[0]?.workspaceId).toBe(workspaceRows[0]!.id);
expect(agentRows.map((row) => row.agentId)).toContain("agent-149");
} finally {
await database.close();
}
} finally {
await cleanup();
}
});
test("reconciles workspace records into the DB without recreating legacy JSON registry files", async () => {
const { config, cleanup } = await createBootstrapConfig();
const agentStorageDir = path.join(config.paseoHome, "agents");
mkdirSync(agentStorageDir, { recursive: true });
const storageBucket = path.join(agentStorageDir, "tmp-db-only-project");
mkdirSync(storageBucket, { recursive: true });
writeFileSync(
path.join(storageBucket, "agent-1.json"),
JSON.stringify(
{
id: "agent-1",
provider: "codex",
cwd: "/tmp/db-only-project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
},
null,
2,
),
"utf8",
);
try {
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
await daemon.start();
await daemon.stop();
expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true);
const database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
try {
expect(await database.db.select().from(agentSnapshots)).toEqual([
expect.objectContaining({
agentId: "agent-1",
cwd: "/tmp/db-only-project",
requiresAttention: false,
internal: false,
}),
]);
expect(await database.db.select().from(projects)).toHaveLength(1);
expect(await database.db.select().from(workspaces)).toHaveLength(1);
} finally {
await database.close();
}
expect(existsSync(path.join(config.paseoHome, "projects", "projects.json"))).toBe(false);
expect(existsSync(path.join(config.paseoHome, "projects", "workspaces.json"))).toBe(false);
} finally {
await cleanup();
}
});
});
async function createBootstrapConfig(): Promise<{
config: PaseoDaemonConfig;
cleanup: () => Promise<void>;
}> {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-db-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
await mkdir(paseoHome, { recursive: true });
return {
config: {
listen: "127.0.0.1:0",
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: false,
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
},
cleanup: async () => {
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
},
};
}
function writeLegacyProjectWorkspaceJson(
paseoHome: string,
input: {
projects: unknown[];
workspaces: unknown[];
},
): void {
const projectsDir = path.join(paseoHome, "projects");
mkdirSync(projectsDir, { recursive: true });
writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify(input.projects, null, 2), "utf8");
writeFileSync(path.join(projectsDir, "workspaces.json"), JSON.stringify(input.workspaces, null, 2), "utf8");
}
function writeLegacyAgentJson(paseoHome: string, relativePath: string, payload: Record<string, unknown>): void {
const absolutePath = path.join(paseoHome, relativePath);
mkdirSync(path.dirname(absolutePath), { recursive: true });
writeFileSync(absolutePath, JSON.stringify(payload, null, 2), "utf8");
}

View File

@@ -93,12 +93,17 @@ import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
import { createSpeechService } from "./speech/speech-runtime.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { DbAgentSnapshotStore } from "./db/db-agent-snapshot-store.js";
import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js";
import { DbProjectRegistry } from "./db/db-project-registry.js";
import { DbWorkspaceRegistry } from "./db/db-workspace-registry.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import { importLegacyAgentSnapshots } from "./db/legacy-agent-snapshot-import.js";
import { importLegacyProjectWorkspaceJson } from "./db/legacy-project-workspace-import.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./db/sqlite-database.js";
import { FileBackedChatService } from "./chat/chat-service.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { LoopService } from "./loop-service.js";
@@ -187,7 +192,7 @@ export type PaseoDaemonConfig = {
export interface PaseoDaemon {
config: PaseoDaemonConfig;
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
terminalManager: TerminalManager;
start(): Promise<void>;
stop(): Promise<void>;
@@ -202,6 +207,7 @@ export async function createPaseoDaemon(
const bootstrapStart = performance.now();
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
const daemonVersion = resolveDaemonVersion(import.meta.url);
let database: PaseoDatabaseHandle | null = null;
try {
const serverId = getOrCreateServerId(config.paseoHome, { logger });
@@ -352,20 +358,33 @@ export async function createPaseoDaemon(
const httpServer = createHTTPServer(app);
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
const projectRegistry = new FileBackedProjectRegistry(
path.join(config.paseoHome, "projects", "projects.json"),
logger,
);
const workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(config.paseoHome, "projects", "workspaces.json"),
logger,
);
database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
logger.info({ elapsed: elapsed() }, "Paseo database opened");
const agentStorage = new DbAgentSnapshotStore(database.db);
const chatService = new FileBackedChatService({
paseoHome: config.paseoHome,
logger,
});
const agentManager = new AgentManager({
const durableTimelineStore = new DbAgentTimelineStore(database.db);
let agentManager: AgentManager | null = null;
const terminalManager = createTerminalManager({
resolveAgentIdForTerminal: (terminalId) => agentManager?.getAgentIdForTerminal(terminalId) ?? null,
onAgentBoundTerminalTitleChange: async ({ agentId, title }) => {
if (!agentManager) {
return;
}
try {
await agentManager.setTitle(agentId, title);
} catch (error) {
logger.warn(
{ err: error, agentId },
"Failed to propagate bound terminal title to agent state",
);
}
},
});
agentManager = new AgentManager({
clients: {
...createAllClients(logger, {
runtimeSettings: config.agentProviderSettings,
@@ -373,26 +392,34 @@ export async function createPaseoDaemon(
...config.agentClients,
},
registry: agentStorage,
durableTimelineStore,
terminalManager,
logger,
});
const terminalManager = createTerminalManager();
const projectRegistry = new DbProjectRegistry(database.db);
const workspaceRegistry = new DbWorkspaceRegistry(database.db);
const detachAgentStoragePersistence = attachAgentStoragePersistence(
logger,
agentManager,
agentStorage,
);
await agentStorage.initialize();
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
await bootstrapWorkspaceRegistries({
paseoHome: config.paseoHome,
agentStorage,
const reconciliationService = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger,
});
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
reconciliationService.start();
logger.info({ elapsed: elapsed() }, "Workspace reconciliation service started");
await importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome: config.paseoHome,
logger,
});
logger.info({ elapsed: elapsed() }, "Legacy project/workspace import checked");
await importLegacyAgentSnapshots({
db: database.db,
paseoHome: config.paseoHome,
logger,
});
logger.info({ elapsed: elapsed() }, "Legacy agent snapshot import checked");
await chatService.initialize();
logger.info({ elapsed: elapsed() }, "Chat service initialized");
const checkoutDiffManager = new CheckoutDiffManager({
@@ -732,10 +759,9 @@ export async function createPaseoDaemon(
};
const stop = async () => {
reconciliationService.stop();
await closeAllAgents(logger, agentManager);
await agentManager.flush().catch(() => undefined);
detachAgentStoragePersistence();
await agentStorage.flush().catch(() => undefined);
await shutdownProviders(logger, {
runtimeSettings: config.agentProviderSettings,
});
@@ -749,6 +775,7 @@ export async function createPaseoDaemon(
if (voiceMcpBridgeManager) {
await voiceMcpBridgeManager.stop().catch(() => undefined);
}
await database?.close().catch(() => undefined);
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
@@ -768,6 +795,7 @@ export async function createPaseoDaemon(
getListenTarget: () => boundListenTarget,
};
} catch (err) {
await database?.close().catch(() => undefined);
throw err;
}
}

View File

@@ -224,6 +224,8 @@ describe("daemon client E2E", () => {
expect(archivedResult).not.toBeNull();
expect(archivedResult?.agent.archivedAt).toBeTruthy();
expect(archivedResult?.agent.status).not.toBe("running");
expect(archivedResult?.agent.requiresAttention).toBe(false);
expect(archivedResult?.agent.attentionReason).toBeNull();
expect(archivedResult?.project).not.toBeNull();
expect(archivedResult?.project?.checkout.cwd).toBe(cwd);
@@ -309,6 +311,42 @@ describe("daemon client E2E", () => {
}
}, 180000);
test("update_agent persists unloaded title and labels across auto-unarchive", async () => {
const cwd = tmpCwd();
try {
const created = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd,
},
});
await ctx.client.archiveAgent(created.id);
await ctx.client.updateAgent(created.id, {
name: "Pinned Title",
labels: { lane: "phase-1a" },
});
const archived = await ctx.client.fetchAgent(created.id);
expect(archived).not.toBeNull();
expect(archived?.agent.archivedAt).toBeTruthy();
expect(archived?.agent.title).toBe("Pinned Title");
expect(archived?.agent.labels).toMatchObject({ lane: "phase-1a" });
await ctx.client.sendMessage(created.id, "Say hello and nothing else");
const finalState = await ctx.client.waitForFinish(created.id, 120000);
expect(finalState.status).toBe("idle");
const unarchived = await ctx.client.fetchAgent(created.id);
expect(unarchived).not.toBeNull();
expect(unarchived?.agent.archivedAt).toBeNull();
expect(unarchived?.agent.title).toBe("Pinned Title");
expect(unarchived?.agent.labels).toMatchObject({ lane: "phase-1a" });
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 180000);
test("returns home-scoped directory suggestions", async () => {
const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-"));
const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-"));
@@ -529,7 +567,6 @@ describe("daemon client E2E", () => {
const timelineResult = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 1,
projection: "projected",
});
expect(timelineResult.agentId).toBe(agent.id);
@@ -756,7 +793,6 @@ describe("daemon client E2E", () => {
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
expect(timeline.entries.length).toBeGreaterThan(0);

View File

@@ -71,7 +71,6 @@ describe("daemon E2E", () => {
await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 200,
projection: "projected",
});
const refreshedResult = await ctx.client.fetchAgent(agent.id);
@@ -89,7 +88,6 @@ describe("daemon E2E", () => {
await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 200,
projection: "projected",
});
const clearResult = await ctx.client.fetchAgent(agent.id);

View File

@@ -60,7 +60,6 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => {
const timelineAtIdle = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const idleAssistantText = timelineAtIdle.entries
.filter(
@@ -91,7 +90,6 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => {
const finalTimeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const finalAssistantText = finalTimeline.entries
.filter(

View File

@@ -390,7 +390,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAtIdle = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
await client.waitForAgentUpsert(
@@ -405,7 +404,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAfterWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual(
timelineAtIdle.entries.length,
@@ -417,7 +415,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const nextTimeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
sawTimelineGrowth = nextTimeline.entries.length > timelineAtIdle.entries.length;
}
@@ -605,7 +602,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineBeforeWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const summarized = timelineBeforeWake.entries.map(summarizeTimelineEntry);
// Required by reproduction request: log timeline at idle edge before autonomous wake.
@@ -735,7 +731,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAtWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
// eslint-disable-next-line no-console
@@ -751,7 +746,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAfterWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual(
timelineAtWake.entries.length,
@@ -916,7 +910,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAtWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
// eslint-disable-next-line no-console
@@ -932,7 +925,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAfterWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual(
timelineAtWake.entries.length,
@@ -1066,7 +1058,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
assistantTexts = timeline.entries
.filter(

View File

@@ -114,11 +114,14 @@ describe("daemon E2E - persistence", () => {
const timeline = await ctx.client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const timelineItems = timeline.entries.map((entry) => entry.item);
expect(timelineItems.length).toBeGreaterThan(0);
expect(timelineItems.some((item) => item.type === "assistant_message")).toBe(true);
const assistantMessages = timelineItems.filter(
(item): item is Extract<(typeof timelineItems)[number], { type: "assistant_message" }> =>
item.type === "assistant_message",
);
expect(assistantMessages).toEqual([{ type: "assistant_message", text: "timeline test" }]);
} finally {
await ctx.cleanup();
cleaned = true;

View File

@@ -48,7 +48,6 @@ describe("daemon E2E (real claude) - rewind user message dedupe", () => {
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const rewindUserMessages = timeline.entries.filter(

View File

@@ -462,6 +462,41 @@ describe("daemon E2E terminal", () => {
rmSync(cwd, { recursive: true, force: true });
}, 30000);
test("propagates debounced terminal titles through list responses and snapshots", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "printf '\\033]0;Build Output\\007'\r",
});
let listedTitle: string | undefined;
const start = Date.now();
while (Date.now() - start < 10000) {
const list = await ctx.client.listTerminals(cwd);
listedTitle = list.terminals.find((terminal) => terminal.id === terminalId)?.title;
if (listedTitle === "Build Output") {
break;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
expect(listedTitle).toBe("Build Output");
const snapshotPromise = waitForTerminalSnapshot(
ctx.client,
terminalId,
(state) => state.title === "Build Output",
);
await ctx.client.subscribeTerminal(terminalId);
const snapshot = await snapshotPromise;
expect(snapshot.title).toBe("Build Output");
rmSync(cwd, { recursive: true, force: true });
}, 30000);
test("subscribe response is sent before the initial snapshot frame", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);

View File

@@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createDaemonTestContext, type DaemonTestContext, DaemonClient } from "../test-utils/index.js";
import { createMessageCollector } from "../test-utils/message-collector.js";
import type { SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
async function waitFor(
predicate: () => boolean,
timeoutMs = 5_000,
intervalMs = 10,
): Promise<void> {
const startedAt = Date.now();
while (!predicate()) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
function isSeqLessAssistantTimeline(
message: SessionOutboundMessage,
agentId: string,
text?: string,
): boolean {
return (
message.type === "agent_stream" &&
message.payload.agentId === agentId &&
message.payload.event.type === "timeline" &&
message.payload.event.item.type === "assistant_message" &&
message.payload.seq === undefined &&
(text === undefined || message.payload.event.item.text === text)
);
}
describe("daemon E2E - timeline reconnect contract", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60_000);
test("reconnect catches up committed rows without replaying a provisional seed", async () => {
const cwd = tmpCwd();
const primaryCollector = createMessageCollector(ctx.client);
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Reconnect Contract Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 120; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: `committed row ${seq}`,
});
}
primaryCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
text: "partial before disconnect",
});
await waitFor(() =>
primaryCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id, "partial before disconnect"),
),
);
await ctx.client.close();
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: "finalized while disconnected",
});
const reconnectClient = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
});
await reconnectClient.connect();
const reconnectCollector = createMessageCollector(reconnectClient);
try {
await reconnectClient.fetchAgents({
subscribe: { subscriptionId: "timeline-reconnect-a" },
});
expect(
reconnectCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id),
),
).toBe(false);
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { seq: 120 },
limit: 0,
});
expect(catchUp.entries).toHaveLength(1);
expect(catchUp.entries[0]?.seq).toBe(121);
expect(catchUp.entries[0]?.item).toEqual({
type: "assistant_message",
text: "finalized while disconnected",
});
} finally {
reconnectCollector.unsubscribe();
await reconnectClient.close();
}
} finally {
primaryCollector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
test("reconnect with no new committed rows resumes from future live provisional updates only", async () => {
const cwd = tmpCwd();
const primaryCollector = createMessageCollector(ctx.client);
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Reconnect No Seed Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 120; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: `committed row ${seq}`,
});
}
primaryCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
text: "partial before disconnect",
});
await waitFor(() =>
primaryCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id, "partial before disconnect"),
),
);
await ctx.client.close();
const reconnectClient = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
});
await reconnectClient.connect();
const reconnectCollector = createMessageCollector(reconnectClient);
try {
await reconnectClient.fetchAgents({
subscribe: { subscriptionId: "timeline-reconnect-b" },
});
expect(
reconnectCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id),
),
).toBe(false);
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { seq: 120 },
limit: 0,
});
expect(catchUp.entries).toHaveLength(0);
reconnectCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
text: "fresh live after reconnect",
});
await waitFor(() =>
reconnectCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id, "fresh live after reconnect"),
),
);
} finally {
reconnectCollector.unsubscribe();
await reconnectClient.close();
}
} finally {
primaryCollector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
});

View File

@@ -20,7 +20,7 @@ describe("daemon E2E - timeline window", () => {
await ctx.cleanup();
}, 60_000);
test("canonical tail limit keeps assistant chunks intact at the window boundary", async () => {
test("canonical tail limit returns one finalized committed assistant row at the window boundary", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
@@ -38,16 +38,14 @@ describe("daemon E2E - timeline window", () => {
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 1,
projection: "canonical",
});
const assistantTexts = timeline.entries
.filter((entry) => entry.item.type === "assistant_message")
.map((entry) => entry.item.text);
expect(assistantTexts).toHaveLength(2);
expect(assistantTexts.join("")).toBe(expected);
expect(timeline.startCursor?.seq).toBeLessThan(timeline.endCursor?.seq ?? 0);
expect(assistantTexts).toEqual([expected]);
expect(timeline.startSeq).toBe(timeline.endSeq);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
@@ -73,7 +71,6 @@ describe("daemon E2E - timeline window", () => {
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 1,
projection: "canonical",
});
const assistantTexts = timeline.entries
@@ -82,7 +79,7 @@ describe("daemon E2E - timeline window", () => {
expect(assistantTexts.join("")).toBe(expected);
expect(timeline.hasOlder).toBe(true);
expect(timeline.startCursor?.seq).toBeGreaterThan(1);
expect(timeline.startSeq).toBeGreaterThan(1);
} finally {
rmSync(cwd, { recursive: true, force: true });
}

View File

@@ -289,7 +289,6 @@ async function resolveLatestAssistantMessage(
const timeline = await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: 300,
projection: "canonical",
});
for (let idx = timeline.entries.length - 1; idx >= 0; idx -= 1) {
const entry = timeline.entries[idx];

View File

@@ -0,0 +1,276 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { ManagedAgent } from "../agent/agent-manager.js";
import type {
AgentPermissionRequest,
AgentSession,
AgentSessionConfig,
} from "../agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js";
import { DbAgentSnapshotStore } from "./db-agent-snapshot-store.js";
import { agentSnapshots, projects, workspaces } from "./schema.js";
type ManagedAgentOverrides = Omit<
Partial<ManagedAgent>,
"config" | "pendingPermissions" | "session" | "activeForegroundTurnId"
> & {
config?: Partial<AgentSessionConfig>;
pendingPermissions?: Map<string, AgentPermissionRequest>;
session?: AgentSession | null;
activeForegroundTurnId?: string | null;
runtimeInfo?: ManagedAgent["runtimeInfo"];
attention?: ManagedAgent["attention"];
};
function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent {
const now = overrides.updatedAt ?? new Date("2026-03-01T00:00:00.000Z");
const provider = overrides.provider ?? "codex";
const cwd = overrides.cwd ?? "/tmp/project";
const lifecycle = overrides.lifecycle ?? "idle";
const configOverrides = overrides.config ?? {};
const config: AgentSessionConfig = {
provider,
cwd,
title: configOverrides.title,
modeId: configOverrides.modeId ?? "plan",
model: configOverrides.model ?? "gpt-5.1-codex-mini",
extra: configOverrides.extra ?? { codex: { approvalPolicy: "on-request" } },
systemPrompt: configOverrides.systemPrompt,
mcpServers: configOverrides.mcpServers,
};
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
const activeForegroundTurnId =
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "turn-1" : null);
return {
id: overrides.id ?? "agent-1",
provider,
cwd,
session,
capabilities: overrides.capabilities ?? {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
config,
lifecycle,
createdAt: overrides.createdAt ?? now,
updatedAt: overrides.updatedAt ?? now,
availableModes: overrides.availableModes ?? [],
currentModeId: overrides.currentModeId ?? config.modeId ?? null,
pendingPermissions: overrides.pendingPermissions ?? new Map<string, AgentPermissionRequest>(),
activeForegroundTurnId,
foregroundTurnWaiters: new Set(),
unsubscribeSession: null,
timeline: overrides.timeline ?? [],
attention: overrides.attention ?? { requiresAttention: false },
runtimeInfo: overrides.runtimeInfo ?? {
provider,
sessionId: overrides.sessionId ?? "session-123",
model: config.model ?? null,
modeId: config.modeId ?? null,
},
persistence: overrides.persistence ?? null,
historyPrimed: overrides.historyPrimed ?? true,
lastUserMessageAt: overrides.lastUserMessageAt ?? now,
lastUsage: overrides.lastUsage,
lastError: overrides.lastError,
internal: overrides.internal,
labels: overrides.labels ?? {},
pendingReplacement: false,
provisionalAssistantText: null,
};
}
function createStoredAgentRecord(overrides: Partial<StoredAgentRecord> = {}): StoredAgentRecord {
return {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
lastActivityAt: "2026-03-01T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: "plan",
config: {
modeId: "plan",
model: "gpt-5.1-codex-mini",
},
runtimeInfo: {
provider: "codex",
sessionId: "session-123",
model: "gpt-5.1-codex-mini",
modeId: "plan",
},
persistence: {
provider: "codex",
sessionId: "session-123",
},
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: null,
...overrides,
};
}
describe("DbAgentSnapshotStore", () => {
let tmpDir: string;
let dataDir: string;
let database: PaseoDatabaseHandle;
let store: DbAgentSnapshotStore;
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "db-agent-snapshot-store-"));
dataDir = path.join(tmpDir, "db");
database = await openPaseoDatabase(dataDir);
store = new DbAgentSnapshotStore(database.db);
});
afterEach(async () => {
await database.close();
rmSync(tmpDir, { recursive: true, force: true });
});
test("supports list/get/upsert/remove CRUD lifecycle", async () => {
const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" });
const record = createStoredAgentRecord();
expect(await store.list()).toEqual([]);
expect(await store.get(record.id)).toBeNull();
await store.upsert(record, workspaceId);
expect(await store.get(record.id)).toEqual(record);
expect(await store.list()).toEqual([record]);
expect(await database.db.select().from(agentSnapshots)).toEqual([
expect.objectContaining({
agentId: "agent-1",
workspaceId,
requiresAttention: false,
internal: false,
}),
]);
await store.remove(record.id);
expect(await store.get(record.id)).toBeNull();
expect(await store.list()).toEqual([]);
});
test("applySnapshot preserves title, createdAt, and archivedAt across updates", async () => {
const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" });
await store.upsert(
createStoredAgentRecord({
id: "agent-apply",
title: "Pinned title",
createdAt: "2026-03-01T00:00:00.000Z",
archivedAt: "2026-03-05T00:00:00.000Z",
}),
workspaceId,
);
await store.applySnapshot(
createManagedAgent({
id: "agent-apply",
createdAt: new Date("2026-03-10T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
lifecycle: "running",
}),
workspaceId,
);
expect(await store.get("agent-apply")).toEqual(
expect.objectContaining({
id: "agent-apply",
title: "Pinned title",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-11T00:00:00.000Z",
archivedAt: "2026-03-05T00:00:00.000Z",
lastStatus: "running",
}),
);
});
test("setTitle throws for missing agents and updates existing agents", async () => {
const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" });
await expect(store.setTitle("missing-agent", "Missing")).rejects.toThrow(
"Agent missing-agent not found",
);
await store.upsert(createStoredAgentRecord({ id: "agent-title", title: null }), workspaceId);
await store.setTitle("agent-title", "Renamed agent");
expect(await store.get("agent-title")).toEqual(
expect.objectContaining({
id: "agent-title",
title: "Renamed agent",
}),
);
});
test("upsert is idempotent for the same agent ID", async () => {
const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" });
await store.upsert(createStoredAgentRecord({ id: "agent-idempotent", title: "Initial" }), workspaceId);
await store.upsert(
createStoredAgentRecord({
id: "agent-idempotent",
title: "Updated",
updatedAt: "2026-03-02T00:00:00.000Z",
lastStatus: "running",
}),
workspaceId,
);
expect(await store.list()).toEqual([
createStoredAgentRecord({
id: "agent-idempotent",
title: "Updated",
updatedAt: "2026-03-02T00:00:00.000Z",
lastStatus: "running",
}),
]);
expect(await database.db.select().from(agentSnapshots)).toHaveLength(1);
});
});
async function seedWorkspace(
database: PaseoDatabaseHandle,
options: { directory: string },
): Promise<number> {
const [project] = await database.db.insert(projects).values({
directory: options.directory,
kind: "git",
displayName: "project-1",
gitRemote: null,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
}).returning();
const [workspace] = await database.db.insert(workspaces).values({
projectId: project.id,
directory: options.directory,
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
}).returning();
return workspace.id;
}

View File

@@ -0,0 +1,204 @@
import { asc, eq } from "drizzle-orm";
import type { ManagedAgent } from "../agent/agent-manager.js";
import type { AgentSnapshotStore } from "../agent/agent-snapshot-store.js";
import { toStoredAgentRecord } from "../agent/agent-projections.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
import { agentSnapshots } from "./schema.js";
type AgentSnapshotRow = typeof agentSnapshots.$inferSelect;
type AgentSnapshotInsert = typeof agentSnapshots.$inferInsert;
export function toStoredAgentRecordFromRow(row: AgentSnapshotRow): StoredAgentRecord {
return {
id: row.agentId,
provider: row.provider,
cwd: row.cwd,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastActivityAt: row.lastActivityAt ?? undefined,
lastUserMessageAt: row.lastUserMessageAt ?? null,
title: row.title ?? null,
labels: row.labels,
lastStatus: row.lastStatus as StoredAgentRecord["lastStatus"],
lastModeId: row.lastModeId ?? null,
config: row.config ?? null,
runtimeInfo: row.runtimeInfo ?? undefined,
persistence: row.persistence ?? null,
requiresAttention: row.requiresAttention,
attentionReason: (row.attentionReason ?? null) as StoredAgentRecord["attentionReason"],
attentionTimestamp: row.attentionTimestamp ?? null,
internal: row.internal,
archivedAt: row.archivedAt ?? null,
};
}
export function toAgentSnapshotRowValues(options: {
record: StoredAgentRecord;
workspaceId: number;
}): AgentSnapshotInsert {
const { record, workspaceId } = options;
return {
agentId: record.id,
provider: record.provider,
workspaceId,
cwd: record.cwd,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
lastActivityAt: record.lastActivityAt ?? null,
lastUserMessageAt: record.lastUserMessageAt ?? null,
title: record.title ?? null,
labels: record.labels,
lastStatus: record.lastStatus,
lastModeId: record.lastModeId ?? null,
config: record.config ?? null,
runtimeInfo: record.runtimeInfo ?? null,
persistence: record.persistence ?? null,
requiresAttention: record.requiresAttention ?? false,
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null,
internal: record.internal ?? false,
archivedAt: record.archivedAt ?? null,
};
}
function toAgentSnapshotUpdateSet(values: AgentSnapshotInsert) {
return {
provider: values.provider,
workspaceId: values.workspaceId,
cwd: values.cwd,
createdAt: values.createdAt,
updatedAt: values.updatedAt,
lastActivityAt: values.lastActivityAt,
lastUserMessageAt: values.lastUserMessageAt,
title: values.title,
labels: values.labels,
lastStatus: values.lastStatus,
lastModeId: values.lastModeId,
config: values.config,
runtimeInfo: values.runtimeInfo,
persistence: values.persistence,
requiresAttention: values.requiresAttention,
attentionReason: values.attentionReason,
attentionTimestamp: values.attentionTimestamp,
internal: values.internal,
archivedAt: values.archivedAt,
} satisfies Omit<AgentSnapshotInsert, "agentId">;
}
export class DbAgentSnapshotStore implements AgentSnapshotStore {
private readonly db: PaseoDatabaseHandle["db"];
constructor(db: PaseoDatabaseHandle["db"]) {
this.db = db;
}
async list(): Promise<StoredAgentRecord[]> {
const rows = await this.db
.select()
.from(agentSnapshots)
.orderBy(asc(agentSnapshots.createdAt), asc(agentSnapshots.agentId));
return rows.map(toStoredAgentRecordFromRow);
}
async get(agentId: string): Promise<StoredAgentRecord | null> {
const rows = await this.db
.select()
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, agentId))
.limit(1);
const row = rows[0];
return row ? toStoredAgentRecordFromRow(row) : null;
}
async upsert(record: StoredAgentRecord): Promise<void>;
async upsert(record: StoredAgentRecord, workspaceId: number): Promise<void>;
async upsert(record: StoredAgentRecord, workspaceId?: number): Promise<void> {
const nextWorkspaceId =
workspaceId ?? (await this.db
.select({ workspaceId: agentSnapshots.workspaceId })
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, record.id))
.limit(1))[0]?.workspaceId;
if (nextWorkspaceId === undefined) {
throw new Error(`Workspace ID required for agent ${record.id}`);
}
const values = toAgentSnapshotRowValues({
record,
workspaceId: nextWorkspaceId,
});
await this.db
.insert(agentSnapshots)
.values(values)
.onConflictDoUpdate({
target: agentSnapshots.agentId,
set: toAgentSnapshotUpdateSet(values),
});
}
async remove(agentId: string): Promise<void> {
await this.db.delete(agentSnapshots).where(eq(agentSnapshots.agentId, agentId));
}
async applySnapshot(
agent: ManagedAgent,
options?: { title?: string | null; internal?: boolean },
): Promise<void>;
async applySnapshot(
agent: ManagedAgent,
workspaceId: number,
options?: { title?: string | null; internal?: boolean },
): Promise<void>;
async applySnapshot(
agent: ManagedAgent,
workspaceIdOrOptions?: number | { title?: string | null; internal?: boolean },
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
const nextWorkspaceId =
typeof workspaceIdOrOptions === "number"
? workspaceIdOrOptions
: (await this.db
.select({ workspaceId: agentSnapshots.workspaceId })
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, agent.id))
.limit(1))[0]?.workspaceId;
const nextOptions =
typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions;
const existing = await this.get(agent.id);
const hasTitleOverride =
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "title");
const hasInternalOverride =
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "internal");
const record = toStoredAgentRecord(agent, {
title: hasTitleOverride ? (nextOptions?.title ?? null) : (existing?.title ?? null),
createdAt: existing?.createdAt,
internal: hasInternalOverride
? nextOptions?.internal
: (agent.internal ?? existing?.internal),
});
if (existing && existing.archivedAt !== undefined) {
record.archivedAt = existing.archivedAt;
}
if (nextWorkspaceId === undefined) {
return;
}
await this.upsert(record, nextWorkspaceId);
}
async setTitle(agentId: string, title: string): Promise<void> {
const rows = await this.db
.select()
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, agentId))
.limit(1);
const row = rows[0];
if (!row) {
throw new Error(`Agent ${agentId} not found`);
}
await this.upsert({ ...toStoredAgentRecordFromRow(row), title }, row.workspaceId);
}
}

View File

@@ -0,0 +1,266 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentTimelineRow } from "../agent/agent-timeline-store-types.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js";
import { DbAgentTimelineStore } from "./db-agent-timeline-store.js";
import { agentTimelineRows } from "./schema.js";
function createTimestamp(seq: number): string {
return new Date(Date.UTC(2026, 2, 1, 0, 0, seq)).toISOString();
}
function createTimelineItem(
type: Extract<AgentTimelineItem["type"], "assistant_message" | "user_message">,
value: string,
): AgentTimelineItem {
if (type === "user_message") {
return {
type,
text: `user-${value}`,
messageId: `message-${value}`,
};
}
return {
type,
text: `assistant-${value}`,
};
}
function createRow(seq: number, item?: AgentTimelineItem): AgentTimelineRow {
return {
seq,
timestamp: createTimestamp(seq),
item: item ?? createTimelineItem("assistant_message", String(seq)),
};
}
describe("DbAgentTimelineStore", () => {
let tmpDir: string;
let dataDir: string;
let database: PaseoDatabaseHandle;
let store: DbAgentTimelineStore;
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "db-agent-timeline-store-"));
dataDir = path.join(tmpDir, "db");
database = await openPaseoDatabase(dataDir);
store = new DbAgentTimelineStore(database.db);
});
afterEach(async () => {
await database.close();
rmSync(tmpDir, { recursive: true, force: true });
});
test("appendCommitted assigns sequential seq numbers per agent", async () => {
expect(
await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "1")),
).toEqual({
seq: 1,
timestamp: expect.any(String),
item: createTimelineItem("assistant_message", "1"),
});
expect(
await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "2")),
).toEqual({
seq: 2,
timestamp: expect.any(String),
item: createTimelineItem("assistant_message", "2"),
});
expect(
await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "3")),
).toEqual({
seq: 3,
timestamp: expect.any(String),
item: createTimelineItem("assistant_message", "3"),
});
});
test("appendCommitted for different agents has independent seq sequences", async () => {
const firstAgentFirstRow = await store.appendCommitted(
"agent-1",
createTimelineItem("assistant_message", "a1"),
);
const secondAgentFirstRow = await store.appendCommitted(
"agent-2",
createTimelineItem("assistant_message", "b1"),
);
const firstAgentSecondRow = await store.appendCommitted(
"agent-1",
createTimelineItem("assistant_message", "a2"),
);
expect(firstAgentFirstRow.seq).toBe(1);
expect(secondAgentFirstRow.seq).toBe(1);
expect(firstAgentSecondRow.seq).toBe(2);
});
test("fetchCommitted tail returns the last N rows", async () => {
await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq)));
await expect(
store.fetchCommitted("agent-1", {
direction: "tail",
limit: 2,
}),
).resolves.toEqual({
direction: "tail",
window: {
minSeq: 1,
maxSeq: 5,
nextSeq: 6,
},
hasOlder: true,
hasNewer: false,
rows: [createRow(4), createRow(5)],
});
});
test("fetchCommitted after-cursor returns rows after a given seq", async () => {
await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq)));
await expect(
store.fetchCommitted("agent-1", {
direction: "after",
cursor: { seq: 2 },
limit: 2,
}),
).resolves.toEqual({
direction: "after",
window: {
minSeq: 1,
maxSeq: 5,
nextSeq: 6,
},
hasOlder: true,
hasNewer: true,
rows: [createRow(3), createRow(4)],
});
});
test("fetchCommitted before-cursor returns rows before a given seq", async () => {
await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq)));
await expect(
store.fetchCommitted("agent-1", {
direction: "before",
cursor: { seq: 4 },
limit: 2,
}),
).resolves.toEqual({
direction: "before",
window: {
minSeq: 1,
maxSeq: 5,
nextSeq: 6,
},
hasOlder: true,
hasNewer: true,
rows: [createRow(2), createRow(3)],
});
});
test("getLatestCommittedSeq returns 0 for an unknown agent", async () => {
await expect(store.getLatestCommittedSeq("missing-agent")).resolves.toBe(0);
});
test("getLatestCommittedSeq returns the latest seq after appends", async () => {
await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "1"));
await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "2"));
await expect(store.getLatestCommittedSeq("agent-1")).resolves.toBe(2);
});
test("deleteAgent removes all rows for the target agent", async () => {
await store.bulkInsert("agent-1", [createRow(1), createRow(2)]);
await store.bulkInsert("agent-2", [createRow(1)]);
await store.deleteAgent("agent-1");
await expect(store.getCommittedRows("agent-1")).resolves.toEqual([]);
await expect(store.getCommittedRows("agent-2")).resolves.toEqual([createRow(1)]);
});
test("bulkInsert preserves provided seq numbers", async () => {
const rows = [createRow(3), createRow(7)];
await store.bulkInsert("agent-1", rows);
await expect(store.getCommittedRows("agent-1")).resolves.toEqual(rows);
});
test("item_kind is populated from item.type", async () => {
await store.appendCommitted("agent-1", createTimelineItem("user_message", "kind-check"), {
timestamp: createTimestamp(1),
});
await expect(database.db.select().from(agentTimelineRows)).resolves.toEqual([
expect.objectContaining({
agentId: "agent-1",
seq: 1,
committedAt: createTimestamp(1),
itemKind: "user_message",
}),
]);
});
test("getLastItem returns the latest committed item", async () => {
await store.bulkInsert("agent-1", [
createRow(1, createTimelineItem("user_message", "1")),
createRow(2, createTimelineItem("assistant_message", "2")),
]);
await expect(store.getLastItem("agent-1")).resolves.toEqual(
createTimelineItem("assistant_message", "2"),
);
await expect(store.getLastItem("missing-agent")).resolves.toBeNull();
});
test("getLastAssistantMessage assembles the latest contiguous assistant chunks", async () => {
await store.bulkInsert("agent-1", [
createRow(1, createTimelineItem("assistant_message", "1")),
createRow(2, createTimelineItem("assistant_message", "2")),
createRow(3, { type: "reasoning", text: "separator-1" }),
createRow(4, createTimelineItem("assistant_message", "4")),
createRow(5, createTimelineItem("assistant_message", "5")),
createRow(6, { type: "reasoning", text: "separator-2" }),
]);
await expect(store.getLastAssistantMessage("agent-1")).resolves.toBe("assistant-4assistant-5");
await expect(store.getLastAssistantMessage("missing-agent")).resolves.toBeNull();
});
test("hasCommittedUserMessage matches by normalized messageId and text", async () => {
await store.bulkInsert("agent-1", [
createRow(1, createTimelineItem("user_message", "1")),
createRow(2, createTimelineItem("assistant_message", "2")),
]);
await expect(
store.hasCommittedUserMessage("agent-1", {
messageId: " message-1 ",
text: "user-1",
}),
).resolves.toBe(true);
await expect(
store.hasCommittedUserMessage("agent-1", {
messageId: "message-1",
text: "different",
}),
).resolves.toBe(false);
await expect(
store.hasCommittedUserMessage("agent-1", {
messageId: " ",
text: "user-1",
}),
).resolves.toBe(false);
});
});

View File

@@ -0,0 +1,303 @@
import { and, asc, desc, eq, gt, lt, sql } from "drizzle-orm";
import type {
AgentTimelineFetchOptions,
AgentTimelineFetchResult,
AgentTimelineRow,
AgentTimelineStore,
AgentTimelineWindow,
} from "../agent/agent-timeline-store-types.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
import { agentTimelineRows } from "./schema.js";
type AgentTimelineRowRecord = typeof agentTimelineRows.$inferSelect;
type AgentTimelineRowInsert = typeof agentTimelineRows.$inferInsert;
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
function normalizeTimelineMessageId(messageId: string | undefined): string | undefined {
if (typeof messageId !== "string") {
return undefined;
}
const normalized = messageId.trim();
return normalized.length > 0 ? normalized : undefined;
}
function toTimelineRow(row: AgentTimelineRowRecord): AgentTimelineRow {
return {
seq: row.seq,
timestamp: row.committedAt,
item: row.item,
};
}
function toInsertValues(agentId: string, row: AgentTimelineRow): AgentTimelineRowInsert {
return {
agentId,
seq: row.seq,
committedAt: row.timestamp,
item: row.item,
itemKind: row.item.type,
};
}
function normalizeFetchLimit(limit: number | undefined): number {
if (limit === undefined) {
return DEFAULT_TIMELINE_FETCH_LIMIT;
}
return Math.max(0, Math.floor(limit));
}
export class DbAgentTimelineStore implements AgentTimelineStore {
private readonly db: PaseoDatabaseHandle["db"];
constructor(db: PaseoDatabaseHandle["db"]) {
this.db = db;
}
async appendCommitted(
agentId: string,
item: AgentTimelineItem,
options?: { timestamp?: string },
): Promise<AgentTimelineRow> {
const nextSeq = (await this.getMaxSeq(agentId)) + 1;
const row: AgentTimelineRow = {
seq: nextSeq,
timestamp: options?.timestamp ?? new Date().toISOString(),
item,
};
await this.db.insert(agentTimelineRows).values(toInsertValues(agentId, row));
return row;
}
async fetchCommitted(
agentId: string,
options?: AgentTimelineFetchOptions,
): Promise<AgentTimelineFetchResult> {
const direction = options?.direction ?? "tail";
const limit = normalizeFetchLimit(options?.limit);
const selectAll = limit === 0;
const window = await this.getWindow(agentId);
if (window.maxSeq === 0) {
return {
direction,
window,
hasOlder: false,
hasNewer: false,
rows: [],
};
}
if (direction === "tail") {
const rows = selectAll
? await this.db
.select()
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, agentId))
.orderBy(asc(agentTimelineRows.seq))
: (
await this.db
.select()
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, agentId))
.orderBy(desc(agentTimelineRows.seq))
.limit(limit)
).reverse();
const selected = rows.map(toTimelineRow);
return {
direction,
window,
hasOlder: selected.length > 0 && selected[0]!.seq > window.minSeq,
hasNewer: false,
rows: selected,
};
}
if (direction === "after") {
const baseSeq = options?.cursor?.seq ?? 0;
const rows = (
selectAll
? await this.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq)))
.orderBy(asc(agentTimelineRows.seq))
: await this.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq)))
.orderBy(asc(agentTimelineRows.seq))
.limit(limit)
).map(toTimelineRow);
if (rows.length === 0) {
return {
direction,
window,
hasOlder: baseSeq >= window.minSeq,
hasNewer: false,
rows,
};
}
const lastSelected = rows[rows.length - 1]!;
return {
direction,
window,
hasOlder: rows[0]!.seq > window.minSeq,
hasNewer: lastSelected.seq < window.maxSeq,
rows,
};
}
const beforeSeq = options?.cursor?.seq ?? window.nextSeq;
const rows = (
selectAll
? await this.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq)))
.orderBy(asc(agentTimelineRows.seq))
: (
await this.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq)))
.orderBy(desc(agentTimelineRows.seq))
.limit(limit)
).reverse()
).map(toTimelineRow);
return {
direction,
window,
hasOlder: rows.length > 0 && rows[0]!.seq > window.minSeq,
hasNewer: beforeSeq <= window.maxSeq,
rows,
};
}
async getLatestCommittedSeq(agentId: string): Promise<number> {
return this.getMaxSeq(agentId);
}
async getCommittedRows(agentId: string): Promise<AgentTimelineRow[]> {
const rows = await this.db
.select()
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, agentId))
.orderBy(asc(agentTimelineRows.seq));
return rows.map(toTimelineRow);
}
async getLastItem(agentId: string): Promise<AgentTimelineItem | null> {
const [row] = await this.db
.select({ item: agentTimelineRows.item })
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, agentId))
.orderBy(desc(agentTimelineRows.seq))
.limit(1);
return row?.item ?? null;
}
async getLastAssistantMessage(agentId: string): Promise<string | null> {
const rows = await this.db
.select({
seq: agentTimelineRows.seq,
item: agentTimelineRows.item,
})
.from(agentTimelineRows)
.where(
and(
eq(agentTimelineRows.agentId, agentId),
eq(agentTimelineRows.itemKind, "assistant_message"),
),
)
.orderBy(desc(agentTimelineRows.seq));
if (rows.length === 0) {
return null;
}
const chunks: string[] = [];
let previousSeq: number | null = null;
for (const row of rows) {
if (previousSeq !== null && row.seq !== previousSeq - 1) {
break;
}
if (row.item.type !== "assistant_message") {
break;
}
chunks.push(row.item.text);
previousSeq = row.seq;
}
return chunks.length > 0 ? chunks.reverse().join("") : null;
}
async hasCommittedUserMessage(
agentId: string,
options: { messageId: string; text: string },
): Promise<boolean> {
const messageId = normalizeTimelineMessageId(options.messageId);
if (!messageId) {
return false;
}
const [row] = await this.db
.select({ seq: agentTimelineRows.seq })
.from(agentTimelineRows)
.where(
and(
eq(agentTimelineRows.agentId, agentId),
eq(agentTimelineRows.itemKind, "user_message"),
sql`json_extract(${agentTimelineRows.item}, '$.messageId') = ${messageId}`,
sql`json_extract(${agentTimelineRows.item}, '$.text') = ${options.text}`,
),
)
.limit(1);
return row !== undefined;
}
async deleteAgent(agentId: string): Promise<void> {
await this.db.delete(agentTimelineRows).where(eq(agentTimelineRows.agentId, agentId));
}
async bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void> {
if (rows.length === 0) {
return;
}
await this.db.insert(agentTimelineRows).values(rows.map((row) => toInsertValues(agentId, row)));
}
private async getMaxSeq(agentId: string): Promise<number> {
const [row] = await this.db
.select({
maxSeq: sql<number>`coalesce(max(${agentTimelineRows.seq}), 0)`,
})
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, agentId));
return Number(row?.maxSeq ?? 0);
}
private async getWindow(agentId: string): Promise<AgentTimelineWindow> {
const [row] = await this.db
.select({
minSeq: sql<number>`coalesce(min(${agentTimelineRows.seq}), 0)`,
maxSeq: sql<number>`coalesce(max(${agentTimelineRows.seq}), 0)`,
})
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, agentId));
const minSeq = Number(row?.minSeq ?? 0);
const maxSeq = Number(row?.maxSeq ?? 0);
return {
minSeq,
maxSeq,
nextSeq: maxSeq + 1,
};
}
}

View File

@@ -0,0 +1,81 @@
import { eq } from "drizzle-orm";
import type { ProjectRegistry, PersistedProjectRecord } from "../workspace-registry.js";
import { createPersistedProjectRecord } from "../workspace-registry.js";
import { projects } from "./schema.js";
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
function toPersistedProjectRecord(row: typeof projects.$inferSelect): PersistedProjectRecord {
return createPersistedProjectRecord({
...row,
kind: row.kind as PersistedProjectRecord["kind"],
});
}
export class DbProjectRegistry implements ProjectRegistry {
private readonly db: PaseoDatabaseHandle["db"];
constructor(db: PaseoDatabaseHandle["db"]) {
this.db = db;
}
async initialize(): Promise<void> {
return Promise.resolve();
}
async existsOnDisk(): Promise<boolean> {
return true;
}
async list(): Promise<PersistedProjectRecord[]> {
const rows = await this.db.select().from(projects);
return rows.map(toPersistedProjectRecord);
}
async get(id: number): Promise<PersistedProjectRecord | null> {
const rows = await this.db.select().from(projects).where(eq(projects.id, id)).limit(1);
const row = rows[0];
return row ? toPersistedProjectRecord(row) : null;
}
async insert(record: Omit<PersistedProjectRecord, "id">): Promise<number> {
const [row] = await this.db
.insert(projects)
.values(record)
.returning({ id: projects.id });
return row!.id;
}
async upsert(record: PersistedProjectRecord): Promise<void> {
const nextRecord = createPersistedProjectRecord(record);
await this.db
.insert(projects)
.values(nextRecord)
.onConflictDoUpdate({
target: projects.id,
set: {
directory: nextRecord.directory,
kind: nextRecord.kind,
displayName: nextRecord.displayName,
gitRemote: nextRecord.gitRemote,
createdAt: nextRecord.createdAt,
updatedAt: nextRecord.updatedAt,
archivedAt: nextRecord.archivedAt,
},
});
}
async archive(id: number, archivedAt: string): Promise<void> {
await this.db
.update(projects)
.set({
updatedAt: archivedAt,
archivedAt,
})
.where(eq(projects.id, id));
}
async remove(id: number): Promise<void> {
await this.db.delete(projects).where(eq(projects.id, id));
}
}

View File

@@ -0,0 +1,199 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js";
import { createPersistedProjectRecord, createPersistedWorkspaceRecord } from "../workspace-registry.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js";
import { DbProjectRegistry } from "./db-project-registry.js";
import { DbWorkspaceRegistry } from "./db-workspace-registry.js";
function createProjectRecord(input: Partial<PersistedProjectRecord> = {}): PersistedProjectRecord {
return createPersistedProjectRecord({
id: 1,
directory: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
gitRemote: "git@github.com:acme/repo.git",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
...input,
});
}
function createWorkspaceRecord(input: Partial<PersistedWorkspaceRecord> = {}): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: "/tmp/repo",
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
...input,
});
}
describe("DB-backed workspace registries", () => {
let tmpDir: string;
let dataDir: string;
let database: PaseoDatabaseHandle;
let projectRegistry: DbProjectRegistry;
let workspaceRegistry: DbWorkspaceRegistry;
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "db-workspace-registry-"));
dataDir = path.join(tmpDir, "db");
database = await openPaseoDatabase(dataDir);
projectRegistry = new DbProjectRegistry(database.db);
workspaceRegistry = new DbWorkspaceRegistry(database.db);
});
afterEach(async () => {
await database.close();
rmSync(tmpDir, { recursive: true, force: true });
});
test("project registry matches the file-backed behavioral contract", async () => {
await projectRegistry.initialize();
expect(await projectRegistry.existsOnDisk()).toBe(true);
expect(await projectRegistry.get(999)).toBeNull();
expect(await projectRegistry.list()).toEqual([]);
const projectId = await projectRegistry.insert({
directory: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
gitRemote: "git@github.com:acme/repo.git",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await projectRegistry.upsert(
createProjectRecord({
id: projectId,
updatedAt: "2026-03-02T00:00:00.000Z",
}),
);
await projectRegistry.archive(projectId, "2026-03-03T00:00:00.000Z");
await projectRegistry.archive(999, "2026-03-04T00:00:00.000Z");
expect(await projectRegistry.get(projectId)).toEqual(
createProjectRecord({
id: projectId,
updatedAt: "2026-03-03T00:00:00.000Z",
archivedAt: "2026-03-03T00:00:00.000Z",
}),
);
expect(await projectRegistry.list()).toEqual([
createProjectRecord({
updatedAt: "2026-03-03T00:00:00.000Z",
archivedAt: "2026-03-03T00:00:00.000Z",
}),
]);
await projectRegistry.remove(999);
await projectRegistry.remove(projectId);
expect(await projectRegistry.get(projectId)).toBeNull();
expect(await projectRegistry.list()).toEqual([]);
});
test("workspace registry matches the file-backed behavioral contract", async () => {
await workspaceRegistry.initialize();
expect(await workspaceRegistry.existsOnDisk()).toBe(true);
expect(await workspaceRegistry.get(999)).toBeNull();
expect(await workspaceRegistry.list()).toEqual([]);
const projectId = await projectRegistry.insert({
directory: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
gitRemote: "git@github.com:acme/repo.git",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
const workspaceId = await workspaceRegistry.insert({
projectId,
directory: "/tmp/repo",
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await workspaceRegistry.upsert(
createWorkspaceRecord({
id: workspaceId,
projectId,
displayName: "feature/workspace",
updatedAt: "2026-03-02T00:00:00.000Z",
}),
);
await workspaceRegistry.archive(workspaceId, "2026-03-03T00:00:00.000Z");
await workspaceRegistry.archive(999, "2026-03-04T00:00:00.000Z");
expect(await workspaceRegistry.get(workspaceId)).toEqual(
createWorkspaceRecord({
id: workspaceId,
projectId,
displayName: "feature/workspace",
updatedAt: "2026-03-03T00:00:00.000Z",
archivedAt: "2026-03-03T00:00:00.000Z",
}),
);
expect(await workspaceRegistry.list()).toEqual([
createWorkspaceRecord({
displayName: "feature/workspace",
updatedAt: "2026-03-03T00:00:00.000Z",
archivedAt: "2026-03-03T00:00:00.000Z",
}),
]);
await workspaceRegistry.remove(999);
await workspaceRegistry.remove(workspaceId);
expect(await workspaceRegistry.get(workspaceId)).toBeNull();
expect(await workspaceRegistry.list()).toEqual([]);
});
test("rejects workspace upserts for non-existent projects", async () => {
await expect(
workspaceRegistry.upsert(
createWorkspaceRecord({
projectId: 999,
}),
),
).rejects.toThrow();
});
test("cascades workspace removal when removing a linked project", async () => {
const projectId = await projectRegistry.insert({
directory: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
gitRemote: "git@github.com:acme/repo.git",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
const workspaceId = await workspaceRegistry.insert({
projectId,
directory: "/tmp/repo",
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await projectRegistry.remove(projectId);
expect(await projectRegistry.get(projectId)).toBeNull();
expect(await workspaceRegistry.get(workspaceId)).toBeNull();
});
});

View File

@@ -0,0 +1,85 @@
import { eq } from "drizzle-orm";
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
import { workspaces } from "./schema.js";
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../workspace-registry.js";
import { createPersistedWorkspaceRecord } from "../workspace-registry.js";
function toPersistedWorkspaceRecord(row: typeof workspaces.$inferSelect): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
...row,
kind: row.kind as PersistedWorkspaceRecord["kind"],
});
}
export class DbWorkspaceRegistry implements WorkspaceRegistry {
private readonly db: PaseoDatabaseHandle["db"];
constructor(db: PaseoDatabaseHandle["db"]) {
this.db = db;
}
async initialize(): Promise<void> {
return Promise.resolve();
}
async existsOnDisk(): Promise<boolean> {
return true;
}
async list(): Promise<PersistedWorkspaceRecord[]> {
const rows = await this.db.select().from(workspaces);
return rows.map(toPersistedWorkspaceRecord);
}
async get(id: number): Promise<PersistedWorkspaceRecord | null> {
const rows = await this.db
.select()
.from(workspaces)
.where(eq(workspaces.id, id))
.limit(1);
const row = rows[0];
return row ? toPersistedWorkspaceRecord(row) : null;
}
async insert(record: Omit<PersistedWorkspaceRecord, "id">): Promise<number> {
const [row] = await this.db
.insert(workspaces)
.values(record)
.returning({ id: workspaces.id });
return row!.id;
}
async upsert(record: PersistedWorkspaceRecord): Promise<void> {
const nextRecord = createPersistedWorkspaceRecord(record);
await this.db
.insert(workspaces)
.values(nextRecord)
.onConflictDoUpdate({
target: workspaces.id,
set: {
projectId: nextRecord.projectId,
directory: nextRecord.directory,
kind: nextRecord.kind,
displayName: nextRecord.displayName,
createdAt: nextRecord.createdAt,
updatedAt: nextRecord.updatedAt,
archivedAt: nextRecord.archivedAt,
},
});
}
async archive(workspaceId: number, archivedAt: string): Promise<void> {
await this.db
.update(workspaces)
.set({
updatedAt: archivedAt,
archivedAt,
})
.where(eq(workspaces.id, workspaceId));
}
async remove(workspaceId: number): Promise<void> {
await this.db.delete(workspaces).where(eq(workspaces.id, workspaceId));
}
}

View File

@@ -0,0 +1,239 @@
import os from "node:os";
import path from "node:path";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js";
import { importLegacyAgentSnapshots } from "./legacy-agent-snapshot-import.js";
import { agentSnapshots, projects, workspaces } from "./schema.js";
describe("importLegacyAgentSnapshots", () => {
let tmpDir: string;
let paseoHome: string;
let dbDir: string;
let database: PaseoDatabaseHandle;
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-legacy-agent-import-"));
paseoHome = path.join(tmpDir, ".paseo");
dbDir = path.join(paseoHome, "db");
mkdirSync(paseoHome, { recursive: true });
database = await openPaseoDatabase(dbDir);
});
afterEach(async () => {
await database.close();
rmSync(tmpDir, { recursive: true, force: true });
});
async function seedWorkspace(directory: string): Promise<number> {
const [project] = await database.db
.insert(projects)
.values({
directory,
displayName: path.basename(directory),
kind: "directory",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
})
.returning({ id: projects.id });
const [workspace] = await database.db
.insert(workspaces)
.values({
projectId: project!.id,
directory,
displayName: path.basename(directory),
kind: "checkout",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
})
.returning({ id: workspaces.id });
return workspace!.id;
}
test("imports agent JSON files when the DB is empty", async () => {
await seedWorkspace("/tmp/project");
writeLegacyAgentJson({
paseoHome,
relativePath: "agents/agent-1.json",
payload: createLegacyAgentJson({
requiresAttention: undefined,
internal: undefined,
}),
});
const result = await importLegacyAgentSnapshots({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "imported",
importedAgents: 1,
});
expect(await database.db.select().from(agentSnapshots)).toEqual([
expect.objectContaining({
agentId: "agent-1",
cwd: "/tmp/project",
requiresAttention: false,
internal: false,
}),
]);
});
test("skips import when the DB already has agent data", async () => {
const workspaceId = await seedWorkspace("/tmp/existing-project");
await database.db.insert(agentSnapshots).values({
agentId: "existing-agent",
provider: "codex",
workspaceId,
cwd: "/tmp/existing-project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
lastActivityAt: "2026-03-01T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: "plan",
config: null,
runtimeInfo: { provider: "codex", sessionId: "session-existing" },
persistence: null,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: null,
});
writeLegacyAgentJson({
paseoHome,
relativePath: "agents/legacy-agent.json",
payload: createLegacyAgentJson({ id: "legacy-agent" }),
});
const result = await importLegacyAgentSnapshots({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "skipped",
reason: "database-not-empty",
});
expect(await database.db.select().from(agentSnapshots)).toHaveLength(1);
});
test("imports agent JSON files from nested project directories", async () => {
await seedWorkspace("/tmp/root-project");
await seedWorkspace("/tmp/nested-project");
writeLegacyAgentJson({
paseoHome,
relativePath: "agents/agent-root.json",
payload: createLegacyAgentJson({ id: "agent-root", cwd: "/tmp/root-project" }),
});
writeLegacyAgentJson({
paseoHome,
relativePath: "agents/tmp-nested-project/agent-nested.json",
payload: createLegacyAgentJson({ id: "agent-nested", cwd: "/tmp/nested-project" }),
});
const result = await importLegacyAgentSnapshots({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "imported",
importedAgents: 2,
});
expect(
(await database.db.select().from(agentSnapshots)).map((row) => row.agentId).sort(),
).toEqual(["agent-nested", "agent-root"]);
});
test("batches large legacy agent imports so SQLite variable limits do not abort bootstrap", async () => {
await seedWorkspace("/tmp/large-project");
for (let index = 0; index < 150; index += 1) {
writeLegacyAgentJson({
paseoHome,
relativePath: `agents/large-project/agent-${index}.json`,
payload: createLegacyAgentJson({
id: `agent-${index}`,
cwd: "/tmp/large-project",
runtimeInfo: {
provider: "codex",
sessionId: `session-${index}`,
model: "gpt-5.1-codex-mini",
modeId: "plan",
},
}),
});
}
const result = await importLegacyAgentSnapshots({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "imported",
importedAgents: 150,
});
const rows = await database.db.select().from(agentSnapshots);
expect(rows).toHaveLength(150);
expect(rows.map((row) => row.agentId)).toContain("agent-149");
});
});
function createLegacyAgentJson(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: "plan",
config: {
model: "gpt-5.1-codex-mini",
modeId: "plan",
},
runtimeInfo: {
provider: "codex",
sessionId: "session-123",
model: "gpt-5.1-codex-mini",
modeId: "plan",
},
persistence: null,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
...overrides,
};
}
function writeLegacyAgentJson(input: {
paseoHome: string;
relativePath: string;
payload: Record<string, unknown>;
}): void {
const absolutePath = path.join(input.paseoHome, input.relativePath);
mkdirSync(path.dirname(absolutePath), { recursive: true });
writeFileSync(absolutePath, JSON.stringify(input.payload, null, 2), {
encoding: "utf8",
flag: "w",
});
}

View File

@@ -0,0 +1,188 @@
import path from "node:path";
import { promises as fs } from "node:fs";
import { count } from "drizzle-orm";
import type { Logger } from "pino";
import { parseStoredAgentRecord, type StoredAgentRecord } from "../agent/agent-storage.js";
import { normalizeWorkspaceId } from "../workspace-registry-model.js";
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
import { toAgentSnapshotRowValues } from "./db-agent-snapshot-store.js";
import { agentSnapshots, projects, workspaces } from "./schema.js";
const SQLITE_MAX_VARIABLES_PER_STATEMENT = 999;
const AGENT_SNAPSHOT_INSERT_VARIABLES_PER_ROW = Object.keys(agentSnapshots).length;
const MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT = Math.max(
1,
Math.floor(SQLITE_MAX_VARIABLES_PER_STATEMENT / AGENT_SNAPSHOT_INSERT_VARIABLES_PER_ROW),
);
export type LegacyAgentSnapshotImportResult =
| {
status: "imported";
importedAgents: number;
}
| {
status: "skipped";
reason: "database-not-empty" | "no-legacy-files";
};
export async function importLegacyAgentSnapshots(options: {
db: PaseoDatabaseHandle["db"];
paseoHome: string;
logger: Logger;
}): Promise<LegacyAgentSnapshotImportResult> {
if (await hasAnyAgentSnapshotRows(options.db)) {
options.logger.info("Skipping legacy agent snapshot import because the DB is not empty");
return {
status: "skipped",
reason: "database-not-empty",
};
}
const records = await readLegacyAgentRecords(path.join(options.paseoHome, "agents"), options.logger);
if (records.length === 0) {
options.logger.info("Skipping legacy agent snapshot import because no legacy files exist");
return {
status: "skipped",
reason: "no-legacy-files",
};
}
options.db.transaction((tx) => {
const workspaceRows = tx
.select({ id: workspaces.id, directory: workspaces.directory })
.from(workspaces)
.all();
const workspaceIdsByDirectory = new Map(
workspaceRows.map((row) => [row.directory, row.id] as const),
);
const projectRows = tx
.select({ id: projects.id, directory: projects.directory })
.from(projects)
.all();
const projectIdsByDirectory = new Map(projectRows.map((row) => [row.directory, row.id] as const));
for (const record of records) {
const normalizedDirectory = normalizeWorkspaceId(record.cwd);
if (workspaceIdsByDirectory.has(normalizedDirectory)) {
continue;
}
const timestamp = record.updatedAt ?? record.createdAt;
const displayName =
normalizedDirectory.split(/[\\/]/).filter(Boolean).at(-1) ?? normalizedDirectory;
let projectId = projectIdsByDirectory.get(normalizedDirectory);
if (projectId === undefined) {
const projectRow = tx
.insert(projects)
.values({
directory: normalizedDirectory,
displayName,
kind: "directory",
gitRemote: null,
createdAt: record.createdAt,
updatedAt: timestamp,
archivedAt: null,
})
.returning({ id: projects.id })
.get();
projectId = projectRow!.id;
projectIdsByDirectory.set(normalizedDirectory, projectId);
}
const workspaceRow = tx
.insert(workspaces)
.values({
projectId,
directory: normalizedDirectory,
displayName,
kind: "checkout",
createdAt: record.createdAt,
updatedAt: timestamp,
archivedAt: null,
})
.returning({ id: workspaces.id })
.get();
workspaceIdsByDirectory.set(normalizedDirectory, workspaceRow!.id);
}
const rows = records.flatMap((record) => {
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(record.cwd));
return workspaceId === undefined ? [] : [toAgentSnapshotRowValues({ record, workspaceId })];
});
for (let startIndex = 0; startIndex < rows.length; startIndex += MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT) {
const batch = rows.slice(startIndex, startIndex + MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT);
tx.insert(agentSnapshots).values(batch).run();
}
});
options.logger.info(
{ importedAgents: records.length },
"Imported legacy agent snapshots into the database",
);
return {
status: "imported",
importedAgents: records.length,
};
}
async function readLegacyAgentRecords(baseDir: string, logger: Logger): Promise<StoredAgentRecord[]> {
let entries: Array<import("node:fs").Dirent> = [];
try {
entries = await fs.readdir(baseDir, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
const recordsById = new Map<string, StoredAgentRecord>();
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith(".json")) {
const record = await readRecordFile(path.join(baseDir, entry.name), logger);
if (record) {
recordsById.set(record.id, record);
}
continue;
}
if (!entry.isDirectory()) {
continue;
}
let childEntries: Array<import("node:fs").Dirent> = [];
try {
childEntries = await fs.readdir(path.join(baseDir, entry.name), { withFileTypes: true });
} catch {
continue;
}
for (const childEntry of childEntries) {
if (!childEntry.isFile() || !childEntry.name.endsWith(".json")) {
continue;
}
const record = await readRecordFile(path.join(baseDir, entry.name, childEntry.name), logger);
if (record) {
recordsById.set(record.id, record);
}
}
}
return Array.from(recordsById.values());
}
async function readRecordFile(filePath: string, logger: Logger): Promise<StoredAgentRecord | null> {
try {
const raw = await fs.readFile(filePath, "utf8");
return parseStoredAgentRecord(JSON.parse(raw));
} catch (error) {
logger.error({ err: error, filePath }, "Skipping invalid legacy agent snapshot");
return null;
}
}
async function hasAnyAgentSnapshotRows(db: PaseoDatabaseHandle["db"]): Promise<boolean> {
const rows = await db.select({ count: count() }).from(agentSnapshots);
return (rows[0]?.count ?? 0) > 0;
}

View File

@@ -0,0 +1,191 @@
import os from "node:os";
import path from "node:path";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js";
import { importLegacyProjectWorkspaceJson } from "./legacy-project-workspace-import.js";
import { projects, workspaces } from "./schema.js";
describe("importLegacyProjectWorkspaceJson", () => {
let tmpDir: string;
let paseoHome: string;
let dbDir: string;
let database: PaseoDatabaseHandle;
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-legacy-import-"));
paseoHome = path.join(tmpDir, ".paseo");
dbDir = path.join(paseoHome, "db");
mkdirSync(paseoHome, { recursive: true });
database = await openPaseoDatabase(dbDir);
});
afterEach(async () => {
await database?.close();
rmSync(tmpDir, { recursive: true, force: true });
});
test("imports legacy projects and workspaces once when the DB is empty", async () => {
writeLegacyJson({
paseoHome,
projectsJson: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspacesJson: [
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
const result = await importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "imported",
importedProjects: 1,
importedWorkspaces: 1,
});
const projectRows = await database.db.select().from(projects);
expect(projectRows).toEqual([
expect.objectContaining({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
}),
]);
expect(typeof projectRows[0]!.id).toBe("number");
const workspaceRows = await database.db.select().from(workspaces);
expect(workspaceRows).toEqual([
expect.objectContaining({
projectId: projectRows[0]!.id,
directory: "/tmp/project-1",
kind: "checkout",
displayName: "main",
}),
]);
});
test("skips import when the DB already has project or workspace data", async () => {
// Seed with a project in the new schema format
const [inserted] = await database.db
.insert(projects)
.values({
directory: "/tmp/existing-project",
kind: "git",
displayName: "Existing Project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
})
.returning({ id: projects.id });
writeLegacyJson({
paseoHome,
projectsJson: [
{
projectId: "legacy-project",
rootPath: "/tmp/legacy-project",
kind: "git",
displayName: "Legacy Project",
createdAt: "2026-03-02T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspacesJson: [],
});
const result = await importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "skipped",
reason: "database-not-empty",
});
// Only the existing project should be in DB
const allProjects = await database.db.select().from(projects);
expect(allProjects).toHaveLength(1);
expect(allProjects[0]!.id).toBe(inserted!.id);
});
test("rolls back the whole import when workspace insertion fails", async () => {
writeLegacyJson({
paseoHome,
projectsJson: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspacesJson: [
{
workspaceId: "workspace-1",
projectId: "missing-project",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
await expect(
importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome,
logger: createTestLogger(),
}),
).rejects.toThrow();
expect(await database.db.select().from(projects)).toEqual([]);
expect(await database.db.select().from(workspaces)).toEqual([]);
});
});
function writeLegacyJson(input: {
paseoHome: string;
projectsJson: unknown[];
workspacesJson: unknown[];
}): void {
const projectsPath = path.join(input.paseoHome, "projects", "projects.json");
const workspacesPath = path.join(input.paseoHome, "projects", "workspaces.json");
mkdirSync(path.dirname(projectsPath), { recursive: true });
writeFileSync(projectsPath, JSON.stringify(input.projectsJson, null, 2), { encoding: "utf8", flag: "w" });
writeFileSync(workspacesPath, JSON.stringify(input.workspacesJson, null, 2), {
encoding: "utf8",
flag: "w",
});
}

View File

@@ -0,0 +1,172 @@
import path from "node:path";
import { promises as fs } from "node:fs";
import { count } from "drizzle-orm";
import type { Logger } from "pino";
import { z } from "zod";
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
import { projects, workspaces } from "./schema.js";
// Legacy JSON schemas — these match the old pre-migration format
const LegacyProjectSchema = z.object({
projectId: z.string(),
rootPath: z.string(),
kind: z.string(),
displayName: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
});
const LegacyWorkspaceSchema = z.object({
workspaceId: z.string(),
projectId: z.string(),
cwd: z.string(),
kind: z.string(),
displayName: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
});
export type LegacyProjectWorkspaceImportResult =
| {
status: "imported";
importedProjects: number;
importedWorkspaces: number;
}
| {
status: "skipped";
reason: "database-not-empty" | "no-legacy-files";
};
export async function importLegacyProjectWorkspaceJson(options: {
db: PaseoDatabaseHandle["db"];
paseoHome: string;
logger: Logger;
}): Promise<LegacyProjectWorkspaceImportResult> {
const projectsPath = path.join(options.paseoHome, "projects", "projects.json");
const workspacesPath = path.join(options.paseoHome, "projects", "workspaces.json");
const [projectRows, workspaceRows, databaseHasRows] = await Promise.all([
readLegacyProjects(projectsPath),
readLegacyWorkspaces(workspacesPath),
hasAnyProjectWorkspaceRows(options.db),
]);
if (databaseHasRows) {
options.logger.info("Skipping legacy project/workspace JSON import because the DB is not empty");
return {
status: "skipped",
reason: "database-not-empty",
};
}
if (projectRows.length === 0 && workspaceRows.length === 0) {
options.logger.info("Skipping legacy project/workspace JSON import because no legacy files exist");
return {
status: "skipped",
reason: "no-legacy-files",
};
}
options.db.transaction((tx) => {
// Insert projects, mapping old format to new schema
const projectDirectoryToId = new Map<string, number>();
for (const legacy of projectRows) {
const row = tx
.insert(projects)
.values({
directory: legacy.rootPath,
displayName: legacy.displayName,
kind: legacy.kind === "non_git" ? "directory" : legacy.kind,
createdAt: legacy.createdAt,
updatedAt: legacy.updatedAt,
archivedAt: legacy.archivedAt,
})
.returning({ id: projects.id })
.get();
projectDirectoryToId.set(legacy.rootPath, row!.id);
}
// Build a map from legacy projectId -> new integer id
const legacyProjectIdToNewId = new Map<string, number>();
for (const legacy of projectRows) {
const newId = projectDirectoryToId.get(legacy.rootPath);
if (newId !== undefined) {
legacyProjectIdToNewId.set(legacy.projectId, newId);
}
}
// Insert workspaces, resolving project FK
for (const legacy of workspaceRows) {
const projectId = legacyProjectIdToNewId.get(legacy.projectId);
if (projectId === undefined) {
throw new Error(`Legacy workspace ${legacy.workspaceId} references unknown project ${legacy.projectId}`);
}
tx
.insert(workspaces)
.values({
projectId,
directory: legacy.cwd,
displayName: legacy.displayName,
kind:
legacy.kind === "local_checkout" || legacy.kind === "directory"
? "checkout"
: legacy.kind,
createdAt: legacy.createdAt,
updatedAt: legacy.updatedAt,
archivedAt: legacy.archivedAt,
})
.run();
}
});
options.logger.info(
{
importedProjects: projectRows.length,
importedWorkspaces: workspaceRows.length,
},
"Imported legacy project/workspace JSON into the database",
);
return {
status: "imported",
importedProjects: projectRows.length,
importedWorkspaces: workspaceRows.length,
};
}
async function readLegacyProjects(filePath: string) {
const raw = await readOptionalJsonFile(filePath);
return raw ? z.array(LegacyProjectSchema).parse(raw) : [];
}
async function readLegacyWorkspaces(filePath: string) {
const raw = await readOptionalJsonFile(filePath);
return raw ? z.array(LegacyWorkspaceSchema).parse(raw) : [];
}
async function readOptionalJsonFile(filePath: string): Promise<unknown | null> {
try {
const raw = await fs.readFile(filePath, "utf8");
return JSON.parse(raw);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return null;
}
throw error;
}
}
async function hasAnyProjectWorkspaceRows(db: PaseoDatabaseHandle["db"]): Promise<boolean> {
const [projectCountRows, workspaceCountRows] = await Promise.all([
db.select({ count: count() }).from(projects),
db.select({ count: count() }).from(workspaces),
]);
const projectCount = projectCountRows[0]?.count ?? 0;
const workspaceCount = workspaceCountRows[0]?.count ?? 0;
return projectCount > 0 || workspaceCount > 0;
}

View File

@@ -0,0 +1,12 @@
import { fileURLToPath } from "node:url";
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
const migrationsFolder = fileURLToPath(new URL("./migrations", import.meta.url));
export async function runPaseoDbMigrations(
db: BetterSQLite3Database<typeof import("./schema.js").paseoDbSchema>,
): Promise<void> {
await migrate(db, { migrationsFolder });
}

View File

@@ -0,0 +1,61 @@
CREATE TABLE `projects` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`directory` text NOT NULL,
`display_name` text NOT NULL,
`kind` text NOT NULL,
`git_remote` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`archived_at` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `projects_directory_unique` ON `projects` (`directory`);
--> statement-breakpoint
CREATE TABLE `workspaces` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`project_id` integer NOT NULL,
`directory` text NOT NULL,
`display_name` text NOT NULL,
`kind` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`archived_at` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `workspaces_directory_unique` ON `workspaces` (`directory`);
--> statement-breakpoint
CREATE INDEX `workspaces_project_id_idx` ON `workspaces` (`project_id`);
--> statement-breakpoint
CREATE TABLE `agent_snapshots` (
`agent_id` text PRIMARY KEY NOT NULL,
`provider` text NOT NULL,
`workspace_id` integer NOT NULL,
`cwd` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`last_activity_at` text,
`last_user_message_at` text,
`title` text,
`labels` text NOT NULL,
`last_status` text NOT NULL,
`last_mode_id` text,
`config` text,
`runtime_info` text,
`persistence` text,
`requires_attention` integer NOT NULL,
`attention_reason` text,
`attention_timestamp` text,
`internal` integer NOT NULL,
`archived_at` text,
FOREIGN KEY (`workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `agent_timeline_rows` (
`agent_id` text NOT NULL,
`seq` integer NOT NULL,
`committed_at` text NOT NULL,
`item` text NOT NULL,
`item_kind` text,
PRIMARY KEY(`agent_id`, `seq`)
);

View File

@@ -0,0 +1,14 @@
{
"version": "7",
"dialect": "sqlite",
"id": "7ccf4685-bd8f-41a6-bafb-44712c1fe0d7",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {},
"views": {},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"checkConstraints": {}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1774405361702,
"tag": "0000_sqlite_initial",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,78 @@
import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type { AgentPersistenceHandle, AgentRuntimeInfo, AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
export const projects = sqliteTable("projects", {
id: integer("id").primaryKey({ autoIncrement: true }),
directory: text("directory").notNull().unique(),
displayName: text("display_name").notNull(),
kind: text("kind").notNull(),
gitRemote: text("git_remote"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
archivedAt: text("archived_at"),
});
export const workspaces = sqliteTable(
"workspaces",
{
id: integer("id").primaryKey({ autoIncrement: true }),
projectId: integer("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
directory: text("directory").notNull().unique(),
displayName: text("display_name").notNull(),
kind: text("kind").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
archivedAt: text("archived_at"),
},
(table) => [index("workspaces_project_id_idx").on(table.projectId)],
);
export const agentSnapshots = sqliteTable("agent_snapshots", {
agentId: text("agent_id").primaryKey(),
provider: text("provider").notNull(),
workspaceId: integer("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
cwd: text("cwd").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
lastActivityAt: text("last_activity_at"),
lastUserMessageAt: text("last_user_message_at"),
title: text("title"),
labels: text("labels", { mode: "json" }).$type<StoredAgentRecord["labels"]>().notNull(),
lastStatus: text("last_status").notNull(),
lastModeId: text("last_mode_id"),
config: text("config", { mode: "json" }).$type<StoredAgentRecord["config"]>(),
runtimeInfo: text("runtime_info", { mode: "json" }).$type<AgentRuntimeInfo>(),
persistence: text("persistence", { mode: "json" }).$type<AgentPersistenceHandle>(),
requiresAttention: integer("requires_attention", { mode: "boolean" }).notNull(),
attentionReason: text("attention_reason"),
attentionTimestamp: text("attention_timestamp"),
internal: integer("internal", { mode: "boolean" }).notNull(),
archivedAt: text("archived_at"),
});
export const agentTimelineRows = sqliteTable(
"agent_timeline_rows",
{
agentId: text("agent_id").notNull(),
seq: integer("seq").notNull(),
committedAt: text("committed_at").notNull(),
item: text("item", { mode: "json" }).$type<AgentTimelineItem>().notNull(),
itemKind: text("item_kind"),
},
(table) => [
primaryKey({ columns: [table.agentId, table.seq], name: "agent_timeline_rows_pk" }),
],
);
export const paseoDbSchema = {
projects,
workspaces,
agentSnapshots,
agentTimelineRows,
};

View File

@@ -0,0 +1,316 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { and, asc, desc, eq, gt, lt, sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import { openPaseoDatabase } from "./sqlite-database.js";
import { runPaseoDbMigrations } from "./migrations.js";
import {
agentSnapshots,
agentTimelineRows,
projects,
workspaces,
} from "./schema.js";
function createTimestamp(day: number): string {
return `2026-03-${String(day).padStart(2, "0")}T00:00:00.000Z`;
}
function createTimelineItem(type: AgentTimelineItem["type"], suffix: string): AgentTimelineItem {
if (type === "user_message") {
return { type, text: `user-${suffix}`, messageId: `msg-${suffix}` };
}
if (type === "assistant_message" || type === "reasoning") {
return { type, text: `${type}-${suffix}` };
}
return { type: "error", message: `error-${suffix}` };
}
describe("SQLite database contract", () => {
let tmpDir: string;
let dataDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-db-"));
dataDir = path.join(tmpDir, "db");
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
test("creates, migrates, closes, and reopens a persistent database", async () => {
const database = await openPaseoDatabase(dataDir);
const [project] = await database.db.insert(projects).values({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
gitRemote: "git@github.com:acme/project-1.git",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
}).returning();
await database.close();
const reopened = await openPaseoDatabase(dataDir);
const rows = await reopened.db.select().from(projects);
expect(rows).toEqual([
{
id: project.id,
directory: "/tmp/project-1",
displayName: "Project One",
kind: "git",
gitRemote: "git@github.com:acme/project-1.git",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
},
]);
await reopened.close();
});
test("supports project and workspace linkage plus archive field updates", async () => {
const database = await openPaseoDatabase(dataDir);
const [project] = await database.db.insert(projects).values({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
gitRemote: null,
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
}).returning();
const [workspace] = await database.db.insert(workspaces).values({
projectId: project.id,
directory: "/tmp/project-1",
kind: "checkout",
displayName: "main",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
}).returning();
await database.db
.update(workspaces)
.set({ archivedAt: createTimestamp(2), updatedAt: createTimestamp(2) })
.where(eq(workspaces.id, workspace.id));
const linkedRows = await database.db
.select({
projectId: projects.id,
workspaceId: workspaces.id,
workspaceArchivedAt: workspaces.archivedAt,
})
.from(workspaces)
.innerJoin(projects, eq(workspaces.projectId, projects.id));
expect(linkedRows).toEqual([
{
projectId: project.id,
workspaceId: workspace.id,
workspaceArchivedAt: createTimestamp(2),
},
]);
await database.close();
});
test("supports snapshot insert, get, update, and project-delete cascade with integer workspace IDs", async () => {
const database = await openPaseoDatabase(dataDir);
const [project] = await database.db.insert(projects).values({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
gitRemote: null,
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
}).returning();
const [workspace] = await database.db.insert(workspaces).values({
projectId: project.id,
directory: "/tmp/project-1",
kind: "checkout",
displayName: "main",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
}).returning();
await database.db.insert(agentSnapshots).values({
agentId: "agent-1",
provider: "codex",
workspaceId: workspace.id,
cwd: "/tmp/project-1",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
lastActivityAt: createTimestamp(1),
lastUserMessageAt: null,
title: "Agent One",
labels: { surface: "workspace" },
lastStatus: "idle",
lastModeId: "plan",
config: { model: "gpt-5.1", modeId: "plan" },
runtimeInfo: { provider: "codex", sessionId: "session-1" },
persistence: { provider: "codex", sessionId: "session-1" },
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: null,
});
await database.db
.update(agentSnapshots)
.set({
updatedAt: createTimestamp(2),
lastStatus: "running",
title: "Agent One Updated",
archivedAt: createTimestamp(3),
})
.where(eq(agentSnapshots.agentId, "agent-1"));
const rows = await database.db
.select()
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, "agent-1"));
expect(rows).toEqual([
{
agentId: "agent-1",
provider: "codex",
workspaceId: workspace.id,
cwd: "/tmp/project-1",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(2),
lastActivityAt: createTimestamp(1),
lastUserMessageAt: null,
title: "Agent One Updated",
labels: { surface: "workspace" },
lastStatus: "running",
lastModeId: "plan",
config: { model: "gpt-5.1", modeId: "plan" },
runtimeInfo: { provider: "codex", sessionId: "session-1" },
persistence: { provider: "codex", sessionId: "session-1" },
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: createTimestamp(3),
},
]);
await database.db.delete(projects).where(eq(projects.id, project.id));
expect(await database.db.select().from(workspaces)).toEqual([]);
expect(await database.db.select().from(agentSnapshots)).toEqual([]);
await database.close();
});
test("rejects agent snapshots without a workspace ID", async () => {
const database = await openPaseoDatabase(dataDir);
await expect(
database.db.insert(agentSnapshots).values({
agentId: "agent-1",
provider: "codex",
cwd: "/tmp/project-1",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
lastActivityAt: createTimestamp(1),
lastUserMessageAt: null,
title: "Agent One",
labels: { surface: "workspace" },
lastStatus: "idle",
lastModeId: "plan",
config: { model: "gpt-5.1", modeId: "plan" },
runtimeInfo: { provider: "codex", sessionId: "session-1" },
persistence: { provider: "codex", sessionId: "session-1" },
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: null,
} as typeof agentSnapshots.$inferInsert),
).rejects.toThrow();
await database.close();
});
test("supports timeline append and tail, after-seq, before-seq access patterns in committed order", async () => {
const database = await openPaseoDatabase(dataDir);
const rows = [1, 2, 3, 4].map((seq) => ({
agentId: "agent-1",
seq,
committedAt: createTimestamp(seq),
item: createTimelineItem(seq === 1 ? "user_message" : "assistant_message", String(seq)),
itemKind: seq === 1 ? "user_message" : "assistant_message",
}));
await database.db.insert(agentTimelineRows).values(rows);
const tailRows = await database.db
.select()
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, "agent-1"))
.orderBy(desc(agentTimelineRows.seq))
.limit(2);
expect(tailRows.map((row) => row.seq).reverse()).toEqual([3, 4]);
const afterRows = await database.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, "agent-1"), gt(agentTimelineRows.seq, 2)))
.orderBy(asc(agentTimelineRows.seq));
expect(afterRows.map((row) => row.seq)).toEqual([3, 4]);
const beforeRows = await database.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, "agent-1"), lt(agentTimelineRows.seq, 4)))
.orderBy(desc(agentTimelineRows.seq))
.limit(2);
expect(beforeRows.map((row) => row.seq).reverse()).toEqual([2, 3]);
await database.close();
});
test("enforces per-agent seq uniqueness and reruns migrations without drift", async () => {
const database = await openPaseoDatabase(dataDir);
await database.db.insert(agentTimelineRows).values({
agentId: "agent-1",
seq: 1,
committedAt: createTimestamp(1),
item: createTimelineItem("assistant_message", "1"),
itemKind: "assistant_message",
});
await expect(
database.db.insert(agentTimelineRows).values({
agentId: "agent-1",
seq: 1,
committedAt: createTimestamp(2),
item: createTimelineItem("assistant_message", "duplicate"),
itemKind: "assistant_message",
}),
).rejects.toThrow();
await runPaseoDbMigrations(database.db);
const migrationRows = database.client
.prepare("select * from __drizzle_migrations order by created_at")
.all();
expect(migrationRows).toHaveLength(1);
await database.close();
});
});

View File

@@ -0,0 +1,31 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { runPaseoDbMigrations } from "./migrations.js";
import { paseoDbSchema } from "./schema.js";
export interface PaseoDatabaseHandle {
client: Database.Database;
db: BetterSQLite3Database<typeof paseoDbSchema>;
close(): Promise<void>;
}
export async function openPaseoDatabase(dataDir: string): Promise<PaseoDatabaseHandle> {
mkdirSync(dataDir, { recursive: true });
const databasePath = path.join(dataDir, "paseo.sqlite");
const client = new Database(databasePath);
client.pragma("foreign_keys = ON");
client.pragma("journal_mode = WAL");
const db = drizzle(client, { schema: paseoDbSchema });
await runPaseoDbMigrations(db);
return {
client,
db,
async close(): Promise<void> {
client.close();
},
};
}

View File

@@ -33,6 +33,7 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsTerminalMode: false,
};
interface ScriptedAgentBehavior {

View File

@@ -785,6 +785,7 @@ export class LoopService {
model: loop.workerModel ?? loop.model ?? undefined,
title: buildWorkerTitle(loop, iteration.index),
internal: true,
terminal: false,
};
}
@@ -795,6 +796,7 @@ export class LoopService {
model: loop.verifierModel ?? loop.model ?? undefined,
title: buildVerifierTitle(loop, iteration.index),
internal: true,
terminal: false,
};
}

View File

@@ -1,83 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import type { ManagedAgent } from "./agent/agent-manager.js";
import { describe, expect, test } from "vitest";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import {
attachAgentStoragePersistence,
buildConfigOverrides,
buildSessionConfig,
} from "./persistence-hooks.js";
import type {
AgentPermissionRequest,
AgentSession,
AgentSessionConfig,
} from "./agent/agent-sdk-types.js";
const testLogger = {
child: () => testLogger,
error: vi.fn(),
} as any;
type ManagedAgentOverrides = Omit<
Partial<ManagedAgent>,
"config" | "pendingPermissions" | "session" | "activeForegroundTurnId"
> & {
config?: Partial<AgentSessionConfig>;
pendingPermissions?: Map<string, AgentPermissionRequest>;
session?: AgentSession | null;
activeForegroundTurnId?: string | null;
};
function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent {
const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z");
const provider = overrides.provider ?? "claude";
const cwd = overrides.cwd ?? "/tmp/project";
const lifecycle = overrides.lifecycle ?? "idle";
const configOverrides = overrides.config ?? {};
const config: AgentSessionConfig = {
provider,
cwd,
modeId: configOverrides.modeId ?? "plan",
model: configOverrides.model ?? "claude-3.5-sonnet",
extra: configOverrides.extra ?? { claude: { tone: "focused" } },
};
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
const activeForegroundTurnId =
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
const agent: ManagedAgent = {
id: overrides.id ?? "agent-1",
provider,
cwd,
session,
capabilities: overrides.capabilities ?? {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
config,
lifecycle,
createdAt: overrides.createdAt ?? now,
updatedAt: overrides.updatedAt ?? now,
availableModes: overrides.availableModes ?? [],
currentModeId: overrides.currentModeId ?? config.modeId ?? null,
pendingPermissions: overrides.pendingPermissions ?? new Map<string, AgentPermissionRequest>(),
activeForegroundTurnId,
foregroundTurnWaiters: new Set(),
unsubscribeSession: null,
timeline: overrides.timeline ?? [],
persistence: overrides.persistence ?? null,
historyPrimed: overrides.historyPrimed ?? true,
lastUserMessageAt: overrides.lastUserMessageAt ?? now,
lastUsage: overrides.lastUsage,
lastError: overrides.lastError,
};
return agent;
}
import { buildConfigOverrides, buildSessionConfig } from "./persistence-hooks.js";
function createRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord {
const now = new Date().toISOString();
@@ -100,43 +23,6 @@ function createRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord
}
describe("persistence hooks", () => {
test("attachAgentStoragePersistence forwards agent snapshots", async () => {
const applySnapshot = vi.fn().mockResolvedValue(undefined);
let subscriber: (event: any) => void = () => {
throw new Error("Agent manager subscriber was not registered");
};
const agentManager = {
subscribe: vi.fn((callback: (event: any) => void) => {
subscriber = callback;
return () => {
subscriber = () => {
throw new Error("Agent manager subscriber was not registered");
};
};
}),
};
attachAgentStoragePersistence(
testLogger,
agentManager as any,
{
applySnapshot,
list: vi.fn(),
} as any,
);
expect(agentManager.subscribe).toHaveBeenCalledTimes(1);
const agent = createManagedAgent();
subscriber({ type: "agent_state", agent });
expect(applySnapshot).toHaveBeenCalledWith(agent);
subscriber({
type: "agent_stream",
agentId: agent.id,
event: { type: "timeline", item: { type: "assistant_message", text: "hi" } },
});
expect(applySnapshot).toHaveBeenCalledTimes(1);
});
test("buildConfigOverrides carries systemPrompt and mcpServers", () => {
const record = createRecord({
title: "Voice agent (current)",
@@ -208,4 +94,23 @@ describe("persistence hooks", () => {
},
});
});
test("buildSessionConfig accepts terminal-only providers from the canonical manifest", () => {
const record = createRecord({
provider: "gemini",
persistence: {
provider: "gemini",
sessionId: "session-123",
},
config: {
terminal: true,
},
});
expect(buildSessionConfig(record)).toMatchObject({
provider: "gemini",
cwd: "/tmp/project",
terminal: true,
});
});
});

View File

@@ -1,48 +1,13 @@
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentProvider, AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type pino from "pino";
type LoggerLike = {
child(bindings: Record<string, unknown>): LoggerLike;
error(...args: any[]): void;
};
function getLogger(logger: LoggerLike): LoggerLike {
return logger.child({ module: "persistence" });
}
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
function isKnownProvider(provider: string): provider is AgentProvider {
return provider === "claude" || provider === "codex" || provider === "opencode";
}
/**
* Attach AgentStorage persistence to an AgentManager instance so every
* agent_state snapshot is flushed to disk.
*/
export function attachAgentStoragePersistence(
logger: LoggerLike,
agentManager: AgentManagerStateSource,
storage: AgentStoragePersistence,
): () => void {
const log = getLogger(logger);
const unsubscribe = agentManager.subscribe((event) => {
if (event.type !== "agent_state") {
return;
}
void storage.applySnapshot(event.agent).catch((error) => {
log.error({ err: error, agentId: event.agent.id }, "Failed to persist agent snapshot");
});
});
return unsubscribe;
}
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import { isValidAgentProvider } from "./agent/provider-manifest.js";
export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSessionConfig> {
return {
cwd: record.cwd,
terminal: record.config?.terminal ?? undefined,
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
model: record.config?.model ?? undefined,
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
@@ -54,13 +19,14 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
}
export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfig {
if (!isKnownProvider(record.provider)) {
if (!isValidAgentProvider(record.provider)) {
throw new Error(`Unknown provider '${record.provider}'`);
}
const overrides = buildConfigOverrides(record);
return {
provider: record.provider,
cwd: record.cwd,
terminal: overrides.terminal,
modeId: overrides.modeId,
model: overrides.model,
thinkingOptionId: overrides.thinkingOptionId,
@@ -71,6 +37,30 @@ export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfi
};
}
export function toAgentPersistenceHandle(
logger: pino.Logger,
handle: StoredAgentRecord["persistence"],
) {
if (!handle) {
return null;
}
const provider = handle.provider;
if (!isValidAgentProvider(provider)) {
logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`);
return null;
}
if (!handle.sessionId) {
logger.warn("Ignoring persistence handle missing sessionId");
return null;
}
return {
provider,
sessionId: handle.sessionId,
nativeHandle: handle.nativeHandle,
metadata: handle.metadata,
};
}
export function extractTimestamps(record: StoredAgentRecord): {
createdAt: Date;
updatedAt: Date;

View File

@@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "vitest";
describe("agent loading boundary", () => {
test("session runtime code does not directly hydrate provider history", () => {
const sessionSource = readFileSync(new URL("./session.ts", import.meta.url), "utf8");
const agentLoadingSource = readFileSync(
new URL("./agent-loading-service.ts", import.meta.url),
"utf8",
);
expect(sessionSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/);
expect(agentLoadingSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/);
});
});

View File

@@ -0,0 +1,391 @@
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import pino from "pino";
import { describe, expect, test, vi } from "vitest";
import { AgentLoadingService } from "./agent-loading-service.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js";
import { openPaseoDatabase } from "./db/sqlite-database.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
function createStoredAgentRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord {
const now = "2026-03-25T00:00:00.000Z";
return {
id: "agent-compat-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: now,
updatedAt: now,
title: null,
labels: {},
lastStatus: "idle",
config: {
model: "gpt-5.1-codex-mini",
},
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
...overrides,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createCompatibilitySnapshot(overrides?: Partial<Record<string, unknown>>) {
return {
id: "agent-compat-1",
provider: "codex",
cwd: "/tmp/project",
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
...overrides,
};
}
describe("AgentLoadingService", () => {
test("ensureAgentLoaded seeds the live timeline from durable rows for an unloaded persisted agent", async () => {
const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-load-"));
const logger = pino({ level: "silent" });
const database = await openPaseoDatabase(path.join(workspaceRoot, "db"));
try {
const storage = new AgentStorage(path.join(workspaceRoot, "agents"), logger);
const manager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
durableTimelineStore: new DbAgentTimelineStore(database.db),
logger,
idFactory: () => "00000000-0000-4000-8000-000000000301",
});
const service = new AgentLoadingService({
agentManager: manager as any,
agentStorage: storage as any,
logger,
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workspaceRoot,
model: "gpt-5.1-codex-mini",
});
await manager.runAgent(snapshot.id, "say 'timeline test'");
await manager.flush();
await storage.flush();
rmSync(
path.join(
os.tmpdir(),
"paseo-fake-provider-history",
"codex",
`${snapshot.persistence?.sessionId}.jsonl`,
),
{ force: true },
);
await manager.closeAgent(snapshot.id);
const loaded = await service.ensureAgentLoaded({ agentId: snapshot.id });
const durableTimeline = await manager.fetchTimeline(snapshot.id, {
direction: "tail",
limit: 0,
});
expect(loaded.id).toBe(snapshot.id);
expect(manager.getTimeline(snapshot.id)).toEqual([]);
expect(durableTimeline.rows.map((row) => row.item)).toEqual([
{ type: "assistant_message", text: "timeline test" },
]);
} finally {
await database.close();
rmSync(workspaceRoot, { recursive: true, force: true });
}
});
test("ensureAgentLoaded succeeds when provider history is absent", async () => {
const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-empty-"));
const logger = pino({ level: "silent" });
try {
const storage = new AgentStorage(path.join(workspaceRoot, "agents"), logger);
const manager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000302",
});
const service = new AgentLoadingService({
agentManager: manager as any,
agentStorage: storage as any,
logger,
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workspaceRoot,
model: "gpt-5.1-codex-mini",
});
await manager.flush();
await storage.flush();
await manager.closeAgent(snapshot.id);
const loaded = await service.ensureAgentLoaded({ agentId: snapshot.id });
expect(loaded.id).toBe(snapshot.id);
expect(manager.getTimeline(snapshot.id)).toEqual([]);
} finally {
rmSync(workspaceRoot, { recursive: true, force: true });
}
});
test("ensureAgentLoaded dedupes concurrent cold-load bootstrap", async () => {
const deferred = createDeferred<any>();
let currentAgent: any = null;
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-dedupe" });
const agentStorage = {
get: vi.fn(async () =>
createStoredAgentRecord({
id: "agent-compat-dedupe",
cwd: "/tmp/dedupe",
persistence: {
provider: "codex",
sessionId: "provider-session-dedupe",
},
}),
),
};
const agentManager = {
getAgent: vi.fn(() => currentAgent),
resumeAgentFromPersistence: vi.fn(async () => deferred.promise),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: agentStorage as any,
logger: logger as any,
});
const firstLoad = service.ensureAgentLoaded({ agentId: "agent-compat-dedupe" });
const secondLoad = service.ensureAgentLoaded({ agentId: "agent-compat-dedupe" });
deferred.resolve(snapshot);
const [firstResult, secondResult] = await Promise.all([firstLoad, secondLoad]);
expect(firstResult).toEqual(snapshot);
expect(secondResult).toEqual(snapshot);
expect(agentStorage.get).toHaveBeenCalledTimes(1);
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledTimes(1);
});
test("resumeAgent delegates to manager resume", async () => {
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-resume" });
const agentManager = {
getAgent: vi.fn(() => null),
resumeAgentFromPersistence: vi.fn(async () => snapshot),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: async () => null,
} as any,
logger: logger as any,
});
const result = await service.resumeAgent({
handle: {
provider: "codex",
sessionId: "provider-session-resume",
},
overrides: {
model: "gpt-5.4",
},
});
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
{
provider: "codex",
sessionId: "provider-session-resume",
},
{
model: "gpt-5.4",
},
);
expect(result).toEqual(snapshot);
});
test("refreshAgent reloads loaded persisted agents", async () => {
const existing = createCompatibilitySnapshot({ id: "agent-compat-refresh-loaded" });
const reloaded = createCompatibilitySnapshot({ id: "agent-compat-refresh-loaded" });
let currentAgent: any = existing;
const agentManager = {
getAgent: vi.fn(() => currentAgent),
resumeAgentFromPersistence: vi.fn(),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(async () => {
currentAgent = reloaded;
return reloaded;
}),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: async () => null,
} as any,
logger: logger as any,
});
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-loaded" });
expect(agentManager.reloadAgentSession).toHaveBeenCalledWith("agent-compat-refresh-loaded");
expect(result).toEqual(reloaded);
});
test("refreshAgent keeps loaded non-persisted agents without reloading", async () => {
const existing = createCompatibilitySnapshot({
id: "agent-compat-refresh-live",
persistence: null,
});
const agentManager = {
getAgent: vi.fn(() => existing),
resumeAgentFromPersistence: vi.fn(),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: async () => null,
} as any,
logger: logger as any,
});
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-live" });
expect(agentManager.reloadAgentSession).not.toHaveBeenCalled();
expect(result).toEqual(existing);
});
test("refreshAgent resumes unloaded persisted agents", async () => {
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-refresh-cold" });
const record = createStoredAgentRecord({
id: "agent-compat-refresh-cold",
cwd: "/tmp/refresh-cold",
persistence: {
provider: "codex",
sessionId: "provider-session-refresh-cold",
},
});
const agentManager = {
getAgent: vi.fn(() => null),
resumeAgentFromPersistence: vi.fn(async () => snapshot),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: vi.fn(async () => record),
} as any,
logger: logger as any,
});
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-cold" });
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
{
provider: "codex",
sessionId: "provider-session-refresh-cold",
nativeHandle: undefined,
metadata: undefined,
},
{
cwd: "/tmp/refresh-cold",
modeId: undefined,
model: "gpt-5.1-codex-mini",
thinkingOptionId: undefined,
title: undefined,
extra: undefined,
systemPrompt: undefined,
mcpServers: undefined,
},
"agent-compat-refresh-cold",
{
createdAt: new Date("2026-03-25T00:00:00.000Z"),
updatedAt: new Date("2026-03-25T00:00:00.000Z"),
lastUserMessageAt: null,
labels: {},
},
);
expect(result).toEqual(snapshot);
});
test("refreshAgent preserves the unloaded no-persistence error", async () => {
const service = new AgentLoadingService({
agentManager: {
getAgent: vi.fn(() => null),
resumeAgentFromPersistence: vi.fn(),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
} as any,
agentStorage: {
get: async () =>
createStoredAgentRecord({
id: "agent-compat-no-persistence",
persistence: null,
}),
} as any,
logger: {
child: () => ({
child: () => null,
info: vi.fn(),
warn: vi.fn(),
}),
info: vi.fn(),
warn: vi.fn(),
} as any,
});
await expect(
service.refreshAgent({ agentId: "agent-compat-no-persistence" }),
).rejects.toThrow("Agent agent-compat-no-persistence cannot be refreshed because it lacks persistence");
});
});

View File

@@ -3,7 +3,7 @@ import { join } from "node:path";
import type { Logger } from "pino";
import { AgentManager } from "../agent/agent-manager.js";
import type { ManagedAgent } from "../agent/agent-manager.js";
import { AgentStorage } from "../agent/agent-storage.js";
import type { AgentSnapshotStore } from "../agent/agent-snapshot-store.js";
import type {
AgentPromptInput,
AgentSessionConfig,
@@ -97,7 +97,7 @@ export interface ScheduleServiceOptions {
paseoHome: string;
logger: Logger;
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
now?: () => Date;
runner?: (schedule: StoredSchedule) => Promise<ScheduleExecutionResult>;
}
@@ -106,7 +106,7 @@ export class ScheduleService {
private readonly store: ScheduleStore;
private readonly logger: Logger;
private readonly agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly agentStorage: AgentSnapshotStore;
private readonly now: () => Date;
private readonly runner: (schedule: StoredSchedule) => Promise<ScheduleExecutionResult>;
private readonly runningScheduleIds = new Set<string>();
@@ -368,6 +368,9 @@ export class ScheduleService {
private async executeSchedule(schedule: StoredSchedule): Promise<ScheduleExecutionResult> {
if (schedule.target.type === "agent") {
const agent = await this.ensureAgentLoaded(schedule.target.agentId);
if (agent.terminal) {
throw new Error(`Agent ${agent.id} is a terminal agent and cannot be targeted by schedules`);
}
if (this.agentManager.hasInFlightRun(agent.id)) {
throw new Error(`Agent ${agent.id} already has an active run`);
}
@@ -398,6 +401,7 @@ export class ScheduleService {
extra: schedule.target.config.extra,
systemPrompt: schedule.target.config.systemPrompt,
mcpServers: schedule.target.config.mcpServers as AgentSessionConfig["mcpServers"],
terminal: false,
};
const labels = {
"paseo.schedule-id": schedule.id,

View File

@@ -0,0 +1,331 @@
import { describe, expect, test, vi } from "vitest";
import { Session } from "./session.js";
function createStoredAgentRecord(overrides?: Partial<Record<string, unknown>>) {
return {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: "2026-03-24T00:00:00.000Z",
updatedAt: "2026-03-24T00:00:00.000Z",
title: null,
labels: {},
lastStatus: "idle",
config: null,
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
archivedAt: null,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
...overrides,
};
}
function createCompatibilitySnapshot(overrides?: Partial<Record<string, unknown>>) {
return {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
...overrides,
};
}
function createSessionForOwnershipTests(options?: {
agentLoadingService?: {
ensureAgentLoaded?: (options: { agentId: string }) => Promise<any>;
resumeAgent?: (options: {
handle: { provider: string; sessionId: string };
overrides?: Record<string, unknown>;
}) => Promise<any>;
refreshAgent?: (options: { agentId: string }) => Promise<any>;
};
storedRecord?: Record<string, unknown> | null;
loadedAgent?: Record<string, unknown> | null;
timelineRows?: Array<{ seq: number; item: Record<string, unknown>; timestamp: Date }>;
}) {
const emitted: Array<{ type: string; payload: unknown }> = [];
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const agentManager = {
subscribe: () => () => {},
listAgents: () => [],
getAgent: vi.fn(() => options?.loadedAgent ?? null),
createAgent: vi.fn(async () => {
throw new Error("Session should delegate unloaded bootstrap to AgentLoadingService");
}),
resumeAgentFromPersistence: vi.fn(async () => {
throw new Error("Session should delegate persistence resume to AgentLoadingService");
}),
reloadAgentSession: vi.fn(async () => {
throw new Error("Session should delegate refresh reload to AgentLoadingService");
}),
hydrateTimelineFromProvider: vi.fn(async () => {
throw new Error("Session should not call hydrateTimelineFromProvider directly");
}),
getStructuredSendRejection: vi.fn(async () => null),
fetchTimeline: vi.fn(async () => ({
rows: options?.timelineRows ?? [],
hasOlder: false,
hasNewer: false,
})),
recordUserMessage: vi.fn(),
waitForAgentRunStart: vi.fn(async () => undefined),
getTimeline: vi.fn(() => []),
};
const session = new Session({
clientId: "test-client",
onMessage: (message) => emitted.push(message as any),
logger: logger as any,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-test",
agentManager: agentManager as any,
agentStorage: {
list: async () => (options?.storedRecord ? [options.storedRecord as any] : []),
get: async () => (options?.storedRecord as any) ?? null,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
stt: null,
tts: null,
terminalManager: null,
agentLoadingService: options?.agentLoadingService,
} as any) as any;
return { session, emitted, agentManager };
}
describe("provider history compatibility ownership", () => {
test("fetch_agent_timeline_request delegates unloaded bootstrap through the compatibility seam", async () => {
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
const { session, emitted } = createSessionForOwnershipTests({
storedRecord: createStoredAgentRecord(),
timelineRows: [
{
seq: 1,
item: { type: "assistant_message", text: "rehydrated from provider history" },
timestamp: new Date("2026-03-24T00:00:01.000Z"),
},
],
agentLoadingService: {
ensureAgentLoaded,
},
});
session.buildAgentPayload = vi.fn(async () => ({ id: "agent-1" }));
await session.handleMessage({
type: "fetch_agent_timeline_request",
requestId: "req-fetch",
agentId: "agent-1",
});
expect(ensureAgentLoaded).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(emitted).toContainEqual({
type: "fetch_agent_timeline_response",
payload: expect.objectContaining({
requestId: "req-fetch",
agentId: "agent-1",
error: null,
entries: [
expect.objectContaining({
seq: 1,
}),
],
}),
});
});
test("send_agent_message_request delegates unloaded bootstrap before recording and streaming", async () => {
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
const { session, agentManager, emitted } = createSessionForOwnershipTests({
storedRecord: createStoredAgentRecord(),
agentLoadingService: {
ensureAgentLoaded,
},
});
session.resolveAgentIdentifier = vi.fn(async () => ({ ok: true, agentId: "agent-1" }));
session.unarchiveAgentState = vi.fn(async () => true);
session.buildAgentPrompt = vi.fn((text: string) => text);
session.startAgentStream = vi.fn(() => ({ ok: true }));
await session.handleMessage({
type: "send_agent_message_request",
requestId: "req-send",
agentId: "agent-1",
text: "hello",
images: [],
messageId: "msg-1",
});
expect(ensureAgentLoaded).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(ensureAgentLoaded.mock.invocationCallOrder[0]).toBeLessThan(
agentManager.recordUserMessage.mock.invocationCallOrder[0],
);
expect(agentManager.recordUserMessage).toHaveBeenCalledWith("agent-1", "hello", {
messageId: "msg-1",
emitState: false,
});
expect(session.startAgentStream).toHaveBeenCalledWith("agent-1", "hello");
expect(emitted).toContainEqual({
type: "send_agent_message_response",
payload: {
requestId: "req-send",
agentId: "agent-1",
accepted: true,
error: null,
},
});
});
test("resume_agent_request delegates persistence bootstrap through the compatibility seam", async () => {
const resumeAgent = vi.fn(async () => createCompatibilitySnapshot());
const { session, emitted } = createSessionForOwnershipTests({
agentLoadingService: {
resumeAgent,
},
});
session.unarchiveAgentByHandle = vi.fn(async () => undefined);
session.unarchiveAgentState = vi.fn(async () => true);
session.forwardAgentUpdate = vi.fn(async () => undefined);
session.getAgentPayloadById = vi.fn(async () => ({ id: "agent-1" }));
await session.handleMessage({
type: "resume_agent_request",
requestId: "req-resume",
handle: {
provider: "codex",
sessionId: "provider-session-1",
},
overrides: {
model: "gpt-5.4",
},
});
expect(resumeAgent).toHaveBeenCalledWith({
handle: {
provider: "codex",
sessionId: "provider-session-1",
},
overrides: {
model: "gpt-5.4",
},
});
expect(emitted).toContainEqual({
type: "status",
payload: expect.objectContaining({
status: "agent_resumed",
requestId: "req-resume",
agentId: "agent-1",
}),
});
});
test("refresh_agent_request delegates loaded persisted refresh through the compatibility seam", async () => {
const refreshAgent = vi.fn(async () =>
createCompatibilitySnapshot({
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
}),
);
const { session, emitted } = createSessionForOwnershipTests({
loadedAgent: createCompatibilitySnapshot(),
agentLoadingService: {
refreshAgent,
},
});
session.unarchiveAgentState = vi.fn(async () => true);
session.interruptAgentIfRunning = vi.fn(async () => undefined);
session.forwardAgentUpdate = vi.fn(async () => undefined);
await session.handleMessage({
type: "refresh_agent_request",
requestId: "req-refresh-loaded",
agentId: "agent-1",
});
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1");
expect(refreshAgent).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(emitted).toContainEqual({
type: "status",
payload: {
status: "agent_refreshed",
requestId: "req-refresh-loaded",
agentId: "agent-1",
timelineSize: 0,
},
});
});
test("refresh_agent_request delegates unloaded persisted refresh through the compatibility seam", async () => {
const refreshAgent = vi.fn(async () => createCompatibilitySnapshot());
const { session, emitted } = createSessionForOwnershipTests({
storedRecord: createStoredAgentRecord(),
agentLoadingService: {
refreshAgent,
},
});
session.unarchiveAgentState = vi.fn(async () => true);
session.interruptAgentIfRunning = vi.fn(async () => undefined);
session.forwardAgentUpdate = vi.fn(async () => undefined);
await session.handleMessage({
type: "refresh_agent_request",
requestId: "req-refresh-unloaded",
agentId: "agent-1",
});
expect(session.interruptAgentIfRunning).not.toHaveBeenCalled();
expect(refreshAgent).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(emitted).toContainEqual({
type: "status",
payload: {
status: "agent_refreshed",
requestId: "req-refresh-unloaded",
agentId: "agent-1",
timelineSize: 0,
},
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { Session } from "./session.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
const { watchCalls, watchMock } = vi.hoisted(() => {
const hoistedWatchCalls: Array<{
@@ -46,15 +51,15 @@ vi.mock("./checkout-git-utils.js", () => ({
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
}));
import { Session } from "./session.js";
function createSessionForWorkspaceGitWatchTests(): {
session: Session;
emitted: Array<{ type: string; payload: unknown }>;
projects: Map<number, ReturnType<typeof createPersistedProjectRecord>>;
workspaces: Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>;
} {
const emitted: Array<{ type: string; payload: unknown }> = [];
const projects = new Map<string, any>();
const workspaces = new Map<string, any>();
const projects = new Map<number, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>();
const logger = {
child: () => logger,
trace: vi.fn(),
@@ -84,46 +89,48 @@ function createSessionForWorkspaceGitWatchTests(): {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (projectId: string) => projects.get(projectId) ?? null,
get: async (id: number) => projects.get(id) ?? null,
insert: async () => 0,
upsert: async (record: any) => {
projects.set(record.projectId, record);
projects.set(record.id, record);
},
archive: async (projectId: string, archivedAt: string) => {
const existing = projects.get(projectId);
archive: async (id: number, archivedAt: string) => {
const existing = projects.get(id);
if (!existing) {
return;
}
projects.set(projectId, {
projects.set(id, {
...existing,
archivedAt,
updatedAt: archivedAt,
});
},
remove: async (projectId: string) => {
projects.delete(projectId);
remove: async (id: number) => {
projects.delete(id);
},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
get: async (id: number) => workspaces.get(id) ?? null,
insert: async () => 0,
upsert: async (record: any) => {
workspaces.set(record.workspaceId, record);
workspaces.set(record.id, record);
},
archive: async (workspaceId: string, archivedAt: string) => {
const existing = workspaces.get(workspaceId);
archive: async (id: number, archivedAt: string) => {
const existing = workspaces.get(id);
if (!existing) {
return;
}
workspaces.set(workspaceId, {
workspaces.set(id, {
...existing,
archivedAt,
updatedAt: archivedAt,
});
},
remove: async (workspaceId: string) => {
workspaces.delete(workspaceId);
remove: async (id: number) => {
workspaces.delete(id);
},
} as any,
checkoutDiffManager: {
@@ -148,12 +155,43 @@ function createSessionForWorkspaceGitWatchTests(): {
terminalManager: null,
}) as any;
session.listAgentPayloads = async () => [];
(session as any).listAgentPayloads = async () => [];
return {
session,
emitted,
};
return { session, emitted, projects, workspaces };
}
function seedGitWorkspace(input: {
projects: Map<number, ReturnType<typeof createPersistedProjectRecord>>;
workspaces: Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>;
projectId: number;
workspaceId: number;
cwd: string;
name: string;
}) {
input.projects.set(
input.projectId,
createPersistedProjectRecord({
id: input.projectId,
directory: "/tmp/repo",
displayName: "repo",
kind: "git",
gitRemote: "https://github.com/acme/repo.git",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
);
input.workspaces.set(
input.workspaceId,
createPersistedWorkspaceRecord({
id: input.workspaceId,
projectId: input.projectId,
directory: input.cwd,
displayName: input.name,
kind: "checkout",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
);
}
describe("workspace git watch targets", () => {
@@ -170,21 +208,17 @@ describe("workspace git watch targets", () => {
});
test("debounces watcher events and skips unchanged branch/diff snapshots", async () => {
const { session, emitted } = createSessionForWorkspaceGitWatchTests();
const { session, emitted, projects, workspaces } = createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
sessionAny.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: "repo",
checkout: {
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
seedGitWorkspace({
projects,
workspaces,
projectId: 1,
workspaceId: 10,
cwd: "/tmp/repo",
name: "main",
});
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
sessionAny.workspaceUpdatesSubscription = {
subscriptionId: "sub-1",
@@ -192,15 +226,14 @@ describe("workspace git watch targets", () => {
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
let descriptor = {
id: "/tmp/repo",
projectId: "/tmp/repo",
id: 10,
projectId: 1,
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "local_checkout",
workspaceKind: "checkout",
name: "main",
status: "done",
activityAt: null,
@@ -209,8 +242,7 @@ describe("workspace git watch targets", () => {
sessionAny.listWorkspaceDescriptorsSnapshot = async () => [descriptor];
await sessionAny.ensureWorkspaceRegistered("/tmp/repo");
sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
await sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
"/tmp/repo/.git/HEAD",
@@ -237,7 +269,7 @@ describe("workspace git watch targets", () => {
expect(workspaceUpdates[0]?.payload).toMatchObject({
kind: "upsert",
workspace: {
id: "/tmp/repo",
id: 10,
name: "renamed-branch",
diffStat: { additions: 1, deletions: 0 },
},
@@ -256,29 +288,45 @@ describe("workspace git watch targets", () => {
});
test("closes watchers when a workspace is archived and when the session closes", async () => {
const { session } = createSessionForWorkspaceGitWatchTests();
const { session, projects, workspaces } = createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
sessionAny.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: path.basename(cwd),
checkout: {
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
seedGitWorkspace({
projects,
workspaces,
projectId: 2,
workspaceId: 20,
cwd: "/tmp/repo-one",
name: "main",
});
seedGitWorkspace({
projects,
workspaces,
projectId: 3,
workspaceId: 30,
cwd: "/tmp/repo-two",
name: "main",
});
resolveCheckoutGitDirMock.mockImplementation(async (cwd: string) => path.join(cwd, ".git"));
await sessionAny.ensureWorkspaceRegistered("/tmp/repo-one");
await sessionAny.primeWorkspaceGitWatchFingerprints([
{
id: 20,
projectId: 2,
projectDisplayName: "repo-one",
projectRootPath: "/tmp/repo-one",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
status: "done",
activityAt: null,
},
]);
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
expect(watchCalls).toHaveLength(2);
await sessionAny.archiveWorkspaceRecord("/tmp/repo-one", "2026-03-21T00:00:00.000Z");
await sessionAny.archiveWorkspaceRecord(20, "2026-03-21T00:00:00.000Z");
expect(sessionAny.workspaceGitWatchTargets.size).toBe(0);
expect(watchCalls.every((entry) => entry.close.mock.calls.length === 1)).toBe(true);
@@ -286,7 +334,19 @@ describe("workspace git watch targets", () => {
watchCalls.length = 0;
watchMock.mockClear();
await sessionAny.ensureWorkspaceRegistered("/tmp/repo-two");
await sessionAny.primeWorkspaceGitWatchFingerprints([
{
id: 30,
projectId: 3,
projectDisplayName: "repo-two",
projectRootPath: "/tmp/repo-two",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
status: "done",
activityAt: null,
},
]);
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
expect(watchCalls).toHaveLength(2);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { afterEach, describe, expect, test, vi } from "vitest";
import { Session } from "./session.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { projects, workspaces } from "./db/schema.js";
describe("snapshot mutation ownership boundary", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("daemon live mutations write one durable snapshot through the manager-owned path", async () => {
const daemonHandle = await createTestPaseoDaemon();
const cwd = mkdtempSync(path.join(os.tmpdir(), "snapshot-owner-live-"));
try {
const db = (daemonHandle.daemon.agentStorage as any).db;
const [projectRow] = await db
.insert(projects)
.values({
directory: cwd,
displayName: "test-project",
kind: "directory",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
.returning({ id: projects.id });
const [workspaceRow] = await db
.insert(workspaces)
.values({
projectId: projectRow!.id,
directory: cwd,
displayName: "test-workspace",
kind: "checkout",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
.returning({ id: workspaces.id });
const snapshot = await daemonHandle.daemon.agentManager.createAgent(
{
provider: "codex",
cwd,
model: "gpt-5.2-codex",
},
undefined,
{ workspaceId: workspaceRow!.id },
);
await daemonHandle.daemon.agentManager.flush();
const applySnapshotSpy = vi.spyOn(daemonHandle.daemon.agentStorage, "applySnapshot");
await daemonHandle.daemon.agentManager.setAgentModel(snapshot.id, "gpt-5.4");
await daemonHandle.daemon.agentManager.flush();
expect(applySnapshotSpy).toHaveBeenCalledTimes(1);
const persisted = await daemonHandle.daemon.agentStorage.get(snapshot.id);
expect(persisted?.config?.model).toBe("gpt-5.4");
} finally {
rmSync(cwd, { recursive: true, force: true });
await daemonHandle.close();
}
});
test("session runtime flows delegate snapshot mutations to agent manager without direct storage writes", async () => {
const onMessage = vi.fn();
const archiveSnapshot = vi.fn(async (_agentId: string, archivedAt: string) => ({
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: "2026-03-24T00:00:00.000Z",
updatedAt: archivedAt,
title: null,
labels: {},
lastStatus: "idle" as const,
config: null,
persistence: null,
archivedAt,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
}));
const unarchiveSnapshot = vi.fn(async () => true);
const unarchiveSnapshotByHandle = vi.fn(async () => undefined);
const updateAgentMetadata = vi.fn(async () => undefined);
const directStorageWrite = vi.fn(async () => {
throw new Error("Session should not write snapshots directly");
});
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = new Session({
clientId: "test-client",
onMessage,
logger: logger as any,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-test",
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
archiveSnapshot,
unarchiveSnapshot,
unarchiveSnapshotByHandle,
updateAgentMetadata,
} as any,
agentStorage: {
list: async () => [],
get: async () => null,
applySnapshot: directStorageWrite,
upsert: directStorageWrite,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
stt: null,
tts: null,
terminalManager: null,
}) as any;
const archiveResult = await session.archiveAgentState("agent-1");
expect(archiveSnapshot).toHaveBeenCalledTimes(1);
expect(archiveResult.archivedAt).toBeTruthy();
await session.unarchiveAgentState("agent-1");
expect(unarchiveSnapshot).toHaveBeenCalledWith("agent-1");
const handle = { provider: "codex", sessionId: "session-1" };
await session.unarchiveAgentByHandle(handle);
expect(unarchiveSnapshotByHandle).toHaveBeenCalledWith(handle);
await session.handleUpdateAgentRequest(
"agent-1",
"Renamed agent",
{ lane: "phase-1a" },
"req-1",
);
expect(updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
title: "Renamed agent",
labels: { lane: "phase-1a" },
});
expect(onMessage).toHaveBeenCalledWith({
type: "update_agent_response",
payload: {
requestId: "req-1",
agentId: "agent-1",
accepted: true,
error: null,
},
});
expect(directStorageWrite).not.toHaveBeenCalled();
});
});

View File

@@ -30,6 +30,7 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
};
type Deferred<T> = {
@@ -929,6 +930,9 @@ export function createTestAgentClients(): Record<string, AgentClient> {
return {
claude: new FakeAgentClient("claude"),
codex: new FakeAgentClient("codex"),
gemini: new FakeAgentClient("gemini"),
amp: new FakeAgentClient("amp"),
aider: new FakeAgentClient("aider"),
opencode: new FakeAgentClient("opencode"),
};
}

View File

@@ -63,6 +63,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
const agentManager = {
setAgentAttentionCallback: vi.fn(),
getAgent: vi.fn(() => null),
getLastAssistantMessage: vi.fn(async () => null),
...agentManagerOverrides,
};
@@ -109,22 +110,20 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
vi.clearAllMocks();
});
it("uses assistant preview text for push notifications with markdown removed", () => {
it("uses assistant preview text for push notifications with markdown removed", async () => {
const getLastAssistantMessage = vi.fn(
async () => "**Done**. Updated `README.md` and [link](https://example.com).",
);
const { server } = createServer({
getAgent: vi.fn(() => ({
config: { title: null },
cwd: "/tmp/worktree",
timeline: [
{
type: "assistant_message",
text: "**Done**. Updated `README.md` and [link](https://example.com).",
},
],
pendingPermissions: new Map(),
})),
getLastAssistantMessage,
});
(server as any).broadcastAgentAttention({
await (server as any).broadcastAgentAttention({
agentId: "agent-1",
provider: "claude",
reason: "finished",
@@ -139,30 +138,28 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
reason: "finished",
},
});
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-1");
});
it("sends push notifications regardless of UI label presence", () => {
it("sends push notifications regardless of UI label presence", async () => {
const getLastAssistantMessage = vi.fn(async () => "Done.");
const { server } = createServer({
getAgent: vi.fn(() => ({
config: { title: null },
cwd: "/tmp/worktree",
labels: {},
timeline: [
{
type: "assistant_message",
text: "Done.",
},
],
pendingPermissions: new Map(),
})),
getLastAssistantMessage,
});
(server as any).broadcastAgentAttention({
await (server as any).broadcastAgentAttention({
agentId: "agent-2",
provider: "claude",
reason: "finished",
});
expect(pushMocks.sendPush).toHaveBeenCalledTimes(1);
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-2");
});
});

View File

@@ -4,7 +4,7 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { join } from "path";
import { hostname as getHostname } from "node:os";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino";
@@ -42,7 +42,6 @@ import {
} from "./agent-attention-policy.js";
import {
buildAgentAttentionNotificationPayload,
findLatestAssistantMessageFromTimeline,
findLatestPermissionRequest,
} from "../shared/agent-attention-notification.js";
@@ -70,6 +69,7 @@ function createNoopProjectRegistry(): ProjectRegistry {
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
insert: async () => 0,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -82,6 +82,7 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry {
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
insert: async () => 0,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -227,7 +228,7 @@ export class VoiceAssistantWebSocketServer {
private readonly serverId: string;
private readonly daemonVersion: string;
private readonly agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly agentStorage: AgentSnapshotStore;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly chatService: FileBackedChatService;
@@ -283,7 +284,7 @@ export class VoiceAssistantWebSocketServer {
logger: pino.Logger,
serverId: string,
agentManager: AgentManager,
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
downloadTokenStore: DownloadTokenStore,
paseoHome: string,
createAgentMcpTransport: AgentMcpTransportFactory,
@@ -355,7 +356,9 @@ export class VoiceAssistantWebSocketServer {
this.pushService = new PushService(pushLogger, this.pushTokenStore);
this.agentManager.setAgentAttentionCallback((params) => {
this.broadcastAgentAttention(params);
void this.broadcastAgentAttention(params).catch((err) => {
this.logger.warn({ err, agentId: params.agentId }, "Failed to broadcast agent attention");
});
});
const { allowedOrigins, allowedHosts } = wsConfig;
@@ -1319,11 +1322,11 @@ export class VoiceAssistantWebSocketServer {
};
}
private broadcastAgentAttention(params: {
private async broadcastAgentAttention(params: {
agentId: string;
provider: AgentProvider;
reason: "finished" | "error" | "permission";
}): void {
}): Promise<void> {
const clientEntries: Array<{
ws: WebSocketLike;
state: ClientAttentionState;
@@ -1338,11 +1341,12 @@ export class VoiceAssistantWebSocketServer {
const allStates = clientEntries.map((e) => e.state);
const agent = this.agentManager.getAgent(params.agentId);
const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId);
const notification = buildAgentAttentionNotificationPayload({
reason: params.reason,
serverId: this.serverId,
agentId: params.agentId,
assistantMessage: agent ? findLatestAssistantMessageFromTimeline(agent.timeline) : null,
assistantMessage,
permissionRequest: agent ? findLatestPermissionRequest(agent.pendingPermissions) : null,
});

View File

@@ -0,0 +1,82 @@
import { execSync } from "child_process";
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
export type WorkspaceGitMetadata = {
projectKind: "git" | "directory";
projectDisplayName: string;
workspaceDisplayName: string;
gitRemote: string | null;
};
export function readGitCommand(cwd: string, command: string): string | null {
try {
const output = execSync(command, {
cwd,
env: READ_ONLY_GIT_ENV,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
const trimmed = output.trim();
return trimmed.length > 0 ? trimmed : null;
} catch {
return null;
}
}
export function parseGitHubRepoFromRemote(remoteUrl: string): string | null {
let cleaned = remoteUrl.trim();
if (!cleaned) {
return null;
}
if (cleaned.startsWith("git@github.com:")) {
cleaned = cleaned.slice("git@github.com:".length);
} else if (cleaned.startsWith("https://github.com/")) {
cleaned = cleaned.slice("https://github.com/".length);
} else if (cleaned.startsWith("http://github.com/")) {
cleaned = cleaned.slice("http://github.com/".length);
} else {
const marker = "github.com/";
const markerIndex = cleaned.indexOf(marker);
if (markerIndex === -1) {
return null;
}
cleaned = cleaned.slice(markerIndex + marker.length);
}
if (cleaned.endsWith(".git")) {
cleaned = cleaned.slice(0, -".git".length);
}
if (!cleaned.includes("/")) {
return null;
}
return cleaned;
}
export function detectWorkspaceGitMetadata(
cwd: string,
directoryName: string,
): WorkspaceGitMetadata {
const gitDir = readGitCommand(cwd, "git rev-parse --git-dir");
if (!gitDir) {
return {
projectKind: "directory",
projectDisplayName: directoryName,
workspaceDisplayName: directoryName,
gitRemote: null,
};
}
const gitRemote = readGitCommand(cwd, "git config --get remote.origin.url");
const githubRepo = gitRemote ? parseGitHubRepoFromRemote(gitRemote) : null;
const branchName = readGitCommand(cwd, "git symbolic-ref --short HEAD");
return {
projectKind: "git",
projectDisplayName: githubRepo ?? directoryName,
workspaceDisplayName: branchName ?? directoryName,
gitRemote,
};
}

View File

@@ -0,0 +1,417 @@
import { execSync } from "node:child_process";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test, vi, afterEach } from "vitest";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
function createTestRegistries() {
const projects = new Map<number, PersistedProjectRecord>();
const workspaces = new Map<number, PersistedWorkspaceRecord>();
let nextProjectId = 1;
let nextWorkspaceId = 1;
const projectRegistry = {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (id: number) => projects.get(id) ?? null,
insert: async (record: Omit<PersistedProjectRecord, "id">) => {
const id = nextProjectId++;
projects.set(id, createPersistedProjectRecord({ id, ...record }));
return id;
},
upsert: async (record: PersistedProjectRecord) => {
projects.set(record.id, record);
},
archive: async (id: number, archivedAt: string) => {
const existing = projects.get(id);
if (existing) {
projects.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
}
},
remove: async (id: number) => {
projects.delete(id);
},
};
const workspaceRegistry = {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (id: number) => workspaces.get(id) ?? null,
insert: async (record: Omit<PersistedWorkspaceRecord, "id">) => {
const id = nextWorkspaceId++;
workspaces.set(id, createPersistedWorkspaceRecord({ id, ...record }));
return id;
},
upsert: async (record: PersistedWorkspaceRecord) => {
workspaces.set(record.id, record);
},
archive: async (id: number, archivedAt: string) => {
const existing = workspaces.get(id);
if (existing) {
workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
}
},
remove: async (id: number) => {
workspaces.delete(id);
},
};
return { projects, workspaces, projectRegistry, workspaceRegistry };
}
function createTestLogger() {
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
return logger as any;
}
function createTempGitRepo(prefix: string): string {
const raw = mkdtempSync(path.join(tmpdir(), prefix));
const dir = realpathSync(raw);
execSync("git init -b main", { cwd: dir, stdio: "ignore" });
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "ignore" });
execSync('git config user.name "Test"', { cwd: dir, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: dir, stdio: "ignore" });
writeFileSync(path.join(dir, "README.md"), "# Test\n");
execSync("git add .", { cwd: dir, stdio: "ignore" });
execSync('git commit -m "init"', { cwd: dir, stdio: "ignore" });
return dir;
}
const timestamp = "2025-01-01T00:00:00.000Z";
describe("WorkspaceReconciliationService", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true });
}
tempDirs.length = 0;
});
test("archives workspaces whose directories no longer exist", async () => {
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
1,
createPersistedProjectRecord({
id: 1,
directory: "/tmp/does-not-exist-reconcile-test",
kind: "directory",
displayName: "ghost",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
1,
createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: "/tmp/does-not-exist-reconcile-test",
kind: "checkout",
displayName: "ghost",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
const result = await service.runOnce();
expect(result.changesApplied.length).toBeGreaterThanOrEqual(1);
const wsChange = result.changesApplied.find((c) => c.kind === "workspace_archived");
expect(wsChange).toBeDefined();
expect(workspaces.get(1)!.archivedAt).toBeTruthy();
});
test("archives orphaned projects after all workspaces are archived", async () => {
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
1,
createPersistedProjectRecord({
id: 1,
directory: "/tmp/does-not-exist-reconcile-orphan",
kind: "directory",
displayName: "orphan",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
1,
createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: "/tmp/does-not-exist-reconcile-orphan",
kind: "checkout",
displayName: "orphan",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
const result = await service.runOnce();
const projChange = result.changesApplied.find((c) => c.kind === "project_archived");
expect(projChange).toBeDefined();
expect(projects.get(1)!.archivedAt).toBeTruthy();
});
test("updates project kind when a directory becomes a git repo", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "reconcile-git-init-"));
const resolved = realpathSync(dir);
tempDirs.push(resolved);
writeFileSync(path.join(resolved, "README.md"), "# Test\n");
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
1,
createPersistedProjectRecord({
id: 1,
directory: resolved,
kind: "directory",
displayName: path.basename(resolved),
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
1,
createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: resolved,
kind: "checkout",
displayName: path.basename(resolved),
createdAt: timestamp,
updatedAt: timestamp,
}),
);
// Initialize as git repo
execSync("git init -b main", { cwd: resolved, stdio: "ignore" });
execSync('git config user.email "test@test.com"', { cwd: resolved, stdio: "ignore" });
execSync('git config user.name "Test"', { cwd: resolved, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: resolved, stdio: "ignore" });
execSync("git add .", { cwd: resolved, stdio: "ignore" });
execSync('git commit -m "init"', { cwd: resolved, stdio: "ignore" });
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
const result = await service.runOnce();
const projUpdate = result.changesApplied.find((c) => c.kind === "project_updated");
expect(projUpdate).toBeDefined();
expect(projects.get(1)!.kind).toBe("git");
});
test("updates project display name when git remote changes", async () => {
const dir = createTempGitRepo("reconcile-remote-");
tempDirs.push(dir);
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
1,
createPersistedProjectRecord({
id: 1,
directory: dir,
kind: "git",
displayName: "old-owner/old-repo",
gitRemote: "git@github.com:old-owner/old-repo.git",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
1,
createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: dir,
kind: "checkout",
displayName: "main",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
// Change the remote
execSync("git remote add origin git@github.com:new-owner/new-repo.git", {
cwd: dir,
stdio: "ignore",
});
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
const result = await service.runOnce();
const projUpdate = result.changesApplied.find((c) => c.kind === "project_updated");
expect(projUpdate).toBeDefined();
expect(projects.get(1)!.displayName).toBe("new-owner/new-repo");
expect(projects.get(1)!.gitRemote).toBe("git@github.com:new-owner/new-repo.git");
});
test("updates workspace display name when branch changes", async () => {
const dir = createTempGitRepo("reconcile-branch-");
tempDirs.push(dir);
execSync("git checkout -b feature-branch", { cwd: dir, stdio: "ignore" });
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
1,
createPersistedProjectRecord({
id: 1,
directory: dir,
kind: "git",
displayName: path.basename(dir),
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
1,
createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: dir,
kind: "checkout",
displayName: "main",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
const result = await service.runOnce();
const wsUpdate = result.changesApplied.find((c) => c.kind === "workspace_updated");
expect(wsUpdate).toBeDefined();
expect(workspaces.get(1)!.displayName).toBe("feature-branch");
});
test("does not modify already-archived records", async () => {
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
1,
createPersistedProjectRecord({
id: 1,
directory: "/tmp/does-not-exist-archived",
kind: "directory",
displayName: "archived",
createdAt: timestamp,
updatedAt: timestamp,
archivedAt: timestamp,
}),
);
workspaces.set(
1,
createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: "/tmp/does-not-exist-archived",
kind: "checkout",
displayName: "archived",
createdAt: timestamp,
updatedAt: timestamp,
archivedAt: timestamp,
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
const result = await service.runOnce();
expect(result.changesApplied).toHaveLength(0);
});
test("calls onChanges callback when changes are applied", async () => {
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
1,
createPersistedProjectRecord({
id: 1,
directory: "/tmp/does-not-exist-callback-test",
kind: "directory",
displayName: "ghost",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
1,
createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: "/tmp/does-not-exist-callback-test",
kind: "checkout",
displayName: "ghost",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const onChanges = vi.fn();
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
onChanges,
});
await service.runOnce();
expect(onChanges).toHaveBeenCalledTimes(1);
expect(onChanges.mock.calls[0][0].length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,239 @@
import { existsSync } from "node:fs";
import type pino from "pino";
import type {
ProjectRegistry,
WorkspaceRegistry,
PersistedProjectRecord,
PersistedWorkspaceRecord,
} from "./workspace-registry.js";
import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js";
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
export type ReconciliationChange =
| { kind: "workspace_archived"; workspaceId: number; directory: string; reason: string }
| { kind: "project_archived"; projectId: number; directory: string; reason: string }
| {
kind: "project_updated";
projectId: number;
directory: string;
fields: Partial<Pick<PersistedProjectRecord, "kind" | "displayName" | "gitRemote">>;
}
| {
kind: "workspace_updated";
workspaceId: number;
directory: string;
fields: Partial<Pick<PersistedWorkspaceRecord, "displayName">>;
};
export type ReconciliationResult = {
changesApplied: ReconciliationChange[];
durationMs: number;
};
export type WorkspaceReconciliationServiceOptions = {
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
logger: pino.Logger;
intervalMs?: number;
onChanges?: (changes: ReconciliationChange[]) => void;
};
export class WorkspaceReconciliationService {
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly logger: pino.Logger;
private readonly intervalMs: number;
private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null;
private timer: ReturnType<typeof setInterval> | null = null;
private running = false;
constructor(options: WorkspaceReconciliationServiceOptions) {
this.projectRegistry = options.projectRegistry;
this.workspaceRegistry = options.workspaceRegistry;
this.logger = options.logger.child({ module: "workspace-reconciliation" });
this.intervalMs = options.intervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS;
this.onChanges = options.onChanges ?? null;
}
start(): void {
if (this.timer) return;
this.logger.info({ intervalMs: this.intervalMs }, "Starting workspace reconciliation service");
this.timer = setInterval(() => void this.runSafe(), this.intervalMs);
// Run once immediately on start
void this.runSafe();
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
async runOnce(): Promise<ReconciliationResult> {
return this.reconcile();
}
private async runSafe(): Promise<void> {
if (this.running) return;
this.running = true;
try {
const result = await this.reconcile();
if (result.changesApplied.length > 0) {
this.logger.info(
{ changeCount: result.changesApplied.length, durationMs: result.durationMs },
"Reconciliation pass completed with changes",
);
}
} catch (error) {
this.logger.error({ err: error }, "Reconciliation pass failed");
} finally {
this.running = false;
}
}
private async reconcile(): Promise<ReconciliationResult> {
const start = Date.now();
const changes: ReconciliationChange[] = [];
const allProjects = await this.projectRegistry.list();
const allWorkspaces = await this.workspaceRegistry.list();
const activeProjects = allProjects.filter((p) => !p.archivedAt);
const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt);
const workspacesByProject = new Map<number, PersistedWorkspaceRecord[]>();
for (const workspace of activeWorkspaces) {
const list = workspacesByProject.get(workspace.projectId) ?? [];
list.push(workspace);
workspacesByProject.set(workspace.projectId, list);
}
// 1. Archive workspaces whose directories no longer exist
for (const workspace of activeWorkspaces) {
if (!existsSync(workspace.directory)) {
const timestamp = new Date().toISOString();
await this.workspaceRegistry.archive(workspace.id, timestamp);
changes.push({
kind: "workspace_archived",
workspaceId: workspace.id,
directory: workspace.directory,
reason: "directory_missing",
});
// Update the in-memory list for the project orphan check below
const siblings = workspacesByProject.get(workspace.projectId);
if (siblings) {
const updated = siblings.filter((w) => w.id !== workspace.id);
workspacesByProject.set(workspace.projectId, updated);
}
}
}
// 2. Archive orphaned projects (all workspaces archived/removed)
for (const project of activeProjects) {
const siblings = workspacesByProject.get(project.id) ?? [];
if (siblings.length === 0) {
const timestamp = new Date().toISOString();
await this.projectRegistry.archive(project.id, timestamp);
changes.push({
kind: "project_archived",
projectId: project.id,
directory: project.directory,
reason: "no_active_workspaces",
});
}
}
// 3. Reconcile git metadata for active projects whose directories still exist
for (const project of activeProjects) {
if (project.archivedAt) continue;
const siblings = workspacesByProject.get(project.id) ?? [];
if (siblings.length === 0) continue;
if (!existsSync(project.directory)) continue;
const directoryName =
project.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? project.directory;
const currentGit = detectWorkspaceGitMetadata(project.directory, directoryName);
const projectUpdates: Partial<
Pick<PersistedProjectRecord, "kind" | "displayName" | "gitRemote">
> = {};
// Detect kind change: directory → git
if (project.kind !== currentGit.projectKind) {
projectUpdates.kind = currentGit.projectKind;
projectUpdates.displayName = currentGit.projectDisplayName;
projectUpdates.gitRemote = currentGit.gitRemote;
}
// Detect display name change (e.g. remote renamed)
if (
project.kind === "git" &&
currentGit.projectKind === "git" &&
project.displayName !== currentGit.projectDisplayName
) {
projectUpdates.displayName = currentGit.projectDisplayName;
}
// Detect git remote change
if (
project.kind === "git" &&
currentGit.projectKind === "git" &&
project.gitRemote !== currentGit.gitRemote
) {
projectUpdates.gitRemote = currentGit.gitRemote;
}
if (Object.keys(projectUpdates).length > 0) {
const timestamp = new Date().toISOString();
await this.projectRegistry.upsert({
...project,
...projectUpdates,
updatedAt: timestamp,
});
changes.push({
kind: "project_updated",
projectId: project.id,
directory: project.directory,
fields: projectUpdates,
});
}
// 4. Reconcile workspace display names (branch name changes)
for (const workspace of siblings) {
if (workspace.kind !== "checkout") continue;
if (!existsSync(workspace.directory)) continue;
const wsDirName =
workspace.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.directory;
const wsGit = detectWorkspaceGitMetadata(workspace.directory, wsDirName);
if (
wsGit.projectKind === "git" &&
workspace.displayName !== wsGit.workspaceDisplayName
) {
const timestamp = new Date().toISOString();
await this.workspaceRegistry.upsert({
...workspace,
displayName: wsGit.workspaceDisplayName,
updatedAt: timestamp,
});
changes.push({
kind: "workspace_updated",
workspaceId: workspace.id,
directory: workspace.directory,
fields: { displayName: wsGit.workspaceDisplayName },
});
}
}
}
if (changes.length > 0 && this.onChanges) {
this.onChanges(changes);
}
return { changesApplied: changes, durationMs: Date.now() - start };
}
}

View File

@@ -1,167 +0,0 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../test-utils/test-logger.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
describe("bootstrapWorkspaceRegistries", () => {
let tmpDir: string;
let paseoHome: string;
let agentStorage: AgentStorage;
let projectRegistry: FileBackedProjectRegistry;
let workspaceRegistry: FileBackedWorkspaceRegistry;
const logger = createTestLogger();
beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-bootstrap-"));
paseoHome = path.join(tmpDir, ".paseo");
agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger);
projectRegistry = new FileBackedProjectRegistry(
path.join(paseoHome, "projects", "projects.json"),
logger,
);
workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(paseoHome, "projects", "workspaces.json"),
logger,
);
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
test("materializes workspace registries from non-archived agent records", async () => {
await agentStorage.initialize();
await agentStorage.upsert({
id: "agent-1",
provider: "codex",
cwd: "/tmp/non-git-project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
});
await agentStorage.upsert({
id: "agent-2",
provider: "codex",
cwd: "/tmp/non-git-project",
createdAt: "2026-03-01T01:00:00.000Z",
updatedAt: "2026-03-03T00:00:00.000Z",
lastActivityAt: "2026-03-03T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "running",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
});
await agentStorage.upsert({
id: "agent-archived",
provider: "codex",
cwd: "/tmp/archived-project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
lastActivityAt: "2026-03-01T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: "2026-03-02T00:00:00.000Z",
});
await bootstrapWorkspaceRegistries({
paseoHome,
agentStorage,
projectRegistry,
workspaceRegistry,
logger,
});
const workspaces = await workspaceRegistry.list();
expect(workspaces).toHaveLength(1);
expect(workspaces[0]?.workspaceId).toBe("/tmp/non-git-project");
expect(workspaces[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
expect(workspaces[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
const projects = await projectRegistry.list();
expect(projects).toHaveLength(1);
expect(projects[0]?.projectId).toBe("/tmp/non-git-project");
expect(projects[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
expect(projects[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
});
test("does not rematerialize when registry files already exist", async () => {
await projectRegistry.initialize();
await workspaceRegistry.initialize();
await projectRegistry.upsert({
projectId: "/tmp/existing",
rootPath: "/tmp/existing",
kind: "non_git",
displayName: "existing",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await workspaceRegistry.upsert({
workspaceId: "/tmp/existing",
projectId: "/tmp/existing",
cwd: "/tmp/existing",
kind: "directory",
displayName: "existing",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await agentStorage.initialize();
await agentStorage.upsert({
id: "agent-1",
provider: "codex",
cwd: "/tmp/another-project",
createdAt: "2026-03-02T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
});
await bootstrapWorkspaceRegistries({
paseoHome,
agentStorage,
projectRegistry,
workspaceRegistry,
logger,
});
expect(await projectRegistry.list()).toHaveLength(1);
expect(await workspaceRegistry.list()).toHaveLength(1);
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("/tmp/existing");
});
});

View File

@@ -1,142 +0,0 @@
import path from "node:path";
import type { Logger } from "pino";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import {
buildProjectPlacementForCwd,
deriveProjectKind,
deriveProjectRootPath,
deriveWorkspaceDisplayName,
deriveWorkspaceKind,
normalizeWorkspaceId,
} from "./workspace-registry-model.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
} from "./workspace-registry.js";
function minIsoDate(left: string | null, right: string | null): string | null {
if (!left) {
return right;
}
if (!right) {
return left;
}
return Date.parse(left) <= Date.parse(right) ? left : right;
}
function maxIsoDate(left: string | null, right: string | null): string | null {
if (!left) {
return right;
}
if (!right) {
return left;
}
return Date.parse(left) >= Date.parse(right) ? left : right;
}
function resolveAgentCreatedAt(record: StoredAgentRecord): string {
return record.createdAt || record.updatedAt || new Date(0).toISOString();
}
function resolveAgentUpdatedAt(record: StoredAgentRecord): string {
return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString();
}
export async function bootstrapWorkspaceRegistries(options: {
paseoHome: string;
agentStorage: AgentStorage;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
logger: Logger;
}): Promise<void> {
const [projectsExists, workspacesExists] = await Promise.all([
options.projectRegistry.existsOnDisk(),
options.workspaceRegistry.existsOnDisk(),
]);
await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]);
if (projectsExists && workspacesExists) {
return;
}
const records = await options.agentStorage.list();
const activeRecords = records.filter((record) => !record.archivedAt);
const recordsByWorkspaceId = new Map<string, StoredAgentRecord[]>();
for (const record of activeRecords) {
const workspaceId = normalizeWorkspaceId(record.cwd);
const existing = recordsByWorkspaceId.get(workspaceId) ?? [];
existing.push(record);
recordsByWorkspaceId.set(workspaceId, existing);
}
const projectRanges = new Map<string, { createdAt: string | null; updatedAt: string | null }>();
for (const [workspaceId, workspaceRecords] of recordsByWorkspaceId.entries()) {
const placement = await buildProjectPlacementForCwd({
cwd: workspaceId,
paseoHome: options.paseoHome,
});
let workspaceCreatedAt: string | null = null;
let workspaceUpdatedAt: string | null = null;
for (const record of workspaceRecords) {
workspaceCreatedAt = minIsoDate(workspaceCreatedAt, resolveAgentCreatedAt(record));
workspaceUpdatedAt = maxIsoDate(workspaceUpdatedAt, resolveAgentUpdatedAt(record));
}
const createdAt = workspaceCreatedAt ?? new Date().toISOString();
const updatedAt = workspaceUpdatedAt ?? createdAt;
await options.workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
workspaceId,
projectId: placement.projectKey,
cwd: workspaceId,
kind: deriveWorkspaceKind(placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: workspaceId,
checkout: placement.checkout,
}),
createdAt,
updatedAt,
}),
);
const existingProjectRange = projectRanges.get(placement.projectKey) ?? {
createdAt: null,
updatedAt: null,
};
existingProjectRange.createdAt = minIsoDate(existingProjectRange.createdAt, createdAt);
existingProjectRange.updatedAt = maxIsoDate(existingProjectRange.updatedAt, updatedAt);
projectRanges.set(placement.projectKey, existingProjectRange);
await options.projectRegistry.upsert(
createPersistedProjectRecord({
projectId: placement.projectKey,
rootPath: deriveProjectRootPath({
cwd: workspaceId,
checkout: placement.checkout,
}),
kind: deriveProjectKind(placement.checkout),
displayName: placement.projectName,
createdAt: existingProjectRange.createdAt ?? createdAt,
updatedAt: existingProjectRange.updatedAt ?? updatedAt,
}),
);
}
options.logger.info(
{
projectsFile: path.join(options.paseoHome, "projects", "projects.json"),
workspacesFile: path.join(options.paseoHome, "projects", "workspaces.json"),
materializedProjects: projectRanges.size,
materializedWorkspaces: recordsByWorkspaceId.size,
},
"Workspace registries bootstrapped from existing agent storage",
);
}

View File

@@ -1,75 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { detectStaleWorkspaces } from "./workspace-registry-model.js";
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
function createWorkspaceRecord(workspaceId: string) {
return createPersistedWorkspaceRecord({
workspaceId,
projectId: workspaceId,
cwd: workspaceId,
kind: "directory",
displayName: workspaceId.split("/").at(-1) ?? workspaceId,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
}
describe("detectStaleWorkspaces", () => {
test("returns workspace ids whose directories no longer exist", async () => {
const checkDirectoryExists = vi.fn(async (cwd: string) => cwd !== "/tmp/missing");
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces: [
createWorkspaceRecord("/tmp/existing"),
createWorkspaceRecord("/tmp/missing"),
],
agentRecords: [],
checkDirectoryExists,
});
expect(Array.from(staleWorkspaceIds)).toEqual(["/tmp/missing"]);
expect(checkDirectoryExists.mock.calls).toEqual([["/tmp/existing"], ["/tmp/missing"]]);
});
test("returns workspace ids when all matching agents are archived", async () => {
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces: [createWorkspaceRecord("/tmp/repo"), createWorkspaceRecord("/tmp/other")],
agentRecords: [
{
cwd: "/tmp/repo",
archivedAt: "2026-03-02T00:00:00.000Z",
},
{
cwd: "/tmp/other",
archivedAt: null,
},
],
checkDirectoryExists: async () => true,
});
expect(Array.from(staleWorkspaceIds)).toEqual(["/tmp/repo"]);
});
test("keeps workspaces with no agents or at least one active agent", async () => {
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces: [
createWorkspaceRecord("/tmp/active"),
createWorkspaceRecord("/tmp/no-agents"),
],
agentRecords: [
{
cwd: "/tmp/active",
archivedAt: "2026-03-02T00:00:00.000Z",
},
{
cwd: "/tmp/active/../active",
archivedAt: null,
},
],
checkDirectoryExists: async () => true,
});
expect(Array.from(staleWorkspaceIds)).toEqual([]);
});
});

View File

@@ -1,21 +1,7 @@
import { resolve } from "node:path";
import { getCheckoutStatusLite } from "../utils/checkout-git.js";
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js";
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
export type PersistedProjectKind = "git" | "non_git";
export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory";
export type StaleWorkspaceAgentRecord = {
cwd: string;
archivedAt: string | null;
};
export type DetectStaleWorkspacesInput = {
activeWorkspaces: PersistedWorkspaceRecord[];
agentRecords: StaleWorkspaceAgentRecord[];
checkDirectoryExists: (cwd: string) => Promise<boolean>;
};
export type PersistedProjectKind = "git" | "directory";
export type PersistedWorkspaceKind = "checkout" | "worktree";
export function normalizeWorkspaceId(cwd: string): string {
const trimmed = cwd.trim();
@@ -24,212 +10,3 @@ export function normalizeWorkspaceId(cwd: string): string {
}
return resolve(trimmed);
}
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
if (!remoteUrl) {
return null;
}
const trimmed = remoteUrl.trim();
if (!trimmed) {
return null;
}
let host: string | null = null;
let remotePath: string | null = null;
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
if (scpLike) {
host = scpLike[1] ?? null;
remotePath = scpLike[2] ?? null;
} else if (trimmed.includes("://")) {
try {
const parsed = new URL(trimmed);
host = parsed.hostname || null;
remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null;
} catch {
return null;
}
}
if (!host || !remotePath) {
return null;
}
let cleanedPath = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, "");
if (cleanedPath.endsWith(".git")) {
cleanedPath = cleanedPath.slice(0, -4);
}
if (!cleanedPath.includes("/")) {
return null;
}
const cleanedHost = host.toLowerCase();
if (cleanedHost === "github.com") {
return `remote:github.com/${cleanedPath}`;
}
return `remote:${cleanedHost}/${cleanedPath}`;
}
export function deriveProjectGroupingKey(options: {
cwd: string;
remoteUrl: string | null;
isPaseoOwnedWorktree: boolean;
mainRepoRoot: string | null;
}): string {
const remoteKey = deriveRemoteProjectKey(options.remoteUrl);
if (remoteKey) {
return remoteKey;
}
const mainRepoRoot = options.mainRepoRoot?.trim();
if (options.isPaseoOwnedWorktree && mainRepoRoot) {
return mainRepoRoot;
}
return options.cwd;
}
export function deriveProjectGroupingName(projectKey: string): string {
const githubRemotePrefix = "remote:github.com/";
if (projectKey.startsWith(githubRemotePrefix)) {
return projectKey.slice(githubRemotePrefix.length) || projectKey;
}
const segments = projectKey.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] || projectKey;
}
function deriveWorkspaceDirectoryName(cwd: string): string {
const normalized = cwd.replace(/\\/g, "/");
const segments = normalized.split("/").filter(Boolean);
return segments[segments.length - 1] ?? cwd;
}
export function deriveWorkspaceDisplayName(input: {
cwd: string;
checkout: ProjectCheckoutLitePayload;
}): string {
const branch = input.checkout.currentBranch?.trim() ?? null;
if (branch && branch.toUpperCase() !== "HEAD") {
return branch;
}
return deriveWorkspaceDirectoryName(input.cwd);
}
export function deriveProjectRootPath(input: {
cwd: string;
checkout: ProjectCheckoutLitePayload;
}): string {
if (input.checkout.isGit && input.checkout.isPaseoOwnedWorktree) {
return input.checkout.mainRepoRoot;
}
return input.cwd;
}
export function deriveProjectKind(checkout: ProjectCheckoutLitePayload): PersistedProjectKind {
return checkout.isGit ? "git" : "non_git";
}
export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): PersistedWorkspaceKind {
if (!checkout.isGit) {
return "directory";
}
return checkout.isPaseoOwnedWorktree ? "worktree" : "local_checkout";
}
export async function detectStaleWorkspaces(
input: DetectStaleWorkspacesInput,
): Promise<Set<string>> {
const staleWorkspaceIds = new Set<string>();
const cwdsWithActiveAgents = new Set<string>();
const cwdsWithAnyAgent = new Set<string>();
for (const agent of input.agentRecords) {
const normalizedCwd = normalizeWorkspaceId(agent.cwd);
cwdsWithAnyAgent.add(normalizedCwd);
if (!agent.archivedAt) {
cwdsWithActiveAgents.add(normalizedCwd);
}
}
for (const workspace of input.activeWorkspaces) {
const dirExists = await input.checkDirectoryExists(workspace.cwd);
if (!dirExists) {
staleWorkspaceIds.add(workspace.workspaceId);
continue;
}
const hasAgents = cwdsWithAnyAgent.has(workspace.workspaceId);
const hasActiveAgents = cwdsWithActiveAgents.has(workspace.workspaceId);
if (hasAgents && !hasActiveAgents) {
staleWorkspaceIds.add(workspace.workspaceId);
}
}
return staleWorkspaceIds;
}
export async function buildProjectPlacementForCwd(input: {
cwd: string;
paseoHome: string;
}): Promise<ProjectPlacementPayload> {
const normalizedCwd = normalizeWorkspaceId(input.cwd);
const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome })
.then((status): ProjectCheckoutLitePayload => {
if (!status.isGit) {
return {
cwd: normalizedCwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
}
if (status.isPaseoOwnedWorktree && status.mainRepoRoot) {
return {
cwd: normalizedCwd,
isGit: true,
currentBranch: status.currentBranch,
remoteUrl: status.remoteUrl,
isPaseoOwnedWorktree: true,
mainRepoRoot: status.mainRepoRoot,
};
}
return {
cwd: normalizedCwd,
isGit: true,
currentBranch: status.currentBranch,
remoteUrl: status.remoteUrl,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
})
.catch(
(): ProjectCheckoutLitePayload => ({
cwd: normalizedCwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
);
const projectKey = deriveProjectGroupingKey({
cwd: normalizedCwd,
remoteUrl: checkout.remoteUrl,
isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree,
mainRepoRoot: checkout.mainRepoRoot,
});
return {
projectKey,
projectName: deriveProjectGroupingName(projectKey),
checkout,
};
}

View File

@@ -0,0 +1,172 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { Logger } from "pino";
import {
parsePersistedProjectRecords,
parsePersistedWorkspaceRecords,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
} from "./workspace-registry.js";
type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord;
class FileBackedRegistry<TRecord extends RegistryRecord> {
private readonly filePath: string;
private readonly logger: Logger;
private readonly parseRecord: (record: unknown) => TRecord;
private readonly parseRecords: (input: unknown) => TRecord[];
private readonly getId: (record: TRecord) => number;
private loaded = false;
private readonly cache = new Map<string, TRecord>();
private persistQueue: Promise<void> = Promise.resolve();
constructor(options: {
filePath: string;
logger: Logger;
parseRecords: (input: unknown) => TRecord[];
getId: (record: TRecord) => number;
component: string;
}) {
this.filePath = options.filePath;
this.parseRecords = options.parseRecords;
this.parseRecord = (record) => options.parseRecords([record])[0]!;
this.getId = options.getId;
this.logger = options.logger.child({
module: "workspace-registry",
component: options.component,
});
}
async initialize(): Promise<void> {
await this.load();
}
async existsOnDisk(): Promise<boolean> {
try {
await fs.access(this.filePath);
return true;
} catch {
return false;
}
}
async list(): Promise<TRecord[]> {
await this.load();
return Array.from(this.cache.values());
}
async get(id: number): Promise<TRecord | null> {
await this.load();
return this.cache.get(String(id)) ?? null;
}
async insert(record: Omit<TRecord, "id">): Promise<number> {
await this.load();
const nextId = Math.max(0, ...Array.from(this.cache.values(), (value) => this.getId(value))) + 1;
const parsed = this.parseRecord({ ...record, id: nextId });
this.cache.set(String(this.getId(parsed)), parsed);
await this.enqueuePersist();
return nextId;
}
async upsert(record: TRecord): Promise<void> {
await this.load();
const parsed = this.parseRecord(record);
this.cache.set(String(this.getId(parsed)), parsed);
await this.enqueuePersist();
}
async archive(id: number, archivedAt: string): Promise<void> {
await this.load();
const key = String(id);
const existing = this.cache.get(key);
if (!existing) {
return;
}
const next = this.parseRecord({
...existing,
updatedAt: archivedAt,
archivedAt,
});
this.cache.set(key, next);
await this.enqueuePersist();
}
async remove(id: number): Promise<void> {
await this.load();
if (!this.cache.delete(String(id))) {
return;
}
await this.enqueuePersist();
}
private async load(): Promise<void> {
if (this.loaded) {
return;
}
this.cache.clear();
try {
const raw = await fs.readFile(this.filePath, "utf8");
const parsed = this.parseRecords(JSON.parse(raw));
for (const record of parsed) {
this.cache.set(String(this.getId(record)), record);
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
this.logger.error({ err: error, filePath: this.filePath }, "Failed to load registry file");
}
}
this.loaded = true;
}
private async persist(): Promise<void> {
const records = Array.from(this.cache.values());
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
await fs.writeFile(tempPath, JSON.stringify(records, null, 2), "utf8");
await fs.rename(tempPath, this.filePath);
}
private async enqueuePersist(): Promise<void> {
const nextPersist = this.persistQueue.then(() => this.persist());
this.persistQueue = nextPersist.catch(() => {});
await nextPersist;
}
}
export class FileBackedProjectRegistry
extends FileBackedRegistry<PersistedProjectRecord>
implements ProjectRegistry
{
constructor(filePath: string, logger: Logger) {
super({
filePath,
logger,
parseRecords: parsePersistedProjectRecords,
getId: (record) => record.id,
component: "projects",
});
}
}
export class FileBackedWorkspaceRegistry
extends FileBackedRegistry<PersistedWorkspaceRecord>
implements WorkspaceRegistry
{
constructor(filePath: string, logger: Logger) {
super({
filePath,
logger,
parseRecords: parsePersistedWorkspaceRecords,
getId: (record) => record.id,
component: "workspaces",
});
}
}

View File

@@ -6,10 +6,12 @@ import { beforeEach, afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../test-utils/test-logger.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
FileBackedProjectRegistry,
FileBackedWorkspaceRegistry,
} from "./workspace-registry.test-helpers.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
describe("workspace registries", () => {
@@ -36,71 +38,78 @@ describe("workspace registries", () => {
test("creates, updates, archives, deletes, and lists project records", async () => {
await projectRegistry.initialize();
await projectRegistry.upsert(
createPersistedProjectRecord({
projectId: "remote:github.com/acme/repo",
rootPath: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
const projectId = await projectRegistry.insert({
directory: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
gitRemote: "git@github.com:acme/repo.git",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await projectRegistry.upsert(
createPersistedProjectRecord({
projectId: "remote:github.com/acme/repo",
rootPath: "/tmp/repo",
id: projectId,
directory: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
gitRemote: "git@github.com:acme/repo.git",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
}),
);
await projectRegistry.archive("remote:github.com/acme/repo", "2026-03-03T00:00:00.000Z");
await projectRegistry.archive(projectId, "2026-03-03T00:00:00.000Z");
const archived = await projectRegistry.get("remote:github.com/acme/repo");
const archived = await projectRegistry.get(projectId);
expect(archived?.archivedAt).toBe("2026-03-03T00:00:00.000Z");
expect(await projectRegistry.list()).toHaveLength(1);
await projectRegistry.remove("remote:github.com/acme/repo");
expect(await projectRegistry.get("remote:github.com/acme/repo")).toBeNull();
await projectRegistry.remove(projectId);
expect(await projectRegistry.get(projectId)).toBeNull();
expect(await projectRegistry.list()).toEqual([]);
});
test("creates, updates, archives, deletes, and lists workspace records", async () => {
await workspaceRegistry.initialize();
await workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo",
projectId: "remote:github.com/acme/repo",
cwd: "/tmp/repo",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
const projectId = await projectRegistry.insert({
directory: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
gitRemote: "git@github.com:acme/repo.git",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
const workspaceId = await workspaceRegistry.insert({
projectId,
directory: "/tmp/repo",
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo",
projectId: "remote:github.com/acme/repo",
cwd: "/tmp/repo",
kind: "local_checkout",
id: workspaceId,
projectId,
directory: "/tmp/repo",
kind: "checkout",
displayName: "feature/workspace",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
}),
);
await workspaceRegistry.archive("/tmp/repo", "2026-03-03T00:00:00.000Z");
await workspaceRegistry.archive(workspaceId, "2026-03-03T00:00:00.000Z");
const archived = await workspaceRegistry.get("/tmp/repo");
const archived = await workspaceRegistry.get(workspaceId);
expect(archived?.displayName).toBe("feature/workspace");
expect(archived?.archivedAt).toBe("2026-03-03T00:00:00.000Z");
await workspaceRegistry.remove("/tmp/repo");
expect(await workspaceRegistry.get("/tmp/repo")).toBeNull();
await workspaceRegistry.remove(workspaceId);
expect(await workspaceRegistry.get(workspaceId)).toBeNull();
expect(await workspaceRegistry.list()).toEqual([]);
});
});

View File

@@ -1,27 +1,21 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
import type { PersistedProjectKind, PersistedWorkspaceKind } from "./workspace-registry-model.js";
const PersistedProjectRecordSchema = z.object({
projectId: z.string(),
rootPath: z.string(),
kind: z.enum(["git", "non_git"]),
id: z.number().int(),
directory: z.string(),
kind: z.enum(["git", "directory"]),
displayName: z.string(),
gitRemote: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
});
const PersistedWorkspaceRecordSchema = z.object({
workspaceId: z.string(),
projectId: z.string(),
cwd: z.string(),
kind: z.enum(["local_checkout", "worktree", "directory"]),
id: z.number().int(),
projectId: z.number().int(),
directory: z.string(),
kind: z.enum(["checkout", "worktree"]),
displayName: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
@@ -31,192 +25,58 @@ const PersistedWorkspaceRecordSchema = z.object({
export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>;
export type PersistedWorkspaceRecord = z.infer<typeof PersistedWorkspaceRecordSchema>;
export function parsePersistedProjectRecords(input: unknown): PersistedProjectRecord[] {
return z.array(PersistedProjectRecordSchema).parse(input);
}
export function parsePersistedWorkspaceRecords(input: unknown): PersistedWorkspaceRecord[] {
return z.array(PersistedWorkspaceRecordSchema).parse(input);
}
export interface ProjectRegistry {
initialize(): Promise<void>;
existsOnDisk(): Promise<boolean>;
list(): Promise<PersistedProjectRecord[]>;
get(projectId: string): Promise<PersistedProjectRecord | null>;
get(id: number): Promise<PersistedProjectRecord | null>;
insert(record: Omit<PersistedProjectRecord, "id">): Promise<number>;
upsert(record: PersistedProjectRecord): Promise<void>;
archive(projectId: string, archivedAt: string): Promise<void>;
remove(projectId: string): Promise<void>;
archive(id: number, archivedAt: string): Promise<void>;
remove(id: number): Promise<void>;
}
export interface WorkspaceRegistry {
initialize(): Promise<void>;
existsOnDisk(): Promise<boolean>;
list(): Promise<PersistedWorkspaceRecord[]>;
get(workspaceId: string): Promise<PersistedWorkspaceRecord | null>;
get(id: number): Promise<PersistedWorkspaceRecord | null>;
insert(record: Omit<PersistedWorkspaceRecord, "id">): Promise<number>;
upsert(record: PersistedWorkspaceRecord): Promise<void>;
archive(workspaceId: string, archivedAt: string): Promise<void>;
remove(workspaceId: string): Promise<void>;
}
type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord;
class FileBackedRegistry<TRecord extends RegistryRecord> {
private readonly filePath: string;
private readonly logger: Logger;
private readonly schema: z.ZodSchema<TRecord>;
private readonly getId: (record: TRecord) => string;
private loaded = false;
private readonly cache = new Map<string, TRecord>();
private persistQueue: Promise<void> = Promise.resolve();
constructor(options: {
filePath: string;
logger: Logger;
schema: z.ZodSchema<TRecord>;
getId: (record: TRecord) => string;
component: string;
}) {
this.filePath = options.filePath;
this.schema = options.schema;
this.getId = options.getId;
this.logger = options.logger.child({
module: "workspace-registry",
component: options.component,
});
}
async initialize(): Promise<void> {
await this.load();
}
async existsOnDisk(): Promise<boolean> {
try {
await fs.access(this.filePath);
return true;
} catch {
return false;
}
}
async list(): Promise<TRecord[]> {
await this.load();
return Array.from(this.cache.values());
}
async get(id: string): Promise<TRecord | null> {
await this.load();
return this.cache.get(id) ?? null;
}
async upsert(record: TRecord): Promise<void> {
await this.load();
const parsed = this.schema.parse(record);
this.cache.set(this.getId(parsed), parsed);
await this.enqueuePersist();
}
async archive(id: string, archivedAt: string): Promise<void> {
await this.load();
const existing = this.cache.get(id);
if (!existing) {
return;
}
const next = this.schema.parse({
...existing,
updatedAt: archivedAt,
archivedAt,
});
this.cache.set(id, next);
await this.enqueuePersist();
}
async remove(id: string): Promise<void> {
await this.load();
if (!this.cache.delete(id)) {
return;
}
await this.enqueuePersist();
}
private async load(): Promise<void> {
if (this.loaded) {
return;
}
this.cache.clear();
try {
const raw = await fs.readFile(this.filePath, "utf8");
const parsed = z.array(this.schema).parse(JSON.parse(raw));
for (const record of parsed) {
this.cache.set(this.getId(record), record);
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
this.logger.error({ err: error, filePath: this.filePath }, "Failed to load registry file");
}
}
this.loaded = true;
}
private async persist(): Promise<void> {
const records = Array.from(this.cache.values());
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
await fs.writeFile(tempPath, JSON.stringify(records, null, 2), "utf8");
await fs.rename(tempPath, this.filePath);
}
private async enqueuePersist(): Promise<void> {
const nextPersist = this.persistQueue.then(() => this.persist());
this.persistQueue = nextPersist.catch(() => {});
await nextPersist;
}
}
export class FileBackedProjectRegistry
extends FileBackedRegistry<PersistedProjectRecord>
implements ProjectRegistry
{
constructor(filePath: string, logger: Logger) {
super({
filePath,
logger,
schema: PersistedProjectRecordSchema,
getId: (record) => record.projectId,
component: "projects",
});
}
}
export class FileBackedWorkspaceRegistry
extends FileBackedRegistry<PersistedWorkspaceRecord>
implements WorkspaceRegistry
{
constructor(filePath: string, logger: Logger) {
super({
filePath,
logger,
schema: PersistedWorkspaceRecordSchema,
getId: (record) => record.workspaceId,
component: "workspaces",
});
}
archive(id: number, archivedAt: string): Promise<void>;
remove(id: number): Promise<void>;
}
export function createPersistedProjectRecord(input: {
projectId: string;
rootPath: string;
kind: PersistedProjectKind;
id: number;
directory: string;
kind: "git" | "directory";
displayName: string;
gitRemote?: string | null;
createdAt: string;
updatedAt: string;
archivedAt?: string | null;
}): PersistedProjectRecord {
return PersistedProjectRecordSchema.parse({
...input,
gitRemote: input.gitRemote ?? null,
archivedAt: input.archivedAt ?? null,
});
}
export function createPersistedWorkspaceRecord(input: {
workspaceId: string;
projectId: string;
cwd: string;
kind: PersistedWorkspaceKind;
id: number;
projectId: number;
directory: string;
kind: "checkout" | "worktree";
displayName: string;
createdAt: string;
updatedAt: string;

View File

@@ -14,7 +14,6 @@ import {
type WorkspaceDescriptorPayload,
} from "./messages.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
@@ -77,26 +76,15 @@ type ArchivePaseoWorktreeDependencies = {
};
type RegisterPendingWorktreeWorkspaceDependencies = {
buildPersistedProjectRecord: (input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}) => PersistedProjectRecord;
buildPersistedWorkspaceRecord: (input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}) => PersistedWorkspaceRecord;
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
findWorkspaceByDirectory: (directory: string) => Promise<PersistedWorkspaceRecord | null>;
projectRegistry: Pick<ProjectRegistry, "get" | "upsert" | "insert" | "archive">;
syncWorkspaceGitWatchTarget: (
cwd: string,
options: { isGit: boolean },
) => Promise<void>;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert">;
archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise<void>;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert" | "insert" | "list">;
archiveProjectRecordIfEmpty: (projectId: number, archivedAt: string) => Promise<void>;
};
type CreatePaseoWorktreeInBackgroundDependencies = {
@@ -509,50 +497,50 @@ export async function registerPendingWorktreeWorkspace(
branchName: string;
},
): Promise<PersistedWorkspaceRecord> {
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot);
const placement: ProjectPlacementPayload = {
...basePlacement,
checkout: {
cwd: workspaceId,
isGit: true,
currentBranch: options.branchName,
remoteUrl: basePlacement.checkout.remoteUrl,
isPaseoOwnedWorktree: true,
mainRepoRoot: options.repoRoot,
},
};
const projectId = Number(basePlacement.projectKey);
if (!Number.isInteger(projectId)) {
throw new Error(`Invalid project id for repo root ${options.repoRoot}`);
}
const now = new Date().toISOString();
const existingWorkspace = await dependencies.workspaceRegistry.get(workspaceId);
const existingProject = await dependencies.projectRegistry.get(placement.projectKey);
const nextProjectRecord = dependencies.buildPersistedProjectRecord({
workspaceId,
placement,
createdAt: existingProject?.createdAt ?? now,
updatedAt: now,
});
const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({
workspaceId,
placement,
createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now,
});
const existingWorkspace = await dependencies.findWorkspaceByDirectory(workspaceDirectory);
if (!existingWorkspace) {
const workspaceId = await dependencies.workspaceRegistry.insert({
projectId,
directory: workspaceDirectory,
displayName: options.branchName,
kind: "worktree",
createdAt: now,
updatedAt: now,
archivedAt: null,
});
const workspace = await dependencies.workspaceRegistry.get(workspaceId);
if (!workspace) {
throw new Error(`Workspace not found after insert: ${workspaceId}`);
}
await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
return workspace;
}
await dependencies.projectRegistry.upsert(nextProjectRecord);
await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord);
await dependencies.syncWorkspaceGitWatchTarget(workspaceId, {
isGit: placement.checkout.isGit,
await dependencies.workspaceRegistry.upsert({
id: existingWorkspace.id,
projectId,
directory: workspaceDirectory,
displayName: options.branchName,
kind: "worktree",
createdAt: existingWorkspace.createdAt,
updatedAt: now,
archivedAt: null,
});
await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) {
await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return nextWorkspaceRecord;
return (await dependencies.workspaceRegistry.get(existingWorkspace.id))!;
}
export async function handleCreatePaseoWorktreeRequest(
@@ -585,6 +573,13 @@ export async function handleCreatePaseoWorktreeRequest(
worktreePath,
branchName: normalizedSlug,
});
await createAgentWorktree({
cwd: repoRoot,
branchName: normalizedSlug,
baseBranch,
worktreeSlug: normalizedSlug,
paseoHome: dependencies.paseoHome,
});
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
dependencies.emit({
type: "create_paseo_worktree_response",
@@ -634,14 +629,6 @@ export async function createPaseoWorktreeInBackground(
let setupTerminalId: string | null = null;
try {
await createAgentWorktree({
cwd: options.repoRoot,
branchName: options.slug,
baseBranch: options.baseBranch,
worktreeSlug: options.slug,
paseoHome: dependencies.paseoHome,
});
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
if (setupCommands.length > 0 && dependencies.terminalManager) {
const runtimeEnv = await resolveWorktreeRuntimeEnv({

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
AgentStreamMessageSchema,
FetchAgentTimelineRequestMessageSchema,
FetchAgentTimelineResponseMessageSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
@@ -17,14 +18,8 @@ describe("shared messages stream parsing", () => {
agentId: "agent_live",
agent: null,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 2 },
startSeq: 1,
endSeq: 2,
hasOlder: false,
hasNewer: false,
entries: [
@@ -32,10 +27,7 @@ describe("shared messages stream parsing", () => {
provider: "codex",
item: { type: "assistant_message", text: "hello" },
timestamp: "2026-02-08T20:10:00.000Z",
seqStart: 1,
seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
collapsed: ["assistant_merge"],
seq: 2,
},
],
error: null,
@@ -46,6 +38,51 @@ describe("shared messages stream parsing", () => {
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
});
it("rejects removed fetch timeline request baggage at the parser boundary", () => {
const parsed = FetchAgentTimelineRequestMessageSchema.safeParse({
type: "fetch_agent_timeline_request",
agentId: "agent_live",
requestId: "req-legacy",
direction: "after",
cursor: {
seq: 12,
epoch: "legacy-epoch",
},
projection: "canonical",
});
expect(parsed.success).toBe(false);
});
it("rejects removed fetch timeline response baggage at the parser boundary", () => {
const parsed = FetchAgentTimelineResponseMessageSchema.safeParse({
type: "fetch_agent_timeline_response",
payload: {
requestId: "req-1",
agentId: "agent_live",
agent: null,
direction: "tail",
startSeq: 1,
endSeq: 2,
hasOlder: false,
hasNewer: false,
reset: false,
startCursor: { seq: 1 },
entries: [
{
provider: "codex",
item: { type: "assistant_message", text: "hello" },
timestamp: "2026-02-08T20:10:00.000Z",
seq: 2,
},
],
error: null,
},
});
expect(parsed.success).toBe(false);
});
it("parses explicit shutdown and restart lifecycle request payloads as distinct message types", () => {
const shutdownParsed = SessionInboundMessageSchema.safeParse({
type: "shutdown_server_request",
@@ -70,6 +107,7 @@ describe("shared messages stream parsing", () => {
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
seq: 12,
event: {
type: "timeline",
provider: "claude",
@@ -97,6 +135,27 @@ describe("shared messages stream parsing", () => {
}
});
it("rejects removed agent_stream baggage at the parser boundary", () => {
const parsed = AgentStreamMessageSchema.safeParse({
type: "agent_stream",
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
epoch: "legacy-epoch",
event: {
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: "hello",
},
},
},
});
expect(parsed.success).toBe(false);
});
it("parses representative sub_agent tool_call event", () => {
const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream",

View File

@@ -95,6 +95,7 @@ const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
supportsMcpServers: z.boolean(),
supportsReasoningStream: z.boolean(),
supportsToolInvocations: z.boolean(),
supportsTerminalMode: z.boolean(),
});
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
@@ -132,6 +133,7 @@ const McpServerConfigSchema = z.discriminatedUnion("type", [
const AgentSessionConfigSchema = z.object({
provider: AgentProviderSchema,
cwd: z.string(),
terminal: z.boolean().optional(),
modeId: z.string().optional(),
model: z.string().optional(),
thinkingOptionId: z.string().optional(),
@@ -453,10 +455,19 @@ const AgentRuntimeInfoSchema: z.ZodType<AgentRuntimeInfo> = z.object({
extra: z.record(z.unknown()).optional(),
});
const TerminalExitDetailsSchema = z.object({
command: z.string(),
message: z.string(),
exitCode: z.number().nullable(),
signal: z.number().nullable(),
outputLines: z.array(z.string()),
});
export const AgentSnapshotPayloadSchema = z.object({
id: z.string(),
provider: AgentProviderSchema,
cwd: z.string(),
terminal: z.boolean().optional(),
model: z.string().nullable(),
thinkingOptionId: z.string().nullable().optional(),
effectiveThinkingOptionId: z.string().nullable().optional(),
@@ -472,6 +483,7 @@ export const AgentSnapshotPayloadSchema = z.object({
runtimeInfo: AgentRuntimeInfoSchema.optional(),
lastUsage: AgentUsageSchema.optional(),
lastError: z.string().optional(),
terminalExit: TerminalExitDetailsSchema.optional(),
title: z.string().nullable(),
labels: z.record(z.string()).default({}),
requiresAttention: z.boolean().optional(),
@@ -605,7 +617,7 @@ export const FetchWorkspacesRequestMessageSchema = z.object({
filter: z
.object({
query: z.string().optional(),
projectId: z.string().optional(),
projectId: z.number().int().optional(),
idPrefix: z.string().optional(),
})
.optional(),
@@ -704,6 +716,7 @@ export type GitSetupOptions = z.infer<typeof GitSetupOptionsSchema>;
export const CreateAgentRequestMessageSchema = z.object({
type: z.literal("create_agent_request"),
config: AgentSessionConfigSchema,
workspaceId: z.number().int().optional(),
worktreeName: z.string().optional(),
initialPrompt: z.string().optional(),
clientMessageId: z.string().optional(),
@@ -762,22 +775,23 @@ export const ShutdownServerRequestMessageSchema = z.object({
requestId: z.string(),
});
export const AgentTimelineCursorSchema = z.object({
epoch: z.string(),
seq: z.number().int().nonnegative(),
});
export const AgentTimelineCursorSchema = z
.object({
seq: z.number().int().nonnegative(),
})
.strict();
export const FetchAgentTimelineRequestMessageSchema = z.object({
type: z.literal("fetch_agent_timeline_request"),
agentId: z.string(),
requestId: z.string(),
direction: z.enum(["tail", "before", "after"]).optional(),
cursor: AgentTimelineCursorSchema.optional(),
// 0 means "all matching rows for this query window".
limit: z.number().int().nonnegative().optional(),
// Default should be projected for app timeline loading.
projection: z.enum(["projected", "canonical"]).optional(),
});
export const FetchAgentTimelineRequestMessageSchema = z
.object({
type: z.literal("fetch_agent_timeline_request"),
agentId: z.string(),
requestId: z.string(),
direction: z.enum(["tail", "before", "after"]).optional(),
cursor: AgentTimelineCursorSchema.optional(),
// 0 means "all matching rows for this query window".
limit: z.number().int().nonnegative().optional(),
})
.strict();
export const SetAgentModeRequestMessageSchema = z.object({
type: z.literal("set_agent_mode_request"),
@@ -998,7 +1012,7 @@ export const OpenProjectRequestSchema = z.object({
export const ArchiveWorkspaceRequestSchema = z.object({
type: z.literal("archive_workspace_request"),
workspaceId: z.string(),
workspaceId: z.number().int(),
requestId: z.string(),
});
@@ -1141,6 +1155,9 @@ export const CreateTerminalRequestSchema = z.object({
type: z.literal("create_terminal_request"),
cwd: z.string(),
name: z.string().optional(),
agentId: z.string().optional(),
command: z.string().optional(),
args: z.array(z.string()).optional(),
requestId: z.string(),
});
@@ -1580,12 +1597,13 @@ export const ProjectPlacementPayloadSchema = z.object({
});
export const WorkspaceDescriptorPayloadSchema = z.object({
id: z.string(),
projectId: z.string(),
id: z.number().int(),
projectId: z.number().int(),
projectDisplayName: z.string(),
projectRootPath: z.string(),
projectKind: z.enum(["git", "non_git"]),
workspaceKind: z.enum(["local_checkout", "worktree", "directory"]),
workspaceDirectory: z.string(),
projectKind: z.enum(["git", "directory"]),
workspaceKind: z.enum(["checkout", "worktree"]),
name: z.string(),
status: WorkspaceStateBucketSchema,
activityAt: z.string().nullable(),
@@ -1613,17 +1631,20 @@ export const AgentUpdateMessageSchema = z.object({
]),
});
export const AgentStreamMessageSchema = z.object({
type: z.literal("agent_stream"),
payload: z.object({
agentId: z.string(),
event: AgentStreamEventPayloadSchema,
timestamp: z.string(),
// Present for timeline events. Maps 1:1 to canonical in-memory timeline rows.
seq: z.number().int().nonnegative().optional(),
epoch: z.string().optional(),
}),
});
export const AgentStreamMessageSchema = z
.object({
type: z.literal("agent_stream"),
payload: z
.object({
agentId: z.string(),
event: AgentStreamEventPayloadSchema,
timestamp: z.string(),
// Present only for committed timeline events.
seq: z.number().int().nonnegative().optional(),
})
.strict(),
})
.strict();
export const AgentStatusMessageSchema = z.object({
type: z.literal("agent_status"),
@@ -1683,7 +1704,7 @@ export const WorkspaceUpdateMessageSchema = z.object({
}),
z.object({
kind: z.literal("remove"),
id: z.string(),
id: z.number().int(),
}),
]),
});
@@ -1701,7 +1722,7 @@ export const ArchiveWorkspaceResponseMessageSchema = z.object({
type: z.literal("archive_workspace_response"),
payload: z.object({
requestId: z.string(),
workspaceId: z.string(),
workspaceId: z.number().int(),
archivedAt: z.string().nullable(),
error: z.string().nullable(),
}),
@@ -1717,46 +1738,34 @@ export const FetchAgentResponseMessageSchema = z.object({
}),
});
const AgentTimelineSeqRangeSchema = z.object({
startSeq: z.number().int().nonnegative(),
endSeq: z.number().int().nonnegative(),
});
export const AgentTimelineEntryPayloadSchema = z
.object({
provider: AgentProviderSchema,
item: AgentTimelineItemPayloadSchema,
timestamp: z.string(),
seq: z.number().int().nonnegative(),
})
.strict();
export const AgentTimelineEntryPayloadSchema = z.object({
provider: AgentProviderSchema,
item: AgentTimelineItemPayloadSchema,
timestamp: z.string(),
seqStart: z.number().int().nonnegative(),
seqEnd: z.number().int().nonnegative(),
sourceSeqRanges: z.array(AgentTimelineSeqRangeSchema),
collapsed: z.array(z.enum(["assistant_merge", "tool_lifecycle"])),
});
export const FetchAgentTimelineResponseMessageSchema = z.object({
type: z.literal("fetch_agent_timeline_response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
agent: AgentSnapshotPayloadSchema.nullable(),
direction: z.enum(["tail", "before", "after"]),
projection: z.enum(["projected", "canonical"]),
epoch: z.string(),
reset: z.boolean(),
staleCursor: z.boolean(),
gap: z.boolean(),
window: z.object({
minSeq: z.number().int().nonnegative(),
maxSeq: z.number().int().nonnegative(),
nextSeq: z.number().int().nonnegative(),
}),
startCursor: AgentTimelineCursorSchema.nullable(),
endCursor: AgentTimelineCursorSchema.nullable(),
hasOlder: z.boolean(),
hasNewer: z.boolean(),
entries: z.array(AgentTimelineEntryPayloadSchema),
error: z.string().nullable(),
}),
});
export const FetchAgentTimelineResponseMessageSchema = z
.object({
type: z.literal("fetch_agent_timeline_response"),
payload: z
.object({
requestId: z.string(),
agentId: z.string(),
agent: AgentSnapshotPayloadSchema.nullable(),
direction: z.enum(["tail", "before", "after"]),
startSeq: z.number().int().nonnegative().nullable(),
endSeq: z.number().int().nonnegative().nullable(),
hasOlder: z.boolean(),
hasNewer: z.boolean(),
entries: z.array(AgentTimelineEntryPayloadSchema),
error: z.string().nullable(),
})
.strict(),
})
.strict();
export const SendAgentMessageResponseMessageSchema = z.object({
type: z.literal("send_agent_message_response"),
@@ -2152,6 +2161,7 @@ const TerminalInfoSchema = z.object({
id: z.string(),
name: z.string(),
cwd: z.string(),
title: z.string().optional(),
});
export const TerminalCellSchema = z
@@ -2189,6 +2199,7 @@ export const TerminalStateSchema = z
grid: z.array(z.array(TerminalCellSchema)),
scrollback: z.array(z.array(TerminalCellSchema)),
cursor: TerminalCursorSchema,
title: z.string().optional(),
})
.strict();

View File

@@ -8,7 +8,7 @@ describe("workspace message schemas", () => {
requestId: "req-1",
filter: {
query: "repo",
projectId: "remote:github.com/acme/repo",
projectId: 12,
idPrefix: "/Users/me",
},
sort: [{ key: "activity_at", direction: "desc" }],
@@ -35,12 +35,12 @@ describe("workspace message schemas", () => {
payload: {
kind: "upsert",
workspace: {
id: "/repo",
projectId: "/repo",
id: 1,
projectId: 1,
projectDisplayName: "repo",
projectRootPath: "/repo",
projectKind: "non_git",
workspaceKind: "directory",
projectKind: "directory",
workspaceKind: "checkout",
name: "",
status: "not-a-bucket",
activityAt: null,

View File

@@ -0,0 +1,17 @@
typeset -g PASEO_SHELL_INTEGRATION_DIR="${${(%):-%N}:A:h}"
if [[ -n "${PASEO_ZSH_ZDOTDIR-}" ]]; then
export ZDOTDIR="${PASEO_ZSH_ZDOTDIR}"
else
unset ZDOTDIR
fi
if [[ -n "${ZDOTDIR-}" ]]; then
if [[ -f "${ZDOTDIR}/.zshenv" ]]; then
source "${ZDOTDIR}/.zshenv"
fi
elif [[ -f "${HOME}/.zshenv" ]]; then
source "${HOME}/.zshenv"
fi
source "${PASEO_SHELL_INTEGRATION_DIR}/paseo-integration.zsh"

View File

@@ -0,0 +1,17 @@
if [[ -n "${_PASEO_ZSH_INTEGRATION_LOADED-}" ]]; then
return
fi
typeset -g _PASEO_ZSH_INTEGRATION_LOADED=1
autoload -Uz add-zsh-hook
function _paseo_precmd() {
printf '\e]2;%s\a' "${PWD/#$HOME/~}"
}
function _paseo_preexec() {
printf '\e]2;%s\a' "$1"
}
add-zsh-hook precmd _paseo_precmd
add-zsh-hook preexec _paseo_preexec

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, vi } from "vitest";
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -301,11 +301,14 @@ describe("TerminalManager", () => {
describe("subscribeTerminalsChanged", () => {
it("emits cwd snapshots when terminals are created", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminalNames: string[] }> = [];
const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
cwd: input.cwd,
terminalNames: input.terminals.map((terminal) => terminal.name),
terminals: input.terminals.map((terminal) => ({
name: terminal.name,
...(terminal.title ? { title: terminal.title } : {}),
})),
});
});
@@ -314,16 +317,111 @@ describe("TerminalManager", () => {
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminalNames: ["Terminal 1"],
terminals: [{ name: "Terminal 1" }],
});
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminalNames: ["Terminal 1", "Dev Server"],
terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }],
});
unsubscribe();
});
it(
"emits updated terminal titles after debounced title changes",
async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(
input.terminals.map((terminal) => ({
id: terminal.id,
...(terminal.title ? { title: terminal.title } : {}),
})),
);
});
const session = await manager.createTerminal({ cwd: "/tmp" });
session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" });
await waitForCondition(
() =>
snapshots.some((snapshot) =>
snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Logs"),
),
10000,
);
unsubscribe();
});
},
10000,
);
it("forwards bound terminal titles through the agent bridge without changing standalone lists", async () => {
await withShell("/bin/sh", async () => {
const onAgentBoundTerminalTitleChange = vi.fn();
manager = createTerminalManager({
resolveAgentIdForTerminal: () => "agent-1",
onAgentBoundTerminalTitleChange,
});
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(
input.terminals.map((terminal) => ({
id: terminal.id,
...(terminal.title ? { title: terminal.title } : {}),
})),
);
});
const session = await manager.createTerminal({ cwd: "/tmp" });
session.send({ type: "input", data: "printf '\\033]0;Agent Shell\\007'\r" });
await waitForCondition(() => onAgentBoundTerminalTitleChange.mock.calls.length > 0, 10000);
expect(onAgentBoundTerminalTitleChange).toHaveBeenCalledWith({
agentId: "agent-1",
title: "Agent Shell",
});
expect(
snapshots.some((snapshot) =>
snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Agent Shell"),
),
).toBe(true);
unsubscribe();
});
});
it("forwards initial titles for agent-bound terminals created with command args", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-manager-title-script-"));
temporaryDirs.push(packageRoot);
const scriptPath = join(packageRoot, "npm-cli.js");
writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n");
const onAgentBoundTerminalTitleChange = vi.fn();
manager = createTerminalManager({
resolveAgentIdForTerminal: () => "agent-1",
onAgentBoundTerminalTitleChange,
});
await manager.createTerminal({
cwd: packageRoot,
command: process.execPath,
args: [scriptPath, "run", "dev"],
});
await waitForCondition(() => onAgentBoundTerminalTitleChange.mock.calls.length > 0, 10000);
expect(onAgentBoundTerminalTitleChange).toHaveBeenCalledWith({
agentId: "agent-1",
title: "npm run dev",
});
});
it("emits empty snapshot when last terminal is removed", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];

View File

@@ -5,6 +5,7 @@ export interface TerminalListItem {
id: string;
name: string;
cwd: string;
title?: string;
}
export interface TerminalsChangedEvent {
@@ -17,9 +18,12 @@ export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void;
export interface TerminalManager {
getTerminals(cwd: string): Promise<TerminalSession[]>;
createTerminal(options: {
id?: string;
cwd: string;
name?: string;
env?: Record<string, string>;
command?: string;
args?: string[];
}): Promise<TerminalSession>;
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
getTerminal(id: string): TerminalSession | undefined;
@@ -29,10 +33,16 @@ export interface TerminalManager {
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
}
export function createTerminalManager(): TerminalManager {
type AgentBoundTerminalTitleHandler = (input: { agentId: string; title: string }) => Promise<void> | void;
export function createTerminalManager(options?: {
resolveAgentIdForTerminal?: (terminalId: string) => string | null;
onAgentBoundTerminalTitleChange?: AgentBoundTerminalTitleHandler;
}): TerminalManager {
const terminalsByCwd = new Map<string, TerminalSession[]>();
const terminalsById = new Map<string, TerminalSession>();
const terminalExitUnsubscribeById = new Map<string, () => void>();
const terminalTitleUnsubscribeById = new Map<string, () => void>();
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
@@ -53,6 +63,11 @@ export function createTerminalManager(): TerminalManager {
unsubscribeExit();
terminalExitUnsubscribeById.delete(id);
}
const unsubscribeTitle = terminalTitleUnsubscribeById.get(id);
if (unsubscribeTitle) {
unsubscribeTitle();
terminalTitleUnsubscribeById.delete(id);
}
terminalsById.delete(id);
@@ -96,7 +111,27 @@ export function createTerminalManager(): TerminalManager {
const unsubscribeExit = session.onExit(() => {
removeSessionById(session.id, { kill: false });
});
const unsubscribeTitle = session.onTitleChange((title) => {
emitTerminalsChanged({ cwd: session.cwd });
const normalizedTitle = title?.trim();
if (!normalizedTitle) {
return;
}
const agentId = options?.resolveAgentIdForTerminal?.(session.id) ?? null;
if (!agentId) {
return;
}
void Promise.resolve(
options?.onAgentBoundTerminalTitleChange?.({
agentId,
title: normalizedTitle,
}),
).catch(() => {
// no-op
});
});
terminalExitUnsubscribeById.set(session.id, unsubscribeExit);
terminalTitleUnsubscribeById.set(session.id, unsubscribeTitle);
return session;
}
@@ -105,6 +140,7 @@ export function createTerminalManager(): TerminalManager {
id: input.session.id,
name: input.session.name,
cwd: input.session.cwd,
title: input.session.getTitle(),
};
}
@@ -138,9 +174,12 @@ export function createTerminalManager(): TerminalManager {
},
async createTerminal(options: {
id?: string;
cwd: string;
name?: string;
env?: Record<string, string>;
command?: string;
args?: string[];
}): Promise<TerminalSession> {
assertAbsolutePath(options.cwd);
@@ -153,8 +192,11 @@ export function createTerminalManager(): TerminalManager {
: undefined;
const session = registerSession(
await createTerminal({
...(options.id ? { id: options.id } : {}),
cwd: options.cwd,
name: options.name ?? defaultName,
...(options.command ? { command: options.command } : {}),
...(options.args ? { args: options.args } : {}),
...(mergedEnv ? { env: mergedEnv } : {}),
}),
);

View File

@@ -1,8 +1,12 @@
import { describe, it, expect, afterEach } from "vitest";
import {
buildTerminalEnvironment,
createTerminal,
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
resolveDefaultTerminalShell,
humanizeProcessTitle,
normalizeProcessTitle,
resolveZshShellIntegrationDir,
type TerminalSession,
} from "./terminal.js";
import { chmodSync, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -71,6 +75,23 @@ async function waitForState(
throw new Error("Timeout waiting for terminal state predicate to match");
}
async function waitForTitle(
session: TerminalSession,
predicate: (title: string | undefined) => boolean,
timeoutMs = 5000,
): Promise<string | undefined> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const title = session.getTitle();
if (predicate(title)) {
return title;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error("Timeout waiting for terminal title predicate to match");
}
describe("Terminal", () => {
const sessions: TerminalSession[] = [];
const temporaryDirs: string[] = [];
@@ -94,6 +115,30 @@ describe("Terminal", () => {
}
describe("createTerminal", () => {
it("keeps full process titles while stripping path prefixes", () => {
expect(normalizeProcessTitle(" /usr/local/bin/npm run dev ")).toBe("npm run dev");
expect(normalizeProcessTitle("/opt/homebrew/bin/node /tmp/work/npm-cli.js run dev")).toBe(
"node npm-cli.js run dev",
);
expect(normalizeProcessTitle("")).toBeUndefined();
});
it("humanizes interpreter-backed package manager commands", () => {
expect(
humanizeProcessTitle("/usr/local/bin/node /opt/homebrew/lib/node_modules/npm/bin/npm-cli.js run dev"),
).toBe("npm run dev");
expect(
humanizeProcessTitle("/usr/bin/env FOO=bar /opt/homebrew/bin/node /tmp/npm-cli.js test"),
).toBe("npm test");
});
it("drops common interpreter prefixes for direct scripts", () => {
expect(humanizeProcessTitle("/usr/bin/python3 /tmp/server.py --port 3000")).toBe(
"server.py --port 3000",
);
expect(humanizeProcessTitle("/bin/bash /tmp/dev.sh")).toBe("dev.sh");
});
it("ensures darwin prebuild spawn-helper is executable", () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-node-pty-helper-"));
temporaryDirs.push(packageRoot);
@@ -140,6 +185,20 @@ describe("Terminal", () => {
expect(session.cwd).toBe("/tmp");
});
it("sets zsh wrapper env when spawning zsh", () => {
const resolvedEnv = buildTerminalEnvironment({
shell: "/bin/zsh",
env: {
HOME: "/tmp/paseo-home",
ZDOTDIR: "/tmp/paseo-zdotdir",
},
});
expect(resolvedEnv.TERM).toBe("xterm-256color");
expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir");
expect(resolvedEnv.ZDOTDIR).toBe(resolveZshShellIntegrationDir());
});
it("uses custom name when provided", async () => {
const session = trackSession(
await createTerminal({
@@ -192,6 +251,28 @@ describe("Terminal", () => {
expect(state.rows).toBe(40);
expect(state.cols).toBe(120);
});
it("captures exit diagnostics from the terminal buffer", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
command: "/bin/sh",
args: ["-lc", "printf 'launch failed\\ncommand missing\\n'; exit 127"],
}),
);
const exitInfo = await new Promise<NonNullable<ReturnType<TerminalSession["getExitInfo"]>>>(
(resolve) => {
session.onExit((info) => resolve(info));
},
);
expect(exitInfo.exitCode).toBe(127);
expect(exitInfo.signal).toBeNull();
// lastOutputLines may be empty if the process exits before xterm processes the data write
expect(Array.isArray(exitInfo.lastOutputLines)).toBe(true);
expect(session.getExitInfo()).toEqual(exitInfo);
});
});
describe("send input", () => {
@@ -268,6 +349,154 @@ describe("Terminal", () => {
});
});
describe("terminal title", () => {
it("restores the user's ZDOTDIR through the zsh wrapper", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-"));
temporaryDirs.push(homeDir);
const realZdotdir = join(homeDir, ".config", "zsh");
mkdirSync(realZdotdir, { recursive: true });
writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n");
const session = trackSession(
await createTerminal({
cwd: homeDir,
command: "/bin/zsh",
args: ["-c", "printf '%s\\n%s\\n' \"${ZDOTDIR-}\" \"${PASEO_TEST_REAL_ZDOTDIR-}\""],
env: {
HOME: homeDir,
ZDOTDIR: realZdotdir,
},
}),
);
const exitInfo = await new Promise<NonNullable<ReturnType<TerminalSession["getExitInfo"]>>>(
(resolve) => {
session.onExit((info) => resolve(info));
},
);
expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]);
});
it("emits the initial title from command args to title listeners", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-"));
temporaryDirs.push(packageRoot);
const scriptPath = join(packageRoot, "npm-cli.js");
writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n");
const session = trackSession(
await createTerminal({
cwd: packageRoot,
command: process.execPath,
args: [scriptPath, "run", "dev"],
}),
);
const seenTitles: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
await waitForTitle(session, (title) => title === "npm run dev");
await waitForState(session, (state) => state.title === "npm run dev");
expect(seenTitles).toContain("npm run dev");
expect(session.getTitle()).toBe("npm run dev");
expect(session.getState().title).toBe("npm run dev");
unsubscribeTitle();
});
it("emits OSC title updates to title listeners", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const seenTitles: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
await waitForTitle(session, (title) => title === "Build Log");
expect(seenTitles).toContain("Build Log");
expect(session.getTitle()).toBe("Build Log");
expect(session.getState().title).toBe("Build Log");
unsubscribeTitle();
});
it("debounces rapid title changes and emits only the final title", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const seenTitles: Array<string | undefined> = [];
const seenMessages: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
const unsubscribeMessages = session.subscribe((message) => {
if (message.type === "titleChange") {
seenMessages.push(message.title);
}
});
await waitForLines(session, ["$"]);
session.send({
type: "input",
data:
"printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r",
});
await waitForTitle(session, (title) => title === "Final");
expect(seenTitles).toEqual(["Final"]);
expect(seenMessages).toEqual(["Final"]);
unsubscribeMessages();
unsubscribeTitle();
});
it("emits zsh shell integration titles for commands and prompts", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-home-"));
temporaryDirs.push(homeDir);
const realZdotdir = join(homeDir, ".config", "zsh");
const workingDir = join(homeDir, "dev", "faro");
mkdirSync(realZdotdir, { recursive: true });
mkdirSync(workingDir, { recursive: true });
writeFileSync(join(realZdotdir, ".zshenv"), "");
writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n");
const session = trackSession(
await createTerminal({
cwd: workingDir,
shell: "/bin/zsh",
env: {
HOME: homeDir,
ZDOTDIR: realZdotdir,
},
}),
);
await waitForLines(session, ["$"]);
await waitForTitle(session, (title) => title === "~/dev/faro");
session.send({ type: "input", data: "sleep 1\r" });
await waitForTitle(session, (title) => title === "sleep 1");
await waitForTitle(session, (title) => title === "~/dev/faro", 4000);
});
});
describe("colors", () => {
it("captures ANSI 16 color codes (mode 1)", async () => {
const session = trackSession(

View File

@@ -2,14 +2,24 @@ import * as pty from "node-pty";
import xterm, { type Terminal as TerminalType } from "@xterm/headless";
import { randomUUID } from "crypto";
import { chmodSync, existsSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { basename, dirname, join } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import stripAnsi from "strip-ansi";
import type { TerminalCell, TerminalState } from "../shared/messages.js";
const { Terminal } = xterm;
const require = createRequire(import.meta.url);
let nodePtySpawnHelperChecked = false;
const TERMINAL_TITLE_DEBOUNCE_MS = 150;
const TERMINAL_EXIT_OUTPUT_LINE_LIMIT = 12;
const TERMINAL_EXIT_OUTPUT_CHAR_LIMIT = 16000;
export interface TerminalExitInfo {
exitCode: number | null;
signal: number | null;
lastOutputLines: string[];
}
export type ClientMessage =
| { type: "input"; data: string }
@@ -18,7 +28,8 @@ export type ClientMessage =
export type ServerMessage =
| { type: "output"; data: string }
| { type: "snapshot"; state: TerminalState };
| { type: "snapshot"; state: TerminalState }
| { type: "titleChange"; title?: string };
export interface TerminalSession {
id: string;
@@ -26,19 +37,30 @@ export interface TerminalSession {
cwd: string;
send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void): () => void;
onExit(listener: () => void): () => void;
onExit(listener: (info: TerminalExitInfo) => void): () => void;
onTitleChange(listener: (title?: string) => void): () => void;
getSize(): { rows: number; cols: number };
getState(): TerminalState;
getTitle(): string | undefined;
getExitInfo(): TerminalExitInfo | null;
kill(): void;
}
export interface CreateTerminalOptions {
id?: string;
cwd: string;
shell?: string;
env?: Record<string, string>;
rows?: number;
cols?: number;
name?: string;
command?: string;
args?: string[];
}
interface BuildTerminalEnvironmentInput {
shell: string;
env: Record<string, string>;
}
export interface CaptureTerminalLinesOptions {
@@ -135,6 +157,29 @@ export function resolveDefaultTerminalShell(
return env.SHELL || "/bin/sh";
}
export function resolveZshShellIntegrationDir(): string {
return fileURLToPath(new URL("./shell-integration/zsh", import.meta.url));
}
export function buildTerminalEnvironment(input: BuildTerminalEnvironmentInput): Record<string, string> {
const baseEnv: Record<string, string> = {
...process.env,
...input.env,
TERM: "xterm-256color",
};
if (basename(input.shell) !== "zsh") {
return baseEnv;
}
const originalZdotdir = baseEnv.ZDOTDIR ?? "";
return {
...baseEnv,
PASEO_ZSH_ZDOTDIR: originalZdotdir,
ZDOTDIR: resolveZshShellIntegrationDir(),
};
}
function extractCell(terminal: TerminalType, row: number, col: number): TerminalCell {
const buffer = terminal.buffer.active;
const line = buffer.getLine(row);
@@ -258,6 +303,157 @@ function extractCursorState(terminal: TerminalType): TerminalState["cursor"] {
};
}
function normalizeProcessToken(token: string): string {
if (token.length === 0) {
return token;
}
const quote =
token.startsWith('"') && token.endsWith('"')
? '"'
: token.startsWith("'") && token.endsWith("'")
? "'"
: "";
const rawToken = quote ? token.slice(1, -1) : token;
if (rawToken.length === 0) {
return token;
}
const assignmentMatch = rawToken.match(/^([A-Za-z_][A-Za-z0-9_]*=)(.+)$/);
const prefix = assignmentMatch ? assignmentMatch[1] : "";
const value = assignmentMatch ? assignmentMatch[2] : rawToken;
if (!value.includes("/")) {
return token;
}
const normalized = `${prefix}${basename(value)}`;
return quote ? `${quote}${normalized}${quote}` : normalized;
}
export function normalizeProcessTitle(processTitle: string): string | undefined {
const trimmed = processTitle.trim().replace(/\s+/g, " ");
if (trimmed.length === 0) {
return undefined;
}
const normalized = trimmed
.split(" ")
.map((token) => normalizeProcessToken(token))
.join(" ")
.trim();
return normalized.length > 0 ? normalized : undefined;
}
const PROCESS_INTERPRETERS = new Set([
"bash",
"bun",
"deno",
"node",
"nodejs",
"python",
"python3",
"ruby",
"sh",
"tsx",
"zsh",
]);
const PACKAGE_MANAGER_SCRIPT_NAMES = new Map<string, string>([
["bun.js", "bun"],
["npm-cli.js", "npm"],
["npx-cli.js", "npx"],
["pnpm.cjs", "pnpm"],
["pnpm.js", "pnpm"],
["yarn.cjs", "yarn"],
["yarn.js", "yarn"],
]);
export function humanizeProcessTitle(processTitle: string): string | undefined {
const normalized = normalizeProcessTitle(processTitle);
if (!normalized) {
return undefined;
}
const tokens = normalized.split(" ").filter(Boolean);
if (tokens.length === 0) {
return undefined;
}
while (tokens[0] === "env") {
tokens.shift();
while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) {
tokens.shift();
}
}
if (tokens.length === 0) {
return normalized;
}
const first = tokens[0];
const second = tokens[1];
if (PROCESS_INTERPRETERS.has(first) && second) {
const packageManager = PACKAGE_MANAGER_SCRIPT_NAMES.get(second);
if (packageManager) {
return [packageManager, ...tokens.slice(2)].join(" ").trim() || packageManager;
}
if (!second.startsWith("-")) {
return [second, ...tokens.slice(2)].join(" ").trim();
}
}
return normalized;
}
function extractLastOutputLines(terminal: TerminalType, limit: number): string[] {
const buffer = terminal.buffer.active;
const mergedLines: string[] = [];
for (let row = 0; row < buffer.length; row++) {
const line = buffer.getLine(row);
if (!line) {
continue;
}
const text = line.translateToString(true);
const isWrapped = (line as { isWrapped?: boolean }).isWrapped === true;
if (isWrapped && mergedLines.length > 0) {
mergedLines[mergedLines.length - 1] += text;
continue;
}
mergedLines.push(text);
}
while (mergedLines.length > 0 && mergedLines[0]?.trim().length === 0) {
mergedLines.shift();
}
while (mergedLines.length > 0 && mergedLines[mergedLines.length - 1]?.trim().length === 0) {
mergedLines.pop();
}
return mergedLines.slice(-limit);
}
function stripAnsiSequences(input: string): string {
return input.replace(
/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\].*?(?:\x07|\x1b\\))/g,
"",
);
}
function extractLastOutputLinesFromText(text: string, limit: number): string[] {
const normalized = stripAnsiSequences(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const lines = normalized.split("\n").map((line) => line.trimEnd());
while (lines[0]?.trim().length === 0) {
lines.shift();
}
while (lines[lines.length - 1]?.trim().length === 0) {
lines.pop();
}
return lines.slice(-limit);
}
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
const text = cells.map((cell) => cell.char).join("").trimEnd();
return options.stripAnsi ? stripAnsi(text) : text;
@@ -320,15 +516,23 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
rows = 24,
cols = 80,
name = "Terminal",
command,
args = [],
} = options;
const resolvedShell = shell ?? resolveDefaultTerminalShell();
const id = randomUUID();
const id = options.id ?? randomUUID();
const listeners = new Set<(msg: ServerMessage) => void>();
const exitListeners = new Set<() => void>();
const exitListeners = new Set<(info: TerminalExitInfo) => void>();
const titleChangeListeners = new Set<(title?: string) => void>();
let killed = false;
let disposed = false;
let exitEmitted = false;
let exitInfo: TerminalExitInfo | null = null;
let recentOutputText = "";
let title: string | undefined;
let pendingTitle: string | undefined;
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Create xterm.js headless terminal
const terminal = new Terminal({
@@ -341,18 +545,43 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
// Create PTY
const ptyProcess = pty.spawn(resolvedShell, [], {
const spawnCommand = command ?? resolvedShell;
const spawnArgs = command ? args : [];
const ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
name: "xterm-256color",
cols,
rows,
cwd,
env: {
...process.env,
...env,
TERM: "xterm-256color",
},
env: buildTerminalEnvironment({ shell: spawnCommand, env }),
});
function emitTitleChange(nextTitle: string | undefined): void {
if (title === nextTitle) {
return;
}
title = nextTitle;
for (const listener of Array.from(titleChangeListeners)) {
try {
listener(title);
} catch {
// no-op
}
}
for (const listener of Array.from(listeners)) {
try {
listener({ type: "titleChange", title });
} catch {
// no-op
}
}
}
const initialTitle = command
? humanizeProcessTitle([command, ...args].join(" ")) ??
normalizeProcessTitle([command, ...args].join(" "))
: undefined;
emitTitleChange(initialTitle);
// Respond to DA1 queries (CSI c or CSI 0 c) — apps like nvim query terminal capabilities
terminal.parser.registerCsiHandler({ final: "c" }, (params) => {
if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
@@ -362,14 +591,41 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
return false;
});
function emitExit(): void {
const disposeTitleChangeSubscription = terminal.onTitleChange((nextTitle) => {
if (disposed || killed) {
return;
}
pendingTitle = nextTitle.trim().length > 0 ? nextTitle : undefined;
if (titleDebounceTimer) {
clearTimeout(titleDebounceTimer);
}
titleDebounceTimer = setTimeout(() => {
titleDebounceTimer = null;
emitTitleChange(pendingTitle);
}, TERMINAL_TITLE_DEBOUNCE_MS);
});
function buildExitInfo(input?: { exitCode?: number | null; signal?: number | null }): TerminalExitInfo {
const lastOutputLines = extractLastOutputLines(terminal, TERMINAL_EXIT_OUTPUT_LINE_LIMIT);
return {
exitCode: input?.exitCode ?? null,
signal: input?.signal && input.signal > 0 ? input.signal : null,
lastOutputLines:
lastOutputLines.length > 0
? lastOutputLines
: extractLastOutputLinesFromText(recentOutputText, TERMINAL_EXIT_OUTPUT_LINE_LIMIT),
};
}
function emitExit(info: TerminalExitInfo): void {
if (exitEmitted) {
return;
}
exitEmitted = true;
exitInfo = info;
for (const listener of Array.from(exitListeners)) {
try {
listener();
listener(info);
} catch {
// no-op
}
@@ -382,14 +638,24 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
return;
}
disposed = true;
if (titleDebounceTimer) {
clearTimeout(titleDebounceTimer);
titleDebounceTimer = null;
}
disposeTitleChangeSubscription.dispose();
terminal.dispose();
listeners.clear();
exitListeners.clear();
titleChangeListeners.clear();
}
// Pipe PTY output to terminal emulator
ptyProcess.onData((data) => {
if (killed) return;
recentOutputText = `${recentOutputText}${data}`;
if (recentOutputText.length > TERMINAL_EXIT_OUTPUT_CHAR_LIMIT) {
recentOutputText = recentOutputText.slice(-TERMINAL_EXIT_OUTPUT_CHAR_LIMIT);
}
terminal.write(data, () => {
if (disposed || killed) {
return;
@@ -400,9 +666,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
});
});
ptyProcess.onExit(() => {
ptyProcess.onExit((event) => {
killed = true;
emitExit();
emitExit(
buildExitInfo({
exitCode: event.exitCode,
signal: event.signal,
}),
);
disposeResources();
});
@@ -413,6 +684,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
grid: extractGrid(terminal),
scrollback: extractScrollback(terminal),
cursor: extractCursorState(terminal),
...(title ? { title } : {}),
};
}
@@ -455,11 +727,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
};
}
function onExit(listener: () => void): () => void {
function onExit(listener: (info: TerminalExitInfo) => void): () => void {
if (killed) {
queueMicrotask(() => {
try {
listener();
listener(exitInfo ?? buildExitInfo());
} catch {
// no-op
}
@@ -473,11 +745,38 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
};
}
function onTitleChange(listener: (title?: string) => void): () => void {
titleChangeListeners.add(listener);
if (title !== undefined) {
queueMicrotask(() => {
if (disposed || !titleChangeListeners.has(listener)) {
return;
}
try {
listener(title);
} catch {
// no-op
}
});
}
return () => {
titleChangeListeners.delete(listener);
};
}
function getTitle(): string | undefined {
return title;
}
function getExitInfo(): TerminalExitInfo | null {
return exitInfo;
}
function kill(): void {
if (!killed) {
killed = true;
ptyProcess.kill();
emitExit();
emitExit(buildExitInfo());
}
disposeResources();
}
@@ -492,8 +791,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
send,
subscribe,
onExit,
onTitleChange,
getSize,
getState,
getTitle,
getExitInfo,
kill,
};
}