@bastani/atomic 0.9.19-alpha.6 → 0.9.19-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/dist/builtin/intercom/package.json +1 -1
  4. package/dist/builtin/mcp/package.json +1 -1
  5. package/dist/builtin/subagents/package.json +1 -1
  6. package/dist/builtin/web-access/CHANGELOG.md +6 -0
  7. package/dist/builtin/web-access/index.bundle.mjs +22 -3
  8. package/dist/builtin/web-access/package.json +1 -1
  9. package/dist/builtin/workflows/CHANGELOG.md +19 -0
  10. package/dist/builtin/workflows/README.md +1 -1
  11. package/dist/builtin/workflows/builtin/adversarial-verification.js +2 -2
  12. package/dist/builtin/workflows/builtin/{chunk-vhmnzdj9.js → chunk-1jth021m.js} +1 -1
  13. package/dist/builtin/workflows/builtin/{chunk-p8jjwfg6.js → chunk-2377r57g.js} +4 -2
  14. package/dist/builtin/workflows/builtin/{chunk-hbnwv56f.js → chunk-31j2gs7h.js} +2 -2
  15. package/dist/builtin/workflows/builtin/{chunk-n8zysf1t.js → chunk-ngkqkzej.js} +1 -1
  16. package/dist/builtin/workflows/builtin/{chunk-1nbq96fh.js → chunk-nkrafh9s.js} +1 -1
  17. package/dist/builtin/workflows/builtin/{chunk-1kx4aggw.js → chunk-wf741bv3.js} +2 -2
  18. package/dist/builtin/workflows/builtin/{chunk-t6x2d78s.js → chunk-z29qbehr.js} +1 -1
  19. package/dist/builtin/workflows/builtin/generate-and-filter.js +2 -2
  20. package/dist/builtin/workflows/builtin/goal.js +3 -3
  21. package/dist/builtin/workflows/builtin/index.js +7 -7
  22. package/dist/builtin/workflows/builtin/ralph.js +3 -3
  23. package/dist/builtin/workflows/builtin/tournament.js +2 -2
  24. package/dist/builtin/workflows/package.json +1 -1
  25. package/dist/builtin/workflows/src/extension/index.bundle.mjs +590 -256
  26. package/dist/builtin/workflows/src/index.js +67 -19
  27. package/dist/builtin/workflows/src/shared/authoring-contract-stage.d.ts +2 -0
  28. package/dist/modes/interactive/components/task-live-transcript.d.ts +1 -1
  29. package/dist/modes/interactive/components/task-live-transcript.d.ts.map +1 -1
  30. package/dist/modes/interactive/components/task-live-transcript.js +19 -11
  31. package/dist/modes/interactive/components/task-live-transcript.js.map +1 -1
  32. package/docs/background-tasks.md +1 -1
  33. package/docs/build.md +51 -0
  34. package/docs/changelog.mdx +8 -8
  35. package/docs/compaction/reference.md +267 -0
  36. package/docs/compaction.md +36 -219
  37. package/docs/computer-use.md +35 -0
  38. package/docs/containerization.md +2 -0
  39. package/docs/custom-provider/api-reference.md +145 -0
  40. package/docs/custom-provider/oauth.md +131 -0
  41. package/docs/custom-provider/override.md +34 -0
  42. package/docs/custom-provider/registration.md +163 -0
  43. package/docs/custom-provider/streaming.md +214 -0
  44. package/docs/custom-provider.md +43 -607
  45. package/docs/development.md +76 -0
  46. package/docs/docs.json +199 -77
  47. package/docs/environment-variables.md +1 -1
  48. package/docs/extensions/api-reference.md +1001 -0
  49. package/docs/extensions/authoring.md +676 -0
  50. package/docs/extensions/events.md +793 -0
  51. package/docs/extensions/examples.md +94 -0
  52. package/docs/extensions/ui.md +470 -0
  53. package/docs/extensions.md +270 -2768
  54. package/docs/getting-started/authentication.md +55 -0
  55. package/docs/getting-started/first-session.md +172 -0
  56. package/docs/getting-started/installation.md +154 -0
  57. package/docs/getting-started/project-instructions.md +43 -0
  58. package/docs/guides/configuration.md +65 -0
  59. package/docs/guides/intercom.md +24 -0
  60. package/docs/guides/non-interactive.md +33 -0
  61. package/docs/guides/subagents.md +24 -0
  62. package/docs/guides/workflows.md +27 -0
  63. package/docs/guides.md +41 -0
  64. package/docs/index.md +13 -45
  65. package/docs/intercom/operations.md +178 -0
  66. package/docs/intercom/reference.md +146 -0
  67. package/docs/intercom.md +54 -259
  68. package/docs/json.md +17 -0
  69. package/docs/llama-cpp.md +5 -0
  70. package/docs/models/artificial-analysis-index.md +89 -0
  71. package/docs/models/evals.md +2 -2
  72. package/docs/models/model-selection.md +3 -3
  73. package/docs/models/pareto-efficiency.md +2 -2
  74. package/docs/models/reference.md +601 -0
  75. package/docs/models.md +45 -550
  76. package/docs/packages/authoring.md +88 -0
  77. package/docs/packages/reference.md +43 -0
  78. package/docs/packages.md +28 -107
  79. package/docs/programmatic.md +29 -0
  80. package/docs/prompt-templates.md +11 -0
  81. package/docs/providers/reference.md +23 -0
  82. package/docs/providers.md +10 -15
  83. package/docs/quickstart.md +44 -238
  84. package/docs/reference/cli.md +252 -0
  85. package/docs/reference.md +52 -0
  86. package/docs/rpc/examples.md +64 -0
  87. package/docs/rpc/extension-ui.md +198 -0
  88. package/docs/rpc/protocol.md +1341 -0
  89. package/docs/rpc.md +109 -1346
  90. package/docs/sdk/reference.md +1090 -0
  91. package/docs/sdk.md +56 -1002
  92. package/docs/session-format.md +2 -0
  93. package/docs/sessions.md +6 -10
  94. package/docs/settings.md +6 -45
  95. package/docs/skills/authoring.md +95 -0
  96. package/docs/skills/reference.md +60 -0
  97. package/docs/skills.md +20 -122
  98. package/docs/subagents/authoring.md +37 -0
  99. package/docs/subagents/reference.md +60 -0
  100. package/docs/subagents.md +39 -95
  101. package/docs/terminal-setup.md +1 -1
  102. package/docs/themes/reference.md +176 -0
  103. package/docs/themes.md +21 -135
  104. package/docs/tmux.md +5 -0
  105. package/docs/tools/edit.md +5 -0
  106. package/docs/tools.md +5 -0
  107. package/docs/tui/reference.md +343 -0
  108. package/docs/tui.md +23 -286
  109. package/docs/usage.md +26 -209
  110. package/docs/web-access.md +2 -0
  111. package/docs/windows.md +2 -1
  112. package/docs/workflows/api-reference.md +11 -4
  113. package/docs/workflows/authoring.md +4 -2
  114. package/docs/workflows/builtins.md +2 -2
  115. package/docs/workflows/operations.md +15 -10
  116. package/docs/workflows/reliable-design.md +21 -3
  117. package/docs/workflows/verification.md +8 -0
  118. package/npm-shrinkwrap.json +32 -32
  119. package/package.json +3 -3
