@phnx-labs/agents-cli 1.20.91 → 1.20.93
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 +276 -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/events.js +91 -1
- package/dist/commands/exec.d.ts +14 -0
- package/dist/commands/exec.js +144 -14
- package/dist/commands/projects.d.ts +22 -0
- package/dist/commands/projects.js +539 -0
- package/dist/commands/secrets.d.ts +17 -0
- package/dist/commands/secrets.js +198 -7
- package/dist/commands/send.d.ts +14 -12
- package/dist/commands/send.js +105 -35
- 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/commands/sync.js +9 -3
- package/dist/commands/view.js +4 -0
- package/dist/index.js +18 -1
- package/dist/lib/activity.d.ts +77 -12
- package/dist/lib/activity.js +424 -74
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/channels/send.d.ts +83 -0
- package/dist/lib/channels/send.js +112 -0
- package/dist/lib/devices/registry.d.ts +14 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/events-ingest.d.ts +46 -0
- package/dist/lib/events-ingest.js +182 -0
- package/dist/lib/events.d.ts +15 -3
- package/dist/lib/events.js +55 -3
- package/dist/lib/feed-post.js +8 -2
- package/dist/lib/hosts/remote-cmd.js +4 -0
- package/dist/lib/linear-project-counts.d.ts +62 -0
- package/dist/lib/linear-project-counts.js +122 -0
- package/dist/lib/linear-projects.d.ts +50 -0
- package/dist/lib/linear-projects.js +114 -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/menubar/notify-desktop.d.ts +17 -2
- package/dist/lib/menubar/notify-desktop.js +8 -2
- package/dist/lib/project-key.d.ts +44 -0
- package/dist/lib/project-key.js +79 -0
- package/dist/lib/project-probe.d.ts +75 -0
- package/dist/lib/project-probe.js +160 -0
- package/dist/lib/project-resources.d.ts +8 -0
- package/dist/lib/project-resources.js +31 -3
- package/dist/lib/project-root.js +16 -0
- package/dist/lib/project-status.d.ts +100 -0
- package/dist/lib/project-status.js +182 -0
- package/dist/lib/projects.d.ts +144 -0
- package/dist/lib/projects.js +313 -0
- package/dist/lib/remote-agents-json.d.ts +9 -0
- package/dist/lib/remote-agents-json.js +11 -5
- package/dist/lib/routine-notify.d.ts +11 -0
- package/dist/lib/routine-notify.js +22 -0
- package/dist/lib/run-notify.js +3 -0
- 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/secrets/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +53 -10
- package/dist/lib/secrets/list-filter.d.ts +20 -5
- package/dist/lib/secrets/list-filter.js +22 -6
- package/dist/lib/secrets/usage-db.d.ts +106 -0
- package/dist/lib/secrets/usage-db.js +236 -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-active.d.ts +5 -1
- package/dist/lib/session/remote-active.js +4 -1
- 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/sqlite.js +28 -1
- 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 +14 -0
- package/dist/lib/state.js +19 -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 +6 -5
- package/dist/lib/versions.d.ts +6 -0
- package/dist/lib/versions.js +6 -4
- package/package.json +2 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project workspace probing — the drift signal behind `projects status --fleet`.
|
|
3
|
+
*
|
|
4
|
+
* Projects are natively multi-device: the same definition (home-relative paths)
|
|
5
|
+
* re-roots on every fleet machine, and the question is whether the project's
|
|
6
|
+
* repos are PRESENT on each box, on which branch, how far ahead/behind their
|
|
7
|
+
* upstream, and whether they carry uncommitted changes. This module is the pure
|
|
8
|
+
* local half: given a set of home-relative paths it probes each one with a
|
|
9
|
+
* handful of read-only git calls. Drift is measured against the LAST-FETCHED
|
|
10
|
+
* upstream (`@{upstream}`) — deliberately no `git fetch`, so a probe is fast
|
|
11
|
+
* and offline-safe. The fleet half (`--fleet`) just runs this probe on every
|
|
12
|
+
* peer via the canonical `remote-agents-json` SSH fan-out.
|
|
13
|
+
*/
|
|
14
|
+
import type { ProjectDef } from './projects.js';
|
|
15
|
+
/** The on-disk state of one workspace repo on one machine. */
|
|
16
|
+
export interface RepoWorkspaceStatus {
|
|
17
|
+
/** The probed path, echoed home-relative (re-roots per machine). */
|
|
18
|
+
path: string;
|
|
19
|
+
/** `.git` exists (a directory, or a FILE for a linked worktree). */
|
|
20
|
+
present: boolean;
|
|
21
|
+
branch?: string;
|
|
22
|
+
/** The configured upstream ref (e.g. `origin/main`); absent → no upstream. */
|
|
23
|
+
upstream?: string;
|
|
24
|
+
/** Commits on HEAD not on the upstream. Undefined without an upstream. */
|
|
25
|
+
ahead?: number;
|
|
26
|
+
/** Commits on the upstream not on HEAD. Undefined without an upstream. */
|
|
27
|
+
behind?: number;
|
|
28
|
+
/** Uncommitted (incl. untracked) paths from `git status --porcelain`. */
|
|
29
|
+
dirty?: number;
|
|
30
|
+
/** ISO 8601 committer date of HEAD. */
|
|
31
|
+
lastCommit?: string;
|
|
32
|
+
/** `.git` exists but git could not read it — never looks silently clean. */
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
/** A probe result tagged with the machine that answered (the fleet view). */
|
|
36
|
+
export interface HostWorkspaceStatus extends RepoWorkspaceStatus {
|
|
37
|
+
host: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Probe one workspace repo. A missing path yields `{present: false}` and no
|
|
41
|
+
* git call is made. On a present repo every signal is best-effort: whatever
|
|
42
|
+
* succeeded is reported, and a repo whose `.git` exists yet every git call
|
|
43
|
+
* failed surfaces as present-with-error rather than silently clean.
|
|
44
|
+
*/
|
|
45
|
+
export declare function probeRepoWorkspace(absPath: string): RepoWorkspaceStatus;
|
|
46
|
+
/** Probe each home-relative path (expanded against the local home), in order. */
|
|
47
|
+
export declare function probeProjectWorkspaces(paths: string[]): RepoWorkspaceStatus[];
|
|
48
|
+
/**
|
|
49
|
+
* The home-relative paths to probe for a project definition: its `root` plus
|
|
50
|
+
* each `repos[].path` (the opt-in for additional repos), deduped. Every target
|
|
51
|
+
* is normalized through the same `toHomeRelative(expandLocalHome(...))` the
|
|
52
|
+
* probe echoes, so a hand-edited def (absolute path under home, trailing
|
|
53
|
+
* slash) matches its probe rows exactly — `writeProjectDef` normalizes on
|
|
54
|
+
* write, but defs are hand-editable YAML and never silently drop a row.
|
|
55
|
+
*/
|
|
56
|
+
export declare function workspaceTargetsForDef(def: ProjectDef): string[];
|
|
57
|
+
/**
|
|
58
|
+
* Parse a peer's `projects probe --json` stdout, tagging each row with the
|
|
59
|
+
* machine that answered. Defensive against version skew / partial output, the
|
|
60
|
+
* same boundary contract as `parseRemoteActive`: non-JSON or a non-array
|
|
61
|
+
* yields `[]`, and rows without a `path`/`present` core are dropped.
|
|
62
|
+
*/
|
|
63
|
+
export declare function parseRemoteProbe(stdout: string, machine: string): HostWorkspaceStatus[];
|
|
64
|
+
/**
|
|
65
|
+
* One workspace's compact state: `✓ clean · main`, `⚠ 12 dirty · ↑3 ↓1 ·
|
|
66
|
+
* feature/x`, `✗ missing`, or `⚠ error: …`. Pure — chalk styling only.
|
|
67
|
+
*/
|
|
68
|
+
export declare function formatWorkspaceLine(s: RepoWorkspaceStatus): string;
|
|
69
|
+
/**
|
|
70
|
+
* The fleet view of one project's workspaces: one content line per probed
|
|
71
|
+
* path (host-sorted `host: state` cells joined by ` · `), labelled with the
|
|
72
|
+
* path when a project probes more than one. Pure — the caller adds the
|
|
73
|
+
* `fleet` row label.
|
|
74
|
+
*/
|
|
75
|
+
export declare function formatFleetWorkspaces(statuses: HostWorkspaceStatus[]): string[];
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project workspace probing — the drift signal behind `projects status --fleet`.
|
|
3
|
+
*
|
|
4
|
+
* Projects are natively multi-device: the same definition (home-relative paths)
|
|
5
|
+
* re-roots on every fleet machine, and the question is whether the project's
|
|
6
|
+
* repos are PRESENT on each box, on which branch, how far ahead/behind their
|
|
7
|
+
* upstream, and whether they carry uncommitted changes. This module is the pure
|
|
8
|
+
* local half: given a set of home-relative paths it probes each one with a
|
|
9
|
+
* handful of read-only git calls. Drift is measured against the LAST-FETCHED
|
|
10
|
+
* upstream (`@{upstream}`) — deliberately no `git fetch`, so a probe is fast
|
|
11
|
+
* and offline-safe. The fleet half (`--fleet`) just runs this probe on every
|
|
12
|
+
* peer via the canonical `remote-agents-json` SSH fan-out.
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import { execFileSync } from 'child_process';
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import { expandLocalHome, toHomeRelative } from './project-root.js';
|
|
19
|
+
/** Per-call git budget. A read-only git call taking >3s is wedged by any
|
|
20
|
+
* definition (NFS stall, index lock) — and the fleet fan-out SIGKILLs the SSH
|
|
21
|
+
* hop at 12s, so a probe must fit inside that budget to avoid a slow peer
|
|
22
|
+
* being misreported as unreachable: 3s × 5 calls leaves headroom even when
|
|
23
|
+
* one repo is genuinely stuck. */
|
|
24
|
+
const GIT_TIMEOUT_MS = 3_000;
|
|
25
|
+
/** One read-only git call against `absPath`; undefined on any failure. */
|
|
26
|
+
function git(absPath, args) {
|
|
27
|
+
try {
|
|
28
|
+
return execFileSync('git', ['-C', absPath, ...args], {
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
timeout: GIT_TIMEOUT_MS,
|
|
31
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
32
|
+
}).trim();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Probe one workspace repo. A missing path yields `{present: false}` and no
|
|
40
|
+
* git call is made. On a present repo every signal is best-effort: whatever
|
|
41
|
+
* succeeded is reported, and a repo whose `.git` exists yet every git call
|
|
42
|
+
* failed surfaces as present-with-error rather than silently clean.
|
|
43
|
+
*/
|
|
44
|
+
export function probeRepoWorkspace(absPath) {
|
|
45
|
+
const status = { path: toHomeRelative(absPath), present: false };
|
|
46
|
+
if (!fs.existsSync(path.join(absPath, '.git')))
|
|
47
|
+
return status;
|
|
48
|
+
status.present = true;
|
|
49
|
+
const branch = git(absPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
50
|
+
const upstream = git(absPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']);
|
|
51
|
+
// `--left-right --count A...B` prints "<left>\t<right>" — left is
|
|
52
|
+
// upstream-only (we are BEHIND by that much), right is HEAD-only (AHEAD).
|
|
53
|
+
const counts = upstream !== undefined
|
|
54
|
+
? git(absPath, ['rev-list', '--left-right', '--count', '@{upstream}...HEAD'])
|
|
55
|
+
: undefined;
|
|
56
|
+
const dirtyOut = git(absPath, ['status', '--porcelain']);
|
|
57
|
+
const lastCommit = git(absPath, ['log', '-1', '--format=%cI']);
|
|
58
|
+
if (branch === undefined && dirtyOut === undefined && lastCommit === undefined) {
|
|
59
|
+
status.error = '.git exists but git could not read this repo';
|
|
60
|
+
return status;
|
|
61
|
+
}
|
|
62
|
+
if (branch !== undefined)
|
|
63
|
+
status.branch = branch;
|
|
64
|
+
if (upstream !== undefined)
|
|
65
|
+
status.upstream = upstream;
|
|
66
|
+
if (counts !== undefined) {
|
|
67
|
+
const [behind, ahead] = counts.split(/\s+/).map(Number);
|
|
68
|
+
if (Number.isFinite(behind) && Number.isFinite(ahead)) {
|
|
69
|
+
status.behind = behind;
|
|
70
|
+
status.ahead = ahead;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (dirtyOut !== undefined)
|
|
74
|
+
status.dirty = dirtyOut === '' ? 0 : dirtyOut.split('\n').length;
|
|
75
|
+
if (lastCommit !== undefined)
|
|
76
|
+
status.lastCommit = lastCommit;
|
|
77
|
+
return status;
|
|
78
|
+
}
|
|
79
|
+
/** Probe each home-relative path (expanded against the local home), in order. */
|
|
80
|
+
export function probeProjectWorkspaces(paths) {
|
|
81
|
+
return paths.map((p) => probeRepoWorkspace(expandLocalHome(p)));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The home-relative paths to probe for a project definition: its `root` plus
|
|
85
|
+
* each `repos[].path` (the opt-in for additional repos), deduped. Every target
|
|
86
|
+
* is normalized through the same `toHomeRelative(expandLocalHome(...))` the
|
|
87
|
+
* probe echoes, so a hand-edited def (absolute path under home, trailing
|
|
88
|
+
* slash) matches its probe rows exactly — `writeProjectDef` normalizes on
|
|
89
|
+
* write, but defs are hand-editable YAML and never silently drop a row.
|
|
90
|
+
*/
|
|
91
|
+
export function workspaceTargetsForDef(def) {
|
|
92
|
+
const targets = [def.root, ...(def.repos ?? []).map((r) => r.path)]
|
|
93
|
+
.filter((p) => typeof p === 'string' && p.length > 0)
|
|
94
|
+
.map((p) => toHomeRelative(expandLocalHome(p)));
|
|
95
|
+
return [...new Set(targets)];
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse a peer's `projects probe --json` stdout, tagging each row with the
|
|
99
|
+
* machine that answered. Defensive against version skew / partial output, the
|
|
100
|
+
* same boundary contract as `parseRemoteActive`: non-JSON or a non-array
|
|
101
|
+
* yields `[]`, and rows without a `path`/`present` core are dropped.
|
|
102
|
+
*/
|
|
103
|
+
export function parseRemoteProbe(stdout, machine) {
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = JSON.parse(stdout);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(parsed))
|
|
112
|
+
return [];
|
|
113
|
+
return parsed.flatMap((x) => {
|
|
114
|
+
if (x && typeof x === 'object' && !Array.isArray(x)) {
|
|
115
|
+
const o = x;
|
|
116
|
+
if (typeof o.path === 'string' && typeof o.present === 'boolean') {
|
|
117
|
+
return [{ ...o, host: machine }];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return [];
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* One workspace's compact state: `✓ clean · main`, `⚠ 12 dirty · ↑3 ↓1 ·
|
|
125
|
+
* feature/x`, `✗ missing`, or `⚠ error: …`. Pure — chalk styling only.
|
|
126
|
+
*/
|
|
127
|
+
export function formatWorkspaceLine(s) {
|
|
128
|
+
if (!s.present)
|
|
129
|
+
return chalk.red('✗ missing');
|
|
130
|
+
if (s.error)
|
|
131
|
+
return chalk.yellow(`⚠ error: ${s.error}`);
|
|
132
|
+
const parts = [];
|
|
133
|
+
if (s.dirty !== undefined && s.dirty > 0)
|
|
134
|
+
parts.push(`${s.dirty} dirty`);
|
|
135
|
+
const drift = [
|
|
136
|
+
s.ahead !== undefined && s.ahead > 0 ? `↑${s.ahead}` : '',
|
|
137
|
+
s.behind !== undefined && s.behind > 0 ? `↓${s.behind}` : '',
|
|
138
|
+
].filter(Boolean).join(' ');
|
|
139
|
+
if (drift)
|
|
140
|
+
parts.push(drift);
|
|
141
|
+
const head = parts.length > 0 ? chalk.yellow(`⚠ ${parts.join(' · ')}`) : chalk.green('✓ clean');
|
|
142
|
+
return s.branch ? `${head} ${chalk.dim('·')} ${s.branch}` : head;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The fleet view of one project's workspaces: one content line per probed
|
|
146
|
+
* path (host-sorted `host: state` cells joined by ` · `), labelled with the
|
|
147
|
+
* path when a project probes more than one. Pure — the caller adds the
|
|
148
|
+
* `fleet` row label.
|
|
149
|
+
*/
|
|
150
|
+
export function formatFleetWorkspaces(statuses) {
|
|
151
|
+
const paths = [...new Set(statuses.map((s) => s.path))];
|
|
152
|
+
const multi = paths.length > 1;
|
|
153
|
+
return paths.map((p) => {
|
|
154
|
+
const rows = statuses
|
|
155
|
+
.filter((s) => s.path === p)
|
|
156
|
+
.sort((a, b) => a.host.localeCompare(b.host));
|
|
157
|
+
const body = rows.map((r) => `${chalk.cyan(r.host)}: ${formatWorkspaceLine(r)}`).join(chalk.dim(' · '));
|
|
158
|
+
return multi ? `${chalk.dim(`${p} · `)}${body}` : body;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -5,3 +5,11 @@ export interface ProjectResourceSyncResult {
|
|
|
5
5
|
}
|
|
6
6
|
export declare function projectAgentRoot(projectRoot: string, agent: AgentId): string;
|
|
7
7
|
export declare function syncProjectResourcesToAgent(agent: AgentId, version: string, projectAgentsDir: string): ProjectResourceSyncResult;
|
|
8
|
+
/**
|
|
9
|
+
* One human line for the files a project sync left alone because you already
|
|
10
|
+
* wrote them. This is the normal steady state — every sync of a project whose
|
|
11
|
+
* `.claude/commands/` you hand-authored hits it — so it is a single grouped
|
|
12
|
+
* line, not one wrapped warning per file, and it says "yours" rather than the
|
|
13
|
+
* internal "user-owned". Returns null when nothing was skipped.
|
|
14
|
+
*/
|
|
15
|
+
export declare function formatKeptProjectResources(skipped: string[]): string | null;
|
|
@@ -127,9 +127,37 @@ function record(kind, name, relPaths, result, manifestPaths) {
|
|
|
127
127
|
manifestPaths.add(toPosixRel(rel));
|
|
128
128
|
}
|
|
129
129
|
function skip(dest, projectRoot, result) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
result.skipped.push(path.relative(projectRoot, dest));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* One human line for the files a project sync left alone because you already
|
|
134
|
+
* wrote them. This is the normal steady state — every sync of a project whose
|
|
135
|
+
* `.claude/commands/` you hand-authored hits it — so it is a single grouped
|
|
136
|
+
* line, not one wrapped warning per file, and it says "yours" rather than the
|
|
137
|
+
* internal "user-owned". Returns null when nothing was skipped.
|
|
138
|
+
*/
|
|
139
|
+
export function formatKeptProjectResources(skipped) {
|
|
140
|
+
if (skipped.length === 0)
|
|
141
|
+
return null;
|
|
142
|
+
const rels = [...skipped].sort((a, b) => a.localeCompare(b)).map(toPosixRel);
|
|
143
|
+
if (rels.length === 1)
|
|
144
|
+
return `Kept your existing ${rels[0]}`;
|
|
145
|
+
const byDir = new Map();
|
|
146
|
+
for (const rel of rels) {
|
|
147
|
+
const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '.';
|
|
148
|
+
const names = byDir.get(dir) ?? [];
|
|
149
|
+
names.push(rel.slice(rel.lastIndexOf('/') + 1));
|
|
150
|
+
byDir.set(dir, names);
|
|
151
|
+
}
|
|
152
|
+
if (byDir.size === 1) {
|
|
153
|
+
const [dir, names] = [...byDir.entries()][0];
|
|
154
|
+
const PREVIEW = 3;
|
|
155
|
+
const preview = names.slice(0, PREVIEW).join(', ');
|
|
156
|
+
const more = names.length > PREVIEW ? `, +${names.length - PREVIEW} more` : '';
|
|
157
|
+
return `Kept ${rels.length} of your own files in ${dir}: ${preview}${more}`;
|
|
158
|
+
}
|
|
159
|
+
const dirs = [...byDir.entries()].map(([dir, names]) => `${dir} (${names.length})`).join(', ');
|
|
160
|
+
return `Kept ${rels.length} of your own files in ${dirs}`;
|
|
133
161
|
}
|
|
134
162
|
function syncProjectCommands(agent, version, projectAgentsDir, agentRoot, result, manifestPaths) {
|
|
135
163
|
const cfg = AGENTS[agent];
|
package/dist/lib/project-root.js
CHANGED
|
@@ -17,6 +17,7 @@ import * as fs from 'fs';
|
|
|
17
17
|
import { readMeta, updateMeta } from './state.js';
|
|
18
18
|
import { getMainRepoRoot } from './git.js';
|
|
19
19
|
import { toPosix } from './platform/index.js';
|
|
20
|
+
import { loadProjectDef, resolveDefinedProjectPath } from './projects.js';
|
|
20
21
|
const HOME = process.env.HOME ?? os.homedir();
|
|
21
22
|
/** Rewrite an absolute path under the local home to a `~/`-relative string; pass others through. */
|
|
22
23
|
export function toHomeRelative(abs) {
|
|
@@ -124,6 +125,21 @@ export function buildProjectPath(root, ref, forRemote) {
|
|
|
124
125
|
* absolute local path and verifies it exists (so a mistyped slug fails loudly).
|
|
125
126
|
*/
|
|
126
127
|
export async function resolveProjectRef(ref, opts) {
|
|
128
|
+
const { slug, worktree } = parseProjectRef(ref);
|
|
129
|
+
if (!slug)
|
|
130
|
+
throw new Error(`Invalid --project value: "${ref}"`);
|
|
131
|
+
// Definition first: a named project in ~/.agents/projects/<slug>.yaml overrides
|
|
132
|
+
// the <root>/<slug> convention. Absent (or root-less) → fall through unchanged.
|
|
133
|
+
const def = loadProjectDef(slug);
|
|
134
|
+
if (def) {
|
|
135
|
+
const fromDef = resolveDefinedProjectPath(def, worktree, opts.forRemote);
|
|
136
|
+
if (fromDef) {
|
|
137
|
+
if (!opts.forRemote && !fs.existsSync(fromDef)) {
|
|
138
|
+
throw new Error(`Project path not found: ${fromDef} (defined in ${slug}.yaml)`);
|
|
139
|
+
}
|
|
140
|
+
return fromDef;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
127
143
|
const cwd = opts.cwd ?? process.cwd();
|
|
128
144
|
const root = await ensureProjectRoot(cwd);
|
|
129
145
|
const resolved = buildProjectPath(root, ref, opts.forRemote);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-level progress rollup — the headline of the projects subsystem.
|
|
3
|
+
*
|
|
4
|
+
* At 50–100 agents the per-agent activity line is noise; what matters is the
|
|
5
|
+
* PROJECT. This aggregates the signals already carried per session (status,
|
|
6
|
+
* plan progress, open PRs, tickets, worktrees) into one row per project, keyed
|
|
7
|
+
* by matching each session's cwd to a defined project root (`projectNameForCwd`).
|
|
8
|
+
* The session set is whatever the caller passes (today `getActiveSessions()` —
|
|
9
|
+
* this machine's live view, matched by local-home cwd; a fleet-wide fan-out is a
|
|
10
|
+
* deferred follow-up). Pure over an `ActiveSession[]` so the aggregation is
|
|
11
|
+
* unit-testable; the merged-PR signal IS repo-global (harvested via `gh`) and the
|
|
12
|
+
* artifact signal is local, both added in `enrichProjectSignals`.
|
|
13
|
+
*/
|
|
14
|
+
import type { ActiveSession, ActiveStatus } from './session/active.js';
|
|
15
|
+
import { type ProjectDef } from './projects.js';
|
|
16
|
+
/** One live agent on a project — the WHO behind the byStatus count. */
|
|
17
|
+
export interface ProjectMember {
|
|
18
|
+
/** Harness name (claude / codex / …), from the session's `kind`. */
|
|
19
|
+
agent: string;
|
|
20
|
+
/** Lifecycle status (running / idle / …). */
|
|
21
|
+
status: string;
|
|
22
|
+
/** Tracker ticket the session is tied to, when any. */
|
|
23
|
+
ticket?: string;
|
|
24
|
+
/** Machine the session runs on (provenance host / fleet peer), when known. */
|
|
25
|
+
host?: string;
|
|
26
|
+
}
|
|
27
|
+
/** One project's live session rollup. */
|
|
28
|
+
export interface ProjectSessionRollup {
|
|
29
|
+
name: string;
|
|
30
|
+
/** Total sessions whose cwd is inside this project. */
|
|
31
|
+
agents: number;
|
|
32
|
+
/** Count per lifecycle status. */
|
|
33
|
+
byStatus: Partial<Record<ActiveStatus, number>>;
|
|
34
|
+
/** Which agents are on the project (one per matched session). */
|
|
35
|
+
members: ProjectMember[];
|
|
36
|
+
/** Summed checklist progress across this project's sessions. */
|
|
37
|
+
plan: {
|
|
38
|
+
done: number;
|
|
39
|
+
total: number;
|
|
40
|
+
};
|
|
41
|
+
/** Distinct open PRs held by this project's sessions. */
|
|
42
|
+
openPrs: {
|
|
43
|
+
url: string;
|
|
44
|
+
number?: number;
|
|
45
|
+
}[];
|
|
46
|
+
/** Distinct tickets worked or created by this project's sessions. */
|
|
47
|
+
tickets: string[];
|
|
48
|
+
/** Sessions running inside a worktree. */
|
|
49
|
+
worktrees: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Roll active sessions up by project. Returns a map keyed by project name,
|
|
53
|
+
* containing only projects with at least one matched session — callers merge
|
|
54
|
+
* with the full definition list to show zero-agent projects.
|
|
55
|
+
*/
|
|
56
|
+
export declare function rollupSessionsByProject(defs: ProjectDef[], sessions: ActiveSession[]): Map<string, ProjectSessionRollup>;
|
|
57
|
+
/** Plan completion percentage (0–100), or undefined when nothing is tracked. */
|
|
58
|
+
export declare function planPct(plan: {
|
|
59
|
+
done: number;
|
|
60
|
+
total: number;
|
|
61
|
+
}): number | undefined;
|
|
62
|
+
/** Sort members for the card: running first, then idle, then the rest; agent name asc within a state. */
|
|
63
|
+
export declare function sortProjectMembers(members: ProjectMember[]): ProjectMember[];
|
|
64
|
+
/** Cap for the members line before it collapses to `+N more`. */
|
|
65
|
+
export declare const MEMBERS_LINE_LIMIT = 6;
|
|
66
|
+
/**
|
|
67
|
+
* The `agents` line under `live`: one cell per DISTINCT member state —
|
|
68
|
+
* `claude · running · RUSH-2107 @zion` — with identical cells collapsed to a
|
|
69
|
+
* `×N` count (35 same-harness sessions in one state are one fact, not six
|
|
70
|
+
* truncated duplicates), capped at {@link MEMBERS_LINE_LIMIT} cells with a
|
|
71
|
+
* `+N more` tail counting members, not cells. Pure (chalk styling only); the
|
|
72
|
+
* caller adds the label.
|
|
73
|
+
*/
|
|
74
|
+
export declare function formatProjectMembers(members: ProjectMember[], limit?: number): string;
|
|
75
|
+
/** Harvested signals not on the session list: repo-global merged PRs + releases, local artifacts, in a time window. */
|
|
76
|
+
export interface ProjectRemoteSignals {
|
|
77
|
+
windowDays: number;
|
|
78
|
+
/** PRs merged into the primary repo within the window (via `gh`). */
|
|
79
|
+
mergedPrs: number;
|
|
80
|
+
/** Artifacts agents produced within the window (activity.created milestones). */
|
|
81
|
+
artifacts: number;
|
|
82
|
+
/** Basename of the most recent artifact, when any. */
|
|
83
|
+
lastArtifact?: string;
|
|
84
|
+
/** Latest release of the PRIMARY repo (via `gh release list`), when any. */
|
|
85
|
+
latestRelease?: {
|
|
86
|
+
tag: string;
|
|
87
|
+
publishedAt: string;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Harvest the signals that don't live on the active-session list: recently
|
|
92
|
+
* merged PRs (from GitHub via `gh`) and artifacts agents produced (from the
|
|
93
|
+
* local activity-milestone log, matched to the project by cwd). Best-effort —
|
|
94
|
+
* a missing `gh`, no auth, or no repo degrades to zero rather than throwing, so
|
|
95
|
+
* `projects status` still renders. `nowMs` is injected for testability.
|
|
96
|
+
*/
|
|
97
|
+
export declare function enrichProjectSignals(def: ProjectDef, windowDays: number, nowMs: number, opts?: {
|
|
98
|
+
activityRoot?: string;
|
|
99
|
+
skipRemote?: boolean;
|
|
100
|
+
}): Promise<ProjectRemoteSignals>;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-level progress rollup — the headline of the projects subsystem.
|
|
3
|
+
*
|
|
4
|
+
* At 50–100 agents the per-agent activity line is noise; what matters is the
|
|
5
|
+
* PROJECT. This aggregates the signals already carried per session (status,
|
|
6
|
+
* plan progress, open PRs, tickets, worktrees) into one row per project, keyed
|
|
7
|
+
* by matching each session's cwd to a defined project root (`projectNameForCwd`).
|
|
8
|
+
* The session set is whatever the caller passes (today `getActiveSessions()` —
|
|
9
|
+
* this machine's live view, matched by local-home cwd; a fleet-wide fan-out is a
|
|
10
|
+
* deferred follow-up). Pure over an `ActiveSession[]` so the aggregation is
|
|
11
|
+
* unit-testable; the merged-PR signal IS repo-global (harvested via `gh`) and the
|
|
12
|
+
* artifact signal is local, both added in `enrichProjectSignals`.
|
|
13
|
+
*/
|
|
14
|
+
import { execFile } from 'child_process';
|
|
15
|
+
import { promisify } from 'util';
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import { projectNameForCwd } from './projects.js';
|
|
18
|
+
import { readRecentActivity } from './activity.js';
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
function blank(name) {
|
|
21
|
+
return {
|
|
22
|
+
name,
|
|
23
|
+
agents: 0,
|
|
24
|
+
byStatus: {},
|
|
25
|
+
members: [],
|
|
26
|
+
plan: { done: 0, total: 0 },
|
|
27
|
+
openPrs: [],
|
|
28
|
+
tickets: [],
|
|
29
|
+
worktrees: 0,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Roll active sessions up by project. Returns a map keyed by project name,
|
|
34
|
+
* containing only projects with at least one matched session — callers merge
|
|
35
|
+
* with the full definition list to show zero-agent projects.
|
|
36
|
+
*/
|
|
37
|
+
export function rollupSessionsByProject(defs, sessions) {
|
|
38
|
+
const map = new Map();
|
|
39
|
+
const prSeen = new Map();
|
|
40
|
+
const ticketSeen = new Map();
|
|
41
|
+
for (const s of sessions) {
|
|
42
|
+
const name = projectNameForCwd(s.cwd, defs);
|
|
43
|
+
if (!name)
|
|
44
|
+
continue;
|
|
45
|
+
let r = map.get(name);
|
|
46
|
+
if (!r) {
|
|
47
|
+
r = blank(name);
|
|
48
|
+
map.set(name, r);
|
|
49
|
+
prSeen.set(name, new Set());
|
|
50
|
+
ticketSeen.set(name, new Set());
|
|
51
|
+
}
|
|
52
|
+
r.agents++;
|
|
53
|
+
r.byStatus[s.status] = (r.byStatus[s.status] ?? 0) + 1;
|
|
54
|
+
const member = { agent: s.kind, status: s.status };
|
|
55
|
+
if (s.ticket?.id)
|
|
56
|
+
member.ticket = s.ticket.id;
|
|
57
|
+
if (s.machine)
|
|
58
|
+
member.host = s.machine;
|
|
59
|
+
r.members.push(member);
|
|
60
|
+
if (s.todos) {
|
|
61
|
+
r.plan.done += s.todos.done;
|
|
62
|
+
r.plan.total += s.todos.total;
|
|
63
|
+
}
|
|
64
|
+
if (s.pr?.url && !prSeen.get(name).has(s.pr.url)) {
|
|
65
|
+
prSeen.get(name).add(s.pr.url);
|
|
66
|
+
r.openPrs.push({ url: s.pr.url, number: s.pr.number });
|
|
67
|
+
}
|
|
68
|
+
const tset = ticketSeen.get(name);
|
|
69
|
+
for (const t of [s.ticket?.id, ...(s.createdTickets ?? [])]) {
|
|
70
|
+
if (t && !tset.has(t)) {
|
|
71
|
+
tset.add(t);
|
|
72
|
+
r.tickets.push(t);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (s.worktree)
|
|
76
|
+
r.worktrees++;
|
|
77
|
+
}
|
|
78
|
+
return map;
|
|
79
|
+
}
|
|
80
|
+
/** Plan completion percentage (0–100), or undefined when nothing is tracked. */
|
|
81
|
+
export function planPct(plan) {
|
|
82
|
+
if (plan.total <= 0)
|
|
83
|
+
return undefined;
|
|
84
|
+
return Math.round((plan.done / plan.total) * 100);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Display order for the members line: the states a human scans for first
|
|
88
|
+
* (running, then idle, then need-input, then queued), everything else after,
|
|
89
|
+
* status name then agent name ascending within a state.
|
|
90
|
+
*/
|
|
91
|
+
const MEMBER_STATUS_RANK = { running: 0, idle: 1, input_required: 2, queued: 3 };
|
|
92
|
+
/** Sort members for the card: running first, then idle, then the rest; agent name asc within a state. */
|
|
93
|
+
export function sortProjectMembers(members) {
|
|
94
|
+
return [...members].sort((a, b) => {
|
|
95
|
+
const ra = MEMBER_STATUS_RANK[a.status] ?? 4;
|
|
96
|
+
const rb = MEMBER_STATUS_RANK[b.status] ?? 4;
|
|
97
|
+
if (ra !== rb)
|
|
98
|
+
return ra - rb;
|
|
99
|
+
if (ra === 4 && a.status !== b.status)
|
|
100
|
+
return a.status.localeCompare(b.status);
|
|
101
|
+
return a.agent.localeCompare(b.agent);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** Cap for the members line before it collapses to `+N more`. */
|
|
105
|
+
export const MEMBERS_LINE_LIMIT = 6;
|
|
106
|
+
/**
|
|
107
|
+
* The `agents` line under `live`: one cell per DISTINCT member state —
|
|
108
|
+
* `claude · running · RUSH-2107 @zion` — with identical cells collapsed to a
|
|
109
|
+
* `×N` count (35 same-harness sessions in one state are one fact, not six
|
|
110
|
+
* truncated duplicates), capped at {@link MEMBERS_LINE_LIMIT} cells with a
|
|
111
|
+
* `+N more` tail counting members, not cells. Pure (chalk styling only); the
|
|
112
|
+
* caller adds the label.
|
|
113
|
+
*/
|
|
114
|
+
export function formatProjectMembers(members, limit = MEMBERS_LINE_LIMIT) {
|
|
115
|
+
if (members.length === 0)
|
|
116
|
+
return '';
|
|
117
|
+
// Collapse identical cells — 35 same-harness sessions in the same state are
|
|
118
|
+
// one fact (`claude · running ×16`), not six truncated duplicates.
|
|
119
|
+
const counts = new Map();
|
|
120
|
+
for (const m of sortProjectMembers(members)) {
|
|
121
|
+
const parts = [m.agent, m.status];
|
|
122
|
+
if (m.ticket)
|
|
123
|
+
parts.push(m.ticket);
|
|
124
|
+
const cell = parts.join(' · ') + (m.host ? ` @${m.host}` : '');
|
|
125
|
+
const key = cell.toLowerCase();
|
|
126
|
+
const entry = counts.get(key);
|
|
127
|
+
if (entry)
|
|
128
|
+
entry.n++;
|
|
129
|
+
else
|
|
130
|
+
counts.set(key, { cell, n: 1 });
|
|
131
|
+
}
|
|
132
|
+
const entries = [...counts.values()];
|
|
133
|
+
const shown = entries.slice(0, Math.max(1, limit));
|
|
134
|
+
const shownMembers = shown.reduce((acc, e) => acc + e.n, 0);
|
|
135
|
+
const more = members.length - shownMembers;
|
|
136
|
+
const cells = shown.map(({ cell, n }) => (n > 1 ? `${cell} ×${n}` : cell));
|
|
137
|
+
return cells.join(chalk.dim(' · ')) + (more > 0 ? chalk.dim(` · +${more} more`) : '');
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Harvest the signals that don't live on the active-session list: recently
|
|
141
|
+
* merged PRs (from GitHub via `gh`) and artifacts agents produced (from the
|
|
142
|
+
* local activity-milestone log, matched to the project by cwd). Best-effort —
|
|
143
|
+
* a missing `gh`, no auth, or no repo degrades to zero rather than throwing, so
|
|
144
|
+
* `projects status` still renders. `nowMs` is injected for testability.
|
|
145
|
+
*/
|
|
146
|
+
export async function enrichProjectSignals(def, windowDays, nowMs, opts = {}) {
|
|
147
|
+
const sinceMs = nowMs - windowDays * 86_400_000;
|
|
148
|
+
const out = { windowDays, mergedPrs: 0, artifacts: 0 };
|
|
149
|
+
try {
|
|
150
|
+
const evs = readRecentActivity({ events: ['artifact.created'], sinceMs, root: opts.activityRoot });
|
|
151
|
+
const mine = evs.filter((e) => projectNameForCwd(e.cwd, [def]) === def.name);
|
|
152
|
+
out.artifacts = mine.length;
|
|
153
|
+
if (mine.length && typeof mine[0].detail === 'string')
|
|
154
|
+
out.lastArtifact = mine[0].detail;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
/* activity log unreadable — best-effort */
|
|
158
|
+
}
|
|
159
|
+
if (def.repo && !opts.skipRemote) {
|
|
160
|
+
try {
|
|
161
|
+
const { stdout } = await execFileAsync('gh', ['pr', 'list', '--repo', def.repo, '--state', 'merged', '--json', 'number,mergedAt', '--limit', '100'], { timeout: 8000, encoding: 'utf8' });
|
|
162
|
+
const rows = JSON.parse(stdout);
|
|
163
|
+
out.mergedPrs = rows.filter((r) => r.mergedAt && Date.parse(r.mergedAt) >= sinceMs).length;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
/* gh missing / unauthenticated / repo not found — skip this signal */
|
|
167
|
+
}
|
|
168
|
+
// Latest release of the PRIMARY repo only (repos[] is deliberately not
|
|
169
|
+
// scanned — one release line per card). Same best-effort degradation.
|
|
170
|
+
try {
|
|
171
|
+
const { stdout } = await execFileAsync('gh', ['release', 'list', '-R', def.repo, '-L', '1', '--json', 'tagName,publishedAt'], { timeout: 8000, encoding: 'utf8' });
|
|
172
|
+
const rows = JSON.parse(stdout);
|
|
173
|
+
const first = rows[0];
|
|
174
|
+
if (first?.tagName)
|
|
175
|
+
out.latestRelease = { tag: first.tagName, publishedAt: first.publishedAt ?? '' };
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* gh missing / unauthenticated / repo has no releases — skip this signal */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|