pi-code 1.0.7 → 1.0.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.
@@ -46,6 +46,7 @@ import { Type } from 'typebox'
46
46
  import { matchesBashRules } from './internal/bash-rules.js'
47
47
  import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
48
48
  import { readManagedSettings } from './internal/managed-settings.js'
49
+ import { capForContext } from './internal/output-guard.js'
49
50
  import { matchesPathRules } from './internal/path-rules.js'
50
51
  import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
51
52
  import { isProjectApproved } from './internal/project-approval.js'
@@ -315,12 +316,17 @@ export default function commandsExtension(pi: ExtensionAPI) {
315
316
  pendingBashRules = undefined
316
317
  pendingPathRules = undefined
317
318
  if (pendingModelRestore) {
318
- void pi.setModel(pendingModelRestore as Parameters<typeof pi.setModel>[0])
319
+ const restore = pendingModelRestore as Parameters<typeof pi.setModel>[0]
319
320
  pendingModelRestore = undefined
321
+ // setModel can reject (e.g. auth resolution fails), and a floated rejection would
322
+ // escape as unhandled; surface it instead of leaving the session silently on the
323
+ // command's override model.
324
+ void pi.setModel(restore).catch(() => {})
325
+ }
326
+ if (pendingRestore) {
327
+ pi.setActiveTools(pendingRestore)
328
+ pendingRestore = undefined
320
329
  }
321
- if (!pendingRestore) return
322
- pi.setActiveTools(pendingRestore)
323
- pendingRestore = undefined
324
330
  })
325
331
 
326
332
  // The active-tool set has no argument dimension, so a scoped grant hands the turn
@@ -416,6 +422,16 @@ export default function commandsExtension(pi: ExtensionAPI) {
416
422
  // restored when that run ends. Restoring inline does not work: sendUserMessage is
417
423
  // fire-and-forget, so the restore would land before the agent ever read the tool
418
424
  // list, leaving the command running with everything enabled.
425
+ // A command invoked while the agent is streaming must not narrow the in-flight run's
426
+ // tools or switch its model (that would corrupt a run it does not own), and a bare
427
+ // sendUserMessage throws mid-stream and would be silently dropped. Queue it as a
428
+ // follow-up through pi's own queue, which is abort-aware and shown to the user; its
429
+ // frontmatter scoping is not applied in that case, since it cannot land on a run that
430
+ // has not started yet.
431
+ if (!ctx.isIdle()) {
432
+ pi.sendUserMessage(expanded, { deliverAs: 'followUp' })
433
+ return
434
+ }
419
435
  applyAllowedTools(parsed, vars)
420
436
  applyDisallowedTools(parsed)
421
437
  await applyModelOverride(parsed, varCtx)
@@ -500,7 +516,10 @@ export default function commandsExtension(pi: ExtensionAPI) {
500
516
  // the tool result is the channel, and frontmatter scoping stays user-path
501
517
  // territory (see the header).
502
518
  const expanded = await expandCommand(pi, current, args, execCtx, command.filePath, command.plugin, { allowShell: false })
503
- return { content: [{ type: 'text' as const, text: `Contents of /${name} (expanded):\n\n${expanded}` }], details: {} }
519
+ // Cap the tool result: a command body can inline an arbitrarily large @file, and
520
+ // an uncapped tool result overflows the model's context (every other pi-code tool
521
+ // routes its output through capForContext). The user-invoked path stays uncapped.
522
+ return { content: [{ type: 'text' as const, text: capForContext(`Contents of /${name} (expanded):\n\n${expanded}`) }], details: {} }
504
523
  },
505
524
  })
506
525
  })
