@fitchmultz/pi-subagents 0.46.0 → 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 (92) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/agents/agent-management.js +8 -2
  3. package/dist/agents/agent-selection.js +5 -17
  4. package/dist/agents/agents.js +10 -10
  5. package/dist/extension/build-info.js +1 -1
  6. package/dist/extension/index.js +11 -5
  7. package/dist/extension/result-snapshot.js +0 -18
  8. package/dist/history/ingest.js +40 -31
  9. package/dist/history/prune.js +1 -1
  10. package/dist/history/run-projection.js +1 -2
  11. package/dist/history/search-queries.js +8 -2
  12. package/dist/history/store.js +20 -4
  13. package/dist/history/text-chunks.js +1 -2
  14. package/dist/history/transcript-queries.js +12 -6
  15. package/dist/intercom/result-intercom.js +0 -74
  16. package/dist/pi-intercom/broker/broker.js +33 -16
  17. package/dist/pi-intercom/broker/framing.js +67 -17
  18. package/dist/pi-intercom/broker/snapshot-reader.js +29 -8
  19. package/dist/pi-intercom/broker/snapshots.js +144 -41
  20. package/dist/pi-intercom/session-targets.js +0 -1
  21. package/dist/runs/background/async-run-discovery.js +4 -6
  22. package/dist/runs/background/completion-dedupe.js +4 -55
  23. package/dist/runs/background/notify.js +1 -9
  24. package/dist/runs/background/result-watcher.js +1 -1
  25. package/dist/runs/background/runner-child-observer.js +12 -7
  26. package/dist/runs/background/runner-monitor.js +2 -0
  27. package/dist/runs/background/runner-parallel.js +2 -3
  28. package/dist/runs/background/runner-step-output.js +1 -3
  29. package/dist/runs/shared/acceptance-finalization.js +2 -2
  30. package/dist/runs/shared/acceptance.js +1 -1
  31. package/dist/runs/shared/child-observation-spool.js +8 -3
  32. package/dist/runs/shared/model-fallback.js +3 -0
  33. package/dist/runs/shared/parallel-utils.js +1 -21
  34. package/dist/runs/shared/run-restoration.js +4 -5
  35. package/dist/runs/shared/single-output.js +1 -4
  36. package/dist/runs/shared/subagent-prompt-runtime.js +2 -2
  37. package/dist/runs/shared/supervisor-questions.js +7 -4
  38. package/dist/runs/shared/workflow-policy.js +2 -1
  39. package/dist/shared/journal-frames.js +1 -1
  40. package/dist/shared/settings.js +1 -4
  41. package/dist/slash/command-discovery.js +13 -12
  42. package/dist/tui/agent-history.js +0 -14
  43. package/dist/tui/agent-view.js +0 -1
  44. package/dist/tui/conversation-history.js +10 -1
  45. package/dist/tui/history-cards.js +8 -22
  46. package/docs/intercom.md +5 -1
  47. package/docs/performance.md +72 -3
  48. package/docs/reference.md +1 -1
  49. package/package.json +1 -1
  50. package/src/agents/agent-management.ts +8 -4
  51. package/src/agents/agent-selection.ts +8 -19
  52. package/src/agents/agents.ts +19 -14
  53. package/src/extension/index.ts +12 -5
  54. package/src/extension/result-snapshot.ts +1 -27
  55. package/src/history/ingest.ts +57 -49
  56. package/src/history/prune.ts +1 -1
  57. package/src/history/run-projection.ts +2 -4
  58. package/src/history/search-queries.ts +9 -2
  59. package/src/history/store.ts +21 -5
  60. package/src/history/text-chunks.ts +7 -2
  61. package/src/history/transcript-queries.ts +16 -10
  62. package/src/intercom/result-intercom.ts +0 -103
  63. package/src/pi-intercom/broker/broker.ts +34 -20
  64. package/src/pi-intercom/broker/framing.ts +71 -20
  65. package/src/pi-intercom/broker/snapshot-reader.ts +47 -9
  66. package/src/pi-intercom/broker/snapshots.ts +184 -51
  67. package/src/pi-intercom/session-targets.ts +0 -2
  68. package/src/runs/background/async-run-discovery.ts +4 -10
  69. package/src/runs/background/completion-dedupe.ts +6 -69
  70. package/src/runs/background/notify.ts +2 -12
  71. package/src/runs/background/result-watcher.ts +1 -1
  72. package/src/runs/background/runner-child-observer.ts +19 -11
  73. package/src/runs/background/runner-monitor.ts +2 -0
  74. package/src/runs/background/runner-parallel.ts +2 -3
  75. package/src/runs/background/runner-step-output.ts +1 -3
  76. package/src/runs/shared/acceptance-finalization.ts +2 -2
  77. package/src/runs/shared/acceptance.ts +0 -2
  78. package/src/runs/shared/child-observation-spool.ts +8 -3
  79. package/src/runs/shared/model-fallback.ts +3 -0
  80. package/src/runs/shared/parallel-utils.ts +1 -25
  81. package/src/runs/shared/run-restoration.ts +4 -4
  82. package/src/runs/shared/single-output.ts +0 -4
  83. package/src/runs/shared/subagent-prompt-runtime.ts +2 -2
  84. package/src/runs/shared/supervisor-questions.ts +10 -10
  85. package/src/runs/shared/workflow-policy.ts +1 -1
  86. package/src/shared/journal-frames.ts +1 -1
  87. package/src/shared/settings.ts +1 -4
  88. package/src/slash/command-discovery.ts +13 -12
  89. package/src/tui/agent-history.ts +1 -18
  90. package/src/tui/agent-view.ts +0 -1
  91. package/src/tui/conversation-history.ts +15 -1
  92. package/src/tui/history-cards.ts +8 -26
