@phnx-labs/agents-cli 1.20.62 → 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 (114) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +19 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +96 -28
  6. package/dist/commands/feed.d.ts +4 -0
  7. package/dist/commands/feed.js +27 -8
  8. package/dist/commands/funnel.d.ts +5 -0
  9. package/dist/commands/funnel.js +62 -0
  10. package/dist/commands/hosts.js +42 -0
  11. package/dist/commands/lease.d.ts +23 -0
  12. package/dist/commands/lease.js +201 -0
  13. package/dist/commands/mailboxes.d.ts +20 -0
  14. package/dist/commands/mailboxes.js +390 -0
  15. package/dist/commands/repo.d.ts +4 -4
  16. package/dist/commands/repo.js +30 -19
  17. package/dist/commands/routines.js +92 -29
  18. package/dist/commands/sessions-export.d.ts +2 -0
  19. package/dist/commands/sessions-export.js +279 -0
  20. package/dist/commands/sessions-import.d.ts +2 -0
  21. package/dist/commands/sessions-import.js +230 -0
  22. package/dist/commands/sessions-sync.d.ts +1 -0
  23. package/dist/commands/sessions-sync.js +16 -2
  24. package/dist/commands/sessions.js +12 -1
  25. package/dist/commands/setup.js +9 -0
  26. package/dist/commands/ssh.js +170 -5
  27. package/dist/commands/sync-provision.d.ts +23 -0
  28. package/dist/commands/sync-provision.js +107 -0
  29. package/dist/commands/usage.d.ts +2 -0
  30. package/dist/commands/usage.js +7 -2
  31. package/dist/commands/view.d.ts +1 -1
  32. package/dist/commands/webhook.d.ts +9 -0
  33. package/dist/commands/webhook.js +93 -0
  34. package/dist/index.js +7 -2
  35. package/dist/lib/agents.d.ts +44 -0
  36. package/dist/lib/agents.js +85 -35
  37. package/dist/lib/browser/drivers/ssh.js +19 -2
  38. package/dist/lib/browser/ipc.js +5 -4
  39. package/dist/lib/browser/profiles.d.ts +13 -0
  40. package/dist/lib/browser/profiles.js +17 -0
  41. package/dist/lib/browser/service.d.ts +12 -1
  42. package/dist/lib/browser/service.js +48 -13
  43. package/dist/lib/browser/sessions-list.d.ts +40 -0
  44. package/dist/lib/browser/sessions-list.js +190 -0
  45. package/dist/lib/comms-render.d.ts +37 -0
  46. package/dist/lib/comms-render.js +89 -0
  47. package/dist/lib/crabbox/cli.d.ts +72 -0
  48. package/dist/lib/crabbox/cli.js +158 -9
  49. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  50. package/dist/lib/crabbox/runtimes.js +24 -0
  51. package/dist/lib/daemon.js +8 -1
  52. package/dist/lib/devices/fleet.d.ts +62 -0
  53. package/dist/lib/devices/fleet.js +128 -0
  54. package/dist/lib/devices/health.d.ts +77 -0
  55. package/dist/lib/devices/health.js +186 -0
  56. package/dist/lib/funnel.d.ts +5 -0
  57. package/dist/lib/funnel.js +23 -0
  58. package/dist/lib/git.d.ts +21 -5
  59. package/dist/lib/git.js +64 -14
  60. package/dist/lib/hosts/credentials.d.ts +28 -0
  61. package/dist/lib/hosts/credentials.js +48 -0
  62. package/dist/lib/hosts/dispatch.d.ts +25 -0
  63. package/dist/lib/hosts/dispatch.js +68 -2
  64. package/dist/lib/hosts/passthrough.d.ts +13 -10
  65. package/dist/lib/hosts/passthrough.js +119 -29
  66. package/dist/lib/mailbox-gc.js +4 -16
  67. package/dist/lib/mailbox.d.ts +39 -0
  68. package/dist/lib/mailbox.js +112 -0
  69. package/dist/lib/migrate.d.ts +12 -0
  70. package/dist/lib/migrate.js +55 -1
  71. package/dist/lib/paths.d.ts +13 -0
  72. package/dist/lib/paths.js +26 -4
  73. package/dist/lib/routines.d.ts +50 -12
  74. package/dist/lib/routines.js +82 -27
  75. package/dist/lib/runner.js +255 -13
  76. package/dist/lib/sandbox.d.ts +9 -1
  77. package/dist/lib/sandbox.js +11 -2
  78. package/dist/lib/session/bundle.d.ts +150 -0
  79. package/dist/lib/session/bundle.js +189 -0
  80. package/dist/lib/session/remote-bundle.d.ts +12 -0
  81. package/dist/lib/session/remote-bundle.js +61 -0
  82. package/dist/lib/session/sync/agents.d.ts +56 -6
  83. package/dist/lib/session/sync/agents.js +0 -0
  84. package/dist/lib/session/sync/config.d.ts +8 -0
  85. package/dist/lib/session/sync/config.js +6 -1
  86. package/dist/lib/session/sync/manifest.d.ts +14 -3
  87. package/dist/lib/session/sync/manifest.js +4 -0
  88. package/dist/lib/session/sync/provision.d.ts +49 -0
  89. package/dist/lib/session/sync/provision.js +91 -0
  90. package/dist/lib/session/sync/sync.d.ts +26 -2
  91. package/dist/lib/session/sync/sync.js +192 -69
  92. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  93. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  94. package/dist/lib/ssh-tunnel.js +13 -1
  95. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  96. package/dist/lib/staleness/detectors/subagents.js +5 -192
  97. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  98. package/dist/lib/staleness/writers/subagents.js +11 -102
  99. package/dist/lib/startup/command-registry.d.ts +4 -0
  100. package/dist/lib/startup/command-registry.js +16 -0
  101. package/dist/lib/state.d.ts +10 -2
  102. package/dist/lib/state.js +14 -2
  103. package/dist/lib/subagents-registry.d.ts +85 -0
  104. package/dist/lib/subagents-registry.js +393 -0
  105. package/dist/lib/subagents.d.ts +8 -8
  106. package/dist/lib/subagents.js +32 -663
  107. package/dist/lib/sync-umbrella.d.ts +1 -0
  108. package/dist/lib/sync-umbrella.js +14 -3
  109. package/dist/lib/triggers/webhook.d.ts +70 -27
  110. package/dist/lib/triggers/webhook.js +264 -43
  111. package/dist/lib/types.d.ts +9 -0
  112. package/dist/lib/usage.d.ts +42 -3
  113. package/dist/lib/usage.js +162 -22
  114. package/package.json +1 -1
