@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.
Files changed (105) hide show
  1. package/CHANGELOG.md +276 -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/events.js +91 -1
  9. package/dist/commands/exec.d.ts +14 -0
  10. package/dist/commands/exec.js +144 -14
  11. package/dist/commands/projects.d.ts +22 -0
  12. package/dist/commands/projects.js +539 -0
  13. package/dist/commands/secrets.d.ts +17 -0
  14. package/dist/commands/secrets.js +198 -7
  15. package/dist/commands/send.d.ts +14 -12
  16. package/dist/commands/send.js +105 -35
  17. package/dist/commands/sessions-picker.d.ts +15 -0
  18. package/dist/commands/sessions-picker.js +37 -12
  19. package/dist/commands/sessions-resume.d.ts +2 -0
  20. package/dist/commands/sessions-resume.js +9 -1
  21. package/dist/commands/sessions.d.ts +10 -5
  22. package/dist/commands/sessions.js +65 -27
  23. package/dist/commands/sync.js +9 -3
  24. package/dist/commands/view.js +4 -0
  25. package/dist/index.js +18 -1
  26. package/dist/lib/activity.d.ts +77 -12
  27. package/dist/lib/activity.js +424 -74
  28. package/dist/lib/beta.d.ts +1 -1
  29. package/dist/lib/beta.js +1 -1
  30. package/dist/lib/channels/send.d.ts +83 -0
  31. package/dist/lib/channels/send.js +112 -0
  32. package/dist/lib/devices/registry.d.ts +14 -0
  33. package/dist/lib/devices/registry.js +37 -0
  34. package/dist/lib/events-ingest.d.ts +46 -0
  35. package/dist/lib/events-ingest.js +182 -0
  36. package/dist/lib/events.d.ts +15 -3
  37. package/dist/lib/events.js +55 -3
  38. package/dist/lib/feed-post.js +8 -2
  39. package/dist/lib/hosts/remote-cmd.js +4 -0
  40. package/dist/lib/linear-project-counts.d.ts +62 -0
  41. package/dist/lib/linear-project-counts.js +122 -0
  42. package/dist/lib/linear-projects.d.ts +50 -0
  43. package/dist/lib/linear-projects.js +114 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  46. package/dist/lib/menubar/install-menubar.js +20 -6
  47. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  48. package/dist/lib/menubar/notify-desktop.js +8 -2
  49. package/dist/lib/project-key.d.ts +44 -0
  50. package/dist/lib/project-key.js +79 -0
  51. package/dist/lib/project-probe.d.ts +75 -0
  52. package/dist/lib/project-probe.js +160 -0
  53. package/dist/lib/project-resources.d.ts +8 -0
  54. package/dist/lib/project-resources.js +31 -3
  55. package/dist/lib/project-root.js +16 -0
  56. package/dist/lib/project-status.d.ts +100 -0
  57. package/dist/lib/project-status.js +182 -0
  58. package/dist/lib/projects.d.ts +144 -0
  59. package/dist/lib/projects.js +313 -0
  60. package/dist/lib/remote-agents-json.d.ts +9 -0
  61. package/dist/lib/remote-agents-json.js +11 -5
  62. package/dist/lib/routine-notify.d.ts +11 -0
  63. package/dist/lib/routine-notify.js +22 -0
  64. package/dist/lib/run-notify.js +3 -0
  65. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  66. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  67. package/dist/lib/secrets/audit.d.ts +1 -1
  68. package/dist/lib/secrets/audit.js +53 -10
  69. package/dist/lib/secrets/list-filter.d.ts +20 -5
  70. package/dist/lib/secrets/list-filter.js +22 -6
  71. package/dist/lib/secrets/usage-db.d.ts +106 -0
  72. package/dist/lib/secrets/usage-db.js +236 -0
  73. package/dist/lib/session/bash-command.d.ts +53 -0
  74. package/dist/lib/session/bash-command.js +364 -0
  75. package/dist/lib/session/digest.d.ts +6 -0
  76. package/dist/lib/session/digest.js +19 -0
  77. package/dist/lib/session/relative-time.d.ts +23 -0
  78. package/dist/lib/session/relative-time.js +60 -8
  79. package/dist/lib/session/remote-active.d.ts +5 -1
  80. package/dist/lib/session/remote-active.js +4 -1
  81. package/dist/lib/session/remote-list.js +5 -2
  82. package/dist/lib/session/render.d.ts +2 -9
  83. package/dist/lib/session/render.js +25 -56
  84. package/dist/lib/sqlite.js +28 -1
  85. package/dist/lib/ssh-exec.d.ts +6 -0
  86. package/dist/lib/ssh-exec.js +10 -1
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/state.d.ts +14 -0
  90. package/dist/lib/state.js +19 -0
  91. package/dist/lib/terminal/backends/index.d.ts +10 -2
  92. package/dist/lib/terminal/backends/index.js +14 -2
  93. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  94. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  95. package/dist/lib/terminal/index.d.ts +2 -1
  96. package/dist/lib/terminal/index.js +2 -1
  97. package/dist/lib/terminal/preferred.d.ts +89 -0
  98. package/dist/lib/terminal/preferred.js +87 -0
  99. package/dist/lib/terminal/run-surface.d.ts +82 -0
  100. package/dist/lib/terminal/run-surface.js +146 -0
  101. package/dist/lib/terminal/types.d.ts +1 -1
  102. package/dist/lib/types.d.ts +6 -5
  103. package/dist/lib/versions.d.ts +6 -0
  104. package/dist/lib/versions.js +6 -4
  105. package/package.json +2 -1
