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/README.md +26 -2
- package/dist/commands.d.ts +2 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +4 -0
- package/dist/commands.js.map +1 -1
- package/dist/docs-index.json +1 -1
- package/dist/generated/contract.d.ts +15 -8
- package/dist/generated/contract.d.ts.map +1 -1
- package/dist/generated/contract.js +26 -11
- package/dist/generated/contract.js.map +1 -1
- package/dist/handoff.d.ts +33 -0
- package/dist/handoff.d.ts.map +1 -1
- package/dist/handoff.js +641 -26
- package/dist/handoff.js.map +1 -1
- package/dist/localControl.d.ts +4 -0
- package/dist/localControl.d.ts.map +1 -1
- package/dist/localControl.js.map +1 -1
- package/dist/nativeSession.d.ts +92 -0
- package/dist/nativeSession.d.ts.map +1 -0
- package/dist/nativeSession.js +410 -0
- package/dist/nativeSession.js.map +1 -0
- package/dist/workspace.d.ts +4 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +17 -7
- package/dist/workspace.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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}`,
|