@prestyj/cli 4.3.207 → 4.3.210

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/cli.js +18 -7
  2. package/dist/cli.js.map +1 -1
  3. package/dist/core/process-manager-dev-server-repro.test.js +37 -1
  4. package/dist/core/process-manager-dev-server-repro.test.js.map +1 -1
  5. package/dist/core/process-manager.d.ts +9 -0
  6. package/dist/core/process-manager.d.ts.map +1 -1
  7. package/dist/core/process-manager.js +20 -6
  8. package/dist/core/process-manager.js.map +1 -1
  9. package/dist/core/repomap.js +8 -1
  10. package/dist/core/repomap.js.map +1 -1
  11. package/dist/core/repomap.test.js +32 -0
  12. package/dist/core/repomap.test.js.map +1 -1
  13. package/dist/tools/bash.d.ts.map +1 -1
  14. package/dist/tools/bash.js +2 -51
  15. package/dist/tools/bash.js.map +1 -1
  16. package/dist/tools/enter-plan.d.ts +3 -1
  17. package/dist/tools/enter-plan.d.ts.map +1 -1
  18. package/dist/tools/enter-plan.js +10 -1
  19. package/dist/tools/enter-plan.js.map +1 -1
  20. package/dist/tools/grep.d.ts.map +1 -1
  21. package/dist/tools/grep.js +22 -5
  22. package/dist/tools/grep.js.map +1 -1
  23. package/dist/tools/grep.test.d.ts +2 -0
  24. package/dist/tools/grep.test.d.ts.map +1 -0
  25. package/dist/tools/grep.test.js +20 -0
  26. package/dist/tools/grep.test.js.map +1 -0
  27. package/dist/tools/index.d.ts +9 -0
  28. package/dist/tools/index.d.ts.map +1 -1
  29. package/dist/tools/index.js +1 -1
  30. package/dist/tools/index.js.map +1 -1
  31. package/dist/tools/plan-mode.test.js +22 -0
  32. package/dist/tools/plan-mode.test.js.map +1 -1
  33. package/dist/tools/safe-env.d.ts +2 -0
  34. package/dist/tools/safe-env.d.ts.map +1 -0
  35. package/dist/tools/safe-env.js +52 -0
  36. package/dist/tools/safe-env.js.map +1 -0
  37. package/dist/tools/subagent.d.ts.map +1 -1
  38. package/dist/tools/subagent.js +1 -2
  39. package/dist/tools/subagent.js.map +1 -1
  40. package/dist/tools/web-fetch.d.ts +5 -0
  41. package/dist/tools/web-fetch.d.ts.map +1 -1
  42. package/dist/tools/web-fetch.js +12 -1
  43. package/dist/tools/web-fetch.js.map +1 -1
  44. package/dist/tools/web-fetch.test.js +9 -0
  45. package/dist/tools/web-fetch.test.js.map +1 -1
  46. package/dist/ui/App.d.ts +11 -3
  47. package/dist/ui/App.d.ts.map +1 -1
  48. package/dist/ui/App.js +224 -215
  49. package/dist/ui/App.js.map +1 -1
  50. package/dist/ui/app-state-persistence.test.js +60 -4
  51. package/dist/ui/app-state-persistence.test.js.map +1 -1
  52. package/dist/ui/goal-events.test.js +19 -0
  53. package/dist/ui/goal-events.test.js.map +1 -1
  54. package/dist/ui/goal-overlay.test.js +30 -0
  55. package/dist/ui/goal-overlay.test.js.map +1 -1
  56. package/dist/ui/hooks/useAgentLoop.d.ts +3 -1
  57. package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
  58. package/dist/ui/hooks/useAgentLoop.js +10 -2
  59. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  60. package/dist/ui/render.d.ts +15 -0
  61. package/dist/ui/render.d.ts.map +1 -1
  62. package/dist/ui/render.js +2 -0
  63. package/dist/ui/render.js.map +1 -1
  64. package/dist/ui/scroll-stabilization.test.js +2 -2
  65. package/dist/ui/scroll-stabilization.test.js.map +1 -1
  66. package/dist/ui/slash-command-images.test.js +10 -0
  67. package/dist/ui/slash-command-images.test.js.map +1 -1
  68. package/package.json +6 -5
package/dist/ui/App.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
3
- import { Box, Text, Static, useStdout } from "ink";
3
+ import { Box, Text, Static } from "ink";
4
4
  import { useTerminalSize } from "./hooks/useTerminalSize.js";
5
5
  import { useDoublePress } from "./hooks/useDoublePress.js";
6
6
  import { useTaskBarStore, useTaskBarPolling, focusTaskBar, exitTaskBar, expandTaskBar, collapseTaskBar, navigateTaskBar, killTask, } from "./stores/taskbar-store.js";
