@phnx-labs/agents-cli 1.22.115 → 1.22.117

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 (91) hide show
  1. package/CHANGELOG.md +161 -14
  2. package/README.md +1 -1
  3. package/dist/commands/browser.js +11 -0
  4. package/dist/commands/exec.d.ts +18 -0
  5. package/dist/commands/exec.js +263 -152
  6. package/dist/commands/feed.js +65 -14
  7. package/dist/commands/sessions-picker.d.ts +11 -0
  8. package/dist/commands/sessions-picker.js +88 -7
  9. package/dist/commands/sessions.d.ts +21 -2
  10. package/dist/commands/sessions.js +157 -3
  11. package/dist/commands/setup-secrets.d.ts +2 -2
  12. package/dist/commands/setup-term.d.ts +25 -0
  13. package/dist/commands/setup-term.js +71 -0
  14. package/dist/commands/setup.d.ts +1 -1
  15. package/dist/commands/setup.js +12 -4
  16. package/dist/commands/ssh.js +1 -69
  17. package/dist/lib/accounting/rotate.d.ts +63 -1
  18. package/dist/lib/accounting/rotate.js +56 -0
  19. package/dist/lib/accounts/add.js +2 -2
  20. package/dist/lib/accounts/slots.js +32 -2
  21. package/dist/lib/answer-router.d.ts +11 -2
  22. package/dist/lib/answer-router.js +26 -2
  23. package/dist/lib/auth-mint.d.ts +5 -4
  24. package/dist/lib/auth-mint.js +4 -3
  25. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  26. package/dist/lib/browser/service.d.ts +10 -0
  27. package/dist/lib/browser/service.js +208 -36
  28. package/dist/lib/browser/types.d.ts +18 -0
  29. package/dist/lib/config-keys.d.ts +1 -1
  30. package/dist/lib/config-keys.js +5 -0
  31. package/dist/lib/device-config.js +61 -0
  32. package/dist/lib/devices/doctor-findings.js +2 -6
  33. package/dist/lib/feed/answer.d.ts +153 -4
  34. package/dist/lib/feed/answer.js +716 -105
  35. package/dist/lib/feed/feed.d.ts +61 -1
  36. package/dist/lib/feed/feed.js +226 -14
  37. package/dist/lib/feed/hub-server.d.ts +58 -3
  38. package/dist/lib/feed/hub-server.js +306 -54
  39. package/dist/lib/feed/pr-status.d.ts +8 -0
  40. package/dist/lib/feed/pr-status.js +9 -1
  41. package/dist/lib/feed-outcome.d.ts +1 -1
  42. package/dist/lib/feed-outcome.js +9 -2
  43. package/dist/lib/feed-policy.js +9 -3
  44. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  45. package/dist/lib/fleet/auth-sync.js +2 -89
  46. package/dist/lib/harness-auth-capabilities.js +7 -2
  47. package/dist/lib/hosts/dispatch.d.ts +20 -1
  48. package/dist/lib/hosts/dispatch.js +52 -30
  49. package/dist/lib/hosts/remote-cmd.d.ts +21 -0
  50. package/dist/lib/hosts/remote-cmd.js +27 -2
  51. package/dist/lib/mailbox.d.ts +12 -0
  52. package/dist/lib/mailbox.js +16 -2
  53. package/dist/lib/menubar/snapshot.d.ts +51 -0
  54. package/dist/lib/menubar/snapshot.js +42 -3
  55. package/dist/lib/open-url.js +2 -2
  56. package/dist/lib/placement.d.ts +2 -0
  57. package/dist/lib/placement.js +5 -0
  58. package/dist/lib/projects.d.ts +23 -0
  59. package/dist/lib/projects.js +78 -0
  60. package/dist/lib/secrets-cli.d.ts +3 -3
  61. package/dist/lib/secrets-cli.js +1 -1
  62. package/dist/lib/session/active.d.ts +1 -0
  63. package/dist/lib/session/active.js +8 -0
  64. package/dist/lib/session/db.d.ts +67 -3
  65. package/dist/lib/session/db.js +381 -126
  66. package/dist/lib/session/prompt.d.ts +23 -7
  67. package/dist/lib/session/prompt.js +46 -8
  68. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  69. package/dist/lib/session/remote/remote-list.js +22 -6
  70. package/dist/lib/session/remote/watch.d.ts +12 -0
  71. package/dist/lib/session/remote/watch.js +9 -0
  72. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  73. package/dist/lib/session/remote-preview-cache.js +373 -0
  74. package/dist/lib/session/tail.d.ts +50 -0
  75. package/dist/lib/session/tail.js +219 -0
  76. package/dist/lib/setup-tool-install.js +2 -1
  77. package/dist/lib/setup-tool-status.d.ts +1 -1
  78. package/dist/lib/setup-tool-status.js +7 -1
  79. package/dist/lib/signin-badge.d.ts +19 -4
  80. package/dist/lib/signin-badge.js +29 -11
  81. package/dist/lib/term-driver.d.ts +24 -0
  82. package/dist/lib/term-driver.js +36 -0
  83. package/dist/lib/terminal/index.d.ts +1 -1
  84. package/dist/lib/terminal/index.js +1 -1
  85. package/dist/lib/terminal/inject.d.ts +38 -0
  86. package/dist/lib/terminal/inject.js +55 -9
  87. package/dist/lib/terminal/transport.d.ts +15 -5
  88. package/dist/lib/terminal/transport.js +61 -11
  89. package/package.json +1 -1
  90. package/dist/lib/fleet/remote-login.d.ts +0 -170
  91. package/dist/lib/fleet/remote-login.js +0 -568
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Native-login inventory for `agents apply` and `agents fleet login`.
2
+ * Native-login inventory for `agents apply`.
3
3
  * Native OAuth/session files are identified only to report device readiness;
