@phnx-labs/agents-cli 1.20.77 → 1.20.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +29 -3
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +4 -2
  9. package/dist/commands/secrets.js +8 -2
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +118 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +15 -1
  15. package/dist/commands/sessions-picker.js +58 -4
  16. package/dist/commands/sessions.d.ts +95 -1
  17. package/dist/commands/sessions.js +224 -26
  18. package/dist/commands/ssh.js +9 -1
  19. package/dist/index.js +3 -5
  20. package/dist/lib/agents.d.ts +0 -21
  21. package/dist/lib/agents.js +0 -37
  22. package/dist/lib/auto-pull.d.ts +16 -8
  23. package/dist/lib/auto-pull.js +23 -28
  24. package/dist/lib/daemon.d.ts +9 -41
  25. package/dist/lib/daemon.js +16 -117
  26. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  27. package/dist/lib/devices/fleet-divergence.js +188 -0
  28. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  29. package/dist/lib/devices/fleet-inventory.js +57 -0
  30. package/dist/lib/devices/health-report.d.ts +10 -2
  31. package/dist/lib/devices/health-report.js +32 -1
  32. package/dist/lib/git.d.ts +13 -0
  33. package/dist/lib/git.js +102 -4
  34. package/dist/lib/hosts/remote-cmd.js +2 -0
  35. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  36. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  38. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  39. package/dist/lib/runner.js +29 -26
  40. package/dist/lib/sandbox.js +0 -16
  41. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  42. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  43. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  44. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  45. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  46. package/dist/lib/secrets/agent.d.ts +2 -0
  47. package/dist/lib/secrets/agent.js +26 -14
  48. package/dist/lib/secrets/bundles.d.ts +1 -1
  49. package/dist/lib/secrets/bundles.js +16 -8
  50. package/dist/lib/secrets/scope.d.ts +26 -0
  51. package/dist/lib/secrets/scope.js +29 -0
  52. package/dist/lib/secrets/session-store.d.ts +10 -0
  53. package/dist/lib/secrets/session-store.js +64 -11
  54. package/dist/lib/session/active.d.ts +7 -0
  55. package/dist/lib/session/active.js +38 -3
  56. package/dist/lib/session/db.d.ts +37 -0
  57. package/dist/lib/session/db.js +112 -4
  58. package/dist/lib/session/digest.js +126 -21
  59. package/dist/lib/session/discover.d.ts +15 -2
  60. package/dist/lib/session/discover.js +19 -11
  61. package/dist/lib/session/origin-machine.d.ts +18 -0
  62. package/dist/lib/session/origin-machine.js +34 -0
  63. package/dist/lib/session/state.d.ts +1 -0
  64. package/dist/lib/session/state.js +1 -1
  65. package/dist/lib/session/sync/config.js +2 -2
  66. package/dist/lib/smart-launch.d.ts +86 -0
  67. package/dist/lib/smart-launch.js +172 -0
  68. package/package.json +1 -1
  69. package/dist/lib/secrets/account-token.d.ts +0 -20
  70. package/dist/lib/secrets/account-token.js +0 -64
@@ -99,31 +99,136 @@ export function toolHistogram(toolCounts, top = 8) {
99
99
  .sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool))
100
100
  .slice(0, top);
101
101
  }
