u-foo 3.0.3 → 3.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "3.0.3",
3
+ "version": "3.0.5",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
@@ -232,6 +232,7 @@ function renderPlanModeContext(executionState = null) {
232
232
  " 2) expand_node on the current ready/waiting task when needed",
233
233
  " 3) Runtime executes ready tools / TaskLoops; continue from results",
234
234
  " 4) control.complete_task (inline) or control.start_task (task_loop)",
235
+ "Do not end the turn with text only while a task is waiting — advance it.",
235
236
  "Do not mix plan_graph with data-plane tools in the same turn.",
236
237
  );
237
238
 
@@ -204,6 +204,168 @@ function buildCompactSummary(rows = [], focusId = "") {
204
204
  .join(" · ");
205
205
  }
206
206
 
207
+ /**
208
+ * Top-level plan tasks from planGraph JSON (no parent, not generated tools).
209
+ */
210
+ function listTopLevelPlanTasks(planGraph = {}) {
211
+ const nodes = Array.isArray(planGraph.nodes) ? planGraph.nodes : [];
212
+ return nodes.filter((node) => (
213
+ node
214
+ && node.type === "task"
215
+ && !String(node.parentTaskId || "").trim()
216
+ && !node.generated
217
+ ));
218
+ }
219
+
220
+ /**
221
+ * Parse planGraph JSON into a DAG IR: nodes, edges, dependency waves.
222
+ */
223
+ function buildPlanDag(planGraph = {}) {
224
+ const tasks = listTopLevelPlanTasks(planGraph);
225
+ const idSet = new Set(tasks.map((node) => String(node.id || "").trim()).filter(Boolean));
226
+ const nodes = tasks.map((task) => {
227
+ const id = String(task.id || "").trim();
228
+ const deps = (Array.isArray(task.dependsOn) ? task.dependsOn : [])
229
+ .map((dep) => String(dep || "").trim())
230
+ .filter((dep) => dep && idSet.has(dep));
231
+ const { mark, kind } = statusToMark(task.status);
232
+ return {
233
+ id,
234
+ title: nodeTitle(task),
235
+ status: String(task.status || "pending"),
236
+ mark,
237
+ kind,
238
+ dependsOn: deps,
239
+ displayOrder: Number(task.displayOrder) || 0,
240
+ };
241
+ }).filter((node) => node.id);
242
+
243
+ const byId = new Map(nodes.map((node) => [node.id, node]));
244
+ const edges = [];
245
+ for (const node of nodes) {
246
+ for (const dep of node.dependsOn) {
247
+ edges.push({ from: dep, to: node.id });
248
+ }
249
+ }
250
+
251
+ const depthMemo = new Map();
252
+ function depthOf(id, stack = new Set()) {
253
+ if (depthMemo.has(id)) return depthMemo.get(id);
254
+ if (stack.has(id)) return 0;
255
+ stack.add(id);
256
+ const node = byId.get(id);
257
+ let depth = 0;
258
+ if (node) {
259
+ for (const dep of node.dependsOn) {
260
+ depth = Math.max(depth, depthOf(dep, stack) + 1);
261
+ }
262
+ }
263
+ stack.delete(id);
264
+ depthMemo.set(id, depth);
265
+ return depth;
266
+ }
267
+
268
+ for (const node of nodes) depthOf(node.id);
269
+
270
+ const maxDepth = nodes.reduce((max, node) => Math.max(max, depthMemo.get(node.id) || 0), 0);
271
+ const buckets = Array.from({ length: maxDepth + 1 }, () => []);
272
+ const ordered = nodes.slice().sort((a, b) => {
273
+ const depthDiff = (depthMemo.get(a.id) || 0) - (depthMemo.get(b.id) || 0);
274
+ if (depthDiff !== 0) return depthDiff;
275
+ if (a.displayOrder !== b.displayOrder) return a.displayOrder - b.displayOrder;
276
+ return a.id.localeCompare(b.id);
277
+ });
278
+ for (const node of ordered) {
279
+ buckets[depthMemo.get(node.id) || 0].push(node);
280
+ }
281
+ const waves = buckets.filter((wave) => wave.length > 0);
282
+ return {
283
+ nodes,
284
+ edges,
285
+ waves,
286
+ linear: waves.length > 0 && waves.every((wave) => wave.length === 1),
287
+ };
288
+ }
289
+
290
+ function waveStepLabel(waveIndex = 0, nodeIndex = 0, waveSize = 1) {
291
+ const step = Math.max(1, Math.floor(Number(waveIndex) || 0) + 1);
292
+ if (waveSize <= 1) return String(step);
293
+ const letter = String.fromCharCode(97 + Math.max(0, Math.min(25, Math.floor(Number(nodeIndex) || 0))));
294
+ return `${step}${letter}`;
295
+ }
296
+
297
+ function formatParallelWaveLines(wave = [], waveIndex = 0, titleMax = 40) {
298
+ const lines = [];
299
+ const size = wave.length;
300
+ wave.forEach((node, nodeIndex) => {
301
+ const label = waveStepLabel(waveIndex, nodeIndex, size);
302
+ const body = `${label} ${node.mark} ${truncate(node.title, titleMax)}`;
303
+ if (nodeIndex === 0) {
304
+ lines.push(` ┌─ ${body}`);
305
+ if (size > 1) lines.push("──┤");
306
+ return;
307
+ }
308
+ if (nodeIndex === size - 1) {
309
+ lines.push(` └─ ${body}`);
310
+ return;
311
+ }
312
+ lines.push(` ├─ ${body}`);
313
+ });
314
+ return lines;
315
+ }
316
+
317
+ /**
318
+ * Build markdown (linear list) or ASCII flowchart (parallel waves) from planGraph JSON.
319
+ */
320
+ function buildRoadmapMarkdown(planGraph = {}, {
321
+ cols = 80,
322
+ taskRunLine = "",
323
+ maxRows = 10,
324
+ } = {}) {
325
+ const dag = buildPlanDag(planGraph);
326
+ if (dag.nodes.length === 0) {
327
+ return { markdown: "", lines: [], dag };
328
+ }
329
+
330
+ const titleMax = Math.max(12, Math.min(48, Math.floor(Number(cols) || 80) - 12));
331
+ const objective = truncate(String(planGraph.objective || "").trim(), titleMax);
332
+ const lines = [objective ? `**Plan** · ${objective}` : "**Plan**"];
333
+
334
+ if (dag.linear) {
335
+ dag.waves.forEach((wave, waveIndex) => {
336
+ const node = wave[0];
337
+ lines.push(`${waveIndex + 1}. ${node.mark} ${truncate(node.title, titleMax)}`);
338
+ });
339
+ } else {
340
+ dag.waves.forEach((wave, waveIndex) => {
341
+ if (wave.length === 1) {
342
+ const node = wave[0];
343
+ lines.push(`${waveStepLabel(waveIndex, 0, 1)} ${node.mark} ${truncate(node.title, titleMax)}`);
344
+ return;
345
+ }
346
+ for (const line of formatParallelWaveLines(wave, waveIndex, Math.max(8, titleMax - 4))) {
347
+ lines.push(line);
348
+ }
349
+ });
350
+ }
351
+
352
+ const extra = String(taskRunLine || "").trim();
353
+ if (extra) lines.push(extra);
354
+
355
+ const limit = Number.isFinite(maxRows) && maxRows > 0 ? Math.floor(maxRows) : 10;
356
+ let clipped = lines.slice(0, Math.max(1, limit));
357
+ if (lines.length > clipped.length) {
358
+ clipped = clipped.slice(0, Math.max(1, limit - 1));
359
+ clipped.push(`… +${lines.length - clipped.length} more`);
360
+ }
361
+
362
+ return {
363
+ markdown: clipped.join("\n"),
364
+ lines: clipped,
365
+ dag,
366
+ };
367
+ }
368
+
207
369
  function buildDebugLines(executionState = null, planGraph = {}) {
208
370
  const lines = [];
209
371
  const pg = planGraph && typeof planGraph === "object" ? planGraph : {};
@@ -305,61 +467,52 @@ function buildPlanUiProjection(executionState = null, options = {}) {
305
467
  const focusTitle = focus ? truncate(focus.title, narrow ? 18 : 28) : "";
306
468
 
307
469
  let bandLines = [];
470
+ let roadmapMarkdown = "";
308
471
  let visible = false;
472
+ let planDag = null;
473
+
474
+ const taskRunSuffix = (() => {
475
+ if (!taskRun) return "";
476
+ const leaseBit = leaseHeld ? "writing" : taskRun.phase;
477
+ const files = taskRun.changedFilesHint ? ` · ${taskRun.changedFilesHint}` : "";
478
+ return `TaskLoop ${leaseBit}${files}`;
479
+ })();
309
480
 
310
481
  if (hasPlan && bandMode !== "hidden") {
311
482
  visible = true;
312
483
  if (bandMode === "debug") {
313
484
  bandLines = buildDebugLines(state, pg);
485
+ roadmapMarkdown = "";
314
486
  } else if (narrow) {
315
487
  const summary = buildCompactSummary(tree, focus && focus.nodeId);
316
488
  bandLines = [truncate(
317
489
  `Plan${focusTitle ? ` · ${focusTitle}` : ""}${progressLabel ? ` (${progressLabel})` : ""}${summary && !focusTitle ? ` ${summary}` : ""}`,
318
490
  Math.max(24, cols - 2)
319
491
  )];
320
- } else if (bandMode === "auto") {
321
- const summary = buildCompactSummary(tree, focus && focus.nodeId);
322
- const header = truncate(
323
- `Plan${pg.objective ? ` · ${pg.objective}` : ""}${summary ? ` ${summary}` : ""}`,
324
- Math.max(24, cols - 2)
325
- );
326
- bandLines = [header];
327
- if (focus) {
328
- const focusChildren = view
329
- .filter((node) => node && node.parentId === focus.nodeId)
330
- .map((node) => {
331
- const { mark } = statusToMark(node.status);
332
- return `${mark} ${nodeTitle(node)}`;
333
- });
334
- if (focusChildren.length > 0) {
335
- bandLines.push(truncate(
336
- ` └ ${focusChildren.join(" · ")}`,
337
- Math.max(24, cols - 2)
338
- ));
339
- } else if (focus.title) {
340
- bandLines.push(truncate(` → ${focus.title}`, Math.max(24, cols - 2)));
341
- }
342
- }
343
- if (taskRun) {
344
- const leaseBit = leaseHeld ? "writing" : taskRun.phase;
345
- const files = taskRun.changedFilesHint ? ` · ${taskRun.changedFilesHint}` : "";
346
- bandLines.push(truncate(` TaskLoop ${leaseBit}${files}`, Math.max(24, cols - 2)));
347
- }
348
- const maxRows = Number.isFinite(options.maxBandRows) ? options.maxBandRows : 3;
349
- bandLines = bandLines.slice(0, Math.max(1, maxRows));
492
+ roadmapMarkdown = "";
350
493
  } else {
351
- // expanded
352
- const title = pg.objective ? `Plan · ${pg.objective}` : "Plan";
353
- bandLines = [truncate(title, Math.max(24, cols - 2))];
354
- for (const row of tree) {
355
- bandLines.push(truncate(formatTreeLine(row), Math.max(24, cols - 2)));
356
- }
357
- if (taskRun) {
358
- const files = taskRun.changedFilesHint ? ` · ${taskRun.changedFilesHint}` : "";
359
- bandLines.push(truncate(`TaskLoop · ${taskRun.phase}${files}`, Math.max(24, cols - 2)));
494
+ // auto + expanded: JSON → DAG → roadmap markdown
495
+ const maxRows = Number.isFinite(options.maxBandRows)
496
+ ? options.maxBandRows
497
+ : (bandMode === "expanded" ? 16 : 10);
498
+ const roadmap = buildRoadmapMarkdown(pg, {
499
+ cols,
500
+ taskRunLine: taskRunSuffix,
501
+ maxRows,
502
+ });
503
+ planDag = roadmap.dag;
504
+ roadmapMarkdown = roadmap.markdown;
505
+ bandLines = roadmap.lines.slice();
506
+ if (bandMode === "expanded" && tree.length > 0) {
507
+ // Keep tree as fallback detail only when roadmap empty (shouldn't happen).
508
+ if (bandLines.length === 0) {
509
+ const title = pg.objective ? `Plan · ${pg.objective}` : "Plan";
510
+ bandLines = [truncate(title, Math.max(24, cols - 2))];
511
+ for (const row of tree) {
512
+ bandLines.push(truncate(formatTreeLine(row), Math.max(24, cols - 2)));
513
+ }
514
+ }
360
515
  }
361
- const maxRows = Number.isFinite(options.maxBandRows) ? options.maxBandRows : 7;
362
- bandLines = bandLines.slice(0, Math.max(1, maxRows));
363
516
  }
364
517
  }
365
518
 
@@ -402,7 +555,7 @@ function buildPlanUiProjection(executionState = null, options = {}) {
402
555
  leaseHeld,
403
556
  progressDone: progress.done,
404
557
  progressTotal: progress.total,
405
- bandLines,
558
+ bandLines: roadmapMarkdown ? [roadmapMarkdown] : bandLines,
406
559
  });
407
560
 
408
561
  return {
@@ -413,11 +566,13 @@ function buildPlanUiProjection(executionState = null, options = {}) {
413
566
  progress,
414
567
  focus,
415
568
  tree,
569
+ dag: planDag,
416
570
  taskRun,
417
571
  leaseHeld,
418
572
  statusLine,
419
573
  idleHint,
420
574
  activityStatusLine,
575
+ roadmapMarkdown,
421
576
  bandLines,
422
577
  hash,
423
578
  };
@@ -428,5 +583,7 @@ module.exports = {
428
583
  getBandMode,
429
584
  setBandMode,
430
585
  statusToMark,
586
+ buildPlanDag,
587
+ buildRoadmapMarkdown,
431
588
  buildPlanUiProjection,
432
589
  };
@@ -56,6 +56,7 @@ function buildImmutablePrefix() {
56
56
  "- After an accepted plan_graph create or patch, Runtime automatically advances ready tool nodes. Never invent or request an execute_graph tool.",
57
57
  "- Do not call plan_graph or task_run together with read, read_image, write, edit, bash, or artifact_read in the same assistant turn.",
58
58
  "- When an active graph is waiting on a task, advance that node through plan_graph instead of bypassing it with direct workspace tools: use patch.expand_node for execution.kind=expand, control.complete_task (nodeId) for execution.kind=inline_llm, or control.start_task for execution.kind=task_loop.",
59
+ "- Do not end a turn with text only while the plan is still waiting on a task; expand, start, or complete that node. Runtime will auto-continue if you stop early, but prefer advancing in the same turn.",
59
60
  "- control.complete_task with nodeId completes a waiting_llm inline_llm task for the current Graph owner. control.complete_task with taskRunId (or task_run complete) is reserved for the owning TaskLoop. Do not directly complete expand or aggregate tasks.",
60
61
  "- While Plan Mode is ON, workspace mutations must be represented as plan_graph tool nodes or performed inside a running TaskRun/task_loop.",
61
62
  "- Treat a User reminder as the latest user instruction. Reconcile it before continuing from tool results. If it is compatible with the active plan, resume the waiting plan node; otherwise patch, cancel, or replan first.",
@@ -104,6 +104,52 @@ function buildContinuationUserPrompt(userText = "", executionState = null) {
104
104
  return formatUserReminderMessage([text], { waitingFor: waiting });
105
105
  }
106
106
 
107
+ const PLAN_AUTO_CONTINUE_STOP_REASONS = new Set([
108
+ "approval_required",
109
+ "graph_terminal",
110
+ "scheduler_deadlock",
111
+ ]);
112
+
113
+ /**
114
+ * Whether the Agent Loop should keep going after a text-only model turn
115
+ * because the plan graph is still waiting on an agent-actionable task.
116
+ */
117
+ function shouldAutoContinuePlan(executionState = null) {
118
+ if (!executionState || typeof executionState !== "object") return false;
119
+ if (executionState.pendingUserInteraction) return false;
120
+ const pg = executionState.planGraph && typeof executionState.planGraph === "object"
121
+ ? executionState.planGraph
122
+ : null;
123
+ if (!pg) return false;
124
+ const waiting = pg.waitingFor && typeof pg.waitingFor === "object" ? pg.waitingFor : null;
125
+ if (!waiting || !waiting.id) return false;
126
+ if (String(waiting.type || "").trim().toLowerCase() !== "task") return false;
127
+ const yieldReason = String(pg.lastYieldReason || "").trim().toLowerCase();
128
+ if (yieldReason && PLAN_AUTO_CONTINUE_STOP_REASONS.has(yieldReason)) return false;
129
+ return true;
130
+ }
131
+
132
+ /**
133
+ * Internal reminder injected by runtime when the model ends a turn while the
134
+ * plan is still waiting on a task. Same shape as user nudges.
135
+ */
136
+ function buildPlanAutoContinueReminder(executionState = null) {
137
+ const waiting = executionState
138
+ && executionState.planGraph
139
+ && executionState.planGraph.waitingFor
140
+ ? executionState.planGraph.waitingFor
141
+ : null;
142
+ if (!waiting || !waiting.id) return "";
143
+ return formatUserReminderMessage(
144
+ [
145
+ "Continue the active plan. Serve the waiting task now via plan_graph "
146
+ + "(expand_node, control.start_task, or control.complete_task as appropriate). "
147
+ + "Do not end the turn with text only while this node is waiting.",
148
+ ],
149
+ { waitingFor: waiting },
150
+ );
151
+ }
152
+
107
153
  module.exports = {
108
154
  ensurePendingUserPrompts,
109
155
  enqueueUserPrompt,
@@ -113,4 +159,7 @@ module.exports = {
113
159
  shouldFrameAsUserReminder,
114
160
  formatUserReminderMessage,
115
161
  buildContinuationUserPrompt,
162
+ shouldAutoContinuePlan,
163
+ buildPlanAutoContinueReminder,
164
+ PLAN_AUTO_CONTINUE_STOP_REASONS,
116
165
  };
@@ -27,7 +27,15 @@ const {
27
27
  clearUserPrompts,
28
28
  formatUserReminderMessage,
29
29
  ensurePendingUserPrompts,
30
+ shouldAutoContinuePlan,
31
+ buildPlanAutoContinueReminder,
30
32
  } = require("./context/userNudge");
33
+ const {
34
+ drainAgentMailboxForTurn,
35
+ shouldAutoContinueForTaskWake,
36
+ buildTaskRunWakeReminder,
37
+ listTaskRunsAwaitingModel,
38
+ } = require("./runtime/agentWakeup");
31
39
  const {
32
40
  runAskUserTool,
33
41
  syncInteractionFromPlanGraph,
@@ -85,6 +93,8 @@ const DEFAULT_KIMI_MODEL = "k3";
85
93
  const DEFAULT_MAX_NATIVE_TOOL_CALLS = 100;
86
94
  const DEFAULT_MAX_NATIVE_TOOL_ERRORS = 20;
87
95
  const DEFAULT_NATIVE_TIMEOUT_MS = 43200000; // 12 hours
96
+ /** Max text-only auto-continues while a plan is waiting on a task (per user submit). */
97
+ const DEFAULT_MAX_PLAN_AUTO_CONTINUES = 24;
88
98
  // Anthropic Messages rejects max_tokens above the model's real cap (64K on
89
99
  // current models), so the transports use different defaults. Override either
90
100
  // via UFOO_UCODE_MAX_TOKENS (positive integer).
@@ -1759,6 +1769,9 @@ async function runNativeLoop({
1759
1769
  // materialize Provider messages yet. STRICT via UFOO_UCODE_PROTOCOL_STRICT=1.
1760
1770
  let activeLedger = null;
1761
1771
  let lastProtocolLedger = null;
1772
+ let planAutoContinues = 0;
1773
+ let lastAutoContinueWaitingId = "";
1774
+ let consecutiveEmptyAutoContinues = 0;
1762
1775
 
1763
1776
  if (resume) {
1764
1777
  await withFaultPoint("before_provider_resume", () => {});
@@ -1775,10 +1788,74 @@ async function runNativeLoop({
1775
1788
  messages.push({ role: "user", content });
1776
1789
  }
1777
1790
 
1791
+ /** Deliver mid-loop TaskRun runtime events before the next model call. */
1792
+ function injectRuntimeMailboxEvents() {
1793
+ const drained = drainAgentMailboxForTurn(executionState);
1794
+ if (!drained.text) return false;
1795
+ messages.push({ role: "user", content: drained.text });
1796
+ return true;
1797
+ }
1798
+
1799
+ function nextAutoContinueKey() {
1800
+ const waitingId = String(
1801
+ (executionState.planGraph && executionState.planGraph.waitingFor
1802
+ && executionState.planGraph.waitingFor.id) || ""
1803
+ ).trim();
1804
+ if (waitingId) return `plan:${waitingId}`;
1805
+ const runs = listTaskRunsAwaitingModel(executionState);
1806
+ if (runs.length > 0) {
1807
+ return `task:${runs.map((run) => run.id).sort().join(",")}`;
1808
+ }
1809
+ return "mailbox";
1810
+ }
1811
+
1812
+ function tryInjectAgentAutoContinue() {
1813
+ if (planAutoContinues >= DEFAULT_MAX_PLAN_AUTO_CONTINUES) return false;
1814
+ const continueKey = nextAutoContinueKey();
1815
+ if (
1816
+ consecutiveEmptyAutoContinues >= 2
1817
+ && continueKey
1818
+ && continueKey === lastAutoContinueWaitingId
1819
+ ) {
1820
+ return false;
1821
+ }
1822
+
1823
+ // Prefer draining fresh runtime mail (task_started, etc.) before nudges.
1824
+ if (injectRuntimeMailboxEvents()) {
1825
+ planAutoContinues += 1;
1826
+ lastAutoContinueWaitingId = continueKey;
1827
+ consecutiveEmptyAutoContinues += 1;
1828
+ return true;
1829
+ }
1830
+
1831
+ if (shouldAutoContinuePlan(executionState)) {
1832
+ const reminder = buildPlanAutoContinueReminder(executionState);
1833
+ if (!reminder) return false;
1834
+ messages.push({ role: "user", content: reminder });
1835
+ planAutoContinues += 1;
1836
+ lastAutoContinueWaitingId = continueKey;
1837
+ consecutiveEmptyAutoContinues += 1;
1838
+ return true;
1839
+ }
1840
+
1841
+ if (shouldAutoContinueForTaskWake(executionState)) {
1842
+ const reminder = buildTaskRunWakeReminder(executionState);
1843
+ if (!reminder) return false;
1844
+ messages.push({ role: "user", content: reminder });
1845
+ planAutoContinues += 1;
1846
+ lastAutoContinueWaitingId = continueKey;
1847
+ consecutiveEmptyAutoContinues += 1;
1848
+ return true;
1849
+ }
1850
+
1851
+ return false;
1852
+ }
1853
+
1778
1854
  while (true) {
1779
1855
  guards.ensureActive();
1780
1856
 
1781
1857
  injectPendingUserReminders();
1858
+ injectRuntimeMailboxEvents();
1782
1859
 
1783
1860
  if (activeLedger) {
1784
1861
  runProviderTurnGate(activeLedger);
@@ -1846,6 +1923,9 @@ async function runNativeLoop({
1846
1923
  if (!aggregated.trim() && text) {
1847
1924
  aggregated = text;
1848
1925
  }
1926
+ if (tryInjectAgentAutoContinue()) {
1927
+ continue;
1928
+ }
1849
1929
  return {
1850
1930
  text: aggregated,
1851
1931
  streamed,
@@ -1857,6 +1937,9 @@ async function runNativeLoop({
1857
1937
  };
1858
1938
  }
1859
1939
 
1940
+ // A tool-using turn resets the empty auto-continue streak (progress possible).
1941
+ consecutiveEmptyAutoContinues = 0;
1942
+
1860
1943
  const pendingCalls = transport.prepareToolCalls({ messages, turnResult, toolCalls });
1861
1944
  if (!pendingCalls) {
1862
1945
  return {
@@ -2,9 +2,12 @@
2
2
 
3
3
  /**
4
4
  * Drain Agent Loop mailbox into a turnDynamic block (never as user role).
5
+ * Mid-loop wakeups (nativeRunner) also consume the same mailbox into the
6
+ * conversation so TaskRun waiting_model does not strand the Agent Loop.
5
7
  */
6
8
 
7
- const { drainAgentMailbox, ensureMailbox } = require("./loopMailbox");
9
+ const { drainAgentMailbox, ensureMailbox, peek } = require("./loopMailbox");
10
+ const { ensureTaskRunStore } = require("./taskRun");
8
11
 
9
12
  function formatAgentRuntimeEvents(events = []) {
10
13
  const list = Array.isArray(events) ? events : [];
@@ -43,6 +46,11 @@ function peekAgentMailboxText(executionState = null) {
43
46
  return formatAgentRuntimeEvents(box.queue || []);
44
47
  }
45
48
 
49
+ function hasPendingAgentMailbox(executionState = null) {
50
+ const state = executionState && typeof executionState === "object" ? executionState : {};
51
+ return Boolean(peek(ensureMailbox(state, "agentMailbox")));
52
+ }
53
+
46
54
  function drainAgentMailboxForTurn(executionState = null) {
47
55
  const events = drainAgentMailbox(executionState);
48
56
  return {
@@ -51,8 +59,63 @@ function drainAgentMailboxForTurn(executionState = null) {
51
59
  };
52
60
  }
53
61
 
62
+ const AWAITING_MODEL_PHASES = new Set([
63
+ "waiting_model",
64
+ "planning",
65
+ "initializing",
66
+ ]);
67
+
68
+ /**
69
+ * TaskRuns that still need the Agent Loop (model turn / planning).
70
+ */
71
+ function listTaskRunsAwaitingModel(executionState = null) {
72
+ const store = ensureTaskRunStore(executionState);
73
+ return Object.values(store.byId || {}).filter((run) => (
74
+ run
75
+ && (run.status === "running" || run.status === "queued")
76
+ && AWAITING_MODEL_PHASES.has(String(run.phase || "").trim().toLowerCase())
77
+ ));
78
+ }
79
+
80
+ function shouldWakeAgentForTaskRuns(executionState = null) {
81
+ return listTaskRunsAwaitingModel(executionState).length > 0;
82
+ }
83
+
84
+ /**
85
+ * Whether the Agent Loop must keep going after a text-only model turn
86
+ * because TaskRuns or unread runtime mail still need service.
87
+ */
88
+ function shouldAutoContinueForTaskWake(executionState = null) {
89
+ if (!executionState || typeof executionState !== "object") return false;
90
+ if (executionState.pendingUserInteraction) return false;
91
+ return hasPendingAgentMailbox(executionState) || shouldWakeAgentForTaskRuns(executionState);
92
+ }
93
+
94
+ function buildTaskRunWakeReminder(executionState = null) {
95
+ const runs = listTaskRunsAwaitingModel(executionState);
96
+ if (runs.length === 0 && !hasPendingAgentMailbox(executionState)) return "";
97
+ const lines = [
98
+ "Runtime wake (not a user message): active TaskRun(s) still need the Agent Loop.",
99
+ "Continue serving them now (read/inspect/edit via tools, or plan_graph/task_run control).",
100
+ "Do not end the turn with text only while a TaskRun is waiting_model.",
101
+ ];
102
+ for (const run of runs.slice(0, 4)) {
103
+ const label = run.title || run.objective || run.parentNodeId || run.id;
104
+ lines.push(
105
+ `- taskRunId=${run.id} phase=${run.phase || ""} status=${run.status || ""}`
106
+ + (label ? ` — ${String(label).slice(0, 160)}` : "")
107
+ );
108
+ }
109
+ return lines.join("\n");
110
+ }
111
+
54
112
  module.exports = {
55
113
  formatAgentRuntimeEvents,
56
114
  peekAgentMailboxText,
115
+ hasPendingAgentMailbox,
57
116
  drainAgentMailboxForTurn,
117
+ listTaskRunsAwaitingModel,
118
+ shouldWakeAgentForTaskRuns,
119
+ shouldAutoContinueForTaskWake,
120
+ buildTaskRunWakeReminder,
58
121
  };
@@ -90,6 +90,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
90
90
  hasPlan: false,
91
91
  visible: false,
92
92
  bandLines: [],
93
+ roadmapMarkdown: "",
93
94
  idleHint: "",
94
95
  statusLine: "",
95
96
  hash: "",
@@ -1248,18 +1249,30 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1248
1249
  renderMergeText(activeMerge)
1249
1250
  ),
1250
1251
  ) : null,
1251
- planUi.visible && planUi.bandLines.length > 0
1252
+ planUi.visible && (planUi.roadmapMarkdown || (planUi.bandLines && planUi.bandLines.length > 0))
1252
1253
  ? h(Box, {
1253
1254
  flexDirection: "column",
1254
1255
  width: "100%",
1255
1256
  marginTop: 1,
1256
1257
  },
1257
- ...planUi.bandLines.map((line, idx) => h(Text, {
1258
- key: `plan-band-${idx}`,
1259
- color: "magenta",
1260
- dimColor: idx > 0,
1261
- wrap: "truncate",
1262
- }, line || " ")),
1258
+ ...(() => {
1259
+ let lines = Array.isArray(planUi.bandLines) ? planUi.bandLines.slice() : [];
1260
+ const md = String(planUi.roadmapMarkdown || "").trim();
1261
+ if (md) {
1262
+ try {
1263
+ const rendered = fmt.renderLogLinesWithMarkdownAnsi(md, { inCodeBlock: false });
1264
+ if (Array.isArray(rendered) && rendered.length > 0) lines = rendered;
1265
+ } catch {
1266
+ lines = md.split(/\r?\n/);
1267
+ }
1268
+ }
1269
+ return lines.map((line, idx) => h(Text, {
1270
+ key: `plan-band-${idx}`,
1271
+ color: md ? undefined : "magenta",
1272
+ dimColor: !md && idx > 0,
1273
+ wrap: "truncate",
1274
+ }, line || " "));
1275
+ })(),
1263
1276
  )
1264
1277
  : null,
1265
1278
  interactionLines.length > 0