toolroll 0.9.51 → 0.9.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -227,6 +227,7 @@ export type BrowserSettingsView = {
227
227
  mode: 'subscription' | 'api-key';
228
228
  set: boolean;
229
229
  }[] | null;
230
+ legacyWebhookWarning?: string;
230
231
  services: {
231
232
  configured: string[];
232
233
  channel: string | null;
@@ -0,0 +1,4 @@
1
+ import type { Database } from "./store.js";
2
+ /** Reclaim whole free pages on databases created without auto_vacuum. Call only after committing writes.
3
+ * A migration needs at least 1 MiB to justify the I/O; retention reclaims whenever more than 25% is free. */
4
+ export declare function reclaimDatabase(db: Database, reason: "migration" | "retention"): boolean;
@@ -0,0 +1,13 @@
1
+ /** Reclaim whole free pages on databases created without auto_vacuum. Call only after committing writes.
2
+ * A migration needs at least 1 MiB to justify the I/O; retention reclaims whenever more than 25% is free. */
3
+ export function reclaimDatabase(db, reason) {
4
+ const free = Number(db.prepare("PRAGMA freelist_count").get()["freelist_count"]);
5
+ const pages = Number(db.prepare("PRAGMA page_count").get()["page_count"]);
6
+ const size = Number(db.prepare("PRAGMA page_size").get()["page_size"]);
7
+ if (reason === "migration" ? free * size < 1024 * 1024 : free * 4 <= pages)
8
+ return false;
9
+ db.exec("VACUUM");
10
+ // VACUUM writes through WAL too: checkpoint so the main file (and its backups) shrinks now.
11
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
12
+ return true;
13
+ }
package/dist/demo.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * - The database is stamped `demo` BEFORE any row exists — an append-only
9
9
  * installation fact with no unset API. Every spending or external-effect
10
10
  * command (tick, watch, build, publish, reconcile, daemon, bridge,
11
- * outbox deliver, intake) fails closed on the stamp, so a kept sandbox
11
+ * intake) fails closed on the stamp, so a kept sandbox
12
12
  * can never be mistaken for real work by a worker pointed at it later.
13
13
  * The console banner is decoration; the fence is enforcement.
14
14
  * - The seeded history is SYNTHETIC and does not pretend otherwise: runs
package/dist/demo.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * - The database is stamped `demo` BEFORE any row exists — an append-only
9
9
  * installation fact with no unset API. Every spending or external-effect
10
10
  * command (tick, watch, build, publish, reconcile, daemon, bridge,
11
- * outbox deliver, intake) fails closed on the stamp, so a kept sandbox
11
+ * intake) fails closed on the stamp, so a kept sandbox
12
12
  * can never be mistaken for real work by a worker pointed at it later.
13
13
  * The console banner is decoration; the fence is enforcement.
14
14
  * - The seeded history is SYNTHETIC and does not pretend otherwise: runs
