@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.
- package/LICENSE +21 -0
- package/README.md +171 -2
- package/README.zh-CN.md +172 -0
- package/docs/releasing.md +61 -0
- package/herdr-plugin/README.md +34 -0
- package/herdr-plugin/action.ts +19 -0
- package/herdr-plugin/herdr-plugin.toml +27 -0
- package/herdr-plugin/index.mjs +108 -0
- package/package.json +65 -4
- package/src/adapters/herdr.ts +155 -0
- package/src/adapters/index.ts +31 -0
- package/src/channel-monitor.ts +127 -0
- package/src/channel.ts +286 -0
- package/src/commands/clean-sub.ts +82 -0
- package/src/commands/sub.ts +39 -0
- package/src/inbox-state.ts +108 -0
- package/src/index.ts +45 -0
- package/src/main-context.ts +91 -0
- package/src/manual-context.ts +9 -0
- package/src/profiles/context.ts +27 -0
- package/src/profiles/launch-args.ts +21 -0
- package/src/profiles/loader.ts +325 -0
- package/src/profiles/mcp.ts +16 -0
- package/src/profiles/runtime.ts +102 -0
- package/src/profiles/system-prompt.ts +33 -0
- package/src/profiles/tool-selection.ts +21 -0
- package/src/profiles/tool-sources.ts +49 -0
- package/src/profiles/types.ts +37 -0
- package/src/run-manager.ts +442 -0
- package/src/sessions.ts +50 -0
- package/src/talk-delivery.ts +73 -0
- package/src/talk-message.ts +32 -0
- package/src/talk-render.ts +18 -0
- package/src/tools/main.ts +299 -0
- package/src/tools/sub.ts +215 -0
- package/src/types.ts +111 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { channelPath, runtimeRoot } from "../channel.js";
|
|
5
|
+
|
|
6
|
+
function entries(directory: string): fs.Dirent[] {
|
|
7
|
+
try { return fs.readdirSync(directory, { withFileTypes: true }); }
|
|
8
|
+
catch (error) {
|
|
9
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
10
|
+
throw error;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Read only the header; never load transcripts or treat an unreadable session as deleted. */
|
|
15
|
+
function sessionId(file: string): string {
|
|
16
|
+
const fd = fs.openSync(file, "r");
|
|
17
|
+
try {
|
|
18
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
19
|
+
const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
20
|
+
const text = buffer.subarray(0, length).toString("utf8");
|
|
21
|
+
const header = JSON.parse(text.split("\n", 1)[0]!);
|
|
22
|
+
if (header?.type !== "session" || typeof header.id !== "string" || !header.id) throw new Error("Invalid session header");
|
|
23
|
+
return header.id;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
throw new Error(`Cannot inspect session ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
26
|
+
} finally { fs.closeSync(fd); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function cleanSubChannels(currentMainId: string, currentSessionDir: string): { removed: number; errors: string[] } {
|
|
30
|
+
const root = runtimeRoot();
|
|
31
|
+
const candidates = entries(root).filter((entry) => entry.isDirectory());
|
|
32
|
+
if (candidates.length === 0) return { removed: 0, errors: [] };
|
|
33
|
+
if (fs.lstatSync(root).isSymbolicLink()) throw new Error("Refusing to clean a symlinked runtime directory.");
|
|
34
|
+
|
|
35
|
+
const sessionsRoot = path.join(getAgentDir(), "sessions");
|
|
36
|
+
const directories = new Set([sessionsRoot]);
|
|
37
|
+
if (currentSessionDir) directories.add(currentSessionDir);
|
|
38
|
+
for (const entry of entries(sessionsRoot)) {
|
|
39
|
+
const file = path.join(sessionsRoot, entry.name);
|
|
40
|
+
if (entry.isDirectory() || (entry.isSymbolicLink() && fs.statSync(file).isDirectory())) directories.add(file);
|
|
41
|
+
}
|
|
42
|
+
const live = new Set([currentMainId]);
|
|
43
|
+
// Complete discovery before deleting anything. A read/parse error aborts cleanup.
|
|
44
|
+
for (const directory of directories) {
|
|
45
|
+
for (const entry of entries(directory)) {
|
|
46
|
+
if (entry.name.endsWith(".jsonl")) live.add(sessionId(path.join(directory, entry.name)));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const liveDirectories = new Set([...live].map((id) => path.basename(path.dirname(channelPath(id, "unused")))));
|
|
50
|
+
let removed = 0;
|
|
51
|
+
const errors: string[] = [];
|
|
52
|
+
for (const entry of candidates) {
|
|
53
|
+
if (liveDirectories.has(entry.name)) continue;
|
|
54
|
+
const directory = path.join(root, entry.name);
|
|
55
|
+
try {
|
|
56
|
+
// Do not follow a directory replaced with a symlink during discovery.
|
|
57
|
+
if (!fs.lstatSync(directory).isDirectory()) continue;
|
|
58
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
59
|
+
removed++;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
errors.push(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { removed, errors };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function registerCleanSubCommand(pi: ExtensionAPI): void {
|
|
68
|
+
pi.registerCommand("clean-sub", {
|
|
69
|
+
description: "Delete runtime communication files whose Main session can no longer be found; keep persistent Sub sessions.",
|
|
70
|
+
handler: async (_args, ctx) => {
|
|
71
|
+
try {
|
|
72
|
+
const result = cleanSubChannels(ctx.sessionManager.getSessionId(), ctx.sessionManager.getSessionDir());
|
|
73
|
+
ctx.ui.notify([
|
|
74
|
+
`Removed ${result.removed} orphaned Main communication directories. Persistent Sub sessions were not deleted.`,
|
|
75
|
+
...result.errors,
|
|
76
|
+
].join("\n"), result.errors.length ? "warning" : "info");
|
|
77
|
+
} catch (error) {
|
|
78
|
+
ctx.ui.notify(`Cleanup aborted: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadProfiles, resolveProfile } from "../profiles/loader.js";
|
|
3
|
+
import { resolveToolExtensions } from "../profiles/tool-sources.js";
|
|
4
|
+
import type { MainRunManager } from "../run-manager.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TASK = "The user invoked this profile for the current workspace. Perform the task described by your profile; ask Main for any required details.";
|
|
7
|
+
|
|
8
|
+
export function registerSubCommands(pi: ExtensionAPI, manager: MainRunManager): void {
|
|
9
|
+
const owned = new Set<string>();
|
|
10
|
+
const invoke = async (name: string, task: string, ctx: ExtensionCommandContext) => {
|
|
11
|
+
try {
|
|
12
|
+
const profile = resolveToolExtensions(resolveProfile(name, ctx.cwd, ctx.isProjectTrusted()), pi.getAllTools());
|
|
13
|
+
const result = await manager.invokeProfile({ profile, task: task.trim() || DEFAULT_TASK, cwd: ctx.cwd });
|
|
14
|
+
ctx.ui.notify(`${result.action}: ${name} · ${result.run.runId.slice(0, 8)}${result.run.subSessionId ? ` · session ${result.run.subSessionId}` : ""}`, "info");
|
|
15
|
+
} catch (error) {
|
|
16
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Trust and other extensions' startup registrations are resolved at this point.
|
|
21
|
+
// Only register the namespaced command; Pi's native fuzzy completion matches
|
|
22
|
+
// /review to /sub:review without an alias or a separate routing hook.
|
|
23
|
+
pi.on("resources_discover", (_event, ctx) => {
|
|
24
|
+
const catalog = loadProfiles(ctx.cwd, ctx.isProjectTrusted());
|
|
25
|
+
const occupied = new Set(pi.getCommands().map((command) => command.name));
|
|
26
|
+
for (const name of [...catalog.profiles.keys()].sort()) {
|
|
27
|
+
const command = `sub:${name}`;
|
|
28
|
+
if (occupied.has(command) && !owned.has(command)) {
|
|
29
|
+
ctx.ui.notify(`Cannot register /${command}: another command already uses that name.`, "warning");
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
pi.registerCommand(command, {
|
|
33
|
+
description: `Manually invoke profile ${name}; reuse its Sub in this Main session`,
|
|
34
|
+
handler: (task, commandCtx) => invoke(name, task, commandCtx),
|
|
35
|
+
});
|
|
36
|
+
owned.add(command);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { MESSAGE_TYPE, type TalkDirection } from "./channel.js";
|
|
3
|
+
import type { DelegateManifest, TalkMessage } from "./types.js";
|
|
4
|
+
import { readTalkReceipt } from "./talk-message.js";
|
|
5
|
+
|
|
6
|
+
type ReadonlySessionManager = ExtensionContext["sessionManager"];
|
|
7
|
+
interface PendingTalk {
|
|
8
|
+
queued: boolean;
|
|
9
|
+
queuedWhileBusy: boolean;
|
|
10
|
+
runSerial: number;
|
|
11
|
+
}
|
|
12
|
+
interface DeliveryState {
|
|
13
|
+
pending: Map<string, PendingTalk>;
|
|
14
|
+
runSerial: number;
|
|
15
|
+
aborted: boolean;
|
|
16
|
+
active: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Pi can retain native message queues across /reload. Retain only their
|
|
20
|
+
// delivery state across extension replacement, keyed weakly by the session.
|
|
21
|
+
const STATE_KEY = Symbol.for("pi-subagent.inbox-state.v1");
|
|
22
|
+
const globals = globalThis as unknown as Record<symbol, unknown>;
|
|
23
|
+
const states = (globals[STATE_KEY] ??= new WeakMap<ReadonlySessionManager, DeliveryState>()) as WeakMap<ReadonlySessionManager, DeliveryState>;
|
|
24
|
+
function state(ctx: ExtensionContext): DeliveryState {
|
|
25
|
+
let value = states.get(ctx.sessionManager);
|
|
26
|
+
if (!value) {
|
|
27
|
+
value = { pending: new Map(), runSerial: 0, aborted: false, active: false };
|
|
28
|
+
states.set(ctx.sessionManager, value);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function key(direction: TalkDirection, runId: string, messageId: string): string {
|
|
33
|
+
return `${direction}:${runId}:${messageId}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function rememberTalk(ctx: ExtensionContext, direction: TalkDirection, manifest: DelegateManifest, message: TalkMessage): PendingTalk {
|
|
37
|
+
const value = state(ctx);
|
|
38
|
+
const id = key(direction, manifest.runId, message.id);
|
|
39
|
+
let pending = value.pending.get(id);
|
|
40
|
+
if (!pending) {
|
|
41
|
+
pending = { queued: false, queuedWhileBusy: false, runSerial: value.runSerial };
|
|
42
|
+
value.pending.set(id, pending);
|
|
43
|
+
}
|
|
44
|
+
return pending;
|
|
45
|
+
}
|
|
46
|
+
export function forgetTalk(ctx: ExtensionContext, direction: TalkDirection, runId: string, messageId: string): void {
|
|
47
|
+
state(ctx).pending.delete(key(direction, runId, messageId));
|
|
48
|
+
}
|
|
49
|
+
/** Queue behind streaming work, but never compete with manual compaction or prompt preflight. */
|
|
50
|
+
export function canUseNativeQueue(ctx: ExtensionContext): boolean {
|
|
51
|
+
const value = state(ctx);
|
|
52
|
+
// sendUserMessage awaits input/auth hooks before Pi becomes busy. Submit only
|
|
53
|
+
// one idle prompt at a time; its receipt or agent_start will wake the rest.
|
|
54
|
+
if (ctx.isIdle()) return ![...value.pending.values()].some((pending) => pending.queued && !pending.queuedWhileBusy);
|
|
55
|
+
return value.active || ctx.signal !== undefined;
|
|
56
|
+
}
|
|
57
|
+
export function markTalkQueued(ctx: ExtensionContext, pending: PendingTalk): void {
|
|
58
|
+
pending.queued = true;
|
|
59
|
+
pending.queuedWhileBusy = !ctx.isIdle();
|
|
60
|
+
pending.runSerial = state(ctx).runSerial;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function bindInboxEvents(pi: ExtensionAPI, getContext: () => ExtensionContext | undefined, wake: () => void): void {
|
|
64
|
+
const wakePending = () => {
|
|
65
|
+
const ctx = getContext();
|
|
66
|
+
if (ctx && state(ctx).pending.size > 0) wake();
|
|
67
|
+
};
|
|
68
|
+
pi.on("agent_start", () => {
|
|
69
|
+
const ctx = getContext();
|
|
70
|
+
if (!ctx) return;
|
|
71
|
+
const value = state(ctx);
|
|
72
|
+
value.runSerial++;
|
|
73
|
+
value.aborted = ctx.signal?.aborted === true;
|
|
74
|
+
value.active = true;
|
|
75
|
+
const runSerial = value.runSerial;
|
|
76
|
+
ctx.signal?.addEventListener("abort", () => { if (value.runSerial === runSerial) value.aborted = true; }, { once: true });
|
|
77
|
+
wakePending();
|
|
78
|
+
});
|
|
79
|
+
pi.on("agent_end", (event) => {
|
|
80
|
+
const ctx = getContext();
|
|
81
|
+
if (!ctx) return;
|
|
82
|
+
const last = [...event.messages].reverse().find((message) => message.role === "assistant");
|
|
83
|
+
state(ctx).aborted ||= ctx.signal?.aborted === true || (last?.role === "assistant" && last.stopReason === "aborted");
|
|
84
|
+
});
|
|
85
|
+
pi.on("message_end", (event) => {
|
|
86
|
+
if (readTalkReceipt(event.message)
|
|
87
|
+
|| (event.message.role === "custom" && event.message.customType === MESSAGE_TYPE)) wake();
|
|
88
|
+
});
|
|
89
|
+
pi.on("turn_start", wakePending);
|
|
90
|
+
pi.on("model_select", wakePending);
|
|
91
|
+
pi.on("session_compact", wakePending);
|
|
92
|
+
pi.on("session_compact_failed", wakePending);
|
|
93
|
+
pi.on("agent_settled", () => {
|
|
94
|
+
const ctx = getContext();
|
|
95
|
+
if (!ctx) return;
|
|
96
|
+
const value = state(ctx);
|
|
97
|
+
value.active = false;
|
|
98
|
+
// A normal settle has drained native follow-ups. Missing receipts then
|
|
99
|
+
// indicate withdrawn messages. Abort alone can leave the queue intact:
|
|
100
|
+
// keep in-flight state until the next normal run or a fresh session.
|
|
101
|
+
if (!value.aborted) {
|
|
102
|
+
for (const pending of value.pending.values()) {
|
|
103
|
+
if (pending.queuedWhileBusy && pending.runSerial <= value.runSerial) pending.queued = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
wakePending();
|
|
107
|
+
});
|
|
108
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { MESSAGE_TYPE } from "./channel.js";
|
|
4
|
+
import { MainContextRuntime } from "./main-context.js";
|
|
5
|
+
import { StartupProfileRuntime } from "./profiles/runtime.js";
|
|
6
|
+
import { MainRunManager } from "./run-manager.js";
|
|
7
|
+
import { registerSub } from "./tools/sub.js";
|
|
8
|
+
import { talkView } from "./talk-render.js";
|
|
9
|
+
import { registerSubCommands } from "./commands/sub.js";
|
|
10
|
+
import { registerCleanSubCommand } from "./commands/clean-sub.js";
|
|
11
|
+
import { registerMainTools } from "./tools/main.js";
|
|
12
|
+
|
|
13
|
+
export default function piSubagent(pi: ExtensionAPI): void {
|
|
14
|
+
if (process.env.PI_SUBAGENT_ROLE === "sub") {
|
|
15
|
+
registerSub(pi);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const manager = new MainRunManager(pi);
|
|
20
|
+
const startupProfile = new StartupProfileRuntime(pi);
|
|
21
|
+
registerMainTools(pi, manager);
|
|
22
|
+
registerSubCommands(pi, manager);
|
|
23
|
+
registerCleanSubCommand(pi);
|
|
24
|
+
startupProfile.register();
|
|
25
|
+
new MainContextRuntime(pi).register();
|
|
26
|
+
|
|
27
|
+
// Preserve rendering for legacy custom messages in resumed sessions.
|
|
28
|
+
pi.registerMessageRenderer(MESSAGE_TYPE, (message, options, theme) => {
|
|
29
|
+
const details = message.details as { title?: string; message?: string } | undefined;
|
|
30
|
+
const title = details?.title ?? "Sub";
|
|
31
|
+
const subMessage = details?.message ?? "";
|
|
32
|
+
const view = talkView(subMessage, options.expanded);
|
|
33
|
+
let text = `${theme.fg("accent", "›")} ${theme.fg("toolTitle", theme.bold("message inbox"))} ${theme.fg("muted", `· ${title}`)}`;
|
|
34
|
+
if (subMessage) text += `\n\n${view.lines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
|
|
35
|
+
if (view.remaining > 0) {
|
|
36
|
+
text += theme.fg("muted", `\n... (${view.remaining} more lines, ${view.totalLines} total, ctrl+o to expand)`);
|
|
37
|
+
}
|
|
38
|
+
const box = new Box(1, 1, (line) => theme.bg("toolSuccessBg", line));
|
|
39
|
+
box.addChild(new Text(text, 0, 0));
|
|
40
|
+
return box;
|
|
41
|
+
});
|
|
42
|
+
pi.on("session_start", (_event, ctx) => manager.start(ctx));
|
|
43
|
+
|
|
44
|
+
pi.on("session_shutdown", () => manager.shutdown());
|
|
45
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { bindPromptSections } from "./profiles/system-prompt.js";
|
|
6
|
+
|
|
7
|
+
const MAIN_CONTEXT_FILE = "MAIN.md";
|
|
8
|
+
const MAX_MAIN_CONTEXT_BYTES = 256 * 1024;
|
|
9
|
+
|
|
10
|
+
export interface MainContextLoadResult {
|
|
11
|
+
content: string;
|
|
12
|
+
paths: string[];
|
|
13
|
+
diagnostics: Array<{ path: string; message: string }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ancestorDirectories(cwd: string): string[] {
|
|
17
|
+
const directories: string[] = [];
|
|
18
|
+
let current = path.resolve(cwd);
|
|
19
|
+
while (true) {
|
|
20
|
+
directories.push(current);
|
|
21
|
+
const parent = path.dirname(current);
|
|
22
|
+
if (parent === current) return directories;
|
|
23
|
+
current = parent;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function globalMainContextPath(): string {
|
|
28
|
+
return path.join(getAgentDir(), "subagent", MAIN_CONTEXT_FILE);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function projectMainContextPath(directory: string): string {
|
|
32
|
+
return path.join(directory, CONFIG_DIR_NAME, "subagent", MAIN_CONTEXT_FILE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function loadMainContext(cwd: string, includeProject: boolean): MainContextLoadResult {
|
|
36
|
+
const candidates = [
|
|
37
|
+
globalMainContextPath(),
|
|
38
|
+
...(includeProject
|
|
39
|
+
? ancestorDirectories(cwd).reverse().map((directory) => projectMainContextPath(directory))
|
|
40
|
+
: []),
|
|
41
|
+
];
|
|
42
|
+
const sections: Array<{ path: string; content: string }> = [];
|
|
43
|
+
const diagnostics: Array<{ path: string; message: string }> = [];
|
|
44
|
+
const seen = new Set<string>();
|
|
45
|
+
|
|
46
|
+
for (const candidate of candidates) {
|
|
47
|
+
const sourcePath = path.resolve(candidate);
|
|
48
|
+
if (seen.has(sourcePath)) continue;
|
|
49
|
+
seen.add(sourcePath);
|
|
50
|
+
try {
|
|
51
|
+
const stat = fs.statSync(sourcePath);
|
|
52
|
+
if (!stat.isFile()) throw new Error("MAIN.md must be a regular file");
|
|
53
|
+
if (stat.size > MAX_MAIN_CONTEXT_BYTES) {
|
|
54
|
+
throw new Error(`MAIN.md exceeds ${MAX_MAIN_CONTEXT_BYTES} bytes`);
|
|
55
|
+
}
|
|
56
|
+
const content = fs.readFileSync(sourcePath, "utf8").trim();
|
|
57
|
+
if (content) sections.push({ path: sourcePath, content });
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
60
|
+
diagnostics.push({ path: sourcePath, message: error instanceof Error ? error.message : String(error) });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
content: sections
|
|
66
|
+
.map((section) => `## Pi Subagent Main context: ${section.path}\n\n${section.content}`)
|
|
67
|
+
.join("\n\n"),
|
|
68
|
+
paths: sections.map((section) => section.path),
|
|
69
|
+
diagnostics,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class MainContextRuntime {
|
|
74
|
+
private content = "";
|
|
75
|
+
|
|
76
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
77
|
+
|
|
78
|
+
register(): void {
|
|
79
|
+
this.pi.on("session_start", (_event, ctx) => {
|
|
80
|
+
const loaded = loadMainContext(ctx.cwd, ctx.isProjectTrusted());
|
|
81
|
+
this.content = loaded.content;
|
|
82
|
+
for (const diagnostic of loaded.diagnostics) {
|
|
83
|
+
const message = `Pi Subagent Main context error (${diagnostic.path}): ${diagnostic.message}`;
|
|
84
|
+
ctx.ui.notify(message, "warning");
|
|
85
|
+
if (!ctx.hasUI) console.error(message);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
bindPromptSections(this.pi, ["subagent_main"], (): Record<string, string> => this.content ? { subagent_main: this.content } : {});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const MANUAL_SUB_PREFIX = "[sub:manual] ";
|
|
2
|
+
|
|
3
|
+
export function subSessionName(title: string, origin?: "manual"): string {
|
|
4
|
+
return `${origin === "manual" ? MANUAL_SUB_PREFIX : "[sub] "}${title}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const MANUAL_MAIN_GUIDANCE = "This specialist was explicitly invoked by the user. Follow up only on the user's task and its results; do not assign unrelated work or substitute it for general consulting profiles. New tasks are initiated by the user with a profile command.";
|
|
8
|
+
|
|
9
|
+
export const MANUAL_SUB_GUIDANCE = "This is a user-invoked specialist session, not a general-purpose delegate. Work on the user's explicitly submitted tasks and related follow-ups from Main. Return findings through talk. If Main asks for unrelated work, ask it to use an agent-invokable profile or wait for the user's next command.";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ProfileCatalog } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const MAX_CONTEXT_BYTES = 16 * 1024;
|
|
4
|
+
|
|
5
|
+
export function buildProfilesContext(catalog: ProfileCatalog): string {
|
|
6
|
+
const header = "## Available Pi Subagent delegation profiles\n";
|
|
7
|
+
const footer = "Use the exact profile name as delegate.profile. Project profiles override same-named global profiles.";
|
|
8
|
+
const profiles = [...catalog.profiles.values()].filter((profile) => profile.invocation !== "manual").sort((left, right) => left.name.localeCompare(right.name));
|
|
9
|
+
if (profiles.length === 0) {
|
|
10
|
+
return `${header}No agent-invokable profiles are configured. Do not call delegate until the user configures one.\n${footer}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const lines: string[] = [];
|
|
14
|
+
let omitted = 0;
|
|
15
|
+
for (const profile of profiles) {
|
|
16
|
+
const persistence = profile.sessionPersistence ?? "ephemeral";
|
|
17
|
+
const line = `- ${profile.name} [${profile.source}, ${persistence}]${profile.description ? `: ${profile.description}` : ""}`;
|
|
18
|
+
const candidate = `${header}${[...lines, line].join("\n")}\n${footer}`;
|
|
19
|
+
if (Buffer.byteLength(candidate, "utf8") > MAX_CONTEXT_BYTES) {
|
|
20
|
+
omitted += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
lines.push(line);
|
|
24
|
+
}
|
|
25
|
+
if (omitted > 0) lines.push(`- (${omitted} additional profiles omitted from context)`);
|
|
26
|
+
return `${header}${lines.join("\n")}\n${footer}`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ResolvedProfile } from "./types.js";
|
|
2
|
+
import { isMcpTool, MCP_EXTENSIONS, MCP_TOOL_SELECTION } from "./mcp.js";
|
|
3
|
+
|
|
4
|
+
export const SUB_CONTROL_TOOLS = ["talk"] as const;
|
|
5
|
+
|
|
6
|
+
function unique(values: readonly string[]): string[] {
|
|
7
|
+
return [...new Set(values)];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function subCapabilityArgs(profile: ResolvedProfile, subagentEntryPath: string): string[] {
|
|
11
|
+
const args: string[] = [];
|
|
12
|
+
for (const extension of unique([...profile.resolvedExtensions, ...MCP_EXTENSIONS]).filter((entry) => entry !== subagentEntryPath)) {
|
|
13
|
+
args.push("-e", extension);
|
|
14
|
+
}
|
|
15
|
+
args.push("--tools", unique([...profile.tools.filter((name) => !isMcpTool(name)), ...SUB_CONTROL_TOOLS, ...MCP_TOOL_SELECTION]).join(","));
|
|
16
|
+
if (profile.model) args.push("--model", profile.model);
|
|
17
|
+
if (profile.thinkingLevel) args.push("--thinking", profile.thinkingLevel);
|
|
18
|
+
args.push("--no-skills");
|
|
19
|
+
for (const skill of profile.resolvedSkills) args.push("--skill", skill);
|
|
20
|
+
return args;
|
|
21
|
+
}
|