@@ -0,0 +1,144 @@
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
+ * Optional home-relative local checkout of this repo. The def's `root` only
29
+ * knows the primary repo on disk; `path` opts an additional repo into
30
+ * workspace probing (`projects status --fleet`).
31
+ */
32
+ path?: string;
33
+ }
34
+ /**
35
+ * A described context anchor: a subdirectory plus what it is. Agents starting on
36
+ * the project read `purpose` to know where to look — an indexed starting point,
37
+ * not just a path. This is the richer form of the single monorepo-focus dir.
38
+ */
39
+ export interface ProjectContext {
40
+ /** Path relative to the project root (e.g. `apps/web`). */
41
+ path: string;
42
+ /** One line on how this subtree relates to the project. */
43
+ purpose: string;
44
+ }
45
+ /** An external context source hung off the project (surfaced in `projects show`). */
46
+ export interface ProjectIntegration {
47
+ /** e.g. `gdrive`, `notion`, `figma`, `url`. */
48
+ kind: string;
49
+ url: string;
50
+ label?: string;
51
+ }
52
+ /** The parsed `~/.agents/projects/<name>.yaml`. */
53
+ export interface ProjectDef {
54
+ /** Stable id; matches the filename; what `--project` takes. */
55
+ name: string;
56
+ description?: string;
57
+ /** Repo / monorepo root, home-relative for portability. */
58
+ root?: string;
59
+ /** Where an agent's cwd lands. Defaults to `root` when unset. */
60
+ defaultPath?: string;
61
+ /** Primary GitHub slug (`owner/repo`) — for PR / CI / status roll-up. */
62
+ repo?: string;
63
+ /** All bound repos, each with an optional monorepo subpath. */
64
+ repos?: ProjectRepo[];
65
+ /** Described starting points inside the project. */
66
+ contexts?: ProjectContext[];
67
+ /** External context sources (Drive, docs, …). */
68
+ integrations?: ProjectIntegration[];
69
+ /** Linear project link — reuses the existing GraphQL path. */
70
+ linear?: {
71
+ projectId?: string;
72
+ url?: string;
73
+ };
74
+ /** Free-form doc links surfaced in `projects show`. */
75
+ docs?: string[];
76
+ }
77
+ /** A project name safe to use as a filename: no separators, `..`, or leading dot. */
78
+ export declare function isSafeProjectName(name: string): boolean;
79
+ /** Absolute path to a project's YAML definition. Throws on an unsafe name. */
80
+ export declare function projectDefPath(name: string): string;
81
+ /**
82
+ * Validate a raw parsed object into a `ProjectDef`, throwing an actionable error
83
+ * on the first problem. A malformed document or identity (bad/mismatched name)
84
+ * throws; malformed entries inside the optional lists (`repos`/`contexts`/
85
+ * `integrations`) are dropped so one bad row can't sink an otherwise good def.
86
+ */
87
+ export declare function validateProjectDef(raw: unknown, sourceName?: string): ProjectDef;
88
+ /**
89
+ * Load a single project definition by name. Returns undefined when the file is
90
+ * absent (the common "not a defined project, fall back to convention" case) but
91
+ * throws when a file EXISTS and is malformed — a broken definition is loud.
92
+ */
93
+ export declare function loadProjectDef(name: string): ProjectDef | undefined;
94
+ /**
95
+ * List every defined project, sorted by name. Skips (does not throw on) a
96
+ * malformed file so one bad definition can't break `projects list`; the loader
97
+ * for a single named project stays strict.
98
+ */
99
+ export declare function listProjectDefs(): ProjectDef[];
100
+ /**
101
+ * Persist a project definition, normalizing `root`/`defaultPath` to home-relative
102
+ * so it stays portable across machines. Creates the projects dir on first write.
103
+ */
104
+ export declare function writeProjectDef(def: ProjectDef): string;
105
+ /** Delete a project definition. Returns true if a file was removed. Never touches the repo. */
106
+ export declare function removeProjectDef(name: string): boolean;
107
+ /**
108
+ * The cwd an agent lands in for a defined project: `defaultPath` when set, else
109
+ * `root`. Home-relative when `forRemote` (the remote shell expands `~`), else
110
+ * expanded against the local home. Returns undefined when neither is set.
111
+ */
112
+ export declare function projectBasePath(def: ProjectDef, forRemote: boolean): string | undefined;
113
+ /**
114
+ * Which defined project a session belongs to, derived from its working
115
+ * directory. A session whose `cwd` sits inside a project's repo root (or a
116
+ * worktree under it) is a member; the LONGEST matching root wins so a nested
117
+ * project beats its parent. Returns undefined when no definition contains the
118
+ * path.
119
+ *
120
+ * The comparison is against the LOCAL home: roots and the cwd are both expanded
121
+ * with `expandLocalHome` and resolved, so this matches sessions whose cwd shares
122
+ * this machine's home layout. A session recorded on a different-home machine
123
+ * (`/Users/x/…` vs `/home/x/…`) will not match until the fleet-wide,
124
+ * home-relative variant lands (see the deferred item in docs/11-projects.md).
125
+ */
126
+ export declare function projectNameForCwd(cwd: string | undefined, defs: ProjectDef[]): string | undefined;
127
+ /**
128
+ * The canonical project label for a cwd, for every surface that buckets work by
129
+ * project (the activity timeline, feed posts, the sessions overview): the
130
+ * DEFINED project whose root contains the cwd (longest root wins, so a
131
+ * multi-repo project reads as one bucket), else the repository-level key from
132
+ * {@link resolveProjectKey}. `defs` comes from {@link listProjectDefs}, which is
133
+ * fail-open — with no definitions this degrades to exactly today's behavior.
134
+ */
135
+ export declare function resolveProjectNameForCwd(cwd: string | undefined | null, defs: ProjectDef[]): string | undefined;
136
+ /**
137
+ * Resolve a defined project's ref to a working directory, mirroring
138
+ * `buildProjectPath`'s `forRemote` contract (a home-relative `~/…` for the
139
+ * remote shell to expand, an absolute local path otherwise). A `@worktree`
140
+ * lands under the repo ROOT's `.agents/worktrees/`, not the `defaultPath`
141
+ * subdir — worktrees are per-repo, not per-focus. Returns undefined when the
142
+ * definition carries no `root`/`defaultPath` (caller falls back to convention).
143
+ */
144
+ export declare function resolveDefinedProjectPath(def: ProjectDef, worktree: string | undefined, forRemote: boolean): string | undefined;
@@ -0,0 +1,313 @@
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
+ import * as fs from 'fs';
22
+ import * as path from 'path';
23
+ import * as yaml from 'yaml';
24
+ import { getProjectsDir } from './state.js';
25
+ import { safeJoin } from './paths.js';
26
+ import { toHomeRelative, expandLocalHome } from './project-root.js';
27
+ import { resolveProjectKey } from './project-key.js';
28
+ /** A project name safe to use as a filename: no separators, `..`, or leading dot. */
29
+ export function isSafeProjectName(name) {
30
+ return (typeof name === 'string' &&
31
+ name.length > 0 &&
32
+ name.length <= 64 &&
33
+ /^[a-z0-9][a-z0-9._-]*$/i.test(name) &&
34
+ name !== '.' &&
35
+ name !== '..');
36
+ }
37
+ /** Absolute path to a project's YAML definition. Throws on an unsafe name. */
38
+ export function projectDefPath(name) {
39
+ if (!isSafeProjectName(name)) {
40
+ throw new Error(`Invalid project name: "${name}" (letters, digits, ., _, - only)`);
41
+ }
42
+ return safeJoin(getProjectsDir(), `${name}.yaml`);
43
+ }
44
+ /**
45
+ * Validate a raw parsed object into a `ProjectDef`, throwing an actionable error
46
+ * on the first problem. A malformed document or identity (bad/mismatched name)
47
+ * throws; malformed entries inside the optional lists (`repos`/`contexts`/
48
+ * `integrations`) are dropped so one bad row can't sink an otherwise good def.
49
+ */
50
+ export function validateProjectDef(raw, sourceName) {
51
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
52
+ throw new Error(`Project ${sourceName ?? ''} is not a YAML mapping`.trim());
53
+ }
54
+ const o = raw;
55
+ // The filename is the identity; a `name:` field is optional but, when present,
56
+ // must be a valid slug — a malformed one is a loud error, not a silent fallback.
57
+ const hasNameField = 'name' in o && o.name !== undefined && o.name !== null;
58
+ let name;
59
+ if (hasNameField) {
60
+ if (typeof o.name !== 'string' || !isSafeProjectName(o.name)) {
61
+ throw new Error(`Project ${sourceName ?? ''}: "name" must be a valid slug (got ${JSON.stringify(o.name)})`);
62
+ }
63
+ name = o.name;
64
+ }
65
+ else {
66
+ name = sourceName;
67
+ }
68
+ if (!name || !isSafeProjectName(name)) {
69
+ throw new Error('Project definition is missing a valid "name"');
70
+ }
71
+ // The filename IS the stable id — a def whose `name:` disagrees with its
72
+ // filename would resolve under one name and list under another.
73
+ if (hasNameField && sourceName && name !== sourceName) {
74
+ throw new Error(`Project ${sourceName}: "name" (${JSON.stringify(name)}) must match the filename — the filename is the stable id`);
75
+ }
76
+ const def = { name };
77
+ if (typeof o.description === 'string')
78
+ def.description = o.description;
79
+ if (typeof o.root === 'string')
80
+ def.root = o.root;
81
+ if (typeof o.defaultPath === 'string')
82
+ def.defaultPath = o.defaultPath;
83
+ if (typeof o.repo === 'string')
84
+ def.repo = o.repo;
85
+ if (Array.isArray(o.repos)) {
86
+ def.repos = o.repos.flatMap((r) => {
87
+ if (r && typeof r === 'object' && typeof r.slug === 'string') {
88
+ const rr = r;
89
+ // A malformed `path` sinks the whole entry, like any other malformed
90
+ // list row — a half-valid repo must not probe a surprising location.
91
+ if (rr.path !== undefined && typeof rr.path !== 'string')
92
+ return [];
93
+ const repo = { slug: rr.slug };
94
+ if (typeof rr.subpath === 'string')
95
+ repo.subpath = rr.subpath;
96
+ if (typeof rr.path === 'string')
97
+ repo.path = rr.path;
98
+ return [repo];
99
+ }
100
+ return [];
101
+ });
102
+ }
103
+ if (Array.isArray(o.contexts)) {
104
+ def.contexts = o.contexts.flatMap((c) => {
105
+ if (c &&
106
+ typeof c === 'object' &&
107
+ typeof c.path === 'string' &&
108
+ typeof c.purpose === 'string') {
109
+ const cc = c;
110
+ return [{ path: cc.path, purpose: cc.purpose }];
111
+ }
112
+ return [];
113
+ });
114
+ }
115
+ if (Array.isArray(o.integrations)) {
116
+ def.integrations = o.integrations.flatMap((i) => {
117
+ if (i &&
118
+ typeof i === 'object' &&
119
+ typeof i.kind === 'string' &&
120
+ typeof i.url === 'string') {
121
+ const ii = i;
122
+ const integ = { kind: ii.kind, url: ii.url };
123
+ if (typeof ii.label === 'string')
124
+ integ.label = ii.label;
125
+ return [integ];
126
+ }
127
+ return [];
128
+ });
129
+ }
130
+ if (o.linear && typeof o.linear === 'object' && !Array.isArray(o.linear)) {
131
+ const l = o.linear;
132
+ def.linear = {};
133
+ if (typeof l.projectId === 'string')
134
+ def.linear.projectId = l.projectId;
135
+ if (typeof l.url === 'string')
136
+ def.linear.url = l.url;
137
+ }
138
+ if (Array.isArray(o.docs))
139
+ def.docs = o.docs.filter((d) => typeof d === 'string');
140
+ return def;
141
+ }
142
+ /**
143
+ * Load a single project definition by name. Returns undefined when the file is
144
+ * absent (the common "not a defined project, fall back to convention" case) but
145
+ * throws when a file EXISTS and is malformed — a broken definition is loud.
146
+ */
147
+ export function loadProjectDef(name) {
148
+ if (!isSafeProjectName(name))
149
+ return undefined;
150
+ let raw;
151
+ try {
152
+ raw = fs.readFileSync(projectDefPath(name), 'utf8');
153
+ }
154
+ catch {
155
+ return undefined; // absent — not a defined project
156
+ }
157
+ return validateProjectDef(yaml.parse(raw), name);
158
+ }
159
+ /**
160
+ * List every defined project, sorted by name. Skips (does not throw on) a
161
+ * malformed file so one bad definition can't break `projects list`; the loader
162
+ * for a single named project stays strict.
163
+ */
164
+ export function listProjectDefs() {
165
+ let files;
166
+ try {
167
+ // Definitions are `<name>.yaml` (what projectDefPath/loadProjectDef read). We
168
+ // deliberately do NOT list `.yml` here — accepting it would then ENOENT in the
169
+ // loader and silently drop the project. One extension, one code path.
170
+ files = fs.readdirSync(getProjectsDir()).filter((f) => f.endsWith('.yaml'));
171
+ }
172
+ catch {
173
+ return [];
174
+ }
175
+ const out = [];
176
+ for (const f of files) {
177
+ const name = f.replace(/\.yaml$/, '');
178
+ try {
179
+ const def = loadProjectDef(name);
180
+ if (def)
181
+ out.push(def);
182
+ }
183
+ catch {
184
+ /* malformed — skip in the listing */
185
+ }
186
+ }
187
+ return out.sort((a, b) => a.name.localeCompare(b.name));
188
+ }
189
+ /**
190
+ * Persist a project definition, normalizing `root`/`defaultPath` to home-relative
191
+ * so it stays portable across machines. Creates the projects dir on first write.
192
+ */
193
+ export function writeProjectDef(def) {
194
+ const validated = validateProjectDef(def, def.name);
195
+ const normalized = {
196
+ ...validated,
197
+ root: validated.root ? toHomeRelative(expandLocalHome(validated.root)) : undefined,
198
+ defaultPath: validated.defaultPath
199
+ ? toHomeRelative(expandLocalHome(validated.defaultPath))
200
+ : undefined,
201
+ repos: validated.repos?.map((r) => {
202
+ const repo = { ...r };
203
+ if (r.path)
204
+ repo.path = toHomeRelative(expandLocalHome(r.path));
205
+ return repo;
206
+ }),
207
+ };
208
+ // Drop undefined keys so the YAML stays clean.
209
+ const clean = Object.fromEntries(Object.entries(normalized).filter(([, v]) => v !== undefined));
210
+ const target = projectDefPath(def.name);
211
+ fs.mkdirSync(getProjectsDir(), { recursive: true });
212
+ fs.writeFileSync(target, yaml.stringify(clean), 'utf8');
213
+ return target;
214
+ }
215
+ /** Delete a project definition. Returns true if a file was removed. Never touches the repo. */
216
+ export function removeProjectDef(name) {
217
+ try {
218
+ fs.unlinkSync(projectDefPath(name));
219
+ return true;
220
+ }
221
+ catch {
222
+ return false;
223
+ }
224
+ }
225
+ /**
226
+ * The cwd an agent lands in for a defined project: `defaultPath` when set, else
227
+ * `root`. Home-relative when `forRemote` (the remote shell expands `~`), else
228
+ * expanded against the local home. Returns undefined when neither is set.
229
+ */
230
+ export function projectBasePath(def, forRemote) {
231
+ const base = def.defaultPath ?? def.root;
232
+ if (!base)
233
+ return undefined;
234
+ return forRemote ? base : expandLocalHome(base);
235
+ }
236
+ function projectRootsAbs(defs) {
237
+ const out = [];
238
+ for (const def of defs) {
239
+ const raw = def.root ?? def.defaultPath;
240
+ if (!raw)
241
+ continue;
242
+ out.push({ name: def.name, abs: path.resolve(expandLocalHome(raw)) });
243
+ }
244
+ return out;
245
+ }
246
+ /** True when `child` is `parent` or nested under it (path-segment aware). */
247
+ function isUnder(child, parent) {
248
+ if (child === parent)
249
+ return true;
250
+ const withSep = parent.endsWith(path.sep) ? parent : parent + path.sep;
251
+ return child.startsWith(withSep);
252
+ }
253
+ /**
254
+ * Which defined project a session belongs to, derived from its working
255
+ * directory. A session whose `cwd` sits inside a project's repo root (or a
256
+ * worktree under it) is a member; the LONGEST matching root wins so a nested
257
+ * project beats its parent. Returns undefined when no definition contains the
258
+ * path.
259
+ *
260
+ * The comparison is against the LOCAL home: roots and the cwd are both expanded
261
+ * with `expandLocalHome` and resolved, so this matches sessions whose cwd shares
262
+ * this machine's home layout. A session recorded on a different-home machine
263
+ * (`/Users/x/…` vs `/home/x/…`) will not match until the fleet-wide,
264
+ * home-relative variant lands (see the deferred item in docs/11-projects.md).
265
+ */
266
+ export function projectNameForCwd(cwd, defs) {
267
+ if (!cwd)
268
+ return undefined;
269
+ const abs = path.resolve(expandLocalHome(cwd));
270
+ let best;
271
+ let bestLen = -1;
272
+ for (const { name, abs: root } of projectRootsAbs(defs)) {
273
+ if (isUnder(abs, root) && root.length > bestLen) {
274
+ best = name;
275
+ bestLen = root.length;
276
+ }
277
+ }
278
+ return best;
279
+ }
280
+ /**
281
+ * The canonical project label for a cwd, for every surface that buckets work by
282
+ * project (the activity timeline, feed posts, the sessions overview): the
283
+ * DEFINED project whose root contains the cwd (longest root wins, so a
284
+ * multi-repo project reads as one bucket), else the repository-level key from
285
+ * {@link resolveProjectKey}. `defs` comes from {@link listProjectDefs}, which is
286
+ * fail-open — with no definitions this degrades to exactly today's behavior.
287
+ */
288
+ export function resolveProjectNameForCwd(cwd, defs) {
289
+ if (!cwd)
290
+ return undefined;
291
+ return projectNameForCwd(cwd, defs) ?? resolveProjectKey(cwd);
292
+ }
293
+ /**
294
+ * Resolve a defined project's ref to a working directory, mirroring
295
+ * `buildProjectPath`'s `forRemote` contract (a home-relative `~/…` for the
296
+ * remote shell to expand, an absolute local path otherwise). A `@worktree`
297
+ * lands under the repo ROOT's `.agents/worktrees/`, not the `defaultPath`
298
+ * subdir — worktrees are per-repo, not per-focus. Returns undefined when the
299
+ * definition carries no `root`/`defaultPath` (caller falls back to convention).
300
+ */
301
+ export function resolveDefinedProjectPath(def, worktree, forRemote) {
302
+ if (worktree) {
303
+ const rootRaw = def.root ?? def.defaultPath;
304
+ if (!rootRaw)
305
+ return undefined;
306
+ const wt = `${rootRaw}/.agents/worktrees/${worktree}`;
307
+ return forRemote ? wt : path.resolve(expandLocalHome(wt));
308
+ }
309
+ const base = projectBasePath(def, forRemote);
310
+ if (!base)
311
+ return undefined;
312
+ return forRemote ? base : path.resolve(base);
313
+ }
@@ -3,10 +3,19 @@ export interface RemoteAgentsJsonOptions<T> {
3
3
  noFanoutEnv: string;
4
4
  hosts?: string[];
5
5
  parse: (stdout: string, machine: string) => T[];
6
+ /**
7
+ * Suppress the per-device "unreachable — skipped" stderr line. The skipped
8
+ * names still come back in {@link RemoteAgentsJsonResult.skipped}, so a caller
9
+ * that fans out by DEFAULT can report them once, compactly, instead of
10
+ * printing a line per offline box above its output. Never a silent drop.
11
+ */
12
+ quiet?: boolean;
6
13
  }
