@fitchmultz/pi-subagents 0.52.17 → 0.52.19

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 (49) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +4 -0
  3. package/dist/agent/host.js +12 -0
  4. package/dist/agent/index.js +70 -56
  5. package/dist/agent/launch.js +12 -3
  6. package/dist/agent/notifications.js +5 -5
  7. package/dist/agent/parking.js +26 -15
  8. package/dist/agent/runner.js +18 -16
  9. package/dist/agent/tool-schemas.js +2 -0
  10. package/dist/agent/usage.js +42 -3
  11. package/dist/agent/view.js +13 -13
  12. package/dist/agent/wake.js +17 -15
  13. package/dist/build-info.js +1 -1
  14. package/dist/pi-intercom/broker/broker.js +2 -2
  15. package/dist/pi-intercom/broker/snapshots.d.ts +25 -7
  16. package/dist/pi-intercom/broker/snapshots.js +56 -25
  17. package/dist/pi-intercom/broker/spawn-claim.js +89 -0
  18. package/dist/pi-intercom/broker/spawn.js +31 -78
  19. package/dist/pi-intercom/inbound-delivery.js +2 -6
  20. package/dist/pi-intercom/lifecycle.js +26 -26
  21. package/dist/pi-intercom/reply-tracker.js +47 -55
  22. package/dist/pi-intercom/session-events.js +2 -0
  23. package/dist/shared/post-exit-stdio-guard.js +44 -37
  24. package/docs/development.md +25 -0
  25. package/docs/intercom.md +19 -2
  26. package/docs/reference.md +16 -5
  27. package/package.json +1 -1
  28. package/scripts/build.mjs +41 -14
  29. package/skills/pi-subagents/SKILL.md +8 -4
  30. package/src/agent/contracts.ts +17 -0
  31. package/src/agent/host.ts +19 -0
  32. package/src/agent/index.ts +91 -62
  33. package/src/agent/launch.ts +16 -3
  34. package/src/agent/notifications.ts +5 -5
  35. package/src/agent/parking.ts +39 -17
  36. package/src/agent/runner.ts +20 -21
  37. package/src/agent/tool-schemas.ts +2 -0
  38. package/src/agent/usage.ts +48 -3
  39. package/src/agent/view.ts +15 -13
  40. package/src/agent/wake.ts +19 -15
  41. package/src/pi-intercom/broker/broker.ts +2 -1
  42. package/src/pi-intercom/broker/snapshots.ts +82 -37
  43. package/src/pi-intercom/broker/spawn-claim.ts +98 -0
  44. package/src/pi-intercom/broker/spawn.ts +42 -82
  45. package/src/pi-intercom/inbound-delivery.ts +2 -6
  46. package/src/pi-intercom/lifecycle.ts +38 -27
  47. package/src/pi-intercom/reply-tracker.ts +50 -58
  48. package/src/pi-intercom/session-events.ts +3 -1
  49. package/src/shared/post-exit-stdio-guard.ts +48 -40
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "child_process";
2
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs";
2
+ import { readFileSync } from "fs";
3
3
  import { join, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import net from "net";
@@ -12,6 +12,7 @@ import {
12
12
  prepareBrokerSocketPath,
13
13
  } from "./paths.ts";
14
14
  import { isBrokerPidReused } from "./pid.ts";
15
+ import { acquireSpawnClaim } from "./spawn-claim.ts";
15
16
  import { errorMessage, hasErrorCode } from "../../shared/unknown.ts";
16
17
 
17
18
  const EXTENSION_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
@@ -85,8 +86,8 @@ export async function spawnBrokerIfNeeded(
85
86
  return;
86
87
  }
87
88
 
88
- const ownsLock = acquireSpawnLock();
89
- if (!ownsLock) {
89
+ const release = acquireSpawnClaim(BROKER_SPAWN_LOCK);
90
+ if (release === undefined) {
90
91
  await waitForBroker();
91
92
  return;
92
93
  }
@@ -138,7 +139,7 @@ export async function spawnBrokerIfNeeded(
138
139
  });
139
140
  });
140
141
  } finally {
141
- releaseSpawnLock();
142
+ release();
142
143
  }
143
144
  }
144
145
 
@@ -153,26 +154,33 @@ export async function assertBrokerCanStart(): Promise<void> {
153
154
  await stopUnhealthyBrokerBeforeSpawn();
154
155
  }
155
156
 
