@tintinweb/pi-subagents 0.4.0 → 0.4.3
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/CHANGELOG.md +57 -0
- package/README.md +85 -1
- package/dist/agent-manager.d.ts +70 -0
- package/dist/agent-manager.js +236 -0
- package/dist/agent-runner.d.ts +60 -0
- package/dist/agent-runner.js +265 -0
- package/dist/agent-types.d.ts +41 -0
- package/dist/agent-types.js +130 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +56 -0
- package/dist/custom-agents.d.ts +14 -0
- package/dist/custom-agents.js +100 -0
- package/dist/default-agents.d.ts +7 -0
- package/dist/default-agents.js +126 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.js +28 -0
- package/dist/group-join.d.ts +32 -0
- package/dist/group-join.js +116 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1270 -0
- package/dist/model-resolver.d.ts +19 -0
- package/dist/model-resolver.js +62 -0
- package/dist/prompts.d.ts +14 -0
- package/dist/prompts.js +48 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +5 -0
- package/dist/ui/agent-widget.d.ts +101 -0
- package/dist/ui/agent-widget.js +333 -0
- package/dist/ui/conversation-viewer.d.ts +31 -0
- package/dist/ui/conversation-viewer.js +236 -0
- package/package.json +1 -1
- package/src/agent-manager.ts +71 -3
- package/src/agent-runner.ts +71 -15
- package/src/agent-types.ts +26 -0
- package/src/custom-agents.ts +34 -5
- package/src/index.ts +88 -1
- package/src/memory.ts +165 -0
- package/src/prompts.ts +24 -2
- package/src/skill-loader.ts +79 -0
- package/src/types.ts +16 -0
- package/src/worktree.ts +162 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model resolution: exact match ("provider/modelId") with fuzzy fallback.
|
|
3
|
+
*/
|
|
4
|
+
export interface ModelEntry {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
provider: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ModelRegistry {
|
|
10
|
+
find(provider: string, modelId: string): any;
|
|
11
|
+
getAll(): any[];
|
|
12
|
+
getAvailable?(): any[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a model string to a Model instance.
|
|
16
|
+
* Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
|
|
17
|
+
* Returns the Model on success, or an error message string on failure.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveModel(input: string, registry: ModelRegistry): any | string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model resolution: exact match ("provider/modelId") with fuzzy fallback.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a model string to a Model instance.
|
|
6
|
+
* Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
|
|
7
|
+
* Returns the Model on success, or an error message string on failure.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveModel(input, registry) {
|
|
10
|
+
// Available models (those with auth configured)
|
|
11
|
+
const all = (registry.getAvailable?.() ?? registry.getAll());
|
|
12
|
+
const availableSet = new Set(all.map(m => `${m.provider}/${m.id}`.toLowerCase()));
|
|
13
|
+
// 1. Exact match: "provider/modelId" — only if available (has auth)
|
|
14
|
+
const slashIdx = input.indexOf("/");
|
|
15
|
+
if (slashIdx !== -1) {
|
|
16
|
+
const provider = input.slice(0, slashIdx);
|
|
17
|
+
const modelId = input.slice(slashIdx + 1);
|
|
18
|
+
if (availableSet.has(input.toLowerCase())) {
|
|
19
|
+
const found = registry.find(provider, modelId);
|
|
20
|
+
if (found)
|
|
21
|
+
return found;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// 2. Fuzzy match against available models
|
|
25
|
+
const query = input.toLowerCase();
|
|
26
|
+
// Score each model: prefer exact id match > id contains > name contains > provider+id contains
|
|
27
|
+
let bestMatch;
|
|
28
|
+
let bestScore = 0;
|
|
29
|
+
for (const m of all) {
|
|
30
|
+
const id = m.id.toLowerCase();
|
|
31
|
+
const name = m.name.toLowerCase();
|
|
32
|
+
const full = `${m.provider}/${m.id}`.toLowerCase();
|
|
33
|
+
let score = 0;
|
|
34
|
+
if (id === query || full === query) {
|
|
35
|
+
score = 100; // exact
|
|
36
|
+
}
|
|
37
|
+
else if (id.includes(query) || full.includes(query)) {
|
|
38
|
+
score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
|
|
39
|
+
}
|
|
40
|
+
else if (name.includes(query)) {
|
|
41
|
+
score = 40 + (query.length / name.length) * 20;
|
|
42
|
+
}
|
|
43
|
+
else if (query.split(/[\s\-/]+/).every(part => id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))) {
|
|
44
|
+
score = 20; // all parts present somewhere
|
|
45
|
+
}
|
|
46
|
+
if (score > bestScore) {
|
|
47
|
+
bestScore = score;
|
|
48
|
+
bestMatch = m;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (bestMatch && bestScore >= 20) {
|
|
52
|
+
const found = registry.find(bestMatch.provider, bestMatch.id);
|
|
53
|
+
if (found)
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
// 3. No match — list available models
|
|
57
|
+
const modelList = all
|
|
58
|
+
.map(m => ` ${m.provider}/${m.id}`)
|
|
59
|
+
.sort()
|
|
60
|
+
.join("\n");
|
|
61
|
+
return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
|
|
62
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompts.ts — System prompt builder for agents.
|
|
3
|
+
*/
|
|
4
|
+
import type { AgentConfig, EnvInfo } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* Build the system prompt for an agent from its config.
|
|
7
|
+
*
|
|
8
|
+
* - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
|
|
9
|
+
* - "append" mode: env header + parent system prompt + sub-agent context + config.systemPrompt
|
|
10
|
+
* - "append" with empty systemPrompt: pure parent clone
|
|
11
|
+
*
|
|
12
|
+
* @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
|
|
13
|
+
*/
|
|
14
|
+
export declare function buildAgentPrompt(config: AgentConfig, cwd: string, env: EnvInfo, parentSystemPrompt?: string): string;
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompts.ts — System prompt builder for agents.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Build the system prompt for an agent from its config.
|
|
6
|
+
*
|
|
7
|
+
* - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
|
|
8
|
+
* - "append" mode: env header + parent system prompt + sub-agent context + config.systemPrompt
|
|
9
|
+
* - "append" with empty systemPrompt: pure parent clone
|
|
10
|
+
*
|
|
11
|
+
* @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
|
|
12
|
+
*/
|
|
13
|
+
export function buildAgentPrompt(config, cwd, env, parentSystemPrompt) {
|
|
14
|
+
const envBlock = `# Environment
|
|
15
|
+
Working directory: ${cwd}
|
|
16
|
+
${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
|
|
17
|
+
Platform: ${env.platform}`;
|
|
18
|
+
if (config.promptMode === "append") {
|
|
19
|
+
const identity = parentSystemPrompt || genericBase;
|
|
20
|
+
const bridge = `<sub_agent_context>
|
|
21
|
+
You are operating as a sub-agent invoked to handle a specific task.
|
|
22
|
+
- Use the read tool instead of cat/head/tail
|
|
23
|
+
- Use the edit tool instead of sed/awk
|
|
24
|
+
- Use the write tool instead of echo/heredoc
|
|
25
|
+
- Use the find tool instead of bash find/ls for file search
|
|
26
|
+
- Use the grep tool instead of bash grep/rg for content search
|
|
27
|
+
- Make independent tool calls in parallel
|
|
28
|
+
- Use absolute file paths
|
|
29
|
+
- Do not use emojis
|
|
30
|
+
- Be concise but complete
|
|
31
|
+
</sub_agent_context>`;
|
|
32
|
+
const customSection = config.systemPrompt?.trim()
|
|
33
|
+
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
|
|
34
|
+
: "";
|
|
35
|
+
return envBlock + "\n\n<inherited_system_prompt>\n" + identity + "\n</inherited_system_prompt>\n\n" + bridge + customSection;
|
|
36
|
+
}
|
|
37
|
+
// "replace" mode — env header + the config's full system prompt
|
|
38
|
+
const replaceHeader = `You are a pi coding agent sub-agent.
|
|
39
|
+
You have been invoked to handle a specific task autonomously.
|
|
40
|
+
|
|
41
|
+
${envBlock}`;
|
|
42
|
+
return replaceHeader + "\n\n" + config.systemPrompt;
|
|
43
|
+
}
|
|
44
|
+
/** Fallback base prompt when parent system prompt is unavailable in append mode. */
|
|
45
|
+
const genericBase = `# Role
|
|
46
|
+
You are a general-purpose coding agent for complex, multi-step tasks.
|
|
47
|
+
You have full access to read, write, edit files, and execute commands.
|
|
48
|
+
Do what has been asked; nothing more, nothing less.`;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* types.ts — Type definitions for the subagent system.
|
|
3
|
+
*/
|
|
4
|
+
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
|
6
|
+
export type { ThinkingLevel };
|
|
7
|
+
/** Agent type: any string name (built-in defaults or user-defined). */
|
|
8
|
+
export type SubagentType = string;
|
|
9
|
+
/** Names of the three embedded default agents. */
|
|
10
|
+
export declare const DEFAULT_AGENT_NAMES: readonly ["general-purpose", "Explore", "Plan"];
|
|
11
|
+
/** Unified agent configuration — used for both default and user-defined agents. */
|
|
12
|
+
export interface AgentConfig {
|
|
13
|
+
name: string;
|
|
14
|
+
displayName?: string;
|
|
15
|
+
description: string;
|
|
16
|
+
builtinToolNames?: string[];
|
|
17
|
+
/** true = inherit all, string[] = only listed, false = none */
|
|
18
|
+
extensions: true | string[] | false;
|
|
19
|
+
/** true = inherit all, string[] = only listed, false = none */
|
|
20
|
+
skills: true | string[] | false;
|
|
21
|
+
model?: string;
|
|
22
|
+
thinking?: ThinkingLevel;
|
|
23
|
+
maxTurns?: number;
|
|
24
|
+
systemPrompt: string;
|
|
25
|
+
promptMode: "replace" | "append";
|
|
26
|
+
/** Default for spawn: fork parent conversation */
|
|
27
|
+
inheritContext: boolean;
|
|
28
|
+
/** Default for spawn: run in background */
|
|
29
|
+
runInBackground: boolean;
|
|
30
|
+
/** Default for spawn: no extension tools */
|
|
31
|
+
isolated: boolean;
|
|
32
|
+
/** true = this is an embedded default agent (informational) */
|
|
33
|
+
isDefault?: boolean;
|
|
34
|
+
/** false = agent is hidden from the registry */
|
|
35
|
+
enabled?: boolean;
|
|
36
|
+
/** Where this agent was loaded from */
|
|
37
|
+
source?: "default" | "project" | "global";
|
|
38
|
+
}
|
|
39
|
+
export type JoinMode = 'async' | 'group' | 'smart';
|
|
40
|
+
export interface AgentRecord {
|
|
41
|
+
id: string;
|
|
42
|
+
type: SubagentType;
|
|
43
|
+
description: string;
|
|
44
|
+
status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
|
|
45
|
+
result?: string;
|
|
46
|
+
error?: string;
|
|
47
|
+
toolUses: number;
|
|
48
|
+
startedAt: number;
|
|
49
|
+
completedAt?: number;
|
|
50
|
+
session?: AgentSession;
|
|
51
|
+
abortController?: AbortController;
|
|
52
|
+
promise?: Promise<string>;
|
|
53
|
+
groupId?: string;
|
|
54
|
+
joinMode?: JoinMode;
|
|
55
|
+
/** Set when result was already consumed via get_subagent_result — suppresses completion notification. */
|
|
56
|
+
resultConsumed?: boolean;
|
|
57
|
+
}
|
|
58
|
+
export interface EnvInfo {
|
|
59
|
+
isGitRepo: boolean;
|
|
60
|
+
branch: string;
|
|
61
|
+
platform: string;
|
|
62
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-widget.ts — Persistent widget showing running/completed agents above the editor.
|
|
3
|
+
*
|
|
4
|
+
* Displays a tree of agents with animated spinners, live stats, and activity descriptions.
|
|
5
|
+
* Uses the callback form of setWidget for themed rendering.
|
|
6
|
+
*/
|
|
7
|
+
import type { AgentManager } from "../agent-manager.js";
|
|
8
|
+
import type { SubagentType } from "../types.js";
|
|
9
|
+
/** Braille spinner frames for animated running indicator. */
|
|
10
|
+
export declare const SPINNER: string[];
|
|
11
|
+
/** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
|
|
12
|
+
export declare const ERROR_STATUSES: Set<string>;
|
|
13
|
+
export type Theme = {
|
|
14
|
+
fg(color: string, text: string): string;
|
|
15
|
+
bold(text: string): string;
|
|
16
|
+
};
|
|
17
|
+
export type UICtx = {
|
|
18
|
+
setStatus(key: string, text: string | undefined): void;
|
|
19
|
+
setWidget(key: string, content: undefined | ((tui: any, theme: Theme) => {
|
|
20
|
+
render(): string[];
|
|
21
|
+
invalidate(): void;
|
|
22
|
+
}), options?: {
|
|
23
|
+
placement?: "aboveEditor" | "belowEditor";
|
|
24
|
+
}): void;
|
|
25
|
+
};
|
|
26
|
+
/** Per-agent live activity state. */
|
|
27
|
+
export interface AgentActivity {
|
|
28
|
+
activeTools: Map<string, string>;
|
|
29
|
+
toolUses: number;
|
|
30
|
+
tokens: string;
|
|
31
|
+
responseText: string;
|
|
32
|
+
session?: {
|
|
33
|
+
getSessionStats(): {
|
|
34
|
+
tokens: {
|
|
35
|
+
total: number;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Metadata attached to Agent tool results for custom rendering. */
|
|
41
|
+
export interface AgentDetails {
|
|
42
|
+
displayName: string;
|
|
43
|
+
description: string;
|
|
44
|
+
subagentType: string;
|
|
45
|
+
toolUses: number;
|
|
46
|
+
tokens: string;
|
|
47
|
+
durationMs: number;
|
|
48
|
+
status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
|
|
49
|
+
/** Human-readable description of what the agent is currently doing. */
|
|
50
|
+
activity?: string;
|
|
51
|
+
/** Current spinner frame index (for animated running indicator). */
|
|
52
|
+
spinnerFrame?: number;
|
|
53
|
+
/** Short model name if different from parent (e.g. "haiku", "sonnet"). */
|
|
54
|
+
modelName?: string;
|
|
55
|
+
/** Notable config tags (e.g. ["thinking: high", "isolated"]). */
|
|
56
|
+
tags?: string[];
|
|
57
|
+
agentId?: string;
|
|
58
|
+
error?: string;
|
|
59
|
+
}
|
|
60
|
+
/** Format a token count compactly: "33.8k token", "1.2M token". */
|
|
61
|
+
export declare function formatTokens(count: number): string;
|
|
62
|
+
/** Format milliseconds as human-readable duration. */
|
|
63
|
+
export declare function formatMs(ms: number): string;
|
|
64
|
+
/** Format duration from start/completed timestamps. */
|
|
65
|
+
export declare function formatDuration(startedAt: number, completedAt?: number): string;
|
|
66
|
+
/** Get display name for any agent type (built-in or custom). */
|
|
67
|
+
export declare function getDisplayName(type: SubagentType): string;
|
|
68
|
+
/** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
|
|
69
|
+
export declare function getPromptModeLabel(type: SubagentType): string | undefined;
|
|
70
|
+
/** Build a human-readable activity string from currently-running tools or response text. */
|
|
71
|
+
export declare function describeActivity(activeTools: Map<string, string>, responseText?: string): string;
|
|
72
|
+
export declare class AgentWidget {
|
|
73
|
+
private manager;
|
|
74
|
+
private agentActivity;
|
|
75
|
+
private uiCtx;
|
|
76
|
+
private widgetFrame;
|
|
77
|
+
private widgetInterval;
|
|
78
|
+
/** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
|
|
79
|
+
private finishedTurnAge;
|
|
80
|
+
/** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
|
|
81
|
+
private static readonly ERROR_LINGER_TURNS;
|
|
82
|
+
constructor(manager: AgentManager, agentActivity: Map<string, AgentActivity>);
|
|
83
|
+
/** Set the UI context (grabbed from first tool execution). */
|
|
84
|
+
setUICtx(ctx: UICtx): void;
|
|
85
|
+
/**
|
|
86
|
+
* Called on each new turn (tool_execution_start).
|
|
87
|
+
* Ages finished agents and clears those that have lingered long enough.
|
|
88
|
+
*/
|
|
89
|
+
onTurnStart(): void;
|
|
90
|
+
/** Ensure the widget update timer is running. */
|
|
91
|
+
ensureTimer(): void;
|
|
92
|
+
/** Check if a finished agent should still be shown in the widget. */
|
|
93
|
+
private shouldShowFinished;
|
|
94
|
+
/** Record an agent as finished (call when agent completes). */
|
|
95
|
+
markFinished(agentId: string): void;
|
|
96
|
+
/** Render a finished agent line. */
|
|
97
|
+
private renderFinishedLine;
|
|
98
|
+
/** Force an immediate widget update. */
|
|
99
|
+
update(): void;
|
|
100
|
+
dispose(): void;
|
|
101
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-widget.ts — Persistent widget showing running/completed agents above the editor.
|
|
3
|
+
*
|
|
4
|
+
* Displays a tree of agents with animated spinners, live stats, and activity descriptions.
|
|
5
|
+
* Uses the callback form of setWidget for themed rendering.
|
|
6
|
+
*/
|
|
7
|
+
import { truncateToWidth } from "@mariozechner/pi-tui";
|
|
8
|
+
import { getConfig } from "../agent-types.js";
|
|
9
|
+
// ---- Constants ----
|
|
10
|
+
/** Maximum number of rendered lines before overflow collapse kicks in. */
|
|
11
|
+
const MAX_WIDGET_LINES = 12;
|
|
12
|
+
/** Braille spinner frames for animated running indicator. */
|
|
13
|
+
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
14
|
+
/** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
|
|
15
|
+
export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
|
|
16
|
+
/** Tool name → human-readable action for activity descriptions. */
|
|
17
|
+
const TOOL_DISPLAY = {
|
|
18
|
+
read: "reading",
|
|
19
|
+
bash: "running command",
|
|
20
|
+
edit: "editing",
|
|
21
|
+
write: "writing",
|
|
22
|
+
grep: "searching",
|
|
23
|
+
find: "finding files",
|
|
24
|
+
ls: "listing",
|
|
25
|
+
};
|
|
26
|
+
// ---- Formatting helpers ----
|
|
27
|
+
/** Format a token count compactly: "33.8k token", "1.2M token". */
|
|
28
|
+
export function formatTokens(count) {
|
|
29
|
+
if (count >= 1_000_000)
|
|
30
|
+
return `${(count / 1_000_000).toFixed(1)}M token`;
|
|
31
|
+
if (count >= 1_000)
|
|
32
|
+
return `${(count / 1_000).toFixed(1)}k token`;
|
|
33
|
+
return `${count} token`;
|
|
34
|
+
}
|
|
35
|
+
/** Format milliseconds as human-readable duration. */
|
|
36
|
+
export function formatMs(ms) {
|
|
37
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
38
|
+
}
|
|
39
|
+
/** Format duration from start/completed timestamps. */
|
|
40
|
+
export function formatDuration(startedAt, completedAt) {
|
|
41
|
+
if (completedAt)
|
|
42
|
+
return formatMs(completedAt - startedAt);
|
|
43
|
+
return `${formatMs(Date.now() - startedAt)} (running)`;
|
|
44
|
+
}
|
|
45
|
+
/** Get display name for any agent type (built-in or custom). */
|
|
46
|
+
export function getDisplayName(type) {
|
|
47
|
+
return getConfig(type).displayName;
|
|
48
|
+
}
|
|
49
|
+
/** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
|
|
50
|
+
export function getPromptModeLabel(type) {
|
|
51
|
+
const config = getConfig(type);
|
|
52
|
+
return config.promptMode === "append" ? "twin" : undefined;
|
|
53
|
+
}
|
|
54
|
+
/** Truncate text to a single line, max `len` chars. */
|
|
55
|
+
function truncateLine(text, len = 60) {
|
|
56
|
+
const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
|
|
57
|
+
if (line.length <= len)
|
|
58
|
+
return line;
|
|
59
|
+
return line.slice(0, len) + "…";
|
|
60
|
+
}
|
|
61
|
+
/** Build a human-readable activity string from currently-running tools or response text. */
|
|
62
|
+
export function describeActivity(activeTools, responseText) {
|
|
63
|
+
if (activeTools.size > 0) {
|
|
64
|
+
const groups = new Map();
|
|
65
|
+
for (const toolName of activeTools.values()) {
|
|
66
|
+
const action = TOOL_DISPLAY[toolName] ?? toolName;
|
|
67
|
+
groups.set(action, (groups.get(action) ?? 0) + 1);
|
|
68
|
+
}
|
|
69
|
+
const parts = [];
|
|
70
|
+
for (const [action, count] of groups) {
|
|
71
|
+
if (count > 1) {
|
|
72
|
+
parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
parts.push(action);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return parts.join(", ") + "…";
|
|
79
|
+
}
|
|
80
|
+
// No tools active — show truncated response text if available
|
|
81
|
+
if (responseText && responseText.trim().length > 0) {
|
|
82
|
+
return truncateLine(responseText);
|
|
83
|
+
}
|
|
84
|
+
return "thinking…";
|
|
85
|
+
}
|
|
86
|
+
// ---- Widget manager ----
|
|
87
|
+
export class AgentWidget {
|
|
88
|
+
manager;
|
|
89
|
+
agentActivity;
|
|
90
|
+
uiCtx;
|
|
91
|
+
widgetFrame = 0;
|
|
92
|
+
widgetInterval;
|
|
93
|
+
/** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
|
|
94
|
+
finishedTurnAge = new Map();
|
|
95
|
+
/** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
|
|
96
|
+
static ERROR_LINGER_TURNS = 2;
|
|
97
|
+
constructor(manager, agentActivity) {
|
|
98
|
+
this.manager = manager;
|
|
99
|
+
this.agentActivity = agentActivity;
|
|
100
|
+
}
|
|
101
|
+
/** Set the UI context (grabbed from first tool execution). */
|
|
102
|
+
setUICtx(ctx) {
|
|
103
|
+
this.uiCtx = ctx;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Called on each new turn (tool_execution_start).
|
|
107
|
+
* Ages finished agents and clears those that have lingered long enough.
|
|
108
|
+
*/
|
|
109
|
+
onTurnStart() {
|
|
110
|
+
// Age all finished agents
|
|
111
|
+
for (const [id, age] of this.finishedTurnAge) {
|
|
112
|
+
this.finishedTurnAge.set(id, age + 1);
|
|
113
|
+
}
|
|
114
|
+
// Trigger a widget refresh (will filter out expired agents)
|
|
115
|
+
this.update();
|
|
116
|
+
}
|
|
117
|
+
/** Ensure the widget update timer is running. */
|
|
118
|
+
ensureTimer() {
|
|
119
|
+
if (!this.widgetInterval) {
|
|
120
|
+
this.widgetInterval = setInterval(() => this.update(), 80);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Check if a finished agent should still be shown in the widget. */
|
|
124
|
+
shouldShowFinished(agentId, status) {
|
|
125
|
+
const age = this.finishedTurnAge.get(agentId) ?? 0;
|
|
126
|
+
const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
|
|
127
|
+
return age < maxAge;
|
|
128
|
+
}
|
|
129
|
+
/** Record an agent as finished (call when agent completes). */
|
|
130
|
+
markFinished(agentId) {
|
|
131
|
+
if (!this.finishedTurnAge.has(agentId)) {
|
|
132
|
+
this.finishedTurnAge.set(agentId, 0);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Render a finished agent line. */
|
|
136
|
+
renderFinishedLine(a, theme) {
|
|
137
|
+
const name = getDisplayName(a.type);
|
|
138
|
+
const modeLabel = getPromptModeLabel(a.type);
|
|
139
|
+
const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
|
|
140
|
+
let icon;
|
|
141
|
+
let statusText;
|
|
142
|
+
if (a.status === "completed") {
|
|
143
|
+
icon = theme.fg("success", "✓");
|
|
144
|
+
statusText = "";
|
|
145
|
+
}
|
|
146
|
+
else if (a.status === "steered") {
|
|
147
|
+
icon = theme.fg("warning", "✓");
|
|
148
|
+
statusText = theme.fg("warning", " (turn limit)");
|
|
149
|
+
}
|
|
150
|
+
else if (a.status === "stopped") {
|
|
151
|
+
icon = theme.fg("dim", "■");
|
|
152
|
+
statusText = theme.fg("dim", " stopped");
|
|
153
|
+
}
|
|
154
|
+
else if (a.status === "error") {
|
|
155
|
+
icon = theme.fg("error", "✗");
|
|
156
|
+
const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
|
|
157
|
+
statusText = theme.fg("error", ` error${errMsg}`);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
// aborted
|
|
161
|
+
icon = theme.fg("error", "✗");
|
|
162
|
+
statusText = theme.fg("warning", " aborted");
|
|
163
|
+
}
|
|
164
|
+
const parts = [];
|
|
165
|
+
if (a.toolUses > 0)
|
|
166
|
+
parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
|
|
167
|
+
parts.push(duration);
|
|
168
|
+
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
|
|
169
|
+
return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
|
|
170
|
+
}
|
|
171
|
+
/** Force an immediate widget update. */
|
|
172
|
+
update() {
|
|
173
|
+
if (!this.uiCtx)
|
|
174
|
+
return;
|
|
175
|
+
const allAgents = this.manager.listAgents();
|
|
176
|
+
const running = allAgents.filter(a => a.status === "running");
|
|
177
|
+
const queued = allAgents.filter(a => a.status === "queued");
|
|
178
|
+
const finished = allAgents.filter(a => a.status !== "running" && a.status !== "queued" && a.completedAt
|
|
179
|
+
&& this.shouldShowFinished(a.id, a.status));
|
|
180
|
+
const hasActive = running.length > 0 || queued.length > 0;
|
|
181
|
+
const hasFinished = finished.length > 0;
|
|
182
|
+
// Nothing to show — clear widget
|
|
183
|
+
if (!hasActive && !hasFinished) {
|
|
184
|
+
this.uiCtx.setWidget("agents", undefined);
|
|
185
|
+
this.uiCtx.setStatus("subagents", undefined);
|
|
186
|
+
if (this.widgetInterval) {
|
|
187
|
+
clearInterval(this.widgetInterval);
|
|
188
|
+
this.widgetInterval = undefined;
|
|
189
|
+
}
|
|
190
|
+
// Clean up stale entries
|
|
191
|
+
for (const [id] of this.finishedTurnAge) {
|
|
192
|
+
if (!allAgents.some(a => a.id === id))
|
|
193
|
+
this.finishedTurnAge.delete(id);
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// Status bar
|
|
198
|
+
if (hasActive) {
|
|
199
|
+
const statusParts = [];
|
|
200
|
+
if (running.length > 0)
|
|
201
|
+
statusParts.push(`${running.length} running`);
|
|
202
|
+
if (queued.length > 0)
|
|
203
|
+
statusParts.push(`${queued.length} queued`);
|
|
204
|
+
const total = running.length + queued.length;
|
|
205
|
+
this.uiCtx.setStatus("subagents", `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
this.uiCtx.setStatus("subagents", undefined);
|
|
209
|
+
}
|
|
210
|
+
this.widgetFrame++;
|
|
211
|
+
const frame = SPINNER[this.widgetFrame % SPINNER.length];
|
|
212
|
+
this.uiCtx.setWidget("agents", (tui, theme) => {
|
|
213
|
+
const w = tui.terminal.columns;
|
|
214
|
+
const truncate = (line) => truncateToWidth(line, w);
|
|
215
|
+
const headingColor = hasActive ? "accent" : "dim";
|
|
216
|
+
const headingIcon = hasActive ? "●" : "○";
|
|
217
|
+
// Build sections separately for overflow-aware assembly.
|
|
218
|
+
// Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
|
|
219
|
+
const finishedLines = [];
|
|
220
|
+
for (const a of finished) {
|
|
221
|
+
finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
|
|
222
|
+
}
|
|
223
|
+
const runningLines = []; // each entry is [header, activity]
|
|
224
|
+
for (const a of running) {
|
|
225
|
+
const name = getDisplayName(a.type);
|
|
226
|
+
const modeLabel = getPromptModeLabel(a.type);
|
|
227
|
+
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
|
|
228
|
+
const elapsed = formatMs(Date.now() - a.startedAt);
|
|
229
|
+
const bg = this.agentActivity.get(a.id);
|
|
230
|
+
const toolUses = bg?.toolUses ?? a.toolUses;
|
|
231
|
+
let tokenText = "";
|
|
232
|
+
if (bg?.session) {
|
|
233
|
+
try {
|
|
234
|
+
tokenText = formatTokens(bg.session.getSessionStats().tokens.total);
|
|
235
|
+
}
|
|
236
|
+
catch { /* */ }
|
|
237
|
+
}
|
|
238
|
+
const parts = [];
|
|
239
|
+
if (toolUses > 0)
|
|
240
|
+
parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
|
|
241
|
+
if (tokenText)
|
|
242
|
+
parts.push(tokenText);
|
|
243
|
+
parts.push(elapsed);
|
|
244
|
+
const statsText = parts.join(" · ");
|
|
245
|
+
const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
|
|
246
|
+
runningLines.push([
|
|
247
|
+
truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`),
|
|
248
|
+
truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
|
|
249
|
+
]);
|
|
250
|
+
}
|
|
251
|
+
const queuedLine = queued.length > 0
|
|
252
|
+
? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
|
|
253
|
+
: undefined;
|
|
254
|
+
// Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
|
|
255
|
+
const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
|
|
256
|
+
const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
|
|
257
|
+
const lines = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
|
|
258
|
+
if (totalBody <= maxBody) {
|
|
259
|
+
// Everything fits — add all lines and fix up connectors for the last item.
|
|
260
|
+
lines.push(...finishedLines);
|
|
261
|
+
for (const pair of runningLines)
|
|
262
|
+
lines.push(...pair);
|
|
263
|
+
if (queuedLine)
|
|
264
|
+
lines.push(queuedLine);
|
|
265
|
+
// Fix last connector: swap ├─ → └─ and │ → space for activity lines.
|
|
266
|
+
if (lines.length > 1) {
|
|
267
|
+
const last = lines.length - 1;
|
|
268
|
+
lines[last] = lines[last].replace("├─", "└─");
|
|
269
|
+
// If last item is a running agent activity line, fix indent of that line
|
|
270
|
+
// and fix the header line above it.
|
|
271
|
+
if (runningLines.length > 0 && !queuedLine) {
|
|
272
|
+
// The last two lines are the last running agent's header + activity.
|
|
273
|
+
if (last >= 2) {
|
|
274
|
+
lines[last - 1] = lines[last - 1].replace("├─", "└─");
|
|
275
|
+
lines[last] = lines[last].replace("│ ", " ");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
// Overflow — prioritize: running > queued > finished.
|
|
282
|
+
// Reserve 1 line for overflow indicator.
|
|
283
|
+
let budget = maxBody - 1;
|
|
284
|
+
let hiddenRunning = 0;
|
|
285
|
+
let hiddenFinished = 0;
|
|
286
|
+
// 1. Running agents (2 lines each)
|
|
287
|
+
for (const pair of runningLines) {
|
|
288
|
+
if (budget >= 2) {
|
|
289
|
+
lines.push(...pair);
|
|
290
|
+
budget -= 2;
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
hiddenRunning++;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// 2. Queued line
|
|
297
|
+
if (queuedLine && budget >= 1) {
|
|
298
|
+
lines.push(queuedLine);
|
|
299
|
+
budget--;
|
|
300
|
+
}
|
|
301
|
+
// 3. Finished agents
|
|
302
|
+
for (const fl of finishedLines) {
|
|
303
|
+
if (budget >= 1) {
|
|
304
|
+
lines.push(fl);
|
|
305
|
+
budget--;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
hiddenFinished++;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// Overflow summary
|
|
312
|
+
const overflowParts = [];
|
|
313
|
+
if (hiddenRunning > 0)
|
|
314
|
+
overflowParts.push(`${hiddenRunning} running`);
|
|
315
|
+
if (hiddenFinished > 0)
|
|
316
|
+
overflowParts.push(`${hiddenFinished} finished`);
|
|
317
|
+
const overflowText = overflowParts.join(", ");
|
|
318
|
+
lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`));
|
|
319
|
+
}
|
|
320
|
+
return { render: () => lines, invalidate: () => { } };
|
|
321
|
+
}, { placement: "aboveEditor" });
|
|
322
|
+
}
|
|
323
|
+
dispose() {
|
|
324
|
+
if (this.widgetInterval) {
|
|
325
|
+
clearInterval(this.widgetInterval);
|
|
326
|
+
this.widgetInterval = undefined;
|
|
327
|
+
}
|
|
328
|
+
if (this.uiCtx) {
|
|
329
|
+
this.uiCtx.setWidget("agents", undefined);
|
|
330
|
+
this.uiCtx.setStatus("subagents", undefined);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|