@hheei/pi-subagents 0.1.2 → 0.2.3
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 +45 -137
- package/dist/agent-resolver.d.ts +20 -0
- package/dist/agent-resolver.js +155 -21
- package/dist/bridge-client.d.ts +55 -0
- package/dist/bridge-client.js +453 -0
- package/dist/bridge-server.d.ts +72 -0
- package/dist/bridge-server.js +490 -0
- package/dist/builtin-agents.d.ts +24 -0
- package/dist/builtin-agents.js +152 -0
- package/dist/child-bridge.d.ts +31 -15
- package/dist/child-bridge.js +95 -117
- package/dist/child-control.d.ts +58 -0
- package/dist/child-control.js +176 -0
- package/dist/child-process.d.ts +21 -0
- package/dist/child-process.js +93 -0
- package/dist/commands.d.ts +2 -2
- package/dist/commands.js +52 -60
- package/dist/diagnostics.d.ts +0 -2
- package/dist/diagnostics.js +0 -8
- package/dist/domain.d.ts +57 -27
- package/dist/domain.js +60 -11
- package/dist/extension.d.ts +26 -1
- package/dist/extension.js +243 -46
- package/dist/host-adapter.d.ts +37 -12
- package/dist/host-adapter.js +214 -63
- package/dist/json-lines.js +2 -1
- package/dist/launch-spec.d.ts +8 -6
- package/dist/launch-spec.js +61 -18
- package/dist/manager.d.ts +93 -49
- package/dist/manager.js +1175 -528
- package/dist/markdown.d.ts +8 -0
- package/dist/markdown.js +21 -0
- package/dist/protocol.d.ts +33 -52
- package/dist/protocol.js +41 -52
- package/dist/registry.d.ts +3 -8
- package/dist/registry.js +41 -163
- package/dist/rpc-events.d.ts +3 -5
- package/dist/rpc-events.js +5 -9
- package/dist/runtime.d.ts +66 -11
- package/dist/runtime.js +182 -208
- package/dist/session-bootstrap.d.ts +23 -7
- package/dist/session-bootstrap.js +79 -15
- package/dist/session-fork.d.ts +16 -0
- package/dist/session-fork.js +67 -0
- package/dist/state.d.ts +7 -0
- package/dist/state.js +60 -25
- package/dist/tool-activation.d.ts +18 -0
- package/dist/tool-activation.js +227 -0
- package/dist/tools.d.ts +6 -3
- package/dist/tools.js +213 -72
- package/dist/widget.d.ts +4 -8
- package/dist/widget.js +55 -64
- package/package.json +51 -52
- package/dist/child-nudge.d.ts +0 -24
- package/dist/child-nudge.js +0 -102
- package/dist/connector.d.ts +0 -53
- package/dist/connector.js +0 -283
- package/dist/pause-gate.d.ts +0 -14
- package/dist/pause-gate.js +0 -64
- package/dist/rpc-adapter.d.ts +0 -40
- package/dist/rpc-adapter.js +0 -194
- package/dist/runner-entry.d.ts +0 -24
- package/dist/runner-entry.js +0 -166
- package/dist/runner.d.ts +0 -52
- package/dist/runner.js +0 -930
|
@@ -2,19 +2,33 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { open, readdir, stat } from "node:fs/promises";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { isRecord } from "@hheei/pi-ext-core";
|
|
5
6
|
import { resolveAgent } from "./agent-resolver.js";
|
|
6
|
-
import {
|
|
7
|
+
import { builtinAgentToolProblem } from "./builtin-agents.js";
|
|
8
|
+
import { isSessionId } from "./domain.js";
|
|
7
9
|
import { assembleChildPrompt, resolveBridgeExtensionPath, resolvePiInvocation, } from "./launch-spec.js";
|
|
10
|
+
import { formatSessionReferencePrompt } from "./session-fork.js";
|
|
8
11
|
/** Bounded read for a session header; the first line holds the whole identity of the file. */
|
|
9
12
|
const HEADER_READ_BYTES = 64 * 1024;
|
|
10
13
|
/**
|
|
11
|
-
* The
|
|
12
|
-
*
|
|
13
|
-
* as an explicit `--session-dir` so recovery can find the file again.
|
|
14
|
+
* The session directory Pi itself would use for this cwd. At the top level it holds the
|
|
15
|
+
* human's own sessions, which is why children never write there directly.
|
|
14
16
|
*/
|
|
15
17
|
export function resolveDefaultSessionDir(cwd) {
|
|
16
18
|
return SessionManager.create(cwd).getSessionDir();
|
|
17
19
|
}
|
|
20
|
+
/** Child sessions live in this subdirectory of the parent-scoped session directory. */
|
|
21
|
+
const SUBAGENT_SESSION_DIR_NAME = "agents";
|
|
22
|
+
/**
|
|
23
|
+
* The session directory a delegated child writes to.
|
|
24
|
+
*
|
|
25
|
+
* Pi discovers sessions with a non-recursive `readdir` of the session directory, so a
|
|
26
|
+
* subdirectory keeps delegated sessions out of the human's session list while the child still
|
|
27
|
+
* receives that directory as an explicit `--session-dir` recovery can find again.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveSubagentSessionDir(cwd) {
|
|
30
|
+
return join(resolveDefaultSessionDir(cwd), SUBAGENT_SESSION_DIR_NAME);
|
|
31
|
+
}
|
|
18
32
|
async function readSessionHeader(path) {
|
|
19
33
|
const handle = await open(path, "r");
|
|
20
34
|
try {
|
|
@@ -86,7 +100,7 @@ export async function planSessionPlacement(options) {
|
|
|
86
100
|
if (!isSessionId(options.sessionId)) {
|
|
87
101
|
throw new Error(`Invalid session id ${options.sessionId}`);
|
|
88
102
|
}
|
|
89
|
-
const sessionDir = options.sessionDir ??
|
|
103
|
+
const sessionDir = options.sessionDir ?? resolveSubagentSessionDir(options.cwd);
|
|
90
104
|
const sessionPath = options.sessionPath;
|
|
91
105
|
if (sessionPath !== undefined &&
|
|
92
106
|
(await stat(sessionPath).then(() => true, () => false))) {
|
|
@@ -107,8 +121,27 @@ export async function planSessionPlacement(options) {
|
|
|
107
121
|
}
|
|
108
122
|
return { sessionId: options.sessionId, sessionDir, persistence: "never_flushed" };
|
|
109
123
|
}
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
let nextSubagentIndex = 0;
|
|
125
|
+
export function resetSubagentIdCounter(value = 0) {
|
|
126
|
+
nextSubagentIndex = value;
|
|
127
|
+
}
|
|
128
|
+
export function createSubagentId(existingIds) {
|
|
129
|
+
if (existingIds !== undefined && existingIds.length > 0) {
|
|
130
|
+
let max = 0;
|
|
131
|
+
for (const id of existingIds) {
|
|
132
|
+
const match = /^agent-(\d+)$/u.exec(id);
|
|
133
|
+
if (match?.[1]) {
|
|
134
|
+
const num = Number.parseInt(match[1], 10);
|
|
135
|
+
if (num > max)
|
|
136
|
+
max = num;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (max > 0) {
|
|
140
|
+
nextSubagentIndex = Math.max(nextSubagentIndex, max);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
nextSubagentIndex += 1;
|
|
144
|
+
return `agent-${nextSubagentIndex}`;
|
|
112
145
|
}
|
|
113
146
|
/**
|
|
114
147
|
* One resolution chain for a spawn: agent definition + explicit parent defaults
|
|
@@ -118,13 +151,16 @@ export function createSubagentId() {
|
|
|
118
151
|
export async function resolveSubagentLaunch(options) {
|
|
119
152
|
const task = options.input.task.trim();
|
|
120
153
|
if (task === "")
|
|
121
|
-
throw new Error("
|
|
154
|
+
throw new Error("spawn_agent requires a non-empty task");
|
|
122
155
|
const name = options.input.agent?.trim();
|
|
123
156
|
if (name === undefined || name === "") {
|
|
124
|
-
throw new Error("
|
|
157
|
+
throw new Error("spawn_agent requires an explicit agent name; V1 has no built-in default agent");
|
|
125
158
|
}
|
|
126
159
|
const cwd = resolve(options.input.cwd?.trim() || options.cwd);
|
|
127
|
-
const
|
|
160
|
+
const requestedTitle = options.input.title?.trim();
|
|
161
|
+
// A blank title is the same as no title: the child derives one from its own identity.
|
|
162
|
+
const title = requestedTitle === "" ? undefined : requestedTitle;
|
|
163
|
+
const subagentId = options.subagentId ?? createSubagentId(options.existingSubagentIds);
|
|
128
164
|
const sessionId = options.sessionId ?? randomUUID();
|
|
129
165
|
const bridgeExtensionPath = options.bridgeExtensionPath ?? resolveBridgeExtensionPath();
|
|
130
166
|
const policy = await resolveAgent({
|
|
@@ -134,25 +170,53 @@ export async function resolveSubagentLaunch(options) {
|
|
|
134
170
|
parent: options.parent,
|
|
135
171
|
bridgeExtensionPath,
|
|
136
172
|
...(options.homeDirectory === undefined ? {} : { homeDirectory: options.homeDirectory }),
|
|
173
|
+
...(options.skillCatalog === undefined ? {} : { skillCatalog: options.skillCatalog }),
|
|
174
|
+
...(options.onWarning === undefined ? {} : { onWarning: options.onWarning }),
|
|
137
175
|
});
|
|
176
|
+
if (options.enforceEnabled === true && policy.enabled === false) {
|
|
177
|
+
throw new Error(`Agent "${policy.agent.name}" is disabled: no model is specified in user configuration (~/.pi/agent/agents/ or .pi/agents/)`);
|
|
178
|
+
}
|
|
179
|
+
let instructions = policy.agent.instructions;
|
|
180
|
+
if (options.referencedSessionPath !== undefined && options.referencedSessionPath.trim() !== "") {
|
|
181
|
+
instructions = `${instructions.trim()}\n\n${formatSessionReferencePrompt(options.referencedSessionPath)}`;
|
|
182
|
+
}
|
|
138
183
|
return Object.freeze({
|
|
139
184
|
subagentId,
|
|
140
185
|
invocation: options.invocation ?? resolvePiInvocation(),
|
|
141
186
|
cwd,
|
|
142
187
|
sessionId,
|
|
143
|
-
sessionDir:
|
|
188
|
+
sessionDir: resolveSubagentSessionDir(cwd),
|
|
189
|
+
...(options.sessionPath === undefined ? {} : { sessionPath: options.sessionPath }),
|
|
190
|
+
...(options.referencedSessionPath === undefined
|
|
191
|
+
? {}
|
|
192
|
+
: { referencedSessionPath: options.referencedSessionPath }),
|
|
144
193
|
agent: policy.agent,
|
|
145
194
|
model: policy.model,
|
|
146
195
|
thinking: policy.thinking,
|
|
147
|
-
tools: policy
|
|
196
|
+
tools: childTools(policy),
|
|
148
197
|
excludeTools: policy.excludeTools,
|
|
149
198
|
extensions: policy.extensions,
|
|
150
199
|
skills: policy.skills,
|
|
151
|
-
prompt: assembleChildPrompt(
|
|
200
|
+
prompt: assembleChildPrompt(instructions),
|
|
152
201
|
bridgeExtensionPath,
|
|
153
202
|
interactive: policy.interactive,
|
|
203
|
+
...(policy.codemodeOnly ? { codemodeOnly: true } : {}),
|
|
204
|
+
...(title === undefined ? {} : { title }),
|
|
154
205
|
});
|
|
155
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* The child's tool allowlist: a built-in agent must still resolve to exactly the tools its
|
|
209
|
+
* definition declares. Failing here happens before any process exists, rather than launching
|
|
210
|
+
* a child with different permissions than it advertises.
|
|
211
|
+
*/
|
|
212
|
+
function childTools(policy) {
|
|
213
|
+
const tools = policy.tools;
|
|
214
|
+
const problem = builtinAgentToolProblem(policy.agent.sourcePath, tools);
|
|
215
|
+
if (problem !== undefined) {
|
|
216
|
+
throw new Error(`Built-in agent ${policy.agent.name} cannot run: ${problem}`);
|
|
217
|
+
}
|
|
218
|
+
return tools;
|
|
219
|
+
}
|
|
156
220
|
/**
|
|
157
221
|
* Writes the spawn intent before anything is launched. Registry failure propagates,
|
|
158
222
|
* so a child is never started from intent that could not be recovered.
|
|
@@ -183,8 +247,8 @@ export async function persistSubagentIntent(options) {
|
|
|
183
247
|
cwd: launchConfig.cwd,
|
|
184
248
|
initialTask: task,
|
|
185
249
|
intent: "active",
|
|
186
|
-
state: "
|
|
187
|
-
|
|
250
|
+
state: "running",
|
|
251
|
+
presentation: options.presentation ?? "background",
|
|
188
252
|
persistence: placement.persistence,
|
|
189
253
|
launchConfig: Object.freeze({
|
|
190
254
|
...launchConfig,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SubagentRegistry } from "./registry.js";
|
|
2
|
+
/** Reads all JSON entries from a session file on disk. */
|
|
3
|
+
export declare function readSessionJsonlEntries(sessionPath: string): Promise<unknown[]>;
|
|
4
|
+
export interface ResolveForkSessionReferenceOptions {
|
|
5
|
+
readonly forkFrom: string;
|
|
6
|
+
readonly parentSessionPath?: string | undefined;
|
|
7
|
+
readonly registry?: Pick<SubagentRegistry, "get"> | undefined;
|
|
8
|
+
readonly onWarning?: ((message: string) => void) | undefined;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolves the source session JSONL file path for on-demand context inspection.
|
|
12
|
+
* Rejects 'current' to prevent ambiguous self-references.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveForkSessionReference(options: ResolveForkSessionReferenceOptions): Promise<string>;
|
|
15
|
+
/** Formats instructions explaining where the referenced session file is and how to inspect it on demand. */
|
|
16
|
+
export declare function formatSessionReferencePrompt(sessionPath: string): string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { findSessionFile } from "./session-bootstrap.js";
|
|
3
|
+
/** Reads all JSON entries from a session file on disk. */
|
|
4
|
+
export async function readSessionJsonlEntries(sessionPath) {
|
|
5
|
+
const raw = await readFile(sessionPath, "utf8");
|
|
6
|
+
const lines = raw.split("\n").filter((line) => line.trim() !== "");
|
|
7
|
+
const entries = [];
|
|
8
|
+
for (const line of lines) {
|
|
9
|
+
try {
|
|
10
|
+
entries.push(JSON.parse(line));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// ignore trailing malformed line
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return entries;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolves the source session JSONL file path for on-demand context inspection.
|
|
20
|
+
* Rejects 'current' to prevent ambiguous self-references.
|
|
21
|
+
*/
|
|
22
|
+
export async function resolveForkSessionReference(options) {
|
|
23
|
+
const { forkFrom, parentSessionPath, registry } = options;
|
|
24
|
+
const target = forkFrom.trim();
|
|
25
|
+
if (target === "current") {
|
|
26
|
+
throw new Error("forkFrom cannot be 'current'. Use 'parent' to reference the parent session, or specify a subagent id like 'agent-1'.");
|
|
27
|
+
}
|
|
28
|
+
if (target === "parent" || target === "self") {
|
|
29
|
+
if (parentSessionPath !== undefined && parentSessionPath.trim() !== "") {
|
|
30
|
+
return parentSessionPath;
|
|
31
|
+
}
|
|
32
|
+
throw new Error("Parent session has no associated session file on disk to reference");
|
|
33
|
+
}
|
|
34
|
+
if (registry !== undefined) {
|
|
35
|
+
const record = await registry.get(target).catch(() => undefined);
|
|
36
|
+
if (record !== undefined) {
|
|
37
|
+
const subagentSessionPath = record.sessionPath ??
|
|
38
|
+
(await findSessionFile(record.launchConfig.sessionDir, record.sessionId));
|
|
39
|
+
if (subagentSessionPath !== undefined) {
|
|
40
|
+
return subagentSessionPath;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`Subagent ${target} has no session file on disk to reference`);
|
|
43
|
+
}
|
|
44
|
+
if (/^agent-\d+$/u.test(target)) {
|
|
45
|
+
throw new Error(`Subagent ${target} was not found in registry`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Treat as explicit session file path
|
|
49
|
+
try {
|
|
50
|
+
const st = await stat(target);
|
|
51
|
+
if (st.isFile())
|
|
52
|
+
return target;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// ignore
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`Referenced session file not found: ${target}`);
|
|
58
|
+
}
|
|
59
|
+
/** Formats instructions explaining where the referenced session file is and how to inspect it on demand. */
|
|
60
|
+
export function formatSessionReferencePrompt(sessionPath) {
|
|
61
|
+
return [
|
|
62
|
+
"## Referenced Session Context",
|
|
63
|
+
"Prior session transcript is available for reference at:",
|
|
64
|
+
sessionPath,
|
|
65
|
+
"Use tools (such as read or grep) to inspect earlier messages, tool outputs, or context from this JSONL file on demand as needed instead of loading the entire history.",
|
|
66
|
+
].join("\n");
|
|
67
|
+
}
|
package/dist/state.d.ts
CHANGED
|
@@ -3,11 +3,18 @@ export interface StateSnapshot {
|
|
|
3
3
|
readonly state: SubagentState;
|
|
4
4
|
readonly summary?: string;
|
|
5
5
|
readonly interrupted?: string;
|
|
6
|
+
readonly activeTool?: string;
|
|
6
7
|
readonly usage: UsageSummary;
|
|
7
8
|
}
|
|
8
9
|
export interface StateProjector {
|
|
9
10
|
applyEvent(event: unknown): StateSnapshot;
|
|
10
11
|
rebuild(entries: readonly unknown[]): StateSnapshot;
|
|
12
|
+
/**
|
|
13
|
+
* Puts the projected state back in step with a state the child itself reported. Without it a
|
|
14
|
+
* transcript that still ends in a tool result keeps projecting "running" over a child that has
|
|
15
|
+
* answered that it is idle, and the next state-less event writes that back onto the record.
|
|
16
|
+
*/
|
|
17
|
+
syncState(state: SubagentState): StateSnapshot;
|
|
11
18
|
snapshot(): StateSnapshot;
|
|
12
19
|
}
|
|
13
20
|
export declare function summarizeCurrentBranch(entries: readonly unknown[]): string | undefined;
|
package/dist/state.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from "@hheei/pi-ext-core";
|
|
1
2
|
const EMPTY_USAGE = {
|
|
2
3
|
inputTokens: 0,
|
|
3
4
|
outputTokens: 0,
|
|
@@ -7,9 +8,7 @@ const EMPTY_USAGE = {
|
|
|
7
8
|
turns: 0,
|
|
8
9
|
};
|
|
9
10
|
function record(value) {
|
|
10
|
-
return
|
|
11
|
-
? Object.fromEntries(Object.entries(value))
|
|
12
|
-
: undefined;
|
|
11
|
+
return isRecord(value) ? Object.fromEntries(Object.entries(value)) : undefined;
|
|
13
12
|
}
|
|
14
13
|
function finite(value) {
|
|
15
14
|
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
@@ -102,15 +101,17 @@ export function aggregateUsage(entries) {
|
|
|
102
101
|
}
|
|
103
102
|
return total;
|
|
104
103
|
}
|
|
105
|
-
export function createStateProjector(initialState = "
|
|
104
|
+
export function createStateProjector(initialState = "running") {
|
|
106
105
|
let state = initialState;
|
|
107
106
|
let summary;
|
|
108
107
|
let interrupted;
|
|
108
|
+
let activeTool;
|
|
109
109
|
let usage = EMPTY_USAGE;
|
|
110
110
|
const snapshot = () => ({
|
|
111
111
|
state,
|
|
112
112
|
...(summary === undefined ? {} : { summary }),
|
|
113
113
|
...(interrupted === undefined ? {} : { interrupted }),
|
|
114
|
+
...(activeTool === undefined ? {} : { activeTool }),
|
|
114
115
|
usage,
|
|
115
116
|
});
|
|
116
117
|
return {
|
|
@@ -118,49 +119,83 @@ export function createStateProjector(initialState = "starting") {
|
|
|
118
119
|
const value = record(event);
|
|
119
120
|
if (value === undefined)
|
|
120
121
|
return snapshot();
|
|
121
|
-
if (value.type === "agent_start"
|
|
122
|
+
if (value.type === "agent_start" ||
|
|
123
|
+
value.type === "auto_retry_start" ||
|
|
124
|
+
value.type === "auto_retry") {
|
|
122
125
|
state = "running";
|
|
126
|
+
interrupted = undefined;
|
|
127
|
+
activeTool = undefined;
|
|
128
|
+
}
|
|
129
|
+
else if (value.type === "tool_execution_start") {
|
|
130
|
+
activeTool = typeof value.toolName === "string" ? value.toolName : undefined;
|
|
131
|
+
}
|
|
132
|
+
else if (value.type === "tool_execution_end") {
|
|
133
|
+
activeTool = undefined;
|
|
134
|
+
}
|
|
135
|
+
else if (value.type === "message_update") {
|
|
136
|
+
const message = record(value.message);
|
|
137
|
+
if (message?.role === "assistant") {
|
|
138
|
+
const currentText = text(message);
|
|
139
|
+
if (currentText !== undefined) {
|
|
140
|
+
summary = currentText;
|
|
141
|
+
activeTool = undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
123
145
|
else if (value.type === "agent_end" || value.type === "agent_settled") {
|
|
124
|
-
|
|
146
|
+
activeTool = undefined;
|
|
147
|
+
// agent_end precedes retries, compaction and queued continuation work.
|
|
148
|
+
if (value.type === "agent_settled") {
|
|
149
|
+
if (state !== "error" && state !== "blocked") {
|
|
150
|
+
state = "done";
|
|
151
|
+
interrupted = undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
125
154
|
const messages = Array.isArray(value.messages) ? value.messages : [];
|
|
126
155
|
const message = record(messages.length > 0 ? messages[messages.length - 1] : value.message);
|
|
127
156
|
if (message?.role === "assistant") {
|
|
128
157
|
summary = text(message) ?? summary;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
158
|
+
if (value.type === "agent_settled") {
|
|
159
|
+
interrupted = diagnostic(message);
|
|
160
|
+
if (message.stopReason === "error")
|
|
161
|
+
state = "error";
|
|
162
|
+
else
|
|
163
|
+
interrupted = undefined;
|
|
164
|
+
}
|
|
132
165
|
}
|
|
133
166
|
}
|
|
134
167
|
else if (value.type === "error") {
|
|
135
|
-
state = "
|
|
168
|
+
state = "error";
|
|
136
169
|
interrupted =
|
|
137
170
|
typeof value.message === "string" ? value.message : "Runner reported an error";
|
|
138
171
|
}
|
|
139
|
-
else if (value.type === "runner_events_dropped") {
|
|
140
|
-
const count = typeof value.count === "number" ? value.count : undefined;
|
|
141
|
-
interrupted =
|
|
142
|
-
count === undefined
|
|
143
|
-
? "Runner dropped events; live state may be stale"
|
|
144
|
-
: `Runner dropped ${count} events; live state may be stale`;
|
|
145
|
-
}
|
|
146
|
-
else if (value.type === "runner_exit" || value.type === "exit") {
|
|
147
|
-
state = value.code === 0 ? "done" : "failed";
|
|
148
|
-
if (value.code !== 0)
|
|
149
|
-
interrupted = `Runner exited${typeof value.code === "number" ? ` with code ${value.code}` : " unexpectedly"}`;
|
|
150
|
-
}
|
|
151
172
|
return snapshot();
|
|
152
173
|
},
|
|
153
174
|
rebuild(entries) {
|
|
175
|
+
activeTool = undefined;
|
|
154
176
|
summary = summarizeCurrentBranch(entries);
|
|
155
177
|
usage = aggregateUsage(entries);
|
|
178
|
+
let lastAssistant;
|
|
156
179
|
for (const entry of entries) {
|
|
157
180
|
const item = assistant(entry);
|
|
158
181
|
if (item === undefined || !completed(item.message))
|
|
159
182
|
continue;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
183
|
+
lastAssistant = item.message;
|
|
184
|
+
}
|
|
185
|
+
if (lastAssistant !== undefined) {
|
|
186
|
+
interrupted = diagnostic(lastAssistant);
|
|
187
|
+
if (lastAssistant.stopReason === "error")
|
|
188
|
+
state = "error";
|
|
189
|
+
else if (state === "error")
|
|
190
|
+
state = "done";
|
|
163
191
|
}
|
|
192
|
+
else {
|
|
193
|
+
interrupted = undefined;
|
|
194
|
+
}
|
|
195
|
+
return snapshot();
|
|
196
|
+
},
|
|
197
|
+
syncState(next) {
|
|
198
|
+
state = next;
|
|
164
199
|
return snapshot();
|
|
165
200
|
},
|
|
166
201
|
snapshot,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { SubagentManager } from "./manager.js";
|
|
3
|
+
/** Interactive agent tools managed by dynamic activation. */
|
|
4
|
+
export declare const INTERACTIVE_TOOL_NAMES: readonly ["spawn_agent", "send_agent", "get_agent", "stop_agent"];
|
|
5
|
+
export type InteractiveToolName = (typeof INTERACTIVE_TOOL_NAMES)[number];
|
|
6
|
+
export declare const SUBAGENT_LOADER_NAME = "subagent_enable";
|
|
7
|
+
export declare const SUBAGENTS_LOADER_NAME = "subagent_enable";
|
|
8
|
+
export declare function supportsDynamicTools(pi: ExtensionAPI): boolean;
|
|
9
|
+
export interface RegisterInteractiveToolActivationOptions {
|
|
10
|
+
readonly pi: ExtensionAPI;
|
|
11
|
+
readonly manager: SubagentManager;
|
|
12
|
+
readonly context: ExtensionContext;
|
|
13
|
+
}
|
|
14
|
+
export interface InteractiveToolActivationHandle {
|
|
15
|
+
dispose(): void;
|
|
16
|
+
selectFromSession(ctx: ExtensionContext): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export declare function registerInteractiveToolActivation(options: RegisterInteractiveToolActivationOptions): InteractiveToolActivationHandle;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { buildSessionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getToolTui, isRecord, textToolResult } from "@hheei/pi-ext-core";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { discoverAgents } from "./agent-resolver.js";
|
|
5
|
+
import { builtinAgents } from "./builtin-agents.js";
|
|
6
|
+
/** Interactive agent tools managed by dynamic activation. */
|
|
7
|
+
export const INTERACTIVE_TOOL_NAMES = [
|
|
8
|
+
"spawn_agent",
|
|
9
|
+
"send_agent",
|
|
10
|
+
"get_agent",
|
|
11
|
+
"stop_agent",
|
|
12
|
+
];
|
|
13
|
+
export const SUBAGENT_LOADER_NAME = "subagent_enable";
|
|
14
|
+
export const SUBAGENTS_LOADER_NAME = SUBAGENT_LOADER_NAME;
|
|
15
|
+
const DESCRIPTION = "Enable interactive agent tools (spawn_agent, send_agent, get_agent, stop_agent) for running interactive subagents. Enabled tools are available on the next model request.";
|
|
16
|
+
const SNIPPET = "Interactive subagents (spawn_agent, send_agent, get_agent, stop_agent) require activation. Call subagent_enable to activate them.";
|
|
17
|
+
const emptySchema = Type.Object({}, { additionalProperties: false });
|
|
18
|
+
export function supportsDynamicTools(pi) {
|
|
19
|
+
return (typeof pi.getAllTools === "function" &&
|
|
20
|
+
typeof pi.getActiveTools === "function" &&
|
|
21
|
+
typeof pi.setActiveTools === "function");
|
|
22
|
+
}
|
|
23
|
+
function hasToolDeclarations(messages) {
|
|
24
|
+
return messages.some((message) => isRecord(message) && ("toolsAdded" in message || "toolsRemoved" in message));
|
|
25
|
+
}
|
|
26
|
+
function currentTranscriptToolNames(messages) {
|
|
27
|
+
const tools = new Set();
|
|
28
|
+
for (const message of messages) {
|
|
29
|
+
if (!isRecord(message))
|
|
30
|
+
continue;
|
|
31
|
+
const declaration = message;
|
|
32
|
+
for (const tool of declaration.toolsRemoved ?? [])
|
|
33
|
+
tools.delete(tool.name);
|
|
34
|
+
for (const tool of declaration.toolsAdded ?? [])
|
|
35
|
+
tools.add(tool.name);
|
|
36
|
+
}
|
|
37
|
+
return [...tools];
|
|
38
|
+
}
|
|
39
|
+
const LEGACY_TOOL_NAMES = new Set([
|
|
40
|
+
"spawn_subagent",
|
|
41
|
+
"send_subagent",
|
|
42
|
+
"get_subagent",
|
|
43
|
+
"stop_subagent",
|
|
44
|
+
"list_subagents",
|
|
45
|
+
"subagents_enable",
|
|
46
|
+
]);
|
|
47
|
+
function branchHasSubagentCalls(messages, toolNames) {
|
|
48
|
+
const names = new Set([...toolNames, ...LEGACY_TOOL_NAMES]);
|
|
49
|
+
for (const msg of messages) {
|
|
50
|
+
if (!isRecord(msg))
|
|
51
|
+
continue;
|
|
52
|
+
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
53
|
+
for (const part of msg.content) {
|
|
54
|
+
if (isRecord(part) &&
|
|
55
|
+
(part.type === "tool_use" || part.type === "toolCall") &&
|
|
56
|
+
typeof part.name === "string") {
|
|
57
|
+
if (part.name === SUBAGENT_LOADER_NAME || names.has(part.name))
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (msg.role === "tool" &&
|
|
63
|
+
typeof msg.toolName === "string" &&
|
|
64
|
+
(msg.toolName === SUBAGENT_LOADER_NAME || names.has(msg.toolName))) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
export function registerInteractiveToolActivation(options) {
|
|
71
|
+
const { pi, manager, context } = options;
|
|
72
|
+
if (!supportsDynamicTools(pi)) {
|
|
73
|
+
return {
|
|
74
|
+
dispose() { },
|
|
75
|
+
selectFromSession: async () => { },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const names = INTERACTIVE_TOOL_NAMES;
|
|
79
|
+
const tui = getToolTui(pi);
|
|
80
|
+
const loaderTool = {
|
|
81
|
+
name: SUBAGENT_LOADER_NAME,
|
|
82
|
+
label: "Enable Interactive Subagents",
|
|
83
|
+
description: DESCRIPTION,
|
|
84
|
+
promptSnippet: SNIPPET,
|
|
85
|
+
parameters: emptySchema,
|
|
86
|
+
async execute(_id, _params, _signal, _onUpdate, execCtx) {
|
|
87
|
+
const registered = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
88
|
+
const unavailable = names.filter((name) => !registered.has(name));
|
|
89
|
+
if (unavailable.length > 0) {
|
|
90
|
+
throw new Error(`Cannot enable unavailable tools: ${unavailable.join(", ")}.`);
|
|
91
|
+
}
|
|
92
|
+
const cwd = execCtx !== undefined &&
|
|
93
|
+
typeof execCtx === "object" &&
|
|
94
|
+
"cwd" in execCtx &&
|
|
95
|
+
typeof execCtx.cwd === "string"
|
|
96
|
+
? execCtx.cwd
|
|
97
|
+
: (context.cwd ?? process.cwd());
|
|
98
|
+
let allAgents;
|
|
99
|
+
try {
|
|
100
|
+
allAgents = await discoverAgents(cwd);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
allAgents = builtinAgents();
|
|
104
|
+
}
|
|
105
|
+
const interactiveAgents = allAgents
|
|
106
|
+
.filter((agent) => agent.frontmatter.interactive === true && agent.frontmatter.hidden !== true)
|
|
107
|
+
.map((agent) => ({
|
|
108
|
+
name: agent.name,
|
|
109
|
+
description: typeof agent.frontmatter.description === "string" &&
|
|
110
|
+
agent.frontmatter.description.trim() !== ""
|
|
111
|
+
? agent.frontmatter.description.trim()
|
|
112
|
+
: "No description provided.",
|
|
113
|
+
}))
|
|
114
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
115
|
+
const currentlyActive = new Set(pi.getActiveTools());
|
|
116
|
+
const alreadyActive = names.every((name) => currentlyActive.has(name));
|
|
117
|
+
if (!alreadyActive) {
|
|
118
|
+
try {
|
|
119
|
+
pi.setActiveTools([...new Set([...pi.getActiveTools(), ...names])]);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
123
|
+
throw new Error(`Activation failed: ${message}.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const lines = [
|
|
127
|
+
alreadyActive
|
|
128
|
+
? `Interactive agent tools are already enabled: ${names.join(", ")}.`
|
|
129
|
+
: `Enabled interactive agent tools: ${names.join(", ")}. They will be available on the next model request.`,
|
|
130
|
+
];
|
|
131
|
+
if (interactiveAgents.length > 0) {
|
|
132
|
+
lines.push("");
|
|
133
|
+
lines.push("<interactive_agents>");
|
|
134
|
+
for (const agent of interactiveAgents) {
|
|
135
|
+
lines.push(`- ${agent.name}: ${agent.description}`);
|
|
136
|
+
}
|
|
137
|
+
lines.push("</interactive_agents>");
|
|
138
|
+
}
|
|
139
|
+
return textToolResult(lines.join("\n"), {
|
|
140
|
+
enabled: [...names],
|
|
141
|
+
...(interactiveAgents.length > 0 ? { interactiveAgents } : {}),
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
pi.registerTool(tui.frame(loaderTool, {
|
|
146
|
+
summary: () => "enable interactive subagents",
|
|
147
|
+
headerLine: "truncate",
|
|
148
|
+
footer: (result) => {
|
|
149
|
+
const details = result.details;
|
|
150
|
+
const count = details?.enabled?.length;
|
|
151
|
+
return count !== undefined
|
|
152
|
+
? count === 1
|
|
153
|
+
? "1 tool enabled"
|
|
154
|
+
: `${count} tools enabled`
|
|
155
|
+
: undefined;
|
|
156
|
+
},
|
|
157
|
+
}));
|
|
158
|
+
function loaderAvailable() {
|
|
159
|
+
return pi.getAllTools().some((tool) => tool.name === SUBAGENT_LOADER_NAME);
|
|
160
|
+
}
|
|
161
|
+
async function selectFromSession(ctx) {
|
|
162
|
+
if (!loaderAvailable())
|
|
163
|
+
return;
|
|
164
|
+
try {
|
|
165
|
+
let messages = [];
|
|
166
|
+
try {
|
|
167
|
+
const branch = ctx.sessionManager?.getBranch?.();
|
|
168
|
+
if (Array.isArray(branch) && branch.length > 0) {
|
|
169
|
+
messages = buildSessionContext(branch).messages;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// branch or sessionManager may not be available in mock contexts
|
|
174
|
+
}
|
|
175
|
+
let hasLiveChildren = false;
|
|
176
|
+
try {
|
|
177
|
+
const children = await manager.list();
|
|
178
|
+
hasLiveChildren = children.some((child) => child.freshness === "live" || child.state === "running");
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// manager may be recovering or unavailable
|
|
182
|
+
}
|
|
183
|
+
const recorded = hasToolDeclarations(messages)
|
|
184
|
+
? new Set(currentTranscriptToolNames(messages))
|
|
185
|
+
: messages.length > 0 && branchHasSubagentCalls(messages, names)
|
|
186
|
+
? new Set(names)
|
|
187
|
+
: new Set();
|
|
188
|
+
const shouldBeActive = hasLiveChildren || names.some((name) => recorded.has(name));
|
|
189
|
+
const heavy = new Set(names);
|
|
190
|
+
const active = pi.getActiveTools().filter((name) => !heavy.has(name));
|
|
191
|
+
if (shouldBeActive) {
|
|
192
|
+
for (const name of names)
|
|
193
|
+
active.push(name);
|
|
194
|
+
}
|
|
195
|
+
pi.setActiveTools([...new Set([...active, SUBAGENT_LOADER_NAME])]);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
console.warn(`[pi-subagents] Subagent tool activation setup fallback: ${error instanceof Error ? error.message : String(error)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const unbindSessionStart = pi.on("session_start", (_event, ctx) => {
|
|
202
|
+
void selectFromSession(ctx);
|
|
203
|
+
});
|
|
204
|
+
const unbindSessionTree = pi.on("session_tree", (_event, ctx) => {
|
|
205
|
+
void selectFromSession(ctx);
|
|
206
|
+
});
|
|
207
|
+
const unbindBeforeAgentStart = () => {
|
|
208
|
+
if (!loaderAvailable() || pi.getActiveTools().includes(SUBAGENT_LOADER_NAME))
|
|
209
|
+
return;
|
|
210
|
+
try {
|
|
211
|
+
pi.setActiveTools([...pi.getActiveTools(), SUBAGENT_LOADER_NAME]);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Best effort
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const unbindBeforeAgent = pi.on("before_agent_start", unbindBeforeAgentStart);
|
|
218
|
+
void selectFromSession(context);
|
|
219
|
+
return {
|
|
220
|
+
dispose() {
|
|
221
|
+
unbindSessionStart();
|
|
222
|
+
unbindSessionTree();
|
|
223
|
+
unbindBeforeAgent();
|
|
224
|
+
},
|
|
225
|
+
selectFromSession,
|
|
226
|
+
};
|
|
227
|
+
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type
|
|
2
|
+
import { type ChildIdentity } from "./domain.js";
|
|
3
3
|
import type { SubagentManager } from "./manager.js";
|
|
4
4
|
export declare function registerParentTools(pi: ExtensionAPI, manager: SubagentManager): void;
|
|
5
|
+
/** Sends a child-initiated request over the bridge and resolves with the parent's answer. */
|
|
6
|
+
export type ChildReport = (operation: string, payload: unknown, signal?: AbortSignal) => Promise<unknown>;
|
|
5
7
|
export interface RegisterChildToolsOptions {
|
|
6
|
-
|
|
8
|
+
/** The bridge connection to the parent; a child without one cannot report anything. */
|
|
9
|
+
readonly report: ChildReport;
|
|
7
10
|
readonly isBound?: (sessionId: string) => boolean;
|
|
8
11
|
}
|
|
9
|
-
export declare function registerChildTools(pi: ExtensionAPI, identity: ChildIdentity,
|
|
12
|
+
export declare function registerChildTools(pi: ExtensionAPI, identity: ChildIdentity, options: RegisterChildToolsOptions): void;
|