@@ -1,10 +1,8 @@
1
1
  /**
2
2
  * Generic `--host` passthrough — the single choke point that runs an allowlisted
3
- * `agents <command>` on a remote host instead of locally, so read-only and
4
- * config commands (`view`, `usage`, `cost`, `doctor`, `inspect`, `list`, `sync`)
5
- * and the team lifecycle (`teams …`) all gain remote support with no per-command
6
- * code. Called once from `index.ts` before commander parses; returns `true` when
7
- * it handled the invocation (the local command must then NOT run).
3
+ * `agents <command>` on a remote host instead of locally. Called once from
4
+ * `index.ts` before commander parses; returns `true` when it handled the
5
+ * invocation (the local command must then NOT run).
8
6
  *
9
7
  * Transport is SSH (via `ssh-exec.ts`), never a daemon: SSH is the one hardened
10
8
  * choke point already used everywhere, and it gives auth + encryption + host-key
@@ -12,9 +10,11 @@
12
10
  * long-running case — `teams start --watch` — dispatches detached so the remote
13
11
  * supervisor outlives a dropped connection.
14
12
  *
15
- * `run`, `sessions`, and `feed` are deliberately absent from the table below:
16
- * they own richer `--host` handling in their own command actions (detached run
17
- * dispatch or multi-host fan-out) and must fall through to it.
13
+ * Commands with their own richer `--host` handling (`run`/`sessions`/`feed`/
14
+ * `computer`/`secrets`/`logs`/…) are listed in {@link OWN_HOST_COMMANDS} and
15
+ * fall through to their local actions. Everything else either routes via this
16
+ * table or, when `--host`/`--device` is present, exits with a clear
17
+ * "not supported" message — never commander's raw `unknown option`.
18
18
  */
