@fitchmultz/pi-subagents 0.52.18 → 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 (43) hide show
  1. package/CHANGELOG.md +34 -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/lifecycle.js +26 -26
  20. package/dist/shared/post-exit-stdio-guard.js +44 -37
  21. package/docs/development.md +25 -0
  22. package/docs/intercom.md +13 -0
  23. package/docs/reference.md +16 -5
  24. package/package.json +1 -1
  25. package/scripts/build.mjs +41 -14
  26. package/skills/pi-subagents/SKILL.md +8 -4
  27. package/src/agent/contracts.ts +17 -0
  28. package/src/agent/host.ts +19 -0
  29. package/src/agent/index.ts +91 -62
  30. package/src/agent/launch.ts +16 -3
  31. package/src/agent/notifications.ts +5 -5
  32. package/src/agent/parking.ts +39 -17
  33. package/src/agent/runner.ts +20 -21
  34. package/src/agent/tool-schemas.ts +2 -0
  35. package/src/agent/usage.ts +48 -3
  36. package/src/agent/view.ts +15 -13
  37. package/src/agent/wake.ts +19 -15
  38. package/src/pi-intercom/broker/broker.ts +2 -1
  39. package/src/pi-intercom/broker/snapshots.ts +82 -37
  40. package/src/pi-intercom/broker/spawn-claim.ts +98 -0
  41. package/src/pi-intercom/broker/spawn.ts +42 -82
  42. package/src/pi-intercom/lifecycle.ts +38 -27
  43. package/src/shared/post-exit-stdio-guard.ts +48 -40
@@ -7,6 +7,37 @@ import { isRecord } from "../shared/unknown.js";
7
7
  import { Assert } from "./typebox.js";
8
8
  import { contributionsSchema, resultSchema, } from "./contracts.js";
9
9
  import { read, runDirectory, write } from "./storage.js";
10
+ /** Pending repair remains authoritative until its native terminal is published and validated. */
11
+ export function accountingState(result) {
12
+ if (result?.accountingPending !== undefined) {
13
+ return { kind: "pending", pending: result.accountingPending, error: result.accountingError };
14
+ }
15
+ const terminalEntryId = result?.terminalEntryId;
16
+ if (result?.contributions !== undefined) {
17
+ return { kind: "verified", contributions: result.contributions, terminalEntryId };
18
+ }
19
+ return result?.accountingError === undefined
20
+ ? { kind: "unrecorded", terminalEntryId }
21
+ : { kind: "unavailable", error: result.accountingError, terminalEntryId };
22
+ }
23
+ export function accountingFields(state) {
24
+ const terminal = state.kind !== "pending" && state.terminalEntryId !== undefined
25
+ ? { terminalEntryId: state.terminalEntryId }
26
+ : {};
27
+ switch (state.kind) {
28
+ case "unrecorded":
29
+ return terminal;
30
+ case "verified":
31
+ return { contributions: state.contributions, ...terminal };
32
+ case "pending":
33
+ return {
34
+ accountingPending: state.pending,
35
+ ...(state.error === undefined ? {} : { accountingError: state.error }),
36
+ };
37
+ case "unavailable":
38
+ return { accountingError: state.error, ...terminal };
39
+ }
40
+ }
10
41
  /** Billing evidence of one native entry. Missing required usage is an error only outside a baseline. */
