@fitchmultz/pi-subagents 0.52.4 → 0.52.5

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/CHANGELOG.md +11 -0
  2. package/README.md +5 -4
  3. package/dist/agent/completion-watches.js +61 -0
  4. package/dist/agent/contracts.js +9 -0
  5. package/dist/agent/host.js +5 -0
  6. package/dist/agent/index.js +20 -51
  7. package/dist/agent/legacy.js +41 -11
  8. package/dist/agent/notifications.js +2 -2
  9. package/dist/agent/parking.js +121 -13
  10. package/dist/agent/process.js +107 -63
  11. package/dist/agent/receipts.js +21 -4
  12. package/dist/agent/runner.js +19 -5
  13. package/dist/agent/shutdown.js +43 -0
  14. package/dist/agent/view.js +5 -1
  15. package/dist/build-info.js +1 -1
  16. package/dist/pi-intercom/broker/broker.js +7 -6
  17. package/dist/pi-intercom/broker/client-wake.d.ts +17 -0
  18. package/dist/pi-intercom/broker/client-wake.js +54 -0
  19. package/dist/pi-intercom/broker/client.d.ts +2 -0
  20. package/dist/pi-intercom/broker/client.js +23 -16
  21. package/dist/pi-intercom/broker/parked.js +122 -5
  22. package/dist/pi-intercom/broker/spawn.js +10 -6
  23. package/dist/pi-intercom/config.js +3 -2
  24. package/dist/pi-intercom/connection.js +72 -11
  25. package/dist/pi-intercom/inbound-delivery.js +10 -5
  26. package/dist/pi-intercom/index.js +41 -6
  27. package/dist/pi-intercom/outbound.js +24 -7
  28. package/dist/pi-intercom/reply-wait.js +6 -1
  29. package/dist/pi-intercom/tool-results.js +2 -0
  30. package/dist/pi-intercom/types.d.ts +6 -0
  31. package/dist/shared/background-reporting.js +72 -0
  32. package/dist/shared/parking.js +6 -0
  33. package/docs/development.md +2 -0
  34. package/docs/intercom.md +14 -0
  35. package/docs/performance.md +2 -2
  36. package/docs/reference.md +13 -5
  37. package/package.json +1 -1
  38. package/src/agent/completion-watches.ts +73 -0
  39. package/src/agent/contracts.ts +11 -0
  40. package/src/agent/host.ts +6 -0
  41. package/src/agent/index.ts +24 -54
  42. package/src/agent/legacy.ts +44 -12
  43. package/src/agent/notifications.ts +2 -3
  44. package/src/agent/parking.ts +148 -12
  45. package/src/agent/process.ts +130 -66
  46. package/src/agent/receipts.ts +25 -4
  47. package/src/agent/runner.ts +25 -5
  48. package/src/agent/shutdown.ts +50 -0
  49. package/src/agent/view.ts +9 -0
  50. package/src/pi-intercom/broker/broker.ts +8 -7
  51. package/src/pi-intercom/broker/client-wake.ts +61 -0
  52. package/src/pi-intercom/broker/client.ts +23 -17
  53. package/src/pi-intercom/broker/parked.ts +152 -10
  54. package/src/pi-intercom/broker/spawn.ts +10 -9
  55. package/src/pi-intercom/config.ts +5 -2
  56. package/src/pi-intercom/connection.ts +88 -12
  57. package/src/pi-intercom/inbound-delivery.ts +21 -6
  58. package/src/pi-intercom/index.ts +64 -21
  59. package/src/pi-intercom/outbound.ts +30 -6
  60. package/src/pi-intercom/reply-wait.ts +6 -1
  61. package/src/pi-intercom/tool-results.ts +1 -0
  62. package/src/pi-intercom/types.ts +4 -0
  63. package/src/shared/background-reporting.ts +78 -0
  64. package/src/shared/parking.ts +16 -0
@@ -7,6 +7,12 @@ export const PARKED_HISTORY = "pi-subagents:parked-history";
7
7
  export function isParkRequest(value) {
8
8
  return isRecord(value) && typeof value.park === "boolean" && typeof value.reply === "function";
9
9
  }