19
19
  import chalk from 'chalk';
20
20
  import { assertValidSshTarget, sshStream } from '../ssh-exec.js';
@@ -24,19 +24,90 @@ import { dispatchAgentsCommand } from './dispatch.js';
24
24
  import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
25
25
  import { resolveRemoteOsSync } from './remote-os.js';
26
26
  import { machineId } from '../session/sync/config.js';
27
+ /**
28
+ * First-class groups that run transparently on a remote via SSH when
29
+ * `--host`/`--device` is present. Keep both canonical names and aliases
30
+ * (`repo`/`repos`, `exec`/`run`) so either argv form routes the same way.
31
+ *
32
+ * Prefer adding here over per-command SSH code — this is the single choke point.
33
+ */
27
34
  const REMOTE_PASSTHROUGH = {
35
+ // status / inspect
28
36
  view: {},
37
+ inspect: {},
38
+ doctor: {},
39
+ status: {},
40
+ check: {},
41
+ list: {},
29
42
  usage: {},
30
43
  cost: {},
31
44
  output: {},
32
- doctor: {},
33
- inspect: {},
34
- list: {},
45
+ budget: {},
46
+ // config / resources
35
47
  sync: { nonInteractive: ['--yes'] },
48
+ pull: {},
49
+ push: {},
50
+ repo: {},
51
+ repos: {},
52
+ plugins: {},
53
+ skills: {},
54
+ hooks: {},
55
+ commands: {},
56
+ rules: {},
57
+ memory: {},
58
+ permissions: {},
59
+ perms: {},
60
+ mcp: {},
61
+ cli: {},
62
+ subagents: {},
63
+ workflows: {},
64
+ packages: {},
65
+ models: {},
66
+ profiles: {},
67
+ defaults: {},
68
+ alias: {},
69
+ // lifecycle
36
70
  teams: {},
37
71
  message: {},
38
72
  routines: {},
73
+ jobs: {},
74
+ cron: {},
75
+ // misc remote-sensible
76
+ prune: {},
77
+ trash: {},
78
+ restore: {},
79
+ worktree: {},
80
+ events: {},
81
+ audit: {},
82
+ lock: {},
83
+ feedback: {},
84
+ wallet: {},
85
+ daemon: {},
86
+ pty: {},
87
+ tmux: {},
88
+ watchdog: {},
89
+ factory: {},
90
+ browser: {},
91
+ versions: {},
39
92
  };
93
+ /**
94
+ * Commands that register and interpret `--host`/`--device` themselves — must
95
+ * fall through to local commander even when the flag is present. Do not add
96
+ * these to {@link REMOTE_PASSTHROUGH}.
97
+ */
98
+ const OWN_HOST_COMMANDS = new Set([
99
+ 'run',
100
+ 'exec', // deprecated alias of run
101
+ 'sessions',
102
+ 'feed',
103
+ 'computer',
104
+ 'secrets',
105
+ 'logs',
106
+ 'hosts',
107
+ 'ssh',
108
+ 'devices',
109
+ 'fleet', // alias of devices
110
+ ]);
40
111
  /** `--no-tty` is stripped like the routing flags but carries no value. */
41
112
  const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
42
113
  /** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
@@ -83,15 +154,26 @@ async function resolveTargetHost(name, any) {
83
154
  /**
84
155
  * Route `agents <command> … --host <name>` to a remote if the command is
85
156
  * host-routable and a `--host` (or its `--device` alias) was given. Returns
86
- * `false` (run locally) when neither flag is present, the command isn't in the
87
- * table, or the target is this very machine.
157
+ * `false` (run locally) when neither flag is present, the command owns its own
158
+ * host handling, the target is this very machine, or placement flags need the
159
+ * local action. Returns `true` after printing a clear error when the flag is
160
+ * present on a command that is neither routable nor self-handling — so the user
161
+ * never sees commander's raw `unknown option '--host'`.
88
162
  *
89
163
  * @param command the resolved subcommand name (`process.argv`'s first non-flag).
90
164
  * @param allArgs `process.argv.slice(2)` — the command name followed by its args.
91
165
  */
