@hheei/pi-subagents 0.8.0 → 0.9.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/README.md +1 -1
- package/dist/delegation.js +1 -1
- package/dist/lease.d.ts +2 -4
- package/dist/lease.js +9 -32
- package/dist/manager.js +54 -25
- package/dist/markdown.d.ts +0 -7
- package/dist/markdown.js +10 -12
- package/dist/registry.d.ts +4 -5
- package/dist/registry.js +6 -12
- package/dist/runtime.js +2 -2
- package/dist/session-bootstrap.d.ts +6 -1
- package/dist/session-bootstrap.js +13 -21
- package/dist/state-db.d.ts +3 -6
- package/dist/state-db.js +24 -23
- package/dist/tools.js +6 -5
- package/package.json +7 -8
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ children and `/subagents stop [id]` to stop one without conflicting with other e
|
|
|
15
15
|
|
|
16
16
|
| Tool | Purpose |
|
|
17
17
|
| --- | --- |
|
|
18
|
-
| `agents_delegate({ task, agent?, id?, cwd?, wait? })` | Delegate work to a new agent (`agent`) or continue an existing one (`id`, which wins over `agent`). A new child runs in a new Herdr tab (or cmux surface) targeting the parent's workspace when a host is available, and headless in the background otherwise. **Prefer reusing existing agents** via `id` for related/follow-up work to keep context and maximize prompt-cache and memory efficiency. The tool description lists available agent names and roles, excluding disabled or hidden definitions. Deferred by default; discover via `tool_search({ query: "agent" })`. `wait:true` (default) returns the settled delegation, `wait:false` returns it as soon as it is accepted; either way the result arrives automatically, so do not poll. |
|
|
18
|
+
| `agents_delegate({ task, agent?, id?, cwd?, wait? })` | Delegate work to a new agent (`agent`) or continue an existing one (`id`, which wins over `agent`). Omit `id` to create an agent: Pi assigns the id, so an unknown or blank id is never a way to create one. A new child runs in a new Herdr tab (or cmux surface) targeting the parent's workspace when a host is available, and headless in the background otherwise. **Prefer reusing existing agents** via `id` for related/follow-up work to keep context and maximize prompt-cache and memory efficiency. The tool description lists available agent names and roles, excluding disabled or hidden definitions. Deferred by default; discover via `tool_search({ query: "agent" })`. `wait:true` (default) returns the settled delegation, `wait:false` returns it as soon as it is accepted; either way the result arrives automatically, so do not poll. |
|
|
19
19
|
| `agents_get({ id, revision?, includeOutput? })` | Read one delegation of an agent: the newest by default, `revision` for one specific delegation, and the result body only with `includeOutput: true` (bounded by the shared output limit). The view carries the delegation `status` and the runtime `progress.runtimeState` separately, so a new delegation never overwrites a historical result. From the second successful inspection of the same agent in this parent runtime, the result adds a short reminder that results return automatically and must not be polled. Deferred by default; discover via `tool_search({ query: "agent" })`. |
|
|
20
20
|
| `agents_stop({ id })` | Stop one agent's runtime by id. Open delegations end as failed; finished results and the session are kept, so a later `agents_delegate` with this id resumes it. Deferred by default; discover via `tool_search({ query: "agent" })`. |
|
|
21
21
|
| `agents_dashboard({})` | Summarise this session's agents: runtime counts plus each agent's latest delegation status and activity, never result bodies. Deferred by default; discover via `tool_search({ query: "agent" })`. |
|
package/dist/delegation.js
CHANGED
|
@@ -6,7 +6,7 @@ import { receiptCarriesOutput } from "./receipts.js";
|
|
|
6
6
|
*/
|
|
7
7
|
export function delegationView(receipt, child, options) {
|
|
8
8
|
if (receipt.childId === undefined || receipt.revision === undefined) {
|
|
9
|
-
throw new Error(
|
|
9
|
+
throw new Error("Delegation has no public identity");
|
|
10
10
|
}
|
|
11
11
|
const text = receipt.resultText;
|
|
12
12
|
const hasOutput = receiptCarriesOutput(receipt.status, text);
|
package/dist/lease.d.ts
CHANGED
|
@@ -57,14 +57,12 @@ export type LeaseReady = {
|
|
|
57
57
|
readonly rebound: readonly string[];
|
|
58
58
|
readonly rehydrated: readonly string[];
|
|
59
59
|
readonly stopped: readonly string[];
|
|
60
|
-
/** Children whose runtime was dropped because the writer is gone. */
|
|
61
|
-
readonly released: readonly string[];
|
|
62
60
|
} | {
|
|
63
61
|
readonly ok: false;
|
|
64
62
|
readonly reason: "unknown";
|
|
65
63
|
};
|
|
66
64
|
export interface ConversationLease {
|
|
67
|
-
commit(id: string, edge: LeaseEdge
|
|
65
|
+
commit(id: string, edge: LeaseEdge): Promise<SubagentRecord>;
|
|
68
66
|
roster(): Promise<readonly SubagentRecord[]>;
|
|
69
67
|
ready(): Promise<LeaseReady>;
|
|
70
68
|
}
|
|
@@ -74,7 +72,7 @@ export declare function createConversationLease(options: {
|
|
|
74
72
|
readonly hosts: HostDirectory;
|
|
75
73
|
readonly processes: ProcessDirectory;
|
|
76
74
|
}): ConversationLease;
|
|
77
|
-
export declare function commitLease(registry: Pick<SubagentRegistry, "get" | "update">, id: string, edge: LeaseEdge,
|
|
75
|
+
export declare function commitLease(registry: Pick<SubagentRegistry, "get" | "update">, id: string, edge: LeaseEdge, expectedRuntimeIdentity?: string): Promise<SubagentRecord>;
|
|
78
76
|
/** `undefined` means the edge changes nothing, so the caller must not write. */
|
|
79
77
|
export declare function applyLeaseEdge(record: SubagentRecord, edge: LeaseEdge): SubagentRecord | undefined;
|
|
80
78
|
export declare function readHostGeneration(filePath: string, parentSessionId: string): Promise<string | undefined>;
|
package/dist/lease.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { processLockHeld, processLockPath } from "./process-lock.js";
|
|
3
1
|
import { SubagentRegistryError } from "./registry.js";
|
|
4
2
|
import { openStateDb, readMeta, writeMeta } from "./state-db.js";
|
|
5
3
|
/**
|
|
@@ -23,8 +21,8 @@ export const LEASE_EDGES = [
|
|
|
23
21
|
export function createConversationLease(options) {
|
|
24
22
|
const { registry, hosts, processes } = options;
|
|
25
23
|
return {
|
|
26
|
-
commit(id, edge
|
|
27
|
-
return commitLease(registry, id, edge
|
|
24
|
+
commit(id, edge) {
|
|
25
|
+
return commitLease(registry, id, edge);
|
|
28
26
|
},
|
|
29
27
|
roster() {
|
|
30
28
|
return registry.list();
|
|
@@ -34,7 +32,7 @@ export function createConversationLease(options) {
|
|
|
34
32
|
},
|
|
35
33
|
};
|
|
36
34
|
}
|
|
37
|
-
export async function commitLease(registry, id, edge,
|
|
35
|
+
export async function commitLease(registry, id, edge, expectedRuntimeIdentity) {
|
|
38
36
|
const current = await registry.get(id);
|
|
39
37
|
if (current === undefined) {
|
|
40
38
|
throw new SubagentRegistryError("unknown_child", `Unknown child ${id}`);
|
|
@@ -42,7 +40,7 @@ export async function commitLease(registry, id, edge, expectedRevision, expected
|
|
|
42
40
|
const next = applyLeaseEdge(current, edge);
|
|
43
41
|
if (next === undefined)
|
|
44
42
|
return current;
|
|
45
|
-
return registry.update(id,
|
|
43
|
+
return registry.update(id, () => next, expectedRuntimeIdentity);
|
|
46
44
|
}
|
|
47
45
|
/** `undefined` means the edge changes nothing, so the caller must not write. */
|
|
48
46
|
export function applyLeaseEdge(record, edge) {
|
|
@@ -131,26 +129,7 @@ async function reconcile(registry, hosts, processes) {
|
|
|
131
129
|
if (!list.known)
|
|
132
130
|
return { ok: false, reason: "unknown" };
|
|
133
131
|
const all = await registry.list();
|
|
134
|
-
const
|
|
135
|
-
for (const record of all) {
|
|
136
|
-
if (record.runtime === undefined)
|
|
137
|
-
continue;
|
|
138
|
-
const lockPath = processLockPath(registry.path, record.parentSessionId, record.subagentId);
|
|
139
|
-
if (lockPath === undefined || !existsSync(lockPath))
|
|
140
|
-
continue;
|
|
141
|
-
let held = true;
|
|
142
|
-
try {
|
|
143
|
-
held = processLockHeld(lockPath);
|
|
144
|
-
}
|
|
145
|
-
catch {
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
if (held)
|
|
149
|
-
continue;
|
|
150
|
-
await releaseRuntime(registry, record, { type: "runtime-gone", outcome: "drop" });
|
|
151
|
-
released.add(record.subagentId);
|
|
152
|
-
}
|
|
153
|
-
const records = all.filter((record) => !released.has(record.subagentId));
|
|
132
|
+
const records = all;
|
|
154
133
|
const parentSessionId = all[0]?.parentSessionId;
|
|
155
134
|
const stored = parentSessionId === undefined
|
|
156
135
|
? undefined
|
|
@@ -200,16 +179,14 @@ async function reconcile(registry, hosts, processes) {
|
|
|
200
179
|
const attachment = record.runtime?.attachment;
|
|
201
180
|
if (attachment?.kind !== "process")
|
|
202
181
|
continue;
|
|
203
|
-
|
|
182
|
+
// A dead pid is a liveness answer, and liveness is read from the lock, never written back.
|
|
183
|
+
// An alive one still has to be re-watched after a reload.
|
|
184
|
+
if (processes.alive(attachment.pid))
|
|
204
185
|
rebound.push(record.subagentId);
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
await releaseRuntime(registry, record, { type: "runtime-gone", outcome: "drop" });
|
|
208
|
-
released.add(record.subagentId);
|
|
209
186
|
}
|
|
210
187
|
if (!held && parentSessionId !== undefined && stored !== list.generation)
|
|
211
188
|
await writeHostGeneration(registry.path, parentSessionId, list.generation);
|
|
212
|
-
return { ok: true, rebound, rehydrated, stopped
|
|
189
|
+
return { ok: true, rebound, rehydrated, stopped };
|
|
213
190
|
}
|
|
214
191
|
function panelId(record) {
|
|
215
192
|
const attachment = record.runtime?.attachment;
|
package/dist/manager.js
CHANGED
|
@@ -6,7 +6,8 @@ import { attachRetainedChild } from "./child-process.js";
|
|
|
6
6
|
import { agentView, delegationView, } from "./delegation.js";
|
|
7
7
|
import { isOperationError } from "./domain.js";
|
|
8
8
|
import { stripHindsightContent } from "./launch-spec.js";
|
|
9
|
-
import { commitLease, createConversationLease
|
|
9
|
+
import { commitLease, createConversationLease } from "./lease.js";
|
|
10
|
+
import { processLockHeld, processLockPath } from "./process-lock.js";
|
|
10
11
|
import { BridgeError, isChildInputPayload, isChildLifecyclePayload, isContactReportPayload, } from "./protocol.js";
|
|
11
12
|
import { abandonReceipts, latestReceiptForChild, latestReceiptSummaries, maxRunBoundary, readReceiptRevision, readStoredReceipt, reserveReceipt, settleReceipts, sweepControlReceipts, terminalReceipt, updateReceipt, waitForReceipt, } from "./receipts.js";
|
|
12
13
|
import { findSessionFile, planSessionPlacement } from "./session-bootstrap.js";
|
|
@@ -720,6 +721,33 @@ export class SubagentManager {
|
|
|
720
721
|
return true;
|
|
721
722
|
return this.#processes.get(id)?.alive === true;
|
|
722
723
|
}
|
|
724
|
+
/**
|
|
725
|
+
* A child's writer holds its process lock for the whole run, so a free lock means no writer —
|
|
726
|
+
* including a child another parent process started, which this process never had a handle for.
|
|
727
|
+
* A probe that fails is not evidence of death.
|
|
728
|
+
*/
|
|
729
|
+
#runtimeAlive(record) {
|
|
730
|
+
const lockPath = processLockPath(this.#deps.registry.path, record.parentSessionId, record.subagentId);
|
|
731
|
+
if (lockPath === undefined)
|
|
732
|
+
return false;
|
|
733
|
+
try {
|
|
734
|
+
return processLockHeld(lockPath);
|
|
735
|
+
}
|
|
736
|
+
catch {
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* The public projection every read returns: a record that still says `running` while no writer
|
|
742
|
+
* holds its lock reads as blocked. Nothing is written, so a death this process did not see happen
|
|
743
|
+
* still reads correctly.
|
|
744
|
+
*/
|
|
745
|
+
#project(record) {
|
|
746
|
+
const snapshot = this.#withSnapshot(record);
|
|
747
|
+
const live = this.#isLive(record.subagentId) || this.#runtimeAlive(record);
|
|
748
|
+
const state = live || snapshot.state !== "running" ? snapshot.state : "blocked";
|
|
749
|
+
return publicChild({ ...snapshot, state }, live, this.#paths.get(record.subagentId));
|
|
750
|
+
}
|
|
723
751
|
/**
|
|
724
752
|
* True only when the host positively reports the panel is gone. An unobservable panel is not a
|
|
725
753
|
* closed one, and treating it as closed is what would let a second runtime start for a session
|
|
@@ -1138,7 +1166,7 @@ export class SubagentManager {
|
|
|
1138
1166
|
const active = input.id === undefined ? undefined : this.#waiters.get(input.id);
|
|
1139
1167
|
if (wait && active !== undefined) {
|
|
1140
1168
|
// Refused before anything is dispatched: a conflicted wait must not steer first.
|
|
1141
|
-
return failure("delegate", `Child ${input.id ?? ""} already has a waiting delegation
|
|
1169
|
+
return failure("delegate", `Child ${input.id ?? ""} already has a waiting delegation`, input.id);
|
|
1142
1170
|
}
|
|
1143
1171
|
const target = {};
|
|
1144
1172
|
const delivery = wait ? "pending" : "detached";
|
|
@@ -1480,8 +1508,7 @@ export class SubagentManager {
|
|
|
1480
1508
|
const record = await this.#deps.registry.get(id);
|
|
1481
1509
|
if (record === undefined)
|
|
1482
1510
|
return failure("get", "Unknown child", id);
|
|
1483
|
-
|
|
1484
|
-
return publicChild(this.#withSnapshot(record), live, this.#paths.get(id));
|
|
1511
|
+
return this.#project(record);
|
|
1485
1512
|
}
|
|
1486
1513
|
/**
|
|
1487
1514
|
* One delegation of one agent, by revision: the newest by default. The result body is only read
|
|
@@ -1500,7 +1527,9 @@ export class SubagentManager {
|
|
|
1500
1527
|
? "This agent has no delegation"
|
|
1501
1528
|
: `This agent has no delegation revision ${input.revision}`, input.id, record.state);
|
|
1502
1529
|
}
|
|
1503
|
-
return agentView(receipt,
|
|
1530
|
+
return agentView(receipt, this.#project(record), {
|
|
1531
|
+
includeOutput: input.includeOutput === true,
|
|
1532
|
+
});
|
|
1504
1533
|
}
|
|
1505
1534
|
/** Every agent of this parent: runtime state now, latest delegation status, no result bodies. */
|
|
1506
1535
|
async dashboard() {
|
|
@@ -1511,15 +1540,15 @@ export class SubagentManager {
|
|
|
1511
1540
|
const summary = summaries.get(record.subagentId);
|
|
1512
1541
|
if (summary === undefined)
|
|
1513
1542
|
continue;
|
|
1514
|
-
const
|
|
1543
|
+
const child = this.#project(record);
|
|
1515
1544
|
children.push({
|
|
1516
|
-
id:
|
|
1517
|
-
agent:
|
|
1518
|
-
runtimeState:
|
|
1545
|
+
id: child.id,
|
|
1546
|
+
agent: child.agent,
|
|
1547
|
+
runtimeState: child.state,
|
|
1519
1548
|
revision: summary.revision,
|
|
1520
1549
|
status: summary.status,
|
|
1521
1550
|
hasOutput: summary.hasOutput,
|
|
1522
|
-
...(
|
|
1551
|
+
...(child.summary === undefined ? {} : { summary: child.summary }),
|
|
1523
1552
|
});
|
|
1524
1553
|
}
|
|
1525
1554
|
const count = (state) => children.filter((child) => child.runtimeState === state).length;
|
|
@@ -1541,7 +1570,7 @@ export class SubagentManager {
|
|
|
1541
1570
|
}
|
|
1542
1571
|
async list() {
|
|
1543
1572
|
const records = await this.#deps.registry.list();
|
|
1544
|
-
return records.map((record) =>
|
|
1573
|
+
return records.map((record) => this.#project(record));
|
|
1545
1574
|
}
|
|
1546
1575
|
stop(id) {
|
|
1547
1576
|
this.#clearSettledPanelClose(id);
|
|
@@ -1918,7 +1947,6 @@ export class SubagentManager {
|
|
|
1918
1947
|
rebound: recovery.recovered,
|
|
1919
1948
|
rehydrated: [],
|
|
1920
1949
|
stopped: [],
|
|
1921
|
-
released: [],
|
|
1922
1950
|
};
|
|
1923
1951
|
}
|
|
1924
1952
|
const lease = createConversationLease({
|
|
@@ -1943,18 +1971,19 @@ export class SubagentManager {
|
|
|
1943
1971
|
this.#watchRuntime(id, runtime);
|
|
1944
1972
|
}
|
|
1945
1973
|
}
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1974
|
+
// A writer that was already gone when this process started cannot report itself, and a
|
|
1975
|
+
// projection is not a delivery. This is the one place liveness is written back, once per
|
|
1976
|
+
// process because `ready` is memoized, through the edge an exit takes. A panel this pass just
|
|
1977
|
+
// reopened is skipped: its writer is starting, not gone.
|
|
1978
|
+
const gone = (await this.#deps.registry.list()).filter((record) => record.state === "running" &&
|
|
1979
|
+
record.intent !== "stopped" &&
|
|
1980
|
+
!result.rehydrated.includes(record.subagentId) &&
|
|
1981
|
+
!this.#isLive(record.subagentId) &&
|
|
1982
|
+
!this.#runtimeAlive(record));
|
|
1983
|
+
for (const record of gone)
|
|
1984
|
+
await this.#settleUnrecovered(record);
|
|
1949
1985
|
return result;
|
|
1950
1986
|
}
|
|
1951
|
-
/** A writer the lease already dropped. Same ledger line as a watched exit. */
|
|
1952
|
-
async #noteReleasedRuntime(id) {
|
|
1953
|
-
const record = await this.#deps.registry.get(id);
|
|
1954
|
-
if (record?.state !== "blocked" || record.interrupted !== RUNTIME_ENDED_RESUME)
|
|
1955
|
-
return;
|
|
1956
|
-
this.#scheduleAutoReport(id, record);
|
|
1957
|
-
}
|
|
1958
1987
|
#withSnapshot(record) {
|
|
1959
1988
|
const projector = this.#projectors.get(record.subagentId);
|
|
1960
1989
|
if (projector === undefined)
|
|
@@ -1973,9 +2002,9 @@ export class SubagentManager {
|
|
|
1973
2002
|
return this.#mutate(id, () => this.#update(id, updater));
|
|
1974
2003
|
}
|
|
1975
2004
|
async #update(id, updater, workVersion) {
|
|
1976
|
-
// The store re-reads the record inside its own write, so
|
|
1977
|
-
//
|
|
1978
|
-
const next = await this.#deps.registry.update(id,
|
|
2005
|
+
// The store re-reads the record inside its own write, so the write cannot lose a concurrent
|
|
2006
|
+
// field update and needs no retry.
|
|
2007
|
+
const next = await this.#deps.registry.update(id, updater);
|
|
1979
2008
|
if (workVersion === undefined || workVersion === (this.#workVersions.get(id) ?? 0)) {
|
|
1980
2009
|
if (next.state === "running")
|
|
1981
2010
|
this.#working.add(id);
|
package/dist/markdown.d.ts
CHANGED
|
@@ -1,8 +1 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Transforms plain agent and background task identifiers (e.g. `agent-1`, `bash-1`)
|
|
3
|
-
* in Markdown prose by wrapping them in inline code backticks, making them visually
|
|
4
|
-
* distinct in terminal TUI rendering.
|
|
5
|
-
*
|
|
6
|
-
* Preserves text inside code blocks, inline code, and URLs intact.
|
|
7
|
-
*/
|
|
8
1
|
export declare function transformSubagentsMarkdown(markdown: string): string;
|
package/dist/markdown.js
CHANGED
|
@@ -1,21 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Transforms plain agent and background task identifiers (e.g. `agent-
|
|
2
|
+
* Transforms plain agent and background task identifiers (e.g. `agent-3f9a1c`, `bash-1`)
|
|
3
3
|
* in Markdown prose by wrapping them in inline code backticks, making them visually
|
|
4
4
|
* distinct in terminal TUI rendering.
|
|
5
5
|
*
|
|
6
6
|
* Preserves text inside code blocks, inline code, and URLs intact.
|
|
7
7
|
*/
|
|
8
|
+
const IDENTIFIER = /(?:agent-[0-9a-f]{6}|bash-\d+)/;
|
|
9
|
+
/** Word boundary: the id is not part of a longer word or a markdown link URL. */
|
|
10
|
+
const PROSE_IDENTIFIER = /(?<![`/\w])((?:agent-[0-9a-f]{6}|bash-\d+))(?![`\w])/gu;
|
|
8
11
|
export function transformSubagentsMarkdown(markdown) {
|
|
9
|
-
if (
|
|
12
|
+
if (!IDENTIFIER.test(markdown))
|
|
10
13
|
return markdown;
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
// Match word boundary: agent-N or bash-N not inside markdown link URLs like (http.../agent-1)
|
|
17
|
-
parts[i] = part.replace(/(?<![`/\w])((?:agent|bash)-\d+)(?![`\w])/g, "`$1`");
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
return parts.join("");
|
|
14
|
+
// The split captures code blocks and inline code, so odd indexes are the parts to leave alone.
|
|
15
|
+
return markdown
|
|
16
|
+
.split(/(```[\s\S]*?```|`[^`\n]+`)/g)
|
|
17
|
+
.map((part, index) => (index % 2 === 1 ? part : part.replace(PROSE_IDENTIFIER, "`$1`")))
|
|
18
|
+
.join("");
|
|
21
19
|
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SubagentRecord } from "./domain.js";
|
|
2
|
-
export type RegistryErrorCode = "invalid_parent_session" | "corrupt_registry" | "unsupported_version" | "parent_mismatch" | "invalid_record" | "duplicate_child" | "unknown_child" | "
|
|
2
|
+
export type RegistryErrorCode = "invalid_parent_session" | "corrupt_registry" | "unsupported_version" | "parent_mismatch" | "invalid_record" | "duplicate_child" | "unknown_child" | "runtime_mismatch" | "stopped_child";
|
|
3
3
|
export declare class SubagentRegistryError extends Error {
|
|
4
4
|
readonly code: RegistryErrorCode;
|
|
5
5
|
constructor(code: RegistryErrorCode, message: string);
|
|
@@ -17,12 +17,11 @@ export interface SubagentRegistry {
|
|
|
17
17
|
list(signal?: AbortSignal): Promise<readonly SubagentRecord[]>;
|
|
18
18
|
create(record: SubagentRecord, signal?: AbortSignal): Promise<SubagentRecord>;
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* already been replaced by a newer one. The registry owns `revision` and
|
|
20
|
+
* Last write wins. `expectedRuntimeIdentity` refuses an update whose target runtime
|
|
21
|
+
* has already been replaced by a newer one. The registry owns `revision` and
|
|
23
22
|
* `updatedAt`; the updater's values for those fields are ignored.
|
|
24
23
|
*/
|
|
25
|
-
update(id: string,
|
|
24
|
+
update(id: string, updater: (record: SubagentRecord) => SubagentRecord, expectedRuntimeIdentity?: string, signal?: AbortSignal): Promise<SubagentRecord>;
|
|
26
25
|
}
|
|
27
26
|
export interface CreateSubagentRegistryOptions {
|
|
28
27
|
readonly parentSessionId: string;
|
package/dist/registry.js
CHANGED
|
@@ -406,10 +406,10 @@ export function createSubagentRegistry(options) {
|
|
|
406
406
|
const now = options.now ?? (() => new Date());
|
|
407
407
|
const db = openStateDb(path);
|
|
408
408
|
const load = (id) => {
|
|
409
|
-
const
|
|
410
|
-
if (
|
|
409
|
+
const record = readChild(db, parentSessionId, id);
|
|
410
|
+
if (record === undefined)
|
|
411
411
|
return undefined;
|
|
412
|
-
return parseSubagentRecord(JSON.parse(
|
|
412
|
+
return parseSubagentRecord(JSON.parse(record), id, parentSessionId, path);
|
|
413
413
|
};
|
|
414
414
|
return {
|
|
415
415
|
path,
|
|
@@ -432,21 +432,18 @@ export function createSubagentRegistry(options) {
|
|
|
432
432
|
},
|
|
433
433
|
async create(record) {
|
|
434
434
|
const candidate = parseSubagentRecord(record, record.subagentId, parentSessionId, path);
|
|
435
|
-
const inserted = withWrite(db, () => insertChild(db, parentSessionId, candidate.subagentId,
|
|
435
|
+
const inserted = withWrite(db, () => insertChild(db, parentSessionId, candidate.subagentId, JSON.stringify(candidate)));
|
|
436
436
|
if (!inserted) {
|
|
437
437
|
throw new SubagentRegistryError("duplicate_child", `Child ${candidate.subagentId} already exists in ${path}`);
|
|
438
438
|
}
|
|
439
439
|
return candidate;
|
|
440
440
|
},
|
|
441
|
-
async update(id,
|
|
441
|
+
async update(id, updater, expectedRuntimeIdentity) {
|
|
442
442
|
return withWrite(db, () => {
|
|
443
443
|
const current = load(id);
|
|
444
444
|
if (current === undefined) {
|
|
445
445
|
throw new SubagentRegistryError("unknown_child", `Unknown child ${id} in ${path}`);
|
|
446
446
|
}
|
|
447
|
-
if (expectedRevision !== undefined && current.revision !== expectedRevision) {
|
|
448
|
-
throw new SubagentRegistryError("stale_revision", `Child ${id} is at revision ${current.revision}, not ${expectedRevision}`);
|
|
449
|
-
}
|
|
450
447
|
if (expectedRuntimeIdentity !== undefined &&
|
|
451
448
|
current.runtime?.runtimeIdentity !== expectedRuntimeIdentity) {
|
|
452
449
|
throw new SubagentRegistryError("runtime_mismatch", `Child ${id} runtime is ${current.runtime?.runtimeIdentity ?? "none"}, not ${expectedRuntimeIdentity}`);
|
|
@@ -459,10 +456,7 @@ export function createSubagentRegistry(options) {
|
|
|
459
456
|
revision: current.revision + 1,
|
|
460
457
|
updatedAt: now().toISOString(),
|
|
461
458
|
}, id, parentSessionId, path);
|
|
462
|
-
|
|
463
|
-
if (!replaced) {
|
|
464
|
-
throw new SubagentRegistryError("stale_revision", `Child ${id} changed while it was being updated`);
|
|
465
|
-
}
|
|
459
|
+
replaceChild(db, parentSessionId, id, JSON.stringify(next));
|
|
466
460
|
return next;
|
|
467
461
|
});
|
|
468
462
|
},
|
package/dist/runtime.js
CHANGED
|
@@ -93,7 +93,7 @@ export async function launchChild(options) {
|
|
|
93
93
|
failure: `Child ${record.subagentId} never connected to the parent bridge and did not stop (it may still be running)`,
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
|
-
await commitLease(options.registry, record.subagentId, { type: "runtime-gone", outcome: "clear" },
|
|
96
|
+
await commitLease(options.registry, record.subagentId, { type: "runtime-gone", outcome: "clear" }, plan.runtimeIdentity);
|
|
97
97
|
return { failure: `Child ${record.subagentId} never connected to the parent bridge` };
|
|
98
98
|
}
|
|
99
99
|
export async function openChildPanel(options) {
|
|
@@ -159,7 +159,7 @@ export async function openChildPanel(options) {
|
|
|
159
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`,
|
|
160
160
|
};
|
|
161
161
|
}
|
|
162
|
-
await commitLease(options.registry, record.subagentId, { type: "runtime-gone", outcome: "clear" },
|
|
162
|
+
await commitLease(options.registry, record.subagentId, { type: "runtime-gone", outcome: "clear" }, plan.runtimeIdentity);
|
|
163
163
|
const detail = earlyExitObserved?.detail ? ` (${earlyExitObserved.detail.trim()})` : "";
|
|
164
164
|
return { failure: `Child ${record.subagentId} never connected to the parent bridge${detail}` };
|
|
165
165
|
}
|
|
@@ -44,7 +44,12 @@ export declare function findSessionFile(sessionDir: string, sessionId: string):
|
|
|
44
44
|
* random id; a flushed session must still prove its identity on disk.
|
|
45
45
|
*/
|
|
46
46
|
export declare function planSessionPlacement(options: PlanSessionPlacementOptions): Promise<SessionPlacement>;
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* A child id is a label, not a capability: short enough for a status line and a lock file name,
|
|
49
|
+
* and lowercase so it cannot collide with a differently cased copy on a case-insensitive
|
|
50
|
+
* filesystem. Nothing arbitrates allocation across processes; `existingIds` is the local check and
|
|
51
|
+
* the ledger's primary key is the backstop.
|
|
52
|
+
*/
|
|
48
53
|
export declare function createSubagentId(existingIds?: readonly string[]): string;
|
|
49
54
|
export interface ResolveSubagentLaunchOptions {
|
|
50
55
|
readonly input: SpawnSubagentInput;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { open, readdir, readFile, stat } from "node:fs/promises";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { getAgentDir, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
@@ -135,27 +135,19 @@ export async function planSessionPlacement(options) {
|
|
|
135
135
|
}
|
|
136
136
|
return { sessionId: options.sessionId, sessionDir, persistence: "never_flushed" };
|
|
137
137
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (num > max)
|
|
150
|
-
max = num;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
if (max > 0) {
|
|
154
|
-
nextSubagentIndex = Math.max(nextSubagentIndex, max);
|
|
155
|
-
}
|
|
138
|
+
/**
|
|
139
|
+
* A child id is a label, not a capability: short enough for a status line and a lock file name,
|
|
140
|
+
* and lowercase so it cannot collide with a differently cased copy on a case-insensitive
|
|
141
|
+
* filesystem. Nothing arbitrates allocation across processes; `existingIds` is the local check and
|
|
142
|
+
* the ledger's primary key is the backstop.
|
|
143
|
+
*/
|
|
144
|
+
export function createSubagentId(existingIds = []) {
|
|
145
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
146
|
+
const id = `agent-${randomBytes(3).toString("hex")}`;
|
|
147
|
+
if (!existingIds.includes(id))
|
|
148
|
+
return id;
|
|
156
149
|
}
|
|
157
|
-
|
|
158
|
-
return `agent-${nextSubagentIndex}`;
|
|
150
|
+
throw new Error("Could not allocate a unique subagent id");
|
|
159
151
|
}
|
|
160
152
|
/**
|
|
161
153
|
* One resolution chain for a spawn: agent definition + explicit parent defaults
|
package/dist/state-db.d.ts
CHANGED
|
@@ -15,13 +15,10 @@ export interface StoredCommand {
|
|
|
15
15
|
readonly errorCode: string | null;
|
|
16
16
|
readonly error: string | null;
|
|
17
17
|
}
|
|
18
|
-
export declare function insertChild(db: DatabaseSync, parentSessionId: string, subagentId: string,
|
|
19
|
-
export declare function readChild(db: DatabaseSync, parentSessionId: string, subagentId: string):
|
|
20
|
-
readonly revision: number;
|
|
21
|
-
readonly record: string;
|
|
22
|
-
} | undefined;
|
|
18
|
+
export declare function insertChild(db: DatabaseSync, parentSessionId: string, subagentId: string, record: string): boolean;
|
|
19
|
+
export declare function readChild(db: DatabaseSync, parentSessionId: string, subagentId: string): string | undefined;
|
|
23
20
|
export declare function listChildRecords(db: DatabaseSync, parentSessionId: string): readonly string[];
|
|
24
|
-
export declare function replaceChild(db: DatabaseSync, parentSessionId: string, subagentId: string,
|
|
21
|
+
export declare function replaceChild(db: DatabaseSync, parentSessionId: string, subagentId: string, record: string): void;
|
|
25
22
|
export declare function readMeta(db: DatabaseSync, parentSessionId: string, key: string): string | undefined;
|
|
26
23
|
export declare function writeMeta(db: DatabaseSync, parentSessionId: string, key: string, value: string): void;
|
|
27
24
|
export declare function enqueueCommand(db: DatabaseSync, command: {
|
package/dist/state-db.js
CHANGED
|
@@ -11,12 +11,11 @@ export function defaultStatePath() {
|
|
|
11
11
|
* instead of being rewritten: this file is runtime state, but silently discarding another build's
|
|
12
12
|
* records is worse than failing to start.
|
|
13
13
|
*/
|
|
14
|
-
const SCHEMA_VERSION =
|
|
14
|
+
const SCHEMA_VERSION = 3;
|
|
15
15
|
const TABLES = `
|
|
16
16
|
CREATE TABLE IF NOT EXISTS children (
|
|
17
17
|
parent_session_id TEXT NOT NULL,
|
|
18
18
|
subagent_id TEXT NOT NULL,
|
|
19
|
-
revision INTEGER NOT NULL,
|
|
20
19
|
record TEXT NOT NULL,
|
|
21
20
|
PRIMARY KEY (parent_session_id, subagent_id)
|
|
22
21
|
);
|
|
@@ -80,7 +79,15 @@ const ADDED_COLUMNS = [
|
|
|
80
79
|
["delegations", "safe_to_retry", "INTEGER"],
|
|
81
80
|
["delegations", "side_effects", "TEXT"],
|
|
82
81
|
];
|
|
82
|
+
/** Columns dropped from a table that already exists, the mirror of {@link ADDED_COLUMNS}. */
|
|
83
|
+
const DROPPED_COLUMNS = [
|
|
84
|
+
["children", "revision"],
|
|
85
|
+
];
|
|
83
86
|
const connections = new Map();
|
|
87
|
+
function hasColumn(db, table, column) {
|
|
88
|
+
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
89
|
+
return columns.some((existing) => existing.name === column);
|
|
90
|
+
}
|
|
84
91
|
function schemaVersion(db) {
|
|
85
92
|
return Number(db.prepare("PRAGMA user_version").get()?.user_version ?? 0);
|
|
86
93
|
}
|
|
@@ -97,10 +104,12 @@ function migrate(db) {
|
|
|
97
104
|
return;
|
|
98
105
|
db.exec(TABLES);
|
|
99
106
|
for (const [table, column, type] of ADDED_COLUMNS) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
107
|
+
if (!hasColumn(db, table, column))
|
|
108
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
|
|
109
|
+
}
|
|
110
|
+
for (const [table, column] of DROPPED_COLUMNS) {
|
|
111
|
+
if (hasColumn(db, table, column))
|
|
112
|
+
db.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`);
|
|
104
113
|
}
|
|
105
114
|
// The bridge token had no reader once the mailbox replaced the socket handshake.
|
|
106
115
|
db.exec("DROP TABLE IF EXISTS tokens");
|
|
@@ -159,19 +168,19 @@ function commandFromRow(row) {
|
|
|
159
168
|
};
|
|
160
169
|
}
|
|
161
170
|
const COMMAND_COLUMNS = "id, parent_session_id, subagent_id, direction, operation, payload, status, result, error_code, error";
|
|
162
|
-
export function insertChild(db, parentSessionId, subagentId,
|
|
171
|
+
export function insertChild(db, parentSessionId, subagentId, record) {
|
|
163
172
|
const result = db
|
|
164
|
-
.prepare(`INSERT INTO children (parent_session_id, subagent_id,
|
|
165
|
-
VALUES (?, ?,
|
|
173
|
+
.prepare(`INSERT INTO children (parent_session_id, subagent_id, record)
|
|
174
|
+
VALUES (?, ?, ?)
|
|
166
175
|
ON CONFLICT DO NOTHING`)
|
|
167
|
-
.run(parentSessionId, subagentId,
|
|
176
|
+
.run(parentSessionId, subagentId, record);
|
|
168
177
|
return Number(result.changes) === 1;
|
|
169
178
|
}
|
|
170
179
|
export function readChild(db, parentSessionId, subagentId) {
|
|
171
180
|
const row = db
|
|
172
|
-
.prepare("SELECT
|
|
181
|
+
.prepare("SELECT record FROM children WHERE parent_session_id = ? AND subagent_id = ?")
|
|
173
182
|
.get(parentSessionId, subagentId);
|
|
174
|
-
return row;
|
|
183
|
+
return row?.record;
|
|
175
184
|
}
|
|
176
185
|
export function listChildRecords(db, parentSessionId) {
|
|
177
186
|
const rows = db
|
|
@@ -179,17 +188,9 @@ export function listChildRecords(db, parentSessionId) {
|
|
|
179
188
|
.all(parentSessionId);
|
|
180
189
|
return rows.map((row) => row.record);
|
|
181
190
|
}
|
|
182
|
-
export function replaceChild(db, parentSessionId, subagentId,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
.prepare(`UPDATE children SET revision = ?, record = ?
|
|
186
|
-
WHERE parent_session_id = ? AND subagent_id = ?`)
|
|
187
|
-
.run(revision, record, parentSessionId, subagentId)
|
|
188
|
-
: db
|
|
189
|
-
.prepare(`UPDATE children SET revision = ?, record = ?
|
|
190
|
-
WHERE parent_session_id = ? AND subagent_id = ? AND revision = ?`)
|
|
191
|
-
.run(revision, record, parentSessionId, subagentId, expectedRevision);
|
|
192
|
-
return Number(result.changes) === 1;
|
|
191
|
+
export function replaceChild(db, parentSessionId, subagentId, record) {
|
|
192
|
+
db.prepare(`UPDATE children SET record = ?
|
|
193
|
+
WHERE parent_session_id = ? AND subagent_id = ?`).run(record, parentSessionId, subagentId);
|
|
193
194
|
}
|
|
194
195
|
export function readMeta(db, parentSessionId, key) {
|
|
195
196
|
const row = db
|
package/dist/tools.js
CHANGED
|
@@ -25,7 +25,7 @@ const delegateSchema = Type.Object({
|
|
|
25
25
|
})),
|
|
26
26
|
id: Type.Optional(Type.String({
|
|
27
27
|
minLength: 1,
|
|
28
|
-
description: "Existing agent id, e.g. agent-
|
|
28
|
+
description: "Existing agent id, e.g. agent-3f9a1c. Takes precedence over agent and reuses its session.",
|
|
29
29
|
})),
|
|
30
30
|
agent: Type.Optional(Type.String({
|
|
31
31
|
minLength: 1,
|
|
@@ -34,7 +34,7 @@ const delegateSchema = Type.Object({
|
|
|
34
34
|
cwd: Type.Optional(Type.String({ description: "Working directory for a new agent; ignored with id." })),
|
|
35
35
|
}, { additionalProperties: false });
|
|
36
36
|
const getSchema = Type.Object({
|
|
37
|
-
id: Type.String({ minLength: 1, description: "Agent id, e.g. agent-
|
|
37
|
+
id: Type.String({ minLength: 1, description: "Agent id, e.g. agent-3f9a1c" }),
|
|
38
38
|
revision: Type.Optional(Type.Integer({
|
|
39
39
|
minimum: 1,
|
|
40
40
|
description: "Delegation revision of this agent. Defaults to the newest one.",
|
|
@@ -44,7 +44,7 @@ const getSchema = Type.Object({
|
|
|
44
44
|
})),
|
|
45
45
|
}, { additionalProperties: false });
|
|
46
46
|
const idSchema = Type.Object({
|
|
47
|
-
id: Type.String({ minLength: 1, description: "Agent id, e.g. agent-
|
|
47
|
+
id: Type.String({ minLength: 1, description: "Agent id, e.g. agent-3f9a1c" }),
|
|
48
48
|
});
|
|
49
49
|
const dashboardSchema = Type.Object({}, { additionalProperties: false });
|
|
50
50
|
const contactSchema = Type.Object({
|
|
@@ -77,14 +77,15 @@ export function formatAvailableAgents(agents) {
|
|
|
77
77
|
}
|
|
78
78
|
function getDelegateDescription(agents) {
|
|
79
79
|
return [
|
|
80
|
-
"Delegate work to
|
|
80
|
+
"Delegate work to an agent. New: omit id and supply agent. Continue: use an existing id; agent/cwd are ignored. Unknown or blank ids fail.",
|
|
81
|
+
'Create: agents_delegate({ agent: "worker", task: "..." }). Continue: agents_delegate({ id: "agent-3f9a1c", task: "..." }).',
|
|
81
82
|
"Returns { id, revision, status, hasOutput }: revision identifies this delegation of that agent, and a finished delegation carries its result when wait is true. Results are delivered automatically when the task finishes, so do independent work or end your turn instead of polling agents_get.",
|
|
82
83
|
"Related agent tools: agents_get, agents_stop, agents_dashboard.",
|
|
83
84
|
formatAvailableAgents(agents),
|
|
84
85
|
].join("\n");
|
|
85
86
|
}
|
|
86
87
|
const GET_DESCRIPTION = "Read one agent delegation: the newest by default, or one revision, with its result body when includeOutput is true. Use it for inspection, not completion polling. Related agent tools: agents_delegate, agents_stop, agents_dashboard.";
|
|
87
|
-
const STOP_DESCRIPTION = "Stop an agent runtime by id (e.g. 'agent-
|
|
88
|
+
const STOP_DESCRIPTION = "Stop an agent runtime by id (e.g. 'agent-3f9a1c'). Open delegations end as failed; finished results and the session are kept, so a later agents_delegate with this id resumes it. Related agent tools: agents_delegate, agents_get, agents_dashboard.";
|
|
88
89
|
const DASHBOARD_DESCRIPTION = "List this session's agents with their runtime state and latest delegation. Related agent tools: agents_delegate, agents_get, agents_stop.";
|
|
89
90
|
const CONTACT_DESCRIPTION = "Notify the parent session when you are BLOCKED and cannot continue without parent decision or intervention. Do NOT call this on success or task completion: simply output your final answer directly as normal text in your response, and the harness will automatically capture and report your result to the parent.";
|
|
90
91
|
const CONTACT_SNIPPET = "Report a blocker to the parent. Do NOT call on success: write normal text to finish.";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hheei/pi-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Independent background Pi subagent sessions with durable identity and RPC control.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -27,30 +27,29 @@
|
|
|
27
27
|
"pi",
|
|
28
28
|
"pi-package",
|
|
29
29
|
"pi-extension",
|
|
30
|
-
"hepi",
|
|
31
30
|
"subagents"
|
|
32
31
|
],
|
|
33
32
|
"license": "MIT",
|
|
34
33
|
"repository": {
|
|
35
34
|
"type": "git",
|
|
36
|
-
"url": "git+https://github.com/hheei/
|
|
35
|
+
"url": "git+https://github.com/hheei/pi-mono.git",
|
|
37
36
|
"directory": "packages/pi-subagents"
|
|
38
37
|
},
|
|
39
38
|
"publishConfig": {
|
|
40
39
|
"access": "public"
|
|
41
40
|
},
|
|
42
41
|
"dependencies": {
|
|
43
|
-
"@hheei/pi-ext-core": "^0.
|
|
42
|
+
"@hheei/pi-ext-core": "^0.8.0",
|
|
44
43
|
"@modelcontextprotocol/server": "2.4.0"
|
|
45
44
|
},
|
|
46
45
|
"peerDependencies": {
|
|
47
|
-
"@earendil-works/pi-coding-agent": ">=1.
|
|
48
|
-
"@earendil-works/pi-tui": ">=1.
|
|
46
|
+
"@earendil-works/pi-coding-agent": ">=1.1.0",
|
|
47
|
+
"@earendil-works/pi-tui": ">=1.1.0",
|
|
49
48
|
"typebox": "*"
|
|
50
49
|
},
|
|
51
50
|
"devDependencies": {
|
|
52
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
53
|
-
"@earendil-works/pi-tui": "1.
|
|
51
|
+
"@earendil-works/pi-coding-agent": "^1.4.0",
|
|
52
|
+
"@earendil-works/pi-tui": "^1.4.0",
|
|
54
53
|
"linkedom": "^0.18.14"
|
|
55
54
|
},
|
|
56
55
|
"scripts": {
|