fix: expand reply cwd (~) and document AGENTS

main
Peter Steinberger 2025-12-14 02:59:31 +00:00
parent 5ace7c9c66
commit 507f5623f4
8 changed files with 105 additions and 7 deletions

View File

@ -6,6 +6,37 @@ read_when:
--- ---
# AGENTS.md — Clawdis Personal Assistant (default) # AGENTS.md — Clawdis Personal Assistant (default)
## First run (recommended)
1) Create a dedicated workspace for your assistant (where it can read/write files):
```bash
mkdir -p ~/clawd
```
2) Copy this template to your workspace root as `AGENTS.md`:
```bash
cp docs/AGENTS.default.md ~/clawd/AGENTS.md
```
3) Point CLAWDIS at that workspace so Pi runs with the right context:
```json5
{
inbound: {
reply: {
cwd: "~/clawd"
}
}
}
```
## Safety defaults
- Dont dump directories or secrets into chat.
- Dont run destructive commands unless explicitly asked.
- Dont send partial/streaming replies to external messaging surfaces (only final replies).
## What Clawdis Does ## What Clawdis Does
- Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run tools via the host Mac. - Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run tools via the host Mac.
- macOS app manages permissions (screen recording, notifications, microphone) and exposes a CLI helper `clawdis-mac` for scripts. - macOS app manages permissions (screen recording, notifications, microphone) and exposes a CLI helper `clawdis-mac` for scripts.

View File

@ -17,6 +17,8 @@ If you dont configure `inbound.reply`, CLAWDIS uses the bundled Pi binary in
This is usually enough for a personal assistant setup; add `inbound.allowFrom` to restrict who can trigger it. This is usually enough for a personal assistant setup; add `inbound.allowFrom` to restrict who can trigger it.
If you keep an `AGENTS.md` (and optional “memory” files) for the agent, set `inbound.reply.cwd` to that workspace so Pi runs with the right context.
## Custom agent command (still Pi) ## Custom agent command (still Pi)
To override the agent command, configure `inbound.reply.mode: "command"`: To override the agent command, configure `inbound.reply.mode: "command"`:

View File

@ -88,6 +88,29 @@ clawdis webchat
Now message the assistant number from your allowlisted phone. Now message the assistant number from your allowlisted phone.
## Give the agent a workspace (AGENTS.md)
Pi (the bundled coding agent) will read operating instructions and “memory” from the current working directory. For a good first-run experience, create a dedicated workspace and drop an `AGENTS.md` there.
From the CLAWDIS repo:
```bash
mkdir -p ~/clawd
cp docs/AGENTS.default.md ~/clawd/AGENTS.md
```
Then set `inbound.reply.cwd` to that directory (supports `~`):
```json5
{
inbound: {
reply: {
cwd: "~/clawd"
}
}
}
```
## The config that turns it into “an assistant” ## The config that turns it into “an assistant”
CLAWDIS defaults to a good Pi setup even without `inbound.reply`, but youll usually want to tune: CLAWDIS defaults to a good Pi setup even without `inbound.reply`, but youll usually want to tune:
@ -110,6 +133,8 @@ Example:
mode: "command", mode: "command",
// Pi is bundled; CLAWDIS forces --mode rpc for Pi runs. // Pi is bundled; CLAWDIS forces --mode rpc for Pi runs.
command: ["pi", "--mode", "rpc", "{{BodyStripped}}"], command: ["pi", "--mode", "rpc", "{{BodyStripped}}"],
// Run the agent from your dedicated workspace (AGENTS.md, memory files, etc).
cwd: "~/clawd",
timeoutSeconds: 1800, timeoutSeconds: 1800,
bodyPrefix: "/think:high ", bodyPrefix: "/think:high ",
session: { session: {

View File

@ -81,6 +81,9 @@ Example command-mode config:
mode: "command", mode: "command",
// Example: run the bundled agent (Pi) in RPC mode // Example: run the bundled agent (Pi) in RPC mode
command: ["pi", "--mode", "rpc", "{{BodyStripped}}"], command: ["pi", "--mode", "rpc", "{{BodyStripped}}"],
// Optional: run the agent from a specific working directory (supports ~).
// Useful when you keep an AGENTS.md + memory files in a dedicated workspace.
cwd: "~/clawd",
timeoutSeconds: 1800, timeoutSeconds: 1800,
heartbeatMinutes: 30, heartbeatMinutes: 30,
// Optional: override the command used for heartbeat runs // Optional: override the command used for heartbeat runs
@ -103,6 +106,10 @@ Example command-mode config:
} }
``` ```
Notes:
- `inbound.reply.cwd` sets the working directory for the reply command (and Pi RPC). It supports `~` and is resolved to an absolute path.
- If you dont set it, the agent runs from the Gateways current directory (often not what you want for a “personal assistant” workspace).
### `browser` (clawd-managed Chrome) ### `browser` (clawd-managed Chrome)
Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server. Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server.

View File

@ -116,6 +116,7 @@ Example:
- Start here: - Start here:
- [Configuration](./configuration.md) - [Configuration](./configuration.md)
- [Clawd personal assistant setup](./clawd.md) - [Clawd personal assistant setup](./clawd.md)
- [AGENTS.md template (default)](./AGENTS.default.md)
- [Gateway runbook](./gateway.md) - [Gateway runbook](./gateway.md)
- [Discovery + transports](./discovery.md) - [Discovery + transports](./discovery.md)
- [Remote access](./remote.md) - [Remote access](./remote.md)

View File

