@aliou/pi-dev-kit 0.6.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,130 +1,95 @@
1
1
  # Messages
2
2
 
3
- Pi provides several ways to display information to the user. Choose based on the UX goal.
3
+ Pi provides several ways to show information. Choose based on persistence and interactivity.
4
4
 
5
5
  ## When to Use What
6
6
 
7
- | Method | Persistence | Interactivity | Use When |
8
- |---|---|---|---|
9
- | `ctx.ui.notify()` | Transient (fades) | None | Quick feedback: "Saved", "API key missing" |
10
- | `ctx.ui.custom()` | Until dismissed | Full keyboard | Rich interactive display: pickers, dashboards |
11
- | `pi.sendMessage()` | In session history | Via renderer | Persistent results that should survive compaction |
12
- | `pi.appendEntry()` | In session history | Via renderer | State tracking entries (see `references/state.md`) |
7
+ | API | Persists | LLM context | Use when |
8
+ |---|---:|---:|---|
9
+ | `ctx.ui.notify()` | No | No | Quick feedback. |
10
+ | `ctx.ui.custom()` | No | No | Rich interactive display. |
11
+ | `pi.sendMessage()` | Yes | Yes when delivered into context | Persistent custom/user-visible messages. |
12
+ | `pi.appendEntry()` | Yes | No | Extension state/history that should not enter model context. |
13
+ | Tool result `details` | Yes | Details no; content yes | Branch-aware state tied to a tool call. |
13
14
 
14
- ## sendMessage
15
+ For tool state, prefer tool result `details`. For command results that should be visible later, use `sendMessage` plus a renderer.
15
16
 
16
- Sends a message into the session conversation. It appears as an assistant message and is persisted in session history.
17
+ ## `pi.sendMessage()`
18
+
19
+ Sends a custom message into the session.
17
20
 
18
21
  ```typescript
19
22
  pi.sendMessage({
20
- customType: "balance-result", // Identifier for the message renderer
21
- content: "Balance: $42.50", // Plain text content (LLM sees this)
22
- display: true, // Show in TUI
23
- details: { balance: 42.50 }, // Rich data for custom rendering
23
+ customType: "balance-result",
24
+ content: "Balance: $42.50",
25
+ display: true,
26
+ details: { balance: 42.5 },
24
27
  });
25
28
  ```
26
29
 
27
- | Field | Type | Description |
28
- |---|---|---|
29
- | `customType` | `string` | Identifies the message type. Paired with `registerMessageRenderer`. |
30
- | `content` | `string` | Plain text content. This is what the LLM sees if the message is in context. |
31
- | `display` | `boolean` | Whether to show the message in the TUI. |
32
- | `details` | `object` | Arbitrary data passed to the message renderer. |
33
-
34
- ## registerMessageRenderer
35
-
36
- Registers a custom renderer for messages with a specific `customType`:
30
+ Options:
37
31
 
38
32
  ```typescript
39
- import type {
40
- ExtensionAPI,
41
- MessageRenderOptions,
42
- Theme,
43
- } from "@mariozechner/pi-coding-agent";
44
- import { Text } from "@mariozechner/pi-tui";
45
-
46
- interface BalanceDetails {
47
- balance?: number;
48
- }
49
-
50
- export default function (pi: ExtensionAPI) {
51
- pi.registerMessageRenderer<BalanceDetails>(
52
- "balance-result",
53
- (message, _options: MessageRenderOptions, theme: Theme) => {
54
- const balance = message.details?.balance;
55
- const text =
56
- typeof balance === "number"
57
- ? `Account Balance: $${balance.toFixed(2)}`
58
- : message.content;
59
-
60
- return new Text(theme.fg("success", text), 0, 0);
61
- },
62
- );
63
- }
33
+ pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
64
34
  ```
65
35
 
66
- The renderer receives the full message object, render options, and theme. It returns a TUI `Component` such as `Text`, `Box`, `Markdown`, or a custom component. If no renderer is registered for a `customType`, the message's `content` field is displayed as plain text.
67
-
68
- For larger renderers, keep the renderer implementation next to the hook or command that emits that `customType`. See `pi-processes/src/hooks/message-renderer.ts` for a compact lifecycle-message renderer and `pi-harness/extensions/breadcrumbs/lib/session-link.ts` for richer persistent session-link renderers.
69
-
70
- ## Custom Message Design Guide (breadcrumbs-style)
71
-
72
- `breadcrumbs` is a good reference for custom entries/messages (`../pi-extensions/extensions/breadcrumbs/lib/session-link.ts`, plus `commands/handoff.ts` and `commands/spawn.ts`).
73
-
74
- ### 1) Prefer paired entries for links/handovers
75
-
76
- For cross-session workflows, use two custom message types:
77
- - **Marker** in source session: short line (`Handed off to X` / `Continues in X`).
78
- - **Source** in new session: header + optional expanded context.
79
-
80
- This gives both directions of navigation and keeps history readable.
36
+ Delivery modes:
81
37
 
