@mgiles/perk 2.3.0 → 3.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 (145) hide show
  1. package/README.md +1 -1
  2. package/extension/adapters/planAdapterPlannotator.ts +135 -91
  3. package/extension/adapters/planAdapterTombell.ts +5 -3
  4. package/extension/doors/address.ts +235 -37
  5. package/extension/doors/annotationPush.ts +924 -0
  6. package/extension/doors/auditWaveTools.ts +352 -0
  7. package/extension/doors/ciExecutor.ts +220 -55
  8. package/extension/doors/commitCompact.ts +98 -10
  9. package/extension/doors/draftReviewWaveTools.ts +412 -0
  10. package/extension/doors/dreamWaveTools.ts +475 -0
  11. package/extension/doors/harvestWaveTools.ts +279 -0
  12. package/extension/doors/land.ts +1 -0
  13. package/extension/doors/learn.ts +24 -6
  14. package/extension/doors/learnFactory.ts +33 -7
  15. package/extension/doors/lifecycleGates.ts +1 -1
  16. package/extension/doors/objectiveReviewBrowser.ts +537 -0
  17. package/extension/doors/objectiveStack.ts +1143 -0
  18. package/extension/doors/planReviewBrowser.ts +475 -0
  19. package/extension/doors/plannotatorHandoff.ts +120 -49
  20. package/extension/doors/prReview.ts +180 -63
  21. package/extension/doors/prReviewBrowser.ts +31 -23
  22. package/extension/doors/prReviewDynamic.ts +57 -30
  23. package/extension/doors/prReviewTerminal.ts +6 -11
  24. package/extension/doors/reviewWaveTools.ts +397 -0
  25. package/extension/doors/selfcheck.ts +1 -1
  26. package/extension/doors/submit.ts +82 -7
  27. package/extension/factories/gistAuthor.ts +4 -4
  28. package/extension/factories/gistDraft.ts +1 -1
  29. package/extension/factories/gistSave.ts +2 -2
  30. package/extension/factories/objective.ts +7 -7
  31. package/extension/factories/objectiveAuthor.ts +5 -4
  32. package/extension/factories/objectiveDraft.ts +153 -30
  33. package/extension/factories/objectiveDreamReport.ts +347 -0
  34. package/extension/factories/objectivePlan.ts +163 -9
  35. package/extension/factories/objectiveSave.ts +90 -2
  36. package/extension/factories/planMode.ts +5 -3
  37. package/extension/factories/planReview.ts +304 -46
  38. package/extension/factories/planTitle.ts +27 -9
  39. package/extension/hunkFeedback/inbox.ts +389 -0
  40. package/extension/hunkFeedback/perkFeedback.ts +364 -0
  41. package/extension/hunkFeedback/receiver.ts +296 -0
  42. package/extension/hunkFeedback/store.ts +533 -0
  43. package/extension/index.ts +169 -57
  44. package/extension/substrate/agentScratch.ts +171 -0
  45. package/extension/substrate/bindingDelivery.ts +9 -11
  46. package/extension/substrate/cache.ts +166 -23
  47. package/extension/substrate/coldDoor.ts +2 -3
  48. package/extension/substrate/command.ts +9 -6
  49. package/extension/substrate/config.ts +41 -12
  50. package/extension/substrate/git.ts +95 -2
  51. package/extension/substrate/providers.ts +4 -33
  52. package/extension/substrate/resources.ts +11 -0
  53. package/extension/substrate/result.ts +16 -7
  54. package/extension/substrate/sessionData.ts +8 -6
  55. package/extension/substrate/sessionPointers.ts +4 -5
  56. package/extension/substrate/structuredOutput.ts +78 -19
  57. package/extension/substrate/toolGating.ts +179 -23
  58. package/extension/substrate/workflowState.ts +50 -2
  59. package/extension/surfaces/footerProvider.ts +6 -6
  60. package/extension/surfaces/report.ts +38 -12
  61. package/extension/surfaces/surfaces.ts +213 -275
  62. package/extension/vendor/btw/btw.ts +94 -17
  63. package/extension/waves/adversarialReviewWave.ts +181 -0
  64. package/extension/waves/auditWave.ts +312 -0
  65. package/extension/waves/draftReviewWave.ts +205 -0
  66. package/extension/waves/dreamReducerWave.ts +700 -0
  67. package/extension/waves/dreamReport.ts +1494 -0
  68. package/extension/waves/dreamWave.ts +927 -0
  69. package/extension/waves/harvestWave.ts +399 -0
  70. package/extension/waves/memoryAdapter.ts +14 -1
  71. package/extension/waves/objectiveExplorerWave.ts +120 -0
  72. package/extension/waves/ponytail.ts +104 -0
  73. package/extension/waves/prReviewDynamicWave.ts +383 -72
  74. package/extension/waves/prReviewWave.ts +175 -25
  75. package/extension/waves/reportWave.ts +503 -107
  76. package/extension/waves/reviewClassifierWave.ts +109 -0
  77. package/extension/waves/rpcAdapter.ts +65 -2
  78. package/extension/worker/readOnlySession.ts +4 -5
  79. package/extension/worker/worker.ts +71 -93
  80. package/extension/workerMain.ts +4 -6
  81. package/package.json +18 -5
  82. package/prompts/README.md +19 -0
  83. package/prompts/_fixtures/live.yaml +115 -65
  84. package/prompts/commit-and-compact-continuation.md +13 -0
  85. package/prompts/common/resume-advisory.md +1 -0
  86. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  87. package/prompts/contexts/adapters/plannotator-objective.md +8 -3
  88. package/prompts/contexts/adapters/plannotator-plan.md +8 -3
  89. package/prompts/contexts/gist-authoring.md +6 -20
  90. package/prompts/contexts/objective-authoring.md +6 -18
  91. package/prompts/stages/address/action.md +3 -14
  92. package/prompts/stages/address/preview.md +2 -13
  93. package/prompts/stages/audit.md +18 -0
  94. package/prompts/stages/conflict-resolution.md +1 -1
  95. package/prompts/stages/gist-author/seed.md +4 -4
  96. package/prompts/stages/gist-save.md +1 -1
  97. package/prompts/stages/implement.md +3 -1
  98. package/prompts/stages/learn-code.md +1 -1
  99. package/prompts/stages/learn-docs.md +2 -2
  100. package/prompts/stages/learn-dream.md +10 -0
  101. package/prompts/stages/learn-harvest.md +15 -0
  102. package/prompts/stages/learn-orchestrate.md +1 -1
  103. package/prompts/stages/objective-author/adopt.md +5 -3
  104. package/prompts/stages/objective-author/file.md +5 -4
  105. package/prompts/stages/objective-author/seed.md +5 -4
  106. package/prompts/stages/objective-land.md +7 -0
  107. package/prompts/stages/objective-plan/guidance.md +2 -13
  108. package/prompts/stages/objective-plan/seed.md +5 -12
  109. package/prompts/stages/objective-recover.md +8 -0
  110. package/prompts/stages/objective-replan.md +4 -2
  111. package/prompts/stages/objective-review-browser.md +9 -0
  112. package/prompts/stages/objective-sync.md +6 -0
  113. package/prompts/stages/plan-from/adopt.md +2 -2
  114. package/prompts/stages/plan-from/file.md +2 -2
  115. package/prompts/stages/plan-review-browser.md +9 -0
  116. package/prompts/stages/pr-review-browser/active.md +6 -14
  117. package/prompts/stages/pr-review-browser/foreign.md +6 -14
  118. package/prompts/stages/pr-review-dynamic.md +6 -6
  119. package/prompts/stages/pr-review-terminal/active.md +7 -15
  120. package/prompts/stages/pr-review-terminal/foreign.md +7 -15
  121. package/prompts/stages/pr-review-terminal/local.md +1 -1
  122. package/prompts/stages/pr-review.md +5 -5
  123. package/prompts/stages/replan.md +3 -3
  124. package/shared/README.md +2 -2
  125. package/shared/bindings.yaml +15 -0
  126. package/shared/contracts-history.md +11 -0
  127. package/shared/contracts.md +5373 -281
  128. package/shared/providers.yaml +27 -60
  129. package/shared/registry.yaml +42 -18
  130. package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
  131. package/shared/schemas/outputs/objective-doctor.schema.json +487 -0
  132. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  133. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  134. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  135. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  136. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  137. package/shared/schemas/outputs/pr-land.schema.json +3 -3
  138. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  139. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  140. package/extension/checkpoints/checkpoints.ts +0 -550
  141. package/extension/checkpoints/planSteps.ts +0 -108
  142. package/extension/doors/askUser.ts +0 -198
  143. package/prompts/common/output-schemas/objective-explorer.md +0 -36
  144. package/prompts/common/output-schemas/review-classifier.md +0 -47
  145. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -0,0 +1,389 @@
