@phnx-labs/agents-cli 1.20.73 → 1.20.76

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 (209) hide show
  1. package/CHANGELOG.md +989 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +31 -1
  13. package/dist/commands/exec.js +31 -3
  14. package/dist/commands/export.d.ts +11 -0
  15. package/dist/commands/export.js +215 -0
  16. package/dist/commands/feed.d.ts +7 -1
  17. package/dist/commands/feed.js +47 -4
  18. package/dist/commands/go.d.ts +9 -2
  19. package/dist/commands/go.js +16 -6
  20. package/dist/commands/harness.d.ts +14 -0
  21. package/dist/commands/harness.js +145 -0
  22. package/dist/commands/import.js +87 -9
  23. package/dist/commands/inspect.js +5 -2
  24. package/dist/commands/profiles.d.ts +20 -0
  25. package/dist/commands/profiles.js +68 -21
  26. package/dist/commands/repo.js +14 -0
  27. package/dist/commands/routines.js +15 -2
  28. package/dist/commands/secrets.js +44 -21
  29. package/dist/commands/sessions-browser.js +1 -1
  30. package/dist/commands/sessions-export.js +19 -5
  31. package/dist/commands/sessions-migrate.d.ts +29 -0
  32. package/dist/commands/sessions-migrate.js +596 -0
  33. package/dist/commands/sessions-picker.js +16 -1
  34. package/dist/commands/sessions.d.ts +36 -13
  35. package/dist/commands/sessions.js +210 -40
  36. package/dist/commands/setup-computer.js +2 -2
  37. package/dist/commands/setup.js +6 -1
  38. package/dist/commands/ssh.js +71 -35
  39. package/dist/commands/status.js +10 -2
  40. package/dist/commands/versions.js +51 -11
  41. package/dist/commands/view.d.ts +5 -0
  42. package/dist/commands/view.js +99 -31
  43. package/dist/commands/watchdog.d.ts +7 -2
  44. package/dist/commands/watchdog.js +73 -57
  45. package/dist/index.js +59 -7
  46. package/dist/lib/activity.d.ts +14 -4
  47. package/dist/lib/activity.js +17 -5
  48. package/dist/lib/actor.d.ts +50 -0
  49. package/dist/lib/actor.js +166 -0
  50. package/dist/lib/agent-spec/provider.js +2 -1
  51. package/dist/lib/agent-spec/resolve.js +19 -5
  52. package/dist/lib/agent-spec/types.d.ts +9 -1
  53. package/dist/lib/agents.d.ts +56 -0
  54. package/dist/lib/agents.js +133 -7
  55. package/dist/lib/cloud/codex.d.ts +2 -0
  56. package/dist/lib/cloud/codex.js +14 -3
  57. package/dist/lib/cloud/session-index.d.ts +32 -0
  58. package/dist/lib/cloud/session-index.js +58 -0
  59. package/dist/lib/cloud/store.d.ts +7 -0
  60. package/dist/lib/cloud/store.js +25 -0
  61. package/dist/lib/config-transfer.d.ts +16 -0
  62. package/dist/lib/config-transfer.js +60 -0
  63. package/dist/lib/daemon.js +18 -2
  64. package/dist/lib/devices/connect.d.ts +15 -1
  65. package/dist/lib/devices/connect.js +15 -1
  66. package/dist/lib/devices/fleet.d.ts +21 -0
  67. package/dist/lib/devices/fleet.js +26 -0
  68. package/dist/lib/devices/health-report.d.ts +38 -0
  69. package/dist/lib/devices/health-report.js +214 -0
  70. package/dist/lib/devices/health.js +4 -1
  71. package/dist/lib/devices/reachability.d.ts +31 -0
  72. package/dist/lib/devices/reachability.js +40 -0
  73. package/dist/lib/devices/registry.d.ts +33 -0
  74. package/dist/lib/devices/registry.js +37 -0
  75. package/dist/lib/devices/resolve-target.d.ts +15 -27
  76. package/dist/lib/devices/resolve-target.js +63 -102
  77. package/dist/lib/devices/sync.d.ts +18 -0
  78. package/dist/lib/devices/sync.js +23 -1
  79. package/dist/lib/devices/tailscale.d.ts +3 -0
  80. package/dist/lib/devices/tailscale.js +1 -0
  81. package/dist/lib/events.d.ts +1 -1
  82. package/dist/lib/exec.d.ts +66 -0
  83. package/dist/lib/exec.js +195 -6
  84. package/dist/lib/export.d.ts +72 -0
  85. package/dist/lib/export.js +269 -0
  86. package/dist/lib/feed-post.d.ts +63 -0
  87. package/dist/lib/feed-post.js +204 -0
  88. package/dist/lib/fleet/apply.d.ts +32 -2
  89. package/dist/lib/fleet/apply.js +97 -10
  90. package/dist/lib/fleet/types.d.ts +11 -0
  91. package/dist/lib/fs-walk.d.ts +13 -0
  92. package/dist/lib/fs-walk.js +16 -7
  93. package/dist/lib/hooks.d.ts +1 -0
  94. package/dist/lib/hooks.js +156 -0
  95. package/dist/lib/hosts/dispatch.d.ts +7 -0
  96. package/dist/lib/hosts/dispatch.js +2 -0
  97. package/dist/lib/hosts/passthrough.js +2 -0
  98. package/dist/lib/hosts/reconnect.d.ts +78 -0
  99. package/dist/lib/hosts/reconnect.js +127 -0
  100. package/dist/lib/hosts/registry.d.ts +75 -14
  101. package/dist/lib/hosts/registry.js +205 -30
  102. package/dist/lib/hosts/remote-cmd.js +1 -0
  103. package/dist/lib/hosts/run-target.d.ts +13 -5
  104. package/dist/lib/hosts/run-target.js +26 -10
  105. package/dist/lib/hosts/session-index.d.ts +18 -1
  106. package/dist/lib/hosts/session-index.js +37 -2
  107. package/dist/lib/hosts/session-marker.d.ts +33 -0
  108. package/dist/lib/hosts/session-marker.js +51 -0
  109. package/dist/lib/hosts/tasks.d.ts +8 -4
  110. package/dist/lib/import.d.ts +25 -0
  111. package/dist/lib/import.js +107 -2
  112. package/dist/lib/isolation-boundary-report.d.ts +10 -0
  113. package/dist/lib/isolation-boundary-report.js +35 -0
  114. package/dist/lib/menubar/install-menubar.d.ts +20 -0
  115. package/dist/lib/menubar/install-menubar.js +45 -1
  116. package/dist/lib/migrate.d.ts +16 -0
  117. package/dist/lib/migrate.js +36 -0
  118. package/dist/lib/models.d.ts +27 -0
  119. package/dist/lib/models.js +54 -1
  120. package/dist/lib/overdue.d.ts +7 -2
  121. package/dist/lib/overdue.js +18 -11
  122. package/dist/lib/picker.js +4 -1
  123. package/dist/lib/platform/process.d.ts +17 -0
  124. package/dist/lib/platform/process.js +70 -0
  125. package/dist/lib/profiles-presets.js +9 -7
  126. package/dist/lib/profiles.d.ts +31 -0
  127. package/dist/lib/profiles.js +70 -0
  128. package/dist/lib/project-resources.js +20 -5
  129. package/dist/lib/pty-server.d.ts +2 -10
  130. package/dist/lib/pty-server.js +4 -38
  131. package/dist/lib/registry.d.ts +1 -1
  132. package/dist/lib/registry.js +48 -8
  133. package/dist/lib/rotate.d.ts +18 -0
  134. package/dist/lib/rotate.js +28 -0
  135. package/dist/lib/routines.d.ts +26 -0
  136. package/dist/lib/routines.js +19 -2
  137. package/dist/lib/runner.js +104 -7
  138. package/dist/lib/secrets/agent.d.ts +66 -1
  139. package/dist/lib/secrets/agent.js +154 -90
  140. package/dist/lib/secrets/bundles.d.ts +34 -0
  141. package/dist/lib/secrets/bundles.js +179 -19
  142. package/dist/lib/secrets/index.d.ts +15 -1
  143. package/dist/lib/secrets/index.js +21 -3
  144. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  145. package/dist/lib/secrets/rc-hygiene.js +154 -0
  146. package/dist/lib/secrets/remote.d.ts +7 -4
  147. package/dist/lib/secrets/remote.js +7 -4
  148. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  149. package/dist/lib/secrets/sync-commands.js +21 -0
  150. package/dist/lib/self-update.d.ts +23 -2
  151. package/dist/lib/self-update.js +86 -5
  152. package/dist/lib/session/active.d.ts +114 -7
  153. package/dist/lib/session/active.js +323 -52
  154. package/dist/lib/session/cloud.js +2 -1
  155. package/dist/lib/session/db.d.ts +48 -0
  156. package/dist/lib/session/db.js +238 -14
  157. package/dist/lib/session/detached.d.ts +30 -0
  158. package/dist/lib/session/detached.js +92 -0
  159. package/dist/lib/session/discover.d.ts +465 -2
  160. package/dist/lib/session/discover.js +1648 -468
  161. package/dist/lib/session/fork.js +2 -1
  162. package/dist/lib/session/hook-sessions.d.ts +43 -0
  163. package/dist/lib/session/hook-sessions.js +135 -0
  164. package/dist/lib/session/linear.d.ts +6 -0
  165. package/dist/lib/session/linear.js +55 -0
  166. package/dist/lib/session/migrate-targets.d.ts +65 -0
  167. package/dist/lib/session/migrate-targets.js +94 -0
  168. package/dist/lib/session/migrations.d.ts +37 -0
  169. package/dist/lib/session/migrations.js +60 -0
  170. package/dist/lib/session/parse.d.ts +18 -0
  171. package/dist/lib/session/parse.js +130 -30
  172. package/dist/lib/session/pid-registry.d.ts +34 -2
  173. package/dist/lib/session/pid-registry.js +49 -2
  174. package/dist/lib/session/render.d.ts +7 -2
  175. package/dist/lib/session/render.js +22 -10
  176. package/dist/lib/session/short-id.d.ts +17 -0
  177. package/dist/lib/session/short-id.js +20 -0
  178. package/dist/lib/session/state.js +6 -2
  179. package/dist/lib/shims.d.ts +27 -1
  180. package/dist/lib/shims.js +88 -6
  181. package/dist/lib/sqlite.d.ts +3 -2
  182. package/dist/lib/sqlite.js +27 -4
  183. package/dist/lib/staleness/detectors/commands.js +1 -1
  184. package/dist/lib/staleness/detectors/workflows.js +13 -2
  185. package/dist/lib/staleness/writers/commands.js +7 -7
  186. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  187. package/dist/lib/startup/command-registry.d.ts +2 -0
  188. package/dist/lib/startup/command-registry.js +5 -0
  189. package/dist/lib/state.d.ts +1 -1
  190. package/dist/lib/state.js +15 -36
  191. package/dist/lib/teams/agents.d.ts +19 -10
  192. package/dist/lib/teams/agents.js +40 -39
  193. package/dist/lib/types.d.ts +48 -2
  194. package/dist/lib/uninstall.js +1 -45
  195. package/dist/lib/usage.d.ts +33 -1
  196. package/dist/lib/usage.js +172 -12
  197. package/dist/lib/versions.d.ts +22 -2
  198. package/dist/lib/versions.js +98 -10
  199. package/dist/lib/watchdog/log.d.ts +43 -0
  200. package/dist/lib/watchdog/log.js +69 -0
  201. package/dist/lib/watchdog/routine.d.ts +44 -0
  202. package/dist/lib/watchdog/routine.js +69 -0
  203. package/dist/lib/watchdog/runner.d.ts +51 -7
  204. package/dist/lib/watchdog/runner.js +239 -64
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +31 -16
  207. package/dist/lib/workflows.d.ts +16 -0
  208. package/dist/lib/workflows.js +110 -1
  209. package/package.json +1 -1
