@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
@@ -31,7 +31,8 @@ import { emit } from '../events.js';
31
31
  import { readMeta, getHelpersDir } from '../state.js';
32
32
  import { assertNameActiveInResourceProfile, filterNamesForActiveResourceProfile } from '../resource-profiles.js';
33
33
  import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, secretsHoldMs } from './agent.js';
34
- import { loadSession, deleteSession } from './session-store.js';
34
+ import { GLOBAL_HARNESS } from './scope.js';
35
+ import { resolveSession, deleteSession } from './session-store.js';
35
36
  import { createHash } from 'node:crypto';
36
37
  const keychainStore = {
37
38
  has: hasKeychainToken,
@@ -1043,7 +1044,7 @@ export function resolveBundleEnv(bundle, _opts = {}) {
1043
1044
  *
1044
1045
  * A read in a macOS headless context resolves broker-only (agentOnly) and fails
1045
1046
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
1046
- * the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
1047
+ * the per-caller broker-only pattern used across the headless secrets readers.
1047
1048
  */
1048
1049
  export function isHeadlessSecretsContext(env = process.env, platform = process.platform,
1049
1050
  // Injected so the TTY branch below is testable: it is the branch that decides a
@@ -1097,7 +1098,11 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1097
1098
  // file-backed bundle has none to dedup. The never-unlocked path is a single
1098
1099
  // stat (agentSocketExists) so it costs nothing when the agent isn't running.
1099
1100
  if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
1100
- const harness = opts.agent || process.env.AGENTS_AGENT_NAME || 'cli';
1101
+ // The scope this reader asks under. Falls back to the GLOBAL scope, not to a
1102
+ // literal `'cli'` harness — the broker and the durable store both resolve
1103
+ // own-harness → global (bundleScopeChain), so an unscoped unlock is visible
1104
+ // here whether this process was launched by an agent or typed in a terminal.
1105
+ const harness = opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS;
1101
1106
  const hit = agentGetSync(name, harness);
1102
1107
  if (hit) {
1103
1108
  // The agent stores the FULL bundle env. Apply the same subset filter and
@@ -1122,13 +1127,16 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1122
1127
  // Touch ID. Serve from it and re-warm the broker, so a warm bundle stays warm
1123
1128
  // across restart — this fixes BOTH the interactive re-prompt and the headless
1124
1129
  // throw below (which now fires only when there is genuinely no session).
1125
- const session = loadSession(name, Date.now(), harness);
1126
- if (session) {
1130
+ const resolved = resolveSession(name, Date.now(), harness);
1131
+ if (resolved) {
1132
+ const session = resolved.entry;
1127
1133
  const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
1128
1134
  stampLastUsed(filtered.bundle);
1129
1135
  // Re-warm the broker with the remaining TTL so later reads hit RAM and
1130
- // `agents secrets status` is honest. Best-effort; no-ops off darwin.
1131
- agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), harness);
1136
+ // `agents secrets status` is honest. Re-warm under the scope the grant was
1137
+ // MADE in (resolved.harness), never the asking scope — re-warming a global
1138
+ // grant as `claude` would silently narrow it for every other harness.
1139
+ agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), resolved.harness);
1132
1140
  emit('secrets.get', {
1133
1141
  module: 'secrets',
1134
1142
  bundle: name,
@@ -1323,7 +1331,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1323
1331
  bundlePolicy(bundle) === 'daily' &&
1324
1332
  secretsAgentAutoEnabled() &&
1325
1333
  canCacheResolvedEnv(bundle, selectedKeys, opts.keyMode)) {
1326
- agentAutoLoadSync(name, bundle, env, secretsHoldMs(), opts.agent || process.env.AGENTS_AGENT_NAME || 'cli');
1334
+ agentAutoLoadSync(name, bundle, env, secretsHoldMs(), opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS);
1327
1335
  }
1328
1336
  return { bundle, env };
1329
1337
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Harness scoping for an unlocked bundle — the shared vocabulary of the broker
3
+ * (agent.ts), the durable session store (session-store.ts), and the read path
4
+ * (bundles.ts).
5
+ *
6
+ * A grant is stored under a scope and read under a scope; the two must agree or
7
+ * the bundle is invisible. `agents secrets unlock --for <agent>` exists to NARROW
8
+ * a grant to one harness, so an unlock without it is global by definition.
9
+ *
10
+ * This module deliberately has NO imports: agent.ts and session-store.ts already
11
+ * import each other, and hanging the scope constants off either one would close
12
+ * that cycle — under ESM a cyclic `const` read can land in the temporal dead zone
13
+ * and throw at runtime even though tsc is happy.
14
+ */
15
+ /**
16
+ * Scope of an unlock that was not narrowed with `--for`: readable by every
17
+ * harness. Not a valid harness name, so it can never collide with one.
18
+ */
19
+ export declare const GLOBAL_HARNESS = "*";
20
+ /**
21
+ * Scopes a reader consults, most specific first: its own harness, then the global
22
+ * grant. This is the resolution order of the scoped-grant model — a narrow
23
+ * `--for claude` unlock stays claude-only while an unscoped unlock serves
24
+ * everyone — not a fallback papering over a miss.
25
+ */
26
+ export declare function bundleScopeChain(harness: string | undefined): string[];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Harness scoping for an unlocked bundle — the shared vocabulary of the broker
3
+ * (agent.ts), the durable session store (session-store.ts), and the read path
4
+ * (bundles.ts).
5
+ *
6
+ * A grant is stored under a scope and read under a scope; the two must agree or
7
+ * the bundle is invisible. `agents secrets unlock --for <agent>` exists to NARROW
8
+ * a grant to one harness, so an unlock without it is global by definition.
9
+ *
10
+ * This module deliberately has NO imports: agent.ts and session-store.ts already
11
+ * import each other, and hanging the scope constants off either one would close
12
+ * that cycle — under ESM a cyclic `const` read can land in the temporal dead zone
13
+ * and throw at runtime even though tsc is happy.
14
+ */
15
+ /**
16
+ * Scope of an unlock that was not narrowed with `--for`: readable by every
17
+ * harness. Not a valid harness name, so it can never collide with one.
18
+ */
19
+ export const GLOBAL_HARNESS = '*';
20
+ /**
21
+ * Scopes a reader consults, most specific first: its own harness, then the global
22
+ * grant. This is the resolution order of the scoped-grant model — a narrow
23
+ * `--for claude` unlock stays claude-only while an unscoped unlock serves
24
+ * everyone — not a fallback papering over a miss.
25
+ */
26
+ export function bundleScopeChain(harness) {
27
+ const own = harness || GLOBAL_HARNESS;
28
+ return own === GLOBAL_HARNESS ? [GLOBAL_HARNESS] : [own, GLOBAL_HARNESS];
29
+ }
@@ -74,6 +74,16 @@ export declare function readIndex(): SessionIndex;
74
74
  export declare function writeIndex(index: SessionIndex): void;
75
75
  /** Persist one unlocked bundle: write its blob no-ACL and record it in the index. */
76
76
  export declare function saveSession(name: string, entry: SessionEntry): void;
77
+ /**
78
+ * Read a session honoring the scope chain: the caller's own harness first, then
79
+ * the global grant (see bundleScopeChain in scope.ts). This is the durable-store
80
+ * twin of the broker's `get` — both must resolve identically, or a bundle would
81
+ * be readable from RAM but not after a restart.
82
+ */
83
+ export declare function resolveSession(name: string, now?: number, harness?: string): {
84
+ entry: SessionEntry;
85
+ harness: string;
86
+ } | null;
77
87
  /** Read one session blob by known name. Null when absent/expired/malformed. */
78
88
  export declare function loadSession(name: string, now?: number, harness?: string): SessionEntry | null;
79
89
  /** Remove every persisted harness grant for one bundle. */
@@ -25,6 +25,7 @@
25
25
  * persistence is an optimization, not a correctness dependency.
26
26
  */
27
27
  import { getKeychainToken, setKeychainToken, deleteKeychainToken, isKeychainBackendOverridden } from './index.js';
28
+ import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
28
29
  /** Prefix for all durable session items (device-local, no-ACL). */
29
30
  export const SESSION_ITEM_PREFIX = 'agents-cli.session.';
30
31
  /** Fixed-name index item — the ONLY thing we ever need to find without a known
@@ -116,7 +117,7 @@ export function saveSession(name, entry) {
116
117
  if (!shouldPersist())
117
118
  return;
118
119
  try {
119
- const harness = entry.harness || 'cli';
120
+ const harness = entry.harness || GLOBAL_HARNESS;
120
121
  const key = `${harness}:${name}`;
121
122
  setKeychainToken(sessionBlobItem(name, harness), JSON.stringify({ ...entry, harness }), { noAcl: true });
122
123
  writeIndex(upsertEntry(readIndex(), key, { expiresAt: entry.expiresAt, sleepPersist: entry.sleepPersist, harness }));
@@ -125,8 +126,22 @@ export function saveSession(name, entry) {
125
126
  /* best-effort — persistence is an optimization */
126
127
  }
127
128
  }
129
+ /**
130
+ * Read a session honoring the scope chain: the caller's own harness first, then
131
+ * the global grant (see bundleScopeChain in scope.ts). This is the durable-store
132
+ * twin of the broker's `get` — both must resolve identically, or a bundle would
133
+ * be readable from RAM but not after a restart.
134
+ */
135
+ export function resolveSession(name, now = Date.now(), harness = GLOBAL_HARNESS) {
136
+ for (const scope of bundleScopeChain(harness)) {
137
+ const entry = loadSession(name, now, scope);
138
+ if (entry)
139
+ return { entry, harness: scope };
140
+ }
141
+ return null;
142
+ }
128
143
  /** Read one session blob by known name. Null when absent/expired/malformed. */
129
- export function loadSession(name, now = Date.now(), harness = 'cli') {
144
+ export function loadSession(name, now = Date.now(), harness = GLOBAL_HARNESS) {
130
145
  if (!shouldPersist())
131
146
  return null;
132
147
  try {
@@ -154,7 +169,7 @@ export function deleteBundleSessions(name) {
154
169
  const scopedName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
155
170
  if (scopedName !== name)
156
171
  continue;
157
- const harness = meta.harness || 'cli';
172
+ const harness = meta.harness || GLOBAL_HARNESS;
158
173
  try {
159
174
  deleteKeychainToken(key.includes(':') ? sessionBlobItem(name, harness) : `${SESSION_ITEM_PREFIX}${name}`);
160
175
  }
@@ -164,7 +179,7 @@ export function deleteBundleSessions(name) {
164
179
  writeIndex(next);
165
180
  }
166
181
  /** Delete one bundle's session blob and prune it from the index. */
167
- export function deleteSession(name, harness = 'cli') {
182
+ export function deleteSession(name, harness = GLOBAL_HARNESS) {
168
183
  if (!shouldPersist())
169
184
  return;
170
185
  try {
@@ -185,7 +200,7 @@ export function deleteAllSessions() {
185
200
  const bundleName = name.includes(':') ? name.split(':').slice(1).join(':') : name;
186
201
  try {
187
202
  deleteKeychainToken(name.includes(':')
188
- ? sessionBlobItem(bundleName, meta.harness || 'cli')
203
+ ? sessionBlobItem(bundleName, meta.harness || GLOBAL_HARNESS)
189
204
  : `${SESSION_ITEM_PREFIX}${bundleName}`);
190
205
  }
191
206
  catch { /* keep going */ }
@@ -214,23 +229,61 @@ export function rehydrateSessions(now = Date.now()) {
214
229
  try {
215
230
  const raw = getKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
216
231
  const legacy = JSON.parse(raw);
217
- setKeychainToken(sessionBlobItem(key, 'cli'), JSON.stringify({ ...legacy, harness: 'cli' }), { noAcl: true });
232
+ setKeychainToken(sessionBlobItem(key, GLOBAL_HARNESS), JSON.stringify({ ...legacy, harness: GLOBAL_HARNESS }), { noAcl: true });
218
233
  deleteKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
219
- migratedIndex = upsertEntry(removeEntry(migratedIndex, key), `cli:${key}`, {
234
+ migratedIndex = upsertEntry(removeEntry(migratedIndex, key), `${GLOBAL_HARNESS}:${key}`, {
220
235
  expiresAt: meta.expiresAt,
221
236
  sleepPersist: meta.sleepPersist,
222
- harness: 'cli',
237
+ harness: GLOBAL_HARNESS,
223
238
  });
224
239
  }
225
240
  catch { /* malformed/absent legacy entry is pruned below */ }
226
241
  }
242
+ // Second source migration: `cli`-scoped grants predate the global scope. `cli`
243
+ // was never a harness — it was the default when no AGENTS_AGENT_NAME was set,
244
+ // i.e. "unlocked from a terminal for general use", which is exactly the global
245
+ // grant. Rewriting them means an unlock a user already paid Touch ID for keeps
246
+ // working after upgrade instead of silently becoming unreadable to every agent.
247
+ for (const [key, meta] of Object.entries(migratedIndex.bundles)) {
248
+ if (!key.startsWith('cli:'))
249
+ continue;
250
+ const bundleName = key.slice('cli:'.length);
251
+ const globalKey = `${GLOBAL_HARNESS}:${bundleName}`;
252
+ // A global grant for this bundle can already exist: the user re-runs
253
+ // `unlock` on the new code (which writes the global scope) before the
254
+ // broker restarts to run this migration. The existing global grant is the
255
+ // NEWER one, so the stale `cli` entry is discarded, never merged over it —
256
+ // overwriting would restore a superseded token and, if the stale entry had
257
+ // expired, hand its expiry to the fresh grant so the prune below deletes a
258
+ // valid unlock outright.
259
+ if (migratedIndex.bundles[globalKey]) {
260
+ try {
261
+ deleteKeychainToken(sessionBlobItem(bundleName, 'cli'));
262
+ }
263
+ catch { /* already gone */ }
264
+ migratedIndex = removeEntry(migratedIndex, key);
265
+ continue;
266
+ }
267
+ try {
268
+ const raw = getKeychainToken(sessionBlobItem(bundleName, 'cli'));
269
+ const legacy = JSON.parse(raw);
270
+ setKeychainToken(sessionBlobItem(bundleName, GLOBAL_HARNESS), JSON.stringify({ ...legacy, harness: GLOBAL_HARNESS }), { noAcl: true });
271
+ deleteKeychainToken(sessionBlobItem(bundleName, 'cli'));
272
+ migratedIndex = upsertEntry(removeEntry(migratedIndex, key), globalKey, {
273
+ expiresAt: meta.expiresAt,
274
+ sleepPersist: meta.sleepPersist,
275
+ harness: GLOBAL_HARNESS,
276
+ });
277
+ }
278
+ catch { /* malformed/absent entry is pruned below */ }
279
+ }
227
280
  writeIndex(migratedIndex);
228
281
  index.bundles = migratedIndex.bundles;
229
282
  const { survivors, expiredNames } = pruneExpired(index, now);
230
283
  for (const name of expiredNames) {
231
284
  const meta = index.bundles[name];
232
285
  try {
233
- deleteKeychainToken(sessionBlobItem(name.split(':').slice(1).join(':'), meta.harness || 'cli'));
286
+ deleteKeychainToken(sessionBlobItem(name.split(':').slice(1).join(':'), meta.harness || GLOBAL_HARNESS));
234
287
  }
235
288
  catch { /* keep going */ }
236
289
  }
@@ -239,7 +292,7 @@ export function rehydrateSessions(now = Date.now()) {
239
292
  for (const name of selectRehydratable(survivors, now)) {
240
293
  const meta = survivors.bundles[name];
241
294
  const bundleName = name.split(':').slice(1).join(':');
242
- const entry = loadSession(bundleName, now, meta.harness || 'cli');
295
+ const entry = loadSession(bundleName, now, meta.harness || GLOBAL_HARNESS);
243
296
  if (entry)
244
297
  out.push({ name: bundleName, entry });
245
298
  }
@@ -259,7 +312,7 @@ export function pruneSessionsOnSleep() {
259
312
  for (const name of deletedNames) {
260
313
  const meta = readIndex().bundles[name];
261
314
  try {
262
- deleteKeychainToken(sessionBlobItem(name.split(':').slice(1).join(':'), meta.harness || 'cli'));
315
+ deleteKeychainToken(sessionBlobItem(name.split(':').slice(1).join(':'), meta.harness || GLOBAL_HARNESS));
263
316
  }
264
317
  catch { /* keep going */ }
265
318
  }
@@ -189,6 +189,13 @@ export declare function agentKindFromComm(commRaw: string): string | undefined;
189
189
  * the start time can't be read, we keep the existence answer.
190
190
  */
191
191
  export declare function isPidAlive(pid: number, startedAtMs?: number): boolean;
192
+ /**
193
+ * Resolve a Claude transcript for `cwd` across the given project roots, newest
194
+ * mtime winning when the same id/cwd resolves in more than one version home
195
+ * (the actively-written copy is the newest). Pure over `projectRoots`, so it is
196
+ * testable against temp dirs without touching the real home directory.
197
+ */
198
+ export declare function pickClaudeSessionFileAcrossRoots(projectRoots: string[], cwd: string, sessionId?: string): string | undefined;
192
199
  /**
193
200
  * Pick a Claude transcript file within a project dir.
194
201
  *
@@ -26,7 +26,7 @@ import { AgentManager } from '../teams/agents.js';
26
26
  import { getTerminalsDir } from '../state.js';
27
27
  import { readPidSessionEntry, listPidSessionEntries, prunePidSessionRegistry } from './pid-registry.js';
28
28
  import { loadHookSessionIndex, resolveHookSessionRecord } from './hook-sessions.js';
29
- import { buildClaudeLabelMap } from './discover.js';
29
+ import { buildClaudeLabelMap, getAgentSessionDirs } from './discover.js';
30
30
  import { buildRunNameMap } from './run-names.js';
31
31
  import { latestSessionFileForCwd } from './db.js';
32
32
  import { extractSessionTopic } from './prompt.js';
@@ -67,7 +67,6 @@ export function activeStatusFromCloudStatus(status) {
67
67
  return 'queued';
68
68
  }
69
69
  }
70
- const HOME = os.homedir();
71
70
  const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
72
71
  /**
73
72
  * A process is classified `running` if its session file was touched in the
@@ -217,9 +216,45 @@ function claudeProjectDirName(cwd) {
217
216
  * Locate the active Claude session file for a process. If we know the session
218
217
  * UUID (from terminal env or team parent), prefer the exact match. Otherwise
219
218
  * fall back to the most-recent-mtime .jsonl in the project's folder.
219
+ *
220
+ * Searches EVERY version-home project root, not just the live `~/.claude`
221
+ * symlink. `~/.claude` points at the currently-installed agent version; a
222
+ * session launched under an EARLIER version keeps its transcript under that
223
+ * version's home (`…/.history/versions/claude/<ver>/home/.claude/projects/`).
224
+ * Resolving only `~/.claude/projects` meant that the instant a newer version
225
+ * was installed, every still-running older-version session lost its transcript
226
+ * here — no `sessionFile`, so no start/activity time, so `agents sessions`
227
+ * rendered it `unknown` and the watchdog skipped it as "no activity timestamp".
228
+ * `getAgentSessionDirs('claude','projects')` is the same version-aware enumerator
229
+ * the rest of the CLI uses, so this stays in lockstep with discovery.
220
230
  */
221
231
  function findClaudeSessionFile(cwd, sessionId) {
222
- return pickSessionFile(path.join(HOME, '.claude', 'projects', claudeProjectDirName(cwd)), sessionId);
232
+ return pickClaudeSessionFileAcrossRoots(getAgentSessionDirs('claude', 'projects'), cwd, sessionId);
233
+ }
234
+ /**
235
+ * Resolve a Claude transcript for `cwd` across the given project roots, newest
236
+ * mtime winning when the same id/cwd resolves in more than one version home
237
+ * (the actively-written copy is the newest). Pure over `projectRoots`, so it is
238
+ * testable against temp dirs without touching the real home directory.
239
+ */
240
+ export function pickClaudeSessionFileAcrossRoots(projectRoots, cwd, sessionId) {
241
+ const enc = claudeProjectDirName(cwd);
242
+ let best;
243
+ for (const root of projectRoots) {
244
+ const hit = pickSessionFile(path.join(root, enc), sessionId);
245
+ if (!hit)
246
+ continue;
247
+ let mtime;
248
+ try {
249
+ mtime = fs.statSync(hit).mtimeMs;
250
+ }
251
+ catch {
252
+ continue;
253
+ }
254
+ if (!best || mtime > best.mtime)
255
+ best = { path: hit, mtime };
256
+ }
257
+ return best?.path;
223
258
  }
224
259
  /**
225
260
  * Pick a Claude transcript file within a project dir.
@@ -40,6 +40,7 @@ export interface SessionRow {
40
40
  worktree_slug: string | null;
41
41
  ticket_id: string | null;
42
42
  plan: string | null;
43
+ machine: string | null;
43
44
  todos: string | null;
44
45
  recent_directories_touched: string | null;
45
46
  linear_project: string | null;
@@ -248,6 +249,42 @@ export interface UsageRollupRow {
248
249
  }
249
250
  /** What to group a usage rollup by. */
250
251
  export type UsageRollupGroup = 'agent' | 'project' | 'day';
252
+ /**
253
+ * Smart-launch affinity priors: group sessions by origin machine, harness, or
254
+ * joint (machine + agent). Ordered by launch count desc.
255
+ *
256
+ * SQL shape (device example):
257
+ * SELECT machine, COUNT(*) launches, SUM(duration_ms), SUM(token_count)
258
+ * FROM sessions
259
+ * WHERE timestamp >= ? AND origin = 'cli' AND is_team_origin = 0
260
+ * GROUP BY machine ORDER BY launches DESC
261
+ *
262
+ * Account rotation is NOT done here — that stays on live rate-limit windows
263
+ * via `--strategy balanced` / rotate.ts.
264
+ */
265
+ export type AffinityGroup = 'machine' | 'agent' | 'machine_agent';
266
+ export interface AffinityRow {
267
+ /** Group key: machine name, agent id, or "machine\\tagent". */
268
+ key: string;
269
+ machine?: string;
270
+ agent?: string;
271
+ launches: number;
272
+ durationMs: number;
273
+ tokenCount: number;
274
+ costUsd: number;
275
+ }
276
+ export declare function queryAffinityRollup(options: {
277
+ groupBy: AffinityGroup;
278
+ /** ISO cutoff or ms; defaults to 14 days ago when omitted. */
279
+ sinceMs?: number;
280
+ /** Restrict to these harnesses (e.g. claude/codex/kimi). */
281
+ agents?: SessionAgentId[];
282
+ /** Default true: only origin=cli rows. */
283
+ onlyCli?: boolean;
284
+ /** Default true: drop team-spawned sessions. */
285
+ excludeTeamOrigin?: boolean;
286
+ project?: string;
287
+ }): AffinityRow[];
251
288
  /**
252
289
  * Aggregate cost / duration / tokens across sessions, grouped by agent,
253
290
  * project, or calendar day. Honors the same filter shape as querySessions
@@ -12,10 +12,11 @@ import Database from '../sqlite.js';
12
12
  import { parseSession } from './parse.js';
13
13
  import { extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
14
14
  import { getSessionsDir, getSessionsDbPath } from '../state.js';
15
+ import { machineForSessionFile } from './origin-machine.js';
15
16
  const SESSIONS_DIR = getSessionsDir();
16
17
  const DB_PATH = getSessionsDbPath();
17
18
  /** Current schema version; bumped when migrations are added. */
18
- const SCHEMA_VERSION = 17;
19
+ const SCHEMA_VERSION = 18;
19
20
  /**
20
21
  * Canonicalize a file path for use as a scan_ledger key. The same physical
21
22
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
@@ -72,6 +73,7 @@ CREATE TABLE IF NOT EXISTS sessions (
72
73
  worktree_slug TEXT,
73
74
  ticket_id TEXT,
74
75
  plan TEXT,
76
+ machine TEXT,
75
77
  todos TEXT,
76
78
  recent_directories_touched TEXT,
77
79
  linear_project TEXT,
@@ -82,6 +84,8 @@ CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
82
84
  CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent);
83
85
  CREATE INDEX IF NOT EXISTS idx_sessions_file_path ON sessions(file_path);
84
86
  CREATE INDEX IF NOT EXISTS idx_sessions_short_id ON sessions(short_id);
87
+ -- idx_sessions_machine_ts / idx_sessions_agent_ts are created after migration
88
+ -- v17 guarantees the machine column exists (same pattern as last_activity).
85
89
 
86
90
  CREATE VIRTUAL TABLE IF NOT EXISTS session_text USING fts5(
87
91
  session_id UNINDEXED,
@@ -323,6 +327,7 @@ function migrateSchema(db, fromVersion) {
323
327
  db.exec(`UPDATE sessions SET short_id = substr(id, 1, 8) WHERE short_id IS NULL OR short_id = ''`);
324
328
  }
325
329
  if (fromVersion < 17) {
330
+ // v16 → v17 (main): todos / recent dirs / linear project metadata.
326
331
  const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
327
332
  if (!cols.some(c => c.name === 'todos'))
328
333
  db.exec(`ALTER TABLE sessions ADD COLUMN todos TEXT`);
@@ -334,6 +339,27 @@ function migrateSchema(db, fromVersion) {
334
339
  db.exec(`ALTER TABLE sessions ADD COLUMN linear_project_url TEXT`);
335
340
  db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
336
341
  }
342
+ if (fromVersion < 18) {
343
+ // v17 → v18: persist origin machine for smart-launch affinity GROUP BY machine.
344
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
345
+ if (!cols.some(c => c.name === 'machine')) {
346
+ db.exec(`ALTER TABLE sessions ADD COLUMN machine TEXT`);
347
+ }
348
+ db.exec(`
349
+ CREATE INDEX IF NOT EXISTS idx_sessions_machine_ts ON sessions(machine, timestamp DESC);
350
+ CREATE INDEX IF NOT EXISTS idx_sessions_agent_ts ON sessions(agent, timestamp DESC);
351
+ `);
352
+ const rows = db
353
+ .prepare(`SELECT id, agent, file_path FROM sessions WHERE machine IS NULL OR machine = ''`)
354
+ .all();
355
+ const upd = db.prepare(`UPDATE sessions SET machine = ? WHERE id = ?`);
356
+ const txn = db.transaction((items) => {
357
+ for (const r of items) {
358
+ upd.run(machineForSessionFile(r.file_path, r.agent), r.id);
359
+ }
360
+ });
361
+ txn(rows);
362
+ }
337
363
  }
338
364
  /** Open (or return the cached) sessions database, applying migrations as needed. */
339
365
  export function getDB() {
@@ -367,6 +393,27 @@ export function getDB() {
367
393
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)`);
368
394
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_origin ON sessions(origin)`);
369
395
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_routine_run_id ON sessions(routine_run_id)`);
396
+ // machine column + indexes: only after the column is guaranteed present.
397
+ // Fresh SCHEMA (v17) includes the column; older DBs get it from migrate v17.
398
+ // If a partial upgrade left schema_version ahead of the column, repair here.
399
+ {
400
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
401
+ if (!cols.some((c) => c.name === 'machine')) {
402
+ db.exec(`ALTER TABLE sessions ADD COLUMN machine TEXT`);
403
+ const rows = db
404
+ .prepare(`SELECT id, agent, file_path FROM sessions WHERE machine IS NULL OR machine = ''`)
405
+ .all();
406
+ const upd = db.prepare(`UPDATE sessions SET machine = ? WHERE id = ?`);
407
+ const txn = db.transaction((items) => {
408
+ for (const r of items) {
409
+ upd.run(machineForSessionFile(r.file_path, r.agent), r.id);
410
+ }
411
+ });
412
+ txn(rows);
413
+ }
414
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_machine_ts ON sessions(machine, timestamp DESC)`);
415
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_agent_ts ON sessions(agent, timestamp DESC)`);
416
+ }
370
417
  // One-shot cleanup of the pre-SQLite JSONL indexes. Safe — nothing reads
371
418
  // them anymore. Guarded by a meta flag so we only try once.
372
419
  const cleaned = db.prepare(`SELECT value FROM meta WHERE key = 'legacy_indexes_removed'`).get();
@@ -675,7 +722,7 @@ const upsertSessionStmt = (db) => db.prepare(`
675
722
  output_tokens, cost_usd, duration_ms,
676
723
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
677
724
  pr_url, pr_number, worktree_slug, ticket_id, plan, todos,
678
- recent_directories_touched, linear_project, linear_project_url
725
+ recent_directories_touched, linear_project, linear_project_url, machine
679
726
  ) VALUES (
680
727
  @id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
681
728
  @version, @account, @timestamp, @last_activity,
@@ -683,7 +730,7 @@ const upsertSessionStmt = (db) => db.prepare(`
683
730
  @output_tokens, @cost_usd, @duration_ms,
684
731
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
685
732
  @pr_url, @pr_number, @worktree_slug, @ticket_id, @plan, @todos,
686
- @recent_directories_touched, @linear_project, @linear_project_url
733
+ @recent_directories_touched, @linear_project, @linear_project_url, @machine
687
734
  )
688
735
  ON CONFLICT(id) DO UPDATE SET
689
736
  short_id = excluded.short_id,
@@ -732,7 +779,8 @@ const upsertSessionStmt = (db) => db.prepare(`
732
779
  linear_project_url = CASE
733
780
  WHEN excluded.ticket_id IS NOT sessions.ticket_id THEN excluded.linear_project_url
734
781
  ELSE COALESCE(excluded.linear_project_url, sessions.linear_project_url)
735
- END
782
+ END,
783
+ machine = excluded.machine
736
784
  `);
737
785
  function enrichCachedSessionMeta(meta) {
738
786
  if (!meta.filePath)
@@ -778,6 +826,12 @@ function storedFtsLabel(readLabel, id) {
778
826
  const row = readLabel.get(id);
779
827
  return row?.label ?? '';
780
828
  }
829
+ /** Resolve origin machine for a row: prefer caller-stamped meta, else path. */
830
+ function resolveMachine(meta) {
831
+ if (meta.machine && meta.machine.trim())
832
+ return meta.machine.trim();
833
+ return machineForSessionFile(meta.filePath, meta.agent);
834
+ }
781
835
  /**
782
836
  * Upsert a session row and replace its FTS5 content in a single transaction.
783
837
  * `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
@@ -821,6 +875,7 @@ export function upsertSession(meta, content, scan) {
821
875
  recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
822
876
  linear_project: meta.linearProject ?? null,
823
877
  linear_project_url: meta.linearProjectUrl ?? null,
878
+ machine: resolveMachine(meta),
824
879
  };
825
880
  const txn = db.transaction(() => {
826
881
  upsert.run(row);
@@ -931,6 +986,7 @@ export function upsertSessionsBatch(entries) {
931
986
  recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
932
987
  linear_project: meta.linearProject ?? null,
933
988
  linear_project_url: meta.linearProjectUrl ?? null,
989
+ machine: resolveMachine(meta),
934
990
  });
935
991
  delText.run(meta.id);
936
992
  insText.run(meta.id,
@@ -1115,6 +1171,7 @@ function rowToMeta(row) {
1115
1171
  recentDirectoriesTouched: parseJsonColumn(row.recent_directories_touched),
1116
1172
  linearProject: row.linear_project ?? undefined,
1117
1173
  linearProjectUrl: row.linear_project_url ?? undefined,
1174
+ machine: row.machine ?? undefined,
1118
1175
  };
1119
1176
  }
1120
1177
  function parseJsonColumn(value) {
@@ -1287,6 +1344,57 @@ export function countSessions(options = {}) {
1287
1344
  const row = db.prepare(sql).get(...params);
1288
1345
  return row ? row.n : 0;
1289
1346
  }
1347
+ export function queryAffinityRollup(options) {
1348
+ const db = getDB();
1349
+ const where = [];
1350
+ const params = [];
1351
+ const sinceMs = options.sinceMs ?? (Date.now() - 14 * 24 * 60 * 60 * 1000);
1352
+ // ISO timestamps sort lexicographically; compare as string prefix of datetime.
1353
+ where.push(`timestamp >= ?`);
1354
+ params.push(new Date(sinceMs).toISOString());
1355
+ if (options.onlyCli !== false) {
1356
+ where.push(`IFNULL(origin, 'cli') = 'cli'`);
1357
+ }
1358
+ if (options.excludeTeamOrigin !== false) {
1359
+ where.push(`IFNULL(is_team_origin, 0) = 0`);
1360
+ }
1361
+ if (options.agents && options.agents.length > 0) {
1362
+ where.push(`agent IN (${options.agents.map(() => '?').join(',')})`);
1363
+ params.push(...options.agents);
1364
+ }
1365
+ if (options.project) {
1366
+ where.push(`LOWER(IFNULL(project, '')) LIKE ?`);
1367
+ params.push(`%${options.project.toLowerCase()}%`);
1368
+ }
1369
+ let keyExpr;
1370
+ let selectExtra;
1371
+ if (options.groupBy === 'machine') {
1372
+ keyExpr = `IFNULL(NULLIF(machine, ''), '(unknown)')`;
1373
+ selectExtra = `${keyExpr} AS key, ${keyExpr} AS machine, NULL AS agent`;
1374
+ }
1375
+ else if (options.groupBy === 'agent') {
1376
+ keyExpr = `agent`;
1377
+ selectExtra = `agent AS key, NULL AS machine, agent AS agent`;
1378
+ }
1379
+ else {
1380
+ keyExpr = `IFNULL(NULLIF(machine, ''), '(unknown)') || char(9) || agent`;
1381
+ selectExtra = `${keyExpr} AS key, IFNULL(NULLIF(machine, ''), '(unknown)') AS machine, agent AS agent`;
1382
+ }
1383
+ const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
1384
+ const sql = `
1385
+ SELECT
1386
+ ${selectExtra},
1387
+ COUNT(*) AS launches,
1388
+ IFNULL(SUM(duration_ms), 0) AS durationMs,
1389
+ IFNULL(SUM(token_count), 0) AS tokenCount,
1390
+ IFNULL(SUM(cost_usd), 0) AS costUsd
1391
+ FROM sessions
1392
+ ${clause}
1393
+ GROUP BY key
1394
+ ORDER BY launches DESC, key ASC
1395
+ `;
1396
+ return db.prepare(sql).all(...params);
1397
+ }
1290
1398
  /**
1291
1399
  * Aggregate cost / duration / tokens across sessions, grouped by agent,
1292
1400
  * project, or calendar day. Honors the same filter shape as querySessions