pi-code 1.0.67 → 1.0.69

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.
@@ -168,6 +168,8 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
168
168
  // assistant turns it drives. before_agent_start starts a run; the first turn_start
169
169
  // then snapshots and clears this, so turns 2..n skip the wasted git work.
170
170
  let runNeedsSnapshot = true
171
+ /** Whether the run about to start came from a prompt (see before_agent_start below). */
172
+ let promptedRun = false
171
173
  let shadowDir: string | undefined
172
174
  let workTree: string | undefined
173
175
  // Absolute paths this session's edit tools targeted: the whole of what a checkpoint
@@ -219,6 +221,17 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
219
221
  await mirrorLocalExcludes(ctx)
220
222
  }
221
223
 
224
+ /** /fork writes a new session file and copies the branch's checkpoint entries into it,
225
+ * while the shadow repository is keyed to the session file: the refs those entries name
226
+ * live in the parent's shadow, and a restore from the fork's own answered "invalid
227
+ * reference". A local bare-to-bare fetch brings them across. Best effort: a parent
228
+ * shadow already pruned leaves those checkpoints unrestorable, as they were. */
229
+ async function inheritCheckpointRefs(previousSessionFile: string): Promise<void> {
230
+ const parentShadow = path.join(getAgentDir(), 'checkpoints', sessionSlug(previousSessionFile))
231
+ if (parentShadow === shadowDir || !fs.existsSync(parentShadow)) return
232
+ await gitShadow(['fetch', '--quiet', parentShadow, `+${CHECKPOINT_REF_PREFIX}/*:${CHECKPOINT_REF_PREFIX}/*`])
233
+ }
234
+
222
235
  /** git reads ignore rules from the tree's .gitignore files, the user's global excludes,
223
236
  * and $GIT_DIR/info/exclude. The shadow is the GIT_DIR here, so the repo's own
224
237
  * .git/info/exclude (where secrets and scratch that must never be committed live)
@@ -308,12 +321,29 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
308
321
  /** Fold a file's pre-edit content into the run's checkpoint. A file first edited part
309
322
  * way through a run is absent from that run's pre-run snapshot, so without this its
310
323
  * checkpoint holds no baseline and /rewind cannot undo that first edit. */
324
+ /** `file` named by the real directory it sits in, when a symlink inside the work tree
325
+ * leads there. git refuses a pathspec that passes through a symlink ("beyond a symbolic
326
+ * link"), so the file has to be tracked where it really is. The real directory is mapped
327
+ * back under the work tree as the session spells it: a cwd that is itself a symlinked
328
+ * path (macOS /var) must not push every file outside the tree. */
329
+ function throughSymlinks(file: string): string {
330
+ if (!workTree) return file
331
+ try {
332
+ const realTree = fs.realpathSync(workTree)
333
+ const realDir = fs.realpathSync(path.dirname(file))
334
+ const inside = path.relative(realTree, realDir)
335
+ if (inside.startsWith('..') || path.isAbsolute(inside)) return file
336
+ return path.join(workTree, inside, path.basename(file))
337
+ } catch {
338
+ return file
339
+ }
340
+ }
341
+
311
342
  async function captureBaseline(file: string): Promise<void> {
312
343
  const rel = workTreePath(file)
313
344
  if (!runRef || rel === undefined || !fs.existsSync(file)) return
314
345
  if ((await withoutIgnored([rel])).length === 0) return
315
- const add = await gitShadow(['add', '--', rel])
316
- if (add.code !== 0) return
346
+ if (!(await addPaths([rel]))) return
317
347
  // A commit, never an amend: the run's checkpoint can be a commit an earlier
318
348
  // checkpoint also points at, and rewriting it would strand that one.
319
349
  const commit = await commitShadow([])
@@ -329,13 +359,50 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
329
359
  return files.code === 0 && files.stdout.trim() === ''
330
360
  }
331
361
 
