@yugabytedb/meko-mcp 2.2.0 → 2.3.0

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 (63) hide show
  1. package/README.md +106 -38
  2. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +1473 -0
  3. package/assets/hooks-handlers/lib/capture-notices.js +131 -14
  4. package/assets/hooks-handlers/lib/capture-state.js +95 -1
  5. package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
  6. package/assets/hooks-handlers/lib/capture.js +1237 -137
  7. package/assets/hooks-handlers/lib/checkpoint-timer.js +51 -10
  8. package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
  9. package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
  10. package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
  11. package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
  12. package/assets/hooks-handlers/lib/terminal-handoff.js +10 -1
  13. package/assets/opencode-plugin/meko.js +213 -0
  14. package/assets/scripts/meko-statusline.sh +85 -18
  15. package/assets/skills/meko-mcp-tools/SKILL.md +57 -14
  16. package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
  17. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
  18. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +86 -14
  19. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
  20. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -6
  21. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +8 -4
  22. package/assets/skills/meko-mcp-tools/references/tools-overview.md +32 -22
  23. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
  24. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +45 -1
  25. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
  26. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +54 -18
  27. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
  28. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +8 -6
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +95 -33
  31. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +14 -10
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +35 -25
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
  36. package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
  37. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +5 -5
  38. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
  39. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
  40. package/bin/create-meko-setup.mjs +70 -7
  41. package/lib/capture-cleanup.mjs +63 -0
  42. package/lib/clients/claude-code.mjs +1 -0
  43. package/lib/clients/codex.mjs +80 -35
  44. package/lib/clients/common.mjs +7 -0
  45. package/lib/clients/index.mjs +2 -0
  46. package/lib/clients/kiro.mjs +5 -5
  47. package/lib/clients/opencode.mjs +532 -0
  48. package/lib/config.mjs +68 -1
  49. package/lib/installer.mjs +18 -2
  50. package/lib/migrate/adapters/claude-code.mjs +11 -2
  51. package/lib/migrate/adapters/codex.mjs +9 -2
  52. package/lib/migrate/adapters/memory-md.mjs +27 -8
  53. package/lib/migrate/adapters/opencode.mjs +148 -0
  54. package/lib/migrate/claude-parser.mjs +153 -9
  55. package/lib/migrate/id.mjs +195 -13
  56. package/lib/migrate/mcp-client.mjs +25 -1
  57. package/lib/migrate/opencode-normalizer.mjs +132 -0
  58. package/lib/migrate/orchestrator.mjs +293 -77
  59. package/lib/migrate/progress.mjs +239 -5
  60. package/lib/migrate/registry.mjs +72 -0
  61. package/lib/migrate.mjs +96 -8
  62. package/package.json +1 -1
  63. package/scripts/bundle-assets.mjs +4 -0
@@ -28,10 +28,17 @@
28
28
  * Environment:
29
29
  * MEKO_MCP_URL MCP server URL (default: http://localhost:8000/mcp)
30
30
  * MEKO_AGENT_ID Agent identifier override; if unset, derived from the
31
- * session's cwd as `claude_code:<repo-basename>` (or
32
- * `meko_agent` if cwd is unknown). See deriveAgentId().
31
+ * session's cwd by walking parents for `.git` and taking
32
+ * the repo root's basename, as `claude_code:<repo>` (or
33
+ * the bare client name if the session is outside a repo,
34
+ * so sibling subdirectories in unrelated repos can't
35
+ * silently collide). See deriveAgentId().
33
36
  * MEKO_API_KEY API key for Cloud Meko auth (optional, omit for local)
34
37
  * MEKO_API_TIMEOUT Request timeout in seconds (default: 10)
38
+ * MEKO_ADD_MESSAGE_TIMEOUT Timeout for conversation_add_message in
39
+ * seconds (default: 60). Storing a large turn can take
40
+ * the server more than 15s. PreCompact runs in the
41
+ * foreground and keeps MEKO_API_TIMEOUT instead.
35
42
  * MEKO_WATERMARK_DIR Watermark directory (default: ~/.claude/meko-capture)
36
43
  * MEKO_MAX_CAPTURE_BATCH_SIZE Max exchanges per drain (default 5, min 1)
37
44
  */
@@ -52,16 +59,73 @@ const MEKO_MCP_URL = process.env.MEKO_MCP_URL || "http://localhost:8000/mcp";
52
59
  const COMMON_BUCKET_AGENT_ID = "meko_agent";
53
60
  const AGENT_ID_MAX_LEN = 64;
54
61
 