92
166
  export async function maybeRunOnHost(command, allArgs) {
93
- const spec = REMOTE_PASSTHROUGH[command];
94
- if (!spec)
167
+ const hostFlag = flagValue(allArgs, 'host', 'H');
168
+ const deviceFlag = flagValue(allArgs, 'device');
169
+ const hostName = hostFlag ?? deviceFlag;
170
+ if (!hostName)
171
+ return false;
172
+ // Commands with their own richer --host semantics must reach local commander
173
+ // BEFORE any single-target conflict gate. sessions/feed merge --host and
174
+ // --device into a multi-host list; rejecting "conflicting" pairs would break
175
+ // `agents sessions --host a --device b` / `agents feed --host a --device b`.
176
+ if (OWN_HOST_COMMANDS.has(command))
95
177
  return false;
96
178
  // Placement, not routing: `teams add`/`teams create` read `--device`/`--devices`
97
179
  // (and `--host`/`--hosts`) as WHERE to place a teammate / the team pool — the
@@ -107,16 +189,6 @@ export async function maybeRunOnHost(command, allArgs) {
107
189
  return false;
108
190
  }
109
191
  }
110
- // `--device` is a first-class alias of `--host` (mirrors `agents run`); the
111
- // device registry is the source of truth for machine identity. Reject a
112
- // conflicting pair rather than silently preferring one — same rule as run.
113
- const hostFlag = flagValue(allArgs, 'host', 'H');
114
- const deviceFlag = flagValue(allArgs, 'device');
115
- if (hostFlag && deviceFlag && hostFlag !== deviceFlag) {
116
- console.error(chalk.red('Conflicting --host/--device values — pass just one.'));
117
- process.exitCode = 1;
118
- return true;
119
- }
120
192
  // `--hosts` is always a generic fleet flag — bail for every command so the
121
193
  // local aggregator handles it. `--devices` is fan-out on most commands but
122
194
  // a placement flag on `routines` (which devices may run the routine), so
