@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.
@@ -4,11 +4,27 @@
4
4
  * Merges embedded default agents with user-defined agents from .pi/agents/*.md.
5
5
  * User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
6
6
  */
7
+ import { createCodingTools, createReadOnlyTools } from "@earendil-works/pi-coding-agent";
7
8
  import { DEFAULT_AGENTS } from "./default-agents.js";
8
- /** All known built-in tool names. */
9
- export const BUILTIN_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"];
9
+ /**
10
+ * All known built-in tool names, derived from pi's own tool factories rather
11
+ * than hardcoded so the set tracks pi-mono if it adds/renames a built-in.
12
+ * `createCodingTools` → read/bash/edit/write; `createReadOnlyTools` →
13
+ * read/grep/find/ls; their de-duplicated union is the 7 built-ins
14
+ * (read, bash, edit, write, grep, find, ls). The `cwd` only binds tool
15
+ * operations we never invoke here — we read each tool's `.name` and discard it.
16
+ */
17
+ export const BUILTIN_TOOL_NAMES = [
18
+ ...new Set([...createCodingTools("."), ...createReadOnlyTools(".")].map((t) => t.name)),
19
+ ];
10
20
  /** Unified runtime registry of all agents (defaults + user-defined). */
11
21
  const agents = new Map();
22
+ /** When true, DEFAULT_AGENTS are skipped during registration. */
23
+ let disableDefaults = false;
24
+ /** Check whether default agents are disabled. */
25
+ export function isDefaultsDisabled() { return disableDefaults; }
26
+ /** Set whether default agents are disabled. */
27
+ export function setDefaultsDisabled(b) { disableDefaults = b; }
12
28
  /**
13
29
  * Register agents into the unified registry.
14
30
  * Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name).
@@ -16,9 +32,11 @@ const agents = new Map();
16
32
  */
