runpane 2.4.185 → 2.4.186

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/dist/handoff.js CHANGED
@@ -15,6 +15,7 @@ const node_path_1 = __importDefault(require("node:path"));
15
15
  const boundaryDecoder_1 = require("./boundaryDecoder");
16
16
  const daemonClient_1 = require("./daemonClient");
17
17
  const localControl_1 = require("./localControl");
18
+ const nativeSession_1 = require("./nativeSession");
18
19
  const errors_1 = require("./errors");
19
20
  const contract_1 = require("./generated/contract");
20
21
  const workspace_1 = require("./workspace");
@@ -80,8 +81,6 @@ function parseDestination(text, machines, overrides = {}) {
80
81
  const agent = overrides.agent ?? agents[0];
81
82
  if (!agent)
82
83
  throw new Error(`Name the agent: claude, codex, or cursor. For example: runpane handoff "claude opus on parsas-macbook-pro" --note-file ~/handoff.md`);
83
- if (agent === 'opencode')
84
- throw new Error('OpenCode is supported in terminal panes, but handoff to OpenCode is not supported yet. Choose claude, codex, or cursor; nothing was committed or sent.');
85
84
  let machine = null;
86
85
  if (overrides.machine) {
87
86
  machine = machineName((0, workspace_1.resolveMachine)(overrides.machine, machines));
@@ -103,6 +102,8 @@ function parseDestination(text, machines, overrides = {}) {
103
102
  throw new Error(`Invalid effort. Use ${[...EFFORTS].join(', ')}.`);
104
103
  if (agent === 'cursor' && effort !== undefined)
105
104
  throw new Error('Cursor effort is not supported. Omit --effort and effort text; nothing was committed or sent.');
105
+ if (agent === 'opencode' && effort !== undefined)
106
+ throw new Error('OpenCode effort is not supported. Omit --effort and effort text; nothing was committed or sent.');
106
107
  if (model)
107
108
  destination.model = model;
108
109
  if (effort)
@@ -170,7 +171,8 @@ function frontMatter(origin) {
170
171
  }
171
172
  /** Replaces the note's front matter with the CLI's own, so the origin and git state are never stale. */
172
173
  function stampNote(text, origin, receiver) {
173
- const body = text.replace(/^---\n[\s\S]*?\n---\n/, '').replace(/^\n+/, '');
174
+ // A note saved by PowerShell starts with a BOM and uses CRLF; its front matter is replaced all the same.
175
+ const body = text.replace(/^/, '').replace(/\r\n/g, '\n').replace(/^---\n[\s\S]*?\n---\n/, '').replace(/^\n+/, '');
174
176
  return `${frontMatter(origin)}\n${body.trimEnd()}\n\n${receiver}\n`;
175
177
  }
176
178
  function git(args, cwd) {
@@ -273,7 +275,8 @@ async function remoteDestination(machine) {
273
275
  repos: () => (0, daemonClient_1.invokeRemoteDaemon)(target, 'runpane:repos:list', [], localControl_1.repoListResultSchema),
274
276
  create: (request) => (0, daemonClient_1.invokeRemoteDaemon)(target, 'runpane:panes:create', [request], localControl_1.paneCreateResultSchema, REPORT_TIMEOUT_MS),
275
277
  run: (argv, timeoutMs = 120_000) => (0, daemonClient_1.invokeRemoteDaemon)(target, 'runpane:machine:exec', [{ command: argv.map((arg) => quote(arg, shell)).join(' '), timeoutMs }], execResultSchema, timeoutMs + 30_000),
276
- write: async (file, content) => (await (0, daemonClient_1.invokeRemoteDaemon)(target, 'runpane:machine:write', [{ path: file, content, encoding: 'utf8' }], writeResultSchema)).path,
278
+ write: async (file, content, encoding = 'utf8') => (await (0, daemonClient_1.invokeRemoteDaemon)(target, 'runpane:machine:write', [{ path: file, content, encoding }], writeResultSchema, REPORT_TIMEOUT_MS)).path,
279
+ invoke: (channel, args, schema) => (0, daemonClient_1.invokeRemoteDaemon)(target, channel, args, schema, REPORT_TIMEOUT_MS),
277
280
  };
278
281
  }
279
282
  function localDestination(name, paneDir) {
@@ -292,18 +295,20 @@ function localDestination(name, paneDir) {
292
295
  child.on('error', (error) => resolve({ exitCode: 1, stdout, stderr: stderr + error.message }));
293
296
  child.on('close', (exitCode) => resolve({ exitCode, stdout, stderr }));
294
297
  }),
295
- write: async (file, content) => {
298
+ write: async (file, content, encoding = 'utf8') => {
296
299
  const resolved = file.replace(/^~(?=[\\/])/, process.env.HOME ?? process.env.USERPROFILE ?? '~');
297
300
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(resolved), { recursive: true });
298
- node_fs_1.default.writeFileSync(resolved, content, { mode: 0o600 });
301
+ node_fs_1.default.writeFileSync(resolved, Buffer.from(content, encoding), { mode: 0o600 });
299
302
  return resolved;
300
303
  },
304
+ invoke: (channel, args, schema) => (0, daemonClient_1.invokeDaemon)(channel, args, schema, { paneDir, timeoutMs: REPORT_TIMEOUT_MS }),
301
305
  };
302
306
  }
303
307
  function quote(value, shell) {
304
308
  if (/^[\w./:@=+-]+$/.test(value))
305
309
  return value;
306
- return /(?:powershell|pwsh)(?:\.exe)?$/i.test(shell) ? `'${value.replace(/'/g, "''")}'` : `'${value.replace(/'/g, `'\\''`)}'`;
310
+ // PowerShell also ends a single-quoted string at a typographic single quote.
311
+ return /(?:powershell|pwsh)(?:\.exe)?$/i.test(shell) ? `'${value.replace(/['\u2018\u2019\u201A\u201B]/g, '$&$&')}'` : `'${value.replace(/'/g, `'\\''`)}'`;
307
312
  }
308
313
  async function mustRun(destination, command, what, timeoutMs) {
309
314
  const result = await destination.run(command, timeoutMs);
@@ -368,14 +373,399 @@ function receiverInstructions(origin, state, remote, reporter) {
368
373
  `5. When you finish or get blocked, report back to the sender: ${reportTarget}`,
369
374
  ].join('\n');
370
375
  }
376
+ /**
377
+ * Where an agent records, for every command it runs, which of its own conversations ran it. This is
378
+ * the sender's own live record, so it is trusted over the id Pane captured for the tab: Pane learns
379
+ * a Codex id only when Codex exits, and a command run through Codex's shared background server
380
+ * can inherit another tab's PANE_PANEL_ID.
381
+ */
382
+ const LIVE_SESSION_ENV = [['claude', 'CLAUDE_CODE_SESSION_ID'], ['codex', 'CODEX_THREAD_ID']];
383
+ /** Which session is handing off: the agent's live record when it has one, otherwise Pane's record for this tab. */
384
+ function senderSession(recorded) {
385
+ const live = LIVE_SESSION_ENV.flatMap(([agent, key]) => {
386
+ const id = process.env[key]?.trim();
387
+ return id ? [{ agent, id }] : [];
388
+ });
389
+ // One agent started inside another sees both records; the tab's agent decides which one is speaking.
390
+ const chosen = live.length > 1 ? live.find(entry => entry.agent === recorded?.agentType) : live[0];
391
+ if (live.length > 1 && !chosen) {
392
+ return { fresh: `Both ${live.map(entry => entry.agent).join(' and ')} report a live session in this terminal (one agent was started by the other), so runpane can't tell which one is handing off. Run the handoff from that agent's own tab.` };
393
+ }
394
+ if (chosen) {
395
+ const sender = { ...chosen, live: true };
396
+ if (recorded?.agentType === chosen.agent && recorded.resumeId && recorded.resumeId !== chosen.id) {
397
+ sender.note = `Pane had recorded ${recorded.resumeId} for this tab; ${chosen.agent} itself reports ${chosen.id}, which is the one carried.`;
398
+ }
399
+ return { sender };
400
+ }
401
+ const agent = recorded?.agentType;
402
+ if (!(0, nativeSession_1.isNativeAgent)(agent) || !recorded?.resumeId)
403
+ return { fresh: 'This tab runs no Claude, Codex, Cursor or OpenCode session that Pane has an id for.' };
404
+ return { sender: { agent, id: recorded.resumeId } };
405
+ }
406
+ /**
407
+ * The sender's session when the receiver can continue it: the agent's own live record, or the id
408
+ * Pane recorded for this tab; the receiver is the same agent; and the session's files are here.
409
+ */
410
+ async function planCarry(parsed, destination) {
411
+ if (parsed.handoffNoteOnly)
412
+ return { fresh: 'You passed --note-only.' };
413
+ const paneId = process.env.PANE_SESSION_ID;
414
+ const panelId = process.env.PANE_PANEL_ID;
415
+ let recorded;
416
+ let panels = [];
417
+ if (paneId && panelId) {
418
+ try {
419
+ const listed = await (0, daemonClient_1.invokeDaemon)('runpane:panels:list', [{ paneId }], localControl_1.panelListResultSchema, { paneDir: parsed.paneDir });
420
+ panels = listed.panels;
421
+ recorded = listed.panels.find(panel => panel.id === panelId || panel.panelId === panelId);
422
+ }
423
+ catch (error) {
424
+ if (!LIVE_SESSION_ENV.some(([, key]) => process.env[key])) {
425
+ return { fresh: `Pane could not say which agent session this tab runs (${error instanceof Error ? error.message : String(error)}).` };
426
+ }
427
+ }
428
+ }
429
+ else if (!LIVE_SESSION_ENV.some(([, key]) => process.env[key])) {
430
+ return { fresh: 'This terminal is not a Pane tab, so there is no agent session to carry.' };
431
+ }
432
+ const identified = senderSession(recorded);
433
+ if ('fresh' in identified)
434
+ return identified;
435
+ const { sender } = identified;
436
+ if (sender.agent !== destination.agent) {
437
+ return { fresh: `Your session is ${sender.agent} and the receiver is ${destination.agent}; a session can't move between agents.` };
438
+ }
439
+ const session = await (0, nativeSession_1.locateNativeSession)(sender.agent, sender.id, process.cwd());
440
+ if (!session)
441
+ return { fresh: `${sender.agent} session ${sender.id} has no files in this machine's ${sender.agent} store.` };
442
+ const carry = { session };
443
+ if (sender.note)
444
+ carry.note = sender.note;
445
+ if (sender.live) {
446
+ carry.live = true;
447
+ // When PANE_PANEL_ID's tab records another session, report back to the tab that records this one.
448
+ const owner = panels.find(panel => panel.resumeId === sender.id)?.id;
449
+ if (recorded?.resumeId && recorded.resumeId !== sender.id) {
450
+ carry.senderPanel = owner ?? null;
451
+ }
452
+ else if (!recorded?.resumeId && !owner && panels.filter(panel => panel.agentType === sender.agent && !panel.resumeId).length > 1) {
453
+ // No tab records this session yet (Pane learns a Codex id when Codex exits), and several of
454
+ // this agent's tabs could be the sender: an inherited PANE_PANEL_ID can't be trusted.
455
+ carry.senderPanel = null;
456
+ }
457
+ }
458
+ const snippet = (0, nativeSession_1.lastMessageSnippet)(session);
459
+ if (snippet)
460
+ carry.snippet = snippet;
461
+ return { carry };
462
+ }
463
+ function describeCarry(carry, fork) {
464
+ return {
465
+ carried: true,
466
+ agent: carry.session.agent,
467
+ sessionId: carry.session.id,
468
+ fork,
469
+ files: carry.session.files.length,
470
+ bytes: carry.session.files.reduce((total, file) => total + file.bytes, 0),
471
+ reason: (fork
472
+ ? `The receiver resumes a fork of your ${carry.session.agent} session ${carry.session.id}: two agents never resume one session on one machine.`
473
+ : `The receiver resumes your own ${carry.session.agent} session ${carry.session.id}, copied to the destination.`)
474
+ + (carry.note ? ` ${carry.note}` : ''),
475
+ };
476
+ }
477
+ /** Machine writes go through one request each; larger files go in parts joined on the destination. */
478
+ const WRITE_CHUNK_BYTES = 8 * 1024 * 1024;
479
+ /**
480
+ * Where the session's main file already is in the destination's store, as a path under home,
481
+ * so a session handed back and forth is replaced in place and its id never has two copies.
482
+ * Two copies there is an error: runpane can't tell which one the agent resumes.
483
+ */
484
+ async function existingCopy(destination, session) {
485
+ const name = node_path_1.default.posix.basename(session.files[0].home);
486
+ const store = session.files[0].home.split('/').slice(0, 2).join('/');
487
+ const result = destination.os === 'Windows'
488
+ ? await destination.run(['powershell', '-NoProfile', '-Command',
489
+ `Get-ChildItem -Recurse -File -Path '${store}' -Filter '${name}' -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }`], 60_000)
490
+ : await destination.run(['find', store, '-name', name, '-type', 'f'], 60_000);
491
+ const found = result.stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
492
+ if (found.length > 1) {
493
+ throw new Error(`${destination.name} has ${found.length} copies of ${session.agent} session ${session.id} (${found.join(', ')}), so runpane can't tell which one ${session.agent} resumes there. Nothing was copied. Remove the stale copy on ${destination.name}, then run the handoff again`);
494
+ }
495
+ if (found.length === 0)
496
+ return undefined;
497
+ const normalized = found[0].replace(/\\/g, '/').replace(/^\.\//, '');
498
+ if (normalized.startsWith(`${store}/`))
499
+ return normalized;
500
+ const index = normalized.indexOf(`/${store}/`);
501
+ return index >= 0 ? normalized.slice(index + 1) : undefined;
502
+ }
503
+ function quoted(value, windows) {
504
+ // PowerShell also ends a single-quoted string at a typographic single quote.
505
+ return windows ? `'${value.replace(/['‘’‚‛]/g, '$&$&')}'` : `'${value.replace(/'/g, `'\\''`)}'`;
506
+ }
507
+ /** Size and sha256 of each file on the destination (paths under home), by index; missing files are left out. */
508
+ async function destinationDigests(destination, files) {
509
+ const windows = destination.os === 'Windows';
510
+ const list = files.map(file => quoted(file, windows));
511
+ // No double quotes in the PowerShell: Windows PowerShell mangles them in a nested -Command.
512
+ const script = windows
513
+ // .NET's SHA256, not Get-FileHash: a Windows PowerShell started from pwsh can fail to load the module that defines it.
514
+ ? `$i=0; $sha=[Security.Cryptography.SHA256]::Create(); foreach ($p in @(${list.join(',')})) { if (Test-Path -LiteralPath $p -PathType Leaf) { $b=[IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $p).ProviderPath); ($i, $b.Length, ([BitConverter]::ToString($sha.ComputeHash($b)) -replace '-','').ToLower()) -join ' ' }; $i++ }`
515
+ : `i=0; for p in ${list.join(' ')}; do if [ -f "$p" ]; then printf '%s %s %s\\n' "$i" "$(wc -c < "$p" | tr -d ' ')" "$( (sha256sum "$p" 2>/dev/null || shasum -a 256 "$p") | cut -d ' ' -f 1)"; fi; i=$((i+1)); done`;
516
+ const output = await mustRun(destination, windows ? ['powershell', '-NoProfile', '-Command', script] : ['sh', '-c', script], 'Reading the session files there', 300_000);
517
+ const digests = new Map();
518
+ for (const line of output.split(/\r?\n/)) {
519
+ const match = /^(\d+) (\d+) ([0-9a-f]{64})$/.exec(line.trim());
520
+ if (match)
521
+ digests.set(Number(match[1]), { bytes: Number(match[2]), sha256: match[3] });
522
+ }
523
+ return digests;
524
+ }
525
+ /**
526
+ * Copies the session's files into the destination agent's store; returns the main file's path
527
+ * there. A copy already there (from an earlier handoff) is replaced only when this machine's copy
528
+ * holds everything it does and no agent there is running it: history continued on the destination
529
+ * is never overwritten. Every file is written beside its target and checked before any is swapped
530
+ * in, and a failed swap puts the previous copy back. An OpenCode session goes in as its export,
531
+ * imported into OpenCode's database from inside `repoPath`, so OpenCode files it under that repository.
532
+ */
533
+ async function placeSession(destination, session, repoPath) {
534
+ if (session.exported)
535
+ return importOpenCodeSession(destination, session, repoPath);
536
+ const windows = destination.os === 'Windows';
537
+ const existing = await existingCopy(destination, session);
538
+ const mainDir = node_path_1.default.posix.dirname(session.files[0].home);
539
+ const targetDir = existing ? node_path_1.default.posix.dirname(existing) : mainDir;
540
+ const tag = `handoff-${(0, node_crypto_1.randomUUID)().slice(0, 8)}`;
541
+ const placements = session.files.map(file => {
542
+ const target = targetDir + file.home.slice(mainDir.length);
543
+ return { bytes: node_fs_1.default.readFileSync(file.absolute), target, staged: `${target}.${tag}`, previous: `${target}.${tag}.previous` };
544
+ });
545
+ if (existing) {
546
+ const there = await destinationDigests(destination, placements.map(placement => placement.target));
547
+ const diverged = placements.filter((placement, index) => {
548
+ const digest = there.get(index);
549
+ return digest !== undefined && (digest.bytes > placement.bytes.length || (0, nativeSession_1.sha256)(placement.bytes.subarray(0, digest.bytes)) !== digest.sha256);
550
+ });
551
+ if (diverged.length) {
552
+ throw new Error(`${destination.name}'s copy of ${session.agent} session ${session.id} has history this machine's copy doesn't (${diverged.map(placement => placement.target).join(', ')}): it was continued there after an earlier handoff. Nothing was copied, so that history is kept. Continue it on ${destination.name}, or hand it off from there back to here`);
553
+ }
554
+ await refuseRunningReceivers(destination, session, 'copied');
555
+ }
556
+ const written = [];
557
+ let mainPath = '';
558
+ try {
559
+ for (const placement of placements) {
560
+ let staged;
561
+ if (placement.bytes.length <= WRITE_CHUNK_BYTES) {
562
+ staged = await destination.write(`~/${placement.staged}`, placement.bytes.toString('base64'), 'base64');
563
+ written.push(staged);
564
+ }
565
+ else {
566
+ const parts = [];
567
+ for (let offset = 0, index = 0; offset < placement.bytes.length; offset += WRITE_CHUNK_BYTES, index += 1) {
568
+ const part = await destination.write(`~/${placement.staged}.part${index}`, placement.bytes.subarray(offset, offset + WRITE_CHUNK_BYTES).toString('base64'), 'base64');
569
+ parts.push(part);
570
+ written.push(part);
571
+ }
572
+ staged = parts[0].replace(/\.part0$/, '');
573
+ written.push(staged);
574
+ const list = parts.map(part => quoted(part, windows));
575
+ const join = windows
576
+ ? ['powershell', '-NoProfile', '-Command', `$out = [IO.File]::Create(${quoted(staged, true)}); foreach ($p in @(${list.join(',')})) { $b = [IO.File]::ReadAllBytes($p); $out.Write($b, 0, $b.Length) }; $out.Close(); Remove-Item ${list.join(',')}`]
577
+ : ['sh', '-c', `cat ${list.join(' ')} > ${quoted(staged, false)} && rm ${list.join(' ')}`];
578
+ await mustRun(destination, join, `Joining ${node_path_1.default.posix.basename(placement.target)}`, 300_000);
579
+ }
580
+ if (placement === placements[0])
581
+ mainPath = staged.slice(0, -(tag.length + 1));
582
+ }
583
+ const staged = await destinationDigests(destination, placements.map(placement => placement.staged));
584
+ const damaged = placements.filter((placement, index) => staged.get(index)?.sha256 !== (0, nativeSession_1.sha256)(placement.bytes));
585
+ if (damaged.length)
586
+ throw new Error(`The copies of ${damaged.map(placement => node_path_1.default.posix.basename(placement.target)).join(', ')} written to ${destination.name} don't match this machine's`);
587
+ }
588
+ catch (error) {
589
+ const list = written.map(file => quoted(file, windows));
590
+ if (list.length) {
591
+ await destination.run(windows
592
+ ? ['powershell', '-NoProfile', '-Command', `Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath ${list.join(',')}`]
593
+ : ['sh', '-c', `rm -f ${list.join(' ')}`]).catch(() => undefined);
594
+ }
595
+ throw new Error(`${error instanceof Error ? error.message : String(error)}. Nothing in ${destination.name}'s ${session.agent} store was changed`, { cause: error });
596
+ }
597
+ await mustRun(destination, swapScript(placements, windows), 'Putting the session files in place (any previous copy was put back)', 300_000);
598
+ return mainPath;
599
+ }
600
+ /**
601
+ * Moves each staged file over its target in one script, keeping the target's previous copy until
602
+ * all are in place. If any move fails, every target gets its previous copy back (or is removed if
603
+ * it had none) and the staged files are deleted, so the store holds the old session or the new one.
604
+ */
605
+ function swapScript(placements, windows) {
606
+ const q = (value) => quoted(value, windows);
607
+ if (windows) {
608
+ const list = placements.map(placement => `$p += ,@(${q(placement.staged)}, ${q(placement.target)}, ${q(placement.previous)})`).join('; ');
609
+ return ['powershell', '-NoProfile', '-Command', [
610
+ `$ErrorActionPreference = 'Stop'; $p = @(); ${list}`,
611
+ 'function Undo { foreach ($x in $p) { if (Test-Path -LiteralPath $x[2]) { Move-Item -Force -LiteralPath $x[2] -Destination $x[1] } elseif (-not (Test-Path -LiteralPath $x[0])) { Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $x[1] }; Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $x[0] } }',
612
+ 'try { foreach ($x in $p) { if (Test-Path -LiteralPath $x[1]) { Move-Item -LiteralPath $x[1] -Destination $x[2] }; Move-Item -LiteralPath $x[0] -Destination $x[1] } } catch { Undo; throw }',
613
+ 'foreach ($x in $p) { Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $x[2] }',
614
+ // -Command exits 1 when its last statement failed, even silently (no previous copy to remove).
615
+ 'exit 0',
616
+ ].join('; ')];
617
+ }
618
+ const undo = placements.map(({ staged, target, previous }) => `if [ -e ${q(previous)} ]; then mv -f ${q(previous)} ${q(target)}; elif [ ! -e ${q(staged)} ]; then rm -f ${q(target)}; fi; rm -f ${q(staged)}`).join('; ');
619
+ const moves = placements.map(({ staged, target, previous }) => `if [ -e ${q(target)} ]; then mv -f ${q(target)} ${q(previous)} || { undo; exit 1; }; fi; mv -f ${q(staged)} ${q(target)} || { undo; exit 1; }`);
620
+ return ['sh', '-c', [`undo() { ${undo}; }`, ...moves, `rm -f ${placements.map(placement => q(placement.previous)).join(' ')}`].join('\n')];
621
+ }
622
+ /** Panels on the destination running this session, as "Pane <id> tab <id>". */
623
+ async function runningReceivers(destination, session) {
624
+ // Panes list only Panes; a Session's agent tab lives in its hidden owner, so Sessions are listed too.
625
+ const { panes } = await destination.invoke('runpane:panes:list', [{}], localControl_1.paneListResultSchema);
626
+ const { sessions } = await destination.invoke('runpane:sessions:list', [], localControl_1.runpaneSessionListResultSchema);
627
+ const owners = [
628
+ ...panes.map(pane => ({ id: pane.id, label: `Pane ${pane.id}` })),
629
+ ...sessions.map(record => ({ id: record.internalSessionId, label: `Session "${record.name}"` })),
630
+ ];
631
+ const running = [];
632
+ for (const owner of owners) {
633
+ const { panels } = await destination.invoke('runpane:panels:list', [{ paneId: owner.id }], localControl_1.panelListResultSchema);
634
+ for (const panel of panels) {
635
+ if (panel.running && panel.resumeId === session.id && panel.agentType === session.agent)
636
+ running.push(`${owner.label} tab ${panel.id}`);
637
+ }
638
+ }
639
+ return running;
640
+ }
641
+ /** Stops the copy when an agent on the destination is running this session. */
642
+ async function refuseRunningReceivers(destination, session, what) {
643
+ const running = await runningReceivers(destination, session);
644
+ if (running.length) {
645
+ throw new Error(`${session.agent} session ${session.id} is open on ${destination.name} (${running.join(', ')}), and replacing it under a running agent would mix two conversations. Nothing was ${what}. Close it there first, then run the handoff again`);
646
+ }
647
+ }
648
+ /** How `opencode export` says the session isn't in its database. */
649
+ const OPENCODE_NO_SESSION = /not\s*found|NotFoundError|no such session|does not exist/i;
650
+ /**
651
+ * An OpenCode session goes in with `opencode import`. A session already there is replaced only
652
+ * when this export holds every message it does and no agent there is running it; an export that
653
+ * fails for any reason but a missing session stops the import.
654
+ */
655
+ async function importOpenCodeSession(destination, session, repoPath) {
656
+ const windows = destination.os === 'Windows';
657
+ const inRepo = (command) => windows
658
+ ? ['powershell', '-NoProfile', '-Command', `Set-Location -LiteralPath ${quoted(repoPath, true)}; ${command}`]
659
+ : ['sh', '-c', `cd ${quoted(repoPath, false)} && ${command}`];
660
+ const exported = node_fs_1.default.readFileSync(session.files[0].absolute, 'utf8');
661
+ const there = await destination.run(inRepo(`opencode export ${quoted(session.id, windows)}`), 120_000);
662
+ if (there.exitCode !== 0) {
663
+ // Only "no such session" means there is no history to keep; anything else (a locked database,
664
+ // a broken install) means runpane can't tell, so it stops instead of importing over it.
665
+ const detail = (there.stderr || there.stdout).trim().split('\n').slice(-3).join(' ');
666
+ if (!OPENCODE_NO_SESSION.test(detail)) {
667
+ throw new Error(`Couldn't read ${destination.name}'s OpenCode session ${session.id} to check for history this machine's copy lacks (${detail || `exit ${there.exitCode}`}). Nothing was imported. Fix that there, then run the handoff again`);
668
+ }
669
+ }
670
+ else {
671
+ await refuseRunningReceivers(destination, session, 'imported');
672
+ }
673
+ const missing = there.exitCode === 0 ? (0, nativeSession_1.openCodeMessagesMissing)(exported, there.stdout) : 0;
674
+ if (missing) {
675
+ throw new Error(`${destination.name}'s copy of OpenCode session ${session.id} has ${missing} message${missing === 1 ? '' : 's'} this machine's copy doesn't: it was continued there after an earlier handoff. Nothing was imported, so that history is kept. Continue it on ${destination.name}, or hand it off from there back to here`);
676
+ }
677
+ const written = await destination.write(`~/${session.files[0].home}`, Buffer.from(exported).toString('base64'), 'base64');
678
+ await mustRun(destination, inRepo(`opencode import ${quoted(written, windows)}`), `opencode import of session ${session.id}`, 300_000);
679
+ return `OpenCode's store, from ${written}`;
680
+ }
681
+ /** How long a receiver resuming a carried session has to reach its input box, unless --ready-timeout-ms says otherwise. */
682
+ const CARRIED_READY_TIMEOUT_MS = 120_000;
683
+ const PROBE = 'What were you last working on? Answer in one or two sentences.';
684
+ /** Checks 1 to 4 of the continuity check on the receiver, then says what was found. */
685
+ async function checkReceiver(options) {
686
+ const { destination, carry, resumedId, paneId, panelId, ready, blocked, original } = options;
687
+ const listed = await destination.invoke('runpane:panels:list', [{ paneId }], localControl_1.panelListResultSchema);
688
+ const panel = listed.panels.find(candidate => candidate.id === panelId || candidate.panelId === panelId);
689
+ const restartResumes = panel?.resumeId === resumedId;
690
+ const resumedById = restartResumes && (panel?.resumeSource ?? 'agent') === 'agent';
691
+ let historyLoaded = null;
692
+ if (ready && carry.snippet) {
693
+ const screen = await destination.invoke('runpane:panels:screen', [{ panelId, limit: 400 }], localControl_1.panelScreenResultSchema);
694
+ historyLoaded = (0, nativeSession_1.screenShows)(screen.text, carry.snippet);
695
+ }
696
+ const checks = { resumedById, restartResumes, historyLoaded };
697
+ const { changed, appended } = original ? await (0, nativeSession_1.originalChanges)(original, { appendOnly: carry.live }) : { changed: [], appended: [] };
698
+ if (original)
699
+ checks.originalUntouched = changed.length === 0;
700
+ if (appended.length)
701
+ checks.originalAppended = true;
702
+ if (!resumedById || !restartResumes) {
703
+ return { checks, result: 'not-continued', why: `Pane recorded ${panel?.resumeId ?? 'no id'} for the receiver, not ${resumedId}, so it is not resuming that session.` };
704
+ }
705
+ if (checks.originalUntouched === false) {
706
+ return { checks, result: 'not-continued', why: `Your session changed while it was forked (${changed.join(', ')}).` };
707
+ }
708
+ const then = `then check it shows your last work, and ask it "${PROBE}" if that is unclear.`;
709
+ if (!ready && blocked) {
710
+ // The agent resumed the session but stopped on its own notice or dialog; history can be checked once it's answered.
711
+ const prompt = blocked.message.split('\n').map(line => line.trim()).filter(Boolean).slice(0, 3).join(' / ');
712
+ const command = blocked.suggestedCommand ? options.route(blocked.suggestedCommand) : undefined;
713
+ const answers = command !== undefined && /\bpanels (?:input|submit)\b/.test(command);
714
+ return {
715
+ checks,
716
+ result: 'inconclusive',
717
+ why: `The receiver resumed the session but is waiting on its agent's own prompt: ${prompt}`,
718
+ next: answers
719
+ ? `Answer it with ${command} (or by hand), ${then}`
720
+ : `Look at the prompt with ${command ?? options.inspect}, answer it in the receiver, ${then}`,
721
+ };
722
+ }
723
+ if (!ready) {
724
+ // Pane recorded the resumed id (checks 1 and 2), so the agent is resuming it; it is just slow to start.
725
+ return {
726
+ checks,
727
+ result: 'inconclusive',
728
+ why: 'The receiver is resuming the session but had not reached its input box when runpane stopped waiting, so its history was not checked.',
729
+ next: `Look at it with ${options.inspect} until it is ready, ${then}`,
730
+ };
731
+ }
732
+ if (historyLoaded === true)
733
+ return { checks, result: 'continued', why: `The receiver shows the session's last message ("${carry.snippet}").` };
734
+ return {
735
+ checks,
736
+ result: 'inconclusive',
737
+ why: carry.snippet
738
+ ? `The receiver is ready but its screen does not show the session's last message ("${carry.snippet}"); it may be scrolled or compacted.`
739
+ : 'The receiver is ready, but this session\'s store is not text runpane can read, so its last message could not be compared.',
740
+ };
741
+ }
742
+ /** What `runpane:doctor` says about the daemon's features; older Panes have none. */
743
+ const doctorFeaturesSchema = boundaryDecoder_1.boundary.object({
744
+ app: boundaryDecoder_1.boundary.object({ version: boundaryDecoder_1.boundary.string }),
745
+ daemon: boundaryDecoder_1.boundary.object({ features: boundaryDecoder_1.boundary.optional(boundaryDecoder_1.boundary.array(boundaryDecoder_1.boundary.string)) }),
746
+ });
747
+ /**
748
+ * The daemon feature (Pane's RUNPANE_DAEMON_FEATURES) that says panes, panels and sessions create
749
+ * honour `resume`. An older Pane drops the field it doesn't know and starts a fresh conversation,
750
+ * so a carried session must not be sent to it.
751
+ */
752
+ const RESUME_ON_CREATE = 'resume-on-create';
753
+ async function assertResumesOnCreate(invoke, where) {
754
+ const doctor = await invoke('runpane:doctor', [], doctorFeaturesSchema);
755
+ if (doctor.daemon.features?.includes(RESUME_ON_CREATE))
756
+ return;
757
+ throw new errors_1.RunpaneError('ERR_HANDOFF_RESUME_UNSUPPORTED', `Pane ${doctor.app.version} on ${where} can't resume a carried session: it would start a fresh conversation and report it as yours. Nothing was committed, copied or created.`, `Update Pane on ${where} (runpane install client), then run the handoff again. Or ask the person before handing off with only the note: add --note-only.`);
758
+ }
371
759
  async function runHandoff(parsed) {
372
760
  const cwd = process.cwd();
373
761
  const tailnet = await (0, workspace_1.readTailnet)();
374
762
  const self = tailnet.ok ? tailnet.self.name : (process.env.HOSTNAME ?? 'this-machine');
763
+ // Which tab the receiver reports back to; planCarry may correct PANE_PANEL_ID.
764
+ let senderPanel;
375
765
  const origin = (state) => ({
376
766
  originMachine: self,
377
767
  originPane: process.env.PANE_SESSION_ID,
378
- originPanel: process.env.PANE_PANEL_ID,
768
+ originPanel: senderPanel === undefined ? process.env.PANE_PANEL_ID : senderPanel ?? undefined,
379
769
  originSession: process.env.PANE_ORCHESTRATION_SESSION_ID,
380
770
  repository: state?.repository,
381
771
  branch: state?.branch,
@@ -397,6 +787,12 @@ async function runHandoff(parsed) {
397
787
  });
398
788
  if (destination.wsl)
399
789
  throw new Error('Handoff to a WSL receiver is not supported yet. Choose a native destination without wsl; nothing was committed or sent.');
790
+ // OpenCode receives a handoff only to continue an OpenCode session; a fresh OpenCode agent
791
+ // with only the note is not supported. Decide that before touching git or the destination.
792
+ const openCodePlan = destination.agent === 'opencode' ? await planCarry(parsed, destination) : undefined;
793
+ if (openCodePlan && 'fresh' in openCodePlan) {
794
+ throw new errors_1.RunpaneError('ERR_HANDOFF_OPENCODE_FRESH', `OpenCode receives a handoff only to continue your own OpenCode session, and this one can't be carried: ${openCodePlan.fresh} Nothing was committed or sent.`, 'Run handoff from the OpenCode tab whose session should continue, or choose claude, codex or cursor for a fresh agent with the note.');
795
+ }
400
796
  if (destination.machine && !tailnet.ok)
401
797
  throw new Error(`Handing off to another machine needs Tailscale: ${tailnet.reason}. ${tailnet.fix}`);
402
798
  const machineLabel = destination.machine ?? `${self} (this machine)`;
@@ -465,6 +861,27 @@ async function runHandoff(parsed) {
465
861
  if (!state.pushed)
466
862
  warnings.push(`${state.branch} at ${state.head.slice(0, 7)} is not on ${state.remote}. Push it, or pass --push.`);
467
863
  say(step('git', `${state.branch} @ ${state.head.slice(0, 7)}, ${state.pushed ? `pushed to ${state.remote}` : 'NOT pushed'}${state.dirty.length ? `, ${state.dirty.length} uncommitted` : ''}`, state.pushed && !state.dirty.length));
864
+ // A same-agent handoff carries the sender's own session; anything else is a fresh agent with the note.
865
+ const plan = openCodePlan ?? await planCarry(parsed, destination);
866
+ const carry = 'carry' in plan ? plan.carry : undefined;
867
+ if (carry?.senderPanel !== undefined) {
868
+ senderPanel = carry.senderPanel;
869
+ if (senderPanel === null)
870
+ warnings.push(`No tab in this Pane records ${carry.session.agent} session ${carry.session.id}, and this terminal's PANE_PANEL_ID may name another tab (it can be inherited from another tab's agent), so the receiver reports back on the branch instead of to a panel.`);
871
+ }
872
+ const continuity = carry ? describeCarry(carry, !target) : { carried: false, reason: 'fresh' in plan ? plan.fresh : '' };
873
+ const noteOnlyCommand = `runpane handoff ${quote(text || describeDestination(destination, machineLabel), senderShell)} --note-file ${quote(parsed.handoffNoteFile ?? '<note>', senderShell)} --note-only`;
874
+ // A second Cursor agent here would change the original chat (CURSOR_NO_FORK), so it is never
875
+ // started; the note route is the person's call, not a silent fallback.
876
+ const cursorHere = carry?.session.agent === 'cursor' && !target;
877
+ if (cursorHere) {
878
+ continuity.carried = false;
879
+ continuity.reason = `${nativeSession_1.CURSOR_NO_FORK}. Nothing was copied and no receiver was started.`;
880
+ continuity.next = `Ask the person before starting a fresh Cursor agent with the note instead: ${noteOnlyCommand}`;
881
+ }
882
+ say(carry && !cursorHere
883
+ ? step('session', continuity.reason)
884
+ : step('session', cursorHere ? continuity.reason : `A fresh ${destination.agent} with your note, not the same session. ${continuity.reason}`, false));
468
885
  const result = {
469
886
  ok: true,
470
887
  dryRun: parsed.dryRun,
@@ -473,17 +890,32 @@ async function runHandoff(parsed) {
473
890
  note: noteInfo,
474
891
  git: { repository: state.repository, branch: state.branch, head: state.head, remote: state.remote, pushed: state.pushed, dirty: state.dirty.length },
475
892
  warnings,
893
+ continuity,
476
894
  };
477
895
  if (parsed.dryRun) {
478
896
  for (const warning of warnings)
479
897
  say(` ! ${warning}`);
898
+ if (cursorHere)
899
+ say(`Next: ${continuity.next}`);
480
900
  say('Dry run: nothing was sent.');
481
901
  if (parsed.json)
482
902
  console.log(JSON.stringify(result, null, 2));
483
903
  return 0;
484
904
  }
905
+ if (cursorHere) {
906
+ continuity.result = 'not-continued';
907
+ result.ok = false;
908
+ say(`Next: ${continuity.next}`);
909
+ if (parsed.json)
910
+ console.log(JSON.stringify(result, null, 2));
911
+ return 1;
912
+ }
913
+ if (!carry)
914
+ continuity.result = 'fresh-agent';
485
915
  if (!state.pushed)
486
916
  throw new Error(`${warnings.join(' ')}\nThe receiver would start without your work, so nothing was sent.`);
917
+ if (carry)
918
+ await assertResumesOnCreate(remote.invoke, remote.name);
487
919
  for (const warning of warnings)
488
920
  say(` ! ${warning}`);
489
921
  await mustRun(remote, ['git', '-C', repo.path, 'fetch', repoRemote, state.branch], 'git fetch', 120_000);
@@ -493,9 +925,42 @@ async function runHandoff(parsed) {
493
925
  const noteOrigin = origin(state);
494
926
  const stamped = stampNote(noteText ?? '', noteOrigin, receiverInstructions(noteOrigin, state, repoRemote, 'runpane'));
495
927
  const notePath = await remote.write(`~/.pane/handoffs/${stamp}-${name}-${(0, node_crypto_1.randomUUID)()}.md`, stamped);
928
+ result.notePath = notePath;
496
929
  say(step('note sent', notePath));
930
+ // Same machine: the receiver resumes a fork, so two agents never share one session file.
931
+ // Another machine: the session is copied into that machine's store and resumed by its own id.
932
+ let original;
933
+ if (carry) {
934
+ try {
935
+ if (target) {
936
+ const placed = await placeSession(remote, carry.session, repo.path);
937
+ continuity.resumedId = carry.session.id;
938
+ say(step('copied', `${continuity.files} file${continuity.files === 1 ? '' : 's'} of ${carry.session.agent} session ${carry.session.id} to ${placed}`));
939
+ }
940
+ else {
941
+ original = await (0, nativeSession_1.snapshotOriginal)(carry.session, state.root);
942
+ continuity.resumedId = await (0, nativeSession_1.forkNativeSession)(carry.session, state.root);
943
+ say(step('forked', `${carry.session.agent} session ${carry.session.id} as ${continuity.resumedId}`));
944
+ }
945
+ }
946
+ catch (error) {
947
+ continuity.result = 'not-continued';
948
+ continuity.reason = `Could not ${target ? 'copy' : 'fork'} your ${carry.session.agent} session ${carry.session.id}: ${error instanceof Error ? error.message : String(error)}. No receiver was started.`;
949
+ continuity.next = `Fix that and run the handoff again, or ask the person before starting a fresh agent with the note instead: ${noteOnlyCommand}`;
950
+ result.ok = false;
951
+ say(step('session', continuity.reason, false));
952
+ say(`Next: ${continuity.next}`);
953
+ if (parsed.json)
954
+ console.log(JSON.stringify(result, null, 2));
955
+ return 1;
956
+ }
957
+ }
958
+ const resumedId = continuity.resumedId;
497
959
  const toolCommand = agentCommand(destination, remote.shell);
498
- const prompt = `Read the handoff note at ${notePath} and continue the work it describes. Start with its "Receiver instructions" section.`;
960
+ // Resuming a long session is slower than starting fresh (Codex on another machine took over 30 s in QA).
961
+ const readyTimeoutMs = parsed.readyTimeoutMs ?? (resumedId ? CARRIED_READY_TIMEOUT_MS : undefined);
962
+ // A resumed session already knows its work; it gets the note with the "moved" message after the check.
963
+ const prompt = resumedId ? undefined : `Read the handoff note at ${notePath} and continue the work it describes. Start with its "Receiver instructions" section.`;
499
964
  // 1 feature = 1 worktree = 1 branch = 1 Pane: on this machine, from inside the
500
965
  // Pane that owns this checkout, the receiver opens as a new tab in that Pane.
501
966
  // Otherwise (another machine, or a checkout the sender's Pane does not own) it
@@ -516,15 +981,17 @@ async function runHandoff(parsed) {
516
981
  let receiver;
517
982
  if (senderPane) {
518
983
  const panelRequest = await (0, localControl_1.buildPanelCreateRequest)({
519
- ...parsed, paneId: senderPane, agent: destination.agent, toolCommand, initialInput: prompt,
984
+ ...parsed, readyTimeoutMs, paneId: senderPane, agent: destination.agent, toolCommand, initialInput: prompt,
520
985
  source: 'agent', noFocus: true, focus: false, waitReady: true, yes: true,
521
986
  });
987
+ if (resumedId)
988
+ panelRequest.resume = resumedId;
522
989
  try {
523
990
  const panel = await (0, daemonClient_1.invokeDaemon)('runpane:panels:create', [panelRequest], localControl_1.panelCreateResultSchema, {
524
991
  paneDir: parsed.paneDir,
525
- timeoutMs: (parsed.readyTimeoutMs ?? 30_000) + 10_000,
992
+ timeoutMs: (readyTimeoutMs ?? 30_000) + 10_000,
526
993
  });
527
- receiver = { ok: panel.ok, paneId: senderPane, panelId: panel.panelId, name: panel.title, initialInput: panel.initialInput, newTab: true };
994
+ receiver = { ok: panel.ok, paneId: senderPane, panelId: panel.panelId, name: panel.title, initialInput: panel.initialInput, ready: panel.readiness?.ok === true, blocked: panel.readiness?.blocked, newTab: true };
528
995
  }
529
996
  catch (error) {
530
997
  throw new Error(`runpane panels create failed in Pane ${senderPane}: ${error instanceof Error ? error.message : String(error)}. The note was sent to ${notePath}.`);
@@ -532,10 +999,12 @@ async function runHandoff(parsed) {
532
999
  }
533
1000
  else {
534
1001
  const request = await (0, localControl_1.buildPaneCreateRequest)({
535
- ...parsed, repo: String(repo.id), name, baseBranch: state.head, agent: destination.agent,
1002
+ ...parsed, readyTimeoutMs, repo: String(repo.id), name, baseBranch: state.head, agent: destination.agent,
536
1003
  toolCommand, initialInput: prompt, source: 'agent', noFocus: true, focus: false,
537
1004
  waitReady: true, yes: true, noAssociate: true, fromJson: undefined,
538
1005
  });
1006
+ if (resumedId)
1007
+ request.panes = request.panes.map(item => ({ ...item, resume: resumedId }));
539
1008
  let created;
540
1009
  try {
541
1010
  // Workspace control carries JSON directly to the reached daemon, without
@@ -547,25 +1016,46 @@ async function runHandoff(parsed) {
547
1016
  }
548
1017
  const item = created.items[0];
549
1018
  if (item?.ok && item.sessionId && item.setupQueue) {
550
- // The receiver's prompt rides on the launch after setup, so delivery is not verified yet.
551
- result.ok = false;
552
- result.notePath = notePath;
553
1019
  result.pane = { id: item.sessionId, name: item.name, worktreePath: item.worktreePath };
554
- warnings.push(`Pane ${item.sessionId} was created and is queued for setup (${item.setupQueue.message}). The receiver starts after setup with the note; its prompt is not verified yet, so do not retry. Check on it after setup: ${receiverStatus(item.sessionId)}`);
1020
+ warnings.push(`Pane ${item.sessionId} was created and is queued for setup (${item.setupQueue.message}). The receiver starts after setup${resumedId ? ', resuming the session' : ' with the note'}; ${resumedId ? 'it is not checked' : 'its prompt is not verified'} yet, so do not retry. Check on it after setup: ${receiverStatus(item.sessionId)}`);
555
1021
  for (const warning of warnings)
556
1022
  say(` ! ${warning}`);
1023
+ if (resumedId) {
1024
+ // The carry itself succeeded; only the check waits for setup, which is what inconclusive (exit 0) means.
1025
+ continuity.result = 'inconclusive';
1026
+ continuity.reason = `Pane ${item.sessionId} is queued for setup; its agent resumes ${resumedId} after setup, so the continuity check has not run.`;
1027
+ continuity.next = `After setup, check the receiver with ${receiverStatus(item.sessionId)} and, if its history is unclear, ask it: "${PROBE}". Then send it: ${receiverMessage(remote.name, item.worktreePath, notePath, true)}`;
1028
+ say(step('queued', continuity.reason, false));
1029
+ say(`Next: ${continuity.next}`);
1030
+ say(`Your session continues there after setup. End your turn now and make no more changes on ${state.branch}.`);
1031
+ if (parsed.json)
1032
+ console.log(JSON.stringify(result, null, 2));
1033
+ return 0;
1034
+ }
1035
+ // The receiver's prompt rides on the launch after setup, so delivery is not verified yet.
1036
+ result.ok = false;
557
1037
  if (parsed.json)
558
1038
  console.log(JSON.stringify(result, null, 2));
559
1039
  return 1;
560
1040
  }
561
- if (!item?.ok || !item.sessionId || !item.panelId)
1041
+ if (!item?.ok && !(resumedId && item?.sessionId && item.panelId)) {
562
1042
  throw new Error(`Pane on ${remote.name} did not start the agent: ${item && 'error' in item ? item.error.message : 'no pane was created'}. The note was sent to ${notePath}.${item?.sessionId ? ` Pane ${item.sessionId}. Check ${receiverStatus(item.sessionId, item.panelId)} before retrying.${item.panelId && !target ? ` Inspect panel ${item.panelId}: ${recovery('panels', 'screen', '--panel', item.panelId)}.` : ''}` : ` Check ${recovery('sessions', 'list')} before retrying.`}`);
563
- receiver = { ok: created.ok, paneId: item.sessionId, panelId: item.panelId, name: item.name, worktreePath: item.worktreePath, initialInput: item.initialInput, newTab: false };
1043
+ }
1044
+ if (!item.sessionId || !item.panelId)
1045
+ throw new Error(`Pane on ${remote.name} reported no panel for the receiver. The note was sent to ${notePath}. Check ${recovery('sessions', 'list')} before retrying.`);
1046
+ const started = 'error' in item ? undefined : item;
1047
+ receiver = { ok: created.ok, paneId: item.sessionId, panelId: item.panelId, name: item.name, worktreePath: item.worktreePath, initialInput: started?.initialInput, ready: started?.readiness?.ok === true, blocked: started?.readiness?.blocked, newTab: false };
564
1048
  }
565
- result.notePath = notePath;
566
1049
  result.pane = { id: receiver.paneId, panelId: receiver.panelId, name: receiver.name, worktreePath: receiver.worktreePath };
567
1050
  result.reportBack = noteOrigin.originPanel ? `${self} panel ${noteOrigin.originPanel}` : undefined;
568
1051
  const inspect = recovery('panels', 'screen', '--panel', receiver.panelId);
1052
+ const check = receiverStatus(receiver.paneId, receiver.panelId);
1053
+ if (carry && resumedId) {
1054
+ return reportCarried({ parsed, result, continuity, carry, resumedId, receiver, destination: remote, original, say,
1055
+ target: Boolean(target), notePath, noteOnlyCommand, inspect, check, branch: state.branch,
1056
+ submit: (panelId) => recovery('panels', 'submit', '--panel', panelId, '--text', '<message>', '--yes'),
1057
+ route: (command) => command.replace(/^runpane\s+/, `${recovery()} `) });
1058
+ }
569
1059
  const input = receiver.initialInput;
570
1060
  const verified = input?.verifiedSubmitted === true
571
1061
  && (input.delivery?.state === 'taken' || input.delivery?.state === 'queued')
@@ -580,13 +1070,69 @@ async function runHandoff(parsed) {
580
1070
  return 1;
581
1071
  }
582
1072
  say(step('started', receiver.newTab ? `${receiver.name ?? name} as a new tab in Pane ${receiver.paneId}` : `${receiver.name ?? name} on ${remote.name}${receiver.worktreePath ? ` (${receiver.worktreePath})` : ''}`));
583
- const check = receiverStatus(receiver.paneId, receiver.panelId);
1073
+ say(`It is a fresh ${destination.agent} with your note, not the same session.`);
584
1074
  say(result.reportBack ? `The receiver reports back to ${result.reportBack}.` : 'No sender panel to report to; the receiver reports on the branch.');
585
1075
  say(`Check on it: ${check}`);
586
1076
  if (parsed.json)
587
1077
  console.log(JSON.stringify(result, null, 2));
588
1078
  return 0;
589
1079
  }
1080
+ /**
1081
+ * The first message a receiver that resumed the sender's session gets, after the check: where it
1082
+ * now runs when the folder or machine changed, and where the sender's note is.
1083
+ */
1084
+ function receiverMessage(machine, movedTo, notePath, moved) {
1085
+ return `${moved ? `This session moved to ${movedTo ?? 'a new worktree'} on ${machine}.` : 'This session was handed off to this tab.'} Continue from where you were. The sender's handoff note is at ${notePath}; follow its "Receiver instructions" to report back.`;
1086
+ }
1087
+ /**
1088
+ * Runs the continuity check on a receiver that resumed the sender's session, sends the "moved"
1089
+ * message when it passed and the folder or machine changed, and reports. A receiver that fails
1090
+ * is kept and never replaced: the next step names the note route for the person to choose.
1091
+ */
1092
+ async function reportCarried(report) {
1093
+ const { parsed, result, continuity, carry, resumedId, receiver, destination, original, target, say } = report;
1094
+ const checked = await checkReceiver({ destination, carry, resumedId, paneId: receiver.paneId, panelId: receiver.panelId, ready: receiver.ready, blocked: receiver.blocked, original, inspect: report.inspect, route: report.route });
1095
+ continuity.checks = checked.checks;
1096
+ continuity.result = checked.result;
1097
+ continuity.reason = carry.note ? `${checked.why} ${carry.note}` : checked.why;
1098
+ const where = receiver.newTab ? `a new tab in Pane ${receiver.paneId}` : `Pane ${receiver.paneId} on ${destination.name}${receiver.worktreePath ? ` (${receiver.worktreePath})` : ''}`;
1099
+ const moved = target || !receiver.newTab;
1100
+ // The receiver started without a prompt, so it learns of the handoff (and its note) only from this.
1101
+ const message = receiverMessage(destination.name, moved ? receiver.worktreePath ?? 'a new worktree' : undefined, report.notePath, moved);
1102
+ if (checked.result === 'continued') {
1103
+ const manual = `Send it yourself: ${report.submit(receiver.panelId)} with: ${message}`;
1104
+ try {
1105
+ const sent = await destination.invoke('runpane:panels:submit', [{ panelId: receiver.panelId, input: message, source: 'agent' }], localControl_1.panelSubmitResultSchema);
1106
+ if (!sent.ok)
1107
+ result.warnings.push(`The message telling the receiver to continue was not delivered to panel ${receiver.panelId}. ${manual}`);
1108
+ }
1109
+ catch (error) {
1110
+ // The check passed and the receiver is kept; only this message's delivery is unknown.
1111
+ result.warnings.push(`Sending the receiver its message to continue failed (${error instanceof Error ? error.message : String(error)}), so it may or may not have arrived. Look at the receiver first: ${report.inspect}. If the message isn't there, ${manual.charAt(0).toLowerCase()}${manual.slice(1)}`);
1112
+ }
1113
+ say(step('continued', `${carry.session.agent} session ${resumedId}${continuity.fork ? ` (a fork of ${carry.session.id})` : ''} in ${where}. ${checked.why}`));
1114
+ }
1115
+ else if (checked.result === 'inconclusive') {
1116
+ continuity.next = `${checked.next ?? `Ask the receiver "${PROBE}" with ${report.submit(receiver.panelId)}, and compare its answer with your own last work.`} If it matches, send it this message: "${message}" If not, tell the person it is not continued.`;
1117
+ say(step('unclear', `${where}. ${checked.why}`, false));
1118
+ say(`Next: ${continuity.next}`);
1119
+ }
1120
+ else {
1121
+ result.ok = false;
1122
+ continuity.next = `The receiver is kept in ${where}; inspect it with ${report.inspect}. Ask the person before starting a fresh agent with the note instead: ${report.noteOnlyCommand}`;
1123
+ say(step('not continued', checked.why, false));
1124
+ say(`Next: ${continuity.next}`);
1125
+ }
1126
+ for (const warning of result.warnings)
1127
+ say(` ! ${warning}`);
1128
+ if (checked.result !== 'not-continued') {
1129
+ say(`Your session now continues in ${where}. End your turn now and make no more changes on ${report.branch}; the person can close this tab.`);
1130
+ say(`Check on it: ${report.check}`);
1131
+ }
1132
+ if (parsed.json)
1133
+ console.log(JSON.stringify(result, null, 2));
1134
+ return checked.result === 'not-continued' ? 1 : 0;
1135
+ }
590
1136
  /**
591
1137
  * The Session route. From a Session's folder there is no git to move, so the receiver is a new
592
1138
  * Session on another machine with the sender's name, goal and agent, and the note as its context
@@ -613,7 +1159,15 @@ async function handOffSession({ parsed, sessionId, destination, machines, self,
613
1159
  receiver: { route: 'session', reason: `A Session's folder has no branch to move, so the receiver is a new Session on ${target.name}.` },
614
1160
  session: { name, machine: target.name },
615
1161
  };
1162
+ // A same-agent move carries the Session agent's own conversation; the new Session resumes it when opened.
1163
+ const plan = await planCarry(parsed, destination);
1164
+ const carry = 'carry' in plan ? plan.carry : undefined;
1165
+ const continuity = carry ? describeCarry(carry, false) : { carried: false, reason: 'fresh' in plan ? plan.fresh : '' };
1166
+ result.continuity = continuity;
616
1167
  say(step('session', `"${source.name}" becomes "${name}" on ${target.name}`));
1168
+ say(carry
1169
+ ? step('session', continuity.reason)
1170
+ : step('session', `A fresh ${destination.agent} with your note, not the same conversation. ${continuity.reason}`, false));
617
1171
  for (const warning of warnings)
618
1172
  say(` ! ${warning}`);
619
1173
  if (parsed.dryRun) {
@@ -622,13 +1176,36 @@ async function handOffSession({ parsed, sessionId, destination, machines, self,
622
1176
  console.log(JSON.stringify(result, null, 2));
623
1177
  return 0;
624
1178
  }
1179
+ if (carry)
1180
+ await assertResumesOnCreate((channel, args, schema) => (0, daemonClient_1.invokeRemoteDaemon)(remote, channel, args, schema), target.name);
625
1181
  const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*/, '').replace('T', '-');
626
1182
  const slug = `handoff-${source.name}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 48).replace(/-+$/, '');
627
- const stamped = stampNote(noteText ?? '', origin, sessionReceiverInstructions(origin, source.name, target.name));
1183
+ const stamped = stampNote(noteText ?? '', origin, sessionReceiverInstructions(origin, source.name, target.name, carry !== undefined));
628
1184
  const written = await (0, daemonClient_1.invokeRemoteDaemon)(remote, 'runpane:machine:write', [{ path: `~/.pane/handoffs/${stamp}-${slug}-${(0, node_crypto_1.randomUUID)()}.md`, content: stamped, encoding: 'utf8' }], writeResultSchema);
629
1185
  const notePath = written.path;
630
1186
  result.notePath = notePath;
631
1187
  say(step('note sent', notePath));
1188
+ if (carry) {
1189
+ try {
1190
+ const placed = await placeSession(await remoteDestination(target), carry.session, '~');
1191
+ continuity.resumedId = carry.session.id;
1192
+ say(step('copied', `${carry.session.agent} session ${carry.session.id} to ${placed}`));
1193
+ }
1194
+ catch (error) {
1195
+ continuity.result = 'not-continued';
1196
+ continuity.reason = `Could not copy your ${carry.session.agent} session ${carry.session.id}: ${error instanceof Error ? error.message : String(error)}. No Session was created.`;
1197
+ continuity.next = `Fix that and run the handoff again, or ask the person before moving the Session with only the note: runpane handoff ${quote(parsed.handoffDestination ?? `${destination.agent} on ${target.name}`, 'sh')} --note-file <note> --note-only`;
1198
+ result.ok = false;
1199
+ say(step('session', continuity.reason, false));
1200
+ say(`Next: ${continuity.next}`);
1201
+ if (parsed.json)
1202
+ console.log(JSON.stringify(result, null, 2));
1203
+ return 1;
1204
+ }
1205
+ }
1206
+ else {
1207
+ continuity.result = 'fresh-agent';
1208
+ }
632
1209
  const launchCommand = agentCommand(destination, 'sh');
633
1210
  let created;
634
1211
  try {
@@ -636,9 +1213,12 @@ async function handOffSession({ parsed, sessionId, destination, machines, self,
636
1213
  name,
637
1214
  agent: destination.agent,
638
1215
  goal: source.goal,
639
- context: `Handed off from Session "${source.name}" on ${self}, which stays there. The sending agent's handoff note is at ${notePath} on this machine: read it first, including its "Receiver instructions".`,
1216
+ context: carry
1217
+ ? `Handed off from Session "${source.name}" on ${self}, which stays there. This Session resumes that Session's own ${carry.session.agent} conversation; the sending agent's handoff note is at ${notePath} on this machine.`
1218
+ : `Handed off from Session "${source.name}" on ${self}, which stays there. The sending agent's handoff note is at ${notePath} on this machine: read it first, including its "Receiver instructions".`,
640
1219
  nextAction: `Read the handoff note at ${notePath} and continue from it.`,
641
1220
  launchCommand,
1221
+ resume: continuity.resumedId,
642
1222
  }], localControl_1.runpaneSessionResultSchema, REPORT_TIMEOUT_MS);
643
1223
  }
644
1224
  catch (error) {
@@ -647,11 +1227,44 @@ async function handOffSession({ parsed, sessionId, destination, machines, self,
647
1227
  result.session = { id: created.session.id, name: created.session.name, machine: target.name };
648
1228
  result.reportBack = origin.originPanel ? `${self} panel ${origin.originPanel}` : undefined;
649
1229
  say(step('created', `Session "${created.session.name}" on ${target.name}`));
650
- say(`It waits for your first message: switch Pane to ${target.name}, open "${created.session.name}", and send "continue".`);
1230
+ if (carry) {
1231
+ // The Session's tab must already record the carried id, or opening it starts a fresh conversation.
1232
+ const recorded = await sessionResumeId(remote, created);
1233
+ if (recorded !== continuity.resumedId) {
1234
+ continuity.result = 'not-continued';
1235
+ continuity.checks = { resumedById: false, restartResumes: false, historyLoaded: null };
1236
+ continuity.reason = `Session "${created.session.name}" on ${target.name} records ${recorded ?? 'no conversation'} for its agent, not ${continuity.resumedId}, so opening it would not continue your conversation.`;
1237
+ continuity.next = `The Session is kept on ${target.name}; look at it before archiving it. Ask the person before moving the Session with only the note instead: runpane handoff ${quote(parsed.handoffDestination ?? `${destination.agent} on ${target.name}`, 'sh')} --note-file <note> --note-only`;
1238
+ result.ok = false;
1239
+ say(step('not continued', continuity.reason, false));
1240
+ say(`Next: ${continuity.next}`);
1241
+ if (parsed.json)
1242
+ console.log(JSON.stringify(result, null, 2));
1243
+ return 1;
1244
+ }
1245
+ continuity.checks = { resumedById: true, restartResumes: true, historyLoaded: null };
1246
+ // A Session's agent launches when the person opens it, so the history check runs then.
1247
+ continuity.result = 'inconclusive';
1248
+ continuity.reason = `"${created.session.name}" resumes ${carry.session.agent} session ${carry.session.id} when it is first opened; its history can only be checked then.`;
1249
+ continuity.next = `Open "${created.session.name}" on ${target.name} and check it shows your last message${carry.snippet ? ` ("${carry.snippet}")` : ''}; if that is unclear, ask it "${PROBE}". Then send it: This session moved to ${target.name}. Continue from where you were. The handoff note is at ${notePath}.`;
1250
+ say(step('resumes', continuity.reason));
1251
+ say(`Next: ${continuity.next}`);
1252
+ say('Your conversation now continues on that machine. End your turn now; the person can archive this Session when they are done with it.');
1253
+ }
1254
+ else {
1255
+ say(`It waits for your first message: switch Pane to ${target.name}, open "${created.session.name}", and send "continue".`);
1256
+ }
651
1257
  if (parsed.json)
652
1258
  console.log(JSON.stringify(result, null, 2));
653
1259
  return 0;
654
1260
  }
1261
+ /** The conversation id a new Session's agent tab records, which it resumes when first opened. */
1262
+ async function sessionResumeId(remote, created) {
1263
+ if (!created.internalSessionId || !created.panelId)
1264
+ return undefined;
1265
+ const listed = await (0, daemonClient_1.invokeRemoteDaemon)(remote, 'runpane:panels:list', [{ paneId: created.internalSessionId }], localControl_1.panelListResultSchema);
1266
+ return listed.panels.find(panel => panel.id === created.panelId || panel.panelId === created.panelId)?.resumeId;
1267
+ }
655
1268
  /** Pane's numbering for a taken Session name: "<name> 2", "<name> 3", compared without case. */
656
1269
  function nextFreeName(name, taken) {
657
1270
  const normalize = (value) => value.trim().toLocaleLowerCase();
@@ -661,7 +1274,7 @@ function nextFreeName(name, taken) {
661
1274
  candidate = `${name} ${suffix}`;
662
1275
  return candidate;
663
1276
  }
664
- function sessionReceiverInstructions(origin, sender, receiverMachine) {
1277
+ function sessionReceiverInstructions(origin, sender, receiverMachine, carried) {
665
1278
  const reportTarget = origin.originPanel
666
1279
  ? `runpane workspace ${origin.originMachine} panels submit --panel ${origin.originPanel} --text "<one line: what you finished, and anything blocked>" --yes`
667
1280
  : 'tell the person here, since the sender left no panel to report to';
@@ -669,7 +1282,9 @@ function sessionReceiverInstructions(origin, sender, receiverMachine) {
669
1282
  '## Receiver instructions',
670
1283
  '',
671
1284
  '<!-- Added by runpane handoff. -->',
672
- `1. You are a new Session on ${receiverMachine}, continuing Session "${sender}" from ${origin.originMachine}. Only this note was sent: the sender's conversation, Session folder and Panes stay on ${origin.originMachine}.`,
1285
+ carried
1286
+ ? `1. You are a new Session on ${receiverMachine}, continuing Session "${sender}" from ${origin.originMachine} in its own conversation, which was copied here. Its Session folder and Panes stay on ${origin.originMachine}.`
1287
+ : `1. You are a new Session on ${receiverMachine}, continuing Session "${sender}" from ${origin.originMachine}. Only this note was sent: the sender's conversation, Session folder and Panes stay on ${origin.originMachine}.`,
673
1288
  `2. Read this whole note. To read a file it names from the sender's machine, run \`runpane workspace ${origin.originMachine} read <path>\`.`,
674
1289
  '3. Continue from "Next steps".',
675
1290
  `4. When you finish or get blocked, report back to the sender: ${reportTarget}`,