@phnx-labs/agents-cli 1.20.74 → 1.20.77

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 (231) hide show
  1. package/CHANGELOG.md +1055 -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 +83 -13
  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 +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  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.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Resolve a remote-created session id back to an interactive `--host` launcher.
3
+ *
4
+ * A headless `--host` run captures the remote-coined id from the followed log
5
+ * (the `--emit-session-id` marker — see session-marker.ts). An INTERACTIVE run
6
+ * has no followed log: the local TTY is wired straight through `sshStream`
7
+ * (stdio:'inherit'), so nothing can tap the stream for a marker without breaking
8
+ * the agent's raw-mode TUI. Instead we correlate by AGENT_LAUNCH_ID.
9
+ *
10
+ * The launcher forwards a launch id it controls (`--env AGENT_LAUNCH_ID=<id>`);
11
+ * the remote `agents run` adopts it (exec.ts `resolveLaunchId`), so the remote
12
+ * SessionStart hook records the agent's REAL session id under that exact key in
13
+ * `~/.agents/.cache/terminals/sessions/<pid>.json`. After the stream returns we
14
+ * do ONE ssh read of that dir and pick the record whose `launch_id` matches —
15
+ * the same launch-id join `agents sessions --active` uses locally
16
+ * (session/hook-sessions.ts), just across the SSH hop.
17
+ *
18
+ * This gives the interactive path a real per-agent id to register in the local
19
+ * session index and to reconnect against, for Codex/Kimi/Grok/Gemini — closing
20
+ * the gap RUSH-2033 left for every non-Claude agent (Claude still forces its own
21
+ * id up front and never reaches here).
22
+ */
23
+ import { sshExec } from '../ssh-exec.js';
24
+ /**
25
+ * Pick the real session id for `launchId` from the remote hook records.
26
+ *
27
+ * `recordsJson` is the newline-delimited JSON the remote read emits — one record
28
+ * per line (a `cat` of every `sessions/*.json`). Scan for the record whose
29
+ * `launch_id` matches and return its `session_id`; when several match (pid reuse,
30
+ * a lingering file) the NEWEST by `ts` wins, mirroring the local reader's
31
+ * keep-newest tie-break (session/hook-sessions.ts). Pure so the correlation is
32
+ * unit-testable from fixtures with no SSH. Returns undefined when no record
33
+ * carries the launch id (the hook hasn't landed yet, or a hookless harness).
34
+ */
35
+ export function pickRemoteSessionId(recordsJson, launchId) {
36
+ if (!launchId)
37
+ return undefined;
38
+ let best;
39
+ for (const line of recordsJson.split('\n')) {
40
+ const trimmed = line.trim();
41
+ if (!trimmed)
42
+ continue;
43
+ let rec;
44
+ try {
45
+ rec = JSON.parse(trimmed);
46
+ }
47
+ catch {
48
+ continue; // a partial/garbled line — skip, never throw
49
+ }
50
+ if (rec.launch_id !== launchId)
51
+ continue;
52
+ if (typeof rec.session_id !== 'string' || !rec.session_id)
53
+ continue;
54
+ const ts = typeof rec.ts === 'number' ? rec.ts : 0;
55
+ // Strict `>` so the FIRST record wins a tie — matches keepNewest (hook-sessions.ts).
56
+ if (!best || ts > best.ts)
57
+ best = { sid: rec.session_id, ts };
58
+ }
59
+ return best?.sid;
60
+ }
61
+ /**
62
+ * Read the remote hook-session dir over ssh and resolve the real session id the
63
+ * remote run recorded under `launchId`. One round-trip, best-effort: any ssh
64
+ * failure, a missing dir, or an absent record yields undefined — the caller then
65
+ * keeps the un-mapped run rather than a fabricated id.
66
+ *
67
+ * `$HOME` expands on the REMOTE login shell (never the local box). The glob is a
68
+ * literal path with no user input, so it is injection-safe; a `2>/dev/null` on a
69
+ * globless dir keeps the command quiet when the dir is empty or absent.
70
+ */
71
+ export function resolveRemoteSessionId(target, launchId, timeoutMs = 6000) {
72
+ if (!launchId)
73
+ return undefined;
74
+ // `cat` every record onto its own line. `head -c` caps a runaway file; the
75
+ // trailing `true` keeps the pipeline's exit 0 even when the glob matches nothing.
76
+ const cmd = 'cat "$HOME"/.agents/.cache/terminals/sessions/*.json 2>/dev/null | head -c 1048576 || true';
77
+ const res = sshExec(target, cmd, { timeoutMs, multiplex: true });
78
+ if (res.code !== 0 && !res.stdout)
79
+ return undefined;
80
+ // Each record is a single-line JSON object; `cat` may run them together, so
81
+ // normalise `}{` boundaries onto separate lines before scanning.
82
+ const normalised = res.stdout.replace(/\}\s*\{/g, '}\n{');
83
+ return pickRemoteSessionId(normalised, launchId);
84
+ }
@@ -45,6 +45,8 @@ export interface HostPromptRun {
45
45
  name?: string;
46
46
  /** Resume an existing session on the host by concrete id. */
47
47
  resume?: string;
48
+ /** Explicit id for a new Claude session. */
49
+ sessionId?: string;
48
50
  /** Working directory on the host, already made remote-portable by the caller. */
49
51
  remoteCwd?: string;
50
52
  /** Stream progress and block until completion (default true). */
@@ -74,11 +76,21 @@ export interface HostPromptRun {
74
76
  /** Copy runtime credentials to the host before the run and shred them after. */
75
77
  copyCreds?: HostCredentials;
76
78
  }
79
+ /** Resolve the id the remote host will adopt for a fresh Claude session. */
80
+ export declare function resolveHostSessionId(agent: string, resume?: string, sessionId?: string): string | undefined;
77
81
  /**
78
- * Dispatch a headless prompt run onto a resolved host: mint the forced session
79
- * id (Claude is the only agent that accepts `--session-id`; on resume the
80
- * remote session keeps its id), launch detached over SSH, and register the run
81
- * in the LOCAL session index. Returns the task record and the remote exit code
82
- * (`-1` = follow window closed while the run continues).
82
+ * Dispatch a headless prompt run onto a resolved host, then relate the run's
83
+ * session id back to this launcher for EVERY agent not just Claude.
84
+ *
85
+ * Claude is the only agent that accepts a forced `--session-id`, so we mint one
86
+ * up front and know it immediately. For every other agent the remote run coins
87
+ * its OWN id; we forward `--emit-session-id` so the remote prints that id as a
88
+ * stdout sentinel (hosts/session-marker.ts), and once the follow returns we parse
89
+ * it out of the mirrored local log and stamp it on the task before registering —
90
+ * so `agents sessions`/resume-by-id can map the discovered session home. On
91
+ * resume the remote session keeps its existing id (passed through, no capture).
92
+ *
93
+ * Returns the task record and the remote exit code (`-1` = follow window closed
94
+ * while the run continues).
83
95
  */
84
96
  export declare function dispatchPromptToHost(host: Host, opts: HostPromptRun): Promise<DispatchResult>;
@@ -17,7 +17,7 @@
17
17
  import { randomUUID } from 'crypto';
18
18
  import { resolveHost, resolveHostByCap } from './registry.js';
19
19
  import { dispatchToHost } from './dispatch.js';
20
- import { registerHostSession } from './session-index.js';
20
+ import { registerHostSession, captureRemoteSessionId } from './session-index.js';
21
21
  /**
22
22
  * Resolution failed with a user-actionable message the caller should print
23
23
  * verbatim. Distinct from `DeviceOffloadUnsupportedError` (which propagates to
@@ -53,15 +53,31 @@ export async function resolveHostRunTarget(name, opts = {}) {
53
53
  throw new HostResolutionError(`Unknown host "${name}". List hosts: agents hosts list`);
54
54
  return host;
55
55
  }
56
+ /** Resolve the id the remote host will adopt for a fresh Claude session. */
57
+ export function resolveHostSessionId(agent, resume, sessionId) {
58
+ return agent === 'claude' && !resume ? sessionId ?? randomUUID() : undefined;
59
+ }
56
60
  /**
57
- * Dispatch a headless prompt run onto a resolved host: mint the forced session
58
- * id (Claude is the only agent that accepts `--session-id`; on resume the
59
- * remote session keeps its id), launch detached over SSH, and register the run
60
- * in the LOCAL session index. Returns the task record and the remote exit code
61
- * (`-1` = follow window closed while the run continues).
61
+ * Dispatch a headless prompt run onto a resolved host, then relate the run's
62
+ * session id back to this launcher for EVERY agent not just Claude.
63
+ *
64
+ * Claude is the only agent that accepts a forced `--session-id`, so we mint one
65
+ * up front and know it immediately. For every other agent the remote run coins
66
+ * its OWN id; we forward `--emit-session-id` so the remote prints that id as a
67
+ * stdout sentinel (hosts/session-marker.ts), and once the follow returns we parse
68
+ * it out of the mirrored local log and stamp it on the task before registering —
69
+ * so `agents sessions`/resume-by-id can map the discovered session home. On
70
+ * resume the remote session keeps its existing id (passed through, no capture).
71
+ *
72
+ * Returns the task record and the remote exit code (`-1` = follow window closed
73
+ * while the run continues).
62
74
  */
63
75
  export async function dispatchPromptToHost(host, opts) {
64
- const sessionId = opts.agent === 'claude' && !opts.resume ? randomUUID() : undefined;
76
+ const forcedSessionId = resolveHostSessionId(opts.agent, opts.resume, opts.sessionId);
77
+ // Ask the remote to print its resolved id whenever we did NOT force one (every
78
+ // non-Claude agent, and Claude-on-resume where the id is already known). No-op
79
+ // when the run isn't followed — nothing tails the log to catch the marker.
80
+ const emitSessionId = !forcedSessionId && !opts.resume && opts.follow !== false;
65
81
  const result = await dispatchToHost(host, {
66
82
  agent: opts.agent,
67
83
  prompt: opts.prompt,
@@ -69,7 +85,8 @@ export async function dispatchPromptToHost(host, opts) {
69
85
  mode: opts.mode,
70
86
  model: opts.model,
71
87
  remoteCwd: opts.remoteCwd,
72
- sessionId,
88
+ sessionId: forcedSessionId,
89
+ emitSessionId,
73
90
  name: opts.name,
74
91
  resume: opts.resume,
75
92
  follow: opts.follow !== false,
@@ -94,6 +111,9 @@ export async function dispatchPromptToHost(host, opts) {
94
111
  passthroughArgs: opts.passthroughArgs,
95
112
  copyCreds: opts.copyCreds,
96
113
  });
97
- registerHostSession(result.task, { cwd: opts.cwd ?? process.cwd(), prompt: opts.prompt });
98
- return result;
114
+ // Capture the remote-coined id from the followed log (non-Claude); harmlessly a
115
+ // no-op when the task already carries a forced/resumed id or no marker landed.
116
+ const task = emitSessionId ? captureRemoteSessionId(result.task) ?? result.task : result.task;
117
+ registerHostSession(task, { cwd: opts.cwd ?? process.cwd(), prompt: opts.prompt });
118
+ return { ...result, task };
99
119
  }
@@ -13,7 +13,7 @@
13
13
  * `[cloud/<status>]` convention.
14
14
  */
15
15
  import type { SessionMeta } from '../session/types.js';
16
- import type { HostTask } from './tasks.js';
16
+ import { type HostTask } from './tasks.js';
17
17
  export interface HostSessionContext {
18
18
  /** Local directory the `agents run --host` was invoked from. */
19
19
  cwd: string;
@@ -32,6 +32,23 @@ export declare function hostSessionMeta(task: HostTask, ctx: HostSessionContext)
32
32
  * break the dispatch itself, which has already been launched on the host.
33
33
  */
34
34
  export declare function registerHostSession(task: HostTask, ctx: HostSessionContext): void;
35
+ /**
36
+ * Relate a remote-created session id back to a followed host dispatch.
37
+ *
38
+ * The remote run (dispatched with `--emit-session-id`) prints its resolved id as
39
+ * a stdout sentinel (see session-marker.ts) that rides the followed log into the
40
+ * task's local mirror. Read that mirror, parse the id, and stamp it on the task
41
+ * so `findTaskBySessionId` and the session-index registration work for agents
42
+ * that never take a forced `--session-id` — closing the gap where every
43
+ * non-Claude host run was orphaned.
44
+ *
45
+ * Returns the updated task when an id was captured (and differs from what's on
46
+ * the record), else null. A task that already carries an id (Claude's forced id,
47
+ * a resume) is left untouched — the marker only fills a genuinely empty slot, so
48
+ * it can never overwrite an authoritative id with a stale echo. Best-effort: a
49
+ * missing/unreadable mirror or absent marker yields null, never an exception.
50
+ */
51
+ export declare function captureRemoteSessionId(task: HostTask): HostTask | null;
35
52
  export interface InteractiveHostSessionContext {
36
53
  cwd: string;
37
54
  host: string;
@@ -12,8 +12,12 @@
12
12
  * local run. The `[host/<name>]` label mirrors the cloud path's
13
13
  * `[cloud/<status>]` convention.
14
14
  */
15
+ import * as fs from 'fs';
15
16
  import { upsertSession } from '../session/db.js';
16
- import { SESSION_AGENTS } from '../session/types.js';
17
+ import { isSessionTrackedAgent } from '../session/types.js';
18
+ import { localLogPath, updateTask } from './tasks.js';
19
+ import { parseSessionIdMarker } from './session-marker.js';
20
+ import { deriveShortId } from '../session/short-id.js';
17
21
  /**
18
22
  * Build the SessionMeta for a host-dispatched run. Returns null when the run has
19
23
  * no captured session id (nothing stable to key/resume on) or its agent isn't a
@@ -23,11 +27,11 @@ export function hostSessionMeta(task, ctx) {
23
27
  const id = task.sessionId;
24
28
  if (!id)
25
29
  return null;
26
- if (!SESSION_AGENTS.includes(task.agent))
30
+ if (!isSessionTrackedAgent(task.agent))
27
31
  return null;
28
32
  return {
29
33
  id,
30
- shortId: id.slice(0, 8),
34
+ shortId: deriveShortId(id),
31
35
  agent: task.agent,
32
36
  timestamp: task.createdAt,
33
37
  cwd: ctx.cwd,
@@ -57,6 +61,37 @@ export function registerHostSession(task, ctx) {
57
61
  /* index write is best-effort; the run is already live on the host */
58
62
  }
59
63
  }
64
+ /**
65
+ * Relate a remote-created session id back to a followed host dispatch.
66
+ *
67
+ * The remote run (dispatched with `--emit-session-id`) prints its resolved id as
68
+ * a stdout sentinel (see session-marker.ts) that rides the followed log into the
69
+ * task's local mirror. Read that mirror, parse the id, and stamp it on the task
70
+ * so `findTaskBySessionId` and the session-index registration work for agents
71
+ * that never take a forced `--session-id` — closing the gap where every
72
+ * non-Claude host run was orphaned.
73
+ *
74
+ * Returns the updated task when an id was captured (and differs from what's on
75
+ * the record), else null. A task that already carries an id (Claude's forced id,
76
+ * a resume) is left untouched — the marker only fills a genuinely empty slot, so
77
+ * it can never overwrite an authoritative id with a stale echo. Best-effort: a
78
+ * missing/unreadable mirror or absent marker yields null, never an exception.
79
+ */
80
+ export function captureRemoteSessionId(task) {
81
+ if (task.sessionId)
82
+ return null;
83
+ let text;
84
+ try {
85
+ text = fs.readFileSync(localLogPath(task.id), 'utf8');
86
+ }
87
+ catch {
88
+ return null; // no local mirror (unfollowed run, or read raced the follow)
89
+ }
90
+ const captured = parseSessionIdMarker(text);
91
+ if (!captured)
92
+ return null;
93
+ return updateTask(task.id, { sessionId: captured });
94
+ }
60
95
  /**
61
96
  * Register an interactive host run (no prompt, TTY forwarded over SSH) in the
62
97
  * local session index. Unlike detached host runs, there is no remote log/exit
@@ -64,12 +99,12 @@ export function registerHostSession(task, ctx) {
64
99
  * surface and resume it by id.
65
100
  */
66
101
  export function registerInteractiveHostSession(ctx) {
67
- if (!SESSION_AGENTS.includes(ctx.agent))
102
+ if (!isSessionTrackedAgent(ctx.agent))
68
103
  return;
69
104
  try {
70
105
  upsertSession({
71
106
  id: ctx.sessionId,
72
- shortId: ctx.sessionId.slice(0, 8),
107
+ shortId: deriveShortId(ctx.sessionId),
73
108
  agent: ctx.agent,
74
109
  timestamp: ctx.createdAt ?? new Date().toISOString(),
75
110
  cwd: ctx.cwd,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Relate a remote-created session id back to the launching agent.
3
+ *
4
+ * A host dispatch (`agents run --host`) launches `agents run` on the remote box.
5
+ * Only Claude accepts a forced `--session-id`, so for every OTHER agent the
6
+ * remote run coins its OWN session id (via its SessionStart hook) and the
7
+ * launcher never learns it — the run is orphaned in `agents sessions`.
8
+ *
9
+ * This module closes that gap without an extra SSH round-trip: the dispatch
10
+ * forwards `--emit-session-id`, which makes the remote `agents run` print its
11
+ * resolved session id as a stable one-line sentinel to stdout. That line rides
12
+ * the same combined log the follow already mirrors locally, so the launcher
13
+ * parses the id straight out of the streamed output and stamps it on the task.
14
+ *
15
+ * The marker is a fixed ASCII frame with the id on its own line. A real session
16
+ * id (`[A-Za-z0-9._-]`) never contains the marker's own bytes, so the parser can
17
+ * scan for the LAST occurrence and stay robust to an agent echoing the token in
18
+ * its own output.
19
+ */
20
+ /**
21
+ * The single line the remote run prints to stdout so the launcher can capture
22
+ * the session id the remote coined. Newline-framed on both sides so it lands on
23
+ * its own line regardless of what the agent last wrote.
24
+ */
25
+ export declare function sessionIdMarkerLine(sessionId: string): string;
26
+ /**
27
+ * Extract the session id from a chunk of followed remote output, or null when no
28
+ * marker is present. Scans for the LAST marker (so an id echoed into the log
29
+ * earlier can never mask the real trailing sentinel) and validates the captured
30
+ * token against the session-id charset — a malformed frame yields null rather
31
+ * than a bogus id that could never match a real session.
32
+ */
33
+ export declare function parseSessionIdMarker(text: string): string | null;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Relate a remote-created session id back to the launching agent.
3
+ *
4
+ * A host dispatch (`agents run --host`) launches `agents run` on the remote box.
5
+ * Only Claude accepts a forced `--session-id`, so for every OTHER agent the
6
+ * remote run coins its OWN session id (via its SessionStart hook) and the
7
+ * launcher never learns it — the run is orphaned in `agents sessions`.
8
+ *
9
+ * This module closes that gap without an extra SSH round-trip: the dispatch
10
+ * forwards `--emit-session-id`, which makes the remote `agents run` print its
11
+ * resolved session id as a stable one-line sentinel to stdout. That line rides
12
+ * the same combined log the follow already mirrors locally, so the launcher
13
+ * parses the id straight out of the streamed output and stamps it on the task.
14
+ *
15
+ * The marker is a fixed ASCII frame with the id on its own line. A real session
16
+ * id (`[A-Za-z0-9._-]`) never contains the marker's own bytes, so the parser can
17
+ * scan for the LAST occurrence and stay robust to an agent echoing the token in
18
+ * its own output.
19
+ */
20
+ const MARKER_PREFIX = '@@AGENTS_SESSION_ID ';
21
+ const MARKER_SUFFIX = '@@';
22
+ /** Only characters a real agent session id can hold — no marker bytes, no spaces. */
23
+ const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/;
24
+ /**
25
+ * The single line the remote run prints to stdout so the launcher can capture
26
+ * the session id the remote coined. Newline-framed on both sides so it lands on
27
+ * its own line regardless of what the agent last wrote.
28
+ */
29
+ export function sessionIdMarkerLine(sessionId) {
30
+ return `\n${MARKER_PREFIX}${sessionId}${MARKER_SUFFIX}\n`;
31
+ }
32
+ /**
33
+ * Extract the session id from a chunk of followed remote output, or null when no
34
+ * marker is present. Scans for the LAST marker (so an id echoed into the log
35
+ * earlier can never mask the real trailing sentinel) and validates the captured
36
+ * token against the session-id charset — a malformed frame yields null rather
37
+ * than a bogus id that could never match a real session.
38
+ */
39
+ export function parseSessionIdMarker(text) {
40
+ const last = text.lastIndexOf(MARKER_PREFIX);
41
+ if (last === -1)
42
+ return null;
43
+ const start = last + MARKER_PREFIX.length;
44
+ const end = text.indexOf(MARKER_SUFFIX, start);
45
+ if (end === -1)
46
+ return null;
47
+ const id = text.slice(start, end).trim();
48
+ if (!SESSION_ID_RE.test(id))
49
+ return null;
50
+ return id;
51
+ }
@@ -24,10 +24,14 @@ export interface HostTask {
24
24
  */
25
25
  name?: string;
26
26
  /**
27
- * The agent session id the remote run was launched with (Claude only — the
28
- * only agent that accepts `--session-id` to force a NEW session's id). Lets
29
- * `agents sessions`/resume-by-id map a discovered session back to the host it
30
- * lives on. Absent for agents that don't take an explicit session id.
27
+ * The remote run's agent session id, so `agents sessions`/resume-by-id can map
28
+ * a discovered session back to the host it lives on. Two sources: for Claude
29
+ * (the only agent that accepts `--session-id`) it's the id we FORCED at
30
+ * dispatch; for every other agent it's the id the remote COINED, captured from
31
+ * the run's stdout sentinel (`--emit-session-id`, see session-marker.ts) once
32
+ * the follow returns and stamped on the record via `captureRemoteSessionId`.
33
+ * Absent only until that capture lands — e.g. an unfollowed (`--no-follow`)
34
+ * non-Claude run whose id the reconcile path fills in later.
31
35
  */
32
36
  sessionId?: string;
33
37
  /** Remote paths (under the host's ~/.agents/.cache/hosts/). */
@@ -127,10 +127,12 @@ export declare function resolvePackageDirFromBinary(binaryPath: string): string
127
127
  */
128
128
  export declare function seedIsolatedConfigFromLocal(agentId: AgentId, version: string, opts?: {
129
129
  withAuth?: boolean;
130
+ all?: boolean;
130
131
  }): {
131
132
  seeded: boolean;
132
133
  from: string;
133
134
  to: string;
134
135
  skippedAuth: string[];
136
+ skippedRuntime: string[];
135
137
  error?: string;
136
138
  };
@@ -263,13 +263,35 @@ export function seedIsolatedConfigFromLocal(agentId, version, opts = {}) {
263
263
  const versionHome = path.join(getVersionsDir(), agentId, version, 'home');
264
264
  // Mirror importAgentConfig's derivation so nested config dirs (e.g. Antigravity's
265
265
  // ~/.gemini/antigravity-cli) land where the shim expects them.
266
- const dest = path.join(versionHome, path.relative(os.homedir(), configDir));
267
- const result = { seeded: false, from: configDir, to: dest, skippedAuth: [] };
266
+ let dest = path.join(versionHome, path.relative(os.homedir(), configDir));
267
+ // Codex uses a SUN_LEN-safe CODEX_HOME: `home/.codex` is a SYMLINK to
268
+ // `~/.agents/.codex-homes/<version>/.codex`, because the real path is too long for a
269
+ // unix socket. cpSync cannot overwrite a symlink with a directory (it fails with
270
+ // "Cannot overwrite non-directory"), and writing beside it would put settings
271
+ // somewhere the agent never reads. Follow the link and seed the actual home.
272
+ try {
273
+ if (fs.lstatSync(dest).isSymbolicLink())
274
+ dest = fs.realpathSync(dest);
275
+ }
276
+ catch {
277
+ /* not created yet — the plain path is correct */
278
+ }
279
+ const result = { seeded: false, from: configDir, to: dest, skippedAuth: [], skippedRuntime: [] };
268
280
  if (!fs.existsSync(configDir))
269
281
  return result;
270
282
  // Known credential paths, relative to the config dir. `authFiles` covers the agents
271
283
  // that declare them for cross-version carry; the rest are verified filenames for
272
284
  // agents that do not declare any (codex `auth.json`, claude `.credentials.json`).
285
+ // Runtime state, not settings. Seeding a sandbox with the user's session history,
286
+ // logs and caches duplicated 757MB on a real machine — 349MB of `sessions` alone —
287
+ // for a copy that wants config. These are regenerated by the agent as it runs, and
288
+ // an isolated copy keeps its own; carrying them over also drags conversation history
289
+ // into a sandbox the user may have created precisely to keep separate.
290
+ const RUNTIME_PREFIXES = ['sessions', 'log', 'logs', 'cache', '.tmp', 'tmp', 'generated_images'];
291
+ const RUNTIME_FILES = ['history.jsonl', 'session_index.jsonl'];
292
+ const isRuntime = (rel) => !opts.all && (RUNTIME_FILES.includes(rel) ||
293
+ /\.sqlite(-shm|-wal)?$/.test(rel) ||
294
+ RUNTIME_PREFIXES.some((d) => rel === d || rel.startsWith(d + path.sep)));
273
295
  const authRel = new Set([
274
296
  ...(agent.authFiles ?? []),
275
297
  'auth.json',
@@ -282,12 +304,23 @@ export function seedIsolatedConfigFromLocal(agentId, version, opts = {}) {
282
304
  const inside = agentsDir + path.sep;
283
305
  fs.cpSync(configDir, dest, {
284
306
  recursive: true,
307
+ // `force: true` is Node's default, but Bun drops it when a `filter` is supplied —
308
+ // existing files are then silently left alone. `dist/bin/agents` is bun-compiled,
309
+ // so this is a production path, not just a test artifact. State it explicitly.
310
+ force: true,
285
311
  filter: (src) => {
286
312
  const rel = path.relative(configDir, src);
287
313
  if (!opts.withAuth && rel && (authRel.has(rel) || rel.startsWith('credentials' + path.sep))) {
288
314
  result.skippedAuth.push(rel);
289
315
  return false;
290
316
  }
317
+ if (rel && isRuntime(rel)) {
318
+ // Record only the top-level name so the report stays one line per item.
319
+ const top = rel.split(path.sep)[0];
320
+ if (!result.skippedRuntime.includes(top))
321
+ result.skippedRuntime.push(top);
322
+ return false;
323
+ }
291
324
  // Same rule as the export path: a link into ~/.agents would dangle for a copy
292
325
  // that is supposed to stand on its own.
293
326
  try {
@@ -14,10 +14,39 @@
14
14
  * migration (`installMenubarLaunchAgent` in migrate.ts) honors, so a disabled
15
15
  * menu bar never silently comes back on the next release.
16
16
  */
17
+ /**
18
+ * Absolute path to the installed MenubarHelper executable if it exists on disk,
19
+ * else null. The desktop notifier (notify-desktop.ts) routes daemon
20
+ * notifications through this one-shot (`MenubarHelper --notify ...`) so they
21
+ * carry the agents-cli mark rather than the generic osascript icon. Null on
22
+ * non-darwin or when the helper is not installed (menu bar disabled, a Linux
23
+ * package, or a dev checkout without a built bundle).
24
+ */
25
+ export declare function resolveInstalledMenubarExecutable(): string | null;
17
26
  /** True if the user explicitly disabled the menu bar (don't auto-enable on upgrade). */
18
27
  export declare function menubarDisabledByUser(): boolean;
19
28
  /** True if the launchd plist for the menu-bar service is installed. */
20
29
  export declare function menubarServiceInstalled(): boolean;
30
+ /** True when the bundle carries a signature the kernel will accept at launch. */
31
+ export declare function codesignVerifies(appPath: string): boolean;
32
+ /**
33
+ * Guarantee the installed bundle has a valid signature on THIS machine.
34
+ *
35
+ * npm's pack/extract strips the ad-hoc/linker signature the release baked into
36
+ * the helper, leaving `code object is not signed at all`. On macOS 26+ the
37
+ * kernel's code-signing monitor SIGKILLs an unsigned/invalid binary at launch
38
+ * (`SIGKILL (Code Signature Invalid)`), so under the launchd `KeepAlive` service
39
+ * an ad-hoc release helper crash-loops forever and its unstable identity makes
40
+ * the Accessibility grant (needed for the clip→paste keystroke in Clip.swift)
41
+ * re-prompt every time. A fresh ad-hoc re-sign gives the on-disk bytes a
42
+ * matching cdhash, which the kernel accepts.
43
+ *
44
+ * A Developer-ID-signed helper survives npm untouched — its embedded signature
45
+ * still verifies — so we leave it alone and only re-sign when verification
46
+ * fails. Returns whether the bundle ends up validly signed. No-op cost on the
47
+ * common (already-valid) path is a single `codesign --verify`.
48
+ */
49
+ export declare function ensureValidSignature(appPath: string): boolean;
21
50
  /**
22
51
  * Copy the bundled `.app` to the stable user path (idempotent unless forced).
23
52
  * Returns the installed executable path, or null if no source bundle ships
@@ -57,6 +57,20 @@ function readInstalledMenubarVersion() {
57
57
  function installedExecutablePath() {
58
58
  return path.join(installedAppPath(), 'Contents', 'MacOS', 'MenubarHelper');
59
59
  }
60
+ /**
61
+ * Absolute path to the installed MenubarHelper executable if it exists on disk,
62
+ * else null. The desktop notifier (notify-desktop.ts) routes daemon
63
+ * notifications through this one-shot (`MenubarHelper --notify ...`) so they
64
+ * carry the agents-cli mark rather than the generic osascript icon. Null on
65
+ * non-darwin or when the helper is not installed (menu bar disabled, a Linux
66
+ * package, or a dev checkout without a built bundle).
67
+ */
68
+ export function resolveInstalledMenubarExecutable() {
69
+ if (!onDarwin())
70
+ return null;
71
+ const exec = installedExecutablePath();
72
+ return fs.existsSync(exec) ? exec : null;
73
+ }
60
74
  /** ~/Library/LaunchAgents/com.phnx-labs.agents-menubar.plist */
61
75
  function servicePlistPath() {
62
76
  return path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
@@ -135,6 +149,37 @@ function copyAppBundle(src, dest) {
135
149
  throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
136
150
  }
137
151
  }
152
+ /** True when the bundle carries a signature the kernel will accept at launch. */
153
+ export function codesignVerifies(appPath) {
154
+ const r = spawnSync('codesign', ['--verify', '--strict', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
155
+ return r.status === 0;
156
+ }
157
+ /**
158
+ * Guarantee the installed bundle has a valid signature on THIS machine.
159
+ *
160
+ * npm's pack/extract strips the ad-hoc/linker signature the release baked into
161
+ * the helper, leaving `code object is not signed at all`. On macOS 26+ the
162
+ * kernel's code-signing monitor SIGKILLs an unsigned/invalid binary at launch
163
+ * (`SIGKILL (Code Signature Invalid)`), so under the launchd `KeepAlive` service
164
+ * an ad-hoc release helper crash-loops forever and its unstable identity makes
165
+ * the Accessibility grant (needed for the clip→paste keystroke in Clip.swift)
166
+ * re-prompt every time. A fresh ad-hoc re-sign gives the on-disk bytes a
167
+ * matching cdhash, which the kernel accepts.
168
+ *
169
+ * A Developer-ID-signed helper survives npm untouched — its embedded signature
170
+ * still verifies — so we leave it alone and only re-sign when verification
171
+ * fails. Returns whether the bundle ends up validly signed. No-op cost on the
172
+ * common (already-valid) path is a single `codesign --verify`.
173
+ */
174
+ export function ensureValidSignature(appPath) {
175
+ if (codesignVerifies(appPath))
176
+ return true;
177
+ // Drop any quarantine/xattrs the tarball round-trip added (they can break
178
+ // codesign), then re-sign ad-hoc under the helper's stable bundle identifier.
179
+ spawnSync('xattr', ['-cr', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
180
+ spawnSync('codesign', ['--force', '--sign', '-', '--identifier', SERVICE_LABEL, appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
181
+ return codesignVerifies(appPath);
182
+ }
138
183
  /**
139
184
  * Copy the bundled `.app` to the stable user path (idempotent unless forced).
140
185
  * Returns the installed executable path, or null if no source bundle ships
@@ -147,9 +192,14 @@ export function ensureMenubarAppInstalled(opts = {}) {
147
192
  if (!src)
148
193
  return null;
149
194
  const dest = installedAppPath();
150
- if (!opts.forceReinstall && fs.existsSync(dest))
195
+ if (!opts.forceReinstall && fs.existsSync(dest)) {
196
+ // Self-heal an already-installed bundle whose signature npm stripped on a
197
+ // prior upgrade (macOS 26+ SIGKILLs it otherwise) without a forced recopy.
198
+ ensureValidSignature(dest);
151
199
  return installedExecutablePath();
200
+ }
152
201
  copyAppBundle(src, dest);
202
+ ensureValidSignature(dest);
153
203
  return installedExecutablePath();
154
204
  }
155
205
  function xmlEscape(s) {
@@ -241,6 +291,14 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
241
291
  const exec = ensureMenubarAppInstalled({ forceReinstall: true });
242
292
  if (!exec)
243
293
  return false;
294
+ // Never bootstrap a helper the kernel will kill on launch: an invalid
295
+ // signature under launchd KeepAlive is an infinite crash loop. If the bundle
296
+ // can't be made valid (re-sign already attempted in ensureMenubarAppInstalled),
297
+ // skip the service rather than spin the loop.
298
+ if (!codesignVerifies(installedAppPath())) {
299
+ process.stderr.write('agents: menu-bar helper has no valid code signature; skipping launch to avoid a crash loop.\n');
300
+ return false;
301
+ }
244
302
  if (opts.clearOptOut) {
245
303
  try {
246
304
  fs.rmSync(disabledSentinelPath(), { force: true });