@phnx-labs/agents-cli 1.20.86 → 1.20.87

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 (41) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/sessions-browser.d.ts +18 -0
  4. package/dist/commands/sessions-browser.js +126 -24
  5. package/dist/commands/sessions-picker.d.ts +21 -8
  6. package/dist/commands/sessions-picker.js +83 -7
  7. package/dist/commands/sessions.d.ts +19 -0
  8. package/dist/commands/sessions.js +147 -18
  9. package/dist/commands/ssh.js +59 -1
  10. package/dist/commands/teams-picker.d.ts +2 -0
  11. package/dist/commands/teams-picker.js +2 -1
  12. package/dist/commands/teams.d.ts +4 -1
  13. package/dist/commands/teams.js +106 -70
  14. package/dist/commands/view.js +14 -3
  15. package/dist/index.js +31 -1
  16. package/dist/lib/claude-account-token.d.ts +12 -0
  17. package/dist/lib/claude-account-token.js +63 -0
  18. package/dist/lib/devices/registry.d.ts +25 -0
  19. package/dist/lib/devices/registry.js +82 -1
  20. package/dist/lib/events.d.ts +8 -1
  21. package/dist/lib/events.js +13 -0
  22. package/dist/lib/exec.js +10 -1
  23. package/dist/lib/format.d.ts +7 -0
  24. package/dist/lib/format.js +11 -0
  25. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  26. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  27. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  28. package/dist/lib/session/db.d.ts +21 -0
  29. package/dist/lib/session/db.js +45 -4
  30. package/dist/lib/session/remote-list.d.ts +7 -0
  31. package/dist/lib/session/remote-list.js +8 -4
  32. package/dist/lib/session/state.js +69 -2
  33. package/dist/lib/session/team-filter.d.ts +22 -3
  34. package/dist/lib/session/team-filter.js +106 -17
  35. package/dist/lib/session/types.d.ts +8 -0
  36. package/dist/lib/signin-badge.d.ts +17 -0
  37. package/dist/lib/signin-badge.js +19 -0
  38. package/dist/lib/state.d.ts +2 -0
  39. package/dist/lib/state.js +2 -0
  40. package/dist/lib/usage.js +1 -60
  41. package/package.json +1 -1
