@kenkaiiii/ggcoder 5.26.1 → 5.26.3

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 (44) hide show
  1. package/dist/app-sidecar.js +123 -53
  2. package/dist/app-sidecar.js.map +1 -1
  3. package/dist/core/agent-session-marker-anchors.test.js +181 -2
  4. package/dist/core/agent-session-marker-anchors.test.js.map +1 -1
  5. package/dist/core/agent-session-queue.test.js +65 -0
  6. package/dist/core/agent-session-queue.test.js.map +1 -1
  7. package/dist/core/agent-session-slash-restore.test.d.ts +2 -0
  8. package/dist/core/agent-session-slash-restore.test.d.ts.map +1 -0
  9. package/dist/core/agent-session-slash-restore.test.js +121 -0
  10. package/dist/core/agent-session-slash-restore.test.js.map +1 -0
  11. package/dist/core/agent-session.d.ts +37 -0
  12. package/dist/core/agent-session.d.ts.map +1 -1
  13. package/dist/core/agent-session.js +126 -31
  14. package/dist/core/agent-session.js.map +1 -1
  15. package/dist/core/compaction/compactor.d.ts +21 -0
  16. package/dist/core/compaction/compactor.d.ts.map +1 -1
  17. package/dist/core/compaction/compactor.js +16 -0
  18. package/dist/core/compaction/compactor.js.map +1 -1
  19. package/dist/core/compaction/compactor.test.js +91 -0
  20. package/dist/core/compaction/compactor.test.js.map +1 -1
  21. package/dist/core/event-bus.d.ts +7 -0
  22. package/dist/core/event-bus.d.ts.map +1 -1
  23. package/dist/core/event-bus.js.map +1 -1
  24. package/dist/core/run-claim.d.ts +37 -0
  25. package/dist/core/run-claim.d.ts.map +1 -0
  26. package/dist/core/run-claim.js +46 -0
  27. package/dist/core/run-claim.js.map +1 -0
  28. package/dist/core/run-claim.test.d.ts +2 -0
  29. package/dist/core/run-claim.test.d.ts.map +1 -0
  30. package/dist/core/run-claim.test.js +107 -0
  31. package/dist/core/run-claim.test.js.map +1 -0
  32. package/dist/core/session-history.d.ts +61 -4
  33. package/dist/core/session-history.d.ts.map +1 -1
  34. package/dist/core/session-history.js +144 -15
  35. package/dist/core/session-history.js.map +1 -1
  36. package/dist/core/session-history.test.js +50 -1
  37. package/dist/core/session-history.test.js.map +1 -1
  38. package/dist/core/session-manager.d.ts +30 -5
  39. package/dist/core/session-manager.d.ts.map +1 -1
  40. package/dist/core/session-manager.js +37 -9
  41. package/dist/core/session-manager.js.map +1 -1
  42. package/dist/core/session-manager.test.js +14 -2
  43. package/dist/core/session-manager.test.js.map +1 -1
  44. package/package.json +4 -4
@@ -24,6 +24,7 @@ import { runSubagentWorkerMode } from "./modes/subagent-worker-mode.js";
24
24
  import { setStreamDiagnostic } from "@kenkaiiii/gg-agent";
25
25
  import { AgentSession } from "./core/agent-session.js";
26
26
  import { RunLifecycle } from "./core/run-lifecycle.js";
27
+ import { RunClaim } from "./core/run-claim.js";
27
28
  import { CHAT_AGENT_IDS, chatAgentSessionsDir, createChatAgent, parseChatAgentId, switchChatAgent, } from "./chat-agents/index.js";
28
29
  import { buildJiwaTools, JiwaStore } from "./chat-agents/jiwa.js";
29
30
  import { buildMemoryTools, MemoryStore } from "./chat-agents/memory.js";
@@ -33,7 +34,7 @@ import { parseAutopilotVerdict } from "./core/autopilot-verdict.js";
33
34
  import { isWorkflowCommandText, countAssistantMessages, shouldStartAutopilotCycle, extractTurnToolCalls, isMechanicalOnlyTurn, } from "./core/autopilot-gate.js";
34
35
  import { driveAutopilotCycle, frameAutopilotInjection } from "./core/autopilot-cycle.js";
35
36
  import { validateKenModelPref, effectiveKenModel } from "./core/ken-model.js";