7
14
  export interface RemoteAgentsJsonResult<T> {
8
15
  items: T[];
9
16
  deviceCount: number;
17
+ /** Devices that were dialed but answered with an error / no CLI / a timeout. */
18
+ skipped: string[];
10
19
  }
11
20
  /** Build the command one peer runs, with a guard that prevents recursive fan-out. */
12
21
  export declare function remoteAgentsJsonCommand(args: string[], noFanoutEnv: string, os?: string): string;
@@ -11,7 +11,7 @@ import chalk from 'chalk';
11
11
  import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from './ssh-exec.js';
12
12
  import { sshTargetFor } from './devices/connect.js';
13
13
  import { resolveExplicitTargets } from './devices/resolve-target.js';
14
- import { loadDevices, isControlDevice } from './devices/registry.js';
14
+ import { loadDevices, isControlDevice, isDialableDevice } from './devices/registry.js';
15
15
  import { remoteShellFor, buildWindowsAgentsCommand } from './hosts/remote-cmd.js';
16
16
  import { machineId, normalizeHost } from './machine-id.js';
17
17
  const REMOTE_TIMEOUT_MS = 12_000;
@@ -59,10 +59,12 @@ export async function gatherRemoteAgentsJson(options) {
59
59
  devices = await loadDevices();
60
60
  }
