@bubblebrain-ai/bubble 0.0.37 → 0.0.38

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 (51) hide show
  1. package/dist/agent/internal-reminder-sanitizer.js +2 -0
  2. package/dist/agent/task-lifecycle-reminder.d.ts +26 -0
  3. package/dist/agent/task-lifecycle-reminder.js +59 -0
  4. package/dist/agent.d.ts +26 -0
  5. package/dist/agent.js +39 -0
  6. package/dist/approval/controller.d.ts +3 -1
  7. package/dist/approval/controller.js +5 -2
  8. package/dist/approval/tool-helper.js +10 -1
  9. package/dist/approval/types.d.ts +15 -0
  10. package/dist/config.d.ts +6 -0
  11. package/dist/config.js +4 -0
  12. package/dist/loop/engine.d.ts +29 -0
  13. package/dist/loop/engine.js +63 -0
  14. package/dist/main.js +29 -0
  15. package/dist/orchestrator/default-hooks.js +16 -0
  16. package/dist/session-types.d.ts +1 -1
  17. package/dist/tasks/manager.d.ts +136 -0
  18. package/dist/tasks/manager.js +530 -0
  19. package/dist/tasks/process-utils.d.ts +10 -0
  20. package/dist/tasks/process-utils.js +49 -0
  21. package/dist/tasks/promotion.d.ts +17 -0
  22. package/dist/tasks/promotion.js +30 -0
  23. package/dist/tasks/wake.d.ts +53 -0
  24. package/dist/tasks/wake.js +125 -0
  25. package/dist/tools/bash.d.ts +15 -1
  26. package/dist/tools/bash.js +103 -3
  27. package/dist/tools/child-tools.js +3 -1
  28. package/dist/tools/edit.js +4 -12
  29. package/dist/tools/index.d.ts +13 -0
  30. package/dist/tools/index.js +8 -1
  31. package/dist/tools/server-manager.d.ts +6 -36
  32. package/dist/tools/server-manager.js +6 -234
  33. package/dist/tools/task-tools.d.ts +10 -0
  34. package/dist/tools/task-tools.js +129 -0
  35. package/dist/tools/write.js +6 -12
  36. package/dist/tui-ink/app.d.ts +20 -2
  37. package/dist/tui-ink/app.js +307 -19
  38. package/dist/tui-ink/approval/approval-dialog.js +4 -4
  39. package/dist/tui-ink/display-history.d.ts +1 -1
  40. package/dist/tui-ink/footer.d.ts +3 -3
  41. package/dist/tui-ink/footer.js +3 -3
  42. package/dist/tui-ink/input-box.d.ts +6 -0
  43. package/dist/tui-ink/markdown.d.ts +10 -2
  44. package/dist/tui-ink/markdown.js +13 -12
  45. package/dist/tui-ink/message-list.js +14 -3
  46. package/dist/tui-ink/run.d.ts +6 -0
  47. package/dist/tui-ink/run.js +1 -1
  48. package/dist/tui-ink/subagent-inspector.d.ts +5 -1
  49. package/dist/tui-ink/subagent-inspector.js +23 -7
  50. package/dist/tui-ink/welcome.js +4 -0
  51. package/package.json +1 -1
@@ -46,6 +46,9 @@ import { shouldContinueGoal, stopReasonNotice } from "../goal/engine.js";
46
46
  import { goalCompleteNotice, goalIndicatorLine, goalSummaryText } from "../goal/format.js";
47
47
  import { tokenUsageTotal } from "../goal/usage.js";
48
48
  import { formatInternalContextBlock } from "../agent/internal-reminder-sanitizer.js";
49
+ import { MAX_ACTIVE_LOOPS, decideLoopFiring, formatInterval, formatLoopList, parseLoopCommand, } from "../loop/engine.js";
50
+ import { TaskWakeCoalescer, findDanglingTaskStarts, formatTaskWakeSummary, isPidAlive, shouldFireTaskWake, taskEligibleForWake, } from "../tasks/wake.js";
51
+ const TASK_WAKE_DEBOUNCE_MS = 2000;
49
52
  import { collectUsageStatsBundle, formatStatsPanelBody, rangeLabel } from "../stats/usage.js";
50
53
  import { GrokRuntimeError } from "../external-runtime/grok-errors.js";
51
54
  import { GROK_LOCAL_SLASH_COMMANDS, classifyGrokInput, isGrokLocalSlashCommand, } from "../external-runtime/grok-input-policy.js";
@@ -76,6 +79,32 @@ function buildTips(agent, registry, grokActive = false) {
76
79
  tips.push("Type / for commands and skills");
77
80
  return tips;
78
81
  }
