feat: embed pi agent runtime

This commit is contained in:
Peter Steinberger
2025-12-17 11:29:04 +01:00
parent c5867b2876
commit fece42ce0a
42 changed files with 2076 additions and 4009 deletions

View File

@@ -1,128 +0,0 @@
import { describe, expect, it } from "vitest";
import { piSpec } from "./pi.js";
describe("pi agent helpers", () => {
it("buildArgs injects print/format flags and identity once", () => {
const argv = ["pi", "hi"];
const built = piSpec.buildArgs({
argv,
bodyIndex: 1,
isNewSession: true,
sessionId: "sess",
provider: "anthropic",
model: "claude-opus-4-5",
sendSystemOnce: false,
systemSent: false,
identityPrefix: "IDENT",
format: "json",
});
expect(built).toContain("-p");
expect(built).toContain("--mode");
expect(built).toContain("json");
expect(built).toContain("--provider");
expect(built).toContain("anthropic");
expect(built).toContain("--model");
expect(built).toContain("claude-opus-4-5");
expect(built.at(-1)).toContain("IDENT");
const builtNoIdentity = piSpec.buildArgs({
argv,
bodyIndex: 1,
isNewSession: false,
sessionId: "sess",
provider: "anthropic",
model: "claude-opus-4-5",
sendSystemOnce: true,
systemSent: true,
identityPrefix: "IDENT",
format: "json",
});
expect(builtNoIdentity.at(-1)).toBe("hi");
});
it("injects provider/model for pi invocations only and avoids duplicates", () => {
const base = piSpec.buildArgs({
argv: ["pi", "hello"],
bodyIndex: 1,
isNewSession: true,
sendSystemOnce: false,
systemSent: false,
format: "json",
});
expect(base.filter((a) => a === "--provider").length).toBe(1);
expect(base).toContain("anthropic");
expect(base.filter((a) => a === "--model").length).toBe(1);
expect(base).toContain("claude-opus-4-5");
const already = piSpec.buildArgs({
argv: [
"pi",
"--provider",
"anthropic",
"--model",
"claude-opus-4-5",
"hi",
],
bodyIndex: 5,
isNewSession: true,
sendSystemOnce: false,
systemSent: false,
format: "json",
});
expect(already.filter((a) => a === "--provider").length).toBe(1);
expect(already.filter((a) => a === "--model").length).toBe(1);
const nonPi = piSpec.buildArgs({
argv: ["echo", "hi"],
bodyIndex: 1,
isNewSession: true,
sendSystemOnce: false,
systemSent: false,
format: "json",
});
expect(nonPi).not.toContain("--provider");
expect(nonPi).not.toContain("--model");
});
it("parses final assistant message and preserves usage meta", () => {
const stdout = [
'{"type":"message_start","message":{"role":"assistant"}}',
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"hello world"}],"usage":{"input":10,"output":5,"cacheRead":100,"cacheWrite":20,"totalTokens":135},"model":"pi-1","provider":"inflection","stopReason":"end"}}',
].join("\n");
const parsed = piSpec.parseOutput(stdout);
expect(parsed.texts?.[0]).toBe("hello world");
expect(parsed.meta?.provider).toBe("inflection");
expect((parsed.meta?.usage as { output?: number })?.output).toBe(5);
expect((parsed.meta?.usage as { cacheRead?: number })?.cacheRead).toBe(100);
expect((parsed.meta?.usage as { cacheWrite?: number })?.cacheWrite).toBe(
20,
);
expect((parsed.meta?.usage as { total?: number })?.total).toBe(135);
});
it("piSpec carries tool names when present", () => {
const stdout =
'{"type":"message_end","message":{"role":"tool_result","name":"bash","details":{"command":"ls -la"},"content":[{"type":"text","text":"ls output"}]}}';
const parsed = piSpec.parseOutput(stdout);
const tool = parsed.toolResults?.[0] as {
text?: string;
toolName?: string;
meta?: string;
};
expect(tool?.text).toBe("ls output");
expect(tool?.toolName).toBe("bash");
expect(tool?.meta).toBe("ls -la");
});
it("keeps usage meta even when assistant message has no text", () => {
const stdout = [
'{"type":"message_start","message":{"role":"assistant"}}',
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"hmm"}],"usage":{"input":10,"output":5},"model":"pi-1","provider":"inflection","stopReason":"end"}}',
].join("\n");
const parsed = piSpec.parseOutput(stdout);
expect(parsed.texts?.length ?? 0).toBe(0);
expect((parsed.meta?.usage as { input?: number })?.input).toBe(10);
expect(parsed.meta?.model).toBe("pi-1");
});
});

View File

@@ -1,12 +0,0 @@
import { describe, expect, it } from "vitest";
import { getAgentSpec } from "./index.js";
describe("agents index", () => {
it("returns a spec for pi", () => {
const spec = getAgentSpec("pi");
expect(spec).toBeTruthy();
expect(spec.kind).toBe("pi");
expect(typeof spec.parseOutput).toBe("function");
});
});

View File

