@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
|
@@ -80,6 +80,20 @@ export interface DeviceProfile {
|
|
|
80
80
|
export declare function deviceRole(d: DeviceProfile): DeviceRole;
|
|
81
81
|
/** True for a control-only device (a cockpit) that must never be dialed/scheduled. */
|
|
82
82
|
export declare function isControlDevice(d: DeviceProfile): boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Whether a fan-out should dial this device, honouring the preference stated on
|
|
85
|
+
* {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
|
|
86
|
+
* {@link DeviceTailscale.online} snapshot.
|
|
87
|
+
*
|
|
88
|
+
* Reading only `tailscale.online` is wrong in both directions, and both were
|
|
89
|
+
* live on a real fleet. A `via:"manual"` device never gets a tailscale peer
|
|
90
|
+
* entry at all, so its `online` is permanently `undefined` and a strict
|
|
91
|
+
* `=== true` test skipped it forever — every session on that box was invisible
|
|
92
|
+
* to the cross-fleet sweep. Conversely a box that has since gone to sleep keeps
|
|
93
|
+
* a stale `online:true` and gets dialed, burning a full ConnectTimeout and
|
|
94
|
+
* reporting a false "unreachable" that callers treat as doubt.
|
|
95
|
+
*/
|
|
96
|
+
export declare function isDialableDevice(d: DeviceProfile): boolean;
|
|
83
97
|
/** Map of device name to profile. */
|
|
84
98
|
export type DeviceRegistry = Record<string, DeviceProfile>;
|
|
85
99
|
/** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
|
|
@@ -27,6 +27,43 @@ export function deviceRole(d) {
|
|
|
27
27
|
export function isControlDevice(d) {
|
|
28
28
|
return deviceRole(d) === 'control';
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether a fan-out should dial this device, honouring the preference stated on
|
|
32
|
+
* {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
|
|
33
|
+
* {@link DeviceTailscale.online} snapshot.
|
|
34
|
+
*
|
|
35
|
+
* Reading only `tailscale.online` is wrong in both directions, and both were
|
|
36
|
+
* live on a real fleet. A `via:"manual"` device never gets a tailscale peer
|
|
37
|
+
* entry at all, so its `online` is permanently `undefined` and a strict
|
|
38
|
+
* `=== true` test skipped it forever — every session on that box was invisible
|
|
39
|
+
* to the cross-fleet sweep. Conversely a box that has since gone to sleep keeps
|
|
40
|
+
* a stale `online:true` and gets dialed, burning a full ConnectTimeout and
|
|
41
|
+
* reporting a false "unreachable" that callers treat as doubt.
|
|
42
|
+
*/
|
|
43
|
+
export function isDialableDevice(d) {
|
|
44
|
+
// Union, deliberately: either signal saying "go" is enough. A probe may only
|
|
45
|
+
// ADD a peer to the sweep, never remove one.
|
|
46
|
+
//
|
|
47
|
+
// The probe is not trustworthy enough to exclude on. It runs with a short SSH
|
|
48
|
+
// budget, so on a congested tailnet it returns false negatives — observed
|
|
49
|
+
// marking the LOCAL machine unreachable, and flipping a live worker box from
|
|
50
|
+
// reachable to unreachable nine minutes apart. Letting that shrink the sweep
|
|
51
|
+
// would hide sessions on healthy boxes, a worse failure than the one below.
|
|
52
|
+
//
|
|
53
|
+
// The snapshot alone is not enough either: a device registered with
|
|
54
|
+
// `address.via: "manual"` never gets a tailscale peer entry, so `online`
|
|
55
|
+
// stays undefined and a strict `=== true` test skipped it forever, making
|
|
56
|
+
// every session on that box unresolvable from any other machine.
|
|
57
|
+
//
|
|
58
|
+
// So: no tailscale block at all is unknown-not-offline (the rule `ssh.ts`
|
|
59
|
+
// renderDeviceTable and Factory's `isDeviceOnline` already use, so the picker
|
|
60
|
+
// and the sweep agree on who exists), and a positive probe rescues a device
|
|
61
|
+
// whose snapshot says offline. The cost of dialing a box that is actually
|
|
62
|
+
// asleep is one ConnectTimeout — the pre-existing behaviour, not a regression.
|
|
63
|
+
if (d.reachability?.reachable)
|
|
64
|
+
return true;
|
|
65
|
+
return !d.tailscale || d.tailscale.online === true;
|
|
66
|
+
}
|
|
30
67
|
function registryPath() {
|
|
31
68
|
return getDevicesRegistryPath();
|
|
32
69
|
}
|
package/dist/lib/feed-post.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
import * as fs from 'fs';
|
|
16
16
|
import * as path from 'path';
|
|
17
17
|
import { spawnSync } from 'child_process';
|
|
18
|
-
import { appendActivityEvent,
|
|
18
|
+
import { appendActivityEvent, } from './activity.js';
|
|
19
|
+
import { resolveProjectNameForCwd, listProjectDefs } from './projects.js';
|
|
19
20
|
import { getHistoryDir } from './state.js';
|
|
20
21
|
import { machineId } from './machine-id.js';
|
|
21
22
|
import { isValidMailboxId } from './mailbox.js';
|
|
@@ -263,7 +264,12 @@ export function postFeedStatus(input) {
|
|
|
263
264
|
+ '(AGENT_SESSION_ID / AGENTS_MAILBOX_DIR / pid registry), or pass --session <id>.');
|
|
264
265
|
}
|
|
265
266
|
const ts = input.ts ?? new Date().toISOString();
|
|
266
|
-
|
|
267
|
+
// The post is written where the agent runs, so the cwd is a local path and
|
|
268
|
+
// gets full canonical resolution — a defined project's name wins (a post from
|
|
269
|
+
// any repo of a multi-repo project files under that project), else the repo
|
|
270
|
+
// key, matching how the timeline groups everything else. listProjectDefs is
|
|
271
|
+
// fail-open, so this costs one small readdir + YAML parse per post.
|
|
272
|
+
const project = resolveProjectNameForCwd(identity.cwd, listProjectDefs());
|
|
267
273
|
const attachments = buildAttachments(input.attach, {
|
|
268
274
|
copyRoot: input.attachmentsRoot ?? path.join(getHistoryDir(), 'attachments'),
|
|
269
275
|
sessionId: identity.sessionId,
|
|
@@ -88,6 +88,7 @@ export const RUN_OPTION_FORWARDING = {
|
|
|
88
88
|
autoSecrets: 'forward', // workflow frontmatter secrets resolve on the REMOTE keychain
|
|
89
89
|
emitSessionId: 'forward', // remote prints its session id as a stdout sentinel the launcher captures (session-marker.ts)
|
|
90
90
|
// rejected — cannot cross the SSH boundary; fail loud, never degrade
|
|
91
|
+
terminal: 'reject', // opens a tab on THIS machine's desktop; a remote tab is a different request
|
|
91
92
|
secrets: 'reject',
|
|
92
93
|
secretsKeys: 'reject',
|
|
93
94
|
allowExpired: 'reject',
|
|
@@ -126,6 +127,9 @@ export const RUN_OPTION_FORWARDING = {
|
|
|
126
127
|
};
|
|
127
128
|
/** Actionable messages for value-aware rejections, keyed by attribute name. */
|
|
128
129
|
export const RUN_OPTION_REJECT_MESSAGES = {
|
|
130
|
+
terminal: '--terminal opens a tab on THIS machine; it cannot be combined with --host. ' +
|
|
131
|
+
'Drop --terminal to dispatch to the host, or drop --host to open the tab here. ' +
|
|
132
|
+
'To watch a remote run in a terminal, dispatch it and follow with `agents sessions focus <id>`.',
|
|
129
133
|
secrets: '--secrets cannot cross the SSH boundary — Keychain values are never sent to a host implicitly. ' +
|
|
130
134
|
'Provision the bundle on the host first (agents secrets export --host <name>), then run without --secrets; ' +
|
|
131
135
|
'workflow frontmatter secrets resolve from the HOST\'s own keychain.',
|
|
Binary file
|
|
@@ -30,7 +30,16 @@ export declare function menubarServiceInstalled(): boolean;
|
|
|
30
30
|
/** True when the bundle carries a signature the kernel will accept at launch. */
|
|
31
31
|
export declare function codesignVerifies(appPath: string): boolean;
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
33
|
+
* True when Gatekeeper will let the bundle execute on this machine.
|
|
34
|
+
* A Developer-ID-signed but un-notarized app is rejected by `spctl --assess`,
|
|
35
|
+
* which macOS surfaces as "the app is damaged" and can crash AppKit during
|
|
36
|
+
* launch. This is separate from `codesign --verify`: a signature can be valid
|
|
37
|
+
* while Gatekeeper still refuses to run it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function gatekeeperAssesses(appPath: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Guarantee the installed bundle has a signature Gatekeeper will accept on THIS
|
|
42
|
+
* machine.
|
|
34
43
|
*
|
|
35
44
|
* npm's pack/extract strips the ad-hoc/linker signature the release baked into
|
|
36
45
|
* the helper, leaving `code object is not signed at all`. On macOS 26+ the
|
|
@@ -42,9 +51,10 @@ export declare function codesignVerifies(appPath: string): boolean;
|
|
|
42
51
|
* matching cdhash, which the kernel accepts.
|
|
43
52
|
*
|
|
44
53
|
* A Developer-ID-signed helper survives npm untouched — its embedded signature
|
|
45
|
-
* still verifies —
|
|
46
|
-
*
|
|
47
|
-
*
|
|
54
|
+
* still verifies — but if the release was not notarized, Gatekeeper rejects it.
|
|
55
|
+
* In that case we strip the quarantine xattr and re-sign ad-hoc so the helper
|
|
56
|
+
* can launch locally. The stable fix is to notarize the release build; this
|
|
57
|
+
* fallback just prevents a crash-loop while the user is on an un-notarized cut.
|
|
48
58
|
*/
|
|
49
59
|
export declare function ensureValidSignature(appPath: string): boolean;
|
|
50
60
|
/**
|
|
@@ -177,7 +177,19 @@ export function codesignVerifies(appPath) {
|
|
|
177
177
|
return r.status === 0;
|
|
178
178
|
}
|
|
179
179
|
/**
|
|
180
|
-
*
|
|
180
|
+
* True when Gatekeeper will let the bundle execute on this machine.
|
|
181
|
+
* A Developer-ID-signed but un-notarized app is rejected by `spctl --assess`,
|
|
182
|
+
* which macOS surfaces as "the app is damaged" and can crash AppKit during
|
|
183
|
+
* launch. This is separate from `codesign --verify`: a signature can be valid
|
|
184
|
+
* while Gatekeeper still refuses to run it.
|
|
185
|
+
*/
|
|
186
|
+
export function gatekeeperAssesses(appPath) {
|
|
187
|
+
const r = spawnSync('spctl', ['--assess', '--type', 'exec', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
188
|
+
return r.status === 0;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Guarantee the installed bundle has a signature Gatekeeper will accept on THIS
|
|
192
|
+
* machine.
|
|
181
193
|
*
|
|
182
194
|
* npm's pack/extract strips the ad-hoc/linker signature the release baked into
|
|
183
195
|
* the helper, leaving `code object is not signed at all`. On macOS 26+ the
|
|
@@ -189,15 +201,17 @@ export function codesignVerifies(appPath) {
|
|
|
189
201
|
* matching cdhash, which the kernel accepts.
|
|
190
202
|
*
|
|
191
203
|
* A Developer-ID-signed helper survives npm untouched — its embedded signature
|
|
192
|
-
* still verifies —
|
|
193
|
-
*
|
|
194
|
-
*
|
|
204
|
+
* still verifies — but if the release was not notarized, Gatekeeper rejects it.
|
|
205
|
+
* In that case we strip the quarantine xattr and re-sign ad-hoc so the helper
|
|
206
|
+
* can launch locally. The stable fix is to notarize the release build; this
|
|
207
|
+
* fallback just prevents a crash-loop while the user is on an un-notarized cut.
|
|
195
208
|
*/
|
|
196
209
|
export function ensureValidSignature(appPath) {
|
|
197
|
-
if (codesignVerifies(appPath))
|
|
210
|
+
if (codesignVerifies(appPath) && gatekeeperAssesses(appPath))
|
|
198
211
|
return true;
|
|
199
212
|
// Drop any quarantine/xattrs the tarball round-trip added (they can break
|
|
200
|
-
// codesign), then re-sign ad-hoc under the helper's
|
|
213
|
+
// both codesign and Gatekeeper), then re-sign ad-hoc under the helper's
|
|
214
|
+
// stable bundle identifier.
|
|
201
215
|
spawnSync('xattr', ['-cr', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
202
216
|
spawnSync('codesign', ['--force', '--sign', '-', '--identifier', SERVICE_LABEL, appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
203
217
|
return codesignVerifies(appPath);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one worktree-aware cwd -> project fold.
|
|
3
|
+
*
|
|
4
|
+
* Several surfaces bucket work "by project": the `agents sessions` overview,
|
|
5
|
+
* the `agents activity` timeline, and anything else that has a cwd and needs a
|
|
6
|
+
* stable repo-level key. They must agree, or the same session shows up under
|
|
7
|
+
* `agents-cli` in one view and `my-branch-slug` in another — so the rule lives
|
|
8
|
+
* here and every caller delegates.
|
|
9
|
+
*
|
|
10
|
+
* The rule: a worktree cwd (`…/<repo>/.agents/worktrees/<slug>[/sub]`) folds to
|
|
11
|
+
* the REPO directory name, so a worktree groups with the repo it branched from;
|
|
12
|
+
* any other path resolves to its own basename. {@link projectKeyFromCwd} is pure
|
|
13
|
+
* — no filesystem, no git, so it works identically for a remote peer's events as
|
|
14
|
+
* for local ones; {@link resolveProjectKey} adds the filesystem repo-root walk
|
|
15
|
+
* for paths this machine can see.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a stable project key from a working directory, or `undefined` when
|
|
19
|
+
* the path carries nothing usable (empty, `/`, whitespace).
|
|
20
|
+
*/
|
|
21
|
+
export declare function projectKeyFromCwd(cwd?: string | null): string | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* The git working-tree root containing `dir`, by walking up for a `.git` entry
|
|
24
|
+
* — a directory in a normal checkout, a file in a linked worktree, so one
|
|
25
|
+
* `existsSync` covers both. Filesystem-only: no `git` process per lookup, which
|
|
26
|
+
* matters because a timeline can hold dozens of distinct cwds.
|
|
27
|
+
*
|
|
28
|
+
* Returns `undefined` when `dir` is not inside a repo, when it does not exist
|
|
29
|
+
* (a path from another machine), or when the only repo found IS the home
|
|
30
|
+
* directory — a dotfiles repo at `$HOME` would otherwise swallow every
|
|
31
|
+
* non-project directory under it into one bogus "project".
|
|
32
|
+
*/
|
|
33
|
+
export declare function repoRootForCwd(dir: string, home?: string): string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the project key for a cwd **on this machine**: the repository it
|
|
36
|
+
* belongs to when there is one (so a monorepo subdir like `<repo>/apps/cli`
|
|
37
|
+
* groups under `<repo>`, not `cli`), else the directory itself.
|
|
38
|
+
*
|
|
39
|
+
* Each machine resolves its own paths — a peer answering a fan-out stamps the
|
|
40
|
+
* project for its events before they cross the wire — so this is never asked
|
|
41
|
+
* about a path it cannot see. {@link projectKeyFromCwd} is the pure fold for
|
|
42
|
+
* everything else.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveProjectKey(cwd?: string | null, home?: string): string | undefined;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one worktree-aware cwd -> project fold.
|
|
3
|
+
*
|
|
4
|
+
* Several surfaces bucket work "by project": the `agents sessions` overview,
|
|
5
|
+
* the `agents activity` timeline, and anything else that has a cwd and needs a
|
|
6
|
+
* stable repo-level key. They must agree, or the same session shows up under
|
|
7
|
+
* `agents-cli` in one view and `my-branch-slug` in another — so the rule lives
|
|
8
|
+
* here and every caller delegates.
|
|
9
|
+
*
|
|
10
|
+
* The rule: a worktree cwd (`…/<repo>/.agents/worktrees/<slug>[/sub]`) folds to
|
|
11
|
+
* the REPO directory name, so a worktree groups with the repo it branched from;
|
|
12
|
+
* any other path resolves to its own basename. {@link projectKeyFromCwd} is pure
|
|
13
|
+
* — no filesystem, no git, so it works identically for a remote peer's events as
|
|
14
|
+
* for local ones; {@link resolveProjectKey} adds the filesystem repo-root walk
|
|
15
|
+
* for paths this machine can see.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as os from 'os';
|
|
19
|
+
import * as path from 'path';
|
|
20
|
+
const WORKTREE_SEGMENT = '/.agents/worktrees/';
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a stable project key from a working directory, or `undefined` when
|
|
23
|
+
* the path carries nothing usable (empty, `/`, whitespace).
|
|
24
|
+
*/
|
|
25
|
+
export function projectKeyFromCwd(cwd) {
|
|
26
|
+
if (!cwd)
|
|
27
|
+
return undefined;
|
|
28
|
+
const norm = cwd.replace(/\\/g, '/').replace(/\/+$/, '').trim();
|
|
29
|
+
if (!norm)
|
|
30
|
+
return undefined;
|
|
31
|
+
const wtIdx = norm.indexOf(WORKTREE_SEGMENT);
|
|
32
|
+
if (wtIdx > 0) {
|
|
33
|
+
const repoPath = norm.slice(0, wtIdx);
|
|
34
|
+
const base = repoPath.slice(repoPath.lastIndexOf('/') + 1);
|
|
35
|
+
if (base)
|
|
36
|
+
return base;
|
|
37
|
+
}
|
|
38
|
+
const base = norm.slice(norm.lastIndexOf('/') + 1);
|
|
39
|
+
return base || undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The git working-tree root containing `dir`, by walking up for a `.git` entry
|
|
43
|
+
* — a directory in a normal checkout, a file in a linked worktree, so one
|
|
44
|
+
* `existsSync` covers both. Filesystem-only: no `git` process per lookup, which
|
|
45
|
+
* matters because a timeline can hold dozens of distinct cwds.
|
|
46
|
+
*
|
|
47
|
+
* Returns `undefined` when `dir` is not inside a repo, when it does not exist
|
|
48
|
+
* (a path from another machine), or when the only repo found IS the home
|
|
49
|
+
* directory — a dotfiles repo at `$HOME` would otherwise swallow every
|
|
50
|
+
* non-project directory under it into one bogus "project".
|
|
51
|
+
*/
|
|
52
|
+
export function repoRootForCwd(dir, home = os.homedir()) {
|
|
53
|
+
const stop = path.resolve(home);
|
|
54
|
+
let current = path.resolve(dir);
|
|
55
|
+
for (;;) {
|
|
56
|
+
if (fs.existsSync(path.join(current, '.git')))
|
|
57
|
+
return current === stop ? undefined : current;
|
|
58
|
+
const parent = path.dirname(current);
|
|
59
|
+
if (parent === current)
|
|
60
|
+
return undefined;
|
|
61
|
+
current = parent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the project key for a cwd **on this machine**: the repository it
|
|
66
|
+
* belongs to when there is one (so a monorepo subdir like `<repo>/apps/cli`
|
|
67
|
+
* groups under `<repo>`, not `cli`), else the directory itself.
|
|
68
|
+
*
|
|
69
|
+
* Each machine resolves its own paths — a peer answering a fan-out stamps the
|
|
70
|
+
* project for its events before they cross the wire — so this is never asked
|
|
71
|
+
* about a path it cannot see. {@link projectKeyFromCwd} is the pure fold for
|
|
72
|
+
* everything else.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveProjectKey(cwd, home) {
|
|
75
|
+
if (!cwd)
|
|
76
|
+
return undefined;
|
|
77
|
+
const root = repoRootForCwd(cwd, home);
|
|
78
|
+
return projectKeyFromCwd(root ?? cwd);
|
|
79
|
+
}
|
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,69 @@
|
|
|
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 project's live session rollup. */
|
|
17
|
+
export interface ProjectSessionRollup {
|
|
18
|
+
name: string;
|
|
19
|
+
/** Total sessions whose cwd is inside this project. */
|
|
20
|
+
agents: number;
|
|
21
|
+
/** Count per lifecycle status. */
|
|
22
|
+
byStatus: Partial<Record<ActiveStatus, number>>;
|
|
23
|
+
/** Summed checklist progress across this project's sessions. */
|
|
24
|
+
plan: {
|
|
25
|
+
done: number;
|
|
26
|
+
total: number;
|
|
27
|
+
};
|
|
28
|
+
/** Distinct open PRs held by this project's sessions. */
|
|
29
|
+
openPrs: {
|
|
30
|
+
url: string;
|
|
31
|
+
number?: number;
|
|
32
|
+
}[];
|
|
33
|
+
/** Distinct tickets worked or created by this project's sessions. */
|
|
34
|
+
tickets: string[];
|
|
35
|
+
/** Sessions running inside a worktree. */
|
|
36
|
+
worktrees: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Roll active sessions up by project. Returns a map keyed by project name,
|
|
40
|
+
* containing only projects with at least one matched session — callers merge
|
|
41
|
+
* with the full definition list to show zero-agent projects.
|
|
42
|
+
*/
|
|
43
|
+
export declare function rollupSessionsByProject(defs: ProjectDef[], sessions: ActiveSession[]): Map<string, ProjectSessionRollup>;
|
|
44
|
+
/** Plan completion percentage (0–100), or undefined when nothing is tracked. */
|
|
45
|
+
export declare function planPct(plan: {
|
|
46
|
+
done: number;
|
|
47
|
+
total: number;
|
|
48
|
+
}): number | undefined;
|
|
49
|
+
/** Harvested signals not on the session list: repo-global merged PRs + local artifacts, in a time window. */
|
|
50
|
+
export interface ProjectRemoteSignals {
|
|
51
|
+
windowDays: number;
|
|
52
|
+
/** PRs merged into the primary repo within the window (via `gh`). */
|
|
53
|
+
mergedPrs: number;
|
|
54
|
+
/** Artifacts agents produced within the window (activity.created milestones). */
|
|
55
|
+
artifacts: number;
|
|
56
|
+
/** Basename of the most recent artifact, when any. */
|
|
57
|
+
lastArtifact?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Harvest the signals that don't live on the active-session list: recently
|
|
61
|
+
* merged PRs (from GitHub via `gh`) and artifacts agents produced (from the
|
|
62
|
+
* local activity-milestone log, matched to the project by cwd). Best-effort —
|
|
63
|
+
* a missing `gh`, no auth, or no repo degrades to zero rather than throwing, so
|
|
64
|
+
* `projects status` still renders. `nowMs` is injected for testability.
|
|
65
|
+
*/
|
|
66
|
+
export declare function enrichProjectSignals(def: ProjectDef, windowDays: number, nowMs: number, opts?: {
|
|
67
|
+
activityRoot?: string;
|
|
68
|
+
skipRemote?: boolean;
|
|
69
|
+
}): Promise<ProjectRemoteSignals>;
|
|
@@ -0,0 +1,101 @@
|
|
|
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 { projectNameForCwd } from './projects.js';
|
|
17
|
+
import { readRecentActivity } from './activity.js';
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
function blank(name) {
|
|
20
|
+
return { name, agents: 0, byStatus: {}, plan: { done: 0, total: 0 }, openPrs: [], tickets: [], worktrees: 0 };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Roll active sessions up by project. Returns a map keyed by project name,
|
|
24
|
+
* containing only projects with at least one matched session — callers merge
|
|
25
|
+
* with the full definition list to show zero-agent projects.
|
|
26
|
+
*/
|
|
27
|
+
export function rollupSessionsByProject(defs, sessions) {
|
|
28
|
+
const map = new Map();
|
|
29
|
+
const prSeen = new Map();
|
|
30
|
+
const ticketSeen = new Map();
|
|
31
|
+
for (const s of sessions) {
|
|
32
|
+
const name = projectNameForCwd(s.cwd, defs);
|
|
33
|
+
if (!name)
|
|
34
|
+
continue;
|
|
35
|
+
let r = map.get(name);
|
|
36
|
+
if (!r) {
|
|
37
|
+
r = blank(name);
|
|
38
|
+
map.set(name, r);
|
|
39
|
+
prSeen.set(name, new Set());
|
|
40
|
+
ticketSeen.set(name, new Set());
|
|
41
|
+
}
|
|
42
|
+
r.agents++;
|
|
43
|
+
r.byStatus[s.status] = (r.byStatus[s.status] ?? 0) + 1;
|
|
44
|
+
if (s.todos) {
|
|
45
|
+
r.plan.done += s.todos.done;
|
|
46
|
+
r.plan.total += s.todos.total;
|
|
47
|
+
}
|
|
48
|
+
if (s.pr?.url && !prSeen.get(name).has(s.pr.url)) {
|
|
49
|
+
prSeen.get(name).add(s.pr.url);
|
|
50
|
+
r.openPrs.push({ url: s.pr.url, number: s.pr.number });
|
|
51
|
+
}
|
|
52
|
+
const tset = ticketSeen.get(name);
|
|
53
|
+
for (const t of [s.ticket?.id, ...(s.createdTickets ?? [])]) {
|
|
54
|
+
if (t && !tset.has(t)) {
|
|
55
|
+
tset.add(t);
|
|
56
|
+
r.tickets.push(t);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (s.worktree)
|
|
60
|
+
r.worktrees++;
|
|
61
|
+
}
|
|
62
|
+
return map;
|
|
63
|
+
}
|
|
64
|
+
/** Plan completion percentage (0–100), or undefined when nothing is tracked. */
|
|
65
|
+
export function planPct(plan) {
|
|
66
|
+
if (plan.total <= 0)
|
|
67
|
+
return undefined;
|
|
68
|
+
return Math.round((plan.done / plan.total) * 100);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Harvest the signals that don't live on the active-session list: recently
|
|
72
|
+
* merged PRs (from GitHub via `gh`) and artifacts agents produced (from the
|
|
73
|
+
* local activity-milestone log, matched to the project by cwd). Best-effort —
|
|
74
|
+
* a missing `gh`, no auth, or no repo degrades to zero rather than throwing, so
|
|
75
|
+
* `projects status` still renders. `nowMs` is injected for testability.
|
|
76
|
+
*/
|
|
77
|
+
export async function enrichProjectSignals(def, windowDays, nowMs, opts = {}) {
|
|
78
|
+
const sinceMs = nowMs - windowDays * 86_400_000;
|
|
79
|
+
const out = { windowDays, mergedPrs: 0, artifacts: 0 };
|
|
80
|
+
try {
|
|
81
|
+
const evs = readRecentActivity({ events: ['artifact.created'], sinceMs, root: opts.activityRoot });
|
|
82
|
+
const mine = evs.filter((e) => projectNameForCwd(e.cwd, [def]) === def.name);
|
|
83
|
+
out.artifacts = mine.length;
|
|
84
|
+
if (mine.length && typeof mine[0].detail === 'string')
|
|
85
|
+
out.lastArtifact = mine[0].detail;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* activity log unreadable — best-effort */
|
|
89
|
+
}
|
|
90
|
+
if (def.repo && !opts.skipRemote) {
|
|
91
|
+
try {
|
|
92
|
+
const { stdout } = await execFileAsync('gh', ['pr', 'list', '--repo', def.repo, '--state', 'merged', '--json', 'number,mergedAt', '--limit', '100'], { timeout: 8000, encoding: 'utf8' });
|
|
93
|
+
const rows = JSON.parse(stdout);
|
|
94
|
+
out.mergedPrs = rows.filter((r) => r.mergedAt && Date.parse(r.mergedAt) >= sinceMs).length;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* gh missing / unauthenticated / repo not found — skip this signal */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named project definitions — the layer above the `--project <slug>` convention.
|
|
3
|
+
*
|
|
4
|
+
* `agents run --project <slug>` already resolves a bare name to a working
|
|
5
|
+
* directory by pure convention (`<projectRoot>/<slug>`, see `project-root.ts`).
|
|
6
|
+
* This module adds editable definitions on top: one YAML file per project under
|
|
7
|
+
* `~/.agents/projects/<name>.yaml`, sitting beside the existing `routines/`,
|
|
8
|
+
* `monitors/`, and `teams/` dirs in the user repo (so definitions sync across
|
|
9
|
+
* machines for free via `agents push/pull`). A defined project can name itself
|
|
10
|
+
* independently of its folder, bind more than one repo, pin a monorepo subpath,
|
|
11
|
+
* describe context subdirectories an agent should start from, carry a Linear
|
|
12
|
+
* link and external integrations, and set an explicit default path.
|
|
13
|
+
*
|
|
14
|
+
* Portable by construction: `root`/`defaultPath` are stored home-relative
|
|
15
|
+
* (`~/…`) via `toHomeRelative`, so the same definition re-roots on any machine
|
|
16
|
+
* whose home differs — the exact mechanism `project-root.ts` already relies on.
|
|
17
|
+
*
|
|
18
|
+
* Resolution stays additive: an undefined slug still resolves exactly as today
|
|
19
|
+
* (see `resolveProjectRef`), a defined one overrides it.
|
|
20
|
+
*/
|
|
21
|
+
/** A git repo bound to a project, with an optional monorepo subpath. */
|
|
22
|
+
export interface ProjectRepo {
|
|
23
|
+
/** GitHub slug `owner/repo`. */
|
|
24
|
+
slug: string;
|
|
25
|
+
/** Optional path within the repo an agent working this project cares about. */
|
|
26
|
+
subpath?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A described context anchor: a subdirectory plus what it is. Agents starting on
|
|
30
|
+
* the project read `purpose` to know where to look — an indexed starting point,
|
|
31
|
+
* not just a path. This is the richer form of the single monorepo-focus dir.
|
|
32
|
+
*/
|
|
33
|
+
export interface ProjectContext {
|
|
34
|
+
/** Path relative to the project root (e.g. `apps/web`). */
|
|
35
|
+
path: string;
|
|
36
|
+
/** One line on how this subtree relates to the project. */
|
|
37
|
+
purpose: string;
|
|
38
|
+
}
|
|
39
|
+
/** An external context source hung off the project (surfaced in `projects show`). */
|
|
40
|
+
export interface ProjectIntegration {
|
|
41
|
+
/** e.g. `gdrive`, `notion`, `figma`, `url`. */
|
|
42
|
+
kind: string;
|
|
43
|
+
url: string;
|
|
44
|
+
label?: string;
|
|
45
|
+
}
|
|
46
|
+
/** The parsed `~/.agents/projects/<name>.yaml`. */
|
|
47
|
+
export interface ProjectDef {
|
|
48
|
+
/** Stable id; matches the filename; what `--project` takes. */
|
|
49
|
+
name: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
/** Repo / monorepo root, home-relative for portability. */
|
|
52
|
+
root?: string;
|
|
53
|
+
/** Where an agent's cwd lands. Defaults to `root` when unset. */
|
|
54
|
+
defaultPath?: string;
|
|
55
|
+
/** Primary GitHub slug (`owner/repo`) — for PR / CI / status roll-up. */
|
|
56
|
+
repo?: string;
|
|
57
|
+
/** All bound repos, each with an optional monorepo subpath. */
|
|
58
|
+
repos?: ProjectRepo[];
|
|
59
|
+
/** Described starting points inside the project. */
|
|
60
|
+
contexts?: ProjectContext[];
|
|
61
|
+
/** External context sources (Drive, docs, …). */
|
|
62
|
+
integrations?: ProjectIntegration[];
|
|
63
|
+
/** Linear project link — reuses the existing GraphQL path. */
|
|
64
|
+
linear?: {
|
|
65
|
+
projectId?: string;
|
|
66
|
+
url?: string;
|
|
67
|
+
};
|
|
68
|
+
/** Free-form doc links surfaced in `projects show`. */
|
|
69
|
+
docs?: string[];
|
|
70
|
+
}
|
|
71
|
+
/** A project name safe to use as a filename: no separators, `..`, or leading dot. */
|
|
72
|
+
export declare function isSafeProjectName(name: string): boolean;
|
|
73
|
+
/** Absolute path to a project's YAML definition. Throws on an unsafe name. */
|
|
74
|
+
export declare function projectDefPath(name: string): string;
|
|
75
|
+
/**
|
|
76
|
+
* Validate a raw parsed object into a `ProjectDef`, throwing an actionable error
|
|
77
|
+
* on the first problem. A malformed document or identity (bad/mismatched name)
|
|
78
|
+
* throws; malformed entries inside the optional lists (`repos`/`contexts`/
|
|
79
|
+
* `integrations`) are dropped so one bad row can't sink an otherwise good def.
|
|
80
|
+
*/
|
|
81
|
+
export declare function validateProjectDef(raw: unknown, sourceName?: string): ProjectDef;
|
|
82
|
+
/**
|
|
83
|
+
* Load a single project definition by name. Returns undefined when the file is
|
|
84
|
+
* absent (the common "not a defined project, fall back to convention" case) but
|
|
85
|
+
* throws when a file EXISTS and is malformed — a broken definition is loud.
|
|
86
|
+
*/
|
|
87
|
+
export declare function loadProjectDef(name: string): ProjectDef | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* List every defined project, sorted by name. Skips (does not throw on) a
|
|
90
|
+
* malformed file so one bad definition can't break `projects list`; the loader
|
|
91
|
+
* for a single named project stays strict.
|
|
92
|
+
*/
|
|
93
|
+
export declare function listProjectDefs(): ProjectDef[];
|
|
94
|
+
/**
|
|
95
|
+
* Persist a project definition, normalizing `root`/`defaultPath` to home-relative
|
|
96
|
+
* so it stays portable across machines. Creates the projects dir on first write.
|
|
97
|
+
*/
|
|
98
|
+
export declare function writeProjectDef(def: ProjectDef): string;
|
|
99
|
+
/** Delete a project definition. Returns true if a file was removed. Never touches the repo. */
|
|
100
|
+
export declare function removeProjectDef(name: string): boolean;
|
|
101
|
+
/**
|
|
102
|
+
* The cwd an agent lands in for a defined project: `defaultPath` when set, else
|
|
103
|
+
* `root`. Home-relative when `forRemote` (the remote shell expands `~`), else
|
|
104
|
+
* expanded against the local home. Returns undefined when neither is set.
|
|
105
|
+
*/
|
|
106
|
+
export declare function projectBasePath(def: ProjectDef, forRemote: boolean): string | undefined;
|
|
107
|
+
/**
|
|
108
|
+
* Which defined project a session belongs to, derived from its working
|
|
109
|
+
* directory. A session whose `cwd` sits inside a project's repo root (or a
|
|
110
|
+
* worktree under it) is a member; the LONGEST matching root wins so a nested
|
|
111
|
+
* project beats its parent. Returns undefined when no definition contains the
|
|
112
|
+
* path.
|
|
113
|
+
*
|
|
114
|
+
* The comparison is against the LOCAL home: roots and the cwd are both expanded
|
|
115
|
+
* with `expandLocalHome` and resolved, so this matches sessions whose cwd shares
|
|
116
|
+
* this machine's home layout. A session recorded on a different-home machine
|
|
117
|
+
* (`/Users/x/…` vs `/home/x/…`) will not match until the fleet-wide,
|
|
118
|
+
* home-relative variant lands (see the deferred item in docs/11-projects.md).
|
|
119
|
+
*/
|
|
120
|
+
export declare function projectNameForCwd(cwd: string | undefined, defs: ProjectDef[]): string | undefined;
|
|
121
|
+
/**
|
|
122
|
+
* The canonical project label for a cwd, for every surface that buckets work by
|
|
123
|
+
* project (the activity timeline, feed posts, the sessions overview): the
|
|
124
|
+
* DEFINED project whose root contains the cwd (longest root wins, so a
|
|
125
|
+
* multi-repo project reads as one bucket), else the repository-level key from
|
|
126
|
+
* {@link resolveProjectKey}. `defs` comes from {@link listProjectDefs}, which is
|
|
127
|
+
* fail-open — with no definitions this degrades to exactly today's behavior.
|
|
128
|
+
*/
|
|
129
|
+
export declare function resolveProjectNameForCwd(cwd: string | undefined | null, defs: ProjectDef[]): string | undefined;
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a defined project's ref to a working directory, mirroring
|
|
132
|
+
* `buildProjectPath`'s `forRemote` contract (a home-relative `~/…` for the
|
|
133
|
+
* remote shell to expand, an absolute local path otherwise). A `@worktree`
|
|
134
|
+
* lands under the repo ROOT's `.agents/worktrees/`, not the `defaultPath`
|
|
135
|
+
* subdir — worktrees are per-repo, not per-focus. Returns undefined when the
|
|
136
|
+
* definition carries no `root`/`defaultPath` (caller falls back to convention).
|
|
137
|
+
*/
|
|
138
|
+
export declare function resolveDefinedProjectPath(def: ProjectDef, worktree: string | undefined, forRemote: boolean): string | undefined;
|