@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/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, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -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 the unified registry. */
595
+ /** Build the full type list text dynamically from available agents only. */
593
596
  const buildTypeListText = () => {
594
- const allNames = [...getDefaultAgentNames(), ...getUserAgentNames()];
597
+ const available = getAvailableTypes();
595
598
 
596
- return allNames.map((name) => {
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: "Agent",
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
- ${typeListText}
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 · 5≤30 · 3 tool uses · 33.8k tokens" stats string
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: "get_subagent_result",
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: "steer_subagent",
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: env header + parent system prompt + sub-agent context + config.systemPrompt
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 prepend an `<active_agent name="${config.name}"/>` tag so downstream
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
- return activeAgentTag + envBlock + "\n\n<inherited_system_prompt>\n" + identity + "\n</inherited_system_prompt>\n\n" + bridge + customSection + extrasSuffix;
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. */
@@ -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 `↻N` in dim.
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 (2)" — compactions only (e.g. right after compact)
108
- * "12.3k token (45% · 2)" — both
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", `↻${compactions}`));
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: "5≤30" or "5". */
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 ? `⟳${turnCount}≤${maxTurns}` : `⟳${turnCount}`;
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 footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
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 (!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
+ });