4
4
  * agents-cli never serializes or materializes them on another device.
5
5
  *
@@ -28,7 +28,7 @@ export declare const KEYCHAIN_BOUND_ON_MAC: ReadonlySet<string>;
28
28
  * collapsing the fleet to a single working login (droid/WorkOS collapsed 10 boxes
29
29
  * to 1 overnight — RUSH-1958). Add any newly-discovered single-use-rotation
30
30
  * harness here; the predicate below is the one place the propagation decision is
31
- * made. See also `remote-login.ts` and `usage.ts` for the per-machine-login policy.
31
+ * made. See also `usage.ts` for the per-machine-login policy.
32
32
  */
33
33
  export declare const SINGLE_USE_ROTATING_REFRESH_AGENTS: ReadonlySet<string>;
34
34
  /**
@@ -48,59 +48,6 @@ export declare function isCredentialSafeToPropagate(_agent: string): boolean;
48
48
  export declare function hasPortableAuthFiles(agent: string): boolean;
49
49
  /** Which agents `apply` can propagate auth for at all. */
50
50
  export declare function isPropagatableAgent(agent: string): boolean;
51
- /**
52
- * The shape of a login flow — enough for `agents fleet login` to drive a remote
53
- * box's device-code OAuth over SSH and scrape the verification URL + user code.
54
- *
55
- * `flowType` decides remotability:
56
- * - `device-code` — the remote box prints a URL + short code the human enters in
57
- * a browser on THIS machine. The only flow `fleet login` can orchestrate.
58
- * - `loopback` — the CLI opens a browser and listens on 127.0.0.1 on the
59
- * remote box; the redirect must reach that box's own loopback, so it can only
60
- * be completed with a browser on the same machine. Non-remotable.
61
- * - `api-key` — a pasted key, not an OAuth handshake. Non-remotable.
62
- * - `unknown` — flow not yet characterized (no captured output). Non-remotable
63
- * until someone captures the real pattern and fills the regexes.
64
- */
65
- export type LoginFlowType = 'device-code' | 'loopback' | 'api-key' | 'unknown';
66
- export interface LoginFlow {
67
- /**
68
- * The exact command that starts the login flow on the remote box, run as
69
- * `ssh -tt <box> <loginCommand>`. Bare `<cli>` for agents whose device flow
70
- * starts on launch (droid, kimi, antigravity), `<cli> login` for
71
- * codex/grok, `<cli> auth login` for opencode.
72
- */
73
- loginCommand: string;
74
- flowType: LoginFlowType;
75
- /**
76
- * Keystrokes sent after launch to force the device-code path when the CLI
77
- * presents a menu (codex: pick "Sign in with Device Code") or requires a
78
- * sub-command inside a TUI (kimi: `/login`). Sent verbatim through the PTY,
79
- * so it must include the submitting CR (`\r`). Absent when the flow needs no
80
- * steering.
81
- */
82
- deviceCodeSelect?: string;
83
- /** Captures the verification URL from scraped login output (group 1 preferred). */
84
- verificationUrlRegex?: RegExp;
85
- /** Captures the user code from scraped login output (group 1 preferred). */
86
- userCodeRegex?: RegExp;
87
- /**
88
- * Home-relative credential file that appears / bumps mtime on success — the
89
- * completion signal. Mirrors {@link FLEET_AUTH_FILES} (first entry for agents
90
- * with several).
91
- */
92
- successFile: string;
93
- }
94
- /**
95
- * Per-agent login flows, populated from live login-output captures (the only
96
- * ground truth — no fixtures exist upstream). Agents whose real device-code
97
- * output has not been captured are marked honestly (`unknown` / no regexes) so
98
- * `fleet login` flags them non-remotable instead of guessing.
99
- *
100
- * Only the `device-code` entries are ones `fleet login` drives; the rest are
101
- * carried so the command can explain WHY a logged-out pair is not remotable.
102
- */
103
- export declare const FLEET_LOGIN_FLOWS: Record<string, LoginFlow>;
104
51
  export interface SnapshotOptions {
105
52
  /** Home directory to read credential files from. */
106
53
  home: string;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Native-login inventory for `agents apply` and `agents fleet login`.
2
+ * Native-login inventory for `agents apply`.
3
3
  * Native OAuth/session files are identified only to report device readiness;
4
4
  * agents-cli never serializes or materializes them on another device.
5
5
  *
@@ -35,7 +35,7 @@ export const KEYCHAIN_BOUND_ON_MAC = new Set(['claude', 'antigravity']);
35
35
  * collapsing the fleet to a single working login (droid/WorkOS collapsed 10 boxes
36
36
  * to 1 overnight — RUSH-1958). Add any newly-discovered single-use-rotation
37
37
  * harness here; the predicate below is the one place the propagation decision is
38
- * made. See also `remote-login.ts` and `usage.ts` for the per-machine-login policy.
38
+ * made. See also `usage.ts` for the per-machine-login policy.
39
39
  */
40
40
  export const SINGLE_USE_ROTATING_REFRESH_AGENTS = new Set(['droid']);
41
41
  /**
@@ -61,93 +61,6 @@ export function hasPortableAuthFiles(agent) {
61
61
  export function isPropagatableAgent(agent) {
62
62
  return hasPortableAuthFiles(agent) && isCredentialSafeToPropagate(agent);
63
63
  }
64
- /** The success-file for an agent — the primary portable credential (first spec). */
65
- function successFileFor(agent) {
66
- const specs = FLEET_AUTH_FILES[agent];
67
- if (!specs || specs.length === 0)
68
- throw new Error(`no auth file spec for agent '${agent}'`);
69
- return specs[0].rel;
70
- }
71
- /**
72
- * Per-agent login flows, populated from live login-output captures (the only
73
- * ground truth — no fixtures exist upstream). Agents whose real device-code
74
- * output has not been captured are marked honestly (`unknown` / no regexes) so
75
- * `fleet login` flags them non-remotable instead of guessing.
76
- *
77
- * Only the `device-code` entries are ones `fleet login` drives; the rest are
78
- * carried so the command can explain WHY a logged-out pair is not remotable.
79
- */
80
- export const FLEET_LOGIN_FLOWS = {
81
- // droid: launches its TUI and prints, verbatim:
82
- // "If the link does not open automatically, please visit
83
- // https://auth.factory.ai/device and enter code MJQW-NQRM to complete
84
- // authentication." then "Waiting for authentication to complete...".
85
- droid: {
86
- loginCommand: 'droid',
87
- flowType: 'device-code',
88
- verificationUrlRegex: /please visit\s+(https:\/\/\S+?)\s+and enter code/i,
89
- userCodeRegex: /enter code\s+([A-Z0-9]{4}-[A-Z0-9]{4})/i,
90
- successFile: successFileFor('droid'),
91
- },
92
- // codex login shows a numbered menu; option 2 ("Sign in with Device Code",
93
- // "Sign in from another device with a one-time code") is the remotable one.
94
- // The post-selection URL/code output was not captured this session, so the
95
- // regexes are best-effort generic device-code patterns — TODO: tighten once a
96
- // real codex device-code screen is captured.
97
- codex: {
98
- loginCommand: 'codex login',
99
- flowType: 'device-code',
100
- deviceCodeSelect: '\x1b[B\r', // down-arrow once (to option 2) + Enter
101
- verificationUrlRegex: /(https:\/\/\S*(?:device|activate|login|auth)\S*)/i,
102
- userCodeRegex: /\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/,
103
- successFile: successFileFor('codex'),
104
- },
105
- // kimi: launch bare `kimi`, then `/login` inside the TUI, which prints
106
- // "Select a platform and authenticate". Post-selection URL/code not captured —
107
- // best-effort regexes, TODO: tighten with a real capture.
108
- kimi: {
109
- loginCommand: 'kimi',
110
- flowType: 'device-code',
111
- deviceCodeSelect: '/login\r',
112
- verificationUrlRegex: /(https:\/\/\S+)/i,
113
- userCodeRegex: /\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/,
114
- successFile: successFileFor('kimi'),
115
- },
116
- // grok: `grok login`. No captured pattern this session — flowType is
117
- // device-code (best guess for the remotable path) but regexes are left unset
118
- // (TODO: capture grok's login output), so scrapeLogin yields nothing until a
119
- // real pattern is added and driveRemoteLogin will time out rather than guess.
120
- grok: {
121
- loginCommand: 'grok login',
122
- flowType: 'device-code',
123
- successFile: successFileFor('grok'),
124
- },
125
- // antigravity: bare `agy`, Google OAuth. Keychain-bound on macOS and loopback
126
- // (browser + 127.0.0.1 listener on the box itself) — non-remotable.
127
- antigravity: {
128
- loginCommand: 'agy',
129
- flowType: 'loopback',
130
- successFile: successFileFor('antigravity'),
131
- },
132
- // opencode: `opencode auth login`. No captured pattern — unknown/non-remotable.
133
- opencode: {
134
- loginCommand: 'opencode auth login',
135
- flowType: 'unknown',
136
- successFile: successFileFor('opencode'),
137
- },
138
- // claude: interactive `/login` inside the TUI is a loopback browser flow, and
139
- // the token is keychain-bound on macOS. That native login stays per-box and
140
- // is never copied. The shareable `claude setup-token` mint is a different
141
- // credential class — minted by `agents accounts add claude [name]` /
142
- // `agents accounts login claude#<name>` (`lib/auth-mint.ts`), not as a
143
- // fleet-login device-code flow (a setup-token is account-scoped, not
144
- // per-machine).
145
- claude: {
146
- loginCommand: 'claude',
147
- flowType: 'loopback',
148
- successFile: successFileFor('claude'),
149
- },
150
- };
151
64
  /**
152
65
  * Capture portable credential files for the given agents from a source home.
153
66
  * Returns the readable file payloads plus the list of agents whose auth is
@@ -1,14 +1,19 @@
1
1
  export const HARNESS_AUTH = {
2
2
  claude: { login: ['auth', 'login'], status: ['auth', 'status'], identity: 'strong', worker: 'setup-token', slotEnv: 'CLAUDE_CONFIG_DIR' },
3
3
  codex: { login: ['login'], status: ['login', 'status'], identity: 'strong', worker: ['api-key:OPENAI_API_KEY', 'per-device:device-auth'], slotEnv: 'CODEX_HOME' },
4
- grok: { login: ['login'], status: null, identity: 'strong', worker: 'api-key:XAI_API_KEY', slotEnv: 'GROK_HOME' },
4
+ // `--device-auth` pins the device-code flow: bare `grok login` defaults to
5
+ // `--oauth`, the loopback browser flow, which opens whatever browser profile
6
+ // the OS defaults to. The device-code screen prints the URL + code instead,
7
+ // so the human finishes it in the browser profile they choose — which is
8
+ // what makes it usable over an SSH shell on a worker.
9
+ grok: { login: ['login', '--device-auth'], status: null, identity: 'strong', worker: 'api-key:XAI_API_KEY', slotEnv: 'GROK_HOME' },
5
10
  // auth.json has no email claim; identity is the sorted provider-id join
6
11
  // (`resolveOpenCodeAccountId`). NATIVE_ACCOUNT_CAPABILITIES.opencode.inspection
7
12
  // is already 'opaque'.
8
13
  opencode: { login: ['auth', 'login'], status: ['auth', 'list'], identity: 'opaque', worker: 'api-key:provider', slotEnv: 'XDG_DATA_HOME' },
9
14
  cursor: { login: ['login'], status: ['status'], identity: 'strong', worker: 'api-key:CURSOR_API_KEY', slotEnv: null },
10
15
  // No finite login argv — launch bare `kimi`, then `/login` in the TUI
11
- // (`loginHint('kimi') === 'kimi'`, fleet/auth-sync `loginCommand: 'kimi'`).
16
+ // (`loginHint('kimi') === 'kimi'`).
12
17
  kimi: { login: null, status: null, identity: 'opaque', worker: 'none', slotEnv: 'KIMI_CODE_HOME' },
13
18
  antigravity: { login: null, status: null, identity: 'opaque', worker: 'none', slotEnv: null },
14
19
  droid: { login: null, status: null, identity: 'opaque', worker: 'api-key:FACTORY_API_KEY', slotEnv: null },
@@ -1,4 +1,5 @@
1
1
  import type { Host } from './types.js';
2
+ import { remoteShellFor } from './remote-cmd.js';
2
3
  import { type HostTask } from './tasks.js';
3
4
  import { type HostCredentials } from './credentials.js';
4
5
  import { deriveMirroredCwd, homeRemainder, remoteCdPrefix } from '../project-root.js';
@@ -31,6 +32,13 @@ export declare function withActorEnv(env?: Record<string, string>): Record<strin
31
32
  * place.
32
33
  */
33
34
  export declare function remoteRunShellPrelude(agent: string, extra?: Record<string, string>): string;
35
+ /**
36
+ * The env every remote `agents run` dispatch carries across the SSH hop —
37
+ * actor provenance, the `run auto` chain-hop guard, and the caller's path
38
+ * markers. `remoteRunShellPrelude` renders it as POSIX exports; a Windows peer
39
+ * gets the same record as `$env:` assignments in its PowerShell script.
40
+ */
41
+ export declare function remoteRunEnv(agent: string, extra?: Record<string, string>): Record<string, string>;
34
42
  /**
35
43
  * Launch a detached login-shell command in its own Unix session/process group.
36
44
  *
@@ -146,7 +154,7 @@ export interface DispatchOptions {
146
154
  copyCreds?: HostCredentials;
147
155
  }
148
156
  export declare function buildRunForwardedArgs(opts: DispatchOptions): string[];
149
- interface InteractiveDispatchOptions {
157
+ export interface InteractiveDispatchOptions {
150
158
  agent: string;
151
159
  /** Explicit agent version pin (e.g. "2.1.207") to forward as `agent@version`. */
152
160
  version?: string;
@@ -199,6 +207,17 @@ interface InteractiveDispatchOptions {
199
207
  * infer headless from the prompt).
200
208
  */
201
209
  export declare function buildInteractiveRunForwardedArgs(opts: InteractiveDispatchOptions): string[];
210
+ /**
211
+ * The remote command of an interactive host dispatch, by peer shell.
212
+ *
213
+ * A Windows peer's sshd hands the command to PowerShell, which cannot parse the
214
+ * POSIX `export …; { cd … || cd "$HOME"; } && agents …` form (a `run --device
215
+ * <windows box>` from a TTY died with `At line:1 char:420` at the `||`). That
216
+ * peer gets the rendered PowerShell every other `--device` site uses — env,
217
+ * mirrored cwd and argv inside one `-EncodedCommand` — the interactive analogue
218
+ * of `buildWindowsDetachedLaunchCommand`.
219
+ */
220
+ export declare function buildInteractiveRemoteCommand(remoteShell: ReturnType<typeof remoteShellFor>, opts: InteractiveDispatchOptions): string;
202
221
  /**
203
222
  * Run an agent interactively on a host, forwarding the local TTY over SSH.
204
223
  * Returns the SSH exit code. The remote `agents` CLI is responsible for its own
@@ -13,7 +13,7 @@ import { randomUUID } from 'crypto';
13
13
  import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
14
14
  import { hostIdentityArgs, sshTargetFor } from './types.js';
15
15
  import { ensureHostReady } from './ready.js';
16
- import { encodePowershell, powershellQuote, remoteShellFor, posixEnvExports } from './remote-cmd.js';
16
+ import { buildWindowsAgentsCommand, encodePowershell, powershellQuote, remoteShellFor, posixEnvExports, windowsRemotePath, windowsSetLocation } from './remote-cmd.js';
17
17
  import { resolveRemoteOsSync } from './remote-os.js';
18
18
  import { resolveActor, actorEnv } from '../actor.js';
19
19
  import { saveTask, updateTask, terminalPatch } from './tasks.js';
@@ -85,10 +85,19 @@ export function withActorEnv(env) {
85
85
  * place.
86
86
  */
87
87
  export function remoteRunShellPrelude(agent, extra = {}) {
88
- const guard = agent === RUN_AUTO_KEYWORD ? { [RUN_AUTO_HOST_RESOLVED_ENV]: '1' } : {};
89
- const exports = posixEnvExports(withActorEnv({ ...guard, ...extra }));
88
+ const exports = posixEnvExports(remoteRunEnv(agent, extra));
90
89
  return exports ? `${exports}; ` : '';
91
90
  }
91
+ /**
92
+ * The env every remote `agents run` dispatch carries across the SSH hop —
93
+ * actor provenance, the `run auto` chain-hop guard, and the caller's path
94
+ * markers. `remoteRunShellPrelude` renders it as POSIX exports; a Windows peer
95
+ * gets the same record as `$env:` assignments in its PowerShell script.
96
+ */
97
+ export function remoteRunEnv(agent, extra = {}) {
98
+ const guard = agent === RUN_AUTO_KEYWORD ? { [RUN_AUTO_HOST_RESOLVED_ENV]: '1' } : {};
99
+ return withActorEnv({ ...guard, ...extra });
100
+ }
92
101
  /**
93
102
  * Launch a detached login-shell command in its own Unix session/process group.
94
103
  *
@@ -107,21 +116,12 @@ export function buildDetachedLaunchCommand(inner) {
107
116
  ].join(' ');
108
117
  return `bash -lc ${shellQuote(`node -e ${shellQuote(nodeScript)}`)}`;
109
118
  }
110
- function windowsRemotePath(path) {
111
- return path.startsWith('$HOME/')
112
- ? `(Join-Path $HOME ${powershellQuote(path.slice('$HOME/'.length))})`
113
- : powershellQuote(path);
114
- }
115
119
  /** Build the detached PowerShell launch protocol used by Windows SSH hosts. */
116
120
  export function buildWindowsDetachedLaunchCommand(opts) {
117
121
  const log = windowsRemotePath(opts.remoteLog);
118
122
  const exit = windowsRemotePath(opts.remoteExit);
119
123
  const env = Object.entries(opts.env).map(([key, value]) => `$env:${key} = ${powershellQuote(value)}`);
120
- const cwd = opts.remoteCwd
121
- ? opts.mirrorCwd
122
- ? `try { Set-Location -LiteralPath ${powershellQuote(opts.remoteCwd)} } catch { Set-Location -LiteralPath $HOME }`
123
- : `Set-Location -LiteralPath ${powershellQuote(opts.remoteCwd)}`
124
- : '';
124
+ const cwd = opts.remoteCwd ? windowsSetLocation(opts.remoteCwd, opts.mirrorCwd) : '';
125
125
  const inner = [
126
126
  `$ProgressPreference = 'SilentlyContinue'`,
127
127
  `$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'`,
@@ -273,7 +273,7 @@ async function launchDetached(host, target, opts) {
273
273
  mirrorCwd: opts.mirrorCwd,
274
274
  remoteLog,
275
275
  remoteExit,
276
- env: withActorEnv(opts.agentLabel === RUN_AUTO_KEYWORD ? { [RUN_AUTO_HOST_RESOLVED_ENV]: '1' } : {}),
276
+ env: remoteRunEnv(opts.agentLabel),
277
277
  })
278
278
  : `mkdir -p ${REMOTE_DIR}; ${buildDetachedLaunchCommand(inner)}`;
279
279
  const res = sshExec(target, launch, {
@@ -471,19 +471,17 @@ export function buildInteractiveRunForwardedArgs(opts) {
471
471
  return args;
472
472
  }
473
473
  /**
474
- * Run an agent interactively on a host, forwarding the local TTY over SSH.
475
- * Returns the SSH exit code. The remote `agents` CLI is responsible for its own
476
- * tmux wrapping; the local machine is just the transport.
474
+ * The remote command of an interactive host dispatch, by peer shell.
475
+ *
476
+ * A Windows peer's sshd hands the command to PowerShell, which cannot parse the
477
+ * POSIX `export …; { cd … || cd "$HOME"; } && agents …` form (a `run --device
478
+ * <windows box>` from a TTY died with `At line:1 char:420` at the `||`). That
479
+ * peer gets the rendered PowerShell every other `--device` site uses — env,
480
+ * mirrored cwd and argv inside one `-EncodedCommand` — the interactive analogue
481
+ * of `buildWindowsDetachedLaunchCommand`.
477
482
  */
478
- export async function runInteractiveOnHost(host, opts) {
479
- const target = sshTargetFor(host);
480
- // Concrete version pins fail loud here (RUSH-2313) so we never open a TTY
481
- // to a box that cannot run the pin.
482
- const { warnings } = ensureHostReady(host, { agent: opts.agent, version: opts.version });
483
- for (const w of warnings)
484
- process.stderr.write(`[hosts] warning: ${w}\n`);
485
- const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
486
- const cwd = remoteCdPrefix(opts.remoteCwd, { mirror: opts.mirrorCwd });
483
+ export function buildInteractiveRemoteCommand(remoteShell, opts) {
484
+ const forwardedArgs = buildInteractiveRunForwardedArgs(opts);
487
485
  // Forward actor provenance so the interactive remote run inherits it rather
488
486
  // than re-resolving from this box's SSH_CONNECTION (RUSH-2028); a `run auto`
489
487
  // dispatch also gets the chain-hop guard (remoteRunShellPrelude).
@@ -496,11 +494,35 @@ export async function runInteractiveOnHost(host, opts) {
496
494
  // longer forces the tmux wrap on a TTY-followed run — the peer's
497
495
  // tmux.enabled decides that. Set here, on the interactive path only:
498
496
  // `launchDetached` already setsids the headless one.
499
- const prelude = remoteRunShellPrelude(opts.agent, { [REMOTE_INTERACTIVE_ENV]: '1' });
500
- let remoteCmd = `${prelude}${cwd}${invocation}`;
501
- if (opts.copyCreds) {
502
- remoteCmd = wrapHostCommandWithCredentials(remoteCmd, opts.copyCreds);
497
+ const env = { [REMOTE_INTERACTIVE_ENV]: '1' };
498
+ if (remoteShell === 'powershell') {
499
+ if (opts.copyCreds)
500
+ throw new Error('--copy-creds cannot ride an interactive dispatch to a Windows host');
501
+ return buildWindowsAgentsCommand({
502
+ args: forwardedArgs,
503
+ env: remoteRunEnv(opts.agent, env),
504
+ cwd: opts.remoteCwd,
505
+ mirrorCwd: opts.mirrorCwd,
506
+ });
503
507
  }
508
+ const invocation = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
509
+ const cwd = remoteCdPrefix(opts.remoteCwd, { mirror: opts.mirrorCwd });
510
+ const remoteCmd = `${remoteRunShellPrelude(opts.agent, env)}${cwd}${invocation}`;
511
+ return opts.copyCreds ? wrapHostCommandWithCredentials(remoteCmd, opts.copyCreds) : remoteCmd;
512
+ }
513
+ /**
514
+ * Run an agent interactively on a host, forwarding the local TTY over SSH.
515
+ * Returns the SSH exit code. The remote `agents` CLI is responsible for its own
516
+ * tmux wrapping; the local machine is just the transport.
517
+ */
518
+ export async function runInteractiveOnHost(host, opts) {
519
+ const target = sshTargetFor(host);
520
+ // Concrete version pins fail loud here (RUSH-2313) so we never open a TTY
521
+ // to a box that cannot run the pin.
522
+ const { warnings } = ensureHostReady(host, { agent: opts.agent, version: opts.version });
523
+ for (const w of warnings)
524
+ process.stderr.write(`[hosts] warning: ${w}\n`);
525
+ const remoteCmd = buildInteractiveRemoteCommand(remoteShellFor(host.os ?? resolveRemoteOsSync(host.name)), opts);
504
526
  // Credentials ride this stream when --copy-creds is set: verify the host key
505
527
  // strictly against the managed pin and force a fresh connection so a stale
506
528
  // accept-new control socket can't bypass the check (RUSH-1767).
@@ -112,6 +112,12 @@ interface WindowsAgentsCommand {
112
112
  env?: Record<string, string>;
113
113
  /** Directory to enter before running (`--remote-cwd`). */
114
114
  cwd?: string;
115
+ /**
116
+ * `cwd` was DERIVED from the local cwd rather than named by the user (see
117
+ * `deriveMirroredCwd`): a directory the peer does not have falls back to
118
+ * `$HOME` instead of failing the run, exactly as `remoteCdPrefix({ mirror })`.
119
+ */
120
+ mirrorCwd?: boolean;
115
121
  /**
116
122
  * Append `exit $LASTEXITCODE` so a native `agents` exit code propagates out
117
123
  * through `powershell.exe` (which otherwise exits 0 regardless). Default true;
@@ -188,6 +194,21 @@ export declare function stripClixml(stdout: string): string;
188
194
  * with it.
189
195
  */
190
196
  export declare function windowsAgentsInvocation(args: string[], binName?: 'agents' | 'ag'): string;
197
+ /**
198
+ * PowerShell expression for a remote path. A home-anchored path (`~/x`,
199
+ * `$HOME/x`) re-roots onto the peer's `$HOME` through `Join-Path` — the Windows
200
+ * counterpart of `remoteCdPrefix`'s `"$HOME"/x` — so a directory mirrored from
201
+ * another box resolves on the peer. Any other path is quoted verbatim.
202
+ */
203
+ export declare function windowsRemotePath(p: string): string;
204
+ /**
205
+ * The `Set-Location` step of a Windows remote command. `-ErrorAction Stop` is
206
+ * spelled out because a missing directory is a NON-terminating error for
207
+ * `Set-Location`, which a `try`/`catch` would otherwise sail past: the mirrored
208
+ * form must genuinely land in `$HOME` when the peer lacks the checkout, and the
209
+ * explicit form must abort rather than run the command in the wrong directory.
210
+ */
211
+ export declare function windowsSetLocation(cwd: string, mirror?: boolean): string;
191
212
  export declare function windowsAgentsScript(cmd: WindowsAgentsCommand): string;
192
213
  /**
193
214
  * Build the `ssh <target> <cmd>` string for one `agents …` invocation on a
@@ -10,6 +10,7 @@
10
10
  import { shellQuote } from '../ssh-exec.js';
11
11
  import { pwshLiteral, pwshNativeExecStatements } from '../pwsh.js';
12
12
  import { quoteWin32ExecArg } from '../platform/exec.js';
13
+ import { homeRemainder } from '../project-root.js';
13
14
  import * as zlib from 'node:zlib';
14
15
  /**
15
16
  * Remove routing flags (and their values) from a command's args, leaving the
@@ -108,6 +109,7 @@ export const RUN_OPTION_FORWARDING = {
108
109
  disableTmux: 'local-only',
109
110
  device: 'local-only',
110
111
  where: 'local-only', // expands into host/lease before dispatch; never re-forwarded
112
+ local: 'local-only', // this machine by definition — a local run never reaches the SSH boundary
111
113
  on: 'local-only',
112
114
  computer: 'local-only',
113
115
  any: 'local-only',
@@ -363,8 +365,31 @@ export function windowsAgentsInvocation(args, binName = 'agents') {
363
365
  // would produce invalid PowerShell.
364
366
  ].join('\n');
365
367
  }
368
+ /**
369
+ * PowerShell expression for a remote path. A home-anchored path (`~/x`,
370
+ * `$HOME/x`) re-roots onto the peer's `$HOME` through `Join-Path` — the Windows
371
+ * counterpart of `remoteCdPrefix`'s `"$HOME"/x` — so a directory mirrored from
372
+ * another box resolves on the peer. Any other path is quoted verbatim.
373
+ */
374
+ export function windowsRemotePath(p) {
375
+ const rest = homeRemainder(p);
376
+ if (rest === null)
377
+ return powershellQuote(p);
378
+ return rest === '' ? '$HOME' : `(Join-Path $HOME ${powershellQuote(rest)})`;
379
+ }
380
+ /**
381
+ * The `Set-Location` step of a Windows remote command. `-ErrorAction Stop` is
382
+ * spelled out because a missing directory is a NON-terminating error for
383
+ * `Set-Location`, which a `try`/`catch` would otherwise sail past: the mirrored
384
+ * form must genuinely land in `$HOME` when the peer lacks the checkout, and the
385
+ * explicit form must abort rather than run the command in the wrong directory.
386
+ */
387
+ export function windowsSetLocation(cwd, mirror = false) {
388
+ const enter = `Set-Location -LiteralPath ${windowsRemotePath(cwd)} -ErrorAction Stop`;
389
+ return mirror ? `try { ${enter} } catch { Set-Location -LiteralPath $HOME }` : enter;
390
+ }
366
391
  export function windowsAgentsScript(cmd) {
367
- const { args, env, cwd, propagateExit = true, remapExit255 = false } = cmd;
392
+ const { args, env, cwd, mirrorCwd = false, propagateExit = true, remapExit255 = false } = cmd;
368
393
  // `Stop` FIRST, before the env assignments and the Set-Location: a failing
369
394
  // `Set-Location` (a cwd that does not exist on the peer) must abort rather than
370
395
  // continue into the launcher and run the command in the wrong directory.
@@ -373,7 +398,7 @@ export function windowsAgentsScript(cmd) {
373
398
  for (const [k, v] of Object.entries(env))
374
399
  parts.push(`$env:${k} = ${powershellQuote(v)}`);
375
400
  if (cwd)
376
- parts.push(`Set-Location -LiteralPath ${powershellQuote(cwd)}`);
401
+ parts.push(windowsSetLocation(cwd, mirrorCwd));
377
402
  parts.push(windowsAgentsInvocation(args));
378
403
  if (propagateExit) {
379
404
  if (remapExit255)
@@ -22,6 +22,16 @@ export interface MailboxMessage {
22
22
  * receipts back to the feed store.
23
23
  */
24
24
  blockId?: string;
25
+ /**
26
+ * Which ASK of that block this message answers, and which claim sent it. A
27
+ * blockId is per SESSION and is reused by every generation of that session's
28
+ * questions, so without these a `consumed` receipt raised on drain cannot say
29
+ * WHICH question it acknowledges -- and a late one for question N would
30
+ * resolve question N+1 (PHNX-3999). Carried on the message so the binding
31
+ * survives the process that enqueued it.
32
+ */
33
+ generation?: string;
34
+ attempt?: string;
25
35
  /**
26
36
  * Drop reason when a message is archived without delivery (expired, dead box, etc.).
27
37
  * Set by the TTL/liveness layer, not by writers.
@@ -49,6 +59,8 @@ export declare function enqueue(boxDir: string, msg: {
49
59
  text: string;
50
60
  from?: string;
51
61
  blockId?: string;
62
+ generation?: string;
63
+ attempt?: string;
52
64
  ttlSeconds?: number;
53
65
  }): string;
54
66
  /** Parse a message file. Returns null on missing/corrupt/invalid-shape. */
@@ -97,6 +97,8 @@ export function enqueue(boxDir, msg) {
97
97
  ts: now.toISOString(),
98
98
  text: msg.text,
99
99
  blockId: msg.blockId,
100
+ ...(msg.generation !== undefined ? { generation: msg.generation } : {}),
101
+ ...(msg.attempt !== undefined ? { attempt: msg.attempt } : {}),
100
102
  };
101
103
  const ttlSeconds = msg.ttlSeconds ?? resolveDefaultTtlSeconds();
102
104
  if (ttlSeconds > 0) {
@@ -128,7 +130,14 @@ export function readMessage(file) {
128
130
  if (typeof m?.msgId !== 'string' || typeof m?.to !== 'string' || typeof m?.text !== 'string') {
129
131
  return null;
130
132
  }
131
- return { msgId: m.msgId, to: m.to, from: m.from, ts: m.ts ?? '', text: m.text, expiresAt: m.expiresAt, blockId: m.blockId, dropped: m.dropped };
133
+ // Field-by-field on purpose (an unknown key from a foreign writer is dropped),
134
+ // so EVERY field the receipt binding depends on must be listed here or it is
135
+ // silently lost on read and the binding it enables never fires.
136
+ return {
137
+ msgId: m.msgId, to: m.to, from: m.from, ts: m.ts ?? '', text: m.text,
138
+ expiresAt: m.expiresAt, blockId: m.blockId, generation: m.generation, attempt: m.attempt,
139
+ dropped: m.dropped,
140
+ };
132
141
  }
133
142
  /** True when a message has a parsed expiry in the past. */
134
143
  export function isExpired(msg, now = new Date()) {
@@ -230,7 +239,12 @@ function consumeClaimed(boxDir, name, expectedTo) {
230
239
  if (msg.blockId) {
231
240
  try {
232
241
  const feedRoot = process.env.AGENTS_FEED_DIR;
233
- recordMessageReceipt(msg.blockId, { msgId: msg.msgId, status: 'consumed', at: new Date().toISOString(), from: msg.from }, feedRoot);
242
+ // The origin rides the message, so the receipt names the ask it answers.
243
+ recordMessageReceipt(msg.blockId, {
244
+ msgId: msg.msgId, status: 'consumed', at: new Date().toISOString(), from: msg.from,
245
+ ...(msg.generation !== undefined ? { generation: msg.generation } : {}),
246
+ ...(msg.attempt !== undefined ? { attempt: msg.attempt } : {}),
247
+ }, feedRoot);
234
248
  }
235
249
  catch {
236
250
  // Receipt surfacing is best-effort; never stall delivery.
@@ -1,3 +1,4 @@
1
+ import type { DeviceStats } from '../devices/health.js';
1
2
  import type { WatchdogTickResult } from '../watchdog/runner.js';
2
3
  /**
3
4
  * One registered fleet device, for the menu-bar's collapsible DEVICES section.
@@ -20,6 +21,47 @@ interface MenubarDevice {
20
21
  interactive: boolean;
21
22
  isLocal: boolean;
22
23
  preferred: boolean;
24
+ /**
25
+ * The operator's role mark — `worker` | `personal` | `desktop`, or `unknown`
26
+ * when the device was never marked (PHNX-3999 F25). Read from the same
27
+ * device-scope config `--device auto` consults, so the menu shows the fact that
28
+ * governs placement rather than a second notion of it.
29
+ */
30
+ role: string;
31
+ /**
32
+ * True when automatic placement (`agents run --device auto`) may pick this
33
+ * device: the verdict of the ONE canonical pool filter, so a `personal` box the
34
+ * user sits at reads as ineligible here exactly as it behaves there.
35
+ */
36
+ autoEligible: boolean;
37
+ /** Hardware facts and the current reading, or null when never observed. */
38
+ stats: MenubarDeviceStats | null;
39
+ }
40
+ /**
41
+ * One device's hardware facts and last reading, projected from the fleet-stats
42
+ * cache the CLI already keeps (`~/.agents/.cache/.fleet-stats.json`).
43
+ *
44
+ * Cache-only by construction: the snapshot never probes the fleet, so opening
45
+ * Settings costs nothing and a device nobody has measured says so. Every value
46
+ * is `null` when that number was not observed — never 0, which would render as a
47
+ * real "idle, empty disk" reading. `stale` carries the CLI's own freshness bound
48
+ * so the menu can label an old reading instead of showing it as live, and
49
+ * `observedAt` / `specsObservedAt` give the observation age F25 asks for
50
+ * (hardware totals survive an unreachable probe; current-state readings do not).
51
+ */
52
+ interface MenubarDeviceStats {
53
+ reachable: boolean;
54
+ observedAt: string;
55
+ stale: boolean;
56
+ cpus: number | null;
57
+ memTotalBytes: number | null;
58
+ memFreeBytes: number | null;
59
+ memPercent: number | null;
60
+ diskTotalBytes: number | null;
61
+ diskFreeBytes: number | null;
62
+ diskUsedPercent: number | null;
63
+ loadPercent: number | null;
64
+ specsObservedAt: string | null;
23
65
  }
24
66
  interface MenubarSnapshot {
25
67
  version: 1;
@@ -55,6 +97,15 @@ interface MenubarSnapshot {
55
97
  * (only `defaultProject`) is omitted so the native app keeps its own default.
56
98
  */
57
99
  export declare function buildMenuPreferences(): Record<string, unknown>;
100
+ /**
101
+ * Project one cached {@link DeviceStats} row into the snapshot shape, or `null`
102
+ * when the device has no cached reading at all.
103
+ *
104
+ * `?? null` rather than a default: an absent number means "not observed", and the
105
+ * menu renders that as unavailable. A 0 would be indistinguishable from a real
106
+ * measurement of an idle box or a full disk.
107
+ */
108
+ export declare function projectDeviceStats(row: DeviceStats | undefined): MenubarDeviceStats | null;
58
109
  export declare function readLastWatchdogTick(stateDir?: string): WatchdogTickResult | null;
59
110
  /** One-process read model for AGI Menu's repeating three-minute refresh. */
60
111
  export declare function computeMenubarSnapshot(): Promise<MenubarSnapshot>;