@windowkit/agent-chat 0.0.0-stage → 0.3.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 +67 -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 +25 -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 +56 -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 +25 -0
  120. package/dist/view/look.d.ts.map +1 -0
  121. package/dist/view/look.js +47 -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 +115 -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 +61 -0
  156. package/src/view/ToolCard.tsx +283 -0
  157. package/src/view/Transcript.tsx +91 -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 +70 -0
  163. package/src/view/types.ts +71 -0
  164. package/src/view/use-agent.ts +40 -0
@@ -0,0 +1,824 @@
1
+ // A session: the transcript, the harness behind it, and every question the
2
+ // harness asks the client — answered by the app's policy, or by the user.
3
+ //
4
+ // It is a store in the `useSyncExternalStore` sense: `snapshot` is one
5
+ // immutable object, replaced on every change, and `subscribe` hears each
6
+ // replacement. Nothing in it is React's, so a terminal or DOM front end can
7
+ // drive the same object.
8
+
9
+ import {
10
+ AgentUnavailableError,
11
+ type AgentDriver,
12
+ type ApprovalResult,
13
+ type DriverConfig,
14
+ type DriverEvent,
15
+ type DriverHost,
16
+ type DriverSession,
17
+ type GateDecision,
18
+ type OutgoingMessage,
19
+ } from './driver.js';
20
+ import { transcriptMarkdown } from './markdown.js';
21
+ import { policyDecision } from './policy.js';
22
+ import {
23
+ reduce,
24
+ restore,
25
+ findLivePart,
26
+ type TranscriptAction,
27
+ } from './reducer.js';
28
+ import {
29
+ errorResult,
30
+ normalizeResult,
31
+ validateInput,
32
+ type AnyToolDef,
33
+ type ToolResult,
34
+ } from './tools.js';
35
+ import {
36
+ NO_CAPABILITIES,
37
+ type AgentMessage,
38
+ type AgentStatus,
39
+ type ApprovalChoice,
40
+ type ApprovalOption,
41
+ type Capabilities,
42
+ type ComposedMessage,
43
+ type CoreToolKey,
44
+ type Part,
45
+ type PermissionPolicy,
46
+ type Question,
47
+ type SessionInfo,
48
+ type ToolCall,
49
+ type ToolDecision,
50
+ } from './types.js';
51
+
52
+ /** A `coreTools` value: on, off, always asking, or replaced by a tool. */
53
+ export type CoreToolSetting = boolean | 'ask' | AnyToolDef;
54
+
55
+ export interface AgentOptions {
56
+ /** Which harness. Default `'claude'`: the Claude Agent SDK. */
57
+ agent?: 'claude' | AgentDriver;
58
+ /** Where it works. Default: the process's working directory. */
59
+ cwd?: string;
60
+ /** More directories it may read and write. */
61
+ folders?: readonly string[];
62
+ /** Appended to the harness's own system prompt. An array is joined with
63
+ * blank lines — `describeRenderers()` returns one. */
64
+ instructions?: string | readonly string[];
65
+ /** Replaces the harness's system prompt. `instructions` still follow it. */
66
+ systemPrompt?: string;
67
+ /**
68
+ * The harness's built-in tools, by what they do — `shell`, `read`, `edit`,
69
+ * `web`, `agents`, `todos` — or by a harness's own name (`NotebookEdit`).
70
+ * `false` removes one, `'ask'` makes it always ask, and a tool of the app's
71
+ * replaces it, which the agent then reaches by the built-in's name.
72
+ */
73
+ coreTools?: Readonly<Partial<Record<CoreToolKey, CoreToolSetting>>> &
74
+ Readonly<Record<string, CoreToolSetting | undefined>>;
75
+ /** The app's own tools, by name. */
76
+ tools?: Readonly<Record<string, AnyToolDef>>;
77
+ /** What runs without asking. Default `'ask'`. */
78
+ permissions?: PermissionPolicy;
79
+ /** Before every tool call the session sees: the app's say, ahead of the
80
+ * policy. `undefined` leaves it to the policy. */
81
+ onToolCall?: (call: ToolCall) => ToolDecision | Promise<ToolDecision>;
82
+ model?: string;
83
+ mode?: string;
84
+ /** A harness session to resume, by its id. */
85
+ resume?: string;
86
+ /**
87
+ * The transcript to start from: an earlier session's `snapshot.messages`,
88
+ * saved by the app. It is shown as it was, with anything left open
89
+ * settled as a stopped turn. The harness remembers the conversation only
90
+ * through `resume`, so pass its `info.id` beside it.
91
+ */
92
+ messages?: readonly AgentMessage[];
93
+ /**
94
+ * `'cli'` (default): the harness behaves as it does on the command line,
95
+ * with the user's settings and project instructions (`CLAUDE.md`).
96
+ * `'isolated'`: none of them.
97
+ */
98
+ settings?: 'cli' | 'isolated';
99
+ /** Passed to the driver as they are, after everything above. */
100
+ driverOptions?: Readonly<Record<string, unknown>>;
101
+ }
102
+
103
+ export interface SessionSnapshot {
104
+ readonly status: AgentStatus;
105
+ /** The transcript. */
106
+ readonly messages: readonly AgentMessage[];
107
+ readonly info: SessionInfo;
108
+ readonly capabilities: Capabilities;
109
+ /** Why the status is `'error'` or `'unavailable'`. */
110
+ readonly error?: string;
111
+ readonly paused: boolean;
112
+ /** Text taken back out of the queue by `stop()`, for the composer. */
113
+ readonly restored?: string;
114
+ }
115
+
116
+ export interface AgentSession {
117
+ readonly snapshot: SessionSnapshot;
118
+ /** Bound, so `useSyncExternalStore(session.subscribe, …)` works as is. */
119
+ readonly subscribe: (listener: () => void) => () => void;
120
+ /** The app's tools, replacements included, by name: what the view asks
121
+ * for a tool's `render`. */
122
+ readonly tools: ReadonlyMap<string, AnyToolDef>;
123
+ /** Start the harness now, rather than at the first `send`. */
124
+ start(): Promise<void>;
125
+ /**
126
+ * Send a message: the user's, or, with `from`, another session's. Sent
127
+ * mid-turn it steers where the harness can, and otherwise waits its turn.
128
+ */
129
+ send(input: string | ComposedMessage): void;
130
+ /** Stop the turn. Queued messages come back as `snapshot.restored`. */
131
+ stop(): Promise<void>;
132
+ /** Hold every tool call at the gate. Needs `capabilities.gate` to be
133
+ * `'every-call'`; elsewhere it holds only the calls the agent asks about. */
134
+ pause(): void;
135
+ resume(): void;
136
+ /** Answer a tool call waiting for approval. */
137
+ respond(toolId: string, choice: ApprovalChoice, note?: string): void;
138
+ /** Answer a question; `null` dismisses it. */
139
+ answer(questionId: string, answers: Record<string, string> | null): void;
140
+ setModel(id: string): Promise<void>;
141
+ setMode(id: string): Promise<void>;
142
+ /** A line in the transcript from the app: an action's result, a failure. */
143
+ notify(level: 'info' | 'warning' | 'error', text: string): void;
144
+ /** The transcript as markdown, for Copy. */
145
+ toMarkdown(): string;
146
+ /** Mark `restored` as taken. */
147
+ takeRestored(): string | undefined;
148
+ close(): Promise<void>;
149
+ }
150
+
151
+ const APPROVAL_OPTIONS: readonly ApprovalOption[] = Object.freeze([
152
+ { id: 'allow-once', label: 'Allow', kind: 'allow' },
153
+ { id: 'allow-session', label: 'Allow for this session', kind: 'allow' },
154
+ { id: 'deny', label: 'Deny', kind: 'deny' },
155
+ ]);
156
+
157
+ const BUSY: ReadonlySet<AgentStatus> = new Set([
158
+ 'starting',
159
+ 'running',
160
+ 'waiting',
161
+ 'paused',
162
+ ]);
163
+
164
+ export function createAgentSession(options: AgentOptions = {}): AgentSession {
165
+ return new Session(options);
166
+ }
167
+
168
+ /** Lines that tell the agent what the UI can draw — see `describe` on a
169
+ * fence or a component. */
170
+ export function describeRenderers(
171
+ renderers: {
172
+ fences?: Readonly<Record<string, unknown>>;
173
+ components?: Readonly<Record<string, unknown>>;
174
+ } = {},
175
+ ): string[] {
176
+ const lines: string[] = [];
177
+ for (const [lang, entry] of Object.entries(renderers.fences ?? {})) {
178
+ const describe = describeOf(entry);
179
+ if (describe) {
180
+ lines.push(
181
+ `The UI draws a fenced code block tagged \`${lang}\` as: ${describe}`,
182
+ );
183
+ }
184
+ }
185
+ for (const [name, entry] of Object.entries(renderers.components ?? {})) {
186
+ const describe = describeOf(entry);
187
+ if (describe) {
188
+ lines.push(
189
+ `The UI draws a <${name}/> tag, alone on its line, as: ${describe}. ` +
190
+ 'Its attributes are strings or JSON.',
191
+ );
192
+ }
193
+ }
194
+ return lines.length
195
+ ? ['You are shown in a chat UI that renders markdown.', ...lines]
196
+ : [];
197
+ }
198
+
199
+ function describeOf(entry: unknown): string | undefined {
200
+ return entry && typeof entry === 'object' && 'describe' in entry
201
+ ? String((entry as { describe: unknown }).describe)
202
+ : undefined;
203
+ }
204
+
205
+ class Session implements AgentSession {
206
+ readonly #options: AgentOptions;
207
+ readonly #config: DriverConfig;
208
+ #snapshot: SessionSnapshot;
209
+ readonly #listeners = new Set<() => void>();
210
+ #driver: DriverSession | null = null;
211
+ #starting: Promise<DriverSession | null> | null = null;
212
+ #turn: AbortController | null = null;
213
+ readonly #queue: OutgoingMessage[] = [];
214
+ #ids = 0;
215
+
216
+ readonly #decisions = new Map<string, GateDecision>();
217
+ readonly #sessionAllowed = new Set<string>();
218
+ readonly #approvals = new Map<string, (r: ApprovalResult) => void>();
219
+ readonly #questions = new Map<
220
+ string,
221
+ (a: Record<string, string> | null) => void
222
+ >();
223
+ #paused = false;
224
+ readonly #resumers = new Set<() => void>();
225
+
226
+ constructor(options: AgentOptions) {
227
+ this.#options = options;
228
+ this.#config = resolveConfig(options);
229
+ const messages = restore(options.messages ?? []);
230
+ // the user's next message continues the saved ones' numbering, so its
231
+ // id is not one already in the transcript
232
+ for (const m of messages) {
233
+ const n = /^u(\d+)$/.exec(m.id);
234
+ if (n) this.#ids = Math.max(this.#ids, Number(n[1]));
235
+ }
236
+ const driverName =
237
+ typeof options.agent === 'object' ? options.agent.name : 'claude';
238
+ this.#snapshot = Object.freeze({
239
+ status: 'idle',
240
+ messages,
241
+ info: {
242
+ agent: driverName,
243
+ cwd: this.#config.cwd,
244
+ models: [],
245
+ modes: [],
246
+ commands: [],
247
+ },
248
+ capabilities: NO_CAPABILITIES,
249
+ paused: false,
250
+ });
251
+ }
252
+
253
+ get snapshot(): SessionSnapshot {
254
+ return this.#snapshot;
255
+ }
256
+
257
+ readonly subscribe = (listener: () => void): (() => void) => {
258
+ this.#listeners.add(listener);
259
+ return () => {
260
+ this.#listeners.delete(listener);
261
+ };
262
+ };
263
+
264
+ get tools(): ReadonlyMap<string, AnyToolDef> {
265
+ return this.#config.tools;
266
+ }
267
+
268
+ #set(patch: Partial<SessionSnapshot>): void {
269
+ this.#snapshot = Object.freeze({ ...this.#snapshot, ...patch });
270
+ for (const l of [...this.#listeners]) l();
271
+ }
272
+
273
+ #dispatch(action: TranscriptAction): void {
274
+ const messages = reduce(this.#snapshot.messages, action);
275
+ if (messages !== this.#snapshot.messages) this.#set({ messages });
276
+ }
277
+
278
+ // --- the harness -----------------------------------------------------------
279
+
280
+ start(): Promise<void> {
281
+ return this.#start().then(() => undefined);
282
+ }
283
+
284
+ #start(): Promise<DriverSession | null> {
285
+ if (this.#driver) return Promise.resolve(this.#driver);
286
+ if (this.#starting) return this.#starting;
287
+ this.#set({ status: 'starting', error: undefined });
288
+ this.#starting = (async () => {
289
+ try {
290
+ const driver = await loadDriver(this.#options.agent);
291
+ const session = await driver.start(this.#host());
292
+ this.#driver = session;
293
+ this.#set({
294
+ capabilities: session.capabilities,
295
+ status: this.#turn ? 'running' : 'idle',
296
+ });
297
+ return session;
298
+ } catch (err) {
299
+ const unavailable = err instanceof AgentUnavailableError;
300
+ this.#set({
301
+ status: unavailable ? 'unavailable' : 'error',
302
+ error: err instanceof Error ? err.message : String(err),
303
+ });
304
+ this.#turn = null;
305
+ return null;
306
+ } finally {
307
+ this.#starting = null;
308
+ }
309
+ })();
310
+ return this.#starting;
311
+ }
312
+
313
+ #host(): DriverHost {
314
+ return {
315
+ config: this.#config,
316
+ emit: (event) => this.#onEvent(event),
317
+ gate: (call) => this.#gate(call, true),
318
+ approve: (call, signal) => this.#approve(call, signal),
319
+ ask: (id, questions, signal) => this.#ask(id, questions, signal),
320
+ runTool: (name, input, ctx) => this.#runTool(name, input, ctx),
321
+ };
322
+ }
323
+
324
+ #onEvent(event: DriverEvent): void {
325
+ switch (event.type) {
326
+ case 'info':
327
+ this.#set({ info: { ...this.#snapshot.info, ...event.info } });
328
+ return;
329
+ case 'usage':
330
+ this.#set({
331
+ info: {
332
+ ...this.#snapshot.info,
333
+ usage: { ...this.#snapshot.info.usage, ...event.usage },
334
+ },
335
+ });
336
+ return;
337
+ case 'message':
338
+ // the harness took it and is acting on it: a turn, if none runs
339
+ this.#dispatch(event);
340
+ if (!this.#turn) {
341
+ this.#turn = new AbortController();
342
+ this.#set({ status: this.#paused ? 'paused' : 'running' });
343
+ }
344
+ return;
345
+ case 'turn-end':
346
+ this.#dispatch(event);
347
+ if (event.reason === 'error' && event.error) {
348
+ this.#dispatch({ type: 'notice', level: 'error', text: event.error });
349
+ }
350
+ this.#endTurn();
351
+ return;
352
+ default:
353
+ this.#dispatch(event);
354
+ }
355
+ }
356
+
357
+ #endTurn(): void {
358
+ this.#turn = null;
359
+ this.#decisions.clear();
360
+ const next = this.#queue.shift();
361
+ if (next) {
362
+ this.#dispatch({ type: 'unqueue', id: next.id });
363
+ void this.#deliver(next);
364
+ return;
365
+ }
366
+ if (BUSY.has(this.#snapshot.status)) this.#set({ status: 'idle' });
367
+ }
368
+
369
+ // --- the user's side -------------------------------------------------------
370
+
371
+ send(input: string | ComposedMessage): void {
372
+ const composed: ComposedMessage =
373
+ typeof input === 'string' ? { text: input } : input;
374
+ const text = composed.text;
375
+ const attachments = composed.attachments ?? [];
376
+ const from = composed.from;
377
+ if (!text.trim() && !attachments.length) return;
378
+ if (this.#snapshot.status === 'closed') return;
379
+
380
+ const id = `u${++this.#ids}`;
381
+ const parts: Part[] = [];
382
+ if (text)
383
+ parts.push({ type: 'text', id: `${id}:t`, text, streaming: false });
384
+ attachments.forEach((a, i) =>
385
+ parts.push({
386
+ type: 'file',
387
+ id: `${id}:f${i}`,
388
+ label: a.label,
389
+ mime: a.mime ?? (a.kind === 'image' ? 'image/png' : 'text/plain'),
390
+ ...(a.path ? { path: a.path } : {}),
391
+ ...(a.data !== undefined ? { data: a.data } : {}),
392
+ }),
393
+ );
394
+
395
+ const busy = !!this.#turn;
396
+ const steer = busy && this.#snapshot.capabilities.steer;
397
+ const queued = busy && !steer;
398
+ this.#dispatch({
399
+ type: 'user',
400
+ message: {
401
+ id,
402
+ role: 'user',
403
+ parts,
404
+ createdAt: Date.now(),
405
+ ...(from ? { from } : {}),
406
+ ...(queued ? { queued: true } : {}),
407
+ },
408
+ });
409
+ const out: OutgoingMessage = {
410
+ id,
411
+ text,
412
+ attachments,
413
+ steer,
414
+ ...(from ? { from } : {}),
415
+ };
416
+ if (queued) this.#queue.push(out);
417
+ else void this.#deliver(out);
418
+ }
419
+
420
+ async #deliver(message: OutgoingMessage): Promise<void> {
421
+ if (!message.steer) this.#turn = new AbortController();
422
+ if (!this.#paused) this.#set({ status: 'running', restored: undefined });
423
+ const driver = await this.#start();
424
+ if (!driver) return;
425
+ if (!this.#turn && !message.steer) return; // stopped while starting
426
+ if (!BUSY.has(this.#snapshot.status)) this.#set({ status: 'running' });
427
+ try {
428
+ driver.send(message);
429
+ } catch (err) {
430
+ this.#onEvent({
431
+ type: 'turn-end',
432
+ reason: 'error',
433
+ error: err instanceof Error ? err.message : String(err),
434
+ });
435
+ }
436
+ }
437
+
438
+ async stop(): Promise<void> {
439
+ if (!this.#turn && !this.#queue.length) return;
440
+ // what the user typed goes back to the composer; what another session
441
+ // sent is not theirs to edit, and goes with the turn
442
+ const restored = this.#queue
443
+ .filter((m) => !m.from)
444
+ .map((m) => m.text)
445
+ .join('\n\n');
446
+ const dropped = new Set(this.#queue.map((m) => m.id));
447
+ this.#queue.length = 0;
448
+ this.#turn?.abort();
449
+ for (const [id, resolve] of this.#approvals) {
450
+ this.#approvals.delete(id);
451
+ resolve({
452
+ behavior: 'deny',
453
+ message: 'Stopped by the user.',
454
+ interrupt: true,
455
+ });
456
+ }
457
+ for (const [id, resolve] of this.#questions) {
458
+ this.#questions.delete(id);
459
+ resolve(null);
460
+ }
461
+ this.#wake();
462
+ if (dropped.size) {
463
+ this.#set({
464
+ messages: this.#snapshot.messages.filter((m) => !dropped.has(m.id)),
465
+ restored: restored || undefined,
466
+ });
467
+ }
468
+ const driver = this.#driver;
469
+ this.#dispatch({ type: 'stopped' });
470
+ this.#turn = null;
471
+ this.#decisions.clear();
472
+ this.#set({ status: 'idle' });
473
+ try {
474
+ await driver?.interrupt();
475
+ } catch {
476
+ // the harness is already gone; the transcript says stopped either way
477
+ }
478
+ }
479
+
480
+ takeRestored(): string | undefined {
481
+ const restored = this.#snapshot.restored;
482
+ if (restored !== undefined) this.#set({ restored: undefined });
483
+ return restored;
484
+ }
485
+
486
+ pause(): void {
487
+ if (this.#paused) return;
488
+ this.#paused = true;
489
+ this.#set({
490
+ paused: true,
491
+ ...(this.#turn ? { status: 'paused' as const } : {}),
492
+ });
493
+ }
494
+
495
+ resume(): void {
496
+ if (!this.#paused) return;
497
+ this.#paused = false;
498
+ this.#set({
499
+ paused: false,
500
+ ...(this.#snapshot.status === 'paused'
501
+ ? {
502
+ status:
503
+ this.#approvals.size || this.#questions.size
504
+ ? 'waiting'
505
+ : 'running',
506
+ }
507
+ : {}),
508
+ } as Partial<SessionSnapshot>);
509
+ this.#wake();
510
+ }
511
+
512
+ #wake(): void {
513
+ const resumers = [...this.#resumers];
514
+ this.#resumers.clear();
515
+ for (const r of resumers) r();
516
+ }
517
+
518
+ #unpaused(signal?: AbortSignal): Promise<void> {
519
+ if (!this.#paused || signal?.aborted || !this.#turn)
520
+ return Promise.resolve();
521
+ return new Promise((resolve) => {
522
+ const done = () => {
523
+ this.#resumers.delete(done);
524
+ signal?.removeEventListener('abort', done);
525
+ resolve();
526
+ };
527
+ this.#resumers.add(done);
528
+ signal?.addEventListener('abort', done, { once: true });
529
+ });
530
+ }
531
+
532
+ respond(toolId: string, choice: ApprovalChoice, note?: string): void {
533
+ const resolve = this.#approvals.get(toolId);
534
+ if (!resolve) return;
535
+ this.#approvals.delete(toolId);
536
+ if (choice === 'deny') {
537
+ resolve({
538
+ behavior: 'deny',
539
+ message: note?.trim()
540
+ ? `Denied by the user: ${note.trim()}`
541
+ : 'Denied by the user.',
542
+ });
543
+ return;
544
+ }
545
+ if (choice === 'allow-session') {
546
+ const part = findLivePart(this.#snapshot.messages, toolId);
547
+ if (part?.type === 'tool') this.#sessionAllowed.add(part.label);
548
+ }
549
+ resolve({ behavior: 'allow' });
550
+ }
551
+
552
+ answer(questionId: string, answers: Record<string, string> | null): void {
553
+ const resolve = this.#questions.get(questionId);
554
+ if (!resolve) return;
555
+ this.#questions.delete(questionId);
556
+ resolve(answers);
557
+ }
558
+
559
+ async setModel(id: string): Promise<void> {
560
+ const driver = this.#driver ?? (await this.#start());
561
+ if (!driver?.setModel) return;
562
+ await driver.setModel(id);
563
+ this.#set({ info: { ...this.#snapshot.info, model: id } });
564
+ }
565
+
566
+ async setMode(id: string): Promise<void> {
567
+ const driver = this.#driver ?? (await this.#start());
568
+ if (!driver?.setMode) return;
569
+ await driver.setMode(id);
570
+ this.#set({ info: { ...this.#snapshot.info, mode: id } });
571
+ }
572
+
573
+ notify(level: 'info' | 'warning' | 'error', text: string): void {
574
+ // a notice goes in the turn under way, or in a message of its own
575
+ if (!this.#turn) {
576
+ this.#dispatch({
577
+ type: 'user',
578
+ message: {
579
+ id: `n${++this.#ids}`,
580
+ role: 'assistant',
581
+ parts: [{ type: 'notice', id: `n${this.#ids}:0`, level, text }],
582
+ createdAt: Date.now(),
583
+ done: true,
584
+ },
585
+ });
586
+ return;
587
+ }
588
+ this.#dispatch({ type: 'notice', level, text });
589
+ }
590
+
591
+ toMarkdown(): string {
592
+ return transcriptMarkdown(this.#snapshot.messages);
593
+ }
594
+
595
+ async close(): Promise<void> {
596
+ if (this.#snapshot.status === 'closed') return;
597
+ await this.stop();
598
+ const driver = this.#driver;
599
+ this.#driver = null;
600
+ this.#set({ status: 'closed' });
601
+ await driver?.close();
602
+ }
603
+
604
+ // --- the harness's questions -------------------------------------------------
605
+
606
+ async #gate(call: ToolCall, remember: boolean): Promise<GateDecision> {
607
+ if (this.#paused && this.#turn) return { behavior: 'ask' };
608
+ const known = this.#decisions.get(call.id);
609
+ if (known) return known;
610
+ if (this.#sessionAllowed.has(call.label)) return { behavior: 'allow' };
611
+
612
+ let decision: GateDecision;
613
+ let fromApp: ToolDecision;
614
+ try {
615
+ fromApp = await this.#options.onToolCall?.(call);
616
+ } catch (err) {
617
+ fromApp = {
618
+ deny: `The app's check failed: ${err instanceof Error ? err.message : String(err)}`,
619
+ };
620
+ }
621
+ if (fromApp === undefined) decision = policyDecision(call, this.#config);
622
+ else if (fromApp === 'allow') decision = { behavior: 'allow' };
623
+ else if (fromApp === 'allow-session') {
624
+ this.#sessionAllowed.add(call.label);
625
+ decision = { behavior: 'allow' };
626
+ } else if (fromApp === 'deny') {
627
+ decision = { behavior: 'deny', message: 'Denied by the app.' };
628
+ } else if (fromApp === 'ask') decision = { behavior: 'ask' };
629
+ else if ('deny' in fromApp)
630
+ decision = { behavior: 'deny', message: fromApp.deny };
631
+ else decision = { behavior: 'allow', input: fromApp.input };
632
+
633
+ if (remember) this.#decisions.set(call.id, decision);
634
+ if (decision.behavior === 'deny') {
635
+ this.#dispatch({ type: 'approved', id: call.id, allowed: false });
636
+ }
637
+ return decision;
638
+ }
639
+
640
+ async #approve(
641
+ call: ToolCall,
642
+ signal?: AbortSignal,
643
+ ): Promise<ApprovalResult> {
644
+ await this.#unpaused(signal);
645
+ if (signal?.aborted || !this.#turn) {
646
+ return {
647
+ behavior: 'deny',
648
+ message: 'Stopped by the user.',
649
+ interrupt: true,
650
+ };
651
+ }
652
+ const decision = await this.#gate(call, true);
653
+ if (decision.behavior === 'allow') {
654
+ return { behavior: 'allow', input: decision.input };
655
+ }
656
+ if (decision.behavior === 'deny') {
657
+ return { behavior: 'deny', message: decision.message };
658
+ }
659
+
660
+ if (!findLivePart(this.#snapshot.messages, call.id)) {
661
+ this.#dispatch({
662
+ type: 'tool-start',
663
+ id: call.id,
664
+ name: call.name,
665
+ label: call.label,
666
+ kind: call.kind,
667
+ title: call.title,
668
+ input: call.input,
669
+ app: call.app,
670
+ });
671
+ }
672
+ this.#dispatch({
673
+ type: 'approval',
674
+ id: call.id,
675
+ options: APPROVAL_OPTIONS,
676
+ });
677
+ this.#set({ status: 'waiting' });
678
+ const result = await new Promise<ApprovalResult>((resolve) => {
679
+ this.#approvals.set(call.id, resolve);
680
+ signal?.addEventListener(
681
+ 'abort',
682
+ () => {
683
+ if (!this.#approvals.delete(call.id)) return;
684
+ resolve({ behavior: 'deny', message: 'Stopped.', interrupt: true });
685
+ },
686
+ { once: true },
687
+ );
688
+ });
689
+ this.#decisions.set(
690
+ call.id,
691
+ result.behavior === 'allow'
692
+ ? { behavior: 'allow', input: result.input }
693
+ : { behavior: 'deny', message: result.message },
694
+ );
695
+ this.#dispatch({
696
+ type: 'approved',
697
+ id: call.id,
698
+ allowed: result.behavior === 'allow',
699
+ });
700
+ this.#settleWaiting();
701
+ return result;
702
+ }
703
+
704
+ async #ask(
705
+ id: string,
706
+ questions: readonly Question[],
707
+ signal?: AbortSignal,
708
+ ): Promise<Record<string, string> | null> {
709
+ this.#dispatch({ type: 'question', id, questions });
710
+ this.#set({ status: 'waiting' });
711
+ const answers = await new Promise<Record<string, string> | null>(
712
+ (resolve) => {
713
+ this.#questions.set(id, resolve);
714
+ signal?.addEventListener(
715
+ 'abort',
716
+ () => {
717
+ if (this.#questions.delete(id)) resolve(null);
718
+ },
719
+ { once: true },
720
+ );
721
+ },
722
+ );
723
+ this.#dispatch({ type: 'answered', id, answers });
724
+ this.#settleWaiting();
725
+ return answers;
726
+ }
727
+
728
+ #settleWaiting(): void {
729
+ if (this.#approvals.size || this.#questions.size) return;
730
+ if (this.#snapshot.status === 'waiting') {
731
+ this.#set({
732
+ status: this.#turn ? (this.#paused ? 'paused' : 'running') : 'idle',
733
+ });
734
+ }
735
+ }
736
+
737
+ async #runTool(
738
+ name: string,
739
+ input: unknown,
740
+ ctx: { callId?: string; signal?: AbortSignal },
741
+ ): Promise<ToolResult> {
742
+ const tool = this.#config.tools.get(name);
743
+ if (!tool) return errorResult(new Error(`No tool named ${name}.`));
744
+ const checked = await validateInput(tool.parameters, input);
745
+ if (!checked.ok) return errorResult(new Error(checked.message));
746
+ const callId = ctx.callId ?? `${name}:${++this.#ids}`;
747
+ const signal =
748
+ ctx.signal ?? this.#turn?.signal ?? new AbortController().signal;
749
+ if (ctx.callId) {
750
+ this.#dispatch({ type: 'tool-update', id: callId, state: 'running' });
751
+ }
752
+ try {
753
+ const returned = await tool.run(checked.value, {
754
+ signal,
755
+ callId,
756
+ progress: (text) => {
757
+ if (ctx.callId) {
758
+ this.#dispatch({ type: 'tool-update', id: callId, progress: text });
759
+ }
760
+ },
761
+ });
762
+ const { result, output } = normalizeResult(returned);
763
+ if (ctx.callId) {
764
+ this.#dispatch({ type: 'tool-update', id: callId, output });
765
+ }
766
+ return result;
767
+ } catch (err) {
768
+ if (ctx.callId) {
769
+ this.#dispatch({
770
+ type: 'tool-update',
771
+ id: callId,
772
+ output: [],
773
+ });
774
+ }
775
+ return errorResult(err);
776
+ }
777
+ }
778
+ }
779
+
780
+ function resolveConfig(options: AgentOptions): DriverConfig {
781
+ const tools = new Map<string, AnyToolDef>(
782
+ Object.entries(options.tools ?? {}),
783
+ );
784
+ const removed: string[] = [];
785
+ const asking: string[] = [];
786
+ const replaced: Partial<Record<CoreToolKey, string>> = {};
787
+ for (const [key, setting] of Object.entries(options.coreTools ?? {})) {
788
+ if (setting === undefined || setting === true) continue;
789
+ if (setting === false) removed.push(key);
790
+ else if (setting === 'ask') asking.push(key);
791
+ else {
792
+ tools.set(key, setting);
793
+ replaced[key as CoreToolKey] = key;
794
+ }
795
+ }
796
+ const instructions = Array.isArray(options.instructions)
797
+ ? options.instructions.filter(Boolean).join('\n\n')
798
+ : ((options.instructions as string | undefined) ?? '');
799
+ return {
800
+ cwd: options.cwd ?? process.cwd(),
801
+ folders: options.folders ?? [],
802
+ instructions,
803
+ ...(options.systemPrompt !== undefined
804
+ ? { systemPrompt: options.systemPrompt }
805
+ : {}),
806
+ removed,
807
+ asking,
808
+ replaced,
809
+ tools,
810
+ permissions: options.permissions ?? 'ask',
811
+ ...(options.model ? { model: options.model } : {}),
812
+ ...(options.mode ? { mode: options.mode } : {}),
813
+ ...(options.resume ? { resume: options.resume } : {}),
814
+ settings: options.settings ?? 'cli',
815
+ driverOptions: options.driverOptions ?? {},
816
+ };
817
+ }
818
+
819
+ async function loadDriver(agent: AgentOptions['agent']): Promise<AgentDriver> {
820
+ if (agent && typeof agent === 'object') return agent;
821
+ // A dynamic import, so a bundle that never names Claude carries none of it.
822
+ const { claudeDriver } = await import('../drivers/claude/index.js');
823
+ return claudeDriver();
824
+ }