@@ -125,14 +197,32 @@ export async function maybeRunOnHost(command, allArgs) {
125
197
  return false;
126
198
  if (allArgs.includes('--devices') && command !== 'routines')
127
199
  return false;
128
- const hostName = hostFlag ?? deviceFlag;
129
- if (!hostName)
130
- return false;
200
+ const spec = REMOTE_PASSTHROUGH[command];
201
+ if (!spec) {
202
+ // Flag was accepted (no raw commander "unknown option") but this group has
203
+ // no remote semantics — say so clearly instead of falling through.
204
+ console.error(chalk.red(`\`agents ${command}\` does not support --host/--device (no remote interpretation).`) +
205
+ chalk.gray(' Run without the flag, or use a host-routable group (repos, view, sync, teams, doctor, …).'));
206
+ process.exitCode = 1;
207
+ return true;
208
+ }
209
+ // Single-target remote path only: reject a conflicting --host/--device pair
210
+ // rather than silently preferring one (same rule as `agents run`).
211
+ if (hostFlag && deviceFlag && hostFlag !== deviceFlag) {
212
+ console.error(chalk.red('Conflicting --host/--device values — pass just one.'));
213
+ process.exitCode = 1;
214
+ return true;
215
+ }
131
216
  // Running against your own machine is just a local run — skip the SSH round-trip.
132
217
  // `machineId()` is the same self-identifier the device registry and session
133
218
  // sync use (lowercased short hostname); compare case-insensitively.
134
- if (hostName.toLowerCase() === machineId())
219
+ // Strip the routing flags from process.argv so the local command never sees
220
+ // an unregistered `--host`/`--device` and dies with "unknown option".
221
+ if (hostName.toLowerCase() === machineId()) {
222
+ const stripped = stripRoutingFlags(allArgs, STRIP_SPECS);
223
+ process.argv = [process.argv[0], process.argv[1], ...stripped];
135
224
  return false;
225
+ }
136
226
  const remoteCwd = flagValue(allArgs, 'remote-cwd');
137
227
  const any = allArgs.includes('--any');
138
228
  let host;
@@ -9,7 +9,7 @@
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { getMailboxRootDir } from './state.js';
12
- import { mailboxDir, isValidMailboxId, readMessage, } from './mailbox.js';
12
+ import { mailboxDir, isValidMailboxId, readMessage, sweepExpired, } from './mailbox.js';
13
13
  import { listBlocks, removeBlock } from './feed.js';
14
14
  const DEFAULT_MAX_CONSUMED_AGE_MINUTES = 24 * 60;
15
15
  function consumedAgeMinutes(file, now) {
@@ -134,21 +134,9 @@ export function gcMailbox(activeBoxIds, options = {}) {
134
134
  }
135
135
  }
136
136
  else {
137
- // Live box: lazy expiry is handled by drain/peek, but sweep here for GC metrics.
138
- // We still need to read expired messages; reuse readMessage directly to avoid
139
- // importing sweepExpired and duplicating logic.
140
- for (const sub of ['inbox', 'processing']) {
141
- const dir = path.join(boxDir, sub);
142
- for (const file of jsonFiles(dir)) {
143
- const msg = readMessage(path.join(dir, file));
144
- if (msg && msg.expiresAt) {
145
- const ts = Date.parse(msg.expiresAt);
146
- if (!Number.isNaN(ts) && ts <= now.getTime()) {
147
- result.messagesDroppedExpired++;
148
- }
149
- }
150
- }
151
- }
137
+ // Live box: archive expired messages (same path as drain/peek) so GC does
138
+ // not leave expired files in inbox/processing while only bumping metrics.
139
+ result.messagesDroppedExpired += sweepExpired(boxDir, name, now);
152
140
  result.consumedPruned += pruneConsumed(boxDir, maxConsumedAgeMinutes, now);
153
141
  }
154
142
  }
@@ -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
+ }
@@ -44,6 +44,18 @@ export declare function foldLegacySystemRepo(): void;
44
44
  * drive a fixture tree without touching the user's ~/.agents.
45
45
  */
46
46
  export declare function migrateAgentsYaml(systemDir?: string, userDir?: string): void;
47
+ /**
48
+ * Fold the legacy GLOBAL browser captures root
49
+ * (`~/.agents/.cache/browser/sessions/<task>/`) into the new PER-PROFILE layout
50
+ * (`~/.agents/.cache/browser/<profile>/sessions/<task>/`), so each profile is one
51
+ * self-contained tree and the new `browser sessions` listing finds old captures.
52
+ *
53
+ * Attribution: a legacy `sessions/<task>` dir is owned by whichever profile's
54
+ * `tasks.json` still lists that task name. Tasks no profile claims (the profile
55
+ * was deleted, or tasks.json was cleared) move under a `_legacy` pseudo-profile so
56
+ * nothing is lost. Idempotent — once the global `sessions/` root is gone it no-ops.
57
+ */
58
+ export declare function foldBrowserSessionsIntoProfiles(browserDir?: string): void;
47
59
  /**
48
60
  * Repair self-referential agent binary symlinks.
49
61
  *
@@ -359,6 +359,56 @@ function foldUserHooksYamlIntoAgentsYaml() {
359
359
  }
360
360
  catch { /* best-effort */ }
361
361
  }
