pi-code 1.0.48 → 1.0.49

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
@@ -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
@@ -511,20 +511,27 @@ interface FenceBlock {
511
511
  }
512
512
 
513
513
  /** 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. */
514
+ * fence, while a ```! fence is itself a placeholder that executes. Fences follow
515
+ * CommonMark: any indentation, closed only by the opener's character in a run at
516
+ * least as long, so a tilde line or a shorter fence inside stays content. */
515
517
  function fenceBlocks(body: string): FenceBlock[] {
516
518
  const blocks: FenceBlock[] = []
517
- const fence = /^(```|~~~)([^\n]*)$/gm
519
+ let fence: Fence | null = null
518
520
  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$/, '') })
521
+ let offset = 0
522
+ for (const line of body.split('\n')) {
523
+ const trimmed = line.trimStart()
524
+ const step = stepFence(fence, trimmed, fenceMarker(trimmed))
525
+ const lineEnd = offset + line.length
526
+ if (fence === null && step.fence !== null) {
527
+ // Only the exact, unindented ```! opener executes, as Claude documents it.
528
+ open = { index: offset, exec: line.startsWith('```') && step.fence.length === 3 && trimmed.slice(3).trim() === '!', contentStart: lineEnd + 1 }
529
+ } else if (fence !== null && step.fence === null && open !== undefined) {
530
+ blocks.push({ start: open.index, end: lineEnd, exec: open.exec, content: body.slice(Math.min(open.contentStart, offset), offset).replace(/\n$/, '') })
525
531
  open = undefined
526
532
  }
527
- match = fence.exec(body)
533
+ fence = step.fence
534
+ offset = lineEnd + 1
528
535
  }
529
536
  // An unterminated fence protects to the end of the body rather than executing.
530
537
  if (open !== undefined) blocks.push({ start: open.index, end: body.length, exec: false, content: '' })
@@ -563,6 +570,10 @@ export function benignExitOne(command: string, shell: SpanShell = 'bash'): boole
563
570
  * documents: the model never sees a half-expanded body. */
564
571
  async function runSpan(exec: CommandExec, command: string, pattern: string, shell: SpanShell): Promise<string> {
565
572
  const result = await exec(command)
573
+ // A timeout kill arrives as killed:true with code 0 (a signal death has no exit code),
574
+ // so the code alone would paste the partial output as a success. Claude kills a span
575
+ // at the Bash timeout and that failure aborts the invocation.
576
+ if (result.killed) throw new Error(`Shell command timed out for pattern "${pattern}"`)
566
577
  if (result.code !== 0 && !(result.code === 1 && benignExitOne(command, shell))) {
567
578
  throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
568
579
  }
@@ -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. */
@@ -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
  }
@@ -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.49",
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",