@oldflag2333333/pi-subagent 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,299 @@
1
+ import * as path from "node:path";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Container, Text } from "@earendil-works/pi-tui";
4
+ import { Type } from "typebox";
5
+ import { resolveProfile } from "../profiles/loader.js";
6
+ import { resolveToolExtensions } from "../profiles/tool-sources.js";
7
+ import { MAX_OPEN_SUBS, type MainRunManager } from "../run-manager.js";
8
+ import { MANUAL_MAIN_GUIDANCE } from "../manual-context.js";
9
+ import { talkView } from "../talk-render.js";
10
+ import type { SubAgentStatus } from "../types.js";
11
+
12
+ function normalizeTitle(value: string): string {
13
+ return value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 48) || "delegated task";
14
+ }
15
+
16
+ function findSubTitle(manager: MainRunManager, runId: unknown): string | undefined {
17
+ return typeof runId === "string" && runId ? manager.titleFor(runId) : undefined;
18
+ }
19
+
20
+ function formatElapsed(seconds: number): string {
21
+ if (seconds < 60) return `${seconds}s`;
22
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
23
+ if (seconds < 86_400) {
24
+ const hours = Math.floor(seconds / 3600);
25
+ const minutes = Math.floor((seconds % 3600) / 60);
26
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
27
+ }
28
+ const days = Math.floor(seconds / 86_400);
29
+ const hours = Math.floor((seconds % 86_400) / 3600);
30
+ return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
31
+ }
32
+
33
+ function compactCapabilityList(values: string[], maxCharacters = 160): string {
34
+ if (values.length === 0) return "(none)";
35
+ const visible: string[] = [];
36
+ let length = 0;
37
+ for (const value of values) {
38
+ const addition = (visible.length > 0 ? 3 : 0) + value.length;
39
+ if (length + addition > maxCharacters) {
40
+ if (visible.length === 0) visible.push(`${value.slice(0, Math.max(1, maxCharacters - 1))}…`);
41
+ break;
42
+ }
43
+ visible.push(value);
44
+ length += addition;
45
+ }
46
+ const omitted = values.length - visible.length;
47
+ return `${visible.join(" · ")}${omitted > 0 ? ` · +${omitted}` : ""}`;
48
+ }
49
+
50
+ export function registerMainTools(pi: ExtensionAPI, manager: MainRunManager): void {
51
+ pi.registerTool({
52
+ name: "delegate",
53
+ label: "delegate",
54
+ exposure: "model-only",
55
+ description: "Create a fresh, context-isolated Sub Pi in Herdr, or resume a closed persistent Sub owned by this Main session. The Sub remains open until the Main calls close_sub or the user closes it manually.",
56
+ promptSnippet: "Create or resume a focused Sub Pi in Herdr with an explicit Pi Subagent profile",
57
+ promptGuidelines: [
58
+ "Use delegate for work assigned by MAIN.md or a separable task that benefits from an independent context; select an explicitly configured profile and provide a short informative title.",
59
+ "After delegate launches, do not wait or repeatedly poll. Pi Subagent will wake the Main when the Sub uses talk.",
60
+ "For Subs labeled user-invoked specialists, follow up only on the user's task and results. Do not assign unrelated work or substitute them for general consulting profiles. New tasks are initiated by the user through a profile command.",
61
+ "Use talk to respond to an existing Sub, interrupt_sub to stop its current turn without closing it, and close_sub only after its delivery is accepted or the user asks to close it.",
62
+ ],
63
+ parameters: Type.Object({
64
+ title: Type.String({ description: "Short Herdr tab title, up to 48 displayed characters." }),
65
+ task: Type.String({ description: "Self-contained initial task. Do not assume the Sub can see the Main conversation." }),
66
+ profile: Type.String({ description: "Configured Pi Subagent profile name. Project profiles override same-named global profiles." }),
67
+ cwd: Type.Optional(Type.String({ description: "Sub working directory; defaults to the Main cwd and is ignored when resuming." })),
68
+ resumeSessionId: Type.Optional(Type.String({ description: "Exact or unique-prefix session ID of a closed persistent Sub owned by this Main; see list_sub." })),
69
+ }),
70
+ async execute(_id, params, signal, onUpdate, ctx) {
71
+ if (manager.runs.size >= MAX_OPEN_SUBS) throw new Error(`Pi Subagent allows at most ${MAX_OPEN_SUBS} open Subs.`);
72
+ const title = normalizeTitle(params.title);
73
+ const cwd = path.resolve(ctx.cwd, params.cwd ?? ".");
74
+ const profile = resolveToolExtensions(
75
+ resolveProfile(params.profile, ctx.cwd, ctx.isProjectTrusted(), { agentOnly: true }),
76
+ pi.getAllTools(),
77
+ );
78
+ const sessionPersistence = profile.sessionPersistence ?? "ephemeral";
79
+ const capabilityDetails = { tools: profile.tools, skills: profile.skills ?? [], sessionPersistence };
80
+ onUpdate?.({
81
+ content: [{ type: "text", text: `Creating Sub with profile ${profile.name}: ${title}` }],
82
+ details: { title, profile: profile.name, ...capabilityDetails },
83
+ });
84
+ const run = await manager.delegate({
85
+ title,
86
+ task: params.task,
87
+ cwd,
88
+ profile,
89
+ ...(params.resumeSessionId ? { resumeSessionId: params.resumeSessionId } : {}),
90
+ }, signal);
91
+ return {
92
+ content: [{ type: "text", text: `${params.resumeSessionId ? "Resumed" : "Created"} Sub: ${run.title} (profile ${run.profileName}, run ${run.runId.slice(0, 8)}${run.subSessionId ? `, session ${run.subSessionId}` : ""}).` }],
93
+ details: {
94
+ runId: run.runId,
95
+ title: run.title,
96
+ profile: run.profileName,
97
+ adapter: run.surface?.adapter,
98
+ ...capabilityDetails,
99
+ },
100
+ terminate: true,
101
+ };
102
+ },
103
+ renderCall(args, theme) {
104
+ const title = normalizeTitle(args.title ?? "delegated task");
105
+ const action = args.resumeSessionId ? "resume" : "delegate";
106
+ return new Text(`${theme.fg("toolTitle", theme.bold(action))} ${theme.fg("muted", `· ${title}`)}`, 0, 0);
107
+ },
108
+ renderResult(result, { isPartial }, theme, context) {
109
+ if (context.isError) {
110
+ const message = result.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
111
+ return new Text(theme.fg("error", message || "delegate failed"), 0, 0);
112
+ }
113
+ const details = result.details as {
114
+ title?: string;
115
+ profile?: string;
116
+ adapter?: string;
117
+ tools?: string[];
118
+ skills?: string[];
119
+ sessionPersistence?: string;
120
+ } | undefined;
121
+ const icon = isPartial ? theme.fg("warning", "◌") : theme.fg("success", "✓");
122
+ const summary = `${icon} ${theme.fg("accent", details?.title ?? "sub")} ${theme.fg("muted", `· ${details?.profile ?? "profile?"} · ${details?.sessionPersistence ?? "ephemeral"}${details?.adapter ? ` · ${details.adapter}` : ""}`)}`;
123
+ const tools = `${theme.fg("dim", " tools ")}${theme.fg("muted", compactCapabilityList(details?.tools ?? []))}`;
124
+ const skills = `${theme.fg("dim", " skills")}${theme.fg("muted", ` ${compactCapabilityList(details?.skills ?? [])}`)}`;
125
+ return new Text(`${summary}\n${tools}\n${skills}`, 0, 0);
126
+ },
127
+ });
128
+
129
+ pi.registerTool({
130
+ name: "talk",
131
+ label: "talk",
132
+ description: "Send one message to an existing Sub Pi. Messages enter the Sub's native follow-up queue and are processed after its current work. Format non-trivial messages as readable Markdown with paragraph breaks and lists.",
133
+ promptSnippet: "Send a message to an existing Sub Pi",
134
+ executionMode: "sequential",
135
+ parameters: Type.Object({
136
+ runId: Type.String({ description: "Full Sub run ID or a unique prefix." }),
137
+ message: Type.String({ description: "Message to the other Agent. For non-trivial content, use readable Markdown with paragraph breaks and lists." }),
138
+ }),
139
+ renderCall(args, theme, context) {
140
+ const message = typeof args.message === "string" ? args.message : "";
141
+ const title = findSubTitle(manager, args.runId);
142
+ const suffix = title ? ` ${theme.fg("muted", `· ${title}`)}` : "";
143
+ const view = talkView(message, context.expanded);
144
+ let text = `${theme.fg("accent", "›")} ${theme.fg("toolTitle", theme.bold("message send"))}${suffix}`;
145
+ if (message) text += `\n\n${view.lines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
146
+ if (view.remaining > 0) {
147
+ text += theme.fg("muted", `\n... (${view.remaining} more lines, ${view.totalLines} total, ctrl+o to expand)`);
148
+ }
149
+ return new Text(text, 0, 0);
150
+ },
151
+ async execute(_id, params) {
152
+ const { run, messageId } = manager.talk(params.runId, params.message);
153
+ return {
154
+ content: [{ type: "text", text: `Message queued for Sub: ${run.title}` }],
155
+ details: { runId: run.runId, messageId },
156
+ };
157
+ },
158
+ renderResult(_result, _options, theme, context) {
159
+ if (context.isError) return new Text(theme.fg("error", "\ntalk failed"), 0, 0);
160
+ return new Container();
161
+ },
162
+ });
163
+
164
+ pi.registerTool({
165
+ name: "interrupt_sub",
166
+ label: "interrupt sub",
167
+ description: "Request cancellation of an open Sub's current turn, including a running tool call, without closing its session or Herdr tab. If the tool ignores cancellation, the Sub may remain busy; use close_sub only if closure is intended.",
168
+ parameters: Type.Object({
169
+ runId: Type.String({ description: "Full Sub run ID or a unique prefix." }),
170
+ }),
171
+ renderCall(args, theme, context) {
172
+ const state = context.state as { title?: string };
173
+ state.title ??= findSubTitle(manager, args.runId);
174
+ return new Text(`${theme.fg("toolTitle", theme.bold("interrupt"))} ${theme.fg("muted", `· ${state.title ?? "sub"}`)}`, 0, 0);
175
+ },
176
+ async execute(_id, params) {
177
+ const run = manager.interrupt(params.runId);
178
+ return { content: [{ type: "text", text: `Requested interruption of Sub: ${run.title}. The Sub session remains open.` }], details: { runId: run.runId } };
179
+ },
180
+ renderResult(_result, _options, theme, context) {
181
+ if (context.isError) return new Text(theme.fg("error", "\ninterrupt failed"), 0, 0);
182
+ return new Container();
183
+ },
184
+ });
185
+
186
+ pi.registerTool({
187
+ name: "close_sub",
188
+ label: "close sub",
189
+ description: "Close a Sub Pi session and its Herdr tab. Use after the delivery is accepted or when the user asks to close it.",
190
+ parameters: Type.Object({
191
+ runId: Type.String({ description: "Full Sub run ID or a unique prefix." }),
192
+ reason: Type.Optional(Type.String({ description: "Reason for closing the Sub session." })),
193
+ }),
194
+ renderCall(args, theme, context) {
195
+ const state = context.state as { title?: string };
196
+ state.title ??= findSubTitle(manager, args.runId);
197
+ return new Text(`${theme.fg("toolTitle", theme.bold("close"))} ${theme.fg("muted", `· ${state.title ?? "sub"}`)}`, 0, 0);
198
+ },
199
+ async execute(_id, params) {
200
+ const run = await manager.close(params.runId, params.reason ?? "The Main closed the Sub session.");
201
+ return { content: [{ type: "text", text: `Closed Sub: ${run.title}` }], details: { runId: run.runId } };
202
+ },
203
+ renderResult(_result, _options, theme, context) {
204
+ if (context.isError) return new Text(theme.fg("error", "\nclose failed"), 0, 0);
205
+ return new Container();
206
+ },
207
+ });
208
+
209
+ pi.registerTool({
210
+ name: "list_sub",
211
+ label: "Sub-agents",
212
+ description: "List only this Main session's Sub-agents: all open Subs, regardless of status, and confirmed-closed persistent sessions that can be resumed. Shows live Herdr status; never returns transcripts or other Mains' sessions.",
213
+ parameters: Type.Object({}),
214
+ async execute() {
215
+ const now = Date.now();
216
+ const listed = await manager.subs();
217
+ const open = listed.open.map(({ run, status }) => ({
218
+ runId: run.runId,
219
+ subSessionId: run.subSessionId,
220
+ title: run.title,
221
+ profile: run.profileName,
222
+ sessionPersistence: run.sessionPersistence,
223
+ origin: run.origin,
224
+ purpose: run.purpose,
225
+ status,
226
+ adapter: run.surface?.adapter,
227
+ elapsedSeconds: Math.max(0, Math.round((now - run.createdAt) / 1000)),
228
+ }));
229
+ const resumable = listed.resumable.map((session) => ({
230
+ ...session,
231
+ modifiedSecondsAgo: Math.max(0, Math.round((now - session.modifiedAt) / 1000)),
232
+ }));
233
+ const lines = [
234
+ ...(open.length ? ["Open"] : []),
235
+ ...open.map((run) => `- ${run.status} ${run.runId.slice(0, 8)} ${run.title} <${run.profile}, ${run.sessionPersistence}${run.origin === "manual" ? ", user-invoked specialist" : ""}>${run.purpose ? ` Purpose: ${run.purpose}` : ""}`),
236
+ ...(resumable.length ? ["Resumable"] : []),
237
+ ...resumable.map((session) => `- closed ${session.sessionId} ${session.title} <persistent, ${session.origin === "manual" ? "user-invoked; resume via user command" : "resumable"}> cwd=${session.cwd}${session.purpose ? ` Purpose: ${session.purpose}` : ""}`),
238
+ ];
239
+ if (open.some((run) => run.origin === "manual") || resumable.some((session) => session.origin === "manual")) lines.push(MANUAL_MAIN_GUIDANCE);
240
+ return { content: [{ type: "text", text: lines.join("\n") || "No Sub-agents in this Main session." }], details: { open, resumable } };
241
+ },
242
+ renderCall(_args, theme) {
243
+ return new Text(theme.fg("toolTitle", theme.bold("Sub-agents")), 0, 0);
244
+ },
245
+ renderResult(result, _options, theme) {
246
+ const details = result.details as {
247
+ open?: Array<{
248
+ runId: string;
249
+ subSessionId?: string;
250
+ title: string;
251
+ profile: string;
252
+ sessionPersistence: string;
253
+ origin?: "manual";
254
+ status: SubAgentStatus;
255
+ adapter?: string;
256
+ elapsedSeconds: number;
257
+ }>;
258
+ resumable?: Array<{
259
+ sessionId: string;
260
+ title: string;
261
+ cwd: string;
262
+ modifiedSecondsAgo: number;
263
+ origin?: "manual";
264
+ }>;
265
+ } | undefined;
266
+ const open = details?.open ?? [];
267
+ const resumable = details?.resumable ?? [];
268
+ if (open.length === 0 && resumable.length === 0) return new Text(theme.fg("muted", "No Sub-agents in this Main session"), 0, 0);
269
+ const lines = [
270
+ ...(open.length ? [theme.fg("toolTitle", theme.bold("Open"))] : []),
271
+ ...open.map((run) => {
272
+ const metadata = [
273
+ run.runId.slice(0, 8),
274
+ run.subSessionId?.slice(0, 8),
275
+ run.profile,
276
+ run.origin === "manual" ? "user-invoked specialist" : undefined,
277
+ run.sessionPersistence,
278
+ run.status,
279
+ run.adapter,
280
+ formatElapsed(run.elapsedSeconds),
281
+ ].filter(Boolean).join(" · ");
282
+ return `${theme.fg("accent", "•")} ${theme.fg("accent", run.title)}\n ${theme.fg("muted", metadata)}`;
283
+ }),
284
+ ...(resumable.length ? [theme.fg("toolTitle", theme.bold("Resumable"))] : []),
285
+ ...resumable.map((session) => {
286
+ const metadata = [
287
+ session.sessionId.slice(0, 8),
288
+ "persistent",
289
+ session.origin === "manual" ? "closed · user-invoked" : "closed · resumable",
290
+ path.basename(session.cwd),
291
+ formatElapsed(session.modifiedSecondsAgo),
292
+ ].join(" · ");
293
+ return `${theme.fg("dim", "○")} ${theme.fg("accent", session.title)}\n ${theme.fg("muted", metadata)}`;
294
+ }),
295
+ ];
296
+ return new Text(lines.join("\n\n"), 0, 0);
297
+ },
298
+ });
299
+ }
@@ -0,0 +1,215 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Container, Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+ import {
5
+ listTalkToSub,
6
+ MESSAGE_TYPE,
7
+ clearActiveTurn,
8
+ clearInterrupt,
9
+ readActiveTurn,
10
+ readInterrupt,
11
+ readClose,
12
+ readManifest,
13
+ talkToMain,
14
+ writeSubClosed,
15
+ writeSubSessionInfo,
16
+ writeActiveTurn,
17
+ } from "../channel.js";
18
+ import { applySubPromptSections, bindPromptSections } from "../profiles/system-prompt.js";
19
+ import { SUB_CONTROL_TOOLS } from "../profiles/launch-args.js";
20
+ import { applyProfileTools } from "../profiles/tool-selection.js";
21
+ import { MANUAL_SUB_GUIDANCE, subSessionName } from "../manual-context.js";
22
+ import { talkView } from "../talk-render.js";
23
+ import { ChannelMonitor } from "../channel-monitor.js";
24
+ import { bindInboxEvents } from "../inbox-state.js";
25
+ import { deliverTalk, ProtocolErrors } from "../talk-delivery.js";
26
+ import type { ActiveTurn, DelegateManifest } from "../types.js";
27
+
28
+ function contentText(content: unknown): string {
29
+ if (typeof content === "string") return content;
30
+ if (!Array.isArray(content)) return "";
31
+ return content
32
+ .map((part) => part && typeof part === "object" && "text" in part ? String(part.text) : "")
33
+ .filter(Boolean)
34
+ .join("\n");
35
+ }
36
+
37
+ function loadManifest(): { channelDir: string; manifest: DelegateManifest } {
38
+ const channelDir = process.env.PI_SUBAGENT_CHANNEL;
39
+ const envToken = process.env.PI_SUBAGENT_TOKEN;
40
+ if (!channelDir) throw new Error("PI_SUBAGENT_CHANNEL is missing in Sub Pi.");
41
+ const manifest = readManifest(channelDir);
42
+ if (!envToken || envToken !== manifest.token) throw new Error("Sub channel capability token does not match.");
43
+ return { channelDir, manifest };
44
+ }
45
+
46
+ export function registerSub(pi: ExtensionAPI): void {
47
+ const loaded = loadManifest();
48
+ const selectedTools = [...new Set([...loaded.manifest.profile.tools, ...SUB_CONTROL_TOOLS])];
49
+ const protocolErrors = new ProtocolErrors();
50
+ let closing = false;
51
+ let activeTurn: ActiveTurn | undefined;
52
+ let inputSeen = false;
53
+ let activeContext: ExtensionContext | undefined;
54
+ let ready = false;
55
+ const monitor = new ChannelMonitor(
56
+ () => scan(),
57
+ (_id, error) => {
58
+ if (activeContext) protocolErrors.report(activeContext, `watch:${loaded.manifest.runId}`, new Error(`File watching unavailable; the 5-second scan remains active. ${error instanceof Error ? error.message : String(error)}`));
59
+ },
60
+ () => protocolErrors.clear(`watch:${loaded.manifest.runId}`),
61
+ );
62
+ bindInboxEvents(pi, () => activeContext, () => monitor.wakeAll());
63
+
64
+ // Pi emits resource discovery after all session_start handlers, including
65
+ // extensions that register the profile's tools dynamically during startup.
66
+ pi.on("resources_discover", (_event, ctx) => {
67
+ try {
68
+ applyProfileTools(pi, loaded.manifest.profile.name, selectedTools);
69
+ ready = true;
70
+ monitor.wakeAll();
71
+ } catch (error) {
72
+ const reason = `Unable to initialize Sub profile: ${error instanceof Error ? error.message : String(error)}`;
73
+ talkToMain(loaded.channelDir, loaded.manifest, reason);
74
+ ctx.shutdown();
75
+ }
76
+ });
77
+
78
+ pi.on("session_start", (_event, ctx) => {
79
+ activeContext = ctx;
80
+ pi.setSessionName(subSessionName(loaded.manifest.title, loaded.manifest.origin));
81
+ ctx.ui.setTitle(`[sub] ${loaded.manifest.title}`);
82
+ ctx.ui.setStatus("subagent", `sub · ${loaded.manifest.profile.name}`);
83
+ const sessionFile = ctx.sessionManager.getSessionFile();
84
+ if (loaded.manifest.profile.sessionPersistence === "persistent" && sessionFile) {
85
+ writeSubSessionInfo(loaded.channelDir, loaded.manifest, {
86
+ sessionId: ctx.sessionManager.getSessionId(),
87
+ sessionFile,
88
+ });
89
+ }
90
+ const previousTurn = readActiveTurn(loaded.channelDir, loaded.manifest);
91
+ if (ctx.isIdle() && previousTurn) clearActiveTurn(loaded.channelDir, previousTurn);
92
+ activeTurn = ctx.isIdle() ? undefined : previousTurn;
93
+ monitor.add(loaded.manifest.runId, loaded.channelDir, "to-sub");
94
+ monitor.start();
95
+ scan();
96
+ });
97
+
98
+ function scan(): void {
99
+ const ctx = activeContext;
100
+ if (!ctx || closing) return;
101
+ try {
102
+ if (readClose(loaded.channelDir, loaded.manifest)) {
103
+ closing = true;
104
+ monitor.stop();
105
+ ctx.abort();
106
+ ctx.shutdown();
107
+ return;
108
+ }
109
+ const interrupt = readInterrupt(loaded.channelDir, loaded.manifest);
110
+ if (interrupt) {
111
+ clearInterrupt(loaded.channelDir);
112
+ if (!ctx.isIdle() && interrupt.turnId === activeTurn?.turnId) ctx.abort();
113
+ }
114
+ if (!ready) return;
115
+ for (const message of listTalkToSub(loaded.channelDir, loaded.manifest)) {
116
+ deliverTalk(pi, ctx, loaded.channelDir, loaded.manifest, "to-sub", message,
117
+ `[Pi Subagent Main message]\nMain says:\n${message.message}`);
118
+ }
119
+ protocolErrors.clear(loaded.manifest.runId);
120
+ } catch (error) {
121
+ protocolErrors.report(ctx, loaded.manifest.runId, error);
122
+ }
123
+ }
124
+
125
+ pi.on("agent_start", () => {
126
+ if (closing) return;
127
+ inputSeen = false;
128
+ activeTurn = writeActiveTurn(loaded.channelDir, loaded.manifest);
129
+ });
130
+
131
+ pi.on("message_start", (event) => {
132
+ const message = event.message;
133
+ const mainInput = message.role === "custom" && message.customType === MESSAGE_TYPE
134
+ && (message.details as { direction?: string } | undefined)?.direction === "to-sub";
135
+ // Native follow-ups can start new work inside one agent run. A late
136
+ // interrupt for the previous input must not abort that subsequent work.
137
+ if (!closing && (message.role === "user" || mainInput)) {
138
+ if (inputSeen) activeTurn = writeActiveTurn(loaded.channelDir, loaded.manifest);
139
+ inputSeen = true;
140
+ }
141
+ });
142
+
143
+ pi.on("agent_settled", (_event, ctx) => {
144
+ if (ctx.isIdle() && activeTurn) {
145
+ clearActiveTurn(loaded.channelDir, activeTurn);
146
+ activeTurn = undefined;
147
+ }
148
+ });
149
+
150
+ bindPromptSections(pi, ["subagent_sub_protocol", "subagent_profile"], () => {
151
+ const sections: Record<string, string> = {};
152
+ applySubPromptSections(sections, loaded.manifest.profile);
153
+ if (loaded.manifest.origin === "manual") sections.subagent_sub_protocol += `\n\n${MANUAL_SUB_GUIDANCE}`;
154
+ return sections;
155
+ });
156
+
157
+ // Preserve rendering for legacy custom messages in resumed sessions.
158
+ pi.registerMessageRenderer(MESSAGE_TYPE, (message, options, theme) => {
159
+ const details = message.details as { title?: string; message?: string } | undefined;
160
+ const title = details?.title ?? loaded.manifest.title;
161
+ const mainMessage = details?.message ?? "";
162
+ const view = talkView(mainMessage, options.expanded);
163
+ let text = `${theme.fg("accent", "›")} ${theme.fg("toolTitle", theme.bold("message inbox"))} ${theme.fg("muted", `· ${title}`)}`;
164
+ if (mainMessage) text += `\n\n${view.lines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
165
+ if (view.remaining > 0) {
166
+ text += theme.fg("muted", `\n... (${view.remaining} more lines, ${view.totalLines} total, ctrl+o to expand)`);
167
+ }
168
+ const box = new Box(1, 1, (line) => theme.bg("toolSuccessBg", line));
169
+ box.addChild(new Text(text, 0, 0));
170
+ return box;
171
+ });
172
+
173
+ pi.registerTool({
174
+ name: "talk",
175
+ label: "talk",
176
+ exposure: "model-only",
177
+ description: "Send one message to the Main Pi and end the current turn. Use it to ask for information or deliver work. Format non-trivial messages as readable Markdown with paragraph breaks and lists.",
178
+ promptSnippet: "Send a message to the Main Pi",
179
+ promptGuidelines: ["Use talk whenever the Sub needs to communicate with the Main; the Main decides when to close the Sub session."],
180
+ executionMode: "sequential",
181
+ parameters: Type.Object({
182
+ message: Type.String({ description: "Message to the other Agent. For non-trivial content, use readable Markdown with paragraph breaks and lists." }),
183
+ }),
184
+ renderCall(args, theme, context) {
185
+ const message = typeof args.message === "string" ? args.message : "";
186
+ const view = talkView(message, context.expanded);
187
+ let text = `${theme.fg("accent", "›")} ${theme.fg("toolTitle", theme.bold("message send"))} ${theme.fg("muted", `· ${loaded.manifest.title}`)}`;
188
+ if (message) text += `\n\n${view.lines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
189
+ if (view.remaining > 0) {
190
+ text += theme.fg("muted", `\n... (${view.remaining} more lines, ${view.totalLines} total, ctrl+o to expand)`);
191
+ }
192
+ return new Text(text, 0, 0);
193
+ },
194
+ async execute(_id, params) {
195
+ const message = talkToMain(loaded.channelDir, loaded.manifest, params.message);
196
+ return {
197
+ content: [{ type: "text", text: "Message queued for the Main Pi." }],
198
+ details: { messageId: message.id },
199
+ terminate: true,
200
+ };
201
+ },
202
+ renderResult(result, _options, theme, context) {
203
+ if (context.isError) return new Text(`\n${theme.fg("error", contentText(result.content) || "talk failed")}`, 0, 0);
204
+ return new Container();
205
+ },
206
+ });
207
+
208
+ pi.on("session_shutdown", (event) => {
209
+ monitor.stop();
210
+ activeContext = undefined;
211
+ if (event.reason === "quit" && !closing && !readClose(loaded.channelDir, loaded.manifest)) {
212
+ writeSubClosed(loaded.channelDir, loaded.manifest, "The Sub session was closed manually.");
213
+ }
214
+ });
215
+ }
package/src/types.ts ADDED
@@ -0,0 +1,111 @@
1
+ import type { ResolvedProfile, SessionPersistence } from "./profiles/types.js";
2
+
3
+ export interface DelegateManifest {
4
+ version: 1;
5
+ runId: string;
6
+ mainSessionId: string;
7
+ title: string;
8
+ task: string;
9
+ cwd: string;
10
+ profile: ResolvedProfile;
11
+ origin?: "manual";
12
+ token: string;
13
+ createdAt: number;
14
+ }
15
+
16
+ export interface TalkMessage {
17
+ version: 1;
18
+ id: string;
19
+ sequence: number;
20
+ runId: string;
21
+ token: string;
22
+ createdAt: number;
23
+ message: string;
24
+ }
25
+
26
+ export interface SubSessionInfo {
27
+ version: 1;
28
+ runId: string;
29
+ token: string;
30
+ sessionId: string;
31
+ sessionFile: string;
32
+ createdAt: number;
33
+ }
34
+
35
+ export interface ActiveTurn {
36
+ version: 1;
37
+ runId: string;
38
+ token: string;
39
+ turnId: string;
40
+ createdAt: number;
41
+ }
42
+
43
+ export interface InterruptRequest extends ActiveTurn {
44
+ requestedAt: number;
45
+ }
46
+
47
+ export interface CloseMessage {
48
+ version: 1;
49
+ runId: string;
50
+ token: string;
51
+ createdAt: number;
52
+ reason: string;
53
+ }
54
+
55
+ export interface SubClosedMessage {
56
+ version: 1;
57
+ runId: string;
58
+ token: string;
59
+ createdAt: number;
60
+ reason: string;
61
+ }
62
+
63
+ export type SubAgentStatus = "working" | "blocked" | "idle" | "unknown";
64
+
65
+ export interface SurfaceHandle {
66
+ adapter: "herdr";
67
+ tabId?: string;
68
+ paneId?: string;
69
+ }
70
+
71
+ export interface RunSnapshot {
72
+ version: 1;
73
+ runId: string;
74
+ mainSessionId: string;
75
+ title: string;
76
+ cwd: string;
77
+ profileName: string;
78
+ origin?: "manual";
79
+ purpose?: string;
80
+ sessionPersistence: SessionPersistence;
81
+ channelDir: string;
82
+ createdAt: number;
83
+ updatedAt: number;
84
+ /** Surface is closed; retain the channel only until pending messages are received. */
85
+ closedAt?: number;
86
+ subSessionId?: string;
87
+ subSessionFile?: string;
88
+ surface?: SurfaceHandle;
89
+ }
90
+
91
+ export interface SubLaunchSpec {
92
+ runId: string;
93
+ mainSessionId: string;
94
+ title: string;
95
+ task: string;
96
+ cwd: string;
97
+ projectTrusted: boolean;
98
+ origin?: "manual";
99
+ resumeSessionId?: string;
100
+ profile: ResolvedProfile;
101
+ channelDir: string;
102
+ token: string;
103
+ entryPath: string;
104
+ }
105
+
106
+ export interface SubSurfaceAdapter {
107
+ readonly id: "herdr";
108
+ available(): Promise<boolean>;
109
+ launch(spec: SubLaunchSpec, signal?: AbortSignal): Promise<SurfaceHandle>;
110
+ close(handle: SurfaceHandle): Promise<void>;
111
+ }