@oh-my-pi/pi-coding-agent 3.15.1 → 3.20.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 (127) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/docs/extensions.md +1055 -0
  3. package/docs/rpc.md +69 -13
  4. package/docs/session-tree-plan.md +1 -1
  5. package/examples/extensions/README.md +141 -0
  6. package/examples/extensions/api-demo.ts +87 -0
  7. package/examples/extensions/chalk-logger.ts +26 -0
  8. package/examples/extensions/hello.ts +33 -0
  9. package/examples/extensions/pirate.ts +44 -0
  10. package/examples/extensions/plan-mode.ts +551 -0
  11. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  12. package/examples/extensions/todo.ts +299 -0
  13. package/examples/extensions/tools.ts +145 -0
  14. package/examples/extensions/with-deps/index.ts +36 -0
  15. package/examples/extensions/with-deps/package-lock.json +31 -0
  16. package/examples/extensions/with-deps/package.json +16 -0
  17. package/examples/sdk/02-custom-model.ts +3 -3
  18. package/examples/sdk/05-tools.ts +7 -3
  19. package/examples/sdk/06-extensions.ts +81 -0
  20. package/examples/sdk/06-hooks.ts +14 -13
  21. package/examples/sdk/08-prompt-templates.ts +42 -0
  22. package/examples/sdk/08-slash-commands.ts +17 -12
  23. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  24. package/examples/sdk/12-full-control.ts +6 -6
  25. package/package.json +11 -7
  26. package/src/capability/extension-module.ts +34 -0
  27. package/src/cli/args.ts +22 -7
  28. package/src/cli/file-processor.ts +38 -67
  29. package/src/cli/list-models.ts +1 -1
  30. package/src/config.ts +25 -14
  31. package/src/core/agent-session.ts +505 -242
  32. package/src/core/auth-storage.ts +33 -21
  33. package/src/core/compaction/branch-summarization.ts +4 -4
  34. package/src/core/compaction/compaction.ts +3 -3
  35. package/src/core/custom-commands/bundled/wt/index.ts +430 -0
  36. package/src/core/custom-commands/loader.ts +9 -0
  37. package/src/core/custom-tools/wrapper.ts +5 -0
  38. package/src/core/event-bus.ts +59 -0
  39. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  40. package/src/core/export-html/vendor/marked.min.js +6 -0
  41. package/src/core/extensions/index.ts +100 -0
  42. package/src/core/extensions/loader.ts +501 -0
  43. package/src/core/extensions/runner.ts +477 -0
  44. package/src/core/extensions/types.ts +712 -0
  45. package/src/core/extensions/wrapper.ts +147 -0
  46. package/src/core/hooks/types.ts +2 -2
  47. package/src/core/index.ts +10 -21
  48. package/src/core/keybindings.ts +199 -0
  49. package/src/core/messages.ts +26 -7
  50. package/src/core/model-registry.ts +123 -46
  51. package/src/core/model-resolver.ts +7 -5
  52. package/src/core/prompt-templates.ts +242 -0
  53. package/src/core/sdk.ts +378 -295
  54. package/src/core/session-manager.ts +72 -58
  55. package/src/core/settings-manager.ts +118 -22
  56. package/src/core/system-prompt.ts +24 -1
  57. package/src/core/terminal-notify.ts +37 -0
  58. package/src/core/tools/context.ts +4 -4
  59. package/src/core/tools/exa/mcp-client.ts +5 -4
  60. package/src/core/tools/exa/render.ts +176 -131
  61. package/src/core/tools/gemini-image.ts +361 -0
  62. package/src/core/tools/git.ts +216 -0
  63. package/src/core/tools/index.ts +28 -15
  64. package/src/core/tools/lsp/config.ts +5 -4
  65. package/src/core/tools/lsp/index.ts +17 -12
  66. package/src/core/tools/lsp/render.ts +39 -47
  67. package/src/core/tools/read.ts +66 -29
  68. package/src/core/tools/render-utils.ts +268 -0
  69. package/src/core/tools/renderers.ts +243 -225
  70. package/src/core/tools/task/discovery.ts +2 -2
  71. package/src/core/tools/task/executor.ts +66 -58
  72. package/src/core/tools/task/index.ts +29 -10
  73. package/src/core/tools/task/model-resolver.ts +8 -13
  74. package/src/core/tools/task/omp-command.ts +24 -0
  75. package/src/core/tools/task/render.ts +35 -60
  76. package/src/core/tools/task/types.ts +3 -0
  77. package/src/core/tools/web-fetch.ts +29 -28
  78. package/src/core/tools/web-search/index.ts +6 -5
  79. package/src/core/tools/web-search/providers/exa.ts +6 -5
  80. package/src/core/tools/web-search/render.ts +66 -111
  81. package/src/core/voice-controller.ts +135 -0
  82. package/src/core/voice-supervisor.ts +1003 -0
  83. package/src/core/voice.ts +308 -0
  84. package/src/discovery/builtin.ts +75 -1
  85. package/src/discovery/claude.ts +47 -1
  86. package/src/discovery/codex.ts +54 -2
  87. package/src/discovery/gemini.ts +55 -2
  88. package/src/discovery/helpers.ts +100 -1
  89. package/src/discovery/index.ts +2 -0
  90. package/src/index.ts +14 -9
  91. package/src/lib/worktree/collapse.ts +179 -0
  92. package/src/lib/worktree/constants.ts +14 -0
  93. package/src/lib/worktree/errors.ts +23 -0
  94. package/src/lib/worktree/git.ts +110 -0
  95. package/src/lib/worktree/index.ts +23 -0
  96. package/src/lib/worktree/operations.ts +216 -0
  97. package/src/lib/worktree/session.ts +114 -0
  98. package/src/lib/worktree/stats.ts +67 -0
  99. package/src/main.ts +61 -37
  100. package/src/migrations.ts +37 -7
  101. package/src/modes/interactive/components/bash-execution.ts +6 -4
  102. package/src/modes/interactive/components/custom-editor.ts +55 -0
  103. package/src/modes/interactive/components/custom-message.ts +95 -0
  104. package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
  105. package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
  106. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  107. package/src/modes/interactive/components/extensions/types.ts +1 -0
  108. package/src/modes/interactive/components/footer.ts +324 -0
  109. package/src/modes/interactive/components/hook-selector.ts +3 -3
  110. package/src/modes/interactive/components/model-selector.ts +7 -6
  111. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  112. package/src/modes/interactive/components/settings-defs.ts +55 -6
  113. package/src/modes/interactive/components/status-line.ts +45 -37
  114. package/src/modes/interactive/components/tool-execution.ts +95 -23
  115. package/src/modes/interactive/interactive-mode.ts +643 -113
  116. package/src/modes/interactive/theme/defaults/index.ts +16 -16
  117. package/src/modes/print-mode.ts +14 -72
  118. package/src/modes/rpc/rpc-client.ts +23 -9
  119. package/src/modes/rpc/rpc-mode.ts +137 -125
  120. package/src/modes/rpc/rpc-types.ts +46 -24
  121. package/src/prompts/task.md +1 -0
  122. package/src/prompts/tools/gemini-image.md +4 -0
  123. package/src/prompts/tools/git.md +9 -0
  124. package/src/prompts/voice-summary.md +12 -0
  125. package/src/utils/image-convert.ts +26 -0
  126. package/src/utils/image-resize.ts +215 -0
  127. package/src/utils/shell-snapshot.ts +22 -20
