@prestyj/cli 5.1.1 → 5.3.0

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 (134) hide show
  1. package/README.md +2 -2
  2. package/dist/app-sidecar.js +506 -34
  3. package/dist/app-sidecar.js.map +1 -1
  4. package/dist/cli/auth.d.ts.map +1 -1
  5. package/dist/cli/auth.js +19 -2
  6. package/dist/cli/auth.js.map +1 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +80 -27
  9. package/dist/cli.js.map +1 -1
  10. package/dist/config.js +32 -30
  11. package/dist/config.js.map +1 -1
  12. package/dist/core/agent-session-compaction.test.js +1 -1
  13. package/dist/core/agent-session-compaction.test.js.map +1 -1
  14. package/dist/core/agent-session-queue.test.d.ts +2 -0
  15. package/dist/core/agent-session-queue.test.d.ts.map +1 -0
  16. package/dist/core/agent-session-queue.test.js +122 -0
  17. package/dist/core/agent-session-queue.test.js.map +1 -0
  18. package/dist/core/agent-session.d.ts +25 -0
  19. package/dist/core/agent-session.d.ts.map +1 -1
  20. package/dist/core/agent-session.js +68 -12
  21. package/dist/core/agent-session.js.map +1 -1
  22. package/dist/core/agent-tools-allowlist.test.d.ts +2 -0
  23. package/dist/core/agent-tools-allowlist.test.d.ts.map +1 -0
  24. package/dist/core/agent-tools-allowlist.test.js +72 -0
  25. package/dist/core/agent-tools-allowlist.test.js.map +1 -0
  26. package/dist/core/auth-providers.d.ts +21 -0
  27. package/dist/core/auth-providers.d.ts.map +1 -1
  28. package/dist/core/auth-providers.js +15 -2
  29. package/dist/core/auth-providers.js.map +1 -1
  30. package/dist/core/autopilot-cycle.d.ts +67 -0
  31. package/dist/core/autopilot-cycle.d.ts.map +1 -0
  32. package/dist/core/autopilot-cycle.js +50 -0
  33. package/dist/core/autopilot-cycle.js.map +1 -0
  34. package/dist/core/autopilot-cycle.test.d.ts +2 -0
  35. package/dist/core/autopilot-cycle.test.d.ts.map +1 -0
  36. package/dist/core/autopilot-cycle.test.js +179 -0
  37. package/dist/core/autopilot-cycle.test.js.map +1 -0
  38. package/dist/core/autopilot-gate.d.ts +83 -0
  39. package/dist/core/autopilot-gate.d.ts.map +1 -0
  40. package/dist/core/autopilot-gate.js +96 -0
  41. package/dist/core/autopilot-gate.js.map +1 -0
  42. package/dist/core/autopilot-gate.test.d.ts +2 -0
  43. package/dist/core/autopilot-gate.test.d.ts.map +1 -0
  44. package/dist/core/autopilot-gate.test.js +159 -0
  45. package/dist/core/autopilot-gate.test.js.map +1 -0
  46. package/dist/core/autopilot-verdict.d.ts +44 -0
  47. package/dist/core/autopilot-verdict.d.ts.map +1 -0
  48. package/dist/core/autopilot-verdict.js +110 -0
  49. package/dist/core/autopilot-verdict.js.map +1 -0
  50. package/dist/core/autopilot-verdict.test.d.ts +2 -0
  51. package/dist/core/autopilot-verdict.test.d.ts.map +1 -0
  52. package/dist/core/autopilot-verdict.test.js +90 -0
  53. package/dist/core/autopilot-verdict.test.js.map +1 -0
  54. package/dist/core/compaction/compactor.test.js +2 -2
  55. package/dist/core/compaction/compactor.test.js.map +1 -1
  56. package/dist/core/compaction/token-estimator.test.js +3 -3
  57. package/dist/core/compaction/token-estimator.test.js.map +1 -1
  58. package/dist/core/event-bus.d.ts +4 -0
  59. package/dist/core/event-bus.d.ts.map +1 -1
  60. package/dist/core/event-bus.js +6 -0
  61. package/dist/core/event-bus.js.map +1 -1
  62. package/dist/core/json-mode-flag-parity.test.d.ts +2 -0
  63. package/dist/core/json-mode-flag-parity.test.d.ts.map +1 -0
  64. package/dist/core/json-mode-flag-parity.test.js +66 -0
  65. package/dist/core/json-mode-flag-parity.test.js.map +1 -0
  66. package/dist/core/nolan-context.d.ts +35 -0
  67. package/dist/core/nolan-context.d.ts.map +1 -1
  68. package/dist/core/nolan-context.js +63 -3
  69. package/dist/core/nolan-context.js.map +1 -1
  70. package/dist/core/nolan-context.test.js +144 -1
  71. package/dist/core/nolan-context.test.js.map +1 -1
  72. package/dist/core/nolan-model.d.ts +46 -0
  73. package/dist/core/nolan-model.d.ts.map +1 -0
  74. package/dist/core/nolan-model.js +30 -0
  75. package/dist/core/nolan-model.js.map +1 -0
  76. package/dist/core/nolan-model.test.d.ts +2 -0
  77. package/dist/core/nolan-model.test.d.ts.map +1 -0
  78. package/dist/core/nolan-model.test.js +51 -0
  79. package/dist/core/nolan-model.test.js.map +1 -0
  80. package/dist/core/nolan-prompt.d.ts +10 -0
  81. package/dist/core/nolan-prompt.d.ts.map +1 -1
  82. package/dist/core/nolan-prompt.js +60 -0
  83. package/dist/core/nolan-prompt.js.map +1 -1
  84. package/dist/core/nolan-prompt.test.d.ts +2 -0
  85. package/dist/core/nolan-prompt.test.d.ts.map +1 -0
  86. package/dist/core/nolan-prompt.test.js +43 -0
  87. package/dist/core/nolan-prompt.test.js.map +1 -0
  88. package/dist/core/prompt-commands.d.ts.map +1 -1
  89. package/dist/core/prompt-commands.js +17 -3
  90. package/dist/core/prompt-commands.js.map +1 -1
  91. package/dist/core/prompt-commands.test.js +27 -1
  92. package/dist/core/prompt-commands.test.js.map +1 -1
  93. package/dist/core/session-manager.test.js +3 -1
  94. package/dist/core/session-manager.test.js.map +1 -1
  95. package/dist/core/speed-benchmark.test.js +2 -3
  96. package/dist/core/speed-benchmark.test.js.map +1 -1
  97. package/dist/core/task-store.d.ts +7 -0
  98. package/dist/core/task-store.d.ts.map +1 -1
  99. package/dist/core/task-store.js +13 -0
  100. package/dist/core/task-store.js.map +1 -1
  101. package/dist/modes/json-mode.d.ts +7 -0
  102. package/dist/modes/json-mode.d.ts.map +1 -1
  103. package/dist/modes/json-mode.js +4 -0
  104. package/dist/modes/json-mode.js.map +1 -1
  105. package/dist/system-prompt.test.js +4 -4
  106. package/dist/system-prompt.test.js.map +1 -1
  107. package/dist/tools/plan-mode.test.js +1 -1
  108. package/dist/tools/plan-mode.test.js.map +1 -1
  109. package/dist/tools/prompt-hints.d.ts.map +1 -1
  110. package/dist/tools/prompt-hints.js +4 -0
  111. package/dist/tools/prompt-hints.js.map +1 -1
  112. package/dist/tools/subagent.d.ts.map +1 -1
  113. package/dist/tools/subagent.js +24 -2
  114. package/dist/tools/subagent.js.map +1 -1
  115. package/dist/ui/App.d.ts.map +1 -1
  116. package/dist/ui/App.js +21 -5
  117. package/dist/ui/App.js.map +1 -1
  118. package/dist/ui/components/ActivityIndicator.d.ts.map +1 -1
  119. package/dist/ui/components/ActivityIndicator.js +1 -0
  120. package/dist/ui/components/ActivityIndicator.js.map +1 -1
  121. package/dist/ui/components/Footer.js +1 -1
  122. package/dist/ui/components/Footer.js.map +1 -1
  123. package/dist/ui/hooks/useAgentLoop.d.ts +1 -1
  124. package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
  125. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  126. package/dist/ui/hooks/useContextCompaction.d.ts.map +1 -1
  127. package/dist/ui/hooks/useContextCompaction.js +4 -2
  128. package/dist/ui/hooks/useContextCompaction.js.map +1 -1
  129. package/dist/ui/login.d.ts.map +1 -1
  130. package/dist/ui/login.js +6 -2
  131. package/dist/ui/login.js.map +1 -1
  132. package/dist/ui/thinking-level-cycle.test.js +1 -1
  133. package/dist/ui/thinking-level-cycle.test.js.map +1 -1
  134. package/package.json +4 -4
