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