82
- ### 2) Collapsed vs expanded behavior
38
+ - `steer`: queue while streaming and deliver before the next LLM call.
39
+ - `followUp`: wait until the agent finishes.
40
+ - `nextTurn`: store for the next user prompt.
83
41
 
84
- Keep collapsed view minimal and scannable:
85
- - one semantic line
86
- - optional hint (`Press Ctrl+O to expand`) only when extra content exists
42
+ ## `registerMessageRenderer`
87
43
 
88
- Use expanded view for rich content:
89
- - markdown body
90
- - multi-line context
91
- - file lists / instructions
44
+ Register a renderer for `customType`. Renderers return TUI `Component | undefined`.
92
45
 
93
- ### 3) Renderer resilience
94
-
95
- Message renderers should degrade safely:
96
- - missing `details` => fallback to plain `content`
97
- - markdown render failure => fallback to plain text
98
- - unknown fields => ignore, don't throw
46
+ ```typescript
47
+ import type { ExtensionAPI, MessageRenderOptions, Theme } from "@earendil-works/pi-coding-agent";
48
+ import { Text } from "@earendil-works/pi-tui";
99
49
 
100
- ### 4) Keep details small and durable
50
+ interface BalanceDetails {
51
+ balance?: number;
52
+ }
101
53
 
102
- `details` should contain stable identifiers and routing data, not large blobs:
103
- - session IDs
104
- - link type (`handoff`, `continue`)
105
- - short metadata (goal/title)
54
+ export default function messagesExtension(pi: ExtensionAPI) {
55
+ pi.registerMessageRenderer<BalanceDetails>("balance-result", (message, options, theme) => {
56
+ const balance = message.details?.balance;
57
+ const text =
58
+ typeof balance === "number"
59
+ ? `Account Balance: $${balance.toFixed(2)}`
60
+ : message.content;
106
61
 
107
- Put large human-readable content in `content` (for expansion/LLM visibility), not deep nested `details`.
62
+ return new Text(theme.fg("success", text), 0, 0);
63
+ });
64
+ }
65
+ ```
108
66
 
109
- ### 5) Use visual hierarchy consistently
67
+ Renderer rules:
110
68
 
111
- For message UIs:
112
- - muted label + accent target/value (`Continues in <session-name>`)
113
- - subtle container background for custom message blocks
114
- - avoid decorative noise; optimize for fast scan in session history
69
+ - Collapsed view should be one scannable line.
70
+ - Use `options.expanded` for details.
71
+ - If `details` is missing or malformed, fall back to `message.content`.
72
+ - Do not throw from renderers.
73
+ - Keep `details` small and durable; put large human-readable text in `content`.
115
74
 
116
- ## Pattern: Command with sendMessage Fallback
75
+ ## Command with Persistent Fallback
117
76
 
118
- This combines with the three-tier pattern from `references/modes.md`. Use `sendMessage` as the RPC fallback for commands that use `custom()`:
77
+ Use this when rich TUI output should persist for RPC users or future session readers.
119
78
 
