runpane 2.4.165 → 2.4.166
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 +15 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +8 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +30 -0
- package/dist/commands.js.map +1 -1
- package/dist/docs-index.json +1 -1
- package/dist/generated/contract.d.ts +92 -1
- package/dist/generated/contract.d.ts.map +1 -1
- package/dist/generated/contract.js +159 -1
- package/dist/generated/contract.js.map +1 -1
- package/dist/handoff.d.ts +80 -0
- package/dist/handoff.d.ts.map +1 -0
- package/dist/handoff.js +528 -0
- package/dist/handoff.js.map +1 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +3 -0
- package/dist/workspace.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ParsedArgs, RunpaneAgent } from './commands';
|
|
2
|
+
import { type TailnetMachine } from './workspace';
|
|
3
|
+
/**
|
|
4
|
+
* The note's sections. They extend Codex's compaction prompt
|
|
5
|
+
* (openai/codex codex-rs/prompts/templates/compact/prompt.md: progress and decisions, context and
|
|
6
|
+
* constraints, next steps, critical references) with what a receiver on another machine needs:
|
|
7
|
+
* what was verified, how to check it, and where the code is.
|
|
8
|
+
*/
|
|
9
|
+
export declare const HANDOFF_SECTIONS: readonly [{
|
|
10
|
+
readonly heading: "Goal";
|
|
11
|
+
readonly prompt: "What the work is for and what \"done\" means, in the person's terms. Name the issue or PR.";
|
|
12
|
+
}, {
|
|
13
|
+
readonly heading: "Current state";
|
|
14
|
+
readonly prompt: "Where things stand right now, in two or three sentences.";
|
|
15
|
+
}, {
|
|
16
|
+
readonly heading: "Done and verified";
|
|
17
|
+
readonly prompt: "Finished work, each item with how you verified it (command and result).";
|
|
18
|
+
}, {
|
|
19
|
+
readonly heading: "In progress";
|
|
20
|
+
readonly prompt: "Half-done work: what is written, what is not, and any failing test or error, quoted.";
|
|
21
|
+
}, {
|
|
22
|
+
readonly heading: "Next steps";
|
|
23
|
+
readonly prompt: "The ordered next actions. Make the first one concrete enough to start without asking.";
|
|
24
|
+
}, {
|
|
25
|
+
readonly heading: "Decisions and constraints";
|
|
26
|
+
readonly prompt: "Decisions with their reasons, the person's preferences, non-goals, and approaches that failed (so they are not retried).";
|
|
27
|
+
}, {
|
|
28
|
+
readonly heading: "Open questions";
|
|
29
|
+
readonly prompt: "What only the person can answer, and what you were unsure about. Write \"None\" if there are none.";
|
|
30
|
+
}, {
|
|
31
|
+
readonly heading: "How to verify";
|
|
32
|
+
readonly prompt: "Exact commands to build, test, and see the change working, with the result you expect.";
|
|
33
|
+
}, {
|
|
34
|
+
readonly heading: "Git state";
|
|
35
|
+
readonly prompt: "Repository, branch, head commit, whether it is pushed, and any uncommitted files that matter.";
|
|
36
|
+
}];
|
|
37
|
+
export interface HandoffDestination {
|
|
38
|
+
/** A Tailscale machine name, or null for this machine. */
|
|
39
|
+
machine: string | null;
|
|
40
|
+
agent: RunpaneAgent;
|
|
41
|
+
model?: string;
|
|
42
|
+
effort?: string;
|
|
43
|
+
/** Explicit WSL selection is recognized and rejected until supported. */
|
|
44
|
+
wsl?: true;
|
|
45
|
+
}
|
|
46
|
+
export interface DestinationOverrides {
|
|
47
|
+
machine?: string;
|
|
48
|
+
agent?: RunpaneAgent;
|
|
49
|
+
model?: string;
|
|
50
|
+
effort?: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Reads "codex gpt-5 high on parsas-macbook-pro" into a machine, agent, model, and effort.
|
|
54
|
+
* Flags win over the text. Anything it cannot place is an error, not a guess.
|
|
55
|
+
*/
|
|
56
|
+
export declare function parseDestination(text: string, machines: readonly TailnetMachine[], overrides?: DestinationOverrides): HandoffDestination;
|
|
57
|
+
export interface NoteCheck {
|
|
58
|
+
ok: boolean;
|
|
59
|
+
/** Required sections with no heading. */
|
|
60
|
+
missing: string[];
|
|
61
|
+
/** Required sections whose heading is there but whose body is empty or only template comments. */
|
|
62
|
+
empty: string[];
|
|
63
|
+
}
|
|
64
|
+
/** Checks that every required section is present and written; "None" counts as written. */
|
|
65
|
+
export declare function validateNote(text: string): NoteCheck;
|
|
66
|
+
export interface NoteOrigin {
|
|
67
|
+
originMachine: string;
|
|
68
|
+
originPane?: string;
|
|
69
|
+
originPanel?: string;
|
|
70
|
+
originSession?: string;
|
|
71
|
+
repository?: string;
|
|
72
|
+
branch?: string;
|
|
73
|
+
head?: string;
|
|
74
|
+
remote?: string;
|
|
75
|
+
pushed?: boolean;
|
|
76
|
+
}
|
|
77
|
+
/** The note an agent fills in: front matter for the origin, then one section per required heading. */
|
|
78
|
+
export declare function noteTemplate(origin: NoteOrigin): string;
|
|
79
|
+
export declare function runHandoff(parsed: ParsedArgs): Promise<number>;
|
|
80
|
+
//# sourceMappingURL=handoff.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handoff.d.ts","sourceRoot":"","sources":["../src/handoff.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAI3D,OAAO,EAAgD,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAEhG;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;EAUnB,CAAC;AAUX,MAAM,WAAW,kBAAkB;IACjC,0DAA0D;IAC1D,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,KAAK,EAAE,YAAY,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yEAAyE;IACzE,GAAG,CAAC,EAAE,IAAI,CAAC;CACZ;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,cAAc,EAAE,EAAE,SAAS,GAAE,oBAAyB,GAAG,kBAAkB,CA8C5I;AAaD,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,OAAO,CAAC;IACZ,yCAAyC;IACzC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,kGAAkG;IAClG,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,2FAA2F;AAC3F,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAgBpD;AAED,MAAM,WAAW,UAAU;IACzB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,sGAAsG;AACtG,wBAAgB,YAAY,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAcvD;AAuQD,wBAAsB,UAAU,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAuIpE"}
|
package/dist/handoff.js
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.HANDOFF_SECTIONS = void 0;
|
|
7
|
+
exports.parseDestination = parseDestination;
|
|
8
|
+
exports.validateNote = validateNote;
|
|
9
|
+
exports.noteTemplate = noteTemplate;
|
|
10
|
+
exports.runHandoff = runHandoff;
|
|
11
|
+
const node_child_process_1 = require("node:child_process");
|
|
12
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
|
+
const node_crypto_1 = require("node:crypto");
|
|
14
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
15
|
+
const boundaryDecoder_1 = require("./boundaryDecoder");
|
|
16
|
+
const daemonClient_1 = require("./daemonClient");
|
|
17
|
+
const localControl_1 = require("./localControl");
|
|
18
|
+
const contract_1 = require("./generated/contract");
|
|
19
|
+
const workspace_1 = require("./workspace");
|
|
20
|
+
/**
|
|
21
|
+
* The note's sections. They extend Codex's compaction prompt
|
|
22
|
+
* (openai/codex codex-rs/prompts/templates/compact/prompt.md: progress and decisions, context and
|
|
23
|
+
* constraints, next steps, critical references) with what a receiver on another machine needs:
|
|
24
|
+
* what was verified, how to check it, and where the code is.
|
|
25
|
+
*/
|
|
26
|
+
exports.HANDOFF_SECTIONS = [
|
|
27
|
+
{ heading: 'Goal', prompt: 'What the work is for and what "done" means, in the person\'s terms. Name the issue or PR.' },
|
|
28
|
+
{ heading: 'Current state', prompt: 'Where things stand right now, in two or three sentences.' },
|
|
29
|
+
{ heading: 'Done and verified', prompt: 'Finished work, each item with how you verified it (command and result).' },
|
|
30
|
+
{ heading: 'In progress', prompt: 'Half-done work: what is written, what is not, and any failing test or error, quoted.' },
|
|
31
|
+
{ heading: 'Next steps', prompt: 'The ordered next actions. Make the first one concrete enough to start without asking.' },
|
|
32
|
+
{ heading: 'Decisions and constraints', prompt: 'Decisions with their reasons, the person\'s preferences, non-goals, and approaches that failed (so they are not retried).' },
|
|
33
|
+
{ heading: 'Open questions', prompt: 'What only the person can answer, and what you were unsure about. Write "None" if there are none.' },
|
|
34
|
+
{ heading: 'How to verify', prompt: 'Exact commands to build, test, and see the change working, with the result you expect.' },
|
|
35
|
+
{ heading: 'Git state', prompt: 'Repository, branch, head commit, whether it is pushed, and any uncommitted files that matter.' },
|
|
36
|
+
];
|
|
37
|
+
const AGENTS = new Set(contract_1.RUNPANE_CONTRACT.enums.agents);
|
|
38
|
+
const AGENT_ALIASES = new Map([['claude-code', 'claude'], ['cursor-agent', 'cursor'], ['codex-cli', 'codex']]);
|
|
39
|
+
const EFFORTS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
|
|
40
|
+
const MODEL_PATTERN = /^(gpt-|o\d|claude-|opus|sonnet|haiku|fable|composer|gemini|grok)/i;
|
|
41
|
+
const FILLER = new Set(['on', 'to', 'with', 'using', 'via', 'at', 'in', 'a', 'an', 'the', 'and', 'effort', 'reasoning', 'model', 'machine']);
|
|
42
|
+
const THIS_MACHINE = /\b(this machine|this computer|here|locally|local|localhost)\b/gi;
|
|
43
|
+
const REPORT_TIMEOUT_MS = 180_000;
|
|
44
|
+
/**
|
|
45
|
+
* Reads "codex gpt-5 high on parsas-macbook-pro" into a machine, agent, model, and effort.
|
|
46
|
+
* Flags win over the text. Anything it cannot place is an error, not a guess.
|
|
47
|
+
*/
|
|
48
|
+
function parseDestination(text, machines, overrides = {}) {
|
|
49
|
+
let local = false;
|
|
50
|
+
const words = text.replace(THIS_MACHINE, () => { local = true; return ' '; })
|
|
51
|
+
.split(/[\s,]+/).filter(Boolean);
|
|
52
|
+
const agents = [];
|
|
53
|
+
const efforts = [];
|
|
54
|
+
const models = [];
|
|
55
|
+
const unknown = [];
|
|
56
|
+
let wsl = false;
|
|
57
|
+
for (const raw of words) {
|
|
58
|
+
const [key, value] = raw.includes('=') ? raw.split('=', 2) : [undefined, raw];
|
|
59
|
+
const word = value.toLowerCase();
|
|
60
|
+
if (key?.toLowerCase() === 'model')
|
|
61
|
+
models.push(value);
|
|
62
|
+
else if (key && ['effort', 'reasoning'].includes(key.toLowerCase()))
|
|
63
|
+
efforts.push(word);
|
|
64
|
+
else if (AGENTS.has(word) || AGENT_ALIASES.has(word))
|
|
65
|
+
agents.push(AGENT_ALIASES.get(word) ?? decodeAgent(word));
|
|
66
|
+
else if (EFFORTS.has(word))
|
|
67
|
+
efforts.push(word);
|
|
68
|
+
else if (word === 'wsl')
|
|
69
|
+
wsl = true;
|
|
70
|
+
else if (MODEL_PATTERN.test(word))
|
|
71
|
+
models.push(word);
|
|
72
|
+
else if (!FILLER.has(word))
|
|
73
|
+
unknown.push(value);
|
|
74
|
+
}
|
|
75
|
+
for (const [label, found] of [['agents', [...new Set(agents)]], ['models', models], ['efforts', efforts]]) {
|
|
76
|
+
if (found.length > 1)
|
|
77
|
+
throw new Error(`"${text}" names two ${label}: ${found.join(' and ')}. Name one.`);
|
|
78
|
+
}
|
|
79
|
+
const agent = overrides.agent ?? agents[0];
|
|
80
|
+
if (!agent)
|
|
81
|
+
throw new Error(`Name the agent: claude, codex, or cursor. For example: runpane handoff "claude opus on parsas-macbook-pro" --note-file ~/handoff.md`);
|
|
82
|
+
let machine = null;
|
|
83
|
+
if (overrides.machine) {
|
|
84
|
+
machine = machineName((0, workspace_1.resolveMachine)(overrides.machine, machines));
|
|
85
|
+
}
|
|
86
|
+
else if (unknown.length > 1) {
|
|
87
|
+
throw new Error(`Could not read "${unknown.join(' ')}" in "${text}". Name one machine, or pass --machine <name>.`);
|
|
88
|
+
}
|
|
89
|
+
else if (unknown.length === 1) {
|
|
90
|
+
if (local)
|
|
91
|
+
throw new Error(`"${text}" names this machine and "${unknown[0]}". Name one.`);
|
|
92
|
+
machine = machineName((0, workspace_1.resolveMachine)(unknown[0], machines));
|
|
93
|
+
}
|
|
94
|
+
const destination = { machine, agent };
|
|
95
|
+
const model = overrides.model ?? models[0];
|
|
96
|
+
const effort = overrides.effort ?? efforts[0];
|
|
97
|
+
if (model !== undefined && !/^[A-Za-z0-9._:/-]+$/.test(model))
|
|
98
|
+
throw new Error('Invalid model identifier. Use letters, numbers, dots, underscores, colons, slashes, or hyphens.');
|
|
99
|
+
if (effort !== undefined && !EFFORTS.has(effort))
|
|
100
|
+
throw new Error(`Invalid effort. Use ${[...EFFORTS].join(', ')}.`);
|
|
101
|
+
if (agent === 'cursor' && effort !== undefined)
|
|
102
|
+
throw new Error('Cursor effort is not supported. Omit --effort and effort text; nothing was committed or sent.');
|
|
103
|
+
if (model)
|
|
104
|
+
destination.model = model;
|
|
105
|
+
if (effort)
|
|
106
|
+
destination.effort = effort;
|
|
107
|
+
if (wsl)
|
|
108
|
+
destination.wsl = true;
|
|
109
|
+
return destination;
|
|
110
|
+
}
|
|
111
|
+
function machineName(machine) {
|
|
112
|
+
return machine.self ? null : machine.name;
|
|
113
|
+
}
|
|
114
|
+
const agentSchema = boundaryDecoder_1.boundary.enumeration(...contract_1.RUNPANE_CONTRACT.enums.agents);
|
|
115
|
+
function decodeAgent(value) {
|
|
116
|
+
return (0, boundaryDecoder_1.decodeBoundary)(value, agentSchema);
|
|
117
|
+
}
|
|
118
|
+
/** Checks that every required section is present and written; "None" counts as written. */
|
|
119
|
+
function validateNote(text) {
|
|
120
|
+
const body = text.replace(/^---\n[\s\S]*?\n---\n/, '');
|
|
121
|
+
const headings = [...body.matchAll(/^#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/gm)];
|
|
122
|
+
const missing = [];
|
|
123
|
+
const empty = [];
|
|
124
|
+
for (const section of exports.HANDOFF_SECTIONS) {
|
|
125
|
+
const index = headings.findIndex((match) => match[1].trim().toLowerCase() === section.heading.toLowerCase());
|
|
126
|
+
if (index === -1) {
|
|
127
|
+
missing.push(section.heading);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const start = (headings[index].index ?? 0) + headings[index][0].length;
|
|
131
|
+
const end = headings[index + 1]?.index ?? body.length;
|
|
132
|
+
if (!body.slice(start, end).replace(/<!--[\s\S]*?-->/g, '').trim())
|
|
133
|
+
empty.push(section.heading);
|
|
134
|
+
}
|
|
135
|
+
return { ok: missing.length === 0 && empty.length === 0, missing, empty };
|
|
136
|
+
}
|
|
137
|
+
/** The note an agent fills in: front matter for the origin, then one section per required heading. */
|
|
138
|
+
function noteTemplate(origin) {
|
|
139
|
+
return [
|
|
140
|
+
frontMatter(origin),
|
|
141
|
+
'# Handoff: <title>',
|
|
142
|
+
'',
|
|
143
|
+
'<!--',
|
|
144
|
+
'Write this for an agent that has none of your context: no chat history, no memory, a different',
|
|
145
|
+
'machine. Be concise and concrete; quote errors and commands exactly. Every section is required;',
|
|
146
|
+
'write "None" when one does not apply. Based on Codex\'s compaction prompt',
|
|
147
|
+
'(github.com/openai/codex, codex-rs/prompts/templates/compact/prompt.md).',
|
|
148
|
+
'-->',
|
|
149
|
+
'',
|
|
150
|
+
...exports.HANDOFF_SECTIONS.flatMap((section) => [`## ${section.heading}`, '', `<!-- ${section.prompt} -->`, '']),
|
|
151
|
+
].join('\n');
|
|
152
|
+
}
|
|
153
|
+
function frontMatter(origin) {
|
|
154
|
+
const fields = [
|
|
155
|
+
['origin_machine', origin.originMachine],
|
|
156
|
+
['origin_pane', origin.originPane],
|
|
157
|
+
['origin_panel', origin.originPanel],
|
|
158
|
+
['origin_session', origin.originSession],
|
|
159
|
+
['repository', origin.repository],
|
|
160
|
+
['branch', origin.branch],
|
|
161
|
+
['head', origin.head],
|
|
162
|
+
['remote', origin.remote],
|
|
163
|
+
['pushed', origin.pushed],
|
|
164
|
+
];
|
|
165
|
+
const lines = fields.filter(([, value]) => value !== undefined && value !== '').map(([key, value]) => `${key}: ${value}`);
|
|
166
|
+
return ['---', ...lines, '---', ''].join('\n');
|
|
167
|
+
}
|
|
168
|
+
/** Replaces the note's front matter with the CLI's own, so the origin and git state are never stale. */
|
|
169
|
+
function stampNote(text, origin, receiver) {
|
|
170
|
+
const body = text.replace(/^---\n[\s\S]*?\n---\n/, '').replace(/^\n+/, '');
|
|
171
|
+
return `${frontMatter(origin)}\n${body.trimEnd()}\n\n${receiver}\n`;
|
|
172
|
+
}
|
|
173
|
+
function git(args, cwd) {
|
|
174
|
+
return (0, node_child_process_1.execFileSync)('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
175
|
+
}
|
|
176
|
+
function tryGit(args, cwd) {
|
|
177
|
+
try {
|
|
178
|
+
return git(args, cwd);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function readGitState(cwd) {
|
|
185
|
+
const root = tryGit(['rev-parse', '--show-toplevel'], cwd);
|
|
186
|
+
if (!root)
|
|
187
|
+
throw new Error('runpane handoff runs inside the git repository being handed off.');
|
|
188
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], root);
|
|
189
|
+
if (branch === 'HEAD')
|
|
190
|
+
throw new Error('HEAD is detached. Check out a branch before handing off.');
|
|
191
|
+
const remote = tryGit(['config', `branch.${branch}.remote`], root) ?? 'origin';
|
|
192
|
+
const remoteUrl = tryGit(['remote', 'get-url', remote], root);
|
|
193
|
+
if (!remoteUrl)
|
|
194
|
+
throw new Error(`This repository has no "${remote}" remote, so the receiver cannot fetch the work. Add one, then hand off again.`);
|
|
195
|
+
const head = git(['rev-parse', 'HEAD'], root);
|
|
196
|
+
const remoteHead = tryGit(['rev-parse', `refs/remotes/${remote}/${branch}`], root);
|
|
197
|
+
return {
|
|
198
|
+
root,
|
|
199
|
+
repository: repositorySlug(remoteUrl) ?? node_path_1.default.basename(root),
|
|
200
|
+
branch,
|
|
201
|
+
head,
|
|
202
|
+
remote,
|
|
203
|
+
remoteUrl,
|
|
204
|
+
dirty: (0, node_child_process_1.execFileSync)('git', ['status', '--porcelain', '-z'], { cwd: root, encoding: 'utf8' }).split('\0').filter(Boolean),
|
|
205
|
+
pushed: remoteHead !== null && tryGit(['merge-base', '--is-ancestor', head, remoteHead], root) !== null,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function noteInCheckout(root, notePath) {
|
|
209
|
+
if (!notePath || notePath === '-')
|
|
210
|
+
return '';
|
|
211
|
+
const resolved = node_path_1.default.resolve(notePath);
|
|
212
|
+
// Git may report a canonical root while Windows TEMP uses an 8.3 alias.
|
|
213
|
+
// Canonicalize directories, keeping the leaf so a note symlink is excluded too.
|
|
214
|
+
const canonicalNote = node_path_1.default.join(node_fs_1.default.realpathSync.native(node_path_1.default.dirname(resolved)), node_path_1.default.basename(resolved));
|
|
215
|
+
const relative = node_path_1.default.relative(node_fs_1.default.realpathSync.native(root), canonicalNote);
|
|
216
|
+
return relative === '..' || relative.startsWith(`..${node_path_1.default.sep}`) || node_path_1.default.isAbsolute(relative) ? '' : relative;
|
|
217
|
+
}
|
|
218
|
+
/** --push: commit uncommitted work as WIP and push the branch; never forces. */
|
|
219
|
+
/** The note is not part of the work: leave it out of the uncommitted count and the WIP commit. */
|
|
220
|
+
function withoutNote(state, notePath) {
|
|
221
|
+
const relative = noteInCheckout(state.root, notePath).split(node_path_1.default.sep).join('/');
|
|
222
|
+
if (!relative)
|
|
223
|
+
return state;
|
|
224
|
+
return { ...state, dirty: state.dirty.filter((line) => line.slice(3) !== relative) };
|
|
225
|
+
}
|
|
226
|
+
function pushWork(state, destination, notePath) {
|
|
227
|
+
const noteRelative = noteInCheckout(state.root, notePath);
|
|
228
|
+
if (noteRelative && git(['diff', '--cached', '--name-only', '--', noteRelative], state.root)) {
|
|
229
|
+
throw new Error(`The handoff note is staged. Unstage it with git restore --staged -- ${JSON.stringify(noteRelative)}, then retry. Nothing was committed or pushed.`);
|
|
230
|
+
}
|
|
231
|
+
if (state.dirty.length) {
|
|
232
|
+
const relative = noteInCheckout(state.root, notePath);
|
|
233
|
+
git(['add', '-A', '--', '.', ...(relative ? [`:(exclude)${relative}`] : [])], state.root);
|
|
234
|
+
git(['commit', '-m', `WIP: hand off to ${destination}`], state.root);
|
|
235
|
+
}
|
|
236
|
+
git(['push', '-u', state.remote, `HEAD:refs/heads/${state.branch}`], state.root);
|
|
237
|
+
git(['fetch', state.remote, state.branch], state.root);
|
|
238
|
+
return withoutNote(readGitState(state.root), notePath);
|
|
239
|
+
}
|
|
240
|
+
/** owner/name for a GitHub-style remote URL. */
|
|
241
|
+
function repositorySlug(url) {
|
|
242
|
+
const match = /[:/]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/.exec(url.trim());
|
|
243
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
244
|
+
}
|
|
245
|
+
// ---------------------------------------------------------------- running on the destination
|
|
246
|
+
const execResultSchema = boundaryDecoder_1.boundary.object({
|
|
247
|
+
os: boundaryDecoder_1.boundary.enumeration('macOS', 'Windows', 'Linux'),
|
|
248
|
+
shell: boundaryDecoder_1.boundary.string,
|
|
249
|
+
exitCode: boundaryDecoder_1.boundary.nullable(boundaryDecoder_1.boundary.number),
|
|
250
|
+
stdout: boundaryDecoder_1.boundary.string,
|
|
251
|
+
stderr: boundaryDecoder_1.boundary.string,
|
|
252
|
+
});
|
|
253
|
+
const writeResultSchema = boundaryDecoder_1.boundary.object({ path: boundaryDecoder_1.boundary.string, bytes: boundaryDecoder_1.boundary.number });
|
|
254
|
+
const repoListSchema = boundaryDecoder_1.boundary.object({
|
|
255
|
+
repos: boundaryDecoder_1.boundary.array(boundaryDecoder_1.boundary.object({ id: boundaryDecoder_1.boundary.number, name: boundaryDecoder_1.boundary.string, path: boundaryDecoder_1.boundary.string, environment: boundaryDecoder_1.boundary.optional(boundaryDecoder_1.boundary.string) })),
|
|
256
|
+
});
|
|
257
|
+
const paneCreateSchema = boundaryDecoder_1.boundary.object({
|
|
258
|
+
items: boundaryDecoder_1.boundary.array(boundaryDecoder_1.boundary.object({
|
|
259
|
+
ok: boundaryDecoder_1.boundary.boolean,
|
|
260
|
+
sessionId: boundaryDecoder_1.boundary.optional(boundaryDecoder_1.boundary.string),
|
|
261
|
+
panelId: boundaryDecoder_1.boundary.optional(boundaryDecoder_1.boundary.string),
|
|
262
|
+
name: boundaryDecoder_1.boundary.optional(boundaryDecoder_1.boundary.string),
|
|
263
|
+
worktreePath: boundaryDecoder_1.boundary.optional(boundaryDecoder_1.boundary.string),
|
|
264
|
+
error: boundaryDecoder_1.boundary.optional(boundaryDecoder_1.boundary.object({ message: boundaryDecoder_1.boundary.string })),
|
|
265
|
+
})),
|
|
266
|
+
});
|
|
267
|
+
async function remoteDestination(machine) {
|
|
268
|
+
const target = (0, workspace_1.workspaceTarget)(machine);
|
|
269
|
+
const probe = await (0, daemonClient_1.invokeRemoteDaemon)(target, 'runpane:machine:exec', [{ command: 'echo runpane-handoff', timeoutMs: 20_000 }], execResultSchema);
|
|
270
|
+
const shell = probe.shell;
|
|
271
|
+
// cmd has expansion rules that cannot safely represent arbitrary terminal prompts.
|
|
272
|
+
if (!/^(bash|zsh|sh|fish|pwsh|powershell)(?:\.exe)?$/i.test(node_path_1.default.posix.basename(shell.replace(/\\/g, '/')))) {
|
|
273
|
+
throw new Error(`Handoff does not support the destination shell "${shell}". Select Bash or PowerShell in Pane and retry.`);
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
name: machine.name,
|
|
277
|
+
shell,
|
|
278
|
+
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),
|
|
279
|
+
write: async (file, content) => (await (0, daemonClient_1.invokeRemoteDaemon)(target, 'runpane:machine:write', [{ path: file, content, encoding: 'utf8' }], writeResultSchema)).path,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function localDestination(name) {
|
|
283
|
+
return {
|
|
284
|
+
name,
|
|
285
|
+
shell: process.platform === 'win32' ? 'powershell' : (process.env.SHELL ?? 'sh'),
|
|
286
|
+
run: (command, timeoutMs = 120_000) => new Promise((resolve) => {
|
|
287
|
+
const child = (0, node_child_process_1.spawn)(command[0], command.slice(1), { shell: false, timeout: timeoutMs, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
288
|
+
let stdout = '';
|
|
289
|
+
let stderr = '';
|
|
290
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
|
|
291
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
|
|
292
|
+
child.on('error', (error) => resolve({ exitCode: 1, stdout, stderr: stderr + error.message }));
|
|
293
|
+
child.on('close', (exitCode) => resolve({ exitCode, stdout, stderr }));
|
|
294
|
+
}),
|
|
295
|
+
write: async (file, content) => {
|
|
296
|
+
const resolved = file.replace(/^~(?=[\\/])/, process.env.HOME ?? process.env.USERPROFILE ?? '~');
|
|
297
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(resolved), { recursive: true });
|
|
298
|
+
node_fs_1.default.writeFileSync(resolved, content, { mode: 0o600 });
|
|
299
|
+
return resolved;
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function quote(value, shell) {
|
|
304
|
+
if (/^[\w./:@=+-]+$/.test(value))
|
|
305
|
+
return value;
|
|
306
|
+
return /(?:powershell|pwsh)(?:\.exe)?$/i.test(shell) ? `'${value.replace(/'/g, "''")}'` : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
307
|
+
}
|
|
308
|
+
async function mustRun(destination, command, what, timeoutMs) {
|
|
309
|
+
const result = await destination.run(command, timeoutMs);
|
|
310
|
+
if (result.exitCode !== 0) {
|
|
311
|
+
const detail = (result.stderr || result.stdout).trim().split('\n').slice(-3).join(' ');
|
|
312
|
+
throw new Error(`${what} failed on ${destination.name}: ${detail || `exit ${result.exitCode}`}`);
|
|
313
|
+
}
|
|
314
|
+
return result.stdout;
|
|
315
|
+
}
|
|
316
|
+
/** The saved repository on the destination whose remotes include ours; returns it and that remote's name. */
|
|
317
|
+
async function findRepo(destination, runpane, state, selector) {
|
|
318
|
+
const listed = (0, boundaryDecoder_1.decodeBoundary)(JSON.parse(await mustRun(destination, [...runpane, 'repos', 'list', '--json'], 'runpane repos list')), repoListSchema);
|
|
319
|
+
const candidates = selector
|
|
320
|
+
? listed.repos.filter((repo) => String(repo.id) === selector || repo.name === selector || repo.path === selector)
|
|
321
|
+
: listed.repos;
|
|
322
|
+
if (selector && !candidates.length)
|
|
323
|
+
throw new Error(`${destination.name} has no saved repository "${selector}". Its repositories: ${listed.repos.map((repo) => repo.name).join(', ')}.`);
|
|
324
|
+
const wanted = repositorySlug(state.remoteUrl)?.toLowerCase();
|
|
325
|
+
for (const repo of candidates) {
|
|
326
|
+
const remotes = await destination.run(['git', '-C', repo.path, 'remote', '-v'], 20_000);
|
|
327
|
+
if (remotes.exitCode !== 0)
|
|
328
|
+
continue;
|
|
329
|
+
const match = remotes.stdout.split('\n').map((line) => line.split(/\s+/))
|
|
330
|
+
.find(([, url]) => url && repositorySlug(url)?.toLowerCase() === wanted);
|
|
331
|
+
if (match)
|
|
332
|
+
return { repo, remote: match[0] };
|
|
333
|
+
}
|
|
334
|
+
throw new Error(`No saved repository on ${destination.name} has a remote for ${wanted}. Add a matching Git remote to the destination clone, then save the clone there with: runpane workspace ${destination.name} exec -- runpane repos add --path <clone> --yes.`);
|
|
335
|
+
}
|
|
336
|
+
function agentCommand(destination, shell) {
|
|
337
|
+
if (!destination.model && !destination.effort)
|
|
338
|
+
return undefined;
|
|
339
|
+
const base = contract_1.RUNPANE_CONTRACT.agentTemplates[destination.agent].command;
|
|
340
|
+
const { model, effort } = destination;
|
|
341
|
+
const flags = destination.agent === 'codex'
|
|
342
|
+
? [model && `-m ${quote(model, shell)}`, effort && `-c ${quote(`model_reasoning_effort=${effort}`, shell)}`]
|
|
343
|
+
: destination.agent === 'claude'
|
|
344
|
+
? [model && `--model ${quote(model, shell)}`, effort && `--effort ${quote(effort, shell)}`]
|
|
345
|
+
: [model && `--model ${quote(model, shell)}`];
|
|
346
|
+
return [base, ...flags].filter(Boolean).join(' ');
|
|
347
|
+
}
|
|
348
|
+
function describeDestination(destination, machine) {
|
|
349
|
+
const detail = [destination.model, destination.effort && `${destination.effort} effort`].filter(Boolean).join(', ');
|
|
350
|
+
return `${destination.agent}${detail ? ` (${detail})` : ''} on ${machine}`;
|
|
351
|
+
}
|
|
352
|
+
function receiverInstructions(origin, state, remote, reporter) {
|
|
353
|
+
const reportTarget = origin.originPanel
|
|
354
|
+
? `${reporter} workspace ${origin.originMachine} panels submit --panel ${origin.originPanel} --text "<one line: what you finished, the commit or PR, and anything blocked>" --yes`
|
|
355
|
+
: `a comment on the PR, or a commit message on ${state.branch}, since the sender left no panel to report to`;
|
|
356
|
+
return [
|
|
357
|
+
'## Receiver instructions',
|
|
358
|
+
'',
|
|
359
|
+
'<!-- Added by runpane handoff. -->',
|
|
360
|
+
'1. Read the repository\'s AGENTS.md or CLAUDE.md, then this whole note.',
|
|
361
|
+
`2. Check the git state: your worktree starts at sender commit ${state.head}; \`git log -1 --format=%H\` must print ${state.head}. If it does not, stop and report.`,
|
|
362
|
+
'3. Run the commands under "How to verify" to confirm the state the note describes, then continue from "Next steps".',
|
|
363
|
+
`4. Push your commits to the original branch: \`git push ${remote} HEAD:${state.branch}\`. Never force-push.`,
|
|
364
|
+
`5. When you finish or get blocked, report back to the sender: ${reportTarget}`,
|
|
365
|
+
].join('\n');
|
|
366
|
+
}
|
|
367
|
+
async function runHandoff(parsed) {
|
|
368
|
+
const cwd = process.cwd();
|
|
369
|
+
const tailnet = await (0, workspace_1.readTailnet)();
|
|
370
|
+
const self = tailnet.ok ? tailnet.self.name : (process.env.HOSTNAME ?? 'this-machine');
|
|
371
|
+
const origin = (state) => ({
|
|
372
|
+
originMachine: self,
|
|
373
|
+
originPane: process.env.PANE_SESSION_ID,
|
|
374
|
+
originPanel: process.env.PANE_PANEL_ID,
|
|
375
|
+
originSession: process.env.PANE_ORCHESTRATION_SESSION_ID,
|
|
376
|
+
repository: state?.repository,
|
|
377
|
+
branch: state?.branch,
|
|
378
|
+
head: state?.head,
|
|
379
|
+
remote: state?.remote,
|
|
380
|
+
pushed: state?.pushed,
|
|
381
|
+
});
|
|
382
|
+
if (parsed.handoffTemplate) {
|
|
383
|
+
process.stdout.write(noteTemplate(origin(tryReadGitState(cwd))));
|
|
384
|
+
return 0;
|
|
385
|
+
}
|
|
386
|
+
const text = parsed.handoffDestination ?? '';
|
|
387
|
+
if (!text && !parsed.handoffMachine && !parsed.agent) {
|
|
388
|
+
throw new Error('runpane handoff needs a destination, for example: runpane handoff "claude opus on parsas-macbook-pro" --note-file ~/handoff.md. Start the note with: runpane handoff --template > ~/handoff.md');
|
|
389
|
+
}
|
|
390
|
+
const machines = tailnet.ok ? [tailnet.self, ...tailnet.machines] : [];
|
|
391
|
+
const destination = parseDestination(text, machines, {
|
|
392
|
+
machine: parsed.handoffMachine, agent: parsed.agent, model: parsed.handoffModel, effort: parsed.handoffEffort,
|
|
393
|
+
});
|
|
394
|
+
if (destination.wsl)
|
|
395
|
+
throw new Error('WSL handoff is not supported yet. Choose a native destination without wsl; nothing was committed or sent.');
|
|
396
|
+
if (destination.machine && !tailnet.ok)
|
|
397
|
+
throw new Error(`Handing off to another machine needs Tailscale: ${tailnet.reason}. ${tailnet.fix}`);
|
|
398
|
+
const machineLabel = destination.machine ?? `${self} (this machine)`;
|
|
399
|
+
const say = (line) => { if (!parsed.json)
|
|
400
|
+
console.log(line); };
|
|
401
|
+
say(`Handoff to ${describeDestination(destination, machineLabel)}`);
|
|
402
|
+
const warnings = [];
|
|
403
|
+
let noteText;
|
|
404
|
+
let noteInfo;
|
|
405
|
+
if (parsed.handoffNoteFile) {
|
|
406
|
+
noteText = node_fs_1.default.readFileSync(parsed.handoffNoteFile === '-' ? 0 : parsed.handoffNoteFile, 'utf8');
|
|
407
|
+
const check = validateNote(noteText);
|
|
408
|
+
if (!check.ok) {
|
|
409
|
+
const parts = [check.missing.length && `missing ${check.missing.join(', ')}`, check.empty.length && `not filled in: ${check.empty.join(', ')}`].filter(Boolean);
|
|
410
|
+
throw new Error(`The note is incomplete (${parts.join('; ')}). Fill in every section, writing "None" where one does not apply. The template: runpane handoff --template`);
|
|
411
|
+
}
|
|
412
|
+
noteInfo = { path: node_path_1.default.resolve(parsed.handoffNoteFile), sections: exports.HANDOFF_SECTIONS.length, bytes: Buffer.byteLength(noteText) };
|
|
413
|
+
say(step('note', `${noteInfo.sections} sections, ${formatBytes(noteInfo.bytes)}`));
|
|
414
|
+
}
|
|
415
|
+
else if (!parsed.dryRun) {
|
|
416
|
+
throw new Error('runpane handoff needs --note-file <path>. Write it from: runpane handoff --template > ~/handoff.md');
|
|
417
|
+
}
|
|
418
|
+
let state = withoutNote(readGitState(cwd), parsed.handoffNoteFile);
|
|
419
|
+
if (parsed.handoffPush && !parsed.dryRun && (!state.pushed || state.dirty.length)) {
|
|
420
|
+
state = pushWork(state, machineLabel, parsed.handoffNoteFile);
|
|
421
|
+
}
|
|
422
|
+
if (state.dirty.length)
|
|
423
|
+
warnings.push(`${state.dirty.length} uncommitted file${state.dirty.length === 1 ? '' : 's'} will not reach the receiver. Commit them, or pass --push to commit them as WIP.`);
|
|
424
|
+
if (!state.pushed)
|
|
425
|
+
warnings.push(`${state.branch} at ${state.head.slice(0, 7)} is not on ${state.remote}. Push it, or pass --push.`);
|
|
426
|
+
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));
|
|
427
|
+
const result = {
|
|
428
|
+
ok: true,
|
|
429
|
+
dryRun: parsed.dryRun,
|
|
430
|
+
destination,
|
|
431
|
+
note: noteInfo,
|
|
432
|
+
git: { repository: state.repository, branch: state.branch, head: state.head, remote: state.remote, pushed: state.pushed, dirty: state.dirty.length },
|
|
433
|
+
warnings,
|
|
434
|
+
};
|
|
435
|
+
if (parsed.dryRun) {
|
|
436
|
+
for (const warning of warnings)
|
|
437
|
+
say(` ! ${warning}`);
|
|
438
|
+
say('Dry run: nothing was sent.');
|
|
439
|
+
if (parsed.json)
|
|
440
|
+
console.log(JSON.stringify(result, null, 2));
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
if (!state.pushed)
|
|
444
|
+
throw new Error(`${warnings.join(' ')}\nThe receiver would start without your work, so nothing was sent.`);
|
|
445
|
+
for (const warning of warnings)
|
|
446
|
+
say(` ! ${warning}`);
|
|
447
|
+
const target = destination.machine ? machines.find((machine) => machine.name === destination.machine) : undefined;
|
|
448
|
+
const remote = target ? await remoteDestination(target) : localDestination(self);
|
|
449
|
+
const installed = await remote.run(['runpane', '--version'], 30_000);
|
|
450
|
+
const runpane = target
|
|
451
|
+
? (installed.exitCode === 0 ? ['runpane'] : ['npx', '--yes', 'runpane@latest'])
|
|
452
|
+
: (process.platform === 'win32' || installed.exitCode !== 0 ? [process.execPath, node_path_1.default.join(__dirname, 'cli.js')] : ['runpane']);
|
|
453
|
+
if (!target && parsed.paneDir)
|
|
454
|
+
runpane.push('--pane-dir', parsed.paneDir);
|
|
455
|
+
// `runpane workspace` arrived in 2.4.164; an older runpane there reports back through npx.
|
|
456
|
+
const reporter = installed.exitCode === 0 && versionAtLeast(installed.stdout, [2, 4, 164]) ? 'runpane' : 'npx --yes runpane@latest';
|
|
457
|
+
const { repo, remote: repoRemote } = await findRepo(remote, runpane, state, parsed.repo);
|
|
458
|
+
say(step('repo', `${repo.name} (${repo.path}), remote ${repoRemote}`));
|
|
459
|
+
await mustRun(remote, ['git', '-C', repo.path, 'fetch', repoRemote, state.branch], 'git fetch', 120_000);
|
|
460
|
+
await mustRun(remote, ['git', '-C', repo.path, 'cat-file', '-e', `${state.head}^{commit}`], 'verify sender commit');
|
|
461
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*/, '').replace('T', '-');
|
|
462
|
+
const name = `handoff-${state.branch.replace(/^handoff[-/]/i, '')}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 48).replace(/-+$/, '');
|
|
463
|
+
const noteOrigin = origin(state);
|
|
464
|
+
const stamped = stampNote(noteText ?? '', noteOrigin, receiverInstructions(noteOrigin, state, repoRemote, reporter));
|
|
465
|
+
const notePath = await remote.write(`~/.pane/handoffs/${stamp}-${name}-${(0, node_crypto_1.randomUUID)()}.md`, stamped);
|
|
466
|
+
say(step('note sent', notePath));
|
|
467
|
+
const toolCommand = agentCommand(destination, remote.shell);
|
|
468
|
+
const prompt = `Read the handoff note at ${notePath} and continue the work it describes. Start with its "Receiver instructions" section.`;
|
|
469
|
+
let create = [
|
|
470
|
+
...runpane, 'panes', 'create', '--repo', String(repo.id), '--name', name, '--base', state.head, '--agent', destination.agent,
|
|
471
|
+
...(toolCommand ? ['--tool-command', toolCommand] : []),
|
|
472
|
+
'--prompt', prompt, '--source', 'agent', '--no-focus', '--wait-ready', '--yes', '--json',
|
|
473
|
+
];
|
|
474
|
+
let created;
|
|
475
|
+
try {
|
|
476
|
+
// PowerShell 5.1 strips embedded quotes and empty arguments when launching
|
|
477
|
+
// native npm wrappers. Keep prompts and nested tool commands in JSON.
|
|
478
|
+
if (target && /(?:powershell|pwsh)(?:\.exe)?$/i.test(remote.shell)) {
|
|
479
|
+
const request = await (0, localControl_1.buildPaneCreateRequest)({
|
|
480
|
+
...parsed, repo: String(repo.id), name, baseBranch: state.head, agent: destination.agent,
|
|
481
|
+
toolCommand, initialInput: prompt, source: 'agent', noFocus: true, focus: false,
|
|
482
|
+
waitReady: true, yes: true, noAssociate: true, fromJson: undefined,
|
|
483
|
+
});
|
|
484
|
+
const requestPath = await remote.write(`${notePath}.create.json`, JSON.stringify(request));
|
|
485
|
+
create = [...runpane, 'panes', 'create', '--from-json', requestPath, '--no-associate', '--yes', '--json'];
|
|
486
|
+
}
|
|
487
|
+
created = (0, boundaryDecoder_1.decodeBoundary)(JSON.parse(await mustRun(remote, create, 'runpane panes create', REPORT_TIMEOUT_MS)), paneCreateSchema);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}. The note was sent to ${notePath}. Check runpane sessions list on ${remote.name} before retrying to avoid a duplicate Pane.`);
|
|
491
|
+
}
|
|
492
|
+
const item = created.items[0];
|
|
493
|
+
if (!item?.ok || !item.sessionId || !item.panelId)
|
|
494
|
+
throw new Error(`Pane on ${remote.name} did not start the agent: ${item?.error?.message ?? 'no pane was created'}. The note was sent to ${notePath}.${item?.sessionId ? ` Check runpane agents status --pane ${item.sessionId} on ${remote.name} before retrying.` : ` Check runpane sessions list on ${remote.name} before retrying.`}`);
|
|
495
|
+
say(step('started', `${item.name ?? name} on ${remote.name}${item.worktreePath ? ` (${item.worktreePath})` : ''}`));
|
|
496
|
+
result.repo = { id: repo.id, name: repo.name, path: repo.path };
|
|
497
|
+
result.notePath = notePath;
|
|
498
|
+
result.pane = { id: item.sessionId, panelId: item.panelId, name: item.name, worktreePath: item.worktreePath };
|
|
499
|
+
result.reportBack = noteOrigin.originPanel ? `${self} panel ${noteOrigin.originPanel}` : undefined;
|
|
500
|
+
const check = `${destination.machine ? `runpane workspace ${remote.name} exec -- ` : ''}runpane agents status --pane ${item.sessionId}`;
|
|
501
|
+
say(result.reportBack ? `The receiver reports back to ${result.reportBack}.` : 'No sender panel to report to; the receiver reports on the branch.');
|
|
502
|
+
say(`Check on it: ${check}`);
|
|
503
|
+
if (parsed.json)
|
|
504
|
+
console.log(JSON.stringify(result, null, 2));
|
|
505
|
+
return 0;
|
|
506
|
+
}
|
|
507
|
+
function tryReadGitState(cwd) {
|
|
508
|
+
try {
|
|
509
|
+
return readGitState(cwd);
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function versionAtLeast(output, minimum) {
|
|
516
|
+
const parts = /(\d+)\.(\d+)\.(\d+)/.exec(output)?.slice(1).map(Number);
|
|
517
|
+
if (!parts)
|
|
518
|
+
return false;
|
|
519
|
+
const index = parts.findIndex((part, position) => part !== minimum[position]);
|
|
520
|
+
return index === -1 || parts[index] > minimum[index];
|
|
521
|
+
}
|
|
522
|
+
function step(label, detail, ok = true) {
|
|
523
|
+
return ` ${ok ? '✓' : '!'} ${label.padEnd(10)} ${detail}`;
|
|
524
|
+
}
|
|
525
|
+
function formatBytes(bytes) {
|
|
526
|
+
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
|
|
527
|
+
}
|
|
528
|
+
//# sourceMappingURL=handoff.js.map
|