@phnx-labs/agents-cli 1.20.49 → 1.20.51

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 (100) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +3 -0
  3. package/dist/commands/browser-picker.js +1 -18
  4. package/dist/commands/cloud.js +1 -25
  5. package/dist/commands/computer.d.ts +1 -0
  6. package/dist/commands/computer.js +129 -8
  7. package/dist/commands/doctor.js +133 -2
  8. package/dist/commands/exec.js +49 -6
  9. package/dist/commands/factory.js +1 -4
  10. package/dist/commands/inspect.js +1 -11
  11. package/dist/commands/mcp.js +2 -6
  12. package/dist/commands/message.js +1 -4
  13. package/dist/commands/profiles.js +1 -18
  14. package/dist/commands/repo.js +33 -14
  15. package/dist/commands/resource-view.d.ts +1 -0
  16. package/dist/commands/resource-view.js +5 -17
  17. package/dist/commands/secrets.d.ts +1 -0
  18. package/dist/commands/secrets.js +1 -28
  19. package/dist/commands/sessions-picker.js +1 -18
  20. package/dist/commands/sessions.js +6 -8
  21. package/dist/commands/teams-picker.js +1 -32
  22. package/dist/commands/teams.js +218 -97
  23. package/dist/commands/tmux.js +1 -3
  24. package/dist/commands/view.js +1 -9
  25. package/dist/commands/worktree.js +1 -4
  26. package/dist/lib/agents.d.ts +0 -4
  27. package/dist/lib/agents.js +20 -33
  28. package/dist/lib/auto-dispatch-linear.d.ts +18 -0
  29. package/dist/lib/auto-dispatch-linear.js +107 -0
  30. package/dist/lib/auto-dispatch-provider.d.ts +10 -0
  31. package/dist/lib/auto-dispatch-provider.js +25 -0
  32. package/dist/lib/auto-dispatch.d.ts +87 -0
  33. package/dist/lib/auto-dispatch.js +142 -0
  34. package/dist/lib/browser/cdp.js +11 -2
  35. package/dist/lib/browser/drivers/ssh.d.ts +28 -10
  36. package/dist/lib/browser/drivers/ssh.js +57 -18
  37. package/dist/lib/browser/refs.js +1 -5
  38. package/dist/lib/cli-resources.d.ts +0 -2
  39. package/dist/lib/cli-resources.js +30 -13
  40. package/dist/lib/cloud/rush.d.ts +0 -24
  41. package/dist/lib/cloud/rush.js +0 -31
  42. package/dist/lib/crabbox/cli.js +4 -1
  43. package/dist/lib/crabbox/lease.js +29 -1
  44. package/dist/lib/daemon.js +41 -0
  45. package/dist/lib/exec.js +43 -18
  46. package/dist/lib/format.d.ts +38 -0
  47. package/dist/lib/format.js +108 -0
  48. package/dist/lib/git.d.ts +21 -0
  49. package/dist/lib/git.js +92 -0
  50. package/dist/lib/hooks/cache.d.ts +9 -2
  51. package/dist/lib/hooks/cache.js +220 -8
  52. package/dist/lib/hooks.js +17 -8
  53. package/dist/lib/hosts/passthrough.js +30 -1
  54. package/dist/lib/hosts/progress.d.ts +31 -0
  55. package/dist/lib/hosts/progress.js +35 -0
  56. package/dist/lib/hosts/remote-cmd.d.ts +1 -1
  57. package/dist/lib/hosts/remote-cmd.js +13 -3
  58. package/dist/lib/platform/exec.d.ts +4 -1
  59. package/dist/lib/platform/exec.js +8 -2
  60. package/dist/lib/resources.d.ts +0 -8
  61. package/dist/lib/resources.js +0 -10
  62. package/dist/lib/runner.js +10 -2
  63. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  64. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  65. package/dist/lib/session/active.d.ts +11 -1
  66. package/dist/lib/session/active.js +3 -0
  67. package/dist/lib/session/db.d.ts +1 -4
  68. package/dist/lib/session/db.js +20 -25
  69. package/dist/lib/session/discover.d.ts +2 -2
  70. package/dist/lib/session/discover.js +61 -48
  71. package/dist/lib/session/parse.js +35 -34
  72. package/dist/lib/session/render.d.ts +7 -3
  73. package/dist/lib/session/render.js +15 -9
  74. package/dist/lib/session/state.d.ts +55 -0
  75. package/dist/lib/session/state.js +87 -10
  76. package/dist/lib/session/types.d.ts +9 -0
  77. package/dist/lib/shims.d.ts +25 -2
  78. package/dist/lib/shims.js +73 -8
  79. package/dist/lib/ssh-tunnel.d.ts +33 -2
  80. package/dist/lib/ssh-tunnel.js +94 -7
  81. package/dist/lib/staleness/types.d.ts +0 -1
  82. package/dist/lib/teams/agents.d.ts +108 -1
  83. package/dist/lib/teams/agents.js +511 -11
  84. package/dist/lib/teams/api.d.ts +7 -1
  85. package/dist/lib/teams/api.js +5 -2
  86. package/dist/lib/teams/registry.d.ts +17 -0
  87. package/dist/lib/teams/registry.js +2 -0
  88. package/dist/lib/teams/remoteWorktree.d.ts +57 -0
  89. package/dist/lib/teams/remoteWorktree.js +213 -0
  90. package/dist/lib/teams/scheduler.d.ts +29 -0
  91. package/dist/lib/teams/scheduler.js +78 -0
  92. package/dist/lib/teams/supervisor.js +7 -0
  93. package/dist/lib/types.d.ts +14 -1
  94. package/dist/lib/versions.d.ts +7 -26
  95. package/dist/lib/versions.js +44 -146
  96. package/dist/lib/warn-unpushed.d.ts +40 -0
  97. package/dist/lib/warn-unpushed.js +128 -0
  98. package/package.json +3 -1
  99. package/dist/lib/resources/index.d.ts +0 -53
  100. package/dist/lib/resources/index.js +0 -76
