@fitchmultz/pi-subagents 0.46.1 → 0.46.2

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 (71) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/agents/agent-management.js +8 -2
  3. package/dist/agents/agents.js +8 -8
  4. package/dist/extension/build-info.js +1 -1
  5. package/dist/extension/index.js +11 -5
  6. package/dist/extension/result-snapshot.js +0 -18
  7. package/dist/history/ingest.js +40 -31
  8. package/dist/history/prune.js +1 -1
  9. package/dist/history/run-projection.js +1 -2
  10. package/dist/history/search-queries.js +8 -2
  11. package/dist/history/store.js +20 -4
  12. package/dist/history/text-chunks.js +1 -2
  13. package/dist/history/transcript-queries.js +12 -6
  14. package/dist/intercom/result-intercom.js +0 -74
  15. package/dist/pi-intercom/broker/broker.js +33 -16
  16. package/dist/pi-intercom/broker/framing.js +67 -17
  17. package/dist/pi-intercom/broker/snapshot-reader.js +29 -8
  18. package/dist/pi-intercom/broker/snapshots.js +144 -41
  19. package/dist/pi-intercom/session-targets.js +0 -1
  20. package/dist/runs/background/async-run-discovery.js +4 -6
  21. package/dist/runs/background/completion-dedupe.js +4 -55
  22. package/dist/runs/background/notify.js +1 -9
  23. package/dist/runs/background/result-watcher.js +1 -1
  24. package/dist/runs/background/runner-child-observer.js +12 -7
  25. package/dist/runs/background/runner-monitor.js +2 -0
  26. package/dist/runs/background/runner-parallel.js +2 -3
  27. package/dist/runs/shared/child-observation-spool.js +8 -3
  28. package/dist/runs/shared/run-restoration.js +4 -5
  29. package/dist/runs/shared/supervisor-questions.js +7 -4
  30. package/dist/shared/journal-frames.js +1 -1
  31. package/dist/slash/command-discovery.js +13 -12
  32. package/dist/tui/agent-history.js +0 -14
  33. package/dist/tui/agent-view.js +0 -1
  34. package/dist/tui/conversation-history.js +10 -1
  35. package/dist/tui/history-cards.js +8 -22
  36. package/docs/intercom.md +5 -1
  37. package/docs/performance.md +72 -3
  38. package/package.json +1 -1
  39. package/src/agents/agent-management.ts +8 -4
  40. package/src/agents/agents.ts +17 -12
  41. package/src/extension/index.ts +12 -5
  42. package/src/extension/result-snapshot.ts +1 -27
  43. package/src/history/ingest.ts +57 -49
  44. package/src/history/prune.ts +1 -1
  45. package/src/history/run-projection.ts +2 -4
  46. package/src/history/search-queries.ts +9 -2
  47. package/src/history/store.ts +21 -5
  48. package/src/history/text-chunks.ts +7 -2
  49. package/src/history/transcript-queries.ts +16 -10
  50. package/src/intercom/result-intercom.ts +0 -103
  51. package/src/pi-intercom/broker/broker.ts +34 -20
  52. package/src/pi-intercom/broker/framing.ts +71 -20
  53. package/src/pi-intercom/broker/snapshot-reader.ts +47 -9
  54. package/src/pi-intercom/broker/snapshots.ts +184 -51
  55. package/src/pi-intercom/session-targets.ts +0 -2
  56. package/src/runs/background/async-run-discovery.ts +4 -10
  57. package/src/runs/background/completion-dedupe.ts +6 -69
  58. package/src/runs/background/notify.ts +2 -12
  59. package/src/runs/background/result-watcher.ts +1 -1
  60. package/src/runs/background/runner-child-observer.ts +19 -11
  61. package/src/runs/background/runner-monitor.ts +2 -0
  62. package/src/runs/background/runner-parallel.ts +2 -3
  63. package/src/runs/shared/child-observation-spool.ts +8 -3
  64. package/src/runs/shared/run-restoration.ts +4 -4
  65. package/src/runs/shared/supervisor-questions.ts +10 -10
  66. package/src/shared/journal-frames.ts +1 -1
  67. package/src/slash/command-discovery.ts +13 -12
  68. package/src/tui/agent-history.ts +1 -18
  69. package/src/tui/agent-view.ts +0 -1
  70. package/src/tui/conversation-history.ts +15 -1
  71. package/src/tui/history-cards.ts +8 -26
