@phnx-labs/agents-cli 1.22.51 → 1.22.52
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 +92 -0
- package/dist/commands/attach.js +7 -0
- package/dist/commands/browser.js +118 -56
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +8 -4
- package/dist/commands/detach.js +1 -1
- package/dist/commands/focus.d.ts +1 -10
- package/dist/commands/focus.js +14 -79
- package/dist/commands/go.d.ts +26 -0
- package/dist/commands/go.js +63 -5
- package/dist/commands/monitors.js +1 -1
- package/dist/commands/repo.js +31 -3
- package/dist/commands/sessions-resume.d.ts +1 -0
- package/dist/commands/sessions-resume.js +13 -2
- package/dist/commands/sessions-stop.js +1 -1
- package/dist/commands/sessions.d.ts +23 -13
- package/dist/commands/sessions.js +40 -20
- package/dist/commands/setup-browser.d.ts +5 -2
- package/dist/commands/setup-browser.js +14 -29
- package/dist/commands/setup-preferences.d.ts +22 -3
- package/dist/commands/setup-preferences.js +25 -8
- package/dist/commands/share.js +12 -8
- package/dist/commands/status.js +5 -0
- package/dist/commands/sync.js +58 -2
- package/dist/commands/tmux.d.ts +8 -1
- package/dist/commands/tmux.js +167 -17
- package/dist/lib/browser/ipc.d.ts +44 -0
- package/dist/lib/browser/ipc.js +120 -8
- package/dist/lib/browser/profiles.d.ts +39 -17
- package/dist/lib/browser/profiles.js +51 -52
- package/dist/lib/browser/runtime-state.d.ts +4 -2
- package/dist/lib/browser/runtime-state.js +4 -2
- package/dist/lib/browser/service.js +4 -3
- package/dist/lib/channels/owner-forward.d.ts +88 -0
- package/dist/lib/channels/owner-forward.js +116 -0
- package/dist/lib/channels/owner-sink.js +7 -0
- package/dist/lib/exec.d.ts +8 -0
- package/dist/lib/exec.js +7 -0
- package/dist/lib/feed-broadcast.js +15 -1
- package/dist/lib/git.d.ts +93 -0
- package/dist/lib/git.js +232 -0
- package/dist/lib/monitors/remote.d.ts +18 -1
- package/dist/lib/monitors/remote.js +15 -2
- package/dist/lib/notify.d.ts +7 -0
- package/dist/lib/notify.js +15 -1
- package/dist/lib/session/local-tmux-attach.d.ts +69 -0
- package/dist/lib/session/local-tmux-attach.js +164 -0
- package/dist/lib/session/remote-active.d.ts +8 -0
- package/dist/lib/session/remote-active.js +1 -0
- package/dist/lib/share/publish.d.ts +8 -11
- package/dist/lib/share/publish.js +16 -20
- package/dist/lib/share/worker-template.js +99 -12
- package/dist/lib/sync-status.d.ts +17 -0
- package/dist/lib/sync-status.js +21 -2
- package/dist/lib/tmux/index.d.ts +1 -1
- package/dist/lib/tmux/index.js +1 -1
- package/dist/lib/tmux/session.d.ts +10 -0
- package/dist/lib/tmux/session.js +29 -0
- package/package.json +1 -1
package/dist/commands/share.js
CHANGED
|
@@ -22,6 +22,8 @@ export function formatSharePublishResult(result, json = false) {
|
|
|
22
22
|
if (json)
|
|
23
23
|
return JSON.stringify(result, null, 2);
|
|
24
24
|
const lines = [chalk.green(result.url)];
|
|
25
|
+
if (result.slug)
|
|
26
|
+
lines.push(chalk.dim(` slug: ${result.slug}`));
|
|
25
27
|
if (result.label) {
|
|
26
28
|
const hint = result.labelSource === 'derived' ? chalk.dim(' (derived — pass --label to set one)') : '';
|
|
27
29
|
lines.push(chalk.dim(` "${result.label}"`) + hint);
|
|
@@ -436,7 +438,7 @@ export function registerShareCommands(artifactsCmd) {
|
|
|
436
438
|
.command('share')
|
|
437
439
|
.description('Publish an HTML file to a shareable link — managed if signed in, otherwise your Cloudflare R2.')
|
|
438
440
|
.argument('[file]', 'file to publish (HTML or any static asset)')
|
|
439
|
-
.option('--slug <slug>', '
|
|
441
|
+
.option('--slug <slug>', 'URL slug override (default: stable slug of the artifact title, then filename)')
|
|
440
442
|
.option('--github-user <user>', 'GitHub username for the share namespace (default: resolved from gh/git config; ignored on the managed endpoint)')
|
|
441
443
|
.option('--expire <spec>', "auto-expire (default 30d). e.g. 12h, 30d, 2026-08-01, or 'never'")
|
|
442
444
|
.addOption(new Option('--visibility <level>', 'public | unlisted | me | org (default public). unlisted is a capability URL; me/org require a Phoenix session and are hidden from the gallery')
|
|
@@ -497,17 +499,17 @@ export function registerShareCommands(artifactsCmd) {
|
|
|
497
499
|
# Hide from the public gallery (direct URL still works, noindex) and expire sooner
|
|
498
500
|
agents artifacts share ./out/report.html --visibility unlisted --expire 12h
|
|
499
501
|
|
|
500
|
-
# Permanent public page (
|
|
501
|
-
agents artifacts share ./out/landing.html --
|
|
502
|
+
# Permanent public page (the slug is derived from its title)
|
|
503
|
+
agents artifacts share ./out/landing.html --expire never
|
|
502
504
|
|
|
503
|
-
#
|
|
505
|
+
# Optional slug override
|
|
504
506
|
agents artifacts share ./out/report.html --slug q3-report --expire 7d
|
|
505
507
|
|
|
506
508
|
# Human title + structured metadata, shown in the gallery and share list
|
|
507
509
|
agents artifacts share ./out/plan.html --label "Q3 fleet plan" --meta kind=plan --meta ticket=RUSH-2683
|
|
508
510
|
|
|
509
|
-
# Republish
|
|
510
|
-
agents artifacts share ./out/plan.html --
|
|
511
|
+
# Republish the same title to the same URL without retaining a revision
|
|
512
|
+
agents artifacts share ./out/plan.html --no-revision
|
|
511
513
|
${SHARE_DELETE_EXAMPLES}
|
|
512
514
|
# One-time setup (or join an existing endpoint)
|
|
513
515
|
agents artifacts setup
|
|
@@ -519,8 +521,10 @@ ${SHARE_DELETE_EXAMPLES}
|
|
|
519
521
|
notes: `
|
|
520
522
|
Signed-in users publish to the managed endpoint (share.agents-cli.sh/<handle>/…)
|
|
521
523
|
with the Phoenix session — no Cloudflare account, bucket, or write token. The
|
|
522
|
-
handle is the local-part of the signed-in email
|
|
523
|
-
|
|
524
|
+
handle is the local-part of the signed-in email. The default slug is derived
|
|
525
|
+
deterministically from the HTML <title> or Markdown frontmatter title, then
|
|
526
|
+
the filename, so republishing the same title updates the same URL. --slug is
|
|
527
|
+
an optional override. Without a session, the existing BYO Cloudflare path still
|
|
524
528
|
applies (setup / join). HTML is stored as one object: local images are inlined
|
|
525
529
|
and Chrome-saved file:// TOC links become in-page hashes.
|
|
526
530
|
|
package/dist/commands/status.js
CHANGED
|
@@ -75,6 +75,11 @@ export function registerStatusCommand(syncCmd) {
|
|
|
75
75
|
else {
|
|
76
76
|
console.log(` ${'.system repo'.padEnd(28)} ${chalk.green('up to date')}`);
|
|
77
77
|
}
|
|
78
|
+
// A non-git / partial ~/.agents is its own drift state, not a per-agent
|
|
79
|
+
// "N missing" — surface it distinctly so the real problem isn't buried (PHNX-3301).
|
|
80
|
+
if (status.user.notGitRepo) {
|
|
81
|
+
console.log(` ${'~/.agents (user repo)'.padEnd(28)} ${chalk.yellow('not a git repo — will adopt on next `agents repo sync user`')}`);
|
|
82
|
+
}
|
|
78
83
|
if (status.agents.length === 0) {
|
|
79
84
|
console.log(chalk.gray(' (no installed agent versions)'));
|
|
80
85
|
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -44,7 +44,7 @@ import { formatKeptProjectResources } from '../lib/project-resources.js';
|
|
|
44
44
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
45
45
|
import { runUmbrellaSync } from '../lib/sync-umbrella.js';
|
|
46
46
|
import { addHostOption } from '../lib/hosts/option.js';
|
|
47
|
-
import { syncRepoGit } from '../lib/git.js';
|
|
47
|
+
import { syncRepoGit, adoptUserRepoIfNeeded, recordUserRepoRemote, resolveUserRepoRemoteUrl } from '../lib/git.js';
|
|
48
48
|
import { getSystemAgentsDir, getUserAgentsDir, getEnabledExtraRepos } from '../lib/state.js';
|
|
49
49
|
import { registerStatusCommand } from './status.js';
|
|
50
50
|
/** Emit one JSON object to stdout for `--json` callers / fleet fan-out. */
|
|
@@ -206,6 +206,37 @@ async function runRepoGitSync(repo, quiet, outLog, errLog, json = false) {
|
|
|
206
206
|
}
|
|
207
207
|
if (!quiet && !json)
|
|
208
208
|
outLog(chalk.bold(`Syncing ${repo} repo…`) + chalk.gray(` (${target.dir})`));
|
|
209
|
+
// Self-heal a non-git / partial user checkout in place before the git sync
|
|
210
|
+
// (PHNX-3301) — otherwise syncRepoGit hard-fails with "Not a git repo" and the
|
|
211
|
+
// only fix is a destructive re-clone. Only the user repo adopts: system is
|
|
212
|
+
// cloned by setup, extras by `repo add`.
|
|
213
|
+
if (repo === 'user') {
|
|
214
|
+
const adopted = await adoptUserRepoIfNeeded(target.dir);
|
|
215
|
+
if (adopted && !adopted.success) {
|
|
216
|
+
const hint = adopted.needsUrl
|
|
217
|
+
? ' — git-back it: agents repo pull user <git-url>'
|
|
218
|
+
: '';
|
|
219
|
+
if (json) {
|
|
220
|
+
emitJson({ ok: false, mode: 'repo-git', repo, error: `${adopted.error}${hint}` });
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
errLog(chalk.red(`sync ${repo} failed: ${adopted.error}`));
|
|
224
|
+
if (adopted.needsUrl)
|
|
225
|
+
errLog(chalk.gray(' git-back it: agents repo pull user <git-url>'));
|
|
226
|
+
}
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (adopted?.success && !quiet && !json) {
|
|
231
|
+
outLog(chalk.green(` adopted ${repo} in place → ${adopted.commit} (${adopted.materialized} file(s) materialized${adopted.reconciledAgentsYaml ? ', agents.yaml reconciled' : ''})`));
|
|
232
|
+
if (adopted.localEdits.length > 0) {
|
|
233
|
+
outLog(chalk.yellow(` kept ${adopted.localEdits.length} local edit(s): ${adopted.localEdits.slice(0, 5).join(', ')}${adopted.localEdits.length > 5 ? ', …' : ''}`));
|
|
234
|
+
}
|
|
235
|
+
if (adopted.agentsYamlBackup) {
|
|
236
|
+
outLog(chalk.gray(` saved the previous agents.yaml to ${adopted.agentsYamlBackup}`));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
209
240
|
const result = await syncRepoGit(target.dir, { push: target.push });
|
|
210
241
|
if (!result.success) {
|
|
211
242
|
if (json) {
|
|
@@ -217,6 +248,14 @@ async function runRepoGitSync(repo, quiet, outLog, errLog, json = false) {
|
|
|
217
248
|
process.exitCode = 1;
|
|
218
249
|
return;
|
|
219
250
|
}
|
|
251
|
+
// Record the resolved remote so a future partial box (lost .git) can adopt in
|
|
252
|
+
// place without the operator re-typing the URL (PHNX-3301). Before the --json
|
|
253
|
+
// early-return so the record is refreshed on every healthy sync, JSON or not.
|
|
254
|
+
if (repo === 'user') {
|
|
255
|
+
const u = resolveUserRepoRemoteUrl(target.dir);
|
|
256
|
+
if (u)
|
|
257
|
+
recordUserRepoRemote(target.dir, u);
|
|
258
|
+
}
|
|
220
259
|
if (json) {
|
|
221
260
|
emitJson({
|
|
222
261
|
ok: true,
|
|
@@ -290,9 +329,26 @@ async function runInteractiveReconcile(opts, outLog, errLog) {
|
|
|
290
329
|
const target = resolveRepoGitTarget(repo);
|
|
291
330
|
if (!target)
|
|
292
331
|
continue;
|
|
332
|
+
// Adopt a non-git / partial user checkout in place before pulling (PHNX-3301).
|
|
333
|
+
if (repo === 'user') {
|
|
334
|
+
const adopted = await adoptUserRepoIfNeeded(target.dir);
|
|
335
|
+
if (adopted?.success) {
|
|
336
|
+
outLog(chalk.gray(` adopted user in place → ${adopted.commit} (${adopted.materialized} file(s) materialized)`));
|
|
337
|
+
}
|
|
338
|
+
else if (adopted && !adopted.success) {
|
|
339
|
+
outLog(chalk.yellow(` ! user: ${adopted.error}${adopted.needsUrl ? ' — agents repo pull user <git-url>' : ''}`));
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
293
343
|
const res = await syncRepoGit(target.dir, { push: false });
|
|
294
|
-
if (res.success)
|
|
344
|
+
if (res.success) {
|
|
295
345
|
outLog(chalk.gray(` pulled ${repo} → ${res.commit}`));
|
|
346
|
+
if (repo === 'user') {
|
|
347
|
+
const u = resolveUserRepoRemoteUrl(target.dir);
|
|
348
|
+
if (u)
|
|
349
|
+
recordUserRepoRemote(target.dir, u);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
296
352
|
else
|
|
297
353
|
outLog(chalk.yellow(` ! ${repo}: ${(res.error ?? 'pull failed').split('\n')[0]}`));
|
|
298
354
|
}
|
package/dist/commands/tmux.d.ts
CHANGED
|
@@ -17,9 +17,16 @@
|
|
|
17
17
|
* agents tmux send <name>[:pane] <keys> [--no-enter] [--raw]
|
|
18
18
|
* agents tmux capture <name>[:pane] [--lines N] [--ansi]
|
|
19
19
|
* agents tmux info <name> [--json]
|
|
20
|
-
* agents tmux kill
|
|
20
|
+
* agents tmux kill [name] no name → picker with live pane preview
|
|
21
21
|
* agents tmux kill-all [--yes]
|
|
22
22
|
*/
|
|
23
23
|
import type { Command } from 'commander';
|
|
24
|
+
import { type ListedSession } from '../lib/tmux/index.js';
|
|
24
25
|
/** Register the `agents tmux` command tree. */
|
|
25
26
|
export declare function registerTmuxCommands(program: Command): void;
|
|
27
|
+
/** Last useful line of a pane capture — what `tmux ls` shows so a name isn't a black box. */
|
|
28
|
+
export declare function tmuxScreenSnippet(raw: string, max?: number): string;
|
|
29
|
+
/** Last non-empty lines of a pane capture, for the kill-picker preview. */
|
|
30
|
+
export declare function tmuxScreenPreview(raw: string, maxLines?: number): string;
|
|
31
|
+
export declare function formatTmuxKillLabel(s: ListedSession, snippet: string, nowMs?: number): string;
|
|
32
|
+
export declare function formatTmuxKillPreview(s: ListedSession, raw: string, nowMs?: number): string;
|
package/dist/commands/tmux.js
CHANGED
|
@@ -17,14 +17,15 @@
|
|
|
17
17
|
* agents tmux send <name>[:pane] <keys> [--no-enter] [--raw]
|
|
18
18
|
* agents tmux capture <name>[:pane] [--lines N] [--ansi]
|
|
19
19
|
* agents tmux info <name> [--json]
|
|
20
|
-
* agents tmux kill
|
|
20
|
+
* agents tmux kill [name] no name → picker with live pane preview
|
|
21
21
|
* agents tmux kill-all [--yes]
|
|
22
22
|
*/
|
|
23
23
|
import chalk from 'chalk';
|
|
24
|
-
import { truncate } from '../lib/format.js';
|
|
24
|
+
import { truncate, isInteractiveTerminal, isPromptCancelled } from '../lib/format.js';
|
|
25
25
|
import * as path from 'path';
|
|
26
26
|
import { setHelpSections } from '../lib/help.js';
|
|
27
|
-
import {
|
|
27
|
+
import { multiItemPicker } from '../lib/picker.js';
|
|
28
|
+
import { assertTmuxAvailable, attachTmux, capturePane, createSession, ensureSessionHookRepaired, getDefaultSocketPath, getTmuxVersion, hasSession, isTmuxInstalled, isTmuxVersionSupported, killAll, killSession, teardownIfAgentExited, listSessions, MIN_TMUX_VERSION, readSessionMeta, sendKeys, splitPane, TmuxCommandError, TmuxSessionError, TmuxUnavailableError, } from '../lib/tmux/index.js';
|
|
28
29
|
/** Register the `agents tmux` command tree. */
|
|
29
30
|
export function registerTmuxCommands(program) {
|
|
30
31
|
const tmux = program
|
|
@@ -47,8 +48,9 @@ export function registerTmuxCommands(program) {
|
|
|
47
48
|
# Send a slash command from a script
|
|
48
49
|
agents tmux send claude-debug "/clear"
|
|
49
50
|
|
|
50
|
-
# Clean up
|
|
51
|
+
# Clean up one by name, or pick from a list that shows each pane's last lines
|
|
51
52
|
agents tmux kill claude-debug
|
|
53
|
+
agents tmux kill
|
|
52
54
|
`,
|
|
53
55
|
notes: `
|
|
54
56
|
Storage:
|
|
@@ -145,7 +147,7 @@ export function registerTmuxCommands(program) {
|
|
|
145
147
|
// ─── attach ─────────────────────────────────────────────────────────────────
|
|
146
148
|
const attachCmd = tmux
|
|
147
149
|
.command('attach <name>')
|
|
148
|
-
.description('Attach to a running session. Replaces this shell with the tmux client until you detach (Ctrl-b d).')
|
|
150
|
+
.description('Attach to a running session. Replaces this shell with the tmux client until you detach (Ctrl-b d) or the agent exits (session is then torn down).')
|
|
149
151
|
.option('--socket <path>', 'Use a custom socket (default: shared server)');
|
|
150
152
|
attachCmd.action(async (name, opts) => {
|
|
151
153
|
await guardTmux(async () => {
|
|
@@ -163,6 +165,11 @@ export function registerTmuxCommands(program) {
|
|
|
163
165
|
// deleted; attach-time repair is what closes the gap now (RUSH-2435).
|
|
164
166
|
await ensureSessionHookRepaired(name, socket);
|
|
165
167
|
const code = await attachTmux({ socket, args: ['attach-session', '-t', `=${name}`] });
|
|
168
|
+
// The v6 pane-died hook only detach-clients when a client is attached
|
|
169
|
+
// (so runInTmux can read the exit status). Attach verbs must kill the
|
|
170
|
+
// husk themselves — otherwise `tmux ls` still lists the session after
|
|
171
|
+
// the agent exits (PHNX-3293).
|
|
172
|
+
await teardownIfAgentExited(name, socket);
|
|
166
173
|
process.exit(code);
|
|
167
174
|
});
|
|
168
175
|
});
|
|
@@ -170,25 +177,34 @@ export function registerTmuxCommands(program) {
|
|
|
170
177
|
const listCmd = tmux
|
|
171
178
|
.command('list')
|
|
172
179
|
.alias('ls')
|
|
173
|
-
.description('List live tmux sessions on the shared server. Prunes stale meta files as a side effect.')
|
|
180
|
+
.description('List live tmux sessions on the shared server (name, age, last screen line). Prunes stale meta files as a side effect.')
|
|
174
181
|
.option('--socket <path>', 'Use a custom socket (default: shared server)')
|
|
175
182
|
.option('--json', 'Output as JSON');
|
|
176
183
|
listCmd.action(async (opts) => {
|
|
177
184
|
await guardTmux(async () => {
|
|
178
185
|
const sessions = await listSessions({ socket: opts.socket });
|
|
179
|
-
if (opts.json) {
|
|
180
|
-
console.log(JSON.stringify(sessions));
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
186
|
if (sessions.length === 0) {
|
|
187
|
+
if (opts.json) {
|
|
188
|
+
console.log('[]');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
184
191
|
console.log(chalk.gray('No tmux sessions.'));
|
|
185
192
|
return;
|
|
186
193
|
}
|
|
194
|
+
const screens = await captureScreens(sessions, opts.socket);
|
|
195
|
+
if (opts.json) {
|
|
196
|
+
console.log(JSON.stringify(sessions.map((s) => ({
|
|
197
|
+
...s,
|
|
198
|
+
preview: tmuxScreenSnippet(screens.get(s.name) ?? ''),
|
|
199
|
+
}))));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
187
202
|
for (const s of sessions) {
|
|
188
203
|
const age = formatAge(Date.now() / 1000 - s.createdAtTmux);
|
|
189
|
-
const cmd = s.meta?.cmd ? chalk.gray(` ${truncate(s.meta.cmd, 60)}`) : '';
|
|
190
204
|
const attached = s.attached ? chalk.green(' [attached]') : '';
|
|
191
|
-
|
|
205
|
+
const snippet = tmuxScreenSnippet(screens.get(s.name) ?? '');
|
|
206
|
+
const preview = snippet ? chalk.gray(` ${snippet}`) : '';
|
|
207
|
+
console.log(` ${chalk.bold(s.name)} ${s.windows}w ${age}${attached}${preview}`);
|
|
192
208
|
}
|
|
193
209
|
});
|
|
194
210
|
});
|
|
@@ -329,15 +345,35 @@ export function registerTmuxCommands(program) {
|
|
|
329
345
|
});
|
|
330
346
|
// ─── kill ───────────────────────────────────────────────────────────────────
|
|
331
347
|
const killCmd = tmux
|
|
332
|
-
.command('kill
|
|
333
|
-
.description('Kill one tmux session
|
|
348
|
+
.command('kill [name]')
|
|
349
|
+
.description('Kill one tmux session, or pick from a list that shows each pane\'s last screen. Idempotent.')
|
|
334
350
|
.option('--socket <path>', 'Use a custom socket (default: shared server)');
|
|
351
|
+
setHelpSections(killCmd, {
|
|
352
|
+
examples: `
|
|
353
|
+
# Kill by name
|
|
354
|
+
agents tmux kill ag-claude-04a1307c
|
|
355
|
+
|
|
356
|
+
# Pick from live panes — preview shows the last lines on screen
|
|
357
|
+
# (trust-folder stall, weekly-limit dialog, real work)
|
|
358
|
+
agents tmux kill
|
|
359
|
+
`,
|
|
360
|
+
notes: `
|
|
361
|
+
With no name, the picker captures each pane so you can tell a leaked
|
|
362
|
+
first-run dialog from an agent that is still working. Space multi-selects;
|
|
363
|
+
enter kills the checked rows (or the highlighted one if none are checked).
|
|
364
|
+
Ctrl-b d still keeps a live session; exiting the agent tears it down.
|
|
365
|
+
`,
|
|
366
|
+
});
|
|
335
367
|
killCmd.action(async (name, opts) => {
|
|
336
368
|
await guardTmux(async () => {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
369
|
+
if (name) {
|
|
370
|
+
const killed = await killSession(name, opts.socket);
|
|
371
|
+
if (!killed) {
|
|
372
|
+
console.log(chalk.gray(`No session "${name}" — nothing to do.`));
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
340
375
|
}
|
|
376
|
+
await pickAndKillSessions(opts.socket);
|
|
341
377
|
});
|
|
342
378
|
});
|
|
343
379
|
// ─── kill-all ───────────────────────────────────────────────────────────────
|
|
@@ -399,6 +435,120 @@ function collectLabel(value, acc) {
|
|
|
399
435
|
acc[value.slice(0, eq)] = value.slice(eq + 1);
|
|
400
436
|
return acc;
|
|
401
437
|
}
|
|
438
|
+
/** Last useful line of a pane capture — what `tmux ls` shows so a name isn't a black box. */
|
|
439
|
+
export function tmuxScreenSnippet(raw, max = 72) {
|
|
440
|
+
const lines = raw
|
|
441
|
+
.split('\n')
|
|
442
|
+
.map((l) => l.replace(/\s+/g, ' ').trim())
|
|
443
|
+
.filter((l) => l.length > 0 && !/^[─═│┃┌┐└┘╰╯╭╮\s|-]+$/.test(l) && !/enter to confirm|esc to cancel|tab:next/i.test(l));
|
|
444
|
+
const tail = lines.slice(-8);
|
|
445
|
+
const preferred = [...tail].reverse().find((l) => /[?❯]|trust this folder|weekly limit|needs.you|waiting/i.test(l));
|
|
446
|
+
return truncate(preferred ?? tail[tail.length - 1] ?? '', max);
|
|
447
|
+
}
|
|
448
|
+
/** Last non-empty lines of a pane capture, for the kill-picker preview. */
|
|
449
|
+
export function tmuxScreenPreview(raw, maxLines = 16) {
|
|
450
|
+
return raw
|
|
451
|
+
.split('\n')
|
|
452
|
+
.map((l) => l.replace(/\s+$/, ''))
|
|
453
|
+
.filter((l) => l.length > 0)
|
|
454
|
+
.slice(-maxLines)
|
|
455
|
+
.join('\n');
|
|
456
|
+
}
|
|
457
|
+
export function formatTmuxKillLabel(s, snippet, nowMs = Date.now()) {
|
|
458
|
+
const age = formatAge(nowMs / 1000 - s.createdAtTmux);
|
|
459
|
+
const agent = s.meta?.labels?.agent;
|
|
460
|
+
const bits = [s.name, age];
|
|
461
|
+
if (agent)
|
|
462
|
+
bits.push(agent);
|
|
463
|
+
if (snippet)
|
|
464
|
+
bits.push(snippet);
|
|
465
|
+
return bits.join(' ');
|
|
466
|
+
}
|
|
467
|
+
export function formatTmuxKillPreview(s, raw, nowMs = Date.now()) {
|
|
468
|
+
const age = formatAge(nowMs / 1000 - s.createdAtTmux);
|
|
469
|
+
const agent = s.meta?.labels?.agent;
|
|
470
|
+
const header = [
|
|
471
|
+
chalk.bold(s.name),
|
|
472
|
+
agent ? chalk.cyan(agent) : '',
|
|
473
|
+
chalk.gray(age),
|
|
474
|
+
s.attached ? chalk.green('attached') : chalk.gray('detached'),
|
|
475
|
+
].filter(Boolean).join(' ');
|
|
476
|
+
const meta = [
|
|
477
|
+
s.meta?.cwd ? `cwd ${s.meta.cwd}` : '',
|
|
478
|
+
s.meta?.cmd ? `cmd ${truncate(s.meta.cmd, 80)}` : '',
|
|
479
|
+
].filter(Boolean).join('\n');
|
|
480
|
+
const body = tmuxScreenPreview(raw) || chalk.gray('(empty pane)');
|
|
481
|
+
return [header, meta, '', chalk.gray('── last screen ──'), body].filter((line) => line !== '').join('\n');
|
|
482
|
+
}
|
|
483
|
+
async function captureScreens(sessions, socket) {
|
|
484
|
+
const rows = await Promise.all(sessions.map(async (s) => {
|
|
485
|
+
try {
|
|
486
|
+
const raw = await capturePane({ name: s.name, socket, lines: 40 });
|
|
487
|
+
return [s.name, raw];
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
return [s.name, ''];
|
|
491
|
+
}
|
|
492
|
+
}));
|
|
493
|
+
return new Map(rows);
|
|
494
|
+
}
|
|
495
|
+
async function pickAndKillSessions(socket) {
|
|
496
|
+
if (!isInteractiveTerminal()) {
|
|
497
|
+
console.error(chalk.red('Pass a session name, or run from a TTY to pick from a list with pane previews.'));
|
|
498
|
+
console.error(chalk.gray(' agents tmux ls # names + last screen line'));
|
|
499
|
+
console.error(chalk.gray(' agents tmux kill <name>'));
|
|
500
|
+
process.exitCode = 1;
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const sessions = await listSessions({ socket });
|
|
504
|
+
if (sessions.length === 0) {
|
|
505
|
+
console.log(chalk.gray('No tmux sessions.'));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const screens = await captureScreens(sessions, socket);
|
|
509
|
+
let chosen;
|
|
510
|
+
try {
|
|
511
|
+
chosen = await multiItemPicker({
|
|
512
|
+
message: 'Kill which tmux sessions?',
|
|
513
|
+
items: sessions,
|
|
514
|
+
filter: (q) => {
|
|
515
|
+
const needle = q.trim().toLowerCase();
|
|
516
|
+
if (!needle)
|
|
517
|
+
return sessions;
|
|
518
|
+
return sessions.filter((s) => {
|
|
519
|
+
const hay = [
|
|
520
|
+
s.name,
|
|
521
|
+
s.meta?.labels?.agent,
|
|
522
|
+
s.meta?.cwd,
|
|
523
|
+
s.meta?.cmd,
|
|
524
|
+
screens.get(s.name),
|
|
525
|
+
].join(' ').toLowerCase();
|
|
526
|
+
return hay.includes(needle);
|
|
527
|
+
});
|
|
528
|
+
},
|
|
529
|
+
labelFor: (s) => formatTmuxKillLabel(s, tmuxScreenSnippet(screens.get(s.name) ?? '')),
|
|
530
|
+
keyFor: (s) => s.name,
|
|
531
|
+
buildPreview: (s) => formatTmuxKillPreview(s, screens.get(s.name) ?? ''),
|
|
532
|
+
pageSize: 12,
|
|
533
|
+
emptyMessage: 'No sessions match.',
|
|
534
|
+
enterHint: 'kill',
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
catch (err) {
|
|
538
|
+
if (isPromptCancelled(err))
|
|
539
|
+
return;
|
|
540
|
+
throw err;
|
|
541
|
+
}
|
|
542
|
+
if (!chosen || chosen.length === 0)
|
|
543
|
+
return;
|
|
544
|
+
for (const s of chosen) {
|
|
545
|
+
const killed = await killSession(s.name, socket);
|
|
546
|
+
if (killed)
|
|
547
|
+
console.log(chalk.gray(`Killed ${s.name}`));
|
|
548
|
+
else
|
|
549
|
+
console.log(chalk.gray(`No session "${s.name}" — already gone.`));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
402
552
|
function formatAge(secs) {
|
|
403
553
|
if (secs < 60)
|
|
404
554
|
return `${Math.max(0, Math.floor(secs))}s`;
|
|
@@ -12,6 +12,50 @@ export declare function getSocketPath(): string;
|
|
|
12
12
|
/** Is the daemon reachable? A real connect probe on every platform — a socket
|
|
13
13
|
* file existing on disk is not proof a daemon is listening on it. */
|
|
14
14
|
export declare function isDaemonReachable(): Promise<boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* Wait until the browser daemon is genuinely reachable, or throw.
|
|
17
|
+
*
|
|
18
|
+
* Re-probes across the whole window rather than latching on the first accept, so
|
|
19
|
+
* it survives an IPC-server restart that happens mid-wait (PHNX-3289): a restart
|
|
20
|
+
* just resets the consecutive-accept counter, and the loop keeps going until the
|
|
21
|
+
* daemon is *stably* up or the deadline passes. Bounded and fail-loud — a daemon
|
|
22
|
+
* that never comes up throws a message naming the endpoint and the budget, never
|
|
23
|
+
* a silent hang.
|
|
24
|
+
*/
|
|
25
|
+
export declare function waitForSocket(_socketPath: string, timeoutMs?: number): Promise<void>;
|
|
26
|
+
/** Outcome of {@link resetBrowserDaemon} — what the wedge-recovery actually did. */
|
|
27
|
+
export interface BrowserDaemonResetResult {
|
|
28
|
+
/** The daemon was reachable before the reset and a stop was issued. */
|
|
29
|
+
wasRunning: boolean;
|
|
30
|
+
/** A leftover `browser.sock` file was unlinked (POSIX only). */
|
|
31
|
+
socketCleared: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Clear a wedged browser daemon so a subsequent `start` comes up clean
|
|
35
|
+
* (PHNX-3289). Stops the shared daemon (the same `stopDaemon` path
|
|
36
|
+
* `reconcileDaemonVersion` uses for a stale-version restart), waits for the IPC
|
|
37
|
+
* endpoint to stop accepting, then unlinks any stale `browser.sock` a
|
|
38
|
+
* hard-crashed daemon left behind — the file a fresh `start` would otherwise
|
|
39
|
+
* `unlink` blindly, racing whatever still holds it.
|
|
40
|
+
*
|
|
41
|
+
* Fails loud: if the endpoint is *still* reachable after the quiesce window, a
|
|
42
|
+
* live server is holding it and clearing the socket under it would orphan two
|
|
43
|
+
* servers on one path, so we throw rather than pretend the reset worked. The
|
|
44
|
+
* daemon auto-restarts on the next browser command.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resetBrowserDaemon(): Promise<BrowserDaemonResetResult>;
|
|
47
|
+
/**
|
|
48
|
+
* Remove a leftover browser socket FILE, but only when nothing is listening on
|
|
49
|
+
* it — re-probing liveness IMMEDIATELY before the unlink to close the TOCTOU
|
|
50
|
+
* window (PHNX-3289 review). Between {@link resetBrowserDaemon}'s quiesce loop
|
|
51
|
+
* deciding the endpoint was unreachable and this unlink, a concurrent
|
|
52
|
+
* `browser start` could bind a NEW listener on the same path; an unconditional
|
|
53
|
+
* unlink would then delete a LIVE daemon's socket — the exact two-servers orphan
|
|
54
|
+
* this function exists to prevent. A listener seen here means the wedge is already
|
|
55
|
+
* resolved (a fresh daemon owns the path), so its socket is left intact. Returns
|
|
56
|
+
* true only when a genuinely dead file was removed.
|
|
57
|
+
*/
|
|
58
|
+
export declare function clearDeadSocketFile(endpoint: string, socketPath: string): Promise<boolean>;
|
|
15
59
|
/**
|
|
16
60
|
* One long-lived connection to the existing browser daemon. Requests are
|
|
17
61
|
* serialized so the daemon's newline-delimited responses always map to the
|
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -69,6 +69,29 @@ const SOCKET_NAME = 'browser.sock';
|
|
|
69
69
|
* coming near the grace window.
|
|
70
70
|
*/
|
|
71
71
|
const IPC_CLOSE_TIMEOUT_MS = 1_500;
|
|
72
|
+
/**
|
|
73
|
+
* How long {@link waitForSocket} waits for the browser daemon to come up before
|
|
74
|
+
* failing loud (PHNX-3289).
|
|
75
|
+
*
|
|
76
|
+
* The old flat 6s ceiling was the wedge: the shared daemon's browser IPC server
|
|
77
|
+
* restarts (version reconcile, supervisor restart, a hard-crashed predecessor's
|
|
78
|
+
* successor claiming the socket), and a client that started its wait *during*
|
|
79
|
+
* one of those restart windows could burn the whole 6s and throw
|
|
80
|
+
* `Timeout waiting for browser daemon socket` on an otherwise-healthy daemon. A
|
|
81
|
+
* browser start/navigate then failed intermittently and self-healed on the next
|
|
82
|
+
* try. 15s comfortably spans a restart; the stable-probe requirement below is
|
|
83
|
+
* what keeps a socket that "appears and is immediately destroyed" (#556) from
|
|
84
|
+
* being mistaken for a ready daemon.
|
|
85
|
+
*/
|
|
86
|
+
const SOCKET_WAIT_TIMEOUT_MS = 15_000;
|
|
87
|
+
/**
|
|
88
|
+
* Consecutive successful probes required before {@link waitForSocket} declares
|
|
89
|
+
* the daemon ready. A single accept can land in the sliver between a restarting
|
|
90
|
+
* server binding and tearing back down; requiring two accepts ~100ms apart means
|
|
91
|
+
* we only return once the daemon is *staying* up, so the caller's real request
|
|
92
|
+
* doesn't race a restart it happened to probe mid-flight.
|
|
93
|
+
*/
|
|
94
|
+
const SOCKET_WAIT_STABLE_PROBES = 2;
|
|
72
95
|
export class BrowserDaemonNotRunningError extends Error {
|
|
73
96
|
constructor() {
|
|
74
97
|
super(formatBrowserDaemonNotRunningError());
|
|
@@ -78,8 +101,8 @@ export class BrowserDaemonNotRunningError extends Error {
|
|
|
78
101
|
export function formatBrowserDaemonNotRunningError() {
|
|
79
102
|
return [
|
|
80
103
|
'Browser daemon not running.',
|
|
81
|
-
'Start it with: agents browser start (
|
|
82
|
-
'
|
|
104
|
+
'Start it with: agents browser start (uses this machine\'s configured default browser)',
|
|
105
|
+
'Pick / pin a profile: agents browser use <name> (or: agents browser start --profile <name>)',
|
|
83
106
|
'List profiles: agents browser profiles list',
|
|
84
107
|
].join('\n');
|
|
85
108
|
}
|
|
@@ -117,15 +140,104 @@ function probeDaemon(endpoint, timeoutMs = 500) {
|
|
|
117
140
|
export async function isDaemonReachable() {
|
|
118
141
|
return probeDaemon(getIpcEndpoint());
|
|
119
142
|
}
|
|
120
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Wait until the browser daemon is genuinely reachable, or throw.
|
|
145
|
+
*
|
|
146
|
+
* Re-probes across the whole window rather than latching on the first accept, so
|
|
147
|
+
* it survives an IPC-server restart that happens mid-wait (PHNX-3289): a restart
|
|
148
|
+
* just resets the consecutive-accept counter, and the loop keeps going until the
|
|
149
|
+
* daemon is *stably* up or the deadline passes. Bounded and fail-loud — a daemon
|
|
150
|
+
* that never comes up throws a message naming the endpoint and the budget, never
|
|
151
|
+
* a silent hang.
|
|
152
|
+
*/
|
|
153
|
+
export async function waitForSocket(_socketPath, timeoutMs = SOCKET_WAIT_TIMEOUT_MS) {
|
|
121
154
|
const endpoint = getIpcEndpoint();
|
|
122
155
|
const deadline = Date.now() + timeoutMs;
|
|
156
|
+
let consecutive = 0;
|
|
123
157
|
while (Date.now() < deadline) {
|
|
124
|
-
if (await probeDaemon(endpoint))
|
|
125
|
-
|
|
158
|
+
if (await probeDaemon(endpoint)) {
|
|
159
|
+
consecutive += 1;
|
|
160
|
+
if (consecutive >= SOCKET_WAIT_STABLE_PROBES)
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
// A dropped probe means a restart (or the daemon isn't up yet) — start the
|
|
165
|
+
// stability count over rather than counting accepts from before the gap.
|
|
166
|
+
consecutive = 0;
|
|
167
|
+
}
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
169
|
+
}
|
|
170
|
+
throw new Error(`Timeout waiting for browser daemon socket after ${Math.round(timeoutMs / 1000)}s (${endpoint}).`);
|
|
171
|
+
}
|
|
172
|
+
/** How long {@link resetBrowserDaemon} waits for the endpoint to go quiet after
|
|
173
|
+
* signalling a stop before it clears the socket and re-checks. Bounded so the
|
|
174
|
+
* command fails loud instead of hanging on a daemon that will not die. */
|
|
175
|
+
const DAEMON_RESET_QUIESCE_MS = 5_000;
|
|
176
|
+
/**
|
|
177
|
+
* Clear a wedged browser daemon so a subsequent `start` comes up clean
|
|
178
|
+
* (PHNX-3289). Stops the shared daemon (the same `stopDaemon` path
|
|
179
|
+
* `reconcileDaemonVersion` uses for a stale-version restart), waits for the IPC
|
|
180
|
+
* endpoint to stop accepting, then unlinks any stale `browser.sock` a
|
|
181
|
+
* hard-crashed daemon left behind — the file a fresh `start` would otherwise
|
|
182
|
+
* `unlink` blindly, racing whatever still holds it.
|
|
183
|
+
*
|
|
184
|
+
* Fails loud: if the endpoint is *still* reachable after the quiesce window, a
|
|
185
|
+
* live server is holding it and clearing the socket under it would orphan two
|
|
186
|
+
* servers on one path, so we throw rather than pretend the reset worked. The
|
|
187
|
+
* daemon auto-restarts on the next browser command.
|
|
188
|
+
*/
|
|
189
|
+
export async function resetBrowserDaemon() {
|
|
190
|
+
const endpoint = getIpcEndpoint();
|
|
191
|
+
const wasRunning = await probeDaemon(endpoint);
|
|
192
|
+
stopDaemon();
|
|
193
|
+
// Wait for the listener to actually release the endpoint. We must decide
|
|
194
|
+
// reachability BEFORE touching the socket file: unlinking a path out from
|
|
195
|
+
// under a live server makes new connects ENOENT (so it would *look* cleared)
|
|
196
|
+
// while the server keeps running — the two-servers orphan the eviction
|
|
197
|
+
// protocol exists to prevent. So a still-reachable endpoint fails loud here,
|
|
198
|
+
// and only a genuinely dead one gets its stale file removed below.
|
|
199
|
+
const deadline = Date.now() + DAEMON_RESET_QUIESCE_MS;
|
|
200
|
+
let reachable = wasRunning;
|
|
201
|
+
while (reachable && Date.now() < deadline) {
|
|
126
202
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
203
|
+
reachable = await probeDaemon(endpoint);
|
|
204
|
+
}
|
|
205
|
+
if (reachable) {
|
|
206
|
+
throw new Error(actionable('Browser daemon is still reachable after stop — a live server is holding the socket.', `Endpoint: ${endpoint}`, 'Next: agents daemon status (find and stop the process holding it)'));
|
|
207
|
+
}
|
|
208
|
+
// Nothing is listening now — clear the leftover socket file a hard-crashed
|
|
209
|
+
// daemon left behind, so the next `start` binds clean instead of unlinking it
|
|
210
|
+
// blindly. (Named pipes vanish with their owning process, so Windows has no
|
|
211
|
+
// stale file to clear.)
|
|
212
|
+
const socketCleared = IS_WINDOWS ? false : await clearDeadSocketFile(endpoint, getSocketPath());
|
|
213
|
+
return { wasRunning, socketCleared };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Remove a leftover browser socket FILE, but only when nothing is listening on
|
|
217
|
+
* it — re-probing liveness IMMEDIATELY before the unlink to close the TOCTOU
|
|
218
|
+
* window (PHNX-3289 review). Between {@link resetBrowserDaemon}'s quiesce loop
|
|
219
|
+
* deciding the endpoint was unreachable and this unlink, a concurrent
|
|
220
|
+
* `browser start` could bind a NEW listener on the same path; an unconditional
|
|
221
|
+
* unlink would then delete a LIVE daemon's socket — the exact two-servers orphan
|
|
222
|
+
* this function exists to prevent. A listener seen here means the wedge is already
|
|
223
|
+
* resolved (a fresh daemon owns the path), so its socket is left intact. Returns
|
|
224
|
+
* true only when a genuinely dead file was removed.
|
|
225
|
+
*/
|
|
226
|
+
export async function clearDeadSocketFile(endpoint, socketPath) {
|
|
227
|
+
if (!fs.existsSync(socketPath))
|
|
228
|
+
return false;
|
|
229
|
+
// A listener bound again since the quiesce loop → do not touch its socket.
|
|
230
|
+
if (await probeDaemon(endpoint))
|
|
231
|
+
return false;
|
|
232
|
+
try {
|
|
233
|
+
fs.unlinkSync(socketPath);
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// Raced with a fresh start that already claimed it — the goal (no stale
|
|
238
|
+
// socket) still holds, so report cleared only when it is genuinely gone.
|
|
239
|
+
return !fs.existsSync(socketPath);
|
|
127
240
|
}
|
|
128
|
-
throw new Error('Timeout waiting for browser daemon socket');
|
|
129
241
|
}
|
|
130
242
|
/**
|
|
131
243
|
* One long-lived connection to the existing browser daemon. Requests are
|
|
@@ -865,7 +977,7 @@ async function reconcileDaemonVersion(socketPath) {
|
|
|
865
977
|
stopDaemon();
|
|
866
978
|
startDaemon();
|
|
867
979
|
if (!(await isDaemonReachable())) {
|
|
868
|
-
await waitForSocket(socketPath
|
|
980
|
+
await waitForSocket(socketPath);
|
|
869
981
|
}
|
|
870
982
|
await new Promise((r) => setTimeout(r, 300));
|
|
871
983
|
}
|
|
@@ -968,7 +1080,7 @@ async function prepareIPC(action, opts) {
|
|
|
968
1080
|
}
|
|
969
1081
|
startDaemon();
|
|
970
1082
|
if (!(await isDaemonReachable())) {
|
|
971
|
-
await waitForSocket(socketPath
|
|
1083
|
+
await waitForSocket(socketPath);
|
|
972
1084
|
}
|
|
973
1085
|
if (!(await isDaemonReachable())) {
|
|
974
1086
|
throw new Error(actionable('Failed to start browser daemon.', `Log: ${getDaemonLogPath()}`, 'Next: agents doctor (checks for a second agents-cli install)'));
|