u-foo 3.0.4 → 3.0.6

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.4",
3
+ "version": "3.0.6",
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",
@@ -430,17 +430,128 @@ function cacheCommand(planGraph, commandId, payload) {
430
430
  }
431
431
 
432
432
  /**
433
- * Apply a normalized plan_graph command against executionState.planGraph.
433
+ * Resolve which graph a plan_graph command targets.
434
+ * Parent lives in executionState.planGraph; TaskLoop children live in graphs[].
435
+ */
436
+ function selectGraphForCommand(executionState = null, command = {}) {
437
+ const state = ensurePlanGraphState(executionState);
438
+ if (!state.graphs || typeof state.graphs !== "object") state.graphs = {};
439
+ const primary = state.planGraph && typeof state.planGraph === "object"
440
+ ? state.planGraph
441
+ : emptyPlanGraphState();
442
+ if (primary.graphId) state.graphs[primary.graphId] = primary;
443
+
444
+ const requested = String(command && command.graphId || "").trim();
445
+ if (!requested) {
446
+ return {
447
+ ok: true,
448
+ graph: primary,
449
+ isPrimary: true,
450
+ primaryGraphId: String(primary.graphId || ""),
451
+ };
452
+ }
453
+ if (primary.graphId && primary.graphId === requested) {
454
+ return {
455
+ ok: true,
456
+ graph: primary,
457
+ isPrimary: true,
458
+ primaryGraphId: primary.graphId,
459
+ };
460
+ }
461
+ const mapped = state.graphs[requested];
462
+ if (mapped && typeof mapped === "object") {
463
+ return {
464
+ ok: true,
465
+ graph: mapped,
466
+ isPrimary: false,
467
+ primaryGraphId: String(primary.graphId || ""),
468
+ };
469
+ }
470
+ return {
471
+ ok: false,
472
+ code: "GRAPH_NOT_FOUND",
473
+ message: `graphId ${requested} not found`,
474
+ };
475
+ }
476
+
477
+ /**
478
+ * Apply a normalized plan_graph command against executionState.planGraph
479
+ * (or a TaskLoop child graph when command.graphId selects it).
434
480
  */
435
481
  function runPlanGraphCommand(commandInput = {}, options = {}) {
436
482
  const command = normalizePlanGraphCommand(commandInput) || commandInput;
437
483
  const operation = String(command && command.operation || "").trim().toLowerCase();
438
484
  const executionState = ensurePlanGraphState(options.executionState);
439
- const planGraph = executionState.planGraph;
485
+ if (!executionState.graphs || typeof executionState.graphs !== "object") {
486
+ executionState.graphs = {};
487
+ }
440
488
  const commandId = String(command.commandId || "").trim();
441
489
 
490
+ if (!operation) {
491
+ const payload = rejected([{ code: "MISSING_OPERATION", message: "operation is required" }]);
492
+ return { ...payload, executionState, modelPayload: payload };
493
+ }
494
+
495
+ // create/control always target the primary agent graph; patch/inspect may
496
+ // select a TaskLoop child via command.graphId.
497
+ const selected = (operation === "patch" || operation === "inspect")
498
+ ? selectGraphForCommand(executionState, command)
499
+ : {
500
+ ok: true,
501
+ graph: executionState.planGraph,
502
+ isPrimary: true,
503
+ primaryGraphId: String(executionState.planGraph && executionState.planGraph.graphId || ""),
504
+ };
505
+
506
+ if (!selected.ok) {
507
+ const payload = rejected([{
508
+ code: selected.code || "GRAPH_NOT_FOUND",
509
+ message: selected.message || "graph not found",
510
+ }]);
511
+ return { ...payload, executionState, modelPayload: payload };
512
+ }
513
+
514
+ const primaryGraphId = selected.primaryGraphId
515
+ || String(executionState.planGraph && executionState.planGraph.graphId || "");
516
+ // Work on the selected graph for this command; restore primary afterward if child.
517
+ if (!selected.isPrimary) {
518
+ executionState.planGraph = selected.graph;
519
+ }
520
+ const planGraph = executionState.planGraph;
521
+
522
+ function restorePrimaryGraph() {
523
+ if (executionState.planGraph && executionState.planGraph.graphId) {
524
+ executionState.graphs[executionState.planGraph.graphId] = executionState.planGraph;
525
+ }
526
+ if (!selected.isPrimary && primaryGraphId && executionState.graphs[primaryGraphId]) {
527
+ executionState.planGraph = executionState.graphs[primaryGraphId];
528
+ } else if (executionState.planGraph && executionState.planGraph.graphId) {
529
+ executionState.graphs[executionState.planGraph.graphId] = executionState.planGraph;
530
+ }
531
+ }
532
+
533
+ function resumeChildTaskLoopIfNeeded(payload = null) {
534
+ if (selected.isPrimary) return null;
535
+ if (!payload || payload.status !== "accepted") return null;
536
+ if (typeof options.runTool !== "function") return null;
537
+ const childId = String((selected.graph && selected.graph.graphId) || "").trim();
538
+ const childLive = childId ? executionState.graphs[childId] : null;
539
+ const owner = childLive && childLive.owner;
540
+ if (!owner || owner.kind !== "task_loop" || !owner.taskRunId) return null;
541
+ try {
542
+ const { processTaskRun } = require("../runtime/taskLoop");
543
+ return processTaskRun(executionState, owner.taskRunId, {
544
+ runTool: options.runTool,
545
+ knownTools: options.knownTools,
546
+ });
547
+ } catch {
548
+ return null;
549
+ }
550
+ }
551
+
442
552
  if (commandId && planGraph.commandLog && planGraph.commandLog[commandId]) {
443
553
  const cached = cloneJson(planGraph.commandLog[commandId]);
554
+ restorePrimaryGraph();
444
555
  return {
445
556
  ...cached,
446
557
  ok: cached.status === "accepted",
@@ -450,13 +561,9 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
450
561
  };
451
562
  }
452
563
 
453
- if (!operation) {
454
- const payload = rejected([{ code: "MISSING_OPERATION", message: "operation is required" }]);
455
- return { ...payload, executionState, modelPayload: payload };
456
- }
457
-
458
564
  if (operation === "inspect") {
459
565
  const payload = inspectPlanGraph(planGraph);
566
+ restorePrimaryGraph();
460
567
  return { ...payload, executionState, modelPayload: payload };
461
568
  }
462
569
 
@@ -556,9 +663,15 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
556
663
  advance: { status: "completed", yieldReason: "cancelled", executedNodes: [], failedNodes: [] },
557
664
  validationWarnings: [],
558
665
  });
