@hheei/pi-subagents 0.1.2
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/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/agent-resolver.d.ts +37 -0
- package/dist/agent-resolver.js +232 -0
- package/dist/child-bridge.d.ts +41 -0
- package/dist/child-bridge.js +214 -0
- package/dist/child-nudge.d.ts +24 -0
- package/dist/child-nudge.js +102 -0
- package/dist/commands.d.ts +6 -0
- package/dist/commands.js +155 -0
- package/dist/connector.d.ts +53 -0
- package/dist/connector.js +283 -0
- package/dist/diagnostics.d.ts +10 -0
- package/dist/diagnostics.js +20 -0
- package/dist/domain.d.ts +192 -0
- package/dist/domain.js +63 -0
- package/dist/extension.d.ts +2 -0
- package/dist/extension.js +145 -0
- package/dist/host-adapter.d.ts +76 -0
- package/dist/host-adapter.js +323 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/json-lines.d.ts +14 -0
- package/dist/json-lines.js +94 -0
- package/dist/launch-spec.d.ts +47 -0
- package/dist/launch-spec.js +149 -0
- package/dist/manager.d.ts +101 -0
- package/dist/manager.js +802 -0
- package/dist/pause-gate.d.ts +14 -0
- package/dist/pause-gate.js +64 -0
- package/dist/protocol.d.ts +141 -0
- package/dist/protocol.js +168 -0
- package/dist/registry.d.ts +41 -0
- package/dist/registry.js +575 -0
- package/dist/rpc-adapter.d.ts +40 -0
- package/dist/rpc-adapter.js +194 -0
- package/dist/rpc-events.d.ts +8 -0
- package/dist/rpc-events.js +29 -0
- package/dist/runner-entry.d.ts +24 -0
- package/dist/runner-entry.js +166 -0
- package/dist/runner.d.ts +52 -0
- package/dist/runner.js +930 -0
- package/dist/runtime.d.ts +22 -0
- package/dist/runtime.js +253 -0
- package/dist/session-bootstrap.d.ts +69 -0
- package/dist/session-bootstrap.js +195 -0
- package/dist/state.d.ts +15 -0
- package/dist/state.js +168 -0
- package/dist/tools.d.ts +9 -0
- package/dist/tools.js +158 -0
- package/dist/widget.d.ts +16 -0
- package/dist/widget.js +158 -0
- package/package.json +53 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { registerChildNudge } from "./child-nudge.js";
|
|
2
|
+
import { connectWithRetry, RunnerConnection, sendLifecycleToRunner } from "./connector.js";
|
|
3
|
+
import { CHILD_AGENT_ENV_KEY, CHILD_SESSION_ENV_KEY, isRecord } from "./domain.js";
|
|
4
|
+
import { PauseGate } from "./pause-gate.js";
|
|
5
|
+
import { CANCEL_PAUSE_EVENT, PAUSE_EVENT } from "./protocol.js";
|
|
6
|
+
import { registerChildTools } from "./tools.js";
|
|
7
|
+
import { createChildIdentityWidget } from "./widget.js";
|
|
8
|
+
const USER_INTERRUPT_STOP_REASON = "aborted";
|
|
9
|
+
export function boundSessionIdFromEnv(env = process.env) {
|
|
10
|
+
const value = env[CHILD_SESSION_ENV_KEY];
|
|
11
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
12
|
+
}
|
|
13
|
+
export function shouldLeaveBoundSession(params) {
|
|
14
|
+
if (params.currentSessionId === params.boundSessionId)
|
|
15
|
+
return false;
|
|
16
|
+
return params.reason === "new" || params.reason === "resume" || params.reason === "fork";
|
|
17
|
+
}
|
|
18
|
+
export function isConfirmedUserInterrupt(messages) {
|
|
19
|
+
if (messages === undefined)
|
|
20
|
+
return false;
|
|
21
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
22
|
+
const message = messages[index];
|
|
23
|
+
if (message?.role !== "assistant")
|
|
24
|
+
continue;
|
|
25
|
+
return message.stopReason === USER_INTERRUPT_STOP_REASON;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
export function lastActivityText(messages) {
|
|
30
|
+
if (messages === undefined)
|
|
31
|
+
return "";
|
|
32
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
33
|
+
const message = messages[index];
|
|
34
|
+
const text = messageText(message?.content);
|
|
35
|
+
if (text !== "")
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
function messageText(content) {
|
|
41
|
+
if (typeof content === "string")
|
|
42
|
+
return content.trim();
|
|
43
|
+
if (!Array.isArray(content))
|
|
44
|
+
return "";
|
|
45
|
+
return content
|
|
46
|
+
.map((part) => {
|
|
47
|
+
const value = isRecord(part) ? part : undefined;
|
|
48
|
+
return value?.type === "text" && typeof value.text === "string" ? value.text : "";
|
|
49
|
+
})
|
|
50
|
+
.join("")
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
53
|
+
function currentSessionId(ctx) {
|
|
54
|
+
return ctx.sessionManager.getSessionId();
|
|
55
|
+
}
|
|
56
|
+
function reportLifecycle(identity, kind, sessionId, message) {
|
|
57
|
+
void sendLifecycleToRunner(identity, {
|
|
58
|
+
type: "child_lifecycle",
|
|
59
|
+
parentSessionId: identity.parentSessionId,
|
|
60
|
+
childId: identity.subagentId,
|
|
61
|
+
runtimeIdentity: identity.runtimeIdentity,
|
|
62
|
+
kind,
|
|
63
|
+
sessionId,
|
|
64
|
+
...(message === undefined || message === "" ? {} : { message }),
|
|
65
|
+
}).catch((error) => {
|
|
66
|
+
console.error(`pi-subagents: failed to report ${kind}: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** Registers the child-only tools, identity widget, and session-leave reporting. */
|
|
70
|
+
export function registerChildBridge(pi, identity, options = {}) {
|
|
71
|
+
const boundSessionId = boundSessionIdFromEnv() ?? "";
|
|
72
|
+
const gate = options.gate ?? new PauseGate();
|
|
73
|
+
const state = {
|
|
74
|
+
bound: boundSessionId !== "",
|
|
75
|
+
boundSessionId,
|
|
76
|
+
gate,
|
|
77
|
+
};
|
|
78
|
+
const nudge = registerChildNudge(pi);
|
|
79
|
+
registerChildTools(pi, identity, {
|
|
80
|
+
onReport: () => nudge.markReported(),
|
|
81
|
+
isBound: (sessionId) => state.bound && (state.boundSessionId === "" || sessionId === state.boundSessionId),
|
|
82
|
+
});
|
|
83
|
+
const stop = new AbortController();
|
|
84
|
+
const pauseSocket = options.pauseSocket ?? openChildPauseSocket(identity);
|
|
85
|
+
pauseSocket.onEvent((event) => {
|
|
86
|
+
if (!isRecord(event) || typeof event.type !== "string")
|
|
87
|
+
return;
|
|
88
|
+
if (event.type === PAUSE_EVENT && typeof event.generation === "number") {
|
|
89
|
+
gate.request(event.generation);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (event.type === CANCEL_PAUSE_EVENT) {
|
|
93
|
+
gate.cancel(typeof event.generation === "number" ? event.generation : undefined);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
const connectPause = options.connect ?? defaultConnectPause;
|
|
97
|
+
void connectPause(pauseSocket, stop.signal).catch((error) => {
|
|
98
|
+
console.error(`pi-subagents: pause bridge failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
99
|
+
});
|
|
100
|
+
let identityWidget;
|
|
101
|
+
const disposeWidget = () => {
|
|
102
|
+
identityWidget?.dispose();
|
|
103
|
+
identityWidget = undefined;
|
|
104
|
+
};
|
|
105
|
+
const showIdentity = (ctx) => {
|
|
106
|
+
disposeWidget();
|
|
107
|
+
if (!state.bound || ctx.mode !== "tui")
|
|
108
|
+
return;
|
|
109
|
+
const tools = pi.getAllTools();
|
|
110
|
+
identityWidget = createChildIdentityWidget(pi, ctx, stop.signal, {
|
|
111
|
+
agent: process.env[CHILD_AGENT_ENV_KEY] ?? "",
|
|
112
|
+
toolCount: tools.length,
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
const leave = (sessionId) => {
|
|
116
|
+
if (!state.bound)
|
|
117
|
+
return;
|
|
118
|
+
state.bound = false;
|
|
119
|
+
gate.cancel();
|
|
120
|
+
pauseSocket.close();
|
|
121
|
+
disposeWidget();
|
|
122
|
+
nudge.dispose();
|
|
123
|
+
reportLifecycle(identity, "left_session", sessionId);
|
|
124
|
+
};
|
|
125
|
+
pi.on("session_start", (event, ctx) => {
|
|
126
|
+
const sessionId = currentSessionId(ctx);
|
|
127
|
+
if (!state.bound && state.boundSessionId === "") {
|
|
128
|
+
state.boundSessionId = sessionId;
|
|
129
|
+
state.bound = true;
|
|
130
|
+
showIdentity(ctx);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (shouldLeaveBoundSession({
|
|
134
|
+
boundSessionId: state.boundSessionId,
|
|
135
|
+
currentSessionId: sessionId,
|
|
136
|
+
reason: event.reason,
|
|
137
|
+
})) {
|
|
138
|
+
leave(sessionId);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (state.bound && sessionId === state.boundSessionId)
|
|
142
|
+
showIdentity(ctx);
|
|
143
|
+
});
|
|
144
|
+
pi.on("session_shutdown", (event) => {
|
|
145
|
+
if (event.reason === "quit" && state.bound) {
|
|
146
|
+
reportLifecycle(identity, "tui_quit", state.boundSessionId);
|
|
147
|
+
}
|
|
148
|
+
disposeWidget();
|
|
149
|
+
if (event.reason === "quit" ||
|
|
150
|
+
event.reason === "new" ||
|
|
151
|
+
event.reason === "resume" ||
|
|
152
|
+
event.reason === "fork") {
|
|
153
|
+
nudge.dispose();
|
|
154
|
+
}
|
|
155
|
+
if (event.reason === "quit") {
|
|
156
|
+
gate.cancel();
|
|
157
|
+
pauseSocket.close();
|
|
158
|
+
stop.abort();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
pi.on("agent_end", (event, ctx) => {
|
|
162
|
+
if (!state.bound || currentSessionId(ctx) !== state.boundSessionId)
|
|
163
|
+
return;
|
|
164
|
+
const messages = Array.isArray(event.messages) ? event.messages : undefined;
|
|
165
|
+
if (!isConfirmedUserInterrupt(messages))
|
|
166
|
+
return;
|
|
167
|
+
const activity = lastActivityText(messages);
|
|
168
|
+
reportLifecycle(identity, "user_interrupt", state.boundSessionId, activity === ""
|
|
169
|
+
? "Task is unfinished and waiting for user intent."
|
|
170
|
+
: `Task is unfinished and waiting for user intent. Last activity: ${activity}`);
|
|
171
|
+
});
|
|
172
|
+
pi.on("turn_end", async () => {
|
|
173
|
+
if (!state.bound || !gate.holding)
|
|
174
|
+
return;
|
|
175
|
+
const generation = gate.generation;
|
|
176
|
+
try {
|
|
177
|
+
await pauseSocket.reportPaused(generation, stop.signal);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (stop.signal.aborted)
|
|
181
|
+
return;
|
|
182
|
+
console.error(`pi-subagents: failed to ack pause: ${error instanceof Error ? error.message : String(error)}`);
|
|
183
|
+
}
|
|
184
|
+
await gate.wait(stop.signal);
|
|
185
|
+
});
|
|
186
|
+
return state;
|
|
187
|
+
}
|
|
188
|
+
function openChildPauseSocket(identity) {
|
|
189
|
+
const connection = new RunnerConnection({
|
|
190
|
+
endpoint: identity.endpoint,
|
|
191
|
+
identity,
|
|
192
|
+
token: identity.token,
|
|
193
|
+
role: "bridge",
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
connect: (signal) => connection.connect(signal),
|
|
197
|
+
async reportPaused(generation, signal) {
|
|
198
|
+
await connection.request("report_paused", {
|
|
199
|
+
type: "report_paused",
|
|
200
|
+
parentSessionId: identity.parentSessionId,
|
|
201
|
+
childId: identity.subagentId,
|
|
202
|
+
runtimeIdentity: identity.runtimeIdentity,
|
|
203
|
+
generation,
|
|
204
|
+
}, signal === undefined ? {} : { signal });
|
|
205
|
+
},
|
|
206
|
+
onEvent: (listener) => connection.onEvent(listener),
|
|
207
|
+
close: () => connection.close(),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async function defaultConnectPause(socket, signal) {
|
|
211
|
+
if (socket.connect === undefined)
|
|
212
|
+
return;
|
|
213
|
+
await connectWithRetry({ connect: (abort) => socket.connect?.(abort) ?? Promise.resolve() }, signal === undefined ? {} : { signal });
|
|
214
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child-side completion reminder.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from HazAT/maplezzk pi-interactive-subagents `subagent-done.ts`
|
|
5
|
+
* (MIT, upstream 7a5c96be138158a5cf6413ac819509eff12b1ccd). This version
|
|
6
|
+
* reminds the child to call `contact_parent` and never exits the session.
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
/** Return true only when the latest assistant message ended by the model stopping normally. */
|
|
10
|
+
export declare function shouldScheduleAgentEndNudge(messages: readonly {
|
|
11
|
+
role?: string;
|
|
12
|
+
stopReason?: string;
|
|
13
|
+
}[] | undefined): boolean;
|
|
14
|
+
export interface ChildNudgeController {
|
|
15
|
+
markReported(): void;
|
|
16
|
+
listen(pi: ExtensionAPI): void;
|
|
17
|
+
dispose(): void;
|
|
18
|
+
}
|
|
19
|
+
/** Owns the delay timer and the “already reported / user took over” gates. */
|
|
20
|
+
export declare function createChildNudgeController(options?: {
|
|
21
|
+
readonly delayMs?: number;
|
|
22
|
+
readonly disabled?: boolean;
|
|
23
|
+
}): ChildNudgeController;
|
|
24
|
+
export declare function registerChildNudge(pi: ExtensionAPI): ChildNudgeController;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { isRecord } from "./domain.js";
|
|
2
|
+
const ASSISTANT_ROLE = "assistant";
|
|
3
|
+
const NORMAL_STOP_REASON = "stop";
|
|
4
|
+
const DEFAULT_NUDGE_DELAY_MS = 5_000;
|
|
5
|
+
const MIN_NUDGE_DELAY_MS = 1_000;
|
|
6
|
+
const NUDGE_TEXT = "You finished a turn without calling contact_parent. If the parent needs a progress update, important finding, decision, or blocker, call contact_parent now. Do not wait for the parent to poll you.";
|
|
7
|
+
/** Return true only when the latest assistant message ended by the model stopping normally. */
|
|
8
|
+
export function shouldScheduleAgentEndNudge(messages) {
|
|
9
|
+
if (messages === undefined || messages.length === 0)
|
|
10
|
+
return false;
|
|
11
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
12
|
+
const message = messages[index];
|
|
13
|
+
if (message?.role !== ASSISTANT_ROLE)
|
|
14
|
+
continue;
|
|
15
|
+
return message.stopReason === NORMAL_STOP_REASON;
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
function parseNudgeDelayMs(rawValue) {
|
|
20
|
+
const parsed = Number.parseInt(rawValue ?? "", 10);
|
|
21
|
+
if (!Number.isFinite(parsed))
|
|
22
|
+
return DEFAULT_NUDGE_DELAY_MS;
|
|
23
|
+
return Math.max(MIN_NUDGE_DELAY_MS, parsed);
|
|
24
|
+
}
|
|
25
|
+
function agentEndMessages(event) {
|
|
26
|
+
if (!isRecord(event) || !Array.isArray(event.messages))
|
|
27
|
+
return undefined;
|
|
28
|
+
const messages = [];
|
|
29
|
+
for (const item of event.messages) {
|
|
30
|
+
if (!isRecord(item))
|
|
31
|
+
continue;
|
|
32
|
+
messages.push({
|
|
33
|
+
...(typeof item.role === "string" ? { role: item.role } : {}),
|
|
34
|
+
...(typeof item.stopReason === "string" ? { stopReason: item.stopReason } : {}),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return messages;
|
|
38
|
+
}
|
|
39
|
+
/** Owns the delay timer and the “already reported / user took over” gates. */
|
|
40
|
+
export function createChildNudgeController(options = {}) {
|
|
41
|
+
const delayMs = options.delayMs ?? parseNudgeDelayMs(process.env.PI_SUBAGENTS_NUDGE_DELAY_MS);
|
|
42
|
+
const disabled = options.disabled ?? process.env.PI_SUBAGENTS_NUDGE_DISABLE === "1";
|
|
43
|
+
let reported = false;
|
|
44
|
+
let userInputAfterAgentEnd = false;
|
|
45
|
+
let agentStarted = false;
|
|
46
|
+
let userTookOver = false;
|
|
47
|
+
let timer = null;
|
|
48
|
+
const clearTimer = () => {
|
|
49
|
+
if (timer === null)
|
|
50
|
+
return;
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
timer = null;
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
markReported() {
|
|
56
|
+
reported = true;
|
|
57
|
+
clearTimer();
|
|
58
|
+
},
|
|
59
|
+
listen(pi) {
|
|
60
|
+
pi.on("input", () => {
|
|
61
|
+
userInputAfterAgentEnd = true;
|
|
62
|
+
clearTimer();
|
|
63
|
+
if (agentStarted)
|
|
64
|
+
userTookOver = true;
|
|
65
|
+
});
|
|
66
|
+
pi.on("before_agent_start", () => {
|
|
67
|
+
clearTimer();
|
|
68
|
+
});
|
|
69
|
+
pi.on("agent_start", () => {
|
|
70
|
+
agentStarted = true;
|
|
71
|
+
reported = false;
|
|
72
|
+
userInputAfterAgentEnd = false;
|
|
73
|
+
clearTimer();
|
|
74
|
+
});
|
|
75
|
+
pi.on("agent_end", (event) => {
|
|
76
|
+
if (disabled || reported || userTookOver) {
|
|
77
|
+
clearTimer();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!shouldScheduleAgentEndNudge(agentEndMessages(event))) {
|
|
81
|
+
clearTimer();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
clearTimer();
|
|
85
|
+
timer = setTimeout(() => {
|
|
86
|
+
timer = null;
|
|
87
|
+
if (reported || userInputAfterAgentEnd || userTookOver)
|
|
88
|
+
return;
|
|
89
|
+
pi.sendUserMessage(NUDGE_TEXT, { deliverAs: "followUp" });
|
|
90
|
+
}, delayMs);
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
dispose() {
|
|
94
|
+
clearTimer();
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function registerChildNudge(pi) {
|
|
99
|
+
const controller = createChildNudgeController();
|
|
100
|
+
controller.listen(pi);
|
|
101
|
+
return controller;
|
|
102
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { PublicSubagent } from "./domain.js";
|
|
3
|
+
import type { SubagentManager } from "./manager.js";
|
|
4
|
+
export declare function formatStatusLine(children: readonly PublicSubagent[]): string | undefined;
|
|
5
|
+
export declare function registerParentCommands(pi: ExtensionAPI, manager: SubagentManager): void;
|
|
6
|
+
export declare function bindParentStatus(_pi: ExtensionAPI, context: ExtensionContext, manager: SubagentManager): () => void;
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const STATUS_KEY = "pi-subagents";
|
|
2
|
+
export function formatStatusLine(children) {
|
|
3
|
+
const visible = children.filter((child) => child.state !== "done" && child.state !== "stopped");
|
|
4
|
+
if (visible.length === 0)
|
|
5
|
+
return undefined;
|
|
6
|
+
return visible
|
|
7
|
+
.map((child) => {
|
|
8
|
+
const name = child.displayName ?? child.agent;
|
|
9
|
+
const mode = child.mode === "tui" ? "tui" : child.state;
|
|
10
|
+
const flag = child.interrupted !== undefined ? " interrupted" : "";
|
|
11
|
+
return `${name} ${mode}${flag}`;
|
|
12
|
+
})
|
|
13
|
+
.join(" · ");
|
|
14
|
+
}
|
|
15
|
+
function notifyResult(ctx, result, ok) {
|
|
16
|
+
if ("reason" in result && typeof result.reason === "string") {
|
|
17
|
+
ctx.ui.notify(result.reason, "error");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
ctx.ui.notify(ok);
|
|
21
|
+
}
|
|
22
|
+
function childLabel(child) {
|
|
23
|
+
const name = child.displayName ?? child.agent;
|
|
24
|
+
return `${name} ${child.id} ${child.state}/${child.mode}`;
|
|
25
|
+
}
|
|
26
|
+
async function pickChild(ctx, children, title) {
|
|
27
|
+
if (children.length === 0) {
|
|
28
|
+
ctx.ui.notify("No matching subagents", "warning");
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
if (children.length === 1)
|
|
32
|
+
return children[0];
|
|
33
|
+
const labels = children.map(childLabel);
|
|
34
|
+
const selected = await ctx.ui.select(title, labels);
|
|
35
|
+
if (selected === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
return children[labels.indexOf(selected)];
|
|
38
|
+
}
|
|
39
|
+
async function attachSelected(ctx, manager, id) {
|
|
40
|
+
if (id !== undefined && id !== "") {
|
|
41
|
+
notifyResult(ctx, await manager.attach(id), `Attached ${id}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const idle = (await manager.list()).filter((child) => child.mode === "rpc" && child.state === "idle");
|
|
45
|
+
const child = await pickChild(ctx, idle, "Attach idle subagent");
|
|
46
|
+
if (child === undefined)
|
|
47
|
+
return;
|
|
48
|
+
notifyResult(ctx, await manager.attach(child.id), `Attached ${child.id}`);
|
|
49
|
+
}
|
|
50
|
+
async function stopSelected(ctx, manager, id) {
|
|
51
|
+
if (id !== undefined && id !== "") {
|
|
52
|
+
notifyResult(ctx, await manager.stop(id), `Stopped ${id}`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const live = (await manager.list()).filter((child) => child.state !== "done" && child.state !== "stopped");
|
|
56
|
+
const child = await pickChild(ctx, live, "Stop subagent");
|
|
57
|
+
if (child === undefined)
|
|
58
|
+
return;
|
|
59
|
+
notifyResult(ctx, await manager.stop(child.id), `Stopped ${child.id}`);
|
|
60
|
+
}
|
|
61
|
+
export function registerParentCommands(pi, manager) {
|
|
62
|
+
pi.registerCommand("subagents", {
|
|
63
|
+
description: "List, inspect, attach, send to, or stop an owned subagent",
|
|
64
|
+
handler: async (args, ctx) => {
|
|
65
|
+
if (ctx.mode !== "tui") {
|
|
66
|
+
ctx.ui.notify("Use spawn_subagent / send_subagent / get_subagent / stop_subagent in this mode");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const action = args.trim() ||
|
|
70
|
+
(await ctx.ui.select("Subagents", ["list", "inspect", "attach", "send", "stop"]));
|
|
71
|
+
if (action === undefined)
|
|
72
|
+
return;
|
|
73
|
+
const children = await manager.list();
|
|
74
|
+
if (action === "list") {
|
|
75
|
+
if (children.length === 0) {
|
|
76
|
+
ctx.ui.notify("No subagents", "warning");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
ctx.ui.notify(children.map(childLabel).join("\n"));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (action === "inspect") {
|
|
83
|
+
const child = await pickChild(ctx, children, "Inspect subagent");
|
|
84
|
+
if (child === undefined)
|
|
85
|
+
return;
|
|
86
|
+
const inspected = await manager.get(child.id);
|
|
87
|
+
if ("reason" in inspected) {
|
|
88
|
+
ctx.ui.notify(inspected.reason, "error");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
ctx.ui.notify(`${childLabel(inspected)}\n${inspected.summary ?? "no summary"}\nmodel ${inspected.model.provider}/${inspected.model.id}`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (action === "attach") {
|
|
95
|
+
await attachSelected(ctx, manager);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (action === "stop") {
|
|
99
|
+
await stopSelected(ctx, manager);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (action === "send") {
|
|
103
|
+
const live = children.filter((child) => child.mode === "rpc" && (child.state === "idle" || child.state === "running"));
|
|
104
|
+
const child = await pickChild(ctx, live, "Send to subagent");
|
|
105
|
+
if (child === undefined)
|
|
106
|
+
return;
|
|
107
|
+
const message = await ctx.ui.input("Message for subagent");
|
|
108
|
+
if (message === undefined || message.trim() === "")
|
|
109
|
+
return;
|
|
110
|
+
notifyResult(ctx, await manager.send(child.id, message.trim()), `Sent to ${child.id}`);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
pi.registerCommand("attach-subagent", {
|
|
115
|
+
description: "Open an RPC child as a native Pi TUI. Idle children switch immediately; busy children wait for the current turn to finish. The session must already be flushed.",
|
|
116
|
+
handler: async (args, ctx) => {
|
|
117
|
+
await attachSelected(ctx, manager, args.trim());
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
pi.registerCommand("stop-subagent", {
|
|
121
|
+
description: "Stop an owned subagent",
|
|
122
|
+
handler: async (args, ctx) => {
|
|
123
|
+
await stopSelected(ctx, manager, args.trim());
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
pi.registerShortcut("ctrl+shift+a", {
|
|
127
|
+
description: "Attach idle subagent",
|
|
128
|
+
handler: (ctx) => {
|
|
129
|
+
if (ctx.mode !== "tui")
|
|
130
|
+
return;
|
|
131
|
+
return attachSelected(ctx, manager);
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
pi.registerShortcut("ctrl+shift+s", {
|
|
135
|
+
description: "Stop subagent",
|
|
136
|
+
handler: (ctx) => {
|
|
137
|
+
if (ctx.mode !== "tui")
|
|
138
|
+
return;
|
|
139
|
+
return stopSelected(ctx, manager);
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
export function bindParentStatus(_pi, context, manager) {
|
|
144
|
+
const refresh = () => {
|
|
145
|
+
void manager.list().then((children) => {
|
|
146
|
+
context.ui.setStatus(STATUS_KEY, formatStatusLine(children));
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
const unsubscribe = manager.onChange(refresh);
|
|
150
|
+
refresh();
|
|
151
|
+
return () => {
|
|
152
|
+
unsubscribe();
|
|
153
|
+
context.ui.setStatus(STATUS_KEY, undefined);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ChildIdentity } from "./domain.js";
|
|
2
|
+
import { type RunnerOperation } from "./protocol.js";
|
|
3
|
+
export interface RunnerConnectionOptions {
|
|
4
|
+
/** Unix socket path published by the runner. */
|
|
5
|
+
readonly endpoint: string;
|
|
6
|
+
readonly identity: ChildIdentity;
|
|
7
|
+
readonly token: string;
|
|
8
|
+
readonly maxFrameBytes?: number;
|
|
9
|
+
readonly maxPendingRequests?: number;
|
|
10
|
+
readonly requestTimeoutMs?: number;
|
|
11
|
+
readonly connectTimeoutMs?: number;
|
|
12
|
+
readonly role?: "controller" | "reporter" | "recovery" | "bridge";
|
|
13
|
+
readonly claimId?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Parent-side client of the runner IPC endpoint.
|
|
17
|
+
*
|
|
18
|
+
* The connection is re-connectable by design: `close()` only releases this
|
|
19
|
+
* parent's local socket and in-flight requests. It never terminates the runner
|
|
20
|
+
* or the Pi child that the runner owns.
|
|
21
|
+
*/
|
|
22
|
+
export declare class RunnerConnection {
|
|
23
|
+
#private;
|
|
24
|
+
constructor(options: RunnerConnectionOptions);
|
|
25
|
+
connect(signal?: AbortSignal): Promise<void>;
|
|
26
|
+
get connected(): boolean;
|
|
27
|
+
request(operation: RunnerOperation, payload?: unknown, options?: {
|
|
28
|
+
readonly signal?: AbortSignal;
|
|
29
|
+
readonly timeoutMs?: number;
|
|
30
|
+
}): Promise<unknown>;
|
|
31
|
+
onEvent(listener: (event: unknown) => void): () => void;
|
|
32
|
+
/**
|
|
33
|
+
* Releases the local socket, reader, listeners and in-flight requests. The
|
|
34
|
+
* runner keeps running and can be reconnected from the same identity.
|
|
35
|
+
*/
|
|
36
|
+
close(): void;
|
|
37
|
+
}
|
|
38
|
+
/** Sends one child report through a short-lived authenticated reporter connection. */
|
|
39
|
+
export declare function sendReportToRunner(identity: ChildIdentity, report: unknown, signal?: AbortSignal): Promise<void>;
|
|
40
|
+
/** Sends one child lifecycle event through a short-lived authenticated reporter connection. */
|
|
41
|
+
export declare function sendLifecycleToRunner(identity: ChildIdentity, payload: unknown, signal?: AbortSignal): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Connects to a runner endpoint that may still be starting up.
|
|
44
|
+
*
|
|
45
|
+
* Readiness is never a fixed sleep: each attempt is a real connect plus
|
|
46
|
+
* identity handshake, so a resolved promise means the runner accepted this
|
|
47
|
+
* controller. Only connection-level failures inside the deadline are retried.
|
|
48
|
+
*/
|
|
49
|
+
export declare function connectWithRetry(connection: Pick<RunnerConnection, "connect">, options?: {
|
|
50
|
+
readonly attempts?: number;
|
|
51
|
+
readonly delayMs?: number;
|
|
52
|
+
readonly signal?: AbortSignal;
|
|
53
|
+
}): Promise<void>;
|