@@ -17,13 +17,18 @@ import os from "node:os";
17
17
  import path from "node:path";
18
18
  import { randomUUID } from "node:crypto";
19
19
  import { parseArgs } from "node:util";
20
+ import { formatError } from "@prestyj/ai";
20
21
  import { runJsonMode } from "./modes/json-mode.js";
21
22
  import { AgentSession } from "./core/agent-session.js";
22
- import { buildNolanSystemPrompt } from "./core/nolan-prompt.js";
23
- import { buildNolanDigest } from "./core/nolan-context.js";
23
+ import { buildNolanSystemPrompt, buildNolanAutopilotSystemPrompt } from "./core/nolan-prompt.js";
24
+ import { buildNolanDigest, buildNolanAutopilotContext } from "./core/nolan-context.js";
25
+ 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";
28
+ import { validateNolanModelPref, effectiveNolanModel, } from "./core/nolan-model.js";
24
29
  import { collectProjectContext } from "./system-prompt.js";
25
30
  import { AuthStorage } from "./core/auth-storage.js";
26
- import { MOONSHOT_OAUTH_KEY } from "@prestyj/core";
31
+ import { MOONSHOT_OAUTH_KEY, XIAOMI_CREDITS_KEY } from "@prestyj/core";
27
32
  import { loginAnthropic } from "./core/oauth/anthropic.js";
28
33
  import { loginOpenAI } from "./core/oauth/openai.js";
29
34
  import { loginGemini } from "./core/oauth/gemini.js";
@@ -38,7 +43,7 @@ import { getNextThinkingLevel, getSupportedThinkingLevels, isThinkingLevelSuppor
38
43
  import { PROMPT_COMMANDS } from "./core/prompt-commands.js";
39
44
  import { loadCustomCommands } from "./core/custom-commands.js";
40
45
  import { discoverProjects, listRecentSessions } from "./core/project-discovery.js";
41
- import { loadTasksSync, saveTasksSync, getNextPendingTask, markTaskInProgress, } from "./core/task-store.js";
46
+ import { loadTasksSync, saveTasksSync, pruneDoneTasksSync, getNextPendingTask, markTaskInProgress, } from "./core/task-store.js";
42
47
  import { initLogger, log } from "./core/logger.js";
43
48
  import { RADIO_STATIONS, getCurrentStation, playRadio, stopRadio } from "./core/radio.js";
44
49
  import { enrichProcessPath } from "./core/shell-path.js";
@@ -79,6 +84,8 @@ async function loadAppSettings() {
79
84
  // Preserve the per-project map verbatim (validated + written by the
80
85
  // model/thinking handlers below).
81
86
  projectModels: raw.projectModels && typeof raw.projectModels === "object" ? raw.projectModels : undefined,
87
+ autopilot: raw.autopilot && typeof raw.autopilot === "object" ? raw.autopilot : undefined,
88
+ nolanModels: raw.nolanModels && typeof raw.nolanModels === "object" ? raw.nolanModels : undefined,
82
89
  };
83
90
  }
