mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-12 19:00:58 +08:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a30547fb6a | |||
| 27de55647b | |||
| 6cd15c6d13 | |||
| 40a21fe8f3 | |||
| 91d30713b5 | |||
| 1ad258622b | |||
| e370cae4e7 | |||
| e40d43a43b | |||
| 642c5ecd37 | |||
| 0bb9016e94 | |||
| f797024770 | |||
| bea2dab171 | |||
| 53ffa3d347 | |||
| 8d39368352 | |||
| 59fc8e3cc7 | |||
| 45f656f21c | |||
| a4513f292f | |||
| 38eb5a44df | |||
| 6d397fc116 | |||
| cb2bbcc7aa | |||
| b9998a4bf1 | |||
| 0d41f0ef94 | |||
| 3363a0be0f | |||
| 05cf44035e | |||
| c1eb1079cc | |||
| 1725d86fbd | |||
| e3bd22f96f | |||
| c2e5b14bf8 | |||
| 1ccef326dc | |||
| 39e7604526 | |||
| c0e591f2fb | |||
| df4929d61d | |||
| 14f528c895 | |||
| 366c5f06f1 | |||
| 077ce6fd9d | |||
| 0c0f4c2592 | |||
| 92635f1934 | |||
| f7a43ef881 | |||
| 5459925c65 | |||
| b0c76ce4c5 | |||
| 1985fd6a5e | |||
| 0820d39424 | |||
| c752f9c065 | |||
| 0e73e4a782 |
@@ -18,6 +18,12 @@ function normTs(ts) {
|
||||
return `${ts.replace(' ', 'T')}Z`;
|
||||
}
|
||||
|
||||
// Local calendar day "YYYY-MM-DD" — chart labels are read by a human, so
|
||||
// bucket by the server's local day, not the UTC date prefix.
|
||||
function localDay(date) {
|
||||
return date.toLocaleDateString('sv-SE');
|
||||
}
|
||||
|
||||
function readTable(dbPath, table) {
|
||||
let db;
|
||||
try {
|
||||
@@ -28,7 +34,7 @@ function readTable(dbPath, table) {
|
||||
for (const r of rows) {
|
||||
const ts = normTs(r.timestamp);
|
||||
if (!ts) continue;
|
||||
const day = ts.slice(0, 10); // ISO date prefix
|
||||
const day = localDay(new Date(ts));
|
||||
byDay.set(day, (byDay.get(day) ?? 0) + 1);
|
||||
if (last === null || ts > last) last = ts;
|
||||
}
|
||||
@@ -52,12 +58,12 @@ function listDirs(path) {
|
||||
* Aggregate message activity across all session DBs under `sessionsRoot`.
|
||||
* @returns {{ sessions: Array, series: Array<{date,in,out}> }}
|
||||
* sessions — per session: { agent_group_id, session_id, in, out, lastActivity }
|
||||
* series — one bucket per day for the last `days` days (UTC, newest last)
|
||||
* series — one bucket per day for the last `days` days (local time, newest last)
|
||||
*/
|
||||
export function collectActivity(sessionsRoot, days, now) {
|
||||
const dates = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
dates.push(new Date(now.getTime() - i * 86_400_000).toISOString().slice(0, 10));
|
||||
dates.push(localDay(new Date(now.getTime() - i * 86_400_000)));
|
||||
}
|
||||
const series = new Map(dates.map((d) => [d, { date: d, in: 0, out: 0 }]));
|
||||
const sessions = [];
|
||||
|
||||
@@ -71,6 +71,14 @@ function icon(name) {
|
||||
|
||||
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
|
||||
|
||||
// Local wall time "YYYY-MM-DD HH:mm" — the raw ISO string is UTC; slicing it
|
||||
// would display UTC wall time with no marker, masquerading as local.
|
||||
function absTime(iso) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
function relTime(iso) {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (Number.isNaN(ms)) return iso;
|
||||
@@ -127,7 +135,7 @@ function cellFor(value) {
|
||||
if (f.cls === 'null') return el('td', { class: 'null' }, 'null');
|
||||
if (f.iso) {
|
||||
return el('td', {}, el('span', { class: 'reltime', title: f.iso }, [
|
||||
relTime(f.iso), el('span', { class: 'abs' }, f.iso.slice(0, 16).replace('T', ' ')),
|
||||
relTime(f.iso), el('span', { class: 'abs' }, absTime(f.iso)),
|
||||
]));
|
||||
}
|
||||
if (f.text.length > 42) {
|
||||
@@ -142,7 +150,7 @@ function kvRows(obj) {
|
||||
return Object.entries(obj ?? {}).map(([k, v]) => {
|
||||
let valEl;
|
||||
if (v && typeof v === 'object') valEl = el('pre', { class: 'kv-json' }, JSON.stringify(v, null, 2));
|
||||
else if (typeof v === 'string' && ISO_RE.test(v)) valEl = el('span', { class: 'reltime', title: v }, `${relTime(v)} (${v.slice(0, 16).replace('T', ' ')})`);
|
||||
else if (typeof v === 'string' && ISO_RE.test(v)) valEl = el('span', { class: 'reltime', title: v }, `${relTime(v)} (${absTime(v)})`);
|
||||
else if (v === null || v === undefined) valEl = el('span', { class: 'null' }, 'null');
|
||||
else valEl = el('span', {}, String(v));
|
||||
return el('div', { class: 'kv-row' }, [el('span', { class: 'kv-key' }, k), valEl]);
|
||||
|
||||
@@ -11,9 +11,14 @@ before(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'clidash-actsrv-'));
|
||||
mkdirSync(join(root, 'ag-1', 'sess-1'), { recursive: true });
|
||||
const mk = (p, t, ts) => { const db = new DatabaseSync(p); db.exec(`CREATE TABLE ${t}(id TEXT, timestamp TEXT)`); const i = db.prepare(`INSERT INTO ${t} VALUES (?,?)`); ts.forEach((x, n) => i.run(String(n), x)); db.close(); };
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
mk(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in', [`${today} 09:00:00`, `${today} 10:00:00`]);
|
||||
mk(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out', [`${today} 09:05:00`]);
|
||||
// Seed with instants moments ago: they land in the LOCAL-today bucket
|
||||
// (series.at(-1)) regardless of the machine's timezone.
|
||||
const now = Date.now();
|
||||
mk(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in', [
|
||||
new Date(now - 120_000).toISOString(),
|
||||
new Date(now - 60_000).toISOString(),
|
||||
]);
|
||||
mk(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out', [new Date(now - 90_000).toISOString()]);
|
||||
});
|
||||
after(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
|
||||
@@ -45,20 +45,30 @@ test('collectActivity: per-session in/out totals + last activity', () => {
|
||||
assert.equal(s2.out, 0);
|
||||
});
|
||||
|
||||
// Buckets are LOCAL calendar days — derive expectations with the same
|
||||
// mapping so the assertions hold in any machine timezone.
|
||||
const day = (t) => new Date(t).toLocaleDateString('sv-SE');
|
||||
|
||||
test('collectActivity: series has one bucket per day for `days`, newest last', () => {
|
||||
const { series } = collectActivity(root, 14, NOW);
|
||||
assert.equal(series.length, 14);
|
||||
assert.equal(series[0].date, '2026-06-01');
|
||||
assert.equal(series[13].date, '2026-06-14');
|
||||
assert.equal(series[0].date, day(new Date(NOW.getTime() - 13 * 86_400_000)));
|
||||
assert.equal(series[13].date, day(NOW));
|
||||
});
|
||||
|
||||
test('collectActivity: counts land in the right day buckets', () => {
|
||||
const { series } = collectActivity(root, 14, NOW);
|
||||
const byDate = Object.fromEntries(series.map((d) => [d.date, d]));
|
||||
assert.equal(byDate['2026-06-14'].in, 2);
|
||||
assert.equal(byDate['2026-06-14'].out, 2);
|
||||
assert.equal(byDate['2026-06-13'].in, 1);
|
||||
assert.equal(byDate['2026-06-13'].out, 0);
|
||||
const expIn = {};
|
||||
const expOut = {};
|
||||
for (const t of ['2026-06-14T09:01:23Z', '2026-06-14T10:00:00Z', '2026-06-13T08:00:00Z']) {
|
||||
expIn[day(t)] = (expIn[day(t)] ?? 0) + 1;
|
||||
}
|
||||
for (const t of ['2026-06-14T09:05:00Z', '2026-06-14T10:05:00Z']) {
|
||||
expOut[day(t)] = (expOut[day(t)] ?? 0) + 1;
|
||||
}
|
||||
for (const [d, n] of Object.entries(expIn)) assert.equal(byDate[d].in, n);
|
||||
for (const [d, n] of Object.entries(expOut)) assert.equal(byDate[d].out, n);
|
||||
});
|
||||
|
||||
test('collectActivity: messages outside the window are counted in totals but not the series', () => {
|
||||
|
||||
@@ -427,12 +427,20 @@ function collectContextWindows() {
|
||||
return results;
|
||||
}
|
||||
|
||||
// "YYYY-MM-DDTHH" in the host's local time — the chart's hour labels are read
|
||||
// by a human, so bucket by local hour, not UTC. sv-SE renders "YYYY-MM-DD HH".
|
||||
function localHourKey(d: Date): string {
|
||||
return d
|
||||
.toLocaleString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false })
|
||||
.replace(' ', 'T');
|
||||
}
|
||||
|
||||
function collectActivity() {
|
||||
const now = Date.now();
|
||||
const buckets: Record<string, { inbound: number; outbound: number }> = {};
|
||||
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const key = new Date(now - i * 3600000).toISOString().slice(0, 13);
|
||||
const key = localHourKey(new Date(now - i * 3600000));
|
||||
buckets[key] = { inbound: 0, outbound: 0 };
|
||||
}
|
||||
|
||||
@@ -453,7 +461,7 @@ function collectActivity() {
|
||||
const table = direction === 'outbound' ? 'messages_out' : 'messages_in';
|
||||
const rows = db.prepare(`SELECT timestamp FROM ${table} WHERE timestamp > ?`).all(cutoff) as { timestamp: string }[];
|
||||
for (const row of rows) {
|
||||
const key = row.timestamp.slice(0, 13);
|
||||
const key = localHourKey(new Date(row.timestamp));
|
||||
if (buckets[key]) buckets[key][direction]++;
|
||||
}
|
||||
db.close();
|
||||
|
||||
@@ -167,7 +167,14 @@ pnpm exec tsx scripts/init-first-agent.ts \
|
||||
|
||||
### Groups
|
||||
|
||||
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group.
|
||||
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group, or wire it directly with `ncl` — **the host service must be running** (`ncl` connects to it over a Unix socket):
|
||||
|
||||
```bash
|
||||
# Engage mode/pattern default to the DeltaChat adapter's declared channel
|
||||
# defaults — for DeltaChat groups that's a name pattern (the platform has no
|
||||
# mention metadata), so the agent responds when addressed by name.
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <ag-id>
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -184,7 +191,7 @@ Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaC
|
||||
- **user-id-format**: `deltachat:{email}` — the contact's email address
|
||||
- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat
|
||||
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically get their own agent group (the default `shared` session mode already gives each messaging group its own session)
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
@@ -105,6 +105,14 @@ pnpm exec tsx setup/index.ts --step register -- \
|
||||
|
||||
`agent-shared` puts Emacs messages in the same session as any other channel wired to the same agent group — so a conversation you started in Telegram continues in Emacs. Use `shared` to keep an independent Emacs thread with the same workspace, or a new `--folder` for a dedicated Emacs-only agent.
|
||||
|
||||
Alternatively create the rows with `ncl` — **the host service must be running** (`ncl` connects to it over a Unix socket). Engage mode/pattern and `unknown_sender_policy` default to the Emacs adapter's declared channel defaults:
|
||||
|
||||
```bash
|
||||
ncl messaging-groups create --channel-type emacs --platform-id "default" --name "Emacs"
|
||||
ncl wirings create --messaging-group-id <mg-id-from-above> --agent-group-id <ag-id> \
|
||||
--session-mode agent-shared
|
||||
```
|
||||
|
||||
## Configure Emacs
|
||||
|
||||
`nanoclaw.el` needs only Emacs 27.1+ builtins (`url`, `json`, `org`) — no package manager.
|
||||
|
||||
@@ -18,7 +18,7 @@ There is no `ncl groups config remove-mount` verb yet (tracked in [#2395](https:
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
|
||||
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
|
||||
updated_at = datetime('now') \
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
|
||||
WHERE agent_group_id = '<group-id>';"
|
||||
```
|
||||
|
||||
|
||||
@@ -168,11 +168,11 @@ HOST_PATH="$HOME/.calendar-mcp"
|
||||
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
|
||||
updated_at = datetime('now') \
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is an ISO-with-Z string everywhere else in the schema (`new Date().toISOString()`), so stamp the same shape with `strftime` — plain `datetime('now')` would mix naive UTC strings into the column, and `strftime('%s','now')` epoch ints.
|
||||
|
||||
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
|
||||
|
||||
|
||||
@@ -107,16 +107,17 @@ Ask the user: **Is this a private or public repo?**
|
||||
- **Private repo** — use `unknown_sender_policy: 'public'`. Only collaborators can comment anyway, so it's safe to let all comments through.
|
||||
- **Public repo** — use `unknown_sender_policy: 'strict'`. Only registered members can trigger the agent, preventing strangers from consuming agent resources. Add trusted collaborators as members (see below).
|
||||
|
||||
Run `/manage-channels` to wire the GitHub channel to an agent group, or insert manually:
|
||||
Run `/manage-channels` to wire the GitHub channel to an agent group, or create the rows directly with `ncl`. **The host service must be running** — `ncl` connects to it over a Unix socket:
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per repo)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', datetime('now'));
|
||||
```bash
|
||||
# Create messaging group (one per repo)
|
||||
ncl messaging-groups create --channel-type github --platform-id "github:owner/repo" \
|
||||
--name "owner/repo" --is-group 1 --unknown-sender-policy <policy>
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES ('mga-github-myrepo', 'mg-github-myrepo', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
|
||||
# Wire to agent group (engage mode/pattern default to the GitHub adapter's
|
||||
# declared channel defaults; grab the mg id from the create output above)
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <your-agent-group-id> \
|
||||
--session-mode per-thread
|
||||
```
|
||||
|
||||
Replace `<policy>` with `public` or `strict` based on the user's choice above.
|
||||
@@ -125,14 +126,12 @@ Replace `<policy>` with `public` or `strict` based on the user's choice above.
|
||||
|
||||
When using `strict`, add each GitHub user who should be able to trigger the agent:
|
||||
|
||||
```sql
|
||||
-- Add user (kind = 'github', id = 'github:<numeric-user-id>')
|
||||
INSERT OR IGNORE INTO users (id, kind, display_name, created_at)
|
||||
VALUES ('github:<user-id>', 'github', '<username>', datetime('now'));
|
||||
```bash
|
||||
# Add user (kind = 'github', id = 'github:<numeric-user-id>')
|
||||
ncl users create --id "github:<user-id>" --kind github --display-name "<username>"
|
||||
|
||||
-- Grant membership to the agent group
|
||||
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id)
|
||||
VALUES ('github:<user-id>', '<agent-group-id>');
|
||||
# Grant membership to the agent group
|
||||
ncl members add --user "github:<user-id>" --group "<agent-group-id>"
|
||||
```
|
||||
|
||||
To find a GitHub user's numeric ID: `gh api users/<username> --jq .id`
|
||||
|
||||
@@ -26,7 +26,7 @@ GROUP_ID='<group-id>'
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
|
||||
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
|
||||
updated_at = datetime('now') \
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
```
|
||||
|
||||
|
||||
@@ -189,11 +189,11 @@ HOST_PATH="$HOME/.gmail-mcp"
|
||||
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
|
||||
updated_at = datetime('now') \
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is an ISO-with-Z string everywhere else in the schema (`new Date().toISOString()`), so stamp the same shape with `strftime` — plain `datetime('now')` would mix naive UTC strings into the column, and `strftime('%s','now')` epoch ints.
|
||||
|
||||
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
|
||||
|
||||
|
||||
@@ -115,19 +115,20 @@ Ask the user: **Is this a private or public Linear workspace?**
|
||||
- **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment.
|
||||
- **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example).
|
||||
|
||||
Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually:
|
||||
Run `/manage-channels` to wire the Linear channel to an agent group, or create the rows directly with `ncl`. **The host service must be running** — `ncl` connects to it over a Unix socket:
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per team)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', datetime('now'));
|
||||
```bash
|
||||
# Create messaging group (one per team)
|
||||
ncl messaging-groups create --channel-type linear --platform-id "linear:ENG" \
|
||||
--name "Engineering" --is-group 1 --unknown-sender-policy <policy>
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES ('mga-linear-eng', 'mg-linear-eng', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
|
||||
# Wire to agent group (engage mode/pattern default to the Linear adapter's
|
||||
# declared channel defaults; grab the mg id from the create output above)
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <your-agent-group-id> \
|
||||
--session-mode per-thread
|
||||
```
|
||||
|
||||
The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
|
||||
Replace `<policy>` with `public` or `strict` based on the user's choice above. The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -220,30 +220,21 @@ Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agen
|
||||
|
||||
### Groups
|
||||
|
||||
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions:
|
||||
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. Each group gets its own session with the default `shared` mode (one session per agent + messaging group). Create the wiring with `ncl` — **the host service must be running** (`ncl` connects to it over a Unix socket):
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "
|
||||
INSERT OR IGNORE INTO messaging_group_agents
|
||||
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
|
||||
VALUES
|
||||
('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW');
|
||||
"
|
||||
# Engage mode/pattern default to the Signal adapter's declared channel defaults
|
||||
ncl wirings create --messaging-group-id mg-GROUPID --agent-group-id ag-AGENTID
|
||||
```
|
||||
|
||||
### Grant user access
|
||||
|
||||
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`:
|
||||
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups` (host service running):
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "
|
||||
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
|
||||
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
|
||||
VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW');
|
||||
"
|
||||
ncl users create --id "signal:UUID" --kind signal --display-name "<name>"
|
||||
ncl roles grant --user "signal:UUID" --role owner
|
||||
ncl members add --user "signal:UUID" --group ag-AGENTID
|
||||
```
|
||||
|
||||
Find the UUID from `messaging_groups.platform_id` or the `users` table.
|
||||
@@ -264,7 +255,7 @@ Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal
|
||||
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID
|
||||
- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant via Signal DMs or small group chats
|
||||
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically get their own agent group (the default `shared` session mode already gives each messaging group its own session)
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ Skip to **Credentials** if all of these are already in place:
|
||||
- `src/channels/slack.ts` exists
|
||||
- `src/channels/slack-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './slack.js';`
|
||||
- `container/skills/slack-formatting/SKILL.md` exists
|
||||
- `@chat-adapter/slack` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
@@ -33,8 +34,15 @@ git fetch origin channels
|
||||
```bash
|
||||
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
|
||||
git show origin/channels:src/channels/slack-registration.test.ts > src/channels/slack-registration.test.ts
|
||||
mkdir -p container/skills/slack-formatting
|
||||
git show origin/channels:container/skills/slack-formatting/SKILL.md > container/skills/slack-formatting/SKILL.md
|
||||
```
|
||||
|
||||
The `slack-formatting` container skill is part of the channel payload: it
|
||||
reaches agents via `~/.claude/skills` (synced at spawn) and teaches Slack's
|
||||
mrkdwn syntax. Trunk does not ship it — without this copy step agents send
|
||||
Slack messages with generic markdown that renders literally.
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
@@ -121,9 +121,11 @@ The bot is now connected as your WeChat account.
|
||||
|
||||
A successful QR login alone isn't enough — the adapter still needs to be wired to an agent group before it can respond.
|
||||
|
||||
**Prerequisite: the host service must be running.** The wire script creates the wiring through `ncl`, which talks to the running host over a Unix socket — there is no offline mode.
|
||||
|
||||
### 1. Trigger the first inbound message
|
||||
|
||||
Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id`.
|
||||
Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id` and the `unknown_sender_policy` the WeChat adapter declares.
|
||||
|
||||
### 2. Run the wire script
|
||||
|
||||
@@ -131,9 +133,9 @@ Have a different WeChat account send a message to the bot account. This auto-cre
|
||||
pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts
|
||||
```
|
||||
|
||||
Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and creates the `messaging_group_agents` row with sensible defaults (sender policy `request_approval`, session mode `shared`).
|
||||
Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and runs `ncl wirings create` — engage mode/pattern and priority come from the WeChat adapter's declared channel defaults, so a wiring created here matches one created by `/manage-channels` or the approval-card flow.
|
||||
|
||||
With `request_approval`, the next DM from the stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent.
|
||||
With `request_approval` as the sender policy, the next DM from a stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent.
|
||||
|
||||
Non-interactive:
|
||||
|
||||
@@ -147,10 +149,16 @@ pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts \
|
||||
Flags:
|
||||
|
||||
- `--platform-id <id>` — wire a specific messaging group (default: most recent unwired)
|
||||
- `--agent-group <id>` — target agent group (default: prompt; or solo admin group in non-interactive)
|
||||
- `--sender-policy public|strict|request_approval` — default `request_approval` (fires an admin approval card on unknown-sender DMs)
|
||||
- `--agent-group <id>` — target agent group (default: prompt; auto-picked when only one exists)
|
||||
- `--sender-policy public|strict|request_approval` — override the messaging group's `unknown_sender_policy` (default: leave whatever the WeChat adapter declared when the row was auto-created)
|
||||
- `--session-mode shared|per-thread` — default `shared`
|
||||
|
||||
Equivalent raw `ncl` invocation (host must be running):
|
||||
|
||||
```bash
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <ag-id> --session-mode shared
|
||||
```
|
||||
|
||||
### 3. Test
|
||||
|
||||
Have the sender message the bot again — the agent should respond.
|
||||
|
||||
@@ -5,43 +5,49 @@
|
||||
* After /add-wechat installs the adapter and the user scans the QR login,
|
||||
* the first inbound message from another WeChat account auto-creates a
|
||||
* `messaging_groups` row. This script finds that row, asks the operator
|
||||
* which agent group to wire it to, and inserts the `messaging_group_agents`
|
||||
* join row with sensible defaults — the "post-login wiring" step /add-wechat
|
||||
* otherwise requires manual SQL for.
|
||||
* which agent group to wire it to, and creates the wiring via
|
||||
* `ncl wirings create` — engage mode/pattern and priority come from the
|
||||
* WeChat adapter's declared channel defaults, not from SQL baked into this
|
||||
* script, so it can't drift against schema migrations.
|
||||
*
|
||||
* Usage:
|
||||
* PREREQUISITE: the NanoClaw host service must be RUNNING — `ncl` talks to
|
||||
* it over a Unix socket and has no offline mode.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts
|
||||
*
|
||||
* Flags:
|
||||
* --platform-id <id> Wire a specific messaging group (default: most recent unwired)
|
||||
* --agent-group <id> Target agent group (default: interactive pick; or solo admin group)
|
||||
* --sender-policy <p> public | strict (default: public)
|
||||
* --agent-group <id> Target agent group (default: interactive pick; auto-picked when only one exists)
|
||||
* --sender-policy <p> public | strict | request_approval — overrides the
|
||||
* channel-declared unknown_sender_policy on the
|
||||
* messaging group (default: leave as the adapter declared)
|
||||
* --session-mode <m> shared | per-thread (default: shared)
|
||||
* --non-interactive Fail instead of prompting
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DB_PATH = process.env.NANOCLAW_DB_PATH ?? path.join(process.cwd(), 'data', 'v2.db');
|
||||
// <root>/.claude/skills/add-wechat/scripts/wire-dm.ts → <root>
|
||||
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
|
||||
|
||||
type SenderPolicy = 'public' | 'strict' | 'request_approval';
|
||||
|
||||
interface Args {
|
||||
platformId?: string;
|
||||
agentGroupId?: string;
|
||||
senderPolicy: SenderPolicy;
|
||||
senderPolicy?: SenderPolicy;
|
||||
sessionMode: 'shared' | 'per-thread';
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const args: Args = {
|
||||
// Default matches the router's auto-create (`request_approval`) so the
|
||||
// admin gets an approval card on the next unknown-sender DM rather than
|
||||
// a silent allow. Pass `--sender-policy public` to open the channel to
|
||||
// anyone, or `strict` to require explicit membership.
|
||||
senderPolicy: 'request_approval',
|
||||
// No --sender-policy default: the router already stamped the policy the
|
||||
// WeChat adapter declares when it auto-created the messaging group.
|
||||
// Only an explicit flag overrides it.
|
||||
sessionMode: 'shared',
|
||||
interactive: true,
|
||||
};
|
||||
@@ -68,72 +74,90 @@ function parseArgs(argv: string[]): Args {
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Run one ncl command against the running host and return its parsed data. */
|
||||
function ncl(...cliArgs: string[]): unknown {
|
||||
const res = spawnSync('pnpm', ['exec', 'tsx', 'src/cli/client.ts', ...cliArgs, '--json'], {
|
||||
cwd: PROJECT_ROOT,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
if (res.error) throw res.error;
|
||||
let frame: { ok: boolean; data?: unknown; error?: { message: string } } | undefined;
|
||||
try {
|
||||
frame = JSON.parse(res.stdout);
|
||||
} catch {
|
||||
// No frame — transport-level failure (host not running), reported on stderr.
|
||||
}
|
||||
if (frame && !frame.ok) throw new Error(`ncl ${cliArgs.join(' ')} failed: ${frame.error?.message}`);
|
||||
if (!frame || res.status !== 0) {
|
||||
const detail = (res.stderr || res.stdout || '').trim();
|
||||
throw new Error(
|
||||
`ncl ${cliArgs.join(' ')} failed:\n${detail}\n\n` +
|
||||
'Is the NanoClaw host service running? ncl connects to it over a Unix socket.',
|
||||
);
|
||||
}
|
||||
return frame.data;
|
||||
}
|
||||
|
||||
async function prompt(q: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => rl.question(q, (a) => { rl.close(); resolve(a.trim()); }));
|
||||
}
|
||||
|
||||
function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
interface MgRow { id: string; platform_id: string; name: string | null; is_group: number; created_at: string }
|
||||
interface AgRow { id: string; name: string; created_at: string }
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
const mgs = ncl('messaging-groups', 'list', '--channel-type', 'wechat') as MgRow[];
|
||||
const wirings = ncl('wirings', 'list', '--limit', '10000') as Array<{ messaging_group_id: string }>;
|
||||
const wiredMgIds = new Set(wirings.map((w) => w.messaging_group_id));
|
||||
|
||||
// 1. Pick the messaging group
|
||||
let platformId = args.platformId;
|
||||
if (!platformId) {
|
||||
const rows = db.prepare(`
|
||||
SELECT mg.id, mg.platform_id, mg.name, mg.is_group, mg.created_at
|
||||
FROM messaging_groups mg
|
||||
LEFT JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id
|
||||
WHERE mg.channel_type = 'wechat' AND mga.id IS NULL
|
||||
ORDER BY mg.created_at DESC
|
||||
`).all() as Array<{ id: string; platform_id: string; name: string | null; is_group: number; created_at: string }>;
|
||||
let mg: MgRow | undefined;
|
||||
if (args.platformId) {
|
||||
mg = mgs.find((r) => r.platform_id === args.platformId);
|
||||
if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${args.platformId}`);
|
||||
} else {
|
||||
const unwired = mgs
|
||||
.filter((r) => !wiredMgIds.has(r.id))
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||||
|
||||
if (rows.length === 0) {
|
||||
if (unwired.length === 0) {
|
||||
console.error('No unwired WeChat messaging groups found.');
|
||||
console.error('Send a message to the bot first (from another WeChat account), then re-run.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (rows.length === 1 || !args.interactive) {
|
||||
platformId = rows[0].platform_id;
|
||||
console.log(`Using most recent unwired group: ${platformId} (${rows[0].is_group ? 'group' : 'DM'})`);
|
||||
if (unwired.length === 1 || !args.interactive) {
|
||||
mg = unwired[0];
|
||||
console.log(`Using most recent unwired group: ${mg.platform_id} (${mg.is_group ? 'group' : 'DM'})`);
|
||||
} else {
|
||||
console.log('Unwired WeChat messaging groups:');
|
||||
rows.forEach((r, i) => {
|
||||
unwired.forEach((r, i) => {
|
||||
console.log(` ${i + 1}. ${r.platform_id} (${r.is_group ? 'group' : 'DM'}, ${r.created_at})`);
|
||||
});
|
||||
const pick = await prompt('Pick one [1]: ');
|
||||
const idx = pick === '' ? 0 : parseInt(pick, 10) - 1;
|
||||
if (Number.isNaN(idx) || idx < 0 || idx >= rows.length) throw new Error('invalid choice');
|
||||
platformId = rows[idx].platform_id;
|
||||
if (Number.isNaN(idx) || idx < 0 || idx >= unwired.length) throw new Error('invalid choice');
|
||||
mg = unwired[idx];
|
||||
}
|
||||
}
|
||||
|
||||
const mg = db.prepare(
|
||||
'SELECT id, platform_id, is_group FROM messaging_groups WHERE channel_type = ? AND platform_id = ?'
|
||||
).get('wechat', platformId) as { id: string; platform_id: string; is_group: number } | undefined;
|
||||
if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${platformId}`);
|
||||
|
||||
// 2. Pick the agent group
|
||||
let agentGroupId = args.agentGroupId;
|
||||
if (!agentGroupId) {
|
||||
const agents = db.prepare('SELECT id, name, is_admin FROM agent_groups ORDER BY is_admin DESC, created_at ASC')
|
||||
.all() as Array<{ id: string; name: string; is_admin: number }>;
|
||||
const agents = (ncl('groups', 'list') as AgRow[])
|
||||
.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
||||
if (agents.length === 0) throw new Error('no agent groups exist — create one first');
|
||||
|
||||
const adminAgents = agents.filter((a) => a.is_admin === 1);
|
||||
if (adminAgents.length === 1 && !args.interactive) {
|
||||
agentGroupId = adminAgents[0].id;
|
||||
console.log(`Auto-selected sole admin agent group: ${adminAgents[0].name} (${agentGroupId})`);
|
||||
if (agents.length === 1) {
|
||||
agentGroupId = agents[0].id;
|
||||
console.log(`Auto-selected sole agent group: ${agents[0].name} (${agentGroupId})`);
|
||||
} else if (args.interactive) {
|
||||
console.log('Agent groups:');
|
||||
agents.forEach((a, i) => {
|
||||
console.log(` ${i + 1}. ${a.name} (${a.id})${a.is_admin ? ' [admin]' : ''}`);
|
||||
console.log(` ${i + 1}. ${a.name} (${a.id})`);
|
||||
});
|
||||
const pick = await prompt('Pick one [1]: ');
|
||||
const idx = pick === '' ? 0 : parseInt(pick, 10) - 1;
|
||||
@@ -144,26 +168,29 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const ag = db.prepare('SELECT id, name FROM agent_groups WHERE id = ?').get(agentGroupId) as
|
||||
{ id: string; name: string } | undefined;
|
||||
const ag = (ncl('groups', 'list') as AgRow[]).find((a) => a.id === agentGroupId);
|
||||
if (!ag) throw new Error(`no agent_group with id = ${agentGroupId}`);
|
||||
|
||||
// 3. Update sender policy + wire
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare('UPDATE messaging_groups SET unknown_sender_policy = ? WHERE id = ?')
|
||||
.run(args.senderPolicy, mg.id);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO messaging_group_agents
|
||||
(id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES (?, ?, ?, '', 'all', ?, 10, datetime('now'))
|
||||
`).run(generateId('mga'), mg.id, ag.id, args.sessionMode);
|
||||
});
|
||||
tx();
|
||||
// 3. Wire, then apply the optional policy override. Engage mode/pattern and
|
||||
// priority are filled by the wirings resolveDefaults hook from the WeChat
|
||||
// adapter's declared channel defaults. Policy update runs second so a
|
||||
// failed create (e.g. already wired) leaves the mg row untouched.
|
||||
const wiring = ncl(
|
||||
'wirings', 'create',
|
||||
'--messaging-group-id', mg.id,
|
||||
'--agent-group-id', ag.id,
|
||||
'--session-mode', args.sessionMode,
|
||||
) as { engage_mode: string; engage_pattern: string | null };
|
||||
if (args.senderPolicy) {
|
||||
ncl('messaging-groups', 'update', mg.id, '--unknown-sender-policy', args.senderPolicy);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} policy=${args.senderPolicy} mode=${args.sessionMode}`);
|
||||
db.close();
|
||||
console.log(
|
||||
`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} ` +
|
||||
`engage=${wiring.engage_mode}${wiring.engage_pattern ? `(${wiring.engage_pattern})` : ''} ` +
|
||||
`policy=${args.senderPolicy ?? '(channel default)'} mode=${args.sessionMode}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ Skip to **Credentials** if all of these are already in place:
|
||||
- `src/channels/whatsapp.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './whatsapp.js';`
|
||||
- `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist
|
||||
- `container/skills/whatsapp-formatting/instructions.md` exists
|
||||
- `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:`
|
||||
- `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies
|
||||
- `.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts` exists (ships with this skill)
|
||||
@@ -40,8 +41,17 @@ git show origin/channels:src/channels/whatsapp-registration.test.ts > src/cha
|
||||
git show origin/channels:src/channels/whatsapp.test.ts > src/channels/whatsapp.test.ts
|
||||
git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts
|
||||
git show origin/channels:setup/groups.ts > setup/groups.ts
|
||||
mkdir -p container/skills/whatsapp-formatting
|
||||
git show origin/channels:container/skills/whatsapp-formatting/SKILL.md > container/skills/whatsapp-formatting/SKILL.md
|
||||
git show origin/channels:container/skills/whatsapp-formatting/instructions.md > container/skills/whatsapp-formatting/instructions.md
|
||||
```
|
||||
|
||||
The `whatsapp-formatting` container skill is part of the channel payload: its
|
||||
`instructions.md` becomes the `skill-whatsapp-formatting.md` fragment in every
|
||||
group's composed CLAUDE.md (see `src/claude-md-compose.ts`), teaching agents
|
||||
WhatsApp's formatting syntax. Trunk does not ship it — without this copy step
|
||||
agents format WhatsApp messages with generic markdown that renders literally.
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
@@ -82,7 +92,7 @@ WhatsApp uses linked-device authentication — no API key, just a one-time pairi
|
||||
|
||||
### Check current state
|
||||
|
||||
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Shared vs dedicated number".
|
||||
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number".
|
||||
|
||||
```bash
|
||||
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
|
||||
@@ -192,16 +202,60 @@ for i in $(seq 1 60); do grep -q 'STATUS: authenticated' /tmp/wa-auth.log 2>/dev
|
||||
test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed"
|
||||
```
|
||||
|
||||
### Shared vs dedicated number
|
||||
## Dedicated vs personal number
|
||||
|
||||
The adapter behaves fundamentally differently depending on whether the linked number is the assistant's own or the operator's personal one. The switch is `ASSISTANT_HAS_OWN_NUMBER` in `.env`, read by the adapter itself at startup. **Inference rule: absent (or anything other than `true`) means shared/personal** — the safe default, since misreading a personal number as dedicated makes the bot claim messages addressed to the human.
|
||||
|
||||
- **Shared/personal number** (`ASSISTANT_HAS_OWN_NUMBER` unset or not `true`) — DMs to this number and group @-tags of it address the *human*, not the bot. The adapter never emits a mention signal (`mentions: 'never'` in its declared channel defaults), so: no stranger DM ever auto-creates a messaging group or raises an admin approval card; group wirings default to a name pattern (`\b<AgentName>\b`) instead of platform mentions; auto-created chats default to `unknown_sender_policy: 'strict'`; outbound messages are prefixed with the assistant's name.
|
||||
- **Dedicated number** (`ASSISTANT_HAS_OWN_NUMBER=true`) — everything sent to the number is for the bot. DMs and group mentions carry a real mention signal (`mentions: 'platform'`), unknown senders escalate via `request_approval` approval cards, and card-approved groups wire with `engage_mode: 'mention'`. No name prefix on outbound.
|
||||
|
||||
AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number?
|
||||
- **Shared number** — your personal WhatsApp (bot prefixes messages with its name)
|
||||
- **Dedicated number** — a separate phone/SIM for the assistant
|
||||
|
||||
If dedicated, add to `.env`:
|
||||
Write the answer to `.env` **explicitly in both cases** (don't rely on the inference rule for new installs):
|
||||
|
||||
```bash
|
||||
# Dedicated:
|
||||
ASSISTANT_HAS_OWN_NUMBER=true
|
||||
# Shared/personal:
|
||||
ASSISTANT_HAS_OWN_NUMBER=false
|
||||
```
|
||||
|
||||
### Update path: existing install, flag unset
|
||||
|
||||
If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Ask the operator which mode applies and write it explicitly.
|
||||
|
||||
Suggest a default by comparing the authed number against the wired DM chat:
|
||||
|
||||
```bash
|
||||
# The number this install is authenticated as
|
||||
node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0])"
|
||||
# The wired WhatsApp DM chats
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp' AND mg.is_group=0"
|
||||
```
|
||||
|
||||
If the wired DM's phone **equals** the authed number, the operator is talking to the bot in their own self-chat — that's a personal number: suggest **Shared**. If they differ, the operator messages the bot from a different number: suggest **Dedicated**. Confirm with the operator either way, then write the flag and restart the service.
|
||||
|
||||
### Migration audit: spam-era group wirings
|
||||
|
||||
Before the shared-number fix, group chats approved via the channel-registration card were wired `engage_mode='pattern'` with pattern `.` — respond-to-everything — because the card flow couldn't tell groups from DMs on non-threaded platforms. On a personal number this shows up as the bot answering every message in family/work groups after someone once tapped Connect on a spam-triggered card.
|
||||
|
||||
List the suspect wirings (host service running — `ncl` is socket-only):
|
||||
|
||||
```bash
|
||||
ncl wirings list --engage-mode pattern --engage-pattern "." --json
|
||||
```
|
||||
|
||||
Cross-reference against WhatsApp group chats (`ncl messaging-groups list --channel-type whatsapp --is-group 1`). For each wiring with pattern `.` on a WhatsApp group that is *not* the operator's deliberate always-on chat (e.g. their self-chat), offer:
|
||||
|
||||
- **Flip to name-based engagement**: `ncl wirings update <wiring-id> --engage-mode pattern --engage-pattern '\b<AgentName>\b'` (or `--engage-mode mention` on a dedicated number)
|
||||
- **Delete the wiring**: `ncl wirings delete <wiring-id>`
|
||||
|
||||
Stale approval cards from that era can also linger. Clear pending channel approvals for chats the operator doesn't want wired:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM pending_channel_approvals WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='whatsapp')"
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
@@ -279,3 +333,12 @@ systemctl --user start $(systemd_unit)
|
||||
### "conflict" disconnection
|
||||
|
||||
Two instances connected with same credentials. Ensure only one NanoClaw process is running.
|
||||
|
||||
### Trunk updated but shared-number behavior unchanged (stale adapter copy)
|
||||
|
||||
The shared-number behavior (no stranger approval cards, name-pattern group defaults) lives in the **adapter copy** at `src/channels/whatsapp.ts`, installed from the `channels` branch — not in trunk. If you updated trunk via `/update-nanoclaw` but skipped the skill-update step, the old adapter copy neither reads `ASSISTANT_HAS_OWN_NUMBER` itself nor declares channel defaults, so trunk falls back to the legacy behavior: approval cards still fire on a personal number, and new wirings get the channel-blind defaults. Symptoms of the skew:
|
||||
|
||||
- `.env` says `ASSISTANT_HAS_OWN_NUMBER=false` (or unset) but strangers' DMs still raise approval cards
|
||||
- `ncl wirings create` on a WhatsApp group defaults to `mention` instead of a name pattern
|
||||
|
||||
Fix: re-run `/add-whatsapp` (or `/update-skills`) to pull the current adapter from the `channels` branch, then restart the service. The reverse skew (new adapter, old trunk) can't happen — the adapter's `defaults` field is optional and old trunk ignores it.
|
||||
|
||||
@@ -113,7 +113,7 @@ If they say it didn't arrive, then diagnose using the DB directly (no waiting lo
|
||||
|
||||
**"Missing required args"** — the script wants `--channel`, `--user-id`, `--platform-id`, `--display-name` at minimum. Re-check the command you assembled.
|
||||
|
||||
**No `messaging_groups` row appears after the user DMs (step 3a)** — the router silently drops messages from unknown senders under `strict` policy but still creates the `messaging_groups` row. If the row is missing entirely, the adapter isn't receiving the inbound message. Check `logs/nanoclaw.log` for adapter errors (auth, gateway disconnect, rate limit).
|
||||
**No `messaging_groups` row appears after the user DMs (step 3a)** — auto-created rows are stamped with the channel adapter's declared `unknown_sender_policy` (two-level model: adapter declaration → per-row override; `strict` only when the adapter has no declaration). Under `strict` the router silently drops messages from unknown senders but still creates the `messaging_groups` row; under `request_approval` an approval card goes to an admin instead. If the row is missing entirely, the adapter isn't receiving the inbound message. Check `logs/nanoclaw.log` for adapter errors (auth, gateway disconnect, rate limit).
|
||||
|
||||
**Owner already exists** — `hasAnyOwner()` returned true, so the grant is skipped silently. That's fine; the script still creates the agent and wiring. Reassigning ownership needs a separate flow (not this skill).
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ pnpm exec tsx scripts/q.ts data/v2.db "<query>"
|
||||
```sql
|
||||
SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups;
|
||||
SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups;
|
||||
SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents;
|
||||
SELECT messaging_group_id, agent_group_id, engage_mode, engage_pattern, session_mode, threads, priority FROM messaging_group_agents;
|
||||
SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC;
|
||||
```
|
||||
|
||||
@@ -36,6 +36,47 @@ If the instance has no owner yet (`SELECT COUNT(*) FROM user_roles WHERE role='o
|
||||
|
||||
**Delegate to `/init-first-agent`.** It handles: channel choice, operator identity lookup, DM platform id resolution (with cold-DM or pair-code fallback), agent group creation, wiring, and the welcome DM. Return here afterward for any additional channels.
|
||||
|
||||
## Channel Defaults: The Two-Level Model
|
||||
|
||||
Wiring defaults (engage mode/pattern, threading, `unknown_sender_policy`) resolve through **exactly two levels**:
|
||||
|
||||
1. **Adapter declaration** — each channel adapter declares `ChannelDefaults` (separate DM and group contexts, plus a `mentions` capability) in its source file. The adapter copy is skill-installed and user-owned: to change a default install-wide, edit `src/channels/<channel>.ts` and restart. Declarations are never persisted to the DB.
|
||||
2. **Per-wiring/per-mg values chosen at creation** — every creation surface (`ncl wirings create` / `ncl messaging-groups create`, the `register` wizard step, the approval-card flow, `/init-first-agent`) fills omitted fields from the declaration and stores the result on the row. Pass explicit flags to override one wiring.
|
||||
|
||||
There is no third level: existing rows are never re-resolved, so editing a declaration only affects wirings created afterward. The one exception is the **`threads` column**, which stays live — `NULL` means "inherit the declaration at message time".
|
||||
|
||||
Channels with no declaration (stale adapter copies) fall back to the legacy behavior; run `/update-skills` to pull current adapters.
|
||||
|
||||
### Wiring via ncl
|
||||
|
||||
`ncl` requires the **host service to be running** (it connects over a Unix socket):
|
||||
|
||||
```bash
|
||||
ncl messaging-groups create --channel-type <type> --platform-id "<id>" --name "<name>" [--is-group 1]
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <ag-id> [--session-mode <mode>]
|
||||
```
|
||||
|
||||
Omitted `engage_mode`/`engage_pattern`/`unknown_sender_policy` come from the adapter declaration for the right context (DM vs group). Run `ncl wirings help` / `ncl messaging-groups help` for the full flag list.
|
||||
|
||||
### Threading override (`--threads`)
|
||||
|
||||
`ncl wirings create/update ... --threads true|false` controls whether platform thread ids are honored for this wiring. `true` (in groups) means per-thread sessions and in-thread replies/typing/cards; `false` collapses to a flat session with top-level replies. Omitted = `NULL` = inherit the channel declaration. A wiring can *disable* threads on a threaded platform (Slack, Discord, GitHub), never enable them on a non-threaded one.
|
||||
|
||||
Two consequences to warn the user about:
|
||||
|
||||
- **Session identity**: sessions are never deleted. Flipping `threads` on a live wiring orphans existing per-thread sessions (or splinters a shared one) — history stays in the old sessions; new messages start fresh ones.
|
||||
- **`mention-sticky` needs threads**: sticky engagement is keyed on per-thread session existence, so with resolved threads off it would engage once and never disengage. Creation and update coerce `mention-sticky` → `mention` (with a warning) when the effective thread policy is off.
|
||||
|
||||
### Mention capability
|
||||
|
||||
Each declaration states which mention signal the adapter emits: `platform` (real platform mentions), `dm-only` (only DMs are flagged), or `never`. On a `mentions: 'never'` channel (Linear OAuth apps, WhatsApp personal-number mode, Emacs), `mention`/`mention-sticky` wirings are **inert — they can never engage** — and `ncl` rejects them at create/update with an error citing the declaration. For groups on those channels, use a name pattern instead:
|
||||
|
||||
```bash
|
||||
ncl wirings update <id> --engage-mode pattern --engage-pattern '(?i)^@?<Name>\b'
|
||||
```
|
||||
|
||||
**Renaming an agent group does not update stored patterns.** Declared group patterns containing `{name}` are substituted with the agent group's name *at creation* and stored literally — after `ncl groups update <id> --name <NewName>`, audit that group's wirings for patterns still matching the old name and update them.
|
||||
|
||||
## Wire New Channel
|
||||
|
||||
For each unwired channel:
|
||||
@@ -67,6 +108,8 @@ 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.
|
||||
|
||||
Omitted engage/policy fields default from the channel adapter's declaration (see "Channel Defaults" above). Optional overrides: `--trigger "<regex>"` (explicit engage pattern), `--engage-mode <pattern|mention|mention-sticky>`, `--is-group <true|false>`, `--unknown-sender-policy <strict|request_approval|public>`. Don't pick a mention mode on a channel whose declaration says `mentions: 'never'` — it can never engage there.
|
||||
|
||||
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.
|
||||
|
||||
For separate agents, also ask for a folder name and optionally a different assistant name.
|
||||
@@ -75,7 +118,7 @@ For separate agents, also ask for a folder name and optionally a different assis
|
||||
|
||||
When adding another group/chat on an already-configured platform (e.g. a second Telegram group):
|
||||
|
||||
1. **Telegram:** ask the isolation question first to determine intent (`wire-to:<folder>` for an existing agent, `new-agent:<folder>` for a fresh one). Run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <intent>`, show the `CODE` from the `PAIR_TELEGRAM_CODE` status block, and tell the user to post `@<botname> CODE` in the target group (or DM the bot for a private chat). Wait for the final `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row with `unknown_sender_policy = 'strict'` and upserted the paired user — `register` only needs to add the wiring:
|
||||
1. **Telegram:** ask the isolation question first to determine intent (`wire-to:<folder>` for an existing agent, `new-agent:<folder>` for a fresh one). Run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <intent>`, show the `CODE` from the `PAIR_TELEGRAM_CODE` status block, and tell the user to post `@<botname> CODE` in the target group (or DM the bot for a private chat). Wait for the final `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row stamped with the Telegram adapter's declared policy (`request_approval` on current adapter copies; `strict` only on stale pre-declaration copies) and upserted the paired user — `register` only needs to add the wiring:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step register -- \
|
||||
@@ -94,6 +137,16 @@ When adding another group/chat on an already-configured platform (e.g. a second
|
||||
3. Delete the old `messaging_group_agents` entry, create a new one
|
||||
4. Note: existing sessions stay with the old agent group; new messages route to the new one. The `agent_destinations` row created for the old wiring is NOT automatically removed — if you want the old agent to stop seeing the channel as a named target, delete it from `agent_destinations` manually.
|
||||
|
||||
## One-Time Check: Legacy Mis-Wired WhatsApp Groups
|
||||
|
||||
Installs that approved WhatsApp group registration cards before the channel-defaults model wired those groups as `engage_mode='pattern'`, `engage_pattern='.'` — respond-to-everything (the card flow couldn't tell groups from DMs on non-threaded platforms). Check once:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "SELECT mga.id, mg.platform_id, mg.name FROM messaging_group_agents mga JOIN messaging_groups mg ON mg.id = mga.messaging_group_id WHERE mg.channel_type='whatsapp' AND mg.is_group=1 AND mga.engage_mode='pattern' AND mga.engage_pattern='.'"
|
||||
```
|
||||
|
||||
For any hit the operator didn't deliberately configure as always-on, offer the repair options in `/add-whatsapp`'s "Migration audit" section (flip to mention/name-pattern engagement, or delete the wiring).
|
||||
|
||||
## Show Configuration
|
||||
|
||||
Display a readable summary showing:
|
||||
|
||||
@@ -194,8 +194,10 @@ Notes:
|
||||
runtime, so pass the `=>` value discovery emitted (or the raw OpenClaw id).
|
||||
- Reuse a `--folder` to put a group on an existing agent (shared base/separate
|
||||
conversations); use a new `--folder` for a fully separate agent.
|
||||
- Group chats default to mention-only; pass `--trigger` to set a regex, or
|
||||
`--no-trigger-required` for respond-to-everything.
|
||||
- Engage defaults come from the channel adapter's declaration (most group
|
||||
chats default to mention-based engagement; channels without a mention
|
||||
signal default to a name pattern). Pass `--trigger` to set an explicit
|
||||
regex, or `--no-trigger-required` for respond-to-everything.
|
||||
- Register groups from channels v2 doesn't support yet too — the messaging
|
||||
group and wiring persist and activate when that channel is installed.
|
||||
|
||||
@@ -238,10 +240,13 @@ model, which is **not** a JSON file. Each messaging group has an
|
||||
- `dmPolicy: "disabled"` → don't wire that chat (or leave it registered but
|
||||
unwired).
|
||||
|
||||
The messaging groups `register` / `init-first-agent` create already default to
|
||||
`unknown_sender_policy = 'strict'`, so unknown senders are gated until you add
|
||||
them. Show the user the OpenClaw allowlist and confirm who to grant before
|
||||
running the commands.
|
||||
The messaging groups `register` / `init-first-agent` create default their
|
||||
`unknown_sender_policy` to whatever the channel adapter declares for that
|
||||
context (DM vs group) — `strict` when the channel has no declaration — so
|
||||
unknown senders are gated until you add them (or an admin approves the
|
||||
adapter-declared approval card). Pass `--unknown-sender-policy` to `register`
|
||||
to override. Show the user the OpenClaw allowlist and confirm who to grant
|
||||
before running the commands.
|
||||
|
||||
## Phase 3: Identity and Memory
|
||||
|
||||
|
||||
@@ -281,6 +281,34 @@ Keep it to these two options — the per-skill selection lives inside
|
||||
its Step 4 — so nothing container-related is owed back here.)
|
||||
- On "Skip": note that `/update-skills` can be run anytime, then proceed.
|
||||
|
||||
## Known behavior changes when channel adapters update
|
||||
|
||||
Channel adapters now declare per-channel wiring defaults (engage mode, threading,
|
||||
sender policy). Updating trunk alone changes nothing for existing rows, but once
|
||||
`/update-skills` pulls current adapter copies, two deliberate behavior changes
|
||||
land. If the user's install has Slack, Discord, or WhatsApp, tell them:
|
||||
|
||||
1. **Slack/Discord DM replies move top-level.** Both adapters now declare
|
||||
`threads: false` for DMs, so DM replies stop chasing per-message sub-threads
|
||||
and land in the main DM view, matching the DM session (which was already
|
||||
flat). Group/channel threading is unchanged. To keep the old in-thread DM
|
||||
behavior for a specific wiring, override it per wiring:
|
||||
`ncl wirings update <wiring-id> --threads true`.
|
||||
2. **Shared-identity channels stop raising stranger approval cards.** On
|
||||
channels where the linked account is the operator's personal identity, the
|
||||
mechanics differ by channel: WhatsApp personal-number mode suppresses the
|
||||
mention signal entirely (no auto-created messaging groups, no cards);
|
||||
iMessage and WeChat still emit DM mention signals — stranger DMs still
|
||||
auto-create `messaging_groups` rows — but their declared `strict` policy
|
||||
makes those rows drop unknown senders silently instead of raising
|
||||
channel-registration cards to the admin.
|
||||
|
||||
**WhatsApp installs on a shared/personal number should re-run `/add-whatsapp`**
|
||||
after the skill update: it now asks the dedicated-vs-personal question
|
||||
explicitly (writing `ASSISTANT_HAS_OWN_NUMBER` to `.env`), audits for legacy
|
||||
mis-wired group rows from spam-era approval cards, and shows how to clear
|
||||
stale pending approvals.
|
||||
|
||||
Proceed to Step 7.9.
|
||||
|
||||
# Step 7.9: Stamp the upgrade marker (required)
|
||||
|
||||
+3
-1
@@ -4,7 +4,9 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **One approval contract across every hold.** All approval holds now share one hold-record shape on `pending_approvals` (approver rule, approver blast-radius scope, dedup key — migration 019, with backfills) and ONE click-authorization rule (`mayResolve` in `src/modules/approvals/approver-rule.ts`), replacing three divergent click-auth copies. Unknown-sender admission folds onto the approvals primitive (sessionless hold, action `sender_admit`) — the `pending_sender_approvals` table is dropped by migration; an in-flight sender card does not survive the upgrade (a new message from the same sender re-triggers one). Channel registration and OneCLI keep their flows and adopt the shared rule. Observers now see the full hold lifecycle with zero touch points inside the flows: `registerApprovalRequestedHandler` (new, the creation-side sibling of `registerApprovalResolvedHandler`) fires once whenever a hold record comes into existence, whichever stack created it — `requestApproval`, the OneCLI credential bridge, channel registration (as a synthesized hold view) — and every resolution announces through the approval-resolved observer (outcome `approve` | `reject` | `expire` | `sweep`, session nullable). Two absorbed security fixes intentionally change decision outcomes: **(D1)** a hold with global blast radius (e.g. `roles grant`) can only be approved by an owner or global admin — a scoped admin's click is now rejected; **(D4, approver half)** channel-registration approvers are owners/global admins, no longer "admin of whichever agent group sorts first".
|
||||
- [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter.
|
||||
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
|
||||
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
|
||||
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
|
||||
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
|
||||
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
|
||||
|
||||
@@ -67,8 +67,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` |
|
||||
| `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup |
|
||||
| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` |
|
||||
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry, hold-lifecycle observers (`registerApprovalRequestedHandler` / `registerApprovalResolvedHandler` — every stack announces creation + resolution through these) |
|
||||
| `src/modules/approvals/approver-rule.ts` | `mayResolve` — the one click-authorization rule (approver rule `exclusive` \| `admins-of-scope` + blast-radius scope) for every hold |
|
||||
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry |
|
||||
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
|
||||
| `src/modules/approvals/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
|
||||
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
|
||||
@@ -78,9 +77,10 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
|
||||
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations |
|
||||
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
|
||||
| `src/channels/channel-defaults.ts` | Wiring-creation helpers over adapter-declared channel defaults (`resolveWiringDefaults`, `resolveThreadPolicy`, engage validation) |
|
||||
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
|
||||
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`) |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `vercel-cli`, `welcome`; channel-specific skills like `slack-formatting` and `whatsapp-formatting` install with their channel) |
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
|
||||
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
|
||||
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
|
||||
@@ -106,6 +106,7 @@ ncl help
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| tasks | list, get, create, update, cancel, pause, resume, delete, run, append-log | Scheduled tasks for an agent group |
|
||||
| user-dms | list | Cold-DM cache (read-only) |
|
||||
| dropped-messages | list | Messages from unregistered senders (read-only) |
|
||||
| approvals | list, get | Pending approval requests (read-only) |
|
||||
@@ -121,6 +122,8 @@ Trunk does not ship any specific channel adapter or non-default agent provider.
|
||||
|
||||
Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy module(s) into the standard paths → append a self-registration import to the relevant barrel → `pnpm install <pkg>@<pinned-version>` → build.
|
||||
|
||||
**Channel defaults.** Each adapter declares its wiring-time defaults (`ChannelDefaults`: per DM/group context — engage mode/pattern, thread policy, unknown-sender policy — plus mention signaling). Exactly two levels: the adapter declaration, and the per-wiring override chosen at creation — no per-instance DB config table. Undeclared (stale) adapters resolve through a behavior-faithful fallback, so a trunk update alone changes nothing. See [docs/api-details.md](docs/api-details.md#channel-defaults) and `src/channels/channel-defaults.ts`.
|
||||
|
||||
## Self-Modification
|
||||
|
||||
One tier of agent self-modification today:
|
||||
@@ -138,7 +141,7 @@ Per-agent-group container runtime config (provider, model, packages, MCP servers
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
|
||||
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
|
||||
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members`, `tasks` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
|
||||
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
|
||||
|
||||
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
|
||||
@@ -183,7 +186,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
||||
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
|
||||
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. a `scripts/` CLI or helper).
|
||||
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `vercel-cli`, `welcome`; channel-specific skills like `slack-formatting` and `whatsapp-formatting` are copied in by their `/add-<channel>` skill).
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
@@ -252,6 +255,13 @@ Check these first when something goes wrong:
|
||||
|
||||
Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect.
|
||||
|
||||
## Timestamps
|
||||
|
||||
Two rules, no exceptions:
|
||||
|
||||
- **Storage**: every timestamp written from JS is `new Date().toISOString()` (ISO-8601 UTC with `Z`). Never `datetime('now')` — its naive `YYYY-MM-DD HH:MM:SS` shape is misparsed as local time by `new Date()` and breaks string comparisons against ISO values. In pure-SQL contexts (skill snippets) use `strftime('%Y-%m-%dT%H:%M:%fZ','now')`. SQL-side *comparisons* wrap both sides in `datetime()`.
|
||||
- **Display**: anything shown to an agent or a user renders in the install timezone — `formatLocalTime` (prose) or `formatLocalStamp` (log lines) from `src/timezone.ts` / `container/agent-runner/src/timezone.ts`. `--json` output, DB values, and operator logs stay ISO.
|
||||
|
||||
## Supply Chain Security (pnpm)
|
||||
|
||||
This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspace.yaml`. New package versions must exist on the npm registry for 3 days before pnpm will resolve them.
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ Skills that run inside the agent container, not on the host. These teach the Nan
|
||||
|
||||
**Location:** `container/skills/<name>/`
|
||||
|
||||
**Examples:** `agent-browser` (web browsing), `frontend-engineer`, `onecli-gateway` (OneCLI proxy usage), `self-customize`, `slack-formatting` (Slack mrkdwn syntax), `vercel-cli`, `welcome`, `whatsapp-formatting`
|
||||
**Examples:** `agent-browser` (web browsing), `frontend-engineer`, `onecli-gateway` (OneCLI proxy usage), `self-customize`, `vercel-cli`, `welcome`; channel-specific: `slack-formatting` (Slack mrkdwn syntax) and `whatsapp-formatting` (channels branch; installed by `/add-slack` / `/add-whatsapp`)
|
||||
|
||||
**Key difference:** You never invoke these from a coding-agent session on the host, the way you run `/setup` or `/update-nanoclaw` in Claude Code/Codex/OpenCode. They're mounted into the sandbox and loaded by the NanoClaw agent itself, shaping how it behaves when you chat with it.
|
||||
|
||||
|
||||
@@ -65,10 +65,11 @@ function writeRequest(req: RequestFrame): void {
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO messages_out (id, seq, timestamp, kind, content)
|
||||
VALUES ($id, $seq, datetime('now'), 'system', $content)`,
|
||||
VALUES ($id, $seq, $timestamp, 'system', $content)`,
|
||||
).run({
|
||||
$id: req.id,
|
||||
$seq: nextSeq,
|
||||
$timestamp: new Date().toISOString(),
|
||||
$content: JSON.stringify({
|
||||
action: 'cli_request',
|
||||
requestId: req.id,
|
||||
@@ -110,9 +111,9 @@ function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | nul
|
||||
outDb.exec('PRAGMA busy_timeout = 5000');
|
||||
outDb
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', ?)",
|
||||
)
|
||||
.run(row.id);
|
||||
.run(row.id, new Date().toISOString());
|
||||
outDb.close();
|
||||
|
||||
const parsed = JSON.parse(row.content);
|
||||
@@ -184,12 +185,46 @@ function printUsage(): void {
|
||||
// Formatting (mirrors src/cli/format.ts on the host)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Mirrors localizeIsoTimestamps in src/cli/format.ts (self-contained — no
|
||||
// imports from agent-runner). Human display shows local time (container TZ
|
||||
// env = install timezone); --json keeps the ISO machine contract. Only whole
|
||||
// ISO-instant strings convert — embedded occurrences may be machine payloads.
|
||||
const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/;
|
||||
|
||||
// "YYYY-MM-DD HH:mm" — round-trips through parseZonedToUtc (naive = local
|
||||
// wall-clock), so a value copied from human output into --process-after
|
||||
// means what it shows.
|
||||
function localTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString('sv-SE', {
|
||||
timeZone: process.env.TZ || 'UTC',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function localizeIsoTimestamps(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return ISO_UTC_RE.test(value) ? localTime(value) : value;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(localizeIsoTimestamps);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, localizeIsoTimestamps(v)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatHuman(resp: ResponseFrame): string {
|
||||
if (!resp.ok) {
|
||||
return `error (${resp.error.code}): ${resp.error.message}\n`;
|
||||
}
|
||||
|
||||
const data = resp.data;
|
||||
const data = localizeIsoTimestamps(resp.data);
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return JSON.stringify(data, null, 2) + '\n';
|
||||
}
|
||||
|
||||
@@ -101,10 +101,10 @@ export function markProcessing(ids: string[]): void {
|
||||
if (ids.length === 0) return;
|
||||
const db = getOutboundDb();
|
||||
const stmt = db.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', datetime('now'))",
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', ?)",
|
||||
);
|
||||
db.transaction(() => {
|
||||
for (const id of ids) stmt.run(id);
|
||||
for (const id of ids) stmt.run(id, new Date().toISOString());
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -113,10 +113,28 @@ export function markCompleted(ids: string[]): void {
|
||||
if (ids.length === 0) return;
|
||||
const db = getOutboundDb();
|
||||
const stmt = db.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', ?)",
|
||||
);
|
||||
db.transaction(() => {
|
||||
for (const id of ids) stmt.run(id);
|
||||
for (const id of ids) stmt.run(id, new Date().toISOString());
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ack task messages whose pre-task script gated the run. The reason decides
|
||||
* the ack: `gated` (wakeAgent=false) is the monitor working as designed → a
|
||||
* plain `completed`; `error` (broken script) → `script-skip:error`, which the
|
||||
* host's ack sync records as a FAILED run so recurrence can read the trailing
|
||||
* failed streak off the occurrence rows and back the series off.
|
||||
*/
|
||||
export function markScriptSkipped(skips: Array<{ id: string; reason: string }>): void {
|
||||
if (skips.length === 0) return;
|
||||
const db = getOutboundDb();
|
||||
const stmt = db.prepare(
|
||||
'INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, ?, ?)',
|
||||
);
|
||||
db.transaction(() => {
|
||||
for (const s of skips) stmt.run(s.id, s.reason === 'error' ? 'script-skip:error' : 'completed', new Date().toISOString());
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -124,9 +142,9 @@ export function markCompleted(ids: string[]): void {
|
||||
export function markFailed(id: string): void {
|
||||
getOutboundDb()
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'failed', datetime('now'))",
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'failed', ?)",
|
||||
)
|
||||
.run(id);
|
||||
.run(id, new Date().toISOString());
|
||||
}
|
||||
|
||||
/** Get a message by ID (read from inbound.db). */
|
||||
|
||||
@@ -58,11 +58,12 @@ export function writeMessageOut(msg: WriteMessageOut): number {
|
||||
outbound
|
||||
.prepare(
|
||||
`INSERT INTO messages_out (id, seq, in_reply_to, timestamp, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content)
|
||||
VALUES ($id, $seq, $in_reply_to, datetime('now'), $deliver_after, $recurrence, $kind, $platform_id, $channel_type, $thread_id, $content)`,
|
||||
VALUES ($id, $seq, $in_reply_to, $timestamp, $deliver_after, $recurrence, $kind, $platform_id, $channel_type, $thread_id, $content)`,
|
||||
)
|
||||
.run({
|
||||
$id: msg.id,
|
||||
$seq: nextSeq,
|
||||
$timestamp: new Date().toISOString(),
|
||||
$in_reply_to: msg.in_reply_to ?? null,
|
||||
$deliver_after: msg.deliver_after ?? null,
|
||||
$recurrence: msg.recurrence ?? null,
|
||||
@@ -136,8 +137,27 @@ export function getUndeliveredMessages(): MessageOutRow[] {
|
||||
return getOutboundDb()
|
||||
.prepare(
|
||||
`SELECT * FROM messages_out
|
||||
WHERE (deliver_after IS NULL OR deliver_after <= datetime('now'))
|
||||
WHERE (deliver_after IS NULL OR datetime(deliver_after) <= datetime('now'))
|
||||
ORDER BY timestamp ASC`,
|
||||
)
|
||||
.all() as MessageOutRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a deliberate send with this exact destination + text already exists
|
||||
* (an MCP send_message row from the current turn). Used by the task-fire
|
||||
* final-text dispatcher to drop the turn-final <message> echo of a send the
|
||||
* agent already made — the dedup happens where the duplication originates.
|
||||
*/
|
||||
export function hasIdenticalSend(platformId: string, channelType: string, text: string): boolean {
|
||||
const row = getOutboundDb()
|
||||
.prepare(
|
||||
`SELECT 1 FROM messages_out
|
||||
WHERE platform_id = $platform_id AND channel_type = $channel_type
|
||||
AND (in_reply_to IS NULL OR in_reply_to = '')
|
||||
AND json_extract(content, '$.text') = $text
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get({ $platform_id: platformId, $channel_type: channelType, $text: text });
|
||||
return row != null;
|
||||
}
|
||||
|
||||
@@ -105,13 +105,11 @@ function buildDestinationsSection(): string {
|
||||
const lines = ['## Sending messages', ''];
|
||||
if (all.length === 1) {
|
||||
const d = all[0];
|
||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||
lines.push(`Your destination is \`${d.name}\`${label}.`);
|
||||
lines.push(`Your destination is \`${d.name}\`${destinationLabel(d)}.`);
|
||||
} else {
|
||||
lines.push('You can send messages to the following destinations:', '');
|
||||
for (const d of all) {
|
||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||
lines.push(`- \`${d.name}\`${label}`);
|
||||
lines.push(`- \`${d.name}\`${destinationLabel(d)}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
@@ -128,3 +126,10 @@ function buildDestinationsSection(): string {
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function destinationLabel(d: DestinationEntry): string {
|
||||
const parts: string[] = [];
|
||||
if (d.channelType) parts.push(d.channelType);
|
||||
if (d.displayName && d.displayName !== d.name) parts.push(d.displayName);
|
||||
return parts.length > 0 ? ` (${parts.join(' · ')})` : '';
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js';
|
||||
import { getPendingMessages } from './db/messages-in.js';
|
||||
import { formatMessages, stripInternalTags } from './formatter.js';
|
||||
import { TIMEZONE } from './timezone.js';
|
||||
import { TIMEZONE, formatLocalTime } from './timezone.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
@@ -109,6 +109,14 @@ describe('timestamp formatting', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('task timestamps', () => {
|
||||
it('renders task time in the user TZ, same as chat rows', () => {
|
||||
insertMessage('t1', 'task', { prompt: 'do the thing' }, { timestamp: '2026-01-05T12:00:00.000Z' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).toContain(`time="${formatLocalTime('2026-01-05T12:00:00.000Z', TIMEZONE)}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reply_to + quoted_message rendering', () => {
|
||||
it('renders reply_to attribute and quoted_message when all fields present', () => {
|
||||
insertMessage('m1', 'chat', {
|
||||
|
||||
@@ -98,6 +98,11 @@ export interface RoutingContext {
|
||||
channelType: string | null;
|
||||
threadId: string | null;
|
||||
inReplyTo: string | null;
|
||||
/** Batch is a task fire — explicit `<message to>` sends must NOT inherit
|
||||
* inReplyTo (the series id), or the host's task-fire suppression drops
|
||||
* them as turn-final echoes: zero delivery. Deliberate sends are
|
||||
* in_reply_to-null, same as the out-of-process MCP send_message path. */
|
||||
taskFire: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +116,7 @@ export function extractRouting(messages: MessageInRow[]): RoutingContext {
|
||||
channelType: first?.channel_type ?? null,
|
||||
threadId: first?.thread_id ?? null,
|
||||
inReplyTo: first?.id ?? null,
|
||||
taskFire: messages.length > 0 && messages.every((m) => m.kind === 'task'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,13 @@ Your CLI access may be scoped. Run `ncl help` to see which resources are availab
|
||||
|
||||
Run `ncl help` for the full list. Common resources:
|
||||
|
||||
| Resource | Verbs | What it is |
|
||||
|----------|-------|------------|
|
||||
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
| Resource | Verbs | What it is |
|
||||
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
| tasks | list, get, create, update, cancel, pause, resume, delete, append-log | Scheduled tasks for your agent group |
|
||||
|
||||
Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals.
|
||||
|
||||
@@ -33,11 +34,12 @@ Additional resources (available under `global` scope only): messaging-groups, wi
|
||||
- **Restarting your container** — `ncl groups restart` (with optional `--rebuild` and `--message`).
|
||||
- **Checking who's in your group** — `ncl members list`.
|
||||
- **Seeing your destinations** — `ncl destinations list`.
|
||||
- **Scheduling work** — `ncl tasks create`, then `ncl tasks list/get/update/cancel/pause/resume/delete`; `ncl tasks run <id>` fires one extra run now (testing) without changing the schedule. At the end of each task run, `ncl tasks append-log --msg "…"` to record what happened (host-timestamped, not a message).
|
||||
- **Answering questions about the system** — query `ncl` rather than guessing.
|
||||
|
||||
### Access rules
|
||||
|
||||
Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it.
|
||||
Read commands (list, get) are open. Most write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it. `ncl tasks` is the exception: an agent can manage its own group tasks without approval.
|
||||
|
||||
### Approval flow
|
||||
|
||||
@@ -61,6 +63,13 @@ ncl groups config get
|
||||
ncl sessions list
|
||||
ncl destinations list
|
||||
ncl members list
|
||||
ncl tasks list
|
||||
# Always pass a short descriptive --name so the task id is readable (e.g. daily-briefing-a25c, not a long uuid).
|
||||
# For a recurring task, --recurrence alone sets the schedule (first run derived from it); add --process-after only for one-shots.
|
||||
ncl tasks create --name "daily briefing" --prompt "Send the daily briefing" --recurrence "0 9 * * *"
|
||||
# At the END of every task run, record one line of history. The host stamps the local time — you supply only the summary.
|
||||
# This is a LOG ENTRY, not a message: it sends nothing to anyone. Inside a task fire --id is auto-derived from your session.
|
||||
ncl tasks append-log --msg "posted the daily digest to slack; one feed returned 403, skipped"
|
||||
|
||||
# Write commands (approval required)
|
||||
ncl groups restart
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* at module scope, and append the import here. No central list.
|
||||
*/
|
||||
import './core.js';
|
||||
import './scheduling.js';
|
||||
import './interactive.js';
|
||||
import './agents.js';
|
||||
import './self-mod.js';
|
||||
|
||||
@@ -1,40 +1,25 @@
|
||||
## Task scheduling (`schedule_task`)
|
||||
## Task scheduling (`ncl tasks`)
|
||||
|
||||
For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below.
|
||||
Use `ncl tasks` for one-shot and recurring tasks. A task runs in this agent group's system session, not in the current chat session, so when it fires you must choose a destination explicitly with `<message to="name">...</message>` or `send_message({ to: "name", ... })`.
|
||||
|
||||
To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule.
|
||||
Pass `--name "<short label>"` on create to get a readable task id (e.g. `--name "sales briefing"` → `sales-briefing-a25c`); without it ids are `t-<hex>`.
|
||||
|
||||
Frequent recurring scheduled tasks — more than a few times a day — consume API credits and can risk account restrictions. You can add a `script` that runs first, and you will only be called when the check passes.
|
||||
|
||||
### How it works
|
||||
|
||||
1. Provide a bash `script` alongside the `prompt` when scheduling
|
||||
2. When the task fires, the script runs first
|
||||
3. Script returns: `{ "wakeAgent": true/false, "data": {...} }`
|
||||
4. If `wakeAgent: false` — nothing happens, task waits for next run
|
||||
5. If `wakeAgent: true` — claude receives the script's data + prompt and handles
|
||||
|
||||
### Always test your script first
|
||||
|
||||
Before scheduling, run the script directly to verify it works:
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
bash -c 'node --input-type=module -e "
|
||||
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
|
||||
const prs = await r.json();
|
||||
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
|
||||
"'
|
||||
ncl tasks create --name "ping" --prompt "Remind me to call Dana" --process-after "tomorrow 18:00"
|
||||
ncl tasks list
|
||||
ncl tasks get ping-a25c # includes run count, failures, and recent run-log lines
|
||||
ncl tasks run ping-a25c # fire once now without changing the schedule (testing)
|
||||
ncl tasks update ping-a25c --prompt "New instructions"
|
||||
ncl tasks pause ping-a25c
|
||||
ncl tasks resume ping-a25c
|
||||
ncl tasks cancel ping-a25c # or --all as a kill switch
|
||||
ncl tasks delete ping-a25c
|
||||
```
|
||||
|
||||
### When NOT to use scripts
|
||||
Use good judgement on whether it's appropriate to check in with the user about the task prompt before task creation, and if so, whether to share verbatim or a description of it.
|
||||
|
||||
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. Do not attempt to do things like sentiment analysis or advanced nlp in scripts.
|
||||
`--process-after` accepts UTC timestamps or naive local timestamps interpreted in the instance timezone (shown in the `<context timezone="..."/>` header).
|
||||
|
||||
### Frequent task guidance
|
||||
|
||||
If a user wants a task to run more than a few times a day and a script can't be used:
|
||||
|
||||
- Explain that each time the task fires it uses API credits and risks rate limits
|
||||
- Suggest adjusting the task requirements in a way that will allow you to use a script
|
||||
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
|
||||
- Help the user find the minimum viable frequency
|
||||
Run `ncl tasks create --help` for schedules, options, and pre-task gate scripts (checks that run before you wake).
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
/**
|
||||
* Scheduling MCP tools: schedule_task, list_tasks, cancel_task, pause_task, resume_task.
|
||||
*
|
||||
* With the two-DB split, the container cannot write to inbound.db (host-owned).
|
||||
* Scheduling operations are sent as system actions via messages_out — the host
|
||||
* reads them during delivery and applies the changes to inbound.db.
|
||||
*/
|
||||
import { getInboundDb } from '../db/connection.js';
|
||||
import { writeMessageOut } from '../db/messages-out.js';
|
||||
import { getSessionRouting } from '../db/session-routing.js';
|
||||
import { TIMEZONE, parseZonedToUtc } from '../timezone.js';
|
||||
import { registerTools } from './server.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[mcp-tools] ${msg}`);
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function routing() {
|
||||
return getSessionRouting();
|
||||
}
|
||||
|
||||
function ok(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
function err(text: string) {
|
||||
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
|
||||
}
|
||||
|
||||
export const scheduleTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'schedule_task',
|
||||
description:
|
||||
`Schedule a one-shot or recurring task. The user's timezone is declared in the <context timezone="..."/> header of your prompt — interpret the user's "9pm" etc. in that zone. Cron expressions are interpreted in the user's timezone too.`,
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
prompt: { type: 'string', description: 'Task instructions/prompt' },
|
||||
processAfter: {
|
||||
type: 'string',
|
||||
description:
|
||||
`ISO 8601 timestamp for the first run. Accepts either UTC (ending in "Z" or "+00:00") or a naive local timestamp (no offset) which is interpreted in the user's timezone (e.g. "2026-01-15T21:00:00" = 9pm user-local). Prefer naive local.`,
|
||||
},
|
||||
recurrence: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" = weekdays at 9am user-local). Evaluated in the user\'s timezone.',
|
||||
},
|
||||
script: { type: 'string', description: 'Optional pre-agent script to run before processing' },
|
||||
},
|
||||
required: ['prompt', 'processAfter'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const prompt = args.prompt as string;
|
||||
const processAfterIn = args.processAfter as string;
|
||||
if (!prompt || !processAfterIn) return err('prompt and processAfter are required');
|
||||
|
||||
let processAfter: string;
|
||||
try {
|
||||
const d = parseZonedToUtc(processAfterIn, TIMEZONE);
|
||||
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${processAfterIn}`);
|
||||
processAfter = d.toISOString();
|
||||
} catch {
|
||||
return err(`invalid processAfter: ${processAfterIn}`);
|
||||
}
|
||||
|
||||
const id = generateId();
|
||||
const r = routing();
|
||||
const recurrence = (args.recurrence as string) || null;
|
||||
const script = (args.script as string) || null;
|
||||
|
||||
// Write as a system action — host will insert into inbound.db
|
||||
writeMessageOut({
|
||||
id,
|
||||
kind: 'system',
|
||||
platform_id: r.platform_id,
|
||||
channel_type: r.channel_type,
|
||||
thread_id: r.thread_id,
|
||||
content: JSON.stringify({
|
||||
action: 'schedule_task',
|
||||
taskId: id,
|
||||
prompt,
|
||||
script,
|
||||
processAfter,
|
||||
recurrence,
|
||||
platformId: r.platform_id,
|
||||
channelType: r.channel_type,
|
||||
threadId: r.thread_id,
|
||||
}),
|
||||
});
|
||||
|
||||
log(`schedule_task: ${id} at ${processAfter}${recurrence ? ` (recurring: ${recurrence})` : ''}`);
|
||||
return ok(`Task scheduled (id: ${id}, runs at: ${processAfter}${recurrence ? `, recurrence: ${recurrence}` : ''})`);
|
||||
},
|
||||
};
|
||||
|
||||
export const listTasks: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'list_tasks',
|
||||
description:
|
||||
'List scheduled tasks. Returns one row per series — the live (pending or paused) occurrence. The id shown is the series id, which is what update_task / cancel_task / pause_task / resume_task expect.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
status: { type: 'string', description: 'Filter by status: pending or paused (default: both)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const status = args.status as string | undefined;
|
||||
const db = getInboundDb();
|
||||
// One row per series — the live (pending or paused) occurrence. Recurring
|
||||
// tasks accumulate one completed row per firing plus one live follow-up;
|
||||
// exposing the whole pile to the agent is noisy and confuses task identity
|
||||
// ("which id do I cancel?"). The series_id is the stable handle.
|
||||
//
|
||||
// SQLite quirk: when MAX(seq) appears in the SELECT list of a GROUP BY
|
||||
// query, the bare columns take values from the row that contains that max
|
||||
// — that's how we pick "the latest live row per series" in one pass.
|
||||
let rows;
|
||||
if (status) {
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
|
||||
FROM messages_in
|
||||
WHERE kind = 'task' AND status = ?
|
||||
GROUP BY series_id
|
||||
ORDER BY process_after ASC`,
|
||||
)
|
||||
.all(status);
|
||||
} else {
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
|
||||
FROM messages_in
|
||||
WHERE kind = 'task' AND status IN ('pending', 'paused')
|
||||
GROUP BY series_id
|
||||
ORDER BY process_after ASC`,
|
||||
)
|
||||
.all();
|
||||
}
|
||||
|
||||
if ((rows as unknown[]).length === 0) return ok('No tasks found.');
|
||||
|
||||
const lines = (rows as Array<{ id: string; status: string; process_after: string | null; recurrence: string | null; content: string }>).map((r) => {
|
||||
const content = JSON.parse(r.content);
|
||||
const prompt = (content.prompt as string || '').slice(0, 80);
|
||||
return `- ${r.id} [${r.status}] at=${r.process_after || 'now'} ${r.recurrence ? `recur=${r.recurrence} ` : ''}→ ${prompt}`;
|
||||
});
|
||||
|
||||
return ok(lines.join('\n'));
|
||||
},
|
||||
};
|
||||
|
||||
export const cancelTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'cancel_task',
|
||||
description: 'Cancel a scheduled task.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Task ID to cancel' },
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
// Write as a system action — host will update inbound.db
|
||||
writeMessageOut({
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'cancel_task', taskId }),
|
||||
});
|
||||
|
||||
log(`cancel_task: ${taskId}`);
|
||||
return ok(`Task cancellation requested: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const pauseTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'pause_task',
|
||||
description: 'Pause a scheduled task. It will not run until resumed.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Task ID to pause' },
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
writeMessageOut({
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'pause_task', taskId }),
|
||||
});
|
||||
|
||||
log(`pause_task: ${taskId}`);
|
||||
return ok(`Task pause requested: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const resumeTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'resume_task',
|
||||
description: 'Resume a paused task.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Task ID to resume' },
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
writeMessageOut({
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'resume_task', taskId }),
|
||||
});
|
||||
|
||||
log(`resume_task: ${taskId}`);
|
||||
return ok(`Task resume requested: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const updateTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'update_task',
|
||||
description:
|
||||
'Update a scheduled task. Pass the series id from list_tasks. Any field omitted is left unchanged. Use this instead of cancel + reschedule when adjusting an existing task.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Series id of the task to update (as shown by list_tasks)' },
|
||||
prompt: { type: 'string', description: 'New task prompt (optional)' },
|
||||
recurrence: {
|
||||
type: 'string',
|
||||
description: 'New cron expression (optional). Pass empty string to clear and make the task one-shot.',
|
||||
},
|
||||
processAfter: {
|
||||
type: 'string',
|
||||
description:
|
||||
`New ISO 8601 timestamp for the next run (optional). Accepts either UTC (ending in "Z" / "+00:00") or a naive local timestamp interpreted in the user's timezone.`,
|
||||
},
|
||||
script: {
|
||||
type: 'string',
|
||||
description: 'New pre-agent script (optional). Pass empty string to clear.',
|
||||
},
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
const update: Record<string, unknown> = { taskId };
|
||||
if (typeof args.prompt === 'string') update.prompt = args.prompt;
|
||||
if (typeof args.processAfter === 'string') {
|
||||
try {
|
||||
const d = parseZonedToUtc(args.processAfter, TIMEZONE);
|
||||
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${args.processAfter}`);
|
||||
update.processAfter = d.toISOString();
|
||||
} catch {
|
||||
return err(`invalid processAfter: ${args.processAfter}`);
|
||||
}
|
||||
}
|
||||
// Empty string clears recurrence/script; undefined leaves them as-is.
|
||||
if (typeof args.recurrence === 'string') update.recurrence = args.recurrence === '' ? null : args.recurrence;
|
||||
if (typeof args.script === 'string') update.script = args.script === '' ? null : args.script;
|
||||
|
||||
if (Object.keys(update).length === 1) return err('at least one field to update is required');
|
||||
|
||||
writeMessageOut({
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'update_task', ...update }),
|
||||
});
|
||||
|
||||
log(`update_task: ${taskId}`);
|
||||
return ok(`Task update requested: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
registerTools([scheduleTask, listTasks, updateTask, cancelTask, pauseTask, resumeTask]);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { getPendingMessages, markProcessing, markCompleted, markScriptSkipped, type MessageInRow } from './db/messages-in.js';
|
||||
import { hasIdenticalSend, writeMessageOut } from './db/messages-out.js';
|
||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
@@ -207,15 +207,15 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// Without the scheduling module, the marker block is empty, `keep`
|
||||
// falls back to `normalMessages`, and no gating happens.
|
||||
let keep: MessageInRow[] = normalMessages;
|
||||
let skipped: string[] = [];
|
||||
let skipped: Array<{ id: string; reason: string }> = [];
|
||||
// MODULE-HOOK:scheduling-pre-task:start
|
||||
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
|
||||
const preTask = await applyPreTaskScripts(normalMessages);
|
||||
keep = preTask.keep;
|
||||
skipped = preTask.skipped;
|
||||
if (skipped.length > 0) {
|
||||
markCompleted(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.join(', ')}`);
|
||||
markScriptSkipped(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.map((s) => s.id).join(', ')}`);
|
||||
}
|
||||
// MODULE-HOOK:scheduling-pre-task:end
|
||||
|
||||
@@ -238,7 +238,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
});
|
||||
|
||||
// Process the query while concurrently polling for new messages
|
||||
const skippedSet = new Set(skipped);
|
||||
const skippedSet = new Set(skipped.map((s) => s.id));
|
||||
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
|
||||
// Publish the batch's in_reply_to so MCP tools (send_message, send_file)
|
||||
// can stamp it on outbound rows — needed for a2a return-path routing.
|
||||
@@ -401,15 +401,15 @@ export async function processQuery(
|
||||
// its script gate and always wakes the agent, defeating the gate.
|
||||
// Mirrors the initial-batch hook above.
|
||||
let keep = newMessages;
|
||||
let skipped: string[] = [];
|
||||
let skipped: Array<{ id: string; reason: string }> = [];
|
||||
// MODULE-HOOK:scheduling-pre-task-followup:start
|
||||
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
|
||||
const preTask = await applyPreTaskScripts(newMessages);
|
||||
keep = preTask.keep;
|
||||
skipped = preTask.skipped;
|
||||
if (skipped.length > 0) {
|
||||
markCompleted(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`);
|
||||
markScriptSkipped(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.map((s) => s.id).join(', ')}`);
|
||||
}
|
||||
// MODULE-HOOK:scheduling-pre-task-followup:end
|
||||
|
||||
@@ -650,6 +650,15 @@ function dispatchResultText(text: string, routing: RoutingContext): { sent: numb
|
||||
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
|
||||
const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
|
||||
const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
|
||||
// Task fires: an explicitly-addressed final-text block is either the echo of
|
||||
// an MCP send the agent already made this turn (drop it HERE, where the
|
||||
// duplication originates) or the agent's only deliberate send (write it
|
||||
// in_reply_to-null like the MCP path, or the host's task-fire suppression
|
||||
// would discard it — zero delivery).
|
||||
if (routing.taskFire && hasIdenticalSend(platformId, channelType, body)) {
|
||||
log(`Dropping turn-final echo of an already-sent task message to ${dest.name}`);
|
||||
return;
|
||||
}
|
||||
// Resolve thread_id per-destination from the most recent inbound message
|
||||
// that came from this same channel+platform. In agent-shared sessions,
|
||||
// different destinations have different thread contexts — using a single
|
||||
@@ -657,7 +666,7 @@ function sendToDestination(dest: DestinationEntry, body: string, routing: Routin
|
||||
const destRouting = resolveDestinationThread(channelType, platformId);
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo,
|
||||
in_reply_to: destRouting?.inReplyTo ?? (routing.taskFire ? null : routing.inReplyTo),
|
||||
kind: 'chat',
|
||||
platform_id: platformId,
|
||||
channel_type: channelType,
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'path';
|
||||
import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
import { clearContainerToolInFlight, setContainerToolInFlight } from '../db/connection.js';
|
||||
import { TIMEZONE, formatLocalStamp } from '../timezone.js';
|
||||
import { registerProvider } from './provider-registry.js';
|
||||
import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
|
||||
|
||||
@@ -17,7 +18,7 @@ function log(msg: string): void {
|
||||
// Code's interactive UI and would hang here).
|
||||
//
|
||||
// - CronCreate / CronDelete / CronList / ScheduleWakeup: we have durable
|
||||
// scheduling via mcp__nanoclaw__schedule_task.
|
||||
// scheduling via `ncl tasks`.
|
||||
// - AskUserQuestion: SDK returns a placeholder instead of blocking on a
|
||||
// real answer — we have mcp__nanoclaw__ask_user_question that persists
|
||||
// the question and blocks on the real reply.
|
||||
@@ -223,7 +224,9 @@ function archiveTranscriptFile(transcriptPath: string | undefined, sessionId: st
|
||||
|
||||
const conversationsDir = process.env.NANOCLAW_CONVERSATIONS_DIR || '/workspace/agent/conversations';
|
||||
fs.mkdirSync(conversationsDir, { recursive: true });
|
||||
const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`;
|
||||
// Local calendar date — the fallback `name` above already uses local
|
||||
// hours, and the agent navigates conversations/ by these date prefixes.
|
||||
const filename = `${formatLocalStamp(new Date(), TIMEZONE).slice(0, 10)}-${name}.md`;
|
||||
fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName));
|
||||
log(`Archived conversation to ${filename}`);
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Container leg of the script-failure backoff chain, tested at unit level so
|
||||
* the e2e suite doesn't need a live multi-sweep scenario for it:
|
||||
*
|
||||
* script error → applyPreTaskScripts skips with reason 'error'
|
||||
* → markScriptSkipped acks `script-skip:error` in outbound.db
|
||||
* (gated → plain 'completed': the monitor working as designed).
|
||||
*
|
||||
* The host leg (ack → FAILED run → streak backoff) is pinned in
|
||||
* src/db/session-db.test.ts and src/modules/scheduling/recurrence.test.ts —
|
||||
* both sides pin the literal 'script-skip:error'; if either renames it, its
|
||||
* own test goes red.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '../db/connection.js';
|
||||
import { getPendingMessages, markScriptSkipped } from '../db/messages-in.js';
|
||||
import { applyPreTaskScripts } from './task-script.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
function insertTask(id: string, script: string) {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, trigger, content)
|
||||
VALUES (?, 'task', datetime('now'), 'pending', 1, ?)`,
|
||||
)
|
||||
.run(id, JSON.stringify({ prompt: 'monitor', script }));
|
||||
}
|
||||
|
||||
const ackStatus = (id: string): string | undefined =>
|
||||
(getOutboundDb().prepare('SELECT status FROM processing_ack WHERE message_id = ?').get(id) as { status: string } | undefined)
|
||||
?.status;
|
||||
|
||||
describe('script-skip ack chain (container leg)', () => {
|
||||
it('an erroring script skips with reason "error" and acks script-skip:error', async () => {
|
||||
insertTask('t-err', 'echo boom >&2; exit 1');
|
||||
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
|
||||
|
||||
expect(keep).toHaveLength(0);
|
||||
expect(skipped).toEqual([{ id: 't-err', reason: 'error' }]);
|
||||
|
||||
markScriptSkipped(skipped);
|
||||
expect(ackStatus('t-err')).toBe('script-skip:error');
|
||||
});
|
||||
|
||||
it('a deliberate wakeAgent=false gate acks plain completed — never backs off', async () => {
|
||||
insertTask('t-gated', 'echo \'{"wakeAgent": false}\'');
|
||||
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
|
||||
|
||||
expect(keep).toHaveLength(0);
|
||||
expect(skipped).toEqual([{ id: 't-gated', reason: 'gated' }]);
|
||||
|
||||
markScriptSkipped(skipped);
|
||||
expect(ackStatus('t-gated')).toBe('completed');
|
||||
});
|
||||
|
||||
it('wakeAgent=true keeps the task and enriches the prompt with script data', async () => {
|
||||
insertTask('t-wake', 'echo \'{"wakeAgent": true, "data": {"alerts": 2}}\'');
|
||||
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
|
||||
|
||||
expect(skipped).toHaveLength(0);
|
||||
expect(keep).toHaveLength(1);
|
||||
expect(JSON.parse(keep[0].content).scriptOutput).toEqual({ alerts: 2 });
|
||||
});
|
||||
});
|
||||
@@ -64,21 +64,26 @@ export async function runScript(script: string, taskId: string): Promise<ScriptR
|
||||
});
|
||||
}
|
||||
|
||||
/** Why a script gated its task: deliberate wakeAgent=false vs a broken script. */
|
||||
export type ScriptSkipReason = 'gated' | 'error';
|
||||
|
||||
export interface TaskScriptOutcome {
|
||||
keep: MessageInRow[];
|
||||
skipped: string[];
|
||||
skipped: Array<{ id: string; reason: ScriptSkipReason }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run pre-task scripts for any task messages that carry one, serially.
|
||||
* - Errors / missing output / wakeAgent=false → task id added to `skipped`.
|
||||
* - Errors / missing output / wakeAgent=false → task id added to `skipped`,
|
||||
* with the reason. The caller acks these as script-skips (not plain
|
||||
* completions) so the host can count consecutive failures and back off.
|
||||
* - wakeAgent=true → content JSON is mutated to carry `scriptOutput`, so the
|
||||
* formatter renders it into the prompt.
|
||||
* Non-task messages and tasks without scripts pass through unchanged.
|
||||
*/
|
||||
export async function applyPreTaskScripts(messages: MessageInRow[]): Promise<TaskScriptOutcome> {
|
||||
const keep: MessageInRow[] = [];
|
||||
const skipped: string[] = [];
|
||||
const skipped: Array<{ id: string; reason: ScriptSkipReason }> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.kind !== 'task') {
|
||||
@@ -106,9 +111,9 @@ export async function applyPreTaskScripts(messages: MessageInRow[]): Promise<Tas
|
||||
touchHeartbeat();
|
||||
|
||||
if (!result || !result.wakeAgent) {
|
||||
const reason = result ? 'wakeAgent=false' : 'script error/no output';
|
||||
log(`task ${msg.id} skipped: ${reason}`);
|
||||
skipped.push(msg.id);
|
||||
const reason: ScriptSkipReason = result ? 'gated' : 'error';
|
||||
log(`task ${msg.id} skipped: ${reason === 'gated' ? 'wakeAgent=false' : 'script error/no output'}`);
|
||||
skipped.push({ id: msg.id, reason });
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,22 @@ export function formatLocalTime(utcIso: string, timezone: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact sortable local stamp for log lines: "YYYY-MM-DD HH:mm" in `timezone`.
|
||||
* (sv-SE is the one locale whose default rendering is this exact shape.)
|
||||
*/
|
||||
export function formatLocalStamp(date: Date, timezone: string): string {
|
||||
return date.toLocaleString('sv-SE', {
|
||||
timeZone: resolveTimezone(timezone),
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveContainerTimezone(): string {
|
||||
const candidates = [process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone];
|
||||
for (const tz of candidates) {
|
||||
|
||||
@@ -92,6 +92,41 @@ agent-browser wait --url "**/dashboard" # Wait for URL pattern
|
||||
agent-browser wait --load networkidle # Wait for network idle
|
||||
```
|
||||
|
||||
### Waiting for a custom condition — ALWAYS bound it
|
||||
|
||||
Prefer the built-in `wait` subcommands above. Only fall back to `eval`-polling
|
||||
when you must wait on a custom JS condition (e.g. a spinner disappearing or a
|
||||
"Send" button re-enabling in a chat UI).
|
||||
|
||||
**Never write an unbounded wait loop.** A bare `until … do sleep; done` that
|
||||
polls a page condition will loop *forever* if the condition never becomes true
|
||||
(page failed to load, selector changed, network stalled). That does not just
|
||||
fail the command — it wedges the entire agent turn: the runner keeps the model
|
||||
stream open, later messages get silently swallowed, and the container can hang
|
||||
for hours without the host's stuck-detection firing.
|
||||
|
||||
Always cap the wait with BOTH a wall-clock `timeout` and a max-attempts counter,
|
||||
and always exit the loop (never leave a `sleep` loop as the last thing running):
|
||||
|
||||
```bash
|
||||
# Bounded wait: succeeds when the condition is met, gives up after ~90s.
|
||||
timeout 90 bash -c '
|
||||
for i in $(seq 1 30); do
|
||||
if agent-browser eval "document.querySelector(\".loading\") === null" 2>/dev/null | grep -q true; then
|
||||
echo READY; exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo TIMEOUT; exit 1
|
||||
'
|
||||
# Check the exit status / output: on TIMEOUT, snapshot the page and decide —
|
||||
# do NOT re-enter another unbounded wait.
|
||||
```
|
||||
|
||||
If the wait times out, treat it as a real failure: take a `snapshot -i` or
|
||||
`screenshot` to see the actual page state, report what you found, and move on.
|
||||
Retrying the same unbounded wait is what causes the hang.
|
||||
|
||||
### Semantic locators (alternative to refs)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
name: slack-formatting
|
||||
description: Format messages for Slack using mrkdwn syntax. Use when responding to Slack channels (folder starts with "slack_" or JID contains slack identifiers).
|
||||
---
|
||||
|
||||
# Slack Message Formatting (mrkdwn)
|
||||
|
||||
When responding to Slack channels, use Slack's mrkdwn syntax instead of standard Markdown.
|
||||
|
||||
## How to detect Slack context
|
||||
|
||||
Check your group folder name or workspace path:
|
||||
- Folder starts with `slack_` (e.g., `slack_engineering`, `slack_general`)
|
||||
- Or check `/workspace/group/` path for `slack_` prefix
|
||||
|
||||
## Formatting reference
|
||||
|
||||
### Text styles
|
||||
|
||||
| Style | Syntax | Example |
|
||||
|-------|--------|---------|
|
||||
| Bold | `*text*` | *bold text* |
|
||||
| Italic | `_text_` | _italic text_ |
|
||||
| Strikethrough | `~text~` | ~strikethrough~ |
|
||||
| Code (inline) | `` `code` `` | `inline code` |
|
||||
| Code block | ` ```code``` ` | Multi-line code |
|
||||
|
||||
### Links and mentions
|
||||
|
||||
```
|
||||
<https://example.com|Link text> # Named link
|
||||
<https://example.com> # Auto-linked URL
|
||||
<@U1234567890> # Mention user by ID
|
||||
<#C1234567890> # Mention channel by ID
|
||||
<!here> # @here
|
||||
<!channel> # @channel
|
||||
```
|
||||
|
||||
### Lists
|
||||
|
||||
Slack supports simple bullet lists but NOT numbered lists:
|
||||
|
||||
```
|
||||
• First item
|
||||
• Second item
|
||||
• Third item
|
||||
```
|
||||
|
||||
Use `•` (bullet character) or `- ` or `* ` for bullets.
|
||||
|
||||
### Block quotes
|
||||
|
||||
```
|
||||
> This is a block quote
|
||||
> It can span multiple lines
|
||||
```
|
||||
|
||||
### Emoji
|
||||
|
||||
Use standard emoji shortcodes: `:white_check_mark:`, `:x:`, `:rocket:`, `:tada:`
|
||||
|
||||
## What NOT to use
|
||||
|
||||
- **NO** `##` headings (use `*Bold text*` for headers instead)
|
||||
- **NO** `**double asterisks**` for bold (use `*single asterisks*`)
|
||||
- **NO** `[text](url)` links (use `<url|text>` instead)
|
||||
- **NO** `1.` numbered lists (use bullets with numbers: `• 1. First`)
|
||||
- **NO** tables (use code blocks or plain text alignment)
|
||||
- **NO** `---` horizontal rules
|
||||
|
||||
## Example message
|
||||
|
||||
```
|
||||
*Daily Standup Summary*
|
||||
|
||||
_March 21, 2026_
|
||||
|
||||
• *Completed:* Fixed authentication bug in login flow
|
||||
• *In Progress:* Building new dashboard widgets
|
||||
• *Blocked:* Waiting on API access from DevOps
|
||||
|
||||
> Next sync: Monday 10am
|
||||
|
||||
:white_check_mark: All tests passing | <https://ci.example.com/builds/123|View Build>
|
||||
```
|
||||
|
||||
## Quick rules
|
||||
|
||||
1. Use `*bold*` not `**bold**`
|
||||
2. Use `<url|text>` not `[text](url)`
|
||||
3. Use `•` bullets, avoid numbered lists
|
||||
4. Use `:emoji:` shortcodes
|
||||
5. Quote blocks with `>`
|
||||
6. Skip headings — use bold text instead
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
name: whatsapp-formatting
|
||||
description: Format messages for WhatsApp, including mentions that render as real WhatsApp tags. Use when responding in a WhatsApp conversation (platform_id / chatJid ends with @s.whatsapp.net or @g.us).
|
||||
---
|
||||
|
||||
# WhatsApp Message Formatting
|
||||
|
||||
WhatsApp uses its own lightweight markup and a phone-number-based mention syntax. The host's WhatsApp adapter (Baileys) handles markdown conversion automatically, but **mentions are only protocol-level mentions if you use the right syntax** — otherwise they render as plain text and don't notify the recipient.
|
||||
|
||||
## How to detect WhatsApp context
|
||||
|
||||
You're in a WhatsApp conversation when any of these are true:
|
||||
- The chat JID / platform id ends with `@s.whatsapp.net` (1-on-1 DM)
|
||||
- The chat JID / platform id ends with `@g.us` (group)
|
||||
- Your inbound message metadata has `chatJid` matching the above
|
||||
|
||||
## Mentions — the important part
|
||||
|
||||
To tag a user so their name appears **bold and clickable** in WhatsApp and they get a push notification, write the `@` followed by their phone number digits (no `+`, no spaces, no display name):
|
||||
|
||||
```
|
||||
@15551234567 can you confirm?
|
||||
```
|
||||
|
||||
The adapter scans your outgoing text for `@<digits>` (5–15 digits, optional leading `+` is stripped) and tells WhatsApp to render them as real mention tags.
|
||||
|
||||
**The sender's phone JID is always in your inbound message metadata.** When a user writes to you, inbound `content.sender` looks like `15551234567@s.whatsapp.net`. The part before the `@` is exactly what you put after `@` when tagging them back.
|
||||
|
||||
### Wrong vs right
|
||||
|
||||
| You write | What recipients see |
|
||||
|-----------|---------------------|
|
||||
| `@Adam can you...` | Plain text `@Adam`. No tag, no notification. |
|
||||
| `@15551234567 can you...` | Bold/blue **@Adam** (or whatever name they're saved as), notification fires. |
|
||||
| `@+15551234567 ...` | Same as above — adapter strips the `+`. |
|
||||
|
||||
### Picking who to tag
|
||||
|
||||
- In a DM, there's no real need to tag the recipient (they already see every message), but tagging still works if you want emphasis.
|
||||
- In a group, look at the `participants` / inbound `content.sender` to find the JID of the person you mean. Don't guess from display names — pushNames can collide and are not reliable.
|
||||
- If you don't know the JID, just refer to the person by name in plain prose. Don't write `@<name>` — it won't tag and it will look like a tag that failed.
|
||||
|
||||
## Text styles
|
||||
|
||||
WhatsApp uses single-character delimiters, *not* doubled like standard Markdown.
|
||||
|
||||
| Style | Syntax | Renders as |
|
||||
|-------|--------|------------|
|
||||
| Bold | `*bold*` | **bold** |
|
||||
| Italic | `_italic_` | *italic* |
|
||||
| Strikethrough | `~strike~` | ~strike~ |
|
||||
| Monospace | `` `code` `` | `code` |
|
||||
| Block monospace | ```` ```block``` ```` | preformatted block |
|
||||
|
||||
The adapter converts standard Markdown (`**bold**`, `[link](url)`, `# heading`) to the WhatsApp-native form automatically, so you don't have to think about it — but be aware that single asterisks become italics, not bold.
|
||||
|
||||
## What not to do
|
||||
|
||||
- Don't write `<@U123>` (that's Slack), `<@!123>` (Discord), or any other channel's mention syntax.
|
||||
- Don't paste a full JID like `@15551234567@s.whatsapp.net` in the text — only the digits before the JID's `@` go after your `@`.
|
||||
- Don't try to tag display names. WhatsApp has no display-name-based mention API.
|
||||
@@ -1,19 +0,0 @@
|
||||
## WhatsApp mentions — always use phone digits
|
||||
|
||||
When you are replying in a WhatsApp conversation (the inbound message's `chatJid` ends with `@s.whatsapp.net` for a DM or `@g.us` for a group), and you want to tag a person so their name appears **bold and clickable** with a push notification, write `@` followed by their phone-number digits — never the display name.
|
||||
|
||||
**The sender's phone JID is in your inbound message metadata** at `content.sender` (e.g. `15551234567@s.whatsapp.net`). The part before the `@` is exactly what you put after `@` when tagging them.
|
||||
|
||||
| You write | What recipients see |
|
||||
|-----------|---------------------|
|
||||
| `@Adam, can you...` | Plain text. No tag, no notification. |
|
||||
| `@15551234567, can you...` | Bold/blue **@Adam** (whatever name they're saved as), notification fires. |
|
||||
| `@+15551234567 ...` | Same as above — the adapter strips the `+` automatically. |
|
||||
|
||||
The host adapter scans your outbound text for `@<5–15 digits>` (with optional leading `+`) and tells WhatsApp to render those as real mention tags. If the digits aren't in the text, the tag doesn't render — no exceptions.
|
||||
|
||||
### In groups
|
||||
|
||||
Tag the person you're addressing using their JID from inbound metadata (look at the most recent message from them). Don't guess — pushNames collide and aren't reliable.
|
||||
|
||||
If you don't know someone's JID, refer to them by name in plain prose. Do not write `@<displayname>` hoping it works.
|
||||
@@ -134,11 +134,11 @@ A personal AI assistant accessible via messaging, with minimal custom code.
|
||||
|
||||
### Scheduler
|
||||
- Built-in scheduler runs on the host, spawns containers for task execution
|
||||
- Custom `nanoclaw` MCP server (inside container) provides scheduling tools
|
||||
- Tools: `schedule_task`, `list_tasks`, `pause_task`, `resume_task`, `cancel_task`, `send_message`
|
||||
- `ncl tasks` provides scheduling commands
|
||||
- Commands: `list`, `get`, `create`, `update`, `cancel`, `pause`, `resume`, `delete`, `run`, `append-log`
|
||||
- Tasks stored in SQLite with run history
|
||||
- Scheduler loop checks for due tasks every minute
|
||||
- Tasks execute Claude Agent SDK in containerized group context
|
||||
- Tasks execute in the agent group's system session
|
||||
|
||||
### Web Access
|
||||
- Built-in WebSearch and WebFetch tools
|
||||
|
||||
+3
-20
@@ -579,12 +579,7 @@ NanoClaw has a built-in scheduler that runs tasks as full agents in their group'
|
||||
```
|
||||
User: @Andy remind me every Monday at 9am to review the weekly metrics
|
||||
|
||||
Claude: [calls mcp__nanoclaw__schedule_task]
|
||||
{
|
||||
"prompt": "Send a reminder to review weekly metrics. Be encouraging!",
|
||||
"schedule_type": "cron",
|
||||
"schedule_value": "0 9 * * 1"
|
||||
}
|
||||
Claude: [runs ncl tasks create --prompt "Send a reminder to review weekly metrics. Be encouraging!" --process-after "2024-02-05T09:00:00" --recurrence "0 9 * * 1"]
|
||||
|
||||
Claude: Done! I'll remind you every Monday at 9am.
|
||||
```
|
||||
@@ -594,12 +589,7 @@ Claude: Done! I'll remind you every Monday at 9am.
|
||||
```
|
||||
User: @Andy at 5pm today, send me a summary of today's emails
|
||||
|
||||
Claude: [calls mcp__nanoclaw__schedule_task]
|
||||
{
|
||||
"prompt": "Search for today's emails, summarize the important ones, and send the summary to the group.",
|
||||
"schedule_type": "once",
|
||||
"schedule_value": "2024-01-31T17:00:00Z"
|
||||
}
|
||||
Claude: [runs ncl tasks create --prompt "Search for today's emails, summarize the important ones, and send the summary to the group." --process-after "2024-01-31T17:00:00"]
|
||||
```
|
||||
|
||||
### Managing Tasks
|
||||
@@ -620,18 +610,11 @@ From main channel:
|
||||
|
||||
### NanoClaw MCP (built-in)
|
||||
|
||||
The `nanoclaw` MCP server is created dynamically per agent call with the current group's context.
|
||||
The `nanoclaw` MCP server is created dynamically per agent call with the current group's context. Scheduled task management lives in `ncl tasks`, not MCP.
|
||||
|
||||
**Available Tools:**
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `schedule_task` | Schedule a recurring or one-time task |
|
||||
| `list_tasks` | Show tasks (group's tasks, or all if main) |
|
||||
| `get_task` | Get task details and run history |
|
||||
| `update_task` | Modify task prompt or schedule |
|
||||
| `pause_task` | Pause a task |
|
||||
| `resume_task` | Resume a paused task |
|
||||
| `cancel_task` | Delete a task |
|
||||
| `send_message` | Send a message to the group via its channel |
|
||||
|
||||
---
|
||||
|
||||
@@ -634,52 +634,18 @@ destination is of type `agent`. `resolveRouting` maps it to a `messages_out` row
|
||||
`channel_type: 'agent'` and `platform_id` set to the target agent group id; the host
|
||||
validates the send and routes it into the target session's `inbound.db`.
|
||||
|
||||
#### schedule_task
|
||||
#### ncl tasks
|
||||
|
||||
Schedule a one-shot or recurring task.
|
||||
Schedule, inspect, and modify one-shot or recurring tasks.
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'schedule_task',
|
||||
params: {
|
||||
prompt: string, // task prompt
|
||||
processAfter: string, // ISO timestamp for first run
|
||||
recurrence?: string, // cron expression (optional)
|
||||
script?: string, // pre-agent script (optional)
|
||||
}
|
||||
}
|
||||
```bash
|
||||
ncl tasks create --prompt "..." --process-after "2026-01-15T09:00:00" --recurrence "0 9 * * *"
|
||||
ncl tasks list
|
||||
ncl tasks update <series_id> --prompt "..."
|
||||
ncl tasks cancel <series_id>
|
||||
```
|
||||
|
||||
Implementation: the container can't write host-owned `inbound.db`, so this writes a `messages_out` row with `kind: 'system'` and `action: 'schedule_task'` (`container/agent-runner/src/mcp-tools/scheduling.ts`). During delivery the host's action handler (`src/modules/scheduling/actions.ts` → `insertTask()` in `src/modules/scheduling/db.ts`) inserts the `kind: 'task'` row into `inbound.db` with `process_after` and optionally `recurrence`. The host sweep picks it up when due.
|
||||
|
||||
#### list_tasks
|
||||
|
||||
List active scheduled/recurring tasks.
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'list_tasks',
|
||||
params: {}
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: a read, not a write — the container may read the read-only `inbound.db` mount directly. Returns one row per series (the live pending/paused occurrence): `SELECT series_id AS id, ... FROM messages_in WHERE kind = 'task' AND status IN ('pending','paused') GROUP BY series_id`. See `container/agent-runner/src/mcp-tools/scheduling.ts`.
|
||||
|
||||
#### cancel_task / pause_task / resume_task / update_task
|
||||
|
||||
Modify a scheduled task.
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'cancel_task',
|
||||
params: { taskId: string }
|
||||
}
|
||||
// pause_task: set status = 'paused' (new status value for recurring tasks)
|
||||
// resume_task: set status = 'pending'
|
||||
// update_task: merge { prompt?, recurrence?, processAfter?, script? } into the live row
|
||||
```
|
||||
|
||||
Implementation: all four are sent as system actions (`messages_out`, `kind: 'system'`, `action: 'cancel_task' | 'pause_task' | 'resume_task' | 'update_task'`) — the container never writes `inbound.db`. The host's handlers in `src/modules/scheduling/actions.ts` apply the change against `inbound.db` via `src/modules/scheduling/db.ts`: cancel/pause/resume flip status on the live row(s); update_task reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
|
||||
Implementation: the host writes `messages_in` task rows into the agent group's system session (`thread_id = system:tasks`). The host sweep wakes that system-session container when a task is due. The task agent chooses its destination at fire time by emitting `<message to="name">...</message>` or using `send_message`.
|
||||
|
||||
#### create_agent
|
||||
|
||||
|
||||
@@ -59,6 +59,52 @@ interface OutboundMessage {
|
||||
}
|
||||
```
|
||||
|
||||
### Channel Defaults
|
||||
|
||||
Each adapter can declare static wiring-time defaults. There are exactly two levels: the adapter declaration, and the per-wiring/per-messaging-group values chosen at creation. There is no DB config table for defaults — install-wide changes mean editing the adapter copy (skill-installed, user-owned).
|
||||
|
||||
```typescript
|
||||
// src/channels/adapter.ts
|
||||
interface ChannelContextDefaults {
|
||||
engageMode: 'pattern' | 'mention' | 'mention-sticky';
|
||||
engagePattern?: string; // required iff engageMode='pattern'; may contain the
|
||||
// literal token {name} — creation helpers substitute the
|
||||
// regex-escaped agent_group name
|
||||
threads: boolean; // whether thread ids are honored in this context by default;
|
||||
// must be false when the adapter's supportsThreads is false
|
||||
unknownSenderPolicy: 'strict' | 'request_approval' | 'public';
|
||||
}
|
||||
|
||||
interface ChannelDefaults {
|
||||
dm: ChannelContextDefaults;
|
||||
group: ChannelContextDefaults;
|
||||
mentions: 'platform' | 'dm-only' | 'never';
|
||||
// 'platform' — platform-confirmed mentions in groups, DMs flagged too
|
||||
// 'dm-only' — only DMs flagged (no group mention metadata)
|
||||
// 'never' — isMention never set: no auto-create card, mention wirings never engage
|
||||
}
|
||||
|
||||
// ChannelAdapter and ChannelRegistration both carry an optional `defaults` field.
|
||||
// The registration-level copy lets offline creation paths (setup/register.ts,
|
||||
// scripts/init-first-agent.ts) resolve declarations without instantiating the
|
||||
// adapter. ChatSdkBridgeConfig.defaults is copied verbatim onto the bridged
|
||||
// adapter, like supportsThreads.
|
||||
```
|
||||
|
||||
**Resolution chain** (`getChannelDefaults(key, channelType?)` in `src/channels/channel-registry.ts`, key = `mg.instance ?? mg.channel_type`, same discipline as `getChannelAdapter`):
|
||||
|
||||
1. Live adapter's `defaults`, instance-exact (lets an instance carry env-computed declarations, e.g. WhatsApp shared-number mode)
|
||||
2. Live adapter of that channelType
|
||||
3. Registration entry under the key
|
||||
4. Registration entry under the channelType (from the live adapter's channelType, else the caller-supplied hint)
|
||||
5. `fallbackChannelDefaults(supportsThreads)` — behavior-faithful to the pre-declaration router: dm `{ pattern '.', threads: supportsThreads, request_approval }`, group `{ mention-sticky, threads: supportsThreads, request_approval }`, mentions `'platform'`. `supportsThreads` is `false` when no adapter is live.
|
||||
|
||||
Never returns undefined. `hasDeclaredChannelDefaults()` reports whether tiers 1–4 hit; manual creation surfaces (`ncl`) gate declaration-derived defaults on it so stale (undeclared) adapters keep the legacy static schema defaults — a trunk update alone changes no behavior.
|
||||
|
||||
**Creation helpers** (`src/channels/channel-defaults.ts`): every wiring-creation path calls `resolveWiringDefaults(channelKey, isGroup, agentGroupName)` — it picks `decl.group` vs `decl.dm` by `isGroup = event.message.isGroup ?? (mg.is_group === 1)` (never `threadId !== null`), substitutes `{name}`, and downgrades `mention-sticky` → `mention` when the context's resolved threads value is false. `resolveUnknownSenderPolicy` does the same for auto-created messaging_groups rows.
|
||||
|
||||
**Runtime thread policy**: engage mode and sender policy are creation-time snapshots; threading is the one setting consulted live. `messaging_group_agents.threads` (migration 019) is the per-wiring override: `NULL` = inherit the adapter declaration for the wiring's context, `1`/`0` = explicit. `resolveThreadPolicy(wiring.threads, decl, isGroup, supportsThreads)` hard-ANDs the result with the adapter's raw capability — a wiring can opt out of threads on a threaded platform, never opt in on a non-threaded one. When the policy is off, event-derived thread ids are nulled at router fanout (sessions collapse, replies land top-level); `event.replyTo` is operator intent from the CLI transport and is never nulled.
|
||||
|
||||
### Chat SDK Bridge
|
||||
|
||||
Wraps a Chat SDK adapter + Chat instance to conform to the NanoClaw ChannelAdapter interface. Trunk ships the bridge and the channel registry only — platform-specific Chat SDK adapters (Discord, Slack, Telegram, etc.) and native adapters (WhatsApp/Baileys) are installed by the `/add-<channel>` skills from the `channels` branch.
|
||||
|
||||
@@ -32,7 +32,7 @@ flowchart TB
|
||||
direction TB
|
||||
PollLoop["Poll Loop<br/>(container/agent-runner)"]
|
||||
Provider["Agent providers<br/>(claude — the only one registered in trunk;<br/>opencode ships via the /add-opencode skill)"]
|
||||
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server"]
|
||||
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>create_agent, install_packages, add_mcp_server<br/>CLI: ncl tasks"]
|
||||
Skills["Container Skills<br/>(container/skills/)"]
|
||||
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>delivered<br/>destinations<br/>session_routing")]
|
||||
OutDB[("outbound.db<br/>container writes<br/>odd seq<br/>messages_out<br/>processing_ack<br/>session_state<br/>container_state<br/>heartbeat file")]
|
||||
|
||||
@@ -904,13 +904,10 @@ MCP tools write to the container's own `outbound.db`. Anything that needs a chan
|
||||
|
||||
(There is no `send_to_agent` tool — agent-to-agent is `send_message` to an `agent` destination.)
|
||||
|
||||
**Scheduling** (all emit `kind: 'system'` actions except the read-only `list_tasks`):
|
||||
|
||||
| Tool | What it does |
|
||||
|------|-------------|
|
||||
| `schedule_task` | `action: 'schedule_task'`; host inserts the `kind: 'task'` `messages_in` row with `process_after` + optional `recurrence` |
|
||||
| `list_tasks` | Read `inbound.db` (read-only) — one row per series: `kind = 'task' AND status IN ('pending','paused') GROUP BY series_id` |
|
||||
| `pause_task` / `resume_task` / `cancel_task` / `update_task` | matching `action`; host updates the live `messages_in` row(s) |
|
||||
**Scheduling**: scheduled-task management is not an MCP surface — it lives on
|
||||
`ncl tasks` (create/list/get/update/cancel/pause/resume/run/append-log). Due
|
||||
task rows live in the agent group's system session and are woken by the host
|
||||
sweep.
|
||||
|
||||
**Central-DB / self-modification** (`kind: 'system'` actions; host authorizes, often via admin approval):
|
||||
|
||||
|
||||
@@ -80,9 +80,11 @@ agent_groups (workspace, memory, CLAUDE.md, personality)
|
||||
↕ many-to-many
|
||||
messaging_groups (a specific channel/chat/group on a platform)
|
||||
via
|
||||
messaging_group_agents (session_mode, engage_mode, engage_pattern, sender_scope, ignored_message_policy, priority)
|
||||
messaging_group_agents (session_mode, engage_mode, engage_pattern, sender_scope, ignored_message_policy, priority, threads)
|
||||
```
|
||||
|
||||
Wiring-creation defaults for engage mode/pattern, thread policy, and unknown-sender policy come from the channel adapter's declaration (per DM/group context), overridable per wiring at creation — see [setup-wiring.md](setup-wiring.md#channel-defaults-two-level-model) and [api-details.md](api-details.md#channel-defaults).
|
||||
|
||||
- **Shared session:** multiple messaging_groups → same agent_group, `session_mode = 'agent-shared'`
|
||||
- **Same agent, separate sessions:** multiple messaging_groups → same agent_group, `session_mode = 'shared'`
|
||||
- **Separate agents:** each messaging_group → different agent_group
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# `ncl tasks` migration
|
||||
|
||||
## Detect
|
||||
|
||||
If an agent mentions `schedule_task`, `list_tasks`, `update_task`, `cancel_task`, `pause_task`, or `resume_task`, it is using the old scheduling MCP surface.
|
||||
|
||||
A subtler symptom of a stale container image: the agent reports a task as scheduled, but `ncl tasks list` shows nothing and the host log has `Unknown system action` — the old image's `schedule_task` call is acknowledged in-container and then dropped by the new host. The fix below (rebuild + restart) resolves it.
|
||||
|
||||
## Why
|
||||
|
||||
Scheduling moved to `ncl tasks`. New tasks are stored in a per-agent-group system session and run there, so a scheduled task does not wake an existing chat session. When it fires, the agent must choose the delivery destination explicitly.
|
||||
|
||||
## Fix
|
||||
|
||||
Rebuild and restart agent containers so they load the updated MCP tool list and instructions:
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
On Linux, restart with `systemctl --user restart nanoclaw`.
|
||||
|
||||
Use:
|
||||
|
||||
```bash
|
||||
ncl tasks list
|
||||
ncl tasks create --group <agent_group_id> --prompt "..." --process-after "2026-01-15T09:00:00" --recurrence "0 9 * * *"
|
||||
ncl tasks update --id <series_id> --prompt "..."
|
||||
ncl tasks cancel --id <series_id>
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
Run `ncl tasks list`. New task rows should show a system `session_id`, not the chat session that requested the task.
|
||||
|
||||
## Legacy tasks (scheduled before this update)
|
||||
|
||||
Tasks created through the old MCP tools live in the **chat session** that created them, not in a per-series system session. They are unaffected by this update: they keep firing and delivering exactly as before. Two things to know:
|
||||
|
||||
- An agent's own `ncl tasks list` (group scope) shows only its group's task rows; from the **host**, unscoped `ncl tasks list` enumerates everything, and `--session <id>` narrows to one session — that is how you find and manage legacy rows (`ncl tasks cancel --session <chat_session_id> --all` to clear a chat session's tasks).
|
||||
- The `messages_in` status enum now includes `cancelled` (cancel marks the row and clears its recurrence rather than deleting it). Custom code that exhaustively switches on task status needs the new arm.
|
||||
|
||||
## Rollback
|
||||
|
||||
Order matters:
|
||||
|
||||
1. Remove tasks created through `ncl tasks` (`ncl tasks list` / `delete`) — they live in per-series system sessions the old code doesn't know about.
|
||||
2. **Wait one sweep (≤60s)** so the host closes the now-empty task sessions.
|
||||
3. Then revert the update and rebuild the container image.
|
||||
|
||||
Reverting before the task sessions are collected leaves system sessions behind that the old `findSessionByAgentGroup` (which has no system-session exclusion) can resolve as the group's session — mis-routing agent-to-agent messages into a dead task thread.
|
||||
+15
-2
@@ -1,6 +1,6 @@
|
||||
# Setup Wiring — Status & Remaining Work
|
||||
|
||||
Last updated: 2026-04-09
|
||||
Last updated: 2026-07-10
|
||||
|
||||
## What's Done
|
||||
|
||||
@@ -35,6 +35,19 @@ Last updated: 2026-04-09
|
||||
### Router Logging
|
||||
- `src/router.ts` logs `MESSAGE DROPPED` at WARN level when no agents wired, with actionable guidance
|
||||
|
||||
### Channel Defaults (two-level model)
|
||||
|
||||
Each adapter declares its own wiring-time defaults (`ChannelDefaults`, see [api-details.md](api-details.md#channel-defaults)): per-context (DM vs group) engage mode, engage pattern, thread policy, and unknown-sender policy, plus how the platform signals mentions (`'platform' | 'dm-only' | 'never'`). Exactly two levels exist:
|
||||
|
||||
1. **Adapter declaration** — a static const in the adapter module (and its `ChannelRegistration`, so offline scripts resolve it without credentials). Adapters are skill-installed and user-owned; install-wide changes mean editing the adapter copy. No DB config table.
|
||||
2. **Per-wiring override** — the explicit value chosen at creation (`ncl` flag, wizard answer, card-flow value), stored on the row. Existing rows are never re-resolved; declarations are consulted only at creation, except threading, which stays live via `messaging_group_agents.threads` (`NULL` = inherit).
|
||||
|
||||
All creation paths (`ncl wirings`/`messaging-groups`, `setup/register.ts`, the router's auto-create, the channel-approval card flow, bootstrap scripts) go through the shared helpers in `src/channels/channel-defaults.ts`, so a platform's defaults are declared once and apply everywhere.
|
||||
|
||||
**Shared-identity pattern.** When the platform identity the adapter connects as belongs to a human (WhatsApp shared-number mode: `ASSISTANT_HAS_OWN_NUMBER` unset), the adapter itself suppresses mention signals — it never sets `isMention` and declares `mentions: 'never'`, group defaults of a name-pattern (`\b{name}\b`) and `strict` sender policy. With no mention signal, the router never auto-creates messaging groups or fires approval cards for the human's own conversations — the spam dies at the source, with zero core conditionals. The pattern is entirely channel-local and reusable by any adapter riding a personal identity (iMessage, Signal) with no core involvement.
|
||||
|
||||
**Back-compat contract.** A trunk update alone changes no behavior: stale (undeclared) adapters resolve through a behavior-faithful fallback at runtime, `ncl` keeps its legacy static defaults for them (gated on `hasDeclaredChannelDefaults`), existing DB rows are untouched, and the new `threads` column ships `NULL` everywhere — which reproduces today's `supportsThreads`-derived routing exactly. Updating an adapter copy (via its `/add-<channel>` skill) is what opts an install into that channel's new defaults, and even then only for wirings created afterwards. The one deliberate exception is the isGroup bugfix: card-approved groups on non-threaded platforms now wire via the group default instead of pattern `'.'` (group-ness comes from `event.message.isGroup ?? mg.is_group`, never `threadId !== null`).
|
||||
|
||||
---
|
||||
|
||||
## Previously Open — Now Resolved
|
||||
@@ -69,7 +82,7 @@ agent_groups (id, name, folder, agent_provider)
|
||||
↕ many-to-many (container runtime config lives in the separate container_configs table)
|
||||
messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, denied_at)
|
||||
via
|
||||
messaging_group_agents (messaging_group_id, agent_group_id, engage_mode, engage_pattern, sender_scope, ignored_message_policy, session_mode, priority)
|
||||
messaging_group_agents (messaging_group_id, agent_group_id, engage_mode, engage_pattern, sender_scope, ignored_message_policy, session_mode, priority, threads)
|
||||
|
||||
users (id, kind, display_name) -- namespaced as "<channel>:<handle>"
|
||||
user_roles (user_id, role, agent_group_id) -- owner / admin (global or scoped)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.40",
|
||||
"version": "2.1.46",
|
||||
"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="213k tokens, 106% of context window">
|
||||
<title>213k tokens, 106% 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="232k tokens, 116% of context window">
|
||||
<title>232k tokens, 116% 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">213k</text>
|
||||
<text x="71" y="14">213k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">232k</text>
|
||||
<text x="71" y="14">232k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -10,7 +10,8 @@
|
||||
* CLAUDE.md.
|
||||
*
|
||||
* Runs alongside the service (WAL-mode sqlite) — does NOT initialize
|
||||
* channel adapters, so there's no Gateway conflict.
|
||||
* channel adapters, so there's no Gateway conflict. (The channels barrel
|
||||
* import below only registers factories + declarations; nothing connects.)
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/init-cli-agent.ts \
|
||||
@@ -19,6 +20,13 @@
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
// Registration-only: makes the in-tree cli adapter's declared defaults
|
||||
// (pattern '.', no threads, 'public') resolvable below.
|
||||
import '../src/channels/index.js';
|
||||
import {
|
||||
resolveUnknownSenderPolicy,
|
||||
resolveWiringDefaults,
|
||||
} from '../src/channels/channel-defaults.js';
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
|
||||
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
|
||||
@@ -139,7 +147,9 @@ async function main(): Promise<void> {
|
||||
platform_id: CLI_PLATFORM_ID,
|
||||
name: 'Local CLI',
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'public',
|
||||
// cli declares 'public' for DMs: the socket is chmod 0600, so
|
||||
// "connected" ≈ "is the owner".
|
||||
unknown_sender_policy: resolveUnknownSenderPolicy(CLI_CHANNEL, false),
|
||||
created_at: now,
|
||||
};
|
||||
createMessagingGroup(cliMg);
|
||||
@@ -148,12 +158,15 @@ async function main(): Promise<void> {
|
||||
|
||||
const existing = getMessagingGroupAgentByPair(cliMg.id, ag.id);
|
||||
if (!existing) {
|
||||
// cli declares pattern '.' for DMs — every line the operator types is
|
||||
// for the agent. Identical to the pre-declaration hardcodes.
|
||||
const engage = resolveWiringDefaults(CLI_CHANNEL, false, ag.name);
|
||||
createMessagingGroupAgent({
|
||||
id: generateId('mga'),
|
||||
messaging_group_id: cliMg.id,
|
||||
agent_group_id: ag.id,
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
engage_mode: engage.engage_mode,
|
||||
engage_pattern: engage.engage_pattern,
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
* --display-name "Gavriel" \
|
||||
* [--agent-name "Andy"] \
|
||||
* [--welcome "System instruction: ..."] \
|
||||
* [--role owner|admin|member] # default: owner
|
||||
* [--role owner|admin|member] \ # default: owner
|
||||
* [--engage-pattern "."] # explicit DM engage regex override
|
||||
*
|
||||
* For direct-addressable channels (telegram, whatsapp, etc.), --platform-id
|
||||
* is typically the same as the handle in --user-id, with the channel prefix.
|
||||
@@ -34,6 +35,16 @@ import fs from 'fs';
|
||||
import net from 'net';
|
||||
import path from 'path';
|
||||
|
||||
// Registration-only barrel import: channel modules call
|
||||
// registerChannelAdapter() at module scope (factories are NOT invoked, no
|
||||
// adapter connects — no Gateway conflict with the running service), so
|
||||
// declared channel defaults resolve here without live adapters.
|
||||
import '../src/channels/index.js';
|
||||
import {
|
||||
resolveUnknownSenderPolicy,
|
||||
resolveWiringDefaults,
|
||||
} from '../src/channels/channel-defaults.js';
|
||||
import { hasDeclaredChannelDefaults } from '../src/channels/channel-registry.js';
|
||||
import { DATA_DIR, GROUPS_DIR } from '../src/config.js';
|
||||
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
@@ -62,6 +73,8 @@ interface Args {
|
||||
agentName: string;
|
||||
welcome: string;
|
||||
role: Role;
|
||||
/** Explicit engage regex for the DM wiring; omitted = channel declaration / '.'. */
|
||||
engagePattern?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_WELCOME =
|
||||
@@ -99,6 +112,10 @@ function parseArgs(argv: string[]): Args {
|
||||
out.welcome = val;
|
||||
i++;
|
||||
break;
|
||||
case '--engage-pattern':
|
||||
out.engagePattern = val;
|
||||
i++;
|
||||
break;
|
||||
case '--role': {
|
||||
const raw = (val ?? '').toLowerCase();
|
||||
if (raw !== 'owner' && raw !== 'admin' && raw !== 'member') {
|
||||
@@ -132,6 +149,7 @@ function parseArgs(argv: string[]): Args {
|
||||
agentName: out.agentName?.trim() || out.displayName!,
|
||||
welcome: out.welcome?.trim() || DEFAULT_WELCOME,
|
||||
role: out.role ?? DEFAULT_ROLE,
|
||||
engagePattern: out.engagePattern?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,21 +161,41 @@ function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function wireIfMissing(mg: MessagingGroup, ag: AgentGroup, now: string, label: string): void {
|
||||
function wireIfMissing(
|
||||
mg: MessagingGroup,
|
||||
ag: AgentGroup,
|
||||
now: string,
|
||||
label: string,
|
||||
engagePattern?: string,
|
||||
): void {
|
||||
const existing = getMessagingGroupAgentByPair(mg.id, ag.id);
|
||||
if (existing) {
|
||||
console.log(`Wiring already exists: ${existing.id} (${label})`);
|
||||
return;
|
||||
}
|
||||
// Engage defaults, first hit wins: explicit --engage-pattern → the
|
||||
// channel's declared defaults → the legacy heuristic for stale
|
||||
// (undeclared) adapters: DMs (is_group=0) respond to everything via a '.'
|
||||
// regex, group chats are mention-only; admins can reconfigure via
|
||||
// /manage-channels once the agent is in use.
|
||||
const isGroup = mg.is_group === 1;
|
||||
const channelKey = mg.instance ?? mg.channel_type;
|
||||
const engage = engagePattern
|
||||
? { engage_mode: 'pattern' as const, engage_pattern: engagePattern }
|
||||
: hasDeclaredChannelDefaults(channelKey, mg.channel_type)
|
||||
? resolveWiringDefaults(channelKey, isGroup, ag.name, mg.channel_type)
|
||||
: isGroup
|
||||
? { engage_mode: 'mention' as const, engage_pattern: null }
|
||||
: { engage_mode: 'pattern' as const, engage_pattern: '.' };
|
||||
createMessagingGroupAgent({
|
||||
id: generateId('mga'),
|
||||
messaging_group_id: mg.id,
|
||||
agent_group_id: ag.id,
|
||||
// DM / CLI (is_group=0) default to "respond to everything" via a '.' regex.
|
||||
// Group chats default to mention-only; admins can upgrade to mention-sticky
|
||||
// via /manage-channels once the agent is in use.
|
||||
engage_mode: mg.is_group === 0 ? 'pattern' : 'mention',
|
||||
engage_pattern: mg.is_group === 0 ? '.' : null,
|
||||
engage_mode: engage.engage_mode,
|
||||
engage_pattern: engage.engage_pattern,
|
||||
// Deliberate owner-bootstrap choices, not channel defaults: the operator
|
||||
// wires their own DM, so every sender is trusted ('all') and ignored
|
||||
// messages carry no value ('drop').
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
@@ -277,13 +315,18 @@ async function main(): Promise<void> {
|
||||
let dmMg = getMessagingGroupByPlatform(args.channel, platformId);
|
||||
if (!dmMg) {
|
||||
const mgId = generateId('mg');
|
||||
// Policy from the channel declaration (DM context); legacy 'strict' for
|
||||
// stale (undeclared) adapters so a trunk update alone changes nothing.
|
||||
const unknownSenderPolicy = hasDeclaredChannelDefaults(args.channel)
|
||||
? resolveUnknownSenderPolicy(args.channel, false)
|
||||
: 'strict';
|
||||
createMessagingGroup({
|
||||
id: mgId,
|
||||
channel_type: args.channel,
|
||||
platform_id: platformId,
|
||||
name: args.displayName,
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'strict',
|
||||
unknown_sender_policy: unknownSenderPolicy,
|
||||
created_at: now,
|
||||
});
|
||||
dmMg = getMessagingGroupByPlatform(args.channel, platformId)!;
|
||||
@@ -293,7 +336,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
// 4. Wire DM messaging group to the agent.
|
||||
wireIfMissing(dmMg, ag, now, 'dm');
|
||||
wireIfMissing(dmMg, ag, now, 'dm', args.engagePattern);
|
||||
|
||||
// 5. Welcome delivery over the CLI socket. Router picks up the line,
|
||||
// writes the message into the DM session's inbound.db, and wakes the
|
||||
|
||||
@@ -60,6 +60,9 @@ try {
|
||||
agent_group_id: AGENT_GROUP_ID,
|
||||
// Discord group channel → mention-sticky default. Mention once, stay
|
||||
// subscribed to the thread. Admins can tune via /manage-channels.
|
||||
// Kept manually in sync with DISCORD_DEFAULTS.group on the channels
|
||||
// branch (the adapter is skill-installed, so the declaration can't be
|
||||
// imported here) — re-check this hardcode if that declaration changes.
|
||||
engage_mode: 'mention-sticky',
|
||||
engage_pattern: null,
|
||||
sender_scope: 'all',
|
||||
|
||||
@@ -34,9 +34,10 @@ db.exec(`
|
||||
|
||||
// Insert test message
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`,
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', ?, 'pending', ?)`,
|
||||
).run(
|
||||
'test-1',
|
||||
new Date().toISOString(),
|
||||
JSON.stringify({ sender: 'Gavriel', text: 'Say "Hello from v2!" and nothing else. Do not use any tools.' }),
|
||||
);
|
||||
console.log('✓ Session DB created with test message');
|
||||
|
||||
@@ -51,6 +51,7 @@ fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/slack.ts ] && return 0
|
||||
[ ! -f container/skills/slack-formatting/SKILL.md ] && return 0
|
||||
! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
@@ -67,6 +68,10 @@ if need_install; then
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts
|
||||
|
||||
# Slack formatting container skill — reaches agents via ~/.claude/skills.
|
||||
mkdir -p container/skills/slack-formatting
|
||||
git show "${CHANNELS_BRANCH}:container/skills/slack-formatting/SKILL.md" > container/skills/slack-formatting/SKILL.md
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './slack.js';" src/channels/index.ts; then
|
||||
echo "import './slack.js';" >> src/channels/index.ts
|
||||
|
||||
@@ -43,6 +43,7 @@ log() { echo "[add-whatsapp] $*" >&2; }
|
||||
need_install() {
|
||||
[ ! -f src/channels/whatsapp.ts ] && return 0
|
||||
[ ! -f setup/groups.ts ] && return 0
|
||||
[ ! -f container/skills/whatsapp-formatting/instructions.md ] && return 0
|
||||
! grep -q "^import './whatsapp.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
! grep -q "'whatsapp-auth':" setup/index.ts 2>/dev/null && return 0
|
||||
! grep -q "^ groups:" setup/index.ts 2>/dev/null && return 0
|
||||
@@ -64,6 +65,12 @@ if need_install; then
|
||||
git show "${CHANNELS_BRANCH}:src/channels/whatsapp.ts" > src/channels/whatsapp.ts
|
||||
git show "${CHANNELS_BRANCH}:setup/groups.ts" > setup/groups.ts
|
||||
|
||||
# WhatsApp formatting container skill — feeds the composed CLAUDE.md
|
||||
# (skill-whatsapp-formatting.md fragment) and ~/.claude/skills.
|
||||
mkdir -p container/skills/whatsapp-formatting
|
||||
git show "${CHANNELS_BRANCH}:container/skills/whatsapp-formatting/SKILL.md" > container/skills/whatsapp-formatting/SKILL.md
|
||||
git show "${CHANNELS_BRANCH}:container/skills/whatsapp-formatting/instructions.md" > container/skills/whatsapp-formatting/instructions.md
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './whatsapp.js';" src/channels/index.ts; then
|
||||
echo "import './whatsapp.js';" >> src/channels/index.ts
|
||||
|
||||
+1
-1
@@ -1236,7 +1236,7 @@ async function askChannelChoice(): Promise<ChannelChoice> {
|
||||
options: [
|
||||
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
||||
{ value: 'discord', label: 'Yes, connect Discord' },
|
||||
{ value: 'whatsapp', label: 'Yes, connect WhatsApp' },
|
||||
{ value: 'whatsapp', label: 'Yes, connect WhatsApp', hint: 'best with a dedicated number' },
|
||||
{
|
||||
value: 'signal',
|
||||
label: 'Yes, connect Signal',
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { selfChatEngagePattern, writeEnvVar } from './whatsapp.js';
|
||||
|
||||
describe('selfChatEngagePattern', () => {
|
||||
it('matches messages starting with @<name> and nothing else', () => {
|
||||
const re = new RegExp(selfChatEngagePattern('Nano'));
|
||||
expect(re.test('@Nano what time is it?')).toBe(true);
|
||||
expect(re.test('@Nano')).toBe(true);
|
||||
expect(re.test('hey @Nano')).toBe(false);
|
||||
expect(re.test('grocery list')).toBe(false);
|
||||
// \b guard: name must end at a word boundary, not prefix a longer word.
|
||||
expect(re.test('@Nanobot hello')).toBe(false);
|
||||
});
|
||||
|
||||
it('escapes regex metacharacters in the agent name', () => {
|
||||
const re = new RegExp(selfChatEngagePattern('C-3PO (backup)'));
|
||||
expect(re.test('@C-3PO (backup) status?')).toBe(true);
|
||||
expect(re.test('@C-3PO Xbackup) status?')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the trailing \\b for names ending in non-word characters', () => {
|
||||
const pattern = selfChatEngagePattern('Nano!');
|
||||
expect(pattern.endsWith('\\b')).toBe(false);
|
||||
expect(new RegExp(pattern).test('@Nano! do the thing')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeEnvVar', () => {
|
||||
let dir: string;
|
||||
let envPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-'));
|
||||
envPath = path.join(dir, '.env');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates the file when missing', () => {
|
||||
writeEnvVar('ASSISTANT_NAME', 'Nano', envPath);
|
||||
expect(fs.readFileSync(envPath, 'utf-8')).toBe('ASSISTANT_NAME=Nano\n');
|
||||
});
|
||||
|
||||
it('appends to an existing file, adding a newline if needed', () => {
|
||||
fs.writeFileSync(envPath, 'TZ=UTC');
|
||||
writeEnvVar('ASSISTANT_HAS_OWN_NUMBER', 'true', envPath);
|
||||
expect(fs.readFileSync(envPath, 'utf-8')).toBe(
|
||||
'TZ=UTC\nASSISTANT_HAS_OWN_NUMBER=true\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces an existing line in place without touching neighbors', () => {
|
||||
fs.writeFileSync(envPath, 'ASSISTANT_NAME=Andy\nTZ=UTC\n');
|
||||
writeEnvVar('ASSISTANT_NAME', 'Nano', envPath);
|
||||
expect(fs.readFileSync(envPath, 'utf-8')).toBe('ASSISTANT_NAME=Nano\nTZ=UTC\n');
|
||||
});
|
||||
|
||||
it('keeps $-sequences in the value literal', () => {
|
||||
fs.writeFileSync(envPath, 'ASSISTANT_NAME=Andy\n');
|
||||
writeEnvVar('ASSISTANT_NAME', "$& $' $1", envPath);
|
||||
expect(fs.readFileSync(envPath, 'utf-8')).toBe("ASSISTANT_NAME=$& $' $1\n");
|
||||
});
|
||||
});
|
||||
+183
-45
@@ -1,24 +1,32 @@
|
||||
/**
|
||||
* WhatsApp (community/Baileys) channel flow for setup:auto.
|
||||
*
|
||||
* `runWhatsAppChannel(displayName)` owns the full branch from auth-method
|
||||
* picker through the welcome DM:
|
||||
* `runWhatsAppChannel(displayName)` owns the full branch from number-
|
||||
* ownership picker through the welcome DM:
|
||||
*
|
||||
* 1. Ask how to authenticate (QR code in terminal, default, or pairing code)
|
||||
* 2. If pairing-code: collect the phone number
|
||||
* 3. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
|
||||
* 4. Run the whatsapp-auth step, rendering status blocks as clack UI:
|
||||
* 1. Ask whether the agent gets a dedicated number or shares the
|
||||
* operator's personal one. Personal ⇒ interception screen that spells
|
||||
* out self-chat-only mode and steers toward alternatives (default is
|
||||
* back to channel selection)
|
||||
* 2. Ask how to authenticate (QR code in terminal, default, or pairing code)
|
||||
* 3. If pairing-code: collect the phone number
|
||||
* 4. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
|
||||
* 5. Run the whatsapp-auth step, rendering status blocks as clack UI:
|
||||
* - WHATSAPP_AUTH_QR (repeating): render the QR as terminal block art
|
||||
* inside a clack note. On rotation we clear the previous QR in-place
|
||||
* via ANSI escapes so the terminal doesn't fill up with stale codes.
|
||||
* - WHATSAPP_AUTH_PAIRING_CODE (one-shot): centred code card.
|
||||
* 5. Read store/auth/creds.json → extract the authenticated (bot) phone
|
||||
* 6. Kick the service so the adapter picks up the new credentials
|
||||
* 7. Ask the operator for the phone they'll chat from (defaults to the
|
||||
* authed number). Different number ⇒ dedicated mode ⇒ also writes
|
||||
* ASSISTANT_HAS_OWN_NUMBER=true so outbound replies aren't prefixed
|
||||
* 8. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 9. Wire the agent via scripts/init-first-agent.ts; the existing welcome
|
||||
* 6. Read store/auth/creds.json → extract the authenticated (bot) phone
|
||||
* 7. Dedicated: ask for the operator's personal number (the one they'll
|
||||
* chat from) and write ASSISTANT_HAS_OWN_NUMBER=true so outbound
|
||||
* replies aren't prefixed. Entering the bot's own number routes back
|
||||
* through the interception screen. Shared: chat number = bot number
|
||||
* 8. Ask for the messaging-agent name (defaulting to "Nano"); persist it
|
||||
* as ASSISTANT_NAME so the adapter's outbound prefix matches. Shared
|
||||
* mode also offers an @<name>-only engage pattern for the self-chat
|
||||
* 9. Kick the service — AFTER the env writes, since the adapter reads
|
||||
* ASSISTANT_HAS_OWN_NUMBER / ASSISTANT_NAME once at module load
|
||||
* 10. Wire the agent via scripts/init-first-agent.ts; the existing welcome
|
||||
* DM path delivers the greeting through the adapter
|
||||
*
|
||||
* All output obeys the three-level contract: clack UI for the user, structured
|
||||
@@ -55,6 +63,14 @@ const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
|
||||
type AuthMethod = 'qr' | 'pairing-code';
|
||||
|
||||
export async function runWhatsAppChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const ownership = await askNumberOwnership();
|
||||
if (ownership === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
let mode: 'dedicated' | 'shared' = ownership;
|
||||
if (mode === 'shared') {
|
||||
const proceed = await confirmSharedNumber();
|
||||
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
|
||||
const method = await askAuthMethod();
|
||||
if (method === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined;
|
||||
@@ -98,18 +114,35 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
);
|
||||
}
|
||||
|
||||
await restartService();
|
||||
|
||||
const chatPhone = await askChatPhone(botPhone);
|
||||
const isDedicated = chatPhone !== botPhone;
|
||||
if (isDedicated) {
|
||||
writeAssistantHasOwnNumber();
|
||||
let chatPhone = botPhone;
|
||||
if (mode === 'dedicated') {
|
||||
chatPhone = await askChatPhone(botPhone);
|
||||
if (chatPhone === botPhone) {
|
||||
// Chatting from the bot's own number IS the shared-number setup —
|
||||
// route through the same interception screen as the up-front pick.
|
||||
const proceed = await confirmSharedNumber();
|
||||
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
mode = 'shared';
|
||||
}
|
||||
}
|
||||
// Written in both modes so a re-run that switches dedicated → shared
|
||||
// doesn't leave a stale `true` behind.
|
||||
writeEnvVar('ASSISTANT_HAS_OWN_NUMBER', mode === 'dedicated' ? 'true' : 'false');
|
||||
|
||||
const role = await askOperatorRole('WhatsApp');
|
||||
setupLog.userInput('whatsapp_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
// Both modes: keep the adapter's outbound prefix / mention normalization
|
||||
// in sync with the chosen agent name (config default is 'Andy' otherwise).
|
||||
writeEnvVar('ASSISTANT_NAME', agentName);
|
||||
|
||||
const engagePattern = mode === 'shared' ? await askSelfChatEngage(agentName) : undefined;
|
||||
|
||||
// Restart only after ASSISTANT_HAS_OWN_NUMBER / ASSISTANT_NAME land in
|
||||
// .env — the adapter computes its shared/dedicated mode and name once at
|
||||
// module load, so restarting earlier would leave it running with defaults.
|
||||
await restartService();
|
||||
|
||||
const platformId = `${chatPhone}@s.whatsapp.net`;
|
||||
|
||||
@@ -124,10 +157,11 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
...(engagePattern ? ['--engage-pattern', engagePattern] : []),
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to WhatsApp…`,
|
||||
done: isDedicated
|
||||
done: mode === 'dedicated'
|
||||
? `${agentName} is ready. Check WhatsApp for a welcome message.`
|
||||
: `${agentName} is ready. Look in your "You" chat on WhatsApp for the welcome.`,
|
||||
},
|
||||
@@ -136,7 +170,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
CHANNEL: 'whatsapp',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: platformId,
|
||||
MODE: isDedicated ? 'dedicated' : 'shared',
|
||||
MODE: mode,
|
||||
ROLE: role,
|
||||
},
|
||||
},
|
||||
@@ -148,6 +182,112 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
'You can retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
if (mode === 'shared') {
|
||||
note(
|
||||
[
|
||||
'Only your self-chat is connected. Messages other people send to your',
|
||||
'number are ignored — never seen, never asked about.',
|
||||
'',
|
||||
k.dim('Wire a specific chat later with /manage-channels.'),
|
||||
].join('\n'),
|
||||
'Self-chat mode',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function askNumberOwnership(): Promise<'dedicated' | 'shared' | 'back'> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'Which WhatsApp number will your agent use?',
|
||||
options: [
|
||||
{
|
||||
value: 'dedicated',
|
||||
label: 'A dedicated number just for the agent',
|
||||
hint: 'recommended — spare SIM, eSIM, or old phone',
|
||||
},
|
||||
{
|
||||
value: 'shared',
|
||||
label: 'My own personal number',
|
||||
},
|
||||
{
|
||||
value: 'back',
|
||||
label: '← Back to channel selection',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'dedicated' | 'shared' | 'back';
|
||||
if (choice !== 'back') setupLog.userInput('whatsapp_number_ownership', choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interception screen for the shared-number path: make the self-chat-only
|
||||
* tradeoff explicit and steer toward alternatives before any install or
|
||||
* auth happens. Default is bailing back to channel selection.
|
||||
*/
|
||||
async function confirmSharedNumber(): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
[
|
||||
'On your personal number, the agent lives only in your "You" / self-chat.',
|
||||
'Messages other people send you are ignored entirely — never read, never',
|
||||
'answered, never flagged for approval. Nobody else can talk to the agent.',
|
||||
'',
|
||||
'If you want the agent reachable as its own contact, consider:',
|
||||
'',
|
||||
` • ${brandBold('Telegram')} — a bot takes ~2 minutes to set up`,
|
||||
` • ${brandBold('a dedicated WhatsApp number')} — spare SIM, eSIM, or old phone`,
|
||||
` • ${brandBold('/add-whatsapp-cloud')} — the official Meta Business API`,
|
||||
].join('\n'),
|
||||
'Personal number = self-chat only',
|
||||
);
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'How would you like to proceed?',
|
||||
options: [
|
||||
{ value: 'back', label: '← Pick a different channel' },
|
||||
{ value: 'continue', label: 'Continue — self-chat only' },
|
||||
],
|
||||
initialValue: 'back',
|
||||
}),
|
||||
) as 'continue' | 'back';
|
||||
setupLog.userInput('whatsapp_shared_confirm', choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared mode only: choose whether the agent answers everything in the
|
||||
* self-chat or only messages addressed to it by name. Returns the engage
|
||||
* regex for init-first-agent's --engage-pattern, or undefined for the
|
||||
* respond-to-everything default.
|
||||
*/
|
||||
async function askSelfChatEngage(agentName: string): Promise<string | undefined> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: `Respond to every self-chat message, or only messages starting with @${agentName}?`,
|
||||
options: [
|
||||
{
|
||||
value: 'all',
|
||||
label: 'Every message',
|
||||
hint: "the self-chat becomes the agent's inbox",
|
||||
},
|
||||
{
|
||||
value: 'mention',
|
||||
label: `Only messages starting with @${agentName}`,
|
||||
hint: 'keep the self-chat for your own notes too',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'all' | 'mention';
|
||||
setupLog.userInput('whatsapp_selfchat_engage', choice);
|
||||
return choice === 'all' ? undefined : selfChatEngagePattern(agentName);
|
||||
}
|
||||
|
||||
/** Engage regex for "only messages starting with @<name>". Exported for tests. */
|
||||
export function selfChatEngagePattern(agentName: string): string {
|
||||
const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
// \b only terminates a match after a word character — skip it for names
|
||||
// ending in punctuation, where it would never match.
|
||||
return /\w$/.test(agentName) ? `^@${escaped}\\b` : `^@${escaped}`;
|
||||
}
|
||||
|
||||
async function askAuthMethod(): Promise<AuthMethod | 'back'> {
|
||||
@@ -359,7 +499,7 @@ function readAuthedPhone(): string {
|
||||
|
||||
async function restartService(): Promise<void> {
|
||||
const s = p.spinner();
|
||||
s.start('Restarting NanoClaw so it sees your WhatsApp credentials…');
|
||||
s.start('Restarting NanoClaw so it sees your WhatsApp credentials and settings…');
|
||||
const start = Date.now();
|
||||
const platform = process.platform;
|
||||
try {
|
||||
@@ -402,25 +542,20 @@ async function restartService(): Promise<void> {
|
||||
async function askChatPhone(authedPhone: string): Promise<string> {
|
||||
note(
|
||||
[
|
||||
`Authenticated with ${k.cyan('+' + authedPhone)}.`,
|
||||
`The agent is signed in as ${k.cyan('+' + authedPhone)}.`,
|
||||
'',
|
||||
"What's the phone number you'll chat with your agent from?",
|
||||
'',
|
||||
k.dim(
|
||||
'Same number = messages will land in your "You" / self-chat on WhatsApp\n' +
|
||||
"(you won't be able to reply to yourself — use a different number for a\n" +
|
||||
'two-way chat).',
|
||||
),
|
||||
"Now, your personal number — the one you'll chat with the agent from.",
|
||||
"It'll show up as a normal two-way conversation with the agent's contact.",
|
||||
].join('\n'),
|
||||
'Your chat number',
|
||||
'Your personal number',
|
||||
);
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Your personal phone number',
|
||||
placeholder: authedPhone,
|
||||
defaultValue: authedPhone,
|
||||
message: "Your personal number, where you'll chat from",
|
||||
placeholder: 'e.g. 14155551234',
|
||||
validate: (v) => {
|
||||
const t = (v ?? authedPhone).trim();
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Phone number is required';
|
||||
if (!/^\d{8,15}$/.test(t)) {
|
||||
return 'Digits only, country code included.';
|
||||
}
|
||||
@@ -428,28 +563,31 @@ async function askChatPhone(authedPhone: string): Promise<string> {
|
||||
},
|
||||
}),
|
||||
);
|
||||
const phone = ((answer as string) || authedPhone).trim();
|
||||
const phone = (answer as string).trim();
|
||||
setupLog.userInput('whatsapp_chat_phone', phone);
|
||||
return phone;
|
||||
}
|
||||
|
||||
/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env. */
|
||||
function writeAssistantHasOwnNumber(): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
/** Persist KEY=value to .env, replacing any existing KEY line. Exported for tests. */
|
||||
export function writeEnvVar(
|
||||
key: string,
|
||||
value: string,
|
||||
envPath: string = path.join(process.cwd(), '.env'),
|
||||
): void {
|
||||
let contents = '';
|
||||
try {
|
||||
contents = fs.readFileSync(envPath, 'utf-8');
|
||||
} catch {
|
||||
contents = '';
|
||||
}
|
||||
if (/^ASSISTANT_HAS_OWN_NUMBER=/m.test(contents)) {
|
||||
contents = contents.replace(
|
||||
/^ASSISTANT_HAS_OWN_NUMBER=.*$/m,
|
||||
'ASSISTANT_HAS_OWN_NUMBER=true',
|
||||
);
|
||||
const line = `${key}=${value}`;
|
||||
const existing = new RegExp(`^${key}=.*$`, 'm');
|
||||
if (existing.test(contents)) {
|
||||
// Replacement via callback so `$`-sequences in the value stay literal.
|
||||
contents = contents.replace(existing, () => line);
|
||||
} else {
|
||||
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
|
||||
contents += 'ASSISTANT_HAS_OWN_NUMBER=true\n';
|
||||
contents += line + '\n';
|
||||
}
|
||||
fs.writeFileSync(envPath, contents);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ echo "=== NANOCLAW SETUP: INSTALL_SLACK ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/slack.ts ]] || needs_install=true
|
||||
[[ -f container/skills/slack-formatting/SKILL.md ]] || needs_install=true
|
||||
grep -q "import './slack.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/slack"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/slack ]] || needs_install=true
|
||||
@@ -30,6 +31,8 @@ git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
|
||||
mkdir -p container/skills/slack-formatting
|
||||
git show origin/channels:container/skills/slack-formatting/SKILL.md > container/skills/slack-formatting/SKILL.md
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './slack.js';" src/channels/index.ts; then
|
||||
|
||||
@@ -20,6 +20,8 @@ CHANNEL_FILES=(
|
||||
src/channels/whatsapp.ts
|
||||
setup/whatsapp-auth.ts
|
||||
setup/groups.ts
|
||||
container/skills/whatsapp-formatting/SKILL.md
|
||||
container/skills/whatsapp-formatting/instructions.md
|
||||
)
|
||||
|
||||
needs_install=false
|
||||
@@ -45,6 +47,7 @@ git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
for f in "${CHANNEL_FILES[@]}"; do
|
||||
mkdir -p "$(dirname "$f")"
|
||||
git show "origin/channels:$f" > "$f"
|
||||
done
|
||||
|
||||
|
||||
+100
-12
@@ -7,6 +7,16 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Registration-only barrel import: each channel module calls
|
||||
// registerChannelAdapter() at module scope (factories are NOT invoked, no
|
||||
// adapter connects), so declared channel defaults resolve without the service.
|
||||
import '../src/channels/index.js';
|
||||
import {
|
||||
resolveUnknownSenderPolicy,
|
||||
resolveWiringDefaults,
|
||||
validateEngageAgainstChannel,
|
||||
} from '../src/channels/channel-defaults.js';
|
||||
import { hasDeclaredChannelDefaults } from '../src/channels/channel-registry.js';
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import { runMigrations } from '../src/db/migrations/index.js';
|
||||
@@ -33,7 +43,7 @@ interface RegisterArgs {
|
||||
trigger: string;
|
||||
/** Agent group folder name */
|
||||
folder: string;
|
||||
/** Channel type (discord, slack, telegram, etc.) */
|
||||
/** Channel type (discord, slack, telegram, etc.) — required */
|
||||
channel: string;
|
||||
/** Whether messages require the trigger pattern to activate */
|
||||
requiresTrigger: boolean;
|
||||
@@ -41,18 +51,28 @@ interface RegisterArgs {
|
||||
assistantName: string;
|
||||
/** Session mode: 'shared' (one session per channel) or 'per-thread' */
|
||||
sessionMode: string;
|
||||
/** Whether the messaging group is a multi-user chat (default: true) */
|
||||
isGroup: boolean;
|
||||
/** Explicit engage mode override; omitted = channel declaration / heuristic */
|
||||
engageMode?: 'pattern' | 'mention' | 'mention-sticky';
|
||||
/** Explicit unknown_sender_policy override; omitted = channel declaration / 'strict' */
|
||||
unknownSenderPolicy?: 'strict' | 'request_approval' | 'public';
|
||||
}
|
||||
|
||||
const ENGAGE_MODES = ['pattern', 'mention', 'mention-sticky'] as const;
|
||||
const SENDER_POLICIES = ['strict', 'request_approval', 'public'] as const;
|
||||
|
||||
function parseArgs(args: string[]): RegisterArgs {
|
||||
const result: RegisterArgs = {
|
||||
platformId: '',
|
||||
name: '',
|
||||
trigger: '',
|
||||
folder: '',
|
||||
channel: 'discord',
|
||||
channel: '',
|
||||
requiresTrigger: false,
|
||||
assistantName: 'Andy',
|
||||
sessionMode: 'shared',
|
||||
isGroup: true,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
@@ -81,6 +101,32 @@ function parseArgs(args: string[]): RegisterArgs {
|
||||
case '--session-mode':
|
||||
result.sessionMode = args[++i] || 'shared';
|
||||
break;
|
||||
case '--is-group': {
|
||||
const raw = (args[++i] || '').toLowerCase();
|
||||
if (!['true', 'false', '1', '0'].includes(raw)) {
|
||||
throw new Error(`--is-group must be true or false, got "${raw}"`);
|
||||
}
|
||||
result.isGroup = raw === 'true' || raw === '1';
|
||||
break;
|
||||
}
|
||||
case '--engage-mode': {
|
||||
const raw = (args[++i] || '').toLowerCase() as RegisterArgs['engageMode'];
|
||||
if (!raw || !ENGAGE_MODES.includes(raw)) {
|
||||
throw new Error(`--engage-mode must be one of ${ENGAGE_MODES.join('|')}, got "${raw}"`);
|
||||
}
|
||||
result.engageMode = raw;
|
||||
break;
|
||||
}
|
||||
case '--unknown-sender-policy': {
|
||||
const raw = (args[++i] || '').toLowerCase() as RegisterArgs['unknownSenderPolicy'];
|
||||
if (!raw || !SENDER_POLICIES.includes(raw)) {
|
||||
throw new Error(
|
||||
`--unknown-sender-policy must be one of ${SENDER_POLICIES.join('|')}, got "${raw}"`,
|
||||
);
|
||||
}
|
||||
result.unknownSenderPolicy = raw;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +141,19 @@ export async function run(args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const parsed = parseArgs(args);
|
||||
|
||||
if (!parsed.channel) {
|
||||
// No silent platform default: the channel decides platform-id namespacing
|
||||
// and the wiring/policy defaults below, so guessing one wires the chat to
|
||||
// the wrong adapter.
|
||||
emitStatus('REGISTER_CHANNEL', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'missing_channel',
|
||||
MESSAGE: '--channel is required (the channel type this chat lives on, e.g. discord, slack, telegram, whatsapp)',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
if (!parsed.platformId || !parsed.name || !parsed.folder) {
|
||||
emitStatus('REGISTER_CHANNEL', {
|
||||
STATUS: 'failed',
|
||||
@@ -150,13 +209,20 @@ export async function run(args: string[]): Promise<void> {
|
||||
let messagingGroup = getMessagingGroupByPlatform(parsed.channel, parsed.platformId);
|
||||
if (!messagingGroup) {
|
||||
const mgId = generateId('mg');
|
||||
// Policy: explicit flag → channel declaration → legacy 'strict' (stale
|
||||
// adapters without a declaration must keep pre-declaration behavior).
|
||||
const unknownSenderPolicy =
|
||||
parsed.unknownSenderPolicy ??
|
||||
(hasDeclaredChannelDefaults(parsed.channel)
|
||||
? resolveUnknownSenderPolicy(parsed.channel, parsed.isGroup)
|
||||
: 'strict');
|
||||
createMessagingGroup({
|
||||
id: mgId,
|
||||
channel_type: parsed.channel,
|
||||
platform_id: parsed.platformId,
|
||||
name: parsed.name,
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'strict',
|
||||
is_group: parsed.isGroup ? 1 : 0,
|
||||
unknown_sender_policy: unknownSenderPolicy,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
messagingGroup = getMessagingGroupByPlatform(parsed.channel, parsed.platformId)!;
|
||||
@@ -170,19 +236,41 @@ export async function run(args: string[]): Promise<void> {
|
||||
if (!existing) {
|
||||
newlyWired = true;
|
||||
const mgaId = generateId('mga');
|
||||
// Mirrors scripts/init-first-agent.ts:wireIfMissing so both setup paths
|
||||
// create rows with the same shape. Groups default to 'mention' (bot only
|
||||
// responds when addressed); DMs default to 'pattern'/'.' (respond to
|
||||
// every message). An explicit --trigger overrides the pattern regex.
|
||||
// Engage defaults, first hit wins: explicit --engage-mode → explicit
|
||||
// --trigger (pattern regex, the historical override) → the channel's
|
||||
// declared defaults → the legacy heuristic for stale (undeclared)
|
||||
// adapters, so a trunk update alone changes nothing for them: groups get
|
||||
// 'mention' (respond when addressed), DMs 'pattern'/'.' (every message).
|
||||
const isGroup = messagingGroup.is_group === 1;
|
||||
const engageMode: 'pattern' | 'mention' = isGroup && !parsed.trigger ? 'mention' : 'pattern';
|
||||
const engagePattern: string | null = engageMode === 'pattern' ? parsed.trigger || '.' : null;
|
||||
const channelKey = messagingGroup.instance ?? messagingGroup.channel_type;
|
||||
let engage: { engage_mode: 'pattern' | 'mention' | 'mention-sticky'; engage_pattern: string | null };
|
||||
if (parsed.engageMode) {
|
||||
if (parsed.engageMode === 'pattern' && !parsed.trigger) {
|
||||
throw new Error(`--engage-mode pattern requires --trigger (use "." to match every message)`);
|
||||
}
|
||||
engage = {
|
||||
engage_mode: parsed.engageMode,
|
||||
engage_pattern: parsed.engageMode === 'pattern' ? parsed.trigger : null,
|
||||
};
|
||||
} else if (parsed.trigger) {
|
||||
engage = { engage_mode: 'pattern', engage_pattern: parsed.trigger };
|
||||
} else if (hasDeclaredChannelDefaults(channelKey, messagingGroup.channel_type)) {
|
||||
engage = resolveWiringDefaults(channelKey, isGroup, agentGroup.name, messagingGroup.channel_type);
|
||||
} else {
|
||||
engage = isGroup
|
||||
? { engage_mode: 'mention', engage_pattern: null }
|
||||
: { engage_mode: 'pattern', engage_pattern: '.' };
|
||||
}
|
||||
// Same cross-checks as `ncl wirings create`: rejects mention modes on
|
||||
// channels declaring mentions:'never'; coerces mention-sticky→mention
|
||||
// when the channel context has no thread ids.
|
||||
validateEngageAgainstChannel(engage, messagingGroup);
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: messagingGroup.id,
|
||||
agent_group_id: agentGroup.id,
|
||||
engage_mode: engageMode,
|
||||
engage_pattern: engagePattern,
|
||||
engage_mode: engage.engage_mode,
|
||||
engage_pattern: engage.engage_pattern,
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared',
|
||||
|
||||
@@ -46,10 +46,12 @@ export function backupEnv(envPath: string): string {
|
||||
const dir = path.dirname(envPath);
|
||||
let backup = path.join(dir, '.env.bak');
|
||||
if (fs.existsSync(backup)) {
|
||||
// Local time (system TZ) — the stamp is read by the human running the
|
||||
// uninstall. sv-SE renders "YYYY-MM-DD HH:mm:ss".
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
.toLocaleString('sv-SE', { hour12: false })
|
||||
.replace(/[-:]/g, '')
|
||||
.replace('T', '-')
|
||||
.replace(' ', '-')
|
||||
.slice(0, 15);
|
||||
backup = path.join(dir, `.env.bak.${stamp}`);
|
||||
}
|
||||
|
||||
+70
-1
@@ -83,7 +83,8 @@ export interface InboundMessage {
|
||||
* display name (e.g. `@Andy`).
|
||||
*
|
||||
* Adapters that don't set it (native / legacy) leave it undefined — the
|
||||
* router falls back to text-match against agent_group_name.
|
||||
* router treats undefined as "not a mention" (`isMention === true` check,
|
||||
* src/router.ts). There is no text-match fallback.
|
||||
*/
|
||||
isMention?: boolean;
|
||||
/** True when the source is a group/channel thread, false for DMs. */
|
||||
@@ -110,6 +111,57 @@ export interface ConversationInfo {
|
||||
isGroup: boolean;
|
||||
}
|
||||
|
||||
/** Wiring/mg defaults for one conversation context (DM vs group/channel). */
|
||||
export interface ChannelContextDefaults {
|
||||
/** Default engage_mode for wirings created in this context. */
|
||||
engageMode: 'pattern' | 'mention' | 'mention-sticky';
|
||||
/**
|
||||
* Default engage_pattern when engageMode === 'pattern'. May contain the
|
||||
* literal token `{name}`: creation helpers replace it with the regex-escaped
|
||||
* agent_group name (for platforms with no group-mention metadata, e.g.
|
||||
* iMessage/DeltaChat groups, WhatsApp shared-number mode). Required iff
|
||||
* engageMode === 'pattern'.
|
||||
*/
|
||||
engagePattern?: string;
|
||||
/**
|
||||
* Whether thread ids are honored in this context by default.
|
||||
* true — inbound thread ids flow into messages_in and (in groups) force
|
||||
* per-thread session identity; replies, typing, and cards land
|
||||
* in-thread.
|
||||
* false — thread ids are nulled per-wiring at router fanout; sessions
|
||||
* collapse; replies land top-level.
|
||||
* MUST be false when `supportsThreads` is false (capability bound; the
|
||||
* router treats supportsThreads=false as a hard pre-strip regardless).
|
||||
* Per-wiring override: messaging_group_agents.threads (NULL = inherit).
|
||||
*/
|
||||
threads: boolean;
|
||||
/**
|
||||
* unknown_sender_policy stamped on messaging_groups rows auto-created by
|
||||
* the router or created by wizard/CLI paths in this context.
|
||||
*/
|
||||
unknownSenderPolicy: 'strict' | 'request_approval' | 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* Static per-channel declaration of wiring-time defaults. Exactly two levels
|
||||
* exist: this declaration, and the per-wiring/per-mg values chosen at
|
||||
* creation. Install-wide changes = edit the adapter copy (skill-installed,
|
||||
* user-owned). Never persisted to the central DB.
|
||||
*/
|
||||
export interface ChannelDefaults {
|
||||
dm: ChannelContextDefaults;
|
||||
group: ChannelContextDefaults;
|
||||
/**
|
||||
* Which mention signal the adapter emits (InboundMessage.isMention):
|
||||
* 'platform' — platform-confirmed mentions in groups; DMs flagged too.
|
||||
* 'dm-only' — only DMs flagged (no group mention metadata).
|
||||
* 'never' — isMention never set: auto-create/registration card never
|
||||
* fires; 'mention'/'mention-sticky' wirings never engage.
|
||||
* Creation surfaces must reject/warn on mention modes that can never fire.
|
||||
*/
|
||||
mentions: 'platform' | 'dm-only' | 'never';
|
||||
}
|
||||
|
||||
/** The v2 channel adapter contract. */
|
||||
export interface ChannelAdapter {
|
||||
name: string;
|
||||
@@ -176,6 +228,15 @@ export interface ChannelAdapter {
|
||||
* Returning the same platform_id on repeated calls is expected.
|
||||
*/
|
||||
openDM?(userHandle: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Declared wiring-time defaults for this channel. Optional for backward
|
||||
* compatibility with stale adapter copies; absent → core fallback
|
||||
* (fallbackChannelDefaults(supportsThreads), see channel-registry.ts).
|
||||
* May be computed from adapter-internal env at module load (e.g. WhatsApp
|
||||
* shared-number mode), but is immutable for the process lifetime.
|
||||
*/
|
||||
defaults?: ChannelDefaults;
|
||||
}
|
||||
|
||||
/** Factory function that creates a channel adapter (returns null if credentials missing). */
|
||||
@@ -184,6 +245,14 @@ export type ChannelAdapterFactory = () => ChannelAdapter | Promise<ChannelAdapte
|
||||
/** Registration entry for a channel adapter. */
|
||||
export interface ChannelRegistration {
|
||||
factory: ChannelAdapterFactory;
|
||||
/**
|
||||
* Same declaration as ChannelAdapter.defaults, resolvable WITHOUT
|
||||
* instantiating the adapter — offline creation paths (setup/register.ts,
|
||||
* scripts/init-first-agent.ts, ncl against a host where the factory
|
||||
* returned null for missing creds) read it from the registry. Channel
|
||||
* modules pass the same const here and to the adapter/bridge.
|
||||
*/
|
||||
defaults?: ChannelDefaults;
|
||||
containerConfig?: {
|
||||
mounts?: Array<{ hostPath: string; containerPath: string; readonly: boolean }>;
|
||||
env?: Record<string, string>;
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Tests for channel default declarations: getChannelDefaults tiered lookup,
|
||||
* the behavior-faithful fallback, and the wiring-creation helpers.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import type { ChannelAdapter, ChannelDefaults, ChannelSetup } from './adapter.js';
|
||||
|
||||
function makeDefaults(marker: string, threads = true): ChannelDefaults {
|
||||
return {
|
||||
dm: { engageMode: 'pattern', engagePattern: marker, threads, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'mention', threads, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
}
|
||||
|
||||
function makeAdapter(
|
||||
channelType: string,
|
||||
opts: { instance?: string; supportsThreads?: boolean; defaults?: ChannelDefaults } = {},
|
||||
): ChannelAdapter {
|
||||
return {
|
||||
name: opts.instance ?? channelType,
|
||||
channelType,
|
||||
instance: opts.instance,
|
||||
supportsThreads: opts.supportsThreads ?? false,
|
||||
defaults: opts.defaults,
|
||||
async setup(_config: ChannelSetup) {},
|
||||
async teardown() {},
|
||||
isConnected: () => true,
|
||||
async deliver() {
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mockSetup = () => ({
|
||||
onInbound: () => {},
|
||||
onInboundEvent: () => {},
|
||||
onMetadata: () => {},
|
||||
onAction: () => {},
|
||||
});
|
||||
|
||||
describe('getChannelDefaults — tiered lookup', () => {
|
||||
// The registry and activeAdapters maps are module-level; fresh module per
|
||||
// test so registrations don't leak across arms.
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { teardownChannelAdapters } = await import('./channel-registry.js');
|
||||
await teardownChannelAdapters();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('live adapter declaration wins over the registration declaration', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const liveDecl = makeDefaults('live');
|
||||
const regDecl = makeDefaults('registration');
|
||||
reg.registerChannelAdapter('mock', {
|
||||
factory: () => makeAdapter('mock', { defaults: liveDecl }),
|
||||
defaults: regDecl,
|
||||
});
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('mock')).toBe(liveDecl);
|
||||
});
|
||||
|
||||
it('falls through a live channelType scan for a channelType key (live tier instance→channelType)', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const decl = makeDefaults('named-instance');
|
||||
reg.registerChannelAdapter('slack-tester', {
|
||||
factory: () => makeAdapter('slack', { instance: 'slack-tester', defaults: decl }),
|
||||
});
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
// Key is the bare channelType; only a named instance is live.
|
||||
expect(reg.getChannelDefaults('slack')).toBe(decl);
|
||||
});
|
||||
|
||||
it('falls through to the registration entry when the factory returned null', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const decl = makeDefaults('registration');
|
||||
reg.registerChannelAdapter('mock', { factory: () => null, defaults: decl });
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('mock')).toBe(decl);
|
||||
});
|
||||
|
||||
it('resolves a stale live instance through its channelType registration (registration tier instance→channelType)', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const decl = makeDefaults('platform-registration');
|
||||
// Stale adapter copy: live under a named-instance key with NO declaration;
|
||||
// the platform's registration (keyed by channelType) carries one.
|
||||
reg.registerChannelAdapter('slack-tester', {
|
||||
factory: () => makeAdapter('slack', { instance: 'slack-tester' }),
|
||||
});
|
||||
reg.registerChannelAdapter('slack', { factory: () => null, defaults: decl });
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('slack-tester')).toBe(decl);
|
||||
});
|
||||
|
||||
it('resolves a dead named instance through the channelType hint (registration tier instance→channelType)', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const decl = makeDefaults('platform-registration');
|
||||
// Nothing live at all: the named instance's factory returned null and its
|
||||
// registration has no declaration — only mg.channel_type can bridge.
|
||||
reg.registerChannelAdapter('slack-tester', { factory: () => null });
|
||||
reg.registerChannelAdapter('slack', { factory: () => null, defaults: decl });
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('slack-tester', 'slack')).toBe(decl);
|
||||
// Without the hint there is no instance→channelType mapping in the registry.
|
||||
expect(reg.getChannelDefaults('slack-tester')).toEqual(reg.fallbackChannelDefaults(false));
|
||||
});
|
||||
|
||||
it('uses the live adapter supportsThreads for the fallback tier', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
reg.registerChannelAdapter('mock', {
|
||||
factory: () => makeAdapter('mock', { supportsThreads: true }),
|
||||
});
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('mock')).toEqual(reg.fallbackChannelDefaults(true));
|
||||
});
|
||||
|
||||
it('unknown channel type resolves the conservative fallback', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
expect(reg.getChannelDefaults('no-such-channel')).toEqual(reg.fallbackChannelDefaults(false));
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallbackChannelDefaults — behavior-faithful values', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('reproduces trunk behavior for undeclared adapters', async () => {
|
||||
const { fallbackChannelDefaults } = await import('./channel-registry.js');
|
||||
expect(fallbackChannelDefaults(true)).toEqual({
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
});
|
||||
// threads track the raw capability in BOTH contexts so NULL-inherit
|
||||
// wirings behave exactly like today's supportsThreads-derived routing.
|
||||
const nonThreaded = fallbackChannelDefaults(false);
|
||||
expect(nonThreaded.dm.threads).toBe(false);
|
||||
expect(nonThreaded.group.threads).toBe(false);
|
||||
expect(nonThreaded.group.engageMode).toBe('mention-sticky');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWiringDefaults', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { teardownChannelAdapters } = await import('./channel-registry.js');
|
||||
await teardownChannelAdapters();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function withDeclaration(defaults: ChannelDefaults) {
|
||||
const reg = await import('./channel-registry.js');
|
||||
reg.registerChannelAdapter('mock', { factory: () => null, defaults });
|
||||
return import('./channel-defaults.js');
|
||||
}
|
||||
|
||||
it('substitutes {name} with the regex-escaped agent group name', async () => {
|
||||
const { resolveWiringDefaults } = await withDeclaration({
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'dm-only',
|
||||
});
|
||||
|
||||
// Name ends in ')' (non-word) — the trailing declared \b could never
|
||||
// match there, so it is dropped; the leading \b stays.
|
||||
expect(resolveWiringDefaults('mock', true, 'C-3PO (dev)')).toEqual({
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '\\bC-3PO \\(dev\\)',
|
||||
});
|
||||
// DM context: no token, pattern passes through untouched.
|
||||
expect(resolveWiringDefaults('mock', false, 'C-3PO (dev)')).toEqual({
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps both \\b boundaries for a plain word name and produces a matching regex', async () => {
|
||||
const { resolveWiringDefaults } = await withDeclaration({
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'dm-only',
|
||||
});
|
||||
|
||||
const word = resolveWiringDefaults('mock', true, 'Andy');
|
||||
expect(word.engage_pattern).toBe('\\bAndy\\b');
|
||||
expect(new RegExp(word.engage_pattern!).test('@Andy status')).toBe(true);
|
||||
expect(new RegExp(word.engage_pattern!).test('@Andyboy status')).toBe(false);
|
||||
|
||||
// Trailing non-word char: '@Andy (backup) status' must still engage.
|
||||
const punct = resolveWiringDefaults('mock', true, 'Andy (backup)');
|
||||
expect(new RegExp(punct.engage_pattern!).test('@Andy (backup) status')).toBe(true);
|
||||
|
||||
// Leading non-word char: the leading \b is dropped instead.
|
||||
const lead = resolveWiringDefaults('mock', true, '!Nano');
|
||||
expect(lead.engage_pattern).toBe('!Nano\\b');
|
||||
expect(new RegExp(lead.engage_pattern!).test('hey !Nano status')).toBe(true);
|
||||
});
|
||||
|
||||
it('coerces mention-sticky to mention when the context threads=false', async () => {
|
||||
const { resolveWiringDefaults } = await withDeclaration({
|
||||
dm: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
|
||||
group: { engageMode: 'mention-sticky', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
});
|
||||
|
||||
expect(resolveWiringDefaults('mock', true, 'Andy')).toEqual({
|
||||
engage_mode: 'mention',
|
||||
engage_pattern: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps mention-sticky when the context threads=true', async () => {
|
||||
const { resolveWiringDefaults } = await withDeclaration({
|
||||
dm: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'strict' },
|
||||
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
});
|
||||
|
||||
expect(resolveWiringDefaults('mock', true, 'Andy')).toEqual({
|
||||
engage_mode: 'mention-sticky',
|
||||
engage_pattern: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on a pattern-mode declaration without a pattern', async () => {
|
||||
const { resolveWiringDefaults } = await withDeclaration({
|
||||
dm: { engageMode: 'pattern', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
});
|
||||
|
||||
expect(() => resolveWiringDefaults('mock', false, 'Andy')).toThrow(/without an engagePattern/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveUnknownSenderPolicy', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('selects the context policy from the declaration', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
reg.registerChannelAdapter('mock', {
|
||||
factory: () => null,
|
||||
defaults: {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
},
|
||||
});
|
||||
const { resolveUnknownSenderPolicy } = await import('./channel-defaults.js');
|
||||
|
||||
expect(resolveUnknownSenderPolicy('mock', false)).toBe('public');
|
||||
expect(resolveUnknownSenderPolicy('mock', true)).toBe('strict');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveThreadPolicy', () => {
|
||||
it('ANDs the resolved value with the raw capability', async () => {
|
||||
vi.resetModules();
|
||||
const { resolveThreadPolicy } = await import('./channel-defaults.js');
|
||||
const decl: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
|
||||
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
|
||||
// NULL = inherit the declaration for the context.
|
||||
expect(resolveThreadPolicy(null, decl, true, true)).toBe(true);
|
||||
expect(resolveThreadPolicy(null, decl, false, true)).toBe(false);
|
||||
// Explicit wiring value beats the declaration…
|
||||
expect(resolveThreadPolicy(1, decl, false, true)).toBe(true);
|
||||
expect(resolveThreadPolicy(0, decl, true, true)).toBe(false);
|
||||
// …but never the capability: no opt-in on a non-threaded platform.
|
||||
expect(resolveThreadPolicy(1, decl, true, false)).toBe(false);
|
||||
expect(resolveThreadPolicy(null, decl, true, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Wiring-creation helpers over channel default declarations.
|
||||
*
|
||||
* Every path that creates a messaging_group_agents row (ncl, setup wizard,
|
||||
* card-approval flow, bootstrap scripts) resolves its engage defaults through
|
||||
* resolveWiringDefaults; every path that auto-creates a messaging_groups row
|
||||
* resolves its policy through resolveUnknownSenderPolicy. The router's fanout
|
||||
* consults resolveThreadPolicy at runtime — threading is the one per-wiring
|
||||
* setting that stays live (NULL = inherit the declaration) rather than being
|
||||
* snapshotted at creation.
|
||||
*
|
||||
* Context selection everywhere: isGroup = event.message.isGroup ??
|
||||
* (mg.is_group === 1) — NEVER `threadId !== null` (DM sub-threads exist on
|
||||
* Slack/Discord, and non-threaded group platforms have null threadIds).
|
||||
*/
|
||||
import type { ChannelDefaults } from './adapter.js';
|
||||
import { getChannelDefaults, hasDeclaredChannelDefaults } from './channel-registry.js';
|
||||
import { log } from '../log.js';
|
||||
import type { MessagingGroup } from '../types.js';
|
||||
|
||||
function escapeRegex(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute the (regex-escaped) agent name for `{name}` in a declared
|
||||
* pattern. A `\b` adjacent to a non-word character can never match, so when
|
||||
* the name starts/ends with one (e.g. "Nano!", "Andy (backup)") the adjacent
|
||||
* declared boundary is dropped — mirrors selfChatEngagePattern in
|
||||
* setup/channels/whatsapp.ts.
|
||||
*/
|
||||
function substituteName(pattern: string, name: string): string {
|
||||
let out = pattern;
|
||||
if (!/^\w/.test(name)) out = out.replaceAll('\\b{name}', '{name}');
|
||||
if (!/\w$/.test(name)) out = out.replaceAll('{name}\\b', '{name}');
|
||||
return out.replaceAll('{name}', escapeRegex(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the engage defaults a new wiring should be created with.
|
||||
*
|
||||
* @param channelKey mg.instance ?? mg.channel_type (getChannelAdapter key discipline)
|
||||
* @param isGroup event.message.isGroup ?? mg.is_group === 1 — never derived from threadId
|
||||
* @param agentGroupName substituted (regex-escaped) for the `{name}` token in declared patterns
|
||||
* @param channelType mg.channel_type — pass when channelKey may be a named
|
||||
* instance so a dead instance still resolves its platform's declaration
|
||||
* (getChannelDefaults' second-arg discipline)
|
||||
*
|
||||
* mention-sticky is downgraded to mention when the context's declared threads
|
||||
* value is false: sticky engagement is keyed on per-thread session existence,
|
||||
* so without thread ids it could engage once and never disengage.
|
||||
*/
|
||||
export function resolveWiringDefaults(
|
||||
channelKey: string,
|
||||
isGroup: boolean,
|
||||
agentGroupName: string,
|
||||
channelType?: string,
|
||||
): { engage_mode: 'pattern' | 'mention' | 'mention-sticky'; engage_pattern: string | null } {
|
||||
const decl = getChannelDefaults(channelKey, channelType);
|
||||
const ctx = isGroup ? decl.group : decl.dm;
|
||||
|
||||
let mode = ctx.engageMode;
|
||||
if (mode === 'mention-sticky' && !ctx.threads) mode = 'mention';
|
||||
|
||||
if (mode !== 'pattern') return { engage_mode: mode, engage_pattern: null };
|
||||
|
||||
if (!ctx.engagePattern) {
|
||||
throw new Error(
|
||||
`Channel '${channelKey}' declares engageMode 'pattern' without an engagePattern (${isGroup ? 'group' : 'dm'} context)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: substituteName(ctx.engagePattern, agentGroupName),
|
||||
};
|
||||
}
|
||||
|
||||
/** unknown_sender_policy for a messaging_groups row created in this context.
|
||||
* `channelType` follows the same dead-named-instance discipline as
|
||||
* resolveWiringDefaults. */
|
||||
export function resolveUnknownSenderPolicy(
|
||||
channelKey: string,
|
||||
isGroup: boolean,
|
||||
channelType?: string,
|
||||
): 'strict' | 'request_approval' | 'public' {
|
||||
const decl = getChannelDefaults(channelKey, channelType);
|
||||
return (isGroup ? decl.group : decl.dm).unknownSenderPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime thread policy for one wiring: does its event-derived address keep
|
||||
* thread ids? wiring.threads (0/1, NULL = inherit the declaration) hard-ANDed
|
||||
* with the adapter's raw capability — a wiring can opt out of threads on a
|
||||
* threaded platform, never opt in on a non-threaded one.
|
||||
*
|
||||
* Applies ONLY to event-derived addresses. `event.replyTo` is operator intent
|
||||
* from the CLI admin transport (src/channels/adapter.ts) and must never be
|
||||
* nulled through this policy.
|
||||
*/
|
||||
export function resolveThreadPolicy(
|
||||
wiringThreads: number | null,
|
||||
decl: ChannelDefaults,
|
||||
isGroup: boolean,
|
||||
supportsThreads: boolean,
|
||||
): boolean {
|
||||
const inherited = (isGroup ? decl.group : decl.dm).threads;
|
||||
const wanted = wiringThreads === null ? inherited : wiringThreads !== 0;
|
||||
return wanted && supportsThreads;
|
||||
}
|
||||
|
||||
export interface EngageValues {
|
||||
engage_mode?: unknown;
|
||||
engage_pattern?: unknown;
|
||||
threads?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-column validation against the channel's declaration. Shared by every
|
||||
* wiring-creation surface (`ncl wirings` create/update, the setup wizard's
|
||||
* register step) so a partial update or an explicit flag can't produce a
|
||||
* combination create would reject. May mutate `w.engage_mode`: the
|
||||
* mention-sticky→mention coercion when the effective thread policy is off —
|
||||
* sticky engagement is keyed on per-thread session existence, so without
|
||||
* thread ids it would engage once and never disengage.
|
||||
*
|
||||
* Declaration-derived checks are gated on hasDeclaredChannelDefaults: stale
|
||||
* (undeclared) adapters keep the legacy lenient behavior — the fallback
|
||||
* declaration is permissive on mentions but its threads value is false when
|
||||
* no adapter is live, which would wrongly coerce offline-created wirings.
|
||||
*/
|
||||
export function validateEngageAgainstChannel(w: EngageValues, mg: MessagingGroup): void {
|
||||
if (
|
||||
w.engage_mode === 'pattern' &&
|
||||
(w.engage_pattern === undefined || w.engage_pattern === null || w.engage_pattern === '')
|
||||
) {
|
||||
throw new Error(`engage_mode 'pattern' requires --engage-pattern (use "." to match every message)`);
|
||||
}
|
||||
if (w.engage_mode !== 'mention' && w.engage_mode !== 'mention-sticky') return;
|
||||
|
||||
const channelKey = mg.instance ?? mg.channel_type;
|
||||
if (!hasDeclaredChannelDefaults(channelKey, mg.channel_type)) return;
|
||||
|
||||
const decl = getChannelDefaults(channelKey, mg.channel_type);
|
||||
if (decl.mentions === 'never') {
|
||||
throw new Error(
|
||||
`engage_mode '${w.engage_mode}' can never engage on channel '${channelKey}' — its adapter declares mentions: 'never' (no mention signal is emitted; use --engage-mode pattern)`,
|
||||
);
|
||||
}
|
||||
if (w.engage_mode === 'mention-sticky') {
|
||||
const ctx = mg.is_group === 1 ? decl.group : decl.dm;
|
||||
const threads = w.threads === undefined || w.threads === null ? ctx.threads : w.threads !== 0;
|
||||
if (!threads) {
|
||||
log.warn('mention-sticky requires thread ids — coerced to mention', {
|
||||
channel: channelKey,
|
||||
messagingGroupId: mg.id,
|
||||
});
|
||||
w.engage_mode = 'mention';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Channels self-register on import. The host calls initChannelAdapters() at startup
|
||||
* to instantiate and set up all registered adapters.
|
||||
*/
|
||||
import type { ChannelAdapter, ChannelRegistration, ChannelSetup, OutboundFile } from './adapter.js';
|
||||
import type { ChannelAdapter, ChannelDefaults, ChannelRegistration, ChannelSetup, OutboundFile } from './adapter.js';
|
||||
import type { ChannelDeliveryAdapter } from '../delivery.js';
|
||||
import { log } from '../log.js';
|
||||
|
||||
@@ -102,6 +102,97 @@ export function createChannelDeliveryAdapter(): ChannelDeliveryAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavior-faithful fallback for adapters with no `defaults` declaration
|
||||
* (stale skill-installed copies, unknown channel types). Values reproduce
|
||||
* what trunk did before declarations existed, so a trunk update alone
|
||||
* changes nothing for undeclared adapters:
|
||||
* - dm: pattern '.' (every DM message engages), router auto-create policy
|
||||
* 'request_approval' (src/router.ts auto-create branch).
|
||||
* - group: mention-sticky (what the card-approval flow stamped on group
|
||||
* channels), same 'request_approval' policy.
|
||||
* - threads follow the raw capability in BOTH contexts — a NULL (inherit)
|
||||
* wiring resolved through this fallback behaves exactly like today's
|
||||
* supportsThreads-derived routing.
|
||||
* - mentions 'platform': never blocks a mention wiring at creation time.
|
||||
*/
|
||||
export function fallbackChannelDefaults(supportsThreads: boolean): ChannelDefaults {
|
||||
return {
|
||||
dm: {
|
||||
engageMode: 'pattern',
|
||||
engagePattern: '.',
|
||||
threads: supportsThreads,
|
||||
unknownSenderPolicy: 'request_approval',
|
||||
},
|
||||
group: {
|
||||
engageMode: 'mention-sticky',
|
||||
threads: supportsThreads,
|
||||
unknownSenderPolicy: 'request_approval',
|
||||
},
|
||||
mentions: 'platform',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a channel's declared wiring defaults. Never returns undefined.
|
||||
*
|
||||
* `key` follows the same discipline as getChannelAdapter: mg.instance ??
|
||||
* mg.channel_type. Tiers, first hit wins:
|
||||
* 1. live adapter, instance-exact — lets an instance carry env-computed
|
||||
* declarations (e.g. WhatsApp shared-number mode);
|
||||
* 2. live adapter of that channelType (mirrors getChannelAdapter's scan);
|
||||
* 3. registration entry under the key — covers offline scripts and
|
||||
* factories that returned null for missing creds;
|
||||
* 4. registration entry under the channelType — resolved from the live
|
||||
* adapter found in tiers 1-2 (a stale adapter copy without a declaration
|
||||
* whose registration has one), else from the optional `channelType`
|
||||
* hint, which callers holding a named-instance mg row should pass so a
|
||||
* dead instance still resolves its platform's declaration;
|
||||
* 5. fallbackChannelDefaults on the live adapter's capability (false when
|
||||
* no adapter is live — conservative, reachable only from manual creation
|
||||
* surfaces since the router never sees events for unregistered channels).
|
||||
*/
|
||||
export function getChannelDefaults(key: string, channelType?: string): ChannelDefaults {
|
||||
const { live, decl } = lookupDeclaredDefaults(key, channelType);
|
||||
return decl ?? fallbackChannelDefaults(live?.supportsThreads ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff getChannelDefaults would resolve from an actual declaration (tiers
|
||||
* 1-4) rather than fallbackChannelDefaults. Manual creation surfaces (`ncl`)
|
||||
* gate declaration-derived defaults on this: for stale (undeclared) adapters
|
||||
* they keep the legacy static schema defaults — engage_mode 'mention',
|
||||
* unknown_sender_policy 'strict' — so a trunk update alone changes nothing.
|
||||
* The faithful fallback exists for the ROUTER's auto-create/runtime paths,
|
||||
* whose historical behavior it reproduces; it is not what `ncl` did.
|
||||
*/
|
||||
export function hasDeclaredChannelDefaults(key: string, channelType?: string): boolean {
|
||||
return lookupDeclaredDefaults(key, channelType).decl !== undefined;
|
||||
}
|
||||
|
||||
/** Shared tiers 1-4 of getChannelDefaults (see its doc); `decl` undefined
|
||||
* means only tier 5 (fallback) remains. */
|
||||
function lookupDeclaredDefaults(
|
||||
key: string,
|
||||
channelType?: string,
|
||||
): { live: ChannelAdapter | undefined; decl: ChannelDefaults | undefined } {
|
||||
let live = activeAdapters.get(key);
|
||||
if (!live) {
|
||||
for (const adapter of activeAdapters.values()) {
|
||||
if (adapter.channelType === key) {
|
||||
live = adapter;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (live?.defaults) return { live, decl: live.defaults };
|
||||
|
||||
const typeKey = live?.channelType ?? channelType;
|
||||
const registered =
|
||||
registry.get(key)?.defaults ?? (typeKey !== undefined ? registry.get(typeKey)?.defaults : undefined);
|
||||
return { live, decl: registered };
|
||||
}
|
||||
|
||||
/** Get all active adapters. */
|
||||
export function getActiveAdapters(): ChannelAdapter[] {
|
||||
return [...activeAdapters.values()];
|
||||
|
||||
@@ -23,7 +23,7 @@ import { SqliteStateAdapter } from '../state-sqlite.js';
|
||||
import { registerWebhookAdapter } from '../webhook-server.js';
|
||||
import { getAskQuestionRender } from '../db/sessions.js';
|
||||
import { normalizeOptions, type NormalizedOption } from './ask-question.js';
|
||||
import type { ChannelAdapter, ChannelSetup, InboundMessage } from './adapter.js';
|
||||
import type { ChannelAdapter, ChannelDefaults, ChannelSetup, InboundMessage } from './adapter.js';
|
||||
|
||||
/** Adapter with optional gateway support (e.g., Discord). */
|
||||
interface GatewayAdapter extends Adapter {
|
||||
@@ -68,6 +68,12 @@ export interface ChatSdkBridgeConfig {
|
||||
* way and the default depends on installation style.
|
||||
*/
|
||||
supportsThreads: boolean;
|
||||
/**
|
||||
* Declared wiring-time defaults for this channel. Copied verbatim onto the
|
||||
* returned ChannelAdapter, exactly like supportsThreads. See
|
||||
* `ChannelAdapter.defaults`.
|
||||
*/
|
||||
defaults?: ChannelDefaults;
|
||||
/**
|
||||
* Optional transform applied to outbound text/markdown before it reaches the
|
||||
* adapter. Used by channels that need to sanitize for a platform-specific
|
||||
@@ -220,6 +226,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
instance: config.instance, // undefined ⇒ default instance
|
||||
|
||||
supportsThreads: config.supportsThreads,
|
||||
defaults: config.defaults,
|
||||
|
||||
async setup(hostConfig: ChannelSetup) {
|
||||
setupConfig = hostConfig;
|
||||
@@ -265,9 +272,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
});
|
||||
|
||||
// DMs — by definition addressed to the bot. Thread id flows through
|
||||
// so sub-thread context reaches delivery (Slack users can open threads
|
||||
// inside a DM). Router collapses DM sub-threads to one session via
|
||||
// is_group=0 short-circuit.
|
||||
// unmodified (Slack users can open sub-threads inside a DM); whether it
|
||||
// is honored is policy, not transport: the channel's declared
|
||||
// dm.threads default (ChannelDefaults) or a per-wiring threads override
|
||||
// decides at router fanout whether replies land in-thread or all DM
|
||||
// sub-threads collapse into the one DM session.
|
||||
chat.onDirectMessage(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
log.info('Inbound DM received', {
|
||||
|
||||
+22
-2
@@ -39,11 +39,30 @@ import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../config.js';
|
||||
import { log } from '../log.js';
|
||||
import type { ChannelAdapter, ChannelSetup, DeliveryAddress, InboundEvent, OutboundMessage } from './adapter.js';
|
||||
import type {
|
||||
ChannelAdapter,
|
||||
ChannelDefaults,
|
||||
ChannelSetup,
|
||||
DeliveryAddress,
|
||||
InboundEvent,
|
||||
OutboundMessage,
|
||||
} from './adapter.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
const PLATFORM_ID = 'local';
|
||||
|
||||
/**
|
||||
* Terminal transport: every line the operator types is for the agent
|
||||
* (pattern '.'), the socket is owner-only so senders are trusted ('public'),
|
||||
* there is no thread or mention concept. Matches what
|
||||
* scripts/init-cli-agent.ts has always created.
|
||||
*/
|
||||
const CLI_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
mentions: 'never',
|
||||
};
|
||||
|
||||
function socketPath(): string {
|
||||
return path.join(DATA_DIR, 'cli.sock');
|
||||
}
|
||||
@@ -56,6 +75,7 @@ function createAdapter(): ChannelAdapter {
|
||||
name: 'cli',
|
||||
channelType: 'cli',
|
||||
supportsThreads: false,
|
||||
defaults: CLI_DEFAULTS,
|
||||
|
||||
async setup(config: ChannelSetup): Promise<void> {
|
||||
const sock = socketPath();
|
||||
@@ -273,4 +293,4 @@ function extractText(message: OutboundMessage): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
registerChannelAdapter('cli', { factory: createAdapter });
|
||||
registerChannelAdapter('cli', { factory: createAdapter, defaults: CLI_DEFAULTS });
|
||||
|
||||
@@ -15,7 +15,7 @@ vi.mock('./log.js', () => ({
|
||||
}));
|
||||
|
||||
import { composeGroupClaudeMd } from './claude-md-compose.js';
|
||||
import { ensureContainerConfig } from './db/container-configs.js';
|
||||
import { ensureContainerConfig, updateContainerConfigScalars } from './db/container-configs.js';
|
||||
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
|
||||
import { PERSONA_PREPEND_FILE } from './group-persona.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
@@ -91,3 +91,28 @@ describe('composeGroupClaudeMd persona prepend', () => {
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeGroupClaudeMd scheduling instructions (ncl tasks reach-in)', () => {
|
||||
// Red-on-delete guard for the `scheduling`/`cli` exclusion at the
|
||||
// module-fragment loop: the agent is taught `ncl tasks` iff it has ncl.
|
||||
it('imports module-scheduling.md at the default cli_scope', () => {
|
||||
const ag = group('ag-sched', 'sched-group');
|
||||
seed(ag);
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
expect(importsOf(ag.folder)).toContain('@./.claude-fragments/module-scheduling.md');
|
||||
});
|
||||
|
||||
it('excludes module-scheduling.md (and module-cli.md) when cli_scope is disabled', () => {
|
||||
const ag = group('ag-sched-off', 'sched-group-off');
|
||||
seed(ag);
|
||||
updateContainerConfigScalars(ag.id, { cli_scope: 'disabled' });
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
const imports = importsOf(ag.folder);
|
||||
expect(imports).not.toContain('@./.claude-fragments/module-scheduling.md');
|
||||
expect(imports).not.toContain('@./.claude-fragments/module-cli.md');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,10 +80,12 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in module fragments — every MCP tool source file that ships a
|
||||
// Built-in module fragments — every MCP/CLI module that ships a
|
||||
// sibling `<name>.instructions.md`. These describe how the agent should
|
||||
// use that module's MCP tools (schedule_task, install_packages, etc.).
|
||||
// Skip cli.instructions.md when cli_scope is disabled.
|
||||
// use that module's tools (`ncl tasks`, install_packages, etc.).
|
||||
// Skip ncl-dependent instructions when cli_scope is disabled. `scheduling`
|
||||
// teaches `ncl tasks`, so it is just as dead as `cli` itself when the agent
|
||||
// has no ncl — dispatch rejects every cli_request and ncl is excluded.
|
||||
const cliDisabled = configRow?.cli_scope === 'disabled';
|
||||
const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH);
|
||||
if (fs.existsSync(mcpToolsHostDir)) {
|
||||
@@ -91,7 +93,7 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
const match = entry.match(/^(.+)\.instructions\.md$/);
|
||||
if (!match) continue;
|
||||
const moduleName = match[1];
|
||||
if (moduleName === 'cli' && cliDisabled) continue;
|
||||
if ((moduleName === 'cli' || moduleName === 'scheduling') && cliDisabled) continue;
|
||||
desired.set(`module-${moduleName}.md`, {
|
||||
type: 'symlink',
|
||||
content: `${SHARED_MCP_TOOLS_CONTAINER_BASE}/${entry}`,
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// `groups.ts`'s postCreate calls `initGroupFilesystem`, which touches the
|
||||
// real filesystem (groups/<folder>, data/v2-sessions/<id>/.claude-shared).
|
||||
// We're not testing the filesystem layout here — we're testing that the
|
||||
// hook fires with the inserted row — so mock the FS-touching helper and
|
||||
// keep only the DB side effect (`ensureContainerConfig`) as the observable.
|
||||
const ensureContainerConfigSpy = vi.fn();
|
||||
vi.mock('../group-init.js', async () => {
|
||||
const { ensureContainerConfig } = await import('../db/container-configs.js');
|
||||
return {
|
||||
initGroupFilesystem: vi.fn((group: { id: string }) => {
|
||||
ensureContainerConfigSpy(group.id);
|
||||
ensureContainerConfig(group.id);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
// `wirings.ts`'s postCommit projects the new destination into every running
|
||||
// session's `inbound.db` via the agent-to-agent module's `writeDestinations`.
|
||||
// That helper opens on-disk session DB files we don't have in a unit test, so
|
||||
// mock it and observe that the projection is invoked per live session.
|
||||
const writeDestinationsSpy = vi.fn();
|
||||
vi.mock('../modules/agent-to-agent/write-destinations.js', () => ({
|
||||
writeDestinations: (...args: unknown[]) => writeDestinationsSpy(...args),
|
||||
}));
|
||||
|
||||
import { initTestDb, closeDb, getDb, runMigrations, createAgentGroup, createMessagingGroup } from '../db/index.js';
|
||||
import { createSession } from '../db/sessions.js';
|
||||
import { getContainerConfig } from '../db/container-configs.js';
|
||||
import { getDestinations } from '../modules/agent-to-agent/db/agent-destinations.js';
|
||||
import { registerResource } from './crud.js';
|
||||
import { lookup } from './registry.js';
|
||||
|
||||
// Importing these for side effects: each calls `registerResource` at
|
||||
// module top-level, which wires up the `groups-create` / `wirings-create`
|
||||
// handlers we exercise below.
|
||||
import '../cli/resources/groups.js';
|
||||
import '../cli/resources/wirings.js';
|
||||
|
||||
const hostCtx = { caller: 'host' as const };
|
||||
|
||||
// Synthetic resource exercising the two-pass create: pass 1 collects explicit
|
||||
// args, pass 2 runs the resolveDefaults hook, pass 3 fills static defaults.
|
||||
// Registered once at module load like the real resources above; its table is
|
||||
// created per-test in the describe's beforeEach.
|
||||
const hookCalls: Record<string, unknown>[] = [];
|
||||
registerResource({
|
||||
name: 'hooktest',
|
||||
plural: 'hooktests',
|
||||
table: 'hooktest_rows',
|
||||
description: 'Synthetic resource for resolveDefaults hook-ordering tests.',
|
||||
idColumn: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||
{ name: 'kind', type: 'string', description: 'test input', required: true },
|
||||
{ name: 'mode', type: 'string', description: 'hook-fillable column', default: 'static' },
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
operations: { create: 'open' },
|
||||
resolveDefaults: (values) => {
|
||||
hookCalls.push({ ...values });
|
||||
if (values.kind === 'boom') throw new Error('hook rejected');
|
||||
if (values.mode === undefined && values.kind === 'fill') values.mode = 'hooked';
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
ensureContainerConfigSpy.mockClear();
|
||||
writeDestinationsSpy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
describe('genericCreate postCreate hook', () => {
|
||||
it('groups-create writes the companion container_configs row', async () => {
|
||||
const cmd = lookup('groups-create');
|
||||
expect(cmd, 'groups-create command must be registered').toBeDefined();
|
||||
|
||||
const result = (await cmd!.handler({ name: 'Test', folder: 'test' }, hostCtx)) as { id: string };
|
||||
|
||||
// Hook fired with the just-inserted row (incl. generated `id`).
|
||||
expect(ensureContainerConfigSpy).toHaveBeenCalledWith(result.id);
|
||||
|
||||
// Visible side effect: container_configs row exists for the new group.
|
||||
// Without postCreate, this was empty and the first spawn threw
|
||||
// "Container config not found" — issue #2415.
|
||||
const config = getContainerConfig(result.id);
|
||||
expect(config).toBeDefined();
|
||||
expect(config!.agent_group_id).toBe(result.id);
|
||||
});
|
||||
|
||||
it('wirings-create writes the companion agent_destinations row', async () => {
|
||||
// Seed the FKs that the wiring references.
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Agent One',
|
||||
folder: 'agent-one',
|
||||
agent_provider: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-1',
|
||||
channel_type: 'discord',
|
||||
platform_id: 'channel-123',
|
||||
name: 'general',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const cmd = lookup('wirings-create');
|
||||
expect(cmd, 'wirings-create command must be registered').toBeDefined();
|
||||
|
||||
await cmd!.handler({ messaging_group_id: 'mg-1', agent_group_id: 'ag-1' }, hostCtx);
|
||||
|
||||
// Visible side effect: a destination row was created so the agent can
|
||||
// address this chat as a delivery target. Without postCreate, this was
|
||||
// empty and the agent's replies were silently dropped by the delivery
|
||||
// ACL — issue #2389.
|
||||
const destinations = getDestinations('ag-1');
|
||||
expect(destinations).toHaveLength(1);
|
||||
expect(destinations[0].target_type).toBe('channel');
|
||||
expect(destinations[0].target_id).toBe('mg-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('genericCreate postCommit hook', () => {
|
||||
it('wirings-create projects the new destination into live sessions', async () => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Agent One',
|
||||
folder: 'agent-one',
|
||||
agent_provider: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-1',
|
||||
channel_type: 'discord',
|
||||
platform_id: 'channel-123',
|
||||
name: 'general',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
// A container is already running for this agent — its session inbound.db
|
||||
// holds a stale destination projection.
|
||||
createSession({
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'running',
|
||||
last_active: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const cmd = lookup('wirings-create');
|
||||
await cmd!.handler({ messaging_group_id: 'mg-1', agent_group_id: 'ag-1' }, hostCtx);
|
||||
|
||||
// Live-refresh parity with `ncl destinations add`: without postCommit the
|
||||
// running container keeps serving the stale projection and drops replies
|
||||
// to this chat as "unknown destination" until a restart — issue #2389.
|
||||
expect(writeDestinationsSpy).toHaveBeenCalledWith('ag-1', 'sess-1');
|
||||
});
|
||||
|
||||
it('wirings-create projection is a no-op when no sessions are running', async () => {
|
||||
createAgentGroup({
|
||||
id: 'ag-2',
|
||||
name: 'Agent Two',
|
||||
folder: 'agent-two',
|
||||
agent_provider: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-2',
|
||||
channel_type: 'discord',
|
||||
platform_id: 'channel-456',
|
||||
name: 'ops',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const cmd = lookup('wirings-create');
|
||||
await cmd!.handler({ messaging_group_id: 'mg-2', agent_group_id: 'ag-2' }, hostCtx);
|
||||
|
||||
// No live session for ag-2 → nothing to project, and the central
|
||||
// destination row was still written (covered above).
|
||||
expect(writeDestinationsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('genericCreate resolveDefaults hook (two-pass create)', () => {
|
||||
beforeEach(() => {
|
||||
getDb().exec(
|
||||
`CREATE TABLE hooktest_rows (id TEXT PRIMARY KEY, kind TEXT NOT NULL, mode TEXT, created_at TEXT NOT NULL)`,
|
||||
);
|
||||
hookCalls.length = 0;
|
||||
});
|
||||
|
||||
it('runs between explicit args and static defaults — a hook fill beats the static default', async () => {
|
||||
const row = (await lookup('hooktests-create')!.handler({ kind: 'fill' }, hostCtx)) as { mode: string };
|
||||
expect(row.mode).toBe('hooked');
|
||||
// The hook saw the pre-static-default state: mode still unset. Were the
|
||||
// static default applied first, the hook could never fill it.
|
||||
expect(hookCalls[0].mode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('static default still applies when the hook leaves the column unset', async () => {
|
||||
const row = (await lookup('hooktests-create')!.handler({ kind: 'plain' }, hostCtx)) as { mode: string };
|
||||
expect(row.mode).toBe('static');
|
||||
});
|
||||
|
||||
it('explicit args always win over the hook', async () => {
|
||||
const row = (await lookup('hooktests-create')!.handler({ kind: 'fill', mode: 'explicit' }, hostCtx)) as {
|
||||
mode: string;
|
||||
};
|
||||
expect(row.mode).toBe('explicit');
|
||||
expect(hookCalls[0].mode).toBe('explicit');
|
||||
});
|
||||
|
||||
it('a hook throw rejects the create and nothing is inserted', async () => {
|
||||
await expect(lookup('hooktests-create')!.handler({ kind: 'boom' }, hostCtx)).rejects.toThrow('hook rejected');
|
||||
const count = getDb().prepare('SELECT COUNT(*) AS n FROM hooktest_rows').get() as { n: number };
|
||||
expect(count.n).toBe(0);
|
||||
});
|
||||
});
|
||||
+80
-5
@@ -83,6 +83,51 @@ export interface ResourceDef {
|
||||
};
|
||||
/** Non-standard verbs (grant, revoke, add, remove, restart, etc.). */
|
||||
customOperations?: Record<string, CustomOperation>;
|
||||
/**
|
||||
* Runs on `create` between explicit-arg collection and static column
|
||||
* defaults (two-pass create): fills omitted columns with context-aware
|
||||
* values (e.g. channel adapter declarations) and cross-validates the
|
||||
* combination, throwing an actionable Error to reject. Mutates `values`
|
||||
* in place. Explicit caller args are already present and must win — only
|
||||
* fill what's still undefined. Static `col.default` / `defaultFrom` apply
|
||||
* afterwards, only to columns the hook left unset, so a static default can
|
||||
* never pre-empt context-aware resolution.
|
||||
*/
|
||||
resolveDefaults?: (values: Record<string, unknown>) => void;
|
||||
/**
|
||||
* Runs on `update` after the update set is built, before the UPDATE
|
||||
* executes. `current` is the existing row; `updates` holds only the
|
||||
* changed columns and is mutable (coercions land here). Throw to reject.
|
||||
* Mirror of the create-side validation in `resolveDefaults` for resources
|
||||
* whose column combinations need cross-checks — a partial update must not
|
||||
* be able to produce a combination `create` would have rejected.
|
||||
*/
|
||||
preUpdate?: (updates: Record<string, unknown>, current: Record<string, unknown>) => void;
|
||||
/**
|
||||
* Runs after a successful `create` INSERT, with the row that was just
|
||||
* written. Used to wire in side effects that the central row alone
|
||||
* doesn't trigger — e.g. creating a `container_configs` row when a new
|
||||
* agent group is added, or the companion `agent_destinations` row when a
|
||||
* wiring is added. The hook receives the same `values` object that was
|
||||
* inserted, so generated fields like `id` and `created_at` are populated.
|
||||
*/
|
||||
postCreate?: (row: Record<string, unknown>) => void;
|
||||
/**
|
||||
* Runs AFTER the create transaction has committed, with the row that was
|
||||
* written. Use this — not `postCreate` — for side effects that live
|
||||
* OUTSIDE the central DB (filesystem writes, projecting rows into a
|
||||
* running agent's session `inbound.db`) or that are async: those must not
|
||||
* sit inside the better-sqlite3 transaction, which only covers central-DB
|
||||
* statements and is synchronous.
|
||||
*
|
||||
* The canonical case is live-refresh parity with `ncl destinations add`:
|
||||
* after `ncl wirings create` writes the companion `agent_destinations`
|
||||
* row, the change has to be projected into any running container's session
|
||||
* DB or the agent won't see the new delivery target until its next spawn
|
||||
* (#2389). Runs only if the transaction succeeds, so it never observes a
|
||||
* rolled-back row.
|
||||
*/
|
||||
postCommit?: (row: Record<string, unknown>) => void | Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -147,6 +192,10 @@ function genericCreate(def: ResourceDef) {
|
||||
return async (args: Record<string, unknown>) => {
|
||||
const values: Record<string, unknown> = {};
|
||||
|
||||
// Pass 1: generated columns + explicit caller args only. Static defaults
|
||||
// wait until after resolveDefaults so the hook sees exactly what the
|
||||
// caller provided and a static default never pre-empts context-aware
|
||||
// resolution.
|
||||
for (const col of def.columns) {
|
||||
if (col.generated) {
|
||||
if (col.name === def.idColumn) {
|
||||
@@ -165,7 +214,16 @@ function genericCreate(def: ResourceDef) {
|
||||
values[col.name] = col.type === 'number' ? Number(v) : v;
|
||||
} else if (col.required) {
|
||||
throw new Error(`--${col.name.replace(/_/g, '-')} is required`);
|
||||
} else if (col.default !== undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: context-aware defaults + cross-column validation.
|
||||
if (def.resolveDefaults) def.resolveDefaults(values);
|
||||
|
||||
// Pass 3: static defaults for whatever is still unset.
|
||||
for (const col of def.columns) {
|
||||
if (col.generated || values[col.name] !== undefined) continue;
|
||||
if (col.default !== undefined) {
|
||||
values[col.name] = col.default;
|
||||
} else if (col.defaultFrom !== undefined && values[col.defaultFrom] !== undefined) {
|
||||
values[col.name] = values[col.defaultFrom];
|
||||
@@ -174,15 +232,25 @@ function genericCreate(def: ResourceDef) {
|
||||
|
||||
const colNames = Object.keys(values);
|
||||
const placeholders = colNames.map((c) => `@${c}`);
|
||||
getDb()
|
||||
.prepare(`INSERT INTO ${def.table} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`)
|
||||
.run(values);
|
||||
// Single transaction so a postCreate throw rolls back the parent INSERT —
|
||||
// closes the partial-state class this PR exists to fix (#2415, #2389).
|
||||
// better-sqlite3 .transaction() is sync, so `postCreate` is sync too and
|
||||
// must only touch the central DB (it's the atomic companion-row write).
|
||||
// Anything async or outside the central DB — filesystem, session-DB
|
||||
// projection — belongs in `postCommit`, which runs after commit below.
|
||||
const db = getDb();
|
||||
db.transaction(() => {
|
||||
db.prepare(`INSERT INTO ${def.table} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`).run(values);
|
||||
if (def.postCreate) def.postCreate(values);
|
||||
})();
|
||||
if (def.postCommit) await def.postCommit(values);
|
||||
return values;
|
||||
};
|
||||
}
|
||||
|
||||
function genericUpdate(def: ResourceDef) {
|
||||
const updatableCols = def.columns.filter((c) => c.updatable);
|
||||
const cols = visibleColumns(def).join(', ');
|
||||
return async (args: Record<string, unknown>) => {
|
||||
const id = args.id as string;
|
||||
if (!id) throw new Error(`${def.name} id is required`);
|
||||
@@ -203,6 +271,14 @@ function genericUpdate(def: ResourceDef) {
|
||||
);
|
||||
}
|
||||
|
||||
if (def.preUpdate) {
|
||||
const current = getDb().prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`).get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!current) throw new Error(`${def.name} not found: ${id}`);
|
||||
def.preUpdate(updates, current);
|
||||
}
|
||||
|
||||
const setClause = Object.keys(updates)
|
||||
.map((k) => `${k} = @${k}`)
|
||||
.join(', ');
|
||||
@@ -211,7 +287,6 @@ function genericUpdate(def: ResourceDef) {
|
||||
.run({ ...updates, _id: id });
|
||||
if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`);
|
||||
|
||||
const cols = visibleColumns(def).join(', ');
|
||||
return getDb().prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`).get(id);
|
||||
};
|
||||
}
|
||||
|
||||
+23
-68
@@ -115,6 +115,15 @@ register({
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'tasks-list',
|
||||
description: 'test command (tasks resource)',
|
||||
resource: 'tasks',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'wirings-list',
|
||||
description: 'test command (wirings resource — not allowed)',
|
||||
@@ -136,33 +145,6 @@ register({
|
||||
},
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'roles-grant',
|
||||
description: 'approval command on a non-scoped resource (global blast radius)',
|
||||
resource: 'roles',
|
||||
access: 'approval',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => ({}),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'members-add-gated',
|
||||
description: 'approval command on a scoped resource',
|
||||
resource: 'members',
|
||||
access: 'approval',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => ({}),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'groups-update',
|
||||
description: 'approval command on the groups resource (id = agent group)',
|
||||
resource: 'groups',
|
||||
access: 'approval',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => ({}),
|
||||
});
|
||||
|
||||
// Commands that return data shaped like real resources (for post-handler filtering tests)
|
||||
register({
|
||||
name: 'groups-list-data',
|
||||
@@ -245,6 +227,7 @@ beforeEach(() => {
|
||||
sessions: 'agent_group_id',
|
||||
destinations: 'agent_group_id',
|
||||
members: 'agent_group_id',
|
||||
tasks: 'agent_group_id',
|
||||
};
|
||||
mockGetResource.mockImplementation((plural: string) =>
|
||||
scopeFields[plural] ? { scopeField: scopeFields[plural] } : undefined,
|
||||
@@ -391,6 +374,19 @@ describe('CLI scope enforcement', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('group: allows tasks, auto-fills --group', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'tasks-list', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.group).toBe('g1');
|
||||
expect(data.echo.id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('group: blocks non-whitelisted resources (wirings)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
@@ -511,47 +507,6 @@ describe('CLI scope enforcement', () => {
|
||||
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// --- Approver blast radius (D1) ---
|
||||
|
||||
it('holds on non-scoped resources carry approverScope global (roles grant)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
|
||||
mockGetAgentGroup.mockReturnValue({ id: 'g1', name: 'Group One' });
|
||||
|
||||
const resp = await dispatch(
|
||||
{ id: '1', command: 'roles-grant', args: { user: 'telegram:mallory', role: 'owner' } },
|
||||
agentCtx(),
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(approvalState.requestApproval.mock.calls[0][0]).toMatchObject({ approverScope: 'global' });
|
||||
});
|
||||
|
||||
it('holds on scoped resources pinned to the caller stay approverScope group', 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 resp = await dispatch({ id: '1', command: 'members-add-gated', args: { user: 'telegram:new' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(approvalState.requestApproval.mock.calls[0][0]).toMatchObject({ approverScope: 'group' });
|
||||
});
|
||||
|
||||
it('holds on scoped resources targeting another group escalate to approverScope global', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
|
||||
mockGetAgentGroup.mockReturnValue({ id: 'g1', name: 'Group One' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-update', args: { id: 'g2' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(approvalState.requestApproval.mock.calls[0][0]).toMatchObject({ approverScope: 'global' });
|
||||
});
|
||||
|
||||
// --- Post-handler filtering ---
|
||||
|
||||
it('group: groups list filters out other groups', async () => {
|
||||
|
||||
+4
-22
@@ -11,34 +11,16 @@ import { getAgentGroup } from '../db/agent-groups.js';
|
||||
import { getSession } from '../db/sessions.js';
|
||||
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
|
||||
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
|
||||
import { localizeIsoTimestamps } from './format.js';
|
||||
import { getResource } from './crud.js';
|
||||
import { listVerbs, renderVerbHelp } from './help-render.js';
|
||||
import { GROUP_SCOPE_RESOURCES, listCommands, lookup, type CommandDef } from './registry.js';
|
||||
import { GROUP_SCOPE_RESOURCES, listCommands, lookup } from './registry.js';
|
||||
|
||||
type DispatchOptions = {
|
||||
/** True when a command is being replayed after approval. */
|
||||
approved?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Blast radius of a held command, for the hold's approver rule (D1): a
|
||||
* mutation of a non-group-scoped resource (roles, users, wirings,
|
||||
* messaging-groups, policies) — or one explicitly targeting another agent
|
||||
* group — needs an owner or global admin to approve; a scoped admin's click
|
||||
* is rejected. GROUP_SCOPE_RESOURCES anchors rows to one agent group, so its
|
||||
* held mutations default to group-local blast radius.
|
||||
*/
|
||||
function approverScopeFor(
|
||||
cmd: CommandDef,
|
||||
args: Record<string, unknown>,
|
||||
callerAgentGroupId: string,
|
||||
): 'group' | 'global' {
|
||||
if (!cmd.resource || !GROUP_SCOPE_RESOURCES.has(cmd.resource)) return 'global';
|
||||
const groupRefs = [args.agent_group_id, args.group];
|
||||
if (cmd.resource === 'groups' || cmd.resource === 'destinations') groupRefs.push(args.id);
|
||||
return groupRefs.some((v) => v !== undefined && v !== callerAgentGroupId) ? 'global' : 'group';
|
||||
}
|
||||
|
||||
export async function dispatch(
|
||||
req: RequestFrame,
|
||||
ctx: CallerContext,
|
||||
@@ -165,7 +147,6 @@ export async function dispatch(
|
||||
payload: { frame: { id: req.id, command: req.command, args: req.args }, callerContext: ctx },
|
||||
title: `CLI: ${req.command}`,
|
||||
question: `Agent "${agentName}" wants to run:\n\`ncl ${req.command}${argSummary ? ' ' + argSummary : ''}\``,
|
||||
approverScope: approverScopeFor(cmd, req.args, ctx.agentGroupId),
|
||||
});
|
||||
|
||||
return err(req.id, 'approval-pending', 'Approval request sent to admin. You will be notified of the result.');
|
||||
@@ -241,7 +222,8 @@ registerApprovalHandler('cli_command', async ({ payload, notify }) => {
|
||||
const response = await dispatch(frame, callerContext, { approved: true });
|
||||
|
||||
if (response.ok) {
|
||||
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
|
||||
const localized = localizeIsoTimestamps(response.data);
|
||||
const data = typeof localized === 'string' ? localized : JSON.stringify(localized, null, 2);
|
||||
notify(`Your \`ncl ${frame.command}\` request was approved and executed.\n\n${data}`);
|
||||
} else {
|
||||
notify(`Your \`ncl ${frame.command}\` request was approved but failed: ${response.error.message}`);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Compact aligned-table renderer for `ncl tasks list` (human mode only).
|
||||
*
|
||||
* A task series is a recurring job; each fire is a run. This surfaces the run history
|
||||
* the raw row hides — run count, last/next fire, schedule — as an aligned table.
|
||||
* Driven by the enriched rows from listTasks (tasks.ts); the --json path is
|
||||
* untouched. `now` is injectable so the relative times are testable.
|
||||
*/
|
||||
|
||||
interface TaskListRow {
|
||||
series_id: string;
|
||||
schedule?: string | null;
|
||||
runs?: number;
|
||||
failed_runs?: number;
|
||||
last_run?: string | null;
|
||||
next_run?: string | null;
|
||||
status?: string;
|
||||
log?: string | null;
|
||||
created_at?: string | null;
|
||||
prompt?: string | null;
|
||||
}
|
||||
|
||||
const COLS = ['SERIES', 'SCHEDULE', 'RUNS', 'FAILED', 'LAST RUN', 'NEXT RUN', 'STATUS', 'AGE', 'PROMPT'] as const;
|
||||
|
||||
function parseMs(iso: string): number {
|
||||
return Date.parse(/[Z+]|[+-]\d\d:\d\d$/.test(iso) ? iso : iso + 'Z');
|
||||
}
|
||||
|
||||
/** "1m ago" / "in 30s" — coarse, human relative time. */
|
||||
function duration(ms: number): string {
|
||||
const s = Math.abs(ms) / 1000;
|
||||
if (s < 60) return `${Math.round(s)}s`;
|
||||
if (s < 3600) return `${Math.round(s / 60)}m`;
|
||||
if (s < 86400) return `${Math.round(s / 3600)}h`;
|
||||
return `${Math.round(s / 86400)}d`;
|
||||
}
|
||||
|
||||
function lastRun(iso: string | null | undefined, now: number): string {
|
||||
if (!iso) return '-';
|
||||
const t = parseMs(iso);
|
||||
if (Number.isNaN(t)) return iso;
|
||||
return `${duration(now - t)} ago`;
|
||||
}
|
||||
|
||||
function nextRun(iso: string | null | undefined, now: number): string {
|
||||
if (!iso) return '-';
|
||||
const t = parseMs(iso);
|
||||
if (Number.isNaN(t)) return iso;
|
||||
return t <= now ? 'due' : `in ${duration(t - now)}`;
|
||||
}
|
||||
|
||||
/** AGE — how long since the series was created. */
|
||||
function age(iso: string | null | undefined, now: number): string {
|
||||
if (!iso) return '-';
|
||||
const t = parseMs(iso);
|
||||
return Number.isNaN(t) ? '-' : duration(now - t);
|
||||
}
|
||||
|
||||
function clip(s: string | null | undefined, n: number): string {
|
||||
const v = (s ?? '').replace(/\s+/g, ' ').trim();
|
||||
return v.length > n ? v.slice(0, n - 1) + '…' : v;
|
||||
}
|
||||
|
||||
export function formatTasksTable(rows: TaskListRow[], now: number = Date.now()): string {
|
||||
if (!rows.length) return 'No tasks.';
|
||||
const body = rows.map((r) => [
|
||||
r.series_id, // full id, copy-pasteable into `ncl tasks get --id <…>`
|
||||
r.schedule || 'once',
|
||||
String(r.runs ?? 0),
|
||||
String(r.failed_runs ?? 0),
|
||||
lastRun(r.last_run, now),
|
||||
nextRun(r.next_run, now),
|
||||
r.status ?? '-',
|
||||
age(r.created_at, now),
|
||||
clip(r.prompt, 40),
|
||||
]);
|
||||
const widths = COLS.map((c, i) => Math.max(c.length, ...body.map((row) => row[i].length)));
|
||||
const line = (cells: string[]) =>
|
||||
cells
|
||||
.map((c, i) => c.padEnd(widths[i]))
|
||||
.join(' ')
|
||||
.trimEnd();
|
||||
return [line([...COLS]), ...body.map(line)].join('\n');
|
||||
}
|
||||
+29
-1
@@ -8,10 +8,37 @@
|
||||
* itself. The DB transport (when it lands) skips this layer entirely —
|
||||
* the agent sees frames directly.
|
||||
*/
|
||||
import { TIMEZONE } from '../config.js';
|
||||
import { formatLocalStamp } from '../timezone.js';
|
||||
import type { ResponseFrame } from './frame.js';
|
||||
|
||||
export type FormatMode = 'human' | 'json';
|
||||
|
||||
// A string is treated as a display timestamp only when the WHOLE value is a
|
||||
// UTC ISO instant; embedded occurrences inside longer strings may be machine
|
||||
// payloads and stay raw. Mirrored in container/agent-runner/src/cli/ncl.ts
|
||||
// (the two runtimes share no modules).
|
||||
const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/;
|
||||
|
||||
/**
|
||||
* Human display shows local time; --json keeps the ISO machine contract.
|
||||
* The "YYYY-MM-DD HH:mm" stamp shape round-trips: parseZonedToUtc reads a
|
||||
* naive string as local wall-clock time, so a value copied from `ncl tasks
|
||||
* get` output into `--process-after` means what it shows.
|
||||
*/
|
||||
export function localizeIsoTimestamps(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return ISO_UTC_RE.test(value) ? formatLocalStamp(new Date(value), TIMEZONE) : value;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(localizeIsoTimestamps);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, localizeIsoTimestamps(v)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatResponse(res: ResponseFrame, mode: FormatMode): string {
|
||||
if (mode === 'json') return JSON.stringify(res, null, 2) + '\n';
|
||||
|
||||
@@ -21,7 +48,8 @@ export function formatResponse(res: ResponseFrame, mode: FormatMode): string {
|
||||
return formatHuman(res.data) + '\n';
|
||||
}
|
||||
|
||||
function formatHuman(data: unknown): string {
|
||||
function formatHuman(rawData: unknown): string {
|
||||
const data = localizeIsoTimestamps(rawData);
|
||||
if (data === null || data === undefined) return '';
|
||||
if (typeof data === 'string') return data;
|
||||
if (Array.isArray(data) && data.every(isFlatRecord)) {
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import type { CallerContext } from './frame.js';
|
||||
* consumed by both dispatch enforcement and `ncl help` filtering, so the
|
||||
* agent is never shown a resource the gate would reject (or vice versa).
|
||||
*/
|
||||
export const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']);
|
||||
export const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members', 'tasks']);
|
||||
|
||||
export type Access = 'open' | 'approval' | 'hidden';
|
||||
|
||||
|
||||
@@ -48,24 +48,6 @@ registerResource({
|
||||
},
|
||||
{ name: 'title', type: 'string', description: 'Card title shown to the admin.' },
|
||||
{ name: 'options_json', type: 'json', description: 'Card button options as JSON array.' },
|
||||
{
|
||||
name: 'approver_user_id',
|
||||
type: 'string',
|
||||
description: 'Named approver (exclusive) or the admin the card was delivered to (admins-of-scope).',
|
||||
},
|
||||
{
|
||||
name: 'approver_rule',
|
||||
type: 'string',
|
||||
description: 'Who may resolve: only the named approver, or the admin chain of the anchoring group.',
|
||||
enum: ['exclusive', 'admins-of-scope'],
|
||||
},
|
||||
{
|
||||
name: 'approver_scope',
|
||||
type: 'string',
|
||||
description: "Blast radius: 'global' holds require an owner or global admin to resolve.",
|
||||
enum: ['group', 'global'],
|
||||
},
|
||||
{ name: 'dedup_key', type: 'string', description: 'In-flight dedup key (e.g. sender admission per chat+sender).' },
|
||||
],
|
||||
operations: { list: 'open', get: 'open' },
|
||||
});
|
||||
|
||||
@@ -9,13 +9,15 @@ import { registerResource } from '../crud.js';
|
||||
* on `hasTable('agent_destinations')` and load `writeDestinations` lazily —
|
||||
* same pattern as container-runner.ts on container wake.
|
||||
*
|
||||
* Called from both `add` and `remove` so the live container picks up the
|
||||
* change without waiting for the next spawn. Without this, send_message to
|
||||
* the new local_name silently drops with "unknown destination" until restart.
|
||||
* Called from every destination-mutating ncl command — `add` and `remove`
|
||||
* here, plus `wirings create` (which writes a companion destination row in
|
||||
* its postCreate hook) — so the live container picks up the change without
|
||||
* waiting for the next spawn. Without this, send_message to the new
|
||||
* local_name silently drops with "unknown destination" until restart.
|
||||
* See the destination-projection invariant in
|
||||
* src/modules/agent-to-agent/db/agent-destinations.ts.
|
||||
*/
|
||||
async function projectDestinationsToSessions(agentGroupId: string): Promise<void> {
|
||||
export async function projectDestinationsToSessions(agentGroupId: string): Promise<void> {
|
||||
if (!hasTable(getDb(), 'agent_destinations')) return;
|
||||
const { writeDestinations } = await import('../../modules/agent-to-agent/write-destinations.js');
|
||||
for (const session of getSessionsByAgentGroup(agentGroupId)) {
|
||||
@@ -58,10 +60,39 @@ registerResource({
|
||||
type: 'string',
|
||||
description: "The target's ID — messaging_groups.id for channels, agent_groups.id for agents.",
|
||||
},
|
||||
{ name: 'channel_type', type: 'string', description: 'Resolved channel type for channel destinations.' },
|
||||
{ name: 'display_name', type: 'string', description: 'Resolved chat title or agent name.' },
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.' },
|
||||
],
|
||||
operations: { list: 'open' },
|
||||
operations: {},
|
||||
customOperations: {
|
||||
list: {
|
||||
access: 'open',
|
||||
description: 'List destinations with resolved channel/title labels.',
|
||||
handler: async (args) => {
|
||||
const agentGroupId = (args.agent_group_id as string | undefined) ?? (args.id as string | undefined);
|
||||
const params: unknown[] = [];
|
||||
const where = agentGroupId ? 'WHERE ad.agent_group_id = ?' : '';
|
||||
if (agentGroupId) params.push(agentGroupId);
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT
|
||||
ad.agent_group_id,
|
||||
ad.local_name,
|
||||
ad.target_type,
|
||||
ad.target_id,
|
||||
CASE WHEN ad.target_type = 'channel' THEN mg.channel_type ELSE NULL END AS channel_type,
|
||||
CASE WHEN ad.target_type = 'channel' THEN mg.name ELSE ag.name END AS display_name,
|
||||
ad.created_at
|
||||
FROM agent_destinations ad
|
||||
LEFT JOIN messaging_groups mg ON ad.target_type = 'channel' AND ad.target_id = mg.id
|
||||
LEFT JOIN agent_groups ag ON ad.target_type = 'agent' AND ad.target_id = ag.id
|
||||
${where}
|
||||
ORDER BY ad.agent_group_id, ad.local_name`,
|
||||
)
|
||||
.all(...params);
|
||||
},
|
||||
},
|
||||
add: {
|
||||
access: 'approval',
|
||||
description: 'Add a destination for an agent. Use --agent-group-id, --local-name, --target-type, --target-id.',
|
||||
@@ -79,9 +110,9 @@ registerResource({
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(agentGroupId, localName, targetType, targetId);
|
||||
.run(agentGroupId, localName, targetType, targetId, new Date().toISOString());
|
||||
await projectDestinationsToSessions(agentGroupId);
|
||||
return { agent_group_id: agentGroupId, local_name: localName, target_type: targetType, target_id: targetId };
|
||||
},
|
||||
|
||||
@@ -109,13 +109,10 @@ describe('groups CLI delete cascades dependent rows (#2525)', () => {
|
||||
VALUES (?, ?, 'req-1', 'cli_command', '{}', ?, ?, 'pending', '', '[]')`,
|
||||
).run('pa-1', SID, now(), GID);
|
||||
|
||||
// Sessionless sender-admission hold anchored to the group (the folded
|
||||
// pending_sender_approvals shape) — covered by the agent_group_id leg of
|
||||
// the pending_approvals cascade.
|
||||
db.prepare(
|
||||
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, agent_group_id, status, title, options_json, dedup_key)
|
||||
VALUES (?, NULL, 'req-2', 'sender_admit', '{}', ?, ?, 'pending', '', '[]', 'sender_admit:mg:tg:99')`,
|
||||
).run('pa-2', now(), GID);
|
||||
`INSERT INTO pending_sender_approvals (id, messaging_group_id, agent_group_id, sender_identity, sender_name, original_message, approver_user_id, created_at)
|
||||
VALUES ('psa-1', ?, ?, 'tg:99', 'them', '{}', ?, ?)`,
|
||||
).run(MGID, GID, UID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO pending_channel_approvals (messaging_group_id, agent_group_id, original_message, approver_user_id, created_at)
|
||||
@@ -151,9 +148,10 @@ describe('groups CLI delete cascades dependent rows (#2525)', () => {
|
||||
expect(data.removed).toMatchObject({
|
||||
sessions: 1,
|
||||
pending_questions: 1,
|
||||
pending_approvals: 2,
|
||||
pending_approvals: 1,
|
||||
agent_destinations_owned: 1,
|
||||
agent_destinations_pointing: 0,
|
||||
pending_sender_approvals: 1,
|
||||
pending_channel_approvals: 1,
|
||||
messaging_group_agents: 1,
|
||||
agent_group_members: 1,
|
||||
@@ -169,6 +167,7 @@ describe('groups CLI delete cascades dependent rows (#2525)', () => {
|
||||
count('SELECT COUNT(*) AS c FROM pending_approvals WHERE agent_group_id = ? OR session_id = ?', GID, SID),
|
||||
).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM agent_destinations WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM pending_sender_approvals WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM pending_channel_approvals WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM agent_group_members WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
updateContainerConfigScalars,
|
||||
updateContainerConfigJson,
|
||||
} from '../../db/container-configs.js';
|
||||
import { initGroupFilesystem } from '../../group-init.js';
|
||||
import { createAgentFromTemplate } from '../../templates/create-agent.js';
|
||||
import type { AgentGroup, ContainerConfigRow } from '../../types.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
@@ -90,6 +91,15 @@ registerResource({
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
createAgentGroup(group);
|
||||
// Provision the workspace folder and the `container_configs` row that
|
||||
// `getContainerConfig` and the spawn path require. Without this, a
|
||||
// group created via `ncl groups create` would throw "Container config
|
||||
// not found" on first spawn and stay broken until the host restart
|
||||
// backfill ran (#2415). The template branch above provisions its own
|
||||
// config + folder in `createAgentFromTemplate`; this covers the bare
|
||||
// path. Mirrors what `setup/register.ts` does after creating an agent
|
||||
// group via the setup flow.
|
||||
initGroupFilesystem(group);
|
||||
return group;
|
||||
},
|
||||
},
|
||||
@@ -124,6 +134,7 @@ registerResource({
|
||||
pending_approvals: 0,
|
||||
agent_destinations_owned: 0,
|
||||
agent_destinations_pointing: 0,
|
||||
pending_sender_approvals: 0,
|
||||
pending_channel_approvals: 0,
|
||||
messaging_group_agents: 0,
|
||||
agent_group_members: 0,
|
||||
@@ -152,6 +163,9 @@ registerResource({
|
||||
.run(groupId, groupId).changes;
|
||||
}
|
||||
counts.sessions = db.prepare('DELETE FROM sessions WHERE agent_group_id = ?').run(groupId).changes;
|
||||
counts.pending_sender_approvals = db
|
||||
.prepare('DELETE FROM pending_sender_approvals WHERE agent_group_id = ?')
|
||||
.run(groupId).changes;
|
||||
counts.pending_channel_approvals = db
|
||||
.prepare('DELETE FROM pending_channel_approvals WHERE agent_group_id = ?')
|
||||
.run(groupId).changes;
|
||||
|
||||
@@ -14,3 +14,4 @@ import './user-dms.js';
|
||||
import './dropped-messages.js';
|
||||
import './approvals.js';
|
||||
import './sessions.js';
|
||||
import './tasks.js';
|
||||
|
||||
@@ -41,9 +41,9 @@ registerResource({
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
|
||||
VALUES (?, ?, ?, datetime('now'))`,
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run(userId, groupId, addedBy);
|
||||
.run(userId, groupId, addedBy, new Date().toISOString());
|
||||
return { user_id: userId, agent_group_id: groupId };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -24,12 +24,23 @@ vi.mock('../../config.js', async () => {
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-cli-msggroups';
|
||||
|
||||
import type { ChannelDefaults } from '../../channels/adapter.js';
|
||||
import { registerChannelAdapter } from '../../channels/channel-registry.js';
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { getMessagingGroupByPlatform } from '../../db/messaging-groups.js';
|
||||
import { dispatch } from '../dispatch.js';
|
||||
// Side-effect import: registers the `messaging-groups-create` command.
|
||||
import './messaging-groups.js';
|
||||
|
||||
// Registration-tier declaration (no live adapter) — the environment `ncl`
|
||||
// sees for offline instances and setup scripts.
|
||||
const declared: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
registerChannelAdapter('declchan-mg', { factory: () => null, defaults: declared });
|
||||
|
||||
describe('messaging-groups CLI create defaults instance to channel_type', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
@@ -73,3 +84,46 @@ describe('messaging-groups CLI create defaults instance to channel_type', () =>
|
||||
expect(getMessagingGroupByPlatform('telegram', '67890', 'work')?.instance).toBe('work');
|
||||
});
|
||||
});
|
||||
|
||||
describe('messaging-groups CLI create resolves unknown_sender_policy from the channel declaration', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
runMigrations(initTestDb());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
const create = (args: Record<string, unknown>, id: string) =>
|
||||
dispatch({ id, command: 'messaging-groups-create', args }, { caller: 'host' });
|
||||
|
||||
it('DM context takes the declared dm policy', async () => {
|
||||
const resp = await create({ channel_type: 'declchan-mg', platform_id: 'dm-1' }, 'req-d1');
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(getMessagingGroupByPlatform('declchan-mg', 'dm-1')?.unknown_sender_policy).toBe('public');
|
||||
});
|
||||
|
||||
it('group context takes the declared group policy', async () => {
|
||||
const resp = await create({ channel_type: 'declchan-mg', platform_id: 'g-1', is_group: '1' }, 'req-d2');
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(getMessagingGroupByPlatform('declchan-mg', 'g-1')?.unknown_sender_policy).toBe('request_approval');
|
||||
});
|
||||
|
||||
it('explicit --unknown-sender-policy wins over the declaration', async () => {
|
||||
const resp = await create(
|
||||
{ channel_type: 'declchan-mg', platform_id: 'dm-2', unknown_sender_policy: 'strict' },
|
||||
'req-d3',
|
||||
);
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(getMessagingGroupByPlatform('declchan-mg', 'dm-2')?.unknown_sender_policy).toBe('strict');
|
||||
});
|
||||
|
||||
it("undeclared channels keep the legacy static 'strict' default (back-compat)", async () => {
|
||||
const resp = await create({ channel_type: 'stalechan-mg', platform_id: 's-1' }, 'req-d4');
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(getMessagingGroupByPlatform('stalechan-mg', 's-1')?.unknown_sender_policy).toBe('strict');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { resolveUnknownSenderPolicy } from '../../channels/channel-defaults.js';
|
||||
import { hasDeclaredChannelDefaults } from '../../channels/channel-registry.js';
|
||||
import { log } from '../../log.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
registerResource({
|
||||
@@ -48,7 +51,7 @@ registerResource({
|
||||
name: 'unknown_sender_policy',
|
||||
type: 'string',
|
||||
description:
|
||||
'What happens when an unrecognized sender posts. "strict" drops silently. "request_approval" sends an approval card to an admin. "public" allows anyone.',
|
||||
'What happens when an unrecognized sender posts. "strict" drops silently. "request_approval" sends an approval card to an admin. "public" allows anyone. Default: declared by the channel adapter for this context (DM vs group); "strict" when the channel has no declaration.',
|
||||
enum: ['strict', 'request_approval', 'public'],
|
||||
default: 'strict',
|
||||
updatable: true,
|
||||
@@ -63,4 +66,21 @@ registerResource({
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
||||
resolveDefaults: (values) => {
|
||||
if (values.unknown_sender_policy !== undefined) return;
|
||||
const channelType = String(values.channel_type);
|
||||
const channelKey = (values.instance as string | undefined) ?? channelType;
|
||||
// Static 'strict' stays the no-declaration fallback: a trunk update alone
|
||||
// must not change ncl's creation defaults for stale (undeclared) adapters.
|
||||
if (!hasDeclaredChannelDefaults(channelKey, channelType)) {
|
||||
log.warn(
|
||||
`messaging-group create: channel '${channelKey}' has no declared defaults (adapter not installed or stale) — using legacy static defaults`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// is_group carries its static default (0) only after this hook runs, so
|
||||
// treat "not provided" as the same DM context the static default means.
|
||||
const isGroup = Number(values.is_group ?? 0) === 1;
|
||||
values.unknown_sender_policy = resolveUnknownSenderPolicy(channelKey, isGroup, channelType);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,9 +41,9 @@ registerResource({
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(userId, role, groupId, grantedBy);
|
||||
.run(userId, role, groupId, grantedBy, new Date().toISOString());
|
||||
return { user_id: userId, role, agent_group_id: groupId };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return {
|
||||
...actual,
|
||||
DATA_DIR: '/tmp/nanoclaw-test-cli-tasks',
|
||||
GROUPS_DIR: '/tmp/nanoclaw-test-cli-tasks/groups',
|
||||
TIMEZONE: 'UTC',
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
getActiveContainerCount: vi.fn().mockReturnValue(0),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-cli-tasks';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
||||
import { createSession, findSessionByAgentGroup, getSessionsByAgentGroup, taskThreadId } from '../../db/sessions.js';
|
||||
import { countDueMessages } from '../../db/session-db.js';
|
||||
import { inboundDbPath, initSessionFolder } from '../../session-manager.js';
|
||||
import { dispatch } from '../dispatch.js';
|
||||
import { formatTasksTable } from '../format-tasks.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
import './tasks.js';
|
||||
import '../commands/index.js'; // registers tasks-help for the help-topic test
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createGroup(id: string): void {
|
||||
createAgentGroup({ id, name: id, folder: id, agent_provider: null, created_at: now() });
|
||||
}
|
||||
|
||||
function createChatSession(group: string, id: string): void {
|
||||
createSession({
|
||||
id,
|
||||
agent_group_id: group,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
});
|
||||
initSessionFolder(group, id);
|
||||
}
|
||||
|
||||
function agentCtx(group = 'ag-1', session = 'chat-1'): CallerContext {
|
||||
return { caller: 'agent', agentGroupId: group, sessionId: session, messagingGroupId: 'mg-1' };
|
||||
}
|
||||
|
||||
describe('tasks CLI resource', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
createGroup('ag-1');
|
||||
createGroup('ag-2');
|
||||
createChatSession('ag-1', 'chat-1');
|
||||
createChatSession('ag-2', 'chat-2');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('create writes the task into the group system session, not the caller chat session', async () => {
|
||||
const resp = await dispatch(
|
||||
{
|
||||
id: 'req-1',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'send a briefing', process_after: '2026-01-15T09:00:00Z' },
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
const created = resp.data as { series_id: string; session_id: string };
|
||||
expect(created.session_id).not.toBe('chat-1');
|
||||
|
||||
// The task lands in its own isolated per-series session, not the chat session.
|
||||
const sessions = getSessionsByAgentGroup('ag-1');
|
||||
const taskSession = sessions.find((s) => s.id === created.session_id);
|
||||
expect(taskSession?.thread_id).toBe(taskThreadId(created.series_id));
|
||||
|
||||
const chatDb = new Database(inboundDbPath('ag-1', 'chat-1'), { readonly: true });
|
||||
expect(chatDb.prepare("SELECT COUNT(*) AS count FROM messages_in WHERE kind = 'task'").get()).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
chatDb.close();
|
||||
|
||||
const systemDb = new Database(inboundDbPath('ag-1', created.session_id), { readonly: true });
|
||||
const row = systemDb.prepare("SELECT content FROM messages_in WHERE kind = 'task'").get() as { content: string };
|
||||
const content = JSON.parse(row.content);
|
||||
expect(content).toMatchObject({ originSessionId: 'chat-1' });
|
||||
expect(content.prompt).toContain('send a briefing');
|
||||
expect(content.prompt).toContain(`tasks/${created.series_id}.md`); // log-path hint injected
|
||||
systemDb.close();
|
||||
});
|
||||
|
||||
it('tasks-list attaches a server-rendered human table (so the container agent gets it too)', async () => {
|
||||
await dispatch(
|
||||
{
|
||||
id: 'c',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'briefing', process_after: '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
const resp = await dispatch({ id: 'l', command: 'tasks-list', args: {} }, agentCtx());
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
// Red-on-delete guard for the dispatch wiring: the host renders format-tasks
|
||||
// once and ships it as `human`, so the Bun container prints the aligned
|
||||
// table instead of a raw column dump (it cannot import the host formatter).
|
||||
expect(resp.human).toBeDefined();
|
||||
expect(resp.human).toMatch(/SERIES\s+SCHEDULE\s+RUNS\s+FAILED\s+LAST RUN\s+NEXT RUN/);
|
||||
expect(resp.human).toContain('briefing-');
|
||||
});
|
||||
|
||||
it('recurrence more frequent than 4x/day is refused with the quota warning', async () => {
|
||||
const resp = await dispatch(
|
||||
{ id: 'c', command: 'tasks-create', args: { prompt: 'x', name: 'spam', recurrence: '*/2 * * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.message).toContain('this task has not been scheduled');
|
||||
expect(resp.error.message).toContain('ncl tasks create --help');
|
||||
expect(resp.error.message).toContain('--dangerously-override-recurrence-limit');
|
||||
}
|
||||
});
|
||||
|
||||
it('exactly 4 fires/day passes; the override flag bypasses the limit', async () => {
|
||||
const four = await dispatch(
|
||||
{ id: 'c4', command: 'tasks-create', args: { prompt: 'x', name: 'four', recurrence: '0 0,6,12,18 * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(four.ok).toBe(true);
|
||||
|
||||
const overridden = await dispatch(
|
||||
{
|
||||
id: 'co',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'fast', recurrence: '*/30 * * * *', dangerously_override_recurrence_limit: true },
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
expect(overridden.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('a --script gate exempts frequent recurrence — the sanctioned monitor pattern', async () => {
|
||||
const scripted = await dispatch(
|
||||
{
|
||||
id: 'cs',
|
||||
command: 'tasks-create',
|
||||
args: {
|
||||
prompt: 'triage queue',
|
||||
name: 'watch',
|
||||
recurrence: '*/10 * * * *',
|
||||
script: 'echo {"wakeAgent": false}',
|
||||
},
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
expect(scripted.ok).toBe(true);
|
||||
|
||||
// update --recurrence on a task that already has a script: also exempt.
|
||||
if (!scripted.ok) return;
|
||||
const seriesId = (scripted.data as { series_id: string }).series_id;
|
||||
const upd = await dispatch(
|
||||
{ id: 'us', command: 'tasks-update', args: { id: seriesId, recurrence: '*/5 * * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(upd.ok).toBe(true);
|
||||
|
||||
// …but clearing the script in the same update re-arms the guard.
|
||||
const cleared = await dispatch(
|
||||
{ id: 'uc', command: 'tasks-update', args: { id: seriesId, recurrence: '*/5 * * * *', script: 'none' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(cleared.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('the limit also guards update --recurrence (no create-slow-then-update bypass)', async () => {
|
||||
const created = await dispatch(
|
||||
{ id: 'c', command: 'tasks-create', args: { prompt: 'x', name: 'sneak', recurrence: '0 9 * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const seriesId = (created.data as { series_id: string }).series_id;
|
||||
|
||||
const upd = await dispatch(
|
||||
{ id: 'u', command: 'tasks-update', args: { id: seriesId, recurrence: '* * * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(upd.ok).toBe(false);
|
||||
if (!upd.ok) expect(upd.error.message).toContain('this task has not been scheduled');
|
||||
});
|
||||
|
||||
it('tasks create --help carries the script contract and the frequency-limit caveat', async () => {
|
||||
// --help and `tasks help create` render the same deep verb help.
|
||||
const resp = await dispatch({ id: 'h', command: 'tasks-create', args: { help: true } }, agentCtx());
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const text = resp.data as string;
|
||||
expect(text).toContain('wakeAgent');
|
||||
expect(text).toContain('Frequency limit');
|
||||
}
|
||||
});
|
||||
|
||||
it('agent-shared lookup skips the task system session', async () => {
|
||||
await dispatch(
|
||||
{
|
||||
id: 'req-1',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'send a briefing', process_after: '2026-01-15T09:00:00Z' },
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
|
||||
expect(findSessionByAgentGroup('ag-1')?.id).toBe('chat-1');
|
||||
});
|
||||
|
||||
it('group-scoped agents cannot list tasks from another group session', async () => {
|
||||
const resp = await dispatch(
|
||||
{ id: 'req-1', command: 'tasks-list', args: { session: 'chat-2' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('handler-error');
|
||||
expect(resp.error.message).toContain('session not found');
|
||||
}
|
||||
});
|
||||
|
||||
it('--name yields a short, readable, fs/thread-safe id', async () => {
|
||||
const r = await dispatch(
|
||||
{
|
||||
id: 'rn',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'Morning Joke!!', process_after: '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const id = (r.data as { series_id: string }).series_id;
|
||||
expect(id).toMatch(/^morning-joke-[0-9a-f]{4}$/);
|
||||
expect(id).toMatch(/^[a-z0-9-]+$/); // safe as thread suffix / filename / --id
|
||||
});
|
||||
|
||||
it('no name yields a t-<hex> id', async () => {
|
||||
const r = await dispatch(
|
||||
{ id: 'rnn', command: 'tasks-create', args: { prompt: 'x', process_after: '2999-01-01T00:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect((r.data as { series_id: string }).series_id).toMatch(/^t-[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
it('recurring create derives the first run from the cron grid when --process-after is omitted', async () => {
|
||||
const r = await dispatch(
|
||||
{ id: 'rec', command: 'tasks-create', args: { prompt: 'x', name: 'nightly', recurrence: '0 9 * * 1-5' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const task = r.data as { process_after: string; recurrence: string };
|
||||
expect(task.recurrence).toBe('0 9 * * 1-5');
|
||||
// First fire snapped onto the cron grid (TIMEZONE=UTC in this suite).
|
||||
const firstRun = new Date(task.process_after);
|
||||
expect(Number.isNaN(firstRun.getTime())).toBe(false);
|
||||
expect(firstRun.getUTCHours()).toBe(9);
|
||||
expect(firstRun.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('one-shot create still requires --process-after (nothing to derive it from)', async () => {
|
||||
const r = await dispatch(
|
||||
{ id: 'os', command: 'tasks-create', args: { prompt: 'x', name: 'once' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error.message).toContain('--process-after is required');
|
||||
});
|
||||
|
||||
it('run queues an extra immediate occurrence without consuming the scheduled one', async () => {
|
||||
const created = await dispatch(
|
||||
{
|
||||
id: 'c',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'pingable', process_after: '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const { series_id, session_id } = created.data as { series_id: string; session_id: string };
|
||||
|
||||
const run = await dispatch({ id: 'r', command: 'tasks-run', args: { id: series_id } }, agentCtx('ag-1', 'chat-1'));
|
||||
expect(run.ok).toBe(true);
|
||||
if (!run.ok) return;
|
||||
const fired = run.data as { series_id: string; row_id: string; status: string };
|
||||
expect(fired.series_id).toBe(series_id);
|
||||
expect(fired.row_id).not.toBe(series_id);
|
||||
expect(fired.status).toBe('pending');
|
||||
|
||||
const db = new Database(inboundDbPath('ag-1', session_id), { readonly: true });
|
||||
const pending = db
|
||||
.prepare(
|
||||
"SELECT id, recurrence, process_after FROM messages_in WHERE kind = 'task' AND status = 'pending' AND series_id = ?",
|
||||
)
|
||||
.all(series_id) as Array<{ id: string; recurrence: string | null; process_after: string }>;
|
||||
db.close();
|
||||
// Original scheduled row + the new run-now occurrence both still pending.
|
||||
expect(pending).toHaveLength(2);
|
||||
const runRow = pending.find((p) => p.id === fired.row_id);
|
||||
expect(runRow?.recurrence).toBeNull(); // never re-armed into a phantom series
|
||||
expect(new Date(runRow!.process_after).getTime()).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
it('task object exposes origin_session_id and created_at', async () => {
|
||||
const r = await dispatch(
|
||||
{
|
||||
id: 'ro',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'o', process_after: '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const d = r.data as { origin_session_id: string | null; created_at: string };
|
||||
expect(d.origin_session_id).toBe('chat-1'); // the session that created it
|
||||
expect(d.created_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('each task gets its own isolated session, and list fans out across them', async () => {
|
||||
const a = await dispatch(
|
||||
{ id: 'r-a', command: 'tasks-create', args: { prompt: 'task A', process_after: '2026-01-15T09:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
const b = await dispatch(
|
||||
{ id: 'r-b', command: 'tasks-create', args: { prompt: 'task B', process_after: '2026-01-15T09:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(a.ok && b.ok).toBe(true);
|
||||
if (!a.ok || !b.ok) return;
|
||||
const ta = a.data as { session_id: string; series_id: string };
|
||||
const tb = b.data as { session_id: string; series_id: string };
|
||||
|
||||
// Distinct per-series sessions — not one shared system:tasks session.
|
||||
expect(ta.session_id).not.toBe(tb.session_id);
|
||||
|
||||
// list (no --session) fans out across every task session in the group.
|
||||
const list = await dispatch({ id: 'r-l', command: 'tasks-list', args: {} }, agentCtx('ag-1', 'chat-1'));
|
||||
expect(list.ok).toBe(true);
|
||||
if (!list.ok) return;
|
||||
const ids = (list.data as Array<{ series_id: string }>).map((t) => t.series_id);
|
||||
expect(ids).toContain(ta.series_id);
|
||||
expect(ids).toContain(tb.series_id);
|
||||
});
|
||||
|
||||
it('list enriches each series with run history (CronJob view)', async () => {
|
||||
const created = await dispatch(
|
||||
{
|
||||
id: 'r-agg',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'brain digest', recurrence: '0 9 * * *', 'process-after': '2026-01-15T09:05:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const { session_id, series_id } = created.data as { session_id: string; series_id: string };
|
||||
|
||||
// Seed three completed fires for this series into its own session inbound.db.
|
||||
const db = new Database(inboundDbPath('ag-1', session_id));
|
||||
const ins = db.prepare(
|
||||
'INSERT INTO messages_in (id, seq, timestamp, status, tries, kind, content, series_id, process_after) ' +
|
||||
"VALUES (?, ?, datetime('now'), 'completed', 0, 'task', '{}', ?, ?)",
|
||||
);
|
||||
ins.run('run-1', 100, series_id, '2026-01-15T09:02:00Z');
|
||||
ins.run('run-2', 102, series_id, '2026-01-15T09:03:00Z');
|
||||
ins.run('run-3', 104, series_id, '2026-01-15T09:04:00Z');
|
||||
db.close();
|
||||
|
||||
const list = await dispatch({ id: 'r-agg-l', command: 'tasks-list', args: {} }, agentCtx('ag-1', 'chat-1'));
|
||||
expect(list.ok).toBe(true);
|
||||
if (!list.ok) return;
|
||||
const row = (list.data as Array<Record<string, unknown>>).find((t) => t.series_id === series_id);
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.runs).toBe(3);
|
||||
expect(row?.last_run).toBe('2026-01-15T09:04:00Z'); // max completed process_after
|
||||
expect(String(row?.next_run)).toMatch(/^2026-01-15T09:05:00/); // the live pending occurrence
|
||||
expect(row?.schedule).toBe('0 9 * * *');
|
||||
expect(row?.log).toBe(`tasks/${series_id}.md`);
|
||||
});
|
||||
|
||||
// The schedule→wake primitive without a container: a task created through the
|
||||
// real `ncl tasks create` path must land in the agent group's system session
|
||||
// AND be counted by the same due-message query the host sweep uses to decide a
|
||||
// wake. Goes red if trigger defaulting, system-session routing, or the due
|
||||
// predicate ever drift apart.
|
||||
describe('a due task makes the system session wakeable', () => {
|
||||
it('countDueMessages sees a past task and ignores a future one', async () => {
|
||||
const created = await dispatch(
|
||||
{ id: 'r-due', command: 'tasks-create', args: { prompt: 'run me', 'process-after': '2020-01-01T00:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const systemId = (created.data as { session_id: string }).session_id;
|
||||
|
||||
const dueDb = new Database(inboundDbPath('ag-1', systemId), { readonly: true });
|
||||
expect(countDueMessages(dueDb)).toBe(1); // host sweep would wake this session
|
||||
dueDb.close();
|
||||
|
||||
// A far-future task in the same system session is not yet due.
|
||||
const future = await dispatch(
|
||||
{ id: 'r-fut', command: 'tasks-create', args: { prompt: 'later', 'process-after': '2999-01-01T00:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(future.ok).toBe(true);
|
||||
|
||||
const stillDb = new Database(inboundDbPath('ag-1', systemId), { readonly: true });
|
||||
expect(countDueMessages(stillDb)).toBe(1); // still just the one past task
|
||||
stillDb.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('append-log', () => {
|
||||
const logFile = (folder: string, series: string) => `${TEST_DIR}/groups/${folder}/tasks/${series}.md`;
|
||||
|
||||
it('writes a host-timestamped line to the run log and creates the file (explicit --id)', async () => {
|
||||
const resp = await dispatch(
|
||||
{ id: 'al-1', command: 'tasks-append-log', args: { id: 'my-task-1', msg: 'did the thing; it worked' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
const content = fs.readFileSync(logFile('ag-1', 'my-task-1'), 'utf8').trim();
|
||||
// Local-time stamp (formatLocalStamp): "YYYY-MM-DD HH:mm".
|
||||
expect(content).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2} — did the thing; it worked$/);
|
||||
});
|
||||
|
||||
it('derives the series from the caller task session when --id is omitted', async () => {
|
||||
const created = await dispatch(
|
||||
{
|
||||
id: 'al-c',
|
||||
command: 'tasks-create',
|
||||
args: { name: 'derive-me', prompt: 'x', 'process-after': '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const { series_id, session_id } = created.data as { series_id: string; session_id: string };
|
||||
|
||||
// The fire runs INSIDE that task session, so no --id is needed.
|
||||
const resp = await dispatch(
|
||||
{ id: 'al-2', command: 'tasks-append-log', args: { msg: 'auto-derived run' } },
|
||||
agentCtx('ag-1', session_id),
|
||||
);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
expect((resp.data as { series: string }).series).toBe(series_id);
|
||||
expect(fs.readFileSync(logFile('ag-1', series_id), 'utf8')).toContain('auto-derived run');
|
||||
});
|
||||
|
||||
it('requires --msg', async () => {
|
||||
const resp = await dispatch(
|
||||
{ id: 'al-3', command: 'tasks-append-log', args: { id: 'my-task-1' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) expect(resp.error.message).toContain('--msg is required');
|
||||
});
|
||||
|
||||
it('errors when there is no --id and the caller is not in a task session', async () => {
|
||||
// chat-1 is a normal chat session, not system:tasks:* → nothing to derive.
|
||||
const resp = await dispatch(
|
||||
{ id: 'al-4', command: 'tasks-append-log', args: { msg: 'orphan' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) expect(resp.error.message).toMatch(/--id is required/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTasksTable', () => {
|
||||
const now = Date.parse('2026-01-15T09:05:30Z');
|
||||
const rows = [
|
||||
{
|
||||
series_id: 'task-5bbe082a-6298-4699',
|
||||
schedule: '* * * * *',
|
||||
runs: 7,
|
||||
failed_runs: 2,
|
||||
last_run: '2026-01-15T09:04:30Z',
|
||||
next_run: '2026-01-15T09:06:00Z',
|
||||
status: 'pending',
|
||||
log: 'tasks/task-5bbe082a.md',
|
||||
created_at: '2026-01-15T08:05:30Z', // 1h before now
|
||||
prompt: 'You are NanoClaw, wired into the company brain, your job this run is to read it',
|
||||
},
|
||||
];
|
||||
|
||||
it('renders an aligned table with run history', () => {
|
||||
const lines = formatTasksTable(rows, now).split('\n');
|
||||
expect(lines[0]).toMatch(/SERIES\s+SCHEDULE\s+RUNS\s+FAILED\s+LAST RUN\s+NEXT RUN\s+STATUS\s+AGE\s+PROMPT/);
|
||||
expect(lines[1]).toContain('1h'); // AGE column — created 1h ago
|
||||
expect(lines[1]).toContain('task-5bbe082a-6298-4699'); // FULL series id — copy-pasteable into `tasks get --id`
|
||||
expect(lines[1]).toContain('* * * * *');
|
||||
expect(lines[1]).toContain('1m ago'); // 09:04:30 vs 09:05:30
|
||||
expect(lines[1]).toContain('in 30s'); // 09:06:00 vs 09:05:30
|
||||
expect(lines[1]).toContain('…'); // prompt truncated
|
||||
});
|
||||
|
||||
it('handles a never-fired series and an empty list', () => {
|
||||
expect(formatTasksTable([], now)).toBe('No tasks.');
|
||||
const oneShot = formatTasksTable(
|
||||
[
|
||||
{
|
||||
series_id: 'task-x',
|
||||
schedule: 'once',
|
||||
runs: 0,
|
||||
last_run: null,
|
||||
next_run: '2026-01-15T09:00:00Z',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
now,
|
||||
).split('\n')[1];
|
||||
expect(oneShot).toContain('once');
|
||||
expect(oneShot).toMatch(/\bdue\b/); // next_run in the past → due
|
||||
expect(oneShot).toContain('-'); // last_run '-' (never fired)
|
||||
});
|
||||
});
|
||||
|
||||
describe('deep verb help (ncl tasks help create)', () => {
|
||||
it('resolves through the dispatcher fallback and renders the full contract + examples', async () => {
|
||||
// Side-effect import mirrors the CLI server boot: registers <plural>-help.
|
||||
await import('../commands/index.js');
|
||||
const resp = await dispatch({ id: 'h1', command: 'tasks-help-create', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const text = resp.data as string;
|
||||
expect(text).toContain('ncl tasks create');
|
||||
expect(text).toContain('wakeAgent'); // full multi-line script contract present
|
||||
expect(text).toContain('Examples:'); // examples block rendered
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an unknown verb with a pointer back to resource help', async () => {
|
||||
await import('../commands/index.js');
|
||||
const resp = await dispatch({ id: 'h2', command: 'tasks-help-frobnicate', args: {} }, { caller: 'host' });
|
||||
expect(resp.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,784 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import fs from 'fs';
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
|
||||
import { GROUPS_DIR, TIMEZONE } from '../../config.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import {
|
||||
findTaskSessions,
|
||||
getActiveSessions,
|
||||
getSession,
|
||||
isTaskThread,
|
||||
TASKS_SYSTEM_THREAD_ID,
|
||||
} from '../../db/sessions.js';
|
||||
import {
|
||||
cancelAllTasks,
|
||||
cancelTask,
|
||||
deleteTask,
|
||||
insertTaskRow,
|
||||
pauseTask,
|
||||
resumeTask,
|
||||
updateTask,
|
||||
type TaskUpdate,
|
||||
} from '../../modules/scheduling/db.js';
|
||||
import { inboundDbPath, resolveTaskSession, withInboundDb } from '../../session-manager.js';
|
||||
import { formatLocalStamp, parseZonedToUtc } from '../../timezone.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
import { formatTasksTable } from '../format-tasks.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
|
||||
type TaskStatus = 'pending' | 'paused';
|
||||
|
||||
interface TaskRow {
|
||||
row_id: string;
|
||||
series_id: string | null;
|
||||
status: string;
|
||||
process_after: string | null;
|
||||
recurrence: string | null;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
tries: number;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
interface ScopedSession {
|
||||
id: string;
|
||||
agent_group_id: string;
|
||||
}
|
||||
|
||||
function str(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function bool(value: unknown): boolean {
|
||||
return value === true || value === 'true' || value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Short, readable, filesystem/thread-safe task id. With a name → `<slug>-<4hex>`
|
||||
* (e.g. "Morning joke" → `morning-joke-a25c`); without → `t-<6hex>`. Always
|
||||
* matches /^[a-z0-9-]+$/ so it is safe as a thread suffix (`system:tasks:<id>`),
|
||||
* a filename (`tasks/<id>.md`), and a copy-pasteable --id.
|
||||
*/
|
||||
function makeTaskId(name: unknown): string {
|
||||
const hex = (n: number): string => randomUUID().replace(/-/g, '').slice(0, n);
|
||||
const slug =
|
||||
typeof name === 'string'
|
||||
? name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 24)
|
||||
.replace(/-+$/g, '')
|
||||
: '';
|
||||
return slug ? `${slug}-${hex(4)}` : `t-${hex(6)}`;
|
||||
}
|
||||
|
||||
function parseProcessAfter(value: unknown): string {
|
||||
const raw = str(value);
|
||||
if (!raw) throw new Error('--process-after is required');
|
||||
const date = parseZonedToUtc(raw, TIMEZONE);
|
||||
if (Number.isNaN(date.getTime())) throw new Error(`invalid --process-after: ${raw}`);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run timestamp for a new task. When a recurrence is given but no
|
||||
* --process-after, derive the first fire from the cron grid (in TIMEZONE) so the
|
||||
* common recurring case is a single flag — `--recurrence "0 9 * * 1-5"` — with no
|
||||
* redundant, easily-stale hand-picked instant. --process-after is still required
|
||||
* for one-shots (no recurrence to derive from) and still wins when supplied.
|
||||
*/
|
||||
function firstRunIso(value: unknown, recurrence: string | null): string {
|
||||
if (str(value) === undefined && recurrence) {
|
||||
const next = CronExpressionParser.parse(recurrence, { tz: TIMEZONE }).next().toISOString();
|
||||
if (!next) throw new Error(`--recurrence has no upcoming run: ${recurrence}`);
|
||||
return next;
|
||||
}
|
||||
return parseProcessAfter(value);
|
||||
}
|
||||
|
||||
function normalizeNullableString(value: unknown): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'string') return String(value);
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '' || trimmed === 'null' || trimmed === 'none') return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateRecurrence(value: string | null | undefined): void {
|
||||
if (!value) return;
|
||||
try {
|
||||
CronExpressionParser.parse(value, { tz: TIMEZONE });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`invalid --recurrence: ${msg}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frequency guard: refuse recurrences more frequent than 4 fires/day unless
|
||||
* the agent explicitly overrides. Frequent tasks burn the user's quota (or
|
||||
* get their account banned) — the sanctioned pattern is a slower cron plus a
|
||||
* pre-task gate script that checks an external condition and only wakes the
|
||||
* agent when something changed (`ncl tasks create --help`). Counted over the
|
||||
* next 24h from now in the instance timezone, so uneven crons are judged by
|
||||
* what they would actually do.
|
||||
*/
|
||||
const MAX_DAILY_FIRES = 4;
|
||||
|
||||
const RECURRENCE_LIMIT_WARNING =
|
||||
'Warning: this task has not been scheduled. Frequent running tasks consume the ' +
|
||||
"user's subscription quota or unnecessarily use tokens and can cause the user's " +
|
||||
'account to be banned. Instead, use a pre-task run script that you write that can ' +
|
||||
'check some kind of external condition, usually via one or more API calls. The ' +
|
||||
'script returns a decision programmatically whether the task needs to be run now ' +
|
||||
'or not. For example, an API call to GitHub to check if there are open PRs, and ' +
|
||||
'only run when there are new open PRs.\n' +
|
||||
'Run `ncl tasks create --help` to get full directions on how to write a script and test it.\n\n' +
|
||||
'Note: if and only if you explicitly need to schedule a task more frequently and ' +
|
||||
"you've verified with the user that they understand and that this is what they " +
|
||||
'want and based on your judgment you agree that this is the right thing to do in ' +
|
||||
'this situation, you can override this with --dangerously-override-recurrence-limit';
|
||||
|
||||
function enforceRecurrenceLimit(recurrence: string | null, override: boolean, hasScript: boolean): void {
|
||||
// A gate script IS the sanctioned mitigation the warning steers toward — a
|
||||
// script-gated fire that finds nothing never wakes the agent, so scripted
|
||||
// tasks may run at any cadence without the override.
|
||||
if (!recurrence || override || hasScript) return;
|
||||
const horizon = Date.now() + 24 * 60 * 60 * 1000;
|
||||
const interval = CronExpressionParser.parse(recurrence, { tz: TIMEZONE });
|
||||
let fires = 0;
|
||||
while (fires <= MAX_DAILY_FIRES) {
|
||||
const next = interval.next();
|
||||
if (next.getTime() > horizon) break;
|
||||
fires++;
|
||||
}
|
||||
if (fires > MAX_DAILY_FIRES) throw new Error(RECURRENCE_LIMIT_WARNING);
|
||||
}
|
||||
|
||||
function statusFilter(args: Record<string, unknown>): TaskStatus | undefined {
|
||||
const status = str(args.status);
|
||||
if (!status) return undefined;
|
||||
if (status !== 'pending' && status !== 'paused') {
|
||||
throw new Error('--status must be pending or paused');
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function groupArg(args: Record<string, unknown>, ctx: CallerContext): string | undefined {
|
||||
if (ctx.caller === 'agent') return ctx.agentGroupId;
|
||||
return str(args.group) ?? str(args.agent_group_id);
|
||||
}
|
||||
|
||||
function ownSession(sessionId: string, ctx: CallerContext): ScopedSession {
|
||||
const session = getSession(sessionId);
|
||||
if (!session) throw new Error(`session not found: ${sessionId}`);
|
||||
if (ctx.caller === 'agent' && session.agent_group_id !== ctx.agentGroupId) {
|
||||
throw new Error(`session not found: ${sessionId}`);
|
||||
}
|
||||
return { id: session.id, agent_group_id: session.agent_group_id };
|
||||
}
|
||||
|
||||
function selectedSessions(args: Record<string, unknown>, ctx: CallerContext): ScopedSession[] {
|
||||
const sessionId = str(args.session);
|
||||
if (sessionId) return [ownSession(sessionId, ctx)];
|
||||
|
||||
const group = groupArg(args, ctx);
|
||||
if (group) {
|
||||
// One session per live task series — the loops below already fan out across them.
|
||||
return findTaskSessions(group).map((s) => ({ id: s.id, agent_group_id: s.agent_group_id }));
|
||||
}
|
||||
|
||||
if (ctx.caller === 'agent') return [];
|
||||
return getActiveSessions().map((s) => ({ id: s.id, agent_group_id: s.agent_group_id }));
|
||||
}
|
||||
|
||||
function withInbound<T>(session: ScopedSession, fn: (db: Database.Database) => T): T | undefined {
|
||||
if (!fs.existsSync(inboundDbPath(session.agent_group_id, session.id))) return undefined;
|
||||
return withInboundDb(session.agent_group_id, session.id, fn);
|
||||
}
|
||||
|
||||
function parseContent(raw: string): { prompt: string; script: string | null; originSessionId: string | null } {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
prompt: typeof parsed.prompt === 'string' ? parsed.prompt : '',
|
||||
script: typeof parsed.script === 'string' ? parsed.script : null,
|
||||
originSessionId: typeof parsed.originSessionId === 'string' ? parsed.originSessionId : null,
|
||||
};
|
||||
} catch {
|
||||
// LEGACY-COMPAT(v1-tasks): plain-string content from rows that predate the
|
||||
// JSON envelope. Removable once no pre-v2 session DBs remain in the wild.
|
||||
return { prompt: raw, script: null, originSessionId: null };
|
||||
}
|
||||
}
|
||||
|
||||
function toOutput(session: ScopedSession, row: TaskRow) {
|
||||
const content = parseContent(row.content);
|
||||
return {
|
||||
agent_group_id: session.agent_group_id,
|
||||
session_id: session.id,
|
||||
series_id: row.series_id ?? row.row_id,
|
||||
row_id: row.row_id,
|
||||
status: row.status,
|
||||
process_after: row.process_after,
|
||||
recurrence: row.recurrence,
|
||||
prompt: content.prompt.length > 120 ? content.prompt.slice(0, 117) + '...' : content.prompt,
|
||||
has_script: content.script ? 1 : 0,
|
||||
origin_session_id: content.originSessionId, // which session created the task (null for CLI-created)
|
||||
created_at: row.timestamp,
|
||||
tries: row.tries,
|
||||
};
|
||||
}
|
||||
|
||||
function selectLiveTasks(db: Database.Database, status?: TaskStatus): TaskRow[] {
|
||||
const statusSql = status ? 'status = ?' : "status IN ('pending', 'paused')";
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id AS row_id, series_id, status, process_after, recurrence, content, timestamp, tries, MAX(seq) AS seq
|
||||
FROM messages_in
|
||||
WHERE kind = 'task'
|
||||
AND ${statusSql}
|
||||
GROUP BY series_id
|
||||
ORDER BY datetime(process_after) ASC, seq ASC`,
|
||||
)
|
||||
.all(...(status ? [status] : [])) as TaskRow[];
|
||||
}
|
||||
|
||||
function selectTask(db: Database.Database, id: string): TaskRow | undefined {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id AS row_id, series_id, status, process_after, recurrence, content, timestamp, tries, seq
|
||||
FROM messages_in
|
||||
WHERE kind = 'task'
|
||||
AND (id = ? OR series_id = ?)
|
||||
ORDER BY CASE WHEN status IN ('pending', 'paused') THEN 0 ELSE 1 END, seq DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(id, id) as TaskRow | undefined;
|
||||
}
|
||||
|
||||
function taskId(args: Record<string, unknown>): string {
|
||||
const id = str(args.id);
|
||||
if (!id) throw new Error('task series id is required');
|
||||
return id;
|
||||
}
|
||||
|
||||
function createTask(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const group = groupArg(args, ctx);
|
||||
if (!group) throw new Error('--group is required');
|
||||
const prompt = str(args.prompt);
|
||||
if (!prompt) throw new Error('--prompt is required');
|
||||
const recurrence = normalizeNullableString(args.recurrence) ?? null;
|
||||
validateRecurrence(recurrence);
|
||||
const script = normalizeNullableString(args.script) ?? null;
|
||||
enforceRecurrenceLimit(recurrence, bool(args.dangerously_override_recurrence_limit), script != null);
|
||||
const processAfter = firstRunIso(args.process_after, recurrence);
|
||||
const id = makeTaskId(args.name);
|
||||
const originSessionId = ctx.caller === 'agent' ? ctx.sessionId : null;
|
||||
// Each series runs in its own isolated session; point the fire at its own log.
|
||||
const { session } = resolveTaskSession(group, id);
|
||||
const promptWithLog =
|
||||
`${prompt}\n\n` +
|
||||
`[A task serves the user two separate ways — do whichever the task above asks for, and ALWAYS the run log:\n` +
|
||||
`• MESSAGE (only if asked): if the task says to report/notify the user, send your result with an EXPLICIT destination — <message to="name">…</message> or send_message({ to: "name", … }). This run has no chat attached: an unaddressed reply is DISCARDED, so the explicit send is the ONLY thing the user receives.\n` +
|
||||
`• RUN LOG (ALWAYS — even if you sent no message and did nothing else this run): after any sends, end the run with:\n` +
|
||||
` ncl tasks append-log --msg "<what you did, and why it mattered>"\n` +
|
||||
` Write it like a work-log entry a human keeps — concrete: what you did and WHY (a no-op run still gets a line saying why nothing was needed). If you wrote or modified files this run, name them in --msg. Not a greeting, not a copy of the message you sent. The host stamps the local time (do NOT add one), do NOT edit tasks/${id}.md by hand, and this NEVER goes to the user.\n` +
|
||||
`Need context from past runs? Read tasks/${id}.md first.]`;
|
||||
|
||||
const created = withInbound(session, (db) => {
|
||||
insertTaskRow(db, {
|
||||
id,
|
||||
seriesId: id,
|
||||
processAfter,
|
||||
recurrence,
|
||||
content: JSON.stringify({ prompt: promptWithLog, script, originSessionId }),
|
||||
});
|
||||
return selectTask(db, id);
|
||||
});
|
||||
if (!created) throw new Error('task system session inbound.db not found');
|
||||
return toOutput(session, created);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one host-timestamped line to a task's run log
|
||||
* (`<GROUPS_DIR>/<folder>/tasks/<series>.md`). This is NOT a delivery — it writes
|
||||
* nothing to messages_out; it just records what happened so the agent (and human)
|
||||
* can see when and why each fire ran. Inside a task fire the series is derived from
|
||||
* the caller's own task session, so the agent supplies only --msg.
|
||||
*/
|
||||
function appendTaskLog(
|
||||
args: Record<string, unknown>,
|
||||
ctx: CallerContext,
|
||||
): { series: string; timestamp: string; path: string; ok: true } {
|
||||
const msg = str(args.msg);
|
||||
if (!msg) throw new Error('--msg is required');
|
||||
|
||||
let series = str(args.id);
|
||||
let group = groupArg(args, ctx);
|
||||
if (!series && ctx.caller === 'agent' && ctx.sessionId) {
|
||||
const sess = getSession(ctx.sessionId);
|
||||
if (sess && sess.thread_id && isTaskThread(sess.thread_id)) {
|
||||
series = sess.thread_id.slice(`${TASKS_SYSTEM_THREAD_ID}:`.length);
|
||||
group ??= sess.agent_group_id;
|
||||
}
|
||||
}
|
||||
if (!series) throw new Error('--id is required (no task session to derive it from)');
|
||||
// Charset guard is the security boundary here: blocks path traversal and keeps
|
||||
// the id safe as a filename / thread suffix. Group scope is already enforced by
|
||||
// groupArg (a cli_scope=group caller can only ever resolve its own folder), so a
|
||||
// foreign id at worst writes a stray log under the caller's OWN folder — no leak.
|
||||
if (!/^[a-z0-9-]+$/.test(series)) throw new Error(`invalid task id: ${series}`);
|
||||
if (!group) throw new Error('could not resolve the agent group');
|
||||
|
||||
const ag = getAgentGroup(group);
|
||||
if (!ag) throw new Error(`agent group not found: ${group}`);
|
||||
|
||||
const timestamp = formatLocalStamp(new Date(), TIMEZONE);
|
||||
const dir = `${GROUPS_DIR}/${ag.folder}/tasks`;
|
||||
const file = `${dir}/${series}.md`;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.appendFileSync(file, `${timestamp} — ${msg}\n`);
|
||||
return { series, timestamp, path: file, ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run history for one task series, aggregated over its occurrence rows: number
|
||||
* of successful fires, the last fire time, and failed fires (a row reaches
|
||||
* `failed` after MAX_TRIES on a stuck claim). Cancelled occurrences are
|
||||
* `cancelled`, not `completed`, so they never inflate the run count.
|
||||
*/
|
||||
function seriesStats(
|
||||
db: Database.Database,
|
||||
seriesKey: string,
|
||||
): { runs: number; last_run: string | null; failed_runs: number } {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'completed') AS runs,
|
||||
MAX(process_after) FILTER (WHERE status = 'completed') AS last_run,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') AS failed_runs
|
||||
FROM messages_in
|
||||
WHERE kind = 'task' AND (id = ? OR series_id = ?)`,
|
||||
)
|
||||
.get(seriesKey, seriesKey) as { runs: number; last_run: string | null; failed_runs: number };
|
||||
}
|
||||
|
||||
/** Last ~10 lines of a series' run log (`tasks/<series>.md`), newest last. */
|
||||
function tailRunLog(agentGroupId: string, seriesKey: string, lines = 10): string[] {
|
||||
const ag = getAgentGroup(agentGroupId);
|
||||
if (!ag) return [];
|
||||
const file = `${GROUPS_DIR}/${ag.folder}/tasks/${seriesKey}.md`;
|
||||
if (!fs.existsSync(file)) return [];
|
||||
return fs.readFileSync(file, 'utf8').trimEnd().split('\n').filter(Boolean).slice(-lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* A task series is CronJob-like: the live (pending/paused) row is the next run,
|
||||
* and the `completed` rows are its run history. Enrich each listed series with
|
||||
* that history — run count, failures, last fire, next fire, schedule, and a
|
||||
* pointer to the agent's own run log — so `tasks list` reads as a compact
|
||||
* run-history table.
|
||||
*/
|
||||
function enrichListRow(db: Database.Database, base: ReturnType<typeof toOutput>) {
|
||||
const seriesKey = base.series_id;
|
||||
const stats = seriesStats(db, seriesKey);
|
||||
return {
|
||||
...base,
|
||||
schedule: base.recurrence ?? 'once',
|
||||
runs: stats.runs,
|
||||
failed_runs: stats.failed_runs,
|
||||
last_run: stats.last_run,
|
||||
next_run: base.process_after,
|
||||
log: `tasks/${seriesKey}.md`,
|
||||
};
|
||||
}
|
||||
|
||||
function listTasks(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const status = statusFilter(args);
|
||||
const rows = [];
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
const sessionRows = withInbound(session, (db) =>
|
||||
selectLiveTasks(db, status).map((row) => enrichListRow(db, toOutput(session, row))),
|
||||
);
|
||||
if (sessionRows) rows.push(...sessionRows);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getTask(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const id = taskId(args);
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
const found = withInbound(session, (db) => {
|
||||
const row = selectTask(db, id);
|
||||
if (!row) return undefined;
|
||||
const seriesKey = row.series_id ?? row.row_id;
|
||||
const stats = seriesStats(db, seriesKey);
|
||||
const content = parseContent(row.content);
|
||||
return {
|
||||
...toOutput(session, row),
|
||||
prompt: content.prompt,
|
||||
script: content.script,
|
||||
origin_session_id: content.originSessionId,
|
||||
completed_runs: stats.runs,
|
||||
failed_runs: stats.failed_runs,
|
||||
recent_log: tailRunLog(session.agent_group_id, seriesKey),
|
||||
};
|
||||
});
|
||||
if (found) return found;
|
||||
}
|
||||
throw new Error(`task not found: ${id}`);
|
||||
}
|
||||
|
||||
function mutateTask(
|
||||
args: Record<string, unknown>,
|
||||
ctx: CallerContext,
|
||||
fn: (db: Database.Database, id: string) => number,
|
||||
) {
|
||||
const id = taskId(args);
|
||||
let touched = 0;
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
touched += withInbound(session, (db) => fn(db, id)) ?? 0;
|
||||
}
|
||||
if (touched === 0) throw new Error(`no live task matched: ${id}`);
|
||||
return { series_id: id, touched };
|
||||
}
|
||||
|
||||
function updateTaskCommand(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const id = taskId(args);
|
||||
const update: TaskUpdate = {};
|
||||
if (typeof args.prompt === 'string') update.prompt = args.prompt;
|
||||
if (args.process_after !== undefined) update.processAfter = parseProcessAfter(args.process_after);
|
||||
const recurrence = normalizeNullableString(args.recurrence);
|
||||
const script = normalizeNullableString(args.script);
|
||||
if (recurrence !== undefined) {
|
||||
validateRecurrence(recurrence);
|
||||
// Effective script AFTER this update: the new value when provided
|
||||
// (including an explicit clear), else whatever the task already has.
|
||||
let scriptAfter: string | null = script !== undefined ? script : null;
|
||||
if (script === undefined) {
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
const row = withInbound(session, (db) => selectTask(db, id));
|
||||
if (row) {
|
||||
scriptAfter = parseContent(row.content).script;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
enforceRecurrenceLimit(recurrence, bool(args.dangerously_override_recurrence_limit), scriptAfter != null);
|
||||
update.recurrence = recurrence;
|
||||
}
|
||||
if (script !== undefined) update.script = script;
|
||||
const fields = Object.keys(update);
|
||||
if (fields.length === 0) throw new Error('nothing to update');
|
||||
|
||||
let touched = 0;
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
touched += withInbound(session, (db) => updateTask(db, id, update)) ?? 0;
|
||||
}
|
||||
if (touched === 0) throw new Error(`no live task matched: ${id}`);
|
||||
return { series_id: id, touched, fields };
|
||||
}
|
||||
|
||||
function cancelTaskCommand(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
if (!bool(args.all)) {
|
||||
return mutateTask(args, ctx, cancelTask);
|
||||
}
|
||||
|
||||
let touched = 0;
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
touched += withInbound(session, cancelAllTasks) ?? 0;
|
||||
}
|
||||
return { cancelled: touched };
|
||||
}
|
||||
|
||||
/**
|
||||
* `ncl tasks run <id>` — fire a task on demand without disturbing its schedule.
|
||||
* Inserts a fresh pending occurrence (same series, content, no recurrence) due
|
||||
* now, which the next sweep delivers through the normal fire path. Unlike
|
||||
* `update --process-after now`, it neither consumes a one-shot nor force-advances
|
||||
* a recurring series' armed occurrence, so it is safe for testing a task.
|
||||
*/
|
||||
function runTaskCommand(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const id = taskId(args);
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
const fired = withInbound(session, (db) => {
|
||||
const row = selectTask(db, id);
|
||||
if (!row) return undefined;
|
||||
const seriesKey = row.series_id ?? row.row_id;
|
||||
const rowId = makeTaskId(`${seriesKey}-run`);
|
||||
// recurrence=NULL is load-bearing: a run-now row must not be re-armed by
|
||||
// handleRecurrence into a phantom series.
|
||||
insertTaskRow(db, {
|
||||
id: rowId,
|
||||
seriesId: seriesKey,
|
||||
processAfter: new Date().toISOString(),
|
||||
recurrence: null,
|
||||
content: row.content,
|
||||
});
|
||||
return { series_id: seriesKey, row_id: rowId, status: 'pending' };
|
||||
});
|
||||
if (fired) return fired;
|
||||
}
|
||||
throw new Error(`task not found: ${id}`);
|
||||
}
|
||||
|
||||
registerResource({
|
||||
name: 'task',
|
||||
plural: 'tasks',
|
||||
table: 'messages_in',
|
||||
description:
|
||||
'Scheduled task — prompt plus run time. Tasks run from the agent group system session and the agent chooses delivery destination at fire time.',
|
||||
idColumn: 'series_id',
|
||||
scopeField: 'agent_group_id',
|
||||
columns: [
|
||||
{ name: 'series_id', type: 'string', description: 'Stable task handle.', generated: true },
|
||||
{ name: 'agent_group_id', type: 'string', description: 'Agent group that owns the task.' },
|
||||
{ name: 'session_id', type: 'string', description: 'System session that runs the task.' },
|
||||
{ name: 'status', type: 'string', description: 'Live state.', enum: ['pending', 'paused'] },
|
||||
{
|
||||
name: 'process_after',
|
||||
type: 'string',
|
||||
// Not flagged required: with --recurrence the first run is derived from the
|
||||
// cron grid (firstRunIso). Required only for one-shots, enforced in the
|
||||
// create handler — so the generic col.required validator must stay off here.
|
||||
description:
|
||||
'Next run time (ISO 8601 or naive local). Required for one-shots; with --recurrence the first run is derived from the cron grid.',
|
||||
updatable: true,
|
||||
},
|
||||
{ name: 'recurrence', type: 'string', description: 'Optional cron expression.', updatable: true },
|
||||
{ name: 'prompt', type: 'string', description: 'Task prompt.', required: true, updatable: true },
|
||||
{ name: 'script', type: 'string', description: 'Optional pre-task bash script.', updatable: true },
|
||||
],
|
||||
operations: {},
|
||||
customOperations: {
|
||||
list: {
|
||||
access: 'open',
|
||||
description: 'List live tasks with per-series run history (schedule, runs, failures, next fire).',
|
||||
args: [
|
||||
{ name: 'status', type: 'string', description: 'Filter by live state.', enum: ['pending', 'paused'] },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
{
|
||||
name: 'all',
|
||||
type: 'boolean',
|
||||
description: 'List across all groups (host default when no --group; accepted for explicitness).',
|
||||
},
|
||||
],
|
||||
handler: async (args, ctx) => listTasks(args, ctx),
|
||||
// Server-rendered run-history table (frame `human` field) — the container
|
||||
// agent gets the same legible view as the host CLI without a Bun-side
|
||||
// formatter copy.
|
||||
formatHuman: (rows) => formatTasksTable(rows as Parameters<typeof formatTasksTable>[0]),
|
||||
},
|
||||
get: {
|
||||
access: 'open',
|
||||
description: 'Get a task by series id.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => getTask(args, ctx),
|
||||
},
|
||||
create: {
|
||||
access: 'open',
|
||||
description:
|
||||
`Create a scheduled task (recurring or one-shot) in the agent group system session.\n\n` +
|
||||
`Requires --prompt plus EITHER --recurrence (recurring; first run derived from the cron grid) OR --process-after (one-shot, ISO 8601 or naive local). Always pass --name for a readable id.\n\n` +
|
||||
`--script contract (pre-task gate, runs BEFORE the agent wakes):\n` +
|
||||
` bash, 30s timeout, 1MB output cap. Its LAST stdout line must be JSON:\n` +
|
||||
` {"wakeAgent": <bool>, "data": {...}}\n` +
|
||||
` wakeAgent=false marks the run handled without waking the agent (zero tokens);\n` +
|
||||
` wakeAgent=true wakes the agent with data attached to the prompt.\n` +
|
||||
` DO: print the JSON as the very last line, exit 0, keep data small (a summary, not a dump).\n` +
|
||||
` DON'T: print anything after the JSON, prompt for input, or rely on state from previous runs.\n` +
|
||||
` Always test with bash -c '<script>' before scheduling.\n` +
|
||||
` Persist state between fires under the group workspace (e.g. a last-seen id file).\n` +
|
||||
` Use good judgement on whether to share with the user the script (only if they are technical), a description of the script condition, or whether there's no need.\n\n` +
|
||||
`Frequency limit: recurrences more frequent than ${MAX_DAILY_FIRES} fires/day are refused unless the task\n` +
|
||||
`carries a --script gate (the script decides whether each fire needs you — a gated fire that\n` +
|
||||
`finds nothing costs zero tokens) or you pass --dangerously-override-recurrence-limit after\n` +
|
||||
`the user explicitly confirmed they want an ungated frequent task.\n\n` +
|
||||
`Failure backoff: a script that ERRORS repeatedly backs the series off (2,4,8,…60 min between fires; each errored fire counts as a failed run); after 8 consecutive failures the series is auto-paused with a note in its run log — fix the script, then \`ncl tasks resume <id>\`. A deliberate wakeAgent=false is a normal run and never backs off. \`ncl tasks get <id>\` shows failed_runs and the run log.`,
|
||||
args: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
description: 'Short descriptive name → readable task id (<slug>-<hex>). Without it, ids are t-<hex>.',
|
||||
},
|
||||
{ name: 'prompt', type: 'string', description: 'Task prompt the agent wakes to.', required: true },
|
||||
{
|
||||
name: 'recurrence',
|
||||
type: 'string',
|
||||
description:
|
||||
'Cron expression (instance TZ). First run derives from the cron grid when --process-after is omitted.',
|
||||
},
|
||||
{
|
||||
name: 'dangerously_override_recurrence_limit',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Schedule more than 4 fires/day anyway. Only after the user explicitly confirmed they understand the quota/token cost and you agree it is right.',
|
||||
},
|
||||
{
|
||||
name: 'process_after',
|
||||
type: 'string',
|
||||
description: 'First/next run time (ISO 8601 or naive local). Required for one-shots.',
|
||||
},
|
||||
{
|
||||
name: 'script',
|
||||
type: 'string',
|
||||
description: 'Pre-task gate script (bash) — see the --script contract above.',
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
`# Recurring — --recurrence alone is enough; the first run comes off the cron grid:\nncl tasks create --name "sales briefing" --prompt "Send the weekday sales briefing" --recurrence "0 9 * * 1-5"`,
|
||||
`# One-shot — --process-after required (UTC, offset, or naive-local in the instance TZ):\nncl tasks create --name "ping" --prompt "Remind me to call Dana" --process-after "tomorrow 18:00"`,
|
||||
`# Monitor — script gates the run; the agent wakes only when something matters:\nncl tasks create --name "alert watch" --recurrence "*/15 * * * *" \\\n --prompt "Investigate the alerts in the script data and notify me if serious" \\\n --script 'c=$(curl -sf https://example.com/api/alerts | jq length) || exit 0\necho "{\\"wakeAgent\\": $([ "$c" -gt 0 ] && echo true || echo false), \\"data\\": {\\"alerts\\": $c}}"'`,
|
||||
],
|
||||
handler: async (args, ctx) => createTask(args, ctx),
|
||||
},
|
||||
'append-log': {
|
||||
access: 'open',
|
||||
description:
|
||||
'Append a one-line run summary to a task run log (tasks/<id>.md).\n\nThe host stamps the local timestamp; you supply --msg. This is a LOG ENTRY, not a message — it sends nothing to anyone. Inside a task fire --id is auto-derived from your session. If you wrote or modified files during the run, name them in --msg.',
|
||||
examples: [
|
||||
`# Inside a task fire (--id auto-derived) — the run's work-log line:\nncl tasks append-log --msg "posted the daily digest to slack; one feed returned 403, skipped"`,
|
||||
],
|
||||
args: [
|
||||
{
|
||||
name: 'msg',
|
||||
type: 'string',
|
||||
description:
|
||||
'Your work-log entry: what you did and why it mattered (like a human work log). The host prepends the local timestamp; this is logged, never sent to the user.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
description: 'Task series id. Auto-derived when called from inside a task fire; required otherwise.',
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
],
|
||||
handler: async (args, ctx) => appendTaskLog(args, ctx),
|
||||
},
|
||||
update: {
|
||||
access: 'open',
|
||||
description: 'Update a live task by series id.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{ name: 'prompt', type: 'string', description: 'Replace the task prompt.' },
|
||||
{ name: 'process_after', type: 'string', description: 'New next-run time (ISO 8601 or naive local).' },
|
||||
{ name: 'recurrence', type: 'string', description: 'New cron expression; "null"/"none" clears it (one-shot).' },
|
||||
{
|
||||
name: 'dangerously_override_recurrence_limit',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Schedule more than 4 fires/day anyway. Only after the user explicitly confirmed they understand the quota/token cost and you agree it is right.',
|
||||
},
|
||||
{ name: 'script', type: 'string', description: 'New pre-task script; "null"/"none" removes it.' },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => updateTaskCommand(args, ctx),
|
||||
},
|
||||
cancel: {
|
||||
access: 'open',
|
||||
description: 'Cancel a live task by series id, or use --all as a kill switch.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id (omit with --all).' },
|
||||
{ name: 'all', type: 'boolean', description: 'Cancel every live task in scope — kill switch.' },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => cancelTaskCommand(args, ctx),
|
||||
},
|
||||
run: {
|
||||
access: 'open',
|
||||
description:
|
||||
'Fire a task now without changing its schedule (queues an extra run due immediately). Safe for testing — unlike update --process-after now, it neither consumes a one-shot nor advances a recurring series.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => runTaskCommand(args, ctx),
|
||||
},
|
||||
pause: {
|
||||
access: 'open',
|
||||
description: 'Pause a pending task by series id.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => mutateTask(args, ctx, pauseTask),
|
||||
},
|
||||
resume: {
|
||||
access: 'open',
|
||||
description: 'Resume a paused task by series id.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => mutateTask(args, ctx, resumeTask),
|
||||
},
|
||||
delete: {
|
||||
access: 'open',
|
||||
description: 'Hard-delete a task series and its history.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => mutateTask(args, ctx, deleteTask),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Wiring creation/update against channel declarations: the resolveDefaults
|
||||
* hook fills omitted engage defaults from the adapter declaration ({name}
|
||||
* substituted), explicit flags always win, undeclared channels keep the
|
||||
* legacy static defaults (back-compat contract), and the create/update
|
||||
* validation rejects combinations that could never engage.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../log.js', () => ({
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
// wirings' postCommit projects destinations into live session DBs — no
|
||||
// sessions run in this test, but the module must not open on-disk DB files.
|
||||
vi.mock('../../modules/agent-to-agent/write-destinations.js', () => ({
|
||||
writeDestinations: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { ChannelDefaults } from '../../channels/adapter.js';
|
||||
import { registerChannelAdapter } from '../../channels/channel-registry.js';
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup } from '../../db/index.js';
|
||||
import { createMessagingGroupAgent, getMessagingGroupAgent } from '../../db/messaging-groups.js';
|
||||
import { lookup } from '../registry.js';
|
||||
// Side-effect import: registers wirings-create / wirings-update.
|
||||
import './wirings.js';
|
||||
|
||||
const hostCtx = { caller: 'host' as const };
|
||||
const now = () => new Date().toISOString();
|
||||
|
||||
// Registration-tier declarations only — no adapter is live, which is exactly
|
||||
// the environment `ncl` sees for offline instances and setup scripts.
|
||||
const declared: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: 'hey {name}!', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
registerChannelAdapter('declchan', { factory: () => null, defaults: declared });
|
||||
|
||||
const neverDeclared: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
|
||||
group: { engageMode: 'pattern', engagePattern: '{name}', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'never',
|
||||
};
|
||||
registerChannelAdapter('neverchan', { factory: () => null, defaults: neverDeclared });
|
||||
|
||||
function mg(id: string, channelType: string, isGroup: number) {
|
||||
createMessagingGroup({
|
||||
id,
|
||||
channel_type: channelType,
|
||||
platform_id: `pid-${id}`,
|
||||
name: null,
|
||||
is_group: isGroup,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function create(args: Record<string, unknown>) {
|
||||
return (await lookup('wirings-create')!.handler(args, hostCtx)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function update(args: Record<string, unknown>) {
|
||||
return (await lookup('wirings-update')!.handler(args, hostCtx)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
runMigrations(initTestDb());
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Helper Bot',
|
||||
folder: 'helper-bot',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
mg('mg-dm', 'declchan', 0);
|
||||
mg('mg-group', 'declchan', 1);
|
||||
mg('mg-never', 'neverchan', 1);
|
||||
mg('mg-stale', 'stalechan', 1); // no declaration anywhere
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
describe('wirings-create — declaration-derived defaults', () => {
|
||||
it('fills DM defaults from the declaration with {name} substituted', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1' });
|
||||
expect(row.engage_mode).toBe('pattern');
|
||||
expect(row.engage_pattern).toBe('hey Helper Bot!');
|
||||
});
|
||||
|
||||
it('fills group defaults from the declaration', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1' });
|
||||
expect(row.engage_mode).toBe('mention-sticky');
|
||||
const persisted = getMessagingGroupAgent(row.id as string);
|
||||
expect(persisted!.engage_pattern).toBeNull();
|
||||
});
|
||||
|
||||
it('explicit --engage-mode wins over the declaration', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', engage_mode: 'mention' });
|
||||
expect(row.engage_mode).toBe('mention');
|
||||
});
|
||||
|
||||
it('undeclared channels keep the legacy static default (back-compat)', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-stale', agent_group_id: 'ag-1' });
|
||||
expect(row.engage_mode).toBe('mention');
|
||||
expect(row.engage_pattern).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('wirings-create — validation', () => {
|
||||
it('rejects pattern mode without --engage-pattern', async () => {
|
||||
await expect(
|
||||
create({ messaging_group_id: 'mg-stale', agent_group_id: 'ag-1', engage_mode: 'pattern' }),
|
||||
).rejects.toThrow(/--engage-pattern/);
|
||||
});
|
||||
|
||||
it("rejects mention modes on a channel declaring mentions: 'never'", async () => {
|
||||
await expect(
|
||||
create({ messaging_group_id: 'mg-never', agent_group_id: 'ag-1', engage_mode: 'mention' }),
|
||||
).rejects.toThrow(/mentions: 'never'/);
|
||||
});
|
||||
|
||||
it('coerces explicit mention-sticky to mention when the declared context has threads=false', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1', engage_mode: 'mention-sticky' });
|
||||
expect(row.engage_mode).toBe('mention');
|
||||
});
|
||||
|
||||
it('coerces mention-sticky when --threads false overrides a threaded declaration', async () => {
|
||||
const row = await create({
|
||||
messaging_group_id: 'mg-group',
|
||||
agent_group_id: 'ag-1',
|
||||
engage_mode: 'mention-sticky',
|
||||
threads: 'false',
|
||||
});
|
||||
expect(row.engage_mode).toBe('mention');
|
||||
expect(row.threads).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps mention-sticky when the declared group context has threads=true', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', engage_mode: 'mention-sticky' });
|
||||
expect(row.engage_mode).toBe('mention-sticky');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wirings — threads and priority columns', () => {
|
||||
it('omitted --threads stores NULL (inherit declaration)', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1' });
|
||||
expect(getMessagingGroupAgent(row.id as string)!.threads).toBeNull();
|
||||
});
|
||||
|
||||
it('--threads true/false stores 1/0', async () => {
|
||||
const on = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', threads: 'true' });
|
||||
expect(getMessagingGroupAgent(on.id as string)!.threads).toBe(1);
|
||||
const off = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1', threads: 'false' });
|
||||
expect(getMessagingGroupAgent(off.id as string)!.threads).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a non-boolean --threads value', async () => {
|
||||
await expect(create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', threads: 'bogus' })).rejects.toThrow(
|
||||
/--threads must be true or false/,
|
||||
);
|
||||
});
|
||||
|
||||
it('--priority is settable on create and defaults to 0', async () => {
|
||||
const dflt = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1' });
|
||||
expect(dflt.priority).toBe(0);
|
||||
const high = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', priority: '5' });
|
||||
expect(high.priority).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wirings-update — same validation as create', () => {
|
||||
it('rejects switching to pattern mode when no engage_pattern exists', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1' }); // sticky, no pattern
|
||||
await expect(update({ id: row.id, engage_mode: 'pattern' })).rejects.toThrow(/--engage-pattern/);
|
||||
});
|
||||
|
||||
it("rejects switching to a mention mode on a mentions:'never' channel", async () => {
|
||||
const row = await create({
|
||||
messaging_group_id: 'mg-never',
|
||||
agent_group_id: 'ag-1',
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
});
|
||||
await expect(update({ id: row.id, engage_mode: 'mention' })).rejects.toThrow(/mentions: 'never'/);
|
||||
});
|
||||
|
||||
it('coerces an existing sticky wiring to mention when --threads is turned off', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1' }); // mention-sticky
|
||||
const updated = (await update({ id: row.id, threads: 'false' })) as { engage_mode: string; threads: number };
|
||||
expect(updated.threads).toBe(0);
|
||||
expect(updated.engage_mode).toBe('mention');
|
||||
});
|
||||
|
||||
it('updates threads and priority', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1' });
|
||||
const updated = (await update({ id: row.id, threads: 'true', priority: '3' })) as {
|
||||
threads: number;
|
||||
priority: number;
|
||||
};
|
||||
expect(updated.threads).toBe(1);
|
||||
expect(updated.priority).toBe(3);
|
||||
});
|
||||
|
||||
it('allows unrelated updates to a legacy pattern row with NULL engage_pattern', async () => {
|
||||
// Rows created on main before engage_pattern defaults existed: pattern
|
||||
// mode + NULL pattern, which the router evaluates as match-all.
|
||||
createMessagingGroupAgent({
|
||||
id: 'mga-legacy',
|
||||
messaging_group_id: 'mg-stale',
|
||||
agent_group_id: 'ag-1',
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: null,
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
const updated = (await update({ id: 'mga-legacy', priority: '5' })) as { priority: number };
|
||||
expect(updated.priority).toBe(5);
|
||||
// The pattern fields stay untouched — no silent backfill.
|
||||
expect(getMessagingGroupAgent('mga-legacy')!.engage_pattern).toBeNull();
|
||||
|
||||
// But actually changing the pattern fields to an invalid combination
|
||||
// still rejects.
|
||||
await expect(update({ id: 'mga-legacy', engage_pattern: '' })).rejects.toThrow(/--engage-pattern/);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,29 @@
|
||||
import {
|
||||
resolveWiringDefaults,
|
||||
validateEngageAgainstChannel,
|
||||
type EngageValues,
|
||||
} from '../../channels/channel-defaults.js';
|
||||
import { hasDeclaredChannelDefaults } from '../../channels/channel-registry.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { ensureAgentDestinationForWiring, getMessagingGroup } from '../../db/messaging-groups.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
import { projectDestinationsToSessions } from './destinations.js';
|
||||
|
||||
function requireMessagingGroup(id: unknown): MessagingGroup {
|
||||
const mg = getMessagingGroup(String(id));
|
||||
if (!mg) throw new Error(`messaging group not found: ${id}`);
|
||||
return mg;
|
||||
}
|
||||
|
||||
/** --threads accepts true/false (or 1/0); stored as INTEGER 1/0. Omitted =
|
||||
* column NULL = inherit the channel declaration. */
|
||||
function normalizeThreads(v: unknown): number {
|
||||
if (v === true || v === 'true' || v === '1' || v === 1) return 1;
|
||||
if (v === false || v === 'false' || v === '0' || v === 0) return 0;
|
||||
throw new Error(`--threads must be true or false, got "${v}"`);
|
||||
}
|
||||
|
||||
registerResource({
|
||||
name: 'wiring',
|
||||
@@ -25,7 +50,7 @@ registerResource({
|
||||
name: 'engage_mode',
|
||||
type: 'string',
|
||||
description:
|
||||
'When the agent engages. "mention" — only when @mentioned or in DMs. "mention-sticky" — once mentioned in a thread, the agent subscribes and responds to all subsequent messages in that thread without needing further mentions. "pattern" — matches every message against engage_pattern regex.',
|
||||
'When the agent engages. "mention" — only when @mentioned or in DMs. "mention-sticky" — once mentioned in a thread, the agent subscribes and responds to all subsequent messages in that thread without needing further mentions. "pattern" — matches every message against engage_pattern regex. Default: declared by the channel adapter for the target chat (DM vs group); "mention" when the channel has no declaration.',
|
||||
enum: ['pattern', 'mention', 'mention-sticky'],
|
||||
default: 'mention',
|
||||
updatable: true,
|
||||
@@ -64,7 +89,89 @@ registerResource({
|
||||
default: 'shared',
|
||||
updatable: true,
|
||||
},
|
||||
{
|
||||
name: 'threads',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Per-wiring thread override: honor platform thread ids for this wiring (per-thread sessions in groups; replies, typing, and cards land in-thread). NULL = inherit channel default. Can disable threads on a threaded platform, never enable them on a non-threaded one.',
|
||||
updatable: true,
|
||||
},
|
||||
{
|
||||
name: 'priority',
|
||||
type: 'number',
|
||||
description: 'Fanout order when multiple agents are wired to the same messaging group — higher priority first.',
|
||||
default: 0,
|
||||
updatable: true,
|
||||
},
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
||||
resolveDefaults: (values) => {
|
||||
const mg = requireMessagingGroup(values.messaging_group_id);
|
||||
if (values.threads !== undefined) values.threads = normalizeThreads(values.threads);
|
||||
|
||||
const channelKey = mg.instance ?? mg.channel_type;
|
||||
// Undeclared (stale) channels: leave engage_mode unset so the static
|
||||
// 'mention' default applies afterwards — a trunk update alone must not
|
||||
// change ncl's creation defaults for adapters without a declaration.
|
||||
if (values.engage_mode === undefined) {
|
||||
if (hasDeclaredChannelDefaults(channelKey, mg.channel_type)) {
|
||||
const ag = getAgentGroup(String(values.agent_group_id));
|
||||
if (!ag) throw new Error(`agent group not found: ${values.agent_group_id}`);
|
||||
const resolved = resolveWiringDefaults(channelKey, mg.is_group === 1, ag.name, mg.channel_type);
|
||||
values.engage_mode = resolved.engage_mode;
|
||||
if (values.engage_pattern === undefined && resolved.engage_pattern !== null) {
|
||||
values.engage_pattern = resolved.engage_pattern;
|
||||
}
|
||||
} else {
|
||||
log.warn(
|
||||
`wiring create: channel '${channelKey}' has no declared defaults (adapter not installed or stale) — using legacy static defaults`,
|
||||
);
|
||||
}
|
||||
}
|
||||
validateEngageAgainstChannel(values, mg);
|
||||
},
|
||||
preUpdate: (updates, current) => {
|
||||
const mg = requireMessagingGroup(current.messaging_group_id);
|
||||
if (updates.threads !== undefined) updates.threads = normalizeThreads(updates.threads);
|
||||
|
||||
const merged: EngageValues = { ...current, ...updates };
|
||||
// Legacy rows can be engage_mode='pattern' with a NULL pattern (the
|
||||
// router treats that as match-all). Don't reject unrelated updates to
|
||||
// them — only enforce the pairing when the pattern fields change.
|
||||
if (
|
||||
updates.engage_mode === undefined &&
|
||||
updates.engage_pattern === undefined &&
|
||||
merged.engage_mode === 'pattern' &&
|
||||
(merged.engage_pattern === undefined || merged.engage_pattern === null)
|
||||
) {
|
||||
merged.engage_pattern = '.';
|
||||
}
|
||||
validateEngageAgainstChannel(merged, mg);
|
||||
// Carry the sticky→mention coercion (if any) back into the update set.
|
||||
if (merged.engage_mode !== (updates.engage_mode ?? current.engage_mode)) {
|
||||
updates.engage_mode = merged.engage_mode;
|
||||
}
|
||||
},
|
||||
postCreate: (row) => {
|
||||
// Create the companion `agent_destinations` row so the agent has a
|
||||
// local name it can address this chat by. Without this, the agent
|
||||
// generates a response, but delivery's ACL drops the outbound message
|
||||
// (no destination matches the target) and the reply is silently lost.
|
||||
// `createMessagingGroupAgent` does this automatically; the generic
|
||||
// CRUD path doesn't, hence this hook. See issue #2389.
|
||||
ensureAgentDestinationForWiring(row as unknown as MessagingGroupAgent);
|
||||
},
|
||||
postCommit: async (row) => {
|
||||
// Live-refresh parity with `ncl destinations add`: `postCreate` above
|
||||
// only wrote the central `agent_destinations` row. Any container already
|
||||
// running for this agent keeps serving its stale session projection, so
|
||||
// it would drop replies to this chat as "unknown destination" until the
|
||||
// next spawn (the exact symptom operators hit running `ncl wirings
|
||||
// create` against a live instance — it needed a group restart). Project
|
||||
// the new destination into live sessions now so the fix takes effect
|
||||
// without a restart. Runs after commit because it writes to session
|
||||
// `inbound.db` files (outside the central-DB transaction) and is async.
|
||||
await projectDestinationsToSessions((row as unknown as MessagingGroupAgent).agent_group_id);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -19,7 +19,17 @@ const envConfig = readEnvFile([
|
||||
'ONECLI_GATEWAY_CONTAINER',
|
||||
]);
|
||||
|
||||
/**
|
||||
* @deprecated WhatsApp adapter copies now read the ASSISTANT_NAME .env key
|
||||
* directly. Re-export retained one release for stale adapter copies
|
||||
* (origin/channels whatsapp.ts:42 imports it); scheduled for deletion.
|
||||
*/
|
||||
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||
/**
|
||||
* @deprecated WhatsApp adapter copies now read the ASSISTANT_HAS_OWN_NUMBER
|
||||
* .env key directly. Re-export retained one release for stale adapter copies
|
||||
* (origin/channels whatsapp.ts:42 imports it); scheduled for deletion.
|
||||
*/
|
||||
export const ASSISTANT_HAS_OWN_NUMBER =
|
||||
(process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
|
||||
|
||||
|
||||
@@ -55,6 +55,22 @@ describe('migrations', () => {
|
||||
// Running again should not throw
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
it('adds messaging_group_agents.threads as a nullable, default-free override column (019)', () => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
const col = db
|
||||
.prepare(
|
||||
`SELECT type, "notnull", dflt_value FROM pragma_table_info('messaging_group_agents') WHERE name = 'threads'`,
|
||||
)
|
||||
.get() as { type: string; notnull: number; dflt_value: unknown } | undefined;
|
||||
expect(col).toBeDefined();
|
||||
// NULL must remain expressible (= inherit the adapter declaration) with
|
||||
// no default — a backfill would freeze today's behavior into rows.
|
||||
expect(col!.type).toBe('INTEGER');
|
||||
expect(col!.notnull).toBe(0);
|
||||
expect(col!.dflt_value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent Groups ──
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Upgrade-path test for migration 019 (holds-approver-rule): in-flight
|
||||
* pending_approvals rows created by the pre-contract code must come out with
|
||||
* the approver rule the old click-auth gave them, and the sender table must be
|
||||
* gone.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { closeDb, initTestDb, runMigrations } from './index.js';
|
||||
import { migrations } from './migrations/index.js';
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = initTestDb();
|
||||
// Everything up to — but not including — the holds-approver-rule migration.
|
||||
runMigrations(
|
||||
db,
|
||||
migrations.filter((m) => m.name !== 'holds-approver-rule'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
function hasTable(name: string): boolean {
|
||||
return db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) !== undefined;
|
||||
}
|
||||
|
||||
describe('migration 019 — holds-approver-rule', () => {
|
||||
it('backfills approver_rule and agent_group_id on in-flight rows and drops the sender table', () => {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare("INSERT INTO agent_groups (id, name, folder, created_at) VALUES ('ag-1', 'One', 'one', ?)").run(now);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, agent_group_id, messaging_group_id, thread_id, created_at) VALUES ('sess-1', 'ag-1', NULL, NULL, ?)",
|
||||
).run(now);
|
||||
|
||||
// Legacy a2a hold: named approver ⇒ was exclusive.
|
||||
db.prepare(
|
||||
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, approver_user_id, title, options_json)
|
||||
VALUES ('appr-a2a', 'sess-1', 'appr-a2a', 'a2a_message_gate', '{}', ?, 'tg:dana', '', '[]')`,
|
||||
).run(now);
|
||||
// Legacy cli hold: no approver, no agent_group_id — click-auth fell back
|
||||
// to the session's group; the backfill makes that anchoring explicit.
|
||||
db.prepare(
|
||||
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, title, options_json)
|
||||
VALUES ('appr-cli', 'sess-1', 'appr-cli', 'cli_command', '{}', ?, '', '[]')`,
|
||||
).run(now);
|
||||
// Legacy OneCLI hold: sessionless, agent_group_id already stamped.
|
||||
db.prepare(
|
||||
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, agent_group_id, title, options_json)
|
||||
VALUES ('oa-1', NULL, 'req-uuid', 'onecli_credential', '{}', ?, 'ag-1', '', '[]')`,
|
||||
).run(now);
|
||||
|
||||
expect(hasTable('pending_sender_approvals')).toBe(true);
|
||||
|
||||
runMigrations(db); // applies only holds-approver-rule
|
||||
|
||||
const rows = db
|
||||
.prepare('SELECT approval_id, approver_rule, approver_scope, agent_group_id FROM pending_approvals')
|
||||
.all() as Array<{ approval_id: string; approver_rule: string; approver_scope: string; agent_group_id: string }>;
|
||||
const byId = Object.fromEntries(rows.map((r) => [r.approval_id, r]));
|
||||
|
||||
expect(byId['appr-a2a']).toMatchObject({
|
||||
approver_rule: 'exclusive',
|
||||
approver_scope: 'group',
|
||||
agent_group_id: 'ag-1',
|
||||
});
|
||||
expect(byId['appr-cli']).toMatchObject({
|
||||
approver_rule: 'admins-of-scope',
|
||||
approver_scope: 'group',
|
||||
agent_group_id: 'ag-1',
|
||||
});
|
||||
expect(byId['oa-1']).toMatchObject({ approver_rule: 'admins-of-scope', agent_group_id: 'ag-1' });
|
||||
|
||||
expect(hasTable('pending_sender_approvals')).toBe(false);
|
||||
});
|
||||
});
|
||||
+31
-20
@@ -183,26 +183,37 @@ export function createMessagingGroupAgent(mga: MessagingGroupAgent): void {
|
||||
)
|
||||
.run(mga);
|
||||
|
||||
// Auto-create an agent_destinations row so delivery's ACL doesn't block
|
||||
// outbound messages that target this chat. Guarded: when the agent-to-agent
|
||||
// module isn't installed the table doesn't exist — skip silently. Without
|
||||
// the module, the ACL check in delivery is also skipped (same guard), so
|
||||
// channel sends still work.
|
||||
//
|
||||
// ⚠️ DESTINATION PROJECTION NOTE: this function only writes the central
|
||||
// `agent_destinations` row. It does NOT project into any running
|
||||
// agent's session inbound.db (see top-of-file invariant in
|
||||
// src/modules/agent-to-agent/db/agent-destinations.ts). In practice this
|
||||
// is fine because the only real callers are one-shot setup scripts
|
||||
// (setup/register.ts, scripts/init-first-agent.ts, /manage-channels
|
||||
// skill) that run in a separate process from the host. Any already-
|
||||
// running container for `mga.agent_group_id` will keep serving the
|
||||
// stale projection until its next wake (idle timeout or next inbound
|
||||
// message) at which point spawnContainer's writeDestinations call
|
||||
// refreshes from central. If you call this from code that runs INSIDE
|
||||
// the host process and need the refresh to happen immediately,
|
||||
// explicitly call the module's `writeDestinations(mga.agent_group_id,
|
||||
// <sessionId>)` afterwards.
|
||||
ensureAgentDestinationForWiring(mga);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the `agent_destinations` row that lets the agent address this chat
|
||||
* as a delivery target. Idempotent — no-op when the destination already
|
||||
* exists, the agent-to-agent module isn't installed, or the messaging group
|
||||
* has been deleted out from under the wiring.
|
||||
*
|
||||
* Split out from `createMessagingGroupAgent` so callers that already wrote
|
||||
* the `messaging_group_agents` row directly (e.g. the generic ncl CRUD path)
|
||||
* can still get the companion destination without re-inserting the wiring.
|
||||
*
|
||||
* ⚠️ DESTINATION PROJECTION NOTE: this function only writes the central
|
||||
* `agent_destinations` row. It does NOT project into any running agent's
|
||||
* session inbound.db (see top-of-file invariant in
|
||||
* src/modules/agent-to-agent/db/agent-destinations.ts). In practice this is
|
||||
* fine because the only real callers are one-shot setup paths
|
||||
* (setup/register.ts, scripts/init-first-agent.ts, /manage-channels skill,
|
||||
* ncl wirings create) that run in a separate process from the host. Any
|
||||
* already-running container for `mga.agent_group_id` will keep serving the
|
||||
* stale projection until its next wake (idle timeout or next inbound
|
||||
* message) at which point spawnContainer's writeDestinations call refreshes
|
||||
* from central. If you call this from code that runs INSIDE the host
|
||||
* process and need the refresh to happen immediately, explicitly call the
|
||||
* module's `writeDestinations(mga.agent_group_id, <sessionId>)` afterwards.
|
||||
*/
|
||||
export function ensureAgentDestinationForWiring(mga: MessagingGroupAgent): void {
|
||||
// Guarded: when the agent-to-agent module isn't installed the table
|
||||
// doesn't exist — skip silently. Without the module, the ACL check in
|
||||
// delivery is also skipped (same guard), so channel sends still work.
|
||||
if (!hasTable(getDb(), 'agent_destinations')) return;
|
||||
|
||||
const existing = getDestinationByTarget(mga.agent_group_id, 'channel', mga.messaging_group_id);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user