@phnx-labs/agents-cli 1.20.88 → 1.20.89

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 (55) hide show
  1. package/CHANGELOG.md +263 -0
  2. package/README.md +9 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/factory.js +26 -2
  6. package/dist/commands/funnel.js +16 -1
  7. package/dist/commands/menubar.js +117 -34
  8. package/dist/commands/routines.js +23 -1
  9. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  10. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  11. package/dist/commands/secrets.js +2 -0
  12. package/dist/commands/sessions.d.ts +7 -1
  13. package/dist/commands/sessions.js +39 -12
  14. package/dist/commands/webhook.js +7 -2
  15. package/dist/lib/commands.js +9 -1
  16. package/dist/lib/daemon.d.ts +29 -0
  17. package/dist/lib/daemon.js +58 -4
  18. package/dist/lib/events.d.ts +1 -1
  19. package/dist/lib/factory/snapshot.d.ts +78 -0
  20. package/dist/lib/factory/snapshot.js +209 -0
  21. package/dist/lib/fs-atomic.d.ts +14 -1
  22. package/dist/lib/fs-atomic.js +35 -3
  23. package/dist/lib/funnel.d.ts +1 -0
  24. package/dist/lib/funnel.js +8 -0
  25. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  26. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  27. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  28. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  29. package/dist/lib/menubar/install-menubar.js +183 -28
  30. package/dist/lib/platform/process.d.ts +2 -0
  31. package/dist/lib/platform/process.js +5 -3
  32. package/dist/lib/resources.d.ts +8 -0
  33. package/dist/lib/resources.js +34 -1
  34. package/dist/lib/routines-placement.d.ts +2 -1
  35. package/dist/lib/routines-placement.js +8 -4
  36. package/dist/lib/routines.d.ts +57 -1
  37. package/dist/lib/routines.js +74 -1
  38. package/dist/lib/runner.d.ts +2 -0
  39. package/dist/lib/runner.js +21 -8
  40. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  41. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  42. package/dist/lib/secrets/bundles.js +9 -34
  43. package/dist/lib/secrets/filestore.d.ts +152 -34
  44. package/dist/lib/secrets/filestore.js +676 -123
  45. package/dist/lib/session/remote-active.d.ts +4 -1
  46. package/dist/lib/session/remote-active.js +8 -2
  47. package/dist/lib/session/viewing-in.d.ts +31 -0
  48. package/dist/lib/session/viewing-in.js +47 -0
  49. package/dist/lib/state.d.ts +17 -0
  50. package/dist/lib/state.js +30 -2
  51. package/dist/lib/triggers/handlers.d.ts +95 -0
  52. package/dist/lib/triggers/handlers.js +384 -0
  53. package/dist/lib/triggers/webhook.d.ts +10 -2
  54. package/dist/lib/triggers/webhook.js +65 -11
  55. package/package.json +1 -1
@@ -70,6 +70,8 @@ function fireConditionLabel(job) {
70
70
  job.trigger.action ? `action=${job.trigger.action}` : null,
71
71
  job.trigger.teamKey ? `team=${job.trigger.teamKey}` : null,
72
72
  job.trigger.label ? `label=${job.trigger.label}` : null,
73
+ job.trigger.stateTo ? `stateTo=${job.trigger.stateTo}` : null,
74
+ job.trigger.stateFrom ? `stateFrom=${job.trigger.stateFrom}` : null,
73
75
  ].filter(Boolean).join(', ');
74
76
  return `on linear:${job.trigger.event}${filters ? ` (${filters})` : ''}`;
75
77
  }
@@ -261,6 +263,10 @@ function parseRoutineTrigger(options) {
261
263
  trigger.teamKey = options.teamKey;
262
264
  if (typeof options.label === 'string')
263
265
  trigger.label = options.label;
266
+ if (typeof options.stateTo === 'string')
267
+ trigger.stateTo = options.stateTo;
268
+ if (typeof options.stateFrom === 'string')
269
+ trigger.stateFrom = options.stateFrom;
264
270
  return trigger;
265
271
  }
266
272
  throw new Error('--on source must be github or linear');
