@phnx-labs/agents-cli 1.22.51 → 1.22.53
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 +238 -0
- package/README.md +1 -1
- package/dist/commands/accounts.js +1 -1
- 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/exec.js +16 -9
- package/dist/commands/fleet-capture.js +7 -0
- package/dist/commands/focus.d.ts +1 -10
- package/dist/commands/focus.js +16 -79
- package/dist/commands/go.d.ts +26 -0
- package/dist/commands/go.js +65 -6
- package/dist/commands/monitors.js +1 -1
- package/dist/commands/repo.js +31 -3
- package/dist/commands/sessions-inject.js +8 -3
- package/dist/commands/sessions-picker.js +2 -1
- 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 +69 -39
- 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/ssh.js +35 -12
- package/dist/commands/status.js +5 -0
- package/dist/commands/sync.js +102 -2
- package/dist/commands/tmux.d.ts +8 -1
- package/dist/commands/tmux.js +167 -17
- package/dist/lib/account-registry.d.ts +15 -5
- package/dist/lib/account-registry.js +150 -50
- package/dist/lib/answer-router.js +2 -1
- package/dist/lib/browser/ipc.d.ts +44 -0
- package/dist/lib/browser/ipc.js +120 -8
- package/dist/lib/browser/profiles.d.ts +57 -17
- package/dist/lib/browser/profiles.js +77 -53
- package/dist/lib/browser/registry.d.ts +44 -14
- package/dist/lib/browser/registry.js +141 -45
- 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/daemon/runner.js +10 -2
- package/dist/lib/device-config.js +3 -2
- package/dist/lib/devices/config-migration.js +147 -1
- package/dist/lib/devices/device-docs.d.ts +35 -0
- package/dist/lib/devices/device-docs.js +163 -0
- package/dist/lib/devices/discovery-policy.d.ts +14 -2
- package/dist/lib/devices/discovery-policy.js +31 -21
- package/dist/lib/devices/registry.d.ts +11 -5
- package/dist/lib/devices/registry.js +46 -18
- package/dist/lib/exec.d.ts +66 -28
- package/dist/lib/exec.js +71 -26
- package/dist/lib/feed/feed.d.ts +10 -2
- package/dist/lib/feed/feed.js +12 -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/hosts/dispatch.d.ts +4 -3
- package/dist/lib/hosts/dispatch.js +12 -8
- package/dist/lib/hosts/providers/local.d.ts +9 -3
- package/dist/lib/hosts/providers/local.js +23 -12
- package/dist/lib/hosts/reconnect.d.ts +7 -4
- package/dist/lib/hosts/reconnect.js +29 -25
- package/dist/lib/hosts/registry.js +4 -1
- package/dist/lib/hosts/remote-os.js +3 -1
- 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/active.d.ts +10 -1
- package/dist/lib/session/active.js +7 -1
- package/dist/lib/session/actor-sidecar.d.ts +7 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +39 -3
- package/dist/lib/session/discover.js +7 -12
- package/dist/lib/session/live-metadata.js +1 -0
- 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/pid-registry.d.ts +7 -0
- package/dist/lib/session/prompt.d.ts +15 -0
- package/dist/lib/session/prompt.js +21 -0
- package/dist/lib/session/remote-active.d.ts +8 -0
- package/dist/lib/session/remote-active.js +1 -0
- package/dist/lib/session/types.d.ts +17 -0
- package/dist/lib/session/types.js +10 -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 +104 -12
- package/dist/lib/state.d.ts +8 -0
- package/dist/lib/state.js +143 -11
- package/dist/lib/sync-status.d.ts +17 -0
- package/dist/lib/sync-status.js +21 -2
- package/dist/lib/terminal/resolve.d.ts +7 -0
- package/dist/lib/terminal/resolve.js +41 -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/insights.d.ts +67 -0
- package/dist/lib/traces/insights.js +178 -0
- package/dist/lib/traces/phenotype.d.ts +67 -0
- package/dist/lib/traces/phenotype.js +437 -0
- package/dist/lib/traces/segments.d.ts +133 -0
- package/dist/lib/traces/segments.js +301 -0
- package/dist/lib/traces/sync.d.ts +33 -0
- package/dist/lib/traces/sync.js +11 -2
- package/dist/lib/types.d.ts +47 -1
- package/dist/lib/watchdog/runner.js +18 -4
- package/package.json +1 -1
package/dist/commands/ssh.js
CHANGED
|
@@ -1136,7 +1136,7 @@ function registerDevicesCommands(program) {
|
|
|
1136
1136
|
registerFleetApplyAlias(devicesCmd);
|
|
1137
1137
|
devicesCmd
|
|
1138
1138
|
.command('register <name>')
|
|
1139
|
-
.description(
|
|
1139
|
+
.description("Register a discovered node and record the approval in this box's device doc (fleet.discovery), unioned fleet-wide.")
|
|
1140
1140
|
.action(async (name) => {
|
|
1141
1141
|
try {
|
|
1142
1142
|
const nodes = parseTailscaleStatus(tailscaleStatusJson());
|
|
@@ -1145,11 +1145,20 @@ function registerDevicesCommands(program) {
|
|
|
1145
1145
|
console.error(chalk.red(`'${name}' is not a current tailscale node. See 'agents devices sync'.`));
|
|
1146
1146
|
process.exit(1);
|
|
1147
1147
|
}
|
|
1148
|
-
await removeIgnored(name); //
|
|
1148
|
+
await removeIgnored(name); // clear THIS box's dismissal, if any
|
|
1149
1149
|
const d = await upsertDevice(name, nodeToDeviceInput(node));
|
|
1150
1150
|
setDeviceDiscoveryStatus(name, 'approved');
|
|
1151
1151
|
clearPendingSentinel(name); // drop the notification immediately
|
|
1152
1152
|
console.log(chalk.green(`Registered '${name}'`) + chalk.gray(` (${d.platform})`));
|
|
1153
|
+
// A dismissal on another box still wins the union (ignored beats
|
|
1154
|
+
// approved), so approving here does not un-dismiss it fleet-wide — say so
|
|
1155
|
+
// rather than imply the node is now discoverable everywhere (PHNX-3315).
|
|
1156
|
+
const otherDismissals = loadIgnoredEntries().filter((e) => e.name === name);
|
|
1157
|
+
if (otherDismissals.length > 0 || getDeviceDiscoveryStatus(name) === 'ignored') {
|
|
1158
|
+
const boxes = [...new Set(otherDismissals.map((e) => e.ignoredOn))].sort().join(', ') || 'another box';
|
|
1159
|
+
console.error(chalk.yellow(`Note: '${name}' is still dismissed on: ${boxes}, so it stays ignored fleet-wide`) +
|
|
1160
|
+
chalk.gray(` until you run \`agents devices unignore ${name}\` there.`));
|
|
1161
|
+
}
|
|
1153
1162
|
}
|
|
1154
1163
|
catch (err) {
|
|
1155
1164
|
console.error(chalk.red(err.message));
|
|
@@ -1158,7 +1167,7 @@ function registerDevicesCommands(program) {
|
|
|
1158
1167
|
});
|
|
1159
1168
|
devicesCmd
|
|
1160
1169
|
.command('ignore <name>')
|
|
1161
|
-
.description(
|
|
1170
|
+
.description("Dismiss a node and record the decision in this box's device doc (fleet.ignored), unioned fleet-wide (also removes it locally).")
|
|
1162
1171
|
.action(async (name) => {
|
|
1163
1172
|
try {
|
|
1164
1173
|
await removeDevice(name);
|
|
@@ -1176,13 +1185,25 @@ function registerDevicesCommands(program) {
|
|
|
1176
1185
|
.command('unignore <name>')
|
|
1177
1186
|
.description('Undo `ignore`: allow a node to be discovered and registered again.')
|
|
1178
1187
|
.action(async (name) => {
|
|
1179
|
-
const
|
|
1180
|
-
|
|
1181
|
-
|
|
1188
|
+
const wasIgnored = getDeviceDiscoveryStatus(name) === 'ignored' || loadIgnoredEntries().some((e) => e.name === name);
|
|
1189
|
+
// A box can edit only its OWN device doc (PHNX-3315), so this clears just
|
|
1190
|
+
// this box's dismissal/approval decision.
|
|
1191
|
+
await removeIgnored(name);
|
|
1192
|
+
setDeviceDiscoveryStatus(name, undefined);
|
|
1193
|
+
// Re-evaluate the cross-box union AFTER clearing our slice: another box's
|
|
1194
|
+
// dismissal keeps the node ignored fleet-wide (ignored beats approved), so
|
|
1195
|
+
// never report a false "no longer ignoring" — name the box(es) to clear.
|
|
1196
|
+
const remaining = loadIgnoredEntries().filter((e) => e.name === name);
|
|
1197
|
+
if (remaining.length > 0 || getDeviceDiscoveryStatus(name) === 'ignored') {
|
|
1198
|
+
const boxes = [...new Set(remaining.map((e) => e.ignoredOn))].sort().join(', ') || 'another box';
|
|
1199
|
+
console.error(chalk.yellow(`Cleared this box's decision, but '${name}' is still dismissed on: ${boxes}.`) +
|
|
1200
|
+
chalk.gray(`\nRun \`agents devices unignore ${name}\` there too to clear it fleet-wide.`));
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if (!wasIgnored) {
|
|
1182
1204
|
console.error(chalk.gray(`'${name}' was not ignored.`));
|
|
1183
1205
|
return;
|
|
1184
1206
|
}
|
|
1185
|
-
setDeviceDiscoveryStatus(name, undefined);
|
|
1186
1207
|
console.log(chalk.green(`No longer ignoring '${name}'`) + chalk.gray(' — run `agents devices sync` to register it.'));
|
|
1187
1208
|
});
|
|
1188
1209
|
const ignoredCmd = devicesCmd
|
|
@@ -1219,11 +1240,13 @@ function registerDevicesCommands(program) {
|
|
|
1219
1240
|
agents devices unignore old-laptop # undo a dismissal
|
|
1220
1241
|
`,
|
|
1221
1242
|
notes: `
|
|
1222
|
-
Dismissals live in
|
|
1223
|
-
sync with 'agents repo
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1243
|
+
Dismissals live in each box's tracked device doc
|
|
1244
|
+
(devices/<host>/agents.yaml fleet.ignored) and sync with 'agents repo
|
|
1245
|
+
push/pull'; the effective list is their cross-box union, so a node
|
|
1246
|
+
dismissed on one box stays dismissed everywhere without the boxes ever
|
|
1247
|
+
rewriting one shared file. An ignored node is not a device — it never
|
|
1248
|
+
enters the registry, so 'agents devices list' never shows it; this command
|
|
1249
|
+
is where dismissals are visible.
|
|
1227
1250
|
`,
|
|
1228
1251
|
});
|
|
1229
1252
|
// ─── devices config (unified settings surface) ────────────────────────────
|
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
|
@@ -35,6 +35,8 @@ import { Option } from 'commander';
|
|
|
35
35
|
import chalk from 'chalk';
|
|
36
36
|
import { resolveSyncPassphraseFromEnv } from '../lib/secrets/sync-passphrase.js';
|
|
37
37
|
import { agentLabel, resolveAgentName, MANAGED_AGENT_IDS, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
|
|
38
|
+
import { autoEvictCentralBrowserProfiles } from '../lib/browser/registry.js';
|
|
39
|
+
import { shouldAutoClaimCentralProfile } from '../lib/browser/profiles.js';
|
|
38
40
|
import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, resolveVersionAlias, listInstalledVersions, healDanglingVersionPointers, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, buildSelection, mergeRepoScopedSelections, listRepoNames, getVersionHomePath, } from '../lib/installations/versions.js';
|
|
39
41
|
import { capableAgents } from '../lib/capabilities.js';
|
|
40
42
|
import { parseHookManifest, registerHooksToSettings } from '../lib/hooks/install.js';
|
|
@@ -44,7 +46,7 @@ import { formatKeptProjectResources } from '../lib/project-resources.js';
|
|
|
44
46
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
45
47
|
import { runUmbrellaSync } from '../lib/sync-umbrella.js';
|
|
46
48
|
import { addHostOption } from '../lib/hosts/option.js';
|
|
47
|
-
import { syncRepoGit } from '../lib/git.js';
|
|
49
|
+
import { syncRepoGit, adoptUserRepoIfNeeded, recordUserRepoRemote, resolveUserRepoRemoteUrl } from '../lib/git.js';
|
|
48
50
|
import { getSystemAgentsDir, getUserAgentsDir, getEnabledExtraRepos } from '../lib/state.js';
|
|
49
51
|
import { registerStatusCommand } from './status.js';
|
|
50
52
|
/** Emit one JSON object to stdout for `--json` callers / fleet fan-out. */
|
|
@@ -206,6 +208,37 @@ async function runRepoGitSync(repo, quiet, outLog, errLog, json = false) {
|
|
|
206
208
|
}
|
|
207
209
|
if (!quiet && !json)
|
|
208
210
|
outLog(chalk.bold(`Syncing ${repo} repo…`) + chalk.gray(` (${target.dir})`));
|
|
211
|
+
// Self-heal a non-git / partial user checkout in place before the git sync
|
|
212
|
+
// (PHNX-3301) — otherwise syncRepoGit hard-fails with "Not a git repo" and the
|
|
213
|
+
// only fix is a destructive re-clone. Only the user repo adopts: system is
|
|
214
|
+
// cloned by setup, extras by `repo add`.
|
|
215
|
+
if (repo === 'user') {
|
|
216
|
+
const adopted = await adoptUserRepoIfNeeded(target.dir);
|
|
217
|
+
if (adopted && !adopted.success) {
|
|
218
|
+
const hint = adopted.needsUrl
|
|
219
|
+
? ' — git-back it: agents repo pull user <git-url>'
|
|
220
|
+
: '';
|
|
221
|
+
if (json) {
|
|
222
|
+
emitJson({ ok: false, mode: 'repo-git', repo, error: `${adopted.error}${hint}` });
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
errLog(chalk.red(`sync ${repo} failed: ${adopted.error}`));
|
|
226
|
+
if (adopted.needsUrl)
|
|
227
|
+
errLog(chalk.gray(' git-back it: agents repo pull user <git-url>'));
|
|
228
|
+
}
|
|
229
|
+
process.exitCode = 1;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (adopted?.success && !quiet && !json) {
|
|
233
|
+
outLog(chalk.green(` adopted ${repo} in place → ${adopted.commit} (${adopted.materialized} file(s) materialized${adopted.reconciledAgentsYaml ? ', agents.yaml reconciled' : ''})`));
|
|
234
|
+
if (adopted.localEdits.length > 0) {
|
|
235
|
+
outLog(chalk.yellow(` kept ${adopted.localEdits.length} local edit(s): ${adopted.localEdits.slice(0, 5).join(', ')}${adopted.localEdits.length > 5 ? ', …' : ''}`));
|
|
236
|
+
}
|
|
237
|
+
if (adopted.agentsYamlBackup) {
|
|
238
|
+
outLog(chalk.gray(` saved the previous agents.yaml to ${adopted.agentsYamlBackup}`));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
209
242
|
const result = await syncRepoGit(target.dir, { push: target.push });
|
|
210
243
|
if (!result.success) {
|
|
211
244
|
if (json) {
|
|
@@ -217,6 +250,14 @@ async function runRepoGitSync(repo, quiet, outLog, errLog, json = false) {
|
|
|
217
250
|
process.exitCode = 1;
|
|
218
251
|
return;
|
|
219
252
|
}
|
|
253
|
+
// Record the resolved remote so a future partial box (lost .git) can adopt in
|
|
254
|
+
// place without the operator re-typing the URL (PHNX-3301). Before the --json
|
|
255
|
+
// early-return so the record is refreshed on every healthy sync, JSON or not.
|
|
256
|
+
if (repo === 'user') {
|
|
257
|
+
const u = resolveUserRepoRemoteUrl(target.dir);
|
|
258
|
+
if (u)
|
|
259
|
+
recordUserRepoRemote(target.dir, u);
|
|
260
|
+
}
|
|
220
261
|
if (json) {
|
|
221
262
|
emitJson({
|
|
222
263
|
ok: true,
|
|
@@ -290,12 +331,35 @@ async function runInteractiveReconcile(opts, outLog, errLog) {
|
|
|
290
331
|
const target = resolveRepoGitTarget(repo);
|
|
291
332
|
if (!target)
|
|
292
333
|
continue;
|
|
334
|
+
// Adopt a non-git / partial user checkout in place before pulling (PHNX-3301).
|
|
335
|
+
if (repo === 'user') {
|
|
336
|
+
const adopted = await adoptUserRepoIfNeeded(target.dir);
|
|
337
|
+
if (adopted?.success) {
|
|
338
|
+
outLog(chalk.gray(` adopted user in place → ${adopted.commit} (${adopted.materialized} file(s) materialized)`));
|
|
339
|
+
}
|
|
340
|
+
else if (adopted && !adopted.success) {
|
|
341
|
+
outLog(chalk.yellow(` ! user: ${adopted.error}${adopted.needsUrl ? ' — agents repo pull user <git-url>' : ''}`));
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
293
345
|
const res = await syncRepoGit(target.dir, { push: false });
|
|
294
|
-
if (res.success)
|
|
346
|
+
if (res.success) {
|
|
295
347
|
outLog(chalk.gray(` pulled ${repo} → ${res.commit}`));
|
|
348
|
+
if (repo === 'user') {
|
|
349
|
+
const u = resolveUserRepoRemoteUrl(target.dir);
|
|
350
|
+
if (u)
|
|
351
|
+
recordUserRepoRemote(target.dir, u);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
296
354
|
else
|
|
297
355
|
outLog(chalk.yellow(` ! ${repo}: ${(res.error ?? 'pull failed').split('\n')[0]}`));
|
|
298
356
|
}
|
|
357
|
+
// Drain the legacy central `browser:` tombstone now that the repos are pulled.
|
|
358
|
+
// Running AFTER the pull is load-bearing: it acts on a view of central that
|
|
359
|
+
// already reflects peers' drains, so it never re-claims a profile another box
|
|
360
|
+
// already migrated (which would flip that profile's kind identity->fungible).
|
|
361
|
+
// The interactive path is always non-quiet, non-json.
|
|
362
|
+
evictCentralBrowserProfilesForSync(false, false, outLog, errLog);
|
|
299
363
|
// 2. One selection spanning the chosen repos.
|
|
300
364
|
const selection = mergeRepoScopedSelections(repos, cwd);
|
|
301
365
|
const hasResources = selection.memory === 'all' || Object.entries(selection).some(([kind, v]) => kind !== 'memory' && Array.isArray(v) && v.length > 0);
|
|
@@ -318,6 +382,36 @@ async function runInteractiveReconcile(opts, outLog, errLog) {
|
|
|
318
382
|
}
|
|
319
383
|
}
|
|
320
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* Drain the legacy central `browser:` tombstone during `agents sync` (PHNX-3315).
|
|
387
|
+
* New profiles write the per-device doc, but profiles created before the
|
|
388
|
+
* device-scoped store lingered in the shared top-level `agents.yaml` and churned
|
|
389
|
+
* every fleet pull until someone ran `agents browser profiles claim` by hand.
|
|
390
|
+
* Fold the ones THIS box can host into its device doc — host-gated, and the
|
|
391
|
+
* selection is computed under the meta lock (see autoEvictCentralBrowserProfiles)
|
|
392
|
+
* so it never races itself. Callers MUST invoke this AFTER the repo pull: acting
|
|
393
|
+
* on a pre-pull view of central risks re-claiming a profile a peer already
|
|
394
|
+
* drained, which would flip its kind identity->fungible. Non-fatal so a hiccup
|
|
395
|
+
* can never wedge the sync.
|
|
396
|
+
*/
|
|
397
|
+
function evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog) {
|
|
398
|
+
try {
|
|
399
|
+
// Auto-claim ONLY remote (ssh://) tombstones — they are fungible by design,
|
|
400
|
+
// so a concurrent cross-machine double-claim is harmless. Local/cdp profiles
|
|
401
|
+
// have no per-machine ownership signal and are left central for an explicit
|
|
402
|
+
// `agents browser profiles claim` (PHNX-3315 review). See
|
|
403
|
+
// shouldAutoClaimCentralProfile.
|
|
404
|
+
const result = autoEvictCentralBrowserProfiles(shouldAutoClaimCentralProfile);
|
|
405
|
+
if (!quiet && !json && result.claimed.length > 0) {
|
|
406
|
+
outLog(chalk.gray(` Claimed ${result.claimed.length} central browser profile(s) into this device: ${result.claimed.join(', ')}`));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
if (!quiet && !json) {
|
|
411
|
+
errLog(chalk.yellow(` ! browser profile eviction skipped: ${err.message}`));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
321
415
|
/**
|
|
322
416
|
* The umbrella verb: bare `agents sync` (no agent) makes this machine current.
|
|
323
417
|
* Resolves the flags + a secrets passphrase (env-only for now; tokenized auth
|
|
@@ -359,6 +453,12 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
|
|
|
359
453
|
log: (msg) => { if (!quiet && !json)
|
|
360
454
|
outLog(chalk.gray(` ${msg}`)); },
|
|
361
455
|
});
|
|
456
|
+
// Drain the legacy central `browser:` tombstone AFTER the umbrella pull, so
|
|
457
|
+
// we act on central as converged by this sync rather than a stale pre-pull
|
|
458
|
+
// copy that could re-claim a profile a peer already migrated. Skipped under
|
|
459
|
+
// --cloud (fetch-only, no local reconcile).
|
|
460
|
+
if (!opts.cloud)
|
|
461
|
+
evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog);
|
|
362
462
|
if (json) {
|
|
363
463
|
emitJson({
|
|
364
464
|
// A refused resource is not a clean sync (RUSH-2700).
|
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`;
|
|
@@ -34,7 +34,15 @@ export type UnifiedAccount = (CredentialAccount & {
|
|
|
34
34
|
export declare function accountRegistryPath(base?: string): string;
|
|
35
35
|
export declare function readAccountRegistry(base?: string): AccountRegistryDocument;
|
|
36
36
|
export declare function findAccount(name: string, doc?: AccountRegistryDocument): CredentialAccount | null;
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Effective native accounts: the fleet-shared central store (version-scoped
|
|
39
|
+
* identities) merged with THIS box's own device doc (device-scoped identities).
|
|
40
|
+
* A native login is machine-local — its home follows its scope (PHNX-3315), so
|
|
41
|
+
* a `scope:'device'` identity is read from this box's device doc and never the
|
|
42
|
+
* shared central agents.yaml (which is where its email/identityKey PII used to
|
|
43
|
+
* accumulate). On an id collision the device slice wins.
|
|
44
|
+
*/
|
|
45
|
+
export declare function listNativeAccounts(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): NativeAccount[];
|
|
38
46
|
/**
|
|
39
47
|
* Resolve one account by name or id across both stores, native first.
|
|
40
48
|
*
|
|
@@ -46,15 +54,17 @@ export declare function listNativeAccounts(meta: Pick<Meta, 'accounts'>): Native
|
|
|
46
54
|
* running before the body, so callers that only need a native lookup must be
|
|
47
55
|
* able to omit it.
|
|
48
56
|
*/
|
|
49
|
-
export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts'>, doc?: AccountRegistryDocument): UnifiedAccount | null;
|
|
57
|
+
export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, doc?: AccountRegistryDocument): UnifiedAccount | null;
|
|
50
58
|
export declare function addNativeAccount(name: string, agent: AgentId, identityKey: string, identityLabel: string | undefined, scope: 'version' | 'device'): NativeAccount;
|
|
51
59
|
/** Create or replace the version-independent label for one native identity. */
|
|
52
60
|
export declare function labelNativeAccount(agent: AgentId, identityKey: string, identityLabel: string | undefined, label: string | undefined, scope: 'version' | 'device'): NativeAccount;
|
|
53
61
|
export declare function bindAccount(nameOrId: string, target: string): UnifiedAccount;
|
|
54
62
|
export declare function unbindAccount(nameOrId: string, target: string): void;
|
|
55
|
-
|
|
63
|
+
/** Every target bound to `accountId`: this box's device-doc bindings merged over
|
|
64
|
+
* the fleet-shared central bindings (PHNX-3315). */
|
|
65
|
+
export declare function accountBindings(accountId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): string[];
|
|
56
66
|
/** Explicit selection wins over a configured per-harness default. */
|
|
57
|
-
export declare function resolveAccountSelection(explicit: string | undefined, agent: AgentId, meta: Pick<Meta, 'accounts'>, opts?: {
|
|
67
|
+
export declare function resolveAccountSelection(explicit: string | undefined, agent: AgentId, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, opts?: {
|
|
58
68
|
useDefault?: boolean;
|
|
59
69
|
target?: string;
|
|
60
70
|
}): string | undefined;
|
|
@@ -100,7 +110,7 @@ export type SpawnAccount = {
|
|
|
100
110
|
* A native account bound to a different harness than the one being launched
|
|
101
111
|
* fails loudly (EXEC-ACCOUNT-4). Returns null when nothing is selected.
|
|
102
112
|
*/
|
|
103
|
-
export declare function resolveSpawnAccount(explicit: string | undefined, agent: AgentId, version: string | undefined, meta: Pick<Meta, 'accounts'>, opts?: {
|
|
113
|
+
export declare function resolveSpawnAccount(explicit: string | undefined, agent: AgentId, version: string | undefined, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, opts?: {
|
|
104
114
|
useDefault?: boolean;
|
|
105
115
|
provider?: string;
|
|
106
116
|
base?: string;
|