Update files

This commit is contained in:
Mohamed Boudra
2026-02-10 13:43:07 +07:00
parent ade4fb2e45
commit 7e5537bcc5
29 changed files with 1296 additions and 515 deletions
+32 -8
View File
@@ -1,17 +1,41 @@
{
"name": "@getpaseo/server",
"version": "0.1.0",
"private": true,
"description": "Paseo backend server",
"type": "module",
"types": "./dist/server/server/exports.d.ts",
"files": [
"dist/server",
"dist/scripts",
"README.md",
".env.example",
"agent-prompt.md"
],
"exports": {
".": "./src/server/exports.ts",
"./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts"
".": {
"types": "./dist/server/server/exports.d.ts",
"default": [
"./dist/server/server/exports.js",
"./src/server/exports.ts"
]
},
"./utils/tool-call-parsers": {
"types": "./dist/server/utils/tool-call-parsers.d.ts",
"default": [
"./dist/server/utils/tool-call-parsers.js",
"./src/utils/tool-call-parsers.ts"
]
}
},
"scripts": {
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
"dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
"build": "tsc -p tsconfig.server.json",
"start": "NODE_ENV=production node dist/server/index.js",
"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",
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && mkdir -p dist/scripts && cp 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",
"typecheck": "tsc -p tsconfig.server.typecheck.json --noEmit",
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
"speech:models": "tsx scripts/list-speech-models.ts",
@@ -25,7 +49,8 @@
"test:e2e:mobile": "playwright test --project='Mobile Chrome'"
},
"dependencies": {
"@ai-sdk/openai": "^2.0.52",
"@getpaseo/relay": "*",
"@ai-sdk/openai": "2.0.52",
"@deepgram/sdk": "^3.4.0",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
@@ -39,7 +64,7 @@
"@opencode-ai/sdk": "^1.1.12",
"@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0",
"ai": "^5.0.76",
"ai": "5.0.78",
"ajv": "^8.17.1",
"dotenv": "^17.2.3",
"express": "^4.18.2",
@@ -50,7 +75,6 @@
"openai": "^4.20.0",
"pino": "^10.2.0",
"pino-pretty": "^13.1.3",
"playwright": "^1.56.1",
"qrcode": "^1.5.4",
"sherpa-onnx": "^1.12.23",
"sherpa-onnx-node": "^1.12.23",
@@ -62,8 +86,8 @@
"zod-to-json-schema": "^3.25.1"
},
"devDependencies": {
"@getpaseo/relay": "*",
"@playwright/test": "^1.56.1",
"playwright": "^1.56.1",
"@types/express": "^4.17.20",
"@types/node": "^20.9.0",
"@types/qrcode": "^1.5.6",
+36
View File
@@ -0,0 +1,36 @@
import { fileURLToPath } from "url";
import { existsSync } from "node:fs";
import { runSupervisor } from "./supervisor.js";
function resolveWorkerEntry(): string {
const candidates = [
fileURLToPath(new URL("../server/server/index.js", import.meta.url)),
fileURLToPath(new URL("../dist/server/server/index.js", import.meta.url)),
fileURLToPath(new URL("../src/server/index.ts", import.meta.url)),
fileURLToPath(new URL("../../src/server/index.ts", import.meta.url)),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return candidates[0];
}
function resolveWorkerExecArgv(): string[] {
const workerEntry = resolveWorkerEntry();
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
}
runSupervisor({
name: "DaemonRunner",
startupMessage: "Starting daemon worker (IPC restart enabled)",
resolveWorkerEntry,
workerArgs: process.argv.slice(2),
workerEnv: process.env,
workerExecArgv: resolveWorkerExecArgv(),
restartOnCrash: false,
shutdownReasons: ["cli_shutdown"],
});
+26 -53
View File
@@ -1,63 +1,36 @@
import { fork, type ChildProcess } from "child_process";
import { fileURLToPath } from "url";
import path from "path";
import { existsSync } from "node:fs";
import { runSupervisor } from "./supervisor.js";
const serverEntry = fileURLToPath(
new URL("../src/server/index.ts", import.meta.url)
);
function resolveWorkerEntry(): string {
const candidates = [
fileURLToPath(new URL("../server/server/index.js", import.meta.url)),
fileURLToPath(new URL("../dist/server/server/index.js", import.meta.url)),
fileURLToPath(new URL("../src/server/index.ts", import.meta.url)),
fileURLToPath(new URL("../../src/server/index.ts", import.meta.url)),
];
let child: ChildProcess | null = null;
let restarting = false;
function spawnServer() {
child = fork(serverEntry, process.argv.slice(2), {
stdio: "inherit",
env: process.env,
execArgv: ["--import", "tsx"],
});
child.on("message", (msg: any) => {
if (msg?.type === "paseo:restart") {
restartServer();
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
});
child.on("exit", (code, signal) => {
const exitDescriptor =
signal ?? (typeof code === "number" ? `code ${code}` : "unknown");
// Restart on: explicit restart request, or any non-zero exit (crash)
if (restarting || (code !== 0 && code !== null)) {
restarting = false;
process.stderr.write(`[DevRunner] Server exited (${exitDescriptor}). Restarting...\n`);
spawnServer();
return;
}
process.stderr.write(`[DevRunner] Server exited (${exitDescriptor}). Shutting down.\n`);
process.exit(0);
});
}
function restartServer() {
if (!child || restarting) {
return;
}
restarting = true;
process.stderr.write("[DevRunner] Restart requested. Stopping current server...\n");
child.kill("SIGTERM");
return candidates[0];
}
function forwardSignal(signal: NodeJS.Signals) {
if (!child) {
process.exit(0);
}
child.kill(signal);
function resolveWorkerExecArgv(): string[] {
const workerEntry = resolveWorkerEntry();
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
}
process.on("SIGINT", () => forwardSignal("SIGINT"));
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
process.stdout.write("[DevRunner] Starting server with tsx (explicit restarts only)\n");
spawnServer();
runSupervisor({
name: "DevRunner",
startupMessage: "Starting server worker (crash restarts enabled)",
resolveWorkerEntry,
workerArgs: process.argv.slice(2),
workerEnv: process.env,
workerExecArgv: resolveWorkerExecArgv(),
restartOnCrash: true,
shutdownReasons: ["cli_shutdown"],
});
+130
View File
@@ -0,0 +1,130 @@
import { fork, type ChildProcess } from "child_process";
type RestartMessage = {
type: "paseo:restart";
reason?: string;
};
type SupervisorOptions = {
name: string;
startupMessage: string;
resolveWorkerEntry: () => string;
workerArgs?: string[];
workerEnv?: NodeJS.ProcessEnv;
workerExecArgv?: string[];
restartOnCrash?: boolean;
shutdownReasons?: string[];
};
function describeExit(code: number | null, signal: NodeJS.Signals | null): string {
return signal ?? (typeof code === "number" ? `code ${code}` : "unknown");
}
function isRestartMessage(msg: unknown): msg is RestartMessage {
return (
typeof msg === "object" &&
msg !== null &&
"type" in msg &&
(msg as { type?: unknown }).type === "paseo:restart"
);
}
export function runSupervisor(options: SupervisorOptions): void {
const shutdownReasons = new Set(options.shutdownReasons ?? ["cli_shutdown"]);
const restartOnCrash = options.restartOnCrash ?? false;
const workerArgs = options.workerArgs ?? process.argv.slice(2);
const workerEnv = options.workerEnv ?? process.env;
const workerExecArgv = options.workerExecArgv ?? ["--import", "tsx"];
let child: ChildProcess | null = null;
let restarting = false;
let shuttingDown = false;
const log = (message: string): void => {
process.stderr.write(`[${options.name}] ${message}\n`);
};
const spawnWorker = () => {
let workerEntry: string;
try {
// Resolve at spawn time so restarts pick up current filesystem state.
workerEntry = options.resolveWorkerEntry();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`Failed to resolve worker entry: ${message}`);
process.exit(1);
return;
}
child = fork(workerEntry, workerArgs, {
stdio: "inherit",
env: workerEnv,
execArgv: workerExecArgv,
});
child.on("message", (msg: unknown) => {
if (!isRestartMessage(msg)) {
return;
}
if (msg.reason && shutdownReasons.has(msg.reason)) {
requestShutdown(`Shutdown requested by worker (${msg.reason})`);
return;
}
requestRestart("Restart requested by worker");
});
child.on("exit", (code, signal) => {
const exitDescriptor = describeExit(code, signal);
if (shuttingDown) {
log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`);
process.exit(0);
}
if (restarting || (restartOnCrash && code !== 0 && code !== null)) {
restarting = false;
log(`Worker exited (${exitDescriptor}). Restarting worker...`);
spawnWorker();
return;
}
log(`Worker exited (${exitDescriptor}). Supervisor exiting.`);
process.exit(typeof code === "number" ? code : 0);
});
};
const requestRestart = (reason: string) => {
if (!child || restarting || shuttingDown) {
return;
}
restarting = true;
log(`${reason}. Stopping worker for restart...`);
child.kill("SIGTERM");
};
const requestShutdown = (reason: string) => {
if (shuttingDown) {
return;
}
shuttingDown = true;
restarting = false;
log(`${reason}. Stopping worker...`);
if (!child) {
process.exit(0);
return;
}
child.kill("SIGTERM");
};
const forwardSignal = (signal: NodeJS.Signals) => {
requestShutdown(`Received ${signal}`);
};
process.on("SIGINT", () => forwardSignal("SIGINT"));
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
process.stdout.write(`[${options.name}] ${options.startupMessage}\n`);
spawnWorker();
}
@@ -977,7 +977,6 @@ function mapCodexPatchNotificationToToolCall(params: {
stdout?: string | null;
stderr?: string | null;
success?: boolean | null;
latestUnifiedDiff?: string | null;
running: boolean;
}): ToolCallTimelineItem {
const files = parseCodexPatchChanges(params.changes);
@@ -985,7 +984,7 @@ function mapCodexPatchNotificationToToolCall(params: {
const firstPatchText = files
.map((file) => file.content?.trim())
.find((value): value is string => typeof value === "string" && value.length > 0);
const patchText = params.latestUnifiedDiff?.trim() || firstPatchText;
const patchText = firstPatchText;
const patchFields = codexPatchTextFields(patchText);
const mapped = mapCodexRolloutToolCall({
callId: params.callId ?? null,
@@ -1736,7 +1735,6 @@ class CodexAppServerAgentSession implements AgentSession {
private warnedUnknownNotificationMethods = new Set<string>();
private warnedInvalidNotificationPayloads = new Set<string>();
private warnedIncompleteEditToolCallIds = new Set<string>();
private latestTurnUnifiedDiff: string | null = null;
private latestUsage: AgentUsage | undefined;
private connected = false;
private collaborationModes: Array<{
@@ -2422,7 +2420,6 @@ class CodexAppServerAgentSession implements AgentSession {
if (parsed.kind === "turn_started") {
this.currentTurnId = parsed.turnId;
this.latestTurnUnifiedDiff = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
this.pendingCommandOutputDeltas.clear();
@@ -2444,7 +2441,6 @@ class CodexAppServerAgentSession implements AgentSession {
} else {
this.emitEvent({ type: "turn_completed", provider: CODEX_PROVIDER, usage: this.latestUsage });
}
this.latestTurnUnifiedDiff = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
this.pendingCommandOutputDeltas.clear();
@@ -2470,8 +2466,6 @@ class CodexAppServerAgentSession implements AgentSession {
}
if (parsed.kind === "diff_updated") {
const trimmedDiff = parsed.diff.trim();
this.latestTurnUnifiedDiff = trimmedDiff.length > 0 ? trimmedDiff : null;
// NOTE: Codex app-server emits frequent `turn/diff/updated` notifications
// containing a full accumulated unified diff for the *entire turn*.
// This is not a concrete file-change tool call; it is progress telemetry.
@@ -2557,7 +2551,6 @@ class CodexAppServerAgentSession implements AgentSession {
callId: parsed.callId,
changes: parsed.changes,
cwd: this.config.cwd ?? null,
latestUnifiedDiff: this.latestTurnUnifiedDiff,
running: true,
});
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", {
@@ -2580,7 +2573,6 @@ class CodexAppServerAgentSession implements AgentSession {
stdout: parsed.stdout ?? bufferedOutput,
stderr: parsed.stderr,
success: parsed.success,
latestUnifiedDiff: this.latestTurnUnifiedDiff,
running: false,
});
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", {
@@ -463,4 +463,38 @@ describe("codex tool-call mapper", () => {
expect(item.detail.newString).toBeUndefined();
}
});
it("maps path-only fileChange payloads to unknown detail instead of empty edit detail", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-file-change-path-only",
status: "completed",
changes: [{ path: "/tmp/repo/src/path-only.ts", kind: "modify" }],
},
{ cwd: "/tmp/repo" }
);
expect(item?.detail.type).toBe("unknown");
if (item?.detail.type === "unknown") {
expect(item.detail.input).toEqual({
files: [{ path: "src/path-only.ts", kind: "modify" }],
});
}
});
it("maps path-only apply_patch rollout payloads to unknown detail instead of empty edit detail", () => {
const item = mapCodexRolloutToolCall({
callId: "codex-call-apply-path-only",
name: "apply_patch",
input: { path: "/tmp/repo/src/path-only-rollout.ts" },
output: { files: [{ path: "/tmp/repo/src/path-only-rollout.ts", kind: "modify" }] },
cwd: "/tmp/repo",
});
expect(item.detail.type).toBe("unknown");
if (item.detail.type === "unknown") {
expect(item.detail.input).toEqual({ path: "/tmp/repo/src/path-only-rollout.ts" });
}
});
});
@@ -309,6 +309,17 @@ function asPatchOrContentFields(text: string | undefined): { patch?: string; con
return { content: text };
}
function hasRenderableEditContent(detail: ToolCallDetail): boolean {
if (detail.type !== "edit") {
return false;
}
return (
(typeof detail.unifiedDiff === "string" && detail.unifiedDiff.length > 0) ||
(typeof detail.newString === "string" && detail.newString.length > 0) ||
(typeof detail.oldString === "string" && detail.oldString.length > 0)
);
}
function pickFirstPatchLikeString(values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.length > 0) {
@@ -610,12 +621,19 @@ function mapFileChangeItem(
const firstFile = files[0];
const firstTextFields = asEditTextFields(firstFile?.diff);
const hasFirstTextFields = Object.keys(firstTextFields).length > 0;
const detail = firstFile?.path
? {
? hasFirstTextFields
? {
type: "edit" as const,
filePath: firstFile.path,
...firstTextFields,
}
: {
type: "unknown" as const,
input,
output,
}
: {
type: "unknown" as const,
input,
@@ -755,6 +773,13 @@ export function mapCodexRolloutToolCall(params: {
detail = fallbackDetail;
}
}
if (detail.type === "edit" && !hasRenderableEditContent(detail)) {
detail = {
type: "unknown",
input,
output,
};
}
return buildToolCall({
callId,
+54 -49
View File
@@ -147,18 +147,22 @@ export async function createPaseoDaemon(
);
}
const serverId = getOrCreateServerId(config.paseoHome, { logger });
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
let relayTransport: RelayTransportController | null = null;
// Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately.
await acquirePidLock(config.paseoHome, config.listen);
const staticDir = config.staticDir;
const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000;
try {
const serverId = getOrCreateServerId(config.paseoHome, { logger });
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
let relayTransport: RelayTransportController | null = null;
const downloadTokenStore = new DownloadTokenStore({ ttlMs: downloadTokenTtlMs });
const staticDir = config.staticDir;
const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000;
const listenTarget = parseListenString(config.listen);
const downloadTokenStore = new DownloadTokenStore({ ttlMs: downloadTokenTtlMs });
const app = express();
const listenTarget = parseListenString(config.listen);
const app = express();
// Host allowlist / DNS rebinding protection (vite-like semantics).
// For non-TCP (unix sockets), skip host validation.
@@ -495,12 +499,9 @@ export async function createPaseoDaemon(
}
);
const start = async () => {
// Acquire PID lock
await acquirePidLock(config.paseoHome, config.listen);
// Start main HTTP server
await new Promise<void>((resolve, reject) => {
const start = async () => {
// Start main HTTP server
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
httpServer.off("listening", onListening);
reject(err);
@@ -581,43 +582,47 @@ export async function createPaseoDaemon(
}
httpServer.listen(listenTarget.path);
}
});
};
});
};
const stop = async () => {
await closeAllAgents(logger, agentManager);
await agentManager.flush().catch(() => undefined);
detachAgentStoragePersistence();
await agentStorage.flush().catch(() => undefined);
await shutdownProviders(logger);
terminalManager.killAll();
cleanupSpeechRuntime();
await relayTransport?.stop().catch(() => undefined);
if (wsServer) {
await wsServer.close();
}
if (voiceMcpBridgeManager) {
await voiceMcpBridgeManager.stop().catch(() => undefined);
}
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
// Clean up socket files
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path);
}
// Release PID lock
await releasePidLock(config.paseoHome);
};
const stop = async () => {
await closeAllAgents(logger, agentManager);
await agentManager.flush().catch(() => undefined);
detachAgentStoragePersistence();
await agentStorage.flush().catch(() => undefined);
await shutdownProviders(logger);
terminalManager.killAll();
cleanupSpeechRuntime();
await relayTransport?.stop().catch(() => undefined);
if (wsServer) {
await wsServer.close();
}
if (voiceMcpBridgeManager) {
await voiceMcpBridgeManager.stop().catch(() => undefined);
}
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
// Clean up socket files
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path);
}
// Release PID lock
await releasePidLock(config.paseoHome);
};
return {
config,
agentManager,
agentStorage,
terminalManager,
start,
stop,
};
return {
config,
agentManager,
agentStorage,
terminalManager,
start,
stop,
};
} catch (err) {
await releasePidLock(config.paseoHome).catch(() => undefined);
throw err;
}
}
async function closeAllAgents(
+11 -3
View File
@@ -1,4 +1,4 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
@@ -157,10 +157,18 @@ export function loadPersistedConfig(
): PersistedConfig {
const log = getLogger(logger);
const configPath = getConfigPath(paseoHome);
const defaultConfig = PersistedConfigSchema.parse({});
if (!existsSync(configPath)) {
log?.info(`No config file at ${configPath}, using defaults`);
return PersistedConfigSchema.parse({});
try {
mkdirSync(path.dirname(configPath), { recursive: true });
writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + "\n");
log?.info(`Initialized config file at ${configPath}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`[Config] Failed to initialize ${configPath}: ${message}`);
}
return defaultConfig;
}
let raw: string;
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2020"],
"types": ["node"],
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"outDir": "./dist",
"rootDir": ".",
"declaration": false,
"sourceMap": true
},
"include": [
"scripts/daemon-runner.ts",
"scripts/dev-runner.ts",
"scripts/supervisor.ts"
],
"exclude": ["node_modules", "dist"]
}