pi-webdesk 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.
Files changed (64) hide show
  1. package/README.md +111 -0
  2. package/dist/apps/daemon/src/appearance-preferences.js +218 -0
  3. package/dist/apps/daemon/src/auth.js +88 -0
  4. package/dist/apps/daemon/src/bin.js +123 -0
  5. package/dist/apps/daemon/src/cli.js +48 -0
  6. package/dist/apps/daemon/src/event-hub.js +155 -0
  7. package/dist/apps/daemon/src/index.js +102 -0
  8. package/dist/apps/daemon/src/launcher-control.js +114 -0
  9. package/dist/apps/daemon/src/launcher.js +73 -0
  10. package/dist/apps/daemon/src/pi-auth.js +290 -0
  11. package/dist/apps/daemon/src/pi-resources.js +182 -0
  12. package/dist/apps/daemon/src/pi-runtime-factory.js +19 -0
  13. package/dist/apps/daemon/src/pi-sessions.js +265 -0
  14. package/dist/apps/daemon/src/runtime-process.js +241 -0
  15. package/dist/apps/daemon/src/secret.js +71 -0
  16. package/dist/apps/daemon/src/server.js +1662 -0
  17. package/dist/apps/daemon/src/session-projection.js +117 -0
  18. package/dist/apps/daemon/src/state-lock.js +31 -0
  19. package/dist/apps/daemon/src/static-web.js +53 -0
  20. package/dist/apps/daemon/src/task-archive.js +152 -0
  21. package/dist/apps/daemon/src/task-commit.js +503 -0
  22. package/dist/apps/daemon/src/task-merge.js +912 -0
  23. package/dist/apps/daemon/src/task-review.js +204 -0
  24. package/dist/apps/daemon/src/task-runtime.js +1124 -0
  25. package/dist/apps/daemon/src/task-validation.js +352 -0
  26. package/dist/apps/daemon/src/workspace-store.js +140 -0
  27. package/dist/apps/daemon/src/workspace.js +795 -0
  28. package/dist/extensions/webdesk.js +34 -0
  29. package/dist/packages/git/src/commit.js +675 -0
  30. package/dist/packages/git/src/errors.js +55 -0
  31. package/dist/packages/git/src/fingerprint.js +286 -0
  32. package/dist/packages/git/src/index.js +123 -0
  33. package/dist/packages/git/src/merge.js +1008 -0
  34. package/dist/packages/git/src/paths.js +58 -0
  35. package/dist/packages/git/src/repository.js +77 -0
  36. package/dist/packages/git/src/review.js +396 -0
  37. package/dist/packages/git/src/runner.js +110 -0
  38. package/dist/packages/git/src/validation.js +263 -0
  39. package/dist/packages/git/src/worktree.js +233 -0
  40. package/dist/packages/pi-bridge/extensions/pita-policy.js +117 -0
  41. package/dist/packages/pi-bridge/src/auth.js +80 -0
  42. package/dist/packages/pi-bridge/src/errors.js +19 -0
  43. package/dist/packages/pi-bridge/src/handshake.js +43 -0
  44. package/dist/packages/pi-bridge/src/index.js +76 -0
  45. package/dist/packages/pi-bridge/src/jsonl.js +105 -0
  46. package/dist/packages/pi-bridge/src/policy-approval.js +62 -0
  47. package/dist/packages/pi-bridge/src/resolve.js +59 -0
  48. package/dist/packages/pi-bridge/src/resources-child.mjs +23 -0
  49. package/dist/packages/pi-bridge/src/resources.js +481 -0
  50. package/dist/packages/pi-bridge/src/rpc/client.js +480 -0
  51. package/dist/packages/pi-bridge/src/rpc/runtime.js +496 -0
  52. package/dist/packages/pi-bridge/src/rpc/supervisor.mjs +129 -0
  53. package/dist/packages/pi-bridge/src/rpc/tool-events.js +78 -0
  54. package/dist/packages/pi-bridge/src/rpc/wire.js +263 -0
  55. package/dist/packages/pi-bridge/src/runtime.js +0 -0
  56. package/dist/packages/pi-bridge/src/sessions-child.mjs +38 -0
  57. package/dist/packages/pi-bridge/src/sessions.js +314 -0
  58. package/dist/packages/pi-bridge/src/tool-activity.js +56 -0
  59. package/dist/packages/protocol/src/index.js +1863 -0
  60. package/dist/web/assets/index-BOw_fhvO.css +2 -0
  61. package/dist/web/assets/index-oXs7yAAo.js +119 -0
  62. package/dist/web/index.html +14 -0
  63. package/package.json +69 -0
  64. package/scripts/prepare.mjs +7 -0
