pi-code 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/extensions/mcp.ts CHANGED
@@ -649,6 +649,23 @@ type HttpFamilyTransport = SSEClientTransport | StreamableHTTPClientTransport //
649
649
 
650
650
  type MakeTransport = (authProvider?: OAuthClientProvider) => HttpFamilyTransport
651
651
 
652
+ /** Interactive OAuth logins block on a confirm dialog and open a browser tab, so two
653
+ * at once (a user-scope and a consented project-scope server both 401ing, connecting in
654
+ * parallel) would stack dialogs and browser tabs. This chains them so a second
655
+ * interactive login waits for the first to settle; the tail is reset to a resolved
656
+ * promise regardless of outcome, so a failed login never poisons the queue. Silent
657
+ * (stored-token) connects do not pass through here and stay fully parallel. */
658
+ let oauthQueue: Promise<unknown> = Promise.resolve()
659
+
660
+ function serializeInteractiveOAuth<T>(run: () => Promise<T>): Promise<T> {
661
+ const result = oauthQueue.then(run, run)
662
+ oauthQueue = result.then(
663
+ () => {},
664
+ () => {},
665
+ )
666
+ return result
667
+ }
668
+
652
669
  /**
653
670
  * Connect an http-family server, running Claude's OAuth login when the server
654
671
  * demands one. Stored tokens ride the first attempt so the SDK refreshes
@@ -671,7 +688,7 @@ async function connectHttpFamily(name: string, config: { url: string }, makeTran
671
688
  } catch (error) {
672
689
  if (bearerToken || !isUnauthorized(error)) throw error
673
690
  if (!authUi) throw new OAuthRequiredError(`${name} requires a login; run pi interactively to authenticate`)
674
- return await runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient)
691
+ return await serializeInteractiveOAuth(() => runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient))
675
692
  }
676
693
  }
677
694
 
@@ -933,7 +950,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
933
950
  ctx.ui.notify(`${commandName}: prompt returned no content`, 'info')
934
951
  return
935
952
  }
936
- pi.sendUserMessage(content)
953
+ // A bare send throws (and is silently swallowed) while the agent is
954
+ // streaming, so mid-stream invocations queue as a follow-up turn.
955
+ pi.sendUserMessage(content, ctx.isIdle() ? {} : { deliverAs: 'followUp' })
937
956
  } catch (error) {
938
957
  ctx.ui.notify(`${commandName}: ${error instanceof Error ? error.message : String(error)}`, 'error')
939
958
  }
@@ -1111,17 +1130,11 @@ export default async function mcpExtension(pi: ExtensionAPI) {
1111
1130
  )
1112
1131
  }
1113
1132
 
1114
- /** Connect the project scope under the per-server policy. Returns whether the scope
1115
- * is settled, so a refused confirm can be retried on a later session start. */
1116
- async function connectProjectScope(ctx: ExtensionContext): Promise<boolean> {
1117
- // The stored decision, read without prompting: consent recorded inside the
1118
- // project only counts once the project itself has been approved.
1119
- const approved = isProjectApprovedSilently(ctx)
1120
- const policy = projectServerPolicy(ctx.cwd, os.homedir(), approved)
1121
- const { allowed, denied } = mcpAllowDeny()
1122
- const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), policy)
1123
- const authUi = authUiFor(ctx)
1124
- if (Object.keys(consented).length > 0) await connectServers(consented, authUi)
1133
+ /** Connect the approval-gated project servers, behind the whole-project confirm.
1134
+ * Returns whether the scope is settled, so a refused confirm can be retried on a
1135
+ * later session start. The consented half of the project scope connects earlier,
1136
+ * concurrently with the user scope, from session_start itself. */
1137
+ async function connectGatedProjectServers(ctx: ExtensionContext, gated: Record<string, ServerConfig>, authUi?: AuthUi): Promise<boolean> {
1125
1138
  if (Object.keys(gated).length === 0) return true
1126
1139
  if (!(await isProjectApproved(ctx))) return false
1127
1140
  await connectServers(gated, authUi)
@@ -1146,16 +1159,28 @@ export default async function mcpExtension(pi: ExtensionAPI) {
1146
1159
  // entry cannot shadow a trusted user server by reusing its name. A gated project
1147
1160
  // server still awaiting the approval prompt does not preempt the user server: that is
1148
1161
  // a deliberate narrowing of Claude's rule to keep the safe default.
1162
+ // The stored project decision, read without prompting: consent recorded inside
1163
+ // the project only counts once the project itself has been approved.
1149
1164
  const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
1150
- const projectWinners = new Set(Object.keys(splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), projectPolicy).consented))
1165
+ const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), projectPolicy)
1166
+ const projectWinners = new Set(Object.keys(consented))
1151
1167
  const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