82
+ /**
83
+ * Slash-command results arrive as plain strings; recognize the /model
84
+ * confirmations by shape so they render as accent-colored UI notices — the
85
+ * same treatment the picker path applies — instead of assistant prose.
86
+ */
87
+ function slashResultNoticeKind(result) {
88
+ return /^(Grok m|M)odel switched to /.test(result) ? "ui_notice" : undefined;
89
+ }
90
+ /**
91
+ * True when a user message is nothing but harness-injected internal blocks
92
+ * (goal kicks, task wakes) — hidden live, so hidden on reconstruction too.
93
+ */
94
+ export function isInternalBlockOnlyContent(content) {
95
+ if (typeof content !== "string")
96
+ return false;
97
+ const trimmed = content.trim();
98
+ return /^<bubble_internal_(?:context|reminder)\b/.test(trimmed)
99
+ && /<\/bubble_internal_(?:context|reminder)>$/.test(trimmed);
100
+ }
101
+ /** Status-row summary for running background tasks (design §2.5). */
102
+ function taskRowSummary(tasks, nowTick) {
103
+ const first = tasks[0];
104
+ const label = first.description?.trim() || first.command.replace(/\s+/g, " ").slice(0, 32);
105
+ const elapsed = Math.max(0, Math.round((nowTick - first.startedAt) / 1000));
106
+ return `${label} ${elapsed}s`;
107
+ }
79
108
  function friendlyCwd(cwd) {
80
109
  const home = os.homedir();
81
110
  if (cwd === home)
@@ -95,7 +124,7 @@ function truncate(value, max) {
95
124
  return value;
96
125
  return `${value.slice(0, Math.max(0, max - 1))}…`;
97
126
  }
98
- function reconstructDisplayMessages(agentMessages) {
127
+ export function reconstructDisplayMessages(agentMessages) {
99
128
  const result = [];
100
129
  for (const m of agentMessages) {
101
130
  if (m.role === "system" || m.role === "tool")
@@ -103,6 +132,12 @@ function reconstructDisplayMessages(agentMessages) {
103
132
  if (m.role === "user") {
104
133
  if (m.isMeta)
105
134
  continue; // <system-reminder> injections are not user-visible
135
+ // Harness-initiated kicks (goal continuations, task wakes) persist as
136
+ // user-role messages wrapped in internal blocks so the model keeps
137
+ // them across resume — but they were hidden live and must stay hidden
138
+ // when the transcript is rebuilt.
139
+ if (isInternalBlockOnlyContent(m.content))
140
+ continue;
106
141
  result.push({
107
142
  key: nextDisplayMessageKey("user"),
108
143
  role: "user",
@@ -222,8 +257,12 @@ export const INK_LOCAL_SLASH_COMMANDS = [
222
257
  name: "goal",
223
258
  description: "Set/manage an autonomous goal (/goal <objective>|clear|pause|resume|edit)",
224
259
  },
260
+ {
261
+ name: "loop",
262
+ description: "Run a prompt on a recurring interval (/loop 5m <prompt>|list|stop [id])",
263
+ },
225
264
  ];
226
- export function App({ agent, args, sessionManager: initialSessionManager, switchSession, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, goalStore, bypassEnabled, updateNotice, updateNoticeRefresh, hookController, externalRuntime, onExit }) {
265
+ export function App({ agent, args, sessionManager: initialSessionManager, switchSession, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, goalStore, processManager, tasksAutoResume, promotionChannel, bypassEnabled, updateNotice, updateNoticeRefresh, hookController, externalRuntime, onExit }) {
227
266
  const [sessionManager, setSessionManager] = useState(initialSessionManager);
228
267
  const [externalRuntimeBinding, setExternalRuntimeBinding] = useState(() => initialSessionManager?.getMetadata().externalRuntime);
229
268
  const sessionManagerRef = useRef(sessionManager);
@@ -369,17 +408,23 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
369
408
  // 1Hz tick keeps the composer activity indicator animated while the agent is
370
409
  // running without churning renders at idle.
371
410
  const [nowTick, setNowTick] = useState(() => Date.now());
411
+ // Live background-task snapshot for the status row (design §2.5); refreshed
412
+ // by the manager subscription effect below.
413
+ const [taskSnapshot, setTaskSnapshot] = useState([]);
414
+ const runningTasks = useMemo(() => taskSnapshot.filter((task) => task.status === "running"), [taskSnapshot]);
372
415
  // Timestamp of when the current agent run started. Used only for the final
373
416
  // per-task duration summary.
374
417
  const runStartRef = useRef(null);
375
418
  // Mark the moment the run started; flips back to null in the finally block.
419
+ // Also ticks while background tasks run at idle so their elapsed counter
420
+ // keeps moving (design §2.5).
376
421
  useEffect(() => {
377
- if (!isRunning)
422
+ if (!isRunning && runningTasks.length === 0)
378
423
  return;
379
424
  setNowTick(Date.now());
380
425
  const t = setInterval(() => setNowTick(Date.now()), 1000);
381
426
  return () => clearInterval(t);
382
- }, [isRunning]);
427
+ }, [isRunning, runningTasks.length]);
383
428
  const userConfig = new UserConfig();
384
429
  const safeRegistry = registry ?? new ProviderRegistry(userConfig);
385
430
  const safeSkillRegistry = skillRegistry ?? new SkillRegistry({
@@ -592,6 +637,22 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
592
637
  setPickerMode("slash");
593
638
  return;
594
639
  }
640
+ // Ctrl+G: promote the running foreground bash command to a background
641
+ // task (design §2.5). A no-op when nothing promotable is running.
642
+ if (isCtrlLetterInput(input, key, "g") && !pickerMode && promotionChannel) {
643
+ const liveTools = [
644
+ ...streamingTools,
645
+ ...streamingParts.flatMap((part) => (part.type === "tools" ? part.toolCalls : [])),
646
+ ];
647
+ const candidate = [...liveTools].reverse().find((tool) => tool.name === "bash" && !tool.result && promotionChannel.hasPromotable(tool.id));
648
+ if (candidate) {
649
+ const taskId = promotionChannel.requestPromotion(candidate.id);
650
+ if (taskId) {
651
+ addMessage("assistant", `Moved to background: ${taskId} — the command keeps running (Ctrl+G).`, "ui_notice");
652
+ }
653
+ }
654
+ return;
655
+ }
595
656
  if (key.ctrl && key.shift && input.toLowerCase() === "m" && !pickerMode) {
596
657
  if (externalSessionBound) {
597
658
  addMessage("assistant", "MCP is unavailable in external runtime sessions (no Bubble workspace access).");
@@ -696,8 +757,8 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
696
757
  reprintTranscript();
697
758
  updateDisplayMessages(updater);
698
759
  }, [reprintTranscript, updateDisplayMessages]);
699
- const addMessage = useCallback((role, content) => {
700
- updateDisplayMessages((prev) => [...prev, withMessageKey({ role, content })]);
760
+ const addMessage = useCallback((role, content, syntheticKind) => {
761
+ updateDisplayMessages((prev) => [...prev, withMessageKey(syntheticKind ? { role, content, syntheticKind } : { role, content })]);
701
762
  }, [updateDisplayMessages]);
702
763
  // Reflow on terminal resize. ink 7.0.3 only clears its dynamic frame when the
703
764
  // terminal NARROWS (see its resized() handler); on widen / tmux split the
@@ -826,7 +887,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
826
887
  manager.appendMarker("thinking_level_switch", selection.reasoningEffort);
827
888
  refreshExternalRuntimeBinding(manager);
828
889
  }
829
- addMessage("assistant", `Grok model switched to ${selection.modelId ?? parsed.modelId}${selection.reasoningEffort !== "off" ? ` (${selection.reasoningEffort})` : ""}.`);
890
+ addMessage("assistant", `Grok model switched to ${selection.modelId ?? parsed.modelId}${selection.reasoningEffort !== "off" ? ` (${selection.reasoningEffort})` : ""}.`, "ui_notice");
830
891
  closePicker();
831
892
  };
832
893
  void run().catch((error) => {
@@ -895,6 +956,8 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
895
956
  clearComposerDraft();
896
957
  sessionManagerRef.current = result.manager;
897
958
  setSessionManager(result.manager);
959
+ // Keep ownerSessionId on newly spawned tasks/servers accurate (§2.2c).
960
+ agent.setSessionID(result.manager.getSessionFile());
898
961
  const nextExternalBinding = result.manager.getMetadata().externalRuntime;
899
962
  externalRuntimeBindingRef.current = nextExternalBinding;
900
963
  setExternalRuntimeBinding(nextExternalBinding);
@@ -1019,7 +1082,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1019
1082
  const effortNote = nextThinkingLevel && nextThinkingLevel !== "off"
1020
1083
  ? (isThinkingToggle || isThinkingOnly ? " in thinking mode" : ` with ${nextThinkingLevel} effort`)
1021
1084
  : "";
1022
- addMessage("assistant", `Model switched to ${displayModel(model)}${effortNote}.`);
1085
+ addMessage("assistant", `Model switched to ${displayModel(model)}${effortNote}.`, "ui_notice");
1023
1086
  closePicker();
1024
1087
  return nextThinkingLevel;
1025
1088
  };
@@ -1079,7 +1142,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1079
1142
  onExternalRuntimeChange: refreshExternalRuntimeBinding,
1080
1143
  });
1081
1144
  if (handled && result)
1082
- addMessage("assistant", result);
1145
+ addMessage("assistant", result, slashResultNoticeKind(result));
1083
1146
  }, [
1084
1147
  addMessage,
1085
1148
  agent,
@@ -1224,6 +1287,9 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1224
1287
  closePicker();
1225
1288
  });
1226
1289
  }, [addMessage, agent, closePicker, createProvider, keyProviderId, safeRegistry, transitionToNative]);
1290
+ // /loop handler lives below handleSubmit (it needs addMessage only); the
1291
+ // ref breaks the declaration cycle so handleSubmit can route to it.
1292
+ const handleLoopCommandRef = useRef(null);
1227
1293
  const handleSubmit = useCallback(async (payload) => {
1228
1294
  const initialPayload = typeof payload === "string" ? { text: payload, images: [] } : payload;
1229
1295
  const input = initialPayload.text;
@@ -1904,6 +1970,13 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1904
1970
  }
1905
1971
  }
1906
1972
  };
1973
+ // Background-task wake (design §2.3b): an internal continuation, not a
1974
+ // user message — hidden from the transcript (the ✦ notice row is the
1975
+ // user-facing record) and wrapped as internal context for the model.
1976
+ if (initialPayload.internal === "task_wake") {
1977
+ await runAgentInput(input, "", [], { hidden: true });
1978
+ return;
1979
+ }
1907
1980
  // Slash commands and skill invocations drop any attached images —
1908
1981
  // they're meant for pure command routing.
1909
1982
  if (input.trimStart().startsWith("/")) {
@@ -1922,6 +1995,10 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1922
1995
  await handleGoalCommand(input);
1923
1996
  return;
1924
1997
  }
1998
+ if (/^\/loop(?:\s|$)/.test(input.trim())) {
1999
+ handleLoopCommandRef.current?.(input);
2000
+ return;
2001
+ }
1925
2002
  const skillInvocation = parseSkillInvocation(input, safeSkillRegistry);
1926
2003
  if (skillInvocation) {
1927
2004
  await runAgentInput(skillInvocation.actualPrompt, displayInput);
@@ -1995,7 +2072,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1995
2072
  ]);
1996
2073
  }