156
- function readLivePid(pidPath: string): number | null {
157
- if (!existsSync(pidPath)) {
158
- return null;
159
- }
157
+ type BrokerOwner =
158
+ | { readonly kind: "absent" }
159
+ | { readonly kind: "live"; readonly pid: number }
160
+ | { readonly kind: "uncertain"; readonly reason: string };
161
+
162
+ function readBrokerOwner(pidPath: string): BrokerOwner {
160
163
  let raw: string;
161
164
  try {
162
165
  raw = readFileSync(pidPath, "utf-8");
163
- } catch {
164
- return null;
166
+ } catch (error) {
167
+ return hasErrorCode(error, "ENOENT")
168
+ ? { kind: "absent" }
169
+ : { kind: "uncertain", reason: `Cannot read broker PID record: ${errorMessage(error)}` };
165
170
  }
166
- const pid = parseInt(raw.trim(), 10);
167
- if (!Number.isFinite(pid) || pid <= 0) {
168
- return null;
171
+ const first = raw.trim().split("\n")[0] ?? "";
172
+ const pid = Number(first);
173
+ if (!/^[1-9]\d*$/.test(first) || !Number.isSafeInteger(pid)) {
174
+ return { kind: "uncertain", reason: "Broker PID record is invalid" };
169
175
  }
170
176
  try {
171
177
  process.kill(pid, 0);
172
178
  } catch (error) {
173
- return hasErrorCode(error, "EPERM") ? pid : null;
179
+ return hasErrorCode(error, "ESRCH")
180
+ ? { kind: "absent" }
181
+ : { kind: "uncertain", reason: `Cannot establish broker PID ${pid}: ${errorMessage(error)}` };
174
182
  }
175
- return isBrokerPidReused(pid, raw) ? null : pid;
183
+ return isBrokerPidReused(pid, raw) ? { kind: "absent" } : { kind: "live", pid };
176
184
  }
177
185
 
178
186
  async function previousBrokerWarning(): Promise<string | undefined> {
@@ -183,9 +191,10 @@ async function previousBrokerWarning(): Promise<string | undefined> {
183
191
  return "An older agent-directory Intercom broker is still running. Its peers are not part of shared same-user discovery until they reload with the updated extension. Existing sessions and accepted work are untouched; no legacy fallback is used.";
184
192
  }
185
193
  }
186
- const previousPid = readLivePid(join(getPiAgentDir(), "intercom", "broker.pid"));
187
- if (previousPid !== null) {
188
- return `Older Intercom broker PID ${previousPid} may still own accepted work. Its peers need to reload with the updated extension to join shared same-user discovery. Nothing was stopped or replaced; no legacy fallback is used.`;
194
+ const previous = readBrokerOwner(join(getPiAgentDir(), "intercom", "broker.pid"));
195
+ if (previous.kind !== "absent") {
196
+ const owner = previous.kind === "live" ? `PID ${previous.pid}` : "with uncertain PID identity";
197
+ return `Older Intercom broker ${owner} may still own accepted work. Its peers need to reload with the updated extension to join shared same-user discovery. Nothing was stopped or replaced; no legacy fallback is used.`;
189
198
  }
190
199
  return undefined;
191
200
  }
@@ -194,13 +203,19 @@ export async function stopUnhealthyBrokerBeforeSpawn(): Promise<void> {
194
203
  if (await checkSocketConnectable()) {
195
204
  return;
196
205
  }
197
- const pid = readLivePid(BROKER_PID);
198
- if (pid === null) {
199
- return;
206
+ const owner = readBrokerOwner(BROKER_PID);
207
+ switch (owner.kind) {
208
+ case "absent":
209
+ return;
210
+ case "live":
211
+ throw new Error(
212
+ `Intercom broker PID ${owner.pid} may still own accepted work but its socket is unhealthy; refusing to spawn a second broker. Let the broker exit normally, then retry.`,
213
+ );
214
+ case "uncertain":
215
+ throw new Error(
216
+ `${owner.reason}; refusing to spawn a second broker while ownership is uncertain.`,
217
+ );
200
218
  }
201
- throw new Error(
202
- `Intercom broker PID ${pid} may still own accepted work but its socket is unhealthy; refusing to spawn a second broker. Let the broker exit normally, then retry.`,
203
- );
204
219
  }
205
220
 
206
221
  async function checkSocketConnectable(): Promise<boolean> {
@@ -227,63 +242,6 @@ function checkOneSocket(brokerSocket: string): Promise<boolean> {
227
242
  });
228
243
  }
229
244
 
230
- function acquireSpawnLock(): boolean {
231
- const maxRetries = 5;
232
- for (let attempt = 0; attempt < maxRetries; attempt++) {
233
- try {
234
- writeFileSync(BROKER_SPAWN_LOCK, `${process.pid}\n${Date.now()}\n`, { flag: "wx" });
235
- return true;
236
- } catch (error) {
237
- if (!hasErrorCode(error, "EEXIST")) {
238
- throw error;
239
- }
240
- if (isSpawnLockStale()) {
241
- releaseSpawnLock();
242
- continue;
243
- }
244
- return false;
245
- }
246
- }
247
- return false;
248
- }
249
-
250
- function isSpawnLockStale(): boolean {
251
- if (!existsSync(BROKER_SPAWN_LOCK)) {
252
- return false;
253
- }
254
-
255
- try {
256
- const [pidLine = "", createdAtLine = "0"] = readFileSync(BROKER_SPAWN_LOCK, "utf-8")
257
- .trim()
258
- .split("\n");
259
- const pid = Number.parseInt(pidLine, 10);
260
- const createdAt = Number.parseInt(createdAtLine, 10);
261
- const ageMs = Date.now() - createdAt;
262
-
263
- if (Number.isFinite(pid)) {
264
- try {
265
- process.kill(pid, 0);
266
- } catch {
267
- // The process that created the lock is gone.
268
- return true;
269
- }
270
- }
271
-
272
- return !Number.isFinite(createdAt) || ageMs > 10_000;
273
- } catch {
274
- // Unreadable lock contents are treated as stale so a new broker can start.
275
- return true;
276
- }
277
- }
278
-
279
- function releaseSpawnLock(): void {
280
- try {
281
- unlinkSync(BROKER_SPAWN_LOCK);
282
- } catch {
283
- // Another cleanup path may already have removed the lock.
284
- }
285
- }
286
-
287
245
  async function waitForBroker(timeoutMs = 5000): Promise<void> {
288
246
  const start = Date.now();
289
247
  while (Date.now() - start < timeoutMs) {
@@ -294,5 +252,7 @@ async function waitForBroker(timeoutMs = 5000): Promise<void> {
294
252
  // Polling waits between probes rather than starting an unbounded batch.
295
253
  await sleep(100);
296
254
  }
297
- throw new Error("Broker failed to start within timeout");
255
+ throw new Error(
256
+ `Broker failed to start within timeout. Startup claim: ${BROKER_SPAWN_LOCK}. Live or uncertain owners are not reclaimed.`,
257
+ );
298
258
  }
@@ -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] {
@@ -24,14 +24,21 @@ type ContextSnapshot = Readonly<Record<keyof ContextTarget, string | null>> &
24
24
  readonly guardedDelivery: 1 | null;
25
25
  };
26
26
 
27
+ type ContextState =
28
+ | { readonly kind: "inactive"; readonly epoch: number }
29
+ | { readonly kind: "invalidated"; readonly epoch: number; readonly sessionId: string }
30
+ | {
31
+ readonly kind: "live";
32
+ readonly epoch: number;
33
+ readonly context: ExtensionContext;
34
+ readonly sessionId: string;
35
+ readonly incarnation: string;
36
+ };
37
+
27
38
  /** Owns native context validity and occupancy, never journal or socket state. */
28
39
  export class IntercomLifecycle {
29
- private context: ExtensionContext | null = null;
30
- private sessionId: string | null = null;
40
+ private state: ContextState = { kind: "inactive", epoch: 0 };
31
41
  private model = "unknown";
32
- private disposed = true;
33
- private epoch = 0;
34
- private incarnation: string | null = null;
35
42
  private peerId: string | null = null;
36
43
  private guardedDelivery = false;
37
44
  private applicationGuard: string | undefined;
@@ -47,10 +54,10 @@ export class IntercomLifecycle {
47
54
  this.lineage = lineage;
48
55
  }
49
56
  get generation(): number {
50
- return this.epoch;
57
+ return this.state.epoch;
51
58
  }
52
59
  get currentSessionId(): string | null {
53
- return this.sessionId;
60
+ return this.state.kind === "inactive" ? null : this.state.sessionId;
54
61
  }
55
62
  get toolCount(): number {
56
63
  return this.tools.size;
@@ -88,7 +95,7 @@ export class IntercomLifecycle {
88
95
  request.respond(this.snapshot());
89
96
  }
90
97
  snapshot(): ContextSnapshot {
91
- if (!this.live()) {
98
+ if (this.state.kind !== "live" || !this.live()) {
92
99
  return {
93
100
  version: 1,
94
101
  rootSessionId: null,
@@ -100,10 +107,10 @@ export class IntercomLifecycle {
100
107
  }
101
108
  return {
102
109
  version: 1,
103
- rootSessionId: this.lineage?.rootSessionId ?? this.sessionId,
110
+ rootSessionId: this.lineage?.rootSessionId ?? this.state.sessionId,
104
111
  parentSessionId: this.lineage?.parentSessionId,
105
112
  peerId: this.peerId,
106
- incarnation: this.incarnation,
113
+ incarnation: this.state.incarnation,
107
114
  connected: this.peerId !== null,
108
115
  guardedDelivery: this.guardedDelivery ? 1 : null,
109
116
  applicationGuard: this.applicationGuard,
@@ -135,39 +142,43 @@ export class IntercomLifecycle {
135
142
  );
136
143
  }
137
144
  start(ctx: ExtensionContext): number {
138
- this.disposed = false;
139
- this.epoch += 1;
140
- this.context = ctx;
141
- this.sessionId = ctx.sessionManager.getSessionId();
142
- this.incarnation = randomUUID();
145
+ this.state = {
146
+ kind: "live",
147
+ epoch: this.state.epoch + 1,
148
+ context: ctx,
149
+ sessionId: ctx.sessionManager.getSessionId(),
150
+ incarnation: randomUUID(),
151
+ };
143
152
  this.applicationGuard = undefined;
144
153
  this.model = ctx.model?.id ?? "unknown";
145
154
  this.running = false;
146
155
  this.activity = 0;
147
156
  this.tools.clear();
148
157
  this.transport(null);
149
- return this.epoch;
158
+ return this.state.epoch;
150
159
  }
151
160
  invalidate(): void {
152
- this.disposed = true;
153
- this.epoch += 1;
161
+ const epoch = this.state.epoch + 1;
162
+ this.state =
163
+ this.state.kind === "inactive"
164
+ ? { kind: "inactive", epoch }
165
+ : { kind: "invalidated", epoch, sessionId: this.state.sessionId };
154
166
  this.running = false;
155
167
  this.tools.clear();
156
168
  this.publishContext();
157
169
  }
158
170
  clear(): void {
159
- this.context = null;
160
- this.sessionId = null;
171
+ this.state = { kind: "inactive", epoch: this.state.epoch };
161
172
  }
162
173
  live(
163
- ctx: ExtensionContext | null = this.context,
164
- generation = this.epoch,
174
+ ctx: ExtensionContext | null = this.state.kind === "live" ? this.state.context : null,
175
+ generation = this.state.epoch,
165
176
  ): ExtensionContext | null {
166
- if (this.disposed || generation !== this.epoch || !ctx) {
177
+ if (this.state.kind !== "live" || generation !== this.state.epoch || !ctx) {
167
178
  return null;
168
179
  }
169
180
  try {
170
- if (this.sessionId !== null && ctx.sessionManager.getSessionId() !== this.sessionId) {
181
+ if (ctx.sessionManager.getSessionId() !== this.state.sessionId) {
171
182
  return null;
172
183
  }
173
184
  // Reading a native property detects invalidated contexts without modifying them.
@@ -240,12 +251,12 @@ export class IntercomLifecycle {
240
251
  }
241
252
  async registration(pendingAsks: number): Promise<Omit<SessionInfo, "id">> {
242
253
  const live = this.live();
243
- if (!live || this.sessionId === null) {
254
+ if (!live || this.state.kind !== "live") {
244
255
  throw new Error("Intercom runtime not initialized");
245
256
  }
246
257
  return {
247
- ...this.identity(this.sessionId),
248
- incarnation: this.incarnation ?? undefined,
258
+ ...this.identity(this.state.sessionId),
259
+ incarnation: this.state.incarnation,
249
260
  guardedDelivery: 1,
250
261
  applicationGuard: this.applicationGuard,
251
262
  cwd: live.cwd,
@@ -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
  ) {
@@ -19,6 +19,7 @@ interface ChildWithPipedStdio {
19
19
  readonly stdout: ChildProcess["stdout"];
20
20
  readonly stderr: ChildProcess["stderr"];
21
21
  readonly on: ChildProcess["on"];
22
+ readonly off: ChildProcess["off"];
22
23
  }
23
24
 
24
25
  interface ChildWithKill {
@@ -356,13 +357,17 @@ export function attachChildProcessLifecycle(
356
357
  }
357
358
 
358
359
  function attachPostExitStdioGuard(child: ChildWithPipedStdio): () => void {
359
- let exited = false;
360
+ let state:
361
+ | { readonly kind: "waiting" }
362
+ | { readonly kind: "draining"; idle: NodeJS.Timeout | undefined; readonly hard: NodeJS.Timeout }
363
+ | { readonly kind: "closed" } = { kind: "waiting" };
360
364
  let stdoutEnded = false;
361
365
  let stderrEnded = false;
362
- let idleTimer: NodeJS.Timeout | undefined;
363
- let hardTimer: NodeJS.Timeout | undefined;
364
366
 
365
367
  const destroyUnendedStdio = () => {
368
+ if (state.kind !== "draining") {
369
+ return;
370
+ }
366
371
  if (!stdoutEnded) {
367
372
  try {
368
373
  child.stdout?.destroy();
@@ -377,55 +382,58 @@ function attachPostExitStdioGuard(child: ChildWithPipedStdio): () => void {
377
382
  // The process has exited; failed pipe destruction must not prevent other cleanup.
378
383
  }
379
384
  }
380
- };
381
-
382
- const clearTimers = () => {
383
- if (idleTimer) {
384
- clearTimeout(idleTimer);
385
- idleTimer = undefined;
386
- }
387
- if (hardTimer) {
388
- clearTimeout(hardTimer);
389
- hardTimer = undefined;
390
- }
385
+ cleanup();
391
386
  };
392
387
 
393
388
  const armIdleTimer = () => {
394
- if (!exited) {
389
+ if (state.kind !== "draining") {
395
390
  return;
396
391
  }
397
- if (idleTimer) {
398
- clearTimeout(idleTimer);
399
- }
400
- idleTimer = setTimeout(destroyUnendedStdio, 2000);
401
- idleTimer.unref();
392
+ clearTimeout(state.idle);
393
+ state.idle = setTimeout(destroyUnendedStdio, 2000);
394
+ state.idle.unref();
402
395
  };
403
-
404
- child.stdout?.on("data", armIdleTimer);
405
- child.stderr?.on("data", armIdleTimer);
406
- child.stdout?.on("end", () => {
396
+ const stdoutEnd = () => {
407
397
  stdoutEnded = true;
408
398
  if (stderrEnded) {
409
- clearTimers();
399
+ cleanup();
410
400
  }
411
- });
412
- child.stderr?.on("end", () => {
401
+ };
402
+ const stderrEnd = () => {
413
403
  stderrEnded = true;
414
404
  if (stdoutEnded) {
415
- clearTimers();
405
+ cleanup();
416
406
  }
417
- });
418
- child.on("exit", () => {
419
- exited = true;
420
- armIdleTimer();
421
- if (hardTimer) {
407
+ };
408
+ const exited = () => {
409
+ if (state.kind !== "waiting") {
422
410
  return;
423
411
  }
424
- hardTimer = setTimeout(destroyUnendedStdio, 8000);
425
- hardTimer.unref();
426
- });
427
- child.on("close", clearTimers);
428
- child.on("error", clearTimers);
429
-
430
- return clearTimers;
412
+ const hard = setTimeout(destroyUnendedStdio, 8000);
413
+ hard.unref();
414
+ state = { kind: "draining", idle: undefined, hard };
415
+ armIdleTimer();
416
+ };
417
+ function cleanup(): void {
418
+ if (state.kind === "draining") {
419
+ clearTimeout(state.idle);
420
+ clearTimeout(state.hard);
421
+ }
422
+ state = { kind: "closed" };
423
+ child.stdout?.off("data", armIdleTimer);
424
+ child.stderr?.off("data", armIdleTimer);
425
+ child.stdout?.off("end", stdoutEnd);
426
+ child.stderr?.off("end", stderrEnd);
427
+ child.off("exit", exited);
428
+ child.off("close", cleanup);
429
+ child.off("error", cleanup);
430
+ }
431
+ child.stdout?.on("data", armIdleTimer);
432
+ child.stderr?.on("data", armIdleTimer);
433
+ child.stdout?.on("end", stdoutEnd);
434
+ child.stderr?.on("end", stderrEnd);
435
+ child.on("exit", exited);
436
+ child.on("close", cleanup);
437
+ child.on("error", cleanup);
438
+ return cleanup;
431
439
  }