1152
- if (Object.keys(userServers).length > 0) await connectServers(userServers, authUiFor(ctx))
1168
+ const authUi = authUiFor(ctx)
1169
+ // The consented project servers carry no ordering dependency on the user scope:
1170
+ // projectWinners already excludes their names from userServers, so the two batches
1171
+ // are disjoint and connect concurrently, and startup pays the slower scope rather
1172
+ // than the sum of both. Reconnect attempts after a refused confirm are safe:
1173
+ // connectServers skips names that already connected.
1174
+ const connects: Promise<void>[] = []
1175
+ if (Object.keys(userServers).length > 0) connects.push(connectServers(userServers, authUi))
1176
+ if (!projectConnected && Object.keys(consented).length > 0) connects.push(connectServers(consented, authUi))
1177
+ await Promise.all(connects)
1153
1178
  // A project .mcp.json can run arbitrary commands on connect, so only honor it once
1154
1179
  // the project is trusted. Per-server settings refine that: disabled servers never
1155
- // connect, servers the user consented to individually connect without the
1156
- // whole-project confirm, and the rest stay behind it. Reconnect attempts after a
1157
- // refusal are safe: connectServers skips names that already connected.
1158
- if (!projectConnected) projectConnected = await connectProjectScope(ctx)
1180
+ // connect, servers the user consented to individually connected above without the
1181
+ // whole-project confirm, and the rest stay behind it, sequentially after both
1182
+ // scopes so the confirm dialog never races a connect.
1183
+ if (!projectConnected) projectConnected = await connectGatedProjectServers(ctx, gated, authUi)
1159
1184
 
1160
1185
  pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
1161
1186
 
