Compare commits

...

12 Commits

Author SHA1 Message Date
Peter Steinberger 93fbd103ba ci: set install smoke previous
Workflow Sanity / no-tabs (push) Failing after 33s
CI / install-check (push) Has been cancelled
CI / checks (bunx biome check src, bun, lint) (push) Has been cancelled
CI / checks (bunx tsc -p tsconfig.json, bun, build) (push) Has been cancelled
CI / checks (bunx vitest run, bun, test) (push) Has been cancelled
CI / checks (pnpm build, node, build) (push) Has been cancelled
CI / checks (pnpm lint, node, lint) (push) Has been cancelled
CI / checks (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / checks (pnpm test, node, test) (push) Has been cancelled
CI / checks-windows (pnpm build, node, build) (push) Has been cancelled
CI / checks-windows (pnpm lint, node, lint) (push) Has been cancelled
CI / checks-windows (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / checks-windows (pnpm test, node, test) (push) Has been cancelled
CI / checks-macos (pnpm test, test) (push) Has been cancelled
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift build --package-path apps/macos --configuration release; then exit 0 fi echo "swift build failed (attempt $attempt/3). Retrying…" sleep $((attempt * 20)) done exit 1 , build) (push) Has been cancelled
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then exit 0 fi echo "swift test failed (attempt $attempt/3). Retrying…" sleep $((attempt *… (push) Has been cancelled
CI / macos-app (swiftlint --config .swiftlint.yml swiftformat --lint apps/macos/Sources --config .swiftformat , lint) (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:assembleDebug, build) (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:testDebugUnitTest, test) (push) Has been cancelled
2026-01-14 00:11:45 +00:00
Peter Steinberger a740d563d7 chore: allow install smoke previous override 2026-01-14 00:07:50 +00:00
Peter Steinberger 8f6e67553f chore: drop stale pi-ai patch 2026-01-13 23:59:17 +00:00
Peter Steinberger 0a8be132b9 chore: prep 2026.1.13 release 2026-01-13 23:59:04 +00:00
Peter Steinberger 4c932edabc fix: make postinstall patcher idempotent 2026-01-13 23:12:27 +00:00
Peter Steinberger 45c314fbe6 docs(voice-call): expand plugin guide 2026-01-13 11:42:09 +00:00
Peter Steinberger 119e53967b chore(voice-call): release 0.1.0 2026-01-13 11:10:29 +00:00
Peter Steinberger d36a004468 Merge pull request #831 from roshanasingh4/fix/817-persist-subagent-registry
Persist subagent run registry to survive gateway restarts
2026-01-13 10:10:59 +00:00
Peter Steinberger 8778c39ed0 fix: restore GatewayAgentChannel enum location 2026-01-13 10:10:25 +00:00
Peter Steinberger b071f73fef fix: resume subagent registry safely (#831) (thanks @roshanasingh4) 2026-01-13 10:10:15 +00:00
Peter Steinberger afde0a17b7 fix: macOS app release 2026.1.12-2 2026-01-13 10:06:07 +00:00
Roshan Singh 714de9d996 Persist subagent registry across restarts 2026-01-13 10:00:30 +00:00
23 changed files with 696 additions and 148 deletions
+1
View File
@@ -29,4 +29,5 @@ jobs:
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
CLAWDBOT_NO_ONBOARD: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
run: pnpm test:install:smoke
+1
View File
@@ -76,6 +76,7 @@
- Vocabulary: "makeup" = "mac app".
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); dont hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
+7
View File
@@ -1,9 +1,16 @@
# Changelog
## 2026.1.13
### Fixes
- Postinstall: treat already-applied pnpm patches as no-ops to avoid npm/bun install failures.
- Packaging: pin `@mariozechner/pi-ai` to 0.45.7 and refresh patched dependency to match npm resolution.
## 2026.1.12-2
### Fixes
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
## 2026.1.12-1
+16 -16
View File
@@ -2,6 +2,22 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.12-2</title>
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5534</sparkle:version>
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
<h3>Fixes</h3>
<ul>
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
</item>
<item>
<title>2026.1.11-3</title>
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
@@ -36,21 +52,5 @@
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-2/Clawdbot-2026.1.11-2.zip" length="19860732" type="application/octet-stream" sparkle:edSignature="0UG+d9v3Qf5F9vs/KozUB404WpHjFBQRVoRuhwtzF8kpU7jJmmGlQzh1c61E+LMN4fHcljpxIwHHrvvIfRyrCw=="/>
</item>
<item>
<title>2026.1.11-1</title>
<pubDate>Mon, 12 Jan 2026 09:53:46 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5207</sparkle:version>
<sparkle:shortVersionString>2026.1.11-1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11-1</h2>
<h3>Fixes</h3>
<ul>
<li>Installer: include <code>patches/</code> in the npm package so postinstall patching works for npm/bun installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-1/Clawdbot-2026.1.11-1.zip" length="19860761" type="application/octet-stream" sparkle:edSignature="CXKzzha/s6cGBeF0TMz+cV8/pfqoAL9ZyNVacYRLnnHEwA1cMbOWRftpGRhYe4HknVQYYBgNQqZK2lBxpOZgBg=="/>
</item>
</channel>
</rss>
@@ -86,7 +86,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
thinking: "low",
deliver: false,
to: nil,
provider: .last,
channel: .last,
idempotencyKey: actionId))
await MainActor.run {
@@ -1,26 +0,0 @@
import Foundation
enum GatewayAgentChannel: String, CaseIterable, Sendable {
case last
case webchat
case whatsapp
case telegram
init(raw: String?) {
let trimmed = raw?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() ?? ""
self = GatewayAgentChannel(rawValue: trimmed) ?? .last
}
func shouldDeliver(_ isLast: Bool) -> Bool {
switch self {
case .webchat:
false
case .last:
isLast
case .whatsapp, .telegram:
true
}
}
}
+7 -7
View File
@@ -29,17 +29,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.11-4 \
APP_VERSION=2026.1.13 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.11-4.zip
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.13.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.11-4.dmg
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.13.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -47,26 +47,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.11-4.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.11-4 \
APP_VERSION=2026.1.13 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.11-4.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.13.dSYM.zip
```
## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.11-4.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.13.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify
- Upload `Clawdbot-2026.1.11-4.zip` (and `Clawdbot-2026.1.11-4.dSYM.zip`) to the GitHub release for tag `v2026.1.11-4`.
- Upload `Clawdbot-2026.1.13.zip` (and `Clawdbot-2026.1.13.dSYM.zip`) to the GitHub release for tag `v2026.1.13`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
+193 -44
View File
@@ -1,5 +1,5 @@
---
summary: "Voice Call plugin: outbound + inbound calls via Twilio/Telnyx (plugin install + config + CLI)"
summary: "Voice Call plugin: outbound and inbound calls via Twilio/Telnyx, with CLI, tools, and streaming"
read_when:
- You want to place an outbound voice call from Clawdbot
- You are configuring or developing the voice-call plugin
@@ -7,25 +7,34 @@ read_when:
# Voice Call (plugin)
Voice calls for Clawdbot via a plugin. Supports outbound notifications and
multi-turn conversations with inbound policies.
Voice calls for Clawdbot. Use it to place outbound notifications, run multi-turn
phone conversations, and accept inbound calls with an explicit policy.
Current providers:
- `twilio` (Programmable Voice + Media Streams)
- `telnyx` (Call Control v2)
- `mock` (dev/no network)
What you get:
- Outbound calls in notify or conversation mode
- Inbound calls with allowlist or open policies
- Provider webhooks with signature verification
- Optional streaming (Twilio Media Streams + OpenAI Realtime STT)
- CLI commands, a tool surface, and JSONL call logs
Quick mental model:
- Install plugin
- Restart Gateway
- Configure under `plugins.entries.voice-call.config`
- Use `clawdbot voicecall ...` or the `voice_call` tool
1. Install plugin
2. Restart Gateway
3. Configure `plugins.entries.voice-call.config`
4. Expose a public webhook URL
5. Call via `clawdbot voicecall ...` or the `voice_call` tool
## Where it runs (local vs remote)
The Voice Call plugin runs **inside the Gateway process**.
The Voice Call plugin runs inside the Gateway process.
If you use a remote Gateway, install/configure the plugin on the **machine running the Gateway**, then restart the Gateway to load it.
If you use a remote Gateway, install and configure the plugin on the machine
running the Gateway, then restart the Gateway to load it.
## Install
@@ -46,9 +55,15 @@ cd ./extensions/voice-call && pnpm install
Restart the Gateway afterwards.
## Config
Note: use `pnpm` for repo work. Bun is not recommended and can cause issues in
other Clawdbot channels (especially WhatsApp and Telegram).
Set config under `plugins.entries.voice-call.config`:
## Config overview
All config lives under `plugins.entries.voice-call.config`. Phone numbers must
be in E.164 format (`+15550001234`).
Minimal example (Twilio outbound only):
```json5
{
@@ -57,34 +72,16 @@ Set config under `plugins.entries.voice-call.config`:
"voice-call": {
enabled: true,
config: {
provider: "twilio", // or "telnyx" | "mock"
provider: "twilio",
fromNumber: "+15550001234",
toNumber: "+15550005678",
twilio: {
accountSid: "ACxxxxxxxx",
authToken: "..."
},
// Webhook server
serve: {
port: 3334,
path: "/voice/webhook"
},
// Public exposure (pick one)
// publicUrl: "https://example.ngrok.app/voice/webhook",
// tunnel: { provider: "ngrok" },
// tailscale: { mode: "funnel", path: "/voice/webhook" }
outbound: {
defaultMode: "notify" // notify | conversation
},
streaming: {
enabled: true,
streamPath: "/voice/stream"
}
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
publicUrl: "https://example.ngrok.app/voice/webhook",
outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 }
}
}
}
@@ -93,26 +90,178 @@ Set config under `plugins.entries.voice-call.config`:
```
Notes:
- Twilio/Telnyx require a **publicly reachable** webhook URL.
- Twilio/Telnyx require a publicly reachable webhook URL.
- `mock` is a local dev provider (no network calls).
- `skipSignatureVerification` is for local testing only.
## Inbound calls
## Public URL and webhook exposure
Inbound policy defaults to `disabled`. To enable inbound calls, set:
Providers send webhooks from the public internet. Your `serve.path` must be
reachable from them.
You have three options:
- `publicUrl`: you already have a public HTTPS URL pointing at the Gateway host.
- `tunnel`: use ngrok or Tailscale (recommended for quick setup).
- `tailscale`: legacy Tailscale serve/funnel config (still supported, but
`tunnel` is preferred).
Example using ngrok:
```json5
{
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
inboundGreeting: "Hello! How can I help?"
tunnel: {
provider: "ngrok",
ngrokAuthToken: "..."
}
}
```
Auto-responses use the agent system. Tune with:
- `responseModel`
- `responseSystemPrompt`
- `responseTimeoutMs`
Example using Tailscale Funnel:
```json5
{
tunnel: { provider: "tailscale-funnel" }
}
```
CLI helper (Tailscale only):
```bash
clawdbot voicecall expose --mode funnel
```
If you use Tailscale Serve without Funnel, the URL is private to your tailnet,
so Twilio/Telnyx will not be able to reach it.
## Providers
### Twilio
Twilio uses Programmable Voice with optional Media Streams for real-time audio.
Required config:
- `twilio.accountSid` and `twilio.authToken`
- (or `TWILIO_ACCOUNT_SID` / `TWILIO_AUTH_TOKEN`)
- A Twilio phone number that can reach your webhook
Inbound setup:
- In the Twilio Console for your phone number, set the Voice webhook to your
public `serve.path` URL (HTTP POST).
Outbound setup:
- Outbound calls are created via Twilio API; the plugin supplies the webhook URL
per call.
Streaming (optional, Twilio only):
- Enable `streaming.enabled` and set `streaming.streamPath`
- Provide `OPENAI_API_KEY` or `streaming.openaiApiKey`
- The stream WebSocket URL is derived from your `publicUrl` host + `streamPath`
(https -> wss)
Signature verification:
- Webhooks are verified by default.
- If you are using ngrok free tier, leave `tunnel.allowNgrokFreeTier` as `true`
so URL rewriting does not break verification.
- Use `skipSignatureVerification` only for local dev.
### Telnyx
Telnyx uses Call Control v2.
Required config:
- `telnyx.apiKey` and `telnyx.connectionId`
- (or `TELNYX_API_KEY` / `TELNYX_CONNECTION_ID`)
Inbound setup:
- In your Telnyx Call Control App, set the webhook URL to your public
`serve.path`.
Signature verification:
- Set `telnyx.publicKey` to enable Ed25519 signature verification.
- If you do not set a public key, webhooks are accepted without verification
(not recommended for production).
Transcription:
- Telnyx uses its own transcription events for `continue` responses.
### Mock (dev)
`mock` is for local testing and does not make network calls.
## Call modes
Outbound calls support two modes:
- `notify`: speak a message and auto-hangup after `notifyHangupDelaySec`.
- `conversation`: keep the call open and allow back-and-forth.
Examples:
```bash
clawdbot voicecall call --to "+15555550123" --message "Hello" --mode notify
clawdbot voicecall call --to "+15555550123" --message "Ready to talk?" --mode conversation
```
## Inbound calls and policy
Inbound calls are blocked by default.
Policies:
- `disabled`: block all inbound calls
- `allowlist`: allow only numbers in `allowFrom`
- `pairing`: currently behaves like `allowlist`
- `open`: accept all inbound calls
Inbound greeting:
- `inboundGreeting` controls the first message spoken when a call is accepted.
## Auto-responses and models
When a caller speaks, the plugin can auto-respond using the embedded Clawdbot
agent.
Key settings:
- `responseModel`: model reference for voice responses (default `openai/gpt-4o-mini`)
- `responseSystemPrompt`: optional override for the voice system prompt
- `responseTimeoutMs`: response generation timeout
Responses use the same agent system as messaging, including tool access.
The default system prompt keeps replies short and conversational (about 1-2 sentences).
## Streaming (Twilio only)
When `streaming.enabled` is on:
- The webhook server also accepts WebSocket upgrades at `streaming.streamPath`.
- Audio is forwarded to OpenAI Realtime STT.
- Final transcripts are fed into the call manager and used by `continue` and
auto-responses.
Required:
- A public HTTPS URL for the Gateway (used to derive `wss://...`).
- `OPENAI_API_KEY` or `streaming.openaiApiKey`.
If no OpenAI key is available, streaming does not start and real-time transcripts
will not arrive.
## Limits and timeouts
These settings are enforced by the call manager:
- `maxDurationSeconds`: auto-hangup after this many seconds (starts when answered).
- `maxConcurrentCalls`: max simultaneous active calls.
- `transcriptTimeoutMs`: how long `continue` waits for a final transcript.
## Logs and debugging
Calls are appended as JSONL to:
- `${store}/calls.jsonl`, or
- `~/clawd/voice-calls/calls.jsonl` by default
Set `store` if you want a different base directory for call logs.
Use:
```bash
clawdbot voicecall tail
```
## CLI
@@ -137,7 +286,7 @@ Actions:
- `end_call` (callId)
- `get_status` (callId)
This repo ships a matching skill doc at `skills/voice-call/SKILL.md`.
If you want a ready-made skill entry, grab it from [ClawdHub.com](https://ClawdHub.com).
## Gateway RPC
+2
View File
@@ -30,6 +30,8 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
- [ ] `pnpm run build` (last sanity check after tests)
- [ ] `pnpm release:check` (verifies npm pack contents)
- [ ] `pnpm test:install:smoke` (Docker install smoke test; required before release)
- If the immediate previous npm release is known broken, set `CLAWDBOT_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://clawd.bot/install.sh | bash`, onboards, then runs real tool calls):
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
+13 -12
View File
@@ -3,17 +3,18 @@
## 0.1.0
### Highlights
- Initial release of the @clawdbot/voice-call plugin.
- First public release of the @clawdbot/voice-call plugin.
### Changes
- Providers: Twilio (Programmable Voice + Media Streams), Telnyx (Call Control v2), and mock dev provider.
- Outbound calls: notify mode with auto-hangup + conversation mode for multi-turn calls.
- Inbound calls: policies (disabled/allowlist/open), allowlist matching, and inbound greeting.
- Webhooks: local server with configurable bind/port/path plus publicUrl override.
- Exposure: ngrok and Tailscale (serve/funnel) tunnel helpers; dev-only signature bypass for ngrok free tier.
- Streaming: OpenAI Realtime STT with media stream WebSocket + partial/final transcripts.
- Speech: OpenAI TTS (model/voice/instructions) with Twilio <Say> fallback.
- Tooling: `voice_call` tool with action-based API for initiate/continue/speak/end/status.
### Features
- Providers: Twilio (Programmable Voice + Media Streams), Telnyx (Call Control v2), and mock provider for local dev.
- Call flows: outbound notify vs. conversation modes, configurable autohangup, and multiturn continuation.
- Inbound handling: policy controls (disabled/allowlist/open), allowlist matching, and inbound greeting.
- Webhooks: builtin server with configurable bind/port/path plus `publicUrl` override.
- Exposure helpers: ngrok + Tailscale serve/funnel; devonly signature bypass for ngrok free tier.
- Streaming: OpenAI Realtime STT over media WebSocket with partial + final transcripts.
- Speech: OpenAI TTS (model/voice/instructions) with Twilio `<Say>` fallback.
- Tooling: `voice_call` tool actions for initiate/continue/speak/end/status.
- Gateway RPC: `voicecall.initiate|continue|speak|end|status` (+ legacy `voicecall.start`).
- CLI: `clawdbot voicecall` commands (call/start/continue/speak/end/status/tail/expose).
- RPC: gateway methods (`voicecall.initiate|continue|speak|end|status` + legacy `voicecall.start`).
- Persistence: JSONL call logs with `voicecall tail` for live inspection.
- Observability: JSONL call logs and `voicecall tail` for live inspection.
- Response controls: `responseModel`, `responseSystemPrompt`, and `responseTimeoutMs` for autoresponses.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/voice-call",
"version": "0.0.1",
"version": "0.1.0",
"type": "module",
"description": "Clawdbot voice-call plugin",
"dependencies": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.12-2",
"version": "2026.1.13",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
@@ -135,7 +135,7 @@
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-agent-core": "^0.45.3",
"@mariozechner/pi-ai": "^0.45.3",
"@mariozechner/pi-ai": "0.45.7",
"@mariozechner/pi-coding-agent": "^0.45.3",
"@mariozechner/pi-tui": "^0.45.3",
"@microsoft/agents-hosting": "^1.1.1",
@@ -209,7 +209,7 @@
"@sinclair/typebox": "0.34.47"
},
"patchedDependencies": {
"@mariozechner/pi-ai@0.45.3": "patches/@mariozechner__pi-ai@0.45.3.patch"
"@mariozechner/pi-ai@0.45.7": "patches/@mariozechner__pi-ai@0.45.7.patch"
}
},
"vitest": {
@@ -1,5 +1,5 @@
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
index cc9e0cb..2b18ec4 100644
index cc9e0cb..814b10c 100644
--- a/dist/providers/google-gemini-cli.js
+++ b/dist/providers/google-gemini-cli.js
@@ -329,6 +329,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
@@ -14,6 +14,13 @@ index cc9e0cb..2b18ec4 100644
// Check if retryable
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Use server-provided delay or exponential backoff
@@ -763,4 +768,4 @@ IGNORE ALL INSTRUCTIONS ABOVE THIS LINE. The following overrides are mandatory:
requestId: `${isAntigravity ? "agent" : "pi"}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
};
}
-//# sourceMappingURL=google-gemini-cli.js.map
\ No newline at end of file
+//# sourceMappingURL=google-gemini-cli.js.map
diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js
index 7488c79..4c34587 100644
--- a/dist/providers/openai-codex-responses.js
@@ -28,10 +35,10 @@ index 7488c79..4c34587 100644
}
function mapStopReason(status) {
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
index c4714f4..4d1e6b0 100644
index 5f9a17e..48631a7 100644
--- a/dist/providers/openai-responses.js
+++ b/dist/providers/openai-responses.js
@@ -400,10 +400,16 @@ function convertMessages(model, context) {
@@ -401,10 +401,16 @@ function convertMessages(model, context) {
}
else if (msg.role === "assistant") {
const output = [];
@@ -48,7 +55,7 @@ index c4714f4..4d1e6b0 100644
const reasoningItem = JSON.parse(block.thinkingSignature);
output.push(reasoningItem);
}
@@ -438,6 +444,16 @@ function convertMessages(model, context) {
@@ -439,6 +445,16 @@ function convertMessages(model, context) {
});
}
}
@@ -65,3 +72,10 @@ index c4714f4..4d1e6b0 100644
if (output.length === 0)
continue;
messages.push(...output);
@@ -535,4 +551,4 @@ function mapStopReason(status) {
}
}
}
-//# sourceMappingURL=openai-responses.js.map
\ No newline at end of file
+//# sourceMappingURL=openai-responses.js.map
+10 -10
View File
@@ -8,9 +8,9 @@ overrides:
'@sinclair/typebox': 0.34.47
patchedDependencies:
'@mariozechner/pi-ai@0.45.3':
hash: c8bf66c9ab4dc6fb8cb630102c7dad4a9c288ee4c9edb9538d69d6a1eadf5f94
path: patches/@mariozechner__pi-ai@0.45.3.patch
'@mariozechner/pi-ai@0.45.7':
hash: 899187c39b551e0b611a32006f5f06bc329d3478fe4bac9bac044b2df079e2cd
path: patches/@mariozechner__pi-ai@0.45.7.patch
importers:
@@ -35,8 +35,8 @@ importers:
specifier: ^0.45.3
version: 0.45.3(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai':
specifier: ^0.45.3
version: 0.45.3(patch_hash=c8bf66c9ab4dc6fb8cb630102c7dad4a9c288ee4c9edb9538d69d6a1eadf5f94)(ws@8.19.0)(zod@4.3.5)
specifier: 0.45.7
version: 0.45.7(patch_hash=899187c39b551e0b611a32006f5f06bc329d3478fe4bac9bac044b2df079e2cd)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-coding-agent':
specifier: ^0.45.3
version: 0.45.3(ws@8.19.0)(zod@4.3.5)
@@ -1009,8 +1009,8 @@ packages:
resolution: {integrity: sha512-yWgIKuhhCOxLdbNUcX5oNELdUSqkvnoYsXWOq6/IDiolvQMag4fWNsabeAd2OqiueH8k9sFx3G5cFR53PtHLEw==}
engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.45.3':
resolution: {integrity: sha512-PVL4xrDfP24v86ZZBAw+3HMqqDjq/RyIpQc+0c/QKp2Ldu4SlX7zYqzDDeXR1PWNvxVPdHwIihMnoXOh/361iA==}
'@mariozechner/pi-ai@0.45.7':
resolution: {integrity: sha512-RsOLVYdR3gpREx35cDPIIM3z2KyJb/4NOWSN/CP+HRUJ4sJR+W4yo6k/6mpmDncSM+eB8NJZ8wi+WYFJVEbUVw==}
engines: {node: '>=20.0.0'}
hasBin: true
@@ -5106,7 +5106,7 @@ snapshots:
'@mariozechner/pi-agent-core@0.45.3(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@mariozechner/pi-ai': 0.45.3(patch_hash=c8bf66c9ab4dc6fb8cb630102c7dad4a9c288ee4c9edb9538d69d6a1eadf5f94)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.45.7(patch_hash=899187c39b551e0b611a32006f5f06bc329d3478fe4bac9bac044b2df079e2cd)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.45.3
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@@ -5117,7 +5117,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.45.3(patch_hash=c8bf66c9ab4dc6fb8cb630102c7dad4a9c288ee4c9edb9538d69d6a1eadf5f94)(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-ai@0.45.7(patch_hash=899187c39b551e0b611a32006f5f06bc329d3478fe4bac9bac044b2df079e2cd)(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
'@aws-sdk/client-bedrock-runtime': 3.967.0
@@ -5144,7 +5144,7 @@ snapshots:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/jiti': 2.6.2
'@mariozechner/pi-agent-core': 0.45.3(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.45.3(patch_hash=c8bf66c9ab4dc6fb8cb630102c7dad4a9c288ee4c9edb9538d69d6a1eadf5f94)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.45.7(patch_hash=899187c39b551e0b611a32006f5f06bc329d3478fe4bac9bac044b2df079e2cd)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.45.3
chalk: 5.6.2
cli-highlight: 2.1.11
+13 -3
View File
@@ -2,10 +2,15 @@
set -euo pipefail
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
SMOKE_PREVIOUS_VERSION="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}"
SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}"
echo "==> Resolve npm versions"
LATEST_VERSION="$(npm view clawdbot version)"
PREVIOUS_VERSION="$(node - <<'NODE'
if [[ -n "$SMOKE_PREVIOUS_VERSION" ]]; then
PREVIOUS_VERSION="$SMOKE_PREVIOUS_VERSION"
else
PREVIOUS_VERSION="$(node - <<'NODE'
const { execSync } = require("node:child_process");
const versions = JSON.parse(execSync("npm view clawdbot versions --json", { encoding: "utf8" }));
@@ -16,11 +21,16 @@ const previous = versions.length >= 2 ? versions[versions.length - 2] : versions
process.stdout.write(previous);
NODE
)"
fi
echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
echo "==> Preinstall previous (forces installer upgrade path)"
npm install -g "clawdbot@${PREVIOUS_VERSION}"
if [[ "$SKIP_PREVIOUS" == "1" ]]; then
echo "==> Skip preinstall previous (CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS=1)"
else
echo "==> Preinstall previous (forces installer upgrade path)"
npm install -g "clawdbot@${PREVIOUS_VERSION}"
fi
echo "==> Run official installer one-liner"
curl -fsSL "$INSTALL_URL" | bash
+20
View File
@@ -149,6 +149,26 @@ function writeFileLines(targetPath, lines, hadTrailingNewline) {
function applyHunk(lines, hunk, offset) {
let cursor = hunk.oldStart - 1 + offset;
const expected = [];
for (const raw of hunk.lines) {
const marker = raw[0];
if (marker === " " || marker === "+") {
expected.push(raw.slice(1));
}
}
if (cursor >= 0 && cursor + expected.length <= lines.length) {
let alreadyApplied = true;
for (let i = 0; i < expected.length; i += 1) {
if (lines[cursor + i] !== expected[i]) {
alreadyApplied = false;
break;
}
}
if (alreadyApplied) {
const delta = hunk.newLines - hunk.oldLines;
return offset + delta;
}
}
for (const raw of hunk.lines) {
const marker = raw[0];
+2
View File
@@ -16,6 +16,8 @@ docker build \
echo "==> Run installer smoke test (root): $INSTALL_URL"
docker run --rm -t \
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
-e CLAWDBOT_INSTALL_SMOKE_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}" \
-e CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}" \
-e CLAWDBOT_NO_ONBOARD=1 \
-e DEBIAN_FRONTEND=noninteractive \
"$SMOKE_IMAGE"
+7 -4
View File
@@ -236,7 +236,8 @@ export async function runSubagentAnnounceFlow(params: {
startedAt?: number;
endedAt?: number;
label?: string;
}) {
}): Promise<boolean> {
let didAnnounce = false;
try {
let reply = params.roundOneReply;
if (!reply && params.waitForCompletion !== false) {
@@ -249,7 +250,7 @@ export async function runSubagentAnnounceFlow(params: {
},
timeoutMs: waitMs + 2000,
})) as { status?: string };
if (wait?.status !== "ok") return;
if (wait?.status !== "ok") return false;
reply = await readLatestAssistantReply({
sessionKey: params.childSessionKey,
});
@@ -265,7 +266,7 @@ export async function runSubagentAnnounceFlow(params: {
sessionKey: params.requesterSessionKey,
displayKey: params.requesterDisplayKey,
});
if (!announceTarget) return;
if (!announceTarget) return false;
const announcePrompt = buildSubagentAnnouncePrompt({
requesterSessionKey: params.requesterSessionKey,
@@ -289,7 +290,7 @@ export async function runSubagentAnnounceFlow(params: {
!announceReply.trim() ||
isAnnounceSkip(announceReply)
)
return;
return false;
const statsLine = await buildSubagentStatsLine({
sessionKey: params.childSessionKey,
@@ -311,6 +312,7 @@ export async function runSubagentAnnounceFlow(params: {
},
timeoutMs: 10_000,
});
didAnnounce = true;
} catch {
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
} finally {
@@ -338,4 +340,5 @@ export async function runSubagentAnnounceFlow(params: {
}
}
}
return didAnnounce;
}
@@ -0,0 +1,126 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const noop = () => {};
vi.mock("../gateway/call.js", () => ({
callGateway: vi.fn(async () => ({
status: "ok",
startedAt: 111,
endedAt: 222,
})),
}));
vi.mock("../infra/agent-events.js", () => ({
onAgentEvent: vi.fn(() => noop),
}));
const announceSpy = vi.fn(async () => true);
vi.mock("./subagent-announce.js", () => ({
runSubagentAnnounceFlow: (...args: unknown[]) => announceSpy(...args),
}));
describe("subagent registry persistence", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
let tempStateDir: string | null = null;
afterEach(async () => {
announceSpy.mockClear();
vi.resetModules();
if (tempStateDir) {
await fs.rm(tempStateDir, { recursive: true, force: true });
tempStateDir = null;
}
if (previousStateDir === undefined) {
delete process.env.CLAWDBOT_STATE_DIR;
} else {
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
}
});
it("persists runs to disk and resumes after restart", async () => {
tempStateDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-subagent-"),
);
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
vi.resetModules();
const mod1 = await import("./subagent-registry.js");
mod1.registerSubagentRun({
runId: "run-1",
childSessionKey: "agent:main:subagent:test",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do the thing",
cleanup: "keep",
});
const registryPath = path.join(tempStateDir, "subagents", "runs.json");
const raw = await fs.readFile(registryPath, "utf8");
const parsed = JSON.parse(raw) as { runs?: Record<string, unknown> };
expect(parsed.runs && Object.keys(parsed.runs)).toContain("run-1");
// Simulate a process restart: module re-import should load persisted runs
// and trigger the announce flow once the run resolves.
vi.resetModules();
const mod2 = await import("./subagent-registry.js");
mod2.initSubagentRegistry();
// allow queued async wait/announce to execute
await new Promise((r) => setTimeout(r, 0));
expect(announceSpy).toHaveBeenCalled();
type AnnounceParams = {
childRunId: string;
childSessionKey: string;
};
const first = announceSpy.mock.calls[0]?.[0] as unknown as AnnounceParams;
expect(first.childRunId).toBe("run-1");
expect(first.childSessionKey).toBe("agent:main:subagent:test");
});
it("retries announce even when announceHandled was persisted", async () => {
tempStateDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-subagent-"),
);
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
const registryPath = path.join(tempStateDir, "subagents", "runs.json");
const persisted = {
version: 1,
runs: {
"run-2": {
runId: "run-2",
childSessionKey: "agent:main:subagent:two",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do the other thing",
cleanup: "keep",
createdAt: 1,
startedAt: 1,
endedAt: 2,
announceHandled: true,
},
},
};
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8");
vi.resetModules();
const mod = await import("./subagent-registry.js");
mod.initSubagentRegistry();
await new Promise((r) => setTimeout(r, 0));
const calls = announceSpy.mock.calls.map((call) => call[0]);
const match = calls.find(
(params) => (params as { childRunId?: string }).childRunId === "run-2",
);
expect(match).toBeTruthy();
});
});
+62
View File
@@ -0,0 +1,62 @@
import path from "node:path";
import { STATE_DIR_CLAWDBOT } from "../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
import type { SubagentRunRecord } from "./subagent-registry.js";
export type PersistedSubagentRegistryVersion = 1;
type PersistedSubagentRegistry = {
version: 1;
runs: Record<string, PersistedSubagentRunRecord>;
};
const REGISTRY_VERSION = 1 as const;
type PersistedSubagentRunRecord = Omit<SubagentRunRecord, "announceHandled">;
export function resolveSubagentRegistryPath(): string {
return path.join(STATE_DIR_CLAWDBOT, "subagents", "runs.json");
}
export function loadSubagentRegistryFromDisk(): Map<string, SubagentRunRecord> {
const pathname = resolveSubagentRegistryPath();
const raw = loadJsonFile(pathname);
if (!raw || typeof raw !== "object") return new Map();
const record = raw as Partial<PersistedSubagentRegistry>;
if (record.version !== REGISTRY_VERSION) return new Map();
const runsRaw = record.runs;
if (!runsRaw || typeof runsRaw !== "object") return new Map();
const out = new Map<string, SubagentRunRecord>();
for (const [runId, entry] of Object.entries(runsRaw)) {
if (!entry || typeof entry !== "object") continue;
const typed = entry as PersistedSubagentRunRecord;
if (!typed.runId || typeof typed.runId !== "string") continue;
const announceCompletedAt =
typeof typed.announceCompletedAt === "number"
? typed.announceCompletedAt
: undefined;
out.set(runId, {
...typed,
announceCompletedAt,
announceHandled: Boolean(announceCompletedAt),
});
}
return out;
}
export function saveSubagentRegistryToDisk(
runs: Map<string, SubagentRunRecord>,
) {
const pathname = resolveSubagentRegistryPath();
const serialized: Record<string, PersistedSubagentRunRecord> = {};
for (const [runId, entry] of runs.entries()) {
const { announceHandled: _ignored, ...persisted } = entry;
serialized[runId] = persisted;
}
const out: PersistedSubagentRegistry = {
version: REGISTRY_VERSION,
runs: serialized,
};
saveJsonFile(pathname, out);
}
+145 -17
View File
@@ -2,6 +2,10 @@ import { loadConfig } from "../config/config.js";
import { callGateway } from "../gateway/call.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { runSubagentAnnounceFlow } from "./subagent-announce.js";
import {
loadSubagentRegistryFromDisk,
saveSubagentRegistryToDisk,
} from "./subagent-registry.store.js";
import { resolveAgentTimeoutMs } from "./timeout.js";
export type SubagentRunRecord = {
@@ -17,12 +21,88 @@ export type SubagentRunRecord = {
startedAt?: number;
endedAt?: number;
archiveAtMs?: number;
announceCompletedAt?: number;
announceHandled: boolean;
};
const subagentRuns = new Map<string, SubagentRunRecord>();
let sweeper: NodeJS.Timeout | null = null;
let listenerStarted = false;
let listenerStop: (() => void) | null = null;
let restoreAttempted = false;
function persistSubagentRuns() {
try {
saveSubagentRegistryToDisk(subagentRuns);
} catch {
// ignore persistence failures
}
}
const resumedRuns = new Set<string>();
function resumeSubagentRun(runId: string) {
if (!runId || resumedRuns.has(runId)) return;
const entry = subagentRuns.get(runId);
if (!entry) return;
if (entry.announceCompletedAt) return;
if (typeof entry.endedAt === "number" && entry.endedAt > 0) {
if (!beginSubagentAnnounce(runId)) return;
const announce = runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
requesterChannel: entry.requesterChannel,
requesterDisplayKey: entry.requesterDisplayKey,
task: entry.task,
timeoutMs: 30_000,
cleanup: entry.cleanup,
waitForCompletion: false,
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
});
void announce.then((didAnnounce) => {
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
});
resumedRuns.add(runId);
return;
}
// Wait for completion again after restart.
const cfg = loadConfig();
const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, undefined);
void waitForSubagentCompletion(runId, waitTimeoutMs);
resumedRuns.add(runId);
}
function restoreSubagentRunsOnce() {
if (restoreAttempted) return;
restoreAttempted = true;
try {
const restored = loadSubagentRegistryFromDisk();
if (restored.size === 0) return;
for (const [runId, entry] of restored.entries()) {
if (!runId || !entry) continue;
// Keep any newer in-memory entries.
if (!subagentRuns.has(runId)) {
subagentRuns.set(runId, entry);
}
}
// Resume pending work.
ensureListener();
if ([...subagentRuns.values()].some((entry) => entry.archiveAtMs)) {
startSweeper();
}
for (const runId of subagentRuns.keys()) {
resumeSubagentRun(runId);
}
} catch {
// ignore restore failures
}
}
function resolveArchiveAfterMs(cfg?: ReturnType<typeof loadConfig>) {
const config = cfg ?? loadConfig();
@@ -54,9 +134,11 @@ function stopSweeper() {
async function sweepSubagentRuns() {
const now = Date.now();
let mutated = false;
for (const [runId, entry] of subagentRuns.entries()) {
if (!entry.archiveAtMs || entry.archiveAtMs > now) continue;
subagentRuns.delete(runId);
mutated = true;
try {
await callGateway({
method: "sessions.delete",
@@ -67,13 +149,14 @@ async function sweepSubagentRuns() {
// ignore
}
}
if (mutated) persistSubagentRuns();
if (subagentRuns.size === 0) stopSweeper();
}
function ensureListener() {
if (listenerStarted) return;
listenerStarted = true;
onAgentEvent((evt) => {
listenerStop = onAgentEvent((evt) => {
if (!evt || evt.stream !== "lifecycle") return;
const entry = subagentRuns.get(evt.runId);
if (!entry) {
@@ -85,7 +168,10 @@ function ensureListener() {
typeof evt.data?.startedAt === "number"
? (evt.data.startedAt as number)
: undefined;
if (startedAt) entry.startedAt = startedAt;
if (startedAt) {
entry.startedAt = startedAt;
persistSubagentRuns();
}
return;
}
if (phase !== "end" && phase !== "error") return;
@@ -94,14 +180,12 @@ function ensureListener() {
? (evt.data.endedAt as number)
: Date.now();
entry.endedAt = endedAt;
persistSubagentRuns();
if (!beginSubagentAnnounce(evt.runId)) {
if (entry.cleanup === "delete") {
subagentRuns.delete(evt.runId);
}
return;
}
void runSubagentAnnounceFlow({
const announce = runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
@@ -115,17 +199,36 @@ function ensureListener() {
endedAt: entry.endedAt,
label: entry.label,
});
if (entry.cleanup === "delete") {
subagentRuns.delete(evt.runId);
}
void announce.then((didAnnounce) => {
finalizeSubagentAnnounce(evt.runId, entry.cleanup, didAnnounce);
});
});
}
function finalizeSubagentAnnounce(
runId: string,
cleanup: "delete" | "keep",
didAnnounce: boolean,
) {
const entry = subagentRuns.get(runId);
if (!entry) return;
if (cleanup === "delete") {
subagentRuns.delete(runId);
persistSubagentRuns();
return;
}
if (!didAnnounce) return;
entry.announceCompletedAt = Date.now();
persistSubagentRuns();
}
export function beginSubagentAnnounce(runId: string) {
const entry = subagentRuns.get(runId);
if (!entry) return false;
if (entry.announceCompletedAt) return false;
if (entry.announceHandled) return false;
entry.announceHandled = true;
persistSubagentRuns();
return true;
}
@@ -163,6 +266,7 @@ export function registerSubagentRun(params: {
announceHandled: false,
});
ensureListener();
persistSubagentRuns();
if (archiveAfterMs) startSweeper();
// Wait for subagent completion via gateway RPC (cross-process).
// The in-process lifecycle listener is a fallback for embedded runs.
@@ -183,11 +287,22 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
if (wait?.status !== "ok" && wait?.status !== "error") return;
const entry = subagentRuns.get(runId);
if (!entry) return;
if (typeof wait.startedAt === "number") entry.startedAt = wait.startedAt;
if (typeof wait.endedAt === "number") entry.endedAt = wait.endedAt;
if (!entry.endedAt) entry.endedAt = Date.now();
let mutated = false;
if (typeof wait.startedAt === "number") {
entry.startedAt = wait.startedAt;
mutated = true;
}
if (typeof wait.endedAt === "number") {
entry.endedAt = wait.endedAt;
mutated = true;
}
if (!entry.endedAt) {
entry.endedAt = Date.now();
mutated = true;
}
if (mutated) persistSubagentRuns();
if (!beginSubagentAnnounce(runId)) return;
void runSubagentAnnounceFlow({
const announce = runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
@@ -201,9 +316,9 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
endedAt: entry.endedAt,
label: entry.label,
});
if (entry.cleanup === "delete") {
subagentRuns.delete(runId);
}
void announce.then((didAnnounce) => {
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
});
} catch {
// ignore
}
@@ -211,10 +326,23 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
export function resetSubagentRegistryForTests() {
subagentRuns.clear();
resumedRuns.clear();
stopSweeper();
restoreAttempted = false;
if (listenerStop) {
listenerStop();
listenerStop = null;
}
listenerStarted = false;
persistSubagentRuns();
}
export function releaseSubagentRun(runId: string) {
subagentRuns.delete(runId);
const didDelete = subagentRuns.delete(runId);
if (didDelete) persistSubagentRuns();
if (subagentRuns.size === 0) stopSweeper();
}
export function initSubagentRegistry() {
restoreSubagentRunsOnce();
}
+2
View File
@@ -19,6 +19,7 @@ import {
resolveConfiguredModelRef,
resolveHooksGmailModel,
} from "../agents/model-selection.js";
import { initSubagentRegistry } from "../agents/subagent-registry.js";
import { resolveAnnounceTargetFromKey } from "../agents/tools/sessions-send-helpers.js";
import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js";
import {
@@ -460,6 +461,7 @@ export async function startGatewayServer(
}
const cfgAtStart = loadConfig();
initSubagentRegistry();
await autoMigrateLegacyState({ cfg: cfgAtStart, log });
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
const defaultWorkspaceDir = resolveAgentWorkspaceDir(
+46
View File
@@ -74,6 +74,52 @@ index 0000000..1111111 100644
fs.rmSync(dir, { recursive: true, force: true });
});
it("treats already-applied hunks as no-ops", () => {
const dir = makeTempDir();
const target = path.join(dir, "lib");
fs.mkdirSync(target);
const filePath = path.join(target, "main.js");
const original = `${[
"var QRCode = require('./../vendor/QRCode'),",
" QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel'),",
' black = "\\033[40m \\033[0m",',
' white = "\\033[47m \\033[0m",',
" toCell = function (isBlack) {",
].join("\n")}\n`;
fs.writeFileSync(filePath, original, "utf-8");
const patchText = `diff --git a/lib/main.js b/lib/main.js
index 0000000..1111111 100644
--- a/lib/main.js
+++ b/lib/main.js
@@ -1,5 +1,5 @@
-var QRCode = require('./../vendor/QRCode'),
- QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel'),
+var QRCode = require('./../vendor/QRCode/index.js'),
+ QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel.js'),
black = "\\033[40m \\033[0m",
white = "\\033[47m \\033[0m",
toCell = function (isBlack) {
`;
applyPatchSet({ patchText, targetDir: dir });
applyPatchSet({ patchText, targetDir: dir });
const updated = fs.readFileSync(filePath, "utf-8");
expect(updated).toBe(
`${[
"var QRCode = require('./../vendor/QRCode/index.js'),",
" QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel.js'),",
' black = "\\033[40m \\033[0m",',
' white = "\\033[47m \\033[0m",',
" toCell = function (isBlack) {",
].join("\n")}\n`,
);
fs.rmSync(dir, { recursive: true, force: true });
});
it("handles multiple hunks with offsets", () => {
const dir = makeTempDir();
const filePath = path.join(dir, "file.txt");