diff --git a/docs/background-process.md b/docs/background-process.md index 8e952f91ac..6a01226065 100644 --- a/docs/background-process.md +++ b/docs/background-process.md @@ -16,6 +16,7 @@ Key parameters: - `yieldMs` (default 20000): auto‑background after this delay - `background` (bool): background immediately - `timeout` (seconds, default 1800): kill the process after this timeout +- `stdinMode` (`pipe` | `pty`): use a real TTY when `pty` is requested and node-pty loads (otherwise warns + falls back) - `workdir`, `env` Behavior: diff --git a/docs/templates/AGENTS.md b/docs/templates/AGENTS.md index 23cd48654c..0e3fe61939 100644 --- a/docs/templates/AGENTS.md +++ b/docs/templates/AGENTS.md @@ -28,6 +28,16 @@ You wake up fresh each session. These files are your continuity: Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them. +### 🧠 Memory Recall - Use qmd! +When you need to remember something from the past, use `qmd` instead of grepping files: +```bash +qmd query "what happened at Christmas" # Semantic search with reranking +qmd search "specific phrase" # BM25 keyword search +qmd vsearch "conceptual question" # Pure vector similarity +``` +Index your memory folder: `qmd index memory/` +Vectors + BM25 + reranking finds things even with different wording. + ## Safety - Don't exfiltrate private data. Ever. diff --git a/docs/tools.md b/docs/tools.md index bd5a6a4898..7d3987d62f 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -21,6 +21,7 @@ Core parameters: - `yieldMs` (auto-background after timeout, default 20000) - `background` (immediate background) - `timeout` (seconds; kills the process if exceeded, default 1800) +- `stdinMode` (`pipe` | `pty`; `pty` uses node-pty for a real TTY with fallback warning) Notes: - Returns `status: "running"` with a `sessionId` when backgrounded. diff --git a/package.json b/package.json index 6fa1a2eabd..6c4c78c610 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "grammy": "^1.39.2", "json5": "^2.2.3", "long": "5.3.2", + "node-pty": "^1.1.0", "playwright-core": "1.57.0", "qrcode-terminal": "^0.12.0", "sharp": "^0.34.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 428690b35e..8b7455c6b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: long: specifier: 5.3.2 version: 5.3.2 + node-pty: + specifier: ^1.1.0 + version: 1.1.0 playwright-core: specifier: 1.57.0 version: 1.57.0 @@ -2158,6 +2161,9 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -2176,6 +2182,9 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-pty@1.1.0: + resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + node-wav@0.0.2: resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==} engines: {node: '>=4.4.0'} @@ -4804,6 +4813,8 @@ snapshots: negotiator@1.0.0: {} + node-addon-api@7.1.1: {} + node-domexception@1.0.0: {} node-fetch@2.7.0: @@ -4816,6 +4827,10 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-pty@1.1.0: + dependencies: + node-addon-api: 7.1.1 + node-wav@0.0.2: optional: true diff --git a/src/agents/bash-process-registry.test.ts b/src/agents/bash-process-registry.test.ts index 2c5b04561a..36e51d9314 100644 --- a/src/agents/bash-process-registry.test.ts +++ b/src/agents/bash-process-registry.test.ts @@ -20,6 +20,7 @@ describe("bash process registry", () => { id: "sess", command: "echo test", child: { pid: 123 } as ChildProcessWithoutNullStreams, + stdinMode: "pipe", startedAt: Date.now(), cwd: "/tmp", maxOutputChars: 10, @@ -48,6 +49,7 @@ describe("bash process registry", () => { id: "sess", command: "echo test", child: { pid: 123 } as ChildProcessWithoutNullStreams, + stdinMode: "pipe", startedAt: Date.now(), cwd: "/tmp", maxOutputChars: 100, diff --git a/src/agents/bash-process-registry.ts b/src/agents/bash-process-registry.ts index cec7767a92..fca9703c6f 100644 --- a/src/agents/bash-process-registry.ts +++ b/src/agents/bash-process-registry.ts @@ -1,4 +1,5 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import type { IPty } from "node-pty"; const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute @@ -15,10 +16,15 @@ let jobTtlMs = clampTtl( export type ProcessStatus = "running" | "completed" | "failed" | "killed"; +export type ProcessStdinMode = "pipe" | "pty"; + export interface ProcessSession { id: string; command: string; - child: ChildProcessWithoutNullStreams; + child?: ChildProcessWithoutNullStreams; + pty?: IPty; + pid?: number; + stdinMode: ProcessStdinMode; startedAt: number; cwd?: string; maxOutputChars: number; diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts index 964a5b2cdf..907bb8c7c1 100644 --- a/src/agents/bash-tools.ts +++ b/src/agents/bash-tools.ts @@ -2,6 +2,7 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; +import type { IPty } from "node-pty"; import { addSession, @@ -14,6 +15,7 @@ import { listRunningSessions, markBackgrounded, markExited, + type ProcessStdinMode, setJobTtlMs, } from "./bash-process-registry.js"; import { @@ -29,6 +31,19 @@ const DEFAULT_MAX_OUTPUT = clampNumber( 1_000, 150_000, ); +const DEFAULT_PTY_NAME = "xterm-256color"; + +type PtyModule = typeof import("node-pty"); +let ptyModulePromise: Promise | null = null; + +async function loadPtyModule(): Promise { + if (!ptyModulePromise) { + ptyModulePromise = import("node-pty") + .then((mod) => mod) + .catch(() => null); + } + return ptyModulePromise; +} const stringEnum = ( values: readonly string[], @@ -72,7 +87,7 @@ const bashSchema = Type.Object({ ), stdinMode: Type.Optional( stringEnum(["pipe", "pty"] as const, { - description: "Only pipe is supported", + description: "stdin mode (pipe or pty when node-pty is available)", }), ), }); @@ -127,9 +142,6 @@ export function createBashTool( if (!params.command) { throw new Error("Provide a command to start."); } - if (params.stdinMode && params.stdinMode !== "pipe") { - throw new Error('Only stdinMode "pipe" is supported right now.'); - } const yieldWindow = params.background ? 0 @@ -146,21 +158,56 @@ export function createBashTool( const { shell, args: shellArgs } = getShellConfig(); const env = params.env ? { ...process.env, ...params.env } : process.env; - const child: ChildProcessWithoutNullStreams = spawn( - shell, - [...shellArgs, params.command], - { + const requestedStdinMode = + params.stdinMode === "pty" ? "pty" : "pipe"; + let stdinMode: ProcessStdinMode = requestedStdinMode; + let warning: string | null = null; + let child: ChildProcessWithoutNullStreams | undefined; + let pty: IPty | undefined; + + if (stdinMode === "pty") { + const ptyModule = await loadPtyModule(); + if (!ptyModule) { + warning = + "Warning: node-pty failed to load; falling back to pipe mode."; + stdinMode = "pipe"; + } else { + const ptyEnv = { + ...env, + TERM: env.TERM ?? DEFAULT_PTY_NAME, + } as Record; + try { + pty = ptyModule.spawn(shell, [...shellArgs, params.command], { + cwd: workdir, + env: ptyEnv, + name: ptyEnv.TERM || DEFAULT_PTY_NAME, + cols: 120, + rows: 30, + }); + } catch { + warning = + "Warning: node-pty failed to start; falling back to pipe mode."; + stdinMode = "pipe"; + } + } + } + + if (stdinMode === "pipe") { + child = spawn(shell, [...shellArgs, params.command], { cwd: workdir, env, detached: true, stdio: ["pipe", "pipe", "pipe"], - }, - ); + }); + } const session = { id: sessionId, command: params.command, child, + pty, + pid: child?.pid ?? pty?.pid, + stdinMode, startedAt, cwd: workdir, maxOutputChars: maxOutput, @@ -190,9 +237,7 @@ export function createBashTool( }; const onAbort = () => { - if (child.pid) { - killProcessTree(child.pid); - } + killSession(session); }; if (signal?.aborted) onAbort(); @@ -212,33 +257,46 @@ export function createBashTool( const emitUpdate = () => { if (!onUpdate) return; const tailText = session.tail || session.aggregated; + const warningText = warning ? `${warning}\n\n` : ""; onUpdate({ - content: [{ type: "text", text: tailText || "" }], + content: [{ type: "text", text: warningText + (tailText || "") }], details: { status: "running", sessionId, - pid: child.pid ?? undefined, + pid: session.pid ?? undefined, startedAt, tail: session.tail, }, }); }; - child.stdout.on("data", (data) => { - const str = sanitizeBinaryOutput(data.toString()); - for (const chunk of chunkString(str)) { - appendOutput(session, "stdout", chunk); - emitUpdate(); - } - }); + if (child) { + child.stdout.on("data", (data) => { + const str = sanitizeBinaryOutput(data.toString()); + for (const chunk of chunkString(str)) { + appendOutput(session, "stdout", chunk); + emitUpdate(); + } + }); - child.stderr.on("data", (data) => { - const str = sanitizeBinaryOutput(data.toString()); - for (const chunk of chunkString(str)) { - appendOutput(session, "stderr", chunk); - emitUpdate(); - } - }); + child.stderr.on("data", (data) => { + const str = sanitizeBinaryOutput(data.toString()); + for (const chunk of chunkString(str)) { + appendOutput(session, "stderr", chunk); + emitUpdate(); + } + }); + } + + if (pty) { + pty.onData((data) => { + const str = sanitizeBinaryOutput(data); + for (const chunk of chunkString(str)) { + appendOutput(session, "stdout", chunk); + emitUpdate(); + } + }); + } return new Promise>( (resolve, reject) => { @@ -249,14 +307,15 @@ export function createBashTool( { type: "text", text: - `Command still running (session ${sessionId}, pid ${child.pid ?? "n/a"}). ` + + `${warning ? `${warning}\n\n` : ""}` + + `Command still running (session ${sessionId}, pid ${session.pid ?? "n/a"}). ` + "Use process (list/poll/log/write/kill/clear/remove) for follow-up.", }, ], details: { status: "running", sessionId, - pid: child.pid ?? undefined, + pid: session.pid ?? undefined, startedAt, tail: session.tail, }, @@ -283,7 +342,10 @@ export function createBashTool( }, yieldWindow); } - child.once("exit", (code, exitSignal) => { + const handleExit = ( + code: number | null, + exitSignal: NodeJS.Signals | number | null, + ) => { if (yieldTimer) clearTimeout(yieldTimer); if (timeoutTimer) clearTimeout(timeoutTimer); const durationMs = Date.now() - startedAt; @@ -315,7 +377,14 @@ export function createBashTool( settle(() => resolve({ - content: [{ type: "text", text: aggregated || "(no output)" }], + content: [ + { + type: "text", + text: + `${warning ? `${warning}\n\n` : ""}` + + (aggregated || "(no output)"), + }, + ], details: { status: "completed", exitCode: code ?? 0, @@ -324,14 +393,26 @@ export function createBashTool( }, }), ); - }); + }; - child.once("error", (err) => { - if (yieldTimer) clearTimeout(yieldTimer); - if (timeoutTimer) clearTimeout(timeoutTimer); - markExited(session, null, null, "failed"); - settle(() => reject(err)); - }); + if (child) { + child.once("exit", (code, exitSignal) => { + handleExit(code, exitSignal); + }); + + child.once("error", (err) => { + if (yieldTimer) clearTimeout(yieldTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + markExited(session, null, null, "failed"); + settle(() => reject(err)); + }); + } + + if (pty) { + pty.onExit(({ exitCode, signal }) => { + handleExit(exitCode ?? null, signal ?? null); + }); + } }, ); }, @@ -383,7 +464,7 @@ export function createProcessTool( const running = listRunningSessions().map((s) => ({ sessionId: s.id, status: "running", - pid: s.child.pid ?? undefined, + pid: s.pid ?? undefined, startedAt: s.startedAt, runtimeMs: Date.now() - s.startedAt, cwd: s.cwd, @@ -627,25 +708,43 @@ export function createProcessTool( details: { status: "failed" }, }; } - if (!session.child.stdin || session.child.stdin.destroyed) { - return { - content: [ - { - type: "text", - text: `Session ${params.sessionId} stdin is not writable.`, - }, - ], - details: { status: "failed" }, - }; - } - await new Promise((resolve, reject) => { - session.child.stdin.write(params.data ?? "", (err) => { - if (err) reject(err); - else resolve(); + if (session.stdinMode === "pty") { + if (!session.pty || session.exited) { + return { + content: [ + { + type: "text", + text: `Session ${params.sessionId} stdin is not writable.`, + }, + ], + details: { status: "failed" }, + }; + } + session.pty.write(params.data ?? ""); + if (params.eof) { + session.pty.write("\x04"); + } + } else { + if (!session.child?.stdin || session.child.stdin.destroyed) { + return { + content: [ + { + type: "text", + text: `Session ${params.sessionId} stdin is not writable.`, + }, + ], + details: { status: "failed" }, + }; + } + await new Promise((resolve, reject) => { + session.child?.stdin.write(params.data ?? "", (err) => { + if (err) reject(err); + else resolve(); + }); }); - }); - if (params.eof) { - session.child.stdin.end(); + if (params.eof) { + session.child.stdin.end(); + } } return { content: [ @@ -687,9 +786,7 @@ export function createProcessTool( details: { status: "failed" }, }; } - if (session.child.pid) { - killProcessTree(session.child.pid); - } + killSession(session); markExited(session, null, "SIGKILL", "failed"); return { content: [ @@ -725,9 +822,7 @@ export function createProcessTool( case "remove": { if (session) { - if (session.child.pid) { - killProcessTree(session.child.pid); - } + killSession(session); markExited(session, null, "SIGKILL", "failed"); return { content: [ @@ -772,6 +867,25 @@ export function createProcessTool( export const processTool = createProcessTool(); +function killSession(session: { + pid?: number; + stdinMode: ProcessStdinMode; + pty?: IPty; + child?: ChildProcessWithoutNullStreams; +}) { + const pid = session.pid ?? session.child?.pid ?? session.pty?.pid; + if (pid) { + killProcessTree(pid); + } + if (session.stdinMode === "pty") { + try { + session.pty?.kill(); + } catch { + // ignore kill failures + } + } +} + function clampNumber( value: number | undefined, defaultValue: number,