@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,105 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { generateKeyPairSync, createPublicKey, createPrivateKey, diffieHellman, hkdfSync, createHmac, randomBytes, timingSafeEqual, } from "node:crypto";
4
+ import { seal, open } from "./e2e.js";
5
+ import { HKDF_INFO, ROOM_KEY_BYTES, WRAP_KEY_BYTES, PAIR_SECRET_BYTES } from "./wire-format.js";
6
+ /**
7
+ * Pairing crypto for the X25519 device-linking handshake (replaces shipping the
8
+ * room key inside the QR fragment).
9
+ *
10
+ * Design
11
+ * ------
12
+ * The node keeps a long-term X25519 identity keypair and a symmetric ROOM KEY
13
+ * used for bulk frame encryption (so the relay still broadcasts one ciphertext
14
+ * to all devices, exactly as before). Pairing establishes, per device, a stable
15
+ * "wrap key" via ECDH(node_priv, device_pub); the node delivers the current room
16
+ * key encrypted under that wrap key. The relay can route the wrapped key but
17
+ * cannot derive the wrap key (it holds neither private key).
18
+ *
19
+ * Authenticating the device's public key
20
+ * --------------------------------------
21
+ * The node's public key reaches the phone OUT OF BAND via the QR (a trusted
22
+ * screen), so the phone cannot be fooled about node identity. The phone's public
23
+ * key reaches the node OVER the relay, so a malicious relay could try to
24
+ * substitute its own. We bind the first exchange with a high-entropy
25
+ * `pairSecret` carried only in the QR (never over the relay): the phone proves
26
+ * knowledge of it with HMAC(pairSecret, device_pub). The relay never saw the QR,
27
+ * so it cannot forge the proof — at worst it can cause pairing to fail, never to
28
+ * leak the room key. After the first exchange the node stores the authentic
29
+ * device public key and can re-wrap future room keys (on revoke/rotate) without
30
+ * re-scanning.
31
+ *
32
+ * Wire encoding is base64url for all key material so it survives JSON/QR/URLs.
33
+ */
34
+ const PAIR_INFO = Buffer.from(HKDF_INFO.pair);
35
+ const ROTATE_INFO = Buffer.from(HKDF_INFO.rotate);
36
+ const MODEL_AUTH_VAULT_INFO = Buffer.from(HKDF_INFO.modelAuthVault);
37
+ const GITHUB_APP_VAULT_INFO = Buffer.from(HKDF_INFO.githubAppVault);
38
+ const EMPTY_SALT = Buffer.alloc(0);
39
+ /** Generate a fresh X25519 keypair (node identity or device ephemeral). */
40
+ export function generatePairingKeypair() {
41
+ const { publicKey, privateKey } = generateKeyPairSync("x25519");
42
+ return {
43
+ publicKeyB64: rawPublicKey(publicKey).toString("base64url"),
44
+ privateKeyB64: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64url"),
45
+ };
46
+ }
47
+ /** Raw 32-byte X25519 public key (the form WebCrypto exports/imports as "raw"). */
48
+ function rawPublicKey(key) {
49
+ // JWK `x` is the base64url raw public key for OKP/X25519.
50
+ const jwk = key.export({ format: "jwk" });
51
+ if (!jwk.x)
52
+ throw new Error("could not export X25519 public key");
53
+ return Buffer.from(jwk.x, "base64url");
54
+ }
55
+ function publicKeyFromRaw(rawB64) {
56
+ return createPublicKey({
57
+ key: { kty: "OKP", crv: "X25519", x: rawB64 },
58
+ format: "jwk",
59
+ });
60
+ }
61
+ function privateKeyFromB64(privB64) {
62
+ return createPrivateKey({ key: Buffer.from(privB64, "base64url"), type: "pkcs8", format: "der" });
63
+ }
64
+ /**
65
+ * Derive the per-device wrap key. Identical on both ends because ECDH is
66
+ * symmetric: ECDH(node_priv, device_pub) === ECDH(device_priv, node_pub).
67
+ */
68
+ export function deriveWrapKey(ourPrivateKeyB64, theirPublicKeyB64, purpose) {
69
+ const shared = diffieHellman({
70
+ privateKey: privateKeyFromB64(ourPrivateKeyB64),
71
+ publicKey: publicKeyFromRaw(theirPublicKeyB64),
72
+ });
73
+ const info = purpose === "pair" ? PAIR_INFO
74
+ : purpose === "rotate" ? ROTATE_INFO
75
+ : purpose === "github-app-vault" ? GITHUB_APP_VAULT_INFO
76
+ : MODEL_AUTH_VAULT_INFO;
77
+ return Buffer.from(hkdfSync("sha256", shared, EMPTY_SALT, info, WRAP_KEY_BYTES));
78
+ }
79
+ /** A fresh 32-byte symmetric room key. */
80
+ export function generateRoomKey() {
81
+ return randomBytes(ROOM_KEY_BYTES);
82
+ }
83
+ /** A high-entropy single-use pairing secret carried only in the QR. */
84
+ export function generatePairSecret() {
85
+ return randomBytes(PAIR_SECRET_BYTES).toString("base64url");
86
+ }
87
+ /** HMAC-SHA256(pairSecret, device_pub) — the phone's proof it holds the QR secret. */
88
+ export function pairingProof(pairSecretB64, devicePublicKeyB64) {
89
+ return createHmac("sha256", Buffer.from(pairSecretB64, "base64url"))
90
+ .update(Buffer.from(devicePublicKeyB64, "base64url"))
91
+ .digest("base64url");
92
+ }
93
+ export function verifyPairingProof(pairSecretB64, devicePublicKeyB64, proofB64) {
94
+ const expected = pairingProof(pairSecretB64, devicePublicKeyB64);
95
+ const a = Buffer.from(expected, "base64url");
96
+ const b = Buffer.from(proofB64, "base64url");
97
+ return a.length === b.length && timingSafeEqual(a, b);
98
+ }
99
+ /** Wrap (encrypt) the room key under a per-device wrap key for delivery. */
100
+ export function wrapRoomKey(wrapKey, roomKey) {
101
+ return seal(wrapKey, roomKey.toString("base64"));
102
+ }
103
+ export function unwrapRoomKey(wrapKey, wrapped) {
104
+ return Buffer.from(open(wrapKey, wrapped), "base64");
105
+ }
@@ -0,0 +1,103 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Typed runtime-failure conditions + a classifier.
5
+ //
6
+ // This is the load-bearing seam of policy-driven run orchestration: rules match
7
+ // STABLE condition codes, never raw provider error strings. A raw failure (an
8
+ // opaque `429`, a `credit balance is too low`, a socket hang-up) is classified
9
+ // here exactly once into a `RuntimeCondition` plus whatever recovery metadata we
10
+ // could recover (a `retryAfterMs`, a `resetsAt`), and every downstream rule,
11
+ // evidence event, and effector speaks only that vocabulary. Add a new provider
12
+ // quirk here and the whole policy layer understands it — no rule edits, no
13
+ // regexes scattered through runtimes and the queue poller.
14
+ import { isAnthropicAuthError } from "../runtime/anthropic-preflight.js";
15
+ function rawText(error) {
16
+ if (error instanceof Error)
17
+ return error.message;
18
+ if (typeof error === "string")
19
+ return error;
20
+ try {
21
+ return JSON.stringify(error) ?? String(error);
22
+ }
23
+ catch {
24
+ return String(error);
25
+ }
26
+ }
27
+ /** Parse a "wait N" hint from a raw error into milliseconds, if present. */
28
+ export function parseRetryAfterMs(raw) {
29
+ // `retry-after: 30` / `retry_after=30` (seconds, HTTP-style).
30
+ const header = /retry[\s_-]?after["'\s:=]+(\d{1,5})/i.exec(raw);
31
+ if (header?.[1])
32
+ return Number(header[1]) * 1000;
33
+ // "try again in 12 seconds" / "retry in 5 minutes".
34
+ const phrase = /(?:try again|retry)\s+in\s+(\d{1,5})\s*(ms|s|sec|second|m|min|minute|h|hour)/i.exec(raw);
35
+ if (phrase?.[1]) {
36
+ const n = Number(phrase[1]);
37
+ const unit = phrase[2].toLowerCase();
38
+ if (unit === "ms")
39
+ return n;
40
+ if (unit.startsWith("h"))
41
+ return n * 3_600_000;
42
+ if (unit.startsWith("m") && unit !== "ms")
43
+ return n * 60_000;
44
+ return n * 1000;
45
+ }
46
+ return undefined;
47
+ }
48
+ /** Parse an ISO reset timestamp (e.g. `resets_at: 2026-07-27T18:00:00Z`). */
49
+ export function parseResetsAt(raw) {
50
+ const iso = /(20\d\d-\d\d-\d\dT[\d:.]+(?:Z|[+-]\d\d:?\d\d))/.exec(raw);
51
+ return iso?.[1];
52
+ }
53
+ // Ordered classifiers: the FIRST match wins, so more-specific/actionable
54
+ // conditions are tested before broader ones (auth 401 before generic HTTP
55
+ // noise; explicit billing/quota before a bare rate-limit; context-window before
56
+ // a generic "too long").
57
+ const CLASSIFIERS = [
58
+ { condition: "auth_failed", test: (r) => isAnthropicAuthError(r) },
59
+ {
60
+ condition: "credits_exhausted",
61
+ test: (r) => /\b402\b|payment required|insufficient\s+(?:credit|quota|balance|funds)|credit balance (?:is )?too low|quota (?:exceeded|exhausted)|billing|(?:usage|session) limit (?:reached|hit)|(?:you(?:'ve| have)\s+)?hit your limit|out of credits|plan (?:limit|allowance)/i.test(r),
62
+ },
63
+ {
64
+ condition: "rate_limited",
65
+ test: (r) => /\b429\b|\b529\b|rate[\s_-]?limit|too many requests|overloaded/i.test(r),
66
+ },
67
+ {
68
+ condition: "context_overflow",
69
+ test: (r) => /context[\s_-]?(?:length|window|limit)|maximum context|prompt is too long|too many tokens|exceeds?\b[^.]*\btokens?\b/i.test(r),
70
+ },
71
+ {
72
+ condition: "node_offline",
73
+ test: (r) => /ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|no route to host|node (?:is )?offline|host unreachable/i.test(r),
74
+ },
75
+ {
76
+ condition: "transport_error",
77
+ test: (r) => /ETIMEDOUT|ECONNRESET|EPIPE|EAI_AGAIN|ENOTFOUND|socket hang ?up|network (?:error|timeout)|fetch failed|timed? ?out|temporarily unavailable|\b50[234]\b/i.test(r),
78
+ },
79
+ {
80
+ condition: "task_failed",
81
+ test: (r) => /tests? failed|checks? failed|no progress|made no changes|nothing to commit|assertion/i.test(r),
82
+ },
83
+ ];
84
+ /**
85
+ * Classify a raw runtime failure into a stable `RuntimeCondition` plus any
86
+ * recovery metadata. Unmatched failures are `"unknown"` — deliberately left for
87
+ * a human rather than blindly retried.
88
+ */
89
+ export function classifyFailure(error) {
90
+ const raw = rawText(error).slice(0, 2000);
91
+ const condition = CLASSIFIERS.find((c) => c.test(raw))?.condition ?? "unknown";
92
+ const out = { condition, raw };
93
+ // Recovery hints are only meaningful for the wait-and-retry conditions.
94
+ if (condition === "rate_limited" || condition === "credits_exhausted") {
95
+ const retryAfterMs = parseRetryAfterMs(raw);
96
+ if (retryAfterMs !== undefined)
97
+ out.retryAfterMs = retryAfterMs;
98
+ const resetsAt = parseResetsAt(raw);
99
+ if (resetsAt !== undefined)
100
+ out.resetsAt = resetsAt;
101
+ }
102
+ return out;
103
+ }
@@ -0,0 +1,20 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { guardToolCall } from "../guard.js";
4
+ import { riskCategoryForTool } from "./risk.js";
5
+ /** Runtime-agnostic policy decision layer. Strong runtimes call this before tools.
6
+ * Persistent "remembered decisions" were removed — the engine now only applies
7
+ * the mode-based guard floor; governance beyond that is left to the agents. */
8
+ export class PolicyEngine {
9
+ options;
10
+ constructor(options) {
11
+ this.options = options;
12
+ }
13
+ decideToolCall(workspace, toolName, input) {
14
+ const risk = riskCategoryForTool(toolName);
15
+ if (this.options.unrestricted)
16
+ return { decision: "allow", risk };
17
+ const base = guardToolCall(workspace, toolName, input, this.options.mode, this.options.isRiskyIntegration ?? (() => false));
18
+ return { ...base, risk };
19
+ }
20
+ }
@@ -0,0 +1,18 @@
1
+ export function riskCategoryForTool(toolName) {
2
+ const tool = toolName.toLowerCase();
3
+ if (["write", "edit", "multi_edit", "patch"].includes(tool))
4
+ return "filesystem";
5
+ if (["bash", "shell", "terminal"].includes(tool))
6
+ return "shell";
7
+ if (tool.includes("git") || tool.includes("github"))
8
+ return "git.write";
9
+ if (tool.includes("secret") || tool.includes("credential") || tool.includes("token"))
10
+ return "secret.read";
11
+ if (tool.includes("stripe") || tool.includes("payment"))
12
+ return "payment";
13
+ if (tool.includes("deploy") || tool.includes("release"))
14
+ return "deploy";
15
+ if (tool.includes("http") || tool.includes("fetch") || tool.includes("curl"))
16
+ return "network.write";
17
+ return "unknown";
18
+ }
@@ -0,0 +1,113 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // The ruleset schema + validator + the pure matcher.
5
+ //
6
+ // A ruleset is user-authored policy: which failures are recoverable, how to
7
+ // recover (retry / reroute through a fallback chain / park for a human), and the
8
+ // bounds (max attempts, backoff). It is validated once with typebox into a
9
+ // versioned in-memory shape — we never execute arbitrary YAML expressions — and
10
+ // the same ruleset serves both the interactive session and the work queue,
11
+ // gated by `appliesTo`. The MATCHER here is pure: `(ruleset, condition,
12
+ // context) → Rule | undefined`. How the chosen rule is carried out lives in the
13
+ // context-specific effectors (see run-policy.ts for the queue effector).
14
+ import { Type } from "typebox";
15
+ import { Check, Errors } from "typebox/value";
16
+ // ── Schema (typebox) ────────────────────────────────────────────────────────
17
+ // Declared with typebox so a ruleset loaded from disk/YAML is validated before
18
+ // it can steer a live run. Kept intentionally small for v1: the reroute slice.
19
+ // The literals are spelled out (not mapped) so typebox infers the union type.
20
+ const ConditionSchema = Type.Union([
21
+ Type.Literal("rate_limited"),
22
+ Type.Literal("credits_exhausted"),
23
+ Type.Literal("context_overflow"),
24
+ Type.Literal("auth_failed"),
25
+ Type.Literal("node_offline"),
26
+ Type.Literal("transport_error"),
27
+ Type.Literal("task_failed"),
28
+ Type.Literal("unknown"),
29
+ ]);
30
+ const RoutingCandidateSchema = Type.Object({
31
+ runtimeId: Type.Optional(Type.String()),
32
+ model: Type.Optional(Type.String()),
33
+ /** Provider/account id to route through — matched against configured creds. */
34
+ account: Type.Optional(Type.String()),
35
+ /** Human-readable label for evidence, if none is derivable. */
36
+ label: Type.Optional(Type.String()),
37
+ }, { additionalProperties: false });
38
+ const BackoffSchema = Type.Object({
39
+ baseMs: Type.Integer({ minimum: 0 }),
40
+ factor: Type.Number({ minimum: 1 }),
41
+ capMs: Type.Integer({ minimum: 0 }),
42
+ jitter: Type.Number({ minimum: 0, maximum: 1 }),
43
+ }, { additionalProperties: false });
44
+ const RuleSchema = Type.Object({
45
+ when: Type.Array(ConditionSchema, { minItems: 1 }),
46
+ action: Type.Union([Type.Literal("retry"), Type.Literal("reroute"), Type.Literal("park")]),
47
+ /** Ordered fallback candidates for `reroute`; first with valid creds wins. */
48
+ chain: Type.Optional(Type.Array(RoutingCandidateSchema)),
49
+ /** What to do when retries/chain are exhausted. Defaults to `park`. */
50
+ onExhausted: Type.Optional(Type.Union([Type.Literal("park"), Type.Literal("give_up")])),
51
+ maxAttempts: Type.Integer({ minimum: 1, maximum: 100 }),
52
+ backoff: Type.Optional(BackoffSchema),
53
+ }, { additionalProperties: false });
54
+ export const RulesetSchema = Type.Object({
55
+ /** Schema version — lets stored rulesets migrate without silent misreads. */
56
+ version: Type.Literal(1),
57
+ name: Type.String({ minLength: 1 }),
58
+ appliesTo: Type.Array(Type.Union([Type.Literal("session"), Type.Literal("queue")]), { minItems: 1 }),
59
+ rules: Type.Array(RuleSchema),
60
+ }, { additionalProperties: false });
61
+ /** Backoff used when a retry/reroute rule doesn't specify one. Mirrors the
62
+ * reconnect layer's defaults (see src/session/reconnect.ts BackoffOptions). */
63
+ export const DEFAULT_BACKOFF = { baseMs: 2000, factor: 2, capMs: 60_000, jitter: 0.3 };
64
+ /**
65
+ * The built-in default ruleset — deliberately SAFE, not clever.
66
+ *
67
+ * Infra hiccups (transient transport, provider rate-limits) retry with backoff;
68
+ * quota/auth/context failures are PARKED for a human (we can't presume a valid
69
+ * fallback model or account for an arbitrary node); genuine task failures and
70
+ * anything unclassified fall through to the caller's existing failure path.
71
+ * Reroute + fallback chains are fully supported (see run-policy.ts) but opt-in:
72
+ * author a rule with a `chain` to enable cross-account/model/provider fallback.
73
+ */
74
+ export const DEFAULT_RULESET = {
75
+ version: 1,
76
+ name: "default",
77
+ appliesTo: ["queue", "session"],
78
+ rules: [
79
+ { when: ["transport_error", "node_offline"], action: "retry", maxAttempts: 3, backoff: DEFAULT_BACKOFF },
80
+ {
81
+ when: ["rate_limited"],
82
+ action: "retry",
83
+ maxAttempts: 4,
84
+ backoff: { baseMs: 5000, factor: 2, capMs: 120_000, jitter: 0.3 },
85
+ },
86
+ { when: ["credits_exhausted"], action: "park", maxAttempts: 1 },
87
+ { when: ["context_overflow"], action: "park", maxAttempts: 1 },
88
+ { when: ["auth_failed"], action: "park", maxAttempts: 1 },
89
+ ],
90
+ };
91
+ /**
92
+ * Validate an untrusted value (e.g. parsed from a user's YAML/JSON) against the
93
+ * ruleset schema. Returns the typed ruleset on success, or a bounded list of
94
+ * human-readable errors — never throws.
95
+ */
96
+ export function validateRuleset(value) {
97
+ if (Check(RulesetSchema, value))
98
+ return { ok: true, ruleset: value, errors: [] };
99
+ const errors = [...Errors(RulesetSchema, value)]
100
+ .slice(0, 20)
101
+ .map((e) => `${e.path || "/"}: ${e.message}`);
102
+ return { ok: false, errors: errors.length ? errors : ["ruleset did not match the expected shape"] };
103
+ }
104
+ /**
105
+ * The pure matcher: the first rule that applies to `context` and lists
106
+ * `condition` in its `when`. Undefined means "no policy for this failure" — the
107
+ * caller keeps its existing behavior (e.g. fail the run).
108
+ */
109
+ export function findRule(ruleset, condition, context) {
110
+ if (!ruleset.appliesTo.includes(context))
111
+ return undefined;
112
+ return ruleset.rules.find((rule) => rule.when.includes(condition));
113
+ }
@@ -0,0 +1,108 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Run policy: the pure decision layer the queue effector drives.
5
+ //
6
+ // Given a failed attempt (its current routing, the raw error, the attempt count,
7
+ // and how many reroutes have already been applied), it classifies the failure,
8
+ // finds the matching rule, and returns ONE decision: retry (with a backoff
9
+ // delay), reroute (to the next credentialed candidate in the rule's chain), park
10
+ // (hand to a human), or give_up (no policy — keep the caller's existing
11
+ // behavior). It touches no I/O and no clock beyond an injectable `random`, so
12
+ // it's fully deterministic and unit-testable. The poller (src/control-plane-
13
+ // tasks.ts) is the effector that carries the decision out on the live run.
14
+ import { classifyFailure } from "./conditions.js";
15
+ import { DEFAULT_BACKOFF, DEFAULT_RULESET, findRule, } from "./ruleset.js";
16
+ /** `min(cap, base·factor^n)` spread by ±jitter/2 — the reconnect-layer formula. */
17
+ export function computeBackoffMs(cfg, n, random) {
18
+ const raw = Math.min(cfg.capMs, cfg.baseMs * Math.pow(cfg.factor, n));
19
+ const spread = raw * cfg.jitter * (random() - 0.5);
20
+ return Math.max(0, Math.round(raw + spread));
21
+ }
22
+ function candidateRef(c) {
23
+ return (c.label ||
24
+ [c.runtimeId, c.model, c.account && `@${c.account}`].filter(Boolean).join(" / ") ||
25
+ "fallback route");
26
+ }
27
+ /** Build a run policy bound to a ruleset. Stateless: all per-run state (attempt,
28
+ * rerouteCount) is passed in, so one instance safely serves every run. */
29
+ export function createRunPolicy(deps = {}) {
30
+ const ruleset = deps.ruleset ?? DEFAULT_RULESET;
31
+ const context = deps.context ?? "queue";
32
+ const hasCredential = deps.hasCredential ?? (() => true);
33
+ const random = deps.random ?? Math.random;
34
+ const now = deps.now ?? Date.now;
35
+ return {
36
+ decide(ctx) {
37
+ const classified = classifyFailure(ctx.error);
38
+ const { condition } = classified;
39
+ const rule = findRule(ruleset, condition, context);
40
+ if (!rule)
41
+ return { action: "give_up", condition };
42
+ const exhausted = ctx.attempt >= rule.maxAttempts;
43
+ const nextAttempt = ctx.attempt + 1;
44
+ const backoff = rule.backoff ?? DEFAULT_BACKOFF;
45
+ // A provider-supplied reset is the most precise recovery time. This is
46
+ // especially important for session/usage limits: retrying with ordinary
47
+ // backoff before the window resets only burns attempts. A relative
48
+ // Retry-After is the next-best hint; otherwise use authored backoff.
49
+ const resetAtMs = classified.resetsAt === undefined ? undefined : Date.parse(classified.resetsAt);
50
+ const resetDelayMs = resetAtMs !== undefined && Number.isFinite(resetAtMs)
51
+ ? Math.max(0, resetAtMs - now())
52
+ : undefined;
53
+ const delayMs = resetDelayMs ?? classified.retryAfterMs ?? computeBackoffMs(backoff, ctx.attempt - 1, random);
54
+ const onExhausted = () => rule.onExhausted === "give_up"
55
+ ? { action: "give_up", condition }
56
+ : {
57
+ action: "park",
58
+ condition,
59
+ summary: `${condition}: no recovery left after ${ctx.attempt} attempt(s) — needs attention.`,
60
+ };
61
+ if (rule.action === "park") {
62
+ return { action: "park", condition, summary: `${condition}: parked for a human — no automatic recovery.` };
63
+ }
64
+ if (rule.action === "retry") {
65
+ if (exhausted)
66
+ return onExhausted();
67
+ const wait = Math.round(delayMs / 1000);
68
+ const timing = resetDelayMs !== undefined && classified.resetsAt
69
+ ? ` when the limit resets at ${classified.resetsAt}`
70
+ : wait ? ` in ~${wait}s` : "";
71
+ return {
72
+ action: "retry",
73
+ delayMs,
74
+ condition,
75
+ summary: `${condition}: transient — retrying (attempt ${nextAttempt}/${rule.maxAttempts})${timing}.`,
76
+ };
77
+ }
78
+ // action === "reroute": walk the chain from the current cursor, skipping
79
+ // any candidate we can prove lacks credentials on this node, or that is a
80
+ // no-op (a pure model change equal to the model we're already on).
81
+ const chain = rule.chain ?? [];
82
+ const isNoop = (c) => c.model !== undefined && c.model === ctx.routing.model && c.runtimeId === undefined && c.account === undefined;
83
+ let cursor = ctx.rerouteCount;
84
+ while (cursor < chain.length && (!hasCredential(chain[cursor]) || isNoop(chain[cursor])))
85
+ cursor += 1;
86
+ if (exhausted || cursor >= chain.length)
87
+ return onExhausted();
88
+ const candidate = chain[cursor];
89
+ const routing = {};
90
+ if (candidate.runtimeId !== undefined)
91
+ routing.runtimeId = candidate.runtimeId;
92
+ if (candidate.model !== undefined)
93
+ routing.model = candidate.model;
94
+ if (candidate.account !== undefined)
95
+ routing.account = candidate.account;
96
+ const ref = candidateRef(candidate);
97
+ return {
98
+ action: "reroute",
99
+ delayMs,
100
+ condition,
101
+ routing,
102
+ ref,
103
+ rerouteCount: cursor + 1,
104
+ summary: `${condition}: rerouting to ${ref} (attempt ${nextAttempt}).`,
105
+ };
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,96 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Session effector — in-session model reroute.
5
+ //
6
+ // The queue effector (src/control-plane-tasks.ts) reruns a failed item under the
7
+ // run policy. This is the interactive-session counterpart, scoped to the ONE
8
+ // action a live session can take fully in-place: swapping the model. When a turn
9
+ // ends in a recoverable error (credits exhausted, rate-limited) and the session
10
+ // ruleset says `reroute` down a model chain, this swaps the model via the
11
+ // runtime's `setModel` and re-sends the same prompt — so the session continues on
12
+ // a cheaper/other model instead of surfacing an error.
13
+ //
14
+ // It deliberately does NOT handle agent (runtime) or node reroutes: those are
15
+ // forks / standby promotions (new session identity, possibly reduced fidelity),
16
+ // not in-place swaps — a separate, heavier effector (see docs/rulesets.md). A
17
+ // chain candidate that changes anything other than the model is skipped here and
18
+ // the error is left to surface.
19
+ //
20
+ // The decision is SYNCHRONOUS (`planReroute`) so the caller can atomically decide
21
+ // whether to suppress the turn's error toast before kicking off the async swap +
22
+ // retry (`applyReroute`). Reroute happens only at the turn boundary, so there is
23
+ // no partial-work hazard.
24
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
25
+ export class SessionRerouteController {
26
+ deps;
27
+ attempt = 1;
28
+ rerouteCount = 0;
29
+ applying = false;
30
+ constructor(deps) {
31
+ this.deps = deps;
32
+ }
33
+ /** Reset the reroute budget at the start of each new user-initiated turn. */
34
+ beginTurn() {
35
+ this.attempt = 1;
36
+ this.rerouteCount = 0;
37
+ }
38
+ /**
39
+ * Synchronously decide whether this turn error should become an in-place model
40
+ * reroute. Returns a plan (the caller should suppress the error toast and call
41
+ * `applyReroute`) or null (let the error surface as usual). Pure w.r.t. the
42
+ * controller's counters — those advance only when the plan is applied.
43
+ */
44
+ planReroute(rawError, currentModel) {
45
+ if (this.applying)
46
+ return null;
47
+ const decision = this.deps.policy.decide({
48
+ routing: { model: currentModel },
49
+ error: rawError,
50
+ attempt: this.attempt,
51
+ rerouteCount: this.rerouteCount,
52
+ });
53
+ if (decision.action !== "reroute")
54
+ return null;
55
+ const model = decision.routing.model;
56
+ // Only a MODEL change is applicable in-session; a chain candidate that swaps
57
+ // agent/account/node (or is a no-op) is left to surface / a future effector.
58
+ if (!model || model === currentModel)
59
+ return null;
60
+ return {
61
+ model,
62
+ condition: decision.condition,
63
+ summary: decision.summary,
64
+ delayMs: decision.delayMs,
65
+ rerouteCount: decision.rerouteCount,
66
+ };
67
+ }
68
+ /** Apply a plan: swap the model and re-drive the turn. Best-effort; on a failed
69
+ * swap it reports via `onFailed` rather than throwing. */
70
+ async applyReroute(plan, target) {
71
+ if (this.applying)
72
+ return;
73
+ this.applying = true;
74
+ try {
75
+ this.deps.onEvent?.({ kind: "fallback", summary: plan.summary });
76
+ this.deps.onNotice?.({
77
+ level: "info",
78
+ message: `Switching to ${plan.model} after ${plan.condition.replace(/_/g, " ")}, then retrying…`,
79
+ });
80
+ if (plan.delayMs > 0)
81
+ await (this.deps.sleep ?? defaultSleep)(plan.delayMs);
82
+ await target.setModel("", plan.model);
83
+ this.deps.onModelChanged?.();
84
+ this.attempt += 1;
85
+ this.rerouteCount = plan.rerouteCount;
86
+ await target.reprompt();
87
+ }
88
+ catch (error) {
89
+ const message = error instanceof Error ? error.message : String(error);
90
+ this.deps.onFailed?.(`Couldn't reroute to ${plan.model}: ${message}`);
91
+ }
92
+ finally {
93
+ this.applying = false;
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env python3
2
+ """Run a command under a pseudo-terminal while relaying stdio over pipes."""
3
+
4
+ import os
5
+ import pty
6
+ import select
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+
11
+
12
+ def main() -> int:
13
+ if len(sys.argv) < 2:
14
+ print("usage: pty-runner.py command [args...]", file=sys.stderr)
15
+ return 2
16
+
17
+ master_fd, slave_fd = pty.openpty()
18
+ env = os.environ.copy()
19
+ env.setdefault("TERM", "xterm-256color")
20
+
21
+ proc = subprocess.Popen(
22
+ sys.argv[1:],
23
+ stdin=slave_fd,
24
+ stdout=slave_fd,
25
+ stderr=slave_fd,
26
+ env=env,
27
+ close_fds=True,
28
+ start_new_session=True,
29
+ )
30
+ os.close(slave_fd)
31
+
32
+ def forward_signal(signum, _frame):
33
+ try:
34
+ os.killpg(proc.pid, signum)
35
+ except ProcessLookupError:
36
+ pass
37
+
38
+ signal.signal(signal.SIGINT, forward_signal)
39
+ signal.signal(signal.SIGTERM, forward_signal)
40
+
41
+ stdin_fd = sys.stdin.fileno()
42
+ stdout_fd = sys.stdout.fileno()
43
+ stdin_open = True
44
+
45
+ while True:
46
+ read_fds = [master_fd]
47
+ if stdin_open:
48
+ read_fds.append(stdin_fd)
49
+
50
+ readable, _, _ = select.select(read_fds, [], [], 0.1)
51
+
52
+ if master_fd in readable:
53
+ try:
54
+ data = os.read(master_fd, 8192)
55
+ except OSError:
56
+ data = b""
57
+ if not data:
58
+ break
59
+ os.write(stdout_fd, data)
60
+
61
+ if stdin_open and stdin_fd in readable:
62
+ data = os.read(stdin_fd, 8192)
63
+ if not data:
64
+ stdin_open = False
65
+ else:
66
+ try:
67
+ os.write(master_fd, data)
68
+ except OSError:
69
+ break
70
+
71
+ if proc.poll() is not None:
72
+ # Drain any final PTY output.
73
+ while True:
74
+ readable, _, _ = select.select([master_fd], [], [], 0)
75
+ if not readable:
76
+ break
77
+ try:
78
+ data = os.read(master_fd, 8192)
79
+ except OSError:
80
+ break
81
+ if not data:
82
+ break
83
+ os.write(stdout_fd, data)
84
+ break
85
+
86
+ try:
87
+ os.close(master_fd)
88
+ except OSError:
89
+ pass
90
+
91
+ return proc.wait()
92
+
93
+
94
+ if __name__ == "__main__":
95
+ raise SystemExit(main())