@tt-a1i/openpi 0.3.1 → 0.5.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 (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -0,0 +1,328 @@
1
+ /**
2
+ * git-read — read-only git inspection tools for pi.
3
+ *
4
+ * Registers git_show, git_diff, and git_log: the minimal surface a review or
5
+ * exploration agent needs from git history without any write path. All three
6
+ * share the file-search output discipline (bounded preview, complete output
7
+ * persisted up to 10 MiB) and every user-controlled argument is validated
8
+ * before it reaches a child process, so a revision or path can never inject
9
+ * flags or escape the repository. The tools are child-safe by design and are
10
+ * classified in CHILD_SAFE_PACKAGE_TOOL_NAMES so review/advisor subagents can
11
+ * read diffs their tool boundary otherwise excludes (issue #61).
12
+ */
13
+
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import type {
17
+ AgentToolResult,
18
+ ExtensionAPI,
19
+ } from "@earendil-works/pi-coding-agent";
20
+ import { Text } from "@earendil-works/pi-tui";
21
+ import { Cause, Effect, Exit } from "effect";
22
+ import { Type } from "typebox";
23
+ import { formatCapturedOutput } from "../file-search/src/output.ts";
24
+ import { sanitizeTerminalText } from "../shared/terminal-text.ts";
25
+ import {
26
+ OPENPI_TOOL_SURFACE,
27
+ patchOwnedTools,
28
+ } from "../shared/tool-surface.ts";
29
+ import {
30
+ buildDiffArgs,
31
+ buildLogArgs,
32
+ buildShowArgs,
33
+ type GitDiffParams,
34
+ type GitLogParams,
35
+ type GitShowParams,
36
+ InvalidPathError,
37
+ InvalidRevisionError,
38
+ } from "./src/args.ts";
39
+ import { type GitOutcome, runGit } from "./src/process.ts";
40
+ import {
41
+ GIT_DIFF_PARAMETER_DESCRIPTIONS,
42
+ GIT_DIFF_PROMPT_GUIDELINES,
43
+ GIT_DIFF_PROMPT_SNIPPET,
44
+ GIT_DIFF_TOOL_DESCRIPTION,
45
+ GIT_LOG_PARAMETER_DESCRIPTIONS,
46
+ GIT_LOG_PROMPT_GUIDELINES,
47
+ GIT_LOG_PROMPT_SNIPPET,
48
+ GIT_LOG_TOOL_DESCRIPTION,
49
+ GIT_SHOW_PARAMETER_DESCRIPTIONS,
50
+ GIT_SHOW_PROMPT_GUIDELINES,
51
+ GIT_SHOW_PROMPT_SNIPPET,
52
+ GIT_SHOW_TOOL_DESCRIPTION,
53
+ } from "./src/prompt.ts";
54
+
55
+ interface GitToolDetails {
56
+ readonly command: string;
57
+ readonly truncated: boolean;
58
+ readonly lineCount?: number;
59
+ readonly fullOutputPath?: string;
60
+ }
61
+
62
+ function causeMessage(cause: unknown): string {
63
+ if (
64
+ cause instanceof InvalidRevisionError ||
65
+ cause instanceof InvalidPathError
66
+ ) {
67
+ return cause.message;
68
+ }
69
+ if (cause instanceof Error) return cause.message;
70
+ return String(cause);
71
+ }
72
+
73
+ function unwrapToolExit<A, E>(exit: Exit.Exit<A, E>, tool: string) {
74
+ if (Exit.isSuccess(exit)) return exit.value;
75
+ if (Cause.hasInterruptsOnly(exit.cause)) {
76
+ throw new Error(`${tool} was cancelled.`);
77
+ }
78
+ throw new Error(causeMessage(Cause.squash(exit.cause)));
79
+ }
80
+
81
+ function gitResult(
82
+ outcome: GitOutcome,
83
+ command: string,
84
+ ): AgentToolResult<GitToolDetails> {
85
+ const formatted = formatCapturedOutput(outcome.output);
86
+ return {
87
+ content: [
88
+ {
89
+ type: "text",
90
+ text: sanitizeTerminalText(formatted.text) || "(no output)",
91
+ },
92
+ ],
93
+ details: {
94
+ command,
95
+ truncated: formatted.truncated,
96
+ lineCount: formatted.lineCount,
97
+ fullOutputPath: formatted.fullOutputPath,
98
+ },
99
+ };
100
+ }
101
+
102
+ function showParameters() {
103
+ return Type.Object({
104
+ revision: Type.String({
105
+ description: GIT_SHOW_PARAMETER_DESCRIPTIONS.revision,
106
+ }),
107
+ path: Type.Optional(
108
+ Type.String({ description: GIT_SHOW_PARAMETER_DESCRIPTIONS.path }),
109
+ ),
110
+ });
111
+ }
112
+
113
+ function diffParameters() {
114
+ return Type.Object({
115
+ from: Type.Optional(
116
+ Type.String({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.from }),
117
+ ),
118
+ to: Type.Optional(
119
+ Type.String({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.to }),
120
+ ),
121
+ staged: Type.Optional(
122
+ Type.Boolean({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.staged }),
123
+ ),
124
+ stat: Type.Optional(
125
+ Type.Boolean({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.stat }),
126
+ ),
127
+ path: Type.Optional(
128
+ Type.String({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.path }),
129
+ ),
130
+ });
131
+ }
132
+
133
+ function logParameters() {
134
+ return Type.Object({
135
+ revision: Type.Optional(
136
+ Type.String({ description: GIT_LOG_PARAMETER_DESCRIPTIONS.revision }),
137
+ ),
138
+ file: Type.Optional(
139
+ Type.String({ description: GIT_LOG_PARAMETER_DESCRIPTIONS.file }),
140
+ ),
141
+ limit: Type.Optional(
142
+ Type.Integer({
143
+ description: GIT_LOG_PARAMETER_DESCRIPTIONS.limit,
144
+ minimum: 1,
145
+ maximum: 1000,
146
+ }),
147
+ ),
148
+ oneline: Type.Optional(
149
+ Type.Boolean({ description: GIT_LOG_PARAMETER_DESCRIPTIONS.oneline }),
150
+ ),
151
+ });
152
+ }
153
+
154
+ function displayRevision(value: string | undefined, fallback = "HEAD") {
155
+ return value === undefined || value.trim() === "" ? fallback : value.trim();
156
+ }
157
+
158
+ export default function gitReadTools(pi: ExtensionAPI) {
159
+ const resultDirectories = new Set<string>();
160
+ const rememberOutput = (outcome: GitOutcome) => {
161
+ if (outcome.output.fullOutputPath) {
162
+ resultDirectories.add(path.dirname(outcome.output.fullOutputPath));
163
+ }
164
+ };
165
+
166
+ pi.on("session_start", () => {
167
+ patchOwnedTools(pi, "gitRead", {
168
+ enable: OPENPI_TOOL_SURFACE.gitRead.entry,
169
+ });
170
+ });
171
+
172
+ pi.on("session_shutdown", () => {
173
+ for (const directory of resultDirectories) {
174
+ try {
175
+ fs.rmSync(directory, { recursive: true, force: true });
176
+ } catch {
177
+ // Temporary git artifacts are best-effort cleanup.
178
+ }
179
+ }
180
+ resultDirectories.clear();
181
+ });
182
+
183
+ pi.registerTool<ReturnType<typeof showParameters>, GitToolDetails>({
184
+ name: "git_show",
185
+ label: "Git Show",
186
+ description: GIT_SHOW_TOOL_DESCRIPTION,
187
+ promptSnippet: GIT_SHOW_PROMPT_SNIPPET,
188
+ promptGuidelines: GIT_SHOW_PROMPT_GUIDELINES,
189
+ parameters: showParameters(),
190
+
191
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
192
+ const exit = await Effect.runPromiseExit(
193
+ Effect.gen(function* () {
194
+ const args = buildShowArgs(params as GitShowParams);
195
+ const outcome = yield* runGit(args, ctx.cwd);
196
+ rememberOutput(outcome);
197
+ return gitResult(outcome, args.join(" "));
198
+ }),
199
+ signal ? { signal } : undefined,
200
+ );
201
+ return unwrapToolExit(exit, "git_show");
202
+ },
203
+
204
+ renderCall(args) {
205
+ return new Text(`git show ${displayRevision(args.revision)}`, 0, 0);
206
+ },
207
+
208
+ renderResult(result, { expanded }, theme) {
209
+ const details = result.details;
210
+ let text = details?.command
211
+ ? `showed ${details.lineCount ?? 0} lines`
212
+ : "shown";
213
+ if (details?.truncated) text += " (truncated)";
214
+ if (expanded)
215
+ text += expandedResultPreview(result, details?.fullOutputPath, theme);
216
+ return new Text(text, 0, 0);
217
+ },
218
+ });
219
+
220
+ pi.registerTool<ReturnType<typeof diffParameters>, GitToolDetails>({
221
+ name: "git_diff",
222
+ label: "Git Diff",
223
+ description: GIT_DIFF_TOOL_DESCRIPTION,
224
+ promptSnippet: GIT_DIFF_PROMPT_SNIPPET,
225
+ promptGuidelines: GIT_DIFF_PROMPT_GUIDELINES,
226
+ parameters: diffParameters(),
227
+
228
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
229
+ const exit = await Effect.runPromiseExit(
230
+ Effect.gen(function* () {
231
+ const args = buildDiffArgs(params as GitDiffParams);
232
+ const outcome = yield* runGit(args, ctx.cwd);
233
+ rememberOutput(outcome);
234
+ return gitResult(outcome, args.join(" "));
235
+ }),
236
+ signal ? { signal } : undefined,
237
+ );
238
+ return unwrapToolExit(exit, "git_diff");
239
+ },
240
+
241
+ renderCall(args) {
242
+ const from = displayRevision(
243
+ args.from,
244
+ args.staged ? "HEAD (staged)" : "index",
245
+ );
246
+ const to = args.to ? displayRevision(args.to) : "worktree";
247
+ let text = `git diff ${from} → ${to}`;
248
+ if (args.stat) text += " (stat)";
249
+ if (args.path) text += ` ${args.path}`;
250
+ return new Text(text, 0, 0);
251
+ },
252
+
253
+ renderResult(result, { expanded }, theme) {
254
+ const details = result.details;
255
+ let text = details?.command
256
+ ? `${details.lineCount ?? 0} diff lines`
257
+ : "diffed";
258
+ if (details?.truncated) text += " (truncated)";
259
+ if (expanded)
260
+ text += expandedResultPreview(result, details?.fullOutputPath, theme);
261
+ return new Text(text, 0, 0);
262
+ },
263
+ });
264
+
265
+ pi.registerTool<ReturnType<typeof logParameters>, GitToolDetails>({
266
+ name: "git_log",
267
+ label: "Git Log",
268
+ description: GIT_LOG_TOOL_DESCRIPTION,
269
+ promptSnippet: GIT_LOG_PROMPT_SNIPPET,
270
+ promptGuidelines: GIT_LOG_PROMPT_GUIDELINES,
271
+ parameters: logParameters(),
272
+
273
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
274
+ const exit = await Effect.runPromiseExit(
275
+ Effect.gen(function* () {
276
+ const args = buildLogArgs(params as GitLogParams);
277
+ const outcome = yield* runGit(args, ctx.cwd);
278
+ rememberOutput(outcome);
279
+ return gitResult(outcome, args.join(" "));
280
+ }),
281
+ signal ? { signal } : undefined,
282
+ );
283
+ return unwrapToolExit(exit, "git_log");
284
+ },
285
+
286
+ renderCall(args) {
287
+ let text = `git log ${displayRevision(args.revision)}`;
288
+ if (args.file) text += ` -- ${args.file}`;
289
+ if (args.limit !== undefined) text += ` -n ${args.limit}`;
290
+ return new Text(text, 0, 0);
291
+ },
292
+
293
+ renderResult(result, { expanded }, theme) {
294
+ const details = result.details;
295
+ let text = details?.command
296
+ ? `${details.lineCount ?? 0} output lines`
297
+ : "logged";
298
+ if (details?.truncated) text += " (truncated)";
299
+ if (expanded)
300
+ text += expandedResultPreview(result, details?.fullOutputPath, theme);
301
+ return new Text(text, 0, 0);
302
+ },
303
+ });
304
+ }
305
+
306
+ const EXPANDED_PREVIEW_LINES = 20;
307
+
308
+ export function expandedResultPreview(
309
+ result: { content: { type: string; text?: string }[] },
310
+ fullOutputPath: string | undefined,
311
+ theme: { fg(color: string, text: string): string },
312
+ ) {
313
+ let text = "";
314
+ const content = result.content[0];
315
+ if (content?.type === "text" && content.text) {
316
+ const lines = sanitizeTerminalText(content.text).split("\n");
317
+ for (const line of lines.slice(0, EXPANDED_PREVIEW_LINES)) {
318
+ text += `\n${theme.fg("dim", line)}`;
319
+ }
320
+ if (lines.length > EXPANDED_PREVIEW_LINES) {
321
+ text += `\n${theme.fg("muted", `... ${lines.length - EXPANDED_PREVIEW_LINES} more lines`)}`;
322
+ }
323
+ }
324
+ if (fullOutputPath) {
325
+ text += `\n${theme.fg("dim", `Full output: ${sanitizeTerminalText(fullOutputPath)}`)}`;
326
+ }
327
+ return text;
328
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Read-only git commands: pure argv construction.
3
+ *
4
+ * Everything here is synchronous and side-effect free so the exact argv
5
+ * passed to the child process can be asserted in tests. Revision and path
6
+ * inputs are validated against strict shapes before they are ever placed
7
+ * in an argument list, and every user-controlled value goes after a `--`
8
+ * separator so it can never be parsed as a flag. Only argv subcommands that
9
+ * cannot write are reachable at all.
10
+ */
11
+
12
+ export const GIT_TIMEOUT_MS = 10_000;
13
+ export const GIT_LOG_DEFAULT_LIMIT = 100;
14
+ export const GIT_LOG_MAX_LIMIT = 1000;
15
+
16
+ /** Revisions may be a sha-ish hex string, HEAD~n, HEAD^n, or a plain name; a
17
+ * leading `-` is rejected so a revision can never smuggle a git flag. */
18
+ const REVISION_PATTERN =
19
+ /^(?:[0-9a-fA-F]{4,40}|HEAD(?:[~^]\d*)*|[A-Za-z_][\w./@-]{0,199})$/;
20
+ /** Diffs compare two revisions; worktree comparison uses one revision. */
21
+ const MAX_REVISION_LENGTH = 200;
22
+
23
+ export function isSafeRevision(revision: string): boolean {
24
+ const trimmed = revision.trim();
25
+ if (trimmed.length === 0 || trimmed.length > MAX_REVISION_LENGTH)
26
+ return false;
27
+ return REVISION_PATTERN.test(trimmed);
28
+ }
29
+
30
+ /** A relative repo path; `..` escapes and absolute paths are rejected. */
31
+ const REPO_PATH_PATTERN = /^[^:/\\?#*[\]'"\s][^:/\\?#*[\]'"\s]*$/;
32
+
33
+ export function isSafeRepoPath(value: string): boolean {
34
+ if (value.length === 0 || value.length > 512) return false;
35
+ const segments = value.split("/");
36
+ for (const segment of segments) {
37
+ if (segment === "" || segment === "." || segment === "..") return false;
38
+ if (!REPO_PATH_PATTERN.test(segment)) return false;
39
+ }
40
+ return true;
41
+ }
42
+
43
+ function revision(value: string): string | undefined {
44
+ return isSafeRevision(value) ? value.trim() : undefined;
45
+ }
46
+
47
+ export class InvalidRevisionError extends Error {
48
+ constructor(value: string) {
49
+ super(
50
+ `Invalid git revision: ${JSON.stringify(value.slice(0, 80))}. Allowed: a commit sha, branch or tag name, or HEAD with ~ / ^ modifiers.`,
51
+ );
52
+ this.name = "InvalidRevisionError";
53
+ }
54
+ }
55
+
56
+ export class InvalidPathError extends Error {
57
+ constructor(value: string) {
58
+ super(
59
+ `Invalid repository path: ${JSON.stringify(value.slice(0, 80))}. Use a relative path inside the repository.`,
60
+ );
61
+ this.name = "InvalidPathError";
62
+ }
63
+ }
64
+
65
+ export class InvalidDiffCombinationError extends Error {
66
+ constructor(message: string) {
67
+ super(`Invalid git diff options: ${message}`);
68
+ this.name = "InvalidDiffCombinationError";
69
+ }
70
+ }
71
+
72
+ export interface GitShowParams {
73
+ revision: string;
74
+ path?: string;
75
+ }
76
+
77
+ export function buildShowArgs(params: GitShowParams): string[] {
78
+ if (!isSafeRevision(params.revision))
79
+ throw new InvalidRevisionError(params.revision);
80
+ const args = [
81
+ "show",
82
+ "--no-color",
83
+ "--no-ext-diff",
84
+ "--no-textconv",
85
+ "--format=fuller",
86
+ params.revision.trim(),
87
+ ];
88
+ if (params.path !== undefined) {
89
+ if (!isSafeRepoPath(params.path)) throw new InvalidPathError(params.path);
90
+ args.push("--", params.path);
91
+ }
92
+ return args;
93
+ }
94
+
95
+ export interface GitDiffParams {
96
+ /** Base revision, e.g. HEAD, HEAD~1, a branch name, or a sha. */
97
+ from?: string;
98
+ /** Compared revision; omit to compare `from` against the worktree. */
99
+ to?: string;
100
+ /** Compare against the index (staged changes) instead of the worktree. */
101
+ staged?: boolean;
102
+ stat?: boolean;
103
+ path?: string;
104
+ }
105
+
106
+ export function buildDiffArgs(params: GitDiffParams): string[] {
107
+ const from = params.from !== undefined ? revision(params.from) : undefined;
108
+ if (params.from !== undefined && from === undefined) {
109
+ throw new InvalidRevisionError(params.from);
110
+ }
111
+ const to = params.to !== undefined ? revision(params.to) : undefined;
112
+ if (params.to !== undefined && to === undefined) {
113
+ throw new InvalidRevisionError(params.to);
114
+ }
115
+ if (params.to !== undefined && params.from === undefined) {
116
+ throw new InvalidDiffCombinationError("to requires from");
117
+ }
118
+ if (params.staged && (params.from !== undefined || params.to !== undefined)) {
119
+ throw new InvalidDiffCombinationError(
120
+ "staged cannot be combined with from or to",
121
+ );
122
+ }
123
+
124
+ const args = ["diff", "--no-color", "--no-ext-diff", "--no-textconv"];
125
+ if (params.staged) args.push("--cached");
126
+ if (params.stat) args.push("--stat");
127
+
128
+ if (from !== undefined && to !== undefined) {
129
+ // An explicit `--` separator is unnecessary for the range form; a
130
+ // validated revision can never start with `-` anyway.
131
+ args.push(`${from}...${to}`);
132
+ } else if (from !== undefined) {
133
+ args.push(from);
134
+ }
135
+ // No revisions: worktree vs index (or HEAD with --cached).
136
+
137
+ if (params.path !== undefined) {
138
+ if (!isSafeRepoPath(params.path)) throw new InvalidPathError(params.path);
139
+ args.push("--", params.path);
140
+ }
141
+ return args;
142
+ }
143
+
144
+ export interface GitLogParams {
145
+ revision?: string;
146
+ file?: string;
147
+ limit?: number;
148
+ oneline?: boolean;
149
+ }
150
+
151
+ export function buildLogArgs(params: GitLogParams): string[] {
152
+ const args = ["log", "--no-color", "--no-ext-diff"];
153
+ if (params.oneline !== false) args.push("--oneline");
154
+ const limit = Math.min(
155
+ GIT_LOG_MAX_LIMIT,
156
+ Math.max(1, Math.floor(params.limit ?? GIT_LOG_DEFAULT_LIMIT)),
157
+ );
158
+ args.push(`-n`, String(limit));
159
+
160
+ if (params.revision !== undefined) {
161
+ if (!isSafeRevision(params.revision)) {
162
+ throw new InvalidRevisionError(params.revision);
163
+ }
164
+ args.push(params.revision.trim());
165
+ }
166
+ if (params.file !== undefined) {
167
+ if (!isSafeRepoPath(params.file)) throw new InvalidPathError(params.file);
168
+ args.push("--", params.file);
169
+ }
170
+ return args;
171
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Bounded git process execution for the read-only git tools.
3
+ *
4
+ * Reuses the file-search capture discipline: a preview is retained in memory
5
+ * under pi's standard truncation limits while the complete output (up to the
6
+ * 10 MiB capture cap) streams to a temporary file, and the process group is
7
+ * terminated when the cap is exceeded.
8
+ */
9
+
10
+ import * as NodeServices from "@effect/platform-node/NodeServices";
11
+ import { Data, Effect } from "effect";
12
+ import type { CapturedOutput } from "../../file-search/src/output.ts";
13
+ import {
14
+ discardCapturedOutput,
15
+ executeSearchProcess,
16
+ } from "../../file-search/src/process.ts";
17
+ import { GIT_TIMEOUT_MS } from "./args.ts";
18
+
19
+ export const GIT_CAPTURE_MAX_BYTES = 10 * 1024 * 1024;
20
+
21
+ export class GitCommandError extends Data.TaggedError("GitCommandError")<{
22
+ readonly message: string;
23
+ }> {}
24
+
25
+ export interface GitOutcome {
26
+ readonly output: CapturedOutput;
27
+ readonly exitCode: number;
28
+ }
29
+
30
+ /**
31
+ * Run one git subcommand. Exit code 128 (not a repository / bad revision) and
32
+ * 129 (usage) surface as a GitCommandError carrying stderr; other non-zero
33
+ * exits also fail with the captured stderr detail.
34
+ */
35
+ export function runGit(
36
+ args: readonly string[],
37
+ cwd: string,
38
+ timeoutMs = GIT_TIMEOUT_MS,
39
+ ): Effect.Effect<GitOutcome, GitCommandError> {
40
+ return Effect.gen(function* () {
41
+ const result = yield* Effect.catchTags(
42
+ executeSearchProcess({
43
+ command: "git",
44
+ args,
45
+ cwd,
46
+ tempPrefix: "pi-git-",
47
+ maxCaptureBytes: GIT_CAPTURE_MAX_BYTES,
48
+ }),
49
+ {
50
+ SearchProcessError: (error) =>
51
+ new GitCommandError({
52
+ message: `failed to run git: ${error.message}`,
53
+ }),
54
+ PlatformError: (error) =>
55
+ new GitCommandError({
56
+ message: `git output capture failed: ${String(error.reason ?? "")}`,
57
+ }),
58
+ },
59
+ );
60
+ if (result.code !== 0) {
61
+ const detail =
62
+ result.stderr.trim() || `git exited with code ${result.code}`;
63
+ // A failure means nothing useful was captured; drop the artifact.
64
+ yield* Effect.ignore(discardCapturedOutput(result.output));
65
+ return yield* new GitCommandError({ message: detail });
66
+ }
67
+ return {
68
+ output: result.output,
69
+ exitCode: result.code,
70
+ } satisfies GitOutcome;
71
+ }).pipe(
72
+ Effect.timeout(timeoutMs),
73
+ Effect.mapError((error) => {
74
+ if (error._tag === "GitCommandError") return error;
75
+ return new GitCommandError({
76
+ message: `git command timed out after ${timeoutMs}ms`,
77
+ });
78
+ }),
79
+ Effect.provide(NodeServices.layer),
80
+ );
81
+ }
@@ -0,0 +1,56 @@
1
+ /** Model-facing text for the read-only git tools. */
2
+
3
+ export const GIT_SHOW_TOOL_DESCRIPTION =
4
+ "Show a git commit (message, author, and patch), optionally limited to one repository path. Read-only.";
5
+
6
+ export const GIT_SHOW_PROMPT_SNIPPET =
7
+ "Inspect a specific git commit with git_show.";
8
+
9
+ export const GIT_SHOW_PROMPT_GUIDELINES = [
10
+ "Use git_show to review what a single commit changed instead of reading the whole file tree and guessing.",
11
+ "Use git_diff to compare revisions or the worktree, and git_log to find commits first.",
12
+ ];
13
+
14
+ export const GIT_SHOW_PARAMETER_DESCRIPTIONS = {
15
+ revision:
16
+ "Commit to show: a sha (>=4 hex chars), branch or tag name, or HEAD with ~ / ^ modifiers, e.g. HEAD, HEAD~2, main.",
17
+ path: "Optional relative path inside the repository. Limits the commit patch to this path; it does not read the file blob.",
18
+ };
19
+
20
+ export const GIT_DIFF_TOOL_DESCRIPTION =
21
+ "Show a git diff: between two revisions, a revision and the worktree, or staged changes. Read-only.";
22
+
23
+ export const GIT_DIFF_PROMPT_SNIPPET =
24
+ "Compare git revisions or working-tree changes with git_diff.";
25
+
26
+ export const GIT_DIFF_PROMPT_GUIDELINES = [
27
+ "Use git_diff (not git_show) when reviewing changes between refs or uncommitted work.",
28
+ "Set stat to true first for a broad overview, then drill into specific paths.",
29
+ "To review a pull request, diff its branch against the base, e.g. from: 'main', to: 'feature-branch'.",
30
+ ];
31
+
32
+ export const GIT_DIFF_PARAMETER_DESCRIPTIONS = {
33
+ from: "Base revision. With to, compares from...to from their merge base (PR-style). Omit both revisions to diff the worktree against the index.",
34
+ to: "Compared revision. Requires from and uses the merge-base range from...to. Give from alone to diff that revision against the worktree.",
35
+ staged:
36
+ "Compare the index (staged changes) against HEAD instead of the worktree.",
37
+ stat: "Show a diffstat (files and line counts) instead of the full patch.",
38
+ path: "Only diff this relative path inside the repository.",
39
+ };
40
+
41
+ export const GIT_LOG_TOOL_DESCRIPTION =
42
+ "List commit history with sha, author, date, and first line. Filter by revision or file. Read-only.";
43
+
44
+ export const GIT_LOG_PROMPT_SNIPPET = "Browse git history with git_log.";
45
+
46
+ export const GIT_LOG_PROMPT_GUIDELINES = [
47
+ "Use git_log with a file to find who last touched a piece of code before changing it.",
48
+ "Keep oneline true unless the full commit message of every entry is needed.",
49
+ ];
50
+
51
+ export const GIT_LOG_PARAMETER_DESCRIPTIONS = {
52
+ revision: "History starting from this revision. Defaults to HEAD.",
53
+ file: "Only commits touching this relative path.",
54
+ limit: "Maximum commits to list (1-1000). Defaults to 100.",
55
+ oneline: "One line per commit (default true); false adds full messages.",
56
+ };