@prestyj/cli 5.1.0 → 5.2.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 (96) hide show
  1. package/README.md +2 -2
  2. package/dist/app-sidecar.js +269 -26
  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.d.ts +17 -0
  15. package/dist/core/agent-session.d.ts.map +1 -1
  16. package/dist/core/agent-session.js +61 -12
  17. package/dist/core/agent-session.js.map +1 -1
  18. package/dist/core/agent-tools-allowlist.test.d.ts +2 -0
  19. package/dist/core/agent-tools-allowlist.test.d.ts.map +1 -0
  20. package/dist/core/agent-tools-allowlist.test.js +72 -0
  21. package/dist/core/agent-tools-allowlist.test.js.map +1 -0
  22. package/dist/core/auth-providers.d.ts +21 -0
  23. package/dist/core/auth-providers.d.ts.map +1 -1
  24. package/dist/core/auth-providers.js +15 -2
  25. package/dist/core/auth-providers.js.map +1 -1
  26. package/dist/core/autopilot-verdict.d.ts +32 -0
  27. package/dist/core/autopilot-verdict.d.ts.map +1 -0
  28. package/dist/core/autopilot-verdict.js +93 -0
  29. package/dist/core/autopilot-verdict.js.map +1 -0
  30. package/dist/core/autopilot-verdict.test.d.ts +2 -0
  31. package/dist/core/autopilot-verdict.test.d.ts.map +1 -0
  32. package/dist/core/autopilot-verdict.test.js +80 -0
  33. package/dist/core/autopilot-verdict.test.js.map +1 -0
  34. package/dist/core/compaction/compactor.test.js +2 -2
  35. package/dist/core/compaction/compactor.test.js.map +1 -1
  36. package/dist/core/compaction/token-estimator.test.js +3 -3
  37. package/dist/core/compaction/token-estimator.test.js.map +1 -1
  38. package/dist/core/event-bus.d.ts +4 -0
  39. package/dist/core/event-bus.d.ts.map +1 -1
  40. package/dist/core/event-bus.js +6 -0
  41. package/dist/core/event-bus.js.map +1 -1
  42. package/dist/core/nolan-context.d.ts +18 -0
  43. package/dist/core/nolan-context.d.ts.map +1 -1
  44. package/dist/core/nolan-context.js +19 -0
  45. package/dist/core/nolan-context.js.map +1 -1
  46. package/dist/core/nolan-context.test.js +23 -1
  47. package/dist/core/nolan-context.test.js.map +1 -1
  48. package/dist/core/nolan-prompt.d.ts +10 -0
  49. package/dist/core/nolan-prompt.d.ts.map +1 -1
  50. package/dist/core/nolan-prompt.js +48 -0
  51. package/dist/core/nolan-prompt.js.map +1 -1
  52. package/dist/core/prompt-commands.d.ts.map +1 -1
  53. package/dist/core/prompt-commands.js +17 -3
  54. package/dist/core/prompt-commands.js.map +1 -1
  55. package/dist/core/prompt-commands.test.js +27 -1
  56. package/dist/core/prompt-commands.test.js.map +1 -1
  57. package/dist/core/session-manager.test.js +3 -1
  58. package/dist/core/session-manager.test.js.map +1 -1
  59. package/dist/core/task-store.d.ts +7 -0
  60. package/dist/core/task-store.d.ts.map +1 -1
  61. package/dist/core/task-store.js +13 -0
  62. package/dist/core/task-store.js.map +1 -1
  63. package/dist/modes/json-mode.d.ts +7 -0
  64. package/dist/modes/json-mode.d.ts.map +1 -1
  65. package/dist/modes/json-mode.js +4 -0
  66. package/dist/modes/json-mode.js.map +1 -1
  67. package/dist/system-prompt.test.js +4 -4
  68. package/dist/system-prompt.test.js.map +1 -1
  69. package/dist/tools/plan-mode.test.js +1 -1
  70. package/dist/tools/plan-mode.test.js.map +1 -1
  71. package/dist/tools/prompt-hints.d.ts.map +1 -1
  72. package/dist/tools/prompt-hints.js +4 -0
  73. package/dist/tools/prompt-hints.js.map +1 -1
  74. package/dist/tools/subagent.d.ts.map +1 -1
  75. package/dist/tools/subagent.js +24 -2
  76. package/dist/tools/subagent.js.map +1 -1
  77. package/dist/ui/App.d.ts.map +1 -1
  78. package/dist/ui/App.js +21 -5
  79. package/dist/ui/App.js.map +1 -1
  80. package/dist/ui/components/ActivityIndicator.d.ts.map +1 -1
  81. package/dist/ui/components/ActivityIndicator.js +1 -0
  82. package/dist/ui/components/ActivityIndicator.js.map +1 -1
  83. package/dist/ui/components/Footer.js +1 -1
  84. package/dist/ui/components/Footer.js.map +1 -1
  85. package/dist/ui/hooks/useAgentLoop.d.ts +1 -1
  86. package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
  87. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  88. package/dist/ui/hooks/useContextCompaction.d.ts.map +1 -1
  89. package/dist/ui/hooks/useContextCompaction.js +4 -2
  90. package/dist/ui/hooks/useContextCompaction.js.map +1 -1
  91. package/dist/ui/login.d.ts.map +1 -1
  92. package/dist/ui/login.js +6 -2
  93. package/dist/ui/login.js.map +1 -1
  94. package/dist/ui/thinking-level-cycle.test.js +1 -1
  95. package/dist/ui/thinking-level-cycle.test.js.map +1 -1
  96. package/package.json +4 -4
