@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/tool-render.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Rendering functions for the subagent tool's renderCall / renderResult.
3
+ *
4
+ * Extracted from commands.ts — output format is identical.
5
+ */
6
+
7
+ import { getMarkdownTheme, type ThemeColor } from "@earendil-works/pi-coding-agent";
8
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
9
+ import { formatToolCall, formatUsageStats } from "./format.js";
10
+ import { getDisplayItems, getFinalOutput } from "./runner.js";
11
+ import { COLLAPSED_ITEM_COUNT } from "./store.js";
12
+ import type { DisplayItem, SubagentDetails } from "./types.js";
13
+
14
+ // ─── Helpers (internal) ──────────────────────────────────────────────────────
15
+
16
+ type RenderTheme = {
17
+ fg: (color: ThemeColor, text: string) => string;
18
+ bold: (text: string) => string;
19
+ };
20
+
21
+ type ToolRenderResult = {
22
+ details?: unknown;
23
+ content: Array<{ type?: string; text?: string }>;
24
+ };
25
+
26
+ type ToolRenderArgs = { command?: unknown };
27
+
28
+ function renderDisplayItems(items: DisplayItem[], expanded: boolean, theme: RenderTheme, limit?: number): string {
29
+ const toShow = limit ? items.slice(-limit) : items;
30
+ const skipped = limit && items.length > limit ? items.length - limit : 0;
31
+ let text = "";
32
+ if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
33
+ for (const item of toShow) {
34
+ if (item.type === "text") {
35
+ const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
36
+ text += `${theme.fg("toolOutput", preview)}\n`;
37
+ } else {
38
+ text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
39
+ }
40
+ }
41
+ return text.trimEnd();
42
+ }
43
+
44
+ // ─── renderCall ──────────────────────────────────────────────────────────────
45
+
46
+ export function renderSubagentToolCall(args: ToolRenderArgs, theme: RenderTheme) {
47
+ const raw = typeof args.command === "string" ? args.command.trim() : "";
48
+ const command = raw || "subagent help";
49
+ const MAX_CALL_LINES = 5;
50
+ const commandLines = command.split("\n");
51
+ const truncated = commandLines.length > MAX_CALL_LINES;
52
+ const preview = truncated ? commandLines.slice(0, MAX_CALL_LINES).join("\n") : command;
53
+
54
+ let text = theme.fg("toolTitle", theme.bold("subagent ")) + theme.fg("accent", "cli");
55
+ text += `\n ${theme.fg("dim", preview)}`;
56
+ if (truncated) text += `\n ${theme.fg("muted", `... +${commandLines.length - MAX_CALL_LINES} more lines`)}`;
57
+ return new Text(text, 0, 0);
58
+ }
59
+
60
+ // ─── renderResult ────────────────────────────────────────────────────────────
61
+
62
+ export function renderSubagentToolResult(
63
+ result: ToolRenderResult,
64
+ { expanded }: { expanded: boolean },
65
+ theme: RenderTheme,
66
+ ) {
67
+ const details = result.details as SubagentDetails | undefined;
68
+ if (!details || details.results.length === 0) {
69
+ const raw = result.content[0];
70
+ const fullText = (raw?.type === "text" ? raw.text : undefined) ?? "(no output)";
71
+ if (!expanded) {
72
+ const firstLine = fullText.split("\n")[0] ?? "";
73
+ const lineCount = fullText.split("\n").length;
74
+ const suffix = lineCount > 1 ? theme.fg("muted", ` (+${lineCount - 1} lines)`) : "";
75
+ return new Text(firstLine + suffix, 0, 0);
76
+ }
77
+ return new Text(fullText, 0, 0);
78
+ }
79
+
80
+ const mdTheme = getMarkdownTheme();
81
+ const r = details.results[0];
82
+ if (!r) {
83
+ const raw2 = result.content[0];
84
+ const fullText2 = (raw2?.type === "text" ? raw2.text : undefined) ?? "(no output)";
85
+ if (!expanded) {
86
+ const firstLine = fullText2.split("\n")[0] ?? "";
87
+ const lineCount = fullText2.split("\n").length;
88
+ const suffix = lineCount > 1 ? theme.fg("muted", ` (+${lineCount - 1} lines)`) : "";
89
+ return new Text(firstLine + suffix, 0, 0);
90
+ }
91
+ return new Text(fullText2, 0, 0);
92
+ }
93
+
94
+ const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
95
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
96
+ const displayItems = getDisplayItems(r.messages);
97
+ const finalOutput = getFinalOutput(r.messages);
98
+
99
+ if (expanded) {
100
+ const container = new Container();
101
+ let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
102
+ if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
103
+ container.addChild(new Text(header, 0, 0));
104
+ if (isError && r.errorMessage) container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
105
+ container.addChild(new Spacer(1));
106
+ container.addChild(new Text(theme.fg("muted", "task:"), 0, 0));
107
+ container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
108
+ container.addChild(new Spacer(1));
109
+ container.addChild(new Text(theme.fg("muted", "──────────────"), 0, 0));
110
+ container.addChild(new Spacer(1));
111
+ if (displayItems.length === 0 && !finalOutput) {
112
+ container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
113
+ } else {
114
+ for (const item of displayItems) {
115
+ if (item.type === "toolCall") {
116
+ container.addChild(
117
+ new Text(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0),
118
+ );
119
+ }
120
+ }
121
+ if (finalOutput) {
122
+ container.addChild(new Spacer(1));
123
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
124
+ }
125
+ }
126
+ const usageStr = formatUsageStats(r.usage, r.model);
127
+ if (usageStr) {
128
+ container.addChild(new Spacer(1));
129
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
130
+ }
131
+ return container;
132
+ }
133
+
134
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
135
+ if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
136
+ if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
137
+ else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
138
+ else {
139
+ text += `\n${renderDisplayItems(displayItems, expanded, theme, COLLAPSED_ITEM_COUNT)}`;
140
+ if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
141
+ }
142
+ const usageStr = formatUsageStats(r.usage, r.model);
143
+ if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
144
+ return new Text(text, 0, 0);
145
+ }
package/types.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Type definitions, interfaces, and Typebox schemas for the Subagent tool.
3
+ */
4
+
5
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
6
+ import type { Message } from "@earendil-works/pi-ai";
7
+ import { Type } from "typebox";
8
+ import type { AgentConfig, AgentRuntime } from "./agents.js";
9
+
10
+ // ─── Interfaces ──────────────────────────────────────────────────────────────
11
+
12
+ export interface UsageStats {
13
+ input: number;
14
+ output: number;
15
+ cacheRead: number;
16
+ cacheWrite: number;
17
+ cost: number;
18
+ contextTokens: number;
19
+ turns: number;
20
+ }
21
+
22
+ export interface SingleResult {
23
+ agent: string;
24
+ agentSource: "user" | "project" | "unknown";
25
+ task: string;
26
+ exitCode: number;
27
+ messages: Message[];
28
+ stderr: string;
29
+ usage: UsageStats;
30
+ model?: string;
31
+ stopReason?: string;
32
+ errorMessage?: string;
33
+ step?: number;
34
+ liveText?: string;
35
+ liveThinking?: string;
36
+ liveToolCalls?: number;
37
+ thoughtText?: string;
38
+ sessionFile?: string;
39
+ runtime?: AgentRuntime;
40
+ claudeSessionId?: string;
41
+ claudeProjectDir?: string;
42
+ liveActivityPreview?: string;
43
+ }
44
+
45
+ export interface BatchOrChainItem {
46
+ agent: string;
47
+ task: string;
48
+ }
49
+
50
+ export interface SubagentLaunchSummary {
51
+ agent: string;
52
+ mode: "run" | "continue" | "batch" | "chain";
53
+ runId?: number;
54
+ batchId?: string;
55
+ pipelineId?: string;
56
+ stepIndex?: number;
57
+ }
58
+
59
+ export interface SubagentDetails {
60
+ mode: "single" | "batch" | "chain";
61
+ inheritMainContext: boolean;
62
+ projectAgentsDir: string | null;
63
+ results: SingleResult[];
64
+ launches?: SubagentLaunchSummary[];
65
+ }
66
+
67
+ export interface CommandRunState {
68
+ id: number;
69
+ agent: string;
70
+ task: string;
71
+ displayTask?: string;
72
+ status: "running" | "done" | "error";
73
+ startedAt: number;
74
+ elapsedMs: number;
75
+ toolCalls: number;
76
+ lastLine: string;
77
+ lastOutput?: string;
78
+ continuedFromRunId?: number;
79
+ turnCount: number;
80
+ sessionFile?: string;
81
+ persistedSessionBaseOffset?: number;
82
+ abortController?: AbortController;
83
+ usage?: UsageStats;
84
+ model?: string;
85
+ removed?: boolean;
86
+ contextMode?: "main" | "sub";
87
+ thoughtText?: string;
88
+ /** Timestamp of last detected activity (tool call / turn / liveText change). Used for hang detection. */
89
+ lastActivityAt: number;
90
+ /** Number of auto-retries already attempted for this run. */
91
+ retryCount?: number;
92
+ /** Last transient failure reason that triggered an auto-retry. */
93
+ lastRetryReason?: string;
94
+ /** Hang detector reason preserved until the normal finalizer emits the sole completion. */
95
+ autoAbortReason?: string;
96
+ runtime?: AgentRuntime;
97
+ claudeSessionId?: string;
98
+ claudeProjectDir?: string;
99
+ /** Origin of this run: "tool" = LLM called subagent tool, "command" = user slash-command / >> shorthand. */
100
+ source?: "tool" | "command";
101
+ /** Optional batch group id for tool-level grouped parallel launches. */
102
+ batchId?: string;
103
+ /** Optional pipeline id for tool-level sequential launches. */
104
+ pipelineId?: string;
105
+ /** Zero-based step index inside batch/pipeline metadata. */
106
+ pipelineStepIndex?: number;
107
+ /** How completion/start events should be surfaced back to the user. */
108
+ deliveryMode?: "followUp" | "humanOnly";
109
+ }
110
+
111
+ export interface SessionReplayItem {
112
+ type: "user" | "assistant" | "tool";
113
+ title: string;
114
+ content: string;
115
+ timestamp: Date;
116
+ elapsed?: string;
117
+ }
118
+
119
+ export type DisplayItem =
120
+ | { type: "text"; text: string }
121
+ | { type: "toolCall"; name: string; args: Record<string, unknown> };
122
+
123
+ export interface AgentAliasMatch {
124
+ matchedAgent?: AgentConfig;
125
+ ambiguousAgents: AgentConfig[];
126
+ }
127
+
128
+ export type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
129
+
130
+ /**
131
+ * Pending completion message stored when a run finishes while the user
132
+ * is in a different session from where the run originated.
133
+ */
134
+ export interface PendingCompletion {
135
+ message: {
136
+ customType: string;
137
+ content: string;
138
+ display: boolean;
139
+ details: Record<string, unknown>;
140
+ };
141
+ options: {
142
+ deliverAs: "followUp";
143
+ triggerTurn?: boolean;
144
+ };
145
+ createdAt: number;
146
+ }
147
+
148
+ /** Live child process state owned by the current Pi extension runtime. */
149
+ export interface GlobalRunEntry {
150
+ runState: CommandRunState;
151
+ abortController: AbortController;
152
+ originSessionFile: string;
153
+ /** Set when the run completes while the user is in a different session. */
154
+ pendingCompletion?: PendingCompletion;
155
+ }
156
+
157
+ export interface BatchGroupState {
158
+ batchId: string;
159
+ runIds: number[];
160
+ completedRunIds: Set<number>;
161
+ failedRunIds: Set<number>;
162
+ originSessionFile: string;
163
+ createdAt: number;
164
+ pendingResults: Map<number, string>;
165
+ pendingCompletion?: PendingCompletion;
166
+ }
167
+
168
+ export interface PipelineStepResult {
169
+ runId: number;
170
+ agent: string;
171
+ task: string;
172
+ output: string;
173
+ status: "done" | "error";
174
+ }
175
+
176
+ export interface PipelineState {
177
+ pipelineId: string;
178
+ currentIndex: number;
179
+ stepRunIds: number[];
180
+ stepResults: PipelineStepResult[];
181
+ originSessionFile: string;
182
+ createdAt: number;
183
+ pendingCompletion?: PendingCompletion;
184
+ }
185
+
186
+ export const ListAgentsParams = Type.Object({});
187
+
188
+ export const SubagentParams = Type.Object({
189
+ command: Type.String({
190
+ description:
191
+ "Command string, e.g. 'subagent help', 'subagent run worker --main -- <task>', 'subagent batch --main --agent worker --task \"A\" --agent reviewer --task \"B\"'. Write long task context to a temp file and reference its path.",
192
+ }),
193
+ });
@@ -0,0 +1,315 @@
1
+ // Vendored from shared personal-extension utilities as a self-contained copy.
2
+ // Maintained independently within this package.
3
+ /**
4
+ * Pure utility functions extracted from subagent/agents.ts and subagent/runner.ts.
5
+ * These handle agent discovery helpers, normalization, matching, and alias computation.
6
+ */
7
+
8
+ // ── Types ────────────────────────────────────────────────────────────────────
9
+
10
+ export interface AgentConfigLike {
11
+ name: string;
12
+ description: string;
13
+ source: string;
14
+ tools?: string[];
15
+ model?: string;
16
+ }
17
+
18
+ export interface AgentAliasMatch<T extends AgentConfigLike = AgentConfigLike> {
19
+ matchedAgent?: T;
20
+ ambiguousAgents: T[];
21
+ }
22
+
23
+ export const AGENT_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
24
+ export type AgentThinkingLevel = (typeof AGENT_THINKING_LEVELS)[number];
25
+
26
+ // ── Constants ────────────────────────────────────────────────────────────────
27
+
28
+ export const CLAUDE_TOOL_MAP: Record<string, string | undefined> = {
29
+ bash: "bash",
30
+ read: "read",
31
+ edit: "edit",
32
+ write: "write",
33
+ grep: "grep",
34
+ glob: "find",
35
+ ls: "ls",
36
+ todowrite: "todo",
37
+ todoread: "todo",
38
+ skill: undefined,
39
+ };
40
+
41
+ export const PI_TO_CLAUDE_TOOL_MAP: Record<string, string> = {
42
+ read: "Read",
43
+ find: "Glob",
44
+ grep: "Grep",
45
+ bash: "Bash",
46
+ edit: "Edit",
47
+ write: "Write",
48
+ ls: "LS",
49
+ };
50
+
51
+ export function mapPiToolsToClaude(piTools: string[]): string[] {
52
+ const mapped: string[] = [];
53
+ for (const tool of piTools) {
54
+ const claudeTool = PI_TO_CLAUDE_TOOL_MAP[tool];
55
+ if (!claudeTool) {
56
+ throw new Error(
57
+ `Unsupported tool "${tool}" for Claude runtime. Supported tools: ${Object.keys(PI_TO_CLAUDE_TOOL_MAP).join(", ")}`,
58
+ );
59
+ }
60
+ mapped.push(claudeTool);
61
+ }
62
+ return Array.from(new Set(mapped));
63
+ }
64
+
65
+ export function isClaudeModel(model: string | undefined): boolean {
66
+ if (!model) return false;
67
+ const lower = model.toLowerCase();
68
+ return lower.startsWith("anthropic/claude-") || lower.startsWith("claude-");
69
+ }
70
+
71
+ export function validateClaudeRuntimeModel(model: string | undefined): void {
72
+ if (!model) return;
73
+ if (isClaudeModel(model)) return;
74
+ throw new Error(
75
+ `Model "${model}" is not supported with Claude runtime. Only Anthropic models (anthropic/claude-* or claude-*) are allowed.`,
76
+ );
77
+ }
78
+
79
+ export const CLAUDE_MODEL_ALIAS_MAP: Record<string, string> = {
80
+ opus: "claude-opus-4-7",
81
+ sonnet: "claude-sonnet-5",
82
+ haiku: "claude-haiku-4-5",
83
+ };
84
+
85
+ // ── Tool / Model / Thinking Normalization ───────────────────────────────────
86
+
87
+ /**
88
+ * Normalize a comma-separated tool list according to the given format.
89
+ * For "claude" format, maps Claude tool names to pi equivalents.
90
+ */
91
+ export function normalizeTools(rawTools: string | undefined, format: "pi" | "claude"): string[] | undefined {
92
+ if (!rawTools) return undefined;
93
+
94
+ const parsed = rawTools
95
+ .split(",")
96
+ .map((t) => t.trim())
97
+ .filter(Boolean);
98
+
99
+ if (parsed.length === 0) return undefined;
100
+ if (format === "pi") return parsed;
101
+
102
+ const mapped = parsed
103
+ .map((tool) => CLAUDE_TOOL_MAP[tool.toLowerCase()] ?? undefined)
104
+ .filter((tool): tool is string => Boolean(tool));
105
+
106
+ if (mapped.length === 0) return undefined;
107
+ return Array.from(new Set(mapped));
108
+ }
109
+
110
+ /**
111
+ * Normalize a model reference according to the given format.
112
+ * For "claude" format, maps short aliases (opus, sonnet, haiku) to full model names.
113
+ */
114
+ export function normalizeModel(rawModel: string | undefined, format: "pi" | "claude"): string | undefined {
115
+ if (!rawModel) return undefined;
116
+ const model = rawModel.trim();
117
+ if (!model) return undefined;
118
+
119
+ if (format === "claude") {
120
+ if (model.includes("/")) return model;
121
+ return CLAUDE_MODEL_ALIAS_MAP[model.toLowerCase()] ?? model;
122
+ }
123
+
124
+ return model;
125
+ }
126
+
127
+ /**
128
+ * Normalize and validate thinking level.
129
+ */
130
+ export function normalizeThinkingLevel(rawThinking: string | undefined): AgentThinkingLevel | undefined {
131
+ if (!rawThinking) return undefined;
132
+ const thinking = rawThinking.trim().toLowerCase();
133
+ if (!thinking) return undefined;
134
+ if ((AGENT_THINKING_LEVELS as readonly string[]).includes(thinking)) return thinking as AgentThinkingLevel;
135
+ return undefined;
136
+ }
137
+
138
+ // ── Agent Alias / Initials ───────────────────────────────────────────────────
139
+
140
+ /**
141
+ * Normalize an agent alias by lowercasing and removing non-alphanumeric chars.
142
+ */
143
+ export function normalizeAgentAlias(value: string): string {
144
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
145
+ }
146
+
147
+ /**
148
+ * Extract initials from agent name parts.
149
+ * "worker" → "w", "code-reviewer" → "cr"
150
+ */
151
+ export function getAgentInitials(name: string): string {
152
+ return name
153
+ .toLowerCase()
154
+ .split(/[^a-z0-9]+/)
155
+ .filter(Boolean)
156
+ .map((part) => part[0])
157
+ .join("");
158
+ }
159
+
160
+ /**
161
+ * Deduplicate agents by name, keeping the first occurrence.
162
+ */
163
+ export function uniqueAgentsByName<T extends { name: string }>(candidates: T[]): T[] {
164
+ const map = new Map<string, T>();
165
+ for (const agent of candidates) {
166
+ if (!map.has(agent.name)) map.set(agent.name, agent);
167
+ }
168
+ return Array.from(map.values());
169
+ }
170
+
171
+ // ── Agent Matching ───────────────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Match a user-supplied token against a list of agents using fuzzy matching.
175
+ * Priority: exact match → prefix match → initials match → contains match.
176
+ */
177
+ export function matchSubCommandAgent<T extends AgentConfigLike>(agents: T[], token: string): AgentAliasMatch<T> {
178
+ const raw = token.trim().toLowerCase();
179
+ if (!raw) return { ambiguousAgents: [] };
180
+
181
+ const normalized = normalizeAgentAlias(raw);
182
+
183
+ // Exact match
184
+ const exact = uniqueAgentsByName(
185
+ agents.filter((agent) => {
186
+ const name = agent.name.toLowerCase();
187
+ if (name === raw) return true;
188
+ if (normalized && normalizeAgentAlias(name) === normalized) return true;
189
+ return false;
190
+ }),
191
+ );
192
+ if (exact.length === 1) return { matchedAgent: exact[0], ambiguousAgents: [] };
193
+ if (exact.length > 1) return { ambiguousAgents: exact };
194
+
195
+ // Prefix match
196
+ const prefix = uniqueAgentsByName(
197
+ agents.filter((agent) => {
198
+ const name = agent.name.toLowerCase();
199
+ const nameNormalized = normalizeAgentAlias(name);
200
+ const parts = name.split(/[^a-z0-9]+/).filter(Boolean);
201
+ if (name.startsWith(raw)) return true;
202
+ if (normalized && nameNormalized.startsWith(normalized)) return true;
203
+ if (parts.some((part) => part.startsWith(raw))) return true;
204
+ if (normalized && parts.some((part) => normalizeAgentAlias(part).startsWith(normalized))) return true;
205
+ return false;
206
+ }),
207
+ );
208
+ if (prefix.length === 1) return { matchedAgent: prefix[0], ambiguousAgents: [] };
209
+ if (prefix.length > 1) return { ambiguousAgents: prefix };
210
+
211
+ // Initials match
212
+ const initialsMatch = uniqueAgentsByName(
213
+ agents.filter((agent) => {
214
+ const agentInitials = getAgentInitials(agent.name);
215
+ return normalized === agentInitials;
216
+ }),
217
+ );
218
+ if (initialsMatch.length === 1) return { matchedAgent: initialsMatch[0], ambiguousAgents: [] };
219
+ if (initialsMatch.length > 1) return { ambiguousAgents: initialsMatch };
220
+
221
+ // Contains match
222
+ const contains = uniqueAgentsByName(
223
+ agents.filter((agent) => {
224
+ const name = agent.name.toLowerCase();
225
+ const nameNormalized = normalizeAgentAlias(name);
226
+ if (name.includes(raw)) return true;
227
+ if (normalized && nameNormalized.includes(normalized)) return true;
228
+ return false;
229
+ }),
230
+ );
231
+ if (contains.length === 1) return { matchedAgent: contains[0], ambiguousAgents: [] };
232
+ if (contains.length > 1) return { ambiguousAgents: contains };
233
+
234
+ return { ambiguousAgents: [] };
235
+ }
236
+
237
+ /**
238
+ * Get tab-completion candidates for an agent name prefix.
239
+ */
240
+ export function getSubCommandAgentCompletions<T extends AgentConfigLike>(
241
+ agents: T[],
242
+ argumentPrefix: string,
243
+ ): { value: string; label: string; description?: string }[] | null {
244
+ const trimmedStart = argumentPrefix.trimStart();
245
+ if (trimmedStart.includes(" ")) return null;
246
+
247
+ const raw = trimmedStart.toLowerCase();
248
+ const normalized = normalizeAgentAlias(raw);
249
+
250
+ const scored = agents
251
+ .map((agent) => {
252
+ const name = agent.name.toLowerCase();
253
+ const nameNormalized = normalizeAgentAlias(name);
254
+ const parts = name.split(/[^a-z0-9]+/).filter(Boolean);
255
+ const agentInitials = getAgentInitials(name);
256
+
257
+ let score = Number.POSITIVE_INFINITY;
258
+ if (!raw) score = 100;
259
+ else if (name === raw || (normalized && nameNormalized === normalized)) score = 0;
260
+ else if (name.startsWith(raw) || (normalized && nameNormalized.startsWith(normalized))) score = 1;
261
+ else if (
262
+ parts.some((part) => part.startsWith(raw)) ||
263
+ (normalized && parts.some((part) => normalizeAgentAlias(part).startsWith(normalized)))
264
+ )
265
+ score = 2;
266
+ else if (normalized && agentInitials === normalized) score = 3;
267
+ else if (name.includes(raw) || (normalized && nameNormalized.includes(normalized))) score = 4;
268
+
269
+ return { agent, score };
270
+ })
271
+ .filter((row) => Number.isFinite(row.score))
272
+ .sort((a, b) => a.score - b.score || a.agent.name.localeCompare(b.agent.name))
273
+ .slice(0, 20)
274
+ .map(({ agent }) => ({
275
+ value: `${agent.name} `,
276
+ label: agent.name,
277
+ description: agent.description || `[${agent.source}]`,
278
+ }));
279
+
280
+ return scored.length > 0 ? scored : null;
281
+ }
282
+
283
+ /**
284
+ * Compute shortest usable alias for each agent and return a formatted hint string.
285
+ * e.g. "w→worker s→searcher r→reviewer v→verifier"
286
+ */
287
+ export function computeAgentAliasHints<T extends AgentConfigLike>(agents: T[]): string {
288
+ const hints: string[] = [];
289
+
290
+ for (const agent of agents) {
291
+ const name = agent.name.toLowerCase();
292
+ const initials = getAgentInitials(name);
293
+
294
+ let shortestAlias = name;
295
+ for (let i = 1; i <= name.length; i++) {
296
+ const candidate = name.slice(0, i);
297
+ const result = matchSubCommandAgent(agents, candidate);
298
+ if (result.matchedAgent?.name === agent.name) {
299
+ shortestAlias = candidate;
300
+ break;
301
+ }
302
+ }
303
+
304
+ if (initials.length >= 2 && initials.length <= shortestAlias.length) {
305
+ const result = matchSubCommandAgent(agents, initials);
306
+ if (result.matchedAgent?.name === agent.name) {
307
+ shortestAlias = initials;
308
+ }
309
+ }
310
+
311
+ hints.push(shortestAlias === name ? name : `${shortestAlias}→${name}`);
312
+ }
313
+
314
+ return hints.join(" ");
315
+ }