@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,439 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { RPC_PROTOCOL_VERSION } from "./rpc-protocol.js";
4
+ const defaultReaperTimers = {
5
+ schedule: (fn, ms) => setTimeout(fn, ms),
6
+ cancel: (h) => clearTimeout(h),
7
+ };
8
+ function modelKey(model) {
9
+ return model ? `${model.provider}:${model.id}` : "";
10
+ }
11
+ export class AgentService {
12
+ runtimeProvider;
13
+ log;
14
+ sessions = new Map();
15
+ binding = new Map();
16
+ detachReapMs;
17
+ timers;
18
+ now;
19
+ sweepHandle;
20
+ constructor(options) {
21
+ this.runtimeProvider = options.runtimeProvider;
22
+ this.log = options.log ?? (() => { });
23
+ this.detachReapMs = options.detachReapMs && options.detachReapMs > 0 ? options.detachReapMs : 0;
24
+ this.timers = options.timers ?? defaultReaperTimers;
25
+ this.now = options.now ?? (() => Date.now());
26
+ if (this.detachReapMs)
27
+ this.armSweep();
28
+ }
29
+ /** Number of live (non-disposed) sessions — test/introspection aid. */
30
+ get sessionCount() {
31
+ return this.sessions.size;
32
+ }
33
+ /** Attach the service to a new daemon connection. */
34
+ accept(conn) {
35
+ conn.onMessage((message) => {
36
+ void this.dispatch(conn, message).catch((error) => {
37
+ this.log(`agent-service dispatch error: ${error instanceof Error ? error.stack : String(error)}`);
38
+ });
39
+ });
40
+ conn.onClose(() => this.handleConnClose(conn));
41
+ }
42
+ /** Reap every session (service shutdown). */
43
+ disposeAll() {
44
+ if (this.sweepHandle !== undefined) {
45
+ this.timers.cancel(this.sweepHandle);
46
+ this.sweepHandle = undefined;
47
+ }
48
+ for (const svc of [...this.sessions.values()])
49
+ this.reap(svc);
50
+ }
51
+ // ---- idle-reaper (detached sessions) --------------------------------------
52
+ /** Schedule the next sweep at ~half the TTL, so worst-case overshoot is bounded. */
53
+ armSweep() {
54
+ if (!this.detachReapMs)
55
+ return;
56
+ this.sweepHandle = this.timers.schedule(() => this.sweep(), Math.max(1, Math.floor(this.detachReapMs / 2)));
57
+ }
58
+ /**
59
+ * Reap sessions that have been DETACHED (no bound daemon connection) and IDLE
60
+ * (not currently streaming) for at least the TTL. Never touches an attached or
61
+ * streaming session — a live turn keeps running. Public for tests; also re-arms.
62
+ */
63
+ sweep() {
64
+ if (!this.detachReapMs)
65
+ return;
66
+ const now = this.now();
67
+ for (const svc of [...this.sessions.values()]) {
68
+ if (svc.disposed || svc.conn)
69
+ continue; // attached → keep
70
+ if (svc.session?.isStreaming)
71
+ continue; // mid-turn → keep (don't kill a live turn)
72
+ if (now - svc.lastActiveAt >= this.detachReapMs) {
73
+ this.log(`agent-service reaping detached idle session ${svc.id} (idle ${Math.round((now - svc.lastActiveAt) / 1000)}s)`);
74
+ this.reap(svc);
75
+ }
76
+ }
77
+ this.armSweep();
78
+ }
79
+ async dispatch(conn, message) {
80
+ switch (message.t) {
81
+ case "start":
82
+ return this.handleStart(conn, message);
83
+ case "rt":
84
+ return this.handleRuntimeCall(conn, message);
85
+ case "req":
86
+ return this.handleRequest(conn, message);
87
+ case "notify":
88
+ return this.handleNotify(conn, message);
89
+ case "intercept-res":
90
+ return this.handleInterceptResult(conn, message);
91
+ case "tool-invoke-res":
92
+ return this.handleToolInvokeResult(conn, message);
93
+ }
94
+ }
95
+ // ---- start / attach -------------------------------------------------------
96
+ async handleStart(conn, message) {
97
+ if (message.protocol !== RPC_PROTOCOL_VERSION) {
98
+ conn.send({ t: "res", id: message.id, ok: false, error: `Unsupported RPC protocol ${message.protocol} (service speaks ${RPC_PROTOCOL_VERSION})` });
99
+ return;
100
+ }
101
+ try {
102
+ if (message.op === "attach") {
103
+ const existing = message.options.sessionId ? this.sessions.get(message.options.sessionId) : undefined;
104
+ if (!existing || existing.disposed)
105
+ throw new Error(`No detached session to attach: ${message.options.sessionId}`);
106
+ // Re-bind: detach any stale connection, resync full state, resume forwarding.
107
+ existing.conn = conn;
108
+ existing.pendingIntercepts.clear();
109
+ existing.pendingInvokes.clear();
110
+ existing.lastActiveAt = this.now(); // re-attach counts as activity
111
+ this.binding.set(conn, existing);
112
+ existing.lastSent = this.mirror(existing.session);
113
+ conn.send({ t: "started", id: message.id, snapshot: this.initialSnapshot(existing) });
114
+ return;
115
+ }
116
+ const runtime = this.runtimeProvider(message.runtime, message.sandbox);
117
+ // Shell first so the interceptor closure can reference the not-yet-built session.
118
+ const svc = {
119
+ id: "",
120
+ runtime,
121
+ session: undefined,
122
+ unsubscribe: () => { },
123
+ conn,
124
+ interceptSeq: 1,
125
+ pendingIntercepts: new Map(),
126
+ invokeSeq: 1,
127
+ pendingInvokes: new Map(),
128
+ lastSent: undefined,
129
+ disposed: false,
130
+ lastActiveAt: this.now(),
131
+ };
132
+ const toolInterceptor = message.options.hasToolInterceptor ? this.makeInterceptor(svc) : undefined;
133
+ // Reconstruct a proxy ToolProvider from the specs the daemon sent, so the
134
+ // hosted runtime gets the tools while each call routes back to the daemon.
135
+ const toolProvider = message.options.toolSpecs?.length ? this.makeToolProvider(svc, message.options.toolSpecs) : undefined;
136
+ const workspace = message.options.workspace ?? process.cwd();
137
+ const result = message.op === "open" && message.options.sessionFile
138
+ ? await runtime.openSession({ workspace, sessionFile: message.options.sessionFile, toolInterceptor, toolProvider })
139
+ : await runtime.createSession({ workspace, toolInterceptor, toolProvider });
140
+ svc.session = result.session;
141
+ svc.id = result.session.id;
142
+ svc.lastSent = this.mirror(result.session);
143
+ this.sessions.set(svc.id, svc);
144
+ this.binding.set(conn, svc);
145
+ svc.unsubscribe = result.session.subscribe((event) => this.forward(svc, event));
146
+ const snapshot = this.initialSnapshot(svc);
147
+ if (result.warning)
148
+ snapshot.warning = result.warning;
149
+ conn.send({ t: "started", id: message.id, snapshot });
150
+ }
151
+ catch (error) {
152
+ conn.send({ t: "res", id: message.id, ok: false, error: error instanceof Error ? error.message : String(error) });
153
+ }
154
+ }
155
+ // ---- session-less runtime calls ------------------------------------------
156
+ async handleRuntimeCall(conn, message) {
157
+ if (message.protocol !== RPC_PROTOCOL_VERSION) {
158
+ conn.send({ t: "res", id: message.id, ok: false, error: `Unsupported RPC protocol ${message.protocol}` });
159
+ return;
160
+ }
161
+ try {
162
+ const runtime = this.runtimeProvider(message.runtime, message.sandbox);
163
+ let value;
164
+ if (message.method === "listSessions") {
165
+ value = await runtime.listSessions();
166
+ }
167
+ else if (message.method === "deleteSession") {
168
+ value = (await runtime.deleteSession?.(message.args[0], message.args[1])) ?? false;
169
+ }
170
+ conn.send({ t: "res", id: message.id, ok: true, value });
171
+ }
172
+ catch (error) {
173
+ conn.send({ t: "res", id: message.id, ok: false, error: error instanceof Error ? error.message : String(error) });
174
+ }
175
+ }
176
+ // ---- per-session method dispatch -----------------------------------------
177
+ async handleRequest(conn, message) {
178
+ const svc = this.binding.get(conn);
179
+ if (!svc) {
180
+ conn.send({ t: "res", id: message.id, ok: false, error: "No session bound to this connection" });
181
+ return;
182
+ }
183
+ const s = svc.session;
184
+ const [a0, a1] = message.args;
185
+ try {
186
+ let value;
187
+ let snapshot;
188
+ switch (message.method) {
189
+ case "prompt":
190
+ await s.prompt(a0, a1);
191
+ break;
192
+ case "abort":
193
+ await s.abort();
194
+ break;
195
+ case "setModel":
196
+ await s.setModel(a0, a1);
197
+ snapshot = { currentModel: s.getCurrentModel() ?? null };
198
+ break;
199
+ case "getModels":
200
+ value = await s.getModels();
201
+ break;
202
+ case "getAllModels":
203
+ value = (await s.getAllModels?.()) ?? [];
204
+ break;
205
+ case "getUsage":
206
+ value = (await s.getUsage?.()) ?? undefined;
207
+ break;
208
+ case "suggestName":
209
+ value = await s.suggestName(a0);
210
+ break;
211
+ case "invokeCommand":
212
+ await s.invokeCommand?.(a0, a1);
213
+ break;
214
+ case "interactiveTuiCommand":
215
+ value = (await s.interactiveTuiCommand?.()) ?? null;
216
+ break;
217
+ }
218
+ conn.send({ t: "res", id: message.id, ok: true, value, snapshot });
219
+ }
220
+ catch (error) {
221
+ conn.send({ t: "res", id: message.id, ok: false, error: error instanceof Error ? error.message : String(error) });
222
+ }
223
+ }
224
+ handleNotify(conn, message) {
225
+ const svc = this.binding.get(conn);
226
+ if (!svc)
227
+ return;
228
+ if (message.method === "dispose") {
229
+ this.reap(svc);
230
+ return;
231
+ }
232
+ try {
233
+ if (message.method === "setName")
234
+ svc.session.setName(message.args[0]);
235
+ else if (message.method === "setThinkingLevel")
236
+ svc.session.setThinkingLevel?.(message.args[0]);
237
+ }
238
+ catch (error) {
239
+ this.log(`agent-service notify ${message.method} error: ${String(error)}`);
240
+ }
241
+ }
242
+ handleInterceptResult(conn, message) {
243
+ const svc = this.binding.get(conn);
244
+ const resolve = svc?.pendingIntercepts.get(message.id);
245
+ if (svc && resolve) {
246
+ svc.pendingIntercepts.delete(message.id);
247
+ resolve(message.decision);
248
+ }
249
+ }
250
+ handleToolInvokeResult(conn, message) {
251
+ const svc = this.binding.get(conn);
252
+ const resolve = svc?.pendingInvokes.get(message.id);
253
+ if (svc && resolve) {
254
+ svc.pendingInvokes.delete(message.id);
255
+ resolve(message.result);
256
+ }
257
+ }
258
+ // ---- tool interception (reverse RPC to the daemon) ------------------------
259
+ makeInterceptor(svc) {
260
+ return (ctx) => {
261
+ const conn = svc.conn;
262
+ // Detached (daemon gone): never run an unapproved tool.
263
+ if (!conn)
264
+ return { block: true, reason: "agent daemon disconnected" };
265
+ return new Promise((resolve) => {
266
+ const id = svc.interceptSeq++;
267
+ svc.pendingIntercepts.set(id, resolve);
268
+ // If the turn aborts service-side, settle immediately so nothing hangs.
269
+ ctx.signal?.addEventListener("abort", () => {
270
+ if (svc.pendingIntercepts.delete(id))
271
+ resolve({});
272
+ }, { once: true });
273
+ conn.send({ t: "intercept", id, ctx: { sessionId: ctx.sessionId, toolName: ctx.toolName, input: ctx.input } });
274
+ });
275
+ };
276
+ }
277
+ // ---- node-hosted tools (reverse RPC to the daemon) ------------------------
278
+ /**
279
+ * Build a proxy ToolProvider from the specs the daemon sent. `list()` returns
280
+ * those specs (so the hosted runtime registers the tools); `invoke()` forwards
281
+ * the call to the daemon as a `tool-invoke` and resolves on its
282
+ * `tool-invoke-res`, so the tool executes on the daemon where its credentials
283
+ * live — the same reverse-RPC shape the guardian uses.
284
+ */
285
+ makeToolProvider(svc, specs) {
286
+ return {
287
+ list: () => specs,
288
+ invoke: (toolName, toolCallId, params, signal) => {
289
+ const conn = svc.conn;
290
+ // Detached (daemon gone): can't run a tool whose implementation lives there.
291
+ if (!conn)
292
+ return Promise.resolve({ content: [{ type: "text", text: "agent daemon disconnected" }], isError: true });
293
+ return new Promise((resolve) => {
294
+ const id = svc.invokeSeq++;
295
+ svc.pendingInvokes.set(id, resolve);
296
+ // If the turn aborts service-side, settle immediately so nothing hangs.
297
+ signal?.addEventListener("abort", () => {
298
+ if (svc.pendingInvokes.delete(id))
299
+ resolve({ content: [{ type: "text", text: "aborted" }], isError: true });
300
+ }, { once: true });
301
+ conn.send({ t: "tool-invoke", id, sessionId: svc.id, toolName, toolCallId, params });
302
+ });
303
+ },
304
+ };
305
+ }
306
+ // ---- event forwarding + snapshots ----------------------------------------
307
+ forward(svc, event) {
308
+ svc.lastActiveAt = this.now(); // any runtime event is activity (reaper anchor)
309
+ const conn = svc.conn;
310
+ if (!conn)
311
+ return; // detached: the daemon re-syncs full state on attach
312
+ // agent_end is the boundary the daemon's post-turn logic reads (usage
313
+ // refresh, worktree diff, PR detection). Force the authoritative post-turn
314
+ // state onto that frame so the mirror is correct even for a runtime that
315
+ // mutates messages/sessionFile exactly at agent_end (no prior message_end
316
+ // delta) — the "set before agent_end" invariant in process.ts.
317
+ const authoritative = event.type === "agent_end";
318
+ const snapshot = this.snapshotDelta(svc, authoritative);
319
+ const message = snapshot ? { t: "event", event, snapshot } : { t: "event", event };
320
+ conn.send(message);
321
+ }
322
+ mirror(session) {
323
+ return {
324
+ isStreaming: session.isStreaming,
325
+ sessionFile: session.sessionFile,
326
+ name: session.getName(),
327
+ currentModelKey: modelKey(session.getCurrentModel()),
328
+ activePid: session.activePid?.(),
329
+ thinkingLevel: session.getThinkingLevel?.(),
330
+ messageCount: session.getMessages().length,
331
+ };
332
+ }
333
+ /**
334
+ * Diff the current session state against lastSent; return only what changed.
335
+ * When `authoritative`, force the post-turn fields (isStreaming/sessionFile/
336
+ * activePid/messages) into the delta even if unchanged, so the caller can
337
+ * guarantee the agent_end frame carries them.
338
+ */
339
+ snapshotDelta(svc, authoritative = false) {
340
+ const s = svc.session;
341
+ const now = this.mirror(s);
342
+ const prev = svc.lastSent;
343
+ const delta = {};
344
+ let changed = false;
345
+ if (authoritative || now.isStreaming !== prev.isStreaming) {
346
+ delta.isStreaming = now.isStreaming;
347
+ changed = true;
348
+ }
349
+ if (authoritative || now.sessionFile !== prev.sessionFile) {
350
+ delta.sessionFile = now.sessionFile ?? null;
351
+ changed = true;
352
+ }
353
+ if (now.name !== prev.name) {
354
+ delta.name = now.name ?? null;
355
+ changed = true;
356
+ }
357
+ if (now.currentModelKey !== prev.currentModelKey) {
358
+ delta.currentModel = s.getCurrentModel() ?? null;
359
+ changed = true;
360
+ }
361
+ if (authoritative || now.activePid !== prev.activePid) {
362
+ delta.activePid = now.activePid ?? null;
363
+ changed = true;
364
+ }
365
+ if (now.thinkingLevel !== prev.thinkingLevel) {
366
+ delta.thinkingLevel = now.thinkingLevel ?? null;
367
+ changed = true;
368
+ }
369
+ if (authoritative || now.messageCount !== prev.messageCount) {
370
+ delta.messages = s.getMessages();
371
+ changed = true;
372
+ }
373
+ svc.lastSent = now;
374
+ return changed ? delta : undefined;
375
+ }
376
+ initialSnapshot(svc) {
377
+ const s = svc.session;
378
+ return {
379
+ sessionId: s.id,
380
+ cwd: s.cwd,
381
+ isStreaming: s.isStreaming,
382
+ sessionFile: s.sessionFile ?? null,
383
+ name: s.getName() ?? null,
384
+ currentModel: s.getCurrentModel() ?? null,
385
+ activePid: s.activePid?.() ?? null,
386
+ thinkingLevel: s.getThinkingLevel?.() ?? null,
387
+ messages: s.getMessages(),
388
+ capabilities: svc.runtime.capabilities,
389
+ supportsThinking: s.supportsThinking?.() ?? false,
390
+ availableThinkingLevels: s.getAvailableThinkingLevels?.() ?? [],
391
+ commands: s.getCommands?.() ?? [],
392
+ };
393
+ }
394
+ // ---- teardown -------------------------------------------------------------
395
+ handleConnClose(conn) {
396
+ const svc = this.binding.get(conn);
397
+ this.binding.delete(conn);
398
+ if (!svc || svc.disposed)
399
+ return;
400
+ if (svc.conn !== conn)
401
+ return; // a newer connection already took over
402
+ // Detach: keep the child running, deny any tool waiting on the vanished daemon.
403
+ svc.conn = null;
404
+ svc.lastActiveAt = this.now(); // start the idle-reaper clock from the detach
405
+ for (const resolve of svc.pendingIntercepts.values())
406
+ resolve({ block: true, reason: "agent daemon disconnected" });
407
+ svc.pendingIntercepts.clear();
408
+ for (const resolve of svc.pendingInvokes.values())
409
+ resolve({ content: [{ type: "text", text: "agent daemon disconnected" }], isError: true });
410
+ svc.pendingInvokes.clear();
411
+ this.log(`agent-service session ${svc.id} detached (kept running)`);
412
+ }
413
+ reap(svc) {
414
+ if (svc.disposed)
415
+ return;
416
+ svc.disposed = true;
417
+ for (const resolve of svc.pendingIntercepts.values())
418
+ resolve({ block: true, reason: "session disposed" });
419
+ svc.pendingIntercepts.clear();
420
+ for (const resolve of svc.pendingInvokes.values())
421
+ resolve({ content: [{ type: "text", text: "session disposed" }], isError: true });
422
+ svc.pendingInvokes.clear();
423
+ try {
424
+ svc.unsubscribe();
425
+ }
426
+ catch {
427
+ // ignore
428
+ }
429
+ try {
430
+ svc.session?.dispose();
431
+ }
432
+ catch (error) {
433
+ this.log(`agent-service dispose error for ${svc.id}: ${String(error)}`);
434
+ }
435
+ if (svc.conn)
436
+ this.binding.delete(svc.conn);
437
+ this.sessions.delete(svc.id);
438
+ }
439
+ }
@@ -0,0 +1,27 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // ANSI escape-sequence stripping for user-facing CLI output.
4
+ //
5
+ // Dumb-pipe CLI agents (OpenCode, Aider, …) colorize their stdout/stderr with
6
+ // ANSI SGR/CSI escapes meant for a real terminal. Bivy relays that text into the
7
+ // app's "Agent output" pane verbatim, where the escapes render as garbage like
8
+ // `[91m[1mError: [0m…` instead of a red bold "Error:". The pane shows plain
9
+ // text, so the color codes carry no information — strip them at display time.
10
+ //
11
+ // We strip only what's emitted to the UI, never the buffered stdout/stderr the
12
+ // runtime parses for exit-code/error logic, so parsing stays byte-exact.
13
+ // ESC () or 8-bit CSI (›) introducer followed by either:
14
+ // • an OSC string (`ESC ] … BEL` — window title / hyperlinks), or
15
+ // • a CSI sequence (`ESC [ params final-byte` — colors, cursor moves).
16
+ // The final-byte class is deliberately broad so SGR colors (incl. 24-bit),
17
+ // cursor moves, and erase codes are all removed. Mirrors the widely-used
18
+ // `ansi-regex` pattern, restated with explicit escapes (no literal control
19
+ // characters in source).
20
+ const ANSI_PATTERN = new RegExp([
21
+ "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d/#&.:=?%@~_]*)*)?\\u0007)",
22
+ "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))",
23
+ ].join("|"), "g");
24
+ /** Remove ANSI escape sequences from a string for plain-text display. */
25
+ export function stripAnsi(input) {
26
+ return input.replace(ANSI_PATTERN, "");
27
+ }
@@ -0,0 +1,80 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Anthropic (Claude Code SDK) credential preflight + auth-error phrasing.
4
+ //
5
+ // The Claude Agent SDK authenticates via `ANTHROPIC_API_KEY`, a Claude
6
+ // Pro/Max subscription token (`CLAUDE_CODE_OAUTH_TOKEN`), or the `claude` CLI's
7
+ // own on-disk login. Bivy's shared vault forwards whichever Anthropic credential
8
+ // the node holds (see credentials.ts / claude-code.ts's resolveCredentialEnv),
9
+ // but when the node has none, the SDK spawns and its very first request fails
10
+ // with an opaque upstream `unexpected status 401 Unauthorized: …`.
11
+ //
12
+ // This module detects the no-credential state up front so Bivy can surface a
13
+ // clear, actionable message instead of the raw 401, and phrases any auth failure
14
+ // that still slips through (expired token, revoked key) with the same guidance.
15
+ import fs from "node:fs";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ /** Candidate paths for the `claude` CLI's own stored login (`.credentials.json`). */
19
+ export function claudeCredentialFiles(deps = {}) {
20
+ const home = deps.home ?? os.homedir();
21
+ const dirs = [deps.configDir ?? process.env.CLAUDE_CONFIG_DIR, path.join(home, ".claude")]
22
+ .filter((d) => Boolean(d && d.trim()));
23
+ return dirs.map((d) => path.join(d, ".credentials.json"));
24
+ }
25
+ /** How to fix a missing/invalid Anthropic credential — shared by both messages. */
26
+ const ANTHROPIC_AUTH_FIX = [
27
+ "• Add an Anthropic API key or sign in with Claude under Models & providers, or",
28
+ "• Run `claude` on this node and use `/login` to sign in with your Claude subscription.",
29
+ ].join("\n");
30
+ /** Shown when the node has no Anthropic credential at all (preflight block). */
31
+ export const ANTHROPIC_NO_CREDENTIAL_MESSAGE = [
32
+ "Claude Code has no Anthropic credential on this node, so it can't start a turn — it would fail with `401 Unauthorized`.",
33
+ "",
34
+ "Sign in one of these ways, then start the session again:",
35
+ ANTHROPIC_AUTH_FIX,
36
+ ].join("\n");
37
+ /** Appended to a raw auth failure that surfaced from the SDK despite a credential. */
38
+ export const ANTHROPIC_AUTH_HINT = ["This looks like an authentication failure.", ANTHROPIC_AUTH_FIX].join("\n");
39
+ /**
40
+ * Whether the Claude Code SDK will find a usable Anthropic credential given the
41
+ * environment it will run with. True when an API key or OAuth token is in the
42
+ * env, or the `claude` CLI has a login on disk. On macOS the CLI login lives in
43
+ * the Keychain (no inspectable file), so this returns true there rather than
44
+ * risk false-blocking a working login — the SDK falls back to its own auth.
45
+ */
46
+ export function hasAnthropicCredential(env, deps = {}) {
47
+ if (env.ANTHROPIC_API_KEY?.trim() || env.CLAUDE_CODE_OAUTH_TOKEN?.trim())
48
+ return true;
49
+ const platform = deps.platform ?? process.platform;
50
+ if (platform === "darwin")
51
+ return true;
52
+ const exists = deps.fileExists ?? ((p) => {
53
+ try {
54
+ return fs.existsSync(p);
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ });
60
+ return claudeCredentialFiles(deps).some(exists);
61
+ }
62
+ /**
63
+ * Returns an actionable error when the Claude Code SDK has no usable Anthropic
64
+ * credential, or undefined when one is present so the turn should proceed.
65
+ */
66
+ export function anthropicCredentialPreflight(env, deps = {}) {
67
+ return hasAnthropicCredential(env, deps) ? undefined : ANTHROPIC_NO_CREDENTIAL_MESSAGE;
68
+ }
69
+ /** True when a raw error string looks like an Anthropic auth failure (401 etc.). */
70
+ export function isAnthropicAuthError(raw) {
71
+ return /\b401\b|unauthorized|authentication|invalid x-api-key|(missing|invalid).*(bearer|api[\s_-]?key|token)/i.test(raw);
72
+ }
73
+ /**
74
+ * Phrase an SDK error for the user: an auth failure gets the sign-in guidance
75
+ * appended; anything else is returned unchanged.
76
+ */
77
+ export function describeAnthropicError(raw) {
78
+ const text = raw.trim() || "Claude Code error";
79
+ return isAnthropicAuthError(text) ? `${text}\n\n${ANTHROPIC_AUTH_HINT}` : text;
80
+ }