@phnx-labs/agents-cli 1.20.42 → 1.20.44

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 (47) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +4 -3
  3. package/dist/commands/exec.js +46 -8
  4. package/dist/commands/hosts.js +14 -9
  5. package/dist/commands/logs.d.ts +4 -0
  6. package/dist/commands/logs.js +19 -13
  7. package/dist/commands/routines.d.ts +6 -0
  8. package/dist/commands/routines.js +70 -12
  9. package/dist/commands/sessions.d.ts +6 -5
  10. package/dist/commands/sessions.js +50 -22
  11. package/dist/commands/teams.js +43 -5
  12. package/dist/lib/browser/chrome.d.ts +22 -0
  13. package/dist/lib/browser/chrome.js +53 -13
  14. package/dist/lib/browser/service.js +13 -0
  15. package/dist/lib/daemon.js +34 -9
  16. package/dist/lib/exec.d.ts +15 -0
  17. package/dist/lib/exec.js +83 -6
  18. package/dist/lib/hosts/dispatch.d.ts +5 -0
  19. package/dist/lib/hosts/dispatch.js +4 -0
  20. package/dist/lib/hosts/logs.d.ts +14 -5
  21. package/dist/lib/hosts/logs.js +39 -13
  22. package/dist/lib/hosts/session-index.js +1 -0
  23. package/dist/lib/hosts/tasks.d.ts +15 -0
  24. package/dist/lib/hosts/tasks.js +16 -0
  25. package/dist/lib/redact.js +1 -0
  26. package/dist/lib/rotate.d.ts +11 -6
  27. package/dist/lib/rotate.js +25 -11
  28. package/dist/lib/session/active.d.ts +8 -0
  29. package/dist/lib/session/active.js +17 -1
  30. package/dist/lib/session/db.d.ts +11 -0
  31. package/dist/lib/session/db.js +84 -19
  32. package/dist/lib/session/discover.js +5 -1
  33. package/dist/lib/session/remote.d.ts +4 -6
  34. package/dist/lib/session/remote.js +5 -12
  35. package/dist/lib/session/run-names.d.ts +32 -0
  36. package/dist/lib/session/run-names.js +63 -0
  37. package/dist/lib/session/types.d.ts +8 -0
  38. package/dist/lib/shims.d.ts +1 -1
  39. package/dist/lib/shims.js +17 -3
  40. package/dist/lib/teams/agents.js +16 -7
  41. package/dist/lib/tmux/session.d.ts +40 -0
  42. package/dist/lib/tmux/session.js +92 -0
  43. package/dist/lib/usage.d.ts +5 -3
  44. package/dist/lib/usage.js +5 -3
  45. package/dist/lib/versions.d.ts +54 -1
  46. package/dist/lib/versions.js +138 -1
  47. package/package.json +1 -1
@@ -2,9 +2,13 @@
2
2
  * Shared host-task log viewer — the show-or-follow core behind both
3
3
  * `agents hosts logs <id>` and the top-level `agents logs <id>`.
4
4
  *
5
- * A running task with follow re-enters the offset-tail (`followHostTask`);
6
- * otherwise the captured local mirror (`localLogPath`) is printed. Kept in one
7
- * place so the two commands can never drift.
5
+ * A running task with follow re-enters the offset-tail (`followHostTask`).
6
+ * Otherwise the view is **concise by default**: a bounded tail of the captured
7
+ * combined-stdout, so an agent glancing at a dispatched run never pulls the whole
8
+ * log. `full` opts into the entire raw log. (A host run's real transcript lives
9
+ * on the remote, not the local index — surfacing its rich summary needs remote
10
+ * runs to be discoverable there first; until then the bounded tail is the safe
11
+ * concise default.) Kept in one place so the two commands can never drift.
8
12
  */
