zidane 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1 +1,724 @@
1
1
  ![Zidane](https://github.com/Tahul/zidane/blob/main/zidane.jpeg?raw=true)
2
+
3
+ # Zidane
4
+
5
+ An agent that goes straight to the goal.
6
+
7
+ Minimal TypeScript agent loop built with [Bun](https://bun.sh).
8
+
9
+ Hook into every step of the agent's execution using [hookable](https://github.com/unjs/hookable).
10
+
11
+ Built to be embedded in other projects easily, extended through [providers](#providers), [harnesses](#harnesses), and [execution contexts](#execution-contexts).
12
+
13
+ ## Quickstart
14
+
15
+ ```bash
16
+ # Install
17
+ bun install
18
+
19
+ # Authenticate with Anthropic OAuth (Claude Pro/Max)
20
+ bun run auth
21
+
22
+ # Run
23
+ bun start --prompt "create a hello world express app"
24
+ ```
25
+
26
+ ## CLI
27
+
28
+ ```bash
29
+ bun start \
30
+ --prompt "your task" \ # required
31
+ --model claude-opus-4-6 \ # model id (default: claude-opus-4-6)
32
+ --provider anthropic \ # anthropic | openrouter | cerebras
33
+ --harness basic \ # tool set to use
34
+ --system "be concise" \ # system prompt
35
+ --thinking off \ # off | minimal | low | medium | high
36
+ --context process \ # process | docker
37
+ --mcp '{"name":"fs","transport":"stdio","command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","."]}'
38
+ ```
39
+
40
+ The `--mcp` flag accepts a JSON object matching `McpServerConfig`. It can be passed multiple times.
41
+
42
+ ## Execution Contexts
43
+
44
+ An execution context defines **where** the agent's tools run. All tool operations (shell, filesystem) go through it.
45
+
46
+ ### In-process (default)
47
+
48
+ Runs in the same Node/Bun process. No isolation, fastest.
49
+
50
+ ```ts
51
+ import { createAgent, createProcessContext } from 'zidane'
52
+
53
+ const agent = createAgent({
54
+ harness,
55
+ provider,
56
+ // execution defaults to createProcessContext()
57
+ })
58
+ ```
59
+
60
+ ### Docker
61
+
62
+ Full container isolation via [dockerode](https://github.com/apocas/dockerode). Configurable resource limits.
63
+
64
+ ```bash
65
+ # CLI
66
+ bun start --prompt "run uname -a" --context docker
67
+ bun start --prompt "build the app" --context docker --image node:22 --cwd /workspace
68
+ ```
69
+
70
+ ```ts
71
+ import { createAgent, createDockerContext } from 'zidane'
72
+
73
+ const agent = createAgent({
74
+ harness,
75
+ provider,
76
+ execution: createDockerContext({
77
+ image: 'node:22',
78
+ cwd: '/workspace',
79
+ limits: { memory: 512, cpu: '1.0' },
80
+ }),
81
+ })
82
+ ```
83
+
84
+ Requires `dockerode` as a peer dependency: `bun add dockerode`
85
+
86
+ ### Sandbox (remote)
87
+
88
+ Offloads execution to a remote sandbox API. Implement the `SandboxProvider` interface for your provider (Rivet, E2B, etc.).
89
+
90
+ ```ts
91
+ import { createAgent, createSandboxContext } from 'zidane'
92
+ import type { SandboxProvider } from 'zidane'
93
+
94
+ const myProvider: SandboxProvider = {
95
+ name: 'my-sandbox',
96
+ spawn: async (config) => { /* ... */ },
97
+ exec: async (id, command) => { /* ... */ },
98
+ readFile: async (id, path) => { /* ... */ },
99
+ writeFile: async (id, path, content) => { /* ... */ },
100
+ listFiles: async (id, path) => { /* ... */ },
101
+ destroy: async (id) => { /* ... */ },
102
+ }
103
+
104
+ const agent = createAgent({
105
+ harness,
106
+ provider,
107
+ execution: createSandboxContext(myProvider),
108
+ })
109
+ ```
110
+
111
+ ### Execution Context Interface
112
+
113
+ All contexts implement the same interface:
114
+
115
+ ```ts
116
+ interface ExecutionContext {
117
+ type: 'process' | 'docker' | 'sandbox'
118
+ capabilities: { shell, filesystem, network, gpu }
119
+ spawn(config?): Promise<ExecutionHandle>
120
+ exec(handle, command, options?): Promise<ExecResult>
121
+ readFile(handle, path): Promise<string>
122
+ writeFile(handle, path, content): Promise<void>
123
+ listFiles(handle, path): Promise<string[]>
124
+ destroy(handle): Promise<void>
125
+ }
126
+ ```
127
+
128
+ Access the context from a running agent:
129
+
130
+ ```ts
131
+ agent.execution // ExecutionContext
132
+ agent.execution.type // 'process' | 'docker' | 'sandbox'
133
+ agent.handle // ExecutionHandle (after first run)
134
+ await agent.destroy() // clean up context resources
135
+ ```
136
+
137
+ ## Providers
138
+
139
+ ### Anthropic
140
+
141
+ Direct Anthropic API with OAuth and API key support.
142
+
143
+ ```bash
144
+ # OAuth (Claude Pro/Max subscription)
145
+ bun run auth
146
+
147
+ # Or API key
148
+ ANTHROPIC_API_KEY=sk-ant-... bun start --prompt "hello"
149
+ ```
150
+
151
+ ### OpenRouter
152
+
153
+ Access 200+ models through OpenRouter's unified API.
154
+
155
+ ```bash
156
+ OPENROUTER_API_KEY=sk-or-... bun start \
157
+ --provider openrouter \
158
+ --model anthropic/claude-sonnet-4-6 \
159
+ --prompt "hello"
160
+ ```
161
+
162
+ ### Cerebras
163
+
164
+ Ultra-fast inference on Cerebras wafer-scale hardware.
165
+
166
+ ```bash
167
+ CEREBRAS_API_KEY=csk-... bun start \
168
+ --provider cerebras \
169
+ --model zai-glm-4.7 \
170
+ --prompt "hello"
171
+ ```
172
+
173
+ ## Thinking
174
+
175
+ Extended reasoning for complex tasks. Maps to Anthropic's thinking API or OpenRouter's `:thinking` variant.
176
+
177
+ ```bash
178
+ bun start --prompt "solve this proof" --thinking high
179
+ ```
180
+
181
+ | Level | Budget |
182
+ |---|---|
183
+ | `off` | disabled |
184
+ | `minimal` | 1k tokens |
185
+ | `low` | 4k tokens |
186
+ | `medium` | 10k tokens |
187
+ | `high` | 32k tokens |
188
+
189
+ ## Tools (Harnesses)
190
+
191
+ Tools are grouped into **harnesses**. The `basic` harness includes:
192
+
193
+ | Tool | Description |
194
+ |---|---|
195
+ | `shell` | Execute shell commands |
196
+ | `read_file` | Read file contents |
197
+ | `write_file` | Write/create files |
198
+ | `list_files` | List directory contents |
199
+ | `spawn` | Spawn a sub-agent for a task |
200
+
201
+ All paths are sandboxed to the working directory.
202
+
203
+ Define a custom harness with `defineHarness`:
204
+
205
+ ```ts
206
+ import { defineHarness } from 'zidane'
207
+
208
+ const harness = defineHarness({
209
+ name: 'researcher',
210
+ system: 'You are a research assistant.',
211
+ tools: { ...basicTools },
212
+ mcpServers: [
213
+ { name: 'filesystem', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '.'] },
214
+ ],
215
+ })
216
+ ```
217
+
218
+ ## Sub-agent Spawning
219
+
220
+ The `spawn` tool lets the agent delegate tasks to child agents. Children run independently and return their result as a tool response.
221
+
222
+ ### Static spawn tool
223
+
224
+ ```ts
225
+ import { spawn, basicTools, defineHarness } from 'zidane'
226
+
227
+ const harness = defineHarness({
228
+ name: 'orchestrator',
229
+ tools: { ...basicTools, spawn },
230
+ })
231
+ ```
232
+
233
+ Children inherit the parent's harness (and can spawn their own children).
234
+
235
+ ### Configurable factory
236
+
237
+ Use `createSpawnTool` when you need custom concurrency limits, model overrides, or lifecycle callbacks.
238
+
239
+ ```ts
240
+ import { createSpawnTool } from 'zidane'
241
+
242
+ const spawnTool = createSpawnTool({
243
+ maxConcurrent: 5,
244
+ model: 'claude-haiku-4-5-20251001',
245
+ system: 'You are a focused sub-agent.',
246
+ thinking: 'low',
247
+ onSpawn: (child) => console.log(`started ${child.id}`),
248
+ onComplete: (child, stats) => console.log(`${child.id} done in ${stats.turns} turns`),
249
+ })
250
+
251
+ const harness = defineHarness({
252
+ name: 'orchestrator',
253
+ tools: { spawn: spawnTool },
254
+ })
255
+ ```
256
+
257
+ ## MCP Servers
258
+
259
+ Connect any MCP-compatible tool server. Tools are namespaced as `mcp_{serverName}_{toolName}`.
260
+
261
+ ### Agent-level
262
+
263
+ ```ts
264
+ const agent = createAgent({
265
+ harness,
266
+ provider,
267
+ mcpServers: [
268
+ { name: 'filesystem', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '.'] },
269
+ { name: 'search', transport: 'sse', url: 'http://localhost:3001/sse' },
270
+ { name: 'api', transport: 'streamable-http', url: 'http://localhost:3002/mcp' },
271
+ ],
272
+ })
273
+ ```
274
+
275
+ ### Harness-level
276
+
277
+ MCP servers can also be declared on the harness so they're shared across all agents using it.
278
+
279
+ ```ts
280
+ const harness = defineHarness({
281
+ name: 'with-mcp',
282
+ tools: { ...basicTools },
283
+ mcpServers: [
284
+ { name: 'db', transport: 'stdio', command: 'node', args: ['db-server.js'] },
285
+ ],
286
+ })
287
+ ```
288
+
289
+ MCP connections are made lazily on the first `run()` call and reused across subsequent runs. They are closed when `agent.destroy()` is called.
290
+
291
+ ## Sessions
292
+
293
+ Sessions give an agent persistent identity, message history, and run metadata across multiple calls or restarts.
294
+
295
+ ### Creating a session
296
+
297
+ ```ts
298
+ import { createSession, createMemoryStore } from 'zidane/session'
299
+
300
+ // In-memory (default, no persistence)
301
+ const session = createSession({ id: 'my-session', agentId: 'my-agent' })
302
+
303
+ // With a store for persistence
304
+ const store = createMemoryStore()
305
+ const session = createSession({ id: 'my-session', store })
306
+ ```
307
+
308
+ ### Storage backends
309
+
310
+ Three built-in stores are available:
311
+
312
+ ```ts
313
+ import { createMemoryStore, createSqliteStore, createRemoteStore } from 'zidane/session'
314
+
315
+ // In-memory, fast, no disk I/O, lost on process restart
316
+ const memStore = createMemoryStore()
317
+
318
+ // SQLite, persistent, zero-dependency (uses Bun's built-in SQLite)
319
+ const sqliteStore = createSqliteStore({ path: './sessions.db' })
320
+
321
+ // Remote HTTP, delegates to a custom REST API
322
+ const remoteStore = createRemoteStore({ url: 'https://api.example.com/sessions' })
323
+ ```
324
+
325
+ ### Agent integration
326
+
327
+ ```ts
328
+ const agent = createAgent({
329
+ harness,
330
+ provider,
331
+ session,
332
+ })
333
+
334
+ await agent.run({ prompt: 'hello' })
335
+ await session.save() // persist to store
336
+ ```
337
+
338
+ ### Session hooks
339
+
340
+ ```ts
341
+ agent.hooks.hook('session:start', (ctx) => {
342
+ // ctx.sessionId, ctx.runId, ctx.prompt
343
+ })
344
+
345
+ agent.hooks.hook('session:end', (ctx) => {
346
+ // ctx.sessionId, ctx.runId
347
+ // ctx.status: 'completed' | 'aborted' | 'error'
348
+ })
349
+
350
+ agent.hooks.hook('session:messages', (ctx) => {
351
+ // ctx.sessionId, ctx.count
352
+ // fired after each turn (live message sync)
353
+ })
354
+
355
+ agent.hooks.hook('session:save', (ctx) => {
356
+ // ctx.sessionId
357
+ // fired after session.save() completes
358
+ })
359
+
360
+ agent.hooks.hook('session:meta', (ctx) => {
361
+ // ctx.sessionId, ctx.key, ctx.value
362
+ // fired when session.setMeta() is called
363
+ })
364
+ ```
365
+
366
+ Messages are synced to the session after every turn, not just at run start/end. If the agent crashes mid-run, you still have messages up to the last completed turn.
367
+
368
+ ### Restoring a session
369
+
370
+ ```ts
371
+ import { loadSession } from 'zidane/session'
372
+
373
+ const session = await loadSession(store, 'my-session')
374
+ if (session) {
375
+ const agent = createAgent({ harness, provider, session })
376
+ await agent.run({ prompt: 'continue from before' })
377
+ }
378
+ ```
379
+
380
+ ## Hooks
381
+
382
+ The agent uses [hookable](https://github.com/unjs/hookable) for lifecycle events. Every hook receives a mutable context object.
383
+
384
+ ### Lifecycle
385
+
386
+ ```ts
387
+ agent.hooks.hook('system:before', (ctx) => {
388
+ // ctx.system: system prompt text
389
+ })
390
+
391
+ agent.hooks.hook('turn:before', (ctx) => {
392
+ // ctx.turn: turn number
393
+ // ctx.options: StreamOptions being sent to provider
394
+ })
395
+
396
+ agent.hooks.hook('turn:after', (ctx) => {
397
+ // ctx.turn, ctx.usage { input, output }
398
+ })
399
+
400
+ agent.hooks.hook('agent:done', (ctx) => {
401
+ // ctx.totalIn, ctx.totalOut, ctx.turns, ctx.elapsed, ctx.children?
402
+ })
403
+
404
+ agent.hooks.hook('agent:abort', () => {
405
+ // fired when agent.abort() is called
406
+ })
407
+ ```
408
+
409
+ ### Streaming
410
+
411
+ ```ts
412
+ agent.hooks.hook('stream:text', (ctx) => {
413
+ // ctx.delta: new text chunk
414
+ // ctx.text: accumulated text so far
415
+ })
416
+
417
+ agent.hooks.hook('stream:end', (ctx) => {
418
+ // ctx.text: final complete text
419
+ })
420
+ ```
421
+
422
+ ### Tool Execution
423
+
424
+ ```ts
425
+ agent.hooks.hook('tool:before', (ctx) => {
426
+ // ctx.name, ctx.input
427
+ })
428
+
429
+ agent.hooks.hook('tool:after', (ctx) => {
430
+ // ctx.name, ctx.input, ctx.result
431
+ })
432
+
433
+ agent.hooks.hook('tool:error', (ctx) => {
434
+ // ctx.name, ctx.input, ctx.error
435
+ })
436
+ ```
437
+
438
+ ### Tool Gate: block execution
439
+
440
+ Mutate `ctx.block = true` to prevent a tool from running.
441
+
442
+ ```ts
443
+ agent.hooks.hook('tool:gate', (ctx) => {
444
+ if (ctx.name === 'shell' && String(ctx.input.command).includes('rm -rf')) {
445
+ ctx.block = true
446
+ ctx.reason = 'dangerous command'
447
+ }
448
+ })
449
+ ```
450
+
451
+ ### Tool Transform: modify output
452
+
453
+ Mutate `ctx.result` or `ctx.isError` to transform tool results before they're sent back to the model.
454
+
455
+ ```ts
456
+ agent.hooks.hook('tool:transform', (ctx) => {
457
+ if (ctx.result.length > 5000)
458
+ ctx.result = ctx.result.slice(0, 5000) + '\n... (truncated)'
459
+ })
460
+ ```
461
+
462
+ ### Context Transform: prune messages
463
+
464
+ Mutate `ctx.messages` before each LLM call for context window management.
465
+
466
+ ```ts
467
+ agent.hooks.hook('context:transform', (ctx) => {
468
+ if (ctx.messages.length > 30)
469
+ ctx.messages.splice(2, ctx.messages.length - 30)
470
+ })
471
+ ```
472
+
473
+ ### Spawn hooks
474
+
475
+ Fired by the `spawn` tool when child agents are created.
476
+
477
+ ```ts
478
+ agent.hooks.hook('spawn:before', (ctx) => {
479
+ // ctx.id: child agent id (e.g. 'child-1')
480
+ // ctx.task: the task prompt given to the child
481
+ })
482
+
483
+ agent.hooks.hook('spawn:complete', (ctx) => {
484
+ // ctx.id, ctx.task
485
+ // ctx.stats: AgentStats from the child run
486
+ })
487
+
488
+ agent.hooks.hook('spawn:error', (ctx) => {
489
+ // ctx.id, ctx.task, ctx.error
490
+ })
491
+ ```
492
+
493
+ ### MCP hooks
494
+
495
+ Fired during MCP server lifecycle.
496
+
497
+ ```ts
498
+ agent.hooks.hook('mcp:connect', (ctx) => {
499
+ // ctx.name: server name
500
+ // ctx.transport: 'stdio' | 'sse' | 'streamable-http'
501
+ // ctx.tools: namespaced tool names discovered on this server
502
+ })
503
+
504
+ agent.hooks.hook('mcp:error', (ctx) => {
505
+ // ctx.name: server name
506
+ // ctx.error: connection error
507
+ })
508
+
509
+ agent.hooks.hook('mcp:close', (ctx) => {
510
+ // ctx.name: server name being closed
511
+ })
512
+
513
+ agent.hooks.hook('mcp:tool:before', (ctx) => {
514
+ // ctx.server: MCP server name
515
+ // ctx.tool: original tool name (not namespaced)
516
+ // ctx.input: tool arguments
517
+ })
518
+
519
+ agent.hooks.hook('mcp:tool:after', (ctx) => {
520
+ // ctx.server, ctx.tool, ctx.input
521
+ // ctx.result: tool output string
522
+ })
523
+
524
+ agent.hooks.hook('mcp:tool:error', (ctx) => {
525
+ // ctx.server, ctx.tool, ctx.input, ctx.error
526
+ })
527
+ ```
528
+
529
+ ### Steering inject
530
+
531
+ ```ts
532
+ agent.hooks.hook('steer:inject', (ctx) => {
533
+ // ctx.message: the steering message being injected
534
+ })
535
+ ```
536
+
537
+ ## Steering and Follow-up
538
+
539
+ ### Steering: interrupt mid-run
540
+
541
+ Inject a message while the agent is working. Delivered between tool calls, skipping remaining tools in the current turn.
542
+
543
+ ```ts
544
+ agent.hooks.hook('tool:after', () => {
545
+ agent.steer('focus only on the tests directory')
546
+ })
547
+ ```
548
+
549
+ ### Follow-up, continue after done
550
+
551
+ Queue messages that extend the conversation after the agent finishes.
552
+
553
+ ```ts
554
+ agent.followUp('now write tests for what you built')
555
+ agent.followUp('then update the README')
556
+ ```
557
+
558
+ ## Parallel Tool Execution
559
+
560
+ Execute multiple tool calls from a single turn concurrently.
561
+
562
+ ```ts
563
+ const agent = createAgent({
564
+ harness,
565
+ provider,
566
+ toolExecution: 'parallel', // default: 'sequential'
567
+ })
568
+ ```
569
+
570
+ ## Image Content
571
+
572
+ Pass images alongside the prompt.
573
+
574
+ ```ts
575
+ import { readFileSync } from 'fs'
576
+
577
+ await agent.run({
578
+ prompt: 'describe this screenshot',
579
+ images: [{
580
+ type: 'image',
581
+ source: {
582
+ type: 'base64',
583
+ media_type: 'image/png',
584
+ data: readFileSync('screenshot.png').toString('base64'),
585
+ },
586
+ }],
587
+ })
588
+ ```
589
+
590
+ ## Message Format
591
+
592
+ All messages in zidane use the canonical `SessionMessage` format, with or without sessions:
593
+
594
+ ```ts
595
+ type SessionContentBlock =
596
+ | { type: 'text', text: string }
597
+ | { type: 'image', mediaType: string, data: string }
598
+ | { type: 'tool_call', id: string, name: string, input: Record<string, unknown> }
599
+ | { type: 'tool_result', callId: string, output: string, isError?: boolean }
600
+ | { type: 'thinking', text: string }
601
+
602
+ interface SessionMessage {
603
+ role: 'user' | 'assistant'
604
+ content: SessionContentBlock[]
605
+ }
606
+ ```
607
+
608
+ Providers convert to and from native wire formats internally. Converters are available for external interop:
609
+
610
+ ```ts
611
+ import { fromAnthropic, toAnthropic, fromOpenAI, toOpenAI, autoDetectAndConvert } from 'zidane'
612
+ ```
613
+
614
+ ## Usage Tracking
615
+
616
+ Every turn reports token usage. Provider-specific fields are optional:
617
+
618
+ ```ts
619
+ interface TurnUsage {
620
+ input: number
621
+ output: number
622
+ cacheCreation?: number // Anthropic: tokens written to cache
623
+ cacheRead?: number // Anthropic: tokens read from cache
624
+ thinking?: number // thinking tokens used
625
+ cost?: number // USD cost reported by provider (e.g. OpenRouter)
626
+ }
627
+ ```
628
+
629
+ Per-turn data is available on `AgentStats` and `SessionRun`:
630
+
631
+ ```ts
632
+ const stats = await agent.run({ prompt: 'hello' })
633
+ stats.turnUsage // TurnUsage[] per turn
634
+ stats.cost // total cost (sum of per-turn costs, if reported)
635
+
636
+ // In session runs
637
+ session.runs[0].turnUsage // per-turn breakdown
638
+ session.runs[0].totalUsage // aggregated TurnUsage
639
+ session.runs[0].cost // total cost for this run
640
+ ```
641
+
642
+ ## State Management
643
+
644
+ ```ts
645
+ agent.isRunning // boolean: is a run in progress?
646
+ agent.messages // SessionMessage[]: conversation history
647
+ agent.execution // ExecutionContext: where tools run
648
+ agent.handle // ExecutionHandle: spawned context handle
649
+ agent.abort() // cancel the current run
650
+ agent.reset() // clear messages and queues
651
+ await agent.destroy() // clean up execution context and MCP connections
652
+ await agent.waitForIdle() // wait for current run to complete
653
+ ```
654
+
655
+ ## Project Structure
656
+
657
+ ```
658
+ src/
659
+ types.ts shared types
660
+ agent.ts createAgent, AgentHooks, state management
661
+ loop.ts turn execution loop
662
+ start.ts CLI entrypoint
663
+ auth.ts Anthropic OAuth flow
664
+ index.ts package exports
665
+ contexts/
666
+ types.ts ExecutionContext interface, capabilities
667
+ process.ts in-process context (default)
668
+ docker.ts Docker container context
669
+ sandbox.ts remote sandbox context
670
+ index.ts barrel exports
671
+ tools/
672
+ index.ts tool exports
673
+ validation.ts tool argument validation
674
+ shell.ts shell tool
675
+ read-file.ts read_file tool
676
+ write-file.ts write_file tool
677
+ list-files.ts list_files tool
678
+ spawn.ts spawn tool and createSpawnTool factory
679
+ providers/
680
+ index.ts Provider interface
681
+ openai-compat.ts shared OpenAI-compatible utilities
682
+ anthropic.ts Anthropic provider
683
+ openrouter.ts OpenRouter provider
684
+ cerebras.ts Cerebras provider
685
+ harnesses/
686
+ index.ts HarnessConfig, defineHarness, ToolContext
687
+ basic.ts basic harness (shell, read, write, list, spawn)
688
+ mcp/
689
+ index.ts MCP server connection and tool discovery
690
+ session/
691
+ index.ts Session interface, createSession, loadSession
692
+ messages.ts SessionMessage converters (Anthropic/OpenAI)
693
+ memory.ts in-memory session store
694
+ sqlite.ts SQLite-backed session store
695
+ remote.ts HTTP remote session store
696
+ output/
697
+ terminal.ts terminal rendering (md4x)
698
+ test/
699
+ mock-provider.ts mock provider for testing
700
+ mock-context.ts mock execution context for testing
701
+ agent.test.ts agent loop tests
702
+ contexts.test.ts execution context tests
703
+ harness.test.ts harness tests
704
+ mcp.test.ts MCP connection and hook tests
705
+ spawn.test.ts spawn tool and hook tests
706
+ validation.test.ts validation tests
707
+ providers.test.ts provider tests
708
+ openai-compat.test.ts OpenAI-compat utility tests
709
+ session.test.ts session store and agent integration tests
710
+ session-messages.test.ts SessionMessage converter tests
711
+ ```
712
+
713
+ ## Testing
714
+
715
+ ```bash
716
+ bun test
717
+ ```
718
+
719
+ 300 tests with mock provider and mock execution context, no LLM calls or Docker needed.
720
+
721
+ ## License
722
+
723
+ ISC
724
+