120
79
  ```typescript
121
- // Register the renderer once at load time
122
- pi.registerMessageRenderer("my-results", (message, theme) => {
123
- const { items } = message.details;
124
- return [
125
- theme.bold(`Results (${items.length})`),
126
- ...items.map((item: string) => ` ${theme.fg("accent", item)}`),
127
- ].join("\n");
80
+ pi.registerMessageRenderer<{ items?: string[] }>("my-results", (message, options, theme) => {
81
+ const items = message.details?.items;
82
+ if (!items) return new Text(message.content, 0, 0);
83
+
84
+ const visible = options.expanded ? items : items.slice(0, 5);
85
+ return new Text(
86
+ [
87
+ theme.fg("accent", theme.bold(`Results (${items.length})`)),
88
+ ...visible.map((item) => ` ${theme.fg("muted", item)}`),
89
+ ].join("\n"),
90
+ 0,
91
+ 0,
92
+ );
128
93
  });
129
94
 
130
95
  pi.registerCommand("results", {
@@ -137,11 +102,10 @@ pi.registerCommand("results", {
137
102
  return;
138
103
  }
139
104
 
140
- const result = await ctx.ui.custom<"closed">((tui, theme, _kb, done) => {
105
+ const result = await ctx.ui.custom<"closed">((_tui, theme, _keybindings, done) => {
141
106
  return new ResultsDisplay(theme, items, () => done("closed"));
142
107
  });
143
108
 
144
- // RPC fallback only: custom() returns undefined in RPC/Print.
145
109
  if (result === undefined) {
146
110
  pi.sendMessage({
147
111
  customType: "my-results",
@@ -154,9 +118,9 @@ pi.registerCommand("results", {
154
118
  });
155
119
  ```
156
120
 
157
- ## notify
121
+ ## Notifications
158
122
 
159
- For transient feedback that does not need to persist:
123
+ Use for transient feedback.
160
124
 
161
125
  ```typescript
162
126
  ctx.ui.notify("Operation complete", "info");
@@ -164,13 +128,26 @@ ctx.ui.notify("Something went wrong", "error");
164
128
  ctx.ui.notify("Proceed with caution", "warning");
165
129
  ```
166
130
 
167
- The second argument is the notification type: `"info"`, `"error"`, or `"warning"`. It affects the color/icon.
131
+ `notify` works in interactive and RPC modes and is a no-op in JSON/print.
168
132
 
169
- `notify` is fire-and-forget. It works in Interactive and RPC modes, and is a no-op in Print mode.
133
+ ## Custom Message Design
170
134
 
171
- ## Writing custom entries in a new session
135
+ For session-link or handoff workflows, use paired messages:
172
136
 
173
- When using `ctx.newSession({ setup })`, write custom entries directly through the setup `SessionManager`:
137
+ - Source session marker: short line such as `Continues in <session>`.
138
+ - Destination session source: header plus optional expanded context.
139
+
140
+ Design rules:
141
+
142
+ - Collapsed message: one semantic line, optional expand hint only when details exist.
143
+ - Expanded message: markdown body, file lists, context, or routing details.
144
+ - Visual hierarchy: muted label, accent target/value, minimal decoration.
145
+ - Details: stable identifiers, link type, session IDs, short metadata.
146
+ - Content: user-readable text and anything the LLM may need.
147
+
148
+ ## Writing Custom Entries in New Sessions
149
+
150
+ When using `ctx.newSession({ setup })`, write initial custom entries through the setup `SessionManager`.
174
151
 
175
152
  ```typescript
176
153
  await ctx.newSession({
@@ -180,7 +157,18 @@ await ctx.newSession({
180
157
  linkType: "handoff",
181
158
  });
182
159
  },
160
+ withSession: async (ctx) => {
161
+ await ctx.sendUserMessage("Continue from this handoff.");
162
+ },
183
163
  });
184
164
  ```
185
165
 
186
- Use this pattern for handoff/spawn-like workflows where the new session must start with structured context.
166
+ Use `withSession` for post-switch work.
167
+
168
+ ## Checklist
169
+
170
+ - [ ] Picked the least persistent API that satisfies the UX.
171
+ - [ ] Custom message renderers return components and handle missing `details`.
172
+ - [ ] Collapsed message views are scannable.
173
+ - [ ] Large content is in `content`, not deeply nested `details`.
174
+ - [ ] `sendMessage` delivery mode is explicit when streaming behavior matters.
@@ -1,82 +1,71 @@
1
1
  # Mode Awareness
2
2
 
3
- Pi runs in different modes. Extensions must handle all of them gracefully.
3
+ Pi extensions must behave correctly in Interactive, RPC, JSON, and Print modes.
4
4
 
5
5
  ## Modes
6
6
 
