@kenkaiiii/ggcoder 5.6.0 → 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 (49) hide show
  1. package/dist/app-sidecar.js +274 -53
  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/ken-context.d.ts +17 -0
  28. package/dist/core/ken-context.d.ts.map +1 -1
  29. package/dist/core/ken-context.js +47 -6
  30. package/dist/core/ken-context.js.map +1 -1
  31. package/dist/core/ken-context.test.js +122 -1
  32. package/dist/core/ken-context.test.js.map +1 -1
  33. package/dist/core/ken-model.d.ts +46 -0
  34. package/dist/core/ken-model.d.ts.map +1 -0
  35. package/dist/core/ken-model.js +26 -0
  36. package/dist/core/ken-model.js.map +1 -0
  37. package/dist/core/ken-model.test.d.ts +2 -0
  38. package/dist/core/ken-model.test.d.ts.map +1 -0
  39. package/dist/core/ken-model.test.js +51 -0
  40. package/dist/core/ken-model.test.js.map +1 -0
  41. package/dist/core/ken-prompt.js +8 -4
  42. package/dist/core/ken-prompt.js.map +1 -1
  43. package/dist/core/ken-prompt.test.d.ts +2 -0
  44. package/dist/core/ken-prompt.test.d.ts.map +1 -0
  45. package/dist/core/ken-prompt.test.js +43 -0
  46. package/dist/core/ken-prompt.test.js.map +1 -0
  47. package/dist/core/speed-benchmark.test.js +2 -3
  48. package/dist/core/speed-benchmark.test.js.map +1 -1
  49. 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,50 +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)
1029
- return;
1030
- if (verdict.kind === "all_clear") {
1031
- broadcast("autopilot_done", {});
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)
1032
1150
  return;
1033
- }
1034
- if (verdict.kind === "ignore") {
1035
- // Nothing worth reviewing (small talk, a mechanical git op, etc.) —
1036
- // stop the cycle silently, no marker at all.
1037
- broadcast("autopilot_ignored", {});
1151
+ const next = session.takeNextQueuedMessage();
1152
+ if (!next)
1038
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);
1039
1177
  }
1040
- if (verdict.kind === "human") {
1041
- broadcast("autopilot_human", { reason: verdict.reason });
1042
- return;
1178
+ else if (autopilot) {
1179
+ log("INFO", "app-sidecar", "autopilot skipped (queued turn)", {
1180
+ reason: decision.reason,
1181
+ });
1043
1182
  }
1044
- // prompt → show a compact Ken-tinted marker (not the prompt body), then
1045
- // feed GG Coder. Bracketed by runAgent so the run streams normally; the
1046
- // shared finally no longer re-triggers autopilot, so this can't recurse.
1047
- broadcast("autopilot_prompted", { round, body: verdict.body });
1048
- await runAgent(verdict.body, () => session.prompt(verdict.body));
1049
- if (autopilotCancelled)
1050
- return;
1051
1183
  }
1052
- broadcast("autopilot_capped", { rounds: MAX_AUTOPILOT_ROUNDS });
1053
1184
  }
1054
1185
  finally {
1055
- autopilotActive = false;
1186
+ drainingStrandedQueue = false;
1056
1187
  }
1057
1188
  }
1058
1189
  // ── Task runner (project task list → sessions) ──────────────
@@ -1066,6 +1197,7 @@ async function createSession(deps, opts) {
1066
1197
  return false;
1067
1198
  // Fresh session per task so one task's context never bleeds into the next.
1068
1199
  await session.newSession();
1200
+ injectedAutopilotPrompts = [];
1069
1201
  titleGenerated = false;
1070
1202
  broadcast("session_reset", {});
1071
1203
  markTaskInProgress(cwd, task.id);
@@ -1173,6 +1305,7 @@ async function createSession(deps, opts) {
1173
1305
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
1174
1306
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
1175
1307
  autopilot,
1308
+ ...kenStatePayload(),
1176
1309
  ...footerExtras(),
1177
1310
  });
