@tintinweb/pi-subagents 0.9.1 → 0.10.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 +36 -0
- package/README.md +54 -17
- package/dist/agent-runner.d.ts +49 -0
- package/dist/agent-runner.js +225 -35
- package/dist/agent-types.d.ts +12 -1
- package/dist/agent-types.js +26 -7
- package/dist/custom-agents.js +21 -1
- package/dist/index.js +46 -24
- package/dist/prompts.d.ts +6 -3
- package/dist/prompts.js +12 -4
- package/dist/settings.d.ts +8 -0
- package/dist/settings.js +5 -0
- package/dist/status-note.d.ts +13 -0
- package/dist/status-note.js +24 -0
- package/dist/types.d.ts +4 -0
- package/dist/ui/agent-widget.d.ts +4 -4
- package/dist/ui/agent-widget.js +6 -6
- package/dist/ui/conversation-viewer.d.ts +9 -1
- package/dist/ui/conversation-viewer.js +35 -2
- package/dist/worktree.d.ts +2 -0
- package/dist/worktree.js +28 -16
- package/package.json +2 -1
- package/src/agent-runner.ts +238 -34
- package/src/agent-types.ts +29 -7
- package/src/custom-agents.ts +23 -1
- package/src/index.ts +46 -26
- package/src/prompts.ts +12 -4
- package/src/settings.ts +12 -0
- package/src/status-note.ts +25 -0
- package/src/types.ts +4 -1
- package/src/ui/agent-widget.ts +6 -6
- package/src/ui/conversation-viewer.ts +32 -1
- package/src/worktree.ts +30 -17
- package/vitest.config.ts +18 -0
package/src/index.ts
CHANGED
|
@@ -16,8 +16,8 @@ import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type Exten
|
|
|
16
16
|
import { Container, Key, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
17
17
|
import { Type } from "@sinclair/typebox";
|
|
18
18
|
import { AgentManager } from "./agent-manager.js";
|
|
19
|
-
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
20
|
-
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes,
|
|
19
|
+
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
20
|
+
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
|
|
21
21
|
import { registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
22
22
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
23
23
|
import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
|
|
@@ -28,6 +28,7 @@ import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./o
|
|
|
28
28
|
import { SubagentScheduler } from "./schedule.js";
|
|
29
29
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
30
30
|
import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js";
|
|
31
|
+
import { getStatusNote } from "./status-note.js";
|
|
31
32
|
import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
|
|
32
33
|
import {
|
|
33
34
|
type AgentActivity,
|
|
@@ -118,16 +119,6 @@ function getStatusLabel(status: string, error?: string): string {
|
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
/** Parenthetical status note for completed agent result text. */
|
|
122
|
-
function getStatusNote(status: string): string {
|
|
123
|
-
switch (status) {
|
|
124
|
-
case "aborted": return " (aborted — max turns exceeded, output may be incomplete)";
|
|
125
|
-
case "steered": return " (wrapped up — reached turn limit)";
|
|
126
|
-
case "stopped": return " (stopped by user)";
|
|
127
|
-
default: return "";
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
122
|
/** Escape XML special characters to prevent injection in structured notifications. */
|
|
132
123
|
function escapeXml(s: string): string {
|
|
133
124
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -154,7 +145,7 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number): stri
|
|
|
154
145
|
record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
|
|
155
146
|
record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
|
|
156
147
|
`<status>${escapeXml(status)}</status>`,
|
|
157
|
-
`<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
|
|
148
|
+
`<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
|
|
158
149
|
`<result>${escapeXml(resultPreview)}</result>`,
|
|
159
150
|
`<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
|
|
160
151
|
`</task-notification>`,
|
|
@@ -530,6 +521,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
530
521
|
function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
|
|
531
522
|
function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; }
|
|
532
523
|
|
|
524
|
+
// ---- Disable default agents configuration ----
|
|
525
|
+
// When enabled, the three hardcoded default agents (general-purpose, Explore,
|
|
526
|
+
// Plan) are not registered. User-defined agents from .pi/agents/*.md are
|
|
527
|
+
// completely unaffected — only DEFAULT_AGENTS are suppressed.
|
|
528
|
+
// Defaults to false; opt-in via `/agents → Settings` or subagents.json.
|
|
529
|
+
// State lives in agent-types.ts (isDefaultsDisabled) because registerAgents
|
|
530
|
+
// needs it; this wrapper just re-registers after flipping it.
|
|
531
|
+
function setDisableDefaultAgents(b: boolean): void {
|
|
532
|
+
setDefaultsDisabled(b);
|
|
533
|
+
reloadCustomAgents(); // re-register with new setting
|
|
534
|
+
}
|
|
535
|
+
|
|
533
536
|
// ---- Batch tracking for smart join mode ----
|
|
534
537
|
// Collects background agent IDs spawned in the current turn for smart grouping.
|
|
535
538
|
// Uses a debounced timer: each new agent resets the 100ms window so that all
|
|
@@ -589,11 +592,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
589
592
|
return isFullSet ? "*" : tools.join(", ");
|
|
590
593
|
};
|
|
591
594
|
|
|
592
|
-
/** Build the full type list text dynamically from
|
|
595
|
+
/** Build the full type list text dynamically from available agents only. */
|
|
593
596
|
const buildTypeListText = () => {
|
|
594
|
-
const
|
|
597
|
+
const available = getAvailableTypes();
|
|
595
598
|
|
|
596
|
-
return
|
|
599
|
+
return available.map((name) => {
|
|
597
600
|
const cfg = getAgentConfig(name);
|
|
598
601
|
const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
|
|
599
602
|
const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
|
|
@@ -609,8 +612,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
609
612
|
return name.replace(/-\d{8}$/, "");
|
|
610
613
|
}
|
|
611
614
|
|
|
612
|
-
const typeListText = buildTypeListText();
|
|
613
|
-
|
|
614
615
|
// Apply persisted settings on startup and emit `subagents:settings_loaded`.
|
|
615
616
|
// Global + project merged; missing → defaults; corrupt file emits a warning
|
|
616
617
|
// to stderr and falls back to defaults.
|
|
@@ -622,6 +623,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
622
623
|
setDefaultJoinMode,
|
|
623
624
|
setSchedulingEnabled,
|
|
624
625
|
setScopeModels: setScopeModelsEnabled,
|
|
626
|
+
setDisableDefaultAgents: setDisableDefaultAgents,
|
|
625
627
|
},
|
|
626
628
|
(event, payload) => pi.events.emit(event, payload),
|
|
627
629
|
);
|
|
@@ -651,12 +653,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
651
653
|
: "";
|
|
652
654
|
|
|
653
655
|
pi.registerTool(defineTool({
|
|
654
|
-
name:
|
|
656
|
+
name: SUBAGENT_TOOL_NAMES.AGENT,
|
|
655
657
|
label: "Agent",
|
|
656
658
|
description: `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
|
|
657
659
|
|
|
658
660
|
Available agent types and the tools they have access to:
|
|
659
|
-
${
|
|
661
|
+
${buildTypeListText()}
|
|
660
662
|
|
|
661
663
|
Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.
|
|
662
664
|
|
|
@@ -772,7 +774,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
772
774
|
return new Text(text, 0, 0);
|
|
773
775
|
}
|
|
774
776
|
|
|
775
|
-
// Helper: build "haiku · thinking: high ·
|
|
777
|
+
// Helper: build "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string
|
|
776
778
|
const stats = (d: AgentDetails) => {
|
|
777
779
|
const parts: string[] = [];
|
|
778
780
|
if (d.modelName) parts.push(d.modelName);
|
|
@@ -1166,8 +1168,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1166
1168
|
|
|
1167
1169
|
const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
|
|
1168
1170
|
|
|
1171
|
+
// "general-purpose" may itself be unregistered (defaults disabled, no
|
|
1172
|
+
// user override) — getConfig then uses the hardcoded fallback config.
|
|
1169
1173
|
const fallbackNote = fellBack
|
|
1170
|
-
? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
|
|
1174
|
+
? `Note: Unknown agent type "${rawType}" — using ${resolveType("general-purpose") ? "general-purpose" : "the fallback agent config"}.\n\n`
|
|
1171
1175
|
: "";
|
|
1172
1176
|
|
|
1173
1177
|
if (record.status === "error") {
|
|
@@ -1188,7 +1192,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1188
1192
|
// ---- get_subagent_result tool ----
|
|
1189
1193
|
|
|
1190
1194
|
pi.registerTool(defineTool({
|
|
1191
|
-
name:
|
|
1195
|
+
name: SUBAGENT_TOOL_NAMES.GET_RESULT,
|
|
1192
1196
|
label: "Get Agent Result",
|
|
1193
1197
|
description:
|
|
1194
1198
|
"Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
|
|
@@ -1236,7 +1240,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1236
1240
|
|
|
1237
1241
|
let output =
|
|
1238
1242
|
`Agent: ${record.id}\n` +
|
|
1239
|
-
`Type: ${displayName} | Status: ${record.status} | ${statsParts.join(" | ")}\n` +
|
|
1243
|
+
`Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
|
|
1240
1244
|
`Description: ${record.description}\n\n`;
|
|
1241
1245
|
|
|
1242
1246
|
if (record.status === "running") {
|
|
@@ -1268,7 +1272,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1268
1272
|
// ---- steer_subagent tool ----
|
|
1269
1273
|
|
|
1270
1274
|
pi.registerTool(defineTool({
|
|
1271
|
-
name:
|
|
1275
|
+
name: SUBAGENT_TOOL_NAMES.STEER,
|
|
1272
1276
|
label: "Steer Agent",
|
|
1273
1277
|
description:
|
|
1274
1278
|
"Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
|
|
@@ -1491,7 +1495,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1491
1495
|
|
|
1492
1496
|
await ctx.ui.custom<undefined>(
|
|
1493
1497
|
(tui, theme, _keybindings, done) => {
|
|
1494
|
-
return new ConversationViewer(tui, session, record, activity, theme, done)
|
|
1498
|
+
return new ConversationViewer(tui, session, record, activity, theme, done, () => {
|
|
1499
|
+
if (manager.abort(record.id)) {
|
|
1500
|
+
ctx.ui.notify(`Stopped "${record.description}".`, "info");
|
|
1501
|
+
}
|
|
1502
|
+
});
|
|
1495
1503
|
},
|
|
1496
1504
|
{
|
|
1497
1505
|
overlay: true,
|
|
@@ -1860,6 +1868,7 @@ ${systemPrompt}
|
|
|
1860
1868
|
defaultJoinMode: getDefaultJoinMode(),
|
|
1861
1869
|
schedulingEnabled: isSchedulingEnabled(),
|
|
1862
1870
|
scopeModels: isScopeModelsEnabled(),
|
|
1871
|
+
disableDefaultAgents: isDefaultsDisabled(),
|
|
1863
1872
|
};
|
|
1864
1873
|
}
|
|
1865
1874
|
|
|
@@ -1914,6 +1923,13 @@ ${systemPrompt}
|
|
|
1914
1923
|
currentValue: isScopeModelsEnabled() ? "on" : "off",
|
|
1915
1924
|
values: ["on", "off"],
|
|
1916
1925
|
},
|
|
1926
|
+
{
|
|
1927
|
+
id: "disableDefaultAgents",
|
|
1928
|
+
label: "Disable defaults",
|
|
1929
|
+
description: "Hide built-in agents (general-purpose, Explore, Plan) — custom agents are unaffected",
|
|
1930
|
+
currentValue: isDefaultsDisabled() ? "on" : "off",
|
|
1931
|
+
values: ["on", "off"],
|
|
1932
|
+
},
|
|
1917
1933
|
];
|
|
1918
1934
|
}
|
|
1919
1935
|
|
|
@@ -1958,6 +1974,10 @@ ${systemPrompt}
|
|
|
1958
1974
|
const enabled = value === "on";
|
|
1959
1975
|
setScopeModelsEnabled(enabled);
|
|
1960
1976
|
notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
|
|
1977
|
+
} else if (id === "disableDefaultAgents") {
|
|
1978
|
+
const enabled = value === "on";
|
|
1979
|
+
setDisableDefaultAgents(enabled);
|
|
1980
|
+
notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
|
|
1961
1981
|
}
|
|
1962
1982
|
}
|
|
1963
1983
|
|
package/src/prompts.ts
CHANGED
|
@@ -16,12 +16,15 @@ export interface PromptExtras {
|
|
|
16
16
|
* Build the system prompt for an agent from its config.
|
|
17
17
|
*
|
|
18
18
|
* - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
|
|
19
|
-
* - "append" mode:
|
|
19
|
+
* - "append" mode: parent system prompt + sub-agent context + env header + config.systemPrompt
|
|
20
20
|
* - "append" with empty systemPrompt: pure parent clone
|
|
21
21
|
*
|
|
22
|
-
* Both modes
|
|
22
|
+
* Both modes include an `<active_agent name="${config.name}"/>` tag so downstream
|
|
23
23
|
* extensions (e.g. permission/policy systems) can resolve per-agent policy
|
|
24
|
-
* inside the child session by parsing the system prompt.
|
|
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).
|
|
25
28
|
*
|
|
26
29
|
* @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
|
|
27
30
|
* @param extras Optional extra sections to inject (memory, preloaded skills).
|
|
@@ -72,7 +75,12 @@ You are operating as a sub-agent invoked to handle a specific task.
|
|
|
72
75
|
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
|
|
73
76
|
: "";
|
|
74
77
|
|
|
75
|
-
|
|
78
|
+
// Place shared/stable content first so the LLM's KV cache can reuse the
|
|
79
|
+
// inherited prefix across all subagent invocations. The parent prompt is
|
|
80
|
+
// placed verbatim (no wrapper tag) so it forms an identical byte prefix
|
|
81
|
+
// with the parent session, maximising KV cache hits. The <active_agent>
|
|
82
|
+
// tag and env block vary per call and are placed after the cached prefix.
|
|
83
|
+
return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + customSection + extrasSuffix;
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
// "replace" mode — env header + the config's full system prompt
|
package/src/settings.ts
CHANGED
|
@@ -48,6 +48,13 @@ export interface SubagentsSettings {
|
|
|
48
48
|
* against. Defaults to false: subagents may use any model.
|
|
49
49
|
*/
|
|
50
50
|
scopeModels?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* When true, the three built-in default agents (general-purpose, Explore, Plan)
|
|
53
|
+
* are not registered at startup. User-defined agents from .pi/agents/*.md are
|
|
54
|
+
* completely unaffected — only the hardcoded DEFAULT_AGENTS are suppressed.
|
|
55
|
+
* Defaults to false.
|
|
56
|
+
*/
|
|
57
|
+
disableDefaultAgents?: boolean;
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
/** Setter hooks used by applySettings to wire persisted values into in-memory state. */
|
|
@@ -58,6 +65,7 @@ export interface SettingsAppliers {
|
|
|
58
65
|
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
59
66
|
setSchedulingEnabled: (b: boolean) => void;
|
|
60
67
|
setScopeModels: (enabled: boolean) => void;
|
|
68
|
+
setDisableDefaultAgents: (b: boolean) => void;
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
|
|
@@ -107,6 +115,9 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
107
115
|
if (typeof r.scopeModels === "boolean") {
|
|
108
116
|
out.scopeModels = r.scopeModels;
|
|
109
117
|
}
|
|
118
|
+
if (typeof r.disableDefaultAgents === "boolean") {
|
|
119
|
+
out.disableDefaultAgents = r.disableDefaultAgents;
|
|
120
|
+
}
|
|
110
121
|
return out;
|
|
111
122
|
}
|
|
112
123
|
|
|
@@ -163,6 +174,7 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
163
174
|
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
|
|
164
175
|
if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
|
|
165
176
|
if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels);
|
|
177
|
+
if (typeof s.disableDefaultAgents === "boolean") appliers.setDisableDefaultAgents(s.disableDefaultAgents);
|
|
166
178
|
}
|
|
167
179
|
|
|
168
180
|
/**
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-note.ts — Parenthetical status note appended to agent result text.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Explicit parenthetical note for a non-normal terminal outcome, so the parent
|
|
7
|
+
* agent can't mistake partial output for a completed result. Empty string for a
|
|
8
|
+
* clean completion (and any unknown/non-terminal status).
|
|
9
|
+
*
|
|
10
|
+
* `stopped` (a human aborted it) is deliberately distinct from `aborted` (the
|
|
11
|
+
* turn limit was hit) — the parent should treat human intervention differently
|
|
12
|
+
* from a budget cutoff.
|
|
13
|
+
*/
|
|
14
|
+
export function getStatusNote(status: string): string {
|
|
15
|
+
switch (status) {
|
|
16
|
+
case "stopped":
|
|
17
|
+
return " (STOPPED BY THE USER before completion — output is partial; the task was NOT finished)";
|
|
18
|
+
case "aborted":
|
|
19
|
+
return " (aborted — hit the turn limit before completion; output may be incomplete)";
|
|
20
|
+
case "steered":
|
|
21
|
+
return " (wrapped up at the turn limit — output may be partial)";
|
|
22
|
+
default:
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -26,6 +26,9 @@ export interface AgentConfig {
|
|
|
26
26
|
displayName?: string;
|
|
27
27
|
description: string;
|
|
28
28
|
builtinToolNames?: string[];
|
|
29
|
+
/** Raw `ext:` selector entries from the `tools:` CSV, e.g. ["ext:foo", "ext:bar/x"].
|
|
30
|
+
* Presence of any entry flips extension tools to an explicit allowlist. */
|
|
31
|
+
extSelectors?: string[];
|
|
29
32
|
/** Tool denylist — these tools are removed even if `builtinToolNames` or extensions include them. */
|
|
30
33
|
disallowedTools?: string[];
|
|
31
34
|
/** true = inherit all, string[] = only listed, false = none */
|
|
@@ -77,7 +80,7 @@ export interface AgentRecord {
|
|
|
77
80
|
/** Steering messages queued before the session was ready. */
|
|
78
81
|
pendingSteers?: string[];
|
|
79
82
|
/** Worktree info if the agent is running in an isolated worktree. */
|
|
80
|
-
worktree?: { path: string; branch: string };
|
|
83
|
+
worktree?: { path: string; branch: string; baseSha: string };
|
|
81
84
|
/** Worktree cleanup result after agent completion. */
|
|
82
85
|
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
83
86
|
/** The tool_use_id from the original Agent tool call. */
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -100,12 +100,12 @@ export function formatTokens(count: number): string {
|
|
|
100
100
|
/**
|
|
101
101
|
* Token count with optional context-fill % and compaction-count annotations.
|
|
102
102
|
* Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
|
|
103
|
-
* Compaction count rendered as
|
|
103
|
+
* Compaction count rendered as `⇊N` in dim.
|
|
104
104
|
*
|
|
105
105
|
* "12.3k token" — no annotations
|
|
106
106
|
* "12.3k token (45%)" — percent only
|
|
107
|
-
* "12.3k token (
|
|
108
|
-
* "12.3k token (45% ·
|
|
107
|
+
* "12.3k token (⇊2)" — compactions only (e.g. right after compact)
|
|
108
|
+
* "12.3k token (45% · ⇊2)" — both
|
|
109
109
|
*/
|
|
110
110
|
export function formatSessionTokens(
|
|
111
111
|
tokens: number,
|
|
@@ -120,15 +120,15 @@ export function formatSessionTokens(
|
|
|
120
120
|
annot.push(theme.fg(color, `${Math.round(percent)}%`));
|
|
121
121
|
}
|
|
122
122
|
if (compactions > 0) {
|
|
123
|
-
annot.push(theme.fg("dim",
|
|
123
|
+
annot.push(theme.fg("dim", `⇊${compactions}`));
|
|
124
124
|
}
|
|
125
125
|
if (annot.length === 0) return tokenStr;
|
|
126
126
|
return `${tokenStr} (${annot.join(" · ")})`;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
/** Format turn count with optional max limit: "
|
|
129
|
+
/** Format turn count with optional max limit: "↻5≤30" or "↻5". */
|
|
130
130
|
export function formatTurns(turnCount: number, maxTurns?: number | null): string {
|
|
131
|
-
return maxTurns != null ?
|
|
131
|
+
return maxTurns != null ? `↻${turnCount}≤${maxTurns}` : `↻${turnCount}`;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/** Format milliseconds as human-readable duration. */
|
|
@@ -25,6 +25,8 @@ export class ConversationViewer implements Component {
|
|
|
25
25
|
private unsubscribe: (() => void) | undefined;
|
|
26
26
|
private lastInnerW = 0;
|
|
27
27
|
private closed = false;
|
|
28
|
+
/** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
|
|
29
|
+
private stopArmed = false;
|
|
28
30
|
|
|
29
31
|
constructor(
|
|
30
32
|
private tui: TUI,
|
|
@@ -33,6 +35,8 @@ export class ConversationViewer implements Component {
|
|
|
33
35
|
private activity: AgentActivity | undefined,
|
|
34
36
|
private theme: Theme,
|
|
35
37
|
private done: (result: undefined) => void,
|
|
38
|
+
/** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
|
|
39
|
+
private onStop?: () => void,
|
|
36
40
|
) {
|
|
37
41
|
this.unsubscribe = session.subscribe(() => {
|
|
38
42
|
if (this.closed) return;
|
|
@@ -47,6 +51,22 @@ export class ConversationViewer implements Component {
|
|
|
47
51
|
return;
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
// Stop/abort the agent (only while it can still be stopped). Two-press:
|
|
55
|
+
// first "x" arms, second confirms — any other key disarms.
|
|
56
|
+
if (matchesKey(data, "x")) {
|
|
57
|
+
if (this.isStoppable()) {
|
|
58
|
+
if (this.stopArmed) {
|
|
59
|
+
this.stopArmed = false;
|
|
60
|
+
this.onStop?.();
|
|
61
|
+
} else {
|
|
62
|
+
this.stopArmed = true;
|
|
63
|
+
}
|
|
64
|
+
this.tui.requestRender();
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (this.stopArmed) this.stopArmed = false;
|
|
69
|
+
|
|
50
70
|
const totalLines = this.buildContentLines(this.lastInnerW).length;
|
|
51
71
|
const viewportHeight = this.viewportHeight();
|
|
52
72
|
const maxScroll = Math.max(0, totalLines - viewportHeight);
|
|
@@ -141,7 +161,13 @@ export class ConversationViewer implements Component {
|
|
|
141
161
|
? "100%"
|
|
142
162
|
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
|
|
143
163
|
const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
|
|
144
|
-
const
|
|
164
|
+
const scrollHint = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
|
|
165
|
+
// Stop hint goes first in the right group so it survives right-edge
|
|
166
|
+
// truncation on narrow terminals (the scroll hint is the expendable part).
|
|
167
|
+
const footerRight = this.isStoppable()
|
|
168
|
+
? (this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop")) +
|
|
169
|
+
th.fg("dim", " · ") + scrollHint
|
|
170
|
+
: scrollHint;
|
|
145
171
|
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
|
|
146
172
|
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
|
|
147
173
|
lines.push(hrBot);
|
|
@@ -149,6 +175,11 @@ export class ConversationViewer implements Component {
|
|
|
149
175
|
return lines;
|
|
150
176
|
}
|
|
151
177
|
|
|
178
|
+
/** Stoppable only when a stop handler exists and the agent is still active. */
|
|
179
|
+
private isStoppable(): boolean {
|
|
180
|
+
return !!this.onStop && (this.record.status === "running" || this.record.status === "queued");
|
|
181
|
+
}
|
|
182
|
+
|
|
152
183
|
invalidate(): void { /* no cached state to clear */ }
|
|
153
184
|
|
|
154
185
|
dispose(): void {
|
package/src/worktree.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface WorktreeInfo {
|
|
|
17
17
|
path: string;
|
|
18
18
|
/** Branch name created for this worktree (if changes exist). */
|
|
19
19
|
branch: string;
|
|
20
|
+
/** Commit SHA that the worktree was created from. */
|
|
21
|
+
baseSha: string;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface WorktreeCleanupResult {
|
|
@@ -34,9 +36,12 @@ export interface WorktreeCleanupResult {
|
|
|
34
36
|
*/
|
|
35
37
|
export function createWorktree(cwd: string, agentId: string): WorktreeInfo | undefined {
|
|
36
38
|
// Verify we're in a git repo with at least one commit (HEAD must exist)
|
|
39
|
+
let baseSha: string;
|
|
37
40
|
try {
|
|
38
41
|
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
39
|
-
execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 })
|
|
42
|
+
baseSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 })
|
|
43
|
+
.toString()
|
|
44
|
+
.trim();
|
|
40
45
|
} catch {
|
|
41
46
|
return undefined;
|
|
42
47
|
}
|
|
@@ -52,7 +57,7 @@ export function createWorktree(cwd: string, agentId: string): WorktreeInfo | und
|
|
|
52
57
|
stdio: "pipe",
|
|
53
58
|
timeout: 30000,
|
|
54
59
|
});
|
|
55
|
-
return { path: worktreePath, branch };
|
|
60
|
+
return { path: worktreePath, branch, baseSha };
|
|
56
61
|
} catch {
|
|
57
62
|
// If worktree creation fails, return undefined (agent runs in normal cwd)
|
|
58
63
|
return undefined;
|
|
@@ -81,22 +86,30 @@ export function cleanupWorktree(
|
|
|
81
86
|
timeout: 10000,
|
|
82
87
|
}).toString().trim();
|
|
83
88
|
|
|
84
|
-
if (
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
if (status) {
|
|
90
|
+
// Changes exist — stage, commit, and create a branch
|
|
91
|
+
execFileSync("git", ["add", "-A"], { cwd: worktree.path, stdio: "pipe", timeout: 10000 });
|
|
92
|
+
// Truncate description for commit message (no shell sanitization needed — execFileSync uses argv)
|
|
93
|
+
const safeDesc = agentDescription.slice(0, 200);
|
|
94
|
+
const commitMsg = `pi-agent: ${safeDesc}`;
|
|
95
|
+
execFileSync("git", ["commit", "--no-verify", "-m", commitMsg], {
|
|
96
|
+
cwd: worktree.path,
|
|
97
|
+
stdio: "pipe",
|
|
98
|
+
timeout: 10000,
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
const currentSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
102
|
+
cwd: worktree.path,
|
|
103
|
+
stdio: "pipe",
|
|
104
|
+
timeout: 5000,
|
|
105
|
+
}).toString().trim();
|
|
89
106
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
cwd: worktree.path,
|
|
97
|
-
stdio: "pipe",
|
|
98
|
-
timeout: 10000,
|
|
99
|
-
});
|
|
107
|
+
if (currentSha === worktree.baseSha) {
|
|
108
|
+
// No changes — remove worktree
|
|
109
|
+
removeWorktree(cwd, worktree.path);
|
|
110
|
+
return { hasChanges: false };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
100
113
|
|
|
101
114
|
// Create a branch pointing to the worktree's HEAD.
|
|
102
115
|
// If the branch already exists, append a suffix to avoid overwriting previous work.
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
// The print-mode e2e suite (test/subagents-print-mode-e2e.test.ts) drives REAL
|
|
5
|
+
// faux-model turns through pi-coding-agent + pi-agent-core. That requires ONE
|
|
6
|
+
// shared @earendil-works/pi-ai instance so the faux provider the test registers
|
|
7
|
+
// lands in the same api-registry the session streams through. npm physically
|
|
8
|
+
// duplicates pi-ai (a top-level copy and one nested under pi-coding-agent), which
|
|
9
|
+
// otherwise yields two registries and "No API provider registered" errors.
|
|
10
|
+
// Inlining the @earendil-works packages routes them through Vite's resolver so
|
|
11
|
+
// dedupe can collapse pi-ai to a single instance — for the parent AND for every
|
|
12
|
+
// subagent session the extension spawns. dedupe alone is insufficient (it only
|
|
13
|
+
// affects modules Vite resolves; without inline the runtime stays externalized).
|
|
14
|
+
test: {
|
|
15
|
+
server: { deps: { inline: [/@earendil-works\/pi-/] } },
|
|
16
|
+
},
|
|
17
|
+
resolve: { dedupe: ["@earendil-works/pi-ai"] },
|
|
18
|
+
});
|