pum-agent 0.2.13-beta.1 → 0.2.15-beta.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/README.md CHANGED
@@ -325,7 +325,7 @@ Set `PUM_DIR` to override the complete PUM data directory.
325
325
  | `auth.json` | Provider credentials and custom-provider keys |
326
326
  | `models.json` | Custom endpoints and model metadata; submitted keys are not stored here |
327
327
  | `settings.json` | Model and thinking level managed by pi |
328
- | `pum.json` | Theme, animation, search, writing, explanation, Check mode, sandbox, and subagent settings |
328
+ | `pum.json` | Theme, animation, transcript output, search, writing, explanation, Check mode, sandbox, and subagent settings |
329
329
  | `theme.json` | Optional semantic color overrides |
330
330
  | `history.json` | Prompt history by working directory |
331
331
  | `prompt-stash.json` | Stashed prompts by working directory |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.13-beta.1",
3
+ "version": "0.2.15-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  import {
26
26
  CHECK_MODE_PROFILES,
27
27
  checkPathsForProject,
28
+ cycleOutputMode,
28
29
  MAX_ACTIVE_SUBAGENTS,
29
30
  MIN_ACTIVE_SUBAGENTS,
30
31
  SANDBOX_MODES,
@@ -48,6 +49,7 @@ import {
48
49
  type Role,
49
50
  } from "./transcript";
50
51
  import { bashOutput, bashResultDisplay, editCounts, toolArg, type ToolCall } from "./tool-line";
52
+ import { toolPreviewFromResult, toolPreviewFromStart } from "./tool-preview";
51
53
  import { readBranch, watchBranch } from "./git-branch";
52
54
  import { HelpPopup, maxHelpScrollOffset } from "./help-popup";
53
55
  import { appendHistory, loadHistory, removeHistory } from "./history";
@@ -119,10 +121,17 @@ import {
119
121
  moveTriggerSelection,
120
122
  sortTriggers,
121
123
  triggerActionForKey,
122
- TriggersPopup,
123
124
  type TriggerAction,
124
125
  type TriggerManagerLike,
125
126
  } from "./triggers/popup";
127
+ import {
128
+ moveProcessSelection,
129
+ processTabForKey,
130
+ ProcessesPopup,
131
+ sortShells,
132
+ type ProcessTab,
133
+ type ShellManagerLike,
134
+ } from "./processes-popup";
126
135
  import type { TerminalTitleController } from "./terminal-title";
127
136
  import { readClipboardText } from "./text-paste";
128
137
  import { copyTextToClipboard } from "./clipboard";
@@ -137,6 +146,10 @@ import {
137
146
  } from "./news";
138
147
  import { statsFromEntries, type SessionStatsManager } from "./session-stats";
139
148
  import { maxStatsScrollOffset, StatsPopup } from "./stats-popup";
149
+ import {
150
+ projectTranscriptLines,
151
+ transcriptOutputMode,
152
+ } from "./transcript-output";
140
153
 
141
154
  type Stream = { kind: "assistant" | "thinking"; text: string } | null;
142
155
  type Transcript = { lines: Line[]; stream: Stream; pending: PendingLine[] };