1997
2074
  else {
1998
- addMessage("assistant", result);
2075
+ addMessage("assistant", result, slashResultNoticeKind(result));
1999
2076
  }
2000
2077
  }
2001
2078
  if (inject) {
@@ -2037,6 +2114,199 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
2037
2114
  }
2038
2115
  }
2039
2116
  }, [addMessage, agent, args.cwd, openPicker, openSessionPicker, openRewindPicker, openStatsPanel, createProvider, currentSessionFile, externalRuntime, externalRuntimeBinding, externalSessionBound, fillComposer, grokSessionBound, prepareSubmitDisplay, refreshExternalRuntimeBinding, safeRegistry, safeSkillRegistry, startFreshSession, transitionToNative, unsupportedExternalSessionBound, updateDisplayMessages, queueInput, submitSteer, requestExit, setStartingSubmit]);
2117
+ // --- Background tasks: markers, notices, auto-resume (design §2.3b) ---
2118
+ // Owner SessionManager captured at spawn so markers always land in the
2119
+ // owner session's jsonl, regardless of what is bound when the task ends.
2120
+ const taskOwnerSessionsRef = useRef(new Map());
2121
+ // Completions for non-current sessions, held until switch-back (§2.2c).
2122
+ const pendingTaskCompletionsRef = useRef(new Map());
2123
+ const taskWakeCoalescerRef = useRef(null);
2124
+ const announceTaskCompletion = useCallback((task) => {
2125
+ const elapsed = Math.round(((task.endedAt ?? Date.now()) - task.startedAt) / 1000);
2126
+ addMessage("assistant", `Background task ${task.id}${task.description ? ` (${task.description})` : ""} ${task.status}${task.exitCode != null ? ` — exit ${task.exitCode}` : ""} in ${elapsed}s.`, "ui_notice");
2127
+ }, [addMessage]);
2128
+ const fireTaskWake = useCallback((tasks) => {
2129
+ if (!processManager)
2130
+ return;
2131
+ // Gate at FIRE time (after the debounce): a turn that started meanwhile
2132
+ // or queued user input suppresses the wake — the state-change reminder
2133
+ // carries the completion into the next turn instead.
2134
+ if (!shouldFireTaskWake({
2135
+ autoResume: tasksAutoResume !== false,
2136
+ turnRunning: !!activeAbortRef.current,
2137
+ queuedInputs: queuedInputsRef.current.length,
2138
+ exiting: exitRequestedRef.current,
2139
+ })) {
2140
+ return;
2141
+ }
2142
+ const summary = formatTaskWakeSummary(tasks, (id) => processManager.taskOutputTail(id, 2000));
2143
+ for (const task of tasks)
2144
+ processManager.markTaskDelivered(task.id);
2145
+ void handleSubmit({
2146
+ text: formatInternalContextBlock("task-finished", summary),
2147
+ images: [],
2148
+ internal: "task_wake",
2149
+ });
2150
+ }, [handleSubmit, processManager, tasksAutoResume]);
2151
+ const refreshTaskSnapshot = useCallback(() => {
2152
+ if (!processManager)
2153
+ return;
2154
+ setTaskSnapshot(processManager.listTasks(sessionManagerRef.current?.getSessionFile()));
2155
+ }, [processManager]);
2156
+ useEffect(() => {
2157
+ refreshTaskSnapshot();
2158
+ }, [refreshTaskSnapshot, sessionManager]);
2159
+ useEffect(() => {
2160
+ if (!processManager)
2161
+ return;
2162
+ taskWakeCoalescerRef.current = new TaskWakeCoalescer(TASK_WAKE_DEBOUNCE_MS, fireTaskWake);
2163
+ const unsubSnapshot = processManager.onChange(() => refreshTaskSnapshot());
2164
+ const unsubChange = processManager.onChange((task) => {
2165
+ if (task.status !== "running" || taskOwnerSessionsRef.current.has(task.id))
2166
+ return;
2167
+ const owner = sessionManagerRef.current;
2168
+ if (!owner || task.ownerSessionId !== owner.getSessionFile())
2169
+ return;
2170
+ taskOwnerSessionsRef.current.set(task.id, owner);
2171
+ owner.appendMarker("task_started", JSON.stringify({
2172
+ id: task.id,
2173
+ pid: task.pid,
2174
+ startedAt: task.startedAt,
2175
+ command: task.command,
2176
+ description: task.description,
2177
+ }));
2178
+ });
2179
+ const unsubFinish = processManager.onTaskFinished((task) => {
2180
+ taskOwnerSessionsRef.current.get(task.id)?.appendMarker(task.status === "killed" ? "task_killed" : "task_finished", JSON.stringify({ id: task.id, status: task.status, exitCode: task.exitCode ?? null, endedAt: task.endedAt }));
2181
+ const currentFile = sessionManagerRef.current?.getSessionFile();
2182
+ if (task.ownerSessionId !== currentFile) {
2183
+ if (taskEligibleForWake(task) && task.ownerSessionId) {
2184
+ const held = pendingTaskCompletionsRef.current.get(task.ownerSessionId) ?? [];
2185
+ held.push(task);
2186
+ pendingTaskCompletionsRef.current.set(task.ownerSessionId, held);
2187
+ }
2188
+ return;
2189
+ }
2190
+ announceTaskCompletion(task);
2191
+ if (taskEligibleForWake(task))
2192
+ taskWakeCoalescerRef.current?.add(task);
2193
+ });
2194
+ return () => {
2195
+ unsubSnapshot();
2196
+ unsubChange();
2197
+ unsubFinish();
2198
+ taskWakeCoalescerRef.current?.cancel();
2199
+ taskWakeCoalescerRef.current = null;
2200
+ };
2201
+ }, [announceTaskCompletion, fireTaskWake, processManager, refreshTaskSnapshot]);
2202
+ // Switch-back sweep: completions held for this session fire now (§2.2c).
2203
+ useEffect(() => {
2204
+ if (!processManager)
2205
+ return;
2206
+ const file = sessionManager?.getSessionFile();
2207
+ if (!file)
2208
+ return;
2209
+ const held = pendingTaskCompletionsRef.current.get(file);
2210
+ if (!held?.length)
2211
+ return;
2212
+ pendingTaskCompletionsRef.current.delete(file);
2213
+ for (const task of held) {
2214
+ announceTaskCompletion(task);
2215
+ taskWakeCoalescerRef.current?.add(task);
2216
+ }
2217
+ }, [announceTaskCompletion, processManager, sessionManager]);
2218
+ // Resume-time orphan report: dangling task_started markers from a previous
2219
+ // process, probed for liveness (§2.2c). Runs once per mount.
2220
+ const orphanProbeDoneRef = useRef(false);
2221
+ useEffect(() => {
2222
+ if (!processManager || orphanProbeDoneRef.current)
2223
+ return;
2224
+ orphanProbeDoneRef.current = true;
2225
+ const manager = sessionManagerRef.current;
2226
+ if (!manager)
2227
+ return;
2228
+ let entries;
2229
+ try {
2230
+ entries = manager.getEntries();
2231
+ }
2232
+ catch {
2233
+ return;
2234
+ }
2235
+ for (const orphan of findDanglingTaskStarts(entries)) {
2236
+ const alive = isPidAlive(orphan.pid);
2237
+ addMessage("assistant", alive
2238
+ ? `Background task ${orphan.id}${orphan.description ? ` (${orphan.description})` : ""} from a previous process is still running (pid ${orphan.pid}). Kill it manually if unwanted: kill ${orphan.pid}`
2239
+ : `Background task ${orphan.id}${orphan.description ? ` (${orphan.description})` : ""} was orphaned by a previous process and is no longer running.`, "ui_notice");
2240
+ }
2241
+ }, [addMessage, processManager]);
2242
+ // --- /loop: recurring prompts (design §2.6) ---
2243
+ const loopsRef = useRef([]);
2244
+ const nextLoopIdRef = useRef(1);
2245
+ const handleSubmitRef = useRef(handleSubmit);
2246
+ handleSubmitRef.current = handleSubmit;
2247
+ const handleLoopCommand = useCallback((raw) => {
2248
+ const parsed = parseLoopCommand(raw);
2249
+ if (parsed.action === "help" || parsed.error) {
2250
+ addMessage("assistant", parsed.error ?? "Usage: /loop <interval> <prompt> (e.g. /loop 5m check CI status). Also: /loop list, /loop stop [id].");
2251
+ return;
2252
+ }
2253
+ if (parsed.action === "list") {
2254
+ addMessage("assistant", formatLoopList(loopsRef.current, Date.now()));
2255
+ return;
2256
+ }
2257
+ if (parsed.action === "stop") {
2258
+ if (parsed.stopId === undefined) {
2259
+ const count = loopsRef.current.length;
2260
+ loopsRef.current = [];
2261
+ addMessage("assistant", count > 0 ? `Stopped ${count} loop${count === 1 ? "" : "s"}.` : "No active loops.");
2262
+ return;
2263
+ }
2264
+ const before = loopsRef.current.length;
2265
+ loopsRef.current = loopsRef.current.filter((loop) => loop.id !== parsed.stopId);
2266
+ addMessage("assistant", loopsRef.current.length < before ? `Stopped loop #${parsed.stopId}.` : `No loop #${parsed.stopId}.`);
2267
+ return;
2268
+ }
2269
+ if (loopsRef.current.length >= MAX_ACTIVE_LOOPS) {
2270
+ addMessage("assistant", `Loop limit reached (${MAX_ACTIVE_LOOPS}). Stop one with /loop stop [id] first.`);
2271
+ return;
2272
+ }
2273
+ const loop = {
2274
+ id: nextLoopIdRef.current++,
2275
+ prompt: parsed.prompt,
2276
+ intervalMs: parsed.intervalMs,
2277
+ // Fires immediately on creation (next idle tick), then repeats.
2278
+ nextFireAt: Date.now(),
2279
+ fires: 0,
2280
+ };
2281
+ loopsRef.current = [...loopsRef.current, loop];
2282
+ addMessage("assistant", `Loop #${loop.id} set: every ${formatInterval(loop.intervalMs)} — ${loop.prompt}\nSession-scoped; /loop stop ${loop.id} to cancel.`, "ui_notice");
2283
+ }, [addMessage]);
2284
+ handleLoopCommandRef.current = handleLoopCommand;
2285
+ useEffect(() => {
2286
+ const timer = setInterval(() => {
2287
+ if (loopsRef.current.length === 0 || exitRequestedRef.current)
2288
+ return;
2289
+ const now = Date.now();
2290
+ for (const loop of loopsRef.current) {
2291
+ const decision = decideLoopFiring(loop, now, !!activeAbortRef.current);
2292
+ if (decision === "wait")
2293
+ continue;
2294
+ loop.nextFireAt = now + loop.intervalMs;
2295
+ if (decision === "defer") {
2296
+ // Defer-not-stack (design §2.6): the previous turn is still running.
2297
+ addMessage("assistant", `Loop #${loop.id} skipped this interval (a turn is still running); next in ${formatInterval(loop.intervalMs)}.`, "ui_notice");
2298
+ continue;
2299
+ }
2300
+ loop.fires += 1;
2301
+ void handleSubmitRef.current({
2302
+ text: loop.prompt,
2303
+ displayText: `⟳ loop #${loop.id}: ${loop.prompt}`,
2304
+ images: [],
2305
+ });
2306
+ }
2307
+ }, 1000);
2308
+ return () => clearInterval(timer);
2309
+ }, [addMessage]);
2040
2310
  // Drain the queue once the run ends and no modal needs the user first.
