@bivy/bivy 0.0.0 → 0.1.0-staging.2
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/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- package/public/qr.js +464 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Ship a session's git checkpoint between the owner node's repo and a standby's
|
|
5
|
+
// replica repo as a git BUNDLE (docs/session-replication.md, worktree sync).
|
|
6
|
+
//
|
|
7
|
+
// The harness records each turn's checkpoint as a commit under the private ref
|
|
8
|
+
// `refs/bivy/checkpoints/<slug>` (src/harness/checkpoint.ts). To replicate the
|
|
9
|
+
// workspace we move those commits — nothing else — to the standby:
|
|
10
|
+
//
|
|
11
|
+
// owner: git bundle create <tmp> [<sinceSha>..]<ref> → bytes over the wire
|
|
12
|
+
// standby: git fetch <tmp> <ref>:<ref> → objects land locally
|
|
13
|
+
// git read-tree <ref> && git checkout-index -a -f → materialize worktree
|
|
14
|
+
//
|
|
15
|
+
// A bundle is a self-contained, verifiable pack: when `sinceSha` is given it is a
|
|
16
|
+
// THIN bundle carrying only the new commits (the standby already holds the base),
|
|
17
|
+
// falling back to a FULL bundle when the standby has nothing or its base is gone.
|
|
18
|
+
// If a thin bundle's prerequisite is missing on the standby, `applyCheckpointBundle`
|
|
19
|
+
// reports `needFull` so the owner re-sends a full one — the same self-healing shape
|
|
20
|
+
// as the transcript cursor.
|
|
21
|
+
//
|
|
22
|
+
// Kept dependency-light (just `git` + a temp file) and unit-tested against real
|
|
23
|
+
// repos (test/checkpoint-pack.test.ts).
|
|
24
|
+
import { execFile } from "node:child_process";
|
|
25
|
+
import { promisify } from "node:util";
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
const exec = promisify(execFile);
|
|
31
|
+
const IDENTITY_ENV = {
|
|
32
|
+
GIT_AUTHOR_NAME: "Bivy Harness",
|
|
33
|
+
GIT_AUTHOR_EMAIL: "harness@bivy.local",
|
|
34
|
+
GIT_COMMITTER_NAME: "Bivy Harness",
|
|
35
|
+
GIT_COMMITTER_EMAIL: "harness@bivy.local",
|
|
36
|
+
};
|
|
37
|
+
/** The private checkpoint ref for a session (mirrors CheckpointStore.slugRef). */
|
|
38
|
+
export function checkpointRef(sessionId) {
|
|
39
|
+
const slug = sessionId.replace(/[^A-Za-z0-9._-]/g, "-");
|
|
40
|
+
return `refs/bivy/checkpoints/${slug}`;
|
|
41
|
+
}
|
|
42
|
+
function git(cwd, args) {
|
|
43
|
+
return exec("git", ["-C", cwd, ...args], {
|
|
44
|
+
env: { ...process.env, ...IDENTITY_ENV },
|
|
45
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function withTempFile(fn) {
|
|
49
|
+
const file = path.join(os.tmpdir(), `bivy-ckpt-${randomUUID()}.bundle`);
|
|
50
|
+
try {
|
|
51
|
+
return await fn(file);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await fs.promises.rm(file, { force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build a bundle carrying the session's checkpoint commit(s). Returns the bundle
|
|
59
|
+
* bytes, or `null` when there is nothing to send (no checkpoint ref yet, or the
|
|
60
|
+
* standby is already at the tip so an incremental range would be empty).
|
|
61
|
+
*
|
|
62
|
+
* @param repoDir the owner's worktree (any path inside the repo)
|
|
63
|
+
* @param sessionId the session whose checkpoint ref to bundle
|
|
64
|
+
* @param sinceSha the standby's last-known checkpoint sha; when it is an ancestor
|
|
65
|
+
* of the current tip, only the delta is bundled (thin)
|
|
66
|
+
*/
|
|
67
|
+
export async function createCheckpointBundle(repoDir, sessionId, sinceSha) {
|
|
68
|
+
const ref = checkpointRef(sessionId);
|
|
69
|
+
// No checkpoint ref → nothing to replicate yet.
|
|
70
|
+
const tip = await git(repoDir, ["rev-parse", "--verify", "--quiet", ref]).then((r) => r.stdout.trim()).catch(() => "");
|
|
71
|
+
if (!tip)
|
|
72
|
+
return null;
|
|
73
|
+
// Already current — skip an empty bundle.
|
|
74
|
+
if (sinceSha && sinceSha === tip)
|
|
75
|
+
return null;
|
|
76
|
+
// Use a thin range only when `sinceSha` is a real ancestor we can negate against.
|
|
77
|
+
let range = ref;
|
|
78
|
+
if (sinceSha) {
|
|
79
|
+
const isAncestor = await git(repoDir, ["merge-base", "--is-ancestor", sinceSha, ref]).then(() => true).catch(() => false);
|
|
80
|
+
if (isAncestor)
|
|
81
|
+
range = `${sinceSha}..${ref}`;
|
|
82
|
+
}
|
|
83
|
+
return withTempFile(async (file) => {
|
|
84
|
+
try {
|
|
85
|
+
await git(repoDir, ["bundle", "create", file, range]);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// An empty range ("Refusing to create empty bundle") or any bundle failure
|
|
89
|
+
// → nothing to ship.
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return fs.promises.readFile(file);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Apply a checkpoint bundle into the standby's replica repo, advancing its
|
|
97
|
+
* checkpoint ref. Returns `{ ok:false, needFull:true }` when the bundle is thin
|
|
98
|
+
* and its prerequisite base commit is missing locally, so the caller can request
|
|
99
|
+
* a full bundle. Does NOT touch the working tree — call `materializeCheckpoint`
|
|
100
|
+
* for that once the objects are present.
|
|
101
|
+
*/
|
|
102
|
+
export async function applyCheckpointBundle(replicaDir, sessionId, bundle) {
|
|
103
|
+
const ref = checkpointRef(sessionId);
|
|
104
|
+
return withTempFile(async (file) => {
|
|
105
|
+
await fs.promises.writeFile(file, bundle);
|
|
106
|
+
// A thin bundle with an absent prerequisite fails verification here.
|
|
107
|
+
const verified = await git(replicaDir, ["bundle", "verify", file]).then(() => true).catch(() => false);
|
|
108
|
+
if (!verified)
|
|
109
|
+
return { ok: false, needFull: true };
|
|
110
|
+
await git(replicaDir, ["fetch", file, `${ref}:${ref}`]);
|
|
111
|
+
return { ok: true };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Materialize the standby's checkpoint ref into its replica working tree, so a
|
|
116
|
+
* promoted session continues from the exact files of the last replicated turn.
|
|
117
|
+
* Uses a throwaway index and force checkout — the replica worktree is ours to own.
|
|
118
|
+
*/
|
|
119
|
+
export async function materializeCheckpoint(replicaDir, sessionId) {
|
|
120
|
+
const ref = checkpointRef(sessionId);
|
|
121
|
+
const tmpIndex = path.join(os.tmpdir(), `bivy-ckpt-index-${randomUUID()}`);
|
|
122
|
+
try {
|
|
123
|
+
const withIndex = (args) => exec("git", ["-C", replicaDir, ...args], {
|
|
124
|
+
env: { ...process.env, ...IDENTITY_ENV, GIT_INDEX_FILE: tmpIndex },
|
|
125
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
126
|
+
});
|
|
127
|
+
await withIndex(["read-tree", ref]);
|
|
128
|
+
await withIndex(["checkout-index", "-a", "-f"]);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await fs.promises.rm(tmpIndex, { force: true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Single append-only per-session event log (docs/dramatic-simplification-plan.md,
|
|
5
|
+
// slice 2). A session's transcript detail used to live in THREE whole-file JSON
|
|
6
|
+
// stores — `intermediate-messages/<id>.json`, `tool-activities/<id>.json`, and the
|
|
7
|
+
// base transcript `transcripts/<id>.json` — each rewritten in full on every change
|
|
8
|
+
// and each able to drift from the others. This collapses all of them into one
|
|
9
|
+
// append-only JSONL log (`event-log/<id>.jsonl`): writes append, and history is
|
|
10
|
+
// DERIVED by replaying the log.
|
|
11
|
+
//
|
|
12
|
+
// The log carries two independent projections, distinguished by `bivyKind`:
|
|
13
|
+
// - OVERLAY entries (`intermediate` / `tool`): reasoning + tool-activity detail,
|
|
14
|
+
// folded by `replayExtras` (reproducing the legacy per-kind fold) into the flat
|
|
15
|
+
// `extras` list `mergeTranscript` consumes.
|
|
16
|
+
// - BASE entries (`base`): the runtime's own transcript (user prompts + assistant
|
|
17
|
+
// text). The base is a SNAPSHOT that is overwritten each turn and SHRINKS on
|
|
18
|
+
// compaction, so it cannot be appended naively. Instead each snapshot is stored
|
|
19
|
+
// as a bounded DELTA against the previous one: a `reset:false` record carrying
|
|
20
|
+
// only the new tail when the snapshot merely extends the last (the common case),
|
|
21
|
+
// or a `reset:true` record carrying the full snapshot when it shrinks or a
|
|
22
|
+
// prefix message changed (compaction / in-place mutation). `baseReplay` folds
|
|
23
|
+
// these back — reset replaces, non-reset extends — reproducing the last snapshot
|
|
24
|
+
// exactly, so the fold is behaviour-preserving while keeping the file bounded
|
|
25
|
+
// (~O(total messages)) instead of O(turns × transcript size).
|
|
26
|
+
//
|
|
27
|
+
// The two projections are independent: base records and overlay records may be
|
|
28
|
+
// interleaved in any order on disk; each replay reads only its own kind.
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript } from "./transcript-merge.js";
|
|
31
|
+
function isOverlay(value) {
|
|
32
|
+
if (!value || typeof value !== "object")
|
|
33
|
+
return false;
|
|
34
|
+
const kind = value.bivyKind;
|
|
35
|
+
return (kind === "intermediate" || kind === "tool") && typeof value.afterMessageCount === "number";
|
|
36
|
+
}
|
|
37
|
+
function isBase(value) {
|
|
38
|
+
if (!value || typeof value !== "object")
|
|
39
|
+
return false;
|
|
40
|
+
const record = value;
|
|
41
|
+
return record.bivyKind === "base" && Array.isArray(record.messages) && typeof record.reset === "boolean";
|
|
42
|
+
}
|
|
43
|
+
function isRecord(value) {
|
|
44
|
+
return isOverlay(value) || isBase(value);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Fold the intermediate-reasoning entries exactly as the legacy incremental
|
|
48
|
+
* upsert did (`upsertIntermediateMessage` in server.ts): last write wins per id;
|
|
49
|
+
* a same-text/same-anchor entry under a DIFFERENT id is merged onto the first
|
|
50
|
+
* (keeping its id and the earlier `createdAt`); the result is ordered by
|
|
51
|
+
* `afterMessageCount` then `createdAt`. Replaying the append log through this
|
|
52
|
+
* reproduces the array the legacy store held.
|
|
53
|
+
*/
|
|
54
|
+
export function foldIntermediate(entries) {
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const entryText = normalizedIntermediateText(thinkingTextFromContent(entry.content));
|
|
58
|
+
const index = out.findIndex((m) => m.id != null && m.id === entry.id);
|
|
59
|
+
if (index >= 0) {
|
|
60
|
+
out[index] = entry;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const dup = entryText
|
|
64
|
+
? out.findIndex((m) => m.id !== entry.id && m.afterMessageCount === entry.afterMessageCount && normalizedIntermediateText(thinkingTextFromContent(m.content)) === entryText)
|
|
65
|
+
: -1;
|
|
66
|
+
if (dup >= 0)
|
|
67
|
+
out[dup] = { ...out[dup], ...entry, id: out[dup].id, createdAt: Math.min(out[dup].createdAt, entry.createdAt) };
|
|
68
|
+
else
|
|
69
|
+
out.push(entry);
|
|
70
|
+
}
|
|
71
|
+
out.sort((a, b) => a.afterMessageCount - b.afterMessageCount || a.createdAt - b.createdAt);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Fold the tool-activity entries exactly as the legacy `upsertToolActivityMessage`
|
|
77
|
+
* did: merge onto an existing id (`{...existing, ...entry}`), else append; order by
|
|
78
|
+
* `afterMessageCount` then `createdAt`; keep only the most recent 500.
|
|
79
|
+
*/
|
|
80
|
+
export function foldTool(entries) {
|
|
81
|
+
let out = [];
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const index = out.findIndex((m) => m.id != null && m.id === entry.id);
|
|
84
|
+
if (index >= 0)
|
|
85
|
+
out[index] = { ...out[index], ...entry };
|
|
86
|
+
else
|
|
87
|
+
out.push(entry);
|
|
88
|
+
out.sort((a, b) => a.afterMessageCount - b.afterMessageCount || a.createdAt - b.createdAt);
|
|
89
|
+
out = out.slice(-500);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Replay a session's overlay entries into the flat `extras` list `mergeTranscript`
|
|
95
|
+
* consumes — the intermediate entries folded first, then the tool entries, matching
|
|
96
|
+
* the legacy `[...loadIntermediateMessages, ...loadToolActivityMessages]` order.
|
|
97
|
+
* Base records are ignored (they are folded separately by `baseReplay`).
|
|
98
|
+
*/
|
|
99
|
+
export function replayExtras(entries) {
|
|
100
|
+
const intermediate = [];
|
|
101
|
+
const tool = [];
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (entry.bivyKind === "intermediate")
|
|
104
|
+
intermediate.push(entry);
|
|
105
|
+
else if (entry.bivyKind === "tool")
|
|
106
|
+
tool.push(entry);
|
|
107
|
+
}
|
|
108
|
+
return [...foldIntermediate(intermediate), ...foldTool(tool)];
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Replay a session's base records into the base transcript: `reset` replaces the
|
|
112
|
+
* working array, non-reset extends it with its tail. The result equals the last
|
|
113
|
+
* full snapshot the server persisted, exactly. Overlay records are ignored.
|
|
114
|
+
*/
|
|
115
|
+
export function baseReplay(entries) {
|
|
116
|
+
let base = [];
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (entry.bivyKind !== "base")
|
|
119
|
+
continue;
|
|
120
|
+
if (entry.reset)
|
|
121
|
+
base = [...entry.messages];
|
|
122
|
+
else
|
|
123
|
+
base = base.concat(entry.messages);
|
|
124
|
+
}
|
|
125
|
+
return base;
|
|
126
|
+
}
|
|
127
|
+
/** Parse a JSONL log body into valid records, skipping malformed/blank lines. */
|
|
128
|
+
export function parseLog(body) {
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const line of body.split("\n")) {
|
|
131
|
+
const trimmed = line.trim();
|
|
132
|
+
if (!trimmed)
|
|
133
|
+
continue;
|
|
134
|
+
try {
|
|
135
|
+
const value = JSON.parse(trimmed);
|
|
136
|
+
if (isRecord(value))
|
|
137
|
+
out.push(value);
|
|
138
|
+
}
|
|
139
|
+
catch { }
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Append-only, coalesced, throttled JSONL log store — the write+read companion to
|
|
145
|
+
* the legacy `SidecarStore`, but appending lines instead of rewriting the whole
|
|
146
|
+
* file. In-memory state keeps reads cheap; disk writes coalesce to at most one per
|
|
147
|
+
* `throttleMs` per session (a burst of same-id deltas within a window collapses to
|
|
148
|
+
* one appended line). Durability at boundaries is explicit `flush()` (turn-end,
|
|
149
|
+
* close, delete, shutdown), mirroring `SidecarStore`.
|
|
150
|
+
*/
|
|
151
|
+
export class EventLog {
|
|
152
|
+
dir;
|
|
153
|
+
pathFor;
|
|
154
|
+
redact;
|
|
155
|
+
throttleMs;
|
|
156
|
+
disk = new Map();
|
|
157
|
+
pending = new Map();
|
|
158
|
+
timers = new Map();
|
|
159
|
+
lastFlush = new Map();
|
|
160
|
+
counters = new Map();
|
|
161
|
+
// Per-message JSON of the base snapshot the log currently represents, so the next
|
|
162
|
+
// snapshot can be diffed (prefix-compared) into a bounded delta. Seeded from disk
|
|
163
|
+
// on first use of a session after a restart.
|
|
164
|
+
baseKeys = new Map();
|
|
165
|
+
constructor(dir, pathFor, redact = (t) => t, throttleMs = 500) {
|
|
166
|
+
this.dir = dir;
|
|
167
|
+
this.pathFor = pathFor;
|
|
168
|
+
this.redact = redact;
|
|
169
|
+
this.throttleMs = throttleMs;
|
|
170
|
+
}
|
|
171
|
+
load(id) {
|
|
172
|
+
const cached = this.disk.get(id);
|
|
173
|
+
if (cached)
|
|
174
|
+
return cached;
|
|
175
|
+
let data = [];
|
|
176
|
+
try {
|
|
177
|
+
data = parseLog(fs.readFileSync(this.pathFor(id), "utf8"));
|
|
178
|
+
}
|
|
179
|
+
catch { }
|
|
180
|
+
this.disk.set(id, data);
|
|
181
|
+
return data;
|
|
182
|
+
}
|
|
183
|
+
/** Allocate a unique synthetic coalescing key (for id-less records). */
|
|
184
|
+
syntheticKey(id) {
|
|
185
|
+
const next = (this.counters.get(id) ?? 0) + 1;
|
|
186
|
+
this.counters.set(id, next);
|
|
187
|
+
return `#${next}`;
|
|
188
|
+
}
|
|
189
|
+
/** Queue a fully-formed record into the pending batch and schedule a flush. */
|
|
190
|
+
enqueue(id, key, record) {
|
|
191
|
+
let batch = this.pending.get(id);
|
|
192
|
+
if (!batch) {
|
|
193
|
+
batch = new Map();
|
|
194
|
+
this.pending.set(id, batch);
|
|
195
|
+
}
|
|
196
|
+
// Same key within a flush window coalesces to one line (streaming deltas of one
|
|
197
|
+
// logical entry). Id-less records get a unique synthetic key so they never merge.
|
|
198
|
+
batch.set(key, record);
|
|
199
|
+
if (this.timers.has(id))
|
|
200
|
+
return; // a trailing flush is already scheduled
|
|
201
|
+
const elapsed = Date.now() - (this.lastFlush.get(id) ?? 0);
|
|
202
|
+
if (elapsed >= this.throttleMs)
|
|
203
|
+
this.flush(id);
|
|
204
|
+
else
|
|
205
|
+
this.timers.set(id, setTimeout(() => { this.timers.delete(id); this.flush(id); }, this.throttleMs - elapsed));
|
|
206
|
+
}
|
|
207
|
+
/** Coalesce an overlay entry into the pending batch and schedule a flush. */
|
|
208
|
+
append(id, entry) {
|
|
209
|
+
this.load(id);
|
|
210
|
+
// Snapshot the entry: the server reuses one live object for a streaming reasoning
|
|
211
|
+
// block, reassigning `.content` per delta. A shallow copy captures the state at
|
|
212
|
+
// append time so the pending/on-disk record can't change under us.
|
|
213
|
+
this.enqueue(id, entry.id ?? this.syntheticKey(id), { ...entry });
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Record the runtime's current base transcript snapshot as a bounded delta. The
|
|
217
|
+
* snapshot is diffed against the base the log already represents: a prefix-extend
|
|
218
|
+
* appends only the new tail (`reset:false`), anything else appends a full replace
|
|
219
|
+
* (`reset:true`). A no-op (identical snapshot) appends nothing. The stored messages
|
|
220
|
+
* are a deep JSON copy, so a later in-place mutation of the runtime's objects can't
|
|
221
|
+
* change what was recorded.
|
|
222
|
+
*/
|
|
223
|
+
appendBaseSnapshot(id, messages) {
|
|
224
|
+
this.load(id);
|
|
225
|
+
let prevKeys = this.baseKeys.get(id);
|
|
226
|
+
if (!prevKeys) {
|
|
227
|
+
prevKeys = baseReplay(this.entries(id)).map((m) => JSON.stringify(m));
|
|
228
|
+
this.baseKeys.set(id, prevKeys);
|
|
229
|
+
}
|
|
230
|
+
const nextKeys = messages.map((m) => JSON.stringify(m));
|
|
231
|
+
// Identical snapshot → nothing to record.
|
|
232
|
+
if (nextKeys.length === prevKeys.length && prevKeys.every((k, i) => k === nextKeys[i]))
|
|
233
|
+
return;
|
|
234
|
+
// A prefix-EXTEND (strictly longer, every prior message unchanged) appends only
|
|
235
|
+
// the new tail; anything else — a shrink (compaction), a changed prefix message
|
|
236
|
+
// (in-place mutation), or the first snapshot (empty prev, nothing to extend) —
|
|
237
|
+
// appends a full reset. Rebuild the recorded messages from the serialized form:
|
|
238
|
+
// a free deep copy that also matches exactly what will land on disk.
|
|
239
|
+
const extend = prevKeys.length > 0 && nextKeys.length > prevKeys.length && prevKeys.every((k, i) => k === nextKeys[i]);
|
|
240
|
+
const record = extend
|
|
241
|
+
? { bivyKind: "base", reset: false, createdAt: Date.now(), messages: nextKeys.slice(prevKeys.length).map((s) => JSON.parse(s)) }
|
|
242
|
+
: { bivyKind: "base", reset: true, createdAt: Date.now(), messages: nextKeys.map((s) => JSON.parse(s)) };
|
|
243
|
+
this.baseKeys.set(id, nextKeys);
|
|
244
|
+
this.enqueue(id, this.syntheticKey(id), record);
|
|
245
|
+
}
|
|
246
|
+
/** Replay the overlay entries (disk + pending) into the flat `extras` list. */
|
|
247
|
+
read(id) {
|
|
248
|
+
return replayExtras(this.entries(id));
|
|
249
|
+
}
|
|
250
|
+
/** Replay the base records (disk + pending) into the base transcript. */
|
|
251
|
+
readBase(id) {
|
|
252
|
+
return baseReplay(this.entries(id));
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The full derived conversation: overlay detail merged into the base transcript.
|
|
256
|
+
* Prefers the runtime's own live transcript when it has one; otherwise replays the
|
|
257
|
+
* base persisted in the log (a reopened session on a runtime that can't rebuild it).
|
|
258
|
+
* This is the single read path — it absorbs the former `mergeConversation` helper.
|
|
259
|
+
*/
|
|
260
|
+
deriveHistory(id, runtimeBase) {
|
|
261
|
+
const base = runtimeBase && runtimeBase.length ? runtimeBase : this.readBase(id);
|
|
262
|
+
return mergeTranscript(base, this.read(id));
|
|
263
|
+
}
|
|
264
|
+
/** Full ordered record list (already-flushed followed by pending). */
|
|
265
|
+
entries(id) {
|
|
266
|
+
const disk = this.load(id);
|
|
267
|
+
const batch = this.pending.get(id);
|
|
268
|
+
return batch && batch.size ? [...disk, ...batch.values()] : disk;
|
|
269
|
+
}
|
|
270
|
+
/** Whether this session has any overlay entries (in memory or on disk). */
|
|
271
|
+
hasEntries(id) {
|
|
272
|
+
return this.entries(id).some((e) => e.bivyKind === "intermediate" || e.bivyKind === "tool");
|
|
273
|
+
}
|
|
274
|
+
/** Whether this session has any base record (in memory or on disk). */
|
|
275
|
+
hasBase(id) {
|
|
276
|
+
return this.entries(id).some((e) => e.bivyKind === "base");
|
|
277
|
+
}
|
|
278
|
+
flush(id) {
|
|
279
|
+
const timer = this.timers.get(id);
|
|
280
|
+
if (timer) {
|
|
281
|
+
clearTimeout(timer);
|
|
282
|
+
this.timers.delete(id);
|
|
283
|
+
}
|
|
284
|
+
const batch = this.pending.get(id);
|
|
285
|
+
if (!batch || !batch.size)
|
|
286
|
+
return;
|
|
287
|
+
const lines = [...batch.values()];
|
|
288
|
+
try {
|
|
289
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
290
|
+
fs.appendFileSync(this.pathFor(id), this.redact(lines.map((e) => JSON.stringify(e)).join("\n") + "\n"));
|
|
291
|
+
const disk = this.load(id);
|
|
292
|
+
disk.push(...lines);
|
|
293
|
+
batch.clear();
|
|
294
|
+
this.lastFlush.set(id, Date.now());
|
|
295
|
+
}
|
|
296
|
+
catch { }
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Overwrite a session's log with a known-complete set of records. Used by the
|
|
300
|
+
* one-time legacy→log migration to seed the log before it becomes the sole source.
|
|
301
|
+
* Replaces the file and the in-memory state and discards any pending batch, since
|
|
302
|
+
* `entries` is authoritative. The replays re-fold on read, so passing already-folded
|
|
303
|
+
* records is exact and idempotent.
|
|
304
|
+
*/
|
|
305
|
+
rewrite(id, entries) {
|
|
306
|
+
const copy = entries.map((e) => ({ ...e }));
|
|
307
|
+
const timer = this.timers.get(id);
|
|
308
|
+
if (timer) {
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
this.timers.delete(id);
|
|
311
|
+
}
|
|
312
|
+
this.pending.delete(id);
|
|
313
|
+
this.baseKeys.delete(id);
|
|
314
|
+
this.disk.set(id, copy);
|
|
315
|
+
try {
|
|
316
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
317
|
+
const body = copy.length ? copy.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
|
|
318
|
+
fs.writeFileSync(this.pathFor(id), this.redact(body));
|
|
319
|
+
this.lastFlush.set(id, Date.now());
|
|
320
|
+
}
|
|
321
|
+
catch { }
|
|
322
|
+
}
|
|
323
|
+
/** Cancel any pending write and forget the session (used when it's deleted). */
|
|
324
|
+
drop(id) {
|
|
325
|
+
const timer = this.timers.get(id);
|
|
326
|
+
if (timer) {
|
|
327
|
+
clearTimeout(timer);
|
|
328
|
+
this.timers.delete(id);
|
|
329
|
+
}
|
|
330
|
+
this.disk.delete(id);
|
|
331
|
+
this.pending.delete(id);
|
|
332
|
+
this.lastFlush.delete(id);
|
|
333
|
+
this.counters.delete(id);
|
|
334
|
+
this.baseKeys.delete(id);
|
|
335
|
+
}
|
|
336
|
+
flushAll() {
|
|
337
|
+
for (const id of this.pending.keys())
|
|
338
|
+
this.flush(id);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* Capture / re-apply a session's UNCOMMITTED working-tree changes for a fork
|
|
7
|
+
* (see docs/session-fork-plan.md). The committed branch already travels via
|
|
8
|
+
* origin (the destination re-clones + checks it out); this carries the in-flight
|
|
9
|
+
* edits on top so a fork never silently drops work-in-progress.
|
|
10
|
+
*
|
|
11
|
+
* Resolved decision: the patch is size-capped. When the working tree is larger
|
|
12
|
+
* than the cap (big or binary churn), we DON'T inline it — `capture` returns
|
|
13
|
+
* `pushedInstead: true` and the caller commits & pushes the branch so the
|
|
14
|
+
* destination reproduces from the pushed commit instead.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB of patch text
|
|
17
|
+
function git(repoDir, args) {
|
|
18
|
+
try {
|
|
19
|
+
return execFileSync("git", ["-C", repoDir, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
// `git diff --no-index` exits 1 when files differ — that's the normal case,
|
|
23
|
+
// and the useful patch is on stdout, so surface it rather than throwing.
|
|
24
|
+
const e = err;
|
|
25
|
+
if (typeof e.stdout === "string")
|
|
26
|
+
return e.stdout;
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Snapshot the working tree at `repoDir`: tracked changes vs HEAD plus untracked
|
|
32
|
+
* (non-ignored) files, as a single `git apply`-able patch. Returns
|
|
33
|
+
* `pushedInstead: true` (and an empty patch) when the snapshot exceeds `maxBytes`.
|
|
34
|
+
*/
|
|
35
|
+
export function captureDirtyPatch(repoDir, opts = {}) {
|
|
36
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
37
|
+
// Tracked changes relative to HEAD (staged + unstaged), binary-safe.
|
|
38
|
+
const tracked = git(repoDir, ["diff", "HEAD", "--binary"]);
|
|
39
|
+
const untracked = git(repoDir, ["ls-files", "--others", "--exclude-standard"])
|
|
40
|
+
.split(/\r?\n/)
|
|
41
|
+
.map((l) => l.trim())
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
// Each untracked file becomes a "new file" patch via --no-index against /dev/null.
|
|
44
|
+
const untrackedPatches = untracked.map((rel) => git(repoDir, ["diff", "--no-index", "--binary", "--", "/dev/null", rel]));
|
|
45
|
+
const patch = [tracked, ...untrackedPatches].filter(Boolean).join("");
|
|
46
|
+
if (Buffer.byteLength(patch, "utf8") > maxBytes) {
|
|
47
|
+
return { patch: "", untracked: [], pushedInstead: true };
|
|
48
|
+
}
|
|
49
|
+
return { patch, untracked };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Re-apply a captured patch onto a fresh checkout at `repoDir`. A no-op when the
|
|
53
|
+
* source pushed the branch instead (`pushedInstead`) or the working tree was
|
|
54
|
+
* clean (empty patch). Uses `git apply` so both tracked hunks and untracked
|
|
55
|
+
* new-file hunks (produced via `--no-index`) land correctly.
|
|
56
|
+
*/
|
|
57
|
+
export function applyDirtyPatch(repoDir, dirty) {
|
|
58
|
+
if (!dirty || dirty.pushedInstead || !dirty.patch.trim())
|
|
59
|
+
return;
|
|
60
|
+
const tmp = path.join(os.tmpdir(), `bivy-fork-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
|
|
61
|
+
fs.writeFileSync(tmp, dirty.patch);
|
|
62
|
+
try {
|
|
63
|
+
execFileSync("git", ["-C", repoDir, "apply", "--whitespace=nowarn", tmp], { stdio: "pipe" });
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
try {
|
|
67
|
+
fs.unlinkSync(tmp);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// best effort
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fork prerequisite detection (docs/session-fork-plan.md, acceptance: "Missing
|
|
3
|
+
* prerequisites on the destination node are detected and clearly communicated").
|
|
4
|
+
*
|
|
5
|
+
* The engine reports what the destination is missing to continue the forked
|
|
6
|
+
* session so the UI can guide the user instead of failing opaquely:
|
|
7
|
+
* - agent — the target runtime must be installed/available (a HARD blocker).
|
|
8
|
+
* - model — the model's provider should have auth on this node (soft: the
|
|
9
|
+
* runtime can fall back, or the model-auth vault can supply it).
|
|
10
|
+
* - repo — the source repo should be reachable (soft: it may be public, or
|
|
11
|
+
* the user can connect GitHub afterwards).
|
|
12
|
+
*
|
|
13
|
+
* Pure so the server can gather the raw booleans however it likes and this stays
|
|
14
|
+
* unit-testable.
|
|
15
|
+
*/
|
|
16
|
+
export function evaluateForkPrereqs(input) {
|
|
17
|
+
const out = [];
|
|
18
|
+
out.push({
|
|
19
|
+
kind: "agent",
|
|
20
|
+
ok: input.agent.available,
|
|
21
|
+
label: input.agent.displayName,
|
|
22
|
+
detail: input.agent.available
|
|
23
|
+
? `${input.agent.displayName} is installed on this node.`
|
|
24
|
+
: `${input.agent.displayName} is not installed on this node — install it to continue.`,
|
|
25
|
+
blocking: !input.agent.available,
|
|
26
|
+
...(input.agent.available ? {} : { fix: "runtime.install" }),
|
|
27
|
+
});
|
|
28
|
+
if (input.model) {
|
|
29
|
+
out.push({
|
|
30
|
+
kind: "model",
|
|
31
|
+
ok: input.model.configured,
|
|
32
|
+
label: input.model.provider,
|
|
33
|
+
detail: input.model.configured
|
|
34
|
+
? `${input.model.provider} is authenticated on this node.`
|
|
35
|
+
: `${input.model.provider} isn't logged in here — connect it (or the fork will fall back to an available model).`,
|
|
36
|
+
blocking: false,
|
|
37
|
+
...(input.model.configured ? {} : { fix: "provider.connect" }),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (input.repo) {
|
|
41
|
+
out.push({
|
|
42
|
+
kind: "repo",
|
|
43
|
+
ok: input.repo.reachable,
|
|
44
|
+
label: input.repo.slug,
|
|
45
|
+
detail: input.repo.reachable
|
|
46
|
+
? `${input.repo.slug} is reachable from this node.`
|
|
47
|
+
: `${input.repo.slug} may not be reachable here — connect GitHub if the branch fails to check out.`,
|
|
48
|
+
blocking: false,
|
|
49
|
+
...(input.repo.reachable ? {} : { fix: "github.connect" }),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** The subset that should stop a fork from proceeding (missing + blocking). */
|
|
55
|
+
export function blockingForkPrereqs(prereqs) {
|
|
56
|
+
return prereqs.filter((p) => !p.ok && p.blocking);
|
|
57
|
+
}
|
|
58
|
+
/** The subset worth surfacing to the user (anything not satisfied). */
|
|
59
|
+
export function missingForkPrereqs(prereqs) {
|
|
60
|
+
return prereqs.filter((p) => !p.ok);
|
|
61
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { normalizeMessages, buildSeedPrompt, } from "./transcript-normal.js";
|
|
2
|
+
/**
|
|
3
|
+
* Capture a fork bundle on the source node. Always includes the normalized
|
|
4
|
+
* transcript (the cross-runtime seed, and the fallback when a same-runtime
|
|
5
|
+
* destination can't natively import). Adds the runtime's native payload for a
|
|
6
|
+
* full same-runtime replay — but only when the fork could actually use it: a
|
|
7
|
+
* fork to a known different agent skips it, since the native format never
|
|
8
|
+
* round-trips across runtimes.
|
|
9
|
+
*/
|
|
10
|
+
export function buildForkBundle(opts) {
|
|
11
|
+
const { runtime, sessionFile, record } = opts;
|
|
12
|
+
const messages = runtime.readMessages?.(sessionFile);
|
|
13
|
+
const normalized = normalizeMessages(messages, {
|
|
14
|
+
sourceRuntimeId: runtime.id,
|
|
15
|
+
model: record.model,
|
|
16
|
+
title: record.title,
|
|
17
|
+
createdAt: new Date().toISOString(),
|
|
18
|
+
});
|
|
19
|
+
// Only worth capturing when the target is the same runtime (or not yet known).
|
|
20
|
+
const nativeCouldReplay = !opts.targetRuntimeId || opts.targetRuntimeId === runtime.id;
|
|
21
|
+
const native = nativeCouldReplay && runtime.capabilities.forkTransport && runtime.exportForFork
|
|
22
|
+
? runtime.exportForFork(sessionFile)
|
|
23
|
+
: undefined;
|
|
24
|
+
return { record, normalized, ...(native ? { native } : {}), ...(opts.dirtyPatch ? { dirtyPatch: opts.dirtyPatch } : {}) };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Decide the fidelity a fork of `bundle` into `targetRuntime` can achieve —
|
|
28
|
+
* "full" only when the target is the SAME runtime that produced the native
|
|
29
|
+
* payload and can import it; "seeded" otherwise. Pure; no side effects.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveForkFidelity(bundle, targetRuntime) {
|
|
32
|
+
const native = bundle.native;
|
|
33
|
+
const canImport = !!native &&
|
|
34
|
+
native.runtimeId === targetRuntime.id &&
|
|
35
|
+
!!targetRuntime.capabilities.forkTransport &&
|
|
36
|
+
typeof targetRuntime.importForFork === "function";
|
|
37
|
+
return canImport ? "full" : "seeded";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Turn a fork bundle into a concrete stand-up plan on the destination node:
|
|
41
|
+
* either resume a natively imported transcript (full) or a seed prompt for a
|
|
42
|
+
* fresh session (seeded). The server executes the returned plan (worktree +
|
|
43
|
+
* session creation live there); this stays pure of daemon wiring.
|
|
44
|
+
*/
|
|
45
|
+
export async function materializeFork(opts) {
|
|
46
|
+
const { bundle, targetRuntime, ctx } = opts;
|
|
47
|
+
if (resolveForkFidelity(bundle, targetRuntime) === "full" && bundle.native && targetRuntime.importForFork) {
|
|
48
|
+
const { sessionFile, id } = await targetRuntime.importForFork(bundle.native, ctx);
|
|
49
|
+
return { kind: "resume", fidelity: "full", sessionFile, id };
|
|
50
|
+
}
|
|
51
|
+
const seedPrompt = buildSeedPrompt(bundle.normalized, {
|
|
52
|
+
targetAgent: targetRuntime.displayName,
|
|
53
|
+
context: { repoSlug: bundle.record.repoSlug, branch: bundle.record.branch, prUrl: bundle.record.prUrl },
|
|
54
|
+
...opts.seed,
|
|
55
|
+
});
|
|
56
|
+
return { kind: "seed", fidelity: "seeded", seedPrompt };
|
|
57
|
+
}
|