larkin 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,12 @@
1
- # Larkin
1
+ <p align="center">
2
+ <img src="./assets/readme/larkin-hero.svg" width="100%" alt="Larkin connecting Codex, Claude Code, and Pi agent runtimes to Feishu with a local dashboard and message surfaces.">
3
+ </p>
2
4
 
3
- Larkin connects Codex, Claude Code, and Pi agent runtimes to Feishu (Lark). It provides a local runtime host, persistent sessions, reminders, interactive messages, and a local dashboard.
5
+ <p align="center">
6
+ <a href="#quick-start">Quick start</a> · <a href="#requirements">Requirements</a> · <a href="#what-it-does">What it does</a> · <a href="#installation">Installation</a> · <a href="#usage">Usage</a> · <a href="#setup-and-configuration">Setup and configuration</a> · <a href="#details">Details</a> · <a href="#development">Development</a>
7
+ </p>
8
+
9
+ Larkin is a local Runtime Host that connects Codex, Claude Code, and Pi agent runtimes to Feishu (Lark). It keeps sessions, reminders, interactive messages, and a local dashboard close to the machine that runs them.
4
10
 
5
11
  ## Requirements
6
12
 
@@ -9,9 +15,56 @@ Larkin connects Codex, Claude Code, and Pi agent runtimes to Feishu (Lark). It p
9
15
  - At least one supported agent runtime and its authentication
10
16
  - Bun 1.3.14 when running the npm package or building from source (standalone binaries bundle their own runtime)
11
17
 
18
+ ## Quick start
19
+
20
+ ```bash
21
+ npx larkin@latest setup
22
+ npx larkin@latest start
23
+ npx larkin@latest status
24
+ ```
25
+
26
+ ## What it does
27
+
28
+ <table>
29
+ <tr>
30
+ <td valign="top">
31
+
32
+ **Runtime host**
33
+
34
+ Connect supported coding-agent runtimes to Feishu from one local process.
35
+
36
+ </td>
37
+ <td valign="top">
38
+
39
+ **Persistent workflow**
40
+
41
+ Keep sessions and reminders available across runs.
42
+
43
+ </td>
44
+ </tr>
45
+ <tr>
46
+ <td valign="top">
47
+
48
+ **Message surfaces**
49
+
50
+ Work with Feishu messages, interactive cards, and related automation.
51
+
52
+ </td>
53
+ <td valign="top">
54
+
55
+ **Local visibility**
56
+
57
+ Inspect the host through the embedded dashboard and OpenTelemetry traces.
58
+
59
+ </td>
60
+ </tr>
61
+ </table>
62
+
63
+ The rest of this document uses the short `larkin` form; it works as-is after `npm install -g larkin`, or prefix any command with `npx larkin@latest` to run it without installing.
64
+
12
65
  ## Installation
13
66
 
14
- Larkin is published to the npm registry and also distributed as standalone binaries. Prefer npm:
67
+ Prefer npm:
15
68
 
