mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-09 18:57:08 +08:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60d1324652 | |||
| 0c0f4c2592 | |||
| 92635f1934 | |||
| f7a43ef881 | |||
| 5459925c65 | |||
| b0c76ce4c5 | |||
| 87a2d3af8d | |||
| 68ebc02114 | |||
| d4e73c5523 | |||
| 9a103f4f93 | |||
| a8554c6248 | |||
| 2480ae7cb8 | |||
| 763a3f757b | |||
| 8943c1cbf4 | |||
| 559bb5ca1a | |||
| 04b364e828 | |||
| 627ab23bad | |||
| 8986fef586 | |||
| 7f49450c0c | |||
| cdc519e1f4 | |||
| 1276645c3b | |||
| d3499b7d70 | |||
| c2cf19ec28 | |||
| 44f351349a | |||
| e8a32207d8 | |||
| 1dda751a48 | |||
| 4f1b17c737 | |||
| 967aee2c27 | |||
| 5aac750aa5 |
@@ -14,6 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write # createLabel — auto-provisions the core-team label
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
@@ -30,6 +31,24 @@ jobs:
|
||||
|
||||
if (body.includes('contributing-guide: v1')) labels.push('follows-guidelines');
|
||||
|
||||
// Lowercase GitHub logins; keep in sync with the core team roster.
|
||||
const CORE_TEAM = ['gavrielc', 'koshkoshinsk', 'glifocat', 'gabi-simons', 'omri-maya', 'amit-shafnir', 'moshe-nanoco'];
|
||||
const author = context.payload.pull_request.user.login.toLowerCase();
|
||||
if (CORE_TEAM.includes(author)) {
|
||||
labels.push('core-team');
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'core-team',
|
||||
color: '1D76DB',
|
||||
description: 'PR opened by a core team member',
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status !== 422) throw e; // 422: label already exists
|
||||
}
|
||||
}
|
||||
|
||||
if (labels.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
|
||||
@@ -4,6 +4,9 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Claude tool allowlist reconciled with the pinned CLI, plus a tool-surface drift guard.** `TOOL_ALLOWLIST` in the agent runner named five tools that don't exist on claude-code 2.1.197 (`Task` — renamed `Agent` upstream — plus `TodoWrite`, `TeamCreate`, `TeamDelete`, `ToolSearch`); the phantom entries are removed and the comment corrected: `allowedTools` is a permission auto-approve list, not an availability filter — though it does *promote* the optional `Glob`/`Grep` tools onto the surface, which is why those stay listed. No wire-visible behavior change. A new wire-captured fixture (`container/agent-runner/src/providers/sdk-tools-baseline.json`) and `claude.tools.test.ts` now fail on any future claude-code pin bump until the fixture is regenerated with `dump-sdk-tools.ts` and the lists re-verified.
|
||||
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
|
||||
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
|
||||
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
|
||||
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
|
||||
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
|
||||
|
||||
@@ -15,11 +15,11 @@ If you are a fresh install (you ran `git clone`, not `git pull`) and there are n
|
||||
|
||||
# NanoClaw
|
||||
|
||||
Personal Claude assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`.
|
||||
Personal AI assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`.
|
||||
|
||||
## Quick Context
|
||||
|
||||
The host is a single Node process that orchestrates per-session agent containers. Platform messages land via channel adapters, route through an entity model (users → messaging groups → agent groups → sessions), get written into the session's inbound DB, and wake a container. The agent-runner inside the container polls the DB, calls Claude, and writes back to the outbound DB. The host polls the outbound DB and delivers through the same adapter.
|
||||
The host is a single Node process that orchestrates per-session agent containers. Platform messages land via channel adapters, route through an entity model (users → messaging groups → agent groups → sessions), get written into the session's inbound DB, and wake a container. The agent-runner inside the container polls the DB, calls the agent, and writes back to the outbound DB. The host polls the outbound DB and delivers through the same adapter.
|
||||
|
||||
**Everything is a message.** There is no IPC, no file watcher, no stdin piping between host and container. The two session DBs are the sole IO surface.
|
||||
|
||||
@@ -32,7 +32,7 @@ agent_group_members (user_id, agent_group_id) — unprivileged access gate
|
||||
user_dms (user_id, channel_type, messaging_group_id) — cold-DM cache
|
||||
|
||||
agent_groups (workspace, memory, CLAUDE.md, personality, container config)
|
||||
↕ many-to-many via messaging_group_agents (session_mode, trigger_rules, priority)
|
||||
↕ many-to-many via messaging_group_agents (session_mode, engage_mode/engage_pattern, sender_scope, priority)
|
||||
messaging_groups (one chat/channel on one platform; instance = adapter-instance name, defaults to channel_type; unknown_sender_policy)
|
||||
|
||||
sessions (agent_group_id + messaging_group_id + thread_id → per-session container)
|
||||
@@ -44,8 +44,8 @@ Privilege is user-level (owner/admin), not agent-group-level. See [docs/isolatio
|
||||
|
||||
Each session has **two** SQLite files under `data/v2-sessions/<session_id>/`:
|
||||
|
||||
- `inbound.db` — host writes, container reads. `messages_in`, routing, destinations, pending_questions, processing_ack.
|
||||
- `outbound.db` — container writes, host reads. `messages_out`, session_state.
|
||||
- `inbound.db` — host writes, container reads. `messages_in`, delivered, destinations, session_routing.
|
||||
- `outbound.db` — container writes, host reads. `messages_out`, processing_ack, session_state, container_state.
|
||||
|
||||
Exactly one writer per file — no cross-mount lock contention. Heartbeat is a file touch at `/workspace/.heartbeat`, not a DB update. Host uses even `seq` numbers, container uses odd.
|
||||
|
||||
@@ -65,7 +65,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/host-sweep.ts` | 60s sweep: `processing_ack` sync, stale detection, due-message wake, recurrence |
|
||||
| `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path |
|
||||
| `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` |
|
||||
| `src/container-runtime.ts` | Runtime selection (Docker vs Apple containers), orphan cleanup |
|
||||
| `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup |
|
||||
| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` |
|
||||
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry |
|
||||
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
|
||||
@@ -105,6 +105,7 @@ ncl help
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| tasks | list, get, create, update, cancel, pause, resume, delete, run, append-log | Scheduled tasks for an agent group |
|
||||
| user-dms | list | Cold-DM cache (read-only) |
|
||||
| dropped-messages | list | Messages from unregistered senders (read-only) |
|
||||
| approvals | list, get | Pending approval requests (read-only) |
|
||||
@@ -115,7 +116,7 @@ Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.
|
||||
|
||||
Trunk does not ship any specific channel adapter or non-default agent provider. The codebase is the registry/infra; the actual adapters and providers live on long-lived sibling branches and get copied in by skills:
|
||||
|
||||
- **`channels` branch** — Discord, Slack, Telegram, WhatsApp, Teams, Linear, GitHub, iMessage, Webex, Resend, Matrix, Google Chat, WhatsApp Cloud (+ helpers, tests, channel-specific setup steps). Installed via `/add-<channel>` skills.
|
||||
- **`channels` branch** — Discord, Slack, Telegram, WhatsApp, Teams, Linear, GitHub, iMessage, Webex, Resend, Matrix, Google Chat, WhatsApp Cloud, Signal, WeChat, DeltaChat, Emacs (+ helpers, tests, channel-specific setup steps). Installed via `/add-<channel>` skills.
|
||||
- **`providers` branch** — OpenCode (and any future non-default agent providers). Installed via `/add-opencode`.
|
||||
|
||||
Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy module(s) into the standard paths → append a self-registration import to the relevant barrel → `pnpm install <pkg>@<pinned-version>` → build.
|
||||
@@ -137,7 +138,7 @@ Per-agent-group container runtime config (provider, model, packages, MCP servers
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
|
||||
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
|
||||
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members`, `tasks` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
|
||||
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
|
||||
|
||||
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
|
||||
@@ -170,7 +171,7 @@ No container restart needed — the gateway looks up secrets per request.
|
||||
|
||||
Approval-gating credentialed actions is a **two-sided** flow:
|
||||
|
||||
- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@1.3.0`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating.
|
||||
- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@2.2.5`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating.
|
||||
- **Host-side** (nanoclaw): receives pending approvals and routes them to a human. `src/modules/approvals/onecli-approvals.ts` registers a callback via `onecli.configureManualApproval(cb)` (long-polls `GET /api/approvals/pending`). The callback uses `pickApprover` + `pickApprovalDelivery` from `src/modules/approvals/primitive.ts` to DM an approver. Approvers are resolved from the `user_roles` table — preference order: scoped admins for the agent group → global admins → owners. There is no env var like `NANOCLAW_ADMIN_USER_IDS`; roles are persisted in the central DB only.
|
||||
|
||||
If approvals are configured server-side but the host callback isn't running (or throws), every credentialed call hangs until the gateway times out. Conversely, if the gateway has no rule asking for approval, the host callback never fires regardless of how it's wired.
|
||||
@@ -216,7 +217,7 @@ Run commands directly — don't tell the user to run them.
|
||||
|
||||
```bash
|
||||
# Host (Node + pnpm)
|
||||
pnpm run dev # Host with hot reload
|
||||
pnpm run dev # Host via tsx (no watch)
|
||||
pnpm run build # Compile host TypeScript (src/)
|
||||
./container/build.sh # Rebuild agent container image (nanoclaw-agent:latest)
|
||||
pnpm test # Host tests (vitest)
|
||||
@@ -297,7 +298,7 @@ The agent container runs on **Bun**; the host runs on **Node** (pnpm). They comm
|
||||
- **Writing a new named-param SQL insert/update in the container** → use `$name` in both SQL and JS keys: `.run({ $id: msg.id })`. `bun:sqlite` does not auto-strip the prefix the way `better-sqlite3` does on the host. Positional `?` params work normally.
|
||||
- **Adding a test in `container/agent-runner/src/`** → import from `bun:test`, not `vitest`. Vitest runs on Node and can't load `bun:sqlite`. `vitest.config.ts` excludes this tree.
|
||||
- **Adding a Node CLI the agent invokes at runtime** (like `agent-browser`, `claude-code`, `vercel`) → put it in the Dockerfile's pnpm global-install block, pinned to an exact version via a new `ARG`. Don't use `bun install -g` — that bypasses the pnpm supply-chain policy.
|
||||
- **Changing the Dockerfile entrypoint or the dynamic-spawn command** (`src/container-runner.ts` line ~301) → keep `exec bun ...` so signals forward cleanly. The image has no `/app/dist`; don't reintroduce a tsc build step.
|
||||
- **Changing the Dockerfile entrypoint or the dynamic-spawn command** (`src/container-runner.ts` line ~503) → keep `exec bun ...` so signals forward cleanly. The image has no `/app/dist`; don't reintroduce a tsc build step.
|
||||
- **Changing session-DB pragmas** (`container/agent-runner/src/db/connection.ts`) → `journal_mode=DELETE` is load-bearing for cross-mount visibility. Read the comment block at the top of the file first.
|
||||
|
||||
## CJK font support
|
||||
|
||||
+4
-4
@@ -75,7 +75,7 @@ Standalone tools that ship code files alongside the SKILL.md. The SKILL.md tells
|
||||
|
||||
#### 3. Operational skills (instruction-only)
|
||||
|
||||
Workflows and guides with no code changes. The SKILL.md is the entire skill — Claude follows the instructions to perform a task.
|
||||
Workflows and guides with no code changes. The SKILL.md is the entire skill — the coding agent follows the instructions to perform a task.
|
||||
|
||||
**Location:** `.claude/skills/` on `main`
|
||||
|
||||
@@ -88,13 +88,13 @@ Workflows and guides with no code changes. The SKILL.md is the entire skill —
|
||||
|
||||
#### 4. Container skills (agent runtime)
|
||||
|
||||
Skills that run inside the agent container, not on the host. These teach the container agent how to use tools, format output, or perform tasks. They are synced into each group's `.claude/skills/` directory when a container starts.
|
||||
Skills that run inside the agent container, not on the host. These teach the NanoClaw agent how to use tools, format output, or perform tasks. They are synced into each group's `.claude/skills/` directory when a container starts.
|
||||
|
||||
**Location:** `container/skills/<name>/`
|
||||
|
||||
**Examples:** `agent-browser` (web browsing), `capabilities` (/capabilities command), `status` (/status command), `slack-formatting` (Slack mrkdwn syntax)
|
||||
**Examples:** `agent-browser` (web browsing), `frontend-engineer`, `onecli-gateway` (OneCLI proxy usage), `self-customize`, `slack-formatting` (Slack mrkdwn syntax), `vercel-cli`, `welcome`, `whatsapp-formatting`
|
||||
|
||||
**Key difference:** These are NOT invoked by the user on the host. They're loaded by Claude Code inside the container and influence how the agent behaves.
|
||||
**Key difference:** You never invoke these from a coding-agent session on the host, the way you run `/setup` or `/update-nanoclaw` in Claude Code/Codex/OpenCode. They're mounted into the sandbox and loaded by the NanoClaw agent itself, shaping how it behaves when you chat with it.
|
||||
|
||||
**Guidelines:**
|
||||
- Follow the same SKILL.md + frontmatter format
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw) is an impressive project, but I wouldn't have been able to sleep if I had given complex software I didn't understand full access to my life. OpenClaw has nearly half a million lines of code, 53 config files, and 70+ dependencies. Its security is at the application level (allowlists, pairing codes) rather than true OS-level isolation. Everything runs in one Node process with shared memory.
|
||||
|
||||
NanoClaw provides that same core functionality, but in a codebase small enough to understand: one process and a handful of files. Claude agents run in their own Linux containers with filesystem isolation, not merely behind permission checks.
|
||||
NanoClaw provides that same core functionality, but in a codebase small enough to understand: one process and a handful of files. Agents run in their own Linux containers with filesystem isolation, not merely behind permission checks.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -49,7 +49,7 @@ bash migrate-v2.sh
|
||||
|
||||
Run the script directly, not from inside a Claude session — the deterministic side needs interactive prompts and real shell I/O for Node/pnpm bootstrap, Docker, OneCLI, and the container build.
|
||||
|
||||
**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including Baileys keystore + LID mappings for WhatsApp), builds the agent container.
|
||||
**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including the Baileys keystore for WhatsApp — LID mapping is now resolved per-message by the Baileys v7 adapter, not migrated), builds the agent container.
|
||||
|
||||
**What it doesn't:** flip the system service. Pick *"switch to v2"* at the prompt, or do it manually after testing — your v1 install is left untouched.
|
||||
|
||||
@@ -78,11 +78,11 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
|
||||
- **Multi-channel messaging** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, and email via Resend. Installed on demand with `/add-<channel>` skills. Run one or many at the same time.
|
||||
- **Flexible isolation** — connect each channel to its own agent for full privacy, share one agent across many channels for unified memory with separate conversations, or fold multiple channels into a single shared session so one conversation spans many surfaces. Pick per channel via `/manage-channels`. See [docs/isolation-model.md](docs/isolation-model.md).
|
||||
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
|
||||
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
|
||||
- **Scheduled tasks** — recurring jobs executed by the agent, which can message you the results
|
||||
- **Web access** — search and fetch content from the web
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional Docker Sandboxes micro-VM isolation
|
||||
- **Container isolation** — agents are sandboxed in Docker containers (macOS/Linux/WSL2)
|
||||
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
||||
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
|
||||
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle via `ncl groups create --template <ref>`. Templates load from the local `templates/` folder; populate it by hand or by copying from the [public library](https://github.com/nanocoai/nanoclaw-templates). See [docs/templates.md](docs/templates.md).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -124,10 +124,7 @@ This keeps trunk as pure registry and infra, and every fork stays lean — users
|
||||
|
||||
### RFS (Request for Skills)
|
||||
|
||||
Skills we'd like to see:
|
||||
|
||||
**Communication Channels**
|
||||
- `/add-signal` — Add Signal as a channel
|
||||
No channel or provider skills are currently requested — propose one via an issue.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -142,7 +139,7 @@ Skills we'd like to see:
|
||||
messaging apps → host process (router) → inbound.db → container (Bun, Claude Agent SDK) → outbound.db → host process (delivery) → messaging apps
|
||||
```
|
||||
|
||||
A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs Claude, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter.
|
||||
A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs the agent, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter.
|
||||
|
||||
Two SQLite files per session, each with exactly one writer — no cross-mount contention, no IPC, no stdin piping. Channels and alternative providers self-register at startup; trunk ships the registry and the Chat SDK bridge, while the adapters themselves are skill-installed per fork.
|
||||
|
||||
@@ -165,7 +162,7 @@ Key files:
|
||||
|
||||
**Why Docker?**
|
||||
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. For additional isolation, Docker Sandboxes run each container inside a micro VM.
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem.
|
||||
|
||||
**Can I run this on Linux or Windows?**
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ type RequestFrame = {
|
||||
};
|
||||
|
||||
type ResponseFrame =
|
||||
| { id: string; ok: true; data: unknown }
|
||||
// `human` mirrors src/cli/frame.ts: an optional server-rendered string we
|
||||
// print verbatim instead of running our own (drift-prone) formatter.
|
||||
| { id: string; ok: true; data: unknown; human?: string }
|
||||
| { id: string; ok: false; error: { code: string; message: string } };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -244,6 +246,9 @@ if (!resp) {
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(JSON.stringify(resp, null, 2) + '\n');
|
||||
} else if (resp.ok && resp.human !== undefined) {
|
||||
// Server-rendered view — print verbatim.
|
||||
process.stdout.write(resp.human + '\n');
|
||||
} else {
|
||||
const output = formatHuman(resp);
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -120,6 +120,24 @@ export function markCompleted(ids: string[]): void {
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ack task messages whose pre-task script gated the run. The reason decides
|
||||
* the ack: `gated` (wakeAgent=false) is the monitor working as designed → a
|
||||
* plain `completed`; `error` (broken script) → `script-skip:error`, which the
|
||||
* host's ack sync records as a FAILED run so recurrence can read the trailing
|
||||
* failed streak off the occurrence rows and back the series off.
|
||||
*/
|
||||
export function markScriptSkipped(skips: Array<{ id: string; reason: string }>): void {
|
||||
if (skips.length === 0) return;
|
||||
const db = getOutboundDb();
|
||||
const stmt = db.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, ?, datetime('now'))",
|
||||
);
|
||||
db.transaction(() => {
|
||||
for (const s of skips) stmt.run(s.id, s.reason === 'error' ? 'script-skip:error' : 'completed');
|
||||
})();
|
||||
}
|
||||
|
||||
/** Mark a single message as failed — writes to processing_ack in outbound.db. */
|
||||
export function markFailed(id: string): void {
|
||||
getOutboundDb()
|
||||
|
||||
@@ -141,3 +141,22 @@ export function getUndeliveredMessages(): MessageOutRow[] {
|
||||
)
|
||||
.all() as MessageOutRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a deliberate send with this exact destination + text already exists
|
||||
* (an MCP send_message row from the current turn). Used by the task-fire
|
||||
* final-text dispatcher to drop the turn-final <message> echo of a send the
|
||||
* agent already made — the dedup happens where the duplication originates.
|
||||
*/
|
||||
export function hasIdenticalSend(platformId: string, channelType: string, text: string): boolean {
|
||||
const row = getOutboundDb()
|
||||
.prepare(
|
||||
`SELECT 1 FROM messages_out
|
||||
WHERE platform_id = $platform_id AND channel_type = $channel_type
|
||||
AND (in_reply_to IS NULL OR in_reply_to = '')
|
||||
AND json_extract(content, '$.text') = $text
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get({ $platform_id: platformId, $channel_type: channelType, $text: text });
|
||||
return row != null;
|
||||
}
|
||||
|
||||
@@ -105,13 +105,11 @@ function buildDestinationsSection(): string {
|
||||
const lines = ['## Sending messages', ''];
|
||||
if (all.length === 1) {
|
||||
const d = all[0];
|
||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||
lines.push(`Your destination is \`${d.name}\`${label}.`);
|
||||
lines.push(`Your destination is \`${d.name}\`${destinationLabel(d)}.`);
|
||||
} else {
|
||||
lines.push('You can send messages to the following destinations:', '');
|
||||
for (const d of all) {
|
||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||
lines.push(`- \`${d.name}\`${label}`);
|
||||
lines.push(`- \`${d.name}\`${destinationLabel(d)}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
@@ -128,3 +126,10 @@ function buildDestinationsSection(): string {
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function destinationLabel(d: DestinationEntry): string {
|
||||
const parts: string[] = [];
|
||||
if (d.channelType) parts.push(d.channelType);
|
||||
if (d.displayName && d.displayName !== d.name) parts.push(d.displayName);
|
||||
return parts.length > 0 ? ` (${parts.join(' · ')})` : '';
|
||||
}
|
||||
|
||||
@@ -98,6 +98,11 @@ export interface RoutingContext {
|
||||
channelType: string | null;
|
||||
threadId: string | null;
|
||||
inReplyTo: string | null;
|
||||
/** Batch is a task fire — explicit `<message to>` sends must NOT inherit
|
||||
* inReplyTo (the series id), or the host's task-fire suppression drops
|
||||
* them as turn-final echoes: zero delivery. Deliberate sends are
|
||||
* in_reply_to-null, same as the out-of-process MCP send_message path. */
|
||||
taskFire: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +116,7 @@ export function extractRouting(messages: MessageInRow[]): RoutingContext {
|
||||
channelType: first?.channel_type ?? null,
|
||||
threadId: first?.thread_id ?? null,
|
||||
inReplyTo: first?.id ?? null,
|
||||
taskFire: messages.length > 0 && messages.every((m) => m.kind === 'task'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,13 @@ Your CLI access may be scoped. Run `ncl help` to see which resources are availab
|
||||
|
||||
Run `ncl help` for the full list. Common resources:
|
||||
|
||||
| Resource | Verbs | What it is |
|
||||
|----------|-------|------------|
|
||||
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
| Resource | Verbs | What it is |
|
||||
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
| tasks | list, get, create, update, cancel, pause, resume, delete, append-log | Scheduled tasks for your agent group |
|
||||
|
||||
Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals.
|
||||
|
||||
@@ -33,11 +34,12 @@ Additional resources (available under `global` scope only): messaging-groups, wi
|
||||
- **Restarting your container** — `ncl groups restart` (with optional `--rebuild` and `--message`).
|
||||
- **Checking who's in your group** — `ncl members list`.
|
||||
- **Seeing your destinations** — `ncl destinations list`.
|
||||
- **Scheduling work** — `ncl tasks create`, then `ncl tasks list/get/update/cancel/pause/resume/delete`; `ncl tasks run <id>` fires one extra run now (testing) without changing the schedule. At the end of each task run, `ncl tasks append-log --msg "…"` to record what happened (host-timestamped, not a message).
|
||||
- **Answering questions about the system** — query `ncl` rather than guessing.
|
||||
|
||||
### Access rules
|
||||
|
||||
Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it.
|
||||
Read commands (list, get) are open. Most write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it. `ncl tasks` is the exception: an agent can manage its own group tasks without approval.
|
||||
|
||||
### Approval flow
|
||||
|
||||
@@ -61,6 +63,13 @@ ncl groups config get
|
||||
ncl sessions list
|
||||
ncl destinations list
|
||||
ncl members list
|
||||
ncl tasks list
|
||||
# Always pass a short descriptive --name so the task id is readable (e.g. daily-briefing-a25c, not a long uuid).
|
||||
# For a recurring task, --recurrence alone sets the schedule (first run derived from it); add --process-after only for one-shots.
|
||||
ncl tasks create --name "daily briefing" --prompt "Send the daily briefing" --recurrence "0 9 * * *"
|
||||
# At the END of every task run, record one line of history. The host stamps the UTC time — you supply only the summary.
|
||||
# This is a LOG ENTRY, not a message: it sends nothing to anyone. Inside a task fire --id is auto-derived from your session.
|
||||
ncl tasks append-log --msg "posted the daily digest to slack; one feed returned 403, skipped"
|
||||
|
||||
# Write commands (approval required)
|
||||
ncl groups restart
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* at module scope, and append the import here. No central list.
|
||||
*/
|
||||
import './core.js';
|
||||
import './scheduling.js';
|
||||
import './interactive.js';
|
||||
import './agents.js';
|
||||
import './self-mod.js';
|
||||
|
||||
@@ -1,40 +1,25 @@
|
||||
## Task scheduling (`schedule_task`)
|
||||
## Task scheduling (`ncl tasks`)
|
||||
|
||||
For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below.
|
||||
Use `ncl tasks` for one-shot and recurring tasks. A task runs in this agent group's system session, not in the current chat session, so when it fires you must choose a destination explicitly with `<message to="name">...</message>` or `send_message({ to: "name", ... })`.
|
||||
|
||||
To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule.
|
||||
Pass `--name "<short label>"` on create to get a readable task id (e.g. `--name "sales briefing"` → `sales-briefing-a25c`); without it ids are `t-<hex>`.
|
||||
|
||||
Frequent recurring scheduled tasks — more than a few times a day — consume API credits and can risk account restrictions. You can add a `script` that runs first, and you will only be called when the check passes.
|
||||
|
||||
### How it works
|
||||
|
||||
1. Provide a bash `script` alongside the `prompt` when scheduling
|
||||
2. When the task fires, the script runs first
|
||||
3. Script returns: `{ "wakeAgent": true/false, "data": {...} }`
|
||||
4. If `wakeAgent: false` — nothing happens, task waits for next run
|
||||
5. If `wakeAgent: true` — claude receives the script's data + prompt and handles
|
||||
|
||||
### Always test your script first
|
||||
|
||||
Before scheduling, run the script directly to verify it works:
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
bash -c 'node --input-type=module -e "
|
||||
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
|
||||
const prs = await r.json();
|
||||
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
|
||||
"'
|
||||
ncl tasks create --name "ping" --prompt "Remind me to call Dana" --process-after "tomorrow 18:00"
|
||||
ncl tasks list
|
||||
ncl tasks get ping-a25c # includes run count, failures, and recent run-log lines
|
||||
ncl tasks run ping-a25c # fire once now without changing the schedule (testing)
|
||||
ncl tasks update ping-a25c --prompt "New instructions"
|
||||
ncl tasks pause ping-a25c
|
||||
ncl tasks resume ping-a25c
|
||||
ncl tasks cancel ping-a25c # or --all as a kill switch
|
||||
ncl tasks delete ping-a25c
|
||||
```
|
||||
|
||||
### When NOT to use scripts
|
||||
Use good judgement on whether it's appropriate to check in with the user about the task prompt before task creation, and if so, whether to share verbatim or a description of it.
|
||||
|
||||
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. Do not attempt to do things like sentiment analysis or advanced nlp in scripts.
|
||||
`--process-after` accepts UTC timestamps or naive local timestamps interpreted in the instance timezone (shown in the `<context timezone="..."/>` header).
|
||||
|
||||
### Frequent task guidance
|
||||
|
||||
If a user wants a task to run more than a few times a day and a script can't be used:
|
||||
|
||||
- Explain that each time the task fires it uses API credits and risks rate limits
|
||||
- Suggest adjusting the task requirements in a way that will allow you to use a script
|
||||
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
|
||||
- Help the user find the minimum viable frequency
|
||||
Run `ncl tasks create --help` for schedules, options, and pre-task gate scripts (checks that run before you wake).
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
/**
|
||||
* Scheduling MCP tools: schedule_task, list_tasks, cancel_task, pause_task, resume_task.
|
||||
*
|
||||
* With the two-DB split, the container cannot write to inbound.db (host-owned).
|
||||
* Scheduling operations are sent as system actions via messages_out — the host
|
||||
* reads them during delivery and applies the changes to inbound.db.
|
||||
*/
|
||||
import { getInboundDb } from '../db/connection.js';
|
||||
import { writeMessageOut } from '../db/messages-out.js';
|
||||
import { getSessionRouting } from '../db/session-routing.js';
|
||||
import { TIMEZONE, parseZonedToUtc } from '../timezone.js';
|
||||
import { registerTools } from './server.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[mcp-tools] ${msg}`);
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function routing() {
|
||||
return getSessionRouting();
|
||||
}
|
||||
|
||||
function ok(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
function err(text: string) {
|
||||
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
|
||||
}
|
||||
|
||||
export const scheduleTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'schedule_task',
|
||||
description:
|
||||
`Schedule a one-shot or recurring task. The user's timezone is declared in the <context timezone="..."/> header of your prompt — interpret the user's "9pm" etc. in that zone. Cron expressions are interpreted in the user's timezone too.`,
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
prompt: { type: 'string', description: 'Task instructions/prompt' },
|
||||
processAfter: {
|
||||
type: 'string',
|
||||
description:
|
||||
`ISO 8601 timestamp for the first run. Accepts either UTC (ending in "Z" or "+00:00") or a naive local timestamp (no offset) which is interpreted in the user's timezone (e.g. "2026-01-15T21:00:00" = 9pm user-local). Prefer naive local.`,
|
||||
},
|
||||
recurrence: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" = weekdays at 9am user-local). Evaluated in the user\'s timezone.',
|
||||
},
|
||||
script: { type: 'string', description: 'Optional pre-agent script to run before processing' },
|
||||
},
|
||||
required: ['prompt', 'processAfter'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const prompt = args.prompt as string;
|
||||
const processAfterIn = args.processAfter as string;
|
||||
if (!prompt || !processAfterIn) return err('prompt and processAfter are required');
|
||||
|
||||
let processAfter: string;
|
||||
try {
|
||||
const d = parseZonedToUtc(processAfterIn, TIMEZONE);
|
||||
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${processAfterIn}`);
|
||||
processAfter = d.toISOString();
|
||||
} catch {
|
||||
return err(`invalid processAfter: ${processAfterIn}`);
|
||||
}
|
||||
|
||||
const id = generateId();
|
||||
const r = routing();
|
||||
const recurrence = (args.recurrence as string) || null;
|
||||
const script = (args.script as string) || null;
|
||||
|
||||
// Write as a system action — host will insert into inbound.db
|
||||
writeMessageOut({
|
||||
id,
|
||||
kind: 'system',
|
||||
platform_id: r.platform_id,
|
||||
channel_type: r.channel_type,
|
||||
thread_id: r.thread_id,
|
||||
content: JSON.stringify({
|
||||
action: 'schedule_task',
|
||||
taskId: id,
|
||||
prompt,
|
||||
script,
|
||||
processAfter,
|
||||
recurrence,
|
||||
platformId: r.platform_id,
|
||||
channelType: r.channel_type,
|
||||
threadId: r.thread_id,
|
||||
}),
|
||||
});
|
||||
|
||||
log(`schedule_task: ${id} at ${processAfter}${recurrence ? ` (recurring: ${recurrence})` : ''}`);
|
||||
return ok(`Task scheduled (id: ${id}, runs at: ${processAfter}${recurrence ? `, recurrence: ${recurrence}` : ''})`);
|
||||
},
|
||||
};
|
||||
|
||||
export const listTasks: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'list_tasks',
|
||||
description:
|
||||
'List scheduled tasks. Returns one row per series — the live (pending or paused) occurrence. The id shown is the series id, which is what update_task / cancel_task / pause_task / resume_task expect.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
status: { type: 'string', description: 'Filter by status: pending or paused (default: both)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const status = args.status as string | undefined;
|
||||
const db = getInboundDb();
|
||||
// One row per series — the live (pending or paused) occurrence. Recurring
|
||||
// tasks accumulate one completed row per firing plus one live follow-up;
|
||||
// exposing the whole pile to the agent is noisy and confuses task identity
|
||||
// ("which id do I cancel?"). The series_id is the stable handle.
|
||||
//
|
||||
// SQLite quirk: when MAX(seq) appears in the SELECT list of a GROUP BY
|
||||
// query, the bare columns take values from the row that contains that max
|
||||
// — that's how we pick "the latest live row per series" in one pass.
|
||||
let rows;
|
||||
if (status) {
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
|
||||
FROM messages_in
|
||||
WHERE kind = 'task' AND status = ?
|
||||
GROUP BY series_id
|
||||
ORDER BY process_after ASC`,
|
||||
)
|
||||
.all(status);
|
||||
} else {
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
|
||||
FROM messages_in
|
||||
WHERE kind = 'task' AND status IN ('pending', 'paused')
|
||||
GROUP BY series_id
|
||||
ORDER BY process_after ASC`,
|
||||
)
|
||||
.all();
|
||||
}
|
||||
|
||||
if ((rows as unknown[]).length === 0) return ok('No tasks found.');
|
||||
|
||||
const lines = (rows as Array<{ id: string; status: string; process_after: string | null; recurrence: string | null; content: string }>).map((r) => {
|
||||
const content = JSON.parse(r.content);
|
||||
const prompt = (content.prompt as string || '').slice(0, 80);
|
||||
return `- ${r.id} [${r.status}] at=${r.process_after || 'now'} ${r.recurrence ? `recur=${r.recurrence} ` : ''}→ ${prompt}`;
|
||||
});
|
||||
|
||||
return ok(lines.join('\n'));
|
||||
},
|
||||
};
|
||||
|
||||
export const cancelTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'cancel_task',
|
||||
description: 'Cancel a scheduled task.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Task ID to cancel' },
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
// Write as a system action — host will update inbound.db
|
||||
writeMessageOut({
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'cancel_task', taskId }),
|
||||
});
|
||||
|
||||
log(`cancel_task: ${taskId}`);
|
||||
return ok(`Task cancellation requested: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const pauseTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'pause_task',
|
||||
description: 'Pause a scheduled task. It will not run until resumed.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Task ID to pause' },
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
writeMessageOut({
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'pause_task', taskId }),
|
||||
});
|
||||
|
||||
log(`pause_task: ${taskId}`);
|
||||
return ok(`Task pause requested: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const resumeTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'resume_task',
|
||||
description: 'Resume a paused task.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Task ID to resume' },
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
writeMessageOut({
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'resume_task', taskId }),
|
||||
});
|
||||
|
||||
log(`resume_task: ${taskId}`);
|
||||
return ok(`Task resume requested: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const updateTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'update_task',
|
||||
description:
|
||||
'Update a scheduled task. Pass the series id from list_tasks. Any field omitted is left unchanged. Use this instead of cancel + reschedule when adjusting an existing task.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Series id of the task to update (as shown by list_tasks)' },
|
||||
prompt: { type: 'string', description: 'New task prompt (optional)' },
|
||||
recurrence: {
|
||||
type: 'string',
|
||||
description: 'New cron expression (optional). Pass empty string to clear and make the task one-shot.',
|
||||
},
|
||||
processAfter: {
|
||||
type: 'string',
|
||||
description:
|
||||
`New ISO 8601 timestamp for the next run (optional). Accepts either UTC (ending in "Z" / "+00:00") or a naive local timestamp interpreted in the user's timezone.`,
|
||||
},
|
||||
script: {
|
||||
type: 'string',
|
||||
description: 'New pre-agent script (optional). Pass empty string to clear.',
|
||||
},
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
const update: Record<string, unknown> = { taskId };
|
||||
if (typeof args.prompt === 'string') update.prompt = args.prompt;
|
||||
if (typeof args.processAfter === 'string') {
|
||||
try {
|
||||
const d = parseZonedToUtc(args.processAfter, TIMEZONE);
|
||||
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${args.processAfter}`);
|
||||
update.processAfter = d.toISOString();
|
||||
} catch {
|
||||
return err(`invalid processAfter: ${args.processAfter}`);
|
||||
}
|
||||
}
|
||||
// Empty string clears recurrence/script; undefined leaves them as-is.
|
||||
if (typeof args.recurrence === 'string') update.recurrence = args.recurrence === '' ? null : args.recurrence;
|
||||
if (typeof args.script === 'string') update.script = args.script === '' ? null : args.script;
|
||||
|
||||
if (Object.keys(update).length === 1) return err('at least one field to update is required');
|
||||
|
||||
writeMessageOut({
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'update_task', ...update }),
|
||||
});
|
||||
|
||||
log(`update_task: ${taskId}`);
|
||||
return ok(`Task update requested: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
registerTools([scheduleTask, listTasks, updateTask, cancelTask, pauseTask, resumeTask]);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { getPendingMessages, markProcessing, markCompleted, markScriptSkipped, type MessageInRow } from './db/messages-in.js';
|
||||
import { hasIdenticalSend, writeMessageOut } from './db/messages-out.js';
|
||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
@@ -207,15 +207,15 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// Without the scheduling module, the marker block is empty, `keep`
|
||||
// falls back to `normalMessages`, and no gating happens.
|
||||
let keep: MessageInRow[] = normalMessages;
|
||||
let skipped: string[] = [];
|
||||
let skipped: Array<{ id: string; reason: string }> = [];
|
||||
// MODULE-HOOK:scheduling-pre-task:start
|
||||
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
|
||||
const preTask = await applyPreTaskScripts(normalMessages);
|
||||
keep = preTask.keep;
|
||||
skipped = preTask.skipped;
|
||||
if (skipped.length > 0) {
|
||||
markCompleted(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.join(', ')}`);
|
||||
markScriptSkipped(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.map((s) => s.id).join(', ')}`);
|
||||
}
|
||||
// MODULE-HOOK:scheduling-pre-task:end
|
||||
|
||||
@@ -238,7 +238,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
});
|
||||
|
||||
// Process the query while concurrently polling for new messages
|
||||
const skippedSet = new Set(skipped);
|
||||
const skippedSet = new Set(skipped.map((s) => s.id));
|
||||
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
|
||||
// Publish the batch's in_reply_to so MCP tools (send_message, send_file)
|
||||
// can stamp it on outbound rows — needed for a2a return-path routing.
|
||||
@@ -401,15 +401,15 @@ export async function processQuery(
|
||||
// its script gate and always wakes the agent, defeating the gate.
|
||||
// Mirrors the initial-batch hook above.
|
||||
let keep = newMessages;
|
||||
let skipped: string[] = [];
|
||||
let skipped: Array<{ id: string; reason: string }> = [];
|
||||
// MODULE-HOOK:scheduling-pre-task-followup:start
|
||||
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
|
||||
const preTask = await applyPreTaskScripts(newMessages);
|
||||
keep = preTask.keep;
|
||||
skipped = preTask.skipped;
|
||||
if (skipped.length > 0) {
|
||||
markCompleted(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`);
|
||||
markScriptSkipped(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.map((s) => s.id).join(', ')}`);
|
||||
}
|
||||
// MODULE-HOOK:scheduling-pre-task-followup:end
|
||||
|
||||
@@ -650,6 +650,15 @@ function dispatchResultText(text: string, routing: RoutingContext): { sent: numb
|
||||
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
|
||||
const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
|
||||
const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
|
||||
// Task fires: an explicitly-addressed final-text block is either the echo of
|
||||
// an MCP send the agent already made this turn (drop it HERE, where the
|
||||
// duplication originates) or the agent's only deliberate send (write it
|
||||
// in_reply_to-null like the MCP path, or the host's task-fire suppression
|
||||
// would discard it — zero delivery).
|
||||
if (routing.taskFire && hasIdenticalSend(platformId, channelType, body)) {
|
||||
log(`Dropping turn-final echo of an already-sent task message to ${dest.name}`);
|
||||
return;
|
||||
}
|
||||
// Resolve thread_id per-destination from the most recent inbound message
|
||||
// that came from this same channel+platform. In agent-shared sessions,
|
||||
// different destinations have different thread contexts — using a single
|
||||
@@ -657,7 +666,7 @@ function sendToDestination(dest: DestinationEntry, body: string, routing: Routin
|
||||
const destRouting = resolveDestinationThread(channelType, platformId);
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo,
|
||||
in_reply_to: destRouting?.inReplyTo ?? (routing.taskFire ? null : routing.inReplyTo),
|
||||
kind: 'chat',
|
||||
platform_id: platformId,
|
||||
channel_type: channelType,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Drift guard for the harness tool surface. sdk-tools-baseline.json is a wire
|
||||
* capture of every tool the pinned CLI can offer under our configuration
|
||||
* (regenerate with dump-sdk-tools.ts — instructions in its header). These
|
||||
* tests catch upstream renames/removals when the claude-code pin moves:
|
||||
* bumping container/cli-tools.json fails the version assertion until the
|
||||
* fixture is regenerated and the lists below are re-verified.
|
||||
*/
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import cliTools from '../../../cli-tools.json';
|
||||
import { SDK_DISALLOWED_TOOLS, TOOL_ALLOWLIST } from './claude.js';
|
||||
import baseline from './sdk-tools-baseline.json';
|
||||
|
||||
/**
|
||||
* Disallow entries that do NOT exist on the pinned CLI in headless SDK mode
|
||||
* (wire-verified: never offered, in both string and streaming input modes).
|
||||
* Kept in SDK_DISALLOWED_TOOLS as drift insurance — if an upstream version
|
||||
* starts offering one, the fixture regeneration surfaces it here and the
|
||||
* entry moves out of this set.
|
||||
*/
|
||||
const KNOWN_ABSENT_DISALLOWED = ['AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode'];
|
||||
|
||||
const baselineTools = new Set<string>(baseline.tools);
|
||||
|
||||
describe('sdk tool-surface drift guard', () => {
|
||||
it('fixture matches the pinned claude-code CLI version', () => {
|
||||
const pin = cliTools.find((t) => t.name === '@anthropic-ai/claude-code')?.version;
|
||||
expect(baseline.cliVersion).toBe(pin);
|
||||
});
|
||||
|
||||
it('every allowlist entry names a real tool on this surface', () => {
|
||||
for (const name of TOOL_ALLOWLIST) {
|
||||
expect(baselineTools.has(name), `allowlist entry '${name}' not in captured surface`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('every disallow entry is either a real tool or documented drift insurance', () => {
|
||||
for (const name of SDK_DISALLOWED_TOOLS) {
|
||||
const real = baselineTools.has(name);
|
||||
const insurance = KNOWN_ABSENT_DISALLOWED.includes(name);
|
||||
expect(
|
||||
real || insurance,
|
||||
`disallow entry '${name}' is neither on the surface nor in KNOWN_ABSENT_DISALLOWED`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('drift-insurance entries are still absent from the surface', () => {
|
||||
for (const name of KNOWN_ABSENT_DISALLOWED) {
|
||||
expect(
|
||||
baselineTools.has(name),
|
||||
`'${name}' now exists on the surface — move it out of KNOWN_ABSENT_DISALLOWED and re-verify its disposition`,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,13 +17,13 @@ function log(msg: string): void {
|
||||
// Code's interactive UI and would hang here).
|
||||
//
|
||||
// - CronCreate / CronDelete / CronList / ScheduleWakeup: we have durable
|
||||
// scheduling via mcp__nanoclaw__schedule_task.
|
||||
// scheduling via `ncl tasks`.
|
||||
// - AskUserQuestion: SDK returns a placeholder instead of blocking on a
|
||||
// real answer — we have mcp__nanoclaw__ask_user_question that persists
|
||||
// the question and blocks on the real reply.
|
||||
// - EnterPlanMode / ExitPlanMode / EnterWorktree / ExitWorktree: Claude
|
||||
// Code UI affordances; in a headless container they'd appear stuck.
|
||||
const SDK_DISALLOWED_TOOLS = [
|
||||
export const SDK_DISALLOWED_TOOLS = [
|
||||
'CronCreate',
|
||||
'CronDelete',
|
||||
'CronList',
|
||||
@@ -35,30 +35,35 @@ const SDK_DISALLOWED_TOOLS = [
|
||||
'ExitWorktree',
|
||||
];
|
||||
|
||||
// Tool allowlist for NanoClaw agent containers. MCP-tool entries are derived
|
||||
// at the call site from the registered `mcpServers` map so that any server
|
||||
// added via `add_mcp_server` (or wired in container.json directly) is
|
||||
// reachable to the agent — without this, the SDK's allowedTools filter
|
||||
// silently drops every MCP namespace not listed here.
|
||||
const TOOL_ALLOWLIST = [
|
||||
// Pre-approved tool set for NanoClaw agent containers. Two verified facts
|
||||
// about `allowedTools` on the pinned CLI (wire-captured, see
|
||||
// sdk-tools-baseline.json + claude.tools.test.ts):
|
||||
//
|
||||
// 1. It is NOT an availability filter — omitting a tool does not remove it
|
||||
// (true for builtin and MCP names alike), and its permission-approval
|
||||
// function is moot under this runner's `bypassPermissions`.
|
||||
// 2. It DOES promote optional tools into the surface: `Glob` and `Grep` are
|
||||
// only offered to the model because they are listed here. Do not remove
|
||||
// them casually — that is the one way this list changes agent behavior.
|
||||
//
|
||||
// The per-server `mcpAllowPattern` entries derived at the call site are
|
||||
// retained because permission-gating of MCP invocation under non-bypass
|
||||
// modes is unverified. Exported for the fixture regenerator and tests.
|
||||
export const TOOL_ALLOWLIST = [
|
||||
'Agent',
|
||||
'Bash',
|
||||
'Read',
|
||||
'Write',
|
||||
'Edit',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'WebSearch',
|
||||
'WebFetch',
|
||||
'Task',
|
||||
'NotebookEdit',
|
||||
'Read',
|
||||
'SendMessage',
|
||||
'Skill',
|
||||
'TaskOutput',
|
||||
'TaskStop',
|
||||
'TeamCreate',
|
||||
'TeamDelete',
|
||||
'SendMessage',
|
||||
'TodoWrite',
|
||||
'ToolSearch',
|
||||
'Skill',
|
||||
'NotebookEdit',
|
||||
'WebFetch',
|
||||
'WebSearch',
|
||||
'Write',
|
||||
];
|
||||
|
||||
// MCP server names are sanitized by the SDK when forming tool prefixes:
|
||||
@@ -449,7 +454,7 @@ export class ClaudeProvider implements AgentProvider {
|
||||
yield { type: 'result', text, isError: m.is_error === true };
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') {
|
||||
yield { type: 'error', message: 'API retry', retryable: true };
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') {
|
||||
} else if (message.type === 'rate_limit_event') {
|
||||
yield { type: 'error', message: 'Rate limit', retryable: false, classification: 'quota' };
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
||||
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Regenerates sdk-tools-baseline.json — the bare SDK tool-surface fixture
|
||||
* asserted by claude.tools.test.ts.
|
||||
*
|
||||
* Must run INSIDE the agent container image (the pinned CLI binary only
|
||||
* exists there). From the repo root:
|
||||
*
|
||||
* docker run --rm --network none \
|
||||
* -v "$PWD/container/agent-runner/src":/app/src:ro \
|
||||
* --entrypoint bun <nanoclaw-agent image> /app/src/providers/dump-sdk-tools.ts \
|
||||
* > container/agent-runner/src/providers/sdk-tools-baseline.json
|
||||
*
|
||||
* Maximal-surface capture: the production TOOL_ALLOWLIST is passed (it
|
||||
* PROMOTES optional tools — Glob/Grep only appear when listed) but no
|
||||
* disallowedTools, and agent-teams is enabled via a temp settings.json
|
||||
* (settings env strictly beats SDK options env, so this is the only way to
|
||||
* enable it — see docs/harness-capabilities.md). Zero API traffic:
|
||||
* ANTHROPIC_BASE_URL points at an in-process stub answering 401; the full
|
||||
* tools array rides on the first /v1/messages request, which is captured
|
||||
* before the run dies on the auth error.
|
||||
*
|
||||
* The fixture records WIRE tool names (what the model actually sees). The
|
||||
* SDK init message reports legacy alias names for some tools (e.g. `Task`
|
||||
* where the wire says `Agent`) — do not swap this to an init capture.
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
import { TOOL_ALLOWLIST } from './claude.js';
|
||||
|
||||
const requests: string[] = [];
|
||||
let captured: (() => void) | null = null;
|
||||
const firstRequest = new Promise<void>((resolve) => {
|
||||
captured = resolve;
|
||||
});
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname: '127.0.0.1',
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
const body = await req.text();
|
||||
if (url.pathname.includes('/messages')) {
|
||||
requests.push(body);
|
||||
captured?.();
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ type: 'error', error: { type: 'authentication_error', message: 'fixture-capture-stub' } }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const HOME = '/tmp/dump-sdk-tools-home';
|
||||
const CWD = '/tmp/dump-sdk-tools-ws';
|
||||
fs.mkdirSync(`${HOME}/.claude`, { recursive: true });
|
||||
fs.mkdirSync(CWD, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
`${HOME}/.claude/settings.json`,
|
||||
JSON.stringify({ env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } }, null, 2),
|
||||
);
|
||||
|
||||
const q = query({
|
||||
prompt: 'fixture capture: reply with one word',
|
||||
options: {
|
||||
cwd: CWD,
|
||||
pathToClaudeCodeExecutable: '/pnpm/claude',
|
||||
systemPrompt: { type: 'preset' as const, preset: 'claude_code' as const },
|
||||
env: {
|
||||
...process.env,
|
||||
HOME,
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.port}`,
|
||||
ANTHROPIC_API_KEY: 'fixture-dummy-key',
|
||||
ANTHROPIC_AUTH_TOKEN: undefined,
|
||||
},
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['user'],
|
||||
allowedTools: TOOL_ALLOWLIST,
|
||||
},
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const _m of q) {
|
||||
/* drain until the auth error kills the run */
|
||||
}
|
||||
} catch {
|
||||
/* expected: 401 from the stub */
|
||||
}
|
||||
})();
|
||||
|
||||
await Promise.race([firstRequest, Bun.sleep(75_000)]);
|
||||
await Bun.sleep(1_500); // let retries land so we can pick the largest body
|
||||
|
||||
if (requests.length === 0) {
|
||||
console.error('[dump-sdk-tools] no /v1/messages request captured');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const biggest = requests.reduce((a, b) => (b.length > a.length ? b : a));
|
||||
const parsed = JSON.parse(biggest) as { tools?: Array<{ name: string }> };
|
||||
const tools = [...new Set((parsed.tools ?? []).map((t) => t.name))].sort();
|
||||
|
||||
const cliVersionRaw = execFileSync('/pnpm/claude', ['--version'], { encoding: 'utf8' }).trim();
|
||||
const cliVersion = cliVersionRaw.split(/\s+/)[0];
|
||||
const sdkVersion = (
|
||||
JSON.parse(fs.readFileSync('/app/node_modules/@anthropic-ai/claude-agent-sdk/package.json', 'utf8')) as {
|
||||
version: string;
|
||||
}
|
||||
).version;
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
cliVersion,
|
||||
sdkVersion,
|
||||
capturedAt: new Date().toISOString(),
|
||||
capture: 'production allowlist (promotes Glob/Grep), no disallowedTools, teams enabled via settings; wire names',
|
||||
tools,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"cliVersion": "2.1.197",
|
||||
"sdkVersion": "0.3.197",
|
||||
"capturedAt": "2026-07-08T12:06:41.811Z",
|
||||
"capture": "production allowlist (promotes Glob/Grep), no disallowedTools, teams enabled via settings; wire names",
|
||||
"tools": [
|
||||
"Agent",
|
||||
"Bash",
|
||||
"CronCreate",
|
||||
"CronDelete",
|
||||
"CronList",
|
||||
"DesignSync",
|
||||
"Edit",
|
||||
"EnterWorktree",
|
||||
"ExitWorktree",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"NotebookEdit",
|
||||
"Read",
|
||||
"ReportFindings",
|
||||
"ScheduleWakeup",
|
||||
"SendMessage",
|
||||
"Skill",
|
||||
"TaskCreate",
|
||||
"TaskGet",
|
||||
"TaskList",
|
||||
"TaskOutput",
|
||||
"TaskStop",
|
||||
"TaskUpdate",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"Workflow",
|
||||
"Write"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Container leg of the script-failure backoff chain, tested at unit level so
|
||||
* the e2e suite doesn't need a live multi-sweep scenario for it:
|
||||
*
|
||||
* script error → applyPreTaskScripts skips with reason 'error'
|
||||
* → markScriptSkipped acks `script-skip:error` in outbound.db
|
||||
* (gated → plain 'completed': the monitor working as designed).
|
||||
*
|
||||
* The host leg (ack → FAILED run → streak backoff) is pinned in
|
||||
* src/db/session-db.test.ts and src/modules/scheduling/recurrence.test.ts —
|
||||
* both sides pin the literal 'script-skip:error'; if either renames it, its
|
||||
* own test goes red.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '../db/connection.js';
|
||||
import { getPendingMessages, markScriptSkipped } from '../db/messages-in.js';
|
||||
import { applyPreTaskScripts } from './task-script.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
function insertTask(id: string, script: string) {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, trigger, content)
|
||||
VALUES (?, 'task', datetime('now'), 'pending', 1, ?)`,
|
||||
)
|
||||
.run(id, JSON.stringify({ prompt: 'monitor', script }));
|
||||
}
|
||||
|
||||
const ackStatus = (id: string): string | undefined =>
|
||||
(getOutboundDb().prepare('SELECT status FROM processing_ack WHERE message_id = ?').get(id) as { status: string } | undefined)
|
||||
?.status;
|
||||
|
||||
describe('script-skip ack chain (container leg)', () => {
|
||||
it('an erroring script skips with reason "error" and acks script-skip:error', async () => {
|
||||
insertTask('t-err', 'echo boom >&2; exit 1');
|
||||
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
|
||||
|
||||
expect(keep).toHaveLength(0);
|
||||
expect(skipped).toEqual([{ id: 't-err', reason: 'error' }]);
|
||||
|
||||
markScriptSkipped(skipped);
|
||||
expect(ackStatus('t-err')).toBe('script-skip:error');
|
||||
});
|
||||
|
||||
it('a deliberate wakeAgent=false gate acks plain completed — never backs off', async () => {
|
||||
insertTask('t-gated', 'echo \'{"wakeAgent": false}\'');
|
||||
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
|
||||
|
||||
expect(keep).toHaveLength(0);
|
||||
expect(skipped).toEqual([{ id: 't-gated', reason: 'gated' }]);
|
||||
|
||||
markScriptSkipped(skipped);
|
||||
expect(ackStatus('t-gated')).toBe('completed');
|
||||
});
|
||||
|
||||
it('wakeAgent=true keeps the task and enriches the prompt with script data', async () => {
|
||||
insertTask('t-wake', 'echo \'{"wakeAgent": true, "data": {"alerts": 2}}\'');
|
||||
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
|
||||
|
||||
expect(skipped).toHaveLength(0);
|
||||
expect(keep).toHaveLength(1);
|
||||
expect(JSON.parse(keep[0].content).scriptOutput).toEqual({ alerts: 2 });
|
||||
});
|
||||
});
|
||||
@@ -64,21 +64,26 @@ export async function runScript(script: string, taskId: string): Promise<ScriptR
|
||||
});
|
||||
}
|
||||
|
||||
/** Why a script gated its task: deliberate wakeAgent=false vs a broken script. */
|
||||
export type ScriptSkipReason = 'gated' | 'error';
|
||||
|
||||
export interface TaskScriptOutcome {
|
||||
keep: MessageInRow[];
|
||||
skipped: string[];
|
||||
skipped: Array<{ id: string; reason: ScriptSkipReason }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run pre-task scripts for any task messages that carry one, serially.
|
||||
* - Errors / missing output / wakeAgent=false → task id added to `skipped`.
|
||||
* - Errors / missing output / wakeAgent=false → task id added to `skipped`,
|
||||
* with the reason. The caller acks these as script-skips (not plain
|
||||
* completions) so the host can count consecutive failures and back off.
|
||||
* - wakeAgent=true → content JSON is mutated to carry `scriptOutput`, so the
|
||||
* formatter renders it into the prompt.
|
||||
* Non-task messages and tasks without scripts pass through unchanged.
|
||||
*/
|
||||
export async function applyPreTaskScripts(messages: MessageInRow[]): Promise<TaskScriptOutcome> {
|
||||
const keep: MessageInRow[] = [];
|
||||
const skipped: string[] = [];
|
||||
const skipped: Array<{ id: string; reason: ScriptSkipReason }> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.kind !== 'task') {
|
||||
@@ -106,9 +111,9 @@ export async function applyPreTaskScripts(messages: MessageInRow[]): Promise<Tas
|
||||
touchHeartbeat();
|
||||
|
||||
if (!result || !result.wakeAgent) {
|
||||
const reason = result ? 'wakeAgent=false' : 'script error/no output';
|
||||
log(`task ${msg.id} skipped: ${reason}`);
|
||||
skipped.push(msg.id);
|
||||
const reason: ScriptSkipReason = result ? 'gated' : 'error';
|
||||
log(`task ${msg.id} skipped: ${reason === 'gated' ? 'wakeAgent=false' : 'script error/no output'}`);
|
||||
skipped.push({ id: msg.id, reason });
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+12
-12
@@ -20,7 +20,7 @@ The entire codebase should be something you can read and understand. One Node.js
|
||||
|
||||
### Security Through True Isolation
|
||||
|
||||
Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac.
|
||||
Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host.
|
||||
|
||||
### Built for the Individual User
|
||||
|
||||
@@ -47,23 +47,23 @@ When people contribute, they shouldn't add "Telegram support alongside WhatsApp.
|
||||
Skills we'd like to see contributed:
|
||||
|
||||
### Communication Channels
|
||||
- `/add-signal` - Add Signal as a channel
|
||||
- `/add-matrix` - Add Matrix integration
|
||||
|
||||
> **Note:** Telegram, Slack, Discord, Gmail, and Apple Container skills already exist. See the [skills documentation](https://docs.nanoclaw.dev/integrations/skills-system) for the full list.
|
||||
None currently — Signal and Matrix have since shipped as skills.
|
||||
|
||||
> **Note:** Telegram, Slack, Discord, Gmail, Signal, and Matrix skills already exist. See the [skills documentation](https://docs.nanoclaw.dev/integrations/skills-system) for the full list.
|
||||
|
||||
---
|
||||
|
||||
## Vision
|
||||
|
||||
A personal Claude assistant accessible via messaging, with minimal custom code.
|
||||
A personal AI assistant accessible via messaging, with minimal custom code.
|
||||
|
||||
**Core components:**
|
||||
- **Claude Agent SDK** as the core agent
|
||||
- **Containers** for isolated agent execution (Linux VMs)
|
||||
- **Containers** for isolated agent execution (Docker)
|
||||
- **Multi-channel messaging** (WhatsApp, Telegram, Discord, Slack, Gmail) — add exactly the channels you need
|
||||
- **Persistent memory** per conversation and globally
|
||||
- **Scheduled tasks** that run Claude and can message back
|
||||
- **Scheduled tasks** executed by the agent, which can message back
|
||||
- **Web access** for search and browsing
|
||||
- **Browser automation** via agent-browser
|
||||
|
||||
@@ -93,14 +93,14 @@ A personal Claude assistant accessible via messaging, with minimal custom code.
|
||||
- Sessions auto-compact when context gets too long, preserving critical information
|
||||
|
||||
### Container Isolation
|
||||
- All agents run inside containers (lightweight Linux VMs)
|
||||
- All agents run inside Docker containers
|
||||
- Each agent invocation spawns a container with mounted directories
|
||||
- Containers provide filesystem isolation - agents can only see mounted paths
|
||||
- Bash access is safe because commands run inside the container, not on the host
|
||||
- Browser automation via agent-browser with Chromium in the container
|
||||
|
||||
### Scheduled Tasks
|
||||
- Users can ask Claude to schedule recurring or one-time tasks from any group
|
||||
- Users can ask the agent to schedule recurring or one-time tasks from any group
|
||||
- Tasks run as full agents in the context of the group that created them
|
||||
- Tasks have access to all tools including Bash (safe in container)
|
||||
- Tasks can optionally send messages to their group via `send_message` tool, or complete silently
|
||||
@@ -134,11 +134,11 @@ A personal Claude assistant accessible via messaging, with minimal custom code.
|
||||
|
||||
### Scheduler
|
||||
- Built-in scheduler runs on the host, spawns containers for task execution
|
||||
- Custom `nanoclaw` MCP server (inside container) provides scheduling tools
|
||||
- Tools: `schedule_task`, `list_tasks`, `pause_task`, `resume_task`, `cancel_task`, `send_message`
|
||||
- `ncl tasks` provides scheduling commands
|
||||
- Commands: `list`, `get`, `create`, `update`, `cancel`, `pause`, `resume`, `delete`, `run`, `append-log`
|
||||
- Tasks stored in SQLite with run history
|
||||
- Scheduler loop checks for due tasks every minute
|
||||
- Tasks execute Claude Agent SDK in containerized group context
|
||||
- Tasks execute in the agent group's system session
|
||||
|
||||
### Web Access
|
||||
- Built-in WebSearch and WebFetch tools
|
||||
|
||||
+471
-434
File diff suppressed because it is too large
Load Diff
+3
-20
@@ -579,12 +579,7 @@ NanoClaw has a built-in scheduler that runs tasks as full agents in their group'
|
||||
```
|
||||
User: @Andy remind me every Monday at 9am to review the weekly metrics
|
||||
|
||||
Claude: [calls mcp__nanoclaw__schedule_task]
|
||||
{
|
||||
"prompt": "Send a reminder to review weekly metrics. Be encouraging!",
|
||||
"schedule_type": "cron",
|
||||
"schedule_value": "0 9 * * 1"
|
||||
}
|
||||
Claude: [runs ncl tasks create --prompt "Send a reminder to review weekly metrics. Be encouraging!" --process-after "2024-02-05T09:00:00" --recurrence "0 9 * * 1"]
|
||||
|
||||
Claude: Done! I'll remind you every Monday at 9am.
|
||||
```
|
||||
@@ -594,12 +589,7 @@ Claude: Done! I'll remind you every Monday at 9am.
|
||||
```
|
||||
User: @Andy at 5pm today, send me a summary of today's emails
|
||||
|
||||
Claude: [calls mcp__nanoclaw__schedule_task]
|
||||
{
|
||||
"prompt": "Search for today's emails, summarize the important ones, and send the summary to the group.",
|
||||
"schedule_type": "once",
|
||||
"schedule_value": "2024-01-31T17:00:00Z"
|
||||
}
|
||||
Claude: [runs ncl tasks create --prompt "Search for today's emails, summarize the important ones, and send the summary to the group." --process-after "2024-01-31T17:00:00"]
|
||||
```
|
||||
|
||||
### Managing Tasks
|
||||
@@ -620,18 +610,11 @@ From main channel:
|
||||
|
||||
### NanoClaw MCP (built-in)
|
||||
|
||||
The `nanoclaw` MCP server is created dynamically per agent call with the current group's context.
|
||||
The `nanoclaw` MCP server is created dynamically per agent call with the current group's context. Scheduled task management lives in `ncl tasks`, not MCP.
|
||||
|
||||
**Available Tools:**
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `schedule_task` | Schedule a recurring or one-time task |
|
||||
| `list_tasks` | Show tasks (group's tasks, or all if main) |
|
||||
| `get_task` | Get task details and run history |
|
||||
| `update_task` | Modify task prompt or schedule |
|
||||
| `pause_task` | Pause a task |
|
||||
| `resume_task` | Resume a paused task |
|
||||
| `cancel_task` | Delete a task |
|
||||
| `send_message` | Send a message to the group via its channel |
|
||||
|
||||
---
|
||||
|
||||
+251
-206
@@ -14,37 +14,62 @@ The boundary: the agent-runner decides **what** to send and **what to do** with
|
||||
|
||||
## AgentProvider Interface
|
||||
|
||||
Provider-wide settings (MCP servers, env, additional directories, model, effort,
|
||||
assistant name) are passed to the provider **constructor** via `ProviderOptions`, not
|
||||
per query. `QueryInput` carries only what changes turn to turn: the prompt, the
|
||||
continuation token to resume, the working directory, and system context to inject.
|
||||
|
||||
```typescript
|
||||
interface AgentProvider {
|
||||
/** True if the SDK handles slash commands natively and wants them passed
|
||||
* through raw. When false, the poll-loop formats them like any chat message. */
|
||||
readonly supportsNativeSlashCommands: boolean;
|
||||
|
||||
/** Opt-in: scaffold a persistent memory/ tree at boot. Providers with native
|
||||
* memory (Claude's CLAUDE.local.md) omit it. Never gated on a provider name. */
|
||||
readonly usesMemoryScaffold?: boolean;
|
||||
|
||||
/** Optional. Called after each completed exchange so providers whose harness
|
||||
* keeps no on-disk transcript can persist it themselves. Claude (the SDK
|
||||
* writes its own .jsonl) omits this. */
|
||||
onExchangeComplete?(exchange: ProviderExchange): void;
|
||||
|
||||
/** Start a new query. Returns a handle for streaming input and output. */
|
||||
query(input: QueryInput): AgentQuery;
|
||||
|
||||
/** True if the error means the stored continuation is invalid (missing
|
||||
* transcript, unknown session) and should be cleared. */
|
||||
isSessionInvalid(err: unknown): boolean;
|
||||
|
||||
/** Optional pre-resume maintenance: given the stored continuation, return a
|
||||
* reason string to drop it and start fresh (e.g. transcript too large/old to
|
||||
* cold-resume before the host idle ceiling), or null to keep resuming. */
|
||||
maybeRotateContinuation?(continuation: string, cwd: string): string | null;
|
||||
}
|
||||
|
||||
interface ProviderOptions {
|
||||
assistantName?: string;
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
env?: Record<string, string | undefined>;
|
||||
additionalDirectories?: string[];
|
||||
model?: string; // alias (sonnet/opus/haiku) or full model ID
|
||||
effort?: string; // low | medium | high | xhigh | max
|
||||
}
|
||||
|
||||
interface QueryInput {
|
||||
/** Initial prompt (already formatted by agent-runner).
|
||||
* String for text-only. ContentBlock[] for multimodal (images, PDFs, audio). */
|
||||
prompt: string | ContentBlock[];
|
||||
/** Initial prompt, already formatted by the agent-runner into a string. */
|
||||
prompt: string;
|
||||
|
||||
/** Session ID to resume, if any */
|
||||
sessionId?: string;
|
||||
/** Opaque continuation token from a previous query. The provider decides
|
||||
* what it means (session ID, thread ID, or nothing). */
|
||||
continuation?: string;
|
||||
|
||||
/** Resume from a specific point in the session (provider-specific, may be ignored) */
|
||||
resumeAt?: string;
|
||||
|
||||
/** Working directory inside the container */
|
||||
/** Working directory inside the container. */
|
||||
cwd: string;
|
||||
|
||||
/** MCP server configurations (normalized format — provider translates) */
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
|
||||
/** System prompt / developer instructions */
|
||||
systemPrompt?: string;
|
||||
|
||||
/** Environment variables for the SDK process */
|
||||
env: Record<string, string | undefined>;
|
||||
|
||||
/** Additional directories the agent can access */
|
||||
additionalDirectories?: string[];
|
||||
/** System context to inject; the provider translates it into whatever its
|
||||
* SDK expects (preset append, full system prompt, per-turn injection). */
|
||||
systemContext?: { instructions?: string };
|
||||
}
|
||||
|
||||
interface McpServerConfig {
|
||||
@@ -54,40 +79,42 @@ interface McpServerConfig {
|
||||
}
|
||||
|
||||
interface AgentQuery {
|
||||
/** Push a follow-up message into the active query */
|
||||
/** Push a follow-up message into the active query. */
|
||||
push(message: string): void;
|
||||
|
||||
/** Signal that no more input will be sent */
|
||||
/** Signal that no more input will be sent. */
|
||||
end(): void;
|
||||
|
||||
/** Output event stream */
|
||||
/** Output event stream. */
|
||||
events: AsyncIterable<ProviderEvent>;
|
||||
|
||||
/** Force-stop the query (e.g., container shutting down) */
|
||||
/** Force-stop the query (e.g., container shutting down). */
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
type ProviderEvent =
|
||||
| { type: 'init'; sessionId: string }
|
||||
| { type: 'result'; text: string | null }
|
||||
| { type: 'init'; continuation: string }
|
||||
| { type: 'result'; text: string | null; isError?: boolean }
|
||||
| { type: 'error'; message: string; retryable: boolean; classification?: string }
|
||||
| { type: 'progress'; message: string };
|
||||
| { type: 'progress'; message: string }
|
||||
| { type: 'activity' };
|
||||
```
|
||||
|
||||
### What the interface does NOT include
|
||||
|
||||
- **Message formatting** — the agent-runner formats messages before passing to the provider. The provider receives a ready-to-send prompt string.
|
||||
- **Hooks** — Claude-specific. The Claude provider registers hooks internally (PreCompact, PreToolUse, etc.). Other providers don't need them.
|
||||
- **Tool allowlists** — Claude uses `allowedTools`. Codex uses `approvalPolicy`. OpenCode uses `permission`. Each provider configures this internally based on the same intent: "allow everything, no prompting."
|
||||
- **Session persistence** — Claude persists sessions to disk automatically. Codex and OpenCode manage their own session state. The agent-runner doesn't control this — it just passes `sessionId` and `resumeAt`.
|
||||
- **Hooks** — Claude-specific. The Claude provider registers hooks internally (PreToolUse, PostToolUse, PreCompact). Other providers don't need them.
|
||||
- **Tool allowlists** — Claude uses `allowedTools` + `disallowedTools`. Other SDKs use their own equivalents. Each provider configures this internally.
|
||||
- **Session persistence** — the agent-runner stores one opaque `continuation` token per provider (see [Session Resume](#session-resume)) and passes it back as `QueryInput.continuation`. What it means is provider-private; Claude persists its own `.jsonl` transcript on disk keyed by the continuation (session ID).
|
||||
- **Sandbox configuration** — provider-specific. Each provider configures its own sandbox internally.
|
||||
|
||||
### Provider event semantics
|
||||
|
||||
- **`init`** — emitted once per query when the provider establishes or resumes a session. The agent-runner captures `sessionId` for future resume.
|
||||
- **`result`** — emitted when the agent produces a complete response. May be emitted multiple times per query (e.g., Claude's multi-turn with subagents). The agent-runner writes each result to messages_out.
|
||||
- **`error`** — emitted on failure. `retryable` indicates whether the agent-runner should retry. `classification` is optional detail (e.g., 'quota', 'auth', 'transport').
|
||||
- **`init`** — emitted once per query when the provider establishes or resumes a session. The agent-runner captures `continuation` and persists it for future resume.
|
||||
- **`result`** — emitted when the agent produces a complete response. May be emitted multiple times per query (e.g., Claude's multi-turn with subagents). `isError` is set when the SDK flagged the turn as an error (e.g. a non-retryable billing error) so the poll-loop still surfaces the text instead of dropping it. The agent-runner writes each result to messages_out.
|
||||
- **`error`** — emitted on failure. `retryable` indicates whether the agent-runner should retry. `classification` is optional detail (e.g., 'quota').
|
||||
- **`progress`** — optional, for logging. The agent-runner logs these but doesn't act on them.
|
||||
- **`activity`** — a liveness signal. Providers MUST yield it on every underlying SDK event (tool call, thinking, partial message) so the poll-loop's idle timer stays honest during long tool runs.
|
||||
|
||||
## Provider Implementations
|
||||
|
||||
@@ -97,58 +124,82 @@ Only the `claude` provider ships in trunk. The Codex and OpenCode sections below
|
||||
|
||||
Wraps `@anthropic-ai/claude-agent-sdk`'s `query()`.
|
||||
|
||||
The provider takes its settings (`mcpServers`, `env`, `additionalDirectories`,
|
||||
`model`, `effort`, `assistantName`) in its constructor via `ProviderOptions`; `query()`
|
||||
only reads the per-turn `QueryInput`.
|
||||
|
||||
```typescript
|
||||
class ClaudeProvider implements AgentProvider {
|
||||
readonly supportsNativeSlashCommands = true;
|
||||
// ...constructor stores options.mcpServers, .env, .additionalDirectories,
|
||||
// .model, .effort, .assistantName...
|
||||
|
||||
query(input: QueryInput): AgentQuery {
|
||||
const stream = new MessageStream(); // AsyncIterable<SDKUserMessage>
|
||||
stream.push(input.prompt);
|
||||
|
||||
const sdkQuery = query({
|
||||
const sdkResult = sdkQuery({
|
||||
prompt: stream,
|
||||
options: {
|
||||
cwd: input.cwd,
|
||||
resume: input.sessionId,
|
||||
resumeSessionAt: input.resumeAt,
|
||||
systemPrompt: input.systemPrompt
|
||||
? { type: 'preset', preset: 'claude_code', append: input.systemPrompt }
|
||||
additionalDirectories: this.additionalDirectories,
|
||||
resume: input.continuation,
|
||||
pathToClaudeCodeExecutable: '/pnpm/claude',
|
||||
systemPrompt: input.systemContext?.instructions
|
||||
? { type: 'preset', preset: 'claude_code', append: input.systemContext.instructions }
|
||||
: undefined,
|
||||
mcpServers: input.mcpServers, // already the right shape
|
||||
additionalDirectories: input.additionalDirectories,
|
||||
env: input.env,
|
||||
allowedTools: NANOCLAW_TOOL_ALLOWLIST,
|
||||
// Base tools plus one `mcp__<server>__*` pattern per registered MCP
|
||||
// server — without the explicit MCP patterns the SDK's allowedTools
|
||||
// filter silently drops every MCP namespace.
|
||||
allowedTools: [...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern)],
|
||||
disallowedTools: SDK_DISALLOWED_TOOLS,
|
||||
env: this.env,
|
||||
model: this.model,
|
||||
effort: this.effort,
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['project', 'user', 'local'],
|
||||
mcpServers: this.mcpServers,
|
||||
hooks: {
|
||||
PreCompact: [{ hooks: [preCompactHook] }],
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [sanitizeBashHook] }],
|
||||
PreToolUse: [{ hooks: [preToolUseHook] }],
|
||||
PostToolUse: [{ hooks: [postToolUseHook] }],
|
||||
PostToolUseFailure: [{ hooks: [postToolUseHook] }],
|
||||
PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let aborted = false;
|
||||
return {
|
||||
push: (msg) => stream.push(msg),
|
||||
end: () => stream.end(),
|
||||
abort: () => sdkQuery.close(),
|
||||
events: translateClaudeEvents(sdkQuery),
|
||||
// Abort doesn't call into the SDK — it flips a flag the event generator
|
||||
// checks and ends the input stream so the query drains and stops.
|
||||
abort: () => { aborted = true; stream.end(); },
|
||||
events: translateEvents(sdkResult, () => aborted),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`translateClaudeEvents` is an async generator that maps SDK messages to `ProviderEvent`:
|
||||
- `message.type === 'system' && message.subtype === 'init'` → `{ type: 'init', sessionId }`
|
||||
- `message.type === 'result'` → `{ type: 'result', text }`
|
||||
- `message.type === 'system' && message.subtype === 'api_retry'` → `{ type: 'error', retryable: true }`
|
||||
- `message.type === 'system' && message.subtype === 'rate_limit_event'` → `{ type: 'error', retryable: false, classification: 'quota' }`
|
||||
- `message.type === 'system' && message.subtype === 'task_notification'` → `{ type: 'progress', message }`
|
||||
- Everything else → logged, not emitted
|
||||
`translateEvents` is an async generator that yields `{ type: 'activity' }` for **every**
|
||||
SDK message (so the idle timer stays honest) and maps recognized messages to `ProviderEvent`:
|
||||
- `system`/`init` → `{ type: 'init', continuation: session_id }`
|
||||
- `result` → `{ type: 'result', text, isError }` — `text` is `result.result`, or the joined `result.errors[]` on error subtypes (billing/quota), so the notice still reaches the user
|
||||
- `system`/`api_retry` → `{ type: 'error', retryable: true }`
|
||||
- `system`/`rate_limit_event` → `{ type: 'error', retryable: false, classification: 'quota' }`
|
||||
- `system`/`compact_boundary` → `{ type: 'result', text: 'Context compacted…' }`
|
||||
- `system`/`task_notification` → `{ type: 'progress', message }`
|
||||
- when the `aborted` flag is set → the generator returns immediately
|
||||
|
||||
**Claude-specific features preserved inside the provider:**
|
||||
- `MessageStream` for async iterable input (push-based)
|
||||
- `resumeSessionAt` for resume at specific message UUID
|
||||
- PreCompact hook for transcript archiving
|
||||
- PreToolUse hook for sanitizing bash env vars
|
||||
- Full tool allowlist
|
||||
**Claude-specific behavior inside the provider:**
|
||||
- `MessageStream` for async iterable input (push-based follow-ups)
|
||||
- Resume via the SDK `resume` option keyed on the stored `continuation` (the SDK session ID) — no separate resume-at cursor
|
||||
- `TOOL_ALLOWLIST` (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Task, Skill, …) extended at the call site with a `mcp__<server>__*` pattern per registered MCP server; `SDK_DISALLOWED_TOOLS` blocks SDK builtins that collide with NanoClaw's own scheduling/interaction model (CronCreate/Delete/List, ScheduleWakeup, AskUserQuestion, Enter/ExitPlanMode, Enter/ExitWorktree)
|
||||
- **PreToolUse hook** records the current tool + its declared timeout to `container_state` (so the host sweep widens its stuck tolerance while a long Bash runs) and, as defense-in-depth, blocks any `SDK_DISALLOWED_TOOLS` call that slips through. It does **not** sanitize bash env vars — there is no such hook.
|
||||
- **PostToolUse / PostToolUseFailure** hooks clear the in-flight tool
|
||||
- **PreCompact** hook archives the transcript to `conversations/` before compaction
|
||||
- `maybeRotateContinuation` drops an oversized/aged transcript (default caps 12 MB / 14 days, both operator-overridable) so a cold container isn't killed reloading days of `.jsonl` before the host idle ceiling; `isSessionInvalid` clears a continuation whose transcript is gone
|
||||
- `additionalDirectories` for multi-directory access
|
||||
|
||||
### Codex Provider
|
||||
@@ -159,8 +210,8 @@ Wraps `@openai/codex-sdk`.
|
||||
class CodexProvider implements AgentProvider {
|
||||
query(input: QueryInput): AgentQuery {
|
||||
const codex = new Codex(this.buildOptions(input));
|
||||
const thread = input.sessionId
|
||||
? codex.resumeThread(input.sessionId, this.threadOptions(input))
|
||||
const thread = input.continuation
|
||||
? codex.resumeThread(input.continuation, this.threadOptions(input))
|
||||
: codex.startThread(this.threadOptions(input));
|
||||
|
||||
const abortController = new AbortController();
|
||||
@@ -188,13 +239,13 @@ class CodexProvider implements AgentProvider {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let continuation: string | undefined;
|
||||
let resultText = '';
|
||||
|
||||
for await (const event of streamed.events) {
|
||||
if (event.type === 'thread.started') {
|
||||
sessionId = event.thread_id;
|
||||
yield { type: 'init', sessionId };
|
||||
continuation = event.thread_id;
|
||||
yield { type: 'init', continuation };
|
||||
}
|
||||
if (event.type === 'item.completed' && event.item.type === 'agent_message') {
|
||||
resultText = event.item.text || resultText;
|
||||
@@ -264,7 +315,7 @@ class OpenCodeProvider implements AgentProvider {
|
||||
|
||||
private async *run(client, server, stream, input, getPendingFollowUp): AsyncIterable<ProviderEvent> {
|
||||
const session = await client.session.create();
|
||||
yield { type: 'init', sessionId: session.data.id };
|
||||
yield { type: 'init', continuation: session.data.id };
|
||||
|
||||
await client.session.promptAsync({
|
||||
path: { id: session.data.id },
|
||||
@@ -356,62 +407,58 @@ The agent-runner transforms messages_in rows into a prompt string. The provider
|
||||
|
||||
**Routing field stripping:** `platform_id`, `channel_type`, `thread_id` are never included in the prompt. They're stored as context for writing messages_out.
|
||||
|
||||
**Single message formatting by kind:**
|
||||
Every kind renders to a single self-contained XML element. The `id` attribute is the
|
||||
message's `seq` (the agent-facing message ID it passes to `edit_message` / `add_reaction`).
|
||||
The `from` attribute is the origin destination name (resolved from the routing fields via
|
||||
the destination map), so the agent always knows where a message came from — routing fields
|
||||
themselves are never shown.
|
||||
|
||||
- **`chat`** — format into message XML:
|
||||
- **`chat`** — one `<message>` per row:
|
||||
```xml
|
||||
<message sender="John" time="2024-01-01 10:00">
|
||||
Check this PR
|
||||
</message>
|
||||
<message id="5" from="family" sender="John" time="Jan 1, 10:00 AM">Check this PR</message>
|
||||
```
|
||||
A reply carries a `reply_to` attribute and an inline `<quoted_message from="…">…</quoted_message>`.
|
||||
|
||||
- **`chat-sdk`** — extract fields from serialized Chat SDK message:
|
||||
- **`chat-sdk`** — same `<message>` shape, fields extracted from the serialized Chat SDK
|
||||
message. Attachments are appended inline: `[image: screenshot.png — saved to /workspace/…]`
|
||||
or `[image: screenshot.png (https://signed-url…)]`. Images/PDFs that Claude handles
|
||||
natively are also passed as content blocks (see Media Handling below).
|
||||
|
||||
- **`task`** — a `<task>` element, script output first when present:
|
||||
```xml
|
||||
<message sender="John (john@slack)" time="2024-01-01 10:00">
|
||||
Check this PR
|
||||
[image: screenshot.png — https://signed-url...]
|
||||
</message>
|
||||
```
|
||||
Attachments are listed inline. Images/PDFs that Claude handles natively are passed as content blocks (see Media Handling below).
|
||||
|
||||
- **`task`** — task prompt, optionally with script output:
|
||||
```
|
||||
[SCHEDULED TASK]
|
||||
|
||||
Script output:
|
||||
{"data": ...}
|
||||
<task from="scheduler" time="Jan 1, 9:00 AM">Script output:
|
||||
{"data": …}
|
||||
|
||||
Instructions:
|
||||
Review open PRs
|
||||
Review open PRs</task>
|
||||
```
|
||||
|
||||
- **`webhook`** — webhook payload:
|
||||
```
|
||||
[WEBHOOK: github/pull_request]
|
||||
|
||||
{"action": "opened", "pull_request": {...}}
|
||||
- **`webhook`** — a `<webhook>` element wrapping the JSON payload:
|
||||
```xml
|
||||
<webhook from="github" source="github" event="pull_request">{"action": "opened", …}</webhook>
|
||||
```
|
||||
|
||||
- **`system`** — host action result (response to an earlier system request):
|
||||
```
|
||||
[SYSTEM RESPONSE]
|
||||
|
||||
Action: register_agent_group
|
||||
Status: success
|
||||
Result: {"agent_group_id": "ag-456"}
|
||||
- **`system`** — host action result, rendered as `<system_response>`:
|
||||
```xml
|
||||
<system_response from="host" action="create_agent" status="success">{"agent_group_id": "ag-456"}</system_response>
|
||||
```
|
||||
|
||||
**Batch formatting:** Multiple pending messages are combined into one prompt:
|
||||
**Batch formatting:** All pending messages are combined into one prompt. The prompt opens
|
||||
with a self-closing `<context timezone="<IANA>" />` header (so the agent interprets every
|
||||
timestamp — and every time it schedules — in the user's zone), then the chat messages
|
||||
concatenated as consecutive `<message>` blocks, then any task/webhook/system elements,
|
||||
joined by blank lines:
|
||||
|
||||
```xml
|
||||
<context timezone="America/Los_Angeles">
|
||||
<messages>
|
||||
<message sender="John" time="10:00">Check this PR</message>
|
||||
<message sender="Jane" time="10:01">Already on it</message>
|
||||
</messages>
|
||||
<context timezone="America/Los_Angeles" />
|
||||
<message id="2" from="family" sender="John" time="10:00">Check this PR</message>
|
||||
<message id="4" from="family" sender="Jane" time="10:01">Already on it</message>
|
||||
```
|
||||
|
||||
Mixed kinds (e.g., a chat message + a system response) are combined with clear delimiters. Each section is labeled by kind.
|
||||
There is **no** outer `<messages>` envelope — an earlier revision wrapped multi-message
|
||||
batches that way, but the Claude Agent SDK answered the wrapped shape with a synthetic
|
||||
"No response requested." stub instead of calling the API (#2555). Dropping the wrapper made
|
||||
the single-message path just the N=1 case of the same concatenation.
|
||||
|
||||
**Command detection:** Messages starting with `/` are checked against a command list. Recognized commands bypass formatting and are passed raw to the provider (for Claude's slash command handling) or intercepted by the agent-runner (for NanoClaw-level commands like session reset).
|
||||
|
||||
@@ -430,54 +477,70 @@ interface RoutingContext {
|
||||
|
||||
When writing messages_out (either from provider results or MCP tool calls), the agent-runner copies this routing context by default. The agent never sees routing fields — it just produces text. The routing is implicit: "respond to whoever sent the message."
|
||||
|
||||
MCP tools that target a different destination (e.g., `send_to_agent`, `send_message` with explicit channel) override the routing context for that specific messages_out row.
|
||||
MCP tools that target a named destination (`send_message` / `send_file` with a `to`
|
||||
argument) resolve routing through the session's destination map instead of the default
|
||||
reply context — including agent-to-agent sends, which are just a `to` pointing at an
|
||||
`agent`-type destination.
|
||||
|
||||
### Status Management
|
||||
|
||||
The agent-runner manages the `status` and `status_changed` fields on messages_in:
|
||||
`inbound.db` is a read-only mount inside the container, so the agent-runner never writes
|
||||
`messages_in`. It tracks processing status in the `processing_ack` table in the
|
||||
container-owned `outbound.db`; the host reads `processing_ack` and mirrors completion
|
||||
back onto `messages_in.status`.
|
||||
|
||||
```
|
||||
pending → processing → completed
|
||||
→ failed (if provider returns error and max retries exhausted)
|
||||
processing_ack: (no row) → processing → completed
|
||||
```
|
||||
|
||||
- **Pick up:** `UPDATE messages_in SET status = 'processing', status_changed = now(), tries = tries + 1 WHERE id IN (...)`
|
||||
- **Complete:** `UPDATE messages_in SET status = 'completed', status_changed = now() WHERE id IN (...)`
|
||||
- **Error:** Agent-runner does NOT set `failed` — it leaves the message as `processing`. The host detects stale processing via `status_changed` and handles retry logic (reset to pending with backoff). This keeps retry policy on the host side.
|
||||
- **Pick up:** `INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', now())` for each claimed row (`markProcessing`). Pending queries skip any row already present in `processing_ack`.
|
||||
- **Complete:** same upsert with `status = 'completed'` (`markCompleted`). Every consumed batch ends here — error outcomes included. On a provider error the poll-loop writes an error **chat message** to `messages_out` (so the user sees it), then still acks the batch completed; errors surface as messages, not as an ack status. (A `markFailed` helper exists in `messages-in.ts` but currently has no callers.)
|
||||
- The host's `syncProcessingAcks` mirrors acked ids onto `messages_in.status = 'completed'`. Its stale/retry policy is driven off the `.heartbeat` file mtime and the `processing_ack` claim timestamps. On startup the agent-runner clears leftover `processing` acks (crash recovery) so orphaned claims re-process.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
The agent-runner runs an MCP server that exposes NanoClaw tools to the agent. All tools write to the session DB.
|
||||
|
||||
**DB path:** The MCP server receives the session DB path via environment variable. It opens a second connection to the same SQLite file (WAL mode allows concurrent access).
|
||||
The agent-runner runs an MCP server (stdio) that exposes NanoClaw tools to the agent. The
|
||||
tool modules use the same two-DB connection layer as the rest of the runner
|
||||
(`container/agent-runner/src/db/connection.ts`): they read the host-written `inbound.db`
|
||||
at `/workspace/inbound.db` **read-only** (destinations, session routing, question
|
||||
responses, task lists) and write to the container-owned `outbound.db` at
|
||||
`/workspace/outbound.db`. There is no shared single-file connection and no WAL — both files
|
||||
are `journal_mode=DELETE` because WAL's memory-mapped `-shm` file does not stay coherent
|
||||
across the VirtioFS host↔container mount.
|
||||
|
||||
#### send_message
|
||||
|
||||
Send a chat message to the current conversation (or a specified destination).
|
||||
Send a chat message to a named destination. Agents address destinations by name, never by
|
||||
raw platform/channel/thread IDs — the destination map (`destinations` table in `inbound.db`,
|
||||
written by the host) resolves the name to routing fields.
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'send_message',
|
||||
params: {
|
||||
text: string, // message content
|
||||
channel?: string, // optional: target channel type (default: reply to origin)
|
||||
platformId?: string, // optional: target platform ID
|
||||
threadId?: string, // optional: target thread ID
|
||||
text: string, // message content (required)
|
||||
to?: string, // destination name (e.g. "family", "worker-1").
|
||||
// Optional when the agent has exactly one destination.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: write a `messages_out` row with `kind: 'chat'`. If channel/platformId/threadId are provided, use those as routing. Otherwise, copy from the current routing context.
|
||||
Implementation: `resolveRouting(to)` looks up the destination. With no `to`, it defaults to
|
||||
the session's own reply routing (`session_routing`); if the destination resolves to the same
|
||||
channel the session is bound to, the session's `thread_id` is preserved so the reply lands
|
||||
in-thread, otherwise `thread_id` is null. The tool then writes a `messages_out` row with
|
||||
`kind: 'chat'` and content `{ text }`, and returns the new `seq` as the message id.
|
||||
|
||||
#### send_file
|
||||
|
||||
Send a file to the current conversation.
|
||||
Send a file to a named destination (same destination model as `send_message`).
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'send_file',
|
||||
params: {
|
||||
path: string, // file path (relative to /workspace/agent/ or absolute)
|
||||
path: string, // file path (relative to /workspace/agent/ or absolute) (required)
|
||||
to?: string, // destination name; optional if the agent has one destination
|
||||
text?: string, // optional accompanying message
|
||||
filename?: string, // display name (default: basename of path)
|
||||
}
|
||||
@@ -485,10 +548,10 @@ Send a file to the current conversation.
|
||||
```
|
||||
|
||||
Implementation:
|
||||
1. Generate a message ID
|
||||
2. Create `outbox/{messageId}/` directory
|
||||
3. Copy the file into the outbox directory
|
||||
4. Write a `messages_out` row with `files: [filename]` in the content
|
||||
1. Resolve routing via `resolveRouting(to)` (as `send_message`)
|
||||
2. Generate a message ID and create `/workspace/outbox/{messageId}/`
|
||||
3. Copy the file into that outbox directory
|
||||
4. Write a `messages_out` row (`kind: 'chat'`) with content `{ text, files: [filename] }`
|
||||
|
||||
#### send_card
|
||||
|
||||
@@ -523,11 +586,11 @@ Send an interactive question and wait for the user's response. This is a **block
|
||||
```
|
||||
|
||||
Implementation:
|
||||
1. Generate a `questionId`
|
||||
2. Write a `messages_out` row with `operation: 'ask_question'`, the question, options, and questionId
|
||||
3. Poll `messages_in` for a row with matching `questionId` in content
|
||||
4. When found, return the `selectedOption` as the tool result
|
||||
5. If timeout expires, return a timeout error as the tool result
|
||||
1. Generate a `questionId` and normalize each option to `{ label, selectedLabel, value }`
|
||||
2. Write a `messages_out` row with `kind: 'chat-sdk'` and content `{ type: 'ask_question', questionId, title, question, options }`
|
||||
3. Poll `inbound.db` (read-only) for a pending `messages_in` row whose content carries the matching `questionId` (`findQuestionResponse`), skipping any already in `processing_ack`
|
||||
4. When found, `markCompleted` the response row (a `processing_ack` write in `outbound.db`) and return its `selectedOption` as the tool result
|
||||
5. If the deadline passes, return a timeout error as the tool result
|
||||
|
||||
The agent's execution is paused at this tool call. The provider's query keeps running (Claude holds the tool call open). The agent-runner polls for the response in a separate loop.
|
||||
|
||||
@@ -563,89 +626,63 @@ Add an emoji reaction to a message.
|
||||
|
||||
Implementation: write a `messages_out` row with `operation: 'reaction'`.
|
||||
|
||||
#### send_to_agent
|
||||
#### Agent-to-agent sends (no dedicated tool)
|
||||
|
||||
Send a message to another agent group.
|
||||
There is no `send_to_agent` tool. Agents and channels share one destination namespace, so
|
||||
messaging another agent is just `send_message(to="<agent-name>")` where the named
|
||||
destination is of type `agent`. `resolveRouting` maps it to a `messages_out` row with
|
||||
`channel_type: 'agent'` and `platform_id` set to the target agent group id; the host
|
||||
validates the send and routes it into the target session's `inbound.db`.
|
||||
|
||||
#### ncl tasks
|
||||
|
||||
Schedule, inspect, and modify one-shot or recurring tasks.
|
||||
|
||||
```bash
|
||||
ncl tasks create --prompt "..." --process-after "2026-01-15T09:00:00" --recurrence "0 9 * * *"
|
||||
ncl tasks list
|
||||
ncl tasks update <series_id> --prompt "..."
|
||||
ncl tasks cancel <series_id>
|
||||
```
|
||||
|
||||
Implementation: the host writes `messages_in` task rows into the agent group's system session (`thread_id = system:tasks`). The host sweep wakes that system-session container when a task is due. The task agent chooses its destination at fire time by emitting `<message to="name">...</message>` or using `send_message`.
|
||||
|
||||
#### create_agent
|
||||
|
||||
Create a long-lived companion sub-agent. The `name` becomes a destination the creating
|
||||
agent can address. (There is no `register_agent_group` tool — this replaced it.)
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'send_to_agent',
|
||||
name: 'create_agent',
|
||||
params: {
|
||||
agentGroupId: string, // target agent group
|
||||
text: string, // message content
|
||||
sessionId?: string, // optional: target specific session
|
||||
name: string, // human-readable name; also the destination name (required)
|
||||
instructions?: string, // CLAUDE.md content for the new agent (role, personality)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: write a `messages_out` row with `channel_type: 'agent'`, `platform_id: agentGroupId`, `thread_id: sessionId`.
|
||||
Implementation: fire-and-forget. Writes a `messages_out` row with `kind: 'system'`,
|
||||
`action: 'create_agent'`, `requestId`, `name`, and `instructions`. The container is
|
||||
untrusted and does not gate itself; the host authorizes by CLI scope — trusted owner groups
|
||||
(scope `global`) create directly, confined groups require admin approval
|
||||
(`src/modules/agent-to-agent/create-agent.ts`) — then creates the entity rows and notifies
|
||||
the agent via a chat message when the agent is ready.
|
||||
|
||||
#### schedule_task
|
||||
#### Self-modification: install_packages, add_mcp_server
|
||||
|
||||
Schedule a one-shot or recurring task.
|
||||
Two fire-and-forget system-action tools let an agent extend its own runtime (both require
|
||||
admin approval, applied host-side):
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'schedule_task',
|
||||
params: {
|
||||
prompt: string, // task prompt
|
||||
processAfter: string, // ISO timestamp for first run
|
||||
recurrence?: string, // cron expression (optional)
|
||||
script?: string, // pre-agent script (optional)
|
||||
}
|
||||
}
|
||||
```
|
||||
- **`install_packages`** — `{ apt?: string[], npm?: string[], reason?: string }`. Package
|
||||
names are validated at the tool boundary and re-validated on the host. On approval the
|
||||
host rebuilds the per-agent image and restarts the container.
|
||||
- **`add_mcp_server`** — `{ name, command, args?, env? }`. Wires an existing third-party MCP
|
||||
server into the agent's `container.json`; on approval the host updates the config and
|
||||
restarts (no rebuild — Bun runs the TS directly).
|
||||
|
||||
Implementation: the container can't write host-owned `inbound.db`, so this writes a `messages_out` row with `kind: 'system'` and `action: 'schedule_task'` (`container/agent-runner/src/mcp-tools/scheduling.ts`). During delivery the host's action handler (`src/modules/scheduling/actions.ts` → `insertTask()` in `src/modules/scheduling/db.ts`) inserts the `kind: 'task'` row into `inbound.db` with `process_after` and optionally `recurrence`. The host sweep picks it up when due.
|
||||
|
||||
#### list_tasks
|
||||
|
||||
List active scheduled/recurring tasks.
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'list_tasks',
|
||||
params: {}
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: a read, not a write — the container may read the read-only `inbound.db` mount directly. Returns one row per series (the live pending/paused occurrence): `SELECT series_id AS id, ... FROM messages_in WHERE kind = 'task' AND status IN ('pending','paused') GROUP BY series_id`. See `container/agent-runner/src/mcp-tools/scheduling.ts`.
|
||||
|
||||
#### cancel_task / pause_task / resume_task / update_task
|
||||
|
||||
Modify a scheduled task.
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'cancel_task',
|
||||
params: { taskId: string }
|
||||
}
|
||||
// pause_task: set status = 'paused' (new status value for recurring tasks)
|
||||
// resume_task: set status = 'pending'
|
||||
// update_task: merge { prompt?, recurrence?, processAfter?, script? } into the live row
|
||||
```
|
||||
|
||||
Implementation: all four are sent as system actions (`messages_out`, `kind: 'system'`, `action: 'cancel_task' | 'pause_task' | 'resume_task' | 'update_task'`) — the container never writes `inbound.db`. The host's handlers in `src/modules/scheduling/actions.ts` apply the change against `inbound.db` via `src/modules/scheduling/db.ts`: cancel/pause/resume flip status on the live row(s); update_task reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
|
||||
|
||||
#### register_agent_group
|
||||
|
||||
Register a new agent group (admin only).
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'register_agent_group',
|
||||
params: {
|
||||
name: string,
|
||||
folder: string,
|
||||
platformId: string, // messaging group to wire to
|
||||
channelType: string,
|
||||
triggerRules?: object,
|
||||
sessionMode?: 'shared' | 'per-thread',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: write a `messages_out` row with `kind: 'system'`, `action: 'register_agent_group'`. The host reads, validates admin permission, creates the entity rows in the central DB, and writes a `system` messages_in response.
|
||||
Both write a `messages_out` row with `kind: 'system'` and the matching `action`, then return
|
||||
immediately; the host notifies the agent when approval resolves.
|
||||
|
||||
### Media Handling
|
||||
|
||||
@@ -701,12 +738,20 @@ Archive location: `/workspace/agent/conversations/{date}-{summary}.md`
|
||||
|
||||
### Session Resume
|
||||
|
||||
The agent-runner tracks `sessionId` and `resumeAt` across queries:
|
||||
The agent-runner tracks a single opaque `continuation` token per provider:
|
||||
|
||||
- `sessionId` — captured from `ProviderEvent { type: 'init' }`. Passed back to `QueryInput.sessionId` on the next query.
|
||||
- `resumeAt` — Claude-specific (last assistant message UUID). Stored by the agent-runner, passed to `QueryInput.resumeAt`. Providers that don't support this ignore it.
|
||||
- Captured from `ProviderEvent { type: 'init', continuation }` and persisted to the
|
||||
`session_state` table in `outbound.db` under the key `continuation:<provider>` (keyed per
|
||||
provider because a continuation is provider-private — a Claude session id is meaningless to
|
||||
another provider).
|
||||
- Passed back as `QueryInput.continuation` on the next query. For Claude that becomes the
|
||||
SDK `resume` option; the SDK reloads its on-disk `.jsonl` transcript for that session id.
|
||||
|
||||
These are ephemeral to the container's lifetime. When the container is killed and restarted, the host passes the stored `sessionId` from the central DB's sessions table. `resumeAt` is lost on container restart (the provider resumes from the end of the session).
|
||||
Because it lives in the session folder's `outbound.db`, the continuation survives container
|
||||
teardown and restart — a fresh container reads it back and resumes. `/clear` deletes the row
|
||||
to start a clean session. Before resuming, `maybeRotateContinuation` may archive and drop an
|
||||
oversized/aged transcript (so a cold container isn't killed reloading it), and
|
||||
`isSessionInvalid` clears a continuation whose backing transcript has gone missing.
|
||||
|
||||
### Container Startup
|
||||
|
||||
@@ -714,7 +759,7 @@ The agent-runner receives configuration via:
|
||||
|
||||
- **`container.json`:** The provider name, model, assistant name, MCP servers, and other NanoClaw config are read from `/workspace/agent/container.json` (materialized by the host from the `container_configs` table), not from environment variables. See `container/agent-runner/src/config.ts`.
|
||||
- **Environment variables:** provider-specific vars only (API keys, model overrides), `TZ`.
|
||||
- **Fixed mount paths:** Session DB at `/workspace/session.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`.
|
||||
- **Fixed mount paths:** Host-written `inbound.db` (read-only) at `/workspace/inbound.db` and container-owned `outbound.db` at `/workspace/outbound.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`.
|
||||
|
||||
The agent-runner reads config, creates the provider, and enters the poll loop. No stdin, no initial prompt — messages are already in the session DB.
|
||||
|
||||
|
||||
+4
-2
@@ -13,6 +13,8 @@ interface ChannelSetup {
|
||||
|
||||
// Host callbacks
|
||||
onInbound(platformId: string, threadId: string | null, message: InboundMessage): void;
|
||||
// Admin-transport adapters (e.g. CLI) route to an arbitrary channel via this instead of onInbound
|
||||
onInboundEvent(event: InboundEvent): void;
|
||||
onMetadata(platformId: string, name?: string, isGroup?: boolean): void;
|
||||
}
|
||||
|
||||
@@ -34,7 +36,7 @@ interface ChannelAdapter {
|
||||
isConnected(): boolean;
|
||||
|
||||
// Outbound delivery
|
||||
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<void>;
|
||||
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<string | undefined>;
|
||||
|
||||
// Optional
|
||||
setTyping?(platformId: string, threadId: string | null): Promise<void>;
|
||||
@@ -307,7 +309,7 @@ function createWhatsAppChannel(): ChannelAdapter {
|
||||
**Ask user question:**
|
||||
```json
|
||||
{
|
||||
"operation": "ask_question",
|
||||
"type": "ask_question",
|
||||
"questionId": "q-123",
|
||||
"title": "Failing Test",
|
||||
"question": "How should we handle the failing test?",
|
||||
|
||||
@@ -311,7 +311,6 @@ erDiagram
|
||||
string name
|
||||
string folder
|
||||
string agent_provider
|
||||
json container_config
|
||||
}
|
||||
messaging_groups {
|
||||
int id
|
||||
@@ -344,14 +343,15 @@ erDiagram
|
||||
int messaging_group_id
|
||||
int agent_group_id
|
||||
string session_mode
|
||||
json trigger_rules
|
||||
string engage_mode "pattern | mention | mention-sticky"
|
||||
string sender_scope "all | known"
|
||||
int priority
|
||||
}
|
||||
sessions {
|
||||
int id
|
||||
int agent_group_id
|
||||
int messaging_group_id
|
||||
string sdk_session_id
|
||||
string thread_id
|
||||
string status
|
||||
}
|
||||
</pre>
|
||||
@@ -391,7 +391,7 @@ flowchart LR
|
||||
Container -->|writes · odd seq| Out
|
||||
Container -->|touch every poll| HB
|
||||
HostSweep[Host sweep] -->|stat mtime| HB
|
||||
HostSweep -->|reads processing_ack| In
|
||||
HostSweep -->|reads processing_ack| Out
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -28,18 +28,18 @@ flowchart TB
|
||||
Approvals["configureManualApproval<br/>-> pending_approvals"]
|
||||
end
|
||||
|
||||
subgraph Session["Per-Session Container (Docker / Apple Container)"]
|
||||
subgraph Session["Per-Session Container (Docker)"]
|
||||
direction TB
|
||||
PollLoop["Poll Loop<br/>(container/agent-runner)"]
|
||||
Provider["Agent providers<br/>(claude, opencode; todo: codex)"]
|
||||
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server"]
|
||||
Provider["Agent providers<br/>(claude — the only one registered in trunk;<br/>opencode ships via the /add-opencode skill)"]
|
||||
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>create_agent, install_packages, add_mcp_server<br/>CLI: ncl tasks"]
|
||||
Skills["Container Skills<br/>(container/skills/)"]
|
||||
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>destinations<br/>processing_ack")]
|
||||
OutDB[("outbound.db<br/>container writes<br/>odd seq<br/>messages_out<br/>heartbeat file")]
|
||||
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>delivered<br/>destinations<br/>session_routing")]
|
||||
OutDB[("outbound.db<br/>container writes<br/>odd seq<br/>messages_out<br/>processing_ack<br/>session_state<br/>container_state<br/>heartbeat file")]
|
||||
end
|
||||
|
||||
subgraph Groups["Agent Group Filesystem (groups/*)"]
|
||||
Folder["CLAUDE.md<br/>memory<br/>per-group skills<br/>container_config"]
|
||||
Folder["CLAUDE.md<br/>memory<br/>per-group skills<br/>container.json (materialized from container_configs)"]
|
||||
end
|
||||
|
||||
P1 & P2 & P3 & P4 & P5 --> Bridge
|
||||
@@ -140,7 +140,6 @@ erDiagram
|
||||
string name
|
||||
string folder
|
||||
string agent_provider
|
||||
json container_config
|
||||
}
|
||||
messaging_groups {
|
||||
int id
|
||||
@@ -173,14 +172,15 @@ erDiagram
|
||||
int messaging_group_id
|
||||
int agent_group_id
|
||||
string session_mode "agent-shared | shared | per-thread"
|
||||
json trigger_rules
|
||||
string engage_mode "pattern | mention | mention-sticky"
|
||||
string sender_scope "all | known"
|
||||
int priority
|
||||
}
|
||||
sessions {
|
||||
int id
|
||||
int agent_group_id
|
||||
int messaging_group_id
|
||||
string sdk_session_id
|
||||
string thread_id
|
||||
string status
|
||||
}
|
||||
```
|
||||
@@ -209,7 +209,7 @@ flowchart LR
|
||||
Container -->|"writes only<br/>(odd seq)"| Out
|
||||
Container -->|touch every poll| HB
|
||||
HostSweep[Host sweep] -->|stat mtime| HB
|
||||
HostSweep -->|reads processing_ack| In
|
||||
HostSweep -->|reads processing_ack| Out
|
||||
|
||||
note1["Each file has exactly ONE writer.<br/>Eliminates SQLite cross-process write contention.<br/>Collision-free seq numbering."]
|
||||
```
|
||||
|
||||
+164
-100
@@ -4,7 +4,17 @@
|
||||
|
||||
## Core Idea
|
||||
|
||||
Each agent session has a mounted SQLite DB. The DB is the one and only IO mechanism between host and container. No IPC files, no stdin piping. Two tables: messages_in (host → agent-runner) and messages_out (agent-runner → host). Everything is a message.
|
||||
Each agent session has a **pair** of mounted SQLite DBs. They are the one and only IO
|
||||
mechanism between host and container. No IPC files, no stdin piping. `inbound.db` carries
|
||||
host → agent-runner messages (`messages_in`); `outbound.db` carries agent-runner → host
|
||||
messages (`messages_out`) plus the container's processing acks. Everything is a message.
|
||||
|
||||
The split exists so each file has exactly one writer: the host writes `inbound.db` (the
|
||||
container opens it read-only) and the container writes `outbound.db` (the host opens it
|
||||
read-only). One writer per file means no cross-process lock contention over the
|
||||
host↔container mount. Both files run `journal_mode=DELETE`, **not** WAL: WAL's memory-mapped
|
||||
`-shm` coherency does not propagate across VirtioFS, so a WAL reader in the guest would
|
||||
freeze on an early snapshot and never see new host writes.
|
||||
|
||||
## Two-Level DB
|
||||
|
||||
@@ -13,15 +23,18 @@ Each agent session has a mounted SQLite DB. The DB is the one and only IO mechan
|
||||
- Maps platform IDs → agent groups → sessions
|
||||
- Channel adapters don't touch this directly — the host does the lookup
|
||||
|
||||
**Per-session DB (mounted into container):**
|
||||
- messages_in (written by host, read by agent-runner)
|
||||
- messages_out (written by agent-runner, read by host)
|
||||
- Everything is a message: chat, tasks, webhooks, system actions, agent-to-agent — all use these two tables
|
||||
- One DB per session, not per agent group
|
||||
**Per-session DBs (mounted into container):**
|
||||
- `inbound.db` → `messages_in` (written by host, read-only in container) plus host-written
|
||||
lookup tables the container reads live: `destinations`, `session_routing`, `delivered`
|
||||
- `outbound.db` → `messages_out` (written by agent-runner, read by host) plus
|
||||
`processing_ack`, `session_state`, and `container_state` (all container-owned)
|
||||
- Everything is a message: chat, tasks, webhooks, system actions, agent-to-agent — all use
|
||||
`messages_in` / `messages_out`
|
||||
- One pair per session, not per agent group
|
||||
|
||||
## Agent Groups vs Sessions
|
||||
|
||||
An agent group has its own filesystem — folder, CLAUDE.md, skills, container config. Multiple sessions can share the same agent group (same filesystem, same skills) but each session gets its own DB mounted at a known path. Each session = a separate container with the same agent group's filesystem but a different session DB.
|
||||
An agent group has its own filesystem — folder, CLAUDE.md, skills, container config. Multiple sessions can share the same agent group (same filesystem, same skills) but each session gets its own `inbound.db`/`outbound.db` pair mounted at known paths. Each session = a separate container with the same agent group's filesystem but a different DB pair.
|
||||
|
||||
## Message Flow
|
||||
|
||||
@@ -30,13 +43,13 @@ Platform event
|
||||
→ Channel adapter (trigger check, ID extraction)
|
||||
→ Returns: { platformChannelId, platformThreadId, triggered }
|
||||
→ Host maps platformChannelId + platformThreadId → agent group + session
|
||||
→ Host writes message to session's DB
|
||||
→ Host writes messages_in row to the session's inbound.db
|
||||
→ Host calls wakeUpAgent(session)
|
||||
→ Container spins up (or is already running)
|
||||
→ Agent-runner polls its session DB, finds new messages
|
||||
→ Agent-runner processes with Claude
|
||||
→ Agent-runner writes response to session DB
|
||||
→ Host polls active session DBs for responses
|
||||
→ Agent-runner polls inbound.db (read-only), finds new messages
|
||||
→ Agent-runner processes with the configured provider
|
||||
→ Agent-runner writes response to messages_out in outbound.db
|
||||
→ Host polls active sessions' outbound.db for responses
|
||||
→ Host reads response, looks up conversation, delivers through channel adapter
|
||||
```
|
||||
|
||||
@@ -131,7 +144,7 @@ The host is an orchestrator:
|
||||
1. **Spawn** — when wakeUpAgent is called and no container exists for the session
|
||||
2. **Idle kill** — when a container has no unprocessed messages for some timeout period
|
||||
|
||||
When a container spins up, the agent-runner immediately starts polling its session DB. Messages are already there waiting.
|
||||
When a container spins up, the agent-runner immediately starts polling `inbound.db`. Messages are already there waiting.
|
||||
|
||||
## Media Handling
|
||||
|
||||
@@ -185,50 +198,66 @@ Dedup is the channel adapter's responsibility. Chat SDK handles this internally.
|
||||
|
||||
## Session DB Schema
|
||||
|
||||
Two tables. JSON blobs for content — schema-free, format varies by `kind`.
|
||||
Split across the two files. JSON blobs for content — schema-free, format varies by `kind`.
|
||||
`seq` is a global ordering counter with a **disjoint parity**: the host writes even seqs to
|
||||
`messages_in`, the container writes odd seqs to `messages_out`. Each side reads the other's
|
||||
MAX(seq) to pick its next value, so seq is a single monotonic message id across both tables —
|
||||
which is why the agent-facing message id it returns from `send_message` (and accepts in
|
||||
`edit_message` / `add_reaction`) is unambiguous.
|
||||
|
||||
```sql
|
||||
-- Host writes, agent-runner reads
|
||||
-- inbound.db — host writes, container opens read-only
|
||||
CREATE TABLE messages_in (
|
||||
id TEXT PRIMARY KEY,
|
||||
seq INTEGER UNIQUE, -- even (host-assigned)
|
||||
kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending', -- 'pending' | 'processing' | 'completed' | 'failed'
|
||||
status_changed TEXT, -- ISO timestamp of last status change
|
||||
status TEXT DEFAULT 'pending', -- host-owned; the host mirrors the container's
|
||||
-- processing_ack terminal states onto this column
|
||||
process_after TEXT, -- ISO timestamp. NULL = process immediately.
|
||||
recurrence TEXT, -- cron expression. NULL = one-shot.
|
||||
series_id TEXT, -- groups a recurring task's occurrences
|
||||
tries INTEGER DEFAULT 0, -- number of processing attempts
|
||||
|
||||
-- routing (agent-runner copies to messages_out; agent never sees these)
|
||||
platform_id TEXT,
|
||||
trigger INTEGER NOT NULL DEFAULT 1, -- 0 = accumulated context (don't wake), 1 = wake
|
||||
platform_id TEXT, -- routing (stripped before the agent sees content)
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
|
||||
-- payload (structure depends on kind)
|
||||
content TEXT NOT NULL -- JSON blob
|
||||
content TEXT NOT NULL, -- JSON blob (structure depends on kind)
|
||||
source_session_id TEXT, -- a2a return path: source session that emitted the trigger
|
||||
on_wake INTEGER NOT NULL DEFAULT 0 -- 1 = only deliver on a container's first poll
|
||||
);
|
||||
|
||||
-- Agent-runner writes, host reads
|
||||
-- outbound.db — container writes, host opens read-only
|
||||
CREATE TABLE messages_out (
|
||||
id TEXT PRIMARY KEY,
|
||||
in_reply_to TEXT, -- references messages_in.id (optional)
|
||||
seq INTEGER UNIQUE, -- odd (container-assigned)
|
||||
in_reply_to TEXT, -- references messages_in.id (optional)
|
||||
timestamp TEXT NOT NULL,
|
||||
delivered INTEGER DEFAULT 0,
|
||||
deliver_after TEXT, -- ISO timestamp. NULL = deliver immediately.
|
||||
recurrence TEXT, -- cron expression. NULL = one-shot.
|
||||
|
||||
-- routing (default: copied from messages_in by agent-runner)
|
||||
kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'
|
||||
platform_id TEXT,
|
||||
deliver_after TEXT, -- ISO timestamp. NULL = deliver immediately.
|
||||
recurrence TEXT, -- cron expression. NULL = one-shot.
|
||||
kind TEXT NOT NULL, -- copied from messages_in by default
|
||||
platform_id TEXT, -- routing (default: copied from messages_in)
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
|
||||
-- payload (format matches kind)
|
||||
content TEXT NOT NULL -- JSON blob
|
||||
content TEXT NOT NULL -- JSON blob (format matches kind)
|
||||
);
|
||||
|
||||
-- outbound.db — container's processing status (it can't write inbound.db).
|
||||
-- Host reads this to drive the message lifecycle; stale 'processing' rows are
|
||||
-- cleared on container startup (crash recovery).
|
||||
CREATE TABLE processing_ack (
|
||||
message_id TEXT PRIMARY KEY, -- references messages_in.id
|
||||
status TEXT NOT NULL, -- 'processing' | 'completed' | 'failed'
|
||||
status_changed TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
Delivery is tracked host-side in a `delivered` table in `inbound.db` (not a column on
|
||||
`messages_out`, which the host can't write). `inbound.db` also holds the host-written
|
||||
`destinations` and `session_routing` tables the container reads live; `outbound.db` also
|
||||
holds `session_state` (the resume continuation, per provider) and `container_state`
|
||||
(current tool in flight).
|
||||
|
||||
### Scheduling
|
||||
|
||||
One-shot and recurring tasks use the same tables — no separate scheduler.
|
||||
@@ -237,10 +266,10 @@ One-shot and recurring tasks use the same tables — no separate scheduler.
|
||||
|
||||
**Recurring:** Same, plus a `recurrence` cron expression. After the host marks a row as handled/delivered, if `recurrence` is set, it inserts a new row with `process_after`/`deliver_after` advanced to the next cron occurrence. Next time is computed from the scheduled time (not wall clock) to prevent drift.
|
||||
|
||||
**Host sweep** (every ~60s across all session DBs):
|
||||
- `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())` → wake agent
|
||||
- `messages_in WHERE status = 'processing' AND status_changed < (now - stale_threshold)` → stale detection, increment tries, reset to pending with backoff
|
||||
- `messages_out WHERE delivered = 0 AND (deliver_after IS NULL OR deliver_after <= now())` → deliver
|
||||
**Host sweep** (every ~60s across all sessions):
|
||||
- `inbound.db` → `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())` → wake agent
|
||||
- A `processing_ack` (in `outbound.db`) whose claim, or the `.heartbeat` mtime, is older than the stale threshold → stale detection, increment tries, reschedule `process_after` with backoff
|
||||
- `outbound.db` → due `messages_out` rows not yet in the host's `delivered` table (in `inbound.db`) → deliver
|
||||
- After completing/delivering a row with `recurrence`, insert next occurrence
|
||||
|
||||
**Active container poll** (~1s) checks the same conditions but only for sessions with running containers.
|
||||
@@ -332,7 +361,7 @@ Two patterns, both handled at the host level:
|
||||
|
||||
In both cases, the approval and action execution happen on the host side, not the agent side.
|
||||
|
||||
**Approval routing:** Privilege is a user-level concept. `user_roles` records `owner` (global only — first user to pair becomes owner) and `admin` (global or scoped to a specific `agent_group_id`). When an action requires approval, `pickApprover(agentGroupId)` returns candidates in order: scoped admins for that agent group → global admins → owners (deduplicated). `pickApprovalDelivery` then takes the first candidate reachable via `ensureUserDm` (with a same-channel-kind tie-break so a Discord approval request prefers a Discord-using approver). The approval card lands in the approver's DM messaging group, not the origin chat. Delivery is resolved through the Chat SDK's `openDM` for resolution-required channels (Discord/Slack/…) or the user's handle directly for direct-addressable channels (Telegram/WhatsApp/…), and the mapping is cached in `user_dms` for subsequent requests. See `src/access.ts`, `src/user-dm.ts`.
|
||||
**Approval routing:** Privilege is a user-level concept. `user_roles` records `owner` (global only — first user to pair becomes owner) and `admin` (global or scoped to a specific `agent_group_id`). When an action requires approval, `pickApprover(agentGroupId)` returns candidates in order: scoped admins for that agent group → global admins → owners (deduplicated). `pickApprovalDelivery` then takes the first candidate reachable via `ensureUserDm` (with a same-channel-kind tie-break so a Discord approval request prefers a Discord-using approver). The approval card lands in the approver's DM messaging group, not the origin chat. Delivery is resolved through the Chat SDK's `openDM` for resolution-required channels (Discord/Slack/…) or the user's handle directly for direct-addressable channels (Telegram/WhatsApp/…), and the mapping is cached in `user_dms` for subsequent requests. See `src/modules/permissions/access.ts` and `src/modules/permissions/user-dm.ts` (`ensureUserDm`); the approver-picking primitives live in `src/modules/approvals/primitive.ts`.
|
||||
|
||||
**Editing a sent message:**
|
||||
|
||||
@@ -409,14 +438,16 @@ This is documented as a pattern, not a built-in feature.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
**Session DB location:** Not in the agent group folder. Separate directory (e.g., `sessions/{session_id}/`). Each session gets its own folder containing `session.db` and the Claude SDK's `.claude/` directory. The session identity IS the folder — no need to track Claude SDK session IDs.
|
||||
**Session DB location:** Not in the agent group folder. Separate directory (e.g., `sessions/{session_id}/`). Each session gets its own folder containing `inbound.db`, `outbound.db`, and — when using the claude provider — the SDK's `.claude/` directory. The session identity IS the folder. The SDK's resume token (session id) is persisted in `outbound.db`'s `session_state` table, so a fresh container picks the conversation back up without the host tracking it centrally.
|
||||
|
||||
**Container mount structure:**
|
||||
|
||||
```
|
||||
/workspace/ ← mount: session folder (read-write)
|
||||
.claude/ ← Claude SDK session data (auto-created)
|
||||
session.db ← session SQLite DB
|
||||
.claude/ ← Claude SDK session data / transcripts (auto-created)
|
||||
inbound.db ← host writes, container reads (opened read-only)
|
||||
outbound.db ← container writes, host reads (opened read-only)
|
||||
.heartbeat ← container touches this; host watches its mtime
|
||||
outbox/ ← agent-runner writes outbound files here
|
||||
agent/ ← mount: agent group folder (nested, read-write)
|
||||
CLAUDE.md ← agent instructions
|
||||
@@ -424,11 +455,17 @@ This is documented as a pattern, not a built-in feature.
|
||||
... working files
|
||||
```
|
||||
|
||||
Two directory mounts: session folder at `/workspace`, agent group folder at `/workspace/agent/`. The agent-runner CDs into `/workspace/agent/` to run the agent. Claude SDK writes `.claude/` at `/workspace/.claude/` (root of the workspace). The session DB is at `/workspace/session.db`.
|
||||
Two directory mounts: session folder at `/workspace`, agent group folder at `/workspace/agent/`. The agent-runner CDs into `/workspace/agent/` to run the agent. Claude SDK writes `.claude/` at `/workspace/.claude/` (root of the workspace).
|
||||
|
||||
This works on both Docker (nested bind mounts) and Apple Container (directory mounts only — no file-level mounts, but nested directory mounts are supported).
|
||||
The runtime is Docker (`src/container-runtime.ts` hardcodes the `docker` binary); nested bind mounts make this layout straightforward. The layout deliberately sticks to directory mounts (no file-level mounts) so it stays portable to runtimes that only support directory mounts.
|
||||
|
||||
**Session DB concurrent access:** The host writes messages_in, the agent-runner writes messages_out. Both access the same SQLite file simultaneously. WAL mode handles this — SQLite allows concurrent readers, and the two sides write to different tables so writer contention is minimal. The host enables WAL mode when creating the session DB.
|
||||
**Cross-mount DB access:** The two files exist precisely so each has a single writer — the
|
||||
host writes `inbound.db`, the container writes `outbound.db` — which removes writer
|
||||
contention across the mount. Both files use `journal_mode=DELETE`, **not** WAL: WAL keeps its
|
||||
index in a memory-mapped `-shm` file, and VirtioFS does not propagate that mmap coherency
|
||||
from host to guest, so a WAL reader in the container would freeze on an early snapshot and
|
||||
silently never see new host writes. Readers that must see fresh host writes promptly (the
|
||||
`messages_in` poll) open `inbound.db` with `mmap_size = 0` to bypass SQLite's page cache.
|
||||
|
||||
**Session management:** Host-managed. The host creates session folders and mounts them. The container only sees its own session folder.
|
||||
|
||||
@@ -439,7 +476,7 @@ This works on both Docker (nested bind mounts) and Apple Container (directory mo
|
||||
3. More messages arrive before container starts → host finds the existing session, writes to the same session DB
|
||||
4. Container starts, mounts the folder, agent-runner finds messages waiting
|
||||
|
||||
The central DB session row creation is the serialization point. No Claude SDK session ID to coordinate — the SDK discovers its own session data in `.claude/` when the agent runs.
|
||||
The central DB session row creation is the serialization point. No provider session ID to coordinate — the SDK discovers its own session data in `.claude/` when the agent runs.
|
||||
|
||||
**System actions:** The agent uses MCP tools (register group, reset session, schedule task, etc.). The agent-runner handles these tool calls and writes a structured, deterministic messages_out row with `kind: 'system'`. This is not natural language — it's a programmatic, structured payload that the host processes deterministically. Host validates permissions, executes, and writes the result back as a `system` messages_in row.
|
||||
|
||||
@@ -449,7 +486,9 @@ The central DB session row creation is the serialization point. No Claude SDK se
|
||||
|
||||
### Output Delivery
|
||||
|
||||
NanoClaw does not stream tokens to users. The Claude Agent SDK's `query()` yields complete results. The agent-runner writes one complete message to messages_out per result. The host delivers complete messages to channels.
|
||||
NanoClaw does not stream tokens to users. The provider's query interface yields complete results, but a result's text is not delivered as-is: the agent-runner parses it for `<message to="name">...</message>` blocks (`dispatchResultText` in poll-loop.ts) and writes one messages_out row per block, addressed to that destination with its thread context resolved per destination. Everything outside a block — including `<internal>...</internal>` — is scratchpad: logged, never sent. A block naming an unknown destination is dropped into the scratchpad log.
|
||||
|
||||
If a result produced text but no valid block, the agent-runner pushes a one-time `<system>` nudge into the live turn asking the agent to re-wrap its response. The exception is a non-retryable error result (e.g. a billing error) with no envelope, which is delivered as an error notice instead of being dropped as scratchpad. Mid-turn interim updates go out through the `send_message` MCP tool; the final-text envelope parsing is how a turn's reply reaches the user. The host delivers complete messages_out rows to channels.
|
||||
|
||||
Message editing is supported as an explicit operation (agent calls an `edit_message` tool), not as a streaming mechanism.
|
||||
|
||||
@@ -457,21 +496,30 @@ Typing indicators: host sets typing when a container is active for a session, cl
|
||||
|
||||
### Message Batching
|
||||
|
||||
When multiple messages arrive while the container is down, they accumulate as `handled = 0` rows in messages_in. When the container wakes up, the agent-runner queries all unhandled messages and processes them as a batch — multiple messages are formatted into a single `<messages>` XML block.
|
||||
When multiple messages arrive while the container is down, they accumulate as `status = 'pending'` rows in `messages_in`. When the container wakes up, the agent-runner reads all pending messages (those not yet in `processing_ack`) and processes them as a batch — formatted as a `<context timezone="…" />` header followed by the messages concatenated as consecutive `<message>` blocks. (There is no `<messages>` wrapper element; see [agent-runner-details.md](agent-runner-details.md#message-formatting).)
|
||||
|
||||
### Message Lifecycle
|
||||
|
||||
```
|
||||
pending → processing → completed
|
||||
→ failed (after max retries)
|
||||
messages_in.status: pending ──────────► completed (mirrored from ack)
|
||||
└─────────► failed (host-set, retries exhausted)
|
||||
processing_ack.status: processing → completed
|
||||
```
|
||||
|
||||
- **pending**: Written by host. Ready to be picked up (if `process_after` is null or past).
|
||||
- **processing**: Agent-runner sets this when it picks up the message. `status_changed` is set to now. Prevents other polls from re-picking the same message.
|
||||
- **completed**: Agent-runner sets this after successful processing.
|
||||
- **failed**: Set after max retries exhausted.
|
||||
Because `inbound.db` is read-only in the container, the agent-runner never mutates
|
||||
`messages_in.status`. It records lifecycle in `processing_ack` (in `outbound.db`); the host
|
||||
reads that and mirrors completion back.
|
||||
|
||||
**Stale detection**: If a message is `processing` but `status_changed` is too old (e.g., >10 minutes), the host assumes the container crashed. It resets the message to `pending`, increments `tries`, and sets `process_after` with exponential backoff.
|
||||
- **pending**: Host writes the `messages_in` row. Ready to be picked up (if `process_after` is null or past).
|
||||
- **processing**: Agent-runner upserts a `processing_ack` row (`status = 'processing'`) when it claims the message. Subsequent polls skip any id already in `processing_ack`, so it isn't re-picked.
|
||||
- **completed**: Agent-runner sets `processing_ack.status = 'completed'` for **every** consumed batch, error outcomes included — a provider error is surfaced to the user as an error chat message in `messages_out`, then the batch is still acked completed. The host's `syncProcessingAcks` copies it onto `messages_in.status`.
|
||||
- **failed**: Set by the **host** (sweep's `markMessageFailed`) when retries are exhausted — never by the container.
|
||||
|
||||
**Liveness / stale detection**: The container touches a `/workspace/.heartbeat` file rather
|
||||
than writing the DB. The host sweep watches that mtime (widening its tolerance when
|
||||
`container_state` shows a long-declared Bash running) to decide a container has crashed, then
|
||||
increments `tries` and reschedules `process_after` with exponential backoff. On the next
|
||||
container startup, leftover `processing` acks are cleared so orphaned claims re-process.
|
||||
|
||||
### Error Handling and Retries
|
||||
|
||||
@@ -595,13 +643,15 @@ src/db/
|
||||
- **No inline ALTER TABLE.** A migration runner with a `schema_version` table replaces `try { ALTER TABLE } catch { /* exists */ }` blocks. On startup, it checks the current version and applies pending migrations in order. Each migration is a function: `(db: Database) => void`.
|
||||
- **Skills add migrations.** A skill that needs a new column adds a new numbered migration file. No conflicts with other skills' migrations as long as numbers don't collide (use timestamps or high-enough numbers for skill branches).
|
||||
|
||||
**Agent-runner session DB** uses the same pattern but lighter — no migrations needed since session DBs are created fresh by the host:
|
||||
**Agent-runner session DBs** use the same pattern but lighter — no migrations needed since the DB files are created fresh by the host:
|
||||
|
||||
```
|
||||
container/agent-runner/src/db/
|
||||
connection.ts ← open session.db at fixed path, WAL mode
|
||||
messages-in.ts ← read pending, update status
|
||||
messages-out.ts ← write results, outbox queries
|
||||
connection.ts ← open inbound.db (read-only) + outbound.db (DELETE mode) at fixed paths
|
||||
messages-in.ts ← read pending from inbound.db, ack via processing_ack in outbound.db
|
||||
messages-out.ts ← write results/outbox rows to outbound.db (odd seq)
|
||||
session-state.ts ← resume continuation, keyed per provider
|
||||
session-routing.ts ← read the host-written default reply routing
|
||||
index.ts ← barrel
|
||||
```
|
||||
|
||||
@@ -664,9 +714,13 @@ CREATE TABLE agent_groups (
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
agent_provider TEXT, -- default for sessions (null = system default)
|
||||
container_config TEXT, -- JSON: { additionalMounts, timeout }
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
-- Container config is NOT a column here — it lives in a separate container_configs
|
||||
-- table (migration 014), keyed by agent_group_id, with columns: provider, model,
|
||||
-- effort, image_tag, assistant_name, max_messages_per_prompt, cli_scope, and JSON
|
||||
-- columns skills / mcp_servers / packages_apt / packages_npm / additional_mounts.
|
||||
-- The host materializes it into /workspace/agent/container.json for the container.
|
||||
|
||||
-- Platform groups/channels (WhatsApp group, Slack channel, Discord channel, email thread, etc.)
|
||||
-- One row per chat PER ADAPTER INSTANCE. instance defaults to channel_type
|
||||
@@ -721,16 +775,21 @@ CREATE TABLE user_dms (
|
||||
PRIMARY KEY (user_id, channel_type)
|
||||
);
|
||||
|
||||
-- Which agent groups handle which messaging groups, with what rules
|
||||
-- Which agent groups handle which messaging groups, with what rules.
|
||||
-- The opaque trigger_rules JSON + response_scope enum were replaced (migration
|
||||
-- 010) by four orthogonal axes:
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
trigger_rules TEXT, -- JSON: { pattern, mentionOnly, excludeSenders, includeSenders }
|
||||
response_scope TEXT DEFAULT 'all', -- 'all' | 'triggered' | 'allowlisted'
|
||||
session_mode TEXT DEFAULT 'shared', -- 'shared' | 'per-thread'
|
||||
priority INTEGER DEFAULT 0, -- higher = checked first when multiple agents match
|
||||
created_at TEXT NOT NULL,
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
engage_mode TEXT NOT NULL DEFAULT 'mention', -- 'pattern' | 'mention' | 'mention-sticky'
|
||||
engage_pattern TEXT, -- regex; required for engage_mode='pattern'
|
||||
-- ('.' = match every message, the "always" flavor)
|
||||
sender_scope TEXT NOT NULL DEFAULT 'all', -- 'all' | 'known'
|
||||
ignored_message_policy TEXT NOT NULL DEFAULT 'drop', -- 'drop' | 'accumulate'
|
||||
session_mode TEXT DEFAULT 'shared', -- 'shared' | 'per-thread'
|
||||
priority INTEGER DEFAULT 0, -- higher = checked first when multiple agents match
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(messaging_group_id, agent_group_id)
|
||||
);
|
||||
|
||||
@@ -795,7 +854,7 @@ stopped → running → idle → stopped
|
||||
|
||||
## Agent-Runner Architecture
|
||||
|
||||
The agent-runner is the process inside the container. It mediates between the session DB and the Claude SDK — polling for work, formatting messages for the agent, translating tool calls into DB rows, and managing the agent lifecycle.
|
||||
The agent-runner is the process inside the container. It mediates between the session DB and the agent provider — polling for work, formatting messages for the agent, translating tool calls into DB rows, and managing the agent lifecycle.
|
||||
|
||||
### IO Model
|
||||
|
||||
@@ -808,50 +867,55 @@ All IO goes through the session DB. No stdin, no stdout markers, no IPC files.
|
||||
|
||||
### Poll Loop
|
||||
|
||||
1. Query `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())`
|
||||
2. If rows found: set `status = 'processing'`, `status_changed = now()` on each
|
||||
1. Query `inbound.db` (read-only) for `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())`, skipping any id already in `processing_ack`
|
||||
2. If rows found: upsert `processing_ack` rows with `status = 'processing'` in `outbound.db` (the container can't write `messages_in`)
|
||||
3. Batch messages into a single prompt (strip routing fields, format by kind)
|
||||
4. Push into Claude SDK's MessageStream
|
||||
4. Push into the provider's input stream
|
||||
5. Process agent output → write `messages_out` rows
|
||||
6. Set processed messages to `status = 'completed'`
|
||||
6. Set the processed ids' `processing_ack.status = 'completed'` (the host mirrors that onto `messages_in.status`)
|
||||
7. Back to step 1. If no messages found, sleep briefly and re-poll (container stays warm for idle timeout)
|
||||
|
||||
### Message Formatting by Kind
|
||||
|
||||
Agent-runner strips routing fields (`platform_id`, `channel_type`, `thread_id`) before formatting. The agent never sees routing info — it only sees content.
|
||||
|
||||
- **`chat`** — format into `<messages>` XML block
|
||||
- **`chat-sdk`** — extract text, author, attachments from serialized message; format into `<messages>` XML
|
||||
- **`task`** — format as `[SCHEDULED TASK]` prefix + prompt. Run pre-script if present.
|
||||
- **`webhook`** — format as `[WEBHOOK: source/event]` + JSON payload
|
||||
- **`system`** — host action results (e.g., "register_group succeeded"). Format as system context, not chat.
|
||||
- **`chat`** — format into a `<message id="…" from="…" sender="…" time="…">` element
|
||||
- **`chat-sdk`** — extract text, author, attachments from serialized message; same `<message>` element
|
||||
- **`task`** — format as a `<task from="…" time="…">` element (script output first if present). Run pre-script if present.
|
||||
- **`webhook`** — format as a `<webhook source="…" event="…">` element wrapping the JSON payload
|
||||
- **`system`** — host action results, formatted as `<system_response action="…" status="…">`, not chat
|
||||
|
||||
Mixed batches (e.g., a chat message + a system result both pending) are combined into one prompt with clear delimiters.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
MCP tools write to the container's own `outbound.db`. Anything that needs a change in host-owned `inbound.db` (schedule/cancel/pause/resume/update a task, register a group) is emitted as a `kind: 'system'` `messages_out` action that the host applies during delivery — the container never writes `inbound.db`.
|
||||
MCP tools write to the container's own `outbound.db`. Anything that needs a change in host-owned `inbound.db` (schedule/cancel/pause/resume/update a task, create an agent, self-modify) is emitted as a `kind: 'system'` `messages_out` action that the host applies during delivery — the container never writes `inbound.db`.
|
||||
|
||||
**Core tools:**
|
||||
**Messaging & interaction:**
|
||||
|
||||
| Tool | What it does |
|
||||
|------|-------------|
|
||||
| `send_message` | Write `messages_out` row, `kind: 'chat'` |
|
||||
| `send_file` | Move file to `outbox/{msg_id}/`, write `messages_out` with filenames |
|
||||
| `schedule_task` | Write `messages_out`, `kind: 'system'`, `action: 'schedule_task'`; host inserts the `kind: 'task'` `messages_in` row with `process_after` + optional `recurrence` |
|
||||
| `list_tasks` | Read `messages_in` (read-only mount) — one row per series: `kind = 'task' AND status IN ('pending','paused') GROUP BY series_id` |
|
||||
| `pause_task` / `resume_task` / `cancel_task` | Write `messages_out`, `kind: 'system'`, matching `action`; host updates the live `messages_in` row(s) |
|
||||
| `register_agent_group` | Write `messages_out`, `kind: 'system'`, `action: 'register_agent_group'` |
|
||||
|
||||
**New tools:**
|
||||
|
||||
| Tool | What it does |
|
||||
|------|-------------|
|
||||
| `ask_user_question` | Write `messages_out` with question card. Hold tool call open, poll `messages_in` for response matching `questionId`. Return selection as tool result. |
|
||||
| `edit_message` | Write `messages_out` with `operation: 'edit'` |
|
||||
| `send_message` | Resolve `to` (destination name) → routing, write `messages_out` row, `kind: 'chat'`. Omit `to` to reply in place. Also the agent-to-agent path: a `to` naming an `agent`-type destination. |
|
||||
| `send_file` | Copy file to `outbox/{msg_id}/`, write `messages_out` (`kind: 'chat'`) with filenames, same `to` resolution |
|
||||
| `send_card` | Write `messages_out`, `kind: 'chat-sdk'`, content `{ type: 'card', … }` |
|
||||
| `ask_user_question` | Write `messages_out` (`kind: 'chat-sdk'`, `type: 'ask_question'`). Hold tool call open, poll `inbound.db` for the response matching `questionId`. Return selection as tool result. |
|
||||
| `edit_message` | Write `messages_out` with `operation: 'edit'` (targets the original message's destination) |
|
||||
| `add_reaction` | Write `messages_out` with `operation: 'reaction'` |
|
||||
| `send_to_agent` | Write `messages_out` with `channel_type: 'agent'`, `platform_id: '{target}'` |
|
||||
| `send_card` | Write `messages_out` with card structure |
|
||||
|
||||
(There is no `send_to_agent` tool — agent-to-agent is `send_message` to an `agent` destination.)
|
||||
|
||||
**Scheduling**: scheduled-task management is not an MCP surface — it lives on
|
||||
`ncl tasks` (create/list/get/update/cancel/pause/resume/run/append-log). Due
|
||||
task rows live in the agent group's system session and are woken by the host
|
||||
sweep.
|
||||
|
||||
**Central-DB / self-modification** (`kind: 'system'` actions; host authorizes, often via admin approval):
|
||||
|
||||
| Tool | What it does |
|
||||
|------|-------------|
|
||||
| `create_agent` | `action: 'create_agent'` (name + instructions); host creates the agent group (replaces the old `register_agent_group`) |
|
||||
| `install_packages` | `action: 'install_packages'`; on approval host rebuilds the per-agent image and restarts |
|
||||
| `add_mcp_server` | `action: 'add_mcp_server'`; on approval host updates `container.json` and restarts |
|
||||
|
||||
See [agent-runner-details.md](agent-runner-details.md) for full MCP tool parameter definitions.
|
||||
|
||||
@@ -887,11 +951,11 @@ The command lists are hardcoded in the agent-runner. Admin verification happens
|
||||
|
||||
The agent-runner processes recurring task messages like any other messages_in row. After the agent-runner marks a recurring message as `completed`, the **host** handles inserting the next occurrence (new messages_in row with `process_after` advanced to next cron time). The agent-runner doesn't manage recurrence — it just processes what it finds.
|
||||
|
||||
Pre-scripts: if a task message has a `script` field, run it first. If `wakeAgent = false`, mark completed without invoking Claude.
|
||||
Pre-scripts: if a task message has a `script` field, run it first. If `wakeAgent = false`, mark completed without invoking the provider.
|
||||
|
||||
### Agent-to-Agent Messaging
|
||||
|
||||
**Outbound:** Agent calls `send_to_agent` tool → agent-runner writes messages_out with `channel_type: 'agent'`, `platform_id` = target agent group ID. Host validates permissions and writes to target session's messages_in.
|
||||
**Outbound:** Agent calls `send_message(to="<agent-name>")` where the named destination is of type `agent` → agent-runner writes messages_out with `channel_type: 'agent'`, `platform_id` = target agent group ID. Host validates permissions and writes to the target session's `inbound.db` (recording `source_session_id` so the reply routes back to this exact session).
|
||||
|
||||
**Inbound:** Messages from other agents arrive as normal `chat` messages_in rows. The content includes `sender` and `senderId` (e.g., `"senderId": "agent:pr-admin"`). No special formatting — the agent sees it as a chat message.
|
||||
|
||||
@@ -907,7 +971,7 @@ Pre-scripts: if a task message has a `script` field, run it first. If `wakeAgent
|
||||
|
||||
- **Approval routing** — how does the host find the admin's DM conversation? What if no DM channel exists? Is the approval list configurable per agent group or global?
|
||||
- **MCP server lifecycle** — does the MCP server process persist across multiple queries in the same container, or restart each time?
|
||||
- **Container startup config** — what config (if any) is passed to the container at launch beyond env vars? The session DB is at a fixed mount path. System prompt comes from CLAUDE.md. Provider name comes from env. What else?
|
||||
- **Container startup config** — what config (if any) is passed to the container at launch beyond env vars? The DB files are at fixed mount paths. System prompt comes from CLAUDE.md. Provider name comes from `container.json` (materialized from the `container_configs` table), not env. What else?
|
||||
- **Idle detection with pending questions** — when `ask_user_question` is waiting for a response, the container should not be considered idle. Also need to detect when the agent is still working (active tool calls, subagents) and avoid killing the container even if no messages_out have been written recently.
|
||||
|
||||
## Related Documents
|
||||
|
||||
@@ -33,13 +33,13 @@ Both are committed. CI and the Dockerfile run `--frozen-lockfile` variants — a
|
||||
## Supply chain
|
||||
|
||||
- **Host + global CLIs** (pnpm): `minimumReleaseAge: 4320` (3-day hold on new versions), `onlyBuiltDependencies` allowlist for postinstall scripts. See `pnpm-workspace.yaml` and `docs/SECURITY.md`.
|
||||
- **Agent-runner** (Bun): no release-age policy — Bun doesn't have an equivalent today. The defenses are `bun.lock` pinning plus version-pinned CLIs/Bun itself via Dockerfile ARGs. When bumping `@anthropic-ai/claude-agent-sdk` or any runtime dep, review the release date on npm and bump deliberately, not via `bun update`.
|
||||
- **Agent-runner** (Bun): no release-age policy — Bun doesn't have an equivalent today. The defenses are `bun.lock` pinning plus a version-pinned Bun itself via a Dockerfile ARG (global CLIs are pinned separately in `container/cli-tools.json`). When bumping `@anthropic-ai/claude-agent-sdk` or any runtime dep, review the release date on npm and bump deliberately, not via `bun update`.
|
||||
|
||||
## Image build surface
|
||||
|
||||
`container/Dockerfile` is a single-stage build on `node:22-slim`:
|
||||
|
||||
- **Pinned ARGs** — `BUN_VERSION`, `CLAUDE_CODE_VERSION`, `AGENT_BROWSER_VERSION`, `VERCEL_VERSION`. Bump deliberately in PRs.
|
||||
- **Pinned ARGs** — `BUN_VERSION`, `PNPM_VERSION`, `INSTALL_CJK_FONTS`. Bump deliberately in PRs. Global CLI versions (`@anthropic-ai/claude-code`, `agent-browser`, `vercel`) are pinned separately in `container/cli-tools.json`, not as ARGs.
|
||||
- **CJK fonts** — `ARG INSTALL_CJK_FONTS=false`. `container/build.sh` reads `INSTALL_CJK_FONTS` from `.env` and passes it through. Default build saves ~200MB; opt in when the user works with Chinese/Japanese/Korean content.
|
||||
- **BuildKit cache mounts** — `/var/cache/apt`, `/var/lib/apt`, `/root/.bun/install/cache`, `/root/.cache/pnpm`. Rebuilds where `package.json`/`bun.lock` haven't changed are fast. Requires BuildKit (default on Docker 23+, Apple Container-compat).
|
||||
- **`tini` as init** — reaps Chromium zombies, forwards signals so in-flight `outbound.db` writes finalize on SIGTERM.
|
||||
@@ -49,7 +49,7 @@ Both are committed. CI and the Dockerfile run `--frozen-lockfile` variants — a
|
||||
## Session wake (two paths)
|
||||
|
||||
1. **Base image ENTRYPOINT** — used for stdin-piped test invocations like the sample in `container/build.sh`: `tini --> entrypoint.sh` captures stdin to `/tmp/input.json`, then `exec bun run src/index.ts`.
|
||||
2. **Host-spawned session** — `src/container-runner.ts` at line ~301 uses `--entrypoint bash` with `-c 'exec bun run /app/src/index.ts'`. Bypasses tini (Docker's default PID 1 handling applies). Stdin is unused; all IO flows through the mounted session DBs.
|
||||
2. **Host-spawned session** — `src/container-runner.ts` at line ~503 uses `--entrypoint bash` with `-c 'exec bun run /app/src/index.ts'`. Bypasses tini (Docker's default PID 1 handling applies). Stdin is unused; all IO flows through the mounted session DBs.
|
||||
|
||||
Both paths end with Bun running the same source file from `/app/src/index.ts`.
|
||||
|
||||
|
||||
+105
-24
@@ -2,7 +2,7 @@
|
||||
|
||||
Complete reference for `data/v2.db`, the host-owned admin-plane database. Start with [db.md](db.md) for the three-DB overview, the map, and the cross-mount rules.
|
||||
|
||||
Access layer: `src/db/`. Authoritative schema reference: `src/db/schema.ts` (comments only — actual creation runs via migrations in `src/db/migrations/`).
|
||||
Access layer: `src/db/`. `src/db/schema.ts`'s `SCHEMA` constant is a *reference copy* of the core tables for orientation — it is not exhaustive: several tables (`agent_destinations`, `pending_approvals`, `container_configs`, `agent_message_policies`, `pending_channel_approvals`, and others) exist only in their migration files under `src/db/migrations/`, which remain the actual source of truth for what's created at runtime.
|
||||
|
||||
---
|
||||
|
||||
@@ -55,20 +55,24 @@ Wiring: which agent group handles which messaging group. Many-to-many — the sa
|
||||
|
||||
```sql
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
trigger_rules TEXT,
|
||||
response_scope TEXT DEFAULT 'all',
|
||||
session_mode TEXT DEFAULT 'shared',
|
||||
priority INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
engage_mode TEXT NOT NULL DEFAULT 'mention',
|
||||
-- 'pattern' | 'mention' | 'mention-sticky'
|
||||
engage_pattern TEXT, -- regex; required when engage_mode='pattern';
|
||||
-- '.' means "match every message"
|
||||
sender_scope TEXT NOT NULL DEFAULT 'all', -- 'all' | 'known'
|
||||
ignored_message_policy TEXT NOT NULL DEFAULT 'drop', -- 'drop' | 'accumulate'
|
||||
session_mode TEXT DEFAULT 'shared',
|
||||
priority INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(messaging_group_id, agent_group_id)
|
||||
);
|
||||
```
|
||||
|
||||
- `session_mode`: `shared` (one session per channel), `per-thread` (one per thread), `agent-shared` (one per agent group across all channels).
|
||||
- `trigger_rules`: JSON; e.g. regex for native channels.
|
||||
- `engage_mode` / `engage_pattern` / `sender_scope` / `ignored_message_policy`: four orthogonal axes (migration 010) that replaced v1's opaque `trigger_rules` JSON + `response_scope` enum. `engage_mode='pattern'` requires `engage_pattern` (`'.'` matches every message — the "always respond" flavor); `sender_scope='known'` restricts engagement to group members; `ignored_message_policy='accumulate'` keeps ignored messages as context instead of dropping them.
|
||||
- **Side effect:** creating a wiring must also populate `agent_destinations` — don't mutate one without the other (see §1.10).
|
||||
|
||||
### 1.4 `users`
|
||||
@@ -323,6 +327,71 @@ CREATE TABLE container_configs (
|
||||
- **Readers:** `src/container-config.ts`, `src/container-runner.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts`
|
||||
- **Writers:** `src/db/container-configs.ts`, `src/modules/self-mod/apply.ts`, `src/backfill-container-configs.ts`
|
||||
|
||||
### 1.16 `pending_sender_approvals`
|
||||
|
||||
In-flight state for the `unknown_sender_policy = 'request_approval'` flow. A row exists while an admin-approval card is outstanding for a first-time sender in a wired messaging group; `UNIQUE(messaging_group_id, sender_identity)` dedups concurrent attempts from the same sender instead of spamming the admin with repeat cards.
|
||||
|
||||
```sql
|
||||
CREATE TABLE pending_sender_approvals (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
sender_identity TEXT NOT NULL, -- namespaced user id (channel_type:handle)
|
||||
sender_name TEXT,
|
||||
original_message TEXT NOT NULL, -- JSON of the original InboundEvent
|
||||
approver_user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '', -- added by migration 013
|
||||
options_json TEXT NOT NULL DEFAULT '[]', -- added by migration 013
|
||||
UNIQUE(messaging_group_id, sender_identity)
|
||||
);
|
||||
```
|
||||
|
||||
Deleted on admin approve (after adding the sender as a member) or deny.
|
||||
|
||||
- Access layer: `src/modules/permissions/db/pending-sender-approvals.ts`
|
||||
- **Readers/writers:** `src/modules/permissions/sender-approval.ts`, `src/modules/permissions/index.ts`, `src/db/sessions.ts` (`getAskQuestionRender`), `src/cli/resources/groups.ts`
|
||||
|
||||
### 1.17 `pending_channel_approvals`
|
||||
|
||||
In-flight state for the unknown-channel registration flow. When a channel with no `messaging_group_agents` wiring receives a mention or DM, the router escalates to the owner; `PRIMARY KEY(messaging_group_id)` gives free in-flight dedup via `INSERT OR IGNORE` — a second mention while a card is pending drops silently.
|
||||
|
||||
```sql
|
||||
CREATE TABLE pending_channel_approvals (
|
||||
messaging_group_id TEXT PRIMARY KEY REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
-- agent the approved wiring will target (earliest
|
||||
-- agent_group by created_at, picked at request time)
|
||||
original_message TEXT NOT NULL, -- JSON of the original InboundEvent
|
||||
approver_user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '', -- added by migration 013
|
||||
options_json TEXT NOT NULL DEFAULT '[]' -- added by migration 013
|
||||
);
|
||||
```
|
||||
|
||||
Approve creates the `messaging_group_agents` wiring and replays the triggering event; deny sets `messaging_groups.denied_at` so future messages on that channel drop without re-prompting. Either way, this row is deleted.
|
||||
|
||||
- Access layer: `src/modules/permissions/db/pending-channel-approvals.ts`
|
||||
- **Readers/writers:** `src/modules/permissions/channel-approval.ts`, `src/modules/permissions/index.ts`, `src/router.ts`, `src/db/sessions.ts` (`getAskQuestionRender`), `src/cli/resources/groups.ts`
|
||||
|
||||
### 1.18 `agent_message_policies`
|
||||
|
||||
Per-message approval gate on an agent-to-agent connection between two agent groups. No row for a `(from, to)` pair means free flow (no approval required); a row names the `approver` who must sign off on each message.
|
||||
|
||||
```sql
|
||||
CREATE TABLE agent_message_policies (
|
||||
from_agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
to_agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
approver TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (from_agent_group_id, to_agent_group_id)
|
||||
);
|
||||
```
|
||||
|
||||
- Access layer: `src/modules/agent-to-agent/db/agent-message-policies.ts`
|
||||
- **Readers/writers:** `src/cli/resources/policies.ts`; approved messages create a row in `pending_approvals` (see §1.11) via the a2a send path.
|
||||
|
||||
---
|
||||
|
||||
## 2. Migration system
|
||||
@@ -330,21 +399,33 @@ CREATE TABLE container_configs (
|
||||
Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMigrations()` in `src/db/migrations/index.ts`. It:
|
||||
|
||||
1. Creates `schema_version` if absent.
|
||||
2. Reads `MAX(version)` — call it `current`.
|
||||
3. For each migration with `version > current`, executes `up(db)` inside a transaction and appends a `schema_version` row.
|
||||
2. Reads every already-applied `name` from `schema_version` into a `Set` and filters the `migrations` barrel array down to the ones whose `name` isn't in that set — dedup is by **name**, not by the numeric `version` field.
|
||||
3. Runs each pending migration's `up(db)` inside a transaction, in the barrel array's literal order (which is *not* sorted by `version`), then inserts a `schema_version` row.
|
||||
4. The `version` column stored in `schema_version` is **not** the migration's own `version` field — it's `COALESCE(MAX(version), 0) + 1`, i.e. an auto-assigned applied-order number computed at insert time. The `version` field on the `Migration` object is just an ordering hint for humans reading the barrel file; it lets module migrations (installed later by skills) pick arbitrary numbers without coordinating with trunk.
|
||||
|
||||
| # | File | Introduces |
|
||||
|---|------|------------|
|
||||
| 001 | `001-initial.ts` | Core tables: `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, `user_roles`, `agent_group_members`, `user_dms`, `sessions`, `pending_questions` |
|
||||
| 002 | `002-chat-sdk-state.ts` | `chat_sdk_kv`, `chat_sdk_subscriptions`, `chat_sdk_locks`, `chat_sdk_lists` |
|
||||
| 003 | `003-pending-approvals.ts` | `pending_approvals` (session-bound + OneCLI fields) |
|
||||
| 004 | `004-agent-destinations.ts` | `agent_destinations` + backfill from existing `messaging_group_agents` wirings |
|
||||
| 007 | `007-pending-approvals-title-options.ts` | `ALTER TABLE pending_approvals` add `title`, `options_json` (retrofits DBs created between 003 and 007) |
|
||||
| 008 | `008-dropped-messages.ts` | `unregistered_senders` |
|
||||
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
|
||||
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
|
||||
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
|
||||
A few migrations also set `disableForeignKeys: true` (needed for table recreates — SQLite can't relax a table-level `UNIQUE` without DROP+RENAME, which fails FK integrity checks with live child rows). The runner toggles `PRAGMA foreign_keys` around the transaction and runs `PRAGMA foreign_key_check` inside it, snapshotting pre-existing violations so it only fails on violations the migration itself introduced.
|
||||
|
||||
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
|
||||
Several early migrations were later renamed/retired and replaced by "module" files (their original `name` is retained on the new file so already-migrated DBs don't re-run them):
|
||||
|
||||
| Ver. | Name (stored in `schema_version`) | File | Introduces |
|
||||
|---|---|------|------------|
|
||||
| 1 | `initial-v2-schema` | `001-initial.ts` | Core tables: `agent_groups`, `messaging_groups`, `messaging_group_agents` (with the original `trigger_rules`/`response_scope` columns — see v10), `users`, `user_roles`, `agent_group_members`, `user_dms`, `sessions`, `pending_questions` |
|
||||
| 2 | `chat-sdk-state` | `002-chat-sdk-state.ts` | `chat_sdk_kv`, `chat_sdk_subscriptions`, `chat_sdk_locks`, `chat_sdk_lists` |
|
||||
| 3 | `pending-approvals` | `module-approvals-pending-approvals.ts` | `pending_approvals` (session-bound + OneCLI fields) |
|
||||
| 4 | `agent-destinations` | `module-agent-to-agent-destinations.ts` | `agent_destinations` + backfill from existing `messaging_group_agents` wirings |
|
||||
| 7 | `pending-approvals-title-options` | `module-approvals-title-options.ts` | Retroactive `ALTER TABLE pending_approvals` add `title`, `options_json` for DBs that ran migration 3 before its `CREATE TABLE` was edited to include those columns |
|
||||
| 8 | `dropped-messages` | `008-dropped-messages.ts` | `unregistered_senders` |
|
||||
| 9 | `drop-pending-credentials` | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
|
||||
| 10 | `engage-modes` | `010-engage-modes.ts` | `messaging_group_agents`: add `engage_mode`, `engage_pattern`, `sender_scope`, `ignored_message_policy`; backfill from `trigger_rules`/`response_scope`; drop those two legacy columns (see §1.3) |
|
||||
| 11 | `pending-sender-approvals` | `011-pending-sender-approvals.ts` | `pending_sender_approvals` (see §1.16) |
|
||||
| 12 | `channel-registration` | `012-channel-registration.ts` | `messaging_groups.denied_at` + `pending_channel_approvals` (see §1.17) |
|
||||
| 13 | `approval-render-metadata` | `013-approval-render-metadata.ts` | `title`, `options_json` columns on `pending_channel_approvals` and `pending_sender_approvals` |
|
||||
| 14 | `container-configs` | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
|
||||
| 15 | `cli-scope` | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
|
||||
| 16 | `messaging-group-instance` | `016-messaging-group-instance.ts` | `messaging_groups` gets an `instance` column (adapter-instance dimension); table recreate (`disableForeignKeys: true`) backfills `instance = channel_type` on every existing row and relaxes the `UNIQUE` to `(channel_type, platform_id, instance)` |
|
||||
| 17 | `agent-message-policies` | `017-agent-message-policies.ts` | `agent_message_policies` (see §1.18) |
|
||||
| 18 | `approvals-approver-user-id` | `018-approvals-approver-user-id.ts` | `pending_approvals.approver_user_id` — names a single required approver for a2a message-gate policies |
|
||||
|
||||
Numbers 5 and 6 are intentionally absent — migrations were renumbered during early development.
|
||||
|
||||
Session DB schemas (`INBOUND_SCHEMA`, `OUTBOUND_SCHEMA`) are **not** versioned here. They're `CREATE TABLE IF NOT EXISTS` so new columns land via the session-DB lazy migration helpers (`migrateDeliveredTable()` etc.) when a session file from an older build is reopened. See [db-session.md](db-session.md).
|
||||
|
||||
+21
-1
@@ -10,13 +10,15 @@ Schemas live in `src/db/schema.ts` as the `INBOUND_SCHEMA` and `OUTBOUND_SCHEMA`
|
||||
|
||||
```
|
||||
data/v2-sessions/<agent_group_id>/<session_id>/
|
||||
inbound.db ← host writes, container reads (read-only mount)
|
||||
inbound.db ← host writes, container reads (read-only open)
|
||||
outbound.db ← container writes, host reads (read-only open)
|
||||
.heartbeat ← mtime touched by container (not a DB write)
|
||||
inbox/<message_id>/ ← user attachments, decoded from inbound message content
|
||||
outbox/<message_id>/ ← attachments the agent produced
|
||||
```
|
||||
|
||||
The session directory itself is mounted read-write into the container (`src/container-runner.ts`) — read-only is *not* a mount property. The container opens `inbound.db` with `{ readonly: true }` at the SQLite connection layer (`container/agent-runner/src/db/connection.ts`), so the container could technically write to the underlying file via another path, but every code path that touches `inbound.db` from inside the container goes through that read-only handle.
|
||||
|
||||
One session = one folder = one pair of DBs. The `agent_group_id` parent directory also holds per-group state (`.claude-shared/`) that is shared across every session of that agent group. (The agent-runner source is not copied per group — it's a shared read-only mount from `container/agent-runner/src` into every container; see `src/container-runner.ts`.)
|
||||
|
||||
Path helpers in `src/session-manager.ts`: `sessionDir()`, `inboundDbPath()`, `outboundDbPath()`, `heartbeatPath()`.
|
||||
@@ -177,6 +179,24 @@ CREATE TABLE session_state (
|
||||
|
||||
Access: `container/agent-runner/src/db/session-state.ts`.
|
||||
|
||||
### 4.4 `container_state`
|
||||
|
||||
Single-row (`id=1`) tool-in-flight tracker. The container records the currently-running tool on `PreToolUse` and clears it on `PostToolUse`/`PostToolUseFailure`; the host reads it during the stale-container sweep to widen its stuck-tolerance window when `Bash` is running with a user-declared `timeout` over the normal threshold, so long-running scripts aren't killed as "stuck".
|
||||
|
||||
```sql
|
||||
CREATE TABLE container_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
current_tool TEXT,
|
||||
tool_declared_timeout_ms INTEGER,
|
||||
tool_started_at TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
- **Writer (container):** `setContainerToolInFlight()` / `clearContainerToolInFlight()` in `container/agent-runner/src/db/connection.ts`, called from the `preToolUseHook` / `postToolUseHook` in `container/agent-runner/src/providers/claude.ts`.
|
||||
- **Reader (host):** `getContainerState()` in `src/db/session-db.ts`; consumed by the sweep's `bashTimeoutMs()` helper in `src/host-sweep.ts`.
|
||||
- `CREATE TABLE IF NOT EXISTS` — forward-compatible with `outbound.db` files created before this table existed; `getContainerState()` returns `null` if the table or row is absent.
|
||||
|
||||
---
|
||||
|
||||
## 5. Schema evolution
|
||||
|
||||
@@ -80,7 +80,7 @@ agent_groups (workspace, memory, CLAUDE.md, personality)
|
||||
↕ many-to-many
|
||||
messaging_groups (a specific channel/chat/group on a platform)
|
||||
via
|
||||
messaging_group_agents (session_mode, trigger_rules, priority)
|
||||
messaging_group_agents (session_mode, engage_mode, engage_pattern, sender_scope, ignored_message_policy, priority)
|
||||
```
|
||||
|
||||
- **Shared session:** multiple messaging_groups → same agent_group, `session_mode = 'agent-shared'`
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# `ncl tasks` migration
|
||||
|
||||
## Detect
|
||||
|
||||
If an agent mentions `schedule_task`, `list_tasks`, `update_task`, `cancel_task`, `pause_task`, or `resume_task`, it is using the old scheduling MCP surface.
|
||||
|
||||
A subtler symptom of a stale container image: the agent reports a task as scheduled, but `ncl tasks list` shows nothing and the host log has `Unknown system action` — the old image's `schedule_task` call is acknowledged in-container and then dropped by the new host. The fix below (rebuild + restart) resolves it.
|
||||
|
||||
## Why
|
||||
|
||||
Scheduling moved to `ncl tasks`. New tasks are stored in a per-agent-group system session and run there, so a scheduled task does not wake an existing chat session. When it fires, the agent must choose the delivery destination explicitly.
|
||||
|
||||
## Fix
|
||||
|
||||
Rebuild and restart agent containers so they load the updated MCP tool list and instructions:
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
On Linux, restart with `systemctl --user restart nanoclaw`.
|
||||
|
||||
Use:
|
||||
|
||||
```bash
|
||||
ncl tasks list
|
||||
ncl tasks create --group <agent_group_id> --prompt "..." --process-after "2026-01-15T09:00:00" --recurrence "0 9 * * *"
|
||||
ncl tasks update --id <series_id> --prompt "..."
|
||||
ncl tasks cancel --id <series_id>
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
Run `ncl tasks list`. New task rows should show a system `session_id`, not the chat session that requested the task.
|
||||
|
||||
## Legacy tasks (scheduled before this update)
|
||||
|
||||
Tasks created through the old MCP tools live in the **chat session** that created them, not in a per-series system session. They are unaffected by this update: they keep firing and delivering exactly as before. Two things to know:
|
||||
|
||||
- An agent's own `ncl tasks list` (group scope) shows only its group's task rows; from the **host**, unscoped `ncl tasks list` enumerates everything, and `--session <id>` narrows to one session — that is how you find and manage legacy rows (`ncl tasks cancel --session <chat_session_id> --all` to clear a chat session's tasks).
|
||||
- The `messages_in` status enum now includes `cancelled` (cancel marks the row and clears its recurrence rather than deleting it). Custom code that exhaustively switches on task status needs the new arm.
|
||||
|
||||
## Rollback
|
||||
|
||||
Order matters:
|
||||
|
||||
1. Remove tasks created through `ncl tasks` (`ncl tasks list` / `delete`) — they live in per-series system sessions the old code doesn't know about.
|
||||
2. **Wait one sweep (≤60s)** so the host closes the now-empty task sessions.
|
||||
3. Then revert the update and rebuild the container image.
|
||||
|
||||
Reverting before the task sessions are collected leaves system sessions behind that the old `findSessionByAgentGroup` (which has no system-session exclusion) can resolve as the group's session — mis-routing agent-to-agent messages into a dead task thread.
|
||||
@@ -10,11 +10,11 @@ Find out what is running and what is required:
|
||||
|
||||
```bash
|
||||
cat versions.json # the sanctioned pin
|
||||
curl -s http://127.0.0.1:10254/api/health # reports the running gateway version
|
||||
curl -s http://127.0.0.1:10254/api/health # liveness check; `version` field is typically "unknown", not the gateway version
|
||||
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:10254/v1/health
|
||||
```
|
||||
|
||||
If the last command prints `404`, the server predates the `/v1` API that `@onecli-sh/sdk` 2.x requires — every SDK call will fail with 404s that look transient but are permanent. If your gateway is remote, substitute its host for `127.0.0.1` (it's in `.env` as `ONECLI_URL` / `NANOCLAW_ONECLI_API_HOST`).
|
||||
If the last command prints `404`, the server predates the `/v1` API that `@onecli-sh/sdk` 2.x requires — every SDK call will fail with 404s that look transient but are permanent. If your gateway is remote, substitute its host for `127.0.0.1` (it's `ONECLI_URL` in `.env`; `NANOCLAW_ONECLI_API_HOST` is a setup-time override only, not persisted to `.env`).
|
||||
|
||||
Why gateways fall behind: the OneCLI installer's docker-compose tracks the `latest` image tag, but Docker never re-pulls a tag — the server freezes at whatever `latest` meant on install day.
|
||||
|
||||
|
||||
+2
-2
@@ -179,7 +179,7 @@ So during this step:
|
||||
marker.
|
||||
|
||||
The level-2 log still gets an entry (`auth [interactive] → success`
|
||||
with the method — subscription / oauth-token / api-key). Level-3 captures
|
||||
with the method — subscription / oauth / api). Level-3 captures
|
||||
are optional here; mirroring `script -q` output is tricky and the risk of
|
||||
leaking the token to disk outweighs the debugging value.
|
||||
|
||||
@@ -190,7 +190,7 @@ leaking the token to disk outweighs the debugging value.
|
||||
| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. `--uninstall` bypasses bootstrap entirely — it execs setup:auto directly (the flow lives in `setup/uninstall/`), or prints manual-cleanup guidance and exits 1 when the TS toolchain is missing. |
|
||||
| `setup.sh` | Phase 1 bootstrap: Node, pnpm, native-module verify. Emits its own `BOOTSTRAP` status block (historically printed to stdout; now goes to the bootstrap raw log). |
|
||||
| `setup/auto.ts` | Phase 2 driver. Orchestrates the clack UI, step execution, user prompts, and writes to all three log levels for every step it spawns. |
|
||||
| `setup/logs.ts` | The logging primitives (`logStep`, `logUserInput`, `logComplete`, `stepRawLog`, `initSetupLog`). Single source of truth for level 2/3 formatting and file paths. |
|
||||
| `setup/logs.ts` | The logging primitives (`step`, `userInput`, `complete`, `stepRawLog`, `reset`). Single source of truth for level 2/3 formatting and file paths. |
|
||||
| `setup/<step>.ts` | Individual step implementations. Must emit one terminal status block; must not write directly to the terminal. |
|
||||
| `setup/register-claude-token.sh` | The Anthropic exception. Inherits stdio, prints its own UI, returns a status to the driver. |
|
||||
| `setup/add-telegram.sh` | Non-interactive adapter installer. Reads `TELEGRAM_BOT_TOKEN` from env; never prompts. User-facing bits live in `auto.ts`. |
|
||||
|
||||
@@ -14,7 +14,7 @@ Last updated: 2026-04-09
|
||||
- Container clears stale `processing_ack` entries on startup (crash recovery)
|
||||
- Files: `src/db/schema.ts` (INBOUND_SCHEMA + OUTBOUND_SCHEMA), `src/session-manager.ts`, `src/delivery.ts`, `src/host-sweep.ts`, `container/agent-runner/src/db/connection.ts`, `messages-in.ts`, `messages-out.ts`, `poll-loop.ts`, `mcp-tools/scheduling.ts`, `mcp-tools/interactive.ts`
|
||||
- Container image rebuilt with tsconfig (`container/agent-runner/tsconfig.json`)
|
||||
- E2E verified: host → Docker container → Claude responds → "E2E works!" ✓
|
||||
- E2E verified: host → Docker container → agent responds → "E2E works!" ✓
|
||||
|
||||
### OneCLI Integration
|
||||
- `ensureAgent()` call added before `applyContainerConfig()` in `src/container-runner.ts`
|
||||
@@ -65,11 +65,11 @@ Added `session_mode: 'agent-shared'` for cross-channel shared sessions (e.g. Git
|
||||
|
||||
### Entity Model
|
||||
```
|
||||
agent_groups (id, name, folder, agent_provider, container_config)
|
||||
↕ many-to-many
|
||||
messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy)
|
||||
agent_groups (id, name, folder, agent_provider)
|
||||
↕ many-to-many (container runtime config lives in the separate container_configs table)
|
||||
messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, denied_at)
|
||||
via
|
||||
messaging_group_agents (messaging_group_id, agent_group_id, trigger_rules, session_mode, priority)
|
||||
messaging_group_agents (messaging_group_id, agent_group_id, engage_mode, engage_pattern, sender_scope, ignored_message_policy, session_mode, priority)
|
||||
|
||||
users (id, kind, display_name) -- namespaced as "<channel>:<handle>"
|
||||
user_roles (user_id, role, agent_group_id) -- owner / admin (global or scoped)
|
||||
|
||||
+9
-15
@@ -2,27 +2,21 @@
|
||||
|
||||
A **template** is a reusable folder you stamp into a working agent group: it
|
||||
carries the agent's standing instructions, its MCP tool servers, and its skills,
|
||||
but **no secrets and no provider**. Point `ncl` (or the setup wizard) at one and
|
||||
but **no secrets and no provider**. Point `ncl` at one and
|
||||
you get a configured agent in seconds; you choose the runtime/provider
|
||||
separately.
|
||||
|
||||
Templates are purely additive: no DB migration, no new dependency. **At runtime,
|
||||
templates are resolved only from a local directory**: `templates/` at the
|
||||
Templates are purely additive: no DB migration, no new dependency. **Templates
|
||||
are resolved only from a local directory**: `templates/` at the
|
||||
project root by default (committed but shipped empty), or whatever
|
||||
`NANOCLAW_TEMPLATES_DIR` points at (a local path only). The setup wizard can also
|
||||
discover templates from the public registry
|
||||
`NANOCLAW_TEMPLATES_DIR` points at (a local path only). The public registry
|
||||
([`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates))
|
||||
and copy a chosen one into your local `templates/` before stamping.
|
||||
is a manual copy source — clone or download it yourself and copy the chosen
|
||||
template into your local `templates/` before stamping.
|
||||
|
||||
## Using a template
|
||||
|
||||
**During install.** `bash nanoclaw.sh` opens the setup wizard. Choose **Template
|
||||
setup**, then either **NanoClaw template library** (clones the public registry,
|
||||
copies the template you pick into your local `templates/`) or **Local templates**
|
||||
(lists what's already in `templates/`). The normal auth step then picks the
|
||||
runtime, and the wizard stamps and wires your first agent.
|
||||
|
||||
**Anytime, via the CLI:**
|
||||
**Via the CLI:**
|
||||
|
||||
```bash
|
||||
ncl groups create --template sales/sdr --name "SDR Agent"
|
||||
@@ -40,8 +34,8 @@ e.g. `sales/sdr` → `templates/sales/sdr`.
|
||||
|
||||
For safety the ref must stay inside the templates directory: absolute paths, a
|
||||
leading `~`, and `../` escapes are rejected. There is no `--source`, no git URL,
|
||||
and no remote fetch at `ncl` time. Populate `templates/` first (by hand, or via
|
||||
the setup wizard's library option), then stamp.
|
||||
and no remote fetch at `ncl` time. Populate `templates/` first (by hand, e.g.
|
||||
copying from the public registry), then stamp.
|
||||
|
||||
`NANOCLAW_TEMPLATES_DIR` may point the library at another **local** directory; it
|
||||
is never a URL and never changes at runtime.
|
||||
|
||||
@@ -80,7 +80,7 @@ Tasks can exist before a session is awake — the host sweep creates/wakes the c
|
||||
|
||||
**v2:** OneCLI Agent Vault. A separate local service at `http://127.0.0.1:10254` holds secrets. Agents are *scoped* to specific secrets and the vault injects them into approved API requests as they leave the container. The container never sees the raw secret value.
|
||||
|
||||
Gotcha: auto-created agents default to `selective` secret mode — no secrets attached, even if matching secrets exist in the vault. See the "auto-created agents start in selective secret mode" section of the root CLAUDE.md for the fix (`onecli agents set-secret-mode --mode all`).
|
||||
Note: auto-created agents default to `all` secret mode — every vault secret whose host pattern matches is injected automatically. See the "Secret modes" section of the root CLAUDE.md if you want per-agent control (`onecli agents set-secret-mode --mode selective`).
|
||||
|
||||
**What the automated migration does:** copies every v1 `.env` key verbatim into v2 `.env`, never overwriting existing v2 keys. The OneCLI vault migration is a separate step owned by the `/init-onecli` skill, which knows how to pull from `.env`.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.38",
|
||||
"version": "2.1.41",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="208k tokens, 104% of context window">
|
||||
<title>208k tokens, 104% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="223k tokens, 111% of context window">
|
||||
<title>223k tokens, 111% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">208k</text>
|
||||
<text x="71" y="14">208k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">223k</text>
|
||||
<text x="71" y="14">223k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -15,7 +15,7 @@ vi.mock('./log.js', () => ({
|
||||
}));
|
||||
|
||||
import { composeGroupClaudeMd } from './claude-md-compose.js';
|
||||
import { ensureContainerConfig } from './db/container-configs.js';
|
||||
import { ensureContainerConfig, updateContainerConfigScalars } from './db/container-configs.js';
|
||||
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
|
||||
import { PERSONA_PREPEND_FILE } from './group-persona.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
@@ -91,3 +91,28 @@ describe('composeGroupClaudeMd persona prepend', () => {
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeGroupClaudeMd scheduling instructions (ncl tasks reach-in)', () => {
|
||||
// Red-on-delete guard for the `scheduling`/`cli` exclusion at the
|
||||
// module-fragment loop: the agent is taught `ncl tasks` iff it has ncl.
|
||||
it('imports module-scheduling.md at the default cli_scope', () => {
|
||||
const ag = group('ag-sched', 'sched-group');
|
||||
seed(ag);
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
expect(importsOf(ag.folder)).toContain('@./.claude-fragments/module-scheduling.md');
|
||||
});
|
||||
|
||||
it('excludes module-scheduling.md (and module-cli.md) when cli_scope is disabled', () => {
|
||||
const ag = group('ag-sched-off', 'sched-group-off');
|
||||
seed(ag);
|
||||
updateContainerConfigScalars(ag.id, { cli_scope: 'disabled' });
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
const imports = importsOf(ag.folder);
|
||||
expect(imports).not.toContain('@./.claude-fragments/module-scheduling.md');
|
||||
expect(imports).not.toContain('@./.claude-fragments/module-cli.md');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,10 +80,12 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in module fragments — every MCP tool source file that ships a
|
||||
// Built-in module fragments — every MCP/CLI module that ships a
|
||||
// sibling `<name>.instructions.md`. These describe how the agent should
|
||||
// use that module's MCP tools (schedule_task, install_packages, etc.).
|
||||
// Skip cli.instructions.md when cli_scope is disabled.
|
||||
// use that module's tools (`ncl tasks`, install_packages, etc.).
|
||||
// Skip ncl-dependent instructions when cli_scope is disabled. `scheduling`
|
||||
// teaches `ncl tasks`, so it is just as dead as `cli` itself when the agent
|
||||
// has no ncl — dispatch rejects every cli_request and ncl is excluded.
|
||||
const cliDisabled = configRow?.cli_scope === 'disabled';
|
||||
const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH);
|
||||
if (fs.existsSync(mcpToolsHostDir)) {
|
||||
@@ -91,7 +93,7 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
const match = entry.match(/^(.+)\.instructions\.md$/);
|
||||
if (!match) continue;
|
||||
const moduleName = match[1];
|
||||
if (moduleName === 'cli' && cliDisabled) continue;
|
||||
if ((moduleName === 'cli' || moduleName === 'scheduling') && cliDisabled) continue;
|
||||
desired.set(`module-${moduleName}.md`, {
|
||||
type: 'symlink',
|
||||
content: `${SHARED_MCP_TOOLS_CONTAINER_BASE}/${entry}`,
|
||||
|
||||
+8
-2
@@ -43,8 +43,14 @@ async function main(): Promise<void> {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
process.stdout.write(formatResponse(res, json ? 'json' : 'human'));
|
||||
process.exit(res.ok ? 0 : 1);
|
||||
const output =
|
||||
!json && res.ok && res.human !== undefined
|
||||
? res.human + '\n' // server-rendered view — print verbatim
|
||||
: formatResponse(res, json ? 'json' : 'human');
|
||||
// Exit only after stdout drains: process.exit() discards buffered pipe
|
||||
// writes, silently truncating any response past the 64KB pipe buffer
|
||||
// (bit `ncl sessions list --json` at scale).
|
||||
process.stdout.write(output, () => process.exit(res.ok ? 0 : 1));
|
||||
}
|
||||
|
||||
function pickTransport(): Transport {
|
||||
|
||||
+32
-14
@@ -5,11 +5,10 @@
|
||||
* ncl groups help — show group resource details (verbs, columns, enums)
|
||||
*/
|
||||
import { getContainerConfig } from '../../db/container-configs.js';
|
||||
import { getResource, getResources } from '../crud.js';
|
||||
import { getResources } from '../crud.js';
|
||||
import { renderVerbHelp, summaryLine } from '../help-render.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
import { listCommands, register } from '../registry.js';
|
||||
|
||||
const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']);
|
||||
import { GROUP_SCOPE_RESOURCES, listCommands, register } from '../registry.js';
|
||||
|
||||
function getCliScope(ctx: CallerContext): string | undefined {
|
||||
if (ctx.caller !== 'agent') return undefined;
|
||||
@@ -76,13 +75,28 @@ export function registerResourceHelpCommands(): void {
|
||||
try {
|
||||
register({
|
||||
name: `${res.plural}-help`,
|
||||
description: `Show ${res.name} resource details.`,
|
||||
description: `Show ${res.name} resource details; \`${res.plural} help <verb>\` for one verb in depth.`,
|
||||
access: 'open',
|
||||
resource: res.plural,
|
||||
parseArgs: () => ({}),
|
||||
handler: async (_args, ctx) => {
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args, ctx) => {
|
||||
const cliScope = getCliScope(ctx);
|
||||
const lines: string[] = [];
|
||||
|
||||
// `ncl <resource> help <verb>` arrives via the dispatcher's
|
||||
// longest-prefix fallback (`groups-help-create` → `groups-help` +
|
||||
// id=`create`) and renders that verb's deep help — full description,
|
||||
// flags, examples. No new routing. Group-scope auto-fill also puts
|
||||
// the caller's agent group ID into `id` on groups/destinations —
|
||||
// that's not a verb request, so ignore it.
|
||||
const autoFilled = ctx.caller === 'agent' && args.id === ctx.agentGroupId;
|
||||
const verbArg = !autoFilled && typeof args.id === 'string' ? args.id : null;
|
||||
if (verbArg) {
|
||||
const deep = renderVerbHelp(res, verbArg);
|
||||
if (!deep) throw new Error(`no verb "${verbArg}" on ${res.plural} — run \`ncl ${res.plural} help\``);
|
||||
return deep;
|
||||
}
|
||||
|
||||
lines.push(`${res.plural}: ${res.description}`);
|
||||
|
||||
if (cliScope === 'group' && GROUP_SCOPE_RESOURCES.has(res.plural)) {
|
||||
@@ -92,23 +106,27 @@ export function registerResourceHelpCommands(): void {
|
||||
|
||||
lines.push('');
|
||||
|
||||
// Verbs
|
||||
// Verbs — one summary line each; deep help is a verb away. Only the
|
||||
// exceptional access levels are tagged: `open` is the unmarked default.
|
||||
const idAutoFilled = cliScope === 'group' && (res.plural === 'groups' || res.plural === 'destinations');
|
||||
const idHint = idAutoFilled ? '' : ' <id>';
|
||||
const tag = (access: string | undefined) => (!access || access === 'open' ? '' : ` [${access}]`);
|
||||
const verbs: string[] = [];
|
||||
if (res.operations.list) verbs.push(`list [open]`);
|
||||
if (res.operations.get) verbs.push(`get${idHint} [open]`);
|
||||
if (res.operations.create) verbs.push(`create [approval]`);
|
||||
if (res.operations.update) verbs.push(`update${idHint} [approval]`);
|
||||
if (res.operations.delete) verbs.push(`delete${idHint} [approval]`);
|
||||
if (res.operations.list) verbs.push(`list${tag(res.operations.list)}`);
|
||||
if (res.operations.get) verbs.push(`get${idHint}${tag(res.operations.get)}`);
|
||||
if (res.operations.create) verbs.push(`create${tag(res.operations.create)}`);
|
||||
if (res.operations.update) verbs.push(`update${idHint}${tag(res.operations.update)}`);
|
||||
if (res.operations.delete) verbs.push(`delete${idHint}${tag(res.operations.delete)}`);
|
||||
if (res.customOperations) {
|
||||
for (const [verb, op] of Object.entries(res.customOperations)) {
|
||||
verbs.push(`${verb} [${op.access}] — ${op.description}`);
|
||||
verbs.push(`${verb}${tag(op.access)} — ${summaryLine(op.description)}`);
|
||||
}
|
||||
}
|
||||
lines.push('Verbs:');
|
||||
for (const v of verbs) lines.push(` ${v}`);
|
||||
lines.push('');
|
||||
lines.push(`Run \`ncl ${res.plural} help <verb>\` (or add --help to any command) for flags and examples.`);
|
||||
lines.push('');
|
||||
|
||||
// Columns
|
||||
const autoFilledFields =
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('../db/connection.js', () => ({ getDb: vi.fn() }));
|
||||
vi.mock('../db/container-configs.js', () => ({
|
||||
getContainerConfig: vi.fn(() => ({ cli_scope: 'group' })),
|
||||
}));
|
||||
|
||||
import { registerResource, validateArgs } from './crud.js';
|
||||
import { registerResourceHelpCommands } from './commands/help.js';
|
||||
import { lookup } from './registry.js';
|
||||
import type { CallerContext } from './frame.js';
|
||||
|
||||
// --- validateArgs unit ---
|
||||
|
||||
describe('validateArgs', () => {
|
||||
const defs = [
|
||||
{ name: 'target', type: 'string' as const, description: 'Target.', required: true },
|
||||
{ name: 'count', type: 'number' as const, description: 'Count.', default: 1 },
|
||||
{ name: 'force', type: 'boolean' as const, description: 'Force.' },
|
||||
{ name: 'meta', type: 'json' as const, description: 'Meta.' },
|
||||
{ name: 'size', type: 'string' as const, description: 'Size.', enum: ['s', 'm', 'l'] },
|
||||
];
|
||||
|
||||
it('coerces types per declaration', () => {
|
||||
const out = validateArgs(defs, { target: 'x', count: '5', force: 'true', meta: '{"a":1}' });
|
||||
expect(out).toMatchObject({ target: 'x', count: 5, force: true, meta: { a: 1 } });
|
||||
});
|
||||
|
||||
it('applies defaults for absent optional flags', () => {
|
||||
expect(validateArgs(defs, { target: 'x' }).count).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects a missing required flag', () => {
|
||||
expect(() => validateArgs(defs, {})).toThrow('--target is required');
|
||||
});
|
||||
|
||||
it('rejects unknown flags', () => {
|
||||
expect(() => validateArgs(defs, { target: 'x', bogus: '1' })).toThrow('unknown flag --bogus');
|
||||
});
|
||||
|
||||
it('tolerates dispatch-injected keys without declaration', () => {
|
||||
const out = validateArgs(defs, { target: 'x', id: 'ag-1', agent_group_id: 'ag-1', group: 'ag-1' });
|
||||
expect(out.id).toBe('ag-1');
|
||||
});
|
||||
|
||||
it('rejects enum violations', () => {
|
||||
expect(() => validateArgs(defs, { target: 'x', size: 'xl' })).toThrow('--size must be one of: s, m, l');
|
||||
});
|
||||
|
||||
it('rejects non-numeric values for number flags', () => {
|
||||
expect(() => validateArgs(defs, { target: 'x', count: 'many' })).toThrow('--count must be a number');
|
||||
});
|
||||
|
||||
it('rejects a value-less flag on a non-boolean (client sends true)', () => {
|
||||
expect(() => validateArgs(defs, { target: true })).toThrow('--target requires a value');
|
||||
});
|
||||
|
||||
it('accepts a value-less boolean flag', () => {
|
||||
expect(validateArgs(defs, { target: 'x', force: true }).force).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid JSON', () => {
|
||||
expect(() => validateArgs(defs, { target: 'x', meta: '{nope' })).toThrow('--meta must be valid JSON');
|
||||
});
|
||||
});
|
||||
|
||||
// --- registerResource wiring: strict where declared, lenient otherwise ---
|
||||
|
||||
registerResource({
|
||||
name: 'widget',
|
||||
plural: 'widgets',
|
||||
table: 'widgets',
|
||||
description: 'Test widgets.',
|
||||
idColumn: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||
{ name: 'name', type: 'string', description: 'Display name.', required: true, updatable: true },
|
||||
],
|
||||
operations: {},
|
||||
customOperations: {
|
||||
ping: {
|
||||
access: 'open',
|
||||
description: 'Ping a widget.',
|
||||
args: [{ name: 'target', type: 'string', description: 'Where to ping.', required: true }],
|
||||
examples: ['ncl widgets ping --target prod'],
|
||||
handler: async (args) => ({ echo: args }),
|
||||
},
|
||||
legacy: {
|
||||
access: 'open',
|
||||
description: 'Legacy op without declared args.',
|
||||
handler: async (args) => ({ echo: args }),
|
||||
},
|
||||
},
|
||||
});
|
||||
registerResourceHelpCommands();
|
||||
|
||||
describe('strict validation wiring (declared args)', () => {
|
||||
const parse = lookup('widgets-ping')!.parseArgs;
|
||||
|
||||
it('passes and coerces valid args', () => {
|
||||
expect(parse({ target: 'prod' })).toMatchObject({ target: 'prod' });
|
||||
});
|
||||
|
||||
it('failure carries the verb usage block (error + fix in one round-trip)', () => {
|
||||
let message = '';
|
||||
try {
|
||||
parse({});
|
||||
} catch (e) {
|
||||
message = (e as Error).message;
|
||||
}
|
||||
expect(message).toContain('--target is required');
|
||||
expect(message).toContain('ncl widgets ping'); // usage line
|
||||
expect(message).toContain('Flags:');
|
||||
expect(message).toContain('Examples:');
|
||||
});
|
||||
|
||||
it('rejects unknown flags with the usage block', () => {
|
||||
expect(() => parse({ target: 'prod', bogus: '1' })).toThrow(/unknown flag --bogus[\s\S]*Flags:/);
|
||||
});
|
||||
|
||||
it('normalizes dashed flags before validating', () => {
|
||||
// --target arrives as raw key "target"; a dashed alias like "tar-get" would
|
||||
// normalize to underscores — prove normalize runs before validate.
|
||||
expect(() => parse({ 'bogus-flag': '1', target: 'x' })).toThrow('unknown flag --bogus-flag');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lenient ops (no declared args) keep legacy behavior', () => {
|
||||
it('passes stray flags through untouched', () => {
|
||||
const parse = lookup('widgets-legacy')!.parseArgs;
|
||||
expect(parse({ anything: 'goes', 'dash-key': '1' })).toMatchObject({ anything: 'goes', dash_key: '1' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- resource help: deep verb view + group-scope auto-fill guard ---
|
||||
|
||||
describe('resource help command', () => {
|
||||
const helpCmd = lookup('widgets-help')!;
|
||||
const host: CallerContext = { caller: 'host' };
|
||||
const agent: CallerContext = {
|
||||
caller: 'agent',
|
||||
sessionId: 'sess-1',
|
||||
agentGroupId: 'ag-1',
|
||||
messagingGroupId: 'mg-1',
|
||||
};
|
||||
|
||||
it('renders the resource overview with verb summaries', async () => {
|
||||
const out = (await helpCmd.handler(helpCmd.parseArgs({}), host)) as string;
|
||||
expect(out).toContain('widgets: Test widgets.');
|
||||
expect(out).toContain('ping — Ping a widget.');
|
||||
expect(out).toContain('help <verb>');
|
||||
});
|
||||
|
||||
it('renders deep help for `help <verb>` (id from prefix fallback)', async () => {
|
||||
const out = (await helpCmd.handler(helpCmd.parseArgs({ id: 'ping' }), host)) as string;
|
||||
expect(out).toContain('ncl widgets ping');
|
||||
expect(out).toContain('--target');
|
||||
expect(out).toContain('Examples:');
|
||||
});
|
||||
|
||||
it('errors on an unknown verb', async () => {
|
||||
await expect(helpCmd.handler(helpCmd.parseArgs({ id: 'bogus' }), host)).rejects.toThrow(
|
||||
'no verb "bogus" on widgets',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats an auto-filled agent group id as no verb (scoped agent, plain help)', async () => {
|
||||
// dispatch auto-fills id=ctx.agentGroupId on groups/destinations; the
|
||||
// handler must show the overview, not "no verb <uuid>".
|
||||
const out = (await helpCmd.handler(helpCmd.parseArgs({ id: 'ag-1' }), agent)) as string;
|
||||
expect(out).toContain('widgets: Test widgets.');
|
||||
});
|
||||
});
|
||||
+112
-3
@@ -9,6 +9,7 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { getDb } from '../db/connection.js';
|
||||
import { renderVerbHelp } from './help-render.js';
|
||||
import { register } from './registry.js';
|
||||
import type { Access } from './registry.js';
|
||||
import type { CallerContext } from './frame.js';
|
||||
@@ -37,9 +38,21 @@ export interface ColumnDef {
|
||||
|
||||
export interface CustomOperation {
|
||||
access: Access;
|
||||
/** First line = one-line summary (resource help). Full text renders in the
|
||||
* per-verb deep help (`ncl <resource> help <verb>` / `--help`). */
|
||||
description: string;
|
||||
/**
|
||||
* Declaring args opts this verb into strict validation: required/enum/type
|
||||
* checks plus unknown-flag rejection, with the verb's generated usage block
|
||||
* appended to every failure. Omit to keep the legacy lenient behavior
|
||||
* (handler validates by hand, stray flags ignored).
|
||||
*/
|
||||
args?: ColumnDef[];
|
||||
/** Ready-to-paste invocations, rendered under EXAMPLES in deep help. */
|
||||
examples?: string[];
|
||||
handler: (args: Record<string, unknown>, ctx: CallerContext) => Promise<unknown>;
|
||||
/** Presentational renderer for human mode — see CommandDef.formatHuman. */
|
||||
formatHuman?: (data: unknown) => string;
|
||||
}
|
||||
|
||||
export interface ResourceDef {
|
||||
@@ -110,8 +123,11 @@ function genericList(def: ResourceDef) {
|
||||
}
|
||||
const where = filters.length > 0 ? ` WHERE ${filters.join(' AND ')}` : '';
|
||||
params.push(limit);
|
||||
// Newest first: without an ORDER BY the LIMIT silently hides the most
|
||||
// recently inserted rows once a table outgrows it (bit `sessions list`
|
||||
// past 200 sessions — a just-created session was invisible).
|
||||
return getDb()
|
||||
.prepare(`SELECT ${cols} FROM ${def.table}${where} LIMIT ?`)
|
||||
.prepare(`SELECT ${cols} FROM ${def.table}${where} ORDER BY rowid DESC LIMIT ?`)
|
||||
.all(...params);
|
||||
};
|
||||
}
|
||||
@@ -222,6 +238,85 @@ function normalizeArgs(raw: Record<string, unknown>): Record<string, unknown> {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Strict arg validation (opt-in via CustomOperation.args)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Keys the dispatcher may inject into `req.args` before parseArgs runs
|
||||
* (group-scope auto-fill). Strict validation must tolerate them even when a
|
||||
* verb doesn't declare them, or every scoped agent call breaks.
|
||||
*/
|
||||
const DISPATCH_INJECTED_KEYS = ['id', 'agent_group_id', 'group'] as const;
|
||||
|
||||
/**
|
||||
* Validate `args` (already underscore-normalized) against a ColumnDef list:
|
||||
* unknown-flag rejection, required, enum, and type coercion per the declared
|
||||
* type. Returns a coerced copy; throws with a focused message on the first
|
||||
* problem. Works from any ColumnDef list so generic CRUD resources can opt
|
||||
* into the same strictness later without a second validator.
|
||||
*/
|
||||
export function validateArgs(
|
||||
defs: ColumnDef[],
|
||||
args: Record<string, unknown>,
|
||||
opts: { allowExtra?: readonly string[] } = {},
|
||||
): Record<string, unknown> {
|
||||
const declared = new Map(defs.map((d) => [d.name, d]));
|
||||
const allowed = new Set<string>([...declared.keys(), ...(opts.allowExtra ?? DISPATCH_INJECTED_KEYS)]);
|
||||
|
||||
for (const key of Object.keys(args)) {
|
||||
if (!allowed.has(key)) {
|
||||
throw new Error(`unknown flag --${key.replace(/_/g, '-')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const out: Record<string, unknown> = { ...args };
|
||||
for (const def of defs) {
|
||||
const flag = `--${def.name.replace(/_/g, '-')}`;
|
||||
const v = args[def.name];
|
||||
if (v === undefined) {
|
||||
if (def.required) throw new Error(`${flag} is required`);
|
||||
if (def.default !== undefined) out[def.name] = def.default;
|
||||
continue;
|
||||
}
|
||||
// The client parses a value-less `--flag` as boolean true.
|
||||
if (v === true && def.type !== 'boolean') {
|
||||
throw new Error(`${flag} requires a value`);
|
||||
}
|
||||
switch (def.type) {
|
||||
case 'number': {
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) throw new Error(`${flag} must be a number, got "${v}"`);
|
||||
out[def.name] = n;
|
||||
break;
|
||||
}
|
||||
case 'boolean': {
|
||||
if (v === true || v === 'true' || v === '1') out[def.name] = true;
|
||||
else if (v === false || v === 'false' || v === '0') out[def.name] = false;
|
||||
else throw new Error(`${flag} must be true or false, got "${v}"`);
|
||||
break;
|
||||
}
|
||||
case 'json': {
|
||||
if (typeof v === 'string') {
|
||||
try {
|
||||
out[def.name] = JSON.parse(v);
|
||||
} catch {
|
||||
throw new Error(`${flag} must be valid JSON`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'string':
|
||||
out[def.name] = String(v);
|
||||
break;
|
||||
}
|
||||
if (def.enum && !def.enum.includes(String(out[def.name]))) {
|
||||
throw new Error(`${flag} must be one of: ${def.enum.join(', ')}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// registerResource
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -286,16 +381,30 @@ export function registerResource(def: ResourceDef): void {
|
||||
});
|
||||
}
|
||||
|
||||
// Custom operations
|
||||
// Custom operations. Declaring `args` opts the verb into strict validation;
|
||||
// every failure carries the verb's usage block so a caller (human or agent)
|
||||
// can fix the invocation without a second help round-trip.
|
||||
if (def.customOperations) {
|
||||
for (const [verb, op] of Object.entries(def.customOperations)) {
|
||||
const declared = op.args;
|
||||
register({
|
||||
name: `${def.plural}-${verb.replace(/ /g, '-')}`,
|
||||
description: op.description,
|
||||
access: op.access,
|
||||
resource: def.plural,
|
||||
parseArgs: (raw) => normalizeArgs(raw),
|
||||
parseArgs: declared
|
||||
? (raw) => {
|
||||
try {
|
||||
return validateArgs(declared, normalizeArgs(raw));
|
||||
} catch (e) {
|
||||
const usage = renderVerbHelp(def, verb);
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(usage ? `${msg}\n\n${usage}` : msg);
|
||||
}
|
||||
}
|
||||
: (raw) => normalizeArgs(raw),
|
||||
handler: async (args, ctx) => op.handler(args as Record<string, unknown>, ctx),
|
||||
formatHuman: op.formatHuman,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+283
-7
@@ -115,6 +115,15 @@ register({
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'tasks-list',
|
||||
description: 'test command (tasks resource)',
|
||||
resource: 'tasks',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'wirings-list',
|
||||
description: 'test command (wirings resource — not allowed)',
|
||||
@@ -174,6 +183,17 @@ register({
|
||||
handler: async () => ({ agent_group_id: 'g1', model: 'opus' }),
|
||||
});
|
||||
|
||||
// A dash-joined command whose custom-operation key contains spaces
|
||||
// ('config update') — used by the --help space/dash bridging test.
|
||||
register({
|
||||
name: 'groups-config-update',
|
||||
description: 'bare registry description (should not be the help answer)',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
// The real `sessions-get` name — triggers the pre-handler ownership check.
|
||||
register({
|
||||
name: 'sessions-get',
|
||||
@@ -207,6 +227,7 @@ beforeEach(() => {
|
||||
sessions: 'agent_group_id',
|
||||
destinations: 'agent_group_id',
|
||||
members: 'agent_group_id',
|
||||
tasks: 'agent_group_id',
|
||||
};
|
||||
mockGetResource.mockImplementation((plural: string) =>
|
||||
scopeFields[plural] ? { scopeField: scopeFields[plural] } : undefined,
|
||||
@@ -353,6 +374,19 @@ describe('CLI scope enforcement', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('group: allows tasks, auto-fills --group', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'tasks-list', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.group).toBe('g1');
|
||||
expect(data.echo.id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('group: blocks non-whitelisted resources (wirings)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
@@ -595,18 +629,260 @@ describe('CLI scope enforcement', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Dash-joined positional id resolution (generated ids contain dashes) ---
|
||||
// Multi-segment command, to prove the longest-prefix match (verb itself has dashes).
|
||||
register({
|
||||
name: 'groups-cfg-get',
|
||||
description: 'test multi-segment command',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
describe('dash-joined positional id resolution', () => {
|
||||
it('resolves `groups-get-<uuid-with-dashes>` to (groups get, id=<uuid>)', async () => {
|
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
describe('positional dashed-id resolution', () => {
|
||||
const host = { caller: 'host' as const };
|
||||
|
||||
const resp = await dispatch({ id: '1', command: `groups-get-${uuid}`, args: {} }, { caller: 'host' });
|
||||
it('resolves a long dashed id to command + intact id (no shredding)', async () => {
|
||||
const id = 'task-374f0630-d3e0-4965-81da-fe4bf7a6a442';
|
||||
const resp = await dispatch({ id: '1', command: `groups-test-${id}`, args: {} }, host);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) expect(resp.data).toEqual({ echo: { id } });
|
||||
});
|
||||
|
||||
it('matches the LONGEST command prefix when the verb itself has dashes', async () => {
|
||||
const resp = await dispatch({ id: '2', command: 'groups-cfg-get-abc-123', args: {} }, host);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) expect(resp.data).toEqual({ echo: { id: 'abc-123' } });
|
||||
});
|
||||
|
||||
it('leaves a registered no-id command alone', async () => {
|
||||
const resp = await dispatch({ id: '3', command: 'groups-test', args: {} }, host);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) expect(resp.data).toEqual({ echo: {} });
|
||||
});
|
||||
|
||||
it('does not override an explicit --id', async () => {
|
||||
const resp = await dispatch({ id: '4', command: 'groups-test-tail', args: { id: 'explicit' } }, host);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) expect(resp.data).toEqual({ echo: { id: 'explicit' } });
|
||||
});
|
||||
});
|
||||
|
||||
// --- `--help` interception: answer with generated help, execute nothing ---
|
||||
|
||||
describe('--help interception', () => {
|
||||
it('returns command help instead of executing (open command)', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { help: true } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.id).toBe(uuid);
|
||||
// No deep resource def for 'test' → falls back to the description.
|
||||
expect(resp.data).toBe('test command (non-group resource)');
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the help text in `human` so clients print it verbatim', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { help: true } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(typeof resp.data).toBe('string');
|
||||
expect(resp.human).toBe(resp.data);
|
||||
}
|
||||
});
|
||||
|
||||
it('never mints an approval card for --help on an approval-gated command', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'approval-context-command', args: { help: true } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(approvalState.requestApproval).not.toHaveBeenCalled();
|
||||
expect(approvalState.observedContexts).toHaveLength(0); // handler never ran
|
||||
});
|
||||
|
||||
it('renders deep verb help when the resource def is available', async () => {
|
||||
mockGetResource.mockImplementation((plural: string) =>
|
||||
plural === 'groups'
|
||||
? {
|
||||
name: 'group',
|
||||
plural: 'groups',
|
||||
table: 'agent_groups',
|
||||
description: 'Agent groups.',
|
||||
idColumn: 'id',
|
||||
scopeField: 'id',
|
||||
columns: [],
|
||||
operations: {},
|
||||
customOperations: {
|
||||
test: {
|
||||
access: 'open',
|
||||
description: 'Deep test op.',
|
||||
args: [{ name: 'foo', type: 'string', description: 'A foo.', required: true }],
|
||||
handler: async () => ({}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-test', args: { help: true } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(resp.data).toContain('ncl groups test');
|
||||
expect(resp.data).toContain('--foo');
|
||||
expect(resp.data).toContain('(required)');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders deep verb help for a multi-word custom-operation key (spaces vs dashes)', async () => {
|
||||
// registerResource stores the op under 'config update' but registers the
|
||||
// command as 'groups-config-update'; help must bridge the two.
|
||||
mockGetResource.mockImplementation((plural: string) =>
|
||||
plural === 'groups'
|
||||
? {
|
||||
name: 'group',
|
||||
plural: 'groups',
|
||||
table: 'agent_groups',
|
||||
description: 'Agent groups.',
|
||||
idColumn: 'id',
|
||||
scopeField: 'id',
|
||||
columns: [],
|
||||
operations: {},
|
||||
customOperations: {
|
||||
'config update': {
|
||||
access: 'open',
|
||||
description: 'Update container config.',
|
||||
args: [{ name: 'model', type: 'string', description: 'Model override.' }],
|
||||
handler: async () => ({}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-config-update', args: { help: true } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(resp.data).toContain('ncl groups config update');
|
||||
expect(resp.data).toContain('--model');
|
||||
// Not the bare registry description fallback:
|
||||
expect(resp.data).not.toBe('bare registry description (should not be the help answer)');
|
||||
}
|
||||
});
|
||||
|
||||
it('still enforces group scope before answering --help', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'wirings-list', args: { help: true } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) expect(resp.error.code).toBe('forbidden');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Unknown-command errors carry their fix ---
|
||||
|
||||
describe('unknown-command errors', () => {
|
||||
it('lists the resource verbs when the command names a known resource', async () => {
|
||||
mockGetResource.mockImplementation((plural: string) =>
|
||||
plural === 'groups'
|
||||
? {
|
||||
name: 'group',
|
||||
plural: 'groups',
|
||||
table: 'agent_groups',
|
||||
description: 'Agent groups.',
|
||||
idColumn: 'id',
|
||||
columns: [],
|
||||
operations: { list: 'open', get: 'open' },
|
||||
customOperations: {
|
||||
restart: { access: 'approval', description: 'Restart.', handler: async () => ({}) },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-restrat', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('unknown-command');
|
||||
expect(resp.error.message).toContain('verbs for groups: list, get, restart');
|
||||
expect(resp.error.message).toContain('ncl groups help');
|
||||
}
|
||||
});
|
||||
|
||||
it('suggests the closest command name for near-miss typos', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'test-cm', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('unknown-command');
|
||||
expect(resp.error.message).toContain('did you mean "test-cmd"?');
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to a plain pointer when nothing is close', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'zzz-qqq-vvv', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.message).toContain('no command "zzz-qqq-vvv"');
|
||||
expect(resp.error.message).toContain('ncl help');
|
||||
expect(resp.error.message).not.toContain('did you mean');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- formatHuman hook: server-rendered human view on the frame ---
|
||||
|
||||
describe('formatHuman hook', () => {
|
||||
register({
|
||||
name: 'render-cmd',
|
||||
description: 'command with a human renderer',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => [{ id: 'x1', status: 'live' }],
|
||||
formatHuman: (rows) => `TABLE(${(rows as { id: string }[]).map((r) => r.id).join(',')})`,
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'render-throws',
|
||||
description: 'command whose renderer throws',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => ({ fine: true }),
|
||||
formatHuman: () => {
|
||||
throw new Error('renderer bug');
|
||||
},
|
||||
});
|
||||
|
||||
it('attaches human alongside data', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'render-cmd', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(resp.human).toBe('TABLE(x1)');
|
||||
expect(resp.data).toEqual([{ id: 'x1', status: 'live' }]); // machine contract intact
|
||||
}
|
||||
});
|
||||
|
||||
it('a throwing renderer degrades to a plain frame, never an error', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'render-throws', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(resp.human).toBeUndefined();
|
||||
expect(resp.data).toEqual({ fine: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('commands without a renderer stay human-less', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) expect(resp.human).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+116
-16
@@ -12,7 +12,8 @@ import { getSession } from '../db/sessions.js';
|
||||
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
|
||||
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
|
||||
import { getResource } from './crud.js';
|
||||
import { lookup } from './registry.js';
|
||||
import { listVerbs, renderVerbHelp } from './help-render.js';
|
||||
import { GROUP_SCOPE_RESOURCES, listCommands, lookup } from './registry.js';
|
||||
|
||||
type DispatchOptions = {
|
||||
/** True when a command is being replayed after approval. */
|
||||
@@ -26,21 +27,22 @@ export async function dispatch(
|
||||
): Promise<ResponseFrame> {
|
||||
let cmd = lookup(req.command);
|
||||
|
||||
// Fallback: if the full command isn't registered, split the dash-joined
|
||||
// command and treat the longest registered prefix as the command, with the
|
||||
// re-joined remainder as the target ID. Clients join all positional args
|
||||
// with dashes (e.g. `ncl groups get abc123` → command "groups-get-abc123"),
|
||||
// and generated ids (UUIDs, `sess-…`, `appr-…`) themselves contain dashes,
|
||||
// so trimming a single trailing segment isn't enough — walk prefixes from
|
||||
// longest to shortest so `groups-get-<uuid-with-dashes>` still resolves to
|
||||
// "groups-get" + id "<uuid-with-dashes>".
|
||||
// Fallback: if the full command isn't registered, find the LONGEST registered
|
||||
// command that is a dash-prefix of req.command; the remainder is the target ID,
|
||||
// kept intact (dashes and all). This lets clients join all positional args with
|
||||
// dashes — e.g. `ncl groups get abc123` → "groups-get-abc123" → "groups-get" +
|
||||
// id "abc123", and crucially `ncl tasks cancel task-374f-...-442` →
|
||||
// "tasks-cancel" + id "task-374f-...-442" (a dashed id is no longer shredded).
|
||||
// Trimming from the end (longest→shortest) means a multi-segment verb like
|
||||
// "groups-config-add-mcp-server" still matches before any shorter prefix.
|
||||
if (!cmd) {
|
||||
const parts = req.command.split('-');
|
||||
for (let i = parts.length - 1; i > 0; i--) {
|
||||
const shortened = parts.slice(0, i).join('-');
|
||||
let shortened = req.command;
|
||||
let idx: number;
|
||||
while ((idx = shortened.lastIndexOf('-')) > 0) {
|
||||
shortened = shortened.slice(0, idx);
|
||||
const fallback = lookup(shortened);
|
||||
if (fallback) {
|
||||
const tail = parts.slice(i).join('-');
|
||||
const tail = req.command.slice(shortened.length + 1); // full remainder = id, dashes intact
|
||||
cmd = fallback;
|
||||
req = { ...req, command: shortened, args: { ...req.args, id: req.args.id ?? tail } };
|
||||
break;
|
||||
@@ -49,7 +51,7 @@ export async function dispatch(
|
||||
}
|
||||
|
||||
if (!cmd) {
|
||||
return err(req.id, 'unknown-command', `no command "${req.command}"`);
|
||||
return err(req.id, 'unknown-command', unknownCommandMessage(req.command));
|
||||
}
|
||||
|
||||
// CLI scope enforcement for agent callers
|
||||
@@ -62,9 +64,8 @@ export async function dispatch(
|
||||
}
|
||||
|
||||
if (cliScope === 'group') {
|
||||
const allowed = new Set(['groups', 'sessions', 'destinations', 'members']);
|
||||
// Only allow whitelisted resources and general commands (no resource, like help)
|
||||
if (cmd.resource && !allowed.has(cmd.resource)) {
|
||||
if (cmd.resource && !GROUP_SCOPE_RESOURCES.has(cmd.resource)) {
|
||||
return err(req.id, 'forbidden', `CLI access is scoped to this agent group. Cannot access "${cmd.resource}".`);
|
||||
}
|
||||
|
||||
@@ -115,6 +116,17 @@ export async function dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// `--help` interception: answer with the command's generated help instead of
|
||||
// executing. Placed after scope enforcement (a group-scoped agent can't probe
|
||||
// forbidden resources) and BEFORE approval gating — asking for help on an
|
||||
// approval-gated verb must never mint an approval card.
|
||||
if (req.args.help === true) {
|
||||
// Carry the help text in `human` too, so both clients print it verbatim
|
||||
// as clean multi-line text instead of a JSON-stringified blob.
|
||||
const helpText = commandHelp(cmd.name, cmd.resource, cmd.description);
|
||||
return { id: req.id, ok: true, data: helpText, human: helpText };
|
||||
}
|
||||
|
||||
if (ctx.caller !== 'host' && cmd.access === 'approval' && !opts.approved) {
|
||||
const session = getSession(ctx.sessionId);
|
||||
if (!session) {
|
||||
@@ -186,6 +198,17 @@ export async function dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Server-render the human view once, so every transport — host CLI and
|
||||
// the Bun container client (which can't import host formatters) — prints
|
||||
// one canonical rendering. Runs after scope filtering; a throwing
|
||||
// formatter degrades to plain `data`, never fails the response.
|
||||
if (cmd.formatHuman) {
|
||||
try {
|
||||
return { id: req.id, ok: true, data, human: cmd.formatHuman(data) };
|
||||
} catch {
|
||||
// fall through to the plain frame
|
||||
}
|
||||
}
|
||||
return { id: req.id, ok: true, data };
|
||||
} catch (e) {
|
||||
return err(req.id, 'handler-error', errMsg(e));
|
||||
@@ -225,6 +248,83 @@ function parseCallerContext(value: unknown): CallerContext | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Help text for a resolved command: deep verb help when derivable, else description. */
|
||||
function commandHelp(name: string, resource: string | undefined, description: string): string {
|
||||
if (resource && name.startsWith(`${resource}-`)) {
|
||||
const res = getResource(resource);
|
||||
const verb = name.slice(resource.length + 1);
|
||||
const deep = res && renderVerbHelp(res, verb);
|
||||
if (deep) return deep;
|
||||
// Custom-operation KEYS may contain spaces ('config update') while command
|
||||
// names are dash-joined ('groups-config-update'). Resolve by matching keys
|
||||
// normalized the same way registerResource builds command names.
|
||||
if (res?.customOperations) {
|
||||
const spaced = Object.keys(res.customOperations).find((k) => k.replace(/ /g, '-') === verb);
|
||||
const deepSpaced = spaced && renderVerbHelp(res, spaced);
|
||||
if (deepSpaced) return deepSpaced;
|
||||
}
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unknown-command error that carries its fix: if the command names a known
|
||||
* resource, list that resource's verbs; otherwise suggest the closest
|
||||
* registered command. Resource detection walks dash-prefixes longest-first,
|
||||
* same as the ID fallback above, so multi-word plurals (messaging-groups,
|
||||
* user-dms) resolve.
|
||||
*/
|
||||
function unknownCommandMessage(command: string): string {
|
||||
const parts = command.split('-');
|
||||
for (let i = parts.length; i > 0; i--) {
|
||||
const prefix = parts.slice(0, i).join('-');
|
||||
const res = getResource(prefix);
|
||||
if (res) {
|
||||
return (
|
||||
`no command "${command}" — verbs for ${res.plural}: ${listVerbs(res).join(', ')}. ` +
|
||||
`Run \`ncl ${res.plural} help <verb>\` for flags and examples.`
|
||||
);
|
||||
}
|
||||
}
|
||||
const names = listCommands()
|
||||
.filter((c) => c.access !== 'hidden')
|
||||
.map((c) => c.name);
|
||||
const closest = closestName(command, names);
|
||||
return `no command "${command}"${closest ? ` — did you mean "${closest}"?` : ''} Run \`ncl help\`.`;
|
||||
}
|
||||
|
||||
/** Closest name by edit distance, only when convincingly close (≤2 edits). */
|
||||
function closestName(input: string, names: string[]): string | undefined {
|
||||
let best: string | undefined;
|
||||
let bestDist = 3;
|
||||
for (const name of names) {
|
||||
if (Math.abs(name.length - input.length) >= bestDist) continue;
|
||||
const d = editDistance(input, name, bestDist);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = name;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function editDistance(a: string, b: string, cap: number): number {
|
||||
const prev = new Array(b.length + 1).fill(0).map((_, i) => i);
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
let diag = prev[0];
|
||||
prev[0] = i;
|
||||
let rowMin = prev[0];
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const tmp = prev[j];
|
||||
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
||||
diag = tmp;
|
||||
rowMin = Math.min(rowMin, prev[j]);
|
||||
}
|
||||
if (rowMin >= cap) return cap;
|
||||
}
|
||||
return prev[b.length];
|
||||
}
|
||||
|
||||
function err(id: string, code: ErrorCode, message: string): ResponseFrame {
|
||||
return { id, ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Compact aligned-table renderer for `ncl tasks list` (human mode only).
|
||||
*
|
||||
* A task series is a recurring job; each fire is a run. This surfaces the run history
|
||||
* the raw row hides — run count, last/next fire, schedule — as an aligned table.
|
||||
* Driven by the enriched rows from listTasks (tasks.ts); the --json path is
|
||||
* untouched. `now` is injectable so the relative times are testable.
|
||||
*/
|
||||
|
||||
interface TaskListRow {
|
||||
series_id: string;
|
||||
schedule?: string | null;
|
||||
runs?: number;
|
||||
failed_runs?: number;
|
||||
last_run?: string | null;
|
||||
next_run?: string | null;
|
||||
status?: string;
|
||||
log?: string | null;
|
||||
created_at?: string | null;
|
||||
prompt?: string | null;
|
||||
}
|
||||
|
||||
const COLS = ['SERIES', 'SCHEDULE', 'RUNS', 'FAILED', 'LAST RUN', 'NEXT RUN', 'STATUS', 'AGE', 'PROMPT'] as const;
|
||||
|
||||
function parseMs(iso: string): number {
|
||||
return Date.parse(/[Z+]|[+-]\d\d:\d\d$/.test(iso) ? iso : iso + 'Z');
|
||||
}
|
||||
|
||||
/** "1m ago" / "in 30s" — coarse, human relative time. */
|
||||
function duration(ms: number): string {
|
||||
const s = Math.abs(ms) / 1000;
|
||||
if (s < 60) return `${Math.round(s)}s`;
|
||||
if (s < 3600) return `${Math.round(s / 60)}m`;
|
||||
if (s < 86400) return `${Math.round(s / 3600)}h`;
|
||||
return `${Math.round(s / 86400)}d`;
|
||||
}
|
||||
|
||||
function lastRun(iso: string | null | undefined, now: number): string {
|
||||
if (!iso) return '-';
|
||||
const t = parseMs(iso);
|
||||
if (Number.isNaN(t)) return iso;
|
||||
return `${duration(now - t)} ago`;
|
||||
}
|
||||
|
||||
function nextRun(iso: string | null | undefined, now: number): string {
|
||||
if (!iso) return '-';
|
||||
const t = parseMs(iso);
|
||||
if (Number.isNaN(t)) return iso;
|
||||
return t <= now ? 'due' : `in ${duration(t - now)}`;
|
||||
}
|
||||
|
||||
/** AGE — how long since the series was created. */
|
||||
function age(iso: string | null | undefined, now: number): string {
|
||||
if (!iso) return '-';
|
||||
const t = parseMs(iso);
|
||||
return Number.isNaN(t) ? '-' : duration(now - t);
|
||||
}
|
||||
|
||||
function clip(s: string | null | undefined, n: number): string {
|
||||
const v = (s ?? '').replace(/\s+/g, ' ').trim();
|
||||
return v.length > n ? v.slice(0, n - 1) + '…' : v;
|
||||
}
|
||||
|
||||
export function formatTasksTable(rows: TaskListRow[], now: number = Date.now()): string {
|
||||
if (!rows.length) return 'No tasks.';
|
||||
const body = rows.map((r) => [
|
||||
r.series_id, // full id, copy-pasteable into `ncl tasks get --id <…>`
|
||||
r.schedule || 'once',
|
||||
String(r.runs ?? 0),
|
||||
String(r.failed_runs ?? 0),
|
||||
lastRun(r.last_run, now),
|
||||
nextRun(r.next_run, now),
|
||||
r.status ?? '-',
|
||||
age(r.created_at, now),
|
||||
clip(r.prompt, 40),
|
||||
]);
|
||||
const widths = COLS.map((c, i) => Math.max(c.length, ...body.map((row) => row[i].length)));
|
||||
const line = (cells: string[]) =>
|
||||
cells
|
||||
.map((c, i) => c.padEnd(widths[i]))
|
||||
.join(' ')
|
||||
.trimEnd();
|
||||
return [line([...COLS]), ...body.map(line)].join('\n');
|
||||
}
|
||||
+8
-1
@@ -18,7 +18,14 @@ export type RequestFrame = {
|
||||
};
|
||||
|
||||
export type ResponseFrame =
|
||||
| { id: string; ok: true; data: unknown }
|
||||
// `human` is an optional server-rendered presentational string. It lets
|
||||
// every transport — host CLI and the Bun container client — print one
|
||||
// canonical rendering without importing host-only formatters (the two
|
||||
// runtimes share no modules, so client-side formatters drift). `data`
|
||||
// stays the machine contract; --json callers ignore `human`. Additive:
|
||||
// old clients that don't know the field just fall back to their own
|
||||
// rendering of `data`.
|
||||
| { id: string; ok: true; data: unknown; human?: string }
|
||||
| { id: string; ok: false; error: { code: ErrorCode; message: string } };
|
||||
|
||||
export type ErrorCode =
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { ResourceDef } from './crud.js';
|
||||
import { listVerbs, renderVerbHelp, summaryLine } from './help-render.js';
|
||||
|
||||
const res: ResourceDef = {
|
||||
name: 'widget',
|
||||
plural: 'widgets',
|
||||
table: 'widgets',
|
||||
description: 'Test widgets.',
|
||||
idColumn: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||
{ name: 'name', type: 'string', description: 'Display name.', required: true, updatable: true },
|
||||
{ name: 'size', type: 'string', description: 'Widget size.', enum: ['s', 'm', 'l'], default: 'm' },
|
||||
],
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
|
||||
customOperations: {
|
||||
ping: {
|
||||
access: 'open',
|
||||
description: 'Ping a widget.\nLonger prose that only deep help shows.',
|
||||
args: [
|
||||
{ name: 'target', type: 'string', description: 'Where to ping.', required: true },
|
||||
{ name: 'count', type: 'number', description: 'How many times.', default: 1 },
|
||||
],
|
||||
examples: ['ncl widgets ping --target prod --count 3'],
|
||||
handler: async () => ({}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('listVerbs', () => {
|
||||
it('lists enabled generics then custom verbs', () => {
|
||||
expect(listVerbs(res)).toEqual(['list', 'get', 'create', 'update', 'ping']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderVerbHelp — custom operation', () => {
|
||||
it('renders usage, full description, flags with tags, and examples', () => {
|
||||
const out = renderVerbHelp(res, 'ping')!;
|
||||
expect(out).toContain('ncl widgets ping');
|
||||
expect(out).toContain('Longer prose that only deep help shows.');
|
||||
expect(out).toContain('--target');
|
||||
expect(out).toContain('(required)');
|
||||
expect(out).toContain('--count');
|
||||
expect(out).toContain('default: 1');
|
||||
expect(out).toContain('Examples:');
|
||||
expect(out).toContain('ncl widgets ping --target prod --count 3');
|
||||
});
|
||||
|
||||
it('tags non-open access on the usage line', () => {
|
||||
const gated: ResourceDef = {
|
||||
...res,
|
||||
customOperations: { ping: { ...res.customOperations!.ping, access: 'approval' } },
|
||||
};
|
||||
expect(renderVerbHelp(gated, 'ping')).toContain('ncl widgets ping [approval]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderVerbHelp — generic verbs', () => {
|
||||
it('create renders non-generated columns as flags', () => {
|
||||
const out = renderVerbHelp(res, 'create')!;
|
||||
expect(out).toContain('ncl widgets create [approval]');
|
||||
expect(out).toContain('--name');
|
||||
expect(out).toContain('(required)');
|
||||
expect(out).toContain('--size');
|
||||
expect(out).toContain('values: s | m | l');
|
||||
expect(out).not.toContain('--id'); // generated
|
||||
});
|
||||
|
||||
it('update renders only updatable columns and takes <id>', () => {
|
||||
const out = renderVerbHelp(res, 'update')!;
|
||||
expect(out).toContain('ncl widgets update <id> [approval]');
|
||||
expect(out).toContain('--name');
|
||||
expect(out).not.toContain('--size');
|
||||
});
|
||||
|
||||
it('list renders filter flags plus --limit, never marked required', () => {
|
||||
const out = renderVerbHelp(res, 'list')!;
|
||||
expect(out).toContain('--limit');
|
||||
expect(out).toContain('--name');
|
||||
expect(out).not.toContain('(required)');
|
||||
});
|
||||
|
||||
it('returns undefined for verbs the resource does not have', () => {
|
||||
expect(renderVerbHelp(res, 'delete')).toBeUndefined(); // not in operations
|
||||
expect(renderVerbHelp(res, 'bogus')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('summaryLine', () => {
|
||||
it('returns only the first line', () => {
|
||||
expect(summaryLine('Ping a widget.\nLonger prose.')).toBe('Ping a widget.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Pure renderers for command help. Single source for three surfaces that must
|
||||
* never disagree:
|
||||
* - `ncl <resource> help [<verb>]` (commands/help.ts)
|
||||
* - `--help` on any command (dispatch interception)
|
||||
* - the usage block appended to invalid-args errors (crud.ts validation)
|
||||
*
|
||||
* Imports only types from crud.ts, so crud.ts can import these functions at
|
||||
* runtime without a cycle.
|
||||
*/
|
||||
import type { ColumnDef, CustomOperation, ResourceDef } from './crud.js';
|
||||
|
||||
const GENERIC_VERBS = ['list', 'get', 'create', 'update', 'delete'] as const;
|
||||
type GenericVerb = (typeof GENERIC_VERBS)[number];
|
||||
|
||||
export function flagName(col: Pick<ColumnDef, 'name'>): string {
|
||||
return `--${col.name.replace(/_/g, '-')}`;
|
||||
}
|
||||
|
||||
/** First line of a possibly multi-paragraph description. */
|
||||
export function summaryLine(description: string): string {
|
||||
return description.split('\n', 1)[0];
|
||||
}
|
||||
|
||||
/** Indent every non-empty line of a block by `pad`. */
|
||||
export function indent(text: string, pad: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((l) => (l ? pad + l : l))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function flagLine(col: ColumnDef, extraTags: string[] = []): string {
|
||||
const tags: string[] = [...extraTags];
|
||||
if (col.required) tags.push('required');
|
||||
if (col.default !== undefined && col.default !== null) tags.push(`default: ${col.default}`);
|
||||
if (col.enum) tags.push(`values: ${col.enum.join(' | ')}`);
|
||||
const tagStr = tags.length > 0 ? ` (${tags.join(', ')})` : '';
|
||||
return ` ${flagName(col).padEnd(28)} ${summaryLine(col.description)}${tagStr}`;
|
||||
}
|
||||
|
||||
/** All verbs a resource exposes, generics first, in help order. */
|
||||
export function listVerbs(res: ResourceDef): string[] {
|
||||
const verbs: string[] = GENERIC_VERBS.filter((v) => res.operations[v]);
|
||||
if (res.customOperations) verbs.push(...Object.keys(res.customOperations));
|
||||
return verbs;
|
||||
}
|
||||
|
||||
/** Flags a generic verb accepts, derived from the resource's columns. */
|
||||
function genericFlags(res: ResourceDef, verb: GenericVerb): ColumnDef[] {
|
||||
switch (verb) {
|
||||
case 'create':
|
||||
return res.columns.filter((c) => !c.generated);
|
||||
case 'update':
|
||||
return res.columns.filter((c) => c.updatable);
|
||||
case 'list':
|
||||
// Non-generated columns double as equality filters.
|
||||
return [
|
||||
...res.columns.filter((c) => !c.generated).map((c) => ({ ...c, required: false })),
|
||||
{ name: 'limit', type: 'number', description: 'Max rows returned.', default: 200 } as ColumnDef,
|
||||
];
|
||||
case 'get':
|
||||
case 'delete':
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function genericSummary(res: ResourceDef, verb: GenericVerb): string {
|
||||
switch (verb) {
|
||||
case 'list':
|
||||
return `List ${res.plural}. Flags below act as equality filters.`;
|
||||
case 'get':
|
||||
return `Get a ${res.name} by ID.`;
|
||||
case 'create':
|
||||
return `Create a new ${res.name}.`;
|
||||
case 'update':
|
||||
return `Update a ${res.name} by ID. Provide at least one updatable flag.`;
|
||||
case 'delete':
|
||||
return `Delete a ${res.name} by ID.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep help for one verb: usage line, full description, flags, examples.
|
||||
* `verb` is a custom-operation key or a generic CRUD verb. Returns undefined
|
||||
* for a verb the resource doesn't have.
|
||||
*/
|
||||
export function renderVerbHelp(res: ResourceDef, verb: string): string | undefined {
|
||||
const op: CustomOperation | undefined = res.customOperations?.[verb];
|
||||
const generic = !op && (GENERIC_VERBS as readonly string[]).includes(verb) ? (verb as GenericVerb) : undefined;
|
||||
if (!op && !generic) return undefined;
|
||||
if (generic && !res.operations[generic]) return undefined;
|
||||
|
||||
const access = op ? op.access : res.operations[generic!];
|
||||
const accessTag = access && access !== 'open' ? ` [${access}]` : '';
|
||||
const needsId = generic === 'get' || generic === 'update' || generic === 'delete';
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`ncl ${res.plural} ${verb}${needsId ? ' <id>' : ''}${accessTag}`);
|
||||
lines.push('');
|
||||
lines.push(op ? op.description : genericSummary(res, generic!));
|
||||
|
||||
const flags = op ? (op.args ?? []) : genericFlags(res, generic!);
|
||||
if (flags.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Flags:');
|
||||
for (const f of flags) lines.push(flagLine(f));
|
||||
}
|
||||
if (op?.examples?.length) {
|
||||
lines.push('');
|
||||
lines.push('Examples:');
|
||||
for (const ex of op.examples) lines.push(indent(ex, ' '));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
+16
-1
@@ -10,7 +10,14 @@
|
||||
*/
|
||||
import type { CallerContext } from './frame.js';
|
||||
|
||||
export type Access = 'open' | 'approval';
|
||||
/**
|
||||
* Resources an agent under `cli_scope=group` may touch. Single source —
|
||||
* consumed by both dispatch enforcement and `ncl help` filtering, so the
|
||||
* agent is never shown a resource the gate would reject (or vice versa).
|
||||
*/
|
||||
export const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members', 'tasks']);
|
||||
|
||||
export type Access = 'open' | 'approval' | 'hidden';
|
||||
|
||||
export type CommandDef<TArgs = unknown, TData = unknown> = {
|
||||
name: string;
|
||||
@@ -34,6 +41,14 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
|
||||
/** Validates `frame.args` and produces the typed handler input. Throws on invalid. */
|
||||
parseArgs: (raw: Record<string, unknown>) => TArgs;
|
||||
handler: (args: TArgs, ctx: CallerContext) => Promise<TData>;
|
||||
/**
|
||||
* Optional presentational renderer. When set, dispatch attaches its output
|
||||
* as the response frame's `human` field (server-rendered once, printed
|
||||
* verbatim by every client in human mode). Runs after post-handler scope
|
||||
* filtering, so it only ever sees data the caller is allowed to see. A
|
||||
* throwing formatter is ignored — clients fall back to rendering `data`.
|
||||
*/
|
||||
formatHuman?: (data: TData) => string;
|
||||
};
|
||||
|
||||
const registry = new Map<string, CommandDef>();
|
||||
|
||||
@@ -58,10 +58,39 @@ registerResource({
|
||||
type: 'string',
|
||||
description: "The target's ID — messaging_groups.id for channels, agent_groups.id for agents.",
|
||||
},
|
||||
{ name: 'channel_type', type: 'string', description: 'Resolved channel type for channel destinations.' },
|
||||
{ name: 'display_name', type: 'string', description: 'Resolved chat title or agent name.' },
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.' },
|
||||
],
|
||||
operations: { list: 'open' },
|
||||
operations: {},
|
||||
customOperations: {
|
||||
list: {
|
||||
access: 'open',
|
||||
description: 'List destinations with resolved channel/title labels.',
|
||||
handler: async (args) => {
|
||||
const agentGroupId = (args.agent_group_id as string | undefined) ?? (args.id as string | undefined);
|
||||
const params: unknown[] = [];
|
||||
const where = agentGroupId ? 'WHERE ad.agent_group_id = ?' : '';
|
||||
if (agentGroupId) params.push(agentGroupId);
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT
|
||||
ad.agent_group_id,
|
||||
ad.local_name,
|
||||
ad.target_type,
|
||||
ad.target_id,
|
||||
CASE WHEN ad.target_type = 'channel' THEN mg.channel_type ELSE NULL END AS channel_type,
|
||||
CASE WHEN ad.target_type = 'channel' THEN mg.name ELSE ag.name END AS display_name,
|
||||
ad.created_at
|
||||
FROM agent_destinations ad
|
||||
LEFT JOIN messaging_groups mg ON ad.target_type = 'channel' AND ad.target_id = mg.id
|
||||
LEFT JOIN agent_groups ag ON ad.target_type = 'agent' AND ad.target_id = ag.id
|
||||
${where}
|
||||
ORDER BY ad.agent_group_id, ad.local_name`,
|
||||
)
|
||||
.all(...params);
|
||||
},
|
||||
},
|
||||
add: {
|
||||
access: 'approval',
|
||||
description: 'Add a destination for an agent. Use --agent-group-id, --local-name, --target-type, --target-id.',
|
||||
|
||||
@@ -14,3 +14,4 @@ import './user-dms.js';
|
||||
import './dropped-messages.js';
|
||||
import './approvals.js';
|
||||
import './sessions.js';
|
||||
import './tasks.js';
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return {
|
||||
...actual,
|
||||
DATA_DIR: '/tmp/nanoclaw-test-cli-tasks',
|
||||
GROUPS_DIR: '/tmp/nanoclaw-test-cli-tasks/groups',
|
||||
TIMEZONE: 'UTC',
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
getActiveContainerCount: vi.fn().mockReturnValue(0),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-cli-tasks';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
||||
import { createSession, findSessionByAgentGroup, getSessionsByAgentGroup, taskThreadId } from '../../db/sessions.js';
|
||||
import { countDueMessages } from '../../db/session-db.js';
|
||||
import { inboundDbPath, initSessionFolder } from '../../session-manager.js';
|
||||
import { dispatch } from '../dispatch.js';
|
||||
import { formatTasksTable } from '../format-tasks.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
import './tasks.js';
|
||||
import '../commands/index.js'; // registers tasks-help for the help-topic test
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createGroup(id: string): void {
|
||||
createAgentGroup({ id, name: id, folder: id, agent_provider: null, created_at: now() });
|
||||
}
|
||||
|
||||
function createChatSession(group: string, id: string): void {
|
||||
createSession({
|
||||
id,
|
||||
agent_group_id: group,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
});
|
||||
initSessionFolder(group, id);
|
||||
}
|
||||
|
||||
function agentCtx(group = 'ag-1', session = 'chat-1'): CallerContext {
|
||||
return { caller: 'agent', agentGroupId: group, sessionId: session, messagingGroupId: 'mg-1' };
|
||||
}
|
||||
|
||||
describe('tasks CLI resource', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
createGroup('ag-1');
|
||||
createGroup('ag-2');
|
||||
createChatSession('ag-1', 'chat-1');
|
||||
createChatSession('ag-2', 'chat-2');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('create writes the task into the group system session, not the caller chat session', async () => {
|
||||
const resp = await dispatch(
|
||||
{
|
||||
id: 'req-1',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'send a briefing', process_after: '2026-01-15T09:00:00Z' },
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
const created = resp.data as { series_id: string; session_id: string };
|
||||
expect(created.session_id).not.toBe('chat-1');
|
||||
|
||||
// The task lands in its own isolated per-series session, not the chat session.
|
||||
const sessions = getSessionsByAgentGroup('ag-1');
|
||||
const taskSession = sessions.find((s) => s.id === created.session_id);
|
||||
expect(taskSession?.thread_id).toBe(taskThreadId(created.series_id));
|
||||
|
||||
const chatDb = new Database(inboundDbPath('ag-1', 'chat-1'), { readonly: true });
|
||||
expect(chatDb.prepare("SELECT COUNT(*) AS count FROM messages_in WHERE kind = 'task'").get()).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
chatDb.close();
|
||||
|
||||
const systemDb = new Database(inboundDbPath('ag-1', created.session_id), { readonly: true });
|
||||
const row = systemDb.prepare("SELECT content FROM messages_in WHERE kind = 'task'").get() as { content: string };
|
||||
const content = JSON.parse(row.content);
|
||||
expect(content).toMatchObject({ originSessionId: 'chat-1' });
|
||||
expect(content.prompt).toContain('send a briefing');
|
||||
expect(content.prompt).toContain(`tasks/${created.series_id}.md`); // log-path hint injected
|
||||
systemDb.close();
|
||||
});
|
||||
|
||||
it('tasks-list attaches a server-rendered human table (so the container agent gets it too)', async () => {
|
||||
await dispatch(
|
||||
{
|
||||
id: 'c',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'briefing', process_after: '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
const resp = await dispatch({ id: 'l', command: 'tasks-list', args: {} }, agentCtx());
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
// Red-on-delete guard for the dispatch wiring: the host renders format-tasks
|
||||
// once and ships it as `human`, so the Bun container prints the aligned
|
||||
// table instead of a raw column dump (it cannot import the host formatter).
|
||||
expect(resp.human).toBeDefined();
|
||||
expect(resp.human).toMatch(/SERIES\s+SCHEDULE\s+RUNS\s+FAILED\s+LAST RUN\s+NEXT RUN/);
|
||||
expect(resp.human).toContain('briefing-');
|
||||
});
|
||||
|
||||
it('recurrence more frequent than 4x/day is refused with the quota warning', async () => {
|
||||
const resp = await dispatch(
|
||||
{ id: 'c', command: 'tasks-create', args: { prompt: 'x', name: 'spam', recurrence: '*/2 * * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.message).toContain('this task has not been scheduled');
|
||||
expect(resp.error.message).toContain('ncl tasks create --help');
|
||||
expect(resp.error.message).toContain('--dangerously-override-recurrence-limit');
|
||||
}
|
||||
});
|
||||
|
||||
it('exactly 4 fires/day passes; the override flag bypasses the limit', async () => {
|
||||
const four = await dispatch(
|
||||
{ id: 'c4', command: 'tasks-create', args: { prompt: 'x', name: 'four', recurrence: '0 0,6,12,18 * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(four.ok).toBe(true);
|
||||
|
||||
const overridden = await dispatch(
|
||||
{
|
||||
id: 'co',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'fast', recurrence: '*/30 * * * *', dangerously_override_recurrence_limit: true },
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
expect(overridden.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('a --script gate exempts frequent recurrence — the sanctioned monitor pattern', async () => {
|
||||
const scripted = await dispatch(
|
||||
{
|
||||
id: 'cs',
|
||||
command: 'tasks-create',
|
||||
args: {
|
||||
prompt: 'triage queue',
|
||||
name: 'watch',
|
||||
recurrence: '*/10 * * * *',
|
||||
script: 'echo {"wakeAgent": false}',
|
||||
},
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
expect(scripted.ok).toBe(true);
|
||||
|
||||
// update --recurrence on a task that already has a script: also exempt.
|
||||
if (!scripted.ok) return;
|
||||
const seriesId = (scripted.data as { series_id: string }).series_id;
|
||||
const upd = await dispatch(
|
||||
{ id: 'us', command: 'tasks-update', args: { id: seriesId, recurrence: '*/5 * * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(upd.ok).toBe(true);
|
||||
|
||||
// …but clearing the script in the same update re-arms the guard.
|
||||
const cleared = await dispatch(
|
||||
{ id: 'uc', command: 'tasks-update', args: { id: seriesId, recurrence: '*/5 * * * *', script: 'none' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(cleared.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('the limit also guards update --recurrence (no create-slow-then-update bypass)', async () => {
|
||||
const created = await dispatch(
|
||||
{ id: 'c', command: 'tasks-create', args: { prompt: 'x', name: 'sneak', recurrence: '0 9 * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const seriesId = (created.data as { series_id: string }).series_id;
|
||||
|
||||
const upd = await dispatch(
|
||||
{ id: 'u', command: 'tasks-update', args: { id: seriesId, recurrence: '* * * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(upd.ok).toBe(false);
|
||||
if (!upd.ok) expect(upd.error.message).toContain('this task has not been scheduled');
|
||||
});
|
||||
|
||||
it('tasks create --help carries the script contract and the frequency-limit caveat', async () => {
|
||||
// --help and `tasks help create` render the same deep verb help.
|
||||
const resp = await dispatch({ id: 'h', command: 'tasks-create', args: { help: true } }, agentCtx());
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const text = resp.data as string;
|
||||
expect(text).toContain('wakeAgent');
|
||||
expect(text).toContain('Frequency limit');
|
||||
}
|
||||
});
|
||||
|
||||
it('agent-shared lookup skips the task system session', async () => {
|
||||
await dispatch(
|
||||
{
|
||||
id: 'req-1',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'send a briefing', process_after: '2026-01-15T09:00:00Z' },
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
|
||||
expect(findSessionByAgentGroup('ag-1')?.id).toBe('chat-1');
|
||||
});
|
||||
|
||||
it('group-scoped agents cannot list tasks from another group session', async () => {
|
||||
const resp = await dispatch(
|
||||
{ id: 'req-1', command: 'tasks-list', args: { session: 'chat-2' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('handler-error');
|
||||
expect(resp.error.message).toContain('session not found');
|
||||
}
|
||||
});
|
||||
|
||||
it('--name yields a short, readable, fs/thread-safe id', async () => {
|
||||
const r = await dispatch(
|
||||
{
|
||||
id: 'rn',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'Morning Joke!!', process_after: '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const id = (r.data as { series_id: string }).series_id;
|
||||
expect(id).toMatch(/^morning-joke-[0-9a-f]{4}$/);
|
||||
expect(id).toMatch(/^[a-z0-9-]+$/); // safe as thread suffix / filename / --id
|
||||
});
|
||||
|
||||
it('no name yields a t-<hex> id', async () => {
|
||||
const r = await dispatch(
|
||||
{ id: 'rnn', command: 'tasks-create', args: { prompt: 'x', process_after: '2999-01-01T00:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect((r.data as { series_id: string }).series_id).toMatch(/^t-[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
it('recurring create derives the first run from the cron grid when --process-after is omitted', async () => {
|
||||
const r = await dispatch(
|
||||
{ id: 'rec', command: 'tasks-create', args: { prompt: 'x', name: 'nightly', recurrence: '0 9 * * 1-5' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const task = r.data as { process_after: string; recurrence: string };
|
||||
expect(task.recurrence).toBe('0 9 * * 1-5');
|
||||
// First fire snapped onto the cron grid (TIMEZONE=UTC in this suite).
|
||||
const firstRun = new Date(task.process_after);
|
||||
expect(Number.isNaN(firstRun.getTime())).toBe(false);
|
||||
expect(firstRun.getUTCHours()).toBe(9);
|
||||
expect(firstRun.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('one-shot create still requires --process-after (nothing to derive it from)', async () => {
|
||||
const r = await dispatch(
|
||||
{ id: 'os', command: 'tasks-create', args: { prompt: 'x', name: 'once' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error.message).toContain('--process-after is required');
|
||||
});
|
||||
|
||||
it('run queues an extra immediate occurrence without consuming the scheduled one', async () => {
|
||||
const created = await dispatch(
|
||||
{
|
||||
id: 'c',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'pingable', process_after: '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const { series_id, session_id } = created.data as { series_id: string; session_id: string };
|
||||
|
||||
const run = await dispatch({ id: 'r', command: 'tasks-run', args: { id: series_id } }, agentCtx('ag-1', 'chat-1'));
|
||||
expect(run.ok).toBe(true);
|
||||
if (!run.ok) return;
|
||||
const fired = run.data as { series_id: string; row_id: string; status: string };
|
||||
expect(fired.series_id).toBe(series_id);
|
||||
expect(fired.row_id).not.toBe(series_id);
|
||||
expect(fired.status).toBe('pending');
|
||||
|
||||
const db = new Database(inboundDbPath('ag-1', session_id), { readonly: true });
|
||||
const pending = db
|
||||
.prepare(
|
||||
"SELECT id, recurrence, process_after FROM messages_in WHERE kind = 'task' AND status = 'pending' AND series_id = ?",
|
||||
)
|
||||
.all(series_id) as Array<{ id: string; recurrence: string | null; process_after: string }>;
|
||||
db.close();
|
||||
// Original scheduled row + the new run-now occurrence both still pending.
|
||||
expect(pending).toHaveLength(2);
|
||||
const runRow = pending.find((p) => p.id === fired.row_id);
|
||||
expect(runRow?.recurrence).toBeNull(); // never re-armed into a phantom series
|
||||
expect(new Date(runRow!.process_after).getTime()).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
it('task object exposes origin_session_id and created_at', async () => {
|
||||
const r = await dispatch(
|
||||
{
|
||||
id: 'ro',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'o', process_after: '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const d = r.data as { origin_session_id: string | null; created_at: string };
|
||||
expect(d.origin_session_id).toBe('chat-1'); // the session that created it
|
||||
expect(d.created_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('each task gets its own isolated session, and list fans out across them', async () => {
|
||||
const a = await dispatch(
|
||||
{ id: 'r-a', command: 'tasks-create', args: { prompt: 'task A', process_after: '2026-01-15T09:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
const b = await dispatch(
|
||||
{ id: 'r-b', command: 'tasks-create', args: { prompt: 'task B', process_after: '2026-01-15T09:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(a.ok && b.ok).toBe(true);
|
||||
if (!a.ok || !b.ok) return;
|
||||
const ta = a.data as { session_id: string; series_id: string };
|
||||
const tb = b.data as { session_id: string; series_id: string };
|
||||
|
||||
// Distinct per-series sessions — not one shared system:tasks session.
|
||||
expect(ta.session_id).not.toBe(tb.session_id);
|
||||
|
||||
// list (no --session) fans out across every task session in the group.
|
||||
const list = await dispatch({ id: 'r-l', command: 'tasks-list', args: {} }, agentCtx('ag-1', 'chat-1'));
|
||||
expect(list.ok).toBe(true);
|
||||
if (!list.ok) return;
|
||||
const ids = (list.data as Array<{ series_id: string }>).map((t) => t.series_id);
|
||||
expect(ids).toContain(ta.series_id);
|
||||
expect(ids).toContain(tb.series_id);
|
||||
});
|
||||
|
||||
it('list enriches each series with run history (CronJob view)', async () => {
|
||||
const created = await dispatch(
|
||||
{
|
||||
id: 'r-agg',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'brain digest', recurrence: '0 9 * * *', 'process-after': '2026-01-15T09:05:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const { session_id, series_id } = created.data as { session_id: string; series_id: string };
|
||||
|
||||
// Seed three completed fires for this series into its own session inbound.db.
|
||||
const db = new Database(inboundDbPath('ag-1', session_id));
|
||||
const ins = db.prepare(
|
||||
'INSERT INTO messages_in (id, seq, timestamp, status, tries, kind, content, series_id, process_after) ' +
|
||||
"VALUES (?, ?, datetime('now'), 'completed', 0, 'task', '{}', ?, ?)",
|
||||
);
|
||||
ins.run('run-1', 100, series_id, '2026-01-15T09:02:00Z');
|
||||
ins.run('run-2', 102, series_id, '2026-01-15T09:03:00Z');
|
||||
ins.run('run-3', 104, series_id, '2026-01-15T09:04:00Z');
|
||||
db.close();
|
||||
|
||||
const list = await dispatch({ id: 'r-agg-l', command: 'tasks-list', args: {} }, agentCtx('ag-1', 'chat-1'));
|
||||
expect(list.ok).toBe(true);
|
||||
if (!list.ok) return;
|
||||
const row = (list.data as Array<Record<string, unknown>>).find((t) => t.series_id === series_id);
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.runs).toBe(3);
|
||||
expect(row?.last_run).toBe('2026-01-15T09:04:00Z'); // max completed process_after
|
||||
expect(String(row?.next_run)).toMatch(/^2026-01-15T09:05:00/); // the live pending occurrence
|
||||
expect(row?.schedule).toBe('0 9 * * *');
|
||||
expect(row?.log).toBe(`tasks/${series_id}.md`);
|
||||
});
|
||||
|
||||
// The schedule→wake primitive without a container: a task created through the
|
||||
// real `ncl tasks create` path must land in the agent group's system session
|
||||
// AND be counted by the same due-message query the host sweep uses to decide a
|
||||
// wake. Goes red if trigger defaulting, system-session routing, or the due
|
||||
// predicate ever drift apart.
|
||||
describe('a due task makes the system session wakeable', () => {
|
||||
it('countDueMessages sees a past task and ignores a future one', async () => {
|
||||
const created = await dispatch(
|
||||
{ id: 'r-due', command: 'tasks-create', args: { prompt: 'run me', 'process-after': '2020-01-01T00:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const systemId = (created.data as { session_id: string }).session_id;
|
||||
|
||||
const dueDb = new Database(inboundDbPath('ag-1', systemId), { readonly: true });
|
||||
expect(countDueMessages(dueDb)).toBe(1); // host sweep would wake this session
|
||||
dueDb.close();
|
||||
|
||||
// A far-future task in the same system session is not yet due.
|
||||
const future = await dispatch(
|
||||
{ id: 'r-fut', command: 'tasks-create', args: { prompt: 'later', 'process-after': '2999-01-01T00:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(future.ok).toBe(true);
|
||||
|
||||
const stillDb = new Database(inboundDbPath('ag-1', systemId), { readonly: true });
|
||||
expect(countDueMessages(stillDb)).toBe(1); // still just the one past task
|
||||
stillDb.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('append-log', () => {
|
||||
const logFile = (folder: string, series: string) => `${TEST_DIR}/groups/${folder}/tasks/${series}.md`;
|
||||
|
||||
it('writes a host-timestamped line to the run log and creates the file (explicit --id)', async () => {
|
||||
const resp = await dispatch(
|
||||
{ id: 'al-1', command: 'tasks-append-log', args: { id: 'my-task-1', msg: 'did the thing; it worked' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
const content = fs.readFileSync(logFile('ag-1', 'my-task-1'), 'utf8').trim();
|
||||
expect(content).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z — did the thing; it worked$/);
|
||||
});
|
||||
|
||||
it('derives the series from the caller task session when --id is omitted', async () => {
|
||||
const created = await dispatch(
|
||||
{
|
||||
id: 'al-c',
|
||||
command: 'tasks-create',
|
||||
args: { name: 'derive-me', prompt: 'x', 'process-after': '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const { series_id, session_id } = created.data as { series_id: string; session_id: string };
|
||||
|
||||
// The fire runs INSIDE that task session, so no --id is needed.
|
||||
const resp = await dispatch(
|
||||
{ id: 'al-2', command: 'tasks-append-log', args: { msg: 'auto-derived run' } },
|
||||
agentCtx('ag-1', session_id),
|
||||
);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
expect((resp.data as { series: string }).series).toBe(series_id);
|
||||
expect(fs.readFileSync(logFile('ag-1', series_id), 'utf8')).toContain('auto-derived run');
|
||||
});
|
||||
|
||||
it('requires --msg', async () => {
|
||||
const resp = await dispatch(
|
||||
{ id: 'al-3', command: 'tasks-append-log', args: { id: 'my-task-1' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) expect(resp.error.message).toContain('--msg is required');
|
||||
});
|
||||
|
||||
it('errors when there is no --id and the caller is not in a task session', async () => {
|
||||
// chat-1 is a normal chat session, not system:tasks:* → nothing to derive.
|
||||
const resp = await dispatch(
|
||||
{ id: 'al-4', command: 'tasks-append-log', args: { msg: 'orphan' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) expect(resp.error.message).toMatch(/--id is required/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTasksTable', () => {
|
||||
const now = Date.parse('2026-01-15T09:05:30Z');
|
||||
const rows = [
|
||||
{
|
||||
series_id: 'task-5bbe082a-6298-4699',
|
||||
schedule: '* * * * *',
|
||||
runs: 7,
|
||||
failed_runs: 2,
|
||||
last_run: '2026-01-15T09:04:30Z',
|
||||
next_run: '2026-01-15T09:06:00Z',
|
||||
status: 'pending',
|
||||
log: 'tasks/task-5bbe082a.md',
|
||||
created_at: '2026-01-15T08:05:30Z', // 1h before now
|
||||
prompt: 'You are NanoClaw, wired into the company brain, your job this run is to read it',
|
||||
},
|
||||
];
|
||||
|
||||
it('renders an aligned table with run history', () => {
|
||||
const lines = formatTasksTable(rows, now).split('\n');
|
||||
expect(lines[0]).toMatch(/SERIES\s+SCHEDULE\s+RUNS\s+FAILED\s+LAST RUN\s+NEXT RUN\s+STATUS\s+AGE\s+PROMPT/);
|
||||
expect(lines[1]).toContain('1h'); // AGE column — created 1h ago
|
||||
expect(lines[1]).toContain('task-5bbe082a-6298-4699'); // FULL series id — copy-pasteable into `tasks get --id`
|
||||
expect(lines[1]).toContain('* * * * *');
|
||||
expect(lines[1]).toContain('1m ago'); // 09:04:30 vs 09:05:30
|
||||
expect(lines[1]).toContain('in 30s'); // 09:06:00 vs 09:05:30
|
||||
expect(lines[1]).toContain('…'); // prompt truncated
|
||||
});
|
||||
|
||||
it('handles a never-fired series and an empty list', () => {
|
||||
expect(formatTasksTable([], now)).toBe('No tasks.');
|
||||
const oneShot = formatTasksTable(
|
||||
[
|
||||
{
|
||||
series_id: 'task-x',
|
||||
schedule: 'once',
|
||||
runs: 0,
|
||||
last_run: null,
|
||||
next_run: '2026-01-15T09:00:00Z',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
now,
|
||||
).split('\n')[1];
|
||||
expect(oneShot).toContain('once');
|
||||
expect(oneShot).toMatch(/\bdue\b/); // next_run in the past → due
|
||||
expect(oneShot).toContain('-'); // last_run '-' (never fired)
|
||||
});
|
||||
});
|
||||
|
||||
describe('deep verb help (ncl tasks help create)', () => {
|
||||
it('resolves through the dispatcher fallback and renders the full contract + examples', async () => {
|
||||
// Side-effect import mirrors the CLI server boot: registers <plural>-help.
|
||||
await import('../commands/index.js');
|
||||
const resp = await dispatch({ id: 'h1', command: 'tasks-help-create', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const text = resp.data as string;
|
||||
expect(text).toContain('ncl tasks create');
|
||||
expect(text).toContain('wakeAgent'); // full multi-line script contract present
|
||||
expect(text).toContain('Examples:'); // examples block rendered
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an unknown verb with a pointer back to resource help', async () => {
|
||||
await import('../commands/index.js');
|
||||
const resp = await dispatch({ id: 'h2', command: 'tasks-help-frobnicate', args: {} }, { caller: 'host' });
|
||||
expect(resp.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,784 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import fs from 'fs';
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
|
||||
import { GROUPS_DIR, TIMEZONE } from '../../config.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import {
|
||||
findTaskSessions,
|
||||
getActiveSessions,
|
||||
getSession,
|
||||
isTaskThread,
|
||||
TASKS_SYSTEM_THREAD_ID,
|
||||
} from '../../db/sessions.js';
|
||||
import {
|
||||
cancelAllTasks,
|
||||
cancelTask,
|
||||
deleteTask,
|
||||
insertTaskRow,
|
||||
pauseTask,
|
||||
resumeTask,
|
||||
updateTask,
|
||||
type TaskUpdate,
|
||||
} from '../../modules/scheduling/db.js';
|
||||
import { inboundDbPath, resolveTaskSession, withInboundDb } from '../../session-manager.js';
|
||||
import { parseZonedToUtc } from '../../timezone.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
import { formatTasksTable } from '../format-tasks.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
|
||||
type TaskStatus = 'pending' | 'paused';
|
||||
|
||||
interface TaskRow {
|
||||
row_id: string;
|
||||
series_id: string | null;
|
||||
status: string;
|
||||
process_after: string | null;
|
||||
recurrence: string | null;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
tries: number;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
interface ScopedSession {
|
||||
id: string;
|
||||
agent_group_id: string;
|
||||
}
|
||||
|
||||
function str(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function bool(value: unknown): boolean {
|
||||
return value === true || value === 'true' || value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Short, readable, filesystem/thread-safe task id. With a name → `<slug>-<4hex>`
|
||||
* (e.g. "Morning joke" → `morning-joke-a25c`); without → `t-<6hex>`. Always
|
||||
* matches /^[a-z0-9-]+$/ so it is safe as a thread suffix (`system:tasks:<id>`),
|
||||
* a filename (`tasks/<id>.md`), and a copy-pasteable --id.
|
||||
*/
|
||||
function makeTaskId(name: unknown): string {
|
||||
const hex = (n: number): string => randomUUID().replace(/-/g, '').slice(0, n);
|
||||
const slug =
|
||||
typeof name === 'string'
|
||||
? name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 24)
|
||||
.replace(/-+$/g, '')
|
||||
: '';
|
||||
return slug ? `${slug}-${hex(4)}` : `t-${hex(6)}`;
|
||||
}
|
||||
|
||||
function parseProcessAfter(value: unknown): string {
|
||||
const raw = str(value);
|
||||
if (!raw) throw new Error('--process-after is required');
|
||||
const date = parseZonedToUtc(raw, TIMEZONE);
|
||||
if (Number.isNaN(date.getTime())) throw new Error(`invalid --process-after: ${raw}`);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run timestamp for a new task. When a recurrence is given but no
|
||||
* --process-after, derive the first fire from the cron grid (in TIMEZONE) so the
|
||||
* common recurring case is a single flag — `--recurrence "0 9 * * 1-5"` — with no
|
||||
* redundant, easily-stale hand-picked instant. --process-after is still required
|
||||
* for one-shots (no recurrence to derive from) and still wins when supplied.
|
||||
*/
|
||||
function firstRunIso(value: unknown, recurrence: string | null): string {
|
||||
if (str(value) === undefined && recurrence) {
|
||||
const next = CronExpressionParser.parse(recurrence, { tz: TIMEZONE }).next().toISOString();
|
||||
if (!next) throw new Error(`--recurrence has no upcoming run: ${recurrence}`);
|
||||
return next;
|
||||
}
|
||||
return parseProcessAfter(value);
|
||||
}
|
||||
|
||||
function normalizeNullableString(value: unknown): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'string') return String(value);
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '' || trimmed === 'null' || trimmed === 'none') return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateRecurrence(value: string | null | undefined): void {
|
||||
if (!value) return;
|
||||
try {
|
||||
CronExpressionParser.parse(value, { tz: TIMEZONE });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`invalid --recurrence: ${msg}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frequency guard: refuse recurrences more frequent than 4 fires/day unless
|
||||
* the agent explicitly overrides. Frequent tasks burn the user's quota (or
|
||||
* get their account banned) — the sanctioned pattern is a slower cron plus a
|
||||
* pre-task gate script that checks an external condition and only wakes the
|
||||
* agent when something changed (`ncl tasks create --help`). Counted over the
|
||||
* next 24h from now in the instance timezone, so uneven crons are judged by
|
||||
* what they would actually do.
|
||||
*/
|
||||
const MAX_DAILY_FIRES = 4;
|
||||
|
||||
const RECURRENCE_LIMIT_WARNING =
|
||||
'Warning: this task has not been scheduled. Frequent running tasks consume the ' +
|
||||
"user's subscription quota or unnecessarily use tokens and can cause the user's " +
|
||||
'account to be banned. Instead, use a pre-task run script that you write that can ' +
|
||||
'check some kind of external condition, usually via one or more API calls. The ' +
|
||||
'script returns a decision programmatically whether the task needs to be run now ' +
|
||||
'or not. For example, an API call to GitHub to check if there are open PRs, and ' +
|
||||
'only run when there are new open PRs.\n' +
|
||||
'Run `ncl tasks create --help` to get full directions on how to write a script and test it.\n\n' +
|
||||
'Note: if and only if you explicitly need to schedule a task more frequently and ' +
|
||||
"you've verified with the user that they understand and that this is what they " +
|
||||
'want and based on your judgment you agree that this is the right thing to do in ' +
|
||||
'this situation, you can override this with --dangerously-override-recurrence-limit';
|
||||
|
||||
function enforceRecurrenceLimit(recurrence: string | null, override: boolean, hasScript: boolean): void {
|
||||
// A gate script IS the sanctioned mitigation the warning steers toward — a
|
||||
// script-gated fire that finds nothing never wakes the agent, so scripted
|
||||
// tasks may run at any cadence without the override.
|
||||
if (!recurrence || override || hasScript) return;
|
||||
const horizon = Date.now() + 24 * 60 * 60 * 1000;
|
||||
const interval = CronExpressionParser.parse(recurrence, { tz: TIMEZONE });
|
||||
let fires = 0;
|
||||
while (fires <= MAX_DAILY_FIRES) {
|
||||
const next = interval.next();
|
||||
if (next.getTime() > horizon) break;
|
||||
fires++;
|
||||
}
|
||||
if (fires > MAX_DAILY_FIRES) throw new Error(RECURRENCE_LIMIT_WARNING);
|
||||
}
|
||||
|
||||
function statusFilter(args: Record<string, unknown>): TaskStatus | undefined {
|
||||
const status = str(args.status);
|
||||
if (!status) return undefined;
|
||||
if (status !== 'pending' && status !== 'paused') {
|
||||
throw new Error('--status must be pending or paused');
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function groupArg(args: Record<string, unknown>, ctx: CallerContext): string | undefined {
|
||||
if (ctx.caller === 'agent') return ctx.agentGroupId;
|
||||
return str(args.group) ?? str(args.agent_group_id);
|
||||
}
|
||||
|
||||
function ownSession(sessionId: string, ctx: CallerContext): ScopedSession {
|
||||
const session = getSession(sessionId);
|
||||
if (!session) throw new Error(`session not found: ${sessionId}`);
|
||||
if (ctx.caller === 'agent' && session.agent_group_id !== ctx.agentGroupId) {
|
||||
throw new Error(`session not found: ${sessionId}`);
|
||||
}
|
||||
return { id: session.id, agent_group_id: session.agent_group_id };
|
||||
}
|
||||
|
||||
function selectedSessions(args: Record<string, unknown>, ctx: CallerContext): ScopedSession[] {
|
||||
const sessionId = str(args.session);
|
||||
if (sessionId) return [ownSession(sessionId, ctx)];
|
||||
|
||||
const group = groupArg(args, ctx);
|
||||
if (group) {
|
||||
// One session per live task series — the loops below already fan out across them.
|
||||
return findTaskSessions(group).map((s) => ({ id: s.id, agent_group_id: s.agent_group_id }));
|
||||
}
|
||||
|
||||
if (ctx.caller === 'agent') return [];
|
||||
return getActiveSessions().map((s) => ({ id: s.id, agent_group_id: s.agent_group_id }));
|
||||
}
|
||||
|
||||
function withInbound<T>(session: ScopedSession, fn: (db: Database.Database) => T): T | undefined {
|
||||
if (!fs.existsSync(inboundDbPath(session.agent_group_id, session.id))) return undefined;
|
||||
return withInboundDb(session.agent_group_id, session.id, fn);
|
||||
}
|
||||
|
||||
function parseContent(raw: string): { prompt: string; script: string | null; originSessionId: string | null } {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
prompt: typeof parsed.prompt === 'string' ? parsed.prompt : '',
|
||||
script: typeof parsed.script === 'string' ? parsed.script : null,
|
||||
originSessionId: typeof parsed.originSessionId === 'string' ? parsed.originSessionId : null,
|
||||
};
|
||||
} catch {
|
||||
// LEGACY-COMPAT(v1-tasks): plain-string content from rows that predate the
|
||||
// JSON envelope. Removable once no pre-v2 session DBs remain in the wild.
|
||||
return { prompt: raw, script: null, originSessionId: null };
|
||||
}
|
||||
}
|
||||
|
||||
function toOutput(session: ScopedSession, row: TaskRow) {
|
||||
const content = parseContent(row.content);
|
||||
return {
|
||||
agent_group_id: session.agent_group_id,
|
||||
session_id: session.id,
|
||||
series_id: row.series_id ?? row.row_id,
|
||||
row_id: row.row_id,
|
||||
status: row.status,
|
||||
process_after: row.process_after,
|
||||
recurrence: row.recurrence,
|
||||
prompt: content.prompt.length > 120 ? content.prompt.slice(0, 117) + '...' : content.prompt,
|
||||
has_script: content.script ? 1 : 0,
|
||||
origin_session_id: content.originSessionId, // which session created the task (null for CLI-created)
|
||||
created_at: row.timestamp,
|
||||
tries: row.tries,
|
||||
};
|
||||
}
|
||||
|
||||
function selectLiveTasks(db: Database.Database, status?: TaskStatus): TaskRow[] {
|
||||
const statusSql = status ? 'status = ?' : "status IN ('pending', 'paused')";
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id AS row_id, series_id, status, process_after, recurrence, content, timestamp, tries, MAX(seq) AS seq
|
||||
FROM messages_in
|
||||
WHERE kind = 'task'
|
||||
AND ${statusSql}
|
||||
GROUP BY series_id
|
||||
ORDER BY datetime(process_after) ASC, seq ASC`,
|
||||
)
|
||||
.all(...(status ? [status] : [])) as TaskRow[];
|
||||
}
|
||||
|
||||
function selectTask(db: Database.Database, id: string): TaskRow | undefined {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id AS row_id, series_id, status, process_after, recurrence, content, timestamp, tries, seq
|
||||
FROM messages_in
|
||||
WHERE kind = 'task'
|
||||
AND (id = ? OR series_id = ?)
|
||||
ORDER BY CASE WHEN status IN ('pending', 'paused') THEN 0 ELSE 1 END, seq DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(id, id) as TaskRow | undefined;
|
||||
}
|
||||
|
||||
function taskId(args: Record<string, unknown>): string {
|
||||
const id = str(args.id);
|
||||
if (!id) throw new Error('task series id is required');
|
||||
return id;
|
||||
}
|
||||
|
||||
function createTask(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const group = groupArg(args, ctx);
|
||||
if (!group) throw new Error('--group is required');
|
||||
const prompt = str(args.prompt);
|
||||
if (!prompt) throw new Error('--prompt is required');
|
||||
const recurrence = normalizeNullableString(args.recurrence) ?? null;
|
||||
validateRecurrence(recurrence);
|
||||
const script = normalizeNullableString(args.script) ?? null;
|
||||
enforceRecurrenceLimit(recurrence, bool(args.dangerously_override_recurrence_limit), script != null);
|
||||
const processAfter = firstRunIso(args.process_after, recurrence);
|
||||
const id = makeTaskId(args.name);
|
||||
const originSessionId = ctx.caller === 'agent' ? ctx.sessionId : null;
|
||||
// Each series runs in its own isolated session; point the fire at its own log.
|
||||
const { session } = resolveTaskSession(group, id);
|
||||
const promptWithLog =
|
||||
`${prompt}\n\n` +
|
||||
`[A task serves the user two separate ways — do whichever the task above asks for, and ALWAYS the run log:\n` +
|
||||
`• MESSAGE (only if asked): if the task says to report/notify the user, send your result with an EXPLICIT destination — <message to="name">…</message> or send_message({ to: "name", … }). This run has no chat attached: an unaddressed reply is DISCARDED, so the explicit send is the ONLY thing the user receives.\n` +
|
||||
`• RUN LOG (ALWAYS — even if you sent no message and did nothing else this run): after any sends, end the run with:\n` +
|
||||
` ncl tasks append-log --msg "<what you did, and why it mattered>"\n` +
|
||||
` Write it like a work-log entry a human keeps — concrete: what you did and WHY (a no-op run still gets a line saying why nothing was needed). If you wrote or modified files this run, name them in --msg. Not a greeting, not a copy of the message you sent. The host stamps the UTC time (do NOT add one), do NOT edit tasks/${id}.md by hand, and this NEVER goes to the user.\n` +
|
||||
`Need context from past runs? Read tasks/${id}.md first.]`;
|
||||
|
||||
const created = withInbound(session, (db) => {
|
||||
insertTaskRow(db, {
|
||||
id,
|
||||
seriesId: id,
|
||||
processAfter,
|
||||
recurrence,
|
||||
content: JSON.stringify({ prompt: promptWithLog, script, originSessionId }),
|
||||
});
|
||||
return selectTask(db, id);
|
||||
});
|
||||
if (!created) throw new Error('task system session inbound.db not found');
|
||||
return toOutput(session, created);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one host-timestamped line to a task's run log
|
||||
* (`<GROUPS_DIR>/<folder>/tasks/<series>.md`). This is NOT a delivery — it writes
|
||||
* nothing to messages_out; it just records what happened so the agent (and human)
|
||||
* can see when and why each fire ran. Inside a task fire the series is derived from
|
||||
* the caller's own task session, so the agent supplies only --msg.
|
||||
*/
|
||||
function appendTaskLog(
|
||||
args: Record<string, unknown>,
|
||||
ctx: CallerContext,
|
||||
): { series: string; timestamp: string; path: string; ok: true } {
|
||||
const msg = str(args.msg);
|
||||
if (!msg) throw new Error('--msg is required');
|
||||
|
||||
let series = str(args.id);
|
||||
let group = groupArg(args, ctx);
|
||||
if (!series && ctx.caller === 'agent' && ctx.sessionId) {
|
||||
const sess = getSession(ctx.sessionId);
|
||||
if (sess && sess.thread_id && isTaskThread(sess.thread_id)) {
|
||||
series = sess.thread_id.slice(`${TASKS_SYSTEM_THREAD_ID}:`.length);
|
||||
group ??= sess.agent_group_id;
|
||||
}
|
||||
}
|
||||
if (!series) throw new Error('--id is required (no task session to derive it from)');
|
||||
// Charset guard is the security boundary here: blocks path traversal and keeps
|
||||
// the id safe as a filename / thread suffix. Group scope is already enforced by
|
||||
// groupArg (a cli_scope=group caller can only ever resolve its own folder), so a
|
||||
// foreign id at worst writes a stray log under the caller's OWN folder — no leak.
|
||||
if (!/^[a-z0-9-]+$/.test(series)) throw new Error(`invalid task id: ${series}`);
|
||||
if (!group) throw new Error('could not resolve the agent group');
|
||||
|
||||
const ag = getAgentGroup(group);
|
||||
if (!ag) throw new Error(`agent group not found: ${group}`);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const dir = `${GROUPS_DIR}/${ag.folder}/tasks`;
|
||||
const file = `${dir}/${series}.md`;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.appendFileSync(file, `${timestamp} — ${msg}\n`);
|
||||
return { series, timestamp, path: file, ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run history for one task series, aggregated over its occurrence rows: number
|
||||
* of successful fires, the last fire time, and failed fires (a row reaches
|
||||
* `failed` after MAX_TRIES on a stuck claim). Cancelled occurrences are
|
||||
* `cancelled`, not `completed`, so they never inflate the run count.
|
||||
*/
|
||||
function seriesStats(
|
||||
db: Database.Database,
|
||||
seriesKey: string,
|
||||
): { runs: number; last_run: string | null; failed_runs: number } {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'completed') AS runs,
|
||||
MAX(process_after) FILTER (WHERE status = 'completed') AS last_run,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') AS failed_runs
|
||||
FROM messages_in
|
||||
WHERE kind = 'task' AND (id = ? OR series_id = ?)`,
|
||||
)
|
||||
.get(seriesKey, seriesKey) as { runs: number; last_run: string | null; failed_runs: number };
|
||||
}
|
||||
|
||||
/** Last ~10 lines of a series' run log (`tasks/<series>.md`), newest last. */
|
||||
function tailRunLog(agentGroupId: string, seriesKey: string, lines = 10): string[] {
|
||||
const ag = getAgentGroup(agentGroupId);
|
||||
if (!ag) return [];
|
||||
const file = `${GROUPS_DIR}/${ag.folder}/tasks/${seriesKey}.md`;
|
||||
if (!fs.existsSync(file)) return [];
|
||||
return fs.readFileSync(file, 'utf8').trimEnd().split('\n').filter(Boolean).slice(-lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* A task series is CronJob-like: the live (pending/paused) row is the next run,
|
||||
* and the `completed` rows are its run history. Enrich each listed series with
|
||||
* that history — run count, failures, last fire, next fire, schedule, and a
|
||||
* pointer to the agent's own run log — so `tasks list` reads as a compact
|
||||
* run-history table.
|
||||
*/
|
||||
function enrichListRow(db: Database.Database, base: ReturnType<typeof toOutput>) {
|
||||
const seriesKey = base.series_id;
|
||||
const stats = seriesStats(db, seriesKey);
|
||||
return {
|
||||
...base,
|
||||
schedule: base.recurrence ?? 'once',
|
||||
runs: stats.runs,
|
||||
failed_runs: stats.failed_runs,
|
||||
last_run: stats.last_run,
|
||||
next_run: base.process_after,
|
||||
log: `tasks/${seriesKey}.md`,
|
||||
};
|
||||
}
|
||||
|
||||
function listTasks(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const status = statusFilter(args);
|
||||
const rows = [];
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
const sessionRows = withInbound(session, (db) =>
|
||||
selectLiveTasks(db, status).map((row) => enrichListRow(db, toOutput(session, row))),
|
||||
);
|
||||
if (sessionRows) rows.push(...sessionRows);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getTask(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const id = taskId(args);
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
const found = withInbound(session, (db) => {
|
||||
const row = selectTask(db, id);
|
||||
if (!row) return undefined;
|
||||
const seriesKey = row.series_id ?? row.row_id;
|
||||
const stats = seriesStats(db, seriesKey);
|
||||
const content = parseContent(row.content);
|
||||
return {
|
||||
...toOutput(session, row),
|
||||
prompt: content.prompt,
|
||||
script: content.script,
|
||||
origin_session_id: content.originSessionId,
|
||||
completed_runs: stats.runs,
|
||||
failed_runs: stats.failed_runs,
|
||||
recent_log: tailRunLog(session.agent_group_id, seriesKey),
|
||||
};
|
||||
});
|
||||
if (found) return found;
|
||||
}
|
||||
throw new Error(`task not found: ${id}`);
|
||||
}
|
||||
|
||||
function mutateTask(
|
||||
args: Record<string, unknown>,
|
||||
ctx: CallerContext,
|
||||
fn: (db: Database.Database, id: string) => number,
|
||||
) {
|
||||
const id = taskId(args);
|
||||
let touched = 0;
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
touched += withInbound(session, (db) => fn(db, id)) ?? 0;
|
||||
}
|
||||
if (touched === 0) throw new Error(`no live task matched: ${id}`);
|
||||
return { series_id: id, touched };
|
||||
}
|
||||
|
||||
function updateTaskCommand(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const id = taskId(args);
|
||||
const update: TaskUpdate = {};
|
||||
if (typeof args.prompt === 'string') update.prompt = args.prompt;
|
||||
if (args.process_after !== undefined) update.processAfter = parseProcessAfter(args.process_after);
|
||||
const recurrence = normalizeNullableString(args.recurrence);
|
||||
const script = normalizeNullableString(args.script);
|
||||
if (recurrence !== undefined) {
|
||||
validateRecurrence(recurrence);
|
||||
// Effective script AFTER this update: the new value when provided
|
||||
// (including an explicit clear), else whatever the task already has.
|
||||
let scriptAfter: string | null = script !== undefined ? script : null;
|
||||
if (script === undefined) {
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
const row = withInbound(session, (db) => selectTask(db, id));
|
||||
if (row) {
|
||||
scriptAfter = parseContent(row.content).script;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
enforceRecurrenceLimit(recurrence, bool(args.dangerously_override_recurrence_limit), scriptAfter != null);
|
||||
update.recurrence = recurrence;
|
||||
}
|
||||
if (script !== undefined) update.script = script;
|
||||
const fields = Object.keys(update);
|
||||
if (fields.length === 0) throw new Error('nothing to update');
|
||||
|
||||
let touched = 0;
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
touched += withInbound(session, (db) => updateTask(db, id, update)) ?? 0;
|
||||
}
|
||||
if (touched === 0) throw new Error(`no live task matched: ${id}`);
|
||||
return { series_id: id, touched, fields };
|
||||
}
|
||||
|
||||
function cancelTaskCommand(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
if (!bool(args.all)) {
|
||||
return mutateTask(args, ctx, cancelTask);
|
||||
}
|
||||
|
||||
let touched = 0;
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
touched += withInbound(session, cancelAllTasks) ?? 0;
|
||||
}
|
||||
return { cancelled: touched };
|
||||
}
|
||||
|
||||
/**
|
||||
* `ncl tasks run <id>` — fire a task on demand without disturbing its schedule.
|
||||
* Inserts a fresh pending occurrence (same series, content, no recurrence) due
|
||||
* now, which the next sweep delivers through the normal fire path. Unlike
|
||||
* `update --process-after now`, it neither consumes a one-shot nor force-advances
|
||||
* a recurring series' armed occurrence, so it is safe for testing a task.
|
||||
*/
|
||||
function runTaskCommand(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const id = taskId(args);
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
const fired = withInbound(session, (db) => {
|
||||
const row = selectTask(db, id);
|
||||
if (!row) return undefined;
|
||||
const seriesKey = row.series_id ?? row.row_id;
|
||||
const rowId = makeTaskId(`${seriesKey}-run`);
|
||||
// recurrence=NULL is load-bearing: a run-now row must not be re-armed by
|
||||
// handleRecurrence into a phantom series.
|
||||
insertTaskRow(db, {
|
||||
id: rowId,
|
||||
seriesId: seriesKey,
|
||||
processAfter: new Date().toISOString(),
|
||||
recurrence: null,
|
||||
content: row.content,
|
||||
});
|
||||
return { series_id: seriesKey, row_id: rowId, status: 'pending' };
|
||||
});
|
||||
if (fired) return fired;
|
||||
}
|
||||
throw new Error(`task not found: ${id}`);
|
||||
}
|
||||
|
||||
registerResource({
|
||||
name: 'task',
|
||||
plural: 'tasks',
|
||||
table: 'messages_in',
|
||||
description:
|
||||
'Scheduled task — prompt plus run time. Tasks run from the agent group system session and the agent chooses delivery destination at fire time.',
|
||||
idColumn: 'series_id',
|
||||
scopeField: 'agent_group_id',
|
||||
columns: [
|
||||
{ name: 'series_id', type: 'string', description: 'Stable task handle.', generated: true },
|
||||
{ name: 'agent_group_id', type: 'string', description: 'Agent group that owns the task.' },
|
||||
{ name: 'session_id', type: 'string', description: 'System session that runs the task.' },
|
||||
{ name: 'status', type: 'string', description: 'Live state.', enum: ['pending', 'paused'] },
|
||||
{
|
||||
name: 'process_after',
|
||||
type: 'string',
|
||||
// Not flagged required: with --recurrence the first run is derived from the
|
||||
// cron grid (firstRunIso). Required only for one-shots, enforced in the
|
||||
// create handler — so the generic col.required validator must stay off here.
|
||||
description:
|
||||
'Next run time (ISO 8601 or naive local). Required for one-shots; with --recurrence the first run is derived from the cron grid.',
|
||||
updatable: true,
|
||||
},
|
||||
{ name: 'recurrence', type: 'string', description: 'Optional cron expression.', updatable: true },
|
||||
{ name: 'prompt', type: 'string', description: 'Task prompt.', required: true, updatable: true },
|
||||
{ name: 'script', type: 'string', description: 'Optional pre-task bash script.', updatable: true },
|
||||
],
|
||||
operations: {},
|
||||
customOperations: {
|
||||
list: {
|
||||
access: 'open',
|
||||
description: 'List live tasks with per-series run history (schedule, runs, failures, next fire).',
|
||||
args: [
|
||||
{ name: 'status', type: 'string', description: 'Filter by live state.', enum: ['pending', 'paused'] },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
{
|
||||
name: 'all',
|
||||
type: 'boolean',
|
||||
description: 'List across all groups (host default when no --group; accepted for explicitness).',
|
||||
},
|
||||
],
|
||||
handler: async (args, ctx) => listTasks(args, ctx),
|
||||
// Server-rendered run-history table (frame `human` field) — the container
|
||||
// agent gets the same legible view as the host CLI without a Bun-side
|
||||
// formatter copy.
|
||||
formatHuman: (rows) => formatTasksTable(rows as Parameters<typeof formatTasksTable>[0]),
|
||||
},
|
||||
get: {
|
||||
access: 'open',
|
||||
description: 'Get a task by series id.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => getTask(args, ctx),
|
||||
},
|
||||
create: {
|
||||
access: 'open',
|
||||
description:
|
||||
`Create a scheduled task (recurring or one-shot) in the agent group system session.\n\n` +
|
||||
`Requires --prompt plus EITHER --recurrence (recurring; first run derived from the cron grid) OR --process-after (one-shot, ISO 8601 or naive local). Always pass --name for a readable id.\n\n` +
|
||||
`--script contract (pre-task gate, runs BEFORE the agent wakes):\n` +
|
||||
` bash, 30s timeout, 1MB output cap. Its LAST stdout line must be JSON:\n` +
|
||||
` {"wakeAgent": <bool>, "data": {...}}\n` +
|
||||
` wakeAgent=false marks the run handled without waking the agent (zero tokens);\n` +
|
||||
` wakeAgent=true wakes the agent with data attached to the prompt.\n` +
|
||||
` DO: print the JSON as the very last line, exit 0, keep data small (a summary, not a dump).\n` +
|
||||
` DON'T: print anything after the JSON, prompt for input, or rely on state from previous runs.\n` +
|
||||
` Always test with bash -c '<script>' before scheduling.\n` +
|
||||
` Persist state between fires under the group workspace (e.g. a last-seen id file).\n` +
|
||||
` Use good judgement on whether to share with the user the script (only if they are technical), a description of the script condition, or whether there's no need.\n\n` +
|
||||
`Frequency limit: recurrences more frequent than ${MAX_DAILY_FIRES} fires/day are refused unless the task\n` +
|
||||
`carries a --script gate (the script decides whether each fire needs you — a gated fire that\n` +
|
||||
`finds nothing costs zero tokens) or you pass --dangerously-override-recurrence-limit after\n` +
|
||||
`the user explicitly confirmed they want an ungated frequent task.\n\n` +
|
||||
`Failure backoff: a script that ERRORS repeatedly backs the series off (2,4,8,…60 min between fires; each errored fire counts as a failed run); after 8 consecutive failures the series is auto-paused with a note in its run log — fix the script, then \`ncl tasks resume <id>\`. A deliberate wakeAgent=false is a normal run and never backs off. \`ncl tasks get <id>\` shows failed_runs and the run log.`,
|
||||
args: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
description: 'Short descriptive name → readable task id (<slug>-<hex>). Without it, ids are t-<hex>.',
|
||||
},
|
||||
{ name: 'prompt', type: 'string', description: 'Task prompt the agent wakes to.', required: true },
|
||||
{
|
||||
name: 'recurrence',
|
||||
type: 'string',
|
||||
description:
|
||||
'Cron expression (instance TZ). First run derives from the cron grid when --process-after is omitted.',
|
||||
},
|
||||
{
|
||||
name: 'dangerously_override_recurrence_limit',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Schedule more than 4 fires/day anyway. Only after the user explicitly confirmed they understand the quota/token cost and you agree it is right.',
|
||||
},
|
||||
{
|
||||
name: 'process_after',
|
||||
type: 'string',
|
||||
description: 'First/next run time (ISO 8601 or naive local). Required for one-shots.',
|
||||
},
|
||||
{
|
||||
name: 'script',
|
||||
type: 'string',
|
||||
description: 'Pre-task gate script (bash) — see the --script contract above.',
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
`# Recurring — --recurrence alone is enough; the first run comes off the cron grid:\nncl tasks create --name "sales briefing" --prompt "Send the weekday sales briefing" --recurrence "0 9 * * 1-5"`,
|
||||
`# One-shot — --process-after required (UTC, offset, or naive-local in the instance TZ):\nncl tasks create --name "ping" --prompt "Remind me to call Dana" --process-after "tomorrow 18:00"`,
|
||||
`# Monitor — script gates the run; the agent wakes only when something matters:\nncl tasks create --name "alert watch" --recurrence "*/15 * * * *" \\\n --prompt "Investigate the alerts in the script data and notify me if serious" \\\n --script 'c=$(curl -sf https://example.com/api/alerts | jq length) || exit 0\necho "{\\"wakeAgent\\": $([ "$c" -gt 0 ] && echo true || echo false), \\"data\\": {\\"alerts\\": $c}}"'`,
|
||||
],
|
||||
handler: async (args, ctx) => createTask(args, ctx),
|
||||
},
|
||||
'append-log': {
|
||||
access: 'open',
|
||||
description:
|
||||
'Append a one-line run summary to a task run log (tasks/<id>.md).\n\nThe host stamps the UTC timestamp; you supply --msg. This is a LOG ENTRY, not a message — it sends nothing to anyone. Inside a task fire --id is auto-derived from your session. If you wrote or modified files during the run, name them in --msg.',
|
||||
examples: [
|
||||
`# Inside a task fire (--id auto-derived) — the run's work-log line:\nncl tasks append-log --msg "posted the daily digest to slack; one feed returned 403, skipped"`,
|
||||
],
|
||||
args: [
|
||||
{
|
||||
name: 'msg',
|
||||
type: 'string',
|
||||
description:
|
||||
'Your work-log entry: what you did and why it mattered (like a human work log). The host prepends the UTC timestamp; this is logged, never sent to the user.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
description: 'Task series id. Auto-derived when called from inside a task fire; required otherwise.',
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
],
|
||||
handler: async (args, ctx) => appendTaskLog(args, ctx),
|
||||
},
|
||||
update: {
|
||||
access: 'open',
|
||||
description: 'Update a live task by series id.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{ name: 'prompt', type: 'string', description: 'Replace the task prompt.' },
|
||||
{ name: 'process_after', type: 'string', description: 'New next-run time (ISO 8601 or naive local).' },
|
||||
{ name: 'recurrence', type: 'string', description: 'New cron expression; "null"/"none" clears it (one-shot).' },
|
||||
{
|
||||
name: 'dangerously_override_recurrence_limit',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Schedule more than 4 fires/day anyway. Only after the user explicitly confirmed they understand the quota/token cost and you agree it is right.',
|
||||
},
|
||||
{ name: 'script', type: 'string', description: 'New pre-task script; "null"/"none" removes it.' },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => updateTaskCommand(args, ctx),
|
||||
},
|
||||
cancel: {
|
||||
access: 'open',
|
||||
description: 'Cancel a live task by series id, or use --all as a kill switch.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id (omit with --all).' },
|
||||
{ name: 'all', type: 'boolean', description: 'Cancel every live task in scope — kill switch.' },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => cancelTaskCommand(args, ctx),
|
||||
},
|
||||
run: {
|
||||
access: 'open',
|
||||
description:
|
||||
'Fire a task now without changing its schedule (queues an extra run due immediately). Safe for testing — unlike update --process-after now, it neither consumes a one-shot nor advances a recurring series.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => runTaskCommand(args, ctx),
|
||||
},
|
||||
pause: {
|
||||
access: 'open',
|
||||
description: 'Pause a pending task by series id.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => mutateTask(args, ctx, pauseTask),
|
||||
},
|
||||
resume: {
|
||||
access: 'open',
|
||||
description: 'Resume a paused task by series id.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => mutateTask(args, ctx, resumeTask),
|
||||
},
|
||||
delete: {
|
||||
access: 'open',
|
||||
description: 'Hard-delete a task series and its history.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => mutateTask(args, ctx, deleteTask),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
import { getInboundSourceSessionId, migrateMessagesInTable } from './session-db.js';
|
||||
import { ensureSchema, getInboundSourceSessionId, migrateMessagesInTable, syncProcessingAcks } from './session-db.js';
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-session-db-test';
|
||||
const DB_PATH = path.join(TEST_DIR, 'inbound.db');
|
||||
@@ -92,3 +92,66 @@ describe('migrateMessagesInTable', () => {
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncProcessingAcks — script-skip counter', () => {
|
||||
function freshPair() {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
ensureSchema(DB_PATH, 'inbound');
|
||||
const outPath = path.join(TEST_DIR, 'outbound.db');
|
||||
ensureSchema(outPath, 'outbound');
|
||||
return { inDb: new Database(DB_PATH), outDb: new Database(outPath) };
|
||||
}
|
||||
|
||||
function seedTask(inDb: InstanceType<typeof Database>, id: string, content: Record<string, unknown>) {
|
||||
inDb
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, seq, timestamp, status, tries, kind, content, series_id)
|
||||
VALUES (?, 2, datetime('now'), 'processing', 0, 'task', ?, ?)`,
|
||||
)
|
||||
.run(id, JSON.stringify(content), id);
|
||||
}
|
||||
|
||||
function ack(outDb: InstanceType<typeof Database>, id: string, status: string) {
|
||||
outDb
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, ?, datetime('now'))",
|
||||
)
|
||||
.run(id, status);
|
||||
}
|
||||
|
||||
const status = (inDb: InstanceType<typeof Database>, id: string) =>
|
||||
(inDb.prepare('SELECT status FROM messages_in WHERE id = ?').get(id) as { status: string }).status;
|
||||
|
||||
it('script-skip:error ack lands the row as a FAILED run (streak-derivable history)', () => {
|
||||
const { inDb, outDb } = freshPair();
|
||||
seedTask(inDb, 't1', { prompt: 'p', script: 'x' });
|
||||
ack(outDb, 't1', 'script-skip:error');
|
||||
|
||||
syncProcessingAcks(inDb, outDb);
|
||||
|
||||
expect(status(inDb, 't1')).toBe('failed');
|
||||
});
|
||||
|
||||
it('a settled row is terminal — a lingering ack cannot flip failed back to completed', () => {
|
||||
const { inDb, outDb } = freshPair();
|
||||
seedTask(inDb, 't1', { prompt: 'p', script: 'x' });
|
||||
ack(outDb, 't1', 'script-skip:error');
|
||||
syncProcessingAcks(inDb, outDb);
|
||||
|
||||
ack(outDb, 't1', 'completed');
|
||||
syncProcessingAcks(inDb, outDb);
|
||||
|
||||
expect(status(inDb, 't1')).toBe('failed');
|
||||
});
|
||||
|
||||
it('plain completed ack completes the row as before', () => {
|
||||
const { inDb, outDb } = freshPair();
|
||||
seedTask(inDb, 't1', { prompt: 'p', script: 'x' });
|
||||
ack(outDb, 't1', 'completed');
|
||||
|
||||
syncProcessingAcks(inDb, outDb);
|
||||
|
||||
expect(status(inDb, 't1')).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
+20
-8
@@ -168,15 +168,25 @@ export function getMessageForRetry(
|
||||
|
||||
export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): void {
|
||||
const completed = outDb
|
||||
.prepare("SELECT message_id FROM processing_ack WHERE status IN ('completed', 'failed')")
|
||||
.all() as Array<{ message_id: string }>;
|
||||
.prepare(
|
||||
"SELECT message_id, status FROM processing_ack WHERE status IN ('completed', 'failed', 'script-skip:error')",
|
||||
)
|
||||
.all() as Array<{ message_id: string; status: string }>;
|
||||
|
||||
if (completed.length === 0) return;
|
||||
|
||||
const updateStmt = inDb.prepare("UPDATE messages_in SET status = 'completed' WHERE id = ? AND status != 'completed'");
|
||||
// `script-skip:error` (pre-task script crashed) lands as a FAILED run —
|
||||
// semantically true, and it lets recurrence derive the trailing failed
|
||||
// streak from the occurrence rows themselves (no stored counter).
|
||||
const completeStmt = inDb.prepare(
|
||||
"UPDATE messages_in SET status = 'completed' WHERE id = ? AND status NOT IN ('completed', 'failed')",
|
||||
);
|
||||
const failStmt = inDb.prepare(
|
||||
"UPDATE messages_in SET status = 'failed' WHERE id = ? AND status NOT IN ('completed', 'failed')",
|
||||
);
|
||||
inDb.transaction(() => {
|
||||
for (const { message_id } of completed) {
|
||||
updateStmt.run(message_id);
|
||||
for (const { message_id, status } of completed) {
|
||||
(status === 'script-skip:error' ? failStmt : completeStmt).run(message_id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -294,9 +304,11 @@ export function migrateDeliveredTable(db: Database.Database): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Adds columns added to messages_in after the initial v2 schema to
|
||||
// pre-existing session DBs. No-op on fresh installs where the columns are
|
||||
// in the baseline schema. Backfills existing rows so invariants hold.
|
||||
// LEGACY-COMPAT(v1-tasks): adds columns added to messages_in after the initial
|
||||
// v2 schema to pre-existing session DBs — this lazy, on-open migration IS the
|
||||
// upgrade path for old installs (there is no central migration for session
|
||||
// DBs). No-op on fresh installs where the columns are in the baseline schema.
|
||||
// Backfills existing rows so invariants hold (series_id = id).
|
||||
export function migrateMessagesInTable(db: Database.Database): void {
|
||||
const cols = new Set(
|
||||
(db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name),
|
||||
|
||||
+48
-1
@@ -3,6 +3,8 @@ import { getDb, hasTable } from './connection.js';
|
||||
|
||||
// ── Sessions ──
|
||||
|
||||
export const TASKS_SYSTEM_THREAD_ID = 'system:tasks';
|
||||
|
||||
export function createSession(session: Session): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
@@ -55,7 +57,14 @@ export function findSessionForAgent(
|
||||
/** Find an active session scoped to an agent group (ignoring messaging group). */
|
||||
export function findSessionByAgentGroup(agentGroupId: string): Session | undefined {
|
||||
return getDb()
|
||||
.prepare("SELECT * FROM sessions WHERE agent_group_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1")
|
||||
.prepare(
|
||||
`SELECT * FROM sessions
|
||||
WHERE agent_group_id = ?
|
||||
AND status = 'active'
|
||||
AND NOT (messaging_group_id IS NULL AND thread_id IS NOT NULL AND thread_id LIKE 'system:%')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(agentGroupId) as Session | undefined;
|
||||
}
|
||||
|
||||
@@ -63,6 +72,44 @@ export function getSessionsByAgentGroup(agentGroupId: string): Session[] {
|
||||
return getDb().prepare('SELECT * FROM sessions WHERE agent_group_id = ?').all(agentGroupId) as Session[];
|
||||
}
|
||||
|
||||
export function findSystemSession(agentGroupId: string, threadId: string): Session | undefined {
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT * FROM sessions
|
||||
WHERE agent_group_id = ?
|
||||
AND messaging_group_id IS NULL
|
||||
AND thread_id = ?
|
||||
AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(agentGroupId, threadId) as Session | undefined;
|
||||
}
|
||||
|
||||
/** Per-task session thread id for a scheduled task series. */
|
||||
export function taskThreadId(seriesId: string): string {
|
||||
return `${TASKS_SYSTEM_THREAD_ID}:${seriesId}`;
|
||||
}
|
||||
|
||||
/** True for any task session thread — a per-series one or the legacy shared one. */
|
||||
export function isTaskThread(threadId: string | null): boolean {
|
||||
return threadId === TASKS_SYSTEM_THREAD_ID || (threadId?.startsWith(`${TASKS_SYSTEM_THREAD_ID}:`) ?? false);
|
||||
}
|
||||
|
||||
/** All active task sessions for a group — one per live series, plus any legacy shared one. */
|
||||
export function findTaskSessions(agentGroupId: string): Session[] {
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT * FROM sessions
|
||||
WHERE agent_group_id = ?
|
||||
AND messaging_group_id IS NULL
|
||||
AND status = 'active'
|
||||
AND (thread_id = ? OR thread_id LIKE ?)
|
||||
ORDER BY created_at DESC`,
|
||||
)
|
||||
.all(agentGroupId, TASKS_SYSTEM_THREAD_ID, `${TASKS_SYSTEM_THREAD_ID}:%`) as Session[];
|
||||
}
|
||||
|
||||
export function getActiveSessions(): Session[] {
|
||||
return getDb().prepare("SELECT * FROM sessions WHERE status = 'active'").all() as Session[];
|
||||
}
|
||||
|
||||
+1
-1
@@ -254,7 +254,7 @@ async function deliverMessage(
|
||||
|
||||
const content = JSON.parse(msg.content);
|
||||
|
||||
// System actions — handle internally (schedule_task, cancel_task, etc.)
|
||||
// System actions — handle internally (cli_request, etc.)
|
||||
if (msg.kind === 'system') {
|
||||
await handleSystemAction(content, session, inDb);
|
||||
return;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
_resetStuckProcessingRowsForTesting,
|
||||
decideStuckAction,
|
||||
parseSqliteUtc,
|
||||
shouldCloseTaskSession,
|
||||
} from './host-sweep.js';
|
||||
import type { Session } from './types.js';
|
||||
|
||||
@@ -334,3 +335,22 @@ describe('parseSqliteUtc', () => {
|
||||
expect(parseSqliteUtc(bare)).toBe(Date.parse(bare + 'Z'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldCloseTaskSession', () => {
|
||||
it('closes a spent per-task session (no live tasks, no container)', () => {
|
||||
expect(shouldCloseTaskSession('system:tasks:task-1', false, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps it while a task is still live (recurring re-armed, or pending/paused)', () => {
|
||||
expect(shouldCloseTaskSession('system:tasks:task-1', false, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps it while its container is running (mid-fire)', () => {
|
||||
expect(shouldCloseTaskSession('system:tasks:task-1', true, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('never touches non-task sessions', () => {
|
||||
expect(shouldCloseTaskSession('telegram:12345', false, 0)).toBe(false);
|
||||
expect(shouldCloseTaskSession(null, false, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+28
-1
@@ -30,7 +30,7 @@ import type Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
|
||||
import { ensureEgressNetwork } from './egress-lockdown.js';
|
||||
import { getActiveSessions } from './db/sessions.js';
|
||||
import { getActiveSessions, isTaskThread, updateSession } from './db/sessions.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import {
|
||||
countDueMessages,
|
||||
@@ -167,6 +167,15 @@ async function sweep(): Promise<void> {
|
||||
setTimeout(sweep, SWEEP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** A per-task session with no live tasks and no running container is spent → close it. */
|
||||
export function shouldCloseTaskSession(
|
||||
threadId: string | null,
|
||||
containerRunning: boolean,
|
||||
liveTaskCount: number,
|
||||
): boolean {
|
||||
return isTaskThread(threadId) && !containerRunning && liveTaskCount === 0;
|
||||
}
|
||||
|
||||
async function sweepSession(session: Session): Promise<void> {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) return;
|
||||
@@ -234,6 +243,24 @@ async function sweepSession(session: Session): Promise<void> {
|
||||
const { handleRecurrence } = await import('./modules/scheduling/recurrence.js');
|
||||
await handleRecurrence(inDb, session);
|
||||
// MODULE-HOOK:scheduling-recurrence:end
|
||||
|
||||
// 6. GC spent task sessions. An isolated per-task session with no live task
|
||||
// rows left (one-shot fired, or all cancelled/deleted) and no container
|
||||
// running is dead — close it so it stops being swept and listed. Runs after
|
||||
// recurrence so a just-fired recurring series has already re-armed its next
|
||||
// pending row and is never collected. The per-task log file in the workspace
|
||||
// is the durable history and survives the close.
|
||||
if (isTaskThread(session.thread_id)) {
|
||||
const liveTasks = (
|
||||
inDb
|
||||
.prepare("SELECT COUNT(*) AS c FROM messages_in WHERE kind = 'task' AND status IN ('pending', 'paused')")
|
||||
.get() as { c: number }
|
||||
).c;
|
||||
if (shouldCloseTaskSession(session.thread_id, isContainerRunning(session.id), liveTasks)) {
|
||||
updateSession(session.id, { status: 'closed' });
|
||||
log.info('Closed spent task session', { sessionId: session.id, threadId: session.thread_id });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
inDb.close();
|
||||
outDb?.close();
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
// registers its handlers at import time.
|
||||
import './approvals/index.js';
|
||||
import './interactive/index.js';
|
||||
import './scheduling/index.js';
|
||||
import './permissions/index.js';
|
||||
import './agent-to-agent/index.js';
|
||||
import './self-mod/index.js';
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Delivery action handlers for scheduling.
|
||||
*
|
||||
* The container can't write to inbound.db (host-owned). When the agent calls
|
||||
* schedule_task / cancel_task / etc. via MCP, the container writes a
|
||||
* `kind='system'` outbound message with an `action` field. The delivery path
|
||||
* reaches into this module via the delivery-action registry and we apply the
|
||||
* change to inbound.db here.
|
||||
*/
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { cancelTask, insertTask, pauseTask, resumeTask, updateTask, type TaskUpdate } from './db.js';
|
||||
|
||||
export async function handleScheduleTask(
|
||||
content: Record<string, unknown>,
|
||||
_session: Session,
|
||||
inDb: Database.Database,
|
||||
): Promise<void> {
|
||||
const taskId = content.taskId as string;
|
||||
const prompt = content.prompt as string;
|
||||
const script = content.script as string | null;
|
||||
const processAfter = content.processAfter as string;
|
||||
const recurrence = (content.recurrence as string) || null;
|
||||
|
||||
insertTask(inDb, {
|
||||
id: taskId,
|
||||
processAfter,
|
||||
recurrence,
|
||||
platformId: (content.platformId as string) ?? null,
|
||||
channelType: (content.channelType as string) ?? null,
|
||||
threadId: (content.threadId as string) ?? null,
|
||||
content: JSON.stringify({ prompt, script }),
|
||||
});
|
||||
log.info('Scheduled task created', { taskId, processAfter, recurrence });
|
||||
}
|
||||
|
||||
export async function handleCancelTask(
|
||||
content: Record<string, unknown>,
|
||||
_session: Session,
|
||||
inDb: Database.Database,
|
||||
): Promise<void> {
|
||||
const taskId = content.taskId as string;
|
||||
cancelTask(inDb, taskId);
|
||||
log.info('Task cancelled', { taskId });
|
||||
}
|
||||
|
||||
export async function handlePauseTask(
|
||||
content: Record<string, unknown>,
|
||||
_session: Session,
|
||||
inDb: Database.Database,
|
||||
): Promise<void> {
|
||||
const taskId = content.taskId as string;
|
||||
pauseTask(inDb, taskId);
|
||||
log.info('Task paused', { taskId });
|
||||
}
|
||||
|
||||
export async function handleResumeTask(
|
||||
content: Record<string, unknown>,
|
||||
_session: Session,
|
||||
inDb: Database.Database,
|
||||
): Promise<void> {
|
||||
const taskId = content.taskId as string;
|
||||
resumeTask(inDb, taskId);
|
||||
log.info('Task resumed', { taskId });
|
||||
}
|
||||
|
||||
export async function handleUpdateTask(
|
||||
content: Record<string, unknown>,
|
||||
session: Session,
|
||||
inDb: Database.Database,
|
||||
): Promise<void> {
|
||||
const taskId = content.taskId as string;
|
||||
const update: TaskUpdate = {};
|
||||
if (typeof content.prompt === 'string') update.prompt = content.prompt;
|
||||
if (typeof content.processAfter === 'string') update.processAfter = content.processAfter;
|
||||
if (content.recurrence === null || typeof content.recurrence === 'string') {
|
||||
update.recurrence = content.recurrence as string | null;
|
||||
}
|
||||
if (content.script === null || typeof content.script === 'string') {
|
||||
update.script = content.script as string | null;
|
||||
}
|
||||
const touched = updateTask(inDb, taskId, update);
|
||||
log.info('Task updated', { taskId, touched, fields: Object.keys(update) });
|
||||
if (touched === 0) {
|
||||
// Notify the agent that update_task matched nothing. Replicates the
|
||||
// old notifyAgent helper that used to live in delivery.ts — inlined
|
||||
// here so scheduling doesn't depend on delivery's private helpers.
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: JSON.stringify({
|
||||
text: `update_task: no live task matched id "${taskId}".`,
|
||||
sender: 'system',
|
||||
senderId: 'system',
|
||||
}),
|
||||
});
|
||||
const fresh = getSession(session.id);
|
||||
if (fresh) {
|
||||
wakeContainer(fresh).catch((err) =>
|
||||
log.error('Failed to wake container after update_task notification', { err }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
import { ensureSchema, openInboundDb } from '../../db/session-db.js';
|
||||
import {
|
||||
insertTask,
|
||||
insertTaskRow,
|
||||
insertRecurrence,
|
||||
cancelTask,
|
||||
pauseTask,
|
||||
@@ -31,13 +31,11 @@ function freshDb() {
|
||||
}
|
||||
|
||||
function insertBasicTask(db: ReturnType<typeof openInboundDb>, id: string, recurrence: string | null) {
|
||||
insertTask(db, {
|
||||
insertTaskRow(db, {
|
||||
id,
|
||||
seriesId: id,
|
||||
processAfter: new Date().toISOString(),
|
||||
recurrence,
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'noop' }),
|
||||
});
|
||||
}
|
||||
@@ -46,7 +44,7 @@ afterEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
describe('insertTask', () => {
|
||||
describe('insertTaskRow', () => {
|
||||
it('stamps series_id = id on insert', () => {
|
||||
const db = freshDb();
|
||||
insertBasicTask(db, 'task-1', null);
|
||||
@@ -68,13 +66,8 @@ describe('cancelTask / pauseTask / resumeTask series matching', () => {
|
||||
|
||||
const msg: RecurringMessage = {
|
||||
id: 'task-orig',
|
||||
kind: 'task',
|
||||
content: JSON.stringify({ prompt: 'noop' }),
|
||||
recurrence: '0 9 * * *',
|
||||
process_after: null,
|
||||
platform_id: null,
|
||||
channel_type: null,
|
||||
thread_id: null,
|
||||
series_id: 'task-orig',
|
||||
};
|
||||
insertRecurrence(db, msg, 'task-next', new Date(Date.now() + 86400000).toISOString());
|
||||
@@ -93,7 +86,8 @@ describe('cancelTask / pauseTask / resumeTask series matching', () => {
|
||||
status: string;
|
||||
recurrence: string | null;
|
||||
};
|
||||
expect(followUp.status).toBe('completed');
|
||||
// Cancel marks 'cancelled' (not 'completed') so it never counts as a run.
|
||||
expect(followUp.status).toBe('cancelled');
|
||||
// Recurrence cleared so the sweep doesn't spawn another clone.
|
||||
expect(followUp.recurrence).toBeNull();
|
||||
db.close();
|
||||
@@ -131,18 +125,37 @@ describe('cancelTask / pauseTask / resumeTask series matching', () => {
|
||||
expect(followUp.status).toBe('pending');
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('pause/resume touch ONLY status — recurrence and process_after survive the cycle', () => {
|
||||
const db = freshDb();
|
||||
seedRecurringChain(db);
|
||||
const before = db.prepare("SELECT recurrence, process_after FROM messages_in WHERE id = 'task-next'").get() as {
|
||||
recurrence: string | null;
|
||||
process_after: string | null;
|
||||
};
|
||||
|
||||
pauseTask(db, 'task-next');
|
||||
resumeTask(db, 'task-next');
|
||||
|
||||
const after = db.prepare("SELECT recurrence, process_after FROM messages_in WHERE id = 'task-next'").get() as {
|
||||
recurrence: string | null;
|
||||
process_after: string | null;
|
||||
};
|
||||
// A cancel-style copy-paste (clearing recurrence) would kill the series here.
|
||||
expect(after.recurrence).toBe(before.recurrence);
|
||||
expect(after.process_after).toBe(before.process_after);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTask', () => {
|
||||
it('merges supplied fields into content JSON without clobbering others', () => {
|
||||
const db = freshDb();
|
||||
insertTask(db, {
|
||||
insertTaskRow(db, {
|
||||
id: 'task-1',
|
||||
seriesId: 'task-1',
|
||||
processAfter: new Date().toISOString(),
|
||||
recurrence: null,
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'old', script: 'echo old', extra: 'keep me' }),
|
||||
});
|
||||
|
||||
@@ -158,13 +171,11 @@ describe('updateTask', () => {
|
||||
|
||||
it('updates recurrence and process_after when supplied', () => {
|
||||
const db = freshDb();
|
||||
insertTask(db, {
|
||||
insertTaskRow(db, {
|
||||
id: 'task-1',
|
||||
seriesId: 'task-1',
|
||||
processAfter: '2026-01-01T00:00:00Z',
|
||||
recurrence: '0 9 * * *',
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'p' }),
|
||||
});
|
||||
|
||||
@@ -180,13 +191,11 @@ describe('updateTask', () => {
|
||||
|
||||
it('clears recurrence when null is passed', () => {
|
||||
const db = freshDb();
|
||||
insertTask(db, {
|
||||
insertTaskRow(db, {
|
||||
id: 'task-1',
|
||||
seriesId: 'task-1',
|
||||
processAfter: '2026-01-01T00:00:00Z',
|
||||
recurrence: '0 9 * * *',
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'p' }),
|
||||
});
|
||||
|
||||
@@ -200,26 +209,19 @@ describe('updateTask', () => {
|
||||
|
||||
it('reaches the live follow-up via series_id when called with the original id', () => {
|
||||
const db = freshDb();
|
||||
insertTask(db, {
|
||||
insertTaskRow(db, {
|
||||
id: 'task-orig',
|
||||
seriesId: 'task-orig',
|
||||
processAfter: new Date().toISOString(),
|
||||
recurrence: '0 9 * * *',
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'old' }),
|
||||
});
|
||||
db.prepare("UPDATE messages_in SET status = 'completed' WHERE id = 'task-orig'").run();
|
||||
|
||||
const msg: RecurringMessage = {
|
||||
id: 'task-orig',
|
||||
kind: 'task',
|
||||
content: JSON.stringify({ prompt: 'old' }),
|
||||
recurrence: '0 9 * * *',
|
||||
process_after: null,
|
||||
platform_id: null,
|
||||
channel_type: null,
|
||||
thread_id: null,
|
||||
series_id: 'task-orig',
|
||||
};
|
||||
insertRecurrence(db, msg, 'task-next', new Date(Date.now() + 86400000).toISOString());
|
||||
@@ -238,13 +240,11 @@ describe('updateTask', () => {
|
||||
|
||||
it('returns 0 when no live task matches', () => {
|
||||
const db = freshDb();
|
||||
insertTask(db, {
|
||||
insertTaskRow(db, {
|
||||
id: 'task-1',
|
||||
seriesId: 'task-1',
|
||||
processAfter: new Date().toISOString(),
|
||||
recurrence: null,
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'p' }),
|
||||
});
|
||||
db.prepare("UPDATE messages_in SET status = 'completed' WHERE id = 'task-1'").run();
|
||||
@@ -262,13 +262,8 @@ describe('insertRecurrence', () => {
|
||||
|
||||
const msg: RecurringMessage = {
|
||||
id: 'task-orig',
|
||||
kind: 'task',
|
||||
content: '{}',
|
||||
recurrence: '0 9 * * *',
|
||||
process_after: null,
|
||||
platform_id: null,
|
||||
channel_type: null,
|
||||
thread_id: null,
|
||||
series_id: 'task-orig',
|
||||
};
|
||||
insertRecurrence(db, msg, 'task-next', new Date().toISOString());
|
||||
|
||||
@@ -14,43 +14,71 @@ import type Database from 'better-sqlite3';
|
||||
|
||||
import { nextEvenSeq } from '../../db/session-db.js';
|
||||
|
||||
export function insertTask(
|
||||
/**
|
||||
* Insert one pending task occurrence. `seriesId` is the series join key — equal
|
||||
* to `id` for a brand-new series, or the existing series for a recurrence clone
|
||||
* or an on-demand run. Tasks never set platform/channel/thread (they fire into
|
||||
* an isolated system session), so those columns are always NULL.
|
||||
*/
|
||||
export function insertTaskRow(
|
||||
db: Database.Database,
|
||||
task: {
|
||||
row: {
|
||||
id: string;
|
||||
processAfter: string;
|
||||
seriesId: string;
|
||||
processAfter: string | null;
|
||||
recurrence: string | null;
|
||||
platformId: string | null;
|
||||
channelType: string | null;
|
||||
threadId: string | null;
|
||||
content: string;
|
||||
status?: 'pending' | 'paused';
|
||||
},
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, seq, timestamp, status, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content, series_id)
|
||||
VALUES (@id, @seq, datetime('now'), 'pending', 0, @processAfter, @recurrence, 'task', @platformId, @channelType, @threadId, @content, @id)`,
|
||||
VALUES (@id, @seq, datetime('now'), @status, 0, @processAfter, @recurrence, 'task', NULL, NULL, NULL, @content, @seriesId)`,
|
||||
).run({
|
||||
...task,
|
||||
status: 'pending',
|
||||
...row,
|
||||
seq: nextEvenSeq(db),
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelTask(db: Database.Database, taskId: string): void {
|
||||
db.prepare(
|
||||
"UPDATE messages_in SET status = 'completed', recurrence = NULL WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status IN ('pending', 'paused')",
|
||||
).run(taskId, taskId);
|
||||
// Cancel marks the live row 'cancelled' (not 'completed') so a never-fired
|
||||
// occurrence is distinguishable from a real run and never inflates run history;
|
||||
// recurrence is cleared so the series isn't re-armed by handleRecurrence.
|
||||
export function cancelTask(db: Database.Database, taskId: string): number {
|
||||
return db
|
||||
.prepare(
|
||||
"UPDATE messages_in SET status = 'cancelled', recurrence = NULL WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status IN ('pending', 'paused')",
|
||||
)
|
||||
.run(taskId, taskId).changes;
|
||||
}
|
||||
|
||||
export function pauseTask(db: Database.Database, taskId: string): void {
|
||||
db.prepare(
|
||||
"UPDATE messages_in SET status = 'paused' WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status = 'pending'",
|
||||
).run(taskId, taskId);
|
||||
export function cancelAllTasks(db: Database.Database): number {
|
||||
return db
|
||||
.prepare(
|
||||
"UPDATE messages_in SET status = 'cancelled', recurrence = NULL WHERE kind = 'task' AND status IN ('pending', 'paused')",
|
||||
)
|
||||
.run().changes;
|
||||
}
|
||||
|
||||
export function resumeTask(db: Database.Database, taskId: string): void {
|
||||
db.prepare(
|
||||
"UPDATE messages_in SET status = 'pending' WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status = 'paused'",
|
||||
).run(taskId, taskId);
|
||||
export function pauseTask(db: Database.Database, taskId: string): number {
|
||||
return db
|
||||
.prepare(
|
||||
"UPDATE messages_in SET status = 'paused' WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status = 'pending'",
|
||||
)
|
||||
.run(taskId, taskId).changes;
|
||||
}
|
||||
|
||||
export function resumeTask(db: Database.Database, taskId: string): number {
|
||||
return db
|
||||
.prepare(
|
||||
"UPDATE messages_in SET status = 'pending' WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status = 'paused'",
|
||||
)
|
||||
.run(taskId, taskId).changes;
|
||||
}
|
||||
|
||||
export function deleteTask(db: Database.Database, taskId: string): number {
|
||||
return db.prepare("DELETE FROM messages_in WHERE (id = ? OR series_id = ?) AND kind = 'task'").run(taskId, taskId)
|
||||
.changes;
|
||||
}
|
||||
|
||||
export interface TaskUpdate {
|
||||
@@ -107,45 +135,64 @@ export function updateTask(db: Database.Database, taskId: string, update: TaskUp
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
// Only tasks carry a recurrence (non-task writeSessionMessage never sets one),
|
||||
// so getCompletedRecurring only ever returns task rows — the fields below are
|
||||
// all that handleRecurrence needs to clone the next occurrence.
|
||||
export interface RecurringMessage {
|
||||
id: string;
|
||||
kind: string;
|
||||
content: string;
|
||||
recurrence: string;
|
||||
process_after: string | null;
|
||||
platform_id: string | null;
|
||||
channel_type: string | null;
|
||||
thread_id: string | null;
|
||||
series_id: string;
|
||||
}
|
||||
|
||||
// Failed occurrences (script-skip:error runs) re-arm too — a broken monitor
|
||||
// must keep its series alive so backoff can throttle it and the cap can pause
|
||||
// it; dropping the row would silently kill the series on first script error.
|
||||
export function getCompletedRecurring(db: Database.Database): RecurringMessage[] {
|
||||
return db
|
||||
.prepare("SELECT * FROM messages_in WHERE status = 'completed' AND recurrence IS NOT NULL")
|
||||
.prepare("SELECT * FROM messages_in WHERE status IN ('completed', 'failed') AND recurrence IS NOT NULL")
|
||||
.all() as RecurringMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Trailing consecutive FAILED occurrences of a series, newest backwards until
|
||||
* the first completed run. This IS the script-failure streak — derived from
|
||||
* the occurrence history, no stored counter to update or reset. Deliberately
|
||||
* counts ANY failed occurrence (script-skip:error acks AND stuck-message
|
||||
* failures from host-sweep's MAX_TRIES path): a series failing for either
|
||||
* reason should throttle, not spin.
|
||||
*/
|
||||
export function trailingFailedRuns(db: Database.Database, seriesKey: string): number {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT status FROM messages_in
|
||||
WHERE (series_id = ? OR id = ?) AND kind = 'task' AND status IN ('completed', 'failed')
|
||||
ORDER BY seq DESC`,
|
||||
)
|
||||
.all(seriesKey, seriesKey) as Array<{ status: string }>;
|
||||
let streak = 0;
|
||||
for (const r of rows) {
|
||||
if (r.status !== 'failed') break;
|
||||
streak++;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
export function insertRecurrence(
|
||||
db: Database.Database,
|
||||
msg: RecurringMessage,
|
||||
newId: string,
|
||||
nextRun: string | null,
|
||||
status: 'pending' | 'paused' = 'pending',
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content, series_id)
|
||||
VALUES (?, ?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
newId,
|
||||
nextEvenSeq(db),
|
||||
msg.kind,
|
||||
nextRun,
|
||||
msg.recurrence,
|
||||
msg.platform_id,
|
||||
msg.channel_type,
|
||||
msg.thread_id,
|
||||
msg.content,
|
||||
msg.series_id,
|
||||
);
|
||||
insertTaskRow(db, {
|
||||
id: newId,
|
||||
seriesId: msg.series_id,
|
||||
processAfter: nextRun,
|
||||
recurrence: msg.recurrence,
|
||||
content: msg.content,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearRecurrence(db: Database.Database, messageId: string): void {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Scheduling module — one-shot and recurring tasks.
|
||||
*
|
||||
* Registers:
|
||||
* - Five delivery action handlers: schedule_task, cancel_task, pause_task,
|
||||
* resume_task, update_task. The container's scheduling MCP tools
|
||||
* (container/agent-runner/src/mcp-tools/scheduling.ts) write system
|
||||
* messages with these actions; the host applies them to inbound.db.
|
||||
*
|
||||
* Host integration points (filled by MODULE-HOOK markers, validated here
|
||||
* with the scheduling module shipping inline):
|
||||
* - `src/host-sweep.ts` → MODULE-HOOK:scheduling-recurrence calls
|
||||
* `handleRecurrence` each sweep tick.
|
||||
* - `container/agent-runner/src/poll-loop.ts` → MODULE-HOOK:scheduling-pre-task
|
||||
* runs `applyPreTaskScripts` before the provider call so tasks carrying
|
||||
* a pre-agent script can gate their own execution.
|
||||
*
|
||||
* No DB migration — tasks are `messages_in` rows with `kind='task'`, so the
|
||||
* module piggybacks on the core schema.
|
||||
*/
|
||||
import { registerDeliveryAction } from '../../delivery.js';
|
||||
import {
|
||||
handleCancelTask,
|
||||
handlePauseTask,
|
||||
handleResumeTask,
|
||||
handleScheduleTask,
|
||||
handleUpdateTask,
|
||||
} from './actions.js';
|
||||
|
||||
registerDeliveryAction('schedule_task', handleScheduleTask);
|
||||
registerDeliveryAction('cancel_task', handleCancelTask);
|
||||
registerDeliveryAction('pause_task', handlePauseTask);
|
||||
registerDeliveryAction('resume_task', handleResumeTask);
|
||||
registerDeliveryAction('update_task', handleUpdateTask);
|
||||
@@ -8,13 +8,20 @@
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ensureSchema, openInboundDb } from '../../db/session-db.js';
|
||||
import { insertTask } from './db.js';
|
||||
import { handleRecurrence } from './recurrence.js';
|
||||
import { insertTaskRow } from './db.js';
|
||||
import { handleRecurrence, scriptBackoffMinutes } from './recurrence.js';
|
||||
import type { Session } from '../../types.js';
|
||||
|
||||
// Pin a non-UTC zone so the tz-interpretation test is exact even on UTC CI.
|
||||
// Asia/Tokyo is UTC+9 with no DST: "0 9 * * *" must land at 00:00:00Z sharp.
|
||||
vi.mock('../../config.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../config.js')>();
|
||||
return { ...actual, TIMEZONE: 'Asia/Tokyo' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-recurrence-test';
|
||||
const DB_PATH = path.join(TEST_DIR, 'inbound.db');
|
||||
|
||||
@@ -45,13 +52,11 @@ afterEach(() => {
|
||||
describe('handleRecurrence', () => {
|
||||
it('clones a completed recurring task with a next-run in the future', async () => {
|
||||
const db = freshDb();
|
||||
insertTask(db, {
|
||||
insertTaskRow(db, {
|
||||
id: 'task-1',
|
||||
seriesId: 'task-1',
|
||||
processAfter: '2020-01-01T00:00:00.000Z',
|
||||
recurrence: '0 9 * * *', // every day at 09:00 (user TZ)
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'daily digest' }),
|
||||
});
|
||||
db.prepare(`UPDATE messages_in SET status='completed' WHERE id='task-1'`).run();
|
||||
@@ -77,15 +82,34 @@ describe('handleRecurrence', () => {
|
||||
expect(new Date(follow.process_after).getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('interprets the cron expression in TIMEZONE, not UTC (the v1 regression)', async () => {
|
||||
const db = freshDb();
|
||||
insertTaskRow(db, {
|
||||
id: 'task-tz',
|
||||
seriesId: 'task-tz',
|
||||
processAfter: '2020-01-01T00:00:00.000Z',
|
||||
recurrence: '0 9 * * *', // 09:00 Asia/Tokyo === 00:00 UTC, exactly
|
||||
content: JSON.stringify({ prompt: 'daily digest' }),
|
||||
});
|
||||
db.prepare(`UPDATE messages_in SET status='completed' WHERE id='task-tz'`).run();
|
||||
|
||||
await handleRecurrence(db, fakeSession());
|
||||
|
||||
const follow = db.prepare(`SELECT process_after FROM messages_in WHERE id != 'task-tz'`).get() as {
|
||||
process_after: string;
|
||||
};
|
||||
// Drop the `{ tz: TIMEZONE }` option in recurrence.ts and this reads
|
||||
// T09:00:00 (09:00 UTC) instead — red, even on a UTC CI runner.
|
||||
expect(follow.process_after).toMatch(/T00:00:00/);
|
||||
});
|
||||
|
||||
it('does not clone rows whose recurrence is already cleared', async () => {
|
||||
const db = freshDb();
|
||||
insertTask(db, {
|
||||
insertTaskRow(db, {
|
||||
id: 'task-1',
|
||||
seriesId: 'task-1',
|
||||
processAfter: '2020-01-01T00:00:00.000Z',
|
||||
recurrence: null,
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'one-off' }),
|
||||
});
|
||||
db.prepare(`UPDATE messages_in SET status='completed' WHERE id='task-1'`).run();
|
||||
@@ -96,3 +120,74 @@ describe('handleRecurrence', () => {
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRecurrence — script-failure backoff (streak derived from failed runs)', () => {
|
||||
// A series whose last `fails` occurrences all landed as FAILED (script-skip:error
|
||||
// runs, as synced by syncProcessingAcks). Only the newest row keeps recurrence —
|
||||
// older occurrences had theirs cleared when they were re-armed. fails=0 seeds one
|
||||
// healthy completed run.
|
||||
function seedFailedStreak(db: ReturnType<typeof freshDb>, fails: number) {
|
||||
const rows = Math.max(fails, 1);
|
||||
for (let i = 0; i < rows; i++) {
|
||||
insertTaskRow(db, {
|
||||
id: `task-s-${i}`,
|
||||
seriesId: 'task-s-0',
|
||||
processAfter: '2020-01-01T00:00:00.000Z',
|
||||
recurrence: i === rows - 1 ? '* * * * *' : null, // every minute — raw cron next is ~+1min
|
||||
content: JSON.stringify({ prompt: 'monitor', script: 'exit 1' }),
|
||||
});
|
||||
db.prepare(`UPDATE messages_in SET status = ? WHERE id = ?`).run(
|
||||
fails === 0 ? 'completed' : 'failed',
|
||||
`task-s-${i}`,
|
||||
);
|
||||
}
|
||||
return `task-s-${rows - 1}`; // the row carrying recurrence
|
||||
}
|
||||
|
||||
const clone = (db: ReturnType<typeof freshDb>) =>
|
||||
db.prepare(`SELECT status, process_after, recurrence FROM messages_in WHERE id NOT LIKE 'task-s-%'`).get() as {
|
||||
status: string;
|
||||
process_after: string;
|
||||
recurrence: string | null;
|
||||
};
|
||||
|
||||
it('exports the documented 2,4,8,…,60 progression', () => {
|
||||
expect([1, 2, 3, 4, 5, 6, 7].map(scriptBackoffMinutes)).toEqual([2, 4, 8, 16, 32, 60, 60]);
|
||||
});
|
||||
|
||||
it('pushes the clone past raw cron cadence while the script is failing', async () => {
|
||||
const db = freshDb();
|
||||
seedFailedStreak(db, 3); // streak 3 → backoff 8 min; cron next ≈ +1 min
|
||||
await handleRecurrence(db, fakeSession());
|
||||
|
||||
const next = clone(db);
|
||||
expect(next.status).toBe('pending');
|
||||
const deltaMin = (new Date(next.process_after).getTime() - Date.now()) / 60_000;
|
||||
expect(deltaMin).toBeGreaterThan(7); // backoff won over the 1-min cron grid
|
||||
});
|
||||
|
||||
it('a healthy series (trailing run completed) re-arms on the raw cron grid', async () => {
|
||||
const db = freshDb();
|
||||
seedFailedStreak(db, 0);
|
||||
await handleRecurrence(db, fakeSession());
|
||||
|
||||
const next = clone(db);
|
||||
expect(next.status).toBe('pending');
|
||||
const deltaMin = (new Date(next.process_after).getTime() - Date.now()) / 60_000;
|
||||
expect(deltaMin).toBeLessThan(2); // no backoff applied
|
||||
});
|
||||
|
||||
it('auto-pauses the series at the cap instead of re-arming', async () => {
|
||||
const db = freshDb();
|
||||
const liveId = seedFailedStreak(db, 8);
|
||||
await handleRecurrence(db, fakeSession());
|
||||
|
||||
const next = clone(db);
|
||||
expect(next.status).toBe('paused'); // `ncl tasks resume` revives in place
|
||||
expect(next.recurrence).toBe('* * * * *');
|
||||
const original = db.prepare(`SELECT recurrence FROM messages_in WHERE id = ?`).get(liveId) as {
|
||||
recurrence: string | null;
|
||||
};
|
||||
expect(original.recurrence).toBeNull(); // not re-cloned next sweep
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,27 +11,79 @@
|
||||
* direct dynamic import. When scheduling moves to the modules branch in
|
||||
* PR #8, the install skill re-fills the marker on install.
|
||||
*/
|
||||
import type Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { TIMEZONE } from '../../config.js';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
|
||||
import { GROUPS_DIR, TIMEZONE } from '../../config.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { clearRecurrence, getCompletedRecurring, insertRecurrence } from './db.js';
|
||||
import { clearRecurrence, getCompletedRecurring, insertRecurrence, trailingFailedRuns } from './db.js';
|
||||
|
||||
// Consecutive pre-task-script failures (the series' trailing FAILED runs —
|
||||
// derived from occurrence rows, no stored counter) throttle a broken monitor
|
||||
// script instead of letting it wake a container at raw cron cadence forever.
|
||||
// A deliberate wakeAgent=false gate is a normal completed run and never backs
|
||||
// off. Mirrors the stuck-message retry in host-sweep.ts (BACKOFF_BASE_MS
|
||||
// doubling, MAX_TRIES → failed): fail loud, don't spin.
|
||||
const SCRIPT_FAIL_PAUSE_CAP = 8;
|
||||
const SCRIPT_BACKOFF_CAP_MIN = 60;
|
||||
|
||||
/** 2, 4, 8, 16, 32, 60, 60… minutes for fails = 1, 2, 3… */
|
||||
export function scriptBackoffMinutes(fails: number): number {
|
||||
return Math.min(2 * 2 ** (fails - 1), SCRIPT_BACKOFF_CAP_MIN);
|
||||
}
|
||||
|
||||
/** Host-written line in the series run log — no agent session exists to call
|
||||
* append-log when a script-gated series is auto-paused. Same format as
|
||||
* appendTaskLog (tasks.ts). */
|
||||
function appendHostTaskNote(agentGroupId: string, seriesId: string, note: string): void {
|
||||
const ag = getAgentGroup(agentGroupId);
|
||||
if (!ag) return;
|
||||
const dir = path.join(GROUPS_DIR, ag.folder, 'tasks');
|
||||
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.appendFileSync(path.join(dir, `${seriesId}.md`), `${timestamp} — ${note}\n`);
|
||||
}
|
||||
|
||||
export async function handleRecurrence(inDb: Database.Database, session: Session): Promise<void> {
|
||||
const recurring = getCompletedRecurring(inDb);
|
||||
|
||||
for (const msg of recurring) {
|
||||
try {
|
||||
const { CronExpressionParser } = await import('cron-parser');
|
||||
// Interpret the cron expression in the user's timezone. v1 did this
|
||||
// (src/v1/task-scheduler.ts:20-49); without it, a task written "0 9 * * *"
|
||||
// by an agent running in a user's local TZ fires at 09:00 UTC instead of
|
||||
// 09:00 user-local.
|
||||
const interval = CronExpressionParser.parse(msg.recurrence, { tz: TIMEZONE });
|
||||
const nextRun = interval.next().toISOString();
|
||||
const prefix = msg.kind === 'task' ? 'task' : 'msg';
|
||||
const newId = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const cronNext = interval.next().toDate();
|
||||
const newId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const scriptFails = trailingFailedRuns(inDb, msg.series_id ?? msg.id);
|
||||
|
||||
if (scriptFails >= SCRIPT_FAIL_PAUSE_CAP) {
|
||||
// Re-arm PAUSED at the cron time so `ncl tasks resume` revives the
|
||||
// series in place; leave the why in the run log.
|
||||
insertRecurrence(inDb, msg, newId, cronNext.toISOString(), 'paused');
|
||||
clearRecurrence(inDb, msg.id);
|
||||
appendHostTaskNote(
|
||||
session.agent_group_id,
|
||||
msg.series_id,
|
||||
`auto-paused after ${scriptFails} consecutive script failures (host); fix the script, then \`ncl tasks resume ${msg.series_id}\``,
|
||||
);
|
||||
log.warn('Task series auto-paused: script keeps failing', {
|
||||
seriesId: msg.series_id,
|
||||
scriptFails,
|
||||
sessionId: session.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const backoffAt = scriptFails > 0 ? Date.now() + scriptBackoffMinutes(scriptFails) * 60_000 : 0;
|
||||
const nextRun = new Date(Math.max(cronNext.getTime(), backoffAt)).toISOString();
|
||||
|
||||
insertRecurrence(inDb, msg, newId, nextRun);
|
||||
clearRecurrence(inDb, msg.id);
|
||||
@@ -41,6 +93,7 @@ export async function handleRecurrence(inDb: Database.Database, session: Session
|
||||
newId,
|
||||
seriesId: msg.series_id,
|
||||
nextRun,
|
||||
...(scriptFails > 0 && { scriptFails, backoffMin: scriptBackoffMinutes(scriptFails) }),
|
||||
sessionId: session.id,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -22,9 +22,11 @@ import { ensureContainedInboxDir, isPathInside } from './inbox-safety.js';
|
||||
import { getMessagingGroup } from './db/messaging-groups.js';
|
||||
import {
|
||||
createSession,
|
||||
findSystemSession,
|
||||
findSessionByAgentGroup,
|
||||
findSessionForAgent,
|
||||
getSession,
|
||||
taskThreadId,
|
||||
updateSession,
|
||||
} from './db/sessions.js';
|
||||
import {
|
||||
@@ -120,6 +122,33 @@ export function resolveSession(
|
||||
return { session, created: true };
|
||||
}
|
||||
|
||||
/** Find or create the per-agent-group session used for scheduled tasks. */
|
||||
/** Find or create the isolated session for one task series (thread `system:tasks:<seriesId>`). */
|
||||
export function resolveTaskSession(agentGroupId: string, seriesId: string): { session: Session; created: boolean } {
|
||||
const threadId = taskThreadId(seriesId);
|
||||
const existing = findSystemSession(agentGroupId, threadId);
|
||||
if (existing) return { session: existing, created: false };
|
||||
|
||||
const id = generateId();
|
||||
const session: Session = {
|
||||
id,
|
||||
agent_group_id: agentGroupId,
|
||||
messaging_group_id: null,
|
||||
thread_id: threadId,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
createSession(session);
|
||||
initSessionFolder(agentGroupId, id);
|
||||
log.info('Task session created', { id, agentGroupId, seriesId });
|
||||
|
||||
return { session, created: true };
|
||||
}
|
||||
|
||||
/** Create the session folder and initialize both DBs. */
|
||||
export function initSessionFolder(agentGroupId: string, sessionId: string): void {
|
||||
const dir = sessionDir(agentGroupId, sessionId);
|
||||
@@ -350,6 +379,16 @@ export function openInboundDb(agentGroupId: string, sessionId: string): Database
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Open a session's inbound DB, run `fn`, and always close it. */
|
||||
export function withInboundDb<T>(agentGroupId: string, sessionId: string, fn: (db: Database.Database) => T): T {
|
||||
const db = openInboundDb(agentGroupId, sessionId);
|
||||
try {
|
||||
return fn(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the outbound DB for a session (host reads only). */
|
||||
export function openOutboundDb(agentGroupId: string, sessionId: string): Database.Database {
|
||||
return openOutboundDbRaw(outboundDbPath(agentGroupId, sessionId));
|
||||
|
||||
+28
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { formatLocalTime, isValidTimezone, resolveTimezone } from './timezone.js';
|
||||
import { formatLocalTime, isValidTimezone, parseZonedToUtc, resolveTimezone } from './timezone.js';
|
||||
|
||||
// --- formatLocalTime ---
|
||||
|
||||
@@ -62,3 +62,30 @@ describe('resolveTimezone', () => {
|
||||
expect(resolveTimezone('')).toBe('UTC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseZonedToUtc', () => {
|
||||
const iso = (s: string, tz: string): string => parseZonedToUtc(s, tz).toISOString();
|
||||
|
||||
it('reads a naive timestamp as wall-clock in a fixed-offset zone', () => {
|
||||
expect(iso('2026-06-20T09:00:00', 'Asia/Tokyo')).toBe('2026-06-20T00:00:00.000Z'); // UTC+9
|
||||
});
|
||||
|
||||
it('applies the correct seasonal offset (DST honored)', () => {
|
||||
// Same wall-clock time, different UTC offset by season — proves the offset
|
||||
// is computed against the zone's rules, not a fixed guess.
|
||||
expect(iso('2026-07-01T12:00:00', 'America/New_York')).toBe('2026-07-01T16:00:00.000Z'); // EDT -4
|
||||
expect(iso('2026-01-01T12:00:00', 'America/New_York')).toBe('2026-01-01T17:00:00.000Z'); // EST -5
|
||||
});
|
||||
|
||||
it('passes a trailing-Z timestamp through unchanged', () => {
|
||||
expect(iso('2026-06-20T09:00:00Z', 'Asia/Tokyo')).toBe('2026-06-20T09:00:00.000Z');
|
||||
});
|
||||
|
||||
it('passes an explicit offset through', () => {
|
||||
expect(iso('2026-06-20T09:00:00+02:00', 'Asia/Tokyo')).toBe('2026-06-20T07:00:00.000Z');
|
||||
});
|
||||
|
||||
it('falls back to UTC for an invalid zone', () => {
|
||||
expect(iso('2026-06-20T09:00:00', 'Not/AZone')).toBe('2026-06-20T09:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,3 +35,45 @@ export function formatLocalTime(utcIso: string, timezone: string): string {
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret a naive ISO-like timestamp (no trailing `Z`, no offset) as wall-clock
|
||||
* time in `tz` and return the corresponding UTC Date. Strings that already carry
|
||||
* offset info (`Z` or `+-HH:MM`) are passed through to the Date constructor.
|
||||
*/
|
||||
export function parseZonedToUtc(input: string, tz: string): Date {
|
||||
const hasOffset = /Z$|[+-]\d{2}:?\d{2}$/.test(input.trim());
|
||||
if (hasOffset) return new Date(input);
|
||||
|
||||
const zone = resolveTimezone(tz);
|
||||
const asIfUtc = new Date(input + 'Z');
|
||||
if (Number.isNaN(asIfUtc.getTime())) return asIfUtc;
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const parts = Object.fromEntries(
|
||||
fmt
|
||||
.formatToParts(asIfUtc)
|
||||
.filter((p) => p.type !== 'literal')
|
||||
.map((p) => [p.type, p.value]),
|
||||
);
|
||||
const hour = parts.hour === '24' ? '00' : parts.hour;
|
||||
const zonedAsUtcMs = Date.UTC(
|
||||
Number(parts.year),
|
||||
Number(parts.month) - 1,
|
||||
Number(parts.day),
|
||||
Number(hour),
|
||||
Number(parts.minute),
|
||||
Number(parts.second),
|
||||
);
|
||||
const offsetMs = zonedAsUtcMs - asIfUtc.getTime();
|
||||
return new Date(asIfUtc.getTime() - offsetMs);
|
||||
}
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ export interface Session {
|
||||
// ── Session DB entities ──
|
||||
|
||||
export type MessageInKind = 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system';
|
||||
export type MessageInStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||
export type MessageInStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
export interface MessageIn {
|
||||
id: string;
|
||||
|
||||
+5
-3
@@ -12,9 +12,11 @@ must stay inside this directory — absolute paths, `~`, and `../` escapes are
|
||||
rejected. Override the location with `NANOCLAW_TEMPLATES_DIR=/another/local/path`
|
||||
(a local path only — never a URL).
|
||||
|
||||
The setup wizard's **Template setup → NanoClaw template library** option clones
|
||||
the public registry and copies your chosen template *into this folder*, after
|
||||
which it stamps from the local copy. **Local templates** lists whatever is here.
|
||||
To use a template from the public registry
|
||||
([`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates)),
|
||||
clone or download it yourself and copy the chosen template *into this folder*,
|
||||
then stamp from the local copy. There is no remote fetch — templates are only
|
||||
ever resolved from here.
|
||||
|
||||
## Anatomy of a template
|
||||
|
||||
|
||||
Reference in New Issue
Block a user