7
- | Mode | `ctx.hasUI` | Description |
8
- |---|---|---|
9
- | **Interactive** | `true` | Full TUI. Normal terminal usage. |
10
- | **RPC** (`--mode rpc`) | `true` | JSON protocol. A host application handles UI. Dialogs work via request/response. |
11
- | **Print** (`-p`, `--mode json`) | `false` | No UI. Extensions run but cannot prompt the user. |
7
+ | Mode | `ctx.hasUI` | Notes |
8
+ |---|---:|---|
9
+ | Interactive | `true` | Full terminal UI. |
10
+ | RPC (`--mode rpc`) | `true` | Host handles dialogs through JSON protocol. TUI-only methods degrade. |
11
+ | JSON (`--mode json`) | `false` | Event stream to stdout; no extension UI. |
12
+ | Print (`-p`) | `false` | One-shot prompt; no extension UI. |
12
13
 
13
- Important nuance: in RPC mode, `ctx.hasUI` is `true`, but TUI-only APIs are degraded.
14
+ Important nuance: RPC has `ctx.hasUI === true` because dialog and fire-and-forget methods work through the extension UI protocol. But `ctx.ui.custom()` and other TUI-only methods do not work in RPC.
14
15
 
15
- ## Method Behavior by Mode
16
+ ## Dialog Methods
16
17
 
17
- ### Dialog Methods (return a value)
18
+ These return values and may need mode-specific behavior.
18
19
 
19
- These methods prompt the user and return a result. Their behavior varies by mode.
20
-
21
- | Method | Interactive | RPC | Print |
22
- |---|---|---|---|
23
- | `ctx.ui.select()` | TUI picker | JSON request to host | Returns `undefined` |
24
- | `ctx.ui.confirm()` | TUI dialog | JSON request to host | Returns `false` |
25
- | `ctx.ui.input()` | TUI text input | JSON request to host | Returns `undefined` |
26
- | `ctx.ui.editor()` | TUI editor | JSON request to host | Returns `undefined` |
27
- | `ctx.ui.custom()` | TUI component | Returns `undefined` | Returns `undefined` |
28
-
29
- Key observation: `custom()` returns `undefined` in both RPC and Print modes. All other dialog methods work in RPC (the host presents them to the user).
30
-
31
- Second key observation: `custom()` can also resolve to `undefined` in Interactive mode if your component calls `done(undefined)`. So `result === undefined` is not a reliable mode detector by itself.
32
-
33
- ### Fire-and-Forget Methods (no return value)
34
-
35
- These methods are safe to call unconditionally in any mode. In modes that do not support them, they are silently ignored.
36
-
37
- | Method | Interactive | RPC | Print |
20
+ | Method | Interactive | RPC | JSON/Print |
38
21
  |---|---|---|---|
39
- | `ctx.ui.notify()` | TUI notification | JSON event to host | No-op |
40
- | `ctx.ui.setStatus()` | Status bar | JSON event to host | No-op |
41
- | `ctx.ui.setWidget()` | Widget area | JSON event to host (string arrays only) | No-op |
42
- | `ctx.ui.setTitle()` | Window title | JSON event to host | No-op |
43
- | `ctx.ui.setEditorText()` | Sets editor content | JSON event to host | No-op |
44
- | `ctx.ui.setFooter()` | Footer area | No-op | No-op |
45
- | `ctx.ui.setHeader()` | Header area | No-op | No-op |
46
- | `ctx.ui.setWorkingMessage()` | Loader text | No-op | No-op |
47
- | `ctx.ui.setEditorComponent()` | Custom editor | No-op | No-op |
48
-
49
- You never need to check `ctx.hasUI` before calling fire-and-forget methods.
22
+ | `ctx.ui.select()` | TUI picker | JSON request to host | `undefined` |
23
+ | `ctx.ui.confirm()` | TUI dialog | JSON request to host | `false` |
24
+ | `ctx.ui.input()` | TUI input | JSON request to host | `undefined` |
25
+ | `ctx.ui.editor()` | TUI editor | JSON request to host | `undefined` |
26
+ | `ctx.ui.custom()` | Custom TUI component | `undefined` | `undefined` |
50
27
 
51
- ## When to Check ctx.hasUI
52
-
53
- Check `ctx.hasUI` when a dialog method gates behavior. If the dialog result determines what happens next (for example, blocking a tool call or cancelling a session switch), you must handle the case where the dialog cannot run.
28
+ Check `ctx.hasUI` when a dialog gates behavior. If there is no UI, choose a safe default.
54
29
 
