@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
@@ -79,6 +79,30 @@ export async function pickRuntimes(detected, prompt) {
79
79
  const { checkbox } = await import('@inquirer/prompts');
80
80
  return checkbox({ message: 'Provision which runtime(s) on the leased box?', choices });
81
81
  }
82
+ /**
83
+ * The lease runtime to provision for a headless run of `agentName`.
84
+ *
85
+ * When the agent is itself a lease-capable runtime (claude/codex/gemini/grok)
86
+ * that IS the runtime to install. Otherwise fall back to the single signed-in
87
+ * lease runtime (preferring claude), or null when none is signed in. This is the
88
+ * non-interactive replacement for the runtime checkbox picker: `--lease` requires
89
+ * a prompt, so it is headless by contract and must never block on a TTY.
90
+ *
91
+ * Profile-dispatch agents (kimi/deepseek) and custom workflow agents that run
92
+ * under a non-obvious runtime are resolved separately — see RUSH-1725.
93
+ */
94
+ export function inferLeaseRuntime(agentName, detected) {
95
+ const signedIn = detected.filter((d) => d.signedIn && d.credPath);
96
+ // The agent names a lease runtime directly: require that runtime to be signed
97
+ // in — never silently substitute a different one for an explicit `run <runtime>`
98
+ // (that would lease a billable box only to boot it "Not logged in"). Not signed
99
+ // in → null, so the caller exits with "sign into it locally first".
100
+ if (LEASE_RUNTIMES.some((c) => c.id === agentName)) {
101
+ return signedIn.find((d) => d.id === agentName)?.id ?? null;
102
+ }
103
+ // Custom/workflow agent: fall back to the signed-in runtime (preferring claude).
104
+ return signedIn.find((d) => d.id === 'claude')?.id ?? signedIn[0]?.id ?? null;
105
+ }
82
106
  // A long random sentinel makes an accidental (or malicious) collision with a
83
107
  // token's contents effectively impossible, so the quoted heredoc can never be
84
108
  // closed early by the credential body.
@@ -329,7 +329,12 @@ export async function runDaemon() {
329
329
  log('WARN', `Secrets broker host skipped: ${err.message}`);
330
330
  }
