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

@@ -3,7 +3,7 @@
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, Twilio in `src/twilio`, Web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Tests: colocated `*.test.ts` plus e2e in `src/cli/relay.e2e.test.ts`.
- Docs: `docs/` (images, queue, Claude config). Built output lives in `dist/`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
## Build, Test, and Development Commands
- Install deps: `pnpm install`

View File

@@ -1,5 +1,14 @@
# Changelog
## 1.5.0 — 2025-12-05
### Breaking
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); `inbound.reply.agent.kind` now only accepts `"pi"` and related CLI helpers have been removed.
### Changes
- Default agent handling now favors Pi RPC while falling back to the plain command runner for non-Pi invocations, keeping heartbeat/session plumbing intact.
- Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical.
## 1.4.1 — 2025-12-04
### Changes

View File

@@ -19,7 +19,7 @@
```
┌─────────────┐ ┌──────────┐ ┌─────────────┐
│ WhatsApp │ ───▶ │ CLAWDIS │ ───▶ │ AI Agent │
│ (You) │ ◀─── │ 🦞⏱️💙 │ ◀─── │ (Tau/Claude)
│ (You) │ ◀─── │ 🦞⏱️💙 │ ◀─── │ (Pi/Tau)
└─────────────┘ └──────────┘ └─────────────┘
```
@@ -32,7 +32,7 @@ Because every space lobster needs a time-and-space machine. The Doctor has a TAR
## Features
- 📱 **WhatsApp Integration** — Personal WhatsApp Web or Twilio
- 🤖 **AI Agent Gateway**Works with Tau/Pi, Claude CLI, Codex, Gemini
- 🤖 **AI Agent Gateway**Pi/Tau only (Pi CLI in RPC mode)
- 💬 **Session Management** — Per-sender conversation context
- 🔔 **Heartbeats** — Periodic check-ins for proactive AI
- 👥 **Group Chat Support** — Mention-based triggering
@@ -40,6 +40,8 @@ Because every space lobster needs a time-and-space machine. The Doctor has a TAR
- 🎤 **Voice Transcription** — Whisper integration
- 🔧 **Tool Streaming** — Real-time display (💻📄✍️📝)
Only the Pi/Tau CLI is supported now; legacy Claude/Codex/Gemini paths have been removed.
## Quick Start
```bash

View File

@@ -1,78 +0,0 @@
# Agent Abstraction Refactor Plan
Goal: support multiple agent CLIs (Claude, Codex, Pi, Opencode, Gemini) cleanly, without legacy flags, and make parsing/injection per-agent. Keep WhatsApp/Twilio plumbing intact.
## Overview
- Introduce a pluggable agent layer (`src/agents/*`), selected by config.
- Normalize config (`agent` block) and remove `claudeOutputFormat` legacy knobs.
- Provide per-agent argv builders and output parsers (including NDJSON streams).
- Preserve MEDIA-token handling and shared queue/heartbeat behavior.
## Configuration
- New shape (no backward compat):
```json5
inbound: {
reply: {
mode: "command",
agent: {
kind: "claude" | "opencode" | "pi" | "codex" | "gemini",
format?: "text" | "json",
identityPrefix?: string
},
command: ["claude", "{{Body}}"],
cwd?: string,
session?: { ... },
timeoutSeconds?: number,
bodyPrefix?: string,
mediaUrl?: string,
mediaMaxMb?: number,
typingIntervalSeconds?: number,
heartbeatMinutes?: number
}
}
```
- Validation moves to `config.ts` (new `AgentKind`/`AgentConfig` types).
- If `agent` is missing → config error.
## Agent modules
- `src/agents/types.ts` `AgentKind`, `AgentSpec`:
- `buildArgs(argv: string[], body: string, ctx: { sessionId?, isNewSession?, sendSystemOnce?, systemSent?, identityPrefix? }): string[]`
- `parse(stdout: string): { text?: string; mediaUrls?: string[]; meta?: AgentMeta }`
- `src/agents/claude.ts` current flag injection (`--output-format`, `-p`), identity prepend.
- `src/agents/opencode.ts` reuse `parseOpencodeJson` (from PR #5), inject `--format json`, session flag `--session` defaults, identity prefix.
- `src/agents/pi.ts` parse NDJSON `AssistantMessageEvent` (final `message_end.message.content[text]`), inject `--mode json`/`-p` defaults, session flags.
- `src/agents/codex.ts` parse Codex JSONL (last `item` with `type:"agent_message"`; usage from `turn.completed`), inject `codex exec --json --skip-git-repo-check`, sandbox default read-only.
- `src/agents/gemini.ts` minimal parsing (plain text), identity prepend, honors `--output-format` when `format` is set, and defaults to `--resume {{SessionId}}` for session resume (new sessions need no flag). Override `sessionArgNew/sessionArgResume` if you use a different session strategy.
- Shared MEDIA extraction stays in `media/parse.ts`.
## Command runner changes
- `runCommandReply`:
- Resolve agent spec from config.
- Apply `buildArgs` (handles identity prepend and session args per agent).
- Run command; send stdout to `spec.parse` → `text`, `mediaUrls`, `meta` (stored as `agentMeta`).
- Remove `claudeMeta` naming; tests updated to `agentMeta`.
## Sessions
- Session arg defaults become agent-specific (Claude: `--resume/--session-id`; Opencode/Pi/Codex: `--session`).
- Still overridable via `sessionArgNew/sessionArgResume` in config.
## Tests
- Update existing tests to new config (no `claudeOutputFormat`).
- Add fixtures:
- Opencode NDJSON sample (from PR #5) → parsed text + meta.
- Codex NDJSON sample (captured: thread/turn/item/usage) → parsed text.
- Pi NDJSON sample (AssistantMessageEvent) → parsed text.
- Ensure MEDIA token parsing works on agent text output.
## Docs
- README: rename “Claude-aware” → “Multi-agent (Claude, Codex, Pi, Opencode)”.
- New short guide per agent (Opencode doc from PR #5; add Codex/Pi snippets).
- Mention identityPrefix override and session arg differences.
## Migration
- Breaking change: configs must specify `agent`. Remove old `claudeOutputFormat` keys.
- Provide migration note in CHANGELOG 1.3.x.
## Out of scope
- No media binary support; still relies on MEDIA tokens in text.
- No UI changes; WhatsApp/Twilio plumbing unchanged.

View File

@@ -1,12 +1,10 @@
# Agent Integration 🤖
CLAWDIS can work with any AI agent that accepts prompts via CLI. Here's how to set them up.
CLAWDIS now ships with a single coding agent: Pi (the Tau CLI). Legacy Claude/Codex/Gemini/Opencode paths have been removed.
## Supported Agents
## Pi / Tau
### Tau / Pi
The recommended agent for CLAWDIS. Built by Mario Zechner, forked with love.
The recommended (and only) agent for CLAWDIS. Built by Mario Zechner, forked with love.
```json
{
@@ -41,40 +39,11 @@ For streaming tool output and better integration:
}
```
RPC mode gives you:
RPC mode is enforced by CLAWDIS (we rewrite `--mode` to `rpc` for Pi invocations). It gives you:
- 💻 Real-time tool execution display
- 📊 Token usage tracking
- 🔄 Streaming responses
### Claude Code
```json
{
"command": [
"claude",
"-p",
"{{BodyStripped}}"
]
}
```
### Custom Agents
Any CLI that:
1. Accepts a prompt as an argument
2. Outputs text to stdout
3. Exits when done
```json
{
"command": [
"/path/to/my-agent",
"--prompt", "{{Body}}",
"--format", "text"
]
}
```
## Session Management
### Per-Sender Sessions
@@ -90,6 +59,7 @@ Each phone number gets its own conversation history:
}
}
```
By default CLAWDIS stores sessions under `~/.clawdis/sessions` and will create the folder automatically.
### Global Session

View File

@@ -5,7 +5,7 @@
1) Download inbound audio (Web or Twilio) to a temp path if only a URL is present.
2) Run the configured CLI (templated with `{{MediaPath}}`), expecting transcript on stdout.
3) Replace `Body` with the transcript, set `{{Transcript}}`, and prepend the original media path plus a `Transcript:` section in the command prompt so models see both.
4) Continue through the normal auto-reply pipeline (templating, sessions, Claude/command).
4) Continue through the normal auto-reply pipeline (templating, sessions, Pi command).
- **Verbose logging**: In `--verbose`, we log when transcription runs and when the transcript replaces the body.
## Config example (OpenAI Whisper CLI)
@@ -29,7 +29,8 @@ Requires `OPENAI_API_KEY` in env and `openai` CLI installed:
},
reply: {
mode: "command",
command: ["claude", "{{Body}}"]
command: ["pi", "{{Body}}"],
agent: { kind: "pi" }
}
}
}

View File

@@ -1,6 +1,8 @@
# Building Your Own AI Personal Assistant with warelay
> **TL;DR:** warelay lets you turn Claude into a proactive personal assistant that lives in your pocket via WhatsApp. It can check in on you, remember context across conversations, run commands on your Mac, and even wake you up with music. This doc shows you how.
> **TL;DR:** CLAWDIS (Pi/Tau only) lets you run a proactive assistant over WhatsApp. It can check in on you, remember context across conversations, run commands on your Mac, and even wake you up with music. This doc was originally written for Claude Code; where you see `claude ...`, use `pi --mode rpc ...` instead. A Pi-specific rewrite is coming soon.
⚠️ **Note (2025-12-05):** CLAWDIS now ships with only the Pi/Tau agent. The walkthrough below references Claude Code; swap those commands for `pi`/`tau` if you follow along. A Pi-specific guide is coming soon.
---

View File

@@ -7,7 +7,7 @@ Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that
- Group allowlist bypass: we still enforce `allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies.
- Per-group sessions: session keys look like `group:<jid>` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`.
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Tau/Claude know who is speaking.
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Tau/Pi know who is speaking.
- Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger.
- New session primer: on the first turn of a group session we now prepend a short blurb to the model like `You are replying inside the WhatsApp group "<subject>". Group members: +44..., +43..., … Address the specific sender noted in the message context.` If metadata isnt available we still tell the agent its a group chat.

View File

@@ -1,9 +1,9 @@
# Heartbeat polling plan (2025-11-26)
Goal: add a simple heartbeat poll for command-based auto-replies (Claude-driven) that only notifies users when something matters, using the `HEARTBEAT_OK` sentinel. The heartbeat body we send is `HEARTBEAT /think:high` so the model can easily spot it.
Goal: add a simple heartbeat poll for command-based auto-replies (Pi/Tau) that only notifies users when something matters, using the `HEARTBEAT_OK` sentinel. The heartbeat body we send is `HEARTBEAT /think:high` so the model can easily spot it.
## Prompt contract
- Extend the Claude system/identity text to explain: “If this is a heartbeat poll and nothing needs attention, reply exactly `HEARTBEAT_OK` and nothing else. For any alert, do **not** include `HEARTBEAT_OK`; just return the alert text.” Heartbeat prompt body is `HEARTBEAT /think:high`.
- Extend the Pi/Tau system/identity text to explain: “If this is a heartbeat poll and nothing needs attention, reply exactly `HEARTBEAT_OK` and nothing else. For any alert, do **not** include `HEARTBEAT_OK`; just return the alert text.” Heartbeat prompt body is `HEARTBEAT /think:high`.
- Keep existing WhatsApp length guidance; forbid burying the sentinel inside alerts.
## Config & defaults
@@ -13,8 +13,8 @@ Goal: add a simple heartbeat poll for command-based auto-replies (Claude-driven)
## Poller behavior
- When relay runs with command-mode auto-reply, start a timer with the resolved heartbeat interval.
- Each tick invokes the configured command with a short heartbeat body (e.g., “(heartbeat) summarize any important changes since last turn”) while reusing the active session args so Claude context stays warm.
- Heartbeats never create a new session implicitly: if theres no stored session for the target (fallback path), the heartbeat is skipped instead of starting a fresh Claude session.
- Each tick invokes the configured command with a short heartbeat body (e.g., “(heartbeat) summarize any important changes since last turn”) while reusing the active session args so Pi context stays warm.
- Heartbeats never create a new session implicitly: if theres no stored session for the target (fallback path), the heartbeat is skipped instead of starting a fresh Pi session.
- Abort timer on SIGINT/abort of the relay.
## Sentinel handling

View File

@@ -56,7 +56,7 @@ This document defines how `warelay` should handle sending and replying with imag
- Web inbox:
- If `mediaUrl` present, fetch/resolve same as send (local path or URL), send via Baileys with caption.
## Inbound Media to Commands (Claude etc.)
## Inbound Media to Commands (Pi/Tau)
- For completeness: when inbound Twilio/Web messages include media, download to temp file, expose templating variables:
- `{{MediaUrl}}` original URL (Twilio) or pseudo-URL (web).
- `{{MediaPath}}` local temp path written before running the command.

View File

@@ -18,7 +18,7 @@ CLAWDIS (née Warelay) bridges WhatsApp to AI coding agents like [Tau/Pi](https:
## Features
- 📱 **WhatsApp Integration** — Uses Baileys for WhatsApp Web protocol
- 🤖 **AI Agent Gateway**Spawns coding agents (Tau, Claude, etc.) per message
- 🤖 **AI Agent Gateway**Pi/Tau only (Pi CLI in RPC mode)
- 💬 **Session Management** — Maintains conversation context across messages
- 🔔 **Heartbeats** — Periodic check-ins so your AI doesn't feel lonely
- 👥 **Group Chat Support** — Mention-based triggering in group chats
@@ -26,6 +26,8 @@ CLAWDIS (née Warelay) bridges WhatsApp to AI coding agents like [Tau/Pi](https:
- 🎤 **Voice Messages** — Transcription via Whisper
- 🔧 **Tool Streaming** — Real-time display of AI tool usage (💻📄✍️📝)
Note: support for Claude, Codex, Gemini, and Opencode has been removed; Pi/Tau is now the only coding agent path.
## The Name
**CLAWDIS** = CLAW + TARDIS

View File

@@ -21,8 +21,7 @@
- Confirmation reply is sent (`Thinking level set to high.` / `Thinking disabled.`). If the level is invalid (e.g. `/thinking big`), the command is rejected with a hint and the session state is left unchanged.
## Application by agent
- **Pi/Tau**: injects `--thinking <level>` (skipped for `off`).
- **Claude & other text agents**: appends the cue word to the prompt text as above.
- **Pi/Tau**: injects `--thinking <level>` (skipped for `off`). Other agent paths have been removed.
## Verbose directives (/verbose or /v)
- Levels: `on|full` or `off` (default).

View File

@@ -8,7 +8,7 @@
## Commands
- `warelay relay:tmux` — restarts the `warelay-relay` session running `pnpm warelay relay --verbose`, then attaches (skips attach when stdout isnt a TTY).
- `warelay relay:tmux:attach` — attach to the existing session without restarting it.
- `warelay relay:heartbeat:tmux` — same as `relay:tmux` but adds `--heartbeat-now` so Claude is pinged immediately on startup.
- `warelay relay:heartbeat:tmux` — same as `relay:tmux` but adds `--heartbeat-now` so Pi is pinged immediately on startup.
All helpers use the fixed session name `warelay-relay`.

View File

@@ -1,62 +1,39 @@
import { describe, expect, it } from "vitest";
import { CLAUDE_IDENTITY_PREFIX } from "../auto-reply/claude.js";
import { OPENCODE_IDENTITY_PREFIX } from "../auto-reply/opencode.js";
import { claudeSpec } from "./claude.js";
import { codexSpec } from "./codex.js";
import { GEMINI_IDENTITY_PREFIX, geminiSpec } from "./gemini.js";
import { opencodeSpec } from "./opencode.js";
import { piSpec } from "./pi.js";
describe("agent buildArgs + parseOutput helpers", () => {
it("claudeSpec injects flags and identity once", () => {
const argv = ["claude", "hi"];
const built = claudeSpec.buildArgs({
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",
sendSystemOnce: false,
systemSent: false,
identityPrefix: undefined,
identityPrefix: "IDENT",
format: "json",
});
expect(built).toContain("--output-format");
expect(built).toContain("json");
expect(built).toContain("-p");
expect(built.at(-1)).toContain(CLAUDE_IDENTITY_PREFIX);
expect(built).toContain("--mode");
expect(built).toContain("json");
expect(built.at(-1)).toContain("IDENT");
const builtNoIdentity = claudeSpec.buildArgs({
const builtNoIdentity = piSpec.buildArgs({
argv,
bodyIndex: 1,
isNewSession: false,
sessionId: "sess",
sendSystemOnce: true,
systemSent: true,
identityPrefix: undefined,
identityPrefix: "IDENT",
format: "json",
});
expect(builtNoIdentity.at(-1)).not.toContain(CLAUDE_IDENTITY_PREFIX);
expect(builtNoIdentity.at(-1)).toBe("hi");
});
it("opencodeSpec adds format flag and identity prefix when needed", () => {
const argv = ["opencode", "body"];
const built = opencodeSpec.buildArgs({
argv,
bodyIndex: 1,
isNewSession: true,
sessionId: "sess",
sendSystemOnce: false,
systemSent: false,
identityPrefix: undefined,
format: "json",
});
expect(built).toContain("--format");
expect(built).toContain("json");
expect(built.at(-1)).toContain(OPENCODE_IDENTITY_PREFIX);
});
it("piSpec parses final assistant message and preserves usage meta", () => {
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},"model":"pi-1","provider":"inflection","stopReason":"end"}}',
@@ -80,83 +57,4 @@ describe("agent buildArgs + parseOutput helpers", () => {
expect(tool?.toolName).toBe("bash");
expect(tool?.meta).toBe("ls -la");
});
it("codexSpec parses agent_message and aggregates usage", () => {
const stdout = [
'{"type":"item.completed","item":{"type":"agent_message","text":"hi there"}}',
'{"type":"turn.completed","usage":{"input_tokens":50,"output_tokens":10,"cached_input_tokens":5}}',
].join("\n");
const parsed = codexSpec.parseOutput(stdout);
expect(parsed.texts?.[0]).toBe("hi there");
const usage = parsed.meta?.usage as {
input?: number;
output?: number;
cacheRead?: number;
total?: number;
};
expect(usage?.input).toBe(50);
expect(usage?.output).toBe(10);
expect(usage?.cacheRead).toBe(5);
expect(usage?.total).toBe(65);
});
it("opencodeSpec parses streamed events and summarizes meta", () => {
const stdout = [
'{"type":"step_start","timestamp":0}',
'{"type":"text","part":{"text":"hi"}}',
'{"type":"step_finish","timestamp":1200,"part":{"cost":0.002,"tokens":{"input":100,"output":20}}}',
].join("\n");
const parsed = opencodeSpec.parseOutput(stdout);
expect(parsed.texts?.[0]).toBe("hi");
expect(parsed.meta?.extra?.summary).toContain("duration=1200ms");
expect(parsed.meta?.extra?.summary).toContain("cost=$0.0020");
expect(parsed.meta?.extra?.summary).toContain("tokens=100+20");
});
it("codexSpec buildArgs enforces exec/json/sandbox defaults", () => {
const argv = ["codex", "hello world"];
const built = codexSpec.buildArgs({
argv,
bodyIndex: 1,
isNewSession: true,
sessionId: "sess",
sendSystemOnce: false,
systemSent: false,
identityPrefix: undefined,
format: "json",
});
expect(built[1]).toBe("exec");
expect(built).toContain("--json");
expect(built).toContain("--skip-git-repo-check");
expect(built).toContain("read-only");
});
it("geminiSpec prepends identity unless already sent", () => {
const argv = ["gemini", "hi"];
const built = geminiSpec.buildArgs({
argv,
bodyIndex: 1,
isNewSession: true,
sessionId: "sess",
sendSystemOnce: false,
systemSent: false,
identityPrefix: undefined,
format: "json",
});
expect(built.at(-1)).toContain(GEMINI_IDENTITY_PREFIX);
const builtOnce = geminiSpec.buildArgs({
argv,
bodyIndex: 1,
isNewSession: false,
sessionId: "sess",
sendSystemOnce: true,
systemSent: true,
identityPrefix: undefined,
format: "json",
});
expect(builtOnce.at(-1)).toBe("hi");
expect(builtOnce).toContain("--output-format");
expect(builtOnce).toContain("json");
});
});

View File

@@ -1,76 +0,0 @@
import path from "node:path";
import {
CLAUDE_BIN,
CLAUDE_IDENTITY_PREFIX,
type ClaudeJsonParseResult,
parseClaudeJson,
summarizeClaudeMetadata,
} from "../auto-reply/claude.js";
import type { AgentMeta, AgentSpec } from "./types.js";
function toMeta(parsed?: ClaudeJsonParseResult): AgentMeta | undefined {
if (!parsed?.parsed) return undefined;
const summary = summarizeClaudeMetadata(parsed.parsed);
const sessionId =
parsed.parsed &&
typeof parsed.parsed === "object" &&
typeof (parsed.parsed as { session_id?: unknown }).session_id === "string"
? (parsed.parsed as { session_id: string }).session_id
: undefined;
const meta: AgentMeta = {};
if (sessionId) meta.sessionId = sessionId;
if (summary) meta.extra = { summary };
return Object.keys(meta).length ? meta : undefined;
}
export const claudeSpec: AgentSpec = {
kind: "claude",
isInvocation: (argv) =>
argv.length > 0 && path.basename(argv[0]) === CLAUDE_BIN,
buildArgs: (ctx) => {
// Split around the body so we can inject flags without losing the body
// position. This keeps templated prompts intact even when we add flags.
const argv = [...ctx.argv];
const body = argv[ctx.bodyIndex] ?? "";
const beforeBody = argv.slice(0, ctx.bodyIndex);
const afterBody = argv.slice(ctx.bodyIndex + 1);
const wantsOutputFormat = typeof ctx.format === "string";
if (wantsOutputFormat) {
const hasOutputFormat = argv.some(
(part) =>
part === "--output-format" || part.startsWith("--output-format="),
);
if (!hasOutputFormat) {
const outputFormat = ctx.format ?? "json";
beforeBody.push("--output-format", outputFormat);
}
}
const hasPrintFlag = argv.some(
(part) => part === "-p" || part === "--print",
);
if (!hasPrintFlag) {
beforeBody.push("-p");
}
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent);
const bodyWithIdentity =
shouldPrependIdentity && body
? [ctx.identityPrefix ?? CLAUDE_IDENTITY_PREFIX, body]
.filter(Boolean)
.join("\n\n")
: body;
return [...beforeBody, bodyWithIdentity, ...afterBody];
},
parseOutput: (rawStdout) => {
const parsed = parseClaudeJson(rawStdout);
const text = parsed?.text ?? rawStdout.trim();
return {
texts: text ? [text.trim()] : undefined,
meta: toMeta(parsed),
};
},
};

View File

@@ -1,80 +0,0 @@
import path from "node:path";
import type { AgentMeta, AgentParseResult, AgentSpec } from "./types.js";
function parseCodexJson(raw: string): AgentParseResult {
const lines = raw.split(/\n+/).filter((l) => l.trim().startsWith("{"));
const texts: string[] = [];
let meta: AgentMeta | undefined;
for (const line of lines) {
try {
const ev = JSON.parse(line) as {
type?: string;
item?: { type?: string; text?: string };
usage?: unknown;
};
// Codex streams multiple events; capture the last agent_message text and
// the final turn usage for cost/telemetry.
if (
ev.type === "item.completed" &&
ev.item?.type === "agent_message" &&
typeof ev.item.text === "string"
) {
texts.push(ev.item.text);
}
if (
ev.type === "turn.completed" &&
ev.usage &&
typeof ev.usage === "object"
) {
const u = ev.usage as {
input_tokens?: number;
cached_input_tokens?: number;
output_tokens?: number;
};
meta = {
usage: {
input: u.input_tokens,
output: u.output_tokens,
cacheRead: u.cached_input_tokens,
total:
(u.input_tokens ?? 0) +
(u.output_tokens ?? 0) +
(u.cached_input_tokens ?? 0),
},
};
}
} catch {
// ignore
}
}
const finalTexts = texts.length ? texts.map((t) => t.trim()) : undefined;
return { texts: finalTexts, meta };
}
export const codexSpec: AgentSpec = {
kind: "codex",
isInvocation: (argv) => argv.length > 0 && path.basename(argv[0]) === "codex",
buildArgs: (ctx) => {
const argv = [...ctx.argv];
const hasExec = argv.length > 0 && argv[1] === "exec";
if (!hasExec) {
argv.splice(1, 0, "exec");
}
// Ensure JSON output
if (!argv.includes("--json")) {
argv.splice(argv.length - 1, 0, "--json");
}
// Safety defaults
if (!argv.includes("--skip-git-repo-check")) {
argv.splice(argv.length - 1, 0, "--skip-git-repo-check");
}
if (!argv.some((p) => p === "--sandbox" || p.startsWith("--sandbox="))) {
argv.splice(argv.length - 1, 0, "--sandbox", "read-only");
}
return argv;
},
parseOutput: parseCodexJson,
};

View File

@@ -1,54 +0,0 @@
import path from "node:path";
import type { AgentParseResult, AgentSpec } from "./types.js";
const GEMINI_BIN = "gemini";
export const GEMINI_IDENTITY_PREFIX =
"You are Gemini responding for clawdis. Keep WhatsApp replies concise (<1500 chars). If the prompt contains media paths or a Transcript block, use them. If this was a heartbeat probe and nothing needs attention, reply with exactly HEARTBEAT_OK.";
// Gemini CLI currently prints plain text; --output json is flaky across versions, so we
// keep parsing minimal and let MEDIA token stripping happen later in the pipeline.
function parseGeminiOutput(raw: string): AgentParseResult {
const trimmed = raw.trim();
const text = trimmed || undefined;
return {
texts: text ? [text] : undefined,
meta: undefined,
} satisfies AgentParseResult;
}
export const geminiSpec: AgentSpec = {
kind: "gemini",
isInvocation: (argv) =>
argv.length > 0 && path.basename(argv[0]) === GEMINI_BIN,
buildArgs: (ctx) => {
const argv = [...ctx.argv];
const body = argv[ctx.bodyIndex] ?? "";
const beforeBody = argv.slice(0, ctx.bodyIndex);
const afterBody = argv.slice(ctx.bodyIndex + 1);
if (ctx.format) {
const hasOutput =
beforeBody.some(
(p) => p === "--output-format" || p.startsWith("--output-format="),
) ||
afterBody.some(
(p) => p === "--output-format" || p.startsWith("--output-format="),
);
if (!hasOutput) {
beforeBody.push("--output-format", ctx.format);
}
}
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent);
const bodyWithIdentity =
shouldPrependIdentity && body
? [ctx.identityPrefix ?? GEMINI_IDENTITY_PREFIX, body]
.filter(Boolean)
.join("\n\n")
: body;
return [...beforeBody, bodyWithIdentity, ...afterBody];
},
parseOutput: parseGeminiOutput,
};

View File

@@ -1,15 +1,7 @@
import { claudeSpec } from "./claude.js";
import { codexSpec } from "./codex.js";
import { geminiSpec } from "./gemini.js";
import { opencodeSpec } from "./opencode.js";
import { piSpec } from "./pi.js";
import type { AgentKind, AgentSpec } from "./types.js";
const specs: Record<AgentKind, AgentSpec> = {
claude: claudeSpec,
codex: codexSpec,
gemini: geminiSpec,
opencode: opencodeSpec,
pi: piSpec,
};

View File

@@ -1,62 +0,0 @@
import path from "node:path";
import {
OPENCODE_BIN,
OPENCODE_IDENTITY_PREFIX,
parseOpencodeJson,
summarizeOpencodeMetadata,
} from "../auto-reply/opencode.js";
import type { AgentMeta, AgentSpec } from "./types.js";
function toMeta(
parsed: ReturnType<typeof parseOpencodeJson>,
): AgentMeta | undefined {
const summary = summarizeOpencodeMetadata(parsed.meta);
return summary ? { extra: { summary } } : undefined;
}
export const opencodeSpec: AgentSpec = {
kind: "opencode",
isInvocation: (argv) =>
argv.length > 0 && path.basename(argv[0]) === OPENCODE_BIN,
buildArgs: (ctx) => {
// Split around the body so we can insert flags without losing the prompt.
const argv = [...ctx.argv];
const body = argv[ctx.bodyIndex] ?? "";
const beforeBody = argv.slice(0, ctx.bodyIndex);
const afterBody = argv.slice(ctx.bodyIndex + 1);
const wantsJson = ctx.format === "json";
// Ensure format json for parsing
if (wantsJson) {
const hasFormat = [...beforeBody, body, ...afterBody].some(
(part) => part === "--format" || part.startsWith("--format="),
);
if (!hasFormat) {
beforeBody.push("--format", "json");
}
}
// Session args default to --session
// Identity prefix
// Opencode streams text tokens; we still seed an identity so the agent
// keeps context on first turn.
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent);
const bodyWithIdentity =
shouldPrependIdentity && body
? [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, body]
.filter(Boolean)
.join("\n\n")
: body;
return [...beforeBody, bodyWithIdentity, ...afterBody];
},
parseOutput: (rawStdout) => {
const parsed = parseOpencodeJson(rawStdout);
const text = parsed.text ?? rawStdout.trim();
return {
texts: text ? [text.trim()] : undefined,
meta: toMeta(parsed),
};
},
};

View File

@@ -145,22 +145,25 @@ export const piSpec: AgentSpec = {
},
buildArgs: (ctx) => {
const argv = [...ctx.argv];
let bodyPos = ctx.bodyIndex;
// Non-interactive print + JSON
if (!argv.includes("-p") && !argv.includes("--print")) {
argv.splice(argv.length - 1, 0, "-p");
argv.splice(bodyPos, 0, "-p");
bodyPos += 1;
}
if (
ctx.format === "json" &&
!argv.includes("--mode") &&
!argv.some((a) => a === "--mode")
) {
argv.splice(argv.length - 1, 0, "--mode", "json");
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[ctx.bodyIndex]) {
const existingBody = argv[ctx.bodyIndex];
argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody]
if (!(ctx.sendSystemOnce && ctx.systemSent) && argv[bodyPos]) {
const existingBody = argv[bodyPos];
argv[bodyPos] = [ctx.identityPrefix, existingBody]
.filter(Boolean)
.join("\n\n");
}

View File

@@ -1,4 +1,4 @@
export type AgentKind = "claude" | "opencode" | "pi" | "codex" | "gemini";
export type AgentKind = "pi";
export type AgentMeta = {
model?: string;

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."

View File

@@ -87,7 +87,7 @@ describe("cli program", () => {
const program = buildProgram();
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
expect(spawnRelayTmux).toHaveBeenCalledWith(
"pnpm warelay relay --verbose",
"pnpm clawdis relay --verbose",
true,
false,
);
@@ -122,7 +122,7 @@ describe("cli program", () => {
await program.parseAsync(["relay:heartbeat:tmux"], { from: "user" });
const shouldAttach = Boolean(process.stdout.isTTY);
expect(spawnRelayTmux).toHaveBeenCalledWith(
"pnpm warelay relay --verbose --heartbeat-now",
"pnpm clawdis relay --verbose --heartbeat-now",
shouldAttach,
);
});

View File

@@ -247,7 +247,7 @@ Examples:
.option("--to <number>", "Override target E.164; defaults to allowFrom[0]")
.option(
"--session-id <id>",
"Force a session id for this heartbeat (resumes a specific Claude session)",
"Force a session id for this heartbeat (resumes a specific Pi session)",
)
.option(
"--all",

View File

@@ -26,7 +26,7 @@ describe("spawnRelayTmux", () => {
it("kills old session, starts new one, and attaches", async () => {
const session = await spawnRelayTmux("echo hi", true, true);
expect(session).toBe("warelay-relay");
expect(session).toBe("clawdis-relay");
const spawnMock = spawn as unknown as vi.Mock;
expect(spawnMock.mock.calls.length).toBe(3);
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;

View File

@@ -118,7 +118,7 @@ const ReplySchema = z
.object({
mode: z.union([z.literal("text"), z.literal("command")]),
text: z.string().optional(),
command: z.array(z.string()).optional(),
command: z.array(z.string()).optional(),
heartbeatCommand: z.array(z.string()).optional(),
thinkingDefault: z
.union([
@@ -147,8 +147,8 @@ const ReplySchema = z
heartbeatIdleMinutes: z.number().int().positive().optional(),
store: z.string().optional(),
sessionArgNew: z.array(z.string()).optional(),
sessionArgResume: z.array(z.string()).optional(),
sessionArgBeforeBody: z.boolean().optional(),
sessionArgResume: z.array(z.string()).optional(),
sessionArgBeforeBody: z.boolean().optional(),
sendSystemOnce: z.boolean().optional(),
sessionIntro: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
@@ -157,13 +157,7 @@ const ReplySchema = z
heartbeatMinutes: z.number().int().nonnegative().optional(),
agent: z
.object({
kind: z.union([
z.literal("claude"),
z.literal("opencode"),
z.literal("pi"),
z.literal("codex"),
z.literal("gemini"),
]),
kind: z.literal("pi"),
format: z.union([z.literal("text"), z.literal("json")]).optional(),
identityPrefix: z.string().optional(),
})

View File

@@ -505,62 +505,6 @@ describe("config and templating", () => {
expect(rpcSpy.mock.calls[0][0].prompt).toBe("hello");
});
it("rewrites /think directive to textual cue for non-pi agents", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok",
stderr: "",
code: 0,
signal: null,
killed: false,
});
const cfg = {
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
},
},
};
await index.getReplyFromConfig(
{ Body: "/think:medium hi there", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(runSpy).toHaveBeenCalled();
const args = runSpy.mock.calls[0][0] as string[];
expect(args[1]).toBe("hi there think harder");
});
it("treats /think:off as no-op for non-pi agents", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok",
stderr: "",
code: 0,
signal: null,
killed: false,
});
const cfg = {
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
},
},
};
await index.getReplyFromConfig(
{ Body: "/think:off hi there", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(runSpy).toHaveBeenCalled();
const args = runSpy.mock.calls[0][0] as string[];
expect(args[1]).toBe("hi there");
});
it("treats /think:off as no-op for pi (no --thinking injected)", async () => {
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
stdout: "ok",
@@ -589,48 +533,6 @@ describe("config and templating", () => {
expect(rpcSpy.mock.calls[0][0].prompt).toBe("hello");
});
it("persists session thinking level when directive-only message is sent", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok",
stderr: "",
code: 0,
signal: null,
killed: false,
});
const storeDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "warelay-session-"),
);
const storePath = path.join(storeDir, "sessions.json");
const cfg = {
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath },
},
},
};
await index.getReplyFromConfig(
{ Body: "/think:medium", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
await index.getReplyFromConfig(
{ Body: "hi there", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(runSpy).toHaveBeenCalledTimes(1);
const args = runSpy.mock.calls[0][0] as string[];
expect(args.join(" ")).toContain("hi there think harder");
});
it("confirms directive-only think level and skips command", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok",
@@ -644,7 +546,6 @@ describe("config and templating", () => {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
},
},
};
@@ -673,7 +574,6 @@ describe("config and templating", () => {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
},
},
};
@@ -705,7 +605,6 @@ describe("config and templating", () => {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath },
},
},
@@ -784,7 +683,6 @@ describe("config and templating", () => {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath },
},
},
@@ -802,56 +700,6 @@ describe("config and templating", () => {
expect(payloads[1]?.text).toBe("ok");
});
it("treats directive-only even when bracket prefixes are present", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok",
stderr: "",
code: 0,
signal: null,
killed: false,
});
const storeDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "warelay-session-"),
);
const storePath = path.join(storeDir, "sessions.json");
const cfg = {
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath },
},
},
};
const ack = await index.getReplyFromConfig(
{
Body: "[Dec 1 00:00] [🦞 same-phone] /think:high",
From: "+1",
To: "+2",
},
undefined,
cfg,
runSpy,
);
expect(runSpy).not.toHaveBeenCalled();
expect(ack?.text).toBe("Thinking level set to high.");
await index.getReplyFromConfig(
{ Body: "hello", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(runSpy).toHaveBeenCalledTimes(1);
const args = runSpy.mock.calls[0][0] as string[];
const bodyArg = args[args.length - 1];
expect(bodyArg).toBe("hello ultrathink");
});
it("treats verbose directive-only inside group batch context", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok",
@@ -869,7 +717,6 @@ describe("config and templating", () => {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath },
},
},
@@ -926,7 +773,6 @@ describe("config and templating", () => {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath },
},
},
@@ -970,7 +816,6 @@ describe("config and templating", () => {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath },
},
},
@@ -1026,7 +871,6 @@ describe("config and templating", () => {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath },
},
},
@@ -1064,7 +908,6 @@ describe("config and templating", () => {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath },
},
},
@@ -1095,7 +938,7 @@ describe("config and templating", () => {
});
it("uses global thinkingDefault when no directive or session override", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
stdout: "ok",
stderr: "",
code: 0,
@@ -1106,8 +949,8 @@ describe("config and templating", () => {
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
thinkingDefault: "low" as const,
},
},
@@ -1116,15 +959,15 @@ describe("config and templating", () => {
{ Body: "hello", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(runSpy).toHaveBeenCalled();
const args = runSpy.mock.calls[0][0] as string[];
expect(args[1]).toBe("hello think hard");
expect(rpcSpy).toHaveBeenCalled();
const args = rpcSpy.mock.calls[0][0].argv;
expect(args).toContain("--thinking");
expect(args).toContain("low");
});
it("accepts spaced directive form '/think high' and applies cue", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
it("accepts spaced directive form '/think high' and passes level to pi", async () => {
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
stdout: "ok",
stderr: "",
code: 0,
@@ -1135,8 +978,8 @@ describe("config and templating", () => {
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
},
},
};
@@ -1144,15 +987,16 @@ describe("config and templating", () => {
{ Body: "/think high hello world", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(runSpy).toHaveBeenCalled();
const args = runSpy.mock.calls[0][0] as string[];
expect(args[1]).toBe("hello world ultrathink");
expect(rpcSpy).toHaveBeenCalled();
const args = rpcSpy.mock.calls[0][0].argv;
expect(args).toContain("--thinking");
expect(args).toContain("high");
expect(rpcSpy.mock.calls[0][0].prompt).toBe("hello world");
});
it("accepts shorthand '/t:medium' and applies cue", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
it("accepts shorthand '/t:medium' and passes level to pi", async () => {
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
stdout: "ok",
stderr: "",
code: 0,
@@ -1163,8 +1007,8 @@ describe("config and templating", () => {
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
},
},
};
@@ -1172,11 +1016,12 @@ describe("config and templating", () => {
{ Body: "/t:medium greetings", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(runSpy).toHaveBeenCalled();
const args = runSpy.mock.calls[0][0] as string[];
expect(args[1]).toBe("greetings think harder");
expect(rpcSpy).toHaveBeenCalled();
const args = rpcSpy.mock.calls[0][0].argv;
expect(args).toContain("--thinking");
expect(args).toContain("medium");
expect(rpcSpy.mock.calls[0][0].prompt).toBe("greetings");
});
it("stores session thinking for pi and injects on next message", async () => {
@@ -1539,42 +1384,6 @@ describe("config and templating", () => {
expect(secondArgv[secondArgv.length - 1]).toBe("[sys] next");
});
it("stores session id returned by agent meta when it differs", async () => {
const tmpStore = path.join(
os.tmpdir(),
`warelay-store-${Date.now()}-sessionid.json`,
);
vi.spyOn(crypto, "randomUUID").mockReturnValue("initial-sid");
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: '{"text":"hi","session_id":"agent-sid-123"}\n',
stderr: "",
code: 0,
signal: null,
killed: false,
});
const cfg = {
inbound: {
reply: {
mode: "command" as const,
command: ["claude", "{{Body}}"],
agent: { kind: "claude", format: "json" as const },
session: { store: tmpStore },
},
},
};
await index.getReplyFromConfig(
{ Body: "/new hi", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
const persisted = JSON.parse(fs.readFileSync(tmpStore, "utf-8"));
const entry = Object.values(persisted)[0] as { sessionId?: string };
expect(entry.sessionId).toBe("agent-sid-123");
});
it("aborts command when stop word is received and skips command runner", async () => {
const tmpStore = path.join(
os.tmpdir(),
@@ -1735,105 +1544,6 @@ describe("config and templating", () => {
expect(onReplyStart.mock.calls.length).toBeGreaterThanOrEqual(3);
});
it("injects Claude output format + print flag when configured", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok",
stderr: "",
code: 0,
signal: null,
killed: false,
});
const cfg = {
inbound: {
reply: {
mode: "command" as const,
command: ["claude", "{{Body}}"],
agent: { kind: "claude", format: "text" as const },
},
},
};
await index.getReplyFromConfig(
{ Body: "hi", From: "+1555", To: "+1666" },
undefined,
cfg,
runSpy,
);
const argv = runSpy.mock.calls[0][0];
expect(argv[0]).toBe("claude");
expect(argv.at(-1)).toContain("You are Clawd (Claude)");
expect(argv.at(-1)).toContain("scratchpad");
expect(argv.at(-1)).toMatch(/hi$/);
// The helper should auto-add print and output format flags without disturbing the prompt position.
expect(argv.includes("-p") || argv.includes("--print")).toBe(true);
const outputIdx = argv.findIndex(
(part) =>
part === "--output-format" || part.startsWith("--output-format="),
);
expect(outputIdx).toBeGreaterThan(-1);
expect(argv[outputIdx + 1]).toBe("text");
});
it("parses Claude JSON output and returns text content", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: '{"text":"hello world"}\n',
stderr: "",
code: 0,
signal: null,
killed: false,
});
const cfg = {
inbound: {
reply: {
mode: "command" as const,
command: ["claude", "{{Body}}"],
agent: { kind: "claude", format: "json" as const },
},
},
};
const result = await index.getReplyFromConfig(
{ Body: "hi", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(result?.text).toBe("hello world");
});
it("parses Claude JSON output even without explicit claudeOutputFormat when using claude bin", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: '{"result":"Sure! What\'s up?"}\n',
stderr: "",
code: 0,
signal: null,
killed: false,
});
const cfg = {
inbound: {
reply: {
mode: "command" as const,
command: ["claude", "{{Body}}"],
agent: { kind: "claude" },
},
},
};
const result = await index.getReplyFromConfig(
{ Body: "hi", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(result?.text).toBe("Sure! What's up?");
const argv = runSpy.mock.calls[0][0];
expect(argv.at(-1)).toContain("You are Clawd (Claude)");
expect(argv.at(-1)).toContain("scratchpad");
});
it("serializes command auto-replies via the queue", async () => {
let active = 0;
let maxActive = 0;

View File

@@ -59,7 +59,7 @@ export async function runCommandWithTimeout(
: optionsOrTimeout;
const { timeoutMs, cwd, input } = options;
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
// Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed.
return await new Promise((resolve, reject) => {
const child = spawn(argv[0], argv.slice(1), {
stdio: [input ? "pipe" : "inherit", "pipe", "pipe"],