2041
2311
  // The placeholder row is removed right before resubmitting — handleSubmit
2042
2312
  // renders the message again as a regular user row.
@@ -2097,13 +2367,22 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
2097
2367
  const showThinkingLabel = Boolean(thinkingLevel)
2098
2368
  && thinkingLevel !== "off"
2099
2369
  && (isThinkingToggle || isThinkingOnly || availableThinkingLevels.length > 2);
2100
- const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, tips: buildTips(agent, safeRegistry, grokSessionBound), updateNotice: currentUpdateNotice, cwd: friendlyCwd(args.cwd), sessionLabel: sessionBasename(currentSessionFile()), providerId: grokSessionBound ? "grok" : externalSessionBound ? undefined : agent.providerId || safeRegistry.getDefault()?.id, modelId: grokSessionBound ? externalRuntimeBinding?.modelId : externalSessionBound ? undefined : agent.apiModel, modelLabel: externalSessionBound
2101
- ? (grokSessionBound
2102
- ? `Grok Subscription${externalRuntimeBinding?.modelId ? ` · ${externalRuntimeBinding.modelId}` : ""}`
2103
- : "Unsupported external runtime · recovery-only mode")
2104
- : agent.model ? displayModel(agent.model) : undefined, thinkingLabel: grokSessionBound
2105
- ? externalRuntimeBinding?.reasoningEffort
2106
- : !externalSessionBound && showThinkingLabel ? thinkingLevel : undefined })) : null;
2370
+ // The banner's model row stays live until the first settled row commits it
2371
+ // into <Static> scrollback (so a /model switch on the fresh launch screen
2372
+ // updates in place); after that the accent-colored transcript notices carry
2373
+ // model changes. External runtimes surface theirs via the footer runtimeLabel.
2374
+ const bannerTips = buildTips(agent, safeRegistry, grokSessionBound);
2375
+ const bannerProviderId = grokSessionBound ? "grok" : externalSessionBound ? undefined : agent.providerId || safeRegistry.getDefault()?.id;
2376
+ const bannerModelId = grokSessionBound ? externalRuntimeBinding?.modelId : externalSessionBound ? undefined : agent.apiModel;
2377
+ const bannerModelLabel = externalSessionBound
2378
+ ? (grokSessionBound
2379
+ ? `Grok Subscription${externalRuntimeBinding?.modelId ? ` · ${externalRuntimeBinding.modelId}` : ""}`
2380
+ : "Unsupported external runtime · recovery-only mode")
2381
+ : agent.model ? displayModel(agent.model) : undefined;
2382
+ const bannerThinkingLabel = grokSessionBound
2383
+ ? externalRuntimeBinding?.reasoningEffort
2384
+ : !externalSessionBound && showThinkingLabel ? thinkingLevel : undefined;
2385
+ const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, tips: bannerTips, updateNotice: currentUpdateNotice, cwd: friendlyCwd(args.cwd), sessionLabel: sessionBasename(currentSessionFile()), providerId: bannerProviderId, modelId: bannerModelId, modelLabel: bannerModelLabel, thinkingLabel: bannerThinkingLabel })) : null;
2107
2386
  const commandPaletteItems = useMemo(() => buildCommandPaletteItems(safeSkillRegistry, externalSessionBound), [externalSessionBound, safeSkillRegistry]);