61
61
  catch {
62
- return { items: [], deviceCount: 0 };
62
+ return { items: [], deviceCount: 0, skipped: [] };
63
63
  }
64
64
  for (const device of Object.values(devices)) {
65
- if (device.tailscale?.online !== true)
65
+ // Live SSH-probe verdict first, cached tailscale snapshot only as a
66
+ // fallback — see isDialableDevice (mirrors session/remote-list.ts).
67
+ if (!isDialableDevice(device))
66
68
  continue;
67
69
  if (normalizeHost(device.name) === self)
68
70
  continue;
@@ -87,14 +89,18 @@ export async function gatherRemoteAgentsJson(options) {
87
89
  }
88
90
  }
89
91
  }
92
+ const skipped = [];
90
93
  const results = await Promise.all(targets.map(async (target) => {
91
94
  const command = remoteAgentsJsonCommand(options.args, options.noFanoutEnv, target.os);
92
95
  const result = await sshCapture(target.target, command);
93
96
  if (result.code !== 0) {
94
- process.stderr.write(chalk.gray(` ${target.name}: unreachable or no agents CLI — skipped\n`));
97
+ skipped.push(target.name);
98
+ if (!options.quiet) {
99
+ process.stderr.write(chalk.gray(` ${target.name}: unreachable or no agents CLI — skipped\n`));
100
+ }
95
101
  return [];
96
102
  }
97
103
  return options.parse(result.stdout, target.machine);
98
104
  }));
