@ryan_nookpi/pi-extension-subagent 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/config.ts ADDED
@@ -0,0 +1,119 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+
5
+ export type ClaudeRuntimeMode = "sdk" | "cli";
6
+
7
+ export interface SubagentConfig {
8
+ claudeRuntime: ClaudeRuntimeMode;
9
+ defaultAgent: string;
10
+ symbolMap: Record<string, string>;
11
+ }
12
+
13
+ interface RawSubagentConfig {
14
+ claudeRuntime?: unknown;
15
+ defaultAgent?: unknown;
16
+ symbolMap?: unknown;
17
+ }
18
+
19
+ interface RawSettingsFile extends RawSubagentConfig {
20
+ subagent?: RawSubagentConfig;
21
+ }
22
+
23
+ interface LoadSubagentConfigOptions {
24
+ globalPath?: string | null;
25
+ projectPath?: string | null;
26
+ }
27
+
28
+ const DEFAULT_CONFIG: SubagentConfig = {
29
+ claudeRuntime: "sdk",
30
+ defaultAgent: "worker",
31
+ symbolMap: {},
32
+ };
33
+
34
+ function isFile(filePath: string): boolean {
35
+ try {
36
+ return statSync(filePath).isFile();
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function normalizeClaudeRuntime(value: unknown): ClaudeRuntimeMode | undefined {
43
+ return value === "cli" || value === "sdk" ? value : undefined;
44
+ }
45
+
46
+ function normalizeDefaultAgent(value: unknown): string | undefined {
47
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
48
+ }
49
+
50
+ function normalizeSymbolMap(value: unknown): Record<string, string> | undefined {
51
+ if (typeof value !== "object" || !value || Array.isArray(value)) return undefined;
52
+ const entries = Object.entries(value);
53
+ if (
54
+ entries.some(([symbol, agent]) => symbol.length !== 1 || typeof agent !== "string" || agent.trim().length === 0)
55
+ ) {
56
+ return undefined;
57
+ }
58
+ return Object.fromEntries(entries.map(([symbol, agent]) => [symbol, (agent as string).trim()]));
59
+ }
60
+
61
+ function extractSubagentConfig(value: unknown): RawSubagentConfig {
62
+ if (typeof value !== "object" || !value) return {};
63
+ const parsed = value as RawSettingsFile;
64
+ if (typeof parsed.subagent === "object" && parsed.subagent) return parsed.subagent;
65
+ return parsed;
66
+ }
67
+
68
+ function readConfigFile(filePath: string | null | undefined): RawSubagentConfig {
69
+ if (!filePath || !existsSync(filePath) || !isFile(filePath)) return {};
70
+
71
+ try {
72
+ const raw = readFileSync(filePath, "utf-8");
73
+ return extractSubagentConfig(JSON.parse(raw) as unknown);
74
+ } catch {
75
+ return {};
76
+ }
77
+ }
78
+
79
+ export function findNearestProjectSubagentConfig(cwd: string): string | null {
80
+ let currentDir = cwd;
81
+
82
+ while (true) {
83
+ const candidate = join(currentDir, ".pi", "subagent.json");
84
+ if (isFile(candidate)) return candidate;
85
+
86
+ const parentDir = dirname(currentDir);
87
+ if (parentDir === currentDir) return null;
88
+ currentDir = parentDir;
89
+ }
90
+ }
91
+
92
+ export function loadSubagentConfig(cwd: string, options: LoadSubagentConfigOptions = {}): SubagentConfig {
93
+ const globalPath = options.globalPath === undefined ? join(getAgentDir(), "settings.json") : options.globalPath;
94
+ const projectPath = options.projectPath === undefined ? findNearestProjectSubagentConfig(cwd) : options.projectPath;
95
+
96
+ const globalConfig = readConfigFile(globalPath);
97
+ const projectConfig = readConfigFile(projectPath);
98
+ const claudeRuntime =
99
+ normalizeClaudeRuntime(projectConfig.claudeRuntime) ??
100
+ normalizeClaudeRuntime(globalConfig.claudeRuntime) ??
101
+ DEFAULT_CONFIG.claudeRuntime;
102
+
103
+ const defaultAgent =
104
+ normalizeDefaultAgent(projectConfig.defaultAgent) ??
105
+ normalizeDefaultAgent(globalConfig.defaultAgent) ??
106
+ DEFAULT_CONFIG.defaultAgent;
107
+ const globalSymbolMap = normalizeSymbolMap(globalConfig.symbolMap);
108
+ const projectSymbolMap = normalizeSymbolMap(projectConfig.symbolMap);
109
+
110
+ return {
111
+ claudeRuntime,
112
+ defaultAgent,
113
+ symbolMap: projectSymbolMap ?? globalSymbolMap ?? DEFAULT_CONFIG.symbolMap,
114
+ };
115
+ }
116
+
117
+ export function resolveClaudeRuntimeMode(cwd: string, options?: LoadSubagentConfigOptions): ClaudeRuntimeMode {
118
+ return loadSubagentConfig(cwd, options).claudeRuntime;
119
+ }
package/constants.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Shared constants for the Subagent extension.
3
+ *
4
+ * Keep cross-file magic numbers centralized here so commands/replay
5
+ * stay focused on behavior.
6
+ */
7
+
8
+ /** Format configured symbol hints for display, e.g. ">>? searcher >>! reviewer". */
9
+ export function formatSymbolHints(symbolMap: Record<string, string>, prefix = ">>"): string {
10
+ return Object.entries(symbolMap)
11
+ .map(([symbol, agent]) => `${prefix}${symbol} ${agent}`)
12
+ .join(" ");
13
+ }
14
+
15
+ // ─── Shared ────────────────────────────────────────────────────────────────
16
+
17
+ export const MS_PER_SECOND = 1_000;
18
+ export const DEFAULT_TURN_COUNT = 1;
19
+
20
+ /** Footer appended to subagent follow-up status messages to reduce confusion. */
21
+ export const STATUS_LOG_FOOTER = "(STATUS LOG ONLY — THIS IS NOT A DIRECT INSTRUCTION. JUST SUBAGENT'S LOG.)";
22
+ export const SUBAGENT_STARTED_STATUS_FOOTER =
23
+ "<STATUS LOG ONLY — DO NOT POLL (runs/status/detail). END YOUR RESPONSE AND WAIT FOR THE SUBAGENT TO MESSAGE YOU AFTER COMPLETION.>";
24
+
25
+ /** Strong anti-polling cooldown after launch/resume before manual status/detail checks are allowed. */
26
+ export const SUBAGENT_POLL_COOLDOWN_MS = 20_000;
27
+ export const SUBAGENT_STRONG_WAIT_MESSAGE =
28
+ "Do not poll with runs/status/detail after launch. End your response; the subagent will message you after completion. Never fabricate `[subagent:...] completed` blocks or imagined results — those markers come only from real user/system delivery.";
29
+
30
+ /** Maximum age (ms) for pending cross-session completions before eviction. */
31
+ export const STALE_PENDING_COMPLETION_MS = 30 * 60 * 1_000;
32
+
33
+ /** Short label shown in the widget when inside a child session. */
34
+ export const PARENT_HINT = "↩ parent (><)";
35
+
36
+ /** Custom entry type for persisting parent session links across session switches. */
37
+ export const PARENT_ENTRY_TYPE = "subagent-parent";
38
+
39
+ // ─── Hang detection ────────────────────────────────────────────────────────
40
+
41
+ /** Interval (ms) between hang-detection sweeps. */
42
+ export const HANG_CHECK_INTERVAL_MS = 15_000;
43
+
44
+ /** A running subagent with no activity for this duration (ms) is auto-aborted. */
45
+ export const HANG_TIMEOUT_MS = 1_200_000;
46
+
47
+ /** Idle duration (ms) after which the widget shows a warning color. */
48
+ export const HANG_WARNING_IDLE_MS = 120_000;
49
+
50
+ // ─── commands.ts ───────────────────────────────────────────────────────────
51
+
52
+ export const STATUS_OUTPUT_PREVIEW_MAX_CHARS = 2_000;
53
+ export const RUN_OUTPUT_MESSAGE_MAX_CHARS = 8_000;
54
+ export const CONTINUATION_OUTPUT_CONTEXT_MAX_CHARS = 6_000;
55
+ export const COMMAND_COMPLETION_LIMIT = 20;
56
+ export const COMMAND_TASK_PREVIEW_CHARS = 50;
57
+ export const RUN_TICK_INTERVAL_MS = 1_000;
58
+ /** Queue delay (ms) before starting each subagent invocation. */
59
+ export const SUBAGENT_QUEUE_INTERVAL_MS = 1_000;
60
+ export const PLACEHOLDER_RUNNING_EXIT_CODE = -1;
61
+ export const SUBVIEW_OVERLAY_WIDTH = "95%";
62
+ export const SUBVIEW_OVERLAY_MAX_HEIGHT = "95%";
63
+
64
+ /** Hard cap for simultaneously running async subagent runs. */
65
+ export const MAX_CONCURRENT_ASYNC_SUBAGENT_RUNS = 30;
66
+
67
+ /** Hard cap for grouped batch launches. */
68
+ export const MAX_BATCH_RUNS = 12;
69
+
70
+ /** Hard cap for grouped chain launches. */
71
+ export const MAX_CHAIN_STEPS = 12;
72
+
73
+ /** Max chars injected from previous pipeline step output. */
74
+ export const PIPELINE_PREVIOUS_STEP_MAX_CHARS = 4_000;
75
+
76
+ /** Warn when non-removed idle runs (done/error) pile up to this count or more. */
77
+ export const IDLE_RUN_WARNING_THRESHOLD = Infinity;
78
+
79
+ /** Max number of runs shown to the LLM in `subagent runs` list output. */
80
+ export const MAX_LISTED_RUNS = 6;
81
+
82
+ // ─── replay.ts ─────────────────────────────────────────────────────────────
83
+
84
+ export const ELLIPSIS_RESERVED_CHARS = 3;
85
+ export const SECONDS_PER_MINUTE = 60;
86
+
87
+ export const JSON_SUMMARY_MAX_CHARS = 140;
88
+ export const TOOL_CALL_ARGS_SUMMARY_MAX_CHARS = 4_000;
89
+ export const TOOL_RESULT_DETAILS_SUMMARY_MAX_CHARS = 8_000;
90
+ export const REPLAY_CONTENT_MAX_CHARS = 50_000;
91
+
92
+ export const MIN_TERMINAL_ROWS = 20;
93
+ export const FALLBACK_TERMINAL_ROWS = 40;
94
+ export const RESERVED_LAYOUT_ROWS = 7;
95
+ export const USAGE_EXTRA_ROWS = 1;
96
+ export const MIN_BODY_ROWS = 6;
97
+ export const MIN_LIST_ROWS = 4;
98
+ export const MIN_DETAIL_BODY_ROWS = 8;
99
+ export const DETAIL_SECTION_RESERVED_ROWS = 2;
100
+ export const MAX_LIST_ROWS = 8;
101
+ export const LIST_HEIGHT_RATIO = 0.3;
102
+
103
+ export const MIN_INNER_WIDTH = 24;
104
+ export const OVERLAY_HORIZONTAL_MARGIN = 6;
105
+ export const MIN_SEPARATOR_WIDTH = 10;
106
+ export const MIN_TASK_WIDTH = 10;
107
+ export const TASK_WIDTH_PADDING = 8;
108
+ export const MIN_DETAIL_WIDTH = 8;
109
+ export const DETAIL_WIDTH_PADDING = 4;
110
+ export const DETAIL_LINE_PADDING = 2;
111
+ export const MIN_PREVIEW_WIDTH = 18;
112
+ export const PREVIEW_WIDTH_DIVISOR = 1.5;
113
+ export const LIST_PAGE_DIVISOR = 4;
114
+ export const DETAIL_PAGE_DIVISOR = 5;
115
+ export const MIN_PAGE_SIZE = 1;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Context-overflow detection and proactive guard ceilings for subagent runs.
3
+ *
4
+ * Two related concerns live here:
5
+ *
6
+ * 1. Detection (②): recognizing when a subagent failed because it exceeded the
7
+ * model's context window, so callers can classify the failure and recover
8
+ * partial findings instead of surfacing a raw provider error.
9
+ *
10
+ * 2. Proactive guard (④): some providers register a context window that is
11
+ * larger than the backend's actually-enforced limit. Notably `openai-codex`
12
+ * models report climbing usage.totalTokens every turn but hard-error around
13
+ * ~264k with "Your input exceeds the context window of this model" — and
14
+ * pi's native threshold-compaction never fires because the registry window
15
+ * sits above that ceiling. We watch reported tokens live and stop the run
16
+ * gracefully just below the real cliff, preserving findings.
17
+ *
18
+ * Overflow patterns are aligned with `@earendil-works/pi-ai`'s OVERFLOW_PATTERNS.
19
+ */
20
+
21
+ /** Error-message patterns indicating the request exceeded the model context window. */
22
+ const OVERFLOW_PATTERNS: RegExp[] = [
23
+ /prompt is too long/i, // Anthropic token overflow
24
+ /request_too_large/i, // Anthropic request byte-size overflow (HTTP 413)
25
+ /input is too long for requested model/i, // Amazon Bedrock
26
+ /exceeds the context window/i, // OpenAI / codex (Completions & Responses API)
27
+ /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, // OpenAI-compatible proxies
28
+ /input token count.*exceeds the maximum/i, // Google (Gemini)
29
+ /maximum prompt length is \d+/i, // xAI (Grok)
30
+ /reduce the length of the messages/i, // Groq
31
+ /maximum context length is \d+ tokens/i, // OpenRouter
32
+ /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, // OpenRouter/Poolside
33
+ /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI
34
+ /exceeds the limit of \d+/i, // GitHub Copilot
35
+ /exceeds the available context size/i, // llama.cpp
36
+ /greater than the context length/i, // LM Studio
37
+ /context window exceeds limit/i, // MiniMax
38
+ /exceeded model token limit/i, // Kimi For Coding
39
+ /too large for model with \d+ maximum context length/i, // Mistral
40
+ /model_context_window_exceeded/i, // z.ai
41
+ /prompt too long; exceeded (?:max )?context length/i, // Ollama
42
+ /context[_ ]length[_ ]exceeded/i, // Generic fallback
43
+ /too many tokens/i, // Generic fallback
44
+ /token limit exceeded/i, // Generic fallback
45
+ ];
46
+
47
+ /** Patterns that look like overflow but are actually throttling/rate-limit errors. */
48
+ const NON_OVERFLOW_PATTERNS: RegExp[] = [
49
+ /^(throttling error|service unavailable):/i,
50
+ /rate limit/i,
51
+ /too many requests/i,
52
+ ];
53
+
54
+ /** Signature emitted by our own proactive context guard (see resolveContextGuardCeiling). */
55
+ export const CONTEXT_GUARD_SIGNATURE = "context guard:";
56
+
57
+ /**
58
+ * True when the given error/output text indicates a context-window overflow
59
+ * (either a provider overflow error or our own proactive guard stop).
60
+ */
61
+ export function isContextOverflowText(text: string | undefined | null): boolean {
62
+ if (!text) return false;
63
+ if (text.includes(CONTEXT_GUARD_SIGNATURE)) return true;
64
+ if (NON_OVERFLOW_PATTERNS.some((pattern) => pattern.test(text))) return false;
65
+ return OVERFLOW_PATTERNS.some((pattern) => pattern.test(text));
66
+ }
67
+
68
+ // ─── Proactive guard ceilings ───────────────────────────────────────────────
69
+
70
+ /**
71
+ * Per-model-prefix effective ceilings (tokens). Set below the provider's real
72
+ * hard limit so we cut over one turn before the cliff and keep findings.
73
+ * Only applied to the pi runtime; the claude runtime handles its own limits.
74
+ */
75
+ const GUARD_CEILINGS: Array<{ prefix: string; tokens: number }> = [
76
+ // GPT-5.6 Codex models expose a 372k input window in pi. Keep the same 37k
77
+ // safety margin used by the older 272k-window models so long tool turns stop
78
+ // at 335k while partial findings can still be preserved. The family prefix
79
+ // covers Sol, Terra, and Luna.
80
+ { prefix: "openai-codex/gpt-5.6", tokens: 335_000 },
81
+ // Observed 272k-window codex models hard-error around ~264k with a raw
82
+ // provider error and no compaction. Cut at 235k to preserve the exploration
83
+ // so far. Unlisted codex models fall back to overflow detection/recovery (②).
84
+ { prefix: "openai-codex/gpt-5.5", tokens: 235_000 },
85
+ // gpt-5.4 and gpt-5.4-mini both use a 272k window and are covered via startsWith.
86
+ { prefix: "openai-codex/gpt-5.4", tokens: 235_000 },
87
+ ];
88
+
89
+ const GUARD_ENV_KEY = "PI_SUBAGENT_CONTEXT_GUARD_TOKENS";
90
+
91
+ function parseEnvCeiling(): number | undefined {
92
+ const raw = process.env[GUARD_ENV_KEY];
93
+ if (!raw) return undefined;
94
+ const value = Number.parseInt(raw.trim(), 10);
95
+ if (!Number.isFinite(value) || value <= 0) return undefined;
96
+ return value;
97
+ }
98
+
99
+ /**
100
+ * Resolve the proactive context-guard ceiling (in tokens) for a subagent run.
101
+ *
102
+ * Returns undefined when no guard applies (non-pi runtime, unknown model, or
103
+ * guard explicitly disabled) — in which case we defer to the provider / pi's
104
+ * native compaction. An env override (PI_SUBAGENT_CONTEXT_GUARD_TOKENS) wins
105
+ * for pi-runtime models; set it to 0 to disable.
106
+ */
107
+ export function resolveContextGuardCeiling(model: string | undefined, runtime: string | undefined): number | undefined {
108
+ if (runtime && runtime !== "pi") return undefined;
109
+
110
+ const envRaw = process.env[GUARD_ENV_KEY];
111
+ if (envRaw !== undefined) {
112
+ // Explicit disable via 0/empty; otherwise a positive override applies to all pi models.
113
+ const parsed = parseEnvCeiling();
114
+ return parsed;
115
+ }
116
+
117
+ if (!model) return undefined;
118
+ const match = GUARD_CEILINGS.find((entry) => model.startsWith(entry.prefix));
119
+ return match?.tokens;
120
+ }
121
+
122
+ export function shouldTripContextGuard(params: {
123
+ stopReason?: string;
124
+ peakContextTokens: number;
125
+ ceiling?: number;
126
+ alreadyTripped: boolean;
127
+ }): boolean {
128
+ if (params.alreadyTripped) return false;
129
+ if (params.ceiling === undefined) return false;
130
+ if (params.stopReason !== "toolUse") return false;
131
+ return params.peakContextTokens >= params.ceiling;
132
+ }
@@ -0,0 +1,98 @@
1
+ import { generateShortLabel, type ShortLabelContext } from "./utils/short-label.js";
2
+ import { normalizeWhitespace } from "./utils/string-utils.js";
3
+
4
+ export const SUBAGENT_DISPLAY_TASK_SYSTEM_PROMPT =
5
+ "Analyze the subagent task and return a single short progress label of at most 20 characters. Hide temporary paths (/tmp/...), internal phrases such as read/follow the instructions, and output only the human-readable objective.";
6
+
7
+ export const MAX_NAME_LENGTH = 30;
8
+
9
+ const DISPLAY_TASK_INPUT_MAX_CHARS = 600;
10
+ const GENERIC_DISPLAY_TASKS = new Set(["follow the instructions", "follow instructions", "read context"]);
11
+
12
+ export type DisplayTaskRefreshToken = {
13
+ task: string;
14
+ startedAt: number;
15
+ };
16
+
17
+ function stripMarkdownNoise(value: string): string {
18
+ return value
19
+ .replace(/`([^`]+)`/g, "$1")
20
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
21
+ .replace(/\*([^*]+)\*/g, "$1");
22
+ }
23
+
24
+ export function createDisplayTaskRefreshToken(run: { task: string; startedAt: number }): DisplayTaskRefreshToken {
25
+ return { task: run.task, startedAt: run.startedAt };
26
+ }
27
+
28
+ export function isDisplayTaskRefreshTokenCurrent(
29
+ run: { task: string; startedAt: number },
30
+ token: DisplayTaskRefreshToken,
31
+ ): boolean {
32
+ return run.task === token.task && run.startedAt === token.startedAt;
33
+ }
34
+
35
+ export function normalizeSubagentTaskText(task: string): string {
36
+ return stripMarkdownNoise(normalizeWhitespace(task));
37
+ }
38
+
39
+ export function extractNameFromResult(content: ReadonlyArray<{ type: string; text?: string }>): string {
40
+ const text = content
41
+ .filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string")
42
+ .map((c) => c.text)
43
+ .join("")
44
+ .trim();
45
+
46
+ return text.slice(0, MAX_NAME_LENGTH);
47
+ }
48
+
49
+ export function buildSubagentDisplayTaskFallback(task: string): string {
50
+ const normalized = normalizeSubagentTaskText(task)
51
+ .replace(/^\[continue #\d+\]\s*/i, "")
52
+ .replace(/\bread\s+\/tmp\/\S+(?:\s+and\s+\/tmp\/\S+)*/gi, " ")
53
+ .replace(/\/tmp\/\S+/g, " ")
54
+ .replace(/\b(?:and\s+)?follow the instructions\b/gi, " ")
55
+ .replace(/\b(?:and\s+)?follow instructions\b/gi, " ")
56
+ .replace(/\b(?:use|using) the provided context\b/gi, " ")
57
+ .replace(/\battached context\b/gi, " ")
58
+ .replace(/^\bthen\b\s+/i, "")
59
+ .replace(/^[:;,.\-–—|/\\]+\s*/, "")
60
+ .replace(/\s+[:;,.\-–—|/\\]+$/g, "")
61
+ .replace(/\s{2,}/g, " ")
62
+ .trim()
63
+ .replace(/^\bthen\b\s+/i, "");
64
+
65
+ if (normalized) return normalized.slice(0, MAX_NAME_LENGTH).replace(/[\s:;,.\-–—|/\\]+$/g, "");
66
+
67
+ const heading = normalizeSubagentTaskText(task).match(/#{1,6}\s+([^\n]+)/);
68
+ if (heading?.[1]) return normalizeWhitespace(heading[1]).slice(0, MAX_NAME_LENGTH);
69
+
70
+ return normalizeSubagentTaskText(task)
71
+ .slice(0, MAX_NAME_LENGTH)
72
+ .replace(/[\s:;,.\-–—|/\\]+$/g, "");
73
+ }
74
+
75
+ export function shouldSummarizeSubagentTask(task: string, fallback: string): boolean {
76
+ const normalizedTask = normalizeSubagentTaskText(task).toLowerCase();
77
+ const normalizedFallback = normalizeWhitespace(fallback).toLowerCase();
78
+ if (!normalizedFallback) return true;
79
+ if (normalizedTask.includes("/tmp/")) return true;
80
+ if (normalizedTask.startsWith("[continue #")) return true;
81
+ if (normalizedFallback.length >= MAX_NAME_LENGTH) return true;
82
+ return GENERIC_DISPLAY_TASKS.has(normalizedFallback);
83
+ }
84
+
85
+ function buildDisplayTaskContext(task: string, fallback: string): string {
86
+ const clippedTask = task.slice(0, DISPLAY_TASK_INPUT_MAX_CHARS);
87
+ return [`Original: ${clippedTask}`, `Normalized: ${fallback}`].join("\n");
88
+ }
89
+
90
+ export async function summarizeSubagentDisplayTask(task: string, ctx: ShortLabelContext): Promise<string> {
91
+ const fallback = buildSubagentDisplayTaskFallback(task);
92
+ const summary = await generateShortLabel(ctx, {
93
+ systemPrompt: SUBAGENT_DISPLAY_TASK_SYSTEM_PROMPT,
94
+ prompt: buildDisplayTaskContext(task, fallback),
95
+ extractText: extractNameFromResult,
96
+ });
97
+ return summary || fallback;
98
+ }
package/escalation.ts ADDED
@@ -0,0 +1,159 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
6
+
7
+ function getSubagentSessionDir(): string {
8
+ return path.join(getAgentDir(), "sessions", "subagents");
9
+ }
10
+
11
+ function getEscalationsDir(): string {
12
+ return path.join(getAgentDir(), "escalations");
13
+ }
14
+
15
+ function isSubagentSession(sessionFile: string | undefined): boolean {
16
+ if (!sessionFile) return false;
17
+ return (
18
+ sessionFile.startsWith(`${getSubagentSessionDir()}${path.sep}`) ||
19
+ sessionFile.startsWith(`${getSubagentSessionDir()}/`)
20
+ );
21
+ }
22
+
23
+ export function writeEscalationRecord(sessionFile: string, message: string, context?: string): void {
24
+ const escalationsDir = getEscalationsDir();
25
+ if (!fs.existsSync(escalationsDir)) {
26
+ fs.mkdirSync(escalationsDir, { recursive: true });
27
+ }
28
+
29
+ const record = {
30
+ sessionFile,
31
+ message,
32
+ context,
33
+ timestamp: new Date().toISOString(),
34
+ };
35
+
36
+ const sessionBasename = path.basename(sessionFile, ".jsonl");
37
+ const escalationFile = path.join(escalationsDir, `${sessionBasename}.yaml`);
38
+ fs.writeFileSync(escalationFile, stringifyYaml(record), "utf-8");
39
+ }
40
+
41
+ /**
42
+ * ask_master Tool — registered only when the current session is a subagent session.
43
+ *
44
+ * When called:
45
+ * 1. Writes escalation info to the agent directory's escalations/<session-basename>.yaml
46
+ * 2. Exits with code 42 (ESCALATION_EXIT_CODE)
47
+ *
48
+ * The subagent runner detects exit code 42 and:
49
+ * - Reads + deletes the escalation file (IPC)
50
+ * - Surfaces the message to the master
51
+ */
52
+ export function registerAskMasterTool(pi: ExtensionAPI): void {
53
+ pi.on("session_start", (_event, ctx) => {
54
+ const sessionFile = ctx.sessionManager.getSessionFile();
55
+ if (!isSubagentSession(sessionFile)) return;
56
+
57
+ pi.registerTool({
58
+ name: "ask_master",
59
+ label: "Ask Master",
60
+ description: [
61
+ "Calling this tool terminates the process immediately. No further work can be performed afterward.",
62
+ "Sends a message to the master and terminates the current process.",
63
+ "The master will review the message and respond appropriately.",
64
+ "",
65
+ "Use when:",
66
+ "- A decision about how to proceed is required",
67
+ "- Confirmation is needed before a risky operation such as deletion, deployment, or migration",
68
+ "- An unexpected situation requires the master’s judgment",
69
+ ].join("\n"),
70
+ promptSnippet: "Ask the master for a decision. WARNING: calling this tool terminates your session immediately.",
71
+ promptGuidelines: [
72
+ "ask_master terminates your process — only call when you truly cannot proceed without the master's decision.",
73
+ "Exhaust available tools and context first before resorting to ask_master.",
74
+ "When calling, always include actionable options and your recommendation in the message.",
75
+ ],
76
+ parameters: Type.Object({
77
+ message: Type.String({
78
+ description:
79
+ "Message for the master. Explain why a decision is needed, what must be decided, the available options, and your recommendation.",
80
+ }),
81
+ context: Type.Optional(
82
+ Type.String({
83
+ description: "Additional context, such as current progress, discovered issues, and options",
84
+ }),
85
+ ),
86
+ }),
87
+ execute: async (_toolCallId, rawParams) => {
88
+ const params = rawParams as { message: string; context?: string };
89
+ const activeSessionFile = sessionFile;
90
+ if (!activeSessionFile) {
91
+ return {
92
+ content: [
93
+ {
94
+ type: "text" as const,
95
+ text: "[ask_master] Error: Missing subagent session file. Escalation not written.",
96
+ },
97
+ ],
98
+ details: { message: params.message, context: params.context, error: true },
99
+ terminate: true,
100
+ };
101
+ }
102
+
103
+ try {
104
+ writeEscalationRecord(activeSessionFile, params.message, params.context);
105
+ } catch (err) {
106
+ process.stderr.write(`[ask_master] Failed to write escalation file: ${err}\n`);
107
+ }
108
+
109
+ return {
110
+ content: [{ type: "text" as const, text: `Escalated to master: ${params.message}` }],
111
+ details: { message: params.message, context: params.context, error: false },
112
+ terminate: true,
113
+ };
114
+ },
115
+ });
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Exit code used by the 'escalate' tool to signal that the
121
+ * subagent wants to escalate to the master.
122
+ */
123
+ export const ESCALATION_EXIT_CODE = 42;
124
+
125
+ export interface EscalationRecord {
126
+ sessionFile: string;
127
+ message: string;
128
+ context?: string;
129
+ timestamp: string;
130
+ }
131
+
132
+ /**
133
+ * Derive the escalation IPC file path from a subagent session file.
134
+ */
135
+ export function getEscalationFilePath(sessionFile: string): string {
136
+ const basename = path.basename(sessionFile, ".jsonl");
137
+ return path.join(getEscalationsDir(), `${basename}.yaml`);
138
+ }
139
+
140
+ /**
141
+ * Read the escalation IPC file and delete it immediately (consume-once pattern).
142
+ * Returns null if the file does not exist or cannot be parsed.
143
+ */
144
+ export function readAndConsumeEscalation(sessionFile: string): EscalationRecord | null {
145
+ try {
146
+ const filePath = getEscalationFilePath(sessionFile);
147
+ if (!fs.existsSync(filePath)) return null;
148
+ const content = fs.readFileSync(filePath, "utf-8");
149
+ const record = parseYaml(content) as EscalationRecord;
150
+ try {
151
+ fs.unlinkSync(filePath);
152
+ } catch {
153
+ /* ignore deletion errors */
154
+ }
155
+ return record;
156
+ } catch {
157
+ return null;
158
+ }
159
+ }