@kenkaiiii/ggcoder 5.5.1 → 5.6.1

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 (56) hide show
  1. package/dist/app-sidecar.js +274 -47
  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/ken-context.d.ts +17 -0
  34. package/dist/core/ken-context.d.ts.map +1 -1
  35. package/dist/core/ken-context.js +47 -6
  36. package/dist/core/ken-context.js.map +1 -1
  37. package/dist/core/ken-context.test.js +122 -1
  38. package/dist/core/ken-context.test.js.map +1 -1
  39. package/dist/core/ken-model.d.ts +46 -0
  40. package/dist/core/ken-model.d.ts.map +1 -0
  41. package/dist/core/ken-model.js +26 -0
  42. package/dist/core/ken-model.js.map +1 -0
  43. package/dist/core/ken-model.test.d.ts +2 -0
  44. package/dist/core/ken-model.test.d.ts.map +1 -0
  45. package/dist/core/ken-model.test.js +51 -0
  46. package/dist/core/ken-model.test.js.map +1 -0
  47. package/dist/core/ken-prompt.d.ts +3 -3
  48. package/dist/core/ken-prompt.js +19 -7
  49. package/dist/core/ken-prompt.js.map +1 -1
  50. package/dist/core/ken-prompt.test.d.ts +2 -0
  51. package/dist/core/ken-prompt.test.d.ts.map +1 -0
  52. package/dist/core/ken-prompt.test.js +43 -0
  53. package/dist/core/ken-prompt.test.js.map +1 -0
  54. package/dist/core/speed-benchmark.test.js +2 -3
  55. package/dist/core/speed-benchmark.test.js.map +1 -1
  56. package/package.json +4 -4
@@ -23,6 +23,9 @@ import { AgentSession } from "./core/agent-session.js";
23
23
  import { buildKenSystemPrompt, buildKenAutopilotSystemPrompt } from "./core/ken-prompt.js";
24
24
  import { buildKenDigest, buildKenAutopilotContext } from "./core/ken-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 { validateKenModelPref, effectiveKenModel } from "./core/ken-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 "@kenkaiiii/gg-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