@@ -1,12 +0,0 @@
import { piSpec } from "./pi.js";
import type { AgentKind, AgentSpec } from "./types.js";
const specs: Record<AgentKind, AgentSpec> = {
pi: piSpec,
};
export function getAgentSpec(kind: AgentKind): AgentSpec {
return specs[kind];
}
export type { AgentKind, AgentMeta, AgentParseResult } from "./types.js";

507
src/agents/pi-embedded.ts Normal file
View File

@@ -0,0 +1,507 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
Agent,
type AgentEvent,
type AppMessage,
ProviderTransport,
type ThinkingLevel,
} from "@mariozechner/pi-agent-core";
import {
type Api,
type AssistantMessage,
getApiKey,
getModels,
getProviders,
type KnownProvider,
type Model,
} from "@mariozechner/pi-ai";
import {
AgentSession,
codingTools,
messageTransformer,
SessionManager,
SettingsManager,
} from "@mariozechner/pi-coding-agent";
import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
import {
createToolDebouncer,
formatToolAggregate,
} from "../auto-reply/tool-meta.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { splitMediaFromOutput } from "../media/parse.js";
import { enqueueCommand } from "../process/command-queue.js";
import { resolveUserPath } from "../utils.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import { getAnthropicOAuthToken } from "./pi-oauth.js";
import { buildAgentSystemPrompt } from "./system-prompt.js";
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
export type EmbeddedPiAgentMeta = {
sessionId: string;
provider: string;
model: string;
usage?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
};
export type EmbeddedPiRunMeta = {
durationMs: number;
agentMeta?: EmbeddedPiAgentMeta;
aborted?: boolean;
};
export type EmbeddedPiRunResult = {
payloads?: Array<{
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
}>;
meta: EmbeddedPiRunMeta;
};
function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel {
// pi-agent-core supports "xhigh" too; Clawdis doesn't surface it for now.
if (!level) return "off";
return level;
}
function isKnownProvider(provider: string): provider is KnownProvider {
return getProviders().includes(provider as KnownProvider);
}
function resolveModel(
provider: string,
modelId: string,
): Model<Api> | undefined {
if (!isKnownProvider(provider)) return undefined;
const models = getModels(provider);
const model = models.find((m) => m.id === modelId);
return model as Model<Api> | undefined;
}
function extractAssistantText(msg: AssistantMessage): string {
const isTextBlock = (
block: unknown,
): block is { type: "text"; text: string } => {
if (!block || typeof block !== "object") return false;
const rec = block as Record<string, unknown>;
return rec.type === "text" && typeof rec.text === "string";
};
const blocks = Array.isArray(msg.content)
? msg.content
.filter(isTextBlock)
.map((c) => c.text.trim())
.filter(Boolean)
: [];
return blocks.join("\n").trim();
}
function inferToolMetaFromArgs(
toolName: string,
args: unknown,
): string | undefined {
if (!args || typeof args !== "object") return undefined;
const record = args as Record<string, unknown>;
const p = typeof record.path === "string" ? record.path : undefined;
const command =
typeof record.command === "string" ? record.command : undefined;
if (toolName === "read" && p) {
const offset =
typeof record.offset === "number" ? record.offset : undefined;
const limit = typeof record.limit === "number" ? record.limit : undefined;
if (offset !== undefined && limit !== undefined) {
return `${p}:${offset}-${offset + limit}`;
}
return p;
}
if ((toolName === "edit" || toolName === "write") && p) return p;
if (toolName === "bash" && command) return command;
return p ?? command;
}
async function ensureSessionHeader(params: {
sessionFile: string;
sessionId: string;
cwd: string;
provider: string;
modelId: string;
thinkingLevel: ThinkingLevel;
}) {
const file = params.sessionFile;
try {
await fs.stat(file);
return;
} catch {
// create
}
await fs.mkdir(path.dirname(file), { recursive: true });
const entry = {
type: "session",
id: params.sessionId,
timestamp: new Date().toISOString(),
cwd: params.cwd,
provider: params.provider,
modelId: params.modelId,
thinkingLevel: params.thinkingLevel,
};
await fs.writeFile(file, `${JSON.stringify(entry)}\n`, "utf-8");
}
async function getApiKeyForProvider(
provider: string,
): Promise<string | undefined> {
if (provider === "anthropic") {
const oauthToken = await getAnthropicOAuthToken();
if (oauthToken) return oauthToken;
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
if (oauthEnv?.trim()) return oauthEnv.trim();
}
return getApiKey(provider) ?? undefined;
}
export async function runEmbeddedPiAgent(params: {
sessionId: string;
sessionFile: string;
workspaceDir: string;
prompt: string;
provider?: string;
model?: string;
thinkLevel?: ThinkLevel;
verboseLevel?: VerboseLevel;
timeoutMs: number;
runId: string;
onPartialReply?: (payload: {
text?: string;
mediaUrls?: string[];
}) => void | Promise<void>;
onAgentEvent?: (evt: {
stream: string;
data: Record<string, unknown>;
}) => void;
enqueue?: typeof enqueueCommand;
}): Promise<EmbeddedPiRunResult> {
const enqueue = params.enqueue ?? enqueueCommand;
return enqueue(async () => {
const started = Date.now();
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const prevCwd = process.cwd();
const provider =
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
const model = resolveModel(provider, modelId);
if (!model) {
throw new Error(`Unknown model: ${provider}/${modelId}`);
}
const thinkingLevel = mapThinkingLevel(params.thinkLevel);
await fs.mkdir(resolvedWorkspace, { recursive: true });
await ensureSessionHeader({
sessionFile: params.sessionFile,
sessionId: params.sessionId,
cwd: resolvedWorkspace,
provider,
modelId,
thinkingLevel,
});
process.chdir(resolvedWorkspace);
try {
const bootstrapFiles =
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
const systemPrompt = buildAgentSystemPrompt({
workspaceDir: resolvedWorkspace,
bootstrapFiles: bootstrapFiles.map((f) => ({
name: f.name,
path: f.path,
content: f.content,
missing: f.missing,
})),
defaultThinkLevel: params.thinkLevel,
});
const sessionManager = new SessionManager(false, params.sessionFile);
const settingsManager = new SettingsManager();
const agent = new Agent({
initialState: {
systemPrompt,
model,
thinkingLevel,
tools: codingTools,
},
messageTransformer,
queueMode: settingsManager.getQueueMode(),
transport: new ProviderTransport({
getApiKey: async (providerName) => {
const key = await getApiKeyForProvider(providerName);
if (!key) {
throw new Error(
`No API key found for provider "${providerName}"`,
);
}
return key;
},
}),
});
// Resume messages from the transcript if present.
const prior = sessionManager.loadSession().messages;
if (prior.length > 0) {
agent.replaceMessages(prior);
}
const session = new AgentSession({
agent,
sessionManager,
settingsManager,
});
const assistantTexts: string[] = [];
const toolDebouncer = createToolDebouncer((toolName, metas) => {
if (!params.onPartialReply) return;
const text = formatToolAggregate(toolName, metas);
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text);
void params.onPartialReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
});
});
const toolMetas: Array<{ toolName?: string; meta?: string }> = [];
const toolMetaById = new Map<string, string | undefined>();
let deltaBuffer = "";
let lastStreamedAssistant: string | undefined;
let aborted = false;
const unsubscribe = session.subscribe(
(evt: AgentEvent | { type: string; [k: string]: unknown }) => {
if (evt.type === "tool_execution_start") {
const toolName = String(
(evt as AgentEvent & { toolName: string }).toolName,
);
const toolCallId = String(
(evt as AgentEvent & { toolCallId: string }).toolCallId,
);
const args = (evt as AgentEvent & { args: unknown }).args;
const meta = inferToolMetaFromArgs(toolName, args);
toolMetaById.set(toolCallId, meta);
emitAgentEvent({
runId: params.runId,
stream: "tool",
data: {
phase: "start",
name: toolName,
toolCallId,
args: args as Record<string, unknown>,
},
});
params.onAgentEvent?.({
stream: "tool",
data: { phase: "start", name: toolName, toolCallId },
});
}
if (evt.type === "tool_execution_end") {
const toolName = String(
(evt as AgentEvent & { toolName: string }).toolName,
);
const toolCallId = String(
(evt as AgentEvent & { toolCallId: string }).toolCallId,
);
const isError = Boolean(
(evt as AgentEvent & { isError: boolean }).isError,
);
const meta = toolMetaById.get(toolCallId);
toolMetas.push({ toolName, meta });
toolDebouncer.push(toolName, meta);
emitAgentEvent({
runId: params.runId,
stream: "tool",
data: {
phase: "result",
name: toolName,
toolCallId,
meta,
isError,
},
});
params.onAgentEvent?.({
stream: "tool",
data: {
phase: "result",
name: toolName,
toolCallId,
meta,
isError,
},
});
}
if (evt.type === "message_update") {
const msg = (evt as AgentEvent & { message: AppMessage }).message;
if (msg?.role === "assistant") {
const assistantEvent = (
evt as AgentEvent & { assistantMessageEvent?: unknown }
).assistantMessageEvent;
const assistantRecord =
assistantEvent && typeof assistantEvent === "object"
? (assistantEvent as Record<string, unknown>)
: undefined;
const evtType =
typeof assistantRecord?.type === "string"
? assistantRecord.type
: "";
if (
evtType === "text_delta" ||
evtType === "text_start" ||
evtType === "text_end"
) {
const chunk =
typeof assistantRecord?.delta === "string"
? assistantRecord.delta
: typeof assistantRecord?.content === "string"
? assistantRecord.content
: "";
if (chunk) {
deltaBuffer += chunk;
const next = deltaBuffer.trim();
if (
next &&
next !== lastStreamedAssistant &&
params.onPartialReply
) {
lastStreamedAssistant = next;
const { text: cleanedText, mediaUrls } =
splitMediaFromOutput(next);
void params.onPartialReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
});
}
}
}
}
}
if (evt.type === "message_end") {
const msg = (evt as AgentEvent & { message: AppMessage }).message;
if (msg?.role === "assistant") {
const text = extractAssistantText(msg as AssistantMessage);
if (text) assistantTexts.push(text);
deltaBuffer = "";
}
}
if (evt.type === "agent_end") {
toolDebouncer.flush();
}
},
);
const abortTimer = setTimeout(
() => {
aborted = true;
void session.abort();
},
Math.max(1, params.timeoutMs),
);
let messagesSnapshot: AppMessage[] = [];
let sessionIdUsed = session.sessionId;
try {
await session.prompt(params.prompt);
messagesSnapshot = session.messages.slice();
sessionIdUsed = session.sessionId;
} finally {
clearTimeout(abortTimer);
unsubscribe();
toolDebouncer.flush();
session.dispose();
}
const lastAssistant = messagesSnapshot
.slice()
.reverse()
.find((m) => (m as AppMessage)?.role === "assistant") as
| AssistantMessage
| undefined;
const usage = lastAssistant?.usage;
const agentMeta: EmbeddedPiAgentMeta = {
sessionId: sessionIdUsed,
provider: lastAssistant?.provider ?? provider,
model: lastAssistant?.model ?? model.id,
usage: usage
? {
input: usage.input,
output: usage.output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
total: usage.totalTokens,
}
: undefined,
};
const replyItems: Array<{ text: string; media?: string[] }> = [];
const inlineToolResults =
params.verboseLevel === "on" &&
!params.onPartialReply &&
toolMetas.length > 0;
if (inlineToolResults) {
for (const { toolName, meta } of toolMetas) {
const agg = formatToolAggregate(toolName, meta ? [meta] : []);
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(agg);
if (cleanedText)
replyItems.push({ text: cleanedText, media: mediaUrls });
}
}
for (const text of assistantTexts.length
? assistantTexts
: lastAssistant
? [extractAssistantText(lastAssistant)]
: []) {
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) continue;
replyItems.push({ text: cleanedText, media: mediaUrls });
}
const payloads = replyItems
.map((item) => ({
text: item.text?.trim() ? item.text.trim() : undefined,
mediaUrls: item.media?.length ? item.media : undefined,
mediaUrl: item.media?.[0],
}))
.filter(
(p) =>
p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0),
);
return {
payloads: payloads.length ? payloads : undefined,
meta: {
durationMs: Date.now() - started,
agentMeta,
aborted,
},
};
} finally {
process.chdir(prevCwd);
}
});
}

