@prestyj/cli 5.2.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 (192) hide show
  1. package/dist/app-sidecar.js +856 -91
  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-queue.test.d.ts +2 -0
  6. package/dist/core/agent-session-queue.test.d.ts.map +1 -0
  7. package/dist/core/agent-session-queue.test.js +122 -0
  8. package/dist/core/agent-session-queue.test.js.map +1 -0
  9. package/dist/core/agent-session.d.ts +79 -3
  10. package/dist/core/agent-session.d.ts.map +1 -1
  11. package/dist/core/agent-session.js +268 -47
  12. package/dist/core/agent-session.js.map +1 -1
  13. package/dist/core/autopilot-cycle.d.ts +119 -0
  14. package/dist/core/autopilot-cycle.d.ts.map +1 -0
  15. package/dist/core/autopilot-cycle.js +119 -0
  16. package/dist/core/autopilot-cycle.js.map +1 -0
  17. package/dist/core/autopilot-cycle.test.d.ts +2 -0
  18. package/dist/core/autopilot-cycle.test.d.ts.map +1 -0
  19. package/dist/core/autopilot-cycle.test.js +375 -0
  20. package/dist/core/autopilot-cycle.test.js.map +1 -0
  21. package/dist/core/autopilot-gate.d.ts +109 -0
  22. package/dist/core/autopilot-gate.d.ts.map +1 -0
  23. package/dist/core/autopilot-gate.js +205 -0
  24. package/dist/core/autopilot-gate.js.map +1 -0
  25. package/dist/core/autopilot-gate.test.d.ts +2 -0
  26. package/dist/core/autopilot-gate.test.d.ts.map +1 -0
  27. package/dist/core/autopilot-gate.test.js +295 -0
  28. package/dist/core/autopilot-gate.test.js.map +1 -0
  29. package/dist/core/autopilot-verdict.d.ts +20 -2
  30. package/dist/core/autopilot-verdict.d.ts.map +1 -1
  31. package/dist/core/autopilot-verdict.js +94 -3
  32. package/dist/core/autopilot-verdict.js.map +1 -1
  33. package/dist/core/autopilot-verdict.test.js +73 -0
  34. package/dist/core/autopilot-verdict.test.js.map +1 -1
  35. package/dist/core/bash-spawn-benchmark.d.ts +2 -0
  36. package/dist/core/bash-spawn-benchmark.d.ts.map +1 -0
  37. package/dist/core/bash-spawn-benchmark.js +139 -0
  38. package/dist/core/bash-spawn-benchmark.js.map +1 -0
  39. package/dist/core/hashline-edit-benchmark.d.ts +33 -1
  40. package/dist/core/hashline-edit-benchmark.d.ts.map +1 -1
  41. package/dist/core/hashline-edit-benchmark.js +193 -21
  42. package/dist/core/hashline-edit-benchmark.js.map +1 -1
  43. package/dist/core/hashline-edit-benchmark.test.js +51 -2
  44. package/dist/core/hashline-edit-benchmark.test.js.map +1 -1
  45. package/dist/core/json-mode-flag-parity.test.d.ts +2 -0
  46. package/dist/core/json-mode-flag-parity.test.d.ts.map +1 -0
  47. package/dist/core/json-mode-flag-parity.test.js +66 -0
  48. package/dist/core/json-mode-flag-parity.test.js.map +1 -0
  49. package/dist/core/mcp/deferred-catalog.d.ts +28 -0
  50. package/dist/core/mcp/deferred-catalog.d.ts.map +1 -0
  51. package/dist/core/mcp/deferred-catalog.js +72 -0
  52. package/dist/core/mcp/deferred-catalog.js.map +1 -0
  53. package/dist/core/mcp/resolve-stdio.d.ts +19 -3
  54. package/dist/core/mcp/resolve-stdio.d.ts.map +1 -1
  55. package/dist/core/mcp/resolve-stdio.js +136 -5
  56. package/dist/core/mcp/resolve-stdio.js.map +1 -1
  57. package/dist/core/mcp/resolve-stdio.test.js +82 -1
  58. package/dist/core/mcp/resolve-stdio.test.js.map +1 -1
  59. package/dist/core/nolan-context.d.ts +43 -2
  60. package/dist/core/nolan-context.d.ts.map +1 -1
  61. package/dist/core/nolan-context.js +91 -8
  62. package/dist/core/nolan-context.js.map +1 -1
  63. package/dist/core/nolan-context.test.js +167 -5
  64. package/dist/core/nolan-context.test.js.map +1 -1
  65. package/dist/core/nolan-model.d.ts +46 -0
  66. package/dist/core/nolan-model.d.ts.map +1 -0
  67. package/dist/core/nolan-model.js +30 -0
  68. package/dist/core/nolan-model.js.map +1 -0
  69. package/dist/core/nolan-model.test.d.ts +2 -0
  70. package/dist/core/nolan-model.test.d.ts.map +1 -0
  71. package/dist/core/nolan-model.test.js +51 -0
  72. package/dist/core/nolan-model.test.js.map +1 -0
  73. package/dist/core/nolan-prompt.d.ts +5 -18
  74. package/dist/core/nolan-prompt.d.ts.map +1 -1
  75. package/dist/core/nolan-prompt.js +112 -19
  76. package/dist/core/nolan-prompt.js.map +1 -1
  77. package/dist/core/nolan-prompt.test.d.ts +2 -0
  78. package/dist/core/nolan-prompt.test.d.ts.map +1 -0
  79. package/dist/core/nolan-prompt.test.js +138 -0
  80. package/dist/core/nolan-prompt.test.js.map +1 -0
  81. package/dist/core/persistent-shell.d.ts +27 -0
  82. package/dist/core/persistent-shell.d.ts.map +1 -0
  83. package/dist/core/persistent-shell.js +150 -0
  84. package/dist/core/persistent-shell.js.map +1 -0
  85. package/dist/core/persistent-shell.test.d.ts +2 -0
  86. package/dist/core/persistent-shell.test.d.ts.map +1 -0
  87. package/dist/core/persistent-shell.test.js +84 -0
  88. package/dist/core/persistent-shell.test.js.map +1 -0
  89. package/dist/core/process-manager-dev-server-repro.test.js +34 -6
  90. package/dist/core/process-manager-dev-server-repro.test.js.map +1 -1
  91. package/dist/core/progress/engine.d.ts +23 -0
  92. package/dist/core/progress/engine.d.ts.map +1 -0
  93. package/dist/core/progress/engine.js +131 -0
  94. package/dist/core/progress/engine.js.map +1 -0
  95. package/dist/core/progress/engine.test.d.ts +2 -0
  96. package/dist/core/progress/engine.test.d.ts.map +1 -0
  97. package/dist/core/progress/engine.test.js +136 -0
  98. package/dist/core/progress/engine.test.js.map +1 -0
  99. package/dist/core/progress/git-xp.d.ts +16 -0
  100. package/dist/core/progress/git-xp.d.ts.map +1 -0
  101. package/dist/core/progress/git-xp.js +106 -0
  102. package/dist/core/progress/git-xp.js.map +1 -0
  103. package/dist/core/progress/git-xp.test.d.ts +2 -0
  104. package/dist/core/progress/git-xp.test.d.ts.map +1 -0
  105. package/dist/core/progress/git-xp.test.js +88 -0
  106. package/dist/core/progress/git-xp.test.js.map +1 -0
  107. package/dist/core/progress/ranks.d.ts +21 -0
  108. package/dist/core/progress/ranks.d.ts.map +1 -0
  109. package/dist/core/progress/ranks.js +141 -0
  110. package/dist/core/progress/ranks.js.map +1 -0
  111. package/dist/core/progress/ranks.test.d.ts +2 -0
  112. package/dist/core/progress/ranks.test.d.ts.map +1 -0
  113. package/dist/core/progress/ranks.test.js +59 -0
  114. package/dist/core/progress/ranks.test.js.map +1 -0
  115. package/dist/core/progress/rebuild.d.ts +7 -0
  116. package/dist/core/progress/rebuild.d.ts.map +1 -0
  117. package/dist/core/progress/rebuild.js +106 -0
  118. package/dist/core/progress/rebuild.js.map +1 -0
  119. package/dist/core/progress/rebuild.test.d.ts +2 -0
  120. package/dist/core/progress/rebuild.test.d.ts.map +1 -0
  121. package/dist/core/progress/rebuild.test.js +72 -0
  122. package/dist/core/progress/rebuild.test.js.map +1 -0
  123. package/dist/core/progress/store.d.ts +35 -0
  124. package/dist/core/progress/store.d.ts.map +1 -0
  125. package/dist/core/progress/store.js +200 -0
  126. package/dist/core/progress/store.js.map +1 -0
  127. package/dist/core/progress/store.test.d.ts +2 -0
  128. package/dist/core/progress/store.test.d.ts.map +1 -0
  129. package/dist/core/progress/store.test.js +108 -0
  130. package/dist/core/progress/store.test.js.map +1 -0
  131. package/dist/core/progress/types.d.ts +108 -0
  132. package/dist/core/progress/types.d.ts.map +1 -0
  133. package/dist/core/progress/types.js +3 -0
  134. package/dist/core/progress/types.js.map +1 -0
  135. package/dist/core/project-discovery.d.ts.map +1 -1
  136. package/dist/core/project-discovery.js +40 -9
  137. package/dist/core/project-discovery.js.map +1 -1
  138. package/dist/core/project-discovery.test.d.ts +2 -0
  139. package/dist/core/project-discovery.test.d.ts.map +1 -0
  140. package/dist/core/project-discovery.test.js +104 -0
  141. package/dist/core/project-discovery.test.js.map +1 -0
  142. package/dist/core/session-history.d.ts +51 -0
  143. package/dist/core/session-history.d.ts.map +1 -0
  144. package/dist/core/session-history.js +156 -0
  145. package/dist/core/session-history.js.map +1 -0
  146. package/dist/core/session-history.test.d.ts +2 -0
  147. package/dist/core/session-history.test.d.ts.map +1 -0
  148. package/dist/core/session-history.test.js +101 -0
  149. package/dist/core/session-history.test.js.map +1 -0
  150. package/dist/core/session-manager.d.ts +34 -0
  151. package/dist/core/session-manager.d.ts.map +1 -1
  152. package/dist/core/session-manager.js +67 -0
  153. package/dist/core/session-manager.js.map +1 -1
  154. package/dist/core/session-manager.test.js +140 -1
  155. package/dist/core/session-manager.test.js.map +1 -1
  156. package/dist/core/settings-manager.d.ts +1 -0
  157. package/dist/core/settings-manager.d.ts.map +1 -1
  158. package/dist/core/settings-manager.js +4 -0
  159. package/dist/core/settings-manager.js.map +1 -1
  160. package/dist/core/speed-benchmark.test.js +2 -3
  161. package/dist/core/speed-benchmark.test.js.map +1 -1
  162. package/dist/system-prompt.d.ts.map +1 -1
  163. package/dist/system-prompt.js +12 -8
  164. package/dist/system-prompt.js.map +1 -1
  165. package/dist/system-prompt.test.js +15 -20
  166. package/dist/system-prompt.test.js.map +1 -1
  167. package/dist/tools/bash.d.ts +1 -0
  168. package/dist/tools/bash.d.ts.map +1 -1
  169. package/dist/tools/bash.js +27 -2
  170. package/dist/tools/bash.js.map +1 -1
  171. package/dist/tools/edit.d.ts +9 -2
  172. package/dist/tools/edit.d.ts.map +1 -1
  173. package/dist/tools/edit.js +91 -10
  174. package/dist/tools/edit.js.map +1 -1
  175. package/dist/tools/edit.test.js +97 -0
  176. package/dist/tools/edit.test.js.map +1 -1
  177. package/dist/tools/plan-mode.test.js +25 -0
  178. package/dist/tools/plan-mode.test.js.map +1 -1
  179. package/dist/tools/prompt-hints.d.ts.map +1 -1
  180. package/dist/tools/prompt-hints.js +3 -0
  181. package/dist/tools/prompt-hints.js.map +1 -1
  182. package/dist/tools/subagent.d.ts.map +1 -1
  183. package/dist/tools/subagent.js +24 -1
  184. package/dist/tools/subagent.js.map +1 -1
  185. package/dist/tools/tool-search.d.ts +15 -0
  186. package/dist/tools/tool-search.d.ts.map +1 -0
  187. package/dist/tools/tool-search.js +35 -0
  188. package/dist/tools/tool-search.js.map +1 -0
  189. package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
  190. package/dist/ui/hooks/useAgentLoop.js +22 -9
  191. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  192. package/package.json +5 -5
