@windowkit/agent-chat 0.0.0-stage → 0.1.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.
Files changed (164) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -2
  3. package/dist/core/driver.d.ts +183 -0
  4. package/dist/core/driver.d.ts.map +1 -0
  5. package/dist/core/driver.js +12 -0
  6. package/dist/core/driver.js.map +1 -0
  7. package/dist/core/index.d.ts +8 -0
  8. package/dist/core/index.d.ts.map +1 -0
  9. package/dist/core/index.js +11 -0
  10. package/dist/core/index.js.map +1 -0
  11. package/dist/core/markdown.d.ts +7 -0
  12. package/dist/core/markdown.d.ts.map +1 -0
  13. package/dist/core/markdown.js +53 -0
  14. package/dist/core/markdown.js.map +1 -0
  15. package/dist/core/policy.d.ts +18 -0
  16. package/dist/core/policy.d.ts.map +1 -0
  17. package/dist/core/policy.js +67 -0
  18. package/dist/core/policy.js.map +1 -0
  19. package/dist/core/reducer.d.ts +61 -0
  20. package/dist/core/reducer.d.ts.map +1 -0
  21. package/dist/core/reducer.js +391 -0
  22. package/dist/core/reducer.js.map +1 -0
  23. package/dist/core/session.d.ts +105 -0
  24. package/dist/core/session.d.ts.map +1 -0
  25. package/dist/core/session.js +648 -0
  26. package/dist/core/session.js.map +1 -0
  27. package/dist/core/tools.d.ts +120 -0
  28. package/dist/core/tools.d.ts.map +1 -0
  29. package/dist/core/tools.js +120 -0
  30. package/dist/core/tools.js.map +1 -0
  31. package/dist/core/types.d.ts +295 -0
  32. package/dist/core/types.d.ts.map +1 -0
  33. package/dist/core/types.js +21 -0
  34. package/dist/core/types.js.map +1 -0
  35. package/dist/drivers/claude/index.d.ts +20 -0
  36. package/dist/drivers/claude/index.d.ts.map +1 -0
  37. package/dist/drivers/claude/index.js +727 -0
  38. package/dist/drivers/claude/index.js.map +1 -0
  39. package/dist/drivers/claude/input.d.ts +7 -0
  40. package/dist/drivers/claude/input.d.ts.map +1 -0
  41. package/dist/drivers/claude/input.js +46 -0
  42. package/dist/drivers/claude/input.js.map +1 -0
  43. package/dist/drivers/claude/peers.d.ts +16 -0
  44. package/dist/drivers/claude/peers.d.ts.map +1 -0
  45. package/dist/drivers/claude/peers.js +137 -0
  46. package/dist/drivers/claude/peers.js.map +1 -0
  47. package/dist/drivers/claude/schema.d.ts +5 -0
  48. package/dist/drivers/claude/schema.d.ts.map +1 -0
  49. package/dist/drivers/claude/schema.js +77 -0
  50. package/dist/drivers/claude/schema.js.map +1 -0
  51. package/dist/drivers/claude/sdk.d.ts +86 -0
  52. package/dist/drivers/claude/sdk.d.ts.map +1 -0
  53. package/dist/drivers/claude/sdk.js +47 -0
  54. package/dist/drivers/claude/sdk.js.map +1 -0
  55. package/dist/drivers/claude/tools.d.ts +18 -0
  56. package/dist/drivers/claude/tools.d.ts.map +1 -0
  57. package/dist/drivers/claude/tools.js +119 -0
  58. package/dist/drivers/claude/tools.js.map +1 -0
  59. package/dist/index.d.ts +8 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +12 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/testing/fake.d.ts +63 -0
  64. package/dist/testing/fake.d.ts.map +1 -0
  65. package/dist/testing/fake.js +270 -0
  66. package/dist/testing/fake.js.map +1 -0
  67. package/dist/testing/index.d.ts +2 -0
  68. package/dist/testing/index.d.ts.map +1 -0
  69. package/dist/testing/index.js +4 -0
  70. package/dist/testing/index.js.map +1 -0
  71. package/dist/view/AgentChat.d.ts +53 -0
  72. package/dist/view/AgentChat.d.ts.map +1 -0
  73. package/dist/view/AgentChat.js +87 -0
  74. package/dist/view/AgentChat.js.map +1 -0
  75. package/dist/view/CodeBlock.d.ts +9 -0
  76. package/dist/view/CodeBlock.d.ts.map +1 -0
  77. package/dist/view/CodeBlock.js +60 -0
  78. package/dist/view/CodeBlock.js.map +1 -0
  79. package/dist/view/Composer.d.ts +24 -0
  80. package/dist/view/Composer.d.ts.map +1 -0
  81. package/dist/view/Composer.js +144 -0
  82. package/dist/view/Composer.js.map +1 -0
  83. package/dist/view/Header.d.ts +3 -0
  84. package/dist/view/Header.d.ts.map +1 -0
  85. package/dist/view/Header.js +30 -0
  86. package/dist/view/Header.js.map +1 -0
  87. package/dist/view/MessageList.d.ts +9 -0
  88. package/dist/view/MessageList.d.ts.map +1 -0
  89. package/dist/view/MessageList.js +111 -0
  90. package/dist/view/MessageList.js.map +1 -0
  91. package/dist/view/QuestionCard.d.ts +6 -0
  92. package/dist/view/QuestionCard.d.ts.map +1 -0
  93. package/dist/view/QuestionCard.js +39 -0
  94. package/dist/view/QuestionCard.js.map +1 -0
  95. package/dist/view/TextBlock.d.ts +9 -0
  96. package/dist/view/TextBlock.d.ts.map +1 -0
  97. package/dist/view/TextBlock.js +22 -0
  98. package/dist/view/TextBlock.js.map +1 -0
  99. package/dist/view/ToolCard.d.ts +7 -0
  100. package/dist/view/ToolCard.d.ts.map +1 -0
  101. package/dist/view/ToolCard.js +155 -0
  102. package/dist/view/ToolCard.js.map +1 -0
  103. package/dist/view/Transcript.d.ts +5 -0
  104. package/dist/view/Transcript.d.ts.map +1 -0
  105. package/dist/view/Transcript.js +47 -0
  106. package/dist/view/Transcript.js.map +1 -0
  107. package/dist/view/context.d.ts +21 -0
  108. package/dist/view/context.d.ts.map +1 -0
  109. package/dist/view/context.js +37 -0
  110. package/dist/view/context.js.map +1 -0
  111. package/dist/view/fences.d.ts +13 -0
  112. package/dist/view/fences.d.ts.map +1 -0
  113. package/dist/view/fences.js +59 -0
  114. package/dist/view/fences.js.map +1 -0
  115. package/dist/view/files.d.ts +11 -0
  116. package/dist/view/files.d.ts.map +1 -0
  117. package/dist/view/files.js +109 -0
  118. package/dist/view/files.js.map +1 -0
  119. package/dist/view/look.d.ts +18 -0
  120. package/dist/view/look.d.ts.map +1 -0
  121. package/dist/view/look.js +37 -0
  122. package/dist/view/look.js.map +1 -0
  123. package/dist/view/types.d.ts +50 -0
  124. package/dist/view/types.d.ts.map +1 -0
  125. package/dist/view/types.js +14 -0
  126. package/dist/view/types.js.map +1 -0
  127. package/dist/view/use-agent.d.ts +11 -0
  128. package/dist/view/use-agent.d.ts.map +1 -0
  129. package/dist/view/use-agent.js +33 -0
  130. package/dist/view/use-agent.js.map +1 -0
  131. package/package.json +92 -4
  132. package/src/core/driver.ts +191 -0
  133. package/src/core/index.ts +54 -0
  134. package/src/core/markdown.ts +56 -0
  135. package/src/core/policy.ts +77 -0
  136. package/src/core/reducer.ts +490 -0
  137. package/src/core/session.ts +824 -0
  138. package/src/core/tools.ts +233 -0
  139. package/src/core/types.ts +321 -0
  140. package/src/drivers/claude/index.ts +825 -0
  141. package/src/drivers/claude/input.ts +47 -0
  142. package/src/drivers/claude/peers.ts +153 -0
  143. package/src/drivers/claude/schema.ts +99 -0
  144. package/src/drivers/claude/sdk.ts +138 -0
  145. package/src/drivers/claude/tools.ts +141 -0
  146. package/src/index.ts +51 -0
  147. package/src/testing/fake.ts +374 -0
  148. package/src/testing/index.ts +11 -0
  149. package/src/view/AgentChat.tsx +204 -0
  150. package/src/view/CodeBlock.tsx +108 -0
  151. package/src/view/Composer.tsx +230 -0
  152. package/src/view/Header.tsx +73 -0
  153. package/src/view/MessageList.tsx +290 -0
  154. package/src/view/QuestionCard.tsx +107 -0
  155. package/src/view/TextBlock.tsx +58 -0
  156. package/src/view/ToolCard.tsx +283 -0
  157. package/src/view/Transcript.tsx +81 -0
  158. package/src/view/context.ts +70 -0
  159. package/src/view/dom-globals.d.ts +10 -0
  160. package/src/view/fences.ts +73 -0
  161. package/src/view/files.ts +111 -0
  162. package/src/view/look.ts +53 -0
  163. package/src/view/types.ts +71 -0
  164. package/src/view/use-agent.ts +40 -0