16
69
  ```bash
17
70
  # Run the latest version directly with npx — no install step, always the newest release
@@ -26,39 +79,24 @@ Standalone binaries for macOS, Linux, and Windows (x64) are attached to every [G
26
79
 
27
80
  ## Usage
28
81
 
29
- ```bash
30
- npx larkin@latest setup
31
- npx larkin@latest start
32
- npx larkin@latest status
33
- ```
34
-
35
- The rest of this document uses the short `larkin` form; it works as-is after `npm install -g larkin`, or prefix any command with `npx larkin@latest` to run it without installing.
36
-
37
- Run `larkin --help` or `larkin config --help` for the available commands and configuration options. Local configuration is stored under `~/.larkin` by default; set `LARKIN_CONFIG_DIR` to use another directory.
82
+ Run `larkin --help` or `larkin config --help` for the available commands and configuration options. `larkin agents` reports event readiness, reply-scope readiness, subscription mode/status/dimension, arrivals, and read failures. Local configuration is stored under `~/.larkin` by default; set `LARKIN_CONFIG_DIR` to use another directory.
38
83
 
39
84
  ### Feishu message links
40
85
 
41
86
  Feishu clients do not reliably render Markdown links such as `[label](URL)` as clickable in text or Markdown messages. When a recipient must be able to open a link, keep the complete bare HTTPS URL visible, for example: Issue 115 — https://github.com/eddiearc/larkin/issues/115. A label may accompany it, but must not replace the bare URL. Larkin does not rewrite exact, verbatim, or user-authored message bodies to enforce this guidance.
42
87
 
43
- During setup, a new Agent is offered Pi first, followed by Codex and Claude Code. Pi can use an existing
44
- official `pi` installation or the official Pi runtime bundled in Larkin. The bundled option supports
45
- DeepSeek, Kimi/Moonshot, MiniMax, Zhipu/BigModel, and a custom OpenAI-compatible Base URL. Provider keys
46
- are stored only in the selected Agent's private provider directory, not in the ordinary Agent config. Setup
47
- also discovers every API-key and OAuth/subscription login exposed by the pinned official Pi registry and
48
- delegates those flows to Pi. Use `larkin pi-auth status` or `larkin pi-auth logout <provider>` to manage them.
49
- The builtin Pi runtime loads Larkin's two supported extensions inline: background subagents and the 60-second
50
- foreground bash timeout guard are enabled without writing extension files or arguments. A compatible external
51
- Pi installation keeps the existing explicit `-e` extension path.
88
+ ## Setup and configuration
89
+
90
+ During setup, a new Agent is offered Pi first, followed by Codex and Claude Code. Provider keys are stored only in the selected Agent's private provider directory, not in the ordinary Agent config. Setup also discovers every API-key and OAuth/subscription login exposed by the pinned official Pi registry and delegates those flows to Pi.
91
+
92
+ Use `larkin pi-auth status` or `larkin pi-auth logout <provider>` to manage Pi auth. The builtin Pi runtime loads Larkin's two supported extensions inline: background subagents and the 60-second foreground bash timeout guard are enabled without writing extension files or arguments. A compatible external Pi installation keeps the existing explicit `-e` extension path.
52
93
 
53
- ### Windows support boundary and optional autostart
94
+ <details>
95
+ <summary>Windows support and optional autostart</summary>
54
96
 
55
- Windows 11 x64 core support covers the standalone CLI, local Runtime Host startup, builtin Pi RPC with the
56
- inline extensions above, and the embedded Dashboard. Provider authentication, the official `lark-cli`, and
57
- external Codex, Claude Code, or Pi executables remain separately installed dependencies; secret-bearing live
58
- channel/provider tests are intentionally outside the hosted Windows CI gate.
97
+ Windows 11 x64 core support covers the standalone CLI, local Runtime Host startup, builtin Pi RPC with the inline extensions above, and the embedded Dashboard. Provider authentication, the official `lark-cli`, and external Codex, Claude Code, or Pi executables remain separately installed dependencies; secret-bearing live channel/provider tests are intentionally outside the hosted Windows CI gate.
59
98
 
60
- An Administrator account can optionally start Larkin at that account's interactive logon with Task Scheduler.
61
- From an elevated PowerShell prompt, adjust the executable and working-directory paths first:
99
+ An Administrator account can optionally start Larkin at that account's interactive logon with Task Scheduler. From an elevated PowerShell prompt, adjust the executable and working-directory paths first:
62
100
 
63
101
  ```powershell
64
102
  $Exe = 'C:\Tools\Larkin\larkin.exe'
@@ -69,13 +107,13 @@ Register-ScheduledTask -TaskName 'Larkin Runtime Host' -Action $Action -Trigger
69
107
  -Description 'Start Larkin for this Administrator account at logon' -RunLevel Highest