36
- import { normalizeAutopilotMarkersForHistory, normalizeAppMarkersForHistory, normalizeKenTurnsForHistory, restoreUserRow, restoreAssistantTexts, autopilotMarkerCopySeed, } from "./core/session-history.js";
37
+ import { normalizeAutopilotMarkersForHistory, normalizeAppMarkersForHistory, normalizeKenTurnsForHistory, restoreUserRow, restoreAssistantTexts, resolveRestoredCommand, autopilotMarkerCopySeed, } from "./core/session-history.js";
37
38
  import { sessionToMarkdown, defaultExportFilename, } from "./core/session-export.js";
38
39
  import { AuthStorage } from "./core/auth-storage.js";
39
40
  import { cleanupToolOutputs } from "./tools/overflow.js";
@@ -363,30 +364,6 @@ function detectHookKind(text) {
363
364
  return "regrounding";
364
365
  return null;
365
366
  }
366
- // Separator AgentSession.prompt() inserts between a command's prompt body and
367
- // the user's trailing args. Must stay in sync with the expansion there.
368
- const COMMAND_ARGS_SEP = "\n\n## User Instructions\n\n";
369
- /**
370
- * Reverse a prompt-template command's expansion. When a `/name` command runs,
371
- * the agent persists the FULL expanded prompt body as the user message — so on
372
- * resume the raw body would render instead of the short `/name` chip the user
373
- * saw live. Given the candidate commands (built-in + custom) and a restored
374
- * message body, recover the original `/name [args]` invocation. Returns null
375
- * when the text isn't a known command body (an ordinary user message).
376
- */
377
- function detectPromptCommand(text, candidates) {
378
- for (const c of candidates) {
379
- if (!c.prompt)
380
- continue;
381
- if (text === c.prompt)
382
- return `/${c.name}`;
383
- if (text.startsWith(c.prompt + COMMAND_ARGS_SEP)) {
384
- const args = text.slice(c.prompt.length + COMMAND_ARGS_SEP.length).trim();
385
- return args ? `/${c.name} ${args}` : `/${c.name}`;
386
- }
387
- }
388
- return null;
389
- }
390
367
  /** A short transport summary for display (URL for http/sse, command+args for stdio). */
391
368
  function mcpRowSummary(config) {
392
369
  if (config.url)
@@ -1532,6 +1509,10 @@ async function createSession(deps, opts) {
1532
1509
  recordApprovedPlanMarkers(d.text);
1533
1510
  });
1534
1511
  session.eventBus.on("thinking_delta", (d) => broadcast("thinking_delta", d));
