@phnx-labs/agents-cli 1.20.42 → 1.20.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +4 -3
- package/dist/commands/exec.js +46 -8
- package/dist/commands/hosts.js +14 -9
- package/dist/commands/logs.d.ts +4 -0
- package/dist/commands/logs.js +19 -13
- package/dist/commands/routines.d.ts +6 -0
- package/dist/commands/routines.js +70 -12
- package/dist/commands/sessions.d.ts +6 -5
- package/dist/commands/sessions.js +50 -22
- package/dist/commands/teams.js +43 -5
- 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/daemon.js +34 -9
- package/dist/lib/exec.d.ts +15 -0
- package/dist/lib/exec.js +83 -6
- package/dist/lib/hosts/dispatch.d.ts +5 -0
- package/dist/lib/hosts/dispatch.js +4 -0
- package/dist/lib/hosts/logs.d.ts +14 -5
- package/dist/lib/hosts/logs.js +39 -13
- 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/redact.js +1 -0
- package/dist/lib/rotate.d.ts +11 -6
- package/dist/lib/rotate.js +25 -11
- package/dist/lib/session/active.d.ts +8 -0
- package/dist/lib/session/active.js +17 -1
- 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/shims.d.ts +1 -1
- package/dist/lib/shims.js +17 -3
- package/dist/lib/teams/agents.js +16 -7
- package/dist/lib/tmux/session.d.ts +40 -0
- package/dist/lib/tmux/session.js +92 -0
- package/dist/lib/usage.d.ts +5 -3
- package/dist/lib/usage.js +5 -3
- package/dist/lib/versions.d.ts +54 -1
- package/dist/lib/versions.js +138 -1
- package/package.json +1 -1
|
@@ -425,7 +425,14 @@ export function groupActiveSessions(sessions) {
|
|
|
425
425
|
* to the same id form); else the local machine. Never keys off `ActiveSession.host`
|
|
426
426
|
* — that is the terminal *app* (code/tmux), not the computer.
|
|
427
427
|
*/
|
|
428
|
+
/** Synthetic top-level group key for provider-sandboxed cloud tasks. */
|
|
429
|
+
const CLOUD_MACHINE_KEY = 'cloud';
|
|
428
430
|
function machineKeyFor(s, localMachine) {
|
|
431
|
+
// Cloud tasks run in a provider sandbox, not on the machine they're attributed
|
|
432
|
+
// to for reply routing (s.machine = the querier). Surface them as their own
|
|
433
|
+
// top-level "cloud" group instead of nested under the local device.
|
|
434
|
+
if (s.context === 'cloud')
|
|
435
|
+
return CLOUD_MACHINE_KEY;
|
|
429
436
|
if (s.machine)
|
|
430
437
|
return s.machine;
|
|
431
438
|
if (s.provenance?.host)
|
|
@@ -449,6 +456,11 @@ export function groupSessionsByMachine(sessions, localMachine) {
|
|
|
449
456
|
return -1;
|
|
450
457
|
if (b === localMachine)
|
|
451
458
|
return 1;
|
|
459
|
+
// The synthetic "cloud" category sorts after all real machines.
|
|
460
|
+
if (a === CLOUD_MACHINE_KEY)
|
|
461
|
+
return 1;
|
|
462
|
+
if (b === CLOUD_MACHINE_KEY)
|
|
463
|
+
return -1;
|
|
452
464
|
const ac = byMachine.get(a).length, bc = byMachine.get(b).length;
|
|
453
465
|
if (ac !== bc)
|
|
454
466
|
return bc - ac;
|
|
@@ -575,38 +587,48 @@ function groupTally(sessions) {
|
|
|
575
587
|
return parts.join(' · ');
|
|
576
588
|
}
|
|
577
589
|
/** Print one machine's workspace tree, indented under its machine header. */
|
|
578
|
-
function renderWorkspaceLayout(layout, base) {
|
|
590
|
+
function renderWorkspaceLayout(layout, base, machineKey) {
|
|
579
591
|
let first = true;
|
|
580
592
|
for (const ws of layout.workspaces) {
|
|
581
593
|
if (!first)
|
|
582
594
|
console.log();
|
|
583
595
|
first = false;
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
596
|
+
// Under the top-level "cloud" machine group the __cloud__ workspace header is
|
|
597
|
+
// redundant ("▸ cloud" then "cloud") — render its rows flat under the machine
|
|
598
|
+
// header instead. Row indent collapses by one level to match.
|
|
599
|
+
const redundantCloud = ws.key === '__cloud__' && machineKey === CLOUD_MACHINE_KEY;
|
|
600
|
+
const rowBase = redundantCloud ? base : base + ' ';
|
|
601
|
+
if (!redundantCloud) {
|
|
602
|
+
const header = ws.key === '__cloud__'
|
|
603
|
+
? chalk.magenta.bold('cloud')
|
|
604
|
+
: ws.key === '__unknown__'
|
|
605
|
+
? chalk.gray.bold('unknown')
|
|
606
|
+
: chalk.cyan.bold(shortCwd(ws.key));
|
|
607
|
+
const wsSessions = [...ws.windows.flatMap(w => w.sessions), ...ws.flat];
|
|
608
|
+
const tally = groupTally(wsSessions);
|
|
609
|
+
console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}${tally ? chalk.gray(` ${tally}`) : ''}`);
|
|
610
|
+
}
|
|
592
611
|
for (const win of ws.windows) {
|
|
593
612
|
// Host is per-process, but every terminal in the same IDE window shares
|
|
594
613
|
// an ancestor — take the first non-empty host as the window's label.
|
|
595
614
|
const host = win.sessions.find((s) => s.host)?.host ?? 'terminal';
|
|
596
615
|
const winHeader = `${chalk.gray(host)} ${chalk.gray('·')} ${chalk.gray(shortWindowLabel(win.windowId))} ${chalk.gray(`(${win.sessions.length})`)}`;
|
|
597
|
-
console.log(
|
|
616
|
+
console.log(rowBase + winHeader);
|
|
598
617
|
for (const s of win.sessions)
|
|
599
|
-
printActiveRow(s,
|
|
618
|
+
printActiveRow(s, rowBase + ' ');
|
|
600
619
|
}
|
|
601
620
|
for (const s of ws.flat)
|
|
602
|
-
printActiveRow(s,
|
|
621
|
+
printActiveRow(s, rowBase);
|
|
603
622
|
}
|
|
604
623
|
}
|
|
605
624
|
/** Machine header: `▸ <name> ← this machine` for the local box (cyan), matching
|
|
606
625
|
* the `ag devices list` treatment; a plain `▸ <name>` for remotes. */
|
|
607
626
|
function printMachineHeader(mg) {
|
|
608
|
-
|
|
609
|
-
|
|
627
|
+
// The synthetic "cloud" group isn't a device — tint it magenta (matching the
|
|
628
|
+
// cloud row/label styling) so it reads as a category, not a machine.
|
|
629
|
+
const isCloud = mg.machine === CLOUD_MACHINE_KEY;
|
|
630
|
+
const marker = mg.isLocal ? chalk.cyan('▸ ') : isCloud ? chalk.magenta('▸ ') : chalk.gray('▸ ');
|
|
631
|
+
const name = mg.isLocal ? chalk.bold.cyan(mg.machine) : isCloud ? chalk.bold.magenta(mg.machine) : chalk.bold(mg.machine);
|
|
610
632
|
const here = mg.isLocal ? chalk.cyan(' ← this machine') : '';
|
|
611
633
|
console.log(`${marker}${name} ${chalk.gray(`(${mg.total})`)}${here}`);
|
|
612
634
|
}
|
|
@@ -740,11 +762,16 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
|
740
762
|
console.log();
|
|
741
763
|
firstMachine = false;
|
|
742
764
|
printMachineHeader(mg);
|
|
743
|
-
renderWorkspaceLayout(mg.layout, ' ');
|
|
765
|
+
renderWorkspaceLayout(mg.layout, ' ', mg.machine);
|
|
744
766
|
}
|
|
745
767
|
const parts = groupTally(sessions).split(' · ').filter(Boolean);
|
|
746
|
-
|
|
747
|
-
|
|
768
|
+
// The synthetic "cloud" group is a category, not a machine — exclude it from the
|
|
769
|
+
// machine count and note it separately so the tally stays truthful.
|
|
770
|
+
const realMachines = grouped.machines.filter((m) => m.machine !== CLOUD_MACHINE_KEY).length;
|
|
771
|
+
const hasCloud = grouped.machines.some((m) => m.machine === CLOUD_MACHINE_KEY);
|
|
772
|
+
const machineWord = realMachines === 1 ? 'machine' : 'machines';
|
|
773
|
+
const cloudNote = hasCloud ? ' + cloud' : '';
|
|
774
|
+
console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}) across ${realMachines} ${machineWord}${cloudNote}.`));
|
|
748
775
|
// Tip only when nothing else could be included and the user didn't opt out.
|
|
749
776
|
if (!opts.local && !opts.hosts?.length && remoteDeviceCount === 0)
|
|
750
777
|
printCrossMachineTip();
|
|
@@ -1258,12 +1285,13 @@ function resolveViewMode(options, filters) {
|
|
|
1258
1285
|
return 'summary';
|
|
1259
1286
|
}
|
|
1260
1287
|
/**
|
|
1261
|
-
* Render a session
|
|
1262
|
-
* `agents logs <sessionId>`.
|
|
1263
|
-
* `agents sessions <id
|
|
1288
|
+
* Render a resolved session to stdout — the non-follow view behind
|
|
1289
|
+
* `agents logs <sessionId>`. Defaults to the concise `summary` digest (same as
|
|
1290
|
+
* `agents sessions <id>`); pass `'markdown'` for the full transcript
|
|
1291
|
+
* (`agents logs <id> --full`). Reuses the shared `renderSession` renderer.
|
|
1264
1292
|
*/
|
|
1265
|
-
export async function renderSessionLog(session) {
|
|
1266
|
-
await renderSession(session,
|
|
1293
|
+
export async function renderSessionLog(session, mode = 'summary') {
|
|
1294
|
+
await renderSession(session, mode, {});
|
|
1267
1295
|
}
|
|
1268
1296
|
async function renderSession(session, mode, filters, options = {}) {
|
|
1269
1297
|
// OpenCode stores sessions in SQLite; filePath is "db_path#session_id"
|
package/dist/commands/teams.js
CHANGED
|
@@ -11,9 +11,10 @@ import { handleSpawn, handleStatus, handleStop, handleTasks, toTaskStatusSummary
|
|
|
11
11
|
import { createTeam, ensureTeam, getTeam, loadTeams, removeTeam, teamExists, } from '../lib/teams/registry.js';
|
|
12
12
|
import { setHelpSections } from '../lib/help.js';
|
|
13
13
|
import { createWorktree, isGitRepo, hasUncommittedChanges, removeWorktree, } from '../lib/teams/worktree.js';
|
|
14
|
-
import { isVersionInstalled, resolveVersionAlias, resolveVersionAliasLoose } from '../lib/versions.js';
|
|
14
|
+
import { isVersionInstalled, resolveVersion, resolveVersionAlias, resolveVersionAliasLoose, verifyInstalledBinaryLaunches } from '../lib/versions.js';
|
|
15
15
|
import { AGENTS, warnAgentDeprecated } from '../lib/agents.js';
|
|
16
16
|
import { discoverSessions, parseTimeFilter, resolveSessionById } from '../lib/session/discover.js';
|
|
17
|
+
import { renderSessionLog } from './sessions.js';
|
|
17
18
|
import { buildPreview as buildSessionPreview } from './sessions-picker.js';
|
|
18
19
|
import { parseExecEnv } from '../lib/exec.js';
|
|
19
20
|
import { teamPicker, printTeamTable } from './teams-picker.js';
|
|
@@ -1651,8 +1652,9 @@ export function registerTeamsCommands(program) {
|
|
|
1651
1652
|
teams
|
|
1652
1653
|
.command('logs [teammate]')
|
|
1653
1654
|
.alias('log')
|
|
1654
|
-
.description("
|
|
1655
|
-
.option('-n, --tail <n>', 'Show
|
|
1655
|
+
.description("Show a teammate's concise session summary. --full (or -n <lines>) for the raw stdout. Accepts positional name, --teammate <name>, UUID, or UUID prefix.")
|
|
1656
|
+
.option('-n, --tail <n>', 'Show the last N lines of raw stdout instead of the concise summary')
|
|
1657
|
+
.option('-m, --full', 'Show the full raw stdout log instead of the concise summary')
|
|
1656
1658
|
.option('--team <team>', 'Disambiguate when the same name appears in multiple teams')
|
|
1657
1659
|
.option('--teammate <name>', 'Teammate name (alias for the positional arg; useful for scripts)')
|
|
1658
1660
|
.action(async (ref, opts) => {
|
|
@@ -1682,14 +1684,28 @@ export function registerTeamsCommands(program) {
|
|
|
1682
1684
|
}
|
|
1683
1685
|
agentId = resolved.agentId;
|
|
1684
1686
|
}
|
|
1687
|
+
// Concise by default: a teammate's agentId IS its agent session id (passed
|
|
1688
|
+
// as --session-id at launch), so render the same summary digest as
|
|
1689
|
+
// `agents sessions <id>`. --full / -n <lines> opt into the raw stdout.log.
|
|
1690
|
+
if (!opts.full && !opts.tail) {
|
|
1691
|
+
const all = await discoverSessions({ all: true, limit: 5000 });
|
|
1692
|
+
const matches = resolveSessionById(all, agentId);
|
|
1693
|
+
if (matches.length > 0) {
|
|
1694
|
+
await renderSessionLog(matches[0], 'summary');
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
// No resolvable session (e.g. a non-Claude teammate) — fall through to a
|
|
1698
|
+
// bounded tail of raw stdout rather than dumping the whole file.
|
|
1699
|
+
}
|
|
1685
1700
|
const logPath = path.join(base, agentId, 'stdout.log');
|
|
1686
1701
|
try {
|
|
1687
1702
|
const content = await fs.readFile(logPath, 'utf-8');
|
|
1688
|
-
if (
|
|
1703
|
+
if (opts.full) {
|
|
1689
1704
|
process.stdout.write(content);
|
|
1690
1705
|
return;
|
|
1691
1706
|
}
|
|
1692
|
-
|
|
1707
|
+
// Default tail size keeps an un-resolvable teammate's glance bounded too.
|
|
1708
|
+
const n = opts.tail ? Math.max(1, parseInt(opts.tail, 10) || 50) : 40;
|
|
1693
1709
|
const lines = content.split('\n');
|
|
1694
1710
|
process.stdout.write(lines.slice(-n).join('\n'));
|
|
1695
1711
|
}
|
|
@@ -1705,6 +1721,28 @@ export function registerTeamsCommands(program) {
|
|
|
1705
1721
|
.option('--json', 'Output machine-readable JSON')
|
|
1706
1722
|
.action(async (opts) => {
|
|
1707
1723
|
const info = checkAllClis();
|
|
1724
|
+
// Deep integrity probe. `checkAllClis` reports presence (shim + stub guard),
|
|
1725
|
+
// but a GUTTED native binary (JS wrapper present, platform binary missing —
|
|
1726
|
+
// the codex/kimi optional-dep partial-extract failure) still passes that. So
|
|
1727
|
+
// actually launch the resolved default version and, if it won't run, flip the
|
|
1728
|
+
// agent to not-installed with a repair hint — otherwise doctor says "ready"
|
|
1729
|
+
// and the teammate ENOENTs at spawn. Parallel; win32 is treated as healthy by
|
|
1730
|
+
// verifyInstalledBinaryLaunches.
|
|
1731
|
+
await Promise.all(Object.entries(info).map(async ([name, entry]) => {
|
|
1732
|
+
if (!entry.installed)
|
|
1733
|
+
return;
|
|
1734
|
+
const agent = name;
|
|
1735
|
+
const version = resolveVersion(agent);
|
|
1736
|
+
if (!version)
|
|
1737
|
+
return;
|
|
1738
|
+
const health = await verifyInstalledBinaryLaunches(agent, version);
|
|
1739
|
+
if (!health.ok) {
|
|
1740
|
+
entry.installed = false;
|
|
1741
|
+
entry.path = null;
|
|
1742
|
+
entry.error = `${AGENTS[agent]?.cliCommand ?? name}@${version} is installed but its binary won't launch`
|
|
1743
|
+
+ `${health.detail ? ` (${health.detail})` : ''}. Repair: agents add ${agent}@${version}`;
|
|
1744
|
+
}
|
|
1745
|
+
}));
|
|
1708
1746
|
// Advisory enrichment only. Sign-in detection is UNRELIABLE, so it never
|
|
1709
1747
|
// changes the authoritative installed/ready column — it annotates. And an
|
|
1710
1748
|
// agent that is actually running in a team is treated as signed in
|
|
@@ -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/daemon.js
CHANGED
|
@@ -19,6 +19,7 @@ import { detectOverdueJobs, notifyOverdue } from './overdue.js';
|
|
|
19
19
|
import { BrowserService } from './browser/service.js';
|
|
20
20
|
import { BrowserIPCServer } from './browser/ipc.js';
|
|
21
21
|
import { readAndResolveBundleEnv } from './secrets/bundles.js';
|
|
22
|
+
import { redactSecrets } from './redact.js';
|
|
22
23
|
const PID_FILE = 'daemon.pid';
|
|
23
24
|
const LOCK_FILE = 'daemon.lock';
|
|
24
25
|
const LOG_FILE = 'logs.jsonl';
|
|
@@ -207,15 +208,6 @@ export function reapStrayDaemons(keepPid = process.pid) {
|
|
|
207
208
|
}
|
|
208
209
|
return { reaped, details };
|
|
209
210
|
}
|
|
210
|
-
/** Redact values that look like tokens or credentials in a log message. */
|
|
211
|
-
function redactSecrets(message) {
|
|
212
|
-
let safe = message;
|
|
213
|
-
safe = safe.replace(/eyJ[A-Za-z0-9_-]{20,}/g, '[REDACTED_TOKEN]');
|
|
214
|
-
safe = safe.replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]');
|
|
215
|
-
safe = safe.replace(/(sk-[a-zA-Z0-9]{20,})/g, '[REDACTED_KEY]');
|
|
216
|
-
safe = safe.replace(/(ANTHROPIC_API_KEY|OPENAI_API_KEY|API_KEY|SECRET|TOKEN|PASSWORD)=\S+/gi, '$1=[REDACTED]');
|
|
217
|
-
return safe;
|
|
218
|
-
}
|
|
219
211
|
function rotateLogsIfNeeded(logPath) {
|
|
220
212
|
try {
|
|
221
213
|
const stat = fs.statSync(logPath);
|
|
@@ -428,6 +420,37 @@ export async function runDaemon() {
|
|
|
428
420
|
};
|
|
429
421
|
const deviceProbeInterval = setInterval(() => { void runDeviceProbe(); }, 3 * 60_000);
|
|
430
422
|
const deviceProbeKickoff = setTimeout(() => { void runDeviceProbe(); }, 15_000);
|
|
423
|
+
// tmux hook reconcile: retrofit the guarded `pane-died` hook onto managed
|
|
424
|
+
// `agents run` sessions a pre-fix binary left with the old unconditional hook
|
|
425
|
+
// (which detached the whole client — kicking the user out of the view — when
|
|
426
|
+
// they exited a split they'd opened). Non-destructive: set-hook only, never a
|
|
427
|
+
// kill or detach. A per-session schema marker makes steady-state a no-op, so
|
|
428
|
+
// this stays cheap at ~every 5 min, plus once ~20s after startup so a
|
|
429
|
+
// just-upgraded daemon heals still-running sessions without waiting for them to
|
|
430
|
+
// cycle or the shared server to be recycled.
|
|
431
|
+
let reconcilingTmux = false;
|
|
432
|
+
const runTmuxReconcile = async () => {
|
|
433
|
+
if (reconcilingTmux)
|
|
434
|
+
return;
|
|
435
|
+
reconcilingTmux = true;
|
|
436
|
+
try {
|
|
437
|
+
const { isTmuxInstalled } = await import('./tmux/binary.js');
|
|
438
|
+
if (!isTmuxInstalled())
|
|
439
|
+
return;
|
|
440
|
+
const { reconcileSessionHooks } = await import('./tmux/session.js');
|
|
441
|
+
const r = await reconcileSessionHooks();
|
|
442
|
+
if (r.reconciled > 0)
|
|
443
|
+
log('INFO', `tmux: retrofitted pane-died hook on ${r.reconciled} session(s)`);
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
log('ERROR', `tmux reconcile failed: ${err.message}`);
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
reconcilingTmux = false;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const tmuxReconcileInterval = setInterval(() => { void runTmuxReconcile(); }, 5 * 60_000);
|
|
453
|
+
const tmuxReconcileKickoff = setTimeout(() => { void runTmuxReconcile(); }, 20_000);
|
|
431
454
|
const handleReload = () => {
|
|
432
455
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
433
456
|
scheduler.reloadAll();
|
|
@@ -447,6 +470,8 @@ export async function runDaemon() {
|
|
|
447
470
|
clearTimeout(healKickoff);
|
|
448
471
|
clearInterval(deviceProbeInterval);
|
|
449
472
|
clearTimeout(deviceProbeKickoff);
|
|
473
|
+
clearInterval(tmuxReconcileInterval);
|
|
474
|
+
clearTimeout(tmuxReconcileKickoff);
|
|
450
475
|
removeDaemonPid();
|
|
451
476
|
process.exit(0);
|
|
452
477
|
};
|
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`
|
|
@@ -265,6 +272,14 @@ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
|
|
|
265
272
|
* (`BASH_FUNC_*%%`) can't make `env` choke.
|
|
266
273
|
*/
|
|
267
274
|
export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv): string;
|
|
275
|
+
/**
|
|
276
|
+
* Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
|
|
277
|
+
* (right-stripping each). Used by runInTmux to recap a fast-failed agent's
|
|
278
|
+
* output into the caller's shell so a launch crash (e.g. a gutted install that
|
|
279
|
+
* dies with ENOENT the instant it spawns) isn't swallowed by the bare
|
|
280
|
+
* `[detached]` the pane-died hook otherwise leaves behind.
|
|
281
|
+
*/
|
|
282
|
+
export declare function formatPaneTail(raw: string, maxLines?: number): string;
|
|
268
283
|
/** Exit code spawnAgent resolves with when a run is killed for crossing a budget cap. */
|
|
269
284
|
export declare const BUDGET_KILL_EXIT_CODE = 7;
|
|
270
285
|
/**
|
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,
|
|
@@ -508,7 +515,13 @@ export function buildExecCommand(options) {
|
|
|
508
515
|
cmd[0] = absPath;
|
|
509
516
|
}
|
|
510
517
|
else {
|
|
511
|
-
|
|
518
|
+
// No versioned shim on disk. Prefer the version's REAL launch binary
|
|
519
|
+
// (node_modules/.bin/<cli>) over the bare `<cli>@<version>` name — that
|
|
520
|
+
// literal is not on PATH and spawns as ENOENT (the `kimi@0.19.2` failure).
|
|
521
|
+
// Fall back to the literal only if the binary is absent (the run path's
|
|
522
|
+
// ensureAgentRunnable normally repairs/creates the alias before we reach here).
|
|
523
|
+
const realBinary = options.agent ? getBinaryPath(options.agent, options.version) : undefined;
|
|
524
|
+
cmd[0] = realBinary && fs.existsSync(realBinary) ? realBinary : versionedName;
|
|
512
525
|
}
|
|
513
526
|
}
|
|
514
527
|
// Add reasoning effort flags (before mode flags for codex -c positioning)
|
|
@@ -807,6 +820,21 @@ export function buildTmuxAgentCommand(executable, args, env) {
|
|
|
807
820
|
const agentCmd = [executable, ...args].map(shellQuote).join(' ');
|
|
808
821
|
return `exec env ${envPrefix} ${agentCmd}`;
|
|
809
822
|
}
|
|
823
|
+
/**
|
|
824
|
+
* Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
|
|
825
|
+
* (right-stripping each). Used by runInTmux to recap a fast-failed agent's
|
|
826
|
+
* output into the caller's shell so a launch crash (e.g. a gutted install that
|
|
827
|
+
* dies with ENOENT the instant it spawns) isn't swallowed by the bare
|
|
828
|
+
* `[detached]` the pane-died hook otherwise leaves behind.
|
|
829
|
+
*/
|
|
830
|
+
export function formatPaneTail(raw, maxLines = 30) {
|
|
831
|
+
return raw
|
|
832
|
+
.split('\n')
|
|
833
|
+
.map(l => l.replace(/\s+$/, ''))
|
|
834
|
+
.filter(l => l.length > 0)
|
|
835
|
+
.slice(-maxLines)
|
|
836
|
+
.join('\n');
|
|
837
|
+
}
|
|
810
838
|
/**
|
|
811
839
|
* Run an interactive agent inside a detached tmux session on the shared socket,
|
|
812
840
|
* attach the current TTY, and propagate the wrapped agent's exit code.
|
|
@@ -827,7 +855,7 @@ export function buildTmuxAgentCommand(executable, args, env) {
|
|
|
827
855
|
* (Ctrl-b d) — return 0 and LEAVE the session for `agents focus` to re-attach.
|
|
828
856
|
*/
|
|
829
857
|
async function runInTmux(options, executable, args) {
|
|
830
|
-
const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName } = await import('./tmux/session.js');
|
|
858
|
+
const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName, agentPaneDiedHook, markSessionHookSchema } = await import('./tmux/session.js');
|
|
831
859
|
const { getDefaultSocketPath } = await import('./tmux/paths.js');
|
|
832
860
|
const { attachTmux, runTmux } = await import('./tmux/binary.js');
|
|
833
861
|
const socket = getDefaultSocketPath();
|
|
@@ -848,7 +876,10 @@ async function runInTmux(options, executable, args) {
|
|
|
848
876
|
// that split in place instead of detaching everyone (the pane-died hook runs
|
|
849
877
|
// in the dead pane's context, so bare `kill-pane` targets it). Without the
|
|
850
878
|
// guard, exiting any split kicked the user clean out of tmux.
|
|
851
|
-
await setSessionHook(name, 'pane-died',
|
|
879
|
+
await setSessionHook(name, 'pane-died', agentPaneDiedHook(name, pane), socket);
|
|
880
|
+
// Stamp the schema marker so the daemon reconcile (which retrofits older
|
|
881
|
+
// sessions) recognizes this one as already current and skips it.
|
|
882
|
+
await markSessionHookSchema(name, socket);
|
|
852
883
|
// Record the agent's OS pid (the pane leaf, thanks to `exec`) WITH its tmux
|
|
853
884
|
// pane so the active-scan attributes it exactly and shows the %pane.
|
|
854
885
|
let panePid = 0;
|
|
@@ -866,14 +897,54 @@ async function runInTmux(options, executable, args) {
|
|
|
866
897
|
startedAtMs: Date.now(),
|
|
867
898
|
});
|
|
868
899
|
}
|
|
900
|
+
// Recap a dead pane's tail into THIS shell's stderr. The pane-died hook
|
|
901
|
+
// detaches the client the instant the agent exits, so a fast failure (a
|
|
902
|
+
// gutted install that dies with ENOENT, a bad flag, a crash on startup) would
|
|
903
|
+
// otherwise leave only a bare `[detached]` with no clue why. Must run BEFORE
|
|
904
|
+
// killSession — capture-pane needs the session still alive (remain-on-exit
|
|
905
|
+
// keeps the dead pane readable until we tear it down). Best-effort throughout.
|
|
906
|
+
const surfacePaneFailure = async (status, headline) => {
|
|
907
|
+
if (!pane)
|
|
908
|
+
return;
|
|
909
|
+
let tail = '';
|
|
910
|
+
try {
|
|
911
|
+
const r = await runTmux({ socket, args: ['capture-pane', '-p', '-t', pane, '-S', '-200'], throwOnError: false });
|
|
912
|
+
if (r.code === 0)
|
|
913
|
+
tail = formatPaneTail(r.stdout);
|
|
914
|
+
}
|
|
915
|
+
catch { /* best-effort — a missing pane just means no recap */ }
|
|
916
|
+
const RED = '\x1b[31m', GRAY = '\x1b[90m', OFF = '\x1b[0m';
|
|
917
|
+
process.stderr.write(`\n${RED}agents: ${headline} (exit ${status ?? 1}).${OFF}\n`);
|
|
918
|
+
if (tail) {
|
|
919
|
+
process.stderr.write(`${GRAY} ── last output from ${options.agent} ──${OFF}\n`);
|
|
920
|
+
process.stderr.write(tail.replace(/^/gm, ' ') + '\n');
|
|
921
|
+
process.stderr.write(`${GRAY} ${'─'.repeat(30)}${OFF}\n`);
|
|
922
|
+
}
|
|
923
|
+
process.stderr.write(`${GRAY} Tip: re-run with --no-tmux to launch the agent directly and see its full output.${OFF}\n\n`);
|
|
924
|
+
};
|
|
869
925
|
// The agent could exit before we attach (fast failure). Don't attach to an
|
|
870
|
-
// already-dead pane —
|
|
926
|
+
// already-dead pane — surface its output + status directly and tear down.
|
|
871
927
|
const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
872
|
-
if (
|
|
873
|
-
|
|
928
|
+
if (before.dead) {
|
|
929
|
+
// Only recap a FAILURE. A clean (0) exit before we attached is a successful
|
|
930
|
+
// quick run, not a crash — a red banner there would be spurious (mirrors the
|
|
931
|
+
// post-attach guard below).
|
|
932
|
+
if ((before.status ?? 0) !== 0) {
|
|
933
|
+
await surfacePaneFailure(before.status, `${options.agent} exited before it could start`);
|
|
934
|
+
}
|
|
935
|
+
await killSession(name, socket).catch(() => { });
|
|
936
|
+
return { exitCode: before.status ?? 0, stderr: '' };
|
|
874
937
|
}
|
|
938
|
+
await attachTmux({ socket, args: ['attach-session', '-t', name] });
|
|
875
939
|
const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
876
940
|
if (after.dead) {
|
|
941
|
+
// Nonzero exit after attach → the agent crashed rather than the user
|
|
942
|
+
// detaching cleanly (a clean detach leaves the pane ALIVE, handled below).
|
|
943
|
+
// The pane-died hook may have yanked the view before the error was readable,
|
|
944
|
+
// so recap it into the shell. A clean (0) exit stays quiet — nothing to say.
|
|
945
|
+
if ((after.status ?? 0) !== 0) {
|
|
946
|
+
await surfacePaneFailure(after.status, `${options.agent} exited`);
|
|
947
|
+
}
|
|
877
948
|
await killSession(name, socket).catch(() => { });
|
|
878
949
|
return { exitCode: after.status ?? 0, stderr: '' };
|
|
879
950
|
}
|
|
@@ -900,6 +971,12 @@ async function spawnAgent(options) {
|
|
|
900
971
|
if (options.agent === 'claude' && !options.resume && !options.sessionId) {
|
|
901
972
|
options = { ...options, sessionId: randomUUID() };
|
|
902
973
|
}
|
|
974
|
+
// Record the run's --name against its session id (when both are known at
|
|
975
|
+
// launch) so `agents sessions <name>` resolves it. Best-effort; unnamed runs
|
|
976
|
+
// and agents whose id isn't known up front simply skip this.
|
|
977
|
+
if (options.name && options.sessionId) {
|
|
978
|
+
recordRunName({ sessionId: options.sessionId, name: options.name, agent: options.agent, cwd: options.cwd });
|
|
979
|
+
}
|
|
903
980
|
const cmd = buildExecCommand(options);
|
|
904
981
|
const [executable, ...args] = cmd;
|
|
905
982
|
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,
|