diffing 0.14.1 → 0.16.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.
@@ -0,0 +1,754 @@
1
+ /**
2
+ * diffing × pi — deep integration extension
3
+ *
4
+ * Bridges the local-first diffing review loop into pi:
5
+ * - Structured LLM tools mirroring the diffing MCP surface (status, review
6
+ * start, comments, reply/resolve, plan loop, progress, sessions, GH PR).
7
+ * - `/diffing` command to open/reuse the review UI for the current repo.
8
+ * - Footer status showing the active review session.
9
+ * - Skill self-heal: keeps `~/.agents/skills/diffing*` as symlinks to the
10
+ * canonical `.agents/skills` checkout so pi dedupes by realpath and no
11
+ * `[Skill conflicts]` banner appears when pi runs inside the diffing repo.
12
+ *
13
+ * All tool executions spawn the `diffing` CLI in the consumer repo (ctx.cwd).
14
+ * Install: `pi install git:github.com/ahmedragab20/diffing`, or symlink this
15
+ * directory into `~/.pi/agent/extensions/diffing`.
16
+ */
17
+
18
+ import { spawn } from "node:child_process";
19
+ import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, symlinkSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { dirname, join, resolve } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { StringEnum } from "@earendil-works/pi-ai";
24
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
25
+ import { Type } from "typebox";
26
+
27
+ // ────────────────────────────────────────────────────────────────────────────
28
+ // Constants
29
+ // ────────────────────────────────────────────────────────────────────────────
30
+
31
+ const SKILL_NAMES = [
32
+ "diffing",
33
+ "diffing-finish-review",
34
+ "diffing-plan-review",
35
+ "diffing-pr-address",
36
+ "diffing-pr-read",
37
+ "diffing-review",
38
+ "diffing-start-review",
39
+ ] as const;
40
+
41
+ const MAX_OUTPUT_BYTES = 48 * 1024;
42
+ const REVIEW_START_TIMEOUT_MS = 15_000;
43
+ const REVIEW_START_POLL_MS = 400;
44
+
45
+ const SKILLS_REL = join(".agents", "skills");
46
+
47
+ // ────────────────────────────────────────────────────────────────────────────
48
+ // CLI runner
49
+ // ────────────────────────────────────────────────────────────────────────────
50
+
51
+ interface RunResult {
52
+ exitCode: number;
53
+ stdout: string;
54
+ stderr: string;
55
+ }
56
+
57
+ function runDiffing(
58
+ args: string[],
59
+ cwd: string,
60
+ opts: { timeoutMs?: number; stdin?: string; signal?: AbortSignal } = {},
61
+ ): Promise<RunResult> {
62
+ return new Promise((resolvePromise) => {
63
+ let child;
64
+ try {
65
+ child = spawn("diffing", args, {
66
+ cwd,
67
+ stdio: ["pipe", "pipe", "pipe"],
68
+ signal: opts.signal,
69
+ shell: false,
70
+ });
71
+ } catch (error) {
72
+ resolvePromise({
73
+ exitCode: 127,
74
+ stdout: "",
75
+ stderr: `Failed to spawn "diffing": ${error instanceof Error ? error.message : String(error)}`,
76
+ });
77
+ return;
78
+ }
79
+ const stdout: Buffer[] = [];
80
+ const stderr: Buffer[] = [];
81
+ let stdoutLen = 0;
82
+ let stderrLen = 0;
83
+ const cap = (bufs: Buffer[], len: number, chunk: Buffer) => {
84
+ if (len >= MAX_OUTPUT_BYTES) return len;
85
+ const remaining = MAX_OUTPUT_BYTES - len;
86
+ bufs.push(chunk.subarray(0, remaining));
87
+ return len + Math.min(remaining, chunk.length);
88
+ };
89
+ child.stdout?.on("data", (chunk: Buffer) => {
90
+ stdoutLen = cap(stdout, stdoutLen, chunk);
91
+ });
92
+ child.stderr?.on("data", (chunk: Buffer) => {
93
+ stderrLen = cap(stderr, stderrLen, chunk);
94
+ });
95
+ let settled = false;
96
+ const finish = (exitCode: number) => {
97
+ if (settled) return;
98
+ settled = true;
99
+ resolvePromise({
100
+ exitCode,
101
+ stdout: Buffer.concat(stdout).toString("utf-8"),
102
+ stderr: Buffer.concat(stderr).toString("utf-8"),
103
+ });
104
+ };
105
+ child.on("error", (error) => {
106
+ if (!settled) {
107
+ settled = true;
108
+ resolvePromise({
109
+ exitCode: 127,
110
+ stdout: "",
111
+ stderr: `Failed to run "diffing": ${error.message}`,
112
+ });
113
+ }
114
+ });
115
+ child.on("close", (code) => finish(code ?? 1));
116
+ if (opts.stdin !== undefined) {
117
+ child.stdin?.end(opts.stdin);
118
+ } else {
119
+ child.stdin?.end();
120
+ }
121
+ });
122
+ }
123
+
124
+ function textResult(text: string, details: Record<string, unknown>) {
125
+ return {
126
+ content: [{ type: "text" as const, text }],
127
+ details,
128
+ };
129
+ }
130
+
131
+ function describe(result: RunResult, command: string): string {
132
+ if (result.exitCode === 0) {
133
+ const out = result.stdout.trim();
134
+ return out || "(no output)";
135
+ }
136
+ const err = result.stderr.trim();
137
+ const hint =
138
+ result.exitCode === 127
139
+ ? "\n\n`diffing` was not found on PATH. Install it with `npm i -g diffing` or run `diffing setup`."
140
+ : result.exitCode === 3
141
+ ? "\n\nNo diffing server is running for this repo. Start one with `diffing` (or the diffing_start_review tool)."
142
+ : "";
143
+ return err ? `${err}${hint}` : `exit code ${result.exitCode}${hint}`;
144
+ }
145
+
146
+ async function diffingUrl(cwd: string): Promise<string | null> {
147
+ const result = await runDiffing(["url"], cwd);
148
+ if (result.exitCode !== 0) return null;
149
+ const url = result.stdout.trim();
150
+ return url.startsWith("http") ? url : null;
151
+ }
152
+
153
+ // ────────────────────────────────────────────────────────────────────────────
154
+ // Session status
155
+ // ────────────────────────────────────────────────────────────────────────────
156
+
157
+ interface SessionSummary {
158
+ id: string;
159
+ active?: boolean;
160
+ mode?: string;
161
+ pid?: number;
162
+ url?: string;
163
+ scope?: string;
164
+ }
165
+
166
+ async function activeSessions(cwd: string): Promise<SessionSummary[]> {
167
+ const result = await runDiffing(["sessions", "--json"], cwd);
168
+ if (result.exitCode !== 0) return [];
169
+ try {
170
+ const parsed = JSON.parse(result.stdout);
171
+ return Array.isArray(parsed) ? (parsed as SessionSummary[]) : [];
172
+ } catch {
173
+ return [];
174
+ }
175
+ }
176
+
177
+ async function refreshStatus(ctx: ExtensionContext): Promise<void> {
178
+ try {
179
+ const sessions = await activeSessions(ctx.cwd);
180
+ const active = sessions.find((s) => s.active) ?? sessions[0];
181
+ if (active) {
182
+ ctx.ui.setStatus("diffing", `diffing: ${active.mode ?? "session"} — ${active.url ?? ""}`);
183
+ } else {
184
+ ctx.ui.setStatus("diffing", "diffing: no server");
185
+ }
186
+ } catch {
187
+ // status is best-effort
188
+ }
189
+ }
190
+
191
+ // ────────────────────────────────────────────────────────────────────────────
192
+ // Canonical skill root + self-heal
193
+ // ────────────────────────────────────────────────────────────────────────────
194
+
195
+ function packageRootOfExtension(): string {
196
+ // extensions/pi/index.ts -> repo root
197
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
198
+ }
199
+
200
+ function findGitRepoRoot(startDir: string): string | null {
201
+ let dir = resolve(startDir);
202
+ for (;;) {
203
+ if (existsSync(join(dir, ".git"))) return dir;
204
+ const parent = dirname(dir);
205
+ if (parent === dir) return null;
206
+ dir = parent;
207
+ }
208
+ }
209
+
210
+ function isDiffingRoot(dir: string): boolean {
211
+ try {
212
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as { name?: string };
213
+ if (pkg.name !== "diffing") return false;
214
+ return existsSync(join(dir, SKILLS_REL));
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ /** Resolve the canonical diffing checkout whose `.agents/skills` are the source of truth. */
221
+ function findCanonicalRoot(cwd: string): string | null {
222
+ const repoRoot = findGitRepoRoot(cwd);
223
+ if (repoRoot && isDiffingRoot(repoRoot)) return repoRoot;
224
+ const pkgRoot = packageRootOfExtension();
225
+ if (isDiffingRoot(pkgRoot)) return pkgRoot;
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * Keep the 7 `~/.agents/skills/diffing*` entries as symlinks to the canonical
231
+ * checkout's `.agents/skills`. pi dedupes skills by canonical realpath, so
232
+ * symlinked home entries merge silently with the repo's project skills and no
233
+ * `[Skill conflicts]` banner appears. Repairs stale real copies left behind by
234
+ * `diffing setup skills` / `npx skills add --copy`. Only touches the 7 known
235
+ * names; never deletes anything else.
236
+ */
237
+ function selfHealSkillLinks(canonical: string): void {
238
+ const homeSkills = join(homedir(), ".agents", "skills");
239
+ if (!existsSync(homeSkills)) return;
240
+ for (const name of SKILL_NAMES) {
241
+ const target = join(canonical, SKILLS_REL, name);
242
+ if (!existsSync(target)) continue;
243
+ const link = join(homeSkills, name);
244
+ try {
245
+ if (existsSync(link) || lstatSync(link)) {
246
+ if (lstatSync(link).isSymbolicLink()) {
247
+ const real = realpathSync(link);
248
+ if (real === realpathSync(target)) continue; // already correct
249
+ }
250
+ rmSync(link, { recursive: true, force: true });
251
+ }
252
+ symlinkSync(target, link, "dir");
253
+ } catch {
254
+ // best-effort; a failed heal must not break the session
255
+ }
256
+ }
257
+ }
258
+
259
+ // ────────────────────────────────────────────────────────────────────────────
260
+ // Detached review server start
261
+ // ────────────────────────────────────────────────────────────────────────────
262
+
263
+ function spawnDetachedReview(cwd: string, args: string[]): void {
264
+ try {
265
+ const child = spawn("diffing", args, { cwd, stdio: "ignore", detached: true });
266
+ child.unref();
267
+ } catch {
268
+ // surfaced by the URL poll below
269
+ }
270
+ }
271
+
272
+ /** Start (or reuse) a web review session and return its base URL. */
273
+ async function ensureReviewUrl(
274
+ cwd: string,
275
+ extraArgs: string[] = [],
276
+ reuse: boolean,
277
+ signal?: AbortSignal,
278
+ ): Promise<{ url: string; reused: boolean }> {
279
+ const existing = await diffingUrl(cwd);
280
+ if (existing && reuse) return { url: existing, reused: true };
281
+ if (existing && !reuse) return { url: existing, reused: true };
282
+ spawnDetachedReview(cwd, ["--web", "--no-open", "--skip-setup", ...extraArgs]);
283
+ const deadline = Date.now() + REVIEW_START_TIMEOUT_MS;
284
+ for (;;) {
285
+ if (signal?.aborted) break;
286
+ const url = await diffingUrl(cwd);
287
+ if (url) return { url, reused: false };
288
+ if (Date.now() >= deadline) break;
289
+ await new Promise((r) => setTimeout(r, REVIEW_START_POLL_MS));
290
+ }
291
+ throw new Error(
292
+ "diffing server did not become ready in time. Check `diffing doctor` and that `diffing` is on PATH.",
293
+ );
294
+ }
295
+
296
+ // ────────────────────────────────────────────────────────────────────────────
297
+ // Tool helpers
298
+ // ────────────────────────────────────────────────────────────────────────────
299
+
300
+ function modelName(ctx: ExtensionContext, explicit?: string): string {
301
+ return explicit ?? (ctx.model ? `${ctx.model.id}` : "pi");
302
+ }
303
+
304
+ // ────────────────────────────────────────────────────────────────────────────
305
+ // Extension
306
+ // ────────────────────────────────────────────────────────────────────────────
307
+
308
+ export default function (pi: ExtensionAPI) {
309
+ // Keep the home skill links canonical for every session (idempotent).
310
+ pi.on("session_start", async (_event, ctx) => {
311
+ const canonical = findCanonicalRoot(ctx.cwd);
312
+ if (canonical) selfHealSkillLinks(canonical);
313
+ await refreshStatus(ctx);
314
+ });
315
+
316
+ // Contribute the canonical skills so pi users outside the repo (or without
317
+ // `~/.agents/skills` copies) still get them. Dedup-safe: when the path is
318
+ // the same realpath as project-discovered skills, pi merges silently.
319
+ pi.on("resources_discover", async (event) => {
320
+ const canonical = findCanonicalRoot(event.cwd);
321
+ if (!canonical) return undefined;
322
+ return { skillPaths: [join(canonical, SKILLS_REL)] };
323
+ });
324
+
325
+ // ── tools ────────────────────────────────────────────────────────────────
326
+
327
+ pi.registerTool({
328
+ name: "diffing_status",
329
+ label: "Diffing Status",
330
+ description:
331
+ "Report the active diffing review session for the current repo: server state, mode (web/tui/gh-pr), scope, and base URL. Call this first before other diffing tools.",
332
+ parameters: Type.Object({}),
333
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
334
+ const sessions = await activeSessions(ctx.cwd);
335
+ const url = await diffingUrl(ctx.cwd);
336
+ await refreshStatus(ctx);
337
+ if (sessions.length === 0) {
338
+ return textResult(
339
+ "No diffing server is running for this repo. Start one with diffing_start_review or `/diffing`.",
340
+ { running: false, sessions: [], url },
341
+ );
342
+ }
343
+ const lines = sessions.map((s) =>
344
+ [
345
+ `- ${s.id}${s.active ? " (active)" : ""} mode=${s.mode ?? "?"} url=${s.url ?? "?"} scope=${s.scope ?? "?"}`,
346
+ ].join(""),
347
+ );
348
+ return textResult(`Active diffing sessions:\n${lines.join("\n")}`, {
349
+ running: true,
350
+ sessions,
351
+ url,
352
+ });
353
+ },
354
+ });
355
+
356
+ pi.registerTool({
357
+ name: "diffing_start_review",
358
+ label: "Start Review",
359
+ description:
360
+ "Start (or reuse) a diffing web review session for the current repo's working tree, or a GitHub PR with prRef. Returns the human review URL. Use before handing work to a human.",
361
+ parameters: Type.Object({
362
+ prRef: Type.Optional(
363
+ Type.String({
364
+ description:
365
+ "GitHub PR reference to review instead of the working tree: bare number, owner/repo#N, or full URL.",
366
+ }),
367
+ ),
368
+ reuse: Type.Optional(
369
+ Type.Boolean({ description: "Reuse the active session if one is running. Default: true.", default: true }),
370
+ ),
371
+ }),
372
+ async execute(_id, params, signal, _onUpdate, ctx) {
373
+ try {
374
+ const extra = params.prRef ? ["--gh-pr", params.prRef] : [];
375
+ const { url, reused } = await ensureReviewUrl(ctx.cwd, extra, params.reuse ?? true, signal);
376
+ await refreshStatus(ctx);
377
+ return textResult(
378
+ `Review ${reused ? "reused" : "started"}: ${url}`,
379
+ { url, reused, mode: params.prRef ? "gh-pr" : "web" },
380
+ );
381
+ } catch (error) {
382
+ return textResult(error instanceof Error ? error.message : String(error), { started: false });
383
+ }
384
+ },
385
+ });
386
+
387
+ pi.registerTool({
388
+ name: "diffing_comments",
389
+ label: "Diffing Comments",
390
+ description:
391
+ "Snapshot the current review comments as XML (or JSON). Use --open to see only unresolved threads. Parse the returned XML to act on feedback.",
392
+ parameters: Type.Object({
393
+ open: Type.Optional(
394
+ Type.Boolean({ description: "Only include open (unresolved) comment threads. Default: true.", default: true }),
395
+ ),
396
+ format: Type.Optional(StringEnum(["xml", "json", "md"] as const, { description: "Output format. Default: xml." })),
397
+ }),
398
+ async execute(_id, params, _signal, _onUpdate, ctx) {
399
+ const args = ["comments"];
400
+ if (params.open) args.push("--open");
401
+ if (params.format) args.push("--format", params.format);
402
+ const result = await runDiffing(args, ctx.cwd);
403
+ return textResult(describe(result, args.join(" ")), {
404
+ exitCode: result.exitCode,
405
+ stderr: result.stderr.trim() || undefined,
406
+ command: `diffing ${args.join(" ")}`,
407
+ });
408
+ },
409
+ });
410
+
411
+ pi.registerTool({
412
+ name: "diffing_reply",
413
+ label: "Diffing Reply",
414
+ description: "Append a reply to an existing comment thread by id. Answers questions; does not resolve.",
415
+ parameters: Type.Object({
416
+ commentId: Type.String({ description: "UUID of the comment thread to reply to." }),
417
+ body: Type.String({ description: "Markdown reply body." }),
418
+ model: Type.Optional(Type.String({ description: "Model name to attribute. Defaults to the active model." })),
419
+ }),
420
+ async execute(_id, params, _signal, _onUpdate, ctx) {
421
+ const result = await runDiffing(["reply", params.commentId, "-", "--model", modelName(ctx, params.model)], ctx.cwd, {
422
+ stdin: params.body,
423
+ });
424
+ return textResult(describe(result, `diffing reply ${params.commentId}`), {
425
+ exitCode: result.exitCode,
426
+ stderr: result.stderr.trim() || undefined,
427
+ });
428
+ },
429
+ });
430
+
431
+ pi.registerTool({
432
+ name: "diffing_resolve",
433
+ label: "Diffing Resolve",
434
+ description: "Mark a review comment thread resolved. Use after addressing a change request.",
435
+ parameters: Type.Object({ commentId: Type.String({ description: "UUID of the comment thread." }) }),
436
+ async execute(_id, params, _signal, _onUpdate, ctx) {
437
+ const result = await runDiffing(["resolve", params.commentId], ctx.cwd);
438
+ return textResult(describe(result, `diffing resolve ${params.commentId}`), {
439
+ exitCode: result.exitCode,
440
+ stderr: result.stderr.trim() || undefined,
441
+ });
442
+ },
443
+ });
444
+
445
+ pi.registerTool({
446
+ name: "diffing_unresolve",
447
+ label: "Diffing Unresolve",
448
+ description: "Re-open a previously resolved comment thread.",
449
+ parameters: Type.Object({ commentId: Type.String({ description: "UUID of the comment thread." }) }),
450
+ async execute(_id, params, _signal, _onUpdate, ctx) {
451
+ const result = await runDiffing(["unresolve", params.commentId], ctx.cwd);
452
+ return textResult(describe(result, `diffing unresolve ${params.commentId}`), {
453
+ exitCode: result.exitCode,
454
+ stderr: result.stderr.trim() || undefined,
455
+ });
456
+ },
457
+ });
458
+
459
+ pi.registerTool({
460
+ name: "diffing_progress",
461
+ label: "Diffing Progress",
462
+ description:
463
+ "Post a live progress toast to the review UI so the human sees status while work is underway. Safe to call frequently.",
464
+ parameters: Type.Object({
465
+ message: Type.String({ description: "Progress message, e.g. 'Addressing L42…'." }),
466
+ pct: Type.Optional(Type.Number({ description: "Optional 0-100 completion percent." })),
467
+ model: Type.Optional(Type.String({ description: "Model name to attribute. Defaults to the active model." })),
468
+ }),
469
+ async execute(_id, params, _signal, _onUpdate, ctx) {
470
+ const args = ["progress", "--message", params.message, "--model", modelName(ctx, params.model)];
471
+ if (params.pct !== undefined) args.push("--pct", String(params.pct));
472
+ const result = await runDiffing(args, ctx.cwd);
473
+ return textResult(describe(result, `diffing progress --message "${params.message}"`), {
474
+ exitCode: result.exitCode,
475
+ stderr: result.stderr.trim() || undefined,
476
+ });
477
+ },
478
+ });
479
+
480
+ pi.registerTool({
481
+ name: "diffing_await_review",
482
+ label: "Diffing Await Review",
483
+ description:
484
+ "Synchronously wait (long-poll) until the human releases the review ('Send to agent'), then return the comments XML. Exit code 2 (or a timeout) is an expected park signal — do not silent-loop; resume once the human says the review is ready.",
485
+ parameters: Type.Object({
486
+ timeout: Type.Optional(
487
+ Type.Number({ description: "Max seconds to block. Default: 60 (park beyond that).", default: 60 }),
488
+ ),
489
+ }),
490
+ async execute(_id, params, signal, _onUpdate, ctx) {
491
+ const timeout = Math.min(Math.max(params.timeout ?? 60, 1), 600);
492
+ const result = await runDiffing(["await-review", "-t", String(timeout)], ctx.cwd, { signal });
493
+ const parked = result.exitCode === 2;
494
+ if (parked) {
495
+ return textResult(
496
+ `Timed out after ${timeout}s with no review release (park). Share the review URL and end your turn; run diffing_await_review again when the human says the review is ready.`,
497
+ { parked: true, exitCode: result.exitCode },
498
+ );
499
+ }
500
+ return textResult(describe(result, "diffing await-review"), {
501
+ parked: false,
502
+ exitCode: result.exitCode,
503
+ stderr: result.stderr.trim() || undefined,
504
+ });
505
+ },
506
+ });
507
+
508
+ pi.registerTool({
509
+ name: "diffing_plan_submit",
510
+ label: "Diffing Plan Submit",
511
+ description:
512
+ "Submit (or resubmit) a markdown plan for human review. Pass the plan as file (path) or body (inline text). Returns the plan id and review URL. Default handoff is async: share the URL, end the turn, act on the verdict when the human decides.",
513
+ parameters: Type.Object({
514
+ file: Type.Optional(
515
+ Type.String({ description: "Path to the plan markdown file. Use the body parameter instead to submit inline text." }),
516
+ ),
517
+ body: Type.Optional(Type.String({ description: "Inline plan markdown body." })),
518
+ title: Type.Optional(Type.String({ description: "Display title. Defaults to the plan's first heading." })),
519
+ planId: Type.Optional(
520
+ Type.String({ description: "Resubmit a revised body for an existing plan id (bumps version, resets verdict)." }),
521
+ ),
522
+ model: Type.Optional(Type.String({ description: "Authoring model. Defaults to the active model." })),
523
+ }),
524
+ async execute(_id, params, signal, _onUpdate, ctx) {
525
+ let stdin: string | undefined;
526
+ let fileArg = params.file;
527
+ if (!fileArg) {
528
+ if (!params.body) {
529
+ return textResult("Provide either file (path) or body (inline plan markdown).", { submitted: false });
530
+ }
531
+ fileArg = "-";
532
+ stdin = params.body;
533
+ }
534
+ const args = ["plan", "submit", fileArg, "--save-source", "--model", modelName(ctx, params.model)];
535
+ if (params.title) args.push("--title", params.title);
536
+ if (params.planId) args.push("--id", params.planId);
537
+ const result = await runDiffing(args, ctx.cwd, { signal, stdin });
538
+ const stdout = result.stdout.trim();
539
+ const stderr = result.stderr.trim();
540
+ if (result.exitCode !== 0) {
541
+ return textResult(describe(result, "diffing plan submit"), {
542
+ submitted: false,
543
+ exitCode: result.exitCode,
544
+ stderr,
545
+ });
546
+ }
547
+ return textResult(`${stdout}\n${stderr}`, {
548
+ submitted: true,
549
+ planId: stdout || undefined,
550
+ url: stderr || undefined,
551
+ });
552
+ },
553
+ });
554
+
555
+ pi.registerTool({
556
+ name: "diffing_plan_await",
557
+ label: "Diffing Plan Await",
558
+ description:
559
+ "Synchronously wait until the human decides on the submitted plan, then return the <plan-review> XML with the verdict. Exit code 2 is an expected park signal — do not silent-loop.",
560
+ parameters: Type.Object({
561
+ timeout: Type.Optional(
562
+ Type.Number({ description: "Max seconds to block. Default: 60 (park beyond that).", default: 60 }),
563
+ ),
564
+ }),
565
+ async execute(_id, params, signal, _onUpdate, ctx) {
566
+ const timeout = Math.min(Math.max(params.timeout ?? 60, 1), 600);
567
+ const result = await runDiffing(["plan", "await", "-t", String(timeout)], ctx.cwd, { signal });
568
+ if (result.exitCode === 2) {
569
+ return textResult(
570
+ `No plan verdict within ${timeout}s (park). Share the plan URL and end your turn; run diffing_plan_await again when the human says the verdict is ready.`,
571
+ { parked: true, exitCode: result.exitCode },
572
+ );
573
+ }
574
+ return textResult(describe(result, "diffing plan await"), {
575
+ parked: false,
576
+ exitCode: result.exitCode,
577
+ stderr: result.stderr.trim() || undefined,
578
+ });
579
+ },
580
+ });
581
+
582
+ pi.registerTool({
583
+ name: "diffing_plan_list",
584
+ label: "Diffing Plan List",
585
+ description: "List submitted plans for the current repo (id, decision, version, open comments, title).",
586
+ parameters: Type.Object({
587
+ json: Type.Optional(Type.Boolean({ description: "Emit raw JSON instead of the table." })),
588
+ }),
589
+ async execute(_id, params, _signal, _onUpdate, ctx) {
590
+ const args = ["plan", "list"];
591
+ if (params.json) args.push("--json");
592
+ const result = await runDiffing(args, ctx.cwd);
593
+ return textResult(describe(result, args.join(" ")), {
594
+ exitCode: result.exitCode,
595
+ stderr: result.stderr.trim() || undefined,
596
+ });
597
+ },
598
+ });
599
+
600
+ pi.registerTool({
601
+ name: "diffing_plan_show",
602
+ label: "Diffing Plan Show",
603
+ description: "Show a single plan as <plan-review> XML (or raw JSON) including the verdict and open comments.",
604
+ parameters: Type.Object({
605
+ planId: Type.Optional(Type.String({ description: "Plan id. Omit for the latest plan." })),
606
+ json: Type.Optional(Type.Boolean({ description: "Emit raw JSON instead of XML." })),
607
+ }),
608
+ async execute(_id, params, _signal, _onUpdate, ctx) {
609
+ const args = ["plan", "show"];
610
+ if (params.planId) args.push(params.planId);
611
+ if (params.json) args.push("--json");
612
+ const result = await runDiffing(args, ctx.cwd);
613
+ return textResult(describe(result, args.join(" ")), {
614
+ exitCode: result.exitCode,
615
+ stderr: result.stderr.trim() || undefined,
616
+ });
617
+ },
618
+ });
619
+
620
+ pi.registerTool({
621
+ name: "diffing_plan_reply",
622
+ label: "Diffing Plan Reply",
623
+ description: "Reply to an inline plan comment (the owning plan is resolved automatically).",
624
+ parameters: Type.Object({
625
+ commentId: Type.String({ description: "UUID of the plan comment." }),
626
+ body: Type.String({ description: "Markdown reply body." }),
627
+ model: Type.Optional(Type.String({ description: "Model name to attribute. Defaults to the active model." })),
628
+ }),
629
+ async execute(_id, params, _signal, _onUpdate, ctx) {
630
+ const result = await runDiffing(
631
+ ["plan", "reply", params.commentId, "-", "--model", modelName(ctx, params.model)],
632
+ ctx.cwd,
633
+ { stdin: params.body },
634
+ );
635
+ return textResult(describe(result, `diffing plan reply ${params.commentId}`), {
636
+ exitCode: result.exitCode,
637
+ stderr: result.stderr.trim() || undefined,
638
+ });
639
+ },
640
+ });
641
+
642
+ pi.registerTool({
643
+ name: "diffing_plan_resolve",
644
+ label: "Diffing Plan Resolve",
645
+ description: "Mark a plan comment resolved.",
646
+ parameters: Type.Object({ commentId: Type.String({ description: "UUID of the plan comment." }) }),
647
+ async execute(_id, params, _signal, _onUpdate, ctx) {
648
+ const result = await runDiffing(["plan", "resolve", params.commentId], ctx.cwd);
649
+ return textResult(describe(result, `diffing plan resolve ${params.commentId}`), {
650
+ exitCode: result.exitCode,
651
+ stderr: result.stderr.trim() || undefined,
652
+ });
653
+ },
654
+ });
655
+
656
+ pi.registerTool({
657
+ name: "diffing_url",
658
+ label: "Diffing URL",
659
+ description: "Print the base URL of the active diffing review server for this repo, or report that none is running.",
660
+ parameters: Type.Object({}),
661
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
662
+ const url = await diffingUrl(ctx.cwd);
663
+ return textResult(
664
+ url ? url : "No diffing server running for this repo. Start one with diffing_start_review or `/diffing`.",
665
+ { url },
666
+ );
667
+ },
668
+ });
669
+
670
+ pi.registerTool({
671
+ name: "diffing_sessions",
672
+ label: "Diffing Sessions",
673
+ description:
674
+ "List, select, or stop live diffing review sessions for this repo. use: retarget agent commands to a session id prefix. stop: graceful shutdown of a session.",
675
+ parameters: Type.Object({
676
+ action: Type.Optional(StringEnum(["list", "use", "stop"] as const, { description: "Action. Default: list." })),
677
+ value: Type.Optional(Type.String({ description: "Session id prefix (for use/stop), or 'active'/'all'." })),
678
+ json: Type.Optional(Type.Boolean({ description: "Emit JSON for list. Default: false." })),
679
+ }),
680
+ async execute(_id, params, _signal, _onUpdate, ctx) {
681
+ const action = params.action ?? "list";
682
+ const args = ["sessions"];
683
+ if (action !== "list") {
684
+ args.push(action, params.value ?? "active");
685
+ } else if (params.json) {
686
+ args.push("--json");
687
+ }
688
+ const result = await runDiffing(args, ctx.cwd);
689
+ await refreshStatus(ctx);
690
+ return textResult(describe(result, args.join(" ")), {
691
+ exitCode: result.exitCode,
692
+ stderr: result.stderr.trim() || undefined,
693
+ });
694
+ },
695
+ });
696
+
697
+ pi.registerTool({
698
+ name: "diffing_gh_overview",
699
+ label: "Diffing GitHub PR Overview",
700
+ description:
701
+ "Probe the active GitHub PR review session: PR identity, head/base SHAs, patch size, and conversation/draft counts. Fails cleanly when no PR session is active.",
702
+ parameters: Type.Object({
703
+ json: Type.Optional(Type.Boolean({ description: "Emit raw JSON instead of human-readable output." })),
704
+ }),
705
+ async execute(_id, params, _signal, _onUpdate, ctx) {
706
+ const args = ["gh", "overview"];
707
+ if (params.json) args.push("--json");
708
+ const result = await runDiffing(args, ctx.cwd);
709
+ return textResult(describe(result, args.join(" ")), {
710
+ exitCode: result.exitCode,
711
+ stderr: result.stderr.trim() || undefined,
712
+ });
713
+ },
714
+ });
715
+
716
+ pi.registerTool({
717
+ name: "diffing_cli",
718
+ label: "Diffing CLI",
719
+ description:
720
+ "Escape hatch: run any documented `diffing` agent subcommand with raw arguments. Examples: ['inspect','summary'], ['comment','edit',id,'--body','...'], ['gh','threads','--unresolved'], ['gh','reviews'], ['plan','versions',id], ['doctor'], ['mode']. Returns stdout + exit code; exit 3 means no server.",
721
+ parameters: Type.Object({
722
+ args: Type.Array(Type.String({ description: "Subcommand and arguments, e.g. ['inspect','summary']." })),
723
+ stdin: Type.Optional(Type.String({ description: "Optional text to pipe to the command's stdin (e.g. reply bodies)." })),
724
+ }),
725
+ async execute(_id, params, signal, _onUpdate, ctx) {
726
+ const args = params.args;
727
+ if (!Array.isArray(args) || args.length === 0) {
728
+ return textResult("Provide at least one subcommand argument.", { ran: false });
729
+ }
730
+ const result = await runDiffing(args, ctx.cwd, { signal, stdin: params.stdin });
731
+ return textResult(describe(result, `diffing ${args.join(" ")}`), {
732
+ exitCode: result.exitCode,
733
+ stderr: result.stderr.trim() || undefined,
734
+ });
735
+ },
736
+ });
737
+
738
+ // ── command ──────────────────────────────────────────────────────────────
739
+
740
+ pi.registerCommand("diffing", {
741
+ description:
742
+ "Open (or reuse) the diffing review UI for the current repo's working tree and print the review URL. Optional arg passthrough, e.g. `/diffing --staged` or `/diffing main..feature`.",
743
+ handler: async (args, ctx) => {
744
+ const passthrough = (args ?? "").trim().split(/\s+/).filter(Boolean);
745
+ try {
746
+ const { url } = await ensureReviewUrl(ctx.cwd, passthrough, true);
747
+ await refreshStatus(ctx);
748
+ ctx.ui.notify(`diffing review: ${url}`, "info");
749
+ } catch (error) {
750
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
751
+ }
752
+ },
753
+ });
754
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffing",
3
- "version": "0.14.1",
3
+ "version": "0.16.0",
4
4
  "description": "local-first CLI for reviewing, navigating, and discussing git diffs with AI",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,15 +20,22 @@
20
20
  "git",
21
21
  "diff",
22
22
  "code-review",
23
- "cli"
23
+ "cli",
24
+ "pi-package"
24
25
  ],
25
26
  "bin": {
26
27
  "diffing": "./dist/cli.mjs"
27
28
  },
28
29
  "files": [
29
30
  "dist",
30
- "scripts/postinstall.mjs"
31
+ "scripts/postinstall.mjs",
32
+ "extensions/pi"
31
33
  ],
34
+ "pi": {
35
+ "extensions": [
36
+ "./extensions/pi"
37
+ ]
38
+ },
32
39
  "scripts": {
33
40
  "build": "pnpm run build:ts && pnpm run build:tui && pnpm run bundle:tui",
34
41
  "build:ts": "tsdown && vite build",