@phnx-labs/agents-cli 1.22.63 → 1.22.65

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 (40) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/commands/accounts.d.ts +8 -7
  3. package/dist/commands/accounts.js +40 -13
  4. package/dist/commands/exec.js +40 -0
  5. package/dist/commands/feed.js +2 -0
  6. package/dist/commands/resume.d.ts +8 -0
  7. package/dist/commands/resume.js +66 -4
  8. package/dist/lib/accounting/account-pool-collect.js +7 -2
  9. package/dist/lib/accounting/account-pool.d.ts +12 -0
  10. package/dist/lib/accounting/account-pool.js +1 -0
  11. package/dist/lib/accounting/rotate.d.ts +57 -8
  12. package/dist/lib/accounting/rotate.js +57 -10
  13. package/dist/lib/accounting/usage.d.ts +12 -0
  14. package/dist/lib/accounting/usage.js +119 -15
  15. package/dist/lib/agent-spec/agents.js +21 -6
  16. package/dist/lib/claude-account-token.d.ts +26 -0
  17. package/dist/lib/claude-account-token.js +65 -11
  18. package/dist/lib/daemon-ticks.js +6 -2
  19. package/dist/lib/event-families.js +1 -1
  20. package/dist/lib/exec.d.ts +66 -1
  21. package/dist/lib/exec.js +104 -8
  22. package/dist/lib/feed/events.d.ts +1 -1
  23. package/dist/lib/feed/events.js +5 -0
  24. package/dist/lib/feed-broadcast.d.ts +2 -0
  25. package/dist/lib/feed-broadcast.js +31 -4
  26. package/dist/lib/harness/adapters/claude.js +33 -27
  27. package/dist/lib/hosts/passthrough.d.ts +44 -0
  28. package/dist/lib/hosts/passthrough.js +66 -8
  29. package/dist/lib/identity/client.d.ts +5 -4
  30. package/dist/lib/identity/client.js +6 -5
  31. package/dist/lib/session/active.d.ts +10 -1
  32. package/dist/lib/session/active.js +8 -0
  33. package/dist/lib/session/digest.js +8 -2
  34. package/dist/lib/session/parse.d.ts +4 -0
  35. package/dist/lib/session/parse.js +16 -11
  36. package/dist/lib/session/state.d.ts +5 -0
  37. package/dist/lib/session/state.js +6 -0
  38. package/dist/lib/usage-refresh.d.ts +100 -1
  39. package/dist/lib/usage-refresh.js +195 -4
  40. package/package.json +1 -1
package/dist/lib/exec.js CHANGED
@@ -14,7 +14,7 @@ import { parseTimeout } from './scheduling/routines.js';
14
14
  import { compareVersions, getBinaryPath, getVersionHomePath, isVersionInstalled, listInstalledVersions, resolveVersion } from './installations/versions.js';
15
15
  import { resolveModel, buildReasoningFlags } from './models.js';
16
16
  import { isTierToken, resolveTier } from './model-tiers.js';
17
- import { createTimer, redactPrompt, redactArgs } from './feed/events.js';
17
+ import { emit, createTimer, redactPrompt, redactArgs } from './feed/events.js';
18
18
  import { sanitizeProcessEnv } from './secrets/bundles.js';
19
19
  import { resolveActor, actorEnv } from './actor.js';
20
20
  import { expandLocalHome } from './project-root.js';
@@ -1151,6 +1151,12 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
1151
1151
  // buildExecEnv — `installations/shims.ts` generateShimScript).
1152
1152
  const env = buildExecEnv({ agent, version, cwd, mode: defaultModeFor(agent), effort: 'auto', env: { AGENT_LAUNCH_ID: launchId } });
1153
1153
  const { command, args, shell } = resolveShimSpawn(process.platform, binary, [...launchArgs, ...rawArgs]);
