@phnx-labs/agents-cli 1.20.64 → 1.20.66

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 (188) hide show
  1. package/CHANGELOG.md +51 -3
  2. package/README.md +156 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.d.ts +12 -0
  5. package/dist/commands/apply.js +274 -0
  6. package/dist/commands/browser.js +2 -2
  7. package/dist/commands/cloud.js +32 -2
  8. package/dist/commands/doctor.js +4 -1
  9. package/dist/commands/exec.d.ts +48 -0
  10. package/dist/commands/exec.js +159 -49
  11. package/dist/commands/feed.js +25 -11
  12. package/dist/commands/hosts.js +44 -6
  13. package/dist/commands/mcp.js +55 -5
  14. package/dist/commands/monitors.d.ts +12 -0
  15. package/dist/commands/monitors.js +748 -0
  16. package/dist/commands/output.js +2 -2
  17. package/dist/commands/plugins.js +28 -7
  18. package/dist/commands/routines.js +23 -2
  19. package/dist/commands/secrets.d.ts +16 -0
  20. package/dist/commands/secrets.js +215 -64
  21. package/dist/commands/serve.js +31 -0
  22. package/dist/commands/sessions-browser.d.ts +82 -0
  23. package/dist/commands/sessions-browser.js +320 -0
  24. package/dist/commands/sessions-export.js +8 -3
  25. package/dist/commands/sessions.d.ts +17 -0
  26. package/dist/commands/sessions.js +157 -10
  27. package/dist/commands/share.d.ts +2 -0
  28. package/dist/commands/share.js +150 -0
  29. package/dist/commands/ssh.js +54 -3
  30. package/dist/commands/versions.js +7 -3
  31. package/dist/commands/view.d.ts +26 -0
  32. package/dist/commands/view.js +32 -9
  33. package/dist/commands/webhook.js +10 -2
  34. package/dist/index.js +35 -14
  35. package/dist/lib/agents.d.ts +46 -0
  36. package/dist/lib/agents.js +121 -3
  37. package/dist/lib/auto-dispatch-provider.js +7 -2
  38. package/dist/lib/auto-dispatch.d.ts +3 -0
  39. package/dist/lib/auto-dispatch.js +3 -0
  40. package/dist/lib/browser/chrome.js +2 -2
  41. package/dist/lib/cloud/antigravity.js +2 -2
  42. package/dist/lib/cloud/host.d.ts +59 -0
  43. package/dist/lib/cloud/host.js +224 -0
  44. package/dist/lib/cloud/registry.js +4 -0
  45. package/dist/lib/cloud/types.d.ts +6 -4
  46. package/dist/lib/computer-rpc.js +3 -1
  47. package/dist/lib/crabbox/cli.js +5 -1
  48. package/dist/lib/crabbox/runtimes.js +11 -2
  49. package/dist/lib/daemon.d.ts +20 -4
  50. package/dist/lib/daemon.js +62 -19
  51. package/dist/lib/devices/connect.d.ts +18 -1
  52. package/dist/lib/devices/connect.js +10 -2
  53. package/dist/lib/devices/fleet.d.ts +3 -2
  54. package/dist/lib/devices/fleet.js +9 -0
  55. package/dist/lib/devices/known-hosts.d.ts +62 -0
  56. package/dist/lib/devices/known-hosts.js +137 -0
  57. package/dist/lib/devices/registry.d.ts +15 -0
  58. package/dist/lib/devices/registry.js +9 -0
  59. package/dist/lib/exec.d.ts +19 -2
  60. package/dist/lib/exec.js +41 -13
  61. package/dist/lib/fleet/apply.d.ts +63 -0
  62. package/dist/lib/fleet/apply.js +214 -0
  63. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  64. package/dist/lib/fleet/auth-sync.js +142 -0
  65. package/dist/lib/fleet/manifest.d.ts +29 -0
  66. package/dist/lib/fleet/manifest.js +127 -0
  67. package/dist/lib/fleet/types.d.ts +129 -0
  68. package/dist/lib/fleet/types.js +13 -0
  69. package/dist/lib/git.d.ts +27 -0
  70. package/dist/lib/git.js +34 -2
  71. package/dist/lib/hosts/dispatch.d.ts +29 -8
  72. package/dist/lib/hosts/dispatch.js +66 -20
  73. package/dist/lib/hosts/passthrough.js +2 -0
  74. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  75. package/dist/lib/hosts/providers/devices.js +98 -0
  76. package/dist/lib/hosts/registry.d.ts +10 -16
  77. package/dist/lib/hosts/registry.js +17 -50
  78. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  79. package/dist/lib/hosts/remote-cmd.js +79 -4
  80. package/dist/lib/hosts/run-target.d.ts +84 -0
  81. package/dist/lib/hosts/run-target.js +99 -0
  82. package/dist/lib/hosts/types.d.ts +23 -5
  83. package/dist/lib/hosts/types.js +22 -4
  84. package/dist/lib/linear-autoclose.d.ts +30 -0
  85. package/dist/lib/linear-autoclose.js +22 -0
  86. package/dist/lib/mcp.d.ts +27 -1
  87. package/dist/lib/mcp.js +126 -12
  88. package/dist/lib/monitors/config.d.ts +161 -0
  89. package/dist/lib/monitors/config.js +372 -0
  90. package/dist/lib/monitors/dispatch.d.ts +28 -0
  91. package/dist/lib/monitors/dispatch.js +91 -0
  92. package/dist/lib/monitors/engine.d.ts +61 -0
  93. package/dist/lib/monitors/engine.js +205 -0
  94. package/dist/lib/monitors/sources/command.d.ts +11 -0
  95. package/dist/lib/monitors/sources/command.js +31 -0
  96. package/dist/lib/monitors/sources/device.d.ts +13 -0
  97. package/dist/lib/monitors/sources/device.js +45 -0
  98. package/dist/lib/monitors/sources/file.d.ts +14 -0
  99. package/dist/lib/monitors/sources/file.js +57 -0
  100. package/dist/lib/monitors/sources/http.d.ts +10 -0
  101. package/dist/lib/monitors/sources/http.js +34 -0
  102. package/dist/lib/monitors/sources/index.d.ts +14 -0
  103. package/dist/lib/monitors/sources/index.js +31 -0
  104. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  105. package/dist/lib/monitors/sources/poll.js +9 -0
  106. package/dist/lib/monitors/sources/types.d.ts +18 -0
  107. package/dist/lib/monitors/sources/types.js +9 -0
  108. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  109. package/dist/lib/monitors/sources/webhook.js +47 -0
  110. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  111. package/dist/lib/monitors/sources/ws.js +45 -0
  112. package/dist/lib/monitors/state.d.ts +69 -0
  113. package/dist/lib/monitors/state.js +144 -0
  114. package/dist/lib/picker.d.ts +53 -0
  115. package/dist/lib/picker.js +214 -1
  116. package/dist/lib/platform/exec.d.ts +16 -0
  117. package/dist/lib/platform/exec.js +17 -0
  118. package/dist/lib/plugins.d.ts +31 -1
  119. package/dist/lib/plugins.js +175 -15
  120. package/dist/lib/redact.d.ts +14 -1
  121. package/dist/lib/redact.js +47 -1
  122. package/dist/lib/remote-agents-json.js +7 -1
  123. package/dist/lib/rotate.d.ts +6 -3
  124. package/dist/lib/rotate.js +0 -1
  125. package/dist/lib/routines.d.ts +16 -0
  126. package/dist/lib/routines.js +19 -0
  127. package/dist/lib/runner.d.ts +1 -0
  128. package/dist/lib/runner.js +102 -9
  129. package/dist/lib/secrets/agent.d.ts +83 -10
  130. package/dist/lib/secrets/agent.js +237 -70
  131. package/dist/lib/secrets/bundles.d.ts +26 -0
  132. package/dist/lib/secrets/bundles.js +59 -8
  133. package/dist/lib/secrets/filestore.d.ts +9 -0
  134. package/dist/lib/secrets/filestore.js +21 -8
  135. package/dist/lib/secrets/index.d.ts +7 -0
  136. package/dist/lib/secrets/index.js +26 -6
  137. package/dist/lib/secrets/mcp.js +4 -2
  138. package/dist/lib/secrets/remote.d.ts +17 -0
  139. package/dist/lib/secrets/remote.js +40 -0
  140. package/dist/lib/self-update.d.ts +20 -0
  141. package/dist/lib/self-update.js +54 -1
  142. package/dist/lib/serve/control.d.ts +95 -0
  143. package/dist/lib/serve/control.js +260 -0
  144. package/dist/lib/serve/server.d.ts +35 -1
  145. package/dist/lib/serve/server.js +106 -76
  146. package/dist/lib/serve/stream.d.ts +43 -0
  147. package/dist/lib/serve/stream.js +116 -0
  148. package/dist/lib/serve/token.d.ts +35 -0
  149. package/dist/lib/serve/token.js +85 -0
  150. package/dist/lib/session/bundle.d.ts +14 -0
  151. package/dist/lib/session/bundle.js +12 -1
  152. package/dist/lib/session/remote-list.js +5 -1
  153. package/dist/lib/session/state.d.ts +7 -25
  154. package/dist/lib/session/state.js +16 -6
  155. package/dist/lib/session/sync/config.js +8 -2
  156. package/dist/lib/session/types.d.ts +30 -0
  157. package/dist/lib/share/capture.d.ts +29 -0
  158. package/dist/lib/share/capture.js +140 -0
  159. package/dist/lib/share/config.d.ts +35 -0
  160. package/dist/lib/share/config.js +100 -0
  161. package/dist/lib/share/og.d.ts +25 -0
  162. package/dist/lib/share/og.js +84 -0
  163. package/dist/lib/share/provision.d.ts +10 -0
  164. package/dist/lib/share/provision.js +91 -0
  165. package/dist/lib/share/publish.d.ts +57 -0
  166. package/dist/lib/share/publish.js +145 -0
  167. package/dist/lib/share/worker-template.d.ts +2 -0
  168. package/dist/lib/share/worker-template.js +82 -0
  169. package/dist/lib/shims.d.ts +13 -0
  170. package/dist/lib/shims.js +42 -2
  171. package/dist/lib/ssh-exec.d.ts +24 -0
  172. package/dist/lib/ssh-exec.js +15 -3
  173. package/dist/lib/ssh-tunnel.d.ts +19 -1
  174. package/dist/lib/ssh-tunnel.js +86 -7
  175. package/dist/lib/startup/command-registry.d.ts +3 -0
  176. package/dist/lib/startup/command-registry.js +6 -0
  177. package/dist/lib/state.d.ts +5 -0
  178. package/dist/lib/state.js +12 -0
  179. package/dist/lib/tmux/session.d.ts +7 -0
  180. package/dist/lib/tmux/session.js +3 -1
  181. package/dist/lib/triggers/webhook.d.ts +18 -0
  182. package/dist/lib/triggers/webhook.js +105 -0
  183. package/dist/lib/types.d.ts +36 -1
  184. package/dist/lib/usage.js +7 -5
  185. package/dist/lib/versions.js +14 -11
  186. package/dist/lib/workflows.d.ts +20 -0
  187. package/dist/lib/workflows.js +24 -0
  188. package/package.json +2 -1
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Host cloud provider — your own machines as a task-execution backend.
3
+ *
4
+ * A thin adapter over the hosts subsystem (lib/hosts/*): `agents cloud run
5
+ * --provider host --host <name>` dispatches through the SAME detached-SSH
6
+ * launch as `agents run --host`, and the resulting task shows up in BOTH
7
+ * `agents cloud ps` and `agents hosts ps` — one store (the host-task sidecars
8
+ * under ~/.agents/.cache/hosts/), two views. No new transport, no relay: SSH
9
+ * is the only wire, exactly like the rest of the hosts design (docs/hosts.md).
10
+ *
11
+ * Status semantics follow reconcile.ts's prime rule: completion is only ever
12
+ * CONFIRMED from the remote `.exit` file — an unreachable host leaves a task
13
+ * `running`, never guessed as failed. The cloud SQLite store row is a cached
14
+ * index (the `cloud list` refresh loop upserts what `status()` returns); the
15
+ * sidecar stays the source of truth. Reachability is memoized per target for
16
+ * the life of the process so a down host costs ONE short timeout, not one per
17
+ * task ("care around the cloud status-refresh path", lib/hosts/tasks.ts).
18
+ */
19
+ import { MissingTargetError, resolveDispatchRepos } from './types.js';
20
+ import { listTasks, loadTask, terminalPatch, updateTask } from '../hosts/tasks.js';
21
+ import { readRemoteExit } from '../hosts/reconcile.js';
22
+ import { sshReachable } from '../ssh-exec.js';
23
+ import { fetchProgress } from '../hosts/progress.js';
24
+ import { dispatchPromptToHost, resolveHostRunTarget } from '../hosts/run-target.js';
25
+ import { listAllHosts } from '../hosts/registry.js';
26
+ import { terminateDispatchedTask } from '../hosts/dispatch.js';
27
+ /** Host-task lifecycle → canonical cloud enum. `unknown` stays `running`
28
+ * (completion is confirmed, never guessed — reconcile.ts's rule). */
29
+ function toCloudStatus(status) {
30
+ switch (status) {
31
+ case 'completed': return 'completed';
32
+ case 'failed': return 'failed';
33
+ case 'running':
34
+ case 'unknown':
35
+ default:
36
+ return 'running';
37
+ }
38
+ }
39
+ /** Project a host-task sidecar into the cloud task shape. */
40
+ export function hostTaskToCloudTask(task) {
41
+ return {
42
+ id: task.id,
43
+ provider: 'host',
44
+ status: toCloudStatus(task.status),
45
+ agent: task.agent,
46
+ prompt: task.prompt,
47
+ createdAt: task.createdAt,
48
+ updatedAt: task.finishedAt ?? task.createdAt,
49
+ summary: `on ${task.host}${task.name ? ` as "${task.name}"` : ''}`,
50
+ };
51
+ }
52
+ export class HostCloudProvider {
53
+ id = 'host';
54
+ name = 'Host (your machines)';
55
+ targetKind = 'host';
56
+ /**
57
+ * Reachability memo, per target, for the life of this process. The cloud
58
+ * refresh loop calls `status()` once per active task; without the memo a
59
+ * down host would cost one ~6s ssh timeout PER task instead of one total.
60
+ * CLI processes are short-lived, so staleness is bounded by the invocation.
61
+ */
62
+ reachable = new Map();
63
+ capabilities() {
64
+ return {
65
+ available: true, // ssh is the only dependency; per-host reachability is probed at dispatch
66
+ dispatch: true,
67
+ status: true,
68
+ list: true,
69
+ stream: true,
70
+ cancel: true,
71
+ message: true, // gated per task: needs the sessionId only Claude runs carry
72
+ multiRepo: false,
73
+ skills: false,
74
+ images: false,
75
+ };
76
+ }
77
+ async dispatch(options) {
78
+ const hostName = options.providerOptions?.host;
79
+ if (!hostName) {
80
+ throw new MissingTargetError('host', 'No host given. Pass --host <name> (a registered host, a device, a capability tag, or user@host).', 'List your machines: agents hosts list · register more: agents devices sync / agents hosts add');
81
+ }
82
+ if (resolveDispatchRepos(options).length > 0) {
83
+ throw new Error('--repo has no meaning for --provider host — the run executes in a directory on the machine, not a cloned repo. ' +
84
+ 'Pass the working directory via providerOptions.remoteCwd (CLI: --remote-cwd) instead.');
85
+ }
86
+ if (options.branch) {
87
+ throw new Error('--branch has no meaning for --provider host (no clone step). Check out the branch on the host, or use --remote-cwd.');
88
+ }
89
+ // DeviceOffloadUnsupportedError / HostResolutionError propagate — both carry
90
+ // actionable messages the CLI prints verbatim.
91
+ const host = await resolveHostRunTarget(hostName, {
92
+ any: options.providerOptions?.any === true,
93
+ });
94
+ const { task } = await dispatchPromptToHost(host, {
95
+ agent: options.agent ?? 'claude',
96
+ prompt: options.prompt,
97
+ mode: options.providerOptions?.mode,
98
+ model: options.model,
99
+ timeout: options.timeout,
100
+ remoteCwd: options.providerOptions?.remoteCwd,
101
+ name: options.providerOptions?.name,
102
+ follow: false, // the cloud pipeline streams via stream(); never block dispatch
103
+ });
104
+ return hostTaskToCloudTask(task);
105
+ }
106
+ async status(taskId) {
107
+ const task = loadTask(taskId);
108
+ if (!task)
109
+ throw new Error(`Unknown host task: ${taskId}`);
110
+ return hostTaskToCloudTask(this.reconcileMemoized(task));
111
+ }
112
+ async list(filter) {
113
+ const tasks = listTasks().map((t) => hostTaskToCloudTask(this.reconcileMemoized(t)));
114
+ return filter?.status ? tasks.filter((t) => t.status === filter.status) : tasks;
115
+ }
116
+ /**
117
+ * `reconcileTask` with the per-process reachability memo folded in: probe a
118
+ * target at most once per process; a down host leaves its tasks `running`.
119
+ */
120
+ reconcileMemoized(task) {
121
+ if (task.status !== 'running')
122
+ return task;
123
+ if (!this.reachable.has(task.target)) {
124
+ this.reachable.set(task.target, sshReachable(task.target, 6000));
125
+ }
126
+ if (!this.reachable.get(task.target))
127
+ return task;
128
+ const st = readRemoteExit(task.target, task.remoteExit);
129
+ if (st.state !== 'done')
130
+ return task;
131
+ return updateTask(task.id, terminalPatch(st.code)) ?? task;
132
+ }
133
+ /**
134
+ * Offset-tail the remote log (the same one-round-trip fetch the `run --host`
135
+ * follow uses) and yield it as `text` events until the `.exit` file lands.
136
+ */
137
+ async *stream(taskId) {
138
+ const task = loadTask(taskId);
139
+ if (!task)
140
+ throw new Error(`Unknown host task: ${taskId}`);
141
+ if (task.status !== 'running') {
142
+ yield { type: 'status', status: toCloudStatus(task.status) };
143
+ yield { type: 'done', status: toCloudStatus(task.status) };
144
+ return;
145
+ }
146
+ let offset = 0;
147
+ const fastPollMs = 1500;
148
+ const maxPollMs = 6000;
149
+ let pollMs = fastPollMs;
150
+ for (;;) {
151
+ const fetched = fetchProgress(task.target, {
152
+ remoteLog: task.remoteLog,
153
+ remoteExit: task.remoteExit,
154
+ taskId: task.id,
155
+ offset,
156
+ });
157
+ if (fetched) {
158
+ if (fetched.logChunk.length > 0) {
159
+ offset += fetched.logChunk.length;
160
+ pollMs = fastPollMs; // output is flowing — snap back to the fast poll
161
+ yield { type: 'text', content: fetched.logChunk.toString('utf8') };
162
+ }
163
+ else {
164
+ pollMs = Math.min(Math.round(pollMs * 1.5), maxPollMs); // idle backoff
165
+ }
166
+ const exit = fetched.exit.trim();
167
+ if (exit !== '') {
168
+ const code = Number.parseInt(exit, 10);
169
+ const finished = updateTask(task.id, terminalPatch(Number.isFinite(code) ? code : 0));
170
+ const status = toCloudStatus(finished?.status ?? (code === 0 ? 'completed' : 'failed'));
171
+ yield { type: 'done', status };
172
+ return;
173
+ }
174
+ }
175
+ await new Promise((r) => setTimeout(r, pollMs));
176
+ }
177
+ }
178
+ async cancel(taskId) {
179
+ const task = loadTask(taskId);
180
+ if (!task)
181
+ throw new Error(`Unknown host task: ${taskId}`);
182
+ terminateDispatchedTask(task);
183
+ }
184
+ /**
185
+ * Follow-up message = resume the run's session on the same host. Only runs
186
+ * that captured a session id (Claude — the one agent that takes
187
+ * `--session-id`) can be resumed; others get an actionable refusal.
188
+ */
189
+ async message(taskId, content) {
190
+ const task = loadTask(taskId);
191
+ if (!task)
192
+ throw new Error(`Unknown host task: ${taskId}`);
193
+ if (!task.sessionId) {
194
+ throw new Error(`Host task ${taskId} has no session id to resume (only Claude runs capture one). ` +
195
+ `Start a follow-up run instead: agents run ${task.agent} "<prompt>" --host ${task.host}`);
196
+ }
197
+ const host = await resolveHostRunTarget(task.host);
198
+ await dispatchPromptToHost(host, {
199
+ agent: task.agent,
200
+ prompt: content,
201
+ resume: task.sessionId,
202
+ name: task.name,
203
+ follow: false,
204
+ });
205
+ }
206
+ /** The unified host pool (enrolled hosts ∪ devices), dispatchable ones only. */
207
+ async listTargets() {
208
+ const hosts = await listAllHosts();
209
+ return hosts
210
+ .filter((h) => h.dispatchable !== false)
211
+ .map((h) => ({
212
+ id: h.name,
213
+ label: [
214
+ h.provider === 'devices' ? 'device' : h.source,
215
+ h.os,
216
+ h.status && h.status !== 'unknown' ? h.status : undefined,
217
+ h.caps?.length ? `caps: ${h.caps.join(',')}` : undefined,
218
+ ]
219
+ .filter(Boolean)
220
+ .join(' · '),
221
+ kind: 'host',
222
+ }));
223
+ }
224
+ }
@@ -11,6 +11,7 @@ import { RushCloudProvider } from './rush.js';
11
11
  import { CodexCloudProvider } from './codex.js';
12
12
  import { FactoryCloudProvider } from './factory.js';
13
13
  import { AntigravityCloudProvider } from './antigravity.js';
14
+ import { HostCloudProvider } from './host.js';
14
15
  import { getUserAgentsDir } from '../state.js';
15
16
  import { AGENTS } from '../agents.js';
16
17
  const META_FILE = path.join(getUserAgentsDir(), 'agents.yaml');
@@ -43,6 +44,9 @@ function initProviders() {
43
44
  providers.set('codex', new CodexCloudProvider(config.providers?.codex));
44
45
  providers.set('factory', new FactoryCloudProvider(config.providers?.factory));
45
46
  providers.set('antigravity', new AntigravityCloudProvider(config.providers?.antigravity));
47
+ // Your own machines (agents hosts / agents devices) over SSH. No agent
48
+ // auto-routes here — it's always an explicit --provider host / --host choice.
49
+ providers.set('host', new HostCloudProvider());
46
50
  }
47
51
  /**
48
52
  * The cloud provider an agent dispatches to by default. Reads the canonical
@@ -9,16 +9,18 @@ import type { JobTrigger } from '../routines.js';
9
9
  /**
10
10
  * Identifier for a supported cloud dispatch backend.
11
11
  *
12
- * Each id is one agent's *own* cloud:
12
+ * Each id is one agent's *own* cloud — plus your own machines:
13
13
  * - `rush` — Rush Cloud (runs Claude against a GitHub repo → PR)
14
14
  * - `codex` — OpenAI Codex Cloud (`codex cloud exec`)
15
15
  * - `factory` — Factory Droid Computers (`droid computer ssh` + remote `droid exec`)
16
16
  * - `antigravity` — Google Gemini Managed Agents (Interactions API)
17
+ * - `host` — a machine you own (`agents hosts` / `agents devices`), over SSH
17
18
  *
18
19
  * Agents route to their native cloud automatically (see `cloudProvider` in the
19
- * agent registry); `--provider` overrides.
20
+ * agent registry); `--provider` overrides. Nothing auto-routes to `host` — it
21
+ * is always an explicit `--provider host` (or `--host <name>`) choice.
20
22
  */
21
- export type CloudProviderId = 'rush' | 'codex' | 'factory' | 'antigravity';
23
+ export type CloudProviderId = 'rush' | 'codex' | 'factory' | 'antigravity' | 'host';
22
24
  /**
23
25
  * Lifecycle state of a cloud-dispatched task.
24
26
  *
@@ -219,7 +221,7 @@ export interface CloudTarget {
219
221
  kind: TargetKind;
220
222
  }
221
223
  /** Which dispatch option a provider's pre-provisioned target maps to. */
222
- export type TargetKind = 'env' | 'computer';
224
+ export type TargetKind = 'env' | 'computer' | 'host';
223
225
  /**
224
226
  * Thrown by a provider's `dispatch()` when it needs a pre-provisioned target
225
227
  * (Codex env / Factory computer) and none was supplied. The CLI catches this
@@ -341,7 +341,9 @@ class TcpClient extends BaseClient {
341
341
  this.failPending('helper_exited', 'tcp connection closed before reply');
342
342
  });
343
343
  // Kick off the auth handshake synchronously so its frame (id 1) is the
344
- // first thing written. No token daemon is open (tunnel-gated).
344
+ // first thing written. The Windows daemon now REQUIRES a token (it refuses to
345
+ // start without one); a null token here means no auth frame is sent, the
346
+ // daemon drops the connection, and the caller is told to re-run `setup`.
345
347
  this.authReady = token ? this.authenticate(token) : Promise.resolve();
346
348
  }
347
349
  async authenticate(token) {
@@ -12,7 +12,7 @@
12
12
  * from a secrets bundle when one is configured (see `crabboxEnv`).
13
13
  */
14
14
  import { spawn, spawnSync } from 'child_process';
15
- import { readAndResolveBundleEnv, listBundles, bundleExists } from '../secrets/bundles.js';
15
+ import { readAndResolveBundleEnv, isHeadlessSecretsContext, listBundles, bundleExists } from '../secrets/bundles.js';
16
16
  import { readMeta, writeMeta } from '../state.js';
17
17
  /** Locate the crabbox binary, or throw an actionable error. */
18
18
  export function findCrabbox() {
@@ -105,6 +105,10 @@ export function crabboxEnv(opts) {
105
105
  const { env } = readAndResolveBundleEnv(resolved.name, {
106
106
  caller: 'agents run --lease (crabbox)',
107
107
  keys: resolved.keys,
108
+ // --lease is headless by contract and crabboxEnv is called several times
109
+ // per run (list/wait/spawn/stop) — resolve broker-only so a keychain bundle
110
+ // can't pop repeated unwatched Touch ID sheets mid-lease.
111
+ agentOnly: isHeadlessSecretsContext(),
108
112
  });
109
113
  return { ...process.env, ...env };
110
114
  }
@@ -158,8 +158,17 @@ export async function resolveClaudeCredentialsBlob(opts) {
158
158
  if (process.platform === 'darwin') {
159
159
  // 1) Bare service — the default native (non-managed) install.
160
160
  const bare = tryRead(service(undefined));
161
- if (bare)
162
- return bare;
161
+ if (bare) {
162
+ if (!opts?.preferEmail)
163
+ return bare;
164
+ // preferEmail is set — verify the bare service belongs to the right account
165
+ // before handing it back; on mismatch fall through to managed installs.
166
+ const realHome = process.env.AGENTS_REAL_HOME || os.homedir();
167
+ const bareEmail = await accountEmail(realHome).catch(() => null);
168
+ if (bareEmail === opts.preferEmail)
169
+ return bare;
170
+ // email mismatch — fall through
171
+ }
163
172
  // 2) Managed installs — hash-suffixed service keyed to each version home.
164
173
  // Prefer the version whose account email matches the copied config.
165
174
  let homes;
@@ -81,10 +81,26 @@ export declare function readDaemonClaudeOAuthToken(opts?: {
81
81
  * embed long-lived credentials.
82
82
  */
83
83
  export declare function writeOwnerOnlyServiceManifest(filePath: string, content: string): void;
84
- /** Generate a macOS launchd plist for auto-starting the daemon. */
85
- export declare function generateLaunchdPlist(oauthToken?: string | null, agentsBin?: string): string;
86
- /** Generate a Linux systemd user unit for auto-starting the daemon. */
87
- export declare function generateSystemdUnit(oauthToken?: string | null, agentsBin?: string): string;
84
+ /**
85
+ * Generate a macOS launchd plist for auto-starting the daemon.
86
+ *
87
+ * The plist never embeds the Claude OAuth token (RUSH-1759): a persisted service
88
+ * manifest is a plaintext credential on disk even at 0600. The daemon instead
89
+ * obtains the token at startup from the `claude` secrets bundle
90
+ * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the
91
+ * Keychain-backed secure store and never touches the unit file.
92
+ */
93
+ export declare function generateLaunchdPlist(agentsBin?: string): string;
94
+ /**
95
+ * Generate a Linux systemd user unit for auto-starting the daemon.
96
+ *
97
+ * The unit never embeds the Claude OAuth token (RUSH-1759): a persisted service
98
+ * manifest is a plaintext credential on disk even at 0600. The daemon instead
99
+ * obtains the token at startup from the `claude` secrets bundle
100
+ * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the secure
101
+ * store and never touches the unit file.
102
+ */
103
+ export declare function generateSystemdUnit(agentsBin?: string): string;
88
104
  export declare function getAgentsBinPath(argv1?: string | undefined, execPath?: string): string;
89
105
  /** Start the daemon via launchd, systemd, or as a detached process. */
90
106
  export declare function startDaemon(agentsBin?: string): {
@@ -14,6 +14,7 @@ import { getDaemonDir as getDaemonDirRoot } from './state.js';
14
14
  import { isAlive, killTree, backgroundSpawnOptions } from './platform/index.js';
15
15
  import { listJobs as listAllJobs } from './routines.js';
16
16
  import { JobScheduler } from './scheduler.js';
17
+ import { MonitorEngine } from './monitors/engine.js';
17
18
  import { executeJobDetached, monitorRunningJobs } from './runner.js';
18
19
  import { detectOverdueJobs, notifyOverdue } from './overdue.js';
19
20
  import { BrowserService } from './browser/service.js';
@@ -293,6 +294,21 @@ export async function runDaemon() {
293
294
  process.exit(0);
294
295
  }
295
296
  log('INFO', `Daemon started (PID: ${process.pid})`);
297
+ // RUSH-1759: the launchd plist / systemd unit no longer bake the Claude OAuth
298
+ // token onto disk. Obtain it here from the secure `claude` secrets bundle and
299
+ // inject into this process's env so every routine run this daemon spawns still
300
+ // receives it (via the sandbox allowlist), without the token ever being
301
+ // persisted in the service manifest. A read from a file-backed store (Linux)
302
+ // needs no prompt; on macOS it resolves broker-only from an unlocked
303
+ // secrets-agent and is otherwise absent (leaving the daemon on its existing
304
+ // interactive OAuth session), matching the detached-start path. Never blocks.
305
+ if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
306
+ const oauthToken = readDaemonClaudeOAuthToken();
307
+ if (oauthToken) {
308
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
309
+ log('INFO', 'Loaded Claude OAuth token from secrets bundle for routine runs');
310
+ }
311
+ }
296
312
  // Reap any stray duplicate daemon of this install that slipped past the start
297
313
  // lock or was orphaned by a hard-crash — before it can double-fire jobs.
298
314
  try {
@@ -349,6 +365,16 @@ export async function runDaemon() {
349
365
  for (const job of scheduled) {
350
366
  log('INFO', ` ${job.name} -> next: ${job.nextRun?.toISOString() || 'unknown'}`);
351
367
  }
368
+ // Monitor engine: event-triggered watchers, beside the cron scheduler. Same
369
+ // daemon, same dispatch seam — a monitor is a routine whose trigger is a
370
+ // watched source instead of a clock. Reloads on SIGHUP alongside the scheduler.
371
+ const monitorEngine = new MonitorEngine((level, message) => log(level, message));
372
+ try {
373
+ monitorEngine.start();
374
+ }
375
+ catch (err) {
376
+ log('ERROR', `Monitor engine failed to start: ${err.message}`);
377
+ }
352
378
  // Backlog detection: any enabled recurring job whose most-recent expected
353
379
  // fire is older than its most-recent recorded run is overdue. Happens when
354
380
  // the laptop was off or the daemon crashed through a scheduled fire.
@@ -603,6 +629,12 @@ export async function runDaemon() {
603
629
  scheduler.reloadAll();
604
630
  const reloaded = scheduler.listScheduled();
605
631
  log('INFO', `Reloaded ${reloaded.length} jobs`);
632
+ try {
633
+ monitorEngine.reload();
634
+ }
635
+ catch (err) {
636
+ log('ERROR', `Monitor engine reload failed: ${err.message}`);
637
+ }
606
638
  // Drop the memoized R2 config so rotated/added sync credentials are re-read
607
639
  // on the next cycle instead of waiting for a restart.
608
640
  void import('./session/sync/config.js').then(m => m.clearR2ConfigCache());
@@ -610,6 +642,7 @@ export async function runDaemon() {
610
642
  const handleShutdown = async () => {
611
643
  log('INFO', 'Daemon shutting down');
612
644
  scheduler.stopAll();
645
+ monitorEngine.stop();
613
646
  await browserIPC.stop();
614
647
  clearInterval(monitorInterval);
615
648
  clearInterval(syncInterval);
@@ -680,15 +713,18 @@ export function writeOwnerOnlyServiceManifest(filePath, content) {
680
713
  fs.rmSync(filePath, { force: true });
681
714
  fs.writeFileSync(filePath, content, { encoding: 'utf-8', mode: 0o600 });
682
715
  }
683
- /** Generate a macOS launchd plist for auto-starting the daemon. */
684
- export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken(), agentsBin = getAgentsBinPath()) {
716
+ /**
717
+ * Generate a macOS launchd plist for auto-starting the daemon.
718
+ *
719
+ * The plist never embeds the Claude OAuth token (RUSH-1759): a persisted service
720
+ * manifest is a plaintext credential on disk even at 0600. The daemon instead
721
+ * obtains the token at startup from the `claude` secrets bundle
722
+ * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the
723
+ * Keychain-backed secure store and never touches the unit file.
724
+ */
725
+ export function generateLaunchdPlist(agentsBin = getAgentsBinPath()) {
685
726
  const launch = getDaemonLaunch(agentsBin);
686
727
  const logPath = getLogPath();
687
- const oauthEntry = oauthToken
688
- ? `
689
- <key>${DAEMON_OAUTH_KEY}</key>
690
- <string>${xmlEscape(oauthToken)}</string>`
691
- : '';
692
728
  return `<?xml version="1.0" encoding="UTF-8"?>
693
729
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
694
730
  <plist version="1.0">
@@ -710,7 +746,7 @@ ${[launch.command, ...launch.args].map((arg) => ` <string>${xmlEscape(arg)}</
710
746
  <key>EnvironmentVariables</key>
711
747
  <dict>
712
748
  <key>PATH</key>
713
- <string>${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin</string>${oauthEntry}
749
+ <string>${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin</string>
714
750
  </dict>
715
751
  </dict>
716
752
  </plist>`;
@@ -719,13 +755,18 @@ ${[launch.command, ...launch.args].map((arg) => ` <string>${xmlEscape(arg)}</
719
755
  function systemdExecArg(value) {
720
756
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
721
757
  }
722
- /** Generate a Linux systemd user unit for auto-starting the daemon. */
723
- export function generateSystemdUnit(oauthToken = readDaemonClaudeOAuthToken(), agentsBin = getAgentsBinPath()) {
758
+ /**
759
+ * Generate a Linux systemd user unit for auto-starting the daemon.
760
+ *
761
+ * The unit never embeds the Claude OAuth token (RUSH-1759): a persisted service
762
+ * manifest is a plaintext credential on disk even at 0600. The daemon instead
763
+ * obtains the token at startup from the `claude` secrets bundle
764
+ * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the secure
765
+ * store and never touches the unit file.
766
+ */
767
+ export function generateSystemdUnit(agentsBin = getAgentsBinPath()) {
724
768
  const launch = getDaemonLaunch(agentsBin);
725
769
  const execStart = [launch.command, ...launch.args].map(systemdExecArg).join(' ');
726
- const oauthLine = oauthToken
727
- ? `\nEnvironment=${DAEMON_OAUTH_KEY}=${oauthToken}`
728
- : '';
729
770
  return `[Unit]
730
771
  Description=Agents Daemon - Scheduled Job Runner
731
772
  After=network.target
@@ -735,7 +776,7 @@ Type=simple
735
776
  ExecStart=${execStart}
736
777
  Restart=always
737
778
  RestartSec=10
738
- Environment=PATH=${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin${oauthLine}
779
+ Environment=PATH=${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin
739
780
 
740
781
  [Install]
741
782
  WantedBy=default.target`;
@@ -852,9 +893,10 @@ function startDaemonLocked(agentsBin) {
852
893
  if (!fs.existsSync(plistDir)) {
853
894
  fs.mkdirSync(plistDir, { recursive: true });
854
895
  }
855
- // The plist may embed a long-lived OAuth token in EnvironmentVariables;
856
- // create owner-only atomically (no world-readable window before chmod).
857
- writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist(undefined, agentsBin));
896
+ // The plist carries no credential (RUSH-1759 the daemon reads the OAuth
897
+ // token itself at startup); still create owner-only atomically to match the
898
+ // detached path and keep the log/PATH surface owner-private.
899
+ writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist(agentsBin));
858
900
  try {
859
901
  execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
860
902
  }
@@ -881,8 +923,9 @@ function startDaemonLocked(agentsBin) {
881
923
  if (!fs.existsSync(unitDir)) {
882
924
  fs.mkdirSync(unitDir, { recursive: true });
883
925
  }
884
- // May embed a long-lived OAuth token in an Environment= line; owner-only.
885
- writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit(undefined, agentsBin));
926
+ // Carries no credential (RUSH-1759 the daemon reads the OAuth token
927
+ // itself at startup); owner-only to keep the PATH/log surface private.
928
+ writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit(agentsBin));
886
929
  execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
887
930
  execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
888
931
  execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
@@ -16,13 +16,30 @@ export declare function sshTargetFor(device: DeviceProfile): string;
16
16
  * login).
17
17
  */
18
18
  export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]): string | undefined;
19
+ /** Host-key posture for {@link buildSshInvocation}. */
20
+ export interface SshHostKeyOptions {
21
+ /**
22
+ * True when the device's host key is already pinned in the managed
23
+ * known_hosts store — connections then verify with `StrictHostKeyChecking=yes`
24
+ * (a key swap is refused). False (the default) keeps `accept-new` for a
25
+ * genuine first enrollment, whose learned key lands in the managed store and
26
+ * pins the host for every subsequent connect. See {@link hostKeyCheckingOpts}.
27
+ */
28
+ pinned?: boolean;
29
+ /** Managed known_hosts path override (tests). Defaults to the CLI-managed store. */
30
+ knownHostsFile?: string;
31
+ }
19
32
  /**
20
33
  * Build the argv (after the `ssh` program name) and the environment overlay
21
34
  * for connecting to a device. For password auth this points `SSH_ASKPASS` at
22
35
  * the shim and disables pubkey + the host's interactive password prompt so the
23
36
  * shim is the only auth path. Pure (no spawn) so it is unit-testable.
37
+ *
38
+ * Host-key checking runs against the CLI-managed known_hosts store (never the
39
+ * user's `~/.ssh/known_hosts`): strict once `hostKey.pinned` is set, else
40
+ * `accept-new` to learn+pin the key on first connect (RUSH-1767).
24
41
  */
25
- export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string): {
42
+ export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions): {
26
43
  args: string[];
27
44
  env: Record<string, string>;
28
45
  };
@@ -18,6 +18,7 @@ import * as path from 'path';
18
18
  import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
19
  import { encodePwshBase64 } from '../pwsh.js';
20
20
  import { getCacheDir } from '../state.js';
21
+ import { hostKeyCheckingOpts } from './known-hosts.js';
21
22
  import { hostNameFor } from './ssh-config.js';
22
23
  /** Env var the askpass shim reads to know which bundle holds the password. */
23
24
  export const ASKPASS_BUNDLE_ENV = 'AGENTS_SSH_BUNDLE';
@@ -57,12 +58,19 @@ export function wrapRemoteCommand(device, cmd) {
57
58
  * for connecting to a device. For password auth this points `SSH_ASKPASS` at
58
59
  * the shim and disables pubkey + the host's interactive password prompt so the
59
60
  * shim is the only auth path. Pure (no spawn) so it is unit-testable.
61
+ *
62
+ * Host-key checking runs against the CLI-managed known_hosts store (never the
63
+ * user's `~/.ssh/known_hosts`): strict once `hostKey.pinned` is set, else
64
+ * `accept-new` to learn+pin the key on first connect (RUSH-1767).
60
65
  */
61
- export function buildSshInvocation(device, cmd, askpassShimPath) {
66
+ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}) {
62
67
  const target = sshTargetFor(device);
63
68
  const remote = wrapRemoteCommand(device, cmd);
64
69
  const env = {};
65
- const args = ['-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=10'];
70
+ const args = [
71
+ ...hostKeyCheckingOpts(hostKey.pinned ?? false, hostKey.knownHostsFile),
72
+ '-o', 'ConnectTimeout=10',
73
+ ];
66
74
  if (device.auth.method === 'password') {
67
75
  if (!device.auth.bundle) {
68
76
  throw new Error(`Device '${device.name}' uses password auth but has no secrets bundle. Set one with \`agents devices set ${device.name} --bundle <name>\`.`);
@@ -7,8 +7,8 @@
7
7
  * throws (misconfigured auth, etc.) become `failed` rows — they never abort
8
8
  * the remaining devices.
9
9
  */
10
- import type { DeviceProfile, DeviceRegistry } from './registry.js';
11
- export type FleetSkipReason = 'offline' | 'no-address';
10
+ import { type DeviceProfile, type DeviceRegistry } from './registry.js';
11
+ export type FleetSkipReason = 'offline' | 'no-address' | 'control';
12
12
  /** npm dist-tags / semver pins only — rejects shell metacharacters. */
13
13
  export declare const FLEET_VERSION_RE: RegExp;
14
14
  export interface FleetTarget {
@@ -27,6 +27,7 @@ export interface FleetRunResult {
27
27
  /**
28
28
  * Classify each registered device for a fleet operation.
29
29
  *
30
+ * - Control-only device (a cockpit, e.g. a paired iPhone) → skip `control`
30
31
  * - Tailscale-offline → skip `offline`
31
32
  * - No address → skip `no-address`
32
33
  * - Everything else is a target (including this machine, reached over ssh when
@@ -8,12 +8,14 @@
8
8
  * the remaining devices.
9
9
  */
10
10
  import { spawnSync } from 'child_process';
11
+ import { isControlDevice } from './registry.js';
11
12
  import { buildSshInvocation, sshTargetFor, writeAskpassShim } from './connect.js';
12
13
  /** npm dist-tags / semver pins only — rejects shell metacharacters. */
13
14
  export const FLEET_VERSION_RE = /^[A-Za-z0-9._-]+$/;
14
15
  /**
15
16
  * Classify each registered device for a fleet operation.
16
17
  *
18
+ * - Control-only device (a cockpit, e.g. a paired iPhone) → skip `control`
17
19
  * - Tailscale-offline → skip `offline`
18
20
  * - No address → skip `no-address`
19
21
  * - Everything else is a target (including this machine, reached over ssh when
@@ -23,6 +25,11 @@ export function planFleetTargets(reg) {
23
25
  const names = Object.keys(reg).sort();
24
26
  return names.map((name) => {
25
27
  const device = reg[name];
28
+ // A control device drives the fleet but never runs agents — never a target
29
+ // for update/run/stats, whatever its platform reads as.
30
+ if (isControlDevice(device)) {
31
+ return { device, skip: 'control' };
32
+ }
26
33
  if (device.tailscale && !device.tailscale.online) {
27
34
  return { device, skip: 'offline' };
28
35
  }
@@ -42,6 +49,8 @@ export function skipLabel(reason) {
42
49
  return 'offline';
43
50
  case 'no-address':
44
51
  return 'no address';
52
+ case 'control':
53
+ return 'control device';
45
54
  }
46
55
  }
47
56
  /**