mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77f3be57fa | |||
| 35248f1bfa | |||
| 551660a2bf | |||
| a13bb24300 | |||
| ec35d9c3f7 | |||
| 96f924f2ac | |||
| 5945a19655 |
@@ -135,7 +135,22 @@ ncl groups restart --id <group-id>
|
||||
|
||||
Switching is an operator action — run it from the host. Memory does NOT carry over automatically — each provider keeps its own store; run `/migrate-memory` to carry it across. See [docs/provider-migration.md](../../docs/provider-migration.md) for the carry-over table and rollback.
|
||||
|
||||
There is no install-wide default provider. Setup's provider picker sets codex on the first agent it creates; creation itself is provider-agnostic (no `--provider` flag — provider is a DB property). Any group switches afterward via `ncl groups config update --provider` as above.
|
||||
### Default new groups to codex (optional)
|
||||
|
||||
New groups are created on the **instance default** (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). Installing this skill wires codex in but does NOT change that default — "installed" is not "authenticated", so the default stays claude until you opt in explicitly.
|
||||
|
||||
After install, ask the operator before flipping it:
|
||||
|
||||
> "Codex is installed. Default new agent groups to codex? Existing groups keep their current provider."
|
||||
|
||||
On yes — set it, then restart the host so it takes effect:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step set-env -- --key DEFAULT_AGENT_PROVIDER --value codex
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS; Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
This affects only groups created afterward. Per-group `ncl groups config update --provider` still overrides the default in either direction. Creation itself stays provider-agnostic (no `--provider` flag — provider is a DB property stamped from the instance default at creation).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ npx tsx scripts/init-first-agent.ts \
|
||||
--agent-name "${AGENT_NAME}"
|
||||
```
|
||||
|
||||
Add `--provider <name>` when the user picked a non-default provider (there is no install-wide default — the choice is explicit per group). Add `--welcome "System instruction: ..."` to override the default welcome prompt.
|
||||
The new group is created on the instance default provider (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). To put it on a different provider, switch after creation with `ncl groups config update --id <group-id> --provider <name>`. Add `--welcome "System instruction: ..."` to override the default welcome prompt.
|
||||
|
||||
The script:
|
||||
1. Upserts the `users` row and grants `owner` role if no owner exists.
|
||||
|
||||
@@ -67,7 +67,7 @@ pnpm exec tsx setup/index.ts --step register -- \
|
||||
|
||||
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name.
|
||||
|
||||
When creating a NEW agent group on a non-default provider, append `--provider <name>` (e.g. `--provider codex`) — there is no install-wide default; existing groups switch via `ncl groups config update --provider` instead.
|
||||
New agent groups are created on the instance default provider (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). To run a group on a different provider, switch it after creation with `ncl groups config update --provider <name>` (e.g. `codex`).
|
||||
|
||||
For separate agents, also ask for a folder name and optionally a different assistant name.
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ Parse the diff output for lines that contain `[BREAKING]` anywhere in the line.
|
||||
```
|
||||
|
||||
If no `[BREAKING]` lines are found:
|
||||
- Skip this step silently. Proceed to Step 7 (skill updates check).
|
||||
- Skip this step silently. Proceed to Step 7.
|
||||
|
||||
If one or more `[BREAKING]` lines are found:
|
||||
- Display a warning header to the user: "This update includes breaking changes that may require action:"
|
||||
@@ -244,7 +244,7 @@ If one or more `[BREAKING]` lines are found:
|
||||
- "Skip — I'll handle these manually"
|
||||
- Set `multiSelect: true` so the user can pick multiple skills if there are several breaking changes.
|
||||
- For each skill the user selects, invoke it using the Skill tool.
|
||||
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
|
||||
- After all selected skills complete (or if user chose Skip), proceed to Step 7.
|
||||
|
||||
# Step 7: Skill updates (part of updating NanoClaw)
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`. **The gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
|
||||
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
|
||||
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
|
||||
- **Provider choice is explicit per group — no install-wide default.** Provider is a DB property set via `ncl groups config update --provider` + restart; creation is provider-agnostic.
|
||||
- **New groups inherit an instance-wide default provider.** `DEFAULT_AGENT_PROVIDER` in `.env` (default `claude`) sets which provider newly created agent groups get at creation; provider stays a per-group DB property, overridable via `ncl groups config update --provider` + restart. Existing groups are untouched — no migration, no retroactive flips.
|
||||
- **Memory migrates via `/migrate-memory`, never at runtime.** Each provider keeps its own store; fresh groups on a surfaces-owning provider see no stale `CLAUDE.*` files. See [docs/provider-migration.md](docs/provider-migration.md).
|
||||
- **Per-exchange archiving is provider-owned** — the `onExchangeComplete` hook; the markdown writer ships with the codex payload.
|
||||
- **Container boot failures now say why** — the last stderr lines are logged at `warn` on a non-zero exit instead of a silent crash loop.
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
// level. Skills add a new provider by appending one import line below.
|
||||
|
||||
import './claude.js';
|
||||
import './mock.js';
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.27",
|
||||
"version": "2.1.24",
|
||||
"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="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 |
@@ -21,7 +21,6 @@ import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
|
||||
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import {
|
||||
createMessagingGroup,
|
||||
@@ -124,11 +123,11 @@ async function main(): Promise<void> {
|
||||
`# ${args.agentName}\n\n` +
|
||||
`You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` +
|
||||
'When the user first reaches out, introduce yourself briefly and invite them to chat. Keep replies concise.',
|
||||
// The operator's setup pick (NANOCLAW_PICKED_PROVIDER) when set; otherwise
|
||||
// undefined, so initGroupFilesystem falls back to the instance default and
|
||||
// stamps it onto the fresh config row.
|
||||
provider: pickedProvider,
|
||||
});
|
||||
// Runtime provider lives on the config row, not the deprecated agent_provider.
|
||||
if (pickedProvider && pickedProvider !== 'claude') {
|
||||
updateContainerConfigScalars(ag.id, { provider: pickedProvider });
|
||||
}
|
||||
|
||||
// 3. CLI messaging group + wiring.
|
||||
let cliMg: MessagingGroup | undefined = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID);
|
||||
|
||||
@@ -205,15 +205,13 @@ async function main(): Promise<void> {
|
||||
} else {
|
||||
console.log(`Reusing agent group: ${ag.id} (${folder})`);
|
||||
}
|
||||
// Ensure the config row exists; defer workspace scaffolding to the first
|
||||
// spawn (group-init), where the DB-resolved provider decides the surface
|
||||
// (Claude: CLAUDE.local.md; a surfaces-owning provider: the memory scaffold)
|
||||
// — so a non-Claude group never gets stale CLAUDE.* files written here.
|
||||
ensureContainerConfig(ag.id);
|
||||
// Runtime provider lives on the config row, not the deprecated agent_provider.
|
||||
if (pickedProvider && pickedProvider !== 'claude') {
|
||||
updateContainerConfigScalars(ag.id, { provider: pickedProvider });
|
||||
}
|
||||
// Seed the config row, stamped with the effective provider: the operator's
|
||||
// setup pick (NANOCLAW_PICKED_PROVIDER) when this runs inside a setup run,
|
||||
// otherwise the persisted instance default. Workspace scaffolding is deferred
|
||||
// to the first spawn (group-init), where the DB-resolved provider decides the
|
||||
// surface (Claude: CLAUDE.local.md; a surfaces-owning provider: the memory
|
||||
// scaffold). A reused group keeps its provider (INSERT OR IGNORE).
|
||||
ensureContainerConfig(ag.id, pickedProvider);
|
||||
const groupDir = path.resolve(GROUPS_DIR, folder);
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
|
||||
+16
-4
@@ -46,6 +46,7 @@ import './providers/index.js';
|
||||
import { brightSelect } from './lib/bright-select.js';
|
||||
import { offerClaudeOnFailure } from './lib/claude-handoff.js';
|
||||
import { setPickedProvider } from './lib/picked-provider.js';
|
||||
import { upsertEnvVar } from './set-env.js';
|
||||
import {
|
||||
applyToEnv,
|
||||
parseFlags,
|
||||
@@ -65,6 +66,7 @@ import { ensureAnswer, fail, runQuietChild, runQuietStep, spawnQuiet } from './l
|
||||
import { emit as phEmit } from './lib/diagnostics.js';
|
||||
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
|
||||
import { isValidTimezone } from '../src/timezone.js';
|
||||
import { DEFAULT_AGENT_PROVIDER } from '../src/config.js';
|
||||
|
||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||
const RUN_START = Date.now();
|
||||
@@ -375,6 +377,12 @@ async function main(): Promise<void> {
|
||||
} else {
|
||||
await runAuthStep();
|
||||
}
|
||||
// Persist the pick as the instance-wide default so every future group
|
||||
// (channel-approved, ncl-created) is created on this provider. Read from
|
||||
// .env at host start; per-group `ncl groups config update --provider` wins.
|
||||
// Only after install + auth succeeded — a failed setup must not leave new
|
||||
// groups defaulting to an unauthenticated runtime.
|
||||
upsertEnvVar('DEFAULT_AGENT_PROVIDER', agentProvider);
|
||||
}
|
||||
|
||||
if (!skip.has('mounts')) {
|
||||
@@ -827,14 +835,18 @@ async function askAgentProviderChoice(): Promise<string> {
|
||||
phEmit('agent_provider_chosen', { provider: preset, preset: true });
|
||||
return preset;
|
||||
}
|
||||
// The pick installs and authenticates a runtime — it is not an
|
||||
// install-wide default, so re-runs safely Enter-through on claude (its
|
||||
// auth flow short-circuits when the secret already exists).
|
||||
// The pick is persisted as the instance default (DEFAULT_AGENT_PROVIDER), so
|
||||
// pre-select the current default — a re-run Enter-through then preserves it
|
||||
// instead of silently resetting it to claude. Fall back to claude if the
|
||||
// persisted default isn't an offered option (e.g. its provider was removed).
|
||||
const currentDefault = options.some((o) => o.value === DEFAULT_AGENT_PROVIDER)
|
||||
? DEFAULT_AGENT_PROVIDER
|
||||
: 'claude';
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<string>({
|
||||
message: 'Which agent runtime should power your assistant?',
|
||||
options,
|
||||
initialValue: 'claude',
|
||||
initialValue: currentDefault,
|
||||
}),
|
||||
) as string;
|
||||
setupLog.userInput('agent_provider', choice);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* The agent runtime the operator picked in THIS setup run.
|
||||
* The agent runtime the operator picked in THIS setup run, carried to the
|
||||
* group-creation child processes over the process boundary.
|
||||
*
|
||||
* There is no install-wide default provider and no `--provider` in the
|
||||
* creation contract — provider is a DB property of a group. Setup is the one
|
||||
* orchestrator that knows the operator's pick, so it stashes it here (set once
|
||||
* at the auth step). The group-creation scripts (`init-first-agent`,
|
||||
* `init-cli-agent`) run as **child processes**, so the pick is carried over the
|
||||
* process boundary via an environment variable they inherit; they apply it to
|
||||
* the group at creation, before the welcome wakes the container. This is the
|
||||
* only place the value lives — a setup-run-scoped global, NOT a persisted
|
||||
* install default. `undefined` / `'claude'` means the built-in default and no
|
||||
* provider write at all.
|
||||
* There is no `--provider` flag in the creation contract — provider is a DB
|
||||
* property of a group. Setup persists the pick two ways: as the install-wide
|
||||
* default (`DEFAULT_AGENT_PROVIDER` in `.env`, see src/config.ts), which every
|
||||
* future group inherits at creation via the `ensureContainerConfig` chokepoint;
|
||||
* and here, in a setup-run-scoped env var, so the FIRST agent created in the
|
||||
* same run (by `init-first-agent` / `init-cli-agent`, which run as child
|
||||
* processes) is stamped with the pick before the welcome wakes the container —
|
||||
* without waiting for the host to restart and reload `.env`. `undefined` /
|
||||
* `'claude'` means no run-scoped pick; the creation scripts then fall back to
|
||||
* the install-wide default.
|
||||
*/
|
||||
const ENV_KEY = 'NANOCLAW_PICKED_PROVIDER';
|
||||
|
||||
|
||||
@@ -55,12 +55,19 @@ describe('setup carries the picked provider to creation via a setup-run env var'
|
||||
// The creation scripts run as child processes, inherit the env var, and apply
|
||||
// it to the group's runtime config — container_configs.provider, the source of
|
||||
// truth materialized into container.json (agent_provider is deprecated) — before
|
||||
// the welcome wakes the container. No `--provider` flag in the contract (above).
|
||||
for (const file of ['scripts/init-first-agent.ts', 'scripts/init-cli-agent.ts']) {
|
||||
// the welcome wakes the container, falling back to the instance default
|
||||
// (DEFAULT_AGENT_PROVIDER) when the env var is unset. No `--provider` flag in
|
||||
// the contract (above). init-first-agent stamps directly via
|
||||
// ensureContainerConfig; init-cli-agent threads it through initGroupFilesystem.
|
||||
const applyPattern: Record<string, RegExp> = {
|
||||
'scripts/init-first-agent.ts': /ensureContainerConfig\([^)]*pickedProvider/,
|
||||
'scripts/init-cli-agent.ts': /provider:\s*pickedProvider/,
|
||||
};
|
||||
for (const [file, pattern] of Object.entries(applyPattern)) {
|
||||
it(`${file} applies the env-carried provider to container_configs.provider`, () => {
|
||||
const src = read(file);
|
||||
expect(src).toContain('NANOCLAW_PICKED_PROVIDER');
|
||||
expect(src).toMatch(/updateContainerConfigScalars\([^)]*provider:\s*pickedProvider/);
|
||||
expect(src).toMatch(pattern);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+6
-5
@@ -126,11 +126,12 @@ export async function run(args: string[]): Promise<void> {
|
||||
const db = initDb(dbPath);
|
||||
runMigrations(db);
|
||||
|
||||
// 1. Create or find agent group. Provider-agnostic: provider is a DB
|
||||
// property set via `ncl groups config update --provider`, not a creation
|
||||
// flag. The workspace is scaffolded at the first spawn (group-init), where
|
||||
// the DB-resolved provider is known; here we only ensure the config row
|
||||
// exists so that update has a row to write.
|
||||
// 1. Create or find agent group. The workspace is scaffolded at the first
|
||||
// spawn (group-init), where the DB-resolved provider is known; here we only
|
||||
// seed the config row — stamped with the instance default so a newly wired
|
||||
// channel group is created on the operator's chosen provider (per-group
|
||||
// `ncl groups config update --provider` still overrides). A reused group
|
||||
// keeps its existing provider (INSERT OR IGNORE).
|
||||
let agentGroup = getAgentGroupByFolder(parsed.folder);
|
||||
if (!agentGroup) {
|
||||
const agId = generateId('ag');
|
||||
|
||||
+31
-25
@@ -18,6 +18,34 @@ import path from 'path';
|
||||
import { log } from '../src/log.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
/**
|
||||
* Upsert a `KEY=VALUE` line into the project's `.env`, returning whether the
|
||||
* key already existed. The canonical writer for new `.env` edits (legacy setup
|
||||
* steps still write directly) so flows don't invent grep/sed pipelines (which
|
||||
* can't be allowlisted tightly).
|
||||
*/
|
||||
export function upsertEnvVar(key: string, value: string): { existed: boolean } {
|
||||
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
|
||||
throw new Error(`Invalid env key: ${key} (must be UPPER_SNAKE_CASE)`);
|
||||
}
|
||||
const envFile = path.join(process.cwd(), '.env');
|
||||
let content = '';
|
||||
if (fs.existsSync(envFile)) {
|
||||
content = fs.readFileSync(envFile, 'utf-8');
|
||||
}
|
||||
const lineRegex = new RegExp(`^${key}=.*$`, 'm');
|
||||
const existed = lineRegex.test(content);
|
||||
const newLine = `${key}=${value}`;
|
||||
if (existed) {
|
||||
content = content.replace(lineRegex, newLine);
|
||||
} else {
|
||||
const sep = content && !content.endsWith('\n') ? '\n' : '';
|
||||
content = content + sep + newLine + '\n';
|
||||
}
|
||||
fs.writeFileSync(envFile, content);
|
||||
return { existed };
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const keyIdx = args.indexOf('--key');
|
||||
const valueIdx = args.indexOf('--value');
|
||||
@@ -33,37 +61,15 @@ export async function run(args: string[]): Promise<void> {
|
||||
const key = args[keyIdx + 1];
|
||||
const value = args[valueIdx + 1];
|
||||
|
||||
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
|
||||
throw new Error(`Invalid env key: ${key} (must be UPPER_SNAKE_CASE)`);
|
||||
}
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const envFile = path.join(projectRoot, '.env');
|
||||
|
||||
let content = '';
|
||||
if (fs.existsSync(envFile)) {
|
||||
content = fs.readFileSync(envFile, 'utf-8');
|
||||
}
|
||||
|
||||
const lineRegex = new RegExp(`^${key}=.*$`, 'm');
|
||||
const newLine = `${key}=${value}`;
|
||||
const existed = lineRegex.test(content);
|
||||
|
||||
if (existed) {
|
||||
content = content.replace(lineRegex, newLine);
|
||||
} else {
|
||||
const sep = content && !content.endsWith('\n') ? '\n' : '';
|
||||
content = content + sep + newLine + '\n';
|
||||
}
|
||||
|
||||
fs.writeFileSync(envFile, content);
|
||||
const { existed } = upsertEnvVar(key, value);
|
||||
log.info('Updated .env', { key, existed });
|
||||
|
||||
let synced = false;
|
||||
if (syncContainer) {
|
||||
const projectRoot = process.cwd();
|
||||
const dataEnvDir = path.join(projectRoot, 'data', 'env');
|
||||
fs.mkdirSync(dataEnvDir, { recursive: true });
|
||||
fs.copyFileSync(envFile, path.join(dataEnvDir, 'env'));
|
||||
fs.copyFileSync(path.join(projectRoot, '.env'), path.join(dataEnvDir, 'env'));
|
||||
synced = true;
|
||||
log.info('Synced .env to container mount', { path: 'data/env/env' });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getDb, hasTable } from '../../db/connection.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import {
|
||||
ensureContainerConfig,
|
||||
getContainerConfig,
|
||||
updateContainerConfigScalars,
|
||||
updateContainerConfigJson,
|
||||
@@ -90,6 +91,11 @@ registerResource({
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
createAgentGroup(group);
|
||||
// Seed the config row now so the group is created on the instance
|
||||
// default (ensureContainerConfig stamps it) and is spawnable without
|
||||
// waiting for the startup backfill. Per-group overrides via
|
||||
// `groups config update --provider` still win.
|
||||
ensureContainerConfig(group.id);
|
||||
return group;
|
||||
},
|
||||
},
|
||||
|
||||
+19
-1
@@ -6,9 +6,27 @@ import { getContainerImageBase, getDefaultContainerImage, getInstallSlug } from
|
||||
import { isValidTimezone } from './timezone.js';
|
||||
|
||||
// Read config values from .env (falls back to process.env).
|
||||
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', 'ONECLI_API_KEY', 'TZ']);
|
||||
const envConfig = readEnvFile([
|
||||
'ASSISTANT_NAME',
|
||||
'ASSISTANT_HAS_OWN_NUMBER',
|
||||
'ONECLI_URL',
|
||||
'ONECLI_API_KEY',
|
||||
'TZ',
|
||||
'DEFAULT_AGENT_PROVIDER',
|
||||
]);
|
||||
|
||||
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||
|
||||
// Instance-wide default agent provider for newly created groups. `claude` (the
|
||||
// built-in provider) when unset, so existing installs are unaffected on upgrade.
|
||||
// Applied only at group-creation time (stamped onto the config row) — never in
|
||||
// provider resolution — so existing groups are never retroactively flipped.
|
||||
// Per-group `ncl groups config update --provider` still overrides it.
|
||||
export const DEFAULT_AGENT_PROVIDER = (
|
||||
process.env.DEFAULT_AGENT_PROVIDER ||
|
||||
envConfig.DEFAULT_AGENT_PROVIDER ||
|
||||
'claude'
|
||||
).toLowerCase();
|
||||
export const ASSISTANT_HAS_OWN_NUMBER =
|
||||
(process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* ensureContainerConfig provider stamping (global-default-provider feature).
|
||||
*
|
||||
* Two load-bearing guarantees:
|
||||
* 1. A fresh row is stamped with the given provider (claude → NULL), so a new
|
||||
* group is created on the instance default.
|
||||
* 2. An existing row is never overwritten (INSERT OR IGNORE), so enabling a
|
||||
* non-claude default never retroactively flips existing groups.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { initTestDb, closeDb } from './connection.js';
|
||||
import { runMigrations } from './migrations/index.js';
|
||||
import { createAgentGroup } from './agent-groups.js';
|
||||
import { ensureContainerConfig, getContainerConfig } from './container-configs.js';
|
||||
|
||||
function makeGroup(id: string): void {
|
||||
createAgentGroup({ id, name: id, folder: id, agent_provider: null, created_at: new Date().toISOString() });
|
||||
}
|
||||
|
||||
describe('ensureContainerConfig provider stamping', () => {
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
});
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
it('stamps a non-default provider on a fresh row; claude is stored as NULL', () => {
|
||||
makeGroup('ag-codex');
|
||||
ensureContainerConfig('ag-codex', 'codex');
|
||||
expect(getContainerConfig('ag-codex')?.provider).toBe('codex');
|
||||
|
||||
makeGroup('ag-claude');
|
||||
ensureContainerConfig('ag-claude', 'claude');
|
||||
expect(getContainerConfig('ag-claude')?.provider).toBeNull();
|
||||
|
||||
// Casing is normalized to match what resolution lowercases to.
|
||||
makeGroup('ag-cased');
|
||||
ensureContainerConfig('ag-cased', 'Codex');
|
||||
expect(getContainerConfig('ag-cased')?.provider).toBe('codex');
|
||||
|
||||
makeGroup('ag-cased-claude');
|
||||
ensureContainerConfig('ag-cased-claude', 'Claude');
|
||||
expect(getContainerConfig('ag-cased-claude')?.provider).toBeNull();
|
||||
});
|
||||
|
||||
it('never overwrites an existing row — existing groups are not flipped', () => {
|
||||
makeGroup('ag-existing');
|
||||
ensureContainerConfig('ag-existing', 'codex'); // existing group already on codex
|
||||
expect(getContainerConfig('ag-existing')?.provider).toBe('codex');
|
||||
|
||||
// A later bare ensure (defensive re-init, or a changed instance default)
|
||||
// must NOT change it — INSERT OR IGNORE keeps the row frozen.
|
||||
ensureContainerConfig('ag-existing');
|
||||
expect(getContainerConfig('ag-existing')?.provider).toBe('codex');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_AGENT_PROVIDER } from '../config.js';
|
||||
import type { ContainerConfigRow } from '../types.js';
|
||||
import { getDb } from './connection.js';
|
||||
|
||||
@@ -39,14 +40,36 @@ export function createContainerConfig(config: ContainerConfigRow): void {
|
||||
.run(config);
|
||||
}
|
||||
|
||||
/** Create an empty config row with sensible defaults. Idempotent — no-ops if row exists. */
|
||||
export function ensureContainerConfig(agentGroupId: string): void {
|
||||
/**
|
||||
* Create a config row if one doesn't exist, stamping the provider. Idempotent —
|
||||
* no-ops if the row already exists, so an existing group's provider is never
|
||||
* overwritten (load-bearing: this is how the global default stays "new groups
|
||||
* only" for groups that already have a row).
|
||||
*
|
||||
* An absent `provider` takes the instance default (`DEFAULT_AGENT_PROVIDER`);
|
||||
* `claude` and an absent value that resolves to claude are stored as NULL — the
|
||||
* column means "follows the built-in default", matching pre-feature rows.
|
||||
*/
|
||||
export function ensureContainerConfig(agentGroupId: string, provider?: string | null): void {
|
||||
// Single chokepoint for the instance default: a fresh row with no explicit
|
||||
// provider is stamped with DEFAULT_AGENT_PROVIDER, so every new-group creation
|
||||
// path inherits it without each having to remember. INSERT OR IGNORE keeps an
|
||||
// EXISTING row untouched — so this stays "new groups only" for any group that
|
||||
// already has a config row (backfillContainerConfigs seeds one for every group
|
||||
// at host startup; a non-claude default would only reach a row-less *legacy*
|
||||
// group if a creation script reused it before that first backfill ran). Callers
|
||||
// that know the provider (subagent → parent's, spawn → resolved) pass it
|
||||
// explicitly and override the default.
|
||||
// `claude` (the built-in default) and casing normalize to NULL/lowercase so the
|
||||
// column matches what resolution lowercases to.
|
||||
const normalized = (provider ?? DEFAULT_AGENT_PROVIDER).toLowerCase();
|
||||
const stamped = normalized && normalized !== 'claude' ? normalized : null;
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO container_configs (agent_group_id, updated_at)
|
||||
VALUES (?, ?)`,
|
||||
`INSERT OR IGNORE INTO container_configs (agent_group_id, provider, updated_at)
|
||||
VALUES (?, ?, ?)`,
|
||||
)
|
||||
.run(agentGroupId, new Date().toISOString());
|
||||
.run(agentGroupId, stamped, new Date().toISOString());
|
||||
}
|
||||
|
||||
/** Update scalar fields on a config row. Only touches fields present in `updates`. */
|
||||
|
||||
+17
-8
@@ -1,7 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR, GROUPS_DIR } from './config.js';
|
||||
import { DATA_DIR, DEFAULT_AGENT_PROVIDER, GROUPS_DIR } from './config.js';
|
||||
import { ensureContainerConfig } from './db/container-configs.js';
|
||||
import { log } from './log.js';
|
||||
import { providerProvidesAgentSurfaces } from './providers/provider-container-registry.js';
|
||||
@@ -53,11 +53,18 @@ export function initGroupFilesystem(
|
||||
): void {
|
||||
const initialized: string[] = [];
|
||||
|
||||
// Default agent surfaces apply unless the group's provider declares (at
|
||||
// registration) that it provides its own. Callers that don't know the
|
||||
// provider omit it — unregistered/unknown names report no capabilities,
|
||||
// so the default surfaces are written, exactly as before this seam.
|
||||
const defaultSurfaces = !providerProvidesAgentSurfaces(opts?.provider);
|
||||
// `opts.provider` absent means "caller has no provider opinion" — for a
|
||||
// brand-new group that resolves to the instance default, so the scaffold and
|
||||
// the stamped config row both match it. A caller that knows the provider
|
||||
// (subagent → parent's, spawn → resolved, setup → operator's pick) passes it
|
||||
// explicitly — including `claude` — which pins the group and skips the
|
||||
// default. ensureContainerConfig is INSERT OR IGNORE, so this only stamps a
|
||||
// genuinely new group; existing rows are never touched.
|
||||
const providerHint = (opts?.provider ?? DEFAULT_AGENT_PROVIDER).toLowerCase();
|
||||
|
||||
// Default agent surfaces apply unless the provider declares (at registration)
|
||||
// that it provides its own.
|
||||
const defaultSurfaces = !providerProvidesAgentSurfaces(providerHint);
|
||||
|
||||
// 1. groups/<folder>/ — group memory + working dir
|
||||
const groupDir = path.resolve(GROUPS_DIR, group.folder);
|
||||
@@ -106,8 +113,10 @@ export function initGroupFilesystem(
|
||||
}
|
||||
|
||||
// Ensure container_configs row exists in the DB. Idempotent — no-op if
|
||||
// the row already exists (e.g. created by backfill or group creation).
|
||||
ensureContainerConfig(group.id);
|
||||
// the row already exists (e.g. created by backfill or group creation). On a
|
||||
// fresh row, stamp the resolved provider hint so a new group is created on
|
||||
// the instance default (or the caller's explicit pick).
|
||||
ensureContainerConfig(group.id, providerHint);
|
||||
initialized.push('container_configs');
|
||||
|
||||
// 2. data/v2-sessions/<id>/.claude-shared/ — Claude state + per-group skills
|
||||
|
||||
@@ -16,7 +16,6 @@ const mockRequestApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const mockGetContainerConfig = vi.fn();
|
||||
const mockCreateAgentGroup = vi.fn();
|
||||
const mockInitGroupFilesystem = vi.fn();
|
||||
const mockUpdateScalars = vi.fn();
|
||||
const mockWriteDestinations = vi.fn();
|
||||
const mockNotifyWrite = vi.fn();
|
||||
|
||||
@@ -26,7 +25,6 @@ vi.mock('../approvals/index.js', () => ({
|
||||
vi.mock('../../db/container-configs.js', () => ({
|
||||
getContainerConfig: (...a: unknown[]) => mockGetContainerConfig(...a),
|
||||
ensureContainerConfig: () => {},
|
||||
updateContainerConfigScalars: (...a: unknown[]) => mockUpdateScalars(...a),
|
||||
}));
|
||||
vi.mock('../../db/agent-groups.js', () => ({
|
||||
getAgentGroup: (id: string) => ({ id, name: id.toUpperCase(), folder: id, agent_provider: null, created_at: '' }),
|
||||
@@ -80,8 +78,10 @@ describe('handleCreateAgent — scope-based authorization', () => {
|
||||
|
||||
it('child inherits the creator provider (codex parent → codex child)', async () => {
|
||||
// A subagent must run on the same authenticated runtime as its creator —
|
||||
// on a codex-only install a claude default would 401. Red-on-delete:
|
||||
// dropping the inheritance leaves the child provider-less (→ claude).
|
||||
// on a codex-only install a claude default would 401. The provider is
|
||||
// passed to initGroupFilesystem, which stamps the child's config row.
|
||||
// Red-on-delete: dropping the inheritance lets the child fall through to the
|
||||
// instance default instead of codex.
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global', provider: 'codex' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
@@ -90,15 +90,19 @@ describe('handleCreateAgent — scope-based authorization', () => {
|
||||
expect.anything(),
|
||||
expect.objectContaining({ provider: 'codex' }),
|
||||
);
|
||||
expect(mockUpdateScalars).toHaveBeenCalledWith(expect.any(String), { provider: 'codex' });
|
||||
});
|
||||
|
||||
it('claude creator leaves the child provider unset (built-in default)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); // no provider
|
||||
it('claude creator pins the child to claude, not the instance default', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); // parent has no explicit provider
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
|
||||
expect(mockUpdateScalars).not.toHaveBeenCalled();
|
||||
// The child inherits the parent's EFFECTIVE provider (claude), passed
|
||||
// explicitly so it never falls through to a non-claude instance default.
|
||||
expect(mockInitGroupFilesystem).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ provider: 'claude' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('group scope (default): requires approval, does NOT create directly', async () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from '../../config.js';
|
||||
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder } from '../../db/agent-groups.js';
|
||||
import { getContainerConfig, updateContainerConfigScalars } from '../../db/container-configs.js';
|
||||
import { getContainerConfig } from '../../db/container-configs.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { initGroupFilesystem } from '../../group-init.js';
|
||||
@@ -163,17 +163,15 @@ async function performCreateAgent(
|
||||
created_at: now,
|
||||
};
|
||||
createAgentGroup(newGroup);
|
||||
// A subagent inherits its creator's provider. Provider is a DB property; the
|
||||
// child is created provider-agnostic, then stamped with the parent's runtime
|
||||
// so a single-provider install (e.g. codex-only, where claude isn't
|
||||
// authenticated) doesn't spawn a child on a runtime it can't reach. The
|
||||
// Subagent path: a child inherits its creator's EFFECTIVE provider, NOT the
|
||||
// instance-wide default — so a child is never spawned on a runtime the parent
|
||||
// can't reach (e.g. a codex-only install where claude isn't authenticated).
|
||||
// Passing it explicitly to initGroupFilesystem pins the child's scaffold and
|
||||
// stamps its config row in one step (a NULL parent resolves to claude). The
|
||||
// operator can still flip a child later with `ncl groups config update
|
||||
// --provider`. claude (the built-in default) leaves the column unset.
|
||||
const parentProvider = getContainerConfig(sourceGroup.id)?.provider ?? undefined;
|
||||
// --provider`.
|
||||
const parentProvider = getContainerConfig(sourceGroup.id)?.provider ?? 'claude';
|
||||
initGroupFilesystem(newGroup, { instructions: instructions ?? undefined, provider: parentProvider });
|
||||
if (parentProvider) {
|
||||
updateContainerConfigScalars(newGroup.id, { provider: parentProvider });
|
||||
}
|
||||
|
||||
// Insert bidirectional destination rows (= ACL grants).
|
||||
// Creator refers to child by the name it chose; child refers to creator as "parent".
|
||||
|
||||
@@ -254,46 +254,16 @@ async function sweepStaleApprovals(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** The hosted gateway's structured request summary — not yet in the SDK's
|
||||
* ApprovalRequest type (observed on api.onecli.sh, 2026-07): the action being
|
||||
* performed plus labeled fields (To / Subject / Body for email sends). */
|
||||
interface ApprovalSummary {
|
||||
action?: string;
|
||||
details?: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
const SUMMARY_VALUE_EXCERPT_CHARS = 900;
|
||||
|
||||
function buildQuestion(request: ApprovalRequest, agentName: string): string {
|
||||
const lines = [`*Agent:* ${agentName}`];
|
||||
|
||||
const summary = (request as ApprovalRequest & { summary?: ApprovalSummary }).summary;
|
||||
if (summary?.details?.length) {
|
||||
if (summary.action) lines.push(`*Action:* ${summary.action}`);
|
||||
// A render bug here must never decide the request: handleRequest's catch
|
||||
// returns 'deny', so stay defensive — coerce non-string values instead of
|
||||
// assuming the gateway's shape, and keep the card under Slack's 3000-char
|
||||
// section limit or delivery itself fails.
|
||||
let budget = 2600;
|
||||
for (const { label, value } of summary.details) {
|
||||
const raw = typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value));
|
||||
const cap = Math.min(SUMMARY_VALUE_EXCERPT_CHARS, Math.max(0, budget));
|
||||
if (cap === 0) {
|
||||
lines.push(`_…${summary.details.length} field(s) omitted for length — see the audit payload._`);
|
||||
break;
|
||||
}
|
||||
const v = raw.length > cap ? `${raw.slice(0, cap)}…` : raw;
|
||||
budget -= v.length + String(label).length + 8;
|
||||
// Multi-line values (message bodies) read better fenced; short labeled
|
||||
// fields (To, Subject) inline.
|
||||
if (v.includes('\n')) lines.push(`*${label}:*`, '```', v, '```');
|
||||
else lines.push(`*${label}:* ${v}`);
|
||||
}
|
||||
} else if (request.bodyPreview) {
|
||||
lines.push('```', request.bodyPreview.slice(0, SUMMARY_VALUE_EXCERPT_CHARS * 2), '```');
|
||||
lines.push(`_${request.method} ${request.host}${request.path}_`);
|
||||
} else {
|
||||
lines.push(`_${request.method} ${request.host}${request.path}_`);
|
||||
const lines = [
|
||||
'Credential access request',
|
||||
`Agent: ${agentName}`,
|
||||
'```',
|
||||
`${request.method} ${request.host}${request.path}`,
|
||||
'```',
|
||||
];
|
||||
if (request.bodyPreview) {
|
||||
lines.push('Body:', '```', request.bodyPreview, '```');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -292,8 +292,10 @@ export function createNewAgentGroup(name: string): AgentGroup {
|
||||
});
|
||||
|
||||
const ag = getAgentGroup(agId)!;
|
||||
// Channel-approved groups get the built-in default provider (claude); the
|
||||
// operator flips a group with `ncl groups config update --provider`.
|
||||
// Channel-approved groups are created on the instance default provider
|
||||
// (DEFAULT_AGENT_PROVIDER, or claude when unset) — initGroupFilesystem stamps
|
||||
// it onto the fresh config row. The operator flips a group afterward with
|
||||
// `ncl groups config update --provider`.
|
||||
initGroupFilesystem(ag);
|
||||
return ag;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user