pi-famulus 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/bin/pi-famulus.js +2 -2
- package/package.json +7 -7
- package/src/child-mode.ts +214 -0
- package/src/comms/comms.ts +18 -13
- package/src/comms/registry-host.ts +10 -0
- package/src/comms/tools.ts +9 -2
- package/src/comms/types.ts +9 -3
- package/src/config.ts +18 -6
- package/src/index.ts +164 -36
- package/src/manager-client.ts +151 -6
- package/src/native-manager.d.ts +30 -0
- package/src/native-manager.js +260 -11
- package/src/pi-command.ts +123 -0
- package/src/prompts.generated.ts +7 -8
- package/src/subagent/hosted-session.ts +268 -0
- package/src/subagent/overrun.ts +11 -0
- package/src/subagent/pi-runtime.ts +4 -0
- package/src/subagent/runner.ts +19 -9
- package/src/subagent/tool.ts +70 -9
- package/src/subagent/types.ts +2 -0
- package/src/tui/attach-command.ts +164 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChildSessionAdapter over a daemon-hosted `pi --mode rpc` process.
|
|
3
|
+
* The runner's timers stay; only the session lives in another process.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { ManagerClient, ManagerEvent } from "../manager-client";
|
|
9
|
+
import { parentResourceArgs, resolvePiArgv } from "../pi-command";
|
|
10
|
+
import { resolveChildModel, type PiRuntimeDeps } from "./pi-runtime";
|
|
11
|
+
import type {
|
|
12
|
+
ChildRunRequest,
|
|
13
|
+
ChildSessionAdapter,
|
|
14
|
+
ChildSessionEvent,
|
|
15
|
+
ChildTokenUsage,
|
|
16
|
+
CreateSessionFn,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
export interface HostedSessionDeps extends PiRuntimeDeps {
|
|
20
|
+
getClient: () => ManagerClient | null;
|
|
21
|
+
getHome: () => string;
|
|
22
|
+
/** This extension's entry file, passed to the child with -e. */
|
|
23
|
+
extensionEntry: string;
|
|
24
|
+
piCommand?: string | null;
|
|
25
|
+
trustProject?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface HostingView {
|
|
29
|
+
residency: string;
|
|
30
|
+
activity: string;
|
|
31
|
+
driver: string;
|
|
32
|
+
cacheExpiresAt?: number;
|
|
33
|
+
sessionFile?: string;
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createHostedSessionFn(deps: HostedSessionDeps): CreateSessionFn {
|
|
38
|
+
return async (req) => {
|
|
39
|
+
const client = deps.getClient();
|
|
40
|
+
if (!client || !client.isAvailable()) {
|
|
41
|
+
throw new Error("pi-famulus daemon is not connected; a subagent needs it to run pi --mode rpc.");
|
|
42
|
+
}
|
|
43
|
+
client.requireHostedProtocol();
|
|
44
|
+
const pi = resolvePiArgv(deps.piCommand);
|
|
45
|
+
if ("error" in pi) throw new Error(pi.error);
|
|
46
|
+
const model = resolveChildModel(deps, req);
|
|
47
|
+
const thinking =
|
|
48
|
+
model.thinkingOverride ?? req.agent.thinking ?? deps.getParentThinkingLevel() ?? "off";
|
|
49
|
+
const cwd = deps.getCwd();
|
|
50
|
+
const promptPath = join(deps.getHome(), "agents", "prompts");
|
|
51
|
+
mkdirSync(promptPath, { recursive: true });
|
|
52
|
+
const promptFile = join(promptPath, `${req.childId}.md`);
|
|
53
|
+
writeFileSync(promptFile, req.agent.systemPrompt);
|
|
54
|
+
const inherited = parentResourceArgs();
|
|
55
|
+
const extensionPaths = inherited.filter((_, i) => inherited[i - 1] === "-e");
|
|
56
|
+
const alreadyLoadsUs = extensionPaths.some((path) => path === deps.extensionEntry);
|
|
57
|
+
const trustInherited = inherited.some((arg) => arg === "-a" || arg === "--approve" || arg === "-na" || arg === "--no-approve");
|
|
58
|
+
const argv = [
|
|
59
|
+
...pi.argv,
|
|
60
|
+
"--mode", "rpc",
|
|
61
|
+
"--session-id", req.childId,
|
|
62
|
+
"--name", `${req.agent.name}: ${req.name}`,
|
|
63
|
+
...inherited,
|
|
64
|
+
...(alreadyLoadsUs ? [] : ["-e", deps.extensionEntry]),
|
|
65
|
+
...(model.model ? ["--model", `${model.model.provider}/${model.model.id}`] : []),
|
|
66
|
+
"--thinking", String(thinking),
|
|
67
|
+
"--append-system-prompt", promptFile,
|
|
68
|
+
...(trustInherited ? [] : [deps.getProjectTrusted?.() ? "-a" : "-na"]),
|
|
69
|
+
...(req.agent.tools ? ["--tools", req.agent.tools.join(",")] : []),
|
|
70
|
+
];
|
|
71
|
+
const env = { ...process.env } as Record<string, string>;
|
|
72
|
+
if (req.agent.tools) env.PI_FAMULUS_CHILD_TOOLS = req.agent.tools.join(",");
|
|
73
|
+
env.PI_FAMULUS_CHILD_RUN = req.runId;
|
|
74
|
+
env.PI_FAMULUS_CHILD_NAME = req.name;
|
|
75
|
+
const spawned = await client.agentSpawn({
|
|
76
|
+
childId: req.childId,
|
|
77
|
+
runId: req.runId,
|
|
78
|
+
name: req.name,
|
|
79
|
+
argv,
|
|
80
|
+
cwd,
|
|
81
|
+
env,
|
|
82
|
+
});
|
|
83
|
+
let warning = model.warning;
|
|
84
|
+
try {
|
|
85
|
+
await client.agentInput(req.childId, "set_steering_mode", "all", "parent");
|
|
86
|
+
} catch (err) {
|
|
87
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
88
|
+
const note = `Steering mode was not set (${message}); queued input may wait until the current turn ends.`;
|
|
89
|
+
warning = warning ? `${warning} ${note}` : note;
|
|
90
|
+
}
|
|
91
|
+
await client.agentWatch(req.childId);
|
|
92
|
+
const session = new HostedRpcSession(client, req.childId, {
|
|
93
|
+
warning,
|
|
94
|
+
resolvedModel: model.model ? `${model.model.provider}/${model.model.id}` : undefined,
|
|
95
|
+
effectiveThinkingLevel: typeof thinking === "string" ? thinking : undefined,
|
|
96
|
+
sessionFile: typeof spawned.session_file === "string" ? spawned.session_file : undefined,
|
|
97
|
+
sessionId: typeof spawned.session_id === "string" ? spawned.session_id : req.childId,
|
|
98
|
+
});
|
|
99
|
+
return session;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
class HostedRpcSession implements ChildSessionAdapter {
|
|
104
|
+
readonly warning?: string;
|
|
105
|
+
readonly resolvedModel?: string;
|
|
106
|
+
readonly effectiveThinkingLevel?: string;
|
|
107
|
+
private readonly listeners = new Set<(event: ChildSessionEvent) => void>();
|
|
108
|
+
private readonly unsubscribe: () => void;
|
|
109
|
+
private streaming = false;
|
|
110
|
+
private lastText = "";
|
|
111
|
+
private turns: { role: string; text: string }[] = [];
|
|
112
|
+
private usage: ChildTokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
113
|
+
private settle: (() => void) | null = null;
|
|
114
|
+
private hostingView: HostingView;
|
|
115
|
+
private disposed = false;
|
|
116
|
+
|
|
117
|
+
constructor(
|
|
118
|
+
private readonly client: ManagerClient,
|
|
119
|
+
private readonly childId: string,
|
|
120
|
+
init: { warning?: string; resolvedModel?: string; effectiveThinkingLevel?: string; sessionFile?: string; sessionId?: string },
|
|
121
|
+
) {
|
|
122
|
+
this.warning = init.warning;
|
|
123
|
+
this.resolvedModel = init.resolvedModel;
|
|
124
|
+
this.effectiveThinkingLevel = init.effectiveThinkingLevel;
|
|
125
|
+
this.hostingView = {
|
|
126
|
+
residency: "warm",
|
|
127
|
+
activity: "starting",
|
|
128
|
+
driver: "parent",
|
|
129
|
+
sessionFile: init.sessionFile,
|
|
130
|
+
sessionId: init.sessionId,
|
|
131
|
+
};
|
|
132
|
+
this.unsubscribe = this.client.onEvent((event) => this.onManagerEvent(event as ManagerEvent & Record<string, unknown>));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
hosting(): HostingView {
|
|
136
|
+
return { ...this.hostingView };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async prompt(text: string): Promise<void> {
|
|
140
|
+
const done = this.armSettle();
|
|
141
|
+
const op = this.streaming ? "prompt_steer" : "prompt";
|
|
142
|
+
await this.client.agentInput(this.childId, op, text, "parent");
|
|
143
|
+
if (!this.streaming) await done;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async steer(text: string): Promise<void> {
|
|
147
|
+
await this.client.agentInput(this.childId, "prompt_steer", text, "parent");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async followUp(text: string): Promise<void> {
|
|
151
|
+
await this.client.agentInput(this.childId, "prompt_follow", text, "parent");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async abort(): Promise<void> {
|
|
155
|
+
await this.client.agentInput(this.childId, "abort", "", "parent");
|
|
156
|
+
this.streaming = false;
|
|
157
|
+
this.settle?.();
|
|
158
|
+
this.settle = null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async park(): Promise<void> {
|
|
162
|
+
await this.client.agentPark(this.childId);
|
|
163
|
+
this.hostingView.residency = "parked";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async waitForIdle(): Promise<void> {
|
|
167
|
+
if (!this.streaming) return;
|
|
168
|
+
await this.armSettle();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
getLastAssistantText(): string | undefined {
|
|
172
|
+
return this.lastText || undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
getConversation() {
|
|
176
|
+
return this.turns.map((turn) => ({ ...turn }));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
tokenUsage(): ChildTokenUsage {
|
|
180
|
+
return { ...this.usage };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
isStreaming(): boolean {
|
|
184
|
+
return this.streaming;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
subscribe(listener: (event: ChildSessionEvent) => void): () => void {
|
|
188
|
+
this.listeners.add(listener);
|
|
189
|
+
return () => this.listeners.delete(listener);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
dispose(): void {
|
|
193
|
+
if (this.disposed) return;
|
|
194
|
+
this.disposed = true;
|
|
195
|
+
this.unsubscribe();
|
|
196
|
+
this.settle?.();
|
|
197
|
+
this.settle = null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private armSettle(): Promise<void> {
|
|
201
|
+
return new Promise((resolve) => {
|
|
202
|
+
this.settle = resolve;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private onManagerEvent(event: ManagerEvent & Record<string, unknown>): void {
|
|
207
|
+
if (event.child_id !== this.childId) return;
|
|
208
|
+
if (event.event === "agent_state") {
|
|
209
|
+
this.hostingView = {
|
|
210
|
+
residency: String(event.residency ?? this.hostingView.residency),
|
|
211
|
+
activity: String(event.activity ?? this.hostingView.activity),
|
|
212
|
+
driver: String(event.driver ?? this.hostingView.driver),
|
|
213
|
+
cacheExpiresAt: typeof event.cache_expires_at === "number" ? event.cache_expires_at : this.hostingView.cacheExpiresAt,
|
|
214
|
+
sessionFile: typeof event.session_file === "string" ? event.session_file : this.hostingView.sessionFile,
|
|
215
|
+
sessionId: typeof event.session_id === "string" ? event.session_id : this.hostingView.sessionId,
|
|
216
|
+
};
|
|
217
|
+
if (event.activity === "running") this.streaming = true;
|
|
218
|
+
if (event.activity === "idle" || event.activity === "failed") {
|
|
219
|
+
this.streaming = false;
|
|
220
|
+
this.settle?.();
|
|
221
|
+
this.settle = null;
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (event.event !== "agent_rpc" || !event.record || typeof event.record !== "object") return;
|
|
226
|
+
const record = event.record as { type?: string; message?: { role?: string; content?: { type?: string; text?: string }[]; usage?: Record<string, unknown> }; toolCallId?: string };
|
|
227
|
+
const type = record.type ?? "";
|
|
228
|
+
if (type === "agent_start") this.streaming = true;
|
|
229
|
+
if (type === "agent_settled") {
|
|
230
|
+
this.streaming = false;
|
|
231
|
+
this.settle?.();
|
|
232
|
+
this.settle = null;
|
|
233
|
+
}
|
|
234
|
+
if (type === "message_end" && record.message?.role === "assistant") {
|
|
235
|
+
const text = (record.message.content ?? []).map((part) => part.text ?? "").join("");
|
|
236
|
+
if (text) {
|
|
237
|
+
this.lastText = text;
|
|
238
|
+
this.turns.push({ role: "assistant", text });
|
|
239
|
+
}
|
|
240
|
+
const usage = record.message.usage;
|
|
241
|
+
if (usage) {
|
|
242
|
+
this.usage.input += num(usage.input);
|
|
243
|
+
this.usage.output += num(usage.output);
|
|
244
|
+
this.usage.cacheRead += num(usage.cacheRead);
|
|
245
|
+
this.usage.cacheWrite += num(usage.cacheWrite);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (type === "message_end" && record.message?.role === "user") {
|
|
249
|
+
const text = (record.message.content ?? []).map((part) => part.text ?? "").join("");
|
|
250
|
+
if (text) this.turns.push({ role: "user", text });
|
|
251
|
+
}
|
|
252
|
+
const adapted: ChildSessionEvent = {
|
|
253
|
+
type,
|
|
254
|
+
...(record.message?.role ? { role: record.message.role } : {}),
|
|
255
|
+
...(record.toolCallId ? { toolCallId: record.toolCallId } : {}),
|
|
256
|
+
};
|
|
257
|
+
for (const listener of this.listeners) listener(adapted);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function num(value: unknown): number {
|
|
262
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Request id for a child→parent decision. */
|
|
266
|
+
export function newCommsRequestId(): string {
|
|
267
|
+
return `rq_${randomUUID().slice(0, 8)}`;
|
|
268
|
+
}
|
package/src/subagent/overrun.ts
CHANGED
|
@@ -38,6 +38,17 @@ export class ChildShellTracker {
|
|
|
38
38
|
this.sizes.delete(taskId);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** A hosted child's shell is observed from the parent session's task_exited. */
|
|
42
|
+
endByTask(taskId: string): void {
|
|
43
|
+
for (const [childId, shell] of this.shells) {
|
|
44
|
+
if (shell.taskId === taskId) {
|
|
45
|
+
this.shells.delete(childId);
|
|
46
|
+
this.sizes.delete(taskId);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
41
52
|
current(childId: string): ChildShell | undefined {
|
|
42
53
|
return this.shells.get(childId);
|
|
43
54
|
}
|
|
@@ -157,6 +157,10 @@ interface ResolvedModel {
|
|
|
157
157
|
* falls back to the parent model with a warning (user-authored files must not
|
|
158
158
|
* hard-fail across machines).
|
|
159
159
|
*/
|
|
160
|
+
export function resolveChildModel(deps: PiRuntimeDeps, req: ChildRunRequest): ResolvedModel {
|
|
161
|
+
return resolveModel(deps, req);
|
|
162
|
+
}
|
|
163
|
+
|
|
160
164
|
function resolveModel(deps: PiRuntimeDeps, req: ChildRunRequest): ResolvedModel {
|
|
161
165
|
const candidates = modelCandidates(deps);
|
|
162
166
|
const findModel = (provider: string, id: string): Model | undefined =>
|
package/src/subagent/runner.ts
CHANGED
|
@@ -99,10 +99,10 @@ export interface InProcessRunnerOptions {
|
|
|
99
99
|
/** Shared time source and scheduler. */
|
|
100
100
|
clock?: Clock;
|
|
101
101
|
/**
|
|
102
|
-
* Per-generation admission hook. Awaited before each (re)start
|
|
103
|
-
* releaser
|
|
104
|
-
*
|
|
105
|
-
* Rejecting cancels the generation as {status:"interrupted", error}.
|
|
102
|
+
* Per-generation admission hook. Awaited before each (re)start. The
|
|
103
|
+
* releaser is called with terminal=true when the turn settles, including
|
|
104
|
+
* an interrupted turn: the machine permit is released and the next turn
|
|
105
|
+
* acquires again. Rejecting cancels the generation as {status:"interrupted", error}.
|
|
106
106
|
*/
|
|
107
107
|
acquire?: (req: ChildRunRequest, ticket: AdmissionTicket) => Promise<(terminal?: boolean) => void>;
|
|
108
108
|
/**
|
|
@@ -462,8 +462,17 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
462
462
|
await this.beginGeneration(message, false, false, gen);
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
+
hosting(): { residency?: string; driver?: string; cacheExpiresAt?: number; sessionFile?: string; sessionId?: string } | undefined {
|
|
466
|
+
const session = this.session as { hosting?: () => { residency?: string; driver?: string; cacheExpiresAt?: number; sessionFile?: string; sessionId?: string } } | null;
|
|
467
|
+
return session?.hosting?.();
|
|
468
|
+
}
|
|
469
|
+
|
|
465
470
|
async interrupt(): Promise<void> {
|
|
466
|
-
if (this.status_ !== "running" && this.status_ !== "pending")
|
|
471
|
+
if (this.status_ !== "running" && this.status_ !== "pending") {
|
|
472
|
+
const session = this.session as { park?: () => Promise<void> } | null;
|
|
473
|
+
if (session?.park) await session.park();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
467
476
|
this.settle(this.generation, {
|
|
468
477
|
status: "interrupted",
|
|
469
478
|
// A pending turn has produced nothing yet; the last assistant text
|
|
@@ -563,8 +572,8 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
563
572
|
signal: admissionAbort.signal,
|
|
564
573
|
});
|
|
565
574
|
if (this.disposed || this.isSettled(gen)) {
|
|
566
|
-
//
|
|
567
|
-
release(
|
|
575
|
+
// The turn is over (interrupted or disposed). The next turn acquires again.
|
|
576
|
+
release(true);
|
|
568
577
|
return;
|
|
569
578
|
}
|
|
570
579
|
this.releaseSlot = release;
|
|
@@ -756,8 +765,9 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
756
765
|
result.warning = this.session.warning;
|
|
757
766
|
}
|
|
758
767
|
this.status_ = result.status;
|
|
759
|
-
|
|
760
|
-
|
|
768
|
+
// Every settled turn releases its machine permit, including an
|
|
769
|
+
// interrupted one. The next turn (resume, send, user input) acquires again.
|
|
770
|
+
this.release(true);
|
|
761
771
|
this.notifyActivity();
|
|
762
772
|
this.resolveResult(result);
|
|
763
773
|
}
|
package/src/subagent/tool.ts
CHANGED
|
@@ -140,6 +140,8 @@ export interface SubagentToolDeps {
|
|
|
140
140
|
/** Agent definition resolver (M5 wires the real loader; default: worker). */
|
|
141
141
|
resolveAgent?: (name: string | undefined) => AgentDefinition;
|
|
142
142
|
/** Selectable models for action:"models" (§4.6); absent → action errors. */
|
|
143
|
+
/** Receives the watcher for turns started by agent_message send. */
|
|
144
|
+
registerResumeWatch?: (watch: (childId: string) => void) => void;
|
|
143
145
|
listModels?: () => {
|
|
144
146
|
provider: string;
|
|
145
147
|
id: string;
|
|
@@ -346,6 +348,20 @@ export function createSubagentTool(
|
|
|
346
348
|
return true;
|
|
347
349
|
};
|
|
348
350
|
|
|
351
|
+
const armResumeWake = (childId: string): void => {
|
|
352
|
+
const registry = deps.getRegistry();
|
|
353
|
+
if (!registry) return;
|
|
354
|
+
const record = registry.list().find((run) => run.children.some((c) => c.childId === childId));
|
|
355
|
+
if (!record) return;
|
|
356
|
+
const handedOver = new Set<string>();
|
|
357
|
+
void registry.getResult(childId)?.then(() => {
|
|
358
|
+
if (!notifyChildHandover(registry, record.runId, childId, handedOver)) {
|
|
359
|
+
notifyRunCompleted(registry, record.runId);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
};
|
|
363
|
+
deps.registerResumeWatch?.(armResumeWake);
|
|
364
|
+
|
|
349
365
|
const startRun = async (
|
|
350
366
|
params: SubagentParams,
|
|
351
367
|
signal: AbortSignal | undefined,
|
|
@@ -547,7 +563,18 @@ export function createSubagentTool(
|
|
|
547
563
|
};
|
|
548
564
|
}
|
|
549
565
|
|
|
550
|
-
const record =
|
|
566
|
+
const record = params.run_id
|
|
567
|
+
? requireRun(registry, params.run_id)
|
|
568
|
+
: params.child_id
|
|
569
|
+
? resolveRunByChild(registry, params.child_id)
|
|
570
|
+
: undefined;
|
|
571
|
+
if (!record) {
|
|
572
|
+
if (params.child_id && !params.run_id) {
|
|
573
|
+
throw new Error(`unknown child "${params.child_id}" (no run in this session holds it)`);
|
|
574
|
+
}
|
|
575
|
+
requireRun(registry, params.run_id);
|
|
576
|
+
throw new Error("run_id is required for this action");
|
|
577
|
+
}
|
|
551
578
|
|
|
552
579
|
if (action === "get") {
|
|
553
580
|
return {
|
|
@@ -585,18 +612,45 @@ export function createSubagentTool(
|
|
|
585
612
|
const known = record.children.map((c) => `${c.name} (${c.childId})`).join(", ");
|
|
586
613
|
throw new Error(`no child "${params.child_id}" in run ${record.runId} (children: ${known})`);
|
|
587
614
|
}
|
|
615
|
+
const idle = record.children.filter((c) => {
|
|
616
|
+
if (params.child_id && c.childId !== params.child_id && c.name !== params.child_id) return false;
|
|
617
|
+
return c.status === "completed" || c.status === "failed" || c.status === "interrupted";
|
|
618
|
+
});
|
|
588
619
|
for (const child of targets) {
|
|
589
620
|
await registry.handle(child.childId)?.interrupt();
|
|
590
621
|
}
|
|
591
|
-
const
|
|
622
|
+
for (const child of idle) {
|
|
623
|
+
await registry.handle(child.childId)?.interrupt();
|
|
624
|
+
}
|
|
625
|
+
const interrupted = [...targets, ...idle].map((c) => c.childId);
|
|
626
|
+
const scope = params.child_id ? `subagent ${params.child_id}` : `${interrupted.length} subagent(s)`;
|
|
592
627
|
return {
|
|
593
628
|
content: [{ type: "text", text: `Interrupted ${scope} in run ${record.runId}.` }],
|
|
594
|
-
details: { run_id: record.runId, interrupted
|
|
629
|
+
details: { run_id: record.runId, interrupted },
|
|
595
630
|
};
|
|
596
631
|
}
|
|
597
632
|
|
|
598
633
|
if (action === "steer") {
|
|
599
634
|
if (!params.message) throw new Error("message is required for steer");
|
|
635
|
+
const named = params.child_id
|
|
636
|
+
? record.children.find((c) => c.childId === params.child_id || c.name === params.child_id)
|
|
637
|
+
: record.children.length === 1 ? record.children[0] : undefined;
|
|
638
|
+
if (named && named.status !== "running" && named.status !== "pending") {
|
|
639
|
+
const { queuedBehind } = await registry.resumeChild(named.childId, params.message);
|
|
640
|
+
armResumeWake(named.childId);
|
|
641
|
+
return {
|
|
642
|
+
content: [{
|
|
643
|
+
type: "text",
|
|
644
|
+
text: fill("tools.subagent.result.resumed", {
|
|
645
|
+
name: named.name,
|
|
646
|
+
childId: named.childId,
|
|
647
|
+
runId: record.runId,
|
|
648
|
+
queued: queuedText(queuedBehind),
|
|
649
|
+
}),
|
|
650
|
+
}],
|
|
651
|
+
details: { run_id: record.runId, child_id: named.childId, queued_behind: queuedBehind },
|
|
652
|
+
};
|
|
653
|
+
}
|
|
600
654
|
const handle = resolveSingleActiveChild(registry, record, params.child_id, "steer");
|
|
601
655
|
await handle.steer(params.message);
|
|
602
656
|
return {
|
|
@@ -645,12 +699,7 @@ export function createSubagentTool(
|
|
|
645
699
|
);
|
|
646
700
|
// Resume is asynchronous. If siblings are still running, hand this child
|
|
647
701
|
// back as soon as it finishes; otherwise the run-complete wake covers it.
|
|
648
|
-
|
|
649
|
-
void registry.getResult(child.childId)?.then(() => {
|
|
650
|
-
if (!notifyChildHandover(registry, record.runId, child.childId, handedOver)) {
|
|
651
|
-
notifyRunCompleted(registry, record.runId);
|
|
652
|
-
}
|
|
653
|
-
});
|
|
702
|
+
armResumeWake(child.childId);
|
|
654
703
|
return {
|
|
655
704
|
content: [
|
|
656
705
|
{
|
|
@@ -733,6 +782,18 @@ export function createSubagentTool(
|
|
|
733
782
|
};
|
|
734
783
|
}
|
|
735
784
|
|
|
785
|
+
function resolveRunByChild(registry: SubagentRegistry, childIdOrName: string) {
|
|
786
|
+
const runs = registry.list();
|
|
787
|
+
const byId = runs.filter((run) => run.children.some((c) => c.childId === childIdOrName));
|
|
788
|
+
if (byId.length === 1) return byId[0];
|
|
789
|
+
const byName = runs.filter((run) => run.children.some((c) => c.name === childIdOrName));
|
|
790
|
+
if (byName.length > 1) {
|
|
791
|
+
const ids = byName.map((run) => run.runId).join(", ");
|
|
792
|
+
throw new Error(`child name "${childIdOrName}" is in more than one run (${ids}); pass run_id`);
|
|
793
|
+
}
|
|
794
|
+
return byName[0];
|
|
795
|
+
}
|
|
796
|
+
|
|
736
797
|
/** Resume result clause for a turn waiting for an admission slot ("" when it started at once). */
|
|
737
798
|
function queuedText(queuedBehind: number | null): string {
|
|
738
799
|
if (queuedBehind === null) return "";
|
package/src/subagent/types.ts
CHANGED
|
@@ -135,6 +135,8 @@ export interface ChildHandle {
|
|
|
135
135
|
tokenUsage(): ChildTokenUsage;
|
|
136
136
|
/** Cumulative observed LLM, tool, admission-queue and approximate wall time. */
|
|
137
137
|
wallUsage(): ChildWallUsage;
|
|
138
|
+
/** Hosted-process view, when this child runs as `pi --mode rpc`. */
|
|
139
|
+
hosting?(): { residency?: string; driver?: string; cacheExpiresAt?: number; sessionFile?: string; sessionId?: string } | undefined;
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
/**
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/attach <child>`: talk to a hosted subagent through the daemon, in the pi TUI.
|
|
3
|
+
* Lines are the child's transcript; Enter sends a prompt (steered while it runs).
|
|
4
|
+
*/
|
|
5
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { ManagerClient } from "../manager-client";
|
|
7
|
+
import type { SubagentRegistry } from "../subagent/registry";
|
|
8
|
+
import { fitLines, loadPiTui } from "./pi-tui-load";
|
|
9
|
+
|
|
10
|
+
export interface AttachCommandDeps {
|
|
11
|
+
getClient: () => ManagerClient | null;
|
|
12
|
+
getRegistry: () => SubagentRegistry | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registerAttachCommand(pi: ExtensionAPI, deps: AttachCommandDeps): void {
|
|
16
|
+
pi.registerCommand("attach", {
|
|
17
|
+
description: "Talk to a hosted subagent (child id)",
|
|
18
|
+
getArgumentCompletions: () => {
|
|
19
|
+
const children = deps.getRegistry()?.list().flatMap((run) => run.children.map((child) => child.childId)) ?? [];
|
|
20
|
+
return children.map((id) => ({ value: id, label: id }));
|
|
21
|
+
},
|
|
22
|
+
handler: async (args, ctx) => {
|
|
23
|
+
const childId = args.trim();
|
|
24
|
+
if (!childId) {
|
|
25
|
+
ctx.ui.notify("Usage: /attach <child id>", "warning");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const client = deps.getClient();
|
|
29
|
+
if (!client?.isAvailable() || client.protocolLevel() < 6) {
|
|
30
|
+
ctx.ui.notify("Hosted subagents need a connected pi-famulus daemon (protocol 6).", "error");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
34
|
+
ctx.ui.notify(`Attach from a terminal with: pi-famulus attach ${childId}`, "info");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
await openAttachView(ctx, client, childId);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface EntryMessage {
|
|
43
|
+
role?: string;
|
|
44
|
+
content?: string | { text?: string }[];
|
|
45
|
+
message?: EntryMessage;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function textOf(message: EntryMessage | undefined): string {
|
|
49
|
+
const content = message?.content;
|
|
50
|
+
if (typeof content === "string") return content;
|
|
51
|
+
if (!Array.isArray(content)) return "";
|
|
52
|
+
return content.map((part) => (typeof part === "string" ? part : part?.text ?? "")).join("");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function linesFromEntries(entries: unknown): string[] {
|
|
56
|
+
if (!Array.isArray(entries)) return [];
|
|
57
|
+
const out: string[] = [];
|
|
58
|
+
for (const entry of entries as EntryMessage[]) {
|
|
59
|
+
const message = entry.message ?? entry;
|
|
60
|
+
const text = textOf(message);
|
|
61
|
+
if (!text) continue;
|
|
62
|
+
if (message.role === "user") out.push(`you: ${text}`);
|
|
63
|
+
else if (message.role === "assistant") out.push(text);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Live transcript for one hosted child. Closing does not respawn a parked child. */
|
|
69
|
+
export async function openAttachView(ctx: ExtensionContext, client: ManagerClient, childId: string): Promise<void> {
|
|
70
|
+
const piTui = loadPiTui();
|
|
71
|
+
if (!piTui) {
|
|
72
|
+
ctx.ui.notify(`pi-famulus attach ${childId}`, "info");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
await client.agentWatch(childId).catch((err: unknown) => {
|
|
76
|
+
throw new Error(err instanceof Error ? err.message : String(err));
|
|
77
|
+
});
|
|
78
|
+
const seeded = await client.agentInput(childId, "get_entries", "", "parent").catch(() => null);
|
|
79
|
+
const state = await client.agentInput(childId, "get_state", "", "parent").catch(() => null);
|
|
80
|
+
const lines: string[] = [`attached to ${childId}`, ""];
|
|
81
|
+
lines.push(...linesFromEntries(seeded?.entries));
|
|
82
|
+
let input = "";
|
|
83
|
+
let activity = state?.isStreaming === true ? "running" : "idle";
|
|
84
|
+
let status = activity;
|
|
85
|
+
let requestRender: (() => void) | null = null;
|
|
86
|
+
const unsub = client.onEvent((event) => {
|
|
87
|
+
const hosted = event as {
|
|
88
|
+
event?: string;
|
|
89
|
+
child_id?: string;
|
|
90
|
+
record?: { type?: string; message?: { role?: string; content?: { text?: string }[] } };
|
|
91
|
+
activity?: string;
|
|
92
|
+
residency?: string;
|
|
93
|
+
driver?: string;
|
|
94
|
+
};
|
|
95
|
+
if (hosted.child_id !== childId) return;
|
|
96
|
+
if (hosted.event === "agent_state") {
|
|
97
|
+
activity = hosted.activity ?? activity;
|
|
98
|
+
status = `${hosted.activity ?? "?"} · ${hosted.residency ?? "?"} · ${hosted.driver ?? "parent"}`;
|
|
99
|
+
requestRender?.();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (hosted.event !== "agent_rpc" || !hosted.record) return;
|
|
103
|
+
const record = hosted.record;
|
|
104
|
+
if (record.type === "message_end" && record.message?.role === "assistant") {
|
|
105
|
+
const text = textOf(record.message);
|
|
106
|
+
if (text) lines.push(text);
|
|
107
|
+
} else if (record.type === "agent_start") {
|
|
108
|
+
activity = "running";
|
|
109
|
+
lines.push("…");
|
|
110
|
+
}
|
|
111
|
+
requestRender?.();
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
await ctx.ui.custom<void>((tui, _theme, _kb, done) => {
|
|
115
|
+
requestRender = () => tui.requestRender();
|
|
116
|
+
return {
|
|
117
|
+
render(width: number) {
|
|
118
|
+
const limit = Math.max(1, width);
|
|
119
|
+
const rows = Math.max(4, (tui.terminal?.rows ?? 24) - 6);
|
|
120
|
+
const body = lines.flatMap((line) => fitLines(line, limit)).slice(-rows);
|
|
121
|
+
return [
|
|
122
|
+
`◆ ${childId} ${status}`,
|
|
123
|
+
...body,
|
|
124
|
+
"",
|
|
125
|
+
`› ${input}`,
|
|
126
|
+
"enter send esc close",
|
|
127
|
+
];
|
|
128
|
+
},
|
|
129
|
+
invalidate() {},
|
|
130
|
+
handleInput(data: string) {
|
|
131
|
+
if (data === "\u001b") {
|
|
132
|
+
done();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (data === "\r" || data === "\n") {
|
|
136
|
+
const text = input;
|
|
137
|
+
input = "";
|
|
138
|
+
if (!text.trim()) return;
|
|
139
|
+
lines.push(`you: ${text}`);
|
|
140
|
+
const op = activity === "running" ? "prompt_steer" : "prompt";
|
|
141
|
+
void client.agentInput(childId, op, text, "user").catch((err: unknown) => {
|
|
142
|
+
lines.push(err instanceof Error ? err.message : String(err));
|
|
143
|
+
requestRender?.();
|
|
144
|
+
});
|
|
145
|
+
tui.requestRender();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (data === "\u007f" || data === "\b") {
|
|
149
|
+
input = input.slice(0, -1);
|
|
150
|
+
tui.requestRender();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (data.length === 1 && data >= " ") {
|
|
154
|
+
input += data;
|
|
155
|
+
tui.requestRender();
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
} finally {
|
|
161
|
+
unsub();
|
|
162
|
+
await client.agentUnwatch(childId).catch(() => {});
|
|
163
|
+
}
|
|
164
|
+
}
|