2108
2387
  const mcpReconnectItems = useMemo(() => buildMcpReconnectItems(mcpManager), [mcpManager]);
2109
2388
  // No fixed-height frame: settled rows flow into the terminal's native
@@ -2164,7 +2443,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
2164
2443
  }, onCancel: closePicker }) })), pickerMode === "mcp-reconnect" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(McpReconnectPicker, { items: mcpReconnectItems, terminalColumns: mainWidth, terminalRows: terminalRows, onSelect: (item) => {
2165
2444
  closePicker();
2166
2445
  void handleSubmit(item.command);
2167
- }, onCancel: closePicker }) })), pickerMode === "session" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SessionPicker, { currentCwd: args.cwd, currentSessions: SessionManager.summarizeSessionsForCwd(args.cwd), allSessions: SessionManager.listAllSessions(), onSelect: handleSessionSelect, onCancel: closePicker }) })), pickerMode === "agents" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SubagentInspector, { groups: subagentGroups, onCancel: closePicker }) })), pickerMode === "rewind" && sessionManager && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(RewindPicker, { sessionManager: sessionManager, terminalColumns: mainWidth, terminalRows: terminalRows, onSelect: (command) => {
2446
+ }, onCancel: closePicker }) })), pickerMode === "session" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SessionPicker, { currentCwd: args.cwd, currentSessions: SessionManager.summarizeSessionsForCwd(args.cwd), allSessions: SessionManager.listAllSessions(), onSelect: handleSessionSelect, onCancel: closePicker }) })), pickerMode === "agents" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SubagentInspector, { groups: subagentGroups, onCancel: closePicker, tasks: taskSnapshot, onKillTask: (taskId) => { void processManager?.killTask(taskId); } }) })), pickerMode === "rewind" && sessionManager && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(RewindPicker, { sessionManager: sessionManager, terminalColumns: mainWidth, terminalRows: terminalRows, onSelect: (command) => {
2168
2447
  closePicker();
2169
2448
  void handleSubmit(command);
2170
2449
  }, onCancel: closePicker }) })), pickerMode === "feishu-setup" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(FeishuSetupPicker, { onComplete: (summary) => {
@@ -2213,7 +2492,16 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
2213
2492
  : 0, flexShrink: 0, backgroundColor: palette.background, children: _jsx(WaitingIndicator, { tools: streamingTools, hasStreamingText: streamingContent.length > 0, hasStreamingReasoning: streamingReasoning.length > 0, streamedChars: streamingContent.length + streamingReasoning.length, nowTick: nowTick, pendingSteerCount: pendingSteerCount, queuedCount: queuedCount }) })), !isExiting && !pickerMode && !statsPanel && (_jsx(Box, { paddingBottom: 1, flexShrink: 0, backgroundColor: palette.background, children: _jsx(InputBox, { onSubmit: handleSubmit, onQueue: isRunning ? queueInput : undefined, onArrowDownAtBottom: () => {
2214
2493
  if (subagentMembers.length > 0 && !pickerMode)
2215
2494
  setSubagentEntryFocused(true);
2216
- }, disabled: !!pendingPlan || !!pendingApproval || !!pendingQuestion || !!pendingFeedback || !!statsPanel || subagentEntryFocused, cursorResetEpoch: cursorResetEpoch, draftText: composerDraft?.text, draftEpoch: composerDraft?.epoch, onDraftApplied: clearComposerDraft, skillRegistry: externalSessionBound ? undefined : safeSkillRegistry, localSlashCommands: externalSessionBound ? [] : [...INK_LOCAL_SLASH_COMMANDS], allowedSlashCommands: unsupportedExternalSessionBound ? GROK_LOCAL_SLASH_COMMANDS : undefined, allowWorkspaceMentions: !unsupportedExternalSessionBound, allowImageAttachments: !externalSessionBound, terminalColumns: mainWidth, cwd: args.cwd, sessionFile: currentSessionFile(), nextImageLabelStart: nextImageDisplayLabelStartRef.current }) })), !isExiting && !pickerMode && !statsPanel && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && subagentMembers.length > 0 && (_jsxs(Box, { paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: subagentEntryFocused, color: subagentEntryFocused ? palette.accent : palette.toolName, children: subagentEntryFocused ? "> ↳ " : " ↳ " }), _jsxs(Text, { color: subagentEntryFocused ? palette.accent : palette.muted, children: [subagentMembers.length, " subagent", subagentMembers.length === 1 ? "" : "s", " \u00B7 ", subagentSummary(subagentMembers), " \u00B7 "] }), _jsx(Text, { color: palette.accent, children: subagentEntryFocused ? "Enter open · Esc back" : "↓ to inspect traces" })] })), !isExiting && (_jsx(Box, { flexShrink: 0, children: _jsx(FooterBar, { data: buildFooterData({
2495
+ }, disabled: !!pendingPlan || !!pendingApproval || !!pendingQuestion || !!pendingFeedback || !!statsPanel || subagentEntryFocused, cursorResetEpoch: cursorResetEpoch, draftText: composerDraft?.text, draftEpoch: composerDraft?.epoch, onDraftApplied: clearComposerDraft, skillRegistry: externalSessionBound ? undefined : safeSkillRegistry, localSlashCommands: externalSessionBound ? [] : [...INK_LOCAL_SLASH_COMMANDS], allowedSlashCommands: unsupportedExternalSessionBound ? GROK_LOCAL_SLASH_COMMANDS : undefined, allowWorkspaceMentions: !unsupportedExternalSessionBound, allowImageAttachments: !externalSessionBound, terminalColumns: mainWidth, cwd: args.cwd, sessionFile: currentSessionFile(), nextImageLabelStart: nextImageDisplayLabelStartRef.current }) })), !isExiting && !pickerMode && !statsPanel && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && (subagentMembers.length > 0 || runningTasks.length > 0) && (_jsxs(Box, { paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: subagentEntryFocused, color: subagentEntryFocused ? palette.accent : palette.toolName, children: subagentEntryFocused ? "> ↳ " : " ↳ " }), _jsxs(Text, { color: subagentEntryFocused ? palette.accent : palette.muted, children: [[
2496
+ subagentMembers.length > 0
2497
+ ? `${subagentMembers.length} subagent${subagentMembers.length === 1 ? "" : "s"} · ${subagentSummary(subagentMembers)}`
2498
+ : undefined,
2499
+ runningTasks.length > 0
2500
+ ? `${runningTasks.length} task${runningTasks.length === 1 ? "" : "s"} · ${taskRowSummary(runningTasks, nowTick)}`
2501
+ : undefined,
2502
+ ].filter(Boolean).join(" · "), " \u00B7 "] }), subagentMembers.length > 0
2503
+ ? _jsx(Text, { color: palette.accent, children: subagentEntryFocused ? "Enter open · Esc back" : "↓ to inspect traces" })
2504
+ : _jsx(Text, { color: palette.accent, children: "task_output to check \u00B7 kill_task to stop" })] })), !isExiting && (_jsx(Box, { flexShrink: 0, children: _jsx(FooterBar, { data: buildFooterData({
2217
2505
  mode: permissionMode,
2218
2506
  goalLine: externalSessionBound ? undefined : goalLine,
2219
2507
  runtimeLabel: externalSessionBound
@@ -29,7 +29,7 @@ export function ApprovalDialog({ request, onDecision, onAllowBashPrefix, }) {
29
29
  const onCancel = () => onDecision({ action: "reject" });
30
30
  const title = dialogTitle(request);
31
31
  const question = dialogQuestion(request);
32
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, marginY: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: title }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(RequestPreview, { request: request }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: question }) }), _jsx(Box, { marginTop: 1, children: _jsx(ApprovalSelect, { options: options, onSubmit: onSubmit, onCancel: onCancel, hint: "\u2191\u2193 choose \u00B7 Enter select \u00B7 Tab add feedback \u00B7 Esc reject" }) })] }));
32
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, marginY: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: title }), (request.type === "edit" || request.type === "write") && request.outsideWorkspace && (_jsx(Text, { color: theme.warning, bold: true, children: "\u26A0 This path is outside the workspace" })), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(RequestPreview, { request: request }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: question }) }), _jsx(Box, { marginTop: 1, children: _jsx(ApprovalSelect, { options: options, onSubmit: onSubmit, onCancel: onCancel, hint: "\u2191\u2193 choose \u00B7 Enter select \u00B7 Tab add feedback \u00B7 Esc reject" }) })] }));
33
33
  }
