@phnx-labs/agents-cli 1.20.63 → 1.20.64

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +9 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +55 -28
  5. package/dist/commands/feed.d.ts +4 -0
  6. package/dist/commands/feed.js +27 -8
  7. package/dist/commands/lease.d.ts +23 -0
  8. package/dist/commands/lease.js +201 -0
  9. package/dist/commands/mailboxes.d.ts +20 -0
  10. package/dist/commands/mailboxes.js +390 -0
  11. package/dist/commands/routines.js +20 -14
  12. package/dist/commands/sessions-export.d.ts +2 -0
  13. package/dist/commands/sessions-export.js +279 -0
  14. package/dist/commands/sessions-import.d.ts +2 -0
  15. package/dist/commands/sessions-import.js +230 -0
  16. package/dist/commands/sessions.js +4 -0
  17. package/dist/commands/ssh.js +98 -3
  18. package/dist/commands/usage.d.ts +2 -0
  19. package/dist/commands/usage.js +7 -2
  20. package/dist/commands/view.d.ts +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/lib/agents.d.ts +18 -0
  23. package/dist/lib/agents.js +27 -17
  24. package/dist/lib/browser/drivers/ssh.js +19 -2
  25. package/dist/lib/comms-render.d.ts +37 -0
  26. package/dist/lib/comms-render.js +89 -0
  27. package/dist/lib/crabbox/cli.d.ts +72 -0
  28. package/dist/lib/crabbox/cli.js +158 -9
  29. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  30. package/dist/lib/crabbox/runtimes.js +24 -0
  31. package/dist/lib/daemon.js +6 -1
  32. package/dist/lib/devices/health.d.ts +77 -0
  33. package/dist/lib/devices/health.js +186 -0
  34. package/dist/lib/mailbox.d.ts +39 -0
  35. package/dist/lib/mailbox.js +112 -0
  36. package/dist/lib/paths.d.ts +13 -0
  37. package/dist/lib/paths.js +26 -4
  38. package/dist/lib/routines.d.ts +21 -2
  39. package/dist/lib/routines.js +35 -12
  40. package/dist/lib/runner.js +255 -13
  41. package/dist/lib/sandbox.d.ts +9 -1
  42. package/dist/lib/sandbox.js +11 -2
  43. package/dist/lib/session/bundle.d.ts +150 -0
  44. package/dist/lib/session/bundle.js +189 -0
  45. package/dist/lib/session/remote-bundle.d.ts +12 -0
  46. package/dist/lib/session/remote-bundle.js +61 -0
  47. package/dist/lib/session/sync/agents.d.ts +54 -6
  48. package/dist/lib/session/sync/agents.js +0 -0
  49. package/dist/lib/session/sync/manifest.d.ts +14 -3
  50. package/dist/lib/session/sync/manifest.js +4 -0
  51. package/dist/lib/session/sync/sync.d.ts +23 -2
  52. package/dist/lib/session/sync/sync.js +177 -74
  53. package/dist/lib/ssh-tunnel.js +13 -1
  54. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  55. package/dist/lib/staleness/detectors/subagents.js +5 -192
  56. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  57. package/dist/lib/staleness/writers/subagents.js +11 -102
  58. package/dist/lib/startup/command-registry.d.ts +2 -0
  59. package/dist/lib/startup/command-registry.js +5 -0
  60. package/dist/lib/subagents-registry.d.ts +85 -0
  61. package/dist/lib/subagents-registry.js +393 -0
  62. package/dist/lib/subagents.d.ts +8 -8
  63. package/dist/lib/subagents.js +32 -663
  64. package/dist/lib/sync-umbrella.d.ts +1 -0
  65. package/dist/lib/sync-umbrella.js +14 -3
  66. package/dist/lib/types.d.ts +9 -0
  67. package/dist/lib/usage.d.ts +42 -3
  68. package/dist/lib/usage.js +162 -22
  69. package/package.json +1 -1
