@tekmidian/pai 0.42.1 → 0.42.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +1 -1
  2. package/dist/chain-B-DbR82m.mjs +164 -0
  3. package/dist/chain-B-DbR82m.mjs.map +1 -0
  4. package/dist/cli/index.mjs +8 -8
  5. package/dist/cli/program.mjs +8 -8
  6. package/dist/{config-Bi_F6lXf.mjs → config-_lQI_W08.mjs} +87 -3
  7. package/dist/config-_lQI_W08.mjs.map +1 -0
  8. package/dist/{context-handover-cache--bm6Siim.mjs → context-handover-cache-pNFdBNrU.mjs} +3 -3
  9. package/dist/{context-handover-cache--bm6Siim.mjs.map → context-handover-cache-pNFdBNrU.mjs.map} +1 -1
  10. package/dist/daemon/index.mjs +7 -7
  11. package/dist/{daemon-BOAPyL_b.mjs → daemon-Awt_3UJp.mjs} +7 -7
  12. package/dist/{daemon-DvXih78C.mjs → daemon-Dq8A1uDF.mjs} +587 -368
  13. package/dist/daemon-Dq8A1uDF.mjs.map +1 -0
  14. package/dist/daemon-mcp/index.mjs +5 -5
  15. package/dist/{fallback-8bzmg6No.mjs → fallback-vQPI1qxt.mjs} +14 -12
  16. package/dist/fallback-vQPI1qxt.mjs.map +1 -0
  17. package/dist/hooks/mcp-deferred-gate.mjs +286 -0
  18. package/dist/hooks/mcp-deferred-gate.mjs.map +7 -0
  19. package/dist/hooks/mcp-deferred-reminder.mjs +54 -0
  20. package/dist/hooks/mcp-deferred-reminder.mjs.map +7 -0
  21. package/dist/hooks/route-agents-to-worker.mjs +65 -2
  22. package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
  23. package/dist/hooks/worker-proxy.mjs +64 -1
  24. package/dist/hooks/worker-proxy.mjs.map +2 -2
  25. package/dist/hooks/worker-status-line.mjs +64 -1
  26. package/dist/hooks/worker-status-line.mjs.map +2 -2
  27. package/dist/hooks/worker-supervision.mjs +64 -1
  28. package/dist/hooks/worker-supervision.mjs.map +2 -2
  29. package/dist/{main-resolver-CqppP_Bw.mjs → main-resolver-BAfOMppW.mjs} +2 -2
  30. package/dist/{main-resolver-Dip2VLB5.mjs → main-resolver-DnkxPYkr.mjs} +111 -16
  31. package/dist/main-resolver-DnkxPYkr.mjs.map +1 -0
  32. package/dist/{planner-ogy-Zy4O.mjs → planner-BfEGcEs1.mjs} +5 -5
  33. package/dist/{planner-ogy-Zy4O.mjs.map → planner-BfEGcEs1.mjs.map} +1 -1
  34. package/dist/{program-B7N5OIJR.mjs → program-BDmeZP-v.mjs} +17 -11
  35. package/dist/{program-B7N5OIJR.mjs.map → program-BDmeZP-v.mjs.map} +1 -1
  36. package/dist/{chain-DsvSHnO9.mjs → run-CGXbhf2k.mjs} +359 -163
  37. package/dist/run-CGXbhf2k.mjs.map +1 -0
  38. package/dist/{server-DvN-IFIV.mjs → server-D4vAieF4.mjs} +25 -6
  39. package/dist/server-D4vAieF4.mjs.map +1 -0
  40. package/dist/{state-BTa10SOM.mjs → state-BRdB-Bf1.mjs} +7 -2
  41. package/dist/state-BRdB-Bf1.mjs.map +1 -0
  42. package/dist/state-CY6I-Hnc.mjs +3 -0
  43. package/dist/{work-queue-worker-19r0cpH4.mjs → work-queue-worker-AEmfVE0F.mjs} +5 -5
  44. package/dist/{work-queue-worker-DXh_FnT5.mjs → work-queue-worker-DIT-VLSh.mjs} +3 -3
  45. package/dist/{work-queue-worker-DXh_FnT5.mjs.map → work-queue-worker-DIT-VLSh.mjs.map} +1 -1
  46. package/docs/cache-keepalive.md +82 -0
  47. package/docs/commands/worker.md +1 -0
  48. package/docs/provider-abstraction.md +52 -2
  49. package/package.json +1 -1
  50. package/plugins/core/hooks/hooks.json +12 -0
  51. package/src/hooks/ts/lib/mcp-deferred-gate.test.ts +258 -0
  52. package/src/hooks/ts/lib/mcp-deferred-gate.ts +261 -0
  53. package/src/hooks/ts/lib/mcp-deferred-reminder.test.ts +90 -0
  54. package/src/hooks/ts/lib/mcp-deferred-reminder.ts +66 -0
  55. package/src/hooks/ts/pre-tool-use/mcp-deferred-gate.ts +126 -0
  56. package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +6 -2
  57. package/src/hooks/ts/session-start/mcp-deferred-reminder.ts +52 -0
  58. package/statusline-command.sh +149 -72
  59. package/dist/chain-DsvSHnO9.mjs.map +0 -1
  60. package/dist/config-Bi_F6lXf.mjs.map +0 -1
  61. package/dist/daemon-DvXih78C.mjs.map +0 -1
  62. package/dist/fallback-8bzmg6No.mjs.map +0 -1
  63. package/dist/main-resolver-Dip2VLB5.mjs.map +0 -1
  64. package/dist/scope-CfYnjy5R.mjs +0 -360
  65. package/dist/scope-CfYnjy5R.mjs.map +0 -1
  66. package/dist/server-DvN-IFIV.mjs.map +0 -1
  67. package/dist/state-BTa10SOM.mjs.map +0 -1
  68. package/dist/state-RXepvbnO.mjs +0 -3
