@prestyj/cli 5.1.1 → 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 +259 -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 +5 -5
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) {
@@ -982,6 +1163,7 @@ async function createSession(deps, opts) {
982
1163
  thinkingLevel: session.getThinkingLevel() ?? null,
983
1164
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
984
1165
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
1166
+ autopilot,
985
1167
  ...footerExtras(),
986
1168
  });
987
1169
  return;
@@ -1005,6 +1187,7 @@ async function createSession(deps, opts) {
1005
1187
  thinkingLevel: session.getThinkingLevel() ?? null,
1006
1188
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
1007
1189
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
1190
+ autopilot,
1008
1191
  ...footerExtras(),
1009
1192
  },
1010
1193
  })}\n\n`);
@@ -1328,10 +1511,13 @@ async function createSession(deps, opts) {
1328
1511
  json(res, 400, { error: "empty prompt" });
1329
1512
  return;
1330
1513
  }
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.
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.
1335
1521
  const prepared = attachments.length > 0 ? await prepareAttachments(cwd, attachments) : [];
1336
1522
  const count = session.queueMessage(text, prepared);
1337
1523
  broadcast("queued", { count });
@@ -1339,6 +1525,9 @@ async function createSession(deps, opts) {
1339
1525
  return;
1340
1526
  }
1341
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;
1342
1531
  await runAgent(text, async () => {
1343
1532
  if (attachments.length > 0) {
1344
1533
  // Persist each attachment under .ezcoder/uploads so files are inspectable
@@ -1354,6 +1543,11 @@ async function createSession(deps, opts) {
1354
1543
  await session.prompt(text);
1355
1544
  }
1356
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();
1357
1551
  });
1358
1552
  return;
1359
1553
  }
@@ -1394,9 +1588,7 @@ async function createSession(deps, opts) {
1394
1588
  await session.persistNolanTurn(text, reply);
1395
1589
  }
1396
1590
  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 });
1591
+ broadcastError("nolan_error", "ken run failed", err);
1400
1592
  }
1401
1593
  finally {
1402
1594
  nolanRunning = false;
@@ -1418,6 +1610,24 @@ async function createSession(deps, opts) {
1418
1610
  json(res, 200, { cancelled: true });
1419
1611
  return;
1420
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
+ }
1421
1631
  if (method === "POST" && url === "/enhance") {
1422
1632
  void readBody(req).then(async (raw) => {
1423
1633
  let text;
@@ -1447,7 +1657,7 @@ async function createSession(deps, opts) {
1447
1657
  return;
1448
1658
  }
1449
1659
  if (method === "GET" && url === "/tasks") {
1450
- json(res, 200, { tasks: loadTasksSync(cwd) });
1660
+ json(res, 200, { tasks: pruneDoneTasksSync(cwd) });
1451
1661
  return;
1452
1662
  }
1453
1663
  // ── Radio (app-wide) ──────────────────────────────────────
@@ -1652,6 +1862,7 @@ async function createSession(deps, opts) {
1652
1862
  await auth.reload();
1653
1863
  await session.switchModel(target.provider, target.id);
1654
1864
  await syncNolanModel(target.provider, target.id);
1865
+ await syncNolanAutoModel(target.provider, target.id);
1655
1866
  // Clamp the reasoning level to what the new model supports (mirrors the
1656
1867
  // CLI): keep thinking on at the first supported tier if it was on but
1657
1868
  // the prior level is unsupported here; leave it off if it was off.
@@ -1733,6 +1944,13 @@ async function createSession(deps, opts) {
1733
1944
  running = false;
1734
1945
  // Stop a run-all sweep so the next pending task isn't auto-started.
1735
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;
1736
1954
  // Drop any queued steering and return it so the webview can restore it to
1737
1955
  // the composer.
1738
1956
  const drained = session.drainQueue();
@@ -1802,10 +2020,12 @@ async function createSession(deps, opts) {
1802
2020
  void readBody(req).then(async (raw) => {
1803
2021
  let provider = "";
1804
2022
  let key;
2023
+ let variant;
1805
2024
  try {
1806
2025
  const body = JSON.parse(raw);
1807
2026
  provider = body.provider ?? "";
1808
2027
  key = (body.key ?? "").trim();
2028
+ variant = body.variant;
1809
2029
  }
1810
2030
  catch {
1811
2031
  json(res, 400, { error: "invalid JSON body" });
@@ -1820,13 +2040,20 @@ async function createSession(deps, opts) {
1820
2040
  json(res, 400, { error: "API key is required" });
1821
2041
  return;
1822
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;
1823
2050
  const creds = {
1824
2051
  accessToken: key,
1825
2052
  refreshToken: "",
1826
2053
  expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000 * 100, // ~100y
1827
- ...(meta.apiKeyBaseUrl ? { baseUrl: meta.apiKeyBaseUrl } : {}),
2054
+ ...(baseUrl ? { baseUrl } : {}),
1828
2055
  };
1829
- await auth.setCredentials(provider, creds);
2056
+ await auth.setCredentials(storageKey, creds);
1830
2057
  broadcast("auth_done", { provider });
1831
2058
  json(res, 200, { ok: true });
1832
2059
  });
@@ -1923,6 +2150,10 @@ async function createSession(deps, opts) {
1923
2150
  // "disconnect" fully removes Kimi OAuth and the API key.
1924
2151
  if (provider === "moonshot")
1925
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);
1926
2157
  broadcast("auth_done", { provider });
1927
2158
  json(res, 200, { ok: true });
1928
2159
  });
@@ -2206,7 +2437,9 @@ async function createSession(deps, opts) {
2206
2437
  for (const c of clients)
2207
2438
  c.res.end();
2208
2439
  nolanAbort.abort();
2440
+ nolanAutoAbort.abort();
2209
2441
  await nolanSession?.dispose().catch(() => { });
2442
+ await nolanAutoSession?.dispose().catch(() => { });
2210
2443
  await session.dispose().catch(() => { });
2211
2444
  }
2212
2445
  return {