@@ -25,26 +25,44 @@ export declare function connectSSH(endpoint: string, profile: BrowserProfile): P
25
25
  */
26
26
  export declare function encodePowerShell(script: string): string;
27
27
  /**
28
- * The PowerShell that launches the browser on a Windows remote. Two hard
28
+ * The PowerShell that launches the browser on a Windows remote. Three hard
29
29
  * requirements shaped this:
30
30
  * 1. The browser must OUTLIVE the ssh session. Windows OpenSSH terminates
31
31
  * the session's job tree on disconnect, which reaps both `start /B` and
32
- * `Start-Process` children (verified against a real box). WMI
33
- * `Win32_Process.Create` spawns under the WMI provider service instead,
34
- * so the process survives after we drop the ssh connection and reconnect
35
- * over the CDP tunnel.
36
- * 2. A distinct `--user-data-dir` so a fresh instance bound to the debugging
32
+ * `Start-Process` children (verified against a real box). A scheduled
33
+ * task runs under the Task Scheduler service, so the process survives
34
+ * after we drop the ssh connection and reconnect over the CDP tunnel.
35
+ * 2. The browser must run in the user's INTERACTIVE session. The previous
36
+ * WMI `Win32_Process.Create` launch also survived disconnect, but it
37
+ * spawned Edge in session 0 (the services session): the CDP socket bound
38
+ * and accepted, yet the DevTools server never initialized — every
39
+ * /json/version probe hung forever and DevToolsActivePort was never
40
+ * written (verified against a real box, Edg/150). A scheduled task
41
+ * registered and started by the logged-on user runs in that user's
42
+ * interactive session, where DevTools comes up normally.
43
+ * 3. A distinct `--user-data-dir` so a fresh instance bound to the debugging
37
44
  * port comes up even when the user already has Edge open.
38
45
  * CreateProcess ignores App Paths, so we resolve the real `.exe` from the
39
- * registry at runtime rather than relying on a bare `msedge` name.
46
+ * registry at runtime rather than relying on a bare `msedge` name. The task is
47
+ * unregistered immediately after start — the browser keeps running; nothing
48
+ * lingers in the scheduler.
40
49
  */
41
50
  export declare function buildWindowsLaunchScript(browserType: string, port: number, customBinary?: string): string;
42
- /** The PowerShell that kills whatever holds the CDP port on a Windows remote. */
51
+ /**
52
+ * The PowerShell that kills whatever holds the CDP port on a Windows remote.
53
+ * Tree-kill (`taskkill /T`), not `Stop-Process`: Chromium is multi-process, and
54
+ * killing only the port-owning main process orphans its children. The orphans
55
+ * keep the profile's SingletonLock, so every later launch against the same
56
+ * `--user-data-dir` delegates to the zombie tree, exits, and never binds the
57
+ * CDP port — `browser start --host` then fails until someone hand-cleans the
58
+ * box (found live on win-mini by the #561 e2e suite).
59
+ */
43
60
  export declare function buildWindowsKillScript(port: number): string;
44
61
  /**
45
62
  * Build the remote command that launches the browser detached with a CDP port.
46
63
  * POSIX backgrounds the `.app` binary with `… &`; Windows resolves the exe and
47
- * spawns it via WMI (encoded PowerShell) so it survives the ssh session.
64
+ * starts it via an interactive one-shot scheduled task (encoded PowerShell)
65
+ * so it survives the ssh session AND serves CDP (session 0 never does).
48
66
  */
49
67
  export declare function buildLaunchCmd(remoteOs: RemoteOs, browserType: string, port: number, customBinary?: string): string;
50
68
  /**
@@ -57,7 +75,7 @@ export declare function ensureRemoteBrowser(user: string, host: string, browserT
57
75
  export declare function restartRemoteBrowser(user: string, host: string, browserType: string, port: number, remoteOs: RemoteOs, customBinary?: string): Promise<void>;
58
76
  /**
59
77
  * Kill the remote browser holding the CDP port. Invoked on stop/cleanup so the
60
- * WMI-spawned (Windows) / detached (posix) browser process is not orphaned when
78
+ * task-launched (Windows) / detached (posix) browser process is not orphaned when
61
79
  * the ssh tunnel is torn down — killing only the tunnel left it running on
62
80
  * win-mini after every `browser stop`. Best-effort: never rejects.
63
81
  */
@@ -5,8 +5,11 @@ import { getPortOccupant } from '../chrome.js';
5
5
  import { parseEndpointUrl } from '../profiles.js';
6
6
  import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
7
7
  // shellQuote lives in the shared ssh-exec helper (single choke point); re-export
8
- // so existing importers of `shellQuote` from this module keep working.
9
- import { shellQuote } from '../../ssh-exec.js';
8
+ // so existing importers of `shellQuote` from this module keep working. SSH_OPTS
9
+ // is the shared hardened baseline (BatchMode + ConnectTimeout + keepalive) —
10
+ // reuse it so the raw-ssh spawns below fail fast on an unreachable host instead
11
+ // of hanging on the default ~127s TCP timeout, rather than re-listing options.
12
+ import { shellQuote, SSH_OPTS } from '../../ssh-exec.js';
10
13
  export { shellQuote };
