impel-cli 0.20.2 → 0.20.4

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.
@@ -1,4 +1,7 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createInterface } from "node:readline/promises";
2
5
 
3
6
  import { parseFlags } from "../args.js";
4
7
  import {
@@ -19,11 +22,18 @@ import {
19
22
  awsContext,
20
23
  describeTask,
21
24
  readStack,
25
+ startBrokerSshProxy,
22
26
  startSshProxy,
23
27
  startTask,
24
28
  stopTask,
25
29
  waitForTaskReady,
26
30
  } from "../remote/aws.js";
31
+ import { isSessionsUUID, RemoteBrokerClient, newMutationKey } from "../remote/broker.js";
32
+ import { prepareProviderCheckpoint, exactCheckpointRequiredMessage } from "../remote/checkpoint.js";
33
+ import {
34
+ assertRemoteCommandContract,
35
+ capabilityFailureMessage,
36
+ } from "../remote/contracts.js";
27
37
  import { requireExecutables, runCapture, runInteractive } from "../remote/process.js";
28
38
  import {
29
39
  initializeRun,
@@ -32,6 +42,7 @@ import {
32
42
  newRunId,
33
43
  readRunState,
34
44
  removeRunSecrets,
45
+ removeTransferArtifacts,
35
46
  removeSshAlias,
36
47
  resolveRunId,
37
48
  runPaths,
@@ -47,51 +58,74 @@ import {
47
58
  uploadRepository,
48
59
  waitForSsh,
49
60
  } from "../remote/transfer.js";
61
+ import { recordPortableLegacyInvocation } from "../remote/telemetry.js";
62
+ import { createWorkspaceArtifact } from "../remote/workspace.js";
50
63
 
51
64
  const DEFAULT_REGION = "eu-west-2";
52
65
  const DEFAULT_STACK = "impel-remote-dev";
53
66
  const DEFAULT_TTL_SECONDS = 3600;
54
67
  const DEFAULT_STARTUP_TIMEOUT_SECONDS = 300;
55
68
  const PROVIDERS = new Set(["claude", "codex"]);
69
+ const LEGACY_MODE = "portable-legacy";
70
+ const LEGACY_FLAG = "IMPEL_REMOTE_ENABLE_PORTABLE_LEGACY";
56
71
 
57
72
  const HELP = `impel remote - run an Impel session on an isolated AWS Fargate runner
58
73
 
59
74
  Usage:
60
- impel remote up [path] [options]
75
+ impel remote dispatch [path] --provider codex|claude --mode fork|portable --session <sessions-uuid>
76
+ impel remote handoff [path] --provider codex --mode native --session <sessions-uuid>
61
77
  impel remote status [run-id] [--json]
78
+ impel remote viewer [run-id] [--json]
79
+ impel remote proxy <run-id> --port <port>
80
+ impel remote handback [run-id] --yes [--abandon]
81
+
82
+ Legacy rollback commands (require ${LEGACY_FLAG}=1; removal scheduled for 2026-10-31):
83
+ impel remote up [path] --mode portable-legacy [options]
62
84
  impel remote attach [run-id] [--provider codex|claude] [--desktop]
63
- impel remote dispatch [path] --provider codex|claude --session <id> [--fork]
64
- impel remote handoff [path] --provider codex|claude --session <id> [--detach]
65
85
  impel remote follow [run-id]
66
- impel remote proxy <run-id> --port <port>
67
86
  impel remote down [run-id] --yes
68
87
  impel remote down --all --yes
69
88
 
70
- Lifecycle options:
89
+ Broker lifecycle options:
71
90
  --provider <name> Provider prepared for the run. Default: codex.
72
91
  --ttl <seconds> Task lifetime from 300 through 86400. Default: 3600.
92
+ --timeout <seconds> Runner startup timeout from 30 through 900. Default: 300.
93
+ --json Emit machine-readable state where supported.
94
+ --mode <mode> Public modes: native, fork, or portable.
95
+ --source <surface> Explicit source: desktop, cli, or app-server. Default: cli.
96
+ --broker <url> Remote broker origin. Defaults to config remoteUrl/appUrl.
97
+ --checkpoint <path> Closed exact provider archive for fork.
98
+ --checkpoint-manifest <path> Closed capture manifest; CLI binds it to the broker run.
99
+ --session <uuid> Required Sessions binding UUID for every brokered run.
100
+ This is not a provider-native thread/session ID.
101
+
102
+ Legacy rollback options:
73
103
  --region <region> AWS region. Default: eu-west-2.
74
104
  --profile <profile> AWS CLI profile. Defaults to AWS_PROFILE/current AWS config.
75
105
  --stack <name> CloudFormation stack. Default: impel-remote-dev.
76
106
  --install auto|skip Restore recognized lockfile dependencies. Default: auto.
77
107
  --setup <command> Additional explicit setup command to run after transfer.
78
108
  --env <csv> Explicit environment variable names to copy; none by default.
79
- --timeout <seconds> Runner startup timeout from 30 through 900. Default: 300.
80
- --json Emit machine-readable state where supported.
109
+ --detach Start the legacy remote worker and return immediately.
81
110
 
82
- Handoff options:
83
- --detach Start remote execution and return immediately.
111
+ Migration rollback (temporary; removal scheduled for 2026-10-31):
112
+ --mode portable-legacy Use the former one-shot worker only when
113
+ ${LEGACY_FLAG}=1 is also set.
84
114
 
85
115
  Attach options:
86
- --session <id> Resume a transferred provider session.
116
+ --session <id> Legacy attach: resume a transferred provider session.
87
117
  --fork Fork instead of continuing the transferred session.
88
118
  --desktop Open the native desktop connection flow instead of a terminal.
89
119
 
120
+ Native availability:
121
+ Codex Desktop and its SSH tunnel must remain awake and online for ChatGPT Remote.
122
+ Laptop-independent control requires Codex App Server or Claude through the Impel viewer.
123
+
90
124
  The live UI uses native SSH: Codex Desktop starts remote codex app-server and can
91
125
  hand off an existing chat and Git state. Claude Desktop can start an SSH session;
92
- existing Claude sessions use dispatch/resume because Claude has no arbitrary-host
93
- desktop handoff API. Handoff starts a headless worker inside Fargate and streams
94
- its structured events back; the local app is only the control/viewer process.
126
+ gateway-backed Claude has no native arbitrary-host handoff. The former one-shot
127
+ worker is not a handoff and is available only as the explicitly flagged,
128
+ short-lived portable-legacy rollback mode.
95
129
  Credentials, ignored files, SSH agents, and environment variables are never
96
130
  copied implicitly.
97
131
  `;
@@ -118,6 +152,15 @@ function providerFlag(value, fallback = "codex") {
118
152
  return provider;
119
153
  }
120
154
 
155
+ function sessionsUUIDFlag(value, action) {
156
+ if (!value) fail(`impel remote ${action}: --session <sessions-uuid> is required for every brokered run`);
157
+ const sessionId = String(value);
158
+ if (!isSessionsUUID(sessionId)) {
159
+ fail(`impel remote ${action}: --session must be a lowercase Sessions UUID, not a provider thread/session ID`);
160
+ }
161
+ return sessionId;
162
+ }
163
+
121
164
  function rejectFlags(flags, allowed, action) {
122
165
  const unsupported = Object.keys(flags).find((name) => !allowed.has(name));
123
166
  if (unsupported) fail(`impel remote ${action}: unknown option --${unsupported}`);
@@ -130,16 +173,22 @@ function rejectMissingFlagValues(flags, spec, action) {
130
173
 
131
174
  function lifecycleSpec() {
132
175
  return {
176
+ broker: { type: "string" },
177
+ checkpoint: { type: "string" },
178
+ "checkpoint-manifest": { type: "string" },
179
+ "desktop-confirmed": { type: "boolean" },
133
180
  env: { type: "string" },
134
181
  detach: { type: "boolean" },
135
182
  help: { type: "boolean" },
136
183
  install: { type: "string" },
137
184
  json: { type: "boolean" },
185
+ mode: { type: "string" },
138
186
  profile: { type: "string" },
139
187
  provider: { type: "string" },
140
188
  region: { type: "string" },
141
189
  session: { type: "string" },
142
190
  setup: { type: "string" },
191
+ source: { type: "string" },
143
192
  stack: { type: "string" },
144
193
  timeout: { type: "string" },
145
194
  ttl: { type: "string" },
@@ -147,6 +196,258 @@ function lifecycleSpec() {
147
196
  };
148
197
  }
149
198
 
199
+ function brokerClient(config, brokerOverride) {
200
+ return new RemoteBrokerClient({
201
+ baseUrl: normalizeGatewayUrl(brokerOverride || config.remoteUrl || config.appUrl || resolveDefaultAppUrl()),
202
+ pat: config.pat,
203
+ });
204
+ }
205
+
206
+ function brokerRunState(remote, prior = {}) {
207
+ const remoteState = remote.state || remote.status;
208
+ return {
209
+ ...prior,
210
+ runId: remote.runId,
211
+ backend: "broker",
212
+ alias: prior.alias || sshAlias(remote.runId),
213
+ status: remoteState,
214
+ provider: remote.provider || prior.provider,
215
+ mode: remote.mode || prior.mode,
216
+ sourceSurface: remote.sourceSurface || prior.sourceSurface,
217
+ session: remote.sessionId ? { provider: remote.provider || prior.provider, id: remote.sessionId } : prior.session || null,
218
+ createdAt: remote.createdAt || prior.createdAt,
219
+ expiresAt: remote.expiresAt || prior.expiresAt,
220
+ brokerUrl: prior.brokerUrl,
221
+ repository: prior.repository ? {
222
+ head: prior.repository.head,
223
+ branch: prior.repository.branch,
224
+ relativeProjectPath: prior.repository.relativeProjectPath,
225
+ remoteProjectPath: prior.repository.remoteProjectPath,
226
+ } : null,
227
+ remote: {
228
+ state: remoteState,
229
+ version: remote.version ?? null,
230
+ workspaceClosed: typeof remote.workspace?.closedAt === "string"
231
+ && Date.parse(remote.workspace.closedAt) > 0,
232
+ inputCheckpointClosed: typeof remote.inputCheckpoint?.closedAt === "string"
233
+ && Date.parse(remote.inputCheckpoint.closedAt) > 0,
234
+ taskState: remote.task?.state || null,
235
+ connectionState: remote.connection?.state || null,
236
+ reasonCode: remote.stateReason || remote.reasonCode || null,
237
+ },
238
+ };
239
+ }
240
+
241
+ function persistBrokerRun(remote, prior) {
242
+ return writeRunState(brokerRunState(remote, prior));
243
+ }
244
+
245
+ function sourceSurfaceFlag(value) {
246
+ return value || process.env.IMPEL_REMOTE_SOURCE_SURFACE || "cli";
247
+ }
248
+
249
+ function terminalBrokerState(state) {
250
+ return new Set(["stopped", "failed", "expired", "cancelled"]).has(state);
251
+ }
252
+
253
+ async function waitForDesktopConfirmation(flags) {
254
+ if (flags["desktop-confirmed"] === true) return;
255
+ console.log("Codex CLI native handoff first requires the same saved session in Desktop. Run `/app`, confirm that Desktop opened this chat, then continue.");
256
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
257
+ fail("impel remote handoff: Desktop confirmation is required; complete `/app`, then rerun with --source desktop (or --desktop-confirmed from a trusted interactive wrapper)");
258
+ }
259
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
260
+ try { await prompt.question("Press Enter after Desktop shows the same chat: "); }
261
+ finally { prompt.close(); }
262
+ }
263
+
264
+ async function negotiateCapability(client, contract, flags) {
265
+ let current = contract;
266
+ let capability = await client.capability(current);
267
+ if (
268
+ current.provider === "codex" && current.mode === "native" && current.sourceSurface === "cli"
269
+ && !capability.supported && capability.reasonCode === "DESKTOP_HANDOFF_REQUIRED"
270
+ ) {
271
+ await waitForDesktopConfirmation(flags);
272
+ current = { ...current, sourceSurface: "desktop" };
273
+ capability = await client.capability(current);
274
+ }
275
+ if (!capability.supported) fail(`impel remote ${contract.action}: ${capabilityFailureMessage(capability)} No fallback was selected.`);
276
+ return { contract: current, capability };
277
+ }
278
+
279
+ async function waitForBrokerRun(client, state, timeoutSeconds, { json = false } = {}) {
280
+ const deadline = Date.now() + timeoutSeconds * 1000;
281
+ let lastState = state.status;
282
+ while (Date.now() < deadline) {
283
+ const remote = await client.getRun(state.runId);
284
+ state = persistBrokerRun(remote, state);
285
+ if (!json && state.status !== lastState) console.log(`Remote run ${state.runId}: ${state.status}`);
286
+ lastState = state.status;
287
+ if (new Set(["ready", "running"]).has(state.status)) return state;
288
+ if (terminalBrokerState(state.status)) {
289
+ throw new Error(`remote run ${state.runId} became ${state.status}${state.remote.reasonCode ? ` (${state.remote.reasonCode})` : ""}`);
290
+ }
291
+ await new Promise((resolve) => setTimeout(resolve, Number(process.env.IMPEL_REMOTE_BROKER_POLL_MS || 1000)));
292
+ }
293
+ throw new Error(`remote run ${state.runId} did not become ready within ${timeoutSeconds} seconds`);
294
+ }
295
+
296
+ function generateNativeSshKey() {
297
+ requireExecutables(["ssh-keygen", "session-manager-plugin"]);
298
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "impel-native-ssh-"));
299
+ const privateKey = path.join(root, "id_ed25519");
300
+ runCapture(process.env.IMPEL_REMOTE_SSH_KEYGEN_BIN || "ssh-keygen", [
301
+ "-q", "-t", "ed25519", "-N", "", "-C", "impel-remote-native", "-f", privateKey,
302
+ ]);
303
+ const publicKey = fs.readFileSync(`${privateKey}.pub`, "utf8").trim();
304
+ if (!/^ssh-ed25519 [A-Za-z0-9+/]+={0,2}(?: [^\r\n]+)?$/u.test(publicKey)) {
305
+ fs.rmSync(root, { recursive: true, force: true });
306
+ throw new Error("ssh-keygen returned an invalid Ed25519 public key");
307
+ }
308
+ return { root, privateKey, publicKey: `${publicKey}\n` };
309
+ }
310
+
311
+ function retainNativeSshKey(generated, runId) {
312
+ const paths = initializeRun(runId);
313
+ fs.renameSync(generated.privateKey, paths.privateKey);
314
+ fs.renameSync(`${generated.privateKey}.pub`, paths.publicKey);
315
+ fs.writeFileSync(paths.knownHosts, "", { mode: 0o600 });
316
+ fs.rmSync(generated.root, { recursive: true, force: true });
317
+ }
318
+
319
+ async function createBrokerRun(argv, action) {
320
+ const spec = lifecycleSpec();
321
+ const { flags, positionals } = parseFlags(argv, spec);
322
+ rejectFlags(flags, new Set([
323
+ "broker", "checkpoint", "checkpoint-manifest", "desktop-confirmed", "help", "json", "mode", "provider", "session", "source", "timeout", "ttl",
324
+ ]), action);
325
+ rejectMissingFlagValues(flags, spec, action);
326
+ if (flags.help) { console.log(HELP); return null; }
327
+ if (positionals.length > 1) fail(`impel remote ${action}: expected at most one repository path`);
328
+ if (!flags.mode) {
329
+ fail(`impel remote ${action}: --mode is required; no legacy or fallback mode is selected implicitly`);
330
+ }
331
+ const contract = assertRemoteCommandContract({
332
+ action,
333
+ provider: providerFlag(flags.provider),
334
+ mode: flags.mode,
335
+ sourceSurface: sourceSurfaceFlag(flags.source),
336
+ });
337
+ const sessionId = sessionsUUIDFlag(flags.session, action);
338
+ if ((flags.checkpoint && !flags["checkpoint-manifest"]) || (!flags.checkpoint && flags["checkpoint-manifest"])) {
339
+ fail("impel remote dispatch: --checkpoint and --checkpoint-manifest must be provided together");
340
+ }
341
+ if (contract.mode === "fork" && !flags.checkpoint) fail(`impel remote dispatch: ${exactCheckpointRequiredMessage(contract.provider)}`);
342
+ if (contract.mode !== "fork" && flags.checkpoint) fail("impel remote: provider checkpoints are accepted only for --mode fork");
343
+ const config = requiredConfig();
344
+ const client = brokerClient(config, flags.broker);
345
+ const negotiated = await negotiateCapability(client, contract, flags);
346
+ const selected = await ensureTenantSelection(config, { refresh: !Array.isArray(config.scopes) });
347
+ assertProviderScopes(selected.scopes, [contract.provider], { requireLive: true });
348
+ const repository = inspectRepository(positionals[0] || process.cwd());
349
+ const ttlSeconds = integerFlag(flags.ttl, "ttl", DEFAULT_TTL_SECONDS, 300, 86400);
350
+ const timeoutSeconds = integerFlag(flags.timeout, "timeout", DEFAULT_STARTUP_TIMEOUT_SECONDS, 30, 900);
351
+ let generatedKey = null;
352
+ let state = null;
353
+ let cancelled = false;
354
+ let cancellation = null;
355
+ try {
356
+ if (negotiated.contract.mode === "native") generatedKey = generateNativeSshKey();
357
+ const remote = await client.createRun({
358
+ provider: negotiated.contract.provider,
359
+ mode: negotiated.contract.mode,
360
+ sourceSurface: negotiated.contract.sourceSurface,
361
+ sessionId,
362
+ ...(repository.relativeProjectPath !== "." ? { repositorySubdir: repository.relativeProjectPath } : {}),
363
+ ttlSeconds,
364
+ ...(generatedKey ? { sshPublicKey: generatedKey.publicKey } : {}),
365
+ }, newMutationKey("create"));
366
+ initializeRun(remote.runId);
367
+ if (generatedKey) {
368
+ retainNativeSshKey(generatedKey, remote.runId);
369
+ generatedKey = null;
370
+ }
371
+ state = persistBrokerRun(remote, {
372
+ runId: remote.runId,
373
+ backend: "broker",
374
+ brokerUrl: client.baseUrl,
375
+ provider: negotiated.contract.provider,
376
+ mode: negotiated.contract.mode,
377
+ sourceSurface: negotiated.contract.sourceSurface,
378
+ repository,
379
+ createdAt: new Date().toISOString(),
380
+ });
381
+ const onInterrupt = () => {
382
+ cancelled = true;
383
+ cancellation ||= client.cancelRun(state.runId, { reason: "client_interrupt" }, newMutationKey("sigint-cancel"))
384
+ .then((value) => { state = persistBrokerRun(value, state); })
385
+ .catch(() => {});
386
+ };
387
+ process.once("SIGINT", onInterrupt);
388
+ try {
389
+ if (negotiated.contract.mode !== "native") {
390
+ if (!flags.json) console.log("Capturing immutable workspace state…");
391
+ const workspace = createWorkspaceArtifact(repository, runPaths(state.runId).workspaceRoot);
392
+ state = persistBrokerRun(await client.uploadWorkspace(state.runId, workspace), state);
393
+ if (cancelled) throw new Error("remote run cancelled after interrupt");
394
+ }
395
+ if (negotiated.contract.mode === "fork") {
396
+ const checkpoint = prepareProviderCheckpoint({
397
+ archivePath: path.resolve(flags.checkpoint),
398
+ manifestPath: path.resolve(flags["checkpoint-manifest"]),
399
+ outputPath: runPaths(state.runId).providerCheckpoint,
400
+ provider: negotiated.contract.provider,
401
+ mode: "fork",
402
+ runId: state.runId,
403
+ sessionId,
404
+ });
405
+ state = persistBrokerRun(await client.uploadProviderCheckpoint(state.runId, checkpoint), state);
406
+ }
407
+ if (cancelled) throw new Error("remote run cancelled after interrupt");
408
+ state = await waitForBrokerRun(client, state, timeoutSeconds, { json: flags.json === true });
409
+ if (cancelled) throw new Error("remote run cancelled after interrupt");
410
+ } finally {
411
+ process.removeListener("SIGINT", onInterrupt);
412
+ if (cancellation) await cancellation;
413
+ }
414
+ if (negotiated.contract.mode === "native") installSshAlias(state);
415
+ removeTransferArtifacts(state.runId);
416
+ printRun(state, flags.json === true);
417
+ if (!flags.json && negotiated.contract.mode === "native") {
418
+ console.log("");
419
+ console.log(`In Codex Desktop, add/open SSH host ${state.alias} and project ${state.repository.remoteProjectPath}.`);
420
+ console.log("Use the chat footer run-location picker to move this chat. Active generation will be interrupted.");
421
+ console.log("Desktop and this tunnel must stay awake/online for ChatGPT Remote. Detached control requires App Server plus the Impel viewer.");
422
+ console.log(`For handback, select “This computer” in Desktop, wait for the remote child to exit, then run: impel remote handback ${state.runId} --yes`);
423
+ }
424
+ return state;
425
+ } catch (error) {
426
+ if (state && !terminalBrokerState(state.status)) {
427
+ try { state = persistBrokerRun(await client.cancelRun(state.runId, { reason: "client_setup_failed" }), state); } catch { /* Broker sweeper remains authoritative. */ }
428
+ }
429
+ if (state) {
430
+ removeSshAlias(state.runId);
431
+ removeRunSecrets(state.runId);
432
+ }
433
+ throw error;
434
+ } finally {
435
+ if (generatedKey) fs.rmSync(generatedKey.root, { recursive: true, force: true });
436
+ }
437
+ }
438
+
439
+ function requirePortableLegacy(flags, action) {
440
+ if (flags.mode !== LEGACY_MODE) {
441
+ const suffix = action === "handoff"
442
+ ? "The old one-shot worker did not transfer a live session. Use `--mode native` for a supported native handoff, or `impel remote dispatch --mode fork|portable`."
443
+ : "The direct AWS runner is now a rollback-only path. Use a broker-backed `handoff --mode native` or `dispatch --mode fork|portable`.";
444
+ fail(`impel remote ${action}: ${suffix}`);
445
+ }
446
+ if (process.env[LEGACY_FLAG] !== "1") {
447
+ fail(`impel remote ${action}: portable-legacy is disabled; set ${LEGACY_FLAG}=1 only for the temporary rollback path (removal 2026-10-31)`);
448
+ }
449
+ }
450
+
150
451
  function requiredConfig() {
151
452
  const config = loadConfig();
152
453
  if (!config?.pat) fail("impel remote: not authenticated; run `impel setup` first");
@@ -218,6 +519,8 @@ async function createRemoteRun(options) {
218
519
  alias: sshAlias(runId),
219
520
  status: "preparing",
220
521
  provider,
522
+ mode: LEGACY_MODE,
523
+ legacyOnly: true,
221
524
  createdAt,
222
525
  expiresAt,
223
526
  ttlSeconds,
@@ -227,6 +530,7 @@ async function createRemoteRun(options) {
227
530
  let credential = null;
228
531
  let taskStarted = false;
229
532
  try {
533
+ recordPortableLegacyInvocation({ tenantId: selection.tenantId, runId });
230
534
  runCapture(process.env.IMPEL_REMOTE_SSH_KEYGEN_BIN || "ssh-keygen", [
231
535
  "-q", "-t", "ed25519", "-N", "", "-C", `impel-remote-${runId}`, "-f", paths.privateKey,
232
536
  ]);
@@ -342,10 +646,11 @@ async function cmdUp(argv, { dispatch = false } = {}) {
342
646
  const spec = lifecycleSpec();
343
647
  const { flags, positionals } = parseFlags(argv, spec);
344
648
  rejectFlags(flags, new Set([
345
- "env", "help", "install", "json", "profile", "provider", "region", "session", "setup", "stack", "timeout", "ttl", "fork",
649
+ "env", "help", "install", "json", "mode", "profile", "provider", "region", "session", "setup", "stack", "timeout", "ttl", "fork",
346
650
  ]), dispatch ? "dispatch" : "up");
347
651
  rejectMissingFlagValues(flags, spec, dispatch ? "dispatch" : "up");
348
652
  if (flags.help) { console.log(HELP); return null; }
653
+ requirePortableLegacy(flags, dispatch ? "dispatch" : "up");
349
654
  if (positionals.length > 1) fail(`impel remote ${dispatch ? "dispatch" : "up"}: expected at most one repository path`);
350
655
  if (!dispatch && (flags.session || flags.fork)) fail("impel remote up: --session and --fork are only supported by `impel remote dispatch`");
351
656
  if (dispatch && !flags.session) fail("impel remote dispatch: --session is required");
@@ -417,19 +722,55 @@ async function cmdStatus(argv) {
417
722
  if (flags.help) { console.log(HELP); return; }
418
723
  if (positionals.length > 1) fail("impel remote status: expected at most one run id");
419
724
  if (!positionals[0] && flags.json) {
420
- const states = listRunStates().map((state) => {
421
- try { return syncTaskStatus(state); } catch (error) {
422
- return { ...state, statusError: redactSecretText(error?.message || error) };
725
+ const states = [];
726
+ const config = loadConfig();
727
+ for (const local of listRunStates()) {
728
+ try {
729
+ states.push(local.backend === "broker"
730
+ ? persistBrokerRun(await brokerClient(config, local.brokerUrl).getRun(local.runId), local)
731
+ : syncTaskStatus(local));
732
+ } catch (error) {
733
+ states.push({ ...local, statusError: redactSecretText(error?.message || error) });
423
734
  }
424
- });
735
+ }
425
736
  console.log(JSON.stringify(states, null, 2));
426
737
  return;
427
738
  }
428
739
  const runId = resolveRunId(positionals[0], { includeStopped: true });
429
- const state = syncTaskStatus(readRunState(runId));
740
+ let state = readRunState(runId);
741
+ if (state.backend === "broker") {
742
+ const config = requiredConfig();
743
+ state = persistBrokerRun(await brokerClient(config, state.brokerUrl).getRun(runId), state);
744
+ } else {
745
+ state = syncTaskStatus(state);
746
+ }
430
747
  printRun(state, flags.json === true);
431
748
  }
432
749
 
750
+ async function cmdViewer(argv) {
751
+ const spec = { json: { type: "boolean" }, help: { type: "boolean" } };
752
+ const { flags, positionals } = parseFlags(argv, spec);
753
+ rejectFlags(flags, new Set(["help", "json"]), "viewer");
754
+ if (flags.help) { console.log(HELP); return; }
755
+ if (positionals.length > 1) fail("impel remote viewer: expected at most one run id");
756
+ let state = readRunState(resolveRunId(positionals[0]));
757
+ if (state.backend !== "broker") fail("impel remote viewer: the legacy runner has no Impel viewer");
758
+ const config = requiredConfig();
759
+ const client = brokerClient(config, state.brokerUrl);
760
+ state = persistBrokerRun(await client.getRun(state.runId), state);
761
+ if (state.status !== "running") {
762
+ fail(`impel remote viewer: run ${state.runId} is ${state.status}, not running`);
763
+ }
764
+ const viewer = await client.createViewerLink(state.runId);
765
+ if (flags.json) {
766
+ console.log(JSON.stringify({ runId: state.runId, ...viewer }, null, 2));
767
+ return;
768
+ }
769
+ console.log(`Viewer link (one-time, expires ${viewer.expiresAt}):`);
770
+ console.log(viewer.url);
771
+ console.log("The exchange secret stays in the URL fragment and is removed by the viewer after exchange.");
772
+ }
773
+
433
774
  function shellQuote(value) {
434
775
  return `'${String(value).replaceAll("'", `'"'"'`)}'`;
435
776
  }
@@ -492,7 +833,7 @@ async function cmdAttach(argv) {
492
833
  if (exitCode !== 0) process.exitCode = exitCode;
493
834
  }
494
835
 
495
- async function cmdDispatch(argv) {
836
+ async function cmdLegacyDispatch(argv) {
496
837
  const state = await cmdUp(argv, { dispatch: true });
497
838
  if (!state) return;
498
839
  const { flags } = parseFlags(argv, lifecycleSpec());
@@ -561,14 +902,15 @@ async function followRemoteWorker(state) {
561
902
  return { state: cleaned.state, completed: true };
562
903
  }
563
904
 
564
- async function cmdHandoff(argv) {
905
+ async function cmdLegacyHandoff(argv) {
565
906
  const spec = lifecycleSpec();
566
907
  const { flags, positionals } = parseFlags(argv, spec);
567
908
  rejectFlags(flags, new Set([
568
- "detach", "env", "help", "install", "json", "profile", "provider", "region", "session", "setup", "stack", "timeout", "ttl",
909
+ "detach", "env", "help", "install", "json", "mode", "profile", "provider", "region", "session", "setup", "stack", "timeout", "ttl",
569
910
  ]), "handoff");
570
911
  rejectMissingFlagValues(flags, spec, "handoff");
571
912
  if (flags.help) { console.log(HELP); return; }
913
+ requirePortableLegacy(flags, "handoff");
572
914
  if (positionals.length > 1) fail("impel remote handoff: expected at most one repository path");
573
915
  if (!flags.session) fail("impel remote handoff: --session is required");
574
916
 
@@ -603,6 +945,18 @@ async function cmdHandoff(argv) {
603
945
  await followRemoteWorker(state);
604
946
  }
605
947
 
948
+ async function cmdDispatch(argv) {
949
+ const { flags } = parseFlags(argv, lifecycleSpec());
950
+ if (flags.mode === LEGACY_MODE) return cmdLegacyDispatch(argv);
951
+ return createBrokerRun(argv, "dispatch");
952
+ }
953
+
954
+ async function cmdHandoff(argv) {
955
+ const { flags } = parseFlags(argv, lifecycleSpec());
956
+ if (flags.mode === LEGACY_MODE) return cmdLegacyHandoff(argv);
957
+ return createBrokerRun(argv, "handoff");
958
+ }
959
+
606
960
  async function cmdFollow(argv) {
607
961
  const spec = { help: { type: "boolean" } };
608
962
  const { flags, positionals } = parseFlags(argv, spec);
@@ -631,13 +985,22 @@ async function cmdDown(argv) {
631
985
  : [readRunState(resolveRunId(positionals[0], { includeStopped: true }))];
632
986
  const config = loadConfig();
633
987
  for (let state of states) {
988
+ if (state.backend === "broker") {
989
+ if (!config?.pat) fail("impel remote down: not authenticated; run `impel setup` first");
990
+ const remote = await brokerClient(config, state.brokerUrl).stopRun(state.runId, { reason: "user_stop", abandon: true });
991
+ state = persistBrokerRun(remote, state);
992
+ removeSshAlias(state.runId);
993
+ if (terminalBrokerState(state.status)) removeRunSecrets(state.runId);
994
+ console.log(`Remote run ${state.runId} is ${state.status}; broker cleanup and credential revocation are authoritative.`);
995
+ continue;
996
+ }
634
997
  const cleaned = await stopAndCleanRun(config, state);
635
998
  state = cleaned.state;
636
999
  printCleanupResult(state, cleaned.revokeError);
637
1000
  }
638
1001
  }
639
1002
 
640
- function cmdProxy(argv) {
1003
+ async function cmdProxy(argv) {
641
1004
  const spec = {
642
1005
  help: { type: "boolean" },
643
1006
  port: { type: "string" },
@@ -650,13 +1013,52 @@ function cmdProxy(argv) {
650
1013
  const port = integerFlag(flags.port, "port", 22, 1, 65535);
651
1014
  if (port !== 22) fail("impel remote proxy: the runner only exposes SSH port 22");
652
1015
  const state = readRunState(positionals[0]);
653
- if (state.status === "stopped" || state.status === "failed") {
1016
+ if (state.backend === "broker" ? terminalBrokerState(state.status) : state.status === "stopped" || state.status === "failed") {
654
1017
  fail(`impel remote proxy: run ${state.runId} is ${state.status}`);
655
1018
  }
656
- const exitCode = startSshProxy(awsContext(state), state, port);
1019
+ let exitCode;
1020
+ if (state.backend === "broker") {
1021
+ const config = requiredConfig();
1022
+ const client = brokerClient(config, state.brokerUrl);
1023
+ const connection = await client.createConnection(state.runId);
1024
+ try {
1025
+ exitCode = startBrokerSshProxy(connection);
1026
+ } finally {
1027
+ try { await client.closeConnection(state.runId, connection.leaseId); }
1028
+ catch { exitCode ||= 1; }
1029
+ }
1030
+ } else {
1031
+ exitCode = startSshProxy(awsContext(state), state, port);
1032
+ }
657
1033
  if (exitCode !== 0) process.exitCode = exitCode;
658
1034
  }
659
1035
 
1036
+ async function cmdHandback(argv) {
1037
+ const spec = { abandon: { type: "boolean" }, help: { type: "boolean" }, yes: { type: "boolean" } };
1038
+ const { flags, positionals } = parseFlags(argv, spec);
1039
+ rejectFlags(flags, new Set(["abandon", "help", "yes"]), "handback");
1040
+ if (flags.help) { console.log(HELP); return; }
1041
+ if (positionals.length > 1) fail("impel remote handback: expected at most one run id");
1042
+ if (flags.yes !== true) {
1043
+ fail("impel remote handback: first select “This computer” in Desktop and wait for the remote child to exit, then confirm with --yes");
1044
+ }
1045
+ let state = readRunState(resolveRunId(positionals[0], { includeStopped: true }));
1046
+ if (state.backend !== "broker" || state.mode !== "native") fail("impel remote handback: run is not a broker-backed native handoff");
1047
+ const config = requiredConfig();
1048
+ const client = brokerClient(config, state.brokerUrl);
1049
+ const remote = flags.abandon
1050
+ ? await client.stopRun(state.runId, { reason: "user_abandon", abandon: true })
1051
+ : await client.handbackRun(state.runId);
1052
+ state = persistBrokerRun(remote, state);
1053
+ if (terminalBrokerState(state.status)) {
1054
+ removeSshAlias(state.runId);
1055
+ removeRunSecrets(state.runId);
1056
+ console.log(`Native run ${state.runId} is ${state.status}; its remote writer lease and credential are closed.`);
1057
+ } else {
1058
+ console.log(`Handback confirmation recorded for ${state.runId}; state is ${state.status}. The broker will stop only after the remote child/SSH signal also closes.`);
1059
+ }
1060
+ }
1061
+
660
1062
  export async function cmdRemote(argv) {
661
1063
  const [action, ...rest] = argv;
662
1064
  try {
@@ -669,12 +1071,14 @@ export async function cmdRemote(argv) {
669
1071
  return;
670
1072
  case "up": return await cmdUp(rest);
671
1073
  case "status": return await cmdStatus(rest);
1074
+ case "viewer": return await cmdViewer(rest);
672
1075
  case "attach": return await cmdAttach(rest);
673
1076
  case "dispatch": return await cmdDispatch(rest);
674
1077
  case "handoff": return await cmdHandoff(rest);
675
1078
  case "follow": return await cmdFollow(rest);
676
1079
  case "down": return await cmdDown(rest);
677
- case "proxy": return cmdProxy(rest);
1080
+ case "handback": return await cmdHandback(rest);
1081
+ case "proxy": return await cmdProxy(rest);
678
1082
  default: fail(`impel remote: unknown subcommand ${JSON.stringify(action)}`);
679
1083
  }
680
1084
  } catch (error) {