362
+ /** Stage what `git add` silently skipped. For a path under another repository (a
363
+ * workspace of clones, a submodule, $HOME) it exits 0 and stages nothing, so the edit
364
+ * was never snapshotted while /rewind still reported success. Such a file is written to
365
+ * the index directly, which `checkout` restores like any other entry. A deletion needs
366
+ * nothing here: `git add` does stage the removal of an entry the index already holds. */
367
+ async function stageSkippedPaths(paths: string[]): Promise<void> {
368
+ if (!workTree) return
369
+ const listed = await gitShadow(['ls-files', '-z', '--', ...paths])
370
+ if (listed.code !== 0) return
371
+ const indexed = new Set(listed.stdout.split('\0').filter(Boolean))
372
+ for (const rel of paths) {
373
+ if (!indexed.has(rel) && fs.existsSync(path.join(workTree, rel))) await indexDirectly(rel)
374
+ }
375
+ }
376
+
377
+ async function indexDirectly(rel: string): Promise<void> {
378
+ if (!workTree) return
379
+ const blob = await gitShadow(['hash-object', '-w', '--', rel])
380
+ if (blob.code !== 0) return
381
+ const executable = (fs.statSync(path.join(workTree, rel)).mode & 0o111) !== 0
382
+ await gitShadow(['update-index', '--add', '--cacheinfo', `${executable ? '100755' : '100644'},${blob.stdout.trim()},${rel}`])
383
+ }
384
+
385
+ /** Stage the tracked paths. git fails the whole add when one pathspec is refused, which
386
+ * used to end checkpointing for the session over a single path. On a failure each path
387
+ * is added alone and one git still refuses is dropped from the edit set, so it costs
388
+ * its own checkpoint and nobody else's. False only when nothing could be staged. */
389
+ async function addPaths(paths: string[]): Promise<boolean> {
390
+ if ((await gitShadow(['add', '--', ...paths])).code === 0) {
391
+ await stageSkippedPaths(paths)
392
+ return true
393
+ }
394
+ let staged = 0
395
+ for (const rel of paths) {
396
+ if ((await gitShadow(['add', '--', rel])).code === 0) staged++
397
+ else if (workTree) touched.delete(path.resolve(workTree, rel))
398
+ }
399
+ return staged > 0
400
+ }
401
+
332
402
  async function snapshot(): Promise<{ ref: string; createdAt: string } | undefined> {
333
403
  const createdAt = new Date().toISOString()
334
404
  const paths = await addablePaths()
335
- if (paths.length > 0) {
336
- const add = await gitShadow(['add', '--', ...paths])
337
- if (add.code !== 0) return undefined
338
- }
405
+ if (paths.length > 0 && !(await addPaths(paths))) return undefined
339
406
  // Decide "nothing changed" from the index, not from the commit exit code: a commit can
340
407
  // also fail on the user's global signing or hooks config, and reusing HEAD then would
341
408
  // record a ref that predates the current tree, so /rewind restores the wrong state.
@@ -386,7 +453,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
386
453
  ctx.ui.notify('Rewind complete', 'info')
387
454
  }
388
455
 
