@inetafrica/open-claudia 3.4.23 → 3.5.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.0
4
+
5
+ Security fixes for the permission system: phase 0 of the redesign in the owner's 25 Sep review. The owner's own chat turns work as before. Staff and external turns lose the ways around the checks. First shipped as a canary on two bots.
6
+
7
+ - **Who a process acts for is now decided by the running bot.** The runner registers every run with the person it acts for, resolved from the inbound message, and stamps an attestation bound to that run. Tools, the keyring, notes and scheduling ask the bot through the loopback's new `whoami`. The following are all *unattributed*, and unattributed is never the owner:
8
+ - a shell that unsets or rewrites its environment;
9
+ - a process that outlives its turn;
10
+ - anything started outside a chat turn.
11
+ Before this release, the CLIs read the speaker from `OC_CHANNEL_*` variables that the agent's own shell could change, and an unset speaker counted as the owner.
12
+ - **Staff turns never see the AI service credentials.** Non-owner runs set `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1`.
13
+ - **A staff write or destructive tool run is decided inside the bot.** The mandate check runs there. If the owner must approve, the bot posts the card itself. Requests raised from inside a tool used to die with "could not post to owner" or "no reachable owner" (18 dropped fleet-wide in 14 days).
14
+ - **A verb that a tool's manifest does not declare is refused for everyone,** and the refusal lists the declared verbs. Before, a staff turn's undeclared verb was classed as a write and sent to the owner.
15
+ - **An owner approval carries exactly the tier approved.** Redeeming any token used to export "destructive", so approving a write unlocked destructive for that run.
16
+ - **Changing tools, notes, the keyring and sub-agents is owner-only:**
17
+ - tool add, set, edit, note, remove and lib;
18
+ - entity, pack, lessons and ideas writes;
19
+ - every keyring verb, including `list`;
20
+ - `open-claudia agent`.
21
+ From a plain terminal, these now need an owner approval token.
22
+ - **Follow-ups set in a staff turn run at that person's level, in that room.** This covers wakeups, crons and watches. The job's creator is the attested run. Before, it came from fields the run's own shell could forge, with a fallback to the owner. Watch checks run without the bot's secrets, as their creator.
23
+ - **People notes and tasks are owner-only through the bot's commands.**
24
+ - **History stays in the caller's room.** For a non-owner, `recent`, `transcript-search` and `transcript-window` return only the caller's room. `recent-fetch`'s channel filter, which matched nothing, is fixed.
25
+ - **File reads are screened on staff turns.**
26
+ - Read, Grep, Glob, NotebookRead and LS cannot reach:
27
+ - the bot's config (keyring, transcripts, approvals, people);
28
+ - Claude and Codex credentials;
29
+ - SSH, cloud and kube credentials;
30
+ - other processes' environments.
31
+ - Paths are resolved through symlinks.
32
+ - The shell refuses the obvious equivalents.
33
+ - A permission hook that cannot run now refuses on a staff turn instead of letting the tool through.
34
+ - **Destructive is never a standing permission.**
35
+ - "Always" on a destructive card is refused.
36
+ - A destructive grant already on disk counts as act.
37
+ - Grants for anonymous or unverifiable senders (Telegram anonymous admins, channel posts, the bot itself) are refused.
38
+ - When an exact-room grant and an all-rooms grant both match, the stronger one applies.
39
+ - **Releases can be canaried.**
40
+ - A `canary-vX.Y.Z` tag publishes npm under `next` and pushes docker `:X.Y.Z` only.
41
+ - Pushing `vX.Y.Z` on the same commit later promotes that exact build (`latest`, no rebuild).
42
+
43
+ ## 3.4.24
44
+
45
+ - A repeating watch keeps polling while its triggered delivery waits for a busy room, waits to retry after a failed or `/stop`-killed run, or runs. A new hit folds into that one pending delivery instead of queueing another, and the delivered prompt says how many more times the check passed and whether its latest run had cleared. On 24 Sep the event-bus watch went unpolled for 7.5 h behind a busy room, and after a killed delivery attempt its polling never resumed. One-shot watches are unchanged, and the 15-minute "⏳ Watch … waiting" line still goes out once per occurrence.
46
+ - A watch whose check times out 3 times in a row now wakes the agent once, marked "Watch check timing out", and keeps polling; any completed check resets the count. A hanging health check used to be logged `idle (check timed out)` and never alarm. The alarm does not use up a one-shot watch.
47
+ - Wakeups more than ~24.8 days out no longer fire at once. `setTimeout` overflows past 2^31-1 ms, which fired a 2028 reminder 61 s after it was set (17 Sep); far wakeups now sleep in chunks and re-plan until due, then fire once. `schedule-wakeup` refuses a `<when>` more than 10 years out, in the CLI and in the loopback route.
48
+ - A queued delivery is no longer stranded when a jobs-store write throws (a full disk, a torn journal). Those paths returned without a retry timer, so the alarm was never tried again; they now keep the retry armed and say so in watch.log. This matches the watch alarms that triggered during long turns on 21 and 23 Sep and were never delivered. A regression test also pins that a delivery queued behind a long turn survives the turn's compaction and runs once at the next idle moment, on the compacted session; that path already worked.
49
+ - `cron-list` and `watch-list` say where a fired job stands (delivery waiting, running, retrying or dead) instead of listing it as still to come. A triggered watch whose delivery was queued read "EXPIRED (fires on next poll)" (21 Sep), and a fired 2028 reminder still read "fires 2028-08-01" (17 Sep).
50
+ - `open-claudia say` and the send-file/voice/photo routes now say which step failed when the channel adapter refuses the upload, instead of a bare `loopback 500: {"ok":false}` (10 Sep). The adapter's own error stays in bot.log.
51
+ - Release gate: `test-provider-pack-review.js` gives each probe child 120 s instead of 30 s and reports a timeout as a timeout rather than "failed (null)" (v3.4.19, job 43703). The parity suite's cap for it rises to 8 minutes so it cannot undercut its children.
52
+
3
53
  ## 3.4.23