@@ -0,0 +1,58 @@
1
+ // packages/git/src/paths.ts
2
+ import { realpath, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { GitServiceError } from "./errors.js";
5
+ function requireAbsolutePath(inputPath, label) {
6
+ if (!path.isAbsolute(inputPath)) {
7
+ throw new GitServiceError("GIT_PATH_NOT_ABSOLUTE", `${label} must be an absolute path`, {
8
+ details: { path: inputPath }
9
+ });
10
+ }
11
+ }
12
+ async function realpathOrNull(inputPath) {
13
+ try {
14
+ return await realpath(inputPath);
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+ async function canonicalizeDirectory(inputPath, label) {
20
+ requireAbsolutePath(inputPath, label);
21
+ let canonical;
22
+ try {
23
+ canonical = await realpath(inputPath);
24
+ } catch (error) {
25
+ const errno = error;
26
+ if (errno.code === "ENOENT" || errno.code === "ENOTDIR") {
27
+ throw new GitServiceError("GIT_PATH_NOT_FOUND", `${label} does not exist`, {
28
+ cause: error,
29
+ details: { path: inputPath }
30
+ });
31
+ }
32
+ throw new GitServiceError("GIT_FILESYSTEM_ERROR", `${label} could not be resolved`, {
33
+ cause: error,
34
+ details: { path: inputPath }
35
+ });
36
+ }
37
+ const info = await stat(canonical);
38
+ if (!info.isDirectory()) {
39
+ throw new GitServiceError("GIT_PATH_NOT_DIRECTORY", `${label} is not a directory`, {
40
+ details: { path: canonical }
41
+ });
42
+ }
43
+ return canonical;
44
+ }
45
+ async function canonicalPathForms(inputPath) {
46
+ const resolved = path.resolve(inputPath);
47
+ const direct = await realpathOrNull(resolved);
48
+ if (direct !== null) return [resolved, direct];
49
+ const parentReal = await realpathOrNull(path.dirname(resolved));
50
+ if (parentReal !== null) return [resolved, path.join(parentReal, path.basename(resolved))];
51
+ return [resolved];
52
+ }
53
+ export {
54
+ canonicalPathForms,
55
+ canonicalizeDirectory,
56
+ realpathOrNull,
57
+ requireAbsolutePath
58
+ };
@@ -0,0 +1,77 @@
1
+ // packages/git/src/repository.ts
2
+ import { realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { GitServiceError, boundedDetailText } from "./errors.js";
5
+ import { canonicalizeDirectory } from "./paths.js";
6
+ import {
7
+ defaultGitRunner,
8
+ runGitExpectingSuccess
9
+ } from "./runner.js";
10
+ var COMMIT_SHA_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
11
+ function singleOutputLine(stdout) {
12
+ return stdout.endsWith("\n") ? stdout.slice(0, -1) : stdout;
13
+ }
14
+ async function inspectRepository(repositoryPath, options = {}) {
15
+ const runner = options.runner ?? defaultGitRunner;
16
+ const cwd = await canonicalizeDirectory(repositoryPath, "repository path");
17
+ const classify = await runner(["rev-parse", "--is-bare-repository", "--is-inside-work-tree"], {
18
+ cwd
19
+ });
20
+ if (classify.exitCode !== 0) {
21
+ throw new GitServiceError("GIT_NOT_A_REPOSITORY", "Path is not inside a Git repository", {
22
+ details: { path: cwd, stderr: boundedDetailText(classify.stderr) }
23
+ });
24
+ }
25
+ const [isBare, isInsideWorkTree] = classify.stdout.split("\n");
26
+ if (isBare === "true") {
27
+ throw new GitServiceError("GIT_BARE_REPOSITORY", "Path belongs to a bare Git repository", {
28
+ details: { path: cwd }
29
+ });
30
+ }
31
+ if (isInsideWorkTree !== "true") {
32
+ throw new GitServiceError(
33
+ "GIT_NOT_A_WORK_TREE",
34
+ "Path is inside a Git repository but not inside its working tree",
35
+ { details: { path: cwd } }
36
+ );
37
+ }
38
+ const toplevel = await runGitExpectingSuccess(runner, ["rev-parse", "--show-toplevel"], { cwd });
39
+ const root = await realpath(singleOutputLine(toplevel.stdout));
40
+ const commonDirResult = await runGitExpectingSuccess(runner, ["rev-parse", "--git-common-dir"], {
41
+ cwd
42
+ });
43
+ const commonDir = await realpath(path.resolve(cwd, singleOutputLine(commonDirResult.stdout)));
44
+ const head = await runner(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], { cwd });
45
+ if (head.exitCode !== 0) {
46
+ throw new GitServiceError(
47
+ "GIT_HEAD_UNRESOLVED",
48
+ "HEAD does not resolve to a commit (the repository may have no commits yet)",
49
+ { details: { path: root } }
50
+ );
51
+ }
52
+ const headCommit = singleOutputLine(head.stdout);
53
+ if (!COMMIT_SHA_PATTERN.test(headCommit)) {
54
+ throw new GitServiceError("GIT_COMMAND_FAILED", "git rev-parse returned an unexpected HEAD value", {
55
+ details: { path: root, value: boundedDetailText(headCommit, 80) }
56
+ });
57
+ }
58
+ const branchResult = await runner(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd });
59
+ let branch;
60
+ if (branchResult.exitCode === 0) {
61
+ branch = singleOutputLine(branchResult.stdout);
62
+ } else if (branchResult.exitCode === 1) {
63
+ branch = null;
64
+ } else {
65
+ throw new GitServiceError("GIT_COMMAND_FAILED", "git symbolic-ref exited unexpectedly", {
66
+ details: {
67
+ exitCode: branchResult.exitCode,
68
+ stderr: boundedDetailText(branchResult.stderr)
69
+ }
70
+ });
71
+ }
72
+ return { root, commonDir, headCommit, branch };
73
+ }
74
+ export {
75
+ COMMIT_SHA_PATTERN,
76
+ inspectRepository
77
+ };
@@ -0,0 +1,396 @@
1
+ // packages/git/src/review.ts
2
+ import { createHash } from "node:crypto";
3
+ import { GitServiceError, boundedDetailText, isGitServiceError } from "./errors.js";
4
+ import { inspectRepository } from "./repository.js";
5
+ import {
6
+ createDefaultGitRunner,
7
+ defaultGitRunner,
8
+ runGitExpectingSuccess
9
+ } from "./runner.js";
10
+ var DEFAULT_REVIEW_MAX_FILES = 200;
11
+ var DEFAULT_REVIEW_MAX_DIFF_CHARS_PER_FILE = 2e4;
12
+ var DEFAULT_REVIEW_MAX_TOTAL_DIFF_CHARS = 2e5;
13
+ var DEFAULT_REVIEW_MAX_DURATION_MS = 2e4;
14
+ var DEFAULT_REVIEW_DIFF_TIMEOUT_MS = 2e3;
15
+ var DEFAULT_REVIEW_IDENTITY_RESERVE_MS = 1e3;
16
+ var MIN_REVIEW_DIFF_OUTPUT_BYTES = 64 * 1024;
17
+ var MAX_REVIEW_PATH_CHARS = 4096;
18
+ var READ_ONLY_GIT_FLAGS = [
19
+ "--no-optional-locks",
20
+ "--literal-pathspecs",
21
+ "-c",
22
+ "core.quotepath=false",
23
+ "-c",
24
+ "core.fsmonitor=false"
25
+ ];
26
+ var DIFF_FLAGS = [
27
+ "--no-color",
28
+ "--no-ext-diff",
29
+ "--no-textconv",
30
+ "--unified=3"
31
+ ];
32
+ function parseFailure(reason, sample) {
33
+ return new GitServiceError(
34
+ "GIT_REVIEW_PARSE_FAILED",
35
+ `git status returned unexpected output: ${reason}`,
36
+ { details: { sample: boundedDetailText(sample, 200) } }
37
+ );
38
+ }
39
+ function validateReportedPath(candidate) {
40
+ if (candidate.length === 0 || candidate.length > MAX_REVIEW_PATH_CHARS) {
41
+ throw parseFailure("path is empty or too long", candidate);
42
+ }
43
+ if (candidate.startsWith("/")) {
44
+ throw parseFailure("path is absolute", candidate);
45
+ }
46
+ if (candidate.includes("\uFFFD")) {
47
+ throw parseFailure("path is not valid UTF-8", candidate);
48
+ }
49
+ const segments = candidate.split("/");
50
+ for (const [index, segment] of segments.entries()) {
51
+ if (segment === "" && index === segments.length - 1 && candidate.endsWith("/")) {
52
+ continue;
53
+ }
54
+ if (segment === "" || segment === "." || segment === "..") {
55
+ throw parseFailure("path contains an empty, '.', or '..' segment", candidate);
56
+ }
57
+ }
58
+ return candidate;
59
+ }
60
+ function statusPair(parts, token) {
61
+ const xy = parts[1];
62
+ if (xy === void 0 || xy.length !== 2) {
63
+ throw parseFailure("missing XY status field", token);
64
+ }
65
+ const [x, y] = xy;
66
+ if (!/^[.MTADRCU]$/.test(x) || !/^[.MTADRCU]$/.test(y)) {
67
+ throw parseFailure("invalid XY status field", token);
68
+ }
69
+ return { x, y };
70
+ }
71
+ function submoduleState(parts, token) {
72
+ const sub = parts[2];
73
+ if (sub === void 0 || !/^(?:N\.\.\.|S[.C][.M][.U])$/.test(sub)) {
74
+ throw parseFailure("invalid submodule status field", token);
75
+ }
76
+ return sub;
77
+ }
78
+ function positiveBound(value, label) {
79
+ if (!Number.isSafeInteger(value) || value < 1) {
80
+ throw new GitServiceError(
81
+ "GIT_REVIEW_PARSE_FAILED",
82
+ `${label} must be a positive safe integer`
83
+ );
84
+ }
85
+ return value;
86
+ }
87
+ function restOfRecord(parts, fixedFields, token) {
88
+ if (parts.length <= fixedFields) {
89
+ throw parseFailure("missing path field", token);
90
+ }
91
+ return parts.slice(fixedFields).join(" ");
92
+ }
93
+ function parseStatusOutput(stdout) {
94
+ const tokens = stdout.split("\0");
95
+ const entries = [];
96
+ for (let index = 0; index < tokens.length; index++) {
97
+ const token = tokens[index];
98
+ if (token === "") continue;
99
+ if (token.startsWith("? ")) {
100
+ entries.push({
101
+ record: "untracked",
102
+ path: validateReportedPath(token.slice(2)),
103
+ previousPath: null,
104
+ x: ".",
105
+ y: ".",
106
+ sub: "N..."
107
+ });
108
+ continue;
109
+ }
110
+ if (token.startsWith("1 ")) {
111
+ const parts = token.split(" ");
112
+ entries.push({
113
+ record: "changed",
114
+ path: validateReportedPath(restOfRecord(parts, 8, token)),
115
+ previousPath: null,
116
+ ...statusPair(parts, token),
117
+ sub: submoduleState(parts, token)
118
+ });
119
+ continue;
120
+ }
121
+ if (token.startsWith("2 ")) {
122
+ const parts = token.split(" ");
123
+ const previous = tokens[++index];
124
+ if (previous === void 0 || previous === "") {
125
+ throw parseFailure("rename record is missing its source path", token);
126
+ }
127
+ entries.push({
128
+ record: "renamed",
129
+ path: validateReportedPath(restOfRecord(parts, 9, token)),
130
+ previousPath: validateReportedPath(previous),
131
+ ...statusPair(parts, token),
132
+ sub: submoduleState(parts, token)
133
+ });
134
+ continue;
135
+ }
136
+ if (token.startsWith("u ")) {
137
+ const parts = token.split(" ");
138
+ entries.push({
139
+ record: "unmerged",
140
+ path: validateReportedPath(restOfRecord(parts, 10, token)),
141
+ previousPath: null,
142
+ ...statusPair(parts, token),
143
+ sub: submoduleState(parts, token)
144
+ });
145
+ continue;
146
+ }
147
+ if (token.startsWith("# ") || token.startsWith("! ")) continue;
148
+ throw parseFailure("unrecognized record type", token);
149
+ }
150
+ return entries;
151
+ }
152
+ function changeKind(entry) {
153
+ if (entry.record === "unmerged") return "conflicted";
154
+ if (entry.record === "untracked") return "untracked";
155
+ if (entry.record === "renamed") {
156
+ return entry.x === "C" || entry.y === "C" ? "copied" : "renamed";
157
+ }
158
+ if (entry.x === "A" || entry.y === "A") return "added";
159
+ if (entry.x === "D" || entry.y === "D") return "deleted";
160
+ if (entry.x === "T" || entry.y === "T") return "type-changed";
161
+ return "modified";
162
+ }
163
+ function isBinaryDiff(diff) {
164
+ return diff.split("\n").some((line) => line.startsWith("Binary files ") && line.endsWith(" differ"));
165
+ }
166
+ async function produceFileDiff(runner, root, entry, baseCommit) {
167
+ let args;
168
+ if (entry.record === "untracked") {
169
+ args = [...READ_ONLY_GIT_FLAGS, "diff", ...DIFF_FLAGS, "--no-index", "--", "/dev/null", entry.path];
170
+ } else if (entry.record === "unmerged") {
171
+ args = [...READ_ONLY_GIT_FLAGS, "diff", ...DIFF_FLAGS, "--", entry.path];
172
+ } else {
173
+ args = [
174
+ ...READ_ONLY_GIT_FLAGS,
175
+ "diff",
176
+ ...DIFF_FLAGS,
177
+ "--find-renames",
178
+ baseCommit,
179
+ "--",
180
+ entry.path,
181
+ ...entry.previousPath === null ? [] : [entry.previousPath]
182
+ ];
183
+ }
184
+ let result;
185
+ try {
186
+ result = await runner(args, { cwd: root });
187
+ } catch (error) {
188
+ if (isGitServiceError(error, "GIT_OUTPUT_OVERFLOW")) return { outcome: "too-large" };
189
+ if (isGitServiceError(error, "GIT_REVIEW_DEADLINE_EXCEEDED")) {
190
+ return { outcome: "review-budget" };
191
+ }
192
+ if (isGitServiceError(error, "GIT_COMMAND_TERMINATED")) return { outcome: "diff-failed" };
193
+ throw error;
194
+ }
195
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
196
+ return { outcome: "diff-failed" };
197
+ }
198
+ if (isBinaryDiff(result.stdout)) return { outcome: "binary" };
199
+ return { outcome: "text", text: result.stdout };
200
+ }
201
+ function withDeadline(runner, deadlineMs, now, perCommandLimitMs) {
202
+ return async (args, options = {}) => {
203
+ const remainingMs = Math.floor(deadlineMs - now());
204
+ if (remainingMs < 1) {
205
+ throw new GitServiceError(
206
+ "GIT_REVIEW_DEADLINE_EXCEEDED",
207
+ "The Git review exceeded its overall time budget"
208
+ );
209
+ }
210
+ const callerLimitMs = options.timeoutMs ?? Number.POSITIVE_INFINITY;
211
+ const commandLimitMs = perCommandLimitMs ?? Number.POSITIVE_INFINITY;
212
+ const aggregateIsLimit = remainingMs <= callerLimitMs && remainingMs <= commandLimitMs;
213
+ try {
214
+ return await runner(args, {
215
+ ...options,
216
+ timeoutMs: Math.min(remainingMs, callerLimitMs, commandLimitMs)
217
+ });
218
+ } catch (error) {
219
+ if (aggregateIsLimit && isGitServiceError(error, "GIT_COMMAND_TERMINATED")) {
220
+ throw new GitServiceError(
221
+ "GIT_REVIEW_DEADLINE_EXCEEDED",
222
+ "The Git review exceeded its overall time budget",
223
+ { cause: error }
224
+ );
225
+ }
226
+ throw error;
227
+ }
228
+ };
229
+ }
230
+ function statusEntryId(entry) {
231
+ return createHash("sha256").update(entry.record).update("\0").update(entry.path).update("\0").update(entry.previousPath ?? "").update("\0").update(entry.x).update(entry.y).update(entry.sub).digest("hex");
232
+ }
233
+ function sameRepositoryIdentity(left, right) {
234
+ return left.root === right.root && left.commonDir === right.commonDir && left.headCommit === right.headCommit && left.branch === right.branch;
235
+ }
236
+ async function reviewWorktree(options) {
237
+ const maxFiles = positiveBound(
238
+ options.maxFiles ?? DEFAULT_REVIEW_MAX_FILES,
239
+ "Review file limit"
240
+ );
241
+ const maxDiffCharsPerFile = positiveBound(
242
+ options.maxDiffCharsPerFile ?? DEFAULT_REVIEW_MAX_DIFF_CHARS_PER_FILE,
243
+ "Per-file diff limit"
244
+ );
245
+ const maxTotalDiffChars = positiveBound(
246
+ options.maxTotalDiffChars ?? DEFAULT_REVIEW_MAX_TOTAL_DIFF_CHARS,
247
+ "Total diff limit"
248
+ );
249
+ const maxDurationMs = positiveBound(
250
+ options.maxDurationMs ?? DEFAULT_REVIEW_MAX_DURATION_MS,
251
+ "Review duration limit"
252
+ );
253
+ const now = options.now ?? Date.now;
254
+ const startedAt = now();
255
+ const deadlineMs = startedAt + maxDurationMs;
256
+ const identityReserveMs = Math.min(
257
+ DEFAULT_REVIEW_IDENTITY_RESERVE_MS,
258
+ Math.max(1, Math.floor(maxDurationMs / 4))
259
+ );
260
+ const diffDeadlineMs = deadlineMs - identityReserveMs;
261
+ const baseRunner = options.runner ?? defaultGitRunner;
262
+ const reviewRunner = withDeadline(baseRunner, deadlineMs, now);
263
+ const rawDiffRunner = options.runner ?? createDefaultGitRunner({
264
+ timeoutMs: DEFAULT_REVIEW_DIFF_TIMEOUT_MS,
265
+ maxOutputBytes: Math.max(
266
+ MIN_REVIEW_DIFF_OUTPUT_BYTES,
267
+ maxDiffCharsPerFile * 4 + 16 * 1024
268
+ )
269
+ });
270
+ const diffRunner = withDeadline(
271
+ rawDiffRunner,
272
+ diffDeadlineMs,
273
+ now,
274
+ DEFAULT_REVIEW_DIFF_TIMEOUT_MS
275
+ );
276
+ const worktree = await inspectRepository(options.worktreePath, { runner: reviewRunner });
277
+ const status = await runGitExpectingSuccess(
278
+ reviewRunner,
279
+ [
280
+ ...READ_ONLY_GIT_FLAGS,
281
+ "status",
282
+ "--porcelain=v2",
283
+ "-z",
284
+ "--untracked-files=all"
285
+ ],
286
+ { cwd: worktree.root }
287
+ );
288
+ const entries = parseStatusOutput(status.stdout);
289
+ entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
290
+ const totalChangedFiles = entries.length;
291
+ const selected = entries.slice(0, maxFiles);
292
+ let remainingChars = maxTotalDiffChars;
293
+ let totalDiffTruncated = false;
294
+ const files = [];
295
+ for (const entry of selected) {
296
+ const base = {
297
+ entryId: statusEntryId(entry),
298
+ path: entry.path,
299
+ previousPath: entry.previousPath,
300
+ kind: changeKind(entry),
301
+ staged: (entry.record === "changed" || entry.record === "renamed") && entry.x !== ".",
302
+ unstaged: (entry.record === "changed" || entry.record === "renamed") && entry.y !== "."
303
+ };
304
+ if (remainingChars <= 0) {
305
+ totalDiffTruncated = true;
306
+ files.push({
307
+ ...base,
308
+ binary: false,
309
+ diff: null,
310
+ diffTruncated: false,
311
+ diffOmittedReason: "total-budget"
312
+ });
313
+ continue;
314
+ }
315
+ if (now() >= diffDeadlineMs) {
316
+ totalDiffTruncated = true;
317
+ files.push({
318
+ ...base,
319
+ binary: false,
320
+ diff: null,
321
+ diffTruncated: false,
322
+ diffOmittedReason: "review-budget"
323
+ });
324
+ continue;
325
+ }
326
+ const produced = await produceFileDiff(
327
+ diffRunner,
328
+ worktree.root,
329
+ entry,
330
+ worktree.headCommit
331
+ );
332
+ if (produced.outcome !== "text") {
333
+ if (produced.outcome === "review-budget") totalDiffTruncated = true;
334
+ files.push({
335
+ ...base,
336
+ binary: produced.outcome === "binary",
337
+ diff: null,
338
+ diffTruncated: false,
339
+ diffOmittedReason: produced.outcome
340
+ });
341
+ continue;
342
+ }
343
+ const perFileLimit = Math.min(maxDiffCharsPerFile, remainingChars);
344
+ let text = produced.text;
345
+ let truncated = false;
346
+ if (text.length > perFileLimit) {
347
+ if (perFileLimit < maxDiffCharsPerFile) totalDiffTruncated = true;
348
+ text = text.slice(0, perFileLimit);
349
+ truncated = true;
350
+ }
351
+ remainingChars -= text.length;
352
+ files.push({
353
+ ...base,
354
+ binary: false,
355
+ diff: text,
356
+ diffTruncated: truncated,
357
+ diffOmittedReason: null
358
+ });
359
+ }
360
+ const verifiedWorktree = await inspectRepository(worktree.root, {
361
+ runner: reviewRunner
362
+ });
363
+ if (!sameRepositoryIdentity(worktree, verifiedWorktree)) {
364
+ throw new GitServiceError(
365
+ "GIT_REPOSITORY_CHANGED_DURING_REVIEW",
366
+ "The worktree branch or HEAD changed while its review was being captured; refresh to retry",
367
+ {
368
+ details: {
369
+ beforeHead: worktree.headCommit,
370
+ afterHead: verifiedWorktree.headCommit,
371
+ beforeBranch: worktree.branch,
372
+ afterBranch: verifiedWorktree.branch
373
+ }
374
+ }
375
+ );
376
+ }
377
+ return {
378
+ worktree,
379
+ clean: totalChangedFiles === 0,
380
+ files,
381
+ totalChangedFiles,
382
+ omittedFiles: totalChangedFiles - selected.length,
383
+ totalDiffTruncated
384
+ };
385
+ }
386
+ export {
387
+ DEFAULT_REVIEW_MAX_DIFF_CHARS_PER_FILE,
388
+ DEFAULT_REVIEW_MAX_DURATION_MS,
389
+ DEFAULT_REVIEW_MAX_FILES,
390
+ DEFAULT_REVIEW_MAX_TOTAL_DIFF_CHARS,
391
+ READ_ONLY_GIT_FLAGS,
392
+ parseStatusOutput,
393
+ reviewWorktree,
394
+ sameRepositoryIdentity,
395
+ validateReportedPath
396
+ };
@@ -0,0 +1,110 @@
1
+ // packages/git/src/runner.ts
2
+ import { execFile } from "node:child_process";
3
+ import { GitServiceError, boundedDetailText } from "./errors.js";
4
+ var DEFAULT_TIMEOUT_MS = 3e4;
5
+ var DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
6
+ function createDefaultGitRunner(options = {}) {
7
+ const gitExecutable = options.gitExecutable ?? "git";
8
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
9
+ const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
10
+ const pinnedEnv = {
11
+ GIT_TERMINAL_PROMPT: "0",
12
+ GIT_OPTIONAL_LOCKS: "0",
13
+ LC_ALL: "C",
14
+ LANG: "C"
15
+ };
16
+ const baseEnv = {
17
+ ...process.env,
18
+ ...options.extraEnv,
19
+ ...pinnedEnv
20
+ };
21
+ return (args, runOptions = {}) => new Promise((resolve, reject) => {
22
+ const invocationTimeoutMs = Math.min(
23
+ timeoutMs,
24
+ runOptions.timeoutMs ?? timeoutMs
25
+ );
26
+ const env = runOptions.extraEnv === void 0 ? baseEnv : { ...baseEnv, ...runOptions.extraEnv, ...pinnedEnv };
27
+ const child = execFile(
28
+ gitExecutable,
29
+ args,
30
+ {
31
+ cwd: runOptions.cwd,
32
+ env,
33
+ encoding: "utf8",
34
+ timeout: invocationTimeoutMs,
35
+ maxBuffer: maxOutputBytes,
36
+ windowsHide: true
37
+ },
38
+ (error, stdout, stderr) => {
39
+ if (error === null) {
40
+ resolve({ exitCode: 0, stdout, stderr });
41
+ return;
42
+ }
43
+ const failure = error;
44
+ if (typeof failure.code === "number") {
45
+ resolve({ exitCode: failure.code, stdout, stderr });
46
+ return;
47
+ }
48
+ const details = { args: boundedDetailText(args.join(" "), 256) };
49
+ if (failure.code === "ENOENT") {
50
+ reject(
51
+ new GitServiceError(
52
+ "GIT_EXECUTABLE_NOT_FOUND",
53
+ `Git executable "${gitExecutable}" was not found`,
54
+ { cause: failure, details }
55
+ )
56
+ );
57
+ } else if (failure.killed === true || failure.signal != null) {
58
+ reject(
59
+ new GitServiceError(
60
+ "GIT_COMMAND_TERMINATED",
61
+ "Git command timed out or was terminated by a signal",
62
+ { cause: failure, details }
63
+ )
64
+ );
65
+ } else if (failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
66
+ reject(
67
+ new GitServiceError(
68
+ "GIT_OUTPUT_OVERFLOW",
69
+ "Git command produced more output than the configured bound",
70
+ { cause: failure, details }
71
+ )
72
+ );
73
+ } else {
74
+ reject(
75
+ new GitServiceError("GIT_SPAWN_FAILED", "Failed to spawn the Git process", {
76
+ cause: failure,
77
+ details
78
+ })
79
+ );
80
+ }
81
+ }
82
+ );
83
+ if (child.stdin !== null) {
84
+ child.stdin.on("error", () => void 0);
85
+ if (runOptions.stdin !== void 0) {
86
+ child.stdin.write(runOptions.stdin);
87
+ }
88
+ child.stdin.end();
89
+ }
90
+ });
91
+ }
92
+ var defaultGitRunner = createDefaultGitRunner();
93
+ async function runGitExpectingSuccess(runner, args, options) {
94
+ const result = await runner(args, options);
95
+ if (result.exitCode !== 0) {
96
+ throw new GitServiceError("GIT_COMMAND_FAILED", `git ${args[0] ?? ""} exited unexpectedly`, {
97
+ details: {
98
+ args: boundedDetailText(args.join(" "), 256),
99
+ exitCode: result.exitCode,
100
+ stderr: boundedDetailText(result.stderr)
101
+ }
102
+ });
103
+ }
104
+ return result;
105
+ }
106
+ export {
107
+ createDefaultGitRunner,
108
+ defaultGitRunner,
109
+ runGitExpectingSuccess
110
+ };