@kenkaiiii/ggcoder 5.16.0 → 5.18.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 (162) hide show
  1. package/README.md +2 -2
  2. package/dist/app-sidecar.js +303 -88
  3. package/dist/app-sidecar.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +24 -2
  6. package/dist/cli.js.map +1 -1
  7. package/dist/core/agent-session-review-coverage.test.d.ts +2 -0
  8. package/dist/core/agent-session-review-coverage.test.d.ts.map +1 -0
  9. package/dist/core/agent-session-review-coverage.test.js +57 -0
  10. package/dist/core/agent-session-review-coverage.test.js.map +1 -0
  11. package/dist/core/agent-session-tool-result-policy.test.d.ts +2 -0
  12. package/dist/core/agent-session-tool-result-policy.test.d.ts.map +1 -0
  13. package/dist/core/agent-session-tool-result-policy.test.js +12 -0
  14. package/dist/core/agent-session-tool-result-policy.test.js.map +1 -0
  15. package/dist/core/agent-session.d.ts +19 -12
  16. package/dist/core/agent-session.d.ts.map +1 -1
  17. package/dist/core/agent-session.js +185 -66
  18. package/dist/core/agent-session.js.map +1 -1
  19. package/dist/core/auth-providers.js +1 -1
  20. package/dist/core/auth-providers.js.map +1 -1
  21. package/dist/core/compaction/compactor.test.js +2 -1
  22. package/dist/core/compaction/compactor.test.js.map +1 -1
  23. package/dist/core/event-bus.d.ts +2 -0
  24. package/dist/core/event-bus.d.ts.map +1 -1
  25. package/dist/core/event-bus.js.map +1 -1
  26. package/dist/core/ideal-review.d.ts +27 -0
  27. package/dist/core/ideal-review.d.ts.map +1 -1
  28. package/dist/core/ideal-review.js +64 -0
  29. package/dist/core/ideal-review.js.map +1 -1
  30. package/dist/core/ideal-review.test.js +34 -1
  31. package/dist/core/ideal-review.test.js.map +1 -1
  32. package/dist/core/lsp/client.d.ts +3 -0
  33. package/dist/core/lsp/client.d.ts.map +1 -1
  34. package/dist/core/lsp/client.js +20 -0
  35. package/dist/core/lsp/client.js.map +1 -1
  36. package/dist/core/lsp/manager.d.ts +32 -9
  37. package/dist/core/lsp/manager.d.ts.map +1 -1
  38. package/dist/core/lsp/manager.js +89 -37
  39. package/dist/core/lsp/manager.js.map +1 -1
  40. package/dist/core/lsp/manager.test.js +56 -4
  41. package/dist/core/lsp/manager.test.js.map +1 -1
  42. package/dist/core/project-discovery.d.ts +4 -11
  43. package/dist/core/project-discovery.d.ts.map +1 -1
  44. package/dist/core/project-discovery.js +33 -30
  45. package/dist/core/project-discovery.js.map +1 -1
  46. package/dist/core/project-discovery.test.js +148 -0
  47. package/dist/core/project-discovery.test.js.map +1 -1
  48. package/dist/core/run-lifecycle.d.ts +40 -0
  49. package/dist/core/run-lifecycle.d.ts.map +1 -0
  50. package/dist/core/run-lifecycle.js +95 -0
  51. package/dist/core/run-lifecycle.js.map +1 -0
  52. package/dist/core/run-lifecycle.test.d.ts +2 -0
  53. package/dist/core/run-lifecycle.test.d.ts.map +1 -0
  54. package/dist/core/run-lifecycle.test.js +64 -0
  55. package/dist/core/run-lifecycle.test.js.map +1 -0
  56. package/dist/core/session-compaction.d.ts +3 -0
  57. package/dist/core/session-compaction.d.ts.map +1 -1
  58. package/dist/core/session-compaction.js +14 -1
  59. package/dist/core/session-compaction.js.map +1 -1
  60. package/dist/core/session-compaction.test.js +5 -0
  61. package/dist/core/session-compaction.test.js.map +1 -1
  62. package/dist/core/session-manager.d.ts +33 -2
  63. package/dist/core/session-manager.d.ts.map +1 -1
  64. package/dist/core/session-manager.js +73 -2
  65. package/dist/core/session-manager.js.map +1 -1
  66. package/dist/core/session-manager.test.js +97 -2
  67. package/dist/core/session-manager.test.js.map +1 -1
  68. package/dist/core/session-preview.d.ts +11 -0
  69. package/dist/core/session-preview.d.ts.map +1 -0
  70. package/dist/core/session-preview.js +50 -0
  71. package/dist/core/session-preview.js.map +1 -0
  72. package/dist/core/subagent-manager.d.ts +42 -7
  73. package/dist/core/subagent-manager.d.ts.map +1 -1
  74. package/dist/core/subagent-manager.js +346 -30
  75. package/dist/core/subagent-manager.js.map +1 -1
  76. package/dist/core/subagent-manager.test.js +195 -3
  77. package/dist/core/subagent-manager.test.js.map +1 -1
  78. package/dist/core/subagent-store.d.ts +20 -0
  79. package/dist/core/subagent-store.d.ts.map +1 -0
  80. package/dist/core/subagent-store.js +177 -0
  81. package/dist/core/subagent-store.js.map +1 -0
  82. package/dist/core/subagent-store.test.d.ts +2 -0
  83. package/dist/core/subagent-store.test.d.ts.map +1 -0
  84. package/dist/core/subagent-store.test.js +94 -0
  85. package/dist/core/subagent-store.test.js.map +1 -0
  86. package/dist/modes/agent-home-mode.d.ts.map +1 -1
  87. package/dist/modes/agent-home-mode.js +10 -3
  88. package/dist/modes/agent-home-mode.js.map +1 -1
  89. package/dist/modes/serve-mode.d.ts.map +1 -1
  90. package/dist/modes/serve-mode.js +10 -3
  91. package/dist/modes/serve-mode.js.map +1 -1
  92. package/dist/modes/subagent-worker-mode.d.ts +2 -0
  93. package/dist/modes/subagent-worker-mode.d.ts.map +1 -1
  94. package/dist/modes/subagent-worker-mode.js +13 -3
  95. package/dist/modes/subagent-worker-mode.js.map +1 -1
  96. package/dist/tools/subagent-shared.d.ts +8 -0
  97. package/dist/tools/subagent-shared.d.ts.map +1 -1
  98. package/dist/tools/subagent-shared.js.map +1 -1
  99. package/dist/tools/subagent.d.ts +3 -8
  100. package/dist/tools/subagent.d.ts.map +1 -1
  101. package/dist/tools/subagent.js +10 -8
  102. package/dist/tools/subagent.js.map +1 -1
  103. package/dist/tools/subagent.test.js +17 -1
  104. package/dist/tools/subagent.test.js.map +1 -1
  105. package/dist/tools/tasks.d.ts +1 -1
  106. package/dist/tools/web-fetch.d.ts +1 -1
  107. package/dist/ui/App.d.ts +7 -3
  108. package/dist/ui/App.d.ts.map +1 -1
  109. package/dist/ui/App.js +66 -83
  110. package/dist/ui/App.js.map +1 -1
  111. package/dist/ui/components/Footer.d.ts +1 -0
  112. package/dist/ui/components/Footer.d.ts.map +1 -1
  113. package/dist/ui/components/Footer.js +3 -3
  114. package/dist/ui/components/Footer.js.map +1 -1
  115. package/dist/ui/components/Footer.test.d.ts +2 -0
  116. package/dist/ui/components/Footer.test.d.ts.map +1 -0
  117. package/dist/ui/components/Footer.test.js +16 -0
  118. package/dist/ui/components/Footer.test.js.map +1 -0
  119. package/dist/ui/components/SubAgentPanel.d.ts +2 -0
  120. package/dist/ui/components/SubAgentPanel.d.ts.map +1 -1
  121. package/dist/ui/components/SubAgentPanel.js +4 -2
  122. package/dist/ui/components/SubAgentPanel.js.map +1 -1
  123. package/dist/ui/hooks/useAgentLoop.d.ts +8 -3
  124. package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
  125. package/dist/ui/hooks/useAgentLoop.js +64 -8
  126. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  127. package/dist/ui/hooks/useSessionPersistence.d.ts +4 -2
  128. package/dist/ui/hooks/useSessionPersistence.d.ts.map +1 -1
  129. package/dist/ui/hooks/useSessionPersistence.js +10 -1
  130. package/dist/ui/hooks/useSessionPersistence.js.map +1 -1
  131. package/dist/ui/hooks/useTerminalTitle.d.ts +3 -3
  132. package/dist/ui/hooks/useTerminalTitle.d.ts.map +1 -1
  133. package/dist/ui/hooks/useTerminalTitle.js +5 -9
  134. package/dist/ui/hooks/useTerminalTitle.js.map +1 -1
  135. package/dist/ui/login.d.ts.map +1 -1
  136. package/dist/ui/login.js +5 -1
  137. package/dist/ui/login.js.map +1 -1
  138. package/dist/ui/render.d.ts +7 -2
  139. package/dist/ui/render.d.ts.map +1 -1
  140. package/dist/ui/render.js +4 -4
  141. package/dist/ui/render.js.map +1 -1
  142. package/dist/ui/render.test.js +10 -1
  143. package/dist/ui/render.test.js.map +1 -1
  144. package/dist/ui/terminal-history.js +6 -2
  145. package/dist/ui/terminal-history.js.map +1 -1
  146. package/dist/ui/terminal-history.test.js +2 -2
  147. package/dist/ui/terminal-history.test.js.map +1 -1
  148. package/dist/ui/tui-history-parity.test.js +8 -1
  149. package/dist/ui/tui-history-parity.test.js.map +1 -1
  150. package/dist/utils/git.d.ts +2 -0
  151. package/dist/utils/git.d.ts.map +1 -1
  152. package/dist/utils/git.js +12 -0
  153. package/dist/utils/git.js.map +1 -1
  154. package/dist/utils/git.test.d.ts +2 -0
  155. package/dist/utils/git.test.d.ts.map +1 -0
  156. package/dist/utils/git.test.js +36 -0
  157. package/dist/utils/git.test.js.map +1 -0
  158. package/package.json +4 -4
  159. package/dist/utils/session-title.d.ts +0 -19
  160. package/dist/utils/session-title.d.ts.map +0 -1
  161. package/dist/utils/session-title.js +0 -82
  162. package/dist/utils/session-title.js.map +0 -1