102
- /** Recognized test/build runners → the label we show. */
102
+ /**
103
+ * Recognized test/build runners → the label we show.
104
+ *
105
+ * These match a *real invocation* of a runner, not any command that merely
106
+ * contains a runner token. In particular we must NOT fire on npm-script
107
+ * sub-targets like `bun test:setup`, `npm run test:watch`, or `pnpm test:ci` —
108
+ * those are setup/watch/CI-orchestration scripts, not a test run whose output we
109
+ * can scrape for a pass/fail verdict. A `test` token followed by `:something` is
110
+ * a distinct script name and is excluded via the `(?![:\w-])` guard.
111
+ */
103
112
  const TEST_RUNNERS = [
104
- { re: /\b((?:bun|npm|yarn|pnpm)\s+(?:run\s+)?test|vitest|jest)\b/, label: 'tests' },
105
- { re: /\bpytest\b/, label: 'pytest' },
106
- { re: /\bgo\s+test\b/, label: 'go test' },
107
- { re: /\bcargo\s+test\b/, label: 'cargo test' },
108
- { re: /\b(tsc|tsc\s+--noEmit)\b/, label: 'tsc' },
113
+ // Package-manager `test` script: `bun test`, `npm test`, `npm run test`,
114
+ // `yarn test`, `pnpm test`. The negative lookahead `(?![:\w-])` rejects
115
+ // `test:watch` / `test-ci` / `testfoo` — only the bare `test` target counts.
116
+ { re: /\b(?:bun|npm|yarn|pnpm)\s+(?:run\s+)?test(?![:\w-])/, label: 'tests' },
117
+ // Direct binaries — `vitest`, `jest`, `mocha` (optionally via a runner). These
118
+ // are real invocations; a trailing subcommand like `vitest run` is fine.
119
+ { re: /\b(?:vitest|jest|mocha)(?![:\w-])/, label: 'tests' },
120
+ { re: /\bpytest(?![:\w-])/, label: 'pytest' },
121
+ { re: /\bgo\s+test(?![:\w-])/, label: 'go test' },
122
+ { re: /\bcargo\s+test(?![:\w-])/, label: 'cargo test' },
123
+ { re: /\btsc(?:\s+--noEmit)?(?![:\w-])/, label: 'tsc' },
109
124
  ];
125
+ /**
126
+ * Anchored summary-line extractors, per runner family. Each returns a pass/fail
127
+ * verdict ONLY when it matches that runner's authoritative summary construct —
128
+ * never an arbitrary `\d+ pass`-shaped substring elsewhere in stdout. This is
129
+ * what stops `442 passwords generated`, `442 files`, or `442 passes/sec` from
130
+ * being reported as `442 pass`.
131
+ */
132
+ function parseSummaryLine(output) {
133
+ const lines = output.split(/\r?\n/);
134
+ // vitest: ` Tests 1 failed | 2 passed (3)` (mixed) or ` Tests 2 passed (2)`
135
+ // (all green). Leading indent + the `Tests` / `Test Files` label + double
136
+ // space is the summary row. Prefer the ` Tests ` row (test count) over the
137
+ // ` Test Files ` row (file count).
138
+ for (const label of ['Tests', 'Test Files']) {
139
+ for (const line of lines) {
140
+ const row = new RegExp(`^\\s*${label}\\s{2,}(.+)$`).exec(line);
141
+ if (!row)
142
+ continue;
143
+ const seg = row[1];
144
+ const passed = /(\d+)\s+passed/.exec(seg);
145
+ const failed = /(\d+)\s+failed/.exec(seg);
146
+ // Only accept a vitest row that actually reports a pass/fail count.
147
+ if (passed || failed) {
148
+ return {
149
+ passed: passed ? +passed[1] : undefined,
150
+ failed: failed ? +failed[1] : 0,
151
+ };
152
+ }
153
+ }
154
+ }
155
+ // jest: `Tests: 1 failed, 2 passed, 3 total`. The `Tests:` label anchors
156
+ // it; order of failed/passed varies.
157
+ for (const line of lines) {
158
+ const row = /^\s*Tests:\s+(.+)$/.exec(line);
159
+ if (!row)
160
+ continue;
161
+ const seg = row[1];
162
+ const passed = /(\d+)\s+passed/.exec(seg);
163
+ const failed = /(\d+)\s+failed/.exec(seg);
164
+ if (passed || failed) {
165
+ return { passed: passed ? +passed[1] : undefined, failed: failed ? +failed[1] : 0 };
166
+ }
167
+ }
168
+ // pytest: the summary rule line `==== 3 passed, 1 failed in 0.12s ====`. The
169
+ // surrounding `=` rule and the ` in <dur>s` tail anchor it to the real footer,
170
+ // not a stray `N passed` in captured stdout.
171
+ for (const line of lines) {
172
+ if (!/={3,}.*\bin\s+[\d.]+s\b.*={3,}/.test(line) && !/^={3,}.*={3,}$/.test(line))
173
+ continue;
174
+ if (!/\bin\s+[\d.]+m?s\b/.test(line))
175
+ continue;
176
+ const passed = /(\d+)\s+passed/.exec(line);
177
+ const failed = /(\d+)\s+failed/.exec(line);
178
+ const errors = /(\d+)\s+error/.exec(line);
179
+ if (passed || failed || errors) {
180
+ const failCount = (failed ? +failed[1] : 0) + (errors ? +errors[1] : 0);
181
+ return { passed: passed ? +passed[1] : undefined, failed: failed || errors ? failCount : 0 };
182
+ }
183
+ }
184
+ // bun test: a summary block of ` N pass` / ` N fail` lines (each its own line,
185
+ // leading-indented), closed by `Ran N tests across M files.`. Require the
186
+ // `Ran … tests` sentinel to be present so we only read bun's real summary
187
+ // block — bun also prints inline `(pass)`/`(fail)` per-test lines we ignore.
188
+ if (/^Ran\s+\d+\s+tests?\b/m.test(output)) {
189
+ let passed;
190
+ let failed;
191
+ for (const line of lines) {
192
+ const p = /^\s*(\d+)\s+pass$/.exec(line);
193
+ const f = /^\s*(\d+)\s+fail$/.exec(line);
194
+ if (p)
195
+ passed = +p[1];
196
+ if (f)
197
+ failed = +f[1];
198
+ }
199
+ if (passed !== undefined || failed !== undefined) {
200
+ return { passed, failed: failed ?? 0 };
201
+ }
202
+ }
203
+ // mocha: ` 2 passing` / ` 1 failing` (leading-indented, own line). The
204
+ // `passing`/`failing` gerund is mocha-specific and won't match `442 passwords`.
205
+ {
206
+ let passed;
207
+ let failed;
208
+ for (const line of lines) {
209
+ const p = /^\s*(\d+)\s+passing\b/.exec(line);
210
+ const f = /^\s*(\d+)\s+failing\b/.exec(line);
211
+ if (p)
212
+ passed = +p[1];
213
+ if (f)
214
+ failed = +f[1];
215
+ }
216
+ if (passed !== undefined || failed !== undefined) {
217
+ return { passed, failed: failed ?? 0 };
218
+ }
219
+ }
220
+ return undefined;
221
+ }
110
222
  /** Parse pass/fail counts from common runner output. */