@@ -405,6 +418,7 @@ export function App({
405
418
  copyNewsAnswerText = copyTextToClipboard,
406
419
  onExit = () => process.exit(0),
407
420
  triggerManager,
421
+ shellManager,
408
422
  messageCacheController,
409
423
  terminalTitle,
410
424
  startupWarnings = [],
@@ -434,6 +448,7 @@ export function App({
434
448
  copyNewsAnswerText?: typeof copyTextToClipboard;
435
449
  onExit?: () => void | Promise<void>;
436
450
  triggerManager?: TriggerManagerLike;
451
+ shellManager?: ShellManagerLike;
437
452
  messageCacheController?: MessageCacheController;
438
453
  terminalTitle?: TerminalTitleController;
439
454
  /** Visible process-local warnings. These lines never enter pi session context. */
@@ -528,8 +543,12 @@ export function App({
528
543
  const [modelSearchFocused, setModelSearchFocused] = useState(false);
529
544
  const [, setAgentRevision] = useState(0);
530
545
  const [triggersOpen, setTriggersOpen] = useState(false);
546
+ const [processTab, setProcessTab] = useState<ProcessTab>("triggers");
531
547
  const [triggerCursor, setTriggerCursor] = useState(0);
548
+ const [shellCursor, setShellCursor] = useState(0);
532
549
  const [, setTriggerRevision] = useState(0);
550
+ const [shellRevision, setShellRevision] = useState(0);
551
+ const [shellTails, setShellTails] = useState<Record<string, string>>({});
533
552
 
534
553
  const [newsOpen, setNewsOpen] = useState(false);
535
554
  const newsOpenRef = useRef(false);
@@ -553,6 +572,11 @@ export function App({
553
572
  const questionnaire = questionnaireManager?.current();
554
573
  const spawnPreview = spawnPreviewManager?.current();
555
574
  const visibleTx = activeAgent?.transcript ?? tx;
575
+ const outputMode = transcriptOutputMode(settings);
576
+ const visibleLines = useMemo(
577
+ () => projectTranscriptLines(visibleTx.lines, outputMode),
578
+ [visibleTx.lines, outputMode],
579
+ );
556
580
  const visibleBusy = activeAgent
557
581
  ? activeAgent.status === "starting" || activeAgent.status === "running"
558
582
  : busy;
@@ -563,6 +587,10 @@ export function App({
563
587
  const visibleUsage = activeAgent?.usage ?? usage;
564
588
  const agentTreeRows = buildAgentTree(agents);
565
589
  const triggers = sortTriggers(triggerManager?.getTriggers() ?? []);
590
+ const shells = sortShells(shellManager?.list() ?? []);
591
+ const runningShellCount = shells.filter(
592
+ (shell) => shell.state === "starting" || shell.state === "running",
593
+ ).length;
566
594
  const statsSnapshot = useMemo(() => statsManager?.snapshot() ?? statsFromEntries(
567
595
  (session.sessionManager as any).getEntries?.() ?? session.sessionManager.buildContextEntries(),
568
596
  `${session.agent.state.model.provider}/${session.agent.state.model.id}`,
@@ -591,12 +619,14 @@ export function App({
591
619
  const spawnPreviewInputRef = useRef<TextareaRenderable>(null);
592
620
  const settingsOpenRef = useRef(settingsOpen);
593
621
  const triggersOpenRef = useRef(false);
622
+ const processTabRef = useRef<ProcessTab>("triggers");
594
623
  const settingsPageRef = useRef(page);
595
624
  const settingsSearchFocusedRef = useRef(settingsSearchFocused);
596
625
  const focusInputAfterSwitch = useRef(false);
597
626
  const activeAgentIdRef = useRef<string | null>(null);
598
627
  const agentSelectorCursorRef = useRef(0);
599
628
  const triggerCursorRef = useRef(0);
629
+ const shellCursorRef = useRef(0);
600
630
  const commandCursorRef = useRef(0);
601
631
  const stashRef = useRef(stash);
602
632
  const stashOpenRef = useRef(false);
@@ -678,11 +708,32 @@ export function App({
678
708
  setStatsOpen(false);
679
709
  statsOpenRef.current = false;
680
710
  setStashMode(false);
711
+ processTabRef.current = "triggers";
712
+ setProcessTab("triggers");
681
713
  const nextCursor = Math.min(triggerCursorRef.current, Math.max(0, triggers.length - 1));
682
714
  triggerCursorRef.current = nextCursor;
683
715
  setTriggerCursor(nextCursor);
684
716
  setTriggerPopup(true);
685
717
  };
718
+ const openProcesses = () => {
719
+ settingsOpenRef.current = false;
720
+ setSettingsOpen(false);
721
+ setHelpOpen(false);
722
+ setHistoryOpen(false);
723
+ setAgentSelectorOpen(false);
724
+ setNewsOpen(false);
725
+ newsOpenRef.current = false;
726
+ setStatsOpen(false);
727
+ statsOpenRef.current = false;
728
+ setStashMode(false);
729
+ const nextTriggerCursor = Math.min(triggerCursorRef.current, Math.max(0, triggers.length - 1));
730
+ const nextShellCursor = Math.min(shellCursorRef.current, Math.max(0, shells.length - 1));
731
+ triggerCursorRef.current = nextTriggerCursor;
732
+ shellCursorRef.current = nextShellCursor;
733
+ setTriggerCursor(nextTriggerCursor);
734
+ setShellCursor(nextShellCursor);
735
+ setTriggerPopup(true);
736
+ };
686
737
  // The event subscription is set up once, so it reads the toggle via a ref.
687
738
  const showThinkingRef = useRef(initial.showThinking);
688
739
  const startupWarningsRef = useRef([...startupWarnings]);
@@ -1051,6 +1102,28 @@ export function App({
1051
1102
  setTriggerCursor(next);
1052
1103
  }), [triggerManager]);
1053
1104
 
1105
+ useEffect(() => shellManager?.subscribe(() => {
1106
+ setShellRevision((revision) => revision + 1);
1107
+ const count = shellManager.list().length;
1108
+ const next = Math.min(shellCursorRef.current, Math.max(0, count - 1));
1109
+ shellCursorRef.current = next;
1110
+ setShellCursor(next);
1111
+ }), [shellManager]);
1112
+
1113
+ useEffect(() => {
1114
+ if (!shellManager || !triggersOpen || processTab !== "shells") return;
1115
+ const shell = shells[shellCursor];
1116
+ if (!shell || !shell.output.exists) return;
1117
+ let active = true;
1118
+ void shellManager.getOutput(shell.id, { lineLimit: 20 }).then((result) => {
1119
+ if (!active) return;
1120
+ setShellTails((tails) => tails[shell.id] === result.tail
1121
+ ? tails
1122
+ : { ...tails, [shell.id]: result.tail });
1123
+ }).catch(() => {});
1124
+ return () => { active = false; };
1125
+ }, [shellManager, triggersOpen, processTab, shellCursor, shellRevision]);
1126
+
1054
1127
  useEffect(
1055
1128
  () => subagentManager.subscribe((event) => {
1056
1129
  if (event.type === "main-line") append(event.line);
@@ -1184,6 +1257,8 @@ export function App({
1184
1257
  arg: toolArg(event.toolName, event.args, cwd),
1185
1258
  state: "running",
1186
1259
  startedAt: Date.now(),
1260
+ input: event.args,
1261
+ preview: toolPreviewFromStart(event.toolName, event.args),
1187
1262
  },
1188
1263
  });
1189
1264
  break;
@@ -1194,6 +1269,7 @@ export function App({
1194
1269
  break;
1195
1270
  case "tool_execution_end": {
1196
1271
  const bashResult = event.toolName === "bash" ? bashResultDisplay(event.result) : {};
1272
+ const preview = toolPreviewFromResult(event.toolName, event.result);
1197
1273
  patchTool(event.toolCallId, {
1198
1274
  state: isRejectedToolResult(event.result, event.toolCallId)
1199
1275
  ? "rejected"
@@ -1209,8 +1285,10 @@ export function App({
1209
1285
  : event.toolName.startsWith("message_cache_")
1210
1286
  ? messageCacheDetail(event.result)
1211
1287
  : undefined,
1212
- output: undefined,
1213
1288
  exitCode: bashResult.exitCode,
1289
+ result: event.result,
1290
+ isError: event.isError,
1291
+ ...(preview ? { preview } : {}),
1214
1292
  });
1215
1293
  break;
1216
1294
  }
@@ -1436,6 +1514,10 @@ export function App({
1436
1514
  update({ workingRuleAnimation: next });
1437
1515
  };
1438
1516
 
1517
+ const stepOutputMode = (step: number) => {
1518
+ update({ outputMode: cycleOutputMode(settings.outputMode, step) });
1519
+ };
1520
+
1439
1521
  const openLogin = () => {
1440
1522
  settingsOpenRef.current = false;
1441
1523
  setSettingsOpen(false);
@@ -1818,10 +1900,11 @@ export function App({
1818
1900
  const loginCommand = trimmed === "/login";
1819
1901
  const checkPathCommand = /^\/check-path(?:\s|$)/.test(trimmed);
1820
1902
  const triggersCommand = trimmed === "/triggers";
1903
+ const processesCommand = trimmed === "/processes";
1821
1904
  const newsCommand = trimmed === "/news";
1822
1905
  const statsCommand = trimmed === "/stats";
1823
1906
  const worktreeCommand = /^\/worktree(?:\s+([a-zA-Z0-9_-]+))?$/.exec(trimmed);
1824
- if (!compress && !clear && !historyCommand && !loginCommand && !checkPathCommand && !triggersCommand && !newsCommand && !statsCommand && !worktreeCommand) return false;
1907
+ if (!compress && !clear && !historyCommand && !loginCommand && !checkPathCommand && !triggersCommand && !processesCommand && !newsCommand && !statsCommand && !worktreeCommand) return false;
1825
1908
  editingStashIndex.current = null;
1826
1909
 
1827
1910
  if (historyCommand) {
@@ -1839,6 +1922,11 @@ export function App({
1839
1922
  openTriggers();
1840
1923
  return true;
1841
1924
  }
1925
+ if (processesCommand) {
1926
+ setEditorText("");
1927
+ openProcesses();
1928
+ return true;
1929
+ }
1842
1930
  if (newsCommand) {
1843
1931
  setEditorText("");
1844
1932
  openNews();
@@ -2109,6 +2197,16 @@ export function App({
2109
2197
  }));
2110
2198
  };
2111
2199
 
2200
+ const killSelectedShell = () => {
2201
+ const shell = shells[shellCursorRef.current];
2202
+ if (!shell || !shellManager || (shell.state !== "starting" && shell.state !== "running")) return;
2203
+ Promise.resolve(shellManager.terminate(shell.id)).catch((error) => append({
2204
+ kind: "text",
2205
+ role: "error",
2206
+ text: `shell kill failed: ${String(error)}`,
2207
+ }));
2208
+ };
2209
+
2112
2210
  const stepCheckMode = (step: number) => {
2113
2211
  const index = CHECK_MODE_PROFILES.indexOf(settings.checkMode);
2114
2212
  update({ checkMode: CHECK_MODE_PROFILES[(index + step + CHECK_MODE_PROFILES.length) % CHECK_MODE_PROFILES.length]! });
@@ -2125,6 +2223,7 @@ export function App({
2125
2223
  providers: { enter: openLogin },
2126
2224
  animations: { step: () => update({ animations: !settings.animations }) },
2127
2225
  workingRuleAnimation: { step: stepWorkingRuleAnimation },
2226
+ outputMode: { step: stepOutputMode },
2128
2227
  webSearch: { step: () => update({ webSearch: !settings.webSearch }) },
2129
2228
  writingStyle: { step: stepWritingStyle },
2130
2229
  explanationStrength: { step: stepExplanationStrength },
@@ -2167,6 +2266,7 @@ export function App({
2167
2266
  providers: "login and custom setup ›",
2168
2267
  animations: `‹ ${settings.animations ? "on" : "off"} ›`,
2169
2268
  workingRuleAnimation: `‹ ${settings.workingRuleAnimation} ›${settings.workingRuleAnimation === "off" ? "" : animationUnavailable}`,
2269
+ outputMode: `‹ ${settings.outputMode ?? "default"} ›`,
2170
2270
  webSearch: `‹ ${settings.webSearch ? "on" : "off"} ›${searchProviders.length ? "" : " (not on provider)"}`,
2171
2271
  writingStyle: `‹ ${settings.writingStyle} ›`,
2172
2272
  explanationStrength: `‹ ${settings.explanationStrength} ›`,
@@ -2388,7 +2488,7 @@ export function App({
2388
2488
  if (key.ctrl && key.name === "t") {
2389
2489
  key.stopPropagation();
2390
2490
  if (triggersOpenRef.current) setTriggerPopup(false);
2391
- else openTriggers();
2491
+ else openProcesses();
2392
2492
  return;
2393
2493
  }
2394
2494
 
@@ -2396,15 +2496,29 @@ export function App({
2396
2496
  key.stopPropagation();
2397
2497
  if (key.name === "escape") {
2398
2498
  setTriggerPopup(false);
2499
+ } else if (processTabForKey(key, processTabRef.current)) {
2500
+ const tab = processTabForKey(key, processTabRef.current)!;
2501
+ processTabRef.current = tab;
2502
+ setProcessTab(tab);
2399
2503
  } else if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") {
2400
2504
  const direction = key.name === "up" || key.name === "pageup" ? -1 : 1;
2401
2505
  const steps = key.name === "pageup" || key.name === "pagedown" ? 5 : 1;
2402
- let next = triggerCursorRef.current;
2506
+ const shellTab = processTabRef.current === "shells";
2507
+ let next = shellTab ? shellCursorRef.current : triggerCursorRef.current;
2403
2508
  for (let index = 0; index < steps; index++) {
2404
- next = moveTriggerSelection(next, triggers.length, direction);
2509
+ next = shellTab
2510
+ ? moveProcessSelection(next, shells.length, direction)
2511
+ : moveTriggerSelection(next, triggers.length, direction);
2512
+ }
2513
+ if (shellTab) {
2514
+ shellCursorRef.current = next;
2515
+ setShellCursor(next);
2516
+ } else {
2517
+ triggerCursorRef.current = next;
2518
+ setTriggerCursor(next);
2405
2519
  }
2406
- triggerCursorRef.current = next;
2407
- setTriggerCursor(next);
2520
+ } else if (processTabRef.current === "shells") {
2521
+ if (key.name === "k" || key.sequence === "k") killSelectedShell();
2408
2522
  } else {
2409
2523
  const action = triggerActionForKey(key, triggers[triggerCursorRef.current]);
2410
2524
  if (action) performTriggerAction(action);
@@ -2870,7 +2984,10 @@ export function App({
2870
2984
  }
2871
2985
  });
2872
2986
 
2873
- const lastLine = visibleTx.lines[visibleTx.lines.length - 1];
2987
+ const lastProjectedLine = visibleLines[visibleLines.length - 1];
2988
+ const lastLine: Line | undefined = lastProjectedLine?.kind === "tool-summary"
2989
+ ? { kind: "text", role: "system", text: lastProjectedLine.text }
2990
+ : lastProjectedLine;
2874
2991
  const streamGap = visibleTx.stream
2875
2992
  ? needsTranscriptGap(lastLine, { kind: "text", role: visibleTx.stream.kind, text: visibleTx.stream.text })
2876
2993
  : false;
@@ -2905,6 +3022,7 @@ export function App({
2905
3022
  agentCount={agents.length}
2906
3023
  runningAgentCount={activeSubagentCount}
2907
3024
  maxActiveAgentCount={settings.maxActiveSubagents}
3025
+ runningShellCount={runningShellCount}
2908
3026
  activeAgentName={activeAgent?.name}
2909
3027
  />
2910
3028
  <WorkingRule
@@ -2923,11 +3041,24 @@ export function App({
2923
3041
  stickyStart="bottom"
2924
3042
  verticalScrollbarOptions={{ visible: true }}
2925
3043
  >
2926
- {visibleTx.lines.map((line, i) => {
2927
- const workingCaret = visibleBusy && !visibleTx.stream && i === visibleTx.lines.length - 1;
3044
+ {visibleLines.map((line, i) => {
3045
+ const workingCaret = visibleBusy && !visibleTx.stream && i === visibleLines.length - 1;
2928
3046
  const row =
2929
- line.kind === "tool" ? (
2930
- <ToolLine theme={theme} call={line.call} workingCaret={workingCaret} />
3047
+ line.kind === "tool-summary" ? (
3048
+ <TextLine
3049
+ theme={theme}
3050
+ syntaxStyle={syntaxStyle}
3051
+ role="system"
3052
+ text={line.text}
3053
+ />
3054
+ ) : line.kind === "tool" ? (
3055
+ <ToolLine
3056
+ theme={theme}
3057
+ syntaxStyle={syntaxStyle}
3058
+ call={line.call}
3059
+ workingCaret={workingCaret}
3060
+ outputMode={outputMode}
3061
+ />
2931
3062
  ) : line.kind === "agent-message" ? (
2932
3063
  <AgentMessageLine theme={theme} syntaxStyle={syntaxStyle} line={line} />
2933
3064
  ) : (
@@ -2944,9 +3075,18 @@ export function App({
2944
3075
  }
2945
3076
  />
2946
3077
  );
2947
- const gapBefore = needsTranscriptGap(visibleTx.lines[i - 1], line);
3078
+ const currentGapLine: Line = line.kind === "tool-summary"
3079
+ ? { kind: "text", role: "system", text: line.text }
3080
+ : line;
3081
+ const previousProjected = visibleLines[i - 1];
3082
+ const previousGapLine: Line | undefined = previousProjected?.kind === "tool-summary"
3083
+ ? { kind: "text", role: "system", text: previousProjected.text }
3084
+ : previousProjected;
3085
+ const gapBefore = needsTranscriptGap(previousGapLine, currentGapLine);
2948
3086
  const lineKey =
2949
- line.kind === "tool"
3087
+ line.kind === "tool-summary"
3088
+ ? `tool-summary:${i}:${line.text}`
3089
+ : line.kind === "tool"
2950
3090
  ? `tool:${line.call.id}`
2951
3091
  : line.kind === "agent-message"
2952
3092
  ? `agent:${line.sender}:${line.recipient}:${i}:${line.text}`
@@ -2977,7 +3117,7 @@ export function App({
2977
3117
  ) : null}
2978
3118
  {visibleTx.pending.some((pending) => !pending.delivered) ? (
2979
3119
  <>
2980
- {(visibleTx.lines.length > 0 || visibleTx.stream) ? <Gap /> : null}
3120
+ {(visibleLines.length > 0 || visibleTx.stream) ? <Gap /> : null}
2981
3121
  {visibleTx.pending.filter((pending) => !pending.delivered).map((pending) => (
2982
3122
  <PendingMessageLine
2983
3123
  key={pending.id}
@@ -3125,10 +3265,14 @@ export function App({
3125
3265
  />
3126
3266
  ) : null}
3127
3267
  {triggersOpen ? (
3128
- <TriggersPopup
3268
+ <ProcessesPopup
3129
3269
  theme={theme}
3270
+ tab={processTab}
3130
3271
  triggers={triggers}
3131
- cursor={triggerCursor}
3272
+ shells={shells}
3273
+ triggerCursor={triggerCursor}
3274
+ shellCursor={shellCursor}
3275
+ shellTail={shells[shellCursor] ? shellTails[shells[shellCursor]!.id] : undefined}
3132
3276
  terminalWidth={width}
3133
3277
  terminalHeight={height}
3134
3278
  />
package/src/check-mode.ts CHANGED
@@ -209,13 +209,14 @@ export function isProcessCheckProposal(value: unknown): value is ProcessCheckPro
209
209
  if (!value || typeof value !== "object") return false;
210
210
  const proposal = value as Partial<ProcessCheckProposal>;
211
211
  return proposal.kind === "process"
212
- && proposal.source === "external-trigger"
212
+ && ["external-trigger", "managed-shell"].includes(proposal.source ?? "")
213
213
  && typeof proposal.executable === "string"
214
214
  && Array.isArray(proposal.args)
215
215
  && proposal.args.every((argument) => typeof argument === "string")
216
216
  && typeof proposal.cwd === "string"
217
217
  && ["create", "start", "resume", "repeat", "invoke-run"].includes(proposal.operation ?? "")
218
- && (proposal.triggerName === undefined || typeof proposal.triggerName === "string");
218
+ && (proposal.triggerName === undefined || typeof proposal.triggerName === "string")
219
+ && (proposal.shellName === undefined || typeof proposal.shellName === "string");
219
220
  }
220
221
 
221
222
  /** Build the exact safety identity. Display-only triggerName is intentionally omitted. */
@@ -297,7 +298,7 @@ export async function prepareCheck(
297
298
  const paths = mutation?.changedPaths ?? [];
298
299
  const processProposal = isProcessCheckProposal(input) ? input : undefined;
299
300
  const summary = processProposal
300
- ? `${processProposal.operation} external trigger process: ${processProposal.executable}`
301
+ ? `${processProposal.operation} ${processProposal.source === "managed-shell" ? "managed shell" : "external trigger"} process: ${processProposal.executable}`
301
302
  : toolName === "bash"
302
303
  ? `Run ${bash!.stages.length} shell stage${bash!.stages.length === 1 ? "" : "s"}`
303
304
  : `Change ${paths.length} project file${paths.length === 1 ? "" : "s"} (+${mutation!.additions} −${mutation!.removals})`;
@@ -325,6 +326,7 @@ export async function prepareCheck(
325
326
  args: processProposal.args,
326
327
  cwd: processProposal.cwd,
327
328
  triggerName: processProposal.triggerName,
329
+ shellName: processProposal.shellName,
328
330
  analysis: bash,
329
331
  } : undefined,
330
332
  proposedMutation: mutation ? {
@@ -710,6 +712,8 @@ export type ExternalTriggerSafetyChecker = (
710
712
  signal?: AbortSignal,
711
713
  ) => Promise<void>;
712
714
 
715
+ export type ManagedShellSafetyChecker = ExternalTriggerSafetyChecker;
716
+
713
717
  /** Create the process safety callback used by TriggerManager. */
714
718
  export function createExternalTriggerSafetyChecker(
715
719
  runtime: CheckerRuntime,
@@ -733,6 +737,9 @@ export function createExternalTriggerSafetyChecker(
733
737
  };
734
738
  }
735
739
 
740
+ /** Create the structured process safety callback used by ShellManager. */
741
+ export const createManagedShellSafetyChecker = createExternalTriggerSafetyChecker;
742
+
736
743
  export async function verifyToolCall(runtime: CheckerRuntime, call: ToolCheck): Promise<ToolBlock | undefined> {
737
744
  const evaluation = await evaluateToolCall(runtime, call);
738
745
  return evaluation.decision === "allow" ? undefined : { block: true, reason: evaluation.reason };
@@ -144,7 +144,7 @@ export type ProcessCheckOperation = "create" | "start" | "resume" | "repeat" | "
144
144
 
145
145
  export type ProcessCheckProposal = {
146
146
  kind: "process";
147
- source: "external-trigger";
147
+ source: "external-trigger" | "managed-shell";
148
148
  executable: string;
149
149
  /** Process arguments. The executable is not included. */
150
150
  args: readonly string[];
@@ -152,6 +152,8 @@ export type ProcessCheckProposal = {
152
152
  operation: ProcessCheckOperation;
153
153
  /** Display context only. This field is not part of the safety identity. */
154
154
  triggerName?: string;
155
+ /** Display context only. This field is not part of the safety identity. */
156
+ shellName?: string;
155
157
  };
156
158
 
157
159
  export type AnalyzeExecutablePolicyOptions = Pick<ProcessCheckProposal, "executable" | "args" | "cwd"> & {
package/src/commands.ts CHANGED
@@ -39,7 +39,11 @@ export const COMMANDS: Command[] = [
39
39
  },
40
40
  {
41
41
  name: "/triggers",
42
- description: "Manage external triggers",
42
+ description: "Open Processes on the Triggers tab",
43
+ },
44
+ {
45
+ name: "/processes",
46
+ description: "Manage external triggers and shells",
43
47
  },
44
48
  {
45
49
  name: "/worktree",
@@ -59,7 +59,7 @@ export const HELP_GROUPS: HelpGroup[] = [
59
59
  ["/news", "Open recent answers (News)"],
60
60
  ["/stats", "Show session statistics"],
61
61
  ["/check-path", "Manage extra Check mode paths"],
62
- ["/triggers", "Manage external triggers"],
62
+ ["/processes", "Manage triggers and shells"],
63
63
  ["/worktree", "Create a managed worktree"],
64
64
  ["Tab", "Complete a command preview"],
65
65
  ],
@@ -69,7 +69,7 @@ export const HELP_GROUPS: HelpGroup[] = [
69
69
  controls: [
70
70
  ["Ctrl+P", "Open Settings"],
71
71
  ["Ctrl+N", "Open News; n/p jump"],
72
- ["Ctrl+T", "Open external triggers"],
72
+ ["Ctrl+T", "Open Processes"],
73
73
  ["Ctrl+End", "Scroll transcript to the end"],
74
74
  ["/ in Settings", "Focus settings search"],
75
75
  ["Esc", "Close; twice to cancel work"],
package/src/main.tsx CHANGED
@@ -52,6 +52,16 @@ import {
52
52
  filesystemSandboxExtension,
53
53
  } from "./filesystem-sandbox";
54
54
  import { SessionStatsManager } from "./session-stats";
55
+ import { ShellManager } from "./shells/manager";
56
+ import {
57
+ NodeShellFileOperations,
58
+ NodeShellProcessAdapter,
59
+ systemShellClock,
60
+ } from "./shells/process";
61
+ import {
62
+ ManagedShellLifecycleController,
63
+ lifecycleEventFromSnapshot,
64
+ } from "./shells/lifecycle";
55
65
 
56
66
  export async function start(options: StartupOptions): Promise<void> {
57
67
  mkdirSync(AGENT_DIR, { recursive: true });
@@ -100,6 +110,33 @@ export async function start(options: StartupOptions): Promise<void> {
100
110
  });
101
111
  });
102
112
  let subagentManager!: SubagentManager;
113
+ const shellLifecycle = new ManagedShellLifecycleController(
114
+ {
115
+ append: (_owner, _customType, data) =>
116
+ subagentManager.persistManagedShellEvent(data as any),
117
+ },
118
+ {
119
+ deliver: (message) => subagentManager.deliverManagedShellCompletion(message.details),
120
+ },
121
+ );
122
+ const startedShells = new Set<string>();
123
+ const shellManager = new ShellManager({
124
+ process: new NodeShellProcessAdapter(),
125
+ files: new NodeShellFileOperations(),
126
+ clock: systemShellClock,
127
+ async onCompleted(snapshot) {
128
+ const output = await shellManager.getOutput(snapshot.id, { lineLimit: 200 }).catch(() => undefined);
129
+ const event = lifecycleEventFromSnapshot(snapshot, output?.tail);
130
+ await shellLifecycle.recordExit(event, snapshot.state === "terminated");
131
+ },
132
+ });
133
+ shellManager.subscribe((event) => {
134
+ if (event.type !== "changed"
135
+ || !["starting", "running"].includes(event.snapshot.state)
136
+ || startedShells.has(event.snapshot.id)) return;
137
+ startedShells.add(event.snapshot.id);
138
+ void shellLifecycle.record(lifecycleEventFromSnapshot(event.snapshot));
139
+ });
103
140
  const triggerManager = new TriggerManager({
104
141
  process: new NodeTriggerProcessAdapter(),
105
142
  clock: systemTriggerClock,
@@ -151,6 +188,7 @@ export async function start(options: StartupOptions): Promise<void> {
151
188
  messageCacheController,
152
189
  statsManager,
153
190
  triggerManager,
191
+ shellManager,
154
192
  childExtensionFactories: [
155
193
  identityExtension,
156
194
  writingStyleExtension,
@@ -239,6 +277,7 @@ export async function start(options: StartupOptions): Promise<void> {
239
277
  selectionClipboard.dispose();
240
278
  cleanupPendingImages();
241
279
  },
280
+ shutdownShells: () => shellManager.shutdown(),
242
281
  shutdownTriggers: () => triggerManager.shutdown(),
243
282
  dispose: () => sessionRuntime.dispose(),
244
283
  destroy: async () => {