@@ -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,9 +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 { collectProjectContext } from "./system-prompt.js";
27
+ import { isWorkflowCommandText, countAssistantMessages, shouldStartAutopilotCycle, extractTurnToolCalls, isMechanicalOnlyTurn, } from "./core/autopilot-gate.js";
28
+ import { driveAutopilotCycle, frameAutopilotInjection } from "./core/autopilot-cycle.js";
29
+ import { validateNolanModelPref, effectiveNolanModel, } from "./core/nolan-model.js";
30
+ import { normalizeAutopilotMarkersForHistory, normalizeAppMarkersForHistory, normalizeNolanTurnsForHistory, restoreUserRow, restoreAssistantTexts, autopilotMarkerCopySeed, } from "./core/session-history.js";
27
31
  import { AuthStorage } from "./core/auth-storage.js";
28
32
  import { MOONSHOT_OAUTH_KEY, XIAOMI_CREDITS_KEY } from "@prestyj/core";
29
33
  import { loginAnthropic } from "./core/oauth/anthropic.js";
@@ -48,6 +52,11 @@ import { downscaleForPreview, validateVisionImage } from "./utils/image.js";
48
52
  import { startServeMode } from "./modes/serve-mode.js";
49
53
  import { loadTelegramConfig, saveTelegramConfig, verifyBotToken } from "./core/telegram-config.js";