1
+ // The hunk watch feedback inbox (contracts.md §8.58): the single-flight delivery machine that
2
+ // drains the worktree outbox into the one eligible implement session.
3
+ //
4
+ // idle → dispatching → awaiting-observation → acknowledged → idle
5
+ // └→ backoff → dispatching
6
+ //
7
+ // Exactly ONE unacknowledged batch exists between injection and acknowledgement; while it awaits
8
+ // observation no new batch is injected — triggers (watch events, poll ticks, overflow) only mark
9
+ // the inbox dirty. Acknowledgement requires transcript OBSERVATION (`transport.isInjected`), not
10
+ // call-return: Pi's `sendUserMessage` is a void wrapper over in-memory queues an abort discards.
11
+ // Backoff resets ONLY on observation, so persistent asynchronous rejection backs off
12
+ // exponentially instead of retrying every poll tick — and unconfirmed messages can never
13
+ // accumulate (one in flight, ever). Duplicates possible, silent loss not.
14
+ //
15
+ // Failure containment: every background callback is wrapped — nothing ever escapes into the
16
+ // host session. Watcher failure degrades permanently to poll-only (polling is the correctness
17
+ // path); a failed lease verification closes the inbox fail-closed (misdelivery is never the
18
+ // fallback). Diagnostics route through `deps.report`, never into the model conversation.
19
+ //
20
+ // All effects arrive through `deps` (clock, timers, watch factory, report sink) — deterministic
21
+ // under test, no hidden globals.
22
+
23
+ import {
24
+ hunkConsumerLockDir,
25
+ hunkDeliveredPath,
26
+ hunkOutboxPath,
27
+ hunkWatchDir,
28
+ } from "../substrate/cache.ts";
29
+ import { lsFiles } from "../substrate/git.ts";
30
+ import {
31
+ acquireLease,
32
+ appendAcks,
33
+ type DeliveryAck,
34
+ type FeedbackRecord,
35
+ HEARTBEAT_MS,
36
+ readDeliveredIds,
37
+ readOutbox,
38
+ releaseLease,
39
+ renewHeartbeat,
40
+ sweepQuarantine,
41
+ verifyLease,
42
+ } from "./store.ts";
43
+
44
+ // Implementation constants (§8.58) — code constants, deliberately not config.
45
+ export const DEBOUNCE_MS = 500;
46
+ export const POLL_MS = 10_000;
47
+ export const BATCH_MAX_RECORDS = 10;
48
+ export const BATCH_MAX_BYTES = 49_152;
49
+ export const BACKOFF_BASE_MS = 1_000;
50
+ export const BACKOFF_CAP_MS = 60_000;
51
+ /** An in-flight batch is only demotable once it has had ≥ one poll interval to land. */
52
+ export const IN_FLIGHT_MIN_AGE_MS = POLL_MS;
53
+
54
+ export interface ConsumerIdentity {
55
+ cwd: string;
56
+ runId: string;
57
+ piSessionId: string;
58
+ /** The worktree plan-ref's `pr_id` — records for any other plan are held, never delivered. */
59
+ planId: string;
60
+ }
61
+
62
+ export interface FeedbackTransport {
63
+ /** Render + sendUserMessage (idle vs steer). A synchronous throw = refusal (records stay pending). */
64
+ inject(batch: readonly FeedbackRecord[]): void;
65
+ /** True when the batch's injected message is observed as a persisted user-message entry on the branch. */
66
+ isInjected(batch: readonly FeedbackRecord[]): boolean;
67
+ isIdle(): boolean;
68
+ }
69
+
70
+ export interface FeedbackInboxHandle {
71
+ close(): void;
72
+ }
73
+
74
+ /** The lease was foreign and fresh — this session stays passive (and says so, once). */
75
+ export interface PassiveClaim {
76
+ passive: true;
77
+ reason: string;
78
+ }
79
+
80
+ export interface InboxTimers {
81
+ setTimeout(fn: () => void, ms: number): unknown;
82
+ clearTimeout(handle: unknown): void;
83
+ setInterval(fn: () => void, ms: number): unknown;
84
+ clearInterval(handle: unknown): void;
85
+ }
86
+
87
+ export interface InboxWatcher {
88
+ close(): void;
89
+ }
90
+
91
+ /** An `fs.watch` factory seam; construction may throw (degrades to poll-only). */
92
+ export type WatchFactory = (
93
+ dir: string,
94
+ onChange: () => void,
95
+ onError: (error: unknown) => void,
96
+ ) => InboxWatcher;
97
+
98
+ export interface InboxDeps {
99
+ now(): number;
100
+ timers: InboxTimers;
101
+ watch: WatchFactory;
102
+ report(severity: "info" | "warning" | "error", message: string): void;
103
+ }
104
+
105
+ export interface HunkFeedbackInbox {
106
+ open(
107
+ identity: ConsumerIdentity,
108
+ transport: FeedbackTransport,
109
+ ): FeedbackInboxHandle | PassiveClaim;
110
+ }
111
+
112
+ export function createHunkFeedbackInbox(deps: InboxDeps): HunkFeedbackInbox {
113
+ return {
114
+ open(identity, transport) {
115
+ const lockDir = hunkConsumerLockDir(identity.cwd);
116
+ const outboxPath = hunkOutboxPath(identity.cwd);
117
+ const deliveredPath = hunkDeliveredPath(identity.cwd);
118
+ const watchDir = hunkWatchDir(identity.cwd);
119
+ const { now, timers, report } = deps;
120
+
121
+ // Provenance fence (§8.58): the family is DISPOSABLE LOCAL state — a git-TRACKED entry
122
+ // under it means checkout-supplied bytes (a force-added outbox/symlink) are posing as
123
+ // live watch feedback. Refuse to open, loudly; nothing under the family is read.
124
+ const tracked = lsFiles(identity.cwd, watchDir);
125
+ if (tracked.length > 0) {
126
+ const reason =
127
+ "tracked file(s) under .perk/workflow/hunk-watch — repository-supplied feedback is " +
128
+ `refused (untrack them to re-enable the bridge): ${tracked.join(", ")}`;
129
+ report("error", reason);
130
+ return { passive: true, reason };
131
+ }
132
+
133
+ // Quarantine sweep first (leftovers from a crashed reclaimer are harmless but dirty),
134
+ // then the lease: a fresh foreign holder means this session never inspects the stream.
135
+ for (const warning of sweepQuarantine(lockDir)) report("warning", warning);
136
+ const lease = acquireLease(
137
+ lockDir,
138
+ {
139
+ runId: identity.runId,
140
+ piSessionId: identity.piSessionId,
141
+ },
142
+ now,
143
+ );
144
+ if (!lease.owned) return { passive: true, reason: lease.reason };
145
+ const token = lease.token;
146
+
147
+ // Accepted-but-unacknowledged suppression lives here too: an id whose ack append failed
148
+ // stays in this set for the rest of the session (may redeliver in a later one — §8.58).
149
+ const deliveredRead = readDeliveredIds(deliveredPath);
150
+ const delivered = deliveredRead.ids;
151
+
152
+ type Phase = "idle" | "awaiting" | "backoff";
153
+ let phase: Phase = "idle";
154
+ let dirty = false;
155
+ let closed = false;
156
+ let inFlight: { batch: readonly FeedbackRecord[]; injectedAt: number } | null = null;
157
+ /** The CURRENT backoff delay; 0 = no failures since the last observation (the only reset). */
158
+ let backoffMs = 0;
159
+ let backoffTimer: unknown = null;
160
+ let debounceTimer: unknown = null;
161
+ let pollHandle: unknown = null;
162
+ let heartbeatHandle: unknown = null;
163
+ let watcher: InboxWatcher | null = null;
164
+ const reportedOnce = new Set<string>();
165
+ const heldPlanIds = new Set<string>();
166
+
167
+ const reportOnce = (severity: "warning" | "error", message: string): void => {
168
+ if (reportedOnce.has(message)) return;
169
+ reportedOnce.add(message);
170
+ report(severity, message);
171
+ };
172
+
173
+ const close = (): void => {
174
+ if (closed) return;
175
+ closed = true;
176
+ if (debounceTimer !== null) timers.clearTimeout(debounceTimer);
177
+ if (backoffTimer !== null) timers.clearTimeout(backoffTimer);
178
+ if (pollHandle !== null) timers.clearInterval(pollHandle);
179
+ if (heartbeatHandle !== null) timers.clearInterval(heartbeatHandle);
180
+ try {
181
+ watcher?.close();
182
+ } catch {
183
+ // disposal is best-effort
184
+ }
185
+ watcher = null;
186
+ inFlight = null;
187
+ releaseLease(lockDir, token); // removes only on token match
188
+ };
189
+
190
+ /** Fail-closed shutdown: the lease is no longer provably ours — stop, loudly, once. */
191
+ const closeFailClosed = (): void => {
192
+ reportOnce(
193
+ "error",
194
+ "feedback lease verification failed — closing the hunk feedback inbox (records stay queued for the next eligible session)",
195
+ );
196
+ close();
197
+ };
198
+
199
+ const enterBackoff = (): void => {
200
+ // Never resets on dispatch: consecutive failures double toward the cap.
201
+ backoffMs = backoffMs === 0 ? BACKOFF_BASE_MS : Math.min(backoffMs * 2, BACKOFF_CAP_MS);
202
+ phase = "backoff";
203
+ dirty = true;
204
+ backoffTimer = timers.setTimeout(() => {
205
+ backoffTimer = null;
206
+ if (closed) return;
207
+ phase = "idle";
208
+ guard(dispatch);
209
+ }, backoffMs);
210
+ };
211
+
212
+ const acknowledge = (batch: readonly FeedbackRecord[]): void => {
213
+ const at = new Date(now()).toISOString();
214
+ const acks: DeliveryAck[] = batch.map((record) => ({
215
+ schema: 1,
216
+ feedback_id: record.feedback_id,
217
+ delivered_at: at,
218
+ run_id: identity.runId,
219
+ pi_session_id: identity.piSessionId,
220
+ }));
221
+ try {
222
+ appendAcks(identity.cwd, acks);
223
+ } catch (error) {
224
+ // The message IS on the transcript — suppress same-session redelivery in memory; a
225
+ // later session may redeliver (at-least-once, stated plainly).
226
+ report(
227
+ "warning",
228
+ `could not append feedback acknowledgements (${error}) — delivery stands; the records may redeliver in a later session`,
229
+ );
230
+ }
231
+ for (const record of batch) delivered.add(record.feedback_id);
232
+ backoffMs = 0; // the ONLY reset site: transcript observation
233
+ inFlight = null;
234
+ phase = "idle";
235
+ if (dirty) dispatch();
236
+ };
237
+
238
+ /** One dispatch pass — only ever entered from `idle`. */
239
+ const dispatch = (): void => {
240
+ if (closed || phase !== "idle") return;
241
+ dirty = false;
242
+ const read = readOutbox(outboxPath);
243
+ for (const warning of read.warnings) reportOnce("warning", warning);
244
+ const pending: FeedbackRecord[] = [];
245
+ for (const record of read.records) {
246
+ if (delivered.has(record.feedback_id)) continue;
247
+ if (record.plan_id !== identity.planId) {
248
+ if (!heldPlanIds.has(record.feedback_id)) {
249
+ heldPlanIds.add(record.feedback_id);
250
+ report(
251
+ "warning",
252
+ `holding feedback ${record.feedback_id} addressed to plan ${record.plan_id} — this session implements plan ${identity.planId}`,
253
+ );
254
+ }
255
+ continue; // held: never delivered, never acked
256
+ }
257
+ pending.push(record);
258
+ }
259
+ if (pending.length === 0) return;
260
+
261
+ // Bounded batch, append order retained; the remainder re-marks dirty.
262
+ const batch: FeedbackRecord[] = [];
263
+ let bytes = 0;
264
+ for (const record of pending) {
265
+ const size = Buffer.byteLength(record.body, "utf8");
266
+ if (
267
+ batch.length > 0 &&
268
+ (batch.length >= BATCH_MAX_RECORDS || bytes + size > BATCH_MAX_BYTES)
269
+ ) {
270
+ break;
271
+ }
272
+ batch.push(record);
273
+ bytes += size;
274
+ }
275
+ if (batch.length < pending.length) dirty = true;
276
+
277
+ // The delivery fence: verify the lease immediately before every injection.
278
+ if (!verifyLease(lockDir, token)) {
279
+ closeFailClosed();
280
+ return;
281
+ }
282
+ if (transport.isInjected(batch)) {
283
+ // A prior injection survives on this branch — acknowledge without re-injecting.
284
+ acknowledge(batch);
285
+ return;
286
+ }
287
+ try {
288
+ transport.inject(batch);
289
+ } catch (error) {
290
+ report("warning", `feedback injection refused synchronously (${error}) — backing off`);
291
+ enterBackoff();
292
+ return;
293
+ }
294
+ inFlight = { batch, injectedAt: now() };
295
+ phase = "awaiting";
296
+ };
297
+
298
+ /** Wrap a background callback: report, never throw into the host session. */
299
+ const guard = (fn: () => void): void => {
300
+ try {
301
+ fn();
302
+ } catch (error) {
303
+ reportOnce("error", `hunk feedback inbox error: ${error}`);
304
+ }
305
+ };
306
+
307
+ const pollTick = (): void => {
308
+ if (closed) return;
309
+ if (phase === "awaiting" && inFlight !== null) {
310
+ const flight = inFlight;
311
+ if (transport.isInjected(flight.batch)) {
312
+ acknowledge(flight.batch);
313
+ return;
314
+ }
315
+ if (transport.isIdle() && now() - flight.injectedAt >= IN_FLIGHT_MIN_AGE_MS) {
316
+ // The session went idle again without the message landing (an abort-discarded
317
+ // steer queue or a failed turn) — demote: the records return to pending (they are
318
+ // still in the outbox and not delivered) and backoff owns the next dispatch.
319
+ inFlight = null;
320
+ enterBackoff();
321
+ }
322
+ return;
323
+ }
324
+ if (phase === "idle") {
325
+ dispatch(); // polling is the correctness path — the watcher may be dead
326
+ return;
327
+ }
328
+ dirty = true; // backoff owns the next dispatch; triggers only mark dirty
329
+ };
330
+
331
+ const onWatchEvent = (): void => {
332
+ if (closed) return;
333
+ dirty = true;
334
+ if (debounceTimer !== null) timers.clearTimeout(debounceTimer);
335
+ debounceTimer = timers.setTimeout(() => {
336
+ debounceTimer = null;
337
+ if (closed) return;
338
+ if (phase === "idle") guard(dispatch);
339
+ }, DEBOUNCE_MS);
340
+ };
341
+
342
+ const heartbeatTick = (): void => {
343
+ if (closed) return;
344
+ try {
345
+ renewHeartbeat(lockDir, token, now);
346
+ } catch (error) {
347
+ // The pre-injection verifyLease remains the delivery fence — report, keep going.
348
+ reportOnce("warning", `feedback lease heartbeat failed: ${error}`);
349
+ }
350
+ };
351
+
352
+ for (const warning of deliveredRead.warnings) reportOnce("warning", warning);
353
+
354
+ // The immediate initial drain, then low-latency watch + poll fallback + heartbeat.
355
+ guard(dispatch);
356
+ if (closed) return { close }; // the drain closed fail-closed — install nothing
357
+ try {
358
+ watcher = deps.watch(
359
+ watchDir,
360
+ () => guard(onWatchEvent),
361
+ (error) => {
362
+ guard(() => {
363
+ reportOnce(
364
+ "warning",
365
+ `hunk feedback watcher failed (${error}) — continuing on the polling fallback`,
366
+ );
367
+ try {
368
+ watcher?.close();
369
+ } catch {
370
+ // best-effort
371
+ }
372
+ watcher = null;
373
+ });
374
+ },
375
+ );
376
+ } catch (error) {
377
+ reportOnce(
378
+ "warning",
379
+ `could not watch the feedback outbox (${error}) — continuing on the polling fallback`,
380
+ );
381
+ watcher = null;
382
+ }
383
+ pollHandle = timers.setInterval(() => guard(pollTick), POLL_MS);
384
+ heartbeatHandle = timers.setInterval(() => guard(heartbeatTick), HEARTBEAT_MS);
385
+
386
+ return { close };
387
+ },
388
+ };
389
+ }