@@ -3,7 +3,7 @@
3
3
  * Handles loading, saving, and refreshing credentials from auth.json.
4
4
  */
5
5
 
6
- import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
6
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { dirname } from "node:path";
8
8
  import {
9
9
  getEnvApiKey,
@@ -12,6 +12,7 @@ import {
12
12
  loginAntigravity,
13
13
  loginGeminiCli,
14
14
  loginGitHubCopilot,
15
+ loginOpenAICodex,
15
16
  type OAuthCredentials,
16
17
  type OAuthProvider,
17
18
  } from "@oh-my-pi/pi-ai";
@@ -46,9 +47,7 @@ export class AuthStorage {
46
47
  constructor(
47
48
  private authPath: string,
48
49
  private fallbackPaths: string[] = [],
49
- ) {
50
- this.reload();
51
- }
50
+ ) {}
52
51
 
53
52
  /**
54
53
  * Set a runtime API key override (not persisted to disk).
@@ -77,7 +76,7 @@ export class AuthStorage {
77
76
  * Reload credentials from disk.
78
77
  * Checks primary path first, then fallback paths.
79
78
  */
80
- reload(): void {
79
+ async reload(): Promise<void> {
81
80
  const pathsToCheck = [this.authPath, ...this.fallbackPaths];
82
81
 
83
82
  logger.debug("AuthStorage.reload checking paths", { paths: pathsToCheck });
@@ -105,13 +104,11 @@ export class AuthStorage {
105
104
  /**
106
105
  * Save credentials to disk.
107
106
  */
108
- private save(): void {
109
- const dir = dirname(this.authPath);
110
- if (!existsSync(dir)) {
111
- mkdirSync(dir, { recursive: true, mode: 0o700 });
112
- }
113
- Bun.write(this.authPath, JSON.stringify(this.data, null, 2));
107
+ private async save(): Promise<void> {
108
+ writeFileSync(this.authPath, JSON.stringify(this.data, null, 2));
114
109
  chmodSync(this.authPath, 0o600);
110
+ const dir = dirname(this.authPath);
111
+ chmodSync(dir, 0o700);
115
112
  }
116
113
 
117
114
  /**
@@ -124,17 +121,17 @@ export class AuthStorage {
124
121
  /**
125
122
  * Set credential for a provider.
126
123
  */
127
- set(provider: string, credential: AuthCredential): void {
124
+ async set(provider: string, credential: AuthCredential): Promise<void> {
128
125
  this.data[provider] = credential;
129
- this.save();
126
+ await this.save();
130
127
  }
131
128
 
132
129
  /**
133
130
  * Remove credential for a provider.
134
131
  */
135
- remove(provider: string): void {
132
+ async remove(provider: string): Promise<void> {
136
133
  delete this.data[provider];
137
- this.save();
134
+ await this.save();
138
135
  }
139
136
 
140
137
  /**
@@ -145,12 +142,24 @@ export class AuthStorage {
145
142
  }
146
143
 
147
144
  /**
148
- * Check if credentials exist for a provider.
145
+ * Check if credentials exist for a provider in auth.json.
149
146
  */
150
147
  has(provider: string): boolean {
151
148
  return provider in this.data;
152
149
  }
153
150
 
151
+ /**
152
+ * Check if any form of auth is configured for a provider.
153
+ * Unlike getApiKey(), this doesn't refresh OAuth tokens.
154
+ */
155
+ hasAuth(provider: string): boolean {
156
+ if (this.runtimeOverrides.has(provider)) return true;
157
+ if (this.data[provider]) return true;
158
+ if (getEnvApiKey(provider)) return true;
159
+ if (this.fallbackResolver?.(provider)) return true;
160
+ return false;
161
+ }
162
+
154
163
  /**
155
164
  * Get all credentials (for passing to getOAuthApiKey).
156
165
  */
@@ -191,18 +200,21 @@ export class AuthStorage {
191
200
  case "google-antigravity":
192
201
  credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress);
193
202
  break;
203
+ case "openai-codex":
204
+ credentials = await loginOpenAICodex(callbacks);
205
+ break;
194
206
  default:
195
207
  throw new Error(`Unknown OAuth provider: ${provider}`);
196
208
  }
197
209
 
198
- this.set(provider, { type: "oauth", ...credentials });
210
+ await this.set(provider, { type: "oauth", ...credentials });
199
211
  }
200
212
 
201
213
  /**
202
214
  * Logout from a provider.
203
215
  */
204
- logout(provider: string): void {
205
- this.remove(provider);
216
+ async logout(provider: string): Promise<void> {
217
+ await this.remove(provider);
206
218
  }
207
219
 
208
220
  /**
@@ -240,11 +252,11 @@ export class AuthStorage {
240
252
  const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
241
253
  if (result) {
242
254
  this.data[provider] = { type: "oauth", ...result.newCredentials };
243
- this.save();
255
+ await this.save();
244
256
  return result.apiKey;
245
257
  }
246
258
  } catch {
247
- this.remove(provider);
259
+ await this.remove(provider);
248
260
  }
249
261
  }
250
262
 
@@ -14,7 +14,7 @@ import {
14
14
  convertToLlm,
15
15
  createBranchSummaryMessage,
16
16
  createCompactionSummaryMessage,
17
- createHookMessage,
17
+ createCustomMessage,
18
18
  } from "../messages";
19
19
  import type { ReadonlySessionManager, SessionEntry } from "../session-manager";
20
20
  import { estimateTokens } from "./compaction";
@@ -149,7 +149,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
149
149
  return entry.message;
150
150
 
151
151
  case "custom_message":
152
- return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
152
+ return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
153
153
 
154
154
  case "branch_summary":
155
155
  return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
@@ -186,9 +186,9 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe
186
186
 
187
187
  // First pass: collect file ops from ALL entries (even if they don't fit in token budget)
188
188
  // This ensures we capture cumulative file tracking from nested branch summaries
189
- // Only extract from pi-generated summaries (fromHook !== true), not hook-generated ones
189
+ // Only extract from pi-generated summaries (fromExtension !== true), not extension-generated ones
190
190
  for (const entry of entries) {
191
- if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
191
+ if (entry.type === "branch_summary" && !entry.fromExtension && entry.details) {
192
192
  const details = entry.details as BranchSummaryDetails;
193
193
  if (Array.isArray(details.readFiles)) {
194
194
  for (const f of details.readFiles) fileOps.read.add(f);
@@ -11,7 +11,7 @@ import { complete, completeSimple } from "@oh-my-pi/pi-ai";
11
11
  import compactionSummaryPrompt from "../../prompts/compaction-summary.md" with { type: "text" };
12
12
  import compactionTurnPrefixPrompt from "../../prompts/compaction-turn-prefix.md" with { type: "text" };
13
13
  import compactionUpdateSummaryPrompt from "../../prompts/compaction-update-summary.md" with { type: "text" };
14
- import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages";
14
+ import { convertToLlm, createBranchSummaryMessage, createCustomMessage } from "../messages";
15
15
  import type { CompactionEntry, SessionEntry } from "../session-manager";
16
16
  import {
17
17
  computeFileLists,
@@ -46,7 +46,7 @@ function extractFileOperations(
46
46
  // Collect from previous compaction's details (if pi-generated)
47
47
  if (prevCompactionIndex >= 0) {
48
48
  const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
49
- if (!prevCompaction.fromHook && prevCompaction.details) {
49
+ if (!prevCompaction.fromExtension && prevCompaction.details) {
50
50
  const details = prevCompaction.details as CompactionDetails;
51
51
  if (Array.isArray(details.readFiles)) {
52
52
  for (const f of details.readFiles) fileOps.read.add(f);
@@ -78,7 +78,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
78
78
  return entry.message;
79
79
  }
80
80
  if (entry.type === "custom_message") {
81
- return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
81
+ return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
82
82
  }
83
83
  if (entry.type === "branch_summary") {
84
84
  return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
@@ -0,0 +1,430 @@
1
+ import * as path from "node:path";
2
+ import { nanoid } from "nanoid";
3
+ import { type CollapseStrategy, collapse } from "../../../../lib/worktree/collapse";
4
+ import { WorktreeError, WorktreeErrorCode } from "../../../../lib/worktree/errors";
5
+ import { getRepoRoot, git } from "../../../../lib/worktree/git";
6
+ import * as worktree from "../../../../lib/worktree/index";
7
+ import { createSession, updateSession } from "../../../../lib/worktree/session";
8
+ import { formatStats, getStats } from "../../../../lib/worktree/stats";
9
+ import type { HookCommandContext } from "../../../hooks/types";
10
+ import { discoverAgents, getAgent } from "../../../tools/task/discovery";
11
+ import { runSubprocess } from "../../../tools/task/executor";
12
+ import type { AgentDefinition } from "../../../tools/task/types";
13
+ import type { CustomCommand, CustomCommandAPI } from "../../types";
14
+
15
+ interface FlagParseResult {
16
+ positionals: string[];
17
+ flags: Map<string, string | boolean>;
18
+ }
19
+
20
+ interface NewArgs {
21
+ branch: string;
22
+ base?: string;
23
+ }
24
+
25
+ interface MergeArgs {
26
+ source: string;
27
+ target?: string;
28
+ strategy?: CollapseStrategy;
29
+ keep?: boolean;
30
+ }
31
+
32
+ interface RmArgs {
33
+ name: string;
34
+ force?: boolean;
35
+ }
36
+
37
+ interface SpawnArgs {
38
+ task: string;
39
+ scope?: string;
40
+ name?: string;
41
+ }
42
+
43
+ interface ParallelTask {
44
+ task: string;
45
+ scope: string;
46
+ }
47
+
48
+ function parseFlags(args: string[]): FlagParseResult {
49
+ const flags = new Map<string, string | boolean>();
50
+ const positionals: string[] = [];
51
+
52
+ for (let i = 0; i < args.length; i++) {
53
+ const arg = args[i];
54
+ if (arg.startsWith("--")) {
55
+ const name = arg.slice(2);
56
+ const next = args[i + 1];
57
+ if (next && !next.startsWith("--")) {
58
+ flags.set(name, next);
59
+ i += 1;
60
+ } else {
61
+ flags.set(name, true);
62
+ }
63
+ } else {
64
+ positionals.push(arg);
65
+ }
66
+ }
67
+
68
+ return { positionals, flags };
69
+ }
70
+
71
+ function getFlagValue(flags: Map<string, string | boolean>, name: string): string | undefined {
72
+ const value = flags.get(name);
73
+ if (typeof value === "string") return value;
74
+ return undefined;
75
+ }
76
+
77
+ function getFlagBoolean(flags: Map<string, string | boolean>, name: string): boolean {
78
+ return flags.get(name) === true;
79
+ }
80
+
81
+ function formatUsage(): string {
82
+ return [
83
+ "Usage:",
84
+ " /wt new <branch> [--base <ref>]",
85
+ " /wt list",
86
+ " /wt merge <src> [dst] [--strategy simple|merge-base|rebase] [--keep]",
87
+ " /wt rm <name> [--force]",
88
+ " /wt status",
89
+ ' /wt spawn "<task>" [--scope <glob>] [--name <branch>]',
90
+ " /wt parallel --task <t> --scope <s> [--task <t> --scope <s>]...",
91
+ ].join("\n");
92
+ }
93
+
94
+ function formatError(err: unknown): string {
95
+ if (err instanceof WorktreeError) {
96
+ return `${err.code}: ${err.message}`;
97
+ }
98
+ if (err instanceof Error) return err.message;
99
+ return String(err);
100
+ }
101
+
102
+ async function pickAgent(cwd: string): Promise<AgentDefinition> {
103
+ const { agents } = await discoverAgents(cwd);
104
+ // Use the bundled "task" agent as the general-purpose default.
105
+ const agent = getAgent(agents, "task") ?? agents[0];
106
+ if (!agent) {
107
+ throw new Error("No agents available");
108
+ }
109
+ return agent;
110
+ }
111
+
112
+ function parseParallelTasks(args: string[]): ParallelTask[] {
113
+ const tasks: ParallelTask[] = [];
114
+ let current: Partial<ParallelTask> = {};
115
+
116
+ for (let i = 0; i < args.length; i++) {
117
+ const arg = args[i];
118
+ if (arg === "--task") {
119
+ const value = args[i + 1];
120
+ if (!value || value.startsWith("--")) {
121
+ throw new Error("Missing value for --task");
122
+ }
123
+ current.task = value;
124
+ i += 1;
125
+ } else if (arg === "--scope") {
126
+ const value = args[i + 1];
127
+ if (!value || value.startsWith("--")) {
128
+ throw new Error("Missing value for --scope");
129
+ }
130
+ current.scope = value;
131
+ i += 1;
132
+ } else {
133
+ throw new Error(`Unknown argument: ${arg}`);
134
+ }
135
+
136
+ if (current.task && current.scope) {
137
+ tasks.push({ task: current.task, scope: current.scope });
138
+ current = {};
139
+ }
140
+ }
141
+
142
+ if (current.task || current.scope) {
143
+ throw new Error("Each --task must be paired with a --scope");
144
+ }
145
+
146
+ return tasks;
147
+ }
148
+
149
+ function validateDisjointScopes(scopes: string[]): void {
150
+ for (let i = 0; i < scopes.length; i++) {
151
+ for (let j = i + 1; j < scopes.length; j++) {
152
+ const a = scopes[i].replace(/\*.*$/, "");
153
+ const b = scopes[j].replace(/\*.*$/, "");
154
+ if (a.startsWith(b) || b.startsWith(a)) {
155
+ throw new WorktreeError(
156
+ `Overlapping scopes: "${scopes[i]}" and "${scopes[j]}"`,
157
+ WorktreeErrorCode.OVERLAPPING_SCOPES,
158
+ );
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ async function handleNew(args: NewArgs): Promise<string> {
165
+ const wt = await worktree.create(args.branch, { base: args.base });
166
+
167
+ return [`Created worktree: ${wt.path}`, `Branch: ${wt.branch ?? "detached"}`, "", `To switch: cd ${wt.path}`].join(
168
+ "\n",
169
+ );
170
+ }
171
+
172
+ async function handleList(ctx: HookCommandContext): Promise<string> {
173
+ const worktrees = await worktree.list();
174
+ const cwd = path.resolve(ctx.cwd);
175
+ const mainPath = await getRepoRoot();
176
+
177
+ const lines: string[] = [];
178
+
179
+ for (const wt of worktrees) {
180
+ const stats = await getStats(wt.path);
181
+ const isCurrent = cwd === wt.path || cwd.startsWith(wt.path + path.sep);
182
+ const isMain = wt.path === mainPath;
183
+
184
+ const marker = isCurrent ? "->" : " ";
185
+ const mainTag = isMain ? " [main]" : "";
186
+ const branch = wt.branch ?? "detached";
187
+ const statsStr = formatStats(stats);
188
+
189
+ lines.push(`${marker} ${branch}${mainTag} (${statsStr})`);
190
+ }
191
+
192
+ return lines.join("\n") || "No worktrees found";
193
+ }
194
+
195
+ async function handleMerge(args: MergeArgs): Promise<string> {
196
+ const target = args.target ?? "main";
197
+ const strategy = args.strategy ?? "rebase";
198
+
199
+ const result = await collapse(args.source, target, {
200
+ strategy,
201
+ keepSource: args.keep,
202
+ });
203
+
204
+ const lines = [
205
+ `Collapsed ${args.source} -> ${target}`,
206
+ `Strategy: ${strategy}`,
207
+ `Changes: +${result.insertions} -${result.deletions} in ${result.filesChanged} files`,
208
+ ];
209
+
210
+ if (!args.keep) {
211
+ lines.push("Source worktree removed");
212
+ }
213
+
214
+ return lines.join("\n");
215
+ }
216
+
217
+ async function handleRm(args: RmArgs): Promise<string> {
218
+ const wt = await worktree.find(args.name);
219
+ await worktree.remove(args.name, { force: args.force });
220
+
221
+ const mainPath = await getRepoRoot();
222
+ if (wt.branch) {
223
+ await git(["branch", "-D", wt.branch], mainPath);
224
+ return `Removed worktree and branch: ${wt.branch}`;
225
+ }
226
+
227
+ return `Removed worktree: ${wt.path}`;
228
+ }
229
+
230
+ async function handleStatus(): Promise<string> {
231
+ const worktrees = await worktree.list();
232
+ const sections: string[] = [];
233
+
234
+ for (const wt of worktrees) {
235
+ const branch = wt.branch ?? "detached";
236
+ const name = path.basename(wt.path);
237
+
238
+ const statusResult = await git(["status", "--short"], wt.path);
239
+ const status = statusResult.stdout.trim() || "(clean)";
240
+
241
+ sections.push(`${name} (${branch})\n${"-".repeat(40)}\n${status}`);
242
+ }
243
+
244
+ return sections.join("\n\n");
245
+ }
246
+
247
+ async function handleSpawn(args: SpawnArgs, ctx: HookCommandContext): Promise<string> {
248
+ const branch = args.name ?? `wt-agent-${nanoid(6)}`;
249
+ const wt = await worktree.create(branch);
250
+
251
+ const session = await createSession({
252
+ branch,
253
+ path: wt.path,
254
+ scope: args.scope ? [args.scope] : undefined,
255
+ task: args.task,
256
+ });
257
+ await updateSession(session.id, { status: "active" });
258
+
259
+ const agent = await pickAgent(ctx.cwd);
260
+ const context = args.scope ? `Scope: ${args.scope}` : undefined;
261
+
262
+ // Command context doesn't expose a spawn API, so run the task subprocess directly.
263
+ const result = await runSubprocess({
264
+ cwd: wt.path,
265
+ agent,
266
+ task: args.task,
267
+ index: 0,
268
+ context,
269
+ });
270
+
271
+ await updateSession(session.id, {
272
+ status: result.exitCode === 0 ? "completed" : "failed",
273
+ completedAt: Date.now(),
274
+ });
275
+
276
+ if (result.exitCode !== 0) {
277
+ return [
278
+ `Agent failed in worktree: ${branch}`,
279
+ result.stderr.trim() ? `Error: ${result.stderr.trim()}` : "Error: agent execution failed",
280
+ "",
281
+ "Actions:",
282
+ ` /wt merge ${branch} - Apply changes to main`,
283
+ " /wt status - Inspect changes",
284
+ ` /wt rm ${branch} - Discard changes`,
285
+ ].join("\n");
286
+ }
287
+
288
+ return [
289
+ `Agent completed in worktree: ${branch}`,
290
+ "",
291
+ "Actions:",
292
+ ` /wt merge ${branch} - Apply changes to main`,
293
+ " /wt status - Inspect changes",
294
+ ` /wt rm ${branch} - Discard changes`,
295
+ ].join("\n");
296
+ }
297
+
298
+ async function handleParallel(args: ParallelTask[], ctx: HookCommandContext): Promise<string> {
299
+ validateDisjointScopes(args.map((t) => t.scope));
300
+
301
+ const sessionId = `parallel-${Date.now()}`;
302
+ const agent = await pickAgent(ctx.cwd);
303
+
304
+ const worktrees: Array<{ task: ParallelTask; wt: worktree.Worktree; session: worktree.WorktreeSession }> = [];
305
+ for (let i = 0; i < args.length; i++) {
306
+ const task = args[i];
307
+ const branch = `wt-parallel-${sessionId}-${i}`;
308
+ const wt = await worktree.create(branch);
309
+ const session = await createSession({
310
+ branch,
311
+ path: wt.path,
312
+ scope: [task.scope],
313
+ task: task.task,
314
+ });
315
+ worktrees.push({ task, wt, session });
316
+ }
317
+
318
+ const agentPromises = worktrees.map(async ({ task, wt, session }, index) => {
319
+ await updateSession(session.id, { status: "active" });
320
+ const result = await runSubprocess({
321
+ cwd: wt.path,
322
+ agent,
323
+ task: task.task,
324
+ index,
325
+ context: `Scope: ${task.scope}`,
326
+ });
327
+ await updateSession(session.id, {
328
+ status: result.exitCode === 0 ? "completed" : "failed",
329
+ completedAt: Date.now(),
330
+ });
331
+ return { wt, session, result };
332
+ });
333
+
334
+ const results = await Promise.all(agentPromises);
335
+
336
+ const mergeResults: string[] = [];
337
+
338
+ for (const { wt, session } of results) {
339
+ try {
340
+ await updateSession(session.id, { status: "merging" });
341
+ const collapseResult = await collapse(wt.branch ?? wt.path, "main", {
342
+ strategy: "simple",
343
+ keepSource: false,
344
+ });
345
+ await updateSession(session.id, { status: "merged" });
346
+ mergeResults.push(
347
+ `ok ${wt.branch ?? path.basename(wt.path)}: +${collapseResult.insertions} -${collapseResult.deletions}`,
348
+ );
349
+ } catch (err) {
350
+ await updateSession(session.id, { status: "failed" });
351
+ mergeResults.push(`err ${wt.branch ?? path.basename(wt.path)}: ${formatError(err)}`);
352
+ }
353
+ }
354
+
355
+ return [`Parallel execution complete (${args.length} agents)`, "", "Results:", ...mergeResults].join("\n");
356
+ }
357
+
358
+ export function createWorktreeCommand(_api: CustomCommandAPI): CustomCommand {
359
+ return {
360
+ name: "wt",
361
+ description: "Git worktree management",
362
+ async execute(args: string[], ctx: HookCommandContext): Promise<string | undefined> {
363
+ if (args.length === 0) return formatUsage();
364
+
365
+ const subcommand = args[0];
366
+ const rest = args.slice(1);
367
+
368
+ try {
369
+ switch (subcommand) {
370
+ case "new": {
371
+ const parsed = parseFlags(rest);
372
+ const branch = parsed.positionals[0];
373
+ if (!branch) return formatUsage();
374
+ const base = getFlagValue(parsed.flags, "base");
375
+ if (parsed.flags.get("base") === true) {
376
+ return "Missing value for --base";
377
+ }
378
+ return await handleNew({ branch, base });
379
+ }
380
+ case "list":
381
+ return await handleList(ctx);
382
+ case "merge": {
383
+ const parsed = parseFlags(rest);
384
+ const source = parsed.positionals[0];
385
+ const target = parsed.positionals[1];
386
+ if (!source) return formatUsage();
387
+ const strategyRaw = getFlagValue(parsed.flags, "strategy");
388
+ if (parsed.flags.get("strategy") === true) {
389
+ return "Missing value for --strategy";
390
+ }
391
+ const strategy = strategyRaw as CollapseStrategy | undefined;
392
+ const keep = getFlagBoolean(parsed.flags, "keep");
393
+ return await handleMerge({ source, target, strategy, keep });
394
+ }
395
+ case "rm": {
396
+ const parsed = parseFlags(rest);
397
+ const name = parsed.positionals[0];
398
+ if (!name) return formatUsage();
399
+ const force = getFlagBoolean(parsed.flags, "force");
400
+ return await handleRm({ name, force });
401
+ }
402
+ case "status":
403
+ return await handleStatus();
404
+ case "spawn": {
405
+ const parsed = parseFlags(rest);
406
+ const task = parsed.positionals[0];
407
+ if (!task) return formatUsage();
408
+ const scope = getFlagValue(parsed.flags, "scope");
409
+ if (parsed.flags.get("scope") === true) {
410
+ return "Missing value for --scope";
411
+ }
412
+ const name = getFlagValue(parsed.flags, "name");
413
+ return await handleSpawn({ task, scope, name }, ctx);
414
+ }
415
+ case "parallel": {
416
+ const tasks = parseParallelTasks(rest);
417
+ if (tasks.length === 0) return formatUsage();
418
+ return await handleParallel(tasks, ctx);
419
+ }
420
+ default:
421
+ return formatUsage();
422
+ }
423
+ } catch (err) {
424
+ return formatError(err);
425
+ }
426
+ },
427
+ };
428
+ }
429
+
430
+ export default createWorktreeCommand;
@@ -12,6 +12,7 @@ import { getAgentDir, getConfigDirs } from "../../config";
12
12
  import * as piCodingAgent from "../../index";
13
13
  import { execCommand } from "../exec";
14
14
  import { createReviewCommand } from "./bundled/review";
15
+ import { createWorktreeCommand } from "./bundled/wt";
15
16
  import type {
16
17
  CustomCommand,
17
18
  CustomCommandAPI,
@@ -151,6 +152,14 @@ function loadBundledCommands(sharedApi: CustomCommandAPI): LoadedCustomCommand[]
151
152
  source: "bundled",
152
153
  });
153
154
 
155
+ const worktreeCommand = createWorktreeCommand(sharedApi);
156
+ bundled.push({
157
+ path: "bundled:wt",
158
+ resolvedPath: "bundled:wt",
159
+ command: worktreeCommand,
160
+ source: "bundled",
161
+ });
162
+
154
163
  return bundled;
155
164
  }
156
165
 
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
6
+ import type { Theme } from "../../modes/interactive/theme/theme";
6
7
  import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types";
7
8
 
8
9
  /**
@@ -18,6 +19,10 @@ export function wrapCustomTool(tool: CustomTool, getContext: () => CustomToolCon
18
19
  hidden: tool.hidden,
19
20
  execute: (toolCallId, params, signal, onUpdate, context) =>
20
21
  tool.execute(toolCallId, params, onUpdate, context ?? getContext(), signal),
22
+ renderCall: tool.renderCall ? (args, theme) => tool.renderCall?.(args, theme as Theme) : undefined,
23
+ renderResult: tool.renderResult
24
+ ? (result, options, theme) => tool.renderResult?.(result, options, theme as Theme)
25
+ : undefined,
21
26
  };
22
27
  }
23
28