666
+ restorePrimaryGraph();
559
667
  return { ...payload, executionState, modelPayload: payload };
560
668
  }
561
669
 
670
+ function rejectWorking(payload, extra = {}) {
671
+ restorePrimaryGraph();
672
+ return { ...payload, executionState, modelPayload: payload, ...extra };
673
+ }
674
+
562
675
  if (
563
676
  Number.isFinite(command.expectedSpecRevision)
564
677
  && command.expectedSpecRevision !== null
@@ -572,15 +685,16 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
572
685
  commandRevision: Number(planGraph.specRevision) || 0,
573
686
  stateRevision: Number(planGraph.stateRevision) || 0,
574
687
  });
575
- return { ...payload, executionState, modelPayload: payload };
688
+ return rejectWorking(payload);
576
689
  }
577
690
 
578
691
  if (command.graphId && planGraph.graphId && command.graphId !== planGraph.graphId) {
692
+ // Should not happen after selectGraphForCommand switched the working graph.
579
693
  const payload = rejected([{
580
694
  code: "GRAPH_ID_MISMATCH",
581
695
  message: `expected graphId ${command.graphId}, actual ${planGraph.graphId}`,
582
696
  }]);
583
- return { ...payload, executionState, modelPayload: payload };
697
+ return rejectWorking(payload);
584
698
  }
585
699
 
586
700
  const beforeIds = new Set(listNodeIds(planGraph.nodes));
@@ -622,7 +736,7 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
622
736
  code: "NESTED_TASK_LOOP_NOT_SUPPORTED",
623
737
  message: "V1 child graphs cannot create task_loop nodes",
624
738
  }]);
625
- return { ...payload, executionState, modelPayload: payload };
739
+ return rejectWorking(payload);
626
740
  }
627
741
  }
628
742
  const targetId = String(op.nodeId || (op.node && op.node.id) || "").trim();
@@ -639,7 +753,7 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
639
753
  code: "RUNNING_TASK_SPEC_FROZEN",
640
754
  message: `cannot mutate running task_loop ${targetId}`,
641
755
  }]);
642
- return { ...payload, executionState, modelPayload: payload };
756
+ return rejectWorking(payload);
643
757
  }
644
758
  }
645
759
  }
