@phnx-labs/agents-cli 1.22.50 → 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 +219 -0
- package/dist/bootstrap.js +1 -1
- package/dist/commands/attach.js +7 -0
- package/dist/commands/browser.d.ts +10 -0
- package/dist/commands/browser.js +191 -54
- package/dist/commands/config.js +20 -0
- 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/feedback.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/menubar.js +6 -4
- 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/commands/traces.js +32 -4
- 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/claude-statusline.d.ts +9 -0
- package/dist/lib/claude-statusline.js +45 -4
- package/dist/lib/computer/ssh-tunnel.d.ts +7 -6
- package/dist/lib/computer/ssh-tunnel.js +13 -8
- package/dist/lib/config-keys.d.ts +4 -3
- package/dist/lib/config-keys.js +9 -2
- package/dist/lib/device-config.js +23 -0
- package/dist/lib/exec.d.ts +8 -0
- package/dist/lib/exec.js +7 -0
- package/dist/lib/factory/snapshot.d.ts +1 -1
- package/dist/lib/factory/snapshot.js +1 -1
- 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/helper-download.d.ts +12 -2
- package/dist/lib/helper-download.js +12 -2
- package/dist/lib/installations/migrate.js +4 -4
- package/dist/lib/menubar/install-menubar.d.ts +71 -8
- package/dist/lib/menubar/install-menubar.js +183 -24
- 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/star-nudge.d.ts +2 -2
- package/dist/lib/star-nudge.js +2 -2
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +2 -2
- 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/dist/lib/traces/sync.d.ts +30 -0
- package/dist/lib/traces/sync.js +91 -10
- package/package.json +3 -3
|
@@ -80,11 +80,19 @@ export async function maybePickInteractiveHost() {
|
|
|
80
80
|
* Offer to pin this machine's default browser profile to a chosen browser.
|
|
81
81
|
* Only asked when there is nothing to preserve: no configured device default
|
|
82
82
|
* AND no existing `default` profile (an existing profile is the user's earlier
|
|
83
|
-
* choice — never re-pinned behind their back).
|
|
84
|
-
*
|
|
83
|
+
* choice — never re-pinned behind their back). The list is the installed
|
|
84
|
+
* Chromium-family browsers plus a "None — this box uses the fleet hub" opt-out.
|
|
85
|
+
*
|
|
86
|
+
* This is the ONE place a default browser is chosen for a machine (PHNX-3296):
|
|
87
|
+
* a bare `agents browser start` no longer auto-detects and mints one, so picking
|
|
88
|
+
* here (or `agents browser use` / `agents setup browser` later) is how a box
|
|
89
|
+
* gets a local default at all. Picking "None" leaves it to the fleet hub
|
|
90
|
+
* (`browser.device`). Returns true when a profile was created and set as the
|
|
91
|
+
* device default. Silent no-op non-TTY — a headless box relies on the hub.
|
|
85
92
|
*/
|
|
86
|
-
export async function maybePickBrowserProfile() {
|
|
87
|
-
|
|
93
|
+
export async function maybePickBrowserProfile(deps = {}) {
|
|
94
|
+
const interactive = deps.interactive ?? isInteractiveTerminal();
|
|
95
|
+
if (!interactive)
|
|
88
96
|
return false;
|
|
89
97
|
if (getConfiguredDefaultProfileName())
|
|
90
98
|
return false;
|
|
@@ -93,15 +101,24 @@ export async function maybePickBrowserProfile() {
|
|
|
93
101
|
const installed = listInstalledBrowsers();
|
|
94
102
|
if (installed.length === 0)
|
|
95
103
|
return false;
|
|
96
|
-
const
|
|
97
|
-
const picked = await select({
|
|
104
|
+
const config = {
|
|
98
105
|
message: 'Which browser should agents drive on THIS machine?',
|
|
99
106
|
default: defaultBrowserChoice(installed) ?? SKIP,
|
|
100
107
|
choices: [
|
|
101
108
|
...installed.map((b) => ({ name: `${b.browserType} ${chalk.dim(b.binary)}`, value: b.browserType })),
|
|
102
|
-
{ name: `
|
|
109
|
+
{ name: `None ${chalk.dim('— this box uses the fleet hub (agents config set browser.device <host>)')}`, value: SKIP },
|
|
103
110
|
],
|
|
104
|
-
}
|
|
111
|
+
};
|
|
112
|
+
// The prompt is the only non-testable boundary here, so it is injectable —
|
|
113
|
+
// everything after (createProfile, setConfigValue) runs for real.
|
|
114
|
+
let picked;
|
|
115
|
+
if (deps.select) {
|
|
116
|
+
picked = await deps.select(config);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const { select } = await import('@inquirer/prompts');
|
|
120
|
+
picked = await select(config);
|
|
121
|
+
}
|
|
105
122
|
if (picked === SKIP)
|
|
106
123
|
return false;
|
|
107
124
|
const chosen = installed.find((b) => b.browserType === picked) ?? installed[0];
|
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`;
|
package/dist/commands/traces.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { setHelpSections } from '../lib/help.js';
|
|
3
|
-
import { syncTraces } from '../lib/traces/sync.js';
|
|
4
|
-
import { readSyncLedger } from '../lib/traces/sync.js';
|
|
3
|
+
import { readSyncLedger, syncTraces } from '../lib/traces/sync.js';
|
|
5
4
|
import { managedTracesBaseUrl, resolveTracesBackend } from '../lib/traces/backend.js';
|
|
6
5
|
import { showUrl } from '../lib/open-url.js';
|
|
7
6
|
import { DEFAULT_CF_BUNDLE, readCloudflareCreds } from '../lib/share/config.js';
|
|
@@ -93,7 +92,18 @@ async function handleSync(opts) {
|
|
|
93
92
|
parts.push(chalk.dim(`${result.skipped} skipped`));
|
|
94
93
|
}
|
|
95
94
|
if (result.errors > 0) {
|
|
96
|
-
|
|
95
|
+
// Distinguish expected history (transcripts cleaned off disk) from genuine
|
|
96
|
+
// failures that will be retried, so the operator knows which need attention.
|
|
97
|
+
const detail = [];
|
|
98
|
+
if (result.transcriptUnavailable > 0) {
|
|
99
|
+
detail.push(`${result.transcriptUnavailable} transcripts no longer on disk`);
|
|
100
|
+
}
|
|
101
|
+
const retryable = result.parseFailed + result.uploadFailed;
|
|
102
|
+
if (retryable > 0) {
|
|
103
|
+
detail.push(`${retryable} parse/upload failures — will retry`);
|
|
104
|
+
}
|
|
105
|
+
parts.push(chalk.yellow(`${result.errors} errors`) +
|
|
106
|
+
(detail.length ? chalk.dim(` (${detail.join(' · ')})`) : ''));
|
|
97
107
|
}
|
|
98
108
|
if (parts.length === 0) {
|
|
99
109
|
parts.push(chalk.dim('nothing new'));
|
|
@@ -108,7 +118,25 @@ async function handleStatus() {
|
|
|
108
118
|
}
|
|
109
119
|
const when = new Date(ledger.lastSyncMtime).toLocaleString();
|
|
110
120
|
console.log(`Last sync: ${chalk.bold(when)}`);
|
|
111
|
-
|
|
121
|
+
const failures = ledger.failures ?? [];
|
|
122
|
+
if (failures.length > 0) {
|
|
123
|
+
const byKind = new Map();
|
|
124
|
+
for (const f of failures) {
|
|
125
|
+
const list = byKind.get(f.kind) ?? [];
|
|
126
|
+
list.push(f);
|
|
127
|
+
byKind.set(f.kind, list);
|
|
128
|
+
}
|
|
129
|
+
console.log(chalk.yellow(`\n${failures.length} unresolved failure${failures.length === 1 ? '' : 's'}:`));
|
|
130
|
+
for (const [kind, list] of byKind) {
|
|
131
|
+
const retry = kind === 'transcript-unavailable' ? chalk.dim(' (not retried)') : chalk.dim(' (retried each sync)');
|
|
132
|
+
console.log(` ${chalk.bold(list.length)} ${kind}${retry}`);
|
|
133
|
+
const example = list[0];
|
|
134
|
+
if (example?.detail) {
|
|
135
|
+
console.log(chalk.dim(` e.g. ${example.id.slice(0, 8)} · ${example.detail}`));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
console.log(chalk.dim('\nRun `agents traces sync` to push new sessions.'));
|
|
112
140
|
}
|
|
113
141
|
async function handleOpen() {
|
|
114
142
|
// M2 console — for now open the base URL which will route to the console once deployed.
|
|
@@ -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
|