@@ -1,5 +1,11 @@
1
1
  import { isRecord, isUnknownArray, type UnknownRecord } from "../../shared/unknown.ts";
2
- import { normalizeSessionInfo, type SessionInfo, type SessionSnapshot } from "../types.ts";
2
+ import {
3
+ normalizeSessionInfo,
4
+ type SessionInfo,
5
+ type SessionSnapshot,
6
+ type TopicSubscription,
7
+ type TopicUpdate,
8
+ } from "../types.ts";
3
9
 
4
10
  type Receipt = SessionSnapshot["receipts"][number];
5
11
 
@@ -46,9 +52,30 @@ function receipt(value: unknown): Receipt {
46
52
  };
47
53
  }
48
54
 
55
+ interface SessionParts {
56
+ readonly info: SessionInfo;
57
+ readonly topics: TopicUpdate[] | undefined;
58
+ readonly subscriptions: TopicSubscription[] | undefined;
59
+ }
60
+
61
+ function appendRows<T>(
62
+ previous: T[] | undefined,
63
+ rows: readonly T[] | undefined,
64
+ supplied: boolean,
65
+ ): T[] | undefined {
66
+ if (rows === undefined) {
67
+ return supplied ? undefined : previous;
68
+ }
69
+ const result = previous ?? [];
70
+ for (const row of rows) {
71
+ result.push(row);
72
+ }
73
+ return result;
74
+ }
75
+
49
76
  /** Multipart frames build a private cache; only a complete validated snapshot escapes. */
