Compare commits

..

1 Commits

Author SHA1 Message Date
gavrielc 2c36835762 Add an ncl tasks resource so operators can stop runaway tasks
Adds a host-side `ncl tasks` CLI resource (list/get/cancel/pause/resume)
backed by per-session inbound.db rows rather than the central DB, giving
operators a way to stop a runaway scheduled task without asking the agent
that owns it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:16:44 +03:00
16 changed files with 928 additions and 119 deletions
+26 -2
View File
@@ -206,6 +206,29 @@ ncl groups restart --id <group-id> --message "on_wake test"
Without `--message`, the container comes back on the next user message. From inside a container, `--id` is auto-filled and only the calling session restarts.
## Runaway task
A scheduled task (cron) that keeps firing is a `messages_in` row with `kind='task'` in a session's `inbound.db`. When an agent won't stop one — or you'd rather not ask the misbehaving agent — use the `ncl tasks` operator surface to inspect and stop it directly from the host:
```bash
# List every pending/paused task across all groups (one row per series).
ncl tasks list
# Scope to a single agent group.
ncl tasks list --group <group-id>
# Inspect one task by id or series id.
ncl tasks get --id <task-id>
# Stop it. cancel/pause/resume match by id OR series id, so a recurring
# task's live next occurrence is caught, not just the row you looked up.
ncl tasks cancel --id <task-id>
ncl tasks pause --id <task-id>
ncl tasks resume --id <task-id>
```
The host is the legitimate writer of `inbound.db`, so these apply straight to the session DB without waking the container. `cancel` marks the series completed, `pause` holds a pending task, `resume` re-arms a paused one.
## Manual Container Probes
The container's entry point is `exec bun run /app/src/index.ts`; it talks only to the mounted session DBs, so there is no JSON to pipe in. To probe the image directly:
@@ -258,13 +281,14 @@ docker builder prune -af
## Clearing a Session
Conversation continuity lives in the container-owned `session_state` table in `outbound.db` (the provider's session/continuation id). The agent's `/clear` clears it. To reset a session from the host, remove the session folder so a fresh one is provisioned on the next message:
Conversation continuity lives in the container-owned `session_state` table in `outbound.db` (the provider's session/continuation id). The agent's `/clear` clears it. To reset a session from the host, just remove the session folder — it self-heals: the host re-creates the folder and re-initializes both DBs on the next inbound message, so you leave the central `sessions` row in place and don't need to restart the host.
```bash
# Inspect first
ncl sessions get <session-id>
# Remove a single session's folder (host re-provisions both DBs on next message)
# Remove a single session's folder — the host re-provisions it (both DBs)
# on the next message. No row deletion or host restart needed.
rm -rf data/v2-sessions/<group>/<session>/
```
+1
View File
@@ -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, cancel, pause, resume | Scheduled tasks (cron jobs) in a group's sessions — operator surface to stop a runaway task |
| 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) |
@@ -3,3 +3,4 @@
// level. Skills add a new provider by appending one import line below.
import './claude.js';
import './mock.js';
+1 -1
View File
@@ -31,7 +31,7 @@ flowchart TB
subgraph Session["Per-Session Container (Docker / Apple Container)"]
direction TB
PollLoop["Poll Loop<br/>(container/agent-runner)"]
Provider["Agent providers<br/>(claude, opencode; todo: codex)"]
Provider["Agent providers<br/>(claude, opencode, mock; 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"]
Skills["Container Skills<br/>(container/skills/)"]
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>destinations<br/>processing_ack")]
+166
View File
@@ -0,0 +1,166 @@
# Main
You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
## What You Can Do
- Answer questions and have conversations
- Search the web and fetch content from URLs
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
- Read and write files in your workspace
- Run bash commands in your sandbox
- Schedule tasks to run later or on a recurring basis
- Send messages back to the chat
## Communication
Be concise — every message costs the reader's attention.
### Destinations
Each turn, your system prompt lists the destinations available to you. If you only have one destination, just write your response directly — it goes there automatically. If you have multiple, wrap each message in a `<message to="name">...</message>` block:
```
<message to="family">On my way home, 15 minutes</message>
<message to="worker-1">kick off the pipeline</message>
```
Inbound messages are labeled with `from="name"` so you can tell which destination they came from and reply using that same name.
### Mid-turn updates
Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work:
- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final response.
- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") so the user knows you got the message.
- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages.
**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call.
**Outcomes, not play-by-play.** When the work is done, the final message should be about the result, not a transcript of what you did.
### Internal thoughts
Wrap reasoning in `<internal>...</internal>` tags to mark it as scratchpad — logged but not sent. With multiple destinations, any text outside of `<message>` blocks is also treated as scratchpad. With a single destination, only explicit `<internal>` tags are scratchpad; the rest of your response is sent.
```
<internal>Compiled all three reports, ready to summarize.</internal>
Here are the key findings from the research…
```
### Sub-agents and teammates
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
## Your Workspace
Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist.
## Memory
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
When you learn something important:
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
- Split files larger than 500 lines into folders
- Keep an index in your memory for the files you create
## Message Formatting
Format messages based on the channel you're responding to. Check your group folder name:
### Slack channels (folder starts with `slack_`)
Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules:
- `*bold*` (single asterisks)
- `_italic_` (underscores)
- `<https://url|link text>` for links (NOT `[text](url)`)
- `•` bullets (no numbered lists)
- `:emoji:` shortcodes
- `>` for block quotes
- No `##` headings — use `*Bold text*` instead
### WhatsApp/Telegram channels (folder starts with `whatsapp_` or `telegram_`)
- `*bold*` (single asterisks, NEVER **double**)
- `_italic_` (underscores)
- `•` bullet points
- ` ``` ` code blocks
No `##` headings. No `[links](url)`. No `**double stars**`.
### Discord channels (folder starts with `discord_`)
Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
---
## Installing Packages & Tools
Your container is ephemeral — anything installed via `apt-get` or `pnpm install -g` is lost on restart. To install packages that persist, use the self-modification tools:
1. **`install_packages`** — request system (apt) or global npm packages. Requires admin approval.
2. **`request_rebuild`** — rebuild your container image so approved packages are baked in. Always call this after `install_packages` to apply the changes.
Example flow:
```
install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" })
# → Admin gets an approval card → approves
request_rebuild({ reason: "Apply ffmpeg + transformers" })
# → Admin approves → image rebuilt with the packages
```
**When to use this vs workspace pnpm install:**
- `pnpm install` in `/workspace/agent/` persists on disk (it's mounted) but isn't on the global PATH — use it for project-level dependencies
- `install_packages` is for system tools (ffmpeg, imagemagick) and global npm packages that need to be on PATH
### MCP Servers
Use **`add_mcp_server`** to add an MCP server to your configuration, then **`request_rebuild`** to apply. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `pnpm dlx`, e.g.:
```
add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] })
request_rebuild({ reason: "Add memory MCP server" })
```
## Task Scripts
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. Other scheduling tools you might discover (e.g. `CronCreate`, `ScheduleWakeup`) are session-scoped SDK builtins and won't behave the way NanoClaw users expect, so stick with `schedule_task`.
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 — it preserves the series id the user already knows.
Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
### How it works
1. You provide a bash `script` alongside the `prompt` when scheduling
2. When the task fires, the script runs first (30-second timeout)
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
4. If `wakeAgent: false` — nothing happens, task waits for next run
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
### Always test your script first
Before scheduling, run the script in your sandbox to verify it works:
```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) }));
"'
```
### When NOT to use scripts
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
### Frequent task guidance
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
- Explain that each wake-up uses API credits and risks rate limits
- Suggest restructuring with a script that checks the condition first
- 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
+312
View File
@@ -0,0 +1,312 @@
@./.claude-global.md
# Main
You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
## What You Can Do
- Answer questions and have conversations
- Search the web and fetch content from URLs
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
- Read and write files in your workspace
- Run bash commands in your sandbox
- Schedule tasks to run later or on a recurring basis
- Send messages back to the chat
## Communication
Your output is sent to the user or group.
You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work.
### Internal thoughts
If part of your output is internal reasoning rather than something for the user, wrap it in `<internal>` tags:
```
<internal>Compiled all three reports, ready to summarize.</internal>
Here are the key findings from the research...
```
Text inside `<internal>` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `<internal>` to avoid sending it again.
### Sub-agents and teammates
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
## Memory
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
When you learn something important:
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
- Split files larger than 500 lines into folders
- Keep an index in your memory for the files you create
## Message Formatting
Format messages based on the channel. Check the group folder name prefix:
### Slack channels (folder starts with `slack_`)
Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules:
- `*bold*` (single asterisks)
- `_italic_` (underscores)
- `<https://url|link text>` for links (NOT `[text](url)`)
- `•` bullets (no numbered lists)
- `:emoji:` shortcodes like `:white_check_mark:`, `:rocket:`
- `>` for block quotes
- No `##` headings — use `*Bold text*` instead
### WhatsApp/Telegram (folder starts with `whatsapp_` or `telegram_`)
- `*bold*` (single asterisks, NEVER **double**)
- `_italic_` (underscores)
- `•` bullet points
- ` ``` ` code blocks
No `##` headings. No `[links](url)`. No `**double stars**`.
### Discord (folder starts with `discord_`)
Standard Markdown: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
---
## Admin Context
This is the **main channel**, which has elevated privileges.
## Authentication
Anthropic credentials must be either an API key from console.anthropic.com (`ANTHROPIC_API_KEY`) or a long-lived OAuth token from `claude setup-token` (`CLAUDE_CODE_OAUTH_TOKEN`). Short-lived tokens from the system keychain or `~/.claude/.credentials.json` expire within hours and can cause recurring container 401s. The `/setup` skill walks through this. OneCLI manages credentials (including Anthropic auth) — run `onecli --help`.
## Container Mounts
Main has read-only access to the project, read-write access to the store (SQLite DB), and read-write access to its group folder:
| Container Path | Host Path | Access |
|----------------|-----------|--------|
| `/workspace/project` | Project root | read-only |
| `/workspace/project/store` | `store/` | read-write |
| `/workspace/group` | `groups/main/` | read-write |
Key paths inside the container:
- `/workspace/project/store/messages.db` - SQLite database (read-write)
- `/workspace/project/store/messages.db` (registered_groups table) - Group config
- `/workspace/project/groups/` - All group folders
---
## Managing Groups
### Finding Available Groups
Available groups are provided in `/workspace/ipc/available_groups.json`:
```json
{
"groups": [
{
"jid": "120363336345536173@g.us",
"name": "Family Chat",
"lastActivity": "2026-01-31T12:00:00.000Z",
"isRegistered": false
}
],
"lastSync": "2026-01-31T12:00:00.000Z"
}
```
Groups are ordered by most recent activity. The list is synced from WhatsApp daily.
If a group the user mentions isn't in the list, request a fresh sync:
```bash
echo '{"type": "refresh_groups"}' > /workspace/ipc/tasks/refresh_$(date +%s).json
```
Then wait a moment and re-read `available_groups.json`.
**Fallback**: Query the SQLite database directly:
```bash
sqlite3 /workspace/project/store/messages.db "
SELECT jid, name, last_message_time
FROM chats
WHERE jid LIKE '%@g.us' AND jid != '__group_sync__'
ORDER BY last_message_time DESC
LIMIT 10;
"
```
### Registered Groups Config
Groups are registered in the SQLite `registered_groups` table:
```json
{
"1234567890-1234567890@g.us": {
"name": "Family Chat",
"folder": "whatsapp_family-chat",
"trigger": "@Andy",
"added_at": "2024-01-31T12:00:00.000Z"
}
}
```
Fields:
- **Key**: The chat JID (unique identifier — WhatsApp, Telegram, Slack, Discord, etc.)
- **name**: Display name for the group
- **folder**: Channel-prefixed folder name under `groups/` for this group's files and memory
- **trigger**: The trigger word (usually same as global, but could differ)
- **requiresTrigger**: Whether `@trigger` prefix is needed (default: `true`). Set to `false` for solo/personal chats where all messages should be processed
- **isMain**: Whether this is the main control group (elevated privileges, no trigger required)
- **added_at**: ISO timestamp when registered
### Trigger Behavior
- **Main group** (`isMain: true`): No trigger needed — all messages are processed automatically
- **Groups with `requiresTrigger: false`**: No trigger needed — all messages processed (use for 1-on-1 or solo chats)
- **Other groups** (default): Messages must start with `@AssistantName` to be processed
### Adding a Group
1. Query the database to find the group's JID
2. Ask the user whether the group should require a trigger word before registering
3. Use the `register_group` MCP tool with the JID, name, folder, trigger, and the chosen `requiresTrigger` setting
4. Optionally include `containerConfig` for additional mounts
5. The group folder is created automatically: `/workspace/project/groups/{folder-name}/`
6. Optionally create an initial `CLAUDE.md` for the group
Folder naming convention — channel prefix with underscore separator:
- WhatsApp "Family Chat" → `whatsapp_family-chat`
- Telegram "Dev Team" → `telegram_dev-team`
- Discord "General" → `discord_general`
- Slack "Engineering" → `slack_engineering`
- Use lowercase, hyphens for the group name part
#### Adding Additional Directories for a Group
Groups can have extra directories mounted. Add `containerConfig` to their entry:
```json
{
"1234567890@g.us": {
"name": "Dev Team",
"folder": "dev-team",
"trigger": "@Andy",
"added_at": "2026-01-31T12:00:00Z",
"containerConfig": {
"additionalMounts": [
{
"hostPath": "~/projects/webapp",
"containerPath": "webapp",
"readonly": false
}
]
}
}
}
```
The directory will appear at `/workspace/extra/webapp` in that group's container.
#### Sender Allowlist
After registering a group, explain the sender allowlist feature to the user:
> This group can be configured with a sender allowlist to control who can interact with me. There are two modes:
>
> - **Trigger mode** (default): Everyone's messages are stored for context, but only allowed senders can trigger me with @{AssistantName}.
> - **Drop mode**: Messages from non-allowed senders are not stored at all.
>
> For closed groups with trusted members, I recommend setting up an allow-only list so only specific people can trigger me. Want me to configure that?
If the user wants to set up an allowlist, edit `~/.config/nanoclaw/sender-allowlist.json` on the host:
```json
{
"default": { "allow": "*", "mode": "trigger" },
"chats": {
"<chat-jid>": {
"allow": ["sender-id-1", "sender-id-2"],
"mode": "trigger"
}
},
"logDenied": true
}
```
Notes:
- Your own messages (`is_from_me`) explicitly bypass the allowlist in trigger checks. Bot messages are filtered out by the database query before trigger evaluation, so they never reach the allowlist.
- If the config file doesn't exist or is invalid, all senders are allowed (fail-open)
- The config file is on the host at `~/.config/nanoclaw/sender-allowlist.json`, not inside the container
### Removing a Group
1. Read `/workspace/project/data/registered_groups.json`
2. Remove the entry for that group
3. Write the updated JSON back
4. The group folder and its files remain (don't delete them)
### Listing Groups
Read `/workspace/project/data/registered_groups.json` and format it nicely.
---
## Global Memory
You can read and write to `/workspace/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar.
---
## Scheduling for Other Groups
When scheduling tasks for other groups, use the `target_group_jid` parameter with the group's JID from `registered_groups.json`:
- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")`
The task will run in that group's context with access to their files and memory.
---
## Task Scripts
For any recurring task, use `schedule_task`. Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
Use `list_tasks` to see existing tasks (one row per series with the stable id), and `update_task` / `cancel_task` / `pause_task` / `resume_task` to modify them. Prefer `update_task` over cancel + reschedule when adjusting an existing task.
### How it works
1. You provide a bash `script` alongside the `prompt` when scheduling
2. When the task fires, the script runs first (30-second timeout)
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
4. If `wakeAgent: false` — nothing happens, task waits for next run
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
### Always test your script first
Before scheduling, run the script in your sandbox to verify it works:
```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) }));
"'
```
### When NOT to use scripts
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
### Frequent task guidance
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
- Explain that each wake-up uses API credits and risks rate limits
- Suggest restructuring with a script that checks the condition first
- 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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.26",
"version": "2.1.24",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -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="207k tokens, 104% of context window">
<title>207k 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="204k tokens, 102% of context window">
<title>204k tokens, 102% 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">207k</text>
<text x="71" y="14">207k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">204k</text>
<text x="71" y="14">204k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+2 -74
View File
@@ -2,32 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// --- Mocks ---
const approvalState = vi.hoisted(() => ({
requestApproval: vi.fn(),
approvalHandler: null as
| null
| ((args: {
session: unknown;
payload: Record<string, unknown>;
userId: string;
notify: (text: string) => void;
}) => Promise<void>),
registerApprovalHandler: vi.fn(
(
action: string,
handler: (args: {
session: unknown;
payload: Record<string, unknown>;
userId: string;
notify: (text: string) => void;
}) => Promise<void>,
) => {
if (action === 'cli_command') approvalState.approvalHandler = handler;
},
),
observedContexts: [] as CallerContext[],
}));
vi.mock('../log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
@@ -55,8 +29,8 @@ vi.mock('./crud.js', () => ({
}));
vi.mock('../modules/approvals/index.js', () => ({
registerApprovalHandler: approvalState.registerApprovalHandler,
requestApproval: approvalState.requestApproval,
registerApprovalHandler: vi.fn(),
requestApproval: vi.fn(),
}));
// Register a test command so dispatch has something to find
@@ -124,18 +98,6 @@ register({
handler: async (args) => ({ echo: args }),
});
register({
name: 'approval-context-command',
description: 'approval command that records caller context',
resource: 'groups',
access: 'approval',
parseArgs: (raw) => raw,
handler: async (_args, ctx) => {
approvalState.observedContexts.push(ctx);
return { caller: ctx.caller };
},
});
// Commands that return data shaped like real resources (for post-handler filtering tests)
register({
name: 'groups-list-data',
@@ -190,7 +152,6 @@ import type { CallerContext } from './frame.js';
beforeEach(() => {
vi.clearAllMocks();
approvalState.observedContexts.length = 0;
// Default: the four CLI-whitelisted resources with their real scopeFields.
const scopeFields: Record<string, string> = {
groups: 'id',
@@ -430,39 +391,6 @@ describe('CLI scope enforcement', () => {
expect(mockGetContainerConfig).not.toHaveBeenCalled();
});
it('approval replay preserves the original agent caller context', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
mockGetAgentGroup.mockReturnValue({ id: 'g1', name: 'Group One' });
const ctx = agentCtx();
const resp = await dispatch({ id: '1', command: 'approval-context-command', args: {} }, ctx);
expect(resp.ok).toBe(false);
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
const approval = approvalState.requestApproval.mock.calls[0][0] as { payload: Record<string, unknown> };
expect(approval.payload).toEqual({
frame: {
id: '1',
command: 'approval-context-command',
args: { agent_group_id: 'g1', group: 'g1', id: 'g1' },
},
callerContext: ctx,
});
expect(approvalState.approvalHandler).toBeTypeOf('function');
await approvalState.approvalHandler!({
session: { id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' },
payload: approval.payload,
userId: 'telegram:admin',
notify: vi.fn(),
});
expect(approvalState.observedContexts).toEqual([ctx]);
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
});
// --- Post-handler filtering ---
it('group: groups list filters out other groups', async () => {
+5 -35
View File
@@ -14,16 +14,7 @@ import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './fr
import { getResource } from './crud.js';
import { lookup } from './registry.js';
type DispatchOptions = {
/** True when a command is being replayed after approval. */
approved?: boolean;
};
export async function dispatch(
req: RequestFrame,
ctx: CallerContext,
opts: DispatchOptions = {},
): Promise<ResponseFrame> {
export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<ResponseFrame> {
let cmd = lookup(req.command);
// Fallback: if the full command isn't registered, trim the last
@@ -110,7 +101,7 @@ export async function dispatch(
}
}
if (ctx.caller !== 'host' && cmd.access === 'approval' && !opts.approved) {
if (ctx.caller !== 'host' && cmd.access === 'approval') {
const session = getSession(ctx.sessionId);
if (!session) {
return err(req.id, 'handler-error', 'Session not found.');
@@ -126,7 +117,7 @@ export async function dispatch(
session,
agentName,
action: 'cli_command',
payload: { frame: { id: req.id, command: req.command, args: req.args }, callerContext: ctx },
payload: { frame: { id: req.id, command: req.command, args: req.args } },
title: `CLI: ${req.command}`,
question: `Agent "${agentName}" wants to run:\n\`ncl ${req.command}${argSummary ? ' ' + argSummary : ''}\``,
});
@@ -187,10 +178,9 @@ export async function dispatch(
}
}
registerApprovalHandler('cli_command', async ({ payload, notify }) => {
registerApprovalHandler('cli_command', async ({ session, payload, userId, notify }) => {
const frame = payload.frame as RequestFrame;
const callerContext = parseCallerContext(payload.callerContext) ?? { caller: 'host' };
const response = await dispatch(frame, callerContext, { approved: true });
const response = await dispatch(frame, { caller: 'host' });
if (response.ok) {
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
@@ -200,26 +190,6 @@ registerApprovalHandler('cli_command', async ({ payload, notify }) => {
}
});
function parseCallerContext(value: unknown): CallerContext | undefined {
if (!value || typeof value !== 'object') return undefined;
const record = value as Record<string, unknown>;
if (record.caller === 'host') return { caller: 'host' };
if (
record.caller === 'agent' &&
typeof record.sessionId === 'string' &&
typeof record.agentGroupId === 'string' &&
typeof record.messagingGroupId === 'string'
) {
return {
caller: 'agent',
sessionId: record.sessionId,
agentGroupId: record.agentGroupId,
messagingGroupId: record.messagingGroupId,
};
}
return undefined;
}
function err(id: string, code: ErrorCode, message: string): ResponseFrame {
return { id, ok: false, error: { code, message } };
}
+1
View File
@@ -14,3 +14,4 @@ import './user-dms.js';
import './dropped-messages.js';
import './approvals.js';
import './sessions.js';
import './tasks.js';
+165
View File
@@ -0,0 +1,165 @@
/**
* `ncl tasks` operator surface the only host-side way to inspect and stop
* scheduled tasks (cron jobs) without asking the agent that owns them.
*
* Tasks are `messages_in` rows with `kind='task'` in a session's inbound.db,
* so the resource opens each session DB from the host (the legitimate writer),
* the same way destinations.ts projects rows. These tests drive dispatch with
* `caller: 'host'` the path a real operator socket connection uses.
*/
import Database from 'better-sqlite3';
import fs from 'fs';
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
isContainerRunning: vi.fn().mockReturnValue(false),
getActiveContainerCount: vi.fn().mockReturnValue(0),
killContainer: vi.fn(),
}));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-tasks' };
});
const TEST_DIR = '/tmp/nanoclaw-test-cli-tasks';
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
import { createSession } from '../../db/sessions.js';
import { insertRecurrence, insertTask } from '../../modules/scheduling/db.js';
import { initSessionFolder, inboundDbPath, openInboundDb } from '../../session-manager.js';
import { dispatch } from '../dispatch.js';
// Side-effect import: registers the `tasks-*` commands.
import './tasks.js';
const AG = 'ag-tasks';
const SESSION = 'sess-tasks-1';
function now(): string {
return new Date().toISOString();
}
function readTaskStatus(id: string): string | undefined {
const db = new Database(inboundDbPath(AG, SESSION), { readonly: true });
const row = db.prepare('SELECT status FROM messages_in WHERE id = ?').get(id) as { status: string } | undefined;
db.close();
return row?.status;
}
function seedTask(id: string, prompt: string): void {
const db = openInboundDb(AG, SESSION);
try {
insertTask(db, {
id,
processAfter: now(),
recurrence: null,
platformId: null,
channelType: null,
threadId: null,
content: JSON.stringify({ prompt }),
});
} finally {
db.close();
}
}
describe('tasks CLI resource (operator surface)', () => {
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: AG, name: 'tasks', folder: 'tasks', agent_provider: null, created_at: now() });
createSession({
id: SESSION,
agent_group_id: AG,
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: null,
created_at: now(),
});
initSessionFolder(AG, SESSION);
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('list: returns a pending task from the session inbound.db', async () => {
seedTask('task-1', 'water the plants');
const resp = await dispatch({ id: 'req-list', command: 'tasks-list', args: {} }, { caller: 'host' });
expect(resp.ok).toBe(true);
const rows = (resp as { ok: true; data: unknown }).data as Array<Record<string, unknown>>;
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
agent_group_id: AG,
session_id: SESSION,
id: 'task-1',
status: 'pending',
prompt: 'water the plants',
});
});
it('cancel: cancels a task matched by its id', async () => {
seedTask('task-1', 'water the plants');
expect(readTaskStatus('task-1')).toBe('pending');
const resp = await dispatch(
{ id: 'req-cancel', command: 'tasks-cancel', args: { id: 'task-1' } },
{ caller: 'host' },
);
expect(resp.ok).toBe(true);
expect((resp as { ok: true; data: { affected: number } }).data.affected).toBe(1);
expect(readTaskStatus('task-1')).toBe('completed');
});
it('cancel: cancels a recurring follow-up matched by series id', async () => {
// Original firing plus a live follow-up occurrence that shares the series
// id but has a distinct row id — the shape recurrence produces. Cancelling
// by the series id must reach the follow-up whose id != the arg.
seedTask('series-1', 'daily standup');
const db = openInboundDb(AG, SESSION);
try {
insertRecurrence(
db,
{
id: 'series-1',
kind: 'task',
content: JSON.stringify({ prompt: 'daily standup' }),
recurrence: 'daily',
process_after: now(),
platform_id: null,
channel_type: null,
thread_id: null,
series_id: 'series-1',
},
'occ-2',
now(),
);
} finally {
db.close();
}
expect(readTaskStatus('occ-2')).toBe('pending');
const resp = await dispatch(
{ id: 'req-cancel-series', command: 'tasks-cancel', args: { id: 'series-1' } },
{ caller: 'host' },
);
expect(resp.ok).toBe(true);
// Both the original row and the follow-up share series_id 'series-1'.
expect((resp as { ok: true; data: { affected: number } }).data.affected).toBe(2);
expect(readTaskStatus('occ-2')).toBe('completed');
expect(readTaskStatus('series-1')).toBe('completed');
});
});
+235
View File
@@ -0,0 +1,235 @@
import fs from 'fs';
import type Database from 'better-sqlite3';
import { getAllAgentGroups } from '../../db/agent-groups.js';
import { getSessionsByAgentGroup } from '../../db/sessions.js';
import { cancelTask, pauseTask, resumeTask } from '../../modules/scheduling/db.js';
import { inboundDbPath, openInboundDb } from '../../session-manager.js';
import { registerResource } from '../crud.js';
import type { CallerContext } from '../frame.js';
/**
* `ncl tasks` the operator surface for scheduled tasks (cron jobs).
*
* Tasks are `messages_in` rows with `kind='task'` living in each session's
* host-owned `inbound.db` there is no central table, so the auto-CRUD in
* crud.ts (which is central-DB-bound) can't back this resource. Instead we
* iterate `getSessionsByAgentGroup` and open each session's `inbound.db`
* directly from the host process, which is the sole legitimate writer of that
* file (see session-manager.ts). This is the same shape destinations.ts uses
* to project rows into live sessions.
*
* Before this existed the only way to stop a runaway recurring task was to ask
* the (misbehaving) agent to cancel it via its container-side task tools.
*/
interface ProjectedTask {
agent_group_id: string;
session_id: string;
/** The stable series handle — one row per series (see list_tasks). */
id: string;
status: string;
process_after: string | null;
recurrence: string | null;
prompt: string;
}
/**
* Resolve which agent groups the caller may act on. Agent callers are pinned
* to their own group (cli_scope=group), mirroring how groups/sessions/
* destinations keep a container inside its own agent group. Host (operator)
* callers may target one group via --group or every group when it's omitted.
*/
function targetAgentGroupIds(group: string | undefined, ctx: CallerContext): string[] {
if (ctx.caller === 'agent') return [ctx.agentGroupId];
if (group) return [group];
return getAllAgentGroups().map((g) => g.id);
}
/**
* Run `cb` against every session's host-owned inbound.db for the resolved
* agent groups. Skips sessions whose inbound.db hasn't been created yet
* (same guard as write-destinations.ts) so opening one never fabricates an
* empty, schema-less DB file.
*/
function forEachSessionDb(
groupIds: string[],
cb: (db: Database.Database, agentGroupId: string, sessionId: string) => void,
): void {
for (const agentGroupId of groupIds) {
for (const session of getSessionsByAgentGroup(agentGroupId)) {
if (!fs.existsSync(inboundDbPath(agentGroupId, session.id))) continue;
const db = openInboundDb(agentGroupId, session.id);
try {
cb(db, agentGroupId, session.id);
} finally {
db.close();
}
}
}
}
function taskPrompt(content: string): string {
try {
const parsed = JSON.parse(content) as { prompt?: unknown };
return typeof parsed.prompt === 'string' ? parsed.prompt : '';
} catch {
return '';
}
}
/** Count the live rows a control op would touch, so we can report `affected`. */
function countTargetRows(db: Database.Database, taskId: string, statuses: string[]): number {
const placeholders = statuses.map(() => '?').join(', ');
const row = db
.prepare(
`SELECT COUNT(*) AS n FROM messages_in
WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status IN (${placeholders})`,
)
.get(taskId, taskId, ...statuses) as { n: number };
return row.n;
}
/**
* Shared body for cancel/pause/resume. `statuses` is the set of live statuses
* the underlying db.ts helper actually mutates, used only to report how many
* rows were affected. The helper itself matches by id OR series_id, so a
* recurring task's live next occurrence is caught, not just the row an agent
* happens to remember.
*/
function control(
args: Record<string, unknown>,
ctx: CallerContext,
statuses: string[],
apply: (db: Database.Database, taskId: string) => void,
): { taskId: string; affected: number; sessions: string[] } {
const taskId = args.id as string | undefined;
if (!taskId) throw new Error('--id is required (task id or series id)');
const groupIds = targetAgentGroupIds(args.group as string | undefined, ctx);
let affected = 0;
const sessions: string[] = [];
forEachSessionDb(groupIds, (db, _agentGroupId, sessionId) => {
const n = countTargetRows(db, taskId, statuses);
if (n === 0) return;
apply(db, taskId);
affected += n;
sessions.push(sessionId);
});
return { taskId, affected, sessions };
}
registerResource({
name: 'task',
plural: 'tasks',
// Tasks aren't a central-DB table — they're messages_in rows in per-session
// inbound.db files. `table` is unused because no generic CRUD op is enabled.
table: 'messages_in',
description:
"Scheduled task (cron job) — a messages_in row with kind=task in a session inbound.db. Operator surface to inspect and stop tasks without going through the agent. list/get show one row per series; cancel/pause/resume match by id OR series id so a recurring task's live next occurrence is caught.",
idColumn: 'id',
scopeField: 'agent_group_id',
columns: [
{ name: 'agent_group_id', type: 'string', description: 'Agent group whose session holds the task.' },
{ name: 'session_id', type: 'string', description: 'Session whose inbound.db holds the task.' },
{ name: 'id', type: 'string', description: 'Series id — the stable handle for the task.' },
{ name: 'status', type: 'string', description: '"pending" or "paused".' },
{ name: 'process_after', type: 'string', description: 'When the next occurrence is due (UTC).' },
{ name: 'recurrence', type: 'string', description: 'Recurrence rule, or null for a one-shot.' },
{ name: 'prompt', type: 'string', description: 'The task prompt the agent runs when it fires.' },
],
operations: {},
customOperations: {
list: {
access: 'open',
description:
'List pending and paused scheduled tasks (one row per series). Use --group to scope to a single agent group; defaults to all groups.',
handler: async (args, ctx) => {
const groupIds = targetAgentGroupIds(args.group as string | undefined, ctx);
const tasks: ProjectedTask[] = [];
forEachSessionDb(groupIds, (db, agentGroupId, sessionId) => {
const 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() as Array<{
id: string;
status: string;
process_after: string | null;
recurrence: string | null;
content: string;
}>;
for (const r of rows) {
tasks.push({
agent_group_id: agentGroupId,
session_id: sessionId,
id: r.id,
status: r.status,
process_after: r.process_after,
recurrence: r.recurrence,
prompt: taskPrompt(r.content),
});
}
});
return tasks;
},
},
get: {
access: 'open',
description: 'Get a scheduled task by id or series id. Use --id <task-id>, optional --group.',
handler: async (args, ctx) => {
const taskId = args.id as string | undefined;
if (!taskId) throw new Error('--id is required (task id or series id)');
const groupIds = targetAgentGroupIds(args.group as string | undefined, ctx);
let found: ProjectedTask | undefined;
forEachSessionDb(groupIds, (db, agentGroupId, sessionId) => {
if (found) return;
const r = db
.prepare(
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
FROM messages_in
WHERE kind = 'task' AND (id = ? OR series_id = ?) AND status IN ('pending', 'paused')
GROUP BY series_id`,
)
.get(taskId, taskId) as
| { id: string; status: string; process_after: string | null; recurrence: string | null; content: string }
| undefined;
if (r) {
found = {
agent_group_id: agentGroupId,
session_id: sessionId,
id: r.id,
status: r.status,
process_after: r.process_after,
recurrence: r.recurrence,
prompt: taskPrompt(r.content),
};
}
});
if (!found) throw new Error(`task not found: ${taskId}`);
return found;
},
},
cancel: {
access: 'approval',
description: 'Cancel a scheduled task (matches id or series id). Use --id <task-id>, optional --group.',
handler: async (args, ctx) => control(args, ctx, ['pending', 'paused'], cancelTask),
},
pause: {
access: 'approval',
description: 'Pause a scheduled task so it stops firing until resumed. Use --id <task-id>, optional --group.',
handler: async (args, ctx) => control(args, ctx, ['pending'], pauseTask),
},
resume: {
access: 'approval',
description: 'Resume a paused scheduled task. Use --id <task-id>, optional --group.',
handler: async (args, ctx) => control(args, ctx, ['paused'], resumeTask),
},
},
});
+6
View File
@@ -320,6 +320,12 @@ export function buildMounts(
mounts.push({ hostPath: fragmentsDir, containerPath: '/workspace/agent/.claude-fragments', readonly: true });
}
// Global memory directory — always read-only.
const globalDir = path.join(GROUPS_DIR, 'global');
if (fs.existsSync(globalDir)) {
mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: true });
}
// Shared CLAUDE.md — read-only, imported by the composed entry point via
// the `.claude-shared.md` symlink inside the group dir.
const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
+1 -1
View File
@@ -1,6 +1,6 @@
// Host-side provider container-config barrel.
// Providers that need host-side container setup (extra mounts, env passthrough,
// per-session directories) self-register on import. Providers with no host
// needs (claude) don't appear here.
// needs (claude, mock) don't appear here.
//
// Skills add a new provider by appending one import line below.
+1 -1
View File
@@ -7,7 +7,7 @@
* the registered config fn, and merges the returned mounts/env into the spawn
* args.
*
* Providers without host-side needs (e.g. `claude`) don't appear in
* Providers without host-side needs (e.g. `claude`, `mock`) don't appear in
* this registry at all the lookup returns `undefined` and the spawn path
* proceeds with only the default mounts and env.
*