17
33
  export function registerAgents(userAgents) {
18
34
  agents.clear();
19
- // Start with defaults
20
- for (const [name, config] of DEFAULT_AGENTS) {
21
- agents.set(name, config);
35
+ // Start with defaults (unless disabled via settings)
36
+ if (!disableDefaults) {
37
+ for (const [name, config] of DEFAULT_AGENTS) {
38
+ agents.set(name, config);
39
+ }
22
40
  }
23
41
  // Overlay user agents (overrides defaults with same name)
24
42
  for (const [name, config] of userAgents) {
@@ -95,8 +113,9 @@ export function getToolNamesForType(type) {
95
113
  const key = resolveKey(type);
96
114
  const raw = key ? agents.get(key) : undefined;
97
115
  const config = raw?.enabled !== false ? raw : undefined;
98
- const names = config?.builtinToolNames?.length ? config.builtinToolNames : [...BUILTIN_TOOL_NAMES];
99
- return names;
116
+ // `undefined` (definition omitted the field) all built-ins; an explicit `[]`
117
+ // (`tools: none` or a `tools:` with only `ext:` entries) → zero built-ins.
118
+ return config?.builtinToolNames ?? [...BUILTIN_TOOL_NAMES];
100
119
  }
101
120
  /** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */
102
121
  export function getConfig(type) {
@@ -43,11 +43,13 @@ function loadFromDir(dir, agents, source) {
43
43
  continue;
44
44
  }
45
45
  const { frontmatter: fm, body } = parseFrontmatter(content);
46
+ const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
46
47
  agents.set(name, {
47
48
  name,
48
49
  displayName: str(fm.display_name),
49
50
  description: str(fm.description) ?? name,
50
- builtinToolNames: csvList(fm.tools, BUILTIN_TOOL_NAMES),
51
+ builtinToolNames,
52
+ extSelectors,
51
53
  disallowedTools: csvListOptional(fm.disallowed_tools),
52
54
  extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
53
55
  skills: inheritField(fm.skills ?? fm.inherit_skills),
@@ -97,6 +99,24 @@ function csvList(val, defaults) {
97
99
  return defaults;
98
100
  return parseCsvField(val) ?? [];
99
101
  }
102
+ /**
103
+ * Partition the `tools:` CSV into the built-in tool allowlist and raw `ext:` selectors.
104
+ * `*` (and the case-insensitive alias `all`, for `tools: all`) expands to all
105
+ * built-ins; plain entries are built-in names; `ext:` entries are extension-tool
106
+ * selectors parsed later by the runner. omitted → all built-ins, no selectors.
107
+ * `tools:` present with only `ext:` entries → zero built-ins (use `*`).
108
+ */
109
+ function parseToolsField(val) {
110
+ const entries = csvList(val, BUILTIN_TOOL_NAMES);
111
+ const isWildcard = (e) => e === "*" || e.toLowerCase() === "all";
112
+ const hasWildcard = entries.some(isWildcard);
113
+ const plain = entries.filter(e => !isWildcard(e) && !e.startsWith("ext:"));
114
+ const extEntries = entries.filter(e => e.startsWith("ext:"));
115
+ return {
116
+ builtinToolNames: hasWildcard ? [...new Set([...BUILTIN_TOOL_NAMES, ...plain])] : plain,
117
+ extSelectors: extEntries.length > 0 ? extEntries : undefined,
118
+ };
119
+ }
100
120
  /**
101
121
  * Parse an optional comma-separated list field.
102
122
  * omitted → undefined; "none"/empty → undefined; csv → listed items.
package/dist/index.js CHANGED
@@ -15,8 +15,8 @@ import { defineTool, getAgentDir, getSettingsListTheme } from "@earendil-works/p
15
15
  import { Container, Key, matchesKey, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
16
16
  import { Type } from "@sinclair/typebox";
17
17
  import { AgentManager } from "./agent-manager.js";
18
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
19
- import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
18
+ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
19
+ import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
20
20
  import { registerRpcHandlers } from "./cross-extension-rpc.js";
21
21
  import { loadCustomAgents } from "./custom-agents.js";
22
22
  import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
@@ -27,6 +27,7 @@ import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./o
27
27
  import { SubagentScheduler } from "./schedule.js";
28
28
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
29
29
  import { applyAndEmitLoaded, saveAndEmitChanged } from "./settings.js";
30
+ import { getStatusNote } from "./status-note.js";
30
31
  import { AgentWidget, buildInvocationTags, describeActivity, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
31
32
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
32
33
  import { addUsage, getLifetimeTotal, getSessionContextPercent } from "./usage.js";
@@ -98,15 +99,6 @@ function getStatusLabel(status, error) {
98
99
  default: return "Done";
99
100
  }
100
101
  }
101
- /** Parenthetical status note for completed agent result text. */
102
- function getStatusNote(status) {
103
- switch (status) {
104
- case "aborted": return " (aborted — max turns exceeded, output may be incomplete)";
105
- case "steered": return " (wrapped up — reached turn limit)";
106
- case "stopped": return " (stopped by user)";
107
- default: return "";
108
- }
109
- }
110
102
  /** Escape XML special characters to prevent injection in structured notifications. */
111
103
  function escapeXml(s) {
112
104
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -130,7 +122,7 @@ function formatTaskNotification(record, resultMaxLen) {
130
122
  record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
131
123
  record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
132
124
  `<status>${escapeXml(status)}</status>`,
133
- `<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
125
+ `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
134
126
  `<result>${escapeXml(resultPreview)}</result>`,
135
127
  `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
136
128
  `</task-notification>`,
@@ -470,6 +462,17 @@ export default function (pi) {
470
462
  let scopeModelsEnabled = false;
471
463
  function isScopeModelsEnabled() { return scopeModelsEnabled; }
472
464
  function setScopeModelsEnabled(enabled) { scopeModelsEnabled = enabled; }
465
+ // ---- Disable default agents configuration ----
466
+ // When enabled, the three hardcoded default agents (general-purpose, Explore,
467
+ // Plan) are not registered. User-defined agents from .pi/agents/*.md are
468
+ // completely unaffected — only DEFAULT_AGENTS are suppressed.
469
+ // Defaults to false; opt-in via `/agents → Settings` or subagents.json.
470
+ // State lives in agent-types.ts (isDefaultsDisabled) because registerAgents
471
+ // needs it; this wrapper just re-registers after flipping it.
472
+ function setDisableDefaultAgents(b) {
473
+ setDefaultsDisabled(b);
474
+ reloadCustomAgents(); // re-register with new setting
475
+ }
473
476
  // ---- Batch tracking for smart join mode ----
474
477
  // Collects background agent IDs spawned in the current turn for smart grouping.
475
478
  // Uses a debounced timer: each new agent resets the 100ms window so that all
@@ -526,10 +529,10 @@ export default function (pi) {
526
529
  && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t));
527
530
  return isFullSet ? "*" : tools.join(", ");
528
531
  };
529
- /** Build the full type list text dynamically from the unified registry. */
532
+ /** Build the full type list text dynamically from available agents only. */
530
533
  const buildTypeListText = () => {
531
- const allNames = [...getDefaultAgentNames(), ...getUserAgentNames()];
532
- return allNames.map((name) => {
534
+ const available = getAvailableTypes();
535
+ return available.map((name) => {
533
536
  const cfg = getAgentConfig(name);
534
537
  const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
535
538
  const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
@@ -543,7 +546,6 @@ export default function (pi) {
543
546
  // Strip trailing date suffix (e.g. "claude-haiku-4-5-20251001" → "claude-haiku-4-5")
544
547
  return name.replace(/-\d{8}$/, "");
545
548
  }
546
- const typeListText = buildTypeListText();
547
549
  // Apply persisted settings on startup and emit `subagents:settings_loaded`.
548
550
  // Global + project merged; missing → defaults; corrupt file emits a warning
549
551
  // to stderr and falls back to defaults.
@@ -554,6 +556,7 @@ export default function (pi) {
554
556
  setDefaultJoinMode,
555
557
  setSchedulingEnabled,
556
558
  setScopeModels: setScopeModelsEnabled,
559
+ setDisableDefaultAgents: setDisableDefaultAgents,
557
560
  }, (event, payload) => pi.events.emit(event, payload));
558
561
  // ---- Agent tool ----
559
562
  // Schedule param + its guideline are gated on `schedulingEnabled` (read once
@@ -573,12 +576,12 @@ export default function (pi) {
573
576
  ? `\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.`
574
577
  : "";
575
578
  pi.registerTool(defineTool({
576
- name: "Agent",
579
+ name: SUBAGENT_TOOL_NAMES.AGENT,
577
580
  label: "Agent",
578
581
  description: `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
579
582
 
580
583
  Available agent types and the tools they have access to:
581
- ${typeListText}
584
+ ${buildTypeListText()}
582
585
 
583
586
  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.
584
587
 
@@ -673,7 +676,7 @@ Terse command-style prompts produce shallow, generic work.
673
676
  const text = result.content[0]?.type === "text" ? result.content[0].text : "";
674
677
  return new Text(text, 0, 0);
675
678
  }
676
- // Helper: build "haiku · thinking: high · 5≤30 · 3 tool uses · 33.8k tokens" stats string
679
+ // Helper: build "haiku · thinking: high · 5≤30 · 3 tool uses · 33.8k tokens" stats string
677
680
  const stats = (d) => {
678
681
  const parts = [];
679
682
  if (d.modelName)
@@ -1026,8 +1029,10 @@ Terse command-style prompts produce shallow, generic work.
1026
1029
  // Get final token count
1027
1030
  const tokenText = formatLifetimeTokens(fgState);
1028
1031
  const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
1032
+ // "general-purpose" may itself be unregistered (defaults disabled, no
1033
+ // user override) — getConfig then uses the hardcoded fallback config.
1029
1034
  const fallbackNote = fellBack
1030
- ? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
1035
+ ? `Note: Unknown agent type "${rawType}" — using ${resolveType("general-purpose") ? "general-purpose" : "the fallback agent config"}.\n\n`
1031
1036
  : "";
1032
1037
  if (record.status === "error") {
1033
1038
  return textResult(`${fallbackNote}Agent failed: ${record.error}`, details);
@@ -1042,7 +1047,7 @@ Terse command-style prompts produce shallow, generic work.
1042
1047
  }));
1043
1048
  // ---- get_subagent_result tool ----
1044
1049
  pi.registerTool(defineTool({
1045
- name: "get_subagent_result",
1050
+ name: SUBAGENT_TOOL_NAMES.GET_RESULT,
1046
1051
  label: "Get Agent Result",
1047
1052
  description: "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
1048
1053
  promptSnippet: "Check status and retrieve results from a background agent",
@@ -1084,7 +1089,7 @@ Terse command-style prompts produce shallow, generic work.
1084
1089
  statsParts.push(`Compactions: ${record.compactionCount}`);
1085
1090
  statsParts.push(`Duration: ${duration}`);
1086
1091
  let output = `Agent: ${record.id}\n` +
1087
- `Type: ${displayName} | Status: ${record.status} | ${statsParts.join(" | ")}\n` +
1092
+ `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
1088
1093
  `Description: ${record.description}\n\n`;
1089
1094
  if (record.status === "running") {
1090
1095
  output += "Agent is still running. Use wait: true or check back later.";
@@ -1112,7 +1117,7 @@ Terse command-style prompts produce shallow, generic work.
1112
1117
  }));
1113
1118
  // ---- steer_subagent tool ----
1114
1119
  pi.registerTool(defineTool({
1115
- name: "steer_subagent",
1120
+ name: SUBAGENT_TOOL_NAMES.STEER,
1116
1121
  label: "Steer Agent",
1117
1122
  description: "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
1118
1123
  "and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
@@ -1322,7 +1327,11 @@ Terse command-style prompts produce shallow, generic work.
1322
1327
  const session = record.session;
1323
1328
  const activity = agentActivity.get(record.id);
1324
1329
  await ctx.ui.custom((tui, theme, _keybindings, done) => {
1325
- return new ConversationViewer(tui, session, record, activity, theme, done);
1330
+ return new ConversationViewer(tui, session, record, activity, theme, done, () => {
1331
+ if (manager.abort(record.id)) {
1332
+ ctx.ui.notify(`Stopped "${record.description}".`, "info");
1333
+ }
1334
+ });
1326
1335
  }, {
1327
1336
  overlay: true,
1328
1337
  overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
@@ -1694,6 +1703,7 @@ ${systemPrompt}
1694
1703
  defaultJoinMode: getDefaultJoinMode(),
1695
1704
  schedulingEnabled: isSchedulingEnabled(),
1696
1705
  scopeModels: isScopeModelsEnabled(),
1706
+ disableDefaultAgents: isDefaultsDisabled(),
1697
1707
  };
1698
1708
  }
1699
1709
  const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns"]);
@@ -1745,6 +1755,13 @@ ${systemPrompt}
1745
1755
  currentValue: isScopeModelsEnabled() ? "on" : "off",
1746
1756
  values: ["on", "off"],
1747
1757
  },
1758
+ {
1759
+ id: "disableDefaultAgents",
1760
+ label: "Disable defaults",
1761
+ description: "Hide built-in agents (general-purpose, Explore, Plan) — custom agents are unaffected",
1762
+ currentValue: isDefaultsDisabled() ? "on" : "off",
1763
+ values: ["on", "off"],
1764
+ },
1748
1765
  ];
1749
1766
  }
1750
1767
  function applyValue(id, value) {
@@ -1794,6 +1811,11 @@ ${systemPrompt}
1794
1811
  setScopeModelsEnabled(enabled);
1795
1812
  notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
1796
1813
  }
1814
+ else if (id === "disableDefaultAgents") {
1815
+ const enabled = value === "on";
1816
+ setDisableDefaultAgents(enabled);
1817
+ notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
1818
+ }
1797
1819
  }
1798
1820
  let list;
1799
1821
  // Track current selection index directly (SettingsList doesn't expose it).
package/dist/prompts.d.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).
package/dist/prompts.js CHANGED
@@ -5,12 +5,15 @@
5
5
  * Build the system prompt for an agent from its config.
6
6
  *
7
7
  * - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
8
- * - "append" mode: env header + parent system prompt + sub-agent context + config.systemPrompt
8
+ * - "append" mode: parent system prompt + sub-agent context + env header + config.systemPrompt
9
9
  * - "append" with empty systemPrompt: pure parent clone
10
10
  *
11
- * Both modes prepend an `<active_agent name="${config.name}"/>` tag so downstream
11
+ * Both modes include an `<active_agent name="${config.name}"/>` tag so downstream
12
12
  * extensions (e.g. permission/policy systems) can resolve per-agent policy
13
- * inside the child session by parsing the system prompt.
13
+ * inside the child session by parsing the system prompt. In replace mode the tag
14
+ * is prepended; in append mode it follows the shared inherited content so the
15
+ * parent prompt forms an identical, cacheable byte prefix with the parent
16
+ * session (the LLM's KV cache can then reuse those tokens across every spawn).
14
17
  *
15
18
  * @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
16
19
  * @param extras Optional extra sections to inject (memory, preloaded skills).
@@ -49,7 +52,12 @@ You are operating as a sub-agent invoked to handle a specific task.
49
52
  const customSection = config.systemPrompt?.trim()
50
53
  ? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
51
54
  : "";
52
- return activeAgentTag + envBlock + "\n\n<inherited_system_prompt>\n" + identity + "\n</inherited_system_prompt>\n\n" + bridge + customSection + extrasSuffix;
55
+ // Place shared/stable content first so the LLM's KV cache can reuse the
56
+ // inherited prefix across all subagent invocations. The parent prompt is
57
+ // placed verbatim (no wrapper tag) so it forms an identical byte prefix
58
+ // with the parent session, maximising KV cache hits. The <active_agent>
59
+ // tag and env block vary per call and are placed after the cached prefix.
60
+ return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + customSection + extrasSuffix;
53
61
  }
54
62
  // "replace" mode — env header + the config's full system prompt
55
63
  const replaceHeader = `You are a pi coding agent sub-agent.
@@ -40,6 +40,13 @@ export interface SubagentsSettings {
40
40
  * against. Defaults to false: subagents may use any model.
41
41
  */
42
42
  scopeModels?: boolean;
43
+ /**
44
+ * When true, the three built-in default agents (general-purpose, Explore, Plan)
45
+ * are not registered at startup. User-defined agents from .pi/agents/*.md are
46
+ * completely unaffected — only the hardcoded DEFAULT_AGENTS are suppressed.
47
+ * Defaults to false.
48
+ */
49
+ disableDefaultAgents?: boolean;
43
50
  }
44
51
  /** Setter hooks used by applySettings to wire persisted values into in-memory state. */
45
52
  export interface SettingsAppliers {
@@ -49,6 +56,7 @@ export interface SettingsAppliers {
49
56
  setDefaultJoinMode: (mode: JoinMode) => void;
50
57
  setSchedulingEnabled: (b: boolean) => void;
51
58
  setScopeModels: (enabled: boolean) => void;
59
+ setDisableDefaultAgents: (b: boolean) => void;
52
60
  }
53
61
  /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
54
62
  export type SettingsEmit = (event: string, payload: unknown) => void;
package/dist/settings.js CHANGED
@@ -41,6 +41,9 @@ function sanitize(raw) {
41
41
  if (typeof r.scopeModels === "boolean") {
42
42
  out.scopeModels = r.scopeModels;
43
43
  }
44
+ if (typeof r.disableDefaultAgents === "boolean") {
45
+ out.disableDefaultAgents = r.disableDefaultAgents;
46
+ }
44
47
  return out;
45
48
  }
46
49
  function globalPath() {
@@ -100,6 +103,8 @@ export function applySettings(s, appliers) {
100
103
  appliers.setSchedulingEnabled(s.schedulingEnabled);
101
104
  if (typeof s.scopeModels === "boolean")
102
105
  appliers.setScopeModels(s.scopeModels);
106
+ if (typeof s.disableDefaultAgents === "boolean")
107
+ appliers.setDisableDefaultAgents(s.disableDefaultAgents);
103
108
  }
104
109
  /**
105
110
  * Format the user-facing toast for a settings mutation. Pure function —
@@ -0,0 +1,13 @@
1
+ /**
2
+ * status-note.ts — Parenthetical status note appended to agent result text.
3
+ */
4
+ /**
5
+ * Explicit parenthetical note for a non-normal terminal outcome, so the parent
6
+ * agent can't mistake partial output for a completed result. Empty string for a
7
+ * clean completion (and any unknown/non-terminal status).
8
+ *
9
+ * `stopped` (a human aborted it) is deliberately distinct from `aborted` (the
10
+ * turn limit was hit) — the parent should treat human intervention differently
11
+ * from a budget cutoff.
12
+ */
13
+ export declare function getStatusNote(status: string): string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * status-note.ts — Parenthetical status note appended to agent result text.
3
+ */
4
+ /**
5
+ * Explicit parenthetical note for a non-normal terminal outcome, so the parent
6
+ * agent can't mistake partial output for a completed result. Empty string for a
7
+ * clean completion (and any unknown/non-terminal status).
8
+ *
9
+ * `stopped` (a human aborted it) is deliberately distinct from `aborted` (the
10
+ * turn limit was hit) — the parent should treat human intervention differently
11
+ * from a budget cutoff.
12
+ */
13
+ export function getStatusNote(status) {
14
+ switch (status) {
15
+ case "stopped":
16
+ return " (STOPPED BY THE USER before completion — output is partial; the task was NOT finished)";
17
+ case "aborted":
18
+ return " (aborted — hit the turn limit before completion; output may be incomplete)";
19
+ case "steered":
20
+ return " (wrapped up at the turn limit — output may be partial)";
21
+ default:
22
+ return "";
23
+ }
24
+ }
package/dist/types.d.ts CHANGED
@@ -19,6 +19,9 @@ export interface AgentConfig {
19
19
  displayName?: string;
20
20
  description: string;
21
21
  builtinToolNames?: string[];
22
+ /** Raw `ext:` selector entries from the `tools:` CSV, e.g. ["ext:foo", "ext:bar/x"].
23
+ * Presence of any entry flips extension tools to an explicit allowlist. */
24
+ extSelectors?: string[];
22
25
  /** Tool denylist — these tools are removed even if `builtinToolNames` or extensions include them. */
23
26
  disallowedTools?: string[];
24
27
  /** true = inherit all, string[] = only listed, false = none */
@@ -71,6 +74,7 @@ export interface AgentRecord {
71
74
  worktree?: {
72
75
  path: string;
73
76
  branch: string;
77
+ baseSha: string;
74
78
  };
75
79
  /** Worktree cleanup result after agent completion. */
76
80
  worktreeResult?: {
@@ -66,15 +66,15 @@ export declare function formatTokens(count: number): string;
66
66
  /**
67
67
  * Token count with optional context-fill % and compaction-count annotations.
68
68
  * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
69
- * Compaction count rendered as `↻N` in dim.
69
+ * Compaction count rendered as `⇊N` in dim.
70
70
  *
71
71
  * "12.3k token" — no annotations
72
72
  * "12.3k token (45%)" — percent only
73
- * "12.3k token (2)" — compactions only (e.g. right after compact)
74
- * "12.3k token (45% · 2)" — both
73
+ * "12.3k token (2)" — compactions only (e.g. right after compact)
74
+ * "12.3k token (45% · 2)" — both
75
75
  */
76
76
  export declare function formatSessionTokens(tokens: number, percent: number | null, theme: Theme, compactions?: number): string;
77
- /** Format turn count with optional max limit: "5≤30" or "5". */
77
+ /** Format turn count with optional max limit: "5≤30" or "5". */
78
78
  export declare function formatTurns(turnCount: number, maxTurns?: number | null): string;
79
79
  /** Format milliseconds as human-readable duration. */
80
80
  export declare function formatMs(ms: number): string;
@@ -36,12 +36,12 @@ export function formatTokens(count) {
36
36
  /**
37
37
  * Token count with optional context-fill % and compaction-count annotations.
38
38
  * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
39
- * Compaction count rendered as `↻N` in dim.
39
+ * Compaction count rendered as `⇊N` in dim.
40
40
  *
41
41
  * "12.3k token" — no annotations
42
42
  * "12.3k token (45%)" — percent only
43
- * "12.3k token (2)" — compactions only (e.g. right after compact)
44
- * "12.3k token (45% · 2)" — both
43
+ * "12.3k token (2)" — compactions only (e.g. right after compact)
44
+ * "12.3k token (45% · 2)" — both
45
45
  */
46
46
  export function formatSessionTokens(tokens, percent, theme, compactions = 0) {
47
47
  const tokenStr = formatTokens(tokens);
@@ -51,15 +51,15 @@ export function formatSessionTokens(tokens, percent, theme, compactions = 0) {
51
51
  annot.push(theme.fg(color, `${Math.round(percent)}%`));
52
52
  }
53
53
  if (compactions > 0) {
54
- annot.push(theme.fg("dim", `↻${compactions}`));
54
+ annot.push(theme.fg("dim", `⇊${compactions}`));
55
55
  }
56
56
  if (annot.length === 0)
57
57
  return tokenStr;
58
58
  return `${tokenStr} (${annot.join(" · ")})`;
59
59
  }
60
- /** Format turn count with optional max limit: "5≤30" or "5". */
60
+ /** Format turn count with optional max limit: "5≤30" or "5". */
61
61
  export function formatTurns(turnCount, maxTurns) {
62
- return maxTurns != null ? `⟳${turnCount}≤${maxTurns}` : `⟳${turnCount}`;
62
+ return maxTurns != null ? `↻${turnCount}≤${maxTurns}` : `↻${turnCount}`;
63
63
  }
64
64
  /** Format milliseconds as human-readable duration. */
65
65
  export function formatMs(ms) {
@@ -18,14 +18,22 @@ export declare class ConversationViewer implements Component {
18
18
  private activity;
19
19
  private theme;
20
20
  private done;
21
+ /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
22
+ private onStop?;
21
23
  private scrollOffset;
22
24
  private autoScroll;
23
25
  private unsubscribe;
24
26
  private lastInnerW;
25
27
  private closed;
26
- constructor(tui: TUI, session: AgentSession, record: AgentRecord, activity: AgentActivity | undefined, theme: Theme, done: (result: undefined) => void);
28
+ /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
29
+ private stopArmed;
30
+ constructor(tui: TUI, session: AgentSession, record: AgentRecord, activity: AgentActivity | undefined, theme: Theme, done: (result: undefined) => void,
31
+ /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
32
+ onStop?: (() => void) | undefined);
27
33
  handleInput(data: string): void;
28
34
  render(width: number): string[];
35
+ /** Stoppable only when a stop handler exists and the agent is still active. */
36
+ private isStoppable;
29
37
  invalidate(): void;
30
38
  dispose(): void;
31
39
  private viewportHeight;
@@ -20,18 +20,24 @@ export class ConversationViewer {
20
20
  activity;
21
21
  theme;
22
22
  done;
23
+ onStop;
23
24
  scrollOffset = 0;
24
25
  autoScroll = true;
25
26
  unsubscribe;
26
27
  lastInnerW = 0;
27
28
  closed = false;
28
- constructor(tui, session, record, activity, theme, done) {
29
+ /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
30
+ stopArmed = false;
31
+ constructor(tui, session, record, activity, theme, done,
32
+ /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
33
+ onStop) {
29
34
  this.tui = tui;
30
35
  this.session = session;
31
36
  this.record = record;
32
37
  this.activity = activity;
33
38
  this.theme = theme;
34
39
  this.done = done;
40
+ this.onStop = onStop;
35
41
  this.unsubscribe = session.subscribe(() => {
36
42
  if (this.closed)
37
43
  return;
@@ -44,6 +50,23 @@ export class ConversationViewer {
44
50
  this.done(undefined);
45
51
  return;
46
52
  }
53
+ // Stop/abort the agent (only while it can still be stopped). Two-press:
54
+ // first "x" arms, second confirms — any other key disarms.
55
+ if (matchesKey(data, "x")) {
56
+ if (this.isStoppable()) {
57
+ if (this.stopArmed) {
58
+ this.stopArmed = false;
59
+ this.onStop?.();
60
+ }
61
+ else {
62
+ this.stopArmed = true;
63
+ }
64
+ this.tui.requestRender();
65
+ }
66
+ return;
67
+ }
68
+ if (this.stopArmed)
69
+ this.stopArmed = false;
47
70
  const totalLines = this.buildContentLines(this.lastInnerW).length;
48
71
  const viewportHeight = this.viewportHeight();
49
72
  const maxScroll = Math.max(0, totalLines - viewportHeight);
@@ -132,12 +155,22 @@ export class ConversationViewer {
132
155
  ? "100%"
133
156
  : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
134
157
  const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
135
- const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
158
+ const scrollHint = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
159
+ // Stop hint goes first in the right group so it survives right-edge
160
+ // truncation on narrow terminals (the scroll hint is the expendable part).
161
+ const footerRight = this.isStoppable()
162
+ ? (this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop")) +
163
+ th.fg("dim", " · ") + scrollHint
164
+ : scrollHint;
136
165
  const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
137
166
  lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
138
167
  lines.push(hrBot);
139
168
  return lines;
140
169
  }
170
+ /** Stoppable only when a stop handler exists and the agent is still active. */
171
+ isStoppable() {
172
+ return !!this.onStop && (this.record.status === "running" || this.record.status === "queued");
173
+ }
141
174
  invalidate() { }
142
175
  dispose() {
143
176
  this.closed = true;
@@ -10,6 +10,8 @@ export interface WorktreeInfo {
10
10
  path: string;
11
11
  /** Branch name created for this worktree (if changes exist). */
12
12
  branch: string;
13
+ /** Commit SHA that the worktree was created from. */
14
+ baseSha: string;
13
15
  }
14
16
  export interface WorktreeCleanupResult {
15
17
  /** Whether changes were found in the worktree. */