Compare commits

..
Author SHA1 Message Date
Peter Steinberger af8ab49f13 fix: satisfy discord and gateway typing 2026-01-03 02:52:04 +01:00
Peter Steinberger dcafc78024 docs: note onboarding scroll gutter 2026-01-03 02:47:56 +01:00
Peter Steinberger a1ebc99c7b fix: respect onboarding scroll indicator preference 2026-01-03 02:47:34 +01:00
Shadow ddc976004a macOS: add onboarding scroll gutter 2026-01-03 02:47:31 +01:00
Peter Steinberger 79403f9083 docs: update apple notes/reminders skill setup 2026-01-03 02:41:12 +01:00
Claude 7a44c19362 feat(skills): add Apple Notes and Reminders skills via memo CLI 2026-01-03 02:41:12 +01:00
Peter Steinberger 11fc10ea47 docs: thank contributor for telegram group gating 2026-01-03 02:34:48 +01:00
Peter Steinberger 7e4e9ecdea templates: add qmd semantic memory recall to AGENTS.md 2026-01-03 01:33:10 +00:00
Peter Steinberger 0c013a237f fix: default block streaming break to message_end 2026-01-03 01:33:10 +00:00
Peter Steinberger f85951bc65 docs: add changelog entry for gog calendar fix 2026-01-03 02:32:50 +01:00
Jared Verdi 12e27f9e5e Gateway: ack skipped hook transforms with 204 2026-01-03 02:32:50 +01:00
Jay Hickey 7e9be3c28c Update gog Calendar command date format to RFC3339
I am seeing instances where Clawdis is not including timezone in the gog calendar range requests. This results in a 400 bad request from the Google API, e.g.

```
gog calendar events primary --from 2026-01-02T00:00:00 --to 2026-01-03T23:59:59 --account <email>

Google API error (400 badRequest): Bad Request
```

While this is a valid ISO 8601 format, Google Calendar API requires a stricter RFC 3339 format like the following:

```
gog calendar events primary --from 2026-01-02T00:00:00Z --to 2026-01-03T23:59:59Z --account <email>

<calendar events listed successfully>
```
2026-01-03 02:30:32 +01:00
Peter Steinberger 3368fcf31e fix: avoid duplicate replies with block streaming 2026-01-03 02:16:01 +01:00
Peter Steinberger 32877afe55 docs: note self-chat config docs update 2026-01-03 02:05:37 +01:00
rafaelreis-randClaude Opus 4.5 efe7eca726 docs: clarify routing.allowFrom and self-chat mode for group mentions
- Add new section explaining self-chat mode for group control
- Document routing.allowFrom as the key setting for controlling metadata mentions
- Clarify difference between whatsapp.allowFrom (DM allowlist) and routing.allowFrom (self-chat mode)
- Explain metadata mentions vs text patterns in routing.groupChat
- Add example config for responding only to specific text triggers

When routing.allowFrom contains the bot's own number, WhatsApp native
@-mentions are ignored in groups, and only mentionPatterns trigger responses.
This prevents unwanted responses when users tap-to-mention the bot owner.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 02:05:37 +01:00
21 changed files with 499 additions and 151 deletions

No files matched your search