package/README.md CHANGED
@@ -69,12 +69,12 @@ Switch mid-conversation with `/model`. Not locked to anyone.
69
69
 
70
70
  | Provider | Models | Auth |
71
71
  |---|---|---|
72
- | **Anthropic** | Claude Fable 5, Opus 4.8, Sonnet 4.6, Haiku 4.5 | OAuth |
72
+ | **Anthropic** | Claude Opus 4.8, Sonnet 5, Haiku 4.5 | OAuth |
73
73
  | **OpenAI** | GPT-5.5, GPT-5.5 Pro, GPT-5.4, GPT-5.3 Codex | OAuth |
74
74
  | **Moonshot** | Kimi K2.6 | API key |
75
75
  | **Z.AI (GLM)** | GLM-5.1, GLM-4.7, GLM-4.7 Flash | API key |
76
76
  | **MiniMax** | MiniMax M3 (image + video) | API key |
77
- | **Xiaomi (MiMo)** | MiMo-V2.5-Pro, MiMo-V2.5 (image + video) | API key |
77
+ | **Xiaomi (MiMo)** | MiMo-V2.5-Pro, MiMo-V2.5-Pro-UltraSpeed, MiMo-V2.5 (image + video) | API key |
78
78
  | **DeepSeek** | DeepSeek V4 Pro, V4 Flash | API key |
79
79
  | **OpenRouter** | Qwen3.6-Plus + multi-provider gateway | API key |
80
80
 
@@ -17,13 +17,15 @@ 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";
24
26
  import { collectProjectContext } from "./system-prompt.js";
25
27
  import { AuthStorage } from "./core/auth-storage.js";
26
- import { MOONSHOT_OAUTH_KEY } from "@prestyj/core";
28
+ import { MOONSHOT_OAUTH_KEY, XIAOMI_CREDITS_KEY } from "@prestyj/core";
27
29
  import { loginAnthropic } from "./core/oauth/anthropic.js";
28
30
  import { loginOpenAI } from "./core/oauth/openai.js";
29
31
  import { loginGemini } from "./core/oauth/gemini.js";