1154
+ // Pre-launch marker for the SECOND live launch path: the Windows generated
1155
+ // `.cmd` shim delegates here and spawns the harness directly, so without this
1156
+ // a pinned, logged-out version launched via the shim would be invisible —
1157
+ // exactly the blind spot this event closes. Best-effort; re-derives the
1158
+ // signed-in verdict from the pinned version home (no rotated pick here).
1159
+ await emitRunLaunch({ agent, version, resolvedVia: 'shim' });
1154
1160
  return new Promise((resolve) => {
1155
1161
  const child = spawn(command, args, { cwd, stdio: 'inherit', env, shell });
1156
1162
  // Record the launch so `ag sessions --active` can attribute the agent
@@ -1633,15 +1639,88 @@ function emitResolvedSessionId(options, launchId, childPid) {
1633
1639
  process.stdout.write(sessionIdMarkerLine(sessionId));
1634
1640
  }
1635
1641
  /**
1636
- * Spawn an agent process and return its exit code plus a tee'd copy of stderr.
1642
+ * Build the `run.launch` event payload. Pure and exported so the signedIn ->
1643
+ * launchedLoggedOut mapping is unit-testable without spawning. Mirrors the
1644
+ * buildRotationDecisionEvent / emitRotationDecision split in rotate.ts.
1637
1645
  *
1638
- * Stderr is always piped so the caller can inspect it (e.g., for rate-limit
1639
- * detection) while also forwarding every chunk to process.stderr in real time --
1640
- * the user sees the same output they would with stdio: 'inherit'. Stdout keeps
1641
- * the original behavior: 'pipe' when downstream output is piped (so `agents
1642
- * run ... | ...` composes cleanly), otherwise 'inherit' so TTY output is
1643
- * unbuffered.
1646
+ * `launchedLoggedOut` is the headline flag — true ONLY when `signedIn` was
1647
+ * resolved to false (a launch into a logged-out version, the yosemite-m3 2.1.219
1648
+ * incident). An UNKNOWN verdict (`signedIn === null`) is never treated as
1649
+ * logged out.
1644
1650
  */
1651
+ export function buildRunLaunchPayload(input) {
1652
+ return {
1653
+ module: 'run',
1654
+ agent: input.agent,
1655
+ ...(input.harnessName ? { harnessName: input.harnessName } : {}),
1656
+ // Omitted only when no version could be resolved at all — the typed `version`
1657
+ // field can't carry null, and an absent version is honest.
1658
+ version: input.version,
1659
+ strategy: input.strategy ?? null,
1660
+ signedIn: input.signedIn,
1661
+ launchedLoggedOut: input.signedIn === false,
1662
+ email: input.email,
1663
+ ...(input.resolvedVia ? { resolvedVia: input.resolvedVia } : {}),
1664
+ };
1665
+ }
1666
+ /**
1667
+ * Emit the pre-launch `run.launch` observability event just before the harness
1668
+ * child is spawned. Best-effort by construction — the whole point is that it can
1669
+ * NEVER break a launch, so every step (the signed-in probe, the emit itself) is
1670
+ * wrapped and a failure is swallowed. Mirrors the non-fatal pattern of
1671
+ * recordDispatchedRun and emitRotationDecision.
1672
+ *
1673
+ * The `signedIn` verdict is for the SPECIFIC launched version on THIS device, so
1674
+ * `launchedLoggedOut` catches a launch into a logged-out version that
1675
+ * `--device auto` readiness let through (the yosemite-m3 2.1.219 incident). It is
1676
+ * REUSED from `ctx.launchSignedIn` when the caller already computed it via the
1677
+ * identical gate (the rotated pick — no double fs read, no disagreement window),
1678
+ * and only otherwise re-derived via {@link isVersionLaunchableHere} (dynamically
1679
+ * imported so the spawn path takes on no static dependency on the accounting
1680
+ * layer).
1681
+ */
1682
+ async function emitRunLaunch(ctx) {
1683
+ try {
1684
+ let signedIn;
1685
+ let email;
1686
+ if (ctx.launchSignedIn !== undefined) {
1687
+ // Caller already computed the verdict for this exact version via the same
1688
+ // gate — reuse it verbatim.
1689
+ signedIn = ctx.launchSignedIn;
1690
+ email = ctx.launchEmail ?? null;
1691
+ }
1692
+ else {
1693
+ // Pinned-default / shim paths: probe the version home now. Without a
1694
+ // concrete version we cannot probe, so leave the verdict unknown (null)
1695
+ // rather than guess.
1696
+ signedIn = null;
1697
+ email = null;
1698
+ if (ctx.version) {
1699
+ try {
1700
+ const { isVersionLaunchableHere } = await import('./accounting/rotate.js');
1701
+ const state = await isVersionLaunchableHere(ctx.agent, ctx.version);
1702
+ signedIn = state.launchable;
1703
+ email = state.email;
1704
+ }
1705
+ catch {
1706
+ /* probe unavailable — record the launch without the signed-in verdict */
1707
+ }
1708
+ }
1709
+ }
1710
+ emit('run.launch', buildRunLaunchPayload({
1711
+ agent: ctx.agent,
1712
+ harnessName: ctx.harnessName,
1713
+ version: ctx.version,
1714
+ strategy: ctx.strategy,
1715
+ signedIn,
1716
+ email,
1717
+ resolvedVia: ctx.resolvedVia,
1718
+ }));
1719
+ }
1720
+ catch {
1721
+ /* observability must never break a launch */
1722
+ }
1723
+ }
1645
1724
  async function spawnAgent(options) {
1646
1725
  // Assign a known session id up front for agents that accept one, so the
1647
1726
  // launcher can record an EXACT pid -> session mapping (see pid-registry) —
@@ -1758,6 +1837,23 @@ async function spawnAgent(options) {
1758
1837
  timer.end({ exitCode: 1, status: 'failed', error: 'remote interactive run has no tmux for durability' });
1759
1838
  return { exitCode: 1, stdout: '', stderr: msg };
1760
1839
  }
1840
+ // Pre-launch marker (`run.launch`), fired on THIS device right before the
1841
+ // harness child is spawned — for BOTH the tmux-wrapped and bare paths below,
1842
+ // and only once the undurable refusal above has passed (a refused run never
1843
+ // spawns). Unlike `run.dispatched` (recordDispatchedRun, at FINALIZE), this
1844
+ // records a launch that then sits stuck at a login screen and never finalizes,
1845
+ // so a launch into a logged-out version is visible instead of silent. The
1846
+ // signed-in verdict is REUSED from the rotated pick when the command threaded
1847
+ // it (options.launchSignedIn), else re-derived from the version home.
1848
+ await emitRunLaunch({
1849
+ agent: options.agent,
1850
+ harnessName: options.harnessName,
1851
+ version: options.version ?? resolveVersion(options.agent, options.cwd || process.cwd()) ?? undefined,
1852
+ strategy: options.strategy,
1853
+ resolvedVia: options.resolvedVia,
1854
+ launchSignedIn: options.launchSignedIn,
1855
+ launchEmail: options.launchEmail,
1856
+ });
1761
1857
  if (tmuxWrap.kind === 'wrap') {
1762
1858
  timer.mark('startup');
1763
1859
  try {
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import type { ActorKind } from '../actor.js';
15
15
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
16
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'run.dispatched' | 'daemon.start' | 'daemon.stop' | 'daemon.error' | 'daemon.info' | 'routine.start' | 'routine.end' | 'watchdog.action' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'computer.action' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'rotation.unresolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.failed' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
16
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'run.dispatched' | 'run.launch' | 'daemon.start' | 'daemon.stop' | 'daemon.error' | 'daemon.info' | 'routine.start' | 'routine.end' | 'watchdog.action' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'computer.action' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'rotation.unresolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.failed' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
17
17
  /** Every known event kind. Derived from {@link EVENT_TYPE_TABLE}, never hand-listed. */
18
18
  export declare const EVENT_TYPES: readonly EventType[];
19
19
  /** Runtime guard for an event kind arriving from outside this process. */
@@ -121,6 +121,7 @@ const FILE_MODE = 0o600;
121
121
  const EVENT_TYPE_TABLE = {
122
122
  'agent.run.start': true, 'agent.run.end': true, 'agent.spawn.start': true, 'agent.spawn.end': true,
123
123
  'run.dispatched': true,
124
+ 'run.launch': true,
124
125
  'daemon.start': true, 'daemon.stop': true, 'daemon.error': true, 'daemon.info': true,
125
126
  'routine.start': true, 'routine.end': true,
126
127
  'watchdog.action': true,
@@ -158,6 +159,10 @@ export function isEventType(value) {
158
159
  const AUDIT_EVENTS = new Set([
159
160
  'command.start', 'command.end',
160
161
  'run.dispatched',
162
+ // Sibling of run.dispatched, and the MORE reliable signal for a stuck /
163
+ // logged-out launch (it fires pre-spawn, before an agent can hang at a login
164
+ // screen and never finalize). Must surface under --level audit --include runs.
165
+ 'run.launch',
161
166
  'secrets.get', 'secrets.unlocked', 'secrets.create', 'secrets.import', 'secrets.export', 'secrets.view', 'secrets.lease-denied', 'secrets.lease-expire',
162
167
  'secrets.set', 'secrets.delete', 'secrets.rename',
163
168
  'teams.create', 'teams.add', 'teams.start', 'teams.complete', 'teams.disband',
@@ -42,6 +42,8 @@ export interface FeedBroadcastContext {
42
42
  level: FeedPostLevel;
43
43
  /** Tracker id for the work, e.g. `RUSH-2081`. */
44
44
  ticket?: string;
45
+ /** Canonical clickable tracker URL for `ticket`, when the tracker can resolve it. */
46
+ ticketUrl?: string;
45
47
  /** Repo/project the post came from. */
46
48
  project?: string;
47
49
  agent?: string;
@@ -38,6 +38,8 @@ import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } fro
38
38
  import { lookupTransport } from './channels/resolve.js';
39
39
  import { registerBuiltinProviders } from './channels/providers/index.js';
40
40
  import { sendToOwner } from './notify.js';
41
+ import { linearIssueUrl } from './session/linear.js';
42
+ import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
41
43
  const LEVEL_RANK = { milestone: 0, important: 1 };
42
44
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
43
45
  export function parseFeedPostLevel(raw) {
@@ -77,6 +79,7 @@ export function blockBroadcastContext(block, extras = {}) {
77
79
  text,
78
80
  level: 'important',
79
81
  ticket: block.ticket,
82
+ ticketUrl: linearIssueUrl(block.ticket),
80
83
  project: extras.project,
81
84
  agent: extras.agent,
82
85
  host: block.host,
@@ -117,7 +120,7 @@ export function blockDeliveryFailure(blocked, outcomes) {
117
120
  }
118
121
  return undefined;
119
122
  }
120
- const PLACEHOLDER = /\{([a-z]+)\}/g;
123
+ const PLACEHOLDER = /\{([a-z_]+)\}/g;
121
124
  /**
122
125
  * Short host label for a phone line — strip user@ and domain so
123
126
  * `muqsit@mac-mini.tailnet.ts.net` reads as `mac-mini`.
@@ -240,7 +243,9 @@ export function composeBroadcastMessage(ctx) {
240
243
  const head = title || body;
241
244
  const mid = title && body && title !== body ? body : undefined;
242
245
  const footer = composeBroadcastFooter(ctx);
243
- const link = ctx.links?.find((l) => /^https?:\/\//i.test(l));
246
+ const links = [ctx.ticketUrl, ...(ctx.links ?? [])]
247
+ .filter((l) => !!l && /^https?:\/\//i.test(l))
248
+ .filter((l, i, all) => all.indexOf(l) === i);
244
249
  // The action block: the one thing the operator can act on from a phone. Show the
245
250
  // choices, then what happens if they do not answer. Deliberately NOT a CLI command
246
251
  // (`agents focus <id>` is unusable from a phone) -- the safe default is the real
@@ -255,7 +260,7 @@ export function composeBroadcastMessage(ctx) {
255
260
  : undefined;
256
261
  const action = [choices, fallback].filter(Boolean).join('\n') || undefined;
257
262
  // Link trail after the "Sent from" footer so the human sentence stays at the top.
258
- const trail = [footer, link].filter(Boolean);
263
+ const trail = [footer, ...links].filter(Boolean);
259
264
  const parts = [];
260
265
  if (head)
261
266
  parts.push(head);
@@ -284,6 +289,7 @@ function templateVars(ctx) {
284
289
  title: ctx.title,
285
290
  text: ctx.text,
286
291
  ticket: ctx.ticket,
292
+ ticket_url: ctx.ticketUrl,
287
293
  project: ctx.project,
288
294
  agent: ctx.agent,
289
295
  host: ctx.host,
@@ -338,7 +344,20 @@ export function renderSinkMessage(template, ctx) {
338
344
  });
339
345
  if (missing)
340
346
  return undefined;
341
- const text = rendered.trim();
347
+ const seenUrls = new Set();
348
+ const text = rendered
349
+ .trim()
350
+ .split('\n')
351
+ .filter((line) => {
352
+ const value = line.trim();
353
+ if (!/^https?:\/\/\S+$/i.test(value))
354
+ return true;
355
+ if (seenUrls.has(value))
356
+ return false;
357
+ seenUrls.add(value);
358
+ return true;
359
+ })
360
+ .join('\n');
342
361
  return text || undefined;
343
362
  }
344
363
  /**
@@ -500,6 +519,14 @@ async function runChannelSink(sink, meta) {
500
519
  const result = await deliverEnvelope(resolved.envelope, meta);
501
520
  if (result.ok)
502
521
  return { name, ok: true };
522
+ // Explicit rush-backed channel sinks need the same cross-device handoff as
523
+ // the owner alias. Linux workers do not carry the macOS Keychain-bound Rush
524
+ // transport, but feed posts originate on those workers routinely. Keep the
525
+ // destination explicit so the peer delivers exactly this sink once instead
526
+ // of expanding the owner's multi-channel policy.
527
+ const forwarded = await forwardOwnerNotifyToPeer(resolved.envelope.text, resolved.envelope.channel, resolved.envelope.to, meta);
528
+ if (forwarded?.ok)
529
+ return { name, ok: true };
503
530
  return { name, ok: false, error: result.error };
504
531
  }
505
532
  /**
@@ -28,28 +28,33 @@ export const claudeAdapter = {
28
28
  // The `auth` bundle's setup-token exists so a run with NO human present
29
29
  // authenticates without the Touch-ID-gated login item — usage probes,
30
30
  // routines, dispatched runs (claude-account-token.ts). It is a WORKER
31
- // credential. Two kinds of run defer to the per-version login instead:
31
+ // credential. Exactly one kind of run defers to the per-version login
32
+ // instead: ANY run on a `personal`/`desktop` (headed) device — the user's
33
+ // own interactive box (zion), marked `config.role: personal`. That box
34
+ // holds a real per-version login and is the single origin of it, so every
35
+ // run there — interactive TUI OR a headless one-shot like `agents run
36
+ // claude "fix the bug"` — MUST use that login, not the setup-token. Keying
37
+ // the credential on DEVICE ROLE, not run mode, is RUSH-2395's fix: gating on
38
+ // `ctx.interactive` alone sent a headless run on the laptop onto the
39
+ // setup-token and hijacked the login.
32
40
  //
33
- // 1. An interactive run: a human is at the TTY, and their per-version login
34
- // is the credential they established and expect. Overriding it made
35
- // `/status` report `Auth token: CLAUDE_CODE_OAUTH_TOKEN` on a personal
36
- // machine and took every hand-driven session off that login.
37
- // 2. ANY run on a `personal` device — the user's own interactive box (zion),
38
- // marked `config.role: personal`. That box holds a real per-version login
39
- // and is the single origin of it, so every run there — interactive TUI OR
40
- // a headless one-shot like `agents run claude "fix the bug"` — MUST use
41
- // that login, not the setup-token. Gating on run mode ALONE
42
- // (resolveInteractive = "this opens a TUI", NOT "a human is present")
43
- // sent a headless run on the laptop onto the setup-token and hijacked the
44
- // login. Keying the credential on DEVICE ROLE is the fix — RUSH-2395.
41
+ // A WORKER device carries no such login regardless of interactive/headless:
42
+ // an interactive run there is a remotely dispatched TUI (`agents run claude
43
+ // --interactive --device <worker>`), not a human sitting at that box's own
44
+ // Keychain-trusted session — the same worker credential headless runs use is
45
+ // the only credential that exists to authenticate it. Treating `interactive`
46
+ // as "defer to native login" regardless of device role left a keychain-less
47
+ // worker with NO injected token and a per-version `.credentials.json` that
48
+ // was never written, so the run landed on Claude Code's login screen instead
49
+ // of authenticating (PHNX-3502).
45
50
  //
46
51
  // macOS cannot cheaply confirm a home's login first (probing the Keychain
47
52
  // raises an authorization sheet per installed version on the `agents run` hot
48
- // path — agents.ts `isClaudeCredentialFileBlank`), so this path defers to
49
- // Claude Code, which reads its own ACL-trusted login item without a prompt and
50
- // asks a present human to log in only if the login is missing.
53
+ // path — agents.ts `isClaudeCredentialFileBlank`), so the headed-device path
54
+ // defers to Claude Code, which reads its own ACL-trusted login item without a
55
+ // prompt and asks a present human to log in only if the login is missing.
51
56
  const headedDevice = isHeadedDeviceRole(ctx.deviceRole);
52
- if (ctx.interactive || headedDevice) {
57
+ if (headedDevice) {
53
58
  // Drop an INHERITED copy of OUR OWN setup-token: a launch from inside a
54
59
  // headless agent's shell inherits that agent's injected value via
55
60
  // sanitizeProcessEnv(process.env) and would keep authenticating as it,
@@ -66,16 +71,17 @@ export const claudeAdapter = {
66
71
  }
67
72
  }
68
73
  else {
69
- // Headless run on a NON-personal device (worker, dispatched, provisioned
70
- // box): mirror the routines path (`runner.ts`) UNCONDITIONALLY. Inject the
71
- // per-account setup-token when one resolves — it replaces any ambient shared
72
- // value inherited from the launcher. When NONE resolves, STRIP the ambient
73
- // CLAUDE_CODE_OAUTH_TOKEN so a run on a provisioned box can never silently
74
- // authenticate as the shared, rotating token an earlier version of this path
75
- // let through — the RUSH-1822 fleet-wide-logout hazard, tracked by RUSH-2360.
76
- // A missing login then fails loud (401) against this home's own credential
77
- // instead of quietly borrowing another's. options.env still wins below for an
78
- // explicit caller override.
74
+ // Any run on a NON-personal device (worker, dispatched, provisioned box) —
75
+ // interactive OR headless: mirror the routines path (`runner.ts`)
76
+ // UNCONDITIONALLY. Inject the per-account setup-token when one resolves —
77
+ // it replaces any ambient shared value inherited from the launcher. When
78
+ // NONE resolves, STRIP the ambient CLAUDE_CODE_OAUTH_TOKEN so a run on a
79
+ // provisioned box can never silently authenticate as the shared, rotating
80
+ // token an earlier version of this path let through — the RUSH-1822
81
+ // fleet-wide-logout hazard, tracked by RUSH-2360. A missing login then
82
+ // fails loud (401) against this home's own credential instead of quietly
83
+ // borrowing another's. options.env still wins below for an explicit
84
+ // caller override.
79
85
  if (setupToken) {
80
86
  result.CLAUDE_CODE_OAUTH_TOKEN = setupToken;
81
87
  }
@@ -25,6 +25,23 @@ export { flagValue, hasHostRoutingFlag } from './routing-flag.js';
25
25
  interface RemoteSpec {
26
26
  /** Flags appended when running non-interactively (no local TTY / `--no-tty`). */
27
27
  nonInteractive?: string[];
28
+ /**
29
+ * Pure read-only RENDER command: draws a screen and exits, never reads stdin.
30
+ * Forwarded over a PLAIN PIPE rather than `ssh -tt` (a forced PTY), because the
31
+ * PTY teardown + local-terminal restore on a CLEAN exit wipes the just-drawn
32
+ * output — so the render "flashes and vanishes" the moment it finishes
33
+ * (PHNX-3583). Over a pipe the output persists exactly as the working non-TTY
34
+ * path already proves; color and terminal geometry are forced into the remote
35
+ * env so it still comes back colored and correctly wrapped
36
+ * ({@link renderForwardDecision}).
37
+ */
38
+ render?: boolean;
39
+ /**
40
+ * For a {@link render} command with a NARROW interactive sub-path, return `true`
41
+ * when THIS argv hits it — the PTY is kept for that one invocation. Absent means
42
+ * the command never prompts, so it is always safe to forward over a pipe.
43
+ */
44
+ interactiveWhen?: (forwarded: string[]) => boolean;
28
45
  }
29
46
  /**
30
47
  * First-class groups that run transparently on a remote via SSH when
@@ -58,6 +75,33 @@ export declare const OWN_HOST_COMMANDS: Set<string>;
58
75
  * (RUSH-2864).
59
76
  */
60
77
  export declare function buildPassthroughForwardedArgs(command: string, allArgs: string[], interactive: boolean): string[];
78
+ /**
79
+ * Decide whether a `--device` passthrough should forward over a PLAIN PIPE
80
+ * instead of a PTY, and what color/geometry env to inject when it does.
81
+ *
82
+ * A pure read-only render ({@link RemoteSpec.render}) drawn under a forced
83
+ * `ssh -tt` PTY vanishes on clean exit: the PTY teardown + local-terminal
84
+ * restore (`restoreLocalTerminal` in ssh-exec.ts) wipes the output the command
85
+ * just drew (PHNX-3583). The non-TTY (piped) path never had this problem, so a
86
+ * render command takes it too — but only when a human is actually at a real
87
+ * local terminal (`isTTY` and not `--no-tty`); a genuinely piped local run is
88
+ * already on the pipe path and wants neither color nor forced geometry.
89
+ *
90
+ * A render command's narrow interactive sub-path ({@link RemoteSpec.interactiveWhen},
91
+ * e.g. `view --prune`'s confirm) keeps the PTY. When forwarding over a pipe the
92
+ * remote sees `isTTY=false`, so chalk goes colorless and `terminalWidth()` has no
93
+ * `$COLUMNS` to read; `FORCE_COLOR`/`COLUMNS`/`LINES` restore both. `FORCE_COLOR`
94
+ * is withheld under `--json` so it can never taint machine-readable output.
95
+ */
96
+ export declare function renderForwardDecision(command: string, allArgs: string[], io: {
97
+ isTTY: boolean;
98
+ noTty: boolean;
99
+ columns?: number;
100
+ rows?: number;
101
+ }): {
102
+ noPty: boolean;
103
+ env?: Record<string, string>;
104
+ };
61
105
  /** Injectable dependencies for {@link runFleetPassthrough} — used by tests. */
62
106
  export interface FleetPassthroughOptions {
63
107
  /** Override the device registry loader (tests). */
@@ -54,14 +54,23 @@ export { flagValue, hasHostRoutingFlag } from './routing-flag.js';
54
54
  * `add`/`use`/`list`, and none) and were removed.
55
55
  */
56
56
  export const REMOTE_PASSTHROUGH = {
57
- // inspect
58
- view: {},
59
- inspect: {},
60
- doctor: {},
57
+ // inspect — pure read-only renders: forward over a pipe, never a forced PTY
58
+ // (PHNX-3583), so the drawn output persists instead of vanishing on exit.
59
+ view: {
60
+ render: true,
61
+ // Only `--prune` (without --yes/--dry-run) asks a confirm(); that one
62
+ // invocation needs the PTY. Every other `view` is a pure render.
63
+ interactiveWhen: (f) => f.includes('--prune') &&
64
+ !f.includes('--dry-run') &&
65
+ !f.includes('--yes') &&
66
+ !f.includes('-y'),
67
+ },
68
+ inspect: { render: true },
69
+ doctor: { render: true },
61
70
  check: {},
62
71
  list: {},
63
72
  usage: {},
64
- insights: {},
73
+ insights: { render: true },
65
74
  // config / resources
66
75
  config: {},
67
76
  sync: { nonInteractive: ['--yes'] },
@@ -162,6 +171,41 @@ export function buildPassthroughForwardedArgs(command, allArgs, interactive) {
162
171
  }
163
172
  return forwarded;
164
173
  }
174
+ /**
175
+ * Decide whether a `--device` passthrough should forward over a PLAIN PIPE
176
+ * instead of a PTY, and what color/geometry env to inject when it does.
177
+ *
178
+ * A pure read-only render ({@link RemoteSpec.render}) drawn under a forced
179
+ * `ssh -tt` PTY vanishes on clean exit: the PTY teardown + local-terminal
180
+ * restore (`restoreLocalTerminal` in ssh-exec.ts) wipes the output the command
181
+ * just drew (PHNX-3583). The non-TTY (piped) path never had this problem, so a
182
+ * render command takes it too — but only when a human is actually at a real
183
+ * local terminal (`isTTY` and not `--no-tty`); a genuinely piped local run is
184
+ * already on the pipe path and wants neither color nor forced geometry.
185
+ *
186
+ * A render command's narrow interactive sub-path ({@link RemoteSpec.interactiveWhen},
187
+ * e.g. `view --prune`'s confirm) keeps the PTY. When forwarding over a pipe the
188
+ * remote sees `isTTY=false`, so chalk goes colorless and `terminalWidth()` has no
189
+ * `$COLUMNS` to read; `FORCE_COLOR`/`COLUMNS`/`LINES` restore both. `FORCE_COLOR`
190
+ * is withheld under `--json` so it can never taint machine-readable output.
191
+ */
192
+ export function renderForwardDecision(command, allArgs, io) {
193
+ const spec = REMOTE_PASSTHROUGH[command];
194
+ const localTty = io.isTTY && !io.noTty;
195
+ if (!spec?.render || !localTty)
196
+ return { noPty: false };
197
+ const forwarded = stripRoutingFlags(allArgs, STRIP_SPECS);
198
+ if (spec.interactiveWhen?.(forwarded))
199
+ return { noPty: false };
200
+ const env = {};
201
+ if (!allArgs.includes('--json'))
202
+ env.FORCE_COLOR = '1';
203
+ if (io.columns && io.columns > 0)
204
+ env.COLUMNS = String(io.columns);
205
+ if (io.rows && io.rows > 0)
206
+ env.LINES = String(io.rows);
207
+ return { noPty: true, env: Object.keys(env).length ? env : undefined };
208
+ }
165
209
  /** Synthesize a `Host` for a raw `user@host` / bare-alias target (not enrolled). */
166
210
  function syntheticHost(target) {
167
211
  const at = target.indexOf('@');
@@ -554,10 +598,21 @@ export async function maybeRunOnHost(command, allArgs, opts) {
554
598
  return true;
555
599
  }
556
600
  const target = sshTargetFor(host);
601
+ // A read-only render (view/inspect/insights/doctor) must forward over a plain
602
+ // pipe even from a real terminal — a forced `ssh -tt` PTY wipes its output on
603
+ // clean exit (PHNX-3583). renderForwardDecision returns that choice plus the
604
+ // color/geometry env that keeps a piped render colored and correctly wrapped.
605
+ const { noPty: renderNoPty, env: renderForwardEnv } = renderForwardDecision(command, allArgs, {
606
+ isTTY: !!process.stdout.isTTY,
607
+ noTty: allArgs.includes('--no-tty'),
608
+ columns: process.stdout.columns,
609
+ rows: process.stdout.rows,
610
+ });
557
611
  // Interactive only when our own stdout is a terminal and the caller didn't opt
558
612
  // out — otherwise force the command's non-interactive path so no half-drawn
559
- // picker is piped into a file or another program.
560
- const interactive = !!process.stdout.isTTY && !allArgs.includes('--no-tty');
613
+ // picker is piped into a file or another program. A no-PTY render is
614
+ // non-interactive by construction.
615
+ const interactive = !!process.stdout.isTTY && !allArgs.includes('--no-tty') && !renderNoPty;
561
616
  const forwarded = buildPassthroughForwardedArgs(command, allArgs, interactive);
562
617
  // The one long-running case: keep the remote team supervisor alive past a
563
618
  // disconnect by dispatching it detached (nohup), still streaming live.
@@ -583,10 +638,13 @@ export async function maybeRunOnHost(command, allArgs, opts) {
583
638
  const doctorPath = isDoctorCommand && !/^win/i.test((remoteOs ?? '').trim())
584
639
  ? { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' }
585
640
  : undefined;
641
+ // Merge the doctor PATH bootstrap with the render color/geometry env (doctor is
642
+ // itself a render command, so both can apply). undefined when neither is needed.
643
+ const extraEnv = doctorPath || renderForwardEnv ? { ...doctorPath, ...renderForwardEnv } : undefined;
586
644
  process.exitCode = streamAgentsOnHost(host, forwarded, {
587
645
  remoteCwd,
588
646
  interactive,
589
- extraEnv: doctorPath,
647
+ extraEnv,
590
648
  remoteOs,
591
649
  target,
592
650
  });
@@ -17,11 +17,12 @@
17
17
  /**
18
18
  * Where the account backend lives. Config, never a literal at a call site.
19
19
  *
20
- * The default is the deployed Phoenix ID Worker. It is a `workers.dev` URL
21
- * rather than a vanity hostname because no custom domain is attached yet — and
22
- * a default naming an unregistered domain is worse than an ugly one: every
23
- * `agents auth login` would fail DNS with nothing to point at.
20
+ * The default is Phoenix ID's branded custom domain. The legacy workers.dev
21
+ * hostname remains live for already-released clients, but new clients and
22
+ * managed Workers must share this canonical base so token verification cannot
23
+ * drift across the release boundary.
24
24
  */
25
+ export declare const DEFAULT_PHOENIX_ID_BASE = "https://id.byphoenix.com";
25
26
  export declare const PHOENIX_ID_BASE: string;
26
27
  /** Our own session file. agents-cli never reads another product's credentials. */
27
28
  export declare function sessionFilePath(): string;
@@ -20,12 +20,13 @@ import { getRuntimeStateDir } from '../state.js';
20
20
  /**
21
21
  * Where the account backend lives. Config, never a literal at a call site.
22
22
  *
23
- * The default is the deployed Phoenix ID Worker. It is a `workers.dev` URL
24
- * rather than a vanity hostname because no custom domain is attached yet — and
25
- * a default naming an unregistered domain is worse than an ugly one: every
26
- * `agents auth login` would fail DNS with nothing to point at.
23
+ * The default is Phoenix ID's branded custom domain. The legacy workers.dev
24
+ * hostname remains live for already-released clients, but new clients and
25
+ * managed Workers must share this canonical base so token verification cannot
26
+ * drift across the release boundary.
27
27
  */
28
- export const PHOENIX_ID_BASE = process.env.PHOENIX_ID_BASE ?? 'https://phoenix-id.muqsitnawaz.workers.dev';
28
+ export const DEFAULT_PHOENIX_ID_BASE = 'https://id.byphoenix.com';
29
+ export const PHOENIX_ID_BASE = process.env.PHOENIX_ID_BASE ?? DEFAULT_PHOENIX_ID_BASE;
29
30
  /** Our own session file. agents-cli never reads another product's credentials. */
30
31
  export function sessionFilePath() {
31
32
  return path.join(getRuntimeStateDir(), 'phoenix-session.json');
@@ -55,7 +55,7 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
55
55
  export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
56
56
  type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
57
57
  /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
58
- export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness'>;
58
+ export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount'>;
59
59
  export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
60
60
  export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
61
61
  export declare function isRunningLiveSession(s: ActiveSession): boolean;
@@ -205,6 +205,15 @@ export interface ActiveSession {
205
205
  spawnedTeam?: string;
206
206
  /** Files/screenshots attached to the session prompt. */
207
207
  attachments?: SessionAttachment[];
208
+ /** Total tokens and elapsed duration from the indexed transcript. */
209
+ tokenCount?: number;
210
+ durationMs?: number;
211
+ /** Number of subagents launched, persisted by the index scanner. */
212
+ subAgentCount?: number;
213
+ /** Durable documents created in the live transcript tail. */
214
+ artifacts?: import('./highlights.js').ProducedArtifact[];
215
+ /** Created plan document singled out for plan-first consumers. */
216
+ planFile?: string;
208
217
  sessionFile?: string;
209
218
  startedAtMs?: number;
210
219
  /**
@@ -164,6 +164,12 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
164
164
  s.routineName = m.routineName;
165
165
  if (!s.harness && m.harness)
166
166
  s.harness = m.harness;
167
+ if (s.tokenCount == null && m.tokenCount != null)
168
+ s.tokenCount = m.tokenCount;
169
+ if (s.durationMs == null && m.durationMs != null)
170
+ s.durationMs = m.durationMs;
171
+ if (s.subAgentCount == null && m.subAgentCount != null)
172
+ s.subAgentCount = m.subAgentCount;
167
173
  }
168
174
  }
169
175
  function loadBackfillMetaFor(sessions) {
@@ -851,6 +857,8 @@ function applyState(base, state, fallbackFile, pidAlive) {
851
857
  createdTickets: state.createdTickets,
852
858
  spawnedTeam: state.spawnedTeam,
853
859
  attachments: state.attachments,
860
+ artifacts: state.artifacts,
861
+ planFile: state.planFile,
854
862
  rateLimited: state.rateLimited,
855
863
  };
856
864
  }
@@ -98,9 +98,15 @@ export function classifyFileChanges(events) {
98
98
  deleted.delete(p); // a write after a delete recreates the file
99
99
  }
100
100
  else if (EDIT_TOOLS.has(tool)) {
101
- modified.add(p);
101
+ if (args.patch_op === 'Add')
102
+ created.add(p);
103
+ else if (args.patch_op === 'Delete')
104
+ deleted.add(p);
105
+ else
106
+ modified.add(p);
102
107
  seen.add(p);
103
- deleted.delete(p);
108
+ if (args.patch_op !== 'Delete')
109
+ deleted.delete(p);
104
110
  }
105
111
  }
106
112
  const out = [];