@hheei/pi-subagents 0.3.0 → 0.4.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.
@@ -0,0 +1,52 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ /** The one durable file for child records, commands, and tokens. */
3
+ export declare function defaultStatePath(): string;
4
+ export declare function openStateDb(filePath: string): DatabaseSync;
5
+ export declare function withWrite<T>(db: DatabaseSync, run: () => T): T;
6
+ export interface StoredCommand {
7
+ readonly id: string;
8
+ readonly parentSessionId: string;
9
+ readonly subagentId: string;
10
+ readonly direction: "to_child" | "to_parent";
11
+ readonly operation: string;
12
+ readonly payload: string;
13
+ readonly status: string;
14
+ readonly result: string | null;
15
+ readonly errorCode: string | null;
16
+ readonly error: string | null;
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;
23
+ 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;
25
+ export declare function readMeta(db: DatabaseSync, parentSessionId: string, key: string): string | undefined;
26
+ export declare function writeMeta(db: DatabaseSync, parentSessionId: string, key: string, value: string): void;
27
+ export declare function enqueueCommand(db: DatabaseSync, command: {
28
+ readonly id: string;
29
+ readonly parentSessionId: string;
30
+ readonly subagentId: string;
31
+ readonly direction: "to_child" | "to_parent";
32
+ readonly operation: string;
33
+ readonly payload: string;
34
+ readonly createdAt: number;
35
+ }): void;
36
+ export declare function hasPendingCommand(db: DatabaseSync, parentSessionId: string, direction: "to_child" | "to_parent", subagentId?: string): boolean;
37
+ export declare function claimCommand(db: DatabaseSync, parentSessionId: string, subagentId: string, direction: "to_child" | "to_parent"): StoredCommand | undefined;
38
+ export declare function claimParentCommands(db: DatabaseSync, parentSessionId: string): readonly StoredCommand[];
39
+ export declare function requeueAbandonedParentCommands(db: DatabaseSync, parentSessionId: string): void;
40
+ export declare function deleteCommand(db: DatabaseSync, id: string): void;
41
+ export declare function readCommand(db: DatabaseSync, id: string): StoredCommand | undefined;
42
+ export declare function finishCommand(db: DatabaseSync, id: string, outcome: {
43
+ readonly status: "done";
44
+ readonly result: string;
45
+ } | {
46
+ readonly status: "failed";
47
+ readonly errorCode: string;
48
+ readonly error: string;
49
+ }): void;
50
+ export declare function readToken(db: DatabaseSync, parentSessionId: string, runtimeIdentity: string): string | undefined;
51
+ export declare function writeToken(db: DatabaseSync, parentSessionId: string, runtimeIdentity: string, token: string): void;
52
+ export declare function deleteToken(db: DatabaseSync, parentSessionId: string, runtimeIdentity: string): void;
@@ -0,0 +1,209 @@
1
+ import { chmodSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ /** The one durable file for child records, commands, and tokens. */
6
+ export function defaultStatePath() {
7
+ return join(getAgentDir(), "pi-subagents", "state.sqlite");
8
+ }
9
+ const SCHEMA = `
10
+ CREATE TABLE IF NOT EXISTS children (
11
+ parent_session_id TEXT NOT NULL,
12
+ subagent_id TEXT NOT NULL,
13
+ revision INTEGER NOT NULL,
14
+ record TEXT NOT NULL,
15
+ PRIMARY KEY (parent_session_id, subagent_id)
16
+ );
17
+ CREATE TABLE IF NOT EXISTS session_meta (
18
+ parent_session_id TEXT NOT NULL,
19
+ key TEXT NOT NULL,
20
+ value TEXT NOT NULL,
21
+ PRIMARY KEY (parent_session_id, key)
22
+ );
23
+ CREATE TABLE IF NOT EXISTS commands (
24
+ id TEXT PRIMARY KEY,
25
+ parent_session_id TEXT NOT NULL,
26
+ subagent_id TEXT NOT NULL,
27
+ direction TEXT NOT NULL,
28
+ operation TEXT NOT NULL,
29
+ payload TEXT NOT NULL,
30
+ status TEXT NOT NULL,
31
+ result TEXT,
32
+ error_code TEXT,
33
+ error TEXT,
34
+ created_at INTEGER NOT NULL
35
+ );
36
+ CREATE INDEX IF NOT EXISTS commands_pending
37
+ ON commands (parent_session_id, subagent_id, direction, status, created_at);
38
+ CREATE TABLE IF NOT EXISTS tokens (
39
+ parent_session_id TEXT NOT NULL,
40
+ runtime_identity TEXT NOT NULL,
41
+ token TEXT NOT NULL,
42
+ PRIMARY KEY (parent_session_id, runtime_identity)
43
+ );
44
+ `;
45
+ const connections = new Map();
46
+ export function openStateDb(filePath) {
47
+ const cached = connections.get(filePath);
48
+ if (cached !== undefined)
49
+ return cached;
50
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
51
+ const db = new DatabaseSync(filePath);
52
+ db.exec("PRAGMA journal_mode = WAL");
53
+ db.exec("PRAGMA busy_timeout = 5000");
54
+ db.exec(SCHEMA);
55
+ db.exec("DROP TABLE IF EXISTS presence");
56
+ chmodSync(filePath, 0o600);
57
+ connections.set(filePath, db);
58
+ return db;
59
+ }
60
+ export function withWrite(db, run) {
61
+ db.exec("BEGIN IMMEDIATE");
62
+ try {
63
+ const value = run();
64
+ db.exec("COMMIT");
65
+ return value;
66
+ }
67
+ catch (error) {
68
+ try {
69
+ db.exec("ROLLBACK");
70
+ }
71
+ catch {
72
+ // The original error is the one the caller handles.
73
+ }
74
+ throw error;
75
+ }
76
+ }
77
+ function commandFromRow(row) {
78
+ return {
79
+ id: row.id,
80
+ parentSessionId: row.parent_session_id,
81
+ subagentId: row.subagent_id,
82
+ direction: row.direction === "to_parent" ? "to_parent" : "to_child",
83
+ operation: row.operation,
84
+ payload: row.payload,
85
+ status: row.status,
86
+ result: row.result,
87
+ errorCode: row.error_code,
88
+ error: row.error,
89
+ };
90
+ }
91
+ const COMMAND_COLUMNS = "id, parent_session_id, subagent_id, direction, operation, payload, status, result, error_code, error";
92
+ export function insertChild(db, parentSessionId, subagentId, revision, record) {
93
+ const result = db
94
+ .prepare(`INSERT INTO children (parent_session_id, subagent_id, revision, record)
95
+ VALUES (?, ?, ?, ?)
96
+ ON CONFLICT DO NOTHING`)
97
+ .run(parentSessionId, subagentId, revision, record);
98
+ return Number(result.changes) === 1;
99
+ }
100
+ export function readChild(db, parentSessionId, subagentId) {
101
+ const row = db
102
+ .prepare("SELECT revision, record FROM children WHERE parent_session_id = ? AND subagent_id = ?")
103
+ .get(parentSessionId, subagentId);
104
+ return row;
105
+ }
106
+ export function listChildRecords(db, parentSessionId) {
107
+ const rows = db
108
+ .prepare("SELECT record FROM children WHERE parent_session_id = ? ORDER BY record")
109
+ .all(parentSessionId);
110
+ return rows.map((row) => row.record);
111
+ }
112
+ export function replaceChild(db, parentSessionId, subagentId, expectedRevision, revision, record) {
113
+ const result = expectedRevision === undefined
114
+ ? db
115
+ .prepare(`UPDATE children SET revision = ?, record = ?
116
+ WHERE parent_session_id = ? AND subagent_id = ?`)
117
+ .run(revision, record, parentSessionId, subagentId)
118
+ : db
119
+ .prepare(`UPDATE children SET revision = ?, record = ?
120
+ WHERE parent_session_id = ? AND subagent_id = ? AND revision = ?`)
121
+ .run(revision, record, parentSessionId, subagentId, expectedRevision);
122
+ return Number(result.changes) === 1;
123
+ }
124
+ export function readMeta(db, parentSessionId, key) {
125
+ const row = db
126
+ .prepare("SELECT value FROM session_meta WHERE parent_session_id = ? AND key = ?")
127
+ .get(parentSessionId, key);
128
+ return row?.value;
129
+ }
130
+ export function writeMeta(db, parentSessionId, key, value) {
131
+ db.prepare(`INSERT INTO session_meta (parent_session_id, key, value) VALUES (?, ?, ?)
132
+ ON CONFLICT (parent_session_id, key) DO UPDATE SET value = excluded.value`).run(parentSessionId, key, value);
133
+ }
134
+ export function enqueueCommand(db, command) {
135
+ db.prepare(`INSERT INTO commands (
136
+ id, parent_session_id, subagent_id, direction, operation, payload, status, created_at
137
+ ) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`).run(command.id, command.parentSessionId, command.subagentId, command.direction, command.operation, command.payload, command.createdAt);
138
+ }
139
+ export function hasPendingCommand(db, parentSessionId, direction, subagentId) {
140
+ const row = subagentId === undefined
141
+ ? db
142
+ .prepare(`SELECT 1 AS hit FROM commands
143
+ WHERE parent_session_id = ? AND direction = ? AND status = 'pending'
144
+ LIMIT 1`)
145
+ .get(parentSessionId, direction)
146
+ : db
147
+ .prepare(`SELECT 1 AS hit FROM commands
148
+ WHERE parent_session_id = ? AND subagent_id = ? AND direction = ? AND status = 'pending'
149
+ LIMIT 1`)
150
+ .get(parentSessionId, subagentId, direction);
151
+ return row !== undefined;
152
+ }
153
+ export function claimCommand(db, parentSessionId, subagentId, direction) {
154
+ const row = db
155
+ .prepare(`UPDATE commands SET status = 'working'
156
+ WHERE id = (
157
+ SELECT id FROM commands
158
+ WHERE parent_session_id = ? AND subagent_id = ? AND direction = ? AND status = 'pending'
159
+ ORDER BY created_at
160
+ LIMIT 1
161
+ )
162
+ RETURNING ${COMMAND_COLUMNS}`)
163
+ .get(parentSessionId, subagentId, direction);
164
+ return row === undefined ? undefined : commandFromRow(row);
165
+ }
166
+ export function claimParentCommands(db, parentSessionId) {
167
+ const rows = db
168
+ .prepare(`UPDATE commands SET status = 'working'
169
+ WHERE id IN (
170
+ SELECT id FROM commands
171
+ WHERE parent_session_id = ? AND direction = 'to_parent' AND status = 'pending'
172
+ ORDER BY created_at
173
+ LIMIT 20
174
+ )
175
+ RETURNING ${COMMAND_COLUMNS}`)
176
+ .all(parentSessionId);
177
+ return rows.map(commandFromRow);
178
+ }
179
+ export function requeueAbandonedParentCommands(db, parentSessionId) {
180
+ db.prepare(`UPDATE commands SET status = 'pending'
181
+ WHERE parent_session_id = ? AND direction = 'to_parent' AND status = 'working'`).run(parentSessionId);
182
+ }
183
+ export function deleteCommand(db, id) {
184
+ db.prepare("DELETE FROM commands WHERE id = ?").run(id);
185
+ }
186
+ export function readCommand(db, id) {
187
+ const row = db.prepare(`SELECT ${COMMAND_COLUMNS} FROM commands WHERE id = ?`).get(id);
188
+ return row === undefined ? undefined : commandFromRow(row);
189
+ }
190
+ export function finishCommand(db, id, outcome) {
191
+ if (outcome.status === "done") {
192
+ db.prepare("UPDATE commands SET status = 'done', result = ? WHERE id = ?").run(outcome.result, id);
193
+ return;
194
+ }
195
+ db.prepare("UPDATE commands SET status = 'failed', error_code = ?, error = ? WHERE id = ?").run(outcome.errorCode, outcome.error, id);
196
+ }
197
+ export function readToken(db, parentSessionId, runtimeIdentity) {
198
+ const row = db
199
+ .prepare("SELECT token FROM tokens WHERE parent_session_id = ? AND runtime_identity = ?")
200
+ .get(parentSessionId, runtimeIdentity);
201
+ return row?.token;
202
+ }
203
+ export function writeToken(db, parentSessionId, runtimeIdentity, token) {
204
+ db.prepare(`INSERT INTO tokens (parent_session_id, runtime_identity, token) VALUES (?, ?, ?)
205
+ ON CONFLICT (parent_session_id, runtime_identity) DO UPDATE SET token = excluded.token`).run(parentSessionId, runtimeIdentity, token);
206
+ }
207
+ export function deleteToken(db, parentSessionId, runtimeIdentity) {
208
+ db.prepare("DELETE FROM tokens WHERE parent_session_id = ? AND runtime_identity = ?").run(parentSessionId, runtimeIdentity);
209
+ }
package/dist/state.d.ts CHANGED
@@ -6,9 +6,17 @@ export interface StateSnapshot {
6
6
  readonly activeTool?: string;
7
7
  readonly usage: UsageSummary;
8
8
  }
9
+ export interface TranscriptFacts {
10
+ readonly usage: UsageSummary;
11
+ readonly summary?: string;
12
+ readonly stopReason?: string;
13
+ readonly interrupted?: string;
14
+ readonly containsInput: boolean;
15
+ }
9
16
  export interface StateProjector {
10
17
  applyEvent(event: unknown): StateSnapshot;
11
18
  rebuild(entries: readonly unknown[]): StateSnapshot;
19
+ applyTranscriptFacts(facts: TranscriptFacts): StateSnapshot;
12
20
  /**
13
21
  * Puts the projected state back in step with a state the child itself reported. Without it a
14
22
  * transcript that still ends in a tool result keeps projecting "running" over a child that has
@@ -19,6 +27,10 @@ export interface StateProjector {
19
27
  }
20
28
  export declare function summarizeCurrentBranch(entries: readonly unknown[]): string | undefined;
21
29
  export declare function aggregateUsage(entries: readonly unknown[]): UsageSummary;
30
+ /** What the parent projects from a session. The transcript itself never crosses the bridge. */
31
+ export declare function transcriptFacts(entries: readonly unknown[], expectedInput?: string): TranscriptFacts;
32
+ /** A mailbox answer is untrusted. Anything that is not these fields is not facts. */
33
+ export declare function parseTranscriptFacts(value: unknown): TranscriptFacts | undefined;
22
34
  /** The phase a streaming assistant update is in: what the parent shows as the child's activity. */
23
35
  export type AssistantPhaseKind = "thinking" | "generating" | "toolcall";
24
36
  export interface AssistantPhase {
package/dist/state.js CHANGED
@@ -101,6 +101,89 @@ export function aggregateUsage(entries) {
101
101
  }
102
102
  return total;
103
103
  }
104
+ function userText(message) {
105
+ if (typeof message.content === "string")
106
+ return message.content;
107
+ if (!Array.isArray(message.content))
108
+ return undefined;
109
+ return message.content
110
+ .map((part) => {
111
+ const content = record(part);
112
+ return content?.type === "text" && typeof content.text === "string" ? content.text : "";
113
+ })
114
+ .join("");
115
+ }
116
+ function lastCompletedAssistant(entries) {
117
+ let last;
118
+ for (const entry of entries) {
119
+ const item = assistant(entry);
120
+ if (item === undefined || !completed(item.message))
121
+ continue;
122
+ last = item.message;
123
+ }
124
+ return last;
125
+ }
126
+ /** What the parent projects from a session. The transcript itself never crosses the bridge. */
127
+ export function transcriptFacts(entries, expectedInput) {
128
+ const last = lastCompletedAssistant(entries);
129
+ const summary = summarizeCurrentBranch(entries);
130
+ const stopReason = typeof last?.stopReason === "string" ? last.stopReason : undefined;
131
+ const interrupted = last === undefined ? undefined : diagnostic(last);
132
+ const containsInput = expectedInput !== undefined &&
133
+ entries.some((entry) => {
134
+ const value = record(entry);
135
+ const message = record(value?.message);
136
+ return message?.role === "user" && userText(message) === expectedInput;
137
+ });
138
+ return {
139
+ usage: aggregateUsage(entries),
140
+ ...(summary === undefined ? {} : { summary }),
141
+ ...(stopReason === undefined ? {} : { stopReason }),
142
+ ...(interrupted === undefined ? {} : { interrupted }),
143
+ containsInput,
144
+ };
145
+ }
146
+ function usageNumber(value) {
147
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
148
+ }
149
+ /** A mailbox answer is untrusted. Anything that is not these fields is not facts. */
150
+ export function parseTranscriptFacts(value) {
151
+ if (!isRecord(value) || typeof value.containsInput !== "boolean" || !isRecord(value.usage)) {
152
+ return undefined;
153
+ }
154
+ const usage = value.usage;
155
+ const inputTokens = usageNumber(usage.inputTokens);
156
+ const outputTokens = usageNumber(usage.outputTokens);
157
+ const cacheReadTokens = usageNumber(usage.cacheReadTokens);
158
+ const cacheWriteTokens = usageNumber(usage.cacheWriteTokens);
159
+ const turns = usageNumber(usage.turns);
160
+ const costUsd = usage.costUsd === null ? null : usageNumber(usage.costUsd);
161
+ if (inputTokens === undefined ||
162
+ outputTokens === undefined ||
163
+ cacheReadTokens === undefined ||
164
+ cacheWriteTokens === undefined ||
165
+ turns === undefined ||
166
+ costUsd === undefined) {
167
+ return undefined;
168
+ }
169
+ const summary = typeof value.summary === "string" ? value.summary : undefined;
170
+ const stopReason = typeof value.stopReason === "string" ? value.stopReason : undefined;
171
+ const interrupted = typeof value.interrupted === "string" ? value.interrupted : undefined;
172
+ return {
173
+ usage: {
174
+ inputTokens,
175
+ outputTokens,
176
+ cacheReadTokens,
177
+ cacheWriteTokens,
178
+ costUsd,
179
+ turns,
180
+ },
181
+ ...(summary === undefined ? {} : { summary }),
182
+ ...(stopReason === undefined ? {} : { stopReason }),
183
+ ...(interrupted === undefined ? {} : { interrupted }),
184
+ containsInput: value.containsInput,
185
+ };
186
+ }
104
187
  /**
105
188
  * Categorizes a streaming assistant update into the phase the parent projects. The child throttles
106
189
  * duplicate deltas with the same answer, so both ends of the bridge agree on what "still thinking"
@@ -143,6 +226,17 @@ export function createStateProjector(initialState = "running") {
143
226
  ...(activeTool === undefined ? {} : { activeTool }),
144
227
  usage,
145
228
  });
229
+ const applyFacts = (facts) => {
230
+ activeTool = undefined;
231
+ summary = facts.summary;
232
+ usage = facts.usage;
233
+ interrupted = facts.interrupted;
234
+ if (facts.stopReason === "error")
235
+ state = "error";
236
+ else if (facts.stopReason !== undefined && state === "error")
237
+ state = "done";
238
+ return snapshot();
239
+ };
146
240
  return {
147
241
  applyEvent(event) {
148
242
  const value = record(event);
@@ -207,6 +301,15 @@ export function createStateProjector(initialState = "running") {
207
301
  }
208
302
  }
209
303
  }
304
+ else if (value.type === "compaction_start") {
305
+ summary = "compacting...";
306
+ activeTool = undefined;
307
+ }
308
+ else if (value.type === "compaction_end") {
309
+ if (summary === "compacting...") {
310
+ summary = undefined;
311
+ }
312
+ }
210
313
  else if (value.type === "error") {
211
314
  state = "error";
212
315
  interrupted =
@@ -215,27 +318,10 @@ export function createStateProjector(initialState = "running") {
215
318
  return snapshot();
216
319
  },
217
320
  rebuild(entries) {
218
- activeTool = undefined;
219
- summary = summarizeCurrentBranch(entries);
220
- usage = aggregateUsage(entries);
221
- let lastAssistant;
222
- for (const entry of entries) {
223
- const item = assistant(entry);
224
- if (item === undefined || !completed(item.message))
225
- continue;
226
- lastAssistant = item.message;
227
- }
228
- if (lastAssistant !== undefined) {
229
- interrupted = diagnostic(lastAssistant);
230
- if (lastAssistant.stopReason === "error")
231
- state = "error";
232
- else if (state === "error")
233
- state = "done";
234
- }
235
- else {
236
- interrupted = undefined;
237
- }
238
- return snapshot();
321
+ return applyFacts(transcriptFacts(entries));
322
+ },
323
+ applyTranscriptFacts(facts) {
324
+ return applyFacts(facts);
239
325
  },
240
326
  syncState(next) {
241
327
  state = next;
@@ -1,9 +1,4 @@
1
1
  import type { ExtensionAPI, ToolRenderers } from "@earendil-works/pi-coding-agent";
2
- /**
3
- * Owned presentations for the four parent-facing subagent tools. The native renderers keep the
4
- * model-visible text and raw errors when pi-ext-ui is absent; a loaded pi-ext-ui wraps the same
5
- * presentation through the ext-core provider registry, so both paths share one semantic source.
6
- */
7
2
  export interface AgentSpawnDetails {
8
3
  readonly id: string;
9
4
  readonly path: string;
@@ -15,3 +10,5 @@ export declare function parentToolRenderers(pi: ExtensionAPI): {
15
10
  readonly get: ToolRenderers;
16
11
  readonly stop: ToolRenderers;
17
12
  };
13
+ /** The child's blocker report names the reason in the header and keeps the full message readable. */
14
+ export declare function contactParentRenderers(pi: ExtensionAPI): ToolRenderers;
@@ -1,4 +1,15 @@
1
- import { agentResultText, createToolPresentationRenderers, isRecord, } from "@hheei/pi-ext-core";
1
+ import { agentResultText, createOptionalToolRenderers, createToolPresentationRenderers, isRecord, } from "@hheei/pi-ext-core";
2
+ /**
3
+ * Owned presentations for every subagent tool. Each definition ships its own native renderers, so a
4
+ * session without pi-ext-ui still shows the tool title, full request, model-visible text and raw
5
+ * errors; when the UI is loaded it decorates those same native renderers through ext-core's
6
+ * optional-renderer point and reads the same ToolPresentation, so both paths share one semantic
7
+ * source. This package never depends on pi-ext-ui.
8
+ */
9
+ /** Native presentation renderers, then the optional UI frame over them. */
10
+ function ownedRenderers(pi, name, present) {
11
+ return createOptionalToolRenderers(pi, name, createToolPresentationRenderers(pi, name, present));
12
+ }
2
13
  function stringField(source, key) {
3
14
  const value = source?.[key];
4
15
  return typeof value === "string" && value.trim() !== "" ? value : undefined;
@@ -149,12 +160,34 @@ function stopPresentation(args, result) {
149
160
  ...(confirmation === undefined ? {} : { copy: { primary: confirmation } }),
150
161
  };
151
162
  }
163
+ function contactPresentation(args, result) {
164
+ const record = isRecord(args) ? args : undefined;
165
+ const reason = stringField(record, "reason") ?? "blocked";
166
+ const message = stringField(record, "message");
167
+ const confirmation = result === undefined ? undefined : agentResultText(result);
168
+ const copy = message === undefined
169
+ ? undefined
170
+ : {
171
+ primary: message,
172
+ ...(confirmation === undefined ? {} : { full: `${message}\n${confirmation}` }),
173
+ };
174
+ return {
175
+ summary: reason,
176
+ ...(message === undefined ? {} : { request: message }),
177
+ ...(confirmation === undefined ? {} : { output: confirmation }),
178
+ ...(copy === undefined ? {} : { copy }),
179
+ };
180
+ }
152
181
  /** One renderer set per tool, named exactly after its tool so nested calls resolve too. */
153
182
  export function parentToolRenderers(pi) {
154
183
  return {
155
- spawn: createToolPresentationRenderers(pi, "agent_spawn", spawnPresentation),
156
- send: createToolPresentationRenderers(pi, "agent_send", sendPresentation),
157
- get: createToolPresentationRenderers(pi, "agent_get", getPresentation),
158
- stop: createToolPresentationRenderers(pi, "agent_stop", stopPresentation),
184
+ spawn: ownedRenderers(pi, "agent_spawn", spawnPresentation),
185
+ send: ownedRenderers(pi, "agent_send", sendPresentation),
186
+ get: ownedRenderers(pi, "agent_get", getPresentation),
187
+ stop: ownedRenderers(pi, "agent_stop", stopPresentation),
159
188
  };
160
189
  }
190
+ /** The child's blocker report names the reason in the header and keeps the full message readable. */
191
+ export function contactParentRenderers(pi) {
192
+ return ownedRenderers(pi, "contact_parent", contactPresentation);
193
+ }
package/dist/tools.js CHANGED
@@ -2,7 +2,7 @@ import { textToolResult } from "@hheei/pi-ext-core";
2
2
  import { Type } from "typebox";
3
3
  import { Value } from "typebox/value";
4
4
  import { isOperationError } from "./domain.js";
5
- import { parentToolRenderers } from "./tool-presentation.js";
5
+ import { contactParentRenderers, parentToolRenderers, } from "./tool-presentation.js";
6
6
  const spawnSchema = Type.Object({
7
7
  task: Type.String({ minLength: 1 }),
8
8
  agent: Type.String({
@@ -66,7 +66,7 @@ function getSpawnDescription(agents) {
66
66
  formatAvailableAgents(agents),
67
67
  ].join("\n");
68
68
  }
69
- const SEND_DESCRIPTION = "Send input, steer, or follow-up instructions to an existing subagent (e.g. 'agent-1'), resuming its session when it is idle, blocked, done, or errored. Results are returned automatically when the task finishes; do not poll agent_get. Related subagent tools: agent_spawn, agent_get, agent_stop.";
69
+ const SEND_DESCRIPTION = "Send input, steer, or follow-up instructions to an existing subagent (e.g. 'agent-1'), resuming its session when it is idle, blocked, done, or errored. A child whose runtime has ended is resumed the same way: send to that id, do not spawn a replacement. Stop it first only when send reports the previous process is still running. Results are returned automatically when the task finishes; do not poll agent_get. Related subagent tools: agent_spawn, agent_get, agent_stop.";
70
70
  const GET_DESCRIPTION = "Inspect one subagent's status, progress, or diagnostic details by id (e.g. 'agent-1'). Use keys to choose fields; defaults to 'status,progress'. Results are returned automatically; use this for inspection, not completion polling. Related subagent tools: agent_spawn, agent_send, agent_stop.";
71
71
  const STOP_DESCRIPTION = "Stop a subagent's runtime by id (e.g. 'agent-1'). The child keeps its session, so a later agent_send can resume it. Related subagent tools: agent_spawn, agent_send, agent_get.";
72
72
  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.";
@@ -247,6 +247,7 @@ export function registerChildTools(pi, identity, options) {
247
247
  ? "Error report delivered to the parent."
248
248
  : "Blocker report queued for the parent.", undefined);
249
249
  },
250
+ ...contactParentRenderers(pi),
250
251
  };
251
252
  pi.registerTool(tool);
252
253
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hheei/pi-subagents",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -35,16 +35,16 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@hheei/pi-ext-core": "^0.3.0"
38
+ "@hheei/pi-ext-core": "^0.5.0"
39
39
  },
40
40
  "peerDependencies": {
41
- "@earendil-works/pi-coding-agent": ">=1.2.0",
42
- "@earendil-works/pi-tui": ">=1.2.0",
41
+ "@earendil-works/pi-coding-agent": ">=1.3.0",
42
+ "@earendil-works/pi-tui": ">=1.3.0",
43
43
  "typebox": "*"
44
44
  },
45
45
  "devDependencies": {
46
- "@earendil-works/pi-coding-agent": "1.2.0",
47
- "@earendil-works/pi-tui": "1.2.0"
46
+ "@earendil-works/pi-coding-agent": "1.3.0",
47
+ "@earendil-works/pi-tui": "1.3.0"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "node -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json"
@@ -1,60 +0,0 @@
1
- import { type ChildIdentity } from "./domain.js";
2
- import { type ChildInputSource } from "./protocol.js";
3
- export interface ChildBridgeRequest {
4
- readonly operation: string;
5
- readonly payload: unknown;
6
- }
7
- export interface ChildBridgeClientOptions {
8
- /** The parent's bridge socket. */
9
- readonly endpoint: string;
10
- readonly identity: ChildIdentity;
11
- /** Executes a parent request; a thrown error becomes a failure response. */
12
- readonly handleRequest: (request: ChildBridgeRequest) => Promise<unknown>;
13
- /**
14
- * Called after every successful handshake, including the first. A reconnecting child uses it to
15
- * resend whatever state the parent needs to stay synchronized.
16
- */
17
- readonly onConnect?: () => void;
18
- readonly reconnectDelayMs?: number;
19
- readonly maxBufferedReports?: number;
20
- readonly maxFrameBytes?: number;
21
- readonly connectTimeoutMs?: number;
22
- readonly reportTimeoutMs?: number;
23
- readonly diagnose?: (message: string) => void;
24
- }
25
- /**
26
- * The child side of the bridge: one long-lived connection to the parent, carrying both directions
27
- * of control.
28
- *
29
- * The child therefore never owns its own lifecycle endpoint: it dials the parent, keeps dialing
30
- * while the parent is away, and reconnects with the same identity when the parent comes back (a
31
- * parent restart re-listens the same socket path). Reports the parent has not accepted yet are
32
- * buffered here, bounded, and flushed in order after a reconnect, so a report is never silently
33
- * lost just because the parent was momentarily gone. Pi events are advisory snapshots and are
34
- * dropped instead of buffered.
35
- */
36
- export declare class ChildBridgeClient {
37
- #private;
38
- constructor(options: ChildBridgeClientOptions);
39
- get connected(): boolean;
40
- /**
41
- * Starts the connect loop. It keeps retrying on its own: a panel child outlives the parent
42
- * process that started it, so being unable to reach the parent is a state to wait out, not a
43
- * failure to give up on. Call `close()` to stop it.
44
- */
45
- start(): void;
46
- /** Sends an advisory frame: Pi events and child lifecycle signals. */
47
- sendEvent(event: unknown): void;
48
- /**
49
- * Tells the parent that input arrived, with its source and nothing else. The parent uses this
50
- * to cancel an idle countdown, so the text of a human message stays inside the child.
51
- */
52
- sendChildInput(source: ChildInputSource): void;
53
- /**
54
- * Reports to the parent, waiting for its answer. While the parent is unreachable the report is
55
- * buffered instead of failed, and delivered after the reconnect.
56
- */
57
- sendReport(operation: string, payload: unknown, signal?: AbortSignal): Promise<unknown>;
58
- /** Stops reconnecting and fails everything still waiting for the parent. */
59
- close(): void;
60
- }