+ kenModels: raw.kenModels && typeof raw.kenModels === "object" ? raw.kenModels : 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 Ken model override, if any. */
113
+ async function loadKenModelPref(cwd) {
114
+ const s = await loadAppSettings();
115
+ return s.kenModels?.[projectModelKey(cwd)];
116
+ }
117
+ /** Persist (or with null, clear) this project's Ken model override via
118
+ * read-modify-write so the rest of the settings file is preserved. */
119
+ async function saveKenModelPref(cwd, pref) {
120
+ const s = await loadAppSettings();
121
+ const key = projectModelKey(cwd);
122
+ const next = { ...(s.kenModels ?? {}) };
123
+ if (pref)
124
+ next[key] = pref;
125
+ else
126
+ delete next[key];
127
+ s.kenModels = 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();
@@ -603,9 +625,11 @@ function lastAssistantText(messages) {
603
625
  /**
604
626
  * Assemble Ken's context digest for one `@Ken` question: project docs (up the
605
627
  * tree) + git/env + the build session's compaction summary + recent activity.
606
- * Prepended to the user's question as Ken's prompt body each turn.
628
+ * Prepended to the user's question as Ken'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.
607
631
  */
608
- async function buildKenContext(buildSession, cwd, gitBranch, question) {
632
+ async function buildKenContext(buildSession, cwd, gitBranch, question, workflowCommands, injectedPrompts) {
609
633
  const projectContext = await collectProjectContext(cwd).catch(() => []);
610
634
  return buildKenDigest({
611
635
  question,
@@ -613,6 +637,8 @@ async function buildKenContext(buildSession, cwd, gitBranch, question) {
613
637
  cwd,
614
638
  gitBranch,
615
639
  messages: buildSession.getMessages(),
640
+ workflowCommands,
641
+ injectedPrompts,
616
642
  });
617
643
  }
618
644
  /**
@@ -804,6 +830,23 @@ async function createSession(deps, opts) {
804
830
  let autopilotCancelled = false;
805
831
  // Hard cap on review→prompt→review rounds per user turn (loop safety).
806
832
  const MAX_AUTOPILOT_ROUNDS = 3;
833
+ // Prompt bodies Autopilot Ken injected into the BUILD session this
834
+ // conversation. Passed into every Ken digest so injected prompts render as
835
+ // "Ken autopilot (injected)" instead of `**User:**` — otherwise multi-round
836
+ // cycles drift into Ken reviewing against his own last prompt. Cleared
837
+ // whenever the conversation resets (new session / plan accept / task run).
838
+ let injectedAutopilotPrompts = [];
839
+ // Workflow (prompt-template) commands: built-in + the project's custom
840
+ // `.gg/commands/*.md`. Used to gate autopilot off command turns and to label
841
+ // expanded templates in Ken's digests. Loaded fresh so a newly added custom
842
+ // command is picked up without a restart (mirrors GET /commands).
843
+ async function loadWorkflowCommandSpecs() {
844
+ const custom = await loadCustomCommands(cwd).catch(() => []);
845
+ return [
846
+ ...PROMPT_COMMANDS.map((c) => ({ name: c.name, aliases: c.aliases, prompt: c.prompt })),
847
+ ...custom.map((c) => ({ name: c.name, aliases: [], prompt: c.prompt })),
848
+ ];
849
+ }
807
850
  // ── Telegram serve (remote control via Telegram) ───────────
808
851
  // A single embedded serve session lives in this sidecar process. Only the main
809
852
  // window's home screen exposes the controls, so there's one bot per app.
@@ -819,6 +862,35 @@ async function createSession(deps, opts) {
819
862
  let kenRunning = false;
820
863
  let pendingKenModel = null;
821
864
  const kenToolCallNames = new Map();
865
+ // Ken's per-project model override. null → Ken (chat + autopilot) follows GG
866
+ // Coder's model, including live switches (the historical behavior). Set → Ken
867
+ // is pinned to his own model and GG Coder switches no longer touch him. A
868
+ // stale persisted pin (model dropped from the registry / provider logged
869
+ // out) validates to null so Ken degrades to following instead of erroring.
870
+ let kenModelOverride = validateKenModelPref(await loadKenModelPref(cwd), {
871
+ modelExists: (id) => getModel(id) !== undefined,
872
+ providerConnected: () => true, // async auth checked below
873
+ });
874
+ if (kenModelOverride && !(await auth.hasProviderAuth(kenModelOverride.provider))) {
875
+ log("WARN", "app-sidecar", "ken model override provider not connected — following GG", {
876
+ provider: kenModelOverride.provider,
877
+ model: kenModelOverride.model,
878
+ });
879
+ kenModelOverride = null;
880
+ }
881
+ /** The model Ken uses next turn: the pin when set, else GG Coder's. */
882
+ function kenCurrentModel() {
883
+ if (kenModelOverride)
884
+ return kenModelOverride;
885
+ const st = session.getState();
886
+ return { provider: st.provider, model: st.model };
887
+ }
888
+ /** Footer payload: Ken's effective model + whether it's a pin. Merged into
889
+ * /state, the SSE ready frame, and every ken_model_change broadcast. */
890
+ function kenStatePayload() {
891
+ const st = session.getState();
892
+ return effectiveKenModel(kenModelOverride, { provider: st.provider, model: st.model });
893
+ }
822
894
  async function syncKenModel(provider, model) {
823
895
  if (kenRunning) {
824
896
  pendingKenModel = { provider, model };
@@ -835,10 +907,10 @@ async function createSession(deps, opts) {
835
907
  async function ensureKenSession() {
836
908
  if (kenSession)
837
909
  return kenSession;
838
- const st = session.getState();
910
+ const target = kenCurrentModel();
839
911
  const ken = new AgentSession({
840
- provider: st.provider,
841
- model: st.model,
912
+ provider: target.provider,
913
+ model: target.model,
842
914
  cwd,
843
915
  systemPrompt: buildKenSystemPrompt(),
844
916
  allowedTools: KEN_ALLOWED_TOOLS,
@@ -869,7 +941,10 @@ async function createSession(deps, opts) {
869
941
  broadcastError("ken_error", "ken error", d.error);
870
942
  });
871
943
  kenSession = ken;
872
- log("INFO", "app-sidecar", "ken session ready", { provider: st.provider, model: st.model });
944
+ log("INFO", "app-sidecar", "ken session ready", {
945
+ provider: target.provider,
946
+ model: target.model,
947
+ });
873
948
  return ken;
874
949
  }
875
950
  // ── Autopilot Ken (auto-reviewer) ──────────────────────────
@@ -898,10 +973,10 @@ async function createSession(deps, opts) {
898
973
  async function ensureKenAutoSession() {
899
974
  if (kenAutoSession)
900
975
  return kenAutoSession;
901
- const st = session.getState();
976
+ const target = kenCurrentModel();
902
977
  const ken = new AgentSession({
903
- provider: st.provider,
904
- model: st.model,
978
+ provider: target.provider,
979
+ model: target.model,
905
980
  cwd,
906
981
  systemPrompt: buildKenAutopilotSystemPrompt(),
907
982
  allowedTools: KEN_ALLOWED_TOOLS,
@@ -914,8 +989,8 @@ async function createSession(deps, opts) {
914
989
  // runAutopilotReview try/catch as autopilot_error frames.
915
990
  kenAutoSession = ken;
916
991
  log("INFO", "app-sidecar", "ken autopilot session ready", {
917
- provider: st.provider,
918
- model: st.model,
992
+ provider: target.provider,
993
+ model: target.model,
919
994
  });
920
995
  return ken;
921
996
  }
@@ -980,7 +1055,9 @@ async function createSession(deps, opts) {
980
1055
  // One review = prompt the kenAuto session with the review digest, read its
981
1056
  // final assistant text, parse a verdict. Returns null on failure (surfaced as
982
1057
  // an autopilot_error frame) so the cycle stops rather than looping blind.
983
- async function runAutopilotReview() {
1058
+ // `originalRequest` is the user prompt that started the turn under review —
1059
+ // pinned in the digest so it can't scroll out during multi-round cycles.
1060
+ async function runAutopilotReview(originalRequest) {
984
1061
  autopilotReviewing = true;
985
1062
  broadcast("autopilot_review_start", {});
986
1063
  try {
@@ -991,6 +1068,9 @@ async function createSession(deps, opts) {
991
1068
  cwd,
992
1069
  gitBranch,
993
1070
  messages: session.getMessages(),
1071
+ originalRequest,
1072
+ injectedPrompts: [...injectedAutopilotPrompts],
1073
+ workflowCommands: await loadWorkflowCommandSpecs(),
994
1074
  });
995
1075
  await ken.prompt(digest);
996
1076
  return parseAutopilotVerdict(lastAssistantText(ken.getMessages()));
@@ -1009,44 +1089,101 @@ async function createSession(deps, opts) {
1009
1089
  }
1010
1090
  }
1011
1091
  // Drive the review→prompt→review loop for one finished user turn. Only ever
1012
- // called from POST /prompt after the user's own run resolves — never from the
1013
- // task runner, resume, /ken, or error paths, so there's no recursion and no
1014
- // guard tangle. Bounded by MAX_AUTOPILOT_ROUNDS and cancellable between steps.
1015
- async function runAutopilotCycle() {
1092
+ // called after shouldStartAutopilotCycle approves the turn (POST /prompt or
1093
+ // the stranded-queue drain) — never from the task runner, resume, /ken, or
1094
+ // error paths, so there's no recursion and no guard tangle. The loop's
1095
+ // control flow lives in driveAutopilotCycle (core/autopilot-cycle.ts) so
1096
+ // every exit path is unit-tested; this only wires the real dependencies.
1097
+ async function runAutopilotCycle(originalRequest) {
1016
1098
  if (!autopilot || autopilotCancelled)
1017
1099
  return;
1018
1100
  autopilotActive = true;
1019
1101
  try {
1020
- // Lean context per user turn: wipe prior review history so each new turn
1021
- // starts cheap, while within this cycle the few review messages persist so
1022
- // Ken remembers what he already asked GG Coder to fix.
1023
- await kenAutoSession?.newSession().catch(() => { });
1024
- for (let round = 1; round <= MAX_AUTOPILOT_ROUNDS; round++) {
1025
- if (autopilotCancelled)
1026
- return;
1027
- const verdict = await runAutopilotReview();
1028
- if (!verdict || autopilotCancelled)
1102
+ await driveAutopilotCycle({
1103
+ maxRounds: MAX_AUTOPILOT_ROUNDS,
1104
+ isCancelled: () => autopilotCancelled,
1105
+ // An injected run entering plan mode halts the cycle (autopilot_human
1106
+ // with the plan-hold reason) Ken never prompts into a read-only
1107
+ // plan-mode session or answers the plan modal for the user.
1108
+ isPlanMode: () => session.getPlanMode(),
1109
+ // Lean context per user turn: wipe prior review history so each new
1110
+ // turn starts cheap, while within this cycle the few review messages
1111
+ // persist so Ken remembers what he already asked GG Coder to fix.
1112
+ resetReviewer: async () => {
1113
+ await kenAutoSession?.newSession().catch(() => { });
1114
+ },
1115
+ review: () => runAutopilotReview(originalRequest),
1116
+ // prompt → record the injected body (so later digests label it as
1117
+ // Ken's, not the user's), show a compact Ken-tinted marker (not the
1118
+ // prompt body), then feed GG Coder bracketed by runAgent so the run
1119
+ // streams normally; the shared finally never re-triggers autopilot,
1120
+ // so this can't recurse.
1121
+ onInjected: (body, round) => {
1122
+ injectedAutopilotPrompts.push(body);
1123
+ broadcast("autopilot_prompted", { round, body });
1124
+ },
1125
+ runPrompt: (body) => runAgent(body, () => session.prompt(body)),
1126
+ emit: (event) => broadcast(event.type, event.data),
1127
+ });
1128
+ }
1129
+ finally {
1130
+ autopilotActive = false;
1131
+ }
1132
+ }
1133
+ // ── Stranded-queue drain ───────────────────────────────
1134
+ // A prompt POSTed while an autopilot cycle is between injected runs (build
1135
+ // idle, Ken reviewing) queues — but the queue only drains INTO a running
1136
+ // turn as steering. If the cycle ends without another run (ALL_CLEAR /
1137
+ // IGNORE / HUMAN / error), that message would sit stranded until the next
1138
+ // unrelated prompt, then land mislabeled as "concurrent steering" of an
1139
+ // unrelated run. Drain it here as a fresh turn of its own (with its own
1140
+ // gated review). Also covers the non-autopilot tail window: a message queued
1141
+ // after the run's last steering drain but before run_end.
1142
+ let drainingStrandedQueue = false;
1143
+ async function runStrandedQueue() {
1144
+ if (drainingStrandedQueue)
1145
+ return;
1146
+ drainingStrandedQueue = true;
1147
+ try {
1148
+ for (;;) {
1149
+ if (running || autopilotActive)
1029
1150
  return;
1030
- if (verdict.kind === "all_clear") {
1031
- broadcast("autopilot_done", {});
1151
+ const next = session.takeNextQueuedMessage();
1152
+ if (!next)
1032
1153
  return;
1154
+ broadcast("queued", { count: session.getQueuedCount() });
1155
+ if (!next.text.trim() && next.attachments.length === 0)
1156
+ continue;
1157
+ const workflowCommand = next.attachments.length === 0 &&
1158
+ isWorkflowCommandText(next.text, await loadWorkflowCommandSpecs());
1159
+ const assistantsBefore = countAssistantMessages(session.getMessages());
1160
+ await runAgent(next.text, async () => {
1161
+ if (next.attachments.length > 0) {
1162
+ await session.promptWithAttachments(next.text, next.attachments);
1163
+ }
1164
+ else {
1165
+ await session.prompt(next.text);
1166
+ }
1167
+ });
1168
+ const decision = shouldStartAutopilotCycle({
1169
+ enabled: autopilot,
1170
+ cancelled: autopilotCancelled,
1171
+ planMode: session.getPlanMode(),
1172
+ workflowCommand,
1173
+ assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
1174
+ });
1175
+ if (decision.start) {
1176
+ await runAutopilotCycle(next.text);
1033
1177
  }
1034
- if (verdict.kind === "human") {
1035
- broadcast("autopilot_human", { reason: verdict.reason });
1036
- return;
1178
+ else if (autopilot) {
1179
+ log("INFO", "app-sidecar", "autopilot skipped (queued turn)", {
1180
+ reason: decision.reason,
1181
+ });
1037
1182
  }
1038
- // prompt → show a compact Ken-tinted marker (not the prompt body), then
1039
- // feed GG Coder. Bracketed by runAgent so the run streams normally; the
1040
- // shared finally no longer re-triggers autopilot, so this can't recurse.
1041
- broadcast("autopilot_prompted", { round, body: verdict.body });
1042
- await runAgent(verdict.body, () => session.prompt(verdict.body));
1043
- if (autopilotCancelled)
1044
- return;
1045
1183
  }
1046
- broadcast("autopilot_capped", { rounds: MAX_AUTOPILOT_ROUNDS });
1047
1184
  }
1048
1185
  finally {
1049
- autopilotActive = false;
1186
+ drainingStrandedQueue = false;
1050
1187
  }
1051
1188
  }
1052
1189
  // ── Task runner (project task list → sessions) ──────────────
@@ -1060,6 +1197,7 @@ async function createSession(deps, opts) {
1060
1197
  return false;
1061
1198
  // Fresh session per task so one task's context never bleeds into the next.
1062
1199
  await session.newSession();
1200
+ injectedAutopilotPrompts = [];
1063
1201
  titleGenerated = false;
1064
1202
  broadcast("session_reset", {});
1065
1203
  markTaskInProgress(cwd, task.id);
@@ -1167,6 +1305,7 @@ async function createSession(deps, opts) {
1167
1305
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
1168
1306
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
1169
1307
  autopilot,
1308
+ ...kenStatePayload(),
1170
1309
  ...footerExtras(),
1171
1310
  });
1172
1311
  return;
@@ -1191,6 +1330,7 @@ async function createSession(deps, opts) {
1191
1330
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
1192
1331
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
1193
1332
  autopilot,
1333
+ ...kenStatePayload(),
1194
1334
  ...footerExtras(),
1195
1335
  },
1196
1336
  })}\n\n`);
@@ -1531,6 +1671,13 @@ async function createSession(deps, opts) {
1531
1671
  // Fresh user turn: clear any cancel flag left from a prior cycle so this
1532
1672
  // turn's autopilot review can run.
1533
1673
  autopilotCancelled = false;
1674
+ // Gate inputs captured around the run: whether this turn is a workflow
1675
+ // slash command (attachment prompts skip slash expansion entirely), and
1676
+ // how many assistant messages the run actually adds. Computed even when
1677
+ // autopilot is currently off — the toggle can flip ON mid-run, and the
1678
+ // gate reads the post-run value.
1679
+ const workflowCommand = attachments.length === 0 && isWorkflowCommandText(text, await loadWorkflowCommandSpecs());
1680
+ const assistantsBefore = countAssistantMessages(session.getMessages());
1534
1681
  await runAgent(text, async () => {
1535
1682
  if (attachments.length > 0) {
1536
1683
  // Persist each attachment under .gg/uploads so files are inspectable
@@ -1546,11 +1693,31 @@ async function createSession(deps, opts) {
1546
1693
  await session.prompt(text);
1547
1694
  }
1548
1695
  });
1549
- // After the user's run settles, kick off Ken's auto-review loop. This is
1550
- // the ONLY entry point into the cycle it drives any follow-up GG Coder
1551
- // runs itself, so the shared runAgent finally never recurses.
1552
- if (autopilot && !autopilotCancelled)
1553
- await runAutopilotCycle();
1696
+ // After the user's run settles, kick off Ken's auto-review loop but
1697
+ // only when the turn is actually reviewable (shouldStartAutopilotCycle):
1698
+ // workflow commands (/compare, /bullet-proof, …) end with reports or
1699
+ // A/B/C choices reserved for the USER; registry commands (/help) and
1700
+ // failed runs add no assistant work to judge; a turn that ended in plan
1701
+ // mode has a pending Accept/Reject modal Ken must not preempt. This is
1702
+ // the ONLY entry point into the cycle besides the stranded-queue drain —
1703
+ // it drives any follow-up GG Coder runs itself, so the shared runAgent
1704
+ // finally never recurses.
1705
+ const decision = shouldStartAutopilotCycle({
1706
+ enabled: autopilot,
1707
+ cancelled: autopilotCancelled,
1708
+ planMode: session.getPlanMode(),
1709
+ workflowCommand,
1710
+ assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
1711
+ });
1712
+ if (decision.start) {
1713
+ await runAutopilotCycle(text);
1714
+ }
1715
+ else if (autopilot) {
1716
+ log("INFO", "app-sidecar", "autopilot skipped", { reason: decision.reason });
1717
+ }
1718
+ // A prompt sent while Ken was reviewing (build idle) queued but had no
1719
+ // run to steer into — run it now as a fresh turn so it never strands.
1720
+ await runStrandedQueue();
1554
1721
  });
1555
1722
  return;
1556
1723
  }
@@ -1581,7 +1748,7 @@ async function createSession(deps, opts) {
1581
1748
  broadcast("ken_run_start", { text });
1582
1749
  try {
1583
1750
  const ken = await ensureKenSession();
1584
- const digest = await buildKenContext(session, cwd, gitBranch, text);
1751
+ const digest = await buildKenContext(session, cwd, gitBranch, text, await loadWorkflowCommandSpecs(), injectedAutopilotPrompts);
1585
1752
  await ken.prompt(digest);
1586
1753
  // Record the turn against the BUILD session so it persists + survives
1587
1754
  // resume (advisory custom entry, never an LLM message). Reply is Ken's
@@ -1779,8 +1946,12 @@ async function createSession(deps, opts) {
1779
1946
  return;
1780
1947
  }
1781
1948
  await session.switchModel(target.provider, target.id);
1782
- await syncKenModel(target.provider, target.id);
1783
- await syncKenAutoModel(target.provider, target.id);
1949
+ // Ken follows GG Coder's model only while un-pinned; a user-set Ken
1950
+ // override survives GG model switches untouched.
1951
+ if (!kenModelOverride) {
1952
+ await syncKenModel(target.provider, target.id);
1953
+ await syncKenAutoModel(target.provider, target.id);
1954
+ }
1784
1955
  // Clamp the reasoning level to what the new model supports (mirrors the
1785
1956
  // CLI): keep thinking on at the first supported tier if it was on but
1786
1957
  // the prior level is unsupported here; leave it off if it was off.
@@ -1807,6 +1978,12 @@ async function createSession(deps, opts) {
1807
1978
  // model_change is emitted by switchModel; follow with thinking_change so
1808
1979
  // the footer toggle reflects the new model's supported levels.
1809
1980
  broadcast("thinking_change", payload);
1981
+ // Un-pinned Ken just followed the switch — update his footer chip too.
1982
+ // When Ken is pinned, his effective model did not change, so skip the
1983
+ // no-op event (keeps footer/event tests from treating a GG switch as a
1984
+ // Ken switch).
1985
+ if (!kenModelOverride)
1986
+ broadcast("ken_model_change", kenStatePayload());
1810
1987
  // The new model usually has a different context window — push extras so
1811
1988
  // the footer's context meter rescales immediately.
1812
1989
  broadcast("extras", footerExtras());
@@ -1814,6 +1991,54 @@ async function createSession(deps, opts) {
1814
1991
  });
1815
1992
  return;
1816
1993
  }
1994
+ // Set or clear Ken's model pin. Body: { model: "<id>" } to pin, or
1995
+ // { model: null } / "" to clear (Ken resumes following GG Coder). Applies
1996
+ // to BOTH Ken sessions (chat + autopilot reviewer); a switch landing while
1997
+ // either is mid-run defers via the pending-model mechanics.
1998
+ if (method === "POST" && url === "/ken/model") {
1999
+ void readBody(req).then(async (raw) => {
2000
+ let modelId;
2001
+ try {
2002
+ const parsed = JSON.parse(raw).model;
2003
+ modelId = typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
2004
+ }
2005
+ catch {
2006
+ json(res, 400, { error: "invalid JSON body" });
2007
+ return;
2008
+ }
2009
+ if (modelId === null) {
2010
+ // Clear the pin → follow GG Coder again, syncing both sessions back.
2011
+ kenModelOverride = null;
2012
+ await saveKenModelPref(cwd, null);
2013
+ const st = session.getState();
2014
+ await syncKenModel(st.provider, st.model);
2015
+ await syncKenAutoModel(st.provider, st.model);
2016
+ log("INFO", "app-sidecar", "ken model pin cleared — following GG", {
2017
+ provider: st.provider,
2018
+ model: st.model,
2019
+ });
2020
+ }
2021
+ else {
2022
+ const target = getModel(modelId);
2023
+ if (!target) {
2024
+ json(res, 404, { error: `unknown model: ${modelId}` });
2025
+ return;
2026
+ }
2027
+ kenModelOverride = { provider: target.provider, model: target.id };
2028
+ await saveKenModelPref(cwd, kenModelOverride);
2029
+ await syncKenModel(target.provider, target.id);
2030
+ await syncKenAutoModel(target.provider, target.id);
2031
+ log("INFO", "app-sidecar", "ken model pinned", {
2032
+ provider: target.provider,
2033
+ model: target.id,
2034
+ });
2035
+ }
2036
+ const payload = kenStatePayload();
2037
+ broadcast("ken_model_change", payload);
2038
+ json(res, 200, payload);
2039
+ });
2040
+ return;
2041
+ }
1817
2042
  if (method === "POST" && url === "/kill") {
1818
2043
  void readBody(req).then(async (raw) => {
1819
2044
  let id;
@@ -1885,6 +2110,7 @@ async function createSession(deps, opts) {
1885
2110
  void session
1886
2111
  .newSession()
1887
2112
  .then(() => {
2113
+ injectedAutopilotPrompts = [];
1888
2114
  broadcast("session_reset", {});
1889
2115
  json(res, 200, { ok: true });
1890
2116
  })
@@ -1918,6 +2144,7 @@ async function createSession(deps, opts) {
1918
2144
  }
1919
2145
  try {
1920
2146
  await session.newSession();
2147
+ injectedAutopilotPrompts = [];
1921
2148
  titleGenerated = false;
1922
2149
  await session.setApprovedPlan(planPath);
1923
2150
  broadcast("session_reset", {});