wolli 0.0.1 → 0.0.2

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,2331 @@
1
+ # Extensions
2
+
3
+ Extensions are TypeScript modules that extend Wolli's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more.
4
+
5
+ > **Placement for /reload:** Put extensions in an agent's `~/.wolli/agents/<name>/extensions/` for auto-discovery, or list extra paths under `extensions` in `settings.json`. Extensions in auto-discovered locations can be hot-reloaded with `/reload`.
6
+
7
+ **Key capabilities:**
8
+ - **Custom tools** - Register tools the LLM can call via `wolli.registerTool()`
9
+ - **Event interception** - Block or modify tool calls, inject context, customize compaction
10
+ - **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, notify)
11
+ - **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` for complex interactions
12
+ - **Custom commands** - Register commands like `/mycommand` via `wolli.registerCommand()`
13
+ - **Session persistence** - Store state that survives restarts via `ctx.session.appendEntry()`
14
+ - **Custom rendering** - Control how tool calls/results and messages appear in TUI
15
+
16
+ **Example use cases:**
17
+ - Permission gates (confirm before `rm -rf`, `sudo`, etc.)
18
+ - Git checkpointing (stash at each turn, restore on branch)
19
+ - Path protection (block writes to `.env`, `node_modules/`)
20
+ - Custom compaction (summarize conversation your way)
21
+ - Interactive tools (questions, wizards, custom dialogs)
22
+ - Stateful tools (todo lists, connection pools)
23
+ - External integrations (file watchers, webhooks, CI triggers)
24
+
25
+ > **Note:** The extension factory's first argument is named `wolli` throughout this document. That name is just a convention for the extension API object — call it whatever you like. (The package.json manifest key used to declare extensions is `"wolli"`; that key name is fixed and unrelated to the argument name.)
26
+ >
27
+ > Every event handler, command, shortcut, and custom-tool `execute` receives a context bag as its last argument: `ctx: ExtensionContext`, where `ExtensionContext = { session, ui, mode }`. Examples destructure it as `{ session, ui, mode }`. `session` is the live session this invocation acts on (carrying `sessionManager`, `model`, `sendMessage`, `compact`, etc.); `ui` is that session's presentation channel; `mode` is the current run mode. Agent-global capabilities (cwd, environments, model registry, session discovery, integrations, reload, shutdown) live on `wolli`.
28
+
29
+ ## Table of Contents
30
+
31
+ - [Quick Start](#quick-start)
32
+ - [Extension Locations](#extension-locations)
33
+ - [Available Imports](#available-imports)
34
+ - [Writing an Extension](#writing-an-extension)
35
+ - [Extension Styles](#extension-styles)
36
+ - [Events](#events)
37
+ - [Lifecycle Overview](#lifecycle-overview)
38
+ - [Handler Ordering and Folding](#handler-ordering-and-folding)
39
+ - [Session Events](#session-events)
40
+ - [Agent Events](#agent-events)
41
+ - [Model Events](#model-events)
42
+ - [Tool Events](#tool-events)
43
+ - [User Bash Events](#user-bash-events)
44
+ - [Input Events](#input-events)
45
+ - [ExtensionContext](#extensioncontext)
46
+ - [ctx.session members](#ctxsession-members)
47
+ - [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns)
48
+ - [ExtensionAPI Methods](#extensionapi-methods)
49
+ - [State Management](#state-management)
50
+ - [Custom Tools](#custom-tools)
51
+ - [Custom UI](#custom-ui)
52
+ - [Error Handling](#error-handling)
53
+ - [Mode Behavior](#mode-behavior)
54
+ - [Worked Examples](#worked-examples)
55
+
56
+ ## Quick Start
57
+
58
+ Create `~/.wolli/agents/<name>/extensions/my-extension.ts`:
59
+
60
+ ```typescript
61
+ import type { ExtensionAPI } from "@opsyhq/wolli";
62
+ import { Type } from "typebox";
63
+
64
+ export default function (wolli: ExtensionAPI) {
65
+ // React to events
66
+ wolli.on("session_start", async (_event, { ui }) => {
67
+ ui.notify("Extension loaded!", "info");
68
+ });
69
+
70
+ wolli.on("tool_call", async (event, { ui }) => {
71
+ if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
72
+ const ok = await ui.confirm("Dangerous!", "Allow rm -rf?");
73
+ if (!ok) return { block: true, reason: "Blocked by user" };
74
+ }
75
+ });
76
+
77
+ // Register a custom tool
78
+ wolli.registerTool({
79
+ name: "greet",
80
+ label: "Greet",
81
+ description: "Greet someone by name",
82
+ parameters: Type.Object({
83
+ name: Type.String({ description: "Name to greet" }),
84
+ }),
85
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
86
+ return {
87
+ content: [{ type: "text", text: `Hello, ${params.name}!` }],
88
+ details: {},
89
+ };
90
+ },
91
+ });
92
+
93
+ // Register a command
94
+ wolli.registerCommand("hello", {
95
+ description: "Say hello",
96
+ handler: async (args, { ui }) => {
97
+ const name = args.trim() || "world";
98
+ ui.notify(`Hello ${name}!`, "info");
99
+ },
100
+ });
101
+ }
102
+ ```
103
+
104
+ The agent loads extensions from its `extensions/` directory automatically. After editing one, run `/reload` to pick up the change without restarting.
105
+
106
+ ## Extension Locations
107
+
108
+ > **Security:** Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust.
109
+
110
+ Extensions are auto-discovered from the agent's own home, because each agent owns its extensions. There is no project-local extension location.
111
+
112
+ | Location | Scope |
113
+ |----------|-------|
114
+ | `~/.wolli/agents/<name>/extensions/*.ts` | The agent (all sessions) |
115
+ | `~/.wolli/agents/<name>/extensions/*/index.ts` | The agent (subdirectory) |
116
+
117
+ Additional paths via `settings.json` (`extensions` is `string[]`):
118
+
119
+ ```json
120
+ {
121
+ "extensions": [
122
+ "/path/to/local/extension.ts",
123
+ "/path/to/local/extension/dir"
124
+ ]
125
+ }
126
+ ```
127
+
128
+ ## Available Imports
129
+
130
+ | Package | Purpose |
131
+ |---------|---------|
132
+ | `@opsyhq/wolli` | Extension types (`ExtensionAPI`, `ExtensionContext`, events) |
133
+ | `typebox` | Schema definitions for tool parameters |
134
+ | `@earendil-works/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |
135
+ | `@opsyhq/tui` | TUI components for custom rendering |
136
+
137
+ npm dependencies work too. Add a `package.json` next to your extension (or in a parent directory), run `npm install`, and imports from `node_modules/` are resolved automatically.
138
+
139
+ Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.
140
+
141
+ ## Writing an Extension
142
+
143
+ An extension exports a default factory function that receives `ExtensionAPI`. The factory can be synchronous or asynchronous:
144
+
145
+ ```typescript
146
+ import type { ExtensionAPI } from "@opsyhq/wolli";
147
+
148
+ export default function (wolli: ExtensionAPI) {
149
+ // Subscribe to events
150
+ wolli.on("event_name", async (event, { ui }) => {
151
+ // ui for user interaction
152
+ const ok = await ui.confirm("Title", "Are you sure?");
153
+ ui.notify("Done!", "info");
154
+ ui.setStatus("my-ext", "Processing..."); // Footer status
155
+ ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor (default)
156
+ });
157
+
158
+ // Register tools, commands, shortcuts, flags
159
+ wolli.registerTool({ ... });
160
+ wolli.registerCommand("name", { ... });
161
+ wolli.registerShortcut("ctrl+x", { ... });
162
+ wolli.registerFlag("my-flag", { ... });
163
+ }
164
+ ```
165
+
166
+ Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation.
167
+
168
+ If the factory returns a `Promise`, Wolli awaits it before continuing startup. That means async initialization completes before `session_start`.
169
+
170
+ ### Async factory functions
171
+
172
+ Use an async factory for one-time startup work such as fetching remote configuration.
173
+
174
+ ```typescript
175
+ import type { ExtensionAPI } from "@opsyhq/wolli";
176
+
177
+ export default async function (wolli: ExtensionAPI) {
178
+ const response = await fetch("http://localhost:1234/config");
179
+ const config = await response.json();
180
+ // Use config to set up tools, commands, etc.
181
+ }
182
+ ```
183
+
184
+ ### Extension Styles
185
+
186
+ **Single file** - simplest, for small extensions:
187
+
188
+ ```
189
+ ~/.wolli/agents/<name>/extensions/
190
+ └── my-extension.ts
191
+ ```
192
+
193
+ **Directory with index.ts** - for multi-file extensions:
194
+
195
+ ```
196
+ ~/.wolli/agents/<name>/extensions/
197
+ └── my-extension/
198
+ ├── index.ts # Entry point (exports default function)
199
+ ├── tools.ts # Helper module
200
+ └── utils.ts # Helper module
201
+ ```
202
+
203
+ **Package with dependencies** - for extensions that need npm packages:
204
+
205
+ ```
206
+ ~/.wolli/agents/<name>/extensions/
207
+ └── my-extension/
208
+ ├── package.json # Declares dependencies and entry points
209
+ ├── package-lock.json
210
+ ├── node_modules/ # After npm install
211
+ └── src/
212
+ └── index.ts
213
+ ```
214
+
215
+ ```json
216
+ // package.json
217
+ {
218
+ "name": "my-extension",
219
+ "dependencies": {
220
+ "zod": "^3.0.0",
221
+ "chalk": "^5.0.0"
222
+ },
223
+ "wolli": {
224
+ "extensions": ["./src/index.ts"]
225
+ }
226
+ }
227
+ ```
228
+
229
+ Run `npm install` in the extension directory, then imports from `node_modules/` work automatically.
230
+
231
+ ## Events
232
+
233
+ ### Lifecycle Overview
234
+
235
+ ```
236
+ Wolli starts
237
+
238
+ └─► session_start { reason: "startup" }
239
+
240
+
241
+ user sends prompt ─────────────────────────────────────────┐
242
+ │ │
243
+ ├─► (extension commands checked first, bypass if found) │
244
+ ├─► input (can intercept, transform, or handle) │
245
+ ├─► (skill/template expansion if not handled) │
246
+ ├─► before_agent_start (can inject message, modify system prompt)
247
+ ├─► agent_start │
248
+ ├─► message_start / message_update / message_end │
249
+ │ │
250
+ │ ┌─── turn (repeats while LLM calls tools) ───┐ │
251
+ │ │ │ │
252
+ │ ├─► turn_start │ │
253
+ │ ├─► context (can modify messages) │ │
254
+ │ ├─► before_provider_request (can inspect or replace payload)
255
+ │ │ │ │
256
+ │ │ LLM responds, may call tools: │ │
257
+ │ │ ├─► tool_execution_start │ │
258
+ │ │ ├─► tool_call (can block) │ │
259
+ │ │ ├─► tool_execution_update │ │
260
+ │ │ ├─► tool_result (can modify) │ │
261
+ │ │ └─► tool_execution_end │ │
262
+ │ │ │ │
263
+ │ └─► turn_end │ │
264
+ │ │
265
+ └─► agent_end │
266
+
267
+ user sends another prompt ◄────────────────────────────────┘
268
+
269
+ /new (new session) or /sessions (switch session)
270
+ ├─► session_shutdown
271
+ └─► session_start { reason: "new" | "resume", previousSessionFile? }
272
+
273
+ /compact or auto-compaction
274
+ └─► session_before_compact (can cancel or customize)
275
+
276
+ /model or Ctrl+P (model selection/cycling)
277
+ ├─► thinking_level_select (if model change changes/clamps thinking level)
278
+ └─► model_select
279
+
280
+ thinking level changes (settings, keybinding, ctx.session.setThinkingLevel())
281
+ └─► thinking_level_select
282
+
283
+ exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM)
284
+ └─► session_shutdown
285
+ ```
286
+
287
+ ### Handler Ordering and Folding
288
+
289
+ The diagram above is the order *events* fire. Within a single event, multiple handlers can be registered — across extensions, and within one extension. Wolli always invokes them in the same nesting:
290
+
291
+ ```
292
+ for each extension (in extension load order)
293
+ for each handler that extension registered for this event (in registration order)
294
+ await handler(event, ctx)
295
+ ```
296
+
297
+ So "extension load order" decides which extension's handlers run first, and registration order (the order you called `wolli.on(...)` inside that extension's factory) decides ordering among handlers from the same extension.
298
+
299
+ How return values combine depends on the event. There are four shapes:
300
+
301
+ | Event | How handlers combine |
302
+ |-------|----------------------|
303
+ | `tool_call` | Each `{ block, reason }` is recorded; the **first** handler to return `{ block: true }` short-circuits and the tool never runs. Argument mutations (`event.input` mutated in place) are cumulative — later handlers see earlier mutations. |
304
+ | `input`, `user_bash` | First-wins short-circuit. For `input`, `{ action: "handled" }` stops the chain immediately; `{ action: "transform" }` rewrites `event.text`/`event.images` and continues so later handlers see the rewritten text. For `user_bash`, the first handler returning any result wins and the rest are skipped. |
305
+ | `context`, `before_provider_request`, `before_agent_start`, `tool_result`, `message_end` | Fold/chain: the running value (messages, payload, system prompt, result fields, finalized message) threads through every handler, and each handler sees the previous handler's output. `message_end` rejects (and logs) any replacement whose `role` differs from the original. `before_agent_start` accumulates injected `message`s from all handlers while chaining `systemPrompt`. |
306
+ | everything else (`session_*`, `agent_*`, `turn_*`, `message_start`/`message_update`, `tool_execution_*`, `model_select`, `thinking_level_select`) | Notification-only. Return values are ignored; all handlers run. |
307
+
308
+ > A throw inside one handler is caught, reported through the extension error channel, and does **not** stop the remaining handlers. Never rely on a sibling handler's failure to halt processing — for fail-safe gating, return an explicit `{ block: true }` from `tool_call`.
309
+
310
+ You cannot control cross-extension ordering from inside an extension; it follows the order extensions are discovered/loaded. Within your own extension, order your `wolli.on(...)` calls if one handler must observe another's mutation first.
311
+
312
+ ### Session Events
313
+
314
+ #### session_start
315
+
316
+ Fired when a session is started, loaded, or reloaded.
317
+
318
+ ```typescript
319
+ wolli.on("session_start", async (event, { session, ui }) => {
320
+ // event.reason - "startup" | "reload" | "new" | "resume" | "fork"
321
+ // event.previousSessionFile - present for "new", "resume", and "fork"
322
+ ui.notify(`Session: ${session.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
323
+ });
324
+ ```
325
+
326
+ After a successful switch or new-session action, Wolli emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "new" | "resume"` and `previousSessionFile`.
327
+ Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`.
328
+
329
+ #### session_before_compact
330
+
331
+ Fired on compaction. **Can cancel or customize.**
332
+
333
+ ```typescript
334
+ wolli.on("session_before_compact", async (event, ctx) => {
335
+ const { preparation, branchEntries, customInstructions, signal } = event;
336
+
337
+ // Cancel:
338
+ return { cancel: true };
339
+
340
+ // Custom summary:
341
+ return {
342
+ compaction: {
343
+ summary: "...",
344
+ firstKeptEntryId: preparation.firstKeptEntryId,
345
+ tokensBefore: preparation.tokensBefore,
346
+ }
347
+ };
348
+ });
349
+ ```
350
+
351
+ #### session_shutdown
352
+
353
+ Fired before an extension runtime is torn down.
354
+
355
+ ```typescript
356
+ wolli.on("session_shutdown", async (event, ctx) => {
357
+ // event.reason - "quit" | "reload" | "new" | "resume" | "fork"
358
+ // event.targetSessionFile - destination session for session replacement flows
359
+ // Cleanup, save state, etc.
360
+ });
361
+ ```
362
+
363
+ ### Agent Events
364
+
365
+ #### before_agent_start
366
+
367
+ Fired after user submits prompt, before agent loop. Can inject a message and/or modify the system prompt.
368
+
369
+ ```typescript
370
+ wolli.on("before_agent_start", async (event, ctx) => {
371
+ // event.prompt - user's prompt text
372
+ // event.images - attached images (if any)
373
+ // event.systemPrompt - current chained system prompt for this handler
374
+ // (includes changes from earlier before_agent_start handlers)
375
+ // event.systemPromptOptions - structured options used to build the system prompt
376
+ // .config - the agent config (present at the real call site)
377
+ // .cwd - working directory
378
+ // .soul - frozen SOUL.md snapshot ("" when absent)
379
+ // .memory - frozen MEMORY.md snapshot ("" when absent)
380
+ // .user - frozen USER.md snapshot ("" when absent)
381
+ // .skills - skills discovered for this agent
382
+ // .selectedTools - names of the tools active this session
383
+ // .appendSystemPrompt - text appended to the end of the system prompt
384
+
385
+ return {
386
+ // Inject a persistent message (stored in session, sent to LLM)
387
+ message: {
388
+ customType: "my-extension",
389
+ content: "Additional context for the LLM",
390
+ display: true,
391
+ },
392
+ // Replace the system prompt for this turn (chained across extensions)
393
+ systemPrompt: event.systemPrompt + "\n\nExtra instructions for this turn...",
394
+ };
395
+ });
396
+ ```
397
+
398
+ The `systemPromptOptions` field gives extensions access to the same structured data Wolli uses to build the system prompt (type `BuildSystemPromptOptions`). This lets you inspect what Wolli has loaded — the agent config, frozen SOUL/MEMORY/USER snapshots, discovered skills, the active tool names, and any appended system-prompt text — without re-discovering resources or re-parsing flags. Use it when your extension needs to make deep, informed changes to the system prompt while respecting user-provided configuration.
399
+
400
+ Inside `before_agent_start`, `event.systemPrompt` and `ctx.session.getSystemPrompt()` both reflect the chained system prompt as of the current handler. Later `before_agent_start` handlers can still modify it again.
401
+
402
+ #### agent_start / agent_end
403
+
404
+ Fired once per user prompt.
405
+
406
+ ```typescript
407
+ wolli.on("agent_start", async (_event, ctx) => {});
408
+
409
+ wolli.on("agent_end", async (event, ctx) => {
410
+ // event.messages - messages from this prompt
411
+ });
412
+ ```
413
+
414
+ #### turn_start / turn_end
415
+
416
+ Fired for each turn (one LLM response + tool calls).
417
+
418
+ ```typescript
419
+ wolli.on("turn_start", async (event, ctx) => {
420
+ // event.turnIndex, event.timestamp
421
+ });
422
+
423
+ wolli.on("turn_end", async (event, ctx) => {
424
+ // event.turnIndex, event.message, event.toolResults
425
+ });
426
+ ```
427
+
428
+ #### message_start / message_update / message_end
429
+
430
+ Fired for message lifecycle updates.
431
+
432
+ - `message_start` and `message_end` fire for user, assistant, and toolResult messages.
433
+ - `message_update` fires for assistant streaming updates.
434
+ - `message_end` handlers can return `{ message }` to replace the finalized message. The replacement must keep the same `role`.
435
+
436
+ ```typescript
437
+ wolli.on("message_start", async (event, ctx) => {
438
+ // event.message
439
+ });
440
+
441
+ wolli.on("message_update", async (event, ctx) => {
442
+ // event.message
443
+ // event.assistantMessageEvent (token-by-token stream event)
444
+ });
445
+
446
+ wolli.on("message_end", async (event, ctx) => {
447
+ if (event.message.role !== "assistant") return;
448
+
449
+ return {
450
+ message: {
451
+ ...event.message,
452
+ usage: {
453
+ ...event.message.usage,
454
+ cost: {
455
+ ...event.message.usage.cost,
456
+ total: 0.123,
457
+ },
458
+ },
459
+ },
460
+ };
461
+ });
462
+ ```
463
+
464
+ #### tool_execution_start / tool_execution_update / tool_execution_end
465
+
466
+ Fired for tool execution lifecycle updates.
467
+
468
+ In parallel tool mode:
469
+ - `tool_execution_start` is emitted in assistant source order during the preflight phase
470
+ - `tool_execution_update` events may interleave across tools
471
+ - `tool_execution_end` is emitted in tool completion order after each tool is finalized
472
+ - final `toolResult` message events are still emitted later in assistant source order
473
+
474
+ ```typescript
475
+ wolli.on("tool_execution_start", async (event, ctx) => {
476
+ // event.toolCallId, event.toolName, event.args
477
+ });
478
+
479
+ wolli.on("tool_execution_update", async (event, ctx) => {
480
+ // event.toolCallId, event.toolName, event.args, event.partialResult
481
+ });
482
+
483
+ wolli.on("tool_execution_end", async (event, ctx) => {
484
+ // event.toolCallId, event.toolName, event.result, event.isError
485
+ });
486
+ ```
487
+
488
+ #### context
489
+
490
+ Fired before each LLM call. Modify messages non-destructively.
491
+
492
+ ```typescript
493
+ wolli.on("context", async (event, ctx) => {
494
+ // event.messages - deep copy, safe to modify
495
+ const filtered = event.messages.filter(m => !shouldPrune(m));
496
+ return { messages: filtered };
497
+ });
498
+ ```
499
+
500
+ #### before_provider_request
501
+
502
+ Fired after the provider-specific payload is built, right before the request is sent. Handlers run in extension load order. Returning `undefined` keeps the payload unchanged. Returning any other value replaces the payload for later handlers and for the actual request.
503
+
504
+ This hook can rewrite provider-level system instructions or remove them entirely. Those payload-level changes are not reflected by `ctx.session.getSystemPrompt()`, which reports Wolli's system prompt string rather than the final serialized provider payload.
505
+
506
+ ```typescript
507
+ wolli.on("before_provider_request", (event, ctx) => {
508
+ console.log(JSON.stringify(event.payload, null, 2));
509
+
510
+ // Optional: replace payload
511
+ // return { ...event.payload, temperature: 0 };
512
+ });
513
+ ```
514
+
515
+ This is mainly useful for debugging provider serialization and cache behavior.
516
+
517
+ > **`console.log` and the TUI.** `console.log`/`console.error` write to the host's stdout/stderr. In `mode === "tui"` that shares the terminal with the rendered UI and can corrupt it. There is no logger on `ExtensionContext`. For user-visible diagnostics in interactive mode, use `ui.notify(...)` or `ui.setStatus(...)` instead. Raw `console.*` output is safe in the non-TUI modes (`rpc`, `json`, `print`), so guard it with `ctx.mode !== "tui"` if you need it in both.
518
+
519
+ ### Model Events
520
+
521
+ #### model_select
522
+
523
+ Fired when the model changes via `/model` command, model cycling (`Ctrl+P`), or session restore.
524
+
525
+ ```typescript
526
+ wolli.on("model_select", async (event, { ui }) => {
527
+ // event.model - newly selected model
528
+ // event.previousModel - previous model (undefined if first selection)
529
+ // event.source - "set" | "cycle" | "restore"
530
+
531
+ const prev = event.previousModel
532
+ ? `${event.previousModel.provider}/${event.previousModel.id}`
533
+ : "none";
534
+ const next = `${event.model.provider}/${event.model.id}`;
535
+
536
+ ui.notify(`Model changed (${event.source}): ${prev} -> ${next}`, "info");
537
+ });
538
+ ```
539
+
540
+ Use this to update UI elements (status bars, footers) or perform model-specific initialization when the active model changes.
541
+
542
+ #### thinking_level_select
543
+
544
+ Fired when the thinking level changes. This is notification-only; handler return values are ignored.
545
+
546
+ ```typescript
547
+ wolli.on("thinking_level_select", async (event, { ui }) => {
548
+ // event.level - newly selected thinking level
549
+ // event.previousLevel - previous thinking level
550
+
551
+ ui.setStatus("thinking", `thinking: ${event.level}`);
552
+ });
553
+ ```
554
+
555
+ Use this to update extension UI when `ctx.session.setThinkingLevel()`, model changes, or built-in thinking-level controls change the active thinking level.
556
+
557
+ ### Tool Events
558
+
559
+ #### tool_call
560
+
561
+ Fired after `tool_execution_start`, before the tool executes. **Can block.** Use `isToolCallEventType` to narrow and get typed inputs.
562
+
563
+ Before `tool_call` runs, Wolli waits for previously emitted Agent events to finish draining. This means `ctx.session.sessionManager` is up to date through the current assistant tool-calling message.
564
+
565
+ In the default parallel tool execution mode, sibling tool calls from the same assistant message are preflighted sequentially, then executed concurrently. `tool_call` is not guaranteed to see sibling tool results from that same assistant message in `ctx.session.sessionManager`.
566
+
567
+ `event.input` is mutable. Mutate it in place to patch tool arguments before execution.
568
+
569
+ Behavior guarantees:
570
+ - Mutations to `event.input` affect the actual tool execution
571
+ - Later `tool_call` handlers see mutations made by earlier handlers
572
+ - No re-validation is performed after your mutation
573
+ - Return values from `tool_call` only control blocking via `{ block: true, reason?: string }`
574
+
575
+ ```typescript
576
+ import { isToolCallEventType } from "@opsyhq/wolli";
577
+
578
+ wolli.on("tool_call", async (event, ctx) => {
579
+ // event.toolName - "bash", "read", "write", "edit", etc.
580
+ // event.toolCallId
581
+ // event.input - tool parameters (mutable)
582
+
583
+ // Built-in tools: no type params needed
584
+ if (isToolCallEventType("bash", event)) {
585
+ // event.input is { command: string; timeout?: number }
586
+ event.input.command = `source ~/.profile\n${event.input.command}`;
587
+
588
+ if (event.input.command.includes("rm -rf")) {
589
+ return { block: true, reason: "Dangerous command" };
590
+ }
591
+ }
592
+
593
+ if (isToolCallEventType("read", event)) {
594
+ // event.input is { path: string; offset?: number; limit?: number }
595
+ console.log(`Reading: ${event.input.path}`);
596
+ }
597
+ });
598
+ ```
599
+
600
+ #### Typing custom tool input
601
+
602
+ Custom tools should export their input type:
603
+
604
+ ```typescript
605
+ // my-extension.ts
606
+ export type MyToolInput = Static<typeof myToolSchema>;
607
+ ```
608
+
609
+ Use `isToolCallEventType` with explicit type parameters:
610
+
611
+ ```typescript
612
+ import { isToolCallEventType } from "@opsyhq/wolli";
613
+ import type { MyToolInput } from "my-extension";
614
+
615
+ wolli.on("tool_call", (event) => {
616
+ if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) {
617
+ event.input.action; // typed
618
+ }
619
+ });
620
+ ```
621
+
622
+ #### tool_result
623
+
624
+ Fired after tool execution finishes and before `tool_execution_end` plus the final tool result message events are emitted. **Can modify result.**
625
+
626
+ In parallel tool mode, `tool_result` and `tool_execution_end` may interleave in tool completion order, while final `toolResult` message events are still emitted later in assistant source order.
627
+
628
+ `tool_result` handlers chain like middleware:
629
+ - Handlers run in extension load order
630
+ - Each handler sees the latest result after previous handler changes
631
+ - Handlers can return partial patches (`content`, `details`, or `isError`); omitted fields keep their current values
632
+
633
+ Use `ctx.session.signal` for nested async work inside the handler. This lets Esc cancel model calls, `fetch()`, and other abort-aware operations started by the extension.
634
+
635
+ ```typescript
636
+ import { isBashToolResult } from "@opsyhq/wolli";
637
+
638
+ wolli.on("tool_result", async (event, { session }) => {
639
+ // event.toolName, event.toolCallId, event.input
640
+ // event.content, event.details, event.isError
641
+
642
+ if (isBashToolResult(event)) {
643
+ // event.details is typed as BashToolDetails
644
+ }
645
+
646
+ const response = await fetch("https://example.com/summarize", {
647
+ method: "POST",
648
+ body: JSON.stringify({ content: event.content }),
649
+ signal: session.signal,
650
+ });
651
+
652
+ // Modify result:
653
+ return { content: [...], details: {...}, isError: false };
654
+ });
655
+ ```
656
+
657
+ ### User Bash Events
658
+
659
+ #### user_bash
660
+
661
+ Fired when user executes `!` or `!!` commands. **Can intercept.**
662
+
663
+ ```typescript
664
+ import { createHostEnvironment } from "@opsyhq/wolli";
665
+
666
+ wolli.on("user_bash", (event, ctx) => {
667
+ // event.command - the bash command
668
+ // event.excludeFromContext - true if !! prefix
669
+ // event.cwd - working directory
670
+
671
+ // Option 1: Run the command in a custom environment (e.g., a sandbox)
672
+ return { environment: customEnvironment };
673
+
674
+ // Option 2: Wrap Wolli's host environment to rewrite commands
675
+ const host = createHostEnvironment(event.cwd);
676
+ return {
677
+ environment: {
678
+ ...host,
679
+ exec: (command, cwd, options) => host.exec(`source ~/.profile\n${command}`, cwd, options),
680
+ },
681
+ };
682
+
683
+ // Option 3: Full replacement - return result directly
684
+ return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } };
685
+ });
686
+ ```
687
+
688
+ ### Input Events
689
+
690
+ #### input
691
+
692
+ Fired when user input is received, after extension commands are checked but before skill and template expansion. The event sees the raw input text, so `/skill:foo` and `/template` are not yet expanded.
693
+
694
+ **Processing order:**
695
+ 1. Extension commands (`/cmd`) checked first - if found, handler runs and input event is skipped
696
+ 2. `input` event fires - can intercept, transform, or handle
697
+ 3. If not handled: skill commands (`/skill:name`) expanded to skill content
698
+ 4. If not handled: prompt templates (`/template`) expanded to template content
699
+ 5. Agent processing begins (`before_agent_start`, etc.)
700
+
701
+ ```typescript
702
+ wolli.on("input", async (event, { ui }) => {
703
+ // event.text - raw input (before skill/template expansion)
704
+ // event.images - attached images, if any
705
+ // event.source - "interactive" (typed), "rpc", or "extension" (via sendUserMessage)
706
+ // event.streamingBehavior - "steer" | "followUp" | undefined
707
+ // undefined when idle, "steer" for mid-stream interrupts,
708
+ // "followUp" for messages queued until the agent finishes
709
+
710
+ // Transform: rewrite input before expansion
711
+ if (event.text.startsWith("?quick "))
712
+ return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` };
713
+
714
+ // Handle: respond without LLM (extension shows its own feedback)
715
+ if (event.text === "ping") {
716
+ ui.notify("pong", "info");
717
+ return { action: "handled" };
718
+ }
719
+
720
+ // Route by source: skip processing for extension-injected messages
721
+ if (event.source === "extension") return { action: "continue" };
722
+
723
+ // Intercept skill commands before expansion
724
+ if (event.text.startsWith("/skill:")) {
725
+ // Could transform, block, or let pass through
726
+ }
727
+
728
+ return { action: "continue" }; // Default: pass through to expansion
729
+ });
730
+ ```
731
+
732
+ **Results:**
733
+ - `continue` - pass through unchanged (default if handler returns nothing)
734
+ - `transform` - modify text/images, then continue to expansion
735
+ - `handled` - skip agent entirely (first handler to return this wins)
736
+
737
+ Transforms chain across handlers.
738
+
739
+ ## ExtensionContext
740
+
741
+ Every event handler, command, shortcut, and custom-tool `execute` receives a context bag as its last argument: `ctx: ExtensionContext`. It has exactly three members:
742
+
743
+ ```typescript
744
+ interface ExtensionContext {
745
+ session: Session; // the live session this handler/tool/command acts on
746
+ ui: ExtensionUIContext; // that session's presentation channel
747
+ mode: ExtensionMode; // "tui" | "rpc" | "json" | "print"
748
+ }
749
+ ```
750
+
751
+ Destructure whichever you need, e.g. `{ session }`, `{ ui }`, or `{ session, ui, mode }`.
752
+
753
+ - **`ctx.session`** is the live session the handler is acting on. It carries the per-session surface (session manager, model, abort signal, send/append/compact actions, tool and model controls). See [ctx.session members](#ctxsession-members).
754
+ - **`ctx.ui`** is the UI rail for user interaction, scoped to this session: a dialog raised through `ui` routes only to this session's subscribers. See [Custom UI](#custom-ui) for the full surface.
755
+ - **`ctx.mode`** is the current run mode: `"tui"`, `"rpc"`, `"json"`, or `"print"`. Use `ctx.mode === "tui"` to guard terminal-only features such as `custom()`, component factories, terminal input, and direct TUI rendering. See [Mode Behavior](#mode-behavior).
756
+
757
+ Outside a handler (for example, in an integration `.on(...)` callback registered at load time), there is no `ctx` in scope. Reach a session through the agent-global discovery methods on `wolli` — `wolli.getSession(id)`, `wolli.openSession(id)`, `wolli.createSession()` — described in [ExtensionAPI Methods](#extensionapi-methods).
758
+
759
+ ### ctx.session members
760
+
761
+ The members below all live on `ctx.session` (type `Session`).
762
+
763
+ #### session.sessionManager
764
+
765
+ Read-only access to session state.
766
+
767
+ For `tool_call`, this state is synchronized through the current assistant message before handlers run. In parallel tool execution mode it is still not guaranteed to include sibling tool results from the same assistant message.
768
+
769
+ ```typescript
770
+ session.sessionManager.getEntries() // All entries
771
+ session.sessionManager.getBranch() // Current branch
772
+ session.sessionManager.getLeafId() // Current leaf entry ID
773
+ session.sessionManager.getLabel(entryId) // Label on an entry, if any
774
+ session.sessionManager.getSessionFile() // Session file path, or undefined
775
+ ```
776
+
777
+ To write tags onto a session, use `appendTags(tags: Record<string, string>): Promise<string>` — the method to call on the `SessionManager` handed to a `createSession({ setup })` callback. (`session.getTags()` / `session.setTags(tags)` read and replace the live session's folded tags; `appendTags` is the additive form used when seeding a freshly created session.)
778
+
779
+ #### session.model
780
+
781
+ The current model, or `undefined`. For the model registry and API keys, use `wolli.modelRegistry` (see [ExtensionAPI Methods](#extensionapi-methods)).
782
+
783
+ #### session.signal
784
+
785
+ The current agent abort signal, or `undefined` when no agent turn is active.
786
+
787
+ Use this for abort-aware nested work started by extension handlers, for example:
788
+ - `fetch(..., { signal: session.signal })`
789
+ - model calls that accept `signal`
790
+ - file or process helpers that accept `AbortSignal`
791
+
792
+ `session.signal` is typically defined during active turn events such as `tool_call`, `tool_result`, `message_update`, and `turn_end`.
793
+ It is usually `undefined` in idle or non-turn contexts such as session events, extension commands, and shortcuts fired while Wolli is idle.
794
+
795
+ ```typescript
796
+ wolli.on("tool_result", async (event, { session }) => {
797
+ const response = await fetch("https://example.com/api", {
798
+ method: "POST",
799
+ body: JSON.stringify(event),
800
+ signal: session.signal,
801
+ });
802
+
803
+ const data = await response.json();
804
+ return { details: data };
805
+ });
806
+ ```
807
+
808
+ #### session.prompt(text, options?)
809
+
810
+ Submit user input through the full command/skill/prompt pipeline, then hand off to the harness.
811
+
812
+ #### session.isIdle() / session.abort() / session.waitForIdle() / session.getPendingMessageCount() / session.hasPendingMessages()
813
+
814
+ Control flow helpers.
815
+
816
+ `session.waitForIdle()` waits for the agent to finish streaming:
817
+
818
+ ```typescript
819
+ wolli.registerCommand("my-cmd", {
820
+ handler: async (args, { session }) => {
821
+ await session.waitForIdle();
822
+ // Agent is now idle, safe to modify session
823
+ },
824
+ });
825
+ ```
826
+
827
+ #### session.getContextUsage()
828
+
829
+ Returns current context usage for the active model. Uses last assistant usage when available, then estimates tokens for trailing messages. The `tokens` and `percent` fields are `null` when token count is unknown (e.g. right after compaction, before the next LLM response).
830
+
831
+ ```typescript
832
+ const usage = session.getContextUsage();
833
+ if (usage && usage.tokens !== null && usage.tokens > 100_000) {
834
+ // ...
835
+ }
836
+ ```
837
+
838
+ #### session.compact(options?)
839
+
840
+ Trigger compaction without awaiting completion. Use `onComplete` and `onError` for follow-up actions.
841
+
842
+ ```typescript
843
+ session.compact({
844
+ customInstructions: "Focus on recent changes",
845
+ onComplete: (result) => {
846
+ ui.notify("Compaction completed", "info");
847
+ },
848
+ onError: (error) => {
849
+ ui.notify(`Compaction failed: ${error.message}`, "error");
850
+ },
851
+ });
852
+ ```
853
+
854
+ #### session.getSystemPrompt()
855
+
856
+ Returns Wolli's current system prompt string.
857
+
858
+ - During `before_agent_start`, this reflects chained system-prompt changes made so far for the current turn.
859
+ - It does not include later `context` message mutations.
860
+ - It does not include `before_provider_request` payload rewrites.
861
+ - If later-loaded extensions run after yours, they can still change what is ultimately sent.
862
+
863
+ ```typescript
864
+ wolli.on("before_agent_start", (event, { session }) => {
865
+ const prompt = session.getSystemPrompt();
866
+ console.log(`System prompt length: ${prompt.length}`);
867
+ });
868
+ ```
869
+
870
+ #### session.getSystemPromptOptions()
871
+
872
+ Returns the base inputs Wolli currently uses to build the system prompt.
873
+
874
+ ```typescript
875
+ const options = session.getSystemPromptOptions();
876
+ const activeToolNames = options.selectedTools ?? [];
877
+ ```
878
+
879
+ This has the same shape as `before_agent_start` `event.systemPromptOptions` (type `BuildSystemPromptOptions`): the agent config, cwd, frozen SOUL/MEMORY/USER snapshots, discovered skills, the active tool names (`selectedTools`), and appended system-prompt text. The frozen memory snapshots can contain sensitive content, so treat it as sensitive extension-local data and avoid exposing it through command lists, logs, or autocomplete metadata.
880
+
881
+ This reports the current base prompt inputs. It does not include per-turn `before_agent_start` chained system-prompt changes, later `context` event message mutations, or `before_provider_request` payload rewrites.
882
+
883
+ #### session.sendMessage(message, options?)
884
+
885
+ Inject a custom message into the session. Signature:
886
+
887
+ ```typescript
888
+ sendMessage<T = unknown>(
889
+ message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
890
+ options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
891
+ ): void;
892
+ ```
893
+
894
+ `CustomMessage<T>` fields you pass: `customType: string`, `content: string | (TextContent | ImageContent)[]`, `display: boolean` (show in TUI), and optional `details?: T` (arbitrary payload available to a registered renderer and to state reconstruction).
895
+
896
+ ```typescript
897
+ session.sendMessage({
898
+ customType: "my-extension",
899
+ content: "Message text",
900
+ display: true,
901
+ details: { ... },
902
+ }, {
903
+ triggerTurn: true,
904
+ deliverAs: "steer",
905
+ });
906
+ ```
907
+
908
+ > **`sendMessage` vs `appendEntry`.** `sendMessage` creates a `role: "custom"` message entry that **is** shown in the TUI (when `display: true`) and **is** sent to the LLM — `convertToLlm` maps a `custom` message to a `user` message, so its `content` enters model context. `appendEntry` (see [session.appendEntry](#sessionappendentrycustomtype-data)) stores a role-less custom entry that is **not** part of LLM context at all. Use `sendMessage` to put something in front of both the user and the model; use `appendEntry` for invisible extension-only state. The two also persist as different entry kinds — see [State Management](#state-management).
909
+
910
+ **Options:**
911
+ - `deliverAs` - Delivery mode:
912
+ - `"steer"` (default) - Queues the message while streaming. Delivered after the current assistant turn finishes executing its tool calls, before the next LLM call.
913
+ - `"followUp"` - Waits for agent to finish. Delivered only when agent has no more tool calls.
914
+ - `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything.
915
+ - `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `"steer"` and `"followUp"` modes (ignored for `"nextTurn"`).
916
+
917
+ When the agent is idle and `triggerTurn` is omitted/false, the message is appended (and persisted) without starting an LLM turn.
918
+
919
+ #### session.sendUserMessage(content, options?)
920
+
921
+ Send a user message to the agent. Unlike `sendMessage()` which sends custom messages, this sends an actual user message that appears as if typed by the user. Always triggers a turn.
922
+
923
+ ```typescript
924
+ // Simple text message
925
+ session.sendUserMessage("What is 2+2?");
926
+
927
+ // With content array (text + images)
928
+ session.sendUserMessage([
929
+ { type: "text", text: "Describe this image:" },
930
+ { type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } },
931
+ ]);
932
+
933
+ // During streaming - must specify delivery mode
934
+ session.sendUserMessage("Focus on error handling", { deliverAs: "steer" });
935
+ session.sendUserMessage("And then summarize", { deliverAs: "followUp" });
936
+ ```
937
+
938
+ **Options:**
939
+ - `deliverAs` - Required when agent is streaming:
940
+ - `"steer"` - Queues the message for delivery after the current assistant turn finishes executing its tool calls
941
+ - `"followUp"` - Waits for agent to finish all tools
942
+
943
+ When not streaming, the message is sent immediately and triggers a new turn. When streaming without `deliverAs`, throws an error.
944
+
945
+ #### session.appendEntry(customType, data?)
946
+
947
+ Persist extension state (does NOT participate in LLM context).
948
+
949
+ ```typescript
950
+ wolli.on("session_start", async (_event, { session }) => {
951
+ session.appendEntry("my-state", { count: 42 });
952
+
953
+ // Restore on reload
954
+ for (const entry of session.sessionManager.getEntries()) {
955
+ if (entry.type === "custom" && entry.customType === "my-state") {
956
+ // Reconstruct from entry.data
957
+ }
958
+ }
959
+ });
960
+ ```
961
+
962
+ #### session.getSessionName() / session.setSessionName(name)
963
+
964
+ Get or set the session display name (shown in the session selector instead of the first message).
965
+
966
+ ```typescript
967
+ session.setSessionName("Refactor auth module");
968
+
969
+ const name = session.getSessionName();
970
+ if (name) {
971
+ console.log(`Session: ${name}`);
972
+ }
973
+ ```
974
+
975
+ #### session.setLabel(entryId, label)
976
+
977
+ Set or clear a label on an entry. Labels are user-defined markers for bookmarking and navigation.
978
+
979
+ ```typescript
980
+ // Set a label
981
+ session.setLabel(entryId, "checkpoint-before-refactor");
982
+
983
+ // Clear a label
984
+ session.setLabel(entryId, undefined);
985
+
986
+ // Read labels via sessionManager
987
+ const label = session.sessionManager.getLabel(entryId);
988
+ ```
989
+
990
+ Labels persist in the session and survive restarts. Use them to mark important points (turns, checkpoints) in the conversation tree.
991
+
992
+ #### session.getTags() / session.setTags(tags)
993
+
994
+ Read or merge the session's folded tags — a durable, append-only key/value binding an extension owns (for example, to an external chat). Core never interprets the keys. Query across sessions with `wolli.findSessions(...)`.
995
+
996
+ ```typescript
997
+ session.setTags({ "telegram:chat": String(chatId) });
998
+
999
+ const tags = session.getTags();
1000
+ const chat = tags["telegram:chat"];
1001
+ ```
1002
+
1003
+ #### session.getCommands()
1004
+
1005
+ Get the slash commands available for invocation via `prompt` in the current session. Includes extension commands, prompt templates, and skill commands.
1006
+ The list order is: extensions first, then templates, then skills.
1007
+
1008
+ ```typescript
1009
+ const commands = session.getCommands();
1010
+ const fromExtensions = commands.filter((command) => command.source === "extension");
1011
+ const userScoped = commands.filter((command) => command.sourceInfo.scope === "user");
1012
+ ```
1013
+
1014
+ Each entry has this shape (`SlashCommandInfo`):
1015
+
1016
+ ```typescript
1017
+ {
1018
+ name: string; // Invokable command name without the leading slash. May be suffixed like "review:1"
1019
+ description?: string;
1020
+ source: "extension" | "prompt" | "skill";
1021
+ sourceInfo: SourceInfo; // { path, source, scope, origin, baseDir? }
1022
+ }
1023
+ ```
1024
+
1025
+ Use `sourceInfo` as the canonical provenance field. Do not infer ownership from command names or from ad hoc path parsing.
1026
+
1027
+ Built-in interactive commands (like `/model` and `/settings`) are not included here. They are handled only in interactive mode and would not execute if sent via `prompt`.
1028
+
1029
+ #### session.getActiveTools() / session.getAllTools() / session.setActiveTools(names) / session.refreshTools()
1030
+
1031
+ Manage active tools. This works for both built-in tools and dynamically registered tools.
1032
+
1033
+ ```typescript
1034
+ const active = session.getActiveTools();
1035
+ const all = session.getAllTools();
1036
+ // [{
1037
+ // name: "read",
1038
+ // description: "Read file contents...",
1039
+ // parameters: ...,
1040
+ // promptGuidelines: ["Use read to examine files instead of cat or sed."],
1041
+ // sourceInfo: { path: "<builtin:read>", source: "builtin", scope: "temporary", origin: "top-level" }
1042
+ // }, ...]
1043
+ const names = all.map(t => t.name);
1044
+ const builtinTools = all.filter((t) => t.sourceInfo.source === "builtin");
1045
+ const extensionTools = all.filter((t) => t.sourceInfo.source !== "builtin" && t.sourceInfo.source !== "sdk");
1046
+ session.setActiveTools(["read", "bash"]); // Switch to read-only
1047
+ ```
1048
+
1049
+ `session.getAllTools()` returns `name`, `description`, `parameters`, `promptGuidelines`, and `sourceInfo`.
1050
+
1051
+ `session.refreshTools()` re-applies the base + extension tool set, picking up tools registered mid-session.
1052
+
1053
+ Typical `sourceInfo.source` values:
1054
+ - `builtin` for built-in tools
1055
+ - `sdk` for tools passed via `createAgentSession({ tools })`
1056
+ - extension source metadata for tools registered by extensions
1057
+
1058
+ #### session.setModel(model) / session.setModelById(provider, modelId)
1059
+
1060
+ Set the current model. `setModel` returns `false` if no API key is available for the model. `setModelById` resolves `{ provider, modelId }` and throws if the model is unknown or unauthenticated.
1061
+
1062
+ ```typescript
1063
+ const model = wolli.modelRegistry.find("anthropic", "claude-sonnet-4-5");
1064
+ if (model) {
1065
+ const success = await session.setModel(model);
1066
+ if (!success) {
1067
+ ui.notify("No API key for this model", "error");
1068
+ }
1069
+ }
1070
+ ```
1071
+
1072
+ #### session.getThinkingLevel() / session.setThinkingLevel(level)
1073
+
1074
+ Get or set the thinking level. Level is clamped to model capabilities (non-reasoning models always use "off"). Changes emit `thinking_level_select`.
1075
+
1076
+ ```typescript
1077
+ const current = session.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
1078
+ await session.setThinkingLevel("high");
1079
+ ```
1080
+
1081
+ #### session.newSession(options?)
1082
+
1083
+ Start a new session, optionally with initialization. The new session goes live; other resident sessions stay live (additive).
1084
+
1085
+ ```typescript
1086
+ const kickoff = "Continue in the replacement session";
1087
+
1088
+ const result = await session.newSession({
1089
+ setup: async (sm) => {
1090
+ sm.appendMessage({
1091
+ role: "user",
1092
+ content: [{ type: "text", text: "Context from previous session..." }],
1093
+ timestamp: Date.now(),
1094
+ });
1095
+ },
1096
+ withSession: async (newSession) => {
1097
+ // Use only the replacement session here.
1098
+ await newSession.sendUserMessage(kickoff);
1099
+ },
1100
+ });
1101
+
1102
+ if (result.cancelled) {
1103
+ // An extension cancelled the new session
1104
+ }
1105
+ ```
1106
+
1107
+ Options:
1108
+ - `setup`: mutate the new session's `SessionManager` before `withSession` runs
1109
+ - `withSession`: run post-switch work against the fresh replacement `Session`. Do not use a captured old `session`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns).
1110
+
1111
+ #### session.reload()
1112
+
1113
+ Run the same reload flow as `/reload`.
1114
+
1115
+ ```typescript
1116
+ wolli.registerCommand("reload-runtime", {
1117
+ description: "Reload extensions, skills, prompts, and themes",
1118
+ handler: async (_args, { session }) => {
1119
+ await session.reload();
1120
+ return;
1121
+ },
1122
+ });
1123
+ ```
1124
+
1125
+ Important behavior:
1126
+ - `await session.reload()` emits `session_shutdown` for the current extension runtime
1127
+ - It then reloads resources and emits `session_start` with `reason: "reload"`
1128
+ - The currently running command handler still continues in the old call frame
1129
+ - Code after `await session.reload()` still runs from the pre-reload version
1130
+ - Code after `await session.reload()` must not assume old in-memory extension state is still valid
1131
+ - After the handler returns, future commands/events/tool calls use the new extension version
1132
+
1133
+ For predictable behavior, treat reload as terminal for that handler (`await session.reload(); return;`).
1134
+
1135
+ `wolli.reload()` runs the same flow at the agent-global level.
1136
+
1137
+ Example tool the LLM can call to trigger reload (tools queue a follow-up command rather than reloading inline):
1138
+
1139
+ ```typescript
1140
+ import type { ExtensionAPI } from "@opsyhq/wolli";
1141
+ import { Type } from "typebox";
1142
+
1143
+ export default function (wolli: ExtensionAPI) {
1144
+ wolli.registerCommand("reload-runtime", {
1145
+ description: "Reload extensions, skills, prompts, and themes",
1146
+ handler: async (_args, { session }) => {
1147
+ await session.reload();
1148
+ return;
1149
+ },
1150
+ });
1151
+
1152
+ wolli.registerTool({
1153
+ name: "reload_runtime",
1154
+ label: "Reload Runtime",
1155
+ description: "Reload extensions, skills, prompts, and themes",
1156
+ parameters: Type.Object({}),
1157
+ async execute(toolCallId, params, signal, onUpdate, { session }) {
1158
+ session.sendUserMessage("/reload-runtime", { deliverAs: "followUp" });
1159
+ return {
1160
+ content: [{ type: "text", text: "Queued /reload-runtime as a follow-up command." }],
1161
+ details: {},
1162
+ };
1163
+ },
1164
+ });
1165
+ }
1166
+ ```
1167
+
1168
+ ### Session replacement lifecycle and footguns
1169
+
1170
+ `withSession` receives the fresh replacement `Session`.
1171
+
1172
+ Lifecycle and footguns:
1173
+ - `withSession` runs only after the old session has emitted `session_shutdown`, the old runtime has been torn down, the replacement session has been rebound, and the new extension instance has already received `session_start`.
1174
+ - The callback still executes in the original closure, not inside the new extension instance. That means your old extension instance may already have run its shutdown cleanup before `withSession` starts.
1175
+ - A captured old `session` is stale after replacement and will throw if used. Use only the `session` passed to `withSession` for session-bound work.
1176
+ - Previously extracted raw objects are still your responsibility. For example, if you capture `const sm = session.sessionManager` before replacement, `sm` is still the old `SessionManager` object. Do not reuse it after replacement.
1177
+ - Code in `withSession` should assume any state invalidated by your `session_shutdown` handler is already gone. Only capture plain data that survives shutdown cleanly, such as strings, ids, and serialized config.
1178
+
1179
+ Safe pattern:
1180
+
1181
+ ```typescript
1182
+ wolli.registerCommand("handoff", {
1183
+ handler: async (_args, { session }) => {
1184
+ const kickoff = "Continue from the replacement session";
1185
+ await session.newSession({
1186
+ withSession: async (newSession) => {
1187
+ await newSession.sendUserMessage(kickoff);
1188
+ },
1189
+ });
1190
+ },
1191
+ });
1192
+ ```
1193
+
1194
+ Unsafe pattern:
1195
+
1196
+ ```typescript
1197
+ wolli.registerCommand("handoff", {
1198
+ handler: async (_args, { session }) => {
1199
+ const oldSessionManager = session.sessionManager;
1200
+ await session.newSession({
1201
+ withSession: async (_newSession) => {
1202
+ // stale old objects: do not do this
1203
+ oldSessionManager.getSessionFile();
1204
+ session.sendUserMessage("wrong");
1205
+ },
1206
+ });
1207
+ },
1208
+ });
1209
+ ```
1210
+
1211
+ ## ExtensionAPI Methods
1212
+
1213
+ Methods are split across two objects. The agent-global ones live on `wolli` (the extension factory argument): registration, provider management, integrations, the shared event bus, and session discovery/creation.
1214
+
1215
+ Per-session actions (`sendMessage`, `sendUserMessage`, `appendEntry`, `setSessionName`, `setActiveTools`, `setModel`, `setThinkingLevel`, etc.) live on `ctx.session` instead — destructure `{ session }` from a handler's context, or reach a session through `wolli.getSession(id)` / `wolli.openSession(id)` outside a handler. The `ctx.session` members are documented in [ctx.session members](#ctxsession-members); the `wolli.*` methods are documented here.
1216
+
1217
+ ### wolli.on(event, handler)
1218
+
1219
+ Subscribe to events. See [Events](#events) for event types and return values.
1220
+
1221
+ ### wolli.registerTool(definition)
1222
+
1223
+ Register a custom tool callable by the LLM. See [Custom Tools](#custom-tools) for full details.
1224
+
1225
+ `wolli.registerTool()` works both during extension load and after startup. You can call it inside `session_start`, command handlers, or other event handlers. New tools are refreshed immediately in the same session, so they appear in `ctx.session.getAllTools()` and are callable by the LLM without `/reload`.
1226
+
1227
+ Use `ctx.session.setActiveTools()` to enable or disable tools (including dynamically added tools) at runtime.
1228
+
1229
+ Use `promptSnippet` to opt a custom tool into a one-line entry in `Available tools`, and `promptGuidelines` to append tool-specific bullets to the default `Guidelines` section when the tool is active.
1230
+
1231
+ **Important:** `promptGuidelines` bullets are appended flat to the `Guidelines` section with no tool name prefix. Each guideline must name the tool it refers to — avoid "Use this tool when..." because the LLM cannot tell which tool "this" means. Write "Use my_tool when..." instead.
1232
+
1233
+ ```typescript
1234
+ import { Type } from "typebox";
1235
+ import { StringEnum } from "@earendil-works/pi-ai";
1236
+
1237
+ wolli.registerTool({
1238
+ name: "my_tool",
1239
+ label: "My Tool",
1240
+ description: "What this tool does",
1241
+ promptSnippet: "Summarize or transform text according to action",
1242
+ promptGuidelines: ["Use my_tool when the user asks to summarize previously generated text."],
1243
+ parameters: Type.Object({
1244
+ action: StringEnum(["list", "add"] as const),
1245
+ text: Type.Optional(Type.String()),
1246
+ }),
1247
+ prepareArguments(args) {
1248
+ // Optional compatibility shim. Runs before schema validation.
1249
+ // Return the current schema shape, for example to fold legacy fields
1250
+ // into the modern parameter object.
1251
+ return args;
1252
+ },
1253
+
1254
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1255
+ // Stream progress
1256
+ onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
1257
+
1258
+ return {
1259
+ content: [{ type: "text", text: "Done" }],
1260
+ details: { result: "..." },
1261
+ };
1262
+ },
1263
+
1264
+ // Optional: Custom rendering
1265
+ renderCall(args, theme, context) { ... },
1266
+ renderResult(result, options, theme, context) { ... },
1267
+ });
1268
+ ```
1269
+
1270
+ ### wolli.registerCommand(name, options)
1271
+
1272
+ Register a command.
1273
+
1274
+ The handler signature is `(args: string, ctx: ExtensionContext) => Promise<void>`. `args` is **always a `string`** — the text after the command name, or `""` when none was given. It is never `undefined`. Use `args.trim()` to test for "no argument" rather than `args || ...`, which falsely implies it can be falsy/undefined.
1275
+
1276
+ If multiple extensions register the same command name, Wolli keeps them all and assigns numeric invocation suffixes in load order, for example `/review:1` and `/review:2`.
1277
+
1278
+ ```typescript
1279
+ wolli.registerCommand("stats", {
1280
+ description: "Show session statistics",
1281
+ handler: async (args, { session, ui }) => {
1282
+ const count = session.sessionManager.getEntries().length;
1283
+ ui.notify(`${count} entries`, "info");
1284
+ }
1285
+ });
1286
+ ```
1287
+
1288
+ Optional: add argument auto-completion for `/command ...`:
1289
+
1290
+ ```typescript
1291
+ import type { AutocompleteItem } from "@opsyhq/tui";
1292
+
1293
+ wolli.registerCommand("deploy", {
1294
+ description: "Deploy to an environment",
1295
+ getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
1296
+ const envs = ["dev", "staging", "prod"];
1297
+ const items = envs.map((e) => ({ value: e, label: e }));
1298
+ const filtered = items.filter((i) => i.value.startsWith(prefix));
1299
+ return filtered.length > 0 ? filtered : null;
1300
+ },
1301
+ handler: async (args, { ui }) => {
1302
+ ui.notify(`Deploying: ${args}`, "info");
1303
+ },
1304
+ });
1305
+ ```
1306
+
1307
+ ### wolli.registerMessageRenderer(customType, renderer)
1308
+
1309
+ Register a custom TUI renderer for messages with your `customType`. See [Custom UI](#custom-ui).
1310
+
1311
+ ### wolli.registerShortcut(shortcut, options)
1312
+
1313
+ Register a keyboard shortcut.
1314
+
1315
+ ```typescript
1316
+ wolli.registerShortcut("ctrl+shift+p", {
1317
+ description: "Toggle plan mode",
1318
+ handler: async ({ ui }) => {
1319
+ ui.notify("Toggled!");
1320
+ },
1321
+ });
1322
+ ```
1323
+
1324
+ ### wolli.registerFlag(name, options) / wolli.getFlag(name)
1325
+
1326
+ Register a CLI flag and read its value.
1327
+
1328
+ ```typescript
1329
+ wolli.registerFlag("plan", {
1330
+ description: "Start in plan mode",
1331
+ type: "boolean",
1332
+ default: false,
1333
+ });
1334
+
1335
+ // Check value
1336
+ if (wolli.getFlag("plan")) {
1337
+ // Plan mode enabled
1338
+ }
1339
+ ```
1340
+
1341
+ ### wolli.cwd / wolli.environments / wolli.modelRegistry
1342
+
1343
+ Agent-global, read-only.
1344
+
1345
+ - `wolli.cwd` - the agent's home directory, where its files and the file/shell tools operate.
1346
+ - `wolli.environments` - the full run-target map (type `AgentEnvironments`), including the unconfined `host` target. Reach a specific target via `wolli.environments.targets[...]`.
1347
+ - `wolli.modelRegistry` - model registry for API key resolution and provider registration.
1348
+
1349
+ ### wolli.getSession(id) / wolli.openSession(id) / wolli.createSession(options?) / wolli.listSessions() / wolli.findSessions(filter)
1350
+
1351
+ Find, open, create, and list sessions. These are the agent-global session-discovery methods — use them from callbacks that run without a handler context (for example, integration `.on(...)` listeners).
1352
+
1353
+ - `wolli.getSession(id)` returns a resident (in-memory) `Session` by id, or `undefined` when it is not currently resident. Find-only — never creates or loads.
1354
+ - `wolli.openSession(id)` rehydrates a stored session by id into the resident set (or returns it if already resident), resolving to a `Session`.
1355
+ - `wolli.createSession(options?)` starts a fresh stored session and makes it resident. Additive — other sessions stay live. Accepts the same `NewSessionOptions` as `session.newSession()`.
1356
+ - `wolli.listSessions()` returns the stored sessions for this agent (newest first) as `SessionInfo[]`.
1357
+ - `wolli.findSessions(filter)` locates stored sessions whose folded tags subset-match `filter`, each with `tags` populated — for example, the session another extension bound to an external conversation via `session.setTags(...)`.
1358
+
1359
+ ```typescript
1360
+ // Reach (or create) the session bound to an external chat, from a background callback.
1361
+ const [match] = await wolli.findSessions({ "telegram:chat": String(chatId) });
1362
+ const session = match ? await wolli.openSession(match.id) : await wolli.createSession();
1363
+ await session.sendUserMessage("Triggered from a background callback");
1364
+ ```
1365
+
1366
+ `SessionInfo` has the shape `{ id: string; createdAt: string; tags: Record<string, string> }`. `tags` is `{}` from the plain `listSessions()` listing and populated by `findSessions()`.
1367
+
1368
+ ### wolli.reload() / wolli.shutdown()
1369
+
1370
+ - `wolli.reload()` runs the same reload flow as `/reload` (see [session.reload()](#sessionreload)). Use this at the agent-global level.
1371
+ - `wolli.shutdown()` requests a graceful shutdown of Wolli.
1372
+ - **Interactive mode:** Deferred until the agent becomes idle (after processing all queued steering and follow-up messages).
1373
+ - **Print mode:** No-op. The process exits automatically when all prompts are processed.
1374
+
1375
+ Emits `session_shutdown` to all extensions before exiting. Available in all contexts (event handlers, tools, commands, shortcuts).
1376
+
1377
+ ```typescript
1378
+ wolli.on("tool_call", (event) => {
1379
+ if (isFatal(event.input)) {
1380
+ wolli.shutdown();
1381
+ }
1382
+ });
1383
+ ```
1384
+
1385
+ ### wolli.registerProvider(name, config) / wolli.unregisterProvider(name)
1386
+
1387
+ Register, override, or remove a model provider. See the inline examples in the `@opsyhq/wolli` type definitions for `ProviderConfig` (custom models, baseUrl overrides, and OAuth). To make a registered provider's models usable, log in with `/login` (subscription/OAuth) or supply the provider's API key; OAuth providers registered via `config.oauth` surface in `/login`.
1388
+
1389
+ ### wolli.getIntegration(name, account?)
1390
+
1391
+ Get a handle to a configured integration. See [integrations.md](integrations.md#consuming-an-integration) for the full surface (`.on(event, handler)`, `.call(action, params)`, account resolution, and binding sessions to external conversations).
1392
+
1393
+ ### wolli.events
1394
+
1395
+ Shared event bus for communication between extensions:
1396
+
1397
+ ```typescript
1398
+ wolli.events.on("my:event", (data) => { ... });
1399
+ wolli.events.emit("my:event", { ... });
1400
+ ```
1401
+
1402
+ ## State Management
1403
+
1404
+ Extensions with state should store it in tool result `details` for proper branching support:
1405
+
1406
+ ```typescript
1407
+ export default function (wolli: ExtensionAPI) {
1408
+ let items: string[] = [];
1409
+
1410
+ // Reconstruct state from session
1411
+ wolli.on("session_start", async (_event, { session }) => {
1412
+ items = [];
1413
+ for (const entry of session.sessionManager.getBranch()) {
1414
+ if (entry.type === "message" && entry.message.role === "toolResult") {
1415
+ if (entry.message.toolName === "my_tool") {
1416
+ items = entry.message.details?.items ?? [];
1417
+ }
1418
+ }
1419
+ }
1420
+ });
1421
+
1422
+ wolli.registerTool({
1423
+ name: "my_tool",
1424
+ // ...
1425
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1426
+ items.push("new item");
1427
+ return {
1428
+ content: [{ type: "text", text: "Added" }],
1429
+ details: { items: [...items] }, // Store for reconstruction
1430
+ };
1431
+ },
1432
+ });
1433
+ }
1434
+ ```
1435
+
1436
+ ### Restoring state written by a command (sendMessage)
1437
+
1438
+ The loop above only matches tool-result `details`. State you persisted with `session.sendMessage(...)` from a command does **not** appear as a `toolResult` message — it is stored as a separate entry kind. In `getBranch()` / `getEntries()` a `sendMessage` custom message surfaces as a `CustomMessageEntry`:
1439
+
1440
+ ```typescript
1441
+ wolli.on("session_start", (_event, { session }) => {
1442
+ for (const entry of session.sessionManager.getBranch()) {
1443
+ if (entry.type === "custom_message" && entry.customType === "my-extension") {
1444
+ // entry.content - string | (TextContent | ImageContent)[]
1445
+ // entry.details - the payload you passed to sendMessage
1446
+ // entry.display - boolean
1447
+ const restored = entry.details as { notes?: string[] };
1448
+ // reconstruct from restored.notes...
1449
+ }
1450
+ }
1451
+ });
1452
+ ```
1453
+
1454
+ > The on-disk entry type is `"custom_message"` with top-level `customType` / `content` / `details` / `display` fields. It is **not** a `type: "message"` entry with `message.role === "custom"`, so a reconstruction loop that only scans `entry.type === "message" && entry.message.role === "toolResult"` (the pattern above) will silently never match command-written state. Match `entry.type === "custom_message"` to restore it. `appendEntry` state is different again — it surfaces as `entry.type === "custom"` with `entry.customType` / `entry.data` (see [session.appendEntry](#sessionappendentrycustomtype-data)).
1455
+
1456
+ ## Custom Tools
1457
+
1458
+ Register tools the LLM can call via `wolli.registerTool()`. Tools appear in the system prompt and can have custom rendering.
1459
+
1460
+ Use `promptSnippet` for a short one-line entry in the `Available tools` section in the default system prompt. If omitted, custom tools are left out of that section.
1461
+
1462
+ Use `promptGuidelines` to add tool-specific bullets to the default system prompt `Guidelines` section. These bullets are included only while the tool is active (for example, after `ctx.session.setActiveTools([...])`).
1463
+
1464
+ **Important:** `promptGuidelines` bullets are appended flat to the `Guidelines` section with no tool name prefix or grouping. Each guideline must name the tool it refers to — avoid "Use this tool when..." because the LLM cannot tell which tool "this" means. Write "Use my_tool when..." instead.
1465
+
1466
+ Note: Some models include an `@` prefix in tool path arguments. Built-in tools strip a leading `@` before resolving paths. If your custom tool accepts a path, normalize a leading `@` as well.
1467
+
1468
+ If your custom tool mutates files, use `withFileMutationQueue()` so it participates in the same per-file queue as built-in `edit` and `write`. This matters because tool calls run in parallel by default. Without the queue, two tools can read the same old file contents, compute different updates, and then whichever write lands last overwrites the other.
1469
+
1470
+ Example failure case: your custom tool edits `foo.ts` while built-in `edit` also changes `foo.ts` in the same assistant turn. If your tool does not participate in the queue, both can read the original `foo.ts`, apply separate changes, and one of those changes is lost.
1471
+
1472
+ Pass the real target file path to `withFileMutationQueue()`, not the raw user argument. Resolve it to an absolute path first, relative to `wolli.cwd` or your tool's working directory. For existing files, the helper canonicalizes through `realpath()`, so symlink aliases for the same file share one queue. For new files, it falls back to the resolved absolute path because there is nothing to `realpath()` yet.
1473
+
1474
+ Queue the entire mutation window on that target path. That includes read-modify-write logic, not just the final write.
1475
+
1476
+ ```typescript
1477
+ import { withFileMutationQueue } from "@opsyhq/wolli";
1478
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
1479
+ import { dirname, resolve } from "node:path";
1480
+
1481
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1482
+ const absolutePath = resolve(wolli.cwd, params.path);
1483
+
1484
+ return withFileMutationQueue(absolutePath, async () => {
1485
+ await mkdir(dirname(absolutePath), { recursive: true });
1486
+ const current = await readFile(absolutePath, "utf8");
1487
+ const next = current.replace(params.oldText, params.newText);
1488
+ await writeFile(absolutePath, next, "utf8");
1489
+
1490
+ return {
1491
+ content: [{ type: "text", text: `Updated ${params.path}` }],
1492
+ details: {},
1493
+ };
1494
+ });
1495
+ }
1496
+ ```
1497
+
1498
+ ### Tool Definition
1499
+
1500
+ ```typescript
1501
+ import { Type } from "typebox";
1502
+ import { StringEnum } from "@earendil-works/pi-ai";
1503
+ import { Text } from "@opsyhq/tui";
1504
+ import { execFile } from "node:child_process";
1505
+ import { promisify } from "node:util";
1506
+
1507
+ const execFileAsync = promisify(execFile);
1508
+
1509
+ wolli.registerTool({
1510
+ name: "my_tool",
1511
+ label: "My Tool",
1512
+ description: "What this tool does (shown to LLM)",
1513
+ promptSnippet: "List or add items in the project todo list",
1514
+ promptGuidelines: [
1515
+ "Use my_tool for todo planning instead of direct file edits when the user asks for a task list."
1516
+ ],
1517
+ parameters: Type.Object({
1518
+ action: StringEnum(["list", "add"] as const), // Use StringEnum for Google compatibility
1519
+ text: Type.Optional(Type.String()),
1520
+ }),
1521
+ prepareArguments(args) {
1522
+ if (!args || typeof args !== "object") return args;
1523
+ const input = args as { action?: string; oldAction?: string };
1524
+ if (typeof input.oldAction === "string" && input.action === undefined) {
1525
+ return { ...input, action: input.oldAction };
1526
+ }
1527
+ return args;
1528
+ },
1529
+
1530
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1531
+ // Check for cancellation
1532
+ if (signal?.aborted) {
1533
+ return { content: [{ type: "text", text: "Cancelled" }], details: {} };
1534
+ }
1535
+
1536
+ // Stream progress updates
1537
+ onUpdate?.({
1538
+ content: [{ type: "text", text: "Working..." }],
1539
+ details: { progress: 50 },
1540
+ });
1541
+
1542
+ // Run commands with Node's child_process
1543
+ const result = await execFileAsync("some-command", [], { signal });
1544
+
1545
+ // Return result
1546
+ return {
1547
+ content: [{ type: "text", text: "Done" }], // Sent to LLM
1548
+ details: { data: result }, // For rendering & state
1549
+ // Optional: stop after this tool batch when every finalized tool result
1550
+ // in the batch also returns terminate: true.
1551
+ terminate: true,
1552
+ };
1553
+ },
1554
+
1555
+ // Optional: Custom rendering
1556
+ renderCall(args, theme, context) { ... },
1557
+ renderResult(result, options, theme, context) { ... },
1558
+ });
1559
+ ```
1560
+
1561
+ **`details` is required:** `execute()` (and the `onUpdate?.(...)` partial-result callback) return an `AgentToolResult<T>`, whose `details: T` field is **non-optional**. Every return — including early-exit and cancellation paths — must include `details`. If your tool has no structured payload, return `details: {}` (or `details: undefined` when `T` permits it). Omitting it is a type error, not a defaulted field.
1562
+
1563
+ **Signaling errors:** To mark a tool execution as failed (sets `isError: true` on the result and reports it to the LLM), throw an error from `execute`. Returning a value never sets the error flag regardless of what properties you include in the return object.
1564
+
1565
+ **Early termination:** Return `terminate: true` from `execute()` to hint that the automatic follow-up LLM call should be skipped after the current tool batch. This only takes effect when every finalized tool result in that batch is terminating. This is useful when the agent should end on a final structured-output tool call.
1566
+
1567
+ ```typescript
1568
+ // Correct: throw to signal an error
1569
+ async execute(toolCallId, params) {
1570
+ if (!isValid(params.input)) {
1571
+ throw new Error(`Invalid input: ${params.input}`);
1572
+ }
1573
+ return { content: [{ type: "text", text: "OK" }], details: {} };
1574
+ }
1575
+ ```
1576
+
1577
+ **Important:** Use `StringEnum` from `@earendil-works/pi-ai` for string enums. `Type.Union`/`Type.Literal` doesn't work with Google's API.
1578
+
1579
+ **Argument preparation:** `prepareArguments(args)` is optional. If defined, it runs before schema validation and before `execute()`. Use it to mimic an older accepted input shape when Wolli resumes an older session whose stored tool call arguments no longer match the current schema. Return the object you want validated against `parameters`. Keep the public schema strict. Do not add deprecated compatibility fields to `parameters` just to keep old resumed sessions working.
1580
+
1581
+ Example: an older session may contain an `edit` tool call with top-level `oldText` and `newText`, while the current schema only accepts `edits: [{ oldText, newText }]`.
1582
+
1583
+ ```typescript
1584
+ wolli.registerTool({
1585
+ name: "edit",
1586
+ label: "Edit",
1587
+ description: "Edit a single file using exact text replacement",
1588
+ parameters: Type.Object({
1589
+ path: Type.String(),
1590
+ edits: Type.Array(
1591
+ Type.Object({
1592
+ oldText: Type.String(),
1593
+ newText: Type.String(),
1594
+ }),
1595
+ ),
1596
+ }),
1597
+ prepareArguments(args) {
1598
+ if (!args || typeof args !== "object") return args;
1599
+
1600
+ const input = args as {
1601
+ path?: string;
1602
+ edits?: Array<{ oldText: string; newText: string }>;
1603
+ oldText?: unknown;
1604
+ newText?: unknown;
1605
+ };
1606
+
1607
+ if (typeof input.oldText !== "string" || typeof input.newText !== "string") {
1608
+ return args;
1609
+ }
1610
+
1611
+ return {
1612
+ ...input,
1613
+ edits: [...(input.edits ?? []), { oldText: input.oldText, newText: input.newText }],
1614
+ };
1615
+ },
1616
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1617
+ // params now matches the current schema
1618
+ return {
1619
+ content: [{ type: "text", text: `Applying ${params.edits.length} edit block(s)` }],
1620
+ details: {},
1621
+ };
1622
+ },
1623
+ });
1624
+ ```
1625
+
1626
+ ### Overriding Built-in Tools
1627
+
1628
+ Extensions can override built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens.
1629
+
1630
+ **Rendering:** Built-in renderer inheritance is resolved per slot. Execution override and rendering override are independent. If your override omits `renderCall`, the built-in `renderCall` is used. If your override omits `renderResult`, the built-in `renderResult` is used. If your override omits both, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI.
1631
+
1632
+ **Prompt metadata:** `promptSnippet` and `promptGuidelines` are not inherited from the built-in tool. If your override should keep those prompt instructions, define them on the override explicitly.
1633
+
1634
+ **Your implementation must match the exact result shape**, including the `details` type. The UI and session logic depend on these shapes for rendering and state tracking. The built-in tool details types (`ReadToolDetails`, `BashToolDetails`, `GrepToolDetails`, `FindToolDetails`, `LsToolDetails`, etc.) are exported from `@opsyhq/wolli`.
1635
+
1636
+ ### The Environment seam
1637
+
1638
+ Every built-in file/shell tool (`read`, `write`, `edit`, `ls`, `grep`, `find`, `bash`) consumes a single `Environment` instead of its own per-tool operations. The `Environment` decides where reads/writes/exec land — the host filesystem today, a sandbox or remote backend later. The agent's run-target map is exposed as `wolli.environments` (type `AgentEnvironments`): `wolli.environments.targets[...]` reaches a specific target, and `wolli.environments.default` names the target tools use when none is specified.
1639
+
1640
+ ```typescript
1641
+ import { createBashTool, createReadTool, type Environment } from "@opsyhq/wolli";
1642
+
1643
+ // Reach the default target environment, then build a tool against a custom
1644
+ // environment (e.g. one that wraps exec)
1645
+ const base = wolli.environments.targets[wolli.environments.default];
1646
+ const env: Environment = {
1647
+ ...base,
1648
+ exec: (command, cwd, options) => base.exec(`source ~/.profile\n${command}`, cwd, options),
1649
+ };
1650
+ const customBash = createBashTool(env);
1651
+ ```
1652
+
1653
+ `createHostEnvironment(cwd, { shellPath? })` builds the default unconfined host backend. An `Environment` provides `exec`, `readFile`, `writeFile`, `mkdir`, `access`, `exists`, `stat`, `readdir`, an optional `detectImageMimeType`, plus `id`/`cwd`/`resolvePath`. Override any of these (spreading a target from `wolli.environments` for the rest) to delegate tools to a different backend.
1654
+
1655
+ For `user_bash`, return `{ environment }` from the handler to run the command in a custom environment, or reuse `createHostEnvironment()` instead of reimplementing local process spawning, shell resolution, and process-tree termination.
1656
+
1657
+ The bash tool also supports a spawn hook to adjust the command, cwd, or env before execution:
1658
+
1659
+ ```typescript
1660
+ import { createBashTool, createHostEnvironment } from "@opsyhq/wolli";
1661
+
1662
+ const env = createHostEnvironment(wolli.cwd);
1663
+ const bashTool = createBashTool(env, {
1664
+ spawnHook: ({ command, cwd, env }) => ({
1665
+ command: `source ~/.profile\n${command}`,
1666
+ cwd: `/mnt/sandbox${cwd}`,
1667
+ env: { ...env, CI: "1" },
1668
+ }),
1669
+ });
1670
+ ```
1671
+
1672
+ ### Output Truncation
1673
+
1674
+ **Tools MUST truncate their output** to avoid overwhelming the LLM context. Large outputs can cause:
1675
+ - Context overflow errors (prompt too long)
1676
+ - Compaction failures
1677
+ - Degraded model performance
1678
+
1679
+ The built-in limit is **50KB** (~10k tokens) and **2000 lines**, whichever is hit first. Use the exported truncation utilities:
1680
+
1681
+ ```typescript
1682
+ import {
1683
+ truncateHead, // Keep first N lines/bytes (good for file reads, search results)
1684
+ truncateTail, // Keep last N lines/bytes (good for logs, command output)
1685
+ truncateLine, // Truncate a single line to maxBytes with ellipsis
1686
+ formatSize, // Human-readable size (e.g., "50KB", "1.5MB")
1687
+ DEFAULT_MAX_BYTES, // 50KB
1688
+ DEFAULT_MAX_LINES, // 2000
1689
+ } from "@opsyhq/wolli";
1690
+
1691
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1692
+ const output = await runCommand();
1693
+
1694
+ // Apply truncation
1695
+ const truncation = truncateHead(output, {
1696
+ maxLines: DEFAULT_MAX_LINES,
1697
+ maxBytes: DEFAULT_MAX_BYTES,
1698
+ });
1699
+
1700
+ let result = truncation.content;
1701
+
1702
+ if (truncation.truncated) {
1703
+ // Write full output to temp file
1704
+ const tempFile = writeTempFile(output);
1705
+
1706
+ // Inform the LLM where to find complete output
1707
+ result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
1708
+ result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
1709
+ result += ` Full output saved to: ${tempFile}]`;
1710
+ }
1711
+
1712
+ return { content: [{ type: "text", text: result }] };
1713
+ }
1714
+ ```
1715
+
1716
+ **Key points:**
1717
+ - Use `truncateHead` for content where the beginning matters (search results, file reads)
1718
+ - Use `truncateTail` for content where the end matters (logs, command output)
1719
+ - Always inform the LLM when output is truncated and where to find the full version
1720
+ - Document the truncation limits in your tool's description
1721
+
1722
+ ### Multiple Tools
1723
+
1724
+ One extension can register multiple tools with shared state:
1725
+
1726
+ ```typescript
1727
+ export default function (wolli: ExtensionAPI) {
1728
+ let connection = null;
1729
+
1730
+ wolli.registerTool({ name: "db_connect", ... });
1731
+ wolli.registerTool({ name: "db_query", ... });
1732
+ wolli.registerTool({ name: "db_close", ... });
1733
+
1734
+ wolli.on("session_shutdown", async () => {
1735
+ connection?.close();
1736
+ });
1737
+ }
1738
+ ```
1739
+
1740
+ ### Custom Rendering
1741
+
1742
+ Tools can provide `renderCall` and `renderResult` for custom TUI display.
1743
+
1744
+ By default, tool output is wrapped in a `Box` that handles padding and background. A defined `renderCall` or `renderResult` must return a `Component`. If a slot renderer is not defined, fallback rendering is used for that slot.
1745
+
1746
+ Set `renderShell: "self"` when the tool should render its own shell instead of using the default `Box`. This is useful for tools that need complete control over framing or background behavior, for example large previews that must stay visually stable after the tool settles.
1747
+
1748
+ ```typescript
1749
+ wolli.registerTool({
1750
+ name: "my_tool",
1751
+ label: "My Tool",
1752
+ description: "Custom shell example",
1753
+ parameters: Type.Object({}),
1754
+ renderShell: "self",
1755
+ async execute() {
1756
+ return { content: [{ type: "text", text: "ok" }], details: undefined };
1757
+ },
1758
+ renderCall(args, theme, context) {
1759
+ return new Text(theme.fg("accent", "my custom shell"), 0, 0);
1760
+ },
1761
+ });
1762
+ ```
1763
+
1764
+ `renderCall` and `renderResult` each receive a `context` object with:
1765
+ - `args` - the current tool call arguments
1766
+ - `state` - shared row-local state across `renderCall` and `renderResult`
1767
+ - `lastComponent` - the previously returned component for that slot, if any
1768
+ - `invalidate()` - request a rerender of this tool row
1769
+ - `toolCallId`, `cwd`, `executionStarted`, `argsComplete`, `isPartial`, `expanded`, `showImages`, `isError`
1770
+
1771
+ Use `context.state` for cross-slot shared state. Keep slot-local caches on the returned component instance when you want to reuse and mutate the same component across renders.
1772
+
1773
+ #### renderCall
1774
+
1775
+ Renders the tool call or header:
1776
+
1777
+ ```typescript
1778
+ import { Text } from "@opsyhq/tui";
1779
+
1780
+ renderCall(args, theme, context) {
1781
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
1782
+ let content = theme.fg("toolTitle", theme.bold("my_tool "));
1783
+ content += theme.fg("muted", args.action);
1784
+ if (args.text) {
1785
+ content += " " + theme.fg("dim", `"${args.text}"`);
1786
+ }
1787
+ text.setText(content);
1788
+ return text;
1789
+ }
1790
+ ```
1791
+
1792
+ #### renderResult
1793
+
1794
+ Renders the tool result or output:
1795
+
1796
+ ```typescript
1797
+ renderResult(result, { expanded, isPartial }, theme, context) {
1798
+ if (isPartial) {
1799
+ return new Text(theme.fg("warning", "Processing..."), 0, 0);
1800
+ }
1801
+
1802
+ if (result.details?.error) {
1803
+ return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0);
1804
+ }
1805
+
1806
+ let text = theme.fg("success", "✓ Done");
1807
+ if (expanded && result.details?.items) {
1808
+ for (const item of result.details.items) {
1809
+ text += "\n " + theme.fg("dim", item);
1810
+ }
1811
+ }
1812
+ return new Text(text, 0, 0);
1813
+ }
1814
+ ```
1815
+
1816
+ If a slot intentionally has no visible content, return an empty `Component` such as an empty `Container`.
1817
+
1818
+ #### Keybinding Hints
1819
+
1820
+ Use `keyHint()` to display keybinding hints that respect the active keybinding configuration:
1821
+
1822
+ ```typescript
1823
+ import { keyHint } from "@opsyhq/wolli";
1824
+
1825
+ renderResult(result, { expanded }, theme, context) {
1826
+ let text = theme.fg("success", "✓ Done");
1827
+ if (!expanded) {
1828
+ text += ` (${keyHint("app.tools.expand", "to expand")})`;
1829
+ }
1830
+ return new Text(text, 0, 0);
1831
+ }
1832
+ ```
1833
+
1834
+ Available functions:
1835
+ - `keyHint(keybinding, description)` - Formats a configured keybinding id such as `"app.tools.expand"` or `"tui.select.confirm"`
1836
+ - `keyText(keybinding)` - Returns the raw configured key text for a keybinding id
1837
+ - `rawKeyHint(key, description)` - Format a raw key string
1838
+
1839
+ Use namespaced keybinding ids:
1840
+ - App ids use the `app.*` namespace, for example `app.tools.expand`, `app.editor.external`, `app.session.rename`
1841
+ - Shared TUI ids use the `tui.*` namespace, for example `tui.select.confirm`, `tui.select.cancel`, `tui.input.tab`
1842
+
1843
+ Custom editors and `ctx.ui.custom()` components receive `keybindings: KeybindingsManager` as an injected argument. They should use that injected manager directly instead of calling `getKeybindings()` or `setKeybindings()`.
1844
+
1845
+ #### Best Practices
1846
+
1847
+ - Use `Text` with padding `(0, 0)`. The default Box handles padding.
1848
+ - Use `\n` for multi-line content.
1849
+ - Handle `isPartial` for streaming progress.
1850
+ - Support `expanded` for detail on demand.
1851
+ - Keep default view compact.
1852
+ - Read `context.args` in `renderResult` instead of copying args into `context.state`.
1853
+ - Use `context.state` only for data that must be shared across call and result slots.
1854
+ - Reuse `context.lastComponent` when the same component instance can be updated in place.
1855
+ - Use `renderShell: "self"` only when the default boxed shell gets in the way. In self-shell mode the tool is responsible for its own framing, padding, and background.
1856
+
1857
+ #### Fallback
1858
+
1859
+ If a slot renderer is not defined or throws:
1860
+ - `renderCall`: Shows the tool name
1861
+ - `renderResult`: Shows raw text from `content`
1862
+
1863
+ ## Custom UI
1864
+
1865
+ Extensions can interact with users via `ctx.ui` methods and customize how messages/tools render.
1866
+
1867
+ ### Dialogs
1868
+
1869
+ ```typescript
1870
+ // Select from options
1871
+ const choice = await ui.select("Pick one:", ["A", "B", "C"]);
1872
+
1873
+ // Confirm dialog
1874
+ const ok = await ui.confirm("Delete?", "This cannot be undone");
1875
+
1876
+ // Text input
1877
+ const name = await ui.input("Name:", "placeholder");
1878
+
1879
+ // Multi-line editor
1880
+ const text = await ui.editor("Edit:", "prefilled text");
1881
+
1882
+ // Notification (non-blocking)
1883
+ ui.notify("Done!", "info"); // "info" | "warning" | "error"
1884
+ ```
1885
+
1886
+ #### Timed Dialogs with Countdown
1887
+
1888
+ Dialogs support a `timeout` option that auto-dismisses with a live countdown display:
1889
+
1890
+ ```typescript
1891
+ // Dialog shows "Title (5s)" → "Title (4s)" → ... → auto-dismisses at 0
1892
+ const confirmed = await ui.confirm(
1893
+ "Timed Confirmation",
1894
+ "This dialog will auto-cancel in 5 seconds. Confirm?",
1895
+ { timeout: 5000 }
1896
+ );
1897
+
1898
+ if (confirmed) {
1899
+ // User confirmed
1900
+ } else {
1901
+ // User cancelled or timed out
1902
+ }
1903
+ ```
1904
+
1905
+ **Return values on timeout:**
1906
+ - `select()` returns `undefined`
1907
+ - `confirm()` returns `false`
1908
+ - `input()` returns `undefined`
1909
+
1910
+ #### Manual Dismissal with AbortSignal
1911
+
1912
+ For more control (e.g., to distinguish timeout from user cancel), use `AbortSignal`:
1913
+
1914
+ ```typescript
1915
+ const controller = new AbortController();
1916
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
1917
+
1918
+ const confirmed = await ui.confirm(
1919
+ "Timed Confirmation",
1920
+ "This dialog will auto-cancel in 5 seconds. Confirm?",
1921
+ { signal: controller.signal }
1922
+ );
1923
+
1924
+ clearTimeout(timeoutId);
1925
+
1926
+ if (confirmed) {
1927
+ // User confirmed
1928
+ } else if (controller.signal.aborted) {
1929
+ // Dialog timed out
1930
+ } else {
1931
+ // User cancelled (pressed Escape or selected "No")
1932
+ }
1933
+ ```
1934
+
1935
+ ### Widgets, Status, and Footer
1936
+
1937
+ ```typescript
1938
+ // Status in footer (persistent until cleared)
1939
+ ui.setStatus("my-ext", "Processing...");
1940
+ ui.setStatus("my-ext", undefined); // Clear
1941
+
1942
+ // Working loader (shown during streaming)
1943
+ ui.setWorkingMessage("Thinking deeply...");
1944
+ ui.setWorkingMessage(); // Restore default
1945
+ ui.setWorkingVisible(false); // Hide the built-in working loader row entirely
1946
+ ui.setWorkingVisible(true); // Show the built-in working loader row
1947
+
1948
+ // Working indicator (shown during streaming)
1949
+ ui.setWorkingIndicator({ frames: [ui.theme.fg("accent", "●")] }); // Static dot
1950
+ ui.setWorkingIndicator({
1951
+ frames: [
1952
+ ui.theme.fg("dim", "·"),
1953
+ ui.theme.fg("muted", "•"),
1954
+ ui.theme.fg("accent", "●"),
1955
+ ui.theme.fg("muted", "•"),
1956
+ ],
1957
+ intervalMs: 120,
1958
+ });
1959
+ ui.setWorkingIndicator({ frames: [] }); // Hide indicator
1960
+ ui.setWorkingIndicator(); // Restore default spinner
1961
+
1962
+ // Widget above editor (default)
1963
+ ui.setWidget("my-widget", ["Line 1", "Line 2"]);
1964
+ // Widget below editor
1965
+ ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });
1966
+ ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0));
1967
+ ui.setWidget("my-widget", undefined); // Clear
1968
+
1969
+ // Custom footer (replaces built-in footer entirely)
1970
+ ui.setFooter((tui, theme) => ({
1971
+ render(width) { return [theme.fg("dim", "Custom footer")]; },
1972
+ invalidate() {},
1973
+ }));
1974
+ ui.setFooter(undefined); // Restore built-in footer
1975
+
1976
+ // Terminal title
1977
+ ui.setTitle("wolli - my-agent");
1978
+
1979
+ // Editor text
1980
+ ui.setEditorText("Prefill text");
1981
+ const current = ui.getEditorText();
1982
+
1983
+ // Paste into editor (triggers paste handling, including collapse for large content)
1984
+ ui.pasteToEditor("pasted content");
1985
+
1986
+ // Stack custom autocomplete behavior on top of the built-in provider
1987
+ ui.addAutocompleteProvider((current) => ({
1988
+ triggerCharacters: ["#"],
1989
+ async getSuggestions(lines, line, col, options) {
1990
+ const beforeCursor = (lines[line] ?? "").slice(0, col);
1991
+ const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/);
1992
+ if (!match) {
1993
+ return current.getSuggestions(lines, line, col, options);
1994
+ }
1995
+
1996
+ return {
1997
+ prefix: `#${match[1] ?? ""}`,
1998
+ items: [{ value: "#2983", label: "#2983", description: "Extension API for autocomplete" }],
1999
+ };
2000
+ },
2001
+ applyCompletion(lines, line, col, item, prefix) {
2002
+ return current.applyCompletion(lines, line, col, item, prefix);
2003
+ },
2004
+ shouldTriggerFileCompletion(lines, line, col) {
2005
+ return current.shouldTriggerFileCompletion?.(lines, line, col) ?? true;
2006
+ },
2007
+ }));
2008
+
2009
+ // Tool output expansion
2010
+ const wasExpanded = ui.getToolsExpanded();
2011
+ ui.setToolsExpanded(true);
2012
+ ui.setToolsExpanded(wasExpanded);
2013
+
2014
+ // Custom editor (vim mode, emacs mode, etc.)
2015
+ ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(tui, theme, keybindings));
2016
+ const currentEditor = ui.getEditorComponent();
2017
+ ui.setEditorComponent((tui, theme, keybindings) =>
2018
+ new WrappedEditor(tui, theme, keybindings, currentEditor?.(tui, theme, keybindings))
2019
+ );
2020
+ ui.setEditorComponent(undefined); // Restore default editor
2021
+
2022
+ // Theme management (see themes.md for creating themes)
2023
+ const themes = ui.getAllThemes(); // [{ name: "dark", path: "/..." | undefined }, ...]
2024
+ const lightTheme = ui.getTheme("light"); // Load without switching
2025
+ const result = ui.setTheme("light"); // Switch by name
2026
+ if (!result.success) {
2027
+ ui.notify(`Failed: ${result.error}`, "error");
2028
+ }
2029
+ ui.setTheme(lightTheme!); // Or switch by Theme object
2030
+ ui.theme.fg("accent", "styled text"); // Access current theme
2031
+ ```
2032
+
2033
+ Custom working-indicator frames are rendered verbatim. If you want colors, add them to the frame strings yourself, for example with `ui.theme.fg(...)`.
2034
+
2035
+ ### Autocomplete Providers
2036
+
2037
+ Use `ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. Set `triggerCharacters` for custom natural triggers such as `$`.
2038
+
2039
+ Typical pattern:
2040
+
2041
+ - inspect the text before the cursor
2042
+ - return your own suggestions when your extension-specific syntax matches
2043
+ - otherwise delegate to `current.getSuggestions(...)`
2044
+ - delegate `applyCompletion(...)` unless you need custom insertion behavior
2045
+
2046
+ ```typescript
2047
+ wolli.on("session_start", (_event, { ui }) => {
2048
+ ui.addAutocompleteProvider((current) => ({
2049
+ triggerCharacters: ["#"],
2050
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
2051
+ const line = lines[cursorLine] ?? "";
2052
+ const beforeCursor = line.slice(0, cursorCol);
2053
+ const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/);
2054
+ if (!match) {
2055
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
2056
+ }
2057
+
2058
+ return {
2059
+ prefix: `#${match[1] ?? ""}`,
2060
+ items: [
2061
+ { value: "#2983", label: "#2983", description: "Extension API for registering custom @ autocomplete providers" },
2062
+ { value: "#2753", label: "#2753", description: "Reload stale resource settings" },
2063
+ ],
2064
+ };
2065
+ },
2066
+
2067
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
2068
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
2069
+ },
2070
+
2071
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
2072
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
2073
+ },
2074
+ }));
2075
+ });
2076
+ ```
2077
+
2078
+ A typical real-world provider preloads the latest open GitHub issues with `gh issue list` and filters them locally for fast `#...` completion, which requires GitHub CLI (`gh`) and a GitHub repository checkout.
2079
+
2080
+ ### Custom Components
2081
+
2082
+ For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called:
2083
+
2084
+ ```typescript
2085
+ import { Text, Component } from "@opsyhq/tui";
2086
+
2087
+ const result = await ui.custom<boolean>((tui, theme, keybindings, done) => {
2088
+ const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1);
2089
+
2090
+ text.onKey = (key) => {
2091
+ if (key === "return") done(true);
2092
+ if (key === "escape") done(false);
2093
+ return true;
2094
+ };
2095
+
2096
+ return text;
2097
+ });
2098
+
2099
+ if (result) {
2100
+ // User pressed Enter
2101
+ }
2102
+ ```
2103
+
2104
+ The callback receives:
2105
+ - `tui` - TUI instance (for screen dimensions, focus management)
2106
+ - `theme` - Current theme for styling
2107
+ - `keybindings` - App keybinding manager (for checking shortcuts)
2108
+ - `done(value)` - Call to close component and return value
2109
+
2110
+ ### Custom Editor
2111
+
2112
+ Replace the main input editor with a custom implementation (vim mode, emacs mode, etc.):
2113
+
2114
+ ```typescript
2115
+ import { CustomEditor, type ExtensionAPI } from "@opsyhq/wolli";
2116
+ import { matchesKey } from "@opsyhq/tui";
2117
+
2118
+ class VimEditor extends CustomEditor {
2119
+ private mode: "normal" | "insert" = "insert";
2120
+
2121
+ handleInput(data: string): void {
2122
+ if (matchesKey(data, "escape") && this.mode === "insert") {
2123
+ this.mode = "normal";
2124
+ return;
2125
+ }
2126
+ if (this.mode === "normal" && data === "i") {
2127
+ this.mode = "insert";
2128
+ return;
2129
+ }
2130
+ super.handleInput(data); // App keybindings + text editing
2131
+ }
2132
+ }
2133
+
2134
+ export default function (wolli: ExtensionAPI) {
2135
+ wolli.on("session_start", (_event, { ui }) => {
2136
+ ui.setEditorComponent((tui, theme, keybindings) =>
2137
+ new VimEditor(tui, theme, keybindings)
2138
+ );
2139
+ });
2140
+ }
2141
+ ```
2142
+
2143
+ **Key points:**
2144
+ - Extend `CustomEditor` (not base `Editor`) to get app keybindings (escape to abort, ctrl+d, model switching)
2145
+ - Call `super.handleInput(data)` for keys you don't handle
2146
+ - Factory receives `(tui, theme, keybindings)` from the app
2147
+ - Use `ui.getEditorComponent()` before `setEditorComponent()` to wrap the previously configured custom editor
2148
+ - Pass `undefined` to restore default: `ui.setEditorComponent(undefined)`
2149
+
2150
+ To compose with another extension that already replaced the editor, capture the previous factory before setting yours:
2151
+
2152
+ ```typescript
2153
+ const previous = ui.getEditorComponent();
2154
+ ui.setEditorComponent((tui, theme, keybindings) =>
2155
+ new MyEditor(tui, theme, keybindings, { base: previous?.(tui, theme, keybindings) })
2156
+ );
2157
+ ```
2158
+
2159
+ ### Message Rendering
2160
+
2161
+ Register a custom renderer for messages with your `customType`:
2162
+
2163
+ ```typescript
2164
+ import { Text } from "@opsyhq/tui";
2165
+
2166
+ wolli.registerMessageRenderer("my-extension", (message, options, theme) => {
2167
+ const { expanded } = options;
2168
+ let text = theme.fg("accent", `[${message.customType}] `);
2169
+ text += message.content;
2170
+
2171
+ if (expanded && message.details) {
2172
+ text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2));
2173
+ }
2174
+
2175
+ return new Text(text, 0, 0);
2176
+ });
2177
+ ```
2178
+
2179
+ Messages are sent via `ctx.session.sendMessage()`:
2180
+
2181
+ ```typescript
2182
+ session.sendMessage({
2183
+ customType: "my-extension", // Matches registerMessageRenderer
2184
+ content: "Status update",
2185
+ display: true, // Show in TUI
2186
+ details: { ... }, // Available in renderer
2187
+ });
2188
+ ```
2189
+
2190
+ **Default rendering (no registered renderer).** Registering a renderer is optional. A `display: true` custom message with no renderer for its `customType` still appears in the TUI: it renders in a boxed message with a bold `[customType]` label header, followed by the message `content` rendered as markdown (text parts only, for array content). So you can see `/command` output without registering anything; register a renderer only when you want custom styling, an expanded view, or to surface `details`. The same fallback applies if your renderer throws or returns nothing. Messages sent with `display: false` are not rendered at all.
2191
+
2192
+ ### Theme Colors
2193
+
2194
+ All render functions receive a `theme` object. See [themes.md](themes.md) for creating custom themes and the full color palette.
2195
+
2196
+ ```typescript
2197
+ // Foreground colors
2198
+ theme.fg("toolTitle", text) // Tool names
2199
+ theme.fg("accent", text) // Highlights
2200
+ theme.fg("success", text) // Success (green)
2201
+ theme.fg("error", text) // Errors (red)
2202
+ theme.fg("warning", text) // Warnings (yellow)
2203
+ theme.fg("muted", text) // Secondary text
2204
+ theme.fg("dim", text) // Tertiary text
2205
+
2206
+ // Text styles
2207
+ theme.bold(text)
2208
+ theme.italic(text)
2209
+ theme.strikethrough(text)
2210
+ ```
2211
+
2212
+ For syntax highlighting in custom tool renderers:
2213
+
2214
+ ```typescript
2215
+ import { highlightCode, getLanguageFromPath } from "@opsyhq/wolli";
2216
+
2217
+ // Highlight code with explicit language (returns an array of styled lines;
2218
+ // uses the active theme internally, so no theme argument is passed)
2219
+ const lines: string[] = highlightCode("const x = 1;", "typescript");
2220
+
2221
+ // Auto-detect language from file path
2222
+ const lang = getLanguageFromPath("/path/to/file.rs"); // "rust"
2223
+ const highlightedLines = highlightCode(code, lang);
2224
+ ```
2225
+
2226
+ ## Error Handling
2227
+
2228
+ - Extension errors are logged, agent continues
2229
+ - `tool_call` errors block the tool (fail-safe)
2230
+ - Tool `execute` errors must be signaled by throwing; the thrown error is caught, reported to the LLM with `isError: true`, and execution continues
2231
+
2232
+ ## Mode Behavior
2233
+
2234
+ `ctx.mode` is one of four run modes. Use `ctx.mode === "tui"` before any terminal-only feature (`custom()`, component factories, terminal input, direct TUI rendering). In non-`tui` modes, dialog methods resolve to their inert defaults (`select`/`input`/`editor` return `undefined`, `confirm` returns `false`) and fire-and-forget UI calls (`notify`, `setStatus`, `setWidget`, `setTitle`) are no-ops, so guarding by mode keeps an extension from blocking on UI that cannot appear.
2235
+
2236
+ | Mode | `ctx.mode` | Notes |
2237
+ |------|-----------|-------|
2238
+ | Interactive | `"tui"` | Full TUI with terminal rendering; dialogs and custom components are available |
2239
+ | RPC | `"rpc"` | Programmatic host drives the session over RPC; no terminal UI |
2240
+ | JSON | `"json"` | Structured JSON stream output; no terminal UI |
2241
+ | Print (`-p`) | `"print"` | One-shot/headless; extensions run but cannot prompt the user |
2242
+
2243
+ ## Worked Examples
2244
+
2245
+ These examples combine the pieces above against the real `@opsyhq/wolli` surface.
2246
+
2247
+ ### Bind a session to an external conversation
2248
+
2249
+ Use folded tags plus `wolli.findSessions` / `openSession` / `createSession` to route an external chat to a stable session, with no live `ctx` in scope.
2250
+
2251
+ ```typescript
2252
+ import type { ExtensionAPI } from "@opsyhq/wolli";
2253
+
2254
+ export default function (wolli: ExtensionAPI) {
2255
+ const telegram = wolli.getIntegration("telegram", "default");
2256
+
2257
+ telegram.on("message", async (msg) => {
2258
+ const key = String(msg.chatId);
2259
+ const [match] = await wolli.findSessions({ "telegram:chat": key });
2260
+
2261
+ const session = match
2262
+ ? await wolli.openSession(match.id)
2263
+ : await wolli.createSession({
2264
+ withSession: async (s) => { s.setTags({ "telegram:chat": key }); },
2265
+ });
2266
+
2267
+ await session.sendUserMessage(msg.text);
2268
+ });
2269
+ }
2270
+ ```
2271
+
2272
+ See [integrations.md](integrations.md#consuming-an-integration) for the integration handle surface.
2273
+
2274
+ ### Permission gate on a custom tool
2275
+
2276
+ Block a dangerous bash command unless the user confirms, using `ctx.ui` and the `tool_call` block result.
2277
+
2278
+ ```typescript
2279
+ import type { ExtensionAPI } from "@opsyhq/wolli";
2280
+ import { isToolCallEventType } from "@opsyhq/wolli";
2281
+
2282
+ export default function (wolli: ExtensionAPI) {
2283
+ wolli.on("tool_call", async (event, { ui, mode }) => {
2284
+ if (!isToolCallEventType("bash", event)) return;
2285
+ if (!/\brm\s+-rf\b/.test(event.input.command)) return;
2286
+
2287
+ // In non-tui modes confirm() resolves false, which blocks fail-safe.
2288
+ const ok = mode === "tui"
2289
+ ? await ui.confirm("Dangerous command", `Run: ${event.input.command}?`)
2290
+ : false;
2291
+
2292
+ if (!ok) return { block: true, reason: "Blocked rm -rf" };
2293
+ });
2294
+ }
2295
+ ```
2296
+
2297
+ ### Persist and restore extension state
2298
+
2299
+ Store state in tool result `details`, then rebuild it from the branch on `session_start`.
2300
+
2301
+ ```typescript
2302
+ import type { ExtensionAPI } from "@opsyhq/wolli";
2303
+ import { Type } from "typebox";
2304
+
2305
+ export default function (wolli: ExtensionAPI) {
2306
+ let todos: string[] = [];
2307
+
2308
+ wolli.on("session_start", (_event, { session }) => {
2309
+ todos = [];
2310
+ for (const entry of session.sessionManager.getBranch()) {
2311
+ if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "todo_add") {
2312
+ todos = (entry.message.details as { todos?: string[] })?.todos ?? todos;
2313
+ }
2314
+ }
2315
+ });
2316
+
2317
+ wolli.registerTool({
2318
+ name: "todo_add",
2319
+ label: "Add Todo",
2320
+ description: "Append an item to the session todo list",
2321
+ parameters: Type.Object({ text: Type.String() }),
2322
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
2323
+ todos.push(params.text);
2324
+ return {
2325
+ content: [{ type: "text", text: `Added: ${params.text}` }],
2326
+ details: { todos: [...todos] },
2327
+ };
2328
+ },
2329
+ });
2330
+ }
2331
+ ```