@prestyj/cli 5.3.0 → 5.3.1

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 (168) hide show
  1. package/dist/app-sidecar.js +581 -55
  2. package/dist/app-sidecar.js.map +1 -1
  3. package/dist/core/agent-session-compaction.test.js +89 -0
  4. package/dist/core/agent-session-compaction.test.js.map +1 -1
  5. package/dist/core/agent-session.d.ts +71 -3
  6. package/dist/core/agent-session.d.ts.map +1 -1
  7. package/dist/core/agent-session.js +261 -47
  8. package/dist/core/agent-session.js.map +1 -1
  9. package/dist/core/autopilot-cycle.d.ts +69 -17
  10. package/dist/core/autopilot-cycle.d.ts.map +1 -1
  11. package/dist/core/autopilot-cycle.js +82 -13
  12. package/dist/core/autopilot-cycle.js.map +1 -1
  13. package/dist/core/autopilot-cycle.test.js +206 -10
  14. package/dist/core/autopilot-cycle.test.js.map +1 -1
  15. package/dist/core/autopilot-gate.d.ts +48 -22
  16. package/dist/core/autopilot-gate.d.ts.map +1 -1
  17. package/dist/core/autopilot-gate.js +110 -1
  18. package/dist/core/autopilot-gate.js.map +1 -1
  19. package/dist/core/autopilot-gate.test.js +139 -3
  20. package/dist/core/autopilot-gate.test.js.map +1 -1
  21. package/dist/core/autopilot-verdict.d.ts +6 -0
  22. package/dist/core/autopilot-verdict.d.ts.map +1 -1
  23. package/dist/core/autopilot-verdict.js +75 -1
  24. package/dist/core/autopilot-verdict.js.map +1 -1
  25. package/dist/core/autopilot-verdict.test.js +63 -0
  26. package/dist/core/autopilot-verdict.test.js.map +1 -1
  27. package/dist/core/bash-spawn-benchmark.d.ts +2 -0
  28. package/dist/core/bash-spawn-benchmark.d.ts.map +1 -0
  29. package/dist/core/bash-spawn-benchmark.js +139 -0
  30. package/dist/core/bash-spawn-benchmark.js.map +1 -0
  31. package/dist/core/hashline-edit-benchmark.d.ts +33 -1
  32. package/dist/core/hashline-edit-benchmark.d.ts.map +1 -1
  33. package/dist/core/hashline-edit-benchmark.js +193 -21
  34. package/dist/core/hashline-edit-benchmark.js.map +1 -1
  35. package/dist/core/hashline-edit-benchmark.test.js +51 -2
  36. package/dist/core/hashline-edit-benchmark.test.js.map +1 -1
  37. package/dist/core/mcp/deferred-catalog.d.ts +28 -0
  38. package/dist/core/mcp/deferred-catalog.d.ts.map +1 -0
  39. package/dist/core/mcp/deferred-catalog.js +72 -0
  40. package/dist/core/mcp/deferred-catalog.js.map +1 -0
  41. package/dist/core/mcp/resolve-stdio.d.ts +19 -3
  42. package/dist/core/mcp/resolve-stdio.d.ts.map +1 -1
  43. package/dist/core/mcp/resolve-stdio.js +136 -5
  44. package/dist/core/mcp/resolve-stdio.js.map +1 -1
  45. package/dist/core/mcp/resolve-stdio.test.js +82 -1
  46. package/dist/core/mcp/resolve-stdio.test.js.map +1 -1
  47. package/dist/core/nolan-context.d.ts +26 -2
  48. package/dist/core/nolan-context.d.ts.map +1 -1
  49. package/dist/core/nolan-context.js +46 -4
  50. package/dist/core/nolan-context.js.map +1 -1
  51. package/dist/core/nolan-context.test.js +53 -12
  52. package/dist/core/nolan-context.test.js.map +1 -1
  53. package/dist/core/nolan-prompt.d.ts +2 -15
  54. package/dist/core/nolan-prompt.d.ts.map +1 -1
  55. package/dist/core/nolan-prompt.js +96 -15
  56. package/dist/core/nolan-prompt.js.map +1 -1
  57. package/dist/core/nolan-prompt.test.js +102 -7
  58. package/dist/core/nolan-prompt.test.js.map +1 -1
  59. package/dist/core/persistent-shell.d.ts +27 -0
  60. package/dist/core/persistent-shell.d.ts.map +1 -0
  61. package/dist/core/persistent-shell.js +150 -0
  62. package/dist/core/persistent-shell.js.map +1 -0
  63. package/dist/core/persistent-shell.test.d.ts +2 -0
  64. package/dist/core/persistent-shell.test.d.ts.map +1 -0
  65. package/dist/core/persistent-shell.test.js +84 -0
  66. package/dist/core/persistent-shell.test.js.map +1 -0
  67. package/dist/core/process-manager-dev-server-repro.test.js +34 -6
  68. package/dist/core/process-manager-dev-server-repro.test.js.map +1 -1
  69. package/dist/core/progress/engine.d.ts +23 -0
  70. package/dist/core/progress/engine.d.ts.map +1 -0
  71. package/dist/core/progress/engine.js +131 -0
  72. package/dist/core/progress/engine.js.map +1 -0
  73. package/dist/core/progress/engine.test.d.ts +2 -0
  74. package/dist/core/progress/engine.test.d.ts.map +1 -0
  75. package/dist/core/progress/engine.test.js +136 -0
  76. package/dist/core/progress/engine.test.js.map +1 -0
  77. package/dist/core/progress/git-xp.d.ts +16 -0
  78. package/dist/core/progress/git-xp.d.ts.map +1 -0
  79. package/dist/core/progress/git-xp.js +106 -0
  80. package/dist/core/progress/git-xp.js.map +1 -0
  81. package/dist/core/progress/git-xp.test.d.ts +2 -0
  82. package/dist/core/progress/git-xp.test.d.ts.map +1 -0
  83. package/dist/core/progress/git-xp.test.js +88 -0
  84. package/dist/core/progress/git-xp.test.js.map +1 -0
  85. package/dist/core/progress/ranks.d.ts +21 -0
  86. package/dist/core/progress/ranks.d.ts.map +1 -0
  87. package/dist/core/progress/ranks.js +141 -0
  88. package/dist/core/progress/ranks.js.map +1 -0
  89. package/dist/core/progress/ranks.test.d.ts +2 -0
  90. package/dist/core/progress/ranks.test.d.ts.map +1 -0
  91. package/dist/core/progress/ranks.test.js +59 -0
  92. package/dist/core/progress/ranks.test.js.map +1 -0
  93. package/dist/core/progress/rebuild.d.ts +7 -0
  94. package/dist/core/progress/rebuild.d.ts.map +1 -0
  95. package/dist/core/progress/rebuild.js +106 -0
  96. package/dist/core/progress/rebuild.js.map +1 -0
  97. package/dist/core/progress/rebuild.test.d.ts +2 -0
  98. package/dist/core/progress/rebuild.test.d.ts.map +1 -0
  99. package/dist/core/progress/rebuild.test.js +72 -0
  100. package/dist/core/progress/rebuild.test.js.map +1 -0
  101. package/dist/core/progress/store.d.ts +35 -0
  102. package/dist/core/progress/store.d.ts.map +1 -0
  103. package/dist/core/progress/store.js +200 -0
  104. package/dist/core/progress/store.js.map +1 -0
  105. package/dist/core/progress/store.test.d.ts +2 -0
  106. package/dist/core/progress/store.test.d.ts.map +1 -0
  107. package/dist/core/progress/store.test.js +108 -0
  108. package/dist/core/progress/store.test.js.map +1 -0
  109. package/dist/core/progress/types.d.ts +108 -0
  110. package/dist/core/progress/types.d.ts.map +1 -0
  111. package/dist/core/progress/types.js +3 -0
  112. package/dist/core/progress/types.js.map +1 -0
  113. package/dist/core/project-discovery.d.ts.map +1 -1
  114. package/dist/core/project-discovery.js +40 -9
  115. package/dist/core/project-discovery.js.map +1 -1
  116. package/dist/core/project-discovery.test.d.ts +2 -0
  117. package/dist/core/project-discovery.test.d.ts.map +1 -0
  118. package/dist/core/project-discovery.test.js +104 -0
  119. package/dist/core/project-discovery.test.js.map +1 -0
  120. package/dist/core/session-history.d.ts +51 -0
  121. package/dist/core/session-history.d.ts.map +1 -0
  122. package/dist/core/session-history.js +156 -0
  123. package/dist/core/session-history.js.map +1 -0
  124. package/dist/core/session-history.test.d.ts +2 -0
  125. package/dist/core/session-history.test.d.ts.map +1 -0
  126. package/dist/core/session-history.test.js +101 -0
  127. package/dist/core/session-history.test.js.map +1 -0
  128. package/dist/core/session-manager.d.ts +34 -0
  129. package/dist/core/session-manager.d.ts.map +1 -1
  130. package/dist/core/session-manager.js +67 -0
  131. package/dist/core/session-manager.js.map +1 -1
  132. package/dist/core/session-manager.test.js +140 -1
  133. package/dist/core/session-manager.test.js.map +1 -1
  134. package/dist/core/settings-manager.d.ts +1 -0
  135. package/dist/core/settings-manager.d.ts.map +1 -1
  136. package/dist/core/settings-manager.js +4 -0
  137. package/dist/core/settings-manager.js.map +1 -1
  138. package/dist/system-prompt.d.ts.map +1 -1
  139. package/dist/system-prompt.js +12 -8
  140. package/dist/system-prompt.js.map +1 -1
  141. package/dist/system-prompt.test.js +15 -20
  142. package/dist/system-prompt.test.js.map +1 -1
  143. package/dist/tools/bash.d.ts +1 -0
  144. package/dist/tools/bash.d.ts.map +1 -1
  145. package/dist/tools/bash.js +27 -2
  146. package/dist/tools/bash.js.map +1 -1
  147. package/dist/tools/edit.d.ts +9 -2
  148. package/dist/tools/edit.d.ts.map +1 -1
  149. package/dist/tools/edit.js +91 -10
  150. package/dist/tools/edit.js.map +1 -1
  151. package/dist/tools/edit.test.js +97 -0
  152. package/dist/tools/edit.test.js.map +1 -1
  153. package/dist/tools/plan-mode.test.js +25 -0
  154. package/dist/tools/plan-mode.test.js.map +1 -1
  155. package/dist/tools/prompt-hints.d.ts.map +1 -1
  156. package/dist/tools/prompt-hints.js +3 -0
  157. package/dist/tools/prompt-hints.js.map +1 -1
  158. package/dist/tools/subagent.d.ts.map +1 -1
  159. package/dist/tools/subagent.js +24 -1
  160. package/dist/tools/subagent.js.map +1 -1
  161. package/dist/tools/tool-search.d.ts +15 -0
  162. package/dist/tools/tool-search.d.ts.map +1 -0
  163. package/dist/tools/tool-search.js +35 -0
  164. package/dist/tools/tool-search.js.map +1 -0
  165. package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
  166. package/dist/ui/hooks/useAgentLoop.js +22 -9
  167. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  168. package/package.json +4 -4
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import http from "node:http";
15
15
  import fs from "node:fs/promises";
