@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.
- package/CHANGELOG.md +64 -0
- package/dist/commands/accounts.d.ts +8 -7
- package/dist/commands/accounts.js +40 -13
- package/dist/commands/exec.js +40 -0
- package/dist/commands/feed.js +2 -0
- package/dist/commands/resume.d.ts +8 -0
- package/dist/commands/resume.js +66 -4
- package/dist/lib/accounting/account-pool-collect.js +7 -2
- package/dist/lib/accounting/account-pool.d.ts +12 -0
- package/dist/lib/accounting/account-pool.js +1 -0
- package/dist/lib/accounting/rotate.d.ts +57 -8
- package/dist/lib/accounting/rotate.js +57 -10
- package/dist/lib/accounting/usage.d.ts +12 -0
- package/dist/lib/accounting/usage.js +119 -15
- package/dist/lib/agent-spec/agents.js +21 -6
- package/dist/lib/claude-account-token.d.ts +26 -0
- package/dist/lib/claude-account-token.js +65 -11
- package/dist/lib/daemon-ticks.js +6 -2
- package/dist/lib/event-families.js +1 -1
- package/dist/lib/exec.d.ts +66 -1
- package/dist/lib/exec.js +104 -8
- package/dist/lib/feed/events.d.ts +1 -1
- package/dist/lib/feed/events.js +5 -0
- package/dist/lib/feed-broadcast.d.ts +2 -0
- package/dist/lib/feed-broadcast.js +31 -4
- package/dist/lib/harness/adapters/claude.js +33 -27
- package/dist/lib/hosts/passthrough.d.ts +44 -0
- package/dist/lib/hosts/passthrough.js +66 -8
- package/dist/lib/identity/client.d.ts +5 -4
- package/dist/lib/identity/client.js +6 -5
- package/dist/lib/session/active.d.ts +10 -1
- package/dist/lib/session/active.js +8 -0
- package/dist/lib/session/digest.js +8 -2
- package/dist/lib/session/parse.d.ts +4 -0
- package/dist/lib/session/parse.js +16 -11
- package/dist/lib/session/state.d.ts +5 -0
- package/dist/lib/session/state.js +6 -0
- package/dist/lib/usage-refresh.d.ts +100 -1
- package/dist/lib/usage-refresh.js +195 -4
- 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
|
-
*
|
|
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
|
-
*
|
|
1639
|
-
*
|
|
1640
|
-
*
|
|
1641
|
-
*
|
|
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. */
|
package/dist/lib/feed/events.js
CHANGED
|
@@ -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-
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
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
|
|
49
|
-
// Claude Code, which reads its own ACL-trusted login item without a
|
|
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 (
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// per-account setup-token when one resolves —
|
|
72
|
-
// value inherited from the launcher. When
|
|
73
|
-
// CLAUDE_CODE_OAUTH_TOKEN so a run on a
|
|
74
|
-
// authenticate as the shared, rotating
|
|
75
|
-
// let through — the RUSH-1822
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
108
|
+
if (args.patch_op !== 'Delete')
|
|
109
|
+
deleted.delete(p);
|
|
104
110
|
}
|
|
105
111
|
}
|
|
106
112
|
const out = [];
|