@phnx-labs/agents-cli 1.22.25 → 1.22.26

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 (82) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +17 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/browser.js +35 -0
  7. package/dist/commands/doctor.js +8 -0
  8. package/dist/commands/insights.d.ts +25 -19
  9. package/dist/commands/insights.js +107 -33
  10. package/dist/commands/reconnect.d.ts +46 -0
  11. package/dist/commands/reconnect.js +109 -0
  12. package/dist/commands/routines.js +2 -2
  13. package/dist/commands/secrets.d.ts +2 -8
  14. package/dist/commands/secrets.js +29 -105
  15. package/dist/commands/sessions.js +4 -0
  16. package/dist/commands/setup-secrets.d.ts +1 -0
  17. package/dist/commands/setup-secrets.js +1 -1
  18. package/dist/commands/setup.d.ts +26 -3
  19. package/dist/commands/setup.js +105 -46
  20. package/dist/commands/teams.d.ts +6 -0
  21. package/dist/commands/teams.js +43 -0
  22. package/dist/commands/trends.d.ts +8 -0
  23. package/dist/commands/trends.js +10 -156
  24. package/dist/index.js +1 -1
  25. package/dist/lib/agents.d.ts +11 -0
  26. package/dist/lib/agents.js +29 -2
  27. package/dist/lib/analytics/dashboard.d.ts +10 -6
  28. package/dist/lib/analytics/dashboard.js +6 -4
  29. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  30. package/dist/lib/analytics/mix-commands.js +229 -0
  31. package/dist/lib/analytics/recipes.d.ts +19 -14
  32. package/dist/lib/analytics/recipes.js +4 -2
  33. package/dist/lib/browser/ipc.d.ts +26 -0
  34. package/dist/lib/browser/ipc.js +139 -24
  35. package/dist/lib/browser/profiles.d.ts +11 -0
  36. package/dist/lib/browser/profiles.js +1 -1
  37. package/dist/lib/browser/stream.d.ts +14 -0
  38. package/dist/lib/browser/stream.js +71 -0
  39. package/dist/lib/channels/owner-sink.d.ts +27 -0
  40. package/dist/lib/channels/owner-sink.js +93 -0
  41. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  42. package/dist/lib/devices/doctor-findings.js +33 -1
  43. package/dist/lib/fleet/apply.d.ts +59 -3
  44. package/dist/lib/fleet/apply.js +183 -6
  45. package/dist/lib/fleet/types.d.ts +21 -2
  46. package/dist/lib/hooks/cache.js +15 -0
  47. package/dist/lib/hosts/passthrough.d.ts +23 -0
  48. package/dist/lib/hosts/passthrough.js +45 -0
  49. package/dist/lib/hosts/ready.d.ts +2 -0
  50. package/dist/lib/hosts/ready.js +10 -1
  51. package/dist/lib/hosts/reconnect.d.ts +14 -12
  52. package/dist/lib/hosts/reconnect.js +41 -40
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  54. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  55. package/dist/lib/routines.js +14 -2
  56. package/dist/lib/runner.d.ts +0 -3
  57. package/dist/lib/runner.js +1 -14
  58. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/push.d.ts +94 -0
  61. package/dist/lib/secrets/push.js +145 -0
  62. package/dist/lib/secrets/reaper.d.ts +15 -1
  63. package/dist/lib/secrets/reaper.js +30 -3
  64. package/dist/lib/session/db.d.ts +21 -3
  65. package/dist/lib/session/db.js +221 -13
  66. package/dist/lib/session/discover.d.ts +1 -0
  67. package/dist/lib/session/discover.js +115 -19
  68. package/dist/lib/session/insights.d.ts +18 -0
  69. package/dist/lib/session/insights.js +143 -1
  70. package/dist/lib/session/tool-index.js +133 -22
  71. package/dist/lib/session/tool-store.d.ts +26 -2
  72. package/dist/lib/session/tool-store.js +36 -17
  73. package/dist/lib/ssh-exec.js +8 -2
  74. package/dist/lib/startup/command-registry.d.ts +1 -0
  75. package/dist/lib/startup/command-registry.js +4 -0
  76. package/dist/lib/teams/agents.d.ts +13 -0
  77. package/dist/lib/teams/agents.js +75 -7
  78. package/dist/lib/teams/placement-probe.d.ts +21 -0
  79. package/dist/lib/teams/placement-probe.js +135 -0
  80. package/dist/lib/teams/scheduler.d.ts +74 -1
  81. package/dist/lib/teams/scheduler.js +187 -10
  82. package/package.json +1 -1