@@ -654,7 +768,7 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
654
768
  commandRevision: Number(planGraph.specRevision) || 0,
655
769
  stateRevision: Number(planGraph.stateRevision) || 0,
656
770
  });
657
- return { ...payload, executionState, modelPayload: payload };
771
+ return rejectWorking(payload);
658
772
  }
659
773
  nextPlan = applied;
660
774
  for (const op of ops) {
@@ -668,7 +782,7 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
668
782
  }
669
783
  } else {
670
784
  const payload = rejected([{ code: "UNKNOWN_OPERATION", message: `unknown operation: ${operation}` }]);
671
- return { ...payload, executionState, modelPayload: payload };
785
+ return rejectWorking(payload);
672
786
  }
673
787
 
674
788
  // Validate. Do not rewrite aggregate sinks via group rewrite when storing flat nodes.
@@ -680,7 +794,7 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
680
794
  stateRevision: Number(planGraph.stateRevision) || 0,
681
795
  validationWarnings: compiled.warnings || [],
682
796
  });
683
- return { ...payload, executionState, modelPayload: payload, compile: compiled };
797
+ return rejectWorking(payload, { compile: compiled });
684
798
  }
685
799
 
686
800
  // Prefer the patched node list (includes aggregate expand status).
@@ -702,7 +816,7 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
702
816
  commandRevision: Number(planGraph.specRevision) || 0,
703
817
  stateRevision: Number(planGraph.stateRevision) || 0,
704
818
  });
705
- return { ...payload, executionState, modelPayload: payload, compile: preferredCompile };
819
+ return rejectWorking(payload, { compile: preferredCompile });
706
820
  }
707
821
 
708
822
  const mergedNodes = applyStatusesFromStore(preferredCompile.nodes, preferredNodes, {
@@ -736,6 +850,8 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
736
850
  lastYieldReason: "",
737
851
  commandLog: planGraph.commandLog || {},
738
852
  owner: nextPlan.owner || planGraph.owner || null,
853
+ parentGraphId: planGraph.parentGraphId || "",
854
+ parentNodeId: planGraph.parentNodeId || "",
739
855
  };
740
856
  if (!executionState.graphs || typeof executionState.graphs !== "object") {
741
857
  executionState.graphs = {};
@@ -767,7 +883,7 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
767
883
  commandRevision: specRevision,
768
884
  stateRevision: Number(executionState.planGraph.stateRevision) || 0,
769
885
  });
770
- return { ...payload, executionState, modelPayload: payload };
886
+ return rejectWorking(payload);
771
887
  }
772
888
  if (advanced.planGraph) {
773
889
  executionState.planGraph = {
@@ -775,6 +891,9 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
775
891
  specRevision,
776
892
  revision: specRevision,
777
893
  commandLog: planGraph.commandLog || {},
894
+ owner: advanced.planGraph.owner || planGraph.owner || null,
895
+ parentGraphId: advanced.planGraph.parentGraphId || planGraph.parentGraphId || "",
896
+ parentNodeId: advanced.planGraph.parentNodeId || planGraph.parentNodeId || "",
778
897
  };
779
898
  }
780
899
  if (advanced.advance) advance = advanced.advance;
@@ -822,12 +941,16 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
822
941
  }
823
942
  }
824
943
 
944
+ restorePrimaryGraph();
945
+ const resumed = resumeChildTaskLoopIfNeeded(payload);
946
+
825
947
  return {
826
948
  ...payload,
827
949
  executionState,
828
950
  modelPayload: payload,
829
951
  compile: preferredCompile,
830
952
  planModeEntered,
953
+ taskLoopResume: resumed || null,
831
954
  };
832
955
  }
833
956
 
@@ -853,5 +976,6 @@ module.exports = {
853
976
  executionSegmentToCreateGraph,
854
977
  projectPlanView,
855
978
  activePlanRequiresExpansion,
979
+ selectGraphForCommand,
856
980
  stripModelStatuses,
857
981
  };
@@ -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
+ "- TaskLoop start returns childGraphId. While that TaskRun is waiting_model on child root, patch with graphId=<childGraphId> and expand_node nodeId=root (add tool children). Do not ask the user to /plan off.",
59
60
  "- 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.",
60
61
  "- 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.",
61
62
  "- While Plan Mode is ON, workspace mutations must be represented as plan_graph tool nodes or performed inside a running TaskRun/task_loop.",
@@ -387,15 +387,40 @@ function resolveUserInteraction(executionState = null, rawText = "") {
387
387
  };