99
- return { items: results.flat(), deviceCount: targets.length };
105
+ return { items: results.flat(), deviceCount: targets.length, skipped };
100
106
  }
@@ -26,6 +26,17 @@ import { type DesktopNotification } from './menubar/notify-desktop.js';
26
26
  type RoutineKind = 'agent' | 'workflow' | 'command';
27
27
  /** Which flavor of routine a config/meta describes — drives the notify threshold. */
28
28
  export declare function routineKind(r: Pick<JobConfig, 'agent' | 'workflow' | 'command'>): RoutineKind;
29
+ /**
30
+ * The harness a routine runs on, for the notification's right-hand avatar, or
31
+ * undefined when none owns it. A command routine is deterministic housekeeping
32
+ * with no agent, so it gets no avatar. An agent routine names its own harness.
33
+ * A workflow routine has no `agent` field (the schema omits it — routines.ts
34
+ * `JobConfig.agent` and the validation that rejects setting both), and it runs
35
+ * via `agents run <workflow>`, which delegates to claude under the hood — so its
36
+ * avatar is the Claude mark, matching `effectiveAgent` on the finish path
37
+ * (runner.ts). Start and finish banners therefore show the same avatar.
38
+ */
39
+ export declare function routineAgent(r: Pick<JobConfig, 'agent' | 'workflow' | 'command'>): string | undefined;
29
40
  /** "1m 20s" / "45s" / "2h 3m" from a millisecond duration, or null when unknown. */
