pum-agent 0.2.34-beta.2 → 0.2.36-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.
Files changed (49) hide show
  1. package/README.md +12 -4
  2. package/package.json +3 -4
  3. package/src/animation.tsx +20 -7
  4. package/src/app.tsx +786 -40
  5. package/src/check-mode-prompt.ts +13 -13
  6. package/src/check-mode.ts +105 -10
  7. package/src/cli.ts +36 -1
  8. package/src/commands.ts +37 -1
  9. package/src/context-estimate.ts +55 -0
  10. package/src/context-guidance.ts +5 -5
  11. package/src/context-recovery.ts +22 -0
  12. package/src/context-window.ts +197 -80
  13. package/src/conversation-branch-popup.tsx +46 -0
  14. package/src/conversation-branch.ts +227 -0
  15. package/src/file-checkpoints.ts +337 -0
  16. package/src/file-mutation-guard.ts +224 -0
  17. package/src/filesystem-sandbox.ts +5 -2
  18. package/src/headless.ts +87 -11
  19. package/src/help-popup.tsx +6 -0
  20. package/src/index.tsx +2 -0
  21. package/src/lsp-files.ts +102 -0
  22. package/src/lsp-protocol.ts +275 -0
  23. package/src/lsp.ts +203 -0
  24. package/src/main.tsx +154 -18
  25. package/src/mcp-config.ts +100 -0
  26. package/src/mcp-process.ts +244 -0
  27. package/src/mcp-protocol.ts +385 -0
  28. package/src/mcp.ts +230 -0
  29. package/src/memory-context.ts +91 -0
  30. package/src/memory.ts +25 -23
  31. package/src/operational-context.ts +91 -0
  32. package/src/plan-mode.ts +139 -0
  33. package/src/project-validation.ts +316 -0
  34. package/src/replay.ts +6 -0
  35. package/src/request-diagnostics-access.ts +25 -0
  36. package/src/request-diagnostics.ts +210 -0
  37. package/src/runtime-settings.ts +363 -0
  38. package/src/sandbox/linux.ts +31 -6
  39. package/src/sandbox/types.ts +5 -0
  40. package/src/session-lock-runtime.ts +332 -25
  41. package/src/status-bar.tsx +2 -0
  42. package/src/status-metadata.ts +8 -1
  43. package/src/subagents/manager.ts +137 -23
  44. package/src/subagents/readonly.ts +34 -11
  45. package/src/theme.ts +10 -9
  46. package/src/tool-groups.ts +85 -27
  47. package/src/transcript-history.ts +93 -18
  48. package/src/web-search.ts +228 -33
  49. package/src/model-catalog.ts +0 -47
package/src/app.tsx CHANGED
@@ -7,6 +7,11 @@ import {
7
7
  type TextareaRenderable,
8
8
  } from "@opentui/core";
9
9
  import { randomUUID } from "node:crypto";