34
34
  function buildOptions(request) {
35
35
  if (request.type === "bash") {
@@ -108,7 +108,7 @@ function basename(p) {
108
108
  function RequestPreview({ request }) {
109
109
  switch (request.type) {
110
110
  case "bash":
111
- return _jsx(BashPreview, { command: request.command, cwd: request.cwd });
111
+ return _jsx(BashPreview, { command: request.command, cwd: request.cwd, background: request.background });
112
112
  case "edit":
113
113
  return _jsx(DiffView, { diff: request.diff });
114
114
  case "patch":
@@ -155,10 +155,10 @@ function AgentProfilePreview({ path, promptPreview }) {
155
155
  const theme = useTheme();
156
156
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.muted, children: compressHome(path) }), _jsx(Text, { children: promptPreview }), _jsx(Text, { color: theme.warning, children: "This prompt comes from the repository's .bubble/agents and will drive a subagent." })] }));
157
157
  }
158
- function BashPreview({ command, cwd }) {
158
+ function BashPreview({ command, cwd, background }) {
159
159
  const theme = useTheme();
160
160
  const danger = classifyBashDanger(command);
161
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "$ " }), _jsx(Text, { children: command })] }), _jsxs(Text, { color: theme.muted, children: ["cwd: ", compressHome(cwd)] }), danger && (_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: theme.warning, bold: true, children: ["\u26A0 ", danger.pattern, ":"] }), _jsxs(Text, { color: theme.warning, children: [" ", danger.message] })] }))] }));
161
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "$ " }), _jsx(Text, { children: command })] }), _jsxs(Text, { color: theme.muted, children: ["cwd: ", compressHome(cwd)] }), background && (_jsx(Text, { color: theme.warning, children: "runs in background \u2014 keeps running after this turn ends" })), danger && (_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: theme.warning, bold: true, children: ["\u26A0 ", danger.pattern, ":"] }), _jsxs(Text, { color: theme.warning, children: [" ", danger.message] })] }))] }));
162
162
  }