@@ -94,13 +94,13 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
94
94
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
95
95
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
96
96
  export declare function desktopUpdateStatus(stateDir: string): {
97
- error?: never;
98
97
  active: boolean;
99
98
  phase: string;
100
99
  detail: string;
101
100
  running: boolean;
102
101
  canResume: boolean;
103
102
  canCancel: boolean;
103
+ error?: never;
104
104
  automaticRecovery?: never;
105
105
  recoveryAttempts?: never;
106
106
  recoveryRepairs?: never;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Live views push on write. Nothing here polls once a second.
3
3
  *
4
- * - The bus. The database's own revision triggers (workspace-revision.ts)
5
- * move one counter on every meaningful write. After each in-process
4
+ * - The bus. Every connection's write wrapper (workspace-revision.ts)
5
+ * moves one counter per committed meaningful write. After each in-process
6
6
  * transact() COMMIT, and whenever another process (the worker, the CLI)
7
7
  * writes the database's write-ahead log, the revision is read once; when
8
8
  * it moved, every subscriber hears `{ revision }`. A rolled-back or quiet
@@ -20,7 +20,7 @@
20
20
  * gets one `reload` and ends so its EventSource can reconnect.
21
21
  *
22
22
  * Transport seam: a future WebSocket (live cursors) subscribes to the same
23
- * LiveBus beside these SSE rooms; nothing in the Store or the triggers changes.
23
+ * LiveBus beside these SSE rooms; nothing in the Store or its write wrapper changes.
24
24
  */
25
25
  import { type FSWatcher } from "node:fs";
26
26
  import type { ServerResponse } from "node:http";
package/dist/live-bus.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Live views push on write. Nothing here polls once a second.
3
3
  *
4
- * - The bus. The database's own revision triggers (workspace-revision.ts)
5
- * move one counter on every meaningful write. After each in-process
4
+ * - The bus. Every connection's write wrapper (workspace-revision.ts)
5
+ * moves one counter per committed meaningful write. After each in-process
6
6
  * transact() COMMIT, and whenever another process (the worker, the CLI)
7
7
  * writes the database's write-ahead log, the revision is read once; when
8
8
  * it moved, every subscriber hears `{ revision }`. A rolled-back or quiet
@@ -20,7 +20,7 @@
20
20
  * gets one `reload` and ends so its EventSource can reconnect.
21
21
  *
22
22
  * Transport seam: a future WebSocket (live cursors) subscribes to the same
23
- * LiveBus beside these SSE rooms; nothing in the Store or the triggers changes.
23
+ * LiveBus beside these SSE rooms; nothing in the Store or its write wrapper changes.
24
24
  */
25
25
  import { watch } from "node:fs";
26
26
  import { WORKSPACE_REVISION_KEY } from "./workspace-revision.js";
@@ -24,6 +24,7 @@ export const SIGNATURE_HEADER = "x-standing-orders-signature";
24
24
  const BATCH = 200;
25
25
  const LEASE_MS = 60_000;
26
26
  const TIMEOUT_MS = 10_000;
27
+ const CHECKPOINT_EVERY_MS = 60 * 60_000;
27
28
  /** An audit event: the ledger entry and its seal, as the stream sends it. */
28
29
  export function auditEvent(entry, instance) {
29
30
  return {
@@ -41,15 +42,34 @@ const nanos = (at) => `${BigInt(Date.parse(at)) * 1000000n}`;
41
42
  /** One pass over every destination that's set up. Returns what it sent, per destination. */
42
43
  export async function monitoringPass(store, settings, deps) {
43
44
  const sent = {};
45
+ const heads = [];
46
+ const audit = (delivered) => { if (delivered.head !== null)
47
+ heads.push(delivered.head); return delivered.count; };
44
48
  // A new destination gets the whole audit history; traces start from now.
45
49
  if (settings.webhook !== null)
46
- sent.webhook = await deliver(store, "webhook", targetOf(settings.webhook.url), () => 0, deps, entries => postSigned(settings.webhook, entries, store, deps));
50
+ sent.webhook = audit(await deliver(store, "webhook", targetOf(settings.webhook.url), () => 0, deps, entries => postSigned(settings.webhook, entries, store, deps)));
47
51
  if (settings.folder !== null)
48
- sent.folder = await deliver(store, "folder", targetOf(settings.folder.path), () => 0, deps, async (entries) => { appendFolder(settings.folder.path, entries, deps.instance); return entries.length; });
52
+ sent.folder = audit(await deliver(store, "folder", targetOf(settings.folder.path), () => 0, deps, async (entries) => { appendFolder(settings.folder.path, entries, deps.instance); return entries.length; }));
49
53
  if (settings.traces !== null)
50
- sent.traces = await deliver(store, "traces", targetOf(settings.traces.endpoint), () => store.ledgerHeadId(), deps, entries => postTraces(settings.traces, entries, store, deps));
54
+ sent.traces = (await deliver(store, "traces", targetOf(settings.traces.endpoint), () => store.ledgerHeadId(), deps, entries => postTraces(settings.traces, entries, store, deps))).count;
55
+ checkpointSent(store, heads, (deps.now ?? (() => new Date()))());
51
56
  return sent;
52
57
  }
58
+ /** The furthest head the audit stream delivered, kept as an automatic checkpoint once the whole chain verifies.
59
+ * Automatic checkpoints write no ledger entry, so recording one gives the stream nothing new to send (and checkpoint)
60
+ * next pass. At most one an hour, so a busy stream neither floods the table nor walks the whole chain every pass. */
61
+ function checkpointSent(store, heads, now) {
62
+ const head = heads.reduce((best, one) => best === null || (one !== null && one.through > best.through) ? one : best, null);
63
+ if (head === null)
64
+ return;
65
+ const newest = store.ledgerCheckpoints(1)[0];
66
+ if (newest !== undefined && (newest.through >= head.through || now.getTime() - Date.parse(newest.at) < CHECKPOINT_EVERY_MS))
67
+ return;
68
+ try {
69
+ store.ledgerCheckpoint("system", now, { head });
70
+ }
71
+ catch { /* a busy database: the next delivery tries again */ }
72
+ }
53
73
  /** A destination's name in its status row: a digest of its address (the address itself may carry a key). */
54
74
  export const targetOf = (address) => hex(`standing-orders/monitoring/${address}`, 16);
55
75
  /** Send what's due to one destination: hold it, start it over if it now points elsewhere, read after its cursor,
@@ -57,26 +77,30 @@ export const targetOf = (address) => hex(`standing-orders/monitoring/${address}`
57
77
  async function deliver(store, sink, target, start, deps, send) {
58
78
  const now = deps.now ?? (() => new Date());
59
79
  const at = now();
80
+ const none = { count: 0, head: null };
60
81
  if (!store.holdMonitoring(sink, deps.holder, at, new Date(at.getTime() + LEASE_MS)))
61
- return 0;
82
+ return none;
62
83
  let status = store.monitoringStatus(sink)[0];
63
84
  if (status === undefined || status.target !== target) {
64
85
  store.resetMonitoring(sink, target, start());
65
86
  status = store.monitoringStatus(sink)[0];
66
87
  }
67
88
  if (status.nextTryAt != null && Date.parse(status.nextTryAt) > at.getTime())
68
- return 0;
89
+ return none;
69
90
  const from = status.through;
70
91
  const entries = store.sealedAfter(from, BATCH);
71
92
  if (entries.length === 0)
72
- return 0;
93
+ return none;
94
+ const last = entries[entries.length - 1];
73
95
  try {
74
96
  const count = await send(entries);
75
- return store.monitoringDelivered(sink, deps.holder, target, from, entries[entries.length - 1].id, count, now()) ? count : 0;
97
+ if (!store.monitoringDelivered(sink, deps.holder, target, from, last.id, count, now()))
98
+ return none;
99
+ return { count, head: last.seal === null ? null : { through: last.id, hash: last.seal.hash } };
76
100
  }
77
101
  catch (error) {
78
102
  store.monitoringFailed(sink, deps.holder, target, error instanceof Error ? error.message : String(error), now(), new Date(now().getTime() + backoff(status.failures + 1)));
79
- return 0;
103
+ return none;
80
104
  }
81
105
  }
82
106
  async function post(url, body, headers, deps) {
@@ -0,0 +1,19 @@
1
+ import type { Notification, Store } from "./store.js";
2
+ type Claim = Notification & {
3
+ destination: string;
4
+ generation: number;
5
+ };
6
+ /** URLs and shell commands can contain secrets. Only a destination hash enters saved receipts. */
7
+ export declare function notificationDestination(kind: string, target: string): string;
8
+ /** One-release compatibility for operator-configured, installation-wide senders. Personal notifications never leave here. */
9
+ export declare function claimNotifications(store: Store, destination: string, owner: string, now: Date): Claim[];
10
+ export declare function finalizeNotification(store: Store, row: Claim, owner: string, outcome: {
11
+ ok: true;
12
+ receipt: string | null;
13
+ } | {
14
+ ok: false;
15
+ error: string;
16
+ }, now: Date): boolean;
17
+ /** Recheck after earlier network sends: expiry or resolution must stop this send, not just its receipt. */
18
+ export declare function notificationClaimHeld(store: Store, row: Claim, owner: string, now: Date): boolean;
19
+ export {};
@@ -0,0 +1,37 @@
1
+ import { createHash } from "node:crypto";
2
+ /** URLs and shell commands can contain secrets. Only a destination hash enters saved receipts. */
3
+ export function notificationDestination(kind, target) {
4
+ return `${kind}:${createHash("sha256").update(target).digest("hex")}`;
5
+ }
6
+ /** One-release compatibility for operator-configured, installation-wide senders. Personal notifications never leave here. */
7
+ export function claimNotifications(store, destination, owner, now) {
8
+ return store.transact(() => {
9
+ const db = store.handle, stamp = now.toISOString();
10
+ db.prepare(`INSERT OR IGNORE INTO notification_delivery(notification, destination)
11
+ SELECT id, ? FROM notification WHERE resolved_at IS NULL AND recipient IS NULL`).run(destination);
12
+ const claims = db.prepare(`SELECT d.notification, d.claim_generation FROM notification_delivery d
13
+ JOIN notification n ON n.id = d.notification WHERE d.destination = ? AND n.resolved_at IS NULL AND n.recipient IS NULL
14
+ AND d.delivered_at IS NULL AND (d.receipt IS NULL OR d.receipt NOT LIKE 'skipped:%')
15
+ AND (d.claim_owner IS NULL OR d.claim_expires_at <= ?) ORDER BY n.id`).all(destination, stamp);
16
+ return claims.map(raw => {
17
+ const id = Number(raw["notification"]), generation = Number(raw["claim_generation"]) + 1;
18
+ db.prepare(`UPDATE notification_delivery SET claim_owner = ?, claim_expires_at = ?, claim_generation = ?
19
+ WHERE notification = ? AND destination = ?`).run(owner, new Date(now.getTime() + 60_000).toISOString(), generation, id, destination);
20
+ return { ...store.notificationById(id), destination, generation };
21
+ });
22
+ });
23
+ }
24
+ export function finalizeNotification(store, row, owner, outcome, now) {
25
+ const skipped = outcome.ok && outcome.receipt?.startsWith("skipped:");
26
+ return Number(store.handle.prepare(`UPDATE notification_delivery SET attempts = attempts + 1, last_attempt_at = ?,
27
+ delivered_at = ?, receipt = ?, last_error = ?, claim_owner = NULL, claim_expires_at = NULL
28
+ WHERE notification = ? AND destination = ? AND claim_owner = ? AND claim_generation = ?
29
+ AND claim_expires_at > ? AND delivered_at IS NULL`).run(now.toISOString(), outcome.ok && !skipped ? now.toISOString() : null, outcome.ok ? outcome.receipt : null, outcome.ok ? null : outcome.error, row.id, row.destination, owner, row.generation, now.toISOString()).changes) === 1;
30
+ }
31
+ /** Recheck after earlier network sends: expiry or resolution must stop this send, not just its receipt. */
32
+ export function notificationClaimHeld(store, row, owner, now) {
33
+ return store.handle.prepare(`SELECT 1 FROM notification_delivery d JOIN notification n ON n.id = d.notification
34
+ WHERE d.notification = ? AND d.destination = ? AND d.claim_owner = ? AND d.claim_generation = ?
35
+ AND d.claim_expires_at > ? AND d.delivered_at IS NULL AND n.resolved_at IS NULL AND n.recipient IS NULL`)
36
+ .get(row.id, row.destination, owner, row.generation, now.toISOString()) !== undefined;
37
+ }
@@ -10,7 +10,7 @@ const args = (positionals = '', values = '', references = {}) => ({
10
10
  * digest. Neither dereferences a conversation, task or proposal. Creation IDs are never looked up. */
11
11
  export const REMOTE_ARGUMENTS = new Map([
12
12
  ...['status', 'ready', 'grants', 'gaps', 'sync', 'runner list', 'coordinator list', 'incident list',
13
- 'routine list', 'intake show', 'intake run', 'template list', 'webhook status', 'webhook test', 'review show',
13
+ 'routine list', 'intake show', 'intake run', 'template list', 'webhook status', 'review show',
14
14
  'chat-approval show', 'chat-approval off'].map(row => [row, args()]),
15
15
  ['integrations', args('', 'saved')],
16
16
  // --person and --token-name are names compared inside the reader's own view; --token stays a refused credential.
@@ -46,7 +46,7 @@ export const REMOTE_SCOPES = new Map([
46
46
  ...["list", "show", "add", "refresh", "pause", "resume", "run-now"].map(action => [`routine ${action}`, installation]),
47
47
  ["config show", installation], ["verify show", repo], ["intake show", installation], ["intake run", installation], ["intake preview", installation],
48
48
  ["intake pr-comments", installation], ["template list", installation], ["template show", installation], ["contest show", installation],
49
- ["contest exclude", installation], ["webhook status", installation], ["webhook test", installation], ["review show", installation],
49
+ ["contest exclude", installation], ["webhook status", installation], ["review show", installation],
50
50
  ["chat-approval show", self], ["chat-approval off", self],
51
51
  // The person's own remote actions, or everyone's within their projects for an approver (remote-audit.ts).
52
52
  ["audit", self],
@@ -64,7 +64,7 @@ export const REMOTE_SCOPES = new Map([
64
64
  export const REMOTE_ALL_PROJECTS_ONLY = new Map([
65
65
  // Settings, connections and acts that belong to the installation as a whole, not to any one project.
66
66
  ...["integrations", "grants", "sync", "incident resolve", "routine add", "routine refresh", "routine pause", "routine resume", "routine run-now",
67
- "config show", "intake run", "contest exclude", "webhook status", "webhook test"].map(row => [row, "installation-wide"]),
67
+ "config show", "intake run", "contest exclude", "webhook status"].map(row => [row, "installation-wide"]),
68
68
  // Cross-project lists and reads a limited person is still refused; scoping them is later work.
69
69
  ...["runner list", "coordinator list", "outbox list", "incident list", "routine list", "routine show", "intake show", "intake preview",
70
70
  "intake pr-comments", "template list", "template show", "contest show", "review show"].map(row => [row, "not yet scoped"]),
package/dist/operate.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { notificationClaimHeld, claimNotifications, finalizeNotification, notificationDestination } from "./notification-delivery.js";
1
2
  import { adapterPolicy } from "./server/route-policy.js";
2
3
  import { UNSENT_REPLY_MS } from "./telegram-settings.js";
3
4
  import { checkPublicCommand } from "./public-check.js";
@@ -47,8 +48,6 @@ import { runRequestedUndos, sendTeammateWeeklies } from "./teammate-week.js";
47
48
  import { refreshConnections } from "./mcp-connect.js";
48
49
  import { followDiscord } from "./discord.js";
49
50
  import { followTeams } from "./teams.js";
50
- import { loadDiscordCredentials } from "./discord-api.js";
51
- import { loadSlackCredentials } from "./slack-api.js";
52
51
  import { followSlack } from "./slack.js";
53
52
  import { validateScopeText } from "./task-text.js";
54
53
  import { runMemoryCommand } from "./memory-cli.js";
@@ -197,7 +196,7 @@ import { isRiskLevel, isTaskSize, legOf, projectRoute, riskConsequence, routeDig
197
196
  import { observeProviderReadiness, reportProviderReadinessAuthed } from "./runner.js";
198
197
  import { parseDemoUrl, projectDemoUrl, saveProjectDemo } from "./project-demo.js";
199
198
  import { effectiveConcurrency, maySlotTake, parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, ProjectPasses, projectConcurrency, saveProjectConcurrency, savedProjectConcurrency } from "./project-concurrency.js";
200
- import { clearWebhook, effectivePrimary, isMessagingChannel, loadConsoleUrl, loadPrimary, loadWebhookTargets, phoneOrigin, saveConsoleUrl, savePrimary, saveWebhook, webhookPass, SLACK_ENV, DISCORD_ENV } from "./webhooks.js";
199
+ import { activeWebhookTargets, webhookPass, LEGACY_WEBHOOK_WARNING, loadWebhookTargets, effectivePrimary, isMessagingChannel, loadConsoleUrl, phoneOrigin, saveConsoleUrl, savePrimary } from "./webhooks.js";
201
200
  import { auditOf, inspectionOf, isProviderId, MONEY_CAPABILITIES, PROVIDER_IDS, validModelId, validateSpec, ALL_CREDENTIAL_ENV } from "./provider.js";
202
201
  import { attestProvider, attestationOf, versionInRange } from "./attest.js";
203
202
  import { recognizesEligible } from "./exhaustion.js";
@@ -575,21 +574,14 @@ Agents — which provider and model each phase runs on
575
574
  says REVIEW was not read)
576
575
 
577
576
  The outbox — facts that want a person, durably
578
- toolroll webhook set slack|discord <url>
579
- UI-only chat mirrors: every page a
580
- message with a console link; acting
581
- stays in the console. Delivers when
582
- Telegram is not configured.
583
577
  toolroll webhook set console-url <http://host:port>
584
578
  toolroll webhook primary telegram|slack|discord
585
579
  which service receives alerts when
586
580
  several are connected (asked once,
587
581
  the first time you add a second)
588
- toolroll webhook status | test | clear slack|discord
589
- toolroll outbox list [--all]
590
- toolroll outbox deliver --cmd <c> runs once per pending row, reading
591
- $TOOLROLL_KIND / _SUBJECT / _BODY;
592
- exit 0 delivered receipts, 1 any fail
582
+ toolroll webhook status
583
+ toolroll outbox list [--all] pending facts (--all: resolved too)
584
+ toolroll outbox deliver --cmd <cmd> deliver through an operator command
593
585
 
594
586
  Runners — the machines that may be given work
595
587
  toolroll runner register <name> [--capacity <n>] [--token-file <path>]
@@ -1063,8 +1055,8 @@ function telegramConversation(context, options = {}) {
1063
1055
  evidenceRoot: context.evidenceRoot,
1064
1056
  ...(context.mateSeams?.subscriptionRunner === undefined ? {} : { subscriptionRunner: context.mateSeams.subscriptionRunner }),
1065
1057
  ...(context.heldCoordinator === undefined ? {} : { held: context.heldCoordinator }),
1066
- // Re-read on every card and every `/task`: the same console-url the
1067
- // mirrors use, held to an https origin, and — inside `up`, where this
1058
+ // Re-read on every card and every `/task`: the same console-url chat
1059
+ // links use, held to an https origin, and — inside `up`, where this
1068
1060
  // process also serves the console — equal to that console's own
1069
1061
  // `--public-url`, or no link at all.
1070
1062
  phoneOrigin: () => phoneOrigin(process.env, dirname(context.databaseFile), { serverOrigin: options.serverOrigin ?? null }),
@@ -4922,7 +4914,7 @@ async function briefCommand(flags, context) {
4922
4914
  lines.push(` #${pull.number} ${pull.title}`);
4923
4915
  }
4924
4916
  if (pending.length > 0) {
4925
- lines.push(` ▸ OUTBOX ${pending.length} undelivered — toolroll outbox deliver --cmd …`);
4917
+ lines.push(` ▸ OUTBOX ${pending.length} undelivered — toolroll outbox list`);
4926
4918
  }
4927
4919
  if (decisions.length > 0) {
4928
4920
  const overdue = decisions.filter(one => one.state === "expired").length;
@@ -5429,18 +5421,16 @@ async function incidentCommand(positional, flags, context) {
5429
5421
  ]);
5430
5422
  }
5431
5423
  /**
5432
- * `toolroll webhook …` — Slack and Discord as UI-ONLY mirrors: every
5433
- * page is a message with a console link; acting stays in the console
5434
- * behind its own authentication. The URL is a credential: 0600 file
5435
- * beside the database, or the environment, never anywhere else.
5424
+ * `toolroll webhook status|primary|set console-url` — messaging settings:
5425
+ * which connected chat service receives alerts, and the console address
5426
+ * chat links open.
5436
5427
  */
5437
- async function webhookCommand(positional, flags, context) {
5438
- const { store, write, json, clock } = context;
5428
+ async function webhookCommand(positional, _flags, context) {
5429
+ const { write, json } = context;
5439
5430
  const dir = dirname(context.databaseFile);
5440
5431
  const [action, which, value] = positional;
5441
5432
  const telegramConfigured = loadBotToken(process.env, context.telegramTokenFile) !== null;
5442
5433
  if (action === undefined || action === "status") {
5443
- const targets = loadWebhookTargets(process.env, dir);
5444
5434
  const consoleUrl = loadConsoleUrl(process.env, dir);
5445
5435
  const primary = effectivePrimary(process.env, dir, telegramConfigured);
5446
5436
  if (json) {
@@ -5456,29 +5446,8 @@ async function webhookCommand(positional, flags, context) {
5456
5446
  write(` Choose: toolroll webhook primary telegram|slack|discord`);
5457
5447
  }
5458
5448
  write(` links ${consoleUrl ?? "NOT SET — messages will carry no console link; toolroll webhook set console-url http://host:port"}`);
5459
- if (targets.length === 0) {
5460
- write("");
5461
- write(" toolroll webhook set slack https://hooks.slack.com/services/…");
5462
- write(" toolroll webhook set discord https://discord.com/api/webhooks/…");
5463
- write(` (or export ${SLACK_ENV} / ${DISCORD_ENV})`);
5464
- }
5465
- write("");
5466
- write(" Mirrors deliver when Telegram is not configured; with a paired Telegram");
5467
- write(" chat, Telegram carries the page (it can hold buttons) and mirrors stay quiet.");
5468
5449
  return EXIT.ok;
5469
5450
  }
5470
- if (action === "test") {
5471
- const targets = loadWebhookTargets(process.env, dir);
5472
- if (targets.length === 0) {
5473
- return fail(write, json, "webhook test", "unconfigured", "no webhook configured — `toolroll webhook set slack|discord <url>`", EXIT.refused);
5474
- }
5475
- store.enqueueNotification({ source: { installation: true }, dedupeKey: `webhook-test:${clock().getTime()}`, kind: "test", subject: "toolroll webhook test", body: "If you can read this, the mirror works. Acting happens in the console." }, clock());
5476
- const report = await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock });
5477
- if (report.problems.length > 0) {
5478
- return fail(write, json, "webhook test", "delivery", report.problems.join("; "), EXIT.failed);
5479
- }
5480
- return succeed(write, json, "webhook test", { sent: report.sent }, () => [`Sent ${report.sent} message(s). Check the channel.`]);
5481
- }
5482
5451
  if (action === "primary") {
5483
5452
  if (which === undefined || !isMessagingChannel(which)) {
5484
5453
  return fail(write, json, "webhook primary", "usage", "primary is one of telegram, slack, discord", EXIT.usage);
@@ -5492,46 +5461,13 @@ async function webhookCommand(positional, flags, context) {
5492
5461
  `Alerts now go to ${which}. ${which === "telegram" ? "Buttons and replies work there as always." : "Telegram (if configured) still accepts taps and replies — it just stops sending alerts."}`,
5493
5462
  ]);
5494
5463
  }
5495
- if (action === "clear" && (which === "slack" || which === "discord")) {
5496
- clearWebhook(dir, which);
5497
- return succeed(write, json, "webhook clear", { which }, () => [`${which} mirror cleared.`]);
5464
+ if (action !== "set" || which !== "console-url" || value === undefined) {
5465
+ return fail(write, json, "webhook", "usage", "`toolroll webhook [status|primary <service>|set console-url <url>]`", EXIT.usage);
5498
5466
  }
5499
- if (action !== "set" || which === undefined || value === undefined) {
5500
- return fail(write, json, "webhook", "usage", "`toolroll webhook [status|test|set slack|discord|console-url <value>|clear slack|discord]`", EXIT.usage);
5501
- }
5502
- if (which === "console-url") {
5503
- const saved = saveConsoleUrl(dir, value);
5504
- if (!saved.ok)
5505
- return fail(write, json, "webhook set", "invalid", saved.message, EXIT.usage);
5506
- return succeed(write, json, "webhook set", { which }, () => [`Console links will open ${value.replace(/\/+$/, "")}.`]);
5507
- }
5508
- if (which !== "slack" && which !== "discord") {
5509
- return fail(write, json, "webhook set", "usage", "set what? slack, discord, or console-url", EXIT.usage);
5510
- }
5511
- const saved = saveWebhook(dir, which, value);
5467
+ const saved = saveConsoleUrl(dir, value);
5512
5468
  if (!saved.ok)
5513
5469
  return fail(write, json, "webhook set", "invalid", saved.message, EXIT.usage);
5514
- // The first moment more than one service exists is the moment to ask
5515
- // which one pages — once, right here, not at 3am when both fire.
5516
- const after = effectivePrimary(process.env, dir, telegramConfigured);
5517
- let chosen = null;
5518
- if (after.implicit && loadPrimary(process.env, dir) === null && after.configured.length > 1 && interactive() && !json) {
5519
- write(`You now have ${after.configured.join(" and ")} connected.`);
5520
- const answer = (await ask(`Which service should receive alerts? [${after.configured.join("/")}] `)).trim().toLowerCase();
5521
- if (isMessagingChannel(answer) && after.configured.includes(answer)) {
5522
- savePrimary(dir, answer);
5523
- chosen = answer;
5524
- }
5525
- else {
5526
- write(`Left unchosen — ${after.channel} receives alerts by default. Decide any time: toolroll webhook primary <service>`);
5527
- }
5528
- }
5529
- return succeed(write, json, "webhook set", { which, ...(chosen === null ? {} : { primary: chosen }) }, () => [
5530
- `${which} mirror configured — the URL lives in a private file beside the database.`,
5531
- ...(chosen === null ? [] : [`${chosen} carries the pages.`]),
5532
- ...(after.implicit && chosen === null && !interactive() ? [`Several services are configured — choose the pager: toolroll webhook primary <service>`] : []),
5533
- `Send yourself a proof: toolroll webhook test`,
5534
- ]);
5470
+ return succeed(write, json, "webhook set", { which }, () => [`Console links will open ${value.replace(/\/+$/, "")}.`]);
5535
5471
  }
5536
5472
  /**
5537
5473
  * `toolroll providers resume <provider>` — a person says the sign-in works
@@ -7865,6 +7801,7 @@ async function runWatchLoop(args) {
7865
7801
  let lastTick = 0;
7866
7802
  let lastBridge = 0;
7867
7803
  let lastPush = 0;
7804
+ let lastWebhook = 0;
7868
7805
  let ticks = 0;
7869
7806
  let built = 0;
7870
7807
  let brokeCount = 0;
@@ -8160,6 +8097,14 @@ async function runWatchLoop(args) {
8160
8097
  // Push is additive; a broken pass never stops the watch.
8161
8098
  }
8162
8099
  }
8100
+ if (now - lastWebhook >= bridgeEveryMs) {
8101
+ lastWebhook = now;
8102
+ const dir = dirname(context.databaseFile);
8103
+ const primary = effectivePrimary(process.env, dir, loadBotToken(process.env, context.telegramTokenFile) !== null);
8104
+ const targets = activeWebhookTargets(process.env, dir, primary.channel);
8105
+ if (targets.length > 0)
8106
+ await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock: context.clock });
8107
+ }
8163
8108
  // The embedded follower owns the wire while it lives; the timer-driven
8164
8109
  // pass is the fallback shape for a watch started before a token existed.
8165
8110
  if (follower === null && now - lastBridge >= bridgeEveryMs) {
@@ -8175,12 +8120,6 @@ async function runWatchLoop(args) {
8175
8120
  quiet.length = 0;
8176
8121
  await bridgeCommand(["telegram"], passFlags(primary.channel === "telegram" ? {} : { "inbound-only": "1" }), quietContext);
8177
8122
  }
8178
- if (primary.channel !== null && primary.channel !== "telegram") {
8179
- const targets = loadWebhookTargets(process.env, dir).filter(one => one.kind === primary.channel && (one.kind !== "slack" || loadSlackCredentials(dir) === null) && (one.kind !== "discord" || loadDiscordCredentials(dir) === null));
8180
- if (targets.length > 0) {
8181
- await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock: context.clock });
8182
- }
8183
- }
8184
8123
  }
8185
8124
  // Work-conserving: if the world moved while we worked — or we just
8186
8125
  // finished something that may have freed a dependent — go again now.
@@ -8194,7 +8133,7 @@ async function runWatchLoop(args) {
8194
8133
  // due, a signal lands, or the trial window (--for) ends. Reading one
8195
8134
  // integer from SQLite twice a second is the entire idle cost — no
8196
8135
  // token has anywhere to be spent from here.
8197
- const idleUntil = Math.min(lastTick + tickEveryMs, lastBridge + bridgeEveryMs, deadline ?? Number.MAX_SAFE_INTEGER) - Date.now();
8136
+ const idleUntil = Math.min(lastTick + tickEveryMs, lastBridge + bridgeEveryMs, lastWebhook + bridgeEveryMs, deadline ?? Number.MAX_SAFE_INTEGER) - Date.now();
8198
8137
  // Doze for the WHOLE idle window, waking early only for a signal or a
8199
8138
  // sequence move — not for one 500ms step and then another pass
8200
8139
  // (2026-09-05: that read as two ticks a second, all night, with
@@ -9804,18 +9743,6 @@ async function publishCommand(positional, flags, context) {
9804
9743
  return succeed(write, json, "publish", { report }, lines);
9805
9744
  }
9806
9745
  // ---- the outbox -----------------------------------------------------------
9807
- /**
9808
- * `toolroll outbox list|deliver` — reading and draining the durable
9809
- * outbox. Delivery runs an operator-supplied command once per pending row;
9810
- * the notification's text reaches it as environment variables, never
9811
- * substituted into the command line, because subjects and bodies quote
9812
- * things agents and repositories said and a shell must not meet those.
9813
- *
9814
- * toolroll outbox deliver --cmd 'curl -d "$TOOLROLL_SUBJECT" ntfy.sh/mine'
9815
- *
9816
- * Exit 0 when everything pending delivered (or nothing was pending);
9817
- * 1 when any delivery failed — a broken channel is breakage, not a "no".
9818
- */
9819
9746
  /**
9820
9747
  * `toolroll peek [<run>] [--tmux] [--lines <n>]` — watch live agents
9821
9748
  * in the terminal: one pane per open run with its stage and transcript
@@ -9865,6 +9792,11 @@ async function peekCommand(positional, flags, context) {
9865
9792
  }
9866
9793
  return runPeek(store, context.evidenceRoot, { ...(runId === undefined ? {} : { runId }), io: { stdout: process.stdout, stdin: process.stdin }, clock });
9867
9794
  }
9795
+ /**
9796
+ * `toolroll outbox list [--all]` — read the durable outbox: the facts that
9797
+ * want a person, pending until resolved. An operator command can also deliver
9798
+ * them with its own destination receipts.
9799
+ */
9868
9800
  async function outboxCommand(positional, flags, context) {
9869
9801
  const { store, write, json, clock } = context;
9870
9802
  const [action] = positional;
@@ -9876,17 +9808,12 @@ async function outboxCommand(positional, flags, context) {
9876
9808
  return EXIT.ok;
9877
9809
  }
9878
9810
  if (notifications.length === 0) {
9879
- write(wanted === "pending" ? "Nothing waiting to be delivered." : "The outbox is empty.");
9811
+ write(wanted === "pending" ? "Nothing pending." : "The outbox is empty.");
9880
9812
  return EXIT.ok;
9881
9813
  }
9882
9814
  for (const one of notifications) {
9883
- const state = one.deliveredAt !== null
9884
- ? `delivered ${one.deliveredAt}`
9885
- : one.attempts > 0
9886
- ? `pending, ${one.attempts} failed attempt(s): ${one.lastError ?? ""}`
9887
- : "pending";
9888
9815
  write(` #${one.id} ${one.kind.padEnd(18)} ${one.subject}`);
9889
- write(` ${state}`);
9816
+ write(` ${one.resolvedAt === null ? "pending" : `resolved ${one.resolvedAt}`}`);
9890
9817
  }
9891
9818
  return EXIT.ok;
9892
9819
  }
@@ -9898,14 +9825,9 @@ async function outboxCommand(positional, flags, context) {
9898
9825
  if (command === undefined) {
9899
9826
  return fail(write, json, "outbox deliver", "usage", "--cmd says how: it runs once per notification, reading $TOOLROLL_KIND, $TOOLROLL_SUBJECT, $TOOLROLL_BODY", EXIT.usage);
9900
9827
  }
9901
- // Claimed, not merely listed: the Telegram bridge drains this same
9902
- // outbox, and select-then-send-then-record from two deliverers pages a
9903
- // person twice. The claim is a short lease on the act of sending; a
9904
- // deliverer that dies mid-send leaves rows that unclaim by expiry.
9828
+ // The exact command owns a leased destination receipt, including across concurrent CLI invocations.
9905
9829
  const owner = `outbox-${randomUUID()}`;
9906
- // Push first, independently (arc 3 finding 8): its pair ledger does not
9907
- // depend on globally-undelivered notifications — Telegram or a webhook
9908
- // may already have stamped delivered_at.
9830
+ // Push delivery has independent receipts and must keep working if this command fails.
9909
9831
  let pushed = 0;
9910
9832
  try {
9911
9833
  pushed = (await pushPass(store, { configDir: dirname(context.databaseFile), clock })).accepted;
@@ -9913,7 +9835,7 @@ async function outboxCommand(positional, flags, context) {
9913
9835
  catch {
9914
9836
  // additive; the shell delivery below still runs
9915
9837
  }
9916
- const pending = store.claimDeliveries(owner, 2 * 60_000, clock());
9838
+ const pending = claimNotifications(store, notificationDestination("command", command), owner, clock());
9917
9839
  if (pending.length === 0) {
9918
9840
  return succeed(write, json, "outbox deliver", { delivered: 0, failed: 0, pushed }, () => [
9919
9841
  pushed > 0 ? `Nothing for the shell command; ${pushed} push(es) accepted.` : "Nothing waiting to be delivered.",
@@ -9922,6 +9844,10 @@ async function outboxCommand(positional, flags, context) {
9922
9844
  let delivered = 0;
9923
9845
  let failed = 0;
9924
9846
  for (const one of pending) {
9847
+ if (!notificationClaimHeld(store, one, owner, clock())) {
9848
+ failed++;
9849
+ continue;
9850
+ }
9925
9851
  const sent = await run("sh", ["-lc", command], {
9926
9852
  timeoutMs: 30_000,
9927
9853
  env: {
@@ -9933,14 +9859,16 @@ async function outboxCommand(positional, flags, context) {
9933
9859
  });
9934
9860
  if (sent.code === 0) {
9935
9861
  const receipt = sent.stdout.split("\n")[0]?.trim() ?? "";
9936
- store.finalizeDelivery(one.id, owner, { ok: true, receipt: receipt === "" ? null : receipt }, clock());
9937
- delivered++;
9862
+ if (finalizeNotification(store, one, owner, { ok: true, receipt: receipt === "" ? null : receipt }, clock()))
9863
+ delivered++;
9864
+ else
9865
+ failed++;
9938
9866
  }
9939
9867
  else {
9940
9868
  const error = sent.timedOut
9941
9869
  ? "timed out"
9942
9870
  : sent.stderr.split("\n")[0]?.trim() || `exit ${sent.code}`;
9943
- store.finalizeDelivery(one.id, owner, { ok: false, error }, clock());
9871
+ finalizeNotification(store, one, owner, { ok: false, error }, clock());
9944
9872
  failed++;
9945
9873
  }
9946
9874
  }
@@ -10171,6 +10099,7 @@ async function statusCommand(positional, flags, context) {
10171
10099
  // An update waiting, on what, and the action: before anything else, as it holds up new work.
10172
10100
  const waiting = waitingUpdate(context.databaseFile, run => context.store.stopQuiescenceProblem(run) !== null, context.clock());
10173
10101
  const updateWaiting = waiting === null ? {} : { updateWaiting: { app: waiting.app, version: waiting.version, stopped: waiting.stopped, run: waiting.run, on: waiting.on, action: waiting.action } };
10102
+ const legacyWebhookWarning = loadWebhookTargets(process.env, dirname(context.databaseFile)).length > 0 ? LEGACY_WEBHOOK_WARNING : null;
10174
10103
  const projects = projectBuildsStatus(context);
10175
10104
  const projectsLine = projects.length === 0 ? null
10176
10105
  : `Builds by project: ${projects.slice(0, 8).map(one => `${one.name} ${one.running} of ${one.limit}`).join(", ")}${projects.length > 8 ? ", …" : ""}`;
@@ -10178,7 +10107,7 @@ async function statusCommand(positional, flags, context) {
10178
10107
  const unsent = context.store.unsentTelegramReplies(null, null, new Date(context.clock().getTime() - UNSENT_REPLY_MS));
10179
10108
  const unsentLine = unsentRepliesLine(unsent);
10180
10109
  const unsentReplies = unsent.length === 0 ? {} : { unsentReplies: unsent.map(one => ({ ...one, retry: "toolroll bridge telegram retry" })) };
10181
- return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting, ...unsentReplies }, () => [...(waiting === null ? [] : [waiting.words]), ...(unsentLine === null ? [] : [unsentLine]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
10110
+ return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting, ...unsentReplies, ...(legacyWebhookWarning === null ? {} : { legacyWebhookWarning }) }, () => [...(waiting === null ? [] : [waiting.words]), ...(unsentLine === null ? [] : [unsentLine]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine]), ...(legacyWebhookWarning === null ? [] : [legacyWebhookWarning])]);
10182
10111
  }
10183
10112
  function unsentRepliesLine(unsent) {
10184
10113
  if (unsent.length === 0)
@@ -12829,6 +12758,7 @@ async function backupCommand(positional, flags, context) {
12829
12758
  return fail(context.write, context.json, command, "failed", `The backup failed: ${made.error}`, EXIT.failed, { backup: made.id });
12830
12759
  return succeed(context.write, context.json, command, { backup: made }, () => [
12831
12760
  `Backed up to ${made.file} (${bytesWords(made.bytes)}).${made.removed === 0 ? "" : ` Removed ${made.removed} older ${made.removed === 1 ? "backup" : "backups"}.`}`,
12761
+ ...(made.checkpoint !== null && "problem" in made.checkpoint ? [`The activity log failed its tamper check, so it was not anchored: ${made.checkpoint.problem}`] : []),
12832
12762
  ]);
12833
12763
  }
12834
12764
  const settings = store.backupSettings();