Compare commits

..

15 Commits

Author SHA1 Message Date
gavrielc e3bd22f96f style: prettier
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
2026-07-10 21:11:38 +03:00
gavrielc c2e5b14bf8 fix: ISO storage + local-time display for all timestamps
Storage: every JS-side datetime('now') write becomes new Date().toISOString()
(messages_out all writers, processing_ack all stamps, delivered, retry
backoff, roles/members/destinations, skill SQL via strftime ISO-Z). Dormant
deliver_after raw string compares wrapped in datetime().

Display: ncl human output renders ISO instants as local "YYYY-MM-DD HH:mm"
(host + container renderers; --json stays ISO; the stamp shape round-trips
through parseZonedToUtc). Task run logs, conversation archive filenames,
clidash, the dashboard pusher, and the uninstall backup stamp all render
local; instruction texts updated in lockstep. CLAUDE.md documents the
convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
2026-07-10 21:06:33 +03:00
github-actions[bot] 1ccef326dc chore: bump version to 2.1.43 2026-07-10 17:17:24 +00:00
gavrielc 39e7604526 Merge pull request #3005 from nanocoai/fix/task-timestamp-iso
fix: stamp task rows with ISO timestamps
2026-07-10 20:17:10 +03:00
gavrielc c0e591f2fb fix: stamp task rows with ISO timestamps
insertTaskRow used SQL datetime('now') — a naive-UTC stamp the container
formatter parses as local time, rendering <task time> hours off from the
ISO-Z chat timestamps beside it. Stamp ISO like every other messages_in
writer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
2026-07-10 19:54:35 +03:00
github-actions[bot] 14f528c895 docs: update token count to 224k tokens · 112% of context window 2026-07-10 06:42:50 +00:00
github-actions[bot] 366c5f06f1 chore: bump version to 2.1.42 2026-07-10 06:42:46 +00:00
glifocat 077ce6fd9d Merge pull request #2416 from glifocat/fix/ncl-create-handlers
fix(cli): provision companion rows on `ncl groups create` and `ncl wirings create`
2026-07-10 08:42:32 +02:00
github-actions[bot] 0c0f4c2592 docs: update token count to 223k tokens · 111% of context window 2026-07-09 08:24:27 +00:00
github-actions[bot] 92635f1934 chore: bump version to 2.1.41 2026-07-09 08:24:22 +00:00
gavrielc f7a43ef881 Merge pull request #2981 from nanocoai/tasks/02-ncl-tasks-core
Scheduled tasks: ncl tasks control plane, isolated sessions, script gate
2026-07-09 11:24:07 +03:00
glifocat 1985fd6a5e fix(cli): live-refresh destinations on ncl wirings create
Closes the restart-required behavior: wirings-create's postCreate wrote
the central agent_destinations row but never projected it into running
sessions' inbound.db, so an agent with a live container dropped every
reply to the newly-wired chat as "unknown destination" until a group
restart (observed on a live instance). `ncl destinations add` already
projects for exactly this reason; wirings create now has parity.

postCreate is sync and runs inside the central-DB transaction, so it's
the wrong place for an async, cross-DB (session inbound.db) side effect.

- crud.ts: add a postCommit hook that runs after the create transaction
  commits; correct the "pure DB writes" comment.
- resources/destinations.ts: export projectDestinationsToSessions so
  wirings.ts reuses the exact same projection as destinations add/remove.
- resources/wirings.ts: project the new destination to live sessions in
  postCommit.
