@phnx-labs/agents-cli 1.20.91 → 1.20.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +1 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +72 -6
  5. package/dist/commands/activity.js +198 -49
  6. package/dist/commands/beta.js +1 -0
  7. package/dist/commands/doctor.js +4 -2
  8. package/dist/commands/exec.d.ts +14 -0
  9. package/dist/commands/exec.js +144 -14
  10. package/dist/commands/projects.d.ts +12 -0
  11. package/dist/commands/projects.js +358 -0
  12. package/dist/commands/sessions-picker.d.ts +15 -0
  13. package/dist/commands/sessions-picker.js +37 -12
  14. package/dist/commands/sessions-resume.d.ts +2 -0
  15. package/dist/commands/sessions-resume.js +9 -1
  16. package/dist/commands/sessions.d.ts +10 -5
  17. package/dist/commands/sessions.js +65 -27
  18. package/dist/index.js +2 -1
  19. package/dist/lib/activity.d.ts +69 -12
  20. package/dist/lib/activity.js +417 -74
  21. package/dist/lib/beta.d.ts +1 -1
  22. package/dist/lib/beta.js +1 -1
  23. package/dist/lib/devices/registry.d.ts +14 -0
  24. package/dist/lib/devices/registry.js +37 -0
  25. package/dist/lib/feed-post.js +8 -2
  26. package/dist/lib/hosts/remote-cmd.js +4 -0
  27. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  28. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  29. package/dist/lib/menubar/install-menubar.js +20 -6
  30. package/dist/lib/project-key.d.ts +44 -0
  31. package/dist/lib/project-key.js +79 -0
  32. package/dist/lib/project-root.js +16 -0
  33. package/dist/lib/project-status.d.ts +69 -0
  34. package/dist/lib/project-status.js +101 -0
  35. package/dist/lib/projects.d.ts +138 -0
  36. package/dist/lib/projects.js +301 -0
  37. package/dist/lib/remote-agents-json.d.ts +9 -0
  38. package/dist/lib/remote-agents-json.js +11 -5
  39. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  40. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  41. package/dist/lib/session/bash-command.d.ts +53 -0
  42. package/dist/lib/session/bash-command.js +364 -0
  43. package/dist/lib/session/digest.d.ts +6 -0
  44. package/dist/lib/session/digest.js +19 -0
  45. package/dist/lib/session/relative-time.d.ts +23 -0
  46. package/dist/lib/session/relative-time.js +60 -8
  47. package/dist/lib/session/remote-list.js +5 -2
  48. package/dist/lib/session/render.d.ts +2 -9
  49. package/dist/lib/session/render.js +25 -56
  50. package/dist/lib/ssh-exec.d.ts +6 -0
  51. package/dist/lib/ssh-exec.js +10 -1
  52. package/dist/lib/startup/command-registry.d.ts +1 -0
  53. package/dist/lib/startup/command-registry.js +2 -0
  54. package/dist/lib/state.d.ts +2 -0
  55. package/dist/lib/state.js +5 -0
  56. package/dist/lib/terminal/backends/index.d.ts +10 -2
  57. package/dist/lib/terminal/backends/index.js +14 -2
  58. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  59. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  60. package/dist/lib/terminal/index.d.ts +2 -1
  61. package/dist/lib/terminal/index.js +2 -1
  62. package/dist/lib/terminal/preferred.d.ts +89 -0
  63. package/dist/lib/terminal/preferred.js +87 -0
  64. package/dist/lib/terminal/run-surface.d.ts +82 -0
  65. package/dist/lib/terminal/run-surface.js +146 -0
  66. package/dist/lib/terminal/types.d.ts +1 -1
  67. package/dist/lib/types.d.ts +1 -1
  68. package/package.json +2 -1
@@ -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
  }
@@ -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, projectFromCwd, } from './activity.js';
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
- const project = projectFromCwd(identity.cwd);
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.',
@@ -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
- * Guarantee the installed bundle has a valid signature on THIS machine.
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 — so we leave it alone and only re-sign when verification
46
- * fails. Returns whether the bundle ends up validly signed. No-op cost on the
47
- * common (already-valid) path is a single `codesign --verify`.
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
- * Guarantee the installed bundle has a valid signature on THIS machine.
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 — so we leave it alone and only re-sign when verification
193
- * fails. Returns whether the bundle ends up validly signed. No-op cost on the
194
- * common (already-valid) path is a single `codesign --verify`.
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 stable bundle identifier.
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
+ }
@@ -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;