claudish 12.3.2 → 13.0.1

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.
@@ -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
+ };