@phnx-labs/agents-cli 1.20.74 → 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 (199) hide show
  1. package/CHANGELOG.md +893 -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 +18 -0
  13. package/dist/commands/exec.js +31 -3
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +15 -2
  26. package/dist/commands/secrets.js +44 -21
  27. package/dist/commands/sessions-browser.js +1 -1
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.js +16 -1
  32. package/dist/commands/sessions.d.ts +36 -13
  33. package/dist/commands/sessions.js +210 -40
  34. package/dist/commands/setup-computer.js +2 -2
  35. package/dist/commands/ssh.js +71 -35
  36. package/dist/commands/status.js +10 -2
  37. package/dist/commands/versions.js +15 -6
  38. package/dist/commands/view.d.ts +5 -0
  39. package/dist/commands/view.js +24 -4
  40. package/dist/commands/watchdog.d.ts +7 -2
  41. package/dist/commands/watchdog.js +73 -57
  42. package/dist/index.js +58 -7
  43. package/dist/lib/activity.d.ts +14 -4
  44. package/dist/lib/activity.js +17 -5
  45. package/dist/lib/actor.d.ts +50 -0
  46. package/dist/lib/actor.js +166 -0
  47. package/dist/lib/agent-spec/provider.js +2 -1
  48. package/dist/lib/agent-spec/resolve.js +19 -5
  49. package/dist/lib/agent-spec/types.d.ts +9 -1
  50. package/dist/lib/agents.d.ts +21 -0
  51. package/dist/lib/agents.js +70 -6
  52. package/dist/lib/cloud/codex.d.ts +2 -0
  53. package/dist/lib/cloud/codex.js +14 -3
  54. package/dist/lib/cloud/session-index.d.ts +32 -0
  55. package/dist/lib/cloud/session-index.js +58 -0
  56. package/dist/lib/cloud/store.d.ts +7 -0
  57. package/dist/lib/cloud/store.js +25 -0
  58. package/dist/lib/config-transfer.js +4 -0
  59. package/dist/lib/daemon.js +18 -2
  60. package/dist/lib/devices/connect.d.ts +15 -1
  61. package/dist/lib/devices/connect.js +15 -1
  62. package/dist/lib/devices/fleet.d.ts +21 -0
  63. package/dist/lib/devices/fleet.js +26 -0
  64. package/dist/lib/devices/health-report.d.ts +38 -0
  65. package/dist/lib/devices/health-report.js +214 -0
  66. package/dist/lib/devices/health.js +4 -1
  67. package/dist/lib/devices/reachability.d.ts +31 -0
  68. package/dist/lib/devices/reachability.js +40 -0
  69. package/dist/lib/devices/registry.d.ts +33 -0
  70. package/dist/lib/devices/registry.js +37 -0
  71. package/dist/lib/devices/resolve-target.d.ts +15 -27
  72. package/dist/lib/devices/resolve-target.js +63 -102
  73. package/dist/lib/devices/sync.d.ts +18 -0
  74. package/dist/lib/devices/sync.js +23 -1
  75. package/dist/lib/devices/tailscale.d.ts +3 -0
  76. package/dist/lib/devices/tailscale.js +1 -0
  77. package/dist/lib/events.d.ts +1 -1
  78. package/dist/lib/exec.d.ts +66 -0
  79. package/dist/lib/exec.js +195 -6
  80. package/dist/lib/feed-post.d.ts +63 -0
  81. package/dist/lib/feed-post.js +204 -0
  82. package/dist/lib/fleet/apply.d.ts +32 -2
  83. package/dist/lib/fleet/apply.js +97 -10
  84. package/dist/lib/fleet/types.d.ts +11 -0
  85. package/dist/lib/fs-walk.d.ts +13 -0
  86. package/dist/lib/fs-walk.js +16 -7
  87. package/dist/lib/hooks.d.ts +1 -0
  88. package/dist/lib/hooks.js +156 -0
  89. package/dist/lib/hosts/dispatch.d.ts +7 -0
  90. package/dist/lib/hosts/dispatch.js +2 -0
  91. package/dist/lib/hosts/passthrough.js +2 -0
  92. package/dist/lib/hosts/reconnect.d.ts +78 -0
  93. package/dist/lib/hosts/reconnect.js +127 -0
  94. package/dist/lib/hosts/registry.d.ts +75 -14
  95. package/dist/lib/hosts/registry.js +205 -30
  96. package/dist/lib/hosts/remote-cmd.js +1 -0
  97. package/dist/lib/hosts/run-target.d.ts +13 -5
  98. package/dist/lib/hosts/run-target.js +26 -10
  99. package/dist/lib/hosts/session-index.d.ts +18 -1
  100. package/dist/lib/hosts/session-index.js +37 -2
  101. package/dist/lib/hosts/session-marker.d.ts +33 -0
  102. package/dist/lib/hosts/session-marker.js +51 -0
  103. package/dist/lib/hosts/tasks.d.ts +8 -4
  104. package/dist/lib/import.d.ts +2 -0
  105. package/dist/lib/import.js +35 -2
  106. package/dist/lib/menubar/install-menubar.d.ts +20 -0
  107. package/dist/lib/menubar/install-menubar.js +45 -1
  108. package/dist/lib/migrate.d.ts +16 -0
  109. package/dist/lib/migrate.js +36 -0
  110. package/dist/lib/models.d.ts +27 -0
  111. package/dist/lib/models.js +54 -1
  112. package/dist/lib/overdue.d.ts +7 -2
  113. package/dist/lib/overdue.js +18 -11
  114. package/dist/lib/picker.js +4 -1
  115. package/dist/lib/platform/process.d.ts +17 -0
  116. package/dist/lib/platform/process.js +70 -0
  117. package/dist/lib/profiles-presets.js +9 -7
  118. package/dist/lib/profiles.d.ts +31 -0
  119. package/dist/lib/profiles.js +70 -0
  120. package/dist/lib/pty-server.d.ts +2 -10
  121. package/dist/lib/pty-server.js +4 -38
  122. package/dist/lib/registry.d.ts +1 -1
  123. package/dist/lib/registry.js +48 -8
  124. package/dist/lib/rotate.d.ts +18 -0
  125. package/dist/lib/rotate.js +28 -0
  126. package/dist/lib/routines.d.ts +26 -0
  127. package/dist/lib/routines.js +19 -2
  128. package/dist/lib/runner.js +104 -7
  129. package/dist/lib/secrets/agent.d.ts +66 -1
  130. package/dist/lib/secrets/agent.js +154 -90
  131. package/dist/lib/secrets/bundles.d.ts +34 -0
  132. package/dist/lib/secrets/bundles.js +179 -19
  133. package/dist/lib/secrets/index.d.ts +15 -1
  134. package/dist/lib/secrets/index.js +21 -3
  135. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  136. package/dist/lib/secrets/rc-hygiene.js +154 -0
  137. package/dist/lib/secrets/remote.d.ts +7 -4
  138. package/dist/lib/secrets/remote.js +7 -4
  139. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  140. package/dist/lib/secrets/sync-commands.js +21 -0
  141. package/dist/lib/self-update.d.ts +23 -2
  142. package/dist/lib/self-update.js +86 -5
  143. package/dist/lib/session/active.d.ts +114 -7
  144. package/dist/lib/session/active.js +323 -52
  145. package/dist/lib/session/cloud.js +2 -1
  146. package/dist/lib/session/db.d.ts +48 -0
  147. package/dist/lib/session/db.js +238 -14
  148. package/dist/lib/session/detached.d.ts +30 -0
  149. package/dist/lib/session/detached.js +92 -0
  150. package/dist/lib/session/discover.d.ts +465 -2
  151. package/dist/lib/session/discover.js +1648 -468
  152. package/dist/lib/session/fork.js +2 -1
  153. package/dist/lib/session/hook-sessions.d.ts +43 -0
  154. package/dist/lib/session/hook-sessions.js +135 -0
  155. package/dist/lib/session/linear.d.ts +6 -0
  156. package/dist/lib/session/linear.js +55 -0
  157. package/dist/lib/session/migrate-targets.d.ts +65 -0
  158. package/dist/lib/session/migrate-targets.js +94 -0
  159. package/dist/lib/session/migrations.d.ts +37 -0
  160. package/dist/lib/session/migrations.js +60 -0
  161. package/dist/lib/session/parse.d.ts +18 -0
  162. package/dist/lib/session/parse.js +130 -30
  163. package/dist/lib/session/pid-registry.d.ts +34 -2
  164. package/dist/lib/session/pid-registry.js +49 -2
  165. package/dist/lib/session/render.d.ts +7 -2
  166. package/dist/lib/session/render.js +22 -10
  167. package/dist/lib/session/short-id.d.ts +17 -0
  168. package/dist/lib/session/short-id.js +20 -0
  169. package/dist/lib/session/state.js +6 -2
  170. package/dist/lib/shims.d.ts +1 -1
  171. package/dist/lib/shims.js +20 -6
  172. package/dist/lib/sqlite.d.ts +3 -2
  173. package/dist/lib/sqlite.js +27 -4
  174. package/dist/lib/staleness/detectors/commands.js +1 -1
  175. package/dist/lib/staleness/detectors/workflows.js +13 -2
  176. package/dist/lib/staleness/writers/commands.js +7 -7
  177. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  178. package/dist/lib/startup/command-registry.d.ts +1 -0
  179. package/dist/lib/startup/command-registry.js +3 -0
  180. package/dist/lib/state.d.ts +1 -1
  181. package/dist/lib/state.js +1 -32
  182. package/dist/lib/teams/agents.d.ts +19 -10
  183. package/dist/lib/teams/agents.js +40 -39
  184. package/dist/lib/types.d.ts +37 -2
  185. package/dist/lib/usage.d.ts +33 -1
  186. package/dist/lib/usage.js +172 -12
  187. package/dist/lib/versions.d.ts +9 -2
  188. package/dist/lib/versions.js +37 -8
  189. package/dist/lib/watchdog/log.d.ts +43 -0
  190. package/dist/lib/watchdog/log.js +69 -0
  191. package/dist/lib/watchdog/routine.d.ts +44 -0
  192. package/dist/lib/watchdog/routine.js +69 -0
  193. package/dist/lib/watchdog/runner.d.ts +51 -7
  194. package/dist/lib/watchdog/runner.js +239 -64
  195. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  196. package/dist/lib/watchdog/watchdog.js +31 -16
  197. package/dist/lib/workflows.d.ts +16 -0
  198. package/dist/lib/workflows.js +110 -1
  199. package/package.json +1 -1