package/README.md CHANGED
@@ -71,7 +71,7 @@ Switch mid-conversation with `/model`. Not locked to anyone.
71
71
  |---|---|---|
72
72
  | **Anthropic** | Claude Opus 4.8, Sonnet 5, Haiku 4.5 | OAuth |
73
73
  | **OpenAI** | GPT-5.5, GPT-5.5 Pro, GPT-5.4, GPT-5.3 Codex | OAuth |
74
- | **Moonshot** | Kimi K2.7 | API key |
74
+ | **Moonshot** | Kimi K3, Kimi K2.7 Code | OAuth or API key |
75
75
  | **Z.AI (GLM)** | GLM-5.1, GLM-4.7, GLM-4.7 Flash | API key |
76
76
  | **MiniMax** | MiniMax M3 (image + video) | API key |
77
77
  | **Xiaomi (MiMo)** | MiMo-V2.5-Pro, MiMo-V2.5-Pro-UltraSpeed, MiMo-V2.5 (image + video) | API key |
@@ -80,7 +80,7 @@ Switch mid-conversation with `/model`. Not locked to anyone.
80
80
 
81
81
  The same conversation, the same tools, the same project context — only the model changes. Use a strong reasoning model when you need it, swap to a fast cheap one for grunt work, never restart your session.