11
14
  // The `ssh -L` tunnel spawn is shared with `agents computer --host`; it lives in
12
15
  // the single ssh-tunnel helper. Calling it with no options preserves this
@@ -168,18 +171,27 @@ export function encodePowerShell(script) {
168
171
  return `powershell -NoProfile -EncodedCommand ${encodePwshBase64(script)}`;
169
172
  }
170
173
  /**
171
- * The PowerShell that launches the browser on a Windows remote. Two hard
174
+ * The PowerShell that launches the browser on a Windows remote. Three hard
172
175
  * requirements shaped this:
173
176
  * 1. The browser must OUTLIVE the ssh session. Windows OpenSSH terminates
174
177
  * the session's job tree on disconnect, which reaps both `start /B` and
175
- * `Start-Process` children (verified against a real box). WMI
176
- * `Win32_Process.Create` spawns under the WMI provider service instead,
177
- * so the process survives after we drop the ssh connection and reconnect
178
- * over the CDP tunnel.
179
- * 2. A distinct `--user-data-dir` so a fresh instance bound to the debugging
178
+ * `Start-Process` children (verified against a real box). A scheduled
179
+ * task runs under the Task Scheduler service, so the process survives
180
+ * after we drop the ssh connection and reconnect over the CDP tunnel.
181
+ * 2. The browser must run in the user's INTERACTIVE session. The previous
182
+ * WMI `Win32_Process.Create` launch also survived disconnect, but it
183
+ * spawned Edge in session 0 (the services session): the CDP socket bound
184
+ * and accepted, yet the DevTools server never initialized — every
185
+ * /json/version probe hung forever and DevToolsActivePort was never
186
+ * written (verified against a real box, Edg/150). A scheduled task
187
+ * registered and started by the logged-on user runs in that user's
188
+ * interactive session, where DevTools comes up normally.
189
+ * 3. A distinct `--user-data-dir` so a fresh instance bound to the debugging
180
190
  * port comes up even when the user already has Edge open.
181
191
  * CreateProcess ignores App Paths, so we resolve the real `.exe` from the
182
- * registry at runtime rather than relying on a bare `msedge` name.
192
+ * registry at runtime rather than relying on a bare `msedge` name. The task is
193
+ * unregistered immediately after start — the browser keeps running; nothing
194
+ * lingers in the scheduler.
183
195
  */
184
196
  export function buildWindowsLaunchScript(browserType, port, customBinary) {
185
197
  let exeStmts;
@@ -211,21 +223,42 @@ export function buildWindowsLaunchScript(browserType, port, customBinary) {
211
223
  // source — a test asserts CDP is never opened to `*`.
212
224
  return [
213
225
  ...exeStmts,
214
- `$cl = '"' + $exe + '" --remote-debugging-port=${port}` +
226
+ // First-run / default-browser modals block automation (same rationale as
227
+ // the local launcher in chrome.ts), and the crash-restore bubble is worse
228
+ // here: `browser stop` hard-kills the remote process, so the NEXT launch
229
+ // against the same --user-data-dir marks the profile crashed and session
230
+ // restore swaps the initial page target — closing its CDP websocket while
231
+ // a command is pending (reproduced live: Page.captureScreenshot rejected
232
+ // with "CDP connection closed" on every reused-profile launch).
233
+ `$args = '--remote-debugging-port=${port}` +
215
234
  ` --remote-allow-origins=http://127.0.0.1:${port}` +
235
+ ` --no-first-run --no-default-browser-check` +
236
+ ` --hide-crash-restore-bubble --disable-session-crashed-bubble` +
216
237
  ` --disable-background-timer-throttling --user-data-dir="' + $env:TEMP + '\\agents-browser-${port}"'`,
217
- `Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = $cl } | Out-Null`,
238
+ `$action = New-ScheduledTaskAction -Execute $exe -Argument $args`,
239
+ `Register-ScheduledTask -TaskName 'agents-browser-${port}' -Action $action -Force | Out-Null`,
240
+ `Start-ScheduledTask -TaskName 'agents-browser-${port}'`,
241
+ `Unregister-ScheduledTask -TaskName 'agents-browser-${port}' -Confirm:$false`,
218
242
  ].join('; ');
219
243
  }
220
- /** The PowerShell that kills whatever holds the CDP port on a Windows remote. */
244
+ /**
245
+ * The PowerShell that kills whatever holds the CDP port on a Windows remote.
246
+ * Tree-kill (`taskkill /T`), not `Stop-Process`: Chromium is multi-process, and
247
+ * killing only the port-owning main process orphans its children. The orphans
248
+ * keep the profile's SingletonLock, so every later launch against the same
249
+ * `--user-data-dir` delegates to the zombie tree, exits, and never binds the
250
+ * CDP port — `browser start --host` then fails until someone hand-cleans the
251
+ * box (found live on win-mini by the #561 e2e suite).
252
+ */
221
253
  export function buildWindowsKillScript(port) {
222
254
  return (`Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue ` +
223
- `| ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }`);
255
+ `| ForEach-Object { taskkill /PID $_.OwningProcess /T /F 2>$null } | Out-Null`);
224
256
  }
225
257
  /**
226
258
  * Build the remote command that launches the browser detached with a CDP port.
227
259
  * POSIX backgrounds the `.app` binary with `… &`; Windows resolves the exe and
228
- * spawns it via WMI (encoded PowerShell) so it survives the ssh session.
260
+ * starts it via an interactive one-shot scheduled task (encoded PowerShell)
261
+ * so it survives the ssh session AND serves CDP (session 0 never does).
229
262
  */