@@ -591,6 +597,8 @@ export function registerRoutinesCommands(program) {
591
597
  .option('--action <name>', 'Webhook action filter for --on triggers (GitHub: labeled/opened; Linear: update)')
592
598
  .option('--team-key <key>', 'Linear team key filter for --on linear:<event> (e.g. RUSH)')
593
599
  .option('--label <name>', 'Label filter for --on triggers (GitHub label name or Linear issue label)')
600
+ .option('--state-to <name>', 'Linear current-state filter for --on linear:<event> (e.g. Plan)')
601
+ .option('--state-from <name>', 'Linear previous-state filter for --on linear:<event> (e.g. Triage)')
594
602
  .option('--end-at <iso>', 'Stop firing on or after this ISO 8601 timestamp (e.g., "2026-12-31T23:59:00Z"); routine auto-disables.')
595
603
  .option('--disabled', 'Create the routine but keep it paused (enable later with resume)')
596
604
  .option('--resume <sessionId>', 'At fire time, resume this existing session id (via `agents run <agent> --resume`) instead of starting fresh — the actual session reopens with full context and the prompt becomes its next turn. Powers self-scheduled wake-ups (e.g. /hibernate). Requires --agent claude or codex; runs un-sandboxed (the session store lives in the real home, not the job overlay).')
@@ -884,12 +892,26 @@ export function registerRoutinesCommands(program) {
884
892
  routinesCmd
885
893
  .command('edit [name]')
886
894
  .description('Open a routine in $EDITOR. Creates a new YAML template if the routine does not exist.')
887
- .action(async (name) => {
895
+ .option('--state-to <name>', 'Update the Linear current-state filter before opening the editor')
896
+ .option('--state-from <name>', 'Update the Linear previous-state filter before opening the editor')
897
+ .action(async (name, options) => {
888
898
  if (!name) {
889
899
  name = await pickJob('Select job to edit', undefined, ['agents routines edit <name>']) ?? undefined;
890
900
  if (!name)
891
901
  return;
892
902
  }
903
+ const existing = readJob(name);
904
+ if (existing && (options.stateTo !== undefined || options.stateFrom !== undefined)) {
905
+ if (!existing.trigger || existing.trigger.type !== 'linear_event') {
906
+ console.error(chalk.red(`'${name}' does not have a Linear trigger; --state-to/--state-from only apply to linear triggers`));
907
+ process.exit(1);
908
+ }
909
+ if (options.stateTo !== undefined)
910
+ existing.trigger.stateTo = options.stateTo || undefined;
911
+ if (options.stateFrom !== undefined)
912
+ existing.trigger.stateFrom = options.stateFrom || undefined;
913
+ writeJob(existing);
914
+ }
893
915
  const jobPath = getJobPath(name);
894
916
  if (!jobPath) {
895
917
  // Job doesn't exist - create a new one
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `agents secrets rotate-passphrase` — rotate the encrypted file store's
3
+ * machine-local master passphrase (RUSH-1975).
4
+ *
5
+ * Re-encrypts every `<item>.enc` under a freshly generated key and rewrites the
6
+ * 0600 key file in place, atomically: the new store is staged, verified
7
+ * (round-trip + count), fsync'd, then swapped by directory rename, so a crash
8
+ * leaves the old store intact and readable. Headless-safe and Linux-first — the
9
+ * remediation path for a compromised passphrase (RUSH-1968), where the two
10
+ * supported alternatives are unacceptable (a hand-rolled non-atomic script, or
11
+ * export-to-plaintext which is the exposure being fixed).
12
+ *
13
+ * Dry-run by default, matching `import-keyring`; `--commit` performs the swap.
14
+ */
15
+ import type { Command } from 'commander';
16
+ /** Register `agents secrets rotate-passphrase` on the parent secrets Command. */
17
+ export declare function registerSecretsRotatePassphraseCommand(secrets: Command): void;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `agents secrets rotate-passphrase` — rotate the encrypted file store's
3
+ * machine-local master passphrase (RUSH-1975).
4
+ *
5
+ * Re-encrypts every `<item>.enc` under a freshly generated key and rewrites the
6
+ * 0600 key file in place, atomically: the new store is staged, verified
7
+ * (round-trip + count), fsync'd, then swapped by directory rename, so a crash
8
+ * leaves the old store intact and readable. Headless-safe and Linux-first — the
9
+ * remediation path for a compromised passphrase (RUSH-1968), where the two
10
+ * supported alternatives are unacceptable (a hand-rolled non-atomic script, or
11
+ * export-to-plaintext which is the exposure being fixed).
12
+ *
13
+ * Dry-run by default, matching `import-keyring`; `--commit` performs the swap.
14
+ */
15
+ import chalk from 'chalk';
16
+ import { machinePassphraseExists, rotatePassphrase } from '../lib/secrets/filestore.js';
17
+ import { agentStatus } from '../lib/secrets/agent.js';
18
+ /** Register `agents secrets rotate-passphrase` on the parent secrets Command. */
19
+ export function registerSecretsRotatePassphraseCommand(secrets) {
20
+ secrets
21
+ .command('rotate-passphrase')
22
+ .description('Re-key the encrypted file store under a new machine-local passphrase (atomic, headless-safe). Dry-run by default.')
23
+ .option('--commit', 'Perform the rotation (default is dry-run reporting only)')
24
+ .option('--dry-run', 'Report bundle count and round-trip result without re-keying (the default). Still heals an interrupted rotation — that is the one thing it writes.')
25
+ .option('--force', 'Override the safety refusals (held broker unlocks, or a passphrase exported in the environment)')
26
+ .addHelpText('after', `
27
+ Rotates the auto-provisioned file-store key at ~/.agents/.secrets-key/passphrase:
28
+ decrypts every item under the current key, re-encrypts under a new one, verifies
29
+ every item round-trips, then swaps the store and key file atomically. A crash
30
+ before the swap leaves the old store readable with the old key; a crash inside the
31
+ swap self-heals on the next rotate-passphrase run (not on an ordinary get). No
32
+ plaintext secret value or passphrase is ever written to disk, argv, or a log.
33
+
34
+ Examples:
35
+ # Report what would rotate (no writes)
36
+ agents secrets rotate-passphrase
37
+
38
+ # Perform the rotation
39
+ agents secrets rotate-passphrase --commit`)
40
+ .action(async (opts) => {
41
+ try {
42
+ if (opts.commit && opts.dryRun) {
43
+ throw new Error('--commit and --dry-run are mutually exclusive.');
44
+ }
45
+ const dryRun = !opts.commit;
46
+ if (!machinePassphraseExists()) {
47
+ throw new Error('No machine-local passphrase is provisioned on this box, so there is nothing to rotate. ' +
48
+ 'This command re-keys the file store\'s auto-provisioned key at ~/.agents/.secrets-key/passphrase.');
49
+ }
50
+ // Guard 1: refuse while the secrets-agent holds live unlocks (macOS), so a
51
+ // concurrent read cannot land against a half-swapped store. --force overrides.
52
+ if (!dryRun && !opts.force) {
53
+ const held = await agentStatus();
54
+ if (held.length > 0) {
55
+ throw new Error(`The secrets-agent is holding ${held.length} unlocked bundle(s) (${held.map((e) => e.name).join(', ')}). ` +
56
+ 'Lock them first (`agents secrets lock --all`) so no concurrent read races the rotation, or pass --force.');
57
+ }
58
+ }
59
+ // Guard 2: a passphrase exported in the environment shadows the on-disk key
60
+ // file with a now-stale value after the rotation, breaking every read. This
61
+ // is the exact RUSH-1968 footgun — refuse the commit and point at the fix.
62
+ if (!dryRun && !opts.force && (process.env.AGENTS_SECRETS_PASSPHRASE ?? '').length > 0) {
63
+ throw new Error('AGENTS_SECRETS_PASSPHRASE is set in this environment; it would shadow the rotated key file ' +
64
+ 'with a stale value and break every read. Unset it (this is the RUSH-1968 fix) before rotating, or pass --force.');
65
+ }
66
+ const report = rotatePassphrase({ dryRun });
67
+ console.log(`${chalk.bold(String(report.bundleCount))} item(s) decrypt under the current key and ` +
68
+ `${report.roundTripOk ? chalk.green('round-trip cleanly') : chalk.red('failed to round-trip')} under a new key.`);
69
+ if (report.skipped.length > 0) {
70
+ console.log(chalk.gray(`skipped ${report.skipped.length} orphan file(s) (not re-keyed):`));
71
+ for (const s of report.skipped)
72
+ console.log(chalk.gray(` - ${s}`));
73
+ }
74
+ if (report.dryRun) {
75
+ if (report.recoveredInterruptedRotation) {
76
+ // Be precise: this run DID write. Recovery is deliberately not gated on
77
+ // --commit, because healing is how a crashed store becomes readable
78
+ // again without re-keying it.
79
+ console.log(chalk.yellow('Recovered an interrupted rotation: the store was healed back to a single ' +
80
+ 'readable state. That is the only thing this dry run wrote — no re-keying happened.'));
81
+ }
82
+ else {
83
+ console.log(chalk.gray('Dry-run: nothing written.'));
84
+ }
85
+ console.log(chalk.gray(`Pass --commit to re-encrypt the store and swap the key file (${report.keyFilePath}).`));
86
+ return;
87
+ }
88
+ console.log(chalk.green(`Rotated: re-encrypted ${report.bundleCount} item(s) and rewrote ${report.keyFilePath} (mode 0600).`));
89
+ console.log(chalk.gray('The previous key and ciphertext were removed. Any process still holding the old passphrase in its environment must be restarted.'));
90
+ }
91
+ catch (err) {
92
+ console.error(chalk.red(err.message));
93
+ process.exit(1);
94
+ }
95
+ });
96
+ }
@@ -38,6 +38,7 @@ import { getVaultSession, vaultExists } from '../lib/secrets/vault.js';
38
38
  import { registerSecretsSyncCommands } from './secrets-sync.js';
39
39
  import { registerSecretsMigrateAclCommand } from './secrets-migrate.js';
40
40
  import { registerSecretsImportKeyringCommand } from './secrets-import.js';
41
+ import { registerSecretsRotatePassphraseCommand } from './secrets-rotate-passphrase.js';
41
42
  /** Prompt the user for a secret value with masked input. Requires an interactive TTY. */
42
43
  async function promptForSecret(message) {
43
44
  if (!isInteractiveTerminal()) {
@@ -2419,6 +2420,7 @@ Examples:
2419
2420
  registerSecretsSyncCommands(cmd);
2420
2421
  registerSecretsMigrateAclCommand(cmd);
2421
2422
  registerSecretsImportKeyringCommand(cmd);
2423
+ registerSecretsRotatePassphraseCommand(cmd);
2422
2424
  }
2423
2425
  /** Validate a prompt-policy value, throwing a clear message on a bad one (the
2424
2426
  * caller's try/catch renders it and exits). Accepts the legacy `biometry` /
@@ -136,11 +136,17 @@ export declare function linkCwdCell(s: Pick<SessionMeta, 'cwd' | '_remote'>, lab
136
136
  * (null when unknown) alongside the raw fields, so every active row is joinable.
137
137
  * `project` uses the same derivation SessionMeta does — basename(cwd) (see
138
138
  * discover.ts) — so the active view and the history view join identically.
139
+ *
140
+ * `viewingIn` flattens to the same display string the row renderer prints —
141
+ * `'codium tab 3'` / `'detached'` / null — so a consumer can tell a watched
142
+ * session from an orphaned one (its terminal died, the agent is still running)
143
+ * without re-implementing the tmux client lookup.
139
144
  */
140
- export declare function serializeActiveSessionsForJson(sessions: ActiveSession[]): Array<ActiveSession & {
145
+ export declare function serializeActiveSessionsForJson(sessions: ActiveSession[]): Array<Omit<ActiveSession, 'viewingIn'> & {
141
146
  ticketId: string | null;
142
147
  project: string | null;
143
148
  prLink: string | null;
149
+ viewingIn: string | null;
144
150
  }>;
145
151
  /**
146
152
  * Compact owner display for the `--active` owner column: the local-part of a
@@ -20,7 +20,7 @@ import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecut
20
20
  import { getActiveSessions } from '../lib/session/active.js';
21
21
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
22
22
  import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
23
- import { resolveViewingIn } from '../lib/session/viewing-in.js';
23
+ import { resolveViewingIn, viewingInLabel } from '../lib/session/viewing-in.js';
24
24
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
25
25
  import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
26
26
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
@@ -442,6 +442,11 @@ function modelLabel(model) {
442
442
  * (null when unknown) alongside the raw fields, so every active row is joinable.
443
443
  * `project` uses the same derivation SessionMeta does — basename(cwd) (see
444
444
  * discover.ts) — so the active view and the history view join identically.
445
+ *
446
+ * `viewingIn` flattens to the same display string the row renderer prints —
447
+ * `'codium tab 3'` / `'detached'` / null — so a consumer can tell a watched
448
+ * session from an orphaned one (its terminal died, the agent is still running)
449
+ * without re-implementing the tmux client lookup.
445
450
  */
446
451
  export function serializeActiveSessionsForJson(sessions) {
447
452
  return sessions.map((s) => ({
@@ -449,6 +454,7 @@ export function serializeActiveSessionsForJson(sessions) {
449
454
  ticketId: s.ticket?.id ?? null,
450
455
  project: s.cwd ? path.basename(s.cwd) : null,
451
456
  prLink: s.pr?.url ?? null,
457
+ viewingIn: viewingInLabel(s) ?? null,
452
458
  }));
453
459
  }
454
460
  /**
@@ -496,13 +502,9 @@ function locatorBadge(s) {
496
502
  // For a tmux-hosted session, say which app+tab is looking at it right now
497
503
  // (or that it's running detached). Only meaningful for tmux (the pane is the
498
504
  // durable handle; the viewer is transient).
499
- if (s.viewingIn) {
500
- const tab = s.viewingIn.tab != null ? ` tab ${s.viewingIn.tab}` : '';
501
- parts.push(chalk.gray(`viewing in ${s.viewingIn.app}${tab}`));
502
- }
503
- else {
504
- parts.push(chalk.gray('detached'));
505
- }
505
+ const label = viewingInLabel(s);
506
+ if (label)
507
+ parts.push(chalk.gray(label === 'detached' ? label : `viewing in ${label}`));
506
508
  }
507
509
  else if (p?.mux?.kind === 'screen') {
508
510
  parts.push(chalk.green('screen'));
@@ -860,13 +862,33 @@ async function enrichLocalLocators(local) {
860
862
  }
861
863
  }
862
864
  catch { /* non-fatal */ }
863
- // tmux attach targets + "viewing in <app> tab N", one batched query per socket.
865
+ // One Ghostty enumeration shared across every socket's viewing-in resolve
866
+ // (a tmux client can be attached from a Ghostty tab).
867
+ await enrichTmuxLocators(local, await enumerateGhosttyTabsQuietly());
868
+ }
869
+ /** {@link enumerateGhosttyTabs}, best-effort — an osascript failure yields no surfaces. */
870
+ async function enumerateGhosttyTabsQuietly() {
871
+ try {
872
+ return await enumerateGhosttyTabs();
873
+ }
874
+ catch {
875
+ return [];
876
+ }
877
+ }
878
+ /**
879
+ * The tmux half of {@link enrichLocalLocators}: the `session:window.pane` attach
880
+ * target and "viewing in <app> tab N" / detached, one batched query per socket.
881
+ *
882
+ * Split out because it is the only locator the `--json` path can afford. It costs
883
+ * tmux queries and a `ps` read — no osascript — so scriptable output stays cheap
884
+ * while still answering the question a consumer actually needs: is anyone looking
885
+ * at this session, or is it running orphaned? Without `surfaces`, a Ghostty-attached
886
+ * client still resolves as attached, just without its tab number.
887
+ */
888
+ async function enrichTmuxLocators(local, surfaces = []) {
864
889
  try {
865
890
  const tmux = local.filter(s => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane);
866
891
  if (tmux.length > 0) {
867
- // One Ghostty enumeration shared across every socket's viewing-in resolve
868
- // (a tmux client can be attached from a Ghostty tab).
869
- const surfaces = await enumerateGhosttyTabs();
870
892
  const sockets = new Set(tmux.map(s => s.provenance.mux.socket));
871
893
  for (const socket of sockets) {
872
894
  const paneMap = await mapPanesToTargets(socket);
@@ -964,6 +986,11 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
964
986
  // present so a supervising agent or hook can poll it as a gate.
965
987
  const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
966
988
  if (asJson) {
989
+ // Resolve who is watching each local tmux pane before serializing: `viewingIn`
990
+ // is how a consumer distinguishes a session someone is looking at from one
991
+ // running orphaned after its terminal died. tmux-only (no osascript) so the
992
+ // scriptable path stays cheap — see enrichTmuxLocators.
993
+ await enrichTmuxLocators(sessions.filter(s => !s.machine || s.machine === self));
967
994
  process.stdout.write(JSON.stringify(serializeActiveSessionsForJson(sessions), null, 2) + '\n');
968
995
  if (waitingOnly && sessions.length > 0)
969
996
  process.exitCode = 1;
@@ -77,9 +77,14 @@ export function registerWebhookCommand(program) {
77
77
  // Durable delivery dedup: replays survive a receiver restart (an
78
78
  // in-memory store would forget every seen delivery on restart).
79
79
  deliveryStore: createFileDeliveryStore(path.join(getRuntimeStateDir(), 'webhook', 'deliveries.json')),
80
- onDelivery: (webhook, fired) => {
80
+ onDelivery: (webhook, fired, handlers) => {
81
+ const parts = [];
82
+ if (fired.length)
83
+ parts.push(`routines ${fired.map((f) => f.jobName).join(', ')}`);
84
+ if (handlers.length)
85
+ parts.push(`handlers ${handlers.map((h) => h.handlerName).join(', ')}`);
81
86
  console.log(`${new Date().toISOString()} ${webhook.source}:${webhook.event} ` +
82
- `${fired.length ? `fired ${fired.map((f) => f.jobName).join(', ')}` : 'no match'}`);
87
+ (parts.length ? `fired ${parts.join('; ')}` : 'no match'));
83
88
  },
84
89
  });
85
90
  await waitForListening(server);
@@ -11,6 +11,7 @@ import * as path from 'path';
11
11
  import * as yaml from 'yaml';
12
12
  import { AGENTS, ensureCommandsDir, agentConfigDirName, resolveAgentName } from './agents.js';
13
13
  import { capableAgents, isCapable, supports } from './capabilities.js';
14
+ import { isDirectoryDoc } from './resources.js';
14
15
  import { markdownToToml } from './convert.js';
15
16
  import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAgentsDir, getSkillsDir, getTrashCommandsDir } from './state.js';
16
17
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions, resolveVersion } from './versions.js';
@@ -150,6 +151,8 @@ export function discoverCommands(repoPath) {
150
151
  for (const file of fs.readdirSync(commandsDir)) {
151
152
  if (file.endsWith('.md')) {
152
153
  const name = file.replace('.md', '');
154
+ if (isDirectoryDoc('commands', name))
155
+ continue;
153
156
  const sourcePath = path.join(commandsDir, file);
154
157
  const metadata = parseCommandMetadata(sourcePath);
155
158
  const validation = validateCommandMetadata(metadata, name);
@@ -694,7 +697,12 @@ export function listCentralCommands() {
694
697
  if (!fs.existsSync(dir))
695
698
  continue;
696
699
  for (const f of fs.readdirSync(dir).filter((f) => f.endsWith('.md'))) {
697
- seen.add(f.replace('.md', ''));
700
+ const name = f.replace('.md', '');
701
+ // A directory's README/AGENTS/CLAUDE/GEMINI documents the dir, it is not a
702
+ // command. Without this the picker offers a name resolveResource refuses.
703
+ if (isDirectoryDoc('commands', name))
704
+ continue;
705
+ seen.add(name);
698
706
  }
699
707
  }
700
708
  return Array.from(seen);
@@ -86,6 +86,23 @@ export declare function log(level: string, message: string): void;
86
86
  * anchoring failed (logged, non-fatal).
87
87
  */
88
88
  export declare function anchorDaemonCwd(): string | null;
89
+ /**
90
+ * Surface, at the daemon's OWN startup, that it was launched from an ephemeral
91
+ * root that will wedge it if the directory is removed. This is the runtime
92
+ * companion to the launch-time check in validateDaemonBinary (which only runs
93
+ * when the daemon is *spawned* via getDaemonLaunch): a direct
94
+ * `agents __daemon-run` from a temp or worktree build — e.g. a review/verify
95
+ * checkout under /tmp — never passes through that path, so without this the
96
+ * wedge risk stays invisible until jobs start ENOENT-ing on their dynamic
97
+ * imports. Best-effort and non-fatal; the cwd is already handled by
98
+ * anchorDaemonCwd, but a deleted module root can only be flagged, not repaired.
99
+ *
100
+ * `resolveBin` is injectable (defaults to getAgentsBinPath) so the wiring — the
101
+ * predicate call, the WARN, and the non-fatal guard around a throwing resolver —
102
+ * is testable. Returns the warning message it logged, or null when the launch
103
+ * root is stable (or could not be resolved).
104
+ */
105
+ export declare function warnEphemeralDaemonRoot(resolveBin?: () => string): string | null;
89
106
  export declare function runDaemon(): Promise<void>;
90
107
  /**
91
108
  * Write a launchd plist or systemd unit with owner-only permissions atomically.
@@ -175,6 +192,18 @@ export declare function getAgentsInvocation(subArgs: string[], agentsBin?: strin
175
192
  command: string;
176
193
  args: string[];
177
194
  };
195
+ /**
196
+ * A daemon binary living under an ephemeral path — a git worktree, or a temp
197
+ * directory (`/tmp`, `/var/folders`, `/dev/shm`) — is a latent wedge. The daemon
198
+ * is long-lived but resolves its own job modules by dynamic `import()` rooted at
199
+ * this entry (getAgentsBinPath → process.argv[1]). If that directory is later
200
+ * removed (`git worktree remove`, a `/tmp` cleanup, a review/verify checkout
201
+ * teardown) the running daemon keeps ENOENT-ing on every job it loads —
202
+ * `anchorDaemonCwd` rescues the cwd, but nothing can re-root a deleted module
203
+ * tree. Returns a human phrase naming the ephemeral kind, or null for a stable
204
+ * install path (version home, a global npm prefix, a normal source checkout).
205
+ */
206
+ export declare function describeEphemeralDaemonRoot(binPath: string): string | null;
178
207
  export declare function validateDaemonBinary(binPath: string): {
179
208
  warnings: string[];
180
209
  };
@@ -321,6 +321,39 @@ export function anchorDaemonCwd() {
321
321
  return null;
322
322
  }
323
323
  }
324
+ /**
325
+ * Surface, at the daemon's OWN startup, that it was launched from an ephemeral
326
+ * root that will wedge it if the directory is removed. This is the runtime
327
+ * companion to the launch-time check in validateDaemonBinary (which only runs
328
+ * when the daemon is *spawned* via getDaemonLaunch): a direct
329
+ * `agents __daemon-run` from a temp or worktree build — e.g. a review/verify
330
+ * checkout under /tmp — never passes through that path, so without this the
331
+ * wedge risk stays invisible until jobs start ENOENT-ing on their dynamic
332
+ * imports. Best-effort and non-fatal; the cwd is already handled by
333
+ * anchorDaemonCwd, but a deleted module root can only be flagged, not repaired.
334
+ *
335
+ * `resolveBin` is injectable (defaults to getAgentsBinPath) so the wiring — the
336
+ * predicate call, the WARN, and the non-fatal guard around a throwing resolver —
337
+ * is testable. Returns the warning message it logged, or null when the launch
338
+ * root is stable (or could not be resolved).
339
+ */
340
+ export function warnEphemeralDaemonRoot(resolveBin = getAgentsBinPath) {
341
+ try {
342
+ const bin = resolveBin();
343
+ const ephemeralRoot = describeEphemeralDaemonRoot(bin);
344
+ if (!ephemeralRoot)
345
+ return null;
346
+ const message = `Daemon launched from ${ephemeralRoot} (${bin}); if that directory is removed, ` +
347
+ `every routine will fail with ENOENT on its module imports. Run the daemon from the ` +
348
+ `globally installed binary instead (npm i -g @phnx-labs/agents-cli), then restart it.`;
349
+ log('WARN', message);
350
+ return message;
351
+ }
352
+ catch (err) {
353
+ log('WARN', `Could not check daemon launch root: ${err.message}`);
354
+ return null;
355
+ }
356
+ }
324
357
  export async function runDaemon() {
325
358
  // Single-instance guard: a direct `agents __daemon-run` (manual, or a
326
359
  // service-manager restart racing a live predecessor) must not clobber a
@@ -334,6 +367,7 @@ export async function runDaemon() {
334
367
  }
335
368
  log('INFO', `Daemon started (PID: ${process.pid})`);
336
369
  anchorDaemonCwd();
370
+ warnEphemeralDaemonRoot();
337
371
  // The daemon holds NO Claude credential of its own. Routine runs authenticate
338
372
  // exactly like an interactive `agents run`: through the per-account
339
373
  // CLAUDE_CONFIG_DIR login on this device (its own auto-refreshing
@@ -1106,15 +1140,35 @@ function daemonPathValue(agentsBin, systemDirs) {
1106
1140
  export function getAgentsInvocation(subArgs, agentsBin = getAgentsBinPath()) {
1107
1141
  return getCliLaunch(subArgs, agentsBin);
1108
1142
  }
1143
+ /**
1144
+ * A daemon binary living under an ephemeral path — a git worktree, or a temp
1145
+ * directory (`/tmp`, `/var/folders`, `/dev/shm`) — is a latent wedge. The daemon
1146
+ * is long-lived but resolves its own job modules by dynamic `import()` rooted at
1147
+ * this entry (getAgentsBinPath → process.argv[1]). If that directory is later
1148
+ * removed (`git worktree remove`, a `/tmp` cleanup, a review/verify checkout
1149
+ * teardown) the running daemon keeps ENOENT-ing on every job it loads —
1150
+ * `anchorDaemonCwd` rescues the cwd, but nothing can re-root a deleted module
1151
+ * tree. Returns a human phrase naming the ephemeral kind, or null for a stable
1152
+ * install path (version home, a global npm prefix, a normal source checkout).
1153
+ */
1154
+ export function describeEphemeralDaemonRoot(binPath) {
1155
+ if (/[/\\]\.agents[/\\]worktrees[/\\]/.test(binPath))
1156
+ return 'a git worktree';
1157
+ if (/^(?:\/private)?\/tmp[/\\]|^(?:\/private)?\/var\/folders[/\\]|^\/dev\/shm[/\\]/.test(binPath)) {
1158
+ return 'a temporary directory';
1159
+ }
1160
+ return null;
1161
+ }
1109
1162
  export function validateDaemonBinary(binPath) {
1110
1163
  const warnings = [];
1111
1164
  if (BUN_VIRTUAL_ROOT.test(binPath)) {
1112
1165
  throw new Error(`Refusing to supervise daemon: resolved binary is a bun virtual path (${binPath}). ` +
1113
1166
  `Install agents globally (npm i -g @phnx-labs/agents-cli) and restart.`);
1114
1167
  }
1115
- if (/[/\\]\.agents[/\\]worktrees[/\\]/.test(binPath)) {
1116
- warnings.push(`Warning: daemon binary is inside a git worktree (${binPath}). ` +
1117
- `A worktree deletion will wedge the daemon. Use the globally installed binary instead.`);
1168
+ const ephemeralRoot = describeEphemeralDaemonRoot(binPath);
1169
+ if (ephemeralRoot) {
1170
+ warnings.push(`Warning: daemon binary is inside ${ephemeralRoot} (${binPath}). ` +
1171
+ `Deleting it will wedge the daemon. Use the globally installed binary instead.`);
1118
1172
  }
1119
1173
  if (!fs.existsSync(binPath) && !/\.(c|m)?js$/.test(binPath)) {
1120
1174
  warnings.push(`Warning: daemon binary does not exist on disk (${binPath}).`);
@@ -1132,7 +1186,7 @@ export function startDetached(opts = {}) {
1132
1186
  // and a console-close event tears it down when the launcher exits (#556).
1133
1187
  const child = spawn(command, args, {
1134
1188
  stdio: ['ignore', logFd, logFd],
1135
- ...backgroundSpawnOptions({ fdStdio: true }),
1189
+ ...backgroundSpawnOptions({ cwd: os.homedir(), fdStdio: true }),
1136
1190
  env: opts.env ?? process.env,
1137
1191
  });
1138
1192
  // A failed spawn (ENOENT/EACCES) emits 'error' asynchronously; without a
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { type ActorKind } from './actor.js';
15
15
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
16
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.unlocked' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
16
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.unlocked' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
17
17
  export declare function levelFor(event: EventType): EventLevel;
18
18
  export interface EventMeta {
19
19
  ts: string;
@@ -0,0 +1,78 @@
1
+ import { type ActiveSession } from '../session/active.js';
2
+ import { serializeActiveSessionsForJson } from '../../commands/sessions.js';
3
+ import { readAuthHealthCache, type AuthVerdict } from '../auth-health.js';
4
+ import { readStatsCache } from '../devices/stats-cache.js';
5
+ export declare const FACTORY_PROJECTS: readonly [{
6
+ readonly name: "Prix";
7
+ readonly repo: "phnx-labs/prix";
8
+ }, {
9
+ readonly name: "Rush App";
10
+ readonly repo: "phnx-labs/rush";
11
+ }, {
12
+ readonly name: "Rush CLI";
13
+ readonly repo: "phnx-labs/rush-cli";
14
+ }, {
15
+ readonly name: "Agents CLI";
16
+ readonly repo: "phnx-labs/agents-cli";
17
+ }, {
18
+ readonly name: "Linear CLI";
19
+ readonly repo: "phnx-labs/linear-cli";
20
+ }];
21
+ export interface FactoryConfig {
22
+ source: 'default' | 'file';
23
+ ceiling: number;
24
+ max_dispatch_per_tick: number;
25
+ per_project: Record<string, {
26
+ weight: number;
27
+ cap: number;
28
+ }>;
29
+ idle_boxes: string[];
30
+ digest: {
31
+ times: string[];
32
+ tz: string;
33
+ };
34
+ }
35
+ export interface FactorySnapshot {
36
+ generatedAt: string;
37
+ sessions: ReturnType<typeof serializeActiveSessionsForJson>;
38
+ queues: Record<string, {
39
+ todo: number;
40
+ inProgress: number;
41
+ blocked: number;
42
+ }>;
43
+ prs: Array<{
44
+ repo: string;
45
+ number: number;
46
+ ci: string;
47
+ review: string;
48
+ mergeable: string;
49
+ }>;
50
+ devices: Array<{
51
+ name: string;
52
+ load: number | null;
53
+ idle: boolean;
54
+ }>;
55
+ recentRuns: Array<{
56
+ routine: string;
57
+ status: string;
58
+ durationMs: number | null;
59
+ }>;
60
+ auth: {
61
+ claude: AuthVerdict | null;
62
+ };
63
+ config: FactoryConfig;
64
+ }
65
+ export interface SnapshotDependencies {
66
+ home: string;
67
+ now: () => Date;
68
+ activeSessions: () => Promise<ActiveSession[]>;
69
+ run: (file: string, args: string[]) => Promise<string>;
70
+ readAuth: () => ReturnType<typeof readAuthHealthCache>;
71
+ readDeviceStats: () => ReturnType<typeof readStatsCache>;
72
+ }
73
+ export declare function readFactoryConfig(home: string): FactoryConfig;
74
+ export declare function queueCounts(todoPayload: unknown, openPayload: unknown): FactorySnapshot['queues'][string];
75
+ export declare function parsePullRequests(repo: string, payload: unknown): FactorySnapshot['prs'];
76
+ export declare function parseDevices(payload: unknown, cached?: ReturnType<typeof readStatsCache>): FactorySnapshot['devices'];
77
+ export declare function readRecentRuns(home: string, limit?: number): FactorySnapshot['recentRuns'];
78
+ export declare function buildFactorySnapshot(overrides?: Partial<SnapshotDependencies>): Promise<FactorySnapshot>;