@prestyj/cli 5.2.0 → 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/dist/app-sidecar.js +288 -49
- package/dist/app-sidecar.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 +8 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +7 -0
- package/dist/core/agent-session.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 +14 -2
- package/dist/core/autopilot-verdict.d.ts.map +1 -1
- package/dist/core/autopilot-verdict.js +19 -2
- package/dist/core/autopilot-verdict.js.map +1 -1
- package/dist/core/autopilot-verdict.test.js +10 -0
- package/dist/core/autopilot-verdict.test.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 +17 -0
- package/dist/core/nolan-context.d.ts.map +1 -1
- package/dist/core/nolan-context.js +47 -6
- package/dist/core/nolan-context.js.map +1 -1
- package/dist/core/nolan-context.test.js +122 -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 +3 -3
- package/dist/core/nolan-prompt.js +19 -7
- 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/speed-benchmark.test.js +2 -3
- package/dist/core/speed-benchmark.test.js.map +1 -1
- package/package.json +5 -5
package/dist/app-sidecar.js
CHANGED
|
@@ -23,6 +23,9 @@ import { AgentSession } from "./core/agent-session.js";
|
|
|
23
23
|
import { buildNolanSystemPrompt, buildNolanAutopilotSystemPrompt } from "./core/nolan-prompt.js";
|
|
24
24
|
import { buildNolanDigest, buildNolanAutopilotContext } from "./core/nolan-context.js";
|
|
25
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";
|
|
26
29
|
import { collectProjectContext } from "./system-prompt.js";
|
|
27
30
|
import { AuthStorage } from "./core/auth-storage.js";
|
|
28
31
|
import { MOONSHOT_OAUTH_KEY, XIAOMI_CREDITS_KEY } from "@prestyj/core";
|
|
@@ -82,6 +85,7 @@ async function loadAppSettings() {
|
|
|
82
85
|
// model/thinking handlers below).
|
|
83
86
|
projectModels: raw.projectModels && typeof raw.projectModels === "object" ? raw.projectModels : undefined,
|
|
84
87
|
autopilot: raw.autopilot && typeof raw.autopilot === "object" ? raw.autopilot : undefined,
|
|
88
|
+
nolanModels: raw.nolanModels && typeof raw.nolanModels === "object" ? raw.nolanModels : undefined,
|
|
85
89
|
};
|
|
86
90
|
}
|
|
87
91
|
catch {
|
|
@@ -105,6 +109,24 @@ async function saveProjectModelPrefs(cwd, prefs) {
|
|
|
105
109
|
s.projectModels = { ...(s.projectModels ?? {}), [key]: prefs };
|
|
106
110
|
await saveAppSettings(s);
|
|
107
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
|
+
}
|
|
108
130
|
/** Read this project's persisted autopilot flag (default off). */
|
|
109
131
|
async function loadAutopilot(cwd) {
|
|
110
132
|
const s = await loadAppSettings();
|
|
@@ -378,12 +400,23 @@ async function runJsonModeIfRequested() {
|
|
|
378
400
|
model: { type: "string" },
|
|
379
401
|
"max-turns": { type: "string" },
|
|
380
402
|
"system-prompt": { type: "string" },
|
|
403
|
+
tools: { type: "string" },
|
|
381
404
|
"prompt-cache-key": { type: "string" },
|
|
382
405
|
},
|
|
383
406
|
allowPositionals: true,
|
|
384
407
|
strict: true,
|
|
385
408
|
});
|
|
386
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;
|
|
387
420
|
await runJsonMode({
|
|
388
421
|
message: positionals[0] ?? "",
|
|
389
422
|
provider: (values.provider ?? "anthropic"),
|
|
@@ -391,6 +424,7 @@ async function runJsonModeIfRequested() {
|
|
|
391
424
|
cwd: process.cwd(),
|
|
392
425
|
systemPrompt: values["system-prompt"],
|
|
393
426
|
maxTurns: maxTurnsRaw ? parseInt(maxTurnsRaw, 10) : undefined,
|
|
427
|
+
allowedTools,
|
|
394
428
|
promptCacheKey: values["prompt-cache-key"],
|
|
395
429
|
}).catch((err) => {
|
|
396
430
|
process.stderr.write((err instanceof Error ? err.message : String(err)) + "\n");
|
|
@@ -591,9 +625,11 @@ function lastAssistantText(messages) {
|
|
|
591
625
|
/**
|
|
592
626
|
* Assemble Nolan's context digest for one `@Nolan` question: project docs (up the
|
|
593
627
|
* tree) + git/env + the build session's compaction summary + recent activity.
|
|
594
|
-
* 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.
|
|
595
631
|
*/
|
|
596
|
-
async function buildNolanContext(buildSession, cwd, gitBranch, question) {
|
|
632
|
+
async function buildNolanContext(buildSession, cwd, gitBranch, question, workflowCommands, injectedPrompts) {
|
|
597
633
|
const projectContext = await collectProjectContext(cwd).catch(() => []);
|
|
598
634
|
return buildNolanDigest({
|
|
599
635
|
question,
|
|
@@ -601,6 +637,8 @@ async function buildNolanContext(buildSession, cwd, gitBranch, question) {
|
|
|
601
637
|
cwd,
|
|
602
638
|
gitBranch,
|
|
603
639
|
messages: buildSession.getMessages(),
|
|
640
|
+
workflowCommands,
|
|
641
|
+
injectedPrompts,
|
|
604
642
|
});
|
|
605
643
|
}
|
|
606
644
|
/**
|
|
@@ -798,6 +836,23 @@ async function createSession(deps, opts) {
|
|
|
798
836
|
let autopilotCancelled = false;
|
|
799
837
|
// Hard cap on review→prompt→review rounds per user turn (loop safety).
|
|
800
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
|
+
}
|
|
801
856
|
// ── Telegram serve (remote control via Telegram) ───────────
|
|
802
857
|
// A single embedded serve session lives in this sidecar process. Only the main
|
|
803
858
|
// window's home screen exposes the controls, so there's one bot per app.
|
|
@@ -813,6 +868,35 @@ async function createSession(deps, opts) {
|
|
|
813
868
|
let nolanRunning = false;
|
|
814
869
|
let pendingNolanModel = null;
|
|
815
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
|
+
}
|
|
816
900
|
async function syncNolanModel(provider, model) {
|
|
817
901
|
if (nolanRunning) {
|
|
818
902
|
pendingNolanModel = { provider, model };
|
|
@@ -829,10 +913,10 @@ async function createSession(deps, opts) {
|
|
|
829
913
|
async function ensureNolanSession() {
|
|
830
914
|
if (nolanSession)
|
|
831
915
|
return nolanSession;
|
|
832
|
-
const
|
|
916
|
+
const target = nolanCurrentModel();
|
|
833
917
|
const ken = new AgentSession({
|
|
834
|
-
provider:
|
|
835
|
-
model:
|
|
918
|
+
provider: target.provider,
|
|
919
|
+
model: target.model,
|
|
836
920
|
cwd,
|
|
837
921
|
systemPrompt: buildNolanSystemPrompt(),
|
|
838
922
|
allowedTools: NOLAN_ALLOWED_TOOLS,
|
|
@@ -863,7 +947,10 @@ async function createSession(deps, opts) {
|
|
|
863
947
|
broadcastError("nolan_error", "ken error", d.error);
|
|
864
948
|
});
|
|
865
949
|
nolanSession = ken;
|
|
866
|
-
log("INFO", "app-sidecar", "ken session ready", {
|
|
950
|
+
log("INFO", "app-sidecar", "ken session ready", {
|
|
951
|
+
provider: target.provider,
|
|
952
|
+
model: target.model,
|
|
953
|
+
});
|
|
867
954
|
return ken;
|
|
868
955
|
}
|
|
869
956
|
// ── Autopilot Nolan (auto-reviewer) ──────────────────────────
|
|
@@ -892,10 +979,10 @@ async function createSession(deps, opts) {
|
|
|
892
979
|
async function ensureNolanAutoSession() {
|
|
893
980
|
if (nolanAutoSession)
|
|
894
981
|
return nolanAutoSession;
|
|
895
|
-
const
|
|
982
|
+
const target = nolanCurrentModel();
|
|
896
983
|
const ken = new AgentSession({
|
|
897
|
-
provider:
|
|
898
|
-
model:
|
|
984
|
+
provider: target.provider,
|
|
985
|
+
model: target.model,
|
|
899
986
|
cwd,
|
|
900
987
|
systemPrompt: buildNolanAutopilotSystemPrompt(),
|
|
901
988
|
allowedTools: NOLAN_ALLOWED_TOOLS,
|
|
@@ -908,8 +995,8 @@ async function createSession(deps, opts) {
|
|
|
908
995
|
// runAutopilotReview try/catch as autopilot_error frames.
|
|
909
996
|
nolanAutoSession = ken;
|
|
910
997
|
log("INFO", "app-sidecar", "ken autopilot session ready", {
|
|
911
|
-
provider:
|
|
912
|
-
model:
|
|
998
|
+
provider: target.provider,
|
|
999
|
+
model: target.model,
|
|
913
1000
|
});
|
|
914
1001
|
return ken;
|
|
915
1002
|
}
|
|
@@ -974,7 +1061,9 @@ async function createSession(deps, opts) {
|
|
|
974
1061
|
// One review = prompt the nolanAuto session with the review digest, read its
|
|
975
1062
|
// final assistant text, parse a verdict. Returns null on failure (surfaced as
|
|
976
1063
|
// an autopilot_error frame) so the cycle stops rather than looping blind.
|
|
977
|
-
|
|
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) {
|
|
978
1067
|
autopilotReviewing = true;
|
|
979
1068
|
broadcast("autopilot_review_start", {});
|
|
980
1069
|
try {
|
|
@@ -985,6 +1074,9 @@ async function createSession(deps, opts) {
|
|
|
985
1074
|
cwd,
|
|
986
1075
|
gitBranch,
|
|
987
1076
|
messages: session.getMessages(),
|
|
1077
|
+
originalRequest,
|
|
1078
|
+
injectedPrompts: [...injectedAutopilotPrompts],
|
|
1079
|
+
workflowCommands: await loadWorkflowCommandSpecs(),
|
|
988
1080
|
});
|
|
989
1081
|
await ken.prompt(digest);
|
|
990
1082
|
return parseAutopilotVerdict(lastAssistantText(ken.getMessages()));
|
|
@@ -1003,44 +1095,101 @@ async function createSession(deps, opts) {
|
|
|
1003
1095
|
}
|
|
1004
1096
|
}
|
|
1005
1097
|
// Drive the review→prompt→review loop for one finished user turn. Only ever
|
|
1006
|
-
// called
|
|
1007
|
-
// task runner, resume, /ken, or
|
|
1008
|
-
//
|
|
1009
|
-
|
|
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) {
|
|
1010
1104
|
if (!autopilot || autopilotCancelled)
|
|
1011
1105
|
return;
|
|
1012
1106
|
autopilotActive = true;
|
|
1013
1107
|
try {
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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);
|
|
1027
1183
|
}
|
|
1028
|
-
if (
|
|
1029
|
-
|
|
1030
|
-
|
|
1184
|
+
else if (autopilot) {
|
|
1185
|
+
log("INFO", "app-sidecar", "autopilot skipped (queued turn)", {
|
|
1186
|
+
reason: decision.reason,
|
|
1187
|
+
});
|
|
1031
1188
|
}
|
|
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
1189
|
}
|
|
1040
|
-
broadcast("autopilot_capped", { rounds: MAX_AUTOPILOT_ROUNDS });
|
|
1041
1190
|
}
|
|
1042
1191
|
finally {
|
|
1043
|
-
|
|
1192
|
+
drainingStrandedQueue = false;
|
|
1044
1193
|
}
|
|
1045
1194
|
}
|
|
1046
1195
|
// ── Task runner (project task list → sessions) ──────────────
|
|
@@ -1054,6 +1203,7 @@ async function createSession(deps, opts) {
|
|
|
1054
1203
|
return false;
|
|
1055
1204
|
// Fresh session per task so one task's context never bleeds into the next.
|
|
1056
1205
|
await session.newSession();
|
|
1206
|
+
injectedAutopilotPrompts = [];
|
|
1057
1207
|
titleGenerated = false;
|
|
1058
1208
|
broadcast("session_reset", {});
|
|
1059
1209
|
markTaskInProgress(cwd, task.id);
|
|
@@ -1164,6 +1314,7 @@ async function createSession(deps, opts) {
|
|
|
1164
1314
|
supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
|
|
1165
1315
|
supportsVideo: getModel(st.model)?.supportsVideo ?? false,
|
|
1166
1316
|
autopilot,
|
|
1317
|
+
...nolanStatePayload(),
|
|
1167
1318
|
...footerExtras(),
|
|
1168
1319
|
});
|
|
1169
1320
|
return;
|
|
@@ -1188,6 +1339,7 @@ async function createSession(deps, opts) {
|
|
|
1188
1339
|
supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
|
|
1189
1340
|
supportsVideo: getModel(st.model)?.supportsVideo ?? false,
|
|
1190
1341
|
autopilot,
|
|
1342
|
+
...nolanStatePayload(),
|
|
1191
1343
|
...footerExtras(),
|
|
1192
1344
|
},
|
|
1193
1345
|
})}\n\n`);
|
|
@@ -1528,6 +1680,13 @@ async function createSession(deps, opts) {
|
|
|
1528
1680
|
// Fresh user turn: clear any cancel flag left from a prior cycle so this
|
|
1529
1681
|
// turn's autopilot review can run.
|
|
1530
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());
|
|
1531
1690
|
await runAgent(text, async () => {
|
|
1532
1691
|
if (attachments.length > 0) {
|
|
1533
1692
|
// Persist each attachment under .ezcoder/uploads so files are inspectable
|
|
@@ -1543,11 +1702,31 @@ async function createSession(deps, opts) {
|
|
|
1543
1702
|
await session.prompt(text);
|
|
1544
1703
|
}
|
|
1545
1704
|
});
|
|
1546
|
-
// After the user's run settles, kick off Nolan's auto-review loop
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
|
|
1550
|
-
|
|
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();
|
|
1551
1730
|
});
|
|
1552
1731
|
return;
|
|
1553
1732
|
}
|
|
@@ -1578,7 +1757,7 @@ async function createSession(deps, opts) {
|
|
|
1578
1757
|
broadcast("nolan_run_start", { text });
|
|
1579
1758
|
try {
|
|
1580
1759
|
const ken = await ensureNolanSession();
|
|
1581
|
-
const digest = await buildNolanContext(session, cwd, gitBranch, text);
|
|
1760
|
+
const digest = await buildNolanContext(session, cwd, gitBranch, text, await loadWorkflowCommandSpecs(), injectedAutopilotPrompts);
|
|
1582
1761
|
await ken.prompt(digest);
|
|
1583
1762
|
// Record the turn against the BUILD session so it persists + survives
|
|
1584
1763
|
// resume (advisory custom entry, never an LLM message). Reply is Nolan's
|
|
@@ -1861,8 +2040,12 @@ async function createSession(deps, opts) {
|
|
|
1861
2040
|
// new model's credentials, so switching to a just-added provider works.
|
|
1862
2041
|
await auth.reload();
|
|
1863
2042
|
await session.switchModel(target.provider, target.id);
|
|
1864
|
-
|
|
1865
|
-
|
|
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
|
+
}
|
|
1866
2049
|
// Clamp the reasoning level to what the new model supports (mirrors the
|
|
1867
2050
|
// CLI): keep thinking on at the first supported tier if it was on but
|
|
1868
2051
|
// the prior level is unsupported here; leave it off if it was off.
|
|
@@ -1889,6 +2072,12 @@ async function createSession(deps, opts) {
|
|
|
1889
2072
|
// model_change is emitted by switchModel; follow with thinking_change so
|
|
1890
2073
|
// the footer toggle reflects the new model's supported levels.
|
|
1891
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());
|
|
1892
2081
|
// The new model usually has a different context window — push extras so
|
|
1893
2082
|
// the footer's context meter rescales immediately.
|
|
1894
2083
|
broadcast("extras", footerExtras());
|
|
@@ -1896,6 +2085,54 @@ async function createSession(deps, opts) {
|
|
|
1896
2085
|
});
|
|
1897
2086
|
return;
|
|
1898
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
|
+
}
|
|
1899
2136
|
if (method === "POST" && url === "/kill") {
|
|
1900
2137
|
void readBody(req).then(async (raw) => {
|
|
1901
2138
|
let id;
|
|
@@ -1967,6 +2204,7 @@ async function createSession(deps, opts) {
|
|
|
1967
2204
|
void session
|
|
1968
2205
|
.newSession()
|
|
1969
2206
|
.then(() => {
|
|
2207
|
+
injectedAutopilotPrompts = [];
|
|
1970
2208
|
broadcast("session_reset", {});
|
|
1971
2209
|
json(res, 200, { ok: true });
|
|
1972
2210
|
})
|
|
@@ -2000,6 +2238,7 @@ async function createSession(deps, opts) {
|
|
|
2000
2238
|
}
|
|
2001
2239
|
try {
|
|
2002
2240
|
await session.newSession();
|
|
2241
|
+
injectedAutopilotPrompts = [];
|
|
2003
2242
|
titleGenerated = false;
|
|
2004
2243
|
await session.setApprovedPlan(planPath);
|
|
2005
2244
|
broadcast("session_reset", {});
|