@@ -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
package/dist/lib/hooks.js CHANGED
@@ -801,6 +801,10 @@ export function parseHookManifest(opts = {}) {
801
801
  }
802
802
  return merged;
803
803
  }
804
+ export function selectHookManifest(manifest, selected) {
805
+ const selectedHooks = new Set(selected);
806
+ return Object.fromEntries(Object.entries(manifest).filter(([name, hook]) => selectedHooks.has(name) || selectedHooks.has(path.basename(hook.script))));
807
+ }
804
808
  /**
805
809
  * Hook script files present on disk that no manifest entry declares — "dead"
806
810
  * hooks. The registrar only wires manifest-declared hooks into an agent's
@@ -934,6 +938,15 @@ function sweepOrphanShims(manifest) {
934
938
  export function registerHooksToSettings(agentId, versionHome, hookManifest, agentsDirOverride) {
935
939
  const manifest = hookManifest || parseHookManifest();
936
940
  if (Object.keys(manifest).length === 0) {
941
+ if (agentId === 'opencode') {
942
+ const pluginPath = path.join(versionHome, '.config', 'opencode', 'plugins', 'agents-cli-hooks.ts');
943
+ try {
944
+ fs.rmSync(pluginPath, { force: true });
945
+ }
946
+ catch (e) {
947
+ return { registered: [], errors: [`Failed to remove agents-cli-hooks.ts: ${e.message}`] };
948
+ }
949
+ }
937
950
  return { registered: [], errors: [] };
938
951
  }
939
952
  sweepOrphanShims(manifest);
@@ -991,6 +1004,9 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
991
1004
  if (agentId === 'grok') {
992
1005
  return registerHooksForGrok(versionHome, manifest, resolveScript, managedPrefixes);
993
1006
  }
1007
+ if (agentId === 'opencode') {
1008
+ return registerHooksForOpenCode(versionHome, manifest, resolveScript);
1009
+ }
994
1010
  if (agentId === 'kimi') {
995
1011
  return registerHooksForKimi(versionHome, manifest, resolveScript, managedPrefixes);
996
1012
  }
@@ -1011,6 +1027,146 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1011
1027
  }
1012
1028
  return { registered: [], errors: [] };
1013
1029
  }
1030
+ const OPENCODE_DIRECT_EVENT_MAP = {
1031
+ PreToolUse: 'tool.execute.before',
1032
+ PostToolUse: 'tool.execute.after',
1033
+ UserPromptSubmit: 'chat.message',
1034
+ };
1035
+ const OPENCODE_LIFECYCLE_EVENT_MAP = {
1036
+ SessionStart: ['session.created'],
1037
+ SessionEnd: ['session.deleted'],
1038
+ Stop: ['session.idle', 'session.error'],
1039
+ PreCompact: ['session.compacted'],
1040
+ OnError: ['session.error'],
1041
+ Notification: ['permission.asked'],
1042
+ };
1043
+ /**
1044
+ * Compile canonical hooks.yaml entries into a local OpenCode plugin.
1045
+ *
1046
+ * OpenCode has no declarative shell-hook config block. It auto-loads direct
1047
+ * TS/JS modules from ~/.config/opencode/plugins/, and supplies Bun's `$` shell
1048
+ * primitive to each plugin. The generated module subscribes to native plugin
1049
+ * events, sends the event payload to the managed hook script as JSON on stdin,
1050
+ * and surfaces a non-zero script exit as a plugin error.
1051
+ */
1052
+ function registerHooksForOpenCode(versionHome, manifest, resolveScript) {
1053
+ const registered = [];
1054
+ const errors = [];
1055
+ const direct = new Map();
1056
+ const lifecycle = new Map();
1057
+ for (const [name, hookDef] of Object.entries(manifest)) {
1058
+ if (!hookDef.events || hookDef.events.length === 0)
1059
+ continue;
1060
+ const command = resolveHookCommand(name, hookDef, resolveScript);
1061
+ if (!command) {
1062
+ errors.push(`${name}: script not found`);
1063
+ continue;
1064
+ }
1065
+ const generated = {
1066
+ name,
1067
+ command,
1068
+ ...(hookDef.timeout !== undefined ? { timeout: hookDef.timeout } : {}),
1069
+ ...(hookDef.matcher ? { matcher: hookDef.matcher } : {}),
1070
+ };
1071
+ for (const event of hookDef.events) {
1072
+ const directEvent = OPENCODE_DIRECT_EVENT_MAP[event];
1073
+ if (directEvent) {
1074
+ const hooks = direct.get(directEvent) ?? [];
1075
+ hooks.push(generated);
1076
+ direct.set(directEvent, hooks);
1077
+ registered.push(`${name} -> ${directEvent}`);
1078
+ }
1079
+ for (const lifecycleEvent of OPENCODE_LIFECYCLE_EVENT_MAP[event] ?? []) {
1080
+ const hooks = lifecycle.get(lifecycleEvent) ?? [];
1081
+ hooks.push(generated);
1082
+ lifecycle.set(lifecycleEvent, hooks);
1083
+ registered.push(`${name} -> ${lifecycleEvent}`);
1084
+ }
1085
+ }
1086
+ }
1087
+ const serializedDirect = JSON.stringify(Object.fromEntries(direct), null, 2);
1088
+ const serializedLifecycle = JSON.stringify(Object.fromEntries(lifecycle), null, 2);
1089
+ const pluginSource = `// Generated by agents-cli. Re-run agents sync to update.
1090
+ const directHooks = ${serializedDirect}
1091
+ const lifecycleHooks = ${serializedLifecycle}
1092
+
1093
+ function matches(hook, tool) {
1094
+ if (!hook.matcher) return true
1095
+ try {
1096
+ return new RegExp(hook.matcher).test(tool)
1097
+ } catch {
1098
+ return hook.matcher === tool
1099
+ }
1100
+ }
1101
+
1102
+ async function runHooks(hooks, payload, $, matchTool = false) {
1103
+ for (const hook of hooks ?? []) {
1104
+ if (matchTool && !matches(hook, payload.tool_name ?? "")) continue
1105
+ const input = JSON.stringify(payload)
1106
+ const home = Bun.env.HOME ?? Bun.env.USERPROFILE ?? ""
1107
+ const command = hook.command.startsWith("~/")
1108
+ ? \`\${home}/\${hook.command.slice(2)}\`
1109
+ : hook.command
1110
+ const shell = Bun.which("bash") ?? Bun.which("sh")
1111
+ if (!shell) throw new Error(\`\${hook.name} requires bash or sh\`)
1112
+ const execArgs = [shell, "-c", 'exec "$1"', "agents-hook", command]
1113
+ if (hook.timeout !== undefined) {
1114
+ const child = Bun.spawn(execArgs, {
1115
+ stdin: new Response(input),
1116
+ stdout: "ignore",
1117
+ stderr: "pipe",
1118
+ })
1119
+ let timedOut = false
1120
+ const timer = setTimeout(() => {
1121
+ timedOut = true
1122
+ if (process.platform === "win32") {
1123
+ Bun.spawnSync(["taskkill", "/PID", String(child.pid), "/T", "/F"])
1124
+ } else {
1125
+ child.kill()
1126
+ }
1127
+ }, hook.timeout * 1000)
1128
+ const exitCode = await child.exited.finally(() => clearTimeout(timer))
1129
+ const stderr = await new Response(child.stderr).text()
1130
+ if (timedOut) {
1131
+ throw new Error(\`\${hook.name} timed out after \${hook.timeout} seconds\`)
1132
+ }
1133
+ if (exitCode !== 0) {
1134
+ throw new Error(\`\${hook.name} failed with exit code \${exitCode}: \${stderr.trim()}\`)
1135
+ }
1136
+ continue
1137
+ }
1138
+ const result = await $\`\${shell} -c \${'exec "$1"'} \${"agents-hook"} \${command} < \${new Response(input)}\`.nothrow().quiet()
1139
+ if (result.exitCode !== 0) {
1140
+ throw new Error(\`\${hook.name} failed with exit code \${result.exitCode}: \${result.stderr.toString().trim()}\`)
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ export const AgentsCliHooks = async ({ $ }) => ({
1146
+ event: async ({ event }) => {
1147
+ await runHooks(lifecycleHooks[event.type], { hook_event_name: event.type, ...event }, $)
1148
+ },
1149
+ "chat.message": async (input, output) => {
1150
+ await runHooks(directHooks["chat.message"], { hook_event_name: "UserPromptSubmit", ...input, ...output }, $)
1151
+ },
1152
+ "tool.execute.before": async (input, output) => {
1153
+ await runHooks(directHooks["tool.execute.before"], { hook_event_name: "PreToolUse", tool_name: input.tool, tool_input: output.args, ...input }, $, true)
1154
+ },
1155
+ "tool.execute.after": async (input, output) => {
1156
+ await runHooks(directHooks["tool.execute.after"], { hook_event_name: "PostToolUse", tool_name: input.tool, tool_input: input.args, tool_response: output, ...input }, $, true)
1157
+ },
1158
+ })
1159
+ `;
1160
+ const pluginDir = path.join(versionHome, '.config', 'opencode', 'plugins');
1161
+ try {
1162
+ fs.mkdirSync(pluginDir, { recursive: true });
1163
+ fs.writeFileSync(path.join(pluginDir, 'agents-cli-hooks.ts'), pluginSource, 'utf-8');
1164
+ }
1165
+ catch (e) {
1166
+ errors.push(`Failed to write agents-cli-hooks.ts: ${e.message}`);
1167
+ }
1168
+ return { registered, errors };
1169
+ }
1014
1170
  /**
1015
1171
  * Antigravity (agy) event names differ from agents-cli manifest names. The
1016
1172
  * mapping below is the documented agy schema. PostToolUse has no exact
@@ -107,6 +107,13 @@ export interface DispatchOptions {
107
107
  name?: string;
108
108
  /** Resume an existing session on the host by id (via `agents run --resume`). */
