@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 +7 -0
- package/dist/commands/exec.js +16 -6
- package/dist/commands/hosts.js +10 -6
- package/dist/lib/browser/chrome.d.ts +22 -0
- package/dist/lib/browser/chrome.js +53 -13
- package/dist/lib/browser/service.js +13 -0
- package/dist/lib/exec.d.ts +7 -0
- package/dist/lib/exec.js +13 -0
- package/dist/lib/hosts/dispatch.d.ts +5 -0
- package/dist/lib/hosts/dispatch.js +4 -0
- package/dist/lib/hosts/session-index.js +1 -0
- package/dist/lib/hosts/tasks.d.ts +15 -0
- package/dist/lib/hosts/tasks.js +16 -0
- package/dist/lib/rotate.d.ts +11 -6
- package/dist/lib/rotate.js +25 -11
- package/dist/lib/session/active.d.ts +2 -0
- package/dist/lib/session/active.js +7 -0
- package/dist/lib/session/db.d.ts +11 -0
- package/dist/lib/session/db.js +84 -19
- package/dist/lib/session/discover.js +5 -1
- package/dist/lib/session/remote.d.ts +4 -6
- package/dist/lib/session/remote.js +5 -12
- package/dist/lib/session/run-names.d.ts +32 -0
- package/dist/lib/session/run-names.js +63 -0
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/usage.d.ts +5 -3
- package/dist/lib/usage.js +5 -3
- package/package.json +1 -1
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`.
|
package/dist/commands/exec.js
CHANGED
|
@@ -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
|
|
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
|
|
279
|
-
available use pinned if
|
|
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
|
-
|
|
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,
|
package/dist/commands/hosts.js
CHANGED
|
@@ -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(
|
|
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 "${
|
|
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
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
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
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
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
|
|
356
|
+
export function ensureProfilePreferences(userDataDir, profileName, persistSessionCookies) {
|
|
335
357
|
const defaultDir = path.join(userDataDir, 'Default');
|
|
336
358
|
const prefsPath = path.join(defaultDir, 'Preferences');
|
|
337
|
-
|
|
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(
|
|
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,
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -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,
|
|
@@ -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;
|
package/dist/lib/hosts/tasks.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -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: `
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
69
|
-
*
|
|
70
|
-
* when no live
|
|
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
|
package/dist/lib/rotate.js
CHANGED
|
@@ -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: `
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
-
?? '
|
|
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
|
|
76
|
-
if (
|
|
77
|
-
|
|
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
|
|
144
|
-
*
|
|
145
|
-
* when no live
|
|
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,
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/session/db.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
967
|
-
let score;
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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. */
|
package/dist/lib/usage.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
92
|
-
* (`
|
|
93
|
-
* the account is throttled until
|
|
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
|
-
*
|
|
216
|
-
* (`
|
|
217
|
-
* the account is throttled until
|
|
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.
|
|
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",
|