@phnx-labs/agents-cli 1.20.42 → 1.20.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.43
6
+
7
+ - **NEW: `agents run --name <slug>` — a durable, human/agent-friendly handle for any run.** An agent that dispatches another agent had no cheap status handle: the host-task id was never even printed (the `--no-follow` tip showed a literal `<id>` placeholder), and only Claude's session id is known up front (pre-minted `--session-id`) — every other agent's id is discovered later by scanning transcripts, so callers fell back to `agents logs`, which dumps the raw, token-heavy transcript. `--name` is chosen at launch, agent-agnostic, and stored on the structures that already back these views: a first-class `name` column on `sessions.db` (schema v9, additive, no rescan) parallel to `label` — `agents sessions <ref>` resolves against **both** name and label; the HostTask sidecar (forwarded to the remote run, so `agents hosts ps` gains a NAME column and `agents hosts logs <name>` resolves by name); and a run-name sidecar (`~/.agents/.cache/run-names/`) that joins a local run's name onto the index by id every scan via `syncNames` — the same idempotent pattern as `/rename` label sync. The `name` column is deliberately left out of the upsert `ON CONFLICT … SET` clause, so a discovery rescan can never null an existing name (regression-tested in `db.names.test.ts`). Omitting `--name` is a strict no-op: `name` stays unset and every id-based path is unchanged. The `--no-follow` dispatch tip now prints the real handle and steers to the compact `agents sessions` digest over the raw log. Source: `apps/cli/src/commands/exec.ts`, `apps/cli/src/lib/session/{db,run-names,discover}.ts`, `apps/cli/src/lib/hosts/{dispatch,tasks}.ts`.
8
+ - **New terminals (and teammates) no longer launch into a rate-limited account; `balanced` is now the default run strategy.** Two coupled fixes. (1) A bare `agents run <agent>` — every new agent terminal the extension spawns, and every non-version-pinned `agents teams add`/`start` teammate, since both route through bare `agents run` — used to default to the `available` strategy, which *prefers the pinned default version when it looks healthy*. But "healthy" was judged by the router's `getRoutingUsedPercent`, which **excluded the 5-hour session window** and looked at weekly usage only. So a session-maxed account with weekly headroom (e.g. session 100% / week 60%) was deemed eligible and kept getting launched — while `agents view` showed it "rate-limited" (its badge, `deriveUsageStatusFromSnapshot`, *counts* the session window). The router and the badge disagreed. Now `hasUsageAvailable` shares the badge's exact signal: an account maxed on **any** blocking window (session or weekly) is ineligible and skipped by both `available` and `balanced` — you never spin up an agent on an account that can't serve the next request. Capacity *weighting* still ranks eligible accounts by weekly headroom, so a brief session spike doesn't distort long-run routing. (2) The default strategy is now `balanced` (was `available`): a bare run spreads load across all healthy accounts by remaining headroom instead of sticking to the pinned default. Override per-workspace with `run.<agent>.strategy` in `agents.yaml`, or per-invocation with `--strategy` / `-b`. Source: `apps/cli/src/lib/rotate.ts`, `apps/cli/src/lib/usage.ts`, `apps/cli/src/commands/exec.ts`.
9
+ - **[browser] Logins survive browser restarts: sandboxed profiles keep memory-only session cookies, without restoring tabs.** Sites that issue login cookies with `expires=-1` (idealista, many banking/classifieds sites) logged the profile out on every browser restart, because Chromium purges memory-only session cookies at startup unless the session-restore preference is set — a constraint that had already leaked into agent designs as "sessions can't survive restarts". Every launch now pins `session.restore_on_startup: 1` ("continue where you left off") in the profile's `Default/Preferences`, which is the switch Chromium's cookie purge actually keys off — and pairs it with `--no-startup-window` so the *visible* side of restore never happens: no window exists at startup for restore to fill, no ghost tabs from the last task reopen, and the task flow creates its own tab over CDP exactly as before. Verified live on Windows/Comet: a memory-only cookie planted pre-restart was still present after a full stop/start, with OS-level window enumeration confirming a single window and zero restored tabs. The Preferences patch runs pre-spawn (browser down, so Chromium can't overwrite it on exit), stamps the profile name only on first launch, skips malformed files untouched, and is a no-op when already set. Electron profiles keep the old name-only seeding — they manage their own storage and need their startup window (the CDP driver binds to it). Bare `agents browser start` (no `--url`) recreates the old startup-window affordance by opening a blank page target when none exists, unregistered on the task like the startup window always was. Server-side session TTLs still apply — this removes the restart logout, not the site's own expiry. Source: `apps/cli/src/lib/browser/chrome.ts` (`ensureProfilePreferences`, launch args), `apps/cli/src/lib/browser/service.ts`.
10
+ - **Security fix: `agents sessions --host <target>` no longer accepts a leading-dash target (SSH argv-flag smuggling).** `session/remote.ts` carried its own copy of `assertValidSshTarget` that omitted the `host.startsWith('-')` guard every other SSH path enforces, so a bare flag like `-l` or `-F/path` — which passes the character allowlist — was handed straight to `ssh` as an argument (`-oProxyCommand=…`-class injection) before any connection. The duplicate validator (and its `SSH_TARGET_RE`) is deleted; `runRemoteSessions` now routes through the canonical `assertValidSshTarget` in `ssh-exec.ts`, whose dash guard is already regression-tested (`ssh-exec.test.ts`). Source: `apps/cli/src/lib/session/remote.ts`.
11
+
5
12
  ## 1.20.42
6
13
 
7
14
  - **Fix: exiting a split pane inside an interactive `ag run` session kicked you out of tmux entirely.** When you split the window of an interactive agent session (`ag run claude`) with Ctrl-b `"`/`%` and then `exit`ed *your* split, the whole tmux client detached and dumped you back to the parent shell — even though the agent was still running in the other pane. Cause: `runInTmux` installed a session-wide `pane-died` hook (`detach-client`) meant to fire only when the AGENT pane exits (so the attach returns and the exit status is read), but with no `#{hook_pane}` guard it fired for *any* pane's death. The hook is now scoped to the agent pane; a user split that exits is closed in place (`kill-pane`, no lingering dead husk) and the agent keeps running full-window. Source: `apps/cli/src/lib/exec.ts`, `apps/cli/src/lib/tmux/session.test.ts`.
@@ -218,12 +218,13 @@ export function registerRunCommand(program) {
218
218
  .option('-i, --interactive', 'Force interactive mode even when a prompt is provided. Mutually exclusive with --headless.')
219
219
  .option('--resume [id]', 'Resume a previous conversation. Accepts a full or partial session id (prefix-matched against the index); omit the id to pick from recent sessions interactively. Resumes under the version that started the session. claude/codex resume natively; other agents replay via a /continue first message. Pair with a prompt to continue headlessly.')
220
220
  .option('--session-id <id>', 'Force a NEW conversation to use this exact session UUID (Claude only). This CREATES a session — to resume an existing one, use --resume.')
221
+ .option('--name <slug>', 'Give the run a durable name — a stable handle you can check on later with `agents sessions <name>` (and `agents hosts logs <name>` for --host runs), instead of an opaque id. Optional; omitting it keeps today\'s id-only behavior.')
221
222
  .option('--verbose', 'Show detailed execution logs')
222
223
  .option('--raw', 'Interactive runs on macOS/Linux launch inside a shared tmux session (for %pane addressing + re-attach). Pass --raw to spawn the agent directly instead. Also disabled by AGENTS_NO_TMUX=1.')
223
224
  .option('--timeout <duration>', 'Kill the agent after this duration (e.g., 30m, 1h, 2h30m)')
224
225
  .option('--fallback <agents>', 'Comma-separated agents to try on rate-limit failure. Each entry accepts an optional @version pin (e.g., codex@0.116.0,gemini). The primary runs first; if it exits with a rate-limit error, the next agent picks up via /continue handoff.')
225
226
  .option('-b, --balanced', 'Shortcut for --strategy balanced. Ignored when @version is pinned.')
226
- .option('--strategy <strategy>', 'Version/account selection strategy: pinned | available | balanced. Defaults to run.<agent>.strategy, then pinned. (Legacy `rotate` accepted as alias for `balanced`.)')
227
+ .option('--strategy <strategy>', 'Version/account selection strategy: pinned | available | balanced. Defaults to run.<agent>.strategy, then balanced (spreads load across healthy accounts and skips any that are rate-limited). (Legacy `rotate` accepted as alias for `balanced`.)')
227
228
  .option('--acp', 'Route through the Agent Client Protocol instead of direct exec. Supported for gemini, claude (via @zed-industries/claude-code-acp adapter). Unified event stream; emits ndjson when --json.')
228
229
  .option('-y, --yes', 'Skip the interactive budget-confirm prompt (require_confirm_over). Never skips a hard budget block.', false)
229
230
  .option('--loop', 'Re-inject the prompt/entrypoint each iteration until a stop condition (issue #332). Guards (--max-iterations, --budget, --until) are enforced outside the agent. Writes a checkpoint after every iteration for --resume-checkpoint.')
@@ -275,9 +276,10 @@ export function registerRunCommand(program) {
275
276
  Legacy 'full' is silently rewritten to 'skip'.
276
277
 
277
278
  Run strategy (set via --strategy or run.<agent>.strategy in agents.yaml):
278
- pinned use the workspace/global pinned version (default)
279
- available use pinned if usage available; otherwise switch to another signed-in version
280
- balanced distribute load across healthy accounts by remaining capacity
279
+ pinned use the workspace/global pinned version
280
+ available use pinned if it can run right now; otherwise switch to another signed-in version
281
+ balanced distribute load across healthy accounts by remaining capacity (default)
282
+ A version/account is skipped when it is rate-limited right now — any usage window (incl. the 5-hour session window) at 100%, matching the 'agents view' badge.
281
283
  --balanced is shorthand for --strategy balanced. Ignored when @version is pinned, when a profile is used, or with --fallback.
282
284
 
283
285
  Fallback: --fallback codex,gemini retries on rate-limit failure via /continue handoff. Each entry accepts @version.
@@ -404,15 +406,22 @@ export function registerRunCommand(program) {
404
406
  model: options.model,
405
407
  remoteCwd: options.remoteCwd,
406
408
  sessionId: hostSessionId,
409
+ name: options.name,
407
410
  resume: resumeId,
408
411
  follow: options.follow !== false,
409
412
  });
410
413
  // Register the dispatched run in the LOCAL session index so it shows
411
- // up in `agents sessions` and resolves by id, even though its
414
+ // up in `agents sessions` and resolves by id/name, even though its
412
415
  // transcript lives on the host. No-op when no session id was captured.
413
416
  registerHostSession(task, { cwd: process.cwd(), prompt });
414
417
  if (options.follow === false) {
415
- console.log(chalk.green(`Dispatched to ${host.name}.`) + chalk.gray(' Track: agents hosts ps · Follow: agents hosts logs <id> -f'));
418
+ // The handle the caller uses to check on the run: the name if given,
419
+ // else the real host-task id (never the old literal `<id>`). Steer
420
+ // to the compact `agents sessions` digest over the raw log first.
421
+ const handle = task.name ?? task.id;
422
+ console.log(chalk.green(`Dispatched to ${host.name}${task.name ? ` as "${task.name}"` : ''}.`) + '\n' +
423
+ chalk.gray(` Status: agents sessions ${handle}`) + chalk.gray(' (compact digest — use this)') + '\n' +
424
+ chalk.gray(` Raw log: agents hosts logs ${handle} -f`) + chalk.gray(' (heavy, only if needed)'));
416
425
  process.exit(0);
417
426
  }
418
427
  // -1 = the follow window closed but the run continues on the host (the
@@ -1062,6 +1071,7 @@ export function registerRunCommand(program) {
1062
1071
  json: options.json,
1063
1072
  headless: options.headless,
1064
1073
  sessionId: resumeSessionId ?? options.sessionId,
1074
+ name: options.name,
1065
1075
  resume: resumeNative,
1066
1076
  verbose: options.verbose,
1067
1077
  raw: options.raw,
@@ -15,7 +15,7 @@ import { sshTargetFor } from '../lib/hosts/types.js';
15
15
  import { listSshConfigHosts, listKnownHosts, isSshConfigHost } from '../lib/hosts/ssh-config.js';
16
16
  import { probeHost, remoteAgentsVersion, bootstrapAgentsCli, localCliVersion, } from '../lib/hosts/ready.js';
17
17
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
18
- import { listTasks } from '../lib/hosts/tasks.js';
18
+ import { listTasks, loadTask, findTaskByName, findTaskBySessionId } from '../lib/hosts/tasks.js';
19
19
  import { reconcileRunningTasks } from '../lib/hosts/reconcile.js';
20
20
  import { showHostTaskLog } from '../lib/hosts/logs.js';
21
21
  /** Parse `user@host` or `host` into its pieces. */
@@ -179,18 +179,22 @@ async function doPs(json) {
179
179
  return;
180
180
  }
181
181
  const cols = terminalWidth();
182
- console.log(chalk.bold('ID').padEnd(11) + chalk.bold('HOST').padEnd(16) + chalk.bold('AGENT').padEnd(10) + chalk.bold('STATUS').padEnd(11) + chalk.bold('PROMPT'));
182
+ console.log(chalk.bold('ID').padEnd(11) + chalk.bold('NAME').padEnd(16) + chalk.bold('HOST').padEnd(16) + chalk.bold('AGENT').padEnd(10) + chalk.bold('STATUS').padEnd(11) + chalk.bold('PROMPT'));
183
183
  for (const t of tasks) {
184
184
  const status = t.status === 'completed' ? chalk.green(t.status) : t.status === 'failed' ? chalk.red(t.status) : chalk.yellow(t.status);
185
+ const nameCol = truncateToWidth(t.name ?? chalk.gray('-'), 15).padEnd(16);
185
186
  // Prompt fills the remaining width instead of a fixed 50-char byte slice (98-char rows).
186
- const promptCol = truncateToWidth(t.prompt, Math.max(12, cols - (11 + 16 + 10 + 11)));
187
- console.log(t.id.padEnd(11) + t.host.padEnd(16) + t.agent.padEnd(10) + status.padEnd(11) + promptCol);
187
+ const promptCol = truncateToWidth(t.prompt, Math.max(12, cols - (11 + 16 + 16 + 10 + 11)));
188
+ console.log(t.id.padEnd(11) + nameCol + t.host.padEnd(16) + t.agent.padEnd(10) + status.padEnd(11) + promptCol);
188
189
  }
189
190
  }
190
- async function doLogs(id, follow) {
191
+ async function doLogs(ref, follow) {
192
+ // Resolve the ref as a task id first, then fall back to a `--name` handle so
193
+ // `agents hosts logs <name>` works, not just the opaque id.
194
+ const id = loadTask(ref) ? ref : (findTaskByName(ref)?.id ?? findTaskBySessionId(ref)?.id ?? ref);
191
195
  const res = await showHostTaskLog(id, follow);
192
196
  if (!res.found) {
193
- console.log(chalk.red(`Unknown task "${id}".`));
197
+ console.log(chalk.red(`Unknown task "${ref}".`));
194
198
  process.exitCode = 1;
195
199
  return;
196
200
  }
@@ -36,6 +36,28 @@ export declare function getRunningChromeInfo(profileName: string): {
36
36
  pid: number;
37
37
  port: number;
38
38
  } | null;
39
+ /**
40
+ * Prepare `<userDataDir>/Default/Preferences` before launch.
41
+ *
42
+ * Two concerns, one write:
43
+ * - First launch (file absent): stamp the agents-cli profile name so
44
+ * Chromium's UI shows "<profile>" instead of its default "Person 1".
45
+ * Cosmetic; existing files keep whatever Chrome wrote in the meantime.
46
+ * - Every launch (when `persistSessionCookies`): pin
47
+ * `session.restore_on_startup: 1` ("continue where you left off").
48
+ * Chromium purges memory-only session cookies at startup UNLESS this
49
+ * preference says the session will be restored — it keys the purge off
50
+ * the pref, not off tabs actually reopening. Sites like idealista issue
51
+ * login cookies with `expires=-1`, so without this every browser restart
52
+ * silently logs the profile out. The visible tab-restore side effect is
53
+ * suppressed separately via `--no-startup-window` (see launchBrowser).
54
+ *
55
+ * Runs only while the browser is down (called before spawn), so Chromium
56
+ * can't overwrite the patch on exit. Best-effort: a malformed existing file
57
+ * is left untouched (Chromium recovers its own state better than we can),
58
+ * and any I/O hiccup is silently ignored.
59
+ */
60
+ export declare function ensureProfilePreferences(userDataDir: string, profileName: string, persistSessionCookies: boolean): void;
39
61
  /**
40
62
  * Is a TCP port currently bound? `lsof` on POSIX, `netstat -ano` on Windows
41
63
  * (lsof doesn't exist there). Returns false on any tooling error so port
@@ -212,11 +212,11 @@ isElectron = false) {
212
212
  const runtimeDir = getProfileRuntimeDir(profileName);
213
213
  const userDataDir = path.join(runtimeDir, 'chrome-data');
214
214
  fs.mkdirSync(userDataDir, { recursive: true });
215
- // First-launch seed: stamp the user-data-dir's Default/Preferences with
216
- // the agents-cli profile name so Chromium's UI shows "<profile>" instead
217
- // of its default "Person 1". Done only when the file doesn't exist
218
- // subsequent launches inherit whatever Chrome wrote in the meantime.
219
- seedDefaultProfileName(userDataDir, profileName);
215
+ // Pre-launch Preferences pass: first-launch profile-name stamp, plus (for
216
+ // real browsers, not Electron apps) the session-cookie persistence pin.
217
+ // Electron apps manage their own storage and don't read Chromium's
218
+ // `session.*` prefs, so they get the name stamp only.
219
+ ensureProfilePreferences(userDataDir, profileName, !isElectron);
220
220
  // Chromium on macOS coordinates instances via the SingletonLock file
221
221
  // *inside* each user-data-dir. Direct binary spawn with a fresh
222
222
  // --user-data-dir creates a fully independent process — the user's
@@ -242,6 +242,14 @@ isElectron = false) {
242
242
  // remote-debugging transport is active. That property is the loudest
243
243
  // signal Cloudflare Turnstile, hCaptcha, and similar checks read.
244
244
  '--disable-blink-features=AutomationControlled',
245
+ // Companion to `session.restore_on_startup: 1` (see
246
+ // ensureProfilePreferences): the pref keeps session cookies alive across
247
+ // restarts, but on its own it would also reopen last session's tabs at
248
+ // startup. Suppressing the startup window leaves restore nothing to fill —
249
+ // cookies survive, no ghost tabs — and the task flow creates its own tab
250
+ // over CDP anyway. Electron apps need their window to appear (the CDP
251
+ // driver binds to it), so they skip the flag.
252
+ ...(isElectron ? [] : ['--no-startup-window']),
245
253
  ...(options.headless ? ['--headless=new'] : []),
246
254
  `--window-size=${viewport.width},${viewport.height}`,
247
255
  ...(viewport.x !== undefined && viewport.y !== undefined
@@ -325,20 +333,52 @@ export function getRunningChromeInfo(profileName) {
325
333
  return { pid: rt.pid, port: rt.port };
326
334
  }
327
335
  /**
328
- * Stamp `<userDataDir>/Default/Preferences` with our profile name so
329
- * Chrome's UI labels the window with the agents-cli name rather than the
330
- * default "Person 1". Only writes when the file is absent (first launch).
331
- * Best-effort: any I/O hiccup is silently ignored; missing the rename is
332
- * cosmetic, not functional.
336
+ * Prepare `<userDataDir>/Default/Preferences` before launch.
337
+ *
338
+ * Two concerns, one write:
339
+ * - First launch (file absent): stamp the agents-cli profile name so
340
+ * Chromium's UI shows "<profile>" instead of its default "Person 1".
341
+ * Cosmetic; existing files keep whatever Chrome wrote in the meantime.
342
+ * - Every launch (when `persistSessionCookies`): pin
343
+ * `session.restore_on_startup: 1` ("continue where you left off").
344
+ * Chromium purges memory-only session cookies at startup UNLESS this
345
+ * preference says the session will be restored — it keys the purge off
346
+ * the pref, not off tabs actually reopening. Sites like idealista issue
347
+ * login cookies with `expires=-1`, so without this every browser restart
348
+ * silently logs the profile out. The visible tab-restore side effect is
349
+ * suppressed separately via `--no-startup-window` (see launchBrowser).
350
+ *
351
+ * Runs only while the browser is down (called before spawn), so Chromium
352
+ * can't overwrite the patch on exit. Best-effort: a malformed existing file
353
+ * is left untouched (Chromium recovers its own state better than we can),
354
+ * and any I/O hiccup is silently ignored.
333
355
  */
334
- function seedDefaultProfileName(userDataDir, profileName) {
356
+ export function ensureProfilePreferences(userDataDir, profileName, persistSessionCookies) {
335
357
  const defaultDir = path.join(userDataDir, 'Default');
336
358
  const prefsPath = path.join(defaultDir, 'Preferences');
337
- if (fs.existsSync(prefsPath))
359
+ let prefs;
360
+ try {
361
+ prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf8'));
362
+ if (typeof prefs !== 'object' || prefs === null)
363
+ return; // not ours to fix
364
+ }
365
+ catch (err) {
366
+ if (err?.code !== 'ENOENT')
367
+ return; // unreadable/malformed: leave alone
368
+ }
369
+ const firstLaunch = prefs === undefined;
370
+ if (firstLaunch)
371
+ prefs = { profile: { name: profileName } };
372
+ let dirty = firstLaunch;
373
+ if (persistSessionCookies && prefs.session?.restore_on_startup !== 1) {
374
+ prefs.session = { ...prefs.session, restore_on_startup: 1 };
375
+ dirty = true;
376
+ }
377
+ if (!dirty)
338
378
  return;
339
379
  try {
340
380
  fs.mkdirSync(defaultDir, { recursive: true });
341
- fs.writeFileSync(prefsPath, JSON.stringify({ profile: { name: profileName } }));
381
+ fs.writeFileSync(prefsPath, JSON.stringify(prefs));
342
382
  }
343
383
  catch { /* not critical */ }
344
384
  }
@@ -310,6 +310,19 @@ export class BrowserService {
310
310
  conn = await this.connectProfile(effectiveProfile, resolved.target);
311
311
  this.connections.set(composite, conn);
312
312
  }
313
+ // Browsers launch with --no-startup-window (session-cookie persistence,
314
+ // see launchBrowser), so a bare `start` with no --url would otherwise
315
+ // leave the user staring at a process with zero windows. Recreate the
316
+ // old startup-window affordance: if no page target exists, open a blank
317
+ // one. Deliberately NOT registered on the task — the startup window
318
+ // never was either, and tasks track only tabs they created.
319
+ if (!opts.url && !conn.electron) {
320
+ const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
321
+ if (!targetInfos.some((t) => t.type === 'page')) {
322
+ await conn.cdp.send('Target.createTarget', { url: 'about:blank' });
323
+ this.invalidateTargetCache(conn);
324
+ }
325
+ }
313
326
  const task = {
314
327
  id: taskId,
315
328
  name: taskName,
@@ -80,6 +80,13 @@ export interface ExecOptions {
80
80
  addDirs?: string[];
81
81
  timeout?: string;
82
82
  sessionId?: string;
83
+ /**
84
+ * Durable `agents run --name <slug>` handle. Exported to the agent's env as
85
+ * `AGENT_SESSION_NAME` (companion to `AGENT_SESSION_ID`) and, when a session
86
+ * id is known at launch, recorded in the run-name index so `agents sessions
87
+ * <name>` resolves the run. Absent for unnamed runs — no behavior change.
88
+ */
89
+ name?: string;
83
90
  /**
84
91
  * Resume the conversation named by `sessionId` using the agent's NATIVE resume
85
92
  * form (claude `--resume`, codex `resume`) instead of the default `--session-id`
package/dist/lib/exec.js CHANGED
@@ -17,6 +17,7 @@ import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js'
17
17
  import { sanitizeProcessEnv } from './secrets/bundles.js';
18
18
  import { getShimsDir } from './state.js';
19
19
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
20
+ import { recordRunName } from './session/run-names.js';
20
21
  import { mailboxDir, isValidMailboxId } from './mailbox.js';
21
22
  import { composeWin32CommandLine } from './platform/index.js';
22
23
  import { isTmuxInstalled } from './tmux/binary.js';
@@ -257,6 +258,12 @@ export function buildExecEnv(options) {
257
258
  if (options.sessionId && isValidMailboxId(options.sessionId)) {
258
259
  result.AGENTS_MAILBOX_DIR = mailboxDir(options.sessionId);
259
260
  }
261
+ // Export the run's durable name (companion to AGENT_SESSION_ID) so a
262
+ // SessionStart hook / the agent can associate its transcript with the handle
263
+ // the user gave the run. Only set when --name was passed.
264
+ if (options.name) {
265
+ result.AGENT_SESSION_NAME = options.name;
266
+ }
260
267
  return {
261
268
  ...result,
262
269
  ...options.env,
@@ -900,6 +907,12 @@ async function spawnAgent(options) {
900
907
  if (options.agent === 'claude' && !options.resume && !options.sessionId) {
901
908
  options = { ...options, sessionId: randomUUID() };
902
909
  }
910
+ // Record the run's --name against its session id (when both are known at
911
+ // launch) so `agents sessions <name>` resolves it. Best-effort; unnamed runs
912
+ // and agents whose id isn't known up front simply skip this.
913
+ if (options.name && options.sessionId) {
914
+ recordRunName({ sessionId: options.sessionId, name: options.name, agent: options.agent, cwd: options.cwd });
915
+ }
903
916
  const cmd = buildExecCommand(options);
904
917
  const [executable, ...args] = cmd;
905
918
  const timeoutMs = options.timeout ? parseTimeout(options.timeout) : undefined;
@@ -27,6 +27,11 @@ export interface DispatchOptions {
27
27
  * resumable by id. Mutually exclusive with `resume`.
28
28
  */
29
29
  sessionId?: string;
30
+ /**
31
+ * Durable `--name <slug>` handle, forwarded to the remote `agents run` and
32
+ * recorded on the local task so `agents hosts logs/ps <name>` resolve it.
33
+ */
34
+ name?: string;
30
35
  /** Resume an existing session on the host by id (via `agents run --resume`). */
31
36
  resume?: string;
32
37
  /** Stream progress and block until completion (default true). */
@@ -62,6 +62,7 @@ async function launchDetached(host, target, opts) {
62
62
  prompt: opts.promptLabel,
63
63
  pid: Number.isFinite(pid) ? pid : undefined,
64
64
  sessionId: opts.sessionId,
65
+ name: opts.name,
65
66
  remoteLog,
66
67
  remoteExit,
67
68
  status: 'running',
@@ -96,6 +97,8 @@ export function buildRunForwardedArgs(opts) {
96
97
  args.push('--mode', opts.mode);
97
98
  if (opts.model)
98
99
  args.push('--model', opts.model);
100
+ if (opts.name)
101
+ args.push('--name', opts.name);
99
102
  if (opts.resume)
100
103
  args.push('--resume', opts.resume);
101
104
  else if (opts.sessionId)
@@ -115,6 +118,7 @@ export async function dispatchToHost(host, opts) {
115
118
  timeoutMs: opts.timeoutMs,
116
119
  agentLabel: opts.agent,
117
120
  promptLabel: opts.prompt,
121
+ name: opts.name,
118
122
  // On resume the remote session keeps its existing id; record that id so the
119
123
  // task stays mapped to the same session.
120
124
  sessionId: opts.resume ?? opts.sessionId,
@@ -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
+ }
@@ -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. */
@@ -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';
@@ -342,6 +343,9 @@ export async function listTerminalsActive() {
342
343
  procByPid.set(r.pid, r);
343
344
  // Build label map from Claude's sessions/*.json for /rename support
344
345
  const labelMap = buildClaudeLabelMap();
346
+ // Run-name handles (`agents run --name`) keyed by session id, for the same
347
+ // sessionId → handle resolution as labels.
348
+ const runNameMap = buildRunNameMap();
345
349
  return entries.map((t) => {
346
350
  // The id cached in live-terminals.json goes stale when Claude rotates its
347
351
  // transcript uuid on resume/compact, so it often no longer matches any
@@ -356,6 +360,8 @@ export async function listTerminalsActive() {
356
360
  const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, resolvedId);
357
361
  // Prefer label from live terminal, fall back to Claude's session label
358
362
  const label = t.label ?? (t.sessionId ? labelMap.get(t.sessionId) : undefined) ?? undefined;
363
+ // Durable run name from `agents run --name`, resolved by the run's session id.
364
+ const name = resolvedId ? runNameMap.get(resolvedId) ?? undefined : undefined;
359
365
  // Extract topic from session file (first meaningful user message)
360
366
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
361
367
  const state = computeLiveState(t.kind, sessionFile, t.cwd ?? undefined, isPidAlive(t.pid));
@@ -368,6 +374,7 @@ export async function listTerminalsActive() {
368
374
  sessionId: t.sessionId ?? sessionIdFromFile(sessionFile),
369
375
  cwd: t.cwd ?? undefined,
370
376
  label,
377
+ name,
371
378
  topic,
372
379
  sessionFile,
373
380
  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
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Run-name index: the join between a `agents run --name <slug>` handle and the
3
+ * session id of the run it named.
4
+ *
5
+ * `agents run` records `<sessionId>.json` here at launch whenever both a name
6
+ * and a session id are known up front (Claude pre-mints its id — see
7
+ * spawnAgent). The session-discovery pass reads these sidecars and applies the
8
+ * names onto the SQLite index by id (via syncNames), the same idempotent,
9
+ * re-applied-every-scan pattern as Claude `/rename` labels. Names therefore
10
+ * survive transcript rescans without being parsed out of the transcript itself.
11
+ *
12
+ * Mirrors the host-task sidecar convention (`~/.agents/.cache/hosts/<id>.json`),
13
+ * one small JSON per run under `~/.agents/.cache/run-names/`.
14
+ */
15
+ export interface RunNameRecord {
16
+ sessionId: string;
17
+ name: string;
18
+ agent: string;
19
+ cwd?: string;
20
+ ts: number;
21
+ }
22
+ export declare function runNamesDir(): string;
23
+ /**
24
+ * Record a run's `--name` handle keyed by its session id. Best-effort: a failed
25
+ * write must never break the run itself. No-op without both a name and id.
26
+ */
27
+ export declare function recordRunName(rec: Omit<RunNameRecord, 'ts'>): void;
28
+ /**
29
+ * Build the sessionId → name map from every run-name sidecar, for syncNames to
30
+ * apply onto the index. Returns an empty map when the dir doesn't exist yet.
31
+ */
32
+ export declare function buildRunNameMap(): Map<string, string | null>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Run-name index: the join between a `agents run --name <slug>` handle and the
3
+ * session id of the run it named.
4
+ *
5
+ * `agents run` records `<sessionId>.json` here at launch whenever both a name
6
+ * and a session id are known up front (Claude pre-mints its id — see
7
+ * spawnAgent). The session-discovery pass reads these sidecars and applies the
8
+ * names onto the SQLite index by id (via syncNames), the same idempotent,
9
+ * re-applied-every-scan pattern as Claude `/rename` labels. Names therefore
10
+ * survive transcript rescans without being parsed out of the transcript itself.
11
+ *
12
+ * Mirrors the host-task sidecar convention (`~/.agents/.cache/hosts/<id>.json`),
13
+ * one small JSON per run under `~/.agents/.cache/run-names/`.
14
+ */
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import { getCacheDir } from '../state.js';
18
+ export function runNamesDir() {
19
+ return path.join(getCacheDir(), 'run-names');
20
+ }
21
+ function recordFile(sessionId) {
22
+ return path.join(runNamesDir(), `${sessionId}.json`);
23
+ }
24
+ /**
25
+ * Record a run's `--name` handle keyed by its session id. Best-effort: a failed
26
+ * write must never break the run itself. No-op without both a name and id.
27
+ */
28
+ export function recordRunName(rec) {
29
+ if (!rec.sessionId || !rec.name)
30
+ return;
31
+ try {
32
+ fs.mkdirSync(runNamesDir(), { recursive: true });
33
+ fs.writeFileSync(recordFile(rec.sessionId), JSON.stringify({ ...rec, ts: Date.now() }, null, 2));
34
+ }
35
+ catch {
36
+ /* the run is already launching; the name is a convenience, not load-bearing */
37
+ }
38
+ }
39
+ /**
40
+ * Build the sessionId → name map from every run-name sidecar, for syncNames to
41
+ * apply onto the index. Returns an empty map when the dir doesn't exist yet.
42
+ */
43
+ export function buildRunNameMap() {
44
+ const map = new Map();
45
+ let files;
46
+ try {
47
+ files = fs.readdirSync(runNamesDir()).filter((f) => f.endsWith('.json'));
48
+ }
49
+ catch {
50
+ return map;
51
+ }
52
+ for (const f of files) {
53
+ try {
54
+ const rec = JSON.parse(fs.readFileSync(path.join(runNamesDir(), f), 'utf-8'));
55
+ if (rec.sessionId && rec.name)
56
+ map.set(rec.sessionId, rec.name);
57
+ }
58
+ catch {
59
+ /* skip a corrupt sidecar */
60
+ }
61
+ }
62
+ return map;
63
+ }
@@ -67,6 +67,14 @@ export interface SessionMeta {
67
67
  topic?: string;
68
68
  /** Custom name the user gave the session (e.g. Claude Code /rename). */
69
69
  label?: string;
70
+ /**
71
+ * Durable launch handle from `agents run --name <slug>` — an alias chosen at
72
+ * launch (not derived from the session id), used to resolve the run in
73
+ * `agents sessions <name>`. Distinct from `label` (post-hoc /rename): a run's
74
+ * name is immutable; both are searchable. Absent for runs launched without
75
+ * `--name`.
76
+ */
77
+ name?: string;
70
78
  /** Set when this session was spawned by `agents teams`. */
71
79
  teamOrigin?: TeamOrigin;
72
80
  /** Durable state signals extracted at scan time by the session-state engine. */
@@ -88,9 +88,11 @@ export declare function getUsageInfoForIdentity(input: UsageIdentityInput): Prom
88
88
  export declare function formatUsageSummary(plan: string | null, snapshot: UsageSnapshot | null, planWidth?: number): string;
89
89
  /**
90
90
  * Derive an account's real throttle state from its live usage windows — the
91
- * same signal `agents usage` shows and balanced rotation trusts
92
- * (`getRoutingUsedPercent` in rotate.ts). A window at 100% utilization means
93
- * the account is throttled until that window resets.
91
+ * single signal both the `agents view` badge and run-rotation eligibility share
92
+ * (`hasUsageAvailable` in rotate.ts treats a `rate_limited` verdict here as
93
+ * ineligible). A window at 100% utilization means the account is throttled until
94
+ * that window resets. Rotation *weighting* still ranks eligible accounts by
95
+ * weekly headroom (`getRoutingUsedPercent`); this function is the yes/no gate.
94
96
  *
95
97
  * Returns `null` when there is no snapshot, so callers render no badge rather
96
98
  * than a misleading one. This deliberately never consults
package/dist/lib/usage.js CHANGED
@@ -212,9 +212,11 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3) {
212
212
  }
213
213
  /**
214
214
  * Derive an account's real throttle state from its live usage windows — the
215
- * same signal `agents usage` shows and balanced rotation trusts
216
- * (`getRoutingUsedPercent` in rotate.ts). A window at 100% utilization means
217
- * the account is throttled until that window resets.
215
+ * single signal both the `agents view` badge and run-rotation eligibility share
216
+ * (`hasUsageAvailable` in rotate.ts treats a `rate_limited` verdict here as
217
+ * ineligible). A window at 100% utilization means the account is throttled until
218
+ * that window resets. Rotation *weighting* still ranks eligible accounts by
219
+ * weekly headroom (`getRoutingUsedPercent`); this function is the yes/no gate.
218
220
  *
219
221
  * Returns `null` when there is no snapshot, so callers render no badge rather
220
222
  * than a misleading one. This deliberately never consults
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.42",
3
+ "version": "1.20.43",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",