10
+ export function isWakeRequest(value) {
11
+ return (isRecord(value) &&
12
+ typeof value.peer === "string" &&
13
+ typeof value.reply === "function" &&
14
+ (value.signal === undefined || value.signal instanceof AbortSignal));
15
+ }
10
16
  export function isHistoryRequest(value) {
11
17
  return isRecord(value) && typeof value.peer === "string" && typeof value.reply === "function";
12
18
  }
@@ -1,5 +1,7 @@
1
1
  # Development
2
2
 
3
+ Node.js 24.21 or newer is required.
4
+
3
5
  The supported Intercom client's package export uses emitted declarations for consumers.
4
6
  The repository's native `development` type condition points at its TypeScript source so
5
7
  cold lint and type checks do not require a prior build. Packed-consumer checks compile
package/docs/intercom.md CHANGED
@@ -4,6 +4,13 @@
4
4
  child-only supervisor tool, peer overlay, topic registry, or separate extension
5
5
  to install. Ordinary and delegated sessions use the same messaging tool.
6
6
 
7
+ Background failures appear as native notifications when a UI is active and on
8
+ stderr in print or JSON mode, including during headless shutdown. In UI sessions,
9
+ failures during shutdown or after the UI context is unavailable are recorded in
10
+ `intercom/background-errors.log` under `PI_CODING_AGENT_DIR` (normally `~/.pi/agent`).
11
+ If that directory is not writable, the fallback cannot record them. Repeated
12
+ identical failures are reported once until the affected operation recovers.
13
+
7
14
  ## Discovery and groups
8
15
 