163
163
  const MAX_WRITE_PREVIEW_LINES = 20;
164
164
  function WritePreview({ path, content }) {
@@ -12,7 +12,7 @@ export interface DisplayMessage {
12
12
  reasoning?: string;
13
13
  toolCalls?: DisplayToolCall[];
14
14
  parts?: DisplayMessagePart[];
15
- syntheticKind?: "ui_summary" | "ui_compact_summary" | "ui_interrupt";
15
+ syntheticKind?: "ui_summary" | "ui_compact_summary" | "ui_interrupt" | "ui_notice";
16
16
  /** Markdown body shown inside a `ui_compact_summary` card. */
17
17
  compactionSummary?: string;
18
18
  hiddenCount?: number;
@@ -5,9 +5,9 @@ export interface FooterData {
5
5
  runtimeLabel?: string;
6
6
  }
7
7
  /**
8
- * Bottom status line. Path / provider / model moved into the welcome banner;
9
- * the footer only surfaces the permission-mode badge, so it renders nothing
10
- * (zero rows) in the default mode.
8
+ * Bottom status line. Path / provider / model live in the welcome banner and
9
+ * the model-switch transcript notices; the footer only surfaces the
10
+ * permission-mode badge, so it renders nothing (zero rows) in the default mode.
11
11
  */
12
12
  export declare function FooterBar({ data }: {
13
13
  data: FooterData;
@@ -3,9 +3,9 @@ import { Box, Text } from "ink";
3
3
  import { useTheme } from "./theme.js";
4
4
  import { PERMISSION_MODE_INFO } from "../permission/mode.js";
5
5
  /**
6
- * Bottom status line. Path / provider / model moved into the welcome banner;
7
- * the footer only surfaces the permission-mode badge, so it renders nothing
8
- * (zero rows) in the default mode.
6
+ * Bottom status line. Path / provider / model live in the welcome banner and
7
+ * the model-switch transcript notices; the footer only surfaces the
8
+ * permission-mode badge, so it renders nothing (zero rows) in the default mode.
9
9
  */
10
10
  export function FooterBar({ data }) {
11
11
  const showMode = !!data.mode && data.mode !== "default";
@@ -11,6 +11,12 @@ export interface SubmitPayload {
11
11
  images: ImageAttachment[];
12
12
  /** First UI-only [Image #N] label reserved for this submitted payload. */
13
13
  imageDisplayStart?: number;
14
+ /**
15
+ * Harness-initiated submission (background-tasks design §2.3b): runs
16
+ * hidden with the text wrapped as internal context, never rendered as a
17
+ * user row. Only the TUI wake path sets this.
18
+ */
19
+ internal?: "task_wake";
14
20
  }
15
21
  interface InputBoxProps {
16
22
  onSubmit: (payload: SubmitPayload) => void;
@@ -79,17 +79,25 @@ export declare function wrapInlineSegments(segments: MarkdownInlineSegment[], ma
79
79
  * reset handles the rare case where `content` is replaced wholesale (e.g.,
80
80
  * the user re-enters a turn).
81
81
  */
82
- export declare function StreamingMarkdown({ content, maxWidth, }: {
82
+ export declare function StreamingMarkdown({ content, maxWidth, flushTop, }: {
83
83
  content: string;
84
84
  maxWidth?: number;
85
+ /** See MarkdownContent: applies to the true document start only. */
86
+ flushTop?: boolean;
85
87
  }): import("react/jsx-runtime").JSX.Element;
86
88
  export declare function splitListItem(line: string): {
87
89
  prefix: string;
88
90
  content: string;
89
91
  indent: number;
90
92
  } | null;
91
- export declare function MarkdownContent({ content, maxWidth, }: {
93
+ export declare function MarkdownContent({ content, maxWidth, flushTop, }: {
92
94
  content: string;
93
95
  maxWidth?: number;
96
+ /**
97
+ * Suppress the FIRST block's top margin. Set when this document renders
98
+ * beside an inline marker (the timeline "● " gutter): a leading heading /
99
+ * code block / table would otherwise start one row below the marker.
100
+ */
101
+ flushTop?: boolean;
94
102
  }): import("react/jsx-runtime").JSX.Element;
95
103
  export {};