@@ -38,7 +40,7 @@ import { getNextThinkingLevel, getSupportedThinkingLevels, isThinkingLevelSuppor
38
40
  import { PROMPT_COMMANDS } from "./core/prompt-commands.js";
39
41
  import { loadCustomCommands } from "./core/custom-commands.js";
40
42
  import { discoverProjects, listRecentSessions } from "./core/project-discovery.js";
41
- import { loadTasksSync, saveTasksSync, getNextPendingTask, markTaskInProgress, } from "./core/task-store.js";
43
+ import { loadTasksSync, saveTasksSync, pruneDoneTasksSync, getNextPendingTask, markTaskInProgress, } from "./core/task-store.js";
42
44
  import { initLogger, log } from "./core/logger.js";
43
45
  import { RADIO_STATIONS, getCurrentStation, playRadio, stopRadio } from "./core/radio.js";
44
46
  import { enrichProcessPath } from "./core/shell-path.js";
@@ -79,6 +81,7 @@ async function loadAppSettings() {
79
81
  // Preserve the per-project map verbatim (validated + written by the
80
82
  // model/thinking handlers below).
81
83
  projectModels: raw.projectModels && typeof raw.projectModels === "object" ? raw.projectModels : undefined,
84
+ autopilot: raw.autopilot && typeof raw.autopilot === "object" ? raw.autopilot : undefined,
82
85
  };
83
86
  }
