@phnx-labs/agents-cli 1.22.115 → 1.22.116

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 (88) hide show
  1. package/CHANGELOG.md +149 -14
  2. package/README.md +1 -1
  3. package/dist/commands/browser.js +11 -0
  4. package/dist/commands/exec.js +212 -141
  5. package/dist/commands/feed.js +65 -14
  6. package/dist/commands/sessions-picker.d.ts +11 -0
  7. package/dist/commands/sessions-picker.js +88 -7
  8. package/dist/commands/sessions.d.ts +21 -2
  9. package/dist/commands/sessions.js +157 -3
  10. package/dist/commands/setup-secrets.d.ts +2 -2
  11. package/dist/commands/setup-term.d.ts +24 -0
  12. package/dist/commands/setup-term.js +70 -0
  13. package/dist/commands/setup.d.ts +1 -1
  14. package/dist/commands/setup.js +12 -4
  15. package/dist/commands/ssh.js +1 -69
  16. package/dist/lib/accounting/rotate.d.ts +63 -1
  17. package/dist/lib/accounting/rotate.js +56 -0
  18. package/dist/lib/accounts/add.js +2 -2
  19. package/dist/lib/accounts/slots.js +32 -2
  20. package/dist/lib/answer-router.d.ts +11 -2
  21. package/dist/lib/answer-router.js +26 -2
  22. package/dist/lib/auth-mint.d.ts +5 -4
  23. package/dist/lib/auth-mint.js +4 -3
  24. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  25. package/dist/lib/browser/service.d.ts +10 -0
  26. package/dist/lib/browser/service.js +208 -36
  27. package/dist/lib/browser/types.d.ts +18 -0
  28. package/dist/lib/config-keys.d.ts +1 -1
  29. package/dist/lib/config-keys.js +5 -0
  30. package/dist/lib/device-config.js +61 -0
  31. package/dist/lib/devices/doctor-findings.js +2 -6
  32. package/dist/lib/feed/answer.d.ts +153 -4
  33. package/dist/lib/feed/answer.js +716 -105
  34. package/dist/lib/feed/feed.d.ts +61 -1
  35. package/dist/lib/feed/feed.js +226 -14
  36. package/dist/lib/feed/hub-server.d.ts +58 -3
  37. package/dist/lib/feed/hub-server.js +306 -54
  38. package/dist/lib/feed/pr-status.d.ts +8 -0
  39. package/dist/lib/feed/pr-status.js +9 -1
  40. package/dist/lib/feed-outcome.d.ts +1 -1
  41. package/dist/lib/feed-outcome.js +9 -2
  42. package/dist/lib/feed-policy.js +9 -3
  43. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  44. package/dist/lib/fleet/auth-sync.js +2 -89
  45. package/dist/lib/harness-auth-capabilities.js +7 -2
  46. package/dist/lib/hosts/dispatch.d.ts +20 -1
  47. package/dist/lib/hosts/dispatch.js +52 -30
  48. package/dist/lib/hosts/remote-cmd.d.ts +21 -0
  49. package/dist/lib/hosts/remote-cmd.js +26 -2
  50. package/dist/lib/mailbox.d.ts +12 -0
  51. package/dist/lib/mailbox.js +16 -2
  52. package/dist/lib/menubar/snapshot.d.ts +51 -0
  53. package/dist/lib/menubar/snapshot.js +42 -3
  54. package/dist/lib/open-url.js +2 -2
  55. package/dist/lib/projects.d.ts +23 -0
  56. package/dist/lib/projects.js +78 -0
  57. package/dist/lib/secrets-cli.d.ts +3 -3
  58. package/dist/lib/secrets-cli.js +1 -1
  59. package/dist/lib/session/active.d.ts +1 -0
  60. package/dist/lib/session/active.js +8 -0
  61. package/dist/lib/session/db.d.ts +67 -3
  62. package/dist/lib/session/db.js +381 -126
  63. package/dist/lib/session/prompt.d.ts +23 -7
  64. package/dist/lib/session/prompt.js +46 -8
  65. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  66. package/dist/lib/session/remote/remote-list.js +22 -6
  67. package/dist/lib/session/remote/watch.d.ts +12 -0
  68. package/dist/lib/session/remote/watch.js +9 -0
  69. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  70. package/dist/lib/session/remote-preview-cache.js +373 -0
  71. package/dist/lib/session/tail.d.ts +50 -0
  72. package/dist/lib/session/tail.js +219 -0
  73. package/dist/lib/setup-tool-install.js +2 -1
  74. package/dist/lib/setup-tool-status.d.ts +1 -1
  75. package/dist/lib/setup-tool-status.js +6 -1
  76. package/dist/lib/signin-badge.d.ts +19 -4
  77. package/dist/lib/signin-badge.js +29 -11
  78. package/dist/lib/term-driver.d.ts +24 -0
  79. package/dist/lib/term-driver.js +36 -0
  80. package/dist/lib/terminal/index.d.ts +1 -1
  81. package/dist/lib/terminal/index.js +1 -1
  82. package/dist/lib/terminal/inject.d.ts +38 -0
  83. package/dist/lib/terminal/inject.js +55 -9
  84. package/dist/lib/terminal/transport.d.ts +15 -5
  85. package/dist/lib/terminal/transport.js +61 -11
  86. package/package.json +1 -1
  87. package/dist/lib/fleet/remote-login.d.ts +0 -170
  88. package/dist/lib/fleet/remote-login.js +0 -568
