@xynogen/pix-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/src/index.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * index.ts — pix-subagent extension entry point.
3
+ *
4
+ * Registers 3 LLM-callable tools (agent, agent_result, agent_steer),
5
+ * a live above-editor widget (model shown inline), and a themed
6
+ * subagent-notification renderer.
7
+ *
8
+ * Best-of-both:
9
+ * - tintinweb/pi-subagents spawn engine (MIT) — battle-tested createAgentSession path
10
+ * - nicobailon/pi-subagents explicit work-splitting (allowed_tools[], model param)
11
+ * - pix twist: model name ALWAYS visible in widget + notification
12
+ */
13
+
14
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ import { AgentManager } from "./agent-manager.ts";
16
+ import { registerAgents } from "./agent-types.ts";
17
+ import { loadCustomAgents } from "./custom-agents.ts";
18
+ import { listAvailable } from "./model-resolver.ts";
19
+ import {
20
+ type AgentActivity,
21
+ createAgentResultTool,
22
+ createAgentSteerTool,
23
+ createAgentTool,
24
+ } from "./tools.ts";
25
+ import type { NotificationDetails } from "./types.ts";
26
+ import { registerNotificationRenderer } from "./ui/notification.ts";
27
+ import { AgentWidget } from "./ui/widget.ts";
28
+ import { getLifetimeTotal } from "./usage.ts";
29
+
30
+ const EXTENSION_KEY = "pix-subagent";
31
+
32
+ // Reload guard key — stored on globalThis so a dev-reload cleans up stale state
33
+ const CLEANUP_KEY = `__${EXTENSION_KEY}Cleanup`;
34
+
35
+ export default function registerPixSubagent(pi: ExtensionAPI): void {
36
+ // ── Cleanup stale timers from a prior reload ───────────────────────────────
37
+ const g = globalThis as Record<string, unknown>;
38
+ const prevCleanup = g[CLEANUP_KEY];
39
+ if (typeof prevCleanup === "function") {
40
+ try {
41
+ (prevCleanup as () => void)();
42
+ } catch {
43
+ /* best effort */
44
+ }
45
+ }
46
+
47
+ // ── Agent registry ─────────────────────────────────────────────────────────
48
+ const reloadCustomAgents = () => {
49
+ const userAgents = loadCustomAgents(process.cwd());
50
+ registerAgents(userAgents);
51
+ };
52
+ reloadCustomAgents();
53
+
54
+ // ── State ──────────────────────────────────────────────────────────────────
55
+ const agentActivity = new Map<string, AgentActivity>();
56
+
57
+ // Debounce: brief hold so agent_result can cancel a notification it just consumed
58
+ const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
59
+ const NUDGE_HOLD_MS = 200;
60
+
61
+ function scheduleNudge(key: string, send: () => void) {
62
+ cancelNudge(key);
63
+ pendingNudges.set(
64
+ key,
65
+ setTimeout(() => {
66
+ pendingNudges.delete(key);
67
+ try {
68
+ send();
69
+ } catch {
70
+ /* ignore stale context errors */
71
+ }
72
+ }, NUDGE_HOLD_MS),
73
+ );
74
+ }
75
+
76
+ function cancelNudge(key: string) {
77
+ const t = pendingNudges.get(key);
78
+ if (t != null) {
79
+ clearTimeout(t);
80
+ pendingNudges.delete(key);
81
+ }
82
+ }
83
+
84
+ // ── AgentManager ──────────────────────────────────────────────────────────
85
+ const manager = new AgentManager(
86
+ // onComplete — fire subagent-notification nudge for each finished bg agent
87
+ (record) => {
88
+ agentActivity.delete(record.id);
89
+ widget.markFinished(record.id);
90
+
91
+ if (record.resultConsumed) {
92
+ widget.update();
93
+ return;
94
+ }
95
+
96
+ const totalTokens = getLifetimeTotal(record.lifetimeUsage);
97
+ const activity = agentActivity.get(record.id);
98
+ const resultPreview = record.result
99
+ ? record.result.length > 500
100
+ ? `${record.result.slice(0, 500)}…`
101
+ : record.result
102
+ : "No output.";
103
+
104
+ const details: NotificationDetails = {
105
+ id: record.id,
106
+ description: record.description,
107
+ status: record.status,
108
+ modelName: record.invocation?.modelName,
109
+ toolUses: record.toolUses,
110
+ turnCount: activity?.turnCount ?? 0,
111
+ maxTurns: activity?.maxTurns,
112
+ totalTokens,
113
+ durationMs: record.completedAt
114
+ ? record.completedAt - record.startedAt
115
+ : 0,
116
+ error: record.error,
117
+ resultPreview,
118
+ };
119
+
120
+ scheduleNudge(record.id, () => {
121
+ if (record.resultConsumed) {
122
+ widget.update();
123
+ return;
124
+ }
125
+ pi.sendMessage<NotificationDetails>(
126
+ {
127
+ customType: "subagent-notification",
128
+ content: `Agent "${record.description}" ${record.status}.`,
129
+ display: true,
130
+ details,
131
+ },
132
+ { deliverAs: "followUp", triggerTurn: true },
133
+ );
134
+ });
135
+
136
+ widget.update();
137
+ },
138
+ 4, // maxConcurrent
139
+ // onStart
140
+ (_record) => {
141
+ widget.update();
142
+ },
143
+ );
144
+
145
+ // ── Widget ─────────────────────────────────────────────────────────────────
146
+ const widget = new AgentWidget(manager, agentActivity);
147
+
148
+ // ── Register renderers ─────────────────────────────────────────────────────
149
+ registerNotificationRenderer(pi);
150
+
151
+ // ── Build initial tool description with model list ─────────────────────────
152
+ // Model list is empty until session_start provides a modelRegistry.
153
+ // Tools are registered once; the description is rebuilt on session_start via
154
+ // re-registering (pi.registerTool replaces by name — verify this at smoke test).
155
+ // ponytail: if re-register isn't supported mid-session, the list stays empty
156
+ // first session; acceptable until we find a hook to refresh.
157
+ let currentModelList: string[] = [];
158
+
159
+ function registerTools() {
160
+ pi.registerTool(
161
+ createAgentTool(
162
+ pi as Parameters<typeof manager.spawnAndWait>[0],
163
+ manager,
164
+ agentActivity,
165
+ reloadCustomAgents,
166
+ currentModelList,
167
+ ),
168
+ );
169
+ pi.registerTool(createAgentResultTool(manager, agentActivity));
170
+ pi.registerTool(createAgentSteerTool(manager));
171
+ }
172
+
173
+ registerTools();
174
+
175
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
176
+ pi.on("session_start", (_event, ctx) => {
177
+ manager.clearCompleted();
178
+ agentActivity.clear();
179
+
180
+ // Refresh model list from live registry
181
+ const newList = listAvailable(ctx.modelRegistry);
182
+ if (newList.join(",") !== currentModelList.join(",")) {
183
+ currentModelList = newList;
184
+ // Re-register tools with fresh description
185
+ // ponytail: if pi throws on duplicate tool names, skip the re-register
186
+ try {
187
+ registerTools();
188
+ } catch {
189
+ /* description may be stale; non-fatal */
190
+ }
191
+ }
192
+ });
193
+
194
+ pi.on("tool_execution_start", (_event, ctx) => {
195
+ widget.setUICtx(ctx.ui as Parameters<typeof widget.setUICtx>[0]);
196
+ widget.onTurnStart();
197
+ widget.ensureTimer();
198
+ });
199
+
200
+ pi.on("session_shutdown", () => {
201
+ for (const t of pendingNudges.values()) clearTimeout(t);
202
+ pendingNudges.clear();
203
+ manager.abortAll();
204
+ manager.dispose();
205
+ widget.dispose();
206
+ agentActivity.clear();
207
+ if (g[CLEANUP_KEY] === runtimeCleanup) delete g[CLEANUP_KEY];
208
+ });
209
+
210
+ const runtimeCleanup = () => {
211
+ for (const t of pendingNudges.values()) clearTimeout(t);
212
+ pendingNudges.clear();
213
+ widget.dispose();
214
+ };
215
+ g[CLEANUP_KEY] = runtimeCleanup;
216
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * invocation-config.ts — Resolve per-call agent invocation options.
3
+ *
4
+ * Ported from tintinweb/pi-subagents (MIT). Trimmed: dropped isolation/joinMode.
5
+ */
6
+
7
+ import type { AgentConfig, ThinkingLevel } from "./types.ts";
8
+
9
+ interface AgentInvocationParams {
10
+ model?: string;
11
+ thinking?: string;
12
+ max_turns?: number;
13
+ run_in_background?: boolean;
14
+ inherit_context?: boolean;
15
+ isolated?: boolean;
16
+ }
17
+
18
+ export function resolveAgentInvocationConfig(
19
+ agentConfig: AgentConfig | undefined,
20
+ params: AgentInvocationParams,
21
+ ): {
22
+ modelInput?: string;
23
+ modelFromParams: boolean;
24
+ thinking?: ThinkingLevel;
25
+ maxTurns?: number;
26
+ inheritContext: boolean;
27
+ runInBackground: boolean;
28
+ isolated: boolean;
29
+ } {
30
+ return {
31
+ modelInput: agentConfig?.model ?? params.model,
32
+ modelFromParams: agentConfig?.model == null && params.model != null,
33
+ thinking: (agentConfig?.thinking ?? params.thinking) as
34
+ | ThinkingLevel
35
+ | undefined,
36
+ maxTurns: agentConfig?.maxTurns ?? params.max_turns,
37
+ inheritContext:
38
+ agentConfig?.inheritContext ?? params.inherit_context ?? false,
39
+ runInBackground:
40
+ agentConfig?.runInBackground ?? params.run_in_background ?? false,
41
+ isolated: agentConfig?.isolated ?? params.isolated ?? false,
42
+ };
43
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Model resolution: exact match ("provider/modelId") with fuzzy fallback.
3
+ */
4
+
5
+ export interface ModelEntry {
6
+ id: string;
7
+ name: string;
8
+ provider: string;
9
+ }
10
+
11
+ export interface ModelRegistry {
12
+ find(provider: string, modelId: string): any;
13
+ getAll(): any[];
14
+ getAvailable?(): any[];
15
+ }
16
+
17
+ /**
18
+ * Resolve a model string to a Model instance.
19
+ * Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
20
+ * Returns the Model on success, or an error message string on failure.
21
+ */
22
+ export function resolveModel(
23
+ input: string,
24
+ registry: ModelRegistry,
25
+ ): any | string {
26
+ // Available models (those with auth configured)
27
+ const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
28
+ const availableSet = new Set(
29
+ all.map((m) => `${m.provider}/${m.id}`.toLowerCase()),
30
+ );
31
+
32
+ // 1. Exact match: "provider/modelId" — only if available (has auth)
33
+ const slashIdx = input.indexOf("/");
34
+ if (slashIdx !== -1) {
35
+ const provider = input.slice(0, slashIdx);
36
+ const modelId = input.slice(slashIdx + 1);
37
+ if (availableSet.has(input.toLowerCase())) {
38
+ const found = registry.find(provider, modelId);
39
+ if (found) return found;
40
+ }
41
+ }
42
+
43
+ // 2. Fuzzy match against available models
44
+ const query = input.toLowerCase();
45
+
46
+ // Score each model: prefer exact id match > id contains > name contains > provider+id contains
47
+ let bestMatch: ModelEntry | undefined;
48
+ let bestScore = 0;
49
+
50
+ for (const m of all) {
51
+ const id = m.id.toLowerCase();
52
+ const name = m.name.toLowerCase();
53
+ const full = `${m.provider}/${m.id}`.toLowerCase();
54
+
55
+ let score = 0;
56
+ if (id === query || full === query) {
57
+ score = 100; // exact
58
+ } else if (id.includes(query) || full.includes(query)) {
59
+ score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
60
+ } else if (name.includes(query)) {
61
+ score = 40 + (query.length / name.length) * 20;
62
+ } else if (
63
+ query
64
+ .split(/[\s\-/]+/)
65
+ .every(
66
+ (part) =>
67
+ id.includes(part) ||
68
+ name.includes(part) ||
69
+ m.provider.toLowerCase().includes(part),
70
+ )
71
+ ) {
72
+ score = 20; // all parts present somewhere
73
+ }
74
+
75
+ if (score > bestScore) {
76
+ bestScore = score;
77
+ bestMatch = m;
78
+ }
79
+ }
80
+
81
+ if (bestMatch && bestScore >= 20) {
82
+ const found = registry.find(bestMatch.provider, bestMatch.id);
83
+ if (found) return found;
84
+ }
85
+
86
+ // 3. No match — list available models
87
+ const modelList = all
88
+ .map((m) => ` ${m.provider}/${m.id}`)
89
+ .sort()
90
+ .join("\n");
91
+ return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
92
+ }
93
+
94
+ /** List available models as "provider/id" strings (for tool-description injection). */
95
+ export function listAvailable(registry: ModelRegistry): string[] {
96
+ const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
97
+ return all.map((m) => `${m.provider}/${m.id}`).sort();
98
+ }
package/src/once.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Per-instance idempotency guard for extension activation.
3
+ *
4
+ * pix-core (the meta-package) invokes this package's factory, and a standalone
5
+ * install makes Pi invoke it again — sometimes against the SAME `pi`. We must
6
+ * dedupe that. But Pi rebuilds the extension runtime on /new, /resume, /fork,
7
+ * and /reload, handing the factory a BRAND-NEW `pi`; that must re-register.
8
+ *
9
+ * Keying the registry on the `pi` instance satisfies both: same instance =>
10
+ * skip, new instance => run. The registry lives on globalThis because jiti
11
+ * (`moduleCache: false`) re-evaluates this module on every load pass, so a
12
+ * module-scoped WeakMap would not be shared between the aggregator pass and the
13
+ * standalone pass within a single session.
14
+ */
15
+ export function once(pi: object, key: string, fn: () => void): void {
16
+ const g = globalThis as { __pixOnce?: WeakMap<object, Set<string>> };
17
+ const registry = (g.__pixOnce ??= new WeakMap<object, Set<string>>());
18
+ let loaded = registry.get(pi);
19
+ if (!loaded) {
20
+ loaded = new Set<string>();
21
+ registry.set(pi, loaded);
22
+ }
23
+ if (loaded.has(key)) return;
24
+ loaded.add(key);
25
+ fn();
26
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * prompts.ts — System prompt builder for agents.
3
+ */
4
+
5
+ import type { AgentConfig, EnvInfo } from "./types.ts";
6
+
7
+ /** Extra sections to inject into the system prompt (memory, skills, etc.). */
8
+ export interface PromptExtras {
9
+ /** Persistent memory content to inject (first 200 lines of MEMORY.md + instructions). */
10
+ memoryBlock?: string;
11
+ /** Preloaded skill contents to inject. */
12
+ skillBlocks?: { name: string; content: string }[];
13
+ }
14
+
15
+ /**
16
+ * Build the system prompt for an agent from its config.
17
+ *
18
+ * - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
19
+ * - "append" mode: parent system prompt + sub-agent context + env header + config.systemPrompt
20
+ * - "append" with empty systemPrompt: pure parent clone
21
+ *
22
+ * Both modes include an `<active_agent name="${config.name}"/>` tag so downstream
23
+ * extensions (e.g. permission/policy systems) can resolve per-agent policy
24
+ * inside the child session by parsing the system prompt. In replace mode the tag
25
+ * is prepended; in append mode it follows the shared inherited content so the
26
+ * parent prompt forms an identical, cacheable byte prefix with the parent
27
+ * session (the LLM's KV cache can then reuse those tokens across every spawn).
28
+ *
29
+ * @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
30
+ * @param extras Optional extra sections to inject (memory, preloaded skills).
31
+ */
32
+ export function buildAgentPrompt(
33
+ config: AgentConfig,
34
+ cwd: string,
35
+ env: EnvInfo,
36
+ parentSystemPrompt?: string,
37
+ extras?: PromptExtras,
38
+ ): string {
39
+ const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
40
+
41
+ const envBlock = `# Environment
42
+ Working directory: ${cwd}
43
+ ${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
44
+ Platform: ${env.platform}`;
45
+
46
+ // Build optional extras suffix
47
+ const extraSections: string[] = [];
48
+ if (extras?.memoryBlock) {
49
+ extraSections.push(extras.memoryBlock);
50
+ }
51
+ if (extras?.skillBlocks?.length) {
52
+ for (const skill of extras.skillBlocks) {
53
+ extraSections.push(
54
+ `\n# Preloaded Skill: ${skill.name}\n${skill.content}`,
55
+ );
56
+ }
57
+ }
58
+ const extrasSuffix =
59
+ extraSections.length > 0 ? `\n\n${extraSections.join("\n")}` : "";
60
+
61
+ if (config.promptMode === "append") {
62
+ const identity = parentSystemPrompt || genericBase;
63
+
64
+ const bridge = `<sub_agent_context>
65
+ You are operating as a sub-agent invoked to handle a specific task.
66
+ - Use the read tool instead of cat/head/tail
67
+ - Use the edit tool instead of sed/awk
68
+ - Use the write tool instead of echo/heredoc
69
+ - Use the find tool instead of bash find/ls for file search
70
+ - Use the grep tool instead of bash grep/rg for content search
71
+ - Make independent tool calls in parallel
72
+ - Use absolute file paths
73
+ - Do not use emojis
74
+ - Be concise but complete
75
+ </sub_agent_context>`;
76
+
77
+ const customSection = config.systemPrompt?.trim()
78
+ ? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
79
+ : "";
80
+
81
+ // Place shared/stable content first so the LLM's KV cache can reuse the
82
+ // inherited prefix across all subagent invocations. The parent prompt is
83
+ // placed verbatim (no wrapper tag) so it forms an identical byte prefix
84
+ // with the parent session, maximising KV cache hits. The <active_agent>
85
+ // tag and env block vary per call and are placed after the cached prefix.
86
+ return (
87
+ identity +
88
+ "\n\n" +
89
+ bridge +
90
+ "\n\n" +
91
+ activeAgentTag +
92
+ envBlock +
93
+ customSection +
94
+ extrasSuffix
95
+ );
96
+ }
97
+
98
+ // "replace" mode — env header + the config's full system prompt
99
+ const replaceHeader = `You are a pi coding agent sub-agent.
100
+ You have been invoked to handle a specific task autonomously.
101
+
102
+ ${envBlock}`;
103
+
104
+ return `${activeAgentTag + replaceHeader}\n\n${config.systemPrompt}${extrasSuffix}`;
105
+ }
106
+
107
+ /** Fallback base prompt when parent system prompt is unavailable in append mode. */
108
+ const genericBase = `# Role
109
+ You are a general-purpose coding agent for complex, multi-step tasks.
110
+ You have full access to read, write, edit files, and execute commands.
111
+ Do what has been asked; nothing more, nothing less.`;