@bivy/bivy 0.1.0-staging.4 → 0.1.0-staging.6
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/bin/bivy.mjs +43 -3
- package/dist/relay-client.js +28 -0
- package/dist/runtime/claude-code.js +44 -0
- package/dist/runtime/codex-sessions.js +50 -0
- package/dist/runtime/index.js +5 -1
- package/dist/runtime/pi.js +21 -0
- package/dist/runtime/protocol.js +16 -0
- package/dist/server.js +10 -3
- package/dist/session/fork.js +44 -11
- package/dist/session/transcript-normal.js +77 -12
- package/package.json +1 -1
package/bin/bivy.mjs
CHANGED
|
@@ -3265,15 +3265,47 @@ async function cmdStatus(args = []) {
|
|
|
3265
3265
|
try { status = await localApi(config, "/api/status"); } catch {}
|
|
3266
3266
|
}
|
|
3267
3267
|
if (json) {
|
|
3268
|
-
|
|
3268
|
+
const relayCfgJson = loadRelayConfig();
|
|
3269
|
+
console.log(JSON.stringify({
|
|
3270
|
+
reachable,
|
|
3271
|
+
url: url(config),
|
|
3272
|
+
workspace: status?.workspace || config.workspace,
|
|
3273
|
+
service: serviceStatusLine(),
|
|
3274
|
+
remoteConfigured: Boolean(status?.relay?.configured || relayCfgJson),
|
|
3275
|
+
relay: {
|
|
3276
|
+
configured: Boolean(status?.relay?.configured || relayCfgJson),
|
|
3277
|
+
connected: status?.relay?.connected ?? null,
|
|
3278
|
+
app: status?.relay?.controlPlaneUrl || relayCfgJson?.controlPlaneUrl || null,
|
|
3279
|
+
relay: status?.relay?.relayUrl || relayCfgJson?.url || null,
|
|
3280
|
+
lastError: status?.relay?.lastError || null,
|
|
3281
|
+
},
|
|
3282
|
+
status,
|
|
3283
|
+
}, null, 2));
|
|
3269
3284
|
return;
|
|
3270
3285
|
}
|
|
3271
3286
|
console.log(c.bold("\n Bivy node\n"));
|
|
3272
3287
|
console.log(` url: ${url(config)} ${reachable ? c.green("● reachable") : c.dim("○ not reachable")}`);
|
|
3273
3288
|
console.log(` workspace: ${status?.workspace || config.workspace}`);
|
|
3274
3289
|
console.log(` ${serviceStatusLine()}`);
|
|
3275
|
-
console.log(` remote: ${status?.relay?.configured || fs.existsSync(relayConfigPath) ? c.green("relay configured") : "local only"}`);
|
|
3276
3290
|
const relay = loadRelayConfig();
|
|
3291
|
+
const relaySt = status?.relay;
|
|
3292
|
+
const relayConfigured = Boolean(relaySt?.configured || relay);
|
|
3293
|
+
if (!relayConfigured) {
|
|
3294
|
+
console.log(` remote: ${c.dim("local only")} ${c.dim("('bivy relay:setup' to enable remote access)")}`);
|
|
3295
|
+
} else {
|
|
3296
|
+
// The live link state is only knowable when the node is running; if it's
|
|
3297
|
+
// down we can say it's configured but not whether it's currently connected.
|
|
3298
|
+
let state;
|
|
3299
|
+
if (!reachable) state = c.yellow("configured (node not running)");
|
|
3300
|
+
else if (relaySt?.connected) state = c.green("● connected");
|
|
3301
|
+
else state = c.yellow("○ configured, not connected");
|
|
3302
|
+
const relErr = relaySt?.lastError ? c.dim(` (${relaySt.lastError})`) : "";
|
|
3303
|
+
console.log(` remote: ${state}${relErr}`);
|
|
3304
|
+
const cpUrl = relaySt?.controlPlaneUrl || relay?.controlPlaneUrl;
|
|
3305
|
+
const rlUrl = relaySt?.relayUrl || relay?.url;
|
|
3306
|
+
if (cpUrl) console.log(` app: ${cpUrl}`);
|
|
3307
|
+
if (rlUrl) console.log(` relay: ${rlUrl}`);
|
|
3308
|
+
}
|
|
3277
3309
|
if (relay?.controlPlaneUrl && relay?.enrollmentToken) {
|
|
3278
3310
|
try {
|
|
3279
3311
|
const acct = await controlPlaneNodeApi(relay, "/node/account");
|
|
@@ -3331,7 +3363,15 @@ async function cmdDoctor(args = []) {
|
|
|
3331
3363
|
console.log(` ${mark(agentAvailable, true)} agent ${runtimeInfo?.displayName || defaultAgent}${agentAvailable ? "" : c.dim(" not available — install it or run 'bivy setup'")}`);
|
|
3332
3364
|
console.log(` ${mark(hasModelConfig(config), authOwner !== "bivy")} model ${hasModelConfig(config) ? "configured" : authOwner === "bivy" ? c.dim("not configured — run 'bivy login'") : c.dim("agent-native auth — use the agent's CLI login if needed")}`);
|
|
3333
3365
|
const relayConfigured = Boolean(status?.relay?.configured || fs.existsSync(relayConfigPath));
|
|
3334
|
-
|
|
3366
|
+
const relayConnected = Boolean(status?.relay?.connected);
|
|
3367
|
+
const relayApp = status?.relay?.controlPlaneUrl;
|
|
3368
|
+
const relayErr = status?.relay?.lastError;
|
|
3369
|
+
const relayLine = !relayConfigured
|
|
3370
|
+
? c.dim("local only — 'bivy relay:setup' to enable")
|
|
3371
|
+
: relayConnected
|
|
3372
|
+
? c.green("relay connected") + (relayApp ? c.dim(` ${relayApp}`) : "")
|
|
3373
|
+
: c.yellow("configured, not connected") + (relayErr ? c.dim(` (${relayErr})`) : "");
|
|
3374
|
+
console.log(` ${relayConfigured ? (relayConnected ? ok : warn) : c.dim("○")} remote ${relayLine}`);
|
|
3335
3375
|
// Derived from BUILTIN_TERMINAL_AGENTS (the same list 'bivy agents'/'bivy run'
|
|
3336
3376
|
// use) rather than a hand-maintained list, so it can't drift out of sync (#113).
|
|
3337
3377
|
const agentCommands = [...BUILTIN_TERMINAL_AGENTS.values()].filter((a) => a.type === "command").map((a) => a.command);
|
package/dist/relay-client.js
CHANGED
|
@@ -51,6 +51,14 @@ export class RelayConnector {
|
|
|
51
51
|
heartbeatTimer;
|
|
52
52
|
stableTimer;
|
|
53
53
|
lastPongAt = 0;
|
|
54
|
+
// True only between the relay's `ready` message (auth + entitlement passed)
|
|
55
|
+
// and the socket closing. This — not "a connector object exists" — is what
|
|
56
|
+
// "connected" means to the control plane, so it's what `bivy status` reports.
|
|
57
|
+
ready = false;
|
|
58
|
+
// Most recent relay-side failure (ticket mint, socket error, or an `error`
|
|
59
|
+
// frame), surfaced by `bivy status`/`doctor` so a node that never connects
|
|
60
|
+
// explains why instead of silently showing "configured".
|
|
61
|
+
lastErrorMessage;
|
|
54
62
|
replay = new ReplayGuard();
|
|
55
63
|
reassembler = new FrameReassembler();
|
|
56
64
|
pairing;
|
|
@@ -71,12 +79,26 @@ export class RelayConnector {
|
|
|
71
79
|
return;
|
|
72
80
|
this.ws.send(JSON.stringify({ t: "pair", p: JSON.stringify({ k: "key.rotate", deliveries }) }));
|
|
73
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* True only while the relay link is live AND the relay has sent `ready`
|
|
84
|
+
* (auth/entitlement checks passed) — i.e. the node is actually reachable from
|
|
85
|
+
* the control plane. A socket that opened but was rejected, or one still
|
|
86
|
+
* reconnecting, reads false.
|
|
87
|
+
*/
|
|
88
|
+
get connected() {
|
|
89
|
+
return this.ready && this.ws?.readyState === WebSocket.OPEN;
|
|
90
|
+
}
|
|
91
|
+
/** Most recent relay-side failure, if any — for status/diagnostics. */
|
|
92
|
+
get lastError() {
|
|
93
|
+
return this.lastErrorMessage;
|
|
94
|
+
}
|
|
74
95
|
start() {
|
|
75
96
|
this.closed = false;
|
|
76
97
|
void this.connect();
|
|
77
98
|
}
|
|
78
99
|
stop() {
|
|
79
100
|
this.closed = true;
|
|
101
|
+
this.ready = false;
|
|
80
102
|
this.stopHeartbeat();
|
|
81
103
|
this.clearBackoffReset();
|
|
82
104
|
this.ws?.close();
|
|
@@ -250,6 +272,7 @@ export class RelayConnector {
|
|
|
250
272
|
({ ticket, relayUrl } = await this.mintTicket());
|
|
251
273
|
}
|
|
252
274
|
catch (error) {
|
|
275
|
+
this.lastErrorMessage = `ticket mint failed: ${error.message}`;
|
|
253
276
|
console.warn("[relay] could not mint relay ticket:", error.message);
|
|
254
277
|
this.scheduleReconnect();
|
|
255
278
|
return;
|
|
@@ -276,6 +299,8 @@ export class RelayConnector {
|
|
|
276
299
|
return;
|
|
277
300
|
}
|
|
278
301
|
if (env.t === "ready") {
|
|
302
|
+
this.ready = true;
|
|
303
|
+
this.lastErrorMessage = undefined;
|
|
279
304
|
this.startHeartbeat(ws);
|
|
280
305
|
this.scheduleBackoffReset(ws);
|
|
281
306
|
console.log("[relay] connected");
|
|
@@ -311,6 +336,7 @@ export class RelayConnector {
|
|
|
311
336
|
}
|
|
312
337
|
if (env.t === "error") {
|
|
313
338
|
const message = env.error || "Relay error";
|
|
339
|
+
this.lastErrorMessage = message;
|
|
314
340
|
console.warn("[relay] error:", message);
|
|
315
341
|
if (isFatalRelayError(message)) {
|
|
316
342
|
console.warn("[relay] disabling connector; fix relay setup/plan and restart the node dev server");
|
|
@@ -323,11 +349,13 @@ export class RelayConnector {
|
|
|
323
349
|
this.lastPongAt = Date.now();
|
|
324
350
|
});
|
|
325
351
|
ws.on("close", () => {
|
|
352
|
+
this.ready = false;
|
|
326
353
|
this.stopHeartbeat();
|
|
327
354
|
this.clearBackoffReset();
|
|
328
355
|
this.scheduleReconnect();
|
|
329
356
|
});
|
|
330
357
|
ws.on("error", (error) => {
|
|
358
|
+
this.lastErrorMessage = error.message;
|
|
331
359
|
console.warn("[relay] socket error:", error.message);
|
|
332
360
|
});
|
|
333
361
|
}
|
|
@@ -1164,6 +1164,10 @@ export class ClaudeCodeRuntime {
|
|
|
1164
1164
|
// The on-disk jsonl transcript can be exported and re-materialised on another
|
|
1165
1165
|
// node under a fresh session id, so a claude->claude fork is full fidelity.
|
|
1166
1166
|
forkTransport: true,
|
|
1167
|
+
// Claude can also synthesise a resumable jsonl from portable {role,text}
|
|
1168
|
+
// history, so a fork FROM another agent INTO claude is a true replay of the
|
|
1169
|
+
// whole transcript rather than a seeded summary (see importHistoryForFork).
|
|
1170
|
+
forkHistoryImport: true,
|
|
1167
1171
|
// Claude Code ignores the streamingBehavior hint entirely — a mid-turn
|
|
1168
1172
|
// prompt always re-enters the live input queue and behaves like an
|
|
1169
1173
|
// immediate steer, regardless of what's asked for. There is no real
|
|
@@ -1281,6 +1285,46 @@ export class ClaudeCodeRuntime {
|
|
|
1281
1285
|
fs.writeFileSync(path.join(projectDir, `${newId}.jsonl`), rewritten ? `${rewritten}\n` : "");
|
|
1282
1286
|
return { sessionFile: newId, id: newId };
|
|
1283
1287
|
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Stand up a claude session from a **cross-runtime** fork's portable history:
|
|
1290
|
+
* synthesise a resumable jsonl transcript from the `{role, text}` turns and
|
|
1291
|
+
* write it under the destination cwd's project dir with a fresh session id, so
|
|
1292
|
+
* `--resume <id>` opens on a copy of the whole conversation (fidelity
|
|
1293
|
+
* "replayed"). Each turn becomes one claude jsonl entry whose `message` is a
|
|
1294
|
+
* plain-text user/assistant message — the same shape loadClaudeTranscript reads
|
|
1295
|
+
* back — chained by `uuid`/`parentUuid` exactly as claude's own transcripts are.
|
|
1296
|
+
* Tool activity is already inlined as text upstream, so no `tool_use`/
|
|
1297
|
+
* `tool_result` blocks (whose ids would dangle) are ever emitted. The source is
|
|
1298
|
+
* never touched.
|
|
1299
|
+
*/
|
|
1300
|
+
async importHistoryForFork(history, ctx) {
|
|
1301
|
+
const newId = randomUUID();
|
|
1302
|
+
const cwd = ctx.cwd || ctx.workspace;
|
|
1303
|
+
// Claude encodes the cwd into the project-dir name by replacing every
|
|
1304
|
+
// non-alphanumeric char with "-" (matches importForFork above).
|
|
1305
|
+
const projectSlug = cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
1306
|
+
const root = claudeProjectDirs()[0] ?? path.join(os.homedir(), ".claude");
|
|
1307
|
+
const projectDir = path.join(root, "projects", projectSlug);
|
|
1308
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
1309
|
+
let parentUuid = null;
|
|
1310
|
+
const lines = history.map((message) => {
|
|
1311
|
+
const uuid = randomUUID();
|
|
1312
|
+
const entry = {
|
|
1313
|
+
parentUuid,
|
|
1314
|
+
isSidechain: false,
|
|
1315
|
+
userType: "external",
|
|
1316
|
+
cwd,
|
|
1317
|
+
sessionId: newId,
|
|
1318
|
+
type: message.role,
|
|
1319
|
+
message: { role: message.role, content: message.text },
|
|
1320
|
+
uuid,
|
|
1321
|
+
};
|
|
1322
|
+
parentUuid = uuid;
|
|
1323
|
+
return JSON.stringify(entry);
|
|
1324
|
+
});
|
|
1325
|
+
fs.writeFileSync(path.join(projectDir, `${newId}.jsonl`), lines.length ? `${lines.join("\n")}\n` : "");
|
|
1326
|
+
return { sessionFile: newId, id: newId };
|
|
1327
|
+
}
|
|
1284
1328
|
async listSessions() {
|
|
1285
1329
|
const dir = this.options.sessionsDir;
|
|
1286
1330
|
if (dir) {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import fs from "node:fs";
|
|
22
22
|
import os from "node:os";
|
|
23
23
|
import path from "node:path";
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
24
25
|
import { hasLiveProcessForCwd } from "./native-process-scan.js";
|
|
25
26
|
/** Binary names a live Codex process could be running under (see
|
|
26
27
|
* native-process-scan.ts's best-effort cwd match). */
|
|
@@ -132,6 +133,55 @@ export function loadCodexTranscriptFile(file) {
|
|
|
132
133
|
}
|
|
133
134
|
return messages;
|
|
134
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Materialise a **cross-runtime** fork's portable history as a fresh Codex
|
|
138
|
+
* rollout so `codex ... resume <id>` (the app-server's `thread/resume`) opens on
|
|
139
|
+
* a copy of the whole conversation — the write-side counterpart to
|
|
140
|
+
* `loadCodexTranscript`, and Codex's `importHistoryForFork` (fidelity
|
|
141
|
+
* "replayed"). The rollout is written in the current wrapped layout — a
|
|
142
|
+
* `session_meta` line then one `response_item` per turn — under the id-addressed
|
|
143
|
+
* date path Codex uses (`$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl`), the
|
|
144
|
+
* same shape `loadCodexTranscriptFile` reads back and `discoverCodexSessionForCwd`
|
|
145
|
+
* locates. Message turns carry a Responses-API `message` item (`input_text` for
|
|
146
|
+
* the user, `output_text` for the assistant), which reads back through `textOf`.
|
|
147
|
+
*
|
|
148
|
+
* IMPORTANT — best-effort, and NOT verified against a live Codex resume here (see
|
|
149
|
+
* this module's header): Codex's rollout schema is version-variable, so whether a
|
|
150
|
+
* *synthesised* rollout is fully honored by `thread/resume` depends on the
|
|
151
|
+
* installed Codex. The fork engine calls this only as its "replayed" tier and
|
|
152
|
+
* falls back to a seeded continuation prompt if it throws; a node can force that
|
|
153
|
+
* fallback outright with `BIVY_CODEX_NO_FORK_REPLAY=1` when its Codex build
|
|
154
|
+
* doesn't accept synthesised rollouts.
|
|
155
|
+
*/
|
|
156
|
+
export function writeCodexRollout(history, cwd) {
|
|
157
|
+
if (process.env.BIVY_CODEX_NO_FORK_REPLAY === "1") {
|
|
158
|
+
throw new Error("Codex fork replay disabled (BIVY_CODEX_NO_FORK_REPLAY=1)");
|
|
159
|
+
}
|
|
160
|
+
const id = randomUUID();
|
|
161
|
+
const now = new Date();
|
|
162
|
+
const iso = now.toISOString();
|
|
163
|
+
const yyyy = String(now.getUTCFullYear());
|
|
164
|
+
const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
165
|
+
const dd = String(now.getUTCDate()).padStart(2, "0");
|
|
166
|
+
const dir = path.join(codexSessionsDir(), yyyy, mm, dd);
|
|
167
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
168
|
+
const stamp = iso.replace(/[:.]/g, "-").replace(/Z$/, "");
|
|
169
|
+
const file = path.join(dir, `rollout-${stamp}-${id}.jsonl`);
|
|
170
|
+
const records = [
|
|
171
|
+
{ type: "session_meta", timestamp: iso, payload: { id, timestamp: iso, cwd, cli_version: "bivy-fork" } },
|
|
172
|
+
...history.map((message) => ({
|
|
173
|
+
type: "response_item",
|
|
174
|
+
timestamp: iso,
|
|
175
|
+
payload: {
|
|
176
|
+
type: "message",
|
|
177
|
+
role: message.role,
|
|
178
|
+
content: [{ type: message.role === "user" ? "input_text" : "output_text", text: message.text }],
|
|
179
|
+
},
|
|
180
|
+
})),
|
|
181
|
+
];
|
|
182
|
+
fs.writeFileSync(file, records.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
183
|
+
return { sessionFile: id, id };
|
|
184
|
+
}
|
|
135
185
|
/** Enumerate Codex sessions on disk, newest first. Best-effort. */
|
|
136
186
|
export function listCodexSessions() {
|
|
137
187
|
const sessions = [];
|
package/dist/runtime/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled } from "./claude-code.js";
|
|
10
|
-
import { deleteCodexSession, discoverNativeCodexSessions, loadCodexTranscript } from "./codex-sessions.js";
|
|
10
|
+
import { deleteCodexSession, discoverNativeCodexSessions, loadCodexTranscript, writeCodexRollout } from "./codex-sessions.js";
|
|
11
11
|
import { createCredentialStore } from "./credentials.js";
|
|
12
12
|
// Args that continue an existing Codex session each prompt. Codex assigns its own
|
|
13
13
|
// session id (no launch-time pin), so a resumed run threads it via `exec resume`.
|
|
@@ -1076,6 +1076,10 @@ function codexAppServerRuntime(credsDir, tier) {
|
|
|
1076
1076
|
resumable: true,
|
|
1077
1077
|
loadHistory: (sessionId) => loadCodexTranscript(sessionId),
|
|
1078
1078
|
deleteHistory: (sessionId) => void deleteCodexSession(sessionId),
|
|
1079
|
+
// True cross-runtime replay INTO Codex: synthesise a resumable rollout from
|
|
1080
|
+
// portable history so a fork from another agent opens on a copy of the whole
|
|
1081
|
+
// conversation instead of a seeded summary (best-effort — see writeCodexRollout).
|
|
1082
|
+
writeHistory: (history, ctx) => writeCodexRollout(history, ctx.cwd || ctx.workspace),
|
|
1079
1083
|
suggestName: suggestCodexSessionName,
|
|
1080
1084
|
// Native discovery (issue #156): enumerate Codex rollouts on this node that
|
|
1081
1085
|
// Bivy didn't start, so a pre-existing `codex` session can be adopted here
|
package/dist/runtime/pi.js
CHANGED
|
@@ -366,6 +366,9 @@ export class PiRuntime {
|
|
|
366
366
|
// pi transcripts are structured messages that round-trip through the session
|
|
367
367
|
// store, so a pi->pi fork is full fidelity (see exportForFork/importForFork).
|
|
368
368
|
forkTransport: true,
|
|
369
|
+
// pi can also stand up a session from portable {role,text} history, so a fork
|
|
370
|
+
// FROM another agent INTO pi is a true replay, not a seeded summary.
|
|
371
|
+
forkHistoryImport: true,
|
|
369
372
|
// The pi-coding-agent SDK implements both explicitly: prompting mid-turn
|
|
370
373
|
// with no streamingBehavior hint throws, forcing every caller to choose.
|
|
371
374
|
streamingBehaviors: ["steer", "followUp"],
|
|
@@ -515,4 +518,22 @@ export class PiRuntime {
|
|
|
515
518
|
throw new Error("pi.importForFork: session file was not persisted");
|
|
516
519
|
return { sessionFile, id: sessionManager.getSessionId() };
|
|
517
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* Stand up a pi session from a **cross-runtime** fork's portable history: open
|
|
523
|
+
* a fresh session in the destination workspace and append each `{role, text}`
|
|
524
|
+
* turn as a plain-text message, so the new session resumes on a copy of the
|
|
525
|
+
* whole conversation (fidelity "replayed"). The turns already have any tool
|
|
526
|
+
* activity inlined as text (see buildForkHistory), so nothing here needs to
|
|
527
|
+
* reconstruct provider-specific tool blocks. Never touches the source.
|
|
528
|
+
*/
|
|
529
|
+
async importHistoryForFork(history, ctx) {
|
|
530
|
+
const sessionManager = SessionManager.create(ctx.cwd || ctx.workspace, this.options.sessionsDir);
|
|
531
|
+
for (const message of history) {
|
|
532
|
+
sessionManager.appendMessage({ role: message.role, content: message.text });
|
|
533
|
+
}
|
|
534
|
+
const sessionFile = sessionManager.getSessionFile();
|
|
535
|
+
if (!sessionFile)
|
|
536
|
+
throw new Error("pi.importHistoryForFork: session file was not persisted");
|
|
537
|
+
return { sessionFile, id: sessionManager.getSessionId() };
|
|
538
|
+
}
|
|
518
539
|
}
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -553,6 +553,10 @@ export class ProtocolRuntime {
|
|
|
553
553
|
// and takeover treat it as resumable up front (the ProcessRuntime convention).
|
|
554
554
|
if (options.resumable)
|
|
555
555
|
this.capabilities.resume = true;
|
|
556
|
+
// A runtime that can write its own resumable store from portable history
|
|
557
|
+
// (Codex's rollout) supports true cross-runtime replay forks INTO it.
|
|
558
|
+
if (options.writeHistory)
|
|
559
|
+
this.capabilities.forkHistoryImport = true;
|
|
556
560
|
if (options.capabilities)
|
|
557
561
|
Object.assign(this.capabilities, options.capabilities);
|
|
558
562
|
}
|
|
@@ -580,6 +584,18 @@ export class ProtocolRuntime {
|
|
|
580
584
|
readMessages(sessionFile) {
|
|
581
585
|
return this.options.loadHistory?.(sessionFile);
|
|
582
586
|
}
|
|
587
|
+
/**
|
|
588
|
+
* Materialise a cross-runtime fork's portable history into this agent's own
|
|
589
|
+
* resumable store (fidelity "replayed"), delegating to the runtime-specific
|
|
590
|
+
* `writeHistory` hook (Codex's `writeCodexRollout`). Only present in effect
|
|
591
|
+
* when configured; the fork engine gates on `capabilities.forkHistoryImport`
|
|
592
|
+
* and falls back to a seeded prompt if this throws.
|
|
593
|
+
*/
|
|
594
|
+
async importHistoryForFork(history, ctx) {
|
|
595
|
+
if (!this.options.writeHistory)
|
|
596
|
+
throw new Error(`${this.displayName} does not support history import.`);
|
|
597
|
+
return this.options.writeHistory(history, ctx);
|
|
598
|
+
}
|
|
583
599
|
/** See ProtocolRuntimeOptions.discoverNativeSessions (issue #156). */
|
|
584
600
|
async discoverNativeSessions() {
|
|
585
601
|
try {
|
package/dist/server.js
CHANGED
|
@@ -3141,7 +3141,7 @@ const RELAY_COMMANDS = {
|
|
|
3141
3141
|
// When the client has already picked a target agent, pass it so the
|
|
3142
3142
|
// bundle omits the native payload for a cross-runtime fork (it could
|
|
3143
3143
|
// never be replayed there — see buildForkBundle). Unset => keep it.
|
|
3144
|
-
const bundle = buildForkBundle({ runtime: getRuntime(rec.runtimeId), sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: agentFrom(msg) });
|
|
3144
|
+
const bundle = buildForkBundle({ runtime: getRuntime(rec.runtimeId), sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: agentFrom(msg), liveMessages: rec.session.getMessages() });
|
|
3145
3145
|
relay?.sendEvent({ type: "session.fork.bundle", requestId, bundle });
|
|
3146
3146
|
}
|
|
3147
3147
|
catch (error) {
|
|
@@ -3225,7 +3225,7 @@ const RELAY_COMMANDS = {
|
|
|
3225
3225
|
catch { /* best effort */ }
|
|
3226
3226
|
}
|
|
3227
3227
|
// Same runtime → the bundle carries the native payload → full fidelity.
|
|
3228
|
-
const bundle = buildForkBundle({ runtime, sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: rec.runtimeId });
|
|
3228
|
+
const bundle = buildForkBundle({ runtime, sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: rec.runtimeId, liveMessages: rec.session.getMessages() });
|
|
3229
3229
|
// Cut a fresh fork branch (the source still holds its own); skip prereq
|
|
3230
3230
|
// detection (same node + same runtime ⇒ agent and repo are present).
|
|
3231
3231
|
const outcome = await standUpFork({
|
|
@@ -7577,7 +7577,14 @@ app.get("/api/status", (_req, res) => {
|
|
|
7577
7577
|
workspaceBoundary: true,
|
|
7578
7578
|
strictApprovalOptIn: true,
|
|
7579
7579
|
},
|
|
7580
|
-
relay: {
|
|
7580
|
+
relay: {
|
|
7581
|
+
configured: Boolean(relayConfig),
|
|
7582
|
+
// Real link state (relay sent `ready`), not merely "a connector exists".
|
|
7583
|
+
connected: Boolean(relay?.connected),
|
|
7584
|
+
controlPlaneUrl: relayConfig?.controlPlaneUrl,
|
|
7585
|
+
relayUrl: relayConfig?.url,
|
|
7586
|
+
...(relay?.lastError ? { lastError: relay.lastError } : {}),
|
|
7587
|
+
},
|
|
7581
7588
|
sessions: {
|
|
7582
7589
|
open: new Set(openSessions.values()).size,
|
|
7583
7590
|
indexed: metadata.listSessions().length,
|
package/dist/session/fork.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeMessages, buildSeedPrompt, } from "./transcript-normal.js";
|
|
1
|
+
import { normalizeMessages, buildSeedPrompt, buildForkHistory, } from "./transcript-normal.js";
|
|
2
2
|
/**
|
|
3
3
|
* Capture a fork bundle on the source node. Always includes the normalized
|
|
4
4
|
* transcript (the cross-runtime seed, and the fallback when a same-runtime
|
|
@@ -9,7 +9,10 @@ import { normalizeMessages, buildSeedPrompt, } from "./transcript-normal.js";
|
|
|
9
9
|
*/
|
|
10
10
|
export function buildForkBundle(opts) {
|
|
11
11
|
const { runtime, sessionFile, record } = opts;
|
|
12
|
-
|
|
12
|
+
// Prefer the build-free readMessages fast path (pi/Claude); fall back to the
|
|
13
|
+
// live session's transcript for runtimes without one (the generic CLI runtime),
|
|
14
|
+
// so a fork *from* any agent still carries its real history.
|
|
15
|
+
const messages = runtime.readMessages?.(sessionFile) ?? opts.liveMessages;
|
|
13
16
|
const normalized = normalizeMessages(messages, {
|
|
14
17
|
sourceRuntimeId: runtime.id,
|
|
15
18
|
model: record.model,
|
|
@@ -24,30 +27,60 @@ export function buildForkBundle(opts) {
|
|
|
24
27
|
return { record, normalized, ...(native ? { native } : {}), ...(opts.dirtyPatch ? { dirtyPatch: opts.dirtyPatch } : {}) };
|
|
25
28
|
}
|
|
26
29
|
/**
|
|
27
|
-
* Decide the fidelity a fork of `bundle` into `targetRuntime` can achieve
|
|
28
|
-
* "full"
|
|
29
|
-
*
|
|
30
|
+
* Decide the best fidelity a fork of `bundle` into `targetRuntime` can achieve:
|
|
31
|
+
* - "full" when the target is the SAME runtime that produced the native
|
|
32
|
+
* payload and can import it (byte-exact resume);
|
|
33
|
+
* - "replayed" when a *different* target can import portable history
|
|
34
|
+
* (`forkHistoryImport`) and there is history to replay — a true
|
|
35
|
+
* fork onto a copy of the transcript;
|
|
36
|
+
* - "seeded" otherwise.
|
|
37
|
+
* Pure; no side effects. `materializeFork` degrades "replayed"→"seeded" if the
|
|
38
|
+
* import fails at run time, so this only reports the *intended* fidelity.
|
|
30
39
|
*/
|
|
31
40
|
export function resolveForkFidelity(bundle, targetRuntime) {
|
|
32
41
|
const native = bundle.native;
|
|
33
|
-
const
|
|
42
|
+
const canImportNative = !!native &&
|
|
34
43
|
native.runtimeId === targetRuntime.id &&
|
|
35
44
|
!!targetRuntime.capabilities.forkTransport &&
|
|
36
45
|
typeof targetRuntime.importForFork === "function";
|
|
37
|
-
|
|
46
|
+
if (canImportNative)
|
|
47
|
+
return "full";
|
|
48
|
+
const canReplayHistory = !!targetRuntime.capabilities.forkHistoryImport &&
|
|
49
|
+
typeof targetRuntime.importHistoryForFork === "function" &&
|
|
50
|
+
bundle.normalized.turns.length > 0;
|
|
51
|
+
return canReplayHistory ? "replayed" : "seeded";
|
|
38
52
|
}
|
|
39
53
|
/**
|
|
40
54
|
* Turn a fork bundle into a concrete stand-up plan on the destination node:
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
55
|
+
* resume a natively imported transcript (full), replay the portable transcript
|
|
56
|
+
* as real history in the target's own store (replayed — a true cross-runtime
|
|
57
|
+
* fork), or a seed prompt for a fresh session (seeded). The server executes the
|
|
58
|
+
* returned plan (worktree + session creation live there); this stays pure of
|
|
59
|
+
* daemon wiring.
|
|
44
60
|
*/
|
|
45
61
|
export async function materializeFork(opts) {
|
|
46
62
|
const { bundle, targetRuntime, ctx } = opts;
|
|
47
|
-
|
|
63
|
+
const fidelity = resolveForkFidelity(bundle, targetRuntime);
|
|
64
|
+
if (fidelity === "full" && bundle.native && targetRuntime.importForFork) {
|
|
48
65
|
const { sessionFile, id } = await targetRuntime.importForFork(bundle.native, ctx);
|
|
49
66
|
return { kind: "resume", fidelity: "full", sessionFile, id };
|
|
50
67
|
}
|
|
68
|
+
// True cross-runtime fork: write the whole transcript as real prior turns into
|
|
69
|
+
// the target runtime's own store and resume it. Best-effort — if the runtime's
|
|
70
|
+
// history import throws (a malformed store, an unwritable dir), fall through to
|
|
71
|
+
// a seeded prompt so the fork still succeeds rather than erroring outright.
|
|
72
|
+
if (fidelity === "replayed" && targetRuntime.importHistoryForFork) {
|
|
73
|
+
try {
|
|
74
|
+
const history = buildForkHistory(bundle.normalized);
|
|
75
|
+
if (history.length > 0) {
|
|
76
|
+
const { sessionFile, id } = await targetRuntime.importHistoryForFork(history, ctx);
|
|
77
|
+
return { kind: "resume", fidelity: "replayed", sessionFile, id };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// fall through to the seeded continuation below
|
|
82
|
+
}
|
|
83
|
+
}
|
|
51
84
|
const seedPrompt = buildSeedPrompt(bundle.normalized, {
|
|
52
85
|
targetAgent: targetRuntime.displayName,
|
|
53
86
|
context: { repoSlug: bundle.record.repoSlug, branch: bundle.record.branch, prUrl: bundle.record.prUrl },
|
|
@@ -90,26 +90,45 @@ function truncate(text, max) {
|
|
|
90
90
|
return compact.length > max ? `${compact.slice(0, Math.max(0, max - 1))}…` : compact;
|
|
91
91
|
}
|
|
92
92
|
/**
|
|
93
|
-
* Render a
|
|
93
|
+
* Render a continuation prompt for a **seeded** (cross-runtime) fork — the
|
|
94
|
+
* fallback when the target runtime can't replay history into its own store.
|
|
94
95
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
96
|
+
* The recent-conversation block is **budget-adaptive**, not a fixed tail: it
|
|
97
|
+
* walks backward from the latest turn packing verbatim turns until `charBudget`
|
|
98
|
+
* is reached (or the optional `recentTurns` count cap is hit), so a long run of
|
|
99
|
+
* short turns carries far more context than the old fixed 12, while a few
|
|
100
|
+
* verbose turns still stay bounded for the target's context window and cost. Any
|
|
101
|
+
* turns that don't fit are summarised as an omission count that points at the
|
|
102
|
+
* full transcript — the complete history is one link away. A structured superset
|
|
103
|
+
* of the old client-side `sessionHandoffSummary`.
|
|
99
104
|
*/
|
|
100
105
|
export function buildSeedPrompt(transcript, opts = {}) {
|
|
101
|
-
const recentTurns = opts.recentTurns ?? 12;
|
|
102
106
|
const perTurnChars = opts.perTurnChars ?? 700;
|
|
107
|
+
const charBudget = opts.charBudget ?? 12000;
|
|
108
|
+
const maxCount = opts.recentTurns ?? Number.POSITIVE_INFINITY;
|
|
103
109
|
const title = transcript.header.title || "Untitled session";
|
|
104
110
|
const targetAgent = opts.targetAgent || "a new agent";
|
|
105
|
-
const
|
|
111
|
+
const formatted = transcript.turns
|
|
106
112
|
.filter((t) => t.text || t.toolSummary)
|
|
107
|
-
.slice(-recentTurns)
|
|
108
113
|
.map((t) => {
|
|
109
114
|
const body = t.text || (t.toolSummary ? `[${t.toolName ?? "tool"}] ${t.toolSummary}` : "");
|
|
110
115
|
return `- ${t.role}: ${truncate(body, perTurnChars)}`;
|
|
111
|
-
})
|
|
112
|
-
|
|
116
|
+
});
|
|
117
|
+
// Pack the newest turns first, within both the char budget and the count cap.
|
|
118
|
+
// The most recent turn is always kept, even if it alone exceeds the budget, so
|
|
119
|
+
// the seed is never empty.
|
|
120
|
+
const picked = [];
|
|
121
|
+
let used = 0;
|
|
122
|
+
for (let i = formatted.length - 1; i >= 0 && picked.length < maxCount; i -= 1) {
|
|
123
|
+
const cost = formatted[i].length + 1;
|
|
124
|
+
if (picked.length > 0 && used + cost > charBudget)
|
|
125
|
+
break;
|
|
126
|
+
picked.push(formatted[i]);
|
|
127
|
+
used += cost;
|
|
128
|
+
}
|
|
129
|
+
picked.reverse();
|
|
130
|
+
const omitted = formatted.length - picked.length;
|
|
131
|
+
const recent = picked.length ? picked.join("\n") : "- (no prior turns were available)";
|
|
113
132
|
const lines = [
|
|
114
133
|
`I am continuing an existing Bivy session (forked from ${transcript.header.sourceRuntimeId} to ${targetAgent}).`,
|
|
115
134
|
`Session: ${title}`,
|
|
@@ -119,8 +138,10 @@ export function buildSeedPrompt(transcript, opts = {}) {
|
|
|
119
138
|
opts.context?.branch ? `Branch: ${opts.context.branch}` : null,
|
|
120
139
|
opts.context?.prUrl ? `PR: ${opts.context.prUrl}` : null,
|
|
121
140
|
"",
|
|
122
|
-
|
|
123
|
-
|
|
141
|
+
omitted > 0
|
|
142
|
+
? `Recent conversation (most recent last; ${omitted} earlier turn${omitted === 1 ? "" : "s"} omitted — see the full transcript${opts.transcriptUrl ? " linked above" : ""}):`
|
|
143
|
+
: "Recent conversation (most recent last):",
|
|
144
|
+
recent,
|
|
124
145
|
"",
|
|
125
146
|
opts.transcriptUrl
|
|
126
147
|
? "Open the full transcript link above if this summary is missing anything, then continue from here."
|
|
@@ -128,3 +149,47 @@ export function buildSeedPrompt(transcript, opts = {}) {
|
|
|
128
149
|
];
|
|
129
150
|
return lines.filter((line) => line != null).join("\n");
|
|
130
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Render a normalized transcript as portable `{role, text}` turns for a
|
|
154
|
+
* **replayed** ("true fork") cross-runtime fork — the target runtime writes
|
|
155
|
+
* these as real prior conversation into its own store and resumes, so the new
|
|
156
|
+
* agent opens on a copy of the whole history instead of a seeded summary.
|
|
157
|
+
*
|
|
158
|
+
* Unlike `buildSeedPrompt` this keeps EVERY turn, not just the tail, and keeps
|
|
159
|
+
* each turn's own role instead of flattening the conversation into one user
|
|
160
|
+
* prompt. Two deliberate shaping rules keep the result valid on any target model:
|
|
161
|
+
* - Tool activity is inlined as plain text (`[ran X] …`, `[tool result] …`),
|
|
162
|
+
* never as provider-specific `tool_use`/`tool_result` blocks whose ids/schemas
|
|
163
|
+
* would dangle or mismatch in a different runtime.
|
|
164
|
+
* - Non-conversational roles fold into the model's voice: a pure tool-result
|
|
165
|
+
* turn and a system/error notice both attach as `assistant` text (the agent's
|
|
166
|
+
* own work), so only the human's turns ever carry the `user` role.
|
|
167
|
+
* Consecutive same-role turns are merged so the resumed history reads as clean
|
|
168
|
+
* alternating turns. Tool payloads inherit `normalizeMessages`' compaction, so a
|
|
169
|
+
* replayed fork is faithful in its prose but summarised in raw tool I/O — the
|
|
170
|
+
* working tree (carried separately as a dirty patch) holds the real file state.
|
|
171
|
+
*/
|
|
172
|
+
export function buildForkHistory(transcript) {
|
|
173
|
+
const history = [];
|
|
174
|
+
for (const turn of transcript.turns) {
|
|
175
|
+
const role = turn.role === "user" ? "user" : "assistant";
|
|
176
|
+
const parts = [];
|
|
177
|
+
if (turn.role === "error" && turn.text)
|
|
178
|
+
parts.push(`[system] ${turn.text}`);
|
|
179
|
+
else if (turn.text)
|
|
180
|
+
parts.push(turn.text);
|
|
181
|
+
if (turn.toolSummary) {
|
|
182
|
+
const label = turn.role === "tool" ? "tool result" : turn.toolName ? `ran ${turn.toolName}` : "tool";
|
|
183
|
+
parts.push(`[${label}] ${turn.toolSummary}`);
|
|
184
|
+
}
|
|
185
|
+
const text = parts.join("\n\n").trim();
|
|
186
|
+
if (!text)
|
|
187
|
+
continue;
|
|
188
|
+
const last = history[history.length - 1];
|
|
189
|
+
if (last && last.role === role)
|
|
190
|
+
last.text = `${last.text}\n\n${text}`;
|
|
191
|
+
else
|
|
192
|
+
history.push({ role, text });
|
|
193
|
+
}
|
|
194
|
+
return history;
|
|
195
|
+
}
|
package/package.json
CHANGED