@@ -0,0 +1,390 @@
1
+ import * as os from 'os';
2
+ import * as path from 'path';
3
+ import chalk from 'chalk';
4
+ import { die, humanDuration, relTime, truncate, visibleWidth } from '../lib/format.js';
5
+ import { listBoxes, readBox, mailboxDir, isValidMailboxId, watchMessages, } from '../lib/mailbox.js';
6
+ import { GLYPH, masthead, sparkline, aggregate, hourlyCounts, graphEdges, } from '../lib/comms-render.js';
7
+ import { getMailboxRootDir } from '../lib/state.js';
8
+ import { getActiveSessions } from '../lib/session/active.js';
9
+ import { mailboxIdForActiveSession } from '../lib/mailbox-target.js';
10
+ /** Short, human label for a box id — the live session's topic when running, else the id stem. */
11
+ function labelForBox(id, byMailbox) {
12
+ const s = byMailbox.get(id);
13
+ if (s) {
14
+ const topic = s.name || s.topic || s.label;
15
+ return { label: topic ? truncate(topic, 40) : id.slice(0, 8), live: true };
16
+ }
17
+ return { label: id.slice(0, 8), live: false };
18
+ }
19
+ /** Build the per-box views, resolving live-session labels once. */
20
+ async function collectBoxes(root) {
21
+ let sessions = [];
22
+ try {
23
+ sessions = await getActiveSessions();
24
+ }
25
+ catch {
26
+ // Label enrichment is best-effort; a box with no live session still lists.
27
+ }
28
+ const byMailbox = new Map();
29
+ for (const s of sessions) {
30
+ const mid = mailboxIdForActiveSession(s);
31
+ if (mid)
32
+ byMailbox.set(mid, s);
33
+ }
34
+ return listBoxes(root).map((id) => {
35
+ const { label, live } = labelForBox(id, byMailbox);
36
+ return { id, label, live, messages: readBox(mailboxDir(id, root)) };
37
+ });
38
+ }
39
+ function pending(box) {
40
+ return box.messages.filter((m) => m.state !== 'consumed').length;
41
+ }
42
+ /** `from` label for a message — agents stamp `claude/<slug>`; operator sends may omit it. */
43
+ function senderOf(m) {
44
+ return m.from || 'operator';
45
+ }
46
+ const STATE_TAG = {
47
+ inbox: chalk.yellow('pending'),
48
+ processing: chalk.cyan('in-flight'),
49
+ consumed: chalk.dim('delivered'),
50
+ };
51
+ /**
52
+ * The watching agent's own box id. Spawn wiring (`buildExecEnv`,
53
+ * lib/exec.ts) hands every agent `AGENTS_MAILBOX_DIR` keyed by its box, so
54
+ * `basename` is the id that resolves to "you". Unset for a human operator at
55
+ * a plain terminal — nothing in the spool is addressed to them.
56
+ */
57
+ function selfMailboxId() {
58
+ const dir = process.env.AGENTS_MAILBOX_DIR;
59
+ if (!dir)
60
+ return undefined;
61
+ const id = path.basename(dir.replace(/[/\\]+$/, ''));
62
+ return id || undefined;
63
+ }
64
+ /** Parse `--since`: relative offsets (30s/5m/2h/7d/4w) or an ISO/absolute date. Returns epoch ms. */
65
+ function parseSinceArg(s) {
66
+ const m = s.match(/^(\d+)([smhdw])$/);
67
+ if (m) {
68
+ const n = parseInt(m[1], 10);
69
+ const unitMs = {
70
+ s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000,
71
+ };
72
+ return Date.now() - n * unitMs[m[2]];
73
+ }
74
+ const ms = Date.parse(s);
75
+ if (Number.isNaN(ms))
76
+ die(`Invalid --since value: ${JSON.stringify(s)} (use e.g. 2h, 7d, or an ISO date).`);
77
+ return ms;
78
+ }
79
+ function buildFilters(opts) {
80
+ return {
81
+ from: opts.from,
82
+ to: opts.to,
83
+ sinceMs: opts.since ? parseSinceArg(opts.since) : undefined,
84
+ };
85
+ }
86
+ function hasFilters(f) {
87
+ return Boolean(f.from || f.to || f.sinceMs != null);
88
+ }
89
+ /** Sender substring on `from`, recipient substring on box id/label, recency cutoff — all case-insensitive. */
90
+ function matchesFilters(msg, toLabel, boxId, f) {
91
+ if (f.sinceMs != null) {
92
+ const t = Date.parse(msg.ts);
93
+ if (Number.isNaN(t) || t < f.sinceMs)
94
+ return false;
95
+ }
96
+ if (f.from && !msg.from.toLowerCase().includes(f.from.toLowerCase()))
97
+ return false;
98
+ if (f.to && !toLabel.toLowerCase().includes(f.to.toLowerCase()) && !boxId.toLowerCase().includes(f.to.toLowerCase()))
99
+ return false;
100
+ return true;
101
+ }
102
+ /** HH:MM:SS local wall-clock for the --watch stream; unparseable stamps render as dashes. */
103
+ function clockTime(iso) {
104
+ const d = new Date(iso);
105
+ if (Number.isNaN(d.getTime()))
106
+ return '--:--:--';
107
+ const p = (n) => String(n).padStart(2, '0');
108
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
109
+ }
110
+ function renderOverview(boxes, limit, filters) {
111
+ const nonEmpty = boxes.filter((b) => b.messages.length > 0);
112
+ if (nonEmpty.length === 0) {
113
+ console.log(chalk.dim('No mailboxes with messages. Boxes are created on the first `agents message`.'));
114
+ return;
115
+ }
116
+ // 1. Masthead + 24h volume sparkline over the whole spool.
117
+ const msgs = aggregate(nonEmpty);
118
+ const live = boxes.filter((b) => b.live).length;
119
+ const awaiting = msgs.filter((m) => m.state !== 'consumed').length;
120
+ console.log(masthead({
121
+ title: 'fleet comms',
122
+ host: os.hostname(),
123
+ accent: 'cyan',
124
+ right: `${live} live · ${boxes.length} boxes`,
125
+ stats: [`${msgs.length} messages`, `${awaiting} awaiting delivery`, `last ${relTime(msgs[0].ts)}`],
126
+ }));
127
+ console.log(` ${chalk.dim('24h')} ${chalk.cyan(sparkline(hourlyCounts(msgs, 24)))}`);
128
+ console.log();
129
+ // 2. Box summary — one row per box that has ever held mail.
130
+ const rows = [...nonEmpty].sort((a, b) => lastTs(b) - lastTs(a));
131
+ for (const box of rows) {
132
+ const p = pending(box);
133
+ const dot = box.live ? chalk.green(GLYPH.live) : chalk.dim(GLYPH.idle);
134
+ const counts = [
135
+ p > 0 ? chalk.yellow(`${p} pending`) : null,
136
+ chalk.dim(`${box.messages.length} total`),
137
+ ].filter(Boolean).join(chalk.dim(' · '));
138
+ const last = box.messages.length ? chalk.dim(relTime(newestMessage(box).ts)) : '';
139
+ console.log(` ${dot} ${chalk.bold(box.label.padEnd(40))} ${counts} ${last}`);
140
+ console.log(` ${chalk.dim(box.id)}`);
141
+ }
142
+ // 3. Recent cross-box message log — the agent-to-agent chatter, newest first.
143
+ const all = msgs
144
+ .filter((m) => matchesFilters(m, m.toLabel, m.box, filters))
145
+ .slice(0, limit);
146
+ console.log();
147
+ console.log(chalk.bold(`Recent messages`) + chalk.dim(` (${all.length})`));
148
+ if (all.length === 0) {
149
+ console.log(chalk.dim(' Nothing matches the current filters.'));
150
+ }
151
+ for (const m of all) {
152
+ const when = chalk.dim(relTime(m.ts).padStart(10));
153
+ const route = `${chalk.magenta(truncate(m.from, 24))} ${chalk.dim(GLYPH.route)} ${chalk.cyan(truncate(m.toLabel, 40))}`;
154
+ console.log(` ${when} ${STATE_TAG[m.state]} ${route}`);
155
+ console.log(` ${truncate(m.text.replace(/\s+/g, ' ').trim(), 100)}`);
156
+ }
157
+ console.log();
158
+ console.log(chalk.dim('Tip: `agents mailboxes <id>` one box · `--watch` live tail · `--between <a> <b>` thread · `--graph` routes · `--json` machine output'));
159
+ }
160
+ /**
161
+ * --watch: the money shot. Stream every new cross-box message as it lands,
162
+ * resolved to live labels; a message addressed to the watching agent's own
163
+ * box (AGENTS_MAILBOX_DIR) renders as `▲ you` so an orchestrator sees its
164
+ * replies light up. ⌃C aborts the poller cleanly via AbortController.
165
+ */
166
+ async function runWatch(root, opts) {
167
+ const boxes = await collectBoxes(root);
168
+ const labels = new Map(boxes.map((b) => [b.id, b.label]));
169
+ const self = selfMailboxId();
170
+ const controller = new AbortController();
171
+ const onSigint = () => controller.abort();
172
+ process.on('SIGINT', onSigint);
173
+ try {
174
+ if (!opts.json) {
175
+ console.log(masthead({
176
+ title: 'fleet comms',
177
+ host: os.hostname(),
178
+ accent: 'cyan',
179
+ right: chalk.green(GLYPH.live) + chalk.dim(' live'),
180
+ stats: ['watching — ⌃C to stop'],
181
+ }));
182
+ }
183
+ // --since backfills: the watcher replays existing mail and the recency
184
+ // filter keeps only the requested window, then the tail continues live.
185
+ for await (const m of watchMessages(root, {
186
+ signal: controller.signal,
187
+ backfill: opts.filters.sinceMs != null,
188
+ })) {
189
+ const toLabel = labels.get(m.box) ?? m.toLabel;
190
+ if (!matchesFilters(m, toLabel, m.box, opts.filters))
191
+ continue;
192
+ const msg = { ...m, toLabel };
193
+ if (opts.json) {
194
+ console.log(JSON.stringify(msg));
195
+ continue;
196
+ }
197
+ const addressedToYou = self != null && m.to === self;
198
+ const to = addressedToYou
199
+ ? chalk.yellow(`${GLYPH.ask} you`)
200
+ : chalk.cyan(truncate(toLabel, 24));
201
+ const text = truncate(m.text.replace(/\s+/g, ' ').trim(), 100);
202
+ console.log(`${chalk.dim(clockTime(m.ts))} ${chalk.magenta(truncate(m.from, 24))} ${chalk.dim(GLYPH.stream)} ${to} ${text}`);
203
+ }
204
+ }
205
+ finally {
206
+ process.removeListener('SIGINT', onSigint);
207
+ }
208
+ }
209
+ /** Sender stamp match: `from` is freeform, so match the counterpart's full id, an id prefix, or its resolved label. */
210
+ function senderIsBox(from, box) {
211
+ if (from === box.id)
212
+ return true;
213
+ if (from.length >= 4 && box.id.startsWith(from))
214
+ return true;
215
+ return from.toLowerCase() === box.label.toLowerCase();
216
+ }
217
+ function renderBetween(boxes, a, b, json) {
218
+ const resolveBox = (q) => {
219
+ const found = boxes.find((x) => x.id === q)
220
+ ?? boxes.find((x) => x.id.startsWith(q))
221
+ ?? boxes.find((x) => x.label.toLowerCase().includes(q.toLowerCase()));
222
+ if (!found)
223
+ die(`No mailbox matching ${JSON.stringify(q)}. Run \`agents mailboxes\` to list boxes.`);
224
+ return found;
225
+ };
226
+ const boxA = resolveBox(a);
227
+ const boxB = resolveBox(b);
228
+ if (boxA.id === boxB.id)
229
+ die('--between needs two different boxes.');
230
+ // Both directions, each stamped with its route so the thread reads chronologically.
231
+ const thread = [
232
+ ...boxB.messages.filter((m) => senderIsBox(senderOf(m), boxA)).map((m) => ({
233
+ from: senderOf(m), to: boxB.id, toLabel: boxB.label, ts: m.ts, text: m.text, state: m.state, box: boxB.id,
234
+ })),
235
+ ...boxA.messages.filter((m) => senderIsBox(senderOf(m), boxB)).map((m) => ({
236
+ from: senderOf(m), to: boxA.id, toLabel: boxA.label, ts: m.ts, text: m.text, state: m.state, box: boxA.id,
237
+ })),
238
+ ].sort((x, y) => (x.ts < y.ts ? -1 : x.ts > y.ts ? 1 : 0));
239
+ if (json) {
240
+ console.log(JSON.stringify({
241
+ a: { id: boxA.id, label: boxA.label },
242
+ b: { id: boxB.id, label: boxB.label },
243
+ count: thread.length,
244
+ messages: thread,
245
+ }, null, 2));
246
+ return;
247
+ }
248
+ const span = thread.length >= 2
249
+ ? humanDuration(Math.max(0, Date.parse(thread[thread.length - 1].ts) - Date.parse(thread[0].ts)))
250
+ : '0s';
251
+ console.log(` ${chalk.magenta(boxA.label)} ${chalk.dim(GLYPH.thread)} ${chalk.cyan(boxB.label)}` +
252
+ chalk.dim(` ${thread.length} message${thread.length === 1 ? '' : 's'} · ${span}`));
253
+ if (thread.length === 0) {
254
+ console.log(chalk.dim(' No messages between these boxes yet. Sender stamps match on box id or label.'));
255
+ return;
256
+ }
257
+ console.log();
258
+ for (const m of thread) {
259
+ const when = chalk.dim(relTime(m.ts).padStart(10));
260
+ const route = `${chalk.magenta(truncate(m.from, 24))} ${chalk.dim(GLYPH.route)} ${chalk.cyan(truncate(m.toLabel, 24))}`;
261
+ console.log(` ${when} ${STATE_TAG[m.state]} ${route}`);
262
+ console.log(` ${truncate(m.text.replace(/\s+/g, ' ').trim(), 100)}`);
263
+ }
264
+ }
265
+ function renderGraph(boxes, filters, json) {
266
+ const nonEmpty = boxes.filter((b) => b.messages.length > 0);
267
+ const msgs = aggregate(nonEmpty).filter((m) => matchesFilters(m, m.toLabel, m.box, filters));
268
+ const edges = graphEdges(msgs);
269
+ if (json) {
270
+ console.log(JSON.stringify(edges, null, 2));
271
+ return;
272
+ }
273
+ console.log(masthead({
274
+ title: 'fleet comms',
275
+ host: os.hostname(),
276
+ accent: 'cyan',
277
+ right: `${edges.length} route${edges.length === 1 ? '' : 's'}`,
278
+ stats: [`${msgs.length} messages`],
279
+ }));
280
+ console.log();
281
+ if (edges.length === 0) {
282
+ console.log(chalk.dim(' No cross-box routes yet.'));
283
+ return;
284
+ }
285
+ for (const e of edges) {
286
+ const route = ` ${chalk.magenta(truncate(e.from, 28))} ${chalk.dim('└─▶')} ${chalk.cyan(truncate(e.to, 40))}`;
287
+ const dots = '.'.repeat(Math.max(2, 76 - visibleWidth(route)));
288
+ console.log(`${route} ${chalk.dim(dots)} ${e.count}`);
289
+ }
290
+ }
291
+ function renderBox(box) {
292
+ const dot = box.live ? chalk.green(`${GLYPH.live} live`) : chalk.dim(`${GLYPH.idle} not running`);
293
+ console.log(chalk.bold(box.label) + ' ' + dot);
294
+ console.log(chalk.dim(box.id));
295
+ if (box.messages.length === 0) {
296
+ console.log(chalk.dim(' (empty)'));
297
+ return;
298
+ }
299
+ console.log();
300
+ for (const m of box.messages) {
301
+ const when = chalk.dim(relTime(m.ts));
302
+ console.log(` ${STATE_TAG[m.state]} ${chalk.magenta(senderOf(m))} ${when}${m.blockId ? chalk.dim(` block ${m.blockId.slice(0, 12)}`) : ''}`);
303
+ console.log(` ${truncate(m.text.replace(/\s+/g, ' ').trim(), 200)}`);
304
+ }
305
+ }
306
+ function lastTs(box) {
307
+ if (box.messages.length === 0)
308
+ return 0;
309
+ const t = Date.parse(newestMessage(box).ts);
310
+ return Number.isNaN(t) ? 0 : t;
311
+ }
312
+ function newestMessage(box) {
313
+ return box.messages.reduce((a, b) => (a.ts >= b.ts ? a : b));
314
+ }
315
+ export function registerMailboxesCommand(program) {
316
+ program
317
+ .command('mailboxes')
318
+ .alias('mailbox')
319
+ .argument('[id]', 'A mailbox id (session UUID / teams agentId) to inspect in full')
320
+ .description('Fleet comms — boxes, live cross-box traffic, threads, and routes across the agent mailbox spool')
321
+ .option('--json', 'Output as JSON (NDJSON stream with --watch)')
322
+ .option('-n, --limit <n>', 'Max recent messages in the overview log', '20')
323
+ .option('-f, --watch', 'Live tail: stream new cross-box messages until Ctrl-C')
324
+ .option('--between <boxes...>', 'Thread view: every message between two boxes (id or label), either direction')
325
+ .option('--from <agent>', 'Only messages whose sender contains <agent>')
326
+ .option('--to <agent>', 'Only messages to boxes whose id or label contains <agent>')
327
+ .option('--since <dur>', 'Only messages newer than <dur> (30s/5m/2h/7d or ISO date); with --watch, backfills the window')
328
+ .option('--graph', 'Who-talks-to-whom adjacency, busiest first')
329
+ .action(async (id, opts) => {
330
+ const root = getMailboxRootDir();
331
+ const limit = Math.max(1, Number.parseInt(opts.limit ?? '20', 10) || 20);
332
+ const filters = buildFilters(opts);
333
+ if (opts.between && opts.between.length !== 2) {
334
+ die(`--between takes exactly two boxes: \`agents mailboxes --between <a> <b>\`.`);
335
+ }
336
+ // The views are mutually exclusive — never silently drop a flag.
337
+ const viewCount = (id ? 1 : 0) + (opts.between ? 1 : 0) + (opts.graph ? 1 : 0);
338
+ if (opts.watch && viewCount > 0) {
339
+ die('--watch streams the whole fleet and combines with no other view. Drop <id>/--between/--graph, or use --from/--to/--since to filter the stream.');
340
+ }
341
+ if (!opts.watch && viewCount > 1) {
342
+ die('Pick one view: <id>, --between, or --graph.');
343
+ }
344
+ if (opts.watch) {
345
+ await runWatch(root, { json: opts.json, filters });
346
+ return;
347
+ }
348
+ const boxes = await collectBoxes(root);
349
+ if (opts.between) {
350
+ renderBetween(boxes, opts.between[0], opts.between[1], opts.json);
351
+ return;
352
+ }
353
+ if (opts.graph) {
354
+ renderGraph(boxes, filters, opts.json);
355
+ return;
356
+ }
357
+ if (id) {
358
+ if (!isValidMailboxId(id))
359
+ die(`Invalid mailbox id ${JSON.stringify(id)}.`);
360
+ const box = boxes.find((b) => b.id === id) ?? boxes.find((b) => b.id.startsWith(id));
361
+ if (!box)
362
+ die(`No mailbox ${JSON.stringify(id)} under ${root}.`);
363
+ if (opts.json) {
364
+ console.log(JSON.stringify(box, null, 2));
365
+ return;
366
+ }
367
+ renderBox(box);
368
+ return;
369
+ }
370
+ if (opts.json) {
371
+ // Unfiltered output keeps the legacy shape byte-for-byte; with filters
372
+ // the JSON mirrors the filtered overview log — pending/total recount.
373
+ console.log(JSON.stringify(boxes.map((b) => {
374
+ const messages = hasFilters(filters)
375
+ ? b.messages.filter((m) => matchesFilters({ from: senderOf(m), ts: m.ts }, b.label, b.id, filters))
376
+ : b.messages;
377
+ return {
378
+ id: b.id,
379
+ label: b.label,
380
+ live: b.live,
381
+ pending: messages.filter((m) => m.state !== 'consumed').length,
382
+ total: messages.length,
383
+ messages,
384
+ };
385
+ }), null, 2));
386
+ return;
387
+ }
388
+ renderOverview(boxes, limit, filters);
389
+ });
390
+ }
@@ -149,7 +149,7 @@ async function pickJob(message, filter, alternatives = [], cwd) {
149
149
  message,
150
150
  choices: jobs.map((job) => ({
151
151
  value: job.name,
152
- name: `${job.name} ${chalk.gray(`(${job.workflow ? `wf:${job.workflow}` : job.agent}, ${job.schedule ?? fireConditionLabel(job)})`)}`,
152
+ name: `${job.name} ${chalk.gray(`(${job.command ? 'command' : job.workflow ? `wf:${job.workflow}` : job.agent}, ${job.schedule ?? fireConditionLabel(job)})`)}`,
153
153
  })),
154
154
  });
