@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.
Files changed (60) hide show
  1. package/dist/app-sidecar.js +288 -49
  2. package/dist/app-sidecar.js.map +1 -1
  3. package/dist/core/agent-session-queue.test.d.ts +2 -0
  4. package/dist/core/agent-session-queue.test.d.ts.map +1 -0
  5. package/dist/core/agent-session-queue.test.js +122 -0
  6. package/dist/core/agent-session-queue.test.js.map +1 -0
  7. package/dist/core/agent-session.d.ts +8 -0
  8. package/dist/core/agent-session.d.ts.map +1 -1
  9. package/dist/core/agent-session.js +7 -0
  10. package/dist/core/agent-session.js.map +1 -1
  11. package/dist/core/autopilot-cycle.d.ts +67 -0
  12. package/dist/core/autopilot-cycle.d.ts.map +1 -0
  13. package/dist/core/autopilot-cycle.js +50 -0
  14. package/dist/core/autopilot-cycle.js.map +1 -0
  15. package/dist/core/autopilot-cycle.test.d.ts +2 -0
  16. package/dist/core/autopilot-cycle.test.d.ts.map +1 -0
  17. package/dist/core/autopilot-cycle.test.js +179 -0
  18. package/dist/core/autopilot-cycle.test.js.map +1 -0
  19. package/dist/core/autopilot-gate.d.ts +83 -0
  20. package/dist/core/autopilot-gate.d.ts.map +1 -0
  21. package/dist/core/autopilot-gate.js +96 -0
  22. package/dist/core/autopilot-gate.js.map +1 -0
  23. package/dist/core/autopilot-gate.test.d.ts +2 -0
  24. package/dist/core/autopilot-gate.test.d.ts.map +1 -0
  25. package/dist/core/autopilot-gate.test.js +159 -0
  26. package/dist/core/autopilot-gate.test.js.map +1 -0
  27. package/dist/core/autopilot-verdict.d.ts +14 -2
  28. package/dist/core/autopilot-verdict.d.ts.map +1 -1
  29. package/dist/core/autopilot-verdict.js +19 -2
  30. package/dist/core/autopilot-verdict.js.map +1 -1
  31. package/dist/core/autopilot-verdict.test.js +10 -0
  32. package/dist/core/autopilot-verdict.test.js.map +1 -1
  33. package/dist/core/json-mode-flag-parity.test.d.ts +2 -0
  34. package/dist/core/json-mode-flag-parity.test.d.ts.map +1 -0
  35. package/dist/core/json-mode-flag-parity.test.js +66 -0
  36. package/dist/core/json-mode-flag-parity.test.js.map +1 -0
  37. package/dist/core/nolan-context.d.ts +17 -0
  38. package/dist/core/nolan-context.d.ts.map +1 -1
  39. package/dist/core/nolan-context.js +47 -6
  40. package/dist/core/nolan-context.js.map +1 -1
  41. package/dist/core/nolan-context.test.js +122 -1
  42. package/dist/core/nolan-context.test.js.map +1 -1
  43. package/dist/core/nolan-model.d.ts +46 -0
  44. package/dist/core/nolan-model.d.ts.map +1 -0
  45. package/dist/core/nolan-model.js +30 -0
  46. package/dist/core/nolan-model.js.map +1 -0
  47. package/dist/core/nolan-model.test.d.ts +2 -0
  48. package/dist/core/nolan-model.test.d.ts.map +1 -0
  49. package/dist/core/nolan-model.test.js +51 -0
  50. package/dist/core/nolan-model.test.js.map +1 -0
  51. package/dist/core/nolan-prompt.d.ts +3 -3
  52. package/dist/core/nolan-prompt.js +19 -7
  53. package/dist/core/nolan-prompt.js.map +1 -1
  54. package/dist/core/nolan-prompt.test.d.ts +2 -0
  55. package/dist/core/nolan-prompt.test.d.ts.map +1 -0
  56. package/dist/core/nolan-prompt.test.js +43 -0
  57. package/dist/core/nolan-prompt.test.js.map +1 -0
  58. package/dist/core/speed-benchmark.test.js +2 -3
  59. package/dist/core/speed-benchmark.test.js.map +1 -1
  60. package/package.json +5 -5
@@ -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 st = session.getState();
916
+ const target = nolanCurrentModel();
833
917
  const ken = new AgentSession({
834
- provider: st.provider,
835
- model: st.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", { provider: st.provider, model: st.model });
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 st = session.getState();
982
+ const target = nolanCurrentModel();
896
983
  const ken = new AgentSession({
897
- provider: st.provider,
898
- model: st.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: st.provider,
912
- model: st.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
- async function runAutopilotReview() {
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 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() {
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
- // 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;
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 (verdict.kind === "human") {
1029
- broadcast("autopilot_human", { reason: verdict.reason });
1030
- return;
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
- autopilotActive = false;
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. 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();
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
- await syncNolanModel(target.provider, target.id);
1865
- await syncNolanAutoModel(target.provider, target.id);
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", {});