112
src/agents/pi-oauth.ts Normal file
View File

@@ -0,0 +1,112 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
type OAuthCredentials = {
type: "oauth";
refresh: string;
access: string;
/** Unix ms timestamp (already includes buffer) */
expires: number;
};
type OAuthStorageFormat = Record<string, OAuthCredentials | undefined>;
const ANTHROPIC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
function getPiAgentDir(): string {
const override = process.env[PI_AGENT_DIR_ENV];
if (override?.trim()) return override.trim();
return path.join(os.homedir(), ".pi", "agent");
}
function getPiOAuthPath(): string {
return path.join(getPiAgentDir(), "oauth.json");
}
async function loadOAuthStorage(): Promise<OAuthStorageFormat> {
const filePath = getPiOAuthPath();
try {
const raw = await fs.readFile(filePath, "utf-8");
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
return parsed as OAuthStorageFormat;
}
} catch {
// missing/invalid: treat as empty
}
return {};
}
async function saveOAuthStorage(storage: OAuthStorageFormat): Promise<void> {
const filePath = getPiOAuthPath();
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
await fs.writeFile(filePath, JSON.stringify(storage, null, 2), {
encoding: "utf-8",
mode: 0o600,
});
try {
await fs.chmod(filePath, 0o600);
} catch {
// best effort (windows / restricted fs)
}
}
async function refreshAnthropicToken(
refreshToken: string,
): Promise<OAuthCredentials> {
const tokenResponse = await fetch(ANTHROPIC_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
client_id: ANTHROPIC_CLIENT_ID,
refresh_token: refreshToken,
}),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
throw new Error(`Anthropic OAuth token refresh failed: ${error}`);
}
const tokenData = (await tokenResponse.json()) as {
refresh_token: string;
access_token: string;
expires_in: number;
};
// 5 min buffer
const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
return {
type: "oauth",
refresh: tokenData.refresh_token,
access: tokenData.access_token,
expires: expiresAt,
};
}
export async function getAnthropicOAuthToken(): Promise<string | null> {
const storage = await loadOAuthStorage();
const creds = storage.anthropic;
if (!creds) return null;
// If expired, attempt refresh; on failure, remove creds.
if (Date.now() >= creds.expires) {
try {
const refreshed = await refreshAnthropicToken(creds.refresh);
storage.anthropic = refreshed;
await saveOAuthStorage(storage);
return refreshed.access;
} catch {
delete storage.anthropic;
await saveOAuthStorage(storage);
return null;
}
}
return creds.access;
}

