@phnx-labs/agents-cli 1.22.25 → 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 +427 -0
- package/README.md +35 -3
- package/dist/bin/agents +0 -0
- package/dist/browser.js +14 -4
- package/dist/commands/apply.js +52 -8
- package/dist/commands/bench.d.ts +2 -0
- package/dist/commands/bench.js +101 -0
- package/dist/commands/browser.js +35 -0
- package/dist/commands/doctor.js +32 -10
- package/dist/commands/exec.js +17 -16
- package/dist/commands/insights.d.ts +25 -19
- package/dist/commands/insights.js +146 -38
- package/dist/commands/output.js +100 -25
- package/dist/commands/reconnect.d.ts +46 -0
- package/dist/commands/reconnect.js +109 -0
- package/dist/commands/resume.d.ts +11 -0
- package/dist/commands/resume.js +51 -0
- package/dist/commands/routines.js +2 -2
- package/dist/commands/secrets.d.ts +2 -8
- package/dist/commands/secrets.js +29 -105
- package/dist/commands/sessions-picker.js +12 -7
- package/dist/commands/sessions.d.ts +47 -1
- package/dist/commands/sessions.js +349 -37
- package/dist/commands/setup-secrets.d.ts +1 -0
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/setup.d.ts +26 -3
- package/dist/commands/setup.js +105 -46
- package/dist/commands/ssh.js +184 -11
- package/dist/commands/teams.d.ts +6 -0
- package/dist/commands/teams.js +46 -3
- package/dist/commands/trends.d.ts +8 -0
- package/dist/commands/trends.js +10 -156
- package/dist/index.js +17 -21
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +29 -2
- package/dist/lib/analytics/dashboard.d.ts +10 -6
- package/dist/lib/analytics/dashboard.js +6 -4
- package/dist/lib/analytics/mix-commands.d.ts +53 -0
- package/dist/lib/analytics/mix-commands.js +229 -0
- package/dist/lib/analytics/recipes.d.ts +19 -14
- package/dist/lib/analytics/recipes.js +4 -2
- 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/browser/ipc.d.ts +26 -0
- package/dist/lib/browser/ipc.js +139 -24
- package/dist/lib/browser/profiles.d.ts +11 -0
- package/dist/lib/browser/profiles.js +1 -1
- package/dist/lib/browser/stream.d.ts +14 -0
- package/dist/lib/browser/stream.js +71 -0
- package/dist/lib/channels/owner-sink.d.ts +27 -0
- package/dist/lib/channels/owner-sink.js +93 -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 +10 -1
- package/dist/lib/devices/doctor-findings.js +47 -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.d.ts +59 -3
- package/dist/lib/fleet/apply.js +191 -12
- package/dist/lib/fleet/remote-login.d.ts +4 -3
- package/dist/lib/fleet/remote-login.js +11 -9
- package/dist/lib/fleet/types.d.ts +21 -2
- package/dist/lib/gemini-settings.d.ts +0 -1
- package/dist/lib/gemini-settings.js +12 -7
- package/dist/lib/hooks/cache.js +15 -0
- 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 +45 -0
- package/dist/lib/hosts/passthrough.js +59 -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 +6 -2
- package/dist/lib/hosts/ready.js +33 -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 +56 -21
- package/dist/lib/hosts/reconnect.js +128 -71
- 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/routines.js +14 -2
- package/dist/lib/runner.d.ts +9 -4
- package/dist/lib/runner.js +182 -30
- 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/secrets/push.d.ts +94 -0
- package/dist/lib/secrets/push.js +145 -0
- package/dist/lib/secrets/reaper.d.ts +15 -1
- package/dist/lib/secrets/reaper.js +30 -3
- package/dist/lib/self-update.d.ts +20 -5
- package/dist/lib/self-update.js +93 -16
- package/dist/lib/session/db.d.ts +38 -3
- package/dist/lib/session/db.js +273 -16
- package/dist/lib/session/discover.d.ts +22 -1
- package/dist/lib/session/discover.js +239 -51
- package/dist/lib/session/insights.d.ts +37 -0
- package/dist/lib/session/insights.js +219 -9
- 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/tool-index.js +133 -22
- package/dist/lib/session/tool-store.d.ts +26 -2
- package/dist/lib/session/tool-store.js +36 -17
- 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 +42 -3
- 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 +2 -0
- package/dist/lib/startup/command-registry.js +6 -0
- package/dist/lib/teams/agents.d.ts +13 -0
- package/dist/lib/teams/agents.js +75 -7
- package/dist/lib/teams/placement-probe.d.ts +21 -0
- package/dist/lib/teams/placement-probe.js +135 -0
- package/dist/lib/teams/scheduler.d.ts +74 -1
- package/dist/lib/teams/scheduler.js +187 -10
- 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,9 +1,9 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import { StringDecoder } from 'string_decoder';
|
|
3
|
-
import { getDB } from './db.js';
|
|
3
|
+
import { getDB, maintainSessionSearchIndex } from './db.js';
|
|
4
4
|
import { parseSession } from './parse.js';
|
|
5
5
|
import { TOOL_INDEX_VERSION, TOOL_INDEX_LIMIT_ORDINAL, ToolCallCollector, collectClaudeToolCalls, collectCodexToolCalls, toolCallEvidenceBytes, toolCallsFromEvents, } from './tool-calls.js';
|
|
6
|
-
import { persistToolCalls, purgeToolCalls, toolEvidenceSourcePath, } from './tool-store.js';
|
|
6
|
+
import { canonicalToolLedgerPath, persistToolCalls, purgeToolCalls, toolEvidenceSourcePath, } from './tool-store.js';
|
|
7
7
|
const BACKFILL_MAX_FILES = 25;
|
|
8
8
|
const BACKFILL_MAX_BYTES = 16 * 1024 * 1024;
|
|
9
9
|
const BACKFILL_MAX_IN_MEMORY_SOURCE_BYTES = 16 * 1024 * 1024;
|
|
@@ -39,16 +39,71 @@ export function toolSearchRemoteReceiveBudget(envelope) {
|
|
|
39
39
|
const localBytes = serializedToolSearchEnvelopeBytes(envelope);
|
|
40
40
|
return Math.max(0, TOOL_QUERY_MAX_SERIALIZED_BYTES - TOOL_QUERY_MERGE_OVERHEAD_BYTES - localBytes);
|
|
41
41
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The ledger columns every candidate session is judged on. Deliberately NOT
|
|
44
|
+
* `parser_state`: this runs once per session in the scan's warm path, and that
|
|
45
|
+
* column holds a serialized collector snapshot that can reach a megabyte. It is
|
|
46
|
+
* read separately, only for the sessions that turn out to need indexing.
|
|
47
|
+
*/
|
|
48
|
+
function readToolLedger(db, sessionId) {
|
|
49
|
+
return db.prepare(`
|
|
50
|
+
SELECT file_path, file_mtime_ms, file_size, extractor_version, parsed_offset
|
|
45
51
|
FROM tool_scan_ledger WHERE session_id = ?
|
|
46
52
|
`).get(sessionId);
|
|
53
|
+
}
|
|
54
|
+
function readToolParserState(db, sessionId) {
|
|
55
|
+
const row = db.prepare(`SELECT parser_state FROM tool_scan_ledger WHERE session_id = ?`)
|
|
56
|
+
.get(sessionId);
|
|
57
|
+
return row?.parser_state ?? null;
|
|
58
|
+
}
|
|
59
|
+
function needsIndex(row, stamp) {
|
|
47
60
|
return !row
|
|
48
61
|
|| row.file_mtime_ms !== stamp.fileMtimeMs
|
|
49
62
|
|| row.file_size !== stamp.fileSize
|
|
50
63
|
|| row.extractor_version !== TOOL_INDEX_VERSION;
|
|
51
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Where to start reading a session whose transcript changed.
|
|
67
|
+
*
|
|
68
|
+
* A live session's transcript is append-only, so re-reading it from byte 0 on
|
|
69
|
+
* every scan re-parses the entire history to discover the handful of records
|
|
70
|
+
* that are new — the cost that makes a large session's tool index quadratic in
|
|
71
|
+
* the number of scans. When the ledger carries a resume point that the current
|
|
72
|
+
* file still agrees with, the scan reads only the appended bytes and merges the
|
|
73
|
+
* result (`append`); anything else re-reads the whole file (`replace`).
|
|
74
|
+
*
|
|
75
|
+
* Each check below rejects a case where the stored prefix may no longer describe
|
|
76
|
+
* the file: a harness the streaming parser cannot resume, a different extractor,
|
|
77
|
+
* no recorded resume point, a source path the ledger row does not describe, a
|
|
78
|
+
* file that shrank below what was already parsed (a rewrite or truncation, not
|
|
79
|
+
* an append), or a snapshot that does not read back.
|
|
80
|
+
*/
|
|
81
|
+
function planToolScan(db, sessionId, row, sourcePath, stamp, resumable) {
|
|
82
|
+
const full = { mode: 'replace', startOffset: 0 };
|
|
83
|
+
if (!resumable || !row)
|
|
84
|
+
return full;
|
|
85
|
+
if (row.extractor_version !== TOOL_INDEX_VERSION)
|
|
86
|
+
return full;
|
|
87
|
+
if (row.parsed_offset === null)
|
|
88
|
+
return full;
|
|
89
|
+
if (row.file_path !== canonicalToolLedgerPath(sourcePath))
|
|
90
|
+
return full;
|
|
91
|
+
if (stamp.fileSize < row.file_size || stamp.fileSize < row.parsed_offset)
|
|
92
|
+
return full;
|
|
93
|
+
const parserState = readToolParserState(db, sessionId);
|
|
94
|
+
if (parserState === null)
|
|
95
|
+
return full;
|
|
96
|
+
let snapshot;
|
|
97
|
+
try {
|
|
98
|
+
snapshot = JSON.parse(parserState);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return full;
|
|
102
|
+
}
|
|
103
|
+
if (snapshot?.v !== 1 || !Number.isSafeInteger(snapshot.nextOrdinal))
|
|
104
|
+
return full;
|
|
105
|
+
return { mode: 'append', startOffset: row.parsed_offset, snapshot };
|
|
106
|
+
}
|
|
52
107
|
/** Read index completeness from SQLite only; never stat or parse transcripts. */
|
|
53
108
|
export function readToolIndexCoverage(sessions) {
|
|
54
109
|
const db = getDB();
|
|
@@ -90,14 +145,23 @@ function backfillLimitCall(session, reason) {
|
|
|
90
145
|
};
|
|
91
146
|
}
|
|
92
147
|
/** Stream Claude/Codex JSONL without ever retaining an oversized record. */
|
|
93
|
-
async function streamJsonlToolCalls(session) {
|
|
94
|
-
const collector = new ToolCallCollector();
|
|
95
|
-
const stream = fs.createReadStream(session.filePath, {
|
|
148
|
+
async function streamJsonlToolCalls(session, from = { startOffset: 0 }) {
|
|
149
|
+
const collector = new ToolCallCollector(from.snapshot);
|
|
150
|
+
const stream = fs.createReadStream(session.filePath, {
|
|
151
|
+
highWaterMark: 64 * 1024,
|
|
152
|
+
start: from.startOffset,
|
|
153
|
+
});
|
|
96
154
|
const decoder = new StringDecoder('utf8');
|
|
97
155
|
let pending = '';
|
|
98
156
|
let pendingBytes = 0;
|
|
99
157
|
let droppingOversizedLine = false;
|
|
100
158
|
let skippedOversizedLine = false;
|
|
159
|
+
// Byte offset just past the last complete record applied. Only a complete,
|
|
160
|
+
// newline-terminated record advances it, so resuming here can never re-apply a
|
|
161
|
+
// record (which would mint a second ordinal for it) nor skip a partial tail.
|
|
162
|
+
let parsedOffset = from.startOffset;
|
|
163
|
+
/** Bytes of the record currently being assembled, across chunk boundaries. */
|
|
164
|
+
let lineBytes = 0;
|
|
101
165
|
const applyLine = (line) => {
|
|
102
166
|
if (!line.trim())
|
|
103
167
|
return;
|
|
@@ -119,8 +183,14 @@ async function streamJsonlToolCalls(session) {
|
|
|
119
183
|
const newline = text.indexOf('\n', start);
|
|
120
184
|
const end = newline >= 0 ? newline : text.length;
|
|
121
185
|
const segment = text.slice(start, end);
|
|
186
|
+
const segmentBytes = Buffer.byteLength(segment);
|
|
187
|
+
// Counted outside the drop guard and across chunk boundaries: this is the
|
|
188
|
+
// record's true size on disk, which is what the resume offset is measured
|
|
189
|
+
// in. `pendingBytes` cannot stand in for it — that one resets when an
|
|
190
|
+
// oversized record is dropped, and a record split over two 64 KiB reads
|
|
191
|
+
// would lose the part carried in from the previous chunk.
|
|
192
|
+
lineBytes += segmentBytes;
|
|
122
193
|
if (!droppingOversizedLine) {
|
|
123
|
-
const segmentBytes = Buffer.byteLength(segment);
|
|
124
194
|
if (pendingBytes + segmentBytes <= BACKFILL_MAX_JSONL_RECORD_BYTES) {
|
|
125
195
|
pending += segment;
|
|
126
196
|
pendingBytes += segmentBytes;
|
|
@@ -136,6 +206,8 @@ async function streamJsonlToolCalls(session) {
|
|
|
136
206
|
break;
|
|
137
207
|
if (!droppingOversizedLine)
|
|
138
208
|
applyLine(pending);
|
|
209
|
+
parsedOffset += lineBytes + 1; // + the newline itself
|
|
210
|
+
lineBytes = 0;
|
|
139
211
|
pending = '';
|
|
140
212
|
pendingBytes = 0;
|
|
141
213
|
droppingOversizedLine = false;
|
|
@@ -145,25 +217,48 @@ async function streamJsonlToolCalls(session) {
|
|
|
145
217
|
for await (const chunk of stream)
|
|
146
218
|
consume(decoder.write(chunk));
|
|
147
219
|
consume(decoder.end());
|
|
220
|
+
// Snapshot BEFORE the unterminated trailing record, and pair it with an offset
|
|
221
|
+
// that stops short of that record. The writer may be mid-append, so the record
|
|
222
|
+
// is indexed now (its evidence is real) but is re-read by the next scan — which
|
|
223
|
+
// resumes with the same next-ordinal and so re-derives the same ordinals,
|
|
224
|
+
// making the re-read an idempotent upsert rather than a duplicate.
|
|
225
|
+
const resume = skippedOversizedLine
|
|
226
|
+
// A dropped oversized record left the ordinals and the pending map out of
|
|
227
|
+
// step with the file; nothing here can be resumed from.
|
|
228
|
+
? null
|
|
229
|
+
: { parserState: JSON.stringify(collector.snapshot()), parsedOffset };
|
|
148
230
|
if (!droppingOversizedLine && pending.length > 0)
|
|
149
231
|
applyLine(pending);
|
|
150
232
|
const calls = collector.drainChanged();
|
|
151
233
|
if (skippedOversizedLine && !calls.some((call) => call.ordinal === TOOL_INDEX_LIMIT_ORDINAL)) {
|
|
152
234
|
calls.push(backfillLimitCall(session, 'At least one JSONL record exceeded the 1 MiB tool-backfill parser limit.'));
|
|
153
235
|
}
|
|
154
|
-
return calls;
|
|
236
|
+
return { calls, resume };
|
|
237
|
+
}
|
|
238
|
+
/** True for the harnesses whose transcript the streaming parser can resume. */
|
|
239
|
+
function isResumableToolSource(agent) {
|
|
240
|
+
return agent === 'claude' || agent === 'codex';
|
|
155
241
|
}
|
|
156
|
-
async function toolCallsForBackfill(session, sourceBytes) {
|
|
157
|
-
if (session.agent
|
|
242
|
+
async function toolCallsForBackfill(session, sourceBytes, from = { startOffset: 0 }) {
|
|
243
|
+
if (isResumableToolSource(session.agent)) {
|
|
158
244
|
if (sourceBytes > BACKFILL_MAX_STREAM_SOURCE_BYTES) {
|
|
159
|
-
return
|
|
245
|
+
return {
|
|
246
|
+
calls: [backfillLimitCall(session, 'Transcript exceeds the 64 MiB safe streaming tool-backfill limit.')],
|
|
247
|
+
resume: null,
|
|
248
|
+
};
|
|
160
249
|
}
|
|
161
|
-
return streamJsonlToolCalls(session);
|
|
250
|
+
return streamJsonlToolCalls(session, from);
|
|
162
251
|
}
|
|
163
252
|
if (sourceBytes > BACKFILL_MAX_IN_MEMORY_SOURCE_BYTES) {
|
|
164
|
-
return
|
|
253
|
+
return {
|
|
254
|
+
calls: [backfillLimitCall(session, 'Transcript exceeds the 16 MiB safe in-memory tool-backfill parser limit.')],
|
|
255
|
+
resume: null,
|
|
256
|
+
};
|
|
165
257
|
}
|
|
166
|
-
|
|
258
|
+
// Every other harness is parsed whole into memory by parseSession, which
|
|
259
|
+
// exposes no byte offset to resume from — so these stay full replaces and
|
|
260
|
+
// record no resume point, rather than storing one this path cannot honour.
|
|
261
|
+
return { calls: toolCallsFromEvents(parseSession(session.filePath, session.agent)), resume: null };
|
|
167
262
|
}
|
|
168
263
|
/**
|
|
169
264
|
* Fill one bounded chunk of the independent tool index. A warm call performs
|
|
@@ -179,6 +274,7 @@ export async function ensureToolIndex(sessions, limits = {}) {
|
|
|
179
274
|
if (!session.filePath)
|
|
180
275
|
continue;
|
|
181
276
|
const sourcePath = toolEvidenceSourcePath(session.filePath, session.agent);
|
|
277
|
+
const ledger = readToolLedger(db, session.id);
|
|
182
278
|
const mustStatSource = limits.verifySourceStamps || sourcePath !== session.filePath;
|
|
183
279
|
const indexed = !mustStatSource
|
|
184
280
|
? db.prepare(`
|
|
@@ -199,8 +295,15 @@ export async function ensureToolIndex(sessions, limits = {}) {
|
|
|
199
295
|
continue;
|
|
200
296
|
}
|
|
201
297
|
}
|
|
202
|
-
if (needsIndex(
|
|
203
|
-
|
|
298
|
+
if (!needsIndex(ledger, stamp))
|
|
299
|
+
continue;
|
|
300
|
+
const plan = planToolScan(db, session.id, ledger, sourcePath, stamp, isResumableToolSource(session.agent));
|
|
301
|
+
pending.push({
|
|
302
|
+
session,
|
|
303
|
+
stamp,
|
|
304
|
+
plan,
|
|
305
|
+
readBytes: Math.max(0, stamp.fileSize - plan.startOffset),
|
|
306
|
+
});
|
|
204
307
|
}
|
|
205
308
|
let indexedFiles = 0;
|
|
206
309
|
let indexedCalls = 0;
|
|
@@ -212,13 +315,16 @@ export async function ensureToolIndex(sessions, limits = {}) {
|
|
|
212
315
|
// The byte budget is a batch boundary, not a correctness boundary. Admit
|
|
213
316
|
// one oversized transcript by itself so it can never wedge the ledger or
|
|
214
317
|
// silently disappear from results; the next invocation resumes afterward.
|
|
215
|
-
|
|
318
|
+
// Budgeted on the bytes this scan reads, not the file's size: a resumed
|
|
319
|
+
// session costs only its appended tail, so a batch can cover far more
|
|
320
|
+
// growing sessions than it could when every one was re-read whole.
|
|
321
|
+
if (attemptedFiles > 0 && consumedBytes + item.readBytes > maxBytes)
|
|
216
322
|
break;
|
|
217
323
|
attemptedFiles++;
|
|
218
|
-
consumedBytes += item.
|
|
324
|
+
consumedBytes += item.readBytes;
|
|
219
325
|
try {
|
|
220
|
-
const calls = await toolCallsForBackfill(item.session, item.stamp.fileSize);
|
|
221
|
-
persistToolCalls(db, item.session, calls, item.stamp);
|
|
326
|
+
const { calls, resume } = await toolCallsForBackfill(item.session, item.stamp.fileSize, item.plan);
|
|
327
|
+
persistToolCalls(db, item.session, calls, item.stamp, { mode: item.plan.mode, resume });
|
|
222
328
|
indexedFiles++;
|
|
223
329
|
indexedCalls += calls.length;
|
|
224
330
|
}
|
|
@@ -226,6 +332,11 @@ export async function ensureToolIndex(sessions, limits = {}) {
|
|
|
226
332
|
skippedFiles++;
|
|
227
333
|
}
|
|
228
334
|
}
|
|
335
|
+
// The scan just wrote a batch of FTS segments; pay a bounded slice of the
|
|
336
|
+
// merge they need so the index converges here instead of degrading until
|
|
337
|
+
// someone runs `agents sessions optimize` by hand (RUSH-2208).
|
|
338
|
+
if (indexedFiles > 0)
|
|
339
|
+
maintainSessionSearchIndex(db);
|
|
229
340
|
const remainingFiles = Math.max(0, pending.length - attemptedFiles);
|
|
230
341
|
const limitedSessionIds = new Set();
|
|
231
342
|
const sessionIds = sessions.map((session) => session.id);
|
|
@@ -8,8 +8,32 @@ export declare function toolEvidenceSourcePath(filePath: string, agent: string):
|
|
|
8
8
|
export declare function purgeToolCalls(db: Database.Database, sessionId: string): void;
|
|
9
9
|
/** Purge deleted direct children when a transcript directory's stamp changes. */
|
|
10
10
|
export declare function purgeMissingToolCallsInDirectory(db: Database.Database, dirPath: string, currentFilePaths: string[]): number;
|
|
11
|
-
/**
|
|
11
|
+
/** The resume point a later incremental scan starts from. */
|
|
12
|
+
export interface ToolScanResumePoint {
|
|
13
|
+
/** Serialized ToolCallCollector snapshot at `parsedOffset`. */
|
|
14
|
+
parserState: string;
|
|
15
|
+
/** Byte offset just past the last complete record consumed. */
|
|
16
|
+
parsedOffset: number;
|
|
17
|
+
}
|
|
18
|
+
export interface PersistToolCallsOptions {
|
|
19
|
+
/**
|
|
20
|
+
* `replace` drops the session's stored evidence first — correct for a parse
|
|
21
|
+
* that started at byte 0. `append` merges the batch into what is already
|
|
22
|
+
* stored and requires an existing ledger row; use it only for a parse that
|
|
23
|
+
* resumed from that row's `parsedOffset`.
|
|
24
|
+
*/
|
|
25
|
+
mode?: 'replace' | 'append';
|
|
26
|
+
/**
|
|
27
|
+
* Where a later scan may resume. Omitted (or null) clears any stored resume
|
|
28
|
+
* point, which forces the next scan of this session to re-read from byte 0 —
|
|
29
|
+
* the correct outcome whenever the parse could not cover the whole prefix
|
|
30
|
+
* (an oversized record, a size-capped transcript, a non-streaming harness).
|
|
31
|
+
*/
|
|
32
|
+
resume?: ToolScanResumePoint | null;
|
|
33
|
+
maxSessionBytes?: number;
|
|
34
|
+
}
|
|
35
|
+
/** Persist one parser batch, its file stamp, and its resume point atomically. */
|
|
12
36
|
export declare function persistToolCalls(db: Database.Database, session: SessionMeta, calls: IndexedToolCall[], sourceStamp: {
|
|
13
37
|
fileMtimeMs: number;
|
|
14
38
|
fileSize: number;
|
|
15
|
-
},
|
|
39
|
+
}, options?: PersistToolCallsOptions): void;
|
|
@@ -28,11 +28,17 @@ export function toolEvidenceSourcePath(filePath, agent) {
|
|
|
28
28
|
return filePath;
|
|
29
29
|
}
|
|
30
30
|
function deleteSessionCalls(db, sessionId) {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
const rows = db.prepare(`SELECT rowid, call_key FROM tool_calls WHERE session_id = ?`)
|
|
32
|
+
.all(sessionId);
|
|
33
|
+
const deletePrograms = db.prepare(`DELETE FROM tool_call_programs WHERE call_key = ?`);
|
|
34
|
+
const deleteOccurrences = db.prepare(`DELETE FROM tool_program_occurrences WHERE call_key = ?`);
|
|
35
|
+
// Addressed by rowid, never by the UNINDEXED call_key — see the tool_call_text
|
|
36
|
+
// schema comment in db.ts. A call_key predicate scans the whole FTS index.
|
|
37
|
+
const deleteText = db.prepare(`DELETE FROM tool_call_text WHERE rowid = ?`);
|
|
38
|
+
for (const { rowid, call_key } of rows) {
|
|
39
|
+
deletePrograms.run(call_key);
|
|
40
|
+
deleteOccurrences.run(call_key);
|
|
41
|
+
deleteText.run(rowid);
|
|
36
42
|
}
|
|
37
43
|
db.prepare(`DELETE FROM tool_calls WHERE session_id = ?`).run(sessionId);
|
|
38
44
|
}
|
|
@@ -61,8 +67,11 @@ export function purgeMissingToolCallsInDirectory(db, dirPath, currentFilePaths)
|
|
|
61
67
|
}
|
|
62
68
|
return purged;
|
|
63
69
|
}
|
|
64
|
-
/** Persist one parser batch
|
|
65
|
-
export function persistToolCalls(db, session, calls, sourceStamp,
|
|
70
|
+
/** Persist one parser batch, its file stamp, and its resume point atomically. */
|
|
71
|
+
export function persistToolCalls(db, session, calls, sourceStamp, options = {}) {
|
|
72
|
+
const mode = options.mode ?? 'replace';
|
|
73
|
+
const maxSessionBytes = options.maxSessionBytes ?? TOOL_SESSION_EVIDENCE_MAX_BYTES;
|
|
74
|
+
const resume = options.resume ?? null;
|
|
66
75
|
const sourcePath = toolEvidenceSourcePath(session.filePath, session.agent);
|
|
67
76
|
const insertCall = db.prepare(`
|
|
68
77
|
INSERT INTO tool_calls (
|
|
@@ -89,16 +98,19 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
|
|
|
89
98
|
INSERT INTO tool_program_occurrences (call_key, occurrence_ordinal, program, role)
|
|
90
99
|
VALUES (?, ?, ?, ?)
|
|
91
100
|
`);
|
|
92
|
-
|
|
101
|
+
// tool_call_text rows are addressed by the rowid of the tool_calls row they
|
|
102
|
+
// describe (db.ts schema comment): its UNINDEXED call_key cannot be seeked.
|
|
103
|
+
const insertText = db.prepare(`INSERT INTO tool_call_text (rowid, call_key, tool, input, output, error) VALUES (?, ?, ?, ?, ?, ?)`);
|
|
104
|
+
const callRowid = db.prepare(`SELECT rowid FROM tool_calls WHERE call_key = ?`);
|
|
93
105
|
const deletePrograms = db.prepare(`DELETE FROM tool_call_programs WHERE call_key = ?`);
|
|
94
106
|
const deleteOccurrences = db.prepare(`DELETE FROM tool_program_occurrences WHERE call_key = ?`);
|
|
95
|
-
const deleteText = db.prepare(`DELETE FROM tool_call_text WHERE
|
|
107
|
+
const deleteText = db.prepare(`DELETE FROM tool_call_text WHERE rowid = ?`);
|
|
96
108
|
const deleteCall = db.prepare(`DELETE FROM tool_calls WHERE call_key = ?`);
|
|
97
109
|
const writeLedger = db.prepare(`
|
|
98
110
|
INSERT INTO tool_scan_ledger (
|
|
99
111
|
session_id, file_path, file_mtime_ms, file_size, extractor_version, indexed_at, call_count,
|
|
100
|
-
evidence_bytes
|
|
101
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
112
|
+
evidence_bytes, parser_state, parsed_offset
|
|
113
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
102
114
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
103
115
|
file_path = excluded.file_path,
|
|
104
116
|
file_mtime_ms = excluded.file_mtime_ms,
|
|
@@ -106,7 +118,9 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
|
|
|
106
118
|
extractor_version = excluded.extractor_version,
|
|
107
119
|
indexed_at = excluded.indexed_at,
|
|
108
120
|
call_count = excluded.call_count,
|
|
109
|
-
evidence_bytes = excluded.evidence_bytes
|
|
121
|
+
evidence_bytes = excluded.evidence_bytes,
|
|
122
|
+
parser_state = excluded.parser_state,
|
|
123
|
+
parsed_offset = excluded.parsed_offset
|
|
110
124
|
`);
|
|
111
125
|
const txn = db.transaction(() => {
|
|
112
126
|
if (mode === 'replace')
|
|
@@ -119,7 +133,7 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
|
|
|
119
133
|
throw new Error('Append tool evidence requires an existing tool ledger row.');
|
|
120
134
|
}
|
|
121
135
|
if (mode === 'append' && calls.length === 0) {
|
|
122
|
-
writeLedger.run(session.id, ledgerPath, sourceStamp.fileMtimeMs, sourceStamp.fileSize, TOOL_INDEX_VERSION, Date.now(), priorLedger.call_count, priorLedger.evidence_bytes);
|
|
136
|
+
writeLedger.run(session.id, ledgerPath, sourceStamp.fileMtimeMs, sourceStamp.fileSize, TOOL_INDEX_VERSION, Date.now(), priorLedger.call_count, priorLedger.evidence_bytes, resume?.parserState ?? null, resume?.parsedOffset ?? null);
|
|
123
137
|
return;
|
|
124
138
|
}
|
|
125
139
|
const limitKey = toolCallKey(session.id, TOOL_INDEX_LIMIT_ORDINAL);
|
|
@@ -129,9 +143,11 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
|
|
|
129
143
|
const priorLimit = mode === 'append'
|
|
130
144
|
? existingSize.get(session.id, TOOL_INDEX_LIMIT_ORDINAL)
|
|
131
145
|
: undefined;
|
|
146
|
+
const limitRow = callRowid.get(limitKey);
|
|
132
147
|
deletePrograms.run(limitKey);
|
|
133
148
|
deleteOccurrences.run(limitKey);
|
|
134
|
-
|
|
149
|
+
if (limitRow)
|
|
150
|
+
deleteText.run(limitRow.rowid);
|
|
135
151
|
deleteCall.run(limitKey);
|
|
136
152
|
const existingRows = mode === 'append'
|
|
137
153
|
? calls.map((call) => ({
|
|
@@ -182,17 +198,20 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
|
|
|
182
198
|
for (const call of accepted) {
|
|
183
199
|
const key = toolCallKey(session.id, call.ordinal);
|
|
184
200
|
insertCall.run(key, session.id, call.ordinal, call.sourceCallId ?? null, call.timestamp, call.tool, call.input, call.outcome, call.exitCode ?? null, call.statusCode ?? null, call.errorCode ?? null, call.output ?? null, call.error ?? null, call.parseError ?? null, toolCallEvidenceBytes(call));
|
|
201
|
+
// The upsert above preserves the rowid of a call it updated, so this is
|
|
202
|
+
// the same rowid the existing text row (if any) was written under.
|
|
203
|
+
const { rowid } = callRowid.get(key);
|
|
185
204
|
deletePrograms.run(key);
|
|
186
205
|
deleteOccurrences.run(key);
|
|
187
|
-
deleteText.run(
|
|
206
|
+
deleteText.run(rowid);
|
|
188
207
|
for (const program of call.programs)
|
|
189
208
|
insertProgram.run(key, program);
|
|
190
209
|
call.programOccurrences.forEach((occurrence, occurrenceOrdinal) => {
|
|
191
210
|
insertOccurrence.run(key, occurrenceOrdinal, occurrence.program, occurrence.role);
|
|
192
211
|
});
|
|
193
|
-
insertText.run(key, call.tool, call.input, call.output ?? '', call.error ?? '');
|
|
212
|
+
insertText.run(rowid, key, call.tool, call.input, call.output ?? '', call.error ?? '');
|
|
194
213
|
}
|
|
195
|
-
writeLedger.run(session.id, ledgerPath, sourceStamp.fileMtimeMs, sourceStamp.fileSize, TOOL_INDEX_VERSION, Date.now(), count, storedBytes);
|
|
214
|
+
writeLedger.run(session.id, ledgerPath, sourceStamp.fileMtimeMs, sourceStamp.fileSize, TOOL_INDEX_VERSION, Date.now(), count, storedBytes, resume?.parserState ?? null, resume?.parsedOffset ?? null);
|
|
196
215
|
});
|
|
197
216
|
txn();
|
|
198
217
|
}
|
|
@@ -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. */
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Device
|
|
2
|
+
* Device placement for `--device auto` / `--host auto`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Explicit auto placement probes live fleet health and harness readiness.
|
|
5
|
+
* Historical affinity remains for generic host resolution callers.
|
|
6
6
|
* Account selection is separate (`--strategy balanced` / rotate.ts).
|
|
7
7
|
*/
|
|
8
8
|
import { type AffinityRow } from './session/db.js';
|
|
9
|
+
import { type DevicePlacementSignal } from './teams/scheduler.js';
|
|
10
|
+
import type { AgentType } from './teams/agents.js';
|
|
9
11
|
/** Peakiness of usage weights; >1 amplifies the most-used option. */
|
|
10
12
|
export declare const DEFAULT_AFFINITY_ALPHA = 1.3;
|
|
11
13
|
export interface WeightedCandidate {
|
|
@@ -45,6 +47,29 @@ export interface DeviceAffinityPlan {
|
|
|
45
47
|
deviceCandidates: WeightedCandidate[];
|
|
46
48
|
pickedDeviceKey: string | null;
|
|
47
49
|
}
|
|
50
|
+
/** Live placement plan used by the explicit `--device auto` surface. */
|
|
51
|
+
export interface DeviceAutoPlan {
|
|
52
|
+
/** null means the local machine won the comparison. */
|
|
53
|
+
host: string | null;
|
|
54
|
+
candidates: Array<{
|
|
55
|
+
key: string;
|
|
56
|
+
loadPercent?: number;
|
|
57
|
+
installed?: boolean;
|
|
58
|
+
signedIn?: boolean;
|
|
59
|
+
}>;
|
|
60
|
+
pickedDeviceKey: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Pick the least-loaded healthy device that can run `agent` when the harness is
|
|
64
|
+
* known. `run auto` omits the agent and ranks the same live health/load signals
|
|
65
|
+
* without an installed-harness filter. The local machine participates in the
|
|
66
|
+
* same probe; a fully unusable fleet degrades to local instead of stranding a run.
|
|
67
|
+
*/
|
|
68
|
+
export declare function resolveDeviceAuto(agent?: string, opts?: {
|
|
69
|
+
eligibleHosts?: string[];
|
|
70
|
+
localMachine?: string;
|
|
71
|
+
probe?: (pool: string[], agent?: AgentType) => Promise<Map<string, DevicePlacementSignal>>;
|
|
72
|
+
}): Promise<DeviceAutoPlan>;
|
|
48
73
|
/**
|
|
49
74
|
* Resolve host for `--device auto`. Does NOT pick harness or accounts.
|
|
50
75
|
*/
|
|
@@ -81,6 +106,7 @@ export type DeviceAutoApplyResult = {
|
|
|
81
106
|
* returns `skipped` — never throws; callers must not hard-exit.
|
|
82
107
|
*/
|
|
83
108
|
export declare function applyDeviceAutoToOptions(options: DeviceAutoHostOptions, deps?: {
|
|
84
|
-
resolve?: () =>
|
|
109
|
+
resolve?: () => DeviceAutoPlan | Promise<DeviceAutoPlan>;
|
|
110
|
+
agent?: string;
|
|
85
111
|
accountPickerRequested?: boolean;
|
|
86
|
-
}): DeviceAutoApplyResult
|
|
112
|
+
}): Promise<DeviceAutoApplyResult>;
|
package/dist/lib/smart-launch.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Device
|
|
2
|
+
* Device placement for `--device auto` / `--host auto`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Explicit auto placement probes live fleet health and harness readiness.
|
|
5
|
+
* Historical affinity remains for generic host resolution callers.
|
|
6
6
|
* Account selection is separate (`--strategy balanced` / rotate.ts).
|
|
7
7
|
*/
|
|
8
8
|
import { queryAffinityRollup } from './session/db.js';
|
|
9
9
|
import { localMachineId } from './session/origin-machine.js';
|
|
10
10
|
import { loadDevicesSync } from './devices/registry.js';
|
|
11
11
|
import { normalizeHost } from './machine-id.js';
|
|
12
|
+
import { probePoolSignals } from './teams/placement-probe.js';
|
|
13
|
+
import { pickBestDevice } from './teams/scheduler.js';
|
|
12
14
|
/** Peakiness of usage weights; >1 amplifies the most-used option. */
|
|
13
15
|
export const DEFAULT_AFFINITY_ALPHA = 1.3;
|
|
14
16
|
/**
|
|
@@ -62,6 +64,39 @@ export function listOnlineDeviceNames(localName = localMachineId()) {
|
|
|
62
64
|
}
|
|
63
65
|
return [...names];
|
|
64
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Pick the least-loaded healthy device that can run `agent` when the harness is
|
|
69
|
+
* known. `run auto` omits the agent and ranks the same live health/load signals
|
|
70
|
+
* without an installed-harness filter. The local machine participates in the
|
|
71
|
+
* same probe; a fully unusable fleet degrades to local instead of stranding a run.
|
|
72
|
+
*/
|
|
73
|
+
export async function resolveDeviceAuto(agent, opts = {}) {
|
|
74
|
+
const local = normalizeHost(opts.localMachine ?? localMachineId());
|
|
75
|
+
const pool = [...new Set((opts.eligibleHosts ?? listOnlineDeviceNames(local)).map(normalizeHost))];
|
|
76
|
+
if (!pool.includes(local))
|
|
77
|
+
pool.push(local);
|
|
78
|
+
try {
|
|
79
|
+
const signals = await (opts.probe ?? probePoolSignals)(pool, agent);
|
|
80
|
+
const picked = pickBestDevice(pool, [], { signals, agentLabel: agent });
|
|
81
|
+
return {
|
|
82
|
+
host: picked === local ? null : picked,
|
|
83
|
+
pickedDeviceKey: picked,
|
|
84
|
+
candidates: pool.map((key) => ({
|
|
85
|
+
key,
|
|
86
|
+
loadPercent: signals.get(key)?.loadPercent,
|
|
87
|
+
installed: signals.get(key)?.installed,
|
|
88
|
+
signedIn: signals.get(key)?.signedIn,
|
|
89
|
+
})),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return {
|
|
94
|
+
host: null,
|
|
95
|
+
pickedDeviceKey: local,
|
|
96
|
+
candidates: [{ key: local }],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
65
100
|
/**
|
|
66
101
|
* Resolve host for `--device auto`. Does NOT pick harness or accounts.
|
|
67
102
|
*/
|
|
@@ -120,7 +155,7 @@ const HOST_SLOTS = ['host', 'device', 'on', 'computer'];
|
|
|
120
155
|
* Mutates `options` in place. On affinity failure, clears auto slots (local) and
|
|
121
156
|
* returns `skipped` — never throws; callers must not hard-exit.
|
|
122
157
|
*/
|
|
123
|
-
export function applyDeviceAutoToOptions(options, deps = {}) {
|
|
158
|
+
export async function applyDeviceAutoToOptions(options, deps = {}) {
|
|
124
159
|
let deprecationSmart = false;
|
|
125
160
|
if (options.smart) {
|
|
126
161
|
const anyHost = HOST_SLOTS.some((k) => typeof options[k] === 'string' && options[k].trim() !== '');
|
|
@@ -133,7 +168,8 @@ export function applyDeviceAutoToOptions(options, deps = {}) {
|
|
|
133
168
|
return { attempted: false, deprecationSmart };
|
|
134
169
|
}
|
|
135
170
|
try {
|
|
136
|
-
const
|
|
171
|
+
const resolve = deps.resolve ?? (() => resolveDeviceAuto(deps.agent));
|
|
172
|
+
const plan = await resolve();
|
|
137
173
|
const concrete = plan.host; // null = local
|
|
138
174
|
for (const k of HOST_SLOTS) {
|
|
139
175
|
if (isDeviceAuto(options[k])) {
|
|
@@ -145,9 +181,9 @@ export function applyDeviceAutoToOptions(options, deps = {}) {
|
|
|
145
181
|
options.balanced = true;
|
|
146
182
|
}
|
|
147
183
|
const hostLabel = concrete ?? 'local';
|
|
148
|
-
const deviceHint = plan.
|
|
184
|
+
const deviceHint = plan.candidates
|
|
149
185
|
.slice(0, 4)
|
|
150
|
-
.map((c) => `${c.key}:${c.
|
|
186
|
+
.map((c) => `${c.key}:${c.loadPercent === undefined ? '?' : `${Math.round(c.loadPercent)}%`}`)
|
|
151
187
|
.join(', ');
|
|
152
188
|
const acctNote = accountPickerRequested ? 'accounts=picker' : 'accounts=balanced';
|
|
153
189
|
return {
|
package/dist/lib/ssh-exec.d.ts
CHANGED
|
@@ -28,6 +28,31 @@ export declare function shellQuote(s: string): string;
|
|
|
28
28
|
* long-lived `-N` tunnel can't leave a zombie ssh + socket pinned on the laptop.
|
|
29
29
|
*/
|
|
30
30
|
export declare const SSH_OPTS: readonly string[];
|
|
31
|
+
/**
|
|
32
|
+
* Hard ceiling on the stdout one peer may return before its capture is aborted.
|
|
33
|
+
* A cross-machine fan-out streams every peer's `agents … --json` into memory in
|
|
34
|
+
* parallel, so an unbounded buffer means one runaway peer (a corrupt or
|
|
35
|
+
* pathologically large payload) can exhaust the caller's heap and take the whole
|
|
36
|
+
* sweep down with it — RUSH-2065 observed ~170MB retained across a single
|
|
37
|
+
* `--active` gather. 16 MiB is far above any legitimate JSON listing yet small
|
|
38
|
+
* enough that N peers in flight stay bounded. A peer that overflows is treated as
|
|
39
|
+
* unreachable rather than trusted with partial output.
|
|
40
|
+
*/
|
|
41
|
+
export declare const REMOTE_STDOUT_MAX_BYTES: number;
|
|
42
|
+
/**
|
|
43
|
+
* Accumulate SSH stdout as UTF-8 without corrupting a multi-byte code point that
|
|
44
|
+
* a chunk boundary splits — the streaming `on('data')` callbacks hand us raw
|
|
45
|
+
* `Buffer`s, and a naive `buf.toString()` per chunk mangles any character the
|
|
46
|
+
* kernel cut in half. `StringDecoder` holds the trailing partial bytes until the
|
|
47
|
+
* next chunk completes them.
|
|
48
|
+
*/
|
|
49
|
+
export declare class RemoteUtf8Accumulator {
|
|
50
|
+
private readonly decoder;
|
|
51
|
+
private value;
|
|
52
|
+
write(chunk: Buffer): void;
|
|
53
|
+
end(): string;
|
|
54
|
+
current(): string;
|
|
55
|
+
}
|
|
31
56
|
export declare function controlOpts(): string[];
|
|
32
57
|
/**
|
|
33
58
|
* Compose an ssh connection-option prefix.
|
|
@@ -128,6 +153,8 @@ export interface SshStreamOptions {
|
|
|
128
153
|
* interactive credential-copy path (RUSH-1767).
|
|
129
154
|
*/
|
|
130
155
|
hostKeyOpts?: string[];
|
|
156
|
+
/** Additional OpenSSH argv placed before the target (for example `-i <path>`). */
|
|
157
|
+
extraSshArgs?: string[];
|
|
131
158
|
}
|
|
132
159
|
/**
|
|
133
160
|
* Foreground counterpart to `sshExec`: run `remoteCmd` on `target` with the
|