pi-code 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/commands.ts +303 -85
- package/extensions/context-imports.ts +162 -91
- package/extensions/git-checkpoint.ts +40 -0
- package/extensions/hooks.ts +203 -36
- package/extensions/internal/command-file.ts +91 -48
- package/extensions/internal/html-markdown.ts +11 -1
- package/extensions/internal/instruction-events.ts +2 -2
- package/extensions/internal/managed-settings.ts +1 -1
- package/extensions/internal/mcp-oauth.ts +44 -8
- package/extensions/internal/path-rules.ts +69 -2
- package/extensions/internal/plugins.ts +29 -16
- package/extensions/internal/strip-comments.ts +56 -33
- package/extensions/mcp.ts +462 -84
- package/extensions/memory.ts +29 -19
- package/extensions/notify.ts +1 -2
- package/extensions/status-line.ts +8 -3
- package/extensions/subagent/background.ts +11 -0
- package/extensions/subagent/index.ts +119 -5
- package/extensions/web.ts +39 -26
- package/package.json +1 -1
|
@@ -159,34 +159,60 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
/** Optional controls for a collection run: the byte/file budget shared across the
|
|
163
|
+
* whole run, the path of the file this content came from (seeds each top-level
|
|
164
|
+
* import's `parent`), and the exclusion predicate. Recursion depth is internal. */
|
|
165
|
+
export interface CollectImportsOptions {
|
|
166
|
+
budget?: ImportBudget
|
|
167
|
+
importer?: string
|
|
168
|
+
isExcluded?: (realPath: string) => boolean
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The parts of a collection run that stay fixed across the recursion: resolution
|
|
172
|
+
* roots, the seen/budget accumulators, and the exclusion predicate. Only content,
|
|
173
|
+
* fromDir, depth and the parent path change from one level to the next. */
|
|
174
|
+
interface ImportScan {
|
|
175
|
+
home: string
|
|
176
|
+
allowedRoots: string[]
|
|
177
|
+
seen: Set<string>
|
|
178
|
+
budget: ImportBudget
|
|
179
|
+
isExcluded?: (realPath: string) => boolean
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One recursion level: read the imports named in `content`, then recurse into each. */
|
|
183
|
+
function collectFrom(scan: ImportScan, content: string, fromDir: string, depth: number, importer?: string): ImportedFile[] {
|
|
168
184
|
if (depth >= MAX_IMPORT_DEPTH) return []
|
|
169
185
|
const out: ImportedFile[] = []
|
|
170
186
|
for (const target of importTargets(content)) {
|
|
171
187
|
// Checked before the read so an exhausted budget costs no I/O.
|
|
172
|
-
if (budget.files === 0 || budget.bytes === 0) {
|
|
173
|
-
budget.dropped += 1
|
|
188
|
+
if (scan.budget.files === 0 || scan.budget.bytes === 0) {
|
|
189
|
+
scan.budget.dropped += 1
|
|
174
190
|
continue
|
|
175
191
|
}
|
|
176
|
-
const file = readImport(target, fromDir, home, allowedRoots, seen, isExcluded)
|
|
192
|
+
const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded)
|
|
177
193
|
if (!file) continue
|
|
178
|
-
budget.files -= 1
|
|
179
|
-
const kept = file.body.slice(0, budget.bytes)
|
|
180
|
-
budget.bytes -= kept.length
|
|
194
|
+
scan.budget.files -= 1
|
|
195
|
+
const kept = file.body.slice(0, scan.budget.bytes)
|
|
196
|
+
scan.budget.bytes -= kept.length
|
|
181
197
|
const body = kept.length < file.body.length ? `${kept.trim()}\n${IMPORT_TRUNCATED_MARKER}` : kept.trim()
|
|
182
198
|
// Comments are stripped before the scan for further imports, so a
|
|
183
199
|
// commented-out @import stays dead at every depth, matching the top level
|
|
184
200
|
// (whose bodies arrive here already stripped by the caller).
|
|
185
|
-
out.push({ path: file.real, body, parent: importer }, ...
|
|
201
|
+
out.push({ path: file.real, body, parent: importer }, ...collectFrom(scan, stripBlockComments(kept), path.dirname(file.real), depth + 1, file.real))
|
|
186
202
|
}
|
|
187
203
|
return out
|
|
188
204
|
}
|
|
189
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Collect the contents of every file transitively imported via `@path`, in
|
|
208
|
+
* discovery order. Imports are resolved through symlinks and kept within
|
|
209
|
+
* `allowedRoots` (which must already be realpath'd).
|
|
210
|
+
*/
|
|
211
|
+
export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, options: CollectImportsOptions = {}): ImportedFile[] {
|
|
212
|
+
const scan: ImportScan = { home, allowedRoots, seen, budget: options.budget ?? createImportBudget(), isExcluded: options.isExcluded }
|
|
213
|
+
return collectFrom(scan, content, fromDir, 0, options.importer)
|
|
214
|
+
}
|
|
215
|
+
|
|
190
216
|
/**
|
|
191
217
|
* Roots an importing file may pull from.
|
|
192
218
|
*
|
|
@@ -357,6 +383,114 @@ export function isExcludedPath(absPath: string, globs: string[], home: string):
|
|
|
357
383
|
})
|
|
358
384
|
}
|
|
359
385
|
|
|
386
|
+
/** Apply claudeMdExcludes and comment-stripping to pi's native context blocks,
|
|
387
|
+
* rewriting the assembled prompt by exact substring: an excluded file's block is
|
|
388
|
+
* removed, a surviving file's block is replaced with its comment-stripped body. A
|
|
389
|
+
* wrapper not found in the prompt is skipped rather than risk corrupting it.
|
|
390
|
+
* Returns the rewritten prompt and the files that survived exclusion (stripped). */
|
|
391
|
+
function rewriteNativeBlocks(prompt: string, native: Array<{ path: string; content: string }>, excluded: (absPath: string) => boolean): { prompt: string; changed: boolean; kept: Array<{ path: string; content: string }> } {
|
|
392
|
+
let changed = false
|
|
393
|
+
const kept: Array<{ path: string; content: string }> = []
|
|
394
|
+
for (const file of native) {
|
|
395
|
+
const wrapper = instructionsBlock(file.path, file.content)
|
|
396
|
+
if (excluded(file.path)) {
|
|
397
|
+
const removed = removeBlock(prompt, wrapper)
|
|
398
|
+
if (removed !== null) {
|
|
399
|
+
prompt = removed
|
|
400
|
+
changed = true
|
|
401
|
+
}
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
const stripped = stripBlockComments(file.content)
|
|
405
|
+
if (stripped !== file.content) {
|
|
406
|
+
const replaced = replaceBlock(prompt, wrapper, instructionsBlock(file.path, stripped))
|
|
407
|
+
if (replaced !== null) {
|
|
408
|
+
prompt = replaced
|
|
409
|
+
changed = true
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
kept.push({ path: file.path, content: stripped })
|
|
413
|
+
}
|
|
414
|
+
return { prompt, changed, kept }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Claude's --add-dir memory files, minus any pi already loaded natively (added to
|
|
418
|
+
* `seenSet` here so an @import cannot pull one in twice) and any the excludes drop
|
|
419
|
+
* or that strip to nothing. Each survivor is comment-stripped and tagged with its
|
|
420
|
+
* additional dir, so its relative imports can resolve from there. */
|
|
421
|
+
function additionalDirExtras(addDirs: string[], seenSet: Set<string>, excluded: (absPath: string) => boolean, includeLocal: boolean): Array<{ path: string; content: string; dir: string }> {
|
|
422
|
+
const extras: Array<{ path: string; content: string; dir: string }> = []
|
|
423
|
+
for (const dir of addDirs) {
|
|
424
|
+
for (const file of additionalDirContextFiles(dir, includeLocal)) {
|
|
425
|
+
const [real] = realRoots([file.path])
|
|
426
|
+
const key = real ?? file.path
|
|
427
|
+
if (seenSet.has(key)) continue // pi already loaded it natively
|
|
428
|
+
seenSet.add(key)
|
|
429
|
+
if (excluded(file.path)) continue
|
|
430
|
+
const stripped = stripBlockComments(file.content)
|
|
431
|
+
if (stripped.trim().length === 0) continue
|
|
432
|
+
extras.push({ path: file.path, content: stripped, dir })
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return extras
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Resolve every context and additional-dir file's @imports through the one shared
|
|
439
|
+
* budget, each with roots scoped to the importing file so a project file never
|
|
440
|
+
* reaches user config. */
|
|
441
|
+
function expandImports(contextFiles: Array<{ path: string; content: string }>, extras: Array<{ path: string; content: string; dir: string }>, home: string, cwd: string, seenSet: Set<string>, excluded: (absPath: string) => boolean, budget: ImportBudget): ImportedFile[] {
|
|
442
|
+
const imported: ImportedFile[] = []
|
|
443
|
+
for (const file of contextFiles) {
|
|
444
|
+
const allowedRoots = rootsForImporter(file.path, home, cwd)
|
|
445
|
+
imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, { budget, importer: file.path, isExcluded: excluded }))
|
|
446
|
+
}
|
|
447
|
+
for (const extra of extras) {
|
|
448
|
+
// The additional dir itself is an allowed root, so its files' relative imports
|
|
449
|
+
// resolve even from .claude/rules two levels down.
|
|
450
|
+
const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, home, cwd)]
|
|
451
|
+
imported.push(...collectImports(extra.content, path.dirname(extra.path), home, allowedRoots, seenSet, { budget, importer: extra.path, isExcluded: excluded }))
|
|
452
|
+
}
|
|
453
|
+
return imported
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The CLAUDE.local.md bodies appended after the native context, announced as they
|
|
457
|
+
* are added (only the non-empty ones, matching what actually reaches the prompt). */
|
|
458
|
+
function localContextAddition(keptLocals: Array<{ path: string; content: string }>, announce: (event: InstructionLoadEvent) => void): string {
|
|
459
|
+
let addition = ''
|
|
460
|
+
for (const local of keptLocals) {
|
|
461
|
+
if (local.content.trim().length > 0) {
|
|
462
|
+
addition += `\n\n## CLAUDE.local.md (${local.path})\n\n${local.content.trim()}`
|
|
463
|
+
announce({ file_path: local.path, memory_type: 'Local', load_reason: 'session_start' })
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return addition
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** The --add-dir memory files appended as extra project_instructions blocks.
|
|
470
|
+
* Additional dirs are extra working directories, so their files are Project-typed
|
|
471
|
+
* regardless of where the dir sits (Local for a CLAUDE.local.md). */
|
|
472
|
+
function additionalDirsAddition(extras: Array<{ path: string; content: string; dir: string }>, announce: (event: InstructionLoadEvent) => void): string {
|
|
473
|
+
let addition = ''
|
|
474
|
+
for (const extra of extras) {
|
|
475
|
+
addition += `\n\n${instructionsBlock(extra.path, extra.content)}`
|
|
476
|
+
announce({ file_path: extra.path, memory_type: path.basename(extra.path) === 'CLAUDE.local.md' ? 'Local' : 'Project', load_reason: 'session_start' })
|
|
477
|
+
}
|
|
478
|
+
return addition
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** The `## Imported context (@)` section for every resolved @import, with the
|
|
482
|
+
* budget-exhaustion notice, announcing each as an `include`. Empty when nothing
|
|
483
|
+
* was imported. */
|
|
484
|
+
function importedAddition(imported: ImportedFile[], budget: ImportBudget, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
|
|
485
|
+
if (imported.length === 0) return ''
|
|
486
|
+
const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
|
|
487
|
+
const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
|
|
488
|
+
for (const entry of imported) {
|
|
489
|
+
announce({ file_path: entry.path, memory_type: memoryTypeForPath(entry.path, home, projectRoot), load_reason: 'include', ...(entry.parent === undefined ? {} : { parent_file_path: entry.parent }) })
|
|
490
|
+
}
|
|
491
|
+
return `\n\n## Imported context (@)\n\n${section}${notice}`
|
|
492
|
+
}
|
|
493
|
+
|
|
360
494
|
export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
361
495
|
let localContexts: Array<{ path: string; content: string }> = []
|
|
362
496
|
// Whether project settings may contribute claudeMdExcludes; decided at session
|
|
@@ -416,37 +550,16 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
416
550
|
const excluded = (absPath: string): boolean => isExcludedPath(absPath, excludeGlobs, home)
|
|
417
551
|
const projectRoot = repoRoot(cwd) ?? cwd
|
|
418
552
|
|
|
419
|
-
let prompt = event.systemPrompt
|
|
420
|
-
let changed = false
|
|
421
|
-
|
|
422
553
|
// claudeMdExcludes drops an excluded file's block from the assembled prompt and
|
|
423
554
|
// from import expansion; surviving blocks get block-level comments stripped.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
prompt = removed
|
|
433
|
-
changed = true
|
|
434
|
-
}
|
|
435
|
-
continue
|
|
436
|
-
}
|
|
437
|
-
const stripped = stripBlockComments(file.content)
|
|
438
|
-
if (stripped !== file.content) {
|
|
439
|
-
const replaced = replaceBlock(prompt, wrapper, instructionsBlock(file.path, stripped))
|
|
440
|
-
if (replaced !== null) {
|
|
441
|
-
prompt = replaced
|
|
442
|
-
changed = true
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
keptNative.push({ path: file.path, content: stripped })
|
|
446
|
-
// Exclusion is owned here, so the session_start InstructionsLoaded events
|
|
447
|
-
// for pi's native context files are published here too, only for files
|
|
448
|
-
// that actually survived it: Claude fires no event for a file it never
|
|
449
|
-
// loaded. The hooks extension consumes them off the shared bus.
|
|
555
|
+
const rewrite = rewriteNativeBlocks(event.systemPrompt, native, excluded)
|
|
556
|
+
let prompt = rewrite.prompt
|
|
557
|
+
let changed = rewrite.changed
|
|
558
|
+
// Exclusion is owned here, so the session_start InstructionsLoaded events for
|
|
559
|
+
// pi's native context files are published here too, only for files that
|
|
560
|
+
// survived it: Claude fires no event for a file it never loaded. The hooks
|
|
561
|
+
// extension consumes them off the shared bus.
|
|
562
|
+
for (const file of rewrite.kept) {
|
|
450
563
|
announce({ file_path: file.path, memory_type: memoryTypeForPath(file.path, home, projectRoot), load_reason: 'session_start' })
|
|
451
564
|
}
|
|
452
565
|
|
|
@@ -460,69 +573,27 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
460
573
|
}
|
|
461
574
|
|
|
462
575
|
const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
|
|
463
|
-
const contextFiles = [...
|
|
576
|
+
const contextFiles = [...rewrite.kept, ...keptLocals]
|
|
464
577
|
|
|
465
578
|
// Seed with every loaded context file path, excluded ones included, so pi's own
|
|
466
579
|
// files are never re-imported and an excluded file cannot return as an import.
|
|
467
|
-
const
|
|
468
|
-
const seenSet = new Set(seen)
|
|
580
|
+
const seenSet = new Set(realRoots([...native, ...localContexts].map((file) => file.path)))
|
|
469
581
|
|
|
470
582
|
// Claude's --add-dir memory loading, env-gated. The files join the seen set
|
|
471
583
|
// before import expansion so an @import cannot pull one in twice, and they get
|
|
472
584
|
// the same exclude and comment-strip treatment as native context files.
|
|
473
585
|
const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
|
|
474
|
-
const extras
|
|
475
|
-
for (const dir of addDirs) {
|
|
476
|
-
for (const file of additionalDirContextFiles(dir, projectApproved)) {
|
|
477
|
-
const [real] = realRoots([file.path])
|
|
478
|
-
const key = real ?? file.path
|
|
479
|
-
if (seenSet.has(key)) continue // pi already loaded it natively
|
|
480
|
-
seenSet.add(key)
|
|
481
|
-
if (excluded(file.path)) continue
|
|
482
|
-
const stripped = stripBlockComments(file.content)
|
|
483
|
-
if (stripped.trim().length === 0) continue
|
|
484
|
-
extras.push({ path: file.path, content: stripped, dir })
|
|
485
|
-
}
|
|
486
|
-
}
|
|
586
|
+
const extras = additionalDirExtras(addDirs, seenSet, excluded, projectApproved)
|
|
487
587
|
|
|
488
|
-
const imported: ImportedFile[] = []
|
|
489
588
|
// One budget for the whole run, so N context files cannot each spend a full one.
|
|
490
589
|
// Exclusion applies inside the recursion: an excluded @import is skipped before
|
|
491
590
|
// it is read, so its transitive imports never load and it spends no budget.
|
|
492
591
|
const budget = createImportBudget()
|
|
493
|
-
|
|
494
|
-
// Roots are scoped per importing file: a project file never reaches user config.
|
|
495
|
-
const allowedRoots = rootsForImporter(file.path, home, cwd)
|
|
496
|
-
imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, budget, 0, file.path, excluded))
|
|
497
|
-
}
|
|
498
|
-
for (const extra of extras) {
|
|
499
|
-
// The additional dir itself is an allowed root, so its files' relative
|
|
500
|
-
// imports resolve even from .claude/rules two levels down.
|
|
501
|
-
const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, home, cwd)]
|
|
502
|
-
imported.push(...collectImports(extra.content, path.dirname(extra.path), home, allowedRoots, seenSet, budget, 0, extra.path, excluded))
|
|
503
|
-
}
|
|
592
|
+
const imported = expandImports(contextFiles, extras, home, cwd, seenSet, excluded, budget)
|
|
504
593
|
|
|
505
|
-
let addition =
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
addition += `\n\n## CLAUDE.local.md (${local.path})\n\n${local.content.trim()}`
|
|
509
|
-
announce({ file_path: local.path, memory_type: 'Local', load_reason: 'session_start' })
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
for (const extra of extras) {
|
|
513
|
-
addition += `\n\n${instructionsBlock(extra.path, extra.content)}`
|
|
514
|
-
// Additional dirs are extra working directories, so their memory files are
|
|
515
|
-
// Project-typed regardless of where the dir sits (Local for CLAUDE.local.md).
|
|
516
|
-
announce({ file_path: extra.path, memory_type: path.basename(extra.path) === 'CLAUDE.local.md' ? 'Local' : 'Project', load_reason: 'session_start' })
|
|
517
|
-
}
|
|
518
|
-
if (imported.length > 0) {
|
|
519
|
-
const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
|
|
520
|
-
const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
|
|
521
|
-
addition += `\n\n## Imported context (@)\n\n${section}${notice}`
|
|
522
|
-
for (const entry of imported) {
|
|
523
|
-
announce({ file_path: entry.path, memory_type: memoryTypeForPath(entry.path, home, projectRoot), load_reason: 'include', ...(entry.parent === undefined ? {} : { parent_file_path: entry.parent }) })
|
|
524
|
-
}
|
|
525
|
-
}
|
|
594
|
+
let addition = localContextAddition(keptLocals, announce)
|
|
595
|
+
addition += additionalDirsAddition(extras, announce)
|
|
596
|
+
addition += importedAddition(imported, budget, home, projectRoot, announce)
|
|
526
597
|
if (!changed && addition.length === 0) return
|
|
527
598
|
|
|
528
599
|
return { systemPrompt: prompt + addition }
|
|
@@ -13,12 +13,15 @@
|
|
|
13
13
|
* the checkpoint (files created after the checkpoint are left in place).
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
16
17
|
import * as fs from 'node:fs'
|
|
17
18
|
import * as os from 'node:os'
|
|
18
19
|
import * as path from 'node:path'
|
|
19
20
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
20
21
|
|
|
21
22
|
const CUSTOM_TYPE = 'git-checkpoint'
|
|
23
|
+
/** Sidecar inside the bare shadow repo recording the work tree it snapshots. */
|
|
24
|
+
const WORK_TREE_FILE = 'pi-work-tree'
|
|
22
25
|
const PROMPT_SNIPPET_LENGTH = 60
|
|
23
26
|
const RESTORE_MODES = ['Code and conversation', 'Conversation only', 'Code only']
|
|
24
27
|
|
|
@@ -72,6 +75,31 @@ export function sessionSlug(sessionFile: string | undefined): string {
|
|
|
72
75
|
return path.basename(sessionFile).replace(/[^\w.-]+/g, '_')
|
|
73
76
|
}
|
|
74
77
|
|
|
78
|
+
/** A stable per-directory key, so a session resumed elsewhere gets its own shadow. */
|
|
79
|
+
function cwdSlug(cwd: string): string {
|
|
80
|
+
const resolved = path.resolve(cwd)
|
|
81
|
+
const hash = createHash('sha256').update(resolved).digest('hex').slice(0, 8)
|
|
82
|
+
return `${path.basename(resolved).replace(/[^\w.-]+/g, '_')}-${hash}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The work tree a shadow repo was created against, or undefined for a repo that
|
|
86
|
+
* predates the sidecar or does not exist yet. */
|
|
87
|
+
function recordedWorkTree(shadowDir: string): string | undefined {
|
|
88
|
+
try {
|
|
89
|
+
return fs.readFileSync(path.join(shadowDir, WORK_TREE_FILE), 'utf8').trim() || undefined
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function rememberWorkTree(shadowDir: string, cwd: string): void {
|
|
96
|
+
try {
|
|
97
|
+
fs.writeFileSync(path.join(shadowDir, WORK_TREE_FILE), `${cwd}\n`)
|
|
98
|
+
} catch {
|
|
99
|
+
// best effort: without the marker the next resume simply cannot detect a move
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
75
103
|
function extractText(content: unknown): string {
|
|
76
104
|
if (typeof content === 'string') return content
|
|
77
105
|
if (!Array.isArray(content)) return ''
|
|
@@ -134,6 +162,16 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
134
162
|
const sessionFile = (ctx.sessionManager as { getSessionFile?: () => string | undefined }).getSessionFile?.()
|
|
135
163
|
const checkpointsRoot = path.join(os.homedir(), '.pi', 'agent', 'checkpoints')
|
|
136
164
|
shadowDir = path.join(checkpointsRoot, sessionSlug(sessionFile))
|
|
165
|
+
// A resumed session can arrive from a different directory than the one the shadow
|
|
166
|
+
// snapshotted; restoring those commits here would silently overwrite unrelated
|
|
167
|
+
// same-named files. Key a fresh shadow to this directory instead of ever checking
|
|
168
|
+
// one tree out into another. Resuming back in the recorded directory takes the
|
|
169
|
+
// original shadow again, so its checkpoints stay restorable there.
|
|
170
|
+
const recorded = recordedWorkTree(shadowDir)
|
|
171
|
+
if (recorded && path.resolve(recorded) !== path.resolve(ctx.cwd)) {
|
|
172
|
+
shadowDir = path.join(checkpointsRoot, `${sessionSlug(sessionFile)}-${cwdSlug(ctx.cwd)}`)
|
|
173
|
+
ctx.ui.notify(`Checkpoints for this session were recorded in ${recorded}; starting fresh checkpoints for ${ctx.cwd} (earlier ones are not restorable here)`, 'warning')
|
|
174
|
+
}
|
|
137
175
|
pruneCheckpointRepos(checkpointsRoot, CHECKPOINT_RETENTION_DAYS, shadowDir)
|
|
138
176
|
const check = await pi.exec('git', ['--git-dir', shadowDir, 'rev-parse', '--git-dir'], { cwd: ctx.cwd })
|
|
139
177
|
if (check.code !== 0) {
|
|
@@ -147,6 +185,8 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
147
185
|
await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.email', 'checkpoint@pi-code'], { cwd: ctx.cwd })
|
|
148
186
|
await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.name', 'pi-code-checkpoint'], { cwd: ctx.cwd })
|
|
149
187
|
}
|
|
188
|
+
// Written on every start, so repos that predate the sidecar pick it up too.
|
|
189
|
+
rememberWorkTree(shadowDir, ctx.cwd)
|
|
150
190
|
}
|
|
151
191
|
|
|
152
192
|
/** `checkout -f <ref> -- .` errors when the ref's tree holds no files, so an empty
|