@phnx-labs/agents-cli 1.20.91 → 1.20.92

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 (68) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +1 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +72 -6
  5. package/dist/commands/activity.js +198 -49
  6. package/dist/commands/beta.js +1 -0
  7. package/dist/commands/doctor.js +4 -2
  8. package/dist/commands/exec.d.ts +14 -0
  9. package/dist/commands/exec.js +144 -14
  10. package/dist/commands/projects.d.ts +12 -0
  11. package/dist/commands/projects.js +358 -0
  12. package/dist/commands/sessions-picker.d.ts +15 -0
  13. package/dist/commands/sessions-picker.js +37 -12
  14. package/dist/commands/sessions-resume.d.ts +2 -0
  15. package/dist/commands/sessions-resume.js +9 -1
  16. package/dist/commands/sessions.d.ts +10 -5
  17. package/dist/commands/sessions.js +65 -27
  18. package/dist/index.js +2 -1
  19. package/dist/lib/activity.d.ts +69 -12
  20. package/dist/lib/activity.js +417 -74
  21. package/dist/lib/beta.d.ts +1 -1
  22. package/dist/lib/beta.js +1 -1
  23. package/dist/lib/devices/registry.d.ts +14 -0
  24. package/dist/lib/devices/registry.js +37 -0
  25. package/dist/lib/feed-post.js +8 -2
  26. package/dist/lib/hosts/remote-cmd.js +4 -0
  27. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  28. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  29. package/dist/lib/menubar/install-menubar.js +20 -6
  30. package/dist/lib/project-key.d.ts +44 -0
  31. package/dist/lib/project-key.js +79 -0
  32. package/dist/lib/project-root.js +16 -0
  33. package/dist/lib/project-status.d.ts +69 -0
  34. package/dist/lib/project-status.js +101 -0
  35. package/dist/lib/projects.d.ts +138 -0
  36. package/dist/lib/projects.js +301 -0
  37. package/dist/lib/remote-agents-json.d.ts +9 -0
  38. package/dist/lib/remote-agents-json.js +11 -5
  39. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  40. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  41. package/dist/lib/session/bash-command.d.ts +53 -0
  42. package/dist/lib/session/bash-command.js +364 -0
  43. package/dist/lib/session/digest.d.ts +6 -0
  44. package/dist/lib/session/digest.js +19 -0
  45. package/dist/lib/session/relative-time.d.ts +23 -0
  46. package/dist/lib/session/relative-time.js +60 -8
  47. package/dist/lib/session/remote-list.js +5 -2
  48. package/dist/lib/session/render.d.ts +2 -9
  49. package/dist/lib/session/render.js +25 -56
  50. package/dist/lib/ssh-exec.d.ts +6 -0
  51. package/dist/lib/ssh-exec.js +10 -1
  52. package/dist/lib/startup/command-registry.d.ts +1 -0
  53. package/dist/lib/startup/command-registry.js +2 -0
  54. package/dist/lib/state.d.ts +2 -0
  55. package/dist/lib/state.js +5 -0
  56. package/dist/lib/terminal/backends/index.d.ts +10 -2
  57. package/dist/lib/terminal/backends/index.js +14 -2
  58. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  59. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  60. package/dist/lib/terminal/index.d.ts +2 -1
  61. package/dist/lib/terminal/index.js +2 -1
  62. package/dist/lib/terminal/preferred.d.ts +89 -0
  63. package/dist/lib/terminal/preferred.js +87 -0
  64. package/dist/lib/terminal/run-surface.d.ts +82 -0
  65. package/dist/lib/terminal/run-surface.js +146 -0
  66. package/dist/lib/terminal/types.d.ts +1 -1
  67. package/dist/lib/types.d.ts +1 -1
  68. package/package.json +2 -1
@@ -12,7 +12,7 @@ import { getCliVersion } from '../lib/version.js';
12
12
  import { resolveHost } from '../lib/hosts/registry.js';
13
13
  import { sshExecAsync } from '../lib/ssh-exec.js';
14
14
  import { sshTargetFor } from '../lib/hosts/types.js';
15
- import { machineId } from '../lib/session/sync/config.js';
15
+ import { machineId, normalizeHost } from '../lib/session/sync/config.js';
16
16
  import chalk from 'chalk';
17
17
  import { checkAllClis, collectTeamsDoctorData } from '../lib/teams/agents.js';
18
18
  import { AGENTS, ALL_AGENT_IDS, resolveAgentName, formatAgentError, getAccountInfo } from '../lib/agents.js';