@@ -0,0 +1,1001 @@
1
+ ---
2
+ title: Extension API reference
3
+ sidebarTitle: "Extension API"
4
+ description: ExtensionContext, ExtensionCommandContext, ExtensionAPI methods, and error handling.
5
+ ---
6
+
7
+ # Extension API reference
8
+
9
+ ## ExtensionContext
10
+
11
+ All handlers receive `ctx: ExtensionContext`.
12
+
13
+ ### ctx.ui
14
+
15
+ UI methods for user interaction. See [Custom UI](/extensions/ui#custom-ui) for full details.
16
+
17
+ ### ctx.hasUI
18
+
19
+ `false` in print mode (`-p`) and JSON mode. `true` in interactive and RPC mode. In RPC mode, dialog methods (`select`, `confirm`, `input`, `editor`) work via the extension UI sub-protocol, and fire-and-forget methods (`notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText`) emit requests to the client. Some TUI-specific methods are no-ops or return defaults (see [RPC mode](/rpc/extension-ui#extension-ui-protocol)).
20
+
21
+ ### ctx.cwd
22
+
23
+ Current working directory.
24
+
25
+ Built-in cwd-sensitive tools (`read`, `write`, `edit`, `search`, `find`, `ls`, `bash`, `powershell`) resolve relative paths against `ctx.cwd` when an extension invokes them, falling back to the cwd captured when the tool was created. An extension that registers a tool and forwards its own context therefore gets paths resolved against the live session cwd rather than a stale one.
26
+
27
+ Use `CONFIG_DIR_NAME` instead of hardcoding `.atomic` (or legacy `.pi`) when constructing project-local config paths. Rebranded distributions can use a different config directory name.
28
+
29
+ ```typescript
30
+ import { CONFIG_DIR_NAME, type ExtensionAPI } from "@bastani/atomic";
31
+ import { join } from "node:path";
32
+
33
+ export default function (pi: ExtensionAPI) {
34
+ pi.on("session_start", (_event, ctx) => {
35
+ const projectConfigPath = join(ctx.cwd, CONFIG_DIR_NAME, "my-extension.json");
36
+ // ...
37
+ });
38
+ }
39
+ ```
40
+
41
+ ### ctx.isProjectTrusted()
42
+
43
+ Returns whether project-local trust is active for the current session context. This includes temporary trust decisions and CLI trust overrides, not just saved decisions in the global trust store.
44
+
45
+ Use this before reading project-local extension configuration that should only be honored for trusted projects.
46
+
47
+
48
+ ### ctx.sessionManager
49
+
50
+ Read-only access to session state. See [Session Format](/session-format) for the full SessionManager API and entry types.
51
+
52
+ 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.
53
+
54
+ ```typescript
55
+ ctx.sessionManager.getEntries() // All entries
56
+ ctx.sessionManager.getBranch() // Current branch
57
+ ctx.sessionManager.getLeafId() // Current leaf entry ID
58
+ ```
59
+
60
+ ### ctx.modelRegistry / ctx.model / ctx.scopedModels
61
+
62
+ Access models, auth state, and provider-aware requests.
63
+
64
+ Use `ctx.modelRegistry.complete()` for an extension model request that must use Atomic's provider composition. It dispatches through the active `ModelRuntime`, retaining registered custom providers and resolved request auth: the credential-specific `baseUrl`, headers (including `null` suppression markers), and environment values.
65
+
66
+ For streaming requests, use `ctx.modelRegistry.streamSimple(model, context, options)` with provider-neutral options, or `stream()` with API-specific options. Both use configured providers and request-time authentication, including extension registrations. Iterate the returned `AssistantMessageEventStream` for events and await `.result()` for the final message. Setup failures produce error events and error results. The global compatibility streaming functions do not see extension provider registrations.
67
+
68
+ ```typescript
69
+ const model = ctx.modelRegistry.find("github-copilot", "gpt-5.5");
70
+ if (!model) throw new Error("Model not found");
71
+
72
+ const response = await ctx.modelRegistry.complete(
73
+ model,
74
+ { messages },
75
+ { signal: ctx.signal },
76
+ );
77
+ ```
78
+
79
+ Use `getApiKeyAndHeaders()` only when an extension must inspect auth before dispatch; normal requests do not need to resolve or overlay auth themselves.
80
+
81
+ OpenRouter Chat Completions and Anthropic Messages requests send `x-session-id` by default when `sessionId` is supplied and prompt caching is enabled. Set the model's `compat.sendSessionAffinityHeaders` to `false` to opt out, or set `cacheRetention: "none"` on the request to disable cache-related affinity. Explicit request headers override generated headers.
82
+
83
+ `await ctx.modelRegistry.refresh(options)` returns `{ aborted, errors }`, not just completion. `errors` is a per-provider map, so extensions can report partial refresh failures; `aborted` reports cancellation. Host integrations that call `ModelRuntime.setRuntimeApiKey(providerId, apiKey, options)` must note that it records the runtime credential but does not refresh the catalog; call `refresh({ providers: [providerId], signal })` explicitly when a fresh catalog is needed.
84
+
85
+ `ctx.scopedModels` is the read-only list of models scoped to the current session — the same set the `/scoped-models` command shows. It is resolved from the `--models` CLI flag and the `enabledModels` setting, matched against the available catalogue. It is empty when no scoping is configured, meaning every available model is usable. Each entry is `{ model, thinkingLevel? }`, where `thinkingLevel` is set only when a pattern pinned it (for example `anthropic/*:high`). Use it to populate a model picker that mirrors the built-in one instead of enumerating the whole catalogue.
86
+
87
+ The value is resolved at access time, so it tracks session replacement. Under the isolated interactive engine it reflects the engine's catalogue rather than a stale host snapshot.
88
+
89
+ It reports the scope and cannot change it. `ctx.scopedModels` is a getter with no setter, typed `readonly ScopedModel[]`, so assigning to it or pushing an entry is a compile error. The guarantee also holds at runtime, where the type does not reach: each read returns a fresh copy — of the array, of every `{ model, thinkingLevel }` entry in it, and of each entry's model — and all three are frozen. A JavaScript extension, or one that asserts the `readonly` away, therefore cannot widen the set of models the session may use by pushing an entry, nor change which model it selects by swapping one in place; the attempt throws rather than quietly working. Read it, and change scope through the commands and settings that own it.
90
+
91
+ ```typescript
92
+ for (const { model, thinkingLevel } of ctx.scopedModels) {
93
+ console.log(`${model.provider}/${model.id}${thinkingLevel ? `:${thinkingLevel}` : ""}`);
94
+ }
95
+ ```
96
+
97
+ Both types are exported: `ScopedModel` for one entry, `ExtensionScopedModels` for the accessor's own type. They are declared at the public extension type path (`core/extensions/types.ts`) and re-exported from the package root, so an extension never reaches into an internal module to describe what it just read.
98
+
99
+ ```typescript
100
+ import type { ExtensionScopedModels, ScopedModel } from "@bastani/atomic";
101
+
102
+ function firstScoped(scope: ExtensionScopedModels): ScopedModel | undefined {
103
+ return scope[0];
104
+ }
105
+ ```
106
+
107
+ ### ctx.signal
108
+
109
+ The current agent abort signal, or `undefined` when no agent turn is active.
110
+
111
+ Use this for abort-aware nested work started by extension handlers, for example:
112
+ - `fetch(..., { signal: ctx.signal })`
113
+ - model calls that accept `signal`
114
+ - file or process helpers that accept `AbortSignal`
115
+
116
+ `ctx.signal` is typically defined during active turn events such as `tool_call`, `tool_result`, `message_update`, and `turn_end`.
117
+ It is usually `undefined` in idle or non-turn contexts such as session events, extension commands, and shortcuts fired while Atomic is idle.
118
+
119
+ ```typescript
120
+ pi.on("tool_result", async (event, ctx) => {
121
+ const response = await fetch("https://example.com/api", {
122
+ method: "POST",
123
+ body: JSON.stringify(event),
124
+ signal: ctx.signal,
125
+ });
126
+
127
+ const data = await response.json();
128
+ return { details: data };
129
+ });
130
+ ```
131
+
132
+ ### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages()
133
+
134
+ Control flow helpers.
135
+
136
+ ### ctx.isProjectTrusted()
137
+
138
+ Returns whether project-local trust is active for the current extension context. Use this before reading project-local config, loading project-local resources, or exposing actions that should only run after the user has trusted the cwd.
139
+
140
+ ```typescript
141
+ pi.registerCommand("project-status", {
142
+ description: "Show trust state",
143
+ handler: async (_args, ctx) => {
144
+ ctx.ui.notify(ctx.isProjectTrusted() ? "Project is trusted" : "Project is not trusted", "info");
145
+ },
146
+ });
147
+ ```
148
+
149
+ ### ctx.shutdown()
150
+
151
+ Request a graceful shutdown of Atomic.
152
+
153
+ - **Interactive mode:** Deferred until the agent becomes idle (after processing all queued steering and follow-up messages).
154
+ - **RPC mode:** Deferred until the next idle state (after completing the current command response, when waiting for the next command).
155
+ - **Print mode:** No-op. The process exits automatically when all prompts are processed.
156
+
157
+ Emits `session_shutdown` event to all extensions before exiting. Available in all contexts (event handlers, tools, commands, shortcuts).
158
+
159
+ ```typescript
160
+ pi.on("tool_call", (event, ctx) => {
161
+ if (isFatal(event.input)) {
162
+ ctx.shutdown();
163
+ }
164
+ });
165
+ ```
166
+
167
+ ### ctx.getContextUsage()
168
+
169
+ Returns current context usage for the active model. Uses last assistant usage when available, then estimates tokens for trailing messages.
170
+
171
+ ```typescript
172
+ const usage = ctx.getContextUsage();
173
+ if (usage && usage.tokens > 100_000) {
174
+ // ...
175
+ }
176
+ ```
177
+
178
+ ### ctx.compact()
179
+
180
+ Trigger Atomic's verbatim line compactor without awaiting completion. The planner emits numbered deleted-line ranges only; Atomic validates them and reconstructs retained text mechanically. Use `compression_ratio` (fraction of compactable lines to keep), client-side `preserve_recent` (an exact context-visible message count), and `query` to tune the run, and `onComplete`/`onError` for follow-up actions.
181
+
182
+ ```typescript
183
+ ctx.compact({
184
+ compression_ratio: 0.5, // fraction of compactable lines to keep
185
+ preserve_recent: 2, // protect exactly the newest two context-visible messages
186
+ query: "keep active migration details",
187
+ onComplete: (result) => {
188
+ ctx.ui.notify(`Compaction kept ${result.stats.linesKept}/${result.stats.linesBefore} lines`, "info");
189
+ },
190
+ onError: (error) => {
191
+ ctx.ui.notify(`Compaction failed: ${error.message}`, "error");
192
+ },
193
+ });
194
+ ```
195
+
196
+ The planner cannot author context text: only validated line ranges enter the mechanical reconstruction path. The `query` parameter guides relevance selection inside the fixed prompt; it is not replacement prose. Extensions that need an offline replacement can return `compactedText` from `session_before_compact`.
197
+
198
+ ### ctx.getSystemPrompt()
199
+
200
+ Returns Atomic's current system prompt string.
201
+
202
+ - During `before_agent_start`, this reflects chained system-prompt changes made so far for the current turn.
203
+ - It does not include later `context` message mutations.
204
+ - It does not include `before_provider_request` payload rewrites.
205
+ - If later-loaded extensions run after yours, they can still change what is ultimately sent.
206
+
207
+ ```typescript
208
+ pi.on("before_agent_start", (event, ctx) => {
209
+ const prompt = ctx.getSystemPrompt();
210
+ console.log(`System prompt length: ${prompt.length}`);
211
+ });
212
+ ```
213
+
214
+ ### ctx.getSkillCatalog()
215
+
216
+ Returns the current loader-owned skill catalog when the host provides one. Use it to resolve exact skill selectors, including source-qualified names such as `tdd@builtin`, without falling back to the bare precedence winner.
217
+
218
+ ```typescript
219
+ pi.on("session_start", (_event, ctx) => {
220
+ const catalog = ctx.getSkillCatalog?.();
221
+ const resolved = catalog?.resolve("tdd@builtin");
222
+ if (resolved?.ok) {
223
+ ctx.ui.notify(`Using ${resolved.candidate.selector}`, "info");
224
+ }
225
+ });
226
+ ```
227
+
228
+ `pi.getCommands()` already includes the same advertised `/skill:name` and `/skill:name@source` names. See [Skill Commands](/skills#skill-commands).
229
+
230
+ ## ExtensionCommandContext
231
+
232
+ Command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers.
233
+
234
+ ### ctx.waitForIdle()
235
+
236
+ Wait for the agent to finish streaming:
237
+
238
+ ```typescript
239
+ pi.registerCommand("my-cmd", {
240
+ handler: async (args, ctx) => {
241
+ await ctx.waitForIdle();
242
+ // Agent is now idle, safe to modify session
243
+ },
244
+ });
245
+ ```
246
+
247
+ ### ctx.newSession(options?)
248
+
249
+ Create a new session:
250
+
251
+ ```typescript
252
+ const parentSession = ctx.sessionManager.getSessionFile();
253
+ const kickoff = "Continue in the replacement session";
254
+
255
+ const result = await ctx.newSession({
256
+ parentSession,
257
+ setup: async (sm) => {
258
+ sm.appendMessage({
259
+ role: "user",
260
+ content: [{ type: "text", text: "Context from previous session..." }],
261
+ timestamp: Date.now(),
262
+ });
263
+ },
264
+ withSession: async (ctx) => {
265
+ // Use only the replacement-session ctx here.
266
+ await ctx.sendUserMessage(kickoff);
267
+ },
268
+ });
269
+
270
+ if (result.cancelled) {
271
+ // An extension cancelled the new session
272
+ }
273
+ ```
274
+
275
+ Options:
276
+ - `parentSession`: parent session file to record in the new session header
277
+ - `setup`: mutate the new session's `SessionManager` before `withSession` runs
278
+ - `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns).
279
+
280
+ ### ctx.fork(entryId, options?)
281
+
282
+ Fork from a specific entry, creating a new session file:
283
+
284
+ ```typescript
285
+ const result = await ctx.fork("entry-id-123", {
286
+ withSession: async (ctx) => {
287
+ // Use only the replacement-session ctx here.
288
+ ctx.ui.notify("Now in the forked session", "info");
289
+ },
290
+ });
291
+ if (result.cancelled) {
292
+ // An extension cancelled the fork
293
+ }
294
+
295
+ const cloneResult = await ctx.fork("entry-id-456", { position: "at" });
296
+ if (cloneResult.cancelled) {
297
+ // An extension cancelled the clone
298
+ }
299
+ ```
300
+
301
+ Options:
302
+ - `position`: `"before"` (default) forks before the selected user message, restoring that prompt into the editor
303
+ - `position`: `"at"` duplicates the active path through the selected entry without restoring editor text
304
+ - `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns).
305
+
306
+ ### ctx.navigateTree(targetId, options?)
307
+
308
+ Navigate to a different point in the session tree. Navigation rejects while a response, compaction, or branch summarization is active, even with `summarize: false`. Rejection leaves the active branch unchanged. Wait for the active operation to finish and retry.
309
+
310
+ ```typescript
311
+ const result = await ctx.navigateTree("entry-id-456", {
312
+ summarize: true,
313
+ customInstructions: "Focus on error handling changes",
314
+ replaceInstructions: false, // true = replace default prompt entirely
315
+ label: "review-checkpoint",
316
+ });
317
+ ```
318
+
319
+ Options:
320
+ - `summarize`: Whether to generate a summary of the abandoned branch
321
+ - `customInstructions`: Custom instructions for the summarizer
322
+ - `replaceInstructions`: If true, `customInstructions` replaces the default prompt instead of being appended
323
+ - `label`: Label to attach to the branch summary entry (or target entry if not summarizing)
324
+
325
+ ### ctx.switchSession(sessionPath, options?)
326
+
327
+ Switch to a different session file:
328
+
329
+ ```typescript
330
+ const result = await ctx.switchSession("/path/to/session.jsonl", {
331
+ withSession: async (ctx) => {
332
+ await ctx.sendUserMessage("Resume work in the replacement session");
333
+ },
334
+ });
335
+ if (result.cancelled) {
336
+ // An extension cancelled the switch via session_before_switch
337
+ }
338
+ ```
339
+
340
+ Options:
341
+ - `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns).
342
+
343
+ To discover available sessions, use the static `SessionManager.list()` or `SessionManager.listAll()` methods:
344
+
345
+ ```typescript
346
+ import { SessionManager } from "@bastani/atomic";
347
+
348
+ pi.registerCommand("switch", {
349
+ description: "Switch to another session",
350
+ handler: async (args, ctx) => {
351
+ const sessions = await SessionManager.list(ctx.cwd);
352
+ if (sessions.length === 0) return;
353
+ const choice = await ctx.ui.select(
354
+ "Pick session:",
355
+ sessions.map(s => s.file),
356
+ );
357
+ if (choice) {
358
+ await ctx.switchSession(choice, {
359
+ withSession: async (ctx) => {
360
+ ctx.ui.notify("Switched session", "info");
361
+ },
362
+ });
363
+ }
364
+ },
365
+ });
366
+ ```
367
+
368
+ ### Session replacement lifecycle and footguns
369
+
370
+ `withSession` receives a fresh `ReplacedSessionContext`, which extends `ExtensionCommandContext` with async `sendMessage()` and `sendUserMessage()` helpers bound to the replacement session.
371
+
372
+ Lifecycle and footguns:
373
+ - `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`.
374
+ - 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.
375
+ - Captured old `pi` / old command `ctx` session-bound objects are stale after replacement and will throw if used. Use only the `ctx` passed to `withSession` for session-bound work.
376
+ - Previously extracted raw objects are still your responsibility. For example, if you capture `const sm = ctx.sessionManager` before replacement, `sm` is still the old `SessionManager` object. Do not reuse it after replacement.
377
+ - 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.
378
+ - Long-lived callbacks that need to classify a stale API error should use `isStaleExtensionContextError(error)` from `@bastani/atomic`, not match the host's error message.
379
+
380
+ Safe pattern:
381
+
382
+ ```typescript
383
+ pi.registerCommand("handoff", {
384
+ handler: async (_args, ctx) => {
385
+ const kickoff = "Continue from the replacement session";
386
+ await ctx.newSession({
387
+ withSession: async (ctx) => {
388
+ await ctx.sendUserMessage(kickoff);
389
+ },
390
+ });
391
+ },
392
+ });
393
+ ```
394
+
395
+ Unsafe pattern:
396
+
397
+ ```typescript
398
+ pi.registerCommand("handoff", {
399
+ handler: async (_args, ctx) => {
400
+ const oldSessionManager = ctx.sessionManager;
401
+ await ctx.newSession({
402
+ withSession: async (_ctx) => {
403
+ // stale old objects: do not do this
404
+ oldSessionManager.getSessionFile();
405
+ pi.sendUserMessage("wrong");
406
+ },
407
+ });
408
+ },
409
+ });
410
+ ```
411
+
412
+ ### ctx.reload()
413
+
414
+ Run the same reload flow as `/reload`.
415
+
416
+ ```typescript
417
+ pi.registerCommand("reload-runtime", {
418
+ description: "Reload extensions, skills, prompts, and themes",
419
+ handler: async (_args, ctx) => {
420
+ await ctx.reload();
421
+ return;
422
+ },
423
+ });
424
+ ```
425
+
426
+ Important behavior:
427
+ - `await ctx.reload()` emits `session_shutdown` for the current extension runtime
428
+ - It then reloads resources and emits `session_start` with `reason: "reload"` and `resources_discover` with reason `"reload"`
429
+ - The currently running command handler still continues in the old call frame
430
+ - Code after `await ctx.reload()` still runs from the pre-reload version
431
+ - Code after `await ctx.reload()` must not assume old in-memory extension state is still valid
432
+ - After the handler returns, future commands/events/tool calls use the new extension version
433
+
434
+ For predictable behavior, treat reload as terminal for that handler (`await ctx.reload(); return;`).
435
+
436
+ Tools run with `ExtensionContext`, so they cannot call `ctx.reload()` directly. Use a command as the reload entrypoint, then expose a tool that queues that command as a follow-up user message.
437
+
438
+ Example tool the LLM can call to trigger reload:
439
+
440
+ ```typescript
441
+ import type { ExtensionAPI } from "@bastani/atomic";
442
+ import { Type } from "typebox";
443
+
444
+ export default function (pi: ExtensionAPI) {
445
+ pi.registerCommand("reload-runtime", {
446
+ description: "Reload extensions, skills, prompts, and themes",
447
+ handler: async (_args, ctx) => {
448
+ await ctx.reload();
449
+ return;
450
+ },
451
+ });
452
+
453
+ pi.registerTool({
454
+ name: "reload_runtime",
455
+ label: "Reload Runtime",
456
+ description: "Reload extensions, skills, prompts, and themes",
457
+ parameters: Type.Object({}),
458
+ async execute() {
459
+ pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" });
460
+ return {
461
+ content: [{ type: "text", text: "Queued /reload-runtime as a follow-up command." }],
462
+ };
463
+ },
464
+ });
465
+ }
466
+ ```
467
+
468
+ ## ExtensionAPI Methods
469
+
470
+ ### pi.on(event, handler)
471
+
472
+ Subscribe to events. See [Events](/extensions/events#events) for event types and return values.
473
+
474
+ ### pi.registerTool(definition)
475
+
476
+ Register a custom tool callable by the LLM. See [Custom Tools](/extensions/authoring#custom-tools) for full details.
477
+
478
+ `pi.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 `pi.getAllTools()` and are callable by the LLM without `/reload`.
479
+
480
+ Use `pi.setActiveTools()` to enable or disable tools (including dynamically added tools) at runtime. Atomic always restores mandatory ordinary `intercom`; other tool behavior is unchanged.
481
+
482
+ 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.
483
+
484
+ **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.
485
+
486
+ See [dynamic-tools.ts](https://github.com/bastani-inc/atomic/blob/main/packages/coding-agent/examples/extensions/dynamic-tools.ts) for a full example.
487
+
488
+ #### Built-in tool prompt contributions
489
+
490
+ Atomic exports immutable prompt metadata for its built-in coding tools. Use these constants when a custom harness or tool registry needs the same prompt entries as the built-in factories:
491
+
492
+ ```typescript
493
+ import {
494
+ bashToolSystemPromptContribution,
495
+ editToolSystemPromptContribution,
496
+ findToolSystemPromptContribution,
497
+ lsToolSystemPromptContribution,
498
+ readToolSystemPromptContribution,
499
+ searchToolSystemPromptContribution,
500
+ writeToolSystemPromptContribution,
501
+ } from "@bastani/atomic";
502
+
503
+ const { snippet, guidelines } = readToolSystemPromptContribution;
504
+ ```
505
+
506
+ Each contribution has a readonly `snippet` for the `Available tools` section and readonly `guidelines` for the active tool's `Guidelines` entries. The seven exports are `bash`, `edit`, `find`, `ls`, `read`, `search`, and `write`; Atomic's public `search` export is the corresponding surface for pi's upstream `grep` tool. The built-in factories use these values directly, so consumers do not need to duplicate prompt text.
507
+
508
+ Use Atomic's export rather than importing `StringEnum` directly from Pi. It preserves Pi's Google-compatible runtime schema while keeping the schema typed against Atomic's direct TypeBox version.
509
+
510
+ ```typescript
511
+ import { Type } from "typebox";
512
+ import { StringEnum } from "@bastani/atomic";
513
+
514
+ pi.registerTool({
515
+ name: "my_tool",
516
+ label: "My Tool",
517
+ description: "What this tool does",
518
+ promptSnippet: "Summarize or transform text according to action",
519
+ promptGuidelines: ["Use my_tool when the user asks to summarize previously generated text."],
520
+ parameters: Type.Object({
521
+ action: StringEnum(["list", "add"] as const),
522
+ text: Type.Optional(Type.String()),
523
+ }),
524
+ prepareArguments(args) {
525
+ // Optional compatibility shim. Runs before schema validation.
526
+ // Return the current schema shape, for example to fold legacy fields
527
+ // into the modern parameter object.
528
+ return args;
529
+ },
530
+
531
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
532
+ // Stream progress
533
+ onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
534
+
535
+ return {
536
+ content: [{ type: "text", text: "Done" }],
537
+ details: { result: "..." },
538
+ };
539
+ },
540
+
541
+ // Optional: Custom rendering
542
+ renderCall(args, theme, context) { ... },
543
+ renderResult(result, options, theme, context) { ... },
544
+ });
545
+ ```
546
+
547
+ ### pi.sendMessage(message, options?)
548
+
549
+ Inject a custom message into the session. The call returns `void | Promise<void>` for compatibility with synchronous hosts; use `await Promise.resolve(pi.sendMessage(...))` when admission or routing failure must be observed. Atomic's AgentSession runtime returns an admission receipt: it settles after the message is accepted by the local queue or workflow late-message route, without waiting for the resulting model turn to finish.
550
+
551
+ ```typescript
552
+ pi.sendMessage({
553
+ customType: "my-extension",
554
+ content: "Message text",
555
+ display: true,
556
+ details: { ... },
557
+ }, {
558
+ triggerTurn: true,
559
+ deliverAs: "steer",
560
+ });
561
+ ```
562
+
563
+ **Options:**
564
+ - `deliverAs` - Delivery mode:
565
+ - `"steer"` (default) - Queues the message while streaming. Delivered after the current assistant turn finishes executing its tool calls, before the next LLM call.
566
+ - `"followUp"` - Waits for agent to finish. Delivered only when agent has no more tool calls.
567
+ - `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything.
568
+ - `"interrupt"` - With `triggerTurn: true`, aborts an active streaming turn and immediately starts a new turn with the custom message. When idle, behaves like a triggered custom message.
569
+ - `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Required for `"interrupt"`; ignored for `"nextTurn"`.
570
+ - `excludeFromContext: true` - Render and persist the custom message without adding it to LLM context. With no `deliverAs`, this remains display-only even while the agent is streaming.
571
+ - `interruptAbortMessage` - Optional text used to replace generic abort results (for example `Operation aborted`) when `deliverAs: "interrupt"` aborts an active turn.
572
+
573
+ ### pi.sendMessages(messages, options?)
574
+
575
+ Atomically admit a batch of custom messages in array order. The call returns `void | Promise<void>` for compatibility with synchronous hosts; use `await Promise.resolve(pi.sendMessages(...))` when admission or routing failure must be observed. The promise is an admission receipt and does not wait for the resulting model turn. Admission is indivisible; use this when a prelude and terminal notice must stay contiguous without globally serializing other extension work.
576
+
577
+ ```typescript
578
+ pi.sendMessages([
579
+ { customType: "worker-update", content: "Ready", display: true },
580
+ { customType: "worker-terminal", content: "Completed", display: true },
581
+ ], { triggerTurn: true });
582
+ ```
583
+
584
+ The batch supports `triggerTurn`, `excludeFromContext`, and `deliverAs: "steer" | "followUp" | "nextTurn"`. Interrupt delivery remains a single-message operation.
585
+
586
+ ### pi.sendUserMessage(content, options?)
587
+
588
+ 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.
589
+
590
+ ```typescript
591
+ // Simple text message
592
+ pi.sendUserMessage("What is 2+2?");
593
+
594
+ // With content array (text + images)
595
+ pi.sendUserMessage([
596
+ { type: "text", text: "Describe this image:" },
597
+ { type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } },
598
+ ]);
599
+
600
+ // During streaming - must specify delivery mode
601
+ pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" });
602
+ pi.sendUserMessage("And then summarize", { deliverAs: "followUp" });
603
+
604
+ // Opt in to extension command dispatch and skill/prompt template expansion
605
+ pi.sendUserMessage("/review src/index.ts", { expandPromptTemplates: true });
606
+ ```
607
+
608
+ **Options:**
609
+ - `deliverAs` - Required when agent is streaming:
610
+ - `"steer"` - Queues the message for delivery after the current assistant turn finishes executing its tool calls
611
+ - `"followUp"` - Waits for agent to finish all tools
612
+
613
+ - `expandPromptTemplates` - Dispatch extension commands and expand skill commands and prompt templates instead of sending the text literally. Defaults to `false`, so an extension-authored message is sent as-is unless it opts in; an unknown command falls through to a literal send.
614
+
615
+ When not streaming, the message is sent immediately and triggers a new turn. When streaming without `deliverAs`, throws an error.
616
+
617
+ See [send-user-message.ts](https://github.com/bastani-inc/atomic/blob/main/packages/coding-agent/examples/extensions/send-user-message.ts) for a complete example.
618
+
619
+ ### pi.appendEntry(customType, data?)
620
+
621
+ Persist extension state (does NOT participate in LLM context).
622
+
623
+ ```typescript
624
+ pi.appendEntry("my-state", { count: 42 });
625
+
626
+ // Restore on reload
627
+ pi.on("session_start", async (_event, ctx) => {
628
+ for (const entry of ctx.sessionManager.getEntries()) {
629
+ if (entry.type === "custom" && entry.customType === "my-state") {
630
+ // Reconstruct from entry.data
631
+ }
632
+ }
633
+ });
634
+ ```
635
+
636
+ Appending emits `entry_appended` with the durable entry. This lets extensions react to session entries without polling.
637
+
638
+ ### pi.registerEntryRenderer(customType, renderer)
639
+
640
+ Register a TUI renderer for durable custom entries created by `pi.appendEntry()`. These entries render in the transcript but do not enter model context.
641
+
642
+ ```typescript
643
+ import { Text } from "@earendil-works/pi-tui";
644
+
645
+ pi.registerEntryRenderer("status-card", (entry, { expanded }, theme) =>
646
+ new Text(theme.fg("accent", `${expanded ? "Details" : "Status"}: ${JSON.stringify(entry.data)}`), 0, 0)
647
+ );
648
+ ```
649
+
650
+
651
+ ### pi.setSessionName(name)
652
+
653
+ Set the session display name (shown in session selector instead of first message).
654
+
655
+ ```typescript
656
+ pi.setSessionName("Refactor auth module");
657
+ ```
658
+
659
+ ### pi.getSessionName()
660
+
661
+ Get the current session name, if set.
662
+
663
+ ```typescript
664
+ const name = pi.getSessionName();
665
+ if (name) {
666
+ console.log(`Session: ${name}`);
667
+ }
668
+ ```
669
+
670
+ ### pi.setLabel(entryId, label)
671
+
672
+ Set or clear a label on an entry. Labels are user-defined markers for bookmarking and navigation (shown in `/tree` selector).
673
+
674
+ ```typescript
675
+ // Set a label
676
+ pi.setLabel(entryId, "checkpoint-before-refactor");
677
+
678
+ // Clear a label
679
+ pi.setLabel(entryId, undefined);
680
+
681
+ // Read labels via sessionManager
682
+ const label = ctx.sessionManager.getLabel(entryId);
683
+ ```
684
+
685
+ Labels persist in the session and survive restarts. Use them to mark important points (turns, checkpoints) in the conversation tree.
686
+
687
+ ### pi.registerCommand(name, options)
688
+
689
+ Register a command.
690
+
691
+ If multiple extensions register the same command name, Atomic keeps them all and assigns numeric invocation suffixes in load order, for example `/review:1` and `/review:2`.
692
+
693
+ ```typescript
694
+ pi.registerCommand("stats", {
695
+ description: "Show session statistics",
696
+ handler: async (args, ctx) => {
697
+ const count = ctx.sessionManager.getEntries().length;
698
+ ctx.ui.notify(`${count} entries`, "info");
699
+ }
700
+ });
701
+ ```
702
+
703
+ Optional: add argument auto-completion for `/command ...`:
704
+
705
+ ```typescript
706
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
707
+
708
+ pi.registerCommand("deploy", {
709
+ description: "Deploy to an environment",
710
+ getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
711
+ const envs = ["dev", "staging", "prod"];
712
+ const items = envs.map((e) => ({ value: e, label: e }));
713
+ const filtered = items.filter((i) => i.value.startsWith(prefix));
714
+ return filtered.length > 0 ? filtered : null;
715
+ },
716
+ handler: async (args, ctx) => {
717
+ ctx.ui.notify(`Deploying: ${args}`, "info");
718
+ },
719
+ });
720
+ ```
721
+
722
+ ### pi.getCommands()
723
+
724
+ Get the slash commands available for invocation via `prompt` in the current session. Includes extension commands, prompt templates, and skill commands.
725
+ The list matches the RPC `get_commands` ordering: extensions first, then templates, then skills.
726
+
727
+ ```typescript
728
+ const commands = pi.getCommands();
729
+ const bySource = commands.filter((command) => command.source === "extension");
730
+ const userScoped = commands.filter((command) => command.sourceInfo.scope === "user");
731
+ ```
732
+
733
+ Each entry has this shape:
734
+
735
+ ```typescript
736
+ {
737
+ name: string; // Invokable command name without the leading slash. May be suffixed like "review:1"
738
+ description?: string;
739
+ source: "extension" | "prompt" | "skill";
740
+ sourceInfo: {
741
+ path: string;
742
+ source: string;
743
+ scope: "user" | "project" | "temporary";
744
+ origin: "package" | "top-level";
745
+ baseDir?: string;
746
+ };
747
+ }
748
+ ```
749
+
750
+ Use `sourceInfo` as the canonical provenance field. Do not infer ownership from command names or from ad hoc path parsing.
751
+
752
+ Built-in interactive commands (like `/model` and `/settings`) are not included here. They are handled only in interactive
753
+ mode and would not execute if sent via `prompt`.
754
+
755
+ ### pi.registerMessageRenderer(customType, renderer)
756
+
757
+ Register a custom TUI renderer for messages with your `customType`. The renderer options contain `expanded` and the current numeric `outputPad`, so custom output can align with built-in messages. The same options are provided in normal and isolated-engine rendering. See [Custom UI](/extensions/ui#custom-ui).
758
+
759
+ ### pi.registerMarkdownTransformer(transformer)
760
+
761
+ Register a synchronous, display-only transformer for Markdown in normal user text, assistant text, and thinking blocks. Atomic runs transformers in extension load order. Each extension retains one transformer, so a later call from that extension replaces its prior transformer. Each transformer receives the Markdown returned by the prior transformer, then Atomic renders the final value with its built-in Markdown renderer.
762
+
763
+ The transformer receives the Markdown string and a context with:
764
+
765
+ - `messageType` — `"user"`, `"assistant"`, or `"assistant-thinking"`
766
+ - `isStreaming` — `true` for partial assistant updates; `false` for user, finalized assistant, and restored messages
767
+ - `availableWidth` — exact terminal columns available for the transformed Markdown content
768
+
769
+ Return the transformed Markdown:
770
+
771
+ ```typescript
772
+ pi.registerMarkdownTransformer((markdown, { messageType, isStreaming }) => {
773
+ if (isStreaming || messageType === "assistant-thinking") return markdown;
774
+ return markdown.replaceAll("-->", "→");
775
+ });
776
+ ```
777
+
778
+ If a transformer throws, Atomic keeps the Markdown produced so far and continues with the next transformer. The hook never changes the original message, session transcript, or model context. It runs for new user messages, assistant streaming updates, restored session messages, and terminal-width changes, so keep transformers synchronous and inexpensive. Isolated-engine rendering does not run host-side display transformers.
779
+
780
+ ### pi.registerShortcut(shortcut, options)
781
+
782
+ Register a keyboard shortcut. See [Keybindings](/keybindings) for the shortcut format and built-in keybindings.
783
+
784
+ ```typescript
785
+ pi.registerShortcut("ctrl+shift+p", {
786
+ description: "Toggle plan mode",
787
+ handler: async (ctx) => {
788
+ ctx.ui.notify("Toggled!");
789
+ },
790
+ });
791
+ ```
792
+
793
+ ### pi.registerFlag(name, options)
794
+
795
+ Register a CLI flag.
796
+
797
+ ```typescript
798
+ pi.registerFlag("plan", {
799
+ description: "Start in plan mode",
800
+ type: "boolean",
801
+ default: false,
802
+ });
803
+
804
+ // Check value
805
+ if (pi.getFlag("plan")) {
806
+ // Plan mode enabled
807
+ }
808
+ ```
809
+
810
+ ### pi.exec(command, args, options?)
811
+
812
+ Execute a shell command.
813
+
814
+ ```typescript
815
+ const result = await pi.exec("git", ["status"], { signal, timeout: 5000 });
816
+ // result.stdout, result.stderr, result.code, result.killed
817
+ ```
818
+
819
+ ### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names)
820
+
821
+ Manage active tools. This works for both built-in tools and dynamically registered tools. `pi.getActiveTools()` returns the active tool names as `string[]`; `pi.getAllTools()` returns metadata for all configured tools.
822
+
823
+ ```typescript
824
+ const active = pi.getActiveTools(); // ["read", "bash", ...]
825
+ const all = pi.getAllTools();
826
+ // all = [{
827
+ // name: "read",
828
+ // description: "Read file contents...",
829
+ // parameters: ...,
830
+ // promptGuidelines: ["Use read to examine files instead of cat or sed."],
831
+ // sourceInfo: { path: "<builtin:read>", source: "builtin", scope: "temporary", origin: "top-level" }
832
+ // }, ...]
833
+ const builtinTools = all.filter((t) => t.sourceInfo.source === "builtin");
834
+ const extensionTools = all.filter((t) => t.sourceInfo.source !== "builtin" && t.sourceInfo.source !== "sdk");
835
+ pi.setActiveTools([...new Set([...active, "my_custom_tool"])]); // Keep current tools and enable my_custom_tool
836
+ pi.setActiveTools(["read", "bash"]); // Switch to read-only
837
+ ```
838
+
839
+ `pi.getAllTools()` returns `name`, `description`, `parameters`, `promptGuidelines`, and `sourceInfo`.
840
+
841
+ Typical `sourceInfo.source` values:
842
+ - `builtin` for built-in tools
843
+ - `sdk` for tools passed via `createAgentSession({ customTools })`
844
+ - extension source metadata for tools registered by extensions
845
+
846
+ ### pi.setModel(model)
847
+
848
+ Set the model for the current session. The change is recorded in session history and restored when that session is resumed, but it does not change the configured `defaultProvider` or `defaultModel` used by new sessions. Returns `false` if authentication is not configured for the model's provider. See [Custom models](/models) for configuring custom models.
849
+
850
+ ```typescript
851
+ const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5");
852
+ if (model) {
853
+ const success = await pi.setModel(model);
854
+ if (!success) {
855
+ ctx.ui.notify("No API key for this model", "error");
856
+ }
857
+ }
858
+ ```
859
+
860
+ ### pi.getThinkingLevel() / pi.setThinkingLevel(level)
861
+
862
+ Get the current thinking level. Level is clamped to model capabilities (non-reasoning models always use `"off"`; `"xhigh"` and `"max"` require model support). Changes emit `thinking_level_select`.
863
+
864
+ `pi.setThinkingLevel()` changes the thinking level for the current session. The change is recorded in session history and restored when that session is resumed, but it does not change the configured default used by new sessions.
865
+
866
+ ```typescript
867
+ const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
868
+ pi.setThinkingLevel("high");
869
+ ```
870
+
871
+ ### pi.events
872
+
873
+ Shared event bus for communication between active extensions. A subscription made with `on()` is removed automatically when its extension reloads or the session disposes. Use the returned function if you need to stop listening sooner:
874
+
875
+ ```typescript
876
+ const unsubscribe = pi.events.on("my:event", (data) => { ... });
877
+ pi.events.emit("my:event", { ... });
878
+ unsubscribe();
879
+ ```
880
+
881
+ `pi.events` belongs to the extension instance that received it. Register listeners again when that instance reloads, and do not retain the object for later use: calling `on()` or `emit()` through a captured handle after reload or disposal throws. To keep in-memory state across `/reload`, pass this facade to [`sessionScopedExtensionState`](/extensions/authoring#session-scoped-in-memory-state); do not capture the facade itself.
882
+
883
+ If you implement an `ExtensionRuntime` for an embedded host, provide `trackEventBusSubscription(unsubscribe)` and retain each returned subscription until that extension runtime is reloaded or disposed. `ExtensionUIContext.getChatRenderSettings()` must return `markdownTransformers`; it may also return `renderLatex` to control terminal math rendering. These fields keep event subscriptions and display transforms scoped to the active extension instance.
884
+
885
+
886
+ ### Native providers
887
+
888
+ In addition to `registerProvider(name, config)`, extensions can register a complete native `Provider` from `@bastani/pi-ai` with `pi.registerProvider(provider)`. Use the native overload for provider-owned authentication, catalog refresh, and transport behavior; use the config overload for ordinary proxies and custom endpoints.
889
+
890
+ ### pi.registerProvider(name, config)
891
+
892
+ Register or override a model provider dynamically. Useful for proxies, custom endpoints, or team-wide model configurations.
893
+
894
+ Calls made during the extension factory function are queued and applied once the runner initialises. Calls made after that — for example from a command handler following a user setup flow — take effect immediately without requiring a `/reload`.
895
+
896
+ If you need to discover models from a remote endpoint, prefer an async extension factory over deferring the fetch to `session_start`. Atomic waits for the factory before startup continues, so the registered models are available immediately, including to `atomic --list-models`.
897
+
898
+ ```typescript
899
+ // Register a new provider with custom models
900
+ pi.registerProvider("my-proxy", {
901
+ name: "My Proxy",
902
+ baseUrl: "https://proxy.example.com",
903
+ apiKey: "$PROXY_API_KEY", // env var reference; omit $ for a literal
904
+ api: "anthropic-messages",
905
+ models: [
906
+ {
907
+ id: "claude-sonnet-4-20250514",
908
+ name: "Claude 4 Sonnet (proxy)",
909
+ reasoning: false,
910
+ input: ["text", "image"],
911
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
912
+ contextWindow: 200000,
913
+ maxTokens: 16384
914
+ }
915
+ ]
916
+ });
917
+
918
+ // Override baseUrl for an existing provider (keeps all models)
919
+ pi.registerProvider("anthropic", {
920
+ baseUrl: "https://proxy.example.com"
921
+ });
922
+
923
+ // Register provider with OAuth support for /login
924
+ pi.registerProvider("corporate-ai", {
925
+ baseUrl: "https://ai.corp.com",
926
+ api: "openai-responses",
927
+ models: [...],
928
+ oauth: {
929
+ name: "Corporate AI (SSO)",
930
+ async login(callbacks, signal) {
931
+ // Custom OAuth flow
932
+ callbacks.onAuth({ url: "https://sso.corp.com/..." });
933
+ const code = await callbacks.onPrompt({ message: "Enter code:" });
934
+ signal.throwIfAborted();
935
+ return { refresh: code, access: code, expires: Date.now() + 3600000 };
936
+ },
937
+ async refreshToken(credentials, signal) {
938
+ // Forward signal to the refresh request.
939
+ signal.throwIfAborted();
940
+ return credentials;
941
+ },
942
+ getApiKey(credentials) {
943
+ return credentials.access;
944
+ }
945
+ }
946
+ });
947
+
948
+ // Register provider-owned API-key setup for /login
949
+ pi.registerProvider("local-server", {
950
+ name: "Local Server",
951
+ auth: {
952
+ apiKey: {
953
+ name: "Local server connection",
954
+ async login({ signal, prompt }) {
955
+ const baseUrl = await prompt({
956
+ type: "text",
957
+ message: "Server URL",
958
+ placeholder: "http://localhost:8080"
959
+ });
960
+ if (signal.aborted) throw new Error("Login cancelled");
961
+ return { type: "api_key", env: { LOCAL_SERVER_URL: baseUrl } };
962
+ }
963
+ }
964
+ }
965
+ });
966
+ ```
967
+
968
+ **Config options:**
969
+ - `name` - Display name for the provider in UI such as `/login`.
970
+ - `baseUrl` - API endpoint URL. Required when defining models.
971
+ - `apiKey` - API key literal or explicit environment variable reference (`$ENV_VAR` or `${ENV_VAR}`). Required when defining models (unless `oauth` provided).
972
+ - `api` - API type: `"anthropic-messages"`, `"openai-completions"`, `"openai-responses"`, etc.
973
+ - `headers` - Custom headers to include in requests.
974
+ - `authHeader` - If true, adds `Authorization: Bearer` header automatically.
975
+ - `models` - Array of model definitions. If provided, replaces all existing models for this provider. Model definitions can set `baseUrl` to override the provider endpoint for that model.
976
+ - `oauth` - OAuth provider config for `/login` support. When provided, the provider appears in the login menu.
977
+ - `auth.apiKey` - Provider-owned API-key or connection setup for `/login`. Its `name` appears in the provider list and `login({ signal, prompt })` returns the credential Atomic persists. Extension providers registered only in the isolated interactive engine child are synchronized into the host's `/login` list; their login callback and credential-dependent model refresh still execute in the child, while prompts are rendered by the terminal host.
978
+ - `streamSimple` - Custom streaming implementation for non-standard APIs.
979
+
980
+ See [Custom providers](/custom-provider) for advanced topics: custom streaming APIs, OAuth details, model definition reference.
981
+
982
+ ### pi.unregisterProvider(name)
983
+
984
+ Remove a previously registered provider and its models. Built-in models that were overridden by the provider are restored. Has no effect if the provider was not registered.
985
+
986
+ Like `registerProvider`, this takes effect immediately when called after the initial load phase, so a `/reload` is not required.
987
+
988
+ ```typescript
989
+ pi.registerCommand("my-setup-teardown", {
990
+ description: "Remove the custom proxy provider",
991
+ handler: async (_args, _ctx) => {
992
+ pi.unregisterProvider("my-proxy");
993
+ },
994
+ });
995
+ ```
996
+
997
+ ## Error Handling
998
+
999
+ - Extension errors are logged, agent continues
1000
+ - `tool_call` errors block the tool (fail-safe)
1001
+ - Tool `execute` errors must be signaled by throwing; the thrown error is caught, reported to the LLM with `isError: true`, and execution continues