62
+ /**
63
+ * The user's home directory, read fresh so a re-pointed `$HOME` (tests,
64
+ * sandboxes) is honored. Mirror of `homeBoundary` in
65
+ * installer/lib/migrate/id.mjs.
66
+ */
67
+ function homeBoundary() {
68
+ const fromEnv = (process.env.HOME || "").trim();
69
+ if (fromEnv) return path.resolve(fromEnv);
70
+ try {
71
+ const h = os.homedir();
72
+ return h ? path.resolve(h) : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Walk parents of `cwd` looking for a `.git` entry (directory for a normal
80
+ * checkout, file for a submodule / linked worktree). Returns the absolute
81
+ * repo-root path, or `null` if none is found. Mirror of `resolveRepoRoot`
82
+ * in installer/lib/migrate/id.mjs.
83
+ *
84
+ * The walk stops at `$HOME` (from below only, so a git-tracked home is still
85
+ * its own repo root): without that bound, dotfiles-under-git would make every
86
+ * non-repo directory under home derive `claude_code:<username>`.
87
+ */
88
+ function resolveRepoRoot(cwd) {
89
+ if (!cwd || typeof cwd !== "string") return null;
90
+ let dir;
91
+ try {
92
+ dir = path.resolve(cwd);
93
+ } catch {
94
+ return null;
95
+ }
96
+ const start = dir;
97
+ const home = homeBoundary();
98
+ let prev = null;
99
+ while (dir && dir !== prev) {
100
+ // A directory below a git-tracked home must not inherit home's `.git`.
101
+ if (home && dir === home && dir !== start) return null;
102
+ try {
103
+ fs.statSync(path.join(dir, ".git"));
104
+ return dir;
105
+ } catch {
106
+ // keep walking
107
+ }
108
+ // Check home itself for `.git`, then stop. A non-git home must not
109
+ // inherit repository state from /Users, /home, or /.
110
+ if (home && dir === home) return null;
111
+ prev = dir;
112
+ dir = path.dirname(dir);
113
+ }
114
+ return null;
115
+ }
116
+
55
117
  /**
56
118
  * Derive an `agent_id` for a Meko write. Mirror of `deriveAgentId` in
57
119
  * installer/lib/migrate/id.mjs — kept inline because capture.js is published
58
120
  * in a separate package with no module dependencies on the installer.
59
121
  *
60
122
  * - `envOverride` (typically MEKO_AGENT_ID) wins if non-empty.
61
- * - Coding clients (claude_code, cursor, codex, kiro) → `<client>:<repo-basename>`,
62
- * with the basename lowercased and non-`[a-z0-9-]` runs collapsed to `-`.
63
- * Outside a repo → bare client name (keeps coding-agent traffic out of
64
- * the common bucket).
123
+ * - Coding clients (claude_code, cursor, codex, kiro, opencode) →
124
+ * `<client>:<repo-basename>`, where the basename comes from the
125
+ * nearest ancestor of `cwd` that contains `.git`. Lowercased,
126
+ * non-`[a-z0-9-]` runs collapse to `-`. Outside a repo → bare
127
+ * client name (keeps sibling `docs`/`src`/`webapp` directories in
128
+ * unrelated repos from collapsing into one agent_id — MEKO-590).
65
129
  * - Loose clients (claude-desktop) → `claude_desktop`.
66
130
  * - Anything else → `meko_agent` (common cross-project bucket).
67
131
  */
@@ -74,13 +138,16 @@ function deriveAgentId(opts) {
74
138
  client !== "claude_code" &&
75
139
  client !== "cursor" &&
76
140
  client !== "codex" &&
77
- client !== "kiro"
141
+ client !== "kiro" &&
142
+ client !== "opencode"
78
143
  ) {
79
144
  return COMMON_BUCKET_AGENT_ID;
80
145
  }
81
- const rawBase = opts && opts.cwd ? path.basename(opts.cwd) : "";
82
- if (!rawBase || rawBase === "." || rawBase === "/") return client;
83
- const project = rawBase.toLowerCase()
146
+ const repoRoot = opts && Object.prototype.hasOwnProperty.call(opts, "repoRoot")
147
+ ? opts.repoRoot
148
+ : resolveRepoRoot(opts && opts.cwd);
149
+ if (!repoRoot) return client;
150
+ const project = path.basename(repoRoot).toLowerCase()
84
151
  .replace(/[^a-z0-9-]+/g, "-")
85
152
  .replace(/^-+|-+$/g, "")
86
153
  .slice(0, AGENT_ID_MAX_LEN);
@@ -88,6 +155,31 @@ function deriveAgentId(opts) {
88
155
  return `${client}:${project}`;
89
156
  }
90
157
 
158
+ /**
159
+ * Pre-MEKO-590 coding-agent derivation. Existing pin filenames and session
160
+ * state may still use this cwd-leaf id, so it remains a read-only alias while
161
+ * new sessions use the repo-root id from `deriveAgentId`.
162
+ */
163
+ function deriveLegacyAgentId(opts) {
164
+ const envOverride = opts && typeof opts.envOverride === "string"
165
+ ? opts.envOverride.trim()
166
+ : "";
167
+ if (envOverride) return envOverride;
168
+ const client = opts && opts.client ? String(opts.client).trim() : "";
169
+ if (client === "claude-desktop" || client === "claude_desktop") {
170
+ return "claude_desktop";
171
+ }
172
+ if (!["claude_code", "cursor", "codex", "kiro"].includes(client)) {
173
+ return COMMON_BUCKET_AGENT_ID;
174
+ }
175
+ const rawBase = opts && opts.cwd ? path.basename(opts.cwd) : "";
176
+ const project = rawBase.toLowerCase()
177
+ .replace(/[^a-z0-9-]+/g, "-")
178
+ .replace(/^-+|-+$/g, "")
179
+ .slice(0, AGENT_ID_MAX_LEN);
180
+ return project ? `${client}:${project}` : client;
181
+ }
182
+
91
183
  /**
92
184
  * Map MEKO_HOOK_CLIENT to a known coding-client identity. The installer
93
185
  * prefixes each client's hook commands with `MEKO_HOOK_CLIENT=<client>`;
@@ -98,7 +190,7 @@ function deriveAgentId(opts) {
98
190
  */
99
191
  function resolveHookClient() {
100
192
  const client = (process.env.MEKO_HOOK_CLIENT || "").trim();
101
- if (client === "cursor" || client === "codex" || client === "kiro") return client;
193
+ if (client === "cursor" || client === "codex" || client === "kiro" || client === "opencode") return client;
102
194
  return "claude_code";
103
195
  }
104
196
 
@@ -114,9 +206,7 @@ function resolveHookClient() {
114
206
  * 4. cwd from the hook input.
115
207
  * 5. process.cwd() — last resort if no transcript metadata yet.
116
208
  */
117
- function resolveSessionAgentId(transcriptPath, hookInput = null) {
118
- const envOverride = process.env.MEKO_AGENT_ID;
119
- if (envOverride && envOverride.trim()) return envOverride.trim();
209
+ function pickSessionCwd(transcriptPath, hookInput) {
120
210
  const meta = transcriptPath ? peekTranscriptMetadata(transcriptPath) : null;
121
211
  const nested = hookInput && hookInput.hookSpecificInput && typeof hookInput.hookSpecificInput === "object"
122
212
  ? hookInput.hookSpecificInput
@@ -127,7 +217,7 @@ function resolveSessionAgentId(transcriptPath, hookInput = null) {
127
217
  const nestedRoots = Array.isArray(nested.workspace_roots) ? nested.workspace_roots : [];
128
218
  const workspaceRoot = [...roots, ...nestedRoots]
129
219
  .find((item) => typeof item === "string" && item.trim());
130
- const cwd =
220
+ return (
131
221
  (meta && meta.cwd) ||
132
222
  hookInput?.workspace_path ||
133
223
  hookInput?.workspacePath ||
@@ -138,14 +228,71 @@ function resolveSessionAgentId(transcriptPath, hookInput = null) {
138
228
  workspaceRoot ||
139
229
  hookInput?.cwd ||
140
230
  nested.cwd ||
141
- process.cwd();
231
+ process.cwd()
232
+ );
233
+ }
234
+
235
+ function resolveSessionAgentId(transcriptPath, hookInput = null, precomputedCwd = null) {
236
+ const envOverride = process.env.MEKO_AGENT_ID;
237
+ if (envOverride && envOverride.trim()) return envOverride.trim();
238
+ // SessionStart picks the cwd once and passes it in, so the transcript is
239
+ // peeked once per hook invocation rather than once per consumer.
240
+ const cwd = precomputedCwd || pickSessionCwd(transcriptPath, hookInput);
142
241
  const client = resolveHookClient();
143
242
  return deriveAgentId({ client, cwd, envOverride });
144
243
  }
145
244
 
245
+ /**
246
+ * Emit a one-line derivation log to stderr so future MEKO-590–class
247
+ * collisions ("why did session X land under agent_id Y?") are diagnosable
248
+ * from capture logs. Called once at SessionStart, not on every drain, so
249
+ * test assertions on downstream hooks stay stable.
250
+ *
251
+ * Takes the already-picked `cwd` so the log reports the SAME cwd the
252
+ * derivation actually used (Cursor's `workspace_roots` included — the
253
+ * hook's raw cwd is `~/.cursor`, which would print misleadingly) without
254
+ * a second transcript peek.
255
+ *
256
+ * Reports `derivedAgentId`, NOT necessarily the id this session writes
257
+ * under: the watermark-reuse and resume branches run
258
+ * `reconcileCachedAgentId` afterwards and may preserve a cached bucket.
259
+ * `logEffectiveAgentId` closes that gap on those paths.
260
+ */
261
+ function logAgentIdDerivation(cwd, derivedAgentId) {
262
+ const envOverride = (process.env.MEKO_AGENT_ID || "").trim();
263
+ if (envOverride) {
264
+ process.stderr.write(
265
+ `[meko-capture] deriveAgentId: source=env-override derivedAgentId=${derivedAgentId}\n`,
266
+ );
267
+ return;
268
+ }
269
+ const client = resolveHookClient();
270
+ const repoRoot = resolveRepoRoot(cwd);
271
+ process.stderr.write(
272
+ `[meko-capture] deriveAgentId: client=${client} cwd=${cwd || ""} ` +
273
+ `resolvedRepoRoot=${repoRoot || "<none>"} derivedAgentId=${derivedAgentId}\n`,
274
+ );
275
+ }
276
+
277
+ /**
278
+ * Log the agent_id this session actually writes under, when a cached bucket
279
+ * won over the fresh derivation (`/clear`, `/compact`, resume, or a HOLD).
280
+ * Silent when it matches, so the common path stays one line.
281
+ *
282
+ * Without this, "why did session X land under agent_id Y?" is unanswerable
283
+ * from the logs in exactly the cases where the answer is not the derivation.
284
+ */
285
+ function logEffectiveAgentId(derivedAgentId, effectiveAgentId, reason) {
286
+ if (!effectiveAgentId || effectiveAgentId === derivedAgentId) return;
287
+ process.stderr.write(
288
+ `[meko-capture] deriveAgentId: effectiveAgentId=${effectiveAgentId} ` +
289
+ `(overrides derivedAgentId=${derivedAgentId}; reason=${reason})\n`,
290
+ );
291
+ }
292
+
146
293
  // Coding clients that derive a `<client>:<repo>` agent_id. Kept in
147
294
  // sync with deriveAgentId's coding-client branch.
148
- const CODING_CLIENTS = ["claude_code", "cursor", "codex", "kiro"];
295
+ const CODING_CLIENTS = ["claude_code", "cursor", "codex", "kiro", "opencode"];
149
296
 
150
297
  /**
151
298
  * Extract the client segment of an agent_id: the part before the first `:`,
@@ -269,6 +416,12 @@ function ownerChanged(cachedAgentId, resolvedAgentId, convId) {
269
416
 
270
417
  const MEKO_API_KEY = process.env.MEKO_API_KEY || "";
271
418
  const MEKO_API_TIMEOUT = parseInt(process.env.MEKO_API_TIMEOUT || "10", 10) * 1000;
419
+ const MEKO_ADD_MESSAGE_TIMEOUT = (() => {
420
+ // A value that doesn't parse would reach the lease TTL as NaN and throw
421
+ // inside renewLease, so fall back to the default instead.
422
+ const n = Number.parseInt(process.env.MEKO_ADD_MESSAGE_TIMEOUT || "", 10);
423
+ return (Number.isFinite(n) && n > 0 ? n : 60) * 1000;
424
+ })();
272
425
  const WATERMARK_DIR =
273
426
  process.env.MEKO_WATERMARK_DIR ||
274
427
  path.join(os.homedir(), ".claude", "meko-capture");
@@ -395,6 +548,197 @@ function extractToolResultContent(msg) {
395
548
  return parts.length > 0 ? "TOOL RESULT:\n" + parts.join("\n") : "";
396
549
  }
397
550
 
551
+ // Turn inputs that are Claude Code bookkeeping, not something the user typed.
552
+ // A turn whose rendered input matches is never captured.
553
+ const SKIP_INPUT_PATTERN =
554
+ /^<(system-reminder|command-|local-command|available-deferred)/;
555
+
556
+ /**
557
+ * Render a Claude Code skill or prompt command entry as the command the user
558
+ * typed. Claude Code writes `/commit fix typo` as
559
+ * `<command-message>commit</command-message><command-name>/commit</command-name>`
560
+ * plus an optional `<command-args>fix typo</command-args>`, and writes the
561
+ * skill or command body as the `isMeta` entry that follows. Built-in commands
562
+ * (`/clear`, `/model`, `/compact`, …) lead with `<command-name>` instead and
563
+ * have no body; they return "" here, so they never pull a following `isMeta`
564
+ * entry into their turn and always keep their raw, filtered input.
565
+ */
566
+ function skillCommandInput(text) {
567
+ if (typeof text !== "string" || !/^<command-message>/.test(text)) {
568
+ return "";
569
+ }
570
+ const name = /<command-name>([\s\S]*?)<\/command-name>/.exec(text);
571
+ if (!name) return "";
572
+ const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text);
573
+ return [name[1].trim(), args ? args[1].trim() : ""].filter(Boolean).join(" ");
574
+ }
575
+
576
+ /**
577
+ * Is this a Claude Code background-task or subagent notification? Claude Code
578
+ * writes these as plain (non-isMeta) `type: "user"` entries whose content is a
579
+ * `<task-notification>…<summary>…</summary>…</task-notification>` block, and
580
+ * newer versions also tag them `origin: { kind: "task-notification" }`.
581
+ */
582
+ function isTaskNotification(entry) {
583
+ if (entry.type !== "user" || isToolResultMessage(entry)) return false;
584
+ if (entry.origin && entry.origin.kind === "task-notification") return true;
585
+ return /^\s*<task-notification>/.test(
586
+ extractText((entry.message || {}).content),
587
+ );
588
+ }
589
+
590
+ /**
591
+ * Render a task notification that has to open its own turn as a one-line
592
+ * input. The full block (which can carry a subagent's whole report in
593
+ * `<result>`) goes to reasoning instead, so it is never read as user input.
594
+ */
595
+ function taskNotificationInput(text) {
596
+ const summary = /<summary>([\s\S]*?)<\/summary>/.exec(text || "");
597
+ return `Task notification: ${summary ? summary[1].trim() : "background task update"}`;
598
+ }
599
+
600
+ /**
601
+ * Is this a subagent's hand-back report? Claude Code delivers it as an
602
+ * `isMeta` user entry tagged `origin: { kind: "peer", handback: true }`, with
603
+ * text starting "Another Claude session sent a message: <agent-message …>".
604
+ * Like a task notification, it is not user input and belongs to the open
605
+ * turn. Other peer messages (a request from an independent session, with no
606
+ * `handback`) are real input and still open their own turn.
607
+ */
608
+ function isSubagentHandback(entry) {
609
+ if (entry.type !== "user" || isToolResultMessage(entry)) return false;
610
+ const origin = entry.origin;
611
+ return !!origin && origin.kind === "peer" && origin.handback === true;
612
+ }
613
+
614
+ /** Render a hand-back that has to open its own turn as a one-line input. */
615
+ function handbackInput(entry) {
616
+ const origin = entry.origin || {};
617
+ const name = origin.name || origin.from || "";
618
+ return name ? `Agent message: ${name}` : "Agent message";
619
+ }
620
+
621
+ const SKILL_BODY_PATTERN = /^Base directory for this skill: *(\S+)/;
622
+
623
+ /**
624
+ * Is this an `isMeta` entry Claude Code injected while loading a skill (the
625
+ * skill body, or a notice such as "Skill /commit was loaded earlier")? The
626
+ * model-invoked shape carries `sourceToolUseID`; a body from a typed `/skill`
627
+ * is recognized by its "Base directory for this skill:" first line.
628
+ */
629
+ function isSkillContext(entry) {
630
+ if (entry.type !== "user" || entry.isMeta !== true) return false;
631
+ if (isToolResultMessage(entry)) return false;
632
+ if (entry.sourceToolUseID) return true;
633
+ return SKILL_BODY_PATTERN.test(extractText((entry.message || {}).content));
634
+ }
635
+
636
+ /** Context injected into a turn rather than typed by the user. */
637
+ function isInjectedContext(entry) {
638
+ return isTaskNotification(entry) || isSubagentHandback(entry) || isSkillContext(entry);
639
+ }
640
+
641
+ /**
642
+ * Render skill context that has to open its own turn (see isTurnStart) as a
643
+ * one-line input naming the skill. The body goes to reasoning instead.
644
+ */
645
+ function skillContextInput(text) {
646
+ const dir = SKILL_BODY_PATTERN.exec(text || "");
647
+ const name = dir ? path.basename(dir[1].replace(/\/+$/, "")) : "";
648
+ return name ? `Skill loaded: ${name}` : "Skill context loaded";
649
+ }
650
+
651
+ /**
652
+ * The rendered input for a turn. Skill-loading commands are unwrapped, and
653
+ * a turn opened by injected context gets a one-line summary so that context
654
+ * is never sent as user input.
655
+ */
656
+ function turnInput(turn) {
657
+ const text = extractText((turn.userMsg.message || {}).content);
658
+ if (isTaskNotification(turn.userMsg)) return taskNotificationInput(text);
659
+ if (isSubagentHandback(turn.userMsg)) return handbackInput(turn.userMsg);
660
+ if (isSkillContext(turn.userMsg)) return skillContextInput(text);
661
+ // Only a skill command that actually got its body is shown as `/name args`.
662
+ return turn.injectedEntries.some((e) => e.isMeta === true && !isSubagentHandback(e))
663
+ ? skillCommandInput(text) || text
664
+ : text;
665
+ }
666
+
667
+ /** Will a turn with this rendered input be captured? */
668
+ function isCapturableInput(input) {
669
+ return !!input && !SKIP_INPUT_PATTERN.test(input);
670
+ }
671
+
672
+ /**
673
+ * Does this user entry open a new turn? `prev` is the parsed entry just
674
+ * before it, or null. `insideInjectedRun` is true while consecutive injected
675
+ * `isMeta` entries continue after a skill-loading command or tool call.
676
+ * `openTurn` is the `{ userMsg, injectedEntries }` of the turn an earlier entry
677
+ * in this parse window opened, or null.
678
+ *
679
+ * Claude Code injects skill bodies ("Base directory for this skill: …") as
680
+ * `type: "user"` entries with `isMeta: true`. They belong to the turn that
681
+ * loaded the skill, not to a new one:
682
+ * - the model called the Skill tool → the entry carries `sourceToolUseID`;
683
+ * - the user typed `/skill` → the entry follows the `<command-message>`
684
+ * entry (see skillCommandInput).
685
+ * Other `isMeta` user entries (scheduled and /loop prompts, image captions,
686
+ * requests from other sessions) still open a turn.
687
+ *
688
+ * Task notifications (see isTaskNotification) and subagent hand-backs (see
689
+ * isSubagentHandback) are not user input either, so they also stay in the
690
+ * open turn and the assistant's reaction joins it — unless that turn is never
691
+ * captured (a `/compact` or `<local-command-…>` turn), which would drop the
692
+ * reaction with it.
693
+ */
694
+ function isTurnStart(entry, { prev = null, insideInjectedRun = false, openTurn = null } = {}) {
695
+ if (entry.type !== "user" || isToolResultMessage(entry)) return false;
696
+ // Nothing to fold into: the turn this entry belongs to was captured by an
697
+ // earlier drain (a notification arriving after the checkpoint), or a cursor
698
+ // persisted by an older capture version points straight at an injected
699
+ // entry. Anchor a turn here so the assistant reply that follows is kept.
700
+ if (!openTurn) return true;
701
+ if (isTaskNotification(entry) || isSubagentHandback(entry)) {
702
+ return !isCapturableInput(turnInput(openTurn));
703
+ }
704
+ if (entry.isMeta !== true) return true;
705
+ if (entry.sourceToolUseID) return false;
706
+ if (insideInjectedRun) return false;
707
+ const prevIsSkillCommand =
708
+ prev != null &&
709
+ prev.type === "user" &&
710
+ skillCommandInput(extractText((prev.message || {}).content)) !== "";
711
+ return !prevIsSkillCommand;
712
+ }
713
+
714
+ /**
715
+ * Stateful wrapper over isTurnStart for a pass over parsed entries, so
716
+ * buildExchanges and realUserTurnLines apply the same turn-start rule.
717
+ * Returns `startsTurn(entry) → boolean`; call it once per entry, in order.
718
+ */
719
+ function turnStartTracker() {
720
+ let prev = null;
721
+ let insideInjectedRun = false;
722
+ // Mirrors the turn buildExchanges is assembling, so turnInput can render it.
723
+ let openTurn = null;
724
+ return (entry) => {
725
+ const startsTurn = isTurnStart(entry, { prev, insideInjectedRun, openTurn });
726
+ const isUserText = entry.type === "user" && !isToolResultMessage(entry);
727
+ // A hand-back is isMeta too, but it never continues a skill's run.
728
+ const isMetaUser =
729
+ isUserText && entry.isMeta === true && !isSubagentHandback(entry);
730
+ insideInjectedRun =
731
+ isMetaUser && (entry.sourceToolUseID != null || !startsTurn);
732
+ if (startsTurn) {
733
+ openTurn = { userMsg: entry, injectedEntries: [] };
734
+ } else if (openTurn && isUserText) {
735
+ openTurn.injectedEntries.push(entry);
736
+ }
737
+ prev = entry;
738
+ return startsTurn;
739
+ };
740
+ }
741
+
398
742
  // --- Turn-assembly exchange extraction ---
399
743
 
400
744
  // Codex turn-completion boundary discriminator.
@@ -524,13 +868,63 @@ function parseTranscriptEntries(filePath, startLine) {
524
868
  return entries;
525
869
  }
526
870
 
871
+ /**
872
+ * Render a turn's assistant side: assistant text becomes `output`; thinking,
873
+ * tool calls, tool results and injected entries become `reasoning`.
874
+ */
875
+ function renderTurnBody(ex) {
876
+ const outputParts = [];
877
+ const reasoningParts = [];
878
+
879
+ for (const aEntry of ex.assistantEntries) {
880
+ const content = (aEntry.message || {}).content;
881
+ if (!Array.isArray(content)) continue;
882
+
883
+ const text = extractText(content);
884
+ if (text) outputParts.push(text);
885
+
886
+ const thinking = extractThinking(content);
887
+ if (thinking) reasoningParts.push("THINKING:\n" + thinking);
888
+
889
+ const toolCalls = extractToolCalls(content);
890
+ if (toolCalls) reasoningParts.push(toolCalls);
891
+ }
892
+
893
+ for (const trEntry of ex.toolResultEntries) {
894
+ const resultText = extractToolResultContent(trEntry);
895
+ if (resultText) reasoningParts.push(resultText);
896
+ }
897
+
898
+ const injected = isInjectedContext(ex.userMsg)
899
+ ? [ex.userMsg, ...ex.injectedEntries]
900
+ : ex.injectedEntries;
901
+ for (const injEntry of injected) {
902
+ const text = extractText((injEntry.message || {}).content);
903
+ if (text) {
904
+ const label = isTaskNotification(injEntry)
905
+ ? "TASK NOTIFICATION:\n"
906
+ : isSubagentHandback(injEntry)
907
+ ? "AGENT MESSAGE:\n"
908
+ : "INJECTED CONTEXT:\n";
909
+ reasoningParts.push(label + truncate(text, MAX_TOOL_RESULT_LEN));
910
+ }
911
+ }
912
+
913
+ return {
914
+ output: outputParts.join("\n\n"),
915
+ reasoning: reasoningParts.join("\n---\n"),
916
+ };
917
+ }
918
+
527
919
  /**
528
920
  * Group parsed entries into exchanges using turn-assembly, then render each
529
921
  * exchange to the { input, output, reasoning } shape addMessage sends.
530
922
  *
531
- * A real user message (no toolUseResult) starts a new exchange. All
532
- * subsequent assistant lines and tool-result user lines belong to that
533
- * exchange until the next real user message.
923
+ * A real user message (no toolUseResult) starts a new exchange. Skill bodies
924
+ * and related injected `isMeta` entries stay in the turn that loaded them, and
925
+ * task notifications stay in the open turn (see isTurnStart). Other user
926
+ * messages start the next exchange. Assistant lines and tool-result user lines
927
+ * belong to the current exchange.
534
928
  *
535
929
  * `items` is the `{ entry, line }` list from parseTranscriptEntries. Returns
536
930
  * `{ exchanges, pendingFromLine }`:
@@ -549,19 +943,25 @@ function parseTranscriptEntries(filePath, startLine) {
549
943
  * complete trailing turn, which advances fully.
550
944
  */
551
945
  function buildExchanges(items) {
552
- const skipPattern =
553
- /^<(system-reminder|command-|local-command|available-deferred)/;
554
946
  const turns = [];
555
947
  let current = null;
556
948
 
949
+ const startsTurn = turnStartTracker();
557
950
  for (const { entry, line } of items) {
558
- if (entry.type === "user" && !isToolResultMessage(entry)) {
951
+ if (startsTurn(entry)) {
559
952
  if (current) turns.push(current);
560
953
  current = {
561
954
  userMsg: entry,
562
955
  userLine: line,
563
956
  assistantEntries: [],
564
957
  toolResultEntries: [],
958
+ // Skill bodies, other isMeta text and task notifications Claude Code
959
+ // writes as user entries inside this turn (see isTurnStart).
960
+ injectedEntries: [],
961
+ // A turn with injected context is incomplete until an assistant entry
962
+ // arrives after its latest injection. Assistant entries before that
963
+ // injection do not make the turn safe for a checkpoint to consume.
964
+ assistantAfterInjection: false,
565
965
  // Codex streaming turns end at a completion boundary (phase:
566
966
  // final_answer, or a task_complete event_msg). `codexStreaming` marks a
567
967
  // turn that emitted commentary and is therefore mid-stream until a
@@ -574,6 +974,9 @@ function buildExchanges(items) {
574
974
  } else if (current) {
575
975
  if (entry.type === "assistant") {
576
976
  current.assistantEntries.push(entry);
977
+ if (current.injectedEntries.length > 0) {
978
+ current.assistantAfterInjection = true;
979
+ }
577
980
  if (entry.codexPhase === CODEX_FINAL_ANSWER_PHASE) {
578
981
  current.codexComplete = true;
579
982
  } else if (entry.codexPhase === CODEX_COMMENTARY_PHASE) {
@@ -583,6 +986,11 @@ function buildExchanges(items) {
583
986
  current.codexComplete = true;
584
987
  } else if (entry.type === "user" && isToolResultMessage(entry)) {
585
988
  current.toolResultEntries.push(entry);
989
+ } else if (entry.type === "user") {
990
+ current.injectedEntries.push(entry);
991
+ // Each injection needs its own reply: a second skill body or
992
+ // notification re-opens the hold until an assistant entry follows it.
993
+ current.assistantAfterInjection = false;
586
994
  }
587
995
  }
588
996
  }
@@ -602,11 +1010,12 @@ function buildExchanges(items) {
602
1010
  const last = turns.length > 0 ? turns[turns.length - 1] : null;
603
1011
  const lastIncomplete = last
604
1012
  ? last.assistantEntries.length === 0 ||
1013
+ (last.injectedEntries.length > 0 && !last.assistantAfterInjection) ||
605
1014
  (last.codexStreaming && !last.codexComplete)
606
1015
  : false;
607
1016
  if (last && lastIncomplete) {
608
- const lastInput = extractText((last.userMsg.message || {}).content);
609
- if (lastInput && !skipPattern.test(lastInput)) {
1017
+ const lastInput = turnInput(last);
1018
+ if (isCapturableInput(lastInput)) {
610
1019
  pendingFromLine = last.userLine;
611
1020
  }
612
1021
  }
@@ -622,31 +1031,11 @@ function buildExchanges(items) {
622
1031
  pendingFromLine !== null && t === turns.length - 1;
623
1032
  if (isPendingTrailing) continue;
624
1033
 
625
- const input = extractText((ex.userMsg.message || {}).content);
626
- if (!input || skipPattern.test(input)) continue;
1034
+ const input = turnInput(ex);
1035
+ if (!isCapturableInput(input)) continue;
627
1036
  if (ex.assistantEntries.length === 0) continue;
628
1037
 
629
- const outputParts = [];
630
- const reasoningParts = [];
631
-
632
- for (const aEntry of ex.assistantEntries) {
633
- const content = (aEntry.message || {}).content;
634
- if (!Array.isArray(content)) continue;
635
-
636
- const text = extractText(content);
637
- if (text) outputParts.push(text);
638
-
639
- const thinking = extractThinking(content);
640
- if (thinking) reasoningParts.push("THINKING:\n" + thinking);
641
-
642
- const toolCalls = extractToolCalls(content);
643
- if (toolCalls) reasoningParts.push(toolCalls);
644
- }
645
-
646
- for (const trEntry of ex.toolResultEntries) {
647
- const resultText = extractToolResultContent(trEntry);
648
- if (resultText) reasoningParts.push(resultText);
649
- }
1038
+ const { output, reasoning } = renderTurnBody(ex);
650
1039
 
651
1040
  results.push({
652
1041
  user_uuid: ex.userMsg.uuid || "unknown",
@@ -658,8 +1047,8 @@ function buildExchanges(items) {
658
1047
  next_line_number:
659
1048
  t + 1 < turns.length ? turns[t + 1].userLine : null,
660
1049
  input: input,
661
- output: outputParts.join("\n\n"),
662
- reasoning: reasoningParts.join("\n---\n"),
1050
+ output,
1051
+ reasoning,
663
1052
  timestamp:
664
1053
  ex.assistantEntries[ex.assistantEntries.length - 1].timestamp ||
665
1054
  ex.userMsg.timestamp ||
@@ -767,6 +1156,7 @@ function writeWatermark(wmPath, convId, lineNum, agentId, datapackPin = null, ag
767
1156
  last_error: null,
768
1157
  last_success_at: null,
769
1158
  blocked_reason: null,
1159
+ consecutive_exchange_drops: 0,
770
1160
  };
771
1161
 
772
1162
  const existingPin =
@@ -828,10 +1218,9 @@ function writeWatermark(wmPath, convId, lineNum, agentId, datapackPin = null, ag
828
1218
  /** Real user-turn line indexes from a parseTranscriptEntries result (PR #262). */
829
1219
  function realUserTurnLines(entries) {
830
1220
  const lines = [];
1221
+ const startsTurn = turnStartTracker();
831
1222
  for (const { entry, line } of entries) {
832
- if (entry.type === "user" && !isToolResultMessage(entry)) {
833
- lines.push(line);
834
- }
1223
+ if (startsTurn(entry)) lines.push(line);
835
1224
  }
836
1225
  return lines;
837
1226
  }
@@ -862,6 +1251,7 @@ function makeDrainResult(partial) {
862
1251
  lifecycle: partial.lifecycle || "active",
863
1252
  delivery: partial.delivery || "idle",
864
1253
  next_retry_at: partial.next_retry_at || null,
1254
+ dropped: partial.dropped || 0,
865
1255
  };
866
1256
  }
867
1257
 
@@ -883,27 +1273,38 @@ function datapackPinSlug(agentId) {
883
1273
  * The pin is a sidecar file at `<WATERMARK_DIR>/pin-<slug(agent_id)>.json`
884
1274
  * with shape `{ datapack_id, datapack_name, selected_at }`. Project-scoped:
885
1275
  * keyed by the same `agent_id` the hook derives for memory writes
886
- * (`claude_code:<repo-basename>`), so a pin set in one Claude Code window
887
- * for a repo applies to every other window in that repo, and survives
888
- * `/clear`, `/compact`, and Claude Code restart.
1276
+ * (`claude_code:<repo-basename>`, where the basename is the nearest
1277
+ * ancestor of the session cwd containing `.git`), so a pin set in one
1278
+ * Claude Code window for a repo applies to every other window in that
1279
+ * repo, and survives `/clear`, `/compact`, and Claude Code restart.
889
1280
  *
890
- * Hooks are read-only — only the skill writes this file. Returns null if
891
- * the file is absent, malformed, or missing required fields, so SessionStart
892
- * can proceed unchanged when no pin is set.
1281
+ * Hooks are read-only — only the skill writes this file. The legacy id is
1282
+ * checked only when the repo-root id has no valid pin, preserving pins written
1283
+ * before MEKO-590 without preventing a newly written repo-root pin from
1284
+ * taking precedence.
893
1285
  */
894
- function readDatapackPin(agentId) {
895
- const slug = datapackPinSlug(agentId);
896
- if (!slug) return null;
897
- const pinPath = path.join(WATERMARK_DIR, `pin-${slug}.json`);
898
- try {
899
- const obj = JSON.parse(fs.readFileSync(pinPath, "utf-8"));
900
- const id = typeof obj.datapack_id === "string" ? obj.datapack_id.trim() : "";
901
- const name = typeof obj.datapack_name === "string" ? obj.datapack_name.trim() : "";
902
- if (!id || !name) return null;
903
- return { datapack_id: id, datapack_name: name, selected_at: obj.selected_at || "" };
904
- } catch {
905
- return null;
1286
+ function readDatapackPin(agentId, legacyAgentId = null) {
1287
+ const candidates = [...new Set([agentId, legacyAgentId].filter(Boolean))];
1288
+ for (const candidate of candidates) {
1289
+ const slug = datapackPinSlug(candidate);
1290
+ if (!slug) continue;
1291
+ const pinPath = path.join(WATERMARK_DIR, `pin-${slug}.json`);
1292
+ try {
1293
+ const obj = JSON.parse(fs.readFileSync(pinPath, "utf-8"));
1294
+ const id = typeof obj.datapack_id === "string" ? obj.datapack_id.trim() : "";
1295
+ const name = typeof obj.datapack_name === "string" ? obj.datapack_name.trim() : "";
1296
+ if (!id || !name) continue;
1297
+ if (candidate !== agentId) {
1298
+ process.stderr.write(
1299
+ `[meko-capture] datapack pin: using legacy agent_id alias ${candidate}\n`,
1300
+ );
1301
+ }
1302
+ return { datapack_id: id, datapack_name: name, selected_at: obj.selected_at || "" };
1303
+ } catch {
1304
+ // Try the compatibility alias, if any.
1305
+ }
906
1306
  }
1307
+ return null;
907
1308
  }
908
1309
 
909
1310
  function datapackPinFromWatermark(watermark) {
@@ -942,9 +1343,14 @@ The user pinned datapack **${pin.datapack_name}** (\`${pin.datapack_id}\`) for t
942
1343
  * Low-level HTTP POST to the MCP endpoint.
943
1344
  * Returns { body: <parsed JSON>, headers: <response headers> }.
944
1345
  */
945
- function mcpPost(jsonBody) {
1346
+ function mcpPost(jsonBody, options) {
946
1347
  const body = JSON.stringify(jsonBody);
1348
+ const timeoutMs =
1349
+ options && Number(options.timeoutMs) > 0
1350
+ ? Number(options.timeoutMs)
1351
+ : MEKO_API_TIMEOUT;
947
1352
  return new Promise((resolve, reject) => {
1353
+ let deadline = null;
948
1354
  const url = new URL(MEKO_MCP_URL);
949
1355
  const transport = url.protocol === "https:" ? https : http;
950
1356
  const headers = {
@@ -973,12 +1379,13 @@ function mcpPost(jsonBody) {
973
1379
  path: url.pathname + url.search,
974
1380
  method: "POST",
975
1381
  headers,
976
- timeout: MEKO_API_TIMEOUT,
1382
+ timeout: timeoutMs,
977
1383
  },
978
1384
  (res) => {
979
1385
  let data = "";
980
1386
  res.on("data", (chunk) => (data += chunk));
981
1387
  res.on("end", () => {
1388
+ clearTimeout(deadline);
982
1389
  inflightRequests.delete(req);
983
1390
  if (res.statusCode >= 400) {
984
1391
  const snippet = data.trim().replace(/\s+/g, " ").slice(0, 200);
@@ -1010,15 +1417,25 @@ function mcpPost(jsonBody) {
1010
1417
  );
1011
1418
 
1012
1419
  inflightRequests.add(req);
1013
- req.on("error", (err) => {
1014
- inflightRequests.delete(req);
1015
- reject(err);
1016
- });
1017
- req.on("timeout", () => {
1420
+ const timedOut = () => {
1421
+ clearTimeout(deadline);
1018
1422
  inflightRequests.delete(req);
1019
1423
  req.destroy();
1020
1424
  reject(new Error("MCP request timed out"));
1425
+ };
1426
+ // The socket timeout only measures idle time, and SSE pings reset it, so
1427
+ // also cap the whole request. unref() and the close listener keep an
1428
+ // aborted request (SessionStart's preload budget) from holding the
1429
+ // process open until the deadline.
1430
+ deadline = setTimeout(timedOut, timeoutMs);
1431
+ deadline.unref();
1432
+ req.on("close", () => clearTimeout(deadline));
1433
+ req.on("error", (err) => {
1434
+ clearTimeout(deadline);
1435
+ inflightRequests.delete(req);
1436
+ reject(err);
1021
1437
  });
1438
+ req.on("timeout", timedOut);
1022
1439
  req.write(body);
1023
1440
  req.end();
1024
1441
  });
@@ -1028,7 +1445,10 @@ function parseMcpResponseBody(data) {
1028
1445
  const trimmed = data.trim();
1029
1446
  if (!trimmed) return null;
1030
1447
 
1031
- if (!trimmed.startsWith("event:") && !trimmed.startsWith("data:")) {
1448
+ // An SSE stream may open with a comment line such as the server's
1449
+ // keepalive `: ping - <timestamp>`, sent every 15s while a slow tool call
1450
+ // is still running.
1451
+ if (!/^(event|data|id|retry)?:/.test(trimmed)) {
1032
1452
  return JSON.parse(trimmed);
1033
1453
  }
1034
1454
 
@@ -1055,24 +1475,78 @@ function parseMcpResponseBody(data) {
1055
1475
  return JSON.parse(payload);
1056
1476
  }
1057
1477
 
1058
- function classifyPersistentCaptureFailure(err) {
1059
- const message = err && err.message ? String(err.message) : String(err || "");
1060
- const normalized = message.toLowerCase();
1061
- const statusCode = Number(err && err.statusCode);
1062
- const statusMatch = normalized.match(/\bmcp http (402|403)\b/);
1063
- let persistentStatus = null;
1064
- if (statusCode === 402 || statusCode === 403) {
1065
- persistentStatus = statusCode;
1066
- } else if (statusMatch) {
1067
- persistentStatus = Number(statusMatch[1]);
1068
- }
1478
+ /**
1479
+ * Collapse a response body to a single-line snippet for error records.
1480
+ * Display/persistence stays short; pattern matching uses the fuller body.
1481
+ */
1482
+ function errorBodySnippet(err) {
1483
+ const body = err && err.responseBody ? String(err.responseBody) : "";
1484
+ return body.trim().replace(/\s+/g, " ").slice(0, 200);
1485
+ }
1069
1486
 
1070
- if (persistentStatus) {
1071
- return {
1072
- code: `http_${persistentStatus}`,
1073
- reason: `Meko rejected capture with HTTP ${persistentStatus}`,
1074
- };
1487
+ /**
1488
+ * Full collapsed response body for classifier pattern matching.
1489
+ * mcpPost keeps up to 1000 chars on err.responseBody; the message snippet
1490
+ * is only 200, so reasons past that window are only visible here.
1491
+ */
1492
+ function errorBodyForMatch(err) {
1493
+ const body = err && err.responseBody ? String(err.responseBody) : "";
1494
+ return body.trim().replace(/\s+/g, " ");
1495
+ }
1496
+
1497
+ function isHtmlErrorDetail(detail) {
1498
+ return /^\s*<|<html|<!doctype/i.test(String(detail || ""));
1499
+ }
1500
+
1501
+ function persistentFailureText(persistent, fallback) {
1502
+ if (!persistent || !persistent.reason) return fallback;
1503
+ const detail = persistent.detail ? String(persistent.detail) : "";
1504
+ if (
1505
+ !detail ||
1506
+ isHtmlErrorDetail(detail) ||
1507
+ String(persistent.reason).includes(detail)
1508
+ ) {
1509
+ return persistent.reason;
1075
1510
  }
1511
+ return `${persistent.reason}: ${detail}`;
1512
+ }
1513
+
1514
+ /**
1515
+ * Classify a capture failure that will not clear on retry.
1516
+ *
1517
+ * Returns null for transient failures. Otherwise:
1518
+ * { code, reason, scope, detail }
1519
+ *
1520
+ * `scope` says WHAT is rejected, which is what the caller needs to decide
1521
+ * whether retrying can ever succeed:
1522
+ * - "account" the account/plan is refusing all writes (quota, entitlement,
1523
+ * 402). Every exchange would fail identically, so the caller
1524
+ * holds the queue: the block clears when billing does, and
1525
+ * dropping would lose the whole session.
1526
+ * - "exchange" the response looks like an edge proxy rejecting THIS
1527
+ * request's body (HTTP 403 with an HTML body — see #13). The
1528
+ * caller may skip one such exchange so later turns still
1529
+ * drain; a second consecutive exchange-scoped failure without
1530
+ * an intervening success is treated as request-wide and held,
1531
+ * because HTML 403 alone does not prove payload-specificity
1532
+ * (missing UA, IP/ASN, rate-limit pages share the shape).
1533
+ *
1534
+ * Body patterns are tested BEFORE the status code. An account-level rejection
1535
+ * that arrives as 402/403 (the common case) would otherwise collapse into an
1536
+ * opaque `http_403` and send the user off regenerating a token that was never
1537
+ * at fault. `detail` is a short body snippet; `reason` is what callers persist
1538
+ * into `last_error` / notices so the real cause survives.
1539
+ * Pass `{ exchange: true }` only from the addMessage path. Initialize and
1540
+ * conversation_create HTML 403s are request-wide, so they stay account-scoped.
1541
+ */
1542
+ function classifyPersistentCaptureFailure(err, opts) {
1543
+ const options = opts && typeof opts === "object" ? opts : {};
1544
+ const forExchange = options.exchange === true;
1545
+ const message = err && err.message ? String(err.message) : String(err || "");
1546
+ const detail = errorBodySnippet(err);
1547
+ // Search the fuller responseBody (up to mcpPost's 1000-char keep) so a
1548
+ // reason that fell outside the 200-char message snippet is still matched.
1549
+ const normalized = `${message} ${errorBodyForMatch(err)}`.toLowerCase();
1076
1550
 
1077
1551
  const persistentPatterns = [
1078
1552
  ["free_tier_limit_reached", /free[_\s-]?tier.*limit|free_tier_limit_reached/],
@@ -1082,6 +1556,13 @@ function classifyPersistentCaptureFailure(err) {
1082
1556
  ["payment_required", /payment[_\s-]?required/],
1083
1557
  ["subscription_required", /subscription[_\s-]?(required|inactive|expired)/],
1084
1558
  ["entitlement_denied", /entitlement.*(denied|required|missing)|(?:denied|required|missing).*entitlement/],
1559
+ // MEKO-656/MEKO-691: _resolve_datapack_id now returns this code (instead
1560
+ // of raising) when the caller has no ownership/share grant on the
1561
+ // datapack_id it supplied. Account-scoped like entitlement_denied above —
1562
+ // every write against that same datapack_id would fail identically until
1563
+ // access is (re-)granted out of band, so the caller should hold rather
1564
+ // than retry forever or silently drop the turn.
1565
+ ["datapack_access_denied", /datapack_access_denied/],
1085
1566
  ];
1086
1567
  for (const [code, pattern] of persistentPatterns) {
1087
1568
  if (pattern.test(normalized)) {
@@ -1093,13 +1574,203 @@ function classifyPersistentCaptureFailure(err) {
1093
1574
  payment_required: "capture requires payment",
1094
1575
  subscription_required: "capture requires an active subscription",
1095
1576
  entitlement_denied: "the account is not entitled to capture conversations",
1577
+ datapack_access_denied: "the account does not have access to the target datapack",
1096
1578
  };
1097
- return { code, reason: reasons[code] };
1579
+ return { code, reason: reasons[code], scope: "account", detail };
1098
1580
  }
1099
1581
  }
1582
+
1583
+ const statusCode = Number(err && err.statusCode);
1584
+ const statusMatch = normalized.match(/\bmcp http (402|403)\b/);
1585
+ let persistentStatus = null;
1586
+ if (statusCode === 402 || statusCode === 403) {
1587
+ persistentStatus = statusCode;
1588
+ } else if (statusMatch) {
1589
+ persistentStatus = Number(statusMatch[1]);
1590
+ }
1591
+
1592
+ if (persistentStatus === 402) {
1593
+ return {
1594
+ code: "http_402",
1595
+ reason: "Meko rejected capture with HTTP 402 (payment required)",
1596
+ scope: "account",
1597
+ detail,
1598
+ };
1599
+ }
1600
+
1601
+ if (persistentStatus === 403) {
1602
+ // A 403 whose body is an HTML error page never came from the MCP service —
1603
+ // the service answers with JSON. Auth failures arrive as 401 with a JSON
1604
+ // body, so this is not a credential problem. A JSON 403 with no recognized
1605
+ // pattern stays account-scoped: it may apply to every write.
1606
+ //
1607
+ // HTML alone is only a candidate for exchange-scope. The drain loop still
1608
+ // requires an intervening success before a second consecutive drop, because
1609
+ // request-wide edge blocks (rate limit, IP, missing UA historically) share
1610
+ // this shape and would otherwise discard the whole session.
1611
+ const fromEdge = isHtmlErrorDetail(detail);
1612
+ return {
1613
+ code: fromEdge ? "content_rejected_403" : "http_403",
1614
+ // Keep exchange reasons short and free of HTML so SessionStart notices
1615
+ // and drop_reasons do not inject error-page markup into model context.
1616
+ reason: fromEdge
1617
+ ? forExchange
1618
+ ? "edge filtering rejected the request body (HTTP 403); this exchange's content cannot be stored as-is"
1619
+ : "edge filtering rejected the request (HTTP 403)"
1620
+ : `Meko rejected capture with HTTP 403${detail ? `: ${detail}` : ""}`,
1621
+ scope: fromEdge && forExchange ? "exchange" : "account",
1622
+ detail,
1623
+ };
1624
+ }
1625
+
1100
1626
  return null;
1101
1627
  }
1102
1628
 
1629
+ /**
1630
+ * Grace period before a closed session whose transcript has vanished is
1631
+ * finalized. Default 1 day; MEKO_TRANSCRIPT_MISSING_GRACE overrides (seconds).
1632
+ */
1633
+ function transcriptMissingGraceSeconds() {
1634
+ const raw = process.env.MEKO_TRANSCRIPT_MISSING_GRACE;
1635
+ const n = raw == null || raw === "" ? NaN : Number.parseInt(String(raw), 10);
1636
+ return Number.isFinite(n) && n >= 0 ? n : 86400;
1637
+ }
1638
+
1639
+ function coerceQueued(state) {
1640
+ const n = Number(state && state.queued_exchanges);
1641
+ return Number.isFinite(n) && n > 0 ? n : 0;
1642
+ }
1643
+
1644
+ /** Consecutive not_found drains required before opening a replacement. */
1645
+ const MISSING_CONVERSATION_RECOVERY_POLLS = 2;
1646
+
1647
+ function missingConversationStreak(state) {
1648
+ const n = Number(state && state.missing_conversation_streak);
1649
+ return Number.isInteger(n) && n > 0 ? n : 0;
1650
+ }
1651
+
1652
+ /**
1653
+ * Clear failure fields after a drain delivers all outstanding work.
1654
+ *
1655
+ * These fields describe the last attempt. Leaving them set after the queue
1656
+ * drains makes sessionHealthBucket() report a resolved failure as degraded.
1657
+ */
1658
+ function clearResolvedFailure(state) {
1659
+ state.failure_class = null;
1660
+ state.last_error = null;
1661
+ state.next_retry_at = null;
1662
+ state.attempt_count = 0;
1663
+ state.blocked_reason = null;
1664
+ state.recovered_from_conversation_id = null;
1665
+ state.missing_conversation_streak = null;
1666
+ return state;
1667
+ }
1668
+
1669
+ /**
1670
+ * Detect a conversation the server no longer has.
1671
+ *
1672
+ * `conversation_add_message` answers `not_found` when its conversation id is
1673
+ * gone: deleted server-side, expired, or created against a different Langfuse
1674
+ * project than the one the current datapack resolves to. classifyFailure() has
1675
+ * no pattern for it, so it falls through to the "transient" default and the
1676
+ * outbox retries the same dead id forever: three sessions reached 609, 441 and
1677
+ * 440 attempts holding 105 turns that could never land.
1678
+ *
1679
+ * Callers treat a match as a signal to open a fresh conversation and move the
1680
+ * undelivered tail into it (see recoverMissingConversation).
1681
+ *
1682
+ * The match keys on the server's detail, `Conversation <id> not found`
1683
+ * (src/tools.py). The tool name in the error prefix also contains
1684
+ * "conversation", so matching on the word alone would catch every not_found.
1685
+ * The server sends the same detail when a Langfuse lookup fails transiently or
1686
+ * the trace is too large to read (MEKO-452, MEKO-714), so a match is not proof
1687
+ * the conversation is gone. Recovery waits until the same detail persists
1688
+ * across MISSING_CONVERSATION_RECOVERY_POLLS drains, preserves the cursor, and
1689
+ * never copies turns that the old conversation already accepted.
1690
+ */
1691
+ function isMissingConversationError(err) {
1692
+ const message = err && err.message ? String(err.message) : String(err || "");
1693
+ const normalized = message.toLowerCase();
1694
+ if (!/not[_\s-]?found/.test(normalized)) return false;
1695
+ return /conversation\s+\S+\s+not\s+found/.test(normalized);
1696
+ }
1697
+
1698
+ /**
1699
+ * Recover from a conversation the server no longer has by opening a new one and
1700
+ * moving the undelivered tail to it.
1701
+ *
1702
+ * The server can also return the same not_found detail for a transient Langfuse
1703
+ * lookup failure. drainSession() calls this only after that detail has persisted
1704
+ * across two drains, and this function keeps last_line_number so turns already
1705
+ * accepted by the old conversation are not copied. conversation_add_message
1706
+ * checks ownership before storing a turn, so the failed exchange and the rest
1707
+ * of the undelivered tail remain safe to move.
1708
+ *
1709
+ * The new conversation is created without `session_id`. The server derives the
1710
+ * id from `session_id`, so passing it again would return the conversation that
1711
+ * just failed, and recovery would keep retrying the dead id.
1712
+ *
1713
+ * On create failure the caller keeps its normal retry path, so a network blip
1714
+ * here costs a retry rather than the queue.
1715
+ *
1716
+ * @returns {Promise<{ok: boolean, state?: object, convId?: string, error?: string}>}
1717
+ */
1718
+ async function recoverMissingConversation({
1719
+ state,
1720
+ sessionId,
1721
+ agentId,
1722
+ transcriptPath,
1723
+ datapackId = null,
1724
+ }) {
1725
+ let newConvId = null;
1726
+ try {
1727
+ newConvId = await createConversation(
1728
+ sessionId || "unknown",
1729
+ agentId,
1730
+ peekTranscriptMetadata(transcriptPath),
1731
+ datapackId,
1732
+ { freshId: true },
1733
+ );
1734
+ } catch (err) {
1735
+ return { ok: false, error: `conversation_create failed: ${err.message}` };
1736
+ }
1737
+ if (!newConvId) {
1738
+ return { ok: false, error: "conversation_create returned no ID" };
1739
+ }
1740
+ // A create that returns the failed id did not provide a recovery target.
1741
+ const sameId = (a, b) =>
1742
+ String(a || "").replace(/-/g, "").toLowerCase() ===
1743
+ String(b || "").replace(/-/g, "").toLowerCase();
1744
+ if (sameId(newConvId, state.conversation_id)) {
1745
+ return {
1746
+ ok: false,
1747
+ error: `conversation_create returned the missing conversation ${newConvId}`,
1748
+ };
1749
+ }
1750
+ const next = {
1751
+ ...state,
1752
+ conversation_id: newConvId,
1753
+ conversation_epoch: Number(state.conversation_epoch || 0) + 1,
1754
+ in_flight: { seed: null, user_line: null, user_turn_id: null },
1755
+ delivery: "pending",
1756
+ failure_class: null,
1757
+ last_error: null,
1758
+ blocked_reason: null,
1759
+ next_retry_at: null,
1760
+ attempt_count: 0,
1761
+ missing_conversation_streak: null,
1762
+ };
1763
+ // Suppress another replacement until this conversation accepts a turn. A
1764
+ // fresh trace may not be visible yet when the next request reaches a
1765
+ // different server replica.
1766
+ next.recovered_from_conversation_id = state.conversation_id || null;
1767
+ const written = captureState.writeState(sessionId, next);
1768
+ if (!written.ok) {
1769
+ return { ok: false, error: `failed to persist recovered conversation: ${written.error}` };
1770
+ }
1771
+ return { ok: true, state: written.state || next, convId: newConvId };
1772
+ }
1773
+
1103
1774
  /**
1104
1775
  * Abort all pending MCP requests. Used by the preload timeout path so the
1105
1776
  * SessionStart hook can return promptly instead of waiting for a slow server
@@ -1149,14 +1820,17 @@ async function mcpInitialize() {
1149
1820
  });
1150
1821
  }
1151
1822
 
1152
- async function mcpCall(toolName, args) {
1823
+ async function mcpCall(toolName, args, options) {
1153
1824
  mcpRequestId++;
1154
- const result = await mcpPost({
1155
- jsonrpc: "2.0",
1156
- method: "tools/call",
1157
- params: { name: toolName, arguments: args },
1158
- id: mcpRequestId,
1159
- });
1825
+ const result = await mcpPost(
1826
+ {
1827
+ jsonrpc: "2.0",
1828
+ method: "tools/call",
1829
+ params: { name: toolName, arguments: args },
1830
+ id: mcpRequestId,
1831
+ },
1832
+ options,
1833
+ );
1160
1834
  return result.body;
1161
1835
  }
1162
1836
 
@@ -1218,21 +1892,29 @@ function peekTranscriptMetadata(transcriptPath) {
1218
1892
  * best-effort enrichment, not a correctness requirement.
1219
1893
  */
1220
1894
  async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, budget = 2000 } = {}) {
1895
+ // memory_get_all takes no `limit`, and FastMCP rejects an unknown argument
1896
+ // with an isError result, so `limit` caps the page client-side instead.
1221
1897
  const payload = {
1222
1898
  agent_id: agentId,
1223
1899
  conversation_id: "00000000-0000-0000-0000-000000000000",
1224
- limit,
1225
1900
  };
1226
1901
  if (datapackId) payload.datapack_id = datapackId;
1227
1902
  const response = await mcpCall("memory_get_all", payload);
1903
+ if (response && response.result && response.result.isError) {
1904
+ const detail = ((response.result.content || [])[0] || {}).text || "";
1905
+ process.stderr.write(`[meko-capture] memory preload failed: ${truncate(detail.replace(/\s+/g, " "), 300)}\n`);
1906
+ return [];
1907
+ }
1228
1908
  const result = extractToolResult(response);
1229
1909
  if (!result) return [];
1230
1910
 
1231
1911
  const entries = Array.isArray(result) ? result : result.memories || result.results || [];
1232
1912
  const summaries = [];
1233
1913
  let used = 0;
1234
- for (const entry of entries) {
1235
- const text = entry.memory || entry.text || entry.content || "";
1914
+ for (const entry of entries.slice(0, limit)) {
1915
+ // The meko API returns the text as `data`; `memory` is the mem0 shape
1916
+ // servers returned before MEKO-216.
1917
+ const text = entry.data || entry.memory || entry.text || entry.content || "";
1236
1918
  if (!text) continue;
1237
1919
  const line = `- ${String(text).replace(/\s+/g, " ").trim()}`;
1238
1920
  if (used + line.length + 1 > budget) break;
@@ -1242,18 +1924,31 @@ async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, bud
1242
1924
  return summaries;
1243
1925
  }
1244
1926
 
1245
- async function createConversation(sessionId, agentId, metadata = null, datapackId = null) {
1927
+ /**
1928
+ * The server derives the conversation id from `session_id`, so the same session
1929
+ * always maps to the same conversation. Pass `freshId: true` to omit
1930
+ * `session_id` and have the server mint a new id instead. Recovery needs this:
1931
+ * re-creating with the session id returns the conversation that just failed.
1932
+ */
1933
+ async function createConversation(
1934
+ sessionId,
1935
+ agentId,
1936
+ metadata = null,
1937
+ datapackId = null,
1938
+ { freshId = false } = {},
1939
+ ) {
1246
1940
  const clientLabel = {
1247
1941
  claude_code: "Claude Code",
1248
1942
  cursor: "Cursor",
1249
1943
  codex: "Codex",
1250
1944
  kiro: "Kiro",
1945
+ opencode: "OpenCode",
1251
1946
  }[resolveHookClient()] || "Coding agent";
1252
1947
  const payload = {
1253
1948
  agent_id: agentId,
1254
1949
  title: `${clientLabel} session (auto-captured)`,
1255
- session_id: sessionId,
1256
1950
  };
1951
+ if (!freshId) payload.session_id = sessionId;
1257
1952
  if (metadata) {
1258
1953
  payload.metadata = JSON.stringify({
1259
1954
  source: "hook",
@@ -1265,6 +1960,24 @@ async function createConversation(sessionId, agentId, metadata = null, datapackI
1265
1960
  if (datapackId) payload.datapack_id = datapackId;
1266
1961
  const response = await mcpCall("conversation_create", payload);
1267
1962
  const result = extractToolResult(response);
1963
+ // MEKO-592: the server now returns a structured error when no datapack_id
1964
+ // resolves (missing pin, no server-side default). Before this guard the
1965
+ // server returned a success payload with a fabricated conversation id and
1966
+ // every subsequent add_message came back not_found — capture was silently
1967
+ // dead. Propagate the error like conversation_add_message already does so
1968
+ // callers can distinguish "server unreachable" from "action required (pin
1969
+ // a datapack)".
1970
+ if (result && result.error) {
1971
+ const err = new Error(`conversation_create failed: ${JSON.stringify(result.error)}`);
1972
+ err.code = result.error;
1973
+ err.detail = result.detail || "";
1974
+ // Distinguish a server-returned structured error from a Node system error
1975
+ // (e.g. ECONNREFUSED off `fetch`), which also carries `.code`. The catch
1976
+ // site branches on this to keep "MCP unavailable" reserved for transport
1977
+ // failures.
1978
+ err.isMcpToolError = true;
1979
+ throw err;
1980
+ }
1268
1981
  return result && result.id ? result.id : null;
1269
1982
  }
1270
1983
 
@@ -1362,7 +2075,7 @@ async function ensureConversationOwner({
1362
2075
  return { convId, agentId: cachedOwner, source: "derived", rebucketed: false };
1363
2076
  }
1364
2077
 
1365
- async function addMessage(convId, agentId, exchange, datapackId = null) {
2078
+ async function addMessage(convId, agentId, exchange, datapackId = null, timeoutMs = null) {
1366
2079
  const seed = `${convId}:${exchange.user_uuid}`;
1367
2080
  const payload = {
1368
2081
  conversation_id: convId,
@@ -1379,7 +2092,9 @@ async function addMessage(convId, agentId, exchange, datapackId = null) {
1379
2092
  ? exchange.metadata
1380
2093
  : JSON.stringify(exchange.metadata);
1381
2094
  }
1382
- const response = await mcpCall("conversation_add_message", payload);
2095
+ const response = await mcpCall("conversation_add_message", payload, {
2096
+ timeoutMs: timeoutMs || MEKO_ADD_MESSAGE_TIMEOUT,
2097
+ });
1383
2098
  // FAIL CLOSED. The MCP transport resolves successfully even when the tool
1384
2099
  // failed, and failures take several shapes: an `isError` result envelope, a
1385
2100
  // JSON body with an `error` field, or a non-JSON / missing-content / otherwise
@@ -1400,7 +2115,9 @@ async function addMessage(convId, agentId, exchange, datapackId = null) {
1400
2115
  );
1401
2116
  }
1402
2117
  if (result.error) {
1403
- throw new Error(`conversation_add_message failed: ${JSON.stringify(result.error)}`);
2118
+ // Keep the server's detail: isMissingConversationError() keys on it.
2119
+ const detail = typeof result.detail === "string" && result.detail ? `: ${result.detail}` : "";
2120
+ throw new Error(`conversation_add_message failed: ${JSON.stringify(result.error)}${detail}`);
1404
2121
  }
1405
2122
  // Contracts: advance only after a structurally valid accepted response that
1406
2123
  // contains a message ID (server returns message_id; tolerate `id` aliases).
@@ -1576,7 +2293,7 @@ ${opening}
1576
2293
 
1577
2294
  ### What you MUST do with Meko tools
1578
2295
 
1579
- - **agent_id**: use "${resolvedAgentId}" verbatim for every Meko MCP tool call in this session. This was derived from the cwd as \`<client>:<repo-basename>\` so memories stay scoped to this project. For genuinely cross-project facts (user identity, global preferences) pass agent_id="${COMMON_BUCKET_AGENT_ID}" — that's the common bucket any agent can read regardless of project.
2296
+ - **agent_id**: use "${resolvedAgentId}" verbatim for every Meko MCP tool call in this session. Project-scoped ids have the shape \`<client>:<repo-basename>\`, where the repo root is found by walking parents of the session cwd for \`.git\`, so memories stay scoped to this repo; sessions outside any repo use the bare client name (e.g. \`claude_code\`) rather than a colliding directory basename. A resumed session, or one that re-fires SessionStart (\`/clear\`, \`/compact\`), keeps the bucket its conversation already belongs to, and \`MEKO_AGENT_ID\` overrides either — so take the value above as given rather than re-deriving it yourself. For genuinely cross-project facts (user identity, global preferences) pass agent_id="${COMMON_BUCKET_AGENT_ID}" — that's the common bucket any agent can read regardless of project.
1580
2297
  - **conversation_id**: Use "${convId}" for all MCP tool calls that accept it
1581
2298
 
1582
2299
  ### Memory capture is automatic only when hook delivery succeeds — do NOT proactively call memory_add
@@ -1590,7 +2307,7 @@ Call memory_add (with the session's agent_id + conversation_id) ONLY for the nar
1590
2307
 
1591
2308
  ### Recall and before destructive actions
1592
2309
 
1593
- - User asks "what do you know about X?" → call memory_search first.
2310
+ - User asks "what do you know about X?" → call context_search first (memory, knowledge base, and past conversations in one call). Confirm an empty memory or knowledge-base result with memory_search or knowledgebase_search before saying nothing is stored.
1594
2311
  - Before any action that deletes, overwrites, or restructures user content (Write-overwrite, large Edit deletions, rm, git reset --hard, destructive SQL), call memory_search for relevant feedback and confirm with the user if any prior guidance applies.
1595
2312
 
1596
2313
  ### Use Meko, not local storage
@@ -1601,9 +2318,17 @@ Never write memories to local files (e.g., ~/.claude/projects/.../memory/). Meko
1601
2318
  function buildSessionStartFallbackContext(agentId, opts) {
1602
2319
  const resolvedAgentId = agentId || COMMON_BUCKET_AGENT_ID;
1603
2320
  const datapackBlock = buildActiveDatapackBlock(opts && opts.datapackPin ? opts.datapackPin : null);
2321
+ // MEKO-592: the "server unreachable" preamble is the wrong story when the
2322
+ // server accepted the call and told us to pin a datapack. Callers pass
2323
+ // reason:"datapack_required" to swap it for setup guidance the agent can
2324
+ // act on.
2325
+ const reason = opts && opts.reason;
2326
+ const preamble = reason === "datapack_required"
2327
+ ? "The SessionStart hook reached the Meko MCP server, but no datapack is pinned for this project, so the server declined to create a conversation. **Automatic conversation capture is idle**. Use `datapack_list` to find a datapack id, then invoke the `meko-select-datapack` skill (or your client's equivalent) to pin it. The next drain picks up the fresh pin and starts capturing this session's queued turns; until then, save facts explicitly."
2328
+ : "The SessionStart hook could not reach the Meko MCP server to create a conversation. **Automatic conversation capture is NOT running this session**, so the server-side extraction that normally saves memories for you will not fire. Fall back to saving facts explicitly.";
1604
2329
  return `## Meko Memory Instructions
1605
2330
 
1606
- The SessionStart hook could not reach the Meko MCP server to create a conversation. **Automatic conversation capture is NOT running this session**, so the server-side extraction that normally saves memories for you will not fire. Fall back to saving facts explicitly.
2331
+ ${preamble}
1607
2332
 
1608
2333
  ### What you should do while capture is down
1609
2334
 
@@ -1615,7 +2340,7 @@ The SessionStart hook could not reach the Meko MCP server to create a conversati
1615
2340
  Because nothing is capturing this session, explicit saves are the only path — this is the exception, not the normal mode:
1616
2341
  - User shares personal info, preferences, or role → call memory_add immediately
1617
2342
  - User shares org conventions or team standards → call memory_add immediately
1618
- - User asks "what do you know about X?" → call memory_search first
2343
+ - User asks "what do you know about X?" → call context_search first
1619
2344
 
1620
2345
  ### Use Meko, not local storage
1621
2346
 
@@ -1685,7 +2410,14 @@ async function handleSessionStart(hookInput) {
1685
2410
  const sessionId = extractSessionId(hookInput, transcriptPath);
1686
2411
 
1687
2412
  const source = hookInput.source || "";
1688
- const derivedAgentId = resolveSessionAgentId(transcriptPath, hookInput);
2413
+ const sessionCwd = pickSessionCwd(transcriptPath, hookInput);
2414
+ const derivedAgentId = resolveSessionAgentId(transcriptPath, hookInput, sessionCwd);
2415
+ const legacyAgentId = deriveLegacyAgentId({
2416
+ client: resolveHookClient(),
2417
+ cwd: sessionCwd,
2418
+ envOverride: process.env.MEKO_AGENT_ID,
2419
+ });
2420
+ logAgentIdDerivation(sessionCwd, derivedAgentId);
1689
2421
  const workspaceKey = workspaceKeyFromHookInput(hookInput, transcriptPath);
1690
2422
 
1691
2423
  // Check if watermark already exists (same session ID re-fires SessionStart, e.g. /clear, /compact)
@@ -1697,7 +2429,7 @@ async function handleSessionStart(hookInput) {
1697
2429
  `[meko-capture] SessionStart: corrupt watermark for ${sessionId} (${existing.error}); blocking.\n`,
1698
2430
  );
1699
2431
  const context = withHealthNotice(buildSessionStartFallbackContext(derivedAgentId, {
1700
- datapackPin: readDatapackPin(derivedAgentId),
2432
+ datapackPin: readDatapackPin(derivedAgentId, legacyAgentId),
1701
2433
  }));
1702
2434
  process.stdout.write(hookOutput(context));
1703
2435
  return;
@@ -1737,6 +2469,7 @@ async function handleSessionStart(hookInput) {
1737
2469
  // conversation_id) and inject context under the actual owner so nothing
1738
2470
  // is written to the wrong namespace; a later hook retries the rebucket.
1739
2471
  if (owned.hold) {
2472
+ logEffectiveAgentId(derivedAgentId, existing.agent_id, "watermark-hold");
1740
2473
  const holdContext = buildSessionStartContext(
1741
2474
  existing.conversation_id,
1742
2475
  sessionId,
@@ -1750,6 +2483,7 @@ async function handleSessionStart(hookInput) {
1750
2483
  return;
1751
2484
  }
1752
2485
  const resumedAgentId = owned.agentId;
2486
+ logEffectiveAgentId(derivedAgentId, resumedAgentId, "watermark-reconcile");
1753
2487
  const resumedConvId = owned.convId;
1754
2488
  // Persist the corrected identity + conversation (+ provenance) so the
1755
2489
  // checkpoint / PreCompact / SessionEnd hooks read a consistent watermark
@@ -1823,6 +2557,7 @@ async function handleSessionStart(hookInput) {
1823
2557
  // watermark for this resumed session (no owner we can safely write under)
1824
2558
  // — inject context under the prior owner and let a later hook retry.
1825
2559
  if (owned.hold) {
2560
+ logEffectiveAgentId(derivedAgentId, prior.agent_id, "resume-hold");
1826
2561
  const holdContext = buildSessionStartContext(
1827
2562
  prior.conversation_id,
1828
2563
  sessionId,
@@ -1837,6 +2572,7 @@ async function handleSessionStart(hookInput) {
1837
2572
  return;
1838
2573
  }
1839
2574
  const resumedAgentId = owned.agentId;
2575
+ logEffectiveAgentId(derivedAgentId, resumedAgentId, "resume-reconcile");
1840
2576
  const resumedConvId = owned.convId;
1841
2577
  // On a fresh rebucketed conversation, start the watermark at 0 so this
1842
2578
  // session's turns replay into it. Otherwise keep currentLineCount so the
@@ -1878,7 +2614,7 @@ async function handleSessionStart(hookInput) {
1878
2614
  // Intent-first: durable outbox BEFORE any network call so create failures
1879
2615
  // leave a recoverable needs_conversation state (Capture V2).
1880
2616
  const transcriptMetadata = peekTranscriptMetadata(transcriptPath);
1881
- const datapackPin = readDatapackPin(agentId);
2617
+ const datapackPin = readDatapackPin(agentId, legacyAgentId);
1882
2618
  const agentIdSource = (process.env.MEKO_AGENT_ID || "").trim() ? "explicit" : "derived";
1883
2619
  if (sessionId) {
1884
2620
  const intent = captureState.writeSessionIntent(sessionId, {
@@ -1908,9 +2644,38 @@ async function handleSessionStart(hookInput) {
1908
2644
  datapackPin && datapackPin.datapack_id,
1909
2645
  );
1910
2646
  } catch (err) {
1911
- process.stderr.write(`[meko-capture] SessionStart: MCP unavailable (${err.message}). Falling back to agent-driven setup.\n`);
1912
- let context = withHealthNotice(buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) }));
2647
+ // MEKO-592: distinguish "server unreachable" (network/init failure) from
2648
+ // "server accepted the call and told us to pin a datapack". The latter
2649
+ // is user action, not a server outage — logging it as "MCP unavailable"
2650
+ // sends the reporter down the wrong troubleshooting path.
2651
+ const isDatapackRequired = err && err.code === "datapack_id_required";
2652
+ if (isDatapackRequired) {
2653
+ process.stderr.write(
2654
+ `[meko-capture] SessionStart: no datapack pinned; auto-capture is idle. Pin one with datapack_list + meko-select-datapack; the next drain picks up the fresh pin.\n`,
2655
+ );
2656
+ } else if (err && err.isMcpToolError) {
2657
+ // Server accepted the call and returned a structured error (e.g.
2658
+ // invalid_metadata, create_failed). It is not "MCP unavailable"; print
2659
+ // the code so the reporter isn't sent down the network path. Node
2660
+ // system errors (ECONNREFUSED, ETIMEDOUT) also carry `.code`, so we
2661
+ // gate on the marker set by createConversation.
2662
+ process.stderr.write(
2663
+ `[meko-capture] SessionStart: server rejected conversation_create (${err.code}: ${err.detail || err.message}). Falling back to agent-driven setup.\n`,
2664
+ );
2665
+ } else {
2666
+ process.stderr.write(`[meko-capture] SessionStart: MCP unavailable (${err.message}). Falling back to agent-driven setup.\n`);
2667
+ }
2668
+ const contextOpts = { datapackPin: readDatapackPin(agentId, legacyAgentId) };
2669
+ if (isDatapackRequired) contextOpts.reason = "datapack_required";
2670
+ let context = withHealthNotice(buildSessionStartFallbackContext(agentId, contextOpts));
1913
2671
  if (sessionId) {
2672
+ // MEKO-592: state was written by writeSessionIntent above with
2673
+ // `delivery: "needs_conversation"`, which is the Capture V2 marker
2674
+ // that `recover` and drain look for. Don't overwrite it here - the
2675
+ // first drain classifies the failure and updates the health bucket.
2676
+ // The prior `status: "action_required"` cache write was dead code
2677
+ // (nothing reads the session cache for `--capture-status`), so keep
2678
+ // `"error"` and leave the health signal to state on the next drain.
1914
2679
  try {
1915
2680
  writeSessionCache(sessionId, {
1916
2681
  status: "error",
@@ -1929,7 +2694,9 @@ async function handleSessionStart(hookInput) {
1929
2694
 
1930
2695
  if (!convId) {
1931
2696
  process.stderr.write("[meko-capture] SessionStart: conversation_create returned no ID.\n");
1932
- let context = withHealthNotice(buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) }));
2697
+ let context = withHealthNotice(buildSessionStartFallbackContext(agentId, {
2698
+ datapackPin: readDatapackPin(agentId, legacyAgentId),
2699
+ }));
1933
2700
  if (sessionId) {
1934
2701
  try {
1935
2702
  writeSessionCache(sessionId, {
@@ -2006,10 +2773,26 @@ function persistExchangeCheckpoint(sessionId, state, nextLine, extras) {
2006
2773
  failure_class: null,
2007
2774
  last_error: null,
2008
2775
  blocked_reason: null,
2776
+ last_failure_scope: null,
2777
+ recovered_from_conversation_id: null,
2778
+ missing_conversation_streak: null,
2009
2779
  last_success_at: new Date().toISOString(),
2780
+ consecutive_exchange_drops:
2781
+ extras.consecutive_exchange_drops != null
2782
+ ? extras.consecutive_exchange_drops
2783
+ : 0,
2010
2784
  in_flight: { seed: null, user_line: null, user_turn_id: null },
2011
2785
  };
2012
2786
  if (extras.lifecycle) next.lifecycle = extras.lifecycle;
2787
+ if (extras.dropped_exchanges != null) {
2788
+ next.dropped_exchanges = extras.dropped_exchanges;
2789
+ }
2790
+ if (extras.last_drop_reason !== undefined) {
2791
+ next.last_drop_reason = extras.last_drop_reason;
2792
+ }
2793
+ if (extras.last_success_at !== undefined) {
2794
+ next.last_success_at = extras.last_success_at;
2795
+ }
2013
2796
  if (extras.agent_id != null) next.agent_id = extras.agent_id;
2014
2797
  if (extras.agent_id_source != null) next.agent_id_source = extras.agent_id_source;
2015
2798
  if (extras.conversation_id != null) next.conversation_id = extras.conversation_id;
@@ -2032,7 +2815,30 @@ function persistExchangeCheckpoint(sessionId, state, nextLine, extras) {
2032
2815
  * Build a synthetic interrupted exchange for a trailing incomplete user turn.
2033
2816
  */
2034
2817
  function buildInterruptedExchange(turn, sessionId) {
2035
- const input = extractText((turn.userMsg.message || {}).content) || "";
2818
+ const input = turnInput(turn) || "";
2819
+ // A turn held only because injected context (a skill body or a task
2820
+ // notification) never got its follow-up reply already has a real answer.
2821
+ // Keep it rather than replacing it with the interrupted marker.
2822
+ if (
2823
+ turn.assistantEntries.length > 0 &&
2824
+ !(turn.codexStreaming && !turn.codexComplete)
2825
+ ) {
2826
+ const { output, reasoning } = renderTurnBody(turn);
2827
+ if (output) {
2828
+ return {
2829
+ user_uuid: turn.userMsg.uuid || "unknown",
2830
+ user_line: turn.userLine,
2831
+ next_line_number: null,
2832
+ input,
2833
+ output,
2834
+ reasoning,
2835
+ timestamp:
2836
+ turn.assistantEntries[turn.assistantEntries.length - 1].timestamp ||
2837
+ turn.userMsg.timestamp ||
2838
+ "",
2839
+ };
2840
+ }
2841
+ }
2036
2842
  return {
2037
2843
  user_uuid: turn.userMsg.uuid || "unknown",
2038
2844
  user_line: turn.userLine,
@@ -2146,12 +2952,14 @@ async function drainSession(options) {
2146
2952
  last_error: null,
2147
2953
  last_success_at: null,
2148
2954
  blocked_reason: null,
2955
+ consecutive_exchange_drops: 0,
2149
2956
  };
2150
2957
  const transcriptPath =
2151
2958
  opts.transcript_path ||
2152
2959
  state.transcript_path ||
2153
2960
  null;
2154
2961
 
2962
+ const persistedLifecycle = state.lifecycle;
2155
2963
  if (opts.lifecycle && opts.lifecycle !== state.lifecycle) {
2156
2964
  state.lifecycle = opts.lifecycle;
2157
2965
  } else if (mode === "session-end" && state.lifecycle === "active") {
@@ -2164,10 +2972,16 @@ async function drainSession(options) {
2164
2972
  const retryAtMs = state.next_retry_at
2165
2973
  ? Date.parse(state.next_retry_at)
2166
2974
  : NaN;
2975
+ const nowMs = Date.now();
2976
+ const persistentRetryDue =
2977
+ state.failure_class === "persistent" &&
2978
+ state.last_failure_scope === "exchange" &&
2979
+ Number.isFinite(retryAtMs) &&
2980
+ retryAtMs <= nowMs;
2167
2981
  if (
2168
2982
  state.failure_class &&
2169
2983
  Number.isFinite(retryAtMs) &&
2170
- retryAtMs > Date.now() &&
2984
+ retryAtMs > nowMs &&
2171
2985
  !(Boolean(opts.force_retry) && state.failure_class === "persistent")
2172
2986
  ) {
2173
2987
  return makeDrainResult({
@@ -2184,6 +2998,52 @@ async function drainSession(options) {
2184
2998
  }
2185
2999
 
2186
3000
  if (!transcriptPath || !fs.existsSync(transcriptPath)) {
3001
+ // The transcript is the only source of turns, so a closed session whose
3002
+ // transcript is gone has nothing left to deliver — ever. Left alone it
3003
+ // sits at "pending" forever and holds the health rollup below healthy:
3004
+ // 26 such tombstones had accumulated, the oldest 16.7 days old, none of
3005
+ // them carrying a single deliverable turn.
3006
+ //
3007
+ // Only a session that is closing/closed AND has been idle past the grace
3008
+ // period is finalized. An ACTIVE session whose transcript has not
3009
+ // appeared yet must stay pending so it can still deliver, and the grace
3010
+ // period covers a transcript that is briefly unreadable (an unmounted
3011
+ // volume, a permissions blip) rather than genuinely gone.
3012
+ const idleMs = state.last_activity_at
3013
+ ? Date.now() - Date.parse(state.last_activity_at)
3014
+ : 0;
3015
+ const graceMs = transcriptMissingGraceSeconds() * 1000;
3016
+ const finalizable =
3017
+ (persistedLifecycle === "closing" || persistedLifecycle === "closed") &&
3018
+ Number.isFinite(idleMs) &&
3019
+ idleMs >= graceMs &&
3020
+ coerceQueued(state) === 0;
3021
+ if (stateExisted && finalizable) {
3022
+ state.lifecycle = "closed";
3023
+ state.delivery = "complete";
3024
+ state.queued_exchanges = 0;
3025
+ clearResolvedFailure(state);
3026
+ captureState.writeState(sessionId, state);
3027
+ process.stderr.write(
3028
+ `[meko-capture] Finalizing ${sessionId}: transcript is gone and the session ` +
3029
+ `closed ${Math.round(idleMs / 86400000)}d ago with nothing queued; ` +
3030
+ `nothing left to deliver.\n`,
3031
+ );
3032
+ return makeDrainResult({
3033
+ status: "noop",
3034
+ session_id: sessionId,
3035
+ last_line_number: state.last_line_number || 0,
3036
+ lifecycle: state.lifecycle,
3037
+ delivery: state.delivery,
3038
+ error: "transcript missing; finalized",
3039
+ });
3040
+ }
3041
+ // A recovery sweep marks abandoned sessions as closing while it drains
3042
+ // them. Do not persist that override when the transcript is absent: the
3043
+ // stored active lifecycle is the evidence that this session may return.
3044
+ if (mode === "recover" && persistedLifecycle === "active") {
3045
+ state.lifecycle = persistedLifecycle;
3046
+ }
2187
3047
  if (stateExisted) captureState.writeState(sessionId, state);
2188
3048
  return makeDrainResult({
2189
3049
  status: "noop",
@@ -2198,11 +3058,27 @@ async function drainSession(options) {
2198
3058
  let convId = state.conversation_id || "";
2199
3059
  let lastLine = state.last_line_number || 0;
2200
3060
  const currentLines = countLines(transcriptPath);
2201
- const datapackPin = datapackPinFromWatermark({
3061
+ let datapackPin = datapackPinFromWatermark({
2202
3062
  datapack_id: state.datapack_id,
2203
3063
  datapack_name: state.datapack_name,
2204
3064
  });
2205
3065
 
3066
+ // MEKO-592: SessionStart freezes state.datapack_id at the value the pin
3067
+ // resolved to when the session opened. If the session started before the
3068
+ // user pinned a datapack, that value is null and every drain here would
3069
+ // resend it, hitting datapack_id_required against the guarded server on
3070
+ // every retry. Re-read the pin file when state has none so a mid-session
3071
+ // pin actually recovers this session's queued turns instead of forcing
3072
+ // the user to start a fresh session.
3073
+ if (!datapackPin && (state.agent_id || "").trim()) {
3074
+ const freshPin = readDatapackPin(state.agent_id);
3075
+ if (freshPin && freshPin.datapack_id) {
3076
+ datapackPin = freshPin;
3077
+ state.datapack_id = freshPin.datapack_id;
3078
+ state.datapack_name = freshPin.datapack_name || null;
3079
+ }
3080
+ }
3081
+
2206
3082
  let effectiveAgentId = state.agent_id || "";
2207
3083
  let effectiveSource = state.agent_id_source || "";
2208
3084
 
@@ -2270,14 +3146,19 @@ async function drainSession(options) {
2270
3146
  }
2271
3147
  }
2272
3148
  } catch (err) {
2273
- const failureClass = captureState.classifyFailure(err);
3149
+ const persistent = classifyPersistentCaptureFailure(err);
3150
+ const failureClass = persistent
3151
+ ? "persistent"
3152
+ : captureState.classifyFailure(err);
3153
+ const failureText = persistentFailureText(persistent, err.message);
2274
3154
  const pendingExchanges = extractExchanges(transcriptPath, lastLine).length;
2275
3155
  // Only persist failure onto an existing outbox — never invent a watermark
2276
3156
  // just because initialize failed (SessionStart owns intent creation).
2277
3157
  if (stateExisted) {
2278
3158
  state.delivery = failureClass === "transient" ? "retry_wait" : "blocked";
2279
3159
  state.failure_class = failureClass;
2280
- state.last_error = err.message;
3160
+ state.last_error = failureText;
3161
+ state.last_failure_scope = persistent ? persistent.scope : null;
2281
3162
  state.attempt_count = (state.attempt_count || 0) + 1;
2282
3163
  state.next_retry_at = captureState.nextRetryAt({
2283
3164
  failure_class: failureClass,
@@ -2287,14 +3168,14 @@ async function drainSession(options) {
2287
3168
  captureState.writeState(sessionId, state);
2288
3169
  }
2289
3170
  process.stderr.write(
2290
- `[meko-capture] Failed to initialize MCP session: ${err.message}\n`,
3171
+ `[meko-capture] Failed to initialize MCP session: ${failureText}\n`,
2291
3172
  );
2292
3173
  return makeDrainResult({
2293
3174
  status: failureClass === "transient" ? "retry_wait" : "blocked",
2294
3175
  session_id: sessionId,
2295
3176
  queued_remaining: pendingExchanges,
2296
3177
  failure_class: failureClass,
2297
- error: err.message,
3178
+ error: failureText,
2298
3179
  last_line_number: lastLine,
2299
3180
  lifecycle: state.lifecycle,
2300
3181
  delivery: stateExisted ? state.delivery : "needs_conversation",
@@ -2391,6 +3272,7 @@ async function drainSession(options) {
2391
3272
  state.lifecycle = "closed";
2392
3273
  }
2393
3274
  state.queued_exchanges = 0;
3275
+ clearResolvedFailure(state);
2394
3276
  }
2395
3277
  state.agent_id = effectiveAgentId;
2396
3278
  state.agent_id_source = effectiveSource;
@@ -2414,8 +3296,32 @@ async function drainSession(options) {
2414
3296
  }
2415
3297
 
2416
3298
  const exchanges = allExchanges.slice(0, maxBatch);
3299
+ // PreCompact blocks the user, so it keeps the short timeout; background
3300
+ // drains wait for a slow server to finish storing a large turn.
3301
+ const addMessageTimeoutMs =
3302
+ mode === "pre-compact" ? MEKO_API_TIMEOUT : MEKO_ADD_MESSAGE_TIMEOUT;
3303
+ const leaseTtl = Math.max(
3304
+ captureState.leaseTtlSeconds(),
3305
+ Math.ceil(addMessageTimeoutMs / 1000) + 30,
3306
+ );
3307
+ // Set when renewal fails: "ownership" means another worker took the
3308
+ // lease; anything else means this worker couldn't write it.
3309
+ let leaseFailure = null;
2417
3310
  let captured = 0;
2418
3311
  let failed = 0;
3312
+ let dropped = 0;
3313
+ // Exchange-scoped HTML 403s may be payload-specific OR request-wide.
3314
+ // Allow one consecutive drop without an intervening success so a single
3315
+ // XSS turn cannot wedge the session; a second consecutive one holds the
3316
+ // queue because it may be a session-wide edge block.
3317
+ let consecutiveExchangeDrops = Number.isInteger(
3318
+ state.consecutive_exchange_drops,
3319
+ )
3320
+ ? state.consecutive_exchange_drops
3321
+ : 0;
3322
+ // Consume the backoff drop on the first exchange of this drain, success
3323
+ // or failure, so a later HTML 403 in the same batch cannot also skip.
3324
+ let allowExchangeDropAfterBackoff = persistentRetryDue;
2419
3325
  let cursorAdvanced = false;
2420
3326
  let watermarkLine = extractFrom;
2421
3327
  state.delivery = "draining";
@@ -2423,6 +3329,23 @@ async function drainSession(options) {
2423
3329
  captureState.writeState(sessionId, state);
2424
3330
 
2425
3331
  for (const exchange of exchanges) {
3332
+ // Keep the lease ahead of a slow add_message so another drain can't
3333
+ // reclaim it mid-batch. If renewal fails, stop without sending: either
3334
+ // another drain owns the session, or this worker can't write the lease.
3335
+ if (leaseToken) {
3336
+ const renewed = captureState.renewLease(sessionId, leaseToken, {
3337
+ ttlSeconds: leaseTtl,
3338
+ });
3339
+ if (!renewed.ok) {
3340
+ leaseFailure = {
3341
+ failure_class: renewed.failure_class === "ownership" ? "ownership" : "transient",
3342
+ error: renewed.error || "lease renewal failed",
3343
+ };
3344
+ break;
3345
+ }
3346
+ }
3347
+ const canDropAfterBackoff = allowExchangeDropAfterBackoff;
3348
+ allowExchangeDropAfterBackoff = false;
2426
3349
  const seed = `${convId}:${exchange.user_uuid}`;
2427
3350
  state.in_flight = {
2428
3351
  seed,
@@ -2438,6 +3361,7 @@ async function drainSession(options) {
2438
3361
  effectiveAgentId,
2439
3362
  exchange,
2440
3363
  datapackPin && datapackPin.datapack_id,
3364
+ addMessageTimeoutMs,
2441
3365
  );
2442
3366
  const boundary = boundaryAfterExchange(turnLines, exchange.user_line);
2443
3367
  const nextLine =
@@ -2446,13 +3370,13 @@ async function drainSession(options) {
2446
3370
  : exchange.next_line_number != null
2447
3371
  ? exchange.next_line_number
2448
3372
  : safeLine;
2449
- const remainingAfter = allExchanges.length - captured - 1;
3373
+ const remainingAfter = allExchanges.length - captured - dropped - 1;
2450
3374
  const ck = persistExchangeCheckpoint(sessionId, state, nextLine, {
2451
3375
  delivery:
2452
3376
  remainingAfter > 0 || allExchanges.length > maxBatch
2453
3377
  ? "pending"
2454
3378
  : "idle",
2455
- queued_exchanges: Math.max(0, allExchanges.length - captured - 1),
3379
+ queued_exchanges: Math.max(0, remainingAfter),
2456
3380
  agent_id: effectiveAgentId,
2457
3381
  agent_id_source: effectiveSource,
2458
3382
  conversation_id: convId,
@@ -2485,32 +3409,194 @@ async function drainSession(options) {
2485
3409
  state = ck.state;
2486
3410
  watermarkLine = state.last_line_number;
2487
3411
  captured++;
3412
+ consecutiveExchangeDrops = 0;
2488
3413
  cursorAdvanced = true;
2489
3414
  } catch (err) {
3415
+ // A conversation the server no longer has never heals, so retrying the
3416
+ // same id burns attempts and strands every queued turn. Open a fresh
3417
+ // conversation and move the undelivered tail into it. Return immediately
3418
+ // rather than falling through to the post-loop cursor advance.
3419
+ //
3420
+ // A single not_found can be a transient Langfuse lookup. Wait until the
3421
+ // same detail persists across two drains before opening a replacement,
3422
+ // so one blip does not fork a live conversation.
3423
+ //
3424
+ // Recover at most once before the replacement accepts a turn. If the
3425
+ // replacement reports not_found first, the fresh trace may not be
3426
+ // visible on another replica yet. Retry it rather than opening one
3427
+ // conversation per attempt. A successful checkpoint clears the guard,
3428
+ // so a later deletion can recover again.
3429
+ const missingConversation = isMissingConversationError(err);
3430
+ if (missingConversation) {
3431
+ state.missing_conversation_streak = missingConversationStreak(state) + 1;
3432
+ } else if (state.missing_conversation_streak) {
3433
+ state.missing_conversation_streak = null;
3434
+ }
3435
+ const alreadyRecovered = Boolean(state.recovered_from_conversation_id);
3436
+ const streakPersisted =
3437
+ missingConversationStreak(state) >= MISSING_CONVERSATION_RECOVERY_POLLS;
3438
+ if (missingConversation && alreadyRecovered) {
3439
+ process.stderr.write(
3440
+ `[meko-capture] Conversation ${convId} reports not_found, but it already replaced ` +
3441
+ `${state.recovered_from_conversation_id}; retrying it instead of opening another.\n`,
3442
+ );
3443
+ } else if (missingConversation && !streakPersisted) {
3444
+ process.stderr.write(
3445
+ `[meko-capture] Conversation ${convId} reports not_found ` +
3446
+ `(${missingConversationStreak(state)}/${MISSING_CONVERSATION_RECOVERY_POLLS}); ` +
3447
+ `retrying it before opening a new conversation.\n`,
3448
+ );
3449
+ }
3450
+ if (missingConversation && !alreadyRecovered && streakPersisted) {
3451
+ const recovered = await recoverMissingConversation({
3452
+ state,
3453
+ sessionId,
3454
+ agentId: effectiveAgentId,
3455
+ transcriptPath,
3456
+ datapackId: datapackPin && datapackPin.datapack_id,
3457
+ });
3458
+ if (recovered.ok) {
3459
+ process.stderr.write(
3460
+ `[meko-capture] Conversation ${convId} is gone server-side (${err.message}); ` +
3461
+ `moving ${allExchanges.length - captured} queued exchange(s) into new conversation ` +
3462
+ `${recovered.convId}.\n`,
3463
+ );
3464
+ return makeDrainResult({
3465
+ status: "retry_wait",
3466
+ session_id: sessionId,
3467
+ captured,
3468
+ queued_remaining: allExchanges.length - captured,
3469
+ last_line_number: recovered.state.last_line_number || 0,
3470
+ lifecycle: recovered.state.lifecycle,
3471
+ delivery: recovered.state.delivery,
3472
+ });
3473
+ }
3474
+ process.stderr.write(
3475
+ `[meko-capture] Conversation ${convId} is gone server-side but recovery failed ` +
3476
+ `(${recovered.error}); retrying.\n`,
3477
+ );
3478
+ }
3479
+ const persistent = classifyPersistentCaptureFailure(err, {
3480
+ exchange: true,
3481
+ });
3482
+
3483
+ // An exchange-scoped persistent rejection *may* be deterministic for
3484
+ // this exchange only (edge filtering matched a signature inside the
3485
+ // transcript text). Holding forever behind a true payload rejection
3486
+ // wedges the rest of the session (#13). HTML 403 alone is not proof
3487
+ // it is payload-specific — request-wide edge blocks share that shape
3488
+ // — so allow at most one consecutive drop without an intervening
3489
+ // success; a second consecutive failure holds for retry.
3490
+ if (
3491
+ persistent &&
3492
+ persistent.scope === "exchange" &&
3493
+ (consecutiveExchangeDrops < 1 || canDropAfterBackoff)
3494
+ ) {
3495
+ const boundary = boundaryAfterExchange(turnLines, exchange.user_line);
3496
+ const nextLine =
3497
+ boundary != null
3498
+ ? boundary
3499
+ : exchange.next_line_number != null
3500
+ ? exchange.next_line_number
3501
+ : safeLine;
3502
+ const remainingAfter =
3503
+ allExchanges.length - captured - dropped - 1;
3504
+ const drop = persistExchangeCheckpoint(sessionId, state, nextLine, {
3505
+ delivery:
3506
+ remainingAfter > 0 || allExchanges.length > maxBatch
3507
+ ? "pending"
3508
+ : "idle",
3509
+ queued_exchanges: Math.max(0, remainingAfter),
3510
+ // Nothing was stored, so the last-success stamp must not move.
3511
+ last_success_at: state.last_success_at || null,
3512
+ dropped_exchanges: (state.dropped_exchanges || 0) + 1,
3513
+ last_drop_reason: persistent.reason,
3514
+ consecutive_exchange_drops: consecutiveExchangeDrops + 1,
3515
+ agent_id: effectiveAgentId,
3516
+ agent_id_source: effectiveSource,
3517
+ conversation_id: convId,
3518
+ datapack_id: datapackPin ? datapackPin.datapack_id : null,
3519
+ datapack_name: datapackPin ? datapackPin.datapack_name : null,
3520
+ });
3521
+ if (drop.ok) {
3522
+ state = drop.state;
3523
+ watermarkLine = state.last_line_number;
3524
+ dropped++;
3525
+ consecutiveExchangeDrops = state.consecutive_exchange_drops;
3526
+ cursorAdvanced = true;
3527
+ process.stderr.write(
3528
+ `[meko-capture] Dropped un-storable exchange (uuid=${exchange.user_uuid}): ` +
3529
+ `${persistent.reason}. Cursor advanced to line ${watermarkLine}; ` +
3530
+ `capture continues for the rest of this session.\n`,
3531
+ );
3532
+ continue;
3533
+ }
3534
+ // Cursor advance itself failed — fall through and hold, rather than
3535
+ // losing the exchange without a durable record that it was dropped.
3536
+ process.stderr.write(
3537
+ `[meko-capture] Could not record dropped exchange (uuid=${exchange.user_uuid}): ${drop.error}\n`,
3538
+ );
3539
+ } else if (persistent && persistent.scope === "exchange") {
3540
+ process.stderr.write(
3541
+ `[meko-capture] Holding after consecutive edge 403s (uuid=${exchange.user_uuid}): ` +
3542
+ `${persistent.reason}. A second consecutive HTML 403 without an ` +
3543
+ `intervening success may be request-wide; queue is held for retry.\n`,
3544
+ );
3545
+ }
3546
+
2490
3547
  failed++;
2491
- const persistent = classifyPersistentCaptureFailure(err);
2492
3548
  const failureClass = persistent
2493
3549
  ? "persistent"
2494
3550
  : captureState.classifyFailure(err);
2495
3551
  state.delivery =
2496
3552
  failureClass === "transient" ? "retry_wait" : "blocked";
2497
3553
  state.failure_class = failureClass;
2498
- state.last_error = err.message;
3554
+ // Prefer the classifier reason so quota/entitlement text survives into
3555
+ // state and SessionStart notices instead of an opaque http_403.
3556
+ state.last_error = persistentFailureText(persistent, err.message);
3557
+ state.last_failure_scope = persistent ? persistent.scope : null;
2499
3558
  state.attempt_count = (state.attempt_count || 0) + 1;
2500
3559
  state.next_retry_at = captureState.nextRetryAt({
2501
3560
  failure_class: failureClass,
2502
3561
  attempt_count: state.attempt_count,
2503
3562
  });
2504
- state.queued_exchanges = allExchanges.length - captured;
3563
+ state.queued_exchanges = allExchanges.length - captured - dropped;
2505
3564
  captureState.writeState(sessionId, state);
2506
3565
  process.stderr.write(
2507
- `[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${err.message}\n`,
3566
+ `[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${state.last_error}\n`,
2508
3567
  );
2509
3568
  break;
2510
3569
  }
2511
3570
  }
2512
3571
 
2513
- const held = allExchanges.length - captured;
3572
+ // Dropped exchanges are resolved, not held: they can never be stored, so
3573
+ // they must not count as backlog or the session reads as permanently behind.
3574
+ const held = allExchanges.length - captured - dropped;
3575
+ if (leaseFailure) {
3576
+ // Progress so far is already checkpointed. Nothing is recorded as a
3577
+ // delivery failure: the server never saw the next exchange.
3578
+ const lost = leaseFailure.failure_class === "ownership";
3579
+ process.stderr.write(
3580
+ lost
3581
+ ? `[meko-capture] drain lease lost after ${captured} exchange(s); leaving the rest to its new owner.\n`
3582
+ : `[meko-capture] could not renew the drain lease after ${captured} exchange(s): ${leaseFailure.error}; stopping this batch.\n`,
3583
+ );
3584
+ return makeDrainResult({
3585
+ status: "retry_wait",
3586
+ session_id: sessionId,
3587
+ captured,
3588
+ dropped,
3589
+ queued_remaining: held,
3590
+ failure_class: leaseFailure.failure_class,
3591
+ error: lost
3592
+ ? "drain lease lost to another worker"
3593
+ : `drain lease renewal failed: ${leaseFailure.error}`,
3594
+ cursor_advanced: cursorAdvanced,
3595
+ last_line_number: state.last_line_number || 0,
3596
+ lifecycle: state.lifecycle,
3597
+ delivery: state.delivery,
3598
+ });
3599
+ }
2514
3600
  if (failed === 0 && allExchanges.length <= maxBatch && pendingFromLine === null) {
2515
3601
  watermarkLine = safeLine;
2516
3602
  if (watermarkLine >= state.last_line_number) {
@@ -2527,8 +3613,10 @@ async function drainSession(options) {
2527
3613
  state.delivery = "idle";
2528
3614
  state.queued_exchanges = 0;
2529
3615
  }
3616
+ clearResolvedFailure(state);
2530
3617
  captureState.writeState(sessionId, state);
2531
- cursorAdvanced = captured > 0 || cursorAdvanced || synthesizedInterrupted;
3618
+ cursorAdvanced =
3619
+ captured > 0 || dropped > 0 || cursorAdvanced || synthesizedInterrupted;
2532
3620
  } else if (failed === 0 && allExchanges.length > maxBatch) {
2533
3621
  state.delivery = "pending";
2534
3622
  state.queued_exchanges = held;
@@ -2544,6 +3632,13 @@ async function drainSession(options) {
2544
3632
  );
2545
3633
  }
2546
3634
 
3635
+ if (dropped > 0) {
3636
+ process.stderr.write(
3637
+ `[meko-capture] ${dropped} exchange(s) could not be stored and were ` +
3638
+ `skipped so the rest of the session still captures.\n`,
3639
+ );
3640
+ }
3641
+
2547
3642
  const finalState = captureState.readState(sessionId);
2548
3643
  const s = finalState.ok ? finalState.state : state;
2549
3644
  let status = "ok";
@@ -2562,6 +3657,7 @@ async function drainSession(options) {
2562
3657
  status,
2563
3658
  session_id: sessionId,
2564
3659
  captured,
3660
+ dropped,
2565
3661
  queued_remaining: held,
2566
3662
  failure_class: failed > 0 ? s.failure_class || null : null,
2567
3663
  error: failed > 0 ? s.last_error || null : null,
@@ -2641,7 +3737,7 @@ async function main() {
2641
3737
  const drainOpts = {
2642
3738
  session_id: sessionId,
2643
3739
  transcript_path: transcriptPath,
2644
- mode: hookType === "recover" ? "drain" : hookType,
3740
+ mode: hookType,
2645
3741
  lifecycle: hookInput.lifecycle,
2646
3742
  confirmed_dead: Boolean(hookInput.confirmed_dead),
2647
3743
  recorded_owner_only: Boolean(hookInput.recorded_owner_only),
@@ -2688,14 +3784,18 @@ async function main() {
2688
3784
 
2689
3785
  module.exports = {
2690
3786
  deriveAgentId,
3787
+ deriveLegacyAgentId,
2691
3788
  drainSession,
2692
3789
  makeDrainResult,
2693
3790
  boundaryAfterExchange,
2694
3791
  realUserTurnLines,
2695
3792
  INTERRUPTED_ASSISTANT_OUTPUT,
2696
3793
  buildExchanges,
3794
+ buildInterruptedExchange,
2697
3795
  extractExchanges,
2698
3796
  addMessage,
3797
+ isMissingConversationError,
3798
+ recoverMissingConversation,
2699
3799
  };
2700
3800
 
2701
3801
  if (require.main === module) {