@ -16,6 +16,7 @@ import { getChildLogger } from "../logging.js";
import { splitMediaFromOutput } from "../media/parse.js"; import { splitMediaFromOutput } from "../media/parse.js";
import { enqueueCommand } from "../process/command-queue.js"; import { enqueueCommand } from "../process/command-queue.js";
import { runPiRpc } from "../process/tau-rpc.js"; import { runPiRpc } from "../process/tau-rpc.js";
import { resolveUserPath } from "../utils.js";
import { applyTemplate, type TemplateContext } from "./templating.js"; import { applyTemplate, type TemplateContext } from "./templating.js";
import { import {
formatToolAggregate, formatToolAggregate,
@ -357,6 +358,11 @@ export async function runCommandReply(
onPartialReply, onPartialReply,
} = params; } = params;
const resolvedCwd =
typeof reply.cwd === "string" && reply.cwd.trim()
? resolveUserPath(reply.cwd)
: undefined;
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");
} }
@ -499,14 +505,14 @@ export async function runCommandReply(
} }
logVerbose( logVerbose(
`Running command auto-reply: ${rpcArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`, `Running command auto-reply: ${rpcArgv.join(" ")}${resolvedCwd ? ` (cwd: ${resolvedCwd})` : ""}`,
); );
logger.info( logger.info(
{ {
agent: agentKind, agent: agentKind,
sessionId: templatingCtx.SessionId, sessionId: templatingCtx.SessionId,
newSession: isNewSession, newSession: isNewSession,
cwd: reply.cwd, cwd: resolvedCwd,
command: rpcArgv.slice(0, -1), // omit body to reduce noise command: rpcArgv.slice(0, -1), // omit body to reduce noise
}, },
"command auto-reply start", "command auto-reply start",
@ -593,7 +599,7 @@ export async function runCommandReply(
const rpcResult = await runPiRpc({ const rpcResult = await runPiRpc({
argv: rpcArgvForRun, argv: rpcArgvForRun,
cwd: reply.cwd, cwd: resolvedCwd,
prompt: body, prompt: body,
timeoutMs, timeoutMs,
onEvent: (line: string) => { onEvent: (line: string) => {
@ -758,7 +764,7 @@ export async function runCommandReply(
signal: signalUsed, signal: signalUsed,
killed: killedUsed, killed: killedUsed,
argv: finalArgv, argv: finalArgv,
cwd: reply.cwd, cwd: resolvedCwd,
stdout: truncate(rawStdout), stdout: truncate(rawStdout),
stderr: truncate(stderrUsed), stderr: truncate(stderrUsed),
}, },
@ -888,7 +894,7 @@ export async function runCommandReply(
const elapsed = Date.now() - started; const elapsed = Date.now() - started;
verboseLog(`Command auto-reply finished in ${elapsed}ms`); verboseLog(`Command auto-reply finished in ${elapsed}ms`);
logger.info( logger.info(
{ durationMs: elapsed, agent: agentKind, cwd: reply.cwd }, { durationMs: elapsed, agent: agentKind, cwd: resolvedCwd },
"command auto-reply finished", "command auto-reply finished",
); );
if ((codeUsed ?? 0) !== 0) { if ((codeUsed ?? 0) !== 0) {
@ -995,7 +1001,7 @@ export async function runCommandReply(
} catch (err) { } catch (err) {
const elapsed = Date.now() - started; const elapsed = Date.now() - started;
logger.info( logger.info(
{ durationMs: elapsed, agent: agentKind, cwd: reply.cwd }, { durationMs: elapsed, agent: agentKind, cwd: resolvedCwd },
"command auto-reply failed", "command auto-reply failed",
); );
const anyErr = err as { killed?: boolean; signal?: string }; const anyErr = err as { killed?: boolean; signal?: string };
@ -1010,7 +1016,7 @@ export async function runCommandReply(
); );
const baseMsg = const baseMsg =
"Command timed out after " + "Command timed out after " +
`${timeoutSeconds}s${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}. Try a shorter prompt or split the request.`; `${timeoutSeconds}s${resolvedCwd ? ` (cwd: ${resolvedCwd})` : ""}. Try a shorter prompt or split the request.`;
const partial = const partial =
extractRpcAssistantText(errorObj.stdout ?? "") || extractRpcAssistantText(errorObj.stdout ?? "") ||
extractAssistantTextLoosely(errorObj.stdout ?? "") || extractAssistantTextLoosely(errorObj.stdout ?? "") ||

View File

@ -9,6 +9,7 @@ import {
jidToE164, jidToE164,
normalizeE164, normalizeE164,
normalizePath, normalizePath,
resolveUserPath,
sleep, sleep,
toWhatsappJid, toWhatsappJid,
withWhatsAppPrefix, withWhatsAppPrefix,
@ -85,3 +86,19 @@ describe("jidToE164", () => {
spy.mockRestore(); spy.mockRestore();
}); });
}); });
describe("resolveUserPath", () => {
it("expands ~ to home dir", () => {
expect(resolveUserPath("~")).toBe(path.resolve(os.homedir()));
});
it("expands ~/ to home dir", () => {
expect(resolveUserPath("~/clawd")).toBe(
path.resolve(os.homedir(), "clawd"),
);
});
it("resolves relative paths", () => {
expect(resolveUserPath("tmp/dir")).toBe(path.resolve("tmp/dir"));
});
});

View File

@ -71,5 +71,14 @@ export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
export function resolveUserPath(input: string): string {
const trimmed = input.trim();
if (!trimmed) return trimmed;
if (trimmed.startsWith("~")) {
return path.resolve(trimmed.replace("~", os.homedir()));
}
return path.resolve(trimmed);
}
// Fixed configuration root; legacy ~/.clawdis is no longer used. // Fixed configuration root; legacy ~/.clawdis is no longer used.
export const CONFIG_DIR = path.join(os.homedir(), ".clawdis"); export const CONFIG_DIR = path.join(os.homedir(), ".clawdis");