- crud.test.ts: cover projection with and without a running session.
2026-07-04 01:44:12 +00:00
glifocat 0820d39424 fix(cli): wrap INSERT + postCreate in db.transaction()
genericCreate previously ran the parent INSERT and the postCreate hook
as two independent statements. If postCreate throws, the parent row is
left behind without its companion — the exact partial-state class this
PR exists to close (#2415, #2389).

Wrap both in a single better-sqlite3 db.transaction() so a postCreate
throw rolls the whole thing back. postCreate is typed `(row) => void`
and the two registered hooks (container_configs insert on groups,
agent_destinations insert on wirings) are pure sync DB writes, so the
sync transaction wrapper is the right shape.
2026-07-04 01:42:29 +00:00
glifocat c752f9c065 test(cli): cover postCreate hook for groups/wirings create
Locks down both regressions: `groups-create` must result in a
`container_configs` row, `wirings-create` must result in an
`agent_destinations` row. Without these, a future refactor of
`genericCreate` that drops the `postCreate` call would silently reopen
issues #2415 and #2389.

Also drops the speculative `Promise<void>` return type on `postCreate`.
The current side effects are sync, and keeping it sync means a future
better-sqlite3 transaction wrapper around INSERT + postCreate stays
viable. If we ever need async, narrow → wide is a cheap change.
2026-07-04 01:42:29 +00:00
glifocat 0e73e4a782 fix(cli): provision companion rows on ncl groups create and ncl wirings create
`ncl groups create` only inserted into `agent_groups`, leaving no
`container_configs` row — so the first spawn for that group threw
"Container config not found" until the next host restart kicked in the
backfill.

`ncl wirings create` only inserted into `messaging_group_agents`, leaving
no companion `agent_destinations` row — so the agent could receive
messages on the wired channel but its replies got silently dropped by the
delivery ACL.

Both bugs share the same shape: the resources are declared in
`src/cli/resources/*.ts` with `create: 'approval'` and no custom
handler, so creation falls through to `genericCreate` in
`src/cli/crud.ts`, which is a raw INSERT. The domain functions
(`initGroupFilesystem`, `createMessagingGroupAgent`) wire the companion
rows, but the generic path skips them.

This adds a `postCreate` hook to `ResourceDef`. `genericCreate` calls it
after the INSERT with the values that were written, so generated fields
(`id`, `created_at`) are populated. `groups.ts` uses it to call
`initGroupFilesystem`; `wirings.ts` uses it to create the destination row.

The destination logic was extracted from `createMessagingGroupAgent`
into a new exported `ensureAgentDestinationForWiring`, so the wirings
hook doesn't have to re-INSERT the wiring row to get the destination
side effect.

Closes #2415
Closes #2389
2026-07-04 01:42:29 +00:00
41 changed files with 530 additions and 107 deletions
@@ -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();
+1 -1
View File
@@ -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>';"
```
+2 -2
View File
@@ -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.
+3 -3
View File
@@ -112,11 +112,11 @@ Run `/manage-channels` to wire the GitHub channel to an agent group, or insert m
```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'));
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
-- 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'));
VALUES ('mga-github-myrepo', 'mg-github-myrepo', '<your-agent-group-id>', '', 'all', 'per-thread', 10, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
```
Replace `<policy>` with `public` or `strict` based on the user's choice above.
@@ -128,7 +128,7 @@ When using `strict`, add each GitHub user who should be able to trigger the agen
```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'));
VALUES ('github:<user-id>', 'github', '<username>', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
-- Grant membership to the agent group
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id)
+1 -1
View File
@@ -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';"
```
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -120,11 +120,11 @@ Run `/manage-channels` to wire the Linear channel to an agent group, or insert m
```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'));
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
-- 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'));
VALUES ('mga-linear-eng', 'mg-linear-eng', '<your-agent-group-id>', '', 'all', 'per-thread', 10, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
```
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.
+2 -2
View File
@@ -156,8 +156,8 @@ async function main(): Promise<void> {
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);
VALUES (?, ?, ?, '', 'all', ?, 10, ?)
`).run(generateId('mga'), mg.id, ag.id, args.sessionMode, new Date().toISOString());
});
tx();
+7
View File
@@ -252,6 +252,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.
+39 -4
View File
@@ -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';
}
+8 -8
View File
@@ -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,10 @@ 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());
})();
}
@@ -131,10 +131,10 @@ export function markScriptSkipped(skips: Array<{ id: string; reason: string }>):
if (skips.length === 0) return;
const db = getOutboundDb();
const stmt = db.prepare(
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, ?, datetime('now'))",
'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');
for (const s of skips) stmt.run(s.id, s.reason === 'error' ? 'script-skip:error' : 'completed', new Date().toISOString());
})();
}
@@ -142,9 +142,9 @@ export function markScriptSkipped(skips: Array<{ id: string; reason: string }>):
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,7 +137,7 @@ 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[];
+9 -1
View File
@@ -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', {
@@ -67,7 +67,7 @@ ncl tasks list
# Always pass a short descriptive --name so the task id is readable (e.g. daily-briefing-a25c, not a long uuid).
# For a recurring task, --recurrence alone sets the schedule (first run derived from it); add --process-after only for one-shots.
ncl tasks create --name "daily briefing" --prompt "Send the daily briefing" --recurrence "0 9 * * *"
# At the END of every task run, record one line of history. The host stamps the UTC time — you supply only the summary.
# 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"
@@ -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';
@@ -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;
+16
View File
@@ -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) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.40",
"version": "2.1.43",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="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="224k tokens, 112% of context window">
<title>224k tokens, 112% 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">224k</text>
<text x="71" y="14">224k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+2 -1
View File
@@ -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');
+4 -2
View File
@@ -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}`);
}
+176
View File
@@ -0,0 +1,176 @@
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, 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 { 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 };
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();
});
});
+37 -3
View File
@@ -83,6 +83,31 @@ export interface ResourceDef {
};
/** Non-standard verbs (grant, revoke, add, remove, restart, etc.). */
customOperations?: Record<string, CustomOperation>;
/**
* 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>;
}
// ---------------------------------------------------------------------------
@@ -174,9 +199,18 @@ 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;
};
}
+3 -1
View File
@@ -11,6 +11,7 @@ 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 } from './registry.js';
@@ -221,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}`);
+29 -1
View File
@@ -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)) {
+8 -6
View File
@@ -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)) {
@@ -108,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 };
},
+10
View File
@@ -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;
},
},
+2 -2
View File
@@ -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 };
},
},
+2 -2
View File
@@ -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 };
},
},
+2 -1
View File
@@ -455,7 +455,8 @@ describe('tasks CLI resource', () => {
expect(resp.ok).toBe(true);
if (!resp.ok) return;
const content = fs.readFileSync(logFile('ag-1', 'my-task-1'), 'utf8').trim();
expect(content).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z — did the thing; it worked$/);
// 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 () => {
+5 -5
View File
@@ -24,7 +24,7 @@ import {
type TaskUpdate,
} from '../../modules/scheduling/db.js';
import { inboundDbPath, resolveTaskSession, withInboundDb } from '../../session-manager.js';
import { parseZonedToUtc } from '../../timezone.js';
import { formatLocalStamp, parseZonedToUtc } from '../../timezone.js';
import { registerResource } from '../crud.js';
import { formatTasksTable } from '../format-tasks.js';
import type { CallerContext } from '../frame.js';
@@ -288,7 +288,7 @@ function createTask(args: Record<string, unknown>, ctx: CallerContext) {
`• MESSAGE (only if asked): if the task says to report/notify the user, send your result with an EXPLICIT destination — <message to="name">…</message> or send_message({ to: "name", … }). This run has no chat attached: an unaddressed reply is DISCARDED, so the explicit send is the ONLY thing the user receives.\n` +
`• RUN LOG (ALWAYS — even if you sent no message and did nothing else this run): after any sends, end the run with:\n` +
` ncl tasks append-log --msg "<what you did, and why it mattered>"\n` +
` Write it like a work-log entry a human keeps — concrete: what you did and WHY (a no-op run still gets a line saying why nothing was needed). If you wrote or modified files this run, name them in --msg. Not a greeting, not a copy of the message you sent. The host stamps the UTC time (do NOT add one), do NOT edit tasks/${id}.md by hand, and this NEVER goes to the user.\n` +
` 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) => {
@@ -339,7 +339,7 @@ function appendTaskLog(
const ag = getAgentGroup(group);
if (!ag) throw new Error(`agent group not found: ${group}`);
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const timestamp = formatLocalStamp(new Date(), TIMEZONE);
const dir = `${GROUPS_DIR}/${ag.folder}/tasks`;
const file = `${dir}/${series}.md`;
fs.mkdirSync(dir, { recursive: true });
@@ -659,7 +659,7 @@ registerResource({
'append-log': {
access: 'open',
description:
'Append a one-line run summary to a task run log (tasks/<id>.md).\n\nThe host stamps the UTC timestamp; you supply --msg. This is a LOG ENTRY, not a message — it sends nothing to anyone. Inside a task fire --id is auto-derived from your session. If you wrote or modified files during the run, name them in --msg.',
'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"`,
],
@@ -668,7 +668,7 @@ registerResource({
name: 'msg',
type: 'string',
description:
'Your work-log entry: what you did and why it mattered (like a human work log). The host prepends the UTC timestamp; this is logged, never sent to the user.',
'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,
},
{
+24
View File
@@ -1,4 +1,7 @@
import { ensureAgentDestinationForWiring } from '../../db/messaging-groups.js';
import type { MessagingGroupAgent } from '../../types.js';
import { registerResource } from '../crud.js';
import { projectDestinationsToSessions } from './destinations.js';
registerResource({
name: 'wiring',
@@ -67,4 +70,25 @@ registerResource({
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
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);
},
});
+31 -20
View File
@@ -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);
+7 -8
View File
@@ -151,9 +151,8 @@ export function markMessageFailed(db: Database.Database, messageId: string): voi
}
export function retryWithBackoff(db: Database.Database, messageId: string, backoffSec: number): void {
db.prepare(
`UPDATE messages_in SET tries = tries + 1, process_after = datetime('now', '+${backoffSec} seconds') WHERE id = ?`,
).run(messageId);
const processAfter = new Date(Date.now() + backoffSec * 1000).toISOString();
db.prepare('UPDATE messages_in SET tries = tries + 1, process_after = ? WHERE id = ?').run(processAfter, messageId);
}
export function getMessageForRetry(
@@ -261,7 +260,7 @@ export function getDueOutboundMessages(db: Database.Database): OutboundMessage[]
return db
.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 OutboundMessage[];
@@ -281,14 +280,14 @@ export function getDeliveredIds(db: Database.Database): Set<string> {
export function markDelivered(db: Database.Database, messageOutId: string, platformMessageId: string | null): void {
db.prepare(
"INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, ?, 'delivered', datetime('now'))",
).run(messageOutId, platformMessageId ?? null);
"INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, ?, 'delivered', ?)",
).run(messageOutId, platformMessageId ?? null, new Date().toISOString());
}
export function markDeliveryFailed(db: Database.Database, messageOutId: string): void {
db.prepare(
"INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, NULL, 'failed', datetime('now'))",
).run(messageOutId);
"INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, NULL, 'failed', ?)",
).run(messageOutId, new Date().toISOString());
}
/** Ensure the delivered table has columns added after initial schema. */
+2 -1
View File
@@ -33,10 +33,11 @@ export function insertTaskRow(
): void {
db.prepare(
`INSERT INTO messages_in (id, seq, timestamp, status, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content, series_id)
VALUES (@id, @seq, datetime('now'), @status, 0, @processAfter, @recurrence, 'task', NULL, NULL, NULL, @content, @seriesId)`,
VALUES (@id, @seq, @timestamp, @status, 0, @processAfter, @recurrence, 'task', NULL, NULL, NULL, @content, @seriesId)`,
).run({
status: 'pending',
...row,
timestamp: new Date().toISOString(),
seq: nextEvenSeq(db),
});
}
+2 -1
View File
@@ -18,6 +18,7 @@ import type Database from 'better-sqlite3';
import { CronExpressionParser } from 'cron-parser';
import { GROUPS_DIR, TIMEZONE } from '../../config.js';
import { formatLocalStamp } from '../../timezone.js';
import { getAgentGroup } from '../../db/agent-groups.js';
import { log } from '../../log.js';
import type { Session } from '../../types.js';
@@ -44,7 +45,7 @@ function appendHostTaskNote(agentGroupId: string, seriesId: string, note: string
const ag = getAgentGroup(agentGroupId);
if (!ag) return;
const dir = path.join(GROUPS_DIR, ag.folder, 'tasks');
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const timestamp = formatLocalStamp(new Date(), TIMEZONE);
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(path.join(dir, `${seriesId}.md`), `${timestamp}${note}\n`);
}
+10 -2
View File
@@ -426,8 +426,16 @@ export function writeOutboundDirect(
try {
db.prepare(
`INSERT OR IGNORE INTO messages_out (id, seq, timestamp, kind, platform_id, channel_type, thread_id, content)
VALUES (?, (SELECT COALESCE(MAX(seq), 0) + 2 FROM messages_out), datetime('now'), ?, ?, ?, ?, ?)`,
).run(message.id, message.kind, message.platformId, message.channelType, message.threadId, message.content);
VALUES (?, (SELECT COALESCE(MAX(seq), 0) + 2 FROM messages_out), ?, ?, ?, ?, ?, ?)`,
).run(
message.id,
new Date().toISOString(),
message.kind,
message.platformId,
message.channelType,
message.threadId,
message.content,
);
} finally {
db.close();
}
+16
View File
@@ -36,6 +36,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,
});
}
/**
* Interpret a naive ISO-like timestamp (no trailing `Z`, no offset) as wall-clock
* time in `tz` and return the corresponding UTC Date. Strings that already carry