@kenkaiiii/ggcoder 5.6.0 → 5.6.2

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 (62) hide show
  1. package/dist/app-sidecar.js +353 -67
  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 +42 -2
  8. package/dist/core/agent-session.d.ts.map +1 -1
  9. package/dist/core/agent-session.js +76 -3
  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 +97 -0
  20. package/dist/core/autopilot-gate.d.ts.map +1 -0
  21. package/dist/core/autopilot-gate.js +196 -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 +270 -0
  26. package/dist/core/autopilot-gate.test.js.map +1 -0
  27. package/dist/core/autopilot-verdict.d.ts.map +1 -1
  28. package/dist/core/autopilot-verdict.js +41 -1
  29. package/dist/core/autopilot-verdict.js.map +1 -1
  30. package/dist/core/autopilot-verdict.test.js +16 -0
  31. package/dist/core/autopilot-verdict.test.js.map +1 -1
  32. package/dist/core/ken-context.d.ts +23 -2
  33. package/dist/core/ken-context.d.ts.map +1 -1
  34. package/dist/core/ken-context.js +47 -8
  35. package/dist/core/ken-context.js.map +1 -1
  36. package/dist/core/ken-context.test.js +120 -5
  37. package/dist/core/ken-context.test.js.map +1 -1
  38. package/dist/core/ken-model.d.ts +46 -0
  39. package/dist/core/ken-model.d.ts.map +1 -0
  40. package/dist/core/ken-model.js +26 -0
  41. package/dist/core/ken-model.js.map +1 -0
  42. package/dist/core/ken-model.test.d.ts +2 -0
  43. package/dist/core/ken-model.test.d.ts.map +1 -0
  44. package/dist/core/ken-model.test.js +51 -0
  45. package/dist/core/ken-model.test.js.map +1 -0
  46. package/dist/core/ken-prompt.d.ts +2 -15
  47. package/dist/core/ken-prompt.d.ts.map +1 -1
  48. package/dist/core/ken-prompt.js +47 -14
  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 +76 -0
  53. package/dist/core/ken-prompt.test.js.map +1 -0
  54. package/dist/core/session-manager.d.ts +18 -0
  55. package/dist/core/session-manager.d.ts.map +1 -1
  56. package/dist/core/session-manager.js +31 -0
  57. package/dist/core/session-manager.js.map +1 -1
  58. package/dist/core/session-manager.test.js +71 -1
  59. package/dist/core/session-manager.test.js.map +1 -1
  60. package/dist/core/speed-benchmark.test.js +2 -3
  61. package/dist/core/speed-benchmark.test.js.map +1 -1
  62. package/package.json +4 -4
@@ -23,7 +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 { collectProjectContext } from "./system-prompt.js";
26
+ import { isWorkflowCommandText, countAssistantMessages, shouldStartAutopilotCycle, extractTurnToolCalls, isMechanicalOnlyTurn, } from "./core/autopilot-gate.js";
27
+ import { driveAutopilotCycle } from "./core/autopilot-cycle.js";
28
+ import { validateKenModelPref, effectiveKenModel } from "./core/ken-model.js";
27
29
  import { AuthStorage } from "./core/auth-storage.js";
28
30
  import { MOONSHOT_OAUTH_KEY, XIAOMI_CREDITS_KEY } from "@kenkaiiii/gg-core";
29
31
  import { loginAnthropic } from "./core/oauth/anthropic.js";
@@ -82,6 +84,7 @@ async function loadAppSettings() {
82
84
  // model/thinking handlers below).
83
85
  projectModels: raw.projectModels && typeof raw.projectModels === "object" ? raw.projectModels : undefined,
84
86
  autopilot: raw.autopilot && typeof raw.autopilot === "object" ? raw.autopilot : undefined,
87
+ kenModels: raw.kenModels && typeof raw.kenModels === "object" ? raw.kenModels : undefined,
85
88
  };
86
89
  }