155
155
  }
@@ -282,6 +282,7 @@ export function registerRoutinesCommands(program) {
282
282
  name: job.name,
283
283
  agent: job.agent ?? null,
284
284
  workflow: job.workflow ?? null,
285
+ command: job.command ?? null,
285
286
  repo: job.repo ?? null,
286
287
  schedule: job.schedule ?? null,
287
288
  scheduleHuman: fireConditionLabel(job),
@@ -356,9 +357,11 @@ export function registerRoutinesCommands(program) {
356
357
  : lastStatus === 'timeout' ? chalk.yellow
357
358
  : chalk.gray;
358
359
  const overdueTag = overdueSet.has(job.name) ? chalk.yellow(' (overdue)') : '';
359
- const agentLabelPadded = job.workflow
360
- ? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
361
- : (job.agent || '').padEnd(10);
360
+ const agentLabelPadded = job.command
361
+ ? chalk.magenta('command'.padEnd(10))
362
+ : job.workflow
363
+ ? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
364
+ : (job.agent || '').padEnd(10);
362
365
  console.log(` ${chalk.cyan(job.name.padEnd(NAME_W))} ${agentLabelPadded} ${repoCell}${' '.repeat(repoPadding)} ${deviceCell}${' '.repeat(devicePad)} ${schedStr.padEnd(SCHED_W)} ${enabledStr}${' '.repeat(enabledPad)} ${chalk.gray(nextStr.padEnd(NEXT_W))} ${statusColor(lastStatus)}${overdueTag}`);
363
366
  }
364
367
  if (overdueSet.size > 0) {
@@ -374,6 +377,7 @@ export function registerRoutinesCommands(program) {
374
377
  .option('-s, --schedule <cron>', 'Cron schedule in standard format (5 fields: minute hour day month weekday)')
375
378
  .option('-a, --agent <agent>', 'Which agent runs this routine: claude, codex, gemini, cursor, or opencode')
376
379
  .option('--workflow <name>', 'Run an installed workflow (~/.agents/workflows/<name>) via `agents run`. Mutually exclusive with --agent.')
380
+ .option('--command <sh>', 'Run a plain shell command directly (no agent, no auth, no sandbox) — for deterministic housekeeping routines. Mutually exclusive with --agent and --workflow; --prompt is not used.')
377
381
  .option('-p, --prompt <prompt>', 'Task instruction for the agent')
378
382
  .option('-m, --mode <mode>', "Execution mode: plan (read-only), edit (can write files), auto (smart classifier, the default), or skip (bypass all permission prompts). 'full' accepted as alias for skip.", 'auto')
379
383
  .option('-e, --effort <effort>', 'Reasoning effort: low | medium | high | xhigh | max | auto', 'auto')
@@ -392,7 +396,7 @@ export function registerRoutinesCommands(program) {
392
396
  .option('--resume <sessionId>', 'At fire time, resume this existing session id (via `agents run <agent> --resume`) instead of starting fresh — the actual session reopens with full context and the prompt becomes its next turn. Powers self-scheduled wake-ups (e.g. /hibernate). Requires --agent claude or codex; runs un-sandboxed (the session store lives in the real home, not the job overlay).')
393
397
  .action(async (nameOrPath, options) => {
394
398
  // Check if inline mode (has flags) or file mode
395
- const hasInlineFlags = options.schedule || options.agent || options.workflow || options.prompt || options.at || options.on;
399
+ const hasInlineFlags = options.schedule || options.agent || options.workflow || options.command || options.prompt || options.at || options.on;
396
400
  if (hasInlineFlags) {
397
401
  // Inline mode: create job from flags
398
402
  if (!nameOrPath) {
@@ -400,9 +404,9 @@ export function registerRoutinesCommands(program) {
400
404
  console.log(chalk.gray('Usage: agents routines add <name> --schedule "..." --agent <agent> --prompt "..."'));
401
405
  process.exit(1);
402
406
  }
403
- // Validate mutually exclusive --agent / --workflow
404
- if (options.agent && options.workflow) {
405
- console.log(chalk.red('--agent and --workflow are mutually exclusive; specify exactly one'));
407
+ // Validate mutually exclusive --agent / --workflow / --command
408
+ if ([options.agent, options.workflow, options.command].filter(Boolean).length > 1) {
409
+ console.log(chalk.red('--agent, --workflow, and --command are mutually exclusive; specify exactly one'));
406
410
  process.exit(1);
407
411
  }
408
412
  let schedule = options.schedule;
@@ -430,11 +434,12 @@ export function registerRoutinesCommands(program) {
430
434
  console.log(chalk.red('Schedule or trigger is required (use --schedule, --at, or --on)'));
431
435
  process.exit(1);
432
436
  }
433
- if (!options.agent && !options.workflow) {
434
- console.log(chalk.red('An agent or workflow is required (use --agent or --workflow)'));
437
+ if (!options.agent && !options.workflow && !options.command) {
438
+ console.log(chalk.red('An agent, workflow, or command is required (use --agent, --workflow, or --command)'));
435
439
  process.exit(1);
436
440
  }
437
- if (!options.prompt) {
441
+ // Command routines run a plain shell and take no prompt; agent/workflow routines require one.
442
+ if (!options.command && !options.prompt) {
438
443
  console.log(chalk.red('Prompt is required (use --prompt)'));
439
444
  process.exit(1);
440
445
  }
@@ -447,13 +452,14 @@ export function registerRoutinesCommands(program) {
447
452
  name: nameOrPath,
448
453
  ...(schedule ? { schedule } : {}),
449
454
  ...(trigger ? { trigger } : {}),
450
- agent: options.agent,
455
+ ...(options.agent ? { agent: options.agent } : {}),
451
456
  ...(options.workflow ? { workflow: options.workflow } : {}),
457
+ ...(options.command ? { command: options.command } : {}),
452
458
  mode: options.mode,
453
459
  effort: options.effort,
454
460
  timeout: options.timeout,
455
461
  enabled: !options.disabled,
456
- prompt: options.prompt,
462
+ prompt: options.prompt ?? '',
457
463
  timezone: options.timezone,
458
464
  ...(devices ? { devices } : {}),
459
465
  ...(runOnce ? { runOnce: true } : {}),
@@ -664,7 +670,7 @@ export function registerRoutinesCommands(program) {
664
670
  console.log(chalk.gray(` ${eligibility.suggestion}`));
665
671
  process.exit(1);
666
672
  }
667
- const runLabel = job.workflow ? `workflow: ${job.workflow}` : `agent: ${job.agent}`;
673
+ const runLabel = job.command ? 'command' : job.workflow ? `workflow: ${job.workflow}` : `agent: ${job.agent}`;
668
674
  console.log(chalk.bold(`Running job '${name}' (${runLabel}, mode: ${job.mode})\n`));
669
675
  const spinner = ora('Executing...').start();
670
676
  try {
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerSessionsExportCommand(sessionsCmd: Command): void;