@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/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, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.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";
@@ -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 the unified registry. */
603
+ /** Build the full type list text dynamically from available agents only. */
584
604
  const buildTypeListText = () => {
585
- const allNames = [...getDefaultAgentNames(), ...getUserAgentNames()];
605
+ const available = getAvailableTypes();
586
606
 
587
- return allNames.map((name) => {
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
- pi.registerTool(defineTool({
645
- name: SUBAGENT_TOOL_NAMES.AGENT,
646
- label: "Agent",
647
- description: `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
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
- ${typeListText}
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, _keybindings, done) => {
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 (matchesKey(data, "up") || matchesKey(data, "k")) {
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 (matchesKey(data, "down") || matchesKey(data, "j")) {
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 (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
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 (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
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 (!status) {
85
- // No changesremove worktree
86
- removeWorktree(cwd, worktree.path);
87
- return { hasChanges: false };
88
- }
89
+ if (status) {
90
+ // Changes existstage, 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
- // 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", "-m", commitMsg], {
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.
@@ -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
+ });