@phnx-labs/agents-cli 1.22.26 → 1.22.27
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 +244 -0
- package/README.md +18 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/bench.d.ts +2 -0
- package/dist/commands/bench.js +101 -0
- package/dist/commands/doctor.js +24 -10
- package/dist/commands/exec.js +17 -16
- package/dist/commands/insights.js +39 -5
- package/dist/commands/output.js +100 -25
- package/dist/commands/resume.d.ts +11 -0
- package/dist/commands/resume.js +51 -0
- package/dist/commands/sessions-picker.js +12 -7
- package/dist/commands/sessions.d.ts +47 -1
- package/dist/commands/sessions.js +345 -37
- package/dist/commands/ssh.js +184 -11
- package/dist/commands/teams.js +3 -3
- package/dist/index.js +16 -20
- package/dist/lib/auth-health.d.ts +47 -3
- package/dist/lib/auth-health.js +74 -15
- package/dist/lib/bench/index.d.ts +4 -0
- package/dist/lib/bench/index.js +4 -0
- package/dist/lib/bench/runner.d.ts +16 -0
- package/dist/lib/bench/runner.js +111 -0
- package/dist/lib/bench/schema.d.ts +5 -0
- package/dist/lib/bench/schema.js +91 -0
- package/dist/lib/bench/storage.d.ts +5 -0
- package/dist/lib/bench/storage.js +32 -0
- package/dist/lib/bench/types.d.ts +40 -0
- package/dist/lib/bench/types.js +1 -0
- package/dist/lib/claude-account-token.d.ts +2 -0
- package/dist/lib/claude-account-token.js +27 -1
- package/dist/lib/crabbox/cli.d.ts +2 -0
- package/dist/lib/crabbox/cli.js +2 -0
- package/dist/lib/crabbox/lease.js +7 -1
- package/dist/lib/daemon.js +20 -0
- package/dist/lib/devices/connect.d.ts +2 -0
- package/dist/lib/devices/connect.js +7 -0
- package/dist/lib/devices/doctor-findings.d.ts +4 -1
- package/dist/lib/devices/doctor-findings.js +15 -1
- package/dist/lib/devices/harness-inventory.d.ts +97 -0
- package/dist/lib/devices/harness-inventory.js +0 -0
- package/dist/lib/devices/registry.d.ts +2 -0
- package/dist/lib/devices/ssh-config.js +3 -0
- package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
- package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
- package/dist/lib/exec.bench.d.ts +1 -0
- package/dist/lib/exec.bench.js +186 -0
- package/dist/lib/exec.js +18 -8
- package/dist/lib/fleet/apply.js +9 -7
- package/dist/lib/fleet/remote-login.d.ts +4 -3
- package/dist/lib/fleet/remote-login.js +11 -9
- package/dist/lib/gemini-settings.d.ts +0 -1
- package/dist/lib/gemini-settings.js +12 -7
- package/dist/lib/hooks.d.ts +8 -5
- package/dist/lib/hooks.js +10 -6
- package/dist/lib/hosts/dispatch.js +7 -3
- package/dist/lib/hosts/passthrough.d.ts +22 -0
- package/dist/lib/hosts/passthrough.js +14 -8
- package/dist/lib/hosts/progress.d.ts +2 -1
- package/dist/lib/hosts/progress.js +4 -3
- package/dist/lib/hosts/providers/devices.js +1 -0
- package/dist/lib/hosts/ready.d.ts +4 -2
- package/dist/lib/hosts/ready.js +24 -8
- package/dist/lib/hosts/reconcile.d.ts +1 -1
- package/dist/lib/hosts/reconcile.js +16 -7
- package/dist/lib/hosts/reconnect.d.ts +45 -12
- package/dist/lib/hosts/reconnect.js +90 -34
- package/dist/lib/hosts/registry.d.ts +2 -2
- package/dist/lib/hosts/registry.js +3 -5
- package/dist/lib/hosts/remote-cmd.d.ts +17 -0
- package/dist/lib/hosts/remote-cmd.js +29 -0
- package/dist/lib/hosts/tasks.d.ts +2 -0
- package/dist/lib/hosts/types.d.ts +1 -0
- package/dist/lib/hosts/types.js +3 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +9 -6
- package/dist/lib/menubar/install-menubar.js +20 -9
- package/dist/lib/models.d.ts +30 -1
- package/dist/lib/models.js +42 -144
- package/dist/lib/pricing/cost.d.ts +9 -0
- package/dist/lib/pricing/cost.js +24 -0
- package/dist/lib/pricing/index.d.ts +1 -1
- package/dist/lib/pricing/index.js +1 -1
- package/dist/lib/redact.js +8 -3
- package/dist/lib/remote-agents-json.d.ts +32 -0
- package/dist/lib/remote-agents-json.js +47 -16
- package/dist/lib/resource-profiles.js +1 -2
- package/dist/lib/routine-notify-owner.d.ts +102 -0
- package/dist/lib/routine-notify-owner.js +232 -0
- package/dist/lib/routines.d.ts +10 -0
- package/dist/lib/runner.d.ts +9 -1
- package/dist/lib/runner.js +182 -17
- package/dist/lib/sandbox.d.ts +0 -2
- package/dist/lib/sandbox.js +2 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.js +3 -2
- package/dist/lib/secrets/lease.d.ts +25 -0
- package/dist/lib/secrets/lease.js +44 -0
- package/dist/lib/self-update.d.ts +20 -5
- package/dist/lib/self-update.js +93 -16
- package/dist/lib/session/db.d.ts +19 -2
- package/dist/lib/session/db.js +54 -5
- package/dist/lib/session/discover.d.ts +21 -1
- package/dist/lib/session/discover.js +124 -32
- package/dist/lib/session/insights.d.ts +19 -0
- package/dist/lib/session/insights.js +78 -10
- package/dist/lib/session/recovery.d.ts +17 -1
- package/dist/lib/session/recovery.js +111 -4
- package/dist/lib/session/remote-list.d.ts +10 -9
- package/dist/lib/session/remote-list.js +25 -23
- package/dist/lib/session/resume-owner.d.ts +55 -0
- package/dist/lib/session/resume-owner.js +69 -0
- package/dist/lib/session/team-filter.d.ts +65 -0
- package/dist/lib/session/team-filter.js +98 -3
- package/dist/lib/session/types.d.ts +33 -0
- package/dist/lib/smart-launch.d.ts +31 -5
- package/dist/lib/smart-launch.js +43 -7
- package/dist/lib/ssh-exec.d.ts +27 -0
- package/dist/lib/ssh-exec.js +34 -1
- package/dist/lib/ssh-tunnel.d.ts +3 -2
- package/dist/lib/ssh-tunnel.js +25 -16
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/teams/placement-probe.d.ts +1 -1
- package/dist/lib/teams/placement-probe.js +24 -24
- package/dist/lib/tmux/session.d.ts +8 -0
- package/dist/lib/tmux/session.js +22 -0
- package/dist/lib/types.d.ts +2 -0
- package/package.json +1 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { AGENTS, agentConfigDirName } from '../agents.js';
|
|
2
4
|
import { isSelfHost } from '../devices/self-host.js';
|
|
3
5
|
import { nativeResume } from '../exec.js';
|
|
4
6
|
import { machineId, normalizeHost } from '../machine-id.js';
|
|
5
7
|
import { collectRunCandidates, formatNoHealthyAccountError, pickBalancedCandidate, readinessFromCandidate, } from '../rotate.js';
|
|
8
|
+
import { getVersionHomePath } from '../versions.js';
|
|
6
9
|
export class SessionRecoveryError extends Error {
|
|
7
10
|
constructor(message) {
|
|
8
11
|
super(message);
|
|
@@ -41,6 +44,99 @@ function sourceReason(session, candidates) {
|
|
|
41
44
|
? `origin ${session.agent}@${session.version} has no native resume form`
|
|
42
45
|
: `origin ${session.agent}@${session.version} is ${readiness.reason}`;
|
|
43
46
|
}
|
|
47
|
+
function isPathInside(candidate, dir) {
|
|
48
|
+
const rel = path.relative(dir, candidate);
|
|
49
|
+
return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
|
50
|
+
}
|
|
51
|
+
function existingDirectory(dir) {
|
|
52
|
+
if (!dir)
|
|
53
|
+
return undefined;
|
|
54
|
+
try {
|
|
55
|
+
return fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() ? dir : undefined;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Read the launch cwd Claude used to choose its projects/<cwd-key> directory.
|
|
62
|
+
* Claude can record attachment envelopes before the first user turn, and those
|
|
63
|
+
* envelopes retain the actual launch cwd even after the session changes dirs. */
|
|
64
|
+
function readClaudeLaunchCwd(filePath) {
|
|
65
|
+
const maxBytes = 2 * 1024 * 1024;
|
|
66
|
+
let fd;
|
|
67
|
+
try {
|
|
68
|
+
fd = fs.openSync(filePath, 'r');
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const chunk = Buffer.alloc(maxBytes);
|
|
75
|
+
const bytesRead = fs.readSync(fd, chunk, 0, maxBytes, 0);
|
|
76
|
+
const lines = chunk.toString('utf8', 0, bytesRead).split('\n');
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
if (!line.trim())
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(line);
|
|
82
|
+
if (typeof parsed?.cwd !== 'string' || !path.isAbsolute(parsed.cwd))
|
|
83
|
+
continue;
|
|
84
|
+
if (existingDirectory(parsed.cwd))
|
|
85
|
+
return parsed.cwd;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// A malformed line or vanished cwd cannot identify a usable native home.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
fs.closeSync(fd);
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Prove that the indexed transcript is reachable from the exact active version
|
|
99
|
+
* home that would receive native resume. Retained trash/backup transcripts are
|
|
100
|
+
* intentionally rejected here: they remain readable by `/continue`, but a new
|
|
101
|
+
* installation with the same version number must not native-resume an empty
|
|
102
|
+
* isolated home.
|
|
103
|
+
*/
|
|
104
|
+
export function inspectNativeResumeSession(session, versionHome) {
|
|
105
|
+
let realFile;
|
|
106
|
+
try {
|
|
107
|
+
realFile = fs.realpathSync(session.filePath);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return { available: false, reason: 'the indexed transcript is no longer present in the origin home' };
|
|
111
|
+
}
|
|
112
|
+
const roots = [versionHome, path.join(versionHome, agentConfigDirName(session.agent))];
|
|
113
|
+
const owned = roots.some((root) => {
|
|
114
|
+
try {
|
|
115
|
+
return isPathInside(realFile, fs.realpathSync(root));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
if (!owned) {
|
|
122
|
+
return {
|
|
123
|
+
available: false,
|
|
124
|
+
reason: `the indexed transcript is retained outside the active ${session.agent}@${session.version ?? 'unknown'} home`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (session.agent === 'claude') {
|
|
128
|
+
const cwd = readClaudeLaunchCwd(realFile);
|
|
129
|
+
if (!cwd) {
|
|
130
|
+
return {
|
|
131
|
+
available: false,
|
|
132
|
+
reason: 'the Claude transcript does not identify an existing original project directory',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { available: true, cwd };
|
|
136
|
+
}
|
|
137
|
+
const cwd = existingDirectory(session.cwd);
|
|
138
|
+
return { available: true, cwd };
|
|
139
|
+
}
|
|
44
140
|
/**
|
|
45
141
|
* Decide how a durable session resumes on the device that owns it.
|
|
46
142
|
*
|
|
@@ -49,7 +145,7 @@ function sourceReason(session, candidates) {
|
|
|
49
145
|
* same harness and uses `/continue`, whose indexed transcript reader can reach
|
|
50
146
|
* retained version trash. No healthy same-harness account is a loud failure.
|
|
51
147
|
*/
|
|
52
|
-
export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume) {
|
|
148
|
+
export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume, nativeInspection) {
|
|
53
149
|
const agent = runnableSessionAgent(session);
|
|
54
150
|
const device = sessionOriginDevice(session);
|
|
55
151
|
const source = session.version
|
|
@@ -69,11 +165,22 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
|
|
|
69
165
|
}
|
|
70
166
|
const version = selection.picked.version;
|
|
71
167
|
if (session.version === version && supportsNative(agent, version)) {
|
|
168
|
+
const inspection = nativeInspection
|
|
169
|
+
?? inspectNativeResumeSession(session, getVersionHomePath(agent, version));
|
|
170
|
+
if (inspection.available) {
|
|
171
|
+
return {
|
|
172
|
+
mode: 'native',
|
|
173
|
+
agent,
|
|
174
|
+
version,
|
|
175
|
+
cwd: inspection.cwd,
|
|
176
|
+
reason: `origin ${agent}@${version} is installed, healthy, and owns the indexed transcript`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
72
179
|
return {
|
|
73
|
-
mode: '
|
|
180
|
+
mode: 'continue',
|
|
74
181
|
agent,
|
|
75
182
|
version,
|
|
76
|
-
reason:
|
|
183
|
+
reason: `${inspection.reason}; continuing with healthy ${agent}@${version}`,
|
|
77
184
|
};
|
|
78
185
|
}
|
|
79
186
|
return {
|
|
@@ -2,20 +2,12 @@ import { type DeviceProfile } from '../devices/registry.js';
|
|
|
2
2
|
import { type RemoteAgentsJsonParseResult } from '../remote-agents-json.js';
|
|
3
3
|
import type { SessionMeta } from './types.js';
|
|
4
4
|
import { type ToolProgramCountEnvelope, type ToolSearchEnvelope } from './tool-index.js';
|
|
5
|
-
export
|
|
5
|
+
export { REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator } from '../ssh-exec.js';
|
|
6
6
|
export declare const REMOTE_TOOL_AGGREGATE_MAX_BYTES: number;
|
|
7
7
|
export interface RemoteToolByteBudget {
|
|
8
8
|
remainingBytes: number;
|
|
9
9
|
exhausted: boolean;
|
|
10
10
|
}
|
|
11
|
-
/** Preserve UTF-8 code points when SSH splits them across stdout chunks. */
|
|
12
|
-
export declare class RemoteUtf8Accumulator {
|
|
13
|
-
private readonly decoder;
|
|
14
|
-
private value;
|
|
15
|
-
write(chunk: Buffer): void;
|
|
16
|
-
end(): string;
|
|
17
|
-
current(): string;
|
|
18
|
-
}
|
|
19
11
|
/** Claim received bytes against one fleet-query budget before retaining them. */
|
|
20
12
|
export declare function consumeRemoteToolByteBudget(budget: RemoteToolByteBudget, bytes: number): boolean;
|
|
21
13
|
/** Charge sanitized, machine-stamped evidence because redaction may expand it. */
|
|
@@ -130,7 +122,16 @@ export declare function resolvePeerTarget(machine: string): Promise<{
|
|
|
130
122
|
* not via a local `--host` hop, which would discover locally and dead-end for a
|
|
131
123
|
* session that exists only on the peer. Resolves 'no-target' when the machine
|
|
132
124
|
* isn't a dialable registered device; the caller surfaces a clear message.
|
|
125
|
+
*
|
|
126
|
+
* `opts.env` adds variables to the remote command. It deliberately does NOT
|
|
127
|
+
* carry `AGENTS_FLEET_REMOTE` the way the `--host` passthrough does: that marker
|
|
128
|
+
* gates consent-sensitive actions on the far side
|
|
129
|
+
* (lib/browser/remote-control.ts), and a resumed agent is a long-lived session
|
|
130
|
+
* that would inherit it for its whole life — `agents browser start` inside it
|
|
131
|
+
* would then be refused as a cross-machine drive. A one-shot `--host` command
|
|
132
|
+
* can carry the marker; a session cannot.
|
|
133
133
|
*/
|
|
134
134
|
export declare function runOnPeer(args: string[], machine: string, opts?: {
|
|
135
135
|
tty?: boolean;
|
|
136
|
+
env?: Record<string, string>;
|
|
136
137
|
}): Promise<'ok' | 'no-target'>;
|
|
@@ -14,13 +14,12 @@
|
|
|
14
14
|
* never fatal — one asleep laptop must not blank the list.
|
|
15
15
|
*/
|
|
16
16
|
import { spawn } from 'child_process';
|
|
17
|
-
import { StringDecoder } from 'string_decoder';
|
|
18
17
|
import chalk from 'chalk';
|
|
19
|
-
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
18
|
+
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote, REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator, } from '../ssh-exec.js';
|
|
20
19
|
import { sshTargetFor } from '../devices/connect.js';
|
|
21
20
|
import { resolveExplicitTargetSet } from '../devices/resolve-target.js';
|
|
22
21
|
import { loadDevices, isControlDevice, isDialableDevice } from '../devices/registry.js';
|
|
23
|
-
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
22
|
+
import { remoteShellFor, buildWindowsAgentsCommand, stripClixml } from '../hosts/remote-cmd.js';
|
|
24
23
|
import { gatherRemoteAgentsJson } from '../remote-agents-json.js';
|
|
25
24
|
import { machineId, normalizeHost } from './sync/config.js';
|
|
26
25
|
import { NO_FANOUT_ENV } from './remote-active.js';
|
|
@@ -30,23 +29,12 @@ import { mapBounded } from '../concurrency.js';
|
|
|
30
29
|
import { TOOL_QUERY_MAX_CLAUSE_BYTES, TOOL_QUERY_MAX_CALL_ROWS, TOOL_QUERY_MAX_CLAUSES, TOOL_QUERY_MAX_RESULT_SESSIONS, TOOL_QUERY_MAX_SERIALIZED_BYTES, serializedToolSearchEnvelopeBytes, } from './tool-index.js';
|
|
31
30
|
import { TOOL_ERROR_OUTPUT_MAX_BYTES, TOOL_INPUT_MAX_BYTES, TOOL_SUCCESS_OUTPUT_MAX_BYTES, sanitizeToolEvidenceText, } from './tool-calls.js';
|
|
32
31
|
const REMOTE_TOOL_TIMEOUT_MS = 60_000;
|
|
33
|
-
|
|
32
|
+
// The per-peer stdout ceiling and the UTF-8-safe accumulator live in ssh-exec.ts
|
|
33
|
+
// (the shared SSH transport both this reader and the top-level `remote-agents-json`
|
|
34
|
+
// fan-out import), so the bound is defined once. Re-exported here for the existing
|
|
35
|
+
// consumers/tests that reach them through this module.
|
|
36
|
+
export { REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator } from '../ssh-exec.js';
|
|
34
37
|
export const REMOTE_TOOL_AGGREGATE_MAX_BYTES = TOOL_QUERY_MAX_SERIALIZED_BYTES;
|
|
35
|
-
/** Preserve UTF-8 code points when SSH splits them across stdout chunks. */
|
|
36
|
-
export class RemoteUtf8Accumulator {
|
|
37
|
-
decoder = new StringDecoder('utf8');
|
|
38
|
-
value = '';
|
|
39
|
-
write(chunk) {
|
|
40
|
-
this.value += this.decoder.write(chunk);
|
|
41
|
-
}
|
|
42
|
-
end() {
|
|
43
|
-
this.value += this.decoder.end();
|
|
44
|
-
return this.value;
|
|
45
|
-
}
|
|
46
|
-
current() {
|
|
47
|
-
return this.value;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
38
|
/** Claim received bytes against one fleet-query budget before retaining them. */
|
|
51
39
|
export function consumeRemoteToolByteBudget(budget, bytes) {
|
|
52
40
|
if (budget.exhausted || bytes > budget.remainingBytes) {
|
|
@@ -91,7 +79,7 @@ export function remoteListCommand(forwardedArgs, os) {
|
|
|
91
79
|
export function parseRemoteList(stdout, machine) {
|
|
92
80
|
let parsed;
|
|
93
81
|
try {
|
|
94
|
-
parsed = JSON.parse(stdout);
|
|
82
|
+
parsed = JSON.parse(stripClixml(stdout));
|
|
95
83
|
}
|
|
96
84
|
catch {
|
|
97
85
|
return [];
|
|
@@ -117,7 +105,7 @@ function isSafeResolverRow(value) {
|
|
|
117
105
|
export function parseRemoteListPayload(stdout, machine, safeResolver = false) {
|
|
118
106
|
let parsed;
|
|
119
107
|
try {
|
|
120
|
-
parsed = JSON.parse(stdout);
|
|
108
|
+
parsed = JSON.parse(stripClixml(stdout));
|
|
121
109
|
}
|
|
122
110
|
catch {
|
|
123
111
|
return { items: [], valid: false };
|
|
@@ -375,6 +363,10 @@ function parseRemoteToolSession(value, machine) {
|
|
|
375
363
|
};
|
|
376
364
|
}
|
|
377
365
|
export function parseRemoteToolSearch(stdout, machine, expectedClauses) {
|
|
366
|
+
// The fleet tool-search fan-out reads a raw sshCapture (not the stripped
|
|
367
|
+
// gatherRemoteAgentsJson wrapper), so a Windows peer's PowerShell CLIXML banner
|
|
368
|
+
// must be removed here too or the box reads as "no envelope" (RUSH-2286).
|
|
369
|
+
stdout = stripClixml(stdout);
|
|
378
370
|
if (Buffer.byteLength(stdout) > REMOTE_STDOUT_MAX_BYTES)
|
|
379
371
|
return undefined;
|
|
380
372
|
try {
|
|
@@ -540,6 +532,14 @@ export async function resolvePeerTarget(machine) {
|
|
|
540
532
|
* not via a local `--host` hop, which would discover locally and dead-end for a
|
|
541
533
|
* session that exists only on the peer. Resolves 'no-target' when the machine
|
|
542
534
|
* isn't a dialable registered device; the caller surfaces a clear message.
|
|
535
|
+
*
|
|
536
|
+
* `opts.env` adds variables to the remote command. It deliberately does NOT
|
|
537
|
+
* carry `AGENTS_FLEET_REMOTE` the way the `--host` passthrough does: that marker
|
|
538
|
+
* gates consent-sensitive actions on the far side
|
|
539
|
+
* (lib/browser/remote-control.ts), and a resumed agent is a long-lived session
|
|
540
|
+
* that would inherit it for its whole life — `agents browser start` inside it
|
|
541
|
+
* would then be refused as a cross-machine drive. A one-shot `--host` command
|
|
542
|
+
* can carry the marker; a session cannot.
|
|
543
543
|
*/
|
|
544
544
|
export async function runOnPeer(args, machine, opts = {}) {
|
|
545
545
|
const peer = await resolvePeerTarget(machine);
|
|
@@ -547,9 +547,11 @@ export async function runOnPeer(args, machine, opts = {}) {
|
|
|
547
547
|
return 'no-target';
|
|
548
548
|
assertValidSshTarget(peer.target); // registry-sourced, but validate like the fan-out does
|
|
549
549
|
const cols = terminalWidth();
|
|
550
|
+
const env = { ...(cols > 0 ? { COLUMNS: String(cols) } : {}), ...opts.env };
|
|
551
|
+
const assignments = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`);
|
|
550
552
|
const remoteCmd = remoteShellFor(peer.os) === 'powershell'
|
|
551
|
-
? buildWindowsAgentsCommand({ args, env:
|
|
552
|
-
: `bash -lc ${shellQuote(
|
|
553
|
+
? buildWindowsAgentsCommand({ args, env: assignments.length ? env : undefined })
|
|
554
|
+
: `bash -lc ${shellQuote(assignments.concat(['agents', ...args].map(shellQuote)).join(' '))}`;
|
|
553
555
|
const sshArgs = [...SSH_OPTS, ...controlOpts()];
|
|
554
556
|
if (opts.tty)
|
|
555
557
|
sshArgs.push('-tt'); // force a PTY so the resumed agent is interactive
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a session may be resumed — the one place that answers "does this
|
|
3
|
+
* transcript belong to another machine?" and, when it does, runs the resume
|
|
4
|
+
* THERE instead of here.
|
|
5
|
+
*
|
|
6
|
+
* A session's agent state lives on the box that produced it: the harness keeps
|
|
7
|
+
* its own conversation store under that machine's home, and `machine` on a
|
|
8
|
+
* SessionMeta records where the transcript originated (see session/types.ts).
|
|
9
|
+
* A row can reach this box two ways and BOTH are remote-owned:
|
|
10
|
+
*
|
|
11
|
+
* - a **synced mirror** — locally readable at `backups/<agent>/<machine>/…`,
|
|
12
|
+
* which is exactly why the old code could not tell: the transcript file is
|
|
13
|
+
* right there, so nothing failed until the harness was asked to resume a
|
|
14
|
+
* conversation it has never seen;
|
|
15
|
+
* - a **live fan-out row** (`_remote`), whose `filePath` is on the peer's disk.
|
|
16
|
+
*
|
|
17
|
+
* Resuming either one locally starts the harness against state it does not
|
|
18
|
+
* have. Before RUSH-2022 that happened silently — `sessions-resume.ts` even fell
|
|
19
|
+
* back to `process.cwd()` when the recorded cwd did not exist locally, so a
|
|
20
|
+
* remote session resumed in whatever directory the user happened to be in.
|
|
21
|
+
*
|
|
22
|
+
* Callers act on the answer differently, and the difference is deliberate:
|
|
23
|
+
* `agents resume` and `agents sessions attach` HOP to the owner over SSH, while
|
|
24
|
+
* `resumeSessionInPlace` — the local takeover every routed caller reaches only
|
|
25
|
+
* after deciding — REFUSES. The batch `sessions resume` needs no check of its
|
|
26
|
+
* own: each of its tabs runs the canonical `agents resume <id>`
|
|
27
|
+
* (lib/session/resume-command.ts), which routes itself.
|
|
28
|
+
*/
|
|
29
|
+
import type { SessionMeta } from './types.js';
|
|
30
|
+
/**
|
|
31
|
+
* Set on the SSH hop that sends a resume to its owning device: the far side must
|
|
32
|
+
* run it, never route again.
|
|
33
|
+
*
|
|
34
|
+
* An env var rather than a flag, deliberately. The fleet is mixed-version — a
|
|
35
|
+
* peer on the released CLI would die on an unknown `--here` with
|
|
36
|
+
* `error: unknown option '--here'`, breaking the very hop this feature adds. An
|
|
37
|
+
* unrecognized exported variable is inert on every version, so routing works
|
|
38
|
+
* against old and new peers alike. The far side deletes it after reading
|
|
39
|
+
* ({@link consumeResumePinned}) so it cannot leak into the agent's own children.
|
|
40
|
+
*/
|
|
41
|
+
export declare const RESUME_PINNED_ENV = "AGENTS_RESUME_PINNED";
|
|
42
|
+
/**
|
|
43
|
+
* Whether this process was handed a resume by its owner-routing hop — read once,
|
|
44
|
+
* then cleared so a nested `agents resume` inside the running agent still routes
|
|
45
|
+
* normally.
|
|
46
|
+
*/
|
|
47
|
+
export declare function consumeResumePinned(): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The peer that owns `session`'s state, or `undefined` when this machine does.
|
|
50
|
+
*
|
|
51
|
+
* Undefined is also the answer for an untagged row (`machine` unset — a session
|
|
52
|
+
* obtained outside `discoverSessions`): there is nothing to route to, so the
|
|
53
|
+
* caller keeps the local behaviour rather than inventing a target.
|
|
54
|
+
*/
|
|
55
|
+
export declare function sessionOwnerDevice(session: Pick<SessionMeta, 'machine'>): string | undefined;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a session may be resumed — the one place that answers "does this
|
|
3
|
+
* transcript belong to another machine?" and, when it does, runs the resume
|
|
4
|
+
* THERE instead of here.
|
|
5
|
+
*
|
|
6
|
+
* A session's agent state lives on the box that produced it: the harness keeps
|
|
7
|
+
* its own conversation store under that machine's home, and `machine` on a
|
|
8
|
+
* SessionMeta records where the transcript originated (see session/types.ts).
|
|
9
|
+
* A row can reach this box two ways and BOTH are remote-owned:
|
|
10
|
+
*
|
|
11
|
+
* - a **synced mirror** — locally readable at `backups/<agent>/<machine>/…`,
|
|
12
|
+
* which is exactly why the old code could not tell: the transcript file is
|
|
13
|
+
* right there, so nothing failed until the harness was asked to resume a
|
|
14
|
+
* conversation it has never seen;
|
|
15
|
+
* - a **live fan-out row** (`_remote`), whose `filePath` is on the peer's disk.
|
|
16
|
+
*
|
|
17
|
+
* Resuming either one locally starts the harness against state it does not
|
|
18
|
+
* have. Before RUSH-2022 that happened silently — `sessions-resume.ts` even fell
|
|
19
|
+
* back to `process.cwd()` when the recorded cwd did not exist locally, so a
|
|
20
|
+
* remote session resumed in whatever directory the user happened to be in.
|
|
21
|
+
*
|
|
22
|
+
* Callers act on the answer differently, and the difference is deliberate:
|
|
23
|
+
* `agents resume` and `agents sessions attach` HOP to the owner over SSH, while
|
|
24
|
+
* `resumeSessionInPlace` — the local takeover every routed caller reaches only
|
|
25
|
+
* after deciding — REFUSES. The batch `sessions resume` needs no check of its
|
|
26
|
+
* own: each of its tabs runs the canonical `agents resume <id>`
|
|
27
|
+
* (lib/session/resume-command.ts), which routes itself.
|
|
28
|
+
*/
|
|
29
|
+
import { isSelfHost } from '../devices/self-host.js';
|
|
30
|
+
/**
|
|
31
|
+
* Set on the SSH hop that sends a resume to its owning device: the far side must
|
|
32
|
+
* run it, never route again.
|
|
33
|
+
*
|
|
34
|
+
* An env var rather than a flag, deliberately. The fleet is mixed-version — a
|
|
35
|
+
* peer on the released CLI would die on an unknown `--here` with
|
|
36
|
+
* `error: unknown option '--here'`, breaking the very hop this feature adds. An
|
|
37
|
+
* unrecognized exported variable is inert on every version, so routing works
|
|
38
|
+
* against old and new peers alike. The far side deletes it after reading
|
|
39
|
+
* ({@link consumeResumePinned}) so it cannot leak into the agent's own children.
|
|
40
|
+
*/
|
|
41
|
+
export const RESUME_PINNED_ENV = 'AGENTS_RESUME_PINNED';
|
|
42
|
+
/**
|
|
43
|
+
* Whether this process was handed a resume by its owner-routing hop — read once,
|
|
44
|
+
* then cleared so a nested `agents resume` inside the running agent still routes
|
|
45
|
+
* normally.
|
|
46
|
+
*/
|
|
47
|
+
export function consumeResumePinned() {
|
|
48
|
+
const pinned = process.env[RESUME_PINNED_ENV] === '1';
|
|
49
|
+
delete process.env[RESUME_PINNED_ENV];
|
|
50
|
+
return pinned;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The peer that owns `session`'s state, or `undefined` when this machine does.
|
|
54
|
+
*
|
|
55
|
+
* Undefined is also the answer for an untagged row (`machine` unset — a session
|
|
56
|
+
* obtained outside `discoverSessions`): there is nothing to route to, so the
|
|
57
|
+
* caller keeps the local behaviour rather than inventing a target.
|
|
58
|
+
*/
|
|
59
|
+
export function sessionOwnerDevice(session) {
|
|
60
|
+
const owner = session.machine?.trim();
|
|
61
|
+
if (!owner)
|
|
62
|
+
return undefined;
|
|
63
|
+
// `isSelfHost` matches every identity this box answers to — `machineId()`
|
|
64
|
+
// itself, the tailnet dnsName and its short form, loopback (devices/
|
|
65
|
+
// self-host.ts `selfAliases`) — so a mirror tagged with this machine's tailnet
|
|
66
|
+
// name is local, not a peer (cf. RUSH-2114). No separate `machineId()`
|
|
67
|
+
// comparison: it is one of those aliases.
|
|
68
|
+
return isSelfHost(owner) ? undefined : owner;
|
|
69
|
+
}
|
|
@@ -51,3 +51,68 @@ export interface FilterResult {
|
|
|
51
51
|
* excluded and counted in `hiddenCount`.
|
|
52
52
|
*/
|
|
53
53
|
export declare function filterTeamSessions(sessions: SessionMeta[], showTeams: boolean): FilterResult;
|
|
54
|
+
/**
|
|
55
|
+
* Whether a team-origin session is a real `agents teams` teammate or a bare SDK
|
|
56
|
+
* spawn with no teammate record. The two share the `sdk-cli` entrypoint that
|
|
57
|
+
* sets `isTeamOrigin`, so they are told apart by whether a `meta.json` teammate
|
|
58
|
+
* record backs the origin ({@link TeamOrigin.source} === `'meta'`).
|
|
59
|
+
*
|
|
60
|
+
* `source` is newer than the rest of {@link TeamOrigin}, so a row that arrived
|
|
61
|
+
* from a pre-`source` peer over the `--host` fan-out carries none. There the
|
|
62
|
+
* meta-only fields settle it: the entrypoint fallback is a bare `{ handle }`, so
|
|
63
|
+
* any `team` / `mode` / `parentSessionId` / `startedAt` means a real teammate
|
|
64
|
+
* record produced it — otherwise a genuine teammate from an unupgraded peer
|
|
65
|
+
* would be misfiled into the no-team bucket, the exact conflation this splits.
|
|
66
|
+
*/
|
|
67
|
+
export declare function teamRowKind(origin: TeamOrigin | undefined): 'teammate' | 'subagent';
|
|
68
|
+
/** A team-origin session with its resolved {@link TeamOrigin} guaranteed present. */
|
|
69
|
+
type TeamSession = SessionMeta & {
|
|
70
|
+
teamOrigin: TeamOrigin;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Key/label for the residual bucket — sessions flagged `isTeamOrigin` (an SDK /
|
|
74
|
+
* `sdk-cli` entrypoint) that carry no teammate `meta.json`. In practice this is
|
|
75
|
+
* headless `agents run` spawns and teammates whose team record aged out; a true
|
|
76
|
+
* `Task` sub-agent's transcript is never indexed, so it cannot land here. Named
|
|
77
|
+
* "(no team)" rather than "(sub-agents)" so the label matches what it holds.
|
|
78
|
+
*/
|
|
79
|
+
export declare const NO_TEAM_GROUP_KEY = "(no team)";
|
|
80
|
+
/** Key/label for teammates whose record carries no team name (`task_name`). */
|
|
81
|
+
export declare const UNNAMED_TEAM_KEY = "(unnamed team)";
|
|
82
|
+
/** One team's sessions, grouped for the `agents sessions --teams` view. */
|
|
83
|
+
export interface TeamSessionGroup {
|
|
84
|
+
/**
|
|
85
|
+
* The grouping key: the team name, {@link UNNAMED_TEAM_KEY} for teammates that
|
|
86
|
+
* carry no `task_name`, or {@link NO_TEAM_GROUP_KEY} for the residual bucket.
|
|
87
|
+
*/
|
|
88
|
+
key: string;
|
|
89
|
+
/** A named team of `agents teams` teammates vs the catch-all no-team bucket. */
|
|
90
|
+
kind: 'team' | 'noTeam';
|
|
91
|
+
/** Team name when this is a named team, else undefined. */
|
|
92
|
+
team?: string;
|
|
93
|
+
/**
|
|
94
|
+
* The orchestrator session id that spawned this team (`parent_session_id`),
|
|
95
|
+
* when every teammate agrees on one. Undefined for the no-team bucket, an
|
|
96
|
+
* unparented team, or a group whose teammates disagree.
|
|
97
|
+
*/
|
|
98
|
+
spawnerSessionId?: string;
|
|
99
|
+
/** The team's rows, newest-active first. */
|
|
100
|
+
sessions: TeamSession[];
|
|
101
|
+
/** Most-recent activity across the group, for ordering groups. */
|
|
102
|
+
maxTs: string;
|
|
103
|
+
/** Earliest spawn time across the group (`started_at`, else `timestamp`). */
|
|
104
|
+
firstSpawnTs: string;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Group team-origin sessions for the `--teams` view: each named team becomes one
|
|
108
|
+
* group of its teammates, and every bare SDK spawn with no teammate record falls
|
|
109
|
+
* into a single trailing {@link NO_TEAM_GROUP_KEY} bucket, so a real teammate and
|
|
110
|
+
* a bare spawn are never shown as the same thing.
|
|
111
|
+
*
|
|
112
|
+
* Only rows with a resolved {@link TeamOrigin} participate (the caller has
|
|
113
|
+
* already run {@link filterTeamSessions}); any non-team session is ignored.
|
|
114
|
+
* Groups are ordered newest-active first; sub-agents always sort last. Pure —
|
|
115
|
+
* unit-tested.
|
|
116
|
+
*/
|
|
117
|
+
export declare function groupSessionsByTeam(sessions: SessionMeta[]): TeamSessionGroup[];
|
|
118
|
+
export {};
|
|
@@ -35,7 +35,7 @@ export function classifyTeamSession(session) {
|
|
|
35
35
|
if (origin)
|
|
36
36
|
return origin;
|
|
37
37
|
if (session.isTeamOrigin) {
|
|
38
|
-
return { handle: session.id.slice(0, 8) };
|
|
38
|
+
return { handle: session.id.slice(0, 8), source: 'entrypoint' };
|
|
39
39
|
}
|
|
40
40
|
return null;
|
|
41
41
|
}
|
|
@@ -54,12 +54,16 @@ function readTeamOrigin(metaPath, agentId) {
|
|
|
54
54
|
mode: str(meta.mode),
|
|
55
55
|
team: str(meta.task_name),
|
|
56
56
|
parentSessionId: str(meta.parent_session_id),
|
|
57
|
+
startedAt: str(meta.started_at),
|
|
58
|
+
source: 'meta',
|
|
57
59
|
},
|
|
58
60
|
sessionId: str(meta.remote_session_id),
|
|
59
61
|
};
|
|
60
62
|
}
|
|
61
63
|
catch {
|
|
62
|
-
|
|
64
|
+
// An unreadable meta.json still lives under the teams agents dir, so the
|
|
65
|
+
// session IS a teammate — mark the source accordingly, just without fields.
|
|
66
|
+
return { origin: { handle: agentId.slice(0, 8), source: 'meta' } };
|
|
63
67
|
}
|
|
64
68
|
}
|
|
65
69
|
/**
|
|
@@ -133,7 +137,8 @@ export function enrichTeamOrigins(sessions) {
|
|
|
133
137
|
// here would find no local record and downgrade a named teammate to a bare id.
|
|
134
138
|
if (session.teamOrigin)
|
|
135
139
|
return session;
|
|
136
|
-
const origin = index.get(session.id) ??
|
|
140
|
+
const origin = index.get(session.id) ??
|
|
141
|
+
(session.isTeamOrigin ? { handle: session.id.slice(0, 8), source: 'entrypoint' } : null);
|
|
137
142
|
return origin ? { ...session, teamOrigin: origin } : session;
|
|
138
143
|
});
|
|
139
144
|
}
|
|
@@ -162,3 +167,93 @@ export function filterTeamSessions(sessions, showTeams) {
|
|
|
162
167
|
}
|
|
163
168
|
return { visible, hiddenCount };
|
|
164
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Whether a team-origin session is a real `agents teams` teammate or a bare SDK
|
|
172
|
+
* spawn with no teammate record. The two share the `sdk-cli` entrypoint that
|
|
173
|
+
* sets `isTeamOrigin`, so they are told apart by whether a `meta.json` teammate
|
|
174
|
+
* record backs the origin ({@link TeamOrigin.source} === `'meta'`).
|
|
175
|
+
*
|
|
176
|
+
* `source` is newer than the rest of {@link TeamOrigin}, so a row that arrived
|
|
177
|
+
* from a pre-`source` peer over the `--host` fan-out carries none. There the
|
|
178
|
+
* meta-only fields settle it: the entrypoint fallback is a bare `{ handle }`, so
|
|
179
|
+
* any `team` / `mode` / `parentSessionId` / `startedAt` means a real teammate
|
|
180
|
+
* record produced it — otherwise a genuine teammate from an unupgraded peer
|
|
181
|
+
* would be misfiled into the no-team bucket, the exact conflation this splits.
|
|
182
|
+
*/
|
|
183
|
+
export function teamRowKind(origin) {
|
|
184
|
+
if (!origin)
|
|
185
|
+
return 'subagent';
|
|
186
|
+
if (origin.source === 'meta')
|
|
187
|
+
return 'teammate';
|
|
188
|
+
if (origin.source === 'entrypoint')
|
|
189
|
+
return 'subagent';
|
|
190
|
+
return origin.team || origin.mode || origin.parentSessionId || origin.startedAt
|
|
191
|
+
? 'teammate'
|
|
192
|
+
: 'subagent';
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Key/label for the residual bucket — sessions flagged `isTeamOrigin` (an SDK /
|
|
196
|
+
* `sdk-cli` entrypoint) that carry no teammate `meta.json`. In practice this is
|
|
197
|
+
* headless `agents run` spawns and teammates whose team record aged out; a true
|
|
198
|
+
* `Task` sub-agent's transcript is never indexed, so it cannot land here. Named
|
|
199
|
+
* "(no team)" rather than "(sub-agents)" so the label matches what it holds.
|
|
200
|
+
*/
|
|
201
|
+
export const NO_TEAM_GROUP_KEY = '(no team)';
|
|
202
|
+
/** Key/label for teammates whose record carries no team name (`task_name`). */
|
|
203
|
+
export const UNNAMED_TEAM_KEY = '(unnamed team)';
|
|
204
|
+
/** Recency signal for a row: last activity, else the creation timestamp. */
|
|
205
|
+
function rowTs(s) {
|
|
206
|
+
return s.lastActivity ?? s.timestamp;
|
|
207
|
+
}
|
|
208
|
+
/** Spawn time for a row: the meta `started_at`, else the transcript creation time. */
|
|
209
|
+
function spawnTs(s) {
|
|
210
|
+
return s.teamOrigin.startedAt ?? s.timestamp;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Group team-origin sessions for the `--teams` view: each named team becomes one
|
|
214
|
+
* group of its teammates, and every bare SDK spawn with no teammate record falls
|
|
215
|
+
* into a single trailing {@link NO_TEAM_GROUP_KEY} bucket, so a real teammate and
|
|
216
|
+
* a bare spawn are never shown as the same thing.
|
|
217
|
+
*
|
|
218
|
+
* Only rows with a resolved {@link TeamOrigin} participate (the caller has
|
|
219
|
+
* already run {@link filterTeamSessions}); any non-team session is ignored.
|
|
220
|
+
* Groups are ordered newest-active first; sub-agents always sort last. Pure —
|
|
221
|
+
* unit-tested.
|
|
222
|
+
*/
|
|
223
|
+
export function groupSessionsByTeam(sessions) {
|
|
224
|
+
const byKey = new Map();
|
|
225
|
+
for (const s of sessions) {
|
|
226
|
+
if (!s.teamOrigin)
|
|
227
|
+
continue;
|
|
228
|
+
const kind = teamRowKind(s.teamOrigin);
|
|
229
|
+
const key = kind === 'subagent'
|
|
230
|
+
? NO_TEAM_GROUP_KEY
|
|
231
|
+
: safeTeamText(s.teamOrigin.team) ?? UNNAMED_TEAM_KEY;
|
|
232
|
+
const ts = s;
|
|
233
|
+
(byKey.get(key) ?? byKey.set(key, []).get(key)).push(ts);
|
|
234
|
+
}
|
|
235
|
+
const groups = [];
|
|
236
|
+
for (const [key, rows] of byKey) {
|
|
237
|
+
rows.sort((a, b) => (rowTs(a) < rowTs(b) ? 1 : rowTs(a) > rowTs(b) ? -1 : 0));
|
|
238
|
+
const kind = key === NO_TEAM_GROUP_KEY ? 'noTeam' : 'team';
|
|
239
|
+
// A single agreed-on spawner names the orchestrator for the whole team; a
|
|
240
|
+
// mix (or none) leaves it unset rather than claiming a wrong parent.
|
|
241
|
+
const parents = new Set(rows.map((r) => r.teamOrigin.parentSessionId).filter((p) => !!p));
|
|
242
|
+
groups.push({
|
|
243
|
+
key,
|
|
244
|
+
kind,
|
|
245
|
+
team: kind === 'team' && key !== UNNAMED_TEAM_KEY ? key : undefined,
|
|
246
|
+
spawnerSessionId: parents.size === 1 ? [...parents][0] : undefined,
|
|
247
|
+
sessions: rows,
|
|
248
|
+
maxTs: rowTs(rows[0]),
|
|
249
|
+
firstSpawnTs: rows.map(spawnTs).sort()[0],
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
// Newest-active team first; the no-team bucket always sinks to the bottom.
|
|
253
|
+
groups.sort((a, b) => {
|
|
254
|
+
if (a.kind !== b.kind)
|
|
255
|
+
return a.kind === 'noTeam' ? 1 : -1;
|
|
256
|
+
return a.maxTs < b.maxTs ? 1 : a.maxTs > b.maxTs ? -1 : a.key.localeCompare(b.key);
|
|
257
|
+
});
|
|
258
|
+
return groups;
|
|
259
|
+
}
|
|
@@ -117,6 +117,24 @@ export interface TeamOrigin {
|
|
|
117
117
|
* meta dir has aged past the teams cleanup window.
|
|
118
118
|
*/
|
|
119
119
|
parentSessionId?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Spawn time (`started_at` in the teammate meta.json) — when `agents teams`
|
|
122
|
+
* launched this teammate. Distinct from the session's own `timestamp` (first
|
|
123
|
+
* transcript line), and present even before the harness writes a transcript.
|
|
124
|
+
* Absent for the entrypoint-only fallback (no meta record).
|
|
125
|
+
*/
|
|
126
|
+
startedAt?: string;
|
|
127
|
+
/**
|
|
128
|
+
* How this origin was established, which is what separates a real
|
|
129
|
+
* `agents teams` teammate from a plain SDK sub-agent (a `Task` / `Agent()`
|
|
130
|
+
* spawn). Both carry the `sdk-cli` entrypoint that sets `isTeamOrigin`, so the
|
|
131
|
+
* entrypoint flag alone cannot tell them apart — only a teammate has a
|
|
132
|
+
* `meta.json` under the teams agents dir.
|
|
133
|
+
* - `'meta'` — a teammate: read from its `meta.json` record.
|
|
134
|
+
* - `'entrypoint'` — an SDK spawn with no team record (a sub-agent, or a
|
|
135
|
+
* teammate whose meta dir aged past the cleanup window).
|
|
136
|
+
*/
|
|
137
|
+
source?: 'meta' | 'entrypoint';
|
|
120
138
|
}
|
|
121
139
|
/** Lightweight metadata for a discovered session, used in listings and pickers. */
|
|
122
140
|
export interface SessionMeta {
|
|
@@ -144,8 +162,23 @@ export interface SessionMeta {
|
|
|
144
162
|
tokenCount?: number;
|
|
145
163
|
/** Real generated (output) tokens — excludes cache-read/-write context (issue: `agents output`). */
|
|
146
164
|
outputTokens?: number;
|
|
165
|
+
/**
|
|
166
|
+
* Uncached input tokens, cache-read tokens, and cache-write (cache-creation)
|
|
167
|
+
* tokens — the burn split `agents output` reports, kept only for harnesses that
|
|
168
|
+
* record a per-message cache split (Claude/Codex/Gemini/Droid). Undefined for
|
|
169
|
+
* harnesses that expose no split (RUSH-2287).
|
|
170
|
+
*/
|
|
171
|
+
inputTokens?: number;
|
|
172
|
+
cacheReadTokens?: number;
|
|
173
|
+
cacheWriteTokens?: number;
|
|
147
174
|
/** Total USD cost, computed at scan time from per-model token usage (issue #323). */
|
|
148
175
|
costUsd?: number;
|
|
176
|
+
/**
|
|
177
|
+
* USD cost priced as if caching were off — cache read/write billed at the full
|
|
178
|
+
* input rate. Backs `agents output --pricing no-cache` (RUSH-2287). Undefined
|
|
179
|
+
* when the harness records no cache split (then no-cache == actual by definition).
|
|
180
|
+
*/
|
|
181
|
+
costUsdNoCache?: number;
|
|
149
182
|
/** Wall-clock duration in ms (lastTs − firstTs), persisted at scan time. */
|
|
150
183
|
durationMs?: number;
|
|
151
184
|
/** Underlying LLM model observed in the transcript, when the agent records one. */
|