chore: make pi-only rpc with fixed sessions

This commit is contained in:
Peter Steinberger
2025-12-05 17:50:02 +00:00
parent b3e50cbb33
commit fcf0c28132
33 changed files with 217 additions and 1565 deletions

View File

@@ -1,39 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseClaudeJson, parseClaudeJsonText } from "./claude.js";
describe("claude JSON parsing", () => {
it("extracts text from single JSON object", () => {
const out = parseClaudeJsonText('{"text":"hello"}');
expect(out).toBe("hello");
});
it("extracts from newline-delimited JSON", () => {
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
expect(out).toBe("there");
});
it("returns undefined on invalid JSON", () => {
expect(parseClaudeJsonText("not json")).toBeUndefined();
});
it("extracts text from Claude CLI result field and preserves metadata", () => {
const sample = {
type: "result",
subtype: "success",
result: "hello from result field",
duration_ms: 1234,
usage: { server_tool_use: { tool_a: 2 } },
};
const parsed = parseClaudeJson(JSON.stringify(sample));
expect(parsed?.text).toBe("hello from result field");
expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 });
expect(parsed?.valid).toBe(true);
});
it("marks invalid Claude JSON as invalid but still attempts text extraction", () => {
const parsed = parseClaudeJson('{"unexpected":1}');
expect(parsed?.valid).toBe(false);
expect(parsed?.text).toBeUndefined();
});
});

View File

@@ -1,165 +0,0 @@
// Helpers specific to Claude CLI output/argv handling.
import { z } from "zod";
// Preferred binary name for Claude CLI invocations.
export const CLAUDE_BIN = "claude";
export const CLAUDE_IDENTITY_PREFIX =
"You are Clawd (Claude) running on the user's Mac via clawdis. Keep WhatsApp replies under ~1500 characters. Your scratchpad is ~/clawd; this is your folder and you can add what you like in markdown files and/or images. You can send media by including MEDIA:/path/to/file.jpg on its own line (no spaces in path). Media limits: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present. If a prompt is a heartbeat poll and nothing needs attention, reply with exactly HEARTBEAT_OK and nothing else; for any alert, do not include HEARTBEAT_OK.";
function extractClaudeText(payload: unknown): string | undefined {
// Best-effort walker to find the primary text field in Claude JSON outputs.
if (payload == null) return undefined;
if (typeof payload === "string") return payload;
if (Array.isArray(payload)) {
for (const item of payload) {
const found = extractClaudeText(item);
if (found) return found;
}
return undefined;
}
if (typeof payload === "object") {
const obj = payload as Record<string, unknown>;
if (typeof obj.result === "string") return obj.result;
if (typeof obj.text === "string") return obj.text;
if (typeof obj.completion === "string") return obj.completion;
if (typeof obj.output === "string") return obj.output;
if (obj.message) {
const inner = extractClaudeText(obj.message);
if (inner) return inner;
}
if (Array.isArray(obj.messages)) {
const inner = extractClaudeText(obj.messages);
if (inner) return inner;
}
if (Array.isArray(obj.content)) {
for (const block of obj.content) {
if (
block &&
typeof block === "object" &&
(block as { type?: string }).type === "text" &&
typeof (block as { text?: unknown }).text === "string"
) {
return (block as { text: string }).text;
}
const inner = extractClaudeText(block);
if (inner) return inner;
}
}
}
return undefined;
}
export type ClaudeJsonParseResult = {
text?: string;
parsed: unknown;
valid: boolean;
};
const ClaudeJsonSchema = z
.object({
type: z.string().optional(),
subtype: z.string().optional(),
is_error: z.boolean().optional(),
result: z.string().optional(),
text: z.string().optional(),
completion: z.string().optional(),
output: z.string().optional(),
message: z.any().optional(),
messages: z.any().optional(),
content: z.any().optional(),
duration_ms: z.number().optional(),
duration_api_ms: z.number().optional(),
num_turns: z.number().optional(),
session_id: z.string().optional(),
total_cost_usd: z.number().optional(),
usage: z.record(z.string(), z.any()).optional(),
modelUsage: z.record(z.string(), z.any()).optional(),
})
.passthrough()
.refine(
(obj) =>
typeof obj.result === "string" ||
typeof obj.text === "string" ||
typeof obj.completion === "string" ||
typeof obj.output === "string" ||
obj.message !== undefined ||
obj.messages !== undefined ||
obj.content !== undefined,
{ message: "Not a Claude JSON payload" },
);
type ClaudeSafeParse = ReturnType<typeof ClaudeJsonSchema.safeParse>;
export function parseClaudeJson(
raw: string,
): ClaudeJsonParseResult | undefined {
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
let firstParsed: unknown;
const candidates = [
raw,
...raw
.split(/\n+/)
.map((s) => s.trim())
.filter(Boolean),
];
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate);
if (firstParsed === undefined) firstParsed = parsed;
let validation: ClaudeSafeParse | { success: false };
try {
validation = ClaudeJsonSchema.safeParse(parsed);
} catch {
validation = { success: false } as const;
}
const validated = validation.success ? validation.data : parsed;
const isLikelyClaude =
typeof validated === "object" &&
validated !== null &&
("result" in validated ||
"text" in validated ||
"completion" in validated ||
"output" in validated);
const text = extractClaudeText(validated);
if (text)
return {
parsed: validated,
text,
// Treat parse as valid when schema passes or we still see Claude-like shape.
valid: Boolean(validation?.success || isLikelyClaude),
};
} catch {
// ignore parse errors; try next candidate
}
}
if (firstParsed !== undefined) {
let validation: ClaudeSafeParse | { success: false };
try {
validation = ClaudeJsonSchema.safeParse(firstParsed);
} catch {
validation = { success: false } as const;
}
const validated = validation.success ? validation.data : firstParsed;
const isLikelyClaude =
typeof validated === "object" &&
validated !== null &&
("result" in validated ||
"text" in validated ||
"completion" in validated ||
"output" in validated);
return {
parsed: validated,
text: extractClaudeText(validated),
valid: Boolean(validation?.success || isLikelyClaude),
};
}
return undefined;
}
export function parseClaudeJsonText(raw: string): string | undefined {
const parsed = parseClaudeJson(raw);
return parsed?.text;
}
// Re-export from command-reply for backwards compatibility
export { summarizeClaudeMetadata } from "./command-reply.js";