82
82
 
83
- **Attachments.** Drag, paste, or type a path to attach images and video in the chat input. Video is sent natively to models that support it (Gemini 3.x, Kimi K2.7, MiniMax M3, MiMo-V2.5); for other models the video is saved to a temp file and the model is told to inspect it with ffmpeg or its own tools.
83
+ **Attachments.** Drag, paste, or type a path to attach images and video in the chat input. Video is sent natively to models that support it (Gemini 3.x, Kimi K3/K2.7 Code, MiniMax M3, MiMo-V2.5); for other models the video is saved to a temp file and the model is told to inspect it with ffmpeg or its own tools.
84
84
 
85
85
  ---
86
86
 
@@ -18,10 +18,11 @@ import os from "node:os";
18
18
  import path from "node:path";
19
19
  import { randomUUID } from "node:crypto";
20
20
  import { parseArgs } from "node:util";
21
- import { formatError } from "@kenkaiiii/gg-ai";
21
+ import { environmentSecrets, formatError, redactValue, } from "@kenkaiiii/gg-ai";
22
22
  import { runJsonMode } from "./modes/json-mode.js";
23
23
  import { runSubagentWorkerMode } from "./modes/subagent-worker-mode.js";
24
24
  import { AgentSession } from "./core/agent-session.js";
25
+ import { RunLifecycle } from "./core/run-lifecycle.js";
25
26
  import { CHAT_AGENT_IDS, chatAgentSessionsDir, createChatAgent, parseChatAgentId, sessionsDirForChatAgent, switchChatAgent, } from "./chat-agents/index.js";
26
27
  import { buildJiwaTools, JiwaStore } from "./chat-agents/jiwa.js";
27
28
  import { buildMemoryTools, MemoryStore } from "./chat-agents/memory.js";
@@ -43,7 +44,8 @@ import { ensureAppDirs, loadSavedSettings } from "./config.js";
43
44
  import { SettingsManager } from "./core/settings-manager.js";
44
45
  import { getModel, getMaxThinkingLevel, getContextWindow, MODELS } from "./core/model-registry.js";
45
46
  import { resolveStartOrFallback } from "./core/resolve-start.js";
46
- import { getGitBranch, isGitRepo } from "./utils/git.js";
47
+ import { getGitBranch, getGitDirtyFileCount, isGitRepo } from "./utils/git.js";
48
+ import { extractPlanSteps } from "./utils/plan-steps.js";
47
49
  import { getNextThinkingLevel, getSupportedThinkingLevels, isThinkingLevelSupported, } from "./core/thinking-level.js";
48
50
  import { PROMPT_COMMANDS } from "./core/prompt-commands.js";
49
51
  import { loadCustomCommands } from "./core/custom-commands.js";