9
16
  ```js
@@ -139,6 +146,13 @@ active or sole pending ask when unambiguous; otherwise specify `replyTo` and the
139
146
  intended sender. Inspect `pending` for the exact correlation rather than guessing
140
147
  from text.
141
148
 
149
+ Messages accepted for a parked child remain queued until it returns. If its
150
+ owner reports a failed wake, a waiting ask returns the failure and says that the
151
+ accepted question may still arrive later. Check the child before resending to
152
+ avoid a duplicate question. A later active message requests another wake. Inspect
153
+ the child's run for its last recorded wake failure. With older brokers or
154
+ clients that cannot report wake outcomes, a waiting ask keeps its usual timeout.
155
+
142
156
  This ordinary request/reply mechanism does not recreate the retired durable
143
157
  supervisor approval/revival workflow. An expired wait is not an answered question
144
158
  or a completed delegated run.
@@ -144,7 +144,7 @@ switch request through an immediate successful `get_state` response changed from
144
144
  it now pays for cold receipt indexing. This is a change to when recovery work
145
145
  runs, not an installed macOS or general delegation-latency claim.
146
146
 
147
- The [issue 248 evidence](evidence/issue-248/README.md) includes both waits,
147
+ The [issue 248 evidence](evidence/issue-248/report.md) includes both waits,
148
148
  switch-to-first-tool measurements, ranges, profiler results, exact runtime and
149
149
  build identities, reproducible commands, and retained synthetic RPC transcripts.
150
150
 
@@ -167,7 +167,7 @@ after `session_start`, chiefly the intercom broker and owner attachment. In two
167
167
  official-host 40 s floor/`0b6da9b` pairs, the later windows of about 10 s each
168
168
  stayed within 1.1 ms of the paired floor. There is no fork long run, and this
169
169
  is not an indefinite-idle bound. The
170
- [issue 238 evidence](evidence/issue-238-idle/README.md) has the samples, host
170
+ [issue 238 evidence](evidence/issue-238-idle/report.md) has the samples, host
171
171
  hashes, native causal chain and limits.
172
172
 
173
173
  ### Idle parking shell records
package/docs/reference.md CHANGED
@@ -106,7 +106,12 @@ not wake it and report `publication_unavailable`. A
106
106
  normal message, ask or continuation restores the exact saved state into a new
107
107
  process, which then receives retained messages in order. If the final save
108
108
  fails, the child records `park-failed.json` and nothing restarts implicitly;
109
- retained messages stay queued and continuing reports the failure. Official Pi
109
+ retained messages stay queued and continuing reports the failure. A failed wake
110
+ is recorded as the run's `lastWakeFailure` in `agent_runs list` and `inspect`.
111
+ This is a historical failure, not a claim about the child's current state. A
112
+ later active message requests another wake. The saved working directory must be
113
+ available; no other directory is substituted. A pre-existing ambiguous actor
114
+ claim still requires inspection and is not removed automatically. Official Pi
110
115
  keeps idle children resident. Parent exit or session replacement stops its actors and verified owned
111
116
  native shells, allowing native abort a short grace period before escalation.
112
117
  Actors also watch for parent death, including a hard kill. Ordinary extension
@@ -227,10 +232,13 @@ New-runtime backlogs are sorted by completion time, then launch time and ID—no
227
232
  directory order. A later scan cannot reorder messages already admitted to Pi.
228
233
 
229
234
  Admission is still not publication or model consumption. Native physical receipts
230
- suppress redelivery across reload/resume, and dropped queues can recover. Receipt
231
- polling reuses unchanged verified journal metadata; changed files validate the
232
- committed prefix before parsing appended records. Replacement, truncation, changed
233
- prefixes, malformed records, and missing final newlines do not fabricate receipts.
235
+ suppress redelivery across reload/resume, and dropped queues can recover. A
236
+ `--no-session` parent has no session file, so Pi's in-memory transcript holds its
237
+ receipts for the life of that process; such a receipt never removes a retained
238
+ legacy result file. Receipt polling reuses unchanged verified
239
+ journal metadata; changed files validate the committed prefix before parsing
240
+ appended records. Replacement, truncation, changed prefixes, malformed records,
241
+ and missing final newlines do not fabricate receipts.
234
242
  Completion delivery appends context; it does not replace prior prompts, tools,
235
243
  model settings, or the active conversation.
236
244
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-subagents",
3
- "version": "0.52.4",
3
+ "version": "0.52.5",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,73 @@
1
+ import * as path from "node:path";
2
+ import type { BackgroundReporter } from "../shared/background-reporting.ts";
3
+ import { errorMessage } from "../shared/unknown.ts";
4
+ import { DirectoryWatch, type WatchState } from "./wake.ts";
5
+
6
+ /** Owns change hints for completion inputs and reports failures without losing polling. */
7
+ export class CompletionWatches {
8
+ private readonly watches = new Map<string, DirectoryWatch>();
9
+ private awaited: ReadonlySet<string> = new Set();
10
+ private readonly reporter: BackgroundReporter;
11
+ private readonly wake: () => void;
12
+ constructor(reporter: BackgroundReporter, wake: () => void) {
13
+ this.reporter = reporter;
14
+ this.wake = wake;
15
+ }
16
+
17
+ update(
18
+ base: string,
19
+ legacy: readonly string[],
20
+ awaited: ReadonlySet<string>,
21
+ ): "poll" | "rescan" | "hints" {
22
+ this.awaited = awaited;
23
+ const directories = [
24
+ ...new Set([base, ...legacy, ...[...awaited].map((file) => path.dirname(file))]),
25
+ ];
26
+ for (const [directory, watch] of this.watches) {
27
+ if (!directories.includes(directory)) {
28
+ watch.close();
29
+ this.watches.delete(directory);
30
+ this.reporter.recover(`watch:${directory}`);
31
+ }
32
+ }
33
+ const states = directories.map((directory): [string, WatchState] => [
34
+ directory,
35
+ this.ensure(directory, directory === base || legacy.includes(directory)),
36
+ ]);
37
+ if (states.some(([directory, state]) => state === "unwatched" && !legacy.includes(directory))) {
38
+ return "poll";
39
+ }
40
+ return states.some(([, state]) => state === "armed") ? "rescan" : "hints";
41
+ }
42
+
43
+ private ensure(directory: string, every: boolean): WatchState {
44
+ let watch = this.watches.get(directory);
45
+ if (watch === undefined) {
46
+ watch = new DirectoryWatch(
47
+ directory,
48
+ (file) => every || file === null || this.awaited.has(path.join(directory, file)),
49
+ this.wake,
50
+ );
51
+ this.watches.set(directory, watch);
52
+ }
53
+ const owner = `watch:${directory}`;
54
+ try {
55
+ const state = watch.ensure();
56
+ this.reporter.recover(owner);
57
+ return state;
58
+ } catch (error) {
59
+ watch.close();
60
+ this.reporter.report(owner, `Agent directory watch ${directory}: ${errorMessage(error)}`);
61
+ return "unwatched";
62
+ }
63
+ }
64
+
65
+ close(): void {
66
+ for (const [directory, watch] of this.watches) {
67
+ watch.close();
68
+ this.reporter.recover(`watch:${directory}`);
69
+ }
70
+ this.watches.clear();
71
+ this.awaited = new Set();
72
+ }
73
+ }
@@ -113,6 +113,17 @@ export const parkedSchema = Type.Object({
113
113
  leafId: Type.Union([Type.String(), Type.Null()]),
114
114
  });
115
115
  export type Parked = Static<typeof parkedSchema>;
116
+ export const wakeFailureSchema = Type.Object({
117
+ at: Type.Number(),
118
+ reason: Type.String(),
119
+ beforeOpen: Type.Optional(
120
+ Type.Object({
121
+ actorToken: Type.String(),
122
+ parkedToken: Type.String(),
123
+ }),
124
+ ),
125
+ });
126
+ export const readySchema = Type.Object({ token: Type.String() });
116
127
  export const exitSchema = Type.Object({
117
128
  pid: Type.Integer(),
118
129
  code: Type.Union([Type.Integer(), Type.Null()]),
package/src/agent/host.ts CHANGED
@@ -9,6 +9,12 @@ import { readIntercomLineage } from "../pi-intercom/identity.ts";
9
9
 
10
10
  export { errorMessage as errorText } from "../shared/unknown.ts";
11
11
 
12
+ /** A launcher may still own children while handing off or replacing a worker. */
13
+ export function retainsChildren(event: object): boolean {
14
+ const disposition: unknown = Reflect.get(event, "disposition");
15
+ return disposition === "handoff" || disposition === "provisional";
16
+ }
17
+
12
18
  /** Fork-only exact native working state; official Pi lacks it, so its children stay resident. */
13
19
  interface LocalWorkingState {
14
20
  readonly header: { readonly id: string };
@@ -15,9 +15,9 @@ import {
15
15
  parkFailure,
16
16
  requestStop,
17
17
  signalProcess,
18
- stopChildren,
19
18
  } from "./process.ts";
20
- import { isChild, errorText, rememberSDK } from "./host.ts";
19
+ import { stopChildren } from "./shutdown.ts";
20
+ import { isChild, errorText, rememberSDK, retainsChildren } from "./host.ts";
21
21
  import { serveParked } from "./parking.ts";
22
22
  import {
23
23
  resultSchema,
@@ -45,23 +45,20 @@ import {
45
45
  import { notifications, deliver, settle } from "./notifications.ts";
46
46
  import { prepareLaunch } from "./launch.ts";
47
47
  import { renderReceipt, renderInventory, renderInspection } from "./view.ts";
48
- import { legacyDirectory, legacyNotifications } from "./legacy.ts";
49
- import { DirectoryWatch, pathStamp, type WatchState } from "./wake.ts";
48
+ import { legacyDirectory, reconcileLegacy } from "./legacy.ts";
49
+ import { pathStamp } from "./wake.ts";
50
+ import { CompletionWatches } from "./completion-watches.ts";
50
51
  import { savedContinuation } from "./saved.ts";
51
52
  import { executionCwd } from "./cwd.ts";
52
53
  import { getPiAgentDir } from "../pi-intercom/agent-dir.ts";
53
54
  import { registerCompactMessages, renderCompactResult } from "../shared/compact-cards.ts";
55
+ import { BackgroundReporter } from "../shared/background-reporting.ts";
54
56
 
55
57
  /** Native fork lifetime owner (`ctx.managedHost`); read structurally so official Pi stays supported. */
56
58
  interface ManagedHost {
57
59
  readonly owner: { readonly pid: number };
58
60
  readonly ready: Promise<boolean>;
59
61
  }
60
- /** `handoff` and `provisional` mean another worker under the same launcher may still own these children. */
61
- function retainsChildren(event: object): boolean {
62
- const disposition: unknown = Reflect.get(event, "disposition");
63
- return disposition === "handoff" || disposition === "provisional";
64
- }
65
62
  function managedHost(context: ExtensionContext): ManagedHost | undefined {
66
63
  const host: unknown = Reflect.get(context, "managedHost");
67
64
  if (typeof host !== "object" || host === null) {
@@ -86,6 +83,7 @@ function legacyInput(): string[] {
86
83
  }
87
84
  class AgentRuntime {
88
85
  private ctx?: ExtensionContext;
86
+ private readonly reporter = new BackgroundReporter(() => this.ctx);
89
87
  // A managed launcher owns children across worker replacement; otherwise this process does.
90
88
  private host?: ManagedHost;
91
89
  private accepted = false;
@@ -96,7 +94,7 @@ class AgentRuntime {
96
94
  private timer?: NodeJS.Timeout;
97
95
  private woken?: NodeJS.Immediate;
98
96
  // Change hints for the owner directory, legacy results and every awaited canonical file.
99
- private readonly watches = new Map<string, DirectoryWatch>();
97
+ private readonly watches: CompletionWatches;
100
98
  private awaited: ReadonlySet<string> = new Set();
101
99
  // Input stamps the last reconciliation saw; a one-second probe recovers dropped hints.
102
100
  private observed = new Map<string, string>();
@@ -109,6 +107,7 @@ class AgentRuntime {
109
107
  private readonly pi: ExtensionAPI;
110
108
  constructor(pi: ExtensionAPI) {
111
109
  this.pi = pi;
110
+ this.watches = new CompletionWatches(this.reporter, this.wake);
112
111
  }
113
112
  private checkMain(): void {
114
113
  if (isChild()) {
@@ -143,19 +142,19 @@ class AgentRuntime {
143
142
  try {
144
143
  const { notices, awaited } = notifications(this.ctx, this.base, this.owner);
145
144
  this.awaited = awaited;
146
- try {
147
- notices.unshift(...legacyNotifications(this.ctx));
148
- } catch (error) {
149
- console.error(`Legacy completion reconciliation: ${errorText(error)}`);
150
- }
145
+ notices.unshift(...reconcileLegacy(this.ctx, this.reporter));
151
146
  deliver(this.pi, this.ctx, notices);
147
+ this.reporter.recover("completion-scan");
152
148
  } catch (error) {
153
149
  reconciled = false;
154
- console.error(`Agent completion reconciliation: ${errorText(error)}`);
150
+ this.reporter.report(
151
+ "completion-scan",
152
+ `Agent completion reconciliation: ${errorText(error)}`,
153
+ );
155
154
  }
156
155
  // Newly awaited files were absent when read, or are rechecked once by the probe.
157
156
  this.observed = new Map(inputs().map((file) => [file, before.get(file) ?? ""]));
158
- const next = this.watch();
157
+ const next = this.watches.update(this.base, legacyInput(), this.awaited);
159
158
  if (!reconciled || next === "poll") {
160
159
  this.timer = setTimeout(this.scan, 200).unref();
161
160
  } else if (next === "rescan") {
@@ -163,49 +162,17 @@ class AgentRuntime {
163
162
  this.wake();
164
163
  }
165
164
  };
166
- /** Watches every directory that can publish an awaited input; legacy results may never exist. */
167
- private watch(): "poll" | "rescan" | "hints" {
168
- const legacy = legacyInput();
169
- const directories = [
170
- ...new Set([this.base, ...legacy, ...[...this.awaited].map((file) => path.dirname(file))]),
171
- ];
172
- for (const [directory, watch] of this.watches) {
173
- if (!directories.includes(directory)) {
174
- watch.close();
175
- this.watches.delete(directory);
176
- }
177
- }
178
- const states = directories.map((directory): [string, WatchState] => {
179
- let watch = this.watches.get(directory);
180
- if (watch === undefined) {
181
- const every = directory === this.base || legacy.includes(directory);
182
- watch = new DirectoryWatch(
183
- directory,
184
- (file) => every || file === null || this.awaited.has(path.join(directory, file)),
185
- this.wake,
186
- );
187
- this.watches.set(directory, watch);
188
- }
189
- return [directory, watch.ensure()];
190
- });
191
- if (states.some(([directory, state]) => state === "unwatched" && !legacy.includes(directory))) {
192
- return "poll";
193
- }
194
- return states.some(([, state]) => state === "armed") ? "rescan" : "hints";
195
- }
196
165
  private unwatch(): void {
197
166
  clearTimeout(this.timer);
198
167
  clearInterval(this.probe);
199
168
  this.observed.clear();
200
169
  clearImmediate(this.woken);
201
170
  this.woken = undefined;
202
- for (const watch of this.watches.values()) {
203
- watch.close();
204
- }
205
- this.watches.clear();
171
+ this.watches.close();
206
172
  this.awaited = new Set();
207
173
  }
208
174
  private start(context: ExtensionContext): Promise<void> | undefined {
175
+ this.reporter.start();
209
176
  this.host = managedHost(context);
210
177
  if (this.host === undefined || this.accepted || this.child) {
211
178
  this.reset = this.reset.then(() => this.attach(context));
@@ -224,7 +191,7 @@ class AgentRuntime {
224
191
  }
225
192
  })
226
193
  .catch((error: unknown) => {
227
- console.error(`Agent runtime attachment: ${errorText(error)}`);
194
+ this.reporter.report(undefined, `Agent runtime attachment: ${errorText(error)}`);
228
195
  });
229
196
  return undefined;
230
197
  }
@@ -254,6 +221,7 @@ class AgentRuntime {
254
221
  this.probe = setInterval(this.recover, 1000).unref();
255
222
  }
256
223
  private async dispose(retain: boolean): Promise<void> {
224
+ this.reporter.shutdown();
257
225
  this.closing = true;
258
226
  this.unwatch();
259
227
  this.shutdown.abort();
@@ -267,7 +235,9 @@ class AgentRuntime {
267
235
  generation: attachment.generation,
268
236
  attached: false,
269
237
  });
270
- await stopChildren(this.base, this.owner);
238
+ await stopChildren(this.base, this.owner, (message) =>
239
+ this.reporter.report(undefined, message),
240
+ );
271
241
  }
272
242
  }
273
243
  this.ctx = undefined;
@@ -296,7 +266,7 @@ class AgentRuntime {
296
266
  signalProcess(target, "SIGKILL");
297
267
  });
298
268
  } catch (error) {
299
- console.error(`Agent stop escalation: ${errorText(error)}`);
269
+ this.reporter.report(undefined, `Agent stop escalation ${launch.id}: ${errorText(error)}`);
300
270
  }
301
271
  }, 3000);
302
272
  escalation.unref();
@@ -1,5 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { errorText } from "./host.ts";
4
+ import type { BackgroundReporter } from "../shared/background-reporting.ts";
3
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
6
  import { journalStamp } from "../shared/native-journal.ts";
5
7
  import { receiptId } from "./receipts.ts";
@@ -7,7 +9,6 @@ import { resolveTempRootDir } from "../shared/types.ts";
7
9
  import { isRecord } from "../shared/unknown.ts";
8
10
  import { release, isHeld, type Completion } from "./notifications.ts";
9
11
  import { missing } from "./storage.ts";
10
- import { errorText } from "./host.ts";
11
12
 
12
13
  interface LegacyInput {
13
14
  file: string;
@@ -123,18 +124,24 @@ function receipt(body: unknown, input: LegacyInput): boolean {
123
124
  }
124
125
  return body.type === "message" && legacyWait(body.message, input);
125
126
  }
126
- function published(ctx: ExtensionContext, input: LegacyInput): boolean {
127
- return (
128
- receiptId(ctx.sessionManager.getSessionFile(), input.owner, (body) => receipt(body, input)) !==
129
- undefined
130
- );
127
+ /** Where Pi recorded this input's receipt: its session file, or memory under `--no-session`. */
128
+ function published(ctx: ExtensionContext, input: LegacyInput): "file" | "memory" | undefined {
129
+ const file = ctx.sessionManager.getSessionFile();
130
+ const matches = (body: unknown) => receipt(body, input);
131
+ if (file === undefined) {
132
+ return ctx.sessionManager.getEntries().some(matches) ? "memory" : undefined;
133
+ }
134
+ return receiptId(file, input.owner, matches) === undefined ? undefined : "file";
131
135
  }
132
136
  function notification(ctx: ExtensionContext, input: LegacyInput): Completion | undefined {
133
137
  const identity = `legacy:${input.id}:${input.key}`;
134
- if (published(ctx, input)) {
138
+ const where = published(ctx, input);
139
+ if (where !== undefined) {
135
140
  release(input.owner, identity);
136
- // Retire only the unchanged recovery input whose actual native receipt exists.
141
+ // Retire only an unchanged recovery input whose receipt is LF-published in the session file.
142
+ // An in-memory receipt stops redelivery but never removes the saved input.
137
143
  if (
144
+ where === "file" &&
138
145
  journalStamp(fs.lstatSync(input.file, { bigint: true })) === input.stamp &&
139
146
  fs.readFileSync(input.file, "utf8") === input.raw
140
147
  ) {
@@ -161,12 +168,15 @@ function legacyNotice(
161
168
  ctx: ExtensionContext,
162
169
  file: string,
163
170
  scan: LegacyScan,
171
+ report: (file: string, error?: unknown) => void,
164
172
  ): Completion | undefined {
165
173
  try {
166
174
  const input = legacyInput(file, scan);
167
- return input ? notification(ctx, input) : undefined;
175
+ const result = input ? notification(ctx, input) : undefined;
176
+ report(file);
177
+ return result;
168
178
  } catch (error) {
169
- console.error(`Skipped legacy completion ${file}: ${errorText(error)}`);
179
+ report(file, error);
170
180
  return;
171
181
  }
172
182
  }
@@ -188,7 +198,18 @@ function startScan(ctx: ExtensionContext, directory: string): LegacyScan {
188
198
  export function legacyDirectory(): string {
189
199
  return path.join(resolveTempRootDir(), "async-subagent-results");
190
200
  }
191
- export function legacyNotifications(ctx: ExtensionContext): Completion[] {
201
+ export function legacyNotifications(
202
+ ctx: ExtensionContext,
203
+ reporter: BackgroundReporter,
204
+ ): Completion[] {
205
+ const report = (file: string, error?: unknown) => {
206
+ const owner = `legacy:${file}`;
207
+ if (error === undefined) {
208
+ reporter.recover(owner);
209
+ } else {
210
+ reporter.report(owner, `Skipped legacy completion ${file}: ${errorText(error)}`);
211
+ }
212
+ };
192
213
  const notices: Completion[] = [];
193
214
  const directory = legacyDirectory();
194
215
  const root = path.dirname(directory);
@@ -212,10 +233,21 @@ export function legacyNotifications(ctx: ExtensionContext): Completion[] {
212
233
  if (!name.endsWith(".json")) {
213
234
  continue;
214
235
  }
215
- const notice = legacyNotice(ctx, path.join(directory, name), scan);
236
+ const notice = legacyNotice(ctx, path.join(directory, name), scan, report);
216
237
  if (notice) {
217
238
  notices.push(notice);
218
239
  }
219
240
  }
220
241
  return notices;
221
242
  }
243
+
244
+ export function reconcileLegacy(ctx: ExtensionContext, reporter: BackgroundReporter): Completion[] {
245
+ try {
246
+ const notices = legacyNotifications(ctx, reporter);
247
+ reporter.recover("legacy-scan");
248
+ return notices;
249
+ } catch (error) {
250
+ reporter.report("legacy-scan", `Legacy completion reconciliation: ${errorText(error)}`);
251
+ return [];
252
+ }
253
+ }
@@ -54,7 +54,7 @@ export function settle(owner: string, pending: boolean): boolean {
54
54
  return released;
55
55
  }
56
56
  export function published(ctx: ExtensionContext, launch: Launch): string | undefined {
57
- return completionReceipts(ctx.sessionManager.getSessionFile(), launch.owner).get(launch.id);
57
+ return completionReceipts(ctx.sessionManager, launch.owner).get(launch.id);
58
58
  }
59
59
  export interface Completion {
60
60
  readonly owner: string;
@@ -239,8 +239,7 @@ export function notifications(ctx: ExtensionContext, base: string, owner: string
239
239
  const notices: Completion[] = [];
240
240
  const awaited = new Set<string>();
241
241
  const runs = completed(base, owner, awaited);
242
- const receipts =
243
- runs.length === 0 ? undefined : completionReceipts(ctx.sessionManager.getSessionFile(), owner);
242
+ const receipts = runs.length === 0 ? undefined : completionReceipts(ctx.sessionManager, owner);
244
243
  for (const run of runs) {
245
244
  const key = `${owner}/${run.id}`;
246
245
  if (receipts?.get(run.id) !== undefined) {