50
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";
51
60
  const ALL_PROVIDERS = [
52
61
  "anthropic",
53
62
  "xiaomi",
@@ -82,6 +91,7 @@ async function loadAppSettings() {
82
91
  // model/thinking handlers below).
83
92
  projectModels: raw.projectModels && typeof raw.projectModels === "object" ? raw.projectModels : undefined,
84
93
  autopilot: raw.autopilot && typeof raw.autopilot === "object" ? raw.autopilot : undefined,
94
+ nolanModels: raw.nolanModels && typeof raw.nolanModels === "object" ? raw.nolanModels : undefined,
85
95
  };
86
96
  }
87
97
  catch {
@@ -105,6 +115,24 @@ async function saveProjectModelPrefs(cwd, prefs) {
105
115
  s.projectModels = { ...(s.projectModels ?? {}), [key]: prefs };
106
116
  await saveAppSettings(s);
107
117
  }
118
+ /** Read this project's persisted Nolan model override, if any. */
119
+ async function loadNolanModelPref(cwd) {
120
+ const s = await loadAppSettings();
121
+ return s.nolanModels?.[projectModelKey(cwd)];
122
+ }
123
+ /** Persist (or with null, clear) this project's Nolan model override via
124
+ * read-modify-write so the rest of the settings file is preserved. */
125
+ async function saveNolanModelPref(cwd, pref) {
126
+ const s = await loadAppSettings();
127
+ const key = projectModelKey(cwd);
128
+ const next = { ...(s.nolanModels ?? {}) };
129
+ if (pref)
130
+ next[key] = pref;
131
+ else
132
+ delete next[key];
133
+ s.nolanModels = next;
134
+ await saveAppSettings(s);
135
+ }
108
136
  /** Read this project's persisted autopilot flag (default off). */
109
137
  async function loadAutopilot(cwd) {
110
138
  const s = await loadAppSettings();
@@ -378,12 +406,23 @@ async function runJsonModeIfRequested() {
378
406
  model: { type: "string" },
379
407
  "max-turns": { type: "string" },
380
408
  "system-prompt": { type: "string" },
409
+ tools: { type: "string" },
381
410
  "prompt-cache-key": { type: "string" },
382
411
  },
383
412
  allowPositionals: true,
384
413
  strict: true,
385
414
  });
386
415
  const maxTurnsRaw = values["max-turns"];