@@ -101,7 +101,9 @@ async function resolveFleetTargets(opts) {
101
101
  const registry = await loadDevices();
102
102
  const localName = machineId();
103
103
  return Object.values(registry)
104
- .filter((d) => d.name.toLowerCase() !== localName)
104
+ // Normalize names so zion/ZION/zion.local all match machineId() and we never
105
+ // self-SSH the local box during fleet probes (RUSH-2114).
106
+ .filter((d) => normalizeHost(d.name) !== localName)
105
107
  // Control devices (a cockpit) never run agents — skip them in the fleet
106
108
  // fan-out (an explicit --device <name> still resolves above).
107
109
  .filter((d) => !isControlDevice(d))
@@ -16,6 +16,20 @@ export interface RunAccountPickerRequest {
16
16
  /** Distinguish a terminal account-picker marker from an explicit @version pin. */
17
17
  export declare function parseRunAccountPickerRequest(agentSpec: string): RunAccountPickerRequest;
18
18
  /** Return every option whose routing semantics conflict with a local account choice. */
19
+ /**
20
+ * The `--host` alias family — the flags that mean "dispatch this run to another
21
+ * machine over SSH". `--host` is canonical; `--device`/`--on`/`--computer` are
22
+ * aliases. Returns the values actually given (so callers can both test presence
23
+ * and read the target). Kept in ONE place because a guard that listed only a
24
+ * subset silently let `--terminal --device` open a local tab and drop the remote
25
+ * target — the drift this predicate exists to prevent.
26
+ */
27
+ export declare function hostTargetGiven(options: {
28
+ host?: string;
29
+ device?: string;
30
+ on?: string;
31
+ computer?: string;
32
+ }): string[];
19
33
  export declare function runAccountPickerConflicts(options: {
20
34
  resume?: string | boolean;
21
35
  strategy?: string;
@@ -34,6 +34,17 @@ export function parseRunAccountPickerRequest(agentSpec) {
34
34
  };
35
35
  }
36
36
  /** Return every option whose routing semantics conflict with a local account choice. */
37
+ /**
38
+ * The `--host` alias family — the flags that mean "dispatch this run to another
39
+ * machine over SSH". `--host` is canonical; `--device`/`--on`/`--computer` are
40
+ * aliases. Returns the values actually given (so callers can both test presence
41
+ * and read the target). Kept in ONE place because a guard that listed only a
42
+ * subset silently let `--terminal --device` open a local tab and drop the remote
43
+ * target — the drift this predicate exists to prevent.
44
+ */
45
+ export function hostTargetGiven(options) {
46
+ return [options.host, options.device, options.on, options.computer].filter((v) => !!v);
47
+ }
37
48
  export function runAccountPickerConflicts(options) {
38
49
  const conflicts = [];
39
50
  if (options.resume !== undefined)
@@ -46,7 +57,7 @@ export function runAccountPickerConflicts(options) {
46
57
  conflicts.push('--lease');
47
58
  if (options.box)
48
59
  conflicts.push('--box');
49
- if (options.host || options.device || options.on || options.computer)
60
+ if (hostTargetGiven(options).length)
50
61
  conflicts.push('--host/--device');
51
62
  return conflicts;
52
63
  }
@@ -349,6 +360,121 @@ export async function runWorkflowForEach(spec, opts) {
349
360
  process.stderr.write(chalk.yellow(`[for_each] stopped by ${result.stoppedBy} after ${result.waves} waves. Team: ${team}\n`));
350
361
  return 1;
351
362
  }
363
+ /**
364
+ * The run's working directory from `--cwd` / `--project`. `--project <slug>` owns
365
+ * the directory and is mutually exclusive with `--cwd`/`--remote-cwd`; both the
366
+ * main dispatch and the `--terminal` handoff need the same answer, so the rule
367
+ * and its error live here once instead of in two places that can drift.
368
+ */
369
+ async function resolveRunCwd(options, opts) {
370
+ if (!options.project)
371
+ return options.cwd;
372
+ if (options.cwd || options.remoteCwd) {
373
+ console.error(chalk.red('Pass --project alone — not with --cwd or --remote-cwd.'));
374
+ process.exit(1);
375
+ }
376
+ const { resolveProjectRef } = await import('../lib/project-root.js');
377
+ try {
378
+ return await resolveProjectRef(options.project, { forRemote: opts.forRemote });
379
+ }
380
+ catch (err) {
381
+ console.error(chalk.red(err.message));
382
+ process.exit(1);
383
+ }
384
+ }
385
+ /**
386
+ * `--terminal`: hand this run to a real terminal tab and exit.
387
+ *
388
+ * The terminal is detected from the user's live sessions, so a run started from
389
+ * a surface that cannot host a TUI (the menu bar's "New Session", a script) lands
390
+ * in the terminal they actually work in instead of a hardcoded Terminal.app.
391
+ * Exits non-zero when no terminal could be opened — the caller must not believe
392
+ * a session started when none did.
393
+ */
394
+ async function handleTerminalHandoff(agentSpec, options, prompt) {
395
+ const { parseTerminalFlag, openRunInTerminal, toHostSamples } = await import('../lib/terminal/run-surface.js');
396
+ const { currentContext } = await import('../lib/terminal/index.js');
397
+ const parsed = parseTerminalFlag(options.terminal);
398
+ if (parsed.error) {
399
+ console.error(chalk.red(parsed.error));
400
+ process.exit(1);
401
+ }
402
+ // Reject an unrunnable target HERE, where the person can read it. The tab would
403
+ // otherwise open, print the same error, and close — the failure lands in a
404
+ // window that is gone before it can be read, which reads as "nothing happened".
405
+ //
406
+ // `agents run <thing>` takes an agent id, a PROFILE, or a WORKFLOW (see the
407
+ // isValidAgent / profileExists / resolveWorkflowRef chain below), so this must
408
+ // accept all three. Gating on the agent table alone rejected every profile —
409
+ // the whole Kimi/DeepSeek/Qwen/GLM path — for `--terminal` runs only.
410
+ const rawTarget = parseRunAccountPickerRequest(agentSpec).normalizedAgentSpec.split('@')[0];
411
+ const knownAgent = resolveAgentName(rawTarget);
412
+ if (knownAgent && isAgentHardDeprecated(knownAgent)) {
413
+ console.error(chalk.red(hardDeprecationError(knownAgent)));
414
+ process.exit(1);
415
+ }
416
+ if (!knownAgent) {
417
+ const [{ profileExists }, { resolveWorkflowRef }] = await Promise.all([
418
+ import('../lib/profiles.js'),
419
+ import('../lib/workflows.js'),
420
+ ]);
421
+ const probeCwd = options.cwd ?? process.cwd();
422
+ if (!profileExists(rawTarget) && !resolveWorkflowRef(rawTarget, probeCwd)) {
423
+ console.error(chalk.red(`Unknown agent, profile, or workflow: ${rawTarget}. See \`agents list\` for the installed harnesses.`));
424
+ process.exit(1);
425
+ }
426
+ }
427
+ // --host and its aliases (--device/--on/--computer) all mean "dispatch this
428
+ // run to another machine over SSH", which is incompatible with opening a
429
+ // terminal tab on THIS machine — so reject the whole alias family, not just
430
+ // the canonical flag. The rule and its wording live once, in the --host
431
+ // forwarding table, so the classification a reviewer reads and the error a
432
+ // user sees can't drift.
433
+ if (hostTargetGiven(options).length) {
434
+ const { RUN_OPTION_REJECT_MESSAGES } = await import('../lib/hosts/remote-cmd.js');
435
+ console.error(chalk.red(RUN_OPTION_REJECT_MESSAGES.terminal));
436
+ process.exit(1);
437
+ }
438
+ // Machine-readable output would land in the tab, where the caller that asked
439
+ // for it can never read it. Same class of failure as --host: refuse, don't
440
+ // hand back a stream that goes nowhere.
441
+ const streamFlag = options.json ? '--json' : options.emitSessionId ? '--emit-session-id' : undefined;
442
+ if (streamFlag) {
443
+ console.error(chalk.red(`${streamFlag} streams to stdout, but --terminal moves the run into a tab where you cannot read it. Drop one.`));
444
+ process.exit(1);
445
+ }
446
+ // `--project` owns the working directory, but the main action resolves it far
447
+ // below this handoff — so without this the tab would open in THIS process's
448
+ // cwd (launchd's `/` for a menu-bar click) while the run inside it moved to
449
+ // the project. `forRemote: false` because --terminal is always local (--host
450
+ // is rejected above).
451
+ const cwd = await resolveRunCwd(options, { forRemote: false });
452
+ const { getActiveSessions } = await import('../lib/session/active.js');
453
+ let sessions = [];
454
+ try {
455
+ sessions = await toHostSamples(await getActiveSessions());
456
+ }
457
+ catch {
458
+ // Detection is best-effort — an unreadable session index must not block the
459
+ // launch; resolution falls through to the available-backend floor.
460
+ }
461
+ const result = await openRunInTerminal({
462
+ argv: process.argv.slice(2),
463
+ forced: parsed.backend,
464
+ consumedValue: typeof options.terminal === 'string' ? options.terminal : undefined,
465
+ cwd: cwd ?? process.cwd(),
466
+ sessions,
467
+ ctx: currentContext(),
468
+ });
469
+ if (!result.ok) {
470
+ console.error(chalk.red(`Could not open a terminal: ${result.error ?? 'unknown error'}`));
471
+ process.exit(1);
472
+ }
473
+ if (!options.quiet) {
474
+ const what = prompt === undefined ? 'session' : 'run';
475
+ console.log(chalk.gray(`Opened the ${what} in ${result.description}.`));
476
+ }
477
+ }
352
478
  /** Register the `agents run <agent> [prompt]` command. */
353
479
  export function registerRunCommand(program) {
354
480
  const runCmd = program
@@ -374,6 +500,7 @@ export function registerRunCommand(program) {
374
500
  .option('--session-id <id>', 'Force a NEW conversation to use this exact session UUID (Claude only). This CREATES a session — to resume an existing one, use --resume.')
375
501
  .option('--name <slug>', 'Name the run — seeds the session label so it shows up as `<name>` in `agents sessions` and resolves by it (and `agents hosts logs <name>` for --host runs) instead of an opaque id. An agent-generated title later refines the label; your name shows until then. Optional.')
376
502
  .option('--notify', 'Post a desktop notification when a headless run finishes. Fired by this process on exit, so it survives whatever launched the run (the menu bar dispatching it, a terminal you closed).')
503
+ .option('--terminal [backend]', "Open this run in a real terminal tab instead of here. Without a value the terminal is detected from your live sessions (`agents sessions --active` host), so it lands where you already work — Ghostty for a Ghostty user, iTerm for an iTerm user. Name one to force it: iterm | ghostty | terminal | tmux | vscodium-agent. This is how the menu bar's New Session opens.")
377
504
  .option('--verbose', 'Show detailed execution logs')
378
505
  .option('--raw', 'Interactive runs on macOS/Linux launch inside a shared tmux session (for %pane addressing + re-attach). Pass --raw to spawn the agent directly instead. Also disabled by AGENTS_NO_TMUX=1.')
379
506
  .option('--no-tmux', 'Spawn the agent directly instead of wrapping it in the shared tmux session. Same effect as --raw / AGENTS_NO_TMUX=1. Use this to see the agent\'s full startup output when a launch is failing.')
@@ -433,6 +560,11 @@ export function registerRunCommand(program) {
433
560
  # Pick a signed-in account/version for only this run
434
561
  agents run claude@
435
562
 
563
+ # Open the session in a terminal tab — detected from where your sessions
564
+ # already run (Ghostty / iTerm / Terminal.app); force one with a value
565
+ agents run claude --terminal
566
+ agents run claude --terminal ghostty
567
+
436
568
  # Pipe JSON events to a parser (--quiet drops the preamble)
437
569
  agents run claude "..." --json --quiet | jq
438
570
 
@@ -499,6 +631,15 @@ export function registerRunCommand(program) {
499
631
  // a native flag, not a prompt. Run interactively.
500
632
  prompt = undefined;
501
633
  }
634
+ // --terminal: this process can't host the TUI (a menu-bar click, a script),
635
+ // so hand the run to a real terminal and exit. Resolved from the user's own
636
+ // live sessions, so it opens where they already work. Done before every
637
+ // other dispatch path because the tab re-runs this same argv without the
638
+ // flag — arming --notify or picking a version here would happen twice.
639
+ if (options.terminal) {
640
+ await handleTerminalHandoff(agentSpec, options, prompt);
641
+ return;
642
+ }
502
643
  // --notify: post a desktop notification when this run finishes. Armed on
503
644
  // process exit so it covers EVERY dispatch path below (local, --host,
504
645
  // --lease, the error path) instead of one branch. Only for headless runs
@@ -906,24 +1047,13 @@ export function registerRunCommand(program) {
906
1047
  }
907
1048
  // --host/--on/--computer: offload this run onto a registered agent host
908
1049
  // over SSH instead of running locally. The three flags are aliases.
909
- const hostGiven = [options.host, options.device, options.on, options.computer].filter((v) => !!v);
1050
+ const hostGiven = hostTargetGiven(options);
910
1051
  // --project <slug>[@worktree]: resolve the projects-root shorthand into a
911
1052
  // cwd. On a host run it resolves home-relative (`~/…`, so the host expands
912
1053
  // it); locally it becomes an absolute path. It owns the working directory,
913
1054
  // so it is mutually exclusive with both --cwd and --remote-cwd.
914
1055
  if (options.project) {
915
- if (options.cwd || options.remoteCwd) {
916
- console.error(chalk.red('Pass --project alone — not with --cwd or --remote-cwd.'));
917
- process.exit(1);
918
- }
919
- const { resolveProjectRef } = await import('../lib/project-root.js');
920
- try {
921
- options.cwd = await resolveProjectRef(options.project, { forRemote: hostGiven.length > 0 });
922
- }
923
- catch (err) {
924
- console.error(chalk.red(err.message));
925
- process.exit(1);
926
- }
1056
+ options.cwd = await resolveRunCwd(options, { forRemote: hostGiven.length > 0 });
927
1057
  }
928
1058
  if (hostGiven.length > 0) {
929
1059
  if (new Set(hostGiven).size > 1) {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `agents projects` — named, multi-repo projects and the progress
3
+ * rollup. Definitions live in `~/.agents/projects/<name>.yaml` (see
4
+ * `lib/projects.ts`); this registers the command tree over them. Beta-gated on
5
+ * `isBetaEnabled('projects')`, mirroring `agents factory`.
6
+ *
7
+ * The headline is `status`: instead of the vague per-agent activity line, it
8
+ * rolls every session up by project (matched on cwd) into one card — agents by
9
+ * lifecycle state, plan completion, open PRs, and tickets in flight.
10
+ */
11
+ import type { Command } from 'commander';
12
+ export declare function registerProjectsCommands(program: Command): void;
@@ -0,0 +1,358 @@
1
+ /**
2
+ * `agents projects` — named, multi-repo projects and the progress
3
+ * rollup. Definitions live in `~/.agents/projects/<name>.yaml` (see
4
+ * `lib/projects.ts`); this registers the command tree over them. Beta-gated on
5
+ * `isBetaEnabled('projects')`, mirroring `agents factory`.
6
+ *
7
+ * The headline is `status`: instead of the vague per-agent activity line, it
8
+ * rolls every session up by project (matched on cwd) into one card — agents by
9
+ * lifecycle state, plan completion, open PRs, and tickets in flight.
10
+ */
11
+ import chalk from 'chalk';
12
+ import * as fs from 'fs';
13
+ import { execFileSync, spawnSync } from 'child_process';
14
+ import { betaEnableHint, isBetaEnabled } from '../lib/beta.js';
15
+ import { setHelpSections } from '../lib/help.js';
16
+ import { getMainRepoRoot } from '../lib/git.js';
17
+ import { parseOwnerRepoFromRemote } from '../lib/registry.js';
18
+ import { toHomeRelative } from '../lib/project-root.js';
19
+ import { getActiveSessions } from '../lib/session/active.js';
20
+ import { factoryProjectsPath } from '../lib/auto-dispatch.js';
21
+ import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, } from '../lib/projects.js';
22
+ import { rollupSessionsByProject, planPct, enrichProjectSignals, } from '../lib/project-status.js';
23
+ /** `path:purpose` → a context anchor. Purpose may contain colons. */
24
+ function parseContextFlag(raw) {
25
+ const i = raw.indexOf(':');
26
+ if (i === -1)
27
+ return { path: raw.trim(), purpose: '' };
28
+ return { path: raw.slice(0, i).trim(), purpose: raw.slice(i + 1).trim() };
29
+ }
30
+ /** Best-effort `owner/repo` from a repo's origin remote. */
31
+ function originSlug(cwd) {
32
+ try {
33
+ const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8' }).trim();
34
+ return parseOwnerRepoFromRemote(url) ?? undefined;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ function statusBar(r) {
41
+ const parts = [];
42
+ const push = (n, label, color) => {
43
+ if (n && n > 0)
44
+ parts.push(color(`${n} ${label}`));
45
+ };
46
+ push(r.byStatus.running, 'running', chalk.green);
47
+ push(r.byStatus.idle, 'idle', chalk.gray);
48
+ push(r.byStatus.input_required, 'need-input', chalk.yellow);
49
+ push(r.byStatus.queued, 'queued', chalk.gray);
50
+ const shown = (r.byStatus.running ?? 0) + (r.byStatus.idle ?? 0) + (r.byStatus.input_required ?? 0) + (r.byStatus.queued ?? 0);
51
+ // Sessions in a non-live state (orphaned/crashed/unknown) count toward the
52
+ // headline — render the remainder so the bar never sums to less than it.
53
+ if (r.agents > shown)
54
+ parts.push(chalk.gray(`+${r.agents - shown} other`));
55
+ return parts.join(' · ') || chalk.gray('no live agents');
56
+ }
57
+ function renderCard(def, r, remote) {
58
+ const agents = r?.agents ?? 0;
59
+ const pct = r ? planPct(r.plan) : undefined;
60
+ const planStr = pct === undefined ? '' : ` · ${chalk.cyan(`${pct}% plan`)}`;
61
+ console.log(`${chalk.bold(def.name)} ${chalk.dim('·')} ${chalk.bold(`${agents} agents`)}${planStr}`);
62
+ if (def.description)
63
+ console.log(` ${chalk.dim(def.description)}`);
64
+ console.log(` ${chalk.dim('live')} ${r ? statusBar(r) : chalk.gray('no live agents')}`);
65
+ const ships = [];
66
+ if (remote?.mergedPrs)
67
+ ships.push(chalk.green(`${remote.mergedPrs} merged (${remote.windowDays}d)`));
68
+ if (r?.openPrs.length)
69
+ ships.push(`${r.openPrs.length} open PR${r.openPrs.length === 1 ? '' : 's'}`);
70
+ if (r?.worktrees)
71
+ ships.push(`${r.worktrees} worktree${r.worktrees === 1 ? '' : 's'}`);
72
+ if (ships.length)
73
+ console.log(` ${chalk.dim('ships')} ${ships.join(' · ')}`);
74
+ if (r && r.tickets.length) {
75
+ console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
76
+ }
77
+ if (remote?.artifacts) {
78
+ const last = remote.lastArtifact ? ` ${chalk.dim(`· last: ${remote.lastArtifact}`)}` : '';
79
+ console.log(` ${chalk.dim('proof')} ${remote.artifacts} artifact${remote.artifacts === 1 ? '' : 's'} (${remote.windowDays}d)${last}`);
80
+ }
81
+ const repos = [def.repo, ...(def.repos ?? []).map((x) => x.slug)].filter(Boolean);
82
+ if (repos.length)
83
+ console.log(` ${chalk.dim('repos')} ${[...new Set(repos)].join(' · ')}`);
84
+ if (def.contexts?.length) {
85
+ console.log(` ${chalk.dim('context')} ${def.contexts.map((c) => c.path).join(' · ')}`);
86
+ }
87
+ if (def.integrations?.length) {
88
+ console.log(` ${chalk.dim('links')} ${def.integrations.map((i) => i.label ?? i.kind).join(' · ')}`);
89
+ }
90
+ console.log('');
91
+ }
92
+ export function registerProjectsCommands(program) {
93
+ const enabled = isBetaEnabled('projects');
94
+ const projects = program
95
+ .command('projects', { hidden: !enabled })
96
+ .description('Named multi-repo projects with a progress rollup.');
97
+ setHelpSections(projects, {
98
+ examples: `
99
+ agents projects add rush --repo phnx-labs/rush --path apps/web
100
+ agents projects list
101
+ agents projects status # progress card for every project
102
+ agents projects status rush --json # one project, machine-readable
103
+ agents run --project rush # land an agent in the project
104
+ `,
105
+ notes: `
106
+ Definitions are hand-editable YAML in ~/.agents/projects/ and sync across
107
+ machines with 'agents push/pull'. Enable with: agents beta enable projects.
108
+ `,
109
+ });
110
+ projects.hook('preAction', () => {
111
+ if (enabled)
112
+ return;
113
+ console.error(chalk.red('agents projects is in beta.'));
114
+ console.error(chalk.gray(betaEnableHint('projects')));
115
+ process.exit(1);
116
+ });
117
+ // ---- list ----
118
+ projects
119
+ .command('list')
120
+ .description('List defined projects with their root, repo, and live agent count.')
121
+ .option('--json', 'Machine-readable output')
122
+ .action(async (opts) => {
123
+ const defs = listProjectDefs();
124
+ if (opts.json) {
125
+ const roll = rollupSessionsByProject(defs, await getActiveSessions());
126
+ console.log(JSON.stringify(defs.map((d) => ({ ...d, agents: roll.get(d.name)?.agents ?? 0 })), null, 2));
127
+ return;
128
+ }
129
+ if (!defs.length) {
130
+ console.log(chalk.gray('No projects defined. Add one: agents projects add <name>'));
131
+ return;
132
+ }
133
+ const roll = rollupSessionsByProject(defs, await getActiveSessions());
134
+ for (const d of defs) {
135
+ const agents = roll.get(d.name)?.agents ?? 0;
136
+ const repo = d.repo ?? d.repos?.[0]?.slug ?? '';
137
+ console.log(` ${chalk.bold(d.name.padEnd(16))} ${chalk.dim((d.root ?? d.defaultPath ?? '').padEnd(32))} ${chalk.cyan(repo.padEnd(24))} ${agents} agents`);
138
+ }
139
+ });
140
+ // ---- add ----
141
+ projects
142
+ .command('add <name>')
143
+ .description('Define a project. Infers root and repo from the current git repo when not given.')
144
+ .option('--root <path>', 'Repo / monorepo root (defaults to the current git repo root)')
145
+ .option('--path <subdir>', 'Default cwd for agents (a monorepo subdir)')
146
+ .option('--repo <owner/repo>', 'Primary GitHub slug (defaults to the origin remote)')
147
+ .option('--context <path:purpose...>', 'A described starting point; repeatable')
148
+ .option('--linear <url-or-id>', 'Linear project URL or id')
149
+ .option('--force', 'Overwrite an existing definition')
150
+ .action(async (name, opts) => {
151
+ if (!isSafeProjectName(name)) {
152
+ console.error(chalk.red(`Invalid project name: "${name}" (letters, digits, ., _, - only)`));
153
+ process.exit(1);
154
+ }
155
+ if (loadProjectDef(name) && !opts.force) {
156
+ console.error(chalk.red(`Project "${name}" already exists. Use --force to overwrite, or 'agents projects edit ${name}'.`));
157
+ process.exit(1);
158
+ }
159
+ const cwd = process.cwd();
160
+ let root = opts.root;
161
+ if (!root) {
162
+ try {
163
+ root = toHomeRelative(await getMainRepoRoot(cwd));
164
+ }
165
+ catch {
166
+ console.error(chalk.red('Not inside a git repo — pass --root <path> explicitly.'));
167
+ process.exit(1);
168
+ }
169
+ }
170
+ const repo = opts.repo ?? originSlug(cwd);
171
+ const def = { name, root };
172
+ if (opts.path)
173
+ def.defaultPath = `${root.replace(/\/$/, '')}/${opts.path.replace(/^\//, '')}`;
174
+ if (repo)
175
+ def.repo = repo;
176
+ if (opts.context?.length)
177
+ def.contexts = opts.context.map(parseContextFlag);
178
+ if (opts.linear) {
179
+ def.linear = /^https?:/.test(opts.linear) ? { url: opts.linear } : { projectId: opts.linear };
180
+ }
181
+ const target = writeProjectDef(def);
182
+ console.log(chalk.green(`Defined project "${name}"`));
183
+ console.log(chalk.gray(` ${target}`));
184
+ console.log(chalk.gray(` root ${def.root}${def.repo ? ` · repo ${def.repo}` : ''}`));
185
+ });
186
+ // ---- show ----
187
+ projects
188
+ .command('show <name>')
189
+ .description('Show a project definition, resolved paths, repos, contexts, and links.')
190
+ .option('--json', 'Machine-readable output')
191
+ .action((name, opts) => {
192
+ const def = loadProjectDef(name);
193
+ if (!def) {
194
+ console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
195
+ process.exit(1);
196
+ }
197
+ if (opts.json) {
198
+ console.log(JSON.stringify(def, null, 2));
199
+ return;
200
+ }
201
+ console.log(chalk.bold(def.name) + (def.description ? chalk.dim(` — ${def.description}`) : ''));
202
+ if (def.root)
203
+ console.log(` root ${def.root}`);
204
+ if (def.defaultPath)
205
+ console.log(` defaultPath ${def.defaultPath}`);
206
+ const repos = [def.repo, ...(def.repos ?? []).map((r) => (r.subpath ? `${r.slug} (${r.subpath})` : r.slug))].filter(Boolean);
207
+ if (repos.length)
208
+ console.log(` repos ${repos.join(', ')}`);
209
+ for (const c of def.contexts ?? [])
210
+ console.log(` context ${chalk.cyan(c.path)} — ${c.purpose}`);
211
+ for (const i of def.integrations ?? [])
212
+ console.log(` ${i.kind.padEnd(12)} ${i.url}${i.label ? chalk.dim(` (${i.label})`) : ''}`);
213
+ if (def.linear?.url || def.linear?.projectId)
214
+ console.log(` linear ${def.linear.url ?? def.linear.projectId}`);
215
+ for (const d of def.docs ?? [])
216
+ console.log(` doc ${d}`);
217
+ console.log(chalk.gray(` ${projectDefPath(name)}`));
218
+ });
219
+ // ---- edit ----
220
+ projects
221
+ .command('edit <name>')
222
+ .description('Open the project YAML in $EDITOR (it is hand-editable regardless).')
223
+ .action((name) => {
224
+ const target = projectDefPath(name);
225
+ if (!fs.existsSync(target)) {
226
+ console.error(chalk.red(`No project named "${name}". Create it: agents projects add ${name}`));
227
+ process.exit(1);
228
+ }
229
+ const editor = process.env.VISUAL || process.env.EDITOR || 'vi';
230
+ // $EDITOR commonly carries args ("code --wait") — split like monitors/routines do.
231
+ const parts = editor.split(/\s+/).filter(Boolean);
232
+ const res = spawnSync(parts[0], [...parts.slice(1), target], { stdio: 'inherit' });
233
+ process.exit(res.status ?? 0);
234
+ });
235
+ // ---- status ----
236
+ projects
237
+ .command('status [name]')
238
+ .description('Progress rollup: agents, plan %, merged/open PRs, tickets, and artifacts per project.')
239
+ .option('--json', 'Machine-readable output')
240
+ .option('--window <days>', 'Window for merged PRs and artifacts', '7')
241
+ .option('--no-remote', 'Skip the GitHub lookup (merged-PR count); faster, offline')
242
+ .action(async (name, opts) => {
243
+ const all = listProjectDefs();
244
+ // Named lookup goes through the strict single-def loader so a broken
245
+ // <name>.yaml surfaces its validation error instead of "No project named".
246
+ const defs = name ? [loadProjectDef(name)].filter((d) => d !== undefined) : all;
247
+ if (name && !defs.length) {
248
+ console.error(chalk.red(`No project named "${name}".`));
249
+ process.exit(1);
250
+ }
251
+ if (!defs.length) {
252
+ if (opts.json)
253
+ console.log('[]');
254
+ else
255
+ console.log(chalk.gray('No projects defined. Add one: agents projects add <name>'));
256
+ return;
257
+ }
258
+ const windowDays = Math.max(1, Number.parseInt(opts.window ?? '7', 10) || 7);
259
+ const nowMs = Date.now();
260
+ const roll = rollupSessionsByProject(all, await getActiveSessions());
261
+ // Enrich only the shown projects. --no-remote still reads the local artifact
262
+ // log but skips the gh call (def.repo left unused → mergedPrs stays 0).
263
+ const remote = new Map();
264
+ await Promise.all(defs.map(async (d) => {
265
+ remote.set(d.name, await enrichProjectSignals(d, windowDays, nowMs, { skipRemote: opts.remote === false }));
266
+ }));
267
+ if (opts.json) {
268
+ console.log(JSON.stringify(defs.map((d) => {
269
+ const r = roll.get(d.name);
270
+ const rem = remote.get(d.name);
271
+ return {
272
+ name: d.name,
273
+ agents: r?.agents ?? 0,
274
+ byStatus: r?.byStatus ?? {},
275
+ plan: r?.plan ?? { done: 0, total: 0 },
276
+ planPct: r ? planPct(r.plan) ?? null : null,
277
+ openPrs: r?.openPrs ?? [],
278
+ mergedPrs: rem?.mergedPrs ?? 0,
279
+ tickets: r?.tickets ?? [],
280
+ worktrees: r?.worktrees ?? 0,
281
+ artifacts: rem?.artifacts ?? 0,
282
+ lastArtifact: rem?.lastArtifact ?? null,
283
+ windowDays,
284
+ repos: [d.repo, ...(d.repos ?? []).map((r2) => r2.slug)].filter(Boolean),
285
+ };
286
+ }), null, 2));
287
+ return;
288
+ }
289
+ for (const d of defs)
290
+ renderCard(d, roll.get(d.name), remote.get(d.name));
291
+ });
292
+ // ---- import ----
293
+ projects
294
+ .command('import')
295
+ .description('Absorb the Factory project registry (~/.agents/factory/projects.json) into YAML definitions.')
296
+ .requiredOption('--from-factory', 'Import from the Factory projects.json registry')
297
+ .option('--force', 'Overwrite existing definitions')
298
+ .action((opts) => {
299
+ const src = factoryProjectsPath();
300
+ let rawText;
301
+ try {
302
+ rawText = fs.readFileSync(src, 'utf8');
303
+ }
304
+ catch {
305
+ console.error(chalk.red(`No Factory registry at ${src}`));
306
+ process.exit(1);
307
+ }
308
+ let rows;
309
+ try {
310
+ rows = JSON.parse(rawText);
311
+ }
312
+ catch {
313
+ console.error(chalk.red(`Factory registry at ${src} is not valid JSON`));
314
+ process.exit(1);
315
+ }
316
+ const list = Array.isArray(rows) ? rows : Array.isArray(rows.projects) ? rows.projects : [];
317
+ let created = 0;
318
+ let skipped = 0;
319
+ for (const raw of list) {
320
+ if (!raw || typeof raw !== 'object')
321
+ continue;
322
+ const o = raw;
323
+ const name = typeof o.name === 'string' ? o.name : undefined;
324
+ if (!name || !isSafeProjectName(name)) {
325
+ skipped++;
326
+ continue;
327
+ }
328
+ if (loadProjectDef(name) && !opts.force) {
329
+ skipped++;
330
+ continue;
331
+ }
332
+ const def = { name };
333
+ if (typeof o.path === 'string')
334
+ def.root = toHomeRelative(o.path);
335
+ if (typeof o.repoSlug === 'string')
336
+ def.repo = o.repoSlug;
337
+ if (typeof o.linearProjectId === 'string')
338
+ def.linear = { projectId: o.linearProjectId };
339
+ writeProjectDef(def);
340
+ created++;
341
+ }
342
+ console.log(chalk.green(`Imported ${created} project${created === 1 ? '' : 's'}${skipped ? chalk.gray(` (${skipped} skipped)`) : ''}`));
343
+ });
344
+ // ---- rm ----
345
+ projects
346
+ .command('rm <name>')
347
+ .alias('remove')
348
+ .description('Delete a project definition. Never touches the repo.')
349
+ .action((name) => {
350
+ if (removeProjectDef(name)) {
351
+ console.log(chalk.green(`Removed project "${name}"`));
352
+ }
353
+ else {
354
+ console.error(chalk.red(`No project named "${name}".`));
355
+ process.exit(1);
356
+ }
357
+ });
358
+ }
@@ -48,6 +48,21 @@ export declare function buildPreview(session: SessionMeta): string;
48
48
  * names the command that does report them instead.
49
49
  */
50
50
  export declare function formatTeamLineage(session: SessionMeta): string;
51
+ /**
52
+ * The three timing facts the header reports: when the session was created, when
53
+ * it was last active, and how long it ran. Reads the parsed transcript when
54
+ * there is one and otherwise the indexed `SessionMeta`, so a remote or
55
+ * unindexed session — which has no local transcript to parse — still reports
56
+ * them instead of silently dropping the whole line.
57
+ *
58
+ * `lastActive` and `lasted` are omitted for a session whose whole life was under
59
+ * a minute: there they just restate `created`.
60
+ */
61
+ export declare function extractTiming(session: Pick<SessionMeta, 'timestamp' | 'lastActivity' | 'durationMs'>, events: SessionEvent[]): {
62
+ createdAgo?: string;
63
+ lastActiveAgo?: string;
64
+ duration?: string;
65
+ };
51
66
  /**
52
67
  * Unique directories the session touched, compact and human-readable.
53
68
  * Prefer `session.recentDirectoriesTouched` — the scan records it on the row, so