4
54
 
5
55
  - Watch, wakeup and cron turns now stream their progress (interim narration, and thinking under `/verbose`) like chat turns. The live preview was gated to chat turns only, so a watch-woken turn showed the bell notice and "thinking…" and then nothing until its final answer, however long it worked.
package/bin/agent.js CHANGED
@@ -58,6 +58,10 @@ async function run(args) {
58
58
  if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
59
59
  console.log(HELP); process.exit(args[0] === "--help" || args[0] === "help" ? 0 : 2);
60
60
  }
61
+ // A sub-agent is an unguarded agent with a shell: only the owner's own run
62
+ // may start one. Decided by the running bot from the run's attestation
63
+ // (bin/owner-gate), never from this process's env.
64
+ if (!(await require("./owner-gate").requireOwner("starting a sub-agent"))) return;
61
65
  let parsed;
62
66
  try { parsed = parseAgentArgs(args); }
63
67
  catch (error) { console.error(error.message); process.exit(2); return; }
package/bin/cli.js CHANGED
@@ -146,7 +146,16 @@ switch (command) {
146
146
  const res = runHook(payload);
147
147
  if (res.stderr) console.error(res.stderr);
148
148
  code = res.exit;
149
- } catch (e) { code = 0; }
149
+ } catch (e) {
150
+ // The guard itself failed (it would not load, or stdin was unreadable).
151
+ // On a non-owner's turn that must not wave the tool through: the runner
152
+ // marks such runs OC_GUARDED_RUN=1 in the agent's env, which the hook
153
+ // inherits from Claude Code and the agent's own shell cannot change.
154
+ if (process.env.OC_GUARDED_RUN === "1") {
155
+ console.error("⛔ The permission check could not run, so this is refused on a non-owner's turn.");
156
+ code = 2;
157
+ } else code = 0;
158
+ }
150
159
  process.exit(code);
151
160
  break;
152
161
  }
package/bin/entity.js CHANGED
@@ -6,15 +6,26 @@
6
6
  // open-claudia entity persona <name> [--relationship owner|trusted|external] [--role ..] [--style ..] [--knows ..] [--mandate ..]
7
7
  // open-claudia entity remove <slug-or-name>
8
8
  // open-claudia entity reindex
9
+ //
10
+ // note/persona/remove/reindex change the notes (and persona mandates) the bot
11
+ // acts on: owner-only, decided by the running bot from the run's attestation
12
+ // (bin/owner-gate), never from OC_CHANNEL_* env. list/show/match are reads.
9
13
 
