pi-code 1.0.48 → 1.0.50

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.
@@ -68,7 +68,8 @@ function parsePaths(frontmatter: string): string[] {
68
68
 
69
69
  /** Split YAML-ish frontmatter off the front of a rule file, extracting `paths`. */
70
70
  export function parseFrontmatter(content: string): Frontmatter {
71
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content)
71
+ // A leading byte order mark (Windows editors add one) is part of the header, not the body.
72
+ const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content)
72
73
  if (!match) return { paths: [], body: content }
73
74
  return { paths: parsePaths(match[1]), body: content.slice(match[0].length) }
74
75
  }
@@ -197,7 +197,7 @@ function commandVars(ctx: { cwd: string }, filePath: string, plugin?: CommandPlu
197
197
 
198
198
  /** The exec seam expandCommand runs spans through; pi itself satisfies it. */
199
199
  interface SpanRunner {
200
- exec(command: string, args: string[], options?: { cwd?: string; timeout?: number }): Promise<{ stdout: string; stderr: string; code: number }>
200
+ exec(command: string, args: string[], options?: { cwd?: string; timeout?: number }): Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>
201
201
  }
202
202
 
203
203
  /**
@@ -231,7 +231,7 @@ export async function expandCommand(runner: SpanRunner, parsed: ParsedCommand, a
231
231
  // pwsh cannot merge a native command's stderr in-script (spanExec sets
232
232
  // mergeStreams), so it is appended here; the sh script merges via 2>&1.
233
233
  const stdout = run.mergeStreams ? result.stdout + result.stderr : result.stdout
234
- return { stdout, stderr: result.stderr, code: result.code }
234
+ return { stdout, stderr: result.stderr, code: result.code, killed: result.killed }
235
235
  }
236
236
  : async () => ({ stdout: SHELL_DISABLED_PLACEHOLDER, stderr: '', code: 0 })
237
237
  let expanded = await expandDynamicContent(withVars, ctx.cwd, exec, parsed.shell === 'powershell' ? 'powershell' : 'bash')
@@ -72,12 +72,13 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
72
72
  import { claudeConfigDir } from './internal/config-dir.js'
73
73
  import { type InstructionLoadEvent, memoryTypeForPath, publishInstructionLoad } from './internal/instruction-events.js'
74
74
  import { managedSettingsPath, readManagedSettings } from './internal/managed-settings.js'
75
+ import { sliceBytes } from './internal/output-guard.js'
75
76
  import { globToRegExpSource } from './internal/path-rules.js'
76
77
  import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
77
78
  import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
78
79
  import { claudeSettingsChain } from './internal/settings-chain.js'
79
80
  import { statToken } from './internal/stat-token.js'
80
- import { fenceMarker, stripBlockComments } from './internal/strip-comments.js'
81
+ import { type Fence, fenceMarker, stepFence, stripBlockComments } from './internal/strip-comments.js'
81
82
 
82
83
  /** Claude documents "a maximum depth of four hops" for recursive imports. */
83
84
  const MAX_IMPORT_DEPTH = 4
@@ -145,16 +146,14 @@ export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES
145
146
  * imports neither in fenced code blocks (backtick or tilde) nor in inline spans. */
146
147
  function importTargets(content: string): string[] {
147
148
  const targets: string[] = []
148
- // A fence only closes with the character that opened it: a backtick-fenced
149
- // example may legitimately contain tilde-fence lines, and vice versa.
150
- let fence: string | null = null
149
+ // CommonMark fences: closed only by the same character in a run at least as long
150
+ // as the opener, so a backtick example may hold tilde lines or shorter fences.
151
+ let fence: Fence | null = null
151
152
  for (const line of content.split('\n')) {
152
- const marker = fenceMarker(line.trimStart())
153
- if (marker !== null && (fence === null || fence === marker)) {
154
- fence = fence === null ? marker : null
155
- continue
156
- }
157
- if (fence !== null) continue
153
+ const trimmed = line.trimStart()
154
+ const step = stepFence(fence, trimmed, fenceMarker(trimmed))
155
+ fence = step.fence
156
+ if (step.fenced) continue
158
157
  // Backreference so a multi-backtick span (``literal `@x` backticks``) strips whole.
159
158
  const withoutSpans = line.replace(/(`+)[^`]*?\1/g, '')
160
159
  for (const match of withoutSpans.matchAll(/(^|\s)@(\S+)/g)) targets.push(match[2])
@@ -222,8 +221,10 @@ function collectFrom(scan: ImportScan, content: string, fromDir: string, depth:
222
221
  const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded)
223
222
  if (!file) continue
224
223
  scan.budget.files -= 1
225
- const kept = file.body.slice(0, scan.budget.bytes)
226
- scan.budget.bytes -= kept.length
224
+ // The budget is bytes: a string slice counts UTF-16 units and lets CJK text through
225
+ // at three times the budget without ever reaching the truncation marker.
226
+ const kept = sliceBytes(file.body, scan.budget.bytes)
227
+ scan.budget.bytes -= Buffer.byteLength(kept)
227
228
  const body = kept.length < file.body.length ? `${kept.trim()}\n${IMPORT_TRUNCATED_MARKER}` : kept.trim()
228
229
  // Comments are stripped before the scan for further imports, so a
229
230
  // commented-out @import stays dead at every depth, matching the top level
@@ -194,6 +194,32 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
194
194
  }
195
195
  // Written on every start, so repos that predate the sidecar pick it up too.
196
196
  rememberWorkTree(shadowDir, ctx.cwd)
197
+ await mirrorLocalExcludes(ctx.cwd)
198
+ }
199
+
200
+ /** git reads ignore rules from the tree's .gitignore files, the user's global excludes,
201
+ * and $GIT_DIR/info/exclude. The shadow is the GIT_DIR here, so the repo's own
202
+ * .git/info/exclude (where secrets and scratch that must never be committed live)
203
+ * would be snapshotted and restored. Mirror it into the shadow on every start; the
204
+ * global excludes stay untouched (core.excludesFile is single-valued, so pointing it
205
+ * at the repo file would replace them). */
206
+ async function mirrorLocalExcludes(cwd: string): Promise<void> {
207
+ if (!shadowDir) return
208
+ // Resolved through git so a linked worktree maps to its common dir; outside a repo
209
+ // git exits 128 and there is nothing to mirror.
210
+ const located = await pi.exec('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd })
211
+ const target = path.join(shadowDir, 'info', 'exclude')
212
+ try {
213
+ const source = located.code === 0 ? path.resolve(cwd, located.stdout.trim()) : undefined
214
+ if (source && fs.existsSync(source)) {
215
+ fs.mkdirSync(path.dirname(target), { recursive: true })
216
+ fs.copyFileSync(source, target)
217
+ } else {
218
+ fs.rmSync(target, { force: true })
219
+ }
220
+ } catch {
221
+ // A failed mirror only means local excludes are not honored this session.
222
+ }
197
223
  }
198
224
 
199
225
  /** `checkout -f <ref> -- .` errors when the ref's tree holds no files, so an empty
@@ -16,6 +16,10 @@ export interface HookCommand {
16
16
  /** exec-form: spawn `command` directly with these args and no shell (shell-form when
17
17
  * absent). $ARGUMENTS in each arg is replaced with the event JSON. */
18
18
  args?: string[]
19
+ /** Claude's `shell`: "bash" (the default) or "powershell". The default is /bin/sh off
20
+ * Windows; on Windows Git Bash, then PowerShell when Git Bash is absent. Ignored when
21
+ * `args` is set. */
22
+ shell?: string
19
23
  timeout?: number
20
24
  /** Claude's background contract, honored on `type: "command"` hooks only: `async` runs
21
25
  * without blocking its event and with no timeout enforced; `asyncRewake` also runs in
@@ -72,7 +72,9 @@
72
72
  * agent-frontmatter hooks arrive via PI_CODE_AGENT_HOOKS (Stop pre-converted to
73
73
  * SubagentStop, fired at the child's own agent end) and die with the process.
74
74
  *
75
- * Hook commands run via `sh -c` with the event JSON on stdin. A PreToolUse
75
+ * Hook commands run through the platform shell (`sh -c`; on Windows Git Bash, or
76
+ * PowerShell when Git Bash is absent, see internal/shell-resolve) with the event JSON
77
+ * on stdin. A PreToolUse
76
78
  * hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
77
79
  * `{"hookSpecificOutput": {"permissionDecision": "deny", ...}}` (or the older
78
80
  * `{"decision": "block"}`).
@@ -271,7 +273,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
271
273
  if (hook.type === 'prompt') return runPromptHook(hook, merged, resolveHookModel(ctx, hook.model), ms)
272
274
  if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
273
275
  if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
274
- return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild)
276
+ return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild, hook.shell)
275
277
  }
276
278
  // Claude's `once` (skill-frontmatter hooks only): removed after the first
277
279
  // successful run; a failure, block, or timeout leaves it in place.
@@ -5,10 +5,12 @@
5
5
  */
6
6
 
7
7
  import { type ChildProcess, spawn } from 'node:child_process'
8
+ import * as path from 'node:path'
8
9
  import type { Api, Model } from '@earendil-works/pi-ai'
9
10
  import { runAgent } from '../internal/agent-run.js'
10
11
  import { callMcpTool } from '../internal/mcp-call.js'
11
12
  import { completeText } from '../internal/model-complete.js'
13
+ import { resolveShell } from '../internal/shell-resolve.js'
12
14
  import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
13
15
 
14
16
  // Claude's defaults vary by type and event (600s for command/http/mcp_tool, 30s
@@ -41,7 +43,7 @@ export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number
41
43
  * `args` array it becomes the exec path: `command` is spawned directly with those args.
42
44
  * `onChild` hands the caller a kill for the spawned tree, so a background hook that is
43
45
  * still running at session end can be reaped (Claude kills async hooks at teardown). */
44
- export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[], onChild?: (kill: () => void) => void) => Promise<HookRunResult>
46
+ export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[], onChild?: (kill: () => void) => void, shell?: string) => Promise<HookRunResult>
45
47
 