70
108
  ```
71
109
 
72
- This is an optional per-user Administrator-logon task, not SYSTEM boot support or a Windows service. Keep the
73
- account profile available because Larkin stores its state there. Release executables are currently unsigned;
74
- normal Windows security policy and SmartScreen decisions still apply.
110
+ This is an optional per-user Administrator-logon task, not SYSTEM boot support or a Windows service. Keep the account profile available because Larkin stores its state there. Release executables are currently unsigned; normal Windows security policy and SmartScreen decisions still apply.
111
+ </details>
75
112
 
76
- For supported Feishu cloud-document comments (`doc`, `docx`, `sheet`, and `file`), the safe default accepts only comments or replies that @ the Bot. Document comments never reuse IM `require`/`free` settings. An explicit platform-verified application-dimension Bot subscription accepts every supported comment event that Feishu actually delivers, whether or not it mentions the Bot. Larkin stores accepted comments as `kind=document_comment` canonical Inbox events and wakes the Bot's persistent Agent. Replies are bound back to the exact comment; whole-document comments use Feishu's top-level fallback. `larkin setup --comment-subscription application` makes the broad trigger surface explicit, creates the Bot subscription through the official `lark-cli` structured API, and then verifies it with the read-only platform status API. `--comment-subscription none` explicitly removes that application subscription and verifies removal. Until positive status verification, only @Bot comments enter the Inbox. Setup requests `docs:document.comment:create` for both in-thread replies and whole-document `create_v2` fallback, while retaining `drive:drive` for event/read support. `larkin agents` reports event readiness, reply-scope readiness, subscription mode/status/dimension, arrivals, and read failures.
113
+ ## Details
77
114
 
78
- ## OpenTelemetry traces
115
+ <details>
116
+ <summary>OpenTelemetry traces</summary>
79
117
 
80
118
  Larkin records a privacy-safe timing waterfall for each woken Feishu message. Tracing is always enabled, and ended spans first enter a durable local OTLP/HTTP JSON spool. Message processing therefore does not depend on an observability backend being reachable.
81
119
 
@@ -136,6 +174,7 @@ larkin.message.process
136
174
  ```
137
175
 
138
176
  The compose stack binds Grafana, Tempo, and OTLP only to `127.0.0.1` and persists `/data`. It is intended for development, demos, and testing. Do not expose its default credentials or plaintext OTLP port publicly; use a deployment-owned TLS/authenticated endpoint or private network for remote automatic upload.
177
+ </details>
139
178
 
140
179
  ## Development
141
180
 