11
42
  function billing(entry) {
12
43
  if (entry.checkpoint === true) {
@@ -143,10 +174,11 @@ export function readResult(base, launch) {
143
174
  if (!result) {
144
175
  return;
145
176
  }
146
- const pending = result.accountingPending;
147
- if (pending === undefined) {
177
+ const accounting = accountingState(result);
178
+ if (accounting.kind !== "pending") {
148
179
  return result;
149
180
  }
181
+ const { pending } = accounting;
150
182
  let contributions;
151
183
  try {
152
184
  contributions = childUsage(launch, new Set(pending.baseline), pending.terminalCandidate);
@@ -156,7 +188,14 @@ export function readResult(base, launch) {
156
188
  return result;
157
189
  }
158
190
  const { accountingPending: _pending, accountingError: _error, ...execution } = result;
159
- const repaired = { ...execution, terminalEntryId: pending.terminalCandidate, contributions };
191
+ const repaired = {
192
+ ...execution,
193
+ ...accountingFields({
194
+ kind: "verified",
195
+ terminalEntryId: pending.terminalCandidate,
196
+ contributions,
197
+ }),
198
+ };
160
199
  write(path.join(runDirectory(base, launch.id), "result.json"), repaired);
161
200
  return repaired;
162
201
  }
@@ -5,25 +5,25 @@ import { actor, alive, observeAlive } from "./process.js";
5
5
  import { admissionSchema, commandSchema, exitSchema, successorSchema, wakeFailureSchema, } from "./contracts.js";
6
6
  import { conversation, read, runDirectory } from "./storage.js";
7
7
  import { published } from "./notifications.js";
8
- import { readCanonicalResult, readResult } from "./usage.js";
8
+ import { accountingFields, accountingState, readCanonicalResult, readResult } from "./usage.js";
9
9
  import { Type } from "./typebox.js";
10
10
  const commandMetadataSchema = Type.Pick(commandSchema, ["id", "runId", "action", "createdAt"]);
11
11
  function toolResult(value, text) {
12
12
  return { content: [{ type: "text", text }], details: value };
13
13
  }
14
14
  function receiptResult(result) {
15
- return result === undefined
16
- ? undefined
17
- : {
18
- id: result.id,
19
- owner: result.owner,
20
- status: result.status,
21
- endedAt: result.endedAt,
22
- terminalEntryId: result.terminalEntryId,
23
- error: result.error,
24
- accountingError: result.accountingError,
25
- accountingPending: result.accountingPending,
26
- };
15
+ if (result === undefined) {
16
+ return;
17
+ }
18
+ const { contributions: _contributions, ...accounting } = accountingFields(accountingState(result));
19
+ return {
20
+ id: result.id,
21
+ owner: result.owner,
22
+ status: result.status,
23
+ endedAt: result.endedAt,
24
+ error: result.error,
25
+ ...accounting,
26
+ };
27
27
  }
28
28
  /** `observed` holds batched liveness by actor token; any other actor gets a fresh check. */
29
29
  function processView(base, launch, observed) {
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import assert from "node:assert/strict";
2
3
  import { journalStamp } from "../shared/native-journal.js";
3
4
  import { missing } from "./storage.js";
4
5
  /** Identity and change stamp of a path; "" when absent. */
@@ -13,10 +14,10 @@ export function pathStamp(file) {
13
14
  }
14
15
  /** Native change hints for one directory identity; owners still reconcile canonical files. */
15
16
  export class DirectoryWatch {
16
- watcher;
17
- armed = "";
17
+ state = {
18
+ kind: "unarmed",
19
+ };
18
20
  checked = "";
19
- failed = false;
20
21
  directory;
21
22
  relevant;
22
23
  wake;
@@ -30,41 +31,41 @@ export class DirectoryWatch {
30
31
  const identity = stat?.isDirectory() === true ? `${stat.dev}:${stat.ino}` : "";
31
32
  const checked = this.checked;
32
33
  this.checked = stat === undefined ? "" : `${identity}:${stat.mtimeNs}:${stat.ctimeNs}`;
33
- if (this.watcher !== undefined && identity === this.armed) {
34
+ if (this.state.kind === "armed" && identity === this.state.identity) {
34
35
  return checked === this.checked ? "watching" : "changed";
35
36
  }
36
37
  // A replaced directory leaves an inode watch silent on Linux.
37
38
  this.close();
38
- return identity === "" || this.failed ? "unwatched" : this.arm(identity);
39
+ return identity === "" || this.state.kind === "polling" ? "unwatched" : this.arm(identity);
39
40
  }
40
41
  arm(identity) {
41
42
  try {
42
43
  const watcher = fs.watch(this.directory, { persistent: false }, (_event, file) => {
43
- if (watcher === this.watcher && this.relevant(file)) {
44
+ if (this.state.kind === "armed" && watcher === this.state.watcher && this.relevant(file)) {
44
45
  this.wake();
45
46
  }
46
47
  });
47
48
  watcher.on("error", () => {
48
- if (watcher === this.watcher) {
49
- this.failed = true;
50
- this.close();
49
+ if (this.state.kind === "armed" && watcher === this.state.watcher) {
50
+ watcher.close();
51
+ this.state = { kind: "polling" };
51
52
  this.wake();
52
53
  }
53
54
  });
54
- this.watcher = watcher;
55
- this.armed = identity;
55
+ this.state = { kind: "armed", watcher, identity };
56
56
  return "armed";
57
57
  }
58
58
  catch (error) {
59
59
  // Unsupported platforms and watch limits keep their owner's polling fallback.
60
- this.failed = !missing(error);
60
+ this.state = { kind: missing(error) ? "unarmed" : "polling" };
61
61
  return "unwatched";
62
62
  }
63
63
  }
64
64
  close() {
65
- this.watcher?.close();
66
- this.watcher = undefined;
67
- this.armed = "";
65
+ if (this.state.kind === "armed") {
66
+ this.state.watcher.close();
67
+ this.state = { kind: "unarmed" };
68
+ }
68
69
  }
69
70
  }
70
71
  /** A coalesced wake for one owner loop; raised until that loop takes it. */
@@ -82,6 +83,7 @@ export class Wake {
82
83
  }
83
84
  /** Returns at once for a wake raised since the last take. */
84
85
  async sleep(ms) {
86
+ assert.equal(this.resolve, undefined, "A Wake belongs to one waiting owner loop.");
85
87
  if (this.raised) {
86
88
  return;
87
89
  }
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.52.18","sha256":"83db07f9acbdd6ff5985c46e1f5f5411f68b6766697a2dfab019b3d00dbba1c1"});
1
+ export const EXTENSION_BUILD = Object.freeze({"version":"0.52.19","sha256":"a5cb823f7d9194659bb3c3e53c68e90032ef520a962758d896dccee78a3bd2c5"});
@@ -13,7 +13,7 @@ import { acceptBrokerConnection, registeredSessionInfo } from "./server-connecti
13
13
  import { guardedDeliveryError } from "./guard.js";
14
14
  import { compactTopicMessage, normalizeMessage, normalizeSessionInfo, } from "../types.js";
15
15
  import { errorMessage, isRecord } from "../../shared/unknown.js";
16
- import { messageSender, snapshotFrames, TopicSnapshot, publicationMessage, deliveryAcknowledgement, shouldReplaceDelivery, } from "./snapshots.js";
16
+ import { messageSender, snapshotFrames, TopicSnapshot, publicationMessage, preparedPublication, deliveryAcknowledgement, shouldReplaceDelivery, } from "./snapshots.js";
17
17
  const PID_PATH = join(getBrokerRuntimeDir(), "broker.pid");
18
18
  const REPLACE_DELIVERY_DELAY_MS = 1500;
19
19
  class IntercomBroker {
@@ -192,7 +192,7 @@ class IntercomBroker {
192
192
  ? session.info
193
193
  : session.topics.snapshot(session.info, prepared);
194
194
  const from = messageSender(nextInfo);
195
- const deliveries = this.planPublications(currentId, from, prepared.publication);
195
+ const deliveries = this.planPublications(currentId, from, preparedPublication(prepared));
196
196
  // Validate every frame before committing state or delivering publications.
197
197
  if (prepared.restore) {
198
198
  session.topics.validateRestore(session.info, prepared, requestId);
@@ -2,15 +2,33 @@ import { type SessionInfo, type TopicUpdate, type Message, type BrokerMessage, t
2
2
  /** Direct messages and old clients receive identity, not potentially large topic state. */
3
3
  export declare function messageSender(info: SessionInfo): SessionInfo;
4
4
  export declare function snapshotFrames(requestId: string, infos: readonly SessionInfo[], stream: boolean): BrokerMessage[];
5
- interface PreparedTopicChange {
6
- readonly topic?: TopicUpdate;
7
- readonly subscription?: TopicSubscription;
8
- readonly unsubscribe?: string;
9
- readonly publication?: TopicUpdate;
5
+ type PreparedTopicChange = {
6
+ readonly kind: "unchanged";
7
+ readonly restore: false;
8
+ } | {
9
+ readonly kind: "normalize";
10
+ readonly restore: true;
11
+ } | {
12
+ readonly kind: "publish";
13
+ readonly restore: false;
14
+ readonly topic: TopicUpdate;
15
+ readonly bytes: number;
16
+ } | {
17
+ readonly kind: "restore-topic";
18
+ readonly restore: true;
19
+ readonly topic: TopicUpdate;
20
+ readonly bytes: number;
21
+ } | {
22
+ readonly kind: "subscription";
10
23
  readonly restore: boolean;
11
- readonly changed: boolean;
24
+ readonly subscription: TopicSubscription;
12
25
  readonly bytes: number;
13
- }
26
+ } | {
27
+ readonly kind: "unsubscribe";
28
+ readonly restore: false;
29
+ readonly topic: string;
30
+ };
31
+ export declare function preparedPublication(prepared: PreparedTopicChange): TopicUpdate | undefined;
14
32
  /** Keep committed records indexed; restores validate only new bytes and cached envelope maxima. */
15
33
  export declare class TopicSnapshot {
16
34
  private readonly topics;
@@ -157,7 +157,26 @@ class SnapshotRecords {
157
157
  return values;
158
158
  }
159
159
  }
160
- const UNCHANGED = { restore: false, changed: false, bytes: 0 };
160
+ const UNCHANGED = { kind: "unchanged", restore: false };
161
+ function updatedTopic(prepared) {
162
+ return prepared.kind === "publish" || prepared.kind === "restore-topic" ? prepared : undefined;
163
+ }
164
+ function subscriptionKey(prepared) {
165
+ switch (prepared.kind) {
166
+ case "subscription":
167
+ return prepared.subscription.topic;
168
+ case "unsubscribe":
169
+ return prepared.topic;
170
+ case "unchanged":
171
+ case "normalize":
172
+ case "publish":
173
+ case "restore-topic":
174
+ return undefined;
175
+ }
176
+ }
177
+ export function preparedPublication(prepared) {
178
+ return prepared.kind === "publish" ? prepared.topic : undefined;
179
+ }
161
180
  /** Keep committed records indexed; restores validate only new bytes and cached envelope maxima. */
162
181
  export class TopicSnapshot {
163
182
  topics;
@@ -177,47 +196,53 @@ export class TopicSnapshot {
177
196
  throw new Error("Topic snapshots unavailable for this client");
178
197
  }
179
198
  const mutation = topicMutation(change);
180
- const base = { restore: change.action === "restore", changed: true, bytes: 0 };
181
199
  switch (mutation.kind) {
182
200
  case "publication":
183
- return this.preparePublication(mutation, base);
201
+ return this.preparePublication(mutation);
184
202
  case "subscription":
185
203
  return {
186
- ...base,
204
+ kind: "subscription",
205
+ restore: change.action === "restore",
187
206
  subscription: mutation.subscription,
188
207
  bytes: intercomMessageSizeBytes(mutation.subscription),
189
208
  };
190
209
  case "unsubscribe":
191
- return { ...base, unsubscribe: mutation.topic };
210
+ return { kind: "unsubscribe", restore: false, topic: mutation.topic };
192
211
  }
193
212
  }
194
- preparePublication(mutation, base) {
213
+ preparePublication(mutation) {
195
214
  const previous = this.topics.get(mutation.topic.topic);
196
215
  const update = publicationUpdate(mutation.topic, previous, mutation.publish);
197
216
  if (!mutation.publish && (previous?.revision ?? 0) >= update.revision) {
198
- return base;
217
+ return { kind: "normalize", restore: true };
199
218
  }
200
219
  return {
201
- ...base,
220
+ ...(mutation.publish
221
+ ? { kind: "publish", restore: false }
222
+ : { kind: "restore-topic", restore: true }),
202
223
  topic: update,
203
224
  bytes: intercomMessageSizeBytes(update),
204
- ...(mutation.publish ? { publication: update } : {}),
205
225
  };
206
226
  }
207
227
  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);
228
+ const topic = updatedTopic(prepared)?.topic;
229
+ const subscription = prepared.kind === "subscription" ? prepared.subscription : undefined;
230
+ const changed = prepared.kind !== "unchanged";
231
+ const topics = this.topics.valuesAfter(topic?.topic, topic, changed);
232
+ const subscriptions = this.subscriptions.valuesAfter(subscriptionKey(prepared), subscription, changed);
210
233
  return {
211
234
  ...info,
212
235
  ...(topics !== undefined ? { topics } : {}),
213
236
  ...(subscriptions !== undefined ? { subscriptions } : {}),
214
- ...(prepared.publication ? { lastIntercomActivity: Date.now() } : {}),
237
+ ...(preparedPublication(prepared) ? { lastIntercomActivity: Date.now() } : {}),
215
238
  };
216
239
  }
217
240
  validateRestore(info, prepared, requestId) {
218
241
  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);
242
+ const topic = updatedTopic(prepared);
243
+ const subscription = prepared.kind === "subscription" ? prepared : undefined;
244
+ const topicBytes = this.topics.maximumAfter(topic?.topic.topic, topic?.bytes ?? 0);
245
+ const subscriptionBytes = this.subscriptions.maximumAfter(subscription?.subscription.topic, subscription?.bytes ?? 0);
221
246
  for (const [field, bytes] of [
222
247
  ["topics", topicBytes],
223
248
  ["subscriptions", subscriptionBytes],
@@ -234,18 +259,24 @@ export class TopicSnapshot {
234
259
  }
235
260
  }
236
261
  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);
262
+ if (prepared.kind === "unchanged") {
263
+ return;
246
264
  }
247
- else if (prepared.unsubscribe !== undefined) {
248
- this.subscriptions.delete(prepared.unsubscribe);
265
+ this.topics.accept();
266
+ this.subscriptions.accept();
267
+ switch (prepared.kind) {
268
+ case "publish":
269
+ case "restore-topic":
270
+ this.topics.set(prepared.topic, prepared.bytes);
271
+ return;
272
+ case "subscription":
273
+ this.subscriptions.set(prepared.subscription, prepared.bytes);
274
+ return;
275
+ case "unsubscribe":
276
+ this.subscriptions.delete(prepared.topic);
277
+ return;
278
+ case "normalize":
279
+ return;
249
280
  }
250
281
  }
251
282
  }
@@ -0,0 +1,89 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { hasErrorCode } from "../../shared/unknown.js";
3
+ import { brokerPidRecord, isLinuxProcessReplaced } from "./pid.js";
4
+ function ownerState(fd) {
5
+ try {
6
+ const lines = readFileSync(fd, "utf8").split("\n");
7
+ const first = lines[0] ?? "";
8
+ const pid = Number(first);
9
+ if (!/^[1-9]\d*$/.test(first) || !Number.isSafeInteger(pid)) {
10
+ return "uncertain";
11
+ }
12
+ process.kill(pid, 0);
13
+ return isLinuxProcessReplaced(pid, lines.slice(2).join("\n")) ? "exited" : "live";
14
+ }
15
+ catch (error) {
16
+ return hasErrorCode(error, "ESRCH") ? "exited" : "uncertain";
17
+ }
18
+ }
19
+ /** The open descriptor pins the original inode until its own cleanup finishes. */
20
+ function removeOwned(file, fd) {
21
+ try {
22
+ const owned = fstatSync(fd, { bigint: true });
23
+ const current = lstatSync(file, { bigint: true, throwIfNoEntry: false });
24
+ if (current?.dev !== owned.dev || current.ino !== owned.ino) {
25
+ return false;
26
+ }
27
+ unlinkSync(file);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ function reclaimExited(file) {
35
+ let fd;
36
+ try {
37
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
38
+ }
39
+ catch (error) {
40
+ return hasErrorCode(error, "ENOENT");
41
+ }
42
+ try {
43
+ return ownerState(fd) === "exited" && removeOwned(file, fd);
44
+ }
45
+ finally {
46
+ closeSync(fd);
47
+ }
48
+ }
49
+ function publishClaim(file, fd) {
50
+ const release = () => {
51
+ try {
52
+ removeOwned(file, fd);
53
+ }
54
+ finally {
55
+ closeSync(fd);
56
+ }
57
+ };
58
+ try {
59
+ // Keep legacy PID/timestamp lines; qualified native identity can prove later PID reuse.
60
+ const record = brokerPidRecord();
61
+ const identity = record.slice(record.indexOf("\n") + 1);
62
+ writeFileSync(fd, `${process.pid}\n${Date.now()}\n${identity}`);
63
+ return release;
64
+ }
65
+ catch (error) {
66
+ release();
67
+ throw error;
68
+ }
69
+ }
70
+ /** An acquired claim returns its release operation; live or uncertain claims remain untouched. */
71
+ export function acquireSpawnClaim(file) {
72
+ for (let attempt = 0; attempt < 5; attempt++) {
73
+ let fd;
74
+ try {
75
+ fd = openSync(file, "wx", 0o600);
76
+ }
77
+ catch (error) {
78
+ if (!hasErrorCode(error, "EEXIST")) {
79
+ throw error;
80
+ }
81
+ if (reclaimExited(file)) {
82
+ continue;
83
+ }
84
+ return;
85
+ }
86
+ return publishClaim(file, fd);
87
+ }
88
+ return;
89
+ }
@@ -1,11 +1,12 @@
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";
6
6
  import { getPiAgentDir } from "../agent-dir.js";
7
7
  import { getBrokerRuntimeDir, getBrokerSocketPath, getPreviousBrokerSocketPaths, isOwnedBrokerSocket, prepareBrokerSocketPath, } from "./paths.js";
8
8
  import { isBrokerPidReused } from "./pid.js";
9
+ import { acquireSpawnClaim } from "./spawn-claim.js";
9
10
  import { errorMessage, hasErrorCode } from "../../shared/unknown.js";
10
11
  const EXTENSION_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
11
12
  const BROKER_PID = join(getBrokerRuntimeDir(), "broker.pid");
@@ -54,8 +55,8 @@ export async function spawnBrokerIfNeeded(brokerCommand, brokerArgs, warning = (
54
55
  if (await isBrokerRunning()) {
55
56
  return;
56
57
  }
57
- const ownsLock = acquireSpawnLock();
58
- if (!ownsLock) {
58
+ const release = acquireSpawnClaim(BROKER_SPAWN_LOCK);
59
+ if (release === undefined) {
59
60
  await waitForBroker();
60
61
  return;
61
62
  }
@@ -99,7 +100,7 @@ export async function spawnBrokerIfNeeded(brokerCommand, brokerArgs, warning = (
99
100
  });
100
101
  }
101
102
  finally {
102
- releaseSpawnLock();
103
+ release();
103
104
  }
104
105
  }
105
106
  export async function isBrokerRunning() {
@@ -111,28 +112,30 @@ export async function assertBrokerCanStart() {
111
112
  }
112
113
  await stopUnhealthyBrokerBeforeSpawn();
113
114
  }
114
- function readLivePid(pidPath) {
115
- if (!existsSync(pidPath)) {
116
- return null;
117
- }
115
+ function readBrokerOwner(pidPath) {
118
116
  let raw;
119
117
  try {
120
118
  raw = readFileSync(pidPath, "utf-8");
121
119
  }
122
- catch {
123
- return null;
120
+ catch (error) {
121
+ return hasErrorCode(error, "ENOENT")
122
+ ? { kind: "absent" }
123
+ : { kind: "uncertain", reason: `Cannot read broker PID record: ${errorMessage(error)}` };
124
124
  }
125
- const pid = parseInt(raw.trim(), 10);
126
- if (!Number.isFinite(pid) || pid <= 0) {
127
- return null;
125
+ const first = raw.trim().split("\n")[0] ?? "";
126
+ const pid = Number(first);
127
+ if (!/^[1-9]\d*$/.test(first) || !Number.isSafeInteger(pid)) {
128
+ return { kind: "uncertain", reason: "Broker PID record is invalid" };
128
129
  }
129
130
  try {
130
131
  process.kill(pid, 0);
131
132
  }
132
133
  catch (error) {
133
- return hasErrorCode(error, "EPERM") ? pid : null;
134
+ return hasErrorCode(error, "ESRCH")
135
+ ? { kind: "absent" }
136
+ : { kind: "uncertain", reason: `Cannot establish broker PID ${pid}: ${errorMessage(error)}` };
134
137
  }
135
- return isBrokerPidReused(pid, raw) ? null : pid;
138
+ return isBrokerPidReused(pid, raw) ? { kind: "absent" } : { kind: "live", pid };
136
139
  }
137
140
  async function previousBrokerWarning() {
138
141
  const previousSockets = getPreviousBrokerSocketPaths();
@@ -142,9 +145,10 @@ async function previousBrokerWarning() {
142
145
  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.";
143
146
  }
144
147
  }
145
- const previousPid = readLivePid(join(getPiAgentDir(), "intercom", "broker.pid"));
146
- if (previousPid !== null) {
147
- 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.`;
148
+ const previous = readBrokerOwner(join(getPiAgentDir(), "intercom", "broker.pid"));
149
+ if (previous.kind !== "absent") {
150
+ const owner = previous.kind === "live" ? `PID ${previous.pid}` : "with uncertain PID identity";
151
+ 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.`;
148
152
  }
149
153
  return undefined;
150
154
  }
@@ -152,11 +156,15 @@ export async function stopUnhealthyBrokerBeforeSpawn() {
152
156
  if (await checkSocketConnectable()) {
153
157
  return;
154
158
  }
155
- const pid = readLivePid(BROKER_PID);
156
- if (pid === null) {
157
- return;
159
+ const owner = readBrokerOwner(BROKER_PID);
160
+ switch (owner.kind) {
161
+ case "absent":
162
+ return;
163
+ case "live":
164
+ throw new Error(`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.`);
165
+ case "uncertain":
166
+ throw new Error(`${owner.reason}; refusing to spawn a second broker while ownership is uncertain.`);
158
167
  }
159
- throw new Error(`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.`);
160
168
  }
161
169
  async function checkSocketConnectable() {
162
170
  const socketPath = getBrokerSocketPath();
@@ -180,61 +188,6 @@ function checkOneSocket(brokerSocket) {
180
188
  timeout.unref();
181
189
  });
182
190
  }
183
- function acquireSpawnLock() {
184
- const maxRetries = 5;
185
- for (let attempt = 0; attempt < maxRetries; attempt++) {
186
- try {
187
- writeFileSync(BROKER_SPAWN_LOCK, `${process.pid}\n${Date.now()}\n`, { flag: "wx" });
188
- return true;
189
- }
190
- catch (error) {
191
- if (!hasErrorCode(error, "EEXIST")) {
192
- throw error;
193
- }
194
- if (isSpawnLockStale()) {
195
- releaseSpawnLock();
196
- continue;
197
- }
198
- return false;
199
- }
200
- }
201
- return false;
202
- }
203
- function isSpawnLockStale() {
204
- if (!existsSync(BROKER_SPAWN_LOCK)) {
205
- return false;
206
- }
207
- try {
208
- const [pidLine = "", createdAtLine = "0"] = readFileSync(BROKER_SPAWN_LOCK, "utf-8")
209
- .trim()
210
- .split("\n");
211
- const pid = Number.parseInt(pidLine, 10);
212
- const createdAt = Number.parseInt(createdAtLine, 10);
213
- const ageMs = Date.now() - createdAt;
214
- if (Number.isFinite(pid)) {
215
- try {
216
- process.kill(pid, 0);
217
- }
218
- catch {
219
- // The process that created the lock is gone.
220
- return true;
221
- }
222
- }
223
- return !Number.isFinite(createdAt) || ageMs > 10_000;
224
- }
225
- catch {
226
- // Unreadable lock contents are treated as stale so a new broker can start.
227
- return true;
228
- }
229
- }
230
- function releaseSpawnLock() {
231
- try {
232
- unlinkSync(BROKER_SPAWN_LOCK);
233
- }
234
- catch {
235
- // Another cleanup path may already have removed the lock.
236
- }
237
- }
238
191
  async function waitForBroker(timeoutMs = 5000) {
239
192
  const start = Date.now();
240
193
  while (Date.now() - start < timeoutMs) {
@@ -245,5 +198,5 @@ async function waitForBroker(timeoutMs = 5000) {
245
198
  // Polling waits between probes rather than starting an unbounded batch.
246
199
  await sleep(100);
247
200
  }
248
- throw new Error("Broker failed to start within timeout");
201
+ throw new Error(`Broker failed to start within timeout. Startup claim: ${BROKER_SPAWN_LOCK}. Live or uncertain owners are not reclaimed.`);
249
202
  }