388
388
  }
389
389
 
390
- function formatInteractionPromptLines(pending = null) {
390
+ function wrapLabeledBlock(label = "", prompt = "", cols = 80) {
391
+ const title = String(label || "Question").trim() || "Question";
392
+ const text = String(prompt || "").replace(/\s+/g, " ").trim();
393
+ const width = Math.max(24, Math.min(120, Math.floor(Number(cols) || 80) - 2));
394
+ if (!text) return [`${title}:`];
395
+
396
+ const prefix = `${title}: `;
397
+ const firstWidth = Math.max(8, width - prefix.length);
398
+ const contPrefix = " ";
399
+ const contWidth = Math.max(8, width - contPrefix.length);
400
+ const lines = [];
401
+
402
+ let offset = 0;
403
+ const firstChunk = text.slice(0, firstWidth);
404
+ lines.push(`${prefix}${firstChunk}`);
405
+ offset = firstChunk.length;
406
+ while (offset < text.length) {
407
+ const chunk = text.slice(offset, offset + contWidth);
408
+ lines.push(`${contPrefix}${chunk}`);
409
+ offset += chunk.length;
410
+ }
411
+ return lines;
412
+ }
413
+
414
+ function formatInteractionPromptLines(pending = null, options = {}) {
391
415
  if (!pending) return [];
416
+ const cols = Number(options.cols) > 0 ? Number(options.cols) : 80;
392
417
  const lines = [];
393
418
  const kind = pending.kind || "chat";
394
419
  if (kind === "approval") {
395
- lines.push(`Approval: ${pending.prompt}`);
420
+ lines.push(...wrapLabeledBlock("Approval", pending.prompt, cols));
396
421
  lines.push(" [yes] Yes [no] No or type a free-text reply");
397
422
  } else if (kind === "choice") {
398
- lines.push(`Choice: ${pending.prompt}`);
423
+ lines.push(...wrapLabeledBlock("Choice", pending.prompt, cols));
399
424
  for (const opt of pending.options || []) {
400
425
  lines.push(` [${opt.key}] ${opt.label}`);
401
426
  }
@@ -403,7 +428,7 @@ function formatInteractionPromptLines(pending = null) {
403
428
  lines.push(" or type a free-text reply");
404
429
  }
405
430
  } else {
406
- lines.push(`Question: ${pending.prompt}`);
431
+ lines.push(...wrapLabeledBlock("Question", pending.prompt, cols));
407
432
  lines.push(" (type your reply)");
408
433
  }
409
434
  return lines;
@@ -30,6 +30,12 @@ const {
30
30
  shouldAutoContinuePlan,
31
31
  buildPlanAutoContinueReminder,
32
32
  } = require("./context/userNudge");
33
+ const {
34
+ drainAgentMailboxForTurn,
35
+ shouldAutoContinueForTaskWake,
36
+ buildTaskRunWakeReminder,
37
+ listTaskRunsAwaitingModel,
38
+ } = require("./runtime/agentWakeup");
33
39
  const {
34
40
  runAskUserTool,
35
41
  syncInteractionFromPlanGraph,
@@ -1782,33 +1788,74 @@ async function runNativeLoop({
1782
1788
  messages.push({ role: "user", content });
1783
1789
  }
1784
1790
 
1785
- function tryInjectPlanAutoContinue() {
1786
- if (!shouldAutoContinuePlan(executionState)) return false;
1787
- if (planAutoContinues >= DEFAULT_MAX_PLAN_AUTO_CONTINUES) return false;
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() {
1788
1800
  const waitingId = String(
1789
1801
  (executionState.planGraph && executionState.planGraph.waitingFor
1790
1802
  && executionState.planGraph.waitingFor.id) || ""
1791
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();
1792
1815
  if (
1793
1816
  consecutiveEmptyAutoContinues >= 2
1794
- && waitingId
1795
- && waitingId === lastAutoContinueWaitingId
1817
+ && continueKey
1818
+ && continueKey === lastAutoContinueWaitingId
1796
1819
  ) {
1797
1820
  return false;
1798
1821
  }
1799
- const reminder = buildPlanAutoContinueReminder(executionState);
1800
- if (!reminder) return false;
1801
- messages.push({ role: "user", content: reminder });
1802
- planAutoContinues += 1;
1803
- lastAutoContinueWaitingId = waitingId;
1804
- consecutiveEmptyAutoContinues += 1;
1805
- return true;
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;
1806
1852
  }
1807
1853
 
1808
1854
  while (true) {
1809
1855
  guards.ensureActive();
1810
1856
 
1811
1857
  injectPendingUserReminders();
1858
+ injectRuntimeMailboxEvents();
1812
1859
 
1813
1860
  if (activeLedger) {
1814
1861
  runProviderTurnGate(activeLedger);
@@ -1876,7 +1923,7 @@ async function runNativeLoop({
1876
1923
  if (!aggregated.trim() && text) {
1877
1924
  aggregated = text;
1878
1925
  }
1879
- if (tryInjectPlanAutoContinue()) {
1926
+ if (tryInjectAgentAutoContinue()) {
1880
1927
  continue;
1881
1928
  }
1882
1929
  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,65 @@ 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. For a TaskLoop child graph waiting on root:",
100
+ "call plan_graph operation=patch with graphId=<childGraphId> and expand_node on nodeId=root",
101
+ "(tool children). Do not ask the user to /plan off. Do not end with text only.",
102
+ ];
103
+ for (const run of runs.slice(0, 4)) {
104
+ const label = run.title || run.objective || run.parentNodeId || run.id;
105
+ lines.push(
106
+ `- taskRunId=${run.id} phase=${run.phase || ""} status=${run.status || ""}`
107
+ + (run.childGraphId ? ` childGraphId=${run.childGraphId}` : "")
108
+ + (label ? ` — ${String(label).slice(0, 120)}` : "")
109
+ );
110
+ }
111
+ return lines.join("\n");
112
+ }
113
+
54
114
  module.exports = {
55
115
  formatAgentRuntimeEvents,
56
116
  peekAgentMailboxText,
117
+ hasPendingAgentMailbox,
57
118
  drainAgentMailboxForTurn,
119
+ listTaskRunsAwaitingModel,
120
+ shouldWakeAgentForTaskRuns,
121
+ shouldAutoContinueForTaskWake,
122
+ buildTaskRunWakeReminder,
58
123
  };
@@ -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: "",
@@ -171,7 +172,9 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
171
172
  );
172
173
  setPlanUi((prev) => (prev && prev.hash === next.hash ? prev : next));
173
174
  const pending = getPendingUserInteraction(props.state && props.state.executionState);
174
- setInteractionLines(pending ? formatInteractionPromptLines(pending) : []);
175
+ setInteractionLines(pending ? formatInteractionPromptLines(pending, {
176
+ cols: size.cols || 80,
177
+ }) : []);
175
178
  return next;
176
179
  } catch {
177
180
  return null;
@@ -1248,18 +1251,30 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1248
1251
  renderMergeText(activeMerge)
1249
1252
  ),
1250
1253
  ) : null,
1251
- planUi.visible && planUi.bandLines.length > 0
1254
+ planUi.visible && (planUi.roadmapMarkdown || (planUi.bandLines && planUi.bandLines.length > 0))
1252
1255
  ? h(Box, {
1253
1256
  flexDirection: "column",
1254
1257
  width: "100%",
1255
1258
  marginTop: 1,
1256
1259
  },
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 || " ")),
1260
+ ...(() => {
1261
+ let lines = Array.isArray(planUi.bandLines) ? planUi.bandLines.slice() : [];
1262
+ const md = String(planUi.roadmapMarkdown || "").trim();
1263
+ if (md) {
1264
+ try {
1265
+ const rendered = fmt.renderLogLinesWithMarkdownAnsi(md, { inCodeBlock: false });
1266
+ if (Array.isArray(rendered) && rendered.length > 0) lines = rendered;
1267
+ } catch {
1268
+ lines = md.split(/\r?\n/);
1269
+ }
1270
+ }
1271
+ return lines.map((line, idx) => h(Text, {
1272
+ key: `plan-band-${idx}`,
1273
+ color: md ? undefined : "magenta",
1274
+ dimColor: !md && idx > 0,
1275
+ wrap: "truncate",
1276
+ }, line || " "));
1277
+ })(),
1263
1278
  )
1264
1279
  : null,
1265
1280
  interactionLines.length > 0
@@ -1271,7 +1286,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1271
1286
  ...interactionLines.map((line, idx) => h(Text, {
1272
1287
  key: `ask-${idx}`,
1273
1288
  color: "yellow",
1274
- wrap: "truncate",
1289
+ wrap: "wrap",
1275
1290
  }, line || " ")),
1276
1291
  )
1277
1292
  : null,