10
+ import { diagnosticsCommandText } from "./request-diagnostics-access";
11
+ import { checkpointControllerForSession, checkpointRecoveryFailureText } from "./file-checkpoints";
12
+ import { VALIDATION_CUSTOM_TYPE, validationForSession } from "./project-validation";
13
+ import type { McpController } from "./mcp";
14
+ import type { LspController } from "./lsp";
10
15
  import { useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
11
16
  import { getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
12
17
  import type { AgentSession, BashOperations, ModelRuntime } from "@earendil-works/pi-coding-agent";
@@ -161,6 +166,25 @@ import {
161
166
  } from "./settings-command";
162
167
  import { isRejectedToolResult, rejectedToolReason } from "./check-mode";
163
168
  import { SessionHistoryPopup } from "./session-history-popup";
169
+ import {
170
+ ConversationBranchPopup,
171
+ CONVERSATION_BRANCH_PAGE_SIZE,
172
+ } from "./conversation-branch-popup";
173
+ import {
174
+ hasConversationBranchPendingInput,
175
+ listConversationBranchPoints,
176
+ type ConversationBranchPage,
177
+ type ConversationBranchSelection,
178
+ } from "./conversation-branch";
179
+ import type { ConversationBranchResult, PlanModeTransitionResult } from "./session-lock-runtime";
180
+ import {
181
+ describePlanRecord,
182
+ loadPlanRecord,
183
+ planModeForSession,
184
+ savePlanRecord,
185
+ validatePlanText,
186
+ type PlanMode,
187
+ } from "./plan-mode";
164
188
  import type { SessionHistoryItem } from "./session-history-metadata";
165
189
  import { setWritingStyle, WRITING_STYLES } from "./writing-style";
166
190
  import {
@@ -168,6 +192,7 @@ import {
168
192
  setExplanationStrength,
169
193
  } from "./explanation-strength";
170
194
  import { setCheckModeConfig } from "./check-mode";
195
+ import { isRuntimeIdle, runtimeSettings, runtimeSettingsPendingNotice } from "./runtime-settings";
171
196
  import { applyCheckPathCommand, parseCheckPathCommand } from "./check-paths";
172
197
  import { pruneEditedMarkers, reindexMarkers } from "./attachment-markers";
173
198
  import {
@@ -801,6 +826,8 @@ const DEFAULT_PROMPT_STASH_STORE: PromptStashStore = {
801
826
  remove: removePromptStash,
802
827
  };
803
828
 
829
+ const EMPTY_BRANCH_PAGE: ConversationBranchPage = { points: [], total: 0, nextOffset: null };
830
+
804
831
  /** Move any buffered stream into the transcript so later lines land in order. */
805
832
  function flushed(t: Transcript): Transcript {
806
833
  return flushStream(t);
@@ -813,6 +840,8 @@ export function App({
813
840
  loadSessions,
814
841
  onSwitchSession,
815
842
  onRelocate,
843
+ onBranchConversation,
844
+ onTransitionPlanMode,
816
845
  settings: initial,
817
846
  searchProviders,
818
847
  subagentManager,
@@ -841,6 +870,8 @@ export function App({
841
870
  initialRelocation,
842
871
  initialCwd,
843
872
  userBashOperations,
873
+ mcpForSession,
874
+ lspForSession,
844
875
  }: {
845
876
  session: AgentSession;
846
877
  modelRuntime: ModelRuntime;
@@ -849,6 +880,24 @@ export function App({
849
880
  onSwitchSession: (path: string) => Promise<AgentSession | null>;
850
881
  /** Move this same session to another directory. Null when it could not move. */
851
882
  onRelocate?: (targetCwd: string) => Promise<AgentSession | null>;
883
+ /**
884
+ * Same-session conversation navigation. The host keeps the canonical file,
885
+ * id and ownership; it never restores files or companion state. Null means
886
+ * the host declined without changing the conversation.
887
+ */
888
+ onBranchConversation?: (
889
+ selection: ConversationBranchSelection,
890
+ options: { validate: () => void; validateRetired: () => void },
891
+ ) => Promise<ConversationBranchResult | null>;
892
+ /**
893
+ * Enter or leave the enforced plan-only role (#51). Same canonical session and
894
+ * ownership; the host rebuilds the runtime with the new role. A failed
895
+ * transition reports `plan`, never a half-upgraded runtime.
896
+ */
897
+ onTransitionPlanMode?: (
898
+ mode: PlanMode,
899
+ options: { validate: () => void; validateRetired: () => void },
900
+ ) => Promise<PlanModeTransitionResult | null>;
852
901
  settings: PumSettings;
853
902
  /** Provider ids that carry the hosted web-search tool; empty means none. */
854
903
  searchProviders: string[];
@@ -886,6 +935,9 @@ export function App({
886
935
  initialCwd?: string;
887
936
  /** User commands bypass Check mode but use this native sandbox execution path. */
888
937
  userBashOperations?: BashOperations;
938
+ /** Exact main-runtime lookup; never inherited by workers or restored as trust. */
939
+ mcpForSession?: (session: AgentSession) => McpController | undefined;
940
+ lspForSession?: (session: AgentSession) => LspController | undefined;
889
941
  }) {
890
942
  // The one authoritative active directory. Everything directory-dependent
891
943
  // reads this rather than process.cwd(), so a live move rebinds by re-render
@@ -980,6 +1032,10 @@ export function App({
980
1032
  const [settings, setSettings] = useState(
981
1033
  () => mergeSessionSettings(globalSettingsRef.current, sessionOverridesRef.current),
982
1034
  );
1035
+ const [pendingSettingsNotice, setPendingSettingsNotice] = useState(() => runtimeSettingsPendingNotice(runtimeSettings.snapshot()));
1036
+ useEffect(() => runtimeSettings.subscribe(() => {
1037
+ setPendingSettingsNotice(runtimeSettingsPendingNotice(runtimeSettings.snapshot()));
1038
+ }), []);
983
1039
  const [transcriptFocused, setTranscriptFocused] = useState(false);
984
1040
  const transcriptFocusedRef = useRef(false);
985
1041
  const [transcriptCursor, setTranscriptCursor] = useState(0);
@@ -1065,11 +1121,30 @@ export function App({
1065
1121
  const newsCursorRef = useRef(0);
1066
1122
  const statsOpenRef = useRef(false);
1067
1123
 
1124
+ const [branchOpen, setBranchOpen] = useState(false);
1125
+ const branchOpenRef = useRef(false);
1126
+ const [branchPage, setBranchPage] = useState<ConversationBranchPage>(EMPTY_BRANCH_PAGE);
1127
+ const branchPageRef = useRef<ConversationBranchPage>(EMPTY_BRANCH_PAGE);
1128
+ const [branchOffset, setBranchOffset] = useState(0);
1129
+ const branchOffsetRef = useRef(0);
1130
+ const [branchCursor, setBranchCursor] = useState(0);
1131
+ const branchCursorRef = useRef(0);
1132
+ const [branchConfirming, setBranchConfirming] = useState(false);
1133
+ const branchConfirmingRef = useRef(false);
1134
+ /** Set only for the window in which a branch transaction owns the session. */
1135
+ const branchRunningRef = useRef(false);
1136
+ const branchNoticeRef = useRef<string | null>(null);
1137
+ const [branchNotice, setBranchNotice] = useState(0);
1138
+ const [planMode, setPlanMode] = useState(() => planModeForSession(initialSession));
1139
+ const planModeRef = useRef(planMode);
1140
+ /** Armed by a direct `/implement`; only `/implement confirm` may upgrade. */
1141
+ const implementArmedRef = useRef(false);
1142
+
1068
1143
  // Preview only the visible autocomplete selection. The committed setting and
1069
1144
  // its session/global persistence never participate in this temporary state.
1070
1145
  const themeSuggestions = shellMode || stashOpen || commandSuggestionsDismissed || activeAgentId
1071
1146
  || transcriptFocused || settingsOpen || helpOpen || historyOpen || statsOpen || agentSelectorOpen
1072
- || triggersOpen || loginOpen || providersOpen || newsOpen || todoOpen
1147
+ || triggersOpen || loginOpen || providersOpen || newsOpen || todoOpen || branchOpen
1073
1148
  || questionnaireManager?.current() || spawnPreviewManager?.current()
1074
1149
  ? [] : settingsCompletions(commandInput, inputCursorOffset, cwd);
1075
1150
  const previewTheme = themeSuggestions[Math.min(commandCursor, Math.max(0, themeSuggestions.length - 1))]?.previewTheme;
@@ -1126,7 +1201,8 @@ export function App({
1126
1201
  // it from each opener means a popup added later cannot forget a line here.
1127
1202
  const todoVisible = todoOpen
1128
1203
  && !settingsOpen && !helpOpen && !historyOpen && !statsOpen && !agentSelectorOpen
1129
- && !triggersOpen && !loginOpen && !providersOpen && !newsOpen && !visibleQuestionnaire && !spawnPreview;
1204
+ && !triggersOpen && !loginOpen && !providersOpen && !newsOpen && !branchOpen
1205
+ && !visibleQuestionnaire && !spawnPreview;
1130
1206
  // Memoized because the filtered result is a new object every call whenever
1131
1207
  // the transcript holds reasoning. That new identity would re-run the dwell
1132
1208
  // and projection passes below on every render, keystrokes included.
@@ -1386,6 +1462,11 @@ export function App({
1386
1462
  });
1387
1463
  /** Cache row currently checked out into the selected transcript input. */
1388
1464
  const editingStashIndex = useRef<number | null>(null);
1465
+ // Authority belongs to the draft, never to a mutable cache row index. Edits
1466
+ // retain this origin; only clearing the editor starts a new direct draft.
1467
+ type DraftOrigin = "direct" | "restored";
1468
+ const draftOrigin = useRef<DraftOrigin>("direct");
1469
+ const viewDraftOrigins = useRef(new Map<string, DraftOrigin>());
1389
1470
  /**
1390
1471
  * Text to install after the cache view closes.
1391
1472
  *
@@ -1416,6 +1497,8 @@ export function App({
1416
1497
  const streamingRef = useRef(false);
1417
1498
  /** True while a session switch or /clear is replacing the session object. */
1418
1499
  const sessionSwitchRef = useRef(false);
1500
+ /** User recovery copies must finish before UI actions can replace their runtime. */
1501
+ const checkpointRecoveryRef = useRef(false);
1419
1502
  /** True while the running main-agent turn started from a direct user prompt. */
1420
1503
  const userTurnActiveRef = useRef(false);
1421
1504
  /** Text of the final assistant message; reset at each assistant message start. */
@@ -1622,7 +1705,9 @@ export function App({
1622
1705
  value: string,
1623
1706
  cursorOffset = value.length,
1624
1707
  preserveImages = false,
1708
+ origin: DraftOrigin = "restored",
1625
1709
  ) => {
1710
+ draftOrigin.current = value ? origin : "direct";
1626
1711
  if (!preserveImages && pendingImages.current.length > 0) clearPendingImages();
1627
1712
  if (!preserveImages && pendingPastedTexts.current.length > 0) clearPendingPastedTexts();
1628
1713
  const input = inputRef.current;
@@ -1801,7 +1886,7 @@ export function App({
1801
1886
  const markerStart = value.indexOf(pending.marker);
1802
1887
  return { ...pending, start: markerStart, end: markerStart + pending.marker.length };
1803
1888
  });
1804
- setEditorText(value, start + marker.length, true);
1889
+ setEditorText(value, start + marker.length, true, draftOrigin.current);
1805
1890
  } catch (error) {
1806
1891
  append({ kind: "text", role: "error", text: `image paste failed: ${String(error)}` });
1807
1892
  } finally {
@@ -1835,6 +1920,7 @@ export function App({
1835
1920
  helpOpen ||
1836
1921
  historyOpen ||
1837
1922
  newsOpenRef.current ||
1923
+ branchOpenRef.current ||
1838
1924
  todoOpenRef.current ||
1839
1925
  statsOpenRef.current ||
1840
1926
  settingsOpenRef.current
@@ -1880,7 +1966,7 @@ export function App({
1880
1966
  start,
1881
1967
  end: start + marker.length,
1882
1968
  });
1883
- setEditorText(value, start + marker.length, true);
1969
+ setEditorText(value, start + marker.length, true, draftOrigin.current);
1884
1970
  };
1885
1971
 
1886
1972
  const append = (line: Line) =>
@@ -2019,6 +2105,10 @@ export function App({
2019
2105
  setLoginOpen(false);
2020
2106
  setNewsOpen(false);
2021
2107
  newsOpenRef.current = false;
2108
+ setBranchOpen(false);
2109
+ branchOpenRef.current = false;
2110
+ setBranchConfirming(false);
2111
+ branchConfirmingRef.current = false;
2022
2112
  setStatsOpen(false);
2023
2113
  statsOpenRef.current = false;
2024
2114
  }
@@ -2047,6 +2137,12 @@ export function App({
2047
2137
  useEffect(() => {
2048
2138
  // A replaced session cannot still be running the previous session's turn.
2049
2139
  streamingRef.current = false;
2140
+ // The role is read from the session's own durable records, never carried
2141
+ // across from the previous runtime.
2142
+ const activePlanMode = planModeForSession(session);
2143
+ planModeRef.current = activePlanMode;
2144
+ setPlanMode(activePlanMode);
2145
+ implementArmedRef.current = false;
2050
2146
  void subagentManager
2051
2147
  .bindMainSession(session.sessionManager, cwd)
2052
2148
  .catch((error) => append({ kind: "text", role: "error", text: String(error) }));
@@ -2086,6 +2182,7 @@ export function App({
2086
2182
  const mergedSettings = mergeSessionSettings(globalSettingsRef.current, loadedOverrides);
2087
2183
  settingsRef.current = mergedSettings;
2088
2184
  setSettings(mergedSettings);
2185
+ applyRuntimeSettings(mergedSettings, cwd);
2089
2186
  const loadedGoal = loadGoal(session.sessionFile);
2090
2187
  goalRef.current = loadedGoal;
2091
2188
  setGoalState(loadedGoal);
@@ -2126,6 +2223,12 @@ export function App({
2126
2223
  case "message_end":
2127
2224
  if (event.message.role === "assistant") {
2128
2225
  setTx((value) => settleTranscriptMessage(value));
2226
+ } else if (event.message.role === "custom" && event.message.customType === VALIDATION_CUSTOM_TYPE) {
2227
+ append({
2228
+ kind: "text",
2229
+ role: (event.message.details as { outcome?: string } | undefined)?.outcome === "passed" ? "system" : "error",
2230
+ text: String(event.message.content),
2231
+ });
2129
2232
  }
2130
2233
  break;
2131
2234
  case "message_update": {
@@ -2249,6 +2352,16 @@ export function App({
2249
2352
  });
2250
2353
  }, [session]);
2251
2354
 
2355
+ // Ordered after the session replay effect above, so a branch disclosure lands
2356
+ // under the newly selected path instead of being wiped by that replay. It
2357
+ // also runs when a host rebuilds runtime state behind the same object.
2358
+ useEffect(() => {
2359
+ const notice = branchNoticeRef.current;
2360
+ if (!notice) return;
2361
+ branchNoticeRef.current = null;
2362
+ append({ kind: "text", role: "system", text: notice });
2363
+ }, [branchNotice, session]);
2364
+
2252
2365
  useEffect(
2253
2366
  () => () => {
2254
2367
  clearTimeout(quitTimer.current);
@@ -2500,11 +2613,7 @@ export function App({
2500
2613
  setCwd(target);
2501
2614
  // The check-mode roots follow the move immediately: the next tool call must
2502
2615
  // not be judged against the directory the session just left.
2503
- setCheckModeConfig({
2504
- profile: settingsRef.current.checkMode,
2505
- model: settingsRef.current.checkModel,
2506
- additionalPaths: liveCheckPaths(settingsRef.current, target),
2507
- });
2616
+ applyRuntimeSettings(settingsRef.current, target);
2508
2617
  void subagentManager.bindMainSession(session.sessionManager, target).catch(() => {});
2509
2618
  // Both are keyed by project, so the old directory's recall would otherwise
2510
2619
  // follow the session into the new one.
@@ -2707,31 +2816,29 @@ export function App({
2707
2816
  : { kind: "return" });
2708
2817
  };
2709
2818
 
2819
+ const applyRuntimeSettings = (next: PumSettings, directory: string) => {
2820
+ setWritingStyle(next.writingStyle);
2821
+ setExplanationStrength(next.explanationStrength);
2822
+ setBashOutputSettingsIfPresent(next.bashOutput);
2823
+ subagentManager.setMaxActiveSubagents?.(next.maxActiveSubagents);
2824
+ runtimeSettings.request({
2825
+ checkMode: next.checkMode,
2826
+ checkModel: next.checkModel,
2827
+ sandboxMode: forcedSandboxMode ?? next.sandboxMode ?? "auto",
2828
+ checkPaths: liveCheckPaths(next, directory),
2829
+ webSearch: next.webSearch,
2830
+ }, (effective) => {
2831
+ setCheckModeConfig({ profile: effective.checkMode, model: effective.checkModel, additionalPaths: effective.checkPaths });
2832
+ onSandboxModeChange?.(effective.sandboxMode);
2833
+ webSearch.enabled = effective.webSearch;
2834
+ });
2835
+ };
2836
+
2710
2837
  const update = (patch: Partial<PumSettings>) => {
2711
2838
  const next = { ...settingsRef.current, ...patch };
2712
2839
  settingsRef.current = next;
2713
2840
  setSettings(next);
2714
- if (patch.webSearch !== undefined) webSearch.enabled = patch.webSearch;
2715
- if (patch.writingStyle !== undefined) setWritingStyle(patch.writingStyle);
2716
- if (patch.explanationStrength !== undefined) {
2717
- setExplanationStrength(patch.explanationStrength);
2718
- }
2719
- if (patch.sandboxMode !== undefined) {
2720
- onSandboxModeChange?.(forcedSandboxMode ?? patch.sandboxMode);
2721
- }
2722
- if (patch.checkMode !== undefined || patch.checkModel !== undefined || patch.checkPaths !== undefined) {
2723
- setCheckModeConfig({
2724
- profile: next.checkMode,
2725
- model: next.checkModel,
2726
- additionalPaths: liveCheckPaths(next, cwd),
2727
- });
2728
- }
2729
- if (patch.maxActiveSubagents !== undefined) {
2730
- subagentManager.setMaxActiveSubagents(patch.maxActiveSubagents);
2731
- }
2732
- // main.tsx applies this at startup. /settings can change it mid-session, so
2733
- // the running bash tool has to see the new policy on the next call.
2734
- if (patch.bashOutput !== undefined) setBashOutputSettingsIfPresent(patch.bashOutput);
2841
+ applyRuntimeSettings(next, cwd);
2735
2842
  // Session-scoped: the popup never writes the global config, which the
2736
2843
  // sandboxes keep read-only. Popup `s` and /store explicitly promote.
2737
2844
  const overrides = sessionSettingsDiff(globalSettingsRef.current, next);
@@ -2979,6 +3086,266 @@ export function App({
2979
3086
  queueMicrotask(() => inputRef.current?.focus());
2980
3087
  };
2981
3088
 
3089
+ // Guards that must still hold when the retired runtime's cleanup has settled
3090
+ // and the selection is about to be published. Refusing here is not a
3091
+ // cancellation: no queued input is dropped and no companion state changes.
3092
+ const sessionTransitionStateBlocker = (noun: string): string | null => {
3093
+ if (activeAgentIdRef.current !== null) {
3094
+ return `${noun} belongs to the main transcript. Select the main transcript first.`;
3095
+ }
3096
+ if (checkpointRecoveryRef.current || relocatingRef.current || pendingRelocationRef.current) {
3097
+ return `Wait for the running session operation to finish before ${noun.toLowerCase()}.`;
3098
+ }
3099
+ if (pendingImages.current.length > 0 || pendingPastedTexts.current.length > 0) {
3100
+ return `Clear attached images and pasted text before ${noun.toLowerCase()}.`;
3101
+ }
3102
+ if (!subagentManager.canBranchMainSession(session.sessionManager)) {
3103
+ return `Worker setup, a retained worker or a pending delivery must finish before ${noun.toLowerCase()}.`;
3104
+ }
3105
+ const goal = goalRef.current;
3106
+ if (goal && (goal.state === "active" || goal.state === "blocked")) {
3107
+ return `Stop the goal with /goal stop before ${noun.toLowerCase()}.`;
3108
+ }
3109
+ if (goal?.pendingContinuation) {
3110
+ return `A goal continuation is still owed. Resolve it before ${noun.toLowerCase()}.`;
3111
+ }
3112
+ return null;
3113
+ };
3114
+
3115
+ /** Full guard for a same-file runtime transition: state plus exact idleness. */
3116
+ const sessionTransitionBlocker = (noun: string): string | null => {
3117
+ if (branchRunningRef.current) return "A session transition is already being applied.";
3118
+ if (sessionSwitchRef.current) {
3119
+ return `Wait for the running session operation to finish before ${noun.toLowerCase()}.`;
3120
+ }
3121
+ const state = sessionTransitionStateBlocker(noun);
3122
+ if (state) return state;
3123
+ if (busyRef.current || !isRuntimeIdle(session) || session.pendingMessageCount !== 0
3124
+ || session.isBashRunning !== false || session.hasPendingBashMessages !== false
3125
+ || txRef.current.pending.length > 0 || hasConversationBranchPendingInput(session)) {
3126
+ return "Wait for the main session to become idle with no pending input, delivery or Bash work first.";
3127
+ }
3128
+ return null;
3129
+ };
3130
+
3131
+ const conversationBranchStateBlocker = () =>
3132
+ sessionTransitionStateBlocker("Conversation branching");
3133
+ const conversationBranchBlocker = (): string | null => {
3134
+ if (!onBranchConversation) return "Conversation branching is unavailable for this session runtime.";
3135
+ return sessionTransitionBlocker("Conversation branching");
3136
+ };
3137
+
3138
+ const closeConversationBranch = () => {
3139
+ branchOpenRef.current = false;
3140
+ setBranchOpen(false);
3141
+ branchConfirmingRef.current = false;
3142
+ setBranchConfirming(false);
3143
+ queueMicrotask(() => inputRef.current?.focus());
3144
+ };
3145
+
3146
+ /** Bounded, read-only projection. A throw means the transcript is unsafe. */
3147
+ const readConversationBranchPage = (offset: number): ConversationBranchPage | null => {
3148
+ try {
3149
+ return listConversationBranchPoints(session, { offset, limit: CONVERSATION_BRANCH_PAGE_SIZE });
3150
+ } catch {
3151
+ append({
3152
+ kind: "text",
3153
+ role: "error",
3154
+ text: "This conversation cannot be projected safely, so branching is refused. Nothing was changed.",
3155
+ });
3156
+ return null;
3157
+ }
3158
+ };
3159
+
3160
+ const openConversationBranch = () => {
3161
+ const blocked = conversationBranchBlocker();
3162
+ if (blocked) {
3163
+ append({ kind: "text", role: "error", text: blocked });
3164
+ return;
3165
+ }
3166
+ const page = readConversationBranchPage(0);
3167
+ if (!page) return;
3168
+ if (page.total === 0) {
3169
+ append({
3170
+ kind: "text",
3171
+ role: "system",
3172
+ text: "No safe conversation points are available to branch from.",
3173
+ });
3174
+ return;
3175
+ }
3176
+ settingsOpenRef.current = false;
3177
+ setSettingsOpen(false);
3178
+ setHelpOpen(false);
3179
+ setHistoryOpen(false);
3180
+ setAgentSelectorOpen(false);
3181
+ setTriggerPopup(false, false);
3182
+ setLoginOpen(false);
3183
+ setStatsOpen(false);
3184
+ statsOpenRef.current = false;
3185
+ todoOpenRef.current = false;
3186
+ setTodoOpen(false);
3187
+ newsOpenRef.current = false;
3188
+ setNewsOpen(false);
3189
+ branchOffsetRef.current = 0;
3190
+ setBranchOffset(0);
3191
+ branchCursorRef.current = 0;
3192
+ setBranchCursor(0);
3193
+ branchConfirmingRef.current = false;
3194
+ setBranchConfirming(false);
3195
+ branchPageRef.current = page;
3196
+ setBranchPage(page);
3197
+ branchOpenRef.current = true;
3198
+ setBranchOpen(true);
3199
+ };
3200
+
3201
+ const moveBranchCursor = (direction: number) => {
3202
+ if (branchConfirmingRef.current) return;
3203
+ const count = branchPageRef.current.points.length;
3204
+ if (count === 0) return;
3205
+ const next = Math.max(0, Math.min(count - 1, branchCursorRef.current + direction));
3206
+ branchCursorRef.current = next;
3207
+ setBranchCursor(next);
3208
+ };
3209
+
3210
+ const pageConversationBranch = (direction: number) => {
3211
+ if (branchConfirmingRef.current) return;
3212
+ const current = branchPageRef.current;
3213
+ const offset = direction > 0
3214
+ ? current.nextOffset
3215
+ : (branchOffsetRef.current > 0
3216
+ ? Math.max(0, branchOffsetRef.current - CONVERSATION_BRANCH_PAGE_SIZE)
3217
+ : null);
3218
+ if (offset === null) return;
3219
+ // Re-project instead of caching pages: a selection must always carry the
3220
+ // transcript state it was actually read from.
3221
+ const page = readConversationBranchPage(offset);
3222
+ if (!page) {
3223
+ closeConversationBranch();
3224
+ return;
3225
+ }
3226
+ branchOffsetRef.current = offset;
3227
+ setBranchOffset(offset);
3228
+ branchPageRef.current = page;
3229
+ setBranchPage(page);
3230
+ branchCursorRef.current = 0;
3231
+ setBranchCursor(0);
3232
+ };
3233
+
3234
+ const confirmConversationBranch = () => {
3235
+ if (!branchPageRef.current.points[branchCursorRef.current]) return;
3236
+ if (!branchConfirmingRef.current) {
3237
+ branchConfirmingRef.current = true;
3238
+ setBranchConfirming(true);
3239
+ return;
3240
+ }
3241
+ applyConversationBranch();
3242
+ };
3243
+
3244
+ const applyConversationBranch = () => {
3245
+ const point = branchPageRef.current.points[branchCursorRef.current];
3246
+ const blocked = conversationBranchBlocker();
3247
+ closeConversationBranch();
3248
+ if (!point || !onBranchConversation) return;
3249
+ if (blocked) {
3250
+ append({ kind: "text", role: "error", text: blocked });
3251
+ return;
3252
+ }
3253
+ // The App owns view/delivery admission; the host owns runtime, ownership and
3254
+ // transcript proof. Both are rechecked while the transaction still holds the
3255
+ // canonical lock, before and after the retired runtime's cleanup.
3256
+ const guard = () => {
3257
+ const reason = conversationBranchStateBlocker();
3258
+ if (reason) throw new Error(reason);
3259
+ };
3260
+ branchRunningRef.current = true;
3261
+ sessionSwitchRef.current = true;
3262
+ setWorking(true);
3263
+ onBranchConversation(point.selection, { validate: guard, validateRetired: guard })
3264
+ .then((result) => {
3265
+ if (!result) {
3266
+ append({
3267
+ kind: "text",
3268
+ role: "system",
3269
+ text: "Conversation branch was not applied; the current path is unchanged.",
3270
+ });
3271
+ queueMicrotask(() => inputRef.current?.focus());
3272
+ return;
3273
+ }
3274
+ branchNoticeRef.current = result.recovered
3275
+ ? "Conversation branch failed and the original conversation path was restored. Nothing else was changed."
3276
+ : "Conversation branch selected in the same session. Files, git state and current goals, todos, settings, News and tool groups were not rewound.";
3277
+ if (activeAgentIdRef.current !== null) selectAgentView(null);
3278
+ focusInputAfterSwitch.current = true;
3279
+ setSession(result.session);
3280
+ setBranchNotice((revision) => revision + 1);
3281
+ const restored = result.recovered ? undefined : result.editorText;
3282
+ queueMicrotask(() => {
3283
+ // A restored earlier prompt is never fresh command authority and is
3284
+ // never submitted for the user.
3285
+ if (restored !== undefined && !inputRef.current?.plainText) {
3286
+ setEditorText(restored, restored.length, false, "restored");
3287
+ }
3288
+ inputRef.current?.focus();
3289
+ });
3290
+ })
3291
+ .catch((error) => {
3292
+ append({ kind: "text", role: "error", text: String(error) });
3293
+ queueMicrotask(() => inputRef.current?.focus());
3294
+ })
3295
+ .finally(() => {
3296
+ branchRunningRef.current = false;
3297
+ sessionSwitchRef.current = false;
3298
+ setWorking(false);
3299
+ });
3300
+ };
3301
+
3302
+ const applyPlanModeTransition = (mode: PlanMode) => {
3303
+ if (!onTransitionPlanMode) {
3304
+ append({ kind: "text", role: "error", text: "Plan mode is unavailable for this session runtime." });
3305
+ return;
3306
+ }
3307
+ const noun = "Changing plan mode";
3308
+ const blocked = sessionTransitionBlocker(noun);
3309
+ if (blocked) {
3310
+ append({ kind: "text", role: "error", text: blocked });
3311
+ return;
3312
+ }
3313
+ const guard = () => {
3314
+ const reason = sessionTransitionStateBlocker(noun);
3315
+ if (reason) throw new Error(reason);
3316
+ };
3317
+ implementArmedRef.current = false;
3318
+ branchRunningRef.current = true;
3319
+ sessionSwitchRef.current = true;
3320
+ setWorking(true);
3321
+ onTransitionPlanMode(mode, { validate: guard, validateRetired: guard })
3322
+ .then((result) => {
3323
+ if (!result) {
3324
+ append({ kind: "text", role: "system", text: "Plan mode was not changed." });
3325
+ queueMicrotask(() => inputRef.current?.focus());
3326
+ return;
3327
+ }
3328
+ branchNoticeRef.current = result.recovered
3329
+ ? "Plan mode transition failed. The session stays in plan mode; no capability was restored."
3330
+ : result.mode === "plan"
3331
+ ? "Plan mode is ON. File mutation, mutable delegation, managed shells, MCP servers and project validation are blocked; Bash needs native sandbox enforcement. Your own ! commands are unchanged."
3332
+ : "Plan mode is off. Full capability is restored for this session.";
3333
+ focusInputAfterSwitch.current = true;
3334
+ setSession(result.session);
3335
+ setBranchNotice((revision) => revision + 1);
3336
+ queueMicrotask(() => inputRef.current?.focus());
3337
+ })
3338
+ .catch((error) => {
3339
+ append({ kind: "text", role: "error", text: String(error) });
3340
+ queueMicrotask(() => inputRef.current?.focus());
3341
+ })
3342
+ .finally(() => {
3343
+ branchRunningRef.current = false;
3344
+ sessionSwitchRef.current = false;
3345
+ setWorking(false);
3346
+ });
3347
+ };
3348
+
2982
3349
  // Re-read on every transcript change while the popup is open: a todo tool call
2983
3350
  // always adds a line, so the list follows the agent without an event bus.
2984
3351
  const todoSessionFile = activeAgent?.sessionFile ?? session.sessionFile;
@@ -3348,6 +3715,7 @@ export function App({
3348
3715
  ? requesterAgentId
3349
3716
  : null;
3350
3717
  viewDrafts.current.set(targetAgentId ?? "main", quote);
3718
+ viewDraftOrigins.current.set(targetAgentId ?? "main", "restored");
3351
3719
  if (activeAgentIdRef.current !== targetAgentId) selectAgentView(targetAgentId);
3352
3720
  else setEditorText(quote);
3353
3721
  queueMicrotask(() => {
@@ -3368,6 +3736,7 @@ export function App({
3368
3736
  };
3369
3737
 
3370
3738
  const selectHistorySession = (path: string) => {
3739
+ if (checkpointRecoveryRef.current) return;
3371
3740
  setHistoryOpen(false);
3372
3741
  if (path === session.sessionFile) {
3373
3742
  queueMicrotask(() => inputRef.current?.focus());
@@ -3462,6 +3831,8 @@ export function App({
3462
3831
  histCursor.current = null;
3463
3832
  // clearQueue may have dropped a subagent completion notice queued to the
3464
3833
  // streaming main agent; re-arm undelivered notices so a merge is not stuck.
3834
+ mcpForSession?.(session)?.cancel();
3835
+ lspForSession?.(session)?.cancel();
3465
3836
  session.abort().finally(() => {
3466
3837
  setWorking(false);
3467
3838
  void subagentManager.resendUndeliveredMainSettlements();
@@ -3481,6 +3852,7 @@ export function App({
3481
3852
  }
3482
3853
  const key = target ?? "main";
3483
3854
  viewDrafts.current.set(key, recalled.text);
3855
+ viewDraftOrigins.current.set(key, "restored");
3484
3856
  if (activeAgentIdRef.current === target) {
3485
3857
  setEditorText(recalled.text);
3486
3858
  histCursor.current = null;
@@ -3604,6 +3976,7 @@ export function App({
3604
3976
  };
3605
3977
 
3606
3978
  const runCommand = (text: string): boolean => {
3979
+ if (checkpointRecoveryRef.current) return true;
3607
3980
  const trimmed = text.trim();
3608
3981
  if (runGoalCommand(trimmed)) return true;
3609
3982
  if (trimmed === "/s" || trimmed === "/store") {
@@ -4213,11 +4586,13 @@ export function App({
4213
4586
  if (activeAgentIdRef.current !== requesterAgentId) {
4214
4587
  if (!(viewDrafts.current.get(requesterKey) ?? "")) {
4215
4588
  viewDrafts.current.set(requesterKey, draftText);
4589
+ viewDraftOrigins.current.set(requesterKey, "restored");
4216
4590
  }
4217
4591
  return;
4218
4592
  }
4219
4593
  if (!(inputRef.current?.plainText ?? "")) {
4220
4594
  viewDrafts.current.set(requesterKey, draftText);
4595
+ viewDraftOrigins.current.set(requesterKey, "restored");
4221
4596
  setEditorText(draftText, draftText.length, true);
4222
4597
  }
4223
4598
  };
@@ -4288,7 +4663,7 @@ export function App({
4288
4663
  return true;
4289
4664
  };
4290
4665
 
4291
- const submitPrompt = (value?: string, stashIndex?: number) => {
4666
+ const submitPrompt = (value?: string, stashIndex?: number, directCompletion = false) => {
4292
4667
  // Read the selected agent from the ref, not the state. A view switch updates
4293
4668
  // the ref synchronously, but a switch-then-send in one input chunk runs
4294
4669
  // before React commits the new state, so the state would still name the
@@ -4300,6 +4675,10 @@ export function App({
4300
4675
  const attachments = value === undefined ? [...pendingImages.current] : [];
4301
4676
  const pastedTexts = value === undefined ? [...pendingPastedTexts.current] : [];
4302
4677
  const submittedEditingIndex = editingStashIndex.current;
4678
+ // Completion transforms a draft; it cannot turn restored/cache content into
4679
+ // user consent. Snapshot before editor cleanup starts the next direct draft.
4680
+ const directSubmission = draftOrigin.current === "direct"
4681
+ && (value === undefined || directCompletion) && stashIndex === undefined;
4303
4682
  const expandedText = expandPastedTexts(rawDisplayText, pastedTexts);
4304
4683
  const displayText = pastedTexts.length ? expandedText : expandedText.trim();
4305
4684
  const expandedPrompt = expandPastedTexts(rawDisplayText, [
@@ -4311,6 +4690,7 @@ export function App({
4311
4690
  const persistedPrompt = displayText;
4312
4691
 
4313
4692
  if (!promptText && attachments.length === 0 && pastedTexts.length === 0) return;
4693
+ if (checkpointRecoveryRef.current) return;
4314
4694
 
4315
4695
  const backgroundCandidate = commandEligible ? parseBackgroundCommand(promptText) : null;
4316
4696
  if (backgroundCandidate?.kind === "error") {
@@ -4385,6 +4765,7 @@ export function App({
4385
4765
  nextPastedTextId.current = Math.max(1, ...pastedTexts.map((pasted) => pasted.id + 1));
4386
4766
  setEditingStash(submittedEditingIndex);
4387
4767
  viewDrafts.current.set(targetKey, rawDisplayText);
4768
+ viewDraftOrigins.current.set(targetKey, "restored");
4388
4769
  viewEditingStashIndices.current.set(targetKey, submittedEditingIndex);
4389
4770
  setEditorText(rawDisplayText, rawDisplayText.length, true);
4390
4771
  };
@@ -4402,6 +4783,324 @@ export function App({
4402
4783
  return;
4403
4784
  }
4404
4785
 
4786
+ // Diagnostics belong to the selected session, but never enter its durable
4787
+ // transcript or model context. Intercept before ordinary child delivery.
4788
+ if (attachments.length === 0 && commandEligible && /^\/diagnostics(?:\s|$)/.test(promptText.trim())) {
4789
+ const diagnosticSessionId = selectedAgentId
4790
+ ? subagentManager.getDiagnosticsSessionId(selectedAgentId)
4791
+ : session.sessionId;
4792
+ const line: Line = { kind: "text", role: "system", text: diagnosticsCommandText(promptText, diagnosticSessionId)! };
4793
+ if (selectedAgentId) subagentManager.appendAgentLine(selectedAgentId, line, { persist: false });
4794
+ else appendMainLine(line);
4795
+ setEditorText("");
4796
+ setEditingStash(null);
4797
+ histCursor.current = null;
4798
+ draft.current = "";
4799
+ return;
4800
+ }
4801
+
4802
+ // Every LSP operation requires the same ref-backed draft origin as MCP.
4803
+ // Even cached reads/status/stop cannot become programmatic consent commands.
4804
+ if (attachments.length === 0 && commandEligible && /^\/lsp(?:\s|$)/.test(promptText)) {
4805
+ setEditingStash(null);
4806
+ histCursor.current = null;
4807
+ draft.current = "";
4808
+ const report = (text: string, error = false) => {
4809
+ const line: Line = { kind: "text", role: error ? "error" : "system", text };
4810
+ if (selectedAgentId) subagentManager.appendAgentLine(selectedAgentId, line, { persist: false });
4811
+ else appendMainLine(line);
4812
+ };
4813
+ if (!directSubmission) {
4814
+ report("LSP commands require direct user input. Clear the draft and type the command anew.", true);
4815
+ return;
4816
+ }
4817
+ if (selectedAgentId) {
4818
+ report("LSP is available only in the main TUI session; workers cannot access servers.", true);
4819
+ return;
4820
+ }
4821
+ const controller = lspForSession?.(session);
4822
+ if (!controller) {
4823
+ report("LSP is unavailable for this session runtime.", true);
4824
+ return;
4825
+ }
4826
+ const action = promptText.trim().split(/\s+/)[1];
4827
+ if ((action === "connect" || action === "check") && (busyRef.current || !isRuntimeIdle(session)
4828
+ || sessionSwitchRef.current || relocatingRef.current || pendingRelocationRef.current)) {
4829
+ report("Wait for the main session to become idle and session operations to finish before connecting or checking LSP.", true);
4830
+ return;
4831
+ }
4832
+ void controller.command(promptText).then((text) => {
4833
+ if (sessionRef.current === session && lspForSession?.(session) === controller) report(text);
4834
+ }).catch(() => {
4835
+ if (sessionRef.current === session && lspForSession?.(session) === controller) {
4836
+ report("LSP request failed or was revoked. Native Linux Bubblewrap, a supported server and unchanged proposal/file are required. Preview /lsp before approving again.", true);
4837
+ }
4838
+ });
4839
+ return;
4840
+ }
4841
+
4842
+ // MCP consent exists only at this direct input boundary. It is not an SDK
4843
+ // command, saved prompt, model capability, or selected-worker operation.
4844
+ if (attachments.length === 0 && commandEligible && /^\/mcp(?:\s|$)/.test(promptText)) {
4845
+ setEditingStash(null);
4846
+ histCursor.current = null;
4847
+ draft.current = "";
4848
+ const report = (text: string, error = false) => {
4849
+ const line: Line = { kind: "text", role: error ? "error" : "system", text };
4850
+ if (selectedAgentId) subagentManager.appendAgentLine(selectedAgentId, line, { persist: false });
4851
+ else appendMainLine(line);
4852
+ };
4853
+ if (!directSubmission) {
4854
+ report("MCP commands require direct user input. Clear the draft and type the command anew.", true);
4855
+ return;
4856
+ }
4857
+ if (selectedAgentId) {
4858
+ report("MCP is available only in the main TUI session; workers cannot access servers.", true);
4859
+ return;
4860
+ }
4861
+ const controller = mcpForSession?.(session);
4862
+ if (!controller) {
4863
+ report("MCP is unavailable for this session runtime.", true);
4864
+ return;
4865
+ }
4866
+ const action = promptText.trim().split(/\s+/)[1];
4867
+ if (planModeRef.current && action !== "status" && action !== undefined) {
4868
+ // PUM cannot certify a server as non-mutating, so plan mode refuses the
4869
+ // whole lifecycle rather than reasoning about one server's intent.
4870
+ report("Plan mode cannot connect, approve or use MCP servers. Leave plan mode with /implement confirm first.", true);
4871
+ return;
4872
+ }
4873
+ if ((action === "connect" || action === "approve") && (busyRef.current || !isRuntimeIdle(session)
4874
+ || sessionSwitchRef.current || relocatingRef.current || pendingRelocationRef.current)) {
4875
+ report("Wait for the main session to become idle and session operations to finish before connecting or approving MCP.", true);
4876
+ return;
4877
+ }
4878
+ void controller.command(promptText).then((text) => {
4879
+ if (sessionRef.current === session && mcpForSession?.(session) === controller) report(text);
4880
+ }).catch(() => {
4881
+ if (sessionRef.current === session && mcpForSession?.(session) === controller) {
4882
+ report("MCP request failed. Preview /mcp and check the current proposal and digests.", true);
4883
+ }
4884
+ });
4885
+ return;
4886
+ }
4887
+
4888
+ // Approval is a direct-user capability, never an SDK command or model
4889
+ // message. Reports are transient and belong only to the selected runtime.
4890
+ if (attachments.length === 0 && commandEligible && /^\/validation(?:\s|$)/.test(promptText)) {
4891
+ const report = (text: string, error = false) => {
4892
+ const line: Line = { kind: "text", role: error ? "error" : "system", text };
4893
+ if (selectedAgentId) subagentManager.appendAgentLine(selectedAgentId, line, { persist: false });
4894
+ else appendMainLine(line);
4895
+ };
4896
+ setEditingStash(null);
4897
+ histCursor.current = null;
4898
+ draft.current = "";
4899
+ if (!directSubmission) {
4900
+ report("Validation commands require direct user input. Clear the draft and type the command anew.", true);
4901
+ return;
4902
+ }
4903
+ const parts = promptText.trim().split(/\s+/);
4904
+ const action = parts[1];
4905
+ if (!(parts.length === 1 || (parts.length === 2 && (action === "status" || action === "disable"))
4906
+ || (parts.length === 3 && action === "enable" && /^[0-9a-f]{64}$/i.test(parts[2]!)))) {
4907
+ report("Usage: /validation [status|enable <64-hex digest>|disable]", true);
4908
+ return;
4909
+ }
4910
+ // A role refusal does not depend on a runtime controller, so it is
4911
+ // reported before an "unavailable" message can hide the real reason.
4912
+ if (action === "enable" && !selectedAgentId && planModeRef.current) {
4913
+ // Validation runs configured project commands: a mutation path.
4914
+ report("Plan mode cannot enable project validation. Leave plan mode with /implement confirm first.", true);
4915
+ return;
4916
+ }
4917
+ const selected = selectedAgentId ? subagentManager.getAgent(selectedAgentId) : undefined;
4918
+ const controller = validationForSession(selectedAgentId ? subagentManager.getDiagnosticsSessionId(selectedAgentId) ?? "" : session);
4919
+ if (!controller) {
4920
+ report("Project validation is unavailable for this session runtime.", true);
4921
+ return;
4922
+ }
4923
+ try {
4924
+ if (action === "enable") {
4925
+ if (selected?.readonly) {
4926
+ report("Readonly sessions cannot enable project validation.", true);
4927
+ return;
4928
+ }
4929
+ if (!controller.isIdle || sessionSwitchRef.current || relocatingRef.current || pendingRelocationRef.current
4930
+ || (selectedAgentId ? !selected || selected.status === "starting" || selected.status === "running" : busyRef.current)) {
4931
+ report("Wait for the selected session to become idle and session operations to finish before enabling validation.", true);
4932
+ return;
4933
+ }
4934
+ controller.enable(parts[2]!);
4935
+ report(controller.status());
4936
+ } else if (action === "disable") {
4937
+ // Deliberately allowed while busy: revoke and cancel in-flight work.
4938
+ controller.disable();
4939
+ report(controller.status());
4940
+ } else report(action === "status" ? controller.status() : controller.preview());
4941
+ } catch {
4942
+ report("Project validation request failed. Preview /validation and check the current proposal and digest before enabling.", true);
4943
+ }
4944
+ return;
4945
+ }
4946
+
4947
+ // Explicit user-only capability gate. Entering plan mode only removes
4948
+ // capability, so it needs no confirmation; leaving it is the upgrade, so it
4949
+ // needs a second directly typed command, never a keypress on a restored draft.
4950
+ if (attachments.length === 0 && commandEligible && /^\/(?:plan|implement)(?:\s|$)/.test(promptText)) {
4951
+ setEditingStash(null);
4952
+ histCursor.current = null;
4953
+ draft.current = "";
4954
+ const report = (text: string, error = false) => {
4955
+ const line: Line = { kind: "text", role: error ? "error" : "system", text };
4956
+ if (selectedAgentId) subagentManager.appendAgentLine(selectedAgentId, line, { persist: false });
4957
+ else appendMainLine(line);
4958
+ };
4959
+ const parts = promptText.trim().split(/\s+/);
4960
+ const implement = parts[0] === "/implement";
4961
+ if (!directSubmission) {
4962
+ implementArmedRef.current = false;
4963
+ report("Plan mode commands require direct user input. Clear the draft and type the command anew.", true);
4964
+ return;
4965
+ }
4966
+ if (selectedAgentId) {
4967
+ report("Plan mode is a main-session role. Select the main transcript first.", true);
4968
+ return;
4969
+ }
4970
+ const active = planModeRef.current;
4971
+ if (implement) {
4972
+ if (parts.length > 2 || (parts.length === 2 && parts[1] !== "confirm")) {
4973
+ report("Usage: /implement, then /implement confirm to leave plan mode.", true);
4974
+ return;
4975
+ }
4976
+ if (!active) {
4977
+ implementArmedRef.current = false;
4978
+ report("Plan mode is already off; this session has full capability.", true);
4979
+ return;
4980
+ }
4981
+ if (parts.length === 1) {
4982
+ implementArmedRef.current = true;
4983
+ report("Leaving plan mode restores file mutation, mutable delegation, managed shells, MCP and project validation for this session. Files are not changed by the transition itself.\nType /implement confirm to leave plan mode, or keep planning.");
4984
+ return;
4985
+ }
4986
+ if (!implementArmedRef.current) {
4987
+ report("Type /implement first, read what it restores, then /implement confirm.", true);
4988
+ return;
4989
+ }
4990
+ applyPlanModeTransition("implement");
4991
+ return;
4992
+ }
4993
+ implementArmedRef.current = false;
4994
+ const text = promptText.trim().slice("/plan".length).trim();
4995
+ if (text) {
4996
+ try {
4997
+ const file = session.sessionFile;
4998
+ if (!file) throw new Error("Recording a plan requires a saved session.");
4999
+ const existing = loadPlanRecord(file);
5000
+ savePlanRecord(file, {
5001
+ version: 1,
5002
+ mode: existing?.mode ?? (active ? "plan" : "implement"),
5003
+ text: validatePlanText(text),
5004
+ at: Date.now(),
5005
+ });
5006
+ } catch (error) {
5007
+ report(error instanceof Error ? error.message : "The plan could not be recorded.", true);
5008
+ return;
5009
+ }
5010
+ report("Plan recorded.");
5011
+ }
5012
+ if (active) {
5013
+ report(describePlanRecord(loadPlanRecord(session.sessionFile), true));
5014
+ return;
5015
+ }
5016
+ applyPlanModeTransition("plan");
5017
+ return;
5018
+ }
5019
+
5020
+ // Explicit user-only conversation navigation. Never an extension command,
5021
+ // model tool, attachment or pasted marker, and never a filesystem restore.
5022
+ if (attachments.length === 0 && commandEligible && /^\/(?:branch|rewind)(?:\s|$)/.test(promptText)) {
5023
+ setEditingStash(null);
5024
+ histCursor.current = null;
5025
+ draft.current = "";
5026
+ const report = (text: string, error = false) => {
5027
+ const line: Line = { kind: "text", role: error ? "error" : "system", text };
5028
+ if (selectedAgentId) subagentManager.appendAgentLine(selectedAgentId, line, { persist: false });
5029
+ else appendMainLine(line);
5030
+ };
5031
+ if (!directSubmission) {
5032
+ report("Conversation branch commands require direct user input. Clear the draft and type the command anew.", true);
5033
+ return;
5034
+ }
5035
+ if (selectedAgentId) {
5036
+ report("Conversation branching is available only in the main transcript. Select the main transcript first.", true);
5037
+ return;
5038
+ }
5039
+ if (promptText.trim().split(/\s+/).length !== 1) {
5040
+ report("Usage: /branch (alias /rewind). Choose and confirm a point in the selector.", true);
5041
+ return;
5042
+ }
5043
+ openConversationBranch();
5044
+ return;
5045
+ }
5046
+
5047
+ // Explicit user-only recovery: never registered as an extension command or
5048
+ // model tool, and never interpreted from an attached image or pasted marker.
5049
+ if (attachments.length === 0 && commandEligible && /^\/checkpoint(?:\s|$)/.test(promptText)) {
5050
+ const report = (text: string, error = false) => {
5051
+ const line: Line = { kind: "text", role: error ? "error" : "system", text };
5052
+ if (selectedAgentId) subagentManager.appendAgentLine(selectedAgentId, line, { persist: false });
5053
+ else appendMainLine(line);
5054
+ };
5055
+ setEditingStash(null);
5056
+ histCursor.current = null;
5057
+ draft.current = "";
5058
+ const selected = selectedAgentId ? subagentManager.getAgent(selectedAgentId) : undefined;
5059
+ if (!directSubmission) {
5060
+ report("Checkpoint commands require direct user input. Clear the draft and type the command anew.", true);
5061
+ return;
5062
+ }
5063
+ const controller = checkpointControllerForSession(selectedAgentId ? subagentManager.getDiagnosticsSessionId(selectedAgentId) ?? "" : session.sessionId);
5064
+ const parts = promptText.trim().split(/\s+/);
5065
+ const action = parts[1] ?? "list";
5066
+ if (!((action === "list" || action === "clear") && parts.length <= 2)
5067
+ && !(action === "recover" && parts.length === 3)) {
5068
+ report("Usage: /checkpoint [list|recover <id>|clear]", true);
5069
+ return;
5070
+ }
5071
+ if (!controller) {
5072
+ report("File checkpoints are unavailable for this session runtime.", true);
5073
+ return;
5074
+ }
5075
+ if (action === "list") {
5076
+ const records = controller.list();
5077
+ report(`${controller.summary()}\n${records.length ? records.map((record) =>
5078
+ `${record.id} | ${record.toolName} | ${record.priorAbsent ? "previously absent" : `${record.bytes} bytes before change`} | ${JSON.stringify(record.path)}`
5079
+ ).join("\n") : "No file checkpoints retained."}\nRecovery creates a new copy only; original files are never restored in place.`);
5080
+ } else if (action === "clear") {
5081
+ controller.clear();
5082
+ report("File checkpoints cleared for this session runtime; original files unchanged.");
5083
+ } else {
5084
+ if (sessionSwitchRef.current || relocatingRef.current || pendingRelocationRef.current
5085
+ || (selectedAgentId ? !selected || selected.status === "starting" || selected.status === "running" : busyRef.current)) {
5086
+ report("Wait for the selected session to become idle and session operations to finish before recovering a checkpoint.", true);
5087
+ return;
5088
+ }
5089
+ checkpointRecoveryRef.current = true;
5090
+ report("Creating a checkpoint recovery copy; wait for it to finish before changing sessions.");
5091
+ void Promise.resolve().then(() => {
5092
+ const liveId = selectedAgentId ? subagentManager.getDiagnosticsSessionId(selectedAgentId) : session.sessionId;
5093
+ if (!liveId || checkpointControllerForSession(liveId) !== controller) throw new Error("Checkpoint runtime changed");
5094
+ return controller.recover(parts[2]!);
5095
+ }).then((path) => {
5096
+ report(`Recovery copy created: ${JSON.stringify(path)}\nOriginal unchanged. Inspect the copy and apply it manually if wanted.`);
5097
+ }).catch((error) => {
5098
+ report(checkpointRecoveryFailureText(error), true);
5099
+ }).finally(() => { checkpointRecoveryRef.current = false; });
5100
+ }
5101
+ return;
5102
+ }
5103
+
4405
5104
  // Model controls follow Settings: only main owns model mutations.
4406
5105
  if (selectedAgentId && attachments.length === 0 && commandEligible
4407
5106
  && /^\/(?:model|effort|s|store)(?:\s|$)/.test(promptText)) {
@@ -4474,6 +5173,7 @@ export function App({
4474
5173
  };
4475
5174
 
4476
5175
  const submitShellCommand = () => {
5176
+ if (checkpointRecoveryRef.current) return;
4477
5177
  const command = expandPastedTexts(inputRef.current?.plainText ?? "", pendingPastedTexts.current);
4478
5178
  if (!command.trim()) return;
4479
5179
  const selectedAgentId = activeAgentIdRef.current;
@@ -4736,6 +5436,7 @@ export function App({
4736
5436
  };
4737
5437
 
4738
5438
  const selectAgentView = (target: string | null): boolean => {
5439
+ if (checkpointRecoveryRef.current) return false;
4739
5440
  const current = activeAgentIdRef.current;
4740
5441
  // Nothing moves between views, so a pending attachment cannot be stranded.
4741
5442
  if (target === current) return true;
@@ -4750,11 +5451,13 @@ export function App({
4750
5451
  const currentKey = current ?? "main";
4751
5452
  const targetKey = target ?? "main";
4752
5453
  viewDrafts.current.set(currentKey, inputRef.current?.plainText ?? "");
5454
+ viewDraftOrigins.current.set(currentKey, draftOrigin.current);
4753
5455
  viewEditingStashIndices.current.set(currentKey, editingStashIndex.current);
4754
5456
  activeAgentIdRef.current = target;
4755
5457
  setActiveAgentId(target);
4756
5458
  setEditingStash(viewEditingStashIndices.current.get(targetKey) ?? null);
4757
- setEditorText(viewDrafts.current.get(targetKey) ?? "");
5459
+ setEditorText(viewDrafts.current.get(targetKey) ?? "", undefined, false,
5460
+ viewDraftOrigins.current.get(targetKey) ?? "restored");
4758
5461
  setStashMode(false);
4759
5462
  histCursor.current = null;
4760
5463
  resetCancelArm();
@@ -4951,6 +5654,13 @@ export function App({
4951
5654
  });
4952
5655
 
4953
5656
  useKeyboard((key) => {
5657
+ // A recovery is an explicit filesystem copy owned by the selected runtime.
5658
+ // Hold user transitions (including quit, settings, and agent/session switches)
5659
+ // until it settles rather than disposing that runtime underneath the copy.
5660
+ if (checkpointRecoveryRef.current) {
5661
+ key.stopPropagation();
5662
+ return;
5663
+ }
4954
5664
  // Match Textarea's printable sequence handling, but leave all modified
4955
5665
  // commands (including Option) with their existing owner.
4956
5666
  const typedText = !key.ctrl && !key.meta && !key.option && !key.super && !key.hyper
@@ -5003,7 +5713,8 @@ export function App({
5003
5713
  helpOpen ||
5004
5714
  historyOpen ||
5005
5715
  statsOpenRef.current ||
5006
- newsOpenRef.current
5716
+ newsOpenRef.current ||
5717
+ branchOpenRef.current
5007
5718
  )) {
5008
5719
  key.stopPropagation();
5009
5720
  resetCancelArm();
@@ -5025,6 +5736,7 @@ export function App({
5025
5736
  queueMicrotask(() => inputRef.current?.focus());
5026
5737
  } else if (statsOpenRef.current) closeStats();
5027
5738
  else if (newsOpenRef.current) closeNews();
5739
+ else if (branchOpenRef.current) closeConversationBranch();
5028
5740
  return;
5029
5741
  }
5030
5742
 
@@ -5265,6 +5977,26 @@ export function App({
5265
5977
  return; // navigation and Enter belong to the focused select
5266
5978
  }
5267
5979
 
5980
+ if (branchOpenRef.current || branchOpen) {
5981
+ key.stopPropagation();
5982
+ if (key.name === "escape") {
5983
+ // Escape backs out of the confirmation first, so a stray key cannot
5984
+ // apply a branch the user only wanted to look at.
5985
+ if (branchConfirmingRef.current) {
5986
+ branchConfirmingRef.current = false;
5987
+ setBranchConfirming(false);
5988
+ } else closeConversationBranch();
5989
+ }
5990
+ else if (key.name === "up") moveBranchCursor(-1);
5991
+ else if (key.name === "down") moveBranchCursor(1);
5992
+ else if (key.name === "left") pageConversationBranch(-1);
5993
+ else if (key.name === "right") pageConversationBranch(1);
5994
+ else if (key.name === "return" || key.name === "enter" || key.name === "kpenter") {
5995
+ confirmConversationBranch();
5996
+ }
5997
+ return;
5998
+ }
5999
+
5268
6000
  if (key.ctrl && key.name === "h") {
5269
6001
  key.stopPropagation();
5270
6002
  openHistory();
@@ -5620,7 +6352,7 @@ export function App({
5620
6352
  if (commandMatches.length > 0 && !/\s/.test(inputValue)) {
5621
6353
  key.stopPropagation();
5622
6354
  const selected = commandMatches[Math.min(commandCursorRef.current, commandMatches.length - 1)]!;
5623
- setEditorText(selected.name);
6355
+ setEditorText(selected.name, undefined, false, draftOrigin.current);
5624
6356
  return;
5625
6357
  }
5626
6358
 
@@ -5638,7 +6370,7 @@ export function App({
5638
6370
  : Math.min(commandCursorRef.current, completions.length - 1);
5639
6371
  const sourceValue = continuing ? previousCycle!.sourceValue : inputValue;
5640
6372
  const completed = applyPathCompletion(sourceValue, completions[index]!);
5641
- setEditorText(completed.value, completed.cursorOffset, true);
6373
+ setEditorText(completed.value, completed.cursorOffset, true, draftOrigin.current);
5642
6374
  pathCompletionCycle.current = {
5643
6375
  sourceValue,
5644
6376
  completions,
@@ -5678,7 +6410,7 @@ export function App({
5678
6410
  if (isPlainReturn && commandMatches.length > 0 && !/\s/.test(inputValue)) {
5679
6411
  key.stopPropagation();
5680
6412
  const selected = commandMatches[Math.min(commandCursorRef.current, commandMatches.length - 1)]!;
5681
- submitPrompt(selected.name);
6413
+ submitPrompt(selected.name, undefined, true);
5682
6414
  return;
5683
6415
  }
5684
6416
 
@@ -5687,10 +6419,10 @@ export function App({
5687
6419
  const index = Math.min(commandCursorRef.current, visibleArgumentMatches.length - 1);
5688
6420
  const completed = applyPathCompletion(inputValue, visibleArgumentMatches[index]!);
5689
6421
  if (closedSetMatches.length > 0) {
5690
- submitPrompt(completed.value);
6422
+ submitPrompt(completed.value, undefined, true);
5691
6423
  return;
5692
6424
  }
5693
- setEditorText(completed.value, completed.cursorOffset, true);
6425
+ setEditorText(completed.value, completed.cursorOffset, true, draftOrigin.current);
5694
6426
  pathCompletionCycle.current = {
5695
6427
  sourceValue: inputValue,
5696
6428
  completions: visibleArgumentMatches,
@@ -5863,14 +6595,14 @@ export function App({
5863
6595
 
5864
6596
  const popupResetKey = [
5865
6597
  loginOpen, spawnPreview?.id ?? "", Boolean(questionnaire), helpOpen, triggersOpen,
5866
- agentSelectorOpen, historyOpen, newsOpen, todoVisible, statsOpen, settingsOpen, page,
6598
+ agentSelectorOpen, historyOpen, newsOpen, branchOpen, todoVisible, statsOpen, settingsOpen, page,
5867
6599
  ].join(":");
5868
6600
  const streamGap = visibleTx.stream
5869
6601
  ? needsTranscriptGap(lastLine, { kind: "text", role: visibleTx.stream.kind, text: visibleTx.stream.text })
5870
6602
  : false;
5871
6603
  const promptFocused = !transcriptFocused && !settingsOpen && !helpOpen && !historyOpen
5872
6604
  && !statsOpen && !agentSelectorOpen && !triggersOpen && !loginOpen && !providersOpen
5873
- && !visibleQuestionnaire && !spawnPreview && !newsOpen && !todoVisible;
6605
+ && !visibleQuestionnaire && !spawnPreview && !newsOpen && !branchOpen && !todoVisible;
5874
6606
 
5875
6607
  // One element for the whole transcript, rebuilt only when what it shows
5876
6608
  // changes. React walks a child list of this size on every render of the app,
@@ -5941,6 +6673,7 @@ export function App({
5941
6673
  />
5942
6674
  <StatusBar
5943
6675
  theme={theme}
6676
+ planMode={planMode}
5944
6677
  modelId={visibleModelId}
5945
6678
  thinkingLevel={visibleThinkingLevel}
5946
6679
  cwd={cwd}
@@ -6006,6 +6739,7 @@ export function App({
6006
6739
  ) : null}
6007
6740
  </RenderErrorBoundary>
6008
6741
  </scrollbox>
6742
+ {pendingSettingsNotice ? <text content={pendingSettingsNotice} fg={theme.dim} flexShrink={0} /> : null}
6009
6743
  {ruleLabels.length > 0 ? <Gap /> : null}
6010
6744
  <WorkingRule
6011
6745
  theme={theme}
@@ -6218,6 +6952,18 @@ export function App({
6218
6952
  onSelect={selectHistorySession}
6219
6953
  />
6220
6954
  ) : null}
6955
+ {branchOpen ? (
6956
+ <ConversationBranchPopup
6957
+ theme={theme}
6958
+ points={branchPage.points}
6959
+ cursor={branchCursor}
6960
+ offset={branchOffset}
6961
+ total={branchPage.total}
6962
+ confirming={branchConfirming}
6963
+ terminalWidth={width}
6964
+ terminalHeight={height}
6965
+ />
6966
+ ) : null}
6221
6967
  {newsOpen ? (
6222
6968
  <NewsPopup
6223
6969
  theme={theme}