389
- pi.on('session_start', async (_event, ctx) => {
456
+ pi.on('session_start', async (event, ctx) => {
390
457
  // One extension instance serves every session. A mid-turn /new fires session_start on
391
458
  // the same instance after turn_start took the pre-run snapshot but before turn_end saved
392
459
  // it; that pending ref belongs to the previous session and must not attach to the next
@@ -394,8 +461,11 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
394
461
  // own tree even though the prior run left it false.
395
462
  pending = undefined
396
463
  runNeedsSnapshot = true
464
+ promptedRun = false
397
465
  runRef = undefined
398
466
  await ensureShadow(ctx)
467
+ const forkedFrom = event.reason === 'fork' ? event.previousSessionFile : undefined
468
+ if (forkedFrom) await inheritCheckpointRefs(forkedFrom)
399
469
  touched.clear()
400
470
  for (const rel of await committedPaths()) touched.add(path.resolve(ctx.cwd, rel))
401
471
  checkpoints.clear()
@@ -425,13 +495,39 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
425
495
  // the checkpoint is only keyed and saved at turn_end. The snapshot is awaited here so
426
496
  // `git add -A` captures the tree before the model's first edit; turn_end reads the
427
497
  // resolved value.
428
- pi.on('turn_start', async () => {
498
+ // Only a prompt fires before_agent_start. A provider retry, an overflow recovery and a
499
+ // queued follow-up all re-enter through agent.continue(), with agent_start alone.
500
+ pi.on('before_agent_start', async () => {
501
+ promptedRun = true
502
+ })
503
+
504
+ /** The checkpoint a continued run is still working under: it re-entered with no new user
505
+ * message, so its prompt already has one. A snapshot taken there stages every tracked
506
+ * file at its MID-run content into the index the recorded checkpoint is later extended
507
+ * from (a baseline captured afterwards moved the checkpoint onto that content), and a
508
+ * file first edited in that turn had its baseline folded into the discarded ref. */
509
+ function continuedCheckpoint(ctx: ExtensionContext): Checkpoint | undefined {
510
+ // A queued follow-up is a new user message and needs its own snapshot. Optional: the
511
+ // peer range reaches runtimes that may not have the method.
512
+ if (ctx.hasPendingMessages?.()) return undefined
513
+ const target = findLastUserMessage(ctx)
514
+ return target ? checkpoints.get(target.entryId) : undefined
515
+ }
516
+
517
+ pi.on('turn_start', async (_event, ctx) => {
429
518
  if (!runNeedsSnapshot) return
430
519
  runNeedsSnapshot = false
520
+ const prompted = promptedRun
521
+ promptedRun = false
431
522
  // Claude: "Set to 1 to disable file checkpointing. The /rewind command will not be
432
523
  // able to restore code changes." No snapshot means turn_end's `if (!snap) return`
433
524
  // always fires, so no checkpoint is ever recorded.
434
525
  if (process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING === '1') return
526
+ const continued = prompted ? undefined : continuedCheckpoint(ctx)
527
+ if (continued) {
528
+ runRef = continued.ref
529
+ return
530
+ }
435
531
  pending = await snapshot()
436
532
  })
437
533
 
@@ -442,7 +538,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
442
538
  if (!EDIT_TOOLS.has(event.toolName)) return
443
539
  const target = fileToolTarget(event)
444
540
  if (target === undefined) return
445
- const file = path.resolve(ctx.cwd, target)
541
+ const file = throughSymlinks(path.resolve(ctx.cwd, target))
446
542
  if (touched.has(file)) return
447
543
  touched.add(file)
448
544
  await captureBaseline(file)
@@ -7,16 +7,41 @@
7
7
  * context-imports and the command path-scope guard all go through it).
8
8
  */
9
9
 
10
+ import * as os from 'node:os'
11
+ import * as path from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
10
14
  /** The tools that name a file pi-code acts on. */
11
15
  const FILE_TOOLS: ReadonlySet<string> = new Set(['read', 'edit', 'write'])
12
16
 
17
+ /** The spaces pi folds to a plain one before resolving (pi dist/utils/paths). */
18
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g
19
+
20
+ /** `target` as pi's file tools read it before resolving against cwd: a leading @ (which
21
+ * some models add) stripped, ~ expanded, a file:// URL decoded, unicode spaces folded.
22
+ * pi does this in resolveToCwd (dist/core/tools/path-utils), which the package does not
23
+ * export. A reader that skips it judges a different file than the one pi opens: a
24
+ * command scoped to `Read(*.md)` allowed `~/secret/notes.md`, read as <cwd>/~/secret. */
25
+ function asPiReadsIt(target: string): string {
26
+ const folded = target.replace(UNICODE_SPACES, ' ')
27
+ const bare = folded.startsWith('@') ? folded.slice(1) : folded
28
+ if (bare === '~') return os.homedir()
29
+ if (bare.startsWith('~/') || (process.platform === 'win32' && bare.startsWith('~\\'))) return path.join(os.homedir(), bare.slice(2))
30
+ if (!bare.startsWith('file://')) return bare
31
+ try {
32
+ return fileURLToPath(bare)
33
+ } catch {
34
+ return bare
35
+ }
36
+ }
37
+
13
38
  /** The path a file tool call named, or undefined for any other tool, an errored call,
14
- * or a call that named none. The path is as the tool gave it: callers resolve it
15
- * against their own cwd. */
39
+ * or a call that named none. Normalised as pi normalises it, and still relative when
40
+ * the model gave a relative path: callers resolve it against their own cwd. */
16
41
  export function fileToolTarget(event: { toolName: string; input?: unknown; isError?: boolean }): string | undefined {
17
42
  if (event.isError === true) return undefined
18
43
  if (!FILE_TOOLS.has(event.toolName)) return undefined
19
44
  const input = event.input as { path?: unknown; file_path?: unknown } | undefined
20
45
  const target = typeof input?.path === 'string' ? input.path : input?.file_path
21
- return typeof target === 'string' && target.length > 0 ? target : undefined
46
+ return typeof target === 'string' && target.length > 0 ? asPiReadsIt(target) : undefined
22
47
  }
@@ -167,11 +167,37 @@ function refusedByOverride(name: string, ctx: ExtensionContext, trusted: boolean
167
167
  return { action: 'handled' }
168
168
  }
169
169
 
170
+ /** Forked skills still running in the background, by name. */
171
+ const runningForks = new Set<string>()
172
+
173
+ /** Whether a context: fork skill holds the invoking turn for its result. Claude: "The
174
+ * forked subagent runs in the background: you keep working while it runs, and its result
175
+ * arrives in your conversation when it completes. Set background: false in the frontmatter
176
+ * to instead wait for the result in the turn that invoked the skill." It also waits in
177
+ * non-interactive mode, when CLAUDE_CODE_DISABLE_BACKGROUND_TASKS is 1, and "when you
178
+ * invoke a forked skill while an earlier invocation of the same skill is still running". */
179
+ function forkWaits(name: string, frontmatter: Record<string, unknown>, ctx: ExtensionContext): boolean {
180
+ if (String(frontmatter.background).trim().toLowerCase() === 'false') return true
181
+ return !ctx.hasUI || process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS === '1' || runningForks.has(name)
182
+ }
183
+
184
+ /** Start a forked skill in the background and hand its result to the conversation when it
185
+ * completes. The promise has no awaiter, and sendMessage on a pi whose session was
186
+ * replaced meanwhile throws: the catch keeps that from exiting pi, at the cost of the
187
+ * delivery, as a background subagent run's completion notice is lost the same way. */
188
+ function startForkedSkill(pi: ExtensionAPI, ctx: ExtensionContext, fork: { name: string; filePath: string; expanded: string; agentName: string | undefined }): { action: 'handled' } {
189
+ runningForks.add(fork.name)
190
+ ctx.ui.notify(`Skill ${fork.name} is running in a forked subagent in the background; its result arrives here when it completes.`, 'info')
191
+ void runForkedSkill(fork.name, fork.filePath, fork.expanded, fork.agentName)
192
+ .then((result) => pi.sendMessage({ customType: 'skill-fork', content: result.text, display: true }, { triggerTurn: true }))
193
+ .catch(() => {})
194
+ .finally(() => runningForks.delete(fork.name))
195
+ return { action: 'handled' }
196
+ }
197
+
170
198
  /** Claude's context: fork run: the expanded skill content becomes the prompt that
171
- * drives a subagent, without the conversation history. Divergence: Claude
172
- * backgrounds the fork by default; pi-code waits for the result in the invoking
173
- * turn (Claude's background: false behavior, which is also what Claude itself
174
- * does in -p and SDK runs). */
199
+ * drives a subagent, without the conversation history. Never rejects: a failed run
200
+ * is reported in the returned block. */
175
201
  async function runForkedSkill(name: string, filePath: string, expanded: string, agentName: string | undefined): Promise<{ action: 'transform'; text: string }> {
176
202
  try {
177
203
  const output = await runAgent({ prompt: expanded, fullTools: true, ...(agentName ? { agent: agentName } : {}) })
@@ -218,7 +244,9 @@ async function expandSkillInvocation(pi: ExtensionAPI, rawText: string, ctx: Ext
218
244
  }
219
245
  const expanded = await expandCommand(pi, parsed, args, { cwd: ctx.cwd }, found.filePath, undefined, { allowShell: !shellExecutionDisabled(ctx.cwd, os.homedir(), trusted) })
220
246
  if (typeof frontmatter.context === 'string' && frontmatter.context.trim().toLowerCase() === 'fork') {
221
- return runForkedSkill(name, found.filePath, expanded, typeof frontmatter.agent === 'string' ? frontmatter.agent.trim() : undefined)
247
+ const agentName = typeof frontmatter.agent === 'string' ? frontmatter.agent.trim() : undefined
248
+ if (forkWaits(name, frontmatter, ctx)) return runForkedSkill(name, found.filePath, expanded, agentName)
249
+ return startForkedSkill(pi, ctx, { name, filePath: found.filePath, expanded, agentName })
222
250
  }
223
251
  return { action: 'transform', text: `<skill name="${name}" location="${found.filePath}">\nReferences are relative to ${found.baseDir}.\n\n${expanded}\n</skill>` }
224
252
  }
@@ -114,12 +114,15 @@ function toNumber(value: string | undefined): number | undefined {
114
114
  }
115
115
 
116
116
  /** One rate-limit window from the `anthropic-ratelimit-<prefix>-*` header family,
117
- * taking a direct `-utilization` percentage when present, else computing it from
118
- * `-limit` and `-remaining`. resets_at comes from `-reset` when the header is set.
119
- * Header names vary, so only what is present is read and a bare number is enough. */
117
+ * taking `-utilization` when present, else computing the percentage from `-limit` and
118
+ * `-remaining`. `-utilization` is a 0 to 1 fraction, while Claude documents
119
+ * used_percentage as "from 0 to 100": it is scaled and rounded to one decimal, as
120
+ * Claude Code does (Math.round(utilization * 1000) / 10). resets_at comes from `-reset`
121
+ * when the header is set. Header names vary, so only what is present is read. */
120
122
  function readRateLimitWindow(headers: Record<string, string>, prefix: string): RateLimitWindow | undefined {
121
123
  const base = `anthropic-ratelimit-${prefix}`
122
- let usedPercentage = toNumber(headers[`${base}-utilization`])
124
+ const utilization = toNumber(headers[`${base}-utilization`])
125
+ let usedPercentage = utilization === undefined ? undefined : Math.round(utilization * 1000) / 10
123
126
  if (usedPercentage === undefined) {
124
127
  const limit = toNumber(headers[`${base}-limit`])
125
128
  const remaining = toNumber(headers[`${base}-remaining`])
@@ -11,6 +11,7 @@ import * as fs from 'node:fs'
11
11
  import * as os from 'node:os'
12
12
  import * as path from 'node:path'
13
13
  import { killProcessTree } from '../internal/process-tree.js'
14
+ import { sharedSlot } from '../internal/shared-slot.js'
14
15
  import { errorMessage } from '../internal/values.js'
15
16
  import { spawnChild } from './run.js'
16
17
 
@@ -57,7 +58,14 @@ export interface BackgroundSpawn {
57
58
  maxTurns?: number
58
59
  }
59
60
 
60
- const runs = new Map<string, BackgroundRun>()
61
+ /** The registry, held on globalThis rather than in module state. /reload and a /resume into
62
+ * another directory clear pi's extension cache and evaluate this module again; a registry
63
+ * that died with the old instance left live children unlisted, uncancellable, and running
64
+ * on past pi's exit, since the quit path signals only what the registry holds. */
65
+ const registry = sharedSlot<{ runs: Map<string, BackgroundRun>; finishSequence: number }>('background-runs')
66
+ const state = registry.get() ?? { runs: new Map<string, BackgroundRun>(), finishSequence: 0 }
67
+ registry.set(state)
68
+ const runs = state.runs
61
69
 
62
70
  /** Cap on simultaneously running background children. */
63
71
  export const MAX_BACKGROUND_RUNS = 8
@@ -76,16 +84,15 @@ export function activeBackgroundRuns(): number {
76
84
  return [...runs.values()].filter((run) => run.live || run.state === 'running').length
77
85
  }
78
86
 
79
- /** Stamps BackgroundRun.finishedAt; a counter rather than a clock so two runs
80
- * completing in the same millisecond still evict in their true finish order. */
81
- let finishSequence = 0
87
+ // state.finishSequence stamps BackgroundRun.finishedAt: a counter rather than a clock, so
88
+ // two runs completing in the same millisecond still evict in their true finish order.
82
89
 
83
90
  /** Test seam: the registry is module state, so tests reset it between cases to
84
91
  * stay order-independent. */
85
92
  export function resetBackgroundRuns(): void {
86
93
  for (const run of runs.values()) removeRebuiltPrompt(run)
87
94
  runs.clear()
88
- finishSequence = 0
95
+ state.finishSequence = 0
89
96
  }
90
97
 
91
98
  function evictFinishedRuns(): void {
@@ -287,7 +294,7 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
287
294
  const complete = (): void => {
288
295
  if (completed) return
289
296
  completed = true
290
- run.finishedAt = ++finishSequence
297
+ run.finishedAt = ++state.finishSequence
291
298
  evictFinishedRuns()
292
299
  // A run outlives the session that started it, and pi's loader wires assertActive()
293
300
  // into every runtime call, so notifying a disposed session throws. This fires from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.67",
3
+ "version": "1.0.69",
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",