@@ -0,0 +1,111 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="400" viewBox="0 0 1200 400" role="img" aria-labelledby="title desc">
2
+ <title id="title">Larkin local runtime host</title>
3
+ <desc id="desc">A project-native hero showing Codex, Claude Code, and Pi connecting through Larkin to Feishu, reminders, interactive messages, and a local dashboard.</desc>
4
+ <defs>
5
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
6
+ <stop offset="0%" stop-color="#07111f"/>
7
+ <stop offset="100%" stop-color="#0c1730"/>
8
+ </linearGradient>
9
+ <radialGradient id="glow-cyan" cx="0" cy="0" r="1">
10
+ <stop offset="0%" stop-color="#00b8d9" stop-opacity="0.34"/>
11
+ <stop offset="100%" stop-color="#00b8d9" stop-opacity="0"/>
12
+ </radialGradient>
13
+ <radialGradient id="glow-blue" cx="0" cy="0" r="1">
14
+ <stop offset="0%" stop-color="#1456f0" stop-opacity="0.28"/>
15
+ <stop offset="100%" stop-color="#1456f0" stop-opacity="0"/>
16
+ </radialGradient>
17
+ <linearGradient id="core" x1="0" y1="0" x2="1" y2="1">
18
+ <stop offset="0%" stop-color="#11284f"/>
19
+ <stop offset="100%" stop-color="#0e1f3f"/>
20
+ </linearGradient>
21
+ <linearGradient id="pill" x1="0" y1="0" x2="1" y2="1">
22
+ <stop offset="0%" stop-color="#11203f"/>
23
+ <stop offset="100%" stop-color="#0b1528"/>
24
+ </linearGradient>
25
+ <pattern id="grid" width="36" height="36" patternUnits="userSpaceOnUse">
26
+ <path d="M 36 0 L 0 0 0 36" fill="none" stroke="#ffffff" stroke-opacity="0.04" stroke-width="1"/>
27
+ </pattern>
28
+ </defs>
29
+
30
+ <rect width="1200" height="400" rx="28" fill="url(#bg)"/>
31
+ <rect width="1200" height="400" rx="28" fill="url(#grid)"/>
32
+ <circle cx="140" cy="60" r="230" fill="url(#glow-cyan)"/>
33
+ <circle cx="1080" cy="52" r="260" fill="url(#glow-blue)"/>
34
+ <circle cx="1010" cy="340" r="220" fill="url(#glow-cyan)" opacity="0.45"/>
35
+
36
+ <g id="title-block" transform="translate(56 56)" fill="#f7fbff" font-family="-apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, sans-serif">
37
+ <text x="0" y="0" font-size="16" font-weight="700" letter-spacing="3.4" fill="#8fb7d8">LOCAL RUNTIME HOST</text>
38
+ <text x="0" y="80" font-size="76" font-weight="800" letter-spacing="-1.8">Larkin</text>
39
+ <text x="0" y="128" font-size="30" font-weight="600" fill="#d9e8ff">Connect Codex, Claude Code, and Pi</text>
40
+ <text x="0" y="166" font-size="30" font-weight="600" fill="#d9e8ff">to Feishu with one local host.</text>
41
+ <text x="0" y="210" font-size="20" font-weight="500" fill="#9cb4cf">Persistent sessions · reminders · interactive messages · local dashboard</text>
42
+
43
+ <g transform="translate(0 238)" font-size="18" font-weight="700" fill="#ecf4ff">
44
+ <rect x="0" y="0" width="138" height="38" rx="19" fill="#0f1b33" stroke="#223556"/>
45
+ <text x="69" y="24" text-anchor="middle">npm package</text>
46
+ <rect x="150" y="0" width="176" height="38" rx="19" fill="#0f1b33" stroke="#223556"/>
47
+ <text x="238" y="24" text-anchor="middle">standalone binaries</text>
48
+ <rect x="338" y="0" width="150" height="38" rx="19" fill="#0f1b33" stroke="#223556"/>
49
+ <text x="413" y="24" text-anchor="middle">Bun 1.3.14</text>
50
+ </g>
51
+
52
+ <g transform="translate(0 292)">
53
+ <rect x="0" y="0" width="408" height="48" rx="14" fill="#07111f" stroke="#244068"/>
54
+ <text x="18" y="31" font-size="18" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" fill="#8ff0ff">npx larkin@latest setup</text>
55
+ <text x="240" y="31" font-size="18" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" fill="#8fb7d8">→ start → status</text>
56
+ </g>
57
+ </g>
58
+
59
+ <g id="project-proof" transform="translate(706 50)" font-family="-apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, sans-serif">
60
+ <rect x="0" y="0" width="444" height="300" rx="26" fill="#0b162b" fill-opacity="0.92" stroke="#2a3f63"/>
61
+ <text x="24" y="34" font-size="16" font-weight="700" letter-spacing="2.6" fill="#8fb7d8">RUNTIME GRAPH</text>
62
+
63
+ <g fill="#eff6ff" font-size="17" font-weight="700">
64
+ <rect x="24" y="74" width="120" height="44" rx="22" fill="url(#pill)" stroke="#264267"/>
65
+ <text x="84" y="101" text-anchor="middle">Codex</text>
66
+
67
+ <rect x="24" y="136" width="120" height="44" rx="22" fill="url(#pill)" stroke="#264267"/>
68
+ <text x="84" y="163" text-anchor="middle">Claude Code</text>
69
+
70
+ <rect x="24" y="198" width="120" height="44" rx="22" fill="url(#pill)" stroke="#264267"/>
71
+ <text x="84" y="225" text-anchor="middle">Pi</text>
72
+ </g>
73
+
74
+ <g stroke-linecap="round" fill="#eff6ff">
75
+ <path d="M144 96 C 196 96, 220 118, 252 132" stroke="#00b8d9" stroke-opacity="0.76" stroke-width="3"/>
76
+ <path d="M144 158 C 196 158, 220 154, 252 150" stroke="#1456f0" stroke-opacity="0.76" stroke-width="3"/>
77
+ <path d="M144 220 C 196 220, 220 186, 252 168" stroke="#a3402c" stroke-opacity="0.72" stroke-width="3"/>
78
+ <path d="M322 150 C 338 150, 352 122, 328 108" stroke="#00b8d9" stroke-opacity="0.42" stroke-width="3"/>
79
+ <path d="M322 150 C 338 150, 352 156, 328 156" stroke="#1456f0" stroke-opacity="0.42" stroke-width="3"/>
80
+ <path d="M322 150 C 338 150, 352 192, 328 214" stroke="#a3402c" stroke-opacity="0.42" stroke-width="3"/>
81
+ <path d="M322 150 C 338 150, 352 232, 328 266" stroke="#8fb7d8" stroke-opacity="0.42" stroke-width="3"/>
82
+ </g>
83
+
84
+ <g>
85
+ <rect x="252" y="92" width="70" height="116" rx="26" fill="url(#core)" stroke="#00b8d9" stroke-opacity="0.7"/>
86
+ <rect x="266" y="108" width="42" height="18" rx="9" fill="#0b162b" stroke="#27456f"/>
87
+ <text x="287" y="122" text-anchor="middle" font-size="10" font-weight="800" fill="#8fb7d8" letter-spacing="1.6">CORE</text>
88
+ <text x="287" y="158" text-anchor="middle" font-size="24" font-weight="800" fill="#f7fbff">LARKIN</text>
89
+ <text x="287" y="181" text-anchor="middle" font-size="12" font-weight="600" fill="#9cb4cf">local host</text>
90
+ </g>
91
+
92
+ <g fill="#eff6ff" font-size="15" font-weight="700">
93
+ <rect x="328" y="92" width="92" height="34" rx="17" fill="#0f1b33" stroke="#264267"/>
94
+ <text x="374" y="113" text-anchor="middle">Feishu</text>
95
+
96
+ <rect x="328" y="144" width="92" height="34" rx="17" fill="#0f1b33" stroke="#264267"/>
97
+ <text x="374" y="165" text-anchor="middle">Cards</text>
98
+
99
+ <rect x="328" y="196" width="92" height="34" rx="17" fill="#0f1b33" stroke="#264267"/>
100
+ <text x="374" y="217" text-anchor="middle">Reminders</text>
101
+
102
+ <rect x="328" y="248" width="92" height="34" rx="17" fill="#0f1b33" stroke="#264267"/>
103
+ <text x="374" y="269" text-anchor="middle">Dashboard</text>
104
+ </g>
105
+
106
+ <g>
107
+ <rect x="24" y="254" width="214" height="26" rx="13" fill="#07111f" stroke="#244068"/>
108
+ <text x="38" y="272" font-size="12" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" fill="#8fb7d8">sessions + traces + local config</text>
109
+ </g>
110
+ </g>
111
+ </svg>
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.AgentStateStore = void 0;
36
+ exports.AgentStateStore = exports.ContextOverflowRecoveryError = void 0;
37
37
  exports.createAgentStateStore = createAgentStateStore;