@@ -0,0 +1,63 @@
1
+ import { type ActivityEvent } from './activity.js';
2
+ import { type PidSessionEntry } from './session/pid-registry.js';
3
+ /** Soft cap so a runaway agent can't flood the activity lane with essays. */
4
+ export declare const STATUS_POST_MAX_CHARS = 500;
5
+ export interface FeedPostInput {
6
+ /** Human-readable progress text (required). Domain-agnostic free text. */
7
+ text: string;
8
+ /** Override session id (escape hatch for scripts/tests). Prefer auto-resolve. */
9
+ sessionId?: string;
10
+ /** Override activity root (tests). */
11
+ activityRoot?: string;
12
+ /** Override env for identity resolution (tests). */
13
+ env?: NodeJS.ProcessEnv;
14
+ /** Override cwd stamp (defaults to process.cwd() / env). */
15
+ cwd?: string;
16
+ /** Fixed timestamp (tests). */
17
+ ts?: string;
18
+ /**
19
+ * Starting pid for registry ancestor walk (defaults to process.ppid so the
20
+ * walk begins at the parent of this CLI process — usually the agent/shell).
21
+ * Tests inject a fake pid here.
22
+ */
23
+ startPid?: number;
24
+ /** Override parent-pid lookup (tests). */
25
+ getParentPid?: (pid: number) => number | undefined;
26
+ /** Override registry read (tests). */
27
+ readEntry?: (pid: number) => PidSessionEntry | undefined;
28
+ /** Override full registry list for launchId match (tests). */
29
+ listEntries?: () => PidSessionEntry[];
30
+ }
31
+ export interface FeedPostResult {
32
+ event: ActivityEvent;
33
+ }
34
+ export interface PostIdentity {
35
+ sessionId: string;
36
+ mailboxId: string;
37
+ host: string;
38
+ runtime: string;
39
+ agent?: string;
40
+ cwd?: string;
41
+ pid?: number;
42
+ launchId?: string;
43
+ terminalId?: string;
44
+ tmuxPane?: string;
45
+ }
46
+ /**
47
+ * Resolve who is posting. Order:
48
+ * 1. Explicit --session flag
49
+ * 2. Env: AGENT_SESSION_ID / AGENTS_SESSION_ID / basename(AGENTS_MAILBOX_DIR)
50
+ * 3. Env AGENT_LAUNCH_ID → match in pid registry
51
+ * 4. Walk parent PIDs from startPid (default process.ppid) through by-pid registry
52
+ */
53
+ export declare function resolvePostIdentity(input: Pick<FeedPostInput, 'sessionId' | 'env' | 'cwd' | 'startPid' | 'getParentPid' | 'readEntry' | 'listEntries'>): PostIdentity | undefined;
54
+ /** Walk up to 16 ancestors looking for a by-pid registry entry with a session. */
55
+ export declare function walkPidRegistry(startPid: number, getParent: (pid: number) => number | undefined, readEntry: (pid: number) => PidSessionEntry | undefined): PidSessionEntry | undefined;
56
+ /** Best-effort parent pid of `pid` (Linux /proc, else `ps`). */
57
+ export declare function parentPidOf(pid: number): number | undefined;
58
+ export declare function normalizeStatusText(text: string): string;
59
+ /**
60
+ * Append a `status.posted` milestone for the calling agent.
61
+ * Throws if text is empty or session identity cannot be resolved.
62
+ */
63
+ export declare function postFeedStatus(input: FeedPostInput): FeedPostResult;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Agent status posts — deliberate progress messages into the activity stream.
3
+ *
4
+ * Surface: `agents feed post <text>` (agent-callable; humans watch via
5
+ * `agents feed` / `agents activity` / `agents events --module activity`).
6
+ *
7
+ * Identity is automatic: session id, agent, cwd, launch/pid/tmux provenance
8
+ * are resolved from the process environment and the per-pid launch registry
9
+ * (`lib/session/pid-registry.ts`). The agent only authors free-form text —
10
+ * no domain-specific flags (tickets, URLs, tracks).
11
+ *
12
+ * Storage: append-only activity log as a `status.posted` milestone. Does NOT
13
+ * open a feed block (blocks remain "needs you" state only).
14
+ */
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import { spawnSync } from 'child_process';
18
+ import { appendActivityEvent, } from './activity.js';
19
+ import { machineId } from './machine-id.js';
20
+ import { isValidMailboxId } from './mailbox.js';
21
+ import { listPidSessionEntries, readPidSessionEntry, } from './session/pid-registry.js';
22
+ /** Soft cap so a runaway agent can't flood the activity lane with essays. */
23
+ export const STATUS_POST_MAX_CHARS = 500;
24
+ /**
25
+ * Resolve who is posting. Order:
26
+ * 1. Explicit --session flag
27
+ * 2. Env: AGENT_SESSION_ID / AGENTS_SESSION_ID / basename(AGENTS_MAILBOX_DIR)
28
+ * 3. Env AGENT_LAUNCH_ID → match in pid registry
29
+ * 4. Walk parent PIDs from startPid (default process.ppid) through by-pid registry
30
+ */
31
+ export function resolvePostIdentity(input) {
32
+ const env = input.env ?? process.env;
33
+ const readEntry = input.readEntry ?? readPidSessionEntry;
34
+ const listEntries = input.listEntries ?? listPidSessionEntries;
35
+ const getParent = input.getParentPid ?? parentPidOf;
36
+ const envSession = firstValidId([
37
+ input.sessionId,
38
+ env.AGENT_SESSION_ID,
39
+ env.AGENTS_SESSION_ID,
40
+ mailboxIdFromEnv(env),
41
+ ]);
42
+ const launchId = env.AGENT_LAUNCH_ID?.trim() || undefined;
43
+ let registry;
44
+ if (launchId) {
45
+ registry = listEntries().find((e) => e.launchId === launchId);
46
+ }
47
+ if (!registry) {
48
+ const start = input.startPid ?? (typeof process.ppid === 'number' ? process.ppid : undefined);
49
+ if (start && start > 1) {
50
+ registry = walkPidRegistry(start, getParent, readEntry);
51
+ }
52
+ }
53
+ // Prefer env session (explicit + managed run), fill gaps from registry.
54
+ const sessionId = envSession ?? registry?.sessionId;
55
+ if (!sessionId || !isValidMailboxId(sessionId))
56
+ return undefined;
57
+ const mailboxFromEnv = mailboxIdFromEnv(env);
58
+ const mailboxId = mailboxFromEnv && isValidMailboxId(mailboxFromEnv)
59
+ ? mailboxFromEnv
60
+ : sessionId;
61
+ return {
62
+ sessionId,
63
+ mailboxId,
64
+ host: machineIdFromEnv(env),
65
+ runtime: env.AGENTS_RUNTIME?.trim() || 'headless',
66
+ agent: env.AGENTS_AGENT_NAME?.trim()
67
+ || registry?.agent
68
+ || detectAgentKind(env),
69
+ cwd: input.cwd
70
+ ?? (env.AGENTS_CWD?.trim() || registry?.cwd || process.cwd()),
71
+ pid: registry?.pid,
72
+ launchId: launchId || registry?.launchId,
73
+ terminalId: env.AGENT_TERMINAL_ID?.trim() || registry?.terminalId,
74
+ tmuxPane: env.TMUX_PANE?.trim() || registry?.tmuxPane,
75
+ };
76
+ }
77
+ function firstValidId(candidates) {
78
+ for (const raw of candidates) {
79
+ const id = (raw ?? '').trim();
80
+ if (id && isValidMailboxId(id))
81
+ return id;
82
+ }
83
+ return undefined;
84
+ }
85
+ function mailboxIdFromEnv(env) {
86
+ const dir = env.AGENTS_MAILBOX_DIR?.trim();
87
+ if (!dir)
88
+ return undefined;
89
+ const base = path.basename(dir.replace(/[/\\]+$/, ''));
90
+ return base || undefined;
91
+ }
92
+ /** Walk up to 16 ancestors looking for a by-pid registry entry with a session. */
93
+ export function walkPidRegistry(startPid, getParent, readEntry) {
94
+ let pid = startPid;
95
+ const seen = new Set();
96
+ let firstHit;
97
+ for (let i = 0; i < 16 && pid && pid > 1 && !seen.has(pid); i++) {
98
+ seen.add(pid);
99
+ const entry = readEntry(pid);
100
+ if (entry) {
101
+ if (entry.sessionId && isValidMailboxId(entry.sessionId))
102
+ return entry;
103
+ if (!firstHit)
104
+ firstHit = entry;
105
+ }
106
+ pid = getParent(pid);
107
+ }
108
+ // Entry without sessionId still carries agent/cwd/launchId — usable when
109
+ // session id comes from env.
110
+ return firstHit;
111
+ }
112
+ /** Best-effort parent pid of `pid` (Linux /proc, else `ps`). */
113
+ export function parentPidOf(pid) {
114
+ if (!Number.isInteger(pid) || pid <= 1)
115
+ return undefined;
116
+ if (process.platform === 'linux') {
117
+ try {
118
+ const status = fs.readFileSync(`/proc/${pid}/status`, 'utf8');
119
+ const m = status.match(/^PPid:\s*(\d+)/m);
120
+ if (m) {
121
+ const pp = Number(m[1]);
122
+ return Number.isInteger(pp) && pp > 0 ? pp : undefined;
123
+ }
124
+ }
125
+ catch {
126
+ /* fall through */
127
+ }
128
+ }
129
+ try {
130
+ const r = spawnSync('ps', ['-o', 'ppid=', '-p', String(pid)], {
131
+ encoding: 'utf8',
132
+ timeout: 1000,
133
+ });
134
+ if (r.status === 0) {
135
+ const pp = Number((r.stdout || '').trim());
136
+ return Number.isInteger(pp) && pp > 0 ? pp : undefined;
137
+ }
138
+ }
139
+ catch {
140
+ /* best-effort */
141
+ }
142
+ return undefined;
143
+ }
144
+ export function normalizeStatusText(text) {
145
+ const collapsed = text.replace(/\s+/g, ' ').trim();
146
+ if (!collapsed)
147
+ return '';
148
+ if (collapsed.length <= STATUS_POST_MAX_CHARS)
149
+ return collapsed;
150
+ return `${collapsed.slice(0, STATUS_POST_MAX_CHARS - 1)}…`;
151
+ }
152
+ /**
153
+ * Append a `status.posted` milestone for the calling agent.
154
+ * Throws if text is empty or session identity cannot be resolved.
155
+ */
156
+ export function postFeedStatus(input) {
157
+ const detail = normalizeStatusText(input.text);
158
+ if (!detail) {
159
+ throw new Error('Status text is empty. Usage: agents feed post "what just happened"');
160
+ }
161
+ const identity = resolvePostIdentity(input);
162
+ if (!identity) {
163
+ throw new Error('No session id. Run from an agents-cli session '
164
+ + '(AGENT_SESSION_ID / AGENTS_MAILBOX_DIR / pid registry), or pass --session <id>.');
165
+ }
166
+ const event = {
167
+ ts: input.ts ?? new Date().toISOString(),
168
+ event: 'status.posted',
169
+ sessionId: identity.sessionId,
170
+ mailboxId: identity.mailboxId,
171
+ host: identity.host,
172
+ runtime: identity.runtime,
173
+ cwd: identity.cwd,
174
+ agent: identity.agent,
175
+ tool: 'feed.post',
176
+ detail,
177
+ ...(identity.pid !== undefined ? { pid: identity.pid } : {}),
178
+ ...(identity.launchId ? { launchId: identity.launchId } : {}),
179
+ ...(identity.terminalId ? { terminalId: identity.terminalId } : {}),
180
+ ...(identity.tmuxPane ? { tmuxPane: identity.tmuxPane } : {}),
181
+ };
182
+ appendActivityEvent(event, input.activityRoot);
183
+ return {
184
+ event: {
185
+ v: 1,
186
+ tier: 'milestone',
187
+ ...event,
188
+ },
189
+ };
190
+ }
191
+ function machineIdFromEnv(env) {
192
+ const raw = env.AGENTS_SYNC_MACHINE_ID || undefined;
193
+ if (raw) {
194
+ return raw.split('.')[0].trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'unknown';
195
+ }
196
+ return machineId();
197
+ }
198
+ function detectAgentKind(env) {
199
+ if (env.CLAUDECODE === '1')
200
+ return 'claude';
201
+ if (env.CODEX_CI || env.CODEX_HOME)
202
+ return 'codex';
203
+ return 'agent';
204
+ }
@@ -11,6 +11,30 @@ import type { DeviceProfile } from '../devices/registry.js';
11
11
  import type { DeviceDesired, DeviceProbe, DeviceDiff, FleetAction, FleetPlan, AuthFilePayload } from './types.js';