362
+ /**
363
+ * Fold the legacy GLOBAL browser captures root
364
+ * (`~/.agents/.cache/browser/sessions/<task>/`) into the new PER-PROFILE layout
365
+ * (`~/.agents/.cache/browser/<profile>/sessions/<task>/`), so each profile is one
366
+ * self-contained tree and the new `browser sessions` listing finds old captures.
367
+ *
368
+ * Attribution: a legacy `sessions/<task>` dir is owned by whichever profile's
369
+ * `tasks.json` still lists that task name. Tasks no profile claims (the profile
370
+ * was deleted, or tasks.json was cleared) move under a `_legacy` pseudo-profile so
371
+ * nothing is lost. Idempotent — once the global `sessions/` root is gone it no-ops.
372
+ */
373
+ export function foldBrowserSessionsIntoProfiles(browserDir = path.join(CACHE_DIR, 'browser')) {
374
+ const legacySessionsDir = path.join(browserDir, 'sessions');
375
+ let taskDirs;
376
+ try {
377
+ taskDirs = fs.readdirSync(legacySessionsDir, { withFileTypes: true });
378
+ }
379
+ catch {
380
+ return; // no legacy global sessions/ root — already folded or never existed
381
+ }
382
+ // Map task name -> owning profile from every profile's tasks.json (first wins).
383
+ const taskOwner = new Map();
384
+ let profileDirs = [];
385
+ try {
386
+ profileDirs = fs.readdirSync(browserDir, { withFileTypes: true });
387
+ }
388
+ catch { /* browserDir vanished mid-run */ }
389
+ for (const p of profileDirs) {
390
+ if (!p.isDirectory() || p.name === 'sessions' || p.name === '_legacy')
391
+ continue;
392
+ try {
393
+ const state = JSON.parse(fs.readFileSync(path.join(browserDir, p.name, 'tasks.json'), 'utf-8'));
394
+ for (const taskName of Object.keys(state)) {
395
+ if (!taskOwner.has(taskName))
396
+ taskOwner.set(taskName, p.name);
397
+ }
398
+ }
399
+ catch { /* missing or invalid tasks.json */ }
400
+ }
401
+ for (const taskEntry of taskDirs) {
402
+ if (!taskEntry.isDirectory())
403
+ continue;
404
+ const owner = taskOwner.get(taskEntry.name) ?? '_legacy';
405
+ // A capture dir can hold a nested recordings/ subdir and may collide with a
406
+ // dest that already has newer captures — moveDirOnce merges (skip-existing),
407
+ // moveFileOnce would EISDIR on unlink and silently strand the captures.
408
+ moveDirOnce(path.join(legacySessionsDir, taskEntry.name), path.join(browserDir, owner, 'sessions', taskEntry.name));
409
+ }
410
+ rmEmptyDirTree(legacySessionsDir);
411
+ }
362
412
  /**
363
413
  * Fold ~/.agents/browser/profiles/*.yaml into ~/.agents/agents.yaml under a
364
414
  * `browser:` key, then delete the profiles directory. Single user file to sync.
@@ -1582,7 +1632,7 @@ function containsOnlyDsStore(dir) {
1582
1632
  function warnSystemOrphans() {
1583
1633
  const SHIPPED_ALLOWLIST = new Set([
1584
1634
  // resource directories shipped by the npm package
1585
- 'commands', 'hooks', 'skills', 'rules', 'mcp', 'cli', 'permissions', 'subagents', 'profiles', 'agents',
1635
+ 'commands', 'hooks', 'skills', 'rules', 'mcp', 'cli', 'permissions', 'subagents', 'profiles', 'agents', 'routines',
1586
1636
  // top-level metadata files
1587
1637
  'agents.yaml', 'hooks.yaml', 'README.md', 'CHANGELOG.md',
1588
1638
  // git + repo metadata
@@ -1966,6 +2016,10 @@ export async function runMigration() {
1966
2016
  // migrateRuntimeToCache so any legacy plugins/ still at the user-root from
1967
2017
  // very-old layouts have already been handled.
1968
2018
  migratePluginsBackToUserRoot();
2019
+ // Browser captures: fold the legacy global browser/sessions/<task> root into the
2020
+ // per-profile browser/<profile>/sessions/<task> layout. After the cache moves so
2021
+ // the browser dir is at its canonical .cache location.
2022
+ foldBrowserSessionsIntoProfiles();
1969
2023
  // System-repo sweep: move every remaining operational dir into its canonical
1970
2024
  // user-bucket location, then drop known-dead artifacts and warn about
1971
2025
  // anything we don't recognize. Order: durable (sessions/teams/trash/repos/
@@ -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
+ }