@@ -0,0 +1,825 @@
1
+ // The Claude driver: Claude Code, through the Claude Agent SDK, in-process.
2
+ //
3
+ // One `query()` in streaming-input mode is one harness process for the
4
+ // session's life; each `send` is a user message pushed into its input. What
5
+ // the SDK yields is mapped to `DriverEvent`s, and every question it asks the
6
+ // client goes to the session:
7
+ //
8
+ // - **A `PreToolUse` hook sees every call** and asks the session's gate —
9
+ // the app's `onToolCall`, then the policy. The gate never waits on the
10
+ // user, because a hook has a timeout. When the user must decide (or the
11
+ // session is paused), the hook answers `ask`, and the harness routes the
12
+ // call to `canUseTool`, which has none.
13
+ // - **`canUseTool` is the approval row**, and `AskUserQuestion`'s form.
14
+ // - **The app's tools run in this process**, in an SDK MCP server: the
15
+ // handler is the app's function. `toolAliases` routes a built-in the app
16
+ // replaced (`coreTools: { shell: tool }`) to it, so a model that calls
17
+ // `Bash` anyway reaches the app's function.
18
+
19
+ import { stat } from 'node:fs/promises';
20
+
21
+ import { AgentUnavailableError } from '../../core/driver.js';
22
+ import type {
23
+ AgentDriver,
24
+ DriverHost,
25
+ DriverSession,
26
+ GateDecision,
27
+ OutgoingMessage,
28
+ } from '../../core/driver.js';
29
+ import { jsonSchemaOf } from '../../core/tools.js';
30
+ import type {
31
+ Capabilities,
32
+ CoreToolKey,
33
+ PermissionPolicy,
34
+ PlanEntry,
35
+ Question,
36
+ ToolCall,
37
+ } from '../../core/types.js';
38
+ import { InputQueue } from './input.js';
39
+ import { incomingOf, originOf, peerEnvelope } from './peers.js';
40
+ import { shapeFromJsonSchema, zodShapeOf } from './schema.js';
41
+ import {
42
+ loadClaudeSdk,
43
+ loadZod,
44
+ type ClaudeSdk,
45
+ type SdkMessage,
46
+ type SdkQuery,
47
+ type SdkUserMessage,
48
+ type ZodLike,
49
+ } from './sdk.js';
50
+ import {
51
+ APP_PREFIX,
52
+ APP_SERVER,
53
+ CATEGORY_TOOLS,
54
+ HIDDEN_TOOLS,
55
+ isAppTool,
56
+ kindOf,
57
+ labelOf,
58
+ outputOf,
59
+ titleOf,
60
+ } from './tools.js';
61
+
62
+ export type { ClaudeSdk, SdkMessage, SdkQuery, SdkUserMessage } from './sdk.js';
63
+
64
+ export interface ClaudeDriverOptions {
65
+ /** The SDK module. Default: `@anthropic-ai/claude-agent-sdk`, imported
66
+ * when the session starts. A test hands a fake. */
67
+ sdk?: ClaudeSdk;
68
+ /** Where to import the SDK from, when `sdk` is not given. Default
69
+ * `@anthropic-ai/claude-agent-sdk`. */
70
+ module?: string;
71
+ }
72
+
73
+ export const CLAUDE_CAPABILITIES: Capabilities = Object.freeze({
74
+ tools: 'in-process',
75
+ systemPrompt: Object.freeze({ replace: true, append: true }),
76
+ coreTools: 'remove',
77
+ gate: 'every-call',
78
+ steer: true,
79
+ fork: false,
80
+ rewindFiles: false,
81
+ models: true,
82
+ modes: true,
83
+ images: true,
84
+ }) as Capabilities;
85
+
86
+ /** Claude Code's permission modes, as the mode picker offers them. */
87
+ export const CLAUDE_MODES = Object.freeze([
88
+ { id: 'default', label: 'Ask before changes' },
89
+ { id: 'acceptEdits', label: 'Accept edits' },
90
+ { id: 'plan', label: 'Plan first' },
91
+ { id: 'auto', label: 'Auto' },
92
+ ]);
93
+
94
+ /** The policy as a starting permission mode. Always passed: the SDK's docs
95
+ * and its declarations disagree about where a session starts otherwise. */
96
+ const MODES: Readonly<Record<PermissionPolicy, string>> = {
97
+ ask: 'default',
98
+ edits: 'acceptEdits',
99
+ 'read-only': 'default',
100
+ auto: 'auto',
101
+ };
102
+
103
+ export function claudeDriver(options: ClaudeDriverOptions = {}): AgentDriver {
104
+ return {
105
+ name: 'claude',
106
+ async start(host) {
107
+ const sdk = options.sdk ?? (await loadClaudeSdk(options.module));
108
+ return startClaude(sdk, host);
109
+ },
110
+ };
111
+ }
112
+
113
+ async function startClaude(
114
+ sdk: ClaudeSdk,
115
+ host: DriverHost,
116
+ ): Promise<DriverSession> {
117
+ const config = host.config;
118
+ // Unless the app runs Claude Code somewhere else, its directories are
119
+ // this machine's.
120
+ if (!config.driverOptions.spawnClaudeCodeProcess) {
121
+ await mustBeDirectories([config.cwd, ...config.folders]);
122
+ }
123
+ const input = new InputQueue<SdkUserMessage>();
124
+
125
+ // --- what is turned off, and what is replaced ----------------------------
126
+ const disallowed = new Set<string>();
127
+ const aliases: Record<string, string> = {};
128
+ const namesOf = (key: string) => CATEGORY_TOOLS[key as CoreToolKey] ?? [key];
129
+ for (const key of config.removed)
130
+ for (const n of namesOf(key)) disallowed.add(n);
131
+ for (const [key, tool] of Object.entries(config.replaced)) {
132
+ for (const n of namesOf(key)) {
133
+ disallowed.add(n);
134
+ aliases[n] = `${APP_PREFIX}${tool}`;
135
+ }
136
+ }
137
+ if (config.permissions === 'read-only') {
138
+ for (const n of CATEGORY_TOOLS.edit) disallowed.add(n);
139
+ }
140
+
141
+ // --- the app's tools, in this process ------------------------------------
142
+ // The SDK's handler is not told which tool_use it serves, so the hook,
143
+ // which is, queues the ids per tool, and the handler takes the next one.
144
+ const callIds = new Map<string, string[]>();
145
+ const queueCall = (label: string, id: string) => {
146
+ const ids = callIds.get(label) ?? [];
147
+ if (!ids.includes(id)) ids.push(id);
148
+ callIds.set(label, ids);
149
+ };
150
+ const dropCall = (label: string, id: string) => {
151
+ const ids = callIds.get(label);
152
+ if (ids)
153
+ callIds.set(
154
+ label,
155
+ ids.filter((x) => x !== id),
156
+ );
157
+ };
158
+ let zod: ZodLike | null = null;
159
+ const sdkTools: unknown[] = [];
160
+ for (const [name, def] of config.tools) {
161
+ let shape = zodShapeOf(def.parameters);
162
+ if (!shape) {
163
+ zod ??= await loadZod();
164
+ shape = shapeFromJsonSchema(jsonSchemaOf(def.parameters), zod);
165
+ }
166
+ sdkTools.push(
167
+ sdk.tool(name, def.description, shape, (args) =>
168
+ host.runTool(name, args, { callId: callIds.get(name)?.shift() }),
169
+ ),
170
+ );
171
+ }
172
+
173
+ // --- tool calls, as the session sees them --------------------------------
174
+ const seen = new Map<string, { name: string; input: unknown }>();
175
+ const denied = new Set<string>();
176
+ const hidden = new Set<string>();
177
+
178
+ const callOf = (name: string, toolInput: unknown, id: string): ToolCall => {
179
+ const resolved = aliases[name] ?? name;
180
+ const app = isAppTool(resolved);
181
+ const label = labelOf(resolved);
182
+ const def = app ? config.tools.get(label) : undefined;
183
+ return {
184
+ id,
185
+ name,
186
+ label,
187
+ kind: def?.kind ?? kindOf(name),
188
+ title: def
189
+ ? (safeTitle(def.title, toolInput) ?? label)
190
+ : titleOf(name, toolInput),
191
+ input: toolInput,
192
+ app,
193
+ };
194
+ };
195
+
196
+ const preToolUse = async (hookInput: unknown) => {
197
+ const i = hookInput as {
198
+ tool_name: string;
199
+ tool_input: unknown;
200
+ tool_use_id: string;
201
+ };
202
+ if (HIDDEN_TOOLS.has(i.tool_name) || i.tool_name === 'ExitPlanMode')
203
+ return {};
204
+ const call = callOf(i.tool_name, i.tool_input, i.tool_use_id);
205
+ const decision = await host.gate(call);
206
+ if (decision.behavior === 'deny') denied.add(call.id);
207
+ else if (call.app) queueCall(call.label, call.id);
208
+ return hookOutput(decision);
209
+ };
210
+
211
+ const canUseTool = async (
212
+ toolName: string,
213
+ toolInput: Record<string, unknown>,
214
+ opts: { signal: AbortSignal; toolUseID: string },
215
+ ) => {
216
+ if (toolName === 'AskUserQuestion') {
217
+ const answers = await host.ask(
218
+ opts.toolUseID,
219
+ questionsOf(toolInput),
220
+ opts.signal,
221
+ );
222
+ if (!answers) {
223
+ return {
224
+ behavior: 'deny',
225
+ message: 'The user dismissed the question.',
226
+ };
227
+ }
228
+ return { behavior: 'allow', updatedInput: { ...toolInput, answers } };
229
+ }
230
+ const call = callOf(toolName, toolInput, opts.toolUseID);
231
+ const result = await host.approve(call, opts.signal);
232
+ if (result.behavior === 'allow') {
233
+ if (call.app) queueCall(call.label, call.id);
234
+ return {
235
+ behavior: 'allow',
236
+ updatedInput:
237
+ (result.input as Record<string, unknown> | undefined) ?? toolInput,
238
+ };
239
+ }
240
+ denied.add(call.id);
241
+ if (call.app) dropCall(call.label, call.id);
242
+ return {
243
+ behavior: 'deny',
244
+ message: result.message,
245
+ ...(result.interrupt ? { interrupt: true } : {}),
246
+ };
247
+ };
248
+
249
+ // --- the query ------------------------------------------------------------
250
+ // The app's tools are not put in `allowedTools`: the hook's gate allows
251
+ // them (the policy runs an app tool unless its `ask` says otherwise), so
252
+ // every call takes one path, `onToolCall` included. A bare `allowedTools`
253
+ // entry would also keep `canUseTool` from an `ask: true` tool, and the SDK
254
+ // warns about exactly that at every start.
255
+ const options: Record<string, unknown> = {
256
+ cwd: config.cwd,
257
+ systemPrompt: config.systemPrompt
258
+ ? [config.systemPrompt, config.instructions].filter(Boolean).join('\n\n')
259
+ : {
260
+ type: 'preset',
261
+ preset: 'claude_code',
262
+ ...(config.instructions ? { append: config.instructions } : {}),
263
+ },
264
+ settingSources:
265
+ config.settings === 'isolated' ? [] : ['user', 'project', 'local'],
266
+ permissionMode: config.mode ?? MODES[config.permissions],
267
+ includePartialMessages: true,
268
+ // Thinking as text to show. On Opus 4.7 and later the SDK's default is
269
+ // `display: 'omitted'`: a thinking block arrives with no text, only its
270
+ // signature, and the chat had an empty "Thought" to open. `adaptive` is
271
+ // already the default for the models that think; `driverOptions.thinking`
272
+ // overrides it, for an older model or for none.
273
+ thinking: { type: 'adaptive', display: 'summarized' },
274
+ hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
275
+ canUseTool,
276
+ env: {
277
+ ...process.env,
278
+ CLAUDE_AGENT_SDK_CLIENT_APP: '@windowkit/agent-chat',
279
+ },
280
+ };
281
+ if (config.folders.length)
282
+ options.additionalDirectories = [...config.folders];
283
+ if (disallowed.size) options.disallowedTools = [...disallowed];
284
+ if (Object.keys(aliases).length) options.toolAliases = aliases;
285
+ if (sdkTools.length) {
286
+ options.mcpServers = {
287
+ [APP_SERVER]: sdk.createSdkMcpServer({
288
+ name: APP_SERVER,
289
+ version: '1.0.0',
290
+ tools: sdkTools,
291
+ }),
292
+ };
293
+ }
294
+ if (config.model) options.model = config.model;
295
+ if (config.resume) options.resume = config.resume;
296
+ Object.assign(options, config.driverOptions);
297
+
298
+ const query = sdk.query({ prompt: input, options });
299
+
300
+ host.emit({
301
+ type: 'info',
302
+ info: {
303
+ agent: 'claude',
304
+ modes: CLAUDE_MODES,
305
+ mode: options.permissionMode as string,
306
+ },
307
+ });
308
+
309
+ // --- what the query yields ------------------------------------------------
310
+ let inTurn = false;
311
+ let swallow = 0;
312
+ let received = 0;
313
+ let asked = false;
314
+ let message = '';
315
+ const blocks = new Map<
316
+ number,
317
+ { kind: 'text' | 'reasoning' | 'tool'; id: string }
318
+ >();
319
+ const streamed = new Map<string, { text: string[]; reasoning: string[] }>();
320
+ const finished = new Map<string, { text: number; reasoning: number }>();
321
+
322
+ const handle = (m: SdkMessage) => {
323
+ const parent =
324
+ (m.parent_tool_use_id as string | null | undefined) ?? undefined;
325
+ switch (m.type) {
326
+ case 'system':
327
+ return onSystem(m);
328
+ case 'stream_event':
329
+ return onStream(m.event as Record<string, unknown>, parent);
330
+ case 'assistant':
331
+ return onAssistant(m, parent);
332
+ case 'user': {
333
+ if (m.isReplay) return;
334
+ const incoming = incomingOf(m);
335
+ if (!incoming) return onToolResults(m);
336
+ // a turn Claude Code took from another session or a channel: it is
337
+ // acting on it, so a stop has a result to swallow
338
+ inTurn = true;
339
+ host.emit({
340
+ type: 'message',
341
+ id: typeof m.uuid === 'string' ? `in:${m.uuid}` : `in:${++received}`,
342
+ ...incoming,
343
+ });
344
+ return;
345
+ }
346
+ case 'result':
347
+ return onResult(m);
348
+ }
349
+ };
350
+
351
+ const onSystem = (m: SdkMessage) => {
352
+ if (m.subtype === 'init') {
353
+ host.emit({
354
+ type: 'info',
355
+ info: {
356
+ ...(typeof m.session_id === 'string' ? { id: m.session_id } : {}),
357
+ ...(typeof m.model === 'string' ? { model: m.model } : {}),
358
+ ...(typeof m.cwd === 'string' ? { cwd: m.cwd } : {}),
359
+ ...(typeof m.permissionMode === 'string'
360
+ ? { mode: m.permissionMode }
361
+ : {}),
362
+ ...(Array.isArray(m.slash_commands)
363
+ ? {
364
+ commands: (m.slash_commands as string[]).map((name) => ({
365
+ name,
366
+ })),
367
+ }
368
+ : {}),
369
+ },
370
+ });
371
+ if (!asked) {
372
+ asked = true;
373
+ void describeHarness(query, host);
374
+ }
375
+ } else if (m.subtype === 'compact_boundary') {
376
+ host.emit({
377
+ type: 'notice',
378
+ level: 'info',
379
+ text: 'The conversation was compacted.',
380
+ });
381
+ }
382
+ };
383
+
384
+ const onStream = (
385
+ event: Record<string, unknown>,
386
+ parent: string | undefined,
387
+ ) => {
388
+ switch (event.type) {
389
+ case 'message_start': {
390
+ message = String(
391
+ (event.message as { id?: unknown } | undefined)?.id ?? '',
392
+ );
393
+ blocks.clear();
394
+ return;
395
+ }
396
+ case 'content_block_start': {
397
+ const index = Number(event.index);
398
+ const block = event.content_block as Record<string, unknown>;
399
+ if (block.type === 'text' || block.type === 'thinking') {
400
+ const kind = block.type === 'text' ? 'text' : 'reasoning';
401
+ const id = `${message}:${index}`;
402
+ blocks.set(index, { kind, id });
403
+ const list = streamed.get(message) ?? { text: [], reasoning: [] };
404
+ list[kind].push(id);
405
+ streamed.set(message, list);
406
+ } else if (block.type === 'tool_use') {
407
+ const id = String(block.id);
408
+ const name = String(block.name);
409
+ blocks.set(index, { kind: 'tool', id });
410
+ seen.set(id, { name, input: undefined });
411
+ if (HIDDEN_TOOLS.has(name)) {
412
+ hidden.add(id);
413
+ return;
414
+ }
415
+ const call = callOf(name, undefined, id);
416
+ host.emit({
417
+ type: 'tool-start',
418
+ id,
419
+ name,
420
+ label: labelOf(aliases[name] ?? name),
421
+ kind: call.kind,
422
+ app: call.app,
423
+ ...(parent ? { parent } : {}),
424
+ });
425
+ }
426
+ return;
427
+ }
428
+ case 'content_block_delta': {
429
+ const block = blocks.get(Number(event.index));
430
+ const delta = event.delta as Record<string, unknown>;
431
+ if (!block) return;
432
+ if (delta.type === 'text_delta' && block.kind === 'text') {
433
+ host.emit({
434
+ type: 'text',
435
+ id: block.id,
436
+ delta: String(delta.text ?? ''),
437
+ });
438
+ } else if (
439
+ delta.type === 'thinking_delta' &&
440
+ block.kind === 'reasoning'
441
+ ) {
442
+ host.emit({
443
+ type: 'text',
444
+ id: block.id,
445
+ reasoning: true,
446
+ delta: String(delta.thinking ?? ''),
447
+ });
448
+ } else if (delta.type === 'input_json_delta' && block.kind === 'tool') {
449
+ if (!hidden.has(block.id)) {
450
+ host.emit({
451
+ type: 'tool-input',
452
+ id: block.id,
453
+ delta: String(delta.partial_json ?? ''),
454
+ });
455
+ }
456
+ }
457
+ return;
458
+ }
459
+ }
460
+ };
461
+
462
+ const onAssistant = (m: SdkMessage, parent: string | undefined) => {
463
+ const msg = m.message as { id?: unknown; content?: unknown } | undefined;
464
+ const msgId = String(msg?.id ?? m.uuid ?? '');
465
+ const content = Array.isArray(msg?.content)
466
+ ? (msg.content as Record<string, unknown>[])
467
+ : [];
468
+ for (const block of content) {
469
+ if (block.type === 'text' || block.type === 'thinking') {
470
+ const kind = block.type === 'text' ? 'text' : 'reasoning';
471
+ const count = finished.get(msgId) ?? { text: 0, reasoning: 0 };
472
+ const k = count[kind]++;
473
+ finished.set(msgId, count);
474
+ const id = streamed.get(msgId)?.[kind][k] ?? `${msgId}:${kind}${k}`;
475
+ const text = String(
476
+ (kind === 'text' ? block.text : block.thinking) ?? '',
477
+ );
478
+ host.emit({
479
+ type: 'text',
480
+ id,
481
+ text,
482
+ done: true,
483
+ ...(kind === 'reasoning' ? { reasoning: true } : {}),
484
+ ...(parent ? { parent } : {}),
485
+ });
486
+ } else if (block.type === 'tool_use') {
487
+ const id = String(block.id);
488
+ const name = String(block.name);
489
+ seen.set(id, { name, input: block.input });
490
+ if (name === 'TodoWrite') {
491
+ hidden.add(id);
492
+ host.emit({
493
+ type: 'plan',
494
+ entries: planOf(block.input),
495
+ ...(parent ? { parent } : {}),
496
+ });
497
+ continue;
498
+ }
499
+ if (HIDDEN_TOOLS.has(name)) {
500
+ hidden.add(id);
501
+ continue;
502
+ }
503
+ const call = callOf(name, block.input, id);
504
+ host.emit({
505
+ type: 'tool-start',
506
+ id,
507
+ name,
508
+ label: labelOf(aliases[name] ?? name),
509
+ kind: call.kind,
510
+ title: call.title,
511
+ input: block.input,
512
+ app: call.app,
513
+ ...(parent ? { parent } : {}),
514
+ });
515
+ }
516
+ }
517
+ };
518
+
519
+ const onToolResults = (m: SdkMessage) => {
520
+ const msg = m.message as { content?: unknown } | undefined;
521
+ if (!Array.isArray(msg?.content)) return;
522
+ for (const block of msg.content as Record<string, unknown>[]) {
523
+ if (block.type !== 'tool_result') continue;
524
+ const id = String(block.tool_use_id);
525
+ if (hidden.has(id)) continue;
526
+ const call = seen.get(id);
527
+ const isError = block.is_error === true;
528
+ const text = resultText(block.content);
529
+ const name = call?.name ?? '';
530
+ const app = isAppTool(aliases[name] ?? name);
531
+ if (denied.has(id)) {
532
+ host.emit({ type: 'tool-end', id, state: 'denied', error: text });
533
+ continue;
534
+ }
535
+ host.emit({
536
+ type: 'tool-end',
537
+ id,
538
+ state: isError ? 'error' : 'done',
539
+ ...(isError ? { error: text } : {}),
540
+ // an app tool's outputs are the session's, from the value `run` returned
541
+ ...(app ? {} : { output: outputOf(name, call?.input, text, isError) }),
542
+ });
543
+ }
544
+ };
545
+
546
+ const onResult = (m: SdkMessage) => {
547
+ const usage = (m.usage ?? {}) as {
548
+ input_tokens?: number;
549
+ output_tokens?: number;
550
+ };
551
+ host.emit({
552
+ type: 'usage',
553
+ usage: {
554
+ ...(typeof m.total_cost_usd === 'number'
555
+ ? { costUsd: m.total_cost_usd }
556
+ : {}),
557
+ ...(typeof usage.input_tokens === 'number'
558
+ ? { inputTokens: usage.input_tokens }
559
+ : {}),
560
+ ...(typeof usage.output_tokens === 'number'
561
+ ? { outputTokens: usage.output_tokens }
562
+ : {}),
563
+ ...(typeof m.num_turns === 'number' ? { turns: m.num_turns } : {}),
564
+ },
565
+ });
566
+ inTurn = false;
567
+ if (swallow > 0) {
568
+ swallow--;
569
+ return;
570
+ }
571
+ if (m.subtype === 'success') {
572
+ host.emit({ type: 'turn-end', reason: 'done' });
573
+ return;
574
+ }
575
+ const errors = Array.isArray(m.errors)
576
+ ? (m.errors as unknown[]).map(String)
577
+ : [];
578
+ const limit =
579
+ m.subtype === 'error_max_turns' || m.subtype === 'error_max_budget_usd';
580
+ host.emit({
581
+ type: 'turn-end',
582
+ reason: limit ? 'limit' : 'error',
583
+ error:
584
+ errors.join('\n') ||
585
+ (limit
586
+ ? 'The turn reached its limit.'
587
+ : `The turn failed (${String(m.subtype)}).`),
588
+ });
589
+ };
590
+
591
+ void (async () => {
592
+ try {
593
+ for await (const m of query) handle(m);
594
+ if (inTurn) {
595
+ inTurn = false;
596
+ host.emit({
597
+ type: 'turn-end',
598
+ reason: 'error',
599
+ error: 'Claude Code exited.',
600
+ });
601
+ }
602
+ } catch (err) {
603
+ inTurn = false;
604
+ host.emit({
605
+ type: 'turn-end',
606
+ reason: 'error',
607
+ error: err instanceof Error ? err.message : String(err),
608
+ });
609
+ }
610
+ })();
611
+
612
+ return {
613
+ capabilities: CLAUDE_CAPABILITIES,
614
+ send(outgoing: OutgoingMessage) {
615
+ if (!outgoing.steer) inTurn = true;
616
+ const from = outgoing.from;
617
+ input.push({
618
+ type: 'user',
619
+ message: {
620
+ role: 'user',
621
+ content: contentOf(
622
+ from
623
+ ? { ...outgoing, text: peerEnvelope(from, outgoing.text) }
624
+ : outgoing,
625
+ ),
626
+ },
627
+ parent_tool_use_id: null,
628
+ origin: originOf(from),
629
+ // another session's words, delivered as written: no `@path`
630
+ // expansion, no slash commands
631
+ ...(from ? { client_composed: true as const } : {}),
632
+ ...(outgoing.steer ? { priority: 'next' as const } : {}),
633
+ });
634
+ },
635
+ async interrupt() {
636
+ if (inTurn) {
637
+ inTurn = false;
638
+ swallow++;
639
+ }
640
+ await query.interrupt();
641
+ },
642
+ async setModel(id: string) {
643
+ await query.setModel?.(id);
644
+ },
645
+ async setMode(id: string) {
646
+ await query.setPermissionMode?.(id);
647
+ },
648
+ async close() {
649
+ input.end();
650
+ query.close?.();
651
+ },
652
+ };
653
+ }
654
+
655
+ function hookOutput(decision: GateDecision): Record<string, unknown> {
656
+ switch (decision.behavior) {
657
+ case 'defer':
658
+ return {};
659
+ case 'allow':
660
+ return {
661
+ hookSpecificOutput: {
662
+ hookEventName: 'PreToolUse',
663
+ permissionDecision: 'allow',
664
+ ...(decision.input !== undefined
665
+ ? { updatedInput: decision.input }
666
+ : {}),
667
+ },
668
+ };
669
+ case 'deny':
670
+ return {
671
+ hookSpecificOutput: {
672
+ hookEventName: 'PreToolUse',
673
+ permissionDecision: 'deny',
674
+ permissionDecisionReason: decision.message,
675
+ },
676
+ };
677
+ case 'ask':
678
+ return {
679
+ hookSpecificOutput: {
680
+ hookEventName: 'PreToolUse',
681
+ permissionDecision: 'ask',
682
+ },
683
+ };
684
+ }
685
+ }
686
+
687
+ /** The models and the commands, once the harness is up. Best effort: an
688
+ * older SDK without the methods leaves the lists as `init` gave them. */
689
+ async function describeHarness(
690
+ query: SdkQuery,
691
+ host: DriverHost,
692
+ ): Promise<void> {
693
+ try {
694
+ const models = await query.supportedModels?.();
695
+ if (models?.length) {
696
+ host.emit({
697
+ type: 'info',
698
+ info: {
699
+ models: models.map((m) => ({
700
+ id: m.value,
701
+ label: m.displayName,
702
+ ...(m.description ? { description: m.description } : {}),
703
+ })),
704
+ },
705
+ });
706
+ }
707
+ } catch {
708
+ // the picker stays empty
709
+ }
710
+ try {
711
+ const commands = await query.supportedCommands?.();
712
+ if (commands?.length) {
713
+ host.emit({
714
+ type: 'info',
715
+ info: {
716
+ commands: commands.map((c) => ({
717
+ name: c.name,
718
+ ...(c.description ? { description: c.description } : {}),
719
+ })),
720
+ },
721
+ });
722
+ }
723
+ } catch {
724
+ // the commands stay as `init` named them
725
+ }
726
+ }
727
+
728
+ function contentOf(
729
+ message: OutgoingMessage,
730
+ ): SdkUserMessage['message']['content'] {
731
+ if (!message.attachments.length) return message.text;
732
+ const blocks: Array<Record<string, unknown>> = [];
733
+ if (message.text) blocks.push({ type: 'text', text: message.text });
734
+ for (const a of message.attachments) {
735
+ if (a.kind === 'image' && a.data) {
736
+ blocks.push({
737
+ type: 'image',
738
+ source: {
739
+ type: 'base64',
740
+ media_type: a.mime ?? 'image/png',
741
+ data: a.data,
742
+ },
743
+ });
744
+ } else if (a.kind === 'paste' && a.data) {
745
+ blocks.push({ type: 'text', text: a.data });
746
+ } else if (a.path) {
747
+ blocks.push({ type: 'text', text: `@${a.path}` });
748
+ }
749
+ }
750
+ return blocks;
751
+ }
752
+
753
+ function resultText(content: unknown): string {
754
+ if (typeof content === 'string') return content;
755
+ if (!Array.isArray(content)) return '';
756
+ return content
757
+ .map((c) =>
758
+ c && typeof c === 'object' && (c as { type?: unknown }).type === 'text'
759
+ ? String((c as { text?: unknown }).text ?? '')
760
+ : '',
761
+ )
762
+ .filter(Boolean)
763
+ .join('\n');
764
+ }
765
+
766
+ function planOf(input: unknown): PlanEntry[] {
767
+ const todos = (input as { todos?: unknown } | undefined)?.todos;
768
+ if (!Array.isArray(todos)) return [];
769
+ return todos.map((t: Record<string, unknown>) => ({
770
+ text: String(t.content ?? t.activeForm ?? ''),
771
+ status:
772
+ t.status === 'completed' || t.status === 'in_progress'
773
+ ? t.status
774
+ : 'pending',
775
+ }));
776
+ }
777
+
778
+ function questionsOf(input: Record<string, unknown>): Question[] {
779
+ const questions = Array.isArray(input.questions) ? input.questions : [];
780
+ return questions.map((q: Record<string, unknown>) => ({
781
+ question: String(q.question ?? ''),
782
+ ...(typeof q.header === 'string' ? { header: q.header } : {}),
783
+ options: (Array.isArray(q.options) ? q.options : []).map(
784
+ (o: Record<string, unknown>) => ({
785
+ label: String(o.label ?? ''),
786
+ ...(typeof o.description === 'string'
787
+ ? { description: o.description }
788
+ : {}),
789
+ }),
790
+ ),
791
+ multiSelect: q.multiSelect === true,
792
+ }));
793
+ }
794
+
795
+ function safeTitle(
796
+ title: ((input: any) => string) | undefined,
797
+ input: unknown,
798
+ ): string | undefined {
799
+ if (!title || input === undefined) return undefined;
800
+ try {
801
+ return title(input);
802
+ } catch {
803
+ return undefined;
804
+ }
805
+ }
806
+
807
+ /**
808
+ * A working directory that does not exist is the app's mistake to hear
809
+ * about, said as such. Claude Code launched in one fails with `ENOENT`
810
+ * naming the executable, which the SDK reads as a binary built for another
811
+ * libc: "exists but failed to launch".
812
+ */
813
+ async function mustBeDirectories(paths: readonly string[]): Promise<void> {
814
+ const missing: string[] = [];
815
+ for (const p of paths) {
816
+ const found = await stat(p).catch(() => null);
817
+ if (!found?.isDirectory()) missing.push(p);
818
+ }
819
+ if (missing.length) {
820
+ throw new AgentUnavailableError(
821
+ `Claude Code cannot start in a directory that does not exist: ${missing.join(', ')}. ` +
822
+ 'Create it, or pass another cwd.',
823
+ );
824
+ }
825
+ }