12
12
  /** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
13
13
  export declare function agentIdOf(spec: string): string;
14
+ /**
15
+ * The explicit pinned version of a spec, or undefined for an id-level spec.
16
+ * `claude@2.1.170` -> `2.1.170`; `claude`, `claude@latest`, `claude@oldest`, and
17
+ * `claude@all` all return undefined (the label channels install-latest / are
18
+ * expanded upstream, so they diff at id granularity, not per-version).
19
+ */
20
+ export declare function pinnedVersion(spec: string): string | undefined;
21
+ /** True when any spec in the roster pins an explicit version (needs a version probe). */
22
+ export declare function rosterNeedsVersions(desired: DeviceDesired[]): boolean;
23
+ /**
24
+ * Expand any `<agent>@all` spec into one pinned spec per version installed on the
25
+ * source, so `--agent claude@all` replicates THIS machine's exact version set.
26
+ * `versionsOf` returns the source's installed versions for an agent id. Every
27
+ * other spec passes through unchanged; the result is de-duplicated in order.
28
+ * Throws if `@all` names an agent with no installed versions here (nothing to
29
+ * replicate — a clear misconfig, not a silent no-op).
30
+ */
31
+ export declare function expandAllSpecs(specs: string[], versionsOf: (id: string) => string[]): string[];
32
+ /**
33
+ * Parse `agents view --json` (the all-agents array form) into a map of agent id
34
+ * -> installed version strings. Tolerant: returns undefined on any parse failure
35
+ * so a version-pinned spec falls back to id-level presence rather than crashing.
36
+ */
37
+ export declare function parseInstalledVersions(stdout: string): Record<string, string[]> | undefined;
14
38
  /** Source-side auth availability, computed once from `snapshotAuth`. */