55
30
  ```typescript
56
- // tool_call handler: must decide to block or allow
57
31
  pi.on("tool_call", async (event, ctx) => {
58
- if (isDangerous(event)) {
59
- if (!ctx.hasUI) {
60
- // Print mode: no way to ask the user, block by default
61
- return { block: true, reason: "Dangerous command blocked (no UI)" };
62
- }
32
+ if (!isDangerous(event)) return;
63
33
 
64
- const choice = await ctx.ui.select("Dangerous command detected", ["Allow", "Block"]);
65
- if (choice !== "Allow") {
66
- return { block: true, reason: "Blocked by user" };
67
- }
34
+ if (!ctx.hasUI) {
35
+ return { block: true, reason: "Dangerous action blocked because no UI is available." };
68
36
  }
69
- return undefined;
37
+
38
+ const ok = await ctx.ui.confirm("Dangerous action", "Allow it?");
39
+ if (!ok) return { block: true, reason: "Blocked by user" };
70
40
  });
71
41
  ```
72
42
 
73
- You do not need to check `ctx.hasUI` for:
74
- - Fire-and-forget calls (`notify`, `setStatus`, `setWidget`, etc.).
75
- - Dialogs where the default return value is acceptable (for example, a non-critical confirm that defaults to `false`).
43
+ ## Fire-and-Forget Methods
76
44
 
77
- ## The Three-Tier Pattern for Custom Components
45
+ These are safe to call without guards. Unsupported modes ignore them or forward them to the RPC host.
78
46
 
79
- When a command uses `ctx.ui.custom()` for a rich TUI display, handle three tiers:
47
+ | Method | Interactive | RPC | JSON/Print |
48
+ |---|---|---|---|
49
+ | `notify()` | TUI notification | JSON event | No-op |
50
+ | `setStatus()` | Footer status | JSON event | No-op |
51
+ | `setWidget()` string arrays | Widget | JSON event | No-op |
52
+ | `setTitle()` | Terminal title | JSON event | No-op |
53
+ | `setEditorText()` | Editor text | JSON event | No-op |
54
+ | `pasteToEditor()` | Paste handling | Set editor text | No-op |
55
+ | `setWorkingMessage()` | Loader text | No-op | No-op |
56
+ | `setWorkingVisible()` | Loader visibility | No-op | No-op |
57
+ | `setWorkingIndicator()` | Loader indicator | No-op | No-op |
58
+ | `setFooter()` | Custom footer | No-op | No-op |
59
+ | `setHeader()` | Custom header | No-op | No-op |
60
+ | `setEditorComponent()` | Custom editor | No-op | No-op |
61
+ | `setToolsExpanded()` | Tool expansion | No-op | No-op |
62
+ | Theme APIs | Full | Mostly unavailable | No-op/unavailable |
63
+
64
+ Component widgets are TUI-only; string-array widgets are portable to RPC.
65
+
66
+ ## Three-Tier Pattern for `ctx.ui.custom()`
67
+
68
+ Use this for commands that display rich TUI components.
80
69
 
81
70
  ```typescript
82
71
  pi.registerCommand("quotas", {
@@ -84,19 +73,18 @@ pi.registerCommand("quotas", {
84
73
  handler: async (_args, ctx) => {
85
74
  const data = await fetchQuotas();
86
75
 
87
- // Tier 1: Print mode -- no UI at all
76
+ // Tier 1: JSON/Print, no UI.
88
77
  if (!ctx.hasUI) {
89
78
  console.log(formatPlain(data));
90
79
  return;
91
80
  }
92
81
 
93
- // Tier 2: Interactive mode -- full TUI component.
94
- // Use an explicit non-undefined sentinel for close/cancel.
95
- const result = await ctx.ui.custom<"closed">((tui, theme, _kb, done) => {
82
+ // Tier 2: Interactive TUI. Use an explicit non-undefined sentinel.
83
+ const result = await ctx.ui.custom<"closed">((_tui, theme, _keybindings, done) => {
96
84
  return new QuotasDisplay(theme, data, () => done("closed"));
97
85
  });
98
86
 
99
- // Tier 3: RPC mode -- custom() returns undefined by design.
87
+ // Tier 3: RPC. custom() returns undefined.
100
88
  if (result === undefined) {
101
89
  ctx.ui.notify(formatPlain(data), "info");
102
90
  }
@@ -104,53 +92,55 @@ pi.registerCommand("quotas", {
104
92
  });
105
93
  ```
106
94
 
107
- Since `select`, `confirm`, `input`, and `notify` all work in RPC mode (forwarded to the host via JSON protocol), use them as the RPC fallback. Choose based on UX:
95
+ Do not use `done(undefined)` for normal interactive close paths when you use `result === undefined` as the RPC fallback detector. Use `null`, `false`, or a string sentinel.
108
96
 
