claudish 12.3.1 → 13.0.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.
- package/bin/claudish.cjs +60 -3
- package/dist/hooks.d.ts +507 -0
- package/dist/hooks.js +53 -0
- package/dist/index.js +42937 -36189
- package/package.json +21 -13
package/bin/claudish.cjs
CHANGED
|
@@ -36,6 +36,13 @@
|
|
|
36
36
|
|
|
37
37
|
const { execFileSync, execSync, spawn } = require("node:child_process");
|
|
38
38
|
const { resolve } = require("node:path");
|
|
39
|
+
const { NO_ENV_FILE_FLAG, envFileKeys, probeFailedNotice } = require("./env-files.cjs");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The names `childEnv` left out, handed to the child so it prints ONE line for
|
|
43
|
+
* everything ignored, after its own `.env` load (src/launch-env.ts reads and deletes it).
|
|
44
|
+
*/
|
|
45
|
+
const LAUNCHER_DROPPED_VAR = "CLAUDISH_LAUNCHER_DROPPED";
|
|
39
46
|
|
|
40
47
|
/**
|
|
41
48
|
* The shell command that asks PATH where bun is.
|
|
@@ -90,6 +97,44 @@ function findBun(platform = process.platform, env = process.env) {
|
|
|
90
97
|
return null;
|
|
91
98
|
}
|
|
92
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The arguments Bun is started with. `--no-env-file` comes first: without it Bun
|
|
102
|
+
* loads `.env`, `.env.local`, `.env.<NODE_ENV>` and `.env.<NODE_ENV>.local` from the
|
|
103
|
+
* caller's working directory into the environment before claudish runs, and that
|
|
104
|
+
* directory is often a checkout nobody has reviewed. claudish reads the provider API
|
|
105
|
+
* keys in `.env` itself, and nothing else (src/dotenv-allowlist.ts).
|
|
106
|
+
*/
|
|
107
|
+
function bunArgv(entry, argv) {
|
|
108
|
+
return [NO_ENV_FILE_FLAG, entry, ...argv];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The environment the Bun child is given, or null when it cannot be built safely.
|
|
113
|
+
*
|
|
114
|
+
* Node never loads env files, so under Node this is the launcher's own environment.
|
|
115
|
+
* Bun does, unless it was started with `--no-env-file`: `bun <path>/claudish.cjs`
|
|
116
|
+
* puts the checkout's env files into THIS process, and the child's `--no-env-file`
|
|
117
|
+
* would then vouch for them as the real environment. So under such a Bun every name
|
|
118
|
+
* those files set is left out (env-files.cjs asks Bun which). A real value sharing a
|
|
119
|
+
* name with a file's is left out too, the safe direction; the child still reads the
|
|
120
|
+
* provider API keys and model settings in `.env`. When Bun cannot answer, there is no child.
|
|
121
|
+
*/
|
|
122
|
+
function childEnv({ isBun, execArgv, bunPath, cwd, env }) {
|
|
123
|
+
const out = { ...env };
|
|
124
|
+
delete out[LAUNCHER_DROPPED_VAR];
|
|
125
|
+
if (!isBun || execArgv.includes(NO_ENV_FILE_FLAG)) return { env: out, dropped: [] };
|
|
126
|
+
const fileKeys = envFileKeys({ bunPath, execArgv, cwd, env });
|
|
127
|
+
if (fileKeys === null) return null;
|
|
128
|
+
const dropped = [];
|
|
129
|
+
for (const key of fileKeys) {
|
|
130
|
+
if (out[key] === undefined) continue;
|
|
131
|
+
delete out[key];
|
|
132
|
+
dropped.push(key);
|
|
133
|
+
}
|
|
134
|
+
if (dropped.length > 0) out[LAUNCHER_DROPPED_VAR] = dropped.join(",");
|
|
135
|
+
return { env: out, dropped };
|
|
136
|
+
}
|
|
137
|
+
|
|
93
138
|
function main() {
|
|
94
139
|
const bun = findBun();
|
|
95
140
|
if (!bun) {
|
|
@@ -107,9 +152,21 @@ Learn more: https://bun.sh`);
|
|
|
107
152
|
|
|
108
153
|
const entry = resolve(__dirname, "..", "dist", "index.js");
|
|
109
154
|
|
|
155
|
+
const base = childEnv({
|
|
156
|
+
isBun: process.versions.bun !== undefined,
|
|
157
|
+
execArgv: process.execArgv,
|
|
158
|
+
bunPath: process.execPath,
|
|
159
|
+
cwd: process.cwd(),
|
|
160
|
+
env: process.env,
|
|
161
|
+
});
|
|
162
|
+
if (base === null) {
|
|
163
|
+
console.error(probeFailedNotice());
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
|
|
110
167
|
let child;
|
|
111
168
|
try {
|
|
112
|
-
child = spawn(bun,
|
|
169
|
+
child = spawn(bun, bunArgv(entry, process.argv.slice(2)), {
|
|
113
170
|
stdio: "inherit",
|
|
114
171
|
// The pid pair tells the Bun child who launched it: this launcher, and the
|
|
115
172
|
// launcher's own parent (Claude Code, for an MCP server). The child
|
|
@@ -117,7 +174,7 @@ Learn more: https://bun.sh`);
|
|
|
117
174
|
// (channel/parent-session.ts `hostPidFrom`), so a value leaked into a
|
|
118
175
|
// nested claudish is inert.
|
|
119
176
|
env: {
|
|
120
|
-
...
|
|
177
|
+
...base.env,
|
|
121
178
|
CLAUDISH_LAUNCHER_PID: String(process.pid),
|
|
122
179
|
CLAUDISH_LAUNCHER_PPID: String(process.ppid),
|
|
123
180
|
},
|
|
@@ -180,5 +237,5 @@ Learn more: https://bun.sh`);
|
|
|
180
237
|
if (require.main === module) {
|
|
181
238
|
main();
|
|
182
239
|
} else {
|
|
183
|
-
module.exports = { findBun, lookupCommand, bunCandidates, installHint };
|
|
240
|
+
module.exports = { findBun, lookupCommand, bunCandidates, installHint, bunArgv, childEnv };
|
|
184
241
|
}
|
package/dist/hooks.d.ts
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claudish/hooks — the public hook contract.
|
|
3
|
+
*
|
|
4
|
+
* Everything a hook author can import lives here, and nothing else does. This
|
|
5
|
+
* file imports nothing from the rest of claudish and has no side effects, so it
|
|
6
|
+
* can be built on its own into `dist/hooks.js` + `dist/hooks.d.ts` and served to
|
|
7
|
+
* user modules as the `claudish/hooks` virtual module (see `loader.ts`).
|
|
8
|
+
*
|
|
9
|
+
* Every internal hooks module takes its types from here, so the contract has a
|
|
10
|
+
* single source.
|
|
11
|
+
*
|
|
12
|
+
* ## Process isolation
|
|
13
|
+
*
|
|
14
|
+
* A hook module's top-level state is PER PROCESS. In the interactive launcher,
|
|
15
|
+
* proxy events (`request` … `responseEnd`, raw) and session events
|
|
16
|
+
* (`sessionStart` … `sessionEnd`) run in one process. In `create_session` and
|
|
17
|
+
* team children they do not: proxy events run in the pane child, and session
|
|
18
|
+
* events run in the MCP server or the CLI process that drives the pane. Pass
|
|
19
|
+
* per-turn data from a proxy event to `sessionIdle` with `annotateTurn`, never
|
|
20
|
+
* with module variables.
|
|
21
|
+
*
|
|
22
|
+
* ## Execution: a hook chain
|
|
23
|
+
*
|
|
24
|
+
* The hooks of one event run one after another, in effective order. Each hook's
|
|
25
|
+
* actions are applied before the next hook is called, so `ctx.request`,
|
|
26
|
+
* `ctx.tool.args` and `ctx.response` show what the earlier hooks did, and
|
|
27
|
+
* `ctx.prior` lists it. A warn-severity or failing hook changes nothing for the
|
|
28
|
+
* next one. Each event has a budget for all its hooks together; a hook's timeout
|
|
29
|
+
* is cut to what is left of it.
|
|
30
|
+
*
|
|
31
|
+
* ## Output
|
|
32
|
+
*
|
|
33
|
+
* Log with `ctx.log`. `console.*` and `process.stdout`/`stderr` writes made by a
|
|
34
|
+
* hook are routed to claudish's debug log, never to the terminal or the MCP
|
|
35
|
+
* protocol stream.
|
|
36
|
+
*/
|
|
37
|
+
export declare const HOOK_API_VERSION = 1;
|
|
38
|
+
export type Severity = "off" | "warn" | "fix";
|
|
39
|
+
export type HookOrder = "pre" | "normal" | "post";
|
|
40
|
+
/** native = RouteInfo.native true; foreign = RouteInfo.native false; all = both */
|
|
41
|
+
export type RouteScope = "foreign" | "native" | "all";
|
|
42
|
+
export type HostMode = "interactive" | "pane-child" | "serve" | "mcp" | "other";
|
|
43
|
+
export type JsonValue = string | number | boolean | null | ReadonlyArray<JsonValue> | {
|
|
44
|
+
readonly [k: string]: JsonValue;
|
|
45
|
+
};
|
|
46
|
+
export interface HarnessFacts {
|
|
47
|
+
readonly planModeActive: boolean;
|
|
48
|
+
readonly planFilePath?: string;
|
|
49
|
+
readonly planDir?: string;
|
|
50
|
+
/** Claude Code session id from request metadata */
|
|
51
|
+
readonly sessionId?: string;
|
|
52
|
+
/** empty = no listing found, not "no skills" */
|
|
53
|
+
readonly skills?: ReadonlyArray<{
|
|
54
|
+
name: string;
|
|
55
|
+
description: string;
|
|
56
|
+
}>;
|
|
57
|
+
}
|
|
58
|
+
export interface RouteInfo {
|
|
59
|
+
/** bare model name the handler serves, e.g. "glm-5" */
|
|
60
|
+
readonly modelId: string;
|
|
61
|
+
/** claudish provider name, e.g. "openrouter"; "anthropic" for native */
|
|
62
|
+
readonly provider: string;
|
|
63
|
+
/**
|
|
64
|
+
* true when the request is served by the native Anthropic handler, OR when
|
|
65
|
+
* !forceForeignModel && (/^claude[-.]/i.test(modelId) || provider === "anthropic")
|
|
66
|
+
* where forceForeignModel is the provider profile's opt-in override. This is the exact
|
|
67
|
+
* rule the plan-mode built-in has always used (a claude-* uid on an aggregator that sets
|
|
68
|
+
* forceForeignModel is foreign).
|
|
69
|
+
*/
|
|
70
|
+
readonly native: boolean;
|
|
71
|
+
}
|
|
72
|
+
export interface ClaudeTool {
|
|
73
|
+
name: string;
|
|
74
|
+
description?: string;
|
|
75
|
+
input_schema?: unknown;
|
|
76
|
+
}
|
|
77
|
+
/** A Claude-format content block. Only `text` blocks are editable by closed actions. */
|
|
78
|
+
export type ClaudeContentBlock = {
|
|
79
|
+
type: "text";
|
|
80
|
+
text: string;
|
|
81
|
+
} | {
|
|
82
|
+
type: "tool_use";
|
|
83
|
+
id: string;
|
|
84
|
+
name: string;
|
|
85
|
+
input: unknown;
|
|
86
|
+
} | {
|
|
87
|
+
type: "tool_result";
|
|
88
|
+
tool_use_id: string;
|
|
89
|
+
content?: unknown;
|
|
90
|
+
is_error?: boolean;
|
|
91
|
+
} | {
|
|
92
|
+
type: "thinking";
|
|
93
|
+
thinking: string;
|
|
94
|
+
signature?: string;
|
|
95
|
+
} | {
|
|
96
|
+
type: string;
|
|
97
|
+
[k: string]: unknown;
|
|
98
|
+
};
|
|
99
|
+
export interface ClaudeMessage {
|
|
100
|
+
role: "user" | "assistant";
|
|
101
|
+
content: string | ReadonlyArray<ClaudeContentBlock>;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* In `request`, the request as the earlier steps of the hook chain left it. On
|
|
105
|
+
* the response side and in `claims()`, the request chain's final view.
|
|
106
|
+
*/
|
|
107
|
+
export interface RequestFacts {
|
|
108
|
+
/** as Claude Code asked for it; frozen */
|
|
109
|
+
readonly model: string;
|
|
110
|
+
/** frozen */
|
|
111
|
+
readonly stream: boolean;
|
|
112
|
+
/** system prompt flattened to text, after earlier steps */
|
|
113
|
+
readonly systemText: string;
|
|
114
|
+
/** read-only view after earlier steps; change it through actions */
|
|
115
|
+
readonly messages: ReadonlyArray<ClaudeMessage>;
|
|
116
|
+
/**
|
|
117
|
+
* read-only view after earlier steps; `description` includes earlier description actions as
|
|
118
|
+
* stage B will apply them to the Claude-format tool (the provider copy may be summarised)
|
|
119
|
+
*/
|
|
120
|
+
readonly tools: ReadonlyArray<ClaudeTool>;
|
|
121
|
+
}
|
|
122
|
+
/** Which session a proxy event belongs to, when claudish knows. */
|
|
123
|
+
export interface ProxySessionRef {
|
|
124
|
+
readonly kind: SessionKind;
|
|
125
|
+
readonly id: string;
|
|
126
|
+
readonly role: "slot" | "judge" | null;
|
|
127
|
+
}
|
|
128
|
+
/** One action an earlier step of this hook chain applied or handed on. */
|
|
129
|
+
export interface PriorAction {
|
|
130
|
+
readonly hookId: string;
|
|
131
|
+
readonly action: HookAction;
|
|
132
|
+
}
|
|
133
|
+
interface BaseContext {
|
|
134
|
+
readonly hookId: string;
|
|
135
|
+
/** "warn" or "fix" (an "off" hook is never called) */
|
|
136
|
+
readonly severity: Severity;
|
|
137
|
+
/** writes to claudish's debug log; never to the terminal */
|
|
138
|
+
log(message: string): void;
|
|
139
|
+
/**
|
|
140
|
+
* this step's own signal: aborted when its step timeout elapses (its own timeout, or the
|
|
141
|
+
* event budget left, whichever is shorter) or when the dispatch is aborted. Never shared
|
|
142
|
+
* with another hook's step.
|
|
143
|
+
*/
|
|
144
|
+
readonly signal: AbortSignal;
|
|
145
|
+
/**
|
|
146
|
+
* actions earlier steps of this hook chain applied (or, for responseEnd/sessionStart/
|
|
147
|
+
* sessionIdle, handed on), in order. Empty for the first step and in claims()/the toolCall
|
|
148
|
+
* predicate. Never lists warn actions or anything a warn-severity hook returned.
|
|
149
|
+
*/
|
|
150
|
+
readonly prior: ReadonlyArray<PriorAction>;
|
|
151
|
+
}
|
|
152
|
+
export interface ProxyContext extends BaseContext {
|
|
153
|
+
readonly host: {
|
|
154
|
+
mode: HostMode;
|
|
155
|
+
};
|
|
156
|
+
readonly route: RouteInfo;
|
|
157
|
+
readonly request: RequestFacts;
|
|
158
|
+
readonly harness: HarnessFacts;
|
|
159
|
+
readonly session: ProxySessionRef | null;
|
|
160
|
+
}
|
|
161
|
+
export interface RequestContext extends ProxyContext {
|
|
162
|
+
readonly event: "request";
|
|
163
|
+
}
|
|
164
|
+
export interface ToolCallContext extends ProxyContext {
|
|
165
|
+
readonly event: "toolCall";
|
|
166
|
+
readonly tool: {
|
|
167
|
+
id: string;
|
|
168
|
+
name: string;
|
|
169
|
+
/** the arguments after every repair earlier hooks in the chain applied */
|
|
170
|
+
args: Readonly<Record<string, unknown>>;
|
|
171
|
+
/** the argument JSON exactly as the model produced it; never rewritten */
|
|
172
|
+
rawArgs: string;
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
export type ResponseBlock = {
|
|
176
|
+
type: "text";
|
|
177
|
+
text: string;
|
|
178
|
+
} | {
|
|
179
|
+
type: "thinking";
|
|
180
|
+
thinking: string;
|
|
181
|
+
} | {
|
|
182
|
+
type: "tool_use";
|
|
183
|
+
id: string;
|
|
184
|
+
name: string;
|
|
185
|
+
input: unknown;
|
|
186
|
+
} | {
|
|
187
|
+
type: "other";
|
|
188
|
+
blockType: string;
|
|
189
|
+
};
|
|
190
|
+
export interface ResponseView {
|
|
191
|
+
blocks: ReadonlyArray<ResponseBlock>;
|
|
192
|
+
/** every text block, joined with "\n\n" */
|
|
193
|
+
text: string;
|
|
194
|
+
/** every thinking block, joined with "\n\n" */
|
|
195
|
+
reasoning: string;
|
|
196
|
+
toolNames: ReadonlyArray<string>;
|
|
197
|
+
/** "tool_use" = an intermediate step of a tool loop; the turn continues after the tool runs */
|
|
198
|
+
stopReason: string | null;
|
|
199
|
+
}
|
|
200
|
+
/** response = the held response after every text action earlier hooks in the chain applied */
|
|
201
|
+
export interface ResponseRewriteContext extends ProxyContext {
|
|
202
|
+
readonly event: "responseRewrite";
|
|
203
|
+
readonly response: ResponseView;
|
|
204
|
+
}
|
|
205
|
+
/** response = the final bytes the client received: after any rewrite AND any raw `response` replacement */
|
|
206
|
+
export interface ResponseEndContext extends ProxyContext {
|
|
207
|
+
readonly event: "responseEnd";
|
|
208
|
+
readonly response: ResponseView;
|
|
209
|
+
}
|
|
210
|
+
export type SessionKind = "interactive" | "channel" | "team";
|
|
211
|
+
export interface SessionInfo {
|
|
212
|
+
kind: SessionKind;
|
|
213
|
+
/** team only; null otherwise */
|
|
214
|
+
role: "slot" | "judge" | null;
|
|
215
|
+
/** channel: the 8-hex session_id · team: "<run_id>:<slot id>" · interactive: the Claude Code session id */
|
|
216
|
+
id: string;
|
|
217
|
+
claudeSessionId: string | null;
|
|
218
|
+
/** as the caller asked for it */
|
|
219
|
+
model: string;
|
|
220
|
+
cwd: string;
|
|
221
|
+
/** a one-shot session stays "one-shot" through a hook-extended turn chain */
|
|
222
|
+
shape: "interactive" | "one-shot";
|
|
223
|
+
injection: "available" | "unavailable";
|
|
224
|
+
}
|
|
225
|
+
export interface SessionStartContext extends BaseContext {
|
|
226
|
+
readonly event: "sessionStart";
|
|
227
|
+
readonly session: SessionInfo;
|
|
228
|
+
}
|
|
229
|
+
export interface TurnAnnotation {
|
|
230
|
+
hookId: string;
|
|
231
|
+
key: string;
|
|
232
|
+
value: JsonValue;
|
|
233
|
+
}
|
|
234
|
+
export interface SessionIdleContext extends BaseContext {
|
|
235
|
+
readonly event: "sessionIdle";
|
|
236
|
+
readonly session: SessionInfo;
|
|
237
|
+
readonly turn: {
|
|
238
|
+
/** 1-based within the session; a re-woken turn keeps its index */
|
|
239
|
+
index: number;
|
|
240
|
+
/** this turn's assistant text */
|
|
241
|
+
answer: string;
|
|
242
|
+
stopReason: string | null;
|
|
243
|
+
apiError: boolean;
|
|
244
|
+
/** this turn was started by an injection */
|
|
245
|
+
hookCaused: boolean;
|
|
246
|
+
/** annotateTurn actions from this turn's responses, in arrival order */
|
|
247
|
+
annotations: ReadonlyArray<TurnAnnotation>;
|
|
248
|
+
};
|
|
249
|
+
readonly consecutiveHookTurns: number;
|
|
250
|
+
/** caller/human input is queued; an injection returned now will be dropped as stale */
|
|
251
|
+
readonly callerInputPending: boolean;
|
|
252
|
+
}
|
|
253
|
+
export interface SessionBlockedContext extends BaseContext {
|
|
254
|
+
readonly event: "sessionBlocked";
|
|
255
|
+
readonly session: SessionInfo;
|
|
256
|
+
readonly block: {
|
|
257
|
+
kind: "question" | "permission";
|
|
258
|
+
text: string;
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
export interface SessionEndContext extends BaseContext {
|
|
262
|
+
readonly event: "sessionEnd";
|
|
263
|
+
readonly session: SessionInfo;
|
|
264
|
+
readonly outcome: {
|
|
265
|
+
state: string;
|
|
266
|
+
reason: string | null;
|
|
267
|
+
exitCode: number | null;
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
type TextMatch = {
|
|
271
|
+
find: string;
|
|
272
|
+
regex?: boolean;
|
|
273
|
+
flags?: string;
|
|
274
|
+
};
|
|
275
|
+
/** default ["user"] */
|
|
276
|
+
type MessageRoles = {
|
|
277
|
+
roles?: ReadonlyArray<"user" | "assistant">;
|
|
278
|
+
};
|
|
279
|
+
export type InjectSystemNote = {
|
|
280
|
+
type: "injectSystemNote";
|
|
281
|
+
text: string;
|
|
282
|
+
};
|
|
283
|
+
export type AppendUserText = {
|
|
284
|
+
type: "appendUserText";
|
|
285
|
+
text: string;
|
|
286
|
+
};
|
|
287
|
+
export type ReplaceMessageText = {
|
|
288
|
+
type: "replaceMessageText";
|
|
289
|
+
replace: string;
|
|
290
|
+
} & TextMatch & MessageRoles;
|
|
291
|
+
export type RemoveMessageText = {
|
|
292
|
+
type: "removeMessageText";
|
|
293
|
+
} & TextMatch & MessageRoles;
|
|
294
|
+
export type RewriteToolDescription = {
|
|
295
|
+
type: "rewriteToolDescription";
|
|
296
|
+
tool: string;
|
|
297
|
+
append: string;
|
|
298
|
+
};
|
|
299
|
+
export type ReplaceToolDescription = {
|
|
300
|
+
type: "replaceToolDescription";
|
|
301
|
+
tool: string;
|
|
302
|
+
description: string;
|
|
303
|
+
};
|
|
304
|
+
export type ReplaceToolSchema = {
|
|
305
|
+
type: "replaceToolSchema";
|
|
306
|
+
tool: string;
|
|
307
|
+
inputSchema: {
|
|
308
|
+
type: "object";
|
|
309
|
+
[k: string]: unknown;
|
|
310
|
+
};
|
|
311
|
+
};
|
|
312
|
+
export type RemoveTool = {
|
|
313
|
+
type: "removeTool";
|
|
314
|
+
tool: string;
|
|
315
|
+
};
|
|
316
|
+
export type RepairToolArgs = {
|
|
317
|
+
type: "repairToolArgs";
|
|
318
|
+
args: Record<string, unknown>;
|
|
319
|
+
reason: string;
|
|
320
|
+
};
|
|
321
|
+
export type AppendText = {
|
|
322
|
+
type: "appendText";
|
|
323
|
+
text: string;
|
|
324
|
+
};
|
|
325
|
+
export type PrependText = {
|
|
326
|
+
type: "prependText";
|
|
327
|
+
text: string;
|
|
328
|
+
};
|
|
329
|
+
export type ReplaceText = {
|
|
330
|
+
type: "replaceText";
|
|
331
|
+
replace: string;
|
|
332
|
+
} & TextMatch;
|
|
333
|
+
export type RemoveText = {
|
|
334
|
+
type: "removeText";
|
|
335
|
+
} & TextMatch;
|
|
336
|
+
export type InjectUserTurn = {
|
|
337
|
+
type: "injectUserTurn";
|
|
338
|
+
text: string;
|
|
339
|
+
};
|
|
340
|
+
export type AnnotateTurn = {
|
|
341
|
+
type: "annotateTurn";
|
|
342
|
+
key: string;
|
|
343
|
+
value: JsonValue;
|
|
344
|
+
};
|
|
345
|
+
export type Warn = {
|
|
346
|
+
type: "warn";
|
|
347
|
+
message: string;
|
|
348
|
+
};
|
|
349
|
+
export type RequestAction = InjectSystemNote | AppendUserText | ReplaceMessageText | RemoveMessageText | RewriteToolDescription | ReplaceToolDescription | ReplaceToolSchema | RemoveTool | Warn;
|
|
350
|
+
export type ToolCallAction = RepairToolArgs | Warn;
|
|
351
|
+
export type RewriteAction = AppendText | PrependText | ReplaceText | RemoveText | Warn;
|
|
352
|
+
export type ResponseEndAction = InjectSystemNote | InjectUserTurn | AnnotateTurn | Warn;
|
|
353
|
+
export type SessionAction = InjectUserTurn | Warn;
|
|
354
|
+
export type ObserveAction = Warn;
|
|
355
|
+
export type HookAction = RequestAction | ToolCallAction | RewriteAction | ResponseEndAction | SessionAction;
|
|
356
|
+
export type HookResult<A> = A[] | void | Promise<A[] | void>;
|
|
357
|
+
export interface HookHandlers {
|
|
358
|
+
request?(ctx: RequestContext): HookResult<RequestAction>;
|
|
359
|
+
toolCall?: {
|
|
360
|
+
/** tool names whose completed arguments this hook sees; "*" = every tool (costs streaming of all tool input) */
|
|
361
|
+
tools: ReadonlyArray<string>;
|
|
362
|
+
/** synchronous, per request; default true. false = not armed for this request */
|
|
363
|
+
armed?(ctx: RequestContext): boolean;
|
|
364
|
+
/** true = see the call after it was forwarded; can only warn; never holds the stream */
|
|
365
|
+
observe?: boolean;
|
|
366
|
+
handle(ctx: ToolCallContext): HookResult<ToolCallAction>;
|
|
367
|
+
};
|
|
368
|
+
responseRewrite?: {
|
|
369
|
+
/** synchronous, per request, evaluated before the response starts; default true */
|
|
370
|
+
claims?(ctx: RequestContext): boolean;
|
|
371
|
+
handle(ctx: ResponseRewriteContext): HookResult<RewriteAction>;
|
|
372
|
+
};
|
|
373
|
+
responseEnd?(ctx: ResponseEndContext): HookResult<ResponseEndAction>;
|
|
374
|
+
sessionStart?(ctx: SessionStartContext): HookResult<SessionAction>;
|
|
375
|
+
sessionIdle?(ctx: SessionIdleContext): HookResult<SessionAction>;
|
|
376
|
+
sessionBlocked?(ctx: SessionBlockedContext): HookResult<ObserveAction>;
|
|
377
|
+
sessionEnd?(ctx: SessionEndContext): HookResult<ObserveAction>;
|
|
378
|
+
}
|
|
379
|
+
export interface RawProviderRequestContext {
|
|
380
|
+
readonly hookId: string;
|
|
381
|
+
readonly route: RouteInfo;
|
|
382
|
+
claudeRequest: any;
|
|
383
|
+
messages: any[];
|
|
384
|
+
tools: any[];
|
|
385
|
+
}
|
|
386
|
+
export interface RawProviderChunkContext {
|
|
387
|
+
readonly hookId: string;
|
|
388
|
+
readonly route: RouteInfo;
|
|
389
|
+
/** A clone of the parsed provider event. Whatever this field holds when the call returns is
|
|
390
|
+
* what the parser reads from then on, so it may be edited in place or replaced. */
|
|
391
|
+
chunk: any;
|
|
392
|
+
/** The delta object inside `chunk` at call time (Responses wire: `chunk` itself). Edit it in
|
|
393
|
+
* place; assigning a new object to this field has no effect. */
|
|
394
|
+
delta: any;
|
|
395
|
+
/** per-stream, shared across this hook's chunk calls; never cloned */
|
|
396
|
+
metadata: Map<string, unknown>;
|
|
397
|
+
}
|
|
398
|
+
export interface RawProviderStreamEndContext {
|
|
399
|
+
readonly hookId: string;
|
|
400
|
+
readonly route: RouteInfo;
|
|
401
|
+
metadata: Map<string, unknown>;
|
|
402
|
+
}
|
|
403
|
+
export interface RawResponseContext {
|
|
404
|
+
readonly hookId: string;
|
|
405
|
+
readonly route: RouteInfo;
|
|
406
|
+
readonly request: RequestFacts;
|
|
407
|
+
/** A replay of the current Claude-format response (SSE, or JSON for stream:false), read at the
|
|
408
|
+
* hook's own pace. claudish drains the original itself, so leaving this unread delays nothing. */
|
|
409
|
+
response: Response;
|
|
410
|
+
}
|
|
411
|
+
export interface RawMiddleware {
|
|
412
|
+
/**
|
|
413
|
+
* Receives clones. They replace the live request only when this call returns (or its promise
|
|
414
|
+
* resolves) within its timeout. A throw, a rejection or a timeout discards every change.
|
|
415
|
+
*/
|
|
416
|
+
providerRequest?(ctx: RawProviderRequestContext): void | Promise<void>;
|
|
417
|
+
/**
|
|
418
|
+
* MUST be synchronous. Called on the parser's loop, on a clone of the chunk. On a normal return
|
|
419
|
+
* of anything but a thenable, the clone (whatever `ctx.chunk` holds then) is committed: it
|
|
420
|
+
* becomes the object the parser reads, deletions included. On a throw nothing is committed.
|
|
421
|
+
* A declared `async` function is rejected at load. Any other thenable return is an invalid
|
|
422
|
+
* invocation: the clone is discarded even if the hook mutated it before returning, the call is
|
|
423
|
+
* journaled `invalid` (`async_return`) and counts toward the circuit breaker, and a rejection
|
|
424
|
+
* handler is attached, so a rejection is journaled `error` and never becomes an
|
|
425
|
+
* unhandledRejection.
|
|
426
|
+
*/
|
|
427
|
+
providerChunk?(ctx: RawProviderChunkContext): void;
|
|
428
|
+
providerStreamEnd?(ctx: RawProviderStreamEndContext): void | Promise<void>;
|
|
429
|
+
/**
|
|
430
|
+
* Return a replacement Response, or nothing to keep the current one. A replacement is read
|
|
431
|
+
* whole and forwarded only when it completes cleanly; on any failure the original is forwarded.
|
|
432
|
+
* The client never sees a partial replacement. A replacement must close within this hook's
|
|
433
|
+
* timeout after the later of this call's return and the end of the original; the original's end
|
|
434
|
+
* never depends on whether `ctx.response` is read.
|
|
435
|
+
*/
|
|
436
|
+
response?(ctx: RawResponseContext): Response | void | Promise<Response | void>;
|
|
437
|
+
}
|
|
438
|
+
interface HookBase {
|
|
439
|
+
apiVersion: 1;
|
|
440
|
+
id: string;
|
|
441
|
+
description?: string;
|
|
442
|
+
/** default "fix" for user hooks */
|
|
443
|
+
defaultSeverity?: Severity;
|
|
444
|
+
/** default "normal" */
|
|
445
|
+
order?: HookOrder;
|
|
446
|
+
/** globs on RouteInfo.modelId; default "*"; proxy events only */
|
|
447
|
+
models?: string | ReadonlyArray<string>;
|
|
448
|
+
/** default "all"; proxy events only */
|
|
449
|
+
routes?: RouteScope;
|
|
450
|
+
/** per call; clamped to the event's maximum and to the event budget left when the hook's turn comes */
|
|
451
|
+
timeoutMs?: number;
|
|
452
|
+
on?: HookHandlers;
|
|
453
|
+
}
|
|
454
|
+
export interface SafeHook extends HookBase {
|
|
455
|
+
unsafe?: never;
|
|
456
|
+
raw?: never;
|
|
457
|
+
}
|
|
458
|
+
export interface UnsafeHook extends HookBase {
|
|
459
|
+
unsafe: true;
|
|
460
|
+
raw: RawMiddleware;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* One hook. Module-level state is per process: in `create_session` and team
|
|
464
|
+
* children, proxy events run in the pane child and session events in the MCP
|
|
465
|
+
* server or CLI process. Pass per-turn data from proxy events to `sessionIdle`
|
|
466
|
+
* with `annotateTurn`, never with module variables.
|
|
467
|
+
*/
|
|
468
|
+
export type HookDefinition = SafeHook | UnsafeHook;
|
|
469
|
+
/** Identity function: gives editor type-checking and inference. */
|
|
470
|
+
export declare function defineHook<const H extends HookDefinition>(hook: H): H;
|
|
471
|
+
/** Action constructors (plain data; equivalent to writing the object literal). */
|
|
472
|
+
export declare const act: {
|
|
473
|
+
injectSystemNote(text: string): InjectSystemNote;
|
|
474
|
+
appendUserText(text: string): AppendUserText;
|
|
475
|
+
replaceMessageText(find: string, replace: string, opts?: {
|
|
476
|
+
regex?: boolean;
|
|
477
|
+
flags?: string;
|
|
478
|
+
roles?: ReadonlyArray<"user" | "assistant">;
|
|
479
|
+
}): ReplaceMessageText;
|
|
480
|
+
removeMessageText(find: string, opts?: {
|
|
481
|
+
regex?: boolean;
|
|
482
|
+
flags?: string;
|
|
483
|
+
roles?: ReadonlyArray<"user" | "assistant">;
|
|
484
|
+
}): RemoveMessageText;
|
|
485
|
+
rewriteToolDescription(tool: string, append: string): RewriteToolDescription;
|
|
486
|
+
replaceToolDescription(tool: string, description: string): ReplaceToolDescription;
|
|
487
|
+
replaceToolSchema(tool: string, inputSchema: {
|
|
488
|
+
type: "object";
|
|
489
|
+
[k: string]: unknown;
|
|
490
|
+
}): ReplaceToolSchema;
|
|
491
|
+
removeTool(tool: string): RemoveTool;
|
|
492
|
+
repairToolArgs(args: Record<string, unknown>, reason: string): RepairToolArgs;
|
|
493
|
+
appendText(text: string): AppendText;
|
|
494
|
+
prependText(text: string): PrependText;
|
|
495
|
+
replaceText(find: string, replace: string, opts?: {
|
|
496
|
+
regex?: boolean;
|
|
497
|
+
flags?: string;
|
|
498
|
+
}): ReplaceText;
|
|
499
|
+
removeText(find: string, opts?: {
|
|
500
|
+
regex?: boolean;
|
|
501
|
+
flags?: string;
|
|
502
|
+
}): RemoveText;
|
|
503
|
+
injectUserTurn(text: string): InjectUserTurn;
|
|
504
|
+
annotateTurn(key: string, value: JsonValue): AnnotateTurn;
|
|
505
|
+
warn(message: string): Warn;
|
|
506
|
+
};
|
|
507
|
+
export {};
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/hooks/public.ts
|
|
3
|
+
var HOOK_API_VERSION = 1;
|
|
4
|
+
function defineHook(hook) {
|
|
5
|
+
return hook;
|
|
6
|
+
}
|
|
7
|
+
function matchOpts(opts) {
|
|
8
|
+
const out = {};
|
|
9
|
+
if (opts?.regex !== undefined)
|
|
10
|
+
out.regex = opts.regex;
|
|
11
|
+
if (opts?.flags !== undefined)
|
|
12
|
+
out.flags = opts.flags;
|
|
13
|
+
if (opts?.roles !== undefined)
|
|
14
|
+
out.roles = opts.roles;
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
var act = {
|
|
18
|
+
injectSystemNote: (text) => ({ type: "injectSystemNote", text }),
|
|
19
|
+
appendUserText: (text) => ({ type: "appendUserText", text }),
|
|
20
|
+
replaceMessageText: (find, replace, opts) => ({
|
|
21
|
+
type: "replaceMessageText",
|
|
22
|
+
find,
|
|
23
|
+
replace,
|
|
24
|
+
...matchOpts(opts)
|
|
25
|
+
}),
|
|
26
|
+
removeMessageText: (find, opts) => ({ type: "removeMessageText", find, ...matchOpts(opts) }),
|
|
27
|
+
rewriteToolDescription: (tool, append) => ({ type: "rewriteToolDescription", tool, append }),
|
|
28
|
+
replaceToolDescription: (tool, description) => ({
|
|
29
|
+
type: "replaceToolDescription",
|
|
30
|
+
tool,
|
|
31
|
+
description
|
|
32
|
+
}),
|
|
33
|
+
replaceToolSchema: (tool, inputSchema) => ({ type: "replaceToolSchema", tool, inputSchema }),
|
|
34
|
+
removeTool: (tool) => ({ type: "removeTool", tool }),
|
|
35
|
+
repairToolArgs: (args, reason) => ({ type: "repairToolArgs", args, reason }),
|
|
36
|
+
appendText: (text) => ({ type: "appendText", text }),
|
|
37
|
+
prependText: (text) => ({ type: "prependText", text }),
|
|
38
|
+
replaceText: (find, replace, opts) => ({
|
|
39
|
+
type: "replaceText",
|
|
40
|
+
find,
|
|
41
|
+
replace,
|
|
42
|
+
...matchOpts(opts)
|
|
43
|
+
}),
|
|
44
|
+
removeText: (find, opts) => ({ type: "removeText", find, ...matchOpts(opts) }),
|
|
45
|
+
injectUserTurn: (text) => ({ type: "injectUserTurn", text }),
|
|
46
|
+
annotateTurn: (key, value) => ({ type: "annotateTurn", key, value }),
|
|
47
|
+
warn: (message) => ({ type: "warn", message })
|
|
48
|
+
};
|
|
49
|
+
export {
|
|
50
|
+
HOOK_API_VERSION,
|
|
51
|
+
act,
|
|
52
|
+
defineHook
|
|
53
|
+
};
|