@phnx-labs/agents-cli 1.20.73 → 1.20.76
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 +989 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +31 -1
- package/dist/commands/exec.js +31 -3
- package/dist/commands/export.d.ts +11 -0
- package/dist/commands/export.js +215 -0
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +87 -9
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +15 -2
- package/dist/commands/secrets.js +44 -21
- package/dist/commands/sessions-browser.js +1 -1
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.js +16 -1
- package/dist/commands/sessions.d.ts +36 -13
- package/dist/commands/sessions.js +210 -40
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/setup.js +6 -1
- package/dist/commands/ssh.js +71 -35
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +51 -11
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +99 -31
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +59 -7
- package/dist/lib/activity.d.ts +14 -4
- package/dist/lib/activity.js +17 -5
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +56 -0
- package/dist/lib/agents.js +133 -7
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.d.ts +16 -0
- package/dist/lib/config-transfer.js +60 -0
- package/dist/lib/daemon.js +18 -2
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +21 -0
- package/dist/lib/devices/fleet.js +26 -0
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +66 -0
- package/dist/lib/exec.js +195 -6
- package/dist/lib/export.d.ts +72 -0
- package/dist/lib/export.js +269 -0
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +7 -0
- package/dist/lib/hosts/dispatch.js +2 -0
- package/dist/lib/hosts/passthrough.js +2 -0
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/hosts/run-target.d.ts +13 -5
- package/dist/lib/hosts/run-target.js +26 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +37 -2
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +25 -0
- package/dist/lib/import.js +107 -2
- package/dist/lib/isolation-boundary-report.d.ts +10 -0
- package/dist/lib/isolation-boundary-report.js +35 -0
- package/dist/lib/menubar/install-menubar.d.ts +20 -0
- package/dist/lib/menubar/install-menubar.js +45 -1
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +7 -2
- package/dist/lib/overdue.js +18 -11
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/project-resources.js +20 -5
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- package/dist/lib/routines.d.ts +26 -0
- package/dist/lib/routines.js +19 -2
- package/dist/lib/runner.js +104 -7
- package/dist/lib/secrets/agent.d.ts +66 -1
- package/dist/lib/secrets/agent.js +154 -90
- package/dist/lib/secrets/bundles.d.ts +34 -0
- package/dist/lib/secrets/bundles.js +179 -19
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +21 -3
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +114 -7
- package/dist/lib/session/active.js +323 -52
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +48 -0
- package/dist/lib/session/db.js +238 -14
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +465 -2
- package/dist/lib/session/discover.js +1648 -468
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +130 -30
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -2
- package/dist/lib/session/render.d.ts +7 -2
- package/dist/lib/session/render.js +22 -10
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.js +6 -2
- package/dist/lib/shims.d.ts +27 -1
- package/dist/lib/shims.js +88 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +15 -36
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +48 -2
- package/dist/lib/uninstall.js +1 -45
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +22 -2
- package/dist/lib/versions.js +98 -10
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import type { SessionMeta } from '../lib/session/types.js';
|
|
3
|
+
/** Agents whose sessions cannot be faithfully --resume'd (buildResumeCommand → null). */
|
|
4
|
+
type MigrateMode = 'resume' | 'rehydrate';
|
|
5
|
+
export declare function registerSessionsMigrateCommand(sessionsCmd: Command): void;
|
|
6
|
+
/**
|
|
7
|
+
* Harness-parity gate (pure, testable). `buildResumeCommand` returns null for the
|
|
8
|
+
* non-resumable agents (gemini, antigravity, openclaw, rush, hermes, grok, kimi,
|
|
9
|
+
* droid) — for those a faithful --resume is impossible, so a requested `resume`
|
|
10
|
+
* transparently becomes `rehydrate`. A resumable agent honors the request.
|
|
11
|
+
* Returns the effective mode plus whether it was downgraded, so the caller can
|
|
12
|
+
* print the notice.
|
|
13
|
+
*/
|
|
14
|
+
export declare function effectiveMode(source: SessionMeta, requested: MigrateMode): {
|
|
15
|
+
mode: MigrateMode;
|
|
16
|
+
downgraded: boolean;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Rehydrate command: launch a fresh agent that reads the transcript. For a
|
|
20
|
+
* non-resumable agent there is no faithful --resume, so we start the agent with
|
|
21
|
+
* a prompt pointing it at the imported transcript path.
|
|
22
|
+
*/
|
|
23
|
+
export declare function rehydrateCommand(source: SessionMeta): string[];
|
|
24
|
+
/**
|
|
25
|
+
* `agents sessions migrations` — the border tracker: the append-only ledger of
|
|
26
|
+
* every session handed off to/from another machine (RUSH-1977).
|
|
27
|
+
*/
|
|
28
|
+
export declare function registerSessionsMigrationsCommand(sessionsCmd: Command): void;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents sessions migrate` (alias `relocate`) — relocate a RUNNING agent session
|
|
3
|
+
* from this machine onto another (a fleet worker, a registered device, or a warm
|
|
4
|
+
* ephemeral crabbox box), then stop the source so the interactive machine
|
|
5
|
+
* reclaims its compute (RUSH-1977).
|
|
6
|
+
*
|
|
7
|
+
* This is orchestration glue over primitives that already exist — it does not
|
|
8
|
+
* reinvent transport, resume, or scoring:
|
|
9
|
+
* - resolve the source session from the current tmux pane via `getActiveSessions()`
|
|
10
|
+
* (`provenance.mux.pane` matched against $TMUX_PANE);
|
|
11
|
+
* - pick / verify the target with the pure scorer in
|
|
12
|
+
* `lib/session/migrate-targets.ts` + `readyProbe()` / `bootstrapAgentsCli()`;
|
|
13
|
+
* - wrap up a dirty working tree (mechanical WIP-PR by default, or delegate a
|
|
14
|
+
* wrap-up turn to the running agent with --agent-wrapup);
|
|
15
|
+
* - ship the transcript with the SAME bundle pipeline as
|
|
16
|
+
* `sessions export --stdout | sessions import -`, over `sshExec`;
|
|
17
|
+
* - resume on the target through the SAME `openSurfaces({ backend:'tmux', host })`
|
|
18
|
+
* path `sessions resume --host` uses;
|
|
19
|
+
* - only AFTER the target's prompt is confirmed live, kill the source tmux
|
|
20
|
+
* session (`killSession`). --keep skips the kill (copy, not move).
|
|
21
|
+
*
|
|
22
|
+
* INVARIANT: the source is never killed before the transcript + branch are
|
|
23
|
+
* confirmed on the target.
|
|
24
|
+
*/
|
|
25
|
+
import * as os from 'os';
|
|
26
|
+
import * as fs from 'fs';
|
|
27
|
+
import * as path from 'path';
|
|
28
|
+
import chalk from 'chalk';
|
|
29
|
+
import simpleGit from 'simple-git';
|
|
30
|
+
import { spawnSync } from 'child_process';
|
|
31
|
+
import { AGENTS } from '../lib/agents.js';
|
|
32
|
+
import { getActiveSessions } from '../lib/session/active.js';
|
|
33
|
+
import { discoverSessions, resolveSessionById } from '../lib/session/discover.js';
|
|
34
|
+
import { buildResumeCommand } from './sessions.js';
|
|
35
|
+
import { injectTargetFromReplyRail } from '../lib/session/inject.js';
|
|
36
|
+
import { injectIntoTerminal } from '../lib/terminal/index.js';
|
|
37
|
+
import { iLoginShell } from '../lib/terminal/shell.js';
|
|
38
|
+
import { shellQuote as quoteArg } from '../lib/terminal/quote.js';
|
|
39
|
+
import { killSession } from '../lib/tmux/session.js';
|
|
40
|
+
import { listAllHosts, resolveHost } from '../lib/hosts/registry.js';
|
|
41
|
+
import { sshTargetFor } from '../lib/hosts/types.js';
|
|
42
|
+
import { readyProbe, bootstrapAgentsCli, viewHasAgent } from '../lib/hosts/ready.js';
|
|
43
|
+
import { sshExec, shellQuote } from '../lib/ssh-exec.js';
|
|
44
|
+
import { loadDevices } from '../lib/devices/registry.js';
|
|
45
|
+
import { readStatsCache } from '../lib/devices/stats-cache.js';
|
|
46
|
+
import { crabboxList, crabboxWarmup, crabboxWaitReady, } from '../lib/crabbox/cli.js';
|
|
47
|
+
import { reusableBoxes, boxAddress } from './lease.js';
|
|
48
|
+
import { pickBestTarget, rankTargets, enumerateTargets, } from '../lib/session/migrate-targets.js';
|
|
49
|
+
import { itemPicker } from '../lib/picker.js';
|
|
50
|
+
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
51
|
+
import { setHelpSections } from '../lib/help.js';
|
|
52
|
+
import { recordMigration, readMigrations } from '../lib/session/migrations.js';
|
|
53
|
+
export function registerSessionsMigrateCommand(sessionsCmd) {
|
|
54
|
+
const cmd = sessionsCmd
|
|
55
|
+
.command('migrate [session-id]')
|
|
56
|
+
.alias('relocate')
|
|
57
|
+
.description('Relocate a running session onto another machine (fleet worker, device, or ephemeral box), then stop the source here.')
|
|
58
|
+
.option('--auto', 'Pick the best target host automatically (idle fleet worker preferred)')
|
|
59
|
+
.option('--host <name>', 'Explicit target: an enrolled host, a device, or a warm ephemeral box slug')
|
|
60
|
+
.option('--lease', 'Provision a fresh ephemeral crabbox box as the target')
|
|
61
|
+
.option('--mode <mode>', 'rehydrate (default: the target agent reads the transported transcript) or resume (best-effort native --resume)', 'rehydrate')
|
|
62
|
+
.option('--keep', 'Copy, not move — do NOT stop the source after resuming on the target')
|
|
63
|
+
.option('--agent-wrapup', 'Delegate the dirty-tree wrap-up to the running agent instead of a mechanical WIP-PR');
|
|
64
|
+
setHelpSections(cmd, {
|
|
65
|
+
examples: `
|
|
66
|
+
# Move the session in THIS pane onto the least-busy fleet worker
|
|
67
|
+
agents sessions migrate --auto
|
|
68
|
+
|
|
69
|
+
# Move a specific session onto a named host
|
|
70
|
+
agents sessions migrate a1b2c3d4 --host yosemite-s1
|
|
71
|
+
|
|
72
|
+
# Spin up a fresh ephemeral box and move onto it
|
|
73
|
+
agents sessions migrate --lease
|
|
74
|
+
|
|
75
|
+
# Copy (don't stop the source), letting the agent wrap up its own dirty tree
|
|
76
|
+
agents sessions migrate --host box-a --keep --agent-wrapup
|
|
77
|
+
`,
|
|
78
|
+
notes: `
|
|
79
|
+
- Without a [session-id], migrate resolves the session running in THIS tmux pane ($TMUX_PANE).
|
|
80
|
+
- Default --mode rehydrate: the transcript is shipped to the target and the agent reads it there
|
|
81
|
+
with 'agents sessions <id>' (its own judgment on --last/--include so long tool output can't
|
|
82
|
+
blow context), then continues. Robust across every harness.
|
|
83
|
+
- --mode resume attempts a native '<agent> --resume' on the target — faithful, but best-effort:
|
|
84
|
+
the target agent must have the session registered, so migrate falls back to rehydrate when it can't.
|
|
85
|
+
- The source is stopped only AFTER the target's session is confirmed live; --keep skips the stop (copy).
|
|
86
|
+
- Every migrate appends to the ledger — see 'agents sessions migrations' for where each session went.
|
|
87
|
+
`,
|
|
88
|
+
});
|
|
89
|
+
cmd.action(async (sessionId, options, command) => {
|
|
90
|
+
// commander 15: the parent `sessions` command owns a global `-H, --host` (the
|
|
91
|
+
// listing fan-out), which shadows this subcommand's own --host — the value
|
|
92
|
+
// lands in the merged globals, not `options.host`. Read it from there so
|
|
93
|
+
// `agents sessions migrate --host <name>` binds.
|
|
94
|
+
const globals = command.optsWithGlobals();
|
|
95
|
+
const hosts = Array.isArray(globals.host) ? globals.host : globals.host ? [globals.host] : [];
|
|
96
|
+
if (hosts.length > 1) {
|
|
97
|
+
console.error(chalk.yellow(`Multiple --host values given; migrating to the first (${hosts[0]}).`));
|
|
98
|
+
}
|
|
99
|
+
await sessionsMigrateAction(sessionId, { ...options, host: hosts[0] });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function fail(message) {
|
|
103
|
+
console.error(chalk.red(message));
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the session to migrate. With an explicit id, resolve it from the
|
|
108
|
+
* on-disk index. Without one, match the current tmux pane ($TMUX_PANE) against a
|
|
109
|
+
* live session's `provenance.mux.pane` and resolve that id to its SessionMeta.
|
|
110
|
+
*/
|
|
111
|
+
async function resolveSourceSession(sessionId) {
|
|
112
|
+
if (sessionId) {
|
|
113
|
+
const all = await discoverSessions({ all: true, sortBy: 'timestamp', limit: 2000 });
|
|
114
|
+
const matches = resolveSessionById(all, sessionId);
|
|
115
|
+
if (matches.length === 0)
|
|
116
|
+
fail(`No session matches "${sessionId}".`);
|
|
117
|
+
if (matches.length > 1) {
|
|
118
|
+
fail(`"${sessionId}" is ambiguous (${matches.length} matches). Pass a longer id fragment.`);
|
|
119
|
+
}
|
|
120
|
+
const active = (await getActiveSessions()).find((s) => s.sessionId === matches[0].id);
|
|
121
|
+
return { meta: matches[0], active };
|
|
122
|
+
}
|
|
123
|
+
const pane = process.env.TMUX_PANE;
|
|
124
|
+
if (!pane) {
|
|
125
|
+
fail('Not inside a tmux pane — pass an explicit [session-id] to migrate a specific session.');
|
|
126
|
+
}
|
|
127
|
+
const actives = await getActiveSessions();
|
|
128
|
+
const active = actives.find((s) => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane === pane);
|
|
129
|
+
if (!active || !active.sessionId) {
|
|
130
|
+
fail(`No running session resolves to this pane (${pane}). Pass an explicit [session-id].`);
|
|
131
|
+
}
|
|
132
|
+
const all = await discoverSessions({ all: true, sortBy: 'timestamp', limit: 2000 });
|
|
133
|
+
const matches = resolveSessionById(all, active.sessionId);
|
|
134
|
+
if (matches.length === 0)
|
|
135
|
+
fail(`This pane's session (${active.sessionId}) is not in the index yet.`);
|
|
136
|
+
return { meta: matches[0], active };
|
|
137
|
+
}
|
|
138
|
+
/** Live headroom for the scorer: prefer the disk stats-cache (fast, no ssh). */
|
|
139
|
+
function statsByName() {
|
|
140
|
+
const cache = readStatsCache();
|
|
141
|
+
return new Map(Object.entries(cache));
|
|
142
|
+
}
|
|
143
|
+
/** Enumerate + rank targets, honoring --host / --auto / --lease. */
|
|
144
|
+
async function resolveTarget(options, source) {
|
|
145
|
+
const selfHostname = os.hostname();
|
|
146
|
+
const ctx = {
|
|
147
|
+
selfHostname,
|
|
148
|
+
sourceHostname: source.machine ?? selfHostname,
|
|
149
|
+
sourceOs: process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux',
|
|
150
|
+
};
|
|
151
|
+
if (options.lease) {
|
|
152
|
+
return await provisionEphemeralTarget();
|
|
153
|
+
}
|
|
154
|
+
const hosts = await listAllHosts();
|
|
155
|
+
await loadDevices(); // ensure device registry is warmed (listAllHosts folds it in)
|
|
156
|
+
let warm = [];
|
|
157
|
+
try {
|
|
158
|
+
warm = reusableBoxes(crabboxList(), Math.floor(Date.now() / 1000));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
warm = []; // crabbox not configured — fleet-only targets
|
|
162
|
+
}
|
|
163
|
+
const stats = statsByName();
|
|
164
|
+
if (options.host) {
|
|
165
|
+
// Explicit target: a fleet host/device, or a warm box slug.
|
|
166
|
+
const box = warm.find((b) => b.slug === options.host);
|
|
167
|
+
if (box) {
|
|
168
|
+
return { name: box.slug, kind: 'ephemeral', os: 'linux', headroom: 'unknown', box };
|
|
169
|
+
}
|
|
170
|
+
const host = await resolveHost(options.host);
|
|
171
|
+
if (!host)
|
|
172
|
+
fail(`No host, device, or warm box named "${options.host}".`);
|
|
173
|
+
if (host.name.toLowerCase() === selfHostname.toLowerCase()) {
|
|
174
|
+
fail(`"${options.host}" is this machine — migrate needs a different target.`);
|
|
175
|
+
}
|
|
176
|
+
return { name: host.name, kind: 'fleet', os: host.os, headroom: 'unknown', host };
|
|
177
|
+
}
|
|
178
|
+
if (options.auto) {
|
|
179
|
+
const best = pickBestTarget(hosts, warm, stats, ctx);
|
|
180
|
+
if (!best) {
|
|
181
|
+
fail('No eligible target found (no reachable, dispatchable host that isn\'t this machine or the source). Try --lease to provision a fresh box.');
|
|
182
|
+
}
|
|
183
|
+
console.log(chalk.gray(`Auto-selected ${best.name} (${best.kind}, ${best.headroom}).`));
|
|
184
|
+
return best;
|
|
185
|
+
}
|
|
186
|
+
// Interactive: rank the candidates and let the user pick.
|
|
187
|
+
if (!isInteractiveTerminal()) {
|
|
188
|
+
fail('Pass --auto, --host <name>, or --lease to choose a target (no interactive picker without a tty).');
|
|
189
|
+
}
|
|
190
|
+
const ranked = rankTargets(enumerateTargets(hosts, warm, stats, ctx), ctx);
|
|
191
|
+
if (ranked.length === 0) {
|
|
192
|
+
fail('No eligible target found. Try --lease to provision a fresh box.');
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const picked = await itemPicker({
|
|
196
|
+
message: 'Migrate this session to which machine?',
|
|
197
|
+
items: ranked,
|
|
198
|
+
filter: () => ranked,
|
|
199
|
+
labelFor: (t) => `${chalk.bold(t.name.padEnd(20))}${chalk.gray(`${t.kind} · ${t.headroom}${t.os ? ' · ' + t.os : ''}`)}`,
|
|
200
|
+
shortIdFor: (t) => t.name,
|
|
201
|
+
enterHint: 'migrate',
|
|
202
|
+
});
|
|
203
|
+
if (!picked)
|
|
204
|
+
fail('Cancelled.');
|
|
205
|
+
return picked.item;
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
if (isPromptCancelled(err))
|
|
209
|
+
fail('Cancelled.');
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Provision a fresh ephemeral crabbox box (tailnet) and wait for it to be ready. */
|
|
214
|
+
async function provisionEphemeralTarget() {
|
|
215
|
+
console.log(chalk.gray('Provisioning a fresh ephemeral box (crabbox)…'));
|
|
216
|
+
const leased = await crabboxWarmup({ netMode: 'tailscale' });
|
|
217
|
+
const ready = await crabboxWaitReady(leased.slug).catch(() => leased);
|
|
218
|
+
console.log(chalk.green(`Leased box ${ready.slug}.`));
|
|
219
|
+
return { name: ready.slug, kind: 'ephemeral', os: 'linux', headroom: 'idle', box: ready };
|
|
220
|
+
}
|
|
221
|
+
/** Resolve the SSH target string for a migrate target (host alias or box address). */
|
|
222
|
+
function sshTargetForTarget(target) {
|
|
223
|
+
if (target.kind === 'fleet' && target.host)
|
|
224
|
+
return sshTargetFor(target.host);
|
|
225
|
+
if (target.kind === 'ephemeral' && target.box) {
|
|
226
|
+
const addr = boxAddress(target.box);
|
|
227
|
+
if (!addr)
|
|
228
|
+
fail(`Ephemeral box ${target.box.slug} has no reachable address.`);
|
|
229
|
+
return addr;
|
|
230
|
+
}
|
|
231
|
+
fail(`Cannot resolve an SSH target for ${target.name}.`);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Harness-parity gate (pure, testable). `buildResumeCommand` returns null for the
|
|
235
|
+
* non-resumable agents (gemini, antigravity, openclaw, rush, hermes, grok, kimi,
|
|
236
|
+
* droid) — for those a faithful --resume is impossible, so a requested `resume`
|
|
237
|
+
* transparently becomes `rehydrate`. A resumable agent honors the request.
|
|
238
|
+
* Returns the effective mode plus whether it was downgraded, so the caller can
|
|
239
|
+
* print the notice.
|
|
240
|
+
*/
|
|
241
|
+
export function effectiveMode(source, requested) {
|
|
242
|
+
const resumable = buildResumeCommand(source) !== null;
|
|
243
|
+
if (!resumable && requested === 'resume')
|
|
244
|
+
return { mode: 'rehydrate', downgraded: true };
|
|
245
|
+
return { mode: requested, downgraded: false };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Verify the target can run the session's agent+version. Returns the effective
|
|
249
|
+
* mode: a non-resumable agent (buildResumeCommand → null) forces rehydrate; a
|
|
250
|
+
* missing agents-cli triggers a bootstrap; a missing agent (in rehydrate) is a
|
|
251
|
+
* printed notice, not a failure.
|
|
252
|
+
*/
|
|
253
|
+
function ensureTargetReady(target, sshTarget, source, requested) {
|
|
254
|
+
// buildResumeCommand gates faithful resume: null → only rehydrate is possible.
|
|
255
|
+
const gated = effectiveMode(source, requested);
|
|
256
|
+
let mode = gated.mode;
|
|
257
|
+
if (gated.downgraded) {
|
|
258
|
+
console.log(chalk.yellow(` ${source.agent} sessions can't be faithfully resumed — using --mode rehydrate.`));
|
|
259
|
+
}
|
|
260
|
+
const remoteOs = target.host?.os;
|
|
261
|
+
let probe = readyProbe(sshTarget, remoteOs);
|
|
262
|
+
if (!probe.reachable) {
|
|
263
|
+
fail(`Target ${target.name} is not reachable over SSH (${sshTarget}).`);
|
|
264
|
+
}
|
|
265
|
+
if (!probe.version) {
|
|
266
|
+
console.log(chalk.gray(` agents-cli not found on ${target.name} — bootstrapping…`));
|
|
267
|
+
const boot = bootstrapAgentsCli(sshTarget, null, remoteOs);
|
|
268
|
+
if (!boot.ok)
|
|
269
|
+
fail(`Failed to bootstrap agents-cli on ${target.name}: ${boot.output.split('\n').pop()}`);
|
|
270
|
+
probe = readyProbe(sshTarget, remoteOs);
|
|
271
|
+
}
|
|
272
|
+
if (!viewHasAgent(probe.view, source.agent)) {
|
|
273
|
+
if (mode === 'resume') {
|
|
274
|
+
console.log(chalk.yellow(` ${source.agent} isn't installed on ${target.name} — falling back to --mode rehydrate.`));
|
|
275
|
+
mode = 'rehydrate';
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
console.log(chalk.gray(` Note: ${source.agent} isn't installed on ${target.name}; the rehydrated session will read the transcript with whatever agent runs there.`));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return mode;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Wrap up the working tree so no local edits are stranded when the source stops.
|
|
285
|
+
* Dirty → commit to a fresh branch + push + open a draft (WIP) PR. Clean-but-ahead
|
|
286
|
+
* → push. --agent-wrapup instead injects a wrap-up turn into the running agent.
|
|
287
|
+
* Returns the branch name to check out on an ephemeral target (or undefined).
|
|
288
|
+
*/
|
|
289
|
+
async function wrapUpWorkingTree(source, active, options) {
|
|
290
|
+
const cwd = source.cwd;
|
|
291
|
+
if (!cwd || !fs.existsSync(cwd))
|
|
292
|
+
return undefined;
|
|
293
|
+
const git = simpleGit(cwd);
|
|
294
|
+
const isRepo = await git.checkIsRepo().catch(() => false);
|
|
295
|
+
if (!isRepo)
|
|
296
|
+
return undefined;
|
|
297
|
+
const status = await git.status();
|
|
298
|
+
const dirty = status.files.length > 0;
|
|
299
|
+
const ahead = status.ahead ?? 0;
|
|
300
|
+
if (!dirty && ahead === 0) {
|
|
301
|
+
return status.current || undefined;
|
|
302
|
+
}
|
|
303
|
+
if (options.agentWrapup) {
|
|
304
|
+
await delegateWrapupToAgent(source, active);
|
|
305
|
+
// The agent handles the commit/push; report its current branch for checkout.
|
|
306
|
+
const after = await git.status();
|
|
307
|
+
return after.current || undefined;
|
|
308
|
+
}
|
|
309
|
+
if (dirty) {
|
|
310
|
+
const branch = status.current && status.current !== 'main' && status.current !== 'master'
|
|
311
|
+
? status.current
|
|
312
|
+
: `migrate/${source.shortId}`;
|
|
313
|
+
// Only create the branch when we're on the default branch (don't strand edits on main).
|
|
314
|
+
if (status.current === 'main' || status.current === 'master') {
|
|
315
|
+
console.log(chalk.gray(` Working tree dirty on ${status.current} — committing to a new branch ${branch}.`));
|
|
316
|
+
await git.checkoutLocalBranch(branch);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
console.log(chalk.gray(` Working tree dirty on ${branch} — committing before migrate.`));
|
|
320
|
+
}
|
|
321
|
+
await git.add('-A');
|
|
322
|
+
await git.commit(`wip: migrate ${source.agent} session ${source.shortId}`);
|
|
323
|
+
await git.push(['-u', 'origin', branch]).catch((e) => {
|
|
324
|
+
console.log(chalk.yellow(` push failed: ${e.message}`));
|
|
325
|
+
});
|
|
326
|
+
openWipPr(cwd, branch);
|
|
327
|
+
return branch;
|
|
328
|
+
}
|
|
329
|
+
// Clean but ahead — push what's committed.
|
|
330
|
+
console.log(chalk.gray(` ${ahead} local commit(s) ahead — pushing before migrate.`));
|
|
331
|
+
await git.push().catch((e) => console.log(chalk.yellow(` push failed: ${e.message}`)));
|
|
332
|
+
return status.current || undefined;
|
|
333
|
+
}
|
|
334
|
+
/** Open a draft (WIP) PR for the wrap-up branch. Best-effort; a failure is a notice. */
|
|
335
|
+
function openWipPr(cwd, branch) {
|
|
336
|
+
const r = spawnSync('gh', ['pr', 'create', '--draft', '--fill', '--head', branch], { cwd, encoding: 'utf-8' });
|
|
337
|
+
if (r.status === 0) {
|
|
338
|
+
const url = (r.stdout || '').trim().split('\n').pop();
|
|
339
|
+
console.log(chalk.green(` WIP PR: ${url}`));
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
console.log(chalk.yellow(` Could not open a WIP PR (${(r.stderr || '').trim().split('\n').pop() || 'gh error'}). Branch ${branch} is pushed.`));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/** --agent-wrapup: inject a wrap-up turn into the running agent's tmux pane. */
|
|
346
|
+
async function delegateWrapupToAgent(source, active) {
|
|
347
|
+
const rail = active?.provenance?.reply;
|
|
348
|
+
if (!rail) {
|
|
349
|
+
console.log(chalk.yellow(' --agent-wrapup: no addressable reply rail for the running agent; committing mechanically instead is not possible here — the tree stays as-is.'));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const target = injectTargetFromReplyRail(rail);
|
|
353
|
+
if (!target) {
|
|
354
|
+
console.log(chalk.yellow(' --agent-wrapup: reply rail is not injectable; tree stays as-is.'));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const text = 'Before this session is migrated to another machine, commit the current working changes to a branch, push it, and open a draft WIP PR. Then reply "wrapped up".';
|
|
358
|
+
const res = await injectIntoTerminal(target, text, { enter: true });
|
|
359
|
+
if (res.ok) {
|
|
360
|
+
console.log(chalk.gray(' Asked the running agent to wrap up its working tree (draft PR).'));
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
console.log(chalk.yellow(` --agent-wrapup injection failed: ${res.error ?? 'unknown'}.`));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Ship the transcript to the target so `<agent> --resume` can find it there.
|
|
368
|
+
*
|
|
369
|
+
* The live transcript (`SessionMeta.filePath`) is copied to the SAME absolute
|
|
370
|
+
* path on the target: claude/codex/opencode resolve a resumable session by the
|
|
371
|
+
* cwd-derived project dir under $HOME, which is identical across the shared fleet
|
|
372
|
+
* home, so a same-path copy is exactly what `--resume` reads. (`sessions import`
|
|
373
|
+
* deliberately lands bundles in the browsable history mirror, not the agent's
|
|
374
|
+
* live dir — right for reading a transcript on another box, wrong for resuming
|
|
375
|
+
* it.) Reuses the same `sshExec` transport, streaming the file over stdin.
|
|
376
|
+
*/
|
|
377
|
+
function shipTranscript(sshTarget, source) {
|
|
378
|
+
const file = source.filePath;
|
|
379
|
+
if (!file || !fs.existsSync(file)) {
|
|
380
|
+
fail(`Cannot locate the local transcript for ${source.shortId} to ship (${file ?? 'no path'}).`);
|
|
381
|
+
}
|
|
382
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
383
|
+
const parent = path.dirname(file);
|
|
384
|
+
const remoteCmd = `mkdir -p ${shellQuote(parent)} && cat > ${shellQuote(file)}`;
|
|
385
|
+
const res = sshExec(sshTarget, remoteCmd, { input: content, timeoutMs: 120000 });
|
|
386
|
+
if (res.code !== 0) {
|
|
387
|
+
fail(`Failed to ship the transcript to ${sshTarget}: ${res.stderr.trim().split('\n').pop() || `ssh exited ${res.code}`}`);
|
|
388
|
+
}
|
|
389
|
+
console.log(chalk.green(` Transcript shipped to the target (${file}).`));
|
|
390
|
+
}
|
|
391
|
+
/** Git-clone + checkout the (WIP) branch on an ephemeral box so the cwd resolves. */
|
|
392
|
+
function prepareEphemeralCwd(sshTarget, source, branch) {
|
|
393
|
+
const remote = spawnSync('git', ['-C', source.cwd || '.', 'remote', 'get-url', 'origin'], { encoding: 'utf-8' });
|
|
394
|
+
const url = (remote.stdout || '').trim();
|
|
395
|
+
if (remote.status !== 0 || !url) {
|
|
396
|
+
console.log(chalk.yellow(' Ephemeral target: the source cwd has no origin remote to clone; the resumed session will start in $HOME.'));
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
const dir = `~/migrated/${source.shortId}`;
|
|
400
|
+
const checkout = branch ? ` && git checkout ${shellQuote(branch)}` : '';
|
|
401
|
+
const script = `mkdir -p ~/migrated && (test -d ${dir}/.git || git clone ${shellQuote(url)} ${dir})${' && cd ' + dir + checkout}`;
|
|
402
|
+
const res = sshExec(sshTarget, `bash -lc ${shellQuote(script)}`, { timeoutMs: 300000 });
|
|
403
|
+
if (res.code !== 0) {
|
|
404
|
+
console.log(chalk.yellow(` Clone on the box failed (${res.stderr.trim().split('\n').pop() || 'git error'}); the resumed session will start in $HOME.`));
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
return dir;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Resume the session on the target through the SAME host path as
|
|
411
|
+
* `sessions resume --host` (tmux backend). Returns true when the resume launched.
|
|
412
|
+
*/
|
|
413
|
+
async function resumeOnTarget(sshTarget, source, remoteCwd, mode) {
|
|
414
|
+
const command = mode === 'resume' ? buildResumeCommand(source) : rehydrateCommand(source);
|
|
415
|
+
if (!command) {
|
|
416
|
+
fail(`Cannot build a resume command for ${source.agent} (mode ${mode}).`);
|
|
417
|
+
}
|
|
418
|
+
// Start a DETACHED tmux session on the target. The generic engine tmux backend
|
|
419
|
+
// uses `new-window`, which needs a live server — a fresh worker or ephemeral
|
|
420
|
+
// box has none ("no server running"), so we create the session (and thus the
|
|
421
|
+
// server) directly with `new-session -d`, mirroring the local `createSession`
|
|
422
|
+
// helper (remain-on-exit keeps the pane inspectable if the agent exits), over
|
|
423
|
+
// the same SSH transport as `sessions resume --host`.
|
|
424
|
+
//
|
|
425
|
+
// Each argv element is quoted BEFORE the zsh -ilc wrapper so a command that
|
|
426
|
+
// carries spaces or backticks (the rehydrate prompt) reaches the agent as one
|
|
427
|
+
// clean argument — an unquoted join would let the target shell split it and
|
|
428
|
+
// run its backticks.
|
|
429
|
+
const sessionName = `migrate-${source.shortId}`;
|
|
430
|
+
const inner = iLoginShell(`exec ${command.map(quoteArg).join(' ')}`);
|
|
431
|
+
const argv = ['tmux', 'set-option', '-g', 'remain-on-exit', 'on', ';', 'new-session', '-d', '-s', sessionName];
|
|
432
|
+
const cwd = remoteCwd ?? source.cwd;
|
|
433
|
+
if (cwd && cwd !== '~')
|
|
434
|
+
argv.push('-c', cwd);
|
|
435
|
+
argv.push(inner);
|
|
436
|
+
const launch = sshExec(sshTarget, argv.map(shellQuote).join(' '), { timeoutMs: 60000 });
|
|
437
|
+
if (launch.code !== 0) {
|
|
438
|
+
console.log(chalk.red(` Resume on ${sshTarget} failed: ${launch.stderr.trim().split('\n').pop() || `tmux exited ${launch.code}`}`));
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
// Liveness gate for the invariant: give the agent a moment to boot, then require
|
|
442
|
+
// the pane to be ALIVE (not merely the session to exist) before the caller may
|
|
443
|
+
// stop the source. An agent that dies on launch → we refuse to move.
|
|
444
|
+
const q = shellQuote(sessionName);
|
|
445
|
+
const probe = `sleep 3; tmux has-session -t ${q} 2>/dev/null || exit 3; test "$(tmux list-panes -t ${q} -F '#{pane_dead}' 2>/dev/null | head -n1)" = 0 || exit 4`;
|
|
446
|
+
const check = sshExec(sshTarget, probe, { timeoutMs: 30000 });
|
|
447
|
+
if (check.code !== 0) {
|
|
448
|
+
const why = check.code === 4 ? 'the agent exited immediately on the target'
|
|
449
|
+
: check.code === 3 ? 'the session did not start'
|
|
450
|
+
: (check.stderr.trim().split('\n').pop() || `liveness probe exited ${check.code}`);
|
|
451
|
+
console.log(chalk.red(` Resume on ${sshTarget} is not live: ${why}.`));
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
console.log(chalk.green(` Resumed on the target in tmux session ${sessionName} (${command.join(' ')}).`));
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Rehydrate command: launch a fresh agent that reads the transcript. For a
|
|
459
|
+
* non-resumable agent there is no faithful --resume, so we start the agent with
|
|
460
|
+
* a prompt pointing it at the imported transcript path.
|
|
461
|
+
*/
|
|
462
|
+
export function rehydrateCommand(source) {
|
|
463
|
+
// Resolve the real executable — the session-agent id is not always the binary
|
|
464
|
+
// name (antigravity → `agy`), matching versionedAliasIfPresent/buildFallbackCommand.
|
|
465
|
+
const cli = AGENTS[source.agent]?.cliCommand ?? source.agent;
|
|
466
|
+
const origin = source.machine ? ` from ${source.machine}` : '';
|
|
467
|
+
const prompt = [
|
|
468
|
+
`You are continuing session ${source.shortId}, migrated to this machine${origin}.`,
|
|
469
|
+
`Its full transcript is here — read it with \`agents sessions ${source.shortId}\`.`,
|
|
470
|
+
`It supports --markdown, role filters (e.g. --include user,assistant), and --last N;`,
|
|
471
|
+
`use them so large tool outputs don't blow your context — skim the recent turns first,`,
|
|
472
|
+
`widen only if you need to, then continue the work where it left off.`,
|
|
473
|
+
].join(' ');
|
|
474
|
+
return [cli, prompt];
|
|
475
|
+
}
|
|
476
|
+
async function sessionsMigrateAction(sessionId, options) {
|
|
477
|
+
if (options.mode && options.mode !== 'resume' && options.mode !== 'rehydrate') {
|
|
478
|
+
fail(`--mode must be 'resume' or 'rehydrate' (got "${options.mode}").`);
|
|
479
|
+
}
|
|
480
|
+
// 1. Resolve the source session (current-pane unless an id is given).
|
|
481
|
+
const { meta: source, active } = await resolveSourceSession(sessionId);
|
|
482
|
+
console.log(chalk.bold(`Migrating ${source.agent} session ${source.shortId}`) + chalk.gray(` (${source.cwd ?? 'no cwd'})`));
|
|
483
|
+
// 2. Resolve the target host.
|
|
484
|
+
const target = await resolveTarget(options, source);
|
|
485
|
+
const sshTarget = sshTargetForTarget(target);
|
|
486
|
+
// 3. Verify the target can run the agent+version (bootstrap if missing); resolve the mode.
|
|
487
|
+
const mode = ensureTargetReady(target, sshTarget, source, options.mode ?? 'resume');
|
|
488
|
+
// 4. Wrap up the working dir (WIP PR by default, or delegate to the agent).
|
|
489
|
+
const branch = await wrapUpWorkingTree(source, active, options);
|
|
490
|
+
// 5. Transport the transcript to the target's live agent dir (same path) so
|
|
491
|
+
// `<agent> --resume` finds it.
|
|
492
|
+
shipTranscript(sshTarget, source);
|
|
493
|
+
// 6. For an ephemeral box, clone + checkout the branch so the cwd resolves.
|
|
494
|
+
const remoteCwd = target.kind === 'ephemeral' ? prepareEphemeralCwd(sshTarget, source, branch) : source.cwd;
|
|
495
|
+
// 7. Resume on the target.
|
|
496
|
+
const resumed = await resumeOnTarget(sshTarget, source, remoteCwd, mode);
|
|
497
|
+
// Ledger stub shared by the success and failure records (RUSH-1977). `at` is
|
|
498
|
+
// stamped from the CLI's real clock.
|
|
499
|
+
const base = {
|
|
500
|
+
sessionId: source.id,
|
|
501
|
+
shortId: source.shortId,
|
|
502
|
+
agent: source.agent,
|
|
503
|
+
mode,
|
|
504
|
+
move: !options.keep,
|
|
505
|
+
from: { host: os.hostname(), cwd: source.cwd, pane: active?.provenance?.mux?.pane },
|
|
506
|
+
to: { host: target.name, cwd: remoteCwd, box: target.box?.slug },
|
|
507
|
+
branch,
|
|
508
|
+
at: new Date().toISOString(),
|
|
509
|
+
};
|
|
510
|
+
if (!resumed) {
|
|
511
|
+
recordMigration({ ...base, status: 'failed', error: 'resume did not launch on the target' });
|
|
512
|
+
fail('Resume on the target did not launch — the source is left running (nothing was stopped).');
|
|
513
|
+
}
|
|
514
|
+
// 8. INVARIANT: only now that the transcript is on the target and the session
|
|
515
|
+
// is confirmed live, stop the source. --keep skips this (copy, not move).
|
|
516
|
+
if (options.keep) {
|
|
517
|
+
console.log(chalk.gray('--keep: the source session is left running (copy, not move).'));
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
await stopSource(source, active);
|
|
521
|
+
}
|
|
522
|
+
// 9. Record the handoff so the session stays trackable (agents sessions migrations).
|
|
523
|
+
recordMigration({ ...base, status: 'completed' });
|
|
524
|
+
console.log(chalk.green(`\nMigrated ${source.shortId} to ${target.name}${options.keep ? ' (copy)' : ''}.`) +
|
|
525
|
+
chalk.gray(" Tracked in 'agents sessions migrations'."));
|
|
526
|
+
}
|
|
527
|
+
/** Stop the source tmux session (kill its tmux session by name via its socket). */
|
|
528
|
+
async function stopSource(source, active) {
|
|
529
|
+
const mux = active?.provenance?.mux;
|
|
530
|
+
// Fail closed: only the SOURCE's own pane, from its active-session provenance.
|
|
531
|
+
// Never fall back to $TMUX_PANE — that is the invoking shell's pane, so a
|
|
532
|
+
// migrate run by explicit id from a different pane (where `active` didn't
|
|
533
|
+
// resolve) would otherwise kill the user's OWN session, not the source.
|
|
534
|
+
const pane = mux?.pane;
|
|
535
|
+
const socket = mux?.socket;
|
|
536
|
+
if (!pane) {
|
|
537
|
+
console.log(chalk.yellow(` Could not resolve ${source.shortId}'s own tmux pane — leaving it running (won't stop a pane not confirmed to be the source).`));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const name = resolveSessionNameForPane(pane, socket);
|
|
541
|
+
if (!name) {
|
|
542
|
+
console.log(chalk.yellow(` Could not resolve a tmux session name for pane ${pane} — leaving the source running.`));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const killed = await killSession(name, socket);
|
|
546
|
+
if (killed)
|
|
547
|
+
console.log(chalk.gray(` Stopped the source tmux session (${name}).`));
|
|
548
|
+
else
|
|
549
|
+
console.log(chalk.yellow(` Source tmux session ${name} was already gone.`));
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* `agents sessions migrations` — the border tracker: the append-only ledger of
|
|
553
|
+
* every session handed off to/from another machine (RUSH-1977).
|
|
554
|
+
*/
|
|
555
|
+
export function registerSessionsMigrationsCommand(sessionsCmd) {
|
|
556
|
+
sessionsCmd
|
|
557
|
+
.command('migrations')
|
|
558
|
+
.description('Show the migration ledger — sessions handed off to/from other machines.')
|
|
559
|
+
.option('--json', 'Output the raw ledger as JSON')
|
|
560
|
+
.option('--session <id>', 'Only rows whose session id starts with this fragment')
|
|
561
|
+
.action((options) => {
|
|
562
|
+
let recs = readMigrations();
|
|
563
|
+
if (options.session)
|
|
564
|
+
recs = recs.filter((r) => r.sessionId.startsWith(options.session) || r.shortId.startsWith(options.session));
|
|
565
|
+
if (options.json) {
|
|
566
|
+
console.log(JSON.stringify(recs, null, 2));
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (recs.length === 0) {
|
|
570
|
+
console.log(chalk.gray('No migrations recorded yet. Move one: agents sessions migrate --auto'));
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
// Newest first — the most recent hop of a session is what you usually want.
|
|
574
|
+
recs.reverse();
|
|
575
|
+
console.log(chalk.bold('WHEN'.padEnd(18)) + chalk.bold('SESSION'.padEnd(11)) + chalk.bold('AGENT'.padEnd(9)) +
|
|
576
|
+
chalk.bold('ROUTE'.padEnd(30)) + chalk.bold('MODE'.padEnd(11)) + chalk.bold('STATUS'));
|
|
577
|
+
for (const r of recs) {
|
|
578
|
+
const when = r.at.slice(0, 16).replace('T', ' ');
|
|
579
|
+
const route = `${r.from.host} → ${r.to.box ?? r.to.host}`;
|
|
580
|
+
const kind = r.move ? r.mode : `${r.mode}·copy`;
|
|
581
|
+
const status = r.status === 'completed' ? chalk.green('ok') : chalk.red('failed');
|
|
582
|
+
const pr = r.wipPr ? chalk.gray(` ${r.wipPr}`) : '';
|
|
583
|
+
console.log(when.padEnd(18) + r.shortId.padEnd(11) + r.agent.padEnd(9) +
|
|
584
|
+
route.padEnd(30) + kind.padEnd(11) + status + pr);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
/** Map a tmux pane id to its session name via the same tmux binary the engine uses. */
|
|
589
|
+
function resolveSessionNameForPane(pane, socket) {
|
|
590
|
+
const args = socket ? ['-S', socket] : [];
|
|
591
|
+
const r = spawnSync('tmux', [...args, 'display-message', '-pt', pane, '-p', '#{session_name}'], { encoding: 'utf-8' });
|
|
592
|
+
if (r.status !== 0)
|
|
593
|
+
return undefined;
|
|
594
|
+
const name = (r.stdout || '').trim();
|
|
595
|
+
return name || undefined;
|
|
596
|
+
}
|
|
@@ -10,7 +10,8 @@ import chalk from 'chalk';
|
|
|
10
10
|
import { truncate, humanDuration } from '../lib/format.js';
|
|
11
11
|
import { parseSession, sanitizeForTerminal } from '../lib/session/parse.js';
|
|
12
12
|
import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.js';
|
|
13
|
-
import { linkPath, relativeToCwd } from '../lib/session/render.js';
|
|
13
|
+
import { linkPath, linkUrl, relativeToCwd } from '../lib/session/render.js';
|
|
14
|
+
import { linearIssueUrl } from '../lib/session/linear.js';
|
|
14
15
|
import { renderMarkdown } from '../lib/markdown.js';
|
|
15
16
|
import { itemPicker } from '../lib/picker.js';
|
|
16
17
|
import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
|
|
@@ -42,6 +43,8 @@ function sanitizeMeta(s) {
|
|
|
42
43
|
account: clean(s.account),
|
|
43
44
|
topic: clean(s.topic),
|
|
44
45
|
label: clean(s.label),
|
|
46
|
+
ticketId: clean(s.ticketId),
|
|
47
|
+
prUrl: clean(s.prUrl),
|
|
45
48
|
};
|
|
46
49
|
}
|
|
47
50
|
const previewCache = new Map();
|
|
@@ -126,10 +129,22 @@ function formatHeader(session, events) {
|
|
|
126
129
|
if (session.label)
|
|
127
130
|
line3.push(chalk.white(session.label));
|
|
128
131
|
line3.push(chalk.gray(linkPath(session.filePath, session.id)));
|
|
132
|
+
// Line 4: ticket + PR — clickable when a URL is resolvable (OSC 8 hyperlink),
|
|
133
|
+
// plain text otherwise. Only rendered when the session carries either.
|
|
134
|
+
const line4 = [];
|
|
135
|
+
if (session.ticketId) {
|
|
136
|
+
const url = linearIssueUrl(session.ticketId);
|
|
137
|
+
line4.push(chalk.blue(url ? linkUrl(url, session.ticketId) : session.ticketId));
|
|
138
|
+
}
|
|
139
|
+
if (session.prUrl) {
|
|
140
|
+
const label = session.prNumber ? `PR#${session.prNumber}` : 'PR';
|
|
141
|
+
line4.push(chalk.blue(linkUrl(session.prUrl, label)));
|
|
142
|
+
}
|
|
129
143
|
return [
|
|
130
144
|
line1.join(DOT),
|
|
131
145
|
line2.join(DOT),
|
|
132
146
|
line3.join(DOT),
|
|
147
|
+
...(line4.length ? [line4.join(DOT)] : []),
|
|
133
148
|
].join('\n');
|
|
134
149
|
}
|
|
135
150
|
function extractModel(events) {
|