331
331
  const scheduler = new JobScheduler(async (config) => {
332
- log('INFO', `Triggering job '${config.name}' (agent: ${config.agent})`);
332
+ const jobLabel = config.command
333
+ ? 'command'
334
+ : config.workflow
335
+ ? `workflow: ${config.workflow}`
336
+ : `agent: ${config.agent}`;
337
+ log('INFO', `Triggering job '${config.name}' (${jobLabel})`);
333
338
  try {
334
339
  const meta = await executeJobDetached(config);
335
340
  log('INFO', `Job '${config.name}' spawned (run: ${meta.runId}, PID: ${meta.pid})`);
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Device resource probing for `agents devices list`.
3
+ *
4
+ * One SSH round-trip per device gathers load average, memory pressure, and core
5
+ * count (mac + linux), parsed into a {@link DeviceStats}. Probes run in parallel
6
+ * with a bounded timeout so the list stays responsive — a slow or hung box
7
+ * degrades to "no stats" instead of blocking the whole table.
8
+ *
9
+ * The parsers are pure and unit-tested (health.test.ts). They mirror the ones in
10
+ * the Factory extension (apps/factory/src/core/deviceHealth.ts) — kept as a
11
+ * separate copy on purpose: the CLI does not import across packages.
12
+ */
13
+ import type { DeviceProfile } from './registry.js';
14
+ /** Default per-device probe budget. Short enough that the list never hangs on a
15
+ * wedged box, long enough for a cold relayed SSH handshake. */
16
+ export declare const PROBE_TIMEOUT_MS = 2500;
17
+ export interface DeviceStats {
18
+ host: string;
19
+ reachable: boolean;
20
+ loadAvg1?: number;
21
+ ncpu?: number;
22
+ /** loadAvg1 / ncpu * 100 — load normalized to core count (the "has room" number). */
23
+ loadPercent?: number;
24
+ memPercent?: number;
25
+ memTotalBytes?: number;
26
+ memFreeBytes?: number;
27
+ fetchedAt: number;
28
+ }
29
+ /** Compact human byte size: 512M, 64G, 1.5T (binary units, ≤1 decimal). */
30
+ export declare function fmtBytes(bytes: number | undefined): string;
31
+ export declare function parseUptime(out: string): {
32
+ loadAvg1?: number;
33
+ };
34
+ interface MemStats {
35
+ memPercent?: number;
36
+ memTotalBytes?: number;
37
+ memFreeBytes?: number;
38
+ }
39
+ export declare function parseVmStat(out: string): MemStats;
40
+ export declare function parseLinuxMemInfo(out: string): MemStats;
41
+ export declare function parseNcpu(out: string): {
42
+ ncpu?: number;
43
+ };
44
+ /** Assemble a DeviceStats from the three snippet sections. */
45
+ export declare function parseProbeOutput(host: string, stdout: string, fetchedAt: number): DeviceStats;
46
+ export interface FleetCapacity {
47
+ reachable: number;
48
+ cores: number;
49
+ memTotalBytes: number;
50
+ memFreeBytes: number;
51
+ }
52
+ /** Sum cores and memory across reachable devices for the summary footer. */
53
+ export declare function fleetCapacity(statsList: Iterable<DeviceStats>): FleetCapacity;
54
+ /** Headroom bucket from the worst of normalized-load and memory. */
55
+ export type Headroom = 'idle' | 'light' | 'busy' | 'loaded' | 'unknown';
56
+ export declare function headroom(stats: DeviceStats | undefined): Headroom;
57
+ /** Probe one device over the same ssh path as `agents ssh <name>`. Never throws;
58
+ * an unreachable/slow/misconfigured device resolves to `reachable: false`. */
59
+ export declare function probeDeviceStats(device: DeviceProfile, opts?: {
60
+ timeoutMs?: number;
61
+ now?: number;
62
+ }): Promise<DeviceStats>;
63
+ /** Probe the local machine directly (no ssh round-trip) — used for the "this
64
+ * machine" row so it always shows real numbers even if it isn't ssh-reachable
65
+ * from itself. */
66
+ export declare function probeLocalStats(host: string, opts?: {
67
+ timeoutMs?: number;
68
+ now?: number;
69
+ }): Promise<DeviceStats>;
70
+ /** Probe many devices concurrently; returns a name→stats map. Bounded by the
71
+ * per-probe timeout, so total wall time ≈ the slowest single probe. The device
72
+ * named `selfName` is probed locally instead of over ssh. */
73
+ export declare function probeFleetStats(devices: DeviceProfile[], opts?: {
74
+ timeoutMs?: number;
75
+ selfName?: string;
76
+ }): Promise<Map<string, DeviceStats>>;
77
+ export {};
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Device resource probing for `agents devices list`.
3
+ *
4
+ * One SSH round-trip per device gathers load average, memory pressure, and core
5
+ * count (mac + linux), parsed into a {@link DeviceStats}. Probes run in parallel
6
+ * with a bounded timeout so the list stays responsive — a slow or hung box
7
+ * degrades to "no stats" instead of blocking the whole table.
8
+ *
9
+ * The parsers are pure and unit-tested (health.test.ts). They mirror the ones in
10
+ * the Factory extension (apps/factory/src/core/deviceHealth.ts) — kept as a
11
+ * separate copy on purpose: the CLI does not import across packages.
12
+ */
13
+ import { execFile } from 'child_process';
14
+ import { buildSshInvocation, writeAskpassShim } from './connect.js';
15
+ /** Default per-device probe budget. Short enough that the list never hangs on a
16
+ * wedged box, long enough for a cold relayed SSH handshake. */
17
+ export const PROBE_TIMEOUT_MS = 2_500;
18
+ const SEP = '---AGSTAT---';
19
+ /** One-shot remote snapshot: load, then memory (mac vm_stat else linux
20
+ * meminfo), then core count (linux nproc else mac hw.ncpu). */
21
+ const PROBE_SNIPPET = `uptime; echo ${SEP}; (vm_stat 2>/dev/null || cat /proc/meminfo 2>/dev/null); echo ${SEP}; (nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null)`;
22
+ /** Compact human byte size: 512M, 64G, 1.5T (binary units, ≤1 decimal). */
23
+ export function fmtBytes(bytes) {
24
+ if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0)
25
+ return '—';
26
+ const units = ['B', 'K', 'M', 'G', 'T', 'P'];
27
+ let v = bytes;
28
+ let i = 0;
29
+ while (v >= 1024 && i < units.length - 1) {
30
+ v /= 1024;
31
+ i++;
32
+ }
33
+ const s = v >= 100 || i <= 1 ? Math.round(v).toString() : v.toFixed(1).replace(/\.0$/, '');
34
+ return `${s}${units[i]}`;
35
+ }
36
+ export function parseUptime(out) {
37
+ const m = out.match(/load average[s]?:\s*([0-9]+[.,][0-9]+|[0-9]+)/i);
38
+ if (!m)
39
+ return {};
40
+ const v = parseFloat(m[1].replace(',', '.'));
41
+ if (!Number.isFinite(v))
42
+ return {};
43
+ return { loadAvg1: v };
44
+ }
45
+ export function parseVmStat(out) {
46
+ const pageSize = parseInt(out.match(/page size of\s+([0-9]+)\s+bytes/)?.[1] ?? '4096', 10);
47
+ const active = out.match(/Pages active:\s+([0-9]+)/);
48
+ const wired = out.match(/Pages wired down:\s+([0-9]+)/);
49
+ const compressed = out.match(/Pages occupied by compressor:\s+([0-9]+)/);
50
+ const free = out.match(/Pages free:\s+([0-9]+)/);
51
+ if (!active || !wired || !compressed || !free)
52
+ return {};
53
+ // macOS reclaims inactive + speculative pages on demand, so they count as
54
+ // available — folding them into "free" (as Activity Monitor / vm_pressure do)
55
+ // rather than "used" keeps the headroom bucket honest on a Mac.
56
+ const inactive = out.match(/Pages inactive:\s+([0-9]+)/);
57
+ const speculative = out.match(/Pages speculative:\s+([0-9]+)/);
58
+ const usedPages = parseInt(active[1], 10) + parseInt(wired[1], 10) + parseInt(compressed[1], 10);
59
+ const freePages = parseInt(free[1], 10) +
60
+ parseInt(inactive?.[1] ?? '0', 10) +
61
+ parseInt(speculative?.[1] ?? '0', 10);
62
+ const totalPages = usedPages + freePages;
63
+ if (totalPages <= 0)
64
+ return {};
65
+ return {
66
+ memPercent: (usedPages / totalPages) * 100,
67
+ memTotalBytes: totalPages * pageSize,
68
+ memFreeBytes: freePages * pageSize,
69
+ };
70
+ }
71
+ export function parseLinuxMemInfo(out) {
72
+ const total = out.match(/^MemTotal:\s+([0-9]+)/im);
73
+ const available = out.match(/^MemAvailable:\s+([0-9]+)/im);
74
+ if (!total || !available)
75
+ return {};
76
+ const tKb = parseInt(total[1], 10);
77
+ const aKb = parseInt(available[1], 10);
78
+ if (tKb <= 0)
79
+ return {};
80
+ return {
81
+ memPercent: Math.max(0, Math.min(100, ((tKb - aKb) / tKb) * 100)),
82
+ memTotalBytes: tKb * 1024,
83
+ memFreeBytes: aKb * 1024,
84
+ };
85
+ }
86
+ export function parseNcpu(out) {
87
+ const n = parseInt(out.trim().split(/\s+/)[0] ?? '', 10);
88
+ return Number.isFinite(n) && n > 0 ? { ncpu: n } : {};
89
+ }
90
+ /** Assemble a DeviceStats from the three snippet sections. */
91
+ export function parseProbeOutput(host, stdout, fetchedAt) {
92
+ const [uptimePart = '', memPart = '', ncpuPart = ''] = stdout.split(SEP);
93
+ const { loadAvg1 } = parseUptime(uptimePart);
94
+ const mem = memPart.includes('MemTotal') ? parseLinuxMemInfo(memPart) : parseVmStat(memPart);
95
+ const { ncpu } = parseNcpu(ncpuPart);
96
+ const loadPercent = loadAvg1 !== undefined && ncpu ? (loadAvg1 / ncpu) * 100 : undefined;
97
+ return {
98
+ host,
99
+ reachable: true,
100
+ loadAvg1,
101
+ ncpu,
102
+ loadPercent,
103
+ memPercent: mem.memPercent,
104
+ memTotalBytes: mem.memTotalBytes,
105
+ memFreeBytes: mem.memFreeBytes,
106
+ fetchedAt,
107
+ };
108
+ }
109
+ /** Sum cores and memory across reachable devices for the summary footer. */
110
+ export function fleetCapacity(statsList) {
111
+ const cap = { reachable: 0, cores: 0, memTotalBytes: 0, memFreeBytes: 0 };
112
+ for (const s of statsList) {
113
+ if (!s.reachable)
114
+ continue;
115
+ cap.reachable++;
116
+ cap.cores += s.ncpu ?? 0;
117
+ cap.memTotalBytes += s.memTotalBytes ?? 0;
118
+ cap.memFreeBytes += s.memFreeBytes ?? 0;
119
+ }
120
+ return cap;
121
+ }
122
+ export function headroom(stats) {
123
+ if (!stats || !stats.reachable)
124
+ return 'unknown';
125
+ const signals = [stats.loadPercent, stats.memPercent].filter((v) => typeof v === 'number');
126
+ if (signals.length === 0)
127
+ return 'unknown';
128
+ const worst = Math.max(...signals);
129
+ if (worst < 15)
130
+ return 'idle';
131
+ if (worst < 40)
132
+ return 'light';
133
+ if (worst < 75)
134
+ return 'busy';
135
+ return 'loaded';
136
+ }
137
+ /** Probe one device over the same ssh path as `agents ssh <name>`. Never throws;
138
+ * an unreachable/slow/misconfigured device resolves to `reachable: false`. */
139
+ export function probeDeviceStats(device, opts = {}) {
140
+ const host = device.name;
141
+ const fetchedAt = opts.now ?? Date.now();
142
+ let args;
143
+ let env;
144
+ try {
145
+ const shim = writeAskpassShim();
146
+ // buildSshInvocation joins the cmd with spaces and hands the string to the
147
+ // remote login shell, which evaluates the snippet's `;`/`||` directly — no
148
+ // `sh -c` wrapper needed (and a wrapper would only re-quote the first token).
149
+ ({ args, env } = buildSshInvocation(device, [PROBE_SNIPPET], shim));
150
+ }
151
+ catch {
152
+ return Promise.resolve({ host, reachable: false, fetchedAt });
153
+ }
154
+ return new Promise((resolve) => {
155
+ execFile('ssh', args, { encoding: 'utf-8', env: { ...process.env, ...env }, timeout: opts.timeoutMs ?? PROBE_TIMEOUT_MS }, (err, stdout) => {
156
+ if (err || !stdout)
157
+ return resolve({ host, reachable: false, fetchedAt });
158
+ resolve(parseProbeOutput(host, stdout, fetchedAt));
159
+ });
160
+ });
161
+ }
162
+ /** Probe the local machine directly (no ssh round-trip) — used for the "this
163
+ * machine" row so it always shows real numbers even if it isn't ssh-reachable
164
+ * from itself. */
165
+ export function probeLocalStats(host, opts = {}) {
166
+ const fetchedAt = opts.now ?? Date.now();
167
+ return new Promise((resolve) => {
168
+ execFile('sh', ['-c', PROBE_SNIPPET], { encoding: 'utf-8', timeout: opts.timeoutMs ?? PROBE_TIMEOUT_MS }, (err, stdout) => {
169
+ if (err || !stdout)
170
+ return resolve({ host, reachable: false, fetchedAt });
171
+ resolve(parseProbeOutput(host, stdout, fetchedAt));
172
+ });
173
+ });
174
+ }
175
+ /** Probe many devices concurrently; returns a name→stats map. Bounded by the
176
+ * per-probe timeout, so total wall time ≈ the slowest single probe. The device
177
+ * named `selfName` is probed locally instead of over ssh. */
178
+ export async function probeFleetStats(devices, opts = {}) {
179
+ const entries = await Promise.all(devices.map(async (d) => {
180
+ const stats = d.name === opts.selfName
181
+ ? await probeLocalStats(d.name, opts)
182
+ : await probeDeviceStats(d, opts);
183
+ return [d.name, stats];
184
+ }));
185
+ return new Map(entries);
186
+ }
@@ -69,3 +69,42 @@ export declare function drain(boxDir: string, boxId?: string, now?: Date): Mailb
69
69
  export declare function peek(boxDir: string, boxId?: string, now?: Date): MailboxMessage[];
70
70
  /** Delete pending (not-yet-claimed) inbox messages. Returns the count removed. */
71
71
  export declare function clear(boxDir: string): number;
72
+ /** Which bucket a stored message currently sits in. */
73
+ export type MailboxState = 'inbox' | 'processing' | 'consumed';
74
+ /** A message read back from a box, tagged with the bucket it was found in. */
75
+ export interface StoredMessage extends MailboxMessage {
76
+ state: MailboxState;
77
+ }
78
+ /** A stored message enriched with the mailbox identity used by comms renderers. */
79
+ export interface CommsMsg {
80
+ from: string;
81
+ to: string;
82
+ toLabel: string;
83
+ ts: string;
84
+ text: string;
85
+ state: MailboxState;
86
+ box: string;
87
+ }
88
+ /**
89
+ * Enumerate the box ids under `root` (directory names that are valid mailbox
90
+ * ids). Read-only; does not create the root. Sorted for stable output.
91
+ */
92
+ export declare function listBoxes(root?: string): string[];
93
+ /**
94
+ * Read every message in a box across all three buckets (inbox, processing,
95
+ * consumed) WITHOUT consuming, sweeping, or archiving anything. Unlike `peek`,
96
+ * this includes `consumed/` — the delivered history — so callers can surface a
97
+ * communication log. Each row is tagged with its bucket. Sorted FIFO by msgId
98
+ * (which is time-sortable), oldest first.
99
+ */
100
+ export declare function readBox(boxDir: string): StoredMessage[];
101
+ /**
102
+ * Poll the complete spool and yield each message once when its box/msgId pair
103
+ * first appears. Existing messages establish the initial baseline unless
104
+ * `backfill` is requested; moving a message between buckets does not re-emit it.
105
+ */
106
+ export declare function watchMessages(root: string, opts: {
107
+ signal?: AbortSignal;
108
+ intervalMs?: number;
109
+ backfill?: boolean;
110
+ }): AsyncGenerator<CommsMsg>;
@@ -267,3 +267,115 @@ export function clear(boxDir) {
267
267
  }
268
268
  return n;
269
269
  }
270
+ /**
271
+ * Enumerate the box ids under `root` (directory names that are valid mailbox
272
+ * ids). Read-only; does not create the root. Sorted for stable output.
273
+ */
274
+ export function listBoxes(root = getMailboxRootDir()) {
275
+ let names;
276
+ try {
277
+ names = fs.readdirSync(root);
278
+ }
279
+ catch {
280
+ return [];
281
+ }
282
+ return names.filter((n) => isValidMailboxId(n) && fs.statSync(path.join(root, n)).isDirectory()).sort();
283
+ }
284
+ /**
285
+ * Read every message in a box across all three buckets (inbox, processing,
286
+ * consumed) WITHOUT consuming, sweeping, or archiving anything. Unlike `peek`,
287
+ * this includes `consumed/` — the delivered history — so callers can surface a
288
+ * communication log. Each row is tagged with its bucket. Sorted FIFO by msgId
289
+ * (which is time-sortable), oldest first.
290
+ */
291
+ export function readBox(boxDir) {
292
+ const out = [];
293
+ const buckets = [
294
+ [inboxDir(boxDir), 'inbox'],
295
+ [processingDir(boxDir), 'processing'],
296
+ [consumedDir(boxDir), 'consumed'],
297
+ ];
298
+ for (const [dir, state] of buckets) {
299
+ for (const name of jsonFiles(dir)) {
300
+ const msg = readMessage(path.join(dir, name));
301
+ if (msg)
302
+ out.push({ ...msg, state });
303
+ }
304
+ }
305
+ out.sort((a, b) => (a.msgId < b.msgId ? -1 : a.msgId > b.msgId ? 1 : 0));
306
+ return out;
307
+ }
308
+ /**
309
+ * Poll the complete spool and yield each message once when its box/msgId pair
310
+ * first appears. Existing messages establish the initial baseline unless
311
+ * `backfill` is requested; moving a message between buckets does not re-emit it.
312
+ */
313
+ export async function* watchMessages(root, opts) {
314
+ const seen = new Set();
315
+ const requestedInterval = opts.intervalMs ?? 500;
316
+ const intervalMs = Number.isFinite(requestedInterval) ? Math.max(1, requestedInterval) : 500;
317
+ let firstPoll = true;
318
+ while (!opts.signal?.aborted) {
319
+ const fresh = [];
320
+ for (const box of listBoxes(root)) {
321
+ for (const stored of readBox(mailboxDir(box, root))) {
322
+ const key = `${box}\0${stored.msgId}`;
323
+ if (seen.has(key))
324
+ continue;
325
+ seen.add(key);
326
+ if (firstPoll && !opts.backfill)
327
+ continue;
328
+ fresh.push({
329
+ key,
330
+ message: {
331
+ from: stored.from || 'operator',
332
+ to: stored.to,
333
+ toLabel: box.slice(0, 8),
334
+ ts: stored.ts,
335
+ text: stored.text,
336
+ state: stored.state,
337
+ box,
338
+ },
339
+ });
340
+ }
341
+ }
342
+ firstPoll = false;
343
+ fresh.sort((a, b) => compareWatched(a.message.ts, b.message.ts) ||
344
+ compareWatched(a.key, b.key));
345
+ for (const { message } of fresh) {
346
+ if (opts.signal?.aborted)
347
+ return;
348
+ yield message;
349
+ }
350
+ if (!await waitForMailboxPoll(intervalMs, opts.signal))
351
+ return;
352
+ }
353
+ }
354
+ function compareWatched(a, b) {
355
+ return a < b ? -1 : a > b ? 1 : 0;
356
+ }
357
+ /** Wait for the next poll, resolving immediately when the watcher is aborted. */
358
+ function waitForMailboxPoll(intervalMs, signal) {
359
+ if (signal?.aborted)
360
+ return Promise.resolve(false);
361
+ return new Promise((resolve) => {
362
+ let timer;
363
+ let settled = false;
364
+ const finish = (keepWatching) => {
365
+ if (settled)
366
+ return;
367
+ settled = true;
368
+ if (timer)
369
+ clearTimeout(timer);
370
+ signal?.removeEventListener('abort', onAbort);
371
+ resolve(keepWatching);
372
+ };
373
+ const onAbort = () => finish(false);
374
+ signal?.addEventListener('abort', onAbort, { once: true });
375
+ if (signal?.aborted) {
376
+ finish(false);
377
+ return;
378
+ }
379
+ timer = setTimeout(() => finish(true), intervalMs);
380
+ });
381
+ }
@@ -1,3 +1,9 @@
1
+ /**
2
+ * True when `name` is a safe single path segment: non-empty, not '.'/'..',
3
+ * free of path separators and null bytes, and within the filename length limit.
4
+ * Dot-prefixed names like '.env.example' are allowed.
5
+ */
6
+ export declare function isSafeSegmentName(name: string): boolean;
1
7
  /**
2
8
  * Resolve base + name while preventing path-traversal attacks.
3
9
  * Rejects path separators, null bytes, '.' and '..', and any resolved path
@@ -6,3 +12,10 @@
6
12
  * Allows spaces, unicode, and other common filename characters.
7
13
  */
8
14
  export declare function safeJoin(base: string, name: string): string;
15
+ /**
16
+ * Assert that `target` (which may legitimately contain path separators, e.g. a
17
+ * multi-segment relative key) stays within `root` after normalization. Use this
18
+ * where a caller must accept nested relative paths but the input is untrusted —
19
+ * `safeJoin` is stricter and only allows single segments.
20
+ */
21
+ export declare function assertWithin(root: string, target: string): string;
package/dist/lib/paths.js CHANGED
@@ -1,4 +1,15 @@
1
1
  import * as path from 'path';
2
+ /**
3
+ * True when `name` is a safe single path segment: non-empty, not '.'/'..',
4
+ * free of path separators and null bytes, and within the filename length limit.
5
+ * Dot-prefixed names like '.env.example' are allowed.
6
+ */
7
+ export function isSafeSegmentName(name) {
8
+ return (!!name &&
9
+ name !== '.' && name !== '..' &&
10
+ !/[\/\\\x00]/.test(name) &&
11
+ name.length <= 255);
12
+ }
2
13
  /**
3
14
  * Resolve base + name while preventing path-traversal attacks.
4
15
  * Rejects path separators, null bytes, '.' and '..', and any resolved path
@@ -7,10 +18,7 @@ import * as path from 'path';
7
18
  * Allows spaces, unicode, and other common filename characters.
8
19
  */
9
20
  export function safeJoin(base, name) {
10
- if (!name ||
11
- name === '.' || name === '..' ||
12
- /[\/\\\x00]/.test(name) ||
13
- name.length > 255) {
21
+ if (!isSafeSegmentName(name)) {
14
22
  throw new Error(`Invalid name: ${name}`);
15
23
  }
16
24
  const resolved = path.resolve(base, name);
@@ -18,3 +26,17 @@ export function safeJoin(base, name) {
18
26
  throw new Error(`Path escape: ${name}`);
19
27
  return resolved;
20
28
  }
29
+ /**
30
+ * Assert that `target` (which may legitimately contain path separators, e.g. a
31
+ * multi-segment relative key) stays within `root` after normalization. Use this
32
+ * where a caller must accept nested relative paths but the input is untrusted —
33
+ * `safeJoin` is stricter and only allows single segments.
34
+ */
35
+ export function assertWithin(root, target) {
36
+ const base = path.resolve(root);
37
+ const resolved = path.resolve(target);
38
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) {
39
+ throw new Error(`Path escape: ${target}`);
40
+ }
41
+ return resolved;
42
+ }
@@ -66,8 +66,16 @@ export interface JobConfig {
66
66
  schedule?: string;
67
67
  /** Event/webhook fire condition. Optional when `schedule` is set. */
68
68
  trigger?: JobTrigger;
69
- agent: AgentId;
69
+ /** Which agent runs the routine. Optional — omitted for `workflow`/`command` routines. Exactly one of agent/workflow/command must be set. */
70
+ agent?: AgentId;
70
71
  workflow?: string;
72
+ /**
73
+ * A plain shell command run directly instead of an agent/workflow — no LLM,
74
+ * no auth, no rotation, no tokens, no sandbox overlay. For deterministic
75
+ * housekeeping routines (version-check, `npm i -g`, `git pull`, notify).
76
+ * Mutually exclusive with `agent` and `workflow`.
77
+ */
78
+ command?: string;
71
79
  mode: 'plan' | 'edit' | 'auto' | 'skip' | 'full';
72
80
  effort: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
73
81
  timeout: string;
@@ -104,8 +112,10 @@ export interface JobConfig {
104
112
  export interface RunMeta {
105
113
  jobName: string;
106
114
  runId: string;
107
- agent: AgentId;
115
+ agent?: AgentId;
108
116
  workflow?: string;
117
+ /** The shell command that ran, for command-mode routines (no agent). */
118
+ command?: string;
109
119
  pid: number | null;
110
120
  /** Process birth time (epoch ms) recorded at spawn for pid-reuse detection. */
111
121
  spawnedAt?: number;
@@ -205,6 +215,15 @@ export declare function getLatestRun(jobName: string): RunMeta | null;
205
215
  export declare function writeRunMeta(meta: RunMeta): void;
206
216
  /** Read run metadata from disk. Returns null if missing or corrupt. */
207
217
  export declare function readRunMeta(jobName: string, runId: string): RunMeta | null;
218
+ /**
219
+ * Runs directory for a single job, with the (untrusted) job name contained to a
220
+ * single segment beneath the runs dir — same guard as `getJobHomePath`. The name
221
+ * comes from routine YAML and can arrive via a synced config repo; every runs-dir
222
+ * sink (run dir, meta read/write, last-report read) routes through here so a
223
+ * crafted `name` like `../../../../tmp/x` can't `mkdirSync`/write `stdout.log`,
224
+ * `meta.json`, or `report.md` outside `~/.agents/.history/runs`.
225
+ */
226
+ export declare function getJobRunsDir(jobName: string): string;
208
227
  /** Get the filesystem path for a specific run's directory. */
209
228
  export declare function getRunDir(jobName: string, runId: string): string;
210
229
  /** Discover routine YAML files in a repository's routines/ directory. */