@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/LICENSE +21 -0
- package/README.md +242 -0
- package/above-widget.ts +20 -0
- package/agents.ts +233 -0
- package/claude-args.ts +103 -0
- package/claude-sdk-runner.ts +223 -0
- package/claude-sdk-stream-adapter.ts +49 -0
- package/claude-sidecar-writer.ts +107 -0
- package/claude-stream-parser.ts +400 -0
- package/cli.ts +549 -0
- package/commands.ts +2295 -0
- package/config.ts +119 -0
- package/constants.ts +115 -0
- package/context-limits.ts +132 -0
- package/display-task.ts +98 -0
- package/escalation.ts +159 -0
- package/format.ts +188 -0
- package/group-pending.ts +90 -0
- package/index.ts +200 -0
- package/invocation-queue.ts +29 -0
- package/live-preview.ts +39 -0
- package/package.json +95 -0
- package/persisted-session.ts +185 -0
- package/replay.ts +461 -0
- package/retry.ts +164 -0
- package/run-utils.ts +203 -0
- package/runner.ts +979 -0
- package/session.ts +319 -0
- package/store.ts +143 -0
- package/tool-execute.ts +1932 -0
- package/tool-render.ts +145 -0
- package/types.ts +193 -0
- package/utils/agent-utils.ts +315 -0
- package/utils/format-utils.ts +255 -0
- package/utils/short-label.ts +78 -0
- package/utils/string-utils.ts +229 -0
- package/utils/time-utils.ts +105 -0
- package/widget.ts +294 -0
package/format.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting and display utility functions for the Subagent tool.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { normalizeModelRef as normalizeModelRefUtil } from "./utils/format-utils.js";
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
AGENT_NAME_PALETTE,
|
|
11
|
+
agentBgIndex,
|
|
12
|
+
formatContextUsageBar,
|
|
13
|
+
formatTokens,
|
|
14
|
+
formatUsageStats,
|
|
15
|
+
getContextBarColorByRemaining,
|
|
16
|
+
getRemainingContextPercent,
|
|
17
|
+
getUsedContextPercent,
|
|
18
|
+
truncateLines,
|
|
19
|
+
truncatePlainToWidth,
|
|
20
|
+
truncateToWidthWithEllipsis,
|
|
21
|
+
} from "./utils/format-utils.js";
|
|
22
|
+
export const normalizeModelRef = normalizeModelRefUtil;
|
|
23
|
+
|
|
24
|
+
// ─── Context Usage ───────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
type ContextWindowResolverContext = {
|
|
27
|
+
model?: { contextWindow?: number };
|
|
28
|
+
modelRegistry?: {
|
|
29
|
+
getAll: () => Array<{ provider: string; id: string; contextWindow?: number }>;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type ThemeFg = (color: ThemeColor, text: string) => string;
|
|
34
|
+
|
|
35
|
+
export function resolveContextWindow(ctx: ContextWindowResolverContext, modelRef?: string): number | undefined {
|
|
36
|
+
const fallback = ctx?.model?.contextWindow;
|
|
37
|
+
if (!ctx?.modelRegistry || typeof ctx.modelRegistry.getAll !== "function") return fallback;
|
|
38
|
+
const models = ctx.modelRegistry.getAll() as Array<{ provider: string; id: string; contextWindow?: number }>;
|
|
39
|
+
if (!modelRef) return fallback;
|
|
40
|
+
|
|
41
|
+
const normalized = normalizeModelRef(modelRef);
|
|
42
|
+
if (normalized.provider) {
|
|
43
|
+
const exact = models.find((m) => m.provider === normalized.provider && m.id === normalized.id);
|
|
44
|
+
if (exact?.contextWindow) return exact.contextWindow;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const byId = models.find((m) => m.id === normalized.id);
|
|
48
|
+
if (byId?.contextWindow) return byId.contextWindow;
|
|
49
|
+
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Tool Call Formatting ────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function stringifyPreviewValue(value: unknown): string {
|
|
56
|
+
if (value === undefined || value === null) return "...";
|
|
57
|
+
if (typeof value === "string") return value;
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
const parts = value.map((item) => stringifyPreviewValue(item));
|
|
60
|
+
return parts.length > 3 ? `${parts.slice(0, 3).join(", ")}, ...` : parts.join(", ");
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return JSON.stringify(value);
|
|
64
|
+
} catch {
|
|
65
|
+
return String(value);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatPathValueForPreview(value: unknown): string {
|
|
70
|
+
const text = stringifyPreviewValue(value);
|
|
71
|
+
const home = os.homedir();
|
|
72
|
+
return text.startsWith(home) ? `~${text.slice(home.length)}` : text;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formatToolCall(toolName: string, args: Record<string, unknown>, themeFg: ThemeFg): string {
|
|
76
|
+
const shortenPath = (value: unknown) => formatPathValueForPreview(value);
|
|
77
|
+
|
|
78
|
+
switch (toolName) {
|
|
79
|
+
case "bash": {
|
|
80
|
+
const command = (args.command as string) || "...";
|
|
81
|
+
const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
|
|
82
|
+
return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
|
|
83
|
+
}
|
|
84
|
+
case "read": {
|
|
85
|
+
const rawPath = args.file_path || args.path || "...";
|
|
86
|
+
const filePath = shortenPath(rawPath);
|
|
87
|
+
const offset = args.offset as number | undefined;
|
|
88
|
+
const limit = args.limit as number | undefined;
|
|
89
|
+
let text = themeFg("accent", filePath);
|
|
90
|
+
if (offset !== undefined || limit !== undefined) {
|
|
91
|
+
const startLine = offset ?? 1;
|
|
92
|
+
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
|
93
|
+
text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
|
94
|
+
}
|
|
95
|
+
return themeFg("muted", "read ") + text;
|
|
96
|
+
}
|
|
97
|
+
case "write": {
|
|
98
|
+
const rawPath = args.file_path || args.path || "...";
|
|
99
|
+
const filePath = shortenPath(rawPath);
|
|
100
|
+
const content = (args.content || "") as string;
|
|
101
|
+
const lines = content.split("\n").length;
|
|
102
|
+
let text = themeFg("muted", "write ") + themeFg("accent", filePath);
|
|
103
|
+
if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
|
|
104
|
+
return text;
|
|
105
|
+
}
|
|
106
|
+
case "edit": {
|
|
107
|
+
const rawPath = args.file_path || args.path || "...";
|
|
108
|
+
return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
|
|
109
|
+
}
|
|
110
|
+
case "ls": {
|
|
111
|
+
const rawPath = args.path || ".";
|
|
112
|
+
return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
|
|
113
|
+
}
|
|
114
|
+
case "find": {
|
|
115
|
+
const pattern = (args.pattern || "*") as string;
|
|
116
|
+
const rawPath = args.path || ".";
|
|
117
|
+
return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`);
|
|
118
|
+
}
|
|
119
|
+
case "grep": {
|
|
120
|
+
const pattern = (args.pattern || "") as string;
|
|
121
|
+
const rawPath = args.path || ".";
|
|
122
|
+
return (
|
|
123
|
+
themeFg("muted", "grep ") + themeFg("accent", `/${pattern}/`) + themeFg("dim", ` in ${shortenPath(rawPath)}`)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
default: {
|
|
127
|
+
const argsStr = JSON.stringify(args);
|
|
128
|
+
const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
|
|
129
|
+
return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function shortenPathForPreview(p: unknown): string {
|
|
135
|
+
return formatPathValueForPreview(p);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function formatToolCallPlain(toolName: string, args: Record<string, unknown>): string {
|
|
139
|
+
switch (toolName) {
|
|
140
|
+
case "bash": {
|
|
141
|
+
const command = (args.command as string) || "...";
|
|
142
|
+
const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
|
|
143
|
+
return `$ ${preview}`;
|
|
144
|
+
}
|
|
145
|
+
case "read": {
|
|
146
|
+
const rawPath = args.file_path || args.path || "...";
|
|
147
|
+
const filePath = shortenPathForPreview(rawPath);
|
|
148
|
+
const offset = args.offset as number | undefined;
|
|
149
|
+
const limit = args.limit as number | undefined;
|
|
150
|
+
if (offset !== undefined || limit !== undefined) {
|
|
151
|
+
const startLine = offset ?? 1;
|
|
152
|
+
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
|
153
|
+
return `read ${filePath}:${startLine}${endLine ? `-${endLine}` : ""}`;
|
|
154
|
+
}
|
|
155
|
+
return `read ${filePath}`;
|
|
156
|
+
}
|
|
157
|
+
case "write": {
|
|
158
|
+
const rawPath = args.file_path || args.path || "...";
|
|
159
|
+
const filePath = shortenPathForPreview(rawPath);
|
|
160
|
+
const content = (args.content || "") as string;
|
|
161
|
+
const lines = content.split("\n").length;
|
|
162
|
+
return lines > 1 ? `write ${filePath} (${lines} lines)` : `write ${filePath}`;
|
|
163
|
+
}
|
|
164
|
+
case "edit": {
|
|
165
|
+
const rawPath = args.file_path || args.path || "...";
|
|
166
|
+
return `edit ${shortenPathForPreview(rawPath)}`;
|
|
167
|
+
}
|
|
168
|
+
case "ls": {
|
|
169
|
+
const rawPath = args.path || ".";
|
|
170
|
+
return `ls ${shortenPathForPreview(rawPath)}`;
|
|
171
|
+
}
|
|
172
|
+
case "find": {
|
|
173
|
+
const pattern = (args.pattern || "*") as string;
|
|
174
|
+
const rawPath = args.path || ".";
|
|
175
|
+
return `find ${pattern} in ${shortenPathForPreview(rawPath)}`;
|
|
176
|
+
}
|
|
177
|
+
case "grep": {
|
|
178
|
+
const pattern = (args.pattern || "") as string;
|
|
179
|
+
const rawPath = args.path || ".";
|
|
180
|
+
return `grep /${pattern}/ in ${shortenPathForPreview(rawPath)}`;
|
|
181
|
+
}
|
|
182
|
+
default: {
|
|
183
|
+
const argsStr = JSON.stringify(args);
|
|
184
|
+
const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
|
|
185
|
+
return `${toolName} ${preview}`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
package/group-pending.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable storage for grouped batch/chain pending completion summaries.
|
|
3
|
+
*
|
|
4
|
+
* This intentionally covers only finished group summaries that still need to be
|
|
5
|
+
* delivered back to the origin session. It does NOT attempt to resume in-flight
|
|
6
|
+
* batch/chain orchestration after reload or process restart.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { PendingCompletion } from "./types.js";
|
|
13
|
+
|
|
14
|
+
export type PendingGroupScope = "batch" | "chain";
|
|
15
|
+
|
|
16
|
+
export interface PersistedPendingGroupCompletion {
|
|
17
|
+
scope: PendingGroupScope;
|
|
18
|
+
groupId: string;
|
|
19
|
+
originSessionFile: string;
|
|
20
|
+
runIds: number[];
|
|
21
|
+
pendingCompletion: PendingCompletion;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getStateDir(): string {
|
|
25
|
+
return path.join(getAgentDir(), "state");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getPendingGroupsFile(): string {
|
|
29
|
+
return path.join(getStateDir(), "subagent-pending-groups.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ensureStateDir(): void {
|
|
33
|
+
fs.mkdirSync(getStateDir(), { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readPersistedEntries(): PersistedPendingGroupCompletion[] {
|
|
37
|
+
try {
|
|
38
|
+
if (!fs.existsSync(getPendingGroupsFile())) return [];
|
|
39
|
+
const raw = fs.readFileSync(getPendingGroupsFile(), "utf-8");
|
|
40
|
+
if (!raw.trim()) return [];
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
if (!Array.isArray(parsed)) return [];
|
|
43
|
+
return parsed.filter((entry): entry is PersistedPendingGroupCompletion => {
|
|
44
|
+
return Boolean(
|
|
45
|
+
entry &&
|
|
46
|
+
(entry.scope === "batch" || entry.scope === "chain") &&
|
|
47
|
+
typeof entry.groupId === "string" &&
|
|
48
|
+
typeof entry.originSessionFile === "string" &&
|
|
49
|
+
Array.isArray(entry.runIds) &&
|
|
50
|
+
entry.pendingCompletion &&
|
|
51
|
+
typeof entry.pendingCompletion.createdAt === "number",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writePersistedEntries(entries: PersistedPendingGroupCompletion[]): void {
|
|
60
|
+
ensureStateDir();
|
|
61
|
+
fs.writeFileSync(getPendingGroupsFile(), `${JSON.stringify(entries, null, 2)}\n`, "utf-8");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function upsertPendingGroupCompletion(entry: PersistedPendingGroupCompletion): void {
|
|
65
|
+
const entries = readPersistedEntries().filter(
|
|
66
|
+
(item) => !(item.scope === entry.scope && item.groupId === entry.groupId),
|
|
67
|
+
);
|
|
68
|
+
entries.push(entry);
|
|
69
|
+
writePersistedEntries(entries);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function clearPendingGroupCompletion(scope: PendingGroupScope, groupId: string): void {
|
|
73
|
+
const entries = readPersistedEntries().filter((entry) => !(entry.scope === scope && entry.groupId === groupId));
|
|
74
|
+
writePersistedEntries(entries);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function consumePendingGroupCompletionsForSession(sessionFile: string): PersistedPendingGroupCompletion[] {
|
|
78
|
+
const entries = readPersistedEntries();
|
|
79
|
+
const matched = entries.filter((entry) => entry.originSessionFile === sessionFile);
|
|
80
|
+
if (matched.length === 0) return [];
|
|
81
|
+
const remaining = entries.filter((entry) => entry.originSessionFile !== sessionFile);
|
|
82
|
+
writePersistedEntries(remaining);
|
|
83
|
+
return matched;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function evictStalePendingGroupCompletions(maxAgeMs: number): void {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const entries = readPersistedEntries().filter((entry) => now - entry.pendingCompletion.createdAt <= maxAgeMs);
|
|
89
|
+
writePersistedEntries(entries);
|
|
90
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent Tool - Delegate tasks to specialized agents
|
|
3
|
+
*
|
|
4
|
+
* Spawns a separate `pi` process for each subagent invocation,
|
|
5
|
+
* giving it an isolated context window.
|
|
6
|
+
*
|
|
7
|
+
* Supports a single run mode via the CLI-style `subagent` command.
|
|
8
|
+
*
|
|
9
|
+
* Uses JSON mode to capture structured output from subagents.
|
|
10
|
+
*
|
|
11
|
+
* Architecture:
|
|
12
|
+
* types.ts — Type definitions, interfaces, Typebox schemas
|
|
13
|
+
* store.ts — Shared state (SubagentStore) and state-mutation helpers
|
|
14
|
+
* format.ts — Token/usage/tool-call formatting utilities
|
|
15
|
+
* session.ts — Session file management and context helpers
|
|
16
|
+
* runner.ts — Subagent process execution, agent matching, concurrency
|
|
17
|
+
* replay.ts — Session replay viewer (TUI overlay)
|
|
18
|
+
* widget.ts — Run status widget (above-editor display)
|
|
19
|
+
* commands.ts — Tool handler, slash-commands, event handlers
|
|
20
|
+
* index.ts — Orchestrator (this file)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { cleanupPixelTimer } from "./above-widget.js";
|
|
25
|
+
import { registerAll } from "./commands.js";
|
|
26
|
+
import { HANG_CHECK_INTERVAL_MS, HANG_TIMEOUT_MS } from "./constants.js";
|
|
27
|
+
import { registerAskMasterTool } from "./escalation.js";
|
|
28
|
+
import { getSessionFileMtimeMs, readPersistedSessionSnapshot } from "./persisted-session.js";
|
|
29
|
+
import { getLastNonEmptyLine } from "./runner.js";
|
|
30
|
+
import { createStore, type SubagentStore } from "./store.js";
|
|
31
|
+
import type { CommandRunState } from "./types.js";
|
|
32
|
+
import { cleanupCommandRunsWidgetTimer, updateCommandRunsWidget } from "./widget.js";
|
|
33
|
+
|
|
34
|
+
function reconcileRunWithPersistedSession(run: CommandRunState): void {
|
|
35
|
+
if (!run.sessionFile) return;
|
|
36
|
+
|
|
37
|
+
const mtimeMs = getSessionFileMtimeMs(run.sessionFile);
|
|
38
|
+
if (mtimeMs && mtimeMs > run.lastActivityAt) {
|
|
39
|
+
run.lastActivityAt = mtimeMs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const snapshot = readPersistedSessionSnapshot(run.sessionFile, {
|
|
43
|
+
startOffset: run.persistedSessionBaseOffset,
|
|
44
|
+
});
|
|
45
|
+
if (snapshot.latestActivityAt && snapshot.latestActivityAt > run.lastActivityAt) {
|
|
46
|
+
run.lastActivityAt = snapshot.latestActivityAt;
|
|
47
|
+
}
|
|
48
|
+
if (!snapshot.isTerminal) return;
|
|
49
|
+
|
|
50
|
+
const exitCode =
|
|
51
|
+
snapshot.completionMarker?.exitCode ??
|
|
52
|
+
(snapshot.terminalStopReason === "error" || snapshot.terminalStopReason === "aborted" ? 1 : 0);
|
|
53
|
+
run.status = exitCode === 0 ? "done" : "error";
|
|
54
|
+
if (snapshot.finalOutput) {
|
|
55
|
+
run.lastOutput = snapshot.finalOutput;
|
|
56
|
+
run.lastLine = getLastNonEmptyLine(snapshot.finalOutput) || run.lastLine;
|
|
57
|
+
}
|
|
58
|
+
if (snapshot.latestActivityAt) {
|
|
59
|
+
run.elapsedMs = Math.max(run.elapsedMs, snapshot.latestActivityAt - run.startedAt);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Abort all session-scoped child processes before Pi invalidates this
|
|
65
|
+
* extension runtime. A non-triggering failure entry is persisted so returning
|
|
66
|
+
* to the old session explains why the run stopped.
|
|
67
|
+
*/
|
|
68
|
+
type SessionShutdownReason = "quit" | "reload" | "new" | "resume" | "fork";
|
|
69
|
+
|
|
70
|
+
export function shutdownSubagentRuns(store: SubagentStore, pi: ExtensionAPI, reason: SessionShutdownReason): void {
|
|
71
|
+
if (store.disposed) return;
|
|
72
|
+
store.disposed = true;
|
|
73
|
+
|
|
74
|
+
const activeRuns = new Map<number, CommandRunState>();
|
|
75
|
+
for (const [runId, run] of store.commandRuns) activeRuns.set(runId, run);
|
|
76
|
+
for (const [runId, entry] of store.globalLiveRuns) activeRuns.set(runId, entry.runState);
|
|
77
|
+
|
|
78
|
+
for (const [runId, run] of activeRuns) {
|
|
79
|
+
if (run.status !== "running") continue;
|
|
80
|
+
const message = `Aborted because the parent pi session ${reason} is shutting down.`;
|
|
81
|
+
run.status = "error";
|
|
82
|
+
run.elapsedMs = Date.now() - run.startedAt;
|
|
83
|
+
run.lastActivityAt = Date.now();
|
|
84
|
+
run.lastLine = message;
|
|
85
|
+
run.lastOutput = message;
|
|
86
|
+
run.removed = true;
|
|
87
|
+
|
|
88
|
+
const controller = run.abortController ?? store.globalLiveRuns.get(runId)?.abortController;
|
|
89
|
+
controller?.abort();
|
|
90
|
+
run.abortController = undefined;
|
|
91
|
+
|
|
92
|
+
if (run.deliveryMode === "humanOnly") continue;
|
|
93
|
+
try {
|
|
94
|
+
pi.sendMessage(
|
|
95
|
+
{
|
|
96
|
+
customType: run.source === "tool" ? "subagent-tool" : "subagent-command",
|
|
97
|
+
content: `[subagent:${run.agent}#${runId}] failed\n\n${message}`,
|
|
98
|
+
display: false,
|
|
99
|
+
details: {
|
|
100
|
+
runId,
|
|
101
|
+
agent: run.agent,
|
|
102
|
+
task: run.task,
|
|
103
|
+
displayTask: run.displayTask,
|
|
104
|
+
status: "error",
|
|
105
|
+
error: message,
|
|
106
|
+
startedAt: run.startedAt,
|
|
107
|
+
elapsedMs: run.elapsedMs,
|
|
108
|
+
lastActivityAt: run.lastActivityAt,
|
|
109
|
+
sessionFile: run.sessionFile,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{ deliverAs: "followUp", triggerTurn: false },
|
|
113
|
+
);
|
|
114
|
+
} catch {
|
|
115
|
+
// The runtime may already be closing; aborting the child remains the priority.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
store.globalLiveRuns.clear();
|
|
120
|
+
store.batchGroups.clear();
|
|
121
|
+
store.pipelines.clear();
|
|
122
|
+
store.recentLaunchTimestamps.clear();
|
|
123
|
+
store.commandRuns.clear();
|
|
124
|
+
store.commandWidgetCtx = null;
|
|
125
|
+
store.pixelWidgetCtx = null;
|
|
126
|
+
cleanupCommandRunsWidgetTimer();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Sweep active runs for inactivity. The normal run finalizer owns completion
|
|
131
|
+
* delivery so an auto-abort cannot emit two follow-up messages.
|
|
132
|
+
*/
|
|
133
|
+
export function checkForHungRuns(store: SubagentStore, _pi: ExtensionAPI): void {
|
|
134
|
+
if (store.disposed) return;
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
const processed = new Set<number>();
|
|
137
|
+
|
|
138
|
+
function tryAbort(runId: number, run: CommandRunState): void {
|
|
139
|
+
reconcileRunWithPersistedSession(run);
|
|
140
|
+
// Skip if already completed/aborted or not running
|
|
141
|
+
if (run.status !== "running") return;
|
|
142
|
+
if (!run.lastActivityAt) return;
|
|
143
|
+
// Guard: skip runs already auto-aborted (prevents duplicate abort/followUp)
|
|
144
|
+
if (run.lastLine?.startsWith("Auto-aborted:")) return;
|
|
145
|
+
|
|
146
|
+
const idleMs = now - run.lastActivityAt;
|
|
147
|
+
if (idleMs < HANG_TIMEOUT_MS) return;
|
|
148
|
+
|
|
149
|
+
// Try to abort via run's own controller, then globalLiveRuns fallback
|
|
150
|
+
const globalEntry = store.globalLiveRuns.get(runId);
|
|
151
|
+
const controller = run.abortController ?? globalEntry?.abortController;
|
|
152
|
+
|
|
153
|
+
const reason = `Auto-aborted: no activity for ${Math.round(idleMs / 1000)}s`;
|
|
154
|
+
run.lastLine = reason;
|
|
155
|
+
run.lastOutput = reason;
|
|
156
|
+
run.status = "error";
|
|
157
|
+
run.autoAbortReason = reason;
|
|
158
|
+
|
|
159
|
+
if (controller) {
|
|
160
|
+
controller.abort();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Sweep commandRuns first
|
|
165
|
+
for (const [runId, run] of store.commandRuns) {
|
|
166
|
+
processed.add(runId);
|
|
167
|
+
tryAbort(runId, run);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Sweep registry-only runs that are not present in the current widget view.
|
|
171
|
+
for (const [runId, entry] of store.globalLiveRuns) {
|
|
172
|
+
if (processed.has(runId)) continue;
|
|
173
|
+
tryAbort(runId, entry.runState);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
updateCommandRunsWidget(store);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export default function (pi: ExtensionAPI) {
|
|
180
|
+
const store = createStore();
|
|
181
|
+
registerAskMasterTool(pi);
|
|
182
|
+
registerAll(pi, store);
|
|
183
|
+
|
|
184
|
+
let hangCheckTimer: ReturnType<typeof setInterval> | undefined;
|
|
185
|
+
pi.on("session_start", () => {
|
|
186
|
+
store.disposed = false;
|
|
187
|
+
if (!hangCheckTimer) {
|
|
188
|
+
hangCheckTimer = setInterval(() => checkForHungRuns(store, pi), HANG_CHECK_INTERVAL_MS);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
pi.on("session_shutdown", async (event) => {
|
|
193
|
+
if (hangCheckTimer) {
|
|
194
|
+
clearInterval(hangCheckTimer);
|
|
195
|
+
hangCheckTimer = undefined;
|
|
196
|
+
}
|
|
197
|
+
shutdownSubagentRuns(store, pi, event.reason);
|
|
198
|
+
cleanupPixelTimer();
|
|
199
|
+
});
|
|
200
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { SUBAGENT_QUEUE_INTERVAL_MS } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
let startQueueTail: Promise<void> = Promise.resolve();
|
|
4
|
+
|
|
5
|
+
function sleep(ms: number): Promise<void> {
|
|
6
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Queue only subagent invocation starts through a single pacing queue.
|
|
11
|
+
*
|
|
12
|
+
* Each invocation waits for its start slot (fixed delay per slot), but once
|
|
13
|
+
* started the job runs independently so multiple subagents can execute in
|
|
14
|
+
* parallel.
|
|
15
|
+
*/
|
|
16
|
+
export function enqueueSubagentInvocation<T>(job: () => Promise<T>): Promise<T> {
|
|
17
|
+
const startGate = startQueueTail.then(
|
|
18
|
+
() => sleep(SUBAGENT_QUEUE_INTERVAL_MS),
|
|
19
|
+
() => sleep(SUBAGENT_QUEUE_INTERVAL_MS),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Important: only pace the next start slot. Do not wait for job completion.
|
|
23
|
+
startQueueTail = startGate.then(
|
|
24
|
+
() => undefined,
|
|
25
|
+
() => undefined,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return startGate.then(() => job());
|
|
29
|
+
}
|
package/live-preview.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { formatToolCallPlain } from "./format.js";
|
|
2
|
+
|
|
3
|
+
const THOUGHT_PREVIEW_MAX_CHARS = 80;
|
|
4
|
+
const ACTIVITY_PREVIEW_MAX_CHARS = 240;
|
|
5
|
+
|
|
6
|
+
function getFirstNonEmptyLine(raw: string): string | undefined {
|
|
7
|
+
return raw
|
|
8
|
+
.split("\n")
|
|
9
|
+
.map((line) => line.trim())
|
|
10
|
+
.filter(Boolean)[0];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function extractThoughtText(raw: string): string | undefined {
|
|
14
|
+
const firstLine = getFirstNonEmptyLine(raw);
|
|
15
|
+
if (!firstLine) return undefined;
|
|
16
|
+
|
|
17
|
+
const clean = firstLine
|
|
18
|
+
.replace(/^#+\s*/, "")
|
|
19
|
+
.replace(/\*{1,3}([^*]+)\*{1,3}/g, "$1")
|
|
20
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
21
|
+
.trim();
|
|
22
|
+
if (!clean) return undefined;
|
|
23
|
+
return clean.slice(0, THOUGHT_PREVIEW_MAX_CHARS);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function extractActivityPreviewFromTextDelta(raw: string): string | undefined {
|
|
27
|
+
const lastLine = raw
|
|
28
|
+
.split("\n")
|
|
29
|
+
.map((line) => line.trim())
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.pop();
|
|
32
|
+
if (!lastLine) return undefined;
|
|
33
|
+
return lastLine.slice(0, ACTIVITY_PREVIEW_MAX_CHARS);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatPiToolExecutionPreview(toolName: string, args: unknown): string {
|
|
37
|
+
const normalizedArgs = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
|
38
|
+
return `→ ${formatToolCallPlain(toolName, normalizedArgs)}`;
|
|
39
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ryan_nookpi/pi-extension-subagent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Asynchronous subagent delegation for pi with run, batch, chain, and continuation workflows.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/Jonghakseo/pi-extension.git",
|
|
9
|
+
"directory": "packages/subagent"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/Jonghakseo/pi-extension/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/Jonghakseo/pi-extension/tree/main/packages/subagent#readme",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"subagent",
|
|
19
|
+
"delegation"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"above-widget.ts",
|
|
23
|
+
"agents.ts",
|
|
24
|
+
"claude-args.ts",
|
|
25
|
+
"claude-sdk-runner.ts",
|
|
26
|
+
"claude-sdk-stream-adapter.ts",
|
|
27
|
+
"claude-sidecar-writer.ts",
|
|
28
|
+
"claude-stream-parser.ts",
|
|
29
|
+
"cli.ts",
|
|
30
|
+
"commands.ts",
|
|
31
|
+
"config.ts",
|
|
32
|
+
"constants.ts",
|
|
33
|
+
"context-limits.ts",
|
|
34
|
+
"display-task.ts",
|
|
35
|
+
"escalation.ts",
|
|
36
|
+
"format.ts",
|
|
37
|
+
"group-pending.ts",
|
|
38
|
+
"index.ts",
|
|
39
|
+
"invocation-queue.ts",
|
|
40
|
+
"live-preview.ts",
|
|
41
|
+
"persisted-session.ts",
|
|
42
|
+
"replay.ts",
|
|
43
|
+
"retry.ts",
|
|
44
|
+
"run-utils.ts",
|
|
45
|
+
"runner.ts",
|
|
46
|
+
"session.ts",
|
|
47
|
+
"store.ts",
|
|
48
|
+
"tool-execute.ts",
|
|
49
|
+
"tool-render.ts",
|
|
50
|
+
"types.ts",
|
|
51
|
+
"widget.ts",
|
|
52
|
+
"utils/agent-utils.ts",
|
|
53
|
+
"utils/short-label.ts",
|
|
54
|
+
"utils/string-utils.ts",
|
|
55
|
+
"utils/format-utils.ts",
|
|
56
|
+
"utils/time-utils.ts",
|
|
57
|
+
"README.md"
|
|
58
|
+
],
|
|
59
|
+
"pi": {
|
|
60
|
+
"extensions": [
|
|
61
|
+
"./index.ts"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.98",
|
|
66
|
+
"yaml": "^2.9.0"
|
|
67
|
+
},
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"@earendil-works/pi-ai": "*",
|
|
70
|
+
"@earendil-works/pi-agent-core": "*",
|
|
71
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
72
|
+
"@earendil-works/pi-tui": "*",
|
|
73
|
+
"typebox": "*"
|
|
74
|
+
},
|
|
75
|
+
"peerDependenciesMeta": {
|
|
76
|
+
"@earendil-works/pi-ai": {
|
|
77
|
+
"optional": true
|
|
78
|
+
},
|
|
79
|
+
"@earendil-works/pi-agent-core": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
82
|
+
"@earendil-works/pi-coding-agent": {
|
|
83
|
+
"optional": true
|
|
84
|
+
},
|
|
85
|
+
"@earendil-works/pi-tui": {
|
|
86
|
+
"optional": true
|
|
87
|
+
},
|
|
88
|
+
"typebox": {
|
|
89
|
+
"optional": true
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"publishConfig": {
|
|
93
|
+
"access": "public"
|
|
94
|
+
}
|
|
95
|
+
}
|