87
90
  catch {
@@ -105,6 +108,24 @@ async function saveProjectModelPrefs(cwd, prefs) {
105
108
  s.projectModels = { ...(s.projectModels ?? {}), [key]: prefs };
106
109
  await saveAppSettings(s);
107
110
  }
111
+ /** Read this project's persisted Ken model override, if any. */
112
+ async function loadKenModelPref(cwd) {
113
+ const s = await loadAppSettings();
114
+ return s.kenModels?.[projectModelKey(cwd)];
115
+ }
116
+ /** Persist (or with null, clear) this project's Ken model override via
117
+ * read-modify-write so the rest of the settings file is preserved. */
118
+ async function saveKenModelPref(cwd, pref) {
119
+ const s = await loadAppSettings();
120
+ const key = projectModelKey(cwd);
121
+ const next = { ...(s.kenModels ?? {}) };
122
+ if (pref)
123
+ next[key] = pref;
124
+ else
125
+ delete next[key];
126
+ s.kenModels = next;
127
+ await saveAppSettings(s);
128
+ }
108
129
  /** Read this project's persisted autopilot flag (default off). */
109
130
  async function loadAutopilot(cwd) {
110
131
  const s = await loadAppSettings();
@@ -601,18 +622,23 @@ function lastAssistantText(messages) {
601
622
  return "";
602
623
  }
603
624
  /**
604
- * Assemble Ken's context digest for one `@Ken` question: project docs (up the
605
- * 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.
625
+ * Assemble Ken's context digest for one `@Ken` question: git/env + the build
626
+ * session's compaction summary + recent activity. Prepended to the user's
627
+ * question as Ken's prompt body each turn. Project docs (CLAUDE.md/AGENTS.md)
628
+ * are NOT here — they're folded into Ken's cached system prompt once per
629
+ * session instead (see ken-prompt.ts), so they hit the provider prompt cache
630
+ * instead of being re-sent uncached on every question. Workflow commands +
631
+ * autopilot-injected prompts are passed through so the digest labels them as
632
+ * what they are instead of user-authored asks.
607
633
  */
608
- async function buildKenContext(buildSession, cwd, gitBranch, question) {
609
- const projectContext = await collectProjectContext(cwd).catch(() => []);
634
+ function buildKenContext(buildSession, cwd, gitBranch, question, workflowCommands, injectedPrompts) {
610
635
  return buildKenDigest({
611
636
  question,
612
- projectContext,
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,16 +907,19 @@ 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
- systemPrompt: buildKenSystemPrompt(),
915
+ systemPrompt: await buildKenSystemPrompt(cwd),
844
916
  allowedTools: KEN_ALLOWED_TOOLS,
845
917
  allowedMcpServers: KEN_ALLOWED_MCP_SERVERS,
846
918
  transient: true,
847
919
  signal: kenAbort.signal,
920
+ // Ken's bursty, spread-out turns (chat) outlast the default 5-min cache
921
+ // TTL regardless of the user's global speedProfile pick.
922
+ forceLongCacheRetention: true,
848
923
  });
849
924
  await ken.initialize();
850
925
  // Bridge Ken's bus to the shared SSE fan-out with ken_-prefixed types so the
@@ -869,7 +944,10 @@ async function createSession(deps, opts) {
869
944
  broadcastError("ken_error", "ken error", d.error);
870
945
  });
871
946
  kenSession = ken;
872
- log("INFO", "app-sidecar", "ken session ready", { provider: st.provider, model: st.model });
947
+ log("INFO", "app-sidecar", "ken session ready", {
948
+ provider: target.provider,
949
+ model: target.model,
950
+ });
873
951
  return ken;
874
952
  }
875
953
  // ── Autopilot Ken (auto-reviewer) ──────────────────────────
@@ -898,24 +976,27 @@ async function createSession(deps, opts) {
898
976
  async function ensureKenAutoSession() {
899
977
  if (kenAutoSession)
900
978
  return kenAutoSession;
901
- const st = session.getState();
979
+ const target = kenCurrentModel();
902
980
  const ken = new AgentSession({
903
- provider: st.provider,
904
- model: st.model,
981
+ provider: target.provider,
982
+ model: target.model,
905
983
  cwd,
906
- systemPrompt: buildKenAutopilotSystemPrompt(),
984
+ systemPrompt: await buildKenAutopilotSystemPrompt(cwd),
907
985
  allowedTools: KEN_ALLOWED_TOOLS,
908
986
  allowedMcpServers: KEN_ALLOWED_MCP_SERVERS,
909
987
  transient: true,
910
988
  signal: kenAutoAbort.signal,
989
+ // Autopilot review rounds routinely span the injected GG Coder run
990
+ // (often >5 min) regardless of the user's global speedProfile pick.
991
+ forceLongCacheRetention: true,
911
992
  });
912
993
  await ken.initialize();
913
994
  // Deliberately no bus bridge: the review is silent. Errors surface via the
914
995
  // runAutopilotReview try/catch as autopilot_error frames.
915
996
  kenAutoSession = ken;
916
997
  log("INFO", "app-sidecar", "ken autopilot session ready", {
917
- provider: st.provider,
918
- model: st.model,
998
+ provider: target.provider,
999
+ model: target.model,
919
1000
  });
920
1001
  return ken;
921
1002
  }
@@ -980,17 +1061,20 @@ async function createSession(deps, opts) {
980
1061
  // One review = prompt the kenAuto session with the review digest, read its
981
1062
  // final assistant text, parse a verdict. Returns null on failure (surfaced as
982
1063
  // an autopilot_error frame) so the cycle stops rather than looping blind.
983
- 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) {
984
1067
  autopilotReviewing = true;
985
1068
  broadcast("autopilot_review_start", {});
986
1069
  try {
987
1070
  const ken = await ensureKenAutoSession();
988
- const projectContext = await collectProjectContext(cwd).catch(() => []);
989
1071
  const digest = buildKenAutopilotContext({
990
- projectContext,
991
1072
  cwd,
992
1073
  gitBranch,
993
1074
  messages: session.getMessages(),
1075
+ originalRequest,
1076
+ injectedPrompts: [...injectedAutopilotPrompts],
1077
+ workflowCommands: await loadWorkflowCommandSpecs(),
994
1078
  });
995
1079
  await ken.prompt(digest);
996
1080
  return parseAutopilotVerdict(lastAssistantText(ken.getMessages()));
@@ -1009,50 +1093,123 @@ async function createSession(deps, opts) {
1009
1093
  }
1010
1094
  }
1011
1095
  // 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() {
1096
+ // called after shouldStartAutopilotCycle approves the turn (POST /prompt or
1097
+ // the stranded-queue drain) — never from the task runner, resume, /ken, or
1098
+ // error paths, so there's no recursion and no guard tangle. The loop's
1099
+ // control flow lives in driveAutopilotCycle (core/autopilot-cycle.ts) so
1100
+ // every exit path is unit-tested; this only wires the real dependencies.
1101
+ async function runAutopilotCycle(originalRequest) {
1016
1102
  if (!autopilot || autopilotCancelled)
1017
1103
  return;
1018
1104
  autopilotActive = true;
1019
1105
  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", {});
1106
+ await driveAutopilotCycle({
1107
+ maxRounds: MAX_AUTOPILOT_ROUNDS,
1108
+ isCancelled: () => autopilotCancelled,
1109
+ // An injected run entering plan mode halts the cycle (autopilot_human
1110
+ // with the plan-hold reason) Ken never prompts into a read-only
1111
+ // plan-mode session or answers the plan modal for the user.
1112
+ isPlanMode: () => session.getPlanMode(),
1113
+ // Lean context per user turn: wipe prior review history so each new
1114
+ // turn starts cheap, while within this cycle the few review messages
1115
+ // persist so Ken remembers what he already asked GG Coder to fix.
1116
+ resetReviewer: async () => {
1117
+ await kenAutoSession?.newSession().catch(() => { });
1118
+ },
1119
+ review: () => runAutopilotReview(originalRequest),
1120
+ // prompt → record the injected body (so later digests label it as
1121
+ // Ken's, not the user's), show a compact Ken-tinted marker (not the
1122
+ // prompt body), then feed GG Coder bracketed by runAgent so the run
1123
+ // streams normally; the shared finally never re-triggers autopilot,
1124
+ // so this can't recurse.
1125
+ onInjected: (body, round) => {
1126
+ injectedAutopilotPrompts.push(body);
1127
+ broadcast("autopilot_prompted", { round, body });
1128
+ void session.persistAutopilotMarker("prompted", { body });
1129
+ },
1130
+ runPrompt: (body) => runAgent(body, () => session.prompt(body)),
1131
+ emit: (event) => {
1132
+ broadcast(event.type, event.data);
1133
+ // Persist the terminal verdict marker so a resumed session renders the
1134
+ // same Ken bubble the live run showed instead of dropping it or
1135
+ // falling back to the raw verdict text (e.g. ALL_CLEAR).
1136
+ if (event.type === "autopilot_done") {
1137
+ void session.persistAutopilotMarker("done");
1138
+ }
1139
+ else if (event.type === "autopilot_human") {
1140
+ void session.persistAutopilotMarker("human", { reason: event.data.reason });
1141
+ }
1142
+ else if (event.type === "autopilot_capped") {
1143
+ void session.persistAutopilotMarker("capped");
1144
+ }
1145
+ // autopilot_ignored renders nothing live, so nothing is persisted either.
1146
+ },
1147
+ });
1148
+ }
1149
+ finally {
1150
+ autopilotActive = false;
1151
+ }
1152
+ }
1153
+ // ── Stranded-queue drain ───────────────────────────────
1154
+ // A prompt POSTed while an autopilot cycle is between injected runs (build
1155
+ // idle, Ken reviewing) queues — but the queue only drains INTO a running
1156
+ // turn as steering. If the cycle ends without another run (ALL_CLEAR /
1157
+ // IGNORE / HUMAN / error), that message would sit stranded until the next
1158
+ // unrelated prompt, then land mislabeled as "concurrent steering" of an
1159
+ // unrelated run. Drain it here as a fresh turn of its own (with its own
1160
+ // gated review). Also covers the non-autopilot tail window: a message queued
1161
+ // after the run's last steering drain but before run_end.
1162
+ let drainingStrandedQueue = false;
1163
+ async function runStrandedQueue() {
1164
+ if (drainingStrandedQueue)
1165
+ return;
1166
+ drainingStrandedQueue = true;
1167
+ try {
1168
+ for (;;) {
1169
+ if (running || autopilotActive)
1032
1170
  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", {});
1171
+ const next = session.takeNextQueuedMessage();
1172
+ if (!next)
1038
1173
  return;
1174
+ broadcast("queued", { count: session.getQueuedCount() });
1175
+ if (!next.text.trim() && next.attachments.length === 0)
1176
+ continue;
1177
+ const workflowCommand = next.attachments.length === 0 &&
1178
+ isWorkflowCommandText(next.text, await loadWorkflowCommandSpecs());
1179
+ const assistantsBefore = countAssistantMessages(session.getMessages());
1180
+ const messagesBefore = session.getMessages().length;
1181
+ await runAgent(next.text, async () => {
1182
+ if (next.attachments.length > 0) {
1183
+ await session.promptWithAttachments(next.text, next.attachments);
1184
+ }
1185
+ else {
1186
+ await session.prompt(next.text);
1187
+ }
1188
+ });
1189
+ const decision = shouldStartAutopilotCycle({
1190
+ enabled: autopilot,
1191
+ cancelled: autopilotCancelled,
1192
+ planMode: session.getPlanMode(),
1193
+ workflowCommand,
1194
+ assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
1195
+ // Skip the review API call outright for turns that only started a
1196
+ // background process (dev server/watcher), ran a read-only lookup, or
1197
+ // committed/pushed — Ken's autopilot contract already IGNOREs these,
1198
+ // so there's no reason to pay for that verdict.
1199
+ mechanicalOnly: isMechanicalOnlyTurn(extractTurnToolCalls(session.getMessages(), messagesBefore)),
1200
+ });
1201
+ if (decision.start) {
1202
+ await runAutopilotCycle(next.text);
1039
1203
  }
1040
- if (verdict.kind === "human") {
1041
- broadcast("autopilot_human", { reason: verdict.reason });
1042
- return;
1204
+ else if (autopilot) {
1205
+ log("INFO", "app-sidecar", "autopilot skipped (queued turn)", {
1206
+ reason: decision.reason,
1207
+ });
1043
1208
  }
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
1209
  }
1052
- broadcast("autopilot_capped", { rounds: MAX_AUTOPILOT_ROUNDS });
1053
1210
  }
1054
1211
  finally {
1055
- autopilotActive = false;
1212
+ drainingStrandedQueue = false;
1056
1213
  }
1057
1214
  }
1058
1215
  // ── Task runner (project task list → sessions) ──────────────
@@ -1066,6 +1223,7 @@ async function createSession(deps, opts) {
1066
1223
  return false;
1067
1224
  // Fresh session per task so one task's context never bleeds into the next.
1068
1225
  await session.newSession();
1226
+ injectedAutopilotPrompts = [];
1069
1227
  titleGenerated = false;
1070
1228
  broadcast("session_reset", {});
1071
1229
  markTaskInProgress(cwd, task.id);
@@ -1173,6 +1331,7 @@ async function createSession(deps, opts) {
1173
1331
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
1174
1332
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
1175
1333
  autopilot,
1334
+ ...kenStatePayload(),
1176
1335
  ...footerExtras(),
1177
1336
  });
1178
1337
  return;
@@ -1197,6 +1356,7 @@ async function createSession(deps, opts) {
1197
1356
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
1198
1357
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
1199
1358
  autopilot,
1359
+ ...kenStatePayload(),
1200
1360
  ...footerExtras(),
1201
1361
  },
1202
1362
  })}\n\n`);
@@ -1371,9 +1531,37 @@ async function createSession(deps, opts) {
1371
1531
  history.push({ role: "assistant", text: turn.reply, ken: true });
1372
1532
  }
1373
1533
  };
1534
+ // Autopilot verdict markers to interleave, same anchor scheme as Ken
1535
+ // turns — each becomes a single assistant row the webview renders
1536
+ // exactly like the live `autopilot` item (never a raw verdict string).
1537
+ const autopilotByCount = new Map();
1538
+ for (const marker of session.getAutopilotMarkers()) {
1539
+ const list = autopilotByCount.get(marker.afterMessageCount) ?? [];
1540
+ list.push(marker);
1541
+ autopilotByCount.set(marker.afterMessageCount, list);
1542
+ }
1543
+ const flushAutopilot = (count) => {
1544
+ const markers = autopilotByCount.get(count);
1545
+ if (!markers)
1546
+ return;
1547
+ autopilotByCount.delete(count);
1548
+ for (const marker of markers) {
1549
+ history.push({
1550
+ role: "assistant",
1551
+ text: "",
1552
+ autopilot: {
1553
+ phase: marker.phase,
1554
+ ...(marker.reason !== undefined ? { reason: marker.reason } : {}),
1555
+ ...(marker.body !== undefined ? { body: marker.body } : {}),
1556
+ },
1557
+ });
1558
+ }
1559
+ };
1374
1560
  let nonSystemCount = 0;
1375
- // Turns recorded before any build message (anchor 0) render at the top.
1561
+ // Turns/markers recorded before any build message (anchor 0) render at
1562
+ // the top.
1376
1563
  flushKen(0);
1564
+ flushAutopilot(0);
1377
1565
  for (const msg of messages) {
1378
1566
  if (msg.role === "system")
1379
1567
  continue;
@@ -1467,14 +1655,19 @@ async function createSession(deps, opts) {
1467
1655
  });
1468
1656
  }
1469
1657
  }
1470
- // Interleave any Ken turns recorded right after this message.
1658
+ // Interleave any Ken turns / autopilot markers recorded right after
1659
+ // this message.
1471
1660
  flushKen(nonSystemCount);
1661
+ flushAutopilot(nonSystemCount);
1472
1662
  }
1473
- // Flush remaining Ken turns whose anchor is at/after the message count
1474
- // (e.g. asked before any build message, or anchors beyond the current
1475
- // count after compaction shrank the history) so none are dropped.
1663
+ // Flush remaining Ken turns / autopilot markers whose anchor is at/after
1664
+ // the message count (e.g. recorded before any build message, or anchors
1665
+ // beyond the current count after compaction shrank the history) so none
1666
+ // are dropped.
1476
1667
  for (const count of [...kenByCount.keys()].sort((a, b) => a - b))
1477
1668
  flushKen(count);
1669
+ for (const count of [...autopilotByCount.keys()].sort((a, b) => a - b))
1670
+ flushAutopilot(count);
1478
1671
  json(res, 200, { history });
1479
1672
  })();
1480
1673
  return;
@@ -1537,6 +1730,14 @@ async function createSession(deps, opts) {
1537
1730
  // Fresh user turn: clear any cancel flag left from a prior cycle so this
1538
1731
  // turn's autopilot review can run.
1539
1732
  autopilotCancelled = false;
1733
+ // Gate inputs captured around the run: whether this turn is a workflow
1734
+ // slash command (attachment prompts skip slash expansion entirely), and
1735
+ // how many assistant messages the run actually adds. Computed even when
1736
+ // autopilot is currently off — the toggle can flip ON mid-run, and the
1737
+ // gate reads the post-run value.
1738
+ const workflowCommand = attachments.length === 0 && isWorkflowCommandText(text, await loadWorkflowCommandSpecs());
1739
+ const assistantsBefore = countAssistantMessages(session.getMessages());
1740
+ const messagesBefore = session.getMessages().length;
1540
1741
  await runAgent(text, async () => {
1541
1742
  if (attachments.length > 0) {
1542
1743
  // Persist each attachment under .gg/uploads so files are inspectable
@@ -1552,11 +1753,36 @@ async function createSession(deps, opts) {
1552
1753
  await session.prompt(text);
1553
1754
  }
1554
1755
  });
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();
1756
+ // After the user's run settles, kick off Ken's auto-review loop but
1757
+ // only when the turn is actually reviewable (shouldStartAutopilotCycle):
1758
+ // workflow commands (/compare, /bullet-proof, …) end with reports or
1759
+ // A/B/C choices reserved for the USER; registry commands (/help) and
1760
+ // failed runs add no assistant work to judge; a turn that ended in plan
1761
+ // mode has a pending Accept/Reject modal Ken must not preempt. This is
1762
+ // the ONLY entry point into the cycle besides the stranded-queue drain —
1763
+ // it drives any follow-up GG Coder runs itself, so the shared runAgent
1764
+ // finally never recurses.
1765
+ const decision = shouldStartAutopilotCycle({
1766
+ enabled: autopilot,
1767
+ cancelled: autopilotCancelled,
1768
+ planMode: session.getPlanMode(),
1769
+ workflowCommand,
1770
+ assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
1771
+ // Skip the review API call outright for turns that only started a
1772
+ // background process (dev server/watcher), ran a read-only lookup, or
1773
+ // committed/pushed — Ken's autopilot contract already IGNOREs these,
1774
+ // so there's no reason to pay for that verdict.
1775
+ mechanicalOnly: isMechanicalOnlyTurn(extractTurnToolCalls(session.getMessages(), messagesBefore)),
1776
+ });
1777
+ if (decision.start) {
1778
+ await runAutopilotCycle(text);
1779
+ }
1780
+ else if (autopilot) {
1781
+ log("INFO", "app-sidecar", "autopilot skipped", { reason: decision.reason });
1782
+ }
1783
+ // A prompt sent while Ken was reviewing (build idle) queued but had no
1784
+ // run to steer into — run it now as a fresh turn so it never strands.
1785
+ await runStrandedQueue();
1560
1786
  });
1561
1787
  return;
1562
1788
  }
@@ -1587,7 +1813,7 @@ async function createSession(deps, opts) {
1587
1813
  broadcast("ken_run_start", { text });
1588
1814
  try {
1589
1815
  const ken = await ensureKenSession();
1590
- const digest = await buildKenContext(session, cwd, gitBranch, text);
1816
+ const digest = await buildKenContext(session, cwd, gitBranch, text, await loadWorkflowCommandSpecs(), injectedAutopilotPrompts);
1591
1817
  await ken.prompt(digest);
1592
1818
  // Record the turn against the BUILD session so it persists + survives
1593
1819
  // resume (advisory custom entry, never an LLM message). Reply is Ken's
@@ -1785,8 +2011,12 @@ async function createSession(deps, opts) {
1785
2011
  return;
1786
2012
  }
1787
2013
  await session.switchModel(target.provider, target.id);
1788
- await syncKenModel(target.provider, target.id);
1789
- await syncKenAutoModel(target.provider, target.id);
2014
+ // Ken follows GG Coder's model only while un-pinned; a user-set Ken
2015
+ // override survives GG model switches untouched.
2016
+ if (!kenModelOverride) {
2017
+ await syncKenModel(target.provider, target.id);
2018
+ await syncKenAutoModel(target.provider, target.id);
2019
+ }
1790
2020
  // Clamp the reasoning level to what the new model supports (mirrors the
1791
2021
  // CLI): keep thinking on at the first supported tier if it was on but
1792
2022
  // the prior level is unsupported here; leave it off if it was off.
@@ -1813,6 +2043,12 @@ async function createSession(deps, opts) {
1813
2043
  // model_change is emitted by switchModel; follow with thinking_change so
1814
2044
  // the footer toggle reflects the new model's supported levels.
1815
2045
  broadcast("thinking_change", payload);
2046
+ // Un-pinned Ken just followed the switch — update his footer chip too.
2047
+ // When Ken is pinned, his effective model did not change, so skip the
2048
+ // no-op event (keeps footer/event tests from treating a GG switch as a
2049
+ // Ken switch).
2050
+ if (!kenModelOverride)
2051
+ broadcast("ken_model_change", kenStatePayload());
1816
2052
  // The new model usually has a different context window — push extras so
1817
2053
  // the footer's context meter rescales immediately.
1818
2054
  broadcast("extras", footerExtras());
@@ -1820,6 +2056,54 @@ async function createSession(deps, opts) {
1820
2056
  });
1821
2057
  return;
1822
2058
  }
2059
+ // Set or clear Ken's model pin. Body: { model: "<id>" } to pin, or
2060
+ // { model: null } / "" to clear (Ken resumes following GG Coder). Applies
2061
+ // to BOTH Ken sessions (chat + autopilot reviewer); a switch landing while
2062
+ // either is mid-run defers via the pending-model mechanics.
2063
+ if (method === "POST" && url === "/ken/model") {
2064
+ void readBody(req).then(async (raw) => {
2065
+ let modelId;
2066
+ try {
2067
+ const parsed = JSON.parse(raw).model;
2068
+ modelId = typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
2069
+ }
2070
+ catch {
2071
+ json(res, 400, { error: "invalid JSON body" });
2072
+ return;
2073
+ }
2074
+ if (modelId === null) {
2075
+ // Clear the pin → follow GG Coder again, syncing both sessions back.
2076
+ kenModelOverride = null;
2077
+ await saveKenModelPref(cwd, null);
2078
+ const st = session.getState();
2079
+ await syncKenModel(st.provider, st.model);
2080
+ await syncKenAutoModel(st.provider, st.model);
2081
+ log("INFO", "app-sidecar", "ken model pin cleared — following GG", {
2082
+ provider: st.provider,
2083
+ model: st.model,
2084
+ });
2085
+ }
2086
+ else {
2087
+ const target = getModel(modelId);
2088
+ if (!target) {
2089
+ json(res, 404, { error: `unknown model: ${modelId}` });
2090
+ return;
2091
+ }
2092
+ kenModelOverride = { provider: target.provider, model: target.id };
2093
+ await saveKenModelPref(cwd, kenModelOverride);
2094
+ await syncKenModel(target.provider, target.id);
2095
+ await syncKenAutoModel(target.provider, target.id);
2096
+ log("INFO", "app-sidecar", "ken model pinned", {
2097
+ provider: target.provider,
2098
+ model: target.id,
2099
+ });
2100
+ }
2101
+ const payload = kenStatePayload();
2102
+ broadcast("ken_model_change", payload);
2103
+ json(res, 200, payload);
2104
+ });
2105
+ return;
2106
+ }
1823
2107
  if (method === "POST" && url === "/kill") {
1824
2108
  void readBody(req).then(async (raw) => {
1825
2109
  let id;
@@ -1891,6 +2175,7 @@ async function createSession(deps, opts) {
1891
2175
  void session
1892
2176
  .newSession()
1893
2177
  .then(() => {
2178
+ injectedAutopilotPrompts = [];
1894
2179
  broadcast("session_reset", {});
1895
2180
  json(res, 200, { ok: true });
1896
2181
  })
@@ -1924,6 +2209,7 @@ async function createSession(deps, opts) {
1924
2209
  }
1925
2210
  try {
1926
2211
  await session.newSession();
2212
+ injectedAutopilotPrompts = [];
1927
2213
  titleGenerated = false;
1928
2214
  await session.setApprovedPlan(planPath);
1929
2215
  broadcast("session_reset", {});