84
87
  catch {
@@ -102,6 +105,19 @@ async function saveProjectModelPrefs(cwd, prefs) {
102
105
  s.projectModels = { ...(s.projectModels ?? {}), [key]: prefs };
103
106
  await saveAppSettings(s);
104
107
  }
108
+ /** Read this project's persisted autopilot flag (default off). */
109
+ async function loadAutopilot(cwd) {
110
+ const s = await loadAppSettings();
111
+ return s.autopilot?.[projectModelKey(cwd)] ?? false;
112
+ }
113
+ /** Persist this project's autopilot flag via read-modify-write so the rest of
114
+ * the settings file (projectsRoot, model map, other projects) is preserved. */
115
+ async function saveAutopilot(cwd, enabled) {
116
+ const s = await loadAppSettings();
117
+ const key = projectModelKey(cwd);
118
+ s.autopilot = { ...(s.autopilot ?? {}), [key]: enabled };
119
+ await saveAppSettings(s);
120
+ }
105
121
  /**
106
122
  * Persist the active model selection to ~/.ezcoder/settings.json so it survives app
107
123
  * restarts. Mirrors the CLI's handleModelSelect persistence (App.tsx).
@@ -632,6 +648,31 @@ async function createSession(deps, opts) {
632
648
  for (const c of clients)
633
649
  c.res.write(frame);
634
650
  }
651
+ // Turn any thrown value into the same clear headline/message/guidance shape
652
+ // the TUI shows (see gg-ai's formatError) instead of a bare `err.message`, log
653
+ // the full detail, and broadcast it under `type` ("error" or "nolan_error").
654
+ // Without this the webview only ever saw a raw provider string like
655
+ // `400 {"code":"400",...}` with no "is this me or them / when does it reset"
656
+ // context that the CLI has always given.
657
+ function broadcastError(type, logLabel, err) {
658
+ const f = formatError(err);
659
+ log("ERROR", "app-sidecar", logLabel, {
660
+ headline: f.headline,
661
+ source: f.source,
662
+ ...(f.message ? { message: f.message } : {}),
663
+ ...(f.provider ? { provider: f.provider } : {}),
664
+ ...(f.statusCode != null ? { statusCode: String(f.statusCode) } : {}),
665
+ ...(f.requestId ? { requestId: f.requestId } : {}),
666
+ });
667
+ broadcast(type, {
668
+ headline: f.headline,
669
+ ...(f.message ? { message: f.message } : {}),
670
+ guidance: f.guidance,
671
+ ...(f.provider ? { provider: f.provider } : {}),
672
+ ...(f.statusCode != null ? { statusCode: f.statusCode } : {}),
673
+ ...(f.resetsAt != null ? { resetsAt: f.resetsAt } : {}),
674
+ });
675
+ }
635
676
  // The session file path to resume (passed by the daemon's POST /session);
636
677
  // empty/unset starts a fresh session.
637
678
  const resumeSessionPath = opts.sessionPath;
@@ -680,7 +721,7 @@ async function createSession(deps, opts) {
680
721
  let gitIsRepo = await isGitRepo(cwd).catch(() => false);
681
722
  function currentContextWindow() {
682
723
  const st = session.getState();
683
- return getContextWindow(st.model, { provider: st.provider });
724
+ return getContextWindow(st.model, { provider: st.provider, accountId: st.accountId });
684
725
  }
685
726
  // Shared shape merged into /state + the SSE `ready` frame so the footer can
686
727
  // render context %, branch, and tasks immediately on connect.
@@ -731,9 +772,7 @@ async function createSession(deps, opts) {
731
772
  session.eventBus.on("turn_end", (d) => broadcast("turn_end", d));
732
773
  session.eventBus.on("agent_done", (d) => broadcast("agent_done", d));
733
774
  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 });
775
+ broadcastError("error", "agent error", d.error);
737
776
  });
738
777
  session.eventBus.on("model_change", (d) => broadcast("model_change", d));
739
778
  session.eventBus.on("hook", (d) => broadcast("hook", d));
@@ -741,6 +780,24 @@ async function createSession(deps, opts) {
741
780
  session.eventBus.on("compaction_end", (d) => broadcast("compaction_end", d));
742
781
  let running = false;
743
782
  let titleGenerated = false;
783
+ // Autopilot (auto-review) toggle for THIS window's project. Loaded from
784
+ // ezcoder-app.json on boot; flipped via POST /autopilot. When on, POST /prompt runs
785
+ // runAutopilotCycle after the user's turn settles — Nolan auto-reviews the work
786
+ // and drives the review→prompt→review loop.
787
+ let autopilot = await loadAutopilot(cwd);
788
+ // True while an autopilot review is in flight (used to defer nolanAuto model
789
+ // switches, like nolanRunning does for chat Nolan, and to drive the spinner).
790
+ let autopilotReviewing = false;
791
+ // True for the WHOLE autopilot cycle (reviews + injected runs). The build
792
+ // `running` flag is false during the review windows between injected runs, so
793
+ // this is the extra guard that makes a user /prompt queue as steering instead
794
+ // of starting a run that would collide with an injected one on the same
795
+ // session (AgentSession.prompt has no concurrency guard).
796
+ let autopilotActive = false;
797
+ // Set by /cancel to break out of an in-flight autopilot cycle between steps.
798
+ let autopilotCancelled = false;
799
+ // Hard cap on review→prompt→review rounds per user turn (loop safety).
800
+ const MAX_AUTOPILOT_ROUNDS = 3;
744
801
  // ── Telegram serve (remote control via Telegram) ───────────
745
802
  // A single embedded serve session lives in this sidecar process. Only the main
746
803
  // window's home screen exposes the controls, so there's one bot per app.
@@ -803,14 +860,59 @@ async function createSession(deps, opts) {
803
860
  ken.eventBus.on("server_tool_call", (d) => broadcast("nolan_server_tool_call", d));
804
861
  ken.eventBus.on("turn_end", (d) => broadcast("nolan_turn_end", d));
805
862
  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 });
863
+ broadcastError("nolan_error", "ken error", d.error);
809
864
  });
810
865
  nolanSession = ken;
811
866
  log("INFO", "app-sidecar", "ken session ready", { provider: st.provider, model: st.model });
812
867
  return ken;
813
868
  }
869
+ // ── Autopilot Nolan (auto-reviewer) ──────────────────────────
870
+ // A THIRD read-only AgentSession, separate from chat Nolan. In autopilot mode
871
+ // Nolan silently reviews each finished EZ Coder turn and returns a verdict
872
+ // (PROMPT / ALL_CLEAR / HUMAN). Its bus is intentionally NOT bridged to the
873
+ // nolan_* chat bubbles — the review is silent; we read its final assistant text
874
+ // and parse it. Uses the lean autopilot system prompt + the same read-only
875
+ // tools. Created lazily on the first autopilot cycle.
876
+ let nolanAutoSession = null;
877
+ let nolanAutoAbort = new AbortController();
878
+ let pendingNolanAutoModel = null;
879
+ async function syncNolanAutoModel(provider, model) {
880
+ if (autopilotReviewing) {
881
+ pendingNolanAutoModel = { provider, model };
882
+ return;
883
+ }
884
+ if (!nolanAutoSession)
885
+ return;
886
+ const st = nolanAutoSession.getState();
887
+ if (st.provider === provider && st.model === model)
888
+ return;
889
+ await nolanAutoSession.switchModel(provider, model);
890
+ log("INFO", "app-sidecar", "ken autopilot session model synced", { provider, model });
891
+ }
892
+ async function ensureNolanAutoSession() {
893
+ if (nolanAutoSession)
894
+ return nolanAutoSession;
895
+ const st = session.getState();
896
+ const ken = new AgentSession({
897
+ provider: st.provider,
898
+ model: st.model,
899
+ cwd,
900
+ systemPrompt: buildNolanAutopilotSystemPrompt(),
901
+ allowedTools: NOLAN_ALLOWED_TOOLS,
902
+ allowedMcpServers: NOLAN_ALLOWED_MCP_SERVERS,
903
+ transient: true,
904
+ signal: nolanAutoAbort.signal,
905
+ });
906
+ await ken.initialize();
907
+ // Deliberately no bus bridge: the review is silent. Errors surface via the
908
+ // runAutopilotReview try/catch as autopilot_error frames.
909
+ nolanAutoSession = ken;
910
+ log("INFO", "app-sidecar", "ken autopilot session ready", {
911
+ provider: st.provider,
912
+ model: st.model,
913
+ });
914
+ return ken;
915
+ }
814
916
  // Resumed session: if it already has a conversation, generate its title now so
815
917
  // the title bar shows it immediately on load (not just after the next prompt).
816
918
  {
@@ -836,9 +938,7 @@ async function createSession(deps, opts) {
836
938
  await run();
837
939
  }
838
940
  catch (err) {
839
- const message = err instanceof Error ? err.message : String(err);
840
- broadcast("error", { message });
841
- log("ERROR", "app-sidecar", "run failed", { message });
941
+ broadcastError("error", "run failed", err);
842
942
  }
843
943
  finally {
844
944
  running = false;
@@ -847,6 +947,14 @@ async function createSession(deps, opts) {
847
947
  gitBranch = await getGitBranch(cwd).catch(() => gitBranch);
848
948
  gitIsRepo = await isGitRepo(cwd).catch(() => gitIsRepo);
849
949
  broadcast("run_end", {});
950
+ // Autopilot's review loop is driven explicitly from POST /prompt (see
951
+ // runAutopilotCycle), NOT from this shared finally — that keeps the
952
+ // injected EZ Coder runs this cycle triggers from recursively re-entering
953
+ // the loop through the same bracket.
954
+ // The agent may have marked project tasks done during the run — prune the
955
+ // completed ones so they drop out of the Tasks modal automatically (users
956
+ // never have to delete finished tasks by hand).
957
+ broadcast("tasks_list", { tasks: pruneDoneTasksSync(cwd) });
850
958
  // Queue drains into the run as steering, so it's empty by run_end —
851
959
  // sync the webview indicator.
852
960
  broadcast("queued", { count: session.getQueuedCount() });
@@ -862,6 +970,79 @@ async function createSession(deps, opts) {
862
970
  }
863
971
  }
864
972
  }
973
+ // ── Autopilot orchestration ─────────────────────────────────
974
+ // One review = prompt the nolanAuto session with the review digest, read its
975
+ // final assistant text, parse a verdict. Returns null on failure (surfaced as
976
+ // an autopilot_error frame) so the cycle stops rather than looping blind.
977
+ async function runAutopilotReview() {
978
+ autopilotReviewing = true;
979
+ broadcast("autopilot_review_start", {});
980
+ try {
981
+ const ken = await ensureNolanAutoSession();
982
+ const projectContext = await collectProjectContext(cwd).catch(() => []);
983
+ const digest = buildNolanAutopilotContext({
984
+ projectContext,
985
+ cwd,
986
+ gitBranch,
987
+ messages: session.getMessages(),
988
+ });
989
+ await ken.prompt(digest);
990
+ return parseAutopilotVerdict(lastAssistantText(ken.getMessages()));
991
+ }
992
+ catch (err) {
993
+ broadcastError("autopilot_error", "autopilot review failed", err);
994
+ return null;
995
+ }
996
+ finally {
997
+ autopilotReviewing = false;
998
+ // Apply any model switch that landed mid-review.
999
+ const pending = pendingNolanAutoModel;
1000
+ pendingNolanAutoModel = null;
1001
+ if (pending)
1002
+ await syncNolanAutoModel(pending.provider, pending.model);
1003
+ }
1004
+ }
1005
+ // 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() {
1010
+ if (!autopilot || autopilotCancelled)
1011
+ return;
1012
+ autopilotActive = true;
1013
+ 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;
1027
+ }
1028
+ if (verdict.kind === "human") {
1029
+ broadcast("autopilot_human", { reason: verdict.reason });
1030
+ return;
1031
+ }
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
+ }
1040
+ broadcast("autopilot_capped", { rounds: MAX_AUTOPILOT_ROUNDS });
1041
+ }
1042
+ finally {
1043
+ autopilotActive = false;
1044
+ }
1045
+ }
865
1046
  // ── Task runner (project task list → sessions) ──────────────
866
1047
  // Mirrors the CLI's task flow: each task runs in its OWN fresh session, with a
867
1048
  // completion hint instructing the agent to mark the task done via the tasks
@@ -883,8 +1064,8 @@ async function createSession(deps, opts) {
883
1064
  `tasks({ action: "done", id: "${shortId}" })`;
884
1065
  await runAgent(task.title, () => session.prompt(task.prompt + completionHint));
885
1066
  // 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) });
1067
+ // prune completed tasks and push the refreshed list so the modal drops them.
1068
+ broadcast("tasks_list", { tasks: pruneDoneTasksSync(cwd) });
888
1069
  return true;
889
1070
  }
890
1071
  async function runTasks(startId, all) {
@@ -921,6 +1102,9 @@ async function createSession(deps, opts) {
921
1102
  };
922
1103
  }
923
1104
  async function authStatusPayload() {
1105
+ // Native (Rust) writes to auth.json bypass this daemon's cache; re-read so
1106
+ // the reported connection state matches what's actually on disk.
1107
+ await auth.reload();
924
1108
  const providers = await Promise.all(AUTH_PROVIDERS.map(async (p) => ({
925
1109
  ...p,
926
1110
  connected: await auth.hasProviderAuth(p.value),
@@ -979,6 +1163,7 @@ async function createSession(deps, opts) {
979
1163
  thinkingLevel: session.getThinkingLevel() ?? null,
980
1164
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
981
1165
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
1166
+ autopilot,
982
1167
  ...footerExtras(),
983
1168
  });
984
1169
  return;
@@ -1002,6 +1187,7 @@ async function createSession(deps, opts) {
1002
1187
  thinkingLevel: session.getThinkingLevel() ?? null,
1003
1188
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
1004
1189
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
1190
+ autopilot,
1005
1191
  ...footerExtras(),
1006
1192
  },
1007
1193
  })}\n\n`);
@@ -1325,10 +1511,13 @@ async function createSession(deps, opts) {
1325
1511
  json(res, 400, { error: "empty prompt" });
1326
1512
  return;
1327
1513
  }
1328
- if (running) {
1329
- // Queue prompts as mid-run steering (mirrors the CLI). Attachments are
1330
- // persisted to .gg/uploads first so the queued media rides the same
1331
- // native-block path as a non-queued attachment prompt when it drains.
1514
+ if (running || autopilotActive) {
1515
+ // Queue prompts as mid-run steering (mirrors the CLI). Also queue while
1516
+ // an autopilot cycle is active but between injected runs (build idle,
1517
+ // Nolan reviewing) so the message never starts a run that collides with
1518
+ // an injected one on the same session. Attachments are persisted to
1519
+ // .gg/uploads first so the queued media rides the same native-block
1520
+ // path as a non-queued attachment prompt when it drains.
1332
1521
  const prepared = attachments.length > 0 ? await prepareAttachments(cwd, attachments) : [];
1333
1522
  const count = session.queueMessage(text, prepared);
1334
1523
  broadcast("queued", { count });
@@ -1336,6 +1525,9 @@ async function createSession(deps, opts) {
1336
1525
  return;
1337
1526
  }
1338
1527
  json(res, 202, { accepted: true });
1528
+ // Fresh user turn: clear any cancel flag left from a prior cycle so this
1529
+ // turn's autopilot review can run.
1530
+ autopilotCancelled = false;
1339
1531
  await runAgent(text, async () => {
1340
1532
  if (attachments.length > 0) {
1341
1533
  // Persist each attachment under .ezcoder/uploads so files are inspectable
@@ -1351,6 +1543,11 @@ async function createSession(deps, opts) {
1351
1543
  await session.prompt(text);
1352
1544
  }
1353
1545
  });
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();
1354
1551
  });
1355
1552
  return;
1356
1553
  }
@@ -1391,9 +1588,7 @@ async function createSession(deps, opts) {
1391
1588
  await session.persistNolanTurn(text, reply);
1392
1589
  }
1393
1590
  catch (err) {
1394
- const message = err instanceof Error ? err.message : String(err);
1395
- log("ERROR", "app-sidecar", "ken run failed", { message });
1396
- broadcast("nolan_error", { message });
1591
+ broadcastError("nolan_error", "ken run failed", err);
1397
1592
  }
1398
1593
  finally {
1399
1594
  nolanRunning = false;
@@ -1415,6 +1610,24 @@ async function createSession(deps, opts) {
1415
1610
  json(res, 200, { cancelled: true });
1416
1611
  return;
1417
1612
  }
1613
+ if (method === "POST" && url === "/autopilot") {
1614
+ void readBody(req).then(async (raw) => {
1615
+ let enabled;
1616
+ try {
1617
+ enabled = Boolean(JSON.parse(raw).enabled);
1618
+ }
1619
+ catch {
1620
+ json(res, 400, { error: "invalid JSON body" });
1621
+ return;
1622
+ }
1623
+ autopilot = enabled;
1624
+ await saveAutopilot(cwd, enabled);
1625
+ log("INFO", "app-sidecar", "autopilot toggled", { enabled: String(enabled) });
1626
+ broadcast("autopilot", { autopilot: enabled });
1627
+ json(res, 200, { autopilot: enabled });
1628
+ });
1629
+ return;
1630
+ }
1418
1631
  if (method === "POST" && url === "/enhance") {
1419
1632
  void readBody(req).then(async (raw) => {
1420
1633
  let text;
@@ -1444,7 +1657,7 @@ async function createSession(deps, opts) {
1444
1657
  return;
1445
1658
  }
1446
1659
  if (method === "GET" && url === "/tasks") {
1447
- json(res, 200, { tasks: loadTasksSync(cwd) });
1660
+ json(res, 200, { tasks: pruneDoneTasksSync(cwd) });
1448
1661
  return;
1449
1662
  }
1450
1663
  // ── Radio (app-wide) ──────────────────────────────────────
@@ -1605,6 +1818,10 @@ async function createSession(deps, opts) {
1605
1818
  }
1606
1819
  if (method === "GET" && url === "/models") {
1607
1820
  void (async () => {
1821
+ // The desktop app writes API keys natively (Rust → auth.json), bypassing
1822
+ // this daemon's AuthStorage cache. Re-read from disk so a provider the
1823
+ // user just logged in for (e.g. Xiaomi/MiMo) shows up in the list.
1824
+ await auth.reload();
1608
1825
  const loggedIn = [];
1609
1826
  for (const p of ALL_PROVIDERS) {
1610
1827
  if (await auth.hasProviderAuth(p))
@@ -1640,8 +1857,12 @@ async function createSession(deps, opts) {
1640
1857
  json(res, 409, { error: "cannot switch model while running" });
1641
1858
  return;
1642
1859
  }
1860
+ // Pick up any natively-written keys (desktop app) before resolving the
1861
+ // new model's credentials, so switching to a just-added provider works.
1862
+ await auth.reload();
1643
1863
  await session.switchModel(target.provider, target.id);
1644
1864
  await syncNolanModel(target.provider, target.id);
1865
+ await syncNolanAutoModel(target.provider, target.id);
1645
1866
  // Clamp the reasoning level to what the new model supports (mirrors the
1646
1867
  // CLI): keep thinking on at the first supported tier if it was on but
1647
1868
  // the prior level is unsupported here; leave it off if it was off.
@@ -1723,6 +1944,13 @@ async function createSession(deps, opts) {
1723
1944
  running = false;
1724
1945
  // Stop a run-all sweep so the next pending task isn't auto-started.
1725
1946
  taskRunAll = false;
1947
+ // Stop any in-flight autopilot cycle: flag it so the loop bails between
1948
+ // steps, and abort a review that's mid-prompt on the nolanAuto session.
1949
+ autopilotCancelled = true;
1950
+ nolanAutoAbort.abort();
1951
+ nolanAutoAbort = new AbortController();
1952
+ nolanAutoSession?.setSignal(nolanAutoAbort.signal);
1953
+ autopilotReviewing = false;
1726
1954
  // Drop any queued steering and return it so the webview can restore it to
1727
1955
  // the composer.
1728
1956
  const drained = session.drainQueue();
@@ -1792,10 +2020,12 @@ async function createSession(deps, opts) {
1792
2020
  void readBody(req).then(async (raw) => {
1793
2021
  let provider = "";
1794
2022
  let key;
2023
+ let variant;
1795
2024
  try {
1796
2025
  const body = JSON.parse(raw);
1797
2026
  provider = body.provider ?? "";
1798
2027
  key = (body.key ?? "").trim();
2028
+ variant = body.variant;
1799
2029
  }
1800
2030
  catch {
1801
2031
  json(res, 400, { error: "invalid JSON body" });
@@ -1810,13 +2040,20 @@ async function createSession(deps, opts) {
1810
2040
  json(res, 400, { error: "API key is required" });
1811
2041
  return;
1812
2042
  }
2043
+ // Providers with multiple API-key variants (currently only Xiaomi: Token
2044
+ // Plan vs. API Credits) store under the chosen variant's key/baseUrl,
2045
+ // defaulting to the first variant. Single-variant providers fall back to
2046
+ // the legacy provider-id storage key + flat apiKeyBaseUrl.
2047
+ const chosenVariant = meta.apiKeyVariants?.find((v) => v.key === variant) ?? meta.apiKeyVariants?.[0];
2048
+ const storageKey = chosenVariant?.key ?? provider;
2049
+ const baseUrl = chosenVariant?.baseUrl ?? meta.apiKeyBaseUrl;
1813
2050
  const creds = {
1814
2051
  accessToken: key,
1815
2052
  refreshToken: "",
1816
2053
  expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000 * 100, // ~100y
1817
- ...(meta.apiKeyBaseUrl ? { baseUrl: meta.apiKeyBaseUrl } : {}),
2054
+ ...(baseUrl ? { baseUrl } : {}),
1818
2055
  };
1819
- await auth.setCredentials(provider, creds);
2056
+ await auth.setCredentials(storageKey, creds);
1820
2057
  broadcast("auth_done", { provider });
1821
2058
  json(res, 200, { ok: true });
1822
2059
  });
@@ -1913,6 +2150,10 @@ async function createSession(deps, opts) {
1913
2150
  // "disconnect" fully removes Kimi OAuth and the API key.
1914
2151
  if (provider === "moonshot")
1915
2152
  await auth.clearCredentials(MOONSHOT_OAUTH_KEY);
2153
+ // Xiaomi's API Credits credential lives under a distinct key — clear it
2154
+ // too so "disconnect" fully removes both the Token Plan and Credits keys.
2155
+ if (provider === "xiaomi")
2156
+ await auth.clearCredentials(XIAOMI_CREDITS_KEY);
1916
2157
  broadcast("auth_done", { provider });
1917
2158
  json(res, 200, { ok: true });
1918
2159
  });
@@ -2196,7 +2437,9 @@ async function createSession(deps, opts) {
2196
2437
  for (const c of clients)
2197
2438
  c.res.end();
2198
2439
  nolanAbort.abort();
2440
+ nolanAutoAbort.abort();
2199
2441
  await nolanSession?.dispose().catch(() => { });
2442
+ await nolanAutoSession?.dispose().catch(() => { });
2200
2443
  await session.dispose().catch(() => { });
2201
2444
  }
2202
2445
  return {