@tintinweb/pi-subagents 0.10.0 → 0.10.2
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 +20 -0
- package/README.md +27 -2
- package/dist/agent-runner.js +54 -10
- package/dist/agent-types.d.ts +5 -0
- package/dist/agent-types.js +13 -3
- package/dist/custom-agents.js +1 -0
- package/dist/index.js +137 -14
- package/dist/settings.d.ts +21 -0
- package/dist/settings.js +11 -0
- package/dist/types.d.ts +4 -0
- package/dist/ui/conversation-viewer.d.ts +5 -1
- package/dist/ui/conversation-viewer.js +10 -5
- package/dist/ui/viewer-keys.d.ts +20 -0
- package/dist/ui/viewer-keys.js +17 -0
- package/dist/worktree.d.ts +2 -0
- package/dist/worktree.js +28 -16
- package/examples/agent-tool-description.md +42 -0
- package/package.json +1 -1
- package/src/agent-runner.ts +53 -10
- package/src/agent-types.ts +17 -3
- package/src/custom-agents.ts +1 -0
- package/src/index.ts +139 -16
- package/src/settings.ts +31 -0
- package/src/types.ts +4 -1
- package/src/ui/conversation-viewer.ts +9 -4
- package/src/ui/viewer-keys.ts +39 -0
- package/src/worktree.ts +30 -17
- package/vitest.config.ts +18 -0
package/src/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { Container, Key, matchesKey, type SettingItem, SettingsList, Spacer, Tex
|
|
|
17
17
|
import { Type } from "@sinclair/typebox";
|
|
18
18
|
import { AgentManager } from "./agent-manager.js";
|
|
19
19
|
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
20
|
-
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes,
|
|
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";
|
|
@@ -27,7 +27,7 @@ import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
|
27
27
|
import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
28
28
|
import { SubagentScheduler } from "./schedule.js";
|
|
29
29
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
30
|
-
import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js";
|
|
30
|
+
import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
|
|
31
31
|
import { getStatusNote } from "./status-note.js";
|
|
32
32
|
import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
|
|
33
33
|
import {
|
|
@@ -521,6 +521,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
521
521
|
function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
|
|
522
522
|
function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; }
|
|
523
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
|
+
|
|
536
|
+
// ---- Agent tool description mode ----
|
|
537
|
+
// "full" (default) keeps the rich Claude Code-style description; "compact"
|
|
538
|
+
// swaps in a ~75% smaller one for small/local models (#91). Read once at
|
|
539
|
+
// tool registration — flipping it applies on the next pi session.
|
|
540
|
+
let toolDescriptionMode: ToolDescriptionMode = "full";
|
|
541
|
+
function getToolDescriptionMode(): ToolDescriptionMode { return toolDescriptionMode; }
|
|
542
|
+
function setToolDescriptionMode(mode: ToolDescriptionMode): void { toolDescriptionMode = mode; }
|
|
543
|
+
|
|
524
544
|
// ---- Batch tracking for smart join mode ----
|
|
525
545
|
// Collects background agent IDs spawned in the current turn for smart grouping.
|
|
526
546
|
// Uses a debounced timer: each new agent resets the 100ms window so that all
|
|
@@ -580,11 +600,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
580
600
|
return isFullSet ? "*" : tools.join(", ");
|
|
581
601
|
};
|
|
582
602
|
|
|
583
|
-
/** Build the full type list text dynamically from
|
|
603
|
+
/** Build the full type list text dynamically from available agents only. */
|
|
584
604
|
const buildTypeListText = () => {
|
|
585
|
-
const
|
|
605
|
+
const available = getAvailableTypes();
|
|
586
606
|
|
|
587
|
-
return
|
|
607
|
+
return available.map((name) => {
|
|
588
608
|
const cfg = getAgentConfig(name);
|
|
589
609
|
const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
|
|
590
610
|
const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
|
|
@@ -592,6 +612,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
592
612
|
}).join("\n");
|
|
593
613
|
};
|
|
594
614
|
|
|
615
|
+
/** First sentence of an agent description — for the compact type list. */
|
|
616
|
+
const firstSentence = (text: string): string => {
|
|
617
|
+
const match = text.match(/^.*?[.!?](?=\s|$)/s);
|
|
618
|
+
return (match ? match[0] : text).replace(/\s+/g, " ").trim();
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
/** Compact type list: one line per agent, first sentence only. */
|
|
622
|
+
const buildCompactTypeListText = () =>
|
|
623
|
+
getAvailableTypes().map((name) => {
|
|
624
|
+
const cfg = getAgentConfig(name);
|
|
625
|
+
return `- ${name}: ${firstSentence(cfg?.description ?? name)} (Tools: ${formatToolsSuffix(cfg)})`;
|
|
626
|
+
}).join("\n");
|
|
627
|
+
|
|
595
628
|
/** Derive a short model label from a model string. */
|
|
596
629
|
function getModelLabelFromConfig(model: string): string {
|
|
597
630
|
// Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
|
|
@@ -600,8 +633,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
600
633
|
return name.replace(/-\d{8}$/, "");
|
|
601
634
|
}
|
|
602
635
|
|
|
603
|
-
const typeListText = buildTypeListText();
|
|
604
|
-
|
|
605
636
|
// Apply persisted settings on startup and emit `subagents:settings_loaded`.
|
|
606
637
|
// Global + project merged; missing → defaults; corrupt file emits a warning
|
|
607
638
|
// to stderr and falls back to defaults.
|
|
@@ -613,6 +644,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
613
644
|
setDefaultJoinMode,
|
|
614
645
|
setSchedulingEnabled,
|
|
615
646
|
setScopeModels: setScopeModelsEnabled,
|
|
647
|
+
setDisableDefaultAgents: setDisableDefaultAgents,
|
|
648
|
+
setToolDescriptionMode: setToolDescriptionMode,
|
|
616
649
|
},
|
|
617
650
|
(event, payload) => pi.events.emit(event, payload),
|
|
618
651
|
);
|
|
@@ -641,13 +674,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
641
674
|
? `\n- Use \`schedule\` only when the user explicitly asked for scheduled / recurring / delayed execution (e.g. "every Monday", "in an hour"). Don't auto-schedule from vague intent like "monitor X" — run once now or ask.`
|
|
642
675
|
: "";
|
|
643
676
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
677
|
+
// Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
|
|
678
|
+
// the same load-bearing facts as the full version at ~75% fewer tokens, for
|
|
679
|
+
// small/local models. Per-option details live in the param descriptions.
|
|
680
|
+
const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
|
|
681
|
+
${buildCompactTypeListText()}
|
|
682
|
+
|
|
683
|
+
Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
|
|
684
|
+
|
|
685
|
+
Notes:
|
|
686
|
+
- description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
|
|
687
|
+
- Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
|
|
688
|
+
- The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
|
|
689
|
+
- resume continues a previous agent by ID; steer_subagent messages a running one.
|
|
690
|
+
- isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
|
|
691
|
+
|
|
692
|
+
const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
|
|
648
693
|
|
|
649
694
|
Available agent types and the tools they have access to:
|
|
650
|
-
${
|
|
695
|
+
${buildTypeListText()}
|
|
651
696
|
|
|
652
697
|
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.
|
|
653
698
|
|
|
@@ -685,7 +730,59 @@ Provide clear, detailed prompts so the agent can work autonomously. Brief it lik
|
|
|
685
730
|
|
|
686
731
|
Terse command-style prompts produce shallow, generic work.
|
|
687
732
|
|
|
688
|
-
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change
|
|
733
|
+
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`;
|
|
734
|
+
|
|
735
|
+
// `toolDescriptionMode: "custom"` — user-authored description with live
|
|
736
|
+
// dynamic parts. Project file wins over global; missing/empty falls back to
|
|
737
|
+
// "full" (a stale fallback beats a blank tool description). Only the prose
|
|
738
|
+
// is customizable — the parameter schema stays code-owned.
|
|
739
|
+
const renderToolDescriptionTemplate = (template: string): string => {
|
|
740
|
+
const vars: Record<string, () => string> = {
|
|
741
|
+
typeList: buildTypeListText,
|
|
742
|
+
compactTypeList: buildCompactTypeListText,
|
|
743
|
+
agentDir: getAgentDir,
|
|
744
|
+
scheduleGuideline: () => scheduleGuideline,
|
|
745
|
+
};
|
|
746
|
+
// Replacement callback (not a string) — agent descriptions may contain `$&` etc.
|
|
747
|
+
return template.replace(/\{\{(\w+)\}\}/g, (raw, name: string) => {
|
|
748
|
+
if (vars[name]) return vars[name]();
|
|
749
|
+
console.warn(`[pi-subagents] agent-tool-description.md: unknown placeholder ${raw} left as-is`);
|
|
750
|
+
return raw;
|
|
751
|
+
});
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const loadCustomToolDescription = (): string | undefined => {
|
|
755
|
+
for (const path of [
|
|
756
|
+
join(process.cwd(), ".pi", "agent-tool-description.md"),
|
|
757
|
+
join(getAgentDir(), "agent-tool-description.md"),
|
|
758
|
+
]) {
|
|
759
|
+
try {
|
|
760
|
+
if (!existsSync(path)) continue;
|
|
761
|
+
const text = readFileSync(path, "utf-8").trim();
|
|
762
|
+
if (text) return renderToolDescriptionTemplate(text);
|
|
763
|
+
console.warn(`[pi-subagents] ${path} is empty — ignoring`);
|
|
764
|
+
} catch (err) {
|
|
765
|
+
console.warn(`[pi-subagents] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return undefined;
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
const agentToolDescription = (() => {
|
|
772
|
+
const mode = getToolDescriptionMode();
|
|
773
|
+
if (mode === "compact") return compactAgentToolDescription;
|
|
774
|
+
if (mode === "custom") {
|
|
775
|
+
const custom = loadCustomToolDescription();
|
|
776
|
+
if (custom) return custom;
|
|
777
|
+
console.warn('[pi-subagents] toolDescriptionMode is "custom" but no agent-tool-description.md found — using "full"');
|
|
778
|
+
}
|
|
779
|
+
return fullAgentToolDescription;
|
|
780
|
+
})();
|
|
781
|
+
|
|
782
|
+
pi.registerTool(defineTool({
|
|
783
|
+
name: SUBAGENT_TOOL_NAMES.AGENT,
|
|
784
|
+
label: "Agent",
|
|
785
|
+
description: agentToolDescription,
|
|
689
786
|
promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
|
|
690
787
|
promptGuidelines: [
|
|
691
788
|
"Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
|
|
@@ -1157,8 +1254,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1157
1254
|
|
|
1158
1255
|
const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
|
|
1159
1256
|
|
|
1257
|
+
// "general-purpose" may itself be unregistered (defaults disabled, no
|
|
1258
|
+
// user override) — getConfig then uses the hardcoded fallback config.
|
|
1160
1259
|
const fallbackNote = fellBack
|
|
1161
|
-
? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
|
|
1260
|
+
? `Note: Unknown agent type "${rawType}" — using ${resolveType("general-purpose") ? "general-purpose" : "the fallback agent config"}.\n\n`
|
|
1162
1261
|
: "";
|
|
1163
1262
|
|
|
1164
1263
|
if (record.status === "error") {
|
|
@@ -1481,12 +1580,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1481
1580
|
const activity = agentActivity.get(record.id);
|
|
1482
1581
|
|
|
1483
1582
|
await ctx.ui.custom<undefined>(
|
|
1484
|
-
(tui, theme,
|
|
1583
|
+
(tui, theme, keybindings, done) => {
|
|
1485
1584
|
return new ConversationViewer(tui, session, record, activity, theme, done, () => {
|
|
1486
1585
|
if (manager.abort(record.id)) {
|
|
1487
1586
|
ctx.ui.notify(`Stopped "${record.description}".`, "info");
|
|
1488
1587
|
}
|
|
1489
|
-
});
|
|
1588
|
+
}, keybindings);
|
|
1490
1589
|
},
|
|
1491
1590
|
{
|
|
1492
1591
|
overlay: true,
|
|
@@ -1588,6 +1687,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1588
1687
|
fmFields.push(`prompt_mode: ${cfg.promptMode}`);
|
|
1589
1688
|
if (cfg.extensions === false) fmFields.push("extensions: false");
|
|
1590
1689
|
else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
|
|
1690
|
+
if (cfg.excludeExtensions?.length) fmFields.push(`exclude_extensions: ${cfg.excludeExtensions.join(", ")}`);
|
|
1591
1691
|
if (cfg.skills === false) fmFields.push("skills: false");
|
|
1592
1692
|
else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
|
|
1593
1693
|
if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
|
|
@@ -1855,6 +1955,8 @@ ${systemPrompt}
|
|
|
1855
1955
|
defaultJoinMode: getDefaultJoinMode(),
|
|
1856
1956
|
schedulingEnabled: isSchedulingEnabled(),
|
|
1857
1957
|
scopeModels: isScopeModelsEnabled(),
|
|
1958
|
+
disableDefaultAgents: isDefaultsDisabled(),
|
|
1959
|
+
toolDescriptionMode: getToolDescriptionMode(),
|
|
1858
1960
|
};
|
|
1859
1961
|
}
|
|
1860
1962
|
|
|
@@ -1909,6 +2011,20 @@ ${systemPrompt}
|
|
|
1909
2011
|
currentValue: isScopeModelsEnabled() ? "on" : "off",
|
|
1910
2012
|
values: ["on", "off"],
|
|
1911
2013
|
},
|
|
2014
|
+
{
|
|
2015
|
+
id: "disableDefaultAgents",
|
|
2016
|
+
label: "Disable defaults",
|
|
2017
|
+
description: "Hide built-in agents (general-purpose, Explore, Plan) — custom agents are unaffected",
|
|
2018
|
+
currentValue: isDefaultsDisabled() ? "on" : "off",
|
|
2019
|
+
values: ["on", "off"],
|
|
2020
|
+
},
|
|
2021
|
+
{
|
|
2022
|
+
id: "toolDescriptionMode",
|
|
2023
|
+
label: "Tool description",
|
|
2024
|
+
description: "Agent tool description sent to the LLM: full (rich, default), compact (~75% fewer tokens, for small/local models), or custom (.pi/agent-tool-description.md with {{placeholders}})",
|
|
2025
|
+
currentValue: getToolDescriptionMode(),
|
|
2026
|
+
values: ["full", "compact", "custom"],
|
|
2027
|
+
},
|
|
1912
2028
|
];
|
|
1913
2029
|
}
|
|
1914
2030
|
|
|
@@ -1953,6 +2069,13 @@ ${systemPrompt}
|
|
|
1953
2069
|
const enabled = value === "on";
|
|
1954
2070
|
setScopeModelsEnabled(enabled);
|
|
1955
2071
|
notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
|
|
2072
|
+
} else if (id === "disableDefaultAgents") {
|
|
2073
|
+
const enabled = value === "on";
|
|
2074
|
+
setDisableDefaultAgents(enabled);
|
|
2075
|
+
notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
|
|
2076
|
+
} else if (id === "toolDescriptionMode") {
|
|
2077
|
+
setToolDescriptionMode(value as ToolDescriptionMode);
|
|
2078
|
+
notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
|
|
1956
2079
|
}
|
|
1957
2080
|
}
|
|
1958
2081
|
|
package/src/settings.ts
CHANGED
|
@@ -48,8 +48,28 @@ 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;
|
|
58
|
+
/**
|
|
59
|
+
* Which Agent tool description the LLM sees. "full" (default) is the rich
|
|
60
|
+
* Claude Code-style prompt; "compact" is a ~75% smaller version (one-line
|
|
61
|
+
* agent type list, terse usage notes) for small/local models where tool-spec
|
|
62
|
+
* tokens are expensive; "custom" reads `.pi/agent-tool-description.md`
|
|
63
|
+
* (project, falling back to `<agentDir>/agent-tool-description.md`) with
|
|
64
|
+
* `{{placeholder}}` substitution — a missing/empty file falls back to "full".
|
|
65
|
+
* The mode is read once at tool registration — changing it applies on the
|
|
66
|
+
* next pi session.
|
|
67
|
+
*/
|
|
68
|
+
toolDescriptionMode?: ToolDescriptionMode;
|
|
51
69
|
}
|
|
52
70
|
|
|
71
|
+
export type ToolDescriptionMode = "full" | "compact" | "custom";
|
|
72
|
+
|
|
53
73
|
/** Setter hooks used by applySettings to wire persisted values into in-memory state. */
|
|
54
74
|
export interface SettingsAppliers {
|
|
55
75
|
setMaxConcurrent: (n: number) => void;
|
|
@@ -58,12 +78,15 @@ export interface SettingsAppliers {
|
|
|
58
78
|
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
59
79
|
setSchedulingEnabled: (b: boolean) => void;
|
|
60
80
|
setScopeModels: (enabled: boolean) => void;
|
|
81
|
+
setDisableDefaultAgents: (b: boolean) => void;
|
|
82
|
+
setToolDescriptionMode: (mode: ToolDescriptionMode) => void;
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
|
|
64
86
|
export type SettingsEmit = (event: string, payload: unknown) => void;
|
|
65
87
|
|
|
66
88
|
const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
|
|
89
|
+
const VALID_TOOL_DESCRIPTION_MODES: ReadonlySet<string> = new Set<ToolDescriptionMode>(["full", "compact", "custom"]);
|
|
67
90
|
|
|
68
91
|
// Sanity ceilings — prevent hand-edited configs from asking for values that
|
|
69
92
|
// make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
|
|
@@ -107,6 +130,12 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
107
130
|
if (typeof r.scopeModels === "boolean") {
|
|
108
131
|
out.scopeModels = r.scopeModels;
|
|
109
132
|
}
|
|
133
|
+
if (typeof r.disableDefaultAgents === "boolean") {
|
|
134
|
+
out.disableDefaultAgents = r.disableDefaultAgents;
|
|
135
|
+
}
|
|
136
|
+
if (typeof r.toolDescriptionMode === "string" && VALID_TOOL_DESCRIPTION_MODES.has(r.toolDescriptionMode)) {
|
|
137
|
+
out.toolDescriptionMode = r.toolDescriptionMode as ToolDescriptionMode;
|
|
138
|
+
}
|
|
110
139
|
return out;
|
|
111
140
|
}
|
|
112
141
|
|
|
@@ -163,6 +192,8 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
163
192
|
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
|
|
164
193
|
if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
|
|
165
194
|
if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels);
|
|
195
|
+
if (typeof s.disableDefaultAgents === "boolean") appliers.setDisableDefaultAgents(s.disableDefaultAgents);
|
|
196
|
+
if (s.toolDescriptionMode) appliers.setToolDescriptionMode(s.toolDescriptionMode);
|
|
166
197
|
}
|
|
167
198
|
|
|
168
199
|
/**
|
package/src/types.ts
CHANGED
|
@@ -33,6 +33,9 @@ export interface AgentConfig {
|
|
|
33
33
|
disallowedTools?: string[];
|
|
34
34
|
/** true = inherit all, string[] = only listed, false = none */
|
|
35
35
|
extensions: true | string[] | false;
|
|
36
|
+
/** Extension-name denylist applied after the `extensions:` include set. Exclude wins.
|
|
37
|
+
* Plain canonical names only (case-insensitive); no paths, no wildcard. */
|
|
38
|
+
excludeExtensions?: string[];
|
|
36
39
|
/** true = inherit all, string[] = only listed, false = none */
|
|
37
40
|
skills: true | string[] | false;
|
|
38
41
|
model?: string;
|
|
@@ -80,7 +83,7 @@ export interface AgentRecord {
|
|
|
80
83
|
/** Steering messages queued before the session was ready. */
|
|
81
84
|
pendingSteers?: string[];
|
|
82
85
|
/** Worktree info if the agent is running in an isolated worktree. */
|
|
83
|
-
worktree?: { path: string; branch: string };
|
|
86
|
+
worktree?: { path: string; branch: string; baseSha: string };
|
|
84
87
|
/** Worktree cleanup result after agent completion. */
|
|
85
88
|
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
86
89
|
/** The tool_use_id from the original Agent tool call. */
|
|
@@ -12,6 +12,7 @@ import type { AgentRecord } from "../types.js";
|
|
|
12
12
|
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
13
13
|
import type { Theme } from "./agent-widget.js";
|
|
14
14
|
import { type AgentActivity, buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
|
|
15
|
+
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
|
|
15
16
|
|
|
16
17
|
/** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
|
|
17
18
|
const CHROME_LINES_BASE = 6;
|
|
@@ -27,6 +28,7 @@ export class ConversationViewer implements Component {
|
|
|
27
28
|
private closed = false;
|
|
28
29
|
/** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
|
|
29
30
|
private stopArmed = false;
|
|
31
|
+
private keys: ViewerKeys;
|
|
30
32
|
|
|
31
33
|
constructor(
|
|
32
34
|
private tui: TUI,
|
|
@@ -37,7 +39,10 @@ export class ConversationViewer implements Component {
|
|
|
37
39
|
private done: (result: undefined) => void,
|
|
38
40
|
/** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
|
|
39
41
|
private onStop?: () => void,
|
|
42
|
+
/** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
|
|
43
|
+
keybindings?: ViewerKeybindings,
|
|
40
44
|
) {
|
|
45
|
+
this.keys = createViewerKeys(keybindings);
|
|
41
46
|
this.unsubscribe = session.subscribe(() => {
|
|
42
47
|
if (this.closed) return;
|
|
43
48
|
this.tui.requestRender();
|
|
@@ -71,16 +76,16 @@ export class ConversationViewer implements Component {
|
|
|
71
76
|
const viewportHeight = this.viewportHeight();
|
|
72
77
|
const maxScroll = Math.max(0, totalLines - viewportHeight);
|
|
73
78
|
|
|
74
|
-
if (
|
|
79
|
+
if (this.keys.scrollUp(data)) {
|
|
75
80
|
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
76
81
|
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
77
|
-
} else if (
|
|
82
|
+
} else if (this.keys.scrollDown(data)) {
|
|
78
83
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
|
|
79
84
|
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
80
|
-
} else if (
|
|
85
|
+
} else if (this.keys.pageUp(data)) {
|
|
81
86
|
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
|
|
82
87
|
this.autoScroll = false;
|
|
83
|
-
} else if (
|
|
88
|
+
} else if (this.keys.pageDown(data)) {
|
|
84
89
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
|
|
85
90
|
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
86
91
|
} else if (matchesKey(data, "home")) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* viewer-keys.ts — Scroll key matchers for the conversation viewer.
|
|
3
|
+
*
|
|
4
|
+
* Resolves `tui.select.*` through the user's keybindings when pi provides a
|
|
5
|
+
* manager, falling back to the previous hardcoded keys otherwise. The viewer's
|
|
6
|
+
* k/j and shift+arrow aliases always work alongside whatever is bound.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
/** The `tui.select.*` keybinding ids the viewer resolves. */
|
|
12
|
+
export type ViewerScrollKeybinding =
|
|
13
|
+
| "tui.select.up"
|
|
14
|
+
| "tui.select.down"
|
|
15
|
+
| "tui.select.pageUp"
|
|
16
|
+
| "tui.select.pageDown";
|
|
17
|
+
|
|
18
|
+
/** Structural subset of pi-tui's `KeybindingsManager` (which satisfies it). */
|
|
19
|
+
export interface ViewerKeybindings {
|
|
20
|
+
matches(data: string, keybinding: ViewerScrollKeybinding): boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ViewerKeys {
|
|
24
|
+
scrollUp(data: string): boolean;
|
|
25
|
+
scrollDown(data: string): boolean;
|
|
26
|
+
pageUp(data: string): boolean;
|
|
27
|
+
pageDown(data: string): boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createViewerKeys(keybindings?: ViewerKeybindings): ViewerKeys {
|
|
31
|
+
const matches = (data: string, id: ViewerScrollKeybinding, fallback: KeyId): boolean =>
|
|
32
|
+
keybindings ? keybindings.matches(data, id) : matchesKey(data, fallback);
|
|
33
|
+
return {
|
|
34
|
+
scrollUp: (data) => matches(data, "tui.select.up", "up") || matchesKey(data, "k"),
|
|
35
|
+
scrollDown: (data) => matches(data, "tui.select.down", "down") || matchesKey(data, "j"),
|
|
36
|
+
pageUp: (data) => matches(data, "tui.select.pageUp", "pageUp") || matchesKey(data, "shift+up"),
|
|
37
|
+
pageDown: (data) => matches(data, "tui.select.pageDown", "pageDown") || matchesKey(data, "shift+down"),
|
|
38
|
+
};
|
|
39
|
+
}
|
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
|
+
});
|