1512
+ // The agent consumed queued steering at a turn boundary. Re-broadcast as the
1513
+ // usual `queued` depth update so the webview drops the pending affordance the
1514
+ // moment the message lands in the loop, not at run_end.
1515
+ session.eventBus.on("queue_drained", (d) => broadcast("queued", { count: d.count, messages: session.listQueuedMessages() }));
1535
1516
  session.eventBus.on("tool_call_start", (d) => {
1536
1517
  toolCallNames.set(d.toolCallId, d.name);
1537
1518
  broadcast("tool_call_start", d);
@@ -1577,6 +1558,10 @@ async function createSession(deps, opts) {
1577
1558
  session.eventBus.on("compaction_start", (d) => broadcast("compaction_start", d));
1578
1559
  session.eventBus.on("compaction_end", (d) => broadcast("compaction_end", d));
1579
1560
  let running = false;
1561
+ // Closes the window between `/prompt` deciding to start a run and `runAgent`
1562
+ // flipping `running` — that stretch awaits, so Node yields inside it. See
1563
+ // RunClaim.
1564
+ const runClaim = new RunClaim();
1580
1565
  const runLifecycle = new RunLifecycle((runState) => {
1581
1566
  running = runState !== "idle";
1582
1567
  if (runState === "cancelling")
@@ -1913,7 +1898,10 @@ async function createSession(deps, opts) {
1913
1898
  // runAutopilotCycle), NOT from this shared finally — that keeps injected
1914
1899
  // runs from recursively entering the same review loop.
1915
1900
  broadcast("tasks_list", { tasks: pruneDoneTasksSync(cwd) });
1916
- broadcast("queued", { count: session.getQueuedCount() });
1901
+ broadcast("queued", {
1902
+ count: session.getQueuedCount(),
1903
+ messages: session.listQueuedMessages(),
1904
+ });
1917
1905
  broadcast("extras", footerExtras());
1918
1906
  }
1919
1907
  }
@@ -2109,12 +2097,14 @@ async function createSession(deps, opts) {
2109
2097
  // falling back to the raw verdict text (e.g. ALL_CLEAR).
2110
2098
  if (event.type === "autopilot_done") {
2111
2099
  // Broadcast the SAME copySeed the persisted marker will produce on
2112
- // resume, so the live all-clear wording matches the resumed one
2113
- // (computed before persistsame synchronous message count).
2100
+ // resume, so the live all-clear wording matches the resumed one.
2101
+ // Must use the PERSISTED count that's what persistAutopilotMarker
2102
+ // anchors against, and it trails the in-memory list after a run
2103
+ // whose messages never made it to disk.
2114
2104
  const seed = autopilotMarkerCopySeed({
2115
2105
  version: 1,
2116
2106
  phase: "done",
2117
- afterMessageCount: session.getMessages().filter((m) => m.role !== "system").length,
2107
+ afterMessageCount: session.getPersistedTranscriptCount(),
2118
2108
  });
2119
2109
  broadcast(event.type, { ...event.data, copySeed: seed });
2120
2110
  void session.persistAutopilotMarker("done");
@@ -2159,7 +2149,10 @@ async function createSession(deps, opts) {
2159
2149
  const next = session.takeNextQueuedMessage();
2160
2150
  if (!next)
2161
2151
  return;
2162
- broadcast("queued", { count: session.getQueuedCount() });
2152
+ broadcast("queued", {
2153
+ count: session.getQueuedCount(),
2154
+ messages: session.listQueuedMessages(),
2155
+ });
2163
2156
  if (!next.text.trim() && next.attachments.length === 0)
2164
2157
  continue;
2165
2158
  // A queued message draining as a fresh turn supersedes any pending
@@ -2683,9 +2676,9 @@ async function createSession(deps, opts) {
2683
2676
  // Autopilot verdict markers to interleave, same anchor scheme as Ken
2684
2677
  // turns — each becomes a single assistant row the webview renders
2685
2678
  // exactly like the live `autopilot` item (never a raw verdict string).
2686
- // Compact/continuation rewrites can carry old markers whose original
2687
- // afterMessageCount is beyond the restored message list; dropping those
2688
- // prevents stale all-clear bubbles from bunching at the bottom on resume.
2679
+ // Normalization pulls anchors left over from an unrebased compaction
2680
+ // back to where the marker was actually written, so stale all-clear
2681
+ // bubbles no longer bunch at the bottom of a reopened session.
2689
2682
  const restoredMessageCount = messages.filter((m) => m.role !== "system").length;
2690
2683
  const autopilotByCount = new Map();
2691
2684
  for (const marker of normalizeAutopilotMarkersForHistory(session.getAutopilotMarkers(), restoredMessageCount)) {
@@ -2835,9 +2828,21 @@ async function createSession(deps, opts) {
2835
2828
  const text = restored.text;
2836
2829
  const hook = detectHookKind(text);
2837
2830
  const compacted = !hook && text.startsWith("[Previous conversation summary]");
2838
- const command = !hook && !compacted ? detectPromptCommand(text, commandCandidates) : null;
2839
- if (text.trim() || restored.images.length > 0) {
2840
- const hint = userHintByCount.get(nonSystemCount);
2831
+ const hint = userHintByCount.get(nonSystemCount);
2832
+ // The typed invocation persisted alongside the prompt is
2833
+ // authoritative. Reversing the expanded body only works while the
2834
+ // template is byte-identical, and templates drift (edited
2835
+ // `.gg/commands/*.md`, reworded built-ins, app-vs-CLI phrasing) —
2836
+ // after which the resumed session dumped the raw multi-KB body
2837
+ // instead of the `/name` chip. Older sessions have no hint, so the
2838
+ // body match stays as the fallback.
2839
+ const command = !hook && !compacted
2840
+ ? resolveRestoredCommand(typeof hint?.command === "string" ? hint.command : null, text, commandCandidates)
2841
+ : null;
2842
+ // Autopilot injected this turn — live showed only the Ken-tinted
2843
+ // marker for it, never a user bubble. Emitting one here would print
2844
+ // the injected instruction a second time, unstyled.
2845
+ if (!restored.autopilotInjected && (text.trim() || restored.images.length > 0)) {
2841
2846
  history.push({
2842
2847
  role: "user",
2843
2848
  text: command ?? text,
@@ -2965,7 +2970,12 @@ async function createSession(deps, opts) {
2965
2970
  return;
2966
2971
  }
2967
2972
  if (method === "POST" && url === "/prompt") {
2968
- void readBody(req, res).then(async (raw) => {
2973
+ // Per-request: only the prompt that actually claimed the start may release
2974
+ // it. A bare release would let an early-returning request (bad JSON, or a
2975
+ // prompt that queued) clear a claim another request is still holding.
2976
+ let claimedStart = false;
2977
+ void readBody(req, res)
2978
+ .then(async (raw) => {
2969
2979
  if (raw === null)
2970
2980
  return;
2971
2981
  let text;
@@ -2985,14 +2995,17 @@ async function createSession(deps, opts) {
2985
2995
  json(res, 400, { error: "empty prompt" });
2986
2996
  return;
2987
2997
  }
2988
- if (runLifecycle.running && runLifecycle.isCancellationRequested(runLifecycle.generation)) {
2998
+ if (runLifecycle.running &&
2999
+ runLifecycle.isCancellationRequested(runLifecycle.generation)) {
2989
3000
  json(res, 409, {
2990
3001
  error: runLifecycle.state === "cancelling" ? "run_cancelling" : "cancel_failed",
2991
3002
  runState: runLifecycle.state,
2992
3003
  });
2993
3004
  return;
2994
3005
  }
2995
- if (running || autopilotActive) {
3006
+ // `runClaim` covers the gap before `runAgent` flips `running`: a
3007
+ // prompt arriving in that window must queue, not start a second run.
3008
+ if (running || runClaim.active || autopilotActive) {
2996
3009
  // Queue prompts as mid-run steering (mirrors the CLI). Also queue while
2997
3010
  // an autopilot cycle is active but between injected runs (build idle,
2998
3011
  // Ken reviewing) so the message never starts a run that collides with
@@ -3001,20 +3014,47 @@ async function createSession(deps, opts) {
3001
3014
  // path as a non-queued attachment prompt when it drains.
3002
3015
  const prepared = attachments.length > 0 ? await prepareAttachments(cwd, attachments) : [];
3003
3016
  const count = session.queueMessage(text, prepared);
3004
- broadcast("queued", { count });
3017
+ broadcast("queued", { count, messages: session.listQueuedMessages() });
3005
3018
  json(res, 202, { queued: true, count });
3006
3019
  return;
3007
3020
  }
3021
+ // Claim the run NOW, synchronously. Everything below this line may
3022
+ // yield, and `running` does not flip until runAgent begins.
3023
+ claimedStart = runClaim.claim();
3008
3024
  json(res, 202, { accepted: true });
3025
+ // Gate inputs captured around the run: whether this turn is a workflow
3026
+ // slash command (attachment prompts skip slash expansion entirely), and
3027
+ // how many assistant messages the run actually adds. Computed even when
3028
+ // autopilot is currently off — the toggle can flip ON mid-run, and the
3029
+ // gate reads the post-run value.
3030
+ const workflowCommand = attachments.length === 0 &&
3031
+ isWorkflowCommandText(text, await loadWorkflowCommandSpecs());
3032
+ // Does this input actually expand into a persisted user message? Asked
3033
+ // of the session itself, because only it knows whether the command
3034
+ // resolves here (name/alias casing, custom `.gg/commands`, non-coder
3035
+ // agents that don't expand at all). A looser guess would anchor the
3036
+ // hint at +1 with no message to land on — decorating an unrelated
3037
+ // later bubble with the wrong `/name`.
3038
+ const expandsToTemplate = attachments.length === 0 && (await session.willExpandPromptTemplate(text));
3009
3039
  // Webview display hint for this prompt's user bubble (kenSent shimmer
3010
- // label / enhancer highlight segments). Anchored +1 so it attaches to
3011
- // the user message the prompt below is about to push. Queued prompts
3012
- // skip this (their position in the run is unpredictable).
3013
- if (meta && (meta.kenSent === true || Array.isArray(meta.enhancements))) {
3040
+ // label / enhancer highlight segments / the `/name` a command was typed
3041
+ // as). Anchored +1 so it attaches to the user message the prompt below
3042
+ // is about to push. Queued prompts skip this (their position in the run
3043
+ // is unpredictable).
3044
+ //
3045
+ // Recording the invocation matters because the agent persists the
3046
+ // EXPANDED template as the user message. Resume used to recover
3047
+ // `/name` by matching that body against the current templates, which
3048
+ // silently fails the moment a template is edited or reworded — the
3049
+ // reopened session then rendered the raw multi-KB prompt instead of
3050
+ // the command chip.
3051
+ if (expandsToTemplate ||
3052
+ (meta && (meta.kenSent === true || Array.isArray(meta.enhancements)))) {
3014
3053
  void session
3015
3054
  .persistAppMarker("user_hint", {
3016
- ...(meta.kenSent === true ? { kenSent: true } : {}),
3017
- ...(Array.isArray(meta.enhancements) ? { enhancements: meta.enhancements } : {}),
3055
+ ...(expandsToTemplate ? { command: text.trim() } : {}),
3056
+ ...(meta?.kenSent === true ? { kenSent: true } : {}),
3057
+ ...(Array.isArray(meta?.enhancements) ? { enhancements: meta.enhancements } : {}),
3018
3058
  }, 1)
3019
3059
  .catch(() => { });
3020
3060
  }
@@ -3025,12 +3065,6 @@ async function createSession(deps, opts) {
3025
3065
  // feedback, anything) supersedes the pending plan — the bump also
3026
3066
  // invalidates any in-flight Ken plan review.
3027
3067
  clearPendingPlan();
3028
- // Gate inputs captured around the run: whether this turn is a workflow
3029
- // slash command (attachment prompts skip slash expansion entirely), and
3030
- // how many assistant messages the run actually adds. Computed even when
3031
- // autopilot is currently off — the toggle can flip ON mid-run, and the
3032
- // gate reads the post-run value.
3033
- const workflowCommand = attachments.length === 0 && isWorkflowCommandText(text, await loadWorkflowCommandSpecs());
3034
3068
  const assistantsBefore = countAssistantMessages(session.getMessages());
3035
3069
  const messagesBefore = session.getMessages().length;
3036
3070
  await runAgent(text, async () => {
@@ -3082,6 +3116,10 @@ async function createSession(deps, opts) {
3082
3116
  // A prompt sent while Ken was reviewing (build idle) queued but had no
3083
3117
  // run to steer into — run it now as a fresh turn so it never strands.
3084
3118
  await runStrandedQueue();
3119
+ })
3120
+ .finally(() => {
3121
+ if (claimedStart)
3122
+ runClaim.release();
3085
3123
  });
3086
3124
  return;
3087
3125
  }
@@ -3485,6 +3523,38 @@ async function createSession(deps, opts) {
3485
3523
  });
3486
3524
  return;
3487
3525
  }
3526
+ // Pending queued steering, for the composer's cancel affordance.
3527
+ if (method === "GET" && url === "/queued") {
3528
+ json(res, 200, { queued: session.listQueuedMessages() });
3529
+ return;
3530
+ }
3531
+ // Cancel one pending queued message by id.
3532
+ if (method === "POST" && url === "/queued/cancel") {
3533
+ void readBody(req, res).then((raw) => {
3534
+ if (raw === null)
3535
+ return;
3536
+ let id;
3537
+ try {
3538
+ id = JSON.parse(raw).id ?? "";
3539
+ }
3540
+ catch {
3541
+ json(res, 400, { error: "invalid JSON body" });
3542
+ return;
3543
+ }
3544
+ if (!id.trim()) {
3545
+ json(res, 400, { error: "missing queued message id" });
3546
+ return;
3547
+ }
3548
+ // `false` means it already drained into the run between render and
3549
+ // click. That is a race, not an error, so report it as a normal result
3550
+ // and let the client reconcile from the fresh list.
3551
+ const cancelled = session.cancelQueuedMessage(id);
3552
+ const queued = session.listQueuedMessages();
3553
+ broadcast("queued", { count: queued.length, messages: queued });
3554
+ json(res, 200, { cancelled, queued });
3555
+ });
3556
+ return;
3557
+ }
3488
3558
  if (method === "POST" && url === "/kill") {
3489
3559
  void readBody(req, res).then(async (raw) => {
3490
3560
  if (raw === null)
@@ -3542,7 +3612,7 @@ async function createSession(deps, opts) {
3542
3612
  const generation = runLifecycle.generation;
3543
3613
  if (!pendingCancelDrain || pendingCancelDrain.generation !== generation) {
3544
3614
  pendingCancelDrain = { generation, text: session.drainQueue() };
3545
- broadcast("queued", { count: 0 });
3615
+ broadcast("queued", { count: 0, messages: [] });
3546
3616
  }
3547
3617
  const result = await runLifecycle.cancel(CANCEL_TIMEOUT_MS);
3548
3618
  const drained = pendingCancelDrain.text;