@@ -929,7 +931,8 @@ async function createSession(deps, opts) {
929
931
  const clients = new Set();
930
932
  let clientSeq = 0;
931
933
  function broadcast(type, data) {
932
- const frame = `data: ${JSON.stringify({ type, data })}\n\n`;
934
+ const safePayload = redactValue({ type, data }, { secrets: environmentSecrets(process.env) });
935
+ const frame = `data: ${JSON.stringify(safePayload)}\n\n`;
933
936
  for (const c of clients)
934
937
  c.res.write(frame);
935
938
  }
@@ -1033,7 +1036,9 @@ async function createSession(deps, opts) {
1033
1036
  ...baseSessionOptions,
1034
1037
  // Plan mode belongs only to the coding agent.
1035
1038
  onEnterPlan: async (reason) => {
1039
+ deactivateApprovedPlan();
1036
1040
  await session.setPlanMode(true);
1041
+ broadcast("plan_progress", { total: 0, completed: [] });
1037
1042
  broadcast("plan_enter", { reason: reason ?? "" });
1038
1043
  void session.persistAppMarker("plan", { reason: reason ?? "" }).catch(() => { });
1039
1044
  },
@@ -1063,11 +1068,17 @@ async function createSession(deps, opts) {
1063
1068
  }
1064
1069
  }
1065
1070
  log("INFO", "app-sidecar", "session ready", { provider, model, mode, chatAgent, cwd });
1066
- // Footer extras (context window, git branch, background tasks). The git
1067
- // branch is resolved once at startup and refreshed lazily; the context
1071
+ // Workspace extras (context window, git status, background tasks). Git state
1072
+ // is resolved once at startup and refreshed after every run; the context
1068
1073
  // window follows the active model.
1069
- let gitBranch = await getGitBranch(cwd).catch(() => null);
1070
- let gitIsRepo = await isGitRepo(cwd).catch(() => false);
1074
+ const [initialGitBranch, initialGitIsRepo, initialDirtyFileCount] = await Promise.all([
1075
+ getGitBranch(cwd).catch(() => null),
1076
+ isGitRepo(cwd).catch(() => false),
1077
+ getGitDirtyFileCount(cwd).catch(() => 0),
1078
+ ]);
1079
+ let gitBranch = initialGitBranch;
1080
+ let gitIsRepo = initialGitIsRepo;
1081
+ let gitDirtyFileCount = initialDirtyFileCount;
1071
1082
  function currentContextWindow() {
1072
1083
  const st = session.getState();
1073
1084
  return getContextWindow(st.model, { provider: st.provider, accountId: st.accountId });
@@ -1079,6 +1090,7 @@ async function createSession(deps, opts) {
1079
1090
  contextWindow: currentContextWindow(),
1080
1091
  gitBranch,
1081
1092
  isGitRepo: gitIsRepo,
1093
+ gitDirtyFileCount,
1082
1094
  tasks: session.listBackgroundProcesses(),
1083
1095
  };
1084
1096
  }
@@ -1088,8 +1100,93 @@ async function createSession(deps, opts) {
1088
1100
  // failures (e.g. repeated invalid-argument errors) instead of leaving the
1089
1101
  // fatal-abort path with no forensic trail.
1090
1102
  const toolCallNames = new Map();
1103
+ // Approved-plan progress belongs beside the plan file, not in the webview.
1104
+ // The implementation can rewrite/expand that file mid-run, so a step count
1105
+ // frozen at approval time becomes dishonest (the exact stale-total bug the
1106
+ // CLI already fixed). Re-read the live file after tools/markers, retain the
1107
+ // last valid step section during transient edits, and send one authoritative
1108
+ // snapshot to the app.
1109
+ let approvedPlanPath = null;
1110
+ let approvedPlanTotal = 0;
1111
+ let approvedPlanMarkers = new Set();
1112
+ let approvedPlanGeneration = 0;
1113
+ let planMarkerTail = "";
1114
+ let planProgressSync = Promise.resolve(false);
1115
+ function planProgressPayload() {
1116
+ const completed = [...approvedPlanMarkers]
1117
+ .filter((step) => step >= 1 && step <= approvedPlanTotal)
1118
+ .sort((a, b) => a - b);
1119
+ return { total: approvedPlanTotal, completed };
1120
+ }
1121
+ async function syncApprovedPlanProgress(generation) {
1122
+ const planPath = approvedPlanPath;
1123
+ if (planPath === null || generation !== approvedPlanGeneration)
1124
+ return false;
1125
+ const content = await fs.readFile(planPath, "utf-8").catch(() => null);
1126
+ if (approvedPlanPath !== planPath || generation !== approvedPlanGeneration)
1127
+ return false;
1128
+ if (content !== null) {
1129
+ const freshTotal = extractPlanSteps(content).length;
1130
+ // During an in-place rewrite the step section can briefly disappear.
1131
+ // Keep the last real total instead of flashing 0 or declaring completion.
1132
+ if (freshTotal > 0 || approvedPlanTotal === 0)
1133
+ approvedPlanTotal = freshTotal;
1134
+ }
1135
+ broadcast("plan_progress", planProgressPayload());
1136
+ return (approvedPlanTotal > 0 &&
1137
+ Array.from({ length: approvedPlanTotal }, (_, index) => index + 1).every((step) => approvedPlanMarkers.has(step)));
1138
+ }
1139
+ function queueApprovedPlanProgressSync() {
1140
+ const generation = approvedPlanGeneration;
1141
+ planProgressSync = planProgressSync
1142
+ .catch(() => false)
1143
+ .then(() => syncApprovedPlanProgress(generation))
1144
+ .catch((error) => {
1145
+ log("WARN", "app-sidecar", "plan progress refresh failed", {
1146
+ message: error instanceof Error ? error.message : String(error),
1147
+ });
1148
+ return false;
1149
+ });
1150
+ return planProgressSync;
1151
+ }
1152
+ async function activateApprovedPlan(planPath) {
1153
+ deactivateApprovedPlan();
1154
+ await session.setApprovedPlan(planPath);
1155
+ if (!planPath)
1156
+ return 0;
1157
+ approvedPlanPath = planPath;
1158
+ await queueApprovedPlanProgressSync();
1159
+ return approvedPlanTotal;
1160
+ }
1161
+ function deactivateApprovedPlan() {
1162
+ approvedPlanGeneration++;
1163
+ approvedPlanPath = null;
1164
+ approvedPlanTotal = 0;
1165
+ approvedPlanMarkers = new Set();
1166
+ planMarkerTail = "";
1167
+ planProgressSync = Promise.resolve(false);
1168
+ }
1169
+ function recordApprovedPlanMarkers(text) {
1170
+ if (approvedPlanPath === null || !text)
1171
+ return;
1172
+ const candidate = planMarkerTail + text;
1173
+ let changed = false;
1174
+ for (const match of candidate.matchAll(/\[DONE:(\d+)\]/gi)) {
1175
+ const step = Number.parseInt(match[1], 10);
1176
+ if (step >= 1 && !approvedPlanMarkers.has(step)) {
1177
+ approvedPlanMarkers.add(step);
1178
+ changed = true;
1179
+ }
1180
+ }
1181
+ planMarkerTail = candidate.slice(-32);
1182
+ if (changed)
1183
+ void queueApprovedPlanProgressSync();
1184
+ }
1091
1185
  // Forward every relevant bus event to the webview.
1092
- session.eventBus.on("text_delta", (d) => broadcast("text_delta", d));
1186
+ session.eventBus.on("text_delta", (d) => {
1187
+ broadcast("text_delta", d);
1188
+ recordApprovedPlanMarkers(d.text);
1189
+ });
1093
1190
  session.eventBus.on("thinking_delta", (d) => broadcast("thinking_delta", d));
1094
1191
  session.eventBus.on("tool_call_start", (d) => {
1095
1192
  toolCallNames.set(d.toolCallId, d.name);
@@ -1106,6 +1203,11 @@ async function createSession(deps, opts) {
1106
1203
  ...(d.isError ? { result: d.result.slice(0, 500) } : {}),
1107
1204
  });
1108
1205
  broadcast("tool_call_end", d);
1206
+ // Any tool can mutate the approved plan (including bash), so refresh after
1207
+ // every completed call while tracking is active. The file is tiny and this
1208
+ // keeps the displayed total aligned before the next completion marker.
1209
+ if (approvedPlanPath !== null)
1210
+ void queueApprovedPlanProgressSync();
1109
1211
  });
1110
1212
  // Native server tools (e.g. Anthropic web_search) do NOT end the turn — text
1111
1213
  // streams before and after them in the SAME turn. The webview must reset its
@@ -1123,7 +1225,13 @@ async function createSession(deps, opts) {
1123
1225
  session.eventBus.on("compaction_start", (d) => broadcast("compaction_start", d));
1124
1226
  session.eventBus.on("compaction_end", (d) => broadcast("compaction_end", d));
1125
1227
  let running = false;
1126
- let titleGenerated = false;
1228
+ const runLifecycle = new RunLifecycle((runState) => {
1229
+ running = runState !== "idle";
1230
+ if (runState === "cancelling")
1231
+ broadcast("run_cancelling", { runState });
1232
+ });
1233
+ const cancelledRunEndGenerations = new Set();
1234
+ let pendingCancelDrain = null;
1127
1235
  // Bumped by /cancel — a run whose cancel generation changed mid-flight was
1128
1236
  // canceled and earns no XP.
1129
1237
  let cancelGeneration = 0;
@@ -1145,6 +1253,7 @@ async function createSession(deps, opts) {
1145
1253
  let autopilotCancelled = false;
1146
1254
  // Hard cap on review→prompt→review rounds per user turn (loop safety).
1147
1255
  const MAX_AUTOPILOT_ROUNDS = 3;
1256
+ const CANCEL_TIMEOUT_MS = 5_000;
1148
1257
  // Prompt bodies Autopilot Ken injected into the BUILD session this
1149
1258
  // conversation. Passed into every Ken digest so injected prompts render as
1150
1259
  // "Ken autopilot (injected)" instead of `**User:**` — otherwise multi-round
@@ -1337,74 +1446,114 @@ async function createSession(deps, opts) {
1337
1446
  });
1338
1447
  return ken;
1339
1448
  }
1340
- // Resumed session: if it already has a conversation, generate its title now so
1341
- // the title bar shows it immediately on load (not just after the next prompt).
1342
- {
1343
- const hasHistory = session
1344
- .getMessages()
1345
- .some((m) => m.role === "user" || m.role === "assistant");
1346
- if (hasHistory) {
1347
- titleGenerated = true;
1348
- void session.generateTitle().then((title) => {
1349
- if (title)
1350
- broadcast("session_title", { title });
1351
- });
1352
- }
1449
+ function abortOwnedWork() {
1450
+ cancelGeneration++;
1451
+ abort.abort();
1452
+ // Stop a run-all sweep and every async child through AgentSession's signal.
1453
+ taskRunAll = false;
1454
+ autopilotCancelled = true;
1455
+ kenAutoAbort.abort();
1456
+ }
1457
+ function installFreshRunControllers() {
1458
+ abort = new AbortController();
1459
+ session.setSignal(abort.signal);
1460
+ kenAutoAbort = new AbortController();
1461
+ kenAutoSession?.setSignal(kenAutoAbort.signal);
1353
1462
  }
1354
- // Core run lifecycle shared by /prompt and the task runner: flips `running`,
1355
- // brackets the run with run_start/run_end, refreshes the footer extras, and
1356
- // generates the session title once. `label` is the text shown live with the
1357
- // run_start frame.
1463
+ function finishOwnedGeneration(generation, emitCancelledFallback) {
1464
+ const cancelled = runLifecycle.isCancellationRequested(generation);
1465
+ const settlement = runLifecycle.settle(generation);
1466
+ if (!settlement.settled)
1467
+ return cancelled;
1468
+ // A replacement signal is safe only after the provider-backed owner settled.
1469
+ installFreshRunControllers();
1470
+ if (cancelled && emitCancelledFallback && !cancelledRunEndGenerations.has(generation)) {
1471
+ cancelledRunEndGenerations.add(generation);
1472
+ broadcast("run_end", { cancelled: true, runState: runLifecycle.state });
1473
+ }
1474
+ return cancelled;
1475
+ }
1476
+ // Core provider-run bracket. Standalone runs own a lifecycle generation;
1477
+ // injected autopilot runs share the cycle's outer generation.
1358
1478
  async function runAgent(label, run) {
1359
- running = true;
1479
+ const ownsGeneration = !runLifecycle.running;
1480
+ const generation = ownsGeneration
1481
+ ? runLifecycle.begin(abortOwnedWork).generation
1482
+ : runLifecycle.generation;
1483
+ if (ownsGeneration)
1484
+ pendingCancelDrain = null;
1360
1485
  // Progress (Ranks): completed, non-canceled runs with ≥1 assistant turn earn
1361
1486
  // XP — prompt + any commits authored during the run window.
1362
1487
  const runStartedAt = Date.now();
1363
1488
  const cancelGenAtStart = cancelGeneration;
1364
1489
  const assistantsBeforeRun = countAssistantMessages(session.getMessages());
1365
1490
  let runSucceeded = false;
1366
- broadcast("run_start", { text: label });
1491
+ broadcast("run_start", { text: label, runState: runLifecycle.state });
1367
1492
  try {
1368
- await run();
1493
+ if (!runLifecycle.isCancellationRequested(generation))
1494
+ await run();
1369
1495
  runSucceeded = true;
1370
1496
  }
1371
1497
  catch (err) {
1372
- broadcastError("error", "run failed", err);
1498
+ if (!runLifecycle.isCancellationRequested(generation)) {
1499
+ broadcastError("error", "run failed", err);
1500
+ }
1373
1501
  }
1374
1502
  finally {
1375
- running = false;
1503
+ const cancelled = runLifecycle.isCancellationRequested(generation);
1376
1504
  if (runSucceeded &&
1505
+ !cancelled &&
1377
1506
  cancelGeneration === cancelGenAtStart &&
1378
1507
  countAssistantMessages(session.getMessages()) > assistantsBeforeRun) {
1379
1508
  // Fire-and-forget — XP must never delay or break run teardown.
1380
1509
  void progress.awardRun(cwd, runStartedAt, opts.id);
1381
1510
  }
1382
- // A run may have switched branches (git checkout) or spawned/finished
1383
- // background tasks refresh the footer extras once it settles.
1384
- gitBranch = await getGitBranch(cwd).catch(() => gitBranch);
1385
- gitIsRepo = await isGitRepo(cwd).catch(() => gitIsRepo);
1386
- broadcast("run_end", {});
1511
+ // A run may have switched branches, changed files, or spawned/finished
1512
+ // background tasks. Refresh the workspace extras once it settles.
1513
+ [gitBranch, gitIsRepo, gitDirtyFileCount] = await Promise.all([
1514
+ getGitBranch(cwd).catch(() => gitBranch),
1515
+ isGitRepo(cwd).catch(() => gitIsRepo),
1516
+ getGitDirtyFileCount(cwd).catch(() => gitDirtyFileCount),
1517
+ ]);
1518
+ // Serialize behind any marker/tool-triggered refresh so the terminal
1519
+ // progress snapshot uses the live plan file. Once every canonical step
1520
+ // is complete, remove the approved plan from future system prompts and
1521
+ // clear the widget before run_end paints the idle activity bar.
1522
+ if (runSucceeded &&
1523
+ !cancelled &&
1524
+ approvedPlanPath !== null &&
1525
+ (await queueApprovedPlanProgressSync())) {
1526
+ try {
1527
+ await session.setApprovedPlan(undefined);
1528
+ deactivateApprovedPlan();
1529
+ broadcast("plan_progress", { total: 0, completed: [] });
1530
+ }
1531
+ catch (error) {
1532
+ // Keep tracking when prompt cleanup fails; hiding the widget here
1533
+ // would claim completion while the approved-plan contract remained.
1534
+ log("WARN", "app-sidecar", "completed plan cleanup failed", {
1535
+ message: error instanceof Error ? error.message : String(error),
1536
+ });
1537
+ }
1538
+ }
1539
+ if (ownsGeneration)
1540
+ finishOwnedGeneration(generation, false);
1541
+ // A cancelled injected run is still owned by the surrounding autopilot
1542
+ // cycle; its outer finalizer emits the one terminal cancelled run_end.
1543
+ if (!(cancelled && !ownsGeneration)) {
1544
+ if (cancelled)
1545
+ cancelledRunEndGenerations.add(generation);
1546
+ broadcast("run_end", {
1547
+ ...(cancelled ? { cancelled: true } : {}),
1548
+ runState: runLifecycle.state,
1549
+ });
1550
+ }
1387
1551
  // Autopilot's review loop is driven explicitly from POST /prompt (see
1388
- // runAutopilotCycle), NOT from this shared finally — that keeps the
1389
- // injected GG Coder runs this cycle triggers from recursively re-entering
1390
- // the loop through the same bracket.
1391
- // The agent may have marked project tasks done during the run — prune the
1392
- // completed ones so they drop out of the Tasks modal automatically (users
1393
- // never have to delete finished tasks by hand).
1552
+ // runAutopilotCycle), NOT from this shared finally — that keeps injected
1553
+ // runs from recursively entering the same review loop.
1394
1554
  broadcast("tasks_list", { tasks: pruneDoneTasksSync(cwd) });
1395
- // Queue drains into the run as steering, so it's empty by run_end —
1396
- // sync the webview indicator.
1397
1555
  broadcast("queued", { count: session.getQueuedCount() });
1398
1556
  broadcast("extras", footerExtras());
1399
- // Generate a session title once, after the first run, for the title bar
1400
- // (best-effort, async — don't block the response).
1401
- if (!titleGenerated) {
1402
- titleGenerated = true;
1403
- void session.generateTitle().then((title) => {
1404
- if (title)
1405
- broadcast("session_title", { title });
1406
- });
1407
- }
1408
1557
  }
1409
1558
  }
1410
1559
  // ── Autopilot orchestration ─────────────────────────────────
@@ -1430,7 +1579,8 @@ async function createSession(deps, opts) {
1430
1579
  return parseAutopilotVerdict(lastAssistantText(ken.getMessages()));
1431
1580
  }
1432
1581
  catch (err) {
1433
- broadcastError("autopilot_error", "autopilot review failed", err);
1582
+ if (!autopilotCancelled)
1583
+ broadcastError("autopilot_error", "autopilot review failed", err);
1434
1584
  return null;
1435
1585
  }
1436
1586
  finally {
@@ -1505,6 +1655,8 @@ async function createSession(deps, opts) {
1505
1655
  async function runAutopilotCycle(originalRequest) {
1506
1656
  if (!autopilot || autopilotCancelled)
1507
1657
  return;
1658
+ const generation = runLifecycle.begin(abortOwnedWork).generation;
1659
+ pendingCancelDrain = null;
1508
1660
  autopilotActive = true;
1509
1661
  // Generation captured by the last plan review; acceptPlan re-checks it so
1510
1662
  // a user Accept/Reject landing mid-review always wins.
@@ -1532,22 +1684,22 @@ async function createSession(deps, opts) {
1532
1684
  if (pendingPlanPath === null || planGeneration !== planGenAtReview)
1533
1685
  return false;
1534
1686
  const planPath = pendingPlanPath;
1687
+ let planTotal;
1535
1688
  try {
1536
- await session.newSession();
1689
+ await session.newSession(true);
1537
1690
  injectedAutopilotPrompts = [];
1538
- titleGenerated = false;
1539
- await session.setApprovedPlan(planPath);
1691
+ planTotal = await activateApprovedPlan(planPath);
1540
1692
  }
1541
1693
  catch (err) {
1542
1694
  broadcastError("autopilot_error", "autopilot plan accept failed", err);
1543
1695
  return false;
1544
1696
  }
1545
1697
  clearPendingPlan();
1546
- // Ordering is load-bearing: the webview reads its still-open plan
1547
- // modal state (step count) on autopilot_plan_accepted, and
1548
- // session_reset clears it — accepted must land first.
1698
+ // Keep the approval marker ahead of the reset, then seed the reset
1699
+ // with the sidecar's canonical count from the actual plan file.
1549
1700
  broadcast("autopilot_plan_accepted", {});
1550
- broadcast("session_reset", {});
1701
+ broadcast("session_reset", { planTotal });
1702
+ broadcast("plan_progress", planProgressPayload());
1551
1703
  // Persisted into the NEW session so a resume shows the marker.
1552
1704
  void session.persistAutopilotMarker("plan_approved");
1553
1705
  return true;
@@ -1619,6 +1771,8 @@ async function createSession(deps, opts) {
1619
1771
  }
1620
1772
  finally {
1621
1773
  autopilotActive = false;
1774
+ finishOwnedGeneration(generation, true);
1775
+ queueMicrotask(() => void runStrandedQueue());
1622
1776
  }
1623
1777
  }
1624
1778
  // ── Stranded-queue drain ───────────────────────────────
@@ -1703,9 +1857,9 @@ async function createSession(deps, opts) {
1703
1857
  return false;
1704
1858
  // Fresh session per task so one task's context never bleeds into the next.
1705
1859
  await session.newSession();
1860
+ deactivateApprovedPlan();
1706
1861
  injectedAutopilotPrompts = [];
1707
1862
  clearPendingPlan();
1708
- titleGenerated = false;
1709
1863
  broadcast("session_reset", {});
1710
1864
  markTaskInProgress(cwd, task.id);
1711
1865
  broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
@@ -1786,6 +1940,30 @@ async function createSession(deps, opts) {
1786
1940
  tasksPoll.unref?.();
1787
1941
  };
1788
1942
  scheduleTasksPoll(1500);
1943
+ // Files can change outside the agent (editor saves, terminal commits), so keep
1944
+ // the dirty count current while idle. Branch/repo state already refreshes after
1945
+ // agent runs; polling only the count avoids spawning three git processes per tick.
1946
+ let gitPoll;
1947
+ let gitPollStopped = false;
1948
+ const scheduleGitPoll = (delay) => {
1949
+ if (gitPollStopped)
1950
+ return;
1951
+ gitPoll = setTimeout(() => {
1952
+ void getGitDirtyFileCount(cwd)
1953
+ .catch(() => gitDirtyFileCount)
1954
+ .then((nextDirtyFileCount) => {
1955
+ if (gitPollStopped)
1956
+ return;
1957
+ if (nextDirtyFileCount !== gitDirtyFileCount) {
1958
+ gitDirtyFileCount = nextDirtyFileCount;
1959
+ broadcast("extras", footerExtras());
1960
+ }
1961
+ scheduleGitPoll(5000);
1962
+ });
1963
+ }, delay);
1964
+ gitPoll.unref?.();
1965
+ };
1966
+ scheduleGitPoll(5000);
1789
1967
  function readBody(req) {
1790
1968
  return new Promise((resolve, reject) => {
1791
1969
  const chunks = [];
@@ -1795,7 +1973,7 @@ async function createSession(deps, opts) {
1795
1973
  });
1796
1974
  }
1797
1975
  function json(res, status, body) {
1798
- const payload = JSON.stringify(body);
1976
+ const payload = JSON.stringify(redactValue(body, { secrets: environmentSecrets(process.env) }));
1799
1977
  res.writeHead(status, {
1800
1978
  "content-type": "application/json",
1801
1979
  "access-control-allow-origin": "*",
@@ -1811,6 +1989,7 @@ async function createSession(deps, opts) {
1811
1989
  mode,
1812
1990
  chatAgent,
1813
1991
  running,
1992
+ runState: runLifecycle.state,
1814
1993
  ready: true,
1815
1994
  thinkingLevel: session.getThinkingLevel() ?? null,
1816
1995
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
@@ -1883,6 +2062,7 @@ async function createSession(deps, opts) {
1883
2062
  mode,
1884
2063
  chatAgent,
1885
2064
  running,
2065
+ runState: runLifecycle.state,
1886
2066
  thinkingLevel: session.getThinkingLevel() ?? null,
1887
2067
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
1888
2068
  supportsVideo: getModel(st.model)?.supportsVideo ?? false,
@@ -2378,6 +2558,13 @@ async function createSession(deps, opts) {
2378
2558
  json(res, 400, { error: "empty prompt" });
2379
2559
  return;
2380
2560
  }
2561
+ if (runLifecycle.running && runLifecycle.isCancellationRequested(runLifecycle.generation)) {
2562
+ json(res, 409, {
2563
+ error: runLifecycle.state === "cancelling" ? "run_cancelling" : "cancel_failed",
2564
+ runState: runLifecycle.state,
2565
+ });
2566
+ return;
2567
+ }
2381
2568
  if (running || autopilotActive) {
2382
2569
  // Queue prompts as mid-run steering (mirrors the CLI). Also queue while
2383
2570
  // an autopilot cycle is active but between injected runs (build idle,
@@ -2866,26 +3053,50 @@ async function createSession(deps, opts) {
2866
3053
  return;
2867
3054
  }
2868
3055
  if (method === "POST" && url === "/cancel") {
2869
- cancelGeneration++;
2870
- abort.abort();
2871
- abort = new AbortController();
2872
- session.setSignal(abort.signal);
2873
- running = false;
2874
- // Stop a run-all sweep so the next pending task isn't auto-started.
2875
- taskRunAll = false;
2876
- // Stop any in-flight autopilot cycle: flag it so the loop bails between
2877
- // steps, and abort a review that's mid-prompt on the kenAuto session.
2878
- autopilotCancelled = true;
2879
- kenAutoAbort.abort();
2880
- kenAutoAbort = new AbortController();
2881
- kenAutoSession?.setSignal(kenAutoAbort.signal);
2882
- autopilotReviewing = false;
2883
- // Drop any queued steering and return it so the webview can restore it to
2884
- // the composer.
2885
- const drained = session.drainQueue();
2886
- broadcast("run_end", { cancelled: true });
2887
- broadcast("queued", { count: 0 });
2888
- json(res, 200, { cancelled: true, drained });
3056
+ void (async () => {
3057
+ // Even between task runs, cancellation stops the sweep. Active provider
3058
+ // ownership invokes the full abort hook exactly once through lifecycle.
3059
+ taskRunAll = false;
3060
+ autopilotCancelled = true;
3061
+ if (!runLifecycle.running) {
3062
+ kenAutoAbort.abort();
3063
+ kenAutoAbort = new AbortController();
3064
+ kenAutoSession?.setSignal(kenAutoAbort.signal);
3065
+ }
3066
+ const generation = runLifecycle.generation;
3067
+ if (!pendingCancelDrain || pendingCancelDrain.generation !== generation) {
3068
+ pendingCancelDrain = { generation, text: session.drainQueue() };
3069
+ broadcast("queued", { count: 0 });
3070
+ }
3071
+ const result = await runLifecycle.cancel(CANCEL_TIMEOUT_MS);
3072
+ const drained = pendingCancelDrain.text;
3073
+ if (result.status === "failed") {
3074
+ broadcast("cancel_failed", {
3075
+ error: "cancel_failed",
3076
+ reason: result.reason,
3077
+ runState: runLifecycle.state,
3078
+ });
3079
+ json(res, 504, {
3080
+ error: "cancel_failed",
3081
+ reason: result.reason,
3082
+ runState: runLifecycle.state,
3083
+ drained,
3084
+ });
3085
+ return;
3086
+ }
3087
+ json(res, 200, {
3088
+ cancelled: result.status === "cancelled",
3089
+ runState: runLifecycle.state,
3090
+ drained,
3091
+ });
3092
+ })().catch((error) => {
3093
+ broadcast("cancel_failed", { error: "cancel_failed", runState: runLifecycle.state });
3094
+ json(res, 500, {
3095
+ error: "cancel_failed",
3096
+ message: error instanceof Error ? error.message : String(error),
3097
+ runState: runLifecycle.state,
3098
+ });
3099
+ });
2889
3100
  return;
2890
3101
  }
2891
3102
  if (method === "POST" && url === "/new-session") {
@@ -2899,6 +3110,7 @@ async function createSession(deps, opts) {
2899
3110
  if (mode === "chat") {
2900
3111
  await session.persistAppMarker("agent_handoff", { chatAgent });
2901
3112
  }
3113
+ deactivateApprovedPlan();
2902
3114
  injectedAutopilotPrompts = [];
2903
3115
  clearPendingPlan();
2904
3116
  broadcast("session_reset", {});
@@ -2951,12 +3163,12 @@ async function createSession(deps, opts) {
2951
3163
  broadcast("autopilot_ignored", {});
2952
3164
  }
2953
3165
  try {
2954
- await session.newSession();
3166
+ await session.newSession(true);
2955
3167
  injectedAutopilotPrompts = [];
2956
- titleGenerated = false;
2957
- await session.setApprovedPlan(planPath);
2958
- broadcast("session_reset", {});
2959
- json(res, 200, { ok: true });
3168
+ const planTotal = await activateApprovedPlan(planPath);
3169
+ broadcast("session_reset", { planTotal });
3170
+ broadcast("plan_progress", planProgressPayload());
3171
+ json(res, 200, { ok: true, planTotal });
2960
3172
  }
2961
3173
  catch (err) {
2962
3174
  json(res, 500, { error: err instanceof Error ? err.message : String(err) });
@@ -3384,6 +3596,9 @@ async function createSession(deps, opts) {
3384
3596
  tasksPollStopped = true;
3385
3597
  if (tasksPoll)
3386
3598
  clearTimeout(tasksPoll);
3599
+ gitPollStopped = true;
3600
+ if (gitPoll)
3601
+ clearTimeout(gitPoll);
3387
3602
  // Stop the Telegram serve loop + dispose its per-chat sessions.
3388
3603
  if (serveController)
3389
3604
  await serveController.stop().catch(() => { });