View File

@@ -2,10 +2,10 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runCommandReply, summarizeClaudeMetadata } from "./command-reply.js";
import type { ReplyPayload } from "./types.js";
import * as tauRpc from "../process/tau-rpc.js";
import { runCommandReply } from "./command-reply.js";
const noopTemplateCtx = {
Body: "hello",
@@ -14,27 +14,6 @@ const noopTemplateCtx = {
IsNewSession: "true",
};
type RunnerResult = {
stdout?: string;
stderr?: string;
code?: number;
signal?: string | null;
killed?: boolean;
};
function makeRunner(result: RunnerResult, capture: ReplyPayload[] = []) {
return vi.fn(async (argv: string[]) => {
capture.push({ text: argv.join(" "), argv });
return {
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
code: result.code ?? 0,
signal: result.signal ?? null,
killed: result.killed ?? false,
};
});
}
const enqueueImmediate = vi.fn(
async <T>(
task: () => Promise<T>,
@@ -45,32 +24,36 @@ const enqueueImmediate = vi.fn(
},
);
describe("summarizeClaudeMetadata", () => {
it("builds concise meta string", () => {
const meta = summarizeClaudeMetadata({
duration_ms: 1200,
num_turns: 3,
total_cost_usd: 0.012345,
usage: { server_tool_use: { a: 1, b: 2 } },
modelUsage: { "claude-3": 2, haiku: 1 },
});
expect(meta).toContain("duration=1200ms");
expect(meta).toContain("turns=3");
expect(meta).toContain("cost=$0.0123");
expect(meta).toContain("tool_calls=3");
expect(meta).toContain("models=claude-3,haiku");
});
function mockPiRpc(result: {
stdout: string;
stderr?: string;
code: number;
signal?: NodeJS.Signals | null;
killed?: boolean;
}) {
return vi
.spyOn(tauRpc, "runPiRpc")
.mockResolvedValue({ killed: false, signal: null, ...result });
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("runCommandReply", () => {
it("injects claude flags and identity prefix", async () => {
const captures: ReplyPayload[] = [];
const runner = makeRunner({ stdout: "ok" }, captures);
describe("runCommandReply (pi)", () => {
it("injects pi flags and forwards prompt via RPC", async () => {
const rpcMock = mockPiRpc({
stdout:
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}',
stderr: "",
code: 0,
});
const { payloads } = await runCommandReply({
reply: {
mode: "command",
command: ["claude", "{{Body}}"],
agent: { kind: "claude", format: "json" },
command: ["pi", "{{Body}}"],
agent: { kind: "pi", format: "json" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
@@ -79,100 +62,37 @@ describe("runCommandReply", () => {
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: runner,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
thinkLevel: "medium",
});
const payload = payloads?.[0];
expect(payload?.text).toBe("ok");
const finalArgv = captures[0].argv as string[];
expect(finalArgv).toContain("--output-format");
expect(finalArgv).toContain("json");
expect(finalArgv).toContain("-p");
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)");
const call = rpcMock.mock.calls[0]?.[0];
expect(call?.prompt).toBe("hello");
expect(call?.argv).toContain("-p");
expect(call?.argv).toContain("--mode");
expect(call?.argv).toContain("rpc");
expect(call?.argv).toContain("--thinking");
expect(call?.argv).toContain("medium");
});
it("omits identity prefix on resumed session when sendSystemOnce=true", async () => {
const captures: ReplyPayload[] = [];
const runner = makeRunner({ stdout: "ok" }, captures);
await runCommandReply({
reply: {
mode: "command",
command: ["claude", "{{Body}}"],
agent: { kind: "claude", format: "json" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: true,
isNewSession: false,
isFirstTurnInSession: false,
systemSent: true,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: runner,
enqueue: enqueueImmediate,
it("adds session args and --continue when resuming", async () => {
const rpcMock = mockPiRpc({
stdout:
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}',
stderr: "",
code: 0,
});
const finalArgv = captures[0].argv as string[];
expect(finalArgv.at(-1)).not.toContain("You are Clawd (Claude)");
});
it("prepends identity on first turn when sendSystemOnce=true", async () => {
const captures: ReplyPayload[] = [];
const runner = makeRunner({ stdout: "ok" }, captures);
await runCommandReply({
reply: {
mode: "command",
command: ["claude", "{{Body}}"],
agent: { kind: "claude", format: "json" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: true,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: runner,
enqueue: enqueueImmediate,
});
const finalArgv = captures[0].argv as string[];
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)");
});
it("still prepends identity if resume session but systemSent=false", async () => {
const captures: ReplyPayload[] = [];
const runner = makeRunner({ stdout: "ok" }, captures);
await runCommandReply({
reply: {
mode: "command",
command: ["claude", "{{Body}}"],
agent: { kind: "claude", format: "json" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: true,
isNewSession: false,
isFirstTurnInSession: false,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: runner,
enqueue: enqueueImmediate,
});
const finalArgv = captures[0].argv as string[];
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)");
});
it("picks session resume args when not new", async () => {
const captures: ReplyPayload[] = [];
const runner = makeRunner({ stdout: "hi" }, captures);
await runCommandReply({
reply: {
mode: "command",
command: ["cli", "{{Body}}"],
agent: { kind: "claude" },
session: {
sessionArgNew: ["--new", "{{SessionId}}"],
sessionArgResume: ["--resume", "{{SessionId}}"],
},
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
session: {},
},
templatingCtx: { ...noopTemplateCtx, SessionId: "abc" },
sendSystemOnce: true,
@@ -181,23 +101,28 @@ describe("runCommandReply", () => {
systemSent: true,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: runner,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
});
const argv = captures[0].argv as string[];
expect(argv).toContain("--resume");
expect(argv).toContain("abc");
const argv = rpcMock.mock.calls[0]?.[0]?.argv ?? [];
expect(argv).toContain("--session");
expect(argv.some((a) => a.includes("abc"))).toBe(true);
expect(argv).toContain("--continue");
});
it("returns timeout text with partial snippet", async () => {
const runner = vi.fn(async () => {
throw { stdout: "partial output here", killed: true, signal: "SIGKILL" };
vi.spyOn(tauRpc, "runPiRpc").mockRejectedValue({
stdout: "partial output here",
killed: true,
signal: "SIGKILL",
});
const { payloads, meta } = await runCommandReply({
reply: {
mode: "command",
command: ["echo", "hi"],
agent: { kind: "claude" },
command: ["pi", "hi"],
agent: { kind: "pi" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
@@ -206,53 +131,33 @@ describe("runCommandReply", () => {
systemSent: false,
timeoutMs: 10,
timeoutSeconds: 1,
commandRunner: runner,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
});
const payload = payloads?.[0];
expect(payload?.text).toContain("Command timed out after 1s");
expect(payload?.text).toContain("partial output");
expect(meta.killed).toBe(true);
});
it("includes cwd hint in timeout message", async () => {
const runner = vi.fn(async () => {
throw { stdout: "", killed: true, signal: "SIGKILL" };
});
const { payloads } = await runCommandReply({
reply: {
mode: "command",
command: ["echo", "hi"],
cwd: "/tmp/work",
agent: { kind: "claude" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 5,
timeoutSeconds: 1,
commandRunner: runner,
enqueue: enqueueImmediate,
});
const payload = payloads?.[0];
expect(payload?.text).toContain("(cwd: /tmp/work)");
});
it("parses MEDIA tokens and respects mediaMaxMb for local files", async () => {
const tmp = path.join(os.tmpdir(), `warelay-test-${Date.now()}.bin`);
const bigBuffer = Buffer.alloc(2 * 1024 * 1024, 1);
await fs.writeFile(tmp, bigBuffer);
const runner = makeRunner({
mockPiRpc({
stdout: `hi\nMEDIA:${tmp}\nMEDIA:https://example.com/img.jpg`,
stderr: "",
code: 0,
});
const { payloads } = await runCommandReply({
reply: {
mode: "command",
command: ["echo", "hi"],
command: ["pi", "hi"],
mediaMaxMb: 1,
agent: { kind: "claude" },
agent: { kind: "pi" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
@@ -261,46 +166,28 @@ describe("runCommandReply", () => {
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: runner,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
});
const payload = payloads?.[0];
expect(payload?.mediaUrls).toEqual(["https://example.com/img.jpg"]);
await fs.unlink(tmp);
});
it("emits Claude metadata", async () => {
const runner = makeRunner({
it("captures queue wait metrics and agent meta", async () => {
mockPiRpc({
stdout:
'{"text":"hi","duration_ms":50,"total_cost_usd":0.0001,"usage":{"server_tool_use":{"a":1}}}',
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input":10,"output":5}}}',
stderr: "",
code: 0,
});
const { meta } = await runCommandReply({
reply: {
mode: "command",
command: ["claude", "{{Body}}"],
agent: { kind: "claude", format: "json" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: runner,
enqueue: enqueueImmediate,
});
expect(meta.agentMeta?.extra?.summary).toContain("duration=50ms");
expect(meta.agentMeta?.extra?.summary).toContain("tool_calls=1");
});
it("captures queue wait metrics in meta", async () => {
const runner = makeRunner({ stdout: "ok" });
const { meta } = await runCommandReply({
reply: {
mode: "command",
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
@@ -309,88 +196,12 @@ describe("runCommandReply", () => {
systemSent: false,
timeoutMs: 100,
timeoutSeconds: 1,
commandRunner: runner,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
});
expect(meta.queuedMs).toBe(25);
expect(meta.queuedAhead).toBe(2);
});
it("handles empty result string without dumping raw JSON", async () => {
// Bug fix: Claude CLI returning {"result": ""} should not send raw JSON to WhatsApp
// The fix changed from truthy check to explicit typeof check
const runner = makeRunner({
stdout: '{"result":"","duration_ms":50,"total_cost_usd":0.001}',
});
const { payloads } = await runCommandReply({
reply: {
mode: "command",
command: ["claude", "{{Body}}"],
agent: { kind: "claude", format: "json" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: runner,
enqueue: enqueueImmediate,
});
// Should NOT contain raw JSON - empty result should produce fallback message
const payload = payloads?.[0];
expect(payload?.text).not.toContain('{"result"');
expect(payload?.text).toContain("command produced no output");
});
it("handles empty text string in Claude JSON", async () => {
const runner = makeRunner({
stdout: '{"text":"","duration_ms":50}',
});
const { payloads } = await runCommandReply({
reply: {
mode: "command",
command: ["claude", "{{Body}}"],
agent: { kind: "claude", format: "json" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: runner,
enqueue: enqueueImmediate,
});
// Empty text should produce fallback message, not raw JSON
const payload = payloads?.[0];
expect(payload?.text).not.toContain('{"text"');
expect(payload?.text).toContain("command produced no output");
});
it("returns actual text when result is non-empty", async () => {
const runner = makeRunner({
stdout: '{"result":"hello world","duration_ms":50}',
});
const { payloads } = await runCommandReply({
reply: {
mode: "command",
command: ["claude", "{{Body}}"],
agent: { kind: "claude", format: "json" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: runner,
enqueue: enqueueImmediate,
});
const payload = payloads?.[0];
expect(payload?.text).toBe("hello world");
expect((meta.agentMeta?.usage as { output?: number })?.output).toBe(5);
});
});

View File

@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { type AgentKind, getAgentSpec } from "../agents/index.js";
@@ -203,75 +204,6 @@ function normalizeToolResults(
.filter((tr) => tr.text.length > 0);
}
export function summarizeClaudeMetadata(payload: unknown): string | undefined {
if (!payload || typeof payload !== "object") return undefined;
const obj = payload as Record<string, unknown>;
const parts: string[] = [];
if (typeof obj.duration_ms === "number") {
parts.push(`duration=${obj.duration_ms}ms`);
}
if (typeof obj.duration_api_ms === "number") {
parts.push(`api=${obj.duration_api_ms}ms`);
}
if (typeof obj.num_turns === "number") {
parts.push(`turns=${obj.num_turns}`);
}
if (typeof obj.total_cost_usd === "number") {
parts.push(`cost=$${obj.total_cost_usd.toFixed(4)}`);
}
const usage = obj.usage;
if (usage && typeof usage === "object") {
const serverToolUse = (
usage as { server_tool_use?: Record<string, unknown> }
).server_tool_use;
if (serverToolUse && typeof serverToolUse === "object") {
const toolCalls = Object.values(serverToolUse).reduce<number>(
(sum, val) => {
if (typeof val === "number") return sum + val;
return sum;
},
0,
);
if (toolCalls > 0) parts.push(`tool_calls=${toolCalls}`);
}
}
const modelUsage = obj.modelUsage;
if (modelUsage && typeof modelUsage === "object") {
const models = Object.keys(modelUsage as Record<string, unknown>);
if (models.length) {
const display =
models.length > 2
? `${models.slice(0, 2).join(",")}+${models.length - 2}`
: models.join(",");
parts.push(`models=${display}`);
}
}
return parts.length ? parts.join(", ") : undefined;
}
function appendThinkingCue(body: string, level?: ThinkLevel): string {
if (!level || level === "off") return body;
const cue = (() => {
switch (level) {
case "high":
return "ultrathink";
case "medium":
return "think harder";
case "low":
return "think hard";
case "minimal":
return "think";
default:
return "";
}
})();
return [body.trim(), cue].filter(Boolean).join(" ");
}
export async function runCommandReply(
params: CommandReplyParams,
): Promise<CommandReplyResult> {
@@ -300,11 +232,11 @@ export async function runCommandReply(
if (!reply.command?.length) {
throw new Error("reply.command is required for mode=command");
}
const agentCfg = reply.agent ?? { kind: "claude" };
const agentKind: AgentKind = agentCfg.kind ?? "claude";
const agentCfg = reply.agent ?? { kind: "pi" };
const agentKind: AgentKind = agentCfg.kind ?? "pi";
const agent = getAgentSpec(agentKind);
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
const isAgentInvocation = agent.isInvocation(argv);
const templatePrefix =
reply.template && (!sendSystemOnce || isFirstTurnInSession || !systemSent)
? applyTemplate(reply.template, templatingCtx)
@@ -318,23 +250,12 @@ export async function runCommandReply(
// Session args prepared (templated) and injected generically
if (reply.session) {
const defaultSessionArgs = (() => {
switch (agentCfg.kind) {
case "claude":
return {
newArgs: ["--session-id", "{{SessionId}}"],
resumeArgs: ["--resume", "{{SessionId}}"],
};
case "gemini":
// Gemini CLI supports --resume <id>; starting a new session needs no flag.
return { newArgs: [], resumeArgs: ["--resume", "{{SessionId}}"] };
default:
return {
newArgs: ["--session", "{{SessionId}}"],
resumeArgs: ["--session", "{{SessionId}}"],
};
}
})();
const defaultSessionDir = path.join(os.homedir(), ".clawdis", "sessions");
const sessionPath = path.join(defaultSessionDir, "{{SessionId}}.jsonl");
const defaultSessionArgs = {
newArgs: ["--session", sessionPath],
resumeArgs: ["--session", sessionPath],
};
const defaultNew = defaultSessionArgs.newArgs;
const defaultResume = defaultSessionArgs.resumeArgs;
const sessionArgList = (
@@ -343,10 +264,24 @@ export async function runCommandReply(
: (reply.session.sessionArgResume ?? defaultResume)
).map((p) => applyTemplate(p, templatingCtx));
// If we are writing session files, ensure the directory exists.
const sessionFlagIndex = sessionArgList.indexOf("--session");
const sessionPathArg =
sessionFlagIndex >= 0 ? sessionArgList[sessionFlagIndex + 1] : undefined;
if (sessionPathArg && !sessionPathArg.includes("://")) {
const dir = path.dirname(sessionPathArg);
try {
await fs.mkdir(dir, { recursive: true });
} catch {
// best-effort
}
}
// Tau (pi agent) needs --continue to reload prior messages when resuming.
// Without it, pi starts from a blank state even though we pass the session file path.
if (
agentKind === "pi" &&
isAgentInvocation &&
!isNewSession &&
!sessionArgList.includes("--continue")
) {
@@ -366,25 +301,21 @@ export async function runCommandReply(
}
}
if (thinkLevel && thinkLevel !== "off") {
if (agentKind === "pi") {
const hasThinkingFlag = argv.some(
(p, i) =>
p === "--thinking" ||
(i > 0 && argv[i - 1] === "--thinking") ||
p.startsWith("--thinking="),
);
if (!hasThinkingFlag) {
argv.splice(bodyIndex, 0, "--thinking", thinkLevel);
bodyIndex += 2;
}
} else if (argv[bodyIndex]) {
argv[bodyIndex] = appendThinkingCue(argv[bodyIndex] ?? "", thinkLevel);
const shouldApplyAgent = isAgentInvocation;
if (shouldApplyAgent && thinkLevel && thinkLevel !== "off") {
const hasThinkingFlag = argv.some(
(p, i) =>
p === "--thinking" ||
(i > 0 && argv[i - 1] === "--thinking") ||
p.startsWith("--thinking="),
);
if (!hasThinkingFlag) {
argv.splice(bodyIndex, 0, "--thinking", thinkLevel);
bodyIndex += 2;
}
}
const shouldApplyAgent = agent.isInvocation(argv);
let finalArgv = shouldApplyAgent
const finalArgv = shouldApplyAgent
? agent.buildArgs({
argv,
bodyIndex,
@@ -397,22 +328,6 @@ export async function runCommandReply(
})
: argv;
// For pi/tau: prefer RPC mode so auto-compaction and streaming events run server-side.
let rpcInput: string | undefined;
if (agentKind === "pi") {
const bodyArg = finalArgv[bodyIndex] ?? templatingCtx.Body ?? "";
rpcInput = JSON.stringify({ type: "prompt", message: bodyArg }) + "\n";
// Remove body argument (RPC expects stdin JSON instead of positional message)
finalArgv = finalArgv.filter((_, idx) => idx !== bodyIndex);
// Force --mode rpc
const modeIdx = finalArgv.findIndex((v) => v === "--mode");
if (modeIdx >= 0 && finalArgv[modeIdx + 1]) {
finalArgv[modeIdx + 1] = "rpc";
} else {
finalArgv.push("--mode", "rpc");
}
}
logVerbose(
`Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`,
);
@@ -475,7 +390,7 @@ export async function runCommandReply(
const run = async () => {
// Prefer long-lived tau RPC for pi agent to avoid cold starts.
if (agentKind === "pi") {
if (agentKind === "pi" && shouldApplyAgent) {
const promptIndex = finalArgv.length - 1;
const body = finalArgv[promptIndex] ?? "";
// Build rpc args without the prompt body; force --mode rpc.
@@ -601,7 +516,6 @@ export async function runCommandReply(
return await commandRunner(finalArgv, {
timeoutMs,
cwd: reply.cwd,
input: rpcInput,
});
};
@@ -640,13 +554,16 @@ export async function runCommandReply(
);
};
const parsed = trimmed ? agent.parseOutput(trimmed) : undefined;
const parserProvided = !!parsed;
const parsed =
shouldApplyAgent && trimmed ? agent.parseOutput(trimmed) : undefined;
const _parserProvided = shouldApplyAgent && !!parsed;
// Collect assistant texts and tool results from parseOutput (tau RPC can emit many).
const parsedTexts =
parsed?.texts?.map((t) => t.trim()).filter(Boolean) ?? [];
const parsedToolResults = normalizeToolResults(parsed?.toolResults);
const hasParsedContent =
parsedTexts.length > 0 || parsedToolResults.length > 0;
type ReplyItem = { text: string; media?: string[] };
const replyItems: ReplyItem[] = [];
@@ -716,7 +633,7 @@ export async function runCommandReply(
}
// If parser gave nothing, fall back to raw stdout as a single message.
if (replyItems.length === 0 && trimmed && !parserProvided) {
if (replyItems.length === 0 && trimmed && !hasParsedContent) {
const { text: cleanedText, mediaUrls: mediaFound } =
splitMediaFromOutput(trimmed);
if (cleanedText || mediaFound?.length) {

View File

@@ -1,104 +0,0 @@
// Helpers specific to Opencode CLI output/argv handling.
// Preferred binary name for Opencode CLI invocations.
export const OPENCODE_BIN = "opencode";
export const OPENCODE_IDENTITY_PREFIX =
"You are Openclawd running on the user's Mac via clawdis. Your scratchpad is /Users/steipete/openclawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present. If a prompt is a heartbeat poll and nothing needs attention, reply with exactly HEARTBEAT_OK and nothing else; for any alert, do not include HEARTBEAT_OK.";
export type OpencodeJsonParseResult = {
text?: string;
parsed: unknown[];
valid: boolean;
meta?: {
durationMs?: number;
cost?: number;
tokens?: {
input?: number;
output?: number;
};
};
};
export function parseOpencodeJson(raw: string): OpencodeJsonParseResult {
const lines = raw.split(/\n+/).filter((s) => s.trim());
const parsed: unknown[] = [];
let text = "";
let valid = false;
let startTime: number | undefined;
let endTime: number | undefined;
let cost = 0;
let inputTokens = 0;
let outputTokens = 0;
for (const line of lines) {
try {
const event = JSON.parse(line);
parsed.push(event);
if (event && typeof event === "object") {
// Opencode emits a stream of events.
if (event.type === "step_start") {
valid = true;
if (typeof event.timestamp === "number") {
if (startTime === undefined || event.timestamp < startTime) {
startTime = event.timestamp;
}
}
}
if (event.type === "text" && event.part?.text) {
text += event.part.text;
valid = true;
}
if (event.type === "step_finish") {
valid = true;
if (typeof event.timestamp === "number") {
endTime = event.timestamp;
}
if (event.part) {
if (typeof event.part.cost === "number") {
cost += event.part.cost;
}
if (event.part.tokens) {
inputTokens += event.part.tokens.input || 0;
outputTokens += event.part.tokens.output || 0;
}
}
}
}
} catch {
// ignore non-JSON lines
}
}
const meta: OpencodeJsonParseResult["meta"] = {};
if (startTime !== undefined && endTime !== undefined) {
meta.durationMs = endTime - startTime;
}
if (cost > 0) meta.cost = cost;
if (inputTokens > 0 || outputTokens > 0) {
meta.tokens = { input: inputTokens, output: outputTokens };
}
return {
text: text || undefined,
parsed,
valid: valid && parsed.length > 0,
meta: Object.keys(meta).length > 0 ? meta : undefined,
};
}
export function summarizeOpencodeMetadata(
meta: OpencodeJsonParseResult["meta"],
): string | undefined {
if (!meta) return undefined;
const parts: string[] = [];
if (meta.durationMs !== undefined)
parts.push(`duration=${meta.durationMs}ms`);
if (meta.cost !== undefined) parts.push(`cost=$${meta.cost.toFixed(4)}`);
if (meta.tokens) {
parts.push(`tokens=${meta.tokens.input}+${meta.tokens.output}`);
}
return parts.length ? parts.join(", ") : undefined;
}

View File

@@ -557,7 +557,7 @@ export async function getReplyFromConfig(
const mediaNote = ctx.MediaPath?.length
? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]`
: undefined;
// For command prompts we prepend the media note so Claude et al. see it; text replies stay clean.
// For command prompts we prepend the media note so Pi sees it; text replies stay clean.
const mediaReplyHint =
mediaNote && reply?.mode === "command"
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."