@fitchmultz/pi-subagents 0.52.17 → 0.52.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## 0.52.18 - 2026-11-06
6
+
7
+ <!-- npm-release:unreleased:start -->
8
+
9
+ - Keep replied or retired asks out of reply attribution when their queued input reaches a later native turn.
10
+ - Preserve unanswered queued asks through abort recovery without sending a premature recipient-failure reply.
11
+
12
+ <!-- npm-release:unreleased:end -->
13
+
14
+ - Keep retired asks out of native reply attribution (#341) ([27a93f9](https://github.com/fitchmultz/pi-subagents/commit/27a93f9bc52aaa9e454ffc593017b66b01461bc4))
15
+
16
+ ### What's Changed
17
+
18
+ - Keep retired asks out of native reply attribution by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/341
19
+
20
+ **Full Changelog**: https://github.com/fitchmultz/pi-subagents/compare/v0.52.17...v0.52.18
21
+
5
22
  ## 0.52.17 - 2026-11-05
6
23
 
7
24
  <!-- npm-release:unreleased:start -->
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.52.17","sha256":"b18d11c5a8e05e11577a3347c2d40808b644af0b5bd5248f4a3314d68c97a640"});
1
+ export const EXTENSION_BUILD = Object.freeze({"version":"0.52.18","sha256":"83db07f9acbdd6ff5985c46e1f5f5411f68b6766697a2dfab019b3d00dbba1c1"});
@@ -131,7 +131,7 @@ export class InboundDeliveryOwner {
131
131
  const trigger = index === -1 ? undefined : rest.splice(index, 1)[0];
132
132
  const replyTarget = entries.find((entry) => entry.message.expectsReply === true) ?? trigger;
133
133
  if (replyTarget && this.journal.get(replyTarget.message.id) === replyTarget) {
134
- this.hooks.replies.queueTurnContext({ ...replyTarget, receivedAt: Date.now() });
134
+ this.hooks.replies.queueTurnContext(replyTarget.message.id);
135
135
  }
136
136
  for (const entry of rest) {
137
137
  if (!this.live(generation)) {
@@ -218,11 +218,7 @@ export class InboundDeliveryOwner {
218
218
  this.journal.markNative(entry.message.id);
219
219
  }
220
220
  if (delivery !== "passive") {
221
- this.hooks.replies.queueTurnContext({
222
- from: entry.from,
223
- message: entry.message,
224
- receivedAt: Date.now(),
225
- });
221
+ this.hooks.replies.queueTurnContext(entry.message.id);
226
222
  }
227
223
  }
228
224
  nativeMessage(entry) {
@@ -55,9 +55,9 @@ function replyBySender(contexts, to) {
55
55
  }
56
56
  export class ReplyTracker {
57
57
  pendingAsks = new Map();
58
- pendingTurnContexts = [];
59
- currentTurnContext = null;
60
- activeAgentContext = null;
58
+ pendingTurnIds = [];
59
+ currentTurnId = null;
60
+ activeAgentId = null;
61
61
  askTimeoutMs;
62
62
  constructor(askTimeoutMs = 10 * 60 * 1000) {
63
63
  this.askTimeoutMs = askTimeoutMs;
@@ -70,61 +70,58 @@ export class ReplyTracker {
70
70
  }
71
71
  return context;
72
72
  }
73
- queueTurnContext(context) {
74
- if (context.message.expectsReply !== true) {
73
+ queueTurnContext(messageId) {
74
+ if (!this.pendingAsks.has(messageId) || this.hasTurnContext(messageId)) {
75
75
  return;
76
76
  }
77
- if (this.hasTurnContext(context.message.id)) {
78
- return;
79
- }
80
- this.pendingTurnContexts.push(context);
77
+ this.pendingTurnIds.push(messageId);
81
78
  }
82
79
  beginTurn(now = Date.now()) {
83
80
  this.pruneExpired(now);
84
- this.currentTurnContext = this.pendingTurnContexts.shift() ?? null;
85
- if (this.currentTurnContext) {
86
- this.activeAgentContext = this.currentTurnContext;
81
+ this.currentTurnId = this.pendingTurnIds.shift() ?? null;
82
+ if (this.currentTurnId !== null) {
83
+ this.activeAgentId = this.currentTurnId;
87
84
  }
88
85
  }
89
86
  currentTurn() {
90
- return this.currentTurnContext ?? this.activeAgentContext;
87
+ const id = this.currentTurnId ?? this.activeAgentId;
88
+ return id === null ? null : (this.pendingAsks.get(id) ?? null);
91
89
  }
92
90
  endTurn() {
93
- this.currentTurnContext = null;
91
+ this.currentTurnId = null;
94
92
  }
95
93
  endAgent() {
96
- this.currentTurnContext = null;
97
- this.activeAgentContext = null;
94
+ this.currentTurnId = null;
95
+ this.activeAgentId = null;
98
96
  }
99
97
  reset() {
100
98
  this.pendingAsks.clear();
101
- this.pendingTurnContexts.length = 0;
102
- this.currentTurnContext = null;
103
- this.activeAgentContext = null;
99
+ this.pendingTurnIds.length = 0;
100
+ this.endAgent();
104
101
  }
105
102
  expireSender(sessionId) {
106
- const expires = (context) => context.from.id === sessionId;
103
+ const expiring = new Set([...this.pendingAsks.values()]
104
+ .filter((context) => context.from.id === sessionId)
105
+ .map((context) => context.message.id));
107
106
  let expired = 0;
108
- for (const [messageId, context] of this.pendingAsks) {
109
- if (expires(context)) {
110
- this.pendingAsks.delete(messageId);
111
- expired += 1;
112
- }
107
+ for (const messageId of expiring) {
108
+ this.pendingAsks.delete(messageId);
109
+ expired += 1;
113
110
  }
114
- const beforeQueued = this.pendingTurnContexts.length;
115
- for (let index = this.pendingTurnContexts.length - 1; index >= 0; index -= 1) {
116
- const context = this.pendingTurnContexts[index];
117
- if (context !== undefined && expires(context)) {
118
- this.pendingTurnContexts.splice(index, 1);
111
+ const beforeQueued = this.pendingTurnIds.length;
112
+ for (let index = this.pendingTurnIds.length - 1; index >= 0; index -= 1) {
113
+ const id = this.pendingTurnIds[index];
114
+ if (id !== undefined && expiring.has(id)) {
115
+ this.pendingTurnIds.splice(index, 1);
119
116
  }
120
117
  }
121
- expired += beforeQueued - this.pendingTurnContexts.length;
122
- if (this.currentTurnContext && expires(this.currentTurnContext)) {
123
- this.currentTurnContext = null;
118
+ expired += beforeQueued - this.pendingTurnIds.length;
119
+ if (this.currentTurnId !== null && expiring.has(this.currentTurnId)) {
120
+ this.currentTurnId = null;
124
121
  expired += 1;
125
122
  }
126
- if (this.activeAgentContext && expires(this.activeAgentContext)) {
127
- this.activeAgentContext = null;
123
+ if (this.activeAgentId !== null && expiring.has(this.activeAgentId)) {
124
+ this.activeAgentId = null;
128
125
  expired += 1;
129
126
  }
130
127
  return expired;
@@ -132,26 +129,21 @@ export class ReplyTracker {
132
129
  resolveReplyTarget(options, now = Date.now()) {
133
130
  this.pruneExpired(now);
134
131
  const pending = Array.from(this.pendingAsks.values());
135
- const contexts = this.currentTurnContext
136
- ? [
137
- this.currentTurnContext,
138
- ...pending.filter((context) => context.message.id !== this.currentTurnContext?.message.id),
139
- ]
140
- : pending;
141
132
  const replyTo = options.replyTo ?? "";
142
133
  const to = options.to ?? "";
143
134
  if (replyTo !== "") {
144
- return explicitReply(contexts, replyTo, to);
135
+ return explicitReply(pending, replyTo, to);
145
136
  }
146
137
  if (to !== "") {
147
- return replyBySender(contexts, to);
138
+ return replyBySender(pending, to);
148
139
  }
149
140
  return this.implicitReplyTarget(pending);
150
141
  }
151
142
  /** Without an explicit target, the turn context wins, then a sole pending ask. */
152
143
  implicitReplyTarget(pending) {
153
- if (this.currentTurnContext) {
154
- return this.currentTurnContext;
144
+ const current = this.currentTurnId === null ? undefined : this.pendingAsks.get(this.currentTurnId);
145
+ if (current) {
146
+ return current;
155
147
  }
156
148
  const [solePending] = pending;
157
149
  if (pending.length === 1 && solePending !== undefined) {
@@ -170,22 +162,22 @@ export class ReplyTracker {
170
162
  return Array.from(this.pendingAsks.values()).sort((a, b) => a.receivedAt - b.receivedAt);
171
163
  }
172
164
  hasTurnContext(messageId) {
173
- return (this.pendingTurnContexts.some((context) => context.message.id === messageId) ||
174
- this.currentTurnContext?.message.id === messageId ||
175
- this.activeAgentContext?.message.id === messageId);
165
+ return (this.pendingTurnIds.includes(messageId) ||
166
+ this.currentTurnId === messageId ||
167
+ this.activeAgentId === messageId);
176
168
  }
177
169
  removeContext(messageId) {
178
170
  this.pendingAsks.delete(messageId);
179
- for (let index = this.pendingTurnContexts.length - 1; index >= 0; index -= 1) {
180
- if (this.pendingTurnContexts[index]?.message.id === messageId) {
181
- this.pendingTurnContexts.splice(index, 1);
171
+ for (let index = this.pendingTurnIds.length - 1; index >= 0; index -= 1) {
172
+ if (this.pendingTurnIds[index] === messageId) {
173
+ this.pendingTurnIds.splice(index, 1);
182
174
  }
183
175
  }
184
- if (this.currentTurnContext?.message.id === messageId) {
185
- this.currentTurnContext = null;
176
+ if (this.currentTurnId === messageId) {
177
+ this.currentTurnId = null;
186
178
  }
187
- if (this.activeAgentContext?.message.id === messageId) {
188
- this.activeAgentContext = null;
179
+ if (this.activeAgentId === messageId) {
180
+ this.activeAgentId = null;
189
181
  }
190
182
  }
191
183
  pruneExpired(now) {
@@ -46,6 +46,8 @@ export class IntercomSessionEvents {
46
46
  if (client?.isConnected() !== true ||
47
47
  !context ||
48
48
  context.message.expectsReply !== true ||
49
+ // A native turn can start without consuming its queued input after an abort.
50
+ journal.get(context.message.id) !== undefined ||
49
51
  error === null ||
50
52
  error === "") {
51
53
  return;
package/docs/intercom.md CHANGED
@@ -330,8 +330,12 @@ not an end-to-end exactly-once guarantee for arbitrary tool side effects.
330
330
 
331
331
  Idle recovery preserves admission order within the non-passive backlog and wakes
332
332
  once on its last item; passive breadcrumbs are appended before that waking work.
333
- The first eligible ask remains the implicit reply target, independently of which
334
- message triggers the turn. A busy recipient's steer is handed to Pi's native
333
+ The first unresolved eligible ask remains the implicit reply target, independently
334
+ of which message triggers the turn. Reply attribution refers to the unresolved
335
+ ask by ID. Placing delayed input does not revive an already replied, expired, or
336
+ retired reply obligation. A failed turn can retire only an ask whose input Pi has
337
+ consumed; queued input remains answerable after abort recovery.
338
+ A busy recipient's steer is handed to Pi's native
335
339
  queue and consumed at its next tool boundary; no extension probe asks another
336
340
  tool to give up the turn.
337
341
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-subagents",
3
- "version": "0.52.17",
3
+ "version": "0.52.18",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -185,7 +185,7 @@ export class InboundDeliveryOwner {
185
185
  const trigger = index === -1 ? undefined : rest.splice(index, 1)[0];
186
186
  const replyTarget = entries.find((entry) => entry.message.expectsReply === true) ?? trigger;
187
187
  if (replyTarget && this.journal.get(replyTarget.message.id) === replyTarget) {
188
- this.hooks.replies.queueTurnContext({ ...replyTarget, receivedAt: Date.now() });
188
+ this.hooks.replies.queueTurnContext(replyTarget.message.id);
189
189
  }
190
190
  for (const entry of rest) {
191
191
  if (!this.live(generation)) {
@@ -295,11 +295,7 @@ export class InboundDeliveryOwner {
295
295
  this.journal.markNative(entry.message.id);
296
296
  }
297
297
  if (delivery !== "passive") {
298
- this.hooks.replies.queueTurnContext({
299
- from: entry.from,
300
- message: entry.message,
301
- receivedAt: Date.now(),
302
- });
298
+ this.hooks.replies.queueTurnContext(entry.message.id);
303
299
  }
304
300
  }
305
301
  private nativeMessage(entry: InboundMessageEntry): Parameters<ExtensionAPI["sendMessage"]>[0] {
@@ -93,9 +93,9 @@ function replyBySender(contexts: readonly IntercomContext[], to: string): Interc
93
93
  }
94
94
  export class ReplyTracker {
95
95
  private readonly pendingAsks = new Map<string, IntercomContext>();
96
- private readonly pendingTurnContexts: IntercomContext[] = [];
97
- private currentTurnContext: IntercomContext | null = null;
98
- private activeAgentContext: IntercomContext | null = null;
96
+ private readonly pendingTurnIds: string[] = [];
97
+ private currentTurnId: string | null = null;
98
+ private activeAgentId: string | null = null;
99
99
  private readonly askTimeoutMs: number;
100
100
 
101
101
  constructor(askTimeoutMs = 10 * 60 * 1000) {
@@ -115,67 +115,66 @@ export class ReplyTracker {
115
115
  return context;
116
116
  }
117
117
 
118
- queueTurnContext(context: IntercomContext): void {
119
- if (context.message.expectsReply !== true) {
118
+ queueTurnContext(messageId: string): void {
119
+ if (!this.pendingAsks.has(messageId) || this.hasTurnContext(messageId)) {
120
120
  return;
121
121
  }
122
- if (this.hasTurnContext(context.message.id)) {
123
- return;
124
- }
125
- this.pendingTurnContexts.push(context);
122
+ this.pendingTurnIds.push(messageId);
126
123
  }
127
124
 
128
125
  beginTurn(now = Date.now()): void {
129
126
  this.pruneExpired(now);
130
- this.currentTurnContext = this.pendingTurnContexts.shift() ?? null;
131
- if (this.currentTurnContext) {
132
- this.activeAgentContext = this.currentTurnContext;
127
+ this.currentTurnId = this.pendingTurnIds.shift() ?? null;
128
+ if (this.currentTurnId !== null) {
129
+ this.activeAgentId = this.currentTurnId;
133
130
  }
134
131
  }
135
132
 
136
133
  currentTurn(): IntercomContext | null {
137
- return this.currentTurnContext ?? this.activeAgentContext;
134
+ const id = this.currentTurnId ?? this.activeAgentId;
135
+ return id === null ? null : (this.pendingAsks.get(id) ?? null);
138
136
  }
139
137
 
140
138
  endTurn(): void {
141
- this.currentTurnContext = null;
139
+ this.currentTurnId = null;
142
140
  }
143
141
 
144
142
  endAgent(): void {
145
- this.currentTurnContext = null;
146
- this.activeAgentContext = null;
143
+ this.currentTurnId = null;
144
+ this.activeAgentId = null;
147
145
  }
148
146
 
149
147
  reset(): void {
150
148
  this.pendingAsks.clear();
151
- this.pendingTurnContexts.length = 0;
152
- this.currentTurnContext = null;
153
- this.activeAgentContext = null;
149
+ this.pendingTurnIds.length = 0;
150
+ this.endAgent();
154
151
  }
155
152
 
156
153
  expireSender(sessionId: string): number {
157
- const expires = (context: IntercomContext) => context.from.id === sessionId;
154
+ const expiring = new Set(
155
+ [...this.pendingAsks.values()]
156
+ .filter((context) => context.from.id === sessionId)
157
+ .map((context) => context.message.id),
158
+ );
158
159
  let expired = 0;
159
- for (const [messageId, context] of this.pendingAsks) {
160
- if (expires(context)) {
161
- this.pendingAsks.delete(messageId);
162
- expired += 1;
163
- }
160
+ for (const messageId of expiring) {
161
+ this.pendingAsks.delete(messageId);
162
+ expired += 1;
164
163
  }
165
- const beforeQueued = this.pendingTurnContexts.length;
166
- for (let index = this.pendingTurnContexts.length - 1; index >= 0; index -= 1) {
167
- const context = this.pendingTurnContexts[index];
168
- if (context !== undefined && expires(context)) {
169
- this.pendingTurnContexts.splice(index, 1);
164
+ const beforeQueued = this.pendingTurnIds.length;
165
+ for (let index = this.pendingTurnIds.length - 1; index >= 0; index -= 1) {
166
+ const id = this.pendingTurnIds[index];
167
+ if (id !== undefined && expiring.has(id)) {
168
+ this.pendingTurnIds.splice(index, 1);
170
169
  }
171
170
  }
172
- expired += beforeQueued - this.pendingTurnContexts.length;
173
- if (this.currentTurnContext && expires(this.currentTurnContext)) {
174
- this.currentTurnContext = null;
171
+ expired += beforeQueued - this.pendingTurnIds.length;
172
+ if (this.currentTurnId !== null && expiring.has(this.currentTurnId)) {
173
+ this.currentTurnId = null;
175
174
  expired += 1;
176
175
  }
177
- if (this.activeAgentContext && expires(this.activeAgentContext)) {
178
- this.activeAgentContext = null;
176
+ if (this.activeAgentId !== null && expiring.has(this.activeAgentId)) {
177
+ this.activeAgentId = null;
179
178
  expired += 1;
180
179
  }
181
180
  return expired;
@@ -188,30 +187,23 @@ export class ReplyTracker {
188
187
  this.pruneExpired(now);
189
188
 
190
189
  const pending = Array.from(this.pendingAsks.values());
191
- const contexts = this.currentTurnContext
192
- ? [
193
- this.currentTurnContext,
194
- ...pending.filter(
195
- (context) => context.message.id !== this.currentTurnContext?.message.id,
196
- ),
197
- ]
198
- : pending;
199
-
200
190
  const replyTo = options.replyTo ?? "";
201
191
  const to = options.to ?? "";
202
192
  if (replyTo !== "") {
203
- return explicitReply(contexts, replyTo, to);
193
+ return explicitReply(pending, replyTo, to);
204
194
  }
205
195
  if (to !== "") {
206
- return replyBySender(contexts, to);
196
+ return replyBySender(pending, to);
207
197
  }
208
198
  return this.implicitReplyTarget(pending);
209
199
  }
210
200
 
211
201
  /** Without an explicit target, the turn context wins, then a sole pending ask. */
212
202
  private implicitReplyTarget(pending: readonly IntercomContext[]): IntercomContext {
213
- if (this.currentTurnContext) {
214
- return this.currentTurnContext;
203
+ const current =
204
+ this.currentTurnId === null ? undefined : this.pendingAsks.get(this.currentTurnId);
205
+ if (current) {
206
+ return current;
215
207
  }
216
208
  const [solePending] = pending;
217
209
  if (pending.length === 1 && solePending !== undefined) {
@@ -234,24 +226,24 @@ export class ReplyTracker {
234
226
 
235
227
  private hasTurnContext(messageId: string): boolean {
236
228
  return (
237
- this.pendingTurnContexts.some((context) => context.message.id === messageId) ||
238
- this.currentTurnContext?.message.id === messageId ||
239
- this.activeAgentContext?.message.id === messageId
229
+ this.pendingTurnIds.includes(messageId) ||
230
+ this.currentTurnId === messageId ||
231
+ this.activeAgentId === messageId
240
232
  );
241
233
  }
242
234
 
243
235
  private removeContext(messageId: string): void {
244
236
  this.pendingAsks.delete(messageId);
245
- for (let index = this.pendingTurnContexts.length - 1; index >= 0; index -= 1) {
246
- if (this.pendingTurnContexts[index]?.message.id === messageId) {
247
- this.pendingTurnContexts.splice(index, 1);
237
+ for (let index = this.pendingTurnIds.length - 1; index >= 0; index -= 1) {
238
+ if (this.pendingTurnIds[index] === messageId) {
239
+ this.pendingTurnIds.splice(index, 1);
248
240
  }
249
241
  }
250
- if (this.currentTurnContext?.message.id === messageId) {
251
- this.currentTurnContext = null;
242
+ if (this.currentTurnId === messageId) {
243
+ this.currentTurnId = null;
252
244
  }
253
- if (this.activeAgentContext?.message.id === messageId) {
254
- this.activeAgentContext = null;
245
+ if (this.activeAgentId === messageId) {
246
+ this.activeAgentId = null;
255
247
  }
256
248
  }
257
249
 
@@ -33,7 +33,7 @@ type Connection = Readonly<
33
33
  >
34
34
  >;
35
35
  type Journal = Readonly<
36
- Pick<InboundJournal, "clearNative" | "consume" | "reconcileConsumed" | "reset" | "retire">
36
+ Pick<InboundJournal, "clearNative" | "consume" | "get" | "reconcileConsumed" | "reset" | "retire">
37
37
  >;
38
38
  type ReplyWaitHandle = Readonly<Pick<ReplyWait, "reject">>;
39
39
  type InboundDeliveryHandle = Readonly<
@@ -101,6 +101,8 @@ export class IntercomSessionEvents {
101
101
  client?.isConnected() !== true ||
102
102
  !context ||
103
103
  context.message.expectsReply !== true ||
104
+ // A native turn can start without consuming its queued input after an abort.
105
+ journal.get(context.message.id) !== undefined ||
104
106
  error === null ||
105
107
  error === ""
106
108
  ) {