@@ -565,7 +565,7 @@ export async function runPromptHook(hook: HookCommand, payload: unknown, model:
565
565
  const prompt = substituteArguments(hook.prompt, payload)
566
566
  const signal = AbortSignal.timeout(timeoutMs)
567
567
  try {
568
- const answer = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
568
+ const { text: answer } = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
569
569
  return { code: 0, stdout: answer, stderr: '', timedOut: false }
570
570
  } catch (error) {
571
571
  return abortAwareFailure(signal, error)
@@ -971,6 +971,12 @@ export default function hooksExtension(pi: ExtensionAPI) {
971
971
  // turn, and stop_hook_active in the payload tells the next firing it is already
972
972
  // continuing from a stop hook, which is the hook script's documented loop guard.
973
973
  // Only exit 2 and decision:"block" continue; continue:false means "stay stopped".
974
+ //
975
+ // On agent_end rather than agent_settled: agent_settled is only emitted after every
976
+ // agent_end handler returns, and a peer extension (plan mode) blocks its agent_end
977
+ // handler on a UI dialog, which would starve the Stop hook and idle notification
978
+ // until the user answers it. agent_end can fire slightly early before a rare
979
+ // automatic retry or compaction; that is the better tradeoff.
974
980
  pi.on('agent_end', async (event, ctx) => {
975
981
  // Claude's Notification event, for the one type pi can honestly source: the
976
982
  // agent finished and is waiting for input (idle_prompt). Observational only;
@@ -75,7 +75,9 @@ export default function initExtension(pi: ExtensionAPI) {
75
75
  const existing = findExistingContextFile(root)
76
76
  const cursorRules = statOf(path.join(root, '.cursor', 'rules'))?.isDirectory() === true || statOf(path.join(root, '.cursorrules'))?.isFile() === true
77
77
  const copilotRules = statOf(path.join(root, '.github', 'copilot-instructions.md'))?.isFile() === true
78
- pi.sendUserMessage(buildInitPrompt({ ...(existing !== undefined ? { existingContextFile: existing } : {}), cursorRules, copilotRules }))
78
+ // A bare send throws (and is silently swallowed) while the agent is
79
+ // streaming, so mid-stream invocations queue as a follow-up turn.
80
+ pi.sendUserMessage(buildInitPrompt({ ...(existing !== undefined ? { existingContextFile: existing } : {}), cursorRules, copilotRules }), ctx.isIdle() ? {} : { deliverAs: 'followUp' })
79
81
  },
80
82
  })
81
83
  }
@@ -14,7 +14,7 @@
14
14
  * throw and the caller falls back to its non-model behavior.
15
15
  */
16
16
 
17
- import type { Api, AssistantMessage, Context, Model, ModelsSimpleStreamOptions } from '@earendil-works/pi-ai'
17
+ import type { Api, AssistantMessage, Context, Model, ModelsSimpleStreamOptions, Usage } from '@earendil-works/pi-ai'
18
18
  import { ModelRuntime } from '@earendil-works/pi-coding-agent'
19
19
 
20
20
  /** The completion backend: model + context -> assistant message. Overridable for tests. */
@@ -52,11 +52,13 @@ export interface CompleteOptions {
52
52
  }
53
53
 
54
54
  /**
55
- * Run `prompt` through `model` as a single user turn and return the reply text.
55
+ * Run `prompt` through `model` as a single user turn and return the reply text plus
56
+ * the call's usage. A tool that makes a nested LLM call must return that usage on
57
+ * its tool result, or the call's tokens and cost vanish from pi's session totals.
56
58
  * Throws on any failure so the caller can fall back; never returns a partial or a
57
59
  * tool call, only assistant text.
58
60
  */
59
- export async function completeText(model: Model<Api>, prompt: string, options: CompleteOptions = {}): Promise<string> {
61
+ export async function completeText(model: Model<Api>, prompt: string, options: CompleteOptions = {}): Promise<{ text: string; usage: Usage }> {
60
62
  backend ??= realBackend()
61
63
  const complete = await backend
62
64
  const context: Context = {
@@ -64,5 +66,5 @@ export async function completeText(model: Model<Api>, prompt: string, options: C
64
66
  messages: [{ role: 'user', content: prompt, timestamp: Date.now() }],
65
67
  }
66
68
  const message = await complete(model, context, { maxTokens: options.maxTokens ?? 1024, signal: options.signal })
67
- return assistantText(message)
69
+ return { text: assistantText(message), usage: message.usage }
68
70
  }
package/extensions/mcp.ts CHANGED
@@ -933,7 +933,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
933
933
  ctx.ui.notify(`${commandName}: prompt returned no content`, 'info')
934
934
  return
935
935
  }
936
- pi.sendUserMessage(content)
936
+ // A bare send throws (and is silently swallowed) while the agent is
937
+ // streaming, so mid-stream invocations queue as a follow-up turn.
938
+ pi.sendUserMessage(content, ctx.isIdle() ? {} : { deliverAs: 'followUp' })
937
939
  } catch (error) {
938
940
  ctx.ui.notify(`${commandName}: ${error instanceof Error ? error.message : String(error)}`, 'error')
939
941
  }
@@ -12,7 +12,7 @@ import * as fs from 'node:fs'
12
12
  import * as os from 'node:os'
13
13
  import * as path from 'node:path'
14
14
  import { StringEnum } from '@earendil-works/pi-ai'
15
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
15
+ import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
16
16
  import { Type } from 'typebox'
17
17
  import { capForContext } from './internal/output-guard.js'
18
18
  import { isProjectApprovedSilently } from './internal/project-approval.js'
@@ -138,29 +138,39 @@ export function indexWouldOverflow(index: string, name: string, description: str
138
138
  return next.split('\n').length > INDEX_MAX_LINES || Buffer.byteLength(next, 'utf-8') > INDEX_MAX_BYTES
139
139
  }
140
140
 
141
- /** Write a memory and its index line, or say why it cannot be written. */
142
- export function saveMemory(dir: string, indexPath: string, name: string | undefined, description: string | undefined, content: string | undefined, now: string = new Date().toISOString()): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
141
+ type MemoryToolResult = { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> }
142
+
143
+ /** Write a memory and its index line, or say why it cannot be written. The whole
144
+ * read-modify-write holds the index's mutation queue: tool calls run in parallel, so
145
+ * two unqueued saves both read the same index and the second silently drops the first's
146
+ * line. The queue keys ONLY on the index, the shared file every save touches, and never
147
+ * also on the memory file: a second nested queue self-deadlocks when a memory name
148
+ * canonicalizes to the same key as the index (e.g. `memory.md` and `MEMORY.md` under a
149
+ * case-insensitive filesystem, since the queue keys on realpath). */
150
+ export async function saveMemory(dir: string, indexPath: string, name: string | undefined, description: string | undefined, content: string | undefined, now: string = new Date().toISOString()): Promise<MemoryToolResult> {
143
151
  if (!name || !description || !content) {
144
152
  return { content: [{ type: 'text', text: 'save requires name, description, and content.' }], details: {} }
145
153
  }
146
- const index = readIndex(dir)
147
- // Claude reports an explicit error rather than writing a memory the next session
148
- // would never load, and says what to do about it.
149
- if (indexWouldOverflow(index, name, description)) {
150
- return {
151
- content: [{ type: 'text', text: `Memory index is full (${INDEX_MAX_LINES} entries or ${INDEX_MAX_BYTES} bytes). Delete or consolidate memories before saving ${name}.` }],
152
- details: {},
154
+ return withFileMutationQueue(indexPath, async (): Promise<MemoryToolResult> => {
155
+ const index = readIndex(dir)
156
+ // Claude reports an explicit error rather than writing a memory the next session
157
+ // would never load, and says what to do about it.
158
+ if (indexWouldOverflow(index, name, description)) {
159
+ return {
160
+ content: [{ type: 'text', text: `Memory index is full (${INDEX_MAX_LINES} entries or ${INDEX_MAX_BYTES} bytes). Delete or consolidate memories before saving ${name}.` }],
161
+ details: {},
162
+ }
153
163
  }
154
- }
155
- fs.mkdirSync(dir, { recursive: true })
156
- // A memory with frontmatter records its write time; one without is left as-is.
157
- fs.writeFileSync(path.join(dir, `${name}.md`), stampModified(content, now))
158
- writeIndex(indexPath, upsertIndexLine(index, name, description))
159
- return { content: [{ type: 'text', text: `Saved memory ${name}.` }], details: {} }
164
+ fs.mkdirSync(dir, { recursive: true })
165
+ // A memory with frontmatter records its write time; one without is left as-is.
166
+ fs.writeFileSync(path.join(dir, `${name}.md`), stampModified(content, now))
167
+ writeIndex(indexPath, upsertIndexLine(index, name, description))
168
+ return { content: [{ type: 'text', text: `Saved memory ${name}.` }], details: {} }
169
+ })
160
170
  }
161
171
 
162
172
  /** The read action: a memory's body, capped for context, or a not-found message. */
163
- function readMemory(dir: string, name: string): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
173
+ function readMemory(dir: string, name: string): MemoryToolResult {
164
174
  try {
165
175
  const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
166
176
  return { content: [{ type: 'text', text: capForContext(body) }], details: {} }
@@ -169,21 +179,23 @@ function readMemory(dir: string, name: string): { content: Array<{ type: 'text';
169
179
  }
170
180
  }
171
181
 
172
- /** The delete action: remove a memory file and its index line. The index is read
173
- * before anything is removed: refusing on a failed read must leave both the memory
174
- * file and the index as they were. */
175
- function deleteMemory(dir: string, indexPath: string, name: string): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
176
- let index: string
182
+ /** The delete action: remove a memory file and its index line, queued on the index
183
+ * like save (single key, no deadlock). The index is read before anything is removed,
184
+ * and any failure (a bad index read, or an unreadable store the queue key cannot
185
+ * realpath) leaves both the memory file and the index as they were. */
186
+ async function deleteMemory(dir: string, indexPath: string, name: string): Promise<MemoryToolResult> {
177
187
  try {
178
- index = readIndex(dir)
188
+ return await withFileMutationQueue(indexPath, async (): Promise<MemoryToolResult> => {
189
+ const index = readIndex(dir)
190
+ fs.rmSync(path.join(dir, `${name}.md`), { force: true })
191
+ const remaining = removeIndexLine(index, name)
192
+ if (remaining) writeIndex(indexPath, remaining)
193
+ else fs.rmSync(indexPath, { force: true })
194
+ return { content: [{ type: 'text', text: `Deleted memory ${name}.` }], details: {} }
195
+ })
179
196
  } catch (error) {
180
197
  return { content: [{ type: 'text', text: `Memory delete failed: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.` }], details: {} }
181
198
  }
182
- fs.rmSync(path.join(dir, `${name}.md`), { force: true })
183
- const remaining = removeIndexLine(index, name)
184
- if (remaining) writeIndex(indexPath, remaining)
185
- else fs.rmSync(indexPath, { force: true })
186
- return { content: [{ type: 'text', text: `Deleted memory ${name}.` }], details: {} }
187
199
  }
188
200
 
189
201
  /** The index as injected into the prompt, bounded like Claude's startup load. */
@@ -346,7 +358,8 @@ export default function memoryExtension(pi: ExtensionAPI) {
346
358
 
347
359
  if (params.action === 'save') {
348
360
  try {
349
- return saveMemory(dir, indexPath, name, params.description, params.content)
361
+ // Awaited here, not returned: the catch must see a queued write's rejection.
362
+ return await saveMemory(dir, indexPath, name, params.description, params.content)
350
363
  } catch (error) {
351
364
  return { content: [{ type: 'text' as const, text: `Memory save failed: ${error instanceof Error ? error.message : String(error)}. The index was left untouched.` }], details: {} }
352
365
  }
@@ -113,6 +113,11 @@ export default function notifyExtension(pi: ExtensionAPI) {
113
113
  lastInputAt = Date.now()
114
114
  })
115
115
 
116
+ // Fires on agent_end rather than agent_settled deliberately: agent_settled is only
117
+ // emitted after every agent_end handler returns, and a peer extension (plan mode)
118
+ // blocks its agent_end handler on a UI dialog, which would starve this notification
119
+ // exactly when the user has stepped away. agent_end can fire slightly early before a
120
+ // rare automatic retry or compaction, which is a better failure than never notifying.
116
121
  pi.on('agent_end', async () => {
117
122
  if (channel === 'off') return
118
123
  // Piped or headless stdout (pi -p, CI) must not receive raw escape bytes.
@@ -209,7 +209,9 @@ export default function planModeExtension(pi: ExtensionAPI): void {
209
209
  planFromTool = false
210
210
  const refinement = await ctx.ui.editor('Refine the plan:', '')
211
211
  if (refinement?.trim()) {
212
- pi.sendUserMessage(refinement.trim())
212
+ // A bare send throws (and is silently swallowed) while the agent is
213
+ // streaming, so mid-stream invocations queue as a follow-up turn.
214
+ pi.sendUserMessage(refinement.trim(), ctx.isIdle() ? {} : { deliverAs: 'followUp' })
213
215
  }
214
216
  }
215
217
  }
@@ -238,7 +238,39 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
238
238
  // The free-text option does not compose with checkbox selection, so it is single-select only.
239
239
  const allOptions: DisplayOption[] = multiSelect ? [...params.options] : [...params.options, { label: 'Type something.', isOther: true }]
240
240
 
241
- const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>((tui: Parameters<Parameters<ExtensionContext['ui']['custom']>[0]>[0], theme: Theme, _kb: unknown, done: (value: { answer: string; wasCustom: boolean; index?: number } | null) => void) => {
241
+ // ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it resolves
242
+ // undefined immediately, which would read as a cancel without ever asking. Ask
243
+ // through the dialog primitives there instead.
244
+ const result = ctx.mode === 'tui' ? await askViaOverlay(params, ctx, allOptions, multiSelect) : await askViaDialogs(params, ctx, allOptions, multiSelect)
245
+
246
+ // Build simple options list for details; header/multiSelect appear only when set,
247
+ // so single-select details are unchanged.
248
+ const simpleOptions = params.options.map((o) => o.label)
249
+ const base = { question: params.question, options: simpleOptions, ...(params.header ? { header: shortHeader(params.header) } : {}), ...(multiSelect ? { multiSelect: true } : {}) }
250
+
251
+ if (!result) {
252
+ return {
253
+ content: [{ type: 'text', text: 'User cancelled the selection' }],
254
+ details: { ...base, answer: null } as QuestionDetails,
255
+ }
256
+ }
257
+
258
+ if (result.wasCustom) {
259
+ return {
260
+ content: [{ type: 'text', text: `User wrote: ${result.answer}` }],
261
+ details: { ...base, answer: result.answer, wasCustom: true } as QuestionDetails,
262
+ }
263
+ }
264
+ const selectionText = multiSelect ? `User selected: ${result.answer || '(none)'}` : `User selected: ${result.index}. ${result.answer}`
265
+ return {
266
+ content: [{ type: 'text', text: selectionText }],
267
+ details: { ...base, answer: result.answer, wasCustom: false } as QuestionDetails,
268
+ }
269
+ }
270
+
271
+ /** Terminal path: the full custom overlay (options list, checkboxes, inline editor). */
272
+ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean): Promise<{ answer: string; wasCustom: boolean; index?: number } | null> {
273
+ return ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>((tui: Parameters<Parameters<ExtensionContext['ui']['custom']>[0]>[0], theme: Theme, _kb: unknown, done: (value: { answer: string; wasCustom: boolean; index?: number } | null) => void) => {
242
274
  let optionIndex = 0
243
275
  let editMode = false
244
276
  const checked: boolean[] = allOptions.map(() => false)
@@ -339,28 +371,29 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
339
371
  handleInput,
340
372
  }
341
373
  })
374
+ }
342
375
 
343
- // Build simple options list for details; header/multiSelect appear only when set,
344
- // so single-select details are unchanged.
345
- const simpleOptions = params.options.map((o) => o.label)
346
- const base = { question: params.question, options: simpleOptions, ...(params.header ? { header: shortHeader(params.header) } : {}), ...(multiSelect ? { multiSelect: true } : {}) }
347
-
348
- if (!result) {
349
- return {
350
- content: [{ type: 'text', text: 'User cancelled the selection' }],
351
- details: { ...base, answer: null } as QuestionDetails,
352
- }
353
- }
354
-
355
- if (result.wasCustom) {
356
- return {
357
- content: [{ type: 'text', text: `User wrote: ${result.answer}` }],
358
- details: { ...base, answer: result.answer, wasCustom: true } as QuestionDetails,
359
- }
360
- }
361
- const selectionText = multiSelect ? `User selected: ${result.answer || '(none)'}` : `User selected: ${result.index}. ${result.answer}`
362
- return {
363
- content: [{ type: 'text', text: selectionText }],
364
- details: { ...base, answer: result.answer, wasCustom: false } as QuestionDetails,
376
+ /** Dialog-primitive fallback for UI without a terminal (RPC mode supports
377
+ * select/input/notify but not custom components). Mirrors the overlay's result
378
+ * shape; a dismissed dialog reads as a cancel, same as Escape in the overlay. */
379
+ async function askViaDialogs(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean): Promise<{ answer: string; wasCustom: boolean; index?: number } | null> {
380
+ const header = shortHeader(params.header)
381
+ const title = header ? `[${header}] ${params.question}` : params.question
382
+ // Number the labels: ctx.ui.select returns the chosen label string, so duplicate
383
+ // labels (or a model-supplied option named like the free-text entry) would be
384
+ // ambiguous by text alone; the number is the unambiguous way back to the option.
385
+ const labels = allOptions.map((option, i) => `${i + 1}. ${option.label}`)
386
+ const choice = await ctx.ui.select(title, labels)
387
+ if (choice === undefined) return null
388
+ const index = labels.indexOf(choice)
389
+ const chosen = allOptions[index]
390
+ if (!multiSelect && chosen?.isOther === true) {
391
+ const typed = await ctx.ui.input(params.question, 'Your answer')
392
+ // A dismissed dialog cancels; a submitted empty answer is an (empty) answer, not a
393
+ // cancel, so one accidental blank Enter does not abort the rest of a question batch.
394
+ if (typed === undefined) return null
395
+ return { answer: typed.trim(), wasCustom: true }
365
396
  }
397
+ const answer = chosen?.label ?? choice
398
+ return multiSelect ? { answer, wasCustom: false } : { answer, wasCustom: false, index: index + 1 }
366
399
  }
@@ -84,6 +84,9 @@ const listMark = (status: TodoStatus): string => {
84
84
  return '[ ]'
85
85
  }
86
86
 
87
+ /** Plain-text list, shared by the list action and the non-terminal /todos path. */
88
+ const plainTodoList = (todos: Todo[]): string => (todos.length ? todos.map((t) => `${listMark(t.status)} #${t.id}: ${t.text}`).join('\n') : 'No todos')
89
+
87
90
  const overlayLabel = (todo: Todo, theme: Theme): string => {
88
91
  if (todo.status === 'completed') return theme.fg('dim', todo.text)
89
92
  if (todo.status === 'in_progress') return theme.fg('text', todo.activeForm ?? todo.text)
@@ -433,7 +436,7 @@ export default function todoExtension(pi: ExtensionAPI) {
433
436
  return ok('clear', `Cleared ${count} todos`)
434
437
  }
435
438
 
436
- const handleList = () => ok('list', todos.length ? todos.map((t) => `${listMark(t.status)} #${t.id}: ${t.text}`).join('\n') : 'No todos')
439
+ const handleList = () => ok('list', plainTodoList(todos))
437
440
 
438
441
  // Register the todo tool for the LLM
439
442
  pi.registerTool({
@@ -516,6 +519,13 @@ export default function todoExtension(pi: ExtensionAPI) {
516
519
  return
517
520
  }
518
521
 
522
+ // ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it
523
+ // resolves undefined without showing anything, so notify the plain list.
524
+ if (ctx.mode !== 'tui') {
525
+ ctx.ui.notify(plainTodoList(todos), 'info')
526
+ return
527
+ }
528
+
519
529
  await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
520
530
  return new TodoListComponent(todos, theme, () => done())
521
531
  })
package/extensions/web.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import type { LookupAddress } from 'node:dns'
10
10
  import { lookup } from 'node:dns/promises'
11
11
  import type { LookupFunction } from 'node:net'
12
+ import type { Usage } from '@earendil-works/pi-ai'
12
13
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
13
14
  import { Type } from 'typebox'
14
15
 
@@ -252,16 +253,16 @@ function rememberFetch(cache: FetchCache, url: string, body: string, now: number
252
253
  }
253
254
 
254
255
  /** Claude's WebFetch runs the prompt over the page with a fast model and returns
255
- * that answer, not the raw page. Best-effort: any failure (no model, provider error)
256
+ * that answer, not the raw page, along with the nested call's usage so the tool
257
+ * result can account for it. Best-effort: any failure (no model, provider error)
256
258
  * yields null so the caller falls back to the markdown. */
257
- async function answerFromPage(model: Parameters<typeof completeText>[0], prompt: string, url: string, body: string, signal?: AbortSignal): Promise<string | null> {
259
+ async function answerFromPage(model: Parameters<typeof completeText>[0], prompt: string, url: string, body: string, signal?: AbortSignal): Promise<{ text: string; usage: Usage } | null> {
258
260
  try {
259
- const answer = await completeText(model, `${prompt}\n\nAnswer using only the page content below, fetched from ${url}:\n\n${body}`, {
261
+ return await completeText(model, `${prompt}\n\nAnswer using only the page content below, fetched from ${url}:\n\n${body}`, {
260
262
  system: 'You extract and answer questions from a web page. Answer only from the provided content, concisely. If the content does not contain the answer, say so.',
261
263
  maxTokens: 1024,
262
264
  signal,
263
265
  })
264
- return answer || null
265
266
  } catch {
266
267
  return null
267
268
  }
@@ -318,14 +319,18 @@ export default function webExtension(pi: ExtensionAPI) {
318
319
  }
319
320
 
320
321
  // Best-effort prompt-over-page: a failure returns null, so web_fetch always
321
- // falls back to the raw markdown and returns something.
322
+ // falls back to the raw markdown and returns something. The nested call's
323
+ // usage rides on the result either way, so pi counts it in session totals.
324
+ let usage: Usage | undefined
322
325
  if (params.prompt && ctx?.model) {
323
326
  const answer = await answerFromPage(ctx.model, params.prompt, params.url, body, signal)
324
- if (answer) return { content: [{ type: 'text' as const, text: answer }], details: {} }
327
+ if (answer?.text) return { content: [{ type: 'text' as const, text: answer.text }], details: {}, usage: answer.usage }
328
+ // An empty answer still cost the completion; the fallback carries its usage.
329
+ usage = answer?.usage
325
330
  }
326
331
  // The char cap alone admits thousands of short lines; pi's tool-output budget
327
332
  // bounds lines too, which the shared guard enforces.
328
- return { content: [{ type: 'text' as const, text: capForContext(body) || '(empty response)' }], details: {} }
333
+ return { content: [{ type: 'text' as const, text: capForContext(body) || '(empty response)' }], details: {}, usage }
329
334
  },
330
335
  })
331
336
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi",