pi-pair-programmer 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.
package/src/index.ts ADDED
@@ -0,0 +1,952 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFile, realpath, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type {
5
+ ExtensionAPI,
6
+ ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { Type } from "typebox";
9
+ import { z } from "zod";
10
+ import {
11
+ buildChangeEvidence,
12
+ captureBaseline,
13
+ type TaskBaseline,
14
+ } from "./change-evidence.js";
15
+ import { FindingAdmission } from "./finding-admission.js";
16
+ import { createPairLogger, type PairLogger } from "./logger.js";
17
+ import type { ModelCallObserver } from "./model-usage.js";
18
+ import {
19
+ SessionAccounting,
20
+ STATS_ENTRY,
21
+ type PairStats,
22
+ type ReviewOutcome,
23
+ } from "./pair-stats.js";
24
+ import { StatsView, statsLines } from "./stats-view.js";
25
+ import {
26
+ isInherited,
27
+ reviewFile,
28
+ ReviewTimeoutError,
29
+ type Host,
30
+ } from "./review-runner.js";
31
+ import { ReviewStore, type Finding } from "./review-store.js";
32
+ import {
33
+ DEFAULT_REVIEWERS,
34
+ loadReviewers,
35
+ matchingReviewers,
36
+ reviewerKey,
37
+ type ReviewerConfig,
38
+ } from "./reviewers.js";
39
+
40
+ const MAX_DELIVERY = 4;
41
+ const DECISION_TOOL = "pair_programmer_decide";
42
+ const WAKE_COALESCE_MS = 250;
43
+
44
+ const ToolPathSchema = z.string();
45
+ const ContinuingRunSchema = z.object({ willContinue: z.literal(true) });
46
+
47
+ const DecisionParameters = Type.Object({
48
+ findingId: Type.String({ minLength: 1 }),
49
+ decision: Type.Union([Type.Literal("accept"), Type.Literal("reject")]),
50
+ reason: Type.String({ minLength: 1 }),
51
+ });
52
+
53
+ interface ReviewJob {
54
+ id: string;
55
+ stats: PairStats;
56
+ file: string;
57
+ fullPath: string;
58
+ revision: string;
59
+ source: string;
60
+ model: string;
61
+ reviewer: ReviewerConfig;
62
+ session: number;
63
+ version: number;
64
+ key: string;
65
+ host: Host;
66
+ cwd: string;
67
+ ctx: ExtensionContext;
68
+ }
69
+
70
+ function isFreshSession(
71
+ reason: string | undefined,
72
+ header: { parentSession?: string } | null | undefined,
73
+ entries: readonly { type: string }[] | undefined,
74
+ ): boolean {
75
+ if (
76
+ header === undefined ||
77
+ header === null ||
78
+ header.parentSession !== undefined
79
+ )
80
+ return false;
81
+ return (
82
+ [undefined, "startup", "new"].includes(reason) &&
83
+ entries?.every((entry) =>
84
+ [
85
+ "model_change",
86
+ "thinking_level_change",
87
+ "mode_change",
88
+ "service_tier_change",
89
+ ].includes(entry.type),
90
+ ) === true
91
+ );
92
+ }
93
+
94
+ function decisionInteraction(
95
+ toolName: string,
96
+ input: Record<string, unknown>,
97
+ ): boolean {
98
+ if (toolName === DECISION_TOOL) return true;
99
+ if (toolName !== "read" && toolName !== "write") return false;
100
+ const target = input["path"];
101
+ if (typeof target !== "string") return false;
102
+ const device = target.trim();
103
+ return (
104
+ device.slice(0, 5).toLowerCase() === "xd://" &&
105
+ device.slice(5) === DECISION_TOOL
106
+ );
107
+ }
108
+
109
+ function hostOf(ctx: ExtensionContext): Host {
110
+ return typeof ctx.modelRegistry.streamSimple === "function" ? "pi" : "omp";
111
+ }
112
+
113
+ function idle(ctx: ExtensionContext): boolean {
114
+ try {
115
+ return ctx.isIdle();
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ function revisionOf(source: string): string {
122
+ return createHash("sha256").update(source).digest("hex");
123
+ }
124
+
125
+ function describe(findings: readonly Finding[]): string {
126
+ const lines = findings.map(
127
+ (finding) =>
128
+ `- ${finding.id} ${finding.file}:${String(finding.line)} ${finding.title}\n ${finding.evidence}`,
129
+ );
130
+ return `Review findings:\n${lines.join("\n")}\nAccept or reject every finding with pair_programmer_decide(findingId, decision, reason) before using another tool. Give a concrete reason for each decision.`;
131
+ }
132
+
133
+ function acceptedReview(finding: Finding, reason: string): string {
134
+ return `### Accepted review: ${finding.title}\n\n**${finding.file}:${String(finding.line)}**\n\n${finding.evidence}\n\n**Reason:** ${reason}`;
135
+ }
136
+
137
+ export default function pairProgrammer(pi: ExtensionAPI): void {
138
+ const appendReviewEntry = (data: unknown): void => {
139
+ pi.appendEntry("pair-programmer", data);
140
+ };
141
+ let logger: PairLogger | undefined;
142
+ const logging = (async () => {
143
+ logger = await createPairLogger(pi);
144
+ })();
145
+ const statsView = new StatsView();
146
+ const accounting = new SessionAccounting();
147
+ let activeContext: ExtensionContext | undefined;
148
+ const cancelJobs = new Map<AbortController, () => void>();
149
+ let store = new ReviewStore(appendReviewEntry);
150
+ let admission = new FindingAdmission(store);
151
+ let reviewers: readonly ReviewerConfig[] = DEFAULT_REVIEWERS;
152
+ let root = process.cwd();
153
+ let baseline: TaskBaseline | undefined;
154
+ let baselineSession: string | undefined;
155
+ let generation = 0;
156
+ let unresolved = 0;
157
+ let nextVersion = 0;
158
+ const versions = new Map<string, number>();
159
+ const reviewed = new Map<string, string>();
160
+ const timers = new Map<string, NodeJS.Timeout>();
161
+ const presented = new Set<string>();
162
+ let wakeTimer: NodeJS.Timeout | undefined;
163
+ let wakePending = false;
164
+ const controllers = new Map<AbortController, string>();
165
+ let resetTimer: NodeJS.Timeout | undefined;
166
+ let checkReset: (() => void) | undefined;
167
+
168
+ function showState(ctx: ExtensionContext): void {
169
+ ctx.ui.setStatus(
170
+ "pair-programmer",
171
+ `Pair Programmer: ${store.enabled ? "on" : "off"}`,
172
+ );
173
+ }
174
+
175
+ function stopWatchingResets(): void {
176
+ clearInterval(resetTimer);
177
+ resetTimer = undefined;
178
+ checkReset = undefined;
179
+ }
180
+
181
+ function watchResets(ctx: ExtensionContext): void {
182
+ if (hostOf(ctx) !== "omp") return;
183
+ const manager = ctx.sessionManager;
184
+ let previousLeaf = manager.getLeafId();
185
+ checkReset = () => {
186
+ const leaf = manager.getLeafId();
187
+ let id = leaf;
188
+ let reset = false;
189
+ while (id !== null && id !== previousLeaf) {
190
+ const entry = manager.getEntry(id);
191
+ if (entry === undefined) break;
192
+ if ((entry.type as string) === "reset_boundary") {
193
+ reset = true;
194
+ break;
195
+ }
196
+ id = entry.parentId;
197
+ }
198
+ previousLeaf = leaf;
199
+ if (!reset) return;
200
+ stop("cleared");
201
+ store = new ReviewStore(appendReviewEntry, manager.getBranch());
202
+ admission = new FindingAdmission(store);
203
+ showState(ctx);
204
+ };
205
+ resetTimer = setInterval(checkReset, 100);
206
+ resetTimer.unref();
207
+ }
208
+
209
+ function stop(
210
+ reasonCode: "session_change" | "shutdown" | "disabled" | "cleared",
211
+ ): void {
212
+ logger?.log("session.stop", {
213
+ sessionId: accounting.stats.sessionId,
214
+ reasonCode,
215
+ });
216
+ statsView.close();
217
+ for (const cancel of cancelJobs.values()) cancel();
218
+ cancelJobs.clear();
219
+ accounting.retain(accounting.stats);
220
+ generation += 1;
221
+ unresolved = 0;
222
+ versions.clear();
223
+ reviewed.clear();
224
+ for (const timer of timers.values()) clearTimeout(timer);
225
+ timers.clear();
226
+ for (const controller of controllers.keys()) controller.abort();
227
+ controllers.clear();
228
+ clearTimeout(wakeTimer);
229
+ wakeTimer = undefined;
230
+ wakePending = false;
231
+ presented.clear();
232
+ }
233
+
234
+ function deliverable(): readonly Finding[] {
235
+ checkReset?.();
236
+ return !store.enabled || unresolved > 0 || store.outstanding().length > 0
237
+ ? []
238
+ : store
239
+ .ready()
240
+ .filter((finding) => !timers.has(finding.file))
241
+ .slice(0, MAX_DELIVERY);
242
+ }
243
+
244
+ function send(batch: readonly Finding[], waking: Host | undefined): void {
245
+ let options: Parameters<ExtensionAPI["sendMessage"]>[1] = {
246
+ deliverAs: "nextTurn",
247
+ triggerTurn: false,
248
+ };
249
+ if (waking === "pi") options = { triggerTurn: true };
250
+ else if (waking === "omp")
251
+ options = { deliverAs: "nextTurn", triggerTurn: true };
252
+ pi.sendMessage(
253
+ {
254
+ customType: "pair-programmer-findings",
255
+ content: describe(batch),
256
+ display: false,
257
+ },
258
+ options,
259
+ );
260
+ const ids = batch.map((finding) => finding.id);
261
+ store.deliver(ids);
262
+ logger?.log("delivery.sent", {
263
+ sessionId: accounting.stats.sessionId,
264
+ count: ids.length,
265
+ reasonCode: waking === undefined ? "passive" : "ready",
266
+ });
267
+ if (waking === undefined) return;
268
+ wakePending = true;
269
+ for (const id of ids) presented.add(id);
270
+ }
271
+
272
+ function deliver(): void {
273
+ const batch = deliverable();
274
+ if (batch.length > 0) send(batch, undefined);
275
+ }
276
+
277
+ function wake(ctx: ExtensionContext, ending: boolean): void {
278
+ checkReset?.();
279
+ if (!store.enabled || wakePending) {
280
+ logger?.log("delivery.wake", {
281
+ sessionId: accounting.stats.sessionId,
282
+ reasonCode: wakePending ? "wake_pending" : "disabled",
283
+ });
284
+ return;
285
+ }
286
+ if (!ending && !idle(ctx)) {
287
+ logger?.log("delivery.wake", {
288
+ sessionId: accounting.stats.sessionId,
289
+ reasonCode: "busy",
290
+ });
291
+ return;
292
+ }
293
+ const outstanding = store.outstanding();
294
+ if (outstanding.length === 0) {
295
+ const batch = deliverable();
296
+ logger?.log("delivery.wake", {
297
+ sessionId: accounting.stats.sessionId,
298
+ reasonCode: batch.length > 0 ? "ready" : "no_findings_ready",
299
+ });
300
+ if (batch.length > 0) send(batch, hostOf(ctx));
301
+ } else if (outstanding.some((finding) => !presented.has(finding.id))) {
302
+ logger?.log("delivery.wake", {
303
+ sessionId: accounting.stats.sessionId,
304
+ reasonCode: "outstanding",
305
+ });
306
+ send(outstanding, hostOf(ctx));
307
+ } else {
308
+ logger?.log("delivery.wake", {
309
+ sessionId: accounting.stats.sessionId,
310
+ reasonCode: "already_presented",
311
+ });
312
+ }
313
+ }
314
+
315
+ function scheduleWake(ctx: ExtensionContext): void {
316
+ wakeTimer ??= setTimeout(() => {
317
+ wakeTimer = undefined;
318
+ wake(ctx, false);
319
+ }, WAKE_COALESCE_MS);
320
+ }
321
+
322
+ function start(job: ReviewJob): void {
323
+ const controller = new AbortController();
324
+ const started = performance.now();
325
+ const fields = {
326
+ sessionId: job.stats.sessionId,
327
+ jobId: job.id,
328
+ reviewerId: reviewerKey(job.reviewer),
329
+ modelId: revisionOf(job.model),
330
+ };
331
+ const reviewState: { outcome: ReviewOutcome } = { outcome: "success" };
332
+ let finished = false;
333
+ const finish = (result: ReviewOutcome, settled = true): void => {
334
+ if (finished) return;
335
+ finished = true;
336
+ const durationMs = performance.now() - started;
337
+ job.stats.finish(job.id, result, durationMs, settled);
338
+ logger?.log("review.finished", {
339
+ ...fields,
340
+ outcome: result,
341
+ durationMs,
342
+ });
343
+ };
344
+ const onModelCall: ModelCallObserver = (observation) => {
345
+ job.stats.observe(job.id, observation);
346
+ logger?.log("model.finished", {
347
+ ...fields,
348
+ modelId: revisionOf(
349
+ JSON.stringify([
350
+ observation.provider,
351
+ observation.model,
352
+ observation.requestedModel,
353
+ ]),
354
+ ),
355
+ stage: observation.stage,
356
+ outcome: observation.outcome,
357
+ durationMs: observation.durationMs,
358
+ ...observation.usage,
359
+ });
360
+ if (observation.stage === "review")
361
+ reviewState.outcome = observation.outcome;
362
+ else if (
363
+ observation.outcome === "failed" ||
364
+ observation.outcome === "timeout"
365
+ ) {
366
+ logger?.log("finding.filtered", {
367
+ ...fields,
368
+ stage: observation.stage,
369
+ reasonCode:
370
+ observation.stage === "attribution"
371
+ ? "attribution_fallback"
372
+ : "dedup_fallback",
373
+ });
374
+ }
375
+ };
376
+ controllers.set(controller, job.key);
377
+ cancelJobs.set(controller, () => {
378
+ finish("cancelled", false);
379
+ });
380
+ job.stats.start(job.id);
381
+ logger?.log("review.started", fields);
382
+ void (async () => {
383
+ try {
384
+ const result = await runJob(job, controller.signal, onModelCall);
385
+ const settledOutcome =
386
+ reviewState.outcome === "success" ? result : reviewState.outcome;
387
+ finish(controller.signal.aborted ? "cancelled" : settledOutcome);
388
+ } catch (error) {
389
+ const failure =
390
+ error instanceof ReviewTimeoutError ? "timeout" : "failed";
391
+ finish(controller.signal.aborted ? "cancelled" : failure);
392
+ } finally {
393
+ job.stats.settle(job.id);
394
+ accounting.retain(job.stats);
395
+ controllers.delete(controller);
396
+ cancelJobs.delete(controller);
397
+ }
398
+ })();
399
+ }
400
+
401
+ function active(job: ReviewJob): boolean {
402
+ checkReset?.();
403
+ return (
404
+ store.enabled &&
405
+ job.session === generation &&
406
+ job.version === versions.get(job.key)
407
+ );
408
+ }
409
+
410
+ async function current(job: ReviewJob): Promise<boolean> {
411
+ if (!active(job)) return false;
412
+ try {
413
+ return (
414
+ (await realpath(job.fullPath)) === job.fullPath &&
415
+ revisionOf(await readFile(job.fullPath, "utf8")) === job.revision &&
416
+ active(job)
417
+ );
418
+ } catch {
419
+ return false;
420
+ }
421
+ }
422
+
423
+ async function runJob(
424
+ job: ReviewJob,
425
+ signal: AbortSignal,
426
+ onModelCall: ModelCallObserver,
427
+ ): Promise<ReviewOutcome> {
428
+ const proposed = await reviewFile({
429
+ host: job.host,
430
+ cwd: job.cwd,
431
+ model: job.model,
432
+ prompt: job.reviewer.prompt,
433
+ file: job.file,
434
+ source: job.source,
435
+ signal,
436
+ onModelCall,
437
+ });
438
+ if (!(await current(job))) {
439
+ logger?.log("review.skipped", {
440
+ sessionId: job.stats.sessionId,
441
+ jobId: job.id,
442
+ reasonCode: "stale",
443
+ });
444
+ return "obsolete";
445
+ }
446
+ const reviewer = reviewerKey(job.reviewer);
447
+ if (proposed.length === 0) {
448
+ reviewed.set(`${job.key}:${reviewer}:${job.model}`, job.revision);
449
+ return "success";
450
+ }
451
+ const judged = await Promise.all(
452
+ proposed.map(async (finding) => ({
453
+ finding,
454
+ inherited: await isInherited({
455
+ finding,
456
+ evidence: await buildChangeEvidence(
457
+ baseline,
458
+ job.file,
459
+ job.source,
460
+ finding.line,
461
+ finding.quote,
462
+ signal,
463
+ ),
464
+ signal,
465
+ onModelCall,
466
+ }),
467
+ })),
468
+ );
469
+ logger?.log("finding.filtered", {
470
+ sessionId: job.stats.sessionId,
471
+ jobId: job.id,
472
+ reasonCode: "inherited",
473
+ count: judged.filter(({ inherited }) => inherited).length,
474
+ });
475
+ if (signal.aborted || !(await current(job))) return "obsolete";
476
+ const result = await admission.admit({
477
+ file: job.file,
478
+ reviewer,
479
+ revision: job.revision,
480
+ findings: judged.flatMap(({ finding, inherited }) =>
481
+ inherited ? [] : [finding],
482
+ ),
483
+ signal,
484
+ isCurrent: () => current(job),
485
+ onModelCall,
486
+ });
487
+ logger?.log("finding.admission", {
488
+ sessionId: job.stats.sessionId,
489
+ jobId: job.id,
490
+ outcome: result,
491
+ });
492
+ if (result === "obsolete") return "obsolete";
493
+ if (result === "added") scheduleWake(job.ctx);
494
+ reviewed.set(`${job.key}:${reviewer}:${job.model}`, job.revision);
495
+ return "success";
496
+ }
497
+
498
+ function startReviews(
499
+ base: Omit<ReviewJob, "model" | "reviewer" | "id" | "stats">,
500
+ ctx: ExtensionContext,
501
+ ): void {
502
+ const jobs: ReviewJob[] = [];
503
+ const keys = new Set<string>();
504
+ for (const reviewer of matchingReviewers(base.file, reviewers)) {
505
+ let model = reviewer.model;
506
+ if (model === "current") {
507
+ if (ctx.model === undefined) {
508
+ logger?.log("review.skipped", {
509
+ sessionId: accounting.stats.sessionId,
510
+ reasonCode: "missing_model",
511
+ });
512
+ continue;
513
+ }
514
+ model = `${ctx.model.provider}/${ctx.model.id}`;
515
+ }
516
+ const key = `${base.file}:${reviewerKey(reviewer)}:${model}`;
517
+ if (reviewed.get(key) === base.revision || keys.has(key)) {
518
+ logger?.log("review.skipped", {
519
+ sessionId: accounting.stats.sessionId,
520
+ reasonCode: keys.has(key) ? "duplicate_config" : "unchanged",
521
+ });
522
+ continue;
523
+ }
524
+ keys.add(key);
525
+ const id = randomUUID();
526
+ logger?.log("review.scheduled", {
527
+ sessionId: accounting.stats.sessionId,
528
+ jobId: id,
529
+ });
530
+ jobs.push({ ...base, model, reviewer, id, stats: accounting.stats });
531
+ }
532
+ for (const job of jobs) start(job);
533
+ }
534
+
535
+ async function prepare(
536
+ fullPath: string,
537
+ file: string,
538
+ ctx: ExtensionContext,
539
+ version: number,
540
+ session: number,
541
+ ): Promise<void> {
542
+ checkReset?.();
543
+ try {
544
+ if (
545
+ (await realpath(fullPath)) !== fullPath ||
546
+ !(await stat(fullPath)).isFile()
547
+ ) {
548
+ if (
549
+ store.enabled &&
550
+ session === generation &&
551
+ version === versions.get(file)
552
+ ) {
553
+ store.discardStale(file, "missing");
554
+ }
555
+ return;
556
+ }
557
+ const source = await readFile(fullPath, "utf8");
558
+ if (
559
+ !store.enabled ||
560
+ session !== generation ||
561
+ version !== versions.get(file)
562
+ )
563
+ return;
564
+ const revision = revisionOf(source);
565
+ store.discardStale(file, revision);
566
+ const host = hostOf(ctx);
567
+ startReviews(
568
+ {
569
+ file,
570
+ fullPath,
571
+ revision,
572
+ source,
573
+ session,
574
+ version,
575
+ key: file,
576
+ host,
577
+ cwd: ctx.cwd,
578
+ ctx,
579
+ },
580
+ ctx,
581
+ );
582
+ } catch {
583
+ if (
584
+ store.enabled &&
585
+ session === generation &&
586
+ version === versions.get(file)
587
+ ) {
588
+ store.discardStale(file, "missing");
589
+ }
590
+ }
591
+ }
592
+
593
+ async function schedule(
594
+ filePath: string,
595
+ ctx: ExtensionContext,
596
+ version: number,
597
+ session: number,
598
+ ): Promise<void> {
599
+ const requested = path.resolve(ctx.cwd, filePath);
600
+ const lexical = path.relative(ctx.cwd, requested).split(path.sep).join("/");
601
+ if (
602
+ !lexical ||
603
+ lexical.startsWith("../") ||
604
+ lexical === ".." ||
605
+ path.isAbsolute(lexical)
606
+ )
607
+ return;
608
+ try {
609
+ const fullPath = await realpath(requested);
610
+ const file = path.relative(root, fullPath).split(path.sep).join("/");
611
+ if (
612
+ !file ||
613
+ file.startsWith("../") ||
614
+ file === ".." ||
615
+ path.isAbsolute(file)
616
+ ) {
617
+ if (store.enabled && session === generation)
618
+ store.discardStale(lexical, "missing");
619
+ return;
620
+ }
621
+ if (
622
+ !store.enabled ||
623
+ session !== generation ||
624
+ version < (versions.get(file) ?? 0)
625
+ )
626
+ return;
627
+ if (lexical !== file) store.discardStale(lexical, "missing");
628
+ versions.set(file, version);
629
+ for (const [controller, activeFile] of controllers) {
630
+ if (activeFile === file) {
631
+ cancelJobs.get(controller)?.();
632
+ controller.abort();
633
+ }
634
+ }
635
+ clearTimeout(timers.get(file));
636
+ timers.set(
637
+ file,
638
+ setTimeout(() => {
639
+ timers.delete(file);
640
+ void prepare(fullPath, file, ctx, version, session);
641
+ }, 120),
642
+ );
643
+ } catch {
644
+ if (store.enabled && session === generation)
645
+ store.discardStale(lexical, "missing");
646
+ }
647
+ }
648
+
649
+ function replaceBaseline(next: TaskBaseline | undefined): void {
650
+ const previous = baseline;
651
+ baseline = next;
652
+ void previous?.dispose();
653
+ }
654
+
655
+ async function assessBaseline(
656
+ event: { reason?: string },
657
+ ctx: ExtensionContext,
658
+ session: number,
659
+ ): Promise<void> {
660
+ const controller = new AbortController();
661
+ controllers.set(controller, "");
662
+ try {
663
+ const manager = ctx.sessionManager as Partial<
664
+ ExtensionContext["sessionManager"]
665
+ >;
666
+ const header = manager.getHeader?.();
667
+ if (event.reason === "fork" || header?.id !== baselineSession) {
668
+ replaceBaseline(undefined);
669
+ baselineSession = header?.id;
670
+ if (isFreshSession(event.reason, header, manager.getEntries?.())) {
671
+ const capture = await captureBaseline(root, controller.signal);
672
+ const captured =
673
+ capture.status === "available" ? capture.baseline : undefined;
674
+ if (session === generation) replaceBaseline(captured);
675
+ else void captured?.dispose();
676
+ }
677
+ }
678
+ } catch {
679
+ if (session === generation) replaceBaseline(undefined);
680
+ } finally {
681
+ controllers.delete(controller);
682
+ }
683
+ }
684
+
685
+ async function startSession(
686
+ event: { reason?: string },
687
+ ctx: ExtensionContext,
688
+ ): Promise<void> {
689
+ stopWatchingResets();
690
+ activeContext = ctx;
691
+ accounting.suspend();
692
+ stop("session_change");
693
+ const session = generation;
694
+ const resolvedRoot = await realpath(ctx.cwd);
695
+ if (session !== generation) return;
696
+ root = resolvedRoot;
697
+ const manager = ctx.sessionManager as Partial<
698
+ ExtensionContext["sessionManager"]
699
+ >;
700
+ accounting.activate(
701
+ manager,
702
+ (data) => {
703
+ pi.appendEntry(STATS_ENTRY, data);
704
+ },
705
+ () =>
706
+ logger?.log("extension.persistence_failed", {
707
+ reasonCode: "persistence_failed",
708
+ }),
709
+ );
710
+ store = new ReviewStore(appendReviewEntry, ctx.sessionManager.getBranch());
711
+ admission = new FindingAdmission(store);
712
+ showState(ctx);
713
+ watchResets(ctx);
714
+ await logging;
715
+ if (session !== generation) return;
716
+ logger?.log("session.start", { sessionId: accounting.stats.sessionId });
717
+ await assessBaseline(event, ctx, session);
718
+ if (session !== generation) return;
719
+ try {
720
+ const loaded = await loadReviewers(ctx.cwd);
721
+ if (session === generation) reviewers = loaded;
722
+ } catch (error) {
723
+ if (session !== generation) return;
724
+ reviewers = [];
725
+ logger?.log("review.skipped", {
726
+ sessionId: accounting.stats.sessionId,
727
+ reasonCode: "configuration_failed",
728
+ });
729
+ ctx.ui.notify(String(error), "warning");
730
+ }
731
+ }
732
+
733
+ pi.on("session_start", startSession);
734
+ const beforeSessionChange = (): void => {
735
+ stopWatchingResets();
736
+ stop("session_change");
737
+ };
738
+ pi.on("session_before_switch", beforeSessionChange);
739
+ pi.on("session_before_fork", beforeSessionChange);
740
+ pi.on("session_before_tree", beforeSessionChange);
741
+ const onOmpBeforeBranch = pi.on.bind(pi) as unknown as (
742
+ name: "session_before_branch",
743
+ handler: () => void,
744
+ ) => void;
745
+ onOmpBeforeBranch("session_before_branch", beforeSessionChange);
746
+ const onOmpSession = pi.on.bind(pi) as unknown as (
747
+ name: "session_switch" | "session_branch",
748
+ handler: (
749
+ event: { reason?: string },
750
+ ctx: ExtensionContext,
751
+ ) => Promise<void>,
752
+ ) => void;
753
+ onOmpSession("session_switch", startSession);
754
+ onOmpSession("session_branch", (_event, ctx) =>
755
+ startSession({ reason: "fork" }, ctx),
756
+ );
757
+
758
+ pi.on("session_tree", (_event, ctx) => startSession({ reason: "tree" }, ctx));
759
+
760
+ pi.on("session_shutdown", () => {
761
+ stopWatchingResets();
762
+ stop("shutdown");
763
+ activeContext?.ui.setStatus("pair-programmer", undefined);
764
+ activeContext = undefined;
765
+ accounting.suspend();
766
+ replaceBaseline(undefined);
767
+ baselineSession = undefined;
768
+ const sessionId = accounting.stats.sessionId;
769
+
770
+ void (async () => {
771
+ await logging;
772
+ logger?.log("extension.shutdown", { sessionId });
773
+ await logger?.close();
774
+ })();
775
+ });
776
+
777
+ pi.on("tool_result", (event, ctx) => {
778
+ checkReset?.();
779
+ if (
780
+ !store.enabled ||
781
+ event.isError ||
782
+ (event.toolName !== "write" && event.toolName !== "edit")
783
+ )
784
+ return;
785
+ const filePath = ToolPathSchema.safeParse(event.input["path"]);
786
+ if (!filePath.success) return;
787
+ const version = ++nextVersion;
788
+ const session = generation;
789
+ unresolved += 1;
790
+ void schedule(filePath.data, ctx, version, session)
791
+ .finally(() => {
792
+ if (session === generation) unresolved -= 1;
793
+ })
794
+ .catch(() => {
795
+ return;
796
+ });
797
+ });
798
+
799
+ pi.on("context", (event) => {
800
+ checkReset?.();
801
+ if (
802
+ event.messages.every(
803
+ (message) =>
804
+ message.role !== "custom" ||
805
+ message.customType !== "pair-programmer-findings",
806
+ )
807
+ )
808
+ return;
809
+ const outstanding = store.enabled ? store.outstanding() : [];
810
+ return {
811
+ messages: event.messages.flatMap((message) => {
812
+ if (
813
+ message.role !== "custom" ||
814
+ message.customType !== "pair-programmer-findings"
815
+ )
816
+ return [message];
817
+ const relevant = outstanding.filter(
818
+ (finding) =>
819
+ typeof message.content === "string" &&
820
+ message.content.includes(finding.id),
821
+ );
822
+ for (const finding of relevant) presented.add(finding.id);
823
+ return relevant.length === 0
824
+ ? []
825
+ : [{ ...message, content: describe(relevant) }];
826
+ }),
827
+ };
828
+ });
829
+
830
+ pi.on("turn_end", () => {
831
+ deliver();
832
+ });
833
+
834
+ pi.on("before_agent_start", () => {
835
+ deliver();
836
+ });
837
+
838
+ pi.on("agent_start", () => {
839
+ wakePending = false;
840
+ });
841
+
842
+ pi.on("agent_settled", (_event, ctx) => {
843
+ wake(ctx, false);
844
+ });
845
+
846
+ pi.on("agent_end", (event, ctx) => {
847
+ if (hostOf(ctx) === "omp" && !ContinuingRunSchema.safeParse(event).success)
848
+ wake(ctx, true);
849
+ });
850
+
851
+ pi.on("tool_call", (event) => {
852
+ checkReset?.();
853
+ if (!store.enabled || decisionInteraction(event.toolName, event.input))
854
+ return;
855
+ deliver();
856
+ const outstanding = store.outstanding();
857
+ if (outstanding.length === 0) return;
858
+ for (const finding of outstanding) presented.add(finding.id);
859
+ return { block: true, reason: describe(outstanding) };
860
+ });
861
+
862
+ pi.registerTool({
863
+ name: DECISION_TOOL,
864
+ label: "Decide review finding",
865
+ description:
866
+ "Accept or reject one delivered review finding and explain why before coding continues",
867
+ parameters: DecisionParameters,
868
+ execute(_toolCallId, params) {
869
+ checkReset?.();
870
+ if (!store.enabled) {
871
+ return Promise.resolve({
872
+ content: [{ type: "text" as const, text: "Pair Programmer is off." }],
873
+ details: { saved: false },
874
+ });
875
+ }
876
+ const finding =
877
+ params.decision === "accept"
878
+ ? store.deliveredFinding(params.findingId)
879
+ : undefined;
880
+ const saved = store.decide(
881
+ params.findingId,
882
+ params.decision,
883
+ params.reason,
884
+ );
885
+ logger?.log("finding.verdict", {
886
+ sessionId: accounting.stats.sessionId,
887
+ outcome: saved ? params.decision : "invalid",
888
+ });
889
+ if (saved && params.decision === "accept" && finding !== undefined) {
890
+ pi.sendMessage(
891
+ {
892
+ customType: "pair-programmer-accepted",
893
+ content: acceptedReview(finding, params.reason),
894
+ display: true,
895
+ },
896
+ { triggerTurn: false },
897
+ );
898
+ }
899
+ return Promise.resolve({
900
+ content: [
901
+ {
902
+ type: "text" as const,
903
+ text: saved
904
+ ? `Recorded ${params.decision} for ${params.findingId}.`
905
+ : "Unknown, already decided, or invalid finding. Decide each delivered finding with a reason.",
906
+ },
907
+ ],
908
+ details: { saved },
909
+ });
910
+ },
911
+ });
912
+
913
+ pi.registerCommand("pair-programmer", {
914
+ description: "Toggle background peer review on or off",
915
+ handler: (_args, ctx) => {
916
+ checkReset?.();
917
+ const enabled = !store.enabled;
918
+ if (!enabled) stop("disabled");
919
+ store.setEnabled(enabled);
920
+ showState(ctx);
921
+ logger?.log("extension.toggle", {
922
+ sessionId: accounting.stats.sessionId,
923
+ outcome: enabled ? "on" : "off",
924
+ });
925
+ ctx.ui.notify(`Pair Programmer ${enabled ? "on" : "off"}.`, "info");
926
+ return Promise.resolve();
927
+ },
928
+ });
929
+
930
+ pi.registerCommand("pair-clear", {
931
+ description: "Cancel reviews and clear all review findings",
932
+ handler: (_args, ctx) => {
933
+ checkReset?.();
934
+ stop("cleared");
935
+ store.clear();
936
+ ctx.ui.notify("Pair Programmer reviews cleared.", "info");
937
+ return Promise.resolve();
938
+ },
939
+ });
940
+
941
+ pi.registerCommand("pair-stats", {
942
+ description:
943
+ "Show on-demand Pair Programmer activity, findings and extension usage",
944
+ handler: (_args, ctx) => {
945
+ checkReset?.();
946
+ return statsView.open(
947
+ ctx,
948
+ statsLines(accounting.stats.snapshot(), store),
949
+ );
950
+ },
951
+ });
952
+ }