@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.
- package/CHANGELOG.md +51 -0
- package/README.md +4 -0
- package/dist/agent/host.js +12 -0
- package/dist/agent/index.js +70 -56
- package/dist/agent/launch.js +12 -3
- package/dist/agent/notifications.js +5 -5
- package/dist/agent/parking.js +26 -15
- package/dist/agent/runner.js +18 -16
- package/dist/agent/tool-schemas.js +2 -0
- package/dist/agent/usage.js +42 -3
- package/dist/agent/view.js +13 -13
- package/dist/agent/wake.js +17 -15
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/broker/broker.js +2 -2
- package/dist/pi-intercom/broker/snapshots.d.ts +25 -7
- package/dist/pi-intercom/broker/snapshots.js +56 -25
- package/dist/pi-intercom/broker/spawn-claim.js +89 -0
- package/dist/pi-intercom/broker/spawn.js +31 -78
- package/dist/pi-intercom/inbound-delivery.js +2 -6
- package/dist/pi-intercom/lifecycle.js +26 -26
- package/dist/pi-intercom/reply-tracker.js +47 -55
- package/dist/pi-intercom/session-events.js +2 -0
- package/dist/shared/post-exit-stdio-guard.js +44 -37
- package/docs/development.md +25 -0
- package/docs/intercom.md +19 -2
- package/docs/reference.md +16 -5
- package/package.json +1 -1
- package/scripts/build.mjs +41 -14
- package/skills/pi-subagents/SKILL.md +8 -4
- package/src/agent/contracts.ts +17 -0
- package/src/agent/host.ts +19 -0
- package/src/agent/index.ts +91 -62
- package/src/agent/launch.ts +16 -3
- package/src/agent/notifications.ts +5 -5
- package/src/agent/parking.ts +39 -17
- package/src/agent/runner.ts +20 -21
- package/src/agent/tool-schemas.ts +2 -0
- package/src/agent/usage.ts +48 -3
- package/src/agent/view.ts +15 -13
- package/src/agent/wake.ts +19 -15
- package/src/pi-intercom/broker/broker.ts +2 -1
- package/src/pi-intercom/broker/snapshots.ts +82 -37
- package/src/pi-intercom/broker/spawn-claim.ts +98 -0
- package/src/pi-intercom/broker/spawn.ts +42 -82
- package/src/pi-intercom/inbound-delivery.ts +2 -6
- package/src/pi-intercom/lifecycle.ts +38 -27
- package/src/pi-intercom/reply-tracker.ts +50 -58
- package/src/pi-intercom/session-events.ts +3 -1
- 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(
|
|
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
|
-
|
|
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.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
125
|
-
this.
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
74
|
-
if (
|
|
73
|
+
queueTurnContext(messageId) {
|
|
74
|
+
if (!this.pendingAsks.has(messageId) || this.hasTurnContext(messageId)) {
|
|
75
75
|
return;
|
|
76
76
|
}
|
|
77
|
-
|
|
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.
|
|
85
|
-
if (this.
|
|
86
|
-
this.
|
|
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
|
-
|
|
87
|
+
const id = this.currentTurnId ?? this.activeAgentId;
|
|
88
|
+
return id === null ? null : (this.pendingAsks.get(id) ?? null);
|
|
91
89
|
}
|
|
92
90
|
endTurn() {
|
|
93
|
-
this.
|
|
91
|
+
this.currentTurnId = null;
|
|
94
92
|
}
|
|
95
93
|
endAgent() {
|
|
96
|
-
this.
|
|
97
|
-
this.
|
|
94
|
+
this.currentTurnId = null;
|
|
95
|
+
this.activeAgentId = null;
|
|
98
96
|
}
|
|
99
97
|
reset() {
|
|
100
98
|
this.pendingAsks.clear();
|
|
101
|
-
this.
|
|
102
|
-
this.
|
|
103
|
-
this.activeAgentContext = null;
|
|
99
|
+
this.pendingTurnIds.length = 0;
|
|
100
|
+
this.endAgent();
|
|
104
101
|
}
|
|
105
102
|
expireSender(sessionId) {
|
|
106
|
-
const
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
expired += 1;
|
|
112
|
-
}
|
|
107
|
+
for (const messageId of expiring) {
|
|
108
|
+
this.pendingAsks.delete(messageId);
|
|
109
|
+
expired += 1;
|
|
113
110
|
}
|
|
114
|
-
const beforeQueued = this.
|
|
115
|
-
for (let index = this.
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
this.
|
|
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.
|
|
122
|
-
if (this.
|
|
123
|
-
this.
|
|
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.
|
|
127
|
-
this.
|
|
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(
|
|
135
|
+
return explicitReply(pending, replyTo, to);
|
|
145
136
|
}
|
|
146
137
|
if (to !== "") {
|
|
147
|
-
return replyBySender(
|
|
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
|
-
|
|
154
|
-
|
|
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.
|
|
174
|
-
this.
|
|
175
|
-
this.
|
|
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.
|
|
180
|
-
if (this.
|
|
181
|
-
this.
|
|
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.
|
|
185
|
-
this.
|
|
176
|
+
if (this.currentTurnId === messageId) {
|
|
177
|
+
this.currentTurnId = null;
|
|
186
178
|
}
|
|
187
|
-
if (this.
|
|
188
|
-
this.
|
|
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
|
|
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 (
|
|
309
|
+
if (state.kind !== "draining") {
|
|
318
310
|
return;
|
|
319
311
|
}
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
327
|
-
child.stderr?.on("data", armIdleTimer);
|
|
328
|
-
child.stdout?.on("end", () => {
|
|
316
|
+
const stdoutEnd = () => {
|
|
329
317
|
stdoutEnded = true;
|
|
330
318
|
if (stderrEnded) {
|
|
331
|
-
|
|
319
|
+
cleanup();
|
|
332
320
|
}
|
|
333
|
-
}
|
|
334
|
-
|
|
321
|
+
};
|
|
322
|
+
const stderrEnd = () => {
|
|
335
323
|
stderrEnded = true;
|
|
336
324
|
if (stdoutEnded) {
|
|
337
|
-
|
|
325
|
+
cleanup();
|
|
338
326
|
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
armIdleTimer();
|
|
343
|
-
if (hardTimer) {
|
|
327
|
+
};
|
|
328
|
+
const exited = () => {
|
|
329
|
+
if (state.kind !== "waiting") {
|
|
344
330
|
return;
|
|
345
331
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
}
|
package/docs/development.md
CHANGED
|
@@ -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
|
|
334
|
-
message triggers the turn.
|
|
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.
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
package/scripts/build.mjs
CHANGED
|
@@ -67,21 +67,37 @@ async function hasPublishedDist(/** @type {string} */ path) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
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) => {
|