230
263
  export function buildLaunchCmd(remoteOs, browserType, port, customBinary) {
231
264
  if (remoteOs === 'windows') {
@@ -266,10 +299,12 @@ export async function ensureRemoteBrowser(user, host, browserType, port, remoteO
266
299
  // ignored and this resolved on close regardless of exit code, so failures
267
300
  // were swallowed and only re-emerged 8s later as a generic
268
301
  // "SSH tunnel failed to establish".
302
+ // Options BEFORE the target (matching `sshExec`): OpenSSH's BSD getopt on
303
+ // macOS stops at the first non-option, so any `-o` after `user@host` would
304
+ // be swallowed into the remote command instead of applied.
269
305
  const child = spawn('ssh', [
306
+ ...SSH_OPTS,
270
307
  `${user}@${host}`,
271
- '-o',
272
- 'BatchMode=yes',
273
308
  remoteCmd,
274
309
  ], { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
275
310
  let stderr = '';
@@ -319,7 +354,7 @@ export async function restartRemoteBrowser(user, host, browserType, port, remote
319
354
  }
320
355
  /**
321
356
  * Kill the remote browser holding the CDP port. Invoked on stop/cleanup so the
322
- * WMI-spawned (Windows) / detached (posix) browser process is not orphaned when
357
+ * task-launched (Windows) / detached (posix) browser process is not orphaned when
323
358
  * the ssh tunnel is torn down — killing only the tunnel left it running on
324
359
  * win-mini after every `browser stop`. Best-effort: never rejects.
325
360
  */
@@ -328,7 +363,11 @@ export function killRemoteBrowser(user, host, remoteOs, port) {
328
363
  }
329
364
  function runSSHCommand(user, host, cmd) {
330
365
  return new Promise((resolve) => {
331
- const child = spawn('ssh', [`${user}@${host}`, '-o', 'BatchMode=yes', cmd], {
366
+ // Reuse the shared hardened baseline so a hung TCP SYN to an unreachable
367
+ // host is bounded by ConnectTimeout (the local 3s kill below only bounds a
368
+ // connected-but-slow command, not the connect itself). Options precede the
369
+ // target — see ensureRemoteBrowser.
370
+ const child = spawn('ssh', [...SSH_OPTS, `${user}@${host}`, cmd], {
332
371
  stdio: 'ignore',
333
372
  windowsHide: true,
334
373
  });
@@ -1,3 +1,4 @@
1
+ import { truncate } from '../format.js';
1
2
  /** Snapshot the stable descriptor for every ref in a freshly-built node map. */
2
3
  export function describeRefs(nodeMap) {
3
4
  return Array.from(nodeMap.values()).map((n) => ({
@@ -175,11 +176,6 @@ export async function getRefs(cdp, sessionId, opts = {}) {
175
176
  }
176
177
  return { refs: lines.join('\n'), nodeMap, opts: { interactive, limit } };
177
178
  }
178
- function truncate(s, max) {
179
- if (s.length <= max)
180
- return s;
181
- return s.slice(0, max - 1) + '…';
182
- }
183
179
  export async function resolveRefToCoords(cdp, sessionId, nodeMap, ref) {
184
180
  const node = nodeMap.get(ref);
185
181
  if (!node)
@@ -133,5 +133,3 @@ export declare function listCliStatus(cwd?: string): {
133
133
  statuses: CliStatus[];
134
134
  errors: CliManifestError[];
135
135
  };
136
- /** Names of CLIs that are declared but not currently installed on the host. */
137
- export declare function getMissingClis(cwd?: string): CliManifest[];
@@ -21,6 +21,7 @@ import * as path from 'path';
21
21
  import { spawnSync } from 'child_process';
22
22
  import * as yaml from 'yaml';
23
23
  import { listResources, resolveResource } from './resources.js';
24
+ import { composeWin32CommandLine } from './platform/index.js';
24
25
  // ─── Validation primitives ───────────────────────────────────────────────────
25
26
  /** Token allowed inside `check:` strings — letters, digits, underscore, dot, slash, dash. */
26
27
  const SAFE_CHECK_TOKEN = /^[a-zA-Z0-9_./-]+$/;
@@ -253,19 +254,27 @@ export function resolveCliManifest(name, cwd) {
253
254
  // ─── Host detection ──────────────────────────────────────────────────────────
254
255
  /**
255
256
  * Return true if a command resolves on the current PATH. Uses POSIX `command -v`
256
- * via spawn argv (no shell); results are cached for the lifetime of the process.
257
+ * (or `where` on Windows) via spawn argv (no shell); results are cached for the
258
+ * lifetime of the process.
257
259
  */
258
260
  const cmdExistsCache = new Map();
259
261
  export function hasCommand(cmd) {
260
262
  if (cmdExistsCache.has(cmd))
261
263
  return cmdExistsCache.get(cmd);
262
- // `command` is a shell builtin on most POSIX shells; invoking `sh -c 'command -v X'`
263
- // with X as an *argument* (not interpolated) is the safe path. `cmd` may be passed
264
- // by callers that haven't validated it, so we route via argv to neutralize metas.
265
- const result = spawnSync('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', '_', cmd], {
266
- stdio: 'ignore',
267
- });
268
- const ok = result.status === 0;
264
+ let ok;
265
+ if (process.platform === 'win32') {
266
+ // `sh` only exists when Git Bash is installed; `where` is the native PATH
267
+ // probe (resolves .exe/.cmd/.bat via PATHEXT). Argv keeps `cmd` uninterpolated.
268
+ ok = spawnSync('where', [cmd], { stdio: 'ignore' }).status === 0;
269
+ }
270
+ else {
271
+ // `command` is a shell builtin on most POSIX shells; invoking `sh -c 'command -v X'`
272
+ // with X as an *argument* (not interpolated) is the safe path. `cmd` may be passed
273
+ // by callers that haven't validated it, so we route via argv to neutralize metas.
274
+ ok = spawnSync('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', '_', cmd], {
275
+ stdio: 'ignore',
276
+ }).status === 0;
277
+ }
269
278
  cmdExistsCache.set(cmd, ok);
270
279
  return ok;
271
280
  }
@@ -280,7 +289,19 @@ export function isCliInstalled(manifest) {
280
289
  return hasCommand(c.cmd);
281
290
  }
282
291
  const result = spawnSync(c.cmd, c.args, { stdio: 'ignore', timeout: 10_000 });
283
- return result.status === 0;
292
+ if (result.status === 0)
293
+ return true;
294
+ // On Windows the PATH entry point is often a `.cmd`/`.bat` shim (npm installs,
295
+ // script installers), which Node refuses to spawn without a shell (ENOENT /
296
+ // EINVAL). A failed *spawn* — not a failed run — gets one retry through the
297
+ // shell, as a single pre-composed line (DEP0190-safe). Check tokens are
298
+ // SAFE_CHECK_TOKEN-validated at parse time, so the line cannot inject.
299
+ if (process.platform === 'win32' && result.error) {
300
+ const line = composeWin32CommandLine(c.cmd, c.args);
301
+ const retry = spawnSync(line, { stdio: 'ignore', timeout: 10_000, shell: true });
302
+ return retry.status === 0;
303
+ }
304
+ return false;
284
305
  }
285
306
  // ─── Method selection ────────────────────────────────────────────────────────
286
307
  /**
@@ -480,7 +501,3 @@ export function listCliStatus(cwd) {
480
501
  }));
481
502
  return { statuses, errors };
482
503
  }
483
- /** Names of CLIs that are declared but not currently installed on the host. */
484
- export function getMissingClis(cwd) {
485
- return listCliStatus(cwd).statuses.filter((s) => !s.installed).map((s) => s.manifest);
486
- }
@@ -75,30 +75,6 @@ export declare function buildDispatchBody(input: {
75
75
  */
76
76
  images?: ImageAttachment[] | null;
77
77
  }): Record<string, unknown>;
78
- /** A single account registered in Rush Cloud's multi-account rotation pool. */
79
- export interface RemoteAccount {
80
- id: string;
81
- provider: string;
82
- email: string | null;
83
- subscription_type: string | null;
84
- five_hour_pct: number | null;
85
- seven_day_pct: number | null;
86
- usage_fetched_at: string | null;
87
- created_at: string;
88
- }
89
- /** Fetch all Claude accounts in this user's Rush Cloud rotation pool (no tokens). */
90
- export declare function listRemoteAccounts(): Promise<RemoteAccount[]>;
91
- /**
92
- * Register a CLAUDE_CODE_OAUTH_TOKEN with Rush Cloud's rotation pool.
93
- * The server validates the token against the Anthropic usage API and stores it
94
- * encrypted in Vault. Returns the account metadata (no token).
95
- */
96
- export declare function addRemoteAccount(provider: string, pastedToken: string): Promise<RemoteAccount & {
97
- five_hour_pct: number | null;
98
- seven_day_pct: number | null;
99
- }>;
100
- /** Remove a Claude account from Rush Cloud's rotation pool by its ID. */
101
- export declare function removeRemoteAccount(id: string): Promise<void>;
102
78
  export declare class RushCloudProvider implements CloudProvider {
103
79
  id: "rush";
104
80
  name: string;
@@ -294,37 +294,6 @@ export function buildDispatchBody(input) {
294
294
  }
295
295
  return body;
296
296
  }
297
- /** Fetch all Claude accounts in this user's Rush Cloud rotation pool (no tokens). */
298
- export async function listRemoteAccounts() {
299
- const token = readToken();
300
- const res = await api('GET', '/api/v1/cloud-accounts', token);
301
- if (!res.ok) {
302
- throw new Error(`Failed to list accounts (${res.status}): ${sanitizeErrorBody(await res.text())}`);
303
- }
304
- const data = await res.json();
305
- return data.accounts ?? [];
306
- }
307
- /**
308
- * Register a CLAUDE_CODE_OAUTH_TOKEN with Rush Cloud's rotation pool.
309
- * The server validates the token against the Anthropic usage API and stores it
310
- * encrypted in Vault. Returns the account metadata (no token).
311
- */
312
- export async function addRemoteAccount(provider, pastedToken) {
313
- const token = readToken();
314
- const res = await api('POST', '/api/v1/cloud-accounts', token, { provider, token: pastedToken });
315
- if (!res.ok) {
316
- throw new Error(`Failed to add account (${res.status}): ${sanitizeErrorBody(await res.text())}`);
317
- }
318
- return await res.json();
319
- }
320
- /** Remove a Claude account from Rush Cloud's rotation pool by its ID. */
321
- export async function removeRemoteAccount(id) {
322
- const token = readToken();
323
- const res = await api('DELETE', `/api/v1/cloud-accounts/${encodeURIComponent(id)}`, token);
324
- if (!res.ok) {
325
- throw new Error(`Failed to remove account (${res.status}): ${sanitizeErrorBody(await res.text())}`);
326
- }
327
- }
328
297
  export class RushCloudProvider {
329
298
  id = 'rush';
330
299
  name = 'Rush Cloud';
@@ -209,7 +209,10 @@ export function crabboxRunScript(slug, script, opts = {}) {
209
209
  /** Release the lease / delete the box. Best-effort; never throws. */
210
210
  export function crabboxStop(slug, opts = {}) {
211
211
  try {
212
- const r = spawnSync('crabbox', ['stop', '--id', slug], { encoding: 'utf-8', env: crabboxEnv(opts) });
212
+ // Positional target: crabbox's stop subcommand has no --id flag (unlike
213
+ // status/run/ssh) — `stop --id <slug>` dies with "flag provided but not
214
+ // defined: -id" and the box leaks past the run it was leased for.
215
+ const r = spawnSync('crabbox', ['stop', slug], { encoding: 'utf-8', env: crabboxEnv(opts) });
213
216
  return r.status === 0;
214
217
  }
215
218
  catch {
@@ -12,6 +12,34 @@ import { buildCredentialScript } from './runtimes.js';
12
12
  function q(s) {
13
13
  return "'" + s.replace(/'/g, "'\\''") + "'";
14
14
  }
15
+ /**
16
+ * Bash snippet that guarantees `agents` is runnable on the box. Fresh crabbox
17
+ * images ship without node, and the box user may not own the global npm prefix,
18
+ * so everything installs user-level under ~/.local (node from the official
19
+ * latest-v22.x tarball to satisfy engines.node >=22.5.0). Exits 96 with a
20
+ * diagnostic when the CLI still isn't runnable — a silent `|| true` here used
21
+ * to surface only as `agents: command not found` deep in the script.
22
+ */
23
+ const ENSURE_AGENTS_CLI = [
24
+ 'export PATH="$HOME/.local/bin:$PATH"',
25
+ 'if ! command -v node >/dev/null 2>&1; then',
26
+ ' case "$(uname -m)" in aarch64|arm64) narch=arm64;; *) narch=x64;; esac',
27
+ ' nver=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ | grep -oE "v22\\.[0-9]+\\.[0-9]+" | head -1)',
28
+ ' mkdir -p "$HOME/.local"',
29
+ ' curl -fsSL "https://nodejs.org/dist/latest-v22.x/node-$nver-linux-$narch.tar.xz" | tar -xJ -C "$HOME/.local" --strip-components=1',
30
+ 'fi',
31
+ 'if ! command -v agents >/dev/null 2>&1; then',
32
+ ' npm config set prefix "$HOME/.local" >/dev/null 2>&1 || true',
33
+ ' npm install -g @phnx-labs/agents-cli >/dev/null 2>&1',
34
+ 'fi',
35
+ 'if ! command -v agents >/dev/null 2>&1; then',
36
+ ' echo "lease bootstrap: agents-cli install failed (node: $(command -v node || echo missing))" >&2',
37
+ ' exit 96',
38
+ 'fi',
39
+ // Same first-run guard the hosts bootstrap uses (hosts/ready.ts) — a fresh
40
+ // install refuses `agents run` with "agents-cli is not set up" until setup ran.
41
+ 'if [ ! -d "$HOME/.agents/.system" ]; then agents setup >/dev/null 2>&1 || true; fi',
42
+ ].join('\n');
15
43
  /**
16
44
  * Build the single bootstrap script run on the box: ensure agents-cli, install
17
45
  * the picked runtime CLIs, write their credentials, run the agent, then shred
@@ -35,7 +63,7 @@ export function buildBootstrapScript(opts) {
35
63
  const installRuntimes = opts.runtimes.map((id) => `agents add ${q(id)} >/dev/null 2>&1 || true`).join('\n');
36
64
  return [
37
65
  'set -uo pipefail',
38
- 'if ! command -v agents >/dev/null 2>&1; then npm install -g @phnx-labs/agents-cli >/dev/null 2>&1 || true; fi',
66
+ ENSURE_AGENTS_CLI,
39
67
  installRuntimes,
40
68
  credScript,
41
69
  `${runParts.join(' ')}`,
@@ -393,6 +393,45 @@ export async function runDaemon() {
393
393
  };
394
394
  const healInterval = setInterval(() => { void runHealCheck(); }, 6 * 60 * 60_000);
395
395
  const healKickoff = setTimeout(() => { void runHealCheck(); }, 30_000);
396
+ // Auto-dispatch: for any managed project that has opted in (autoDispatch:true +
397
+ // maxAgents>0 in ~/.agents/factory/projects.json), pick up Linear tickets that
398
+ // are delegated to an agent and still in Todo, and DISPATCH each through
399
+ // agents-cli's own cloud-provider layer (resolveProvider().dispatch(), same as
400
+ // `agents cloud run`) — then mark it Doing so it isn't re-picked. Capped at
401
+ // maxAgents concurrent per project. No hidden Prix dependency: Rush is one
402
+ // provider among rush/codex/factory, pinned per-project via `provider`. OFF
403
+ // unless a project opts in; no opted-in project or no LINEAR_API_KEY is a clean
404
+ // no-op. Overlap-guarded like the probes above. ~every 3 min.
405
+ let autoDispatching = false;
406
+ const runAutoDispatch = async () => {
407
+ if (autoDispatching)
408
+ return;
409
+ autoDispatching = true;
410
+ try {
411
+ const { readAutoDispatchProjects, isEligible, autoDispatchTick } = await import('./auto-dispatch.js');
412
+ const projects = readAutoDispatchProjects();
413
+ if (!projects.some(isEligible))
414
+ return; // opt-in: nothing enabled → skip
415
+ const { createLinearGateway } = await import('./auto-dispatch-linear.js');
416
+ const linear = createLinearGateway();
417
+ if (!linear)
418
+ return; // no LINEAR_API_KEY configured → skip
419
+ const { createProviderDispatcher } = await import('./auto-dispatch-provider.js');
420
+ const dispatcher = createProviderDispatcher();
421
+ const dispatched = await autoDispatchTick({ projects, linear, dispatcher, log: (lvl, m) => log(lvl, m) });
422
+ if (dispatched.length) {
423
+ log('INFO', `auto-dispatch: started ${dispatched.length} delegated ticket(s): ${dispatched.map((d) => d.identifier).join(', ')}`);
424
+ }
425
+ }
426
+ catch (err) {
427
+ log('ERROR', `auto-dispatch failed: ${err.message}`);
428
+ }
429
+ finally {
430
+ autoDispatching = false;
431
+ }
432
+ };
433
+ const autoDispatchInterval = setInterval(() => { void runAutoDispatch(); }, 3 * 60_000);
434
+ const autoDispatchKickoff = setTimeout(() => { void runAutoDispatch(); }, 45_000);
396
435
  // Device probe: refresh registered devices' reachability and detect newly
397
436
  // appeared tailnet nodes, dropping a sentinel per pending device so the
398
437
  // menu-bar helper can surface "NEW DEVICES → Register / Ignore". Refresh mode
@@ -500,6 +539,8 @@ export async function runDaemon() {
500
539
  clearInterval(syncInterval);
501
540
  clearInterval(healInterval);
502
541
  clearTimeout(healKickoff);
542
+ clearInterval(autoDispatchInterval);
543
+ clearTimeout(autoDispatchKickoff);
503
544
  clearInterval(deviceProbeInterval);
504
545
  clearTimeout(deviceProbeKickoff);
505
546
  clearInterval(tmuxReconcileInterval);
package/dist/lib/exec.js CHANGED
@@ -16,6 +16,7 @@ import { resolveModel, buildReasoningFlags } from './models.js';
16
16
  import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js';
17
17
  import { sanitizeProcessEnv } from './secrets/bundles.js';
18
18
  import { getShimsDir } from './state.js';
19
+ import { readCodexConfiguredModel } from './shims.js';
19
20
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
20
21
  import { recordRunName } from './session/run-names.js';
21
22
  import { mailboxDir, isValidMailboxId } from './mailbox.js';
@@ -306,12 +307,12 @@ export const AGENT_COMMANDS = {
306
307
  promptFlag: 'positional',
307
308
  resume: { subcommand: 'resume' },
308
309
  modeFlags: {
309
- // NOTE: codex has no read-only mode in --sandbox; 'plan' here means
310
- // "workspace-write but no auto-approval" closer to plan-as-restraint.
311
- // True read-only requires --sandbox read-only which we haven't wired.
312
- plan: ['--sandbox', 'workspace-write'],
313
- edit: ['--sandbox', 'workspace-write', '--dangerously-bypass-approvals-and-sandbox'],
314
- // skip drops the sandbox entirely; --dangerously-bypass-approvals-and-sandbox then approves anything.
310
+ plan: ['--sandbox', 'read-only'],
311
+ // Sandboxed writes inside the workspace. Network stays on (workspace-write
312
+ // disables it by default) so edit-mode runs can still use git/gh/package
313
+ // installs. No approval bypass here — only `skip` drops the guardrails.
314
+ edit: ['--sandbox', 'workspace-write', '-c', 'sandbox_workspace_write.network_access=true'],
315
+ // skip = codex --yolo: drops the sandbox entirely and approves anything.
315
316
  skip: ['--dangerously-bypass-approvals-and-sandbox'],
316
317
  },
317
318
  jsonFlags: ['--json'],
@@ -562,14 +563,25 @@ export function buildExecCommand(options) {
562
563
  throw new Error(`Internal error: ${options.agent} declares '${resolvedMode}' in capabilities.modes but has no entry in AGENT_COMMANDS.modeFlags.${resolvedMode}.`);
563
564
  }
564
565
  if (resumeSpec && 'subcommand' in resumeSpec) {
565
- // codex `exec resume` / `resume` does NOT accept `--sandbox <mode>` (only the
566
- // bypass flag, verified against `codex exec resume --help`). So the standard
567
- // codex modeFlags can't be reused on resume. A non-plan HEADLESS resume needs
568
- // the bypass or it stalls on approval prompts; plan and interactive resume
569
- // inherit codex's default sandbox and pass no flag.
570
- if (!interactive && resolvedMode !== 'plan') {
566
+ if (resolvedMode === 'skip') {
567
+ // skip = yolo on resume too; both `codex resume` (TUI) and
568
+ // `codex exec resume` accept the bypass flag.
571
569
  cmd.push('--dangerously-bypass-approvals-and-sandbox');
572
570
  }
571
+ else if (interactive) {
572
+ // `codex resume` (TUI) accepts the same -s/--sandbox flags as a fresh run.
573
+ cmd.push(...modeFlags);
574
+ }
575
+ else {
576
+ // `codex exec resume` rejects `--sandbox <mode>` (verified against
577
+ // `codex exec resume --help` on 0.142.5), but takes -c config overrides —
578
+ // map the mode through sandbox_mode so a non-skip resume never gets the
579
+ // approval/sandbox bypass.
580
+ cmd.push('-c', `sandbox_mode=${resolvedMode === 'plan' ? 'read-only' : 'workspace-write'}`);
581
+ if (resolvedMode !== 'plan') {
582
+ cmd.push('-c', 'sandbox_workspace_write.network_access=true');
583
+ }
584
+ }
573
585
  }
574
586
  else if (options.agent === 'kimi' && !interactive) {
575
587
  // kimi's headless prompt mode (`-p`/`--prompt`) is self-contained and REFUSES
@@ -610,18 +622,25 @@ export function buildExecCommand(options) {
610
622
  else if (options.sessionId && options.agent === 'claude') {
611
623
  cmd.push('--session-id', options.sessionId);
612
624
  }
613
- // Add model (only if explicitly provided by user)
614
- if (options.model && template.modelFlag) {
625
+ // Add model. Prefer the user's explicit --model. Otherwise, for Codex, fall
626
+ // back to the model configured in the user's active ~/.codex/config.toml:
627
+ // Codex runs under a per-version CODEX_HOME (see buildExecEnv) that may not
628
+ // carry that setting, so without this it silently defaults to gpt-5.3-codex,
629
+ // which a ChatGPT-tier account can't use (HTTP 400). Forwarding keeps the
630
+ // user's default model setup for both `agents run` and `agents teams`.
631
+ const effectiveModel = options.model
632
+ ?? (options.agent === 'codex' ? readCodexConfiguredModel() : undefined);
633
+ if (effectiveModel && template.modelFlag) {
615
634
  const effectiveVersion = options.version || resolveVersion(options.agent, options.cwd || process.cwd());
616
635
  if (effectiveVersion) {
617
- const resolved = resolveModel(options.agent, effectiveVersion, options.model);
636
+ const resolved = resolveModel(options.agent, effectiveVersion, effectiveModel);
618
637
  if (resolved.warning) {
619
638
  process.stderr.write(`[agents] ${resolved.warning}\n`);
620
639
  }
621
640
  cmd.push(template.modelFlag, resolved.forwarded);
622
641
  }
623
642
  else {
624
- cmd.push(template.modelFlag, options.model);
643
+ cmd.push(template.modelFlag, effectiveModel);
625
644
  }
626
645
  }
627
646
  // Add JSON output flags if requested
@@ -651,8 +670,14 @@ export function buildExecCommand(options) {
651
670
  cmd.push(template.promptFlag, options.prompt);
652
671
  }
653
672
  }
654
- // Claude-specific: add dirs
655
- if (options.agent === 'claude' && options.addDirs) {
673
+ // Extra writable dirs. Claude and Codex both take `--add-dir`; for Codex it
674
+ // widens the workspace-write sandbox (teams relies on this to let teammates
675
+ // write ~/.agents), so it must actually be forwarded — it used to be
676
+ // claude-only, silently dropped for codex and masked by edit mode carrying
677
+ // the approval/sandbox bypass. Codex's resume forms reject --add-dir, so
678
+ // skip it there (claude's flag-based resume accepts it).
679
+ if (options.addDirs &&
680
+ (options.agent === 'claude' || (options.agent === 'codex' && !options.resume))) {
656
681
  for (const dir of options.addDirs) {
657
682
  cmd.push('--add-dir', dir);
658
683
  }
@@ -0,0 +1,38 @@
1
+ /** Print `msg` in red to stderr and exit the process with `code`. */
2
+ export declare function die(msg: string, code?: number): never;
3
+ /**
4
+ * Truncate `s` to at most `max` characters, appending a single-char ellipsis
5
+ * (`…`) when shortened. Character-count based (not ANSI/width aware — use
6
+ * `truncateToWidth` from `session/width.ts` for colored strings).
7
+ */
8
+ export declare function truncate(s: string, max: number): string;
9
+ /**
10
+ * Format an ISO timestamp as a compact relative age: "just now", "5m ago",
11
+ * "3h ago", "2d ago". The canonical short form — the long "5 minutes ago"
12
+ * variant that once lived in `cloud.ts` is deliberately dropped. (For the
13
+ * session-list long form with calendar fallback, see
14
+ * `formatRelativeTime` in `session/relative-time.ts`.)
15
+ */
16
+ export declare function relTime(iso: string): string;
17
+ /** Format a millisecond duration as "45s", "3m", "2h 5m", "1d 3h". */
18
+ export declare function humanDuration(ms: number): string;
19
+ /**
20
+ * Visible column width of `s`, ignoring ANSI SGR color codes (e.g. chalk
21
+ * wrappers). Matches the full CSI sequence including the `\x1b` escape.
22
+ */
23
+ export declare function visibleWidth(s: string): number;
24
+ /** Pad `s` with trailing spaces to a target character width. */
25
+ export declare function padRight(s: string, width: number): string;
26
+ /** Pad `s` with trailing spaces to a target *visible* width (ANSI-aware). */
27
+ export declare function padVisible(s: string, width: number): string;
28
+ /** True when `--json` was passed or stdout is not a TTY. */
29
+ export declare function isJsonMode(opts: {
30
+ json?: boolean;
31
+ }): boolean;
32
+ /** Read all of stdin synchronously and return it UTF-8 decoded and trimmed. */
33
+ export declare function readStdinSync(): string;
34
+ /**
35
+ * Wrap `text` in an OSC 8 hyperlink to `filePath` (as a `file://` URL) when
36
+ * stdout is a TTY; otherwise return `text` unchanged.
37
+ */
38
+ export declare function termLink(text: string, filePath: string): string;