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 CHANGED
@@ -17,6 +17,10 @@ What a repository ships is treated as untrusted until you approve it: project MC
17
17
 
18
18
  ![pi-code demo](demos/hero.gif)
19
19
 
20
+ ## Requirements
21
+
22
+ pi `>=0.79.1` (0.84.x recommended) and Node `>=22.19` for current pi.
23
+
20
24
  ## Install
21
25
 
22
26
  ```bash
@@ -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()) {
@@ -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 : { block: true, reason: decision.reason }
1017
+ return approved ? undefined : blockedToolCall(decision.reason)
1006
1018
  }
1007
- return { block: true, reason: decision.reason }
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: (current?.tools ?? 0) + added })
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
- const count = registerTools(name, config, tools)
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
- status.set(name, { state: 'connected', tools: count })
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 two ways: setSessionName
7
- * (the name shown in the session selector) and the terminal window/tab title.
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. The
11
- * window-title update is the only terminal-specific part, so it is optional-called rather
12
- * than gated on hasUI. Titling is best-effort throughout: a session that already has a
13
- * name, a run with no user text (a slash-command-only turn), a headless run with no model,
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, and a child
5
- * still running when pi exits finishes on its own rather than being killed.
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
@@ -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.12",
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.0.0",
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.0",
65
- "@earendil-works/pi-ai": "^0.84.0",
66
- "@earendil-works/pi-coding-agent": "^0.84.0",
67
- "@earendil-works/pi-tui": "^0.84.0",
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",