@hheei/pi-subagents 0.8.1 → 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.
@@ -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(`Delegation ${receipt.delegationId} has no public identity`);
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, expectedRevision?: number): Promise<SubagentRecord>;
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, expectedRevision?: number, expectedRuntimeIdentity?: string): Promise<SubagentRecord>;
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, expectedRevision) {
27
- return commitLease(registry, id, edge, expectedRevision);
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, expectedRevision, expectedRuntimeIdentity) {
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, expectedRevision ?? current.revision, () => next, expectedRuntimeIdentity);
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 released = new Set();
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
- if (processes.alive(attachment.pid)) {
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, released: [...released] };
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, RUNTIME_ENDED_RESUME, } from "./lease.js";
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 ${active.delegationId}`, input.id);
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
- const live = this.#isLive(id);
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, publicChild(this.#withSnapshot(record), this.#isLive(input.id), this.#paths.get(input.id)), { includeOutput: input.includeOutput === true });
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 live = this.#withSnapshot(record);
1543
+ const child = this.#project(record);
1515
1544
  children.push({
1516
- id: record.subagentId,
1517
- agent: record.launchConfig.agent.name,
1518
- runtimeState: live.state,
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
- ...(live.latestSummary === undefined ? {} : { summary: live.latestSummary }),
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) => publicChild(this.#withSnapshot(record), this.#isLive(record.subagentId), this.#paths.get(record.subagentId)));
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
- for (const id of result.released) {
1947
- await this.#noteReleasedRuntime(id);
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 an undefined revision cannot
1977
- // lose a concurrent field update and no stale_revision retry is needed.
1978
- const next = await this.#deps.registry.update(id, undefined, updater);
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);
@@ -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-1`, `bash-1`)
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 (!/(?:agent|bash)-\d+/.test(markdown))
12
+ if (!IDENTIFIER.test(markdown))
10
13
  return markdown;
11
- // Split by code blocks (```...```) and inline code (`...`) to only transform prose
12
- const parts = markdown.split(/(```[\s\S]*?```|`[^`\n]+`)/g);
13
- for (let i = 0; i < parts.length; i += 2) {
14
- const part = parts[i];
15
- if (part !== undefined && /(?:agent|bash)-\d+/.test(part)) {
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
  }
@@ -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" | "stale_revision" | "runtime_mismatch" | "stopped_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
- * Conditional write. `expectedRevision` guards against stale writers, and
21
- * `expectedRuntimeIdentity` additionally refuses updates whose target runtime has
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, expectedRevision: number | undefined, updater: (record: SubagentRecord) => SubagentRecord, expectedRuntimeIdentity?: string, signal?: AbortSignal): Promise<SubagentRecord>;
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 row = readChild(db, parentSessionId, id);
410
- if (row === undefined)
409
+ const record = readChild(db, parentSessionId, id);
410
+ if (record === undefined)
411
411
  return undefined;
412
- return parseSubagentRecord(JSON.parse(row.record), id, parentSessionId, path);
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, candidate.revision, JSON.stringify(candidate)));
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, expectedRevision, updater, expectedRuntimeIdentity) {
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
- const replaced = replaceChild(db, parentSessionId, id, current.revision, next.revision, JSON.stringify(next));
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" }, undefined, plan.runtimeIdentity);
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" }, undefined, plan.runtimeIdentity);
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
- export declare function resetSubagentIdCounter(value?: number): void;
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
- let nextSubagentIndex = 0;
139
- export function resetSubagentIdCounter(value = 0) {
140
- nextSubagentIndex = value;
141
- }
142
- export function createSubagentId(existingIds) {
143
- if (existingIds !== undefined && existingIds.length > 0) {
144
- let max = 0;
145
- for (const id of existingIds) {
146
- const match = /^agent-(\d+)$/u.exec(id);
147
- if (match?.[1]) {
148
- const num = Number.parseInt(match[1], 10);
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
- nextSubagentIndex += 1;
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
@@ -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, revision: number, record: string): boolean;
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, expectedRevision: number | undefined, revision: number, record: string): boolean;
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 = 2;
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
- const columns = db.prepare(`PRAGMA table_info(${table})`).all();
101
- if (columns.some((existing) => existing.name === column))
102
- continue;
103
- db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
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, revision, record) {
171
+ export function insertChild(db, parentSessionId, subagentId, record) {
163
172
  const result = db
164
- .prepare(`INSERT INTO children (parent_session_id, subagent_id, revision, record)
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, revision, record);
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 revision, record FROM children WHERE parent_session_id = ? AND subagent_id = ?")
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, expectedRevision, revision, record) {
183
- const result = expectedRevision === undefined
184
- ? db
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-1. Takes precedence over agent and reuses its session.",
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-1" }),
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-1" }),
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({
@@ -78,14 +78,14 @@ export function formatAvailableAgents(agents) {
78
78
  function getDelegateDescription(agents) {
79
79
  return [
80
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-2", task: "..." }).',
81
+ 'Create: agents_delegate({ agent: "worker", task: "..." }). Continue: agents_delegate({ id: "agent-3f9a1c", task: "..." }).',
82
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.",
83
83
  "Related agent tools: agents_get, agents_stop, agents_dashboard.",
84
84
  formatAvailableAgents(agents),
85
85
  ].join("\n");
86
86
  }
87
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.";
88
- const STOP_DESCRIPTION = "Stop an agent runtime by id (e.g. 'agent-1'). 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
+ 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.";
89
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.";
90
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.";
91
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.8.1",
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/hepi-mono.git",
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.7.1",
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.3.0",
48
- "@earendil-works/pi-tui": ">=1.3.0",
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.3.0",
53
- "@earendil-works/pi-tui": "1.3.0",
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": {