416
+ // Optional tool allow-list forwarded by the subagent spawner from an agent
417
+ // definition's `tools:` frontmatter. Mirrors the identical parsing in
418
+ // cli.ts's `values.json` branch — keep both in sync (see subagent.ts).
419
+ const parsedTools = values.tools
420
+ ? values.tools
421
+ .split(",")
422
+ .map((t) => t.trim())
423
+ .filter(Boolean)
424
+ : [];
425
+ const allowedTools = parsedTools.length > 0 ? parsedTools : undefined;
387
426
  await runJsonMode({
388
427
  message: positionals[0] ?? "",
389
428
  provider: (values.provider ?? "anthropic"),
@@ -391,6 +430,7 @@ async function runJsonModeIfRequested() {
391
430
  cwd: process.cwd(),
392
431
  systemPrompt: values["system-prompt"],
393
432
  maxTurns: maxTurnsRaw ? parseInt(maxTurnsRaw, 10) : undefined,
433
+ allowedTools,
394
434
  promptCacheKey: values["prompt-cache-key"],
395
435
  }).catch((err) => {
396
436
  process.stderr.write((err instanceof Error ? err.message : String(err)) + "\n");
@@ -462,6 +502,13 @@ async function main() {
462
502
  // request to its window's session via the `x-gg-session` header (and the
463
503
  // `?session=` query for the SSE /events stream).
464
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
+ });
465
512
  /** Resolve the target session id: the `x-gg-session` header, else a
466
513
  * `?session=` query param (used by the SSE /events connection). */
467
514
  function sessionIdFromReq(req, url) {
@@ -505,7 +552,7 @@ async function main() {
505
552
  const sessionPath = typeof body.sessionPath === "string" && body.sessionPath ? body.sessionPath : undefined;
506
553
  const id = randomUUID();
507
554
  try {
508
- const ctx = await createSession({ auth, paths }, { id, cwd: sessionCwd, sessionPath });
555
+ const ctx = await createSession({ auth, paths, progress }, { id, cwd: sessionCwd, sessionPath });
509
556
  sessions.set(id, ctx);
510
557
  log("INFO", "app-sidecar", "session created", { id, cwd: sessionCwd });
511
558
  daemonJson(res, 200, { sessionId: id });
@@ -530,6 +577,12 @@ async function main() {
530
577
  daemonJson(res, 200, { ok: true });
531
578
  return;
532
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
+ }
533
586
  // ── Per-session delegation ───────────────────────────────────────────
534
587
  const id = sessionIdFromReq(req, url);
535
588
  const ctx = id ? sessions.get(id) : undefined;
@@ -589,20 +642,107 @@ function lastAssistantText(messages) {
589
642
  return "";
590
643
  }
591
644
  /**
592
- * Assemble Nolan's context digest for one `@Nolan` question: project docs (up the
593
- * tree) + git/env + the build session's compaction summary + recent activity.
594
- * Prepended to the user's question as Nolan's prompt body each turn.
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.
595
653
  */
596
- async function buildNolanContext(buildSession, cwd, gitBranch, question) {
597
- const projectContext = await collectProjectContext(cwd).catch(() => []);
654
+ function buildNolanContext(buildSession, cwd, gitBranch, question, workflowCommands, injectedPrompts) {
598
655
  return buildNolanDigest({
599
656
  question,
600
- projectContext,
601
657
  cwd,
602
658
  gitBranch,
603
659
  messages: buildSession.getMessages(),
660
+ workflowCommands,
661
+ injectedPrompts,
604
662
  });
605
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
+ }
606
746
  /**
607
747
  * Build one in-process agent session: its AgentSession, SSE client set, event
608
748
  * bridge, task runner, auth/login bridge, and the full HTTP route table exposed
@@ -611,7 +751,7 @@ async function buildNolanContext(buildSession, cwd, gitBranch, question) {
611
751
  * logger, PATH, shared auth file, and radio live at the daemon level.
612
752
  */
613
753
  async function createSession(deps, opts) {
614
- const { auth } = deps;
754
+ const { auth, progress } = deps;
615
755
  const paths = deps.paths;
616
756
  const cwd = opts.cwd;
617
757
  // Base host for parsing request-URL query params (value is irrelevant to
@@ -672,6 +812,16 @@ async function createSession(deps, opts) {
672
812
  ...(f.statusCode != null ? { statusCode: f.statusCode } : {}),
673
813
  ...(f.resetsAt != null ? { resetsAt: f.resetsAt } : {}),
674
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(() => { });
675
825
  }
676
826
  // The session file path to resume (passed by the daemon's POST /session);
677
827
  // empty/unset starts a fresh session.
@@ -694,10 +844,27 @@ async function createSession(deps, opts) {
694
844
  // session plan state (rebuilds the system prompt + enforces read-only
695
845
  // tools) and surface the transition to the webview.
696
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;
697
854
  await session.setPlanMode(true);
698
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(() => { });
699
858
  },
700
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
+ }
701
868
  await session.setPlanMode(false);
702
869
  // Surface the plan's path + markdown so the webview can show the review
703
870
  // modal (Accept / Feedback / Reject). Best-effort content read.
@@ -708,6 +875,12 @@ async function createSession(deps, opts) {
708
875
  catch {
709
876
  content = "";
710
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);
711
884
  broadcast("plan_exit", { planPath, content });
712
885
  return "Plan submitted for user review. Wait for the user to approve, reject, or dismiss it before implementing.";
713
886
  },
@@ -780,6 +953,9 @@ async function createSession(deps, opts) {
780
953
  session.eventBus.on("compaction_end", (d) => broadcast("compaction_end", d));
781
954
  let running = false;
782
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;
783
959
  // Autopilot (auto-review) toggle for THIS window's project. Loaded from
784
960
  // ezcoder-app.json on boot; flipped via POST /autopilot. When on, POST /prompt runs
785
961
  // runAutopilotCycle after the user's turn settles — Nolan auto-reviews the work
@@ -798,6 +974,45 @@ async function createSession(deps, opts) {
798
974
  let autopilotCancelled = false;
799
975
  // Hard cap on review→prompt→review rounds per user turn (loop safety).
800
976
  const MAX_AUTOPILOT_ROUNDS = 3;
977
+ // Prompt bodies Autopilot Nolan injected into the BUILD session this
978
+ // conversation. Passed into every Nolan digest so injected prompts render as
979
+ // "Nolan autopilot (injected)" instead of `**User:**` — otherwise multi-round
980
+ // cycles drift into Nolan reviewing against his own last prompt. Cleared
981
+ // whenever the conversation resets (new session / plan accept / task run).
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
+ }
1005
+ // Workflow (prompt-template) commands: built-in + the project's custom
1006
+ // `.ezcoder/commands/*.md`. Used to gate autopilot off command turns and to label
1007
+ // expanded templates in Nolan's digests. Loaded fresh so a newly added custom
1008
+ // command is picked up without a restart (mirrors GET /commands).
1009
+ async function loadWorkflowCommandSpecs() {
1010
+ const custom = await loadCustomCommands(cwd).catch(() => []);
1011
+ return [
1012
+ ...PROMPT_COMMANDS.map((c) => ({ name: c.name, aliases: c.aliases, prompt: c.prompt })),
1013
+ ...custom.map((c) => ({ name: c.name, aliases: [], prompt: c.prompt })),
1014
+ ];
1015
+ }
801
1016
  // ── Telegram serve (remote control via Telegram) ───────────
802
1017
  // A single embedded serve session lives in this sidecar process. Only the main
803
1018
  // window's home screen exposes the controls, so there's one bot per app.
@@ -813,6 +1028,35 @@ async function createSession(deps, opts) {
813
1028
  let nolanRunning = false;
814
1029
  let pendingNolanModel = null;
815
1030
  const nolanToolCallNames = new Map();
1031
+ // Nolan's per-project model override. null → Nolan (chat + autopilot) follows GG
1032
+ // Coder's model, including live switches (the historical behavior). Set → Nolan
1033
+ // is pinned to his own model and EZ Coder switches no longer touch him. A
1034
+ // stale persisted pin (model dropped from the registry / provider logged
1035
+ // out) validates to null so Nolan degrades to following instead of erroring.
1036
+ let nolanModelOverride = validateNolanModelPref(await loadNolanModelPref(cwd), {
1037
+ modelExists: (id) => getModel(id) !== undefined,
1038
+ providerConnected: () => true, // async auth checked below
1039
+ });
1040
+ if (nolanModelOverride && !(await auth.hasProviderAuth(nolanModelOverride.provider))) {
1041
+ log("WARN", "app-sidecar", "ken model override provider not connected — following GG", {
1042
+ provider: nolanModelOverride.provider,
1043
+ model: nolanModelOverride.model,
1044
+ });
1045
+ nolanModelOverride = null;
1046
+ }
1047
+ /** The model Nolan uses next turn: the pin when set, else EZ Coder's. */
1048
+ function nolanCurrentModel() {
1049
+ if (nolanModelOverride)
1050
+ return nolanModelOverride;
1051
+ const st = session.getState();
1052
+ return { provider: st.provider, model: st.model };
1053
+ }
1054
+ /** Footer payload: Nolan's effective model + whether it's a pin. Merged into
1055
+ * /state, the SSE ready frame, and every ken_model_change broadcast. */
1056
+ function nolanStatePayload() {
1057
+ const st = session.getState();
1058
+ return effectiveNolanModel(nolanModelOverride, { provider: st.provider, model: st.model });
1059
+ }
816
1060
  async function syncNolanModel(provider, model) {
817
1061
  if (nolanRunning) {
818
1062
  pendingNolanModel = { provider, model };
@@ -829,16 +1073,19 @@ async function createSession(deps, opts) {
829
1073
  async function ensureNolanSession() {
830
1074
  if (nolanSession)
831
1075
  return nolanSession;
832
- const st = session.getState();
1076
+ const target = nolanCurrentModel();
833
1077
  const ken = new AgentSession({
834
- provider: st.provider,
835
- model: st.model,
1078
+ provider: target.provider,
1079
+ model: target.model,
836
1080
  cwd,
837
- systemPrompt: buildNolanSystemPrompt(),
1081
+ systemPrompt: await buildNolanSystemPrompt(cwd),
838
1082
  allowedTools: NOLAN_ALLOWED_TOOLS,
839
1083
  allowedMcpServers: NOLAN_ALLOWED_MCP_SERVERS,
840
1084
  transient: true,
841
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,
842
1089
  });
843
1090
  await ken.initialize();
844
1091
  // Bridge Nolan's bus to the shared SSE fan-out with ken_-prefixed types so the
@@ -863,7 +1110,10 @@ async function createSession(deps, opts) {
863
1110
  broadcastError("nolan_error", "ken error", d.error);
864
1111
  });
865
1112
  nolanSession = ken;
866
- log("INFO", "app-sidecar", "ken session ready", { provider: st.provider, model: st.model });
1113
+ log("INFO", "app-sidecar", "ken session ready", {
1114
+ provider: target.provider,
1115
+ model: target.model,
1116
+ });
867
1117
  return ken;
868
1118
  }
869
1119
  // ── Autopilot Nolan (auto-reviewer) ──────────────────────────
@@ -892,24 +1142,27 @@ async function createSession(deps, opts) {
892
1142
  async function ensureNolanAutoSession() {
893
1143
  if (nolanAutoSession)
894
1144
  return nolanAutoSession;
895
- const st = session.getState();
1145
+ const target = nolanCurrentModel();
896
1146
  const ken = new AgentSession({
897
- provider: st.provider,
898
- model: st.model,
1147
+ provider: target.provider,
1148
+ model: target.model,
899
1149
  cwd,
900
- systemPrompt: buildNolanAutopilotSystemPrompt(),
1150
+ systemPrompt: await buildNolanAutopilotSystemPrompt(cwd),
901
1151
  allowedTools: NOLAN_ALLOWED_TOOLS,
902
1152
  allowedMcpServers: NOLAN_ALLOWED_MCP_SERVERS,
903
1153
  transient: true,
904
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,
905
1158
  });
906
1159
  await ken.initialize();
907
1160
  // Deliberately no bus bridge: the review is silent. Errors surface via the
908
1161
  // runAutopilotReview try/catch as autopilot_error frames.
909
1162
  nolanAutoSession = ken;
910
1163
  log("INFO", "app-sidecar", "ken autopilot session ready", {
911
- provider: st.provider,
912
- model: st.model,
1164
+ provider: target.provider,
1165
+ model: target.model,
913
1166
  });
914
1167
  return ken;
915
1168
  }
@@ -933,15 +1186,28 @@ async function createSession(deps, opts) {
933
1186
  // run_start frame.
934
1187
  async function runAgent(label, run) {
935
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;
936
1195
  broadcast("run_start", { text: label });
937
1196
  try {
938
1197
  await run();
1198
+ runSucceeded = true;
939
1199
  }
940
1200
  catch (err) {
941
1201
  broadcastError("error", "run failed", err);
942
1202
  }
943
1203
  finally {
944
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
+ }
945
1211
  // A run may have switched branches (git checkout) or spawned/finished
946
1212
  // background tasks — refresh the footer extras once it settles.
947
1213
  gitBranch = await getGitBranch(cwd).catch(() => gitBranch);
@@ -974,17 +1240,20 @@ async function createSession(deps, opts) {
974
1240
  // One review = prompt the nolanAuto session with the review digest, read its
975
1241
  // final assistant text, parse a verdict. Returns null on failure (surfaced as
976
1242
  // an autopilot_error frame) so the cycle stops rather than looping blind.
977
- async function runAutopilotReview() {
1243
+ // `originalRequest` is the user prompt that started the turn under review —
1244
+ // pinned in the digest so it can't scroll out during multi-round cycles.
1245
+ async function runAutopilotReview(originalRequest) {
978
1246
  autopilotReviewing = true;
979
1247
  broadcast("autopilot_review_start", {});
980
1248
  try {
981
1249
  const ken = await ensureNolanAutoSession();
982
- const projectContext = await collectProjectContext(cwd).catch(() => []);
983
1250
  const digest = buildNolanAutopilotContext({
984
- projectContext,
985
1251
  cwd,
986
1252
  gitBranch,
987
1253
  messages: session.getMessages(),
1254
+ originalRequest,
1255
+ injectedPrompts: [...injectedAutopilotPrompts],
1256
+ workflowCommands: await loadWorkflowCommandSpecs(),
988
1257
  });
989
1258
  await ken.prompt(digest);
990
1259
  return parseAutopilotVerdict(lastAssistantText(ken.getMessages()));
@@ -1002,45 +1271,254 @@ async function createSession(deps, opts) {
1002
1271
  await syncNolanAutoModel(pending.provider, pending.model);
1003
1272
  }
1004
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.";
1005
1328
  // Drive the review→prompt→review loop for one finished user turn. Only ever
1006
- // called from POST /prompt after the user's own run resolves — never from the
1007
- // task runner, resume, /ken, or error paths, so there's no recursion and no
1008
- // guard tangle. Bounded by MAX_AUTOPILOT_ROUNDS and cancellable between steps.
1009
- async function runAutopilotCycle() {
1329
+ // called after shouldStartAutopilotCycle approves the turn (POST /prompt or
1330
+ // the stranded-queue drain) — never from the task runner, resume, /ken, or
1331
+ // error paths, so there's no recursion and no guard tangle. The loop's
1332
+ // control flow lives in driveAutopilotCycle (core/autopilot-cycle.ts) so
1333
+ // every exit path is unit-tested; this only wires the real dependencies.
1334
+ async function runAutopilotCycle(originalRequest) {
1010
1335
  if (!autopilot || autopilotCancelled)
1011
1336
  return;
1012
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;
1013
1341
  try {
1014
- // Lean context per user turn: wipe prior review history so each new turn
1015
- // starts cheap, while within this cycle the few review messages persist so
1016
- // Nolan remembers what he already asked EZ Coder to fix.
1017
- await nolanAutoSession?.newSession().catch(() => { });
1018
- for (let round = 1; round <= MAX_AUTOPILOT_ROUNDS; round++) {
1019
- if (autopilotCancelled)
1020
- return;
1021
- const verdict = await runAutopilotReview();
1022
- if (!verdict || autopilotCancelled)
1023
- return;
1024
- if (verdict.kind === "all_clear") {
1025
- broadcast("autopilot_done", {});
1026
- return;
1342
+ await driveAutopilotCycle({
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,
1347
+ isCancelled: () => autopilotCancelled,
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.
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
+ },
1393
+ // Lean context per user turn: wipe prior review history so each new
1394
+ // turn starts cheap, while within this cycle the few review messages
1395
+ // persist so Nolan remembers what he already asked EZ Coder to fix.
1396
+ resetReviewer: async () => {
1397
+ await nolanAutoSession?.newSession().catch(() => { });
1398
+ },
1399
+ review: () => runAutopilotReview(originalRequest),
1400
+ // prompt → record the injected body (so later digests label it as
1401
+ // Nolan's, not the user's), show a compact Nolan-tinted marker (not the
1402
+ // prompt body), then feed EZ Coder bracketed by runAgent so the run
1403
+ // streams normally; the shared finally never re-triggers autopilot,
1404
+ // so this can't recurse.
1405
+ onInjected: (body, round) => {
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));
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.
1446
+ },
1447
+ });
1448
+ }
1449
+ finally {
1450
+ autopilotActive = false;
1451
+ }
1452
+ }
1453
+ // ── Stranded-queue drain ───────────────────────────────
1454
+ // A prompt POSTed while an autopilot cycle is between injected runs (build
1455
+ // idle, Nolan reviewing) queues — but the queue only drains INTO a running
1456
+ // turn as steering. If the cycle ends without another run (ALL_CLEAR /
1457
+ // IGNORE / HUMAN / error), that message would sit stranded until the next
1458
+ // unrelated prompt, then land mislabeled as "concurrent steering" of an
1459
+ // unrelated run. Drain it here as a fresh turn of its own (with its own
1460
+ // gated review). Also covers the non-autopilot tail window: a message queued
1461
+ // after the run's last steering drain but before run_end.
1462
+ let drainingStrandedQueue = false;
1463
+ async function runStrandedQueue() {
1464
+ if (drainingStrandedQueue)
1465
+ return;
1466
+ drainingStrandedQueue = true;
1467
+ try {
1468
+ for (;;) {
1469
+ if (running || autopilotActive)
1470
+ return;
1471
+ const next = session.takeNextQueuedMessage();
1472
+ if (!next)
1473
+ return;
1474
+ broadcast("queued", { count: session.getQueuedCount() });
1475
+ if (!next.text.trim() && next.attachments.length === 0)
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();
1480
+ const workflowCommand = next.attachments.length === 0 &&
1481
+ isWorkflowCommandText(next.text, await loadWorkflowCommandSpecs());
1482
+ const assistantsBefore = countAssistantMessages(session.getMessages());
1483
+ const messagesBefore = session.getMessages().length;
1484
+ await runAgent(next.text, async () => {
1485
+ if (next.attachments.length > 0) {
1486
+ await session.promptWithAttachments(next.text, next.attachments);
1487
+ }
1488
+ else {
1489
+ await session.prompt(next.text);
1490
+ }
1491
+ });
1492
+ const decision = shouldStartAutopilotCycle({
1493
+ enabled: autopilot,
1494
+ cancelled: autopilotCancelled,
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,
1499
+ workflowCommand,
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)),
1506
+ });
1507
+ if (decision.start) {
1508
+ log("INFO", "app-sidecar", "autopilot cycle starting (queued turn)", {
1509
+ kind: decision.kind,
1510
+ });
1511
+ await runAutopilotCycle(next.text);
1027
1512
  }
1028
- if (verdict.kind === "human") {
1029
- broadcast("autopilot_human", { reason: verdict.reason });
1030
- return;
1513
+ else if (autopilot) {
1514
+ log("INFO", "app-sidecar", "autopilot skipped (queued turn)", {
1515
+ reason: decision.reason,
1516
+ });
1031
1517
  }
1032
- // prompt → show a compact Nolan-tinted marker (not the prompt body), then
1033
- // feed EZ Coder. Bracketed by runAgent so the run streams normally; the
1034
- // shared finally no longer re-triggers autopilot, so this can't recurse.
1035
- broadcast("autopilot_prompted", { round, body: verdict.body });
1036
- await runAgent(verdict.body, () => session.prompt(verdict.body));
1037
- if (autopilotCancelled)
1038
- return;
1039
1518
  }
1040
- broadcast("autopilot_capped", { rounds: MAX_AUTOPILOT_ROUNDS });
1041
1519
  }
1042
1520
  finally {
1043
- autopilotActive = false;
1521
+ drainingStrandedQueue = false;
1044
1522
  }
1045
1523
  }
1046
1524
  // ── Task runner (project task list → sessions) ──────────────
@@ -1048,17 +1526,34 @@ async function createSession(deps, opts) {
1048
1526
  // completion hint instructing the agent to mark the task done via the tasks
1049
1527
  // tool. Run-all advances to the next pending task after each run finishes.
1050
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;
1051
1533
  async function runTaskById(taskId) {
1052
1534
  const task = loadTasksSync(cwd).find((t) => t.id === taskId || t.id.startsWith(taskId));
1053
1535
  if (!task)
1054
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) {
1055
1546
  // Fresh session per task so one task's context never bleeds into the next.
1056
1547
  await session.newSession();
1548
+ injectedAutopilotPrompts = [];
1549
+ clearPendingPlan();
1057
1550
  titleGenerated = false;
1058
1551
  broadcast("session_reset", {});
1059
1552
  markTaskInProgress(cwd, task.id);
1060
1553
  broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
1061
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(() => { });
1062
1557
  const shortId = task.id.slice(0, 8);
1063
1558
  const completionHint = `\n\n---\nWhen you have fully completed this task, call the tasks tool to mark it done:\n` +
1064
1559
  `tasks({ action: "done", id: "${shortId}" })`;
@@ -1164,10 +1659,15 @@ async function createSession(deps, opts) {
1164
1659
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
1165
1660
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
1166
1661
  autopilot,
1662
+ ...nolanStatePayload(),
1167
1663
  ...footerExtras(),
1168
1664
  });
1169
1665
  return;
1170
1666
  }
1667
+ if (method === "GET" && url === "/progress") {
1668
+ json(res, 200, progress.snapshot());
1669
+ return;
1670
+ }
1171
1671
  if (method === "GET" && (url === "/events" || url.startsWith("/events?"))) {
1172
1672
  res.writeHead(200, {
1173
1673
  "content-type": "text/event-stream",
@@ -1188,6 +1688,7 @@ async function createSession(deps, opts) {
1188
1688
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
1189
1689
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
1190
1690
  autopilot,
1691
+ ...nolanStatePayload(),
1191
1692
  ...footerExtras(),
1192
1693
  },
1193
1694
  })}\n\n`);
@@ -1346,8 +1847,11 @@ async function createSession(deps, opts) {
1346
1847
  // they were recorded after, so each lands right after that message. A
1347
1848
  // turn becomes two wire rows: the `@Nolan` question (user) + Nolan's reply
1348
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).
1349
1853
  const nolanByCount = new Map();
1350
- for (const turn of session.getNolanTurns()) {
1854
+ for (const turn of normalizeNolanTurnsForHistory(session.getNolanTurns(), messages.filter((m) => m.role !== "system").length)) {
1351
1855
  const list = nolanByCount.get(turn.afterMessageCount) ?? [];
1352
1856
  list.push(turn);
1353
1857
  nolanByCount.set(turn.afterMessageCount, list);
@@ -1362,9 +1866,103 @@ async function createSession(deps, opts) {
1362
1866
  history.push({ role: "assistant", text: turn.reply, ken: true });
1363
1867
  }
1364
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
+ };
1365
1960
  let nonSystemCount = 0;
1366
- // 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.
1367
1963
  flushNolan(0);
1964
+ flushAutopilot(0);
1965
+ flushAppMarkers(0);
1368
1966
  for (const msg of messages) {
1369
1967
  if (msg.role === "system")
1370
1968
  continue;
@@ -1414,30 +2012,54 @@ async function createSession(deps, opts) {
1414
2012
  }
1415
2013
  continue;
1416
2014
  }
1417
- // User or assistant message — existing text/hook/command/compacted
1418
- // extraction, plus sub-agent group detection for assistant tool_calls.
1419
- const text = typeof msg.content === "string"
1420
- ? msg.content
1421
- : msg.content
1422
- .map((c) => c.type === "text" && "text" in c && typeof c.text === "string" ? c.text : "")
1423
- .join("");
1424
- const images = typeof msg.content === "string"
1425
- ? []
1426
- : msg.content.flatMap((c) => c.type === "image" ? [`data:${c.mediaType};base64,${c.data}`] : []);
1427
- const hook = msg.role === "user" ? detectHookKind(text) : null;
1428
- const compacted = msg.role === "user" && !hook && text.startsWith("[Previous conversation summary]");
1429
- const command = msg.role === "user" && !hook && !compacted
1430
- ? detectPromptCommand(text, commandCandidates)
1431
- : null;
1432
- if (text.trim() || images.length > 0) {
1433
- history.push({
1434
- role: msg.role,
1435
- text: command ?? text,
1436
- images,
1437
- hook,
1438
- command: command !== null,
1439
- compacted,
1440
- });
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
+ }
1441
2063
  }
1442
2064
  // Assistant tool_call blocks: detect sub-agent delegations.
1443
2065
  if (msg.role === "assistant" && typeof msg.content !== "string") {
@@ -1458,14 +2080,21 @@ async function createSession(deps, opts) {
1458
2080
  });
1459
2081
  }
1460
2082
  }
1461
- // Interleave any Nolan turns recorded right after this message.
2083
+ // Interleave any Nolan turns / autopilot / app markers recorded right
2084
+ // after this message.
1462
2085
  flushNolan(nonSystemCount);
2086
+ flushAutopilot(nonSystemCount);
2087
+ flushAppMarkers(nonSystemCount);
1463
2088
  }
1464
- // Flush remaining Nolan turns whose anchor is at/after the message count
1465
- // (e.g. asked before any build message, or anchors beyond the current
1466
- // 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.
1467
2092
  for (const count of [...nolanByCount.keys()].sort((a, b) => a - b))
1468
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);
1469
2098
  json(res, 200, { history });
1470
2099
  })();
1471
2100
  return;
@@ -1498,10 +2127,12 @@ async function createSession(deps, opts) {
1498
2127
  void readBody(req).then(async (raw) => {
1499
2128
  let text;
1500
2129
  let attachments;
2130
+ let meta;
1501
2131
  try {
1502
2132
  const body = JSON.parse(raw);
1503
2133
  text = body.text ?? "";
1504
2134
  attachments = Array.isArray(body.attachments) ? body.attachments : [];
2135
+ meta = typeof body.meta === "object" && body.meta !== null ? body.meta : undefined;
1505
2136
  }
1506
2137
  catch {
1507
2138
  json(res, 400, { error: "invalid JSON body" });
@@ -1525,9 +2156,33 @@ async function createSession(deps, opts) {
1525
2156
  return;
1526
2157
  }
1527
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
+ }
1528
2171
  // Fresh user turn: clear any cancel flag left from a prior cycle so this
1529
2172
  // turn's autopilot review can run.
1530
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();
2178
+ // Gate inputs captured around the run: whether this turn is a workflow
2179
+ // slash command (attachment prompts skip slash expansion entirely), and
2180
+ // how many assistant messages the run actually adds. Computed even when
2181
+ // autopilot is currently off — the toggle can flip ON mid-run, and the
2182
+ // gate reads the post-run value.
2183
+ const workflowCommand = attachments.length === 0 && isWorkflowCommandText(text, await loadWorkflowCommandSpecs());
2184
+ const assistantsBefore = countAssistantMessages(session.getMessages());
2185
+ const messagesBefore = session.getMessages().length;
1531
2186
  await runAgent(text, async () => {
1532
2187
  if (attachments.length > 0) {
1533
2188
  // Persist each attachment under .ezcoder/uploads so files are inspectable
@@ -1543,11 +2198,40 @@ async function createSession(deps, opts) {
1543
2198
  await session.prompt(text);
1544
2199
  }
1545
2200
  });
1546
- // After the user's run settles, kick off Nolan's auto-review loop. This is
1547
- // the ONLY entry point into the cycle it drives any follow-up EZ Coder
1548
- // runs itself, so the shared runAgent finally never recurses.
1549
- if (autopilot && !autopilotCancelled)
1550
- await runAutopilotCycle();
2201
+ // After the user's run settles, kick off Nolan's auto-review loop but
2202
+ // only when the turn is actually reviewable (shouldStartAutopilotCycle):
2203
+ // workflow commands (/compare, /bullet-proof, …) end with reports or
2204
+ // A/B/C choices reserved for the USER; registry commands (/help) and
2205
+ // failed runs add no assistant work to judge; a turn that ended in plan
2206
+ // mode has a pending Accept/Reject modal Nolan must not preempt. This is
2207
+ // the ONLY entry point into the cycle besides the stranded-queue drain —
2208
+ // it drives any follow-up EZ Coder runs itself, so the shared runAgent
2209
+ // finally never recurses.
2210
+ const decision = shouldStartAutopilotCycle({
2211
+ enabled: autopilot,
2212
+ cancelled: autopilotCancelled,
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,
2217
+ workflowCommand,
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)),
2224
+ });
2225
+ if (decision.start) {
2226
+ log("INFO", "app-sidecar", "autopilot cycle starting", { kind: decision.kind });
2227
+ await runAutopilotCycle(text);
2228
+ }
2229
+ else if (autopilot) {
2230
+ log("INFO", "app-sidecar", "autopilot skipped", { reason: decision.reason });
2231
+ }
2232
+ // A prompt sent while Nolan was reviewing (build idle) queued but had no
2233
+ // run to steer into — run it now as a fresh turn so it never strands.
2234
+ await runStrandedQueue();
1551
2235
  });
1552
2236
  return;
1553
2237
  }
@@ -1578,7 +2262,7 @@ async function createSession(deps, opts) {
1578
2262
  broadcast("nolan_run_start", { text });
1579
2263
  try {
1580
2264
  const ken = await ensureNolanSession();
1581
- const digest = await buildNolanContext(session, cwd, gitBranch, text);
2265
+ const digest = await buildNolanContext(session, cwd, gitBranch, text, await loadWorkflowCommandSpecs(), injectedAutopilotPrompts);
1582
2266
  await ken.prompt(digest);
1583
2267
  // Record the turn against the BUILD session so it persists + survives
1584
2268
  // resume (advisory custom entry, never an LLM message). Reply is Nolan's
@@ -1861,8 +2545,12 @@ async function createSession(deps, opts) {
1861
2545
  // new model's credentials, so switching to a just-added provider works.
1862
2546
  await auth.reload();
1863
2547
  await session.switchModel(target.provider, target.id);
1864
- await syncNolanModel(target.provider, target.id);
1865
- await syncNolanAutoModel(target.provider, target.id);
2548
+ // Nolan follows EZ Coder's model only while un-pinned; a user-set Nolan
2549
+ // override survives GG model switches untouched.
2550
+ if (!nolanModelOverride) {
2551
+ await syncNolanModel(target.provider, target.id);
2552
+ await syncNolanAutoModel(target.provider, target.id);
2553
+ }
1866
2554
  // Clamp the reasoning level to what the new model supports (mirrors the
1867
2555
  // CLI): keep thinking on at the first supported tier if it was on but
1868
2556
  // the prior level is unsupported here; leave it off if it was off.
@@ -1889,6 +2577,12 @@ async function createSession(deps, opts) {
1889
2577
  // model_change is emitted by switchModel; follow with thinking_change so
1890
2578
  // the footer toggle reflects the new model's supported levels.
1891
2579
  broadcast("thinking_change", payload);
2580
+ // Un-pinned Nolan just followed the switch — update his footer chip too.
2581
+ // When Nolan is pinned, his effective model did not change, so skip the
2582
+ // no-op event (keeps footer/event tests from treating a GG switch as a
2583
+ // Nolan switch).
2584
+ if (!nolanModelOverride)
2585
+ broadcast("ken_model_change", nolanStatePayload());
1892
2586
  // The new model usually has a different context window — push extras so
1893
2587
  // the footer's context meter rescales immediately.
1894
2588
  broadcast("extras", footerExtras());
@@ -1896,6 +2590,54 @@ async function createSession(deps, opts) {
1896
2590
  });
1897
2591
  return;
1898
2592
  }
2593
+ // Set or clear Nolan's model pin. Body: { model: "<id>" } to pin, or
2594
+ // { model: null } / "" to clear (Nolan resumes following EZ Coder). Applies
2595
+ // to BOTH Nolan sessions (chat + autopilot reviewer); a switch landing while
2596
+ // either is mid-run defers via the pending-model mechanics.
2597
+ if (method === "POST" && url === "/ken/model") {
2598
+ void readBody(req).then(async (raw) => {
2599
+ let modelId;
2600
+ try {
2601
+ const parsed = JSON.parse(raw).model;
2602
+ modelId = typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
2603
+ }
2604
+ catch {
2605
+ json(res, 400, { error: "invalid JSON body" });
2606
+ return;
2607
+ }
2608
+ if (modelId === null) {
2609
+ // Clear the pin → follow EZ Coder again, syncing both sessions back.
2610
+ nolanModelOverride = null;
2611
+ await saveNolanModelPref(cwd, null);
2612
+ const st = session.getState();
2613
+ await syncNolanModel(st.provider, st.model);
2614
+ await syncNolanAutoModel(st.provider, st.model);
2615
+ log("INFO", "app-sidecar", "ken model pin cleared — following GG", {
2616
+ provider: st.provider,
2617
+ model: st.model,
2618
+ });
2619
+ }
2620
+ else {
2621
+ const target = getModel(modelId);
2622
+ if (!target) {
2623
+ json(res, 404, { error: `unknown model: ${modelId}` });
2624
+ return;
2625
+ }
2626
+ nolanModelOverride = { provider: target.provider, model: target.id };
2627
+ await saveNolanModelPref(cwd, nolanModelOverride);
2628
+ await syncNolanModel(target.provider, target.id);
2629
+ await syncNolanAutoModel(target.provider, target.id);
2630
+ log("INFO", "app-sidecar", "ken model pinned", {
2631
+ provider: target.provider,
2632
+ model: target.id,
2633
+ });
2634
+ }
2635
+ const payload = nolanStatePayload();
2636
+ broadcast("ken_model_change", payload);
2637
+ json(res, 200, payload);
2638
+ });
2639
+ return;
2640
+ }
1899
2641
  if (method === "POST" && url === "/kill") {
1900
2642
  void readBody(req).then(async (raw) => {
1901
2643
  let id;
@@ -1938,12 +2680,14 @@ async function createSession(deps, opts) {
1938
2680
  return;
1939
2681
  }
1940
2682
  if (method === "POST" && url === "/cancel") {
2683
+ cancelGeneration++;
1941
2684
  abort.abort();
1942
2685
  abort = new AbortController();
1943
2686
  session.setSignal(abort.signal);
1944
2687
  running = false;
1945
2688
  // Stop a run-all sweep so the next pending task isn't auto-started.
1946
2689
  taskRunAll = false;
2690
+ taskRunActive = false;
1947
2691
  // Stop any in-flight autopilot cycle: flag it so the loop bails between
1948
2692
  // steps, and abort a review that's mid-prompt on the nolanAuto session.
1949
2693
  autopilotCancelled = true;
@@ -1967,6 +2711,8 @@ async function createSession(deps, opts) {
1967
2711
  void session
1968
2712
  .newSession()
1969
2713
  .then(() => {
2714
+ injectedAutopilotPrompts = [];
2715
+ clearPendingPlan();
1970
2716
  broadcast("session_reset", {});
1971
2717
  json(res, 200, { ok: true });
1972
2718
  })
@@ -1998,8 +2744,27 @@ async function createSession(deps, opts) {
1998
2744
  json(res, 409, { error: "cannot accept a plan while the agent is running" });
1999
2745
  return;
2000
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
+ }
2001
2765
  try {
2002
2766
  await session.newSession();
2767
+ injectedAutopilotPrompts = [];
2003
2768
  titleGenerated = false;
2004
2769
  await session.setApprovedPlan(planPath);
2005
2770
  broadcast("session_reset", {});