109
- - **`notify`**: Transient feedback or displaying data. Best for most display-only commands.
110
- - **`select`**: When the custom component is a picker/selector. The RPC host presents a list.
111
- - **`confirm`**: When the custom component is a confirmation dialog (for example, permission gate).
112
- - **Notify "requires interactive mode"**: When the custom component is too complex to reduce (for example, settings editor, process manager).
97
+ ## Fallback Choices
113
98
 
114
- Use `sendMessage` + `registerMessageRenderer` only when the result must persist in session history. See `references/messages.md`.
99
+ - Use `notify` for display-only results.
100
+ - Use `select` when the rich component is a picker.
101
+ - Use `confirm` when the rich component is a yes/no gate.
102
+ - Use `input`/`editor` when text entry is enough.
103
+ - Use `sendMessage` + `registerMessageRenderer` when output should persist in session history.
104
+ - Tell the user interactive mode is required when the UI cannot be reduced safely.
115
105
 
116
- ### Example: Selector Fallback
106
+ ## Examples
107
+
108
+ ### Selector fallback
117
109
 
118
110
  ```typescript
119
- const result = await ctx.ui.custom<string | null>((_tui, _theme, _kb, done) => {
111
+ const result = await ctx.ui.custom<string | null>((_tui, _theme, _keybindings, done) => {
120
112
  return new FancyPicker(items, done); // done(value) or done(null)
121
113
  });
122
114
 
123
- // RPC fallback: use select dialog
124
115
  if (result === undefined) {
125
- const selected = await ctx.ui.select("Pick an item", items.map((i) => i.label));
126
- // ... handle selected
116
+ const selected = await ctx.ui.select("Pick an item", items.map((item) => item.label));
117
+ // Handle selected.
127
118
  }
128
119
  ```
129
120
 
130
- ### Example: Confirmation Fallback
121
+ ### Confirmation fallback
131
122
 
132
123
  ```typescript
133
- // In a tool_call handler:
134
- if (!ctx.hasUI) {
135
- return { block: true, reason: "No UI to confirm" };
136
- }
124
+ if (!ctx.hasUI) return { block: true, reason: "No UI to confirm" };
137
125
 
138
- const proceed = await ctx.ui.custom<boolean>((_tui, theme, _kb, done) => {
139
- return new ConfirmDialog(theme, message, done); // done(true|false)
126
+ const proceed = await ctx.ui.custom<boolean | null>((_tui, theme, _keybindings, done) => {
127
+ return new ConfirmDialog(theme, message, done); // done(true), done(false), or done(null)
140
128
  });
141
129
 
142
- // RPC fallback: custom() returns undefined, so treat as "not approved".
143
- if (proceed !== true) {
130
+ if (proceed === undefined) {
131
+ const confirmed = await ctx.ui.confirm("Allow action?", message);
132
+ if (!confirmed) return { block: true, reason: "Blocked" };
133
+ } else if (proceed !== true) {
144
134
  return { block: true, reason: "Blocked" };
145
135
  }
146
136
  ```
147
137
 
148
138
  ## Guidelines
149
139
 
150
- 1. Never assume Interactive mode. Always consider what happens in RPC and Print.
151
- 2. Fire-and-forget methods are always safe. Use them freely.
152
- 3. Guard dialog methods that gate behavior with `ctx.hasUI` checks.
153
- 4. Always provide a fallback for `ctx.ui.custom()` because it returns `undefined` in RPC and Print.
154
- 5. Do not use `done(undefined)` for normal interactive close paths if you need to detect RPC fallback.
155
- 6. For `tool_call` handlers, decide a safe default when there is no UI (usually block).
156
- 7. Test your extension in at least Interactive and Print modes. If you use `custom()`, test RPC fallback explicitly.
140
+ 1. Never assume interactive mode.
141
+ 2. Fire-and-forget methods are safe without `ctx.hasUI` guards.
142
+ 3. Guard dialogs that decide whether to proceed.
143
+ 4. `ctx.ui.custom()` always needs fallback.
144
+ 5. Use explicit sentinels instead of `done(undefined)`.
145
+ 6. For security/safety gates, default to blocking when there is no UI.
146
+ 7. Test interactive and print modes. Test RPC fallback for `custom()`.