9
13
  export interface HostLogResult {
10
14
  /** False when no host task with this id exists (caller may fall through to sessions). */
@@ -12,5 +16,10 @@ export interface HostLogResult {
12
16
  /** Process exit code to adopt when the task was shown/followed. */
13
17
  exitCode?: number;
14
18
  }
15
- /** Show (or follow, when running) a dispatched host task's combined-stdout log. */
16
- export declare function showHostTaskLog(id: string, follow: boolean): Promise<HostLogResult>;
19
+ /**
20
+ * Show (or follow, when running) a dispatched host task. Bounded-tail summary by
21
+ * default; `full` dumps the entire raw combined-stdout log.
22
+ */
23
+ export declare function showHostTaskLog(id: string, follow: boolean, full?: boolean): Promise<HostLogResult>;
24
+ /** Last `n` lines of `text`, prefixed with an elision note when truncated. */
25
+ export declare function tailLines(text: string, n: number): string;
@@ -2,9 +2,13 @@
2
2
  * Shared host-task log viewer — the show-or-follow core behind both
3
3
  * `agents hosts logs <id>` and the top-level `agents logs <id>`.
4
4
  *
5
- * A running task with follow re-enters the offset-tail (`followHostTask`);
6
- * otherwise the captured local mirror (`localLogPath`) is printed. Kept in one
7
- * place so the two commands can never drift.
5
+ * A running task with follow re-enters the offset-tail (`followHostTask`).
6
+ * Otherwise the view is **concise by default**: a bounded tail of the captured
7
+ * combined-stdout, so an agent glancing at a dispatched run never pulls the whole
8
+ * log. `full` opts into the entire raw log. (A host run's real transcript lives
9
+ * on the remote, not the local index — surfacing its rich summary needs remote
10
+ * runs to be discoverable there first; until then the bounded tail is the safe
11
+ * concise default.) Kept in one place so the two commands can never drift.
8
12
  */
9
13
  import * as fs from 'fs';
10
14
  import chalk from 'chalk';
@@ -12,8 +16,13 @@ import { loadTask, localLogPath, updateTask, terminalPatch } from './tasks.js';
12
16
  import { followHostTask } from './progress.js';
13
17
  import { reconcileTask } from './reconcile.js';
14
18
  import { sshExecRaw } from '../ssh-exec.js';
15
- /** Show (or follow, when running) a dispatched host task's combined-stdout log. */
16
- export async function showHostTaskLog(id, follow) {
19
+ /** Lines of raw combined-stdout to show in the concise (non-`full`) view. */
20
+ const HOST_LOG_TAIL_LINES = 40;
21
+ /**
22
+ * Show (or follow, when running) a dispatched host task. Bounded-tail summary by
23
+ * default; `full` dumps the entire raw combined-stdout log.
24
+ */
25
+ export async function showHostTaskLog(id, follow, full = false) {
17
26
  const task = loadTask(id);
18
27
  if (!task)
19
28
  return { found: false };
@@ -36,21 +45,38 @@ export async function showHostTaskLog(id, follow) {
36
45
  // plain `logs <id>` also unsticks a task whose follower was killed. No-op (no
37
46
  // ssh) once the record is already terminal.
38
47
  reconcileTask(task);
48
+ // Raw combined-stdout: the whole log with `full`, else a bounded tail.
49
+ const raw = readTaskLog(task);
50
+ if (raw === null) {
51
+ process.stdout.write(chalk.gray('(no local log captured for this task)\n'));
52
+ return { found: true, exitCode: 0 };
53
+ }
54
+ process.stdout.write(full ? raw : tailLines(raw, HOST_LOG_TAIL_LINES));
55
+ return { found: true, exitCode: 0 };
56
+ }
57
+ /** Read the task's combined-stdout — local mirror first, else fetch+cache remote. */
58
+ function readTaskLog(task) {
39
59
  try {
40
- process.stdout.write(fs.readFileSync(localLogPath(id), 'utf-8'));
60
+ return fs.readFileSync(localLogPath(task.id), 'utf-8');
41
61
  }
42
62
  catch {
43
63
  // No local log — task was dispatched with --no-follow. Fetch from the remote
44
64
  // on demand and cache locally so subsequent calls are instant.
45
65
  const remote = fetchAndCacheRemoteLog(task);
46
- if (remote !== null) {
47
- process.stdout.write(remote);
48
- }
49
- else {
50
- process.stdout.write(chalk.gray('(no local log captured for this task)\n'));
51
- }
66
+ return remote !== null ? remote.toString('utf-8') : null;
52
67
  }
53
- return { found: true, exitCode: 0 };
68
+ }
69
+ /** Last `n` lines of `text`, prefixed with an elision note when truncated. */
70
+ export function tailLines(text, n) {
71
+ const lines = text.split('\n');
72
+ // A trailing newline yields a final empty element — drop it from the count.
73
+ if (lines.length > 0 && lines[lines.length - 1] === '')
74
+ lines.pop();
75
+ if (lines.length <= n)
76
+ return lines.join('\n') + '\n';
77
+ const hidden = lines.length - n;
78
+ const note = chalk.gray(`… ${hidden} earlier line${hidden === 1 ? '' : 's'} hidden — pass --full for the whole log\n`);
79
+ return note + lines.slice(-n).join('\n') + '\n';
54
80
  }
55
81
  /**
56
82
  * Fetch a task's remote log over SSH, write it to the local mirror path (for
@@ -36,6 +36,7 @@ export function hostSessionMeta(task, ctx) {
36
36
  filePath: '',
37
37
  topic: ctx.prompt.split('\n')[0]?.slice(0, 120) || undefined,
38
38
  label: `[host/${task.host}]`,
39
+ name: task.name,
39
40
  };
40
41
  }
41
42
  /**
@@ -15,6 +15,14 @@ export interface HostTask {
15
15
  agent: string;
16
16
  prompt: string;
17
17
  pid?: number;
18
+ /**
19
+ * The durable `agents run --name <slug>` handle for this dispatch, if given.
20
+ * Chosen at launch and agent-agnostic (unlike sessionId), so `agents hosts
21
+ * ps/logs <name>` and the dispatch tip can reference the run by a stable name
22
+ * even for agents that never expose a session id up front. Absent when the
23
+ * run was launched without `--name`.
24
+ */
25
+ name?: string;
18
26
  /**
19
27
  * The agent session id the remote run was launched with (Claude only — the
20
28
  * only agent that accepts `--session-id` to force a NEW session's id). Lets
@@ -52,3 +60,10 @@ export declare function listTasks(): HostTask[];
52
60
  * with the same forced id resolves to the most recent dispatch.
53
61
  */
54
62
  export declare function findTaskBySessionId(sessionId: string): HostTask | null;
63
+ /**
64
+ * Find the newest host task launched with `--name <name>`, so `agents hosts
65
+ * logs/ps <name>` and resolve-by-handle can address a run by its durable name.
66
+ * Case-insensitive; newest wins (listTasks is createdAt-desc) when a name was
67
+ * reused across dispatches.
68
+ */
69
+ export declare function findTaskByName(name: string): HostTask | null;
@@ -85,3 +85,19 @@ export function findTaskBySessionId(sessionId) {
85
85
  }
86
86
  return null;
87
87
  }
88
+ /**
89
+ * Find the newest host task launched with `--name <name>`, so `agents hosts
90
+ * logs/ps <name>` and resolve-by-handle can address a run by its durable name.
91
+ * Case-insensitive; newest wins (listTasks is createdAt-desc) when a name was
92
+ * reused across dispatches.
93
+ */
94
+ export function findTaskByName(name) {
95
+ if (!name)
96
+ return null;
97
+ const wanted = name.toLowerCase();
98
+ for (const task of listTasks()) {
99
+ if (task.name && task.name.toLowerCase() === wanted)
100
+ return task;
101
+ }
102
+ return null;
103
+ }
@@ -7,6 +7,7 @@ const SECRET_PATTERNS = [
7
7
  [/\bsk-[A-Za-z0-9]{20,}\b/g, '[REDACTED_API_KEY]'],
8
8
  [/\bnpm_[A-Za-z0-9]{36}\b/g, '[REDACTED_NPM_TOKEN]'],
9
9
  [/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED_JWT]'],
10
+ [/Bearer\s+\S+/gi, 'Bearer [REDACTED]'],
10
11
  [/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD)[A-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)/gi, '$1=[REDACTED]'],
11
12
  ];
12
13
  export function redactSecrets(text) {
@@ -47,9 +47,11 @@ export declare function getProjectRunStrategy(agent: AgentId, startPath: string)
47
47
  * Resolve the configured strategy. Lookup order:
48
48
  * 1. project-local agents.yaml (nearest to `startPath`)
49
49
  * 2. ~/.agents/.system/agents.yaml
50
- * 3. default: `available` (use the pinned default version when healthy,
51
- * otherwise fall through to a healthy account so a single rate-limited
52
- * account doesn't block the run).
50
+ * 3. default: `balanced` (weighted-random across all healthy accounts by
51
+ * remaining headroom, skipping any that are currently rate-limited). A
52
+ * bare `agents run <agent>` — e.g. every new terminal the extension spawns
53
+ * — should spread load and never launch into a throttled account, rather
54
+ * than stick to the pinned default even when it's maxed.
53
55
  */
54
56
  export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: string): RunStrategy;
55
57
  /** Persist the global run strategy used by bare `agents run <agent>`. */
@@ -65,9 +67,12 @@ export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrate
65
67
  * headroom, with no stampede on the lowest-usage one. Stateless — parallel
66
68
  * callers naturally fan out via the random roll.
67
69
  *
68
- * Eligibility: signed in (email present), auth valid, and usage available
69
- * (any non-session window strictly under 100%, or local flag not exhausted
70
- * when no live snapshot exists).
70
+ * Eligibility: signed in (email present), auth valid, and not currently
71
+ * rate-limited — no blocking window (session OR weekly) at 100%, matching the
72
+ * `agents view` badge; or the local cached status is usable when no live
73
+ * snapshot exists. Note the split: eligibility considers the session window
74
+ * (a session-maxed account can't run now), but the capacity *weight* above is
75
+ * driven by weekly headroom so a brief session spike doesn't distort routing.
71
76
  *
72
77
  * Dedupe: when multiple versions share an email, collapse to one candidate
73
78
  * per email (the least-recently-active version). Prevents two parallel pods
@@ -10,7 +10,7 @@ import { getAccountInfo } from './agents.js';
10
10
  import { readMeta, writeMeta, getHelpersDir } from './state.js';
11
11
  import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
12
12
  import { getProjectRunConfigs } from './run-config.js';
13
- import { getUsageInfoByIdentity, getUsageLookupKey, } from './usage.js';
13
+ import { getUsageInfoByIdentity, getUsageLookupKey, deriveUsageStatusFromSnapshot, } from './usage.js';
14
14
  function getRotateDir() {
15
15
  const dir = path.join(getHelpersDir(), 'rotate');
16
16
  fs.mkdirSync(dir, { recursive: true });
@@ -44,14 +44,16 @@ export function getProjectRunStrategy(agent, startPath) {
44
44
  * Resolve the configured strategy. Lookup order:
45
45
  * 1. project-local agents.yaml (nearest to `startPath`)
46
46
  * 2. ~/.agents/.system/agents.yaml
47
- * 3. default: `available` (use the pinned default version when healthy,
48
- * otherwise fall through to a healthy account so a single rate-limited
49
- * account doesn't block the run).
47
+ * 3. default: `balanced` (weighted-random across all healthy accounts by
48
+ * remaining headroom, skipping any that are currently rate-limited). A
49
+ * bare `agents run <agent>` — e.g. every new terminal the extension spawns
50
+ * — should spread load and never launch into a throttled account, rather
51
+ * than stick to the pinned default even when it's maxed.
50
52
  */
51
53
  export function getConfiguredRunStrategy(agent, startPath = process.cwd()) {
52
54
  return getProjectRunStrategy(agent, startPath)
53
55
  ?? normalizeRunStrategy(readMeta().run?.[agent]?.strategy)
54
- ?? 'available';
56
+ ?? 'balanced';
55
57
  }
56
58
  /** Persist the global run strategy used by bare `agents run <agent>`. */
57
59
  export function setGlobalRunStrategy(agent, strategy) {
@@ -72,10 +74,19 @@ function isAvailableEligible(candidate) {
72
74
  && hasUsageAvailable(candidate);
73
75
  }
74
76
  function hasUsageAvailable(candidate) {
75
- const usedPercent = getRoutingUsedPercent(candidate.usageSnapshot);
76
- if (usedPercent !== null) {
77
- return usedPercent < 100;
77
+ const snapshot = candidate.usageSnapshot;
78
+ if (snapshot && snapshot.windows.length > 0) {
79
+ // Eligibility mirrors the `agents view` throttle badge exactly
80
+ // (deriveUsageStatusFromSnapshot): an account maxed on ANY blocking window —
81
+ // including the 5-hour session window — cannot serve the next request, so it
82
+ // must not be picked. Previously this checked only non-session windows
83
+ // (getRoutingUsedPercent), so a session-maxed account with weekly headroom
84
+ // stayed "eligible" and the router kept launching into it while `ag view`
85
+ // showed it rate-limited. Capacity *weighting* still ranks eligible accounts
86
+ // by weekly headroom; this gate only decides can-it-run-right-now.
87
+ return deriveUsageStatusFromSnapshot(snapshot) !== 'rate_limited';
78
88
  }
89
+ // No live snapshot: fall back to the coarse cached status.
79
90
  if (candidate.usageStatus === 'out_of_credits' || candidate.usageStatus === 'rate_limited') {
80
91
  return false;
81
92
  }
@@ -140,9 +151,12 @@ function dedupeAndSortCandidates(candidates) {
140
151
  * headroom, with no stampede on the lowest-usage one. Stateless — parallel
141
152
  * callers naturally fan out via the random roll.
142
153
  *
143
- * Eligibility: signed in (email present), auth valid, and usage available
144
- * (any non-session window strictly under 100%, or local flag not exhausted
145
- * when no live snapshot exists).
154
+ * Eligibility: signed in (email present), auth valid, and not currently
155
+ * rate-limited — no blocking window (session OR weekly) at 100%, matching the
156
+ * `agents view` badge; or the local cached status is usable when no live
157
+ * snapshot exists. Note the split: eligibility considers the session window
158
+ * (a session-maxed account can't run now), but the capacity *weight* above is
159
+ * driven by weekly headroom so a brief session spike doesn't distort routing.
146
160
  *
147
161
  * Dedupe: when multiple versions share an email, collapse to one candidate
148
162
  * per email (the least-recently-active version). Prevents two parallel pods
@@ -20,6 +20,8 @@ export interface ActiveSession {
20
20
  cwd?: string;
21
21
  /** User-given name from /rename command. */
22
22
  label?: string;
23
+ /** Durable `agents run --name` launch handle, when the run was named. */
24
+ name?: string;
23
25
  /** First meaningful line of the initial prompt (extracted topic). */
24
26
  topic?: string;
25
27
  /** Live preview: the latest turn (agent message or tool action), from the state engine. */
@@ -100,6 +102,12 @@ export interface ActiveQueryOptions {
100
102
  /** Skip the `ps` scan for ad-hoc headless agents. */
101
103
  skipHeadless?: boolean;
102
104
  }
105
+ /**
106
+ * Resolve an agent kind from a process's reported executable. `comm` may be an
107
+ * absolute path (shim-launched agents), and Windows image names carry an
108
+ * `.exe` suffix (`claude.exe`), so basename + suffix-strip before the lookup.
109
+ */
110
+ export declare function agentKindFromComm(commRaw: string): string | undefined;
103
111
  /**
104
112
  * Pick a Claude transcript file within a project dir.
105
113
  *
@@ -26,6 +26,7 @@ import { AgentManager } from '../teams/agents.js';
26
26
  import { getTerminalsDir } from '../state.js';
27
27
  import { readPidSessionEntry, prunePidSessionRegistry } from './pid-registry.js';
28
28
  import { buildClaudeLabelMap } from './discover.js';
29
+ import { buildRunNameMap } from './run-names.js';
29
30
  import { latestSessionFileForCwd } from './db.js';
30
31
  import { extractSessionTopic } from './prompt.js';
31
32
  import { readSessionTail } from './tail.js';
@@ -63,7 +64,16 @@ const AGENT_CLI_NAMES = {
63
64
  * absolute path (shim-launched agents), and Windows image names carry an
64
65
  * `.exe` suffix (`claude.exe`), so basename + suffix-strip before the lookup.
65
66
  */
66
- function agentKindFromComm(commRaw) {
67
+ export function agentKindFromComm(commRaw) {
68
+ // A GUI desktop app can bundle a binary with the SAME name as an agent CLI: the
69
+ // Codex desktop app ships `/Applications/Codex.app/Contents/Resources/codex` (its
70
+ // `app-server`), whose basename `codex` would otherwise match the codex CLI and
71
+ // surface the app's background server as a phantom agent session — running at cwd
72
+ // '/', so it shows up unattributed in the feed. A real agent CLI is never inside a
73
+ // `.app` bundle, so exclude those. (The Claude desktop app is a separate case,
74
+ // already excluded by name below: its process is 'Claude', not the CLI's 'claude'.)
75
+ if (commRaw.includes('.app/Contents/'))
76
+ return undefined;
67
77
  const base = path.basename(commRaw);
68
78
  const stripped = base.replace(/\.exe$/i, '');
69
79
  // Windows image names compare case-insensitively; POSIX comms stay exact —
@@ -342,6 +352,9 @@ export async function listTerminalsActive() {
342
352
  procByPid.set(r.pid, r);
343
353
  // Build label map from Claude's sessions/*.json for /rename support
344
354
  const labelMap = buildClaudeLabelMap();
355
+ // Run-name handles (`agents run --name`) keyed by session id, for the same
356
+ // sessionId → handle resolution as labels.
357
+ const runNameMap = buildRunNameMap();
345
358
  return entries.map((t) => {
346
359
  // The id cached in live-terminals.json goes stale when Claude rotates its
347
360
  // transcript uuid on resume/compact, so it often no longer matches any
@@ -356,6 +369,8 @@ export async function listTerminalsActive() {
356
369
  const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, resolvedId);
357
370
  // Prefer label from live terminal, fall back to Claude's session label
358
371
  const label = t.label ?? (t.sessionId ? labelMap.get(t.sessionId) : undefined) ?? undefined;
372
+ // Durable run name from `agents run --name`, resolved by the run's session id.
373
+ const name = resolvedId ? runNameMap.get(resolvedId) ?? undefined : undefined;
359
374
  // Extract topic from session file (first meaningful user message)
360
375
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
361
376
  const state = computeLiveState(t.kind, sessionFile, t.cwd ?? undefined, isPidAlive(t.pid));
@@ -368,6 +383,7 @@ export async function listTerminalsActive() {
368
383
  sessionId: t.sessionId ?? sessionIdFromFile(sessionFile),
369
384
  cwd: t.cwd ?? undefined,
370
385
  label,
386
+ name,
371
387
  topic,
372
388
  sessionFile,
373
389
  startedAtMs: t.startedAtMs,
@@ -22,6 +22,7 @@ export interface SessionRow {
22
22
  git_branch: string | null;
23
23
  topic: string | null;
24
24
  label: string | null;
25
+ name: string | null;
25
26
  message_count: number | null;
26
27
  token_count: number | null;
27
28
  cost_usd: number | null;
@@ -128,6 +129,16 @@ export declare function upsertSessionsBatch(entries: Array<{
128
129
  * Leaves FTS5 content/topic/project untouched — cheap to call every run.
129
130
  */
130
131
  export declare function syncLabels(labelMap: Map<string, string | null>): number;
132
+ /**
133
+ * Sync `agents run --name` handles for a set of sessions, keyed by session id.
134
+ * The name's source of truth lives outside the transcript (host task sidecars,
135
+ * run-name sidecars written at launch), so — like {@link syncLabels} — it is
136
+ * re-applied by id every scan rather than parsed per-file. Updates only
137
+ * `sessions.name` (names resolve via a direct column tier in ftsSearch, not
138
+ * FTS, so there's no session_text column to touch). Only writes when the value
139
+ * differs; cheap to call every run. Returns the number of rows updated.
140
+ */
141
+ export declare function syncNames(nameMap: Map<string, string | null>): number;
131
142
  /**
132
143
  * Sync topics (session titles) for a set of sessions, keyed by id. For agents
133
144
  * whose human-readable title lives in a side index that updates independently
@@ -13,7 +13,7 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
13
13
  const SESSIONS_DIR = getSessionsDir();
14
14
  const DB_PATH = getSessionsDbPath();
15
15
  /** Current schema version; bumped when migrations are added. */
16
- const SCHEMA_VERSION = 8;
16
+ const SCHEMA_VERSION = 9;
17
17
  /**
18
18
  * Canonicalize a file path for use as a scan_ledger key. The same physical
19
19
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
@@ -52,6 +52,7 @@ CREATE TABLE IF NOT EXISTS sessions (
52
52
  git_branch TEXT,
53
53
  topic TEXT,
54
54
  label TEXT,
55
+ name TEXT,
55
56
  message_count INTEGER,
56
57
  token_count INTEGER,
57
58
  cost_usd REAL,
@@ -188,6 +189,15 @@ function migrateSchema(db, fromVersion) {
188
189
  db.exec(`UPDATE sessions SET last_activity = timestamp WHERE last_activity IS NULL`);
189
190
  db.exec(`DELETE FROM scan_ledger;`);
190
191
  }
192
+ if (fromVersion < 9) {
193
+ // v8 → v9: `agents run --name <slug>` gives a run a durable launch handle,
194
+ // resolvable via `agents sessions <name>`. Additive column; NO rescan — the
195
+ // name is set at run time (host sidecar / run-name sidecar), not parsed from
196
+ // transcripts, so existing rows stay valid with a NULL name.
197
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
198
+ if (!cols.some(c => c.name === 'name'))
199
+ db.exec(`ALTER TABLE sessions ADD COLUMN name TEXT`);
200
+ }
191
201
  }
192
202
  /** Open (or return the cached) sessions database, applying migrations as needed. */
193
203
  export function getDB() {
@@ -401,13 +411,13 @@ export function recordScans(entries) {
401
411
  const upsertSessionStmt = (db) => db.prepare(`
402
412
  INSERT INTO sessions (
403
413
  id, short_id, agent, version, account, timestamp, last_activity,
404
- project, cwd, git_branch, topic, label, message_count, token_count,
414
+ project, cwd, git_branch, topic, label, name, message_count, token_count,
405
415
  cost_usd, duration_ms,
406
416
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
407
417
  pr_url, pr_number, worktree_slug, ticket_id
408
418
  ) VALUES (
409
419
  @id, @short_id, @agent, @version, @account, @timestamp, @last_activity,
410
- @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
420
+ @project, @cwd, @git_branch, @topic, @label, @name, @message_count, @token_count,
411
421
  @cost_usd, @duration_ms,
412
422
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
413
423
  @pr_url, @pr_number, @worktree_slug, @ticket_id
@@ -471,6 +481,7 @@ export function upsertSession(meta, content, scan) {
471
481
  git_branch: meta.gitBranch ?? null,
472
482
  topic: meta.topic ?? null,
473
483
  label: meta.label ?? null,
484
+ name: meta.name ?? null,
474
485
  message_count: meta.messageCount ?? null,
475
486
  token_count: meta.tokenCount ?? null,
476
487
  cost_usd: meta.costUsd ?? null,
@@ -559,6 +570,7 @@ export function upsertSessionsBatch(entries) {
559
570
  git_branch: meta.gitBranch ?? null,
560
571
  topic: meta.topic ?? null,
561
572
  label: meta.label ?? null,
573
+ name: meta.name ?? null,
562
574
  message_count: meta.messageCount ?? null,
563
575
  token_count: meta.tokenCount ?? null,
564
576
  cost_usd: meta.costUsd ?? null,
@@ -626,6 +638,45 @@ export function syncLabels(labelMap) {
626
638
  txn(updates);
627
639
  return updates.length;
628
640
  }
641
+ /**
642
+ * Sync `agents run --name` handles for a set of sessions, keyed by session id.
643
+ * The name's source of truth lives outside the transcript (host task sidecars,
644
+ * run-name sidecars written at launch), so — like {@link syncLabels} — it is
645
+ * re-applied by id every scan rather than parsed per-file. Updates only
646
+ * `sessions.name` (names resolve via a direct column tier in ftsSearch, not
647
+ * FTS, so there's no session_text column to touch). Only writes when the value
648
+ * differs; cheap to call every run. Returns the number of rows updated.
649
+ */
650
+ export function syncNames(nameMap) {
651
+ if (nameMap.size === 0)
652
+ return 0;
653
+ const db = getDB();
654
+ const ids = [...nameMap.keys()];
655
+ const CHUNK = 500;
656
+ const updates = [];
657
+ for (let i = 0; i < ids.length; i += CHUNK) {
658
+ const chunk = ids.slice(i, i + CHUNK);
659
+ const placeholders = chunk.map(() => '?').join(',');
660
+ const rows = db
661
+ .prepare(`SELECT id, name FROM sessions WHERE id IN (${placeholders})`)
662
+ .all(...chunk);
663
+ for (const row of rows) {
664
+ const live = nameMap.get(row.id) ?? null;
665
+ if ((live ?? '') !== (row.name ?? '')) {
666
+ updates.push({ id: row.id, name: live });
667
+ }
668
+ }
669
+ }
670
+ if (updates.length === 0)
671
+ return 0;
672
+ const upd = db.prepare(`UPDATE sessions SET name = ? WHERE id = ?`);
673
+ const txn = db.transaction((items) => {
674
+ for (const { id, name } of items)
675
+ upd.run(name, id);
676
+ });
677
+ txn(updates);
678
+ return updates.length;
679
+ }
629
680
  /**
630
681
  * Sync topics (session titles) for a set of sessions, keyed by id. For agents
631
682
  * whose human-readable title lives in a side index that updates independently
@@ -688,6 +739,7 @@ function rowToMeta(row) {
688
739
  account: row.account ?? undefined,
689
740
  topic: row.topic ?? undefined,
690
741
  label: row.label ?? undefined,
742
+ name: row.name ?? undefined,
691
743
  isTeamOrigin: row.is_team_origin === 1,
692
744
  prUrl: row.pr_url ?? undefined,
693
745
  prNumber: row.pr_number ?? undefined,
@@ -956,26 +1008,39 @@ export function ftsSearch(input, limit = 200) {
956
1008
  const lower = trimmed.toLowerCase();
957
1009
  const seen = new Set();
958
1010
  const hits = [];
959
- // Tier 1-3: label-based matches, ordered by exactness.
1011
+ // Tier 1-3: handle-based matches, ordered by exactness. A session's handle is
1012
+ // its /rename `label` OR its `agents run --name` handle — both are user-chosen
1013
+ // aliases and rank identically, so typing either the renamed title or the run
1014
+ // name resolves the session ahead of any FTS content hit.
960
1015
  const labelRows = db.prepare(`
961
- SELECT id, label FROM sessions
962
- WHERE label IS NOT NULL AND LOWER(label) LIKE ?
963
- `).all(`%${lower}%`);
1016
+ SELECT id, label, name FROM sessions
1017
+ WHERE (label IS NOT NULL AND LOWER(label) LIKE ?)
1018
+ OR (name IS NOT NULL AND LOWER(name) LIKE ?)
1019
+ `).all(`%${lower}%`, `%${lower}%`);
964
1020
  let hasExactLabelMatch = false;
965
1021
  for (const row of labelRows) {
966
- const labelLower = row.label.toLowerCase();
967
- let score;
968
- if (labelLower === lower) {
969
- score = 1_000_000;
970
- hasExactLabelMatch = true;
971
- }
972
- else if (labelLower.startsWith(lower)) {
973
- score = 900_000;
974
- }
975
- else {
976
- score = 800_000;
1022
+ // Score against whichever handle matches best (exact > prefix > contains).
1023
+ let score = 0;
1024
+ for (const handle of [row.label, row.name]) {
1025
+ if (!handle)
1026
+ continue;
1027
+ const h = handle.toLowerCase();
1028
+ if (!h.includes(lower))
1029
+ continue;
1030
+ if (h === lower) {
1031
+ score = Math.max(score, 1_000_000);
1032
+ hasExactLabelMatch = true;
1033
+ }
1034
+ else if (h.startsWith(lower)) {
1035
+ score = Math.max(score, 900_000);
1036
+ }
1037
+ else {
1038
+ score = Math.max(score, 800_000);
1039
+ }
977
1040
  }
978
- // matchedTerms is empty for label hits — the picker can render the label
1041
+ if (score === 0)
1042
+ continue;
1043
+ // matchedTerms is empty for handle hits — the picker can render the handle
979
1044
  // itself as the highlight, no badge needed.
980
1045
  hits.push({ sessionId: row.id, score, matchedTerms: [] });
981
1046
  seen.add(row.id);
@@ -25,7 +25,8 @@ import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand } from '.
25
25
  import { costOfUsage } from '../pricing/index.js';
26
26
  import { machineId } from './sync/config.js';
27
27
  import { mapBounded } from '../concurrency.js';
28
- import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
28
+ import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
29
+ import { buildRunNameMap } from './run-names.js';
29
30
  const HOME = os.homedir();
30
31
  // Versions can live under either repo: the user repo (current canonical
31
32
  // location, ~/.agents/.history/versions/) or the system repo (legacy / npm-shipped,
@@ -63,6 +64,9 @@ export async function discoverSessions(options) {
63
64
  // reads to behavioral EDR (CrowdStrike Falcon) as a ransomware-style bulk
64
65
  // file-enumeration sweep. Same dirs, same results — just not all at once.
65
66
  await scanAgentsBounded(agents, agent => dispatchAgentScan(agent, onProgress));
67
+ // Apply `agents run --name` handles onto the freshly-scanned rows by id —
68
+ // the same idempotent, re-applied-every-scan pattern as /rename labels.
69
+ syncNames(buildRunNameMap());
66
70
  }
67
71
  finally {
68
72
  releaseScan(process.pid);
@@ -1,11 +1,9 @@
1
1
  /**
2
- * SSH target: a bare ssh-config host alias (e.g. `yosemite-s1`) or `user@host`.
3
- * The strict allowlist blocks shell metacharacters and a leading `-`, so a target
4
- * can never be smuggled in as an ssh argv flag.
2
+ * POSIX single-quote a string for safe interpolation into a remote shell command.
3
+ * Always wraps (unlike the bare-passthrough variant in `ssh-exec.ts`) the
4
+ * forwarded `agents` argv is embedded verbatim inside `bash -lc '<cmd>'`, so
5
+ * every token is quoted to keep the command boundary unambiguous.
5
6
  */
6
- export declare const SSH_TARGET_RE: RegExp;
7
- export declare function assertValidSshTarget(host: string): void;
8
- /** POSIX single-quote a string for safe interpolation into a remote shell command. */
9
7
  export declare function shellQuote(s: string): string;
10
8
  /**
11
9
  * Strip the `--host`/`-H` flag (and its value) from a raw `agents sessions` argv,
@@ -27,24 +27,17 @@ import { join } from 'path';
27
27
  import { createHash } from 'crypto';
28
28
  import chalk from 'chalk';
29
29
  import { getCacheDir } from '../state.js';
30
- import { SSH_OPTS, controlOpts } from '../ssh-exec.js';
30
+ import { SSH_OPTS, controlOpts, assertValidSshTarget } from '../ssh-exec.js';
31
31
  import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
32
32
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
33
33
  import { formatRelativeTime } from './relative-time.js';
34
34
  import { terminalWidth } from './width.js';
35
35
  /**
36
- * SSH target: a bare ssh-config host alias (e.g. `yosemite-s1`) or `user@host`.
37
- * The strict allowlist blocks shell metacharacters and a leading `-`, so a target
38
- * can never be smuggled in as an ssh argv flag.
36
+ * POSIX single-quote a string for safe interpolation into a remote shell command.
37
+ * Always wraps (unlike the bare-passthrough variant in `ssh-exec.ts`) the
38
+ * forwarded `agents` argv is embedded verbatim inside `bash -lc '<cmd>'`, so
39
+ * every token is quoted to keep the command boundary unambiguous.
39
40
  */
40
- export const SSH_TARGET_RE = /^[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
41
- export function assertValidSshTarget(host) {
42
- if (!SSH_TARGET_RE.test(host)) {
43
- throw new Error(`Invalid SSH target ${JSON.stringify(host)}. Expected a host alias or user@host ` +
44
- `(letters, digits, '.', '_', '-').`);
45
- }
46
- }
47
- /** POSIX single-quote a string for safe interpolation into a remote shell command. */
48
41
  export function shellQuote(s) {
49
42
  return `'${s.replace(/'/g, `'\\''`)}'`;
50
43
  }