@@ -59,8 +59,9 @@ import { getLatestUserText, injectRepoMapContextMessages, stripRepoMapContextMes
59
59
  import { extractPlanSteps, findCompletedMarkers, markStepsCompleted, segmentDisplayText, stripDoneMarkers, } from "../utils/plan-steps.js";
60
60
  import { getMCPServers } from "../core/mcp/index.js";
61
61
  import { trimFlushedItems, flushOnTurnText, flushOnTurnEnd, flushOverflow, } from "./live-item-flush.js";
62
- import { appendGoalDecision, appendGoalEvidence, formatGoalBlockingPrerequisites, goalHasBlockingPrerequisites, loadGoalRuns, reconcileActiveGoalRuns, projectDir, summarizeGoalCounts, summarizeGoalCountsFromRuns, updateGoalTask, upsertGoalRun, } from "../core/goal-store.js";
62
+ import { appendGoalDecision, appendGoalEvidence, formatGoalBlockingPrerequisites, goalHasBlockingPrerequisites, loadGoalRuns, reconcileActiveGoalRuns, summarizeGoalCounts, summarizeGoalCountsFromRuns, updateGoalTask, upsertGoalRun, } from "../core/goal-store.js";
63
63
  import { canCompleteGoalRun, decideGoalNextAction, shouldCreateVerifierFixTask, } from "../core/goal-controller.js";
64
+ import { runGoalVerifierCommand } from "../core/goal-verifier.js";
64
65
  import { listGoalWorkers, startGoalWorker, stopGoalWorker, subscribeGoalWorkerCompletions, } from "../core/goal-worker.js";
65
66
  import { formatGoalVerifierCompletionEvent, formatGoalWorkerCompletionEvent, isGoalSyntheticEvent, parseGoalSyntheticEvent, } from "./goal-events.js";
66
67
  /** Where ezcoder bugs should be reported. Surfaced in the guidance line. */
@@ -189,9 +190,7 @@ function summarizeGoalCompletion(summary) {
189
190
  return statusLine ?? changedLine ?? verificationLine ?? lines[0];
190
191
  }
191
192
  function formatGoalWorkerFinishedTitle(taskTitle, status) {
192
- return status === "done"
193
- ? `Worker finished: ${taskTitle}. Reporting back.`
194
- : `Worker failed: ${taskTitle}. Reporting back.`;
193
+ return status === "done" ? `Done: ${taskTitle}` : `Failed: ${taskTitle}`;
195
194
  }
196
195
  function countGoalTasksByStatus(tasks, status) {
197
196
  return tasks.filter((task) => task.status === status).length;
@@ -298,12 +297,11 @@ export function formatGoalTerminalProgress(run) {
298
297
  return null;
299
298
  }
300
299
  }
301
- export function shouldHideHistoryForOverlayView(isOverlayView, isAgentRunning) {
302
- // Overlay panes should be clean, full-screen views like Tasks/Plans/Skills.
303
- // When the agent is idle, pane open/close goes through resetUI(), so history
304
- // remains in sessionStore and is reprinted after the pane closes. While the
305
- // agent is running we keep Static mounted because resetUI would abort the run.
306
- return isOverlayView && !isAgentRunning;
300
+ export function shouldHideHistoryForOverlayView(_isOverlayView, _isAgentRunning) {
301
+ // Ink Static is append-only. Passing [] for overlay panes rewrites the Static
302
+ // accumulator and can destroy scrollback when the pane closes. Keep history
303
+ // mounted and let overlays render below it.
304
+ return false;
307
305
  }
308
306
  export function shouldStabilizeOverlayPaneRerender({ overlayPane, isAgentRunning, }) {
309
307
  return isAgentRunning && (overlayPane === "goal" || overlayPane === "plan");
@@ -321,8 +319,8 @@ export function getScrollStabilizationDecision({ isUserScrolled, hasNewOutput, h
321
319
  export function isTallLiveUserMessage(text, rows) {
322
320
  return text.split("\n").length > Math.max(8, Math.floor(rows * 0.6));
323
321
  }
324
- export function getStaticHistoryKey({ resizeKey, staticKey, }) {
325
- return `${resizeKey}-${staticKey}`;
322
+ export function getStaticHistoryKey({ resizeKey }) {
323
+ return `${resizeKey}`;
326
324
  }
327
325
  // flushOnTurnText, flushOnTurnEnd are imported from ./live-item-flush.ts
328
326
  /** Check whether an item is still active (running spinner, pending result). */
@@ -481,11 +479,29 @@ function markTaskInProgress(cwd, taskId) {
481
479
  // ignore
482
480
  }
483
481
  }
482
+ /**
483
+ * Mark a task as done in the on-disk tasks.json. Used by run-all auto-advance
484
+ * so the in-flight task is recorded as completed even when the agent forgets
485
+ * to call the `tasks` tool. Errors are swallowed — if the file is missing or
486
+ * the id is stale, the next auto-advance step still proceeds correctly.
487
+ */
488
+ function markTaskDone(cwd, taskId) {
489
+ try {
490
+ const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 16);
491
+ const filePath = join(homedir(), ".ezcoder-tasks", "projects", hash, "tasks.json");
492
+ const data = readFileSync(filePath, "utf-8");
493
+ const tasks = JSON.parse(data);
494
+ const updated = tasks.map((t) => (t.id === taskId ? { ...t, status: "done" } : t));
495
+ writeFileSync(filePath, JSON.stringify(updated, null, 2) + "\n", "utf-8");
496
+ }
497
+ catch {
498
+ // ignore
499
+ }
500
+ }
484
501
  // ── App Component ──────────────────────────────────────────
485
502
  export function App(props) {
486
503
  const theme = useTheme();
487
504
  const switchTheme = useSetTheme();
488
- const { stdout } = useStdout();
489
505
  const { columns, resizeKey } = useTerminalSize();
490
506
  // Hoisted before terminal title hook so it can reference them
491
507
  const [lastUserMessage, setLastUserMessage] = useState("");
@@ -537,6 +553,11 @@ export function App(props) {
537
553
  // remount that startTask() triggers between tasks.
538
554
  const [runAllTasks, setRunAllTasks] = useState(props.sessionStore?.runAllTasks ?? false);
539
555
  const runAllTasksRef = useRef(props.sessionStore?.runAllTasks ?? false);
556
+ // Task id of the run-all task currently driving the agent. Tracked so the
557
+ // run-all `onDone` auto-advance can mark the just-finished task as done
558
+ // even when the agent forgets to call the tasks tool. Mirrored through
559
+ // sessionStore so the value survives the resetUI() remount between tasks.
560
+ const currentTaskIdRef = useRef(props.sessionStore?.currentTaskId ?? null);
540
561
  const startTaskRef = useRef(() => { });
541
562
  const agentRunningRef = useRef(false);
542
563
  const runningGoalIdsRef = useRef(new Set());
@@ -547,7 +568,6 @@ export function App(props) {
547
568
  const startPixelFixRef = useRef(() => { });
548
569
  const cwdRef = useRef(props.cwd);
549
570
  const [displayedCwd, setDisplayedCwd] = useState(props.cwd);
550
- const [staticKey, setStaticKey] = useState(0);
551
571
  const [doneStatus, setDoneStatus] = useState(props.sessionStore?.doneStatus ?? null);
552
572
  // Suppress "done" status when a plan overlay is about to open
553
573
  const planOverlayPendingRef = useRef(false);
@@ -906,14 +926,6 @@ export function App(props) {
906
926
  // premature "done" status that fires when the agent loop finishes
907
927
  planOverlayPendingRef.current = true;
908
928
  setTimeout(() => {
909
- // NOTE: this is the one open-overlay path that does NOT remount via
910
- // resetUI. It runs while the agent is still mid-turn (after the
911
- // exit_plan tool returned but before onDone fires), and unmounting
912
- // here would kill the in-flight agent stream. Keep the bare ANSI
913
- // clear; the drift bug is tolerable across just the agent's
914
- // wrap-up turn, and onApprove/onReject both remount cleanly via
915
- // resetUI when the user resolves the plan.
916
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
917
929
  setPlanAutoExpand(true);
918
930
  setOverlay("plan");
919
931
  // Don't clear planOverlayPendingRef here — keep it true until
@@ -928,7 +940,7 @@ export function App(props) {
928
940
  planPath);
929
941
  };
930
942
  }
931
- }, [props.onExitPlanRef, replaceSystemPrompt, stdout]);
943
+ }, [props.onExitPlanRef, replaceSystemPrompt]);
932
944
  const appendMessagesToSession = useCallback(async (sessionPath, messages, startIndex) => {
933
945
  const sm = sessionManagerRef.current;
934
946
  if (!sm)
@@ -1674,7 +1686,7 @@ export function App(props) {
1674
1686
  return remaining;
1675
1687
  });
1676
1688
  }, []),
1677
- onDone: useCallback((durationMs, toolsUsed) => {
1689
+ onDone: useCallback((durationMs, toolsUsed, errored) => {
1678
1690
  log("INFO", "agent", `Agent done`, {
1679
1691
  duration: `${durationMs}ms`,
1680
1692
  toolsUsed: toolsUsed.join(",") || "none",
@@ -1701,17 +1713,43 @@ export function App(props) {
1701
1713
  if (runAllTasksRef.current) {
1702
1714
  setTimeout(() => {
1703
1715
  const cwd = cwdRef.current;
1716
+ // Clean turn: mark the in-flight task done so the row flips to ✓.
1717
+ // Error path: leave it at `in-progress` so the failed task is
1718
+ // visibly distinct from completed ones. The chain still advances
1719
+ // either way — matching how errors were handled before this fix.
1720
+ const finishedId = currentTaskIdRef.current;
1721
+ if (finishedId) {
1722
+ if (!errored)
1723
+ markTaskDone(cwd, finishedId);
1724
+ currentTaskIdRef.current = null;
1725
+ if (props.sessionStore)
1726
+ props.sessionStore.currentTaskId = null;
1727
+ }
1704
1728
  const next = getNextPendingTask(cwd);
1705
1729
  if (next) {
1706
1730
  markTaskInProgress(cwd, next.id);
1731
+ // startTaskRef.current() re-sets taskRunningRef = true before
1732
+ // the next agent run begins, so the brief clear-then-set is
1733
+ // safe — the agent isn't running in between.
1707
1734
  startTaskRef.current(next.title, next.prompt, next.id);
1708
1735
  }
1709
1736
  else {
1710
1737
  setRunAllTasks(false);
1738
+ if (props.taskRunningRef)
1739
+ props.taskRunningRef.current = false;
1711
1740
  log("INFO", "tasks", "Run-all complete — no more pending tasks");
1712
1741
  }
1713
1742
  }, 500);
1714
1743
  }
1744
+ else if (currentTaskIdRef.current) {
1745
+ // Single manual task done — clear the task-running flag so a
1746
+ // subsequent free-form prompt can use plan mode again.
1747
+ if (props.taskRunningRef)
1748
+ props.taskRunningRef.current = false;
1749
+ currentTaskIdRef.current = null;
1750
+ if (props.sessionStore)
1751
+ props.sessionStore.currentTaskId = null;
1752
+ }
1715
1753
  // Goal loop: after the orchestrator handles a worker/verifier event,
1716
1754
  // continue the same Goal automatically until it reaches a terminal state.
1717
1755
  for (const runId of [...runningGoalIdsRef.current]) {
@@ -1758,6 +1796,16 @@ export function App(props) {
1758
1796
  setRunAllTasks(false);
1759
1797
  setRunAllPixel(false);
1760
1798
  currentPixelFixRef.current = null;
1799
+ // Clear the task-running flag so a subsequent free-form prompt can
1800
+ // re-enable plan mode. Aborted runs never auto-advance.
1801
+ if (props.taskRunningRef)
1802
+ props.taskRunningRef.current = false;
1803
+ // Clear the run-all task id so a subsequent run-all does not
1804
+ // mistakenly mark this aborted task as done. The on-disk row stays
1805
+ // at `in-progress`, which is the correct truth for an aborted task.
1806
+ currentTaskIdRef.current = null;
1807
+ if (props.sessionStore)
1808
+ props.sessionStore.currentTaskId = null;
1761
1809
  setDoneStatus(null);
1762
1810
  setLiveItems((prev) => {
1763
1811
  const next = prev.map((item) => {
@@ -2045,14 +2093,10 @@ export function App(props) {
2045
2093
  process.exit(0);
2046
2094
  }
2047
2095
  // Handle /clear — tear down the entire Ink instance and rebuild fresh.
2048
- // Patching Ink's internal frame tracking in place (log-update reset,
2049
- // lastOutput cleared, fullStaticOutput dropped, staticKey bump) all
2050
- // looked correct for one frame but left the live area drifting on
2051
- // subsequent streaming responses — Ink's cursor math depends on
2052
- // terminal-state assumptions that ANSI clearing breaks. The reliable
2053
- // fix is unmount + render again. Runtime state (model, provider,
2054
- // thinking) survives via renderApp's closure-held `runtimeState`,
2055
- // mirrored from React state via the useEffects above.
2096
+ // Avoid direct ANSI terminal clears here; they can erase scrollback.
2097
+ // Runtime state (model, provider, thinking) survives via renderApp's
2098
+ // closure-held `runtimeState`, mirrored from React state via the
2099
+ // useEffects above.
2056
2100
  if (trimmed === "/clear") {
2057
2101
  if (props.resetUI) {
2058
2102
  void (async () => {
@@ -2065,8 +2109,7 @@ export function App(props) {
2065
2109
  return;
2066
2110
  }
2067
2111
  // Fallback path (resetUI not wired — e.g. tests). Best-effort: clear
2068
- // React state in place. The Ink-internal drift bug remains here.
2069
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2112
+ // React state in place without touching terminal scrollback.
2070
2113
  pendingFlushRef.current = [];
2071
2114
  setHistory([{ kind: "banner", id: "banner" }]);
2072
2115
  setLiveItems([]);
@@ -2082,7 +2125,6 @@ export function App(props) {
2082
2125
  agentLoop.reset();
2083
2126
  setSessionTitle(undefined);
2084
2127
  sessionTitleGeneratedRef.current = false;
2085
- setStaticKey((k) => k + 1);
2086
2128
  setLiveItems([{ kind: "info", text: "Session cleared.", id: getId() }]);
2087
2129
  return;
2088
2130
  }
@@ -2190,8 +2232,6 @@ export function App(props) {
2190
2232
  props.resetUI();
2191
2233
  }
2192
2234
  else {
2193
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2194
- setStaticKey((key) => key + 1);
2195
2235
  if (props.sessionStore) {
2196
2236
  props.sessionStore.overlay = "goal";
2197
2237
  props.sessionStore.planAutoExpand = false;
@@ -2549,11 +2589,23 @@ export function App(props) {
2549
2589
  return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: theme.success, bold: true, children: "▶ " }), _jsx(Text, { color: theme.textDim, children: "Goal: " }), _jsx(Text, { color: theme.success, children: item.title }), item.workerId ? _jsxs(Text, { color: theme.textDim, children: [" \u00B7 worker ", item.workerId] }) : null] }) }, item.id));
2550
2590
  case "goal_progress": {
2551
2591
  const isError = item.status === "failed" || item.status === "fail" || item.status === "blocked";
2552
- const color = item.phase === "terminal" && !isError
2553
- ? theme.success
2554
- : isError
2555
- ? theme.warning
2556
- : theme.primary;
2592
+ const color = isError
2593
+ ? theme.error
2594
+ : item.phase === "worker_finished"
2595
+ ? theme.success
2596
+ : item.phase === "verifier_finished"
2597
+ ? theme.accent
2598
+ : item.phase === "orchestrator_reviewing" || item.phase === "orchestrator_working"
2599
+ ? theme.secondary
2600
+ : item.phase === "continuing"
2601
+ ? theme.warning
2602
+ : item.phase === "verifier_started"
2603
+ ? theme.accent
2604
+ : item.phase === "worker_started"
2605
+ ? theme.primary
2606
+ : item.phase === "terminal"
2607
+ ? theme.success
2608
+ : theme.primary;
2557
2609
  const glyph = item.phase === "worker_finished" || item.phase === "verifier_finished"
2558
2610
  ? "✓ "
2559
2611
  : item.phase === "terminal"
@@ -2643,14 +2695,30 @@ export function App(props) {
2643
2695
  const completionHint = `\n\n---\nWhen you have fully completed this task, call the tasks tool to mark it done:\n` +
2644
2696
  `tasks({ action: "done", id: "${shortId}" })`;
2645
2697
  const fullPrompt = prompt + completionHint;
2698
+ // Task-pane runs are unattended — there is no human to approve a
2699
+ // plan via the plan overlay. Force plan mode off and flag
2700
+ // `taskRunningRef` so `enter_plan` refuses if the agent tries to call
2701
+ // it. Both flags persist across the resetUI() unmount/remount below
2702
+ // because they live in cli.ts-owned refs, not React state.
2703
+ if (props.planModeRef)
2704
+ props.planModeRef.current = false;
2705
+ planModeStateRef.current = false;
2706
+ approvedPlanPathRef.current = undefined;
2707
+ setPlanMode(false);
2708
+ if (props.taskRunningRef)
2709
+ props.taskRunningRef.current = true;
2646
2710
  if (props.resetUI && props.sessionStore) {
2647
- // Preserve the current system prompt (may differ from the launch
2648
- // config — e.g. plan mode toggled or skills changed).
2649
- const sysMsg = messagesRef.current[0];
2650
- const newMessages = sysMsg && sysMsg.role === "system" ? [sysMsg] : messagesRef.current.slice(0, 1);
2651
2711
  const taskItem = { kind: "task", title, id: String(nextIdRef.current++) };
2652
2712
  const sm = sessionManagerRef.current;
2653
2713
  void (async () => {
2714
+ // Rebuild the system prompt without plan mode / approved plan so the
2715
+ // next mount doesn't inherit a stale plan-mode prefix from a prior
2716
+ // user-toggled state. Skills, languages, and cwd are preserved.
2717
+ const taskSysPrompt = await rebuildSystemPrompt({
2718
+ planMode: false,
2719
+ clearApprovedPlan: true,
2720
+ });
2721
+ const newMessages = [{ role: "system", content: taskSysPrompt }];
2654
2722
  let newSessionPath;
2655
2723
  if (sm) {
2656
2724
  try {
@@ -2662,8 +2730,14 @@ export function App(props) {
2662
2730
  // session creation is best-effort
2663
2731
  }
2664
2732
  }
2665
- if (props.sessionStore)
2733
+ if (props.sessionStore) {
2666
2734
  props.sessionStore.overlay = null;
2735
+ // Mirror through sessionStore so the post-resetUI remount can
2736
+ // re-seed the ref. markTaskDone() in the run-all onDone branch
2737
+ // reads currentTaskIdRef.current, which is seeded from this.
2738
+ props.sessionStore.currentTaskId = taskId;
2739
+ }
2740
+ currentTaskIdRef.current = taskId;
2667
2741
  props.resetUI?.({
2668
2742
  wipeSession: true,
2669
2743
  messages: newMessages,
@@ -2675,7 +2749,9 @@ export function App(props) {
2675
2749
  return;
2676
2750
  }
2677
2751
  // Fallback path (resetUI not wired — tests).
2678
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2752
+ currentTaskIdRef.current = taskId;
2753
+ if (props.sessionStore)
2754
+ props.sessionStore.currentTaskId = taskId;
2679
2755
  setHistory([{ kind: "banner", id: "banner" }]);
2680
2756
  setLiveItems([]);
2681
2757
  messagesRef.current = messagesRef.current.slice(0, 1);
@@ -2709,15 +2785,7 @@ export function App(props) {
2709
2785
  setRunAllTasks(false);
2710
2786
  }
2711
2787
  })();
2712
- }, [
2713
- props.cwd,
2714
- props.resetUI,
2715
- props.sessionStore,
2716
- stdout,
2717
- agentLoop,
2718
- currentProvider,
2719
- currentModel,
2720
- ]);
2788
+ }, [props.cwd, props.resetUI, props.sessionStore, agentLoop, currentProvider, currentModel]);
2721
2789
  const openOverlay = useCallback((kind) => {
2722
2790
  if (props.resetUI && props.sessionStore && !agentLoop.isRunning) {
2723
2791
  props.sessionStore.overlay = kind;
@@ -2738,7 +2806,7 @@ export function App(props) {
2738
2806
  setPlanAutoExpand(false);
2739
2807
  setOverlay(kind);
2740
2808
  }
2741
- }, [agentLoop.isRunning, props, stdout]);
2809
+ }, [agentLoop.isRunning, props]);
2742
2810
  const closeOverlay = useCallback(() => {
2743
2811
  if (props.resetUI && props.sessionStore && !agentLoop.isRunning) {
2744
2812
  props.sessionStore.overlay = null;
@@ -2750,7 +2818,7 @@ export function App(props) {
2750
2818
  }
2751
2819
  setOverlay(null);
2752
2820
  }
2753
- }, [agentLoop.isRunning, overlay, props, stdout]);
2821
+ }, [agentLoop.isRunning, overlay, props]);
2754
2822
  const runGoalSyntheticEvent = useCallback((eventText) => {
2755
2823
  const eventInfo = parseGoalSyntheticEvent(eventText);
2756
2824
  const detail = eventInfo?.kind === "worker"
@@ -2841,8 +2909,8 @@ export function App(props) {
2841
2909
  appendGoalProgress({
2842
2910
  kind: "goal_progress",
2843
2911
  phase: "continuing",
2844
- title: `Continuing Goal: ${latestRun.title}`,
2845
- detail: "Starting the next worker task or verifier step automatically.",
2912
+ title: `Choosing next Goal step: ${latestRun.title}`,
2913
+ detail: "Latest result is recorded; starting the next worker task or verifier automatically.",
2846
2914
  status: latestRun.status,
2847
2915
  });
2848
2916
  upsertGoalStatusEntry({
@@ -3138,143 +3206,89 @@ export function App(props) {
3138
3206
  detail: run.verifier.command,
3139
3207
  goalNumber: goalNumberForRun(run.id),
3140
3208
  });
3141
- const { spawn } = await import("node:child_process");
3142
- const { mkdir, writeFile } = await import("node:fs/promises");
3143
- const { join } = await import("node:path");
3144
- const logDir = join(projectDir(props.cwd), "verifiers");
3145
- await mkdir(logDir, { recursive: true });
3146
- const outputPath = join(logDir, `${run.id}-${startedAt}.log`);
3147
- const child = spawn(run.verifier.command, {
3209
+ void runGoalVerifierCommand({
3148
3210
  cwd: props.cwd,
3149
- shell: true,
3150
- stdio: ["ignore", "pipe", "pipe"],
3151
- env: { ...process.env },
3152
- });
3153
- let output = "";
3154
- child.stdout?.on("data", (chunk) => {
3155
- output += chunk.toString("utf-8");
3156
- if (output.length > 20_000)
3157
- output = output.slice(output.length - 20_000);
3158
- });
3159
- child.stderr?.on("data", (chunk) => {
3160
- output += chunk.toString("utf-8");
3161
- if (output.length > 20_000)
3162
- output = output.slice(output.length - 20_000);
3163
- });
3164
- let verifierSettled = false;
3165
- let timedOut = false;
3166
- const timeout = verifierTimeoutMs > 0
3167
- ? setTimeout(() => {
3168
- timedOut = true;
3169
- if (child.pid)
3170
- child.kill("SIGTERM");
3171
- const killTimer = setTimeout(() => {
3172
- if (!verifierSettled && child.pid)
3173
- child.kill("SIGKILL");
3174
- }, 5000);
3175
- killTimer.unref?.();
3176
- finishVerifier(124, `Verifier timed out after ${verifierTimeoutMs}ms and was terminated.\n${output}`);
3177
- }, verifierTimeoutMs)
3178
- : undefined;
3179
- timeout?.unref?.();
3180
- const finishVerifier = (code, forcedOutput) => {
3181
- if (verifierSettled)
3182
- return;
3183
- verifierSettled = true;
3184
- if (timeout)
3185
- clearTimeout(timeout);
3211
+ runId: run.id,
3212
+ command: run.verifier.command,
3213
+ timeoutMs: verifierTimeoutMs,
3214
+ now: () => startedAt,
3215
+ })
3216
+ .then(async ({ verification, failureClass, durationMs }) => {
3186
3217
  activeVerifierRunIdsRef.current.delete(run.id);
3187
- void (async () => {
3188
- const status = code === 0 ? "pass" : "fail";
3189
- const failureClass = timedOut
3190
- ? "verifier_timeout"
3191
- : forcedOutput?.startsWith("Verifier process error:")
3192
- ? "verifier_spawn_error"
3193
- : status === "fail"
3194
- ? "verifier_failure"
3195
- : "verifier_pass";
3196
- const summary = (forcedOutput ?? output).trim() ||
3197
- (code === 0 ? "Verifier passed." : "Verifier failed.");
3198
- const latestRun = (await loadGoalRuns(props.cwd)).find((item) => item.id === run.id) ?? run;
3199
- await writeFile(outputPath, summary + "\n", "utf-8");
3200
- const runWithVerifier = {
3201
- ...latestRun,
3202
- verifier: {
3203
- ...latestRun.verifier,
3204
- description: latestRun.verifier?.description ?? "Goal verifier",
3205
- command: run.verifier?.command,
3206
- lastResult: {
3207
- status,
3208
- summary,
3209
- command: run.verifier?.command,
3210
- exitCode: code ?? 1,
3211
- outputPath,
3212
- checkedAt: new Date().toISOString(),
3213
- },
3214
- },
3215
- };
3216
- const completionCheck = canCompleteGoalRun(runWithVerifier);
3217
- const verifiedRun = await upsertGoalRun(props.cwd, {
3218
- ...runWithVerifier,
3219
- continueRequestedAt: undefined,
3220
- status: status === "pass" && completionCheck.ok
3221
- ? "passed"
3222
- : status === "pass"
3223
- ? "ready"
3224
- : "failed",
3225
- });
3226
- await appendGoalEvidence(props.cwd, run.id, {
3227
- kind: "command",
3228
- label: `Verifier ${status}`,
3229
- content: `${failureClass}: ${summary}`.slice(0, 4000),
3230
- path: outputPath,
3231
- });
3232
- await appendGoalDecision(props.cwd, run.id, {
3233
- kind: `verifier_${status}`,
3234
- reason: `${failureClass}: verifier exited with code ${code ?? 1}.`,
3235
- content: `outputPath=${outputPath}; durationMs=${Date.now() - startedAt}`,
3236
- });
3237
- if (status === "fail" && shouldCreateVerifierFixTask(latestRun)) {
3238
- await updateGoalTask(props.cwd, run.id, `fix-${Date.now()}`, {
3239
- title: "Fix verifier failure",
3240
- prompt: `Goal verifier failed after ${Date.now() - startedAt}ms. Original goal: ${run.goal}\n\n` +
3241
- `Verifier command: ${run.verifier?.command}\n\n` +
3242
- `Failure output:\n${summary.slice(-6000)}\n\nFix the cause, record evidence with the goals tool, and rerun relevant verification.`,
3243
- status: "pending",
3244
- });
3245
- const runWithPendingFix = (await loadGoalRuns(props.cwd)).find((item) => item.id === run.id) ?? latestRun;
3246
- await upsertGoalRun(props.cwd, { ...runWithPendingFix, status: "ready" });
3247
- }
3248
- setGoalCount((await summarizeGoalCounts(props.cwd)).active);
3249
- appendGoalProgress({
3250
- kind: "goal_progress",
3251
- phase: "verifier_finished",
3252
- title: `Verifier ${status}: ${run.title}`,
3253
- detail: summarizeGoalCompletion(summary),
3254
- status,
3255
- });
3256
- upsertGoalStatusEntry({
3257
- runId: run.id,
3258
- label: run.title,
3259
- phase: status === "pass" ? "reviewing" : "failed",
3260
- startedAt: Date.now(),
3261
- detail: status === "pass" ? "reviewing verifier evidence" : "verifier failed",
3262
- goalNumber: goalNumberForRun(run.id),
3218
+ const status = verification.status;
3219
+ const summary = verification.summary;
3220
+ const outputPath = verification.outputPath;
3221
+ const latestRun = (await loadGoalRuns(props.cwd)).find((item) => item.id === run.id) ?? run;
3222
+ const runWithVerifier = {
3223
+ ...latestRun,
3224
+ verifier: {
3225
+ ...latestRun.verifier,
3226
+ description: latestRun.verifier?.description ?? "Goal verifier",
3227
+ command: run.verifier?.command,
3228
+ lastResult: verification,
3229
+ },
3230
+ };
3231
+ const completionCheck = canCompleteGoalRun(runWithVerifier);
3232
+ const verifiedRun = await upsertGoalRun(props.cwd, {
3233
+ ...runWithVerifier,
3234
+ continueRequestedAt: undefined,
3235
+ status: status === "pass" && completionCheck.ok
3236
+ ? "passed"
3237
+ : status === "pass"
3238
+ ? "ready"
3239
+ : "failed",
3240
+ });
3241
+ await appendGoalEvidence(props.cwd, run.id, {
3242
+ kind: "command",
3243
+ label: `Verifier ${status}`,
3244
+ content: `${failureClass}: ${summary}`.slice(0, 4000),
3245
+ path: outputPath,
3246
+ });
3247
+ await appendGoalDecision(props.cwd, run.id, {
3248
+ kind: `verifier_${status}`,
3249
+ reason: `${failureClass}: verifier exited with code ${verification.exitCode ?? 1}.`,
3250
+ content: `outputPath=${outputPath ?? ""}; durationMs=${durationMs}`,
3251
+ });
3252
+ if (status === "fail" && shouldCreateVerifierFixTask(latestRun)) {
3253
+ await updateGoalTask(props.cwd, run.id, `fix-${Date.now()}`, {
3254
+ title: "Fix verifier failure",
3255
+ prompt: `Goal verifier failed after ${durationMs}ms. Original goal: ${run.goal}\n\n` +
3256
+ `Verifier command: ${run.verifier?.command}\n\n` +
3257
+ `Failure output:\n${summary.slice(-6000)}\n\nFix the cause, record evidence with the goals tool, and rerun relevant verification.`,
3258
+ status: "pending",
3263
3259
  });
3264
- const eventText = formatGoalVerifierCompletionEvent(verifiedRun, status, run.verifier?.command ?? "", code ?? 1, summary);
3265
- runGoalSyntheticEvent(eventText);
3266
- const continuationRun = (await loadGoalRuns(props.cwd)).find((item) => item.id === run.id);
3267
- if (continuationRun?.continueRequestedAt && status === "pass") {
3268
- setTimeout(() => continueGoalRun(run.id), 500);
3269
- }
3270
- })().catch((err) => {
3271
- clearGoalStatusEntry(run.id);
3272
- log("ERROR", "goal", err instanceof Error ? err.message : String(err));
3273
- setLiveItems((prev) => [...prev, toErrorItem(err, getId(), "Goal verifier")]);
3260
+ const runWithPendingFix = (await loadGoalRuns(props.cwd)).find((item) => item.id === run.id) ?? latestRun;
3261
+ await upsertGoalRun(props.cwd, { ...runWithPendingFix, status: "ready" });
3262
+ }
3263
+ setGoalCount((await summarizeGoalCounts(props.cwd)).active);
3264
+ appendGoalProgress({
3265
+ kind: "goal_progress",
3266
+ phase: "verifier_finished",
3267
+ title: `Verifier ${status}: ${run.title}`,
3268
+ detail: summarizeGoalCompletion(summary),
3269
+ status,
3274
3270
  });
3275
- };
3276
- child.on("close", (code) => finishVerifier(code));
3277
- child.on("error", (err) => finishVerifier(1, `Verifier process error: ${err.message}`));
3271
+ upsertGoalStatusEntry({
3272
+ runId: run.id,
3273
+ label: run.title,
3274
+ phase: status === "pass" ? "reviewing" : "failed",
3275
+ startedAt: Date.now(),
3276
+ detail: status === "pass" ? "reviewing verifier evidence" : "verifier failed",
3277
+ goalNumber: goalNumberForRun(run.id),
3278
+ });
3279
+ const eventText = formatGoalVerifierCompletionEvent(verifiedRun, status === "pass" ? "pass" : "fail", run.verifier?.command ?? "", verification.exitCode ?? 1, summary);
3280
+ runGoalSyntheticEvent(eventText);
3281
+ const continuationRun = (await loadGoalRuns(props.cwd)).find((item) => item.id === run.id);
3282
+ if (continuationRun?.continueRequestedAt && status === "pass") {
3283
+ setTimeout(() => continueGoalRun(run.id), 500);
3284
+ }
3285
+ })
3286
+ .catch((err) => {
3287
+ activeVerifierRunIdsRef.current.delete(run.id);
3288
+ clearGoalStatusEntry(run.id);
3289
+ log("ERROR", "goal", err instanceof Error ? err.message : String(err));
3290
+ setLiveItems((prev) => [...prev, toErrorItem(err, getId(), "Goal verifier")]);
3291
+ });
3278
3292
  }, [
3279
3293
  props.cwd,
3280
3294
  appendGoalProgress,
@@ -3308,8 +3322,12 @@ export function App(props) {
3308
3322
  startGoalRunRef.current = startGoalRun;
3309
3323
  useEffect(() => {
3310
3324
  runAllTasksRef.current = runAllTasks;
3311
- if (props.sessionStore)
3325
+ if (props.sessionStore) {
3312
3326
  props.sessionStore.runAllTasks = runAllTasks;
3327
+ // Keep currentTaskId in sync with the ref so a sessionStore-seeded
3328
+ // remount picks up the same value the imperative sites wrote.
3329
+ props.sessionStore.currentTaskId = currentTaskIdRef.current;
3330
+ }
3313
3331
  }, [runAllTasks, props.sessionStore]);
3314
3332
  useEffect(() => {
3315
3333
  agentRunningRef.current = agentLoop.isRunning;
@@ -3364,14 +3382,10 @@ export function App(props) {
3364
3382
  activeLanguages: detectedForPixelFix,
3365
3383
  tools: toolsForPixelFix,
3366
3384
  });
3367
- // Now that the cwd swap is committed, reset chat. Doing this BEFORE
3368
- // the chdir would print a banner with the old cwd, then bumping
3369
- // staticKey would print a second banner with the new cwd — leaving
3370
- // two banners stacked in the scrollback.
3371
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
3385
+ // Now that the cwd swap is committed, reset chat. Do not clear the
3386
+ // terminal here; terminal clear sequences can erase saved scrollback.
3372
3387
  setHistory([{ kind: "banner", id: "banner" }]);
3373
3388
  setLiveItems([]);
3374
- setStaticKey((k) => k + 1);
3375
3389
  messagesRef.current = messagesRef.current.slice(0, 1);
3376
3390
  agentLoop.reset();
3377
3391
  persistedIndexRef.current = messagesRef.current.length;
@@ -3403,7 +3417,7 @@ export function App(props) {
3403
3417
  setLiveItems((prev) => [...prev, toErrorItem(err, getId())]);
3404
3418
  }
3405
3419
  })();
3406
- }, [props.cwd, stdout, agentLoop, currentProvider, currentModel]);
3420
+ }, [props.cwd, agentLoop, currentProvider, currentModel]);
3407
3421
  startPixelFixRef.current = startPixelFix;
3408
3422
  // Seed from sessionStore so "Fix All" chaining survives a deferred
3409
3423
  // resetUI() if it fires between pixel fixes (e.g. user toggled a pane).
@@ -3431,12 +3445,13 @@ export function App(props) {
3431
3445
  overlayPane: overlay,
3432
3446
  isAgentRunning: agentLoop.isRunning,
3433
3447
  });
3434
- return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(Static, { items: shouldHideStaticItemsForOverlayView({
3435
- shouldHideHistoryForOverlay,
3436
- stabilizeOverlayPaneRerender,
3437
- })
3438
- ? []
3439
- : history, style: { width: "100%" }, children: (item) => (_jsx(Box, { flexDirection: "column", paddingRight: 1, children: renderItem(item) }, item.id)) }, getStaticHistoryKey({ resizeKey, staticKey })), isTaskView ? (_jsx(TaskOverlay, { cwd: props.cwd, agentRunning: agentLoop.isRunning, onClose: () => {
3448
+ const staticItems = shouldHideStaticItemsForOverlayView({
3449
+ shouldHideHistoryForOverlay,
3450
+ stabilizeOverlayPaneRerender,
3451
+ })
3452
+ ? []
3453
+ : history;
3454
+ return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(Static, { items: staticItems, style: { width: "100%" }, children: (item) => (_jsx(Box, { flexDirection: "column", paddingRight: 1, children: renderItem(item) }, item.id)) }, getStaticHistoryKey({ resizeKey })), isTaskView ? (_jsx(TaskOverlay, { cwd: props.cwd, agentRunning: agentLoop.isRunning, onClose: () => {
3440
3455
  if (props.resetUI && props.sessionStore && !agentLoop.isRunning) {
3441
3456
  props.sessionStore.overlay = null;
3442
3457
  props.resetUI();
@@ -3586,10 +3601,8 @@ export function App(props) {
3586
3601
  approvedPlanPathRef.current = planPath;
3587
3602
  planStepsRef.current = steps;
3588
3603
  setPlanSteps(steps);
3589
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
3590
3604
  setHistory([{ kind: "banner", id: "banner" }]);
3591
3605
  setLiveItems([]);
3592
- setStaticKey((k) => k + 1);
3593
3606
  setPlanAutoExpand(false);
3594
3607
  setOverlay(null);
3595
3608
  messagesRef.current = [{ role: "system", content: newPrompt }];
@@ -3630,8 +3643,6 @@ export function App(props) {
3630
3643
  });
3631
3644
  return;
3632
3645
  }
3633
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
3634
- setStaticKey((k) => k + 1);
3635
3646
  setPlanAutoExpand(false);
3636
3647
  setOverlay(null);
3637
3648
  setDoneStatus(null);
@@ -3646,8 +3657,6 @@ export function App(props) {
3646
3657
  });
3647
3658
  } })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, paddingRight: 1, children: [liveItems.map((item) => renderItem(item)), _jsx(StreamingArea, { isRunning: agentLoop.isRunning, streamingText: agentLoop.streamingText, streamingThinking: agentLoop.streamingThinking, thinkingMs: agentLoop.thinkingMs, planMode: planMode })] }), agentLoop.isRunning && agentLoop.activityPhase !== "idle" ? (_jsx(Box, { marginTop: 1, borderStyle: "round", borderColor: agentLoop.activityPhase === "thinking" ? THINKING_BORDER_COLORS[0] : "transparent", paddingLeft: 1, paddingRight: 1, width: columns, children: _jsx(ActivityIndicator, { phase: agentLoop.activityPhase, elapsedMs: agentLoop.elapsedMs, runStartRef: agentLoop.runStartRef, thinkingMs: agentLoop.thinkingMs, isThinking: agentLoop.isThinking, thinkingEnabled: thinkingEnabled, tokenEstimate: agentLoop.streamedTokenEstimate, charCountRef: agentLoop.charCountRef, realTokensAccumRef: agentLoop.realTokensAccumRef, userMessage: lastUserMessage, activeToolNames: agentLoop.activeToolCalls.map((tc) => tc.name), planMode: planMode, retryInfo: agentLoop.retryInfo, planDone: planSteps.filter((s) => s.completed).length, planTotal: planSteps.length, staticDisplay: true }) })) : agentLoop.stallError ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.warning, children: "⚠ API provider stream interrupted — retries exhausted." }), _jsx(Text, { color: theme.textDim, children: " Your conversation is preserved. Send a message to continue." })] })) : (doneStatus &&
3648
3659
  !agentLoop.isRunning && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.success, children: ["✻ ", doneStatus.verb, " ", formatDuration(doneStatus.durationMs)] }) }))), agentLoop.queuedCount > 0 && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.accent, children: ["⏳ ", agentLoop.queuedCount, " message", agentLoop.queuedCount > 1 ? "s" : "", " queued"] }) })), _jsx(InputArea, { onSubmit: handleSubmit, onAbort: handleAbort, disabled: agentLoop.isRunning, isActive: !taskBarFocused && !overlay, onDownAtEnd: handleFocusTaskBar, onShiftTab: handleToggleThinking, onToggleTasks: () => {
3649
- // While the agent is running, skip the screen-clear + staticKey
3650
- // bump that would otherwise wipe the chat history from scrollback.
3651
3660
  // Just flip the overlay state — Ink's log-update handles the
3652
3661
  // live-area transition (chat input → TaskOverlay) natively, and
3653
3662
  // the chat history above stays in scrollback. When the overlay