1178
1311
  return;
@@ -1197,6 +1330,7 @@ async function createSession(deps, opts) {
1197
1330
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
1198
1331
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
1199
1332
  autopilot,
1333
+ ...kenStatePayload(),
1200
1334
  ...footerExtras(),
1201
1335
  },
1202
1336
  })}\n\n`);
@@ -1537,6 +1671,13 @@ async function createSession(deps, opts) {
1537
1671
  // Fresh user turn: clear any cancel flag left from a prior cycle so this
1538
1672
  // turn's autopilot review can run.
1539
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());
1540
1681
  await runAgent(text, async () => {
1541
1682
  if (attachments.length > 0) {
1542
1683
  // Persist each attachment under .gg/uploads so files are inspectable
@@ -1552,11 +1693,31 @@ async function createSession(deps, opts) {
1552
1693
  await session.prompt(text);
1553
1694
  }
1554
1695
  });
1555
- // After the user's run settles, kick off Ken's auto-review loop. This is
1556
- // the ONLY entry point into the cycle it drives any follow-up GG Coder
1557
- // runs itself, so the shared runAgent finally never recurses.
1558
- if (autopilot && !autopilotCancelled)
1559
- 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();
1560
1721
  });
1561
1722
  return;
1562
1723
  }
@@ -1587,7 +1748,7 @@ async function createSession(deps, opts) {
1587
1748
  broadcast("ken_run_start", { text });
1588
1749
  try {
1589
1750
  const ken = await ensureKenSession();
1590
- const digest = await buildKenContext(session, cwd, gitBranch, text);
1751
+ const digest = await buildKenContext(session, cwd, gitBranch, text, await loadWorkflowCommandSpecs(), injectedAutopilotPrompts);
1591
1752
  await ken.prompt(digest);
1592
1753
  // Record the turn against the BUILD session so it persists + survives
1593
1754
  // resume (advisory custom entry, never an LLM message). Reply is Ken's
@@ -1785,8 +1946,12 @@ async function createSession(deps, opts) {
1785
1946
  return;
1786
1947
  }
1787
1948
  await session.switchModel(target.provider, target.id);
1788
- await syncKenModel(target.provider, target.id);
1789
- 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
+ }
1790
1955
  // Clamp the reasoning level to what the new model supports (mirrors the
1791
1956
  // CLI): keep thinking on at the first supported tier if it was on but
1792
1957
  // the prior level is unsupported here; leave it off if it was off.
@@ -1813,6 +1978,12 @@ async function createSession(deps, opts) {
1813
1978
  // model_change is emitted by switchModel; follow with thinking_change so
1814
1979
  // the footer toggle reflects the new model's supported levels.
1815
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());
1816
1987
  // The new model usually has a different context window — push extras so
1817
1988
  // the footer's context meter rescales immediately.
1818
1989
  broadcast("extras", footerExtras());
@@ -1820,6 +1991,54 @@ async function createSession(deps, opts) {
1820
1991
  });
1821
1992
  return;
1822
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
+ }
1823
2042
  if (method === "POST" && url === "/kill") {
1824
2043
  void readBody(req).then(async (raw) => {
1825
2044
  let id;
@@ -1891,6 +2110,7 @@ async function createSession(deps, opts) {
1891
2110
  void session
1892
2111
  .newSession()
1893
2112
  .then(() => {
2113
+ injectedAutopilotPrompts = [];
1894
2114
  broadcast("session_reset", {});
1895
2115
  json(res, 200, { ok: true });
1896
2116
  })
@@ -1924,6 +2144,7 @@ async function createSession(deps, opts) {
1924
2144
  }
1925
2145
  try {
1926
2146
  await session.newSession();
2147
+ injectedAutopilotPrompts = [];
1927
2148
  titleGenerated = false;
1928
2149
  await session.setApprovedPlan(planPath);
1929
2150
  broadcast("session_reset", {});