+5 -1
View File
@@ -7,7 +7,6 @@
- Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).
- Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).
- Tests: cover read tool image metadata + text output.
- Hooks: treat null transforms as handled skips (204) and let explicit mappings override presets (#117) — thanks @jverdi.
### Breaking
- Skills config schema moved under `skills.*`:
@@ -48,6 +47,7 @@
- CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.
- Skills: allow `bun` as a node manager for skill installs.
- Skills: add `things-mac` (Things 3 CLI) for read/search plus add/update via URL scheme.
- Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).
- Tests: add a Docker-based onboarding E2E harness.
- Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.
- Browser tools: add remote CDP URL support, Linux launcher options (`executablePath`, `noSandbox`), and surface `cdpUrl` in status.
@@ -55,8 +55,10 @@
### Fixes
- Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).
- macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
- Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.
- Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).
- Providers: make outbound text chunk limits configurable via `*.textChunkLimit` (defaults remain 4000/Discord 2000).
- CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.
- Control UI: accept a `?token=` URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.
@@ -103,12 +105,14 @@
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b
- macOS remote: route settings through gateway config and avoid local config reads in remote mode.
- Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).
- Telegram: honor per-group mention gating defaults/overrides via `telegram.groups` and `"*"` defaults (thanks @joshp123).
- Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl
- Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.
- Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.
- Cron: prevent `every` schedules without an anchor from firing in a tight loop (thanks @jamesgroat).
- Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock
- Docs/agent tools: clarify that browser `wait` should be avoided by default and used only in exceptional cases.
- Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.
- Browser tools: `upload` supports auto-click refs, direct `inputRef`/`element` file inputs, and emits input/change after `setFiles` so JS-heavy sites pick up attachments.
- Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.
- Browser CLI: add `clawdis browser reset-profile` to move the clawd profile to Trash when it gets wedged.
@@ -133,10 +133,16 @@ extension OnboardingView {
}
func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
VStack(spacing: 16) {
content()
Spacer()
let scrollIndicatorGutter: CGFloat = 18
return ScrollView {
VStack(spacing: 16) {
content()
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .top)
.padding(.trailing, scrollIndicatorGutter)
}
.scrollIndicators(.automatic)
.padding(.horizontal, 28)
.frame(width: self.pageWidth, alignment: .top)
}
@@ -442,7 +442,7 @@ extension OnboardingView {
}
func permissionsPage() -> some View {
self.onboardingPage {
return self.onboardingPage {
Text("Grant permissions")
.font(.largeTitle.weight(.semibold))
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
+1
View File
@@ -16,6 +16,7 @@ Key parameters:
- `yieldMs` (default 20000): auto‑background after this delay
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill the process after this timeout
- `stdinMode` (`pipe` | `pty`): use a real TTY when `pty` is requested and node-pty loads (otherwise warns + falls back)
- `workdir`, `env`
Behavior:
+49 -65
View File
@@ -10,11 +10,11 @@ CLAWDIS reads an optional **JSON5** config from `~/.clawdis/clawdis.json` (comme
If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to:
- restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.)
- tune group mention behavior (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`)
- control group mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `routing.groupChat`)
- customize message prefixes (`messages`)
- set the agent’s workspace (`agent.workspace`)
- set the agent's workspace (`agent.workspace`)
- tune the embedded agent (`agent`) and session behavior (`session`)
- set the agent’s identity (`identity`)
- set the agent's identity (`identity`)
## Minimal config (recommended starting point)
@@ -25,6 +25,26 @@ If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-
}
```
## Self-chat mode (recommended for group control)
To prevent the bot from responding to WhatsApp @-mentions in groups (only respond to specific text triggers):
```json5
{
agent: { workspace: "~/clawd" },
whatsapp: {
// Allowlist is DMs only; including your own number enables self-chat mode.
allowFrom: ["+15555550123"],
groups: { "*": { requireMention: true } }
},
routing: {
groupChat: {
mentionPatterns: ["@clawd", "reisponde"]
}
}
}
```
## Common options
### `identity`
@@ -78,7 +98,8 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
### `whatsapp.allowFrom`
Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies.
Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (DMs only).
If empty, the default allowlist is your own WhatsApp number (self-chat mode).
```json5
{
@@ -89,24 +110,13 @@ Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies.
}
```
### `whatsapp.groups`
Per-group mention gating for WhatsApp groups. Default group config lives at `whatsapp.groups."*"`.
```json5
{
whatsapp: {
groups: {
"*": { requireMention: true },
"123@g.us": { requireMention: false } // group JID
}
}
}
```
### `routing.groupChat`
Group mention patterns + history handling shared across surfaces (WhatsApp/iMessage/Telegram/Discord).
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats.
**Mention types:**
- **Metadata mentions**: Native platform @-mentions (e.g., WhatsApp tap-to-mention). Ignored in WhatsApp self-chat mode (see `whatsapp.allowFrom`).
- **Text patterns**: Regex patterns defined in `mentionPatterns`. Always checked regardless of self-chat mode.
```json5
{
@@ -118,8 +128,26 @@ Group mention patterns + history handling shared across surfaces (WhatsApp/iMess
}
}
```
Mention gating defaults live per provider (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`).
To respond **only** to specific text triggers (ignoring native @-mentions):
```json5
{
whatsapp: {
// Include your own number to enable self-chat mode (ignore native @-mentions).
allowFrom: ["+15555550123"],
groups: { "*": { requireMention: true } }
},
routing: {
groupChat: {
// Only these text patterns will trigger responses
mentionPatterns: ["reisponde", "@clawd"]
}
}
}
```
### `routing.queue`
Controls how inbound messages behave when an agent run is already active.
@@ -172,12 +200,7 @@ Set `telegram.enabled: false` to disable automatic startup.
telegram: {
enabled: true,
botToken: "your-bot-token",
textChunkLimit: 4000, // optional outbound chunk size (chars)
replyToMode: "off",
groups: {
"*": { requireMention: true },
"123456789": { requireMention: false } // group chat id
},
requireMention: true,
allowFrom: ["123456789"],
mediaMaxMb: 5,
proxy: "socks5://localhost:9050",
@@ -187,8 +210,6 @@ Set `telegram.enabled: false` to disable automatic startup.
}
}
```
Mention gating precedence (most specific wins): `telegram.groups.<chatId>.requireMention` → `telegram.groups."*".requireMention` → default `true`.
Reply threading is controlled via `telegram.replyToMode` (`off` | `first` | `all`) and reply tags in the model output.
### `discord` (bot transport)
@@ -199,10 +220,8 @@ Configure the Discord bot by setting the bot token and optional gating:
discord: {
enabled: true,
token: "your-bot-token",
textChunkLimit: 2000, // optional outbound chunk size (chars)
mediaMaxMb: 8, // clamp inbound media size
enableReactions: true, // allow agent-triggered reactions
replyToMode: "off", // off | first | all
slashCommand: { // user-installed app slash commands
enabled: true,
name: "clawd",
@@ -216,7 +235,6 @@ Configure the Discord bot by setting the bot token and optional gating:
groupChannels: ["clawd-dm"] // optional group DM allowlist
},
guilds: {
"*": { requireMention: true }, // default per-guild mention gating
"123456789012345678": { // guild id (preferred) or slug
slug: "friends-of-clawd",
requireMention: false, // per-guild default
@@ -233,23 +251,7 @@ Configure the Discord bot by setting the bot token and optional gating:
```
Clawdis starts Discord only when a `discord` config section exists. The token is resolved from `DISCORD_BOT_TOKEN` or `discord.token` (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands.
Reply threading is controlled via `discord.replyToMode` (`off` | `first` | `all`) and reply tags in the model output.
Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity.
Use `discord.guilds."*"` for default per-guild settings.
### `signal` (signal-cli JSON-RPC)
Clawdis can send/receive Signal via `signal-cli` (daemon or existing HTTP URL).
```json5
{
signal: {
enabled: true,
textChunkLimit: 4000, // optional outbound chunk size (chars)
mediaMaxMb: 8
}
}
```
### `imessage` (imsg CLI)
@@ -261,12 +263,7 @@ Clawdis spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
enabled: true,
cliPath: "imsg",
dbPath: "~/Library/Messages/chat.db",
textChunkLimit: 4000, // optional outbound chunk size (chars)
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
groups: {
"*": { requireMention: true },
"123": { requireMention: false } // chat_id for the group
},
includeAttachments: false,
mediaMaxMb: 16,
service: "auto",
@@ -279,7 +276,6 @@ Notes:
- Requires Full Disk Access to the Messages DB.
- The first send will prompt for Messages automation permission.
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
- Group mention gating lives in `imessage.groups` (default at `imessage.groups."*"`).
### `agent.workspace`
@@ -296,7 +292,6 @@ Default: `~/clawd`.
### `messages`
Controls inbound/outbound prefixes and timestamps.
Outbound text chunking is configured per provider via `*.textChunkLimit` (e.g. `whatsapp.textChunkLimit`, `telegram.textChunkLimit`).
```json5
{
@@ -351,8 +346,6 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
},
thinkingDefault: "low",
verboseDefault: "off",
blockStreamingDefault: "on",
blockStreamingBreak: "text_end",
timeoutSeconds: 600,
mediaMaxMb: 5,
heartbeat: {
@@ -377,14 +370,6 @@ deprecation fallback.
Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
`ZAI_API_KEY` (or legacy `Z_AI_API_KEY`) in the environment.
`agent.blockStreamingDefault` controls whether completed assistant blocks are
sent immediately (default: `on`). Set to `off` to only deliver the final
consolidated reply.
`agent.blockStreamingBreak` controls what “block” means:
- `text_end` (default): end of each assistant text content block (before tool calls)
- `message_end`: end of the whole assistant message (may wait across tools)
`agent.heartbeat` configures periodic heartbeat runs:
- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Omit or set
`0m` to disable.
@@ -701,7 +686,6 @@ Endpoints:
Mapping notes:
- `match.path` matches the sub-path after `/hooks` (e.g. `/hooks/gmail` → `gmail`).
- `match.source` matches a payload field (e.g. `{ source: "gmail" }`) so you can use a generic `/hooks/ingest` path.
- `hooks.mappings` are evaluated before presets; first match wins.
- Templates like `{{messages[0].subject}}` read from the payload.
- `transform` can point to a JS/TS module that returns a hook action.
+10
View File
@@ -28,6 +28,16 @@ You wake up fresh each session. These files are your continuity:
Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them.
### 🧠 Memory Recall - Use qmd!
When you need to remember something from the past, use `qmd` instead of grepping files:
```bash
qmd query "what happened at Christmas" # Semantic search with reranking
qmd search "specific phrase" # BM25 keyword search
qmd vsearch "conceptual question" # Pure vector similarity
```
Index your memory folder: `qmd index memory/`
Vectors + BM25 + reranking finds things even with different wording.
## Safety
- Don't exfiltrate private data. Ever.
+1
View File
@@ -21,6 +21,7 @@ Core parameters:
- `yieldMs` (auto-background after timeout, default 20000)
- `background` (immediate background)
- `timeout` (seconds; kills the process if exceeded, default 1800)
- `stdinMode` (`pipe` | `pty`; `pty` uses node-pty for a real TTY with fallback warning)
Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded.
-1
View File
@@ -89,7 +89,6 @@ code transforms.
Mapping options (summary):
- `hooks.presets: ["gmail"]` enables the built-in Gmail mapping.
- `hooks.mappings` lets you define `match`, `action`, and templates in config.
- `hooks.mappings` are evaluated before presets; first match wins.
- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic.
- Use `match.source` to keep a generic ingest endpoint (payload-driven routing).
- TS transforms require a TS loader (e.g. `tsx`) or precompiled `.js` at runtime.
+1
View File
@@ -92,6 +92,7 @@
"grammy": "^1.39.2",
"json5": "^2.2.3",
"long": "5.3.2",
"node-pty": "^1.1.0",
"playwright-core": "1.57.0",
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
+15
View File
@@ -88,6 +88,9 @@ importers:
long:
specifier: 5.3.2
version: 5.3.2
node-pty:
specifier: ^1.1.0
version: 1.1.0
playwright-core:
specifier: 1.57.0
version: 1.57.0
@@ -2158,6 +2161,9 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -2176,6 +2182,9 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
node-wav@0.0.2:
resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==}
engines: {node: '>=4.4.0'}
@@ -4804,6 +4813,8 @@ snapshots:
negotiator@1.0.0: {}
node-addon-api@7.1.1: {}
node-domexception@1.0.0: {}
node-fetch@2.7.0:
@@ -4816,6 +4827,10 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
node-pty@1.1.0:
dependencies:
node-addon-api: 7.1.1
node-wav@0.0.2:
optional: true
+50
View File
@@ -0,0 +1,50 @@
---
name: apple-notes
description: Manage Apple Notes via the `memo` CLI on macOS (create, view, edit, delete, search, move, and export notes). Use when a user asks Clawdis to add a note, list notes, search notes, or manage note folders.
homepage: https://github.com/antoniorodr/memo
metadata: {"clawdis":{"emoji":"📝","os":["darwin"],"requires":{"bins":["memo"]},"install":[{"id":"brew","kind":"brew","formula":"antoniorodr/memo/memo","bins":["memo"],"label":"Install memo via Homebrew"}]}}
---
# Apple Notes CLI
Use `memo notes` to manage Apple Notes directly from the terminal. Create, view, edit, delete, search, move notes between folders, and export to HTML/Markdown.
Setup
- Install (Homebrew): `brew tap antoniorodr/memo && brew install antoniorodr/memo/memo`
- Manual (pip): `pip install .` (after cloning the repo)
- macOS-only; if prompted, grant Automation access to Notes.app.
View Notes
- List all notes: `memo notes`
- Filter by folder: `memo notes -f "Folder Name"`
- Search notes (fuzzy): `memo notes -s "query"`
Create Notes
- Add a new note: `memo notes -a`
- Opens an interactive editor to compose the note.
- Quick add with title: `memo notes -a "Note Title"`
Edit Notes
- Edit existing note: `memo notes -e`
- Interactive selection of note to edit.
Delete Notes
- Delete a note: `memo notes -d`
- Interactive selection of note to delete.
Move Notes
- Move note to folder: `memo notes -m`
- Interactive selection of note and destination folder.
Export Notes
- Export to HTML/Markdown: `memo notes -ex`
- Exports selected note; uses Mistune for markdown processing.
Limitations
- Cannot edit notes containing images or attachments.
- Interactive prompts may require terminal access.
Notes
- macOS-only.
- Requires Apple Notes.app to be accessible.
- For automation, grant permissions in System Settings > Privacy & Security > Automation.
+43
View File
@@ -0,0 +1,43 @@
---
name: apple-reminders
description: Manage Apple Reminders via the `memo` CLI on macOS (create, complete, and delete reminders). Use when a user asks Clawdis to add a reminder, mark reminders as done, or manage their reminder list.
homepage: https://github.com/antoniorodr/memo
metadata: {"clawdis":{"emoji":"⏰","os":["darwin"],"requires":{"bins":["memo"]},"install":[{"id":"brew","kind":"brew","formula":"antoniorodr/memo/memo","bins":["memo"],"label":"Install memo via Homebrew"}]}}
---
# Apple Reminders CLI
Use `memo rem` to manage Apple Reminders directly from the terminal. Create, complete, and delete reminders with simple commands.
Setup
- Install (Homebrew): `brew tap antoniorodr/memo && brew install antoniorodr/memo/memo`
- Manual (pip): `pip install .` (after cloning the repo)
- macOS-only; if prompted, grant Automation access to Reminders.app.
View Reminders
- List all reminders: `memo rem`
Create Reminders
- Add a new reminder: `memo rem -a`
- Opens interactive prompt to create a reminder.
- Quick add: `memo rem -a "Buy groceries"`
Complete Reminders
- Mark reminder as done: `memo rem -c`
- Interactive selection of reminder to complete.
Delete Reminders
- Delete a reminder: `memo rem -d`
- Interactive selection of reminder to delete.
Examples
- Add a quick reminder: `memo rem -a "Call dentist tomorrow"`
- List all reminders: `memo rem`
- Complete a reminder: `memo rem -c` (then select from list)
- Delete a reminder: `memo rem -d` (then select from list)
Notes
- macOS-only.
- Requires Apple Reminders.app to be accessible.
- For automation, grant permissions in System Settings > Privacy & Security > Automation.
- The `memo` CLI shares the same installation for both Notes and Reminders functionality.
+1 -1
View File
@@ -17,7 +17,7 @@ Setup (once)
Common commands
- Gmail search: `gog gmail search 'newer_than:7d' --max 10`
- Gmail send: `gog gmail send --to a@b.com --subject "Hi" --body "Hello"`
- Calendar: `gog calendar events <calendarId> --from <iso> --to <iso>`
- Calendar: `gog calendar events <calendarId> --from <rfc3339> --to <rfc3339>`
- Drive: `gog drive search "query" --max 10`
- Contacts: `gog contacts list --max 20`
+2
View File
@@ -20,6 +20,7 @@ describe("bash process registry", () => {
id: "sess",
command: "echo test",
child: { pid: 123 } as ChildProcessWithoutNullStreams,
stdinMode: "pipe",
startedAt: Date.now(),
cwd: "/tmp",
maxOutputChars: 10,
@@ -48,6 +49,7 @@ describe("bash process registry", () => {
id: "sess",
command: "echo test",
child: { pid: 123 } as ChildProcessWithoutNullStreams,
stdinMode: "pipe",
startedAt: Date.now(),
cwd: "/tmp",
maxOutputChars: 100,
+7 -1
View File
@@ -1,4 +1,5 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { IPty } from "node-pty";
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute
@@ -15,10 +16,15 @@ let jobTtlMs = clampTtl(
export type ProcessStatus = "running" | "completed" | "failed" | "killed";
export type ProcessStdinMode = "pipe" | "pty";
export interface ProcessSession {
id: string;
command: string;
child: ChildProcessWithoutNullStreams;
child?: ChildProcessWithoutNullStreams;
pty?: IPty;
pid?: number;
stdinMode: ProcessStdinMode;
startedAt: number;
cwd?: string;
maxOutputChars: number;
+179 -65
View File
@@ -2,6 +2,7 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import type { IPty } from "node-pty";
import {
addSession,
@@ -14,6 +15,7 @@ import {
listRunningSessions,
markBackgrounded,
markExited,
type ProcessStdinMode,
setJobTtlMs,
} from "./bash-process-registry.js";
import {
@@ -29,6 +31,19 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
1_000,
150_000,
);
const DEFAULT_PTY_NAME = "xterm-256color";
type PtyModule = typeof import("node-pty");
let ptyModulePromise: Promise<PtyModule | null> | null = null;
async function loadPtyModule(): Promise<PtyModule | null> {
if (!ptyModulePromise) {
ptyModulePromise = import("node-pty")
.then((mod) => mod)
.catch(() => null);
}
return ptyModulePromise;
}
const stringEnum = (
values: readonly string[],
@@ -72,7 +87,7 @@ const bashSchema = Type.Object({
),
stdinMode: Type.Optional(
stringEnum(["pipe", "pty"] as const, {
description: "Only pipe is supported",
description: "stdin mode (pipe or pty when node-pty is available)",
}),
),
});
@@ -127,9 +142,6 @@ export function createBashTool(
if (!params.command) {
throw new Error("Provide a command to start.");
}
if (params.stdinMode && params.stdinMode !== "pipe") {
throw new Error('Only stdinMode "pipe" is supported right now.');
}
const yieldWindow = params.background
? 0
@@ -146,21 +158,56 @@ export function createBashTool(
const { shell, args: shellArgs } = getShellConfig();
const env = params.env ? { ...process.env, ...params.env } : process.env;
const child: ChildProcessWithoutNullStreams = spawn(
shell,
[...shellArgs, params.command],
{
const requestedStdinMode =
params.stdinMode === "pty" ? "pty" : "pipe";
let stdinMode: ProcessStdinMode = requestedStdinMode;
let warning: string | null = null;
let child: ChildProcessWithoutNullStreams | undefined;
let pty: IPty | undefined;
if (stdinMode === "pty") {
const ptyModule = await loadPtyModule();
if (!ptyModule) {
warning =
"Warning: node-pty failed to load; falling back to pipe mode.";
stdinMode = "pipe";
} else {
const ptyEnv = {
...env,
TERM: env.TERM ?? DEFAULT_PTY_NAME,
} as Record<string, string>;
try {
pty = ptyModule.spawn(shell, [...shellArgs, params.command], {
cwd: workdir,
env: ptyEnv,
name: ptyEnv.TERM || DEFAULT_PTY_NAME,
cols: 120,
rows: 30,
});
} catch {
warning =
"Warning: node-pty failed to start; falling back to pipe mode.";
stdinMode = "pipe";
}
}
}
if (stdinMode === "pipe") {
child = spawn(shell, [...shellArgs, params.command], {
cwd: workdir,
env,
detached: true,
stdio: ["pipe", "pipe", "pipe"],
},
);
});
}
const session = {
id: sessionId,
command: params.command,
child,
pty,
pid: child?.pid ?? pty?.pid,
stdinMode,
startedAt,
cwd: workdir,
maxOutputChars: maxOutput,
@@ -190,9 +237,7 @@ export function createBashTool(
};
const onAbort = () => {
if (child.pid) {
killProcessTree(child.pid);
}
killSession(session);
};
if (signal?.aborted) onAbort();
@@ -212,33 +257,46 @@ export function createBashTool(
const emitUpdate = () => {
if (!onUpdate) return;
const tailText = session.tail || session.aggregated;
const warningText = warning ? `${warning}\n\n` : "";
onUpdate({
content: [{ type: "text", text: tailText || "" }],
content: [{ type: "text", text: warningText + (tailText || "") }],
details: {
status: "running",
sessionId,
pid: child.pid ?? undefined,
pid: session.pid ?? undefined,
startedAt,
tail: session.tail,
},
});
};
child.stdout.on("data", (data) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stdout", chunk);
emitUpdate();
}
});
if (child) {
child.stdout.on("data", (data) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stdout", chunk);
emitUpdate();
}
});
child.stderr.on("data", (data) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stderr", chunk);
emitUpdate();
}
});
child.stderr.on("data", (data) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stderr", chunk);
emitUpdate();
}
});
}
if (pty) {
pty.onData((data) => {
const str = sanitizeBinaryOutput(data);
for (const chunk of chunkString(str)) {
appendOutput(session, "stdout", chunk);
emitUpdate();
}
});
}
return new Promise<AgentToolResult<BashToolDetails>>(
(resolve, reject) => {
@@ -249,14 +307,15 @@ export function createBashTool(
{
type: "text",
text:
`Command still running (session ${sessionId}, pid ${child.pid ?? "n/a"}). ` +
`${warning ? `${warning}\n\n` : ""}` +
`Command still running (session ${sessionId}, pid ${session.pid ?? "n/a"}). ` +
"Use process (list/poll/log/write/kill/clear/remove) for follow-up.",
},
],
details: {
status: "running",
sessionId,
pid: child.pid ?? undefined,
pid: session.pid ?? undefined,
startedAt,
tail: session.tail,
},
@@ -283,7 +342,10 @@ export function createBashTool(
}, yieldWindow);
}
child.once("exit", (code, exitSignal) => {
const handleExit = (
code: number | null,
exitSignal: NodeJS.Signals | number | null,
) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
const durationMs = Date.now() - startedAt;
@@ -315,7 +377,14 @@ export function createBashTool(
settle(() =>
resolve({
content: [{ type: "text", text: aggregated || "(no output)" }],
content: [
{
type: "text",
text:
`${warning ? `${warning}\n\n` : ""}` +
(aggregated || "(no output)"),
},
],
details: {
status: "completed",
exitCode: code ?? 0,
@@ -324,14 +393,26 @@ export function createBashTool(
},
}),
);
});
};
child.once("error", (err) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
markExited(session, null, null, "failed");
settle(() => reject(err));
});
if (child) {
child.once("exit", (code, exitSignal) => {
handleExit(code, exitSignal);
});
child.once("error", (err) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
markExited(session, null, null, "failed");
settle(() => reject(err));
});
}
if (pty) {
pty.onExit(({ exitCode, signal }) => {
handleExit(exitCode ?? null, signal ?? null);
});
}
},
);
},
@@ -383,7 +464,7 @@ export function createProcessTool(
const running = listRunningSessions().map((s) => ({
sessionId: s.id,
status: "running",
pid: s.child.pid ?? undefined,
pid: s.pid ?? undefined,
startedAt: s.startedAt,
runtimeMs: Date.now() - s.startedAt,
cwd: s.cwd,
@@ -627,25 +708,43 @@ export function createProcessTool(
details: { status: "failed" },
};
}
if (!session.child.stdin || session.child.stdin.destroyed) {
return {
content: [
{
type: "text",
text: `Session ${params.sessionId} stdin is not writable.`,
},
],
details: { status: "failed" },
};
}
await new Promise<void>((resolve, reject) => {
session.child.stdin.write(params.data ?? "", (err) => {
if (err) reject(err);
else resolve();
if (session.stdinMode === "pty") {
if (!session.pty || session.exited) {
return {
content: [
{
type: "text",
text: `Session ${params.sessionId} stdin is not writable.`,
},
],
details: { status: "failed" },
};
}
session.pty.write(params.data ?? "");
if (params.eof) {
session.pty.write("\x04");
}
} else {
if (!session.child?.stdin || session.child.stdin.destroyed) {
return {
content: [
{
type: "text",
text: `Session ${params.sessionId} stdin is not writable.`,
},
],
details: { status: "failed" },
};
}
await new Promise<void>((resolve, reject) => {
session.child?.stdin.write(params.data ?? "", (err) => {
if (err) reject(err);
else resolve();
});
});
});
if (params.eof) {
session.child.stdin.end();
if (params.eof) {
session.child.stdin.end();
}
}
return {
content: [
@@ -687,9 +786,7 @@ export function createProcessTool(
details: { status: "failed" },
};
}
if (session.child.pid) {
killProcessTree(session.child.pid);
}
killSession(session);
markExited(session, null, "SIGKILL", "failed");
return {
content: [
@@ -725,9 +822,7 @@ export function createProcessTool(
case "remove": {
if (session) {
if (session.child.pid) {
killProcessTree(session.child.pid);
}
killSession(session);
markExited(session, null, "SIGKILL", "failed");
return {
content: [
@@ -772,6 +867,25 @@ export function createProcessTool(
export const processTool = createProcessTool();
function killSession(session: {
pid?: number;
stdinMode: ProcessStdinMode;
pty?: IPty;
child?: ChildProcessWithoutNullStreams;
}) {
const pid = session.pid ?? session.child?.pid ?? session.pty?.pid;
if (pid) {
killProcessTree(pid);
}
if (session.stdinMode === "pty") {
try {
session.pty?.kill();
} catch {
// ignore kill failures
}
}
}
function clampNumber(
value: number | undefined,
defaultValue: number,
@@ -0,0 +1,103 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
}));
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(),
}));
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-stream-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
return await fn(base);
} finally {
process.env.HOME = previousHome;
await fs.rm(base, { recursive: true, force: true });
}
}
describe("block streaming", () => {
beforeEach(() => {
vi.mocked(runEmbeddedPiAgent).mockReset();
vi.mocked(loadModelCatalog).mockResolvedValue([
{ id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
]);
});
async function waitForCalls(fn: () => number, calls: number) {
const deadline = Date.now() + 1500;
while (fn() < calls) {
if (Date.now() > deadline) {
throw new Error(`Expected ${calls} call(s), got ${fn()}`);
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
it("waits for block replies before returning final payloads", async () => {
await withTempHome(async (home) => {
let releaseTyping: (() => void) | undefined;
const typingGate = new Promise<void>((resolve) => {
releaseTyping = resolve;
});
const onReplyStart = vi.fn(() => typingGate);
const onBlockReply = vi.fn().mockResolvedValue(undefined);
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => {
void params.onBlockReply?.({ text: "hello" });
return {
payloads: [{ text: "hello" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
};
});
const replyPromise = getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-123",
Surface: "discord",
},
{
onReplyStart,
onBlockReply,
},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
},
);
await waitForCalls(() => onReplyStart.mock.calls.length, 1);
releaseTyping?.();
const res = await replyPromise;
expect(res).toBeUndefined();
expect(onBlockReply).toHaveBeenCalledTimes(1);
});
});
});
+10 -6
View File
@@ -604,10 +604,11 @@ export async function getReplyFromConfig(
(agentCfg?.verboseDefault as VerboseLevel | undefined);
const resolvedBlockStreaming =
agentCfg?.blockStreamingDefault === "off" ? "off" : "on";
// TODO(steipete): Default to message_end for now; figure out why text_end breaks and whether we can revert.
const resolvedBlockStreamingBreak =
agentCfg?.blockStreamingBreak === "message_end"
? "message_end"
: "text_end";
agentCfg?.blockStreamingBreak === "text_end"
? "text_end"
: "message_end";
const blockStreamingEnabled = resolvedBlockStreaming === "on";
const streamedPayloadKeys = new Set<string>();
const pendingBlockTasks = new Set<Promise<void>>();
@@ -1421,16 +1422,19 @@ export async function getReplyFromConfig(
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
if (!cleaned && !hasMedia) return;
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia) return;
await startTypingOnText(cleaned);
const blockPayload: ReplyPayload = {
text: cleaned,
mediaUrls: payload.mediaUrls,
mediaUrl: payload.mediaUrls?.[0],
replyToId: tagResult.replyToId,
};
const task = Promise.resolve(opts.onBlockReply?.(blockPayload))
const payloadKey = buildPayloadKey(blockPayload);
const task = (async () => {
await startTypingOnText(cleaned);
await opts.onBlockReply?.(blockPayload);
})()
.then(() => {
streamedPayloadKeys.add(buildPayloadKey(blockPayload));
streamedPayloadKeys.add(payloadKey);
})
.catch((err) => {
logVerbose(`block reply delivery failed: ${String(err)}`);
+8 -3
View File
@@ -396,6 +396,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
MediaType: media?.contentType,
MediaUrl: media?.path,
};
const replyTarget = ctxPayload.To ?? undefined;
if (!replyTarget) {
runtime.error?.(danger("discord: missing reply target"));
return;
}
if (isDirectMessage) {
const sessionCfg = cfg.session;
@@ -430,7 +435,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
.then(async () => {
await deliverReplies({
replies: [payload],
target: ctxPayload.To,
target: replyTarget,
token,
runtime,
replyToMode,
@@ -473,7 +478,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
await deliverReplies({
replies,
target: ctxPayload.To,
target: replyTarget,
token,
runtime,
replyToMode,
@@ -482,7 +487,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
didSendReply = true;
if (isVerbose()) {
logVerbose(
`discord: delivered ${replies.length} reply${replies.length === 1 ? "" : "ies"} to ${ctxPayload.To}`,
`discord: delivered ${replies.length} reply${replies.length === 1 ? "" : "ies"} to ${replyTarget}`,
);
}
if (
+3 -3
View File
@@ -27,6 +27,7 @@ export async function callGateway<T = unknown>(
const config = loadConfig();
const isRemoteMode = config.gateway?.mode === "remote";
const remote = isRemoteMode ? config.gateway?.remote : undefined;
const authToken = config.gateway?.auth?.token;
const url =
(typeof opts.url === "string" && opts.url.trim().length > 0
? opts.url.trim()
@@ -44,9 +45,8 @@ export async function callGateway<T = unknown>(
? remote.token.trim()
: undefined
: process.env.CLAWDIS_GATEWAY_TOKEN?.trim() ||
(typeof config.gateway?.auth?.token === "string" &&
config.gateway.auth.token.trim().length > 0
? config.gateway.auth.token.trim()
(typeof authToken === "string" && authToken.trim().length > 0
? authToken.trim()
: undefined));
const password =
(typeof opts.password === "string" && opts.password.trim().length > 0
+1 -1
View File
@@ -114,11 +114,11 @@ export function resolveHookMappings(
): HookMappingResolved[] {
const presets = hooks?.presets ?? [];
const mappings: HookMappingConfig[] = [];
if (hooks?.mappings) mappings.push(...hooks.mappings);
for (const preset of presets) {
const presetMappings = hookPresetMappings[preset];
if (presetMappings) mappings.push(...presetMappings);
}
if (hooks?.mappings) mappings.push(...hooks.mappings);
if (mappings.length === 0) return [];
const configDir = path.dirname(CONFIG_PATH_CLAWDIS);