46
48
  /** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
47
49
  const MAX_TIMEOUT_S = 2_147_483
@@ -80,6 +82,15 @@ const TIMEOUT_EXIT_CODE = 124
80
82
  * direct child alone leaves a grandchild alive holding stdout/stderr.
81
83
  */
82
84
  function killTree(child: ChildProcess): void {
85
+ if (process.platform === 'win32') {
86
+ // Windows has no process groups: taskkill /T ends the shell's whole tree. By
87
+ // absolute path, so a writable PATH entry cannot stand in for it. If taskkill itself
88
+ // cannot start, the direct kill is all that is left.
89
+ const taskkill = path.join(process.env.SystemRoot ?? String.raw`C:\Windows`, 'System32', 'taskkill.exe')
90
+ if (child.pid) spawn(taskkill, ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }).on('error', () => child.kill('SIGKILL'))
91
+ else child.kill('SIGKILL')
92
+ return
93
+ }
83
94
  try {
84
95
  // Negative pid targets the whole process group, which `detached` gave the shell.
85
96
  if (child.pid) {
@@ -92,11 +103,19 @@ function killTree(child: ChildProcess): void {
92
103
  child.kill('SIGKILL')
93
104
  }
94
105
 
95
- export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args, onChild) =>
106
+ /** The shell invocation for a shell-form command, or undefined when this machine has no
107
+ * shell for it (Windows with neither Git Bash nor PowerShell). */
108
+ function shellInvocation(command: string, shell: string | undefined): { file: string; spawnArgs: string[] } | undefined {
109
+ const resolved = resolveShell(shell)
110
+ return resolved ? { file: resolved.file, spawnArgs: resolved.argsFor(command) } : undefined
111
+ }
112
+
113
+ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args, onChild, shell) =>
96
114
  new Promise((resolve) => {
97
- // Absolute path so the shell can't be resolved through an attacker-controlled PATH.
98
- // `detached` makes the shell its own process group leader so the timeout can kill
99
- // the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
115
+ // /bin/sh by absolute path off Windows, so the shell can't be resolved through an
116
+ // attacker-controlled PATH; on Windows the resolver follows Claude's documented Git
117
+ // Bash lookup. `detached` makes the shell its own process group leader so the
118
+ // timeout can kill the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
100
119
  // reference project files regardless of the shell's cwd. CLAUDECODE=1 marks every
101
120
  // subprocess Claude spawns, so it is set on the child unconditionally.
102
121
  // CLAUDE_CODE_CHILD_SESSION marks per-call children (hook and status line
@@ -110,11 +129,18 @@ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, p
110
129
  // An exec-form hook (an `args` array) spawns the executable directly with those args
111
130
  // and no shell, so shell metacharacters in the args arrive literally; $ARGUMENTS in
112
131
  // each arg is replaced with the event JSON by a replacer function (so $$/$& in the
113
- // payload survive verbatim). Without args it stays the shell path. Both share the
132
+ // payload survive verbatim). Without args the command string goes to the platform's
133
+ // shell, or to PowerShell when the hook says `shell: "powershell"`. Both share the
114
134
  // same detached process group, so killTree reaches the descendants either way.
115
- const file = Array.isArray(args) ? command : '/bin/sh'
116
- const spawnArgs = Array.isArray(args) ? args.map((arg) => substituteArguments(arg, payload)) : ['-c', command]
117
- const child = spawn(file, spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
135
+ const target = Array.isArray(args) ? { file: command, spawnArgs: args.map((arg) => substituteArguments(arg, payload)) } : shellInvocation(command, shell)
136
+ if (!target) {
137
+ // Marked like a spawn failure so a gated event fails closed rather than reading as an allow.
138
+ resolve({ code: 0, stdout: '', stderr: 'no shell found: install Git for Windows or PowerShell', timedOut: false, spawnFailed: true })
139
+ return
140
+ }
141
+ // On Windows `detached` means DETACHED_PROCESS, which gives a console child its own
142
+ // window; windowsHide keeps every hook invisible (a no-op elsewhere).
143
+ const child = spawn(target.file, target.spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, windowsHide: true, env })
118
144
  onChild?.(() => killTree(child))
119
145
  let stdout = ''
120
146
  let stderr = ''
@@ -13,8 +13,8 @@ import * as fs from 'node:fs'
13
13
  import * as path from 'node:path'
14
14
 
15
15
  import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
16
-
17
16
  import { splitSegments } from './shell-split.js'
17
+ import { type Fence, fenceMarker, stepFence } from './strip-comments.js'
18
18
 
19
19
  /** The pi file tools a Claude path rule can govern. */
20
20
  export type PathRuleTool = 'read' | 'edit' | 'write'
@@ -421,7 +421,7 @@ export function discoverCommandFiles(root: string): DiscoveredCommand[] {
421
421
  return found
422
422
  }
423
423
 
424
- export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number }>
424
+ export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>
425
425
 
426
426
  /** PowerShell single-quote escaping: inside a '...' literal the only special
427
427
  * characters are the quote delimiters themselves, written doubled. PowerShell's
@@ -433,26 +433,9 @@ export function powershellQuote(value: string): string {
433
433
  return value.replaceAll(/['‘’‚‛]/g, '$&$&')
434
434
  }
435
435
 
436
- /** The PowerShell names worth trying: pwsh everywhere it installs, plus the
437
- * Windows spellings on win32, where powershell.exe ships with the OS. */
438
- const powershellCandidates = (platform: string): string[] => (platform === 'win32' ? ['pwsh', 'pwsh.exe', 'powershell.exe'] : ['pwsh'])
439
-
440
- /** First PowerShell binary found on PATH, or undefined when none is installed. */
441
- export function resolvePowershellBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env): string | undefined {
442
- const dirs = (env.PATH ?? '').split(path.delimiter).filter(Boolean)
443
- for (const candidate of powershellCandidates(platform)) {
444
- for (const dir of dirs) {
445
- const full = path.join(dir, candidate)
446
- try {
447
- fs.accessSync(full, fs.constants.X_OK)
448
- if (fs.statSync(full).isFile()) return full
449
- } catch {
450
- // not here; keep looking
451
- }
452
- }
453
- }
454
- return undefined
455
- }
436
+ import { bashBinary } from './shell-resolve.js'
437
+
438
+ export { resolvePowershellBinary } from './shell-resolve.js'
456
439
 