109
109
  resume?: string;
110
+ /**
111
+ * Forward `--emit-session-id` so the remote run prints its resolved session id
112
+ * as a stdout sentinel (hosts/session-marker.ts). The launcher parses that id
113
+ * out of the followed log and stamps it on the task — the join that maps a
114
+ * remote-created session back home for agents that don't take `--session-id`.
115
+ */
116
+ emitSessionId?: boolean;
110
117
  /** Stream progress and block until completion (default true). */
111
118
  follow?: boolean;
112
119
  timeoutMs?: number;
@@ -298,6 +298,8 @@ export function buildRunForwardedArgs(opts) {
298
298
  args.push('--resume', opts.resume);
299
299
  else if (opts.sessionId)
300
300
  args.push('--session-id', opts.sessionId);
301
+ if (opts.emitSessionId)
302
+ args.push('--emit-session-id');
301
303
  if (opts.passthroughArgs && opts.passthroughArgs.length > 0)
302
304
  args.push('--', ...opts.passthroughArgs);
303
305
  return args;
@@ -98,6 +98,8 @@ const REMOTE_PASSTHROUGH = {
98
98
  const OWN_HOST_COMMANDS = new Set([
99
99
  'run',
100
100
  'exec', // deprecated alias of run
101
+ 'harness', // `--host <agent>` names the host CLI to run under, not a remote device
102
+ 'harnesses',
101
103
  'sessions',
102
104
  'feed',
103
105
  'computer',
@@ -0,0 +1,78 @@
1
+ import { type Host } from './types.js';
2
+ /** ssh's connection-layer failure code — the signal that the link dropped rather
3
+ * than the remote command exiting on its own. Mirrors ssh-exec.ts `sshStream`. */
4
+ export declare const SSH_CONN_FAILURE = 255;
5
+ /** Consecutive failed-to-connect reattaches before giving up. Backoff is capped at
6
+ * {@link MAX_BACKOFF_MS}. A reattach that actually reconnected (then dropped again)
7
+ * refills the budget, so a long session that blinks all day reconnects every time
8
+ * — only consecutive UNREACHABLE attempts exhaust it. */
9
+ export declare const MAX_ATTEMPTS = 6;
10
+ export interface ReconnectState {
11
+ /** Consecutive failed-to-connect reattaches since the last genuine reconnection. */
12
+ attempt: number;
13
+ }
14
+ export interface ReconnectOutcome {
15
+ /** Exit code of the run (initial) or the last re-attach. */
16
+ code: number;
17
+ /** Whether the ssh handshake for this attempt actually completed. The initial run
18
+ * and any reattach whose preflight probe succeeded are `connected`; a reattach
19
+ * that couldn't reach the host is not. Drives the budget refill (see file head). */
20
+ connected: boolean;
21
+ }
22
+ export type ReconnectDecision = {
23
+ action: 'stop';
24
+ code: number;
25
+ } | {
26
+ action: 'retry';
27
+ waitMs: number;
28
+ state: ReconnectState;
29
+ };
30
+ export declare function initialReconnectState(): ReconnectState;
31
+ /** Exponential backoff capped at {@link MAX_BACKOFF_MS}: 2s, 4s, 8s, 16s, 30s… */
32
+ export declare function backoffMs(attempt: number): number;
33
+ /**
34
+ * Decide what to do after a run/re-attach returned `outcome`. Pure — the only
35
+ * input is the prior state and the outcome, the only output is the next action.
36
+ *
37
+ * - a non-255 code means the remote command spoke for itself (clean detach = 0,
38
+ * agent exit / no live session = non-zero) → stop and surface that code.
39
+ * - a 255 means the link dropped → retry, unless the budget is spent.
40
+ * - a 255 from an attempt that DID connect (a genuine reconnection that then
41
+ * dropped) refills the budget first; a 255 that never connected counts against
42
+ * it, so a host that stays unreachable gives up after MAX_ATTEMPTS.
43
+ */
44
+ export declare function reconnectStep(state: ReconnectState, outcome: ReconnectOutcome): ReconnectDecision;
45
+ /** Human-readable notice shown before each reconnect wait. "13 seconds", not "12.8s". */
46
+ export declare function reconnectNotice(sessionId: string, host: string, attempt: number, waitMs: number): string;
47
+ /** Notice shown once the retry budget is spent. */
48
+ export declare function exhaustedNotice(sessionId: string, host: string): string;
49
+ /**
50
+ * Re-attach the live remote tmux pane for `sessionId` by driving the peer's own
51
+ * `agents sessions focus`. A fast, un-multiplexed preflight probe (`ssh … true`)
52
+ * first establishes whether the host is actually reachable this attempt — that
53
+ * `connected` bit, not the call duration, is what the retry policy keys on. Only on
54
+ * a reachable host do we run the interactive attach (which carries no credentials —
55
+ * the agent already runs on the peer — so it rides the normal transport). Returns
56
+ * the ssh exit code (255 = dropped again / unreachable; 0 = clean detach; other =
57
+ * session ended) plus whether this attempt connected.
58
+ */
59
+ export declare function reattachRemoteSession(host: Host, sessionId: string): ReconnectOutcome;
60
+ export interface ReconnectLoopOpts {
61
+ host: Host;
62
+ sessionId: string;
63
+ /** The exit code from the initial interactive run (which, having run the agent,
64
+ * is treated as a connected attempt). */
65
+ initialExit: number;
66
+ /** Injected for tests: the real re-attach and wait are swapped for deterministic
67
+ * fakes so the loop's control flow is exercised without SSH. Production uses the
68
+ * real {@link reattachRemoteSession} + `sleep`. */
69
+ reattach?: (host: Host, sessionId: string) => ReconnectOutcome;
70
+ wait?: (ms: number) => Promise<void>;
71
+ write?: (s: string) => void;
72
+ }
73
+ /**
74
+ * Drive the reconnect loop from the initial run's outcome to a terminal code.
75
+ * Only the real SSH re-attach and the wait are side effects; the decision is
76
+ * {@link reconnectStep}. Returns the exit code the process should ultimately use.
77
+ */
78
+ export declare function reconnectInteractiveSession(opts: ReconnectLoopOpts): Promise<number>;