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/claude-rules.ts +31 -28
- package/extensions/commands.ts +24 -5
- package/extensions/context-imports.ts +92 -17
- package/extensions/git-checkpoint.ts +21 -3
- package/extensions/hooks.ts +56 -9
- package/extensions/init.ts +3 -1
- package/extensions/internal/model-complete.ts +6 -4
- package/extensions/internal/path-rules.ts +45 -0
- package/extensions/internal/plugins.ts +55 -0
- package/extensions/mcp.ts +44 -19
- package/extensions/memory.ts +74 -31
- package/extensions/notify.ts +5 -0
- package/extensions/plan-mode/index.ts +3 -1
- package/extensions/question.ts +56 -23
- package/extensions/status-line.ts +48 -14
- package/extensions/subagent/index.ts +17 -5
- package/extensions/todo.ts +11 -1
- package/extensions/web.ts +12 -7
- package/package.json +1 -1
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
|
-
|
|
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
|
|
1115
|
-
* is settled, so a refused confirm can be retried on a
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1156
|
-
// whole-project confirm, and the rest stay behind it
|
|
1157
|
-
//
|
|
1158
|
-
if (!projectConnected) projectConnected = await
|
|
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
|
|
package/extensions/memory.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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):
|
|
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
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/extensions/notify.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/extensions/question.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
17
|
-
*
|
|
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
|
|
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
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
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:
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
//
|
|
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
|
|
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')
|
package/extensions/todo.ts
CHANGED
|
@@ -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
|
|
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
|
})
|