@@ -1,23 +1,26 @@
1
1
  /**
2
- * Insights command — how you actually work, split by the account that did the work.
2
+ * Insights command — one observe verb for "how work looks".
3
3
  *
4
- * The behavioural sibling of the existing rollups, and deliberately not a duplicate of
5
- * any of them:
4
+ * Two data paths under one name (do not re-split into peer top-level commands):
6
5
  *
7
- * agents cost what you spent ($ and duration)
8
- * agents output what shipped (burn vs PRs and commits)
9
- * agents usage live quota headroom (rate-limit windows, right now)
10
- * agents trends aggregate distributions (harness mix, tools-per-session, token ratios)
11
- * agents sessions browse individual work (search, resume, render)
12
- * agents insights HOW you work (tools, friction, rhythm, per account)
6
+ * agents insights HOW you work (transcript content: tools, friction,
7
+ * rhythm, edits) split by Claude account by default
8
+ * agents insights mix COUNTERS (sessions index + usage.db recipes:
9
+ * harness/model mix, token ratios, secrets, browser)
10
+ * agents insights <recipe> One baked mix recipe (harness-mix, tools-per-session, )
11
+ * agents insights query Raw usage.db rows
13
12
  *
14
- * The closest neighbour is `agents trends`, and the boundary is the data path: trends
15
- * reads counters — `tool_scan_ledger` call counts and the analytics warehouse — to
16
- * produce distributions ("how many tool calls per session, by harness"). This reads
17
- * transcript CONTENT through `parseSession` to produce behaviour ("which tools, which
18
- * languages, where it went wrong, when you were working"), and splits all of it by
19
- * account, a dimension trends does not have. They overlap in spirit on tool and model
20
- * mix; they do not read the same store or answer the same question.
13
+ * Sibling observe verbs (stay separate different questions):
14
+ *
15
+ * agents cost what you spent ($ and duration)
16
+ * agents output what shipped (burn vs PRs and commits)
17
+ * agents usage live quota headroom
18
+ * agents perf latency (hooks, CLI commands, agent.run) not popularity
19
+ * agents sessions stats which skills/slash-commands were explicitly invoked
20
+ *
21
+ * Why mix lives here (not a second top-level `trends`): two abstract "analytics"
22
+ * nouns taught agents and humans to guess. One verb, two engines — cheap SQL mix
23
+ * vs transcript facets. Latency stays on `perf` so it is never confused with mix.
21
24
  *
22
25
  * Modelled on Claude Code's `/insights`, with the difference that motivated it: that
23
26
  * command reads one account's directory, while `balanced` rotation sprays sessions
@@ -27,6 +30,8 @@
27
30
  * The deterministic report makes zero network calls. `--narrative` is opt-in and adds
28
31
  * the coaching prose by piping the AGGREGATE (never raw transcripts) through a headless
29
32
  * `claude -p`.
33
+ *
34
+ * `agents trends` is a thin deprecated alias of the mix tree only (see commands/trends.ts).
30
35
  */
31
36
  import * as fs from 'fs';
32
37
  import chalk from 'chalk';
@@ -37,11 +42,25 @@ import { setHelpSections } from '../lib/help.js';
37
42
  import { discoverSessions, parseTimeFilter } from '../lib/session/discover.js';
38
43
  import { querySessions, readSessionInsights, writeSessionInsights, clearSessionInsights, } from '../lib/session/db.js';
39
44
  import { parseSession } from '../lib/session/parse.js';
40
- import { computeInsightFacets, mergeFacets, newFacetAccumulator, detectOverlap, percentile, bucketGaps, topEntries, } from '../lib/session/insights.js';
45
+ import { computeInsightFacets, mergeFacets, newFacetAccumulator, detectOverlap, percentile, bucketGaps, topEntries, buildInsightActions, } from '../lib/session/insights.js';
41
46
  import { formatUsd } from '../lib/pricing/index.js';
42
47
  import { formatDuration } from '../lib/session/render.js';
43
48
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
49
+ import { registerMixCommands } from '../lib/analytics/mix-commands.js';
44
50
  const execFileAsync = promisify(execFile);