@@ -14,37 +14,87 @@ export function validateIntercomMessageSize(msg) {
14
14
  * Format: 4-byte big-endian length + JSON payload
15
15
  */
16
16
  export function writeMessage(socket, msg) {
17
- const tooLarge = validateIntercomMessageSize(msg);
18
- if (tooLarge) {
19
- throw tooLarge;
20
- }
21
17
  const json = JSON.stringify(msg);
18
+ const size = Buffer.byteLength(json, "utf-8");
19
+ if (size > MAX_FRAME_SIZE_BYTES) {
20
+ throw new Error(`Intercom message too large (${size} bytes; max ${MAX_FRAME_SIZE_BYTES})`);
21
+ }
22
22
  const payload = Buffer.from(json, "utf-8");
23
23
  const header = Buffer.alloc(4);
24
24
  header.writeUInt32BE(payload.length, 0);
25
25
  socket.write(Buffer.concat([header, payload]));
26
26
  }
27
+ /** Retain fragments until a header or payload is complete; copy each fragmented value once. */
28
+ class MessageBuffer {
29
+ chunks = [];
30
+ index = 0;
31
+ length = 0;
32
+ append(data) {
33
+ if (data.length > 0) {
34
+ this.chunks.push(data);
35
+ this.length += data.length;
36
+ }
37
+ }
38
+ clear() {
39
+ this.chunks = [];
40
+ this.index = 0;
41
+ this.length = 0;
42
+ }
43
+ read(size) {
44
+ const first = this.chunks[this.index];
45
+ if (size === 0) {
46
+ return Buffer.alloc(0);
47
+ }
48
+ const result = first.length >= size ? first.subarray(0, size) : Buffer.allocUnsafe(size);
49
+ let written = 0;
50
+ while (written < size) {
51
+ const chunk = this.chunks[this.index];
52
+ const count = Math.min(chunk.length, size - written);
53
+ if (first.length < size) {
54
+ chunk.copy(result, written, 0, count);
55
+ }
56
+ written += count;
57
+ this.length -= count;
58
+ if (count === chunk.length) {
59
+ this.index++;
60
+ }
61
+ else {
62
+ this.chunks[this.index] = chunk.subarray(count);
63
+ }
64
+ }
65
+ if (this.index === this.chunks.length) {
66
+ this.clear();
67
+ }
68
+ else if (this.index * 2 >= this.chunks.length) {
69
+ this.chunks = this.chunks.slice(this.index);
70
+ this.index = 0;
71
+ }
72
+ return result;
73
+ }
74
+ }
27
75
  /**
28
76
  * Create a message reader that handles partial reads.
29
77
  * Calls onMessage for each complete message received.
30
78
  * Protocol or handler errors are reported to onError so the caller can close the socket.
31
79
  */
32
80
  export function createMessageReader(onMessage, onError) {
33
- let buffer = Buffer.alloc(0);
81
+ const buffer = new MessageBuffer();
82
+ let length;
34
83
  return (data) => {
35
- buffer = Buffer.concat([buffer, data]);
36
- while (buffer.length >= 4) {
37
- const length = buffer.readUInt32BE(0);
38
- if (length > MAX_FRAME_SIZE_BYTES) {
39
- onError(new Error(`Intercom frame too large (${length} bytes; max ${MAX_FRAME_SIZE_BYTES})`));
40
- buffer = Buffer.alloc(0);
41
- return;
42
- }
43
- if (buffer.length < 4 + length) {
44
- break;
84
+ buffer.append(data);
85
+ while (buffer.length >= (length ?? 4)) {
86
+ if (length === undefined) {
87
+ length = buffer.read(4).readUInt32BE(0);
88
+ if (length > MAX_FRAME_SIZE_BYTES) {
89
+ onError(new Error(`Intercom frame too large (${length} bytes; max ${MAX_FRAME_SIZE_BYTES})`));
90
+ buffer.clear();
91
+ length = undefined;
92
+ return;
93
+ }
94
+ continue;
45
95
  }
46
- const payload = buffer.subarray(4, 4 + length);
47
- buffer = buffer.subarray(4 + length);
96
+ const payload = buffer.read(length);
97
+ length = undefined;
48
98
  let msg;
49
99
  try {
50
100
  msg = JSON.parse(payload.toString("utf-8"));
@@ -1,5 +1,5 @@
1
1
  import { isRecord, isUnknownArray } from "../../shared/unknown.js";
2
- import { normalizeSessionInfo } from "../types.js";
2
+ import { normalizeSessionInfo, } from "../types.js";
3
3
  function optionalReceiptFields(value) {
4
4
  if ((value.queued !== undefined && typeof value.queued !== "boolean") ||
5
5
  (value.reason !== undefined && typeof value.reason !== "string")) {
@@ -36,6 +36,16 @@ function receipt(value) {
36
36
  ...optional,
37
37
  };
38
38
  }
39
+ function appendRows(previous, rows, supplied) {
40
+ if (rows === undefined) {
41
+ return supplied ? undefined : previous;
42
+ }
43
+ const result = previous ?? [];
44
+ for (const row of rows) {
45
+ result.push(row);
46
+ }
47
+ return result;
48
+ }
39
49
  /** Multipart frames build a private cache; only a complete validated snapshot escapes. */
40
50
  export class SnapshotReader {
41
51
  sessions = new Map();
@@ -48,17 +58,20 @@ export class SnapshotReader {
48
58
  const subscriptions = optionalArray(row.subscriptions);
49
59
  const previous = this.sessions.get(row.id);
50
60
  const info = normalizeSessionInfo({
51
- ...previous,
61
+ ...previous?.info,
52
62
  ...row,
53
- ...(topics !== undefined ? { topics: [...(previous?.topics ?? []), ...topics] } : {}),
54
- ...(subscriptions !== undefined
55
- ? { subscriptions: [...(previous?.subscriptions ?? []), ...subscriptions] }
56
- : {}),
63
+ ...(topics !== undefined ? { topics } : {}),
64
+ ...(subscriptions !== undefined ? { subscriptions } : {}),
57
65
  });
58
66
  if (!info) {
59
67
  throw new Error("Invalid session snapshot");
60
68
  }
61
- this.sessions.set(info.id, info);
69
+ const { topics: newTopics, subscriptions: newSubscriptions, ...identity } = info;
70
+ this.sessions.set(info.id, {
71
+ info: identity,
72
+ topics: appendRows(previous?.topics, newTopics, "topics" in row),
73
+ subscriptions: appendRows(previous?.subscriptions, newSubscriptions, "subscriptions" in row),
74
+ });
62
75
  }
63
76
  append(sessions, receipts) {
64
77
  for (const row of sessions) {
@@ -73,6 +86,14 @@ export class SnapshotReader {
73
86
  this.receipts.push(...receipts.map(receipt));
74
87
  }
75
88
  finish() {
76
- return { sessions: [...this.sessions.values()], receipts: this.receipts };
89
+ const sessions = [];
90
+ for (const { info, topics, subscriptions } of this.sessions.values()) {
91
+ sessions.push({
92
+ ...info,
93
+ ...(topics !== undefined ? { topics } : {}),
94
+ ...(subscriptions !== undefined ? { subscriptions } : {}),
95
+ });
96
+ }
97
+ return { sessions, receipts: this.receipts };
77
98
  }
78
99
  }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { isRecord } from "../../shared/unknown.js";
3
3
  import { isTopicUpdate, isTopicSubscription, } from "../types.js";
4
- import { validateIntercomMessageSize } from "./framing.js";
4
+ import { intercomMessageSizeBytes, MAX_FRAME_SIZE_BYTES, validateIntercomMessageSize, } from "./framing.js";
5
5
  /** Direct messages and old clients receive identity, not potentially large topic state. */
6
6
  export function messageSender(info) {
7
7
  const { topics: _topics, subscriptions: _subscriptions, ...identity } = info;
@@ -91,60 +91,163 @@ function publicationUpdate(topic, previous, publish) {
91
91
  }
92
92
  return { ...topic, revision };
93
93
  }
94
- class TopicSnapshot {
95
- info;
94
+ class SnapshotRecords {
95
+ records = new Map();
96
+ largest = 0;
97
+ initial;
98
+ present;
99
+ constructor(values) {
100
+ this.initial = values;
101
+ this.present = values !== undefined;
102
+ for (const value of values ?? []) {
103
+ this.set(value, intercomMessageSizeBytes(value));
104
+ }
105
+ }
106
+ get(topic, first = false) {
107
+ if (first && this.initial !== undefined) {
108
+ return this.initial.find((record) => record.topic === topic);
109
+ }
110
+ return this.records.get(topic)?.value;
111
+ }
112
+ maximumAfter(topic, bytes = 0) {
113
+ const previous = topic === undefined ? undefined : this.records.get(topic);
114
+ if (previous?.bytes !== this.largest || bytes >= this.largest) {
115
+ return Math.max(this.largest, bytes);
116
+ }
117
+ // ponytail: shrinking the largest record scans cached sizes; index sizes if churn warrants it.
118
+ let largest = bytes;
119
+ for (const [key, record] of this.records) {
120
+ if (key !== topic) {
121
+ largest = Math.max(largest, record.bytes);
122
+ }
123
+ }
124
+ return largest;
125
+ }
126
+ set(value, bytes) {
127
+ this.largest = this.maximumAfter(value.topic, bytes);
128
+ this.records.set(value.topic, { value, bytes });
129
+ }
130
+ delete(topic) {
131
+ this.largest = this.maximumAfter(topic);
132
+ this.records.delete(topic);
133
+ }
134
+ accept() {
135
+ this.initial = undefined;
136
+ this.present = true;
137
+ }
138
+ valuesAfter(topic, value, changed = false) {
139
+ if (!changed) {
140
+ if (this.initial !== undefined) {
141
+ return this.initial;
142
+ }
143
+ if (!this.present) {
144
+ return;
145
+ }
146
+ }
147
+ const values = [];
148
+ for (const [key, record] of this.records) {
149
+ const next = key === topic ? value : record.value;
150
+ if (next !== undefined) {
151
+ values.push(next);
152
+ }
153
+ }
154
+ if (value !== undefined && !this.records.has(value.topic)) {
155
+ values.push(value);
156
+ }
157
+ return values;
158
+ }
159
+ }
160
+ const UNCHANGED = { restore: false, changed: false, bytes: 0 };
161
+ /** Keep committed records indexed; restores validate only new bytes and cached envelope maxima. */
162
+ export class TopicSnapshot {
96
163
  topics;
97
164
  subscriptions;
98
165
  constructor(info) {
99
- this.info = info;
100
- this.topics = new Map((info.topics ?? []).map((topic) => [topic.topic, topic]));
101
- this.subscriptions = new Map((info.subscriptions ?? []).map((entry) => [entry.topic, entry]));
166
+ this.topics = new SnapshotRecords(info.topics);
167
+ this.subscriptions = new SnapshotRecords(info.subscriptions);
102
168
  }
103
- apply(mutation) {
169
+ subscription(topic) {
170
+ return this.subscriptions.get(topic, true);
171
+ }
172
+ prepare(change, supported) {
173
+ if (change === undefined) {
174
+ return UNCHANGED;
175
+ }
176
+ if (!supported || !isRecord(change)) {
177
+ throw new Error("Topic snapshots unavailable for this client");
178
+ }
179
+ const mutation = topicMutation(change);
180
+ const base = { restore: change.action === "restore", changed: true, bytes: 0 };
104
181
  switch (mutation.kind) {
105
- case "publication": {
106
- const previous = this.topics.get(mutation.topic.topic);
107
- const update = publicationUpdate(mutation.topic, previous, mutation.publish);
108
- if (mutation.publish || (previous?.revision ?? 0) < update.revision) {
109
- this.topics.set(update.topic, update);
110
- }
111
- if (mutation.publish) {
112
- return update;
113
- }
114
- return;
115
- }
182
+ case "publication":
183
+ return this.preparePublication(mutation, base);
116
184
  case "subscription":
117
- this.subscriptions.set(mutation.subscription.topic, mutation.subscription);
118
- return;
185
+ return {
186
+ ...base,
187
+ subscription: mutation.subscription,
188
+ bytes: intercomMessageSizeBytes(mutation.subscription),
189
+ };
119
190
  case "unsubscribe":
120
- this.subscriptions.delete(mutation.topic);
121
- return;
191
+ return { ...base, unsubscribe: mutation.topic };
122
192
  }
123
193
  }
124
- snapshot(publication) {
194
+ preparePublication(mutation, base) {
195
+ const previous = this.topics.get(mutation.topic.topic);
196
+ const update = publicationUpdate(mutation.topic, previous, mutation.publish);
197
+ if (!mutation.publish && (previous?.revision ?? 0) >= update.revision) {
198
+ return base;
199
+ }
125
200
  return {
126
- ...this.info,
127
- topics: [...this.topics.values()],
128
- subscriptions: [...this.subscriptions.values()],
129
- ...(publication ? { lastIntercomActivity: Date.now() } : {}),
201
+ ...base,
202
+ topic: update,
203
+ bytes: intercomMessageSizeBytes(update),
204
+ ...(mutation.publish ? { publication: update } : {}),
130
205
  };
131
206
  }
132
- }
133
- /** Prepare a change without mutating the usable broker snapshot before frame validation. */
134
- export function prepareTopicChange(info, change, supported) {
135
- if (change === undefined) {
136
- return { info, restore: false };
207
+ snapshot(info, prepared = UNCHANGED) {
208
+ const topics = this.topics.valuesAfter(prepared.topic?.topic, prepared.topic, prepared.changed);
209
+ const subscriptions = this.subscriptions.valuesAfter(prepared.subscription?.topic ?? prepared.unsubscribe, prepared.subscription, prepared.changed);
210
+ return {
211
+ ...info,
212
+ ...(topics !== undefined ? { topics } : {}),
213
+ ...(subscriptions !== undefined ? { subscriptions } : {}),
214
+ ...(prepared.publication ? { lastIntercomActivity: Date.now() } : {}),
215
+ };
137
216
  }
138
- if (!supported || !isRecord(change)) {
139
- throw new Error("Topic snapshots unavailable for this client");
217
+ validateRestore(info, prepared, requestId) {
218
+ snapshotFrames(requestId, [{ ...info, topics: [], subscriptions: [] }], true);
219
+ const topicBytes = this.topics.maximumAfter(prepared.topic?.topic, prepared.topic ? prepared.bytes : 0);
220
+ const subscriptionBytes = this.subscriptions.maximumAfter(prepared.subscription?.topic, prepared.subscription ? prepared.bytes : 0);
221
+ for (const [field, bytes] of [
222
+ ["topics", topicBytes],
223
+ ["subscriptions", subscriptionBytes],
224
+ ]) {
225
+ const size = intercomMessageSizeBytes({
226
+ type: "sessions",
227
+ requestId,
228
+ sessions: [{ id: info.id, [field]: [] }],
229
+ more: true,
230
+ }) + bytes;
231
+ if (size > MAX_FRAME_SIZE_BYTES) {
232
+ throw new Error(`Intercom message too large (${size} bytes; max ${MAX_FRAME_SIZE_BYTES})`);
233
+ }
234
+ }
235
+ }
236
+ commit(prepared) {
237
+ if (prepared.changed) {
238
+ this.topics.accept();
239
+ this.subscriptions.accept();
240
+ }
241
+ if (prepared.topic) {
242
+ this.topics.set(prepared.topic, prepared.bytes);
243
+ }
244
+ else if (prepared.subscription) {
245
+ this.subscriptions.set(prepared.subscription, prepared.bytes);
246
+ }
247
+ else if (prepared.unsubscribe !== undefined) {
248
+ this.subscriptions.delete(prepared.unsubscribe);
249
+ }
140
250
  }
141
- const pending = new TopicSnapshot(info);
142
- const publication = pending.apply(topicMutation(change));
143
- return {
144
- info: pending.snapshot(publication),
145
- restore: change.action === "restore",
146
- ...(publication ? { publication } : {}),
147
- };
148
251
  }
149
252
  export function deliveryAcknowledgement(receipt) {
150
253
  if (!receipt.accepted) {
@@ -104,7 +104,6 @@ function shortTargetResolution(names, prefixes) {
104
104
  return {
105
105
  status: "prefix_too_short",
106
106
  matches: prefixes,
107
- minLength: MIN_SESSION_TARGET_PREFIX_LENGTH,
108
107
  };
109
108
  }
110
109
  const byId = new Map();
@@ -3,12 +3,12 @@ import * as path from "node:path";
3
3
  import { RESULTS_DIR } from "../../shared/types.js";
4
4
  import { runSynchronously } from "../../shared/cooperative.js";
5
5
  import { asyncRunRoots, readAsyncRunRecord } from "./async-resume.js";
6
- import { getRunMetadataDir, readRunJson } from "../shared/supervisor-questions.js";
6
+ import { readQuestionOwner } from "../shared/supervisor-questions.js";
7
7
  import { findNestedRouteForRootId } from "../shared/nested-events.js";
8
8
  import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.js";
9
9
  import { validateStatusForSummary } from "./async-status-validation.js";
10
10
  import { asyncStatusToSummary } from "./async-status-projection.js";
11
- import { errorCode, errorMessage, hasText, isRecord } from "./async-value.js";
11
+ import { errorCode, errorMessage, hasText } from "./async-value.js";
12
12
  function directory(root, entry) {
13
13
  const entryPath = path.join(root, entry);
14
14
  try {
@@ -86,10 +86,8 @@ class AsyncRunDiscovery {
86
86
  let owner = this.owners.get(entry);
87
87
  if (!hasText(owner) && hasText(this.ownerSessionId)) {
88
88
  try {
89
- const saved = readRunJson(path.join(getRunMetadataDir(entry), "question-owner.json"));
90
- if (isRecord(saved) &&
91
- typeof saved.sessionId === "string" &&
92
- saved.sessionId.trim().length > 0) {
89
+ const saved = readQuestionOwner(entry);
90
+ if (saved?.sessionId !== undefined && saved.sessionId.trim().length > 0) {
93
91
  owner = saved.sessionId;
94
92
  this.owners.set(entry, owner);
95
93
  }
@@ -1,18 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { isRecord, hasText } from "./async-value.js";
3
- function asNonEmptyString(value) {
4
- if (typeof value !== "string") {
5
- return undefined;
6
- }
7
- const trimmed = value.trim();
8
- return trimmed.length > 0 ? trimmed : undefined;
9
- }
10
- function asFiniteNumber(value) {
11
- if (typeof value !== "number") {
12
- return undefined;
13
- }
14
- return Number.isFinite(value) ? value : undefined;
15
- }
16
3
  function stableStringify(value) {
17
4
  if (value === null || typeof value !== "object") {
18
5
  return JSON.stringify(value);
@@ -32,32 +19,11 @@ function stableStringify(value) {
32
19
  function payloadDigest(value) {
33
20
  return createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16);
34
21
  }
35
- export function buildCompletionKey(data, fallback) {
36
- const completionId = asNonEmptyString(data.completionId);
37
- if (hasText(completionId)) {
38
- return `completion:${completionId}`;
39
- }
40
- const id = asNonEmptyString(data.id);
41
- if (hasText(id)) {
42
- return `id:${id}:${payloadDigest(data)}`;
22
+ export function buildCompletionKey(data) {
23
+ if (hasText(data.completionId)) {
24
+ return `completion:${data.completionId.trim()}`;
43
25
  }
44
- const sessionId = asNonEmptyString(data.sessionId) ?? "no-session";
45
- const agent = asNonEmptyString(data.agent) ?? "unknown";
46
- const timestamp = asFiniteNumber(data.timestamp);
47
- const taskIndex = asFiniteNumber(data.taskIndex);
48
- const totalTasks = asFiniteNumber(data.totalTasks);
49
- const success = data.success === true ? "1" : "?";
50
- const successIdentity = data.success === false ? "0" : success;
51
- return [
52
- "meta",
53
- sessionId,
54
- agent,
55
- timestamp !== undefined ? String(timestamp) : "no-ts",
56
- taskIndex !== undefined ? String(taskIndex) : "-",
57
- totalTasks !== undefined ? String(totalTasks) : "-",
58
- successIdentity,
59
- fallback,
60
- ].join(":");
26
+ return `id:${data.id.trim()}:${payloadDigest(data)}`;
61
27
  }
62
28
  function pruneSeenMap(seen, now, ttlMs) {
63
29
  for (const [key, ts] of seen.entries()) {
@@ -74,20 +40,3 @@ export function markSeenWithTtl(seen, key, now, ttlMs) {
74
40
  seen.set(key, now);
75
41
  return false;
76
42
  }
77
- function isSeenMap(value) {
78
- if (!(value instanceof Map)) {
79
- return false;
80
- }
81
- const entries = value;
82
- return [...entries].every(([key, timestamp]) => typeof key === "string" && typeof timestamp === "number");
83
- }
84
- export function getGlobalSeenMap(storeKey) {
85
- const globalStore = globalThis;
86
- const existing = globalStore[storeKey];
87
- if (isSeenMap(existing)) {
88
- return existing;
89
- }
90
- const map = new Map();
91
- globalStore[storeKey] = map;
92
- return map;
93
- }
@@ -1,4 +1,3 @@
1
- import { buildCompletionKey, getGlobalSeenMap, markSeenWithTtl } from "./completion-dedupe.js";
2
1
  import { SUBAGENT_ASYNC_COMPLETE_EVENT } from "../../shared/types.js";
3
2
  import { hasText, isRecord } from "./async-value.js";
4
3
  function isResult(value) {
@@ -95,22 +94,15 @@ export default function registerSubagentNotify(pi, onQueued) {
95
94
  // Best effort cleanup for stale handlers from an older reload.
96
95
  }
97
96
  }
98
- const seen = getGlobalSeenMap("__pi_subagents_notify_seen__");
99
97
  const handleComplete = (data) => {
100
98
  if (!isResult(data) ||
101
99
  data.intercomResultDelivered === true ||
102
100
  data.suppressNotification === true) {
103
101
  return;
104
102
  }
105
- const key = buildCompletionKey(data, "notify");
106
- // Owned completion keys are governed by the shared queue/journal lifecycle, not a TTL.
107
- if ((onQueued === undefined || !hasText(data.completionKey)) &&
108
- markSeenWithTtl(seen, key, Date.now(), 10 * 60 * 1000)) {
109
- return;
110
- }
111
103
  pi.sendMessage(buildNotification(data), { triggerTurn: true });
112
104
  if (hasText(data.completionKey)) {
113
- onQueued?.(data.completionKey, data.runId);
105
+ onQueued(data.completionKey, data.runId);
114
106
  }
115
107
  };
116
108
  const unsubscribe = pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, handleComplete);
@@ -215,7 +215,7 @@ class ResultWatcher {
215
215
  if (this.foreign(envelope)) {
216
216
  return;
217
217
  }
218
- const key = buildCompletionKey({ ...envelope.data, id: envelope.runId }, "result");
218
+ const key = buildCompletionKey({ ...envelope.data, id: envelope.runId });
219
219
  if (this.alreadyHandled(envelope, key)) {
220
220
  return;
221
221
  }
@@ -31,17 +31,20 @@ export class RunnerChildObserver {
31
31
  this.id = config.id;
32
32
  this.hooks = hooks;
33
33
  this.mutatingFailureStates = statusPayload.steps.map(() => createMutatingFailureState());
34
+ this.syncTopLevelCurrentTool();
34
35
  }
35
36
  expandChildren(flatIndex, count) {
36
37
  this.mutatingFailureStates.splice(flatIndex, 1, ...Array.from({ length: count }, () => createMutatingFailureState()));
37
38
  }
38
39
  syncTopLevelCurrentTool() {
39
- const active = this.statusPayload.steps
40
- .filter((step) => step.status === "running" &&
41
- typeof step.currentTool === "string" &&
42
- step.currentTool.length > 0)
43
- .sort((left, right) => (right.currentToolStartedAt ?? 0) - (left.currentToolStartedAt ?? 0))
44
- .at(0);
40
+ const active = this.statusPayload.state === "running" || this.statusPayload.state === "queued"
41
+ ? this.statusPayload.steps
42
+ .filter((step) => step.status === "running" &&
43
+ typeof step.currentTool === "string" &&
44
+ step.currentTool.length > 0)
45
+ .sort((left, right) => (right.currentToolStartedAt ?? 0) - (left.currentToolStartedAt ?? 0))
46
+ .at(0)
47
+ : undefined;
45
48
  this.statusPayload.currentTool = active?.currentTool;
46
49
  this.statusPayload.currentToolStartedAt = active?.currentToolStartedAt;
47
50
  this.statusPayload.currentPath = active?.currentPath;
@@ -62,7 +65,9 @@ export class RunnerChildObserver {
62
65
  }
63
66
  step.streamingText = updateStreamingText(step.streamingText, event);
64
67
  this.recordChildActivity(flatIndex, event, toolSnapshot, now);
65
- this.syncTopLevelCurrentTool();
68
+ if (event.type === "tool_execution_start" || event.type === "tool_execution_end") {
69
+ this.syncTopLevelCurrentTool();
70
+ }
66
71
  step.lastActivityAt = now;
67
72
  this.statusPayload.lastActivityAt = now;
68
73
  this.statusPayload.lastUpdate = now;
@@ -120,6 +120,8 @@ export class RunnerMonitor {
120
120
  clearTimeout(this.statusWriteTimer);
121
121
  this.statusWriteTimer = undefined;
122
122
  }
123
+ // Step completion, interruption and expansion can change activity without a tool-end event.
124
+ this.childObserver.syncTopLevelCurrentTool();
123
125
  this.refreshWorkflowGraph();
124
126
  writeAtomicJson(this.statusPath, this.statusPayload);
125
127
  if (this.config.runtimeVersion !== 2) {
@@ -1,7 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import { inspect } from "node:util";
3
3
  import { appendJsonl } from "../../shared/artifacts.js";
4
- import { writeAtomicJson } from "../../shared/atomic-json.js";
5
4
  import { writeInitialProgressFile } from "../../shared/settings.js";
6
5
  import { completeWorkflowStep, runParallelTasks } from "../shared/workflow-policy.js";
7
6
  import { createWorktrees, cleanupWorktrees, findWorktreeTaskCwdConflict, formatWorktreeTaskCwdConflict, formatParallelWorktreeSummary, WorktreeCleanupError, } from "../shared/worktree.js";
@@ -129,7 +128,7 @@ export class RunnerParallelGroup {
129
128
  status.currentStep = position.flatIndex;
130
129
  status.lastUpdate = failedAt;
131
130
  status.outputFile = path.join(this.config.asyncDir, `output-${position.flatIndex}.log`);
132
- writeAtomicJson(path.join(this.config.asyncDir, "status.json"), status);
131
+ this.monitor.writeStatusPayload();
133
132
  this.append({
134
133
  type: "subagent.parallel.completed",
135
134
  ts: failedAt,
@@ -158,7 +157,7 @@ export class RunnerParallelGroup {
158
157
  status.lastActivityAt = startedAt;
159
158
  status.lastUpdate = startedAt;
160
159
  status.outputFile = path.join(this.config.asyncDir, `output-${position.flatIndex}.log`);
161
- writeAtomicJson(path.join(this.config.asyncDir, "status.json"), status);
160
+ this.monitor.writeStatusPayload();
162
161
  this.append({
163
162
  type: "subagent.parallel.started",
164
163
  ts: startedAt,
@@ -59,9 +59,7 @@ export function publishStepOutput(step, ctx, state) {
59
59
  originalPath.length > 0 &&
60
60
  step.outputMode !== "file-only" &&
61
61
  step.outputPathFromAgentDefault === true;
62
- const cleanup = shouldConsume
63
- ? cleanupSingleOutputFile(originalPath, output, undefined)
64
- : undefined;
62
+ const cleanup = shouldConsume ? cleanupSingleOutputFile(originalPath, output) : undefined;
65
63
  const savedPath = relocateOutput(ctx, originalPath, cleanup);
66
64
  const reference = outputReference(savedPath, state.resolvedOutput.fullOutput, cleanup);
67
65
  const displayOutput = finalizeSingleOutput({
@@ -287,7 +287,7 @@ export function formatAcceptanceFinalizationPrompt(input) {
287
287
  lines.push(...initialReviewEvidence(input.initialOutput, input.initialLedger, input.previousFailure), "", ...finalizationInstructions(input.nativeReport === true, input.outputSchema));
288
288
  return lines.join("\n");
289
289
  }
290
- export function createFinalizationTurn(input) {
290
+ function createFinalizationTurn(input) {
291
291
  const failureMessage = acceptanceFailureMessage(input.ledger);
292
292
  return {
293
293
  turn: input.turn,
@@ -320,7 +320,7 @@ export function createFinalizationProcessFailureTurn(input) {
320
320
  failureMessage: `Acceptance rejected: ${input.message}`,
321
321
  };
322
322
  }
323
- export function attachFinalizationToLedger(input) {
323
+ function attachFinalizationToLedger(input) {
324
324
  return {
325
325
  ...input.authoritativeLedger,
326
326
  ...(input.initialLedger.childReport
@@ -1,4 +1,4 @@
1
1
  export { acceptanceInputFromResolved, acceptanceSelfReviewConfig, formatAcceptancePrompt, resolveEffectiveAcceptance, shouldRunAcceptanceFinalization, validateAcceptanceInput, } from "./acceptance-contract.js";
2
2
  export { parseAcceptanceReport, validateAcceptanceReportShape, stripAcceptanceReport, } from "./acceptance-reports.js";
3
3
  export { acceptanceFailureMessage, acceptanceHumanAction, evaluateAcceptance, evaluateAcceptanceReport, } from "./acceptance-evaluation.js";
4
- export { attachFinalizationToLedger, buildFinalizationProcessFailureLedger, createFinalizationProcessFailureTurn, createFinalizationTurn, createFinalizationReportRuntime, readFinalizationReport, formatUnconfirmedFinalizationOutput, formatAcceptanceFinalizationPrompt, evaluateRunAcceptance, resolveExecutionOutcome, resolveFinalizationOutput, } from "./acceptance-finalization.js";
4
+ export { buildFinalizationProcessFailureLedger, createFinalizationProcessFailureTurn, createFinalizationReportRuntime, readFinalizationReport, formatUnconfirmedFinalizationOutput, formatAcceptanceFinalizationPrompt, evaluateRunAcceptance, resolveExecutionOutcome, resolveFinalizationOutput, } from "./acceptance-finalization.js";