30
41
  export declare function formatDuration(ms: number | undefined): string | null;
31
42
  /**
@@ -33,6 +33,24 @@ export function routineKind(r) {
33
33
  return 'workflow';
34
34
  return 'agent';
35
35
  }
36
+ /**
37
+ * The harness a routine runs on, for the notification's right-hand avatar, or
38
+ * undefined when none owns it. A command routine is deterministic housekeeping
39
+ * with no agent, so it gets no avatar. An agent routine names its own harness.
40
+ * A workflow routine has no `agent` field (the schema omits it — routines.ts
41
+ * `JobConfig.agent` and the validation that rejects setting both), and it runs
42
+ * via `agents run <workflow>`, which delegates to claude under the hood — so its
43
+ * avatar is the Claude mark, matching `effectiveAgent` on the finish path
44
+ * (runner.ts). Start and finish banners therefore show the same avatar.
45
+ */
46
+ export function routineAgent(r) {
47
+ const kind = routineKind(r);
48
+ if (kind === 'command')
49
+ return undefined;
50
+ if (kind === 'workflow')
51
+ return 'claude';
52
+ return r.agent?.trim() || undefined;
53
+ }
36
54
  /** Human label for the routine body ("agent claude", "workflow deploy", "command"). */
37
55
  function routineLabel(r) {
38
56
  if (r.command)
@@ -90,6 +108,7 @@ export function routineStartNotification(config) {
90
108
  subtitle: config.name,
91
109
  body: `Running ${routineLabel(config)}`,
92
110
  action: 'routines:list',
111
+ agent: routineAgent(config),
93
112
  };
94
113
  }