@@ -73,3 +73,222 @@ export function readSessionTailWithRaw(filePath, agent, maxBytes = DEFAULT_MAX_B
73
73
  export function readSessionTail(filePath, agent, maxBytes = DEFAULT_MAX_BYTES, maxEvents = DEFAULT_MAX_EVENTS) {
74
74
  return readSessionTailWithRaw(filePath, agent, maxBytes, maxEvents).events;
75
75
  }
76
+ /** A session's original first turn is typically within the first few KiB —
77
+ * far smaller than the tail window, since it's exactly one line near the
78
+ * start of the file, not a rolling window of recent activity. */
79
+ const DEFAULT_HEAD_MAX_BYTES = 32 * 1024;
80
+ /**
81
+ * Read exactly the first `maxBytes` of the file and, if that chunk doesn't
82
+ * reach EOF, drop a trailing partial line so downstream per-line parsers only
83
+ * see whole lines. This is the plain, cheap path — correct and unchanged for
84
+ * the overwhelming majority of transcripts, where the opening JSONL record is
85
+ * a few hundred bytes. Returns '' on any error, an empty file, or a chunk that
86
+ * turned out to hold no complete line at all (the record is bigger than
87
+ * `maxBytes`) — callers needing to recover from THAT case use
88
+ * {@link readSessionHeadContentBounded}, not this.
89
+ */
90
+ function readSessionHeadChunk(filePath, maxBytes) {
91
+ let fd;
92
+ try {
93
+ fd = fs.openSync(filePath, 'r');
94
+ }
95
+ catch {
96
+ return undefined;
97
+ }
98
+ try {
99
+ const size = fs.fstatSync(fd).size;
100
+ if (size === 0)
101
+ return { content: '', sawEof: true };
102
+ const len = Math.min(size, maxBytes);
103
+ const buf = Buffer.alloc(len);
104
+ fs.readSync(fd, buf, 0, len, 0);
105
+ return { content: buf.toString('utf8'), sawEof: len >= size };
106
+ }
107
+ catch {
108
+ return undefined;
109
+ }
110
+ finally {
111
+ fs.closeSync(fd);
112
+ }
113
+ }
114
+ /**
115
+ * Read the FIRST `maxBytes` of a JSONL transcript as cleaned text — the mirror
116
+ * of {@link readSessionTailContent}, for recovering the session's ACTUAL
117
+ * original request boundedly when it isn't already indexed
118
+ * (`SessionMeta.firstUserMessage`). Returns '' on any error, an empty file, or
119
+ * a first record too large to fit `maxBytes` (bare — no elision fallback; see
120
+ * {@link readSessionHeadContentBounded} for that).
121
+ */
122
+ export function readSessionHeadContent(filePath, maxBytes = DEFAULT_HEAD_MAX_BYTES) {
123
+ const chunk = readSessionHeadChunk(filePath, maxBytes);
124
+ if (!chunk)
125
+ return '';
126
+ let content = chunk.content;
127
+ if (!chunk.sawEof) {
128
+ // Didn't reach EOF: the last line in this chunk may be a partial write
129
+ // of a longer record. Keep only whole lines.
130
+ const lastNl = content.lastIndexOf('\n');
131
+ content = lastNl >= 0 ? content.slice(0, lastNl) : '';
132
+ }
133
+ return content;
134
+ }
135
+ /**
136
+ * Hard ceiling on how far {@link readSessionHeadContentBounded} will read
137
+ * looking for the opening record's closing newline. A JSONL record is one
138
+ * line, so a user turn that embeds a multi-megabyte image (base64, inline in
139
+ * the same `"data": "..."` string) can push that line's own terminator
140
+ * arbitrarily far past the plain {@link DEFAULT_HEAD_MAX_BYTES} chunk — this
141
+ * is the point past which the record is treated as unrecoverable (`partial`),
142
+ * never guessed at from a truncated fragment.
143
+ */
144
+ const HEAD_ELISION_MAX_READ_BYTES = 4 * 1024 * 1024;
145
+ /** Keep at most this many UTF-16 units of any single JSON string value
146
+ * verbatim; the remainder is replaced with a short marker. Bounds the ELIDED
147
+ * OUTPUT size independent of how large the source string (an image's base64
148
+ * payload can be tens of megabytes) actually is, while staying far larger
149
+ * than any real first-turn text block ever needs to be. */
150
+ const HEAD_ELISION_STRING_KEEP_UNITS = 2048;
151
+ /** Wall-clock ceiling on the elision scan itself. The scan runs over data
152
+ * already bounded by {@link HEAD_ELISION_MAX_READ_BYTES} and does O(1) work
153
+ * per character, so this should never trip in practice — it exists as a
154
+ * defense-in-depth budget, not the primary bound. */
155
+ const HEAD_ELISION_MAX_MS = 200;
156
+ /** How many characters the elision scan advances between wall-clock checks —
157
+ * frequent enough that the time budget above is actually honored, infrequent
158
+ * enough that `Date.now()` isn't on the hot path of every character. */
159
+ const HEAD_ELISION_TIME_CHECK_MASK = 0x3ffff; // every ~262k chars
160
+ /**
161
+ * A single-pass, JSON-string-aware elision scan: copies `input` verbatim
162
+ * except inside a string literal whose content exceeds
163
+ * {@link HEAD_ELISION_STRING_KEEP_UNITS}, where it keeps a short prefix and
164
+ * splices in a `…[elided N chars]` marker for the rest — but still tracks
165
+ * escape/quote state through to that string's real closing quote, so
166
+ * surrounding JSON structure (a text block that follows an oversized image
167
+ * block in the same content array) stays syntactically valid. This is a
168
+ * generic JSON-string eliser, not an image-specific one: it does not care
169
+ * WHAT is inside the oversized string, only that it is too large to keep.
170
+ *
171
+ * `deadlineMs` is an absolute `Date.now()` value. `truncatedByBudget: true`
172
+ * means the scan did not finish (ran out of time) — critically, the output
173
+ * up to that point NEVER includes the unflushed tail of a string that had
174
+ * already crossed the elision threshold (the flush happens once, the moment
175
+ * the threshold is crossed, not at the closing quote), so a cutoff mid-scan
176
+ * can never smuggle megabytes of un-elided content into the result.
177
+ */
178
+ function elideOversizedJsonStrings(input, deadlineMs) {
179
+ const outParts = [];
180
+ let spanStart = 0;
181
+ let inString = false;
182
+ let escaped = false;
183
+ let stringStart = 0;
184
+ let skipping = false; // current string already crossed the keep threshold
185
+ const n = input.length;
186
+ for (let i = 0; i < n; i++) {
187
+ if ((i & HEAD_ELISION_TIME_CHECK_MASK) === 0 && Date.now() > deadlineMs) {
188
+ if (!skipping)
189
+ outParts.push(input.slice(spanStart, i));
190
+ return { text: outParts.join(''), truncatedByBudget: true };
191
+ }
192
+ const ch = input.charCodeAt(i);
193
+ if (!inString) {
194
+ if (ch === 0x22 /* '"' */) {
195
+ inString = true;
196
+ stringStart = i + 1;
197
+ skipping = false;
198
+ }
199
+ continue;
200
+ }
201
+ if (escaped) {
202
+ escaped = false;
203
+ continue;
204
+ }
205
+ if (ch === 0x5c /* '\\' */) {
206
+ escaped = true;
207
+ continue;
208
+ }
209
+ if (ch === 0x22 /* '"' */) {
210
+ inString = false;
211
+ if (skipping) {
212
+ const elidedUnits = i - stringStart - HEAD_ELISION_STRING_KEEP_UNITS;
213
+ outParts.push(`…[elided ${elidedUnits} chars]`);
214
+ spanStart = i; // resume normal copying AT the closing quote
215
+ skipping = false; // the string is closed; the final flush must still emit the record's own tail ("}\n) even when this was the last string literal
216
+ }
217
+ continue;
218
+ }
219
+ if (!skipping && (i - stringStart) === HEAD_ELISION_STRING_KEEP_UNITS) {
220
+ outParts.push(input.slice(spanStart, i)); // flush the kept prefix now
221
+ skipping = true;
222
+ }
223
+ }
224
+ if (!skipping)
225
+ outParts.push(input.slice(spanStart, n));
226
+ return { text: outParts.join(''), truncatedByBudget: false };
227
+ }
228
+ /**
229
+ * Recover the transcript's opening JSONL record even when it doesn't fit the
230
+ * plain {@link DEFAULT_HEAD_MAX_BYTES} chunk — the case a first turn carrying
231
+ * a multi-megabyte inline image produces, where the record's own closing
232
+ * newline sits past whatever small chunk was read, so the old bare head
233
+ * reader dropped the WHOLE record (and with it, the real original request)
234
+ * rather than the oversized value inside it.
235
+ *
236
+ * Only invoked when the cheap path already failed to find a complete line
237
+ * (bounded — see {@link readSessionHeadChunk}'s `sawEof`), so an ordinary
238
+ * transcript never pays this cost. Reads up to {@link HEAD_ELISION_MAX_READ_BYTES}
239
+ * from byte 0, runs it through {@link elideOversizedJsonStrings} (bounded by
240
+ * {@link HEAD_ELISION_MAX_MS}), and returns the first complete elided line —
241
+ * text before and after the oversized value is preserved verbatim (only the
242
+ * oversized value itself is shortened), UTF-8 decoding and escape handling
243
+ * both go through the exact same path an unelided record would. Returns ''
244
+ * (a partial result, not a guess) when the scan is cut off by either budget
245
+ * before a complete line was ever produced — never a followup/tail turn
246
+ * substituted for it.
247
+ */
248
+ export function readSessionHeadContentBounded(filePath, maxReadBytes = HEAD_ELISION_MAX_READ_BYTES) {
249
+ const chunk = readSessionHeadChunk(filePath, maxReadBytes);
250
+ if (!chunk || !chunk.content)
251
+ return '';
252
+ const { text, truncatedByBudget } = elideOversizedJsonStrings(chunk.content, Date.now() + HEAD_ELISION_MAX_MS);
253
+ const firstNl = text.indexOf('\n');
254
+ if (firstNl >= 0)
255
+ return text.slice(0, firstNl);
256
+ // No line terminator found anywhere in the elided text: either the record
257
+ // (minus its oversized values) is STILL bigger than the read budget, or the
258
+ // elision scan itself hit its time budget first, or we reached real EOF
259
+ // with no trailing newline at all (a truncated/interrupted write).
260
+ if (truncatedByBudget)
261
+ return '';
262
+ return chunk.sawEof ? text : '';
263
+ }
264
+ /**
265
+ * Read the FIRST `maxEvents` normalized events from a JSONL transcript's
266
+ * head — the session's actual opening turns, bounded and cheap (one small
267
+ * read from byte 0, the same shared Claude/Codex content parsers `readSessionTail`
268
+ * uses, zero duplicated parse logic). Only Claude and Codex are supported,
269
+ * matching {@link readSessionTailWithRaw}; other agents return no events.
270
+ *
271
+ * When the plain bounded chunk holds no complete first line at all — an
272
+ * opening user turn embedding a multi-megabyte inline image is the real case
273
+ * this covers — falls back to {@link readSessionHeadContentBounded}'s
274
+ * JSON-aware elision scan rather than returning nothing: the same content
275
+ * parsers then see a syntactically valid record with the oversized value
276
+ * shortened, so a text block before or after an elided image block is
277
+ * recovered exactly as if the image had never been oversized (the parser's
278
+ * own image-handling path, e.g. Claude's `normalizedAttachmentEvent`, still
279
+ * runs — it just sees a short placeholder `source.data` instead of the real
280
+ * payload, so an attachment's derived byte size is not meaningful when this
281
+ * fallback fired, only the surrounding TEXT is trustworthy).
282
+ */
283
+ export function readSessionHead(filePath, agent, maxBytes = DEFAULT_HEAD_MAX_BYTES, maxEvents = DEFAULT_MAX_EVENTS) {
284
+ if (agent !== 'claude' && agent !== 'codex')
285
+ return [];
286
+ let content = readSessionHeadContent(filePath, maxBytes);
287
+ if (!content.trim())
288
+ content = readSessionHeadContentBounded(filePath);
289
+ if (!content.trim())
290
+ return [];
291
+ const events = agent === 'codex' ? parseCodexContent(content) : parseClaudeContent(content);
292
+ sanitizeEvents(events);
293
+ return events.length > maxEvents ? events.slice(0, maxEvents) : events;
294
+ }
@@ -1,8 +1,9 @@
1
1
  import { installCli, resolveCliManifest } from './cli-resources.js';
2
2
  import { getCachedToolSetup, refreshToolSetup } from './setup-tool-status.js';
3
3
  const PACKAGES = {
4
- browser: '@phnx-labs/browser-cli@0.1.3',
4
+ browser: '@phnx-labs/browser-cli@0.1.5',
5
5
  computer: '@phnx-labs/computer-cli@0.1.5',
6
+ term: '@phnx-labs/term-cli@0.1.0',
6
7
  };
7
8
  /** Use the host installer; setup must not install or start a tool from a read. */
8
9
  export async function installSetupTool(tool) {
@@ -1,4 +1,4 @@
1
- export declare const SETUP_TOOLS: readonly ["browser", "computer", "secrets"];
1
+ export declare const SETUP_TOOLS: readonly ["browser", "computer", "secrets", "term"];
2
2
  export type SetupTool = typeof SETUP_TOOLS[number];
3
3
  export interface ToolSetupRow {
4
4
  tool: SetupTool;
@@ -6,7 +6,7 @@ import { atomicWriteJsonSync, withFileLockAsync } from './fs-atomic.js';
6
6
  import { probeCapture } from './probe.js';
7
7
  import { invocation, isStandaloneComputer } from './computer-client.js';
8
8
  import { getSocketPath as browserSocketPath } from './browser/ipc.js';
9
- export const SETUP_TOOLS = ['browser', 'computer', 'secrets'];
9
+ export const SETUP_TOOLS = ['browser', 'computer', 'secrets', 'term'];
10
10
  export function toolSetupCacheDir(options = {}) {
11
11
  return path.join(options.cacheDir ?? getCacheDir(), 'setup-tools');
12
12
  }
@@ -139,6 +139,11 @@ async function checkTool(row) {
139
139
  // unlock the broker merely to paint a settings row.
140
140
  if (row.tool === 'secrets')
141
141
  return { ...row, checkedAtMs: Date.now(), detail: 'Installed. Secret access is checked when used; this check does not unlock secrets.' };
142
+ // term is a headless PTY engine with no `status --json` health surface; it is
143
+ // spawned on demand by the OAuth device-code driver (fleet login / auth mint).
144
+ // Presence on PATH is the whole readiness signal — do not probe it.
145
+ if (row.tool === 'term')
146
+ return { ...row, readiness: 'ready', detail: 'Installed. Spawned on demand by fleet login and auth mint.', checkedAtMs: Date.now() };
142
147
  try {
143
148
  const { command, prefix } = invocation(row.executable);
144
149
  const { stdout } = await probeCapture(command, [...prefix, 'status', '--json'], 8000, { acceptedExitCodes: [0, 1], maxOutputBytes: 256 * 1024 });
@@ -6,15 +6,30 @@ export type AccountProvisioning = 'portable' | 'per-device';
6
6
  /**
7
7
  * The exact command that logs a given agent in — for warn banners and nudges.
8
8
  * Driven off the registry `cliCommand` with the per-agent subcommand overrides
9
- * (verified against the real CLIs): codex/grok use `<cli> login`, opencode uses
10
- * `<cli> auth login`, claude logs in from inside its TUI via `/login`, and the
11
- * remaining agents (kimi, gemini, …) start their device/oauth flow on launch.
9
+ * (verified against the real CLIs): codex/grok/opencode run the finite login
10
+ * subcommand `HARNESS_AUTH` wires (`loginSubcommand`), claude logs in from
11
+ * inside its TUI via `/login`, and the remaining agents (kimi, gemini, …) start
12
+ * their device/oauth flow on launch.
12
13
  */
13
14
  export declare function loginHint(agentId: AgentId): string;
15
+ /**
16
+ * Harnesses that log back in through a finite native subcommand runnable via
17
+ * `agents run <agent>@<version> -- <args>`. Claude logs in from inside its TUI
18
+ * (`/login`); cursor and the rest start their device/oauth flow on launch.
19
+ */
20
+ export declare const SUBCOMMAND_LOGIN_AGENTS: readonly AgentId[];
21
+ /**
22
+ * The finite native login subcommand (`login`, `login --device-auth`,
23
+ * `auth login`) for a {@link SUBCOMMAND_LOGIN_AGENTS} harness, read from the one
24
+ * `HARNESS_AUTH` row so every surface that spells it — the hint, the per-version
25
+ * fix, `agents doctor` — agrees. Null for every other harness.
26
+ */
27
+ export declare function loginSubcommand(agent: AgentId): string | null;
14
28
  /**
15
29
  * Exact action shown beside a non-live account. Every emitted command exists
16
30
  * today — never a planned surface and never a hidden verb:
17
- * - Per-device harnesses repair per box via `agents devices login`.
31
+ * - Per-device harnesses (kimi/antigravity) repair on the box itself: run the
32
+ * harness there (`loginHint`) and complete its native login.
18
33
  * - Named accounts re-auth through `agents accounts login <harness>#<name>`.
19
34
  * A known account with no slot on a headed device is onboarded with
20
35
  * `agents accounts add <harness> <name>`. A worker never runs an
@@ -11,13 +11,15 @@
11
11
  import chalk from 'chalk';
12
12
  import { addWorkerRefusal } from './accounts/add.js';
13
13
  import { AGENTS } from './agents.js';
14
+ import { HARNESS_AUTH } from './harness-auth-capabilities.js';
14
15
  import { CONFIG_ENV_ISOLATED_AGENTS } from './installations/shims.js';
15
16
  /**
16
17
  * The exact command that logs a given agent in — for warn banners and nudges.
17
18
  * Driven off the registry `cliCommand` with the per-agent subcommand overrides
18
- * (verified against the real CLIs): codex/grok use `<cli> login`, opencode uses
19
- * `<cli> auth login`, claude logs in from inside its TUI via `/login`, and the
20
- * remaining agents (kimi, gemini, …) start their device/oauth flow on launch.
19
+ * (verified against the real CLIs): codex/grok/opencode run the finite login
20
+ * subcommand `HARNESS_AUTH` wires (`loginSubcommand`), claude logs in from
21
+ * inside its TUI via `/login`, and the remaining agents (kimi, gemini, …) start
22
+ * their device/oauth flow on launch.
21
23
  */
22
24
  export function loginHint(agentId) {
23
25
  const cli = AGENTS[agentId]?.cliCommand ?? agentId;
@@ -26,9 +28,8 @@ export function loginHint(agentId) {
26
28
  return `${cli}, then /login`;
27
29
  case 'codex':
28
30
  case 'grok':
29
- return `${cli} login`;
30
31
  case 'opencode':
31
- return `${cli} auth login`;
32
+ return `${cli} ${loginSubcommand(agentId)}`;
32
33
  // Warp Agent CLI has no `login` subcommand: running `warp` opens a browser
33
34
  // sign-in on launch (or set WARP_API_KEY / pass --api-key), so the default
34
35
  // bare-`warp` hint is correct.
@@ -36,10 +37,28 @@ export function loginHint(agentId) {
36
37
  return cli;
37
38
  }
38
39
  }
40
+ /**
41
+ * Harnesses that log back in through a finite native subcommand runnable via
42
+ * `agents run <agent>@<version> -- <args>`. Claude logs in from inside its TUI
43
+ * (`/login`); cursor and the rest start their device/oauth flow on launch.
44
+ */
45
+ export const SUBCOMMAND_LOGIN_AGENTS = ['codex', 'grok', 'opencode'];
46
+ /**
47
+ * The finite native login subcommand (`login`, `login --device-auth`,
48
+ * `auth login`) for a {@link SUBCOMMAND_LOGIN_AGENTS} harness, read from the one
49
+ * `HARNESS_AUTH` row so every surface that spells it — the hint, the per-version
50
+ * fix, `agents doctor` — agrees. Null for every other harness.
51
+ */
52
+ export function loginSubcommand(agent) {
53
+ if (!SUBCOMMAND_LOGIN_AGENTS.includes(agent))
54
+ return null;
55
+ return HARNESS_AUTH[agent].login.join(' ');
56
+ }
39
57
  /**
40
58
  * Exact action shown beside a non-live account. Every emitted command exists
41
59
  * today — never a planned surface and never a hidden verb:
42
- * - Per-device harnesses repair per box via `agents devices login`.
60
+ * - Per-device harnesses (kimi/antigravity) repair on the box itself: run the
61
+ * harness there (`loginHint`) and complete its native login.
43
62
  * - Named accounts re-auth through `agents accounts login <harness>#<name>`.
44
63
  * A known account with no slot on a headed device is onboarded with
45
64
  * `agents accounts add <harness> <name>`. A worker never runs an
@@ -55,7 +74,7 @@ export function fixFor(input) {
55
74
  if (verdict === 'live' || verdict === 'rate_limited' || verdict === 'unverified' || verdict === 'ready')
56
75
  return null;
57
76
  if (input.provisioning === 'per-device' || verdict === 'per-device') {
58
- return `agents devices login --agents ${agent}`;
77
+ return loginHint(agent);
59
78
  }
60
79
  if (input.name) {
61
80
  const worker = addWorkerRefusal(agent, input.name);
@@ -70,10 +89,9 @@ export function fixFor(input) {
70
89
  return loginHint(agent);
71
90
  if (agent === 'claude')
72
91
  return `agents run ${agent}@${version}, then /login`;
73
- if (agent === 'codex' || agent === 'grok')
74
- return `agents run ${agent}@${version} -- login`;
75
- if (agent === 'opencode')
76
- return `agents run ${agent}@${version} -- auth login`;
92
+ const sub = loginSubcommand(agent);
93
+ if (sub)
94
+ return `agents run ${agent}@${version} -- ${sub}`;
77
95
  return `agents run ${agent}@${version}`;
78
96
  }
79
97
  /**
@@ -0,0 +1,24 @@
1
+ /** The subset of the `term` CLI a screen-scraping drive loop needs — faked in tests. */
2
+ export interface TermDriver {
3
+ start(opts?: {
4
+ rows?: number;
5
+ cols?: number;
6
+ }): Promise<string>;
7
+ exec(id: string, command: string): Promise<void>;
8
+ write(id: string, input: string): Promise<void>;
9
+ screen(id: string): Promise<{
10
+ screen: string;
11
+ exited: boolean;
12
+ }>;
13
+ stop(id: string): Promise<void>;
14
+ }
15
+ /** Real driver over the standalone `term` CLI (`./term-client.js`). */
16
+ export declare function defaultTermDriver(): TermDriver;
17
+ export interface DriveOptions {
18
+ /** Wait after launching before steering / scraping (default 4000ms). */
19
+ initialDelayMs?: number;
20
+ /** Poll cadence for the scrape loop (default 1000ms). */
21
+ pollMs?: number;
22
+ /** Overall scrape deadline (default 90000ms). */
23
+ timeoutMs?: number;
24
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Injectable terminal driver over the standalone `term` CLI.
3
+ *
4
+ * A small seam that lets a flow drive a PTY session — start, exec, write,
5
+ * screen-scrape, stop — through the real `term` CLI in production and a fake in
6
+ * tests. The setup-token mint (`lib/auth-mint.ts`) is the sole consumer today.
7
+ */
8
+ import { termStart, termExec, termWrite, termScreen, termStop } from './term-client.js';
9
+ /** Real driver over the standalone `term` CLI (`./term-client.js`). */
10
+ export function defaultTermDriver() {
11
+ const expectOk = (res, what) => {
12
+ if (!res.ok)
13
+ throw new Error(`term ${what} failed: ${res.error ?? 'unknown'}`);
14
+ };
15
+ return {
16
+ async start(opts) {
17
+ const res = await termStart({ rows: opts?.rows ?? 40, cols: opts?.cols ?? 120 });
18
+ expectOk(res, 'start');
19
+ return res.id;
20
+ },
21
+ async exec(id, command) {
22
+ expectOk(await termExec(id, command), 'exec');
23
+ },
24
+ async write(id, input) {
25
+ expectOk(await termWrite(id, input), 'write');
26
+ },
27
+ async screen(id) {
28
+ const res = await termScreen(id);
29
+ expectOk(res, 'screen');
30
+ return { screen: res.screen ?? '', exited: Boolean(res.exited) };
31
+ },
32
+ async stop(id) {
33
+ await termStop(id).catch(() => undefined);
34
+ },
35
+ };
36
+ }
@@ -14,7 +14,7 @@ export { makeVscodiumAgentBackend, spawnUri, EDITOR_VARIANTS, type EditorVariant
14
14
  export { planLayouts, type Packing } from './policy.js';
15
15
  export { specForRequest, buildRequests, openSurface, openSurfaces, type OpenOptions, type OpenManyOptions, type BuildRequestsOptions, type SurfaceItem, } from './engine.js';
16
16
  export { runLocal, runRemote, runSpec, remoteCommand, type HostResolver, type RunResult } from './transport.js';
17
- export { injectIntoTerminal, tmuxSendKeysArgv, tmuxInjectSpecs, itermInjectScript, ghosttyInjectScript, appleScriptInjectSpec, vscodiumInjectUri, vscodiumInjectSpec, type InjectTarget, type InjectBackend, type InjectOptions, type InjectResult, } from './inject.js';
17
+ export { backendCarriesPaste, injectIntoTerminal, tmuxSendKeysArgv, tmuxInjectSpecs, itermInjectScript, ghosttyInjectScript, appleScriptInjectSpec, vscodiumInjectUri, vscodiumInjectSpec, type InjectTarget, type InjectBackend, type InjectOptions, type InjectResult, } from './inject.js';
18
18
  export { resolveInjectTarget, resolveInjectTargetForSession, type InjectResolution, type InjectRail, type ResolveOptions, } from './resolve.js';
19
19
  export { iLoginShell } from './shell.js';
20
20
  export { shellQuote } from './quote.js';
@@ -5,7 +5,7 @@ export { makeVscodiumAgentBackend, spawnUri, EDITOR_VARIANTS } from './backends/
5
5
  export { planLayouts } from './policy.js';
6
6
  export { specForRequest, buildRequests, openSurface, openSurfaces, } from './engine.js';
7
7
  export { runLocal, runRemote, runSpec, remoteCommand } from './transport.js';
8
- export { injectIntoTerminal, tmuxSendKeysArgv, tmuxInjectSpecs, itermInjectScript, ghosttyInjectScript, appleScriptInjectSpec, vscodiumInjectUri, vscodiumInjectSpec, } from './inject.js';
8
+ export { backendCarriesPaste, injectIntoTerminal, tmuxSendKeysArgv, tmuxInjectSpecs, itermInjectScript, ghosttyInjectScript, appleScriptInjectSpec, vscodiumInjectUri, vscodiumInjectSpec, } from './inject.js';
9
9
  export { resolveInjectTarget, resolveInjectTargetForSession, } from './resolve.js';
10
10
  export { iLoginShell } from './shell.js';
11
11
  export { shellQuote } from './quote.js';
@@ -88,6 +88,21 @@ export interface InjectOptions {
88
88
  ctx?: EngineContext;
89
89
  /** Don't execute — return the spec(s) that WOULD run. Lets the macOS paths be asserted on Linux. */
90
90
  dryRun?: boolean;
91
+ /**
92
+ * Hard bound for the WHOLE injection, in ms. Each spec is run under the
93
+ * remaining budget with process-group cancellation, and a spec is never
94
+ * STARTED once the budget is gone — an operator-facing caller must be able to
95
+ * return a verdict on time without leaving a launcher running behind it
96
+ * (PHNX-3999). Omitted keeps the historical unbounded behaviour.
97
+ */
98
+ deadlineMs?: number;
99
+ /**
100
+ * Frame the text in bracketed-paste markers so a TUI inserts it verbatim
101
+ * instead of reading each embedded newline as a submit. Required for a
102
+ * multiline free-text answer. Only the tmux rail can carry it (see
103
+ * {@link backendCarriesPaste}); every other backend refuses with an error.
104
+ */
105
+ paste?: boolean;
91
106
  }
92
107
  export interface InjectResult {
93
108
  ok: boolean;
@@ -106,10 +121,33 @@ export interface InjectResult {
106
121
  confirmed: boolean;
107
122
  /** Discrete writes delivered: 2 for the Ink-safe text+Enter split, 1 when combined or enter=false. */
108
123
  writes: number;
124
+ /**
125
+ * How many specs were STARTED. This -- not `writes` -- is what says whether
126
+ * anything may have reached the terminal: a spec that failed or timed out may
127
+ * still have written bytes before dying, so only `started === 0` proves
128
+ * nothing landed and the answer is cleanly retryable (PHNX-3999).
129
+ */
130
+ started: number;
109
131
  /** For the tmux / AppleScript backends (or any dryRun), the spec(s) that ran / would run. */
110
132
  specs?: LaunchSpec[];
111
133
  error?: string;
112
134
  }
135
+ /**
136
+ * Bracketed-paste framing (DEC mode 2004) — the pty contract for "insert this
137
+ * verbatim, do not submit". A readline/Ink composer that sees the start marker
138
+ * buffers every byte up to the end marker, so an embedded newline lands as a
139
+ * literal newline in the draft instead of submitting the partial line.
140
+ */
141
+ export declare const BRACKETED_PASTE_START = "\u001B[200~";
142
+ export declare const BRACKETED_PASTE_END = "\u001B[201~";
143
+ /**
144
+ * Only tmux can carry the framing: `send-keys -l` writes the marker bytes
145
+ * straight to the pty. ghostty simulates keystrokes, vscodium hands a JSON
146
+ * payload to the extension, and iTerm's AppleScript `write text` cannot encode
147
+ * a raw ESC byte — so a paste request on those is refused rather than
148
+ * half-delivered as one submit per line.
149
+ */
150
+ export declare function backendCarriesPaste(backend: InjectBackend): boolean;
113
151
  /**
114
152
  * argv for `tmux send-keys` targeting a pane by id. Starts with `tmux` (like the
115
153
  * engine's `tmuxTabArgv`) so the same transport runs it locally or over SSH. The
@@ -53,6 +53,24 @@ function backendConfirmsDelivery(backend) {
53
53
  }
54
54
  /** Carriage return — what Enter delivers into a raw PTY/tmux byte stream. */
55
55
  const CR = '\r';
56
+ /**
57
+ * Bracketed-paste framing (DEC mode 2004) — the pty contract for "insert this
58
+ * verbatim, do not submit". A readline/Ink composer that sees the start marker
59
+ * buffers every byte up to the end marker, so an embedded newline lands as a
60
+ * literal newline in the draft instead of submitting the partial line.
61
+ */
62
+ export const BRACKETED_PASTE_START = '\u001b[200~';
63
+ export const BRACKETED_PASTE_END = '\u001b[201~';
64
+ /**
65
+ * Only tmux can carry the framing: `send-keys -l` writes the marker bytes
66
+ * straight to the pty. ghostty simulates keystrokes, vscodium hands a JSON
67
+ * payload to the extension, and iTerm's AppleScript `write text` cannot encode
68
+ * a raw ESC byte — so a paste request on those is refused rather than
69
+ * half-delivered as one submit per line.
70
+ */
71
+ export function backendCarriesPaste(backend) {
72
+ return backend === 'tmux';
73
+ }
56
74
  // --- tmux -------------------------------------------------------------------
57
75
  /**
58
76
  * argv for `tmux send-keys` targeting a pane by id. Starts with `tmux` (like the
@@ -191,12 +209,21 @@ export function vscodiumInjectSpec(target, text, opts) {
191
209
  export async function injectIntoTerminal(target, text, opts = {}) {
192
210
  const enter = opts.enter !== false;
193
211
  const combined = opts.combined === true;
212
+ // Fail loud at the rail boundary: a backend that cannot carry the markers must
213
+ // not silently deliver the raw text, which submits once per embedded newline.
214
+ if (opts.paste === true && !backendCarriesPaste(target.backend)) {
215
+ return {
216
+ ok: false, confirmed: false, backend: target.backend, writes: 0, started: 0, specs: [],
217
+ error: `The ${target.backend} rail cannot deliver a bracketed paste — open the session and paste it there.`,
218
+ };
219
+ }
220
+ const payload = opts.paste === true ? `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}` : text;
194
221
  // tmux + AppleScript + editor-CLI backends all run through the engine transport.
195
222
  const specs = target.backend === 'tmux'
196
- ? tmuxInjectSpecs(target, text, { enter, combined, socket: opts.socket })
223
+ ? tmuxInjectSpecs(target, payload, { enter, combined, socket: opts.socket })
197
224
  : target.backend === 'vscodium'
198
- ? [vscodiumInjectSpec(target, text, { enter, combined })]
199
- : [appleScriptInjectSpec(target, text, enter, combined)];
225
+ ? [vscodiumInjectSpec(target, payload, { enter, combined })]
226
+ : [appleScriptInjectSpec(target, payload, enter, combined)];
200
227
  // Discrete writes delivered on the far side. tmux counts its send-keys calls.
201
228
  // iterm/vscodium honor `combined` (text+Enter fused into one write). ghostty's
202
229
  // coarse keystroke path ignores `combined` (it always emits keystroke + a
@@ -208,7 +235,7 @@ export async function injectIntoTerminal(target, text, opts = {}) {
208
235
  : enter && !combined ? 2 : 1;
209
236
  const confirmed = backendConfirmsDelivery(target.backend);
210
237
  if (opts.dryRun)
211
- return { ok: true, confirmed, backend: target.backend, writes, specs };
238
+ return { ok: true, confirmed, backend: target.backend, writes, started: 0, specs };
212
239
  // AppleScript backends: guard on the backend's own availability, but only for a
213
240
  // LOCAL run (a remote host is assumed to have the app — its ssh leg reports failure).
214
241
  if (target.backend === 'iterm' || target.backend === 'ghostty') {
@@ -216,14 +243,33 @@ export async function injectIntoTerminal(target, text, opts = {}) {
216
243
  const backend = target.backend === 'iterm' ? itermBackend : ghosttyBackend;
217
244
  const ctx = opts.ctx ?? currentContext();
218
245
  if (!backend.isAvailable(ctx)) {
219
- return { ok: false, confirmed: false, backend: target.backend, writes: 0, specs, error: `${backend.label} is not available here (platform ${ctx.platform})` };
246
+ return { ok: false, confirmed: false, backend: target.backend, writes: 0, started: 0, specs, error: `${backend.label} is not available here (platform ${ctx.platform})` };
220
247
  }
221
248
  }
222
249
  }
250
+ const endMs = opts.deadlineMs === undefined ? undefined : Date.now() + opts.deadlineMs;
251
+ let sent = 0;
252
+ let started = 0;
223
253
  for (const spec of specs) {
224
- const res = await runSpec(spec, opts.host, opts.resolveHost);
225
- if (!res.ok)
226
- return { ok: false, confirmed: false, backend: target.backend, writes: 0, specs, error: res.error };
254
+ // Never START a spec the budget can no longer cover. Racing the caller's
255
+ // promise instead let a write begin AFTER the operator had already been told
256
+ // the answer timed out.
257
+ const remaining = endMs === undefined ? undefined : endMs - Date.now();
258
+ if (remaining !== undefined && remaining <= 0) {
259
+ return {
260
+ ok: false, confirmed: false, backend: target.backend, writes: sent, started, specs,
261
+ error: `injection ran out of budget after ${sent} of ${specs.length} write(s)`,
262
+ };
263
+ }
264
+ started += 1;
265
+ const res = await runSpec(spec, opts.host, opts.resolveHost, remaining);
266
+ if (!res.ok) {
267
+ // `writes` counts specs that COMPLETED; `started` counts specs that began.
268
+ // The failing spec may have written bytes before dying, so the caller must
269
+ // branch on `started`, not on `writes`.
270
+ return { ok: false, confirmed: false, backend: target.backend, writes: sent, started, specs, error: res.error };
271
+ }
272
+ sent += 1;
227
273
  }
228
- return { ok: true, confirmed, backend: target.backend, writes, specs };
274
+ return { ok: true, confirmed, backend: target.backend, writes, started, specs };
229
275
  }
@@ -5,11 +5,21 @@ export interface RunResult {
5
5
  ok: boolean;
6
6
  error?: string;
7
7
  }
8
- /** Run the spec on this machine: spawn the launcher, resolve when it exits. */
9
- export declare function runLocal(spec: LaunchSpec): Promise<RunResult>;
8
+ /** Grace between SIGTERM and SIGKILL when a deadline cancels a launcher. */
9
+ export declare const SPEC_KILL_GRACE_MS = 250;
10
+ /**
11
+ * Run the spec on this machine: spawn the launcher, resolve when it exits.
12
+ *
13
+ * `timeoutMs` makes the call genuinely bounded rather than merely raced: the
14
+ * child is spawned in its OWN process group (`detached`) and the whole group is
15
+ * signalled on expiry, so a launcher that itself spawned something (osascript →
16
+ * the app, tmux → the server) cannot keep running after the caller has given up.
17
+ * Racing the promise alone leaves the process behind (PHNX-3999).
18
+ */
19
+ export declare function runLocal(spec: LaunchSpec, timeoutMs?: number): Promise<RunResult>;
10
20
  /** Serialize a launch argv into a single POSIX-quoted shell command string. */
11
21
  export declare function remoteCommand(spec: LaunchSpec): string;
12
- /** Run the spec on a remote host over SSH. */
13
- export declare function runRemote(spec: LaunchSpec, target: string): RunResult;
22
+ /** Run the spec on a remote host over SSH. `timeoutMs` bounds the ssh client. */
23
+ export declare function runRemote(spec: LaunchSpec, target: string, timeoutMs?: number): RunResult;
14
24
  /** Run a spec locally (no host / 'local') or on a resolved remote host. */
15
- export declare function runSpec(spec: LaunchSpec, host?: string, resolveHost?: HostResolver): Promise<RunResult>;
25
+ export declare function runSpec(spec: LaunchSpec, host?: string, resolveHost?: HostResolver, timeoutMs?: number): Promise<RunResult>;