@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.
- package/CHANGELOG.md +155 -0
- package/README.md +1 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +72 -6
- package/dist/commands/activity.js +198 -49
- package/dist/commands/beta.js +1 -0
- package/dist/commands/doctor.js +4 -2
- package/dist/commands/exec.d.ts +14 -0
- package/dist/commands/exec.js +144 -14
- package/dist/commands/projects.d.ts +12 -0
- package/dist/commands/projects.js +358 -0
- package/dist/commands/sessions-picker.d.ts +15 -0
- package/dist/commands/sessions-picker.js +37 -12
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +9 -1
- package/dist/commands/sessions.d.ts +10 -5
- package/dist/commands/sessions.js +65 -27
- package/dist/index.js +2 -1
- package/dist/lib/activity.d.ts +69 -12
- package/dist/lib/activity.js +417 -74
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/devices/registry.d.ts +14 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/feed-post.js +8 -2
- package/dist/lib/hosts/remote-cmd.js +4 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +14 -4
- package/dist/lib/menubar/install-menubar.js +20 -6
- package/dist/lib/project-key.d.ts +44 -0
- package/dist/lib/project-key.js +79 -0
- package/dist/lib/project-root.js +16 -0
- package/dist/lib/project-status.d.ts +69 -0
- package/dist/lib/project-status.js +101 -0
- package/dist/lib/projects.d.ts +138 -0
- package/dist/lib/projects.js +301 -0
- package/dist/lib/remote-agents-json.d.ts +9 -0
- package/dist/lib/remote-agents-json.js +11 -5
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/session/bash-command.d.ts +53 -0
- package/dist/lib/session/bash-command.js +364 -0
- package/dist/lib/session/digest.d.ts +6 -0
- package/dist/lib/session/digest.js +19 -0
- package/dist/lib/session/relative-time.d.ts +23 -0
- package/dist/lib/session/relative-time.js +60 -8
- package/dist/lib/session/remote-list.js +5 -2
- package/dist/lib/session/render.d.ts +2 -9
- package/dist/lib/session/render.js +25 -56
- package/dist/lib/ssh-exec.d.ts +6 -0
- package/dist/lib/ssh-exec.js +10 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +5 -0
- package/dist/lib/terminal/backends/index.d.ts +10 -2
- package/dist/lib/terminal/backends/index.js +14 -2
- package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
- package/dist/lib/terminal/backends/terminal-app.js +73 -0
- package/dist/lib/terminal/index.d.ts +2 -1
- package/dist/lib/terminal/index.js +2 -1
- package/dist/lib/terminal/preferred.d.ts +89 -0
- package/dist/lib/terminal/preferred.js +87 -0
- package/dist/lib/terminal/run-surface.d.ts +82 -0
- package/dist/lib/terminal/run-surface.js +146 -0
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/types.d.ts +1 -1
- package/package.json +2 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
.
|
|
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))
|
package/dist/commands/exec.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/exec.js
CHANGED
|
@@ -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.
|
|
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 =
|
|
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
|
-
|
|
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
|