10
14
  const fs = require("fs");
11
15
  const path = require("path");
12
16
  const entities = require("../core/entities");
13
17
 
14
- function run(args) {
18
+ const OWNER_ONLY_VERBS = new Set(["note", "persona", "remove", "reindex"]);
19
+
20
+ async function run(args) {
15
21
  const cmd = (args[0] || "list").toLowerCase();
16
22
  const rest = args.slice(1);
17
23
 
24
+ if (OWNER_ONLY_VERBS.has(cmd)) {
25
+ const what = cmd === "persona" ? "setting a persona/mandate" : `"entity ${cmd}" changes the bot's notes`;
26
+ if (!(await require("./owner-gate").requireOwner(what))) return;
27
+ }
28
+
18
29
  switch (cmd) {
19
30
  case "list": {
20
31
  const all = entities.listEntities();
@@ -73,20 +84,11 @@ function run(args) {
73
84
  // is cleared to do/discuss with that person. Each flag replaces its section
74
85
  // wholesale; omit a flag to leave it unchanged.
75
86
  //
76
- // OWNER-ONLY: setting a mandate/relationship is a privilege operation, so
77
- // an external speaker must never reach it (self-escalation). No guarded
78
- // speaker (owner turn, CLI, or cron) may proceed. Note: relationship
79
- // "owner" set here is still coerced to "external" at runtime for a non-
80
- // owner person (R1) — a note can never confer owner powers.
81
- try {
82
- const relationship = require("../core/relationship");
83
- const sp = relationship.speakerFor(process.env.OC_CHANNEL_ADAPTER, process.env.OC_CHANNEL_ID, process.env.OC_CHANNEL_USER_ID);
84
- if (relationship.guardActive(sp)) {
85
- console.error("⛔ Only the owner can set a persona/mandate.");
86
- process.exitCode = 3; return;
87
- }
88
- } catch (e) { /* no context → owner-equivalent, allowed */ }
89
-
87
+ // OWNER-ONLY (gated above): setting a mandate/relationship is a privilege
88
+ // operation, so nobody but the owner may reach it (self-escalation) — a
89
+ // guarded turn and an unattributed process alike are refused. Note:
90
+ // relationship "owner" set here is still coerced to "external" at runtime
91
+ // for a non-owner person (R1) — a note can never confer owner powers.
90
92
  const takeFlag = (flag) => {
91
93
  const i = rest.indexOf(flag);
92
94
  if (i === -1) return null;
package/bin/ideas.js CHANGED
@@ -4,14 +4,23 @@
4
4
  // open-claudia ideas remove <id|text> — remove one
5
5
  // open-claudia ideas show — raw file
6
6
  // open-claudia ideas path — print the file path
7
+ //
8
+ // add/remove change the backlog the nightly dream works from: owner-only,
9
+ // decided by the running bot from the run's attestation (bin/owner-gate).
7
10
 
8
11
  const fs = require("fs");
9
12
  const ideas = require("../core/ideas");
10
13
 
11
- function run(args) {
14
+ const OWNER_ONLY_VERBS = new Set(["add", "remove", "rm"]);
15
+
16
+ async function run(args) {
12
17
  const cmd = (args[0] || "list").toLowerCase();
13
18
  const rest = args.slice(1);
14
19
 
20
+ if (OWNER_ONLY_VERBS.has(cmd)) {
21
+ if (!(await require("./owner-gate").requireOwner(`"ideas ${cmd}" changes the bot's ideas backlog`))) return;
22
+ }
23
+
15
24
  switch (cmd) {
16
25
  case "list": {
17
26
  const all = ideas.listIdeas();
package/bin/keyring.js CHANGED
@@ -15,29 +15,30 @@
15
15
  //
16
16
  // For genuine personal secrets the bot should NOT use unattended, use the
17
17
  // vault (/vault) instead — it stays encrypted and locked.
18
+ //
19
+ // Owner only, every verb (exec and list included): the caller must be a live
20
+ // run the bot attributes to the owner.
18
21
 
19
22
  const keyring = require("../core/keyring");
20
23
 
21
- function run(args) {
24
+ async function run(args) {
22
25
  // The keyring is owner infrastructure wholesale: exec runs an arbitrary
23
26
  // command with every credential in env, get prints raw values, set/remove
24
- // mutate them. No external request ever needs any of it — and the Bash
25
- // control-plane gate exempts `keyring exec` as an authorised channel, so
26
- // the speaker gate must live here, on the authorised side. Same fail
27
- // directions as the file gate: dormant without channel env (owner-local),
28
- // denied when resolution fails.
29
- const speaker = require("../core/tool-guard").guardedSpeakerFromEnv();
30
- if (speaker) {
31
- try {
32
- require("../core/tool-guard").logGuardEvent({
33
- kind: "deny", rule: "external-keyring", command: `keyring ${args.join(" ")}`.slice(0, 300),
34
- });
35
- } catch (e) { /* logging is best-effort */ }
36
- console.error(`⛔ External-speaker guard: the keyring is owner-only — this turn's speaker${speaker.name ? ` (${speaker.name})` : ""} is not the owner.`);
37
- console.error("Describe what you need instead; write-tier tool runs escalate to the owner for approval.");
38
- process.exitCode = 1;
39
- return;
40
- }
27
+ // mutate them, and even list/path map what exists. No other request ever
28
+ // needs any of it — and the Bash control-plane gate exempts `keyring exec`
29
+ // as an authorised channel, so the gate must live here, on the authorised
30
+ // side. EVERY verb needs an attributed owner: whether this process acts for
31
+ // the owner is answered by the running bot from the run's attestation
32
+ // (bin/owner-gate), never from OC_CHANNEL_* env — a stripped or rewritten
33
+ // env, and a process with no live run behind it, are refused.
34
+ const owner = await require("./owner-gate").requireOwner("the keyring", {
35
+ exitCode: 1,
36
+ onRefuse: (caller) => require("../core/tool-guard").logGuardEvent({
37
+ kind: "deny", rule: "external-keyring", caller: caller && caller.attributed ? "guarded" : "unattributed",
38
+ command: `keyring ${args.join(" ")}`.slice(0, 300),
39
+ }),
40
+ });
41
+ if (!owner) return;
41
42
  const cmd = (args[0] || "list").toLowerCase();
42
43
  const rest = args.slice(1);
43
44
 
package/bin/lessons.js CHANGED
@@ -4,14 +4,23 @@
4
4
  // open-claudia lessons remove <id|text> — remove one
5
5
  // open-claudia lessons show — raw file + injected block
6
6
  // open-claudia lessons path — print the file path
7
+ //
8
+ // add/remove change rules loaded into EVERY conversation: owner-only, decided
9
+ // by the running bot from the run's attestation (bin/owner-gate).
7
10
 
8
11
  const fs = require("fs");
9
12
  const lessons = require("../core/lessons");
10
13
 
11
- function run(args) {
14
+ const OWNER_ONLY_VERBS = new Set(["add", "remove", "rm"]);
15
+
16
+ async function run(args) {
12
17
  const cmd = (args[0] || "list").toLowerCase();
13
18
  const rest = args.slice(1);
14
19
 
20
+ if (OWNER_ONLY_VERBS.has(cmd)) {
21
+ if (!(await require("./owner-gate").requireOwner(`"lessons ${cmd}" changes the bot's always-loaded rules`))) return;
22
+ }
23
+
15
24
  switch (cmd) {
16
25
  case "list": {
17
26
  const all = lessons.listLessons();
@@ -46,6 +46,9 @@ function context() {
46
46
  // Runner-minted HMAC binding canonicalUserId to what the bot stamped for
47
47
  // this turn; the loopback's owner gates verify it (see core/speaker-attest).
48
48
  speakerAttest: process.env.OC_SPEAKER_ATTEST || null,
49
+ // Runner-minted attest for THIS run; the bot answers who the run acts
50
+ // for only while the run is live (see core/speaker-attest registerRun).
51
+ runAttest: process.env.OC_RUN_ATTEST || null,
49
52
  provider: process.env.OC_PROVIDER || null,
50
53
  providerSettings,
51
54
  project: process.env.OC_PROJECT || null,
@@ -81,6 +84,7 @@ function postJson(kind, body) {
81
84
  userId: ctx.userId,
82
85
  canonicalUserId: ctx.canonicalUserId,
83
86
  speakerAttest: ctx.speakerAttest,
87
+ runAttest: ctx.runAttest,
84
88
  provider: ctx.provider,
85
89
  providerSettings: ctx.providerSettings,
86
90
  project: requestedProject,
@@ -127,4 +131,45 @@ function postJson(kind, body) {
127
131
  });
128
132
  }
129
133
 
130
- module.exports = { context, requireContext, postJson, readLoopbackInfo };
134
+ // Who this process acts for, as the RUNNING BOT decides it. Needs no chat
135
+ // context: a process with no live run behind it (stripped env, detached from
136
+ // a finished turn, started outside the bot) comes back unattributed, and so
137
+ // does every failure to ask. Unattributed is never the owner.
138
+ const UNATTRIBUTED = Object.freeze({ attributed: false, owner: false, guarded: true, kind: "unattributed" });
139
+
140
+ function whoami({ timeoutMs = 5000 } = {}) {
141
+ const ctx = context();
142
+ if (!ctx.runId || !ctx.runAttest || !ctx.sendUrl || !ctx.sendToken) return Promise.resolve(UNATTRIBUTED);
143
+ let u;
144
+ try { u = new URL(ctx.sendUrl); } catch (_) { return Promise.resolve(UNATTRIBUTED); }
145
+ const payload = JSON.stringify({ runId: ctx.runId, originRunId: ctx.runId, runAttest: ctx.runAttest });
146
+ return new Promise((resolve) => {
147
+ const req = http.request({
148
+ method: "POST",
149
+ hostname: u.hostname,
150
+ port: u.port,
151
+ path: "/whoami",
152
+ timeout: timeoutMs,
153
+ headers: {
154
+ Authorization: `Bearer ${ctx.sendToken}`,
155
+ "Content-Type": "application/json",
156
+ "Content-Length": Buffer.byteLength(payload),
157
+ },
158
+ }, (res) => {
159
+ let out = "";
160
+ res.on("data", (c) => { out += c; });
161
+ res.on("end", () => {
162
+ try {
163
+ const caller = res.statusCode === 200 ? JSON.parse(out).caller : null;
164
+ resolve(caller && caller.attributed === true ? caller : UNATTRIBUTED);
165
+ } catch (_) { resolve(UNATTRIBUTED); }
166
+ });
167
+ });
168
+ req.on("timeout", () => { req.destroy(); resolve(UNATTRIBUTED); });
169
+ req.on("error", () => resolve(UNATTRIBUTED));
170
+ req.write(payload);
171
+ req.end();
172
+ });
173
+ }
174
+
175
+ module.exports = { context, requireContext, postJson, readLoopbackInfo, whoami, UNATTRIBUTED };
@@ -0,0 +1,38 @@
1
+ // Owner-only CLI verbs (permission Phase 0). Whether a process acts for the
2
+ // owner is decided by the RUNNING BOT (loopback whoami over OC_RUN_ID +
3
+ // OC_RUN_ATTEST), never by this process's own env: OC_CHANNEL_* and
4
+ // OC_CANONICAL_USER_ID are ignored, and a process with no live run behind it
5
+ // (a stripped env, a detached leftover, a plain terminal) is unattributed —
6
+ // which is never the owner.
7
+
8
+ const { whoami } = require("./loopback-client");
9
+
10
+ function hasRunAttest(env = process.env) {
11
+ return !!(env.OC_RUN_ID && env.OC_RUN_ATTEST);
12
+ }
13
+
14
+ function refusal(what, caller) {
15
+ const why = !(caller && caller.attributed) ? "this process is not attributed to an owner chat turn"
16
+ : caller.name ? `this turn acts for ${caller.name}, not the owner`
17
+ : "this turn does not act for the owner";
18
+ return `⛔ Owner only: ${what} — ${why}.`;
19
+ }
20
+
21
+ // Resolves the caller when it is an attributed owner, else prints one refusal
22
+ // line, sets process.exitCode and resolves null. With no run attestation at
23
+ // all the refusal happens synchronously (before the first await), so callers
24
+ // that do not await still see the exit code at once.
25
+ async function requireOwner(what, { exitCode = 3, onRefuse = null } = {}) {
26
+ const refuse = (caller) => {
27
+ if (typeof onRefuse === "function") { try { onRefuse(caller); } catch (_) { /* best-effort */ } }
28
+ console.error(refusal(what, caller));
29
+ process.exitCode = exitCode;
30
+ return null;
31
+ };
32
+ if (!hasRunAttest()) return refuse({ attributed: false });
33
+ const caller = await whoami();
34
+ if (caller && caller.attributed === true && caller.owner === true) return caller;
35
+ return refuse(caller);
36
+ }
37
+
38
+ module.exports = { requireOwner, hasRunAttest, refusal };
package/bin/pack.js CHANGED
@@ -6,15 +6,25 @@
6
6
  // open-claudia pack migrate — fold ~/.claude/skills into packs
7
7
  // open-claudia pack remove <dir>
8
8
  // open-claudia pack reindex
9
+ //
10
+ // skill/kind/archive/restore/remove/reindex/migrate change the packs the bot
11
+ // works from: owner-only, decided by the running bot from the run's
12
+ // attestation (bin/owner-gate). The listing/show/match/search verbs are reads.
9
13
 
10
14
  const fs = require("fs");
11
15
  const path = require("path");
12
16
  const packs = require("../core/packs");
13
17
 
14
- function run(args) {
18
+ const OWNER_ONLY_VERBS = new Set(["skill", "kind", "archive", "restore", "remove", "reindex", "migrate"]);
19
+
20
+ async function run(args) {
15
21
  const cmd = (args[0] || "list").toLowerCase();
16
22
  const rest = args.slice(1);
17
23
 
24
+ if (OWNER_ONLY_VERBS.has(cmd)) {
25
+ if (!(await require("./owner-gate").requireOwner(`"pack ${cmd}" changes the bot's packs`))) return;
26
+ }
27
+
18
28
  switch (cmd) {
19
29
  case "list": {
20
30
  const all = packs.listPacks();
package/bin/schedule.js CHANGED
@@ -8,7 +8,8 @@ Background scheduling — manage wakeups and crons.
8
8
 
9
9
  Wakeups (one-shot, resumes the same conversation):
10
10
  open-claudia schedule-wakeup <when> "<prompt>" [--label "<label>"]
11
- <when>: duration (e.g. 30s, 5m, 2h, 1d) OR an ISO datetime (e.g. 2026-05-16T15:00:00Z)
11
+ <when>: duration (e.g. 30s, 5m, 2h, 1d) OR an ISO datetime (e.g. 2026-05-16T15:00:00Z),
12
+ at most 10 years out
12
13
 
13
14
  Crons (recurring):
14
15
  open-claudia cron-add "<5-field cron>" "<prompt>" [--label "<label>"] [--project <name>]
@@ -25,6 +26,8 @@ Watches (silent background monitor — polls a shell check, wakes you only on a
25
26
  datetime. Default 2h. A watch that reaches its timeout without
26
27
  the check ever passing FIRES anyway on the next poll, with the
27
28
  prompt marked "Watch expired" — no watch can end silently.
29
+ A check that times out (30s) 3 times in a row wakes the agent once, marked
30
+ "Watch check timing out"; the watch keeps polling.
28
31
  open-claudia watch-list # watches on this channel + master switch state
29
32
  open-claudia watch-remove <id> # remove a watch by id
30
33
  open-claudia watch-on / watch-off # global master switch for ALL watches (owner)
@@ -37,6 +40,10 @@ switching providers starts a fresh thread.
37
40
  `;
38
41
 
39
42
  const DUR = /^(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)$/i;
43
+ // Mirrors core/scheduler MAX_WAKEUP_HORIZON_MS: a wakeup further out than this
44
+ // is a typo or a mis-parsed <when> (one was set 1273 years out).
45
+ const MAX_WAKEUP_YEARS = 10;
46
+ const MAX_WAKEUP_HORIZON_MS = MAX_WAKEUP_YEARS * 365.25 * 24 * 60 * 60 * 1000;
40
47
 
41
48
  function parseWhen(s) {
42
49
  if (!s) return null;
@@ -63,13 +70,41 @@ function takeFlag(args, name) {
63
70
  return v;
64
71
  }
65
72
 
73
+ // Where a job stands once it has fired. A fired job used to stay listed as a
74
+ // future one ("fires 2028-08-01", "EXPIRED (fires on next poll)") while its
75
+ // delivery was queued, running or dead (17 and 21 Sep).
76
+ function jobProgress(j) {
77
+ const since = j.check && j.watchTriggeredOccurrenceId && j.watchTriggeredOccurrenceId === j.occurrenceId && Number(j.watchTriggeredAt) > 0
78
+ ? `TRIGGERED ${new Date(Number(j.watchTriggeredAt)).toISOString()}, ` : "";
79
+ const state = {
80
+ running: "delivery running",
81
+ deferred: "delivery waiting for a busy room",
82
+ failed: `delivery failed (${j.lastError?.code || "error"}), retrying`,
83
+ "dead-letter": `delivery failed (${j.lastError?.code || "error"}), DEAD: will not fire`,
84
+ }[j.status];
85
+ return state ? `${since}${state}` : "";
86
+ }
87
+
66
88
  function fmtJob(j) {
67
89
  const when = j.kind === "wakeup"
68
90
  ? `fires ${new Date(j.fireAt).toISOString()}`
69
91
  : `schedule "${j.schedule}"`;
70
92
  const proj = j.project ? ` [${j.project}]` : "";
71
93
  const src = j.source ? ` (${j.source})` : "";
72
- return `${j.id} ${j.kind} ${j.label || "(no label)"}${proj}${src} ${when}`;
94
+ const progress = jobProgress(j);
95
+ return `${j.id} ${j.kind} ${j.label || "(no label)"}${proj}${src} ${when}${progress ? ` [${progress}]` : ""}`;
96
+ }
97
+
98
+ function fmtWatch(w, now = Date.now()) {
99
+ const mode = w.repeat ? "repeating" : "one-shot";
100
+ const progress = jobProgress(w);
101
+ const expiry = progress ? ` ${progress}`
102
+ : Number(w.expiresAt) > 0
103
+ ? (now >= Number(w.expiresAt)
104
+ ? " EXPIRED (fires on next poll)"
105
+ : ` expires ${new Date(Number(w.expiresAt)).toISOString()}`)
106
+ : " no expiry (pre-timeout watch)";
107
+ return `${w.id} ${mode} polls "${w.schedule}"${expiry} check: ${w.check} → ${w.label || "(no label)"}`;
73
108
  }
74
109
 
75
110
  async function runScheduleWakeup(args) {
@@ -82,6 +117,11 @@ async function runScheduleWakeup(args) {
82
117
  console.error(`Could not parse <when>: "${rest[0]}". Use a duration (5m, 2h, 1d) or ISO datetime.`);
83
118
  process.exit(2);
84
119
  }
120
+ if (when - Date.now() > MAX_WAKEUP_HORIZON_MS) {
121
+ const at = new Date(when);
122
+ console.error(`<when> "${rest[0]}" is ${Number.isNaN(at.getTime()) ? "past any valid date" : at.toISOString()}, more than ${MAX_WAKEUP_YEARS} years out. Check the date; for a far reminder use a yearly cron-add.`);
123
+ process.exit(2);
124
+ }
85
125
  const prompt = rest.slice(1).join(" ");
86
126
  if (!prompt) { console.error("Missing prompt."); process.exit(2); }
87
127
  try {
@@ -178,15 +218,7 @@ async function runWatchList() {
178
218
  const list = res.watches || [];
179
219
  console.log(`Watches: master switch is ${res.enabled ? "ON" : "OFF"}.`);
180
220
  if (list.length === 0) { console.log("No watches on this channel."); process.exit(0); }
181
- for (const w of list) {
182
- const mode = w.repeat ? "repeating" : "one-shot";
183
- const expiry = Number(w.expiresAt) > 0
184
- ? (Date.now() >= Number(w.expiresAt)
185
- ? " EXPIRED (fires on next poll)"
186
- : ` expires ${new Date(Number(w.expiresAt)).toISOString()}`)
187
- : " no expiry (pre-timeout watch)";
188
- console.log(`${w.id} ${mode} polls "${w.schedule}"${expiry} check: ${w.check} → ${w.label || "(no label)"}`);
189
- }
221
+ for (const w of list) console.log(fmtWatch(w));
190
222
  process.exit(0);
191
223
  } catch (e) { console.error(e.message); process.exit(1); }
192
224
  }
@@ -211,6 +243,8 @@ async function runWatchToggle(enabled) {
211
243
 
212
244
  module.exports = {
213
245
  HELP,
246
+ fmtJob,
247
+ fmtWatch,
214
248
  runScheduleWakeup,
215
249
  runCronAdd,
216
250
  runCronList,