@@ -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. */
@@ -298,6 +310,27 @@ export default function memoryExtension(pi: ExtensionAPI) {
298
310
  let dir = memoryDir(process.cwd())
299
311
  let enabled = true
300
312
 
313
+ // The index is injected every turn but changes only through the tool or an external
314
+ // edit, so a turn costs one stat instead of a full read. The stat token (mtime plus
315
+ // size) catches external edits; save and delete drop the cache outright, since a
316
+ // rename landing within one mtime tick at the same size would slip past the token.
317
+ let indexCache: { token: string; index: string } | null = null
318
+
319
+ const indexStatToken = (): string => {
320
+ try {
321
+ const stat = fs.statSync(path.join(dir, INDEX_FILE))
322
+ return `${stat.mtimeMs}:${stat.size}`
323
+ } catch {
324
+ return 'missing'
325
+ }
326
+ }
327
+
328
+ const readIndexCached = (): string => {
329
+ const token = indexStatToken()
330
+ if (indexCache?.token !== token) indexCache = { token, index: readIndexQuietly(dir) }
331
+ return indexCache.index
332
+ }
333
+
301
334
  // These extensions also load inside spawned subagent processes, which carry the
302
335
  // PI_CODE_SUBAGENT marker. Claude does not load the main conversation's auto memory
303
336
  // into subagents (they get their own store through the agent `memory:` field), so
@@ -313,6 +346,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
313
346
  enabled = autoMemoryEnabled(settings.autoMemoryEnabled, process.env)
314
347
  const override = typeof settings.autoMemoryDirectory === 'string' ? settings.autoMemoryDirectory : undefined
315
348
  dir = enabled ? resolveMemoryDir(ctx.cwd, override) : memoryDir(ctx.cwd)
349
+ indexCache = null
316
350
  if (!enabled) return
317
351
  const count = readIndexQuietly(dir)
318
352
  .split('\n')
@@ -322,7 +356,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
322
356
 
323
357
  pi.on('before_agent_start', async (event) => {
324
358
  if (inSubagent() || !enabled) return
325
- const index = readIndexQuietly(dir)
359
+ const index = readIndexCached()
326
360
  if (!index.trim()) return
327
361
  return {
328
362
  systemPrompt: `${event.systemPrompt}\n\n## Memory\n\nPersistent memories from earlier sessions (index):\n\n${capIndexForPrompt(index)}\nUse the memory tool with action "read" to load a memory's full content when relevant.`,
@@ -346,9 +380,12 @@ export default function memoryExtension(pi: ExtensionAPI) {
346
380
 
347
381
  if (params.action === 'save') {
348
382
  try {
349
- return saveMemory(dir, indexPath, name, params.description, params.content)
383
+ // Awaited here, not returned: the catch must see a queued write's rejection.
384
+ return await saveMemory(dir, indexPath, name, params.description, params.content)
350
385
  } catch (error) {
351
386
  return { content: [{ type: 'text' as const, text: `Memory save failed: ${error instanceof Error ? error.message : String(error)}. The index was left untouched.` }], details: {} }
387
+ } finally {
388
+ indexCache = null
352
389
  }
353
390
  }
354
391
 
@@ -359,7 +396,13 @@ export default function memoryExtension(pi: ExtensionAPI) {
359
396
 
360
397
  if (params.action === 'delete') {
361
398
  if (!name) return { content: [{ type: 'text' as const, text: 'delete requires name.' }], details: {} }
362
- return deleteMemory(dir, indexPath, name)
399
+ // In a finally like the save path: a delete that throws mid-write must still
400
+ // drop the cache, or the next turn injects a stale index.
401
+ try {
402
+ return await deleteMemory(dir, indexPath, name)
403
+ } finally {
404
+ indexCache = null
405
+ }
363
406
  }
364
407
 
365
408
  const index = readIndexQuietly(dir)
@@ -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
  }
@@ -13,8 +13,10 @@
13
13
  * the built-in segment stands in.
14
14
  *
15
15
  * Without a configured statusLine, the built-in segment shows turn state plus
16
- * running session cost, summed from per-message usage on the current branch so it
17
- * stays correct across /tree navigation and forks. The built-in segment is also
16
+ * running session cost: a total seeded from the branch's per-message usage at
17
+ * session start, accumulated per message_end, and reseeded when compaction or
18
+ * /tree navigation reshapes the branch, so it stays correct across navigation
19
+ * and forks without re-walking the branch on every render. The built-in segment is also
18
20
  * the fallback while a configured command produces no output. Multi-line output
19
21
  * is truncated to its first line: the segment is one footer row in pi.
20
22
  *
@@ -48,6 +50,8 @@ interface UsageEntry {
48
50
  message?: { usage?: { cost?: { total?: number } } }
49
51
  }
50
52
 
53
+ /** Full branch walk: used only to (re)seed the running total, at session start
54
+ * and on the events that reshape the branch. Renders read the total instead. */
51
55
  function sessionCost(ctx: ExtensionContext): number {
52
56
  let total = 0
53
57
  for (const entry of ctx.sessionManager.getBranch() as UsageEntry[]) {
@@ -95,8 +99,17 @@ export default function statusLine(pi: ExtensionAPI) {
95
99
  let sessionCtx: ExtensionContext | undefined
96
100
  let commandLine: string | undefined
97
101
  let permissionMode = 'default'
98
- let projectApproved = false
99
102
  let sessionStartMs = Date.now()
103
+ // Running session cost; seeded and reseeded by sessionCost(), see below.
104
+ let costTotal = 0
105
+ // The output-style settings chain and active style name, resolved once at
106
+ // session start: the chain's upward walk and per-file reads are too costly for
107
+ // every refresh tick. /output-style persists a choice straight to settings with
108
+ // no bus event, and the new style applies from the next turn anyway, so the
109
+ // cached name is re-read lazily at most once per turn (styleDirty, turn_start).
110
+ let styleFiles: string[] = []
111
+ let styleName: string | undefined
112
+ let styleDirty = false
100
113
  // Lines changed, counted from successful edit/write inputs: newText and content
101
114
  // lines add, oldText lines remove. An approximation of Claude's counters, which
102
115
  // is honest for the tools pi has; bash-side changes are invisible to both.
@@ -114,8 +127,7 @@ export default function statusLine(pi: ExtensionAPI) {
114
127
 
115
128
  function segmentText(ctx: ExtensionContext, symbol: string): string {
116
129
  const theme = ctx.ui.theme
117
- const cost = sessionCost(ctx)
118
- const costText = cost > 0 ? theme.fg('muted', ` ${formatCost(cost)}`) : ''
130
+ const costText = costTotal > 0 ? theme.fg('muted', ` ${formatCost(costTotal)}`) : ''
119
131
  const turnText = turnCount > 0 ? theme.fg('dim', ` turn ${turnCount}`) : theme.fg('dim', ' ready')
120
132
  return symbol + turnText + costText
121
133
  }
@@ -128,9 +140,11 @@ export default function statusLine(pi: ExtensionAPI) {
128
140
  function buildPayload(ctx: ExtensionContext): Record<string, unknown> {
129
141
  const usage = ctx.getContextUsage() ?? { tokens: null, contextWindow: 0, percent: null }
130
142
  const model = ctx.model as { id?: string; name?: string } | undefined
131
- // Same gate as the config read above: an unapproved project's style is not applied,
132
- // so reporting it here would describe a style the session is not using.
133
- const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), projectApproved))
143
+ // Refresh the cached style name only when a turn boundary may have changed it.
144
+ if (styleDirty) {
145
+ styleName = readActiveStyleName(styleFiles)
146
+ styleDirty = false
147
+ }
134
148
  const payload: Record<string, unknown> = {
135
149
  hook_event_name: 'Status',
136
150
  session_id: ctx.sessionManager.getSessionId(),
@@ -141,7 +155,7 @@ export default function statusLine(pi: ExtensionAPI) {
141
155
  // read .model.display_name and render the literal "null" when it is missing.
142
156
  model: { id: model?.id ?? '', display_name: model?.name ?? model?.id ?? '' },
143
157
  cost: {
144
- total_cost_usd: sessionCost(ctx),
158
+ total_cost_usd: costTotal,
145
159
  total_duration_ms: Date.now() - sessionStartMs,
146
160
  total_api_duration_ms: apiDurationMs,
147
161
  total_lines_added: linesAdded,
@@ -250,10 +264,13 @@ export default function statusLine(pi: ExtensionAPI) {
250
264
  if (requestStartMs !== undefined) apiDurationMs += Date.now() - requestStartMs
251
265
  requestStartMs = undefined
252
266
  })
253
- // The last message's token usage, for the breakdown getContextUsage() omits.
267
+ // The last message's token usage, for the breakdown getContextUsage() omits,
268
+ // and the running cost total, so renders never re-walk the branch.
254
269
  pi.on('message_end', async (event) => {
255
- const usage = (event as { message?: { usage?: NonNullable<typeof lastUsage> } }).message?.usage
256
- if (usage) lastUsage = usage
270
+ const usage = (event as { message?: { usage?: NonNullable<typeof lastUsage> & { cost?: { total?: number } } } }).message?.usage
271
+ if (!usage) return
272
+ lastUsage = usage
273
+ costTotal += usage.cost?.total ?? 0
257
274
  })
258
275
 
259
276
  pi.on('session_start', async (_event, ctx) => {
@@ -268,11 +285,18 @@ export default function statusLine(pi: ExtensionAPI) {
268
285
  requestStartMs = undefined
269
286
  lastUsage = undefined
270
287
  clearInterval(refreshTimer)
288
+ // Seed the running cost from the branch: a resumed or forked session starts
289
+ // with history, and message_end only accumulates from here on.
290
+ costTotal = sessionCost(ctx)
271
291
  // Reading config must never open a trust dialog: several extensions resolve
272
292
  // approval at session start, and a second prompt stacks over the first and eats
273
293
  // the keys meant for it. An undecided project simply skips project settings.
274
294
  const trusted = isProjectApprovedSilently(ctx)
275
- projectApproved = trusted
295
+ // Same gate for the style chain: an unapproved project's style is not applied,
296
+ // so reporting it in the payload would describe a style the session is not using.
297
+ styleFiles = settingsFiles(ctx.cwd, os.homedir(), trusted)
298
+ styleName = readActiveStyleName(styleFiles)
299
+ styleDirty = false
276
300
  const files = hookFiles(ctx.cwd, os.homedir(), trusted)
277
301
  // Claude's disableAllHooks also turns off the custom statusLine command; the
278
302
  // built-in segment still renders as the fallback.
@@ -286,6 +310,9 @@ export default function statusLine(pi: ExtensionAPI) {
286
310
 
287
311
  pi.on('turn_start', async (_event, ctx) => {
288
312
  turnCount++
313
+ // A /output-style between turns lands in settings silently; its style applies
314
+ // from this turn, so this is the moment the cached name can go stale.
315
+ styleDirty = true
289
316
  const theme = ctx.ui.theme
290
317
  show(ctx, theme.fg('accent', '●') + theme.fg('dim', ` turn ${turnCount}...`))
291
318
  })
@@ -300,10 +327,17 @@ export default function statusLine(pi: ExtensionAPI) {
300
327
  scheduleRefresh()
301
328
  })
302
329
 
303
- pi.on('session_compact', async (_event, _ctx) => {
330
+ pi.on('session_compact', async (_event, ctx) => {
331
+ // Compaction replaces the branch entries; reseed the total from what remains.
332
+ costTotal = sessionCost(ctx)
304
333
  scheduleRefresh()
305
334
  })
306
335
 
336
+ pi.on('session_tree', async (_event, ctx) => {
337
+ // Tree navigation swaps the branch wholesale with no message_end events.
338
+ costTotal = sessionCost(ctx)
339
+ })
340
+
307
341
  pi.on('session_shutdown', async () => {
308
342
  clearInterval(refreshTimer)
309
343
  clearTimeout(debounceTimer)
@@ -1352,7 +1352,16 @@ export default function subagentExtension(pi: ExtensionAPI) {
1352
1352
  // available model list are captured per session so a hook run lands in the right repo.
1353
1353
  let hookCwd = process.cwd()
1354
1354
  let hookModels: ReadonlyArray<{ id: string }> = []
1355
+
1356
+ // Discovery walks the plugin cache, the builtin dir, and every agent dir, parsing
1357
+ // each file: dozens of fs ops per call. The roster injection below runs every turn
1358
+ // for a list that almost never changes mid-session, so it reuses one discovery per
1359
+ // (cwd, scope), dropped on session_start. The tool's execute() keeps rediscovering
1360
+ // per invocation, so a just-added agent is still runnable without a restart.
1361
+ let rosterCache: { key: string; agents: AgentConfig[] } | null = null
1362
+
1355
1363
  pi.on('session_start', async (_event, ctx) => {
1364
+ rosterCache = null
1356
1365
  hookCwd = ctx.cwd
1357
1366
  try {
1358
1367
  hookModels = ctx.modelRegistry?.getAvailable?.() ?? []
@@ -1378,12 +1387,15 @@ export default function subagentExtension(pi: ExtensionAPI) {
1378
1387
  })
1379
1388
 
1380
1389
  // Claude surfaces each agent's description so the model can pick one autonomously.
1381
- // Rebuilt per turn (agents are rediscovered per invocation too); project agents are
1382
- // included only when the project is already approved, read without prompting, since
1383
- // a trust dialog must not appear mid-turn and their descriptions are project text.
1390
+ // Served from the session-level cache above (keyed on cwd and scope, so an approval
1391
+ // granted mid-session still widens it); project agents are included only when the
1392
+ // project is already approved, read without prompting, since a trust dialog must
1393
+ // not appear mid-turn and their descriptions are project text.
1384
1394
  pi.on('before_agent_start', async (event, ctx) => {
1385
- const scope = isProjectApprovedSilently(ctx) ? 'both' : 'user'
1386
- const { agents } = discoverAgents(ctx.cwd, scope)
1395
+ const scope: AgentScope = isProjectApprovedSilently(ctx) ? 'both' : 'user'
1396
+ const key = `${scope}\n${ctx.cwd}`
1397
+ if (rosterCache?.key !== key) rosterCache = { key, agents: discoverAgents(ctx.cwd, scope).agents }
1398
+ const { agents } = rosterCache
1387
1399
  if (agents.length === 0) return
1388
1400
  const line = (text: string): string => text.replace(/\s+/g, ' ').trim().slice(0, 200)
1389
1401
  const roster = agents.map((agent) => `- ${agent.name} (${agent.source}): ${line(agent.description)}`).join('\n')
@@ -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
  })