@kenkaiiii/ggcoder 5.26.1 → 5.26.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.
@@ -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";
@@ -1532,6 +1533,10 @@ async function createSession(deps, opts) {
1532
1533
  recordApprovedPlanMarkers(d.text);
1533
1534
  });
1534
1535
  session.eventBus.on("thinking_delta", (d) => broadcast("thinking_delta", d));
1536
+ // The agent consumed queued steering at a turn boundary. Re-broadcast as the
1537
+ // usual `queued` depth update so the webview drops the pending affordance the
1538
+ // moment the message lands in the loop, not at run_end.
1539
+ session.eventBus.on("queue_drained", (d) => broadcast("queued", { count: d.count, messages: session.listQueuedMessages() }));
1535
1540
  session.eventBus.on("tool_call_start", (d) => {
1536
1541
  toolCallNames.set(d.toolCallId, d.name);
1537
1542
  broadcast("tool_call_start", d);
@@ -1577,6 +1582,10 @@ async function createSession(deps, opts) {
1577
1582
  session.eventBus.on("compaction_start", (d) => broadcast("compaction_start", d));
1578
1583
  session.eventBus.on("compaction_end", (d) => broadcast("compaction_end", d));
1579
1584
  let running = false;
1585
+ // Closes the window between `/prompt` deciding to start a run and `runAgent`
1586
+ // flipping `running` — that stretch awaits, so Node yields inside it. See
1587
+ // RunClaim.
1588
+ const runClaim = new RunClaim();
1580
1589
  const runLifecycle = new RunLifecycle((runState) => {
1581
1590
  running = runState !== "idle";
1582
1591
  if (runState === "cancelling")
@@ -1913,7 +1922,10 @@ async function createSession(deps, opts) {
1913
1922
  // runAutopilotCycle), NOT from this shared finally — that keeps injected
1914
1923
  // runs from recursively entering the same review loop.
1915
1924
  broadcast("tasks_list", { tasks: pruneDoneTasksSync(cwd) });
1916
- broadcast("queued", { count: session.getQueuedCount() });
1925
+ broadcast("queued", {
1926
+ count: session.getQueuedCount(),
1927
+ messages: session.listQueuedMessages(),
1928
+ });
1917
1929
  broadcast("extras", footerExtras());
1918
1930
  }
1919
1931
  }
@@ -2159,7 +2171,10 @@ async function createSession(deps, opts) {
2159
2171
  const next = session.takeNextQueuedMessage();
2160
2172
  if (!next)
2161
2173
  return;
2162
- broadcast("queued", { count: session.getQueuedCount() });
2174
+ broadcast("queued", {
2175
+ count: session.getQueuedCount(),
2176
+ messages: session.listQueuedMessages(),
2177
+ });
2163
2178
  if (!next.text.trim() && next.attachments.length === 0)
2164
2179
  continue;
2165
2180
  // A queued message draining as a fresh turn supersedes any pending
@@ -2965,7 +2980,12 @@ async function createSession(deps, opts) {
2965
2980
  return;
2966
2981
  }
2967
2982
  if (method === "POST" && url === "/prompt") {
2968
- void readBody(req, res).then(async (raw) => {
2983
+ // Per-request: only the prompt that actually claimed the start may release
2984
+ // it. A bare release would let an early-returning request (bad JSON, or a
2985
+ // prompt that queued) clear a claim another request is still holding.
2986
+ let claimedStart = false;
2987
+ void readBody(req, res)
2988
+ .then(async (raw) => {
2969
2989
  if (raw === null)
2970
2990
  return;
2971
2991
  let text;
@@ -2985,14 +3005,17 @@ async function createSession(deps, opts) {
2985
3005
  json(res, 400, { error: "empty prompt" });
2986
3006
  return;
2987
3007
  }
2988
- if (runLifecycle.running && runLifecycle.isCancellationRequested(runLifecycle.generation)) {
3008
+ if (runLifecycle.running &&
3009
+ runLifecycle.isCancellationRequested(runLifecycle.generation)) {
2989
3010
  json(res, 409, {
2990
3011
  error: runLifecycle.state === "cancelling" ? "run_cancelling" : "cancel_failed",
2991
3012
  runState: runLifecycle.state,
2992
3013
  });
2993
3014
  return;
2994
3015
  }
2995
- if (running || autopilotActive) {
3016
+ // `runClaim` covers the gap before `runAgent` flips `running`: a
3017
+ // prompt arriving in that window must queue, not start a second run.
3018
+ if (running || runClaim.active || autopilotActive) {
2996
3019
  // Queue prompts as mid-run steering (mirrors the CLI). Also queue while
2997
3020
  // an autopilot cycle is active but between injected runs (build idle,
2998
3021
  // Ken reviewing) so the message never starts a run that collides with
@@ -3001,10 +3024,13 @@ async function createSession(deps, opts) {
3001
3024
  // path as a non-queued attachment prompt when it drains.
3002
3025
  const prepared = attachments.length > 0 ? await prepareAttachments(cwd, attachments) : [];
3003
3026
  const count = session.queueMessage(text, prepared);
3004
- broadcast("queued", { count });
3027
+ broadcast("queued", { count, messages: session.listQueuedMessages() });
3005
3028
  json(res, 202, { queued: true, count });
3006
3029
  return;
3007
3030
  }
3031
+ // Claim the run NOW, synchronously. Everything below this line may
3032
+ // yield, and `running` does not flip until runAgent begins.
3033
+ claimedStart = runClaim.claim();
3008
3034
  json(res, 202, { accepted: true });
3009
3035
  // Webview display hint for this prompt's user bubble (kenSent shimmer
3010
3036
  // label / enhancer highlight segments). Anchored +1 so it attaches to
@@ -3030,7 +3056,8 @@ async function createSession(deps, opts) {
3030
3056
  // how many assistant messages the run actually adds. Computed even when
3031
3057
  // autopilot is currently off — the toggle can flip ON mid-run, and the
3032
3058
  // gate reads the post-run value.
3033
- const workflowCommand = attachments.length === 0 && isWorkflowCommandText(text, await loadWorkflowCommandSpecs());
3059
+ const workflowCommand = attachments.length === 0 &&
3060
+ isWorkflowCommandText(text, await loadWorkflowCommandSpecs());
3034
3061
  const assistantsBefore = countAssistantMessages(session.getMessages());
3035
3062
  const messagesBefore = session.getMessages().length;
3036
3063
  await runAgent(text, async () => {
@@ -3082,6 +3109,10 @@ async function createSession(deps, opts) {
3082
3109
  // A prompt sent while Ken was reviewing (build idle) queued but had no
3083
3110
  // run to steer into — run it now as a fresh turn so it never strands.
3084
3111
  await runStrandedQueue();
3112
+ })
3113
+ .finally(() => {
3114
+ if (claimedStart)
3115
+ runClaim.release();
3085
3116
  });
3086
3117
  return;
3087
3118
  }
@@ -3485,6 +3516,38 @@ async function createSession(deps, opts) {
3485
3516
  });
3486
3517
  return;
3487
3518
  }
3519
+ // Pending queued steering, for the composer's cancel affordance.
3520
+ if (method === "GET" && url === "/queued") {
3521
+ json(res, 200, { queued: session.listQueuedMessages() });
3522
+ return;
3523
+ }
3524
+ // Cancel one pending queued message by id.
3525
+ if (method === "POST" && url === "/queued/cancel") {
3526
+ void readBody(req, res).then((raw) => {
3527
+ if (raw === null)
3528
+ return;
3529
+ let id;
3530
+ try {
3531
+ id = JSON.parse(raw).id ?? "";
3532
+ }
3533
+ catch {
3534
+ json(res, 400, { error: "invalid JSON body" });
3535
+ return;
3536
+ }
3537
+ if (!id.trim()) {
3538
+ json(res, 400, { error: "missing queued message id" });
3539
+ return;
3540
+ }
3541
+ // `false` means it already drained into the run between render and
3542
+ // click. That is a race, not an error, so report it as a normal result
3543
+ // and let the client reconcile from the fresh list.
3544
+ const cancelled = session.cancelQueuedMessage(id);
3545
+ const queued = session.listQueuedMessages();
3546
+ broadcast("queued", { count: queued.length, messages: queued });
3547
+ json(res, 200, { cancelled, queued });
3548
+ });
3549
+ return;
3550
+ }
3488
3551
  if (method === "POST" && url === "/kill") {
3489
3552
  void readBody(req, res).then(async (raw) => {
3490
3553
  if (raw === null)
@@ -3542,7 +3605,7 @@ async function createSession(deps, opts) {
3542
3605
  const generation = runLifecycle.generation;
3543
3606
  if (!pendingCancelDrain || pendingCancelDrain.generation !== generation) {
3544
3607
  pendingCancelDrain = { generation, text: session.drainQueue() };
3545
- broadcast("queued", { count: 0 });
3608
+ broadcast("queued", { count: 0, messages: [] });
3546
3609
  }
3547
3610
  const result = await runLifecycle.cancel(CANCEL_TIMEOUT_MS);
3548
3611
  const drained = pendingCancelDrain.text;