51
+ function collectAgent(value, previous) {
52
+ return [...previous, value];
53
+ }
54
+ function agentsFromArgv(argv) {
55
+ const values = [];
56
+ for (let i = 0; i < argv.length; i++) {
57
+ if (argv[i] === '--agent' && argv[i + 1])
58
+ values.push(argv[++i]);
59
+ else if (argv[i].startsWith('--agent='))
60
+ values.push(argv[i].slice('--agent='.length));
61
+ }
62
+ return values;
63
+ }
45
64
  function resolveGroup(by) {
46
65
  if (by === undefined)
47
66
  return 'account';
@@ -187,7 +206,7 @@ function renderHours(hours, out) {
187
206
  out.push(` ${chalk.cyan(spark)}`);
188
207
  out.push(` ${chalk.gray('0h'.padEnd(6))}${chalk.gray('6h'.padEnd(6))}${chalk.gray('12h'.padEnd(6))}${chalk.gray('18h'.padEnd(5))}${chalk.gray('23h')}`);
189
208
  }
190
- function renderReport(groups, dim, meta) {
209
+ function renderReport(groups, dim, meta, actions, harnesses) {
191
210
  const out = [];
192
211
  const scope = meta.since ? `last ${meta.since}` : 'all time';
193
212
  out.push(chalk.bold('Insights') + chalk.gray(` ${scope} · ${meta.analyzed} of ${meta.scanned} sessions`));
@@ -232,6 +251,22 @@ function renderReport(groups, dim, meta) {
232
251
  for (const e of errs)
233
252
  out.push(` ${chalk.gray('·')} ${padToWidth(e.name, 16)} ${chalk.gray(String(e.count))}`);
234
253
  }
254
+ renderCounts('Friction / thrash', topEntries(all.frictionSignals, 10), out);
255
+ renderCounts('Dissatisfaction / corrections', topEntries(all.correctionSignals, 10), out);
256
+ renderCounts('Automatable repeats', topEntries(all.automationSignals, 10), out);
257
+ renderCounts('Harness split', harnesses, out);
258
+ out.push('');
259
+ out.push(chalk.bold('Actions'));
260
+ if (actions.length === 0) {
261
+ out.push(chalk.gray(' No repeated action pattern met the evidence threshold in this window.'));
262
+ }
263
+ else {
264
+ out.push(chalk.gray(' pri category evidence sample sessions action'));
265
+ for (const action of actions.slice(0, 12)) {
266
+ out.push(` ${padToWidth(action.priority, 7)} ${padToWidth(action.category, 11)} ` +
267
+ `${String(action.evidenceCount).padStart(8)} ${padToWidth(action.sampleSessionIds.join(', '), 25)} ${action.action}`);
268
+ }
269
+ }
235
270
  // Output
236
271
  out.push('');
237
272
  out.push(chalk.bold('What you changed'));
@@ -351,11 +386,11 @@ async function insightsAction(options) {
351
386
  if (options.refresh)
352
387
  clearSessionInsights();
353
388
  const filter = { sinceMs };
354
- if (options.agent)
355
- filter.agent = options.agent;
356
389
  const scanned = querySessions(filter);
357
390
  const wanted = options.account?.toLowerCase();
358
391
  const inScope = scanned.filter((m) => {
392
+ if (options.agent?.length && !options.agent.includes(m.agent))
393
+ return false;
359
394
  if (!wanted)
360
395
  return true;
361
396
  return [m.accountKey, m.account, m.accountOrg]
@@ -381,6 +416,15 @@ async function insightsAction(options) {
381
416
  });
382
417
  const overlap = detectOverlap(spans);
383
418
  const groups = buildGroups(substantive, facetsById, dim);
419
+ const evidence = substantive.flatMap((m) => {
420
+ const facets = facetsById.get(m.id);
421
+ return facets ? [{ id: m.id, facets }] : [];
422
+ });
423
+ const actions = buildInsightActions(evidence);
424
+ const harnesses = topEntries(substantive.reduce((counts, row) => {
425
+ counts[row.agent] = (counts[row.agent] ?? 0) + 1;
426
+ return counts;
427
+ }, {}), 20);
384
428
  if (options.json) {
385
429
  const payload = {
386
430
  generatedAt: new Date().toISOString(),
@@ -392,6 +436,8 @@ async function insightsAction(options) {
392
436
  minMessages,
393
437
  by: dim,
394
438
  overlap,
439
+ actions,
440
+ harnesses,
395
441
  groups: groups.map((g) => ({
396
442
  key: g.key,
397
443
  label: g.label,
@@ -420,7 +466,7 @@ async function insightsAction(options) {
420
466
  unreadable,
421
467
  minMessages,
422
468
  overlap,
423
- });
469
+ }, actions, harnesses);
424
470
  if (options.narrative) {
425
471
  await renderNarrative(groups.map((g) => ({
426
472
  account: g.label, sessions: g.sessions, costUsd: g.costUsd,
@@ -434,49 +480,77 @@ async function insightsAction(options) {
434
480
  })));
435
481
  }
436
482
  }
437
- export function registerInsightsCommand(program) {
438
- const cmd = addHostOption(program.command('insights'))
439
- .description('How you work — tools, friction, and rhythm, split by the account that did the work')
483
+ function configureInsightsCommand(cmd) {
484
+ addHostOption(cmd)
485
+ .description('How work looks behavioural report (default) or counter mix (`mix`, recipes)')
440
486
  .option('--json', 'Output the full report as JSON')
441
487
  .option('--since <time>', 'Window: 7d, 4w, 3mo, an ISO date, or "all" (default 30d)')
442
488
  .option('--all', 'Every session ever indexed. Alias for --since all')
443
489
  .option('--by <dimension>', 'Group by: account (default), agent, project, or day')
444
490
  .option('--account <match>', 'Only sessions whose account key, email, or org contains this')
445
- .option('--agent <id>', 'Only one harness (claude, codex, droid, )')
491
+ .option('--agent <id>', 'Only these harnesses; repeat for more than one', collectAgent, [])
446
492
  .option('--min-messages <n>', 'Skip sessions under this many messages, both roles counted (default 2)')
447
493
  .option('--refresh', 'Discard cached facets and re-read every transcript')
448
494
  .option('--narrative', 'Add a written read on the numbers via a headless `claude -p`')
449
495
  .action(async (options) => {
450
- await insightsAction(options);
496
+ const inherited = cmd.parent?.name() === 'sessions'
497
+ ? cmd.parent.opts()
498
+ : {};
499
+ const inheritedAgent = typeof inherited.agent === 'string' ? [inherited.agent] : [];
500
+ const rawAgents = agentsFromArgv(process.argv.slice(2));
501
+ await insightsAction({
502
+ ...inherited,
503
+ ...options,
504
+ agent: rawAgents.length > 0 ? rawAgents : [...inheritedAgent, ...(options.agent ?? [])],
505
+ json: options.json ?? inherited.json,
506
+ since: options.since ?? inherited.since,
507
+ });
451
508
  });
509
+ // Cheap counter recipes (former top-level `agents trends`) — same parent, no peer verb.
510
+ registerMixCommands(cmd);
452
511
  setHelpSections(cmd, {
453
512
  examples: `
454
- # Last 30 days, split by Claude account — the default
513
+ # Behavioural report — last 30 days, split by Claude account (default)
455
514
  agents insights
456
515
 
457
516
  # Which repo is eating the time
458
517
  agents insights --by project --since 90d
459
518
 
519
+ # Counter mix board (harness/model/token/secrets recipes) — former agents trends
520
+ agents insights mix
521
+ agents insights mix --days 30
522
+ agents insights harness-mix --json
523
+ agents insights query --kind secret --days 7
524
+
460
525
  # One account only, all of its history
461
526
  agents insights --account "Turing Labs" --all
462
527
 
463
528
  # Machine-readable, for a dashboard or a slash command
464
- agents insights --json
529
+ agents sessions insights --agent claude --agent codex --json
465
530
 
466
531
  # Add a written read on what to change
467
532
  agents insights --narrative
468
533
  `,
469
534
  notes: `
470
- Answers "how do you work". For "what did it cost" use \`agents cost\`, for "what
471
- shipped" use \`agents output\`, for live quota use \`agents usage\`.
535
+ Two paths under one verb:
536
+ bare \`agents insights\` — transcript behaviour (tools, friction, rhythm, by account)
537
+ \`agents insights mix\` — cheap counters from sessions.db + usage.db
538
+ Latency is \`agents perf\` (not mix). Quota is \`agents usage\`. Skill/slash popularity
539
+ is \`agents sessions stats\`. \`agents trends\` is a deprecated alias of the mix tree.
472
540
 
473
- The first run parses every in-scope transcript and caches the result; later runs
541
+ The behavioural report parses in-scope transcripts once and caches facets; later runs
474
542
  re-read only files that changed. \`--refresh\` forces a full re-read.
475
543
 
476
- Account attribution is Claude-only today. Sessions from other harnesses group
477
- under \`unattributed:<agent>\`.
544
+ \`agents insights\` is the top-level alias of \`agents sessions insights\`.
545
+ Repeat \`--agent\` to compare several harnesses in one report.
478
546
 
479
547
  Everything except \`--narrative\` is local and makes no network calls.
480
548
  `,
481
549
  });
482
550
  }
551
+ export function registerInsightsCommand(program) {
552
+ configureInsightsCommand(program.command('insights'));
553
+ }
554
+ export function registerSessionsInsightsCommand(sessions) {
555
+ configureInsightsCommand(sessions.command('insights'));
556
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `agents reconnect [session-id]` — get me back into an agent terminal whose ssh
3
+ * link dropped.
4
+ *
5
+ * The auto-reconnect in lib/hosts/reconnect.ts already re-attaches a live remote
6
+ * pane over its own bounded backoff on a transient blink. This is the MANUAL
7
+ * recovery for after it gave up — a sustained outage, or a VS Code terminal tab
8
+ * that closed with the dead ssh client. One verb that always tries hardest to put
9
+ * you back into that agent: attach the live pane if it still exists, else resume
10
+ * the session; live pane > resumed copy > a clear message about what was lost.
11
+ *
12
+ * It delegates to `focus`, which already does exactly attach-else-resume for a
13
+ * resolved id ({@link focusAction} → `focusResolvedSession`), so there is no second
14
+ * recovery path to keep in sync. What `reconnect` adds is the recovery framing and,
15
+ * with no id, targeting the MOST RECENT session in this shell's directory — the one
16
+ * that most likely just dropped — instead of opening the full fleet picker.
17
+ */
18
+ import type { Command } from 'commander';
19
+ import type { SessionMeta } from '../lib/session/types.js';
20
+ /**
21
+ * The recency signal a no-id reconnect targets: last activity if the scan
22
+ * computed one, else the creation time. A row with neither parseable sorts last.
23
+ * Pure so target selection is unit-tested without the scanner.
24
+ */
25
+ export declare function sessionRecency(s: Pick<SessionMeta, 'lastActivity' | 'timestamp'>): number;
26
+ /**
27
+ * Pick the session to reconnect when the user gave no id: the most recently
28
+ * active one in scope — the terminal that most likely just dropped. Pure; returns
29
+ * undefined for an empty scope so the caller can print guidance.
30
+ */
31
+ export declare function pickMostRecentSession(sessions: SessionMeta[]): SessionMeta | undefined;
32
+ /**
33
+ * Resolve the no-id target: prefer the most recent session started from THIS
34
+ * directory (the shell the user is reconnecting from), falling back to the most
35
+ * recent session anywhere when the current directory has none. Best-effort — a
36
+ * discovery failure yields undefined, never throws. Injectable `discover` keeps
37
+ * the two-step scope preference testable without touching the real scanner.
38
+ */
39
+ export declare function resolveRecentTarget(cwd: string, discover?: (opts: {
40
+ cwd?: string;
41
+ all?: boolean;
42
+ since?: string;
43
+ limit?: number;
44
+ }) => Promise<SessionMeta[]>): Promise<SessionMeta | undefined>;
45
+ export declare function reconnectAction(id: string | undefined): Promise<void>;
46
+ export declare function registerReconnectCommand(program: Command): void;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `agents reconnect [session-id]` — get me back into an agent terminal whose ssh
3
+ * link dropped.
4
+ *
5
+ * The auto-reconnect in lib/hosts/reconnect.ts already re-attaches a live remote
6
+ * pane over its own bounded backoff on a transient blink. This is the MANUAL
7
+ * recovery for after it gave up — a sustained outage, or a VS Code terminal tab
8
+ * that closed with the dead ssh client. One verb that always tries hardest to put
9
+ * you back into that agent: attach the live pane if it still exists, else resume
10
+ * the session; live pane > resumed copy > a clear message about what was lost.
11
+ *
12
+ * It delegates to `focus`, which already does exactly attach-else-resume for a
13
+ * resolved id ({@link focusAction} → `focusResolvedSession`), so there is no second
14
+ * recovery path to keep in sync. What `reconnect` adds is the recovery framing and,
15
+ * with no id, targeting the MOST RECENT session in this shell's directory — the one
16
+ * that most likely just dropped — instead of opening the full fleet picker.
17
+ */
18
+ import chalk from 'chalk';
19
+ import { focusAction } from './focus.js';
20
+ import { discoverSessions } from '../lib/session/discover.js';
21
+ import { setHelpSections } from '../lib/help.js';
22
+ /**
23
+ * The recency signal a no-id reconnect targets: last activity if the scan
24
+ * computed one, else the creation time. A row with neither parseable sorts last.
25
+ * Pure so target selection is unit-tested without the scanner.
26
+ */
27
+ export function sessionRecency(s) {
28
+ const t = Date.parse(s.lastActivity ?? s.timestamp ?? '');
29
+ return Number.isFinite(t) ? t : Number.NEGATIVE_INFINITY;
30
+ }
31
+ /**
32
+ * Pick the session to reconnect when the user gave no id: the most recently
33
+ * active one in scope — the terminal that most likely just dropped. Pure; returns
34
+ * undefined for an empty scope so the caller can print guidance.
35
+ */
36
+ export function pickMostRecentSession(sessions) {
37
+ let best;
38
+ for (const s of sessions) {
39
+ if (!best || sessionRecency(s) > sessionRecency(best))
40
+ best = s;
41
+ }
42
+ return best;
43
+ }
44
+ /**
45
+ * Resolve the no-id target: prefer the most recent session started from THIS
46
+ * directory (the shell the user is reconnecting from), falling back to the most
47
+ * recent session anywhere when the current directory has none. Best-effort — a
48
+ * discovery failure yields undefined, never throws. Injectable `discover` keeps
49
+ * the two-step scope preference testable without touching the real scanner.
50
+ */
51
+ export async function resolveRecentTarget(cwd, discover = discoverSessions) {
52
+ try {
53
+ const here = await discover({ cwd, since: '7d', limit: 200 });
54
+ const local = pickMostRecentSession(here);
55
+ if (local)
56
+ return local;
57
+ const any = await discover({ all: true, since: '7d', limit: 200 });
58
+ return pickMostRecentSession(any);
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ export async function reconnectAction(id) {
65
+ if (id) {
66
+ // focus already attaches a live pane or recovers a dropped one for a resolved
67
+ // id — on the origin device for a remote session. Reconnect is that path with
68
+ // a recovery-first name; no reason to duplicate the resolution/attach logic.
69
+ await focusAction(id, {});
70
+ return;
71
+ }
72
+ const recent = await resolveRecentTarget(process.cwd());
73
+ if (!recent) {
74
+ console.log(chalk.gray('No recent session to reconnect from here.'));
75
+ console.log(chalk.gray(' reconnect a specific one: agents reconnect <session-id>'));
76
+ console.log(chalk.gray(' or pick from the fleet: agents sessions'));
77
+ return;
78
+ }
79
+ const where = recent.machine ? ` on ${recent.machine}` : '';
80
+ console.log(chalk.gray(`Reconnecting ${recent.shortId}${where} (most recent here)…`));
81
+ await focusAction(recent.id, {});
82
+ }
83
+ export function registerReconnectCommand(program) {
84
+ const cmd = program
85
+ .command('reconnect')
86
+ .argument('[session-id]', 'Session id/prefix to reconnect (default: the most recent session started here)')
87
+ .description('Re-enter a dropped agent terminal: attach the live pane if it survived, else resume the session')
88
+ .action(async (id) => {
89
+ await reconnectAction(id);
90
+ });
91
+ setHelpSections(cmd, {
92
+ examples: `
93
+ # Reconnect the session that just dropped in this shell (most recent here)
94
+ agents reconnect
95
+
96
+ # Reconnect one specific agent terminal by id/prefix
97
+ agents reconnect 74b13b0b
98
+
99
+ # Same, spelled under the sessions group
100
+ agents sessions reconnect 74b13b0b
101
+ `,
102
+ notes: `
103
+ - Best-effort recovery: a living remote tmux pane is JOINED (a second client, no fork); a dropped one is RESUMED on its origin device (a copy if it was mid-run, a /continue if idle).
104
+ - With no id, targets the most recent session started from this directory (the terminal that most likely just dropped), not the full fleet picker. Falls back to the most recent session anywhere when this directory has none.
105
+ - This is the manual companion to the automatic reconnect that runs during a live 'agents run --device <box>' when the network blinks; use it after that gave up or the terminal tab closed.
106
+ - Related: 'agents sessions focus' (attach/recover with a picker) and 'agents sessions resume' (multi-select history -> tabs).
107
+ `,
108
+ });
109
+ }
@@ -12,7 +12,7 @@ import * as path from 'path';
12
12
  import * as yaml from 'yaml';
13
13
  import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemonLog, getDaemonStatus, } from '../lib/daemon.js';
14
14
  import { assertSchedulerEnabled } from '../lib/device-config.js';
15
- import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
15
+ import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError, ROUTINE_AGENT_IDS } from '../lib/agents.js';
16
16
  import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
17
17
  import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, routineStats, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, HOST_STRATEGIES, computeProjectGroup, computeProjectGroupKind, projectGroupKey, projectGroupTitle, projectGroupOrder, normalizeProjects, } from '../lib/routines.js';
18
18
  import { listProjectDefs, isSafeProjectName } from '../lib/projects.js';
@@ -21,7 +21,7 @@ import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js'
21
21
  import { getRoutinesDir } from '../lib/state.js';
22
22
  import { IS_WINDOWS } from '../lib/platform/index.js';
23
23
  import { safeJoin } from '../lib/paths.js';
24
- import { executeJob, monitorRunningJobs, ROUTINE_AGENT_IDS } from '../lib/runner.js';
24
+ import { executeJob, monitorRunningJobs } from '../lib/runner.js';
25
25
  import { JobScheduler } from '../lib/scheduler.js';
26
26
  import { detectOverdueJobs } from '../lib/overdue.js';
27
27
  import { runCatchup } from '../lib/catchup.js';
@@ -8,6 +8,8 @@
8
8
  import { type Command } from 'commander';
9
9
  import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
10
10
  import { quoteWin32ExecArg } from '../lib/platform/index.js';
11
+ import { bundleEnvToDotenv } from '../lib/secrets/push.js';
12
+ export { bundleEnvToDotenv };
11
13
  import { type SecretsBackend, type SecretsBundle, type SecretsPolicy } from '../lib/secrets/bundles.js';
12
14
  import { type SecretsListFilterOpts } from '../lib/secrets/list-filter.js';
13
15
  import { type BundleUsageSummary } from '../lib/secrets/usage-db.js';
@@ -78,14 +80,6 @@ export { SSH_TARGET_RE, assertValidSshTarget };
78
80
  * the executed command.
79
81
  */
80
82
  export declare function buildSecretsExecEnv(parentEnv: NodeJS.ProcessEnv, secretEnv: Record<string, string>): NodeJS.ProcessEnv;
81
- /**
82
- * Serialize a resolved env map to `.env` lines that round-trip losslessly through
83
- * `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
84
- * quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
85
- * value survives unchanged with no escaping. Newlines would break its line-based
86
- * parse, so multi-line values are rejected rather than silently corrupted.
87
- */
88
- export declare function bundleEnvToDotenv(env: Record<string, string>): string;
89
83
  /**
90
84
  * Encrypt a resolved env map to an offline bundle file using AES-256-GCM
91
85
  * (the same EncFile envelope as the per-item file store). Inner plaintext is
@@ -12,12 +12,12 @@ import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/widt
12
12
  import * as fs from 'fs';
13
13
  import * as os from 'os';
14
14
  import * as path from 'path';
15
- import { SSH_TARGET_RE, assertValidSshTarget, sshExec } from '../lib/ssh-exec.js';
15
+ import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
16
16
  import { quoteWin32ExecArg, composeWin32CommandLine } from '../lib/platform/index.js';
17
17
  import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
18
- import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, verifyRemoteKeychainPush, keychainWriteFailureMessage, buildRemoteFileImportCommand, } from '../lib/secrets/remote.js';
19
- import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
20
- import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
18
+ import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, } from '../lib/secrets/remote.js';
19
+ import { resolveBundleForPush, pushResolvedBundleToHost, bundleEnvToDotenv } from '../lib/secrets/push.js';
20
+ export { bundleEnvToDotenv };
21
21
  import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, isHeadlessSecretsContext, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
22
22
  import { parseListFilters, bundleMatchesFilter, bundleExpiry, filterIsActive, describeFilter, parseSortField, sortBundles, SORT_FIELDS, REF_KINDS, DEFAULT_EXPIRING_DAYS, } from '../lib/secrets/list-filter.js';
23
23
  import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
@@ -354,24 +354,6 @@ function getCliVersion() {
354
354
  return '0.0.0';
355
355
  }
356
356
  }
357
- /**
358
- * Serialize a resolved env map to `.env` lines that round-trip losslessly through
359
- * `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
360
- * quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
361
- * value survives unchanged with no escaping. Newlines would break its line-based
362
- * parse, so multi-line values are rejected rather than silently corrupted.
363
- */
364
- export function bundleEnvToDotenv(env) {
365
- const lines = [];
366
- for (const [k, v] of Object.entries(env)) {
367
- if (/[\r\n]/.test(v)) {
368
- throw new Error(`Key '${k}' has a multi-line value; the SSH .env transport can't carry newlines. ` +
369
- `Set it directly on the remote with 'agents secrets add ${k} --value-stdin'.`);
370
- }
371
- lines.push(`${k}="${v}"`);
372
- }
373
- return lines.join('\n') + '\n';
374
- }
375
357
  /**
376
358
  * Encrypt a resolved env map to an offline bundle file using AES-256-GCM
377
359
  * (the same EncFile envelope as the per-item file store). Inner plaintext is
@@ -2115,100 +2097,42 @@ Examples:
2115
2097
  if (hosts.length > 0) {
2116
2098
  for (const h of hosts)
2117
2099
  assertValidSshTarget(h);
2118
- const remoteBackend = parseBackendOpt(opts.remoteBackend);
2100
+ const parsedBackend = parseBackendOpt(opts.remoteBackend);
2101
+ // `--remote-backend` documents keychain|file only, and parseBackendOpt
2102
+ // exits on anything else — but its return type still admits 'vault',
2103
+ // which has no remote-push path. Refuse it here rather than let it fall
2104
+ // through to the keychain branch and half-work.
2105
+ if (parsedBackend === 'vault') {
2106
+ console.error(chalk.red("--remote-backend vault is not supported; use 'keychain' or 'file'."));
2107
+ process.exit(1);
2108
+ }
2109
+ const remoteBackend = parsedBackend;
2119
2110
  // For a file-backed remote bundle a passphrase is OPTIONAL. The file
2120
2111
  // store is passphrase-free by default: with AGENTS_SECRETS_PASSPHRASE
2121
2112
  // unset the remote `import --backend file` auto-provisions the remote's
2122
2113
  // own machine-local key (0600 under ~/.agents/.secrets-key/), so reads
2123
2114
  // are HEADLESS. We forward the LOCAL AGENTS_SECRETS_PASSPHRASE only when
2124
- // the operator opts in by setting it (e.g. to key the bundle off-disk
2125
- // under a shared secret) shipped as the FIRST stdin line so it never
2126
- // lands in argv / `ps` / the remote shell history. Forcing a shared
2127
- // passphrase would defeat headless reads, so we no longer require one.
2115
+ // the operator opts in by setting it. Forcing a shared passphrase would
2116
+ // defeat headless reads, so we no longer require one.
2128
2117
  const remotePassphrase = remoteBackend === 'file' ? (process.env.AGENTS_SECRETS_PASSPHRASE ?? '') : '';
2129
- const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export`, keyMode: 'storage', agentOnly: true });
2130
- const dotenv = bundleEnvToDotenv(env);
2131
- const keyCount = Object.keys(env).length;
2132
- // Drive the remote's own `agents secrets import --from -` so the values
2133
- // land in its chosen backend, reading the .env off ssh stdin (never
2134
- // parsed by a remote shell — `--from -` replaces the POSIX-only
2135
- // `/dev/stdin`). The keychain path is built OS-aware via
2136
- // `remoteSecretsRaw` (bash -lc on POSIX, PowerShell on Windows), so it
2137
- // works on macOS, Linux AND Windows targets. `import` auto-creates the
2138
- // bundle, so no separate `create` (the old `|| true` was a POSIXism
2139
- // that broke on PowerShell: `'true' is not recognized`).
2118
+ // Resolve ONCE for N hosts reading a bundle can prompt, and doing it
2119
+ // per host would prompt per host.
2120
+ const resolvedForPush = resolveBundleForPush(resolvedBundleName, 'ssh export');
2121
+ const keyCount = resolvedForPush.keyCount;
2140
2122
  let failures = 0;
2141
2123
  for (const host of hosts) {
2142
- let res;
2143
- if (remoteBackend === 'file') {
2144
- // File backend: headless-readable via the remote's machine-local key
2145
- // when no passphrase is set; otherwise forwards AGENTS_SECRETS_PASSPHRASE
2146
- // as the FIRST stdin line (consumed by `read`, so it never lands in
2147
- // argv / `ps` / remote history), then the .env. Both build a POSIX
2148
- // `bash -lc` command — refuse a Windows target cleanly rather than
2149
- // emit broken PowerShell.
2150
- if (remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell') {
2151
- failures++;
2152
- console.error(chalk.red(`${host}: file backend export to a Windows target is not yet supported.`));
2153
- continue;
2154
- }
2155
- const { remoteCmd, input } = buildRemoteFileImportCommand(resolvedBundleName, dotenv, {
2156
- passphrase: remotePassphrase,
2157
- force: opts.force,
2158
- });
2159
- res = sshExec(host, remoteCmd, { input });
2160
- }
2161
- else if (remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell') {
2162
- // Keychain on a Windows target: the `agents.ps1` shim doesn't
2163
- // forward ssh-piped stdin to node, so `--from -` would hang.
2164
- // Bridge the piped .env through PowerShell into a temp file and
2165
- // import `--from <file>` (deleted afterwards). Same hardened ssh
2166
- // engine, .env still only ever crosses the wire over ssh stdin.
2167
- res = sshExec(host, buildWindowsStdinImportCommand(resolvedBundleName, { force: opts.force }), { input: dotenv });
2168
- }
2169
- else {
2170
- // Keychain on a POSIX target: OS-aware wrapping + hardened ssh
2171
- // engine (BatchMode, ConnectTimeout, keepalive, control-socket
2172
- // reuse) via the same path the READ inverse (`remoteResolveEnv`)
2173
- // uses. `--from -` reads the .env off ssh stdin.
2174
- res = remoteSecretsRaw(host, ['import', resolvedBundleName, '--from', '-', ...(opts.force ? ['--force'] : [])], { input: dotenv, osLookupName: host });
2175
- }
2176
- if (res.code === null) {
2177
- failures++;
2178
- console.error(chalk.red(`${host}: ${res.stderr.trim() || (res.timedOut ? 'ssh timed out' : 'ssh failed')}`));
2179
- continue;
2180
- }
2181
- if (res.code !== 0) {
2124
+ const out = pushResolvedBundleToHost(resolvedForPush, resolvedBundleName, host, {
2125
+ remoteBackend,
2126
+ force: opts.force,
2127
+ passphrase: remotePassphrase,
2128
+ operation: 'ssh export',
2129
+ });
2130
+ if (!out.ok) {
2182
2131
  failures++;
2183
- const msg = (res.stderr || res.stdout || '').trim();
2184
- console.error(chalk.red(`${host}: remote import failed (exit ${res.code})${msg ? `: ${msg}` : ''}`));
2132
+ console.error(chalk.red(`${host}: ${out.message}`));
2185
2133
  continue;
2186
2134
  }
2187
- // A keychain-backed push to a macOS remote over headless SSH can land
2188
- // the bundle metadata but no READABLE value items: the remote login
2189
- // keychain is locked in the non-interactive SSH context, so Security
2190
- // accepts the write but the biometry-ACL'd item is unreadable, and the
2191
- // remote `import` still exits 0. Read the bundle back the same way a
2192
- // release will (drops the plaintext, keeps only key presence; the
2193
- // remote's headless `agentOnly` guard fails fast, so no Touch ID) and
2194
- // FAIL LOUDLY if the keys didn't materialize — rather than leave a
2195
- // metadata-only bundle that breaks later with "stored item not found".
2196
- // The file backend is headless-readable by construction, so skip it.
2197
- if (remoteBackend === 'keychain') {
2198
- const verdict = verifyRemoteKeychainPush(host, resolvedBundleName, Object.keys(env), { osLookupName: host });
2199
- if (!verdict.ok) {
2200
- failures++;
2201
- if (verdict.kind === 'locked-keychain') {
2202
- console.error(chalk.red(keychainWriteFailureMessage(host, resolvedBundleName, verdict.reason)));
2203
- }
2204
- else {
2205
- console.error(chalk.red(`${host}: pushed '${resolvedBundleName}' but could not verify it on the remote: ${verdict.reason}`));
2206
- }
2207
- continue;
2208
- }
2209
- }
2210
- const remoteMsg = (res.stdout || '').trim().split('\n').map((l) => l.trim()).filter(Boolean).pop();
2211
- console.log(chalk.green(`${host} -> '${resolvedBundleName}': ${remoteMsg || `${keyCount} key(s) exported`}`));
2135
+ console.log(chalk.green(`${host} -> '${resolvedBundleName}': ${out.message}`));
2212
2136
  }
2213
2137
  emitSecretAudit({ event: 'secrets.export', bundle: resolvedBundleName, operation: `export --host ${hosts.join(',')}`, source: 'ssh', host: hosts.join(','), status: failures > 0 ? 'error' : 'success', keyCount });
2214
2138
  if (failures > 0)