@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.
- package/README.md +2 -2
- package/dist/app-sidecar.js +269 -26
- package/dist/app-sidecar.js.map +1 -1
- package/dist/cli/auth.d.ts.map +1 -1
- package/dist/cli/auth.js +19 -2
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +80 -27
- package/dist/cli.js.map +1 -1
- package/dist/config.js +32 -30
- package/dist/config.js.map +1 -1
- package/dist/core/agent-session-compaction.test.js +1 -1
- package/dist/core/agent-session-compaction.test.js.map +1 -1
- package/dist/core/agent-session.d.ts +17 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +61 -12
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/agent-tools-allowlist.test.d.ts +2 -0
- package/dist/core/agent-tools-allowlist.test.d.ts.map +1 -0
- package/dist/core/agent-tools-allowlist.test.js +72 -0
- package/dist/core/agent-tools-allowlist.test.js.map +1 -0
- package/dist/core/auth-providers.d.ts +21 -0
- package/dist/core/auth-providers.d.ts.map +1 -1
- package/dist/core/auth-providers.js +15 -2
- package/dist/core/auth-providers.js.map +1 -1
- package/dist/core/autopilot-verdict.d.ts +32 -0
- package/dist/core/autopilot-verdict.d.ts.map +1 -0
- package/dist/core/autopilot-verdict.js +93 -0
- package/dist/core/autopilot-verdict.js.map +1 -0
- package/dist/core/autopilot-verdict.test.d.ts +2 -0
- package/dist/core/autopilot-verdict.test.d.ts.map +1 -0
- package/dist/core/autopilot-verdict.test.js +80 -0
- package/dist/core/autopilot-verdict.test.js.map +1 -0
- package/dist/core/compaction/compactor.test.js +2 -2
- package/dist/core/compaction/compactor.test.js.map +1 -1
- package/dist/core/compaction/token-estimator.test.js +3 -3
- package/dist/core/compaction/token-estimator.test.js.map +1 -1
- package/dist/core/event-bus.d.ts +4 -0
- package/dist/core/event-bus.d.ts.map +1 -1
- package/dist/core/event-bus.js +6 -0
- package/dist/core/event-bus.js.map +1 -1
- package/dist/core/nolan-context.d.ts +18 -0
- package/dist/core/nolan-context.d.ts.map +1 -1
- package/dist/core/nolan-context.js +19 -0
- package/dist/core/nolan-context.js.map +1 -1
- package/dist/core/nolan-context.test.js +23 -1
- package/dist/core/nolan-context.test.js.map +1 -1
- package/dist/core/nolan-prompt.d.ts +10 -0
- package/dist/core/nolan-prompt.d.ts.map +1 -1
- package/dist/core/nolan-prompt.js +48 -0
- package/dist/core/nolan-prompt.js.map +1 -1
- package/dist/core/prompt-commands.d.ts.map +1 -1
- package/dist/core/prompt-commands.js +17 -3
- package/dist/core/prompt-commands.js.map +1 -1
- package/dist/core/prompt-commands.test.js +27 -1
- package/dist/core/prompt-commands.test.js.map +1 -1
- package/dist/core/session-manager.test.js +3 -1
- package/dist/core/session-manager.test.js.map +1 -1
- package/dist/core/task-store.d.ts +7 -0
- package/dist/core/task-store.d.ts.map +1 -1
- package/dist/core/task-store.js +13 -0
- package/dist/core/task-store.js.map +1 -1
- package/dist/modes/json-mode.d.ts +7 -0
- package/dist/modes/json-mode.d.ts.map +1 -1
- package/dist/modes/json-mode.js +4 -0
- package/dist/modes/json-mode.js.map +1 -1
- package/dist/system-prompt.test.js +4 -4
- package/dist/system-prompt.test.js.map +1 -1
- package/dist/tools/plan-mode.test.js +1 -1
- package/dist/tools/plan-mode.test.js.map +1 -1
- package/dist/tools/prompt-hints.d.ts.map +1 -1
- package/dist/tools/prompt-hints.js +4 -0
- package/dist/tools/prompt-hints.js.map +1 -1
- package/dist/tools/subagent.d.ts.map +1 -1
- package/dist/tools/subagent.js +24 -2
- package/dist/tools/subagent.js.map +1 -1
- package/dist/ui/App.d.ts.map +1 -1
- package/dist/ui/App.js +21 -5
- package/dist/ui/App.js.map +1 -1
- package/dist/ui/components/ActivityIndicator.d.ts.map +1 -1
- package/dist/ui/components/ActivityIndicator.js +1 -0
- package/dist/ui/components/ActivityIndicator.js.map +1 -1
- package/dist/ui/components/Footer.js +1 -1
- package/dist/ui/components/Footer.js.map +1 -1
- package/dist/ui/hooks/useAgentLoop.d.ts +1 -1
- package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
- package/dist/ui/hooks/useAgentLoop.js.map +1 -1
- package/dist/ui/hooks/useContextCompaction.d.ts.map +1 -1
- package/dist/ui/hooks/useContextCompaction.js +4 -2
- package/dist/ui/hooks/useContextCompaction.js.map +1 -1
- package/dist/ui/login.d.ts.map +1 -1
- package/dist/ui/login.js +6 -2
- package/dist/ui/login.js.map +1 -1
- package/dist/ui/thinking-level-cycle.test.js +1 -1
- package/dist/ui/thinking-level-cycle.test.js.map +1 -1
- 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
|
|
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
|
|
package/dist/app-sidecar.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
887
|
-
broadcast("tasks_list", { tasks:
|
|
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).
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
...(
|
|
2054
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
1818
2055
|
};
|
|
1819
|
-
await auth.setCredentials(
|
|
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 {
|