View File

@@ -1,34 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveBundledPiBinary } from "./pi-path.js";
describe("pi-path", () => {
it("resolves to a bundled binary path when available", () => {
const resolved = resolveBundledPiBinary();
expect(resolved === null || typeof resolved === "string").toBe(true);
if (typeof resolved === "string") {
expect(resolved).toMatch(/pi-coding-agent/);
expect(resolved).toMatch(/dist\/pi|dist\/cli\.js|bin\/tau-dev\.mjs/);
}
});
it("prefers dist/pi when present (branch coverage)", () => {
const original = fs.existsSync.bind(fs);
const spy = vi.spyOn(fs, "existsSync").mockImplementation((p) => {
const s = String(p);
if (s.endsWith(path.join("dist", "pi"))) return true;
return original(p);
});
try {
const resolved = resolveBundledPiBinary();
expect(resolved).not.toBeNull();
expect(typeof resolved).toBe("string");
expect(resolved).toMatch(/dist\/pi$/);
} finally {
spy.mockRestore();
}
});
});

View File

@@ -1,73 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Resolve the bundled pi/tau binary path from the installed dependency.
export function resolveBundledPiBinary(): string | null {
const candidatePkgDirs: string[] = [];
// Preferred: ESM resolution to the package entry, then walk up to package.json.
try {
const resolved = (import.meta as { resolve?: (s: string) => string })
.resolve;
const entryUrl = resolved?.("@mariozechner/pi-coding-agent");
if (typeof entryUrl === "string" && entryUrl.startsWith("file:")) {
const entryPath = fileURLToPath(entryUrl);
let dir = path.dirname(entryPath);
for (let i = 0; i < 12; i += 1) {
const pkgJson = path.join(dir, "package.json");
if (fs.existsSync(pkgJson)) {
candidatePkgDirs.push(dir);
break;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
}
} catch {
// ignore; we'll try filesystem fallbacks below
}
// Fallback: walk up from this module's directory to find node_modules.
try {
let dir = path.dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 12; i += 1) {
candidatePkgDirs.push(
path.join(dir, "node_modules", "@mariozechner", "pi-coding-agent"),
);
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
} catch {
// ignore
}
// Fallback: assume CWD is project root.
candidatePkgDirs.push(
path.resolve(
process.cwd(),
"node_modules",
"@mariozechner",
"pi-coding-agent",
),
);
for (const pkgDir of candidatePkgDirs) {
try {
if (!fs.existsSync(pkgDir)) continue;
const binCandidates = [
path.join(pkgDir, "dist", "pi"),
path.join(pkgDir, "dist", "cli.js"),
path.join(pkgDir, "bin", "tau-dev.mjs"),
];
for (const candidate of binCandidates) {
if (fs.existsSync(candidate)) return candidate;
}
} catch {
// ignore this candidate
}
}
return null;
}

View File

@@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import { piSpec } from "./pi.js";
describe("piSpec.isInvocation", () => {
it("detects pi binary", () => {
expect(piSpec.isInvocation(["/usr/local/bin/pi"])).toBe(true);
});
it("detects tau binary", () => {
expect(piSpec.isInvocation(["/opt/tau"])).toBe(true);
});
it("detects node entry pointing at coding-agent cli", () => {
expect(
piSpec.isInvocation([
"node",
"/Users/me/Projects/pi-mono/packages/coding-agent/dist/cli.js",
]),
).toBe(true);
});
it("rejects unrelated node scripts", () => {
expect(piSpec.isInvocation(["node", "/tmp/script.js"])).toBe(false);
});
});

View File

@@ -1,238 +0,0 @@
import path from "node:path";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import type {
AgentMeta,
AgentParseResult,
AgentSpec,
AgentToolResult,
} from "./types.js";
import { normalizeUsage, type UsageLike } from "./usage.js";
type PiAssistantMessage = {
role?: string;
content?: Array<{ type?: string; text?: string }>;
usage?: UsageLike;
model?: string;
provider?: string;
stopReason?: string;
errorMessage?: string;
name?: string;
toolName?: string;
tool_call_id?: string;
toolCallId?: string;
details?: Record<string, unknown>;
arguments?: Record<string, unknown>;
};
function inferToolName(msg: PiAssistantMessage): string | undefined {
const candidates = [msg.toolName, msg.name, msg.toolCallId, msg.tool_call_id]
.map((c) => (typeof c === "string" ? c.trim() : ""))
.filter(Boolean);
if (candidates.length) return candidates[0];
if (msg.role?.includes(":")) {
const suffix = msg.role.split(":").slice(1).join(":").trim();
if (suffix) return suffix;
}
return undefined;
}
function deriveToolMeta(msg: PiAssistantMessage): string | undefined {
const details = msg.details ?? msg.arguments;
const pathVal =
details && typeof details.path === "string" ? details.path : undefined;
const offset =
details && typeof details.offset === "number" ? details.offset : undefined;
const limit =
details && typeof details.limit === "number" ? details.limit : undefined;
const command =
details && typeof details.command === "string"
? details.command
: undefined;
if (pathVal) {
if (offset !== undefined && limit !== undefined) {
return `${pathVal}:${offset}-${offset + limit}`;
}
return pathVal;
}
if (command) return command;
return undefined;
}
function parsePiJson(raw: string): AgentParseResult {
const lines = raw.split(/\n+/).filter((l) => l.trim().startsWith("{"));
// Collect only completed assistant messages (skip streaming updates/toolcalls).
const texts: string[] = [];
const toolResults: AgentToolResult[] = [];
let lastAssistant: PiAssistantMessage | undefined;
let lastPushed: string | undefined;
const pickText = (msg?: PiAssistantMessage) =>
msg?.content
?.filter((c) => c?.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join("\n")
.trim();
const handleAssistant = (msg?: PiAssistantMessage) => {
if (!msg) return;
lastAssistant = msg;
const text = pickText(msg);
const fallbackError =
!text && typeof msg.errorMessage === "string"
? `Warning: ${msg.errorMessage}`
: undefined;
const chosen = (text || fallbackError)?.trim();
if (chosen && chosen !== lastPushed) {
texts.push(chosen);
lastPushed = chosen;
}
};
const handleToolResult = (msg?: PiAssistantMessage) => {
if (!msg || !msg.content) return;
const toolText = pickText(msg);
if (!toolText) return;
toolResults.push({
text: toolText,
toolName: inferToolName(msg),
meta: deriveToolMeta(msg),
});
};
for (const line of lines) {
try {
const ev = JSON.parse(line) as {
type?: string;
message?: PiAssistantMessage;
toolResults?: PiAssistantMessage[];
messages?: PiAssistantMessage[];
};
// Turn-level assistant + tool results
if (ev.type === "turn_end") {
handleAssistant(ev.message);
if (Array.isArray(ev.toolResults)) {
for (const tr of ev.toolResults) handleToolResult(tr);
}
}
// Agent-level summary of all messages
if (ev.type === "agent_end" && Array.isArray(ev.messages)) {
for (const msg of ev.messages) {
const role = msg?.role ?? "";
if (role === "assistant") handleAssistant(msg);
else if (role.toLowerCase().includes("tool")) handleToolResult(msg);
}
}
const role = ev.message?.role ?? "";
const isAssistantMessage =
(ev.type === "message" ||
ev.type === "message_end" ||
ev.type === "message_start") &&
role === "assistant";
const isToolResult =
(ev.type === "message" ||
ev.type === "message_end" ||
ev.type === "message_start") &&
typeof role === "string" &&
role.toLowerCase().includes("tool");
if (isAssistantMessage) handleAssistant(ev.message);
if (isToolResult) handleToolResult(ev.message);
} catch {
// ignore malformed lines
}
}
const meta: AgentMeta | undefined = lastAssistant
? {
model: lastAssistant.model,
provider: lastAssistant.provider,
stopReason: lastAssistant.stopReason,
usage: normalizeUsage(lastAssistant.usage),
}
: undefined;
return {
texts,
toolResults: toolResults.length ? toolResults : undefined,
meta,
};
}
function isPiInvocation(argv: string[]): boolean {
if (argv.length === 0) return false;
const base = path.basename(argv[0]).replace(/\.(m?js)$/i, "");
if (base === "pi" || base === "tau") return true;
// Also handle node entrypoints like `node /.../pi-mono/packages/coding-agent/dist/cli.js`
if (base === "node" && argv.length > 1) {
const second = argv[1]?.toString().toLowerCase();
return (
second.includes("pi-mono") &&
second.includes("packages") &&
second.includes("coding-agent") &&
(second.endsWith("cli.js") || second.includes("/dist/cli"))
);
}
return false;
}
export const piSpec: AgentSpec = {
kind: "pi",
isInvocation: isPiInvocation,
buildArgs: (ctx) => {
const argv = [...ctx.argv];
if (!isPiInvocation(argv)) return argv;
let bodyPos = ctx.bodyIndex;
const modeIdx = argv.indexOf("--mode");
const modeVal =
modeIdx >= 0 ? argv[modeIdx + 1]?.toString().toLowerCase() : undefined;
const isRpcMode = modeVal === "rpc";
const desiredProvider = (ctx.provider ?? DEFAULT_PROVIDER).trim();
const desiredModel = (ctx.model ?? DEFAULT_MODEL).trim();
const hasFlag = (flag: string) =>
argv.includes(flag) || argv.some((a) => a.startsWith(`${flag}=`));
if (desiredProvider && !hasFlag("--provider")) {
argv.splice(bodyPos, 0, "--provider", desiredProvider);
bodyPos += 2;
}
if (desiredModel && !hasFlag("--model")) {
argv.splice(bodyPos, 0, "--model", desiredModel);
bodyPos += 2;
}
// Non-interactive print + JSON
if (!isRpcMode && !argv.includes("-p") && !argv.includes("--print")) {
argv.splice(bodyPos, 0, "-p");
bodyPos += 1;
}
if (
ctx.format === "json" &&
!argv.includes("--mode") &&
!argv.some((a) => a === "--mode")
) {
argv.splice(bodyPos, 0, "--mode", "json");
bodyPos += 2;
}
// Session defaults
// Identity prefix optional; Pi usually doesn't need it, but allow injection
if (!(ctx.sendSystemOnce && ctx.systemSent) && argv[bodyPos]) {
const existingBody = argv[bodyPos];
argv[bodyPos] = [ctx.identityPrefix, existingBody]
.filter(Boolean)
.join("\n\n");
}
return argv;
},
parseOutput: parsePiJson,
};

View File

@@ -0,0 +1,84 @@
import type { ThinkLevel } from "../auto-reply/thinking.js";
type BootstrapFile = {
name: "AGENTS.md" | "SOUL.md" | "TOOLS.md";
path: string;
content?: string;
missing: boolean;
};
function formatBootstrapFile(file: BootstrapFile): string {
if (file.missing) {
return `## ${file.name}\n\n[MISSING] Expected at: ${file.path}`;
}
return `## ${file.name}\n\n${file.content ?? ""}`.trimEnd();
}
function describeBuiltInTools(): string {
// Keep this short and stable; TOOLS.md is for user-editable external tool notes.
return [
"- read: read file contents",
"- bash: run shell commands",
"- edit: apply precise in-file replacements",
"- write: create/overwrite files",
].join("\n");
}
function formatDateTime(now: Date): string {
return now.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
});
}
export function buildAgentSystemPrompt(params: {
workspaceDir: string;
bootstrapFiles: BootstrapFile[];
now?: Date;
defaultThinkLevel?: ThinkLevel;
}) {
const now = params.now ?? new Date();
const boot = params.bootstrapFiles.map(formatBootstrapFile).join("\n\n");
const thinkHint =
params.defaultThinkLevel && params.defaultThinkLevel !== "off"
? `Default thinking level: ${params.defaultThinkLevel}.`
: "Default thinking level: off.";
return [
"You are Clawd, a personal assistant running inside Clawdis.",
"",
"## Built-in Tools (internal)",
"These tools are always available. TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
describeBuiltInTools(),
"",
"## Workspace",
`Your working directory is: ${params.workspaceDir}`,
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
"",
"## Workspace Files (injected)",
"These user-editable files are loaded by Clawdis and included here directly (no separate read step):",
boot,
"",
"## Messaging Safety",
"Never send streaming/partial replies to external messaging surfaces; only final replies should be delivered there.",
"",
"## Heartbeats",
'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:',
"HEARTBEAT_OK",
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
"",
"## Runtime",
`Current date and time: ${formatDateTime(now)}`,
`Current working directory: ${params.workspaceDir}`,
thinkHint,
]
.filter(Boolean)
.join("\n");
}

View File

@@ -1,52 +0,0 @@
export type AgentKind = "pi";
export type AgentMeta = {
model?: string;
provider?: string;
stopReason?: string;
sessionId?: string;
usage?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
extra?: Record<string, unknown>;
};
export type AgentToolResult = {
text: string;
toolName?: string;
meta?: string;
};
export type AgentParseResult = {
// Plural to support agents that emit multiple assistant turns per prompt.
texts?: string[];
mediaUrls?: string[];
toolResults?: Array<string | AgentToolResult>;
meta?: AgentMeta;
};
export type BuildArgsContext = {
argv: string[];
bodyIndex: number; // index of prompt/body argument in argv
isNewSession: boolean;
sessionId?: string;
provider?: string;
model?: string;
sendSystemOnce: boolean;
systemSent: boolean;
identityPrefix?: string;
format?: "text" | "json";
sessionArgNew?: string[];
sessionArgResume?: string[];
};
export interface AgentSpec {
kind: AgentKind;
isInvocation: (argv: string[]) => boolean;
buildArgs: (ctx: BuildArgsContext) => string[];
parseOutput: (rawStdout: string) => AgentParseResult;
}

View File

@@ -5,12 +5,12 @@ import { describe, expect, it } from "vitest";
import { ensureAgentWorkspace } from "./workspace.js";
describe("ensureAgentWorkspace", () => {
it("creates directory and AGENTS.md when missing", async () => {
it("creates directory and bootstrap files when missing", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-ws-"));
const nested = path.join(dir, "nested");
const result = await ensureAgentWorkspace({
dir: nested,
ensureAgentsFile: true,
ensureBootstrapFiles: true,
});
expect(result.dir).toBe(path.resolve(nested));
expect(result.agentsPath).toBe(
@@ -26,7 +26,7 @@ describe("ensureAgentWorkspace", () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-ws-"));
const agentsPath = path.join(dir, "AGENTS.md");
await fs.writeFile(agentsPath, "custom", "utf-8");
await ensureAgentWorkspace({ dir, ensureAgentsFile: true });
await ensureAgentWorkspace({ dir, ensureBootstrapFiles: true });
expect(await fs.readFile(agentsPath, "utf-8")).toBe("custom");
});
});

View File

@@ -1,10 +1,13 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { resolveUserPath } from "../utils.js";
export const DEFAULT_AGENT_WORKSPACE_DIR = path.join(CONFIG_DIR, "workspace");
export const DEFAULT_AGENT_WORKSPACE_DIR = path.join(os.homedir(), "clawd");
export const DEFAULT_AGENTS_FILENAME = "AGENTS.md";
export const DEFAULT_SOUL_FILENAME = "SOUL.md";
export const DEFAULT_TOOLS_FILENAME = "TOOLS.md";
const DEFAULT_AGENTS_TEMPLATE = `# AGENTS.md — Clawdis Workspace
@@ -20,21 +23,47 @@ This folder is the assistants working directory.
- Customize this file with additional instructions for your assistant.
`;
export async function ensureAgentWorkspace(params?: {
dir?: string;
ensureAgentsFile?: boolean;
}): Promise<{ dir: string; agentsPath?: string }> {
const rawDir = params?.dir?.trim()
? params.dir.trim()
: DEFAULT_AGENT_WORKSPACE_DIR;
const dir = resolveUserPath(rawDir);
await fs.mkdir(dir, { recursive: true });
const DEFAULT_SOUL_TEMPLATE = `# SOUL.md — Persona & Boundaries
if (!params?.ensureAgentsFile) return { dir };
Describe who the assistant is, tone, and boundaries.
const agentsPath = path.join(dir, DEFAULT_AGENTS_FILENAME);
- Keep replies concise and direct.
- Ask clarifying questions when needed.
- Never send streaming/partial replies to external messaging surfaces.
`;
const DEFAULT_TOOLS_TEMPLATE = `# TOOLS.md — User Tool Notes (editable)
This file is for *your* notes about external tools and conventions.
It does not define which tools exist; Clawdis provides built-in tools internally.
## Examples
### imsg
- Send an iMessage/SMS: describe who/what, confirm before sending.
- Prefer short messages; avoid sending secrets.
### sag
- Text-to-speech: specify voice, target speaker/room, and whether to stream.
Add whatever else you want the assistant to know about your local toolchain.
`;
export type WorkspaceBootstrapFileName =
| typeof DEFAULT_AGENTS_FILENAME
| typeof DEFAULT_SOUL_FILENAME
| typeof DEFAULT_TOOLS_FILENAME;
export type WorkspaceBootstrapFile = {
name: WorkspaceBootstrapFileName;
path: string;
content?: string;
missing: boolean;
};
async function writeFileIfMissing(filePath: string, content: string) {
try {
await fs.writeFile(agentsPath, DEFAULT_AGENTS_TEMPLATE, {
await fs.writeFile(filePath, content, {
encoding: "utf-8",
flag: "wx",
});
@@ -42,5 +71,72 @@ export async function ensureAgentWorkspace(params?: {
const anyErr = err as { code?: string };
if (anyErr.code !== "EEXIST") throw err;
}
return { dir, agentsPath };
}
export async function ensureAgentWorkspace(params?: {
dir?: string;
ensureBootstrapFiles?: boolean;
}): Promise<{
dir: string;
agentsPath?: string;
soulPath?: string;
toolsPath?: string;
}> {
const rawDir = params?.dir?.trim()
? params.dir.trim()
: DEFAULT_AGENT_WORKSPACE_DIR;
const dir = resolveUserPath(rawDir);
await fs.mkdir(dir, { recursive: true });
if (!params?.ensureBootstrapFiles) return { dir };
const agentsPath = path.join(dir, DEFAULT_AGENTS_FILENAME);
const soulPath = path.join(dir, DEFAULT_SOUL_FILENAME);
const toolsPath = path.join(dir, DEFAULT_TOOLS_FILENAME);
await writeFileIfMissing(agentsPath, DEFAULT_AGENTS_TEMPLATE);
await writeFileIfMissing(soulPath, DEFAULT_SOUL_TEMPLATE);
await writeFileIfMissing(toolsPath, DEFAULT_TOOLS_TEMPLATE);
return { dir, agentsPath, soulPath, toolsPath };
}
export async function loadWorkspaceBootstrapFiles(
dir: string,
): Promise<WorkspaceBootstrapFile[]> {
const resolvedDir = resolveUserPath(dir);
const entries: Array<{
name: WorkspaceBootstrapFileName;
filePath: string;
}> = [
{
name: DEFAULT_AGENTS_FILENAME,
filePath: path.join(resolvedDir, DEFAULT_AGENTS_FILENAME),
},
{
name: DEFAULT_SOUL_FILENAME,
filePath: path.join(resolvedDir, DEFAULT_SOUL_FILENAME),
},
{
name: DEFAULT_TOOLS_FILENAME,
filePath: path.join(resolvedDir, DEFAULT_TOOLS_FILENAME),
},
];
const result: WorkspaceBootstrapFile[] = [];
for (const entry of entries) {
try {
const content = await fs.readFile(entry.filePath, "utf-8");
result.push({
name: entry.name,
path: entry.filePath,
content,
missing: false,
});
} catch {
result.push({ name: entry.name, path: entry.filePath, missing: true });
}
}
return result;
}