@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
@@ -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) {
@@ -8,12 +8,8 @@ export function isContextRequest(value) {
8
8
  }
9
9
  /** Owns native context validity and occupancy, never journal or socket state. */
10
10
  export class IntercomLifecycle {
11
- context = null;
12
- sessionId = null;
11
+ state = { kind: "inactive", epoch: 0 };
13
12
  model = "unknown";
14
- disposed = true;
15
- epoch = 0;
16
- incarnation = null;
17
13
  peerId = null;
18
14
  guardedDelivery = false;
19
15
  applicationGuard;
@@ -29,10 +25,10 @@ export class IntercomLifecycle {
29
25
  this.lineage = lineage;
30
26
  }
31
27
  get generation() {
32
- return this.epoch;
28
+ return this.state.epoch;
33
29
  }
34
30
  get currentSessionId() {
35
- return this.sessionId;
31
+ return this.state.kind === "inactive" ? null : this.state.sessionId;
36
32
  }
37
33
  get toolCount() {
38
34
  return this.tools.size;
@@ -64,7 +60,7 @@ export class IntercomLifecycle {
64
60
  request.respond(this.snapshot());
65
61
  }
66
62
  snapshot() {
67
- if (!this.live()) {
63
+ if (this.state.kind !== "live" || !this.live()) {
68
64
  return {
69
65
  version: 1,
70
66
  rootSessionId: null,
@@ -76,10 +72,10 @@ export class IntercomLifecycle {
76
72
  }
77
73
  return {
78
74
  version: 1,
79
- rootSessionId: this.lineage?.rootSessionId ?? this.sessionId,
75
+ rootSessionId: this.lineage?.rootSessionId ?? this.state.sessionId,
80
76
  parentSessionId: this.lineage?.parentSessionId,
81
77
  peerId: this.peerId,
82
- incarnation: this.incarnation,
78
+ incarnation: this.state.incarnation,
83
79
  connected: this.peerId !== null,
84
80
  guardedDelivery: this.guardedDelivery ? 1 : null,
85
81
  applicationGuard: this.applicationGuard,
@@ -107,36 +103,40 @@ export class IntercomLifecycle {
107
103
  this.applicationGuard === guard.applicationGuard);
108
104
  }
109
105
  start(ctx) {
110
- this.disposed = false;
111
- this.epoch += 1;
112
- this.context = ctx;
113
- this.sessionId = ctx.sessionManager.getSessionId();
114
- this.incarnation = randomUUID();
106
+ this.state = {
107
+ kind: "live",
108
+ epoch: this.state.epoch + 1,
109
+ context: ctx,
110
+ sessionId: ctx.sessionManager.getSessionId(),
111
+ incarnation: randomUUID(),
112
+ };
115
113
  this.applicationGuard = undefined;
116
114
  this.model = ctx.model?.id ?? "unknown";
117
115
  this.running = false;
118
116
  this.activity = 0;
119
117
  this.tools.clear();
120
118
  this.transport(null);
121
- return this.epoch;
119
+ return this.state.epoch;
122
120
  }
123
121
  invalidate() {
124
- this.disposed = true;
125
- this.epoch += 1;
122
+ const epoch = this.state.epoch + 1;
123
+ this.state =
124
+ this.state.kind === "inactive"
125
+ ? { kind: "inactive", epoch }
126
+ : { kind: "invalidated", epoch, sessionId: this.state.sessionId };
126
127
  this.running = false;
127
128
  this.tools.clear();
128
129
  this.publishContext();
129
130
  }
130
131
  clear() {
131
- this.context = null;
132
- this.sessionId = null;
132
+ this.state = { kind: "inactive", epoch: this.state.epoch };
133
133
  }
134
- live(ctx = this.context, generation = this.epoch) {
135
- if (this.disposed || generation !== this.epoch || !ctx) {
134
+ live(ctx = this.state.kind === "live" ? this.state.context : null, generation = this.state.epoch) {
135
+ if (this.state.kind !== "live" || generation !== this.state.epoch || !ctx) {
136
136
  return null;
137
137
  }
138
138
  try {
139
- if (this.sessionId !== null && ctx.sessionManager.getSessionId() !== this.sessionId) {
139
+ if (ctx.sessionManager.getSessionId() !== this.state.sessionId) {
140
140
  return null;
141
141
  }
142
142
  // Reading a native property detects invalidated contexts without modifying them.
@@ -209,12 +209,12 @@ export class IntercomLifecycle {
209
209
  }
210
210
  async registration(pendingAsks) {
211
211
  const live = this.live();
212
- if (!live || this.sessionId === null) {
212
+ if (!live || this.state.kind !== "live") {
213
213
  throw new Error("Intercom runtime not initialized");
214
214
  }
215
215
  return {
216
- ...this.identity(this.sessionId),
217
- incarnation: this.incarnation ?? undefined,
216
+ ...this.identity(this.state.sessionId),
217
+ incarnation: this.state.incarnation,
218
218
  guardedDelivery: 1,
219
219
  applicationGuard: this.applicationGuard,
220
220
  cwd: live.cwd,
@@ -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;
@@ -280,12 +280,13 @@ export function attachChildProcessLifecycle(child) {
280
280
  return new OwnedChildLifecycle(child);
281
281
  }
282
282
  function attachPostExitStdioGuard(child) {
283
- let exited = false;
283
+ let state = { kind: "waiting" };
284
284
  let stdoutEnded = false;
285
285
  let stderrEnded = false;
286
- let idleTimer;
287
- let hardTimer;
288
286
  const destroyUnendedStdio = () => {
287
+ if (state.kind !== "draining") {
288
+ return;
289
+ }
289
290
  if (!stdoutEnded) {
290
291
  try {
291
292
  child.stdout?.destroy();
@@ -302,51 +303,57 @@ function attachPostExitStdioGuard(child) {
302
303
  // The process has exited; failed pipe destruction must not prevent other cleanup.
303
304
  }
304
305
  }
305
- };
306
- const clearTimers = () => {
307
- if (idleTimer) {
308
- clearTimeout(idleTimer);
309
- idleTimer = undefined;
310
- }
311
- if (hardTimer) {
312
- clearTimeout(hardTimer);
313
- hardTimer = undefined;
314
- }
306
+ cleanup();
315
307
  };
316
308
  const armIdleTimer = () => {
317
- if (!exited) {
309
+ if (state.kind !== "draining") {
318
310
  return;
319
311
  }
320
- if (idleTimer) {
321
- clearTimeout(idleTimer);
322
- }
323
- idleTimer = setTimeout(destroyUnendedStdio, 2000);
324
- idleTimer.unref();
312
+ clearTimeout(state.idle);
313
+ state.idle = setTimeout(destroyUnendedStdio, 2000);
314
+ state.idle.unref();
325
315
  };
326
- child.stdout?.on("data", armIdleTimer);
327
- child.stderr?.on("data", armIdleTimer);
328
- child.stdout?.on("end", () => {
316
+ const stdoutEnd = () => {
329
317
  stdoutEnded = true;
330
318
  if (stderrEnded) {
331
- clearTimers();
319
+ cleanup();
332
320
  }
333
- });
334
- child.stderr?.on("end", () => {
321
+ };
322
+ const stderrEnd = () => {
335
323
  stderrEnded = true;
336
324
  if (stdoutEnded) {
337
- clearTimers();
325
+ cleanup();
338
326
  }
339
- });
340
- child.on("exit", () => {
341
- exited = true;
342
- armIdleTimer();
343
- if (hardTimer) {
327
+ };
328
+ const exited = () => {
329
+ if (state.kind !== "waiting") {
344
330
  return;
345
331
  }
346
- hardTimer = setTimeout(destroyUnendedStdio, 8000);
347
- hardTimer.unref();
348
- });
349
- child.on("close", clearTimers);
350
- child.on("error", clearTimers);
351
- return clearTimers;
332
+ const hard = setTimeout(destroyUnendedStdio, 8000);
333
+ hard.unref();
334
+ state = { kind: "draining", idle: undefined, hard };
335
+ armIdleTimer();
336
+ };
337
+ function cleanup() {
338
+ if (state.kind === "draining") {
339
+ clearTimeout(state.idle);
340
+ clearTimeout(state.hard);
341
+ }
342
+ state = { kind: "closed" };
343
+ child.stdout?.off("data", armIdleTimer);
344
+ child.stderr?.off("data", armIdleTimer);
345
+ child.stdout?.off("end", stdoutEnd);
346
+ child.stderr?.off("end", stderrEnd);
347
+ child.off("exit", exited);
348
+ child.off("close", cleanup);
349
+ child.off("error", cleanup);
350
+ }
351
+ child.stdout?.on("data", armIdleTimer);
352
+ child.stderr?.on("data", armIdleTimer);
353
+ child.stdout?.on("end", stdoutEnd);
354
+ child.stderr?.on("end", stderrEnd);
355
+ child.on("exit", exited);
356
+ child.on("close", cleanup);
357
+ child.on("error", cleanup);
358
+ return cleanup;
352
359
  }
@@ -23,6 +23,12 @@ Run narrow owner-boundary tests locally through `scripts/run-tests.mjs`. Full
23
23
  `npm run ci` belongs in hosted CI and includes package/install smokes and the
24
24
  complete suites. Keep the strict Oxlint/Oxfmt rules; see [code quality](code-quality.md).
25
25
 
26
+ Builds compile into process-owned staging before replacing `dist`. If a publisher
27
+ exits after retiring the previous build, later builds preserve its complete
28
+ `.previous` tree until a new complete build publishes. A failed build reports
29
+ the retained recovery paths. Recovery never promotes an older backup into a
30
+ concurrent publisher's retirement gap or reaps live or uncertain owners.
31
+
26
32
  ## Native Android/Termux
27
33
 
28
34
  Use app-private storage, not Android's shared `/sdcard` filesystem. No Termux app
@@ -80,6 +86,25 @@ npm and attached to the matching GitHub release.
80
86
 
81
87
  ## Isolation and safety
82
88
 
89
+ Parent attachment keeps one binding through detached, waiting, attaching and
90
+ attached phases. The existing shutdown signal closes the owner. Managed
91
+ readiness can attach only its own still-current waiting binding; shutdown joins
92
+ admitted publication before releasing the matching generation.
93
+
94
+ Parking returns resident, exit-with-parked-authority or exit-after-failed-save.
95
+ Both exit outcomes have disposed the native runtime and must exit the actor;
96
+ neither permits a second ordinary disposal. Restore tracks unopened, opening
97
+ and adopted phases, keeping the exact saved cut with the phase that owns it.
98
+
99
+ Directory watches distinguish unarmed, armed-to-one-inode and polling-only
100
+ availability. Missing paths can be armed later; unsupported or failed native
101
+ watches keep the existing polling fallback. Each coalesced Wake has one waiting
102
+ owner loop.
103
+
104
+ Smoke child cleanup treats leader exit and pipe closure separately. Pipe draining
105
+ has waiting, draining and closed phases. Closing it cancels its deadlines and
106
+ detaches its listeners, so later events cannot restart cleanup.
107
+
83
108
  Every test/smoke broker uses a short, explicit `PI_INTERCOM_RUNTIME_DIR` owned by
84
109
  the fixture. Do not touch `/tmp/pi-intercom-<uid>` to test namespace behavior on a
85
110
  workstation with active sessions. Tests for the default path inspect it without
package/docs/intercom.md CHANGED
@@ -330,13 +330,20 @@ 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
 
338
342
  Shutdown joins admitted inbound work before detaching its native owner. Stale
339
343
  runtime generations must not checkpoint or deliver into a replacement session.
344
+ Context state is inactive, live with its session and incarnation, or invalidated
345
+ while transport cleanup retains the session ID. Clearing cleanup drops that ID;
346
+ transport connection and native tool activity remain separate from context validity.
340
347
  The native tests own these contracts; neither a timer nor a source-string test is
341
348
  publication evidence.
342
349
 
@@ -392,4 +399,14 @@ records include native boot/namespace/start identity to reject proven PID reuse;
392
399
  ambiguous legacy numeric PIDs remain blocking. A process is never killed merely
393
400
  to recover broker startup.
394
401
 
402
+ Startup distinguishes absent, live and uncertain ownership. An unreadable or
403
+ invalid PID record is uncertain, not permission to launch another broker.
404
+ A startup claim stays owned while its process is live, regardless of age.
405
+ Only a proven exited or replaced owner permits reclaiming it. Linux claims carry
406
+ the same native process identity as broker PID records. Legacy or incomparable
407
+ identities remain blocking; a timestamp alone cannot prove PID reuse.
408
+ Each starter keeps the claim's
409
+ file descriptor open and releases only the same device/inode, leaving a
410
+ replacement claim untouched.
411
+
395
412
  Remote authenticated mail is separate: see [bridge setup](intercom-bridge.md).
package/docs/reference.md CHANGED
@@ -12,6 +12,7 @@ delegate({
12
12
  name: "optional-readable-name",
13
13
  cwd: "/optional/working/directory",
14
14
  model: "provider/model",
15
+ thinking: "low",
15
16
  context: "fresh",
16
17
  });
17
18
  ```
@@ -21,19 +22,27 @@ canned profile. `name` is an optional display name, not a unique identity or rol
21
22
  Omitted cwd/model inherit the invoking session's effective selection. A supplied
22
23
  model must be available; there is no hidden alternate-model retry catalog.
23
24
 
25
+ `thinking` accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`.
26
+ It overrides parent effort for this child without changing the parent. It works
27
+ with an omitted model or a literal model ID on every supported host, including
28
+ official Pi 1.5.0. Omission preserves inherited effort. Pi applies its native
29
+ model-capability clamping; the launch receipt records the requested value, not
30
+ proof of the child's effective effort.
31
+
24
32
  On hosts exporting Pi's native `resolveModelReference`, `model` also accepts
25
33
  `provider/model:thinking` (for example `provider/model:low` or `provider/model:off`).
26
- The explicit suffix overrides inherited thinking for that child only. Literal
34
+ The explicit suffix overrides inherited thinking for that child only. If both
35
+ `thinking` and a suffix specify effort, they must match or the launch fails. Literal
27
36
  model IDs take precedence, including IDs containing colons or additional slashes;
28
37
  unknown IDs and invalid suffixes fail without fuzzy matching or another provider.
29
38
  Native resolution follows Pi's case-insensitive ID matching. Pi still applies the
30
39
  selected model's native thinking capabilities.
31
40
 
32
41
  Official Pi, including 1.5.0, does not export that resolver. Older fork runtimes also
33
- lack the export. Those hosts retain
34
- case-sensitive literal-ID overrides and inherited thinking; suffix requests fail with an upgrade
35
- message rather than being parsed by an extension-side replacement. This requires
36
- a host with the public resolver export, not merely a matching version number.
42
+ lack the export. Those hosts retain case-sensitive literal-ID overrides and
43
+ support the `thinking` field. Suffix requests fail with an upgrade message rather
44
+ than being parsed by an extension-side replacement. Suffix support requires a
45
+ host with the public resolver export, not merely a matching version number.
37
46
 
38
47
  Fresh context is the default. Fork uses the native conversation, including
39
48
  historical tool calls and results; restricting a child's authority does not erase
@@ -58,6 +67,8 @@ tool or programmatic invocation of `delegate` or `agent_runs`.
58
67
 
59
68
  Saved runs belong to the native main session that launched them, not its cwd,
60
69
  project, display name, or a different session forked from its history.
70
+ Live guidance and saved-run successors keep the child's saved effort even if the
71
+ parent's effort has changed.
61
72
 
62
73
  | Action | Purpose |
63
74
  | ---------- | ------------------------------------------------------------------------------------ |
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.19",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/scripts/build.mjs CHANGED
@@ -67,21 +67,37 @@ async function hasPublishedDist(/** @type {string} */ path) {
67
67
  }
68
68
  }
69
69
 
70
- async function reapStrandedStaging(/** @type {string} */ cwd) {
70
+ /**
71
+ * @param {string} cwd
72
+ * @param {import("node:fs").Dirent} entry
73
+ * @returns {{ path: string, kind: "staging" | "previous" } | undefined}
74
+ */
75
+ function strandedBuild(cwd, entry) {
71
76
  // Signal 0 proves only that the owner was gone at probe time. Pid reuse
72
77
  // between this probe and removal is inherent to pid-based reaping.
73
- for (const entry of await readdir(cwd, { withFileTypes: true })) {
74
- const match = /^dist\.staging\.(\d+)(?:\.previous)?$/.exec(entry.name);
75
- if (!match || !entry.isDirectory()) {
76
- continue;
77
- }
78
- const ownerPid = Number(match[1]);
79
- if (ownerPid === process.pid || !isOwnerGone(ownerPid)) {
80
- continue;
78
+ const match = /^dist\.staging\.(\d+)(\.previous)?$/.exec(entry.name);
79
+ if (!match || !entry.isDirectory()) {
80
+ return;
81
+ }
82
+ const ownerPid = Number(match[1]);
83
+ if (ownerPid === process.pid || !isOwnerGone(ownerPid)) {
84
+ return;
85
+ }
86
+ return { path: join(cwd, entry.name), kind: match[2] === undefined ? "staging" : "previous" };
87
+ }
88
+
89
+ async function reapStrandedBuilds(/** @type {string} */ cwd) {
90
+ const stranded = (await readdir(cwd, { withFileTypes: true })).flatMap((entry) => {
91
+ const build = strandedBuild(cwd, entry);
92
+ return build ? [build] : [];
93
+ });
94
+ for (const entry of stranded) {
95
+ if (entry.kind === "staging") {
96
+ await discardStaging(entry.path);
81
97
  }
82
- // Reap abandoned trees sequentially to bound filesystem removal pressure.
83
- await discardStaging(join(cwd, entry.name));
84
98
  }
99
+ // Never promote rollback material into another live publisher's retirement gap.
100
+ return stranded.filter((entry) => entry.kind === "previous").map((entry) => entry.path);
85
101
  }
86
102
 
87
103
  async function compileProject(
@@ -208,13 +224,24 @@ async function publishStaging(/** @type {string} */ stagingDir, /** @type {strin
208
224
  async function main() {
209
225
  const cwd = process.cwd();
210
226
  const compiler = compilerInvocation(process.argv[2]);
211
- await reapStrandedStaging(cwd);
227
+ const previous = await reapStrandedBuilds(cwd);
212
228
  // Pid-scoped staging isolates concurrent emits; only a complete tree publishes.
213
229
  const stagingDir = join(cwd, `dist.staging.${process.pid}`);
214
230
  await rm(stagingDir, RM_OPTIONS);
215
231
  await rm(`${stagingDir}.previous`, RM_OPTIONS);
216
- await compileToStaging(cwd, stagingDir, compiler);
217
- await publishStaging(stagingDir, join(cwd, "dist"));
232
+ try {
233
+ await compileToStaging(cwd, stagingDir, compiler);
234
+ await publishStaging(stagingDir, join(cwd, "dist"));
235
+ } catch (error) {
236
+ for (const retained of previous.filter(existsSync)) {
237
+ console.warn(`Previous build retained for recovery: ${retained}`);
238
+ }
239
+ throw error;
240
+ }
241
+ // A complete published winner now replaces these older rollback candidates.
242
+ for (const retired of previous) {
243
+ await discardStaging(retired);
244
+ }
218
245
  }
219
246
 
220
247
  main().catch((/** @type {unknown} */ error) => {