pi-petrroll 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Petr Houška
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # pi-petrroll
2
+
3
+ Small, focused extensions for [Pi](https://pi.dev). MIT licensed.
4
+
5
+ | Extension | Command | Agent tool |
6
+ | --- | --- | --- |
7
+ | [Change directory](#change-directory) | `/cd <path>` | `change_directory` |
8
+
9
+ One extension, one command, one tool. No skills, shell wrappers, or replacement built-in tools.
10
+
11
+ ## Install
12
+
13
+ Requires **interactive Pi 0.85.1 or newer** (`@earendil-works/pi-coding-agent`); tested against 0.85.1. Earlier Pi versions may not rebuild cwd-bound services when switching sessions.
14
+
15
+ ```sh
16
+ pi install npm:pi-petrroll
17
+ ```
18
+
19
+ Or install directly from GitHub:
20
+
21
+ ```sh
22
+ pi install git:github.com/petrroll/pi-petrroll
23
+ ```
24
+
25
+ Run `/reload` in an existing Pi session, or start Pi. Install globally (the default), so the extension remains available when switching projects.
26
+
27
+ For local development, use this checkout instead of the npm or GitHub install:
28
+
29
+ ```sh
30
+ pi install /absolute/path/to/pi-petrroll
31
+ ```
32
+
33
+ Choose one installation source, not both. Use `pi config` to enable/disable individual extensions as this collection grows.
34
+
35
+ ## Change directory
36
+
37
+ ### Let the agent do it
38
+
39
+ For example, start Pi in `~/projects` and ask:
40
+
41
+ > Create a worktree of pi-mono for issue #123, switch this session into it, and fix the issue there.
42
+
43
+ The agent uses ordinary `bash`/Git to create the worktree, then calls:
44
+
45
+ ```json
46
+ { "path": "~/projects/pi-issue-123" }
47
+ ```
48
+
49
+ with the `change_directory` tool. Once the current run settles, Pi copies the conversation into the worktree's session storage, switches to that session, and automatically continues the task there.
50
+
51
+ No quit, terminal `cd`, restart, or cross-directory session-copy confirmation. **Normal Pi project-trust prompts and other extensions' session-switch guards still apply.** This extension does not automatically trust destination code.
52
+
53
+ ### Change it yourself
54
+
55
+ ```text
56
+ /cd ~/projects/pi-issue-123
57
+ /cd ../another-worktree
58
+ /cd /home/me/projects/a folder with spaces
59
+ /cd "../a folder with spaces"
60
+ ```
61
+
62
+ Bare `/cd` shows the current directory and usage. Relative paths use Pi's current cwd. `~`, `~/…`, single-quoted paths, JSON double-quoted paths, and symlinks are supported. Paths are not shell commands: no variable expansion, globbing, `~username`, or `cd -`.
63
+
64
+ Manual `/cd` switches without starting another model response; the agent tool resumes automatically. An existing destination is required. The extension does not create directories or worktrees itself.
65
+
66
+ ### What changes
67
+
68
+ - A new session ID and session file are created for the destination, using Pi's `SessionManager.forkFrom()`.
69
+ - The original session is left in place. The full conversation tree is copied, and the currently selected branch is preserved.
70
+ - Pi's native session replacement rebuilds built-in tools, the working-directory display, settings, extensions, skills, and project context such as `AGENTS.md` for the destination.
71
+ - `pi -c` from the destination can continue the copied session. Copies use Pi's normal per-directory session storage (respecting `PI_CODING_AGENT_DIR`), not a source session's explicit `--session-dir` or `PI_CODING_AGENT_SESSION_DIR` override. If you use those overrides, resume the copy by its file path or remove the override.
72
+ - No `process.chdir()` trick, built-in tool overrides, or Pi internals are patched. Your parent terminal's working directory does **not** change.
73
+
74
+ ### Safety and limitations
75
+
76
+ - Interactive/TUI mode only. Print, JSON, RPC, and `--no-session` are deliberately unsupported in this first version.
77
+ - Linux is tested locally and in Ubuntu CI. Windows is expected to work but has not been verified. For manual commands on Windows, prefer `/cd "C:/Users/you/worktree with spaces"`; double-quoted backslashes are interpreted as JSON escapes. Use `~/path`, not `~\path`. The agent tool handles quoting automatically.
78
+ - Pi's session cwd changes, but Node's `process.cwd()` does not. Third-party extensions that use process-relative filesystem paths or spawn commands without an explicit cwd may still operate in the original directory. Pi's built-in tools and `pi.exec()` use the rebuilt session/extension cwd.
79
+ - Switching does not recreate shell environment changes such as `direnv` or virtualenv activation, and does not move already-running background processes.
80
+ - The session must already be saved. In a brand-new session, send a message first.
81
+ - The agent should call `change_directory` **alone**, after worktree creation has completed. The tool requests early termination and the command waits for idle so completed tool results are included in the copy. If mixed with other tools, those calls still run in the **old** directory; the switch waits until the run settles.
82
+ - Explicitly aborting a pending tool handoff prevents the switch. Invalid paths and concurrent requests are rejected.
83
+ - A cancelled session switch removes the unused copy and keeps the original session. If Pi fails while rebuilding the replacement runtime, its normal fatal-error handling applies; the original and any created copy remain available for recovery.
84
+ - The copied history can contain references to the old project. A visible context message records the change, and the new system prompt contains the destination's instructions.
85
+ - Switching reloads project extensions according to Pi's trust policy. Install only code you trust.
86
+
87
+ ## Development
88
+
89
+ ```sh
90
+ npm ci --ignore-scripts
91
+ npm run check
92
+ npm test
93
+ ```
94
+
95
+ Tests cover path handling, copy/branch preservation, cancellation, aborts, concurrency, and a real Pi runtime handoff with a fake model stream. The runtime test verifies that tool results are copied, `read` and `bash` use the new cwd, destination `AGENTS.md` replaces source context, and the agent continues. Tests do not call a model API or use your credentials.
96
+
97
+ ```text
98
+ extensions/
99
+ cd/
100
+ index.ts
101
+ # Add another-extension/index.ts here.
102
+ tests/
103
+ cd.test.ts
104
+ runtime.test.ts
105
+ ```
106
+
107
+ Pi discovers entry points through `pi.extensions` in `package.json`. Add future independent extensions under `extensions/<name>/index.ts`; no new skill or package is needed. Keep helpers inside the extension directory so they are not discovered as standalone extensions.
108
+
109
+ ## License
110
+
111
+ [MIT](LICENSE) © 2026 Petr Houška.
@@ -0,0 +1,148 @@
1
+ import { existsSync, realpathSync, statSync, unlinkSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { resolve } from "node:path";
4
+ import {
5
+ SessionManager,
6
+ type ExtensionAPI,
7
+ type ExtensionContext,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { Type } from "typebox";
10
+
11
+ /** The entire argument is a path, not a shell expression. */
12
+ export function resolveDirectory(argument: string, cwd: string): string {
13
+ let path = argument.trim();
14
+ if (path.startsWith('"')) {
15
+ path = JSON.parse(path) as string;
16
+ if (typeof path !== "string") throw new Error("Expected a directory path.");
17
+ } else if (path.startsWith("'") && path.endsWith("'")) {
18
+ path = path.slice(1, -1);
19
+ }
20
+ if (!path || /[\x00-\x1f\x7f]/.test(path)) {
21
+ throw new Error("Provide a non-empty directory path without control characters.");
22
+ }
23
+ if (path.startsWith("@")) path = path.slice(1);
24
+ if (path === "~") path = homedir();
25
+ else if (path.startsWith("~/")) path = resolve(homedir(), path.slice(2));
26
+ else if (path.startsWith("~")) throw new Error("Use ~ or ~/path, not ~username.");
27
+ const target = realpathSync(resolve(cwd, path));
28
+ if (!statSync(target).isDirectory()) throw new Error(`Not a directory: ${target}`);
29
+ return target;
30
+ }
31
+
32
+ function requireSavedSession(ctx: ExtensionContext): string {
33
+ // Interactive mode owns the asynchronous command handoff and runtime rebinding.
34
+ if (ctx.mode !== "tui") throw new Error("Changing directory currently requires interactive Pi.");
35
+ const file = ctx.sessionManager.getSessionFile();
36
+ if (!file || !existsSync(file)) {
37
+ throw new Error("This session is not saved yet. Send a message first; --no-session is not supported.");
38
+ }
39
+ return file;
40
+ }
41
+
42
+ export default function cdExtension(pi: ExtensionAPI) {
43
+ let busy = false;
44
+ let toolRequest: { argument: string; signal: AbortSignal | undefined } | undefined;
45
+
46
+ pi.registerCommand("cd", {
47
+ description: "Copy this session into another directory and switch there: /cd <path>",
48
+ handler: async (args, ctx) => {
49
+ const request = toolRequest?.argument === args ? toolRequest : undefined;
50
+ if (request) toolRequest = undefined;
51
+ if (busy) throw new Error("A directory change is already pending.");
52
+ if (!args.trim()) {
53
+ ctx.ui.notify(`Current directory: ${ctx.cwd}\nUsage: /cd <path>`, "info");
54
+ return;
55
+ }
56
+ busy = true;
57
+ try {
58
+ // Commands dispatch immediately, even with deliverAs: followUp. Waiting
59
+ // here (never inside tool.execute) lets all tool results reach disk first.
60
+ await ctx.waitForIdle();
61
+ request?.signal?.throwIfAborted();
62
+ const source = requireSavedSession(ctx);
63
+ const target = resolveDirectory(args, ctx.cwd);
64
+ if (target === realpathSync(ctx.cwd)) {
65
+ ctx.ui.notify(`Already in ${target}`, "info");
66
+ return;
67
+ }
68
+
69
+ const copy = SessionManager.forkFrom(source, target);
70
+ // forkFrom copies the whole tree but opens its last entry. Preserve the
71
+ // actual selected leaf, including when the user navigated with /tree.
72
+ const leaf = ctx.sessionManager.getLeafId();
73
+ if (leaf) copy.branch(leaf);
74
+ else copy.resetLeaf();
75
+ copy.appendCustomMessageEntry(
76
+ "pi-petrroll:cd",
77
+ `Working directory changed from ${ctx.cwd} to ${target}. ` +
78
+ "Relative paths now refer to the new directory. Follow the newly loaded project instructions.",
79
+ true,
80
+ { from: ctx.cwd, to: target, parentSession: source },
81
+ );
82
+ const destination = copy.getSessionFile()!;
83
+ // Do not use captured pi/ctx in withSession: the old runtime is invalid.
84
+ const result = await ctx.switchSession(destination, {
85
+ withSession: async (fresh) => {
86
+ fresh.ui.notify(`Changed directory to ${fresh.cwd}`, "info");
87
+ if (request) {
88
+ await fresh.sendUserMessage(
89
+ "The requested directory change has completed. Continue the outstanding task in this directory. " +
90
+ "If no work remains, briefly confirm the change.",
91
+ );
92
+ }
93
+ },
94
+ });
95
+ if (result.cancelled) {
96
+ unlinkSync(destination);
97
+ pi.sendMessage({
98
+ customType: "pi-petrroll:cd",
99
+ content: "Directory change cancelled. The original session and working directory are unchanged.",
100
+ display: true,
101
+ });
102
+ ctx.ui.notify("Directory change cancelled.", "warning");
103
+ }
104
+ } finally {
105
+ // Local state only; session-bound pi/ctx may now be stale.
106
+ busy = false;
107
+ }
108
+ },
109
+ });
110
+
111
+ pi.registerTool({
112
+ name: "change_directory",
113
+ label: "Change directory",
114
+ description:
115
+ "Copy the current saved session into an existing directory and switch Pi there, reloading tools and project context. " +
116
+ "Interactive mode only. The switch happens after the current run settles, then work resumes automatically. " +
117
+ "Create any git worktree with bash first. Call this tool alone, not in parallel with other tools; do not do more work in the old directory afterward.",
118
+ promptSnippet: "Switch Pi to another project/worktree while keeping conversation history",
119
+ promptGuidelines: [
120
+ "Use change_directory to move Pi into a newly created worktree or another project; bash cd does not change Pi's working directory.",
121
+ "Call change_directory alone and let its handoff finish before doing further work.",
122
+ ],
123
+ parameters: Type.Object({
124
+ path: Type.String({ description: "Existing directory: absolute, relative to Pi's cwd, or ~/path" }),
125
+ }),
126
+ async execute(_id, { path }, signal, _onUpdate, ctx) {
127
+ signal?.throwIfAborted();
128
+ if (busy || toolRequest) throw new Error("A directory change is already pending.");
129
+ requireSavedSession(ctx);
130
+ const target = resolveDirectory(JSON.stringify(path), ctx.cwd);
131
+ if (target === realpathSync(ctx.cwd)) {
132
+ return { content: [{ type: "text", text: `Already in ${target}.` }], details: { cwd: target } };
133
+ }
134
+ const argument = JSON.stringify(target);
135
+ toolRequest = { argument, signal };
136
+ // Explicit expansion is essential: otherwise /cd is sent as text to the LLM.
137
+ pi.sendUserMessage(`/cd ${argument}`, { deliverAs: "followUp", expandPromptTemplates: true });
138
+ return {
139
+ content: [{
140
+ type: "text",
141
+ text: `Directory change to ${target} requested, not completed yet. End this run now; work will resume automatically after switching.`,
142
+ }],
143
+ details: { target, status: "pending" },
144
+ terminate: true,
145
+ };
146
+ },
147
+ });
148
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "pi-petrroll",
3
+ "version": "0.1.0",
4
+ "description": "Small, focused Pi extensions by petrroll: change projects without leaving your session.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Petr Houška (petrroll)",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/petrroll/pi-petrroll.git"
11
+ },
12
+ "keywords": [
13
+ "pi-package",
14
+ "pi",
15
+ "extensions",
16
+ "worktree"
17
+ ],
18
+ "files": [
19
+ "extensions",
20
+ "LICENSE",
21
+ "README.md"
22
+ ],
23
+ "pi": {
24
+ "extensions": [
25
+ "./extensions"
26
+ ]
27
+ },
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
31
+ "scripts": {
32
+ "check": "tsc --noEmit",
33
+ "test": "tsx --test tests/*.test.ts"
34
+ },
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "typebox": "*"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@earendil-works/pi-coding-agent": {
41
+ "optional": true
42
+ },
43
+ "typebox": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "@earendil-works/pi-ai": "^0.85.1",
49
+ "@earendil-works/pi-coding-agent": "0.85.1",
50
+ "@types/node": "^22.0.0",
51
+ "tsx": "^4.20.0",
52
+ "typebox": "^1.0.0",
53
+ "typescript": "^5.9.0"
54
+ }
55
+ }