38
38
  const fs = __importStar(require("node:fs"));
39
39
  const crypto = __importStar(require("node:crypto"));
@@ -42,6 +42,7 @@ const root_layout_js_1 = require("../platform/root-layout.cjs");
42
42
  const process_state_js_1 = require("../platform/process-state.cjs");
43
43
  const secure_metadata_js_1 = require("../platform/secure-metadata.cjs");
44
44
  const inbox_projection_js_1 = require("./inbox-projection.cjs");
45
+ const provider_error_classifier_js_1 = require("../runtime/provider-error-classifier.cjs");
45
46
  const JSON_KEYS = new Set([
46
47
  "agentState", "status", "map", "replyctx", "botIdentity", "senderProfiles", "readReceipts", "pendingReact", "runtimeDeliveries", "inboxState", "freshnessState", "documentComments", "reminders", "interactions",
47
48
  ]);
@@ -197,6 +198,15 @@ function validInboxLockOwner(value) {
197
198
  && typeof owner.processStartToken === "string" && !!owner.processStartToken
198
199
  && typeof owner.nonce === "string" && /^[0-9a-f-]{36}$/i.test(owner.nonce);
199
200
  }
201
+ class ContextOverflowRecoveryError extends Error {
202
+ code;
203
+ constructor(code, message) {
204
+ super(message);
205
+ this.code = code;
206
+ this.name = "ContextOverflowRecoveryError";
207
+ }
208
+ }
209
+ exports.ContextOverflowRecoveryError = ContextOverflowRecoveryError;
200
210
  class AgentStateStore {
201
211
  paths;
202
212
  boundary;
@@ -728,6 +738,189 @@ class AgentStateStore {
728
738
  return { status: "missing", code: "canonical_inbox_row_missing" };
729
739
  });