50
77
  export class SnapshotReader {
51
- private readonly sessions = new Map<string, SessionInfo>();
78
+ private readonly sessions = new Map<string, SessionParts>();
52
79
  private readonly receipts: Receipt[] = [];
53
80
 
54
81
  private addSession(row: unknown): void {
@@ -59,17 +86,20 @@ export class SnapshotReader {
59
86
  const subscriptions = optionalArray(row.subscriptions);
60
87
  const previous = this.sessions.get(row.id);
61
88
  const info = normalizeSessionInfo({
62
- ...previous,
89
+ ...previous?.info,
63
90
  ...row,
64
- ...(topics !== undefined ? { topics: [...(previous?.topics ?? []), ...topics] } : {}),
65
- ...(subscriptions !== undefined
66
- ? { subscriptions: [...(previous?.subscriptions ?? []), ...subscriptions] }
67
- : {}),
91
+ ...(topics !== undefined ? { topics } : {}),
92
+ ...(subscriptions !== undefined ? { subscriptions } : {}),
68
93
  });
69
94
  if (!info) {
70
95
  throw new Error("Invalid session snapshot");
71
96
  }
72
- this.sessions.set(info.id, info);
97
+ const { topics: newTopics, subscriptions: newSubscriptions, ...identity } = info;
98
+ this.sessions.set(info.id, {
99
+ info: identity,
100
+ topics: appendRows(previous?.topics, newTopics, "topics" in row),
101
+ subscriptions: appendRows(previous?.subscriptions, newSubscriptions, "subscriptions" in row),
102
+ });
73
103
  }
74
104
 
75
105
  append(sessions: readonly unknown[], receipts: unknown): void {
@@ -86,6 +116,14 @@ export class SnapshotReader {
86
116
  }
87
117
 
88
118
  finish(): SessionSnapshot {
89
- return { sessions: [...this.sessions.values()], receipts: this.receipts };
119
+ const sessions: SessionInfo[] = [];
120
+ for (const { info, topics, subscriptions } of this.sessions.values()) {
121
+ sessions.push({
122
+ ...info,
123
+ ...(topics !== undefined ? { topics } : {}),
124
+ ...(subscriptions !== undefined ? { subscriptions } : {}),
125
+ });
126
+ }
127
+ return { sessions, receipts: this.receipts };
90
128
  }
91
129
  }
@@ -10,7 +10,11 @@ import {
10
10
  type TopicSubscription,
11
11
  type SendResult,
12
12
  } from "../types.ts";
13
- import { validateIntercomMessageSize } from "./framing.ts";
13
+ import {
14
+ intercomMessageSizeBytes,
15
+ MAX_FRAME_SIZE_BYTES,
16
+ validateIntercomMessageSize,
17
+ } from "./framing.ts";
14
18
 
15
19
  /** Direct messages and old clients receive identity, not potentially large topic state. */
16
20
  export function messageSender(info: SessionInfo): SessionInfo {
@@ -124,72 +128,201 @@ function publicationUpdate(
124
128
  return { ...topic, revision };
125
129
  }
126
130
 
127
- class TopicSnapshot {
128
- private readonly info: SessionInfo;
129
- private readonly topics: Map<string, TopicUpdate>;
130
- private readonly subscriptions: Map<string, TopicSubscription>;
131
+ class SnapshotRecords<T extends { readonly topic: string }> {
132
+ private readonly records = new Map<string, { readonly value: T; readonly bytes: number }>();
133
+ private largest = 0;
134
+ private initial: readonly T[] | undefined;
135
+ private present: boolean;
131
136
 
132
- constructor(info: SessionInfo) {
133
- this.info = info;
134
- this.topics = new Map((info.topics ?? []).map((topic) => [topic.topic, topic]));
135
- this.subscriptions = new Map((info.subscriptions ?? []).map((entry) => [entry.topic, entry]));
137
+ constructor(values: readonly T[] | undefined) {
138
+ this.initial = values;
139
+ this.present = values !== undefined;
140
+ for (const value of values ?? []) {
141
+ this.set(value, intercomMessageSizeBytes(value));
142
+ }
136
143
  }
137
144
 
138
- apply(mutation: TopicMutation): TopicUpdate | undefined {
139
- switch (mutation.kind) {
140
- case "publication": {
141
- const previous = this.topics.get(mutation.topic.topic);
142
- const update = publicationUpdate(mutation.topic, previous, mutation.publish);
143
- if (mutation.publish || (previous?.revision ?? 0) < update.revision) {
144
- this.topics.set(update.topic, update);
145
- }
146
- if (mutation.publish) {
147
- return update;
148
- }
145
+ get(topic: string, first = false): T | undefined {
146
+ if (first && this.initial !== undefined) {
147
+ return this.initial.find((record) => record.topic === topic);
148
+ }
149
+ return this.records.get(topic)?.value;
150
+ }
151
+
152
+ maximumAfter(topic?: string, bytes = 0): number {
153
+ const previous = topic === undefined ? undefined : this.records.get(topic);
154
+ if (previous?.bytes !== this.largest || bytes >= this.largest) {
155
+ return Math.max(this.largest, bytes);
156
+ }
157
+ // ponytail: shrinking the largest record scans cached sizes; index sizes if churn warrants it.
158
+ let largest = bytes;
159
+ for (const [key, record] of this.records) {
160
+ if (key !== topic) {
161
+ largest = Math.max(largest, record.bytes);
162
+ }
163
+ }
164
+ return largest;
165
+ }
166
+
167
+ set(value: T, bytes: number): void {
168
+ this.largest = this.maximumAfter(value.topic, bytes);
169
+ this.records.set(value.topic, { value, bytes });
170
+ }
171
+
172
+ delete(topic: string): void {
173
+ this.largest = this.maximumAfter(topic);
174
+ this.records.delete(topic);
175
+ }
176
+
177
+ accept(): void {
178
+ this.initial = undefined;
179
+ this.present = true;
180
+ }
181
+
182
+ valuesAfter(topic?: string, value?: T, changed = false): readonly T[] | undefined {
183
+ if (!changed) {
184
+ if (this.initial !== undefined) {
185
+ return this.initial;
186
+ }
187
+ if (!this.present) {
149
188
  return;
150
189
  }
190
+ }
191
+ const values: T[] = [];
192
+ for (const [key, record] of this.records) {
193
+ const next = key === topic ? value : record.value;
194
+ if (next !== undefined) {
195
+ values.push(next);
196
+ }
197
+ }
198
+ if (value !== undefined && !this.records.has(value.topic)) {
199
+ values.push(value);
200
+ }
201
+ return values;
202
+ }
203
+ }
204
+
205
+ interface PreparedTopicChange {
206
+ readonly topic?: TopicUpdate;
207
+ readonly subscription?: TopicSubscription;
208
+ readonly unsubscribe?: string;
209
+ readonly publication?: TopicUpdate;
210
+ readonly restore: boolean;
211
+ readonly changed: boolean;
212
+ readonly bytes: number;
213
+ }
214
+
215
+ const UNCHANGED: PreparedTopicChange = { restore: false, changed: false, bytes: 0 };
216
+
217
+ /** Keep committed records indexed; restores validate only new bytes and cached envelope maxima. */
218
+ export class TopicSnapshot {
219
+ private readonly topics: SnapshotRecords<TopicUpdate>;
220
+ private readonly subscriptions: SnapshotRecords<TopicSubscription>;
221
+
222
+ constructor(info: SessionInfo) {
223
+ this.topics = new SnapshotRecords(info.topics);
224
+ this.subscriptions = new SnapshotRecords(info.subscriptions);
225
+ }
226
+
227
+ subscription(topic: string): TopicSubscription | undefined {
228
+ return this.subscriptions.get(topic, true);
229
+ }
230
+
231
+ prepare(change: unknown, supported: boolean): PreparedTopicChange {
232
+ if (change === undefined) {
233
+ return UNCHANGED;
234
+ }
235
+ if (!supported || !isRecord(change)) {
236
+ throw new Error("Topic snapshots unavailable for this client");
237
+ }
238
+ const mutation = topicMutation(change);
239
+ const base = { restore: change.action === "restore", changed: true, bytes: 0 };
240
+ switch (mutation.kind) {
241
+ case "publication":
242
+ return this.preparePublication(mutation, base);
151
243
  case "subscription":
152
- this.subscriptions.set(mutation.subscription.topic, mutation.subscription);
153
- return;
244
+ return {
245
+ ...base,
246
+ subscription: mutation.subscription,
247
+ bytes: intercomMessageSizeBytes(mutation.subscription),
248
+ };
154
249
  case "unsubscribe":
155
- this.subscriptions.delete(mutation.topic);
156
- return;
250
+ return { ...base, unsubscribe: mutation.topic };
251
+ }
252
+ }
253
+
254
+ private preparePublication(
255
+ mutation: Extract<TopicMutation, { readonly kind: "publication" }>,
256
+ base: PreparedTopicChange,
257
+ ): PreparedTopicChange {
258
+ const previous = this.topics.get(mutation.topic.topic);
259
+ const update = publicationUpdate(mutation.topic, previous, mutation.publish);
260
+ if (!mutation.publish && (previous?.revision ?? 0) >= update.revision) {
261
+ return base;
157
262
  }
263
+ return {
264
+ ...base,
265
+ topic: update,
266
+ bytes: intercomMessageSizeBytes(update),
267
+ ...(mutation.publish ? { publication: update } : {}),
268
+ };
158
269
  }
159
270
 
160
- snapshot(publication?: TopicUpdate): SessionInfo {
271
+ snapshot(info: SessionInfo, prepared = UNCHANGED): SessionInfo {
272
+ const topics = this.topics.valuesAfter(prepared.topic?.topic, prepared.topic, prepared.changed);
273
+ const subscriptions = this.subscriptions.valuesAfter(
274
+ prepared.subscription?.topic ?? prepared.unsubscribe,
275
+ prepared.subscription,
276
+ prepared.changed,
277
+ );
161
278
  return {
162
- ...this.info,
163
- topics: [...this.topics.values()],
164
- subscriptions: [...this.subscriptions.values()],
165
- ...(publication ? { lastIntercomActivity: Date.now() } : {}),
279
+ ...info,
280
+ ...(topics !== undefined ? { topics } : {}),
281
+ ...(subscriptions !== undefined ? { subscriptions } : {}),
282
+ ...(prepared.publication ? { lastIntercomActivity: Date.now() } : {}),
166
283
  };
167
284
  }
168
- }
169
285
 
170
- /** Prepare a change without mutating the usable broker snapshot before frame validation. */
171
- export function prepareTopicChange(
172
- info: SessionInfo,
173
- change: unknown,
174
- supported: boolean,
175
- ): {
176
- readonly info: SessionInfo;
177
- readonly publication?: TopicUpdate;
178
- readonly restore: boolean;
179
- } {
180
- if (change === undefined) {
181
- return { info, restore: false };
286
+ validateRestore(info: SessionInfo, prepared: PreparedTopicChange, requestId: string): void {
287
+ snapshotFrames(requestId, [{ ...info, topics: [], subscriptions: [] }], true);
288
+ const topicBytes = this.topics.maximumAfter(
289
+ prepared.topic?.topic,
290
+ prepared.topic ? prepared.bytes : 0,
291
+ );
292
+ const subscriptionBytes = this.subscriptions.maximumAfter(
293
+ prepared.subscription?.topic,
294
+ prepared.subscription ? prepared.bytes : 0,
295
+ );
296
+ for (const [field, bytes] of [
297
+ ["topics", topicBytes],
298
+ ["subscriptions", subscriptionBytes],
299
+ ] as const) {
300
+ const size =
301
+ intercomMessageSizeBytes({
302
+ type: "sessions",
303
+ requestId,
304
+ sessions: [{ id: info.id, [field]: [] }],
305
+ more: true,
306
+ }) + bytes;
307
+ if (size > MAX_FRAME_SIZE_BYTES) {
308
+ throw new Error(`Intercom message too large (${size} bytes; max ${MAX_FRAME_SIZE_BYTES})`);
309
+ }
310
+ }
182
311
  }
183
- if (!supported || !isRecord(change)) {
184
- throw new Error("Topic snapshots unavailable for this client");
312
+
313
+ commit(prepared: PreparedTopicChange): void {
314
+ if (prepared.changed) {
315
+ this.topics.accept();
316
+ this.subscriptions.accept();
317
+ }
318
+ if (prepared.topic) {
319
+ this.topics.set(prepared.topic, prepared.bytes);
320
+ } else if (prepared.subscription) {
321
+ this.subscriptions.set(prepared.subscription, prepared.bytes);
322
+ } else if (prepared.unsubscribe !== undefined) {
323
+ this.subscriptions.delete(prepared.unsubscribe);
324
+ }
185
325
  }
186
- const pending = new TopicSnapshot(info);
187
- const publication = pending.apply(topicMutation(change));
188
- return {
189
- info: pending.snapshot(publication),
190
- restore: change.action === "restore",
191
- ...(publication ? { publication } : {}),
192
- };
193
326
  }
194
327
 
195
328
  export function deliveryAcknowledgement(receipt: SendResult): BrokerMessage {
@@ -18,7 +18,6 @@ export interface TargetResolution<T extends TargetIdentity> {
18
18
  readonly status: "none" | "found" | "ambiguous" | "prefix_too_short";
19
19
  readonly target?: T;
20
20
  readonly matches: readonly T[];
21
- readonly minLength?: number;
22
21
  }
23
22
 
24
23
  export function shortSessionId(sessionId: string): string {
@@ -180,7 +179,6 @@ function shortTargetResolution<T extends TargetIdentity>(
180
179
  return {
181
180
  status: "prefix_too_short",
182
181
  matches: prefixes,
183
- minLength: MIN_SESSION_TARGET_PREFIX_LENGTH,
184
182
  };
185
183
  }
186
184
  const byId = new Map<string, T>();
@@ -6,12 +6,12 @@ import { runSynchronously } from "../../shared/cooperative.ts";
6
6
  import { asyncRunRoots, readAsyncRunRecord } from "./async-resume.ts";
7
7
  import type { AsyncRunRecord } from "./async-run-record.ts";
8
8
  import type { AsyncRunSummary } from "./async-run-summary.ts";
9
- import { getRunMetadataDir, readRunJson } from "../shared/supervisor-questions.ts";
9
+ import { readQuestionOwner } from "../shared/supervisor-questions.ts";
10
10
  import { findNestedRouteForRootId } from "../shared/nested-events.ts";
11
11
  import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
12
12
  import { validateStatusForSummary } from "./async-status-validation.ts";
13
13
  import { asyncStatusToSummary } from "./async-status-projection.ts";
14
- import { errorCode, errorMessage, hasText, isRecord } from "./async-value.ts";
14
+ import { errorCode, errorMessage, hasText } from "./async-value.ts";
15
15
 
16
16
  interface AsyncRunListOptions {
17
17
  readonly states?: readonly AsyncRunSummary["state"][];
@@ -112,14 +112,8 @@ class AsyncRunDiscovery {
112
112
  let owner = this.owners.get(entry);
113
113
  if (!hasText(owner) && hasText(this.ownerSessionId)) {
114
114
  try {
115
- const saved: unknown = readRunJson(
116
- path.join(getRunMetadataDir(entry), "question-owner.json"),
117
- );
118
- if (
119
- isRecord(saved) &&
120
- typeof saved.sessionId === "string" &&
121
- saved.sessionId.trim().length > 0
122
- ) {
115
+ const saved = readQuestionOwner(entry);
116
+ if (saved?.sessionId !== undefined && saved.sessionId.trim().length > 0) {
123
117
  owner = saved.sessionId;
124
118
  this.owners.set(entry, owner);
125
119
  }
@@ -2,29 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { isRecord, hasText } from "./async-value.ts";
3
3
 
4
4
  interface CompletionDataLike {
5
- completionId?: unknown;
6
- id?: unknown;
7
- agent?: unknown;
8
- timestamp?: unknown;
9
- sessionId?: unknown;
10
- taskIndex?: unknown;
11
- totalTasks?: unknown;
12
- success?: unknown;
13
- }
14
-
15
- function asNonEmptyString(value: unknown): string | undefined {
16
- if (typeof value !== "string") {
17
- return undefined;
18
- }
19
- const trimmed = value.trim();
20
- return trimmed.length > 0 ? trimmed : undefined;
21
- }
22
-
23
- function asFiniteNumber(value: unknown): number | undefined {
24
- if (typeof value !== "number") {
25
- return undefined;
26
- }
27
- return Number.isFinite(value) ? value : undefined;
5
+ completionId?: string;
6
+ id: string;
28
7
  }
29
8
 
30
9
  function stableStringify(value: unknown): string {
@@ -48,32 +27,11 @@ function payloadDigest(value: unknown): string {
48
27
  return createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16);
49
28
  }
50
29
 
51
- export function buildCompletionKey(data: Readonly<CompletionDataLike>, fallback: string): string {
52
- const completionId = asNonEmptyString(data.completionId);
53
- if (hasText(completionId)) {
54
- return `completion:${completionId}`;
55
- }
56
- const id = asNonEmptyString(data.id);
57
- if (hasText(id)) {
58
- return `id:${id}:${payloadDigest(data)}`;
30
+ export function buildCompletionKey(data: Readonly<CompletionDataLike>): string {
31
+ if (hasText(data.completionId)) {
32
+ return `completion:${data.completionId.trim()}`;
59
33
  }
60
- const sessionId = asNonEmptyString(data.sessionId) ?? "no-session";
61
- const agent = asNonEmptyString(data.agent) ?? "unknown";
62
- const timestamp = asFiniteNumber(data.timestamp);
63
- const taskIndex = asFiniteNumber(data.taskIndex);
64
- const totalTasks = asFiniteNumber(data.totalTasks);
65
- const success = data.success === true ? "1" : "?";
66
- const successIdentity = data.success === false ? "0" : success;
67
- return [
68
- "meta",
69
- sessionId,
70
- agent,
71
- timestamp !== undefined ? String(timestamp) : "no-ts",
72
- taskIndex !== undefined ? String(taskIndex) : "-",
73
- totalTasks !== undefined ? String(totalTasks) : "-",
74
- successIdentity,
75
- fallback,
76
- ].join(":");
34
+ return `id:${data.id.trim()}:${payloadDigest(data)}`;
77
35
  }
78
36
 
79
37
  /** Mutable identity store owned by the completion-admission lifecycle. */
@@ -105,24 +63,3 @@ export function markSeenWithTtl(
105
63
  seen.set(key, now);
106
64
  return false;
107
65
  }
108
-
109
- function isSeenMap(value: unknown): value is Map<string, number> {
110
- if (!(value instanceof Map)) {
111
- return false;
112
- }
113
- const entries: Map<unknown, unknown> = value;
114
- return [...entries].every(
115
- ([key, timestamp]) => typeof key === "string" && typeof timestamp === "number",
116
- );
117
- }
118
-
119
- export function getGlobalSeenMap(storeKey: string): Map<string, number> {
120
- const globalStore = globalThis as Record<string, unknown>;
121
- const existing = globalStore[storeKey];
122
- if (isSeenMap(existing)) {
123
- return existing;
124
- }
125
- const map = new Map<string, number>();
126
- globalStore[storeKey] = map;
127
- return map;
128
- }
@@ -1,6 +1,5 @@
1
1
  /** Subagent completion notifications. */
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { buildCompletionKey, getGlobalSeenMap, markSeenWithTtl } from "./completion-dedupe.ts";
4
3
  import { SUBAGENT_ASYNC_COMPLETE_EVENT } from "../../shared/types.ts";
5
4
  import { hasText, isRecord } from "./async-value.ts";
6
5
  import type { ReadonlyInput } from "../../shared/types/inputs.ts";
@@ -144,7 +143,7 @@ function isUnsubscribe(value: unknown): value is () => void {
144
143
 
145
144
  export default function registerSubagentNotify(
146
145
  pi: ReadonlyInput<ExtensionAPI>,
147
- onQueued?: (completionKey: string, runId?: string) => void,
146
+ onQueued: (completionKey: string, runId?: string) => void,
148
147
  ): () => void {
149
148
  const unsubscribeStoreKey = "__pi_subagents_notify_unsubscribe__";
150
149
  const globalStore = globalThis as Record<string, unknown>;
@@ -156,7 +155,6 @@ export default function registerSubagentNotify(
156
155
  // Best effort cleanup for stale handlers from an older reload.
157
156
  }
158
157
  }
159
- const seen = getGlobalSeenMap("__pi_subagents_notify_seen__");
160
158
  const handleComplete = (data: unknown): void => {
161
159
  if (
162
160
  !isResult(data) ||
@@ -165,17 +163,9 @@ export default function registerSubagentNotify(
165
163
  ) {
166
164
  return;
167
165
  }
168
- const key = buildCompletionKey(data, "notify");
169
- // Owned completion keys are governed by the shared queue/journal lifecycle, not a TTL.
170
- if (
171
- (onQueued === undefined || !hasText(data.completionKey)) &&
172
- markSeenWithTtl(seen, key, Date.now(), 10 * 60 * 1000)
173
- ) {
174
- return;
175
- }
176
166
  pi.sendMessage(buildNotification(data), { triggerTurn: true });
177
167
  if (hasText(data.completionKey)) {
178
- onQueued?.(data.completionKey, data.runId);
168
+ onQueued(data.completionKey, data.runId);
179
169
  }
180
170
  };
181
171
  const unsubscribe = pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, handleComplete);
@@ -292,7 +292,7 @@ class ResultWatcher {
292
292
  if (this.foreign(envelope)) {
293
293
  return;
294
294
  }
295
- const key = buildCompletionKey({ ...envelope.data, id: envelope.runId }, "result");
295
+ const key = buildCompletionKey({ ...envelope.data, id: envelope.runId });
296
296
  if (this.alreadyHandled(envelope, key)) {
297
297
  return;
298
298
  }
@@ -59,6 +59,7 @@ export class RunnerChildObserver {
59
59
  this.id = config.id;
60
60
  this.hooks = hooks;
61
61
  this.mutatingFailureStates = statusPayload.steps.map(() => createMutatingFailureState());
62
+ this.syncTopLevelCurrentTool();
62
63
  }
63
64
 
64
65
  expandChildren(flatIndex: number, count: number): void {
@@ -69,16 +70,21 @@ export class RunnerChildObserver {
69
70
  );
70
71
  }
71
72
 
72
- private syncTopLevelCurrentTool(): void {
73
- const active = this.statusPayload.steps
74
- .filter(
75
- (step) =>
76
- step.status === "running" &&
77
- typeof step.currentTool === "string" &&
78
- step.currentTool.length > 0,
79
- )
80
- .sort((left, right) => (right.currentToolStartedAt ?? 0) - (left.currentToolStartedAt ?? 0))
81
- .at(0);
73
+ syncTopLevelCurrentTool(): void {
74
+ const active =
75
+ this.statusPayload.state === "running" || this.statusPayload.state === "queued"
76
+ ? this.statusPayload.steps
77
+ .filter(
78
+ (step) =>
79
+ step.status === "running" &&
80
+ typeof step.currentTool === "string" &&
81
+ step.currentTool.length > 0,
82
+ )
83
+ .sort(
84
+ (left, right) => (right.currentToolStartedAt ?? 0) - (left.currentToolStartedAt ?? 0),
85
+ )
86
+ .at(0)
87
+ : undefined;
82
88
  this.statusPayload.currentTool = active?.currentTool;
83
89
  this.statusPayload.currentToolStartedAt = active?.currentToolStartedAt;
84
90
  this.statusPayload.currentPath = active?.currentPath;
@@ -106,7 +112,9 @@ export class RunnerChildObserver {
106
112
  }
107
113
  step.streamingText = updateStreamingText(step.streamingText, event);
108
114
  this.recordChildActivity(flatIndex, event, toolSnapshot, now);
109
- this.syncTopLevelCurrentTool();
115
+ if (event.type === "tool_execution_start" || event.type === "tool_execution_end") {
116
+ this.syncTopLevelCurrentTool();
117
+ }
110
118
  step.lastActivityAt = now;
111
119
  this.statusPayload.lastActivityAt = now;
112
120
  this.statusPayload.lastUpdate = now;
@@ -166,6 +166,8 @@ export class RunnerMonitor {
166
166
  clearTimeout(this.statusWriteTimer);
167
167
  this.statusWriteTimer = undefined;
168
168
  }
169
+ // Step completion, interruption and expansion can change activity without a tool-end event.
170
+ this.childObserver.syncTopLevelCurrentTool();
169
171
  this.refreshWorkflowGraph();
170
172
  writeAtomicJson(this.statusPath, this.statusPayload);
171
173
  if (this.config.runtimeVersion !== 2) {
@@ -2,7 +2,6 @@ import * as path from "node:path";
2
2
  import { inspect } from "node:util";
3
3
  import type { ReadonlyDeep } from "type-fest";
4
4
  import { appendJsonl } from "../../shared/artifacts.ts";
5
- import { writeAtomicJson } from "../../shared/atomic-json.ts";
6
5
  import { writeInitialProgressFile } from "../../shared/settings.ts";
7
6
  import { completeWorkflowStep, runParallelTasks } from "../shared/workflow-policy.ts";
8
7
  import {
@@ -194,7 +193,7 @@ export class RunnerParallelGroup {
194
193
  status.currentStep = position.flatIndex;
195
194
  status.lastUpdate = failedAt;
196
195
  status.outputFile = path.join(this.config.asyncDir, `output-${position.flatIndex}.log`);
197
- writeAtomicJson(path.join(this.config.asyncDir, "status.json"), status);
196
+ this.monitor.writeStatusPayload();
198
197
  this.append({
199
198
  type: "subagent.parallel.completed",
200
199
  ts: failedAt,
@@ -224,7 +223,7 @@ export class RunnerParallelGroup {
224
223
  status.lastActivityAt = startedAt;
225
224
  status.lastUpdate = startedAt;
226
225
  status.outputFile = path.join(this.config.asyncDir, `output-${position.flatIndex}.log`);
227
- writeAtomicJson(path.join(this.config.asyncDir, "status.json"), status);
226
+ this.monitor.writeStatusPayload();
228
227
  this.append({
229
228
  type: "subagent.parallel.started",
230
229
  ts: startedAt,
@@ -51,8 +51,11 @@ export class ChildObservationSpool {
51
51
  fs.writeFileSync(this.stderr, text);
52
52
  }
53
53
  resetText(): void {
54
- this.textOffset = 0;
54
+ if (this.textOffset === 0) {
55
+ return;
56
+ }
55
57
  fs.ftruncateSync(this.text, 0);
58
+ this.textOffset = 0;
56
59
  }
57
60
  appendText(text: string): void {
58
61
  const bytes = Buffer.from(text);
@@ -72,14 +75,16 @@ export class ChildObservationSpool {
72
75
  this.textOffset += bytes.length;
73
76
  }
74
77
  replayText(consume: (text: string) => void): void {
75
- this.decode(this.text, 0, this.textOffset, consume);
78
+ if (this.textOffset > 0) {
79
+ this.decode(this.text, 0, this.textOffset, consume);
80
+ }
76
81
  }
77
82
  replayWire(start: number, end: number, consume: (text: string) => void): void {
78
83
  this.decode(this.wire, start, end, consume);
79
84
  }
80
85
 
81
86
  private copy(fd: number, start: number, end: number, consume: (bytes: Buffer) => void): void {
82
- const buffer = Buffer.allocUnsafe(64 * 1024);
87
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, end - start));
83
88
  for (let position = start; position < end;) {
84
89
  const count = fs.readSync(fd, buffer, 0, Math.min(buffer.length, end - position), position);
85
90
  if (count === 0) {