111
223
  function parseTestOutput(runner, output) {
112
- // vitest/jest/bun: "N passed", "N failed"; pytest: "N passed, N failed".
113
- // Take the LAST occurrence runners print a per-file line first, then the
114
- // authoritative aggregate ("Tests 4 failed | 294 passed") at the end.
115
- const lastNum = (re) => {
116
- let m;
117
- let val;
118
- const g = new RegExp(re.source, 'gi');
119
- while ((m = g.exec(output)) !== null)
120
- val = +m[1];
121
- return val;
122
- };
123
- const passed = lastNum(/(\d+)\s+pass(?:ed)?/);
124
- const failed = lastNum(/(\d+)\s+fail(?:ed|ures?)?/);
125
- if (passed !== undefined || failed !== undefined) {
126
- return { passed, failed, ok: true };
224
+ // Anchor to a recognized runner summary construct (vitest ` Tests `, jest
225
+ // `Tests:`, pytest `=== N passed in Xs ===`, bun's `N pass`/`N fail` block,
226
+ // mocha `N passing`) never a blanket `\d+ pass`-shaped scrape over stdout.
227
+ // That blanket scrape used to report `442 passwords`, `442 files passed
228
+ // validation`, or a `442 passes/sec` benchmark as `442 pass`.
229
+ const summary = parseSummaryLine(output);
230
+ if (summary) {
231
+ return { ...summary, ok: true };
127
232
  }
128
233
  // tsc: no news is good news; "error TSxxxx" means failure.
129
234
  if (runner === 'tsc') {
@@ -7,6 +7,7 @@
7
7
  * subsequent queries are served entirely from the cache.
8
8
  */
9
9
  import type { SessionAgentId, SessionEvent, SessionMeta, TodoProgress } from './types.js';
10
+ export { machineForSessionFile } from './origin-machine.js';
10
11
  import { type ScanStamp } from './db.js';
11
12
  /** Options controlling which sessions to discover and how to report progress. */
12
13
  export interface DiscoverOptions {
@@ -148,7 +149,6 @@ export declare function scanAgentsBounded<T>(items: readonly T[], run: (item: T)
148
149
  * `agents add --isolated`, where the whole point was to keep the two apart.
149
150
  */
150
151
  export declare function isManagedSessionFile(filePath: string): boolean;
151
- export declare function machineForSessionFile(filePath: string, agent: string): string;
152
152
  /**
153
153
  * Count sessions in scope without running an incremental scan. Assumes the DB
154
154
  * is already fresh (typically true because `discoverSessions` ran first this
@@ -194,6 +194,20 @@ export declare function _normalizeCwdForTest(cwd?: string): string;
194
194
  * ids, whose charset is too permissive to distinguish from a search phrase.
195
195
  */
196
196
  export declare function isCompleteSessionId(query: string): boolean;
197
+ /**
198
+ * Whether a query should be treated as a session id rather than a search phrase
199
+ * — the one canonical id-shaped test, shared by every session-id resolver.
200
+ *
201
+ * True for a complete id (`isCompleteSessionId`) AND for a bare hex short-id or
202
+ * prefix (`d3470b57`), which the complete-id check rejects. Any id-shaped query
203
+ * resolves by id ONLY (exact -> prefix -> `findSessionsById` index) and must
204
+ * never fall back to fuzzy content search: a short id like `d3470b57` otherwise
205
+ * surfaces every transcript that merely MENTIONS the string (a resume prompt
206
+ * echoes the parent id into the body of many later sessions). The hex test
207
+ * catches the bare short-id/prefix; `isCompleteSessionId` additionally catches
208
+ * the prefixed whole ids (`session_…`, `ses_…`) that the hex test rejects.
209
+ */
210
+ export declare function looksLikeSessionId(query: string): boolean;
197
211
  /**
198
212
  * Resolve a session by full or short ID. Accepts a pre-loaded session list
199
213
  * (fast path from discoverSessions) and falls back to a DB lookup for the
@@ -653,4 +667,3 @@ export declare function readGrokMeta(filePath: string, currentVersion?: string):
653
667
  } | null;
654
668
  /** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
655
669
  export declare function parseTimeFilter(input: string): number;
656
- export {};
@@ -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 { machineId } from './sync/config.js';
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
@@ -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
+ }
@@ -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 as daemon.ts readDaemonClaudeOAuthToken). Using the shared
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();
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.77",
3
+ "version": "1.20.78",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,20 +0,0 @@
1
- /** The `CLAUDE_CODE_OAUTH_TOKEN_<slug>` env key for a given account email/id. */
2
- export declare function accountTokenKey(account: string): string;
3
- /**
4
- * Read the signed-in account email for a Claude version home from
5
- * `oauthAccount.emailAddress`. Sync, no keychain, no network. Tries both stores
6
- * the canonical resolver uses (agents.ts getAccountInfo): the shim-set
7
- * `CLAUDE_CONFIG_DIR` location `<home>/.claude/.claude.json` first, then the
8
- * home-level `<home>/.claude.json` (an account signed in via the IDE / direct
9
- * binary without the shim writes there). Returns null when neither has a usable
10
- * email.
11
- */
12
- export declare function readClaudeAccountEmail(home: string): string | null;
13
- /**
14
- * Resolve the per-account setup-token for the account pinned to `home`, looking
15
- * it up in the provided env (the daemon injects the `claude` bundle's
16
- * `CLAUDE_CODE_OAUTH_TOKEN_*` keys). Returns null when the home has no known
17
- * account, or no matching per-account token is present — callers then leave the
18
- * existing ambient/interactive credential untouched (a safe no-op).
19
- */
20
- export declare function resolveAccountSetupToken(env: Record<string, string | undefined>, home: string): string | null;