16
+ import { watch as fsWatch } from "node:fs";
16
17
  import os from "node:os";
17
18
  import path from "node:path";
18
19
  import { randomUUID } from "node:crypto";
@@ -21,12 +22,12 @@ import { formatError } from "@prestyj/ai";
21
22
  import { runJsonMode } from "./modes/json-mode.js";
22
23
  import { AgentSession } from "./core/agent-session.js";
23
24
  import { buildNolanSystemPrompt, buildNolanAutopilotSystemPrompt } from "./core/nolan-prompt.js";
24
- import { buildNolanDigest, buildNolanAutopilotContext } from "./core/nolan-context.js";
25
+ import { buildNolanDigest, buildNolanAutopilotContext, buildNolanAutopilotPlanContext, } from "./core/nolan-context.js";
25
26
  import { parseAutopilotVerdict } from "./core/autopilot-verdict.js";
26
- import { isWorkflowCommandText, countAssistantMessages, shouldStartAutopilotCycle, } from "./core/autopilot-gate.js";
27
- import { driveAutopilotCycle } from "./core/autopilot-cycle.js";
27
+ import { isWorkflowCommandText, countAssistantMessages, shouldStartAutopilotCycle, extractTurnToolCalls, isMechanicalOnlyTurn, } from "./core/autopilot-gate.js";
28
+ import { driveAutopilotCycle, frameAutopilotInjection } from "./core/autopilot-cycle.js";
28
29
  import { validateNolanModelPref, effectiveNolanModel, } from "./core/nolan-model.js";
29
- import { collectProjectContext } from "./system-prompt.js";
30
+ import { normalizeAutopilotMarkersForHistory, normalizeAppMarkersForHistory, normalizeNolanTurnsForHistory, restoreUserRow, restoreAssistantTexts, autopilotMarkerCopySeed, } from "./core/session-history.js";
30
31
  import { AuthStorage } from "./core/auth-storage.js";
31
32
  import { MOONSHOT_OAUTH_KEY, XIAOMI_CREDITS_KEY } from "@prestyj/core";
32
33
  import { loginAnthropic } from "./core/oauth/anthropic.js";
@@ -51,6 +52,11 @@ import { downscaleForPreview, validateVisionImage } from "./utils/image.js";
51
52
  import { startServeMode } from "./modes/serve-mode.js";
52
53
  import { loadTelegramConfig, saveTelegramConfig, verifyBotToken } from "./core/telegram-config.js";
53
54
  import { loadServers, addServer, removeServer, getServer, parseMcpAddCommand, MCPClientManager, McpOAuthStore, } from "./core/mcp/index.js";
