@signalridge/pi-subagents 1.4.0 → 1.6.1
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 +68 -0
- package/README.md +69 -12
- package/package.json +6 -6
- package/src/agent-color.ts +188 -0
- package/src/agent-file-toggle.ts +8 -0
- package/src/agent-manager.ts +793 -62
- package/src/agent-runner.ts +361 -25
- package/src/agent-tiers.ts +189 -8
- package/src/agent-types.ts +3 -0
- package/src/ask-tools.ts +114 -0
- package/src/cross-extension-rpc.ts +16 -5
- package/src/custom-agents.ts +67 -2
- package/src/default-agents.ts +6 -5
- package/src/gate.ts +0 -0
- package/src/index.ts +3386 -1128
- package/src/mention-clone.ts +196 -0
- package/src/mention.ts +141 -0
- package/src/output-file.ts +23 -1
- package/src/settings.ts +208 -9
- package/src/supervisor.ts +115 -0
- package/src/types.ts +59 -2
- package/src/ui/agent-mention.ts +163 -0
- package/src/ui/conversation-viewer.ts +4 -3
- package/src/ui/fleet-list.ts +6 -5
- package/src/worktree.ts +128 -648
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-mention.ts — what `@` can address, and the suggestions pi renders for it.
|
|
3
|
+
*
|
|
4
|
+
* A subagent is addressable whether or not it is currently running: a live
|
|
5
|
+
* record is messaged or resumed, an evicted one whose session is still on disk
|
|
6
|
+
* is reopened, and an agent *type* with no instance at all is started. That is
|
|
7
|
+
* the point of the handle — `@explore` means the Explore agent, not "the
|
|
8
|
+
* Explore process that happens to exist right now" — so the roster below unions
|
|
9
|
+
* all three, and the dispatcher and the popup read the same list.
|
|
10
|
+
*
|
|
11
|
+
* pi's `CombinedAutocompleteProvider` already owns `@`, where it means "attach a
|
|
12
|
+
* file". Extensions can wrap it (`ctx.ui.addAutocompleteProvider`), so this
|
|
13
|
+
* provider answers the `@` tokens that name an agent and delegates every other
|
|
14
|
+
* one — including all of `applyCompletion`, whose `@`-branch already inserts
|
|
15
|
+
* `item.value` plus a trailing space, which is exactly what a handle needs.
|
|
16
|
+
*
|
|
17
|
+
* Matching is case-insensitive prefix (not fuzzy), and when any agent matches,
|
|
18
|
+
* files are dropped from the list rather than mixed in — an `@name` that names
|
|
19
|
+
* an agent is never also a path.
|
|
20
|
+
*
|
|
21
|
+
* Every string that reaches a row passes through `sanitizeDisplayText` first:
|
|
22
|
+
* an agent description comes from a `.pi/agents/*.md` file that may not be
|
|
23
|
+
* trustworthy, and the popup draws into the user's terminal.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions } from "@earendil-works/pi-tui";
|
|
27
|
+
import type { AgentManager } from "../agent-manager.js";
|
|
28
|
+
import { handleBase, MENTION_TRIGGER } from "../mention.js";
|
|
29
|
+
import type { AgentRecordSnapshot, ResumableAgentEntry } from "../types.js";
|
|
30
|
+
import { sanitizeDisplayText, truncateCodePoints } from "./safe-text.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One thing `@` can address, and what sending to it will do. `typeLabel` is the
|
|
34
|
+
* agent's `display_name`, resolved by the caller: this module stays independent
|
|
35
|
+
* of the type registry, but the popup must agree with FleetView and the widget,
|
|
36
|
+
* which both render the label rather than the raw type.
|
|
37
|
+
*/
|
|
38
|
+
export type MentionTarget =
|
|
39
|
+
| { kind: "record"; handle: string; record: AgentRecordSnapshot; typeLabel: string }
|
|
40
|
+
| { kind: "resumable"; handle: string; entry: ResumableAgentEntry; typeLabel: string }
|
|
41
|
+
| { kind: "type"; handle: string; type: string; description: string };
|
|
42
|
+
|
|
43
|
+
/** The registry facts the roster needs, so it stays independent of agent-types. */
|
|
44
|
+
export type TypeInfo = { name: string; description: string };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Everything `@` can reach, in the order the popup lists it: steerable agents
|
|
48
|
+
* first, then the other live ones earliest-launched, then evicted conversations
|
|
49
|
+
* that can be reopened, then agent types with no live instance. A type whose
|
|
50
|
+
* handle a record already holds is omitted — that name addresses the existing
|
|
51
|
+
* agent, which is what makes `@explore` mean "message the one that's running"
|
|
52
|
+
* and only otherwise "start one".
|
|
53
|
+
*/
|
|
54
|
+
export function mentionRoster(
|
|
55
|
+
manager: AgentManager,
|
|
56
|
+
types: readonly TypeInfo[],
|
|
57
|
+
// Identity by default: a caller with no registry to consult gets the raw
|
|
58
|
+
// type, which is also what the config lookup falls back to when no label is set.
|
|
59
|
+
displayNameOf: (type: string) => string = (type) => type,
|
|
60
|
+
): MentionTarget[] {
|
|
61
|
+
const isLive = (r: AgentRecordSnapshot) => r.status === "running" || r.status === "queued";
|
|
62
|
+
const records = manager
|
|
63
|
+
.listAgents()
|
|
64
|
+
.filter((r) => r.handle !== undefined && r.parentAgentId === undefined)
|
|
65
|
+
.sort((a, b) => Number(isLive(b)) - Number(isLive(a)) || a.startedAt - b.startedAt);
|
|
66
|
+
|
|
67
|
+
const taken = new Set<string>();
|
|
68
|
+
const targets: MentionTarget[] = [];
|
|
69
|
+
|
|
70
|
+
for (const record of records) {
|
|
71
|
+
const handle = record.handle as string;
|
|
72
|
+
taken.add(handle.toLowerCase());
|
|
73
|
+
targets.push({ kind: "record", handle, record, typeLabel: displayNameOf(record.type) });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Then agents that are gone but whose conversation can be reopened. After the
|
|
77
|
+
// live ones: a running agent is the likelier target, and this keeps the
|
|
78
|
+
// ordering "what exists now, then what can be brought back, then what can be
|
|
79
|
+
// started".
|
|
80
|
+
for (const entry of manager.listResumable()) {
|
|
81
|
+
if (taken.has(entry.handle.toLowerCase())) continue;
|
|
82
|
+
taken.add(entry.handle.toLowerCase());
|
|
83
|
+
targets.push({ kind: "resumable", handle: entry.handle, entry, typeLabel: displayNameOf(entry.type) });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const type of types) {
|
|
87
|
+
const handle = handleBase(type.name);
|
|
88
|
+
if (taken.has(handle)) continue;
|
|
89
|
+
taken.add(handle);
|
|
90
|
+
targets.push({ kind: "type", handle, type: type.name, description: type.description });
|
|
91
|
+
}
|
|
92
|
+
return targets;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createMentionProvider(
|
|
96
|
+
current: AutocompleteProvider,
|
|
97
|
+
roster: () => MentionTarget[],
|
|
98
|
+
isEnabled: () => boolean,
|
|
99
|
+
): AutocompleteProvider {
|
|
100
|
+
return {
|
|
101
|
+
// Only `@` — the contract is "characters that should naturally trigger THIS
|
|
102
|
+
// provider", and pi unions each wrapper's own set onto the outermost one
|
|
103
|
+
// itself, so re-declaring the wrapped provider's characters here would both
|
|
104
|
+
// misreport us and duplicate that.
|
|
105
|
+
triggerCharacters: ["@"],
|
|
106
|
+
|
|
107
|
+
async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
|
|
108
|
+
const items = isEnabled() ? mentionItems(roster(), lines[cursorLine] ?? "", cursorCol) : null;
|
|
109
|
+
if (items) return items;
|
|
110
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
114
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
118
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Suggestions for the `@…` token under the cursor, or null when it names no agent. */
|
|
124
|
+
export function mentionItems(
|
|
125
|
+
roster: readonly MentionTarget[],
|
|
126
|
+
line: string,
|
|
127
|
+
cursorCol: number,
|
|
128
|
+
): AutocompleteSuggestions | null {
|
|
129
|
+
const match = MENTION_TRIGGER.exec(line.slice(0, cursorCol));
|
|
130
|
+
if (!match) return null;
|
|
131
|
+
|
|
132
|
+
const typed = match[2].toLowerCase();
|
|
133
|
+
const items: AutocompleteItem[] = [];
|
|
134
|
+
for (const target of roster) {
|
|
135
|
+
if (!target.handle.toLowerCase().startsWith(typed)) continue;
|
|
136
|
+
items.push({ value: `@${target.handle}`, label: `@${target.handle}`, description: describeTarget(target) });
|
|
137
|
+
}
|
|
138
|
+
return items.length > 0 ? { items, prefix: `@${match[2]}` } : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Name the action that will actually happen, so the list never mispromises. */
|
|
142
|
+
function describeTarget(target: MentionTarget): string {
|
|
143
|
+
if (target.kind === "type") return `start agent · ${summarize(target.description)}`;
|
|
144
|
+
if (target.kind === "resumable") {
|
|
145
|
+
// No status: the record is gone, and "completed" would imply one is still
|
|
146
|
+
// being tracked. The type carries the identity the handle may not.
|
|
147
|
+
return `resume · ${sanitizeDisplayText(target.typeLabel)} · ${summarize(target.entry.description)}`;
|
|
148
|
+
}
|
|
149
|
+
const { status, description } = target.record;
|
|
150
|
+
const action = status === "running" || status === "queued" ? "send message" : "resume";
|
|
151
|
+
return `${action} · ${status} · ${summarize(description)}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* First sentence of a description, sanitized then clipped — agent descriptions
|
|
156
|
+
* run to paragraphs, and they come from files this extension did not write.
|
|
157
|
+
* Sanitize BEFORE truncating: cutting first can sever an escape sequence and
|
|
158
|
+
* leave a live introducer behind.
|
|
159
|
+
*/
|
|
160
|
+
function summarize(description: string): string {
|
|
161
|
+
const first = (description.match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? description).replace(/\s+/g, " ").trim();
|
|
162
|
+
return truncateCodePoints(sanitizeDisplayText(first), 60, "…");
|
|
163
|
+
}
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
10
|
+
import { renderAgentName } from "../agent-color.js";
|
|
10
11
|
import { extractText } from "../context.js";
|
|
11
12
|
import type { AgentRecordSnapshot } from "../types.js";
|
|
12
13
|
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
13
14
|
import type { Theme } from "./agent-display.js";
|
|
14
|
-
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens,
|
|
15
|
+
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-display.js";
|
|
15
16
|
import { PREVIEW_SCAN_LIMIT, safeTerminalText, sanitizeDisplayText, truncateCodePoints } from "./safe-text.js";
|
|
16
17
|
import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
|
|
17
18
|
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
|
|
@@ -187,7 +188,7 @@ export class ConversationViewer implements Component {
|
|
|
187
188
|
|
|
188
189
|
lines.push(rule());
|
|
189
190
|
lines.push(row(th.bold("Agent conversation")));
|
|
190
|
-
const name =
|
|
191
|
+
const name = renderAgentName(this.record.type, th, { bold: true });
|
|
191
192
|
const modeLabel = getPromptModeLabel(this.record.type);
|
|
192
193
|
const modeTag = modeLabel ? th.fg("dim", `mode ${modeLabel}`) : undefined;
|
|
193
194
|
const statusText = getAgentStatusLabel(this.record.status);
|
|
@@ -205,7 +206,7 @@ export class ConversationViewer implements Component {
|
|
|
205
206
|
|
|
206
207
|
const headerParts = [
|
|
207
208
|
th.fg(statusColor, `${getAgentStatusMark(this.record.status)} ${statusText}`),
|
|
208
|
-
|
|
209
|
+
name,
|
|
209
210
|
modeTag,
|
|
210
211
|
th.fg("muted", sanitizeDisplayText(this.record.description)),
|
|
211
212
|
fgPreservingNestedStyles(th, "dim", headerStats.join(" · ")),
|
package/src/ui/fleet-list.ts
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { Editor, isKeyRelease, Key, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
|
+
import { renderAgentName } from "../agent-color.js";
|
|
15
16
|
import type { AgentManager } from "../agent-manager.js";
|
|
16
17
|
import type { AgentRecord } from "../types.js";
|
|
17
18
|
import { getLifetimeTotal } from "../usage.js";
|
|
18
|
-
import { type AgentActivity,
|
|
19
|
+
import { type AgentActivity, type Theme } from "./agent-display.js";
|
|
19
20
|
import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
|
|
20
21
|
import { sanitizeDisplayText } from "./safe-text.js";
|
|
21
22
|
import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
|
|
@@ -379,11 +380,11 @@ export class FleetList {
|
|
|
379
380
|
|
|
380
381
|
private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string {
|
|
381
382
|
const selected = rosterIndex === sel;
|
|
382
|
-
const name = getDisplayName(record.type);
|
|
383
383
|
const status = getAgentStatusLabel(record.status);
|
|
384
|
-
const nameText =
|
|
385
|
-
?
|
|
386
|
-
:
|
|
384
|
+
const nameText = renderAgentName(record.type, theme, {
|
|
385
|
+
fallbackColor: selected ? "accent" : "muted",
|
|
386
|
+
bold: selected,
|
|
387
|
+
});
|
|
387
388
|
const description = sanitizeDisplayText(record.description);
|
|
388
389
|
const descriptionText = selected ? theme.fg("accent", description) : description;
|
|
389
390
|
const semanticStatusColor = getAgentStatusColor(record.status);
|