95
114
  /**
@@ -108,6 +127,7 @@ export function routineStartFailedNotification(config, error) {
108
127
  subtitle: config.name,
109
128
  body: `Failed to start: ${error}`,
110
129
  action: 'routines:list',
130
+ agent: routineAgent(config),
111
131
  };
112
132
  }
113
133
  /**
@@ -131,6 +151,7 @@ export function routineFinishNotification(meta, opts = {}) {
131
151
  subtitle: meta.jobName,
132
152
  body: snippet ?? (dur ? `Completed in ${dur}` : 'Completed'),
133
153
  action,
154
+ agent: routineAgent(meta),
134
155
  };
135
156
  }
136
157
  // failed | timeout
@@ -144,6 +165,7 @@ export function routineFinishNotification(meta, opts = {}) {
144
165
  subtitle: meta.jobName,
145
166
  body: reason,
146
167
  action,
168
+ agent: routineAgent(meta),
147
169
  };
148
170
  }
149
171
  /** Read a finished run's report text + the best artifact to open on click. */
@@ -34,6 +34,9 @@ export function buildRunFinishNotification(ctx, exitCode) {
34
34
  const n = {
35
35
  title: exitCode === 0 ? `${label} finished` : `${label} failed`,
36
36
  body: shorten(ctx.prompt?.trim() || `${ctx.agent} run`),
37
+ // The harness that ran becomes the banner's right-hand avatar, so a finished
38
+ // run is identifiable at a glance even when `--name` renamed the title.
39
+ agent: ctx.agent,
37
40
  };
38
41
  if (where)
39
42
  n.subtitle = where;
@@ -1,4 +1,4 @@
1
- export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked';
1
+ export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view';
2
2
  export interface SecretAuditParams {
3
3
  /** Which audit event this is — a read (`secrets.get`) or an unlock grant. */
4
4
  event: SecretAuditEvent;