Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1adf22249 | ||
|
|
ed7962500d |
No files matched your search
@@ -47,18 +47,14 @@
|
|||||||
- CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.
|
- CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.
|
||||||
- Skills: allow `bun` as a node manager for skill installs.
|
- 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 `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: add a Docker-based onboarding E2E harness.
|
||||||
- Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.
|
- 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.
|
- Browser tools: add remote CDP URL support, Linux launcher options (`executablePath`, `noSandbox`), and surface `cdpUrl` in status.
|
||||||
- Skills: add tmux-first coding-agent skill + `requires.anyBins` gate for multi-CLI setup (thanks @sreekaransrinath).
|
- Skills: add tmux-first coding-agent skill + `requires.anyBins` gate for multi-CLI setup (thanks @sreekaransrinath).
|
||||||
|
|
||||||
### Fixes
|
### 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.
|
- 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.
|
- 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).
|
- 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.
|
- 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.
|
- Control UI: accept a `?token=` URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.
|
||||||
@@ -105,7 +101,6 @@
|
|||||||
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b
|
- 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.
|
- 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: 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
|
- 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.
|
- 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.
|
- Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.
|
||||||
|
|||||||
@@ -133,16 +133,10 @@ extension OnboardingView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
|
func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
|
||||||
let scrollIndicatorGutter: CGFloat = 18
|
VStack(spacing: 16) {
|
||||||
return ScrollView {
|
content()
|
||||||
VStack(spacing: 16) {
|
Spacer()
|
||||||
content()
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .top)
|
|
||||||
.padding(.trailing, scrollIndicatorGutter)
|
|
||||||
}
|
}
|
||||||
.scrollIndicators(.automatic)
|
|
||||||
.padding(.horizontal, 28)
|
.padding(.horizontal, 28)
|
||||||
.frame(width: self.pageWidth, alignment: .top)
|
.frame(width: self.pageWidth, alignment: .top)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -442,7 +442,7 @@ extension OnboardingView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func permissionsPage() -> some View {
|
func permissionsPage() -> some View {
|
||||||
return self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Grant permissions")
|
Text("Grant permissions")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
|
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ Key parameters:
|
|||||||
- `yieldMs` (default 20000): auto‑background after this delay
|
- `yieldMs` (default 20000): auto‑background after this delay
|
||||||
- `background` (bool): background immediately
|
- `background` (bool): background immediately
|
||||||
- `timeout` (seconds, default 1800): kill the process after this timeout
|
- `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`
|
- `workdir`, `env`
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|||||||
Vendored
-10
@@ -28,16 +28,6 @@ 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.
|
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
|
## Safety
|
||||||
|
|
||||||
- Don't exfiltrate private data. Ever.
|
- Don't exfiltrate private data. Ever.
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ Core parameters:
|
|||||||
- `yieldMs` (auto-background after timeout, default 20000)
|
- `yieldMs` (auto-background after timeout, default 20000)
|
||||||
- `background` (immediate background)
|
- `background` (immediate background)
|
||||||
- `timeout` (seconds; kills the process if exceeded, default 1800)
|
- `timeout` (seconds; kills the process if exceeded, default 1800)
|
||||||
- `stdinMode` (`pipe` | `pty`; `pty` uses node-pty for a real TTY with fallback warning)
|
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Returns `status: "running"` with a `sessionId` when backgrounded.
|
- Returns `status: "running"` with a `sessionId` when backgrounded.
|
||||||
|
|||||||
@@ -92,7 +92,6 @@
|
|||||||
"grammy": "^1.39.2",
|
"grammy": "^1.39.2",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"long": "5.3.2",
|
"long": "5.3.2",
|
||||||
"node-pty": "^1.1.0",
|
|
||||||
"playwright-core": "1.57.0",
|
"playwright-core": "1.57.0",
|
||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
|||||||
Generated
-15
@@ -88,9 +88,6 @@ importers:
|
|||||||
long:
|
long:
|
||||||
specifier: 5.3.2
|
specifier: 5.3.2
|
||||||
version: 5.3.2
|
version: 5.3.2
|
||||||
node-pty:
|
|
||||||
specifier: ^1.1.0
|
|
||||||
version: 1.1.0
|
|
||||||
playwright-core:
|
playwright-core:
|
||||||
specifier: 1.57.0
|
specifier: 1.57.0
|
||||||
version: 1.57.0
|
version: 1.57.0
|
||||||
@@ -2161,9 +2158,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
node-addon-api@7.1.1:
|
|
||||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
|
||||||
|
|
||||||
node-domexception@1.0.0:
|
node-domexception@1.0.0:
|
||||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||||
engines: {node: '>=10.5.0'}
|
engines: {node: '>=10.5.0'}
|
||||||
@@ -2182,9 +2176,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
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:
|
node-wav@0.0.2:
|
||||||
resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==}
|
resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==}
|
||||||
engines: {node: '>=4.4.0'}
|
engines: {node: '>=4.4.0'}
|
||||||
@@ -4813,8 +4804,6 @@ snapshots:
|
|||||||
|
|
||||||
negotiator@1.0.0: {}
|
negotiator@1.0.0: {}
|
||||||
|
|
||||||
node-addon-api@7.1.1: {}
|
|
||||||
|
|
||||||
node-domexception@1.0.0: {}
|
node-domexception@1.0.0: {}
|
||||||
|
|
||||||
node-fetch@2.7.0:
|
node-fetch@2.7.0:
|
||||||
@@ -4827,10 +4816,6 @@ snapshots:
|
|||||||
fetch-blob: 3.2.0
|
fetch-blob: 3.2.0
|
||||||
formdata-polyfill: 4.0.10
|
formdata-polyfill: 4.0.10
|
||||||
|
|
||||||
node-pty@1.1.0:
|
|
||||||
dependencies:
|
|
||||||
node-addon-api: 7.1.1
|
|
||||||
|
|
||||||
node-wav@0.0.2:
|
node-wav@0.0.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
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
@@ -17,7 +17,7 @@ Setup (once)
|
|||||||
Common commands
|
Common commands
|
||||||
- Gmail search: `gog gmail search 'newer_than:7d' --max 10`
|
- Gmail search: `gog gmail search 'newer_than:7d' --max 10`
|
||||||
- Gmail send: `gog gmail send --to a@b.com --subject "Hi" --body "Hello"`
|
- Gmail send: `gog gmail send --to a@b.com --subject "Hi" --body "Hello"`
|
||||||
- Calendar: `gog calendar events <calendarId> --from <rfc3339> --to <rfc3339>`
|
- Calendar: `gog calendar events <calendarId> --from <iso> --to <iso>`
|
||||||
- Drive: `gog drive search "query" --max 10`
|
- Drive: `gog drive search "query" --max 10`
|
||||||
- Contacts: `gog contacts list --max 20`
|
- Contacts: `gog contacts list --max 20`
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ describe("bash process registry", () => {
|
|||||||
id: "sess",
|
id: "sess",
|
||||||
command: "echo test",
|
command: "echo test",
|
||||||
child: { pid: 123 } as ChildProcessWithoutNullStreams,
|
child: { pid: 123 } as ChildProcessWithoutNullStreams,
|
||||||
stdinMode: "pipe",
|
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
cwd: "/tmp",
|
cwd: "/tmp",
|
||||||
maxOutputChars: 10,
|
maxOutputChars: 10,
|
||||||
@@ -49,7 +48,6 @@ describe("bash process registry", () => {
|
|||||||
id: "sess",
|
id: "sess",
|
||||||
command: "echo test",
|
command: "echo test",
|
||||||
child: { pid: 123 } as ChildProcessWithoutNullStreams,
|
child: { pid: 123 } as ChildProcessWithoutNullStreams,
|
||||||
stdinMode: "pipe",
|
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
cwd: "/tmp",
|
cwd: "/tmp",
|
||||||
maxOutputChars: 100,
|
maxOutputChars: 100,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import type { IPty } from "node-pty";
|
|
||||||
|
|
||||||
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute
|
const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute
|
||||||
@@ -16,15 +15,10 @@ let jobTtlMs = clampTtl(
|
|||||||
|
|
||||||
export type ProcessStatus = "running" | "completed" | "failed" | "killed";
|
export type ProcessStatus = "running" | "completed" | "failed" | "killed";
|
||||||
|
|
||||||
export type ProcessStdinMode = "pipe" | "pty";
|
|
||||||
|
|
||||||
export interface ProcessSession {
|
export interface ProcessSession {
|
||||||
id: string;
|
id: string;
|
||||||
command: string;
|
command: string;
|
||||||
child?: ChildProcessWithoutNullStreams;
|
child: ChildProcessWithoutNullStreams;
|
||||||
pty?: IPty;
|
|
||||||
pid?: number;
|
|
||||||
stdinMode: ProcessStdinMode;
|
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
maxOutputChars: number;
|
maxOutputChars: number;
|
||||||
|
|||||||
+65
-179
@@ -2,7 +2,6 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import type { IPty } from "node-pty";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addSession,
|
addSession,
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
listRunningSessions,
|
listRunningSessions,
|
||||||
markBackgrounded,
|
markBackgrounded,
|
||||||
markExited,
|
markExited,
|
||||||
type ProcessStdinMode,
|
|
||||||
setJobTtlMs,
|
setJobTtlMs,
|
||||||
} from "./bash-process-registry.js";
|
} from "./bash-process-registry.js";
|
||||||
import {
|
import {
|
||||||
@@ -31,19 +29,6 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
|
|||||||
1_000,
|
1_000,
|
||||||
150_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 = (
|
const stringEnum = (
|
||||||
values: readonly string[],
|
values: readonly string[],
|
||||||
@@ -87,7 +72,7 @@ const bashSchema = Type.Object({
|
|||||||
),
|
),
|
||||||
stdinMode: Type.Optional(
|
stdinMode: Type.Optional(
|
||||||
stringEnum(["pipe", "pty"] as const, {
|
stringEnum(["pipe", "pty"] as const, {
|
||||||
description: "stdin mode (pipe or pty when node-pty is available)",
|
description: "Only pipe is supported",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -142,6 +127,9 @@ export function createBashTool(
|
|||||||
if (!params.command) {
|
if (!params.command) {
|
||||||
throw new Error("Provide a command to start.");
|
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
|
const yieldWindow = params.background
|
||||||
? 0
|
? 0
|
||||||
@@ -158,56 +146,21 @@ export function createBashTool(
|
|||||||
|
|
||||||
const { shell, args: shellArgs } = getShellConfig();
|
const { shell, args: shellArgs } = getShellConfig();
|
||||||
const env = params.env ? { ...process.env, ...params.env } : process.env;
|
const env = params.env ? { ...process.env, ...params.env } : process.env;
|
||||||
const requestedStdinMode =
|
const child: ChildProcessWithoutNullStreams = spawn(
|
||||||
params.stdinMode === "pty" ? "pty" : "pipe";
|
shell,
|
||||||
let stdinMode: ProcessStdinMode = requestedStdinMode;
|
[...shellArgs, params.command],
|
||||||
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,
|
cwd: workdir,
|
||||||
env,
|
env,
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
});
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
command: params.command,
|
command: params.command,
|
||||||
child,
|
child,
|
||||||
pty,
|
|
||||||
pid: child?.pid ?? pty?.pid,
|
|
||||||
stdinMode,
|
|
||||||
startedAt,
|
startedAt,
|
||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
maxOutputChars: maxOutput,
|
maxOutputChars: maxOutput,
|
||||||
@@ -237,7 +190,9 @@ export function createBashTool(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
killSession(session);
|
if (child.pid) {
|
||||||
|
killProcessTree(child.pid);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (signal?.aborted) onAbort();
|
if (signal?.aborted) onAbort();
|
||||||
@@ -257,46 +212,33 @@ export function createBashTool(
|
|||||||
const emitUpdate = () => {
|
const emitUpdate = () => {
|
||||||
if (!onUpdate) return;
|
if (!onUpdate) return;
|
||||||
const tailText = session.tail || session.aggregated;
|
const tailText = session.tail || session.aggregated;
|
||||||
const warningText = warning ? `${warning}\n\n` : "";
|
|
||||||
onUpdate({
|
onUpdate({
|
||||||
content: [{ type: "text", text: warningText + (tailText || "") }],
|
content: [{ type: "text", text: tailText || "" }],
|
||||||
details: {
|
details: {
|
||||||
status: "running",
|
status: "running",
|
||||||
sessionId,
|
sessionId,
|
||||||
pid: session.pid ?? undefined,
|
pid: child.pid ?? undefined,
|
||||||
startedAt,
|
startedAt,
|
||||||
tail: session.tail,
|
tail: session.tail,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (child) {
|
child.stdout.on("data", (data) => {
|
||||||
child.stdout.on("data", (data) => {
|
const str = sanitizeBinaryOutput(data.toString());
|
||||||
const str = sanitizeBinaryOutput(data.toString());
|
for (const chunk of chunkString(str)) {
|
||||||
for (const chunk of chunkString(str)) {
|
appendOutput(session, "stdout", chunk);
|
||||||
appendOutput(session, "stdout", chunk);
|
emitUpdate();
|
||||||
emitUpdate();
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr.on("data", (data) => {
|
child.stderr.on("data", (data) => {
|
||||||
const str = sanitizeBinaryOutput(data.toString());
|
const str = sanitizeBinaryOutput(data.toString());
|
||||||
for (const chunk of chunkString(str)) {
|
for (const chunk of chunkString(str)) {
|
||||||
appendOutput(session, "stderr", chunk);
|
appendOutput(session, "stderr", chunk);
|
||||||
emitUpdate();
|
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>>(
|
return new Promise<AgentToolResult<BashToolDetails>>(
|
||||||
(resolve, reject) => {
|
(resolve, reject) => {
|
||||||
@@ -307,15 +249,14 @@ export function createBashTool(
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text:
|
text:
|
||||||
`${warning ? `${warning}\n\n` : ""}` +
|
`Command still running (session ${sessionId}, pid ${child.pid ?? "n/a"}). ` +
|
||||||
`Command still running (session ${sessionId}, pid ${session.pid ?? "n/a"}). ` +
|
|
||||||
"Use process (list/poll/log/write/kill/clear/remove) for follow-up.",
|
"Use process (list/poll/log/write/kill/clear/remove) for follow-up.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
details: {
|
details: {
|
||||||
status: "running",
|
status: "running",
|
||||||
sessionId,
|
sessionId,
|
||||||
pid: session.pid ?? undefined,
|
pid: child.pid ?? undefined,
|
||||||
startedAt,
|
startedAt,
|
||||||
tail: session.tail,
|
tail: session.tail,
|
||||||
},
|
},
|
||||||
@@ -342,10 +283,7 @@ export function createBashTool(
|
|||||||
}, yieldWindow);
|
}, yieldWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExit = (
|
child.once("exit", (code, exitSignal) => {
|
||||||
code: number | null,
|
|
||||||
exitSignal: NodeJS.Signals | number | null,
|
|
||||||
) => {
|
|
||||||
if (yieldTimer) clearTimeout(yieldTimer);
|
if (yieldTimer) clearTimeout(yieldTimer);
|
||||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||||
const durationMs = Date.now() - startedAt;
|
const durationMs = Date.now() - startedAt;
|
||||||
@@ -377,14 +315,7 @@ export function createBashTool(
|
|||||||
|
|
||||||
settle(() =>
|
settle(() =>
|
||||||
resolve({
|
resolve({
|
||||||
content: [
|
content: [{ type: "text", text: aggregated || "(no output)" }],
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text:
|
|
||||||
`${warning ? `${warning}\n\n` : ""}` +
|
|
||||||
(aggregated || "(no output)"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: {
|
details: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
exitCode: code ?? 0,
|
exitCode: code ?? 0,
|
||||||
@@ -393,26 +324,14 @@ export function createBashTool(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
if (child) {
|
child.once("error", (err) => {
|
||||||
child.once("exit", (code, exitSignal) => {
|
if (yieldTimer) clearTimeout(yieldTimer);
|
||||||
handleExit(code, exitSignal);
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||||
});
|
markExited(session, null, null, "failed");
|
||||||
|
settle(() => reject(err));
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -464,7 +383,7 @@ export function createProcessTool(
|
|||||||
const running = listRunningSessions().map((s) => ({
|
const running = listRunningSessions().map((s) => ({
|
||||||
sessionId: s.id,
|
sessionId: s.id,
|
||||||
status: "running",
|
status: "running",
|
||||||
pid: s.pid ?? undefined,
|
pid: s.child.pid ?? undefined,
|
||||||
startedAt: s.startedAt,
|
startedAt: s.startedAt,
|
||||||
runtimeMs: Date.now() - s.startedAt,
|
runtimeMs: Date.now() - s.startedAt,
|
||||||
cwd: s.cwd,
|
cwd: s.cwd,
|
||||||
@@ -708,43 +627,25 @@ export function createProcessTool(
|
|||||||
details: { status: "failed" },
|
details: { status: "failed" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (session.stdinMode === "pty") {
|
if (!session.child.stdin || session.child.stdin.destroyed) {
|
||||||
if (!session.pty || session.exited) {
|
return {
|
||||||
return {
|
content: [
|
||||||
content: [
|
{
|
||||||
{
|
type: "text",
|
||||||
type: "text",
|
text: `Session ${params.sessionId} stdin is not writable.`,
|
||||||
text: `Session ${params.sessionId} stdin is not writable.`,
|
},
|
||||||
},
|
],
|
||||||
],
|
details: { status: "failed" },
|
||||||
details: { status: "failed" },
|
};
|
||||||
};
|
}
|
||||||
}
|
await new Promise<void>((resolve, reject) => {
|
||||||
session.pty.write(params.data ?? "");
|
session.child.stdin.write(params.data ?? "", (err) => {
|
||||||
if (params.eof) {
|
if (err) reject(err);
|
||||||
session.pty.write("\x04");
|
else resolve();
|
||||||
}
|
|
||||||
} 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 {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -786,7 +687,9 @@ export function createProcessTool(
|
|||||||
details: { status: "failed" },
|
details: { status: "failed" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
killSession(session);
|
if (session.child.pid) {
|
||||||
|
killProcessTree(session.child.pid);
|
||||||
|
}
|
||||||
markExited(session, null, "SIGKILL", "failed");
|
markExited(session, null, "SIGKILL", "failed");
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -822,7 +725,9 @@ export function createProcessTool(
|
|||||||
|
|
||||||
case "remove": {
|
case "remove": {
|
||||||
if (session) {
|
if (session) {
|
||||||
killSession(session);
|
if (session.child.pid) {
|
||||||
|
killProcessTree(session.child.pid);
|
||||||
|
}
|
||||||
markExited(session, null, "SIGKILL", "failed");
|
markExited(session, null, "SIGKILL", "failed");
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -867,25 +772,6 @@ export function createProcessTool(
|
|||||||
|
|
||||||
export const processTool = 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(
|
function clampNumber(
|
||||||
value: number | undefined,
|
value: number | undefined,
|
||||||
defaultValue: number,
|
defaultValue: number,
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+6
-10
@@ -604,11 +604,10 @@ export async function getReplyFromConfig(
|
|||||||
(agentCfg?.verboseDefault as VerboseLevel | undefined);
|
(agentCfg?.verboseDefault as VerboseLevel | undefined);
|
||||||
const resolvedBlockStreaming =
|
const resolvedBlockStreaming =
|
||||||
agentCfg?.blockStreamingDefault === "off" ? "off" : "on";
|
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 =
|
const resolvedBlockStreamingBreak =
|
||||||
agentCfg?.blockStreamingBreak === "text_end"
|
agentCfg?.blockStreamingBreak === "message_end"
|
||||||
? "text_end"
|
? "message_end"
|
||||||
: "message_end";
|
: "text_end";
|
||||||
const blockStreamingEnabled = resolvedBlockStreaming === "on";
|
const blockStreamingEnabled = resolvedBlockStreaming === "on";
|
||||||
const streamedPayloadKeys = new Set<string>();
|
const streamedPayloadKeys = new Set<string>();
|
||||||
const pendingBlockTasks = new Set<Promise<void>>();
|
const pendingBlockTasks = new Set<Promise<void>>();
|
||||||
@@ -1422,19 +1421,16 @@ export async function getReplyFromConfig(
|
|||||||
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
||||||
if (!cleaned && !hasMedia) return;
|
if (!cleaned && !hasMedia) return;
|
||||||
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia) return;
|
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia) return;
|
||||||
|
await startTypingOnText(cleaned);
|
||||||
const blockPayload: ReplyPayload = {
|
const blockPayload: ReplyPayload = {
|
||||||
text: cleaned,
|
text: cleaned,
|
||||||
mediaUrls: payload.mediaUrls,
|
mediaUrls: payload.mediaUrls,
|
||||||
mediaUrl: payload.mediaUrls?.[0],
|
mediaUrl: payload.mediaUrls?.[0],
|
||||||
replyToId: tagResult.replyToId,
|
replyToId: tagResult.replyToId,
|
||||||
};
|
};
|
||||||
const payloadKey = buildPayloadKey(blockPayload);
|
const task = Promise.resolve(opts.onBlockReply?.(blockPayload))
|
||||||
const task = (async () => {
|
|
||||||
await startTypingOnText(cleaned);
|
|
||||||
await opts.onBlockReply?.(blockPayload);
|
|
||||||
})()
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
streamedPayloadKeys.add(payloadKey);
|
streamedPayloadKeys.add(buildPayloadKey(blockPayload));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
logVerbose(`block reply delivery failed: ${String(err)}`);
|
logVerbose(`block reply delivery failed: ${String(err)}`);
|
||||||
|
|||||||
@@ -396,11 +396,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
MediaType: media?.contentType,
|
MediaType: media?.contentType,
|
||||||
MediaUrl: media?.path,
|
MediaUrl: media?.path,
|
||||||
};
|
};
|
||||||
const replyTarget = ctxPayload.To ?? undefined;
|
|
||||||
if (!replyTarget) {
|
|
||||||
runtime.error?.(danger("discord: missing reply target"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
@@ -435,7 +430,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
await deliverReplies({
|
await deliverReplies({
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
target: replyTarget,
|
target: ctxPayload.To,
|
||||||
token,
|
token,
|
||||||
runtime,
|
runtime,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
@@ -478,7 +473,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
|
|
||||||
await deliverReplies({
|
await deliverReplies({
|
||||||
replies,
|
replies,
|
||||||
target: replyTarget,
|
target: ctxPayload.To,
|
||||||
token,
|
token,
|
||||||
runtime,
|
runtime,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
@@ -487,7 +482,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`discord: delivered ${replies.length} reply${replies.length === 1 ? "" : "ies"} to ${replyTarget}`,
|
`discord: delivered ${replies.length} reply${replies.length === 1 ? "" : "ies"} to ${ctxPayload.To}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
+3
-3
@@ -27,7 +27,6 @@ export async function callGateway<T = unknown>(
|
|||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const isRemoteMode = config.gateway?.mode === "remote";
|
const isRemoteMode = config.gateway?.mode === "remote";
|
||||||
const remote = isRemoteMode ? config.gateway?.remote : undefined;
|
const remote = isRemoteMode ? config.gateway?.remote : undefined;
|
||||||
const authToken = config.gateway?.auth?.token;
|
|
||||||
const url =
|
const url =
|
||||||
(typeof opts.url === "string" && opts.url.trim().length > 0
|
(typeof opts.url === "string" && opts.url.trim().length > 0
|
||||||
? opts.url.trim()
|
? opts.url.trim()
|
||||||
@@ -45,8 +44,9 @@ export async function callGateway<T = unknown>(
|
|||||||
? remote.token.trim()
|
? remote.token.trim()
|
||||||
: undefined
|
: undefined
|
||||||
: process.env.CLAWDIS_GATEWAY_TOKEN?.trim() ||
|
: process.env.CLAWDIS_GATEWAY_TOKEN?.trim() ||
|
||||||
(typeof authToken === "string" && authToken.trim().length > 0
|
(typeof config.gateway?.auth?.token === "string" &&
|
||||||
? authToken.trim()
|
config.gateway.auth.token.trim().length > 0
|
||||||
|
? config.gateway.auth.token.trim()
|
||||||
: undefined));
|
: undefined));
|
||||||
const password =
|
const password =
|
||||||
(typeof opts.password === "string" && opts.password.trim().length > 0
|
(typeof opts.password === "string" && opts.password.trim().length > 0
|
||||||
|
|||||||
@@ -74,61 +74,6 @@ describe("hooks mapping", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats null transform as a handled skip", async () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-hooks-skip-"));
|
|
||||||
const modPath = path.join(dir, "transform.mjs");
|
|
||||||
fs.writeFileSync(modPath, "export default () => null;");
|
|
||||||
|
|
||||||
const mappings = resolveHookMappings({
|
|
||||||
transformsDir: dir,
|
|
||||||
mappings: [
|
|
||||||
{
|
|
||||||
match: { path: "skip" },
|
|
||||||
action: "agent",
|
|
||||||
transform: { module: "transform.mjs" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await applyHookMappings(mappings, {
|
|
||||||
payload: {},
|
|
||||||
headers: {},
|
|
||||||
url: new URL("http://127.0.0.1:18789/hooks/skip"),
|
|
||||||
path: "skip",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result?.ok).toBe(true);
|
|
||||||
if (result?.ok) {
|
|
||||||
expect(result.action).toBeNull();
|
|
||||||
expect("skipped" in result).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers explicit mappings over presets", async () => {
|
|
||||||
const mappings = resolveHookMappings({
|
|
||||||
presets: ["gmail"],
|
|
||||||
mappings: [
|
|
||||||
{
|
|
||||||
id: "override",
|
|
||||||
match: { path: "gmail" },
|
|
||||||
action: "agent",
|
|
||||||
messageTemplate: "Override subject: {{messages[0].subject}}",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const result = await applyHookMappings(mappings, {
|
|
||||||
payload: { messages: [{ subject: "Hello" }] },
|
|
||||||
headers: {},
|
|
||||||
url: baseUrl,
|
|
||||||
path: "gmail",
|
|
||||||
});
|
|
||||||
expect(result?.ok).toBe(true);
|
|
||||||
if (result?.ok) {
|
|
||||||
expect(result.action.kind).toBe("agent");
|
|
||||||
expect(result.action.message).toBe("Override subject: Hello");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects missing message", async () => {
|
it("rejects missing message", async () => {
|
||||||
const mappings = resolveHookMappings({
|
const mappings = resolveHookMappings({
|
||||||
mappings: [{ match: { path: "noop" }, action: "agent" }],
|
mappings: [{ match: { path: "noop" }, action: "agent" }],
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ export type HookAction =
|
|||||||
|
|
||||||
export type HookMappingResult =
|
export type HookMappingResult =
|
||||||
| { ok: true; action: HookAction }
|
| { ok: true; action: HookAction }
|
||||||
| { ok: true; action: null; skipped: true }
|
|
||||||
| { ok: false; error: string };
|
| { ok: false; error: string };
|
||||||
|
|
||||||
const hookPresetMappings: Record<string, HookMappingConfig[]> = {
|
const hookPresetMappings: Record<string, HookMappingConfig[]> = {
|
||||||
@@ -146,9 +145,7 @@ export async function applyHookMappings(
|
|||||||
if (mapping.transform) {
|
if (mapping.transform) {
|
||||||
const transform = await loadTransform(mapping.transform);
|
const transform = await loadTransform(mapping.transform);
|
||||||
override = await transform(ctx);
|
override = await transform(ctx);
|
||||||
if (override === null) {
|
if (override === null) return null;
|
||||||
return { ok: true, action: null, skipped: true };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = mergeAction(base.action, override, mapping.action);
|
const merged = mergeAction(base.action, override, mapping.action);
|
||||||
|
|||||||
@@ -1706,11 +1706,6 @@ export async function startGatewayServer(
|
|||||||
sendJson(res, 400, { ok: false, error: mapped.error });
|
sendJson(res, 400, { ok: false, error: mapped.error });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (mapped.action === null) {
|
|
||||||
res.statusCode = 204;
|
|
||||||
res.end();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (mapped.action.kind === "wake") {
|
if (mapped.action.kind === "wake") {
|
||||||
dispatchWakeHook({
|
dispatchWakeHook({
|
||||||
text: mapped.action.text,
|
text: mapped.action.text,
|
||||||
|
|||||||
Reference in new issue
Block a user