@mariozechner/pi-coding-agent 0.22.4 → 0.23.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +54 -2
  3. package/dist/cli/args.d.ts +1 -0
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +5 -0
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/core/agent-session.d.ts +9 -0
  8. package/dist/core/agent-session.d.ts.map +1 -1
  9. package/dist/core/agent-session.js +64 -11
  10. package/dist/core/agent-session.js.map +1 -1
  11. package/dist/core/custom-tools/index.d.ts +6 -0
  12. package/dist/core/custom-tools/index.d.ts.map +1 -0
  13. package/dist/core/custom-tools/index.js +5 -0
  14. package/dist/core/custom-tools/index.js.map +1 -0
  15. package/dist/core/custom-tools/loader.d.ts +24 -0
  16. package/dist/core/custom-tools/loader.d.ts.map +1 -0
  17. package/dist/core/custom-tools/loader.js +210 -0
  18. package/dist/core/custom-tools/loader.js.map +1 -0
  19. package/dist/core/custom-tools/types.d.ts +81 -0
  20. package/dist/core/custom-tools/types.d.ts.map +1 -0
  21. package/dist/core/custom-tools/types.js +8 -0
  22. package/dist/core/custom-tools/types.js.map +1 -0
  23. package/dist/core/hooks/index.d.ts +1 -1
  24. package/dist/core/hooks/index.d.ts.map +1 -1
  25. package/dist/core/hooks/index.js.map +1 -1
  26. package/dist/core/hooks/types.d.ts +14 -19
  27. package/dist/core/hooks/types.d.ts.map +1 -1
  28. package/dist/core/hooks/types.js.map +1 -1
  29. package/dist/core/index.d.ts +1 -0
  30. package/dist/core/index.d.ts.map +1 -1
  31. package/dist/core/index.js +1 -0
  32. package/dist/core/index.js.map +1 -1
  33. package/dist/core/session-manager.d.ts.map +1 -1
  34. package/dist/core/session-manager.js +4 -0
  35. package/dist/core/session-manager.js.map +1 -1
  36. package/dist/core/settings-manager.d.ts +3 -0
  37. package/dist/core/settings-manager.d.ts.map +1 -1
  38. package/dist/core/settings-manager.js +7 -0
  39. package/dist/core/settings-manager.js.map +1 -1
  40. package/dist/index.d.ts +4 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +3 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/main.d.ts.map +1 -1
  45. package/dist/main.js +22 -3
  46. package/dist/main.js.map +1 -1
  47. package/dist/modes/interactive/components/tool-execution.d.ts +4 -1
  48. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  49. package/dist/modes/interactive/components/tool-execution.js +64 -20
  50. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  51. package/dist/modes/interactive/interactive-mode.d.ts +11 -2
  52. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  53. package/dist/modes/interactive/interactive-mode.js +66 -12
  54. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  55. package/dist/modes/print-mode.d.ts.map +1 -1
  56. package/dist/modes/print-mode.js +35 -9
  57. package/dist/modes/print-mode.js.map +1 -1
  58. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  59. package/dist/modes/rpc/rpc-mode.js +27 -2
  60. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  61. package/docs/custom-tools.md +341 -0
  62. package/docs/hooks.md +54 -34
  63. package/examples/custom-tools/README.md +101 -0
  64. package/examples/custom-tools/hello.ts +20 -0
  65. package/examples/custom-tools/question.ts +83 -0
  66. package/examples/custom-tools/todo.ts +192 -0
  67. package/examples/hooks/README.md +79 -0
  68. package/examples/hooks/file-trigger.ts +36 -0
  69. package/examples/hooks/git-checkpoint.ts +48 -0
  70. package/examples/hooks/permission-gate.ts +38 -0
  71. package/examples/hooks/protected-paths.ts +30 -0
  72. package/package.json +7 -6
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Todo Tool - Demonstrates state management via session entries
3
+ *
4
+ * This tool stores state in tool result details (not external files),
5
+ * which allows proper branching - when you branch, the todo state
6
+ * is automatically correct for that point in history.
7
+ *
8
+ * The onSession callback reconstructs state by scanning past tool results.
9
+ */
10
+
11
+ import { Type } from "@mariozechner/pi-coding-agent";
12
+ import { StringEnum } from "@mariozechner/pi-ai";
13
+ import { Text } from "@mariozechner/pi-tui";
14
+ import type { CustomAgentTool, CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent";
15
+
16
+ interface Todo {
17
+ id: number;
18
+ text: string;
19
+ done: boolean;
20
+ }
21
+
22
+ // State stored in tool result details
23
+ interface TodoDetails {
24
+ action: "list" | "add" | "toggle" | "clear";
25
+ todos: Todo[];
26
+ nextId: number;
27
+ error?: string;
28
+ }
29
+
30
+ // Define schema separately for proper type inference
31
+ const TodoParams = Type.Object({
32
+ action: StringEnum(["list", "add", "toggle", "clear"] as const),
33
+ text: Type.Optional(Type.String({ description: "Todo text (for add)" })),
34
+ id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })),
35
+ });
36
+
37
+ const factory: CustomToolFactory = (_pi) => {
38
+ // In-memory state (reconstructed from session on load)
39
+ let todos: Todo[] = [];
40
+ let nextId = 1;
41
+
42
+ /**
43
+ * Reconstruct state from session entries.
44
+ * Scans tool results for this tool and applies them in order.
45
+ */
46
+ const reconstructState = (event: ToolSessionEvent) => {
47
+ todos = [];
48
+ nextId = 1;
49
+
50
+ for (const entry of event.entries) {
51
+ if (entry.type !== "message") continue;
52
+ const msg = entry.message;
53
+
54
+ // Tool results have role "toolResult"
55
+ if (msg.role !== "toolResult") continue;
56
+ if (msg.toolName !== "todo") continue;
57
+
58
+ const details = msg.details as TodoDetails | undefined;
59
+ if (details) {
60
+ todos = details.todos;
61
+ nextId = details.nextId;
62
+ }
63
+ }
64
+ };
65
+
66
+ const tool: CustomAgentTool<typeof TodoParams, TodoDetails> = {
67
+ name: "todo",
68
+ label: "Todo",
69
+ description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
70
+ parameters: TodoParams,
71
+
72
+ // Called on session start/switch/branch/clear
73
+ onSession: reconstructState,
74
+
75
+ async execute(_toolCallId, params) {
76
+ switch (params.action) {
77
+ case "list":
78
+ return {
79
+ content: [{ type: "text", text: todos.length ? todos.map((t) => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n") : "No todos" }],
80
+ details: { action: "list", todos: [...todos], nextId },
81
+ };
82
+
83
+ case "add":
84
+ if (!params.text) {
85
+ return {
86
+ content: [{ type: "text", text: "Error: text required for add" }],
87
+ details: { action: "add", todos: [...todos], nextId, error: "text required" },
88
+ };
89
+ }
90
+ const newTodo: Todo = { id: nextId++, text: params.text, done: false };
91
+ todos.push(newTodo);
92
+ return {
93
+ content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }],
94
+ details: { action: "add", todos: [...todos], nextId },
95
+ };
96
+
97
+ case "toggle":
98
+ if (params.id === undefined) {
99
+ return {
100
+ content: [{ type: "text", text: "Error: id required for toggle" }],
101
+ details: { action: "toggle", todos: [...todos], nextId, error: "id required" },
102
+ };
103
+ }
104
+ const todo = todos.find((t) => t.id === params.id);
105
+ if (!todo) {
106
+ return {
107
+ content: [{ type: "text", text: `Todo #${params.id} not found` }],
108
+ details: { action: "toggle", todos: [...todos], nextId, error: `#${params.id} not found` },
109
+ };
110
+ }
111
+ todo.done = !todo.done;
112
+ return {
113
+ content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }],
114
+ details: { action: "toggle", todos: [...todos], nextId },
115
+ };
116
+
117
+ case "clear":
118
+ const count = todos.length;
119
+ todos = [];
120
+ nextId = 1;
121
+ return {
122
+ content: [{ type: "text", text: `Cleared ${count} todos` }],
123
+ details: { action: "clear", todos: [], nextId: 1 },
124
+ };
125
+
126
+ default:
127
+ return {
128
+ content: [{ type: "text", text: `Unknown action: ${params.action}` }],
129
+ details: { action: "list", todos: [...todos], nextId, error: `unknown action: ${params.action}` },
130
+ };
131
+ }
132
+ },
133
+
134
+ renderCall(args, theme) {
135
+ let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
136
+ if (args.text) text += " " + theme.fg("dim", `"${args.text}"`);
137
+ if (args.id !== undefined) text += " " + theme.fg("accent", `#${args.id}`);
138
+ return new Text(text, 0, 0);
139
+ },
140
+
141
+ renderResult(result, { expanded }, theme) {
142
+ const { details } = result;
143
+ if (!details) {
144
+ const text = result.content[0];
145
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
146
+ }
147
+
148
+ // Error
149
+ if (details.error) {
150
+ return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
151
+ }
152
+
153
+ const todoList = details.todos;
154
+
155
+ switch (details.action) {
156
+ case "list":
157
+ if (todoList.length === 0) {
158
+ return new Text(theme.fg("dim", "No todos"), 0, 0);
159
+ }
160
+ let listText = theme.fg("muted", `${todoList.length} todo(s):`);
161
+ const display = expanded ? todoList : todoList.slice(0, 5);
162
+ for (const t of display) {
163
+ const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○");
164
+ const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
165
+ listText += "\n" + check + " " + theme.fg("accent", `#${t.id}`) + " " + itemText;
166
+ }
167
+ if (!expanded && todoList.length > 5) {
168
+ listText += "\n" + theme.fg("dim", `... ${todoList.length - 5} more`);
169
+ }
170
+ return new Text(listText, 0, 0);
171
+
172
+ case "add": {
173
+ const added = todoList[todoList.length - 1];
174
+ return new Text(theme.fg("success", "✓ Added ") + theme.fg("accent", `#${added.id}`) + " " + theme.fg("muted", added.text), 0, 0);
175
+ }
176
+
177
+ case "toggle": {
178
+ const text = result.content[0];
179
+ const msg = text?.type === "text" ? text.text : "";
180
+ return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
181
+ }
182
+
183
+ case "clear":
184
+ return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
185
+ }
186
+ },
187
+ };
188
+
189
+ return tool;
190
+ };
191
+
192
+ export default factory;
@@ -0,0 +1,79 @@
1
+ # Hooks Examples
2
+
3
+ Example hooks for pi-coding-agent.
4
+
5
+ ## Examples
6
+
7
+ ### permission-gate.ts
8
+ Prompts for confirmation before running dangerous bash commands (rm -rf, sudo, chmod 777, etc.).
9
+
10
+ ### git-checkpoint.ts
11
+ Creates git stash checkpoints at each turn, allowing code restoration when branching.
12
+
13
+ ### protected-paths.ts
14
+ Blocks writes to protected paths (.env, .git/, node_modules/).
15
+
16
+ ### file-trigger.ts
17
+ Watches a trigger file and injects its contents into the conversation. Useful for external systems (CI, file watchers, webhooks) to send messages to the agent.
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ # Test directly
23
+ pi --hook examples/hooks/permission-gate.ts
24
+
25
+ # Or copy to hooks directory for persistent use
26
+ cp permission-gate.ts ~/.pi/agent/hooks/
27
+ ```
28
+
29
+ ## Writing Hooks
30
+
31
+ See [docs/hooks.md](../../docs/hooks.md) for full documentation.
32
+
33
+ ### Key Points
34
+
35
+ **Hook structure:**
36
+ ```typescript
37
+ import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
38
+
39
+ export default function (pi: HookAPI) {
40
+ pi.on("session", async (event, ctx) => {
41
+ // event.reason: "start" | "switch" | "clear"
42
+ // ctx.ui, ctx.exec, ctx.cwd, ctx.sessionFile, ctx.hasUI
43
+ });
44
+
45
+ pi.on("tool_call", async (event, ctx) => {
46
+ // Can block tool execution
47
+ if (dangerous) {
48
+ return { block: true, reason: "Blocked" };
49
+ }
50
+ return undefined;
51
+ });
52
+
53
+ pi.on("tool_result", async (event, ctx) => {
54
+ // Can modify result
55
+ return { result: "modified result" };
56
+ });
57
+ }
58
+ ```
59
+
60
+ **Available events:**
61
+ - `session` - startup, session switch, clear
62
+ - `branch` - before branching (can skip conversation restore)
63
+ - `agent_start` / `agent_end` - per user prompt
64
+ - `turn_start` / `turn_end` - per LLM turn
65
+ - `tool_call` - before tool execution (can block)
66
+ - `tool_result` - after tool execution (can modify)
67
+
68
+ **UI methods:**
69
+ ```typescript
70
+ const choice = await ctx.ui.select("Title", ["Option A", "Option B"]);
71
+ const confirmed = await ctx.ui.confirm("Title", "Are you sure?");
72
+ const input = await ctx.ui.input("Title", "placeholder");
73
+ ctx.ui.notify("Message", "info"); // or "warning", "error"
74
+ ```
75
+
76
+ **Sending messages:**
77
+ ```typescript
78
+ pi.send("Message to inject into conversation");
79
+ ```
@@ -0,0 +1,36 @@
1
+ /**
2
+ * File Trigger Hook
3
+ *
4
+ * Watches a trigger file and injects its contents into the conversation.
5
+ * Useful for external systems to send messages to the agent.
6
+ *
7
+ * Usage:
8
+ * echo "Run the tests" > /tmp/agent-trigger.txt
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
13
+
14
+ export default function (pi: HookAPI) {
15
+ pi.on("session", async (event, ctx) => {
16
+ if (event.reason !== "start") return;
17
+
18
+ const triggerFile = "/tmp/agent-trigger.txt";
19
+
20
+ fs.watch(triggerFile, () => {
21
+ try {
22
+ const content = fs.readFileSync(triggerFile, "utf-8").trim();
23
+ if (content) {
24
+ pi.send(`External trigger: ${content}`);
25
+ fs.writeFileSync(triggerFile, ""); // Clear after reading
26
+ }
27
+ } catch {
28
+ // File might not exist yet
29
+ }
30
+ });
31
+
32
+ if (ctx.hasUI) {
33
+ ctx.ui.notify(`Watching ${triggerFile}`, "info");
34
+ }
35
+ });
36
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Git Checkpoint Hook
3
+ *
4
+ * Creates git stash checkpoints at each turn so /branch can restore code state.
5
+ * When branching, offers to restore code to that point in history.
6
+ */
7
+
8
+ import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
9
+
10
+ export default function (pi: HookAPI) {
11
+ const checkpoints = new Map<number, string>();
12
+
13
+ pi.on("turn_start", async (event, ctx) => {
14
+ // Create a git stash entry before LLM makes changes
15
+ const { stdout } = await ctx.exec("git", ["stash", "create"]);
16
+ const ref = stdout.trim();
17
+ if (ref) {
18
+ checkpoints.set(event.turnIndex, ref);
19
+ }
20
+ });
21
+
22
+ pi.on("branch", async (event, ctx) => {
23
+ const ref = checkpoints.get(event.targetTurnIndex);
24
+ if (!ref) return undefined;
25
+
26
+ if (!ctx.hasUI) {
27
+ // In non-interactive mode, don't restore automatically
28
+ return undefined;
29
+ }
30
+
31
+ const choice = await ctx.ui.select("Restore code state?", [
32
+ "Yes, restore code to that point",
33
+ "No, keep current code",
34
+ ]);
35
+
36
+ if (choice?.startsWith("Yes")) {
37
+ await ctx.exec("git", ["stash", "apply", ref]);
38
+ ctx.ui.notify("Code restored to checkpoint", "info");
39
+ }
40
+
41
+ return undefined;
42
+ });
43
+
44
+ pi.on("agent_end", async () => {
45
+ // Clear checkpoints after agent completes
46
+ checkpoints.clear();
47
+ });
48
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Permission Gate Hook
3
+ *
4
+ * Prompts for confirmation before running potentially dangerous bash commands.
5
+ * Patterns checked: rm -rf, sudo, chmod/chown 777
6
+ */
7
+
8
+ import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
9
+
10
+ export default function (pi: HookAPI) {
11
+ const dangerousPatterns = [
12
+ /\brm\s+(-rf?|--recursive)/i,
13
+ /\bsudo\b/i,
14
+ /\b(chmod|chown)\b.*777/i,
15
+ ];
16
+
17
+ pi.on("tool_call", async (event, ctx) => {
18
+ if (event.toolName !== "bash") return undefined;
19
+
20
+ const command = event.input.command as string;
21
+ const isDangerous = dangerousPatterns.some((p) => p.test(command));
22
+
23
+ if (isDangerous) {
24
+ if (!ctx.hasUI) {
25
+ // In non-interactive mode, block by default
26
+ return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
27
+ }
28
+
29
+ const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]);
30
+
31
+ if (choice !== "Yes") {
32
+ return { block: true, reason: "Blocked by user" };
33
+ }
34
+ }
35
+
36
+ return undefined;
37
+ });
38
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Protected Paths Hook
3
+ *
4
+ * Blocks write and edit operations to protected paths.
5
+ * Useful for preventing accidental modifications to sensitive files.
6
+ */
7
+
8
+ import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
9
+
10
+ export default function (pi: HookAPI) {
11
+ const protectedPaths = [".env", ".git/", "node_modules/"];
12
+
13
+ pi.on("tool_call", async (event, ctx) => {
14
+ if (event.toolName !== "write" && event.toolName !== "edit") {
15
+ return undefined;
16
+ }
17
+
18
+ const path = event.input.path as string;
19
+ const isProtected = protectedPaths.some((p) => path.includes(p));
20
+
21
+ if (isProtected) {
22
+ if (ctx.hasUI) {
23
+ ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning");
24
+ }
25
+ return { block: true, reason: `Path "${path}" is protected` };
26
+ }
27
+
28
+ return undefined;
29
+ });
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mariozechner/pi-coding-agent",
3
- "version": "0.22.4",
3
+ "version": "0.23.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -25,6 +25,7 @@
25
25
  "files": [
26
26
  "dist",
27
27
  "docs",
28
+ "examples",
28
29
  "CHANGELOG.md"
29
30
  ],
30
31
  "scripts": {
@@ -32,16 +33,16 @@
32
33
  "build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js && npm run copy-assets",
33
34
  "build:binary": "npm run build && bun build --compile ./dist/cli.js --outfile dist/pi && npm run copy-binary-assets",
34
35
  "copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/",
35
- "copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/ && cp -r docs dist/",
36
+ "copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/ && cp -r docs dist/ && cp -r examples dist/",
36
37
  "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
37
- "check": "tsgo --noEmit",
38
+ "check": "tsgo --noEmit && tsc -p tsconfig.examples.json",
38
39
  "test": "vitest --run",
39
40
  "prepublishOnly": "npm run clean && npm run build"
40
41
  },
41
42
  "dependencies": {
42
- "@mariozechner/pi-agent-core": "^0.22.4",
43
- "@mariozechner/pi-ai": "^0.22.4",
44
- "@mariozechner/pi-tui": "^0.22.4",
43
+ "@mariozechner/pi-agent-core": "^0.23.0",
44
+ "@mariozechner/pi-ai": "^0.23.0",
45
+ "@mariozechner/pi-tui": "^0.23.0",
45
46
  "chalk": "^5.5.0",
46
47
  "diff": "^8.0.2",
47
48
  "glob": "^11.0.3",