{
  "version": 3,
  "sources": ["../../../src/hooks/ts/lib/mcp-deferred-gate.ts", "../../../src/hooks/ts/lib/mcp-deferred-reminder.ts", "../../../src/hooks/ts/lib/worker-session.ts", "../../../src/hooks/ts/session-start/mcp-deferred-reminder.ts"],
  "sourcesContent": ["/**\n * mcp-deferred-gate.ts \u2014 pure pieces of the deferred MCP tool gate.\n *\n * Claude Code defers MCP tool schemas: an mcp__server__tool call fails input\n * validation unless the session surfaced the schema via ToolSearch first.\n * Sessions misread that error as \"MCP server disconnected\" and report an\n * outage that never happened (observed with a rename tool while every server\n * was connected).\n *\n * A second failure shape: when an MCP server re-registers mid-session, the\n * session's deferred tool handles are invalidated and calls fail with the\n * harness text \"N deferred tools are no longer available\" (observed 2026-09-18,\n * N=411, a rename tool). Sessions misread that as a disconnect too. The gate\n * denies that shape with the same ToolSearch remedy; a ToolSearch that\n * surfaces the tool after the last invalidation re-arms the pass-through.\n *\n * The decision is evidence-based: the session transcript either shows the tool\n * was surfaced (a ToolSearch mentioning it) or already ran successfully \u2014 in\n * which case the schema is loaded and the call passes through untouched \u2014 or\n * it shows neither, in which case the call is about to hit the gate (or is a\n * retry that just did) and gets the corrective instruction.\n *\n * Split out of the pre-tool-use entrypoint (which calls main() at import\n * time) so the decision is testable without spawning a hook process, per the\n * transcript-text.ts precedent.\n */\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface GateHookInput {\n  session_id?: string;\n  transcript_path?: string;\n  cwd?: string;\n  tool_name?: string;\n  tool_input?: unknown;\n}\n\nexport interface GateEvidence {\n  /** A ToolSearch call in the transcript mentions this tool by full name. */\n  surfacedByToolSearch: boolean;\n  /** This tool already ran successfully \u2014 its schema must be loaded. */\n  succeededBefore: boolean;\n}\n\nexport interface GateObservation {\n  type: \"decision\";\n  title: string;\n  narrative: string;\n  tool_name: string;\n  tool_input_summary: string;\n  files_read: string[];\n  files_modified: string[];\n  concepts: string[];\n}\n\nexport interface GateDecision {\n  action: \"pass\" | \"correct\";\n  /** The JSON string to write to stdout, same shape as sibling PreToolUse hooks. */\n  output: string;\n  /** Observation payload for the daemon when a correction fired, else null. */\n  observation: GateObservation | null;\n}\n\n// ---------------------------------------------------------------------------\n// Decision\n// ---------------------------------------------------------------------------\n\nexport const PASS_OUTPUT = JSON.stringify({\n  hookSpecificOutput: { hookEventName: \"PreToolUse\", permissionDecision: \"allow\" },\n});\n\n/**\n * Harness text emitted when a mid-session MCP server re-registration\n * invalidates the session's deferred tool handles.\n */\nexport const STALE_HANDLE_MARKER = \"deferred tools are no longer available\";\n\n/** True for MCP tool names (`mcp__<server>__<tool>`), false for everything else. */\nexport function isMcpTool(name: string): boolean {\n  return name.startsWith(\"mcp__\") && name.split(\"__\").length >= 3 && !!name.split(\"__\")[2];\n}\n\n/**\n * Scan transcript text (JSONL, one entry per line, tolerant of junk) for\n * evidence that `toolName` is loadable in this session.\n */\nexport function scanTranscriptEvidence(transcriptText: string, toolName: string): GateEvidence {\n  const toolUseIds = new Set<string>();\n  let surfacedByToolSearch = false;\n  let succeededBefore = false;\n\n  for (const line of transcriptText.split(\"\\n\")) {\n    const trimmed = line.trim();\n    if (!trimmed) continue;\n    let entry: unknown;\n    try {\n      entry = JSON.parse(trimmed);\n    } catch {\n      continue;\n    }\n    const message = (entry as { message?: { content?: unknown } })?.message;\n    const content = message?.content;\n    if (!Array.isArray(content)) continue;\n\n    for (const block of content as Array<Record<string, unknown>>) {\n      if (!block || typeof block !== \"object\") continue;\n      if (block.type === \"tool_use\" && typeof block.name === \"string\") {\n        if (block.name === toolName && typeof block.id === \"string\") {\n          toolUseIds.add(block.id);\n        } else if (block.name === \"ToolSearch\") {\n          // Liberal match: the query may be \"select:<tool>\" or a keyword list.\n          if (JSON.stringify(block.input ?? \"\").includes(toolName)) {\n            surfacedByToolSearch = true;\n          }\n        }\n      } else if (\n        block.type === \"tool_result\" &&\n        typeof block.tool_use_id === \"string\" &&\n        toolUseIds.has(block.tool_use_id) &&\n        !block.is_error\n      ) {\n        succeededBefore = true;\n      }\n    }\n  }\n\n  return { surfacedByToolSearch, succeededBefore };\n}\n\n/**\n * Byte index of the last transcript line where a ToolSearch call surfaced\n * `toolName` (same liberal match as scanTranscriptEvidence), or -1 if never.\n * Used to order the stale-handle remedy against the last invalidation.\n */\nexport function lastToolSearchSurfacingIndex(\n  transcriptText: string,\n  toolName: string,\n): number {\n  let last = -1;\n  let offset = 0;\n  for (const line of transcriptText.split(\"\\n\")) {\n    const start = offset;\n    offset += line.length + 1;\n    const trimmed = line.trim();\n    if (!trimmed) continue;\n    let entry: unknown;\n    try {\n      entry = JSON.parse(trimmed);\n    } catch {\n      continue;\n    }\n    const content = (entry as { message?: { content?: unknown } })?.message?.content;\n    if (!Array.isArray(content)) continue;\n    for (const block of content as Array<Record<string, unknown>>) {\n      if (\n        block?.type === \"tool_use\" &&\n        block.name === \"ToolSearch\" &&\n        JSON.stringify(block.input ?? \"\").includes(toolName)\n      ) {\n        last = start;\n      }\n    }\n  }\n  return last;\n}\n\n/** The ToolSearch remedy shared by both correction shapes. */\nfunction buildRemedy(toolName: string): string {\n  return (\n    `Remedy: call ToolSearch with query \"select:${toolName}\" to load the fresh registry ` +\n    `entry, then call ${toolName} again with the same arguments.`\n  );\n}\n\n/** The corrective instruction fed back to the session on a deny. */\nexport function buildCorrection(toolName: string): string {\n  return (\n    `${toolName} is a deferred MCP tool: its schema is not loaded in this session yet, so this ` +\n    `call would fail input validation. The MCP server is NOT disconnected \u2014 do not report an ` +\n    `outage and do not retry the call unchanged. ${buildRemedy(toolName)}`\n  );\n}\n\n/** Same remedy for the stale-handle shape: a re-registration invalidated a loaded schema. */\nexport function buildStaleHandleCorrection(toolName: string): string {\n  return (\n    `${toolName}'s deferred tool handle is stale: the MCP server re-registered mid-session, ` +\n    `invalidating the schema this session had loaded (\"${STALE_HANDLE_MARKER}\"). The MCP ` +\n    `server is NOT disconnected \u2014 do not report an outage and do not retry the call ` +\n    `unchanged. ${buildRemedy(toolName)}`\n  );\n}\n\n/** The deny decision shared by both failure shapes. */\nfunction correctDecision(toolName: string, reason: string, narrative: string): GateDecision {\n  return {\n    action: \"correct\",\n    output: JSON.stringify({\n      hookSpecificOutput: {\n        hookEventName: \"PreToolUse\",\n        permissionDecision: \"deny\",\n        permissionDecisionReason: reason,\n      },\n    }),\n    observation: {\n      type: \"decision\",\n      title: `MCP deferred-tool gate corrected: ${toolName}`,\n      narrative,\n      tool_name: toolName,\n      tool_input_summary: toolName,\n      files_read: [],\n      files_modified: [],\n      concepts: [\"mcp\", \"deferred\", \"toolsearch\"],\n    },\n  };\n}\n\n/**\n * The whole gate: given hook input and the session transcript, decide whether\n * this mcp__ call passes through or gets the deferred-schema correction.\n */\nexport function decideMcpGate(input: GateHookInput, transcriptText: string): GateDecision {\n  const toolName = typeof input.tool_name === \"string\" ? input.tool_name : \"\";\n  if (!isMcpTool(toolName)) {\n    return { action: \"pass\", output: PASS_OUTPUT, observation: null };\n  }\n\n  // Stale-handle shape: a mid-session re-registration invalidated loaded\n  // schemas. Overrides the normal evidence pass-through, because a tool that\n  // was surfaced (or even ran) earlier can still have a dead handle now. The\n  // gate re-arms to pass-through only once a ToolSearch re-surfaced the tool\n  // after the last invalidation.\n  const toolInputText = JSON.stringify(input.tool_input ?? \"\");\n  const lastStale = transcriptText.lastIndexOf(STALE_HANDLE_MARKER);\n  if (toolInputText.includes(STALE_HANDLE_MARKER) || lastStale !== -1) {\n    const remedyAt = lastToolSearchSurfacingIndex(transcriptText, toolName);\n    if (remedyAt === -1 || remedyAt <= lastStale) {\n      return correctDecision(\n        toolName,\n        buildStaleHandleCorrection(toolName),\n        `Blocked a retry against a stale deferred-tool handle for ${toolName} and issued ` +\n          \"the ToolSearch remedy (server not disconnected)\",\n      );\n    }\n    return { action: \"pass\", output: PASS_OUTPUT, observation: null };\n  }\n\n  const evidence = scanTranscriptEvidence(transcriptText, toolName);\n  if (evidence.surfacedByToolSearch || evidence.succeededBefore) {\n    return { action: \"pass\", output: PASS_OUTPUT, observation: null };\n  }\n\n  return correctDecision(\n    toolName,\n    buildCorrection(toolName),\n    `Blocked a call to deferred MCP tool ${toolName} and issued the ToolSearch remedy ` +\n      \"(server not disconnected)\",\n  );\n}\n", "/**\n * mcp-deferred-reminder.ts \u2014 pure pieces of the session-start deferred-handle reminder.\n *\n * A resumed session inherits its transcript, and a transcript can contain a\n * mid-session MCP re-registration: the deferred tool handles the session held\n * are stale from then on (\"deferred tools are no longer available\"). The\n * call-time gate (../pre-tool-use/mcp-deferred-gate.ts) can only correct a call\n * that is still ATTEMPTED \u2014 but a resumed session that already concluded once\n * that \"the MCP server is disconnected\" never retries, so the gate never fires\n * (observed 2026-09-18: 37s of thinking, then a skip on \"aibroker is down\n * right now\"). The cure has to arrive before the first tool call, at session\n * start: a fresh claim that stale history is not evidence, so the model\n * attempts the call and lands in the gate if the handle really is stale.\n *\n * Fired for sources \"resume\" and \"compact\" only \u2014 fresh sessions have no\n * stale handles and no stale conclusions to unlearn.\n *\n * Split out of the session-start entrypoint so the decision is testable\n * without spawning a hook process, per the mcp-deferred-gate.ts precedent.\n */\n\n// Reused so reminder and gate corrections read as one instrument.\nimport { STALE_HANDLE_MARKER } from \"./mcp-deferred-gate.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ReminderHookInput {\n  source?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Decision\n// ---------------------------------------------------------------------------\n\n/**\n * SessionStart sources the reminder applies to. \"startup\" and \"clear\" begin\n * with an empty session state \u2014 nothing to heal there.\n */\nexport const REMINDER_SOURCES = new Set([\"resume\", \"compact\"]);\n\n/** The exact reminder text; exported so tests can match against the real thing. */\nexport function buildReminder(): string {\n  return (\n    `<system-reminder>\\n` +\n    `Resumed sessions keep stale deferred MCP tool handles: a mid-session MCP server ` +\n    `re-registration invalidates every loaded mcp__ schema (\"${STALE_HANDLE_MARKER}\"). ` +\n    `If any mcp__ tool call fails or a tool seems unavailable: run ToolSearch with query ` +\n    `\"select:<full tool name>\" and retry once. A prior \"the MCP server is disconnected\" ` +\n    `conclusion in this history is stale evidence \u2014 do not skip the retry; only a call ` +\n    `failing after a fresh ToolSearch proves an outage.\\n` +\n    `</system-reminder>`\n  );\n}\n\n/**\n * The whole reminder: given hook input, return the stdout payload for sources\n * resume/compact, or null for every other (silent exit 0).\n */\nexport function decideMcpDeferredReminder(input: ReminderHookInput): string | null {\n  if (typeof input.source !== \"string\" || !REMINDER_SOURCES.has(input.source)) {\n    return null;\n  }\n  return buildReminder();\n}\n", "/**\n * Worker-session detection.\n *\n * A disposable headless worker (a `claude -p` run started by an orchestrating\n * session, possibly against a different model provider with a different\n * context window) shares the project directory and the hook configuration\n * with the real session that spawned it. Left alone, the hooks treat it as a\n * session in its own right: they inject project context into it, create and\n * rename a numbered session note for it, autosave it, and enqueue a\n * model-written handover for it. Its compactions also land in the project's\n * transcript folder, where they are indistinguishable from a real session's\n * and drag the measured compaction trigger down (observed 2026-09-17: two\n * workers compacting at ~151k tokens pulled a project's trigger from ~784k\n * to ~151k, and the real session's handover fired at ~50k tokens).\n *\n * The launcher marks such sessions with `PAI_WORKER=1`. Every hook that does\n * per-session bookkeeping returns immediately when this predicate is true.\n * Deliberately NOT guarded: the security validator (a worker's shell\n * commands must still be checked) and observability capture.\n */\nexport function isWorkerSession(env: NodeJS.ProcessEnv = process.env): boolean {\n  return env.PAI_WORKER === \"1\";\n}\n", "#!/usr/bin/env node\n\n/**\n * mcp-deferred-reminder.ts \u2014 SessionStart hook (matchers: resume, compact)\n *\n * Resumed sessions can inherit stale deferred MCP tool handles (a mid-session\n * server re-registration) together with a stale \"the MCP server is\n * disconnected\" conclusion from before the restart \u2014 a combination that makes\n * the session skip the retry the call-time gate would need in order to fire.\n * This hook injects the counter-instruction at session start, before any tool\n * call: see ../lib/mcp-deferred-reminder.ts for the rationale and text.\n *\n * stdout is captured by Claude Code and injected into the session context,\n * same channel as inject-observations. Never blocks session start: always\n * exits 0.\n */\n\nimport { decideMcpDeferredReminder, type ReminderHookInput } from \"../lib/mcp-deferred-reminder.js\";\nimport { isWorkerSession } from \"../lib/worker-session.js\";\n\nasync function main() {\n  if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping\n  let input: ReminderHookInput = {};\n\n  try {\n    const decoder = new TextDecoder();\n    let raw = '';\n    const timeoutPromise = new Promise<void>((resolve) => { setTimeout(resolve, 500); });\n    const readPromise = (async () => {\n      for await (const chunk of process.stdin) {\n        raw += decoder.decode(chunk, { stream: true });\n      }\n    })();\n    await Promise.race([readPromise, timeoutPromise]);\n    if (raw.trim()) {\n      input = JSON.parse(raw) as ReminderHookInput;\n    }\n  } catch {\n    // Junk input: stay silent, exit 0\n    process.exit(0);\n  }\n\n  const reminder = decideMcpDeferredReminder(input);\n  if (reminder) {\n    console.log(reminder);\n    console.error(`mcp-deferred-reminder: injected reminder for source=${input.source}`);\n  }\n\n  process.exit(0);\n}\n\nmain().catch(() => {\n  process.exit(0);\n});\n"],
  "mappings": ";;;;;;AAqEO,IAAM,cAAc,KAAK,UAAU;AAAA,EACxC,oBAAoB,EAAE,eAAe,cAAc,oBAAoB,QAAQ;AACjF,CAAC;AAMM,IAAM,sBAAsB;;;ACrC5B,IAAM,mBAAmB,oBAAI,IAAI,CAAC,UAAU,SAAS,CAAC;AAGtD,SAAS,gBAAwB;AACtC,SACE;AAAA,0IAE2D,mBAAmB;AAAA;AAOlF;AAMO,SAAS,0BAA0B,OAAyC;AACjF,MAAI,OAAO,MAAM,WAAW,YAAY,CAAC,iBAAiB,IAAI,MAAM,MAAM,GAAG;AAC3E,WAAO;AAAA,EACT;AACA,SAAO,cAAc;AACvB;;;AC7CO,SAAS,gBAAgB,MAAyB,QAAQ,KAAc;AAC7E,SAAO,IAAI,eAAe;AAC5B;;;ACFA,eAAe,OAAO;AACpB,MAAI,gBAAgB,EAAG;AACvB,MAAI,QAA2B,CAAC;AAEhC,MAAI;AACF,UAAM,UAAU,IAAI,YAAY;AAChC,QAAI,MAAM;AACV,UAAM,iBAAiB,IAAI,QAAc,CAAC,YAAY;AAAE,iBAAW,SAAS,GAAG;AAAA,IAAG,CAAC;AACnF,UAAM,eAAe,YAAY;AAC/B,uBAAiB,SAAS,QAAQ,OAAO;AACvC,eAAO,QAAQ,OAAO,OAAO,EAAE,QAAQ,KAAK,CAAC;AAAA,MAC/C;AAAA,IACF,GAAG;AACH,UAAM,QAAQ,KAAK,CAAC,aAAa,cAAc,CAAC;AAChD,QAAI,IAAI,KAAK,GAAG;AACd,cAAQ,KAAK,MAAM,GAAG;AAAA,IACxB;AAAA,EACF,QAAQ;AAEN,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,QAAM,WAAW,0BAA0B,KAAK;AAChD,MAAI,UAAU;AACZ,YAAQ,IAAI,QAAQ;AACpB,YAAQ,MAAM,uDAAuD,MAAM,MAAM,EAAE;AAAA,EACrF;AAEA,UAAQ,KAAK,CAAC;AAChB;AAEA,KAAK,EAAE,MAAM,MAAM;AACjB,UAAQ,KAAK,CAAC;AAChB,CAAC;",
  "names": []
}
