pi-code 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mario Zechner
4
+ Copyright (c) 2026 ilovepixelart
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # pi-code
2
+
3
+ Claude Code experience for the [pi](https://pi.dev) coding agent, in one package. Point pi at a project that already has a `.claude/` directory and it reads your existing config: rules, commands, skills, hooks, output styles, MCP servers, and agents. It also adds the Claude Code features pi lacks: a todo overlay, checkpoints, memory, web search, and subagents.
4
+
5
+ ![pi-code demo](demos/hero.gif)
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install pi-code # from npm (when published)
11
+ pi install git:github.com/ilovepixelart/pi-code
12
+ pi install ~/Documents/pi-code # local path, then /reload after edits
13
+ ```
14
+
15
+ One `pi install`, everything below loads. Each feature is an extension under [`extensions/`](extensions).
16
+
17
+ ## What it does
18
+
19
+ | Feature | Reads / provides | Extension |
20
+ |---|---|---|
21
+ | Global + project rules | `~/.claude/rules`, `.claude/rules` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
22
+ | Custom slash commands | `.claude/commands/*.md` → pi prompt templates | `commands.ts` |
23
+ | Skills | `.claude/skills` → pi skill discovery | `skills.ts` |
24
+ | Hooks | `.claude/settings.json` hooks on pi lifecycle events | `hooks.ts` |
25
+ | Output styles | `.claude/output-styles` + active `outputStyle`, `/output-style` switcher | `output-styles.ts` |
26
+ | CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips | `context-imports.ts` |
27
+ | MCP servers | `.mcp.json`, `~/.claude.json`, `.pi/mcp.json`; stdio + HTTP | `mcp.ts` |
28
+ | Subagents / Task | `~/.claude/agents` + project `.claude/agents`, background runs | `subagent/` |
29
+ | Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
30
+ | Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
31
+ | Checkpoints / rewind | shadow-repo snapshots, hard-reset restore | `git-checkpoint.ts` |
32
+ | Persistent memory | per-project memories, index injected each session | `memory.ts` |
33
+ | WebSearch / WebFetch | key-free DuckDuckGo search, SSRF-guarded fetch | `web.ts` |
34
+ | AskUserQuestion | vendored example | `question.ts` |
35
+ | Statusline | turn state + session cost | `status-line.ts` |
36
+ | Notifications | vendored example | `notify.ts` |
37
+
38
+ `CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base.
39
+
40
+ Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)). Personal config lives in [dot-pi](https://github.com/ilovepixelart/dot-pi) (`~/.pi/agent`).
41
+
42
+ ## Development
43
+
44
+ ```bash
45
+ npm install
46
+ npm run check # biome + strict tsc + vitest, the whole gate
47
+ scripts/e2e.sh # drives the real pi TUI via tmux (needs a working model)
48
+ scripts/record-demos.sh # re-records demos/*.tape with vhs at low thinking
49
+ ```
50
+
51
+ Extensions live in `extensions/`, tests in `tests/`. Install locally with `pi install ~/Documents/pi-code`, then `/reload` after edits.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Claude Rules Extension
3
+ *
4
+ * Replicates Claude Code's rules loading:
5
+ * - Global rules (~/.claude/rules/*.md) are inlined in full into the system prompt.
6
+ * - Project rules (.claude/rules/*.md) are listed as pointers the agent can read on demand.
7
+ *
8
+ * Path-scoped rules: a rule file may declare `paths:` frontmatter (a glob or
9
+ * list of globs). Project-rule pointers surface that scope so the agent knows
10
+ * to read the rule when working on matching files. Frontmatter is stripped
11
+ * from inlined global rules.
12
+ *
13
+ * Adapted from the pi v0.74.2 claude-rules example.
14
+ */
15
+
16
+ import * as fs from 'node:fs'
17
+ import * as os from 'node:os'
18
+ import * as path from 'node:path'
19
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
20
+
21
+ export interface Frontmatter {
22
+ paths: string[]
23
+ body: string
24
+ }
25
+
26
+ function unquote(value: string): string {
27
+ return value.replace(/^["']|["']$/g, '')
28
+ }
29
+
30
+ function splitInline(value: string): string[] {
31
+ return value
32
+ .replace(/^\[|\]$/g, '')
33
+ .split(',')
34
+ .map((entry) => unquote(entry.trim()))
35
+ .filter(Boolean)
36
+ }
37
+
38
+ function parsePaths(frontmatter: string): string[] {
39
+ const lines = frontmatter.split('\n')
40
+ const index = lines.findIndex((line) => /^\s*paths\s*:/.test(line))
41
+ if (index === -1) return []
42
+ const inline = lines[index].replace(/^\s*paths\s*:/, '').trim()
43
+ if (inline) return splitInline(inline)
44
+ const items: string[] = []
45
+ for (let i = index + 1; i < lines.length; i++) {
46
+ const match = /^\s*-\s*(.+)$/.exec(lines[i])
47
+ if (!match) break
48
+ items.push(unquote(match[1].trim()))
49
+ }
50
+ return items
51
+ }
52
+
53
+ /** Split YAML-ish frontmatter off the front of a rule file, extracting `paths`. */
54
+ export function parseFrontmatter(content: string): Frontmatter {
55
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content)
56
+ if (!match) return { paths: [], body: content }
57
+ return { paths: parsePaths(match[1]), body: content.slice(match[0].length) }
58
+ }
59
+
60
+ /** A project-rule pointer line, annotated with its path scope when present. */
61
+ export function formatRulePointer(rel: string, paths: string[]): string {
62
+ const ref = `- .claude/rules/${rel}`
63
+ return paths.length > 0 ? `${ref} — applies when working on: ${paths.join(', ')}` : ref
64
+ }
65
+
66
+ /** Recursively find all .md files in a directory. */
67
+ function findMarkdownFiles(dir: string, basePath = ''): string[] {
68
+ if (!fs.existsSync(dir)) return []
69
+ const results: string[] = []
70
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
71
+ const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name
72
+ if (entry.isDirectory()) {
73
+ results.push(...findMarkdownFiles(path.join(dir, entry.name), relativePath))
74
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
75
+ results.push(relativePath)
76
+ }
77
+ }
78
+ return results
79
+ }
80
+
81
+ function readGlobalRules(globalRulesDir: string): string {
82
+ return findMarkdownFiles(globalRulesDir)
83
+ .map((file) => parseFrontmatter(fs.readFileSync(path.join(globalRulesDir, file), 'utf-8')).body.trim())
84
+ .filter((content) => content.length > 0)
85
+ .join('\n\n')
86
+ }
87
+
88
+ interface ProjectRule {
89
+ rel: string
90
+ paths: string[]
91
+ }
92
+
93
+ function readProjectRules(projectRulesDir: string): ProjectRule[] {
94
+ return findMarkdownFiles(projectRulesDir).map((rel) => {
95
+ try {
96
+ return { rel, paths: parseFrontmatter(fs.readFileSync(path.join(projectRulesDir, rel), 'utf-8')).paths }
97
+ } catch {
98
+ return { rel, paths: [] }
99
+ }
100
+ })
101
+ }
102
+
103
+ export default function claudeRulesExtension(pi: ExtensionAPI) {
104
+ const globalRulesDir = path.join(os.homedir(), '.claude', 'rules')
105
+ let globalRules = ''
106
+ let projectRules: ProjectRule[] = []
107
+
108
+ pi.on('session_start', async (_event, ctx) => {
109
+ globalRules = readGlobalRules(globalRulesDir)
110
+ // Project rule filenames and their paths: frontmatter are surfaced in the system prompt,
111
+ // so read them only for a trusted project.
112
+ const trusted = ctx.isProjectTrusted?.() ?? false
113
+ projectRules = trusted ? readProjectRules(path.join(ctx.cwd, '.claude', 'rules')) : []
114
+
115
+ if (globalRules.length > 0 || projectRules.length > 0) {
116
+ ctx.ui.notify(`Rules loaded: global ${globalRules.length > 0 ? 'yes' : 'no'}, project ${projectRules.length}`, 'info')
117
+ }
118
+ })
119
+
120
+ pi.on('before_agent_start', async (event) => {
121
+ let addition = ''
122
+
123
+ if (globalRules.length > 0) {
124
+ addition += `\n\n## Global Rules\n\nThese rules always apply:\n\n${globalRules}`
125
+ }
126
+
127
+ if (projectRules.length > 0) {
128
+ const rulesList = projectRules.map((rule) => formatRulePointer(rule.rel, rule.paths)).join('\n')
129
+ addition += `\n\n## Project Rules\n\nThe following project rules are available in .claude/rules/:\n\n${rulesList}\n\nRead the relevant rule file with the read tool before working on the files it covers; rules with an "applies when" scope are path-scoped.`
130
+ }
131
+
132
+ if (addition.length === 0) return
133
+
134
+ return { systemPrompt: event.systemPrompt + addition }
135
+ })
136
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Claude Commands Extension
3
+ *
4
+ * Bridges Claude Code's custom slash commands into pi. On resources_discover
5
+ * it hands pi the existing `.claude/commands` directories (user then project)
6
+ * as prompt-template paths, so `/name` invokes `.claude/commands/name.md` the
7
+ * same way pi loads its own `.pi/prompts`. pi's `$ARGUMENTS` / `$1` / `${1:-x}`
8
+ * substitution overlaps Claude Code's, so most command files work unchanged.
9
+ *
10
+ * Not bridged (pi's prompt engine ignores them): `!` bash execution, `@` file
11
+ * refs, `allowed-tools`/`model` frontmatter, and namespaced subdirectories
12
+ * (discovery is non-recursive).
13
+ *
14
+ * Docs: https://code.claude.com/docs/en/slash-commands.md
15
+ */
16
+
17
+ import * as fs from 'node:fs'
18
+ import * as os from 'node:os'
19
+ import * as path from 'node:path'
20
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
21
+
22
+ function isDirectory(target: string): boolean {
23
+ try {
24
+ return fs.statSync(target).isDirectory()
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ /** Existing `.claude/commands` directories, user first then project. */
31
+ export function commandDirs(cwd: string, home: string): string[] {
32
+ const candidates = [path.join(home, '.claude', 'commands'), path.join(cwd, '.claude', 'commands')]
33
+ const dirs: string[] = []
34
+ for (const dir of candidates) {
35
+ if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)
36
+ }
37
+ return dirs
38
+ }
39
+
40
+ export default function commandsExtension(pi: ExtensionAPI) {
41
+ pi.on('resources_discover', async (_event, ctx) => {
42
+ const promptPaths = commandDirs(ctx.cwd, os.homedir())
43
+ return promptPaths.length > 0 ? { promptPaths } : undefined
44
+ })
45
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Context Imports Extension
3
+ *
4
+ * pi loads CLAUDE.md / AGENTS.md context files natively but does not resolve
5
+ * Claude Code's `@path` imports inside them. This fills that one gap: on
6
+ * before_agent_start it reads the already-loaded context files from
7
+ * systemPromptOptions, resolves any `@path` imports (recursive, depth-capped,
8
+ * cycle-safe; ~ expands to home, relative paths resolve against the importing
9
+ * file), and appends ONLY the imported content. pi already injected the base
10
+ * files, so nothing is duplicated.
11
+ *
12
+ * Security: context files can come from an untrusted project, so imports are
13
+ * confined (after resolving symlinks) to the working directory and the user's
14
+ * own ~/.claude and ~/.pi config roots. An import that escapes those roots
15
+ * (absolute paths, ~/.ssh, ../.. traversal, symlinks) is ignored, so a hostile
16
+ * CLAUDE.md cannot read arbitrary files into the prompt. Imports inside fenced
17
+ * code blocks are also skipped.
18
+ *
19
+ * Docs: https://code.claude.com/docs/en/memory.md (imports)
20
+ */
21
+
22
+ import * as fs from 'node:fs'
23
+ import * as os from 'node:os'
24
+ import * as path from 'node:path'
25
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
26
+
27
+ const MAX_IMPORT_DEPTH = 5
28
+
29
+ export function expandHome(target: string, home: string): string {
30
+ if (target === '~') return home
31
+ if (target.startsWith('~/')) return path.join(home, target.slice(2))
32
+ return target
33
+ }
34
+
35
+ function isUnder(target: string, roots: string[]): boolean {
36
+ return roots.some((root) => target === root || target.startsWith(root + path.sep))
37
+ }
38
+
39
+ /** Realpath the roots that exist; used both to seed and to bound the import search. */
40
+ export function realRoots(candidates: string[]): string[] {
41
+ const roots: string[] = []
42
+ for (const candidate of candidates) {
43
+ try {
44
+ roots.push(fs.realpathSync(candidate))
45
+ } catch {
46
+ // a root that does not exist has nothing under it to allow
47
+ }
48
+ }
49
+ return roots
50
+ }
51
+
52
+ export interface ImportedFile {
53
+ path: string
54
+ body: string
55
+ }
56
+
57
+ /**
58
+ * Collect the contents of every file transitively imported via `@path`, in
59
+ * discovery order. Imports are resolved through symlinks and kept within
60
+ * `allowedRoots` (which must already be realpath'd).
61
+ */
62
+ export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, depth = 0): ImportedFile[] {
63
+ if (depth >= MAX_IMPORT_DEPTH) return []
64
+ const out: ImportedFile[] = []
65
+ let inFence = false
66
+ for (const line of content.split('\n')) {
67
+ if (line.trimStart().startsWith('```')) {
68
+ inFence = !inFence
69
+ continue
70
+ }
71
+ if (inFence) continue
72
+ for (const match of line.matchAll(/(^|\s)@(\S+)/g)) {
73
+ const resolved = path.resolve(fromDir, expandHome(match[2], home))
74
+ let real: string
75
+ try {
76
+ real = fs.realpathSync(resolved)
77
+ } catch {
78
+ continue
79
+ }
80
+ if (seen.has(real)) continue
81
+ seen.add(real)
82
+ if (!isUnder(real, allowedRoots)) continue
83
+ let body: string
84
+ try {
85
+ // real may be a directory (EISDIR) or vanish after the realpath (ENOENT/EACCES).
86
+ body = fs.readFileSync(real, 'utf-8')
87
+ } catch {
88
+ continue
89
+ }
90
+ out.push({ path: real, body: body.trim() })
91
+ out.push(...collectImports(body, path.dirname(real), home, allowedRoots, seen, depth + 1))
92
+ }
93
+ }
94
+ return out
95
+ }
96
+
97
+ export default function contextImportsExtension(pi: ExtensionAPI) {
98
+ pi.on('before_agent_start', async (event) => {
99
+ const contextFiles: Array<{ path: string; content: string }> = event.systemPromptOptions?.contextFiles ?? []
100
+ if (contextFiles.length === 0) return
101
+
102
+ const home = os.homedir()
103
+ const cwd = event.systemPromptOptions?.cwd ?? process.cwd()
104
+ const allowedRoots = realRoots([cwd, path.join(home, '.claude'), path.join(home, '.pi')])
105
+ // Seed with the loaded context file paths so pi's own files are never re-imported.
106
+ const seen = realRoots(contextFiles.map((file) => file.path))
107
+ const seenSet = new Set(seen)
108
+
109
+ const imported: ImportedFile[] = []
110
+ for (const file of contextFiles) {
111
+ imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet))
112
+ }
113
+ if (imported.length === 0) return
114
+
115
+ const section = imported.map((entry) => `### ${entry.path}\n\n${entry.body}`).join('\n\n')
116
+ return { systemPrompt: `${event.systemPrompt}\n\n## Imported context (@)\n\n${section}` }
117
+ })
118
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Git Checkpoint Extension
3
+ *
4
+ * Claude Code style /rewind built on a per-session shadow git repo.
5
+ *
6
+ * Snapshots commit the entire working tree (untracked files included, the
7
+ * project's .gitignore is honored) into a bare repo under
8
+ * ~/.pi/agent/checkpoints/<session>, using --git-dir/--work-tree so the
9
+ * project's own git state is never touched. Each user prompt gets one
10
+ * checkpoint persisted as {entryId, ref, prompt, createdAt} in the session
11
+ * file, so /rewind works across restarts, resumes, and forks. Code restore
12
+ * checks the snapshot out over the working tree, resetting file contents to
13
+ * the checkpoint (files created after the checkpoint are left in place).
14
+ */
15
+
16
+ import * as os from 'node:os'
17
+ import * as path from 'node:path'
18
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
19
+
20
+ const CUSTOM_TYPE = 'git-checkpoint'
21
+ const PROMPT_SNIPPET_LENGTH = 60
22
+ const RESTORE_MODES = ['Code and conversation', 'Conversation only', 'Code only']
23
+
24
+ interface Checkpoint {
25
+ entryId: string
26
+ ref: string
27
+ prompt: string
28
+ createdAt: string
29
+ }
30
+
31
+ export function sessionSlug(sessionFile: string | undefined): string {
32
+ if (!sessionFile) return `ephemeral-${process.pid}`
33
+ return path.basename(sessionFile).replace(/[^\w.-]+/g, '_')
34
+ }
35
+
36
+ function extractText(content: unknown): string {
37
+ if (typeof content === 'string') return content
38
+ if (!Array.isArray(content)) return ''
39
+ return content
40
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
41
+ .map((part) => part.text)
42
+ .join(' ')
43
+ }
44
+
45
+ function promptSnippet(content: unknown): string {
46
+ const text = extractText(content).replace(/\s+/g, ' ').trim()
47
+ if (text.length <= PROMPT_SNIPPET_LENGTH) return text
48
+ return `${text.slice(0, PROMPT_SNIPPET_LENGTH)}…`
49
+ }
50
+
51
+ function findLastUserMessage(ctx: ExtensionContext): { entryId: string; prompt: string } | undefined {
52
+ const branch = ctx.sessionManager.getBranch()
53
+ for (let i = branch.length - 1; i >= 0; i--) {
54
+ const entry = branch[i]
55
+ if (entry?.type === 'message' && entry.message.role === 'user') {
56
+ return { entryId: entry.id, prompt: promptSnippet(entry.message.content) }
57
+ }
58
+ }
59
+ return undefined
60
+ }
61
+
62
+ function checkpointLabel(checkpoint: Checkpoint, index: number): string {
63
+ const time = new Date(checkpoint.createdAt).toLocaleTimeString()
64
+ const marker = checkpoint.ref ? '' : ' [no code snapshot]'
65
+ return `${index + 1}. ${time} ${checkpoint.prompt || '(empty prompt)'}${marker}`
66
+ }
67
+
68
+ export default function (pi: ExtensionAPI) {
69
+ const checkpoints = new Map<string, Checkpoint>()
70
+ let pending: { ref: string; createdAt: string } | undefined
71
+ let shadowDir: string | undefined
72
+ let workTree: string | undefined
73
+
74
+ function gitShadow(args: string[]): ReturnType<ExtensionAPI['exec']> {
75
+ if (!shadowDir || !workTree) return Promise.resolve({ stdout: '', stderr: 'shadow repo not initialized', code: 1, killed: false })
76
+ return pi.exec('git', ['--git-dir', shadowDir, '--work-tree', workTree, ...args], { cwd: workTree })
77
+ }
78
+
79
+ async function ensureShadow(ctx: ExtensionContext): Promise<void> {
80
+ workTree = ctx.cwd
81
+ const sessionFile = (ctx.sessionManager as { getSessionFile?: () => string | undefined }).getSessionFile?.()
82
+ shadowDir = path.join(os.homedir(), '.pi', 'agent', 'checkpoints', sessionSlug(sessionFile))
83
+ const check = await pi.exec('git', ['--git-dir', shadowDir, 'rev-parse', '--git-dir'], { cwd: ctx.cwd })
84
+ if (check.code !== 0) {
85
+ await pi.exec('git', ['init', '--bare', '-b', 'main', shadowDir], { cwd: ctx.cwd })
86
+ await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.email', 'checkpoint@pi-code'], { cwd: ctx.cwd })
87
+ await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.name', 'pi-code-checkpoint'], { cwd: ctx.cwd })
88
+ }
89
+ }
90
+
91
+ async function snapshot(): Promise<{ ref: string; createdAt: string } | undefined> {
92
+ const createdAt = new Date().toISOString()
93
+ const add = await gitShadow(['add', '-A'])
94
+ if (add.code !== 0) return undefined
95
+ const commit = await gitShadow(['commit', '-m', 'checkpoint'])
96
+ if (commit.code !== 0) {
97
+ // nothing changed since the last snapshot: reuse HEAD, or create the first empty commit
98
+ const head = await gitShadow(['rev-parse', 'HEAD'])
99
+ if (head.code === 0) return { ref: head.stdout.trim(), createdAt }
100
+ const empty = await gitShadow(['commit', '--allow-empty', '-m', 'checkpoint'])
101
+ if (empty.code !== 0) return undefined
102
+ }
103
+ const sha = await gitShadow(['rev-parse', 'HEAD'])
104
+ return sha.code === 0 ? { ref: sha.stdout.trim(), createdAt } : undefined
105
+ }
106
+
107
+ async function restoreCode(ctx: ExtensionCommandContext, checkpoint: Checkpoint): Promise<boolean> {
108
+ if (!checkpoint.ref) {
109
+ ctx.ui.notify('Checkpoint has no code snapshot; code left untouched', 'warning')
110
+ return true
111
+ }
112
+ const result = await gitShadow(['checkout', '-f', checkpoint.ref, '--', '.'])
113
+ if (result.code !== 0) {
114
+ ctx.ui.notify(`Code restore failed: ${result.stderr.trim()}`, 'warning')
115
+ return false
116
+ }
117
+ return true
118
+ }
119
+
120
+ async function restoreConversation(ctx: ExtensionCommandContext, entryId: string): Promise<boolean> {
121
+ try {
122
+ // navigateTree's published type omits editorText, but it is present at runtime (docs/extensions.md)
123
+ const result = (await ctx.navigateTree(entryId, { summarize: false })) as { cancelled: boolean; editorText?: string }
124
+ if (result.cancelled) return false
125
+ if (typeof result.editorText === 'string') ctx.ui.setEditorText(result.editorText)
126
+ return true
127
+ } catch (error) {
128
+ const message = error instanceof Error ? error.message : String(error)
129
+ ctx.ui.notify(`Conversation restore failed: ${message}`, 'error')
130
+ return false
131
+ }
132
+ }
133
+
134
+ async function runRestoreMode(ctx: ExtensionCommandContext, checkpoint: Checkpoint): Promise<void> {
135
+ const mode = await ctx.ui.select('Restore mode:', [...RESTORE_MODES])
136
+ if (!mode) return
137
+ if (mode !== 'Conversation only' && !(await restoreCode(ctx, checkpoint))) return
138
+ if (mode !== 'Code only' && !(await restoreConversation(ctx, checkpoint.entryId))) return
139
+ ctx.ui.notify('Rewind complete', 'info')
140
+ }
141
+
142
+ pi.on('session_start', async (_event, ctx) => {
143
+ await ensureShadow(ctx)
144
+ checkpoints.clear()
145
+ for (const entry of ctx.sessionManager.getEntries()) {
146
+ if (entry.type !== 'custom' || entry.customType !== CUSTOM_TYPE) continue
147
+ const checkpoint = entry.data as Checkpoint | undefined
148
+ if (checkpoint?.entryId) checkpoints.set(checkpoint.entryId, checkpoint)
149
+ }
150
+ })
151
+
152
+ // Snapshot code state before the LLM acts in this turn. The user message
153
+ // that started the turn is not persisted yet at turn_start (it lands on
154
+ // message_end), so the checkpoint is only keyed and saved at turn_end.
155
+ pi.on('turn_start', async () => {
156
+ pending = await snapshot()
157
+ })
158
+
159
+ pi.on('turn_end', async (_event, ctx) => {
160
+ const snap = pending
161
+ pending = undefined
162
+ if (!snap) return
163
+
164
+ const target = findLastUserMessage(ctx)
165
+ if (!target || checkpoints.has(target.entryId)) return
166
+
167
+ const checkpoint: Checkpoint = { entryId: target.entryId, ref: snap.ref, prompt: target.prompt, createdAt: snap.createdAt }
168
+ checkpoints.set(checkpoint.entryId, checkpoint)
169
+ pi.appendEntry(CUSTOM_TYPE, checkpoint)
170
+ })
171
+
172
+ pi.registerCommand('rewind', {
173
+ description: 'Rewind code and/or conversation to a previous checkpoint',
174
+ handler: async (_args, ctx) => {
175
+ if (!ctx.hasUI) return
176
+ const ordered = [...checkpoints.values()].reverse()
177
+ if (ordered.length === 0) {
178
+ ctx.ui.notify('No checkpoints recorded yet', 'info')
179
+ return
180
+ }
181
+ const labels = ordered.map(checkpointLabel)
182
+ const choice = await ctx.ui.select('Rewind to checkpoint:', labels)
183
+ if (!choice) return
184
+ const checkpoint = ordered[labels.indexOf(choice)]
185
+ if (checkpoint) await runRestoreMode(ctx, checkpoint)
186
+ },
187
+ })
188
+
189
+ pi.on('session_before_fork', async (event, ctx) => {
190
+ const checkpoint = checkpoints.get(event.entryId)
191
+ if (!checkpoint?.ref || !ctx.hasUI) return
192
+
193
+ const choice = await ctx.ui.select('Restore code state?', ['Yes, restore code to that point', 'No, keep current code'])
194
+ if (choice?.startsWith('Yes')) {
195
+ const result = await gitShadow(['checkout', '-f', checkpoint.ref, '--', '.'])
196
+ ctx.ui.notify(result.code === 0 ? 'Code restored to checkpoint' : `Restore failed: ${result.stderr.trim()}`, result.code === 0 ? 'info' : 'warning')
197
+ }
198
+ })
199
+ }