Agents: add pluggable CLIs
Co-authored-by: RealSid08 <RealSid08@users.noreply.github.com>
This commit is contained in:
@@ -160,3 +160,6 @@ 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";
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("runCommandReply", () => {
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
agent: { kind: "claude", format: "json" },
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
@@ -98,7 +98,7 @@ describe("runCommandReply", () => {
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
agent: { kind: "claude", format: "json" },
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: true,
|
||||
@@ -121,7 +121,7 @@ describe("runCommandReply", () => {
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
agent: { kind: "claude", format: "json" },
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: true,
|
||||
@@ -144,7 +144,7 @@ describe("runCommandReply", () => {
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
agent: { kind: "claude", format: "json" },
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: true,
|
||||
@@ -167,6 +167,7 @@ describe("runCommandReply", () => {
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["cli", "{{Body}}"],
|
||||
agent: { kind: "claude" },
|
||||
session: {
|
||||
sessionArgNew: ["--new", "{{SessionId}}"],
|
||||
sessionArgResume: ["--resume", "{{SessionId}}"],
|
||||
@@ -192,7 +193,7 @@ describe("runCommandReply", () => {
|
||||
throw { stdout: "partial output here", killed: true, signal: "SIGKILL" };
|
||||
});
|
||||
const { payload, meta } = await runCommandReply({
|
||||
reply: { mode: "command", command: ["echo", "hi"] },
|
||||
reply: { mode: "command", command: ["echo", "hi"], agent: { kind: "claude" } },
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
@@ -213,7 +214,7 @@ describe("runCommandReply", () => {
|
||||
throw { stdout: "", killed: true, signal: "SIGKILL" };
|
||||
});
|
||||
const { payload } = await runCommandReply({
|
||||
reply: { mode: "command", command: ["echo", "hi"], cwd: "/tmp/work" },
|
||||
reply: { mode: "command", command: ["echo", "hi"], cwd: "/tmp/work", agent: { kind: "claude" } },
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
@@ -235,7 +236,7 @@ describe("runCommandReply", () => {
|
||||
stdout: `hi\nMEDIA:${tmp}\nMEDIA:https://example.com/img.jpg`,
|
||||
});
|
||||
const { payload } = await runCommandReply({
|
||||
reply: { mode: "command", command: ["echo", "hi"], mediaMaxMb: 1 },
|
||||
reply: { mode: "command", command: ["echo", "hi"], mediaMaxMb: 1, agent: { kind: "claude" } },
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
@@ -259,7 +260,7 @@ describe("runCommandReply", () => {
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
agent: { kind: "claude", format: "json" },
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
@@ -271,14 +272,14 @@ describe("runCommandReply", () => {
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
expect(meta.claudeMeta).toContain("duration=50ms");
|
||||
expect(meta.claudeMeta).toContain("tool_calls=1");
|
||||
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}}"] },
|
||||
reply: { mode: "command", command: ["echo", "{{Body}}"], agent: { kind: "claude" } },
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
@@ -303,7 +304,7 @@ describe("runCommandReply", () => {
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
agent: { kind: "claude", format: "json" },
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
@@ -328,7 +329,7 @@ describe("runCommandReply", () => {
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
agent: { kind: "claude", format: "json" },
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
@@ -353,7 +354,7 @@ describe("runCommandReply", () => {
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
agent: { kind: "claude", format: "json" },
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { getAgentSpec } from "../agents/index.js";
|
||||
import type { AgentMeta } from "../agents/types.js";
|
||||
import type { WarelayConfig } from "../config/config.js";
|
||||
import { isVerbose, logVerbose } from "../globals.js";
|
||||
import { logError } from "../logger.js";
|
||||
import { splitMediaFromOutput } from "../media/parse.js";
|
||||
import { enqueueCommand } from "../process/command-queue.js";
|
||||
import type { runCommandWithTimeout } from "../process/exec.js";
|
||||
import {
|
||||
CLAUDE_BIN,
|
||||
CLAUDE_IDENTITY_PREFIX,
|
||||
type ClaudeJsonParseResult,
|
||||
parseClaudeJson,
|
||||
} from "./claude.js";
|
||||
import { applyTemplate, type TemplateContext } from "./templating.js";
|
||||
import type { ReplyPayload } from "./types.js";
|
||||
|
||||
@@ -42,7 +38,7 @@ export type CommandReplyMeta = {
|
||||
exitCode?: number | null;
|
||||
signal?: string | null;
|
||||
killed?: boolean;
|
||||
claudeMeta?: string;
|
||||
agentMeta?: AgentMeta;
|
||||
};
|
||||
|
||||
export type CommandReplyResult = {
|
||||
@@ -119,6 +115,8 @@ 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 agent = getAgentSpec(agentCfg.kind as any);
|
||||
|
||||
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
||||
const templatePrefix =
|
||||
@@ -129,66 +127,47 @@ export async function runCommandReply(
|
||||
argv = [argv[0], templatePrefix, ...argv.slice(1)];
|
||||
}
|
||||
|
||||
// Ensure Claude commands can emit plain text by forcing --output-format when configured.
|
||||
if (
|
||||
reply.claudeOutputFormat &&
|
||||
argv.length > 0 &&
|
||||
path.basename(argv[0]) === CLAUDE_BIN
|
||||
) {
|
||||
const hasOutputFormat = argv.some(
|
||||
(part) =>
|
||||
part === "--output-format" || part.startsWith("--output-format="),
|
||||
);
|
||||
const insertBeforeBody = Math.max(argv.length - 1, 0);
|
||||
if (!hasOutputFormat) {
|
||||
argv = [
|
||||
...argv.slice(0, insertBeforeBody),
|
||||
"--output-format",
|
||||
reply.claudeOutputFormat,
|
||||
...argv.slice(insertBeforeBody),
|
||||
];
|
||||
}
|
||||
const hasPrintFlag = argv.some(
|
||||
(part) => part === "-p" || part === "--print",
|
||||
);
|
||||
if (!hasPrintFlag) {
|
||||
const insertIdx = Math.max(argv.length - 1, 0);
|
||||
argv = [...argv.slice(0, insertIdx), "-p", ...argv.slice(insertIdx)];
|
||||
}
|
||||
}
|
||||
// Default body index is last arg
|
||||
let bodyIndex = Math.max(argv.length - 1, 0);
|
||||
|
||||
// Inject session args if configured (use resume for existing, session-id for new)
|
||||
// Session args prepared (templated) and injected generically
|
||||
if (reply.session) {
|
||||
const defaultNew =
|
||||
agentCfg.kind === "claude"
|
||||
? ["--session-id", "{{SessionId}}"]
|
||||
: ["--session", "{{SessionId}}"];
|
||||
const defaultResume =
|
||||
agentCfg.kind === "claude"
|
||||
? ["--resume", "{{SessionId}}"]
|
||||
: ["--session", "{{SessionId}}"];
|
||||
const sessionArgList = (
|
||||
isNewSession
|
||||
? (reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"])
|
||||
: (reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"])
|
||||
).map((part) => applyTemplate(part, templatingCtx));
|
||||
? reply.session.sessionArgNew ?? defaultNew
|
||||
: reply.session.sessionArgResume ?? defaultResume
|
||||
).map((p) => applyTemplate(p, templatingCtx));
|
||||
if (sessionArgList.length) {
|
||||
const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true;
|
||||
const insertAt =
|
||||
insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length;
|
||||
argv = [
|
||||
...argv.slice(0, insertAt),
|
||||
...sessionArgList,
|
||||
...argv.slice(insertAt),
|
||||
];
|
||||
argv = [...argv.slice(0, insertAt), ...sessionArgList, ...argv.slice(insertAt)];
|
||||
bodyIndex = Math.max(argv.length - 1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
let finalArgv = argv;
|
||||
const isClaudeInvocation =
|
||||
finalArgv.length > 0 && path.basename(finalArgv[0]) === CLAUDE_BIN;
|
||||
const shouldPrependIdentity =
|
||||
isClaudeInvocation && !(sendSystemOnce && systemSent);
|
||||
if (shouldPrependIdentity && finalArgv.length > 0) {
|
||||
const bodyIdx = finalArgv.length - 1;
|
||||
const existingBody = finalArgv[bodyIdx] ?? "";
|
||||
finalArgv = [
|
||||
...finalArgv.slice(0, bodyIdx),
|
||||
[CLAUDE_IDENTITY_PREFIX, existingBody].filter(Boolean).join("\n\n"),
|
||||
];
|
||||
}
|
||||
const shouldApplyAgent = agent.isInvocation(argv);
|
||||
const finalArgv = shouldApplyAgent
|
||||
? agent.buildArgs({
|
||||
argv,
|
||||
bodyIndex,
|
||||
isNewSession,
|
||||
sessionId: templatingCtx.SessionId,
|
||||
sendSystemOnce,
|
||||
systemSent,
|
||||
identityPrefix: agentCfg.identityPrefix,
|
||||
format: agentCfg.format,
|
||||
})
|
||||
: argv;
|
||||
|
||||
logVerbose(
|
||||
`Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`,
|
||||
);
|
||||
@@ -217,28 +196,12 @@ export async function runCommandReply(
|
||||
if (stderr?.trim()) {
|
||||
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
|
||||
}
|
||||
let parsed: ClaudeJsonParseResult | undefined;
|
||||
if (
|
||||
trimmed &&
|
||||
(reply.claudeOutputFormat === "json" || isClaudeInvocation)
|
||||
) {
|
||||
parsed = parseClaudeJson(trimmed);
|
||||
if (parsed?.parsed && isVerbose()) {
|
||||
const summary = summarizeClaudeMetadata(parsed.parsed);
|
||||
if (summary) logVerbose(`Claude JSON meta: ${summary}`);
|
||||
logVerbose(
|
||||
`Claude JSON raw: ${JSON.stringify(parsed.parsed, null, 2)}`,
|
||||
);
|
||||
}
|
||||
if (typeof parsed?.text === "string") {
|
||||
logVerbose(
|
||||
`Claude JSON parsed -> ${parsed.text.slice(0, 120)}${parsed.text.length > 120 ? "…" : ""}`,
|
||||
);
|
||||
trimmed = parsed.text.trim();
|
||||
} else {
|
||||
logVerbose("Claude JSON parse failed; returning raw stdout");
|
||||
}
|
||||
|
||||
const parsed = trimmed ? agent.parseOutput(trimmed) : undefined;
|
||||
if (parsed && parsed.text !== undefined) {
|
||||
trimmed = parsed.text.trim();
|
||||
}
|
||||
|
||||
const { text: cleanedText, mediaUrls: mediaFound } =
|
||||
splitMediaFromOutput(trimmed);
|
||||
trimmed = cleanedText;
|
||||
@@ -249,7 +212,7 @@ export async function runCommandReply(
|
||||
logVerbose("No MEDIA token extracted from final text");
|
||||
}
|
||||
if (!trimmed && !mediaFromCommand) {
|
||||
const meta = parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined;
|
||||
const meta = parsed?.meta?.extra?.summary ?? undefined;
|
||||
trimmed = `(command produced no output${meta ? `; ${meta}` : ""})`;
|
||||
logVerbose("No text/media produced; injecting fallback notice to user");
|
||||
}
|
||||
@@ -271,9 +234,7 @@ export async function runCommandReply(
|
||||
exitCode: code,
|
||||
signal,
|
||||
killed,
|
||||
claudeMeta: parsed
|
||||
? summarizeClaudeMetadata(parsed.parsed)
|
||||
: undefined,
|
||||
agentMeta: parsed?.meta,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -291,9 +252,7 @@ export async function runCommandReply(
|
||||
exitCode: code,
|
||||
signal,
|
||||
killed,
|
||||
claudeMeta: parsed
|
||||
? summarizeClaudeMetadata(parsed.parsed)
|
||||
: undefined,
|
||||
agentMeta: parsed?.meta,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -341,7 +300,7 @@ export async function runCommandReply(
|
||||
exitCode: code,
|
||||
signal,
|
||||
killed,
|
||||
claudeMeta: parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined,
|
||||
agentMeta: parsed?.meta,
|
||||
};
|
||||
if (isVerbose()) {
|
||||
logVerbose(`Command auto-reply meta: ${JSON.stringify(meta)}`);
|
||||
|
||||
105
src/auto-reply/opencode.ts
Normal file
105
src/auto-reply/opencode.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// 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 warelay. 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;
|
||||
}
|
||||
|
||||
@@ -265,8 +265,8 @@ export async function getReplyFromConfig(
|
||||
timeoutSeconds,
|
||||
commandRunner,
|
||||
});
|
||||
if (meta.claudeMeta && isVerbose()) {
|
||||
logVerbose(`Claude JSON meta: ${meta.claudeMeta}`);
|
||||
if (meta.agentMeta && isVerbose()) {
|
||||
logVerbose(`Agent meta: ${JSON.stringify(meta.agentMeta)}`);
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user