@hheei/pi-subagents 0.7.0 → 0.8.0
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/LICENSES/Apache-2.0.txt +202 -0
- package/NOTICE +10 -0
- package/README.md +13 -13
- package/dist/child-control.d.ts +0 -8
- package/dist/child-control.js +40 -8
- package/dist/commands.js +1 -1
- package/dist/controller-ipc.d.ts +38 -0
- package/dist/controller-ipc.js +292 -0
- package/dist/controller-main.d.ts +1 -0
- package/dist/controller-main.js +26 -0
- package/dist/controller-role.d.ts +40 -0
- package/dist/controller-role.js +37 -0
- package/dist/controller-socket.d.ts +6 -0
- package/dist/controller-socket.js +43 -0
- package/dist/controller.d.ts +25 -0
- package/dist/controller.js +217 -0
- package/dist/delegation.d.ts +73 -0
- package/dist/delegation.js +71 -0
- package/dist/domain.d.ts +20 -18
- package/dist/domain.js +6 -1
- package/dist/extension.js +43 -26
- package/dist/host-adapter.js +1 -1
- package/dist/launch-spec.d.ts +1 -3
- package/dist/launch-spec.js +38 -15
- package/dist/lease.d.ts +0 -1
- package/dist/lease.js +8 -11
- package/dist/mailbox.js +110 -34
- package/dist/manager.d.ts +52 -6
- package/dist/manager.js +568 -32
- package/dist/mcp/app.html +948 -0
- package/dist/mcp/delegate.d.ts +30 -0
- package/dist/mcp/delegate.js +44 -0
- package/dist/mcp/views.d.ts +5 -0
- package/dist/mcp/views.js +12 -0
- package/dist/mcp-server.d.ts +6 -0
- package/dist/mcp-server.js +287 -0
- package/dist/mcp-stdio.d.ts +2 -0
- package/dist/mcp-stdio.js +111 -0
- package/dist/process-lock.d.ts +1 -1
- package/dist/process-lock.js +4 -2
- package/dist/protocol.d.ts +5 -5
- package/dist/protocol.js +3 -4
- package/dist/receipts.d.ts +80 -0
- package/dist/receipts.js +326 -0
- package/dist/runtime.d.ts +5 -26
- package/dist/runtime.js +15 -57
- package/dist/session-bootstrap.js +2 -6
- package/dist/state-db.d.ts +11 -4
- package/dist/state-db.js +102 -24
- package/dist/state.js +12 -3
- package/dist/tool-activation.d.ts +2 -2
- package/dist/tool-activation.js +5 -5
- package/dist/tool-presentation.d.ts +2 -6
- package/dist/tool-presentation.js +75 -122
- package/dist/tools.d.ts +0 -3
- package/dist/tools.js +104 -132
- package/package.json +12 -5
- package/dist/json-lines.d.ts +0 -14
- package/dist/json-lines.js +0 -95
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export type ReceiptStatus = "reserved" | "dispatching" | "accepted" | "running" | "completed" | "blocked" | "failed" | "uncertain";
|
|
2
|
+
export type ReceiptDelivery = "pending" | "detached";
|
|
3
|
+
export interface Receipt {
|
|
4
|
+
readonly parentSessionId: string;
|
|
5
|
+
readonly delegationId: string;
|
|
6
|
+
readonly childId?: string;
|
|
7
|
+
readonly revision?: number;
|
|
8
|
+
readonly task: string;
|
|
9
|
+
readonly inputCheckpoint?: string;
|
|
10
|
+
readonly runBoundary?: number;
|
|
11
|
+
/** The compaction this delegation controls; set instead of a run boundary. */
|
|
12
|
+
readonly controlId?: string;
|
|
13
|
+
readonly status: ReceiptStatus;
|
|
14
|
+
readonly delivery: ReceiptDelivery;
|
|
15
|
+
readonly resultText?: string;
|
|
16
|
+
/** Recorded only when a dispatch itself failed; a turn failure leaves both unset. */
|
|
17
|
+
readonly safeToRetry?: boolean;
|
|
18
|
+
readonly sideEffects?: readonly string[];
|
|
19
|
+
readonly createdAt: number;
|
|
20
|
+
readonly updatedAt: number;
|
|
21
|
+
}
|
|
22
|
+
export interface ReceiptSettlement {
|
|
23
|
+
readonly childId: string;
|
|
24
|
+
readonly status: "completed" | "blocked" | "failed";
|
|
25
|
+
readonly message: string;
|
|
26
|
+
/** The turn that can settle this event; absent for a control operation. */
|
|
27
|
+
readonly workVersion?: number;
|
|
28
|
+
/** Set for a control operation's own settlement; such a receipt has no run boundary. */
|
|
29
|
+
readonly controlId?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function createDelegationId(): string;
|
|
32
|
+
export declare function terminalReceipt(status: ReceiptStatus): boolean;
|
|
33
|
+
export declare function reserveReceipt(filePath: string, input: {
|
|
34
|
+
readonly parentSessionId: string;
|
|
35
|
+
readonly task: string;
|
|
36
|
+
readonly childId?: string;
|
|
37
|
+
readonly delivery: ReceiptDelivery;
|
|
38
|
+
}): Receipt;
|
|
39
|
+
export declare function updateReceipt(filePath: string, parentSessionId: string, delegationId: string, patch: {
|
|
40
|
+
readonly from: readonly ReceiptStatus[];
|
|
41
|
+
readonly status: ReceiptStatus;
|
|
42
|
+
readonly childId?: string;
|
|
43
|
+
readonly inputCheckpoint?: string;
|
|
44
|
+
readonly runBoundary?: number;
|
|
45
|
+
readonly controlId?: string;
|
|
46
|
+
readonly resultText?: string;
|
|
47
|
+
readonly safeToRetry?: boolean;
|
|
48
|
+
readonly sideEffects?: readonly string[];
|
|
49
|
+
}): Receipt | undefined;
|
|
50
|
+
/** A dispatch that died before acceptance cannot be retried safely. */
|
|
51
|
+
export declare function sweepUncertainDispatch(filePath: string, parentSessionId: string): number;
|
|
52
|
+
export declare function settleReceipts(filePath: string, parentSessionId: string, event: ReceiptSettlement): readonly Receipt[];
|
|
53
|
+
/**
|
|
54
|
+
* A control operation that outlived the process that started it can never be confirmed: the child
|
|
55
|
+
* may have compacted, may still be compacting, or may never have started. It is recorded as
|
|
56
|
+
* uncertain instead of being replayed, because replaying compaction is not idempotent.
|
|
57
|
+
*/
|
|
58
|
+
export declare function sweepControlReceipts(filePath: string, parentSessionId: string, message: string): readonly Receipt[];
|
|
59
|
+
export declare function listReceipts(filePath: string, parentSessionId: string, cursor?: number, limit?: number): {
|
|
60
|
+
readonly receipts: readonly Receipt[];
|
|
61
|
+
readonly nextCursor?: number;
|
|
62
|
+
};
|
|
63
|
+
/** Stop or shutdown: every open delegation for this child fails, so a waiter cannot hang. */
|
|
64
|
+
export declare function abandonReceipts(filePath: string, parentSessionId: string, childId: string, message: string): readonly Receipt[];
|
|
65
|
+
export declare function maxRunBoundary(filePath: string, parentSessionId: string, childId: string): number;
|
|
66
|
+
export declare function readStoredReceipt(filePath: string, parentSessionId: string, delegationId: string): Receipt | undefined;
|
|
67
|
+
export declare function readReceiptRevision(filePath: string, parentSessionId: string, childId: string, revision: number): Receipt | undefined;
|
|
68
|
+
export declare function latestReceiptForChild(filePath: string, parentSessionId: string, childId: string): Receipt | undefined;
|
|
69
|
+
/** The latest delegation of every agent, without reading any result body. */
|
|
70
|
+
export interface ReceiptSummary {
|
|
71
|
+
readonly childId: string;
|
|
72
|
+
readonly delegationId: string;
|
|
73
|
+
readonly revision: number;
|
|
74
|
+
readonly status: ReceiptStatus;
|
|
75
|
+
readonly hasOutput: boolean;
|
|
76
|
+
}
|
|
77
|
+
/** A failed or uncertain receipt stores a reason, not an answer. */
|
|
78
|
+
export declare function receiptCarriesOutput(status: ReceiptStatus, resultText: string | undefined): boolean;
|
|
79
|
+
export declare function latestReceiptSummaries(filePath: string, parentSessionId: string): ReadonlyMap<string, ReceiptSummary>;
|
|
80
|
+
export declare function waitForReceipt(filePath: string, parentSessionId: string, delegationId: string, signal?: AbortSignal): Promise<Receipt>;
|
package/dist/receipts.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { openStateDb, withWrite } from "./state-db.js";
|
|
3
|
+
const TERMINAL = new Set([
|
|
4
|
+
"completed",
|
|
5
|
+
"blocked",
|
|
6
|
+
"failed",
|
|
7
|
+
"uncertain",
|
|
8
|
+
]);
|
|
9
|
+
const waiters = new Map();
|
|
10
|
+
export function createDelegationId() {
|
|
11
|
+
return `dep_${randomBytes(4).toString("hex")}`;
|
|
12
|
+
}
|
|
13
|
+
export function terminalReceipt(status) {
|
|
14
|
+
return TERMINAL.has(status);
|
|
15
|
+
}
|
|
16
|
+
export function reserveReceipt(filePath, input) {
|
|
17
|
+
const db = openStateDb(filePath);
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
const delegationId = createDelegationId();
|
|
20
|
+
return withWrite(db, () => {
|
|
21
|
+
const revision = input.childId === undefined ? null : nextRevision(db, input.parentSessionId, input.childId);
|
|
22
|
+
db.prepare(`INSERT INTO delegations (
|
|
23
|
+
parent_session_id, delegation_id, child_id, delegation_revision, task, status, delivery, created_at, updated_at
|
|
24
|
+
) VALUES (?, ?, ?, ?, ?, 'reserved', ?, ?, ?)`).run(input.parentSessionId, delegationId, input.childId ?? null, revision, input.task, input.delivery, now, now);
|
|
25
|
+
const stored = readReceipt(db, input.parentSessionId, delegationId);
|
|
26
|
+
if (stored === undefined)
|
|
27
|
+
throw new Error("Reserved delegation was not stored");
|
|
28
|
+
return stored;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export function updateReceipt(filePath, parentSessionId, delegationId, patch) {
|
|
32
|
+
const db = openStateDb(filePath);
|
|
33
|
+
const updated = withWrite(db, () => {
|
|
34
|
+
const current = readReceipt(db, parentSessionId, delegationId);
|
|
35
|
+
if (current === undefined || !patch.from.includes(current.status))
|
|
36
|
+
return undefined;
|
|
37
|
+
const revision = current.revision ??
|
|
38
|
+
(patch.childId === undefined ? null : nextRevision(db, parentSessionId, patch.childId));
|
|
39
|
+
db.prepare(`UPDATE delegations
|
|
40
|
+
SET status = ?, child_id = COALESCE(?, child_id), input_checkpoint = COALESCE(?, input_checkpoint),
|
|
41
|
+
delegation_revision = COALESCE(?, delegation_revision), run_boundary = COALESCE(?, run_boundary),
|
|
42
|
+
control_id = COALESCE(?, control_id), result_text = COALESCE(?, result_text),
|
|
43
|
+
safe_to_retry = COALESCE(?, safe_to_retry), side_effects = COALESCE(?, side_effects), updated_at = ?
|
|
44
|
+
WHERE parent_session_id = ? AND delegation_id = ? AND status = ?`).run(patch.status, patch.childId ?? null, patch.inputCheckpoint ?? null, revision, patch.runBoundary ?? null, patch.controlId ?? null, patch.resultText ?? null, patch.safeToRetry === undefined ? null : patch.safeToRetry ? 1 : 0, patch.sideEffects === undefined ? null : JSON.stringify(patch.sideEffects), Date.now(), parentSessionId, delegationId, current.status);
|
|
45
|
+
return readReceipt(db, parentSessionId, delegationId);
|
|
46
|
+
});
|
|
47
|
+
notify(parentSessionId, delegationId);
|
|
48
|
+
return updated;
|
|
49
|
+
}
|
|
50
|
+
/** A dispatch that died before acceptance cannot be retried safely. */
|
|
51
|
+
export function sweepUncertainDispatch(filePath, parentSessionId) {
|
|
52
|
+
const db = openStateDb(filePath);
|
|
53
|
+
const changed = withWrite(db, () => {
|
|
54
|
+
const rows = db
|
|
55
|
+
.prepare(`SELECT delegation_id FROM delegations
|
|
56
|
+
WHERE parent_session_id = ? AND status = 'dispatching'`)
|
|
57
|
+
.all(parentSessionId);
|
|
58
|
+
for (const row of rows) {
|
|
59
|
+
db.prepare(`UPDATE delegations SET status = 'uncertain', result_text = ?, updated_at = ?
|
|
60
|
+
WHERE parent_session_id = ? AND delegation_id = ? AND status = 'dispatching'`).run("Dispatch was not acknowledged before the controller stopped", Date.now(), parentSessionId, row.delegation_id);
|
|
61
|
+
notify(parentSessionId, row.delegation_id);
|
|
62
|
+
}
|
|
63
|
+
return rows.length;
|
|
64
|
+
});
|
|
65
|
+
return changed;
|
|
66
|
+
}
|
|
67
|
+
export function settleReceipts(filePath, parentSessionId, event) {
|
|
68
|
+
const db = openStateDb(filePath);
|
|
69
|
+
const settled = withWrite(db, () => {
|
|
70
|
+
// A control operation is bound to its own id: a late compaction report cannot settle a turn
|
|
71
|
+
// that started after it, and a turn's answer cannot be reported as a compaction.
|
|
72
|
+
const rows = (event.controlId === undefined
|
|
73
|
+
? db
|
|
74
|
+
.prepare(`SELECT delegation_id FROM delegations
|
|
75
|
+
WHERE parent_session_id = ? AND child_id = ? AND run_boundary = ?
|
|
76
|
+
AND control_id IS NULL AND status IN ('accepted', 'running', 'dispatching')`)
|
|
77
|
+
.all(parentSessionId, event.childId, event.workVersion ?? 0)
|
|
78
|
+
: db
|
|
79
|
+
.prepare(`SELECT delegation_id FROM delegations
|
|
80
|
+
WHERE parent_session_id = ? AND child_id = ? AND control_id = ?
|
|
81
|
+
AND status IN ('accepted', 'running', 'dispatching')`)
|
|
82
|
+
.all(parentSessionId, event.childId, event.controlId));
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const stored = [];
|
|
85
|
+
for (const row of rows) {
|
|
86
|
+
db.prepare(`UPDATE delegations
|
|
87
|
+
SET status = ?, result_text = ?, run_boundary = COALESCE(?, run_boundary), updated_at = ?
|
|
88
|
+
WHERE parent_session_id = ? AND delegation_id = ? AND status IN ('accepted', 'running', 'dispatching')`).run(event.status, event.message, event.controlId === undefined ? (event.workVersion ?? 0) : null, now, parentSessionId, row.delegation_id);
|
|
89
|
+
const receipt = readReceipt(db, parentSessionId, row.delegation_id);
|
|
90
|
+
if (receipt !== undefined)
|
|
91
|
+
stored.push(receipt);
|
|
92
|
+
}
|
|
93
|
+
return stored;
|
|
94
|
+
});
|
|
95
|
+
for (const receipt of settled)
|
|
96
|
+
notify(receipt.parentSessionId, receipt.delegationId);
|
|
97
|
+
return settled;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A control operation that outlived the process that started it can never be confirmed: the child
|
|
101
|
+
* may have compacted, may still be compacting, or may never have started. It is recorded as
|
|
102
|
+
* uncertain instead of being replayed, because replaying compaction is not idempotent.
|
|
103
|
+
*/
|
|
104
|
+
export function sweepControlReceipts(filePath, parentSessionId, message) {
|
|
105
|
+
const db = openStateDb(filePath);
|
|
106
|
+
const swept = withWrite(db, () => {
|
|
107
|
+
const rows = db
|
|
108
|
+
.prepare(`SELECT delegation_id FROM delegations
|
|
109
|
+
WHERE parent_session_id = ? AND control_id IS NOT NULL
|
|
110
|
+
AND status IN ('accepted', 'running')`)
|
|
111
|
+
.all(parentSessionId);
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const stored = [];
|
|
114
|
+
for (const row of rows) {
|
|
115
|
+
db.prepare(`UPDATE delegations SET status = 'uncertain', result_text = ?, updated_at = ?
|
|
116
|
+
WHERE parent_session_id = ? AND delegation_id = ? AND status IN ('accepted', 'running')`).run(message, now, parentSessionId, row.delegation_id);
|
|
117
|
+
const receipt = readReceipt(db, parentSessionId, row.delegation_id);
|
|
118
|
+
if (receipt !== undefined)
|
|
119
|
+
stored.push(receipt);
|
|
120
|
+
}
|
|
121
|
+
return stored;
|
|
122
|
+
});
|
|
123
|
+
for (const receipt of swept)
|
|
124
|
+
notify(receipt.parentSessionId, receipt.delegationId);
|
|
125
|
+
return swept;
|
|
126
|
+
}
|
|
127
|
+
export function listReceipts(filePath, parentSessionId, cursor = 0, limit = 20) {
|
|
128
|
+
const db = openStateDb(filePath);
|
|
129
|
+
const safeLimit = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 50) : 20;
|
|
130
|
+
const safeCursor = Number.isInteger(cursor) && cursor > 0 ? cursor : 0;
|
|
131
|
+
const rows = db
|
|
132
|
+
.prepare(`SELECT delegation_id FROM delegations
|
|
133
|
+
WHERE parent_session_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?`)
|
|
134
|
+
.all(parentSessionId, safeLimit + 1, safeCursor);
|
|
135
|
+
const receipts = rows.slice(0, safeLimit).flatMap((row) => {
|
|
136
|
+
const receipt = readReceipt(db, parentSessionId, row.delegation_id);
|
|
137
|
+
return receipt === undefined ? [] : [receipt];
|
|
138
|
+
});
|
|
139
|
+
return rows.length > safeLimit ? { receipts, nextCursor: safeCursor + safeLimit } : { receipts };
|
|
140
|
+
}
|
|
141
|
+
/** Stop or shutdown: every open delegation for this child fails, so a waiter cannot hang. */
|
|
142
|
+
export function abandonReceipts(filePath, parentSessionId, childId, message) {
|
|
143
|
+
const db = openStateDb(filePath);
|
|
144
|
+
const abandoned = withWrite(db, () => {
|
|
145
|
+
const rows = db
|
|
146
|
+
.prepare(`SELECT delegation_id FROM delegations
|
|
147
|
+
WHERE parent_session_id = ? AND child_id = ?
|
|
148
|
+
AND status IN ('reserved', 'dispatching', 'accepted', 'running')`)
|
|
149
|
+
.all(parentSessionId, childId);
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
const stored = [];
|
|
152
|
+
for (const row of rows) {
|
|
153
|
+
db.prepare(`UPDATE delegations SET status = 'failed', result_text = ?, updated_at = ?
|
|
154
|
+
WHERE parent_session_id = ? AND delegation_id = ?
|
|
155
|
+
AND status IN ('reserved', 'dispatching', 'accepted', 'running')`).run(message, now, parentSessionId, row.delegation_id);
|
|
156
|
+
const receipt = readReceipt(db, parentSessionId, row.delegation_id);
|
|
157
|
+
if (receipt !== undefined)
|
|
158
|
+
stored.push(receipt);
|
|
159
|
+
}
|
|
160
|
+
return stored;
|
|
161
|
+
});
|
|
162
|
+
for (const receipt of abandoned)
|
|
163
|
+
notify(receipt.parentSessionId, receipt.delegationId);
|
|
164
|
+
return abandoned;
|
|
165
|
+
}
|
|
166
|
+
export function maxRunBoundary(filePath, parentSessionId, childId) {
|
|
167
|
+
const db = openStateDb(filePath);
|
|
168
|
+
const row = db
|
|
169
|
+
.prepare(`SELECT MAX(run_boundary) AS boundary FROM delegations
|
|
170
|
+
WHERE parent_session_id = ? AND child_id = ?`)
|
|
171
|
+
.get(parentSessionId, childId);
|
|
172
|
+
return row?.boundary ?? 0;
|
|
173
|
+
}
|
|
174
|
+
export function readStoredReceipt(filePath, parentSessionId, delegationId) {
|
|
175
|
+
return readReceipt(openStateDb(filePath), parentSessionId, delegationId);
|
|
176
|
+
}
|
|
177
|
+
export function readReceiptRevision(filePath, parentSessionId, childId, revision) {
|
|
178
|
+
const db = openStateDb(filePath);
|
|
179
|
+
const row = db
|
|
180
|
+
.prepare("SELECT delegation_id FROM delegations WHERE parent_session_id = ? AND child_id = ? AND delegation_revision = ?")
|
|
181
|
+
.get(parentSessionId, childId, revision);
|
|
182
|
+
return row === undefined ? undefined : readReceipt(db, parentSessionId, row.delegation_id);
|
|
183
|
+
}
|
|
184
|
+
export function latestReceiptForChild(filePath, parentSessionId, childId) {
|
|
185
|
+
const db = openStateDb(filePath);
|
|
186
|
+
const row = db
|
|
187
|
+
.prepare(`SELECT delegation_id FROM delegations
|
|
188
|
+
WHERE parent_session_id = ? AND child_id = ?
|
|
189
|
+
ORDER BY rowid DESC LIMIT 1`)
|
|
190
|
+
.get(parentSessionId, childId);
|
|
191
|
+
if (row === undefined)
|
|
192
|
+
return undefined;
|
|
193
|
+
return readReceipt(db, parentSessionId, row.delegation_id);
|
|
194
|
+
}
|
|
195
|
+
/** A failed or uncertain receipt stores a reason, not an answer. */
|
|
196
|
+
export function receiptCarriesOutput(status, resultText) {
|
|
197
|
+
return resultText !== undefined && status !== "failed" && status !== "uncertain";
|
|
198
|
+
}
|
|
199
|
+
export function latestReceiptSummaries(filePath, parentSessionId) {
|
|
200
|
+
const db = openStateDb(filePath);
|
|
201
|
+
const rows = db
|
|
202
|
+
.prepare(`SELECT child_id, delegation_id, delegation_revision, status,
|
|
203
|
+
result_text IS NOT NULL AND status NOT IN ('failed', 'uncertain') AS has_output
|
|
204
|
+
FROM delegations
|
|
205
|
+
WHERE parent_session_id = ? AND child_id IS NOT NULL
|
|
206
|
+
AND rowid IN (
|
|
207
|
+
SELECT MAX(rowid) FROM delegations
|
|
208
|
+
WHERE parent_session_id = ? AND child_id IS NOT NULL GROUP BY child_id
|
|
209
|
+
)`)
|
|
210
|
+
.all(parentSessionId, parentSessionId);
|
|
211
|
+
const summaries = new Map();
|
|
212
|
+
for (const row of rows) {
|
|
213
|
+
if (!isStatus(row.status) || row.delegation_revision === null)
|
|
214
|
+
continue;
|
|
215
|
+
summaries.set(row.child_id, {
|
|
216
|
+
childId: row.child_id,
|
|
217
|
+
delegationId: row.delegation_id,
|
|
218
|
+
revision: row.delegation_revision,
|
|
219
|
+
status: row.status,
|
|
220
|
+
hasOutput: row.has_output === 1,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return summaries;
|
|
224
|
+
}
|
|
225
|
+
export function waitForReceipt(filePath, parentSessionId, delegationId, signal) {
|
|
226
|
+
const current = readStoredReceipt(filePath, parentSessionId, delegationId);
|
|
227
|
+
if (current !== undefined && terminalReceipt(current.status))
|
|
228
|
+
return Promise.resolve(current);
|
|
229
|
+
return new Promise((resolve, reject) => {
|
|
230
|
+
const key = waiterKey(parentSessionId, delegationId);
|
|
231
|
+
const wake = () => {
|
|
232
|
+
const receipt = readStoredReceipt(filePath, parentSessionId, delegationId);
|
|
233
|
+
if (receipt === undefined || !terminalReceipt(receipt.status))
|
|
234
|
+
return;
|
|
235
|
+
finish();
|
|
236
|
+
resolve(receipt);
|
|
237
|
+
};
|
|
238
|
+
const fail = () => {
|
|
239
|
+
finish();
|
|
240
|
+
reject(signal?.reason instanceof Error ? signal.reason : new Error("Delegation wait aborted"));
|
|
241
|
+
};
|
|
242
|
+
const finish = () => {
|
|
243
|
+
signal?.removeEventListener("abort", fail);
|
|
244
|
+
const set = waiters.get(key);
|
|
245
|
+
set?.delete(wake);
|
|
246
|
+
if (set !== undefined && set.size === 0)
|
|
247
|
+
waiters.delete(key);
|
|
248
|
+
};
|
|
249
|
+
const set = waiters.get(key) ?? new Set();
|
|
250
|
+
set.add(wake);
|
|
251
|
+
waiters.set(key, set);
|
|
252
|
+
if (signal !== undefined) {
|
|
253
|
+
if (signal.aborted) {
|
|
254
|
+
fail();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
signal.addEventListener("abort", fail, { once: true });
|
|
258
|
+
}
|
|
259
|
+
wake();
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function parseSideEffects(raw) {
|
|
263
|
+
try {
|
|
264
|
+
const parsed = JSON.parse(raw);
|
|
265
|
+
return Array.isArray(parsed)
|
|
266
|
+
? parsed.filter((item) => typeof item === "string")
|
|
267
|
+
: [];
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function nextRevision(db, parentSessionId, childId) {
|
|
274
|
+
const row = db
|
|
275
|
+
.prepare("SELECT MAX(delegation_revision) AS revision FROM delegations WHERE parent_session_id = ? AND child_id = ?")
|
|
276
|
+
.get(parentSessionId, childId);
|
|
277
|
+
return (row.revision ?? 0) + 1;
|
|
278
|
+
}
|
|
279
|
+
function readReceipt(db, parentSessionId, delegationId) {
|
|
280
|
+
const row = db
|
|
281
|
+
.prepare(`SELECT delegation_id, child_id, delegation_revision, task, input_checkpoint, run_boundary, control_id, status, delivery, result_text, safe_to_retry, side_effects, created_at, updated_at
|
|
282
|
+
FROM delegations WHERE parent_session_id = ? AND delegation_id = ?`)
|
|
283
|
+
.get(parentSessionId, delegationId);
|
|
284
|
+
if (row === undefined ||
|
|
285
|
+
!isStatus(row.status) ||
|
|
286
|
+
(row.delivery !== "pending" && row.delivery !== "detached")) {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
parentSessionId,
|
|
291
|
+
delegationId: row.delegation_id,
|
|
292
|
+
task: row.task,
|
|
293
|
+
status: row.status,
|
|
294
|
+
delivery: row.delivery,
|
|
295
|
+
createdAt: row.created_at,
|
|
296
|
+
updatedAt: row.updated_at,
|
|
297
|
+
...(row.child_id === null ? {} : { childId: row.child_id }),
|
|
298
|
+
...(row.delegation_revision === null ? {} : { revision: row.delegation_revision }),
|
|
299
|
+
...(row.input_checkpoint === null ? {} : { inputCheckpoint: row.input_checkpoint }),
|
|
300
|
+
...(row.run_boundary === null ? {} : { runBoundary: row.run_boundary }),
|
|
301
|
+
...(row.control_id === null ? {} : { controlId: row.control_id }),
|
|
302
|
+
...(row.safe_to_retry === null ? {} : { safeToRetry: row.safe_to_retry === 1 }),
|
|
303
|
+
...(row.side_effects === null ? {} : { sideEffects: parseSideEffects(row.side_effects) }),
|
|
304
|
+
...(row.result_text === null ? {} : { resultText: row.result_text }),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function isStatus(value) {
|
|
308
|
+
return (value === "reserved" ||
|
|
309
|
+
value === "dispatching" ||
|
|
310
|
+
value === "accepted" ||
|
|
311
|
+
value === "running" ||
|
|
312
|
+
value === "completed" ||
|
|
313
|
+
value === "blocked" ||
|
|
314
|
+
value === "failed" ||
|
|
315
|
+
value === "uncertain");
|
|
316
|
+
}
|
|
317
|
+
function waiterKey(parentSessionId, delegationId) {
|
|
318
|
+
return `${parentSessionId}\n${delegationId}`;
|
|
319
|
+
}
|
|
320
|
+
function notify(parentSessionId, delegationId) {
|
|
321
|
+
const set = waiters.get(waiterKey(parentSessionId, delegationId));
|
|
322
|
+
if (set === undefined)
|
|
323
|
+
return;
|
|
324
|
+
for (const wake of set)
|
|
325
|
+
wake();
|
|
326
|
+
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -2,35 +2,14 @@ import { type ChildRuntime } from "./child-process.js";
|
|
|
2
2
|
import type { SubagentRecord } from "./domain.js";
|
|
3
3
|
import type { HostAdapter, HostAttachment } from "./host-adapter.js";
|
|
4
4
|
import type { SubagentRegistry } from "./registry.js";
|
|
5
|
-
/** Per-runtime bridge tokens, kept out of the registry because they are credentials. */
|
|
6
|
-
export interface RuntimeTokenStore {
|
|
7
|
-
remember(runtimeIdentity: string, token: string): void;
|
|
8
|
-
get(runtimeIdentity: string): string | undefined;
|
|
9
|
-
forget(runtimeIdentity: string): void;
|
|
10
|
-
}
|
|
11
|
-
export interface RuntimeTokenStoreOptions {
|
|
12
|
-
/** The parent session these tokens belong to. */
|
|
13
|
-
readonly parentSessionId: string;
|
|
14
|
-
/** Override for tests. Defaults to this parent's file in the shared runtime directory. */
|
|
15
|
-
readonly file?: string;
|
|
16
|
-
readonly diagnose?: (message: string) => void;
|
|
17
|
-
}
|
|
18
5
|
/**
|
|
19
|
-
* The one
|
|
20
|
-
*
|
|
6
|
+
* The one mailbox a parent session reads. A child polls this path, so a parent that restarts reads
|
|
7
|
+
* the same file and a surviving child reconnects instead of being rebuilt.
|
|
21
8
|
*/
|
|
22
9
|
export declare function parentBridgeEndpoint(_parentSessionId: string): string;
|
|
23
|
-
/** Tokens live in the same sqlite file as the child ledger. Rows do not clobber each other. */
|
|
24
|
-
export declare function parentTokenFile(_parentSessionId: string): string;
|
|
25
|
-
/**
|
|
26
|
-
* Tokens outlive the parent process. A restarted parent reads the same sqlite file and can still
|
|
27
|
-
* match the token a surviving child was launched with.
|
|
28
|
-
*/
|
|
29
|
-
export declare function createRuntimeTokenStore(options: RuntimeTokenStoreOptions): RuntimeTokenStore;
|
|
30
10
|
export interface LaunchChildOptions {
|
|
31
11
|
readonly registry: Pick<SubagentRegistry, "get" | "update">;
|
|
32
12
|
readonly record: SubagentRecord;
|
|
33
|
-
readonly tokens: RuntimeTokenStore;
|
|
34
13
|
/** Waits for this child's bridge connection; false when it did not connect in time. */
|
|
35
14
|
readonly waitForBridge: (childId: string, signal?: AbortSignal) => Promise<boolean>;
|
|
36
15
|
readonly signal?: AbortSignal;
|
|
@@ -38,9 +17,9 @@ export interface LaunchChildOptions {
|
|
|
38
17
|
}
|
|
39
18
|
/**
|
|
40
19
|
* Starts one headless child Pi and waits for it to connect. Nothing is driven over its stdio: the
|
|
41
|
-
* process gets the parent's
|
|
42
|
-
*
|
|
43
|
-
*
|
|
20
|
+
* process gets the parent's mailbox path in its environment, and the bridge is the only control
|
|
21
|
+
* channel. A child that never connects is terminated here rather than left running with no way to
|
|
22
|
+
* reach it.
|
|
44
23
|
*/
|
|
45
24
|
export declare function launchChild(options: LaunchChildOptions): Promise<LaunchOutcome<ChildRuntime>>;
|
|
46
25
|
export interface OpenChildPanelOptions extends LaunchChildOptions {
|
package/dist/runtime.js
CHANGED
|
@@ -6,72 +6,33 @@
|
|
|
6
6
|
* so "is this child alive?" is answered by a bridge connection, and the runtime metadata in the
|
|
7
7
|
* registry only records which runtime and endpoint a child was last launched with.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
10
|
import { chmod, mkdir } from "node:fs/promises";
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { errorMessage } from "@hheei/pi-ext-core";
|
|
14
14
|
import { spawnChildRuntime } from "./child-process.js";
|
|
15
|
-
import { buildLaunchSpec
|
|
15
|
+
import { buildLaunchSpec } from "./launch-spec.js";
|
|
16
16
|
import { commitLease } from "./lease.js";
|
|
17
|
-
import { defaultStatePath
|
|
17
|
+
import { defaultStatePath } from "./state-db.js";
|
|
18
18
|
function runtimeDirectory() {
|
|
19
19
|
const user = typeof process.getuid === "function" ? process.getuid() : "user";
|
|
20
20
|
return join(tmpdir(), `pi-subagents-${user}`);
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
|
-
* The one
|
|
24
|
-
*
|
|
23
|
+
* The one mailbox a parent session reads. A child polls this path, so a parent that restarts reads
|
|
24
|
+
* the same file and a surviving child reconnects instead of being rebuilt.
|
|
25
25
|
*/
|
|
26
26
|
export function parentBridgeEndpoint(_parentSessionId) {
|
|
27
27
|
return defaultStatePath();
|
|
28
28
|
}
|
|
29
|
-
/** Tokens live in the same sqlite file as the child ledger. Rows do not clobber each other. */
|
|
30
|
-
export function parentTokenFile(_parentSessionId) {
|
|
31
|
-
return defaultStatePath();
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Tokens outlive the parent process. A restarted parent reads the same sqlite file and can still
|
|
35
|
-
* match the token a surviving child was launched with.
|
|
36
|
-
*/
|
|
37
|
-
export function createRuntimeTokenStore(options) {
|
|
38
|
-
const file = options.file ?? defaultStatePath();
|
|
39
|
-
const diagnose = options.diagnose ?? (() => { });
|
|
40
|
-
let db;
|
|
41
|
-
try {
|
|
42
|
-
db = openStateDb(file);
|
|
43
|
-
}
|
|
44
|
-
catch (error) {
|
|
45
|
-
diagnose(`could not open the state database: ${errorMessage(error)}`);
|
|
46
|
-
}
|
|
47
|
-
const database = db;
|
|
48
|
-
return {
|
|
49
|
-
remember(runtimeIdentity, token) {
|
|
50
|
-
if (database === undefined)
|
|
51
|
-
return;
|
|
52
|
-
writeToken(database, options.parentSessionId, runtimeIdentity, token);
|
|
53
|
-
},
|
|
54
|
-
get(runtimeIdentity) {
|
|
55
|
-
if (database === undefined)
|
|
56
|
-
return undefined;
|
|
57
|
-
return readToken(database, options.parentSessionId, runtimeIdentity);
|
|
58
|
-
},
|
|
59
|
-
forget(runtimeIdentity) {
|
|
60
|
-
if (database === undefined)
|
|
61
|
-
return;
|
|
62
|
-
deleteToken(database, options.parentSessionId, runtimeIdentity);
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
29
|
/**
|
|
67
|
-
* Mints the runtime identity and
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* child and the host starts a panel one.
|
|
30
|
+
* Mints the runtime identity and builds the one LaunchSpec for this presentation. Who starts the
|
|
31
|
+
* child differs (this parent for a background child, the host for a panel one), but the spec does
|
|
32
|
+
* not.
|
|
71
33
|
*/
|
|
72
|
-
function planChildRuntime(record,
|
|
34
|
+
function planChildRuntime(record, presentation) {
|
|
73
35
|
const runtimeIdentity = randomUUID();
|
|
74
|
-
const token = randomBytes(32).toString("hex");
|
|
75
36
|
const endpoint = parentBridgeEndpoint(record.parentSessionId);
|
|
76
37
|
const spec = buildLaunchSpec({
|
|
77
38
|
config: record.launchConfig,
|
|
@@ -85,8 +46,7 @@ function planChildRuntime(record, tokens, presentation) {
|
|
|
85
46
|
endpoint,
|
|
86
47
|
},
|
|
87
48
|
});
|
|
88
|
-
|
|
89
|
-
return { runtimeIdentity, endpoint, spec: { ...spec, env: withBridgeToken(spec.env, token) } };
|
|
49
|
+
return { runtimeIdentity, endpoint, spec };
|
|
90
50
|
}
|
|
91
51
|
/** Evidence first: the record names the runtime that was asked for even if starting it fails. */
|
|
92
52
|
async function recordRuntimeEvidence(registry, record, plan) {
|
|
@@ -98,13 +58,13 @@ async function recordRuntimeEvidence(registry, record, plan) {
|
|
|
98
58
|
}
|
|
99
59
|
/**
|
|
100
60
|
* Starts one headless child Pi and waits for it to connect. Nothing is driven over its stdio: the
|
|
101
|
-
* process gets the parent's
|
|
102
|
-
*
|
|
103
|
-
*
|
|
61
|
+
* process gets the parent's mailbox path in its environment, and the bridge is the only control
|
|
62
|
+
* channel. A child that never connects is terminated here rather than left running with no way to
|
|
63
|
+
* reach it.
|
|
104
64
|
*/
|
|
105
65
|
export async function launchChild(options) {
|
|
106
66
|
const record = options.record;
|
|
107
|
-
const plan = planChildRuntime(record,
|
|
67
|
+
const plan = planChildRuntime(record, "background");
|
|
108
68
|
await recordRuntimeEvidence(options.registry, record, plan);
|
|
109
69
|
const runtime = spawnChildRuntime({
|
|
110
70
|
command: plan.spec.command,
|
|
@@ -133,13 +93,12 @@ export async function launchChild(options) {
|
|
|
133
93
|
failure: `Child ${record.subagentId} never connected to the parent bridge and did not stop (it may still be running)`,
|
|
134
94
|
};
|
|
135
95
|
}
|
|
136
|
-
options.tokens.forget(plan.runtimeIdentity);
|
|
137
96
|
await commitLease(options.registry, record.subagentId, { type: "runtime-gone", outcome: "clear" }, undefined, plan.runtimeIdentity);
|
|
138
97
|
return { failure: `Child ${record.subagentId} never connected to the parent bridge` };
|
|
139
98
|
}
|
|
140
99
|
export async function openChildPanel(options) {
|
|
141
100
|
const record = options.record;
|
|
142
|
-
const plan = planChildRuntime(record,
|
|
101
|
+
const plan = planChildRuntime(record, "panel");
|
|
143
102
|
await recordRuntimeEvidence(options.registry, record, plan);
|
|
144
103
|
let attachment;
|
|
145
104
|
try {
|
|
@@ -200,7 +159,6 @@ export async function openChildPanel(options) {
|
|
|
200
159
|
failure: `Child ${record.subagentId} never connected to the parent bridge and its panel could not be closed (${cleanup.stderr || "its state could not be observed"}); close it manually`,
|
|
201
160
|
};
|
|
202
161
|
}
|
|
203
|
-
options.tokens.forget(plan.runtimeIdentity);
|
|
204
162
|
await commitLease(options.registry, record.subagentId, { type: "runtime-gone", outcome: "clear" }, undefined, plan.runtimeIdentity);
|
|
205
163
|
const detail = earlyExitObserved?.detail ? ` (${earlyExitObserved.detail.trim()})` : "";
|
|
206
164
|
return { failure: `Child ${record.subagentId} never connected to the parent bridge${detail}` };
|
|
@@ -165,15 +165,12 @@ export function createSubagentId(existingIds) {
|
|
|
165
165
|
export async function resolveSubagentLaunch(options) {
|
|
166
166
|
const task = options.input.task.trim();
|
|
167
167
|
if (task === "")
|
|
168
|
-
throw new Error("
|
|
168
|
+
throw new Error("agents_delegate requires a non-empty task");
|
|
169
169
|
const name = options.input.agent?.trim();
|
|
170
170
|
if (name === undefined || name === "") {
|
|
171
|
-
throw new Error("
|
|
171
|
+
throw new Error("agents_delegate requires an explicit agent name; V1 has no built-in default agent");
|
|
172
172
|
}
|
|
173
173
|
const cwd = resolve(options.input.cwd?.trim() || options.cwd);
|
|
174
|
-
const requestedTitle = options.input.title?.trim();
|
|
175
|
-
// A blank title is the same as no title: the child derives one from its own identity.
|
|
176
|
-
const title = requestedTitle === "" ? undefined : requestedTitle;
|
|
177
174
|
const subagentId = options.subagentId ?? createSubagentId(options.existingSubagentIds);
|
|
178
175
|
const sessionId = options.sessionId ?? randomUUID();
|
|
179
176
|
const bridgeExtensionPath = options.bridgeExtensionPath ?? resolveBridgeExtensionPath();
|
|
@@ -209,7 +206,6 @@ export async function resolveSubagentLaunch(options) {
|
|
|
209
206
|
prompt: assembleChildPrompt(instructions),
|
|
210
207
|
bridgeExtensionPath,
|
|
211
208
|
...(policy.codemodeOnly ? { codemodeOnly: true } : {}),
|
|
212
|
-
...(title === undefined ? {} : { title }),
|
|
213
209
|
});
|
|
214
210
|
}
|
|
215
211
|
/**
|
package/dist/state-db.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite";
|
|
2
|
-
/** The one durable file for child records,
|
|
2
|
+
/** The one durable file for child records, the mailbox, and delegation receipts. */
|
|
3
3
|
export declare function defaultStatePath(): string;
|
|
4
4
|
export declare function openStateDb(filePath: string): DatabaseSync;
|
|
5
5
|
export declare function withWrite<T>(db: DatabaseSync, run: () => T): T;
|
|
@@ -47,6 +47,13 @@ export declare function finishCommand(db: DatabaseSync, id: string, outcome: {
|
|
|
47
47
|
readonly errorCode: string;
|
|
48
48
|
readonly error: string;
|
|
49
49
|
}): void;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
/**
|
|
51
|
+
* A settled command whose waiter died with its process has no reader left. Only a parent that is
|
|
52
|
+
* taking over the mailbox may run this: a live waiter deletes its own row when it reads it.
|
|
53
|
+
*/
|
|
54
|
+
export declare function deleteSettledCommands(db: DatabaseSync, parentSessionId: string): void;
|
|
55
|
+
/**
|
|
56
|
+
* Keeps only the newest `keep` queued events for one child. Events are advisory snapshots, so a
|
|
57
|
+
* parent that stopped reading must not leave an unbounded backlog behind.
|
|
58
|
+
*/
|
|
59
|
+
export declare function trimEventBacklog(db: DatabaseSync, parentSessionId: string, subagentId: string, keep: number): void;
|