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 ## 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`). - 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`. - 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 ## Build, Test, and Development Commands
- Install deps: `pnpm install` - Install deps: `pnpm install`

View File

@@ -1,5 +1,14 @@
# Changelog # 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 ## 1.4.1 — 2025-12-04
### Changes ### Changes

View File

@@ -19,7 +19,7 @@
``` ```
┌─────────────┐ ┌──────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────┐ ┌─────────────┐
│ WhatsApp │ ───▶ │ CLAWDIS │ ───▶ │ AI Agent │ │ 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 ## Features
- 📱 **WhatsApp Integration** — Personal WhatsApp Web or Twilio - 📱 **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 - 💬 **Session Management** — Per-sender conversation context
- 🔔 **Heartbeats** — Periodic check-ins for proactive AI - 🔔 **Heartbeats** — Periodic check-ins for proactive AI
- 👥 **Group Chat Support** — Mention-based triggering - 👥 **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 - 🎤 **Voice Transcription** — Whisper integration
- 🔧 **Tool Streaming** — Real-time display (💻📄✍️📝) - 🔧 **Tool Streaming** — Real-time display (💻📄✍️📝)
Only the Pi/Tau CLI is supported now; legacy Claude/Codex/Gemini paths have been removed.
## Quick Start ## Quick Start
```bash ```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 🤖 # 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 (and only) agent for CLAWDIS. Built by Mario Zechner, forked with love.
The recommended agent for CLAWDIS. Built by Mario Zechner, forked with love.
```json ```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 - 💻 Real-time tool execution display
- 📊 Token usage tracking - 📊 Token usage tracking
- 🔄 Streaming responses - 🔄 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 ## Session Management
### Per-Sender Sessions ### 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 ### 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. 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. 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. 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. - **Verbose logging**: In `--verbose`, we log when transcription runs and when the transcript replaces the body.
## Config example (OpenAI Whisper CLI) ## Config example (OpenAI Whisper CLI)
@@ -29,7 +29,8 @@ Requires `OPENAI_API_KEY` in env and `openai` CLI installed:
}, },
reply: { reply: {
mode: "command", 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 # 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. - 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. - 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]`. - 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. - 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. - 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) # 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 ## 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. - Keep existing WhatsApp length guidance; forbid burying the sentinel inside alerts.
## Config & defaults ## Config & defaults
@@ -13,8 +13,8 @@ Goal: add a simple heartbeat poll for command-based auto-replies (Claude-driven)
## Poller behavior ## Poller behavior
- When relay runs with command-mode auto-reply, start a timer with the resolved heartbeat interval. - 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. - 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 Claude session. - 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. - Abort timer on SIGINT/abort of the relay.
## Sentinel handling ## Sentinel handling

View File

@@ -56,7 +56,7 @@ This document defines how `warelay` should handle sending and replying with imag
- Web inbox: - Web inbox:
- If `mediaUrl` present, fetch/resolve same as send (local path or URL), send via Baileys with caption. - 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: - For completeness: when inbound Twilio/Web messages include media, download to temp file, expose templating variables:
- `{{MediaUrl}}` original URL (Twilio) or pseudo-URL (web). - `{{MediaUrl}}` original URL (Twilio) or pseudo-URL (web).
- `{{MediaPath}}` local temp path written before running the command. - `{{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 ## Features
- 📱 **WhatsApp Integration** — Uses Baileys for WhatsApp Web protocol - 📱 **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 - 💬 **Session Management** — Maintains conversation context across messages
- 🔔 **Heartbeats** — Periodic check-ins so your AI doesn't feel lonely - 🔔 **Heartbeats** — Periodic check-ins so your AI doesn't feel lonely
- 👥 **Group Chat Support** — Mention-based triggering in group chats - 👥 **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 - 🎤 **Voice Messages** — Transcription via Whisper
- 🔧 **Tool Streaming** — Real-time display of AI tool usage (💻📄✍️📝) - 🔧 **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 ## The Name
**CLAWDIS** = CLAW + TARDIS **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. - 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 ## Application by agent
- **Pi/Tau**: injects `--thinking <level>` (skipped for `off`). - **Pi/Tau**: injects `--thinking <level>` (skipped for `off`). Other agent paths have been removed.
- **Claude & other text agents**: appends the cue word to the prompt text as above.
## Verbose directives (/verbose or /v) ## Verbose directives (/verbose or /v)
- Levels: `on|full` or `off` (default). - Levels: `on|full` or `off` (default).

View File

@@ -8,7 +8,7 @@
## Commands ## 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` — 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: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`. All helpers use the fixed session name `warelay-relay`.

View File

@@ -1,62 +1,39 @@
import { describe, expect, it } from "vitest"; 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"; import { piSpec } from "./pi.js";
describe("agent buildArgs + parseOutput helpers", () => { describe("pi agent helpers", () => {
it("claudeSpec injects flags and identity once", () => { it("buildArgs injects print/format flags and identity once", () => {
const argv = ["claude", "hi"]; const argv = ["pi", "hi"];
const built = claudeSpec.buildArgs({ const built = piSpec.buildArgs({
argv, argv,
bodyIndex: 1, bodyIndex: 1,
isNewSession: true, isNewSession: true,
sessionId: "sess", sessionId: "sess",
sendSystemOnce: false, sendSystemOnce: false,
systemSent: false, systemSent: false,
identityPrefix: undefined, identityPrefix: "IDENT",
format: "json", format: "json",
}); });
expect(built).toContain("--output-format");
expect(built).toContain("json");
expect(built).toContain("-p"); 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, argv,
bodyIndex: 1, bodyIndex: 1,
isNewSession: false, isNewSession: false,
sessionId: "sess", sessionId: "sess",
sendSystemOnce: true, sendSystemOnce: true,
systemSent: true, systemSent: true,
identityPrefix: undefined, identityPrefix: "IDENT",
format: "json", 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", () => { it("parses final assistant message and preserves usage meta", () => {
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", () => {
const stdout = [ const stdout = [
'{"type":"message_start","message":{"role":"assistant"}}', '{"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"}}', '{"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?.toolName).toBe("bash");
expect(tool?.meta).toBe("ls -la"); 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 { piSpec } from "./pi.js";
import type { AgentKind, AgentSpec } from "./types.js"; import type { AgentKind, AgentSpec } from "./types.js";
const specs: Record<AgentKind, AgentSpec> = { const specs: Record<AgentKind, AgentSpec> = {
claude: claudeSpec,
codex: codexSpec,
gemini: geminiSpec,
opencode: opencodeSpec,
pi: piSpec, 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) => { buildArgs: (ctx) => {
const argv = [...ctx.argv]; const argv = [...ctx.argv];
let bodyPos = ctx.bodyIndex;
// Non-interactive print + JSON // Non-interactive print + JSON
if (!argv.includes("-p") && !argv.includes("--print")) { if (!argv.includes("-p") && !argv.includes("--print")) {
argv.splice(argv.length - 1, 0, "-p"); argv.splice(bodyPos, 0, "-p");
bodyPos += 1;
} }
if ( if (
ctx.format === "json" && ctx.format === "json" &&
!argv.includes("--mode") && !argv.includes("--mode") &&
!argv.some((a) => a === "--mode") !argv.some((a) => a === "--mode")
) { ) {
argv.splice(argv.length - 1, 0, "--mode", "json"); argv.splice(bodyPos, 0, "--mode", "json");
bodyPos += 2;
} }
// Session defaults // Session defaults
// Identity prefix optional; Pi usually doesn't need it, but allow injection // Identity prefix optional; Pi usually doesn't need it, but allow injection
if (!(ctx.sendSystemOnce && ctx.systemSent) && argv[ctx.bodyIndex]) { if (!(ctx.sendSystemOnce && ctx.systemSent) && argv[bodyPos]) {
const existingBody = argv[ctx.bodyIndex]; const existingBody = argv[bodyPos];
argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody] argv[bodyPos] = [ctx.identityPrefix, existingBody]
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
} }

View File

@@ -1,4 +1,4 @@
export type AgentKind = "claude" | "opencode" | "pi" | "codex" | "gemini"; export type AgentKind = "pi";
export type AgentMeta = { export type AgentMeta = {
model?: string; 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 os from "node:os";
import path from "node:path"; 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 * as tauRpc from "../process/tau-rpc.js";
import type { ReplyPayload } from "./types.js"; import { runCommandReply } from "./command-reply.js";
const noopTemplateCtx = { const noopTemplateCtx = {
Body: "hello", Body: "hello",
@@ -14,27 +14,6 @@ const noopTemplateCtx = {
IsNewSession: "true", 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( const enqueueImmediate = vi.fn(
async <T>( async <T>(
task: () => Promise<T>, task: () => Promise<T>,
@@ -45,32 +24,36 @@ const enqueueImmediate = vi.fn(
}, },
); );
describe("summarizeClaudeMetadata", () => { function mockPiRpc(result: {
it("builds concise meta string", () => { stdout: string;
const meta = summarizeClaudeMetadata({ stderr?: string;
duration_ms: 1200, code: number;
num_turns: 3, signal?: NodeJS.Signals | null;
total_cost_usd: 0.012345, killed?: boolean;
usage: { server_tool_use: { a: 1, b: 2 } }, }) {
modelUsage: { "claude-3": 2, haiku: 1 }, return vi
}); .spyOn(tauRpc, "runPiRpc")
expect(meta).toContain("duration=1200ms"); .mockResolvedValue({ killed: false, signal: null, ...result });
expect(meta).toContain("turns=3"); }
expect(meta).toContain("cost=$0.0123");
expect(meta).toContain("tool_calls=3"); afterEach(() => {
expect(meta).toContain("models=claude-3,haiku"); vi.restoreAllMocks();
}); });
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,
}); });
describe("runCommandReply", () => {
it("injects claude flags and identity prefix", async () => {
const captures: ReplyPayload[] = [];
const runner = makeRunner({ stdout: "ok" }, captures);
const { payloads } = await runCommandReply({ const { payloads } = await runCommandReply({
reply: { reply: {
mode: "command", mode: "command",
command: ["claude", "{{Body}}"], command: ["pi", "{{Body}}"],
agent: { kind: "claude", format: "json" }, agent: { kind: "pi", format: "json" },
}, },
templatingCtx: noopTemplateCtx, templatingCtx: noopTemplateCtx,
sendSystemOnce: false, sendSystemOnce: false,
@@ -79,100 +62,37 @@ describe("runCommandReply", () => {
systemSent: false, systemSent: false,
timeoutMs: 1000, timeoutMs: 1000,
timeoutSeconds: 1, timeoutSeconds: 1,
commandRunner: runner, commandRunner: vi.fn(),
enqueue: enqueueImmediate, enqueue: enqueueImmediate,
thinkLevel: "medium",
}); });
const payload = payloads?.[0]; const payload = payloads?.[0];
expect(payload?.text).toBe("ok"); expect(payload?.text).toBe("ok");
const finalArgv = captures[0].argv as string[];
expect(finalArgv).toContain("--output-format"); const call = rpcMock.mock.calls[0]?.[0];
expect(finalArgv).toContain("json"); expect(call?.prompt).toBe("hello");
expect(finalArgv).toContain("-p"); expect(call?.argv).toContain("-p");
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)"); expect(call?.argv).toContain("--mode");
expect(call?.argv).toContain("rpc");
expect(call?.argv).toContain("--thinking");
expect(call?.argv).toContain("medium");
});
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,
}); });
it("omits identity prefix on resumed session when sendSystemOnce=true", async () => {
const captures: ReplyPayload[] = [];
const runner = makeRunner({ stdout: "ok" }, captures);
await runCommandReply({ await runCommandReply({
reply: { reply: {
mode: "command", mode: "command",
command: ["claude", "{{Body}}"], command: ["pi", "{{Body}}"],
agent: { kind: "claude", format: "json" }, agent: { kind: "pi" },
}, session: {},
templatingCtx: noopTemplateCtx,
sendSystemOnce: true,
isNewSession: false,
isFirstTurnInSession: false,
systemSent: true,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: runner,
enqueue: enqueueImmediate,
});
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}}"],
},
}, },
templatingCtx: { ...noopTemplateCtx, SessionId: "abc" }, templatingCtx: { ...noopTemplateCtx, SessionId: "abc" },
sendSystemOnce: true, sendSystemOnce: true,
@@ -181,23 +101,28 @@ describe("runCommandReply", () => {
systemSent: true, systemSent: true,
timeoutMs: 1000, timeoutMs: 1000,
timeoutSeconds: 1, timeoutSeconds: 1,
commandRunner: runner, commandRunner: vi.fn(),
enqueue: enqueueImmediate, enqueue: enqueueImmediate,
}); });
const argv = captures[0].argv as string[];
expect(argv).toContain("--resume"); const argv = rpcMock.mock.calls[0]?.[0]?.argv ?? [];
expect(argv).toContain("abc"); 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 () => { it("returns timeout text with partial snippet", async () => {
const runner = vi.fn(async () => { vi.spyOn(tauRpc, "runPiRpc").mockRejectedValue({
throw { stdout: "partial output here", killed: true, signal: "SIGKILL" }; stdout: "partial output here",
killed: true,
signal: "SIGKILL",
}); });
const { payloads, meta } = await runCommandReply({ const { payloads, meta } = await runCommandReply({
reply: { reply: {
mode: "command", mode: "command",
command: ["echo", "hi"], command: ["pi", "hi"],
agent: { kind: "claude" }, agent: { kind: "pi" },
}, },
templatingCtx: noopTemplateCtx, templatingCtx: noopTemplateCtx,
sendSystemOnce: false, sendSystemOnce: false,
@@ -206,53 +131,33 @@ describe("runCommandReply", () => {
systemSent: false, systemSent: false,
timeoutMs: 10, timeoutMs: 10,
timeoutSeconds: 1, timeoutSeconds: 1,
commandRunner: runner, commandRunner: vi.fn(),
enqueue: enqueueImmediate, enqueue: enqueueImmediate,
}); });
const payload = payloads?.[0]; const payload = payloads?.[0];
expect(payload?.text).toContain("Command timed out after 1s"); expect(payload?.text).toContain("Command timed out after 1s");
expect(payload?.text).toContain("partial output"); expect(payload?.text).toContain("partial output");
expect(meta.killed).toBe(true); 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 () => { it("parses MEDIA tokens and respects mediaMaxMb for local files", async () => {
const tmp = path.join(os.tmpdir(), `warelay-test-${Date.now()}.bin`); const tmp = path.join(os.tmpdir(), `warelay-test-${Date.now()}.bin`);
const bigBuffer = Buffer.alloc(2 * 1024 * 1024, 1); const bigBuffer = Buffer.alloc(2 * 1024 * 1024, 1);
await fs.writeFile(tmp, bigBuffer); await fs.writeFile(tmp, bigBuffer);
const runner = makeRunner({
mockPiRpc({
stdout: `hi\nMEDIA:${tmp}\nMEDIA:https://example.com/img.jpg`, stdout: `hi\nMEDIA:${tmp}\nMEDIA:https://example.com/img.jpg`,
stderr: "",
code: 0,
}); });
const { payloads } = await runCommandReply({ const { payloads } = await runCommandReply({
reply: { reply: {
mode: "command", mode: "command",
command: ["echo", "hi"], command: ["pi", "hi"],
mediaMaxMb: 1, mediaMaxMb: 1,
agent: { kind: "claude" }, agent: { kind: "pi" },
}, },
templatingCtx: noopTemplateCtx, templatingCtx: noopTemplateCtx,
sendSystemOnce: false, sendSystemOnce: false,
@@ -261,46 +166,28 @@ describe("runCommandReply", () => {
systemSent: false, systemSent: false,
timeoutMs: 1000, timeoutMs: 1000,
timeoutSeconds: 1, timeoutSeconds: 1,
commandRunner: runner, commandRunner: vi.fn(),
enqueue: enqueueImmediate, enqueue: enqueueImmediate,
}); });
const payload = payloads?.[0]; const payload = payloads?.[0];
expect(payload?.mediaUrls).toEqual(["https://example.com/img.jpg"]); expect(payload?.mediaUrls).toEqual(["https://example.com/img.jpg"]);
await fs.unlink(tmp); await fs.unlink(tmp);
}); });
it("emits Claude metadata", async () => { it("captures queue wait metrics and agent meta", async () => {
const runner = makeRunner({ mockPiRpc({
stdout: 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: "",
const { meta } = await runCommandReply({ code: 0,
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({ const { meta } = await runCommandReply({
reply: { reply: {
mode: "command", mode: "command",
command: ["echo", "{{Body}}"], command: ["pi", "{{Body}}"],
agent: { kind: "claude" }, agent: { kind: "pi" },
}, },
templatingCtx: noopTemplateCtx, templatingCtx: noopTemplateCtx,
sendSystemOnce: false, sendSystemOnce: false,
@@ -309,88 +196,12 @@ describe("runCommandReply", () => {
systemSent: false, systemSent: false,
timeoutMs: 100, timeoutMs: 100,
timeoutSeconds: 1, timeoutSeconds: 1,
commandRunner: runner, commandRunner: vi.fn(),
enqueue: enqueueImmediate, enqueue: enqueueImmediate,
}); });
expect(meta.queuedMs).toBe(25); expect(meta.queuedMs).toBe(25);
expect(meta.queuedAhead).toBe(2); expect(meta.queuedAhead).toBe(2);
}); expect((meta.agentMeta?.usage as { output?: number })?.output).toBe(5);
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");
}); });
}); });

View File

@@ -1,4 +1,5 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { type AgentKind, getAgentSpec } from "../agents/index.js"; import { type AgentKind, getAgentSpec } from "../agents/index.js";
@@ -203,75 +204,6 @@ function normalizeToolResults(
.filter((tr) => tr.text.length > 0); .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( export async function runCommandReply(
params: CommandReplyParams, params: CommandReplyParams,
): Promise<CommandReplyResult> { ): Promise<CommandReplyResult> {
@@ -300,11 +232,11 @@ export async function runCommandReply(
if (!reply.command?.length) { if (!reply.command?.length) {
throw new Error("reply.command is required for mode=command"); throw new Error("reply.command is required for mode=command");
} }
const agentCfg = reply.agent ?? { kind: "claude" }; const agentCfg = reply.agent ?? { kind: "pi" };
const agentKind: AgentKind = agentCfg.kind ?? "claude"; const agentKind: AgentKind = agentCfg.kind ?? "pi";
const agent = getAgentSpec(agentKind); const agent = getAgentSpec(agentKind);
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx)); let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
const isAgentInvocation = agent.isInvocation(argv);
const templatePrefix = const templatePrefix =
reply.template && (!sendSystemOnce || isFirstTurnInSession || !systemSent) reply.template && (!sendSystemOnce || isFirstTurnInSession || !systemSent)
? applyTemplate(reply.template, templatingCtx) ? applyTemplate(reply.template, templatingCtx)
@@ -318,23 +250,12 @@ export async function runCommandReply(
// Session args prepared (templated) and injected generically // Session args prepared (templated) and injected generically
if (reply.session) { if (reply.session) {
const defaultSessionArgs = (() => { const defaultSessionDir = path.join(os.homedir(), ".clawdis", "sessions");
switch (agentCfg.kind) { const sessionPath = path.join(defaultSessionDir, "{{SessionId}}.jsonl");
case "claude": const defaultSessionArgs = {
return { newArgs: ["--session", sessionPath],
newArgs: ["--session-id", "{{SessionId}}"], resumeArgs: ["--session", sessionPath],
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 defaultNew = defaultSessionArgs.newArgs; const defaultNew = defaultSessionArgs.newArgs;
const defaultResume = defaultSessionArgs.resumeArgs; const defaultResume = defaultSessionArgs.resumeArgs;
const sessionArgList = ( const sessionArgList = (
@@ -343,10 +264,24 @@ export async function runCommandReply(
: (reply.session.sessionArgResume ?? defaultResume) : (reply.session.sessionArgResume ?? defaultResume)
).map((p) => applyTemplate(p, templatingCtx)); ).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. // 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. // Without it, pi starts from a blank state even though we pass the session file path.
if ( if (
agentKind === "pi" && agentKind === "pi" &&
isAgentInvocation &&
!isNewSession && !isNewSession &&
!sessionArgList.includes("--continue") !sessionArgList.includes("--continue")
) { ) {
@@ -366,8 +301,9 @@ export async function runCommandReply(
} }
} }
if (thinkLevel && thinkLevel !== "off") { const shouldApplyAgent = isAgentInvocation;
if (agentKind === "pi") {
if (shouldApplyAgent && thinkLevel && thinkLevel !== "off") {
const hasThinkingFlag = argv.some( const hasThinkingFlag = argv.some(
(p, i) => (p, i) =>
p === "--thinking" || p === "--thinking" ||
@@ -378,13 +314,8 @@ export async function runCommandReply(
argv.splice(bodyIndex, 0, "--thinking", thinkLevel); argv.splice(bodyIndex, 0, "--thinking", thinkLevel);
bodyIndex += 2; bodyIndex += 2;
} }
} else if (argv[bodyIndex]) {
argv[bodyIndex] = appendThinkingCue(argv[bodyIndex] ?? "", thinkLevel);
} }
} const finalArgv = shouldApplyAgent
const shouldApplyAgent = agent.isInvocation(argv);
let finalArgv = shouldApplyAgent
? agent.buildArgs({ ? agent.buildArgs({
argv, argv,
bodyIndex, bodyIndex,
@@ -397,22 +328,6 @@ export async function runCommandReply(
}) })
: argv; : 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( logVerbose(
`Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`, `Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`,
); );
@@ -475,7 +390,7 @@ export async function runCommandReply(
const run = async () => { const run = async () => {
// Prefer long-lived tau RPC for pi agent to avoid cold starts. // 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 promptIndex = finalArgv.length - 1;
const body = finalArgv[promptIndex] ?? ""; const body = finalArgv[promptIndex] ?? "";
// Build rpc args without the prompt body; force --mode rpc. // Build rpc args without the prompt body; force --mode rpc.
@@ -601,7 +516,6 @@ export async function runCommandReply(
return await commandRunner(finalArgv, { return await commandRunner(finalArgv, {
timeoutMs, timeoutMs,
cwd: reply.cwd, cwd: reply.cwd,
input: rpcInput,
}); });
}; };
@@ -640,13 +554,16 @@ export async function runCommandReply(
); );
}; };
const parsed = trimmed ? agent.parseOutput(trimmed) : undefined; const parsed =
const parserProvided = !!parsed; shouldApplyAgent && trimmed ? agent.parseOutput(trimmed) : undefined;
const _parserProvided = shouldApplyAgent && !!parsed;
// Collect assistant texts and tool results from parseOutput (tau RPC can emit many). // Collect assistant texts and tool results from parseOutput (tau RPC can emit many).
const parsedTexts = const parsedTexts =
parsed?.texts?.map((t) => t.trim()).filter(Boolean) ?? []; parsed?.texts?.map((t) => t.trim()).filter(Boolean) ?? [];
const parsedToolResults = normalizeToolResults(parsed?.toolResults); const parsedToolResults = normalizeToolResults(parsed?.toolResults);
const hasParsedContent =
parsedTexts.length > 0 || parsedToolResults.length > 0;
type ReplyItem = { text: string; media?: string[] }; type ReplyItem = { text: string; media?: string[] };
const replyItems: ReplyItem[] = []; 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 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 } = const { text: cleanedText, mediaUrls: mediaFound } =
splitMediaFromOutput(trimmed); splitMediaFromOutput(trimmed);
if (cleanedText || mediaFound?.length) { 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 const mediaNote = ctx.MediaPath?.length
? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]` ? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]`
: undefined; : 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 = const mediaReplyHint =
mediaNote && reply?.mode === "command" 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." ? "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(); const program = buildProgram();
await program.parseAsync(["relay:tmux:attach"], { from: "user" }); await program.parseAsync(["relay:tmux:attach"], { from: "user" });
expect(spawnRelayTmux).toHaveBeenCalledWith( expect(spawnRelayTmux).toHaveBeenCalledWith(
"pnpm warelay relay --verbose", "pnpm clawdis relay --verbose",
true, true,
false, false,
); );
@@ -122,7 +122,7 @@ describe("cli program", () => {
await program.parseAsync(["relay:heartbeat:tmux"], { from: "user" }); await program.parseAsync(["relay:heartbeat:tmux"], { from: "user" });
const shouldAttach = Boolean(process.stdout.isTTY); const shouldAttach = Boolean(process.stdout.isTTY);
expect(spawnRelayTmux).toHaveBeenCalledWith( expect(spawnRelayTmux).toHaveBeenCalledWith(
"pnpm warelay relay --verbose --heartbeat-now", "pnpm clawdis relay --verbose --heartbeat-now",
shouldAttach, shouldAttach,
); );
}); });

View File

@@ -247,7 +247,7 @@ Examples:
.option("--to <number>", "Override target E.164; defaults to allowFrom[0]") .option("--to <number>", "Override target E.164; defaults to allowFrom[0]")
.option( .option(
"--session-id <id>", "--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( .option(
"--all", "--all",

View File

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

View File

@@ -157,13 +157,7 @@ const ReplySchema = z
heartbeatMinutes: z.number().int().nonnegative().optional(), heartbeatMinutes: z.number().int().nonnegative().optional(),
agent: z agent: z
.object({ .object({
kind: z.union([ kind: z.literal("pi"),
z.literal("claude"),
z.literal("opencode"),
z.literal("pi"),
z.literal("codex"),
z.literal("gemini"),
]),
format: z.union([z.literal("text"), z.literal("json")]).optional(), format: z.union([z.literal("text"), z.literal("json")]).optional(),
identityPrefix: z.string().optional(), identityPrefix: z.string().optional(),
}) })

View File

@@ -505,62 +505,6 @@ describe("config and templating", () => {
expect(rpcSpy.mock.calls[0][0].prompt).toBe("hello"); 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 () => { it("treats /think:off as no-op for pi (no --thinking injected)", async () => {
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
stdout: "ok", stdout: "ok",
@@ -589,48 +533,6 @@ describe("config and templating", () => {
expect(rpcSpy.mock.calls[0][0].prompt).toBe("hello"); 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 () => { it("confirms directive-only think level and skips command", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok", stdout: "ok",
@@ -644,7 +546,6 @@ describe("config and templating", () => {
reply: { reply: {
mode: "command" as const, mode: "command" as const,
command: ["echo", "{{Body}}"], command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
}, },
}, },
}; };
@@ -673,7 +574,6 @@ describe("config and templating", () => {
reply: { reply: {
mode: "command" as const, mode: "command" as const,
command: ["echo", "{{Body}}"], command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
}, },
}, },
}; };
@@ -705,7 +605,6 @@ describe("config and templating", () => {
reply: { reply: {
mode: "command" as const, mode: "command" as const,
command: ["echo", "{{Body}}"], command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath }, session: { store: storePath },
}, },
}, },
@@ -784,7 +683,6 @@ describe("config and templating", () => {
reply: { reply: {
mode: "command" as const, mode: "command" as const,
command: ["echo", "{{Body}}"], command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath }, session: { store: storePath },
}, },
}, },
@@ -802,56 +700,6 @@ describe("config and templating", () => {
expect(payloads[1]?.text).toBe("ok"); 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 () => { it("treats verbose directive-only inside group batch context", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok", stdout: "ok",
@@ -869,7 +717,6 @@ describe("config and templating", () => {
reply: { reply: {
mode: "command" as const, mode: "command" as const,
command: ["echo", "{{Body}}"], command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath }, session: { store: storePath },
}, },
}, },
@@ -926,7 +773,6 @@ describe("config and templating", () => {
reply: { reply: {
mode: "command" as const, mode: "command" as const,
command: ["echo", "{{Body}}"], command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath }, session: { store: storePath },
}, },
}, },
@@ -970,7 +816,6 @@ describe("config and templating", () => {
reply: { reply: {
mode: "command" as const, mode: "command" as const,
command: ["echo", "{{Body}}"], command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath }, session: { store: storePath },
}, },
}, },
@@ -1026,7 +871,6 @@ describe("config and templating", () => {
reply: { reply: {
mode: "command" as const, mode: "command" as const,
command: ["echo", "{{Body}}"], command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath }, session: { store: storePath },
}, },
}, },
@@ -1064,7 +908,6 @@ describe("config and templating", () => {
reply: { reply: {
mode: "command" as const, mode: "command" as const,
command: ["echo", "{{Body}}"], command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath }, session: { store: storePath },
}, },
}, },
@@ -1095,7 +938,7 @@ describe("config and templating", () => {
}); });
it("uses global thinkingDefault when no directive or session override", async () => { 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", stdout: "ok",
stderr: "", stderr: "",
code: 0, code: 0,
@@ -1106,8 +949,8 @@ describe("config and templating", () => {
inbound: { inbound: {
reply: { reply: {
mode: "command" as const, mode: "command" as const,
command: ["echo", "{{Body}}"], command: ["pi", "{{Body}}"],
agent: { kind: "claude" }, agent: { kind: "pi" },
thinkingDefault: "low" as const, thinkingDefault: "low" as const,
}, },
}, },
@@ -1116,15 +959,15 @@ describe("config and templating", () => {
{ Body: "hello", From: "+1", To: "+2" }, { Body: "hello", From: "+1", To: "+2" },
undefined, undefined,
cfg, cfg,
runSpy,
); );
expect(runSpy).toHaveBeenCalled(); expect(rpcSpy).toHaveBeenCalled();
const args = runSpy.mock.calls[0][0] as string[]; const args = rpcSpy.mock.calls[0][0].argv;
expect(args[1]).toBe("hello think hard"); expect(args).toContain("--thinking");
expect(args).toContain("low");
}); });
it("accepts spaced directive form '/think high' and applies cue", async () => { it("accepts spaced directive form '/think high' and passes level to pi", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
stdout: "ok", stdout: "ok",
stderr: "", stderr: "",
code: 0, code: 0,
@@ -1135,8 +978,8 @@ describe("config and templating", () => {
inbound: { inbound: {
reply: { reply: {
mode: "command" as const, mode: "command" as const,
command: ["echo", "{{Body}}"], command: ["pi", "{{Body}}"],
agent: { kind: "claude" }, agent: { kind: "pi" },
}, },
}, },
}; };
@@ -1144,15 +987,16 @@ describe("config and templating", () => {
{ Body: "/think high hello world", From: "+1", To: "+2" }, { Body: "/think high hello world", From: "+1", To: "+2" },
undefined, undefined,
cfg, cfg,
runSpy,
); );
expect(runSpy).toHaveBeenCalled(); expect(rpcSpy).toHaveBeenCalled();
const args = runSpy.mock.calls[0][0] as string[]; const args = rpcSpy.mock.calls[0][0].argv;
expect(args[1]).toBe("hello world ultrathink"); 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 () => { it("accepts shorthand '/t:medium' and passes level to pi", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
stdout: "ok", stdout: "ok",
stderr: "", stderr: "",
code: 0, code: 0,
@@ -1163,8 +1007,8 @@ describe("config and templating", () => {
inbound: { inbound: {
reply: { reply: {
mode: "command" as const, mode: "command" as const,
command: ["echo", "{{Body}}"], command: ["pi", "{{Body}}"],
agent: { kind: "claude" }, agent: { kind: "pi" },
}, },
}, },
}; };
@@ -1172,11 +1016,12 @@ describe("config and templating", () => {
{ Body: "/t:medium greetings", From: "+1", To: "+2" }, { Body: "/t:medium greetings", From: "+1", To: "+2" },
undefined, undefined,
cfg, cfg,
runSpy,
); );
expect(runSpy).toHaveBeenCalled(); expect(rpcSpy).toHaveBeenCalled();
const args = runSpy.mock.calls[0][0] as string[]; const args = rpcSpy.mock.calls[0][0].argv;
expect(args[1]).toBe("greetings think harder"); 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 () => { 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"); 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 () => { it("aborts command when stop word is received and skips command runner", async () => {
const tmpStore = path.join( const tmpStore = path.join(
os.tmpdir(), os.tmpdir(),
@@ -1735,105 +1544,6 @@ describe("config and templating", () => {
expect(onReplyStart.mock.calls.length).toBeGreaterThanOrEqual(3); 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 () => { it("serializes command auto-replies via the queue", async () => {
let active = 0; let active = 0;
let maxActive = 0; let maxActive = 0;

View File

@@ -59,7 +59,7 @@ export async function runCommandWithTimeout(
: optionsOrTimeout; : optionsOrTimeout;
const { timeoutMs, cwd, input } = options; 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) => { return await new Promise((resolve, reject) => {
const child = spawn(argv[0], argv.slice(1), { const child = spawn(argv[0], argv.slice(1), {
stdio: [input ? "pipe" : "inherit", "pipe", "pipe"], stdio: [input ? "pipe" : "inherit", "pipe", "pipe"],