84
91
  catch {
@@ -102,6 +109,37 @@ async function saveProjectModelPrefs(cwd, prefs) {
102
109
  s.projectModels = { ...(s.projectModels ?? {}), [key]: prefs };
103
110
  await saveAppSettings(s);
104
111
  }
112
+ /** Read this project's persisted Nolan model override, if any. */
113
+ async function loadNolanModelPref(cwd) {
114
+ const s = await loadAppSettings();
115
+ return s.nolanModels?.[projectModelKey(cwd)];
116
+ }
117
+ /** Persist (or with null, clear) this project's Nolan model override via
118
+ * read-modify-write so the rest of the settings file is preserved. */
119
+ async function saveNolanModelPref(cwd, pref) {
120
+ const s = await loadAppSettings();
121
+ const key = projectModelKey(cwd);
122
+ const next = { ...(s.nolanModels ?? {}) };
123
+ if (pref)
124
+ next[key] = pref;
125
+ else
126
+ delete next[key];
127
+ s.nolanModels = next;
128
+ await saveAppSettings(s);
129
+ }
130
+ /** Read this project's persisted autopilot flag (default off). */
131
+ async function loadAutopilot(cwd) {
132
+ const s = await loadAppSettings();
133
+ return s.autopilot?.[projectModelKey(cwd)] ?? false;
134
+ }
135
+ /** Persist this project's autopilot flag via read-modify-write so the rest of
136
+ * the settings file (projectsRoot, model map, other projects) is preserved. */
137
+ async function saveAutopilot(cwd, enabled) {
138
+ const s = await loadAppSettings();
139
+ const key = projectModelKey(cwd);
140
+ s.autopilot = { ...(s.autopilot ?? {}), [key]: enabled };
141
+ await saveAppSettings(s);
142
+ }
105
143
  /**
106
144
  * Persist the active model selection to ~/.ezcoder/settings.json so it survives app
107
145
  * restarts. Mirrors the CLI's handleModelSelect persistence (App.tsx).
@@ -362,12 +400,23 @@ async function runJsonModeIfRequested() {
362
400
  model: { type: "string" },
363
401
  "max-turns": { type: "string" },
364
402
  "system-prompt": { type: "string" },
403
+ tools: { type: "string" },
365
404
  "prompt-cache-key": { type: "string" },
366
405
  },
367
406
  allowPositionals: true,
368
407
  strict: true,
369
408
  });
370
409
  const maxTurnsRaw = values["max-turns"];
410
+ // Optional tool allow-list forwarded by the subagent spawner from an agent
411
+ // definition's `tools:` frontmatter. Mirrors the identical parsing in
412
+ // cli.ts's `values.json` branch — keep both in sync (see subagent.ts).
413
+ const parsedTools = values.tools
414
+ ? values.tools
415
+ .split(",")
416
+ .map((t) => t.trim())
417
+ .filter(Boolean)
418
+ : [];
419
+ const allowedTools = parsedTools.length > 0 ? parsedTools : undefined;
371
420
  await runJsonMode({
372
421
  message: positionals[0] ?? "",
373
422
  provider: (values.provider ?? "anthropic"),
@@ -375,6 +424,7 @@ async function runJsonModeIfRequested() {
375
424
  cwd: process.cwd(),
376
425
  systemPrompt: values["system-prompt"],
377
426
  maxTurns: maxTurnsRaw ? parseInt(maxTurnsRaw, 10) : undefined,
427
+ allowedTools,
378
428
  promptCacheKey: values["prompt-cache-key"],
379
429
  }).catch((err) => {
380
430
  process.stderr.write((err instanceof Error ? err.message : String(err)) + "\n");
@@ -575,9 +625,11 @@ function lastAssistantText(messages) {
575
625
  /**
576
626
  * Assemble Nolan's context digest for one `@Nolan` question: project docs (up the
577
627
  * tree) + git/env + the build session's compaction summary + recent activity.
578
- * Prepended to the user's question as Nolan's prompt body each turn.
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.
579
631
  */
580
- async function buildNolanContext(buildSession, cwd, gitBranch, question) {
632
+ async function buildNolanContext(buildSession, cwd, gitBranch, question, workflowCommands, injectedPrompts) {
581
633
  const projectContext = await collectProjectContext(cwd).catch(() => []);
582
634
  return buildNolanDigest({
583
635
  question,
@@ -585,6 +637,8 @@ async function buildNolanContext(buildSession, cwd, gitBranch, question) {
585
637
  cwd,
586
638
  gitBranch,
587
639
  messages: buildSession.getMessages(),
640
+ workflowCommands,
641
+ injectedPrompts,
588
642
  });
589
643
  }
590
644
  /**
@@ -632,6 +686,31 @@ async function createSession(deps, opts) {
632
686
  for (const c of clients)
633
687
  c.res.write(frame);
634
688
  }
689
+ // Turn any thrown value into the same clear headline/message/guidance shape
690
+ // the TUI shows (see gg-ai's formatError) instead of a bare `err.message`, log
691
+ // the full detail, and broadcast it under `type` ("error" or "nolan_error").
692
+ // Without this the webview only ever saw a raw provider string like
693
+ // `400 {"code":"400",...}` with no "is this me or them / when does it reset"
694
+ // context that the CLI has always given.
695
+ function broadcastError(type, logLabel, err) {
696
+ const f = formatError(err);
697
+ log("ERROR", "app-sidecar", logLabel, {
698
+ headline: f.headline,
699
+ source: f.source,
700
+ ...(f.message ? { message: f.message } : {}),
701
+ ...(f.provider ? { provider: f.provider } : {}),
702
+ ...(f.statusCode != null ? { statusCode: String(f.statusCode) } : {}),
703
+ ...(f.requestId ? { requestId: f.requestId } : {}),
704
+ });
705
+ broadcast(type, {
706
+ headline: f.headline,
707
+ ...(f.message ? { message: f.message } : {}),
708
+ guidance: f.guidance,
709
+ ...(f.provider ? { provider: f.provider } : {}),
710
+ ...(f.statusCode != null ? { statusCode: f.statusCode } : {}),
711
+ ...(f.resetsAt != null ? { resetsAt: f.resetsAt } : {}),
712
+ });
713
+ }
635
714
  // The session file path to resume (passed by the daemon's POST /session);
636
715
  // empty/unset starts a fresh session.
637
716
  const resumeSessionPath = opts.sessionPath;
@@ -680,7 +759,7 @@ async function createSession(deps, opts) {
680
759
  let gitIsRepo = await isGitRepo(cwd).catch(() => false);
681
760
  function currentContextWindow() {
682
761
  const st = session.getState();
683
- return getContextWindow(st.model, { provider: st.provider });
762
+ return getContextWindow(st.model, { provider: st.provider, accountId: st.accountId });
684
763
  }
685
764
  // Shared shape merged into /state + the SSE `ready` frame so the footer can
686
765
  // render context %, branch, and tasks immediately on connect.
@@ -731,9 +810,7 @@ async function createSession(deps, opts) {
731
810
  session.eventBus.on("turn_end", (d) => broadcast("turn_end", d));
732
811
  session.eventBus.on("agent_done", (d) => broadcast("agent_done", d));
733
812
  session.eventBus.on("error", (d) => {
734
- const message = d.error instanceof Error ? d.error.message : String(d.error);
735
- log("ERROR", "app-sidecar", "agent error", { message });
736
- broadcast("error", { message });
813
+ broadcastError("error", "agent error", d.error);
737
814
  });
738
815
  session.eventBus.on("model_change", (d) => broadcast("model_change", d));
739
816
  session.eventBus.on("hook", (d) => broadcast("hook", d));
@@ -741,6 +818,41 @@ async function createSession(deps, opts) {
741
818
  session.eventBus.on("compaction_end", (d) => broadcast("compaction_end", d));
742
819
  let running = false;
743
820
  let titleGenerated = false;
821
+ // Autopilot (auto-review) toggle for THIS window's project. Loaded from
822
+ // ezcoder-app.json on boot; flipped via POST /autopilot. When on, POST /prompt runs
823
+ // runAutopilotCycle after the user's turn settles — Nolan auto-reviews the work
824
+ // and drives the review→prompt→review loop.
825
+ let autopilot = await loadAutopilot(cwd);
826
+ // True while an autopilot review is in flight (used to defer nolanAuto model
827
+ // switches, like nolanRunning does for chat Nolan, and to drive the spinner).
828
+ let autopilotReviewing = false;
829
+ // True for the WHOLE autopilot cycle (reviews + injected runs). The build
830
+ // `running` flag is false during the review windows between injected runs, so
831
+ // this is the extra guard that makes a user /prompt queue as steering instead
832
+ // of starting a run that would collide with an injected one on the same
833
+ // session (AgentSession.prompt has no concurrency guard).
834
+ let autopilotActive = false;
835
+ // Set by /cancel to break out of an in-flight autopilot cycle between steps.
836
+ let autopilotCancelled = false;
837
+ // Hard cap on review→prompt→review rounds per user turn (loop safety).
838
+ const MAX_AUTOPILOT_ROUNDS = 3;
839
+ // Prompt bodies Autopilot Nolan injected into the BUILD session this
840
+ // conversation. Passed into every Nolan digest so injected prompts render as
841
+ // "Nolan autopilot (injected)" instead of `**User:**` — otherwise multi-round
842
+ // cycles drift into Nolan reviewing against his own last prompt. Cleared
843
+ // whenever the conversation resets (new session / plan accept / task run).
844
+ let injectedAutopilotPrompts = [];
845
+ // Workflow (prompt-template) commands: built-in + the project's custom
846
+ // `.ezcoder/commands/*.md`. Used to gate autopilot off command turns and to label
847
+ // expanded templates in Nolan's digests. Loaded fresh so a newly added custom
848
+ // command is picked up without a restart (mirrors GET /commands).
849
+ async function loadWorkflowCommandSpecs() {
850
+ const custom = await loadCustomCommands(cwd).catch(() => []);
851
+ return [
852
+ ...PROMPT_COMMANDS.map((c) => ({ name: c.name, aliases: c.aliases, prompt: c.prompt })),
853
+ ...custom.map((c) => ({ name: c.name, aliases: [], prompt: c.prompt })),
854
+ ];
855
+ }
744
856
  // ── Telegram serve (remote control via Telegram) ───────────
745
857
  // A single embedded serve session lives in this sidecar process. Only the main
746
858
  // window's home screen exposes the controls, so there's one bot per app.
@@ -756,6 +868,35 @@ async function createSession(deps, opts) {
756
868
  let nolanRunning = false;
757
869
  let pendingNolanModel = null;
758
870
  const nolanToolCallNames = new Map();
871
+ // Nolan's per-project model override. null → Nolan (chat + autopilot) follows GG
872
+ // Coder's model, including live switches (the historical behavior). Set → Nolan
873
+ // is pinned to his own model and EZ Coder switches no longer touch him. A
874
+ // stale persisted pin (model dropped from the registry / provider logged
875
+ // out) validates to null so Nolan degrades to following instead of erroring.
876
+ let nolanModelOverride = validateNolanModelPref(await loadNolanModelPref(cwd), {
877
+ modelExists: (id) => getModel(id) !== undefined,
878
+ providerConnected: () => true, // async auth checked below
879
+ });
880
+ if (nolanModelOverride && !(await auth.hasProviderAuth(nolanModelOverride.provider))) {
881
+ log("WARN", "app-sidecar", "ken model override provider not connected — following GG", {
882
+ provider: nolanModelOverride.provider,
883
+ model: nolanModelOverride.model,
884
+ });
885
+ nolanModelOverride = null;
886
+ }
887
+ /** The model Nolan uses next turn: the pin when set, else EZ Coder's. */
888
+ function nolanCurrentModel() {
889
+ if (nolanModelOverride)
890
+ return nolanModelOverride;
891
+ const st = session.getState();
892
+ return { provider: st.provider, model: st.model };
893
+ }
894
+ /** Footer payload: Nolan's effective model + whether it's a pin. Merged into
895
+ * /state, the SSE ready frame, and every ken_model_change broadcast. */
896
+ function nolanStatePayload() {
897
+ const st = session.getState();
898
+ return effectiveNolanModel(nolanModelOverride, { provider: st.provider, model: st.model });
899
+ }
759
900
  async function syncNolanModel(provider, model) {
760
901
  if (nolanRunning) {
761
902
  pendingNolanModel = { provider, model };
@@ -772,10 +913,10 @@ async function createSession(deps, opts) {
772
913
  async function ensureNolanSession() {
773
914
  if (nolanSession)
774
915
  return nolanSession;
775
- const st = session.getState();
916
+ const target = nolanCurrentModel();
776
917
  const ken = new AgentSession({
777
- provider: st.provider,
778
- model: st.model,
918
+ provider: target.provider,
919
+ model: target.model,
779
920
  cwd,
780
921
  systemPrompt: buildNolanSystemPrompt(),
781
922
  allowedTools: NOLAN_ALLOWED_TOOLS,
@@ -803,12 +944,60 @@ async function createSession(deps, opts) {
803
944
  ken.eventBus.on("server_tool_call", (d) => broadcast("nolan_server_tool_call", d));
804
945
  ken.eventBus.on("turn_end", (d) => broadcast("nolan_turn_end", d));
805
946
  ken.eventBus.on("error", (d) => {
806
- const message = d.error instanceof Error ? d.error.message : String(d.error);
807
- log("ERROR", "app-sidecar", "ken error", { message });
808
- broadcast("nolan_error", { message });
947
+ broadcastError("nolan_error", "ken error", d.error);
809
948
  });
810
949
  nolanSession = ken;
811
- log("INFO", "app-sidecar", "ken session ready", { provider: st.provider, model: st.model });
950
+ log("INFO", "app-sidecar", "ken session ready", {
951
+ provider: target.provider,
952
+ model: target.model,
953
+ });
954
+ return ken;
955
+ }
956
+ // ── Autopilot Nolan (auto-reviewer) ──────────────────────────
957
+ // A THIRD read-only AgentSession, separate from chat Nolan. In autopilot mode
958
+ // Nolan silently reviews each finished EZ Coder turn and returns a verdict
959
+ // (PROMPT / ALL_CLEAR / HUMAN). Its bus is intentionally NOT bridged to the
960
+ // nolan_* chat bubbles — the review is silent; we read its final assistant text
961
+ // and parse it. Uses the lean autopilot system prompt + the same read-only
962
+ // tools. Created lazily on the first autopilot cycle.
963
+ let nolanAutoSession = null;
964
+ let nolanAutoAbort = new AbortController();
965
+ let pendingNolanAutoModel = null;
966
+ async function syncNolanAutoModel(provider, model) {
967
+ if (autopilotReviewing) {
968
+ pendingNolanAutoModel = { provider, model };
969
+ return;
970
+ }
971
+ if (!nolanAutoSession)
972
+ return;
973
+ const st = nolanAutoSession.getState();
974
+ if (st.provider === provider && st.model === model)
975
+ return;
976
+ await nolanAutoSession.switchModel(provider, model);
977
+ log("INFO", "app-sidecar", "ken autopilot session model synced", { provider, model });
978
+ }
979
+ async function ensureNolanAutoSession() {
980
+ if (nolanAutoSession)
981
+ return nolanAutoSession;
982
+ const target = nolanCurrentModel();
983
+ const ken = new AgentSession({
984
+ provider: target.provider,
985
+ model: target.model,
986
+ cwd,
987
+ systemPrompt: buildNolanAutopilotSystemPrompt(),
988
+ allowedTools: NOLAN_ALLOWED_TOOLS,
989
+ allowedMcpServers: NOLAN_ALLOWED_MCP_SERVERS,
990
+ transient: true,
991
+ signal: nolanAutoAbort.signal,
992
+ });
993
+ await ken.initialize();
994
+ // Deliberately no bus bridge: the review is silent. Errors surface via the
995
+ // runAutopilotReview try/catch as autopilot_error frames.
996
+ nolanAutoSession = ken;
997
+ log("INFO", "app-sidecar", "ken autopilot session ready", {
998
+ provider: target.provider,
999
+ model: target.model,
1000
+ });
812
1001
  return ken;
813
1002
  }
814
1003
  // Resumed session: if it already has a conversation, generate its title now so
@@ -836,9 +1025,7 @@ async function createSession(deps, opts) {
836
1025
  await run();
837
1026
  }
838
1027
  catch (err) {
839
- const message = err instanceof Error ? err.message : String(err);
840
- broadcast("error", { message });
841
- log("ERROR", "app-sidecar", "run failed", { message });
1028
+ broadcastError("error", "run failed", err);
842
1029
  }
843
1030
  finally {
844
1031
  running = false;
@@ -847,6 +1034,14 @@ async function createSession(deps, opts) {
847
1034
  gitBranch = await getGitBranch(cwd).catch(() => gitBranch);
848
1035
  gitIsRepo = await isGitRepo(cwd).catch(() => gitIsRepo);
849
1036
  broadcast("run_end", {});
1037
+ // Autopilot's review loop is driven explicitly from POST /prompt (see
1038
+ // runAutopilotCycle), NOT from this shared finally — that keeps the
1039
+ // injected EZ Coder runs this cycle triggers from recursively re-entering
1040
+ // the loop through the same bracket.
1041
+ // The agent may have marked project tasks done during the run — prune the
1042
+ // completed ones so they drop out of the Tasks modal automatically (users
1043
+ // never have to delete finished tasks by hand).
1044
+ broadcast("tasks_list", { tasks: pruneDoneTasksSync(cwd) });
850
1045
  // Queue drains into the run as steering, so it's empty by run_end —
851
1046
  // sync the webview indicator.
852
1047
  broadcast("queued", { count: session.getQueuedCount() });
@@ -862,6 +1057,141 @@ async function createSession(deps, opts) {
862
1057
  }
863
1058
  }
864
1059
  }
1060
+ // ── Autopilot orchestration ─────────────────────────────────
1061
+ // One review = prompt the nolanAuto session with the review digest, read its
1062
+ // final assistant text, parse a verdict. Returns null on failure (surfaced as
1063
+ // an autopilot_error frame) so the cycle stops rather than looping blind.
1064
+ // `originalRequest` is the user prompt that started the turn under review —
1065
+ // pinned in the digest so it can't scroll out during multi-round cycles.
1066
+ async function runAutopilotReview(originalRequest) {
1067
+ autopilotReviewing = true;
1068
+ broadcast("autopilot_review_start", {});
1069
+ try {
1070
+ const ken = await ensureNolanAutoSession();
1071
+ const projectContext = await collectProjectContext(cwd).catch(() => []);
1072
+ const digest = buildNolanAutopilotContext({
1073
+ projectContext,
1074
+ cwd,
1075
+ gitBranch,
1076
+ messages: session.getMessages(),
1077
+ originalRequest,
1078
+ injectedPrompts: [...injectedAutopilotPrompts],
1079
+ workflowCommands: await loadWorkflowCommandSpecs(),
1080
+ });
1081
+ await ken.prompt(digest);
1082
+ return parseAutopilotVerdict(lastAssistantText(ken.getMessages()));
1083
+ }
1084
+ catch (err) {
1085
+ broadcastError("autopilot_error", "autopilot review failed", err);
1086
+ return null;
1087
+ }
1088
+ finally {
1089
+ autopilotReviewing = false;
1090
+ // Apply any model switch that landed mid-review.
1091
+ const pending = pendingNolanAutoModel;
1092
+ pendingNolanAutoModel = null;
1093
+ if (pending)
1094
+ await syncNolanAutoModel(pending.provider, pending.model);
1095
+ }
1096
+ }
1097
+ // Drive the review→prompt→review loop for one finished user turn. Only ever
1098
+ // called after shouldStartAutopilotCycle approves the turn (POST /prompt or
1099
+ // the stranded-queue drain) — never from the task runner, resume, /ken, or
1100
+ // error paths, so there's no recursion and no guard tangle. The loop's
1101
+ // control flow lives in driveAutopilotCycle (core/autopilot-cycle.ts) so
1102
+ // every exit path is unit-tested; this only wires the real dependencies.
1103
+ async function runAutopilotCycle(originalRequest) {
1104
+ if (!autopilot || autopilotCancelled)
1105
+ return;
1106
+ autopilotActive = true;
1107
+ try {
1108
+ await driveAutopilotCycle({
1109
+ maxRounds: MAX_AUTOPILOT_ROUNDS,
1110
+ 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.
1114
+ isPlanMode: () => session.getPlanMode(),
1115
+ // Lean context per user turn: wipe prior review history so each new
1116
+ // turn starts cheap, while within this cycle the few review messages
1117
+ // persist so Nolan remembers what he already asked EZ Coder to fix.
1118
+ resetReviewer: async () => {
1119
+ await nolanAutoSession?.newSession().catch(() => { });
1120
+ },
1121
+ review: () => runAutopilotReview(originalRequest),
1122
+ // prompt → record the injected body (so later digests label it as
1123
+ // Nolan's, not the user's), show a compact Nolan-tinted marker (not the
1124
+ // prompt body), then feed EZ Coder bracketed by runAgent so the run
1125
+ // streams normally; the shared finally never re-triggers autopilot,
1126
+ // so this can't recurse.
1127
+ onInjected: (body, round) => {
1128
+ injectedAutopilotPrompts.push(body);
1129
+ broadcast("autopilot_prompted", { round, body });
1130
+ },
1131
+ runPrompt: (body) => runAgent(body, () => session.prompt(body)),
1132
+ emit: (event) => broadcast(event.type, event.data),
1133
+ });
1134
+ }
1135
+ finally {
1136
+ autopilotActive = false;
1137
+ }
1138
+ }
1139
+ // ── Stranded-queue drain ───────────────────────────────
1140
+ // A prompt POSTed while an autopilot cycle is between injected runs (build
1141
+ // idle, Nolan reviewing) queues — but the queue only drains INTO a running
1142
+ // turn as steering. If the cycle ends without another run (ALL_CLEAR /
1143
+ // IGNORE / HUMAN / error), that message would sit stranded until the next
1144
+ // unrelated prompt, then land mislabeled as "concurrent steering" of an
1145
+ // unrelated run. Drain it here as a fresh turn of its own (with its own
1146
+ // gated review). Also covers the non-autopilot tail window: a message queued
1147
+ // after the run's last steering drain but before run_end.
1148
+ let drainingStrandedQueue = false;
1149
+ async function runStrandedQueue() {
1150
+ if (drainingStrandedQueue)
1151
+ return;
1152
+ drainingStrandedQueue = true;
1153
+ try {
1154
+ for (;;) {
1155
+ if (running || autopilotActive)
1156
+ return;
1157
+ const next = session.takeNextQueuedMessage();
1158
+ if (!next)
1159
+ return;
1160
+ broadcast("queued", { count: session.getQueuedCount() });
1161
+ if (!next.text.trim() && next.attachments.length === 0)
1162
+ continue;
1163
+ const workflowCommand = next.attachments.length === 0 &&
1164
+ isWorkflowCommandText(next.text, await loadWorkflowCommandSpecs());
1165
+ const assistantsBefore = countAssistantMessages(session.getMessages());
1166
+ await runAgent(next.text, async () => {
1167
+ if (next.attachments.length > 0) {
1168
+ await session.promptWithAttachments(next.text, next.attachments);
1169
+ }
1170
+ else {
1171
+ await session.prompt(next.text);
1172
+ }
1173
+ });
1174
+ const decision = shouldStartAutopilotCycle({
1175
+ enabled: autopilot,
1176
+ cancelled: autopilotCancelled,
1177
+ planMode: session.getPlanMode(),
1178
+ workflowCommand,
1179
+ assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
1180
+ });
1181
+ if (decision.start) {
1182
+ await runAutopilotCycle(next.text);
1183
+ }
1184
+ else if (autopilot) {
1185
+ log("INFO", "app-sidecar", "autopilot skipped (queued turn)", {
1186
+ reason: decision.reason,
1187
+ });
1188
+ }
1189
+ }
1190
+ }
1191
+ finally {
1192
+ drainingStrandedQueue = false;
1193
+ }
1194
+ }
865
1195
  // ── Task runner (project task list → sessions) ──────────────
866
1196
  // Mirrors the CLI's task flow: each task runs in its OWN fresh session, with a
867
1197
  // completion hint instructing the agent to mark the task done via the tasks
@@ -873,6 +1203,7 @@ async function createSession(deps, opts) {
873
1203
  return false;
874
1204
  // Fresh session per task so one task's context never bleeds into the next.
875
1205
  await session.newSession();
1206
+ injectedAutopilotPrompts = [];
876
1207
  titleGenerated = false;
877
1208
  broadcast("session_reset", {});
878
1209
  markTaskInProgress(cwd, task.id);
@@ -883,8 +1214,8 @@ async function createSession(deps, opts) {
883
1214
  `tasks({ action: "done", id: "${shortId}" })`;
884
1215
  await runAgent(task.title, () => session.prompt(task.prompt + completionHint));
885
1216
  // The agent typically marks the task done via the tasks tool during the run;
886
- // push the refreshed list so the webview's task modal reflects it.
887
- broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
1217
+ // prune completed tasks and push the refreshed list so the modal drops them.
1218
+ broadcast("tasks_list", { tasks: pruneDoneTasksSync(cwd) });
888
1219
  return true;
889
1220
  }
890
1221
  async function runTasks(startId, all) {
@@ -982,6 +1313,8 @@ async function createSession(deps, opts) {
982
1313
  thinkingLevel: session.getThinkingLevel() ?? null,
983
1314
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
984
1315
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
1316
+ autopilot,
1317
+ ...nolanStatePayload(),
985
1318
  ...footerExtras(),
986
1319
  });
987
1320
  return;
@@ -1005,6 +1338,8 @@ async function createSession(deps, opts) {
1005
1338
  thinkingLevel: session.getThinkingLevel() ?? null,
1006
1339
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
1007
1340
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
1341
+ autopilot,
1342
+ ...nolanStatePayload(),
1008
1343
  ...footerExtras(),
1009
1344
  },
1010
1345
  })}\n\n`);
@@ -1328,10 +1663,13 @@ async function createSession(deps, opts) {
1328
1663
  json(res, 400, { error: "empty prompt" });
1329
1664
  return;
1330
1665
  }
1331
- if (running) {
1332
- // Queue prompts as mid-run steering (mirrors the CLI). Attachments are
1333
- // persisted to .gg/uploads first so the queued media rides the same
1334
- // native-block path as a non-queued attachment prompt when it drains.
1666
+ if (running || autopilotActive) {
1667
+ // Queue prompts as mid-run steering (mirrors the CLI). Also queue while
1668
+ // an autopilot cycle is active but between injected runs (build idle,
1669
+ // Nolan reviewing) so the message never starts a run that collides with
1670
+ // an injected one on the same session. Attachments are persisted to
1671
+ // .gg/uploads first so the queued media rides the same native-block
1672
+ // path as a non-queued attachment prompt when it drains.
1335
1673
  const prepared = attachments.length > 0 ? await prepareAttachments(cwd, attachments) : [];
1336
1674
  const count = session.queueMessage(text, prepared);
1337
1675
  broadcast("queued", { count });
@@ -1339,6 +1677,16 @@ async function createSession(deps, opts) {
1339
1677
  return;
1340
1678
  }
1341
1679
  json(res, 202, { accepted: true });
1680
+ // Fresh user turn: clear any cancel flag left from a prior cycle so this
1681
+ // turn's autopilot review can run.
1682
+ autopilotCancelled = false;
1683
+ // Gate inputs captured around the run: whether this turn is a workflow
1684
+ // slash command (attachment prompts skip slash expansion entirely), and
1685
+ // how many assistant messages the run actually adds. Computed even when
1686
+ // autopilot is currently off — the toggle can flip ON mid-run, and the
1687
+ // gate reads the post-run value.
1688
+ const workflowCommand = attachments.length === 0 && isWorkflowCommandText(text, await loadWorkflowCommandSpecs());
1689
+ const assistantsBefore = countAssistantMessages(session.getMessages());
1342
1690
  await runAgent(text, async () => {
1343
1691
  if (attachments.length > 0) {
1344
1692
  // Persist each attachment under .ezcoder/uploads so files are inspectable
@@ -1354,6 +1702,31 @@ async function createSession(deps, opts) {
1354
1702
  await session.prompt(text);
1355
1703
  }
1356
1704
  });
1705
+ // After the user's run settles, kick off Nolan's auto-review loop — but
1706
+ // only when the turn is actually reviewable (shouldStartAutopilotCycle):
1707
+ // workflow commands (/compare, /bullet-proof, …) end with reports or
1708
+ // A/B/C choices reserved for the USER; registry commands (/help) and
1709
+ // failed runs add no assistant work to judge; a turn that ended in plan
1710
+ // mode has a pending Accept/Reject modal Nolan must not preempt. This is
1711
+ // the ONLY entry point into the cycle besides the stranded-queue drain —
1712
+ // it drives any follow-up EZ Coder runs itself, so the shared runAgent
1713
+ // finally never recurses.
1714
+ const decision = shouldStartAutopilotCycle({
1715
+ enabled: autopilot,
1716
+ cancelled: autopilotCancelled,
1717
+ planMode: session.getPlanMode(),
1718
+ workflowCommand,
1719
+ assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
1720
+ });
1721
+ if (decision.start) {
1722
+ await runAutopilotCycle(text);
1723
+ }
1724
+ else if (autopilot) {
1725
+ log("INFO", "app-sidecar", "autopilot skipped", { reason: decision.reason });
1726
+ }
1727
+ // A prompt sent while Nolan was reviewing (build idle) queued but had no
1728
+ // run to steer into — run it now as a fresh turn so it never strands.
1729
+ await runStrandedQueue();
1357
1730
  });
1358
1731
  return;
1359
1732
  }
@@ -1384,7 +1757,7 @@ async function createSession(deps, opts) {
1384
1757
  broadcast("nolan_run_start", { text });
1385
1758
  try {
1386
1759
  const ken = await ensureNolanSession();
1387
- const digest = await buildNolanContext(session, cwd, gitBranch, text);
1760
+ const digest = await buildNolanContext(session, cwd, gitBranch, text, await loadWorkflowCommandSpecs(), injectedAutopilotPrompts);
1388
1761
  await ken.prompt(digest);
1389
1762
  // Record the turn against the BUILD session so it persists + survives
1390
1763
  // resume (advisory custom entry, never an LLM message). Reply is Nolan's
@@ -1394,9 +1767,7 @@ async function createSession(deps, opts) {
1394
1767
  await session.persistNolanTurn(text, reply);
1395
1768
  }
1396
1769
  catch (err) {
1397
- const message = err instanceof Error ? err.message : String(err);
1398
- log("ERROR", "app-sidecar", "ken run failed", { message });
1399
- broadcast("nolan_error", { message });
1770
+ broadcastError("nolan_error", "ken run failed", err);
1400
1771
  }
1401
1772
  finally {
1402
1773
  nolanRunning = false;
@@ -1418,6 +1789,24 @@ async function createSession(deps, opts) {
1418
1789
  json(res, 200, { cancelled: true });
1419
1790
  return;
1420
1791
  }
1792
+ if (method === "POST" && url === "/autopilot") {
1793
+ void readBody(req).then(async (raw) => {
1794
+ let enabled;
1795
+ try {
1796
+ enabled = Boolean(JSON.parse(raw).enabled);
1797
+ }
1798
+ catch {
1799
+ json(res, 400, { error: "invalid JSON body" });
1800
+ return;
1801
+ }
1802
+ autopilot = enabled;
1803
+ await saveAutopilot(cwd, enabled);
1804
+ log("INFO", "app-sidecar", "autopilot toggled", { enabled: String(enabled) });
1805
+ broadcast("autopilot", { autopilot: enabled });
1806
+ json(res, 200, { autopilot: enabled });
1807
+ });
1808
+ return;
1809
+ }
1421
1810
  if (method === "POST" && url === "/enhance") {
1422
1811
  void readBody(req).then(async (raw) => {
1423
1812
  let text;
@@ -1447,7 +1836,7 @@ async function createSession(deps, opts) {
1447
1836
  return;
1448
1837
  }
1449
1838
  if (method === "GET" && url === "/tasks") {
1450
- json(res, 200, { tasks: loadTasksSync(cwd) });
1839
+ json(res, 200, { tasks: pruneDoneTasksSync(cwd) });
1451
1840
  return;
1452
1841
  }
1453
1842
  // ── Radio (app-wide) ──────────────────────────────────────
@@ -1651,7 +2040,12 @@ async function createSession(deps, opts) {
1651
2040
  // new model's credentials, so switching to a just-added provider works.
1652
2041
  await auth.reload();
1653
2042
  await session.switchModel(target.provider, target.id);
1654
- await syncNolanModel(target.provider, target.id);
2043
+ // Nolan follows EZ Coder's model only while un-pinned; a user-set Nolan
2044
+ // override survives GG model switches untouched.
2045
+ if (!nolanModelOverride) {
2046
+ await syncNolanModel(target.provider, target.id);
2047
+ await syncNolanAutoModel(target.provider, target.id);
2048
+ }
1655
2049
  // Clamp the reasoning level to what the new model supports (mirrors the
1656
2050
  // CLI): keep thinking on at the first supported tier if it was on but
1657
2051
  // the prior level is unsupported here; leave it off if it was off.
@@ -1678,6 +2072,12 @@ async function createSession(deps, opts) {
1678
2072
  // model_change is emitted by switchModel; follow with thinking_change so
1679
2073
  // the footer toggle reflects the new model's supported levels.
1680
2074
  broadcast("thinking_change", payload);
2075
+ // Un-pinned Nolan just followed the switch — update his footer chip too.
2076
+ // When Nolan is pinned, his effective model did not change, so skip the
2077
+ // no-op event (keeps footer/event tests from treating a GG switch as a
2078
+ // Nolan switch).
2079
+ if (!nolanModelOverride)
2080
+ broadcast("ken_model_change", nolanStatePayload());
1681
2081
  // The new model usually has a different context window — push extras so
1682
2082
  // the footer's context meter rescales immediately.
1683
2083
  broadcast("extras", footerExtras());
@@ -1685,6 +2085,54 @@ async function createSession(deps, opts) {
1685
2085
  });
1686
2086
  return;
1687
2087
  }
2088
+ // Set or clear Nolan's model pin. Body: { model: "<id>" } to pin, or
2089
+ // { model: null } / "" to clear (Nolan resumes following EZ Coder). Applies
2090
+ // to BOTH Nolan sessions (chat + autopilot reviewer); a switch landing while
2091
+ // either is mid-run defers via the pending-model mechanics.
2092
+ if (method === "POST" && url === "/ken/model") {
2093
+ void readBody(req).then(async (raw) => {
2094
+ let modelId;
2095
+ try {
2096
+ const parsed = JSON.parse(raw).model;
2097
+ modelId = typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
2098
+ }
2099
+ catch {
2100
+ json(res, 400, { error: "invalid JSON body" });
2101
+ return;
2102
+ }
2103
+ if (modelId === null) {
2104
+ // Clear the pin → follow EZ Coder again, syncing both sessions back.
2105
+ nolanModelOverride = null;
2106
+ await saveNolanModelPref(cwd, null);
2107
+ const st = session.getState();
2108
+ await syncNolanModel(st.provider, st.model);
2109
+ await syncNolanAutoModel(st.provider, st.model);
2110
+ log("INFO", "app-sidecar", "ken model pin cleared — following GG", {
2111
+ provider: st.provider,
2112
+ model: st.model,
2113
+ });
2114
+ }
2115
+ else {
2116
+ const target = getModel(modelId);
2117
+ if (!target) {
2118
+ json(res, 404, { error: `unknown model: ${modelId}` });
2119
+ return;
2120
+ }
2121
+ nolanModelOverride = { provider: target.provider, model: target.id };
2122
+ await saveNolanModelPref(cwd, nolanModelOverride);
2123
+ await syncNolanModel(target.provider, target.id);
2124
+ await syncNolanAutoModel(target.provider, target.id);
2125
+ log("INFO", "app-sidecar", "ken model pinned", {
2126
+ provider: target.provider,
2127
+ model: target.id,
2128
+ });
2129
+ }
2130
+ const payload = nolanStatePayload();
2131
+ broadcast("ken_model_change", payload);
2132
+ json(res, 200, payload);
2133
+ });
2134
+ return;
2135
+ }
1688
2136
  if (method === "POST" && url === "/kill") {
1689
2137
  void readBody(req).then(async (raw) => {
1690
2138
  let id;
@@ -1733,6 +2181,13 @@ async function createSession(deps, opts) {
1733
2181
  running = false;
1734
2182
  // Stop a run-all sweep so the next pending task isn't auto-started.
1735
2183
  taskRunAll = false;
2184
+ // Stop any in-flight autopilot cycle: flag it so the loop bails between
2185
+ // steps, and abort a review that's mid-prompt on the nolanAuto session.
2186
+ autopilotCancelled = true;
2187
+ nolanAutoAbort.abort();
2188
+ nolanAutoAbort = new AbortController();
2189
+ nolanAutoSession?.setSignal(nolanAutoAbort.signal);
2190
+ autopilotReviewing = false;
1736
2191
  // Drop any queued steering and return it so the webview can restore it to
1737
2192
  // the composer.
1738
2193
  const drained = session.drainQueue();
@@ -1749,6 +2204,7 @@ async function createSession(deps, opts) {
1749
2204
  void session
1750
2205
  .newSession()
1751
2206
  .then(() => {
2207
+ injectedAutopilotPrompts = [];
1752
2208
  broadcast("session_reset", {});
1753
2209
  json(res, 200, { ok: true });
1754
2210
  })
@@ -1782,6 +2238,7 @@ async function createSession(deps, opts) {
1782
2238
  }
1783
2239
  try {
1784
2240
  await session.newSession();
2241
+ injectedAutopilotPrompts = [];
1785
2242
  titleGenerated = false;
1786
2243
  await session.setApprovedPlan(planPath);
1787
2244
  broadcast("session_reset", {});
@@ -1802,10 +2259,12 @@ async function createSession(deps, opts) {
1802
2259
  void readBody(req).then(async (raw) => {
1803
2260
  let provider = "";
1804
2261
  let key;
2262
+ let variant;
1805
2263
  try {
1806
2264
  const body = JSON.parse(raw);
1807
2265
  provider = body.provider ?? "";
1808
2266
  key = (body.key ?? "").trim();
2267
+ variant = body.variant;
1809
2268
  }
1810
2269
  catch {
1811
2270
  json(res, 400, { error: "invalid JSON body" });
@@ -1820,13 +2279,20 @@ async function createSession(deps, opts) {
1820
2279
  json(res, 400, { error: "API key is required" });
1821
2280
  return;
1822
2281
  }
2282
+ // Providers with multiple API-key variants (currently only Xiaomi: Token
2283
+ // Plan vs. API Credits) store under the chosen variant's key/baseUrl,
2284
+ // defaulting to the first variant. Single-variant providers fall back to
2285
+ // the legacy provider-id storage key + flat apiKeyBaseUrl.
2286
+ const chosenVariant = meta.apiKeyVariants?.find((v) => v.key === variant) ?? meta.apiKeyVariants?.[0];
2287
+ const storageKey = chosenVariant?.key ?? provider;
2288
+ const baseUrl = chosenVariant?.baseUrl ?? meta.apiKeyBaseUrl;
1823
2289
  const creds = {
1824
2290
  accessToken: key,
1825
2291
  refreshToken: "",
1826
2292
  expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000 * 100, // ~100y
1827
- ...(meta.apiKeyBaseUrl ? { baseUrl: meta.apiKeyBaseUrl } : {}),
2293
+ ...(baseUrl ? { baseUrl } : {}),
1828
2294
  };
1829
- await auth.setCredentials(provider, creds);
2295
+ await auth.setCredentials(storageKey, creds);
1830
2296
  broadcast("auth_done", { provider });
1831
2297
  json(res, 200, { ok: true });
1832
2298
  });
@@ -1923,6 +2389,10 @@ async function createSession(deps, opts) {
1923
2389
  // "disconnect" fully removes Kimi OAuth and the API key.
1924
2390
  if (provider === "moonshot")
1925
2391
  await auth.clearCredentials(MOONSHOT_OAUTH_KEY);
2392
+ // Xiaomi's API Credits credential lives under a distinct key — clear it
2393
+ // too so "disconnect" fully removes both the Token Plan and Credits keys.
2394
+ if (provider === "xiaomi")
2395
+ await auth.clearCredentials(XIAOMI_CREDITS_KEY);
1926
2396
  broadcast("auth_done", { provider });
1927
2397
  json(res, 200, { ok: true });
1928
2398
  });
@@ -2206,7 +2676,9 @@ async function createSession(deps, opts) {
2206
2676
  for (const c of clients)
2207
2677
  c.res.end();
2208
2678
  nolanAbort.abort();
2679
+ nolanAutoAbort.abort();
2209
2680
  await nolanSession?.dispose().catch(() => { });
2681
+ await nolanAutoSession?.dispose().catch(() => { });
2210
2682
  await session.dispose().catch(() => { });
2211
2683
  }
2212
2684
  return {