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 +22 -0
- package/README.md +51 -0
- package/extensions/claude-rules.ts +136 -0
- package/extensions/commands.ts +45 -0
- package/extensions/context-imports.ts +118 -0
- package/extensions/git-checkpoint.ts +199 -0
- package/extensions/hooks.ts +178 -0
- package/extensions/mcp.ts +268 -0
- package/extensions/memory.ts +127 -0
- package/extensions/notify.ts +52 -0
- package/extensions/output-styles.ts +159 -0
- package/extensions/plan-mode/README.md +65 -0
- package/extensions/plan-mode/index.ts +352 -0
- package/extensions/plan-mode/utils.ts +175 -0
- package/extensions/question.ts +258 -0
- package/extensions/skills.ts +41 -0
- package/extensions/status-line.ts +59 -0
- package/extensions/subagent/README.md +172 -0
- package/extensions/subagent/agents/planner.md +37 -0
- package/extensions/subagent/agents/reviewer.md +35 -0
- package/extensions/subagent/agents/scout.md +50 -0
- package/extensions/subagent/agents/worker.md +24 -0
- package/extensions/subagent/agents.ts +147 -0
- package/extensions/subagent/background.ts +89 -0
- package/extensions/subagent/index.ts +953 -0
- package/extensions/subagent/prompts/implement-and-review.md +10 -0
- package/extensions/subagent/prompts/implement.md +10 -0
- package/extensions/subagent/prompts/scout-and-plan.md +9 -0
- package/extensions/todo.ts +504 -0
- package/extensions/web.ts +195 -0
- package/package.json +59 -0
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
|
+

|
|
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
|
+
}
|