@phnx-labs/agents-cli 1.20.77 → 1.20.82
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 +589 -0
- package/README.md +27 -25
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +9 -0
- package/dist/commands/activity.js +153 -24
- package/dist/commands/apply.js +11 -4
- package/dist/commands/cli.js +1 -1
- package/dist/commands/commands.d.ts +1 -1
- package/dist/commands/commands.js +7 -6
- package/dist/commands/defaults.js +3 -11
- package/dist/commands/doctor.d.ts +74 -0
- package/dist/commands/doctor.js +718 -40
- package/dist/commands/events.js +1 -1
- package/dist/commands/exec.js +53 -8
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +87 -11
- package/dist/commands/harness.js +2 -2
- package/dist/commands/hooks.js +1 -1
- package/dist/commands/import.js +8 -4
- package/dist/commands/logs.js +6 -0
- package/dist/commands/models.js +1 -1
- package/dist/commands/packages.js +6 -6
- package/dist/commands/permissions.js +1 -1
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/pull.d.ts +1 -1
- package/dist/commands/pull.js +4 -4
- package/dist/commands/repo.d.ts +78 -0
- package/dist/commands/repo.js +195 -11
- package/dist/commands/routines.js +16 -4
- package/dist/commands/rules.js +13 -12
- package/dist/commands/secrets.d.ts +3 -1
- package/dist/commands/secrets.js +108 -34
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +118 -12
- package/dist/commands/sessions-export.d.ts +3 -0
- package/dist/commands/sessions-export.js +13 -10
- package/dist/commands/sessions-inject.d.ts +7 -4
- package/dist/commands/sessions-inject.js +18 -18
- package/dist/commands/sessions-picker.d.ts +15 -1
- package/dist/commands/sessions-picker.js +90 -7
- package/dist/commands/sessions-resume.d.ts +16 -0
- package/dist/commands/sessions-resume.js +14 -13
- package/dist/commands/sessions.d.ts +126 -1
- package/dist/commands/sessions.js +365 -42
- package/dist/commands/set.d.ts +15 -0
- package/dist/commands/set.js +79 -0
- package/dist/commands/setup-fleet.d.ts +21 -0
- package/dist/commands/setup-fleet.js +201 -0
- package/dist/commands/setup-secrets.d.ts +20 -0
- package/dist/commands/setup-secrets.js +223 -0
- package/dist/commands/setup.js +17 -3
- package/dist/commands/skills.js +1 -1
- package/dist/commands/ssh.js +9 -1
- package/dist/commands/sync.js +7 -2
- package/dist/commands/teams-picker.js +0 -1
- package/dist/commands/teams.d.ts +33 -1
- package/dist/commands/teams.js +208 -44
- package/dist/commands/versions.js +10 -5
- package/dist/commands/view.d.ts +1 -0
- package/dist/commands/view.js +11 -0
- package/dist/index.js +8 -13
- package/dist/lib/acp/harnesses.js +0 -7
- package/dist/lib/activity.d.ts +156 -0
- package/dist/lib/activity.js +282 -0
- package/dist/lib/agents.d.ts +9 -23
- package/dist/lib/agents.js +72 -100
- package/dist/lib/auto-pull.d.ts +16 -8
- package/dist/lib/auto-pull.js +23 -28
- package/dist/lib/browser/service.js +3 -0
- package/dist/lib/browser/types.d.ts +7 -0
- package/dist/lib/capabilities.js +6 -2
- package/dist/lib/crabbox/lease.js +2 -2
- package/dist/lib/crabbox/setup-copy.d.ts +4 -4
- package/dist/lib/crabbox/setup-copy.js +4 -4
- package/dist/lib/daemon.d.ts +26 -40
- package/dist/lib/daemon.js +63 -123
- package/dist/lib/devices/fleet-divergence.d.ts +101 -0
- package/dist/lib/devices/fleet-divergence.js +188 -0
- package/dist/lib/devices/fleet-inventory.d.ts +19 -0
- package/dist/lib/devices/fleet-inventory.js +57 -0
- package/dist/lib/devices/fleet.d.ts +1 -1
- package/dist/lib/devices/fleet.js +1 -1
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/doctor-diff.d.ts +20 -0
- package/dist/lib/doctor-diff.js +6 -1
- package/dist/lib/drift.d.ts +31 -11
- package/dist/lib/drift.js +58 -7
- package/dist/lib/events.d.ts +8 -1
- package/dist/lib/events.js +10 -1
- package/dist/lib/exec.js +35 -8
- package/dist/lib/feed-post.d.ts +28 -1
- package/dist/lib/feed-post.js +110 -2
- package/dist/lib/fleet/apply.js +8 -1
- package/dist/lib/fleet/auth-sync.d.ts +18 -1
- package/dist/lib/fleet/auth-sync.js +25 -11
- package/dist/lib/fleet/remote-login.js +5 -0
- package/dist/lib/git.d.ts +27 -14
- package/dist/lib/git.js +138 -43
- package/dist/lib/hooks.d.ts +42 -0
- package/dist/lib/hooks.js +137 -93
- package/dist/lib/hosts/dispatch.d.ts +28 -1
- package/dist/lib/hosts/dispatch.js +33 -5
- package/dist/lib/hosts/option.js +2 -2
- package/dist/lib/hosts/passthrough.d.ts +22 -1
- package/dist/lib/hosts/passthrough.js +238 -13
- package/dist/lib/hosts/remote-cmd.js +2 -0
- package/dist/lib/hosts/run-target.d.ts +2 -0
- package/dist/lib/hosts/run-target.js +1 -0
- package/dist/lib/hq/floor.d.ts +1 -1
- package/dist/lib/hq/floor.js +6 -1
- package/dist/lib/mcp.js +3 -71
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/menubar/notify-desktop.d.ts +15 -0
- package/dist/lib/menubar/notify-desktop.js +41 -5
- package/dist/lib/merged-resources.d.ts +11 -0
- package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
- package/dist/lib/permissions.d.ts +0 -32
- package/dist/lib/permissions.js +4 -157
- package/dist/lib/picker.js +1 -1
- package/dist/lib/platform/process.d.ts +16 -5
- package/dist/lib/platform/process.js +54 -0
- package/dist/lib/plugins.d.ts +0 -8
- package/dist/lib/plugins.js +4 -110
- package/dist/lib/project-resources.js +4 -1
- package/dist/lib/refresh.d.ts +5 -3
- package/dist/lib/refresh.js +7 -5
- package/dist/lib/resources/commands.js +4 -1
- package/dist/lib/resources/mcp.js +0 -4
- package/dist/lib/resources/permissions.d.ts +1 -1
- package/dist/lib/resources/permissions.js +1 -5
- package/dist/lib/resources/rules.js +4 -1
- package/dist/lib/resources/skills.js +4 -1
- package/dist/lib/resources/subagents.js +4 -0
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/routines.d.ts +20 -0
- package/dist/lib/routines.js +6 -0
- package/dist/lib/run-defaults.d.ts +1 -0
- package/dist/lib/run-defaults.js +9 -0
- package/dist/lib/runner.d.ts +0 -12
- package/dist/lib/runner.js +71 -29
- package/dist/lib/sandbox.js +0 -16
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/agent.d.ts +18 -0
- package/dist/lib/secrets/agent.js +131 -17
- package/dist/lib/secrets/audit.d.ts +46 -0
- package/dist/lib/secrets/audit.js +56 -0
- package/dist/lib/secrets/bundles.d.ts +12 -9
- package/dist/lib/secrets/bundles.js +47 -31
- package/dist/lib/secrets/index.d.ts +2 -1
- package/dist/lib/secrets/index.js +8 -2
- package/dist/lib/secrets/remote.d.ts +75 -2
- package/dist/lib/secrets/remote.js +139 -5
- package/dist/lib/secrets/scope.d.ts +26 -0
- package/dist/lib/secrets/scope.js +29 -0
- package/dist/lib/secrets/session-store.d.ts +10 -0
- package/dist/lib/secrets/session-store.js +64 -11
- package/dist/lib/secrets/sync.js +3 -3
- package/dist/lib/session/active.d.ts +117 -26
- package/dist/lib/session/active.js +199 -53
- package/dist/lib/session/actor-sidecar.d.ts +23 -0
- package/dist/lib/session/actor-sidecar.js +101 -0
- package/dist/lib/session/db.d.ts +41 -1
- package/dist/lib/session/db.js +173 -8
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +19 -2
- package/dist/lib/session/discover.js +32 -11
- package/dist/lib/session/hook-sessions.d.ts +9 -0
- package/dist/lib/session/hook-sessions.js +57 -8
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/pid-registry.d.ts +13 -0
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/state.d.ts +1 -0
- package/dist/lib/session/state.js +1 -1
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/session/types.d.ts +16 -0
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/dist/lib/staleness/detectors/commands.js +2 -2
- package/dist/lib/staleness/detectors/permissions.js +1 -45
- package/dist/lib/staleness/writers/commands.js +4 -5
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +1 -2
- package/dist/lib/startup/command-registry.js +2 -4
- package/dist/lib/subagents-registry.js +5 -12
- package/dist/lib/subagents.d.ts +0 -10
- package/dist/lib/subagents.js +0 -12
- package/dist/lib/teams/agents.d.ts +0 -2
- package/dist/lib/teams/agents.js +10 -27
- package/dist/lib/teams/index.d.ts +1 -1
- package/dist/lib/teams/index.js +1 -1
- package/dist/lib/types.d.ts +17 -9
- package/dist/lib/usage.d.ts +28 -5
- package/dist/lib/usage.js +271 -8
- package/dist/lib/versions.js +7 -1
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +25 -13
- package/package.json +1 -2
- package/dist/commands/check.d.ts +0 -15
- package/dist/commands/check.js +0 -180
- package/dist/commands/resources.d.ts +0 -5
- package/dist/lib/secrets/account-token.d.ts +0 -20
- package/dist/lib/secrets/account-token.js +0 -64
|
@@ -26,7 +26,8 @@ import { extractSessionTopic } from './prompt.js';
|
|
|
26
26
|
import { parseAntigravity } from './parse.js';
|
|
27
27
|
import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
|
|
28
28
|
import { costOfUsage } from '../pricing/index.js';
|
|
29
|
-
import {
|
|
29
|
+
import { machineForSessionFile } from './origin-machine.js';
|
|
30
|
+
export { machineForSessionFile } from './origin-machine.js';
|
|
30
31
|
import { mapBounded } from '../concurrency.js';
|
|
31
32
|
import { getDB, getScanStampByPath, getScanStampsForPaths, getParserStatesForPaths, getDirLedgerForPaths, recordDirScans, recordScans, syncLabels, seedLabelsFromNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, cacheLinearProject, } from './db.js';
|
|
32
33
|
import { buildRunNameMap } from './run-names.js';
|
|
@@ -223,7 +224,6 @@ function dispatchAgentScan(agent, onProgress) {
|
|
|
223
224
|
default: return Promise.resolve();
|
|
224
225
|
}
|
|
225
226
|
}
|
|
226
|
-
let _localMachineId;
|
|
227
227
|
/**
|
|
228
228
|
* The machine a discovered session originated on. Cross-machine sync mirrors a
|
|
229
229
|
* remote transcript to backups/<agent>/<machine>/<subdir>/… (see mirrorPath in
|
|
@@ -270,15 +270,6 @@ export function isManagedSessionFile(filePath) {
|
|
|
270
270
|
return !!realRoot && real.startsWith(realRoot + path.sep);
|
|
271
271
|
});
|
|
272
272
|
}
|
|
273
|
-
export function machineForSessionFile(filePath, agent) {
|
|
274
|
-
const base = path.join(getHistoryDir(), 'backups', agent) + path.sep;
|
|
275
|
-
if (filePath.startsWith(base)) {
|
|
276
|
-
const seg = filePath.slice(base.length).split(path.sep)[0];
|
|
277
|
-
if (seg)
|
|
278
|
-
return seg;
|
|
279
|
-
}
|
|
280
|
-
return (_localMachineId ??= machineId());
|
|
281
|
-
}
|
|
282
273
|
/**
|
|
283
274
|
* Count sessions in scope without running an incremental scan. Assumes the DB
|
|
284
275
|
* is already fresh (typically true because `discoverSessions` ran first this
|
|
@@ -388,6 +379,23 @@ export function isCompleteSessionId(query) {
|
|
|
388
379
|
const q = query.trim().toLowerCase();
|
|
389
380
|
return UUID_36.test(q.replace(SESSION_UUID_PREFIX, '')) || SES_ULID.test(q);
|
|
390
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Whether a query should be treated as a session id rather than a search phrase
|
|
384
|
+
* — the one canonical id-shaped test, shared by every session-id resolver.
|
|
385
|
+
*
|
|
386
|
+
* True for a complete id (`isCompleteSessionId`) AND for a bare hex short-id or
|
|
387
|
+
* prefix (`d3470b57`), which the complete-id check rejects. Any id-shaped query
|
|
388
|
+
* resolves by id ONLY (exact -> prefix -> `findSessionsById` index) and must
|
|
389
|
+
* never fall back to fuzzy content search: a short id like `d3470b57` otherwise
|
|
390
|
+
* surfaces every transcript that merely MENTIONS the string (a resume prompt
|
|
391
|
+
* echoes the parent id into the body of many later sessions). The hex test
|
|
392
|
+
* catches the bare short-id/prefix; `isCompleteSessionId` additionally catches
|
|
393
|
+
* the prefixed whole ids (`session_…`, `ses_…`) that the hex test rejects.
|
|
394
|
+
*/
|
|
395
|
+
export function looksLikeSessionId(query) {
|
|
396
|
+
const trimmed = query.trim();
|
|
397
|
+
return /^[0-9a-f-]{6,}$/i.test(trimmed) || isCompleteSessionId(trimmed);
|
|
398
|
+
}
|
|
391
399
|
/**
|
|
392
400
|
* Resolve a session by full or short ID. Accepts a pre-loaded session list
|
|
393
401
|
* (fast path from discoverSessions) and falls back to a DB lookup for the
|
|
@@ -992,6 +1000,7 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
|
|
|
992
1000
|
filePath,
|
|
993
1001
|
gitBranch: scan.gitBranch,
|
|
994
1002
|
version: scan.version,
|
|
1003
|
+
model: scan.model,
|
|
995
1004
|
account,
|
|
996
1005
|
topic: scan.topic,
|
|
997
1006
|
label,
|
|
@@ -1022,6 +1031,7 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
|
|
|
1022
1031
|
lastActivity: scan.lastActivity,
|
|
1023
1032
|
filePath,
|
|
1024
1033
|
account,
|
|
1034
|
+
model: scan.model,
|
|
1025
1035
|
label,
|
|
1026
1036
|
messageCount: scan.messageCount,
|
|
1027
1037
|
tokenCount: scan.tokenCount,
|
|
@@ -1319,6 +1329,7 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion, sc
|
|
|
1319
1329
|
filePath,
|
|
1320
1330
|
gitBranch: scan.gitBranch,
|
|
1321
1331
|
version: resolveSessionVersion('codex', filePath, scan.version, currentVersion),
|
|
1332
|
+
model: scan.model,
|
|
1322
1333
|
topic: scan.topic,
|
|
1323
1334
|
messageCount: scan.messageCount,
|
|
1324
1335
|
tokenCount: scan.tokenCount,
|
|
@@ -1537,6 +1548,7 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
|
|
|
1537
1548
|
cwd,
|
|
1538
1549
|
filePath,
|
|
1539
1550
|
version: resolveSessionVersion('gemini', filePath, embeddedVersion, currentVersion),
|
|
1551
|
+
model: sessionModel,
|
|
1540
1552
|
topic,
|
|
1541
1553
|
messageCount,
|
|
1542
1554
|
tokenCount: sawTokenCount ? tokenCount : undefined,
|
|
@@ -2154,6 +2166,7 @@ function readHermesMeta(filePath) {
|
|
|
2154
2166
|
project: platform,
|
|
2155
2167
|
filePath,
|
|
2156
2168
|
version: model,
|
|
2169
|
+
model,
|
|
2157
2170
|
topic,
|
|
2158
2171
|
messageCount: messageCount || (typeof session.message_count === 'number' ? session.message_count : undefined),
|
|
2159
2172
|
};
|
|
@@ -2252,6 +2265,7 @@ async function readDroidMeta(filePath, currentVersion) {
|
|
|
2252
2265
|
cwd,
|
|
2253
2266
|
filePath,
|
|
2254
2267
|
version: resolveSessionVersion('droid', filePath, undefined, currentVersion),
|
|
2268
|
+
model,
|
|
2255
2269
|
topic: scan.topic,
|
|
2256
2270
|
messageCount: scan.messageCount,
|
|
2257
2271
|
tokenCount,
|
|
@@ -2395,6 +2409,7 @@ export function initClaudeParseState() {
|
|
|
2395
2409
|
cwd: undefined,
|
|
2396
2410
|
gitBranch: undefined,
|
|
2397
2411
|
version: undefined,
|
|
2412
|
+
model: undefined,
|
|
2398
2413
|
topic: undefined,
|
|
2399
2414
|
customTitle: undefined,
|
|
2400
2415
|
aiTitle: undefined,
|
|
@@ -2579,6 +2594,8 @@ export function applyClaudeLine(state, parsed) {
|
|
|
2579
2594
|
// Per-assistant-message cost: each event carries its own model, so we
|
|
2580
2595
|
// multiply that event's raw token directions by that model's price.
|
|
2581
2596
|
const model = parsed.message?.model;
|
|
2597
|
+
if (typeof model === 'string' && model)
|
|
2598
|
+
state.model = model;
|
|
2582
2599
|
if (model && usageObj && typeof usageObj === 'object') {
|
|
2583
2600
|
const eventCost = costOfUsage({
|
|
2584
2601
|
model,
|
|
@@ -2611,6 +2628,7 @@ export function finalizeClaudeScan(state) {
|
|
|
2611
2628
|
cwd: state.cwd,
|
|
2612
2629
|
gitBranch: state.gitBranch,
|
|
2613
2630
|
version: state.version,
|
|
2631
|
+
model: state.model,
|
|
2614
2632
|
topic: resolvedTopic,
|
|
2615
2633
|
entrypoint: state.entrypoint,
|
|
2616
2634
|
messageCount: state.messageCount,
|
|
@@ -2676,6 +2694,7 @@ export function serializeClaudeParserState(state, offset) {
|
|
|
2676
2694
|
cwd: state.cwd,
|
|
2677
2695
|
gitBranch: state.gitBranch,
|
|
2678
2696
|
version: state.version,
|
|
2697
|
+
model: state.model,
|
|
2679
2698
|
entrypoint: state.entrypoint,
|
|
2680
2699
|
firstTsMs: state.firstTsMs,
|
|
2681
2700
|
topic: state.topic,
|
|
@@ -2736,6 +2755,7 @@ export function hydrateClaudeParseState(prior) {
|
|
|
2736
2755
|
cwd: prior.cwd,
|
|
2737
2756
|
gitBranch: prior.gitBranch,
|
|
2738
2757
|
version: prior.version,
|
|
2758
|
+
model: prior.model,
|
|
2739
2759
|
topic: prior.topic,
|
|
2740
2760
|
customTitle: prior.customTitle,
|
|
2741
2761
|
aiTitle: prior.aiTitle,
|
|
@@ -3090,6 +3110,7 @@ export function finalizeCodexScan(state) {
|
|
|
3090
3110
|
cwd: state.cwd,
|
|
3091
3111
|
gitBranch: state.gitBranch,
|
|
3092
3112
|
version: state.version,
|
|
3113
|
+
model: state.model,
|
|
3093
3114
|
topic: state.topic,
|
|
3094
3115
|
messageCount: state.messageCount,
|
|
3095
3116
|
tokenCount: state.tokenCount,
|
|
@@ -14,6 +14,15 @@ export interface HookSessionIndex {
|
|
|
14
14
|
byTerminalId: Map<string, HookSessionRecord>;
|
|
15
15
|
byPid: Map<number, HookSessionRecord>;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Read the deployed hook's record for one specific pid, or undefined if absent /
|
|
19
|
+
* corrupt / not fresh. `startedAtMs` (the live process's known start, when the
|
|
20
|
+
* caller has it) rejects a stale record left by a PRIOR process at a reused pid:
|
|
21
|
+
* the hook writes its record AFTER the agent boots, so a record whose `ts`
|
|
22
|
+
* predates the current process's start belongs to a dead predecessor. `ts` is
|
|
23
|
+
* Unix SECONDS (the hook stamps `date +%s`); `startedAtMs` is millis.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readStateSessionRecord(pid: number, startedAtMs?: number): HookSessionRecord | undefined;
|
|
17
26
|
/**
|
|
18
27
|
* Scan the hook state dir once and index every record by launch_id, terminal_id,
|
|
19
28
|
* and pid. Returns empty maps if the dir is absent. Newest-by-`ts` wins a key
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Read-only view of the SessionStart hook's live-session state files.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* Two on-disk sources carry a non-Claude agent's authoritative id (whose id we
|
|
5
|
+
* can't know at spawn), both read here:
|
|
6
|
+
* 1. The `@agents/session-tracker` hook writes
|
|
7
|
+
* `~/.agents/.cache/terminals/sessions/<pid>.json` with the id plus the join
|
|
8
|
+
* keys `launch_id` / `terminal_id`. Rich schema, but this package is NOT
|
|
9
|
+
* deployed on the fleet — its dir is empty there. Scanned into the index by
|
|
10
|
+
* {@link loadHookSessionIndex}.
|
|
11
|
+
* 2. The ACTUALLY-DEPLOYED SessionStart hook writes
|
|
12
|
+
* `~/.agents/.cache/state/sessions/<pid>.json` ({@link readStateSessionRecord})
|
|
13
|
+
* — `{session_id,cwd,pid,ts}`, keyed purely by pid. This is the real fleet id
|
|
14
|
+
* source for every agent; read targeted per-pid, never scanned (RUSH-2007).
|
|
15
|
+
* Both see agents `ag run` did NOT launch (you typing `claude` in a terminal).
|
|
11
16
|
*
|
|
12
17
|
* This module lets `sessions --active` reconcile a `ps`-discovered pid to that
|
|
13
18
|
* authoritative id. It is deliberately a small hand-rolled reader — the CLI does
|
|
@@ -23,12 +28,56 @@
|
|
|
23
28
|
*/
|
|
24
29
|
import fs from 'fs';
|
|
25
30
|
import path from 'path';
|
|
26
|
-
import { getTerminalsDir } from '../state.js';
|
|
31
|
+
import { getTerminalsDir, getRuntimeStateDir } from '../state.js';
|
|
27
32
|
function hookSessionsDir() {
|
|
28
33
|
// Sibling of the pid-registry's by-pid/ dir. The hook hardcodes this same path
|
|
29
34
|
// (packages/session-tracker/src/hook.sh); we read it, never move it.
|
|
30
35
|
return path.join(getTerminalsDir(), 'sessions');
|
|
31
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* The path the ACTUALLY-DEPLOYED SessionStart hook writes:
|
|
39
|
+
* `~/.agents/.cache/state/sessions/<pid>.json`, carrying `{session_id,cwd,pid,ts}`
|
|
40
|
+
* for EVERY agent (claude/codex/gemini/kimi/grok/antigravity). This is the fleet's
|
|
41
|
+
* real id source; the `terminals/sessions/` path above belongs to the separate
|
|
42
|
+
* `@agents/session-tracker` package, which is not deployed (its dir is empty on the
|
|
43
|
+
* fleet), so a non-Claude tmux session's id resolved ONLY through the index above
|
|
44
|
+
* never landed (RUSH-2007). These records are keyed purely by pid — no
|
|
45
|
+
* launch_id/terminal_id/agent — so they join only on pid. The dir is an unpruned
|
|
46
|
+
* graveyard (thousands of dead-pid files), so this is a TARGETED per-pid read on
|
|
47
|
+
* demand, never a full-dir scan folded into {@link loadHookSessionIndex}.
|
|
48
|
+
*/
|
|
49
|
+
function stateSessionRecordPath(pid) {
|
|
50
|
+
return path.join(getRuntimeStateDir(), 'sessions', `${pid}.json`);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Read the deployed hook's record for one specific pid, or undefined if absent /
|
|
54
|
+
* corrupt / not fresh. `startedAtMs` (the live process's known start, when the
|
|
55
|
+
* caller has it) rejects a stale record left by a PRIOR process at a reused pid:
|
|
56
|
+
* the hook writes its record AFTER the agent boots, so a record whose `ts`
|
|
57
|
+
* predates the current process's start belongs to a dead predecessor. `ts` is
|
|
58
|
+
* Unix SECONDS (the hook stamps `date +%s`); `startedAtMs` is millis.
|
|
59
|
+
*/
|
|
60
|
+
export function readStateSessionRecord(pid, startedAtMs) {
|
|
61
|
+
if (!pid || pid < 1)
|
|
62
|
+
return undefined;
|
|
63
|
+
let rec;
|
|
64
|
+
try {
|
|
65
|
+
rec = parseRecord(fs.readFileSync(stateSessionRecordPath(pid), 'utf8'));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined; // absent (the common case) or unreadable
|
|
69
|
+
}
|
|
70
|
+
if (!rec)
|
|
71
|
+
return undefined;
|
|
72
|
+
if (startedAtMs !== undefined && typeof rec.ts === 'number') {
|
|
73
|
+
// Allow a small skew: the hook fires just after exec, and ts is second-
|
|
74
|
+
// granular so it can floor to just before a sub-second process start.
|
|
75
|
+
const SKEW_MS = 5_000;
|
|
76
|
+
if (rec.ts * 1000 < startedAtMs - SKEW_MS)
|
|
77
|
+
return undefined; // reused-pid graveyard record
|
|
78
|
+
}
|
|
79
|
+
return rec;
|
|
80
|
+
}
|
|
32
81
|
function parseRecord(raw) {
|
|
33
82
|
try {
|
|
34
83
|
const o = JSON.parse(raw);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve which machine a session transcript originated on.
|
|
3
|
+
*
|
|
4
|
+
* Cross-machine sync mirrors a remote transcript to
|
|
5
|
+
* `backups/<agent>/<machine>/<subdir>/…`. Every other transcript is a live-home
|
|
6
|
+
* file on this box, so the origin is the local machine id.
|
|
7
|
+
*
|
|
8
|
+
* Kept in a leaf module (no session/db imports) so both discovery and the
|
|
9
|
+
* sessions DB upsert path can stamp `machine` without circular deps.
|
|
10
|
+
*/
|
|
11
|
+
/** Local machine id, cached for the process lifetime. */
|
|
12
|
+
export declare function localMachineId(): string;
|
|
13
|
+
/**
|
|
14
|
+
* The machine a discovered session originated on.
|
|
15
|
+
* When the path sits under the agent's backups root, the first segment below it
|
|
16
|
+
* is the origin machine id; otherwise it's the local machine.
|
|
17
|
+
*/
|
|
18
|
+
export declare function machineForSessionFile(filePath: string, agent: string): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve which machine a session transcript originated on.
|
|
3
|
+
*
|
|
4
|
+
* Cross-machine sync mirrors a remote transcript to
|
|
5
|
+
* `backups/<agent>/<machine>/<subdir>/…`. Every other transcript is a live-home
|
|
6
|
+
* file on this box, so the origin is the local machine id.
|
|
7
|
+
*
|
|
8
|
+
* Kept in a leaf module (no session/db imports) so both discovery and the
|
|
9
|
+
* sessions DB upsert path can stamp `machine` without circular deps.
|
|
10
|
+
*/
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import { getHistoryDir } from '../state.js';
|
|
13
|
+
import { machineId } from '../machine-id.js';
|
|
14
|
+
let _localMachineId;
|
|
15
|
+
/** Local machine id, cached for the process lifetime. */
|
|
16
|
+
export function localMachineId() {
|
|
17
|
+
return (_localMachineId ??= machineId());
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The machine a discovered session originated on.
|
|
21
|
+
* When the path sits under the agent's backups root, the first segment below it
|
|
22
|
+
* is the origin machine id; otherwise it's the local machine.
|
|
23
|
+
*/
|
|
24
|
+
export function machineForSessionFile(filePath, agent) {
|
|
25
|
+
if (!filePath)
|
|
26
|
+
return localMachineId();
|
|
27
|
+
const base = path.join(getHistoryDir(), 'backups', agent) + path.sep;
|
|
28
|
+
if (filePath.startsWith(base)) {
|
|
29
|
+
const seg = filePath.slice(base.length).split(path.sep)[0];
|
|
30
|
+
if (seg)
|
|
31
|
+
return seg;
|
|
32
|
+
}
|
|
33
|
+
return localMachineId();
|
|
34
|
+
}
|
|
@@ -4,6 +4,19 @@ export interface PidSessionEntry {
|
|
|
4
4
|
/** The launch session id. Present for agents launched with a known id (Claude). */
|
|
5
5
|
sessionId?: string;
|
|
6
6
|
cwd?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Resolved actor id (`resolveActor().id`) stamped at spawn — who initiated this
|
|
9
|
+
* run. A tailnet login/email for a resolved human, or `UNRESOLVED@<host>` when
|
|
10
|
+
* it can't be determined. Read back by the active-sessions path to surface an
|
|
11
|
+
* `owner` per session (RUSH-2018), so a co-located fleet shows who launched what.
|
|
12
|
+
*/
|
|
13
|
+
actor?: string;
|
|
14
|
+
/**
|
|
15
|
+
* The actor's kind (`resolveActor().kind`): `'human'` for a person-initiated
|
|
16
|
+
* run, `'agent'` for one an agent spawned. Pairs with {@link actor} the same way
|
|
17
|
+
* `AGENTS_ACTOR` / `AGENTS_ACTOR_KIND` do on the exec env.
|
|
18
|
+
*/
|
|
19
|
+
initiatedBy?: 'human' | 'agent';
|
|
7
20
|
/**
|
|
8
21
|
* The launch id minted by `ag run` and exported to the child as
|
|
9
22
|
* `AGENT_LAUNCH_ID`. The agent's SessionStart hook records the SAME id in its
|
|
@@ -64,6 +64,8 @@ export interface SessionStats {
|
|
|
64
64
|
}
|
|
65
65
|
/** Compute aggregate statistics (turns, tools, tokens, duration) from session events. */
|
|
66
66
|
export declare function computeSummaryStats(events: SessionEvent[]): SessionStats;
|
|
67
|
+
/** Strip the 'claude-' prefix and date suffix from a model identifier. */
|
|
68
|
+
export declare function shortenModel(model: string): string;
|
|
67
69
|
/** Format a duration in milliseconds as a human-readable string (e.g. '12 min', '2h 30min'). */
|
|
68
70
|
export declare function formatDuration(ms: number): string;
|
|
69
71
|
/**
|
|
@@ -220,7 +220,7 @@ export function computeSummaryStats(events) {
|
|
|
220
220
|
};
|
|
221
221
|
}
|
|
222
222
|
/** Strip the 'claude-' prefix and date suffix from a model identifier. */
|
|
223
|
-
function shortenModel(model) {
|
|
223
|
+
export function shortenModel(model) {
|
|
224
224
|
return model.replace(/^claude-/, '').replace(/-\d{8}$/, '');
|
|
225
225
|
}
|
|
226
226
|
/** Format a token count as a human-readable string (e.g. 67.5K, 1.2M). */
|
|
@@ -130,6 +130,7 @@ export declare function extractTodoProgress(args?: Record<string, any>): TodoPro
|
|
|
130
130
|
export declare function extractTodoProgressFromEvents(events: SessionEvent[]): TodoProgress | undefined;
|
|
131
131
|
/** Derive a recency-ordered, de-duplicated list of directories touched by tools. */
|
|
132
132
|
export declare function extractRecentDirectoriesTouched(events: SessionEvent[], cwd?: string): string[] | undefined;
|
|
133
|
+
export declare const WORKTREE_RE: RegExp;
|
|
133
134
|
/** Detect a worktree from the session cwd, per the `.agents/worktrees/<slug>/` convention. */
|
|
134
135
|
export declare function detectWorktree(cwd?: string, branch?: string): DetectedWorktree | undefined;
|
|
135
136
|
/** Detect a tracker ticket from free text (prompt/topic) then a branch name. */
|
|
@@ -188,7 +188,7 @@ const TICKET_BRANCH_RE = /(?:^|[/_-])([a-z]{2,6})-(\d{2,6})(?=[/_-]|$)/;
|
|
|
188
188
|
/** Keys that look like tickets but aren't — avoid false positives from branches. */
|
|
189
189
|
const TICKET_DENYLIST = new Set(['UTF', 'SHA', 'ISO', 'RFC', 'IPV', 'X86', 'ARM', 'MP', 'H']);
|
|
190
190
|
const PR_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/pull\/(\d+)/;
|
|
191
|
-
const WORKTREE_RE = /\/\.agents\/worktrees\/([^/]+)/;
|
|
191
|
+
export const WORKTREE_RE = /\/\.agents\/worktrees\/([^/]+)/;
|
|
192
192
|
/** gh invocations that create/open a PR. */
|
|
193
193
|
const GH_PR_CREATE_RE = /\bgh\s+pr\s+(?:create|new)\b/;
|
|
194
194
|
/** gh invocation that opens an issue — the created number is read from its result. */
|
|
@@ -15,8 +15,8 @@ function resolveR2Config() {
|
|
|
15
15
|
// The daemon monitor loop is headless by construction (no TTY, ever), so
|
|
16
16
|
// isHeadlessSecretsContext() is true here and this resolves broker-only — a
|
|
17
17
|
// broker miss can never pop an unattended Touch ID sheet on the user's screen
|
|
18
|
-
// (same rationale
|
|
19
|
-
// predicate rather than a literal keeps it consistent with the other callers
|
|
18
|
+
// (the same broker-only-when-headless rationale the secrets readers use). Using
|
|
19
|
+
// the shared predicate rather than a literal keeps it consistent with the other callers
|
|
20
20
|
// and lets any interactive caller of loadR2Config still prompt.
|
|
21
21
|
const { env } = readAndResolveBundleEnv(SYNC_BUNDLE, { caller: 'sessions-sync', agentOnly: isHeadlessSecretsContext() });
|
|
22
22
|
const accountId = env.R2_ACCOUNT_ID?.trim();
|
|
@@ -119,6 +119,8 @@ export interface SessionMeta {
|
|
|
119
119
|
costUsd?: number;
|
|
120
120
|
/** Wall-clock duration in ms (lastTs − firstTs), persisted at scan time. */
|
|
121
121
|
durationMs?: number;
|
|
122
|
+
/** Underlying LLM model observed in the transcript, when the agent records one. */
|
|
123
|
+
model?: string;
|
|
122
124
|
version?: string;
|
|
123
125
|
account?: string;
|
|
124
126
|
topic?: string;
|
|
@@ -190,6 +192,20 @@ export interface SessionMeta {
|
|
|
190
192
|
* undefined for sessions obtained outside that path.
|
|
191
193
|
*/
|
|
192
194
|
machine?: string;
|
|
195
|
+
/**
|
|
196
|
+
* Resolved actor id (`resolveActor().id`) who initiated this session — a
|
|
197
|
+
* tailnet login/email for a resolved human, or `UNRESOLVED@<host>`. Persisted
|
|
198
|
+
* write-once at session creation and preserved across content rescans (kept
|
|
199
|
+
* out of the DB upsert's ON CONFLICT set). Undefined for rows created before
|
|
200
|
+
* actor stamping. RUSH-2018.
|
|
201
|
+
*/
|
|
202
|
+
actor?: string;
|
|
203
|
+
/**
|
|
204
|
+
* The actor's kind (`resolveActor().kind`): `'human'` for a person-initiated
|
|
205
|
+
* run, `'agent'` for one an agent spawned. Pairs with {@link actor}, same
|
|
206
|
+
* split as `AGENTS_ACTOR` / `AGENTS_ACTOR_KIND` on the exec env. RUSH-2018.
|
|
207
|
+
*/
|
|
208
|
+
initiatedBy?: 'human' | 'agent';
|
|
193
209
|
/**
|
|
194
210
|
* True only for rows pulled from another machine over the live cross-machine
|
|
195
211
|
* fan-out (`remote-list.ts`) — their transcript is on that peer's disk, so
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device affinity for `--device auto` / `--host auto`.
|
|
3
|
+
*
|
|
4
|
+
* Picks a host from 14d session usage (launches by machine), weighted sample
|
|
5
|
+
* among online devices. Most-used has highest probability — not a hard lock.
|
|
6
|
+
* Account selection is separate (`--strategy balanced` / rotate.ts).
|
|
7
|
+
*/
|
|
8
|
+
import { type AffinityRow } from './session/db.js';
|
|
9
|
+
/** Peakiness of usage weights; >1 amplifies the most-used option. */
|
|
10
|
+
export declare const DEFAULT_AFFINITY_ALPHA = 1.3;
|
|
11
|
+
export interface WeightedCandidate {
|
|
12
|
+
key: string;
|
|
13
|
+
launches: number;
|
|
14
|
+
weight: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Convert affinity rows into positive weights: launches^α (floor 1 for any
|
|
18
|
+
* row with launches > 0 so a single-use host still participates).
|
|
19
|
+
*/
|
|
20
|
+
export declare function affinityWeights(rows: AffinityRow[], alpha?: number): WeightedCandidate[];
|
|
21
|
+
/**
|
|
22
|
+
* Weighted random sample. Returns null when candidates is empty.
|
|
23
|
+
* Pure — inject `rng` for tests.
|
|
24
|
+
*/
|
|
25
|
+
export declare function sampleWeighted(candidates: WeightedCandidate[], rng?: () => number): string | null;
|
|
26
|
+
/** Online device names from the local registry (+ always include local). */
|
|
27
|
+
export declare function listOnlineDeviceNames(localName?: string): string[];
|
|
28
|
+
export interface DeviceAffinityOptions {
|
|
29
|
+
sinceDays?: number;
|
|
30
|
+
alpha?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Eligible hosts (normalized). Defaults to online devices + local.
|
|
33
|
+
* Empty after filter → fall back to local.
|
|
34
|
+
*/
|
|
35
|
+
eligibleHosts?: string[];
|
|
36
|
+
localMachine?: string;
|
|
37
|
+
/** Injected affinity (tests). When omitted, reads sessions.db. */
|
|
38
|
+
deviceAffinity?: AffinityRow[];
|
|
39
|
+
rng?: () => number;
|
|
40
|
+
project?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface DeviceAffinityPlan {
|
|
43
|
+
/** null means run locally (do not pass --host). */
|
|
44
|
+
host: string | null;
|
|
45
|
+
deviceCandidates: WeightedCandidate[];
|
|
46
|
+
pickedDeviceKey: string | null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Resolve host for `--device auto`. Does NOT pick harness or accounts.
|
|
50
|
+
*/
|
|
51
|
+
export declare function resolveDeviceAffinity(opts?: DeviceAffinityOptions): DeviceAffinityPlan;
|
|
52
|
+
/** True when a host flag value means affinity pick. */
|
|
53
|
+
export declare function isDeviceAuto(value: string | undefined | null): boolean;
|
|
54
|
+
export type DeviceAutoHostOptions = {
|
|
55
|
+
host?: string;
|
|
56
|
+
device?: string;
|
|
57
|
+
on?: string;
|
|
58
|
+
computer?: string;
|
|
59
|
+
/** @deprecated Hidden alias for `--device auto`. */
|
|
60
|
+
smart?: boolean;
|
|
61
|
+
balanced?: boolean;
|
|
62
|
+
strategy?: string;
|
|
63
|
+
};
|
|
64
|
+
export type DeviceAutoApplyResult = {
|
|
65
|
+
/** True when affinity ran (success or degrade-to-local). */
|
|
66
|
+
attempted: boolean;
|
|
67
|
+
/** True when deprecated `--smart` was seen. */
|
|
68
|
+
deprecationSmart: boolean;
|
|
69
|
+
/** Affinity threw: host slots cleared → local; message for stderr. */
|
|
70
|
+
skipped?: string;
|
|
71
|
+
/** Present when affinity resolved without error. */
|
|
72
|
+
banner?: {
|
|
73
|
+
hostLabel: string;
|
|
74
|
+
deviceHint: string;
|
|
75
|
+
acctNote: string;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Apply `--device auto` / `--host auto` (and deprecated `--smart`) onto run options.
|
|
80
|
+
* Mutates `options` in place. On affinity failure, clears auto slots (local) and
|
|
81
|
+
* returns `skipped` — never throws; callers must not hard-exit.
|
|
82
|
+
*/
|
|
83
|
+
export declare function applyDeviceAutoToOptions(options: DeviceAutoHostOptions, deps?: {
|
|
84
|
+
resolve?: () => DeviceAffinityPlan;
|
|
85
|
+
accountPickerRequested?: boolean;
|
|
86
|
+
}): DeviceAutoApplyResult;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device affinity for `--device auto` / `--host auto`.
|
|
3
|
+
*
|
|
4
|
+
* Picks a host from 14d session usage (launches by machine), weighted sample
|
|
5
|
+
* among online devices. Most-used has highest probability — not a hard lock.
|
|
6
|
+
* Account selection is separate (`--strategy balanced` / rotate.ts).
|
|
7
|
+
*/
|
|
8
|
+
import { queryAffinityRollup } from './session/db.js';
|
|
9
|
+
import { localMachineId } from './session/origin-machine.js';
|
|
10
|
+
import { loadDevicesSync } from './devices/registry.js';
|
|
11
|
+
import { normalizeHost } from './machine-id.js';
|
|
12
|
+
/** Peakiness of usage weights; >1 amplifies the most-used option. */
|
|
13
|
+
export const DEFAULT_AFFINITY_ALPHA = 1.3;
|
|
14
|
+
/**
|
|
15
|
+
* Convert affinity rows into positive weights: launches^α (floor 1 for any
|
|
16
|
+
* row with launches > 0 so a single-use host still participates).
|
|
17
|
+
*/
|
|
18
|
+
export function affinityWeights(rows, alpha = DEFAULT_AFFINITY_ALPHA) {
|
|
19
|
+
return rows
|
|
20
|
+
.filter((r) => r.launches > 0 && r.key && r.key !== '(unknown)')
|
|
21
|
+
.map((r) => ({
|
|
22
|
+
key: r.key,
|
|
23
|
+
launches: r.launches,
|
|
24
|
+
weight: Math.max(1, Math.pow(r.launches, alpha)),
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Weighted random sample. Returns null when candidates is empty.
|
|
29
|
+
* Pure — inject `rng` for tests.
|
|
30
|
+
*/
|
|
31
|
+
export function sampleWeighted(candidates, rng = Math.random) {
|
|
32
|
+
if (candidates.length === 0)
|
|
33
|
+
return null;
|
|
34
|
+
const total = candidates.reduce((s, c) => s + c.weight, 0);
|
|
35
|
+
if (total <= 0)
|
|
36
|
+
return candidates[0].key;
|
|
37
|
+
let roll = rng() * total;
|
|
38
|
+
for (const c of candidates) {
|
|
39
|
+
roll -= c.weight;
|
|
40
|
+
if (roll <= 0)
|
|
41
|
+
return c.key;
|
|
42
|
+
}
|
|
43
|
+
return candidates[candidates.length - 1].key;
|
|
44
|
+
}
|
|
45
|
+
/** Online device names from the local registry (+ always include local). */
|
|
46
|
+
export function listOnlineDeviceNames(localName = localMachineId()) {
|
|
47
|
+
const names = new Set([normalizeHost(localName)]);
|
|
48
|
+
try {
|
|
49
|
+
const reg = loadDevicesSync();
|
|
50
|
+
for (const [name, d] of Object.entries(reg)) {
|
|
51
|
+
if (!d || typeof d !== 'object')
|
|
52
|
+
continue;
|
|
53
|
+
const online = d.tailscale?.online;
|
|
54
|
+
// No tailscale snapshot → treat as candidate (registry-only box).
|
|
55
|
+
if (online === false)
|
|
56
|
+
continue;
|
|
57
|
+
names.add(normalizeHost(name));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* registry missing — local only */
|
|
62
|
+
}
|
|
63
|
+
return [...names];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Resolve host for `--device auto`. Does NOT pick harness or accounts.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveDeviceAffinity(opts = {}) {
|
|
69
|
+
const local = normalizeHost(opts.localMachine ?? localMachineId());
|
|
70
|
+
const alpha = opts.alpha ?? DEFAULT_AFFINITY_ALPHA;
|
|
71
|
+
const rng = opts.rng ?? Math.random;
|
|
72
|
+
const sinceDays = opts.sinceDays ?? 14;
|
|
73
|
+
const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
|
|
74
|
+
const eligible = new Set((opts.eligibleHosts ?? listOnlineDeviceNames(local)).map(normalizeHost));
|
|
75
|
+
if (eligible.size === 0)
|
|
76
|
+
eligible.add(local);
|
|
77
|
+
const deviceRows = opts.deviceAffinity ??
|
|
78
|
+
queryAffinityRollup({
|
|
79
|
+
groupBy: 'machine',
|
|
80
|
+
sinceMs,
|
|
81
|
+
excludeTeamOrigin: true,
|
|
82
|
+
onlyCli: true,
|
|
83
|
+
project: opts.project,
|
|
84
|
+
});
|
|
85
|
+
// Hosts with history get launches^α; online hosts with no history explore at weight 1.
|
|
86
|
+
const launchesByHost = new Map();
|
|
87
|
+
for (const r of deviceRows) {
|
|
88
|
+
const k = normalizeHost(r.key);
|
|
89
|
+
if (!eligible.has(k) || k === '(unknown)')
|
|
90
|
+
continue;
|
|
91
|
+
launchesByHost.set(k, (launchesByHost.get(k) ?? 0) + r.launches);
|
|
92
|
+
}
|
|
93
|
+
let deviceWeights = [...eligible].map((key) => {
|
|
94
|
+
const launches = launchesByHost.get(key) ?? 0;
|
|
95
|
+
return {
|
|
96
|
+
key,
|
|
97
|
+
launches,
|
|
98
|
+
weight: launches > 0 ? Math.max(1, Math.pow(launches, alpha)) : 1,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
if (deviceWeights.length === 0) {
|
|
102
|
+
deviceWeights = [{ key: local, launches: 1, weight: 1 }];
|
|
103
|
+
}
|
|
104
|
+
const pickedDeviceKey = sampleWeighted(deviceWeights, rng);
|
|
105
|
+
const hostNorm = pickedDeviceKey ? normalizeHost(pickedDeviceKey) : local;
|
|
106
|
+
const host = hostNorm === local ? null : hostNorm;
|
|
107
|
+
return {
|
|
108
|
+
host,
|
|
109
|
+
deviceCandidates: deviceWeights,
|
|
110
|
+
pickedDeviceKey,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** True when a host flag value means affinity pick. */
|
|
114
|
+
export function isDeviceAuto(value) {
|
|
115
|
+
return typeof value === 'string' && value.trim().toLowerCase() === 'auto';
|
|
116
|
+
}
|
|
117
|
+
const HOST_SLOTS = ['host', 'device', 'on', 'computer'];
|
|
118
|
+
/**
|
|
119
|
+
* Apply `--device auto` / `--host auto` (and deprecated `--smart`) onto run options.
|
|
120
|
+
* Mutates `options` in place. On affinity failure, clears auto slots (local) and
|
|
121
|
+
* returns `skipped` — never throws; callers must not hard-exit.
|
|
122
|
+
*/
|
|
123
|
+
export function applyDeviceAutoToOptions(options, deps = {}) {
|
|
124
|
+
let deprecationSmart = false;
|
|
125
|
+
if (options.smart) {
|
|
126
|
+
const anyHost = HOST_SLOTS.some((k) => typeof options[k] === 'string' && options[k].trim() !== '');
|
|
127
|
+
if (!anyHost)
|
|
128
|
+
options.device = 'auto';
|
|
129
|
+
deprecationSmart = true;
|
|
130
|
+
}
|
|
131
|
+
const hasAuto = HOST_SLOTS.some((k) => isDeviceAuto(options[k]));
|
|
132
|
+
if (!hasAuto) {
|
|
133
|
+
return { attempted: false, deprecationSmart };
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const plan = (deps.resolve ?? (() => resolveDeviceAffinity({})))();
|
|
137
|
+
const concrete = plan.host; // null = local
|
|
138
|
+
for (const k of HOST_SLOTS) {
|
|
139
|
+
if (isDeviceAuto(options[k])) {
|
|
140
|
+
options[k] = concrete ?? undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const accountPickerRequested = deps.accountPickerRequested ?? false;
|
|
144
|
+
if (!accountPickerRequested && !options.strategy && !options.balanced) {
|
|
145
|
+
options.balanced = true;
|
|
146
|
+
}
|
|
147
|
+
const hostLabel = concrete ?? 'local';
|
|
148
|
+
const deviceHint = plan.deviceCandidates
|
|
149
|
+
.slice(0, 4)
|
|
150
|
+
.map((c) => `${c.key}:${c.launches}`)
|
|
151
|
+
.join(', ');
|
|
152
|
+
const acctNote = accountPickerRequested ? 'accounts=picker' : 'accounts=balanced';
|
|
153
|
+
return {
|
|
154
|
+
attempted: true,
|
|
155
|
+
deprecationSmart,
|
|
156
|
+
banner: { hostLabel, deviceHint, acctNote },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
// Graceful degrade: clear auto slots so the run continues locally.
|
|
161
|
+
for (const k of HOST_SLOTS) {
|
|
162
|
+
if (isDeviceAuto(options[k])) {
|
|
163
|
+
options[k] = undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
attempted: true,
|
|
168
|
+
deprecationSmart,
|
|
169
|
+
skipped: err.message,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|