15
39
  export interface SourceAuth {
16
40
  /** Agent ids the source has a readable, propagatable credential file for. */
@@ -37,8 +61,14 @@ export interface DiffContext {
37
61
  }
38
62
  /** Pure: desired vs probed -> per-device diff + flat action list. */
39
63
  export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
40
- /** Probe one device: reachability + agents-cli version + installed agent ids. */
41
- export declare function probeDevice(device: DeviceProfile): DeviceProbe;
64
+ export interface ProbeOptions {
65
+ /** Also fetch per-agent installed versions (one extra `agents view --json`
66
+ * round-trip). Enable only when the plan has a version-pinned spec. */
67
+ withVersions?: boolean;
68
+ }
69
+ /** Probe one device: reachability + agents-cli version + installed agent ids
70
+ * (and, when `withVersions`, the installed version strings per agent). */
71
+ export declare function probeDevice(device: DeviceProfile, opts?: ProbeOptions): DeviceProbe;
42
72
  export interface ApplyStep {
43
73
  kind: FleetAction['kind'];
44
74
  ok: boolean;
@@ -17,6 +17,76 @@ import { isPropagatableAgent, KEYCHAIN_BOUND_ON_MAC, buildAuthBundle, } from './
17
17
  export function agentIdOf(spec) {
18
18
  return spec.split('@')[0].trim();
19
19
  }
20
+ /**
21
+ * The explicit pinned version of a spec, or undefined for an id-level spec.
22
+ * `claude@2.1.170` -> `2.1.170`; `claude`, `claude@latest`, `claude@oldest`, and
23
+ * `claude@all` all return undefined (the label channels install-latest / are
24
+ * expanded upstream, so they diff at id granularity, not per-version).
25
+ */
26
+ export function pinnedVersion(spec) {
27
+ const at = spec.indexOf('@');
28
+ if (at < 0)
29
+ return undefined;
30
+ const v = spec.slice(at + 1).trim();
31
+ if (!v || v === 'latest' || v === 'oldest' || v === 'all')
32
+ return undefined;
33
+ return v;
34
+ }
35
+ /** True when any spec in the roster pins an explicit version (needs a version probe). */
36
+ export function rosterNeedsVersions(desired) {
37
+ return desired.some((d) => d.agents.some((s) => pinnedVersion(s) !== undefined));
38
+ }
39
+ /**
40
+ * Expand any `<agent>@all` spec into one pinned spec per version installed on the
41
+ * source, so `--agent claude@all` replicates THIS machine's exact version set.
42
+ * `versionsOf` returns the source's installed versions for an agent id. Every
43
+ * other spec passes through unchanged; the result is de-duplicated in order.
44
+ * Throws if `@all` names an agent with no installed versions here (nothing to
45
+ * replicate — a clear misconfig, not a silent no-op).
46
+ */
47
+ export function expandAllSpecs(specs, versionsOf) {
48
+ const out = [];
49
+ for (const spec of specs) {
50
+ const at = spec.indexOf('@');
51
+ const label = at >= 0 ? spec.slice(at + 1).trim() : '';
52
+ if (label !== 'all') {
53
+ out.push(spec);
54
+ continue;
55
+ }
56
+ const id = agentIdOf(spec);
57
+ const versions = versionsOf(id);
58
+ if (versions.length === 0) {
59
+ throw new Error(`--agent ${spec}: no ${id} versions installed on this machine to replicate.`);
60
+ }
61
+ for (const v of versions)
62
+ out.push(`${id}@${v}`);
63
+ }
64
+ return [...new Set(out)];
65
+ }
66
+ /**
67
+ * Parse `agents view --json` (the all-agents array form) into a map of agent id
68
+ * -> installed version strings. Tolerant: returns undefined on any parse failure
69
+ * so a version-pinned spec falls back to id-level presence rather than crashing.
70
+ */
71
+ export function parseInstalledVersions(stdout) {
72
+ try {
73
+ const arr = JSON.parse(stdout);
74
+ if (!Array.isArray(arr))
75
+ return undefined;
76
+ const out = {};
77
+ for (const a of arr) {
78
+ if (a && typeof a.agent === 'string' && Array.isArray(a.versions)) {
79
+ out[a.agent] = a.versions
80
+ .map((v) => v?.version)
81
+ .filter((v) => typeof v === 'string');
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
20
90
  /**
21
91
  * Can we propagate `agent`'s login to a target on `targetPlatform`? False when
22
92
  * the agent has no portable file, the source can't provide it (bound / not
@@ -53,21 +123,28 @@ export function diffFleet(desired, probes, ctx) {
53
123
  else if (probe.cliVersion !== ctx.targetCliVersion) {
54
124
  rowActions.push({ device: d.device, kind: 'upgrade-cli', detail: `agents-cli ${probe.cliVersion} -> ${ctx.targetCliVersion}` });
55
125
  }
56
- // agents.
126
+ // agents. A version-pinned spec (`claude@2.1.170`, or an expanded
127
+ // `claude@all` member) is present only when that exact version is on the
128
+ // device; a bare/latest spec diffs at id granularity. So `--agent claude@all`
129
+ // installs every missing version even when some claude is already there.
57
130
  for (const spec of d.agents) {
58
131
  const id = agentIdOf(spec);
59
- if (!probe.installedAgents.includes(id)) {
60
- rowActions.push({ device: d.device, kind: 'add-agent', agent: id, detail: `install ${spec}` });
132
+ const want = pinnedVersion(spec);
133
+ const present = want !== undefined
134
+ ? (probe.installedVersions?.[id]?.includes(want) ?? false)
135
+ : probe.installedAgents.includes(id);
136
+ if (!present) {
137
+ rowActions.push({ device: d.device, kind: 'add-agent', agent: id, spec, detail: `install ${spec}` });
61
138
  }
62
139
  }
63
140
  // config.
64
141
  if (d.sync.length > 0) {
65
142
  rowActions.push({ device: d.device, kind: 'sync-config', detail: `sync config (${d.sync.join(', ')})` });
66
143
  }
67
- // login.
144
+ // login — per agent id, not per spec: `claude@all` names one id many times,
145
+ // but login propagates once per agent (its credential is version-shared).
68
146
  if (d.login === 'sync') {
69
- for (const spec of d.agents) {
70
- const id = agentIdOf(spec);
147
+ for (const id of [...new Set(d.agents.map(agentIdOf))]) {
71
148
  if (canPushLogin(id, probe.platform, ctx.sourceAuth)) {
72
149
  rowActions.push({ device: d.device, kind: 'push-login', agent: id, detail: `propagate ${id} login` });
73
150
  }
@@ -107,8 +184,9 @@ function osHint(platform) {
107
184
  function remoteEnv(platform) {
108
185
  return platform === 'windows' ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
109
186
  }
110
- /** Probe one device: reachability + agents-cli version + installed agent ids. */
111
- export function probeDevice(device) {
187
+ /** Probe one device: reachability + agents-cli version + installed agent ids
188
+ * (and, when `withVersions`, the installed version strings per agent). */
189
+ export function probeDevice(device, opts) {
112
190
  let target;
113
191
  try {
114
192
  target = sshTargetFor(device);
@@ -133,12 +211,20 @@ export function probeDevice(device) {
133
211
  /* agents-cli present but doctor output unparsable — treat as no agents */
134
212
  }
135
213
  }
214
+ let installedVersions;
215
+ if (opts?.withVersions) {
216
+ const viewCmd = buildRemoteAgentsInvocation(['view', '--json'], undefined, hint, remoteEnv(device.platform));
217
+ const vres = sshExec(target, viewCmd, { timeoutMs: 30000, multiplex: true });
218
+ if (vres.code === 0)
219
+ installedVersions = parseInstalledVersions(vres.stdout);
220
+ }
136
221
  return {
137
222
  device: device.name,
138
223
  reachable: true,
139
224
  platform: device.platform,
140
225
  cliVersion: ready.version ?? undefined,
141
226
  installedAgents: installed,
227
+ installedVersions,
142
228
  };
143
229
  }
144
230
  /** Execute one device's planned actions in order. Real SSH — no mocks. */
@@ -165,9 +251,10 @@ export function reconcileDevice(row, device, ctx) {
165
251
  steps.push({ kind: cliAction.kind, ok: r.ok, detail: cliAction.detail });
166
252
  ok = ok && r.ok;
167
253
  }
168
- // 2. agents.
254
+ // 2. agents. Every add-agent action carries the full spec (set in diffFleet);
255
+ // install it directly rather than re-parsing the human-readable detail string.
169
256
  for (const a of row.actions.filter((x) => x.kind === 'add-agent')) {
170
- const spec = a.detail.replace(/^install\s+/, '');
257
+ const spec = a.spec;
171
258
  const r = sshAgents(['add', spec, '--yes']);
172
259
  steps.push({ kind: 'add-agent', ok: r.code === 0, detail: a.detail });
173
260
  ok = ok && r.code === 0;
@@ -87,6 +87,14 @@ export interface DeviceProbe {
87
87
  cliVersion?: string;
88
88
  /** Agent ids currently installed on the device. */
89
89
  installedAgents: string[];
90
+ /**
91
+ * Installed version strings per agent id (e.g. `{ claude: ['2.1.170', '2.1.207'] }`),
92
+ * parsed from `agents view --json` on the device. Only populated when the plan
93
+ * involves a version-pinned spec (`claude@2.1.170`, or a `claude@all` expansion)
94
+ * — a bare `claude`/`claude@latest` roster never pays for the extra probe. When
95
+ * undefined, version-pinned specs fall back to id-level presence.
96
+ */
97
+ installedVersions?: Record<string, string[]>;
90
98
  /** Reason string when `reachable` is false or the probe partially failed. */
91
99
  note?: string;
92
100
  }
@@ -100,6 +108,9 @@ export interface FleetAction {
100
108
  kind: FleetActionKind;
101
109
  /** Agent id for agent/login actions; undefined for cli/config actions. */
102
110
  agent?: string;
111
+ /** Full agent spec for `add-agent` (e.g. `claude@2.1.170`) so the plan can show
112
+ * the exact version being installed; equals the id for a bare/latest spec. */
113
+ spec?: string;
103
114
  /** Human, one-line description of the action. */
104
115
  detail: string;
105
116
  }
@@ -1,3 +1,16 @@
1
+ /** A file surfaced by the walk, carrying the mtime+size from the walk's own stat. */
2
+ export interface WalkedFile {
3
+ path: string;
4
+ mtimeMs: number;
5
+ size: number;
6
+ }
7
+ /**
8
+ * Walk a directory recursively for files with a given extension, newest first,
9
+ * keeping each match's mtime and size from the walk's own stat. Callers that
10
+ * then compare against the scan ledger reuse these instead of re-stat'ing every
11
+ * path — eliminating a second stat per file on the Codex/Droid/routine scans.
12
+ */
13
+ export declare function walkForFilesWithStat(dir: string, ext: string, limit: number): WalkedFile[];
1
14
  /** Walk a directory recursively for files with a given extension, newest first. */
2
15
  export declare function walkForFiles(dir: string, ext: string, limit: number): string[];
3
16
  /**
@@ -36,20 +36,29 @@ function walkEntries(dir, ext, onFile) {
36
36
  else if (entry.name.endsWith(ext)) {
37
37
  const stat = safeStatSync(full);
38
38
  if (stat)
39
- onFile(full, stat.mtimeMs);
39
+ onFile(full, stat.mtimeMs, stat.size);
40
40
  }
41
41
  }
42
42
  }
43
43
  walk(dir, 0);
44
44
  }
45
- /** Walk a directory recursively for files with a given extension, newest first. */
46
- export function walkForFiles(dir, ext, limit) {
45
+ /**
46
+ * Walk a directory recursively for files with a given extension, newest first,
47
+ * keeping each match's mtime and size from the walk's own stat. Callers that
48
+ * then compare against the scan ledger reuse these instead of re-stat'ing every
49
+ * path — eliminating a second stat per file on the Codex/Droid/routine scans.
50
+ */
51
+ export function walkForFilesWithStat(dir, ext, limit) {
47
52
  const results = [];
48
- walkEntries(dir, ext, (filePath, mtimeMs) => {
49
- results.push({ path: filePath, mtime: mtimeMs });
53
+ walkEntries(dir, ext, (filePath, mtimeMs, size) => {
54
+ results.push({ path: filePath, mtimeMs, size });
50
55
  });
51
- results.sort((a, b) => b.mtime - a.mtime);
52
- return results.slice(0, limit).map(r => r.path);
56
+ results.sort((a, b) => b.mtimeMs - a.mtimeMs);
57
+ return results.slice(0, limit);
58
+ }
59
+ /** Walk a directory recursively for files with a given extension, newest first. */
60
+ export function walkForFiles(dir, ext, limit) {
61
+ return walkForFilesWithStat(dir, ext, limit).map(r => r.path);
53
62
  }
54
63
  /**
55
64
  * Return the newest mtime (ms) among files with the given extension, or null
@@ -139,6 +139,7 @@ export declare function listCentralHooks(): HookEntry[];
139
139
  export declare function parseHookManifest(opts?: {
140
140
  warn?: boolean;
141
141
  }): Record<string, ManifestHook>;
142
+ export declare function selectHookManifest(manifest: Record<string, ManifestHook>, selected: string[]): Record<string, ManifestHook>;
142
143
  /**
143
144
  * Hook script files present on disk that no manifest entry declares — "dead"
144
145
  * hooks. The registrar only wires manifest-declared hooks into an agent's