@@ -0,0 +1,82 @@
1
+ # Worker prompt-cache keepalive
2
+
3
+ Why `workers.cacheKeepaliveSecs` exists, what the provider cache actually
4
+ does, and the measured numbers behind the default.
5
+
6
+ ## Mechanism
7
+
8
+ Workers on an anthropic-compat GLM endpoint (`protocol: "anthropic"`) carry no
9
+ `cache_control` blocks, yet the endpoint caches implicitly: the final `result`
10
+ usage of a real worker run showed `cache_read_input_tokens: 1,129,984` with
11
+ `cache_creation_input_tokens: 0` (worker `20260918-120947-11956`, 2026-09-18).
12
+
13
+ The keepalive arms that implicit cache for future spawns with a trivial
14
+ single-turn worker (`src/workers/keepalive.ts`) driven by the daemon on
15
+ `workers.cacheKeepaliveSecs` (`src/daemon/daemon/scheduler.ts`,
16
+ `startCacheKeepalive`). The beat goes through `runWorker` itself — the
17
+ maximal sharing of prefix construction (env, tool grants, contract prompt) —
18
+ so a beat and a real worker differ in nothing but the user message. Beats run
19
+ in the workers logDir, adopt no session identity, notify nobody, and write one
20
+ `WORKER-KEEPALIVE` ledger line each; on a Postgres backend they also store one
21
+ observation row via `storeObservationWithProject`.
22
+
23
+ Measurement constraints (measured, not assumed): per-turn `assistant` events
24
+ on this endpoint always carry `usage: {input_tokens: 0, output_tokens: 0}`
25
+ with no cache fields; the only real usage is the final `result` event. A
26
+ single-turn worker therefore reads its first-turn usage straight off its
27
+ result line, and `duration_api_ms` is the TTFT proxy.
28
+
29
+ ## Measured numbers (2026-09-18, probe `scripts/probe-cache-keepalive.ts`)
30
+
31
+ Every spawn is a fresh claude process and session; prompt identical; class
32
+ `simple` on glm-5.3-flash. "gap" = time between the two spawns of a pair.
33
+
34
+ | pair | gap | spawn | input_tokens | cache_read | cache_creation | duration_api_ms |
35
+ |------|-----|-------|--------------|-----------|----------------|-----------------|
36
+ | back-to-back | ~16 s | 1 | 17,906 | 0 | 0 | 12,911 |
37
+ | back-to-back | ~16 s | 2 | 15,282 | **2,624** | 0 | **7,316** |
38
+ | gap 120 s | ~2.2 min | 1 | 17,957 | 0 | 0 | 8,401 |
39
+ | gap 120 s | ~2.2 min | 2 | 17,909 | 0 | 0 | 9,812 |
40
+ | gap 240 s | ~4.4 min | 1 | 17,909 | 0 | 0 | 10,827 |
41
+ | gap 240 s | ~4.4 min | 2 | 17,881 | 0 | 0 | 10,037 |
42
+ | gap 360 s | 6 min | 1 | 17,949 | 0 | 0 | 10,132 |
43
+ | gap 360 s | 6 min | 2 | 17,951 | 0 | 0 | 9,714 |
44
+
45
+ ## Armed-cadence proof (60 s beats through the real beat code, then a pair)
46
+
47
+ | run | after | input_tokens | cache_read | duration_api_ms |
48
+ |-----|-------|--------------|-----------|-----------------|
49
+ | beat 125003 | — | 14,445 | 0 | 5,307 |
50
+ | beat 125111 | ~68 s | 14,010 | 0 | 5,480 |
51
+ | beat 125220 | ~69 s | 14,010 | 0 | 8,577 |
52
+ | probe-1 125232 | ~12 s | 17,989 | 0 | 6,687 |
53
+ | probe-2 125242 | ~10 s | 17,736 | **256** | 10,034 |
54
+
55
+ Ledger lines: `WORKER-KEEPALIVE id=… provider=glm model=glm-5.3-flash
56
+ input_tokens=… cache_read_input_tokens=… duration_api_ms=…` land as designed.
57
+
58
+ ## Decision
59
+
60
+ 1. **Cross-session implicit caching exists, but only at seconds range.**
61
+ Spawn 2 of the back-to-back pair is a different process and session than
62
+ spawn 1 and still got `cache_read > 0` — but the only positive readings
63
+ across nine pairs came 10–16 s after the previous identical request
64
+ (2,624 tokens at ~16 s; 256 at ~10 s), and the hit size is unstable. At
65
+ every gap ≥ 60 s — including a 60 s heartbeat cadence proven live through
66
+ the shipped beat code — every spawn was cold.
67
+ 2. **A keepalive therefore cannot hold this cache at any cadence the
68
+ constraints allow** ("cadence is minutes, not seconds"): holding it would
69
+ need a beat every few seconds, billing ~14–18k fresh input tokens per
70
+ beat around the clock, to serve at best a few hundred to ~2.6k cached
71
+ tokens to the next spawn. The drafted "default = TTL/2" rule assumed a
72
+ TTL in minutes; the measured TTL/2 ≈ 5–30 s, which no sane daemon
73
+ interval meets, and a 60 s default was disproven by the armed proof.
74
+ 3. **Therefore `DEFAULT_CACHE_KEEPALIVE_SECS = 0`** (a deliberate deviation
75
+ from the drafted spec, per its own STOP-on-negative-evidence rule): the
76
+ instrument ships — knob, beat, daemon wiring, ledger, observations, tests
77
+ — and stays off unless the operator explicitly arms it
78
+ (`"cacheKeepaliveSecs": <secs>` in the workers section). The honest
79
+ reading of the measurement: this endpoint's implicit cache is a
80
+ short-range replay buffer, not a warmable baseline; new-worker cold starts
81
+ cannot be removed by a heartbeat, and the fix would have to live in the
82
+ provider (explicit `cache_control` support) rather than in the daemon.
@@ -68,6 +68,7 @@ Grant MCP tools by naming mcp__server__tool in --allowedTools (the server loads
68
68
  | `--agent <name>` | Run the agent definition ~/.claude/agents/<name>.md on a worker | |
69
69
  | `--model <model>` | Override the provider's model for this run | |
70
70
  | `--label <text>` | Short task label shown in ps / follow / status line | |
71
+ | `--cwd <dir>` | Directory the worker runs in (default: this process's cwd) | |
71
72
  | `--mcp <names>` | MCP servers/sets this worker may use (comma-separated; see `pai worker mcp`) | |
72
73
  | `--no-pane` | Do not open a follow pane for this worker | |
73
74
  | `--worktree` | Run in a git worktree on branch worker/<id> (default for implement/complex/plan in a git repo) | |
@@ -7,6 +7,7 @@ The entire assistant stack — interactive sessions, workers, follow panes, hook
7
7
  What that buys:
8
8
 
9
9
  - **No single-vendor lock-in.** Providers are entries in a registry (`src/workers/config.ts`): protocol (`anthropic`/`openai`), engine (`claude`/`codex`), model ids, key files, cost tiers, capability tags, quota probes. Switching is `pai worker providers use <name>`; disabling routing entirely is `pai worker off`.
10
+ - **Plain Anthropic stays a first-class choice.** The reserved provider name `anthropic` needs no registry entry — it is Claude Code's own OAuth/Max-plan login, with no base URL override and no API key. `pai worker providers use anthropic` makes it the default; `--provider anthropic` or a class pointed at `anthropic` uses it for one run. `pai worker providers` lists it as `anthropic [built-in]`.
10
11
  - **Plan/quota-aware instrumentation.** The statusline reads the *active provider's* real plan utilization (5-hour rolling window and weekly quota with reset times), not a hardcoded vendor endpoint.
11
12
  - **Cost routing per task class.** Work is dispatched by class (`draft`, `spotcheck`, `complex`, …); each class maps to a provider and model, so cheap work lands on cheap models without touching code.
12
13
  - **Provider swaps are configuration, not surgery.** Every seam that used to assume one vendor — daemon LLM spawns, compaction-history family detection, context-window defaults, tier aliases — has been patched at protocol level (see below), so a new provider is a registry entry plus a key file.
@@ -40,11 +41,60 @@ The `fast` slot is what cheap classes resolve to; the runner also honors the pla
40
41
 
41
42
  `statusline-command.sh` derives everything it can from the session's model id instead of assuming one vendor:
42
43
 
43
- - **Plan utilization** — glm sessions fetch the provider's plan quota (5-hour window and weekly credit window, `nextResetTime` rendered as reset times); claude models keep the existing Anthropic OAuth path. Each source has its own 60-second cache. On fetch failure the provider windows show `?` rather than silently falling back to another plan's numbers. `PAI_ZAI_QUOTA_URL` overrides the endpoint (kill switch / testing).
44
+ - **Session provider from the registry** — the session's model id (any `[…]` variant suffix stripped) is matched against every `workers.providers.<name>.models` value in the PAI config, so a session belongs to the provider that lists its model, not to a name prefix. No config, no `jq`, unreadable JSON or no match ⇒ the built-in `anthropic` path, silently.
45
+ - **Plan utilization** — a provider with a `usage` block has its quota fetched and rendered from that block (see below); the built-in `anthropic` provider keeps the OAuth path (5-hour, 1d pace, 7-day). Each provider caches to `/tmp/claude/statusline-usage-<provider>.json` for `ttlSeconds` (default 60) and refreshes in the background, so the line never waits on the network. On fetch failure the windows show `?`, and a provider with no `usage` block shows `<provider> usage n/a` — never another plan's numbers.
44
46
  - **Context window from the model id** — a `[1m]` variant suffix means a 1,000,000-token window (`statusline-command.sh:126`); ids with no marker keep the previous default. The same derivation lives in TypeScript as `contextWindowFromModelId()` (`src/utils/model-window.ts`), so the session hooks and the worker meter agree with the statusline.
45
47
  - **`(N% left)`** — remaining context until auto-compact, computed against that derived window.
46
48
  - **Worker row** — line 4 lists the workers this session launched, running ones with their current step (`pai worker status-line`, `statusline-command.sh:634`).
47
49
 
50
+ #### Provider usage blocks
51
+
52
+ A provider's quota endpoint is configuration. `workers.providers.<name>.usage` (schema: `ProviderUsage` / `UsageWindow`, `src/workers/config.ts`) names a JSON `GET` endpoint plus one jq expression per window, and the statusline renders `<label> <window>: <pct>% → <reset>` for each:
53
+
54
+ | field | meaning |
55
+ | --- | --- |
56
+ | `url` | endpoint returning the usage JSON |
57
+ | `authHeader` | header template, default `Authorization: Bearer`; the key from the provider's `keyFile` is appended after a space. A value with no space (e.g. `x-api-key`) is sent as `<value>: <key>` |
58
+ | `label` | display label for the segments, default the provider name |
59
+ | `ttlSeconds` | cache TTL, default 60 |
60
+ | `windows[].name` | window label, e.g. `5h` |
61
+ | `windows[].percent` | jq expression yielding 0–100 — an arithmetic expression is fine, so a percentage can be computed from a used/limit pair |
62
+ | `windows[].resetAt` | jq expression yielding the reset time |
63
+ | `windows[].resetUnit` | `ms` (default), `s` or `iso` |
64
+
65
+ Reset times render as `HH:MM` within the next 24 hours and `<weekday> HH:MM` beyond it. The first window's `percent` doubles as the response probe: a reply that yields no number for it is not cached, so a rejected key answering `HTTP 200` with an empty body never masquerades as a valid zero reading.
66
+
67
+ Two working blocks. A Z.ai-style credit plan, whose two windows live in one array distinguished by `number`, with epoch-millisecond resets:
68
+
69
+ ```json
70
+ "usage": {
71
+ "url": "https://api.z.ai/api/monitor/usage/quota/limit",
72
+ "authHeader": "Authorization: Bearer",
73
+ "label": "zai",
74
+ "ttlSeconds": 60,
75
+ "windows": [
76
+ { "name": "5h", "percent": ".data.limits[]? | select(.number == 5) | .percentage", "resetAt": ".data.limits[]? | select(.number == 5) | .nextResetTime", "resetUnit": "ms" },
77
+ { "name": "7d", "percent": ".data.limits[]? | select(.number == 1) | .percentage", "resetAt": ".data.limits[]? | select(.number == 1) | .nextResetTime", "resetUnit": "ms" }
78
+ ]
79
+ }
80
+ ```
81
+
82
+ And a coding plan that reports *fractions* (`0.0262` = 2.62%, hence the `* 100`), ISO-8601 resets, and a request-count window that has no percentage at all — computed here from its used/limit pair:
83
+
84
+ ```json
85
+ "usage": {
86
+ "url": "https://api.kimi.ai/coding/v1/usages",
87
+ "authHeader": "Authorization: Bearer",
88
+ "label": "kimi",
89
+ "ttlSeconds": 60,
90
+ "windows": [
91
+ { "name": "5h", "percent": ".usages.limit_5h.used_ratio * 100", "resetAt": ".usages.limit_5h.reset_time", "resetUnit": "iso" },
92
+ { "name": "30d", "percent": ".usages.limit_month_total.used_ratio * 100", "resetAt": ".usages.limit_month_total.reset_time", "resetUnit": "iso" },
93
+ { "name": "req", "percent": "(.limits[0].detail.used | tonumber) / (.limits[0].detail.limit | tonumber) * 100", "resetAt": ".limits[0].detail.resetTime", "resetUnit": "iso" }
94
+ ]
95
+ }
96
+ ```
97
+
48
98
  ### Seam patches
49
99
 
50
100
  The seams that previously hardcoded one vendor, now patched:
@@ -82,6 +132,6 @@ Two further instruments are deployed configuration in the PAI config dir rather
82
132
 
83
133
  ## Current limits
84
134
 
85
- The layer is proven against one non-Anthropic provider so far (the `glm` endpoint); other Anthropic-protocol providers are expected to work but have not been exercised end-to-end, and OpenAI-protocol providers additionally depend on the translating proxy. Instrumentation still special-cases per-provider quota endpoints as providers are added. The seam audit that drove these patches lives outside this repository; new seams are patched as they surface, and each patch carries a unit test pinning the provider-neutral behaviour (`src/workers/daemon-llm.test.ts`, `src/workers/model.test.ts`, `src/workers/agents.test.ts`, `src/hooks/ts/lib/context-fill.test.ts`).
135
+ The layer is proven against one non-Anthropic provider so far (the `glm` endpoint); other Anthropic-protocol providers are expected to work but have not been exercised end-to-end, and OpenAI-protocol providers additionally depend on the translating proxy. Quota endpoints are no longer special-cased in code — a provider's windows are its `usage` block — but a provider whose response needs more than a jq expression per window (paging, a second request, an auth flow other than a single header) would still need code. The seam audit that drove these patches lives outside this repository; new seams are patched as they surface, and each patch carries a unit test pinning the provider-neutral behaviour (`src/workers/daemon-llm.test.ts`, `src/workers/model.test.ts`, `src/workers/agents.test.ts`, `src/hooks/ts/lib/context-fill.test.ts`).
86
136
 
87
137
  For the full worker command reference see [worker.md](worker.md) and [commands/worker.md](commands/worker.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekmidian/pai",
3
- "version": "0.42.1",
3
+ "version": "0.42.2",
4
4
  "description": "PAI Knowledge OS — Personal AI Infrastructure with federated memory and project management",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -17,12 +17,24 @@
17
17
  "command": "${PAI_DIR}/Hooks/initialize-session.mjs",
18
18
  "description": "Create numbered session note and register in PAI registry"
19
19
  },
20
+ {
21
+ "event": "SessionStart",
22
+ "matcher": "resume|compact",
23
+ "command": "${PAI_DIR}/Hooks/mcp-deferred-reminder.mjs",
24
+ "description": "Heal stale deferred MCP tool handles on resumed sessions: ToolSearch-and-retry instead of trusting a stale disconnect conclusion"
25
+ },
20
26
  {
21
27
  "event": "PreToolUse",
22
28
  "matcher": "Bash",
23
29
  "command": "${PAI_DIR}/Hooks/security-validator.mjs",
24
30
  "description": "Validate shell commands against security rules before execution"
25
31
  },
32
+ {
33
+ "event": "PreToolUse",
34
+ "matcher": "mcp__",
35
+ "command": "${PAI_DIR}/Hooks/mcp-deferred-gate.mjs",
36
+ "description": "Correct deferred MCP tool calls: ToolSearch remedy instead of a false 'MCP server disconnected' report"
37
+ },
26
38
  {
27
39
  "event": "Stop",
28
40
  "command": "${PAI_DIR}/Hooks/stop-hook.mjs",
@@ -0,0 +1,258 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ decideMcpGate,
4
+ isMcpTool,
5
+ lastToolSearchSurfacingIndex,
6
+ scanTranscriptEvidence,
7
+ type GateHookInput,
8
+ } from "./mcp-deferred-gate.js";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Fixtures — the transcript shapes that matter for the gate
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const TOOL = "mcp__aibroker__aibroker_rename";
15
+
16
+ function hookInput(toolName = TOOL): GateHookInput {
17
+ return {
18
+ session_id: "test-session",
19
+ transcript_path: "/tmp/nonexistent-transcript.jsonl",
20
+ cwd: "/tmp/project",
21
+ tool_name: toolName,
22
+ tool_input: { from: "a", to: "b" },
23
+ };
24
+ }
25
+
26
+ /** A tool_use entry for `name` with the given input. */
27
+ function toolUseLine(id: string, name: string, input: unknown): string {
28
+ return JSON.stringify({
29
+ message: { role: "assistant", content: [{ type: "tool_use", id, name, input }] },
30
+ });
31
+ }
32
+
33
+ /** A tool_result for `id`; `errorText` marks it failed with that message. */
34
+ function toolResultLine(id: string, errorText?: string): string {
35
+ const block: Record<string, unknown> = {
36
+ type: "tool_result",
37
+ tool_use_id: id,
38
+ content: [{ type: "text", text: errorText ?? "ok" }],
39
+ };
40
+ if (errorText) block.is_error = true;
41
+ return JSON.stringify({ message: { role: "user", content: [block] } });
42
+ }
43
+
44
+ /** What the schema gate's rejection looks like in the transcript. */
45
+ const GATE_ERROR =
46
+ "InputValidationError: tool schema not loaded. The following deferred tools are " +
47
+ "available via ToolSearch. Their schemas are NOT loaded — calling them directly will " +
48
+ 'fail. Use ToolSearch with query "select:mcp__aibroker__aibroker_rename" to load tool ' +
49
+ "schemas before calling them.";
50
+
51
+ /** What a mid-session re-registration's stale-handle rejection looks like (2026-09-18 incident). */
52
+ const STALE_ERROR = "Error: 411 deferred tools are no longer available: aibroker_rename";
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // isMcpTool
56
+ // ---------------------------------------------------------------------------
57
+
58
+ describe("isMcpTool", () => {
59
+ it("accepts mcp__server__tool names", () => {
60
+ expect(isMcpTool(TOOL)).toBe(true);
61
+ });
62
+
63
+ it("rejects core tools and bare mcp__ prefixes", () => {
64
+ expect(isMcpTool("Read")).toBe(false);
65
+ expect(isMcpTool("Bash")).toBe(false);
66
+ expect(isMcpTool("mcp__")).toBe(false);
67
+ expect(isMcpTool("mcp__server")).toBe(false);
68
+ });
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // scanTranscriptEvidence
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe("scanTranscriptEvidence", () => {
76
+ it("sees a ToolSearch that surfaced the tool", () => {
77
+ const transcript = [
78
+ toolUseLine("t1", "ToolSearch", { query: `select:${TOOL}` }),
79
+ toolResultLine("t1"),
80
+ ].join("\n");
81
+ expect(scanTranscriptEvidence(transcript, TOOL).surfacedByToolSearch).toBe(true);
82
+ });
83
+
84
+ it("sees a prior successful call but not a gate-failed one", () => {
85
+ const failed = [
86
+ toolUseLine("t1", TOOL, { from: "a", to: "b" }),
87
+ toolResultLine("t1", GATE_ERROR),
88
+ ].join("\n");
89
+ expect(scanTranscriptEvidence(failed, TOOL).succeededBefore).toBe(false);
90
+
91
+ const succeeded = [
92
+ toolUseLine("t1", TOOL, { from: "a", to: "b" }),
93
+ toolResultLine("t1"),
94
+ ].join("\n");
95
+ expect(scanTranscriptEvidence(succeeded, TOOL).succeededBefore).toBe(true);
96
+ });
97
+
98
+ it("ignores junk lines and empty input", () => {
99
+ expect(scanTranscriptEvidence("not json\n\n{broken", TOOL)).toEqual({
100
+ surfacedByToolSearch: false,
101
+ succeededBefore: false,
102
+ });
103
+ });
104
+ });
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // decideMcpGate — (a) deferred/failure context gets the correction
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe("decideMcpGate: deferred and failed-call contexts", () => {
111
+ it("corrects a deferred tool with no transcript evidence (first attempt)", () => {
112
+ const decision = decideMcpGate(hookInput(), "");
113
+ expect(decision.action).toBe("correct");
114
+
115
+ const parsed = JSON.parse(decision.output);
116
+ expect(parsed.hookSpecificOutput.permissionDecision).toBe("deny");
117
+ const reason = parsed.hookSpecificOutput.permissionDecisionReason as string;
118
+
119
+ // The remedy: ToolSearch with the exact select: form of the full tool name.
120
+ expect(reason).toContain("ToolSearch");
121
+ expect(reason).toContain(`select:${TOOL}`);
122
+ // The misreport this hook exists to prevent, denied in plain words.
123
+ expect(reason.toLowerCase()).toContain("not disconnected");
124
+ expect(reason).toContain("do not report an outage");
125
+ });
126
+
127
+ it("corrects the retry after a gate failure (observed incident shape)", () => {
128
+ const transcript = [
129
+ toolUseLine("t1", TOOL, { from: "a", to: "b" }),
130
+ toolResultLine("t1", GATE_ERROR),
131
+ ].join("\n");
132
+ const decision = decideMcpGate(hookInput(), transcript);
133
+
134
+ expect(decision.action).toBe("correct");
135
+ const reason = JSON.parse(decision.output).hookSpecificOutput.permissionDecisionReason;
136
+ expect(reason).toContain(`select:${TOOL}`);
137
+ expect(reason.toLowerCase()).toContain("not disconnected");
138
+ });
139
+
140
+ it("emits a countable observation for every correction", () => {
141
+ const decision = decideMcpGate(hookInput(), "");
142
+ expect(decision.observation).not.toBeNull();
143
+ expect(decision.observation?.type).toBe("decision");
144
+ expect(decision.observation?.tool_name).toBe(TOOL);
145
+ expect(decision.observation?.title).toContain("MCP deferred-tool gate");
146
+ });
147
+ });
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // decideMcpGate — (a2) stale deferred-tool handles after a re-registration
151
+ // ---------------------------------------------------------------------------
152
+
153
+ describe("decideMcpGate: stale deferred-tool handles", () => {
154
+ it("corrects the retry after a re-registration invalidated the handle (2026-09-18 incident)", () => {
155
+ // The tool was surfaced by ToolSearch earlier — evidence would normally
156
+ // pass it through — but the handle is dead now, so the gate must deny.
157
+ const transcript = [
158
+ toolUseLine("t0", "ToolSearch", { query: `select:${TOOL}` }),
159
+ toolResultLine("t0"),
160
+ toolUseLine("t1", TOOL, { from: "a", to: "b" }),
161
+ toolResultLine("t1", STALE_ERROR),
162
+ ].join("\n");
163
+ const decision = decideMcpGate(hookInput(), transcript);
164
+
165
+ expect(decision.action).toBe("correct");
166
+ const reason = JSON.parse(decision.output).hookSpecificOutput.permissionDecisionReason;
167
+ expect(reason).toContain("ToolSearch");
168
+ expect(reason).toContain(`select:${TOOL}`);
169
+ expect(reason.toLowerCase()).toContain("not disconnected");
170
+ expect(reason).toContain("do not report an outage");
171
+ });
172
+
173
+ it("re-arms even when the tool ran successfully before the re-registration", () => {
174
+ const transcript = [
175
+ toolUseLine("t0", TOOL, { from: "a", to: "b" }),
176
+ toolResultLine("t0"),
177
+ toolUseLine("t1", TOOL, { from: "a", to: "b" }),
178
+ toolResultLine("t1", STALE_ERROR),
179
+ ].join("\n");
180
+ expect(decideMcpGate(hookInput(), transcript).action).toBe("correct");
181
+ });
182
+
183
+ it("passes once a ToolSearch after the invalidation reloaded the entry", () => {
184
+ const transcript = [
185
+ toolUseLine("t1", TOOL, { from: "a", to: "b" }),
186
+ toolResultLine("t1", STALE_ERROR),
187
+ toolUseLine("t2", "ToolSearch", { query: `select:${TOOL}` }),
188
+ toolResultLine("t2"),
189
+ ].join("\n");
190
+ const decision = decideMcpGate(hookInput(), transcript);
191
+ expect(decision.action).toBe("pass");
192
+ expect(JSON.parse(decision.output).hookSpecificOutput.permissionDecision).toBe("allow");
193
+ });
194
+
195
+ it("corrects when only the tool input carries the stale-handle text", () => {
196
+ const input = hookInput();
197
+ input.tool_input = { text: `411 ${"deferred tools are no longer available"}` };
198
+ expect(decideMcpGate(input, "").action).toBe("correct");
199
+ });
200
+ });
201
+
202
+ describe("lastToolSearchSurfacingIndex", () => {
203
+ it("finds the last surfacing ToolSearch and returns -1 when absent", () => {
204
+ const transcript = [
205
+ toolUseLine("t1", TOOL, { from: "a", to: "b" }),
206
+ toolResultLine("t1", STALE_ERROR),
207
+ toolUseLine("t2", "ToolSearch", { query: `select:${TOOL}` }),
208
+ toolResultLine("t2"),
209
+ ].join("\n");
210
+ // After the stale error, i.e. the remedy postdates the invalidation.
211
+ expect(lastToolSearchSurfacingIndex(transcript, TOOL)).toBeGreaterThan(
212
+ transcript.lastIndexOf("deferred tools are no longer available"),
213
+ );
214
+ expect(lastToolSearchSurfacingIndex("not json", TOOL)).toBe(-1);
215
+ });
216
+ });
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // decideMcpGate — (b) normal contexts pass through untouched
220
+ // ---------------------------------------------------------------------------
221
+
222
+ describe("decideMcpGate: pass-through", () => {
223
+ it("passes through after ToolSearch surfaced the tool", () => {
224
+ const transcript = [
225
+ toolUseLine("t1", "ToolSearch", { query: `select:${TOOL}` }),
226
+ toolResultLine("t1"),
227
+ ].join("\n");
228
+ const decision = decideMcpGate(hookInput(), transcript);
229
+
230
+ expect(decision.action).toBe("pass");
231
+ expect(decision.observation).toBeNull();
232
+ const parsed = JSON.parse(decision.output);
233
+ expect(parsed.hookSpecificOutput.permissionDecision).toBe("allow");
234
+ expect(decision.output).not.toContain("ToolSearch");
235
+ });
236
+
237
+ it("passes through a tool that already ran successfully", () => {
238
+ const transcript = [
239
+ toolUseLine("t1", TOOL, { from: "a", to: "b" }),
240
+ toolResultLine("t1"),
241
+ ].join("\n");
242
+ const decision = decideMcpGate(hookInput(), transcript);
243
+
244
+ expect(decision.action).toBe("pass");
245
+ expect(JSON.parse(decision.output).hookSpecificOutput.permissionDecision).toBe("allow");
246
+ });
247
+
248
+ it("passes non-mcp tools through regardless of transcript", () => {
249
+ const decision = decideMcpGate(hookInput("Read"), "");
250
+ expect(decision.action).toBe("pass");
251
+ expect(decision.observation).toBeNull();
252
+ });
253
+
254
+ it("passes on missing tool_name or junk input", () => {
255
+ expect(decideMcpGate({}, "").action).toBe("pass");
256
+ expect(decideMcpGate({ tool_name: "" }, "").action).toBe("pass");
257
+ });
258
+ });