@@ -0,0 +1,12 @@
1
+ /** The per-account key an email maps to inside the `auth` bundle. */
2
+ export declare function claudeAccountTokenKey(account: string): string;
3
+ /** Signed-in account email for a version home, from `.claude.json` (no keychain). */
4
+ export declare function readClaudeAccountEmail(home?: string): string | null;
5
+ /**
6
+ * Resolve a long-lived `claude setup-token` for the account signed into `home`
7
+ * from the reserved FILE-BASED `auth` bundle. Returns the token or null. Reads
8
+ * ONLY when the bundle is file-backed (never keychain), so this path itself can
9
+ * never trigger a Touch ID prompt — that is the entire point: usage/probe reads
10
+ * authenticate with the shareable setup-token, not the ACL-bound login item.
11
+ */
12
+ export declare function resolveClaudeSetupToken(home?: string): string | null;
@@ -0,0 +1,63 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { bundleBackend, bundleExists, readAndResolveBundleEnv } from './secrets/bundles.js';
5
+ /**
6
+ * Reserved FILE-BASED secrets bundle holding long-lived, non-rotating Claude
7
+ * setup-tokens. Usage/probe reads authenticate with these instead of Claude
8
+ * Code's ACL-bound login item, so they never pop Touch ID. Keyed strictly
9
+ * per-account (`CLAUDE_CODE_OAUTH_TOKEN_<slug>` from the account email) — never a
10
+ * bare key, so one account's token can't be misapplied to another in a
11
+ * multi-account fleet.
12
+ */
13
+ const AUTH_BUNDLE = 'auth';
14
+ /** The per-account key an email maps to inside the `auth` bundle. */
15
+ export function claudeAccountTokenKey(account) {
16
+ const slug = account
17
+ .trim()
18
+ .toUpperCase()
19
+ .replace(/@/g, '_AT_')
20
+ .replace(/\./g, '_DOT_')
21
+ .replace(/[^A-Z0-9_]/g, '_');
22
+ return `CLAUDE_CODE_OAUTH_TOKEN_${slug}`;
23
+ }
24
+ /** Signed-in account email for a version home, from `.claude.json` (no keychain). */
25
+ export function readClaudeAccountEmail(home) {
26
+ const base = home ?? os.homedir();
27
+ for (const p of [path.join(base, '.claude', '.claude.json'), path.join(base, '.claude.json')]) {
28
+ try {
29
+ const email = JSON.parse(fs.readFileSync(p, 'utf-8')).oauthAccount?.emailAddress;
30
+ if (typeof email === 'string' && email.trim().length > 0)
31
+ return email.trim();
32
+ }
33
+ catch {
34
+ // Missing/unreadable at this location — try the next.
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+ /**
40
+ * Resolve a long-lived `claude setup-token` for the account signed into `home`
41
+ * from the reserved FILE-BASED `auth` bundle. Returns the token or null. Reads
42
+ * ONLY when the bundle is file-backed (never keychain), so this path itself can
43
+ * never trigger a Touch ID prompt — that is the entire point: usage/probe reads
44
+ * authenticate with the shareable setup-token, not the ACL-bound login item.
45
+ */
46
+ export function resolveClaudeSetupToken(home) {
47
+ try {
48
+ // Require a known account (email) up front: without it we cannot key a
49
+ // per-account token, and we must NOT fall back to a bare shared key that
50
+ // would misapply one account's setup-token to another.
51
+ const email = readClaudeAccountEmail(home);
52
+ if (!email)
53
+ return null;
54
+ if (!bundleExists(AUTH_BUNDLE) || bundleBackend(AUTH_BUNDLE) !== 'file')
55
+ return null;
56
+ const { env } = readAndResolveBundleEnv(AUTH_BUNDLE, { caller: 'usage', agentOnly: true });
57
+ const v = (env[claudeAccountTokenKey(email)] ?? '').trim();
58
+ return v.length > 0 ? v : null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
@@ -142,3 +142,28 @@ export declare function addIgnored(name: string): Promise<Set<string>>;
142
142
  /** Remove a node name from the ignore-list (un-ignore). Returns false if it was
143
143
  * not ignored. */
144
144
  export declare function removeIgnored(name: string): Promise<boolean>;
145
+ /**
146
+ * Auto-launch preferences: which registered devices are eligible for Factory's
147
+ * auto-host selection, and which are preferred. Stored as a sibling to the
148
+ * registry and ignore-list under ~/.agents/.history/devices/.
149
+ */
150
+ export interface AutoLaunchPreference {
151
+ enabled?: boolean;
152
+ preferred?: boolean;
153
+ }
154
+ export interface AutoLaunchPreferences {
155
+ devices: Record<string, AutoLaunchPreference>;
156
+ updatedAt: string;
157
+ }
158
+ /** Load auto-launch preferences. Missing or malformed file => empty map. */
159
+ export declare function loadAutoLaunchPreferences(): Promise<Record<string, AutoLaunchPreference>>;
160
+ /** True if the device is enabled for auto-launch. Missing entry defaults to true. */
161
+ export declare function isAutoLaunchEnabled(name: string): Promise<boolean>;
162
+ /** Set whether a device is enabled for auto-launch. Setting to the default
163
+ * (enabled) removes the entry to keep the file minimal. */
164
+ export declare function setAutoLaunchEnabled(name: string, enabled: boolean): Promise<void>;
165
+ /** True if the device is preferred for auto-launch ranking. */
166
+ export declare function isAutoLaunchPreferred(name: string): Promise<boolean>;
167
+ /** Set whether a device is preferred for auto-launch. Setting to the default
168
+ * (not preferred) removes the flag to keep the file minimal. */
169
+ export declare function setAutoLaunchPreferred(name: string, preferred: boolean): Promise<void>;
@@ -18,7 +18,7 @@ import * as fsSync from 'fs';
18
18
  import * as path from 'path';
19
19
  import { randomBytes } from 'crypto';
20
20
  import lockfile from 'proper-lockfile';
21
- import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
21
+ import { getDevicesRegistryPath, getDevicesIgnoredPath, getDevicesAutoLaunchPath } from '../state.js';
22
22
  /** A device's effective role, defaulting to `worker` when unset. */
23
23
  export function deviceRole(d) {
24
24
  return d.role ?? 'worker';
@@ -288,3 +288,84 @@ export async function removeIgnored(name) {
288
288
  return true;
289
289
  });
290
290
  }
291
+ function autoLaunchPath() {
292
+ return getDevicesAutoLaunchPath();
293
+ }
294
+ /** Load auto-launch preferences. Missing or malformed file => empty map. */
295
+ export async function loadAutoLaunchPreferences() {
296
+ const p = autoLaunchPath();
297
+ let raw;
298
+ try {
299
+ raw = await fs.readFile(p, 'utf-8');
300
+ }
301
+ catch (err) {
302
+ if (err && err.code === 'ENOENT')
303
+ return {};
304
+ throw err;
305
+ }
306
+ try {
307
+ const parsed = JSON.parse(raw);
308
+ return parsed.devices && typeof parsed.devices === 'object' ? parsed.devices : {};
309
+ }
310
+ catch (err) {
311
+ throw new Error(`Device auto-launch preferences corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
312
+ }
313
+ }
314
+ /** True if the device is enabled for auto-launch. Missing entry defaults to true. */
315
+ export async function isAutoLaunchEnabled(name) {
316
+ assertValidDeviceName(name);
317
+ const prefs = await loadAutoLaunchPreferences();
318
+ return prefs[name]?.enabled !== false;
319
+ }
320
+ /** Set whether a device is enabled for auto-launch. Setting to the default
321
+ * (enabled) removes the entry to keep the file minimal. */
322
+ export async function setAutoLaunchEnabled(name, enabled) {
323
+ assertValidDeviceName(name);
324
+ const p = autoLaunchPath();
325
+ await withRegistryLock(p, async () => {
326
+ const prefs = await loadAutoLaunchPreferences();
327
+ if (enabled) {
328
+ if (prefs[name]) {
329
+ const { enabled: _, ...rest } = prefs[name];
330
+ if (Object.keys(rest).length === 0) {
331
+ delete prefs[name];
332
+ }
333
+ else {
334
+ prefs[name] = rest;
335
+ }
336
+ }
337
+ }
338
+ else {
339
+ prefs[name] = { ...prefs[name], enabled: false };
340
+ }
341
+ await atomicWriteJson(p, { devices: prefs, updatedAt: new Date().toISOString() });
342
+ });
343
+ }
344
+ /** True if the device is preferred for auto-launch ranking. */
345
+ export async function isAutoLaunchPreferred(name) {
346
+ assertValidDeviceName(name);
347
+ const prefs = await loadAutoLaunchPreferences();
348
+ return prefs[name]?.preferred === true;
349
+ }
350
+ /** Set whether a device is preferred for auto-launch. Setting to the default
351
+ * (not preferred) removes the flag to keep the file minimal. */
352
+ export async function setAutoLaunchPreferred(name, preferred) {
353
+ assertValidDeviceName(name);
354
+ const p = autoLaunchPath();
355
+ await withRegistryLock(p, async () => {
356
+ const prefs = await loadAutoLaunchPreferences();
357
+ if (preferred) {
358
+ prefs[name] = { ...prefs[name], preferred: true };
359
+ }
360
+ else if (prefs[name]) {
361
+ const { preferred: _, ...rest } = prefs[name];
362
+ if (Object.keys(rest).length === 0) {
363
+ delete prefs[name];
364
+ }
365
+ else {
366
+ prefs[name] = rest;
367
+ }
368
+ }
369
+ await atomicWriteJson(p, { devices: prefs, updatedAt: new Date().toISOString() });
370
+ });
371
+ }
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { type ActorKind } from './actor.js';
15
15
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
16
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.unlocked' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'error' | 'warn' | 'info' | 'debug';
16
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.unlocked' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
17
17
  export declare function levelFor(event: EventType): EventLevel;
18
18
  export interface EventMeta {
19
19
  ts: string;
@@ -156,6 +156,13 @@ export declare function emitCommand(command: string, args?: string[], payload?:
156
156
  * Emit an error event with full details.
157
157
  */
158
158
  export declare function emitError(err: Error | string, payload?: EventPayload): void;
159
+ /**
160
+ * Emit a friction event — a structured, point-of-use record of a failure or
161
+ * block the CLI just hit. `surface` is the subsystem (teams, browser, secrets,
162
+ * guard, …); `failureId` is a stable slug that lets the nightly routine group
163
+ * the same failure across sessions (e.g. 'remote-cwd-on-add', 'not-installed').
164
+ */
165
+ export declare function emitFriction(surface: string, failureId: string, payload?: EventPayload): void;
159
166
  /**
160
167
  * Remove log files older than the retention period.
161
168
  * Removes numbered gzip archives whose filesystem mtime exceeds retention.
@@ -523,6 +523,19 @@ export function emitError(err, payload = {}) {
523
523
  errorStack: truncate(error.stack, 1000),
524
524
  });
525
525
  }
526
+ /**
527
+ * Emit a friction event — a structured, point-of-use record of a failure or
528
+ * block the CLI just hit. `surface` is the subsystem (teams, browser, secrets,
529
+ * guard, …); `failureId` is a stable slug that lets the nightly routine group
530
+ * the same failure across sessions (e.g. 'remote-cwd-on-add', 'not-installed').
531
+ */
532
+ export function emitFriction(surface, failureId, payload = {}) {
533
+ emit('friction', {
534
+ ...payload,
535
+ surface,
536
+ failureId,
537
+ });
538
+ }
526
539
  // ─── Gzip rotation ──────────────────────────────────────────────────────────
527
540
  /** Rotate the active file while its append lock is held. */
528
541
  function maybeGzipRotateLocked(logPath) {
package/dist/lib/exec.js CHANGED
@@ -28,6 +28,7 @@ import { mailboxDir, isValidMailboxId } from './mailbox.js';
28
28
  import { composeWin32CommandLine } from './platform/index.js';
29
29
  import { isTmuxInstalled } from './tmux/binary.js';
30
30
  import { shellQuote } from './ssh-exec.js';
31
+ import { resolveClaudeSetupToken } from './claude-account-token.js';
31
32
  /**
32
33
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
33
34
  *
@@ -259,7 +260,15 @@ export function buildExecEnv(options) {
259
260
  ? resolvedVersion
260
261
  : (resolvedVersion && isVersionInstalled('claude', resolvedVersion) ? resolvedVersion : null);
261
262
  if (version) {
262
- result.CLAUDE_CONFIG_DIR = path.join(getVersionHomePath('claude', version), '.claude');
263
+ const versionHome = getVersionHomePath('claude', version);
264
+ result.CLAUDE_CONFIG_DIR = path.join(versionHome, '.claude');
265
+ const setupToken = resolveClaudeSetupToken(versionHome);
266
+ if (setupToken) {
267
+ // A token keyed to this version home's own account replaces any ambient
268
+ // shared value inherited from the launcher. options.env still wins below
269
+ // for explicit caller overrides.
270
+ result.CLAUDE_CODE_OAUTH_TOKEN = setupToken;
271
+ }
263
272
  // A managed pin lives in a per-version dir; Claude Code's own background
264
273
  // auto-updater would rewrite that pinned binary in place (and has left it
265
274
  // half-swapped and broken). Disable it so a pin stays a pin. Honor an
@@ -27,6 +27,13 @@ export declare function formatDie(msg: string, opts?: DieOptions): {
27
27
  * keep the original red-stderr behavior.
28
28
  */
29
29
  export declare function die(msg: string, code?: number, opts?: DieOptions): never;
30
+ /**
31
+ * `die()` with a structured friction event attached. Use this at CLI error
32
+ * chokepoints so the nightly routine can classify and rank recurring failures
33
+ * without re-parsing transcripts. `surface` is the subsystem (teams, browser,
34
+ * secrets, guard, …); `failureId` is a stable slug (e.g. 'remote-cwd-on-add').
35
+ */
36
+ export declare function dieFriction(surface: string, failureId: string, msg: string, code?: number, opts?: DieOptions): never;
30
37
  /**
31
38
  * Truncate `s` to at most `max` characters, appending a single-char ellipsis
32
39
  * (`…`) when shortened. Character-count based (not ANSI/width aware — use
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import chalk from 'chalk';
11
11
  import { readSync } from 'node:fs';
12
+ import { emitFriction } from './events.js';
12
13
  /**
13
14
  * Render a fatal error to the right stream. Pure — no I/O, no `process.exit` — so
14
15
  * the human-vs-agent split is unit-testable. A `--json` caller gets
@@ -43,6 +44,16 @@ export function die(msg, code = 1, opts = {}) {
43
44
  console.error(text);
44
45
  process.exit(code);
45
46
  }
47
+ /**
48
+ * `die()` with a structured friction event attached. Use this at CLI error
49
+ * chokepoints so the nightly routine can classify and rank recurring failures
50
+ * without re-parsing transcripts. `surface` is the subsystem (teams, browser,
51
+ * secrets, guard, …); `failureId` is a stable slug (e.g. 'remote-cwd-on-add').
52
+ */
53
+ export function dieFriction(surface, failureId, msg, code = 1, opts = {}) {
54
+ emitFriction(surface, failureId, { error: msg });
55
+ die(msg, code, opts);
56
+ }
46
57
  /**
47
58
  * Truncate `s` to at most `max` characters, appending a single-char ellipsis
48
59
  * (`…`) when shortened. Character-count based (not ANSI/width aware — use
@@ -8,6 +8,10 @@
8
8
  */
9
9
  import Database from '../sqlite.js';
10
10
  import type { SessionAgentId, SessionMeta } from './types.js';
11
+ /** Current schema version; bumped when migrations are added. Exported so tests
12
+ * assert against the constant instead of hardcoding a number that every bump
13
+ * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
14
+ export declare const SCHEMA_VERSION = 21;
11
15
  /** Raw row shape returned from the sessions table. */
12
16
  export interface SessionRow {
13
17
  id: string;
@@ -40,6 +44,7 @@ export interface SessionRow {
40
44
  pr_number: number | null;
41
45
  worktree_slug: string | null;
42
46
  ticket_id: string | null;
47
+ spawned_team: string | null;
43
48
  plan: string | null;
44
49
  machine: string | null;
45
50
  todos: string | null;
@@ -297,6 +302,22 @@ export declare function queryAffinityRollup(options: {
297
302
  export declare function queryUsageRollup(options: QueryOptions & {
298
303
  groupBy: UsageRollupGroup;
299
304
  }): UsageRollupRow[];
305
+ /** Who spawned a team: the orchestrator session, from its transcript. */
306
+ export interface TeamSpawner {
307
+ sessionId: string;
308
+ shortId: string;
309
+ /** The human the orchestrator ran as, when the row carries actor provenance. */
310
+ actor?: string;
311
+ }
312
+ /**
313
+ * Map every team name to the session that ran `agents teams create/add` for it.
314
+ *
315
+ * One scan over the rows that carry a `spawned_team`, rather than a query per
316
+ * team — `agents teams list` needs the whole map at once, and the column has no
317
+ * index. When two sessions spawned the same team name (a team re-created after a
318
+ * disband), the most recent wins, which is the one whose work the name refers to.
319
+ */
320
+ export declare function teamSpawners(): Map<string, TeamSpawner>;
300
321
  /** A session with its cost, for the top-N-by-cost listing. */
301
322
  export interface TopCostSession {
302
323
  meta: SessionMeta;
@@ -16,8 +16,10 @@ import { machineForSessionFile } from './origin-machine.js';
16
16
  import { loadSessionActorIndex, readSessionActorRecord } from './actor-sidecar.js';
17
17
  const SESSIONS_DIR = getSessionsDir();
18
18
  const DB_PATH = getSessionsDbPath();
19
- /** Current schema version; bumped when migrations are added. */
20
- const SCHEMA_VERSION = 20;
19
+ /** Current schema version; bumped when migrations are added. Exported so tests
20
+ * assert against the constant instead of hardcoding a number that every bump
21
+ * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
22
+ export const SCHEMA_VERSION = 21;
21
23
  /**
22
24
  * Canonicalize a file path for use as a scan_ledger key. The same physical
23
25
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
@@ -74,6 +76,7 @@ CREATE TABLE IF NOT EXISTS sessions (
74
76
  pr_number INTEGER,
75
77
  worktree_slug TEXT,
76
78
  ticket_id TEXT,
79
+ spawned_team TEXT,
77
80
  plan TEXT,
78
81
  machine TEXT,
79
82
  todos TEXT,
@@ -383,6 +386,19 @@ function migrateSchema(db, fromVersion) {
383
386
  db.exec(`ALTER TABLE sessions ADD COLUMN model TEXT`);
384
387
  db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
385
388
  }
389
+ if (fromVersion < 21) {
390
+ // v20 → v21: persist the team a session SPAWNED (`agents teams create/add`).
391
+ // The value was already derived at scan time (discover.ts detectSpawnedTeam)
392
+ // and set on SessionMeta, but had no column — so it was dropped at the write
393
+ // and no consumer ever saw it. Wipe BOTH ledgers, not just scan_ledger: with
394
+ // dir_ledger intact, collectChangedFilesInLeafDirs treats every non-live-root
395
+ // dir as unchanged and derives its hot set from the scan stamps just deleted,
396
+ // so archived dirs would never be re-parsed and would stay NULL forever.
397
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
398
+ if (!cols.some(c => c.name === 'spawned_team'))
399
+ db.exec(`ALTER TABLE sessions ADD COLUMN spawned_team TEXT`);
400
+ db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
401
+ }
386
402
  }
387
403
  /** Open (or return the cached) sessions database, applying migrations as needed. */
388
404
  export function getDB() {
@@ -744,7 +760,7 @@ const upsertSessionStmt = (db) => db.prepare(`
744
760
  project, cwd, git_branch, topic, label, message_count, token_count,
745
761
  output_tokens, cost_usd, duration_ms, model,
746
762
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
747
- pr_url, pr_number, worktree_slug, ticket_id, plan, todos,
763
+ pr_url, pr_number, worktree_slug, ticket_id, spawned_team, plan, todos,
748
764
  recent_directories_touched, linear_project, linear_project_url, machine,
749
765
  actor, initiated_by
750
766
  ) VALUES (
@@ -753,7 +769,7 @@ const upsertSessionStmt = (db) => db.prepare(`
753
769
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
754
770
  @output_tokens, @cost_usd, @duration_ms, @model,
755
771
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
756
- @pr_url, @pr_number, @worktree_slug, @ticket_id, @plan, @todos,
772
+ @pr_url, @pr_number, @worktree_slug, @ticket_id, @spawned_team, @plan, @todos,
757
773
  @recent_directories_touched, @linear_project, @linear_project_url, @machine,
758
774
  @actor, @initiated_by
759
775
  )
@@ -795,6 +811,7 @@ const upsertSessionStmt = (db) => db.prepare(`
795
811
  pr_number = excluded.pr_number,
796
812
  worktree_slug = excluded.worktree_slug,
797
813
  ticket_id = excluded.ticket_id,
814
+ spawned_team = excluded.spawned_team,
798
815
  plan = excluded.plan,
799
816
  todos = excluded.todos,
800
817
  recent_directories_touched = excluded.recent_directories_touched,
@@ -911,6 +928,7 @@ export function upsertSession(meta, content, scan) {
911
928
  pr_number: meta.prNumber ?? null,
912
929
  worktree_slug: meta.worktreeSlug ?? null,
913
930
  ticket_id: meta.ticketId ?? null,
931
+ spawned_team: meta.spawnedTeam ?? null,
914
932
  plan: meta.plan ?? null,
915
933
  todos: meta.todos ? JSON.stringify(meta.todos) : null,
916
934
  recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
@@ -1031,6 +1049,7 @@ export function upsertSessionsBatch(entries) {
1031
1049
  pr_number: meta.prNumber ?? null,
1032
1050
  worktree_slug: meta.worktreeSlug ?? null,
1033
1051
  ticket_id: meta.ticketId ?? null,
1052
+ spawned_team: meta.spawnedTeam ?? null,
1034
1053
  plan: meta.plan ?? null,
1035
1054
  todos: meta.todos ? JSON.stringify(meta.todos) : null,
1036
1055
  recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
@@ -1219,6 +1238,7 @@ function rowToMeta(row) {
1219
1238
  prNumber: row.pr_number ?? undefined,
1220
1239
  worktreeSlug: row.worktree_slug ?? undefined,
1221
1240
  ticketId: row.ticket_id ?? undefined,
1241
+ spawnedTeam: row.spawned_team ?? undefined,
1222
1242
  plan: row.plan ?? undefined,
1223
1243
  todos: parseJsonColumn(row.todos),
1224
1244
  recentDirectoriesTouched: parseJsonColumn(row.recent_directories_touched),
@@ -1483,6 +1503,27 @@ export function queryUsageRollup(options) {
1483
1503
  `;
1484
1504
  return db.prepare(sql).all(...params);
1485
1505
  }
1506
+ /**
1507
+ * Map every team name to the session that ran `agents teams create/add` for it.
1508
+ *
1509
+ * One scan over the rows that carry a `spawned_team`, rather than a query per
1510
+ * team — `agents teams list` needs the whole map at once, and the column has no
1511
+ * index. When two sessions spawned the same team name (a team re-created after a
1512
+ * disband), the most recent wins, which is the one whose work the name refers to.
1513
+ */
1514
+ export function teamSpawners() {
1515
+ const db = getDB();
1516
+ const rows = db
1517
+ .prepare(`SELECT spawned_team, id, short_id, actor FROM sessions
1518
+ WHERE spawned_team IS NOT NULL AND spawned_team != ''
1519
+ ORDER BY timestamp ASC`)
1520
+ .all();
1521
+ const out = new Map();
1522
+ for (const r of rows) {
1523
+ out.set(r.spawned_team, { sessionId: r.id, shortId: r.short_id, actor: r.actor ?? undefined });
1524
+ }
1525
+ return out;
1526
+ }
1486
1527
  /**
1487
1528
  * Return the N most expensive sessions (cost_usd DESC, NULLs excluded),
1488
1529
  * honoring the same filter shape as querySessions. Drops rows whose JSONL
@@ -20,6 +20,13 @@ export interface RemoteListResult {
20
20
  sessions: SessionMeta[];
21
21
  /** How many peer machines we attempted to reach (drives the empty-fleet tip). */
22
22
  deviceCount: number;
23
+ /**
24
+ * Peers that failed to answer, by display name. The stderr note above is
25
+ * enough for a printed listing, but the interactive browser repaints over it —
26
+ * so callers rendering a full-screen UI need the outcome as data to tell
27
+ * "that box is asleep" apart from "that box has no matching sessions".
28
+ */
29
+ unreachable: string[];
23
30
  }
24
31
  /**
25
32
  * Gather listing sessions from other machines. With an explicit `hosts` list
@@ -97,9 +97,9 @@ async function fetchByTarget(target, machine, display, forwardedArgs, os) {
97
97
  const { code, stdout } = await sshCapture(target, remoteListCommand(forwardedArgs, os), REMOTE_TIMEOUT_MS);
98
98
  if (code !== 0) {
99
99
  process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
100
- return [];
100
+ return { sessions: [], unreachable: display };
101
101
  }
102
- return parseRemoteList(stdout, machine);
102
+ return { sessions: parseRemoteList(stdout, machine) };
103
103
  }
104
104
  /**
105
105
  * Gather listing sessions from other machines. With an explicit `hosts` list
@@ -122,7 +122,7 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
122
122
  reg = await loadDevices();
123
123
  }
124
124
  catch {
125
- return { sessions: [], deviceCount: 0 };
125
+ return { sessions: [], deviceCount: 0, unreachable: [] };
126
126
  }
127
127
  for (const d of Object.values(reg)) {
128
128
  if (d.tailscale?.online !== true)
@@ -147,7 +147,11 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
147
147
  }
148
148
  }
149
149
  const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name, forwardedArgs, t.os)));
150
- return { sessions: results.flat(), deviceCount: targets.length };
150
+ return {
151
+ sessions: results.flatMap((r) => r.sessions),
152
+ deviceCount: targets.length,
153
+ unreachable: results.map((r) => r.unreachable).filter((n) => !!n),
154
+ };
151
155
  }
152
156
  /** Resolve a peer's SSH target (and OS) from the device registry by its
153
157
  * normalized machine id — the same id the fan-out tags rows with. Returns
@@ -201,12 +201,66 @@ const GH_PR_CREATE_RE = /\bgh\s+pr\s+(?:create|new)\b/;
201
201
  const GH_ISSUE_CREATE_RE = /\bgh\s+issue\s+create\b/;
202
202
  /** A created GitHub issue URL (…/issues/123) in tool-result output. */
203
203
  const GH_ISSUE_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/issues\/(\d+)/;
204
+ /**
205
+ * Flags of `teams create` / `teams add` that take a value, so the value is not
206
+ * mistaken for the positional team name. Mirrors their value-taking flags in
207
+ * `commands/teams.ts` — most are `.option('… <x>')` registrations, but
208
+ * `--device`/`--host` come from `addHostOption`, so auditing this list against
209
+ * `.option(` alone would wrongly drop them. A flag missing here degrades to "no
210
+ * team detected", never to a wrong one.
211
+ */
212
+ const TEAM_VALUE_FLAGS = [
213
+ '-d', '--description', '--use-worktree', '--devices', '--hosts', '--repo',
214
+ '-n', '--name', '-m', '--mode', '-e', '--effort', '--model', '--env',
215
+ '--cwd', '--worktree', '--after', '--task-type', '--cloud', '--branch',
216
+ '--device', '--host',
217
+ ];
218
+ /**
219
+ * One flag value: a quoted string or a bare token. `-d "sessions lineage"` is the
220
+ * common shape — `--description` is usually a phrase — and a value pattern of
221
+ * `\S+` alone stops at the first space, leaving the rest of the phrase to be read
222
+ * as the positional team name (`… -d "sessions lineage" my-team` detected
223
+ * `lineage`). Quotes are matched as a unit so the whole value is consumed.
224
+ *
225
+ * A value containing an ESCAPED quote (`-d "say \"hi\" now"`) stops the quoted
226
+ * branch early and the match then fails outright — which is the intended failure
227
+ * direction: no team detected rather than a wrong one.
228
+ */
229
+ const FLAG_VALUE = String.raw `(?:"[^"\n]*"|'[^'\n]*'|\S+)`;
204
230
  /**
205
231
  * `agents teams create <name>` / `agents teams add <team> …` (also the `ag` alias).
206
232
  * The team NAME is the first bareword after the sub-verb, skipping any flags. This
207
233
  * is the structural signal that a session SPAWNED a team (vs. was spawned by one).
234
+ *
235
+ * The separators are spaces/tabs, never `\s`: a command string routinely embeds
236
+ * documentation and quoted output, and `\s` let the flag-skip run across newlines
237
+ * to capture a word from a completely different line (a real scan produced
238
+ * `team:installed` from a heredoc). For the same reason the flag-skip is bounded
239
+ * rather than unlimited — a real invocation carries a handful of flags before the
240
+ * name, not dozens.
208
241
  */
209
- const TEAMS_SPAWN_RE = /\bag(?:ents)?\s+teams?\s+(?:create|add)\s+(?:--?[a-z][\w-]*(?:[= ]\S+)?\s+)*([A-Za-z0-9][\w-]*)/;
242
+ const TEAMS_SPAWN_RE = new RegExp(
243
+ // Start of an actually-executed command: string start, a newline, or a shell
244
+ // separator. Without this, a backticked mention inside prose or tool output
245
+ // ("… and `agents teams add --device auto`") reads as a spawn.
246
+ String.raw `(?:^|[\n;&|(]|&&|\|\|)[ \t]*` +
247
+ String.raw `ag(?:ents)?[ \t]+teams?[ \t]+(?:create|add)[ \t]+` +
248
+ // Flags before the positional name. A value-taking flag must swallow its
249
+ // value, or `--device auto` leaves `auto` looking like the team name — and
250
+ // the generic branch must exclude those flags, or it swallows the flag alone
251
+ // and hands the value back as the name.
252
+ String.raw `(?:(?:${TEAM_VALUE_FLAGS.join('|')})[= \t]${FLAG_VALUE}[ \t]+` +
253
+ String.raw `|(?!(?:${TEAM_VALUE_FLAGS.join('|')})[= \t])--?[a-z][\w-]*(?:=\S+)?[ \t]+){0,6}` +
254
+ // A team name may start with a digit — `createTeam` validates nothing, and
255
+ // `2fa-migration` is a legal name — so the class stays [A-Za-z0-9]. The
256
+ // all-digits case is rejected in the guard below instead.
257
+ String.raw `([A-Za-z0-9][\w-]*)`);
258
+ /**
259
+ * Sub-verbs that can follow `teams create|add` in prose ("teams add a teammate")
260
+ * but are never a team name. Guards the common case where the match came from a
261
+ * sentence rather than an executed command.
262
+ */
263
+ const NON_TEAM_WORDS = new Set(['a', 'an', 'the', 'to', 'for', 'with', 'and', 'this', 'your', 'my', 'it']);
210
264
  /** Collapse to a single trimmed line for a one-row preview cell. */
211
265
  function oneLine(s) {
212
266
  return s.replace(/\s+/g, ' ').trim();
@@ -259,7 +313,20 @@ export function detectSpawnedTeam(command) {
259
313
  if (!command)
260
314
  return undefined;
261
315
  const m = command.match(TEAMS_SPAWN_RE);
262
- return m ? m[1] : undefined;
316
+ if (!m)
317
+ return undefined;
318
+ const name = m[1];
319
+ // A single character is a doc placeholder (`agents teams create t --host <box>`)
320
+ // far more often than a real team, and an English article is prose. Both used to
321
+ // land in the index as a team name, and now that the name is rendered on the row
322
+ // a wrong one is worse than none.
323
+ // A single character is a doc placeholder (`agents teams create t --host <name>`)
324
+ // far more often than a real team; an all-digits token is a flag value or a list
325
+ // index that leaked through, never a name someone typed. Both had reached the
326
+ // index, and now that the name is rendered a wrong one is worse than none.
327
+ if (name.length < 2 || /^\d+$/.test(name) || NON_TEAM_WORDS.has(name.toLowerCase()))
328
+ return undefined;
329
+ return name;
263
330
  }
264
331
  /**
265
332
  * True when a tool_use call CREATES a tracker ticket — a Linear MCP `create_issue`