55
+ import { buildSnapshot, levelForXp, rankForLevel } from "./core/progress/ranks.js";
56
+ import { loadProgress, peekProgress, updateProgress } from "./core/progress/store.js";
57
+ import { awardPrompt, awardCommits } from "./core/progress/engine.js";
58
+ import { detectNewCommits, repoKey } from "./core/progress/git-xp.js";
59
+ import { rebuildFromSessions } from "./core/progress/rebuild.js";
54
60
  const ALL_PROVIDERS = [
55
61
  "anthropic",
56
62
  "xiaomi",
@@ -496,6 +502,13 @@ async function main() {
496
502
  // request to its window's session via the `x-gg-session` header (and the
497
503
  // `?session=` query for the SSE /events stream).
498
504
  const sessions = new Map();
505
+ // XP/rank progress — loaded once per daemon; awards fan out to every window.
506
+ // Each frame is tagged `origin: true` only for the session that earned the
507
+ // XP, so that window alone plays sounds/chips while the rest just re-render.
508
+ const progress = await createProgressManager(paths.agentDir, (snapshot, originId) => {
509
+ for (const ctx of sessions.values())
510
+ ctx.broadcast("progress", { ...snapshot, origin: ctx.id === originId });
511
+ });
499
512
  /** Resolve the target session id: the `x-gg-session` header, else a
500
513
  * `?session=` query param (used by the SSE /events connection). */
501
514
  function sessionIdFromReq(req, url) {
@@ -539,7 +552,7 @@ async function main() {
539
552
  const sessionPath = typeof body.sessionPath === "string" && body.sessionPath ? body.sessionPath : undefined;
540
553
  const id = randomUUID();
541
554
  try {
542
- const ctx = await createSession({ auth, paths }, { id, cwd: sessionCwd, sessionPath });
555
+ const ctx = await createSession({ auth, paths, progress }, { id, cwd: sessionCwd, sessionPath });
543
556
  sessions.set(id, ctx);
544
557
  log("INFO", "app-sidecar", "session created", { id, cwd: sessionCwd });
545
558
  daemonJson(res, 200, { sessionId: id });
@@ -564,6 +577,12 @@ async function main() {
564
577
  daemonJson(res, 200, { ok: true });
565
578
  return;
566
579
  }
580
+ // Progress is daemon-level so the Home screen can paint before a project
581
+ // session exists; per-session callers still work through the same endpoint.
582
+ if (method === "GET" && url === "/progress") {
583
+ daemonJson(res, 200, progress.snapshot());
584
+ return;
585
+ }
567
586
  // ── Per-session delegation ───────────────────────────────────────────
568
587
  const id = sessionIdFromReq(req, url);
569
588
  const ctx = id ? sessions.get(id) : undefined;
@@ -623,17 +642,18 @@ function lastAssistantText(messages) {
623
642
  return "";
624
643
  }
625
644
  /**
626
- * Assemble Nolan's context digest for one `@Nolan` question: project docs (up the
627
- * tree) + git/env + the build session's compaction summary + recent activity.
628
- * Prepended to the user's question as Nolan's prompt body each turn. Workflow
629
- * commands + autopilot-injected prompts are passed through so the digest
630
- * labels them as what they are instead of user-authored asks.
645
+ * Assemble Nolan's context digest for one `@Nolan` question: git/env + the build
646
+ * session's compaction summary + recent activity. Prepended to the user's
647
+ * question as Nolan's prompt body each turn. Project docs (CLAUDE.md/AGENTS.md)
648
+ * are NOT here they're folded into Nolan's cached system prompt once per
649
+ * session instead (see nolan-prompt.ts), so they hit the provider prompt cache
650
+ * instead of being re-sent uncached on every question. Workflow commands +
651
+ * autopilot-injected prompts are passed through so the digest labels them as
652
+ * what they are instead of user-authored asks.
631
653
  */
632
- async function buildNolanContext(buildSession, cwd, gitBranch, question, workflowCommands, injectedPrompts) {
633
- const projectContext = await collectProjectContext(cwd).catch(() => []);
654
+ function buildNolanContext(buildSession, cwd, gitBranch, question, workflowCommands, injectedPrompts) {
634
655
  return buildNolanDigest({
635
656
  question,
636
- projectContext,
637
657
  cwd,
638
658
  gitBranch,
639
659
  messages: buildSession.getMessages(),
@@ -641,6 +661,88 @@ async function buildNolanContext(buildSession, cwd, gitBranch, question, workflo
641
661
  injectedPrompts,
642
662
  });
643
663
  }
664
+ async function createProgressManager(agentDir, broadcastAll) {
665
+ // Boot: recovery chain main → backup → one-time session-history rebuild → empty.
666
+ let file = await loadProgress({ rebuild: () => rebuildFromSessions() });
667
+ // Don't re-celebrate an old levelUp event on boot.
668
+ let lastSeenNonce = file.lastEvent?.nonce ?? null;
669
+ log("INFO", "app-sidecar", "progress loaded", {
670
+ xp: String(file.xp),
671
+ level: String(levelForXp(file.xp)),
672
+ });
673
+ function snapshot() {
674
+ return buildSnapshot(file);
675
+ }
676
+ async function awardRun(cwd, runStartedAt, originId) {
677
+ try {
678
+ const now = Date.now();
679
+ const updated = await updateProgress(async (f) => {
680
+ const levelBefore = levelForXp(f.xp);
681
+ awardPrompt(f, now, cwd);
682
+ // Commit XP: probe repo root + HEAD, then score lastHead..HEAD bounded
683
+ // by the run window. First sight of a repo records HEAD, scores nothing.
684
+ const probe = await detectNewCommits(cwd, undefined, runStartedAt);
685
+ if (probe) {
686
+ const key = repoKey(probe.repoRoot);
687
+ const lastHead = f.repos[key]?.lastHead;
688
+ if (lastHead && lastHead !== probe.head) {
689
+ const detected = await detectNewCommits(cwd, lastHead, runStartedAt);
690
+ if (detected && detected.commits.length > 0) {
691
+ awardCommits(f, detected.commits, now, cwd);
692
+ }
693
+ }
694
+ f.repos[key] = { lastHead: probe.head };
695
+ }
696
+ // One combined lastEvent per run so other windows celebrate exactly once.
697
+ const levelAfter = levelForXp(f.xp);
698
+ const levelUp = levelAfter > levelBefore
699
+ ? { from: levelBefore, to: levelAfter, rankName: rankForLevel(levelAfter).name }
700
+ : null;
701
+ f.lastEvent = { nonce: randomUUID(), levelUp };
702
+ return { file: f, levelledUp: levelUp !== null };
703
+ });
704
+ file = updated;
705
+ lastSeenNonce = updated.lastEvent?.nonce ?? null;
706
+ broadcastAll(buildSnapshot(updated), originId);
707
+ }
708
+ catch (err) {
709
+ log("DEBUG", "app-sidecar", "progress award failed", {
710
+ message: err instanceof Error ? err.message : String(err),
711
+ });
712
+ }
713
+ }
714
+ // Watch ~/.gg (dir watch survives the atomic tmp+rename) for progress.json
715
+ // writes from other daemon processes; debounce, reload read-only, dedupe by nonce.
716
+ let watchDebounce = null;
717
+ try {
718
+ const watcher = fsWatch(agentDir, (_event, filename) => {
719
+ if (filename !== "progress.json")
720
+ return;
721
+ if (watchDebounce)
722
+ clearTimeout(watchDebounce);
723
+ watchDebounce = setTimeout(() => {
724
+ void (async () => {
725
+ const reloaded = await peekProgress();
726
+ if (!reloaded)
727
+ return;
728
+ const nonce = reloaded.lastEvent?.nonce ?? null;
729
+ if (nonce === lastSeenNonce)
730
+ return;
731
+ file = reloaded;
732
+ lastSeenNonce = nonce;
733
+ broadcastAll(buildSnapshot(reloaded));
734
+ })();
735
+ }, 150);
736
+ });
737
+ watcher.unref();
738
+ }
739
+ catch (err) {
740
+ log("DEBUG", "app-sidecar", "progress watch unavailable", {
741
+ message: err instanceof Error ? err.message : String(err),
742
+ });
743
+ }
744
+ return { snapshot, awardRun };
745
+ }
644
746
  /**
645
747
  * Build one in-process agent session: its AgentSession, SSE client set, event
646
748
  * bridge, task runner, auth/login bridge, and the full HTTP route table exposed
@@ -649,7 +751,7 @@ async function buildNolanContext(buildSession, cwd, gitBranch, question, workflo
649
751
  * logger, PATH, shared auth file, and radio live at the daemon level.
650
752
  */
651
753
  async function createSession(deps, opts) {
652
- const { auth } = deps;
754
+ const { auth, progress } = deps;
653
755
  const paths = deps.paths;
654
756
  const cwd = opts.cwd;
655
757
  // Base host for parsing request-URL query params (value is irrelevant to
@@ -710,6 +812,16 @@ async function createSession(deps, opts) {
710
812
  ...(f.statusCode != null ? { statusCode: f.statusCode } : {}),
711
813
  ...(f.resetsAt != null ? { resetsAt: f.resetsAt } : {}),
712
814
  });
815
+ // Persist the error row (display-only marker) so a resumed session shows
816
+ // the same headline/message/guidance the live run did. Best-effort.
817
+ void session
818
+ .persistAppMarker("error", {
819
+ scope: type,
820
+ headline: f.headline,
821
+ ...(f.message ? { message: f.message } : {}),
822
+ guidance: f.guidance,
823
+ })
824
+ .catch(() => { });
713
825
  }
714
826
  // The session file path to resume (passed by the daemon's POST /session);
715
827
  // empty/unset starts a fresh session.
@@ -732,10 +844,27 @@ async function createSession(deps, opts) {
732
844
  // session plan state (rebuilds the system prompt + enforces read-only
733
845
  // tools) and surface the transition to the webview.
734
846
  onEnterPlan: async (reason) => {
847
+ // During a task run there is no human present to approve a plan, and a
848
+ // submitted plan would strand the task in-progress while the run-all loop
849
+ // advances to the next one. Decline plan mode so the agent implements the
850
+ // task directly — mirrors the TUI's isUnattendedRun guard (App.tsx). The
851
+ // enter_plan tool turns a false return into an "implement directly" hint.
852
+ if (taskRunActive)
853
+ return false;
735
854
  await session.setPlanMode(true);
736
855
  broadcast("plan_enter", { reason: reason ?? "" });
856
+ // Persist the plan-mode banner so a resumed session still shows it.
857
+ void session.persistAppMarker("plan", { reason: reason ?? "" }).catch(() => { });
737
858
  },
738
859
  onExitPlan: async (planPath) => {
860
+ // A model can call exit_plan even when enter_plan was declined. During a
861
+ // task run, never route the plan into review (no approver) — that would
862
+ // hang the task and stall run-all. Leave plan mode and tell it to build.
863
+ if (taskRunActive) {
864
+ await session.setPlanMode(false);
865
+ return ("Plan review is unavailable during a task run. Skip review and implement " +
866
+ "the plan directly now: make the changes, verify them, then mark the task done.");
867
+ }
739
868
  await session.setPlanMode(false);
740
869
  // Surface the plan's path + markdown so the webview can show the review
741
870
  // modal (Accept / Feedback / Reject). Best-effort content read.
@@ -746,6 +875,12 @@ async function createSession(deps, opts) {
746
875
  catch {
747
876
  content = "";
748
877
  }
878
+ // Record the submitted plan so the autopilot gate can route this turn
879
+ // into a PLAN review instead of a stale work review (plan mode is
880
+ // already false here, so the gate's planMode check alone never catches
881
+ // a submission). setPendingPlan bumps planGeneration, which invalidates
882
+ // any in-flight Nolan plan review racing a user action.
883
+ setPendingPlan(planPath, content);
749
884
  broadcast("plan_exit", { planPath, content });
750
885
  return "Plan submitted for user review. Wait for the user to approve, reject, or dismiss it before implementing.";
751
886
  },
@@ -818,6 +953,9 @@ async function createSession(deps, opts) {
818
953
  session.eventBus.on("compaction_end", (d) => broadcast("compaction_end", d));
819
954
  let running = false;
820
955
  let titleGenerated = false;
956
+ // Bumped by /cancel — a run whose cancel generation changed mid-flight was
957
+ // canceled and earns no XP.
958
+ let cancelGeneration = 0;
821
959
  // Autopilot (auto-review) toggle for THIS window's project. Loaded from
822
960
  // ezcoder-app.json on boot; flipped via POST /autopilot. When on, POST /prompt runs
823
961
  // runAutopilotCycle after the user's turn settles — Nolan auto-reviews the work
@@ -842,6 +980,28 @@ async function createSession(deps, opts) {
842
980
  // cycles drift into Nolan reviewing against his own last prompt. Cleared
843
981
  // whenever the conversation resets (new session / plan accept / task run).
844
982
  let injectedAutopilotPrompts = [];
983
+ // The plan EZ Coder submitted via exit_plan that still awaits a decision
984
+ // (Nolan's auto-review in autopilot, or the user's modal). Path + the content
985
+ // read at submission time (fallback if the file becomes unreadable).
986
+ let pendingPlanPath = null;
987
+ let pendingPlanContent = "";
988
+ // Bumped on EVERY pending-plan set/clear. Nolan's plan review captures it
989
+ // before reviewing and re-checks it before acting on the verdict, so a user
990
+ // Accept/Reject racing an in-flight review always wins — the stale verdict
991
+ // is discarded silently.
992
+ let planGeneration = 0;
993
+ function setPendingPlan(planPath, content) {
994
+ pendingPlanPath = planPath;
995
+ pendingPlanContent = content;
996
+ planGeneration++;
997
+ }
998
+ function clearPendingPlan() {
999
+ if (pendingPlanPath === null)
1000
+ return;
1001
+ pendingPlanPath = null;
1002
+ pendingPlanContent = "";
1003
+ planGeneration++;
1004
+ }
845
1005
  // Workflow (prompt-template) commands: built-in + the project's custom
846
1006
  // `.ezcoder/commands/*.md`. Used to gate autopilot off command turns and to label
847
1007
  // expanded templates in Nolan's digests. Loaded fresh so a newly added custom
@@ -918,11 +1078,14 @@ async function createSession(deps, opts) {
918
1078
  provider: target.provider,
919
1079
  model: target.model,
920
1080
  cwd,
921
- systemPrompt: buildNolanSystemPrompt(),
1081
+ systemPrompt: await buildNolanSystemPrompt(cwd),
922
1082
  allowedTools: NOLAN_ALLOWED_TOOLS,
923
1083
  allowedMcpServers: NOLAN_ALLOWED_MCP_SERVERS,
924
1084
  transient: true,
925
1085
  signal: nolanAbort.signal,
1086
+ // Nolan's bursty, spread-out turns (chat) outlast the default 5-min cache
1087
+ // TTL regardless of the user's global speedProfile pick.
1088
+ forceLongCacheRetention: true,
926
1089
  });
927
1090
  await ken.initialize();
928
1091
  // Bridge Nolan's bus to the shared SSE fan-out with ken_-prefixed types so the
@@ -984,11 +1147,14 @@ async function createSession(deps, opts) {
984
1147
  provider: target.provider,
985
1148
  model: target.model,
986
1149
  cwd,
987
- systemPrompt: buildNolanAutopilotSystemPrompt(),
1150
+ systemPrompt: await buildNolanAutopilotSystemPrompt(cwd),
988
1151
  allowedTools: NOLAN_ALLOWED_TOOLS,
989
1152
  allowedMcpServers: NOLAN_ALLOWED_MCP_SERVERS,
990
1153
  transient: true,
991
1154
  signal: nolanAutoAbort.signal,
1155
+ // Autopilot review rounds routinely span the injected EZ Coder run
1156
+ // (often >5 min) regardless of the user's global speedProfile pick.
1157
+ forceLongCacheRetention: true,
992
1158
  });
993
1159
  await ken.initialize();
994
1160
  // Deliberately no bus bridge: the review is silent. Errors surface via the
@@ -1020,15 +1186,28 @@ async function createSession(deps, opts) {
1020
1186
  // run_start frame.
1021
1187
  async function runAgent(label, run) {
1022
1188
  running = true;
1189
+ // Progress (Ranks): completed, non-canceled runs with ≥1 assistant turn earn
1190
+ // XP — prompt + any commits authored during the run window.
1191
+ const runStartedAt = Date.now();
1192
+ const cancelGenAtStart = cancelGeneration;
1193
+ const assistantsBeforeRun = countAssistantMessages(session.getMessages());
1194
+ let runSucceeded = false;
1023
1195
  broadcast("run_start", { text: label });
1024
1196
  try {
1025
1197
  await run();
1198
+ runSucceeded = true;
1026
1199
  }
1027
1200
  catch (err) {
1028
1201
  broadcastError("error", "run failed", err);
1029
1202
  }
1030
1203
  finally {
1031
1204
  running = false;
1205
+ if (runSucceeded &&
1206
+ cancelGeneration === cancelGenAtStart &&
1207
+ countAssistantMessages(session.getMessages()) > assistantsBeforeRun) {
1208
+ // Fire-and-forget — XP must never delay or break run teardown.
1209
+ void progress.awardRun(cwd, runStartedAt, opts.id);
1210
+ }
1032
1211
  // A run may have switched branches (git checkout) or spawned/finished
1033
1212
  // background tasks — refresh the footer extras once it settles.
1034
1213
  gitBranch = await getGitBranch(cwd).catch(() => gitBranch);
@@ -1068,9 +1247,7 @@ async function createSession(deps, opts) {
1068
1247
  broadcast("autopilot_review_start", {});
1069
1248
  try {
1070
1249
  const ken = await ensureNolanAutoSession();
1071
- const projectContext = await collectProjectContext(cwd).catch(() => []);
1072
1250
  const digest = buildNolanAutopilotContext({
1073
- projectContext,
1074
1251
  cwd,
1075
1252
  gitBranch,
1076
1253
  messages: session.getMessages(),
@@ -1094,6 +1271,60 @@ async function createSession(deps, opts) {
1094
1271
  await syncNolanAutoModel(pending.provider, pending.model);
1095
1272
  }
1096
1273
  }
1274
+ // One PLAN review: like runAutopilotReview but the digest carries the
1275
+ // submitted plan's markdown (`## Plan under review`) and the plan-review
1276
+ // instruction — Nolan judges the plan itself, not finished work. Returns null
1277
+ // on failure; a failure caused by the user's own action racing the review
1278
+ // (cancel or a manual Accept/Reject that bumped planGeneration) stays
1279
+ // SILENT — no autopilot_error — because the user's decision already won.
1280
+ async function runAutopilotPlanReview(originalRequest) {
1281
+ const planPath = pendingPlanPath;
1282
+ if (planPath === null)
1283
+ return null;
1284
+ const genAtStart = planGeneration;
1285
+ autopilotReviewing = true;
1286
+ broadcast("autopilot_review_start", {});
1287
+ try {
1288
+ const ken = await ensureNolanAutoSession();
1289
+ // Re-read the plan file (the run may have revised it in place); fall
1290
+ // back to the content captured at exit_plan time.
1291
+ const planContent = await fs.readFile(planPath, "utf-8").catch(() => pendingPlanContent);
1292
+ const digest = buildNolanAutopilotPlanContext({
1293
+ cwd,
1294
+ gitBranch,
1295
+ messages: session.getMessages(),
1296
+ originalRequest,
1297
+ injectedPrompts: [...injectedAutopilotPrompts],
1298
+ workflowCommands: await loadWorkflowCommandSpecs(),
1299
+ planContent,
1300
+ });
1301
+ await ken.prompt(digest);
1302
+ if (autopilotCancelled || planGeneration !== genAtStart)
1303
+ return null;
1304
+ return parseAutopilotVerdict(lastAssistantText(ken.getMessages()));
1305
+ }
1306
+ catch (err) {
1307
+ // User action mid-review (manual Accept aborts the nolanAuto run): drop
1308
+ // the review silently — the user's decision supersedes Nolan's.
1309
+ if (autopilotCancelled || planGeneration !== genAtStart)
1310
+ return null;
1311
+ broadcastError("autopilot_error", "autopilot plan review failed", err);
1312
+ return null;
1313
+ }
1314
+ finally {
1315
+ autopilotReviewing = false;
1316
+ // Apply any model switch that landed mid-review.
1317
+ const pending = pendingNolanAutoModel;
1318
+ pendingNolanAutoModel = null;
1319
+ if (pending)
1320
+ await syncNolanAutoModel(pending.provider, pending.model);
1321
+ }
1322
+ }
1323
+ // The prompt fed to the fresh session after a plan is accepted — the SAME
1324
+ // string the webview sends on a manual Accept (see PlanReviewModal's accept
1325
+ // handler in ezcoder-app/src/App.tsx). Keep the two in lockstep so auto- and
1326
+ // manual approval produce identical implementation turns.
1327
+ const IMPLEMENT_PLAN_PROMPT = "The plan has been approved. Implement it now, following each step in order.";
1097
1328
  // Drive the review→prompt→review loop for one finished user turn. Only ever
1098
1329
  // called after shouldStartAutopilotCycle approves the turn (POST /prompt or
1099
1330
  // the stranded-queue drain) — never from the task runner, resume, /ken, or
@@ -1104,14 +1335,61 @@ async function createSession(deps, opts) {
1104
1335
  if (!autopilot || autopilotCancelled)
1105
1336
  return;
1106
1337
  autopilotActive = true;
1338
+ // Generation captured by the last plan review; acceptPlan re-checks it so
1339
+ // a user Accept/Reject landing mid-review always wins.
1340
+ let planGenAtReview = -1;
1107
1341
  try {
1108
1342
  await driveAutopilotCycle({
1109
- maxRounds: MAX_AUTOPILOT_ROUNDS,
1343
+ // A plan-pending cycle needs extra rounds: approve+implement and the
1344
+ // post-implement work review each consume one, so +2 keeps a real fix
1345
+ // round available.
1346
+ maxRounds: pendingPlanPath !== null ? MAX_AUTOPILOT_ROUNDS + 2 : MAX_AUTOPILOT_ROUNDS,
1110
1347
  isCancelled: () => autopilotCancelled,
1111
- // An injected run entering plan mode halts the cycle (autopilot_human
1112
- // with the plan-hold reason) — Nolan never prompts into a read-only
1113
- // plan-mode session or answers the plan modal for the user.
1348
+ // An injected run entering plan mode WITHOUT submitting (enter_plan,
1349
+ // no exit_plan) halts the cycle — Nolan never prompts into a read-only
1350
+ // plan-mode session. A submitted plan takes the planPending branch.
1114
1351
  isPlanMode: () => session.getPlanMode(),
1352
+ planPending: () => pendingPlanPath !== null,
1353
+ reviewPlan: async () => {
1354
+ planGenAtReview = planGeneration;
1355
+ return runAutopilotPlanReview(originalRequest);
1356
+ },
1357
+ // Auto-accept: the inlined POST /plan/accept body. Returns false when
1358
+ // the plan generation moved since the review (user acted) — the cycle
1359
+ // exits silently and the user's action stands.
1360
+ acceptPlan: async () => {
1361
+ if (pendingPlanPath === null || planGeneration !== planGenAtReview)
1362
+ return false;
1363
+ const planPath = pendingPlanPath;
1364
+ try {
1365
+ await session.newSession();
1366
+ injectedAutopilotPrompts = [];
1367
+ titleGenerated = false;
1368
+ await session.setApprovedPlan(planPath);
1369
+ }
1370
+ catch (err) {
1371
+ broadcastError("autopilot_error", "autopilot plan accept failed", err);
1372
+ return false;
1373
+ }
1374
+ clearPendingPlan();
1375
+ // Ordering is load-bearing: the webview reads its still-open plan
1376
+ // modal state (step count) on autopilot_plan_accepted, and
1377
+ // session_reset clears it — accepted must land first.
1378
+ broadcast("autopilot_plan_accepted", {});
1379
+ broadcast("session_reset", {});
1380
+ // Persisted into the NEW session so a resume shows the marker.
1381
+ void session.persistAutopilotMarker("plan_approved");
1382
+ return true;
1383
+ },
1384
+ runImplement: () => {
1385
+ // Autopilot-injected run: frame it so EZ Coder knows no human is
1386
+ // watching the implementation. Record the framed string so Nolan's
1387
+ // digest labels it as injected, not as the user's ask. The run_start
1388
+ // label stays the clean prompt.
1389
+ const framed = frameAutopilotInjection(IMPLEMENT_PLAN_PROMPT);
1390
+ injectedAutopilotPrompts.push(framed);
1391
+ return runAgent(IMPLEMENT_PLAN_PROMPT, () => session.prompt(framed));
1392
+ },
1115
1393
  // Lean context per user turn: wipe prior review history so each new
1116
1394
  // turn starts cheap, while within this cycle the few review messages
1117
1395
  // persist so Nolan remembers what he already asked EZ Coder to fix.
@@ -1125,11 +1403,47 @@ async function createSession(deps, opts) {
1125
1403
  // streams normally; the shared finally never re-triggers autopilot,
1126
1404
  // so this can't recurse.
1127
1405
  onInjected: (body, round) => {
1128
- injectedAutopilotPrompts.push(body);
1406
+ // A revision injection supersedes the pending plan — if the run
1407
+ // resubmits via exit_plan, onExitPlan re-sets it (no-op for work-
1408
+ // branch injections, where nothing is pending).
1409
+ clearPendingPlan();
1410
+ // Record the FRAMED string (what actually lands in the build session,
1411
+ // see runPrompt) so Nolan's digest matches and labels it as injected.
1412
+ // The webview marker + persisted body stay the CLEAN prompt so the UI
1413
+ // shows Nolan's actual instruction, not the autopilot preamble.
1414
+ injectedAutopilotPrompts.push(frameAutopilotInjection(body));
1129
1415
  broadcast("autopilot_prompted", { round, body });
1416
+ void session.persistAutopilotMarker("prompted", { body });
1417
+ },
1418
+ // Autopilot-injected run: EZ Coder receives the framed prompt (no human
1419
+ // is watching this turn) while run_start keeps the clean label.
1420
+ runPrompt: (body) => runAgent(body, () => session.prompt(frameAutopilotInjection(body))),
1421
+ emit: (event) => {
1422
+ // Persist the terminal verdict marker so a resumed session renders the
1423
+ // same Nolan bubble the live run showed instead of dropping it or
1424
+ // falling back to the raw verdict text (e.g. ALL_CLEAR).
1425
+ if (event.type === "autopilot_done") {
1426
+ // Broadcast the SAME copySeed the persisted marker will produce on
1427
+ // resume, so the live all-clear wording matches the resumed one
1428
+ // (computed before persist — same synchronous message count).
1429
+ const seed = autopilotMarkerCopySeed({
1430
+ version: 1,
1431
+ phase: "done",
1432
+ afterMessageCount: session.getMessages().filter((m) => m.role !== "system").length,
1433
+ });
1434
+ broadcast(event.type, { ...event.data, copySeed: seed });
1435
+ void session.persistAutopilotMarker("done");
1436
+ return;
1437
+ }
1438
+ broadcast(event.type, event.data);
1439
+ if (event.type === "autopilot_human") {
1440
+ void session.persistAutopilotMarker("human", { reason: event.data.reason });
1441
+ }
1442
+ else if (event.type === "autopilot_capped") {
1443
+ void session.persistAutopilotMarker("capped");
1444
+ }
1445
+ // autopilot_ignored renders nothing live, so nothing is persisted either.
1130
1446
  },
1131
- runPrompt: (body) => runAgent(body, () => session.prompt(body)),
1132
- emit: (event) => broadcast(event.type, event.data),
1133
1447
  });
1134
1448
  }
1135
1449
  finally {
@@ -1160,9 +1474,13 @@ async function createSession(deps, opts) {
1160
1474
  broadcast("queued", { count: session.getQueuedCount() });
1161
1475
  if (!next.text.trim() && next.attachments.length === 0)
1162
1476
  continue;
1477
+ // A queued message draining as a fresh turn supersedes any pending
1478
+ // plan, exactly like a direct POST /prompt turn.
1479
+ clearPendingPlan();
1163
1480
  const workflowCommand = next.attachments.length === 0 &&
1164
1481
  isWorkflowCommandText(next.text, await loadWorkflowCommandSpecs());
1165
1482
  const assistantsBefore = countAssistantMessages(session.getMessages());
1483
+ const messagesBefore = session.getMessages().length;
1166
1484
  await runAgent(next.text, async () => {
1167
1485
  if (next.attachments.length > 0) {
1168
1486
  await session.promptWithAttachments(next.text, next.attachments);
@@ -1175,10 +1493,21 @@ async function createSession(deps, opts) {
1175
1493
  enabled: autopilot,
1176
1494
  cancelled: autopilotCancelled,
1177
1495
  planMode: session.getPlanMode(),
1496
+ // A submitted plan (exit_plan fired) routes into the PLAN review
1497
+ // branch — the cycle reviews the plan itself instead of skipping.
1498
+ planPending: pendingPlanPath !== null,
1178
1499
  workflowCommand,
1179
1500
  assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
1501
+ // Skip the review API call outright for turns that only started a
1502
+ // background process (dev server/watcher), ran a read-only lookup, or
1503
+ // committed/pushed — Nolan's autopilot contract already IGNOREs these,
1504
+ // so there's no reason to pay for that verdict.
1505
+ mechanicalOnly: isMechanicalOnlyTurn(extractTurnToolCalls(session.getMessages(), messagesBefore)),
1180
1506
  });
1181
1507
  if (decision.start) {
1508
+ log("INFO", "app-sidecar", "autopilot cycle starting (queued turn)", {
1509
+ kind: decision.kind,
1510
+ });
1182
1511
  await runAutopilotCycle(next.text);
1183
1512
  }
1184
1513
  else if (autopilot) {
@@ -1197,18 +1526,34 @@ async function createSession(deps, opts) {
1197
1526
  // completion hint instructing the agent to mark the task done via the tasks
1198
1527
  // tool. Run-all advances to the next pending task after each run finishes.
1199
1528
  let taskRunAll = false;
1529
+ // True for the duration of any task run (single or run-all). The plan-mode
1530
+ // callbacks read this to decline plan mode when no human is present to
1531
+ // approve a plan, so tasks implement directly instead of hanging on review.
1532
+ let taskRunActive = false;
1200
1533
  async function runTaskById(taskId) {
1201
1534
  const task = loadTasksSync(cwd).find((t) => t.id === taskId || t.id.startsWith(taskId));
1202
1535
  if (!task)
1203
1536
  return false;
1537
+ taskRunActive = true;
1538
+ try {
1539
+ return await runTaskByIdInner(task);
1540
+ }
1541
+ finally {
1542
+ taskRunActive = false;
1543
+ }
1544
+ }
1545
+ async function runTaskByIdInner(task) {
1204
1546
  // Fresh session per task so one task's context never bleeds into the next.
1205
1547
  await session.newSession();
1206
1548
  injectedAutopilotPrompts = [];
1549
+ clearPendingPlan();
1207
1550
  titleGenerated = false;
1208
1551
  broadcast("session_reset", {});
1209
1552
  markTaskInProgress(cwd, task.id);
1210
1553
  broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
1211
1554
  broadcast("task_start", { id: task.id, title: task.title });
1555
+ // Persist the task header so a resumed task session shows what ran.
1556
+ void session.persistAppMarker("task", { title: task.title }).catch(() => { });
1212
1557
  const shortId = task.id.slice(0, 8);
1213
1558
  const completionHint = `\n\n---\nWhen you have fully completed this task, call the tasks tool to mark it done:\n` +
1214
1559
  `tasks({ action: "done", id: "${shortId}" })`;
@@ -1319,6 +1664,10 @@ async function createSession(deps, opts) {
1319
1664
  });
1320
1665
  return;
1321
1666
  }
1667
+ if (method === "GET" && url === "/progress") {
1668
+ json(res, 200, progress.snapshot());
1669
+ return;
1670
+ }
1322
1671
  if (method === "GET" && (url === "/events" || url.startsWith("/events?"))) {
1323
1672
  res.writeHead(200, {
1324
1673
  "content-type": "text/event-stream",
@@ -1498,8 +1847,11 @@ async function createSession(deps, opts) {
1498
1847
  // they were recorded after, so each lands right after that message. A
1499
1848
  // turn becomes two wire rows: the `@Nolan` question (user) + Nolan's reply
1500
1849
  // (assistant), both flagged `ken` so the webview tints them.
1850
+ // Deduped; stale anchors are clamped to the last message (Nolan turns
1851
+ // carry real conversation, so they render at the end instead of
1852
+ // vanishing).
1501
1853
  const nolanByCount = new Map();
1502
- for (const turn of session.getNolanTurns()) {
1854
+ for (const turn of normalizeNolanTurnsForHistory(session.getNolanTurns(), messages.filter((m) => m.role !== "system").length)) {
1503
1855
  const list = nolanByCount.get(turn.afterMessageCount) ?? [];
1504
1856
  list.push(turn);
1505
1857
  nolanByCount.set(turn.afterMessageCount, list);
@@ -1514,9 +1866,103 @@ async function createSession(deps, opts) {
1514
1866
  history.push({ role: "assistant", text: turn.reply, ken: true });
1515
1867
  }
1516
1868
  };
1869
+ // Autopilot verdict markers to interleave, same anchor scheme as Nolan
1870
+ // turns — each becomes a single assistant row the webview renders
1871
+ // exactly like the live `autopilot` item (never a raw verdict string).
1872
+ // Compact/continuation rewrites can carry old markers whose original
1873
+ // afterMessageCount is beyond the restored message list; dropping those
1874
+ // prevents stale all-clear bubbles from bunching at the bottom on resume.
1875
+ const restoredMessageCount = messages.filter((m) => m.role !== "system").length;
1876
+ const autopilotByCount = new Map();
1877
+ for (const marker of normalizeAutopilotMarkersForHistory(session.getAutopilotMarkers(), restoredMessageCount)) {
1878
+ const list = autopilotByCount.get(marker.afterMessageCount) ?? [];
1879
+ list.push(marker);
1880
+ autopilotByCount.set(marker.afterMessageCount, list);
1881
+ }
1882
+ const flushAutopilot = (count) => {
1883
+ const markers = autopilotByCount.get(count);
1884
+ if (!markers)
1885
+ return;
1886
+ autopilotByCount.delete(count);
1887
+ for (const marker of markers) {
1888
+ history.push({
1889
+ role: "assistant",
1890
+ text: "",
1891
+ autopilot: {
1892
+ phase: marker.phase,
1893
+ ...(marker.reason !== undefined ? { reason: marker.reason } : {}),
1894
+ ...(marker.body !== undefined ? { body: marker.body } : {}),
1895
+ copySeed: marker.copySeed,
1896
+ },
1897
+ });
1898
+ }
1899
+ };
1900
+ // App transcript markers (plan banner / task header / error rows /
1901
+ // user-bubble hints), same anchor scheme. user_hint markers don't
1902
+ // become rows — they decorate the user row at their anchor instead.
1903
+ const appMarkersByCount = new Map();
1904
+ const userHintByCount = new Map();
1905
+ // Compaction-count markers pair with compacted summary rows in file
1906
+ // order (FIFO), not by anchor — the summary user message is what
1907
+ // positions the notice.
1908
+ const compactionCounts = [];
1909
+ for (const marker of normalizeAppMarkersForHistory(session.getAppMarkers(), restoredMessageCount)) {
1910
+ if (marker.kind === "user_hint") {
1911
+ userHintByCount.set(marker.afterMessageCount, marker.data);
1912
+ continue;
1913
+ }
1914
+ if (marker.kind === "compaction") {
1915
+ const d = marker.data;
1916
+ if (typeof d.originalCount === "number" && typeof d.newCount === "number") {
1917
+ compactionCounts.push({ originalCount: d.originalCount, newCount: d.newCount });
1918
+ }
1919
+ continue;
1920
+ }
1921
+ const list = appMarkersByCount.get(marker.afterMessageCount) ?? [];
1922
+ list.push(marker);
1923
+ appMarkersByCount.set(marker.afterMessageCount, list);
1924
+ }
1925
+ const flushAppMarkers = (count) => {
1926
+ const markers = appMarkersByCount.get(count);
1927
+ if (!markers)
1928
+ return;
1929
+ appMarkersByCount.delete(count);
1930
+ for (const marker of markers) {
1931
+ const d = marker.data;
1932
+ if (marker.kind === "plan") {
1933
+ history.push({
1934
+ role: "assistant",
1935
+ text: "",
1936
+ plan: { reason: typeof d.reason === "string" ? d.reason : "" },
1937
+ });
1938
+ }
1939
+ else if (marker.kind === "task") {
1940
+ history.push({
1941
+ role: "assistant",
1942
+ text: "",
1943
+ task: { title: typeof d.title === "string" ? d.title : "" },
1944
+ });
1945
+ }
1946
+ else if (marker.kind === "error" && typeof d.headline === "string") {
1947
+ history.push({
1948
+ role: "assistant",
1949
+ text: "",
1950
+ error: {
1951
+ scope: typeof d.scope === "string" ? d.scope : "error",
1952
+ headline: d.headline,
1953
+ ...(typeof d.message === "string" ? { message: d.message } : {}),
1954
+ ...(typeof d.guidance === "string" ? { guidance: d.guidance } : {}),
1955
+ },
1956
+ });
1957
+ }
1958
+ }
1959
+ };
1517
1960
  let nonSystemCount = 0;
1518
- // Turns recorded before any build message (anchor 0) render at the top.
1961
+ // Turns/markers recorded before any build message (anchor 0) render at
1962
+ // the top.
1519
1963
  flushNolan(0);
1964
+ flushAutopilot(0);
1965
+ flushAppMarkers(0);
1520
1966
  for (const msg of messages) {
1521
1967
  if (msg.role === "system")
1522
1968
  continue;
@@ -1566,30 +2012,54 @@ async function createSession(deps, opts) {
1566
2012
  }
1567
2013
  continue;
1568
2014
  }
1569
- // User or assistant message — existing text/hook/command/compacted
1570
- // extraction, plus sub-agent group detection for assistant tool_calls.
1571
- const text = typeof msg.content === "string"
1572
- ? msg.content
1573
- : msg.content
1574
- .map((c) => c.type === "text" && "text" in c && typeof c.text === "string" ? c.text : "")
1575
- .join("");
1576
- const images = typeof msg.content === "string"
1577
- ? []
1578
- : msg.content.flatMap((c) => c.type === "image" ? [`data:${c.mediaType};base64,${c.data}`] : []);
1579
- const hook = msg.role === "user" ? detectHookKind(text) : null;
1580
- const compacted = msg.role === "user" && !hook && text.startsWith("[Previous conversation summary]");
1581
- const command = msg.role === "user" && !hook && !compacted
1582
- ? detectPromptCommand(text, commandCandidates)
1583
- : null;
1584
- if (text.trim() || images.length > 0) {
1585
- history.push({
1586
- role: msg.role,
1587
- text: command ?? text,
1588
- images,
1589
- hook,
1590
- command: command !== null,
1591
- compacted,
1592
- });
2015
+ // User or assistant message — text/hook/command/compacted extraction,
2016
+ // plus sub-agent group detection for assistant tool_calls.
2017
+ if (msg.role === "user") {
2018
+ // Rebuild the live bubble: strip the steering wrapper, drop
2019
+ // attachment/file notes the model saw but the bubble never showed.
2020
+ const restored = restoreUserRow(msg.content);
2021
+ const text = restored.text;
2022
+ const hook = detectHookKind(text);
2023
+ const compacted = !hook && text.startsWith("[Previous conversation summary]");
2024
+ const command = !hook && !compacted ? detectPromptCommand(text, commandCandidates) : null;
2025
+ if (text.trim() || restored.images.length > 0) {
2026
+ const hint = userHintByCount.get(nonSystemCount);
2027
+ history.push({
2028
+ role: "user",
2029
+ text: command ?? text,
2030
+ images: restored.images,
2031
+ hook,
2032
+ command: command !== null,
2033
+ compacted,
2034
+ // Markers accumulate across continuation files (each rewrite
2035
+ // re-persists prior ones) but only the LATEST summary row
2036
+ // survives compaction — so consume from the newest end.
2037
+ ...(compacted && compactionCounts.length > 0
2038
+ ? { compactionCounts: compactionCounts.pop() }
2039
+ : {}),
2040
+ ...(hint?.nolanSent === true ? { nolanSent: true } : {}),
2041
+ ...(Array.isArray(hint?.enhancements) ? { enhancements: hint.enhancements } : {}),
2042
+ });
2043
+ // Live showed the video-capability warning right after the bubble.
2044
+ if (restored.videoWarning) {
2045
+ history.push({ role: "assistant", text: "", infoKind: "video_warning" });
2046
+ }
2047
+ }
2048
+ }
2049
+ else {
2050
+ // Assistant: one wire row per persisted text block — live streaming
2051
+ // splits bubbles at server_tool_call boundaries, and the persisted
2052
+ // content keeps those blocks separate.
2053
+ for (const blockText of restoreAssistantTexts(msg.content)) {
2054
+ history.push({
2055
+ role: "assistant",
2056
+ text: blockText,
2057
+ images: [],
2058
+ hook: null,
2059
+ command: false,
2060
+ compacted: false,
2061
+ });
2062
+ }
1593
2063
  }
1594
2064
  // Assistant tool_call blocks: detect sub-agent delegations.
1595
2065
  if (msg.role === "assistant" && typeof msg.content !== "string") {
@@ -1610,14 +2080,21 @@ async function createSession(deps, opts) {
1610
2080
  });
1611
2081
  }
1612
2082
  }
1613
- // Interleave any Nolan turns recorded right after this message.
2083
+ // Interleave any Nolan turns / autopilot / app markers recorded right
2084
+ // after this message.
1614
2085
  flushNolan(nonSystemCount);
2086
+ flushAutopilot(nonSystemCount);
2087
+ flushAppMarkers(nonSystemCount);
1615
2088
  }
1616
- // Flush remaining Nolan turns whose anchor is at/after the message count
1617
- // (e.g. asked before any build message, or anchors beyond the current
1618
- // count after compaction shrank the history) so none are dropped.
2089
+ // Flush remaining Nolan turns whose anchor is at/after the message count so
2090
+ // none are dropped. Autopilot/app markers beyond the restored message
2091
+ // count were already filtered above; any remaining marker here is valid.
1619
2092
  for (const count of [...nolanByCount.keys()].sort((a, b) => a - b))
1620
2093
  flushNolan(count);
2094
+ for (const count of [...autopilotByCount.keys()].sort((a, b) => a - b))
2095
+ flushAutopilot(count);
2096
+ for (const count of [...appMarkersByCount.keys()].sort((a, b) => a - b))
2097
+ flushAppMarkers(count);
1621
2098
  json(res, 200, { history });
1622
2099
  })();
1623
2100
  return;
@@ -1650,10 +2127,12 @@ async function createSession(deps, opts) {
1650
2127
  void readBody(req).then(async (raw) => {
1651
2128
  let text;
1652
2129
  let attachments;
2130
+ let meta;
1653
2131
  try {
1654
2132
  const body = JSON.parse(raw);
1655
2133
  text = body.text ?? "";
1656
2134
  attachments = Array.isArray(body.attachments) ? body.attachments : [];
2135
+ meta = typeof body.meta === "object" && body.meta !== null ? body.meta : undefined;
1657
2136
  }
1658
2137
  catch {
1659
2138
  json(res, 400, { error: "invalid JSON body" });
@@ -1677,9 +2156,25 @@ async function createSession(deps, opts) {
1677
2156
  return;
1678
2157
  }
1679
2158
  json(res, 202, { accepted: true });
2159
+ // Webview display hint for this prompt's user bubble (nolanSent shimmer
2160
+ // label / enhancer highlight segments). Anchored +1 so it attaches to
2161
+ // the user message the prompt below is about to push. Queued prompts
2162
+ // skip this (their position in the run is unpredictable).
2163
+ if (meta && (meta.nolanSent === true || Array.isArray(meta.enhancements))) {
2164
+ void session
2165
+ .persistAppMarker("user_hint", {
2166
+ ...(meta.nolanSent === true ? { nolanSent: true } : {}),
2167
+ ...(Array.isArray(meta.enhancements) ? { enhancements: meta.enhancements } : {}),
2168
+ }, 1)
2169
+ .catch(() => { });
2170
+ }
1680
2171
  // Fresh user turn: clear any cancel flag left from a prior cycle so this
1681
2172
  // turn's autopilot review can run.
1682
2173
  autopilotCancelled = false;
2174
+ // A typed message while a plan modal/review is pending (reject,
2175
+ // feedback, anything) supersedes the pending plan — the bump also
2176
+ // invalidates any in-flight Nolan plan review.
2177
+ clearPendingPlan();
1683
2178
  // Gate inputs captured around the run: whether this turn is a workflow
1684
2179
  // slash command (attachment prompts skip slash expansion entirely), and
1685
2180
  // how many assistant messages the run actually adds. Computed even when
@@ -1687,6 +2182,7 @@ async function createSession(deps, opts) {
1687
2182
  // gate reads the post-run value.
1688
2183
  const workflowCommand = attachments.length === 0 && isWorkflowCommandText(text, await loadWorkflowCommandSpecs());
1689
2184
  const assistantsBefore = countAssistantMessages(session.getMessages());
2185
+ const messagesBefore = session.getMessages().length;
1690
2186
  await runAgent(text, async () => {
1691
2187
  if (attachments.length > 0) {
1692
2188
  // Persist each attachment under .ezcoder/uploads so files are inspectable
@@ -1715,10 +2211,19 @@ async function createSession(deps, opts) {
1715
2211
  enabled: autopilot,
1716
2212
  cancelled: autopilotCancelled,
1717
2213
  planMode: session.getPlanMode(),
2214
+ // A submitted plan (exit_plan fired) routes into the PLAN review
2215
+ // branch — the cycle reviews the plan itself instead of skipping.
2216
+ planPending: pendingPlanPath !== null,
1718
2217
  workflowCommand,
1719
2218
  assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
2219
+ // Skip the review API call outright for turns that only started a
2220
+ // background process (dev server/watcher), ran a read-only lookup, or
2221
+ // committed/pushed — Nolan's autopilot contract already IGNOREs these,
2222
+ // so there's no reason to pay for that verdict.
2223
+ mechanicalOnly: isMechanicalOnlyTurn(extractTurnToolCalls(session.getMessages(), messagesBefore)),
1720
2224
  });
1721
2225
  if (decision.start) {
2226
+ log("INFO", "app-sidecar", "autopilot cycle starting", { kind: decision.kind });
1722
2227
  await runAutopilotCycle(text);
1723
2228
  }
1724
2229
  else if (autopilot) {
@@ -2175,12 +2680,14 @@ async function createSession(deps, opts) {
2175
2680
  return;
2176
2681
  }
2177
2682
  if (method === "POST" && url === "/cancel") {
2683
+ cancelGeneration++;
2178
2684
  abort.abort();
2179
2685
  abort = new AbortController();
2180
2686
  session.setSignal(abort.signal);
2181
2687
  running = false;
2182
2688
  // Stop a run-all sweep so the next pending task isn't auto-started.
2183
2689
  taskRunAll = false;
2690
+ taskRunActive = false;
2184
2691
  // Stop any in-flight autopilot cycle: flag it so the loop bails between
2185
2692
  // steps, and abort a review that's mid-prompt on the nolanAuto session.
2186
2693
  autopilotCancelled = true;
@@ -2205,6 +2712,7 @@ async function createSession(deps, opts) {
2205
2712
  .newSession()
2206
2713
  .then(() => {
2207
2714
  injectedAutopilotPrompts = [];
2715
+ clearPendingPlan();
2208
2716
  broadcast("session_reset", {});
2209
2717
  json(res, 200, { ok: true });
2210
2718
  })
@@ -2236,6 +2744,24 @@ async function createSession(deps, opts) {
2236
2744
  json(res, 409, { error: "cannot accept a plan while the agent is running" });
2237
2745
  return;
2238
2746
  }
2747
+ // Manual accept, possibly racing Nolan's autopilot plan review: the user
2748
+ // always wins. Bump the plan generation (invalidates any in-flight
2749
+ // review's verdict), stop the cycle, abort a mid-prompt review on the
2750
+ // nolanAuto session, and clear the spinner — autopilot_ignored renders
2751
+ // nothing, so no stale "approve or reject" bubble ever lands. The
2752
+ // webview's follow-up "implement" /prompt arrives as a fresh turn
2753
+ // (resetting autopilotCancelled), so the implementation still gets its
2754
+ // normal post-run review; if it lands while the cycle is winding down
2755
+ // it queues and runStrandedQueue drains it as a fresh turn.
2756
+ clearPendingPlan();
2757
+ autopilotCancelled = true;
2758
+ nolanAutoAbort.abort();
2759
+ nolanAutoAbort = new AbortController();
2760
+ nolanAutoSession?.setSignal(nolanAutoAbort.signal);
2761
+ if (autopilotReviewing) {
2762
+ autopilotReviewing = false;
2763
+ broadcast("autopilot_ignored", {});
2764
+ }
2239
2765
  try {
2240
2766
  await session.newSession();
2241
2767
  injectedAutopilotPrompts = [];