@tekmidian/pai 0.42.0 → 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.
- package/README.md +1 -1
- package/dist/chain-B-DbR82m.mjs +164 -0
- package/dist/chain-B-DbR82m.mjs.map +1 -0
- package/dist/cli/index.mjs +8 -8
- package/dist/cli/program.mjs +8 -8
- package/dist/{config-DgzR5sfb.mjs → config-_lQI_W08.mjs} +116 -3
- package/dist/config-_lQI_W08.mjs.map +1 -0
- package/dist/{context-handover-cache-dUnSZwaA.mjs → context-handover-cache-pNFdBNrU.mjs} +27 -8
- package/dist/context-handover-cache-pNFdBNrU.mjs.map +1 -0
- package/dist/daemon/index.mjs +7 -7
- package/dist/{daemon-CKBkhOsZ.mjs → daemon-Awt_3UJp.mjs} +7 -7
- package/dist/{daemon-DsvP8KMy.mjs → daemon-Dq8A1uDF.mjs} +591 -370
- package/dist/daemon-Dq8A1uDF.mjs.map +1 -0
- package/dist/daemon-mcp/index.mjs +5 -5
- package/dist/{fallback-5UJO-RyX.mjs → fallback-vQPI1qxt.mjs} +15 -13
- package/dist/fallback-vQPI1qxt.mjs.map +1 -0
- package/dist/hooks/mcp-deferred-gate.mjs +286 -0
- package/dist/hooks/mcp-deferred-gate.mjs.map +7 -0
- package/dist/hooks/mcp-deferred-reminder.mjs +54 -0
- package/dist/hooks/mcp-deferred-reminder.mjs.map +7 -0
- package/dist/hooks/route-agents-to-worker.mjs +65 -2
- package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
- package/dist/hooks/worker-proxy.mjs +64 -1
- package/dist/hooks/worker-proxy.mjs.map +2 -2
- package/dist/hooks/worker-status-line.mjs +68 -2
- package/dist/hooks/worker-status-line.mjs.map +2 -2
- package/dist/hooks/worker-supervision.mjs +64 -1
- package/dist/hooks/worker-supervision.mjs.map +2 -2
- package/dist/{main-resolver-DXGuygFG.mjs → main-resolver-BAfOMppW.mjs} +2 -2
- package/dist/{main-resolver-BgIrC-D9.mjs → main-resolver-DnkxPYkr.mjs} +111 -16
- package/dist/main-resolver-DnkxPYkr.mjs.map +1 -0
- package/dist/{planner-BLDJt3ZS.mjs → planner-BfEGcEs1.mjs} +5 -5
- package/dist/{planner-BLDJt3ZS.mjs.map → planner-BfEGcEs1.mjs.map} +1 -1
- package/dist/{program-CruRnLtc.mjs → program-BDmeZP-v.mjs} +17 -11
- package/dist/{program-CruRnLtc.mjs.map → program-BDmeZP-v.mjs.map} +1 -1
- package/dist/{chain-DGXNoTwc.mjs → run-CGXbhf2k.mjs} +422 -195
- package/dist/run-CGXbhf2k.mjs.map +1 -0
- package/dist/{server-Cp7tNzNC.mjs → server-D4vAieF4.mjs} +49 -8
- package/dist/server-D4vAieF4.mjs.map +1 -0
- package/dist/{state-BTa10SOM.mjs → state-BRdB-Bf1.mjs} +7 -2
- package/dist/state-BRdB-Bf1.mjs.map +1 -0
- package/dist/state-CY6I-Hnc.mjs +3 -0
- package/dist/{work-queue-worker-D1p2V-ae.mjs → work-queue-worker-AEmfVE0F.mjs} +5 -5
- package/dist/{work-queue-worker-7Vkli4Yh.mjs → work-queue-worker-DIT-VLSh.mjs} +3 -3
- package/dist/{work-queue-worker-7Vkli4Yh.mjs.map → work-queue-worker-DIT-VLSh.mjs.map} +1 -1
- package/docs/cache-keepalive.md +82 -0
- package/docs/commands/worker.md +1 -0
- package/docs/merge-demo.md +1 -0
- package/docs/provider-abstraction.md +52 -2
- package/package.json +1 -1
- package/plugins/core/hooks/hooks.json +12 -0
- package/src/hooks/ts/lib/mcp-deferred-gate.test.ts +258 -0
- package/src/hooks/ts/lib/mcp-deferred-gate.ts +261 -0
- package/src/hooks/ts/lib/mcp-deferred-reminder.test.ts +90 -0
- package/src/hooks/ts/lib/mcp-deferred-reminder.ts +66 -0
- package/src/hooks/ts/pre-tool-use/mcp-deferred-gate.ts +126 -0
- package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +6 -2
- package/src/hooks/ts/session-start/mcp-deferred-reminder.ts +52 -0
- package/statusline-command.sh +149 -72
- package/dist/chain-DGXNoTwc.mjs.map +0 -1
- package/dist/config-DgzR5sfb.mjs.map +0 -1
- package/dist/context-handover-cache-dUnSZwaA.mjs.map +0 -1
- package/dist/daemon-DsvP8KMy.mjs.map +0 -1
- package/dist/fallback-5UJO-RyX.mjs.map +0 -1
- package/dist/main-resolver-BgIrC-D9.mjs.map +0 -1
- package/dist/scope-NIwCGxRI.mjs +0 -351
- package/dist/scope-NIwCGxRI.mjs.map +0 -1
- package/dist/server-Cp7tNzNC.mjs.map +0 -1
- package/dist/state-BTa10SOM.mjs.map +0 -1
- package/dist/state-RXepvbnO.mjs +0 -3
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-deferred-gate.ts — pure pieces of the deferred MCP tool gate.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code defers MCP tool schemas: an mcp__server__tool call fails input
|
|
5
|
+
* validation unless the session surfaced the schema via ToolSearch first.
|
|
6
|
+
* Sessions misread that error as "MCP server disconnected" and report an
|
|
7
|
+
* outage that never happened (observed with a rename tool while every server
|
|
8
|
+
* was connected).
|
|
9
|
+
*
|
|
10
|
+
* A second failure shape: when an MCP server re-registers mid-session, the
|
|
11
|
+
* session's deferred tool handles are invalidated and calls fail with the
|
|
12
|
+
* harness text "N deferred tools are no longer available" (observed 2026-09-18,
|
|
13
|
+
* N=411, a rename tool). Sessions misread that as a disconnect too. The gate
|
|
14
|
+
* denies that shape with the same ToolSearch remedy; a ToolSearch that
|
|
15
|
+
* surfaces the tool after the last invalidation re-arms the pass-through.
|
|
16
|
+
*
|
|
17
|
+
* The decision is evidence-based: the session transcript either shows the tool
|
|
18
|
+
* was surfaced (a ToolSearch mentioning it) or already ran successfully — in
|
|
19
|
+
* which case the schema is loaded and the call passes through untouched — or
|
|
20
|
+
* it shows neither, in which case the call is about to hit the gate (or is a
|
|
21
|
+
* retry that just did) and gets the corrective instruction.
|
|
22
|
+
*
|
|
23
|
+
* Split out of the pre-tool-use entrypoint (which calls main() at import
|
|
24
|
+
* time) so the decision is testable without spawning a hook process, per the
|
|
25
|
+
* transcript-text.ts precedent.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Types
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export interface GateHookInput {
|
|
33
|
+
session_id?: string;
|
|
34
|
+
transcript_path?: string;
|
|
35
|
+
cwd?: string;
|
|
36
|
+
tool_name?: string;
|
|
37
|
+
tool_input?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GateEvidence {
|
|
41
|
+
/** A ToolSearch call in the transcript mentions this tool by full name. */
|
|
42
|
+
surfacedByToolSearch: boolean;
|
|
43
|
+
/** This tool already ran successfully — its schema must be loaded. */
|
|
44
|
+
succeededBefore: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface GateObservation {
|
|
48
|
+
type: "decision";
|
|
49
|
+
title: string;
|
|
50
|
+
narrative: string;
|
|
51
|
+
tool_name: string;
|
|
52
|
+
tool_input_summary: string;
|
|
53
|
+
files_read: string[];
|
|
54
|
+
files_modified: string[];
|
|
55
|
+
concepts: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface GateDecision {
|
|
59
|
+
action: "pass" | "correct";
|
|
60
|
+
/** The JSON string to write to stdout, same shape as sibling PreToolUse hooks. */
|
|
61
|
+
output: string;
|
|
62
|
+
/** Observation payload for the daemon when a correction fired, else null. */
|
|
63
|
+
observation: GateObservation | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Decision
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
export const PASS_OUTPUT = JSON.stringify({
|
|
71
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Harness text emitted when a mid-session MCP server re-registration
|
|
76
|
+
* invalidates the session's deferred tool handles.
|
|
77
|
+
*/
|
|
78
|
+
export const STALE_HANDLE_MARKER = "deferred tools are no longer available";
|
|
79
|
+
|
|
80
|
+
/** True for MCP tool names (`mcp__<server>__<tool>`), false for everything else. */
|
|
81
|
+
export function isMcpTool(name: string): boolean {
|
|
82
|
+
return name.startsWith("mcp__") && name.split("__").length >= 3 && !!name.split("__")[2];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Scan transcript text (JSONL, one entry per line, tolerant of junk) for
|
|
87
|
+
* evidence that `toolName` is loadable in this session.
|
|
88
|
+
*/
|
|
89
|
+
export function scanTranscriptEvidence(transcriptText: string, toolName: string): GateEvidence {
|
|
90
|
+
const toolUseIds = new Set<string>();
|
|
91
|
+
let surfacedByToolSearch = false;
|
|
92
|
+
let succeededBefore = false;
|
|
93
|
+
|
|
94
|
+
for (const line of transcriptText.split("\n")) {
|
|
95
|
+
const trimmed = line.trim();
|
|
96
|
+
if (!trimmed) continue;
|
|
97
|
+
let entry: unknown;
|
|
98
|
+
try {
|
|
99
|
+
entry = JSON.parse(trimmed);
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const message = (entry as { message?: { content?: unknown } })?.message;
|
|
104
|
+
const content = message?.content;
|
|
105
|
+
if (!Array.isArray(content)) continue;
|
|
106
|
+
|
|
107
|
+
for (const block of content as Array<Record<string, unknown>>) {
|
|
108
|
+
if (!block || typeof block !== "object") continue;
|
|
109
|
+
if (block.type === "tool_use" && typeof block.name === "string") {
|
|
110
|
+
if (block.name === toolName && typeof block.id === "string") {
|
|
111
|
+
toolUseIds.add(block.id);
|
|
112
|
+
} else if (block.name === "ToolSearch") {
|
|
113
|
+
// Liberal match: the query may be "select:<tool>" or a keyword list.
|
|
114
|
+
if (JSON.stringify(block.input ?? "").includes(toolName)) {
|
|
115
|
+
surfacedByToolSearch = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else if (
|
|
119
|
+
block.type === "tool_result" &&
|
|
120
|
+
typeof block.tool_use_id === "string" &&
|
|
121
|
+
toolUseIds.has(block.tool_use_id) &&
|
|
122
|
+
!block.is_error
|
|
123
|
+
) {
|
|
124
|
+
succeededBefore = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { surfacedByToolSearch, succeededBefore };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Byte index of the last transcript line where a ToolSearch call surfaced
|
|
134
|
+
* `toolName` (same liberal match as scanTranscriptEvidence), or -1 if never.
|
|
135
|
+
* Used to order the stale-handle remedy against the last invalidation.
|
|
136
|
+
*/
|
|
137
|
+
export function lastToolSearchSurfacingIndex(
|
|
138
|
+
transcriptText: string,
|
|
139
|
+
toolName: string,
|
|
140
|
+
): number {
|
|
141
|
+
let last = -1;
|
|
142
|
+
let offset = 0;
|
|
143
|
+
for (const line of transcriptText.split("\n")) {
|
|
144
|
+
const start = offset;
|
|
145
|
+
offset += line.length + 1;
|
|
146
|
+
const trimmed = line.trim();
|
|
147
|
+
if (!trimmed) continue;
|
|
148
|
+
let entry: unknown;
|
|
149
|
+
try {
|
|
150
|
+
entry = JSON.parse(trimmed);
|
|
151
|
+
} catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const content = (entry as { message?: { content?: unknown } })?.message?.content;
|
|
155
|
+
if (!Array.isArray(content)) continue;
|
|
156
|
+
for (const block of content as Array<Record<string, unknown>>) {
|
|
157
|
+
if (
|
|
158
|
+
block?.type === "tool_use" &&
|
|
159
|
+
block.name === "ToolSearch" &&
|
|
160
|
+
JSON.stringify(block.input ?? "").includes(toolName)
|
|
161
|
+
) {
|
|
162
|
+
last = start;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return last;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The ToolSearch remedy shared by both correction shapes. */
|
|
170
|
+
function buildRemedy(toolName: string): string {
|
|
171
|
+
return (
|
|
172
|
+
`Remedy: call ToolSearch with query "select:${toolName}" to load the fresh registry ` +
|
|
173
|
+
`entry, then call ${toolName} again with the same arguments.`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The corrective instruction fed back to the session on a deny. */
|
|
178
|
+
export function buildCorrection(toolName: string): string {
|
|
179
|
+
return (
|
|
180
|
+
`${toolName} is a deferred MCP tool: its schema is not loaded in this session yet, so this ` +
|
|
181
|
+
`call would fail input validation. The MCP server is NOT disconnected — do not report an ` +
|
|
182
|
+
`outage and do not retry the call unchanged. ${buildRemedy(toolName)}`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Same remedy for the stale-handle shape: a re-registration invalidated a loaded schema. */
|
|
187
|
+
export function buildStaleHandleCorrection(toolName: string): string {
|
|
188
|
+
return (
|
|
189
|
+
`${toolName}'s deferred tool handle is stale: the MCP server re-registered mid-session, ` +
|
|
190
|
+
`invalidating the schema this session had loaded ("${STALE_HANDLE_MARKER}"). The MCP ` +
|
|
191
|
+
`server is NOT disconnected — do not report an outage and do not retry the call ` +
|
|
192
|
+
`unchanged. ${buildRemedy(toolName)}`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The deny decision shared by both failure shapes. */
|
|
197
|
+
function correctDecision(toolName: string, reason: string, narrative: string): GateDecision {
|
|
198
|
+
return {
|
|
199
|
+
action: "correct",
|
|
200
|
+
output: JSON.stringify({
|
|
201
|
+
hookSpecificOutput: {
|
|
202
|
+
hookEventName: "PreToolUse",
|
|
203
|
+
permissionDecision: "deny",
|
|
204
|
+
permissionDecisionReason: reason,
|
|
205
|
+
},
|
|
206
|
+
}),
|
|
207
|
+
observation: {
|
|
208
|
+
type: "decision",
|
|
209
|
+
title: `MCP deferred-tool gate corrected: ${toolName}`,
|
|
210
|
+
narrative,
|
|
211
|
+
tool_name: toolName,
|
|
212
|
+
tool_input_summary: toolName,
|
|
213
|
+
files_read: [],
|
|
214
|
+
files_modified: [],
|
|
215
|
+
concepts: ["mcp", "deferred", "toolsearch"],
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The whole gate: given hook input and the session transcript, decide whether
|
|
222
|
+
* this mcp__ call passes through or gets the deferred-schema correction.
|
|
223
|
+
*/
|
|
224
|
+
export function decideMcpGate(input: GateHookInput, transcriptText: string): GateDecision {
|
|
225
|
+
const toolName = typeof input.tool_name === "string" ? input.tool_name : "";
|
|
226
|
+
if (!isMcpTool(toolName)) {
|
|
227
|
+
return { action: "pass", output: PASS_OUTPUT, observation: null };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Stale-handle shape: a mid-session re-registration invalidated loaded
|
|
231
|
+
// schemas. Overrides the normal evidence pass-through, because a tool that
|
|
232
|
+
// was surfaced (or even ran) earlier can still have a dead handle now. The
|
|
233
|
+
// gate re-arms to pass-through only once a ToolSearch re-surfaced the tool
|
|
234
|
+
// after the last invalidation.
|
|
235
|
+
const toolInputText = JSON.stringify(input.tool_input ?? "");
|
|
236
|
+
const lastStale = transcriptText.lastIndexOf(STALE_HANDLE_MARKER);
|
|
237
|
+
if (toolInputText.includes(STALE_HANDLE_MARKER) || lastStale !== -1) {
|
|
238
|
+
const remedyAt = lastToolSearchSurfacingIndex(transcriptText, toolName);
|
|
239
|
+
if (remedyAt === -1 || remedyAt <= lastStale) {
|
|
240
|
+
return correctDecision(
|
|
241
|
+
toolName,
|
|
242
|
+
buildStaleHandleCorrection(toolName),
|
|
243
|
+
`Blocked a retry against a stale deferred-tool handle for ${toolName} and issued ` +
|
|
244
|
+
"the ToolSearch remedy (server not disconnected)",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return { action: "pass", output: PASS_OUTPUT, observation: null };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const evidence = scanTranscriptEvidence(transcriptText, toolName);
|
|
251
|
+
if (evidence.surfacedByToolSearch || evidence.succeededBefore) {
|
|
252
|
+
return { action: "pass", output: PASS_OUTPUT, observation: null };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return correctDecision(
|
|
256
|
+
toolName,
|
|
257
|
+
buildCorrection(toolName),
|
|
258
|
+
`Blocked a call to deferred MCP tool ${toolName} and issued the ToolSearch remedy ` +
|
|
259
|
+
"(server not disconnected)",
|
|
260
|
+
);
|
|
261
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
buildReminder,
|
|
5
|
+
decideMcpDeferredReminder,
|
|
6
|
+
REMINDER_SOURCES,
|
|
7
|
+
type ReminderHookInput,
|
|
8
|
+
} from "./mcp-deferred-reminder.js";
|
|
9
|
+
import { STALE_HANDLE_MARKER } from "./mcp-deferred-gate.js";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Fixtures
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function hookInput(source?: string): ReminderHookInput {
|
|
16
|
+
return { source };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Decision
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
describe("decideMcpDeferredReminder", () => {
|
|
24
|
+
it("emits the reminder for source resume", () => {
|
|
25
|
+
const out = decideMcpDeferredReminder(hookInput("resume"));
|
|
26
|
+
expect(out).not.toBeNull();
|
|
27
|
+
expect(out).toContain("ToolSearch");
|
|
28
|
+
expect(out).toContain("retry once");
|
|
29
|
+
expect(out).toContain(STALE_HANDLE_MARKER);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("emits the reminder for source compact", () => {
|
|
33
|
+
expect(decideMcpDeferredReminder(hookInput("compact"))).not.toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reminder stays consistent with the gate's shared stale-handle marker", () => {
|
|
37
|
+
expect(buildReminder()).toContain(`"${STALE_HANDLE_MARKER}"`);
|
|
38
|
+
expect(REMINDER_SOURCES).toEqual(new Set(["resume", "compact"]));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("stays silent for source startup", () => {
|
|
42
|
+
expect(decideMcpDeferredReminder(hookInput("startup"))).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("stays silent for source clear", () => {
|
|
46
|
+
expect(decideMcpDeferredReminder(hookInput("clear"))).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("stays silent for missing or junk source", () => {
|
|
50
|
+
expect(decideMcpDeferredReminder({})).toBeNull();
|
|
51
|
+
expect(decideMcpDeferredReminder(hookInput("nonsense"))).toBeNull();
|
|
52
|
+
expect(decideMcpDeferredReminder(hookInput("RESUME"))).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Entrypoint — junk input must exit 0 with nothing on stdout
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
describe("session-start/mcp-deferred-reminder entrypoint", () => {
|
|
61
|
+
const ENTRYPOINT = "src/hooks/ts/session-start/mcp-deferred-reminder.ts";
|
|
62
|
+
|
|
63
|
+
function runHook(stdin: string): { status: number; stdout: string } {
|
|
64
|
+
const stdout = execFileSync("bun", [ENTRYPOINT], {
|
|
65
|
+
input: stdin,
|
|
66
|
+
encoding: "utf8",
|
|
67
|
+
timeout: 15_000,
|
|
68
|
+
});
|
|
69
|
+
return { status: 0, stdout };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
it("junk input exits 0 silent", () => {
|
|
73
|
+
const { status, stdout } = runHook("this is not json {{{");
|
|
74
|
+
expect(status).toBe(0);
|
|
75
|
+
expect(stdout).toBe("");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("resume input exits 0 with the reminder on stdout", () => {
|
|
79
|
+
const { status, stdout } = runHook(JSON.stringify({ source: "resume" }));
|
|
80
|
+
expect(status).toBe(0);
|
|
81
|
+
expect(stdout).toContain("ToolSearch");
|
|
82
|
+
expect(stdout).toContain("retry once");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("startup input exits 0 silent", () => {
|
|
86
|
+
const { status, stdout } = runHook(JSON.stringify({ source: "startup" }));
|
|
87
|
+
expect(status).toBe(0);
|
|
88
|
+
expect(stdout).toBe("");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-deferred-reminder.ts — pure pieces of the session-start deferred-handle reminder.
|
|
3
|
+
*
|
|
4
|
+
* A resumed session inherits its transcript, and a transcript can contain a
|
|
5
|
+
* mid-session MCP re-registration: the deferred tool handles the session held
|
|
6
|
+
* are stale from then on ("deferred tools are no longer available"). The
|
|
7
|
+
* call-time gate (../pre-tool-use/mcp-deferred-gate.ts) can only correct a call
|
|
8
|
+
* that is still ATTEMPTED — but a resumed session that already concluded once
|
|
9
|
+
* that "the MCP server is disconnected" never retries, so the gate never fires
|
|
10
|
+
* (observed 2026-09-18: 37s of thinking, then a skip on "aibroker is down
|
|
11
|
+
* right now"). The cure has to arrive before the first tool call, at session
|
|
12
|
+
* start: a fresh claim that stale history is not evidence, so the model
|
|
13
|
+
* attempts the call and lands in the gate if the handle really is stale.
|
|
14
|
+
*
|
|
15
|
+
* Fired for sources "resume" and "compact" only — fresh sessions have no
|
|
16
|
+
* stale handles and no stale conclusions to unlearn.
|
|
17
|
+
*
|
|
18
|
+
* Split out of the session-start entrypoint so the decision is testable
|
|
19
|
+
* without spawning a hook process, per the mcp-deferred-gate.ts precedent.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Reused so reminder and gate corrections read as one instrument.
|
|
23
|
+
import { STALE_HANDLE_MARKER } from "./mcp-deferred-gate.js";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Types
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export interface ReminderHookInput {
|
|
30
|
+
source?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Decision
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* SessionStart sources the reminder applies to. "startup" and "clear" begin
|
|
39
|
+
* with an empty session state — nothing to heal there.
|
|
40
|
+
*/
|
|
41
|
+
export const REMINDER_SOURCES = new Set(["resume", "compact"]);
|
|
42
|
+
|
|
43
|
+
/** The exact reminder text; exported so tests can match against the real thing. */
|
|
44
|
+
export function buildReminder(): string {
|
|
45
|
+
return (
|
|
46
|
+
`<system-reminder>\n` +
|
|
47
|
+
`Resumed sessions keep stale deferred MCP tool handles: a mid-session MCP server ` +
|
|
48
|
+
`re-registration invalidates every loaded mcp__ schema ("${STALE_HANDLE_MARKER}"). ` +
|
|
49
|
+
`If any mcp__ tool call fails or a tool seems unavailable: run ToolSearch with query ` +
|
|
50
|
+
`"select:<full tool name>" and retry once. A prior "the MCP server is disconnected" ` +
|
|
51
|
+
`conclusion in this history is stale evidence — do not skip the retry; only a call ` +
|
|
52
|
+
`failing after a fresh ToolSearch proves an outage.\n` +
|
|
53
|
+
`</system-reminder>`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The whole reminder: given hook input, return the stdout payload for sources
|
|
59
|
+
* resume/compact, or null for every other (silent exit 0).
|
|
60
|
+
*/
|
|
61
|
+
export function decideMcpDeferredReminder(input: ReminderHookInput): string | null {
|
|
62
|
+
if (typeof input.source !== "string" || !REMINDER_SOURCES.has(input.source)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return buildReminder();
|
|
66
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* mcp-deferred-gate.ts — PreToolUse hook on mcp__* tools.
|
|
5
|
+
*
|
|
6
|
+
* Claude Code defers MCP tool schemas: calling a deferred mcp__server__tool
|
|
7
|
+
* without surfacing it via ToolSearch fails input validation, and sessions
|
|
8
|
+
* misread that as "MCP server disconnected" — as they do the stale handles a
|
|
9
|
+
* mid-session server re-registration leaves behind ("deferred tools are no
|
|
10
|
+
* longer available"). PreToolUse is the only event on
|
|
11
|
+
* the attempt path — when the schema gate rejects the call the tool never
|
|
12
|
+
* runs, so no PostToolUse fires to correct it. A deny here lands the remedy
|
|
13
|
+
* in the same channel the model already reads (same mechanism as
|
|
14
|
+
* route-agents-to-worker), and the transcript evidence check in
|
|
15
|
+
* ../lib/mcp-deferred-gate.ts keeps already-loaded tools passing through
|
|
16
|
+
* untouched.
|
|
17
|
+
*
|
|
18
|
+
* Every correction is appended to the observation store via daemon IPC (same
|
|
19
|
+
* fire-and-forget path as observe.ts), so false-outage reports are countable.
|
|
20
|
+
* Never blocks Claude Code: always exits 0, tolerant of missing transcript.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
24
|
+
import { connect } from "node:net";
|
|
25
|
+
import { decideMcpGate, PASS_OUTPUT, type GateHookInput } from "../lib/mcp-deferred-gate.js";
|
|
26
|
+
import { isProbeSession } from "../lib/project-utils.js";
|
|
27
|
+
|
|
28
|
+
/** Enough transcript to see the whole recent tool history; transcripts can be huge. */
|
|
29
|
+
const TRANSCRIPT_TAIL_BYTES = 512 * 1024;
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Transcript tail — bounded read, never loads the whole file
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
function readTranscriptTail(path: string): string {
|
|
36
|
+
try {
|
|
37
|
+
const fd = openSync(path, "r");
|
|
38
|
+
try {
|
|
39
|
+
const size = fstatSync(fd).size;
|
|
40
|
+
const length = Math.min(size, TRANSCRIPT_TAIL_BYTES);
|
|
41
|
+
const buffer = Buffer.alloc(length);
|
|
42
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
43
|
+
return buffer.toString("utf-8");
|
|
44
|
+
} finally {
|
|
45
|
+
closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Inline IPC sender — avoids importing src/daemon/ipc-client.ts at build time
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
function sendToDaemon(method: string, params: Record<string, unknown>): Promise<void> {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const timeout = setTimeout(() => {
|
|
59
|
+
resolve();
|
|
60
|
+
}, 5000);
|
|
61
|
+
try {
|
|
62
|
+
const socket = connect("/tmp/pai.sock", () => {
|
|
63
|
+
const req = JSON.stringify({ id: Date.now().toString(), method, params }) + "\n";
|
|
64
|
+
socket.write(req);
|
|
65
|
+
socket.on("data", () => {
|
|
66
|
+
clearTimeout(timeout);
|
|
67
|
+
socket.destroy();
|
|
68
|
+
resolve();
|
|
69
|
+
});
|
|
70
|
+
socket.on("error", () => {
|
|
71
|
+
clearTimeout(timeout);
|
|
72
|
+
resolve();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
socket.on("error", () => {
|
|
76
|
+
clearTimeout(timeout);
|
|
77
|
+
resolve();
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
clearTimeout(timeout);
|
|
81
|
+
resolve();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Main
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
async function main(): Promise<void> {
|
|
91
|
+
let text = "";
|
|
92
|
+
try {
|
|
93
|
+
for await (const chunk of process.stdin) text += chunk;
|
|
94
|
+
} catch {
|
|
95
|
+
process.stdout.write(PASS_OUTPUT);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!text.trim()) {
|
|
99
|
+
process.stdout.write(PASS_OUTPUT);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let input: GateHookInput;
|
|
104
|
+
try {
|
|
105
|
+
input = JSON.parse(text) as GateHookInput;
|
|
106
|
+
} catch {
|
|
107
|
+
process.stdout.write(PASS_OUTPUT);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const transcript =
|
|
112
|
+
typeof input.transcript_path === "string" ? readTranscriptTail(input.transcript_path) : "";
|
|
113
|
+
const decision = decideMcpGate(input, transcript);
|
|
114
|
+
process.stdout.write(decision.output);
|
|
115
|
+
|
|
116
|
+
if (decision.observation && !isProbeSession(input.cwd)) {
|
|
117
|
+
await sendToDaemon("observation_store", {
|
|
118
|
+
session_id: input.session_id ?? "",
|
|
119
|
+
...decision.observation,
|
|
120
|
+
content_hash: "",
|
|
121
|
+
cwd: input.cwd ?? "",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
main().catch(() => process.stdout.write(PASS_OUTPUT));
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Every decision is appended to the routing ledger read by `pai worker log`.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { readWorkersSection } from "../../../workers/config.js";
|
|
20
|
+
import { readWorkersSection, ANTHROPIC_NATIVE } from "../../../workers/config.js";
|
|
21
21
|
import { workersLogDir, ledgerPath } from "../../../workers/paths.js";
|
|
22
22
|
import { appendLedger } from "../../../workers/ledger.js";
|
|
23
23
|
|
|
@@ -102,7 +102,11 @@ async function main(): Promise<void> {
|
|
|
102
102
|
process.stdout.write(ALLOW);
|
|
103
103
|
return;
|
|
104
104
|
}
|
|
105
|
-
if (
|
|
105
|
+
if (
|
|
106
|
+
!workers.enabled ||
|
|
107
|
+
Object.keys(workers.providers).length === 0 ||
|
|
108
|
+
workers.active === ANTHROPIC_NATIVE
|
|
109
|
+
) {
|
|
106
110
|
process.stdout.write(ALLOW);
|
|
107
111
|
return;
|
|
108
112
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* mcp-deferred-reminder.ts — SessionStart hook (matchers: resume, compact)
|
|
5
|
+
*
|
|
6
|
+
* Resumed sessions can inherit stale deferred MCP tool handles (a mid-session
|
|
7
|
+
* server re-registration) together with a stale "the MCP server is
|
|
8
|
+
* disconnected" conclusion from before the restart — a combination that makes
|
|
9
|
+
* the session skip the retry the call-time gate would need in order to fire.
|
|
10
|
+
* This hook injects the counter-instruction at session start, before any tool
|
|
11
|
+
* call: see ../lib/mcp-deferred-reminder.ts for the rationale and text.
|
|
12
|
+
*
|
|
13
|
+
* stdout is captured by Claude Code and injected into the session context,
|
|
14
|
+
* same channel as session-commands/inject-observations. Never blocks session
|
|
15
|
+
* start: always exits 0.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { decideMcpDeferredReminder, type ReminderHookInput } from "../lib/mcp-deferred-reminder.js";
|
|
19
|
+
|
|
20
|
+
async function main() {
|
|
21
|
+
let input: ReminderHookInput = {};
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const decoder = new TextDecoder();
|
|
25
|
+
let raw = '';
|
|
26
|
+
const timeoutPromise = new Promise<void>((resolve) => { setTimeout(resolve, 500); });
|
|
27
|
+
const readPromise = (async () => {
|
|
28
|
+
for await (const chunk of process.stdin) {
|
|
29
|
+
raw += decoder.decode(chunk, { stream: true });
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
32
|
+
await Promise.race([readPromise, timeoutPromise]);
|
|
33
|
+
if (raw.trim()) {
|
|
34
|
+
input = JSON.parse(raw) as ReminderHookInput;
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// Junk input: stay silent, exit 0
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const reminder = decideMcpDeferredReminder(input);
|
|
42
|
+
if (reminder) {
|
|
43
|
+
console.log(reminder);
|
|
44
|
+
console.error(`mcp-deferred-reminder: injected reminder for source=${input.source}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
main().catch(() => {
|
|
51
|
+
process.exit(0);
|
|
52
|
+
});
|