457
440
  export interface SpanExec {
458
441
  command: string
@@ -464,42 +447,55 @@ export interface SpanExec {
464
447
  mergeStreams?: boolean
465
448
  }
466
449
 
450
+ /** The sh invocation for a span: CLAUDE_PROJECT_DIR and CLAUDECODE=1 exported in-script
451
+ * (pi.exec takes no env; CLAUDECODE marks every subprocess Claude spawns), stderr merged
452
+ * with 2>&1. The group opens with a `:` null command: `{ }` around an empty or
453
+ * comment-only span is a hard sh syntax error (exit 2) that aborted the whole
454
+ * invocation, and `:` keeps such a span the harmless no-op it was on HEAD while the
455
+ * group still merges stderr for real spans. */
456
+ function shSpan(binary: string, projectDir: string, script: string): SpanExec {
457
+ const quoted = projectDir.replaceAll("'", String.raw`'\''`)
458
+ return { command: binary, args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\nexport CLAUDECODE=1\n{ :\n${script}\n} 2>&1`] }
459
+ }
460
+
461
+ /** The PowerShell invocation for a span. No in-script 2>&1: under pwsh 7 it does not
462
+ * merge a native command's stderr on a script block, so mergeStreams has the caller
463
+ * append it. The trailing exit forwards a failed native command's code, which pwsh
464
+ * -Command otherwise swallows (the process exited 0 and a failure never aborted the
465
+ * invocation). An empty or cmdlet-only span leaves $LASTEXITCODE unset and exits 0.
466
+ * Residual gap vs sh: a failing cmdlet sets no exit code, so it cannot abort; its
467
+ * error text still reaches the model through the merged stderr. */
468
+ function powershellSpan(binary: string, projectDir: string, script: string): SpanExec {
469
+ const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'\n$env:CLAUDECODE='1'`
470
+ return { command: binary, args: ['-NoProfile', '-NonInteractive', '-Command', `${preamble}\n& {\n${script}\n}\nexit $LASTEXITCODE`], mergeStreams: true }
471
+ }
472
+
467
473
  /**
468
- * The exec invocation for one injected span, honoring the `shell:` frontmatter.
469
- * The default (absent or `bash`) runs through /bin/sh; `powershell` resolves a
470
- * PowerShell binary and runs the span with -Command, falling back to /bin/sh when
471
- * none is installed so the command still works, per Claude's shell matrix. Both
472
- * paths export CLAUDE_PROJECT_DIR (each shell's own quoting) and merge stderr
473
- * into stdout, as the Bash tool does when it runs these for Claude: the sh script
474
- * in-line with 2>&1, the pwsh path via mergeStreams in the caller.
474
+ * The exec invocation for one injected span, honoring the `shell:` frontmatter per
475
+ * Claude's shell matrix (skills.md). `powershell` runs through a PowerShell binary when
476
+ * one resolves. Otherwise the span runs through bash: /bin/sh off Windows, Git Bash on
477
+ * Windows. Without Git Bash, a skill that declared `shell: bash` fails before any
478
+ * command runs ("requires bash"), an undeclared one falls to PowerShell, and with
479
+ * neither shell the invocation fails. Both paths export CLAUDE_PROJECT_DIR (each
480
+ * shell's own quoting) and merge stderr into stdout, as the Bash tool does when it
481
+ * runs these for Claude: the sh script in-line with 2>&1, the pwsh path via
482
+ * mergeStreams in the caller.
475
483
  *
476
- * The resolver is a parameter rather than a default so the caller passes its own
477
- * imported binding, which keeps the lookup mockable in tests.
484
+ * The resolvers are parameters so a caller (or test) controls the lookups: the
485
+ * PowerShell one is passed as an imported binding, the bash one defaults to the
486
+ * platform rule.
478
487
  */
479
- export function spanExec(shell: string | undefined, projectDir: string, script: string, resolveBinary: () => string | undefined): SpanExec {
488
+ export function spanExec(shell: string | undefined, projectDir: string, script: string, resolveBinary: () => string | undefined, resolveBash: () => string | undefined = bashBinary): SpanExec {
480
489
  if (shell === 'powershell') {
481
490
  const binary = resolveBinary()
482
- if (binary !== undefined) {
483
- // CLAUDECODE=1 marks every subprocess Claude spawns; pi.exec takes no env, so
484
- // it is exported in the script alongside CLAUDE_PROJECT_DIR.
485
- const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'\n$env:CLAUDECODE='1'`
486
- // No in-script 2>&1: under pwsh 7 it does not merge a native command's
487
- // stderr on a script block, so mergeStreams has the caller append it. The
488
- // trailing exit forwards a failed native command's code, which pwsh
489
- // -Command otherwise swallows (the process exited 0 and a failure never
490
- // aborted the invocation). An empty or cmdlet-only span leaves
491
- // $LASTEXITCODE unset and exits 0. Residual gap vs sh: a failing cmdlet
492
- // sets no exit code, so it cannot abort; its error text still reaches the
493
- // model through the merged stderr.
494
- return { command: binary, args: ['-NoProfile', '-NonInteractive', '-Command', `${preamble}\n& {\n${script}\n}\nexit $LASTEXITCODE`], mergeStreams: true }
495
- }
491
+ if (binary !== undefined) return powershellSpan(binary, projectDir, script)
496
492
  }
497
- const quoted = projectDir.replaceAll("'", String.raw`'\''`)
498
- // The group opens with a `:` null command: `{ }` around an empty or
499
- // comment-only span is a hard sh syntax error (exit 2) that aborted the whole
500
- // invocation, and `:` keeps such a span the harmless no-op it was on HEAD
501
- // while the group still merges stderr for real spans.
502
- return { command: '/bin/sh', args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\nexport CLAUDECODE=1\n{ :\n${script}\n} 2>&1`] }
493
+ const bash = resolveBash()
494
+ if (bash !== undefined) return shSpan(bash, projectDir, script)
495
+ if (shell === 'bash') throw new Error('shell: bash requires Git Bash, which was not found (install Git for Windows or set CLAUDE_CODE_GIT_BASH_PATH)')
496
+ const binary = resolveBinary()
497
+ if (binary !== undefined) return powershellSpan(binary, projectDir, script)
498
+ throw new Error('no shell found for the injected commands: install Git for Windows or PowerShell')
503
499
  }
504
500
 
505
501
  interface FenceBlock {
@@ -511,20 +507,27 @@ interface FenceBlock {
511
507
  }
512
508
 
513
509
  /** Fenced blocks of a body: Claude's dynamic syntax is literal text inside a plain
514
- * fence, while a ```! fence is itself a placeholder that executes. */
510
+ * fence, while a ```! fence is itself a placeholder that executes. Fences follow
511
+ * CommonMark: any indentation, closed only by the opener's character in a run at
512
+ * least as long, so a tilde line or a shorter fence inside stays content. */
515
513
  function fenceBlocks(body: string): FenceBlock[] {
516
514
  const blocks: FenceBlock[] = []
517
- const fence = /^(```|~~~)([^\n]*)$/gm
515
+ let fence: Fence | null = null
518
516
  let open: { index: number; exec: boolean; contentStart: number } | undefined
519
- let match = fence.exec(body)
520
- while (match !== null) {
521
- if (open === undefined) {
522
- open = { index: match.index, exec: match[1] === '```' && match[2].trim() === '!', contentStart: match.index + match[0].length + 1 }
523
- } else {
524
- blocks.push({ start: open.index, end: match.index + match[0].length, exec: open.exec, content: body.slice(Math.min(open.contentStart, match.index), match.index).replace(/\n$/, '') })
517
+ let offset = 0
518
+ for (const line of body.split('\n')) {
519
+ const trimmed = line.trimStart()
520
+ const step = stepFence(fence, trimmed, fenceMarker(trimmed))
521
+ const lineEnd = offset + line.length
522
+ if (fence === null && step.fence !== null) {
523
+ // Only the exact, unindented ```! opener executes, as Claude documents it.
524
+ open = { index: offset, exec: line.startsWith('```') && step.fence.length === 3 && trimmed.slice(3).trim() === '!', contentStart: lineEnd + 1 }
525
+ } else if (fence !== null && step.fence === null && open !== undefined) {
526
+ blocks.push({ start: open.index, end: lineEnd, exec: open.exec, content: body.slice(Math.min(open.contentStart, offset), offset).replace(/\n$/, '') })
525
527
  open = undefined
526
528
  }
527
- match = fence.exec(body)
529
+ fence = step.fence
530
+ offset = lineEnd + 1
528
531
  }
529
532
  // An unterminated fence protects to the end of the body rather than executing.
530
533
  if (open !== undefined) blocks.push({ start: open.index, end: body.length, exec: false, content: '' })
@@ -563,6 +566,10 @@ export function benignExitOne(command: string, shell: SpanShell = 'bash'): boole
563
566
  * documents: the model never sees a half-expanded body. */
564
567
  async function runSpan(exec: CommandExec, command: string, pattern: string, shell: SpanShell): Promise<string> {
565
568
  const result = await exec(command)
569
+ // A timeout kill arrives as killed:true with code 0 (a signal death has no exit code),
570
+ // so the code alone would paste the partial output as a success. Claude kills a span
571
+ // at the Bash timeout and that failure aborts the invocation.
572
+ if (result.killed) throw new Error(`Shell command timed out for pattern "${pattern}"`)
566
573
  if (result.code !== 0 && !(result.code === 1 && benignExitOne(command, shell))) {
567
574
  throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
568
575
  }
@@ -15,12 +15,13 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from '
15
15
  /**
16
16
  * Trim `text` to a byte budget. `String.slice` counts UTF-16 units, so slicing a CJK
17
17
  * string by a byte budget keeps up to three times the bytes asked for; cutting the
18
- * encoded buffer is exact. A character straddling the cut decodes to U+FFFD.
19
- * Shorter input comes back whole and a negative budget yields nothing, so callers
20
- * need no length check of their own.
18
+ * encoded buffer is exact. A character straddling the cut is dropped (the streaming
19
+ * decoder holds back an incomplete sequence instead of emitting U+FFFD), so the
20
+ * result is whole characters within the budget. Shorter input comes back whole and a
21
+ * negative budget yields nothing, so callers need no length check of their own.
21
22
  */
22
- function sliceBytes(text: string, maxBytes: number): string {
23
- return Buffer.from(text, 'utf-8').subarray(0, Math.max(0, maxBytes)).toString('utf-8')
23
+ export function sliceBytes(text: string, maxBytes: number): string {
24
+ return new TextDecoder().decode(Buffer.from(text, 'utf-8').subarray(0, Math.max(0, maxBytes)), { stream: true })
24
25
  }
25
26
 
26
27
  /** Trim `text` to pi's documented tool-output budget, noting what was dropped. */
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Which shell runs a command string, per Claude's contract. hooks.md: a shell-form
3
+ * command is "passed to a shell: `sh -c` on macOS and Linux, Git Bash on Windows, or
4
+ * PowerShell when Git Bash isn't installed", and a hook's `shell: "powershell"` runs it
5
+ * via PowerShell (pwsh, then Windows PowerShell 5.1). statusline.md applies the same
6
+ * Git Bash-then-PowerShell rule to statusLine commands. troubleshoot-install.md: when
7
+ * CLAUDE_CODE_GIT_BASH_PATH is unset, Git Bash is looked for in `C:\Program Files\Git`
8
+ * and `C:\Program Files (x86)\Git`, then through the `git` on PATH (the `bin\bash.exe`
9
+ * of that installation), skipping a git in the launch directory or below it under
10
+ * node_modules or a virtual environment. env-vars.md: the override is ignored unless
11
+ * the file exists and is named bash.exe, sh.exe, bash, or sh.
12
+ */
13
+
14
+ import * as fs from 'node:fs'
15
+ import * as path from 'node:path'
16
+
17
+ export interface ResolvedShell {
18
+ kind: 'bash' | 'powershell'
19
+ file: string
20
+ /** The argv that runs `command` through this shell. */
21
+ argsFor: (command: string) => string[]
22
+ }
23
+
24
+ const GIT_BASH_NAMES = new Set(['bash.exe', 'sh.exe', 'bash', 'sh'])
25
+ const DEFAULT_GIT_ROOTS = [String.raw`C:\Program Files\Git`, String.raw`C:\Program Files (x86)\Git`]
26
+ const PROJECT_TOOLING_DIRS = new Set(['node_modules', '.venv', 'venv'])
27
+
28
+ const isFile = (file: string): boolean => {
29
+ try {
30
+ return fs.statSync(file).isFile()
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
36
+ /** The PowerShell names worth trying: pwsh everywhere it installs, plus the
37
+ * Windows spellings on win32, where powershell.exe ships with the OS. */
38
+ const powershellCandidates = (platform: string): string[] => (platform === 'win32' ? ['pwsh', 'pwsh.exe', 'powershell.exe'] : ['pwsh'])
39
+
40
+ /** First PowerShell binary found on PATH, or undefined when none is installed. */
41
+ export function resolvePowershellBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env): string | undefined {
42
+ const dirs = (env.PATH ?? '').split(path.delimiter).filter(Boolean)
43
+ for (const candidate of powershellCandidates(platform)) {
44
+ for (const dir of dirs) {
45
+ const full = path.join(dir, candidate)
46
+ try {
47
+ fs.accessSync(full, fs.constants.X_OK)
48
+ if (fs.statSync(full).isFile()) return full
49
+ } catch {
50
+ // not here; keep looking
51
+ }
52
+ }
53
+ }
54
+ return undefined
55
+ }
56
+
57
+ /** A git in the launch directory itself, or below it under node_modules or a virtual
58
+ * environment, is a project's own tooling rather than the user's Git installation. */
59
+ function isProjectTooling(dir: string, cwd: string): boolean {
60
+ const relative = path.relative(cwd, dir)
61
+ if (relative === '') return true
62
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return false
63
+ return relative.split(path.sep).some((segment) => PROJECT_TOOLING_DIRS.has(segment))
64
+ }
65
+
66
+ /** Git Bash in Claude's documented order, or undefined when none is installed.
67
+ * `installRoots` is the default install location list, a parameter so tests on a
68
+ * Windows host that has Git there can still exercise the later rules. */
69
+ export function resolveGitBash(env: Record<string, string | undefined> = process.env, cwd: string = process.cwd(), installRoots: string[] = DEFAULT_GIT_ROOTS): string | undefined {
70
+ const override = env.CLAUDE_CODE_GIT_BASH_PATH
71
+ if (override && GIT_BASH_NAMES.has(path.basename(override).toLowerCase()) && isFile(override)) return override
72
+ for (const root of installRoots) {
73
+ const bash = path.join(root, 'bin', 'bash.exe')
74
+ if (isFile(bash)) return bash
75
+ }
76
+ for (const dir of (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean)) {
77
+ if (!isFile(path.join(dir, 'git.exe')) || isProjectTooling(dir, cwd)) continue
78
+ // Git for Windows puts git.exe in cmd\ (or bin\); bash.exe lives in bin\ of the same install.
79
+ const bash = path.join(path.dirname(dir), 'bin', 'bash.exe')
80
+ if (isFile(bash)) return bash
81
+ }
82
+ return undefined
83
+ }
84
+
85
+ /** Claude rewrites these three placeholders in a PowerShell shell-form command to
86
+ * PowerShell's `${env:NAME}` form; the bare `$NAME` spelling is left alone (PowerShell
87
+ * reads it as an undefined variable, which Claude only warns about). */
88
+ export const toPowershellPlaceholders = (command: string): string => command.replace(/\$\{(CLAUDE_PROJECT_DIR|CLAUDE_PLUGIN_ROOT|CLAUDE_PLUGIN_DATA)\}/g, (_match, name: string) => `\${env:${name}}`)
89
+
90
+ /** The bash for an injected command span: /bin/sh off Windows, Git Bash on Windows
91
+ * (undefined when it is not installed). */
92
+ export function bashBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env, cwd: string = process.cwd(), installRoots?: string[]): string | undefined {
93
+ return platform === 'win32' ? resolveGitBash(env, cwd, installRoots) : '/bin/sh'
94
+ }
95
+
96
+ const bashShell = (file: string): ResolvedShell => ({ kind: 'bash', file, argsFor: (command) => ['-c', command] })
97
+
98
+ const powershellShell = (file: string): ResolvedShell => ({
99
+ kind: 'powershell',
100
+ file,
101
+ // -Command swallows a native command's exit code unless it is forwarded; a script
102
+ // that calls `exit` itself never reaches the trailer, so its own code stands.
103
+ argsFor: (command) => ['-NoProfile', '-NonInteractive', '-Command', `${toPowershellPlaceholders(command)}\nexit $LASTEXITCODE`],
104
+ })
105
+
106
+ /**
107
+ * The shell for a command string. `preferred` is a hook's `shell` field: "powershell"
108
+ * runs through PowerShell where one is installed. Otherwise /bin/sh off Windows; on
109
+ * Windows Git Bash, then PowerShell. Undefined means nothing on this machine can run
110
+ * it (Windows with neither Git Bash nor PowerShell installed).
111
+ */
112
+ export function resolveShell(preferred: string | undefined, platform: string = process.platform, env: Record<string, string | undefined> = process.env, cwd: string = process.cwd(), installRoots?: string[]): ResolvedShell | undefined {
113
+ if (preferred === 'powershell') {
114
+ const powershell = resolvePowershellBinary(platform, env)
115
+ if (powershell) return powershellShell(powershell)
116
+ }
117
+ const bash = bashBinary(platform, env, cwd, installRoots)
118
+ if (bash) return bashShell(bash)
119
+ const powershell = resolvePowershellBinary(platform, env)
120
+ return powershell ? powershellShell(powershell) : undefined
121
+ }
@@ -11,7 +11,7 @@
11
11
  * fenced code block (backtick or tilde).
12
12
  */
13
13
 
14
- /** The fence a line opens or closes, if any; mirrors context-imports. */
14
+ /** The fence a line opens or closes, if any. */
15
15
  export function fenceMarker(lineStart: string): string | null {
16
16
  if (lineStart.startsWith('```')) return '`'
17
17
  if (lineStart.startsWith('~~~')) return '~'
@@ -29,14 +29,14 @@ function fenceLength(lineStart: string, marker: string): number {
29
29
  // is at least as long as the opener, so both are tracked: a shorter same-char
30
30
  // fence line (the classic 3-backtick block quoted inside a 4-backtick one) is
31
31
  // content, not a closer.
32
- interface Fence {
32
+ export interface Fence {
33
33
  marker: string
34
34
  length: number
35
35
  }
36
36
 
37
37
  /** The fence state after a line, plus whether the line is fenced code (opener,
38
38
  * body, or closer) and so emitted verbatim rather than scanned for comments. */
39
- function stepFence(fence: Fence | null, trimmed: string, marker: string | null): { fence: Fence | null; fenced: boolean } {
39
+ export function stepFence(fence: Fence | null, trimmed: string, marker: string | null): { fence: Fence | null; fenced: boolean } {
40
40
  if (marker !== null && fence === null) {
41
41
  return { fence: { marker, length: fenceLength(trimmed, marker) }, fenced: true }
42
42
  }
@@ -16,6 +16,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
16
16
  import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'
17
17
  import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
18
18
  import { FileOAuthProvider, type OAuthServerConfig } from '../internal/mcp-oauth.js'
19
+ import { resolveShell } from '../internal/shell-resolve.js'
19
20
  import { expandCwd, type HttpServerConfig, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
20
21
  import { runInteractiveOAuth, serializeInteractiveOAuth } from './oauth-flow.js'
21
22
 
@@ -306,11 +307,17 @@ export async function connectWithRetries(name: string, config: ServerConfig, aut
306
307
  }
307
308
 
308
309
  /** Run a headersHelper command and parse its JSON stdout into headers, under the
309
- * environment helperEnv built. A failure or a 10s timeout yields no extra headers
310
- * rather than blocking the connection. */
310
+ * environment helperEnv built. It runs through the platform shell (/bin/sh; Git Bash
311
+ * or PowerShell on Windows). A failure, a 10s timeout, or a machine with no shell
312
+ * yields no extra headers rather than blocking the connection. */
311
313
  function runHeadersHelper(command: string, env: NodeJS.ProcessEnv): Promise<Record<string, string>> {
312
314
  return new Promise((resolve) => {
313
- execFile('/bin/sh', ['-c', command], { timeout: 10_000, env }, (error, stdout) => {
315
+ const shell = resolveShell(undefined)
316
+ if (!shell) {
317
+ resolve({})
318
+ return
319
+ }
320
+ execFile(shell.file, shell.argsFor(command), { timeout: 10_000, env }, (error, stdout) => {
314
321
  resolve(error ? {} : parseHelperHeaders(stdout))
315
322
  })
316
323
  })
@@ -429,7 +429,9 @@ After completing a step, include a [DONE:n] tag in your response.`,
429
429
  planModeEnabled = true
430
430
  }
431
431
 
432
- const entries = ctx.sessionManager.getEntries()
432
+ // The current branch only: getEntries() lists every branch in the file, so after a
433
+ // rewind past a plan it would resurrect the abandoned plan and its tool restriction.
434
+ const entries = ctx.sessionManager.getBranch()
433
435
 
434
436
  // Restore persisted state
435
437
  const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean; savedTools?: string[] } } | undefined
@@ -179,7 +179,7 @@ export default function question(pi: ExtensionAPI) {
179
179
 
180
180
  renderCall(args, theme, _context) {
181
181
  const multi = args.multiSelect === true
182
- const heading = args.header ? `[${args.header}] ` : ''
182
+ const heading = args.header ? `[${shortHeader(String(args.header))}] ` : ''
183
183
  let text = theme.fg('toolTitle', theme.bold('question ')) + theme.fg('muted', heading + String(args.question ?? ''))
184
184
  const opts = Array.isArray(args.options) ? args.options : []
185
185
  if (opts.length) {
@@ -24,6 +24,8 @@ export interface BackgroundRun {
24
24
  stderr?: string
25
25
  /** Claude's partial marker: the run stopped at its maxTurns limit. */
26
26
  partial?: boolean
27
+ /** Temp dir holding a prompt file rebuilt for a resume; removed with the run. */
28
+ rebuiltPromptDir?: string
27
29
  /** Set while running so the run can be cancelled; cleared on completion. */
28
30
  kill?: () => void
29
31
  /** True until the child process actually closes: a cancelled child that ignores
@@ -65,7 +67,7 @@ export const MAX_FINISHED_RUNS = 20
65
67
  /** Grace between the cancel SIGTERM and the SIGKILL that ends a child ignoring it. */
66
68
  const CANCEL_KILL_GRACE_MS = 5000
67
69
 
68
- /** Bytes of stderr kept per run, enough for the boot error without buffering logs. */
70
+ /** Characters of stderr kept per run, enough for the boot error without buffering logs. */
69
71
  const STDERR_TAIL_CHARS = 2048
70
72
 
71
73
  export function activeBackgroundRuns(): number {
@@ -79,6 +81,7 @@ let finishSequence = 0
79
81
  /** Test seam: the registry is module state, so tests reset it between cases to
80
82
  * stay order-independent. */
81
83
  export function resetBackgroundRuns(): void {
84
+ for (const run of runs.values()) removeRebuiltPrompt(run)
82
85
  runs.clear()
83
86
  finishSequence = 0
84
87
  }
@@ -86,7 +89,10 @@ export function resetBackgroundRuns(): void {
86
89
  function evictFinishedRuns(): void {
87
90
  const finished = [...runs.values()].filter((run) => !run.live && run.state !== 'running')
88
91
  finished.sort((a, b) => (a.finishedAt ?? 0) - (b.finishedAt ?? 0))
89
- for (const stale of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_RUNS))) runs.delete(stale.id)
92
+ for (const stale of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_RUNS))) {
93
+ removeRebuiltPrompt(stale)
94
+ runs.delete(stale.id)
95
+ }
90
96
  }
91
97
 
92
98
  /** Line-by-line parser keeping only the last assistant text and a turn count, so a
@@ -162,6 +168,8 @@ export function cancelAllBackgroundRuns(): number {
162
168
  for (const id of Array.from(runs.keys())) {
163
169
  if (cancelBackgroundRun(id) === 'cancelled') count++
164
170
  }
171
+ // Called at quit: nothing in this registry is resumable once pi exits.
172
+ for (const run of runs.values()) removeRebuiltPrompt(run)
165
173
  return count
166
174
  }
167
175
 
@@ -190,38 +198,53 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
190
198
  // Persisted so the rebuild happens once: rebuilding per resume leaked one temp
191
199
  // prompt dir every follow-up.
192
200
  const rebuilt = withRebuiltPrompt(run.spawn)
193
- run.spawn = { ...run.spawn, args: rebuilt }
194
- const args = rebuilt.map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
201
+ run.spawn = { ...run.spawn, args: rebuilt.args }
202
+ if (rebuilt.dir) run.rebuiltPromptDir = rebuilt.dir
203
+ const args = rebuilt.args.map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
195
204
  run.state = 'running'
196
205
  run.task = task
197
206
  run.output = undefined
198
207
  run.exitCode = undefined
199
208
  run.stderr = undefined
200
209
  run.finishedAt = undefined
210
+ // Both belong to the child that ran: a resume that dies before its first turn would
211
+ // otherwise report the previous count, and a clean follow-up to a maxTurns-capped run
212
+ // would still be offered as partial.
213
+ run.turns = 0
214
+ run.partial = undefined
201
215
  driveRun(run, { ...run.spawn, args }, onComplete)
202
216
  return 'resumed'
203
217
  }
204
218
 
205
- /** Re-point --system-prompt at a fresh file when the original is gone. */
206
- function withRebuiltPrompt(spawnSpec: BackgroundSpawn): string[] {
219
+ /** Re-point --system-prompt at a fresh file when the original is gone; `dir` is the
220
+ * temp dir created for it, which the run then owns. */
221
+ function withRebuiltPrompt(spawnSpec: BackgroundSpawn): { args: string[]; dir?: string } {
207
222
  const flag = spawnSpec.args.indexOf('--system-prompt')
208
- if (flag === -1 || !spawnSpec.promptBody) return spawnSpec.args
223
+ if (flag === -1 || !spawnSpec.promptBody) return { args: spawnSpec.args }
209
224
  const current = spawnSpec.args[flag + 1]
210
- if (current && fs.existsSync(current)) return spawnSpec.args
225
+ if (current && fs.existsSync(current)) return { args: spawnSpec.args }
211
226
  try {
212
227
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-subagent-'))
213
228
  const file = path.join(dir, 'prompt.md')
214
229
  fs.writeFileSync(file, spawnSpec.promptBody, { mode: 0o600 })
215
230
  const rebuilt = [...spawnSpec.args]
216
231
  rebuilt[flag + 1] = file
217
- return rebuilt
232
+ return { args: rebuilt, dir }
218
233
  } catch {
219
234
  // Cannot rewrite it: drop the pair rather than hand pi a path it will treat as
220
235
  // prompt text, which would replace the agent persona with a temp path.
221
- return spawnSpec.args.filter((_arg, i) => i !== flag && i !== flag + 1)
236
+ return { args: spawnSpec.args.filter((_arg, i) => i !== flag && i !== flag + 1) }
222
237
  }
223
238
  }
224
239
 
240
+ /** The rebuilt prompt lives as long as its run can be resumed, so it goes when the run
241
+ * leaves the registry: eviction, quit, or the test reset. */
242
+ function removeRebuiltPrompt(run: BackgroundRun): void {
243
+ if (!run.rebuiltPromptDir) return
244
+ fs.rmSync(run.rebuiltPromptDir, { recursive: true, force: true })
245
+ run.rebuiltPromptDir = undefined
246
+ }
247
+
225
248
  export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void, presetId?: string): string | null {
226
249
  // Checked here, synchronously with registration: callers await temp-file writes
227
250
  // between any check of their own and this call, so a parallel tool-call batch
@@ -387,8 +387,12 @@ export default function todoExtension(pi: ExtensionAPI) {
387
387
  content: [{ type: 'text' as const, text }],
388
388
  details,
389
389
  })
390
- const ok = (action: TodoAction, text: string) => toolMessage(text, { action, todos: [...todos], nextId })
391
- const fail = (action: TodoAction, error: string) => toolMessage(`Error: ${error}`, { action, todos: [...todos], nextId, error })
390
+ // pi keeps a result's details by reference in the live session, and start/complete
391
+ // mutate the todo objects in place, so each result carries its own copies: without
392
+ // them a rewind replays, and the TUI re-renders, today's statuses on earlier calls.
393
+ const snapshot = () => todos.map((t) => ({ ...t }))
394
+ const ok = (action: TodoAction, text: string) => toolMessage(text, { action, todos: snapshot(), nextId })
395
+ const fail = (action: TodoAction, error: string) => toolMessage(`Error: ${error}`, { action, todos: snapshot(), nextId, error })
392
396
 
393
397
  const handleAdd = (params: TodoParamsType) => {
394
398
  if (!params.text) return fail('add', 'text required for add')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.48",
3
+ "version": "1.0.50",
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, subagents, and goals",
5
5
  "keywords": [
6
6
  "pi",