730
740
  }
741
+ /**
742
+ * Re-arm only the durable deliveries proven to be the context-window incident.
743
+ * The Inbox bytes are intentionally untouched. An optional synchronous commit
744
+ * hook lets RuntimeHost swap its in-memory generation while this same lock is
745
+ * still held. If the hook fails, the ledger is restored before the lock is
746
+ * released. The callback also receives a guarded rollback for post-callback
747
+ * commit failures before retry scheduling begins.
748
+ */
749
+ rearmContextOverflow(onCommit, expected) {
750
+ let lockActive = true;
751
+ try {
752
+ return this.withInboxTransaction(() => {
753
+ let rows;
754
+ try {
755
+ rows = this.readNdjson("inbox");
756
+ }
757
+ catch {
758
+ throw new ContextOverflowRecoveryError("canonical_inbox_malformed", "canonical Inbox is malformed");
759
+ }
760
+ if (rows.length === 0)
761
+ throw new ContextOverflowRecoveryError("inbox_empty", "canonical Inbox backlog is empty");
762
+ const messageIds = new Set();
763
+ for (const row of rows) {
764
+ try {
765
+ (0, inbox_projection_js_1.targetKeyOfInboxEnvelope)(row);
766
+ }
767
+ catch {
768
+ throw new ContextOverflowRecoveryError("canonical_inbox_malformed", "canonical Inbox contains a malformed row");
769
+ }
770
+ if (typeof row.message_id !== "string" || !row.message_id) {
771
+ throw new ContextOverflowRecoveryError("canonical_inbox_malformed", "canonical Inbox row has no message identity");
772
+ }
773
+ if (messageIds.has(row.message_id))
774
+ throw new ContextOverflowRecoveryError("duplicate_message_id", "canonical Inbox contains duplicate message identities");
775
+ messageIds.add(row.message_id);
776
+ }
777
+ const ledger = this.readJson("runtimeDeliveries", { version: 1, records: [] });
778
+ if (!Array.isArray(ledger.records))
779
+ throw new ContextOverflowRecoveryError("canonical_inbox_malformed", "Runtime delivery ledger is malformed");
780
+ const ledgerRecords = ledger.records;
781
+ const matches = new Map();
782
+ const deliveryIds = new Set();
783
+ const inputIds = new Set();
784
+ for (const candidate of ledger.records) {
785
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
786
+ continue;
787
+ const record = candidate;
788
+ if (typeof record.messageId !== "string" || !messageIds.has(record.messageId))
789
+ continue;
790
+ if (typeof record.deliveryId !== "string" || !record.deliveryId)
791
+ throw new ContextOverflowRecoveryError("delivery_missing", "a matching Runtime delivery record has no delivery identity");
792
+ if (!record.input || typeof record.input !== "object" || Array.isArray(record.input)) {
793
+ throw new ContextOverflowRecoveryError("delivery_identity_invalid", "a matching Runtime delivery record has no Runtime input identity");
794
+ }
795
+ const input = record.input;
796
+ if (typeof input.inputId !== "string" || !input.inputId.trim()
797
+ || typeof input.deliveryId !== "string" || input.deliveryId !== record.deliveryId) {
798
+ throw new ContextOverflowRecoveryError("delivery_identity_invalid", "a matching Runtime delivery record has malformed input identity");
799
+ }
800
+ if (matches.has(record.messageId))
801
+ throw new ContextOverflowRecoveryError("delivery_duplicate", "a canonical Inbox row maps to multiple delivery records");
802
+ if (deliveryIds.has(record.deliveryId))
803
+ throw new ContextOverflowRecoveryError("delivery_duplicate", "matching Runtime delivery records share a delivery identity");
804
+ if (inputIds.has(input.inputId))
805
+ throw new ContextOverflowRecoveryError("duplicate_input_id", "matching Runtime delivery records share an input identity");
806
+ deliveryIds.add(record.deliveryId);
807
+ inputIds.add(input.inputId);
808
+ matches.set(record.messageId, record);
809
+ }
810
+ if (expected?.messageId && !messageIds.has(expected.messageId)) {
811
+ throw new ContextOverflowRecoveryError("delivery_missing", "the expected context-overflow message is not in canonical Inbox");
812
+ }
813
+ if (expected?.messageId) {
814
+ const expectedRecord = matches.get(expected.messageId);
815
+ if (!expectedRecord || (expected.deliveryId && expectedRecord.deliveryId !== expected.deliveryId)
816
+ || (expected.inputId && expectedRecord.input.inputId !== expected.inputId)) {
817
+ throw new ContextOverflowRecoveryError("delivery_identity_invalid", "the expected context-overflow delivery identity changed");
818
+ }
819
+ }
820
+ for (const messageId of messageIds) {
821
+ const record = matches.get(messageId);
822
+ if (!record)
823
+ throw new ContextOverflowRecoveryError("delivery_missing", "a canonical Inbox row has no Runtime delivery record");
824
+ if (record.status !== "error" || record.retryable === true) {
825
+ throw new ContextOverflowRecoveryError("delivery_not_terminal", "a canonical Inbox row is not backed by a non-retryable terminal Runtime error");
826
+ }
827
+ const reasonCategory = (0, provider_error_classifier_js_1.classifyStrictProviderError)((0, provider_error_classifier_js_1.buildStrictProviderErrorInput)({
828
+ reason: record.reason, errorCategory: record.errorCategory,
829
+ }));
830
+ if (record.retryable !== false || reasonCategory !== "context_window"
831
+ || (record.errorCategory !== undefined && record.errorCategory !== "context_window")) {
832
+ throw new ContextOverflowRecoveryError("delivery_not_context_window", "a Runtime delivery error is not classified as context_window");
833
+ }
834
+ }
835
+ for (const candidate of ledgerRecords) {
836
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
837
+ continue;
838
+ const record = candidate;
839
+ if (typeof record.deliveryId === "string" && deliveryIds.has(record.deliveryId)
840
+ && (typeof record.messageId !== "string" || !messageIds.has(record.messageId))) {
841
+ throw new ContextOverflowRecoveryError("delivery_duplicate", "a matching delivery identity is also used by an unrelated Runtime delivery record");
842
+ }
843
+ }
844
+ const updatedAt = new Date().toISOString();
845
+ const records = ledgerRecords.map((candidate) => {
846
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
847
+ return candidate;
848
+ const record = candidate;
849
+ if (typeof record.messageId !== "string" || !messageIds.has(record.messageId))
850
+ return candidate;
851
+ return { ...record, status: "pending", retryable: true, updatedAt,
852
+ reason: "context-window recovery rearmed the retained Inbox delivery" };
853
+ });
854
+ const committedLedger = { ...ledger, records };
855
+ let restored = false;
856
+ const restoreUnlocked = () => {
857
+ if (restored)
858
+ return;
859
+ const current = this.readJson("runtimeDeliveries", { version: 1, records: [] });
860
+ if (!Array.isArray(current.records))
861
+ throw new Error("Runtime delivery rollback found a malformed ledger");
862
+ const originalMatching = ledgerRecords.filter((candidate) => {
863
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
864
+ return false;
865
+ const record = candidate;
866
+ return typeof record.messageId === "string" && messageIds.has(record.messageId);
867
+ });
868
+ const originalDeliveryIds = new Set(originalMatching.flatMap((candidate) => {
869
+ const deliveryId = candidate.deliveryId;
870
+ return typeof deliveryId === "string" ? [deliveryId] : [];
871
+ }));
872
+ const currentMatching = current.records.filter((candidate) => {
873
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
874
+ return false;
875
+ const record = candidate;
876
+ return (typeof record.deliveryId === "string" && originalDeliveryIds.has(record.deliveryId))
877
+ || (typeof record.messageId === "string" && messageIds.has(record.messageId));
878
+ });
879
+ const matchingRestored = currentMatching.length === originalMatching.length
880
+ && currentMatching.every((candidate, index) => JSON.stringify(candidate) === JSON.stringify(originalMatching[index]));
881
+ if (!matchingRestored) {
882
+ const unrelated = current.records.filter((candidate) => {
883
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
884
+ return true;
885
+ const record = candidate;
886
+ return !((typeof record.deliveryId === "string" && originalDeliveryIds.has(record.deliveryId))
887
+ || (typeof record.messageId === "string" && messageIds.has(record.messageId)));
888
+ });
889
+ const restoredRecords = [...unrelated];
890
+ for (const candidate of originalMatching) {
891
+ const originalIndex = ledgerRecords.indexOf(candidate);
892
+ restoredRecords.splice(Math.min(originalIndex, restoredRecords.length), 0, candidate);
893
+ }
894
+ this.writeJson("runtimeDeliveries", { ...current, records: restoredRecords });
895
+ }
896
+ restored = true;
897
+ };
898
+ const rollback = () => {
899
+ if (lockActive)
900
+ restoreUnlocked();
901
+ else
902
+ this.withInboxTransaction(restoreUnlocked);
903
+ };
904
+ try {
905
+ this.writeJson("runtimeDeliveries", committedLedger);
906
+ onCommit?.([...messageIds], rollback);
907
+ }
908
+ catch (error) {
909
+ try {
910
+ rollback();
911
+ }
912
+ catch {
913
+ throw new Error("context-window recovery ledger rollback failed");
914
+ }
915
+ throw error;
916
+ }
917
+ return { rearmedCount: messageIds.size, remainingPendingCount: rows.length };
918
+ });
919
+ }
920
+ finally {
921
+ lockActive = false;
922
+ }
923
+ }
731
924
  /**
732
925
  * Recoverably bridge an outbox wake into the canonical Inbox. A durable
733
926
  * Runtime ledger is authoritative after the Inbox has been drained, so a