u-foo 3.0.7 → 3.0.9

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.7",
3
+ "version": "3.0.9",
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",
@@ -315,7 +315,6 @@ class AgentNotifier {
315
315
  if (this.stopped) return;
316
316
 
317
317
  const currentCount = this.getMessageCount();
318
- const nowMs = Date.now();
319
318
 
320
319
  // 有新消息
321
320
  if (currentCount > this.lastCount) {
@@ -330,14 +329,22 @@ class AgentNotifier {
330
329
  }
331
330
 
332
331
  this.lastCount = this.getMessageCount();
333
- if (this._launcherReady && (!this.lastWorkingAt || nowMs - this.lastWorkingAt >= this.workingHoldMs)) {
332
+ // Delivery moved to the daemon scheduler. ActivityDetector owns
333
+ // working → idle / waiting_input. Never force-idle over working here:
334
+ // lastWorkingAt is only set by the legacy deliverPending path, so the
335
+ // old hold-window check stayed permanently true and stomped real
336
+ // working states every poll (~2s) — Codex injections then slipped mid-turn.
337
+ if (this._launcherReady) {
334
338
  const currentActivityState = this.getCurrentActivityState();
335
- if (currentActivityState !== "waiting_input" && currentActivityState !== "blocked") {
336
- if (currentActivityState === "working") {
337
- this.updateActivityState("idle", { force: true });
338
- } else {
339
- this.updateActivityState("idle");
340
- }
339
+ if (
340
+ currentActivityState
341
+ && currentActivityState !== "working"
342
+ && currentActivityState !== "waiting_input"
343
+ && currentActivityState !== "blocked"
344
+ ) {
345
+ // Soft fallback only (no force): ready/starting → idle for delivery,
346
+ // without overriding detector-owned busy states.
347
+ this.updateActivityState("idle");
341
348
  }
342
349
  }
343
350
  this.refreshTitle();
@@ -27,6 +27,7 @@ function emptyExecutionState() {
27
27
  pendingUserPrompts: [],
28
28
  planGraph: require("./planGraphService").emptyPlanGraphState(),
29
29
  graphs: {},
30
+ archivedPlans: [],
30
31
  taskRuns: require("../runtime/taskRun").emptyTaskRunStore(),
31
32
  agentMailbox: require("../runtime/loopMailbox").emptyMailbox(),
32
33
  taskMailboxes: {},
@@ -48,6 +48,102 @@ function cloneJson(value) {
48
48
  }
49
49
  }
50
50
 
51
+ const MAX_ARCHIVED_PLANS = 5;
52
+
53
+ /**
54
+ * Snapshot a finished/replaced plan out of the live primary slot so the next
55
+ * create starts clean instead of overlaying the previous graph.
56
+ */
57
+ function archivePlanGraph(executionState = null, planGraph = null, {
58
+ reason = "",
59
+ } = {}) {
60
+ if (!executionState || typeof executionState !== "object") return null;
61
+ const live = planGraph && typeof planGraph === "object" ? planGraph : null;
62
+ const graphId = String((live && live.graphId) || "").trim();
63
+ if (!live || !graphId) return null;
64
+
65
+ if (!Array.isArray(executionState.archivedPlans)) {
66
+ executionState.archivedPlans = [];
67
+ }
68
+ const archived = {
69
+ ...cloneJson(live),
70
+ archivedAt: new Date().toISOString(),
71
+ archiveReason: String(reason || "").trim() || "archived",
72
+ };
73
+ executionState.archivedPlans.push(archived);
74
+ if (executionState.archivedPlans.length > MAX_ARCHIVED_PLANS) {
75
+ executionState.archivedPlans = executionState.archivedPlans.slice(-MAX_ARCHIVED_PLANS);
76
+ }
77
+ if (executionState.graphs && typeof executionState.graphs === "object") {
78
+ delete executionState.graphs[graphId];
79
+ }
80
+ return archived;
81
+ }
82
+
83
+ function buildPlanCompletionSummary(planGraph = null, advance = null) {
84
+ const pg = planGraph && typeof planGraph === "object" ? planGraph : {};
85
+ const nodes = Array.isArray(pg.nodes) ? pg.nodes : [];
86
+ const tasks = nodes.filter((node) => node && (node.type === "task" || !node.generated));
87
+ const succeeded = tasks.filter((n) => n.status === "succeeded").length;
88
+ const failed = tasks.filter((n) => (
89
+ n.status === "failed" || n.status === "blocked" || n.status === "cancelled"
90
+ )).length;
91
+ const lines = [
92
+ `Plan completed${pg.graphId ? ` (${pg.graphId})` : ""}.`,
93
+ ];
94
+ if (pg.objective) lines.push(`Objective: ${String(pg.objective).slice(0, 240)}`);
95
+ lines.push(`Tasks: ${succeeded} succeeded, ${failed} failed/blocked, ${tasks.length} total.`);
96
+ for (const node of tasks.slice(0, 12)) {
97
+ const bit = (node.result && node.result.summary)
98
+ || node.error
99
+ || node.status
100
+ || "";
101
+ lines.push(
102
+ `- ${node.id} [${node.status || "pending"}]`
103
+ + (bit ? `: ${String(bit).slice(0, 160)}` : ""),
104
+ );
105
+ }
106
+ const executed = advance && Array.isArray(advance.executedNodes) ? advance.executedNodes : [];
107
+ if (executed.length > 0 && tasks.length === 0) {
108
+ for (const entry of executed.slice(0, 8)) {
109
+ lines.push(`- ${entry.id}: ${entry.summary || entry.status || "done"}`);
110
+ }
111
+ }
112
+ lines.push("Summarize the outcome for the user. The active plan has been cleared; use plan_graph create for a new objective.");
113
+ return lines.join("\n");
114
+ }
115
+
116
+ /**
117
+ * On graph_terminal for the primary Agent Loop plan: archive, clear primary
118
+ * slot, and exit auto Plan Mode. Child TaskLoop graphs stay in graphs[].
119
+ */
120
+ function finalizeTerminalPrimaryPlan(executionState = null, planGraph = null, advance = null) {
121
+ const pg = planGraph && typeof planGraph === "object" ? planGraph : null;
122
+ if (!pg || !pg.graphId) return null;
123
+ if (!advance || String(advance.yieldReason || "") !== "graph_terminal") return null;
124
+
125
+ const completionSummary = buildPlanCompletionSummary(pg, advance);
126
+ const archived = archivePlanGraph(executionState, pg, { reason: "graph_terminal" });
127
+ executionState.planGraph = emptyPlanGraphState();
128
+ executionState.mode = "single_action";
129
+
130
+ try {
131
+ const { getPlanModeSource, setPlanMode } = require("./planMode");
132
+ if (getPlanModeSource(executionState) === "auto") {
133
+ setPlanMode(executionState, false, { reason: "plan completed" });
134
+ }
135
+ } catch {
136
+ // planMode is optional for pure unit callers
137
+ }
138
+
139
+ return {
140
+ planCompleted: true,
141
+ archivedGraphId: (archived && archived.graphId) || pg.graphId,
142
+ completionSummary,
143
+ planMode: Boolean(executionState.planMode),
144
+ };
145
+ }
146
+
51
147
  function ensurePlanGraphState(executionState = null) {
52
148
  const state = executionState && typeof executionState === "object"
53
149
  ? executionState
@@ -614,42 +710,73 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
614
710
  }
615
711
  if (advanced.advance) advance = advanced.advance;
616
712
  }
617
- const live = executionState.planGraph;
618
- const { readyNodes, waitingNodes } = summarizeReadyWaiting(live.nodes);
713
+ const completedGraph = executionState.planGraph;
714
+ const terminal = selected.isPrimary
715
+ ? finalizeTerminalPrimaryPlan(executionState, completedGraph, advance)
716
+ : null;
717
+ const live = terminal
718
+ ? completedGraph
719
+ : executionState.planGraph;
720
+ const { readyNodes, waitingNodes } = summarizeReadyWaiting(
721
+ terminal ? [] : (live.nodes || []),
722
+ );
619
723
  const payload = controlResult.ok
620
724
  ? accepted({
621
- graphId: live.graphId || "",
622
- commandRevision: Number(live.specRevision) || 0,
623
- stateRevision: Number(live.stateRevision) || 0,
624
- revision: Number(live.specRevision) || 0,
725
+ graphId: terminal ? "" : (live.graphId || ""),
726
+ commandRevision: terminal ? 0 : (Number(live.specRevision) || 0),
727
+ stateRevision: terminal ? 0 : (Number(live.stateRevision) || 0),
728
+ revision: terminal ? 0 : (Number(live.specRevision) || 0),
625
729
  control: controlResult,
626
- summary: advance.waitingFor
627
- ? `waiting on ${advance.waitingFor.type}:${advance.waitingFor.id || ""}`
628
- : (advance.yieldReason || "control actions applied"),
629
- changes: { nodesAdded: [], nodesUpdated: [] },
730
+ summary: terminal
731
+ ? terminal.completionSummary
732
+ : (advance.waitingFor
733
+ ? `waiting on ${advance.waitingFor.type}:${advance.waitingFor.id || ""}`
734
+ : (advance.yieldReason || "control actions applied")),
735
+ changes: {
736
+ nodesAdded: [],
737
+ nodesUpdated: [],
738
+ ...(terminal ? { archivedGraphId: terminal.archivedGraphId } : {}),
739
+ },
630
740
  nodesAdded: [],
631
741
  nodesUpdated: [],
632
742
  readyNodes,
633
743
  waitingNodes,
634
- waitingFor: live.waitingFor || null,
744
+ waitingFor: terminal ? null : (live.waitingFor || null),
635
745
  advance,
636
- planView: projectPlanView(live),
746
+ planView: terminal ? [] : projectPlanView(live),
637
747
  validationWarnings: [],
748
+ ...(terminal ? {
749
+ planCompleted: true,
750
+ archivedGraphId: terminal.archivedGraphId,
751
+ completionSummary: terminal.completionSummary,
752
+ } : {}),
638
753
  })
639
754
  : rejected(controlResult.errors || [{
640
755
  code: "CONTROL_REJECTED",
641
756
  message: "one or more control actions rejected",
642
757
  }], { control: controlResult });
643
- if (commandId && payload.status === "accepted") {
758
+ if (commandId && payload.status === "accepted" && !terminal) {
644
759
  cacheCommand(executionState.planGraph, commandId, payload);
645
760
  }
761
+ restorePrimaryGraph();
646
762
  return { ...payload, executionState, modelPayload: payload, ok: payload.status === "accepted" };
647
763
  }
648
764
 
649
765
  if (operation === "cancel_graph" || operation === "clear") {
650
766
  const previousId = planGraph.graphId || "";
767
+ if (previousId) {
768
+ archivePlanGraph(executionState, planGraph, { reason: "cancel_graph" });
769
+ }
651
770
  executionState.planGraph = emptyPlanGraphState();
652
771
  executionState.mode = "single_action";
772
+ try {
773
+ const { getPlanModeSource, setPlanMode } = require("./planMode");
774
+ if (getPlanModeSource(executionState) === "auto") {
775
+ setPlanMode(executionState, false, { reason: "plan cancelled" });
776
+ }
777
+ } catch {
778
+ // ignore
779
+ }
653
780
  const payload = accepted({
654
781
  graphId: "",
655
782
  commandRevision: 0,
@@ -662,6 +789,9 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
662
789
  waitingNodes: [],
663
790
  advance: { status: "completed", yieldReason: "cancelled", executedNodes: [], failedNodes: [] },
664
791
  validationWarnings: [],
792
+ summary: previousId
793
+ ? `Plan ${previousId} cancelled and cleared.`
794
+ : "No active plan to cancel.",
665
795
  });
666
796
  restorePrimaryGraph();
667
797
  return { ...payload, executionState, modelPayload: payload };
@@ -697,15 +827,22 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
697
827
  return rejectWorking(payload);
698
828
  }
699
829
 
700
- const beforeIds = new Set(listNodeIds(planGraph.nodes));
830
+ const beforeIds = operation === "create"
831
+ ? new Set()
832
+ : new Set(listNodeIds(planGraph.nodes));
701
833
  let nextPlan = {
702
834
  id: planGraph.graphId || createPlanId("plan"),
703
835
  objective: planGraph.objective || "",
704
836
  nodes: snapshotNodes(planGraph),
705
837
  };
706
838
  let nodesUpdated = [];
839
+ let replacedGraphId = "";
707
840
 
708
841
  if (operation === "create") {
842
+ if (selected.isPrimary && planGraph.graphId) {
843
+ replacedGraphId = String(planGraph.graphId);
844
+ archivePlanGraph(executionState, planGraph, { reason: "replaced_by_create" });
845
+ }
709
846
  const graphSource = command.graph || {};
710
847
  if (Array.isArray(graphSource.nodes)) {
711
848
  nextPlan = normalizePlanGraph({
@@ -717,6 +854,11 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
717
854
  nextPlan.nodes = stripModelStatuses(nextPlan.nodes);
718
855
  }
719
856
  if (!nextPlan.id) nextPlan.id = createPlanId("plan");
857
+ // Prefer a fresh id when replacing a live plan so the new graph does not
858
+ // collide with the archived one in graphs[].
859
+ if (replacedGraphId && nextPlan.id === replacedGraphId) {
860
+ nextPlan.id = createPlanId("plan");
861
+ }
720
862
  nodesUpdated = listNodeIds(nextPlan.nodes);
721
863
  // Parent graphs are owned by the agent loop.
722
864
  const { agentLoopOwner } = require("../runtime/graphOwner");
@@ -798,9 +940,10 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
798
940
  }
799
941
 
800
942
  // Prefer the patched node list (includes aggregate expand status).
943
+ // create starts clean — do not inherit statuses from a replaced graph.
801
944
  const preferredNodes = applyStatusesFromStore(
802
945
  (Array.isArray(nextPlan.nodes) ? nextPlan.nodes : []).map((node) => normalizePlanNode(node, node.id)),
803
- planGraph.nodes,
946
+ operation === "create" ? [] : planGraph.nodes,
804
947
  { preferSourceStatus: operation === "patch" },
805
948
  );
806
949
 
@@ -831,28 +974,53 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
831
974
  }
832
975
  }
833
976
 
834
- const specRevision = (Number(planGraph.specRevision) || 0) + 1;
835
- executionState.planGraph = {
836
- ...planGraph,
837
- graphId: preferredCompile.planId || nextPlan.id,
838
- specRevision,
839
- stateRevision: Number(planGraph.stateRevision) || 0,
840
- revision: specRevision,
841
- objective: preferredCompile.objective || nextPlan.objective || "",
842
- failurePolicy: preferredCompile.failurePolicy
843
- || nextPlan.failurePolicy
844
- || planGraph.failurePolicy
845
- || "continue_independent",
846
- nodes: mergedNodes,
847
- outputs: operation === "create" ? {} : { ...(planGraph.outputs || {}) },
848
- waitingFor: null,
849
- lastStoppedAt: "",
850
- lastYieldReason: "",
851
- commandLog: planGraph.commandLog || {},
852
- owner: nextPlan.owner || planGraph.owner || null,
853
- parentGraphId: planGraph.parentGraphId || "",
854
- parentNodeId: planGraph.parentNodeId || "",
855
- };
977
+ const specRevision = operation === "create"
978
+ ? 1
979
+ : (Number(planGraph.specRevision) || 0) + 1;
980
+ if (operation === "create") {
981
+ executionState.planGraph = {
982
+ ...emptyPlanGraphState(),
983
+ graphId: preferredCompile.planId || nextPlan.id,
984
+ specRevision,
985
+ stateRevision: 0,
986
+ revision: specRevision,
987
+ objective: preferredCompile.objective || nextPlan.objective || "",
988
+ failurePolicy: preferredCompile.failurePolicy
989
+ || nextPlan.failurePolicy
990
+ || "continue_independent",
991
+ nodes: mergedNodes,
992
+ outputs: {},
993
+ waitingFor: null,
994
+ lastStoppedAt: "",
995
+ lastYieldReason: "",
996
+ commandLog: {},
997
+ owner: nextPlan.owner || null,
998
+ parentGraphId: "",
999
+ parentNodeId: "",
1000
+ };
1001
+ } else {
1002
+ executionState.planGraph = {
1003
+ ...planGraph,
1004
+ graphId: preferredCompile.planId || nextPlan.id,
1005
+ specRevision,
1006
+ stateRevision: Number(planGraph.stateRevision) || 0,
1007
+ revision: specRevision,
1008
+ objective: preferredCompile.objective || nextPlan.objective || "",
1009
+ failurePolicy: preferredCompile.failurePolicy
1010
+ || nextPlan.failurePolicy
1011
+ || planGraph.failurePolicy
1012
+ || "continue_independent",
1013
+ nodes: mergedNodes,
1014
+ outputs: { ...(planGraph.outputs || {}) },
1015
+ waitingFor: null,
1016
+ lastStoppedAt: "",
1017
+ lastYieldReason: "",
1018
+ commandLog: planGraph.commandLog || {},
1019
+ owner: nextPlan.owner || planGraph.owner || null,
1020
+ parentGraphId: planGraph.parentGraphId || "",
1021
+ parentNodeId: planGraph.parentNodeId || "",
1022
+ };
1023
+ }
856
1024
  if (!executionState.graphs || typeof executionState.graphs !== "object") {
857
1025
  executionState.graphs = {};
858
1026
  }
@@ -890,45 +1058,80 @@ function runPlanGraphCommand(commandInput = {}, options = {}) {
890
1058
  ...advanced.planGraph,
891
1059
  specRevision,
892
1060
  revision: specRevision,
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 || "",
1061
+ commandLog: operation === "create" ? {} : (planGraph.commandLog || {}),
1062
+ owner: advanced.planGraph.owner || (operation === "create" ? nextPlan.owner : planGraph.owner) || null,
1063
+ parentGraphId: operation === "create"
1064
+ ? ""
1065
+ : (advanced.planGraph.parentGraphId || planGraph.parentGraphId || ""),
1066
+ parentNodeId: operation === "create"
1067
+ ? ""
1068
+ : (advanced.planGraph.parentNodeId || planGraph.parentNodeId || ""),
897
1069
  };
898
1070
  }
899
1071
  if (advanced.advance) advance = advanced.advance;
900
1072
  }
901
1073
 
902
- const live = executionState.planGraph;
903
- const { readyNodes, waitingNodes } = summarizeReadyWaiting(live.nodes);
1074
+ const completedGraph = executionState.planGraph;
1075
+ const terminal = selected.isPrimary
1076
+ ? finalizeTerminalPrimaryPlan(executionState, completedGraph, advance)
1077
+ : null;
1078
+ if (!terminal && !selected.isPrimary && advance.yieldReason === "graph_terminal") {
1079
+ // Child TaskLoop graph finished — keep snapshot; parent stays active.
1080
+ const childId = String(completedGraph.graphId || "").trim();
1081
+ if (childId) {
1082
+ executionState.graphs[childId] = {
1083
+ ...completedGraph,
1084
+ completedAt: new Date().toISOString(),
1085
+ lastYieldReason: "graph_terminal",
1086
+ };
1087
+ }
1088
+ }
1089
+
1090
+ const live = terminal ? completedGraph : executionState.planGraph;
1091
+ const { readyNodes, waitingNodes } = summarizeReadyWaiting(
1092
+ terminal ? [] : (live.nodes || []),
1093
+ );
904
1094
  const payload = accepted({
905
- graphId: live.graphId,
906
- commandRevision: live.specRevision,
907
- stateRevision: live.stateRevision,
908
- revision: live.specRevision,
1095
+ graphId: terminal ? "" : live.graphId,
1096
+ commandRevision: terminal ? 0 : live.specRevision,
1097
+ stateRevision: terminal ? 0 : live.stateRevision,
1098
+ revision: terminal ? 0 : live.specRevision,
909
1099
  changes: {
910
1100
  nodesAdded,
911
1101
  nodesUpdated: Array.from(new Set(nodesUpdated)),
1102
+ ...(replacedGraphId ? { replacedGraphId } : {}),
1103
+ ...(terminal ? { archivedGraphId: terminal.archivedGraphId } : {}),
912
1104
  },
913
1105
  nodesAdded,
914
1106
  nodesUpdated: Array.from(new Set(nodesUpdated)),
915
1107
  readyNodes,
916
1108
  waitingNodes,
917
- waitingFor: live.waitingFor || null,
918
- stoppedAt: live.lastStoppedAt || "",
1109
+ waitingFor: terminal ? null : (live.waitingFor || null),
1110
+ stoppedAt: terminal ? "graph_terminal" : (live.lastStoppedAt || ""),
919
1111
  advance,
920
- planView: projectPlanView(live),
1112
+ planView: terminal ? [] : projectPlanView(live),
921
1113
  validationWarnings: preferredCompile.warnings || [],
922
- summary: advance.waitingFor
923
- ? `waiting on ${advance.waitingFor.type}:${advance.waitingFor.id || ""}`
924
- : (advance.yieldReason || "graph updated"),
1114
+ summary: terminal
1115
+ ? terminal.completionSummary
1116
+ : (advance.waitingFor
1117
+ ? `waiting on ${advance.waitingFor.type}:${advance.waitingFor.id || ""}`
1118
+ : (advance.yieldReason || "graph updated")),
1119
+ ...(replacedGraphId ? { replacedGraphId } : {}),
1120
+ ...(terminal ? {
1121
+ planCompleted: true,
1122
+ archivedGraphId: terminal.archivedGraphId,
1123
+ completionSummary: terminal.completionSummary,
1124
+ } : {}),
925
1125
  });
926
1126
 
927
- cacheCommand(executionState.planGraph, commandId, payload);
1127
+ if (!terminal) {
1128
+ cacheCommand(executionState.planGraph, commandId, payload);
1129
+ }
928
1130
 
929
1131
  // Agent Loop create enables Plan Mode; TaskLoop child graphs must not.
1132
+ // Skip when create immediately ran to terminal (already exited Plan Mode).
930
1133
  let planModeEntered = null;
931
- if (operation === "create" && payload.status === "accepted") {
1134
+ if (operation === "create" && payload.status === "accepted" && !terminal) {
932
1135
  const ownerKind = String(
933
1136
  (executionState.planGraph && executionState.planGraph.owner && executionState.planGraph.owner.kind)
934
1137
  || "agent_loop",
@@ -968,6 +1171,9 @@ function activePlanRequiresExpansion(planGraph = {}) {
968
1171
  module.exports = {
969
1172
  emptyPlanGraphState,
970
1173
  ensurePlanGraphState,
1174
+ archivePlanGraph,
1175
+ buildPlanCompletionSummary,
1176
+ finalizeTerminalPrimaryPlan,
971
1177
  normalizePlanGraphCommand,
972
1178
  normalizeExpandOp,
973
1179
  runPlanGraphCommand,
@@ -54,6 +54,7 @@ function buildImmutablePrefix() {
54
54
  "- Turning Plan Mode off does not cancel an existing graph or running TaskRuns. Cancel with task_run (standalone) or plan_graph operation=cancel_graph / control.cancel_task (graph-bound).",
55
55
  "- When the user enables Plan Mode and no active graph exists, create a plan_graph before performing side effects.",
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
+ "- When the last plan node finishes (graph_terminal), Runtime archives and clears the active plan, exits auto Plan Mode, and returns a completion summary — narrate that summary to the user. Start a new objective with plan_graph create (create replaces any leftover graph); do not patch a finished plan for a new goal.",
57
58
  "- 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
59
  "- 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
60
  "- 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.",
@@ -58,6 +58,14 @@ function hasPendingUserPrompts(executionState = null) {
58
58
  return state.pendingUserPrompts.length > 0;
59
59
  }
60
60
 
61
+ /** Peek pending nudge texts without draining (for TUI queue banner). */
62
+ function listPendingUserPrompts(executionState = null) {
63
+ const state = ensurePendingUserPrompts(executionState);
64
+ return state.pendingUserPrompts
65
+ .map((entry) => String(entry && entry.text || "").trim())
66
+ .filter(Boolean);
67
+ }
68
+
61
69
  function shouldFrameAsUserReminder(executionState = null) {
62
70
  if (!executionState || typeof executionState !== "object") return false;
63
71
  if (executionState.planMode === true) return true;
@@ -131,7 +139,8 @@ function shouldAutoContinuePlan(executionState = null) {
131
139
 
132
140
  /**
133
141
  * 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.
142
+ * plan is still waiting on a task. Must NOT reuse the User reminder label —
143
+ * that is reserved for real mid-run user text and pollutes transcript/TUI.
135
144
  */
136
145
  function buildPlanAutoContinueReminder(executionState = null) {
137
146
  const waiting = executionState
@@ -140,14 +149,17 @@ function buildPlanAutoContinueReminder(executionState = null) {
140
149
  ? executionState.planGraph.waitingFor
141
150
  : null;
142
151
  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
- );
152
+ const label = waiting.title || waiting.objective || waiting.reason || waiting.id;
153
+ const lines = [
154
+ "Runtime wake (not a user message): active plan is still waiting.",
155
+ "Continue serving it now via plan_graph "
156
+ + "(expand_node, control.start_task, or control.complete_task as appropriate). "
157
+ + "Do not end the turn with text only while this node is waiting.",
158
+ `Prefer serving the current waiting ${waiting.type || "node"}: ${waiting.id}`
159
+ + (label && label !== waiting.id ? ` — ${label}` : "")
160
+ + ".",
161
+ ];
162
+ return lines.join("\n");
151
163
  }
152
164
 
153
165
  module.exports = {
@@ -156,6 +168,7 @@ module.exports = {
156
168
  drainUserPrompts,
157
169
  clearUserPrompts,
158
170
  hasPendingUserPrompts,
171
+ listPendingUserPrompts,
159
172
  shouldFrameAsUserReminder,
160
173
  formatUserReminderMessage,
161
174
  buildContinuationUserPrompt,
@@ -5,6 +5,7 @@ const Injector = require("../../coordination/bus/inject");
5
5
  const { buildPromptInjectionText } = require("../../coordination/bus/promptEnvelope");
6
6
  const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
7
7
  const { normalizeQueueEnvelope } = require("../../coordination/bus/deliveryQueue");
8
+ const { writeActivityState } = require("../../agents/activity/activityStateWriter");
8
9
 
9
10
  function asState(value = "") {
10
11
  return String(value || "").trim().toLowerCase();
@@ -58,6 +59,14 @@ class DeliveryScheduler {
58
59
  this.emitDelivery = typeof options.emitDelivery === "function"
59
60
  ? options.emitDelivery
60
61
  : async () => {};
62
+ this.markWorking = typeof options.markWorking === "function"
63
+ ? options.markWorking
64
+ : (subscriber) => {
65
+ writeActivityState(this.paths.agentsFile, subscriber, "working", {
66
+ force: true,
67
+ detail: "inject",
68
+ });
69
+ };
61
70
  this.log = typeof options.log === "function" ? options.log : () => {};
62
71
  this.now = typeof options.now === "function" ? options.now : () => Date.now();
63
72
  this.deferWarnAfterMs = positiveMs(options.deferWarnAfterMs, DEFAULT_DEFER_WARN_AFTER_MS);
@@ -224,6 +233,15 @@ class DeliveryScheduler {
224
233
  try {
225
234
  await this.injector.inject(subscriber, injectionText);
226
235
  queue.completeClaim(claim);
236
+ // Close the idle gate immediately. PTY ActivityDetector will refresh
237
+ // working from output, then quiet-window back to idle; without this
238
+ // stamp a second pending message can slip through on the next tick
239
+ // before any Codex stdout arrives.
240
+ try {
241
+ this.markWorking(subscriber);
242
+ } catch {
243
+ // activity stamp must never undo a successful inject
244
+ }
227
245
  await this.emitDelivery({
228
246
  subscriber,
229
247
  event: envelope,
@@ -732,6 +732,86 @@ function wrapInternalPlainLine(text = "", width = 80) {
732
732
  return rows;
733
733
  }
734
734
 
735
+ // Ink's wrap:"wrap" + <Static> under-counts CJK row height on live appends,
736
+ // so later log writes overpaint the previous line. Pre-wrap by display cells
737
+ // and paint with wrap:"truncate" so each Static item occupies a known height.
738
+ function expandChatLogPhysicalLines(text = "", width = 80) {
739
+ const limit = Math.max(1, Math.floor(Number(width) || 80));
740
+ const source = String(text || "").replace(/\r/g, "");
741
+ if (!source) return [""];
742
+ const out = [];
743
+ for (const line of source.split("\n")) {
744
+ out.push(...wrapInternalPlainLine(line, limit));
745
+ }
746
+ return out.length > 0 ? out : [""];
747
+ }
748
+
749
+ function padDisplayCells(cells = 0) {
750
+ return " ".repeat(Math.max(0, Math.floor(Number(cells) || 0)));
751
+ }
752
+
753
+ function splitUserLogAtMention(bodyText = "") {
754
+ const body = String(bodyText || "");
755
+ const atMatch = body.match(/^@([^\s]+)\s+(.*)$/);
756
+ if (atMatch) {
757
+ return { at: atMatch[1], rest: atMatch[2] || "" };
758
+ }
759
+ return { at: "", rest: body };
760
+ }
761
+
762
+ /**
763
+ * Flatten one chat log row into terminal-width physical lines. Each returned
764
+ * line is a single string (marker/speaker/body already merged) so Ink never
765
+ * has to wrap it — critical for <Static> append-only CJK safety.
766
+ */
767
+ function buildChatLogDisplayLines(row = {}, options = {}) {
768
+ const cols = Math.max(8, Math.floor(Number(options.cols) || 80));
769
+ const continuation = Boolean(options.continuation);
770
+ const groupKind = options.groupKind || row.kind || "plain";
771
+ const kind = row.kind || "plain";
772
+
773
+ if (kind === "spacer") return [" "];
774
+ if (kind === "divider") {
775
+ return [fitPlainLine(` ${compactDividerLabel(row.body || row.bodyText || "")}`, cols)];
776
+ }
777
+ if (kind === "banner") {
778
+ return expandChatLogPhysicalLines(stripInternalLogMarkup(row.bodyText || row.body || ""), cols)
779
+ .map((line) => fitPlainLine(line, cols));
780
+ }
781
+
782
+ const markerText = continuation
783
+ ? (groupKind === "assistant" || groupKind === "agent" || groupKind === "report" ? " " : " ")
784
+ : String(row.markerText != null ? row.markerText : "");
785
+
786
+ if (kind === "user") {
787
+ const userBody = splitUserLogAtMention(row.bodyText || row.body || "");
788
+ const atPrefix = userBody.at ? `@${userBody.at} ` : "";
789
+ const firstPrefix = `${markerText || "› "}${atPrefix}`;
790
+ const prefixCells = fmt.displayCellWidth(firstPrefix);
791
+ const budget = Math.max(1, cols - prefixCells);
792
+ const chunks = expandChatLogPhysicalLines(userBody.rest, budget);
793
+ const contPad = padDisplayCells(prefixCells);
794
+ return chunks.map((chunk, idx) => {
795
+ const line = idx === 0 ? `${firstPrefix}${chunk}` : `${contPad}${chunk}`;
796
+ return fitPlainLine(line, cols);
797
+ });
798
+ }
799
+
800
+ const speakerPrefix = (!continuation && row.speaker)
801
+ ? `${row.speaker} · `
802
+ : "";
803
+ const head = `${markerText}${speakerPrefix}`;
804
+ const headCells = fmt.displayCellWidth(head);
805
+ const budget = Math.max(1, cols - headCells);
806
+ const bodyPlain = stripInternalLogMarkup(row.bodyText != null ? row.bodyText : (row.body || ""));
807
+ const chunks = expandChatLogPhysicalLines(bodyPlain, budget);
808
+ const contPad = padDisplayCells(headCells);
809
+ return chunks.map((chunk, idx) => {
810
+ const line = idx === 0 ? `${head}${chunk}` : `${contPad}${chunk}`;
811
+ return fitPlainLine(line, cols);
812
+ });
813
+ }
814
+
735
815
  function classifyInternalLogLine(line = "") {
736
816
  const raw = stripInternalLogMarkup(line).replace(/\r/g, "");
737
817
  if (!raw) return { kind: "spacer", text: "", markdown: false, bold: false };
@@ -3580,16 +3660,44 @@ function createChatApp({ React, ink, props, interactive = true }) {
3580
3660
  return null;
3581
3661
  }
3582
3662
 
3583
- const renderUserLogBody = (bodyText = "") => {
3584
- const body = String(bodyText || "");
3585
- const atMatch = body.match(/^@([^\s]+)\s+(.*)$/);
3586
- if (atMatch) {
3587
- return {
3588
- at: atMatch[1],
3589
- rest: atMatch[2] || "",
3590
- };
3663
+ const renderChatLogLines = (row, { key, continuation = false, groupKind = "", marginTop = 0, marginBottom = 0 } = {}) => {
3664
+ const colors = CHAT_LOG_ROW_PALETTE[row.kind] || CHAT_LOG_ROW_PALETTE.plain;
3665
+ const cols = Math.max(20, size.cols || 80);
3666
+ const lines = buildChatLogDisplayLines(row, {
3667
+ continuation,
3668
+ groupKind: groupKind || row.kind,
3669
+ cols,
3670
+ });
3671
+ const textProps = {
3672
+ color: row.kind === "user" ? "green" : colors.body,
3673
+ bold: Boolean(
3674
+ row.kind === "user"
3675
+ || colors.bold
3676
+ || row.kind === "error"
3677
+ || row.kind === "assistant"
3678
+ || row.kind === "banner"
3679
+ ),
3680
+ wrap: "truncate",
3681
+ };
3682
+ if (colors.dim) textProps.dimColor = true;
3683
+ if (row.kind === "agent" || row.kind === "report") {
3684
+ textProps.color = colors.speaker;
3591
3685
  }
3592
- return { at: "", rest: body };
3686
+ if (lines.length <= 1) {
3687
+ return h(Box, { key, width: "100%", marginTop, marginBottom },
3688
+ h(Text, textProps, (lines[0] != null ? lines[0] : " ") || " "));
3689
+ }
3690
+ return h(Box, {
3691
+ key,
3692
+ flexDirection: "column",
3693
+ width: "100%",
3694
+ marginTop,
3695
+ marginBottom,
3696
+ },
3697
+ ...lines.map((line, idx) => h(Text, {
3698
+ key: `${key}-r${idx}`,
3699
+ ...textProps,
3700
+ }, line || " ")));
3593
3701
  };
3594
3702
 
3595
3703
  const renderChatLogEntry = (entry, group) => {
@@ -3598,53 +3706,12 @@ function createChatApp({ React, ink, props, interactive = true }) {
3598
3706
  if (row.kind === "spacer") {
3599
3707
  return h(Text, { key, color: "gray" }, " ");
3600
3708
  }
3601
- const colors = CHAT_LOG_ROW_PALETTE[row.kind] || CHAT_LOG_ROW_PALETTE.plain;
3602
- if (row.kind === "divider") {
3603
- return h(Box, { key, marginBottom: 1 },
3604
- h(Text, { color: colors.body, wrap: "truncate" }, ` ${compactDividerLabel(row.body)}`),
3605
- );
3606
- }
3607
- if (row.kind === "banner") {
3608
- return h(Box, { key },
3609
- h(Text, { color: colors.body, bold: true, wrap: "truncate" }, row.body),
3610
- );
3611
- }
3612
- if (row.kind === "user") {
3613
- const userBody = renderUserLogBody(row.bodyText);
3614
- return h(Box, { key, width: "100%", marginBottom: 1, alignItems: "flex-start" },
3615
- h(Text, { color: "green", bold: true }, row.markerText || "› "),
3616
- userBody.at
3617
- ? h(Text, { color: "magenta", bold: true }, `@${userBody.at} `)
3618
- : null,
3619
- h(Text, { color: "green", bold: true, wrap: "wrap" }, userBody.rest),
3620
- );
3621
- }
3622
- const markerText = entry && entry.continuation
3623
- ? (group && (group.kind === "assistant" || group.kind === "agent" || group.kind === "report") ? " " : " ")
3624
- : row.markerText;
3625
- const bodyProps = {
3626
- color: colors.body,
3627
- wrap: "wrap",
3628
- };
3629
- if (colors.dim) bodyProps.dimColor = true;
3630
- // Pin the gutter glyph to the first text line; default Yoga stretch/center
3631
- // floats markers above wrapped speaker · body rows.
3632
- return h(Box, { key, width: "100%", alignItems: "flex-start" },
3633
- h(Text, {
3634
- color: colors.marker,
3635
- bold: row.kind === "error" || row.kind === "assistant",
3636
- dimColor: Boolean(colors.dim),
3637
- }, markerText),
3638
- h(Text, bodyProps,
3639
- row.speaker && !(entry && entry.continuation)
3640
- ? h(Text, { color: colors.speaker, bold: colors.bold }, row.speaker)
3641
- : null,
3642
- row.speaker && !(entry && entry.continuation)
3643
- ? h(Text, { color: "gray" }, " · ")
3644
- : null,
3645
- row.bodyText,
3646
- ),
3647
- );
3709
+ return renderChatLogLines(row, {
3710
+ key,
3711
+ continuation: Boolean(entry && entry.continuation),
3712
+ groupKind: group && group.kind ? group.kind : row.kind,
3713
+ marginBottom: row.kind === "user" || row.kind === "divider" ? 1 : 0,
3714
+ });
3648
3715
  };
3649
3716
 
3650
3717
  const renderChatLogGroup = (group) => {
@@ -3664,64 +3731,23 @@ function createChatApp({ React, ink, props, interactive = true }) {
3664
3731
  ...entries.map((entry) => renderChatLogEntry(entry, group)));
3665
3732
  };
3666
3733
 
3667
- // Renderer for one finalized (append-only) <Static> log item. Mirrors
3668
- // renderChatLogEntry visually; spacing differs because per-item
3669
- // rendering can't wrap a group in one margin-bottom Box — instead the
3670
- // decoration pass flags `marginBefore` on whatever entry follows a
3671
- // transcript group.
3734
+ // Renderer for one finalized (append-only) <Static> log item. Spacing
3735
+ // uses decorateStaticLogEntry's marginBefore; body text is pre-wrapped to
3736
+ // the terminal width so Ink never wrap:"wrap"s CJK inside Static.
3672
3737
  const renderStaticChatLogItem = (item) => {
3673
3738
  const { row, groupKind, continuation, marginBefore } = item;
3674
3739
  const key = item.entry && item.entry.id ? item.entry.id : `log-${row.body}`;
3675
- const marginTop = marginBefore ? 1 : 0;
3676
3740
  if (row.kind === "spacer") {
3677
- return h(Box, { key, marginTop },
3741
+ return h(Box, { key, marginTop: marginBefore ? 1 : 0 },
3678
3742
  h(Text, { color: "gray" }, " "));
3679
3743
  }
3680
- const colors = CHAT_LOG_ROW_PALETTE[row.kind] || CHAT_LOG_ROW_PALETTE.plain;
3681
- if (row.kind === "divider") {
3682
- return h(Box, { key, marginTop, marginBottom: 1 },
3683
- h(Text, { color: colors.body, wrap: "truncate" }, ` ${compactDividerLabel(row.body)}`),
3684
- );
3685
- }
3686
- if (row.kind === "banner") {
3687
- return h(Box, { key, marginTop },
3688
- h(Text, { color: colors.body, bold: true, wrap: "truncate" }, row.body),
3689
- );
3690
- }
3691
- if (row.kind === "user") {
3692
- const userBody = renderUserLogBody(row.bodyText);
3693
- return h(Box, { key, width: "100%", marginTop, marginBottom: 1, alignItems: "flex-start" },
3694
- h(Text, { color: "green", bold: true }, row.markerText || "› "),
3695
- userBody.at
3696
- ? h(Text, { color: "magenta", bold: true }, `@${userBody.at} `)
3697
- : null,
3698
- h(Text, { color: "green", bold: true, wrap: "wrap" }, userBody.rest),
3699
- );
3700
- }
3701
- const markerText = continuation
3702
- ? (groupKind === "assistant" || groupKind === "agent" || groupKind === "report" ? " " : " ")
3703
- : row.markerText;
3704
- const bodyProps = {
3705
- color: colors.body,
3706
- wrap: "wrap",
3707
- };
3708
- if (colors.dim) bodyProps.dimColor = true;
3709
- return h(Box, { key, width: "100%", marginTop, alignItems: "flex-start" },
3710
- h(Text, {
3711
- color: colors.marker,
3712
- bold: row.kind === "error" || row.kind === "assistant",
3713
- dimColor: Boolean(colors.dim),
3714
- }, markerText),
3715
- h(Text, bodyProps,
3716
- row.speaker && !continuation
3717
- ? h(Text, { color: colors.speaker, bold: colors.bold }, row.speaker)
3718
- : null,
3719
- row.speaker && !continuation
3720
- ? h(Text, { color: "gray" }, " · ")
3721
- : null,
3722
- row.bodyText,
3723
- ),
3724
- );
3744
+ return renderChatLogLines(row, {
3745
+ key,
3746
+ continuation,
3747
+ groupKind,
3748
+ marginTop: marginBefore ? 1 : 0,
3749
+ marginBottom: row.kind === "user" || row.kind === "divider" ? 1 : 0,
3750
+ });
3725
3751
  };
3726
3752
 
3727
3753
  if (state.viewingAgentId) {
@@ -4099,6 +4125,8 @@ module.exports = {
4099
4125
  createInkStreamState,
4100
4126
  createThrottledSender,
4101
4127
  decorateStaticLogEntry,
4128
+ buildChatLogDisplayLines,
4129
+ expandChatLogPhysicalLines,
4102
4130
  bootstrapEnvironment,
4103
4131
  buildDirectBusSendRequest,
4104
4132
  buildPromptIpcRequest,
@@ -96,6 +96,10 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
96
96
  hash: "",
97
97
  }));
98
98
  const [interactionLines, setInteractionLines] = useState([]);
99
+ // Bumps when pendingUserPrompts change so the near-input queue banner
100
+ // re-renders without dumping a chat-log system line.
101
+ const [queueTick, setQueueTick] = useState(0);
102
+ const bumpQueue = useCallback(() => setQueueTick((n) => n + 1), []);
99
103
  const [spinnerTick, setSpinnerTick] = useState(0);
100
104
  const [size, setSize] = useState({ cols: 0, rows: 0 });
101
105
  const [contextMeter, setContextMeter] = useState(() => {
@@ -903,6 +907,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
903
907
  cancelThinkingFlush();
904
908
  thinkingTailRef.current = "";
905
909
  refreshPlanUi();
910
+ bumpQueue();
906
911
  setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
907
912
  }
908
913
  if (streamBuf) {
@@ -942,7 +947,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
942
947
  default:
943
948
  if (result.output) appendLogText(result.output);
944
949
  }
945
- }, [appendLogLine, appendLogText, exit, props, logToolHint, flushActiveMerge, flushTableBuffer, refreshPlanUi]);
950
+ }, [appendLogLine, appendLogText, exit, props, logToolHint, flushActiveMerge, flushTableBuffer, refreshPlanUi, bumpQueue]);
946
951
  // ^ `props` is captured by the createUcodeApp closure on a single mount,
947
952
  // so its reference is stable across renders even though it looks like a
948
953
  // changing dep to React's exhaustive-deps lint.
@@ -1131,7 +1136,19 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1131
1136
 
1132
1137
  // While a native task is in flight, queue an additional user reminder
1133
1138
  // for the next LLM turn instead of starting a second NL task.
1139
+ // Slash commands (/model, /plan, …) must still run immediately — same
1140
+ // rule as the REPL path — otherwise they pollute the nudge queue.
1134
1141
  if (pendingTaskRef.current) {
1142
+ if (/^\//.test(trimmed)) {
1143
+ runChainRef.current = runChainRef.current
1144
+ .then(() => executeLine(modelText, {
1145
+ modelText,
1146
+ logText,
1147
+ preserveNewlines: attachments.length > 0,
1148
+ }))
1149
+ .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : err}`, "error"));
1150
+ return;
1151
+ }
1135
1152
  const { enqueueUserPrompt } = require("../../code/context/userNudge");
1136
1153
  const { emptyExecutionState } = require("../../code/context/executionSegment");
1137
1154
  if (!props.state || typeof props.state !== "object") {
@@ -1141,14 +1158,8 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1141
1158
  if (!props.state.executionState || typeof props.state.executionState !== "object") {
1142
1159
  props.state.executionState = emptyExecutionState();
1143
1160
  }
1144
- const queued = enqueueUserPrompt(props.state.executionState, modelText);
1145
- const reminderPreview = logText.slice(0, 120) + (logText.length > 120 ? "…" : "");
1146
- appendLogText(
1147
- queued.enqueued
1148
- ? `Queued user reminder for next model turn: ${reminderPreview}`
1149
- : "Could not queue user reminder (empty).",
1150
- "system",
1151
- );
1161
+ enqueueUserPrompt(props.state.executionState, modelText);
1162
+ bumpQueue();
1152
1163
  return;
1153
1164
  }
1154
1165
 
@@ -1168,6 +1179,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1168
1179
  appendLogLine,
1169
1180
  flushActiveMerge,
1170
1181
  flushTableBuffer,
1182
+ bumpQueue,
1171
1183
  props.state,
1172
1184
  props.submitUserInteractionAnswer,
1173
1185
  refreshPlanUi,
@@ -1398,6 +1410,25 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1398
1410
  ),
1399
1411
  )
1400
1412
  : null,
1413
+ (() => {
1414
+ void queueTick;
1415
+ let pending = [];
1416
+ try {
1417
+ const { listPendingUserPrompts } = require("../../code/context/userNudge");
1418
+ pending = listPendingUserPrompts(props.state && props.state.executionState);
1419
+ } catch {
1420
+ pending = [];
1421
+ }
1422
+ if (pending.length === 0) return null;
1423
+ const latest = String(pending[pending.length - 1] || "");
1424
+ const more = pending.length > 1 ? ` · +${pending.length - 1}` : "";
1425
+ const preview = latest.length > 72 ? `${latest.slice(0, 72)}…` : latest;
1426
+ return h(Box, { width: "100%" },
1427
+ h(Text, { color: "yellow", wrap: "truncate" },
1428
+ `排队中 · 未发出 · ${preview}${more}`,
1429
+ ),
1430
+ );
1431
+ })(),
1401
1432
  h(Box, { width: "100%" },
1402
1433
  h(MultilineInput, {
1403
1434
  value: draft,
@@ -1457,6 +1488,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1457
1488
  if (props.state && props.state.executionState) {
1458
1489
  clearUserPrompts(props.state.executionState);
1459
1490
  }
1491
+ bumpQueue();
1460
1492
  } catch { /* ignore */ }
1461
1493
  appendLogLine("⚙ Cancellation requested. Stopping the current task...", "system");
1462
1494
  setStatus({