pi-code 1.0.12 → 1.0.13
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/README.md +4 -0
- package/extensions/commands.ts +10 -0
- package/extensions/git-checkpoint.ts +7 -0
- package/extensions/hooks.ts +15 -3
- package/extensions/mcp.ts +25 -3
- package/extensions/session-title.ts +11 -8
- package/extensions/subagent/background.ts +15 -2
- package/extensions/subagent/index.ts +18 -1
- package/extensions/thinking.ts +9 -0
- package/package.json +10 -9
package/README.md
CHANGED
package/extensions/commands.ts
CHANGED
|
@@ -466,6 +466,16 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
466
466
|
}
|
|
467
467
|
|
|
468
468
|
pi.on('session_start', async (_event, ctx) => {
|
|
469
|
+
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
470
|
+
// the same instance while a command's per-run scoping is still pending (its agent_settled
|
|
471
|
+
// never came). Carrying that into the next session would restore an unrelated tool set,
|
|
472
|
+
// bash/path scope, model, or effort onto it, so drop the pending state here. Drop only:
|
|
473
|
+
// no setActiveTools/setModel/setThinkingLevel, since the new session owns its own state.
|
|
474
|
+
pendingRestore = undefined
|
|
475
|
+
pendingBashRules = undefined
|
|
476
|
+
pendingPathRules = undefined
|
|
477
|
+
pendingModelRestore = undefined
|
|
478
|
+
pendingEffortRestore = undefined
|
|
469
479
|
const trusted = await isProjectApproved(ctx)
|
|
470
480
|
projectApproved = trusted
|
|
471
481
|
// A resume/fork/new session can switch projects in-process. pi cannot unregister a
|
|
@@ -253,6 +253,13 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
pi.on('session_start', async (_event, ctx) => {
|
|
256
|
+
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
257
|
+
// the same instance after turn_start took the pre-run snapshot but before turn_end saved
|
|
258
|
+
// it; that pending ref belongs to the previous session and must not attach to the next
|
|
259
|
+
// session's first turn_end. Re-arm runNeedsSnapshot too, so the next run snapshots its
|
|
260
|
+
// own tree even though the prior run left it false.
|
|
261
|
+
pending = undefined
|
|
262
|
+
runNeedsSnapshot = true
|
|
256
263
|
await ensureShadow(ctx)
|
|
257
264
|
checkpoints.clear()
|
|
258
265
|
for (const entry of ctx.sessionManager.getEntries()) {
|
package/extensions/hooks.ts
CHANGED
|
@@ -67,7 +67,7 @@ import * as fs from 'node:fs'
|
|
|
67
67
|
import * as os from 'node:os'
|
|
68
68
|
import * as path from 'node:path'
|
|
69
69
|
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
70
|
-
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
70
|
+
import type { ExtensionAPI, ExtensionContext, ToolCallEventResult } from '@earendil-works/pi-coding-agent'
|
|
71
71
|
import { runAgent } from './internal/agent-run.js'
|
|
72
72
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
73
73
|
import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
|
|
@@ -859,6 +859,13 @@ function postToolFeedback(result: HookRunResult, eventName: string, isError: boo
|
|
|
859
859
|
return lines
|
|
860
860
|
}
|
|
861
861
|
|
|
862
|
+
/** A blocked tool_call verdict carrying pi's `terminate` flag (#7715): with it set on
|
|
863
|
+
* an all-terminating tool batch, pi skips the automatic follow-up model call that a plain
|
|
864
|
+
* block would otherwise pay for. */
|
|
865
|
+
function blockedToolCall(reason: string | undefined): ToolCallEventResult {
|
|
866
|
+
return { block: true, reason, terminate: true }
|
|
867
|
+
}
|
|
868
|
+
|
|
862
869
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
863
870
|
let config: HooksConfig = {}
|
|
864
871
|
let projectDir = ''
|
|
@@ -951,6 +958,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
951
958
|
|
|
952
959
|
pi.on('session_start', async (event, ctx) => {
|
|
953
960
|
sessionCtx = ctx
|
|
961
|
+
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
962
|
+
// the same instance while a Stop-hook continuation streak is in flight; it must not
|
|
963
|
+
// carry into the next session, so reset before any early return (disableAllHooks below).
|
|
964
|
+
stopHookActive = false
|
|
965
|
+
stopHookBlockCount = 0
|
|
954
966
|
const trusted = await isProjectApproved(ctx)
|
|
955
967
|
// Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
|
|
956
968
|
// referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
|
|
@@ -1002,9 +1014,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
1002
1014
|
// With no UI (headless) the block stands, which is the safe default.
|
|
1003
1015
|
if (decision.ask && ctx.hasUI) {
|
|
1004
1016
|
const approved = await ctx.ui.confirm(`Allow ${event.toolName}?`, decision.reason ?? 'A hook asks you to confirm this tool call.')
|
|
1005
|
-
return approved ? undefined :
|
|
1017
|
+
return approved ? undefined : blockedToolCall(decision.reason)
|
|
1006
1018
|
}
|
|
1007
|
-
return
|
|
1019
|
+
return blockedToolCall(decision.reason)
|
|
1008
1020
|
})
|
|
1009
1021
|
|
|
1010
1022
|
// Claude's PostToolUse (success) and PostToolUseFailure (error) both feed their
|
package/extensions/mcp.ts
CHANGED
|
@@ -943,6 +943,12 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
943
943
|
// Original server/tool names per registered pi name, for Claude-style hook matchers.
|
|
944
944
|
const aliases: McpToolAlias[] = []
|
|
945
945
|
|
|
946
|
+
/** How many tools a server actually has registered. Counted from `registered` (the
|
|
947
|
+
* durable owner map) rather than registerTools' return, so a reconnect on a second
|
|
948
|
+
* session, where every tool is already registered and registerTools adds 0, still
|
|
949
|
+
* reports the true count in /mcp and the startup banner instead of zero. */
|
|
950
|
+
const serverToolCount = (name: string): number => [...registered.values()].filter((owner) => owner === name).length
|
|
951
|
+
|
|
946
952
|
/** Register every not-yet-registered tool of a server; returns how many were added. */
|
|
947
953
|
function registerTools(name: string, config: ServerConfig, tools: McpToolInfo[]): number {
|
|
948
954
|
let count = 0
|
|
@@ -1154,7 +1160,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1154
1160
|
const added = registerTools(name, config, refreshed)
|
|
1155
1161
|
if (added === 0) return
|
|
1156
1162
|
const current = status.get(name)
|
|
1157
|
-
status.set(name, { state: current?.state ?? 'connected', tools: (
|
|
1163
|
+
status.set(name, { state: current?.state ?? 'connected', tools: serverToolCount(name) })
|
|
1158
1164
|
pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
|
|
1159
1165
|
} catch (error) {
|
|
1160
1166
|
console.warn(`pi-code-mcp: tool refresh failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
|
|
@@ -1187,7 +1193,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1187
1193
|
const client = await connect(name, config, authUi)
|
|
1188
1194
|
clients.set(name, client)
|
|
1189
1195
|
const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
|
|
1190
|
-
|
|
1196
|
+
registerTools(name, config, tools)
|
|
1191
1197
|
subscribeToToolChanges(name, config, client)
|
|
1192
1198
|
// Prompts and resources are additive surfaces: their failures warn (inside
|
|
1193
1199
|
// connectPrompts) rather than flipping a tool-serving server to failed.
|
|
@@ -1195,7 +1201,10 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1195
1201
|
subscribeToPromptChanges(name, client)
|
|
1196
1202
|
ensureResourceTools()
|
|
1197
1203
|
subscribeToResourceChanges(client)
|
|
1198
|
-
|
|
1204
|
+
// Count from `registered`, not registerTools' return: a reconnect re-lists tools
|
|
1205
|
+
// that are already registered (return 0) but still serves them, so the banner
|
|
1206
|
+
// must reflect the true count.
|
|
1207
|
+
status.set(name, { state: 'connected', tools: serverToolCount(name) })
|
|
1199
1208
|
// A server that dies mid-session would otherwise stay "connected" in /mcp
|
|
1200
1209
|
// while every call fails with the SDK's bare "Not connected"; flip the
|
|
1201
1210
|
// status and free the name so a later session start can reconnect it.
|
|
@@ -1291,6 +1300,12 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1291
1300
|
}
|
|
1292
1301
|
|
|
1293
1302
|
pi.on('session_start', async (_event, ctx) => {
|
|
1303
|
+
// Reset the status map so /mcp and the banner reflect only this session's config: a
|
|
1304
|
+
// server present last session but not this one must not linger as "connected". The
|
|
1305
|
+
// registered tools, aliases, and prompt commands stay: pi has no unregister (a
|
|
1306
|
+
// withdrawn tool keeps its registration and surfaces the server's own error), which
|
|
1307
|
+
// is why serverToolCount reads from `registered` to recover the true count here.
|
|
1308
|
+
status.clear()
|
|
1294
1309
|
const authUi = authUiFor(ctx)
|
|
1295
1310
|
// The managed allow/deny lists filter every scope, including a managed-mcp.json set.
|
|
1296
1311
|
const { allowed, denied } = mcpAllowDeny()
|
|
@@ -1320,6 +1335,13 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1320
1335
|
pi.on('session_shutdown', async () => {
|
|
1321
1336
|
// Close in parallel with a per-client timeout so one hung server can't stall pi's exit.
|
|
1322
1337
|
await Promise.all([...clients.values()].map((client) => withTimeout(client.close(), 3000, 'close').catch(() => {})))
|
|
1338
|
+
// Drop the closed clients and their status now rather than waiting on each client's
|
|
1339
|
+
// onclose, which the SDK fires late: a same-process session switch (/new, /resume,
|
|
1340
|
+
// /fork) runs the next session_start right after this, and a lingering dead client
|
|
1341
|
+
// there would make connectServers skip reconnecting the name, stranding every tool
|
|
1342
|
+
// closure on a closed client. session_start resets status too, so a switch rebuilds it.
|
|
1343
|
+
clients.clear()
|
|
1344
|
+
status.clear()
|
|
1323
1345
|
})
|
|
1324
1346
|
|
|
1325
1347
|
pi.registerCommand('mcp', {
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Claude auto-names a new conversation from its first message; this does the same for
|
|
5
5
|
* pi. After the first run of an unnamed session settles, it asks the current model for
|
|
6
|
-
* a short title based on the first user message and applies it
|
|
7
|
-
*
|
|
6
|
+
* a short title based on the first user message and applies it with setSessionName, which
|
|
7
|
+
* names the session in the selector and refreshes the terminal window/tab title natively
|
|
8
|
+
* (pi changelog); a separate ctx.ui.setTitle call would only duplicate that, so there is none.
|
|
8
9
|
*
|
|
9
10
|
* It runs in every mode, not just the TUI: naming a session is cheap and harmless, and a
|
|
10
|
-
* headless run that persists its session still benefits from a readable name later.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* or any provider error leaves the session untitled and never throws.
|
|
11
|
+
* headless run that persists its session still benefits from a readable name later. Titling
|
|
12
|
+
* is best-effort throughout: a session that already has a name, a run with no user text (a
|
|
13
|
+
* slash-command-only turn), a headless run with no model, or any provider error leaves the
|
|
14
|
+
* session untitled and never throws.
|
|
15
15
|
*
|
|
16
16
|
* Cost: one model call per session at most. The guard is claimed before the completion so
|
|
17
17
|
* repeated settles cannot each fire a call, and a failed attempt is not retried until a
|
|
@@ -132,8 +132,11 @@ export default function sessionTitleExtension(pi: ExtensionAPI) {
|
|
|
132
132
|
// Post-await ctx getters throw once the session is disposed, and an escaping rejection
|
|
133
133
|
// from this un-awaited settle can exit pi; apply the title best-effort.
|
|
134
134
|
try {
|
|
135
|
+
// pi.setSessionName refreshes the terminal/tab title natively (pi changelog), so a
|
|
136
|
+
// separate ctx.ui.setTitle call would only duplicate that. The guard stays: a
|
|
137
|
+
// disposed session throws from setSessionName post-await, and an escaping rejection
|
|
138
|
+
// from this un-awaited settle can exit pi.
|
|
135
139
|
pi.setSessionName(title)
|
|
136
|
-
ctx.ui.setTitle?.(title)
|
|
137
140
|
} catch {
|
|
138
141
|
// disposed session or a setter failure: leave the session untitled.
|
|
139
142
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Background subagent runs: fire-and-forget children whose completion wakes
|
|
3
3
|
* the parent agent via a notification message. State lives in an in-memory
|
|
4
|
-
* registry queried via {status: true}; it is lost on restart
|
|
5
|
-
*
|
|
4
|
+
* registry queried via {status: true}; it is lost on restart. A child still
|
|
5
|
+
* running when pi quits is SIGTERMed (cancelAllBackgroundRuns); one still running
|
|
6
|
+
* across a same-process session switch keeps going under the new session.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { spawn } from 'node:child_process'
|
|
@@ -141,6 +142,18 @@ export function cancelBackgroundRun(id: string): 'cancelled' | 'not-running' | '
|
|
|
141
142
|
return 'cancelled'
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/** SIGTERM every live background child, killing each process group the way a single
|
|
146
|
+
* cancel does. Called on quit: a detached child would otherwise keep running (and
|
|
147
|
+
* spending tokens) after pi exits, its completion swallowed. Returns how many were
|
|
148
|
+
* signalled; each cancelled child still holds its slot until it actually dies. */
|
|
149
|
+
export function cancelAllBackgroundRuns(): number {
|
|
150
|
+
let count = 0
|
|
151
|
+
for (const id of Array.from(runs.keys())) {
|
|
152
|
+
if (cancelBackgroundRun(id) === 'cancelled') count++
|
|
153
|
+
}
|
|
154
|
+
return count
|
|
155
|
+
}
|
|
156
|
+
|
|
144
157
|
export function backgroundStatusText(): string {
|
|
145
158
|
return formatStatus(runs.values())
|
|
146
159
|
}
|
|
@@ -32,7 +32,7 @@ import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
|
32
32
|
import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
|
|
33
33
|
import { skillDirs } from '../skills.js'
|
|
34
34
|
import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
|
|
35
|
-
import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
|
|
35
|
+
import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelAllBackgroundRuns, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
|
|
36
36
|
|
|
37
37
|
const MAX_PARALLEL_TASKS = 8
|
|
38
38
|
const MAX_CONCURRENCY = 4
|
|
@@ -1387,6 +1387,23 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1387
1387
|
})
|
|
1388
1388
|
})
|
|
1389
1389
|
|
|
1390
|
+
pi.on('session_shutdown', (event, ctx) => {
|
|
1391
|
+
// On quit pi is exiting, so a detached background child would keep running (and
|
|
1392
|
+
// spending tokens) with its completion swallowed: SIGTERM every live run, killing the
|
|
1393
|
+
// process group the way a cancel does. On a same-process session switch
|
|
1394
|
+
// (new/resume/fork) the children keep running under the new session, so leave them be
|
|
1395
|
+
// and warn once that they are still spending; /tasks inspects them. reload re-imports
|
|
1396
|
+
// this module (losing the registry), so it neither kills nor warns.
|
|
1397
|
+
if (event.reason === 'quit') {
|
|
1398
|
+
cancelAllBackgroundRuns()
|
|
1399
|
+
return
|
|
1400
|
+
}
|
|
1401
|
+
if (event.reason === 'new' || event.reason === 'resume' || event.reason === 'fork') {
|
|
1402
|
+
const active = activeBackgroundRuns()
|
|
1403
|
+
if (active > 0) ctx.ui?.notify(`${active} background run${active === 1 ? '' : 's'} still active; /tasks to inspect`, 'warning')
|
|
1404
|
+
}
|
|
1405
|
+
})
|
|
1406
|
+
|
|
1390
1407
|
// Claude surfaces each agent's description so the model can pick one autonomously.
|
|
1391
1408
|
// Served from the session-level cache above (keyed on cwd and scope, so an approval
|
|
1392
1409
|
// granted mid-session still widens it); project agents are included only when the
|
package/extensions/thinking.ts
CHANGED
|
@@ -46,6 +46,15 @@ export default function thinkingExtension(pi: ExtensionAPI) {
|
|
|
46
46
|
// already moved the level, thinking stands down instead of clobbering it.
|
|
47
47
|
let pendingTarget: ThinkingLevel | undefined
|
|
48
48
|
|
|
49
|
+
pi.on('session_start', () => {
|
|
50
|
+
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
51
|
+
// the same instance while an escalation is still pending (its agent_settled never came),
|
|
52
|
+
// and that stale restore must be dropped rather than fired into the next session, whose
|
|
53
|
+
// level the new session owns. Drop only: do NOT setThinkingLevel here.
|
|
54
|
+
pendingRestore = undefined
|
|
55
|
+
pendingTarget = undefined
|
|
56
|
+
})
|
|
57
|
+
|
|
49
58
|
pi.on('input', (event, ctx) => {
|
|
50
59
|
// Only genuine user input escalates. sendUserMessage emits an input event with
|
|
51
60
|
// source 'extension' (a subagent prompt, a command body replayed through it); a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
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",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"todo"
|
|
21
21
|
],
|
|
22
22
|
"license": "MIT",
|
|
23
|
+
"author": "ilovepixelart",
|
|
23
24
|
"type": "module",
|
|
24
25
|
"files": [
|
|
25
26
|
"extensions"
|
|
@@ -51,20 +52,20 @@
|
|
|
51
52
|
"provenance": true
|
|
52
53
|
},
|
|
53
54
|
"dependencies": {
|
|
54
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
55
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
55
56
|
"typebox": "^1.3.6"
|
|
56
57
|
},
|
|
57
58
|
"peerDependencies": {
|
|
58
|
-
"@earendil-works/pi-ai": "
|
|
59
|
-
"@earendil-works/pi-coding-agent": "
|
|
60
|
-
"@earendil-works/pi-tui": "
|
|
59
|
+
"@earendil-works/pi-ai": ">=0.79.1",
|
|
60
|
+
"@earendil-works/pi-coding-agent": ">=0.79.1",
|
|
61
|
+
"@earendil-works/pi-tui": ">=0.79.1"
|
|
61
62
|
},
|
|
62
63
|
"devDependencies": {
|
|
63
64
|
"@biomejs/biome": "^2.5.4",
|
|
64
|
-
"@earendil-works/pi-agent-core": "^0.84.
|
|
65
|
-
"@earendil-works/pi-ai": "^0.84.
|
|
66
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
67
|
-
"@earendil-works/pi-tui": "^0.84.
|
|
65
|
+
"@earendil-works/pi-agent-core": "^0.84.2",
|
|
66
|
+
"@earendil-works/pi-ai": "^0.84.2",
|
|
67
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
68
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
68
69
|
"@types/node": "^26.1.1",
|
|
69
70
|
"@vitest/coverage-v8": "^4.1.10",
|
|
70
71
|
"typescript": "^7.0.2",
|