@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
@@ -0,0 +1,301 @@
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
+ const repo = { slug: rr.slug };
90
+ if (typeof rr.subpath === 'string')
91
+ repo.subpath = rr.subpath;
92
+ return [repo];
93
+ }
94
+ return [];
95
+ });
96
+ }
97
+ if (Array.isArray(o.contexts)) {
98
+ def.contexts = o.contexts.flatMap((c) => {
99
+ if (c &&
100
+ typeof c === 'object' &&
101
+ typeof c.path === 'string' &&
102
+ typeof c.purpose === 'string') {
103
+ const cc = c;
104
+ return [{ path: cc.path, purpose: cc.purpose }];
105
+ }
106
+ return [];
107
+ });
108
+ }
109
+ if (Array.isArray(o.integrations)) {
110
+ def.integrations = o.integrations.flatMap((i) => {
111
+ if (i &&
112
+ typeof i === 'object' &&
113
+ typeof i.kind === 'string' &&
114
+ typeof i.url === 'string') {
115
+ const ii = i;
116
+ const integ = { kind: ii.kind, url: ii.url };
117
+ if (typeof ii.label === 'string')
118
+ integ.label = ii.label;
119
+ return [integ];
120
+ }
121
+ return [];
122
+ });
123
+ }
124
+ if (o.linear && typeof o.linear === 'object' && !Array.isArray(o.linear)) {
125
+ const l = o.linear;
126
+ def.linear = {};
127
+ if (typeof l.projectId === 'string')
128
+ def.linear.projectId = l.projectId;
129
+ if (typeof l.url === 'string')
130
+ def.linear.url = l.url;
131
+ }
132
+ if (Array.isArray(o.docs))
133
+ def.docs = o.docs.filter((d) => typeof d === 'string');
134
+ return def;
135
+ }
136
+ /**
137
+ * Load a single project definition by name. Returns undefined when the file is
138
+ * absent (the common "not a defined project, fall back to convention" case) but
139
+ * throws when a file EXISTS and is malformed — a broken definition is loud.
140
+ */
141
+ export function loadProjectDef(name) {
142
+ if (!isSafeProjectName(name))
143
+ return undefined;
144
+ let raw;
145
+ try {
146
+ raw = fs.readFileSync(projectDefPath(name), 'utf8');
147
+ }
148
+ catch {
149
+ return undefined; // absent — not a defined project
150
+ }
151
+ return validateProjectDef(yaml.parse(raw), name);
152
+ }
153
+ /**
154
+ * List every defined project, sorted by name. Skips (does not throw on) a
155
+ * malformed file so one bad definition can't break `projects list`; the loader
156
+ * for a single named project stays strict.
157
+ */
158
+ export function listProjectDefs() {
159
+ let files;
160
+ try {
161
+ // Definitions are `<name>.yaml` (what projectDefPath/loadProjectDef read). We
162
+ // deliberately do NOT list `.yml` here — accepting it would then ENOENT in the
163
+ // loader and silently drop the project. One extension, one code path.
164
+ files = fs.readdirSync(getProjectsDir()).filter((f) => f.endsWith('.yaml'));
165
+ }
166
+ catch {
167
+ return [];
168
+ }
169
+ const out = [];
170
+ for (const f of files) {
171
+ const name = f.replace(/\.yaml$/, '');
172
+ try {
173
+ const def = loadProjectDef(name);
174
+ if (def)
175
+ out.push(def);
176
+ }
177
+ catch {
178
+ /* malformed — skip in the listing */
179
+ }
180
+ }
181
+ return out.sort((a, b) => a.name.localeCompare(b.name));
182
+ }
183
+ /**
184
+ * Persist a project definition, normalizing `root`/`defaultPath` to home-relative
185
+ * so it stays portable across machines. Creates the projects dir on first write.
186
+ */
187
+ export function writeProjectDef(def) {
188
+ const validated = validateProjectDef(def, def.name);
189
+ const normalized = {
190
+ ...validated,
191
+ root: validated.root ? toHomeRelative(expandLocalHome(validated.root)) : undefined,
192
+ defaultPath: validated.defaultPath
193
+ ? toHomeRelative(expandLocalHome(validated.defaultPath))
194
+ : undefined,
195
+ };
196
+ // Drop undefined keys so the YAML stays clean.
197
+ const clean = Object.fromEntries(Object.entries(normalized).filter(([, v]) => v !== undefined));
198
+ const target = projectDefPath(def.name);
199
+ fs.mkdirSync(getProjectsDir(), { recursive: true });
200
+ fs.writeFileSync(target, yaml.stringify(clean), 'utf8');
201
+ return target;
202
+ }
203
+ /** Delete a project definition. Returns true if a file was removed. Never touches the repo. */
204
+ export function removeProjectDef(name) {
205
+ try {
206
+ fs.unlinkSync(projectDefPath(name));
207
+ return true;
208
+ }
209
+ catch {
210
+ return false;
211
+ }
212
+ }
213
+ /**
214
+ * The cwd an agent lands in for a defined project: `defaultPath` when set, else
215
+ * `root`. Home-relative when `forRemote` (the remote shell expands `~`), else
216
+ * expanded against the local home. Returns undefined when neither is set.
217
+ */
218
+ export function projectBasePath(def, forRemote) {
219
+ const base = def.defaultPath ?? def.root;
220
+ if (!base)
221
+ return undefined;
222
+ return forRemote ? base : expandLocalHome(base);
223
+ }
224
+ function projectRootsAbs(defs) {
225
+ const out = [];
226
+ for (const def of defs) {
227
+ const raw = def.root ?? def.defaultPath;
228
+ if (!raw)
229
+ continue;
230
+ out.push({ name: def.name, abs: path.resolve(expandLocalHome(raw)) });
231
+ }
232
+ return out;
233
+ }
234
+ /** True when `child` is `parent` or nested under it (path-segment aware). */
235
+ function isUnder(child, parent) {
236
+ if (child === parent)
237
+ return true;
238
+ const withSep = parent.endsWith(path.sep) ? parent : parent + path.sep;
239
+ return child.startsWith(withSep);
240
+ }
241
+ /**
242
+ * Which defined project a session belongs to, derived from its working
243
+ * directory. A session whose `cwd` sits inside a project's repo root (or a
244
+ * worktree under it) is a member; the LONGEST matching root wins so a nested
245
+ * project beats its parent. Returns undefined when no definition contains the
246
+ * path.
247
+ *
248
+ * The comparison is against the LOCAL home: roots and the cwd are both expanded
249
+ * with `expandLocalHome` and resolved, so this matches sessions whose cwd shares
250
+ * this machine's home layout. A session recorded on a different-home machine
251
+ * (`/Users/x/…` vs `/home/x/…`) will not match until the fleet-wide,
252
+ * home-relative variant lands (see the deferred item in docs/11-projects.md).
253
+ */
254
+ export function projectNameForCwd(cwd, defs) {
255
+ if (!cwd)
256
+ return undefined;
257
+ const abs = path.resolve(expandLocalHome(cwd));
258
+ let best;
259
+ let bestLen = -1;
260
+ for (const { name, abs: root } of projectRootsAbs(defs)) {
261
+ if (isUnder(abs, root) && root.length > bestLen) {
262
+ best = name;
263
+ bestLen = root.length;
264
+ }
265
+ }
266
+ return best;
267
+ }
268
+ /**
269
+ * The canonical project label for a cwd, for every surface that buckets work by
270
+ * project (the activity timeline, feed posts, the sessions overview): the
271
+ * DEFINED project whose root contains the cwd (longest root wins, so a
272
+ * multi-repo project reads as one bucket), else the repository-level key from
273
+ * {@link resolveProjectKey}. `defs` comes from {@link listProjectDefs}, which is
274
+ * fail-open — with no definitions this degrades to exactly today's behavior.
275
+ */
276
+ export function resolveProjectNameForCwd(cwd, defs) {
277
+ if (!cwd)
278
+ return undefined;
279
+ return projectNameForCwd(cwd, defs) ?? resolveProjectKey(cwd);
280
+ }
281
+ /**
282
+ * Resolve a defined project's ref to a working directory, mirroring
283
+ * `buildProjectPath`'s `forRemote` contract (a home-relative `~/…` for the
284
+ * remote shell to expand, an absolute local path otherwise). A `@worktree`
285
+ * lands under the repo ROOT's `.agents/worktrees/`, not the `defaultPath`
286
+ * subdir — worktrees are per-repo, not per-focus. Returns undefined when the
287
+ * definition carries no `root`/`defaultPath` (caller falls back to convention).
288
+ */
289
+ export function resolveDefinedProjectPath(def, worktree, forRemote) {
290
+ if (worktree) {
291
+ const rootRaw = def.root ?? def.defaultPath;
292
+ if (!rootRaw)
293
+ return undefined;
294
+ const wt = `${rootRaw}/.agents/worktrees/${worktree}`;
295
+ return forRemote ? wt : path.resolve(expandLocalHome(wt));
296
+ }
297
+ const base = projectBasePath(def, forRemote);
298
+ if (!base)
299
+ return undefined;
300
+ return forRemote ? base : path.resolve(base);
301
+ }
@@ -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
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Parses the raw command strings agents pass to Bash tool calls into structured
3
+ * metadata: the executable, category, subcommand, and a display summary. Used by
4
+ * session rendering and the activity-log hook so `agents sessions` and
5
+ * `agents activity` can summarize what actually happened instead of printing a
6
+ * wall of shell.
7
+ */
8
+ export type BashCategory = 'vcs' | 'build-test' | 'install' | 'remote' | 'http' | 'media' | 'upscaling' | 'metadata' | 'probe' | 'search' | 'shell' | 'wait' | 'other';
9
+ export interface BashToolInfo {
10
+ category: BashCategory;
11
+ signal: 'high' | 'mid' | 'low';
12
+ action: string;
13
+ aliases?: string[];
14
+ }
15
+ export interface BashCommandInfo {
16
+ tool: string;
17
+ category: BashCategory;
18
+ subcommand: string;
19
+ action: string;
20
+ summary: string;
21
+ signal: 'high' | 'mid' | 'low';
22
+ }
23
+ export declare function unwrapCommand(cmd: string): string;
24
+ /**
25
+ * Split a possibly-compound Bash command into simple commands, then tokenize each
26
+ * with shlex. Returns an empty array for empty input.
27
+ */
28
+ export declare function tokenizeBash(cmd: string): string[][];
29
+ /**
30
+ * Classify the first simple command in a Bash string. Returns coarse metadata
31
+ * (tool name, category, subcommand, human action) used for summaries and
32
+ * activity logging. Unknown executables fall back to `other`.
33
+ */
34
+ export declare function classifyBashCommand(command: string): BashCommandInfo;
35
+ /**
36
+ * Stable bucket key for grouping similar Bash commands in summaries. Commands run
37
+ * through a remote wrapper (ssh/scp/rsync) get an `ssh→` prefix on the inner key,
38
+ * so `ssh host "git push"` buckets as `ssh→git push`, distinct from a local push.
39
+ */
40
+ export declare function bucketKey(command: string): string;
41
+ /**
42
+ * Detect high-signal Bash-driven milestones (video renders, upscales, metadata
43
+ * edits, git commits/pushes/worktrees, PR opens). Returns null for routine
44
+ * commands.
45
+ */
46
+ export declare function detectBashMilestone(command: string): {
47
+ event: string;
48
+ detail: string;
49
+ } | null;
50
+ /**
51
+ * Human-readable category label for renderers.
52
+ */
53
+ export declare function categoryLabel(category: BashCategory): string;