@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.
Files changed (146) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +265 -5
  3. package/bin/acp-shim.mjs +298 -0
  4. package/bin/agent-manifest.json +277 -0
  5. package/bin/bivy.mjs +4100 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/prune-sessions.mjs +52 -0
  9. package/bin/sessions-list.mjs +27 -0
  10. package/bin/shim-path.mjs +126 -0
  11. package/bin/uninstall-paths.mjs +48 -0
  12. package/dist/approval.js +87 -0
  13. package/dist/attach.js +248 -0
  14. package/dist/auth.js +258 -0
  15. package/dist/bivy-login.js +180 -0
  16. package/dist/browser-open.js +50 -0
  17. package/dist/control-plane-tasks.js +236 -0
  18. package/dist/data-dir.js +25 -0
  19. package/dist/device-registry.js +201 -0
  20. package/dist/e2e.js +70 -0
  21. package/dist/ephemeral-exec.js +109 -0
  22. package/dist/exec.js +209 -0
  23. package/dist/git-auth.js +155 -0
  24. package/dist/github-app-auth.js +107 -0
  25. package/dist/github-app-connect.js +235 -0
  26. package/dist/github-app-manifest.js +82 -0
  27. package/dist/github-app-sync-cli.js +93 -0
  28. package/dist/github-app-vault.js +106 -0
  29. package/dist/github-apps.js +121 -0
  30. package/dist/github-connect-repo.js +74 -0
  31. package/dist/github-device-auth.js +109 -0
  32. package/dist/github-tasks.js +650 -0
  33. package/dist/guard.js +109 -0
  34. package/dist/harness/cache-evict.js +88 -0
  35. package/dist/harness/checkpoint.js +0 -0
  36. package/dist/harness/cow-clone.js +84 -0
  37. package/dist/harness/dep-cache.js +78 -0
  38. package/dist/harness/disk-admission.js +46 -0
  39. package/dist/harness/egress.js +30 -0
  40. package/dist/harness/manager.js +97 -0
  41. package/dist/harness/mcp-config-formats.js +164 -0
  42. package/dist/harness/mcp-config.js +111 -0
  43. package/dist/harness/mcp-inject.js +134 -0
  44. package/dist/harness/mcp-proxy-cli.js +88 -0
  45. package/dist/harness/mcp-proxy.js +150 -0
  46. package/dist/harness/net-proxy.js +120 -0
  47. package/dist/harness/sandbox.js +96 -0
  48. package/dist/history-sync.js +26 -0
  49. package/dist/hosted-endpoints.d.mts +14 -0
  50. package/dist/hosted-endpoints.mjs +35 -0
  51. package/dist/identity.js +153 -0
  52. package/dist/integrations/index.js +4 -0
  53. package/dist/integrations/manager.js +279 -0
  54. package/dist/integrations/oauth.js +78 -0
  55. package/dist/integrations/registry.js +239 -0
  56. package/dist/integrations/store.js +54 -0
  57. package/dist/integrations/types.js +1 -0
  58. package/dist/linear-tasks.js +49 -0
  59. package/dist/metadata.js +226 -0
  60. package/dist/multiplexer.js +79 -0
  61. package/dist/native-pi.js +38 -0
  62. package/dist/node-stats.js +237 -0
  63. package/dist/pairing-crypto.js +105 -0
  64. package/dist/policy/conditions.js +103 -0
  65. package/dist/policy/policy-engine.js +20 -0
  66. package/dist/policy/risk.js +18 -0
  67. package/dist/policy/ruleset.js +113 -0
  68. package/dist/policy/run-policy.js +108 -0
  69. package/dist/policy/session-reroute.js +96 -0
  70. package/dist/pty-runner.py +95 -0
  71. package/dist/question.js +146 -0
  72. package/dist/redact.js +97 -0
  73. package/dist/relay-attach.js +345 -0
  74. package/dist/relay-chunk.js +73 -0
  75. package/dist/relay-cli-crypto.js +70 -0
  76. package/dist/relay-client.js +344 -0
  77. package/dist/relay-setup.js +262 -0
  78. package/dist/repo-workspace.js +208 -0
  79. package/dist/runtime/adoption.js +45 -0
  80. package/dist/runtime/agent-service-bin.js +149 -0
  81. package/dist/runtime/agent-service.js +439 -0
  82. package/dist/runtime/ansi.js +27 -0
  83. package/dist/runtime/anthropic-preflight.js +80 -0
  84. package/dist/runtime/claude-code.js +1364 -0
  85. package/dist/runtime/cli-parsers.js +647 -0
  86. package/dist/runtime/codex-auth.js +168 -0
  87. package/dist/runtime/codex-preflight.js +60 -0
  88. package/dist/runtime/codex-sessions.js +229 -0
  89. package/dist/runtime/control-plane-location.js +74 -0
  90. package/dist/runtime/credential-ingest.js +122 -0
  91. package/dist/runtime/credential-provisioning.js +79 -0
  92. package/dist/runtime/credential-store.js +435 -0
  93. package/dist/runtime/credentials.js +153 -0
  94. package/dist/runtime/host.js +153 -0
  95. package/dist/runtime/index.js +1548 -0
  96. package/dist/runtime/local-model-store.js +194 -0
  97. package/dist/runtime/location-registry.js +28 -0
  98. package/dist/runtime/model-catalog.js +97 -0
  99. package/dist/runtime/model-namer.js +85 -0
  100. package/dist/runtime/native-process-scan.js +102 -0
  101. package/dist/runtime/native-session-discovery.js +103 -0
  102. package/dist/runtime/normalize.js +75 -0
  103. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  104. package/dist/runtime/oauth/model-oauth.js +324 -0
  105. package/dist/runtime/opencode-preflight.js +55 -0
  106. package/dist/runtime/pi-auth.js +82 -0
  107. package/dist/runtime/pi-oauth.js +52 -0
  108. package/dist/runtime/pi-session-discovery.js +42 -0
  109. package/dist/runtime/pi.js +518 -0
  110. package/dist/runtime/process.js +499 -0
  111. package/dist/runtime/protocol.js +630 -0
  112. package/dist/runtime/remote.js +541 -0
  113. package/dist/runtime/rpc-protocol.js +56 -0
  114. package/dist/runtime/ruleset-store.js +117 -0
  115. package/dist/runtime/session-location.js +50 -0
  116. package/dist/runtime/types.js +17 -0
  117. package/dist/secrets-cli.js +134 -0
  118. package/dist/secrets.js +264 -0
  119. package/dist/server.js +9411 -0
  120. package/dist/session/bivy-session.js +1 -0
  121. package/dist/session/checkpoint-pack.js +133 -0
  122. package/dist/session/event-log.js +340 -0
  123. package/dist/session/fork-dirty.js +73 -0
  124. package/dist/session/fork-prereqs.js +61 -0
  125. package/dist/session/fork.js +57 -0
  126. package/dist/session/native-import.js +56 -0
  127. package/dist/session/reconnect.js +168 -0
  128. package/dist/session/replication-service.js +236 -0
  129. package/dist/session/replication.js +106 -0
  130. package/dist/session/replicator.js +140 -0
  131. package/dist/session/session-new-dedupe.js +42 -0
  132. package/dist/session/sibling-client.js +201 -0
  133. package/dist/session/transcript-merge.js +131 -0
  134. package/dist/session/transcript-normal.js +130 -0
  135. package/dist/session/workspace-context.js +1 -0
  136. package/dist/session-event-coalescer.js +50 -0
  137. package/dist/session-identity.js +34 -0
  138. package/dist/session-ref.js +65 -0
  139. package/dist/stt-cli.js +131 -0
  140. package/dist/stt.js +168 -0
  141. package/dist/terminal.js +409 -0
  142. package/dist/wire-format.js +67 -0
  143. package/dist/worktree-provision.js +118 -0
  144. package/dist/worktree.js +117 -0
  145. package/package.json +40 -6
  146. package/public/qr.js +464 -0
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Seeded-continuation prompt for importing a discovered provider-native
4
+ // session (issue #156) when a true native resume isn't available — the
5
+ // session-level sibling of session/fork.ts's cross-runtime seed path. Pure and
6
+ // side-effect-free (no filesystem/runtime access) so it's unit-testable with
7
+ // synthetic transcripts.
8
+ //
9
+ // This is deliberately a THIN, import-specific wrapper around
10
+ // transcript-normal.ts's shared primitives rather than a copy: normalizeMessages
11
+ // flattens the runtime's RuntimeMessage[] into portable turns exactly as fork
12
+ // does, but the prompt wording here is honest about being an IMPORT (same
13
+ // runtime, no resume available) rather than fork's "moved to a different
14
+ // agent" framing — those are different user-facing claims and must not be
15
+ // conflated.
16
+ import { normalizeMessages } from "./transcript-normal.js";
17
+ function truncate(text, max) {
18
+ const compact = String(text || "").replace(/\s+/g, " ").trim();
19
+ return compact.length > max ? `${compact.slice(0, Math.max(0, max - 1))}…` : compact;
20
+ }
21
+ /**
22
+ * Build the first prompt for a seeded import: a compact summary of the
23
+ * discovered session's recent turns, framed honestly as a best-effort
24
+ * continuation rather than a true resume. Bounded (recent turns + a per-turn
25
+ * character cap, same shape as fork's buildSeedPrompt) so this never inlines
26
+ * an entire transcript — the "bounded metadata only" spirit of discovery
27
+ * extends to what a seeded import is willing to carry into the new session.
28
+ */
29
+ export function buildNativeImportSeedPrompt(transcript, opts) {
30
+ const recentTurns = opts.recentTurns ?? 12;
31
+ const perTurnChars = opts.perTurnChars ?? 700;
32
+ const title = opts.title || transcript.header.title || "Untitled session";
33
+ const recent = transcript.turns
34
+ .filter((t) => t.text || t.toolSummary)
35
+ .slice(-recentTurns)
36
+ .map((t) => {
37
+ const body = t.text || (t.toolSummary ? `[${t.toolName ?? "tool"}] ${t.toolSummary}` : "");
38
+ return `- ${t.role}: ${truncate(body, perTurnChars)}`;
39
+ })
40
+ .join("\n");
41
+ const lines = [
42
+ `I am continuing a ${opts.provider} session that was started outside Bivy and imported here.`,
43
+ `Native resume wasn't available for it, so this is a fresh session seeded with a summary of the prior conversation — not the original session itself.`,
44
+ `Session: ${title}`,
45
+ opts.cwd ? `Working directory: ${opts.cwd}` : null,
46
+ "",
47
+ "Recent conversation (most recent last):",
48
+ recent || "- (no prior turns were available)",
49
+ ].filter((line) => line !== null);
50
+ return lines.join("\n");
51
+ }
52
+ /** Convenience: normalize + seed in one call, for the common case of reading a
53
+ * runtime's raw messages straight off disk. */
54
+ export function buildNativeImportSeedFromMessages(messages, header, opts) {
55
+ return buildNativeImportSeedPrompt(normalizeMessages(messages, header), opts);
56
+ }
@@ -0,0 +1,168 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // A supervised, self-healing connection with exponential backoff + jitter — the
5
+ // reliability layer under the sibling replication transport
6
+ // (docs/session-replication.md, follow-up #2). Without it, warm replication is
7
+ // only as reliable as the socket at each individual turn boundary: a relay blip
8
+ // or a standby restart silently pauses replication until the next turn happens to
9
+ // reconnect, and repeated failures surface nothing. This keeps exactly one live
10
+ // connection to the standby, reconnecting on drop so the standby stays warm.
11
+ //
12
+ // Generic + transport-free (the connect/close and the clock are injected), so the
13
+ // backoff/state machine unit-tests without a real socket, mirroring
14
+ // session-event-coalescer.ts. `ReplicationService` wires a `SiblingClient` into it.
15
+ //
16
+ // Backoff shape: a CONNECT failure backs off (base·factor^attempt, capped, with
17
+ // jitter); a DROP after a healthy connection resets the counter so reconnection is
18
+ // prompt (one base delay) rather than treating a long-lived session that finally
19
+ // dropped as if it had been flapping.
20
+ const defaultClock = {
21
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
22
+ clearTimeout: (h) => clearTimeout(h),
23
+ random: () => Math.random(),
24
+ };
25
+ export class ReconnectingConnection {
26
+ opts;
27
+ conn;
28
+ attempt = 0; // consecutive CONNECT failures
29
+ timer;
30
+ connecting = false;
31
+ stopped = false;
32
+ waiters = [];
33
+ base;
34
+ factor;
35
+ max;
36
+ jitter;
37
+ clock;
38
+ constructor(opts) {
39
+ this.opts = opts;
40
+ this.base = opts.backoff?.baseMs ?? 1000;
41
+ this.factor = opts.backoff?.factor ?? 2;
42
+ this.max = opts.backoff?.maxMs ?? 30_000;
43
+ this.jitter = Math.min(1, Math.max(0, opts.backoff?.jitter ?? 0.3));
44
+ this.clock = opts.clock ?? defaultClock;
45
+ }
46
+ /** The live connection, or undefined while (re)connecting. */
47
+ current() {
48
+ return this.conn;
49
+ }
50
+ /** Begin connecting (idempotent). */
51
+ start() {
52
+ if (this.stopped)
53
+ return;
54
+ this.kick();
55
+ }
56
+ /**
57
+ * Resolve with the live connection, triggering a connect if idle and waiting up
58
+ * to `timeoutMs`. Resolves `undefined` if it isn't up in time — the caller skips
59
+ * this round while the supervisor keeps reconnecting in the background (the
60
+ * log-based replicator simply ships the accumulated delta on a later turn).
61
+ */
62
+ ensure(timeoutMs) {
63
+ if (this.conn)
64
+ return Promise.resolve(this.conn);
65
+ if (this.stopped)
66
+ return Promise.resolve(undefined);
67
+ this.start();
68
+ return new Promise((resolve) => {
69
+ const waiter = {
70
+ resolve,
71
+ timer: this.clock.setTimeout(() => {
72
+ this.removeWaiter(waiter);
73
+ resolve(undefined);
74
+ }, timeoutMs),
75
+ };
76
+ this.waiters.push(waiter);
77
+ });
78
+ }
79
+ /** Stop for good: cancel timers, fail waiters, and close any live connection. */
80
+ stop() {
81
+ this.stopped = true;
82
+ if (this.timer !== undefined) {
83
+ this.clock.clearTimeout(this.timer);
84
+ this.timer = undefined;
85
+ }
86
+ while (this.waiters.length) {
87
+ const w = this.waiters.pop();
88
+ this.clock.clearTimeout(w.timer);
89
+ w.resolve(undefined);
90
+ }
91
+ if (this.conn !== undefined) {
92
+ try {
93
+ this.opts.close(this.conn);
94
+ }
95
+ catch {
96
+ /* ignore */
97
+ }
98
+ this.conn = undefined;
99
+ }
100
+ }
101
+ // --- internals -----------------------------------------------------------
102
+ kick() {
103
+ if (this.stopped || this.connecting || this.conn !== undefined)
104
+ return;
105
+ this.connecting = true;
106
+ this.opts
107
+ .connect((err) => this.handleDrop(err))
108
+ .then((conn) => {
109
+ this.connecting = false;
110
+ if (this.stopped) {
111
+ try {
112
+ this.opts.close(conn);
113
+ }
114
+ catch {
115
+ /* ignore */
116
+ }
117
+ return;
118
+ }
119
+ this.conn = conn;
120
+ this.attempt = 0; // healthy again → next drop reconnects promptly
121
+ this.opts.onActive?.(conn);
122
+ this.flushWaiters(conn);
123
+ })
124
+ .catch((err) => {
125
+ this.connecting = false;
126
+ this.scheduleRetry(err);
127
+ });
128
+ }
129
+ handleDrop(err) {
130
+ // Only act on the drop of the connection we currently consider live.
131
+ if (this.conn === undefined)
132
+ return;
133
+ this.conn = undefined;
134
+ this.opts.onDrop?.(err);
135
+ // attempt was reset to 0 on the last successful connect, so this schedules one
136
+ // base-delay retry rather than an immediate hot-loop reconnect.
137
+ this.scheduleRetry(err);
138
+ }
139
+ scheduleRetry(err) {
140
+ if (this.stopped || this.timer !== undefined || this.connecting)
141
+ return;
142
+ const delay = this.delayFor(this.attempt);
143
+ this.attempt += 1;
144
+ this.opts.onRetry?.(err, delay);
145
+ this.timer = this.clock.setTimeout(() => {
146
+ this.timer = undefined;
147
+ this.kick();
148
+ }, delay);
149
+ }
150
+ delayFor(attempt) {
151
+ const raw = Math.min(this.max, this.base * Math.pow(this.factor, attempt));
152
+ // Spread ±jitter/2 around the target: with jitter=0.3 the delay is 85%–115%.
153
+ const spread = raw * this.jitter * (this.clock.random() - 0.5);
154
+ return Math.max(0, Math.round(raw + spread));
155
+ }
156
+ flushWaiters(conn) {
157
+ while (this.waiters.length) {
158
+ const w = this.waiters.pop();
159
+ this.clock.clearTimeout(w.timer);
160
+ w.resolve(conn);
161
+ }
162
+ }
163
+ removeWaiter(waiter) {
164
+ const i = this.waiters.indexOf(waiter);
165
+ if (i >= 0)
166
+ this.waiters.splice(i, 1);
167
+ }
168
+ }
@@ -0,0 +1,236 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Daemon integration for warm session replication (docs/session-replication.md):
5
+ // assembles the unit-tested owner/standby orchestration (replicator.ts), the git
6
+ // checkpoint bundle (checkpoint-pack.ts), the sibling relay-client transport
7
+ // (sibling-client.ts), and the control-plane ownership endpoints into one service
8
+ // the node daemon drives through a handful of thin adapters (server.ts owns the
9
+ // daemon-specific accessors, so THIS module has no server.ts coupling).
10
+ //
11
+ // Owner: on each turn boundary, `onTurnComplete(sessionId)` declares the standby
12
+ // (once), gets the supervised standby connection (which reconnects on its own with
13
+ // backoff — reconnect.ts), ships a frame, and advances the cursor on ack (retrying
14
+ // once on a full-resync request). Standby: `handleReplicaFrame` applies an inbound
15
+ // frame; `promote` runs the control-plane compare-and-set and materializes the
16
+ // replica for local resume.
17
+ //
18
+ // Gated entirely on the node's sessionSync setting + a chosen standby, both off by
19
+ // default — so when replication is disabled this service is inert and the daemon
20
+ // behaves exactly as before.
21
+ import { OwnerReplicator, StandbyApplier } from "./replicator.js";
22
+ import { createCheckpointBundle, applyCheckpointBundle, materializeCheckpoint } from "./checkpoint-pack.js";
23
+ import { SiblingClient } from "./sibling-client.js";
24
+ import { ReconnectingConnection } from "./reconnect.js";
25
+ export class ReplicationService {
26
+ deps;
27
+ owner;
28
+ standby;
29
+ // A single supervised connection to the current standby: it reconnects with
30
+ // backoff on drop, so replication survives relay blips / standby restarts
31
+ // instead of silently pausing until the next turn (follow-up #2).
32
+ supervisor;
33
+ supervisorStandbyId;
34
+ declared = new Set();
35
+ epochs = new Map();
36
+ constructor(deps) {
37
+ this.deps = deps;
38
+ this.owner = new OwnerReplicator({
39
+ readRecords: (id) => deps.readRecords(id),
40
+ epochOf: (id) => this.epochs.get(id) ?? 0,
41
+ checkpointHead: (id) => deps.checkpointHead(id),
42
+ bundleCheckpoint: async (id, since) => {
43
+ const dir = deps.repoDirFor(id);
44
+ return dir ? createCheckpointBundle(dir, id, since) : null;
45
+ },
46
+ runtimeSessionRef: (id) => deps.runtimeSessionRef(id),
47
+ worktreeSync: () => deps.settings().worktreeSync === true,
48
+ });
49
+ this.standby = new StandbyApplier({
50
+ persistRecords: (id, records) => deps.persistReplicaRecords(id, records),
51
+ applyBundle: async (id, bundle) => {
52
+ const dir = await deps.replicaRepoDir(id);
53
+ if (!dir)
54
+ return { ok: false, needFull: true };
55
+ return applyCheckpointBundle(dir, id, bundle);
56
+ },
57
+ materialize: async (id) => {
58
+ const dir = await deps.replicaRepoDir(id);
59
+ if (dir)
60
+ await materializeCheckpoint(dir, id);
61
+ },
62
+ });
63
+ }
64
+ get fetchImpl() {
65
+ return this.deps.fetchImpl ?? fetch;
66
+ }
67
+ ready() {
68
+ const s = this.deps.settings();
69
+ if (!s.sessionSync || !s.standbyNodeId)
70
+ return null;
71
+ const cp = this.deps.controlPlaneUrl();
72
+ const token = this.deps.enrollmentToken();
73
+ if (!cp || !token)
74
+ return null;
75
+ return { cp: cp.replace(/\/$/, ""), token, standbyId: s.standbyNodeId };
76
+ }
77
+ async cpPost(path, token, body) {
78
+ try {
79
+ const res = await this.fetchImpl(`${this.deps.controlPlaneUrl().replace(/\/$/, "")}${path}`, {
80
+ method: "POST",
81
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
82
+ body: JSON.stringify(body),
83
+ });
84
+ if (!res.ok)
85
+ return undefined;
86
+ return (await res.json());
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ }
92
+ async cpGet(path, token) {
93
+ try {
94
+ const res = await this.fetchImpl(`${this.deps.controlPlaneUrl().replace(/\/$/, "")}${path}`, {
95
+ headers: { authorization: `Bearer ${token}` },
96
+ });
97
+ if (!res.ok)
98
+ return undefined;
99
+ return (await res.json());
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
105
+ /** Time a turn will wait for the standby connection before skipping this round. */
106
+ static ENSURE_TIMEOUT_MS = 10_000;
107
+ /**
108
+ * The supervised connection to `standbyId`, created (and started) on demand and
109
+ * kept alive across turns. Switching standby tears the old one down. The
110
+ * supervisor owns reconnection: each attempt mints fresh credentials + a fresh
111
+ * `SiblingClient` and wires its close back to the supervisor's drop signal.
112
+ */
113
+ supervisorFor(standbyId) {
114
+ if (this.supervisor && this.supervisorStandbyId === standbyId)
115
+ return this.supervisor;
116
+ this.supervisor?.stop();
117
+ const sup = new ReconnectingConnection({
118
+ connect: async (onDrop) => {
119
+ const cp = this.deps.controlPlaneUrl();
120
+ const token = this.deps.enrollmentToken();
121
+ if (!cp || !token)
122
+ throw new Error("control plane not configured");
123
+ const client = new SiblingClient({
124
+ controlPlaneUrl: cp,
125
+ enrollmentToken: token,
126
+ siblingNodeId: standbyId,
127
+ relayUrl: this.deps.relayUrl(),
128
+ label: "Bivy replica",
129
+ fetchImpl: this.deps.fetchImpl,
130
+ onClose: () => onDrop(),
131
+ });
132
+ await client.connect();
133
+ return client;
134
+ },
135
+ close: (client) => client.close(),
136
+ onActive: () => this.deps.log?.(`replication: connected to standby ${standbyId}`),
137
+ onDrop: () => this.deps.log?.(`replication: standby ${standbyId} connection dropped; reconnecting`),
138
+ onRetry: (err, ms) => this.deps.log?.(`replication: standby ${standbyId} unreachable (${err?.message ?? err}); retrying in ${ms}ms`),
139
+ backoff: { baseMs: 1000, factor: 2, maxMs: 30_000, jitter: 0.3 },
140
+ });
141
+ this.supervisor = sup;
142
+ this.supervisorStandbyId = standbyId;
143
+ sup.start();
144
+ return sup;
145
+ }
146
+ /** Tear down the standby connection (sync disabled, or shutting down). */
147
+ stopSupervisor() {
148
+ this.supervisor?.stop();
149
+ this.supervisor = undefined;
150
+ this.supervisorStandbyId = undefined;
151
+ }
152
+ /** OWNER: called after a turn completes for a session. Ships one frame. */
153
+ async onTurnComplete(sessionId) {
154
+ const r = this.ready();
155
+ if (!r) {
156
+ // Sync turned off (or standby cleared) → drop the connection.
157
+ this.stopSupervisor();
158
+ return;
159
+ }
160
+ // Declare the standby + learn our epoch once per session.
161
+ if (!this.declared.has(sessionId)) {
162
+ const res = await this.cpPost(`/node/sessions/${encodeURIComponent(sessionId)}/standby`, r.token, { standbyNodeId: r.standbyId });
163
+ const ownership = res?.ownership;
164
+ if (ownership && typeof ownership.ownerEpoch === "number")
165
+ this.epochs.set(sessionId, ownership.ownerEpoch);
166
+ this.declared.add(sessionId);
167
+ }
168
+ // Wait briefly for the (supervised, self-reconnecting) standby connection. If
169
+ // it isn't up yet, skip this round — the replicator is log-based, so the next
170
+ // successful turn ships the accumulated delta.
171
+ const client = await this.supervisorFor(r.standbyId).ensure(ReplicationService.ENSURE_TIMEOUT_MS);
172
+ if (!client)
173
+ return;
174
+ await this.shipOnce(sessionId, client, true);
175
+ }
176
+ async shipOnce(sessionId, client, allowRetry) {
177
+ const frame = await this.owner.buildTurnFrame(sessionId);
178
+ if (!frame)
179
+ return;
180
+ try {
181
+ const reply = await client.request({ kind: "session.replica.frame", frame });
182
+ const ack = (reply.ack ?? reply);
183
+ const resend = this.owner.applyAck(sessionId, ack);
184
+ if (resend && allowRetry)
185
+ await this.shipOnce(sessionId, client, false);
186
+ }
187
+ catch (err) {
188
+ this.deps.log?.(`replication: ship failed for ${sessionId}: ${err.message}`);
189
+ }
190
+ }
191
+ /** STANDBY: apply an inbound replication frame and return the ack. */
192
+ async handleReplicaFrame(frame, ownerNodeId) {
193
+ const ack = await this.standby.receive(frame);
194
+ if (ack.status === "applied") {
195
+ this.deps.upsertReplicaMeta(frame.sessionId, { runtimeSessionRef: frame.runtimeSessionRef, ownerNodeId });
196
+ }
197
+ return ack;
198
+ }
199
+ /**
200
+ * STANDBY: promote this node to owner of a replicated session. Reads the current
201
+ * epoch, runs the control-plane compare-and-set, and (on success) materializes
202
+ * the replica working tree so the session can resume locally. Returns the new
203
+ * owner epoch, or undefined if the promotion lost the race.
204
+ */
205
+ async promote(sessionId, thisNodeId) {
206
+ const cp = this.deps.controlPlaneUrl();
207
+ const token = this.deps.enrollmentToken();
208
+ if (!cp || !token)
209
+ return undefined;
210
+ const owned = await this.cpGet(`/node/sessions/${encodeURIComponent(sessionId)}/ownership`, token);
211
+ const ownership = owned?.ownership;
212
+ const expectedEpoch = ownership && typeof ownership.ownerEpoch === "number" ? ownership.ownerEpoch : 0;
213
+ const res = await this.cpPost(`/node/sessions/${encodeURIComponent(sessionId)}/promote`, token, {
214
+ toNodeId: thisNodeId,
215
+ expectedEpoch,
216
+ });
217
+ const promoted = res?.ownership;
218
+ if (!promoted)
219
+ return undefined;
220
+ const dir = await this.deps.replicaRepoDir(sessionId);
221
+ if (dir)
222
+ await materializeCheckpoint(dir, sessionId).catch(() => { });
223
+ this.standby.forget(sessionId);
224
+ return promoted.ownerEpoch;
225
+ }
226
+ /** Tear down (session closed / node shutdown). */
227
+ forget(sessionId) {
228
+ this.owner.forget(sessionId);
229
+ this.standby.forget(sessionId);
230
+ this.declared.delete(sessionId);
231
+ this.epochs.delete(sessionId);
232
+ }
233
+ close() {
234
+ this.stopSupervisor();
235
+ }
236
+ }
@@ -0,0 +1,106 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Warm session replication — the transport-free DECISION core (Phase 1 of
5
+ // docs/session-replication.md).
6
+ //
7
+ // The goal: a standby node continuously mirrors a session's state so that if the
8
+ // owning node goes offline, the session can be *manually* promoted and continued
9
+ // elsewhere WITHOUT fetching anything from the (possibly dead) owner. Both halves
10
+ // of a session's state are already append-only, cursor-friendly logs, so warm
11
+ // replication is "tail two logs and ship their deltas":
12
+ //
13
+ // - Transcript: the per-session `EventLog` (event-log.ts) is append-only JSONL.
14
+ // We ship appended `LogRecord`s using the SAME count+hash cursor as the
15
+ // client-facing incremental sync (history-sync.ts) — self-healing on a gap.
16
+ // - Workspace: the Universal Agent Harness commits a git checkpoint per turn
17
+ // (harness/checkpoint.ts). We ship the checkpoint commit sha; the standby
18
+ // fetches the objects (git's own negotiation is idempotent + self-healing).
19
+ //
20
+ // Replication is anchored to the TURN/CHECKPOINT boundary so the two halves stay
21
+ // mutually consistent: every replicated frame carries the transcript tail AND the
22
+ // checkpoint that closed the same turn, and the standby applies BOTH or NEITHER.
23
+ // Recovery point = "the last completed turn"; a crash mid-turn loses at most the
24
+ // in-flight turn (the standby re-runs the last prompt on promotion).
25
+ //
26
+ // This module is the pure core — no relay, no disk, no git. Side effects (persist
27
+ // the tail, fetch the checkpoint objects) are injected, exactly like adoption.ts
28
+ // injects `attach`/`forget`, so the fencing/cursor/consistency logic unit-tests
29
+ // without a daemon (test/replication.test.ts). Transport, the control-plane epoch
30
+ // column, and the promotion command are layered on top (see the doc).
31
+ import { historyDelta } from "../history-sync.js";
32
+ /** A fresh, empty standby state (before the first frame). */
33
+ export function initialReplState() {
34
+ return { epoch: 0, records: [], historyHash: "" };
35
+ }
36
+ /** The cursor a standby advertises to its owner, derived from its state. */
37
+ export function cursorOf(state) {
38
+ return { count: state.records.length, historyHash: state.historyHash, checkpointCommit: state.checkpointCommit };
39
+ }
40
+ /**
41
+ * OWNER side: build the frame to send a standby, given the cursor the standby last
42
+ * advertised. Returns `null` when the standby is already up to date (no new records
43
+ * AND the same checkpoint) so the owner skips an empty send. The transcript delta
44
+ * reuses `historyDelta`, so a diverged/behind standby self-heals: a matching prefix
45
+ * yields an `append`, anything else a `full`.
46
+ */
47
+ export function buildReplFrame(input) {
48
+ const { sessionId, epoch, records, checkpointCommit, runtimeSessionRef, cursor } = input;
49
+ const delta = historyDelta(records, { have: cursor?.count, haveToken: cursor?.historyHash });
50
+ const checkpointUnchanged = (checkpointCommit ?? undefined) === (cursor?.checkpointCommit ?? undefined);
51
+ // Nothing new: an append that carries no records and no newer checkpoint.
52
+ if (delta.mode === "append" && delta.messages.length === 0 && checkpointUnchanged)
53
+ return null;
54
+ return {
55
+ sessionId,
56
+ epoch,
57
+ runtimeSessionRef,
58
+ checkpointCommit,
59
+ mode: delta.mode,
60
+ baseCount: delta.baseCount,
61
+ records: delta.messages,
62
+ count: delta.count,
63
+ historyHash: delta.historyHash,
64
+ };
65
+ }
66
+ /**
67
+ * STANDBY side: apply one frame, mutating `state` in place ONLY after every injected
68
+ * side effect has succeeded (so a git/persist failure leaves the replica unchanged
69
+ * and the frame is safely retried). Never advances past a gap — it asks the owner to
70
+ * re-send instead, which `buildReplFrame` resolves to an `append` or a `full`.
71
+ *
72
+ * Fencing (the split-brain guard): a frame whose epoch is BELOW the state's is a
73
+ * write from an owner that has already been superseded by a promotion — rejected.
74
+ * A frame at or above the state's epoch is honored, and applying it adopts the
75
+ * (possibly higher) epoch, so a promoted owner's first frame transfers ownership.
76
+ */
77
+ export async function applyReplFrame(state, frame, deps) {
78
+ if (frame.epoch < state.epoch)
79
+ return { status: "stale", ownerEpoch: state.epoch };
80
+ // Decide the next record list without mutating state yet.
81
+ let nextRecords;
82
+ if (frame.mode === "full") {
83
+ nextRecords = [...frame.records];
84
+ }
85
+ else {
86
+ // append — only valid when the frame continues exactly where we are.
87
+ if (frame.baseCount !== state.records.length) {
88
+ return { status: "resync", cursor: cursorOf(state) };
89
+ }
90
+ nextRecords = state.records.concat(frame.records);
91
+ }
92
+ // Git first: if it throws, we abort before touching the transcript replica.
93
+ const nextCheckpoint = frame.checkpointCommit ?? state.checkpointCommit;
94
+ if (frame.checkpointCommit && frame.checkpointCommit !== state.checkpointCommit && deps.fetchCheckpoint) {
95
+ await deps.fetchCheckpoint(frame.sessionId, frame.checkpointCommit);
96
+ }
97
+ await deps.persist(frame.sessionId, nextRecords);
98
+ // Commit the new view atomically now that all effects have landed.
99
+ state.records = nextRecords;
100
+ state.historyHash = frame.historyHash;
101
+ state.checkpointCommit = nextCheckpoint;
102
+ if (frame.runtimeSessionRef !== undefined)
103
+ state.runtimeSessionRef = frame.runtimeSessionRef;
104
+ state.epoch = Math.max(state.epoch, frame.epoch);
105
+ return { status: "applied", cursor: cursorOf(state) };
106
+ }