@inetafrica/open-claudia 3.5.1 → 3.5.7

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/.env.example +13 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bin/cli.js +13 -5
  4. package/bin/loopback-client.js +4 -0
  5. package/bin/schedule.js +7 -2
  6. package/bot.js +6 -2
  7. package/channels/kazee/adapter.js +519 -64
  8. package/channels/kazee/state.js +88 -2
  9. package/channels/types.js +11 -5
  10. package/core/actions.js +6 -0
  11. package/core/approvals.js +9 -0
  12. package/core/context.js +11 -2
  13. package/core/handlers.js +161 -59
  14. package/core/identity.js +33 -3
  15. package/core/io.js +21 -8
  16. package/core/jobs.js +3 -0
  17. package/core/loopback.js +55 -7
  18. package/core/providers/claude.js +6 -2
  19. package/core/providers/utility-policy.js +3 -1
  20. package/core/router.js +25 -13
  21. package/core/run-context.js +49 -8
  22. package/core/runner.js +318 -122
  23. package/core/sandbox-probe.js +78 -0
  24. package/core/scheduler.js +45 -3
  25. package/core/side-chat.js +40 -11
  26. package/core/speaker-attest.js +2 -0
  27. package/core/state.js +317 -50
  28. package/core/system-prompt.js +61 -14
  29. package/core/thread-title.js +237 -0
  30. package/core/transcripts.js +23 -6
  31. package/package.json +3 -3
  32. package/test-approval-cards.js +24 -0
  33. package/test-call-speech.js +5 -3
  34. package/test-fixtures/fake-agent-cli.js +52 -1
  35. package/test-provider-compaction.js +50 -11
  36. package/test-provider-gateway.js +31 -10
  37. package/test-provider-language.js +7 -1
  38. package/test-provider-scheduler.js +77 -20
  39. package/test-provider-session-commands.js +11 -2
  40. package/test-provider-side-chat.js +30 -2
  41. package/test-run-lock.js +122 -39
  42. package/test-thread-lanes.js +530 -0
  43. package/core/queue-drain.js +0 -25
  44. package/test-queue-routing.js +0 -52
package/core/runner.js CHANGED
@@ -13,11 +13,12 @@ const {
13
13
  } = require("./config");
14
14
  const {
15
15
  currentState, saveState, recordSession,
16
- resetSessionUsage, acquireRunLock, cancelTargets, getActiveProvider, getProjectKey,
16
+ resetSessionUsage, admitLane, cancelTargets, getActiveProvider, getProjectKey,
17
+ laneFor, laneForRun, peekLane, laneBusy, runLaneKey, waitingLanes, slotBlocker, pruneIdleLanes,
17
18
  getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction,
18
19
  resolveProviderSettings,
19
20
  } = require("./state");
20
- const { chatContext, currentChannelId, currentAdapter } = require("./context");
21
+ const { chatContext, currentChannelId, currentAdapter, currentThreadId } = require("./context");
21
22
  const { buildTurnPrompt } = require("./system-prompt");
22
23
  const { getProvider, resolveProviderForRun } = require("./providers");
23
24
  const { validateInvocation } = require("./providers/contract");
@@ -942,10 +943,10 @@ function createSideChatService(dependencies = {}) {
942
943
  timeoutMs: dependencies.timeoutMs,
943
944
  killTree: dependencies.killTree,
944
945
  killGraceMs: dependencies.killGraceMs,
945
- onSpawn: dependencies.onSpawn || ((proc, runContext) => sideChatCoordinator.attachProcess(runContext.canonicalUserId, proc)),
946
+ onSpawn: dependencies.onSpawn || ((proc, runContext) => sideChatCoordinator.attachProcess(runContext.canonicalUserId, proc, runLaneKey(runContext))),
946
947
  onStderr: dependencies.onStderr || ((chunk) => console.error("SIDE PROVIDER STDERR:", redactSensitive(chunk))),
947
948
  cleanup: dependencies.cleanup || (async (proc, details, runContext) => {
948
- sideChatCoordinator.detachProcess(runContext.canonicalUserId, proc);
949
+ sideChatCoordinator.detachProcess(runContext.canonicalUserId, proc, runLaneKey(runContext));
949
950
  }),
950
951
  });
951
952
  return {
@@ -1005,7 +1006,7 @@ function effectiveCompactThreshold(state = currentState(), opts = {}) {
1005
1006
  }
1006
1007
 
1007
1008
  function shouldAutoCompact(state = currentState(), opts = {}) {
1008
- if (opts.skipAutoCompact || opts.fresh || opts.continueSession || state.isCompacting) return false;
1009
+ if (opts.skipAutoCompact || opts.fresh || opts.continueSession || peekLane(state, opts.laneKey)?.isCompacting) return false;
1009
1010
  if (!getProviderSession(state, getActiveProvider(state), getProjectKey(state))) return false;
1010
1011
  const threshold = effectiveCompactThreshold(state);
1011
1012
  if (threshold === 0) return false;
@@ -1308,10 +1309,14 @@ function createCompactionService(dependencies = {}) {
1308
1309
  const ackSchemaPath = dependencies.ackSchemaPath || ensureCompactionAckSchemaPath;
1309
1310
  const drainQueue = dependencies.drainQueue || drainQueuedMessages;
1310
1311
 
1312
+ // Compaction acts on ONE conversation: the lane it is asked from (/compact)
1313
+ // or the lane whose turn just finished (auto-compaction, admissionHeld).
1314
+ // Other conversations of the speaker keep running meanwhile.
1311
1315
  async function compact(cwd, opts = {}) {
1312
1316
  const state = getState();
1313
- if (state.isCompacting) return { compacted: false, reason: "Compaction already in progress." };
1314
- if (state.runningProcess || (state.preparingRun && !opts.admissionHeld)) {
1317
+ const lane = laneFor(state, opts.laneKey || null);
1318
+ if (lane.isCompacting) return { compacted: false, reason: "Compaction already in progress." };
1319
+ if (lane.runningProcess || ((lane.preparingRun || lane.settling) && !opts.admissionHeld)) {
1315
1320
  return { compacted: false, reason: "Finish or stop the active run before compacting." };
1316
1321
  }
1317
1322
  const provider = getActiveProvider(state);
@@ -1338,8 +1343,10 @@ function createCompactionService(dependencies = {}) {
1338
1343
  fresh: false,
1339
1344
  });
1340
1345
  const operationId = opts.operationId || (typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `compact-${Date.now()}`);
1341
- const recentTurns = collectRecent(state);
1342
- state.isCompacting = true;
1346
+ // The recent turns come from THIS conversation's transcript: the captured
1347
+ // project, not the state's legacy "Workspace" session (which read another file).
1348
+ const recentTurns = collectRecent(capturedStateView(state, summaryContext));
1349
+ lane.isCompacting = true;
1343
1350
  try {
1344
1351
  if (opts.notify) await send(opts.message || "Context is getting large, compacting first so this stays fast…");
1345
1352
  const summaryRun = await runCapture(summaryPrompt, summaryContext.cwd || cwd, {
@@ -1421,7 +1428,9 @@ function createCompactionService(dependencies = {}) {
1421
1428
  commit,
1422
1429
  };
1423
1430
  } finally {
1424
- state.isCompacting = false;
1431
+ lane.isCompacting = false;
1432
+ // A bare cancel flag names no run: whatever it was for is over now.
1433
+ if (lane.cancelRequested === true) lane.cancelRequested = false;
1425
1434
  try { await drainQueue(state); }
1426
1435
  catch (error) { console.warn(`[compact] queue drain failed: ${redactSensitive(error.message)}`); }
1427
1436
  }
@@ -1666,14 +1675,17 @@ async function persistForegroundSession(result, runContext, state = currentState
1666
1675
  // dropped). Success without narration → the preview only ever showed the
1667
1676
  // streamed answer itself; delete it so the final message isn't a duplicate.
1668
1677
  // Failure/cancel → keep the partial text but drop the lying "working" footer.
1669
- async function finalizeStreamPreview(state, result) {
1670
- if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
1671
- const id = state.statusMessageId;
1672
- const channel = state.statusMessageChannel;
1673
- const buffer = String(state.streamBuffer || "").trim();
1674
- state.statusMessageId = null;
1675
- state.statusMessageChannel = null;
1676
- state.streamBuffer = "";
1678
+ async function finalizeStreamPreview(lane, result, runContext = null) {
1679
+ // Only the run that owns the lane's preview may settle it.
1680
+ if (runContext && lane.previewRunId && lane.previewRunId !== runContext.runId) return;
1681
+ if (lane.streamInterval) { clearTimeout(lane.streamInterval); lane.streamInterval = null; }
1682
+ const id = lane.statusMessageId;
1683
+ const channel = lane.statusMessageChannel;
1684
+ const buffer = String(lane.streamBuffer || "").trim();
1685
+ lane.statusMessageId = null;
1686
+ lane.statusMessageChannel = null;
1687
+ lane.streamBuffer = "";
1688
+ lane.previewRunId = null;
1677
1689
  if (typeof id !== "string") return;
1678
1690
  // A message id is only valid on the channel that minted it; a foreign-
1679
1691
  // channel preview is left as an orphan rather than edited cross-transport.
@@ -1687,21 +1699,22 @@ async function finalizeStreamPreview(state, result) {
1687
1699
  // the preview so settled chat carries just the final answer. Verbose ON keeps
1688
1700
  // the narration trail. Either way the live preview still showed progress mid-run.
1689
1701
  const narration = String(result.narrationText || "").trim();
1690
- if (narration && state.settings?.showThinking) await editMessage(id, narration.slice(-4000), telegramHtmlOpts({ streaming: true }));
1702
+ if (narration && currentState().settings?.showThinking) await editMessage(id, narration.slice(-4000), telegramHtmlOpts({ streaming: true }));
1691
1703
  else await deleteMessage(id);
1692
1704
  } catch (_) { /* preview cleanup is best-effort; the final answer still goes out */ }
1693
1705
  }
1694
1706
 
1695
1707
  async function deliverForegroundResult(result, runContext, state = currentState()) {
1696
- if (runContext.lastInputWasVoice) state.lastInputWasVoice = false;
1697
- await finalizeStreamPreview(state, result);
1708
+ const lane = laneForRun(state, runContext);
1709
+ if (runContext.lastInputWasVoice) lane.lastInputWasVoice = false;
1710
+ await finalizeStreamPreview(lane, result, runContext);
1698
1711
  // /stop unwind: the SIGTERM (or pre-spawn abort) that follows a user cancel
1699
1712
  // is not a failure worth announcing — /stop already replied "Cancelled.".
1700
1713
  // The flag is consumed here (and re-cleared on the run's unwind) so it can
1701
1714
  // never suppress a later run's genuine failure; it names the run it stopped.
1702
- const stoppedHere = cancelTargets(state.cancelRequested, runContext);
1715
+ const stoppedHere = cancelTargets(lane.cancelRequested, runContext);
1703
1716
  if (!result.ok && (stoppedHere || result.error?.code === "RUN_CANCELLED")) {
1704
- if (stoppedHere) state.cancelRequested = false;
1717
+ if (stoppedHere) lane.cancelRequested = false;
1705
1718
  return { ok: true, suppressed: "cancelled" };
1706
1719
  }
1707
1720
  const diagnostic = providerRunDiagnostic(result, runContext);
@@ -1788,6 +1801,9 @@ function runPrincipal(runContext) {
1788
1801
  adapterId: origin.adapterId || null,
1789
1802
  adapterType: origin.transport || null,
1790
1803
  channelId: channelId || null,
1804
+ // The chat thread the run answers in (null for the main timeline), so the
1805
+ // loopback can route a send by the attested run rather than by env.
1806
+ threadId: runContext.threadId || null,
1791
1807
  userId: origin.userId || null,
1792
1808
  };
1793
1809
  if (!base.adapterType || !base.channelId) {
@@ -1809,6 +1825,7 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
1809
1825
  if (!compactionStage) {
1810
1826
  if (runContext.origin?.adapterId) env.OC_CHANNEL_ADAPTER = runContext.origin.adapterId;
1811
1827
  if (runContext.channelId) env.OC_CHANNEL_ID = String(runContext.channelId);
1828
+ if (runContext.threadId) env.OC_THREAD_ID = String(runContext.threadId);
1812
1829
  if (runContext.origin?.userId) env.OC_CHANNEL_USER_ID = String(runContext.origin.userId);
1813
1830
  if (runContext.canonicalUserId) {
1814
1831
  env.OC_CANONICAL_USER_ID = String(runContext.canonicalUserId);
@@ -1831,9 +1848,11 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
1831
1848
  env.OC_RUN_ATTEST = require("./speaker-attest").registerRun(runContext.runId, principal);
1832
1849
  // A non-owner's turn never sees the AI service credentials: Claude Code
1833
1850
  // strips its own auth variables from every subprocess it starts (shell,
1834
- // hooks) when this is set. The agent process itself keeps them.
1851
+ // hooks) when this is set. The agent process itself keeps them. Only where
1852
+ // the sandbox it needs can run (sandbox-probe.js): in the fleet's
1853
+ // containers it cannot, and the scrub made every staff turn crash.
1835
1854
  if (principal.owner !== true) {
1836
- env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB = "1";
1855
+ if (require("./sandbox-probe").staffEnvScrub().ok) env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB = "1";
1837
1856
  // Read by the permission hook (which inherits the agent's env, not the
1838
1857
  // agent's shell's) so a guard that cannot load fails closed on this run.
1839
1858
  env.OC_GUARDED_RUN = "1";
@@ -1850,35 +1869,57 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
1850
1869
  return { ...invocation, env };
1851
1870
  }
1852
1871
 
1853
- // Singleton per chat state: a new start supersedes any running heartbeat
1854
- // (pre-turn "thinking" started at message receipt hands off to the run's own
1855
- // heartbeat here). A stopper only acts while its own interval is current, so
1856
- // a stale stopper from a superseded start can never kill a newer heartbeat.
1857
- function startTypingHeartbeat(state, store) {
1872
+ // One heartbeat per lane: a new start in the same conversation supersedes the
1873
+ // running one (pre-turn "thinking" started at message receipt hands off to the
1874
+ // run's own heartbeat here), while other conversations keep their own. A
1875
+ // stopper only acts while its own interval is current, so a stale stopper from
1876
+ // a superseded start can never kill a newer heartbeat. Inside a chat thread
1877
+ // the indicator goes into the thread and is re-sent every 2.5 s (Kazee
1878
+ // expires a thread's typing indicator after 5 s).
1879
+ const TYPING_INTERVAL_MS = 4000;
1880
+ const THREAD_TYPING_INTERVAL_MS = 2500;
1881
+ function startTypingHeartbeat(state, store, laneKey = null) {
1858
1882
  const adapter = store?.adapter || currentAdapter();
1859
1883
  const channelId = store?.channelId || currentChannelId();
1860
1884
  if (!adapter || !channelId) return () => {};
1861
- if (state.typingHeartbeat) clearInterval(state.typingHeartbeat);
1862
- const tick = () => adapter.typing(channelId, state.thinkingPhase ? "thinking" : undefined).catch(() => {});
1863
- state.thinkingPhase = true;
1885
+ const lane = laneFor(state, laneKey || store?.conversationKey || null);
1886
+ const threadId = store ? (store.threadId || null) : currentThreadId();
1887
+ const opts = threadId ? { threadId } : null;
1888
+ if (lane.typingHeartbeat) clearInterval(lane.typingHeartbeat);
1889
+ const phase = () => (lane.thinkingPhase ? "thinking" : undefined);
1890
+ const tick = () => (opts ? adapter.typing(channelId, phase(), opts) : adapter.typing(channelId, phase())).catch(() => {});
1891
+ lane.thinkingPhase = true;
1864
1892
  // Phase changes are PUSHED through this, not waited for. The interval only
1865
- // samples every 4s, so a final answer that starts streaming and finishes
1866
- // inside one interval would never emit a single "typing" frame — the chip
1867
- // would sit on "thinking" for the whole turn and then stop.
1868
- state.typingTick = tick;
1893
+ // samples every few seconds, so a final answer that starts streaming and
1894
+ // finishes inside one interval would never emit a single "typing" frame —
1895
+ // the chip would sit on "thinking" for the whole turn and then stop.
1896
+ lane.typingTick = tick;
1897
+ lane.typingTarget = { adapter, channelId, threadId };
1869
1898
  tick();
1870
- const interval = setInterval(tick, 4000);
1871
- state.typingHeartbeat = interval;
1899
+ const interval = setInterval(tick, threadId ? THREAD_TYPING_INTERVAL_MS : TYPING_INTERVAL_MS);
1900
+ lane.typingHeartbeat = interval;
1872
1901
  return () => {
1873
- if (state.typingHeartbeat !== interval) return;
1874
- state.thinkingPhase = false;
1875
- state.typingTick = null;
1876
- clearInterval(interval);
1877
- state.typingHeartbeat = null;
1878
- try { adapter.typingStop?.(channelId); } catch (_) {}
1902
+ if (lane.typingHeartbeat !== interval) return;
1903
+ stopLaneTyping(lane);
1879
1904
  };
1880
1905
  }
1881
1906
 
1907
+ // Stop a lane's typing heartbeat now and tell the chat (into the lane's thread).
1908
+ function stopLaneTyping(lane) {
1909
+ if (!lane) return;
1910
+ const target = lane.typingTarget;
1911
+ if (lane.typingHeartbeat) clearInterval(lane.typingHeartbeat);
1912
+ lane.typingHeartbeat = null;
1913
+ lane.thinkingPhase = false;
1914
+ lane.typingTick = null;
1915
+ lane.typingTarget = null;
1916
+ if (!target) return;
1917
+ try {
1918
+ if (target.threadId) target.adapter.typingStop?.(target.channelId, { threadId: target.threadId });
1919
+ else target.adapter.typingStop?.(target.channelId);
1920
+ } catch (_) {}
1921
+ }
1922
+
1882
1923
  function settleImmediateEffects(result, runContext, { state, store, capture }) {
1883
1924
  const stages = [
1884
1925
  ["transcript", persistForegroundTranscript],
@@ -1934,7 +1975,8 @@ function streamPreviewAllowed(runContext) {
1934
1975
  // emits mid-run is appended to one throttled status message, so the user sees
1935
1976
  // the narration as it happens instead of silence. finalizeStreamPreview
1936
1977
  // settles the message when the run ends.
1937
- function createStreamPreview({ state, store }) {
1978
+ function createStreamPreview({ state, store, lane: givenLane = null }) {
1979
+ let lane = givenLane;
1938
1980
  let enabled = !!store
1939
1981
  && (store?.adapter?.type || "") !== "voice"
1940
1982
  && store?.adapter?.suppressStreamPreview !== true;
@@ -1946,34 +1988,60 @@ function createStreamPreview({ state, store }) {
1946
1988
  catch (_) { enabled = false; }
1947
1989
  }
1948
1990
  const channelId = store?.channelId || null;
1991
+ let owner = null; // the run whose preview this is
1949
1992
 
1993
+ // The lane's preview belongs to one run at a time (previewRunId): a run
1994
+ // never writes into or flushes a message another run owns, and only a /stop
1995
+ // naming THIS run mutes it.
1996
+ const ownsPreview = () => !!owner && lane.previewRunId === owner.runId;
1950
1997
  const flush = () => {
1951
- state.streamInterval = null;
1952
- if (!enabled || state.cancelRequested) return;
1953
- const body = String(state.streamBuffer || "").trim();
1998
+ if (!ownsPreview()) return;
1999
+ lane.streamInterval = null;
2000
+ if (!enabled || cancelTargets(lane.cancelRequested, owner)) return;
2001
+ const body = String(lane.streamBuffer || "").trim();
1954
2002
  if (!body) return;
1955
2003
  const display = body.slice(-3800) + STREAM_PREVIEW_FOOTER;
2004
+ const runId = owner.runId;
1956
2005
  // Event callbacks arrive off the provider's stdout stream, outside the
1957
2006
  // chat AsyncLocalStorage context — rebind before any send/edit.
1958
2007
  chatContext.run(store, async () => {
1959
2008
  try {
1960
- if (typeof state.statusMessageId === "string" && String(state.statusMessageChannel) === String(channelId)) {
1961
- await editMessage(state.statusMessageId, display, telegramHtmlOpts({ streaming: true }));
2009
+ if (typeof lane.statusMessageId === "string" && String(lane.statusMessageChannel) === String(channelId)) {
2010
+ await editMessage(lane.statusMessageId, display, telegramHtmlOpts({ streaming: true }));
1962
2011
  return;
1963
2012
  }
1964
- if (state.statusMessageId === true) return; // sent but not editable — leave the single notice alone
2013
+ if (lane.statusMessageId === true) return; // sent but not editable — leave the single notice alone
1965
2014
  const sent = await send(display, telegramHtmlOpts());
1966
- state.statusMessageId = sent.editable ? sent.messageId : (sent.ok ? true : null);
1967
- state.statusMessageChannel = sent.ok ? channelId : null;
2015
+ // Settled while the send was in flight: an id minted for a finished
2016
+ // run must never be adopted (and later edited) by the next one.
2017
+ if (lane.previewRunId !== runId) {
2018
+ if (sent.editable) deleteMessage(sent.messageId).catch(() => {});
2019
+ return;
2020
+ }
2021
+ lane.statusMessageId = sent.editable ? sent.messageId : (sent.ok ? true : null);
2022
+ lane.statusMessageChannel = sent.ok ? channelId : null;
1968
2023
  } catch (_) { /* preview is best-effort */ }
1969
2024
  }).catch(() => {});
1970
2025
  };
1971
2026
 
1972
2027
  const appendChunk = (chunk, runContext) => {
1973
- if (!enabled || !streamPreviewAllowed(runContext) || state.cancelRequested) return;
1974
- state.streamBuffer = state.streamBuffer ? `${state.streamBuffer}\n\n${chunk}` : chunk;
1975
- if (state.streamBuffer.length > 20000) state.streamBuffer = state.streamBuffer.slice(-16000);
1976
- if (!state.streamInterval) state.streamInterval = setTimeout(flush, STREAM_PREVIEW_THROTTLE_MS);
2028
+ if (!enabled || !streamPreviewAllowed(runContext)) return;
2029
+ if (!lane) lane = laneForRun(state, runContext);
2030
+ if (cancelTargets(lane.cancelRequested, runContext)) return;
2031
+ if (owner?.runId !== runContext.runId) {
2032
+ owner = runContext;
2033
+ if (lane.previewRunId !== runContext.runId) {
2034
+ if (lane.streamInterval) clearTimeout(lane.streamInterval);
2035
+ lane.previewRunId = runContext.runId;
2036
+ lane.statusMessageId = null;
2037
+ lane.statusMessageChannel = null;
2038
+ lane.streamBuffer = "";
2039
+ lane.streamInterval = null;
2040
+ }
2041
+ }
2042
+ lane.streamBuffer = lane.streamBuffer ? `${lane.streamBuffer}\n\n${chunk}` : chunk;
2043
+ if (lane.streamBuffer.length > 20000) lane.streamBuffer = lane.streamBuffer.slice(-16000);
2044
+ if (!lane.streamInterval) lane.streamInterval = setTimeout(flush, STREAM_PREVIEW_THROTTLE_MS);
1977
2045
  };
1978
2046
 
1979
2047
  return {
@@ -1993,14 +2061,19 @@ function createStreamPreview({ state, store }) {
1993
2061
  };
1994
2062
  }
1995
2063
 
1996
- function createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture = true }) {
2064
+ function createForegroundService({ state, store, lane, stopTyping, capture, ownsAdmissionLock, persistCapture = true, holdsLane = null, onOwnSpawn = null }) {
2065
+ // Still the lane's holder? A run whose lane /stop's backstop freed (and
2066
+ // another run then claimed) must not touch that lane's run slot.
2067
+ const holds = () => (holdsLane ? holdsLane() : ownsAdmissionLock);
1997
2068
  const callSpeechLeases = new Map();
1998
2069
  const callStage = (stage, runContext) => {
1999
2070
  if (capture || runContext?.liveCall !== true) return;
2000
2071
  console.log(`${new Date().toISOString()} Kazee call provider: stage=${stage} run=${runContext.runId} elapsed=${Math.max(0, Date.now() - runContext.admittedAt)}ms`);
2001
2072
  };
2002
2073
  const persistEffects = !capture || persistCapture;
2003
- const preview = capture ? null : createStreamPreview({ state, store });
2074
+ // The run's lane: given by executeAdmittedRun, else the captured run's own.
2075
+ const laneOf = (runContext) => lane || laneForRun(state, runContext);
2076
+ const preview = capture ? null : createStreamPreview({ state, store, lane });
2004
2077
  // Chat-visibility + learning-signal layer (skill/pack/tool announcements,
2005
2078
  // /recall + /tooltrace banners, recall/tool graph feeding). Foreground only.
2006
2079
  const observer = capture ? null : require("./turn-observer").createTurnObserver({ state, store });
@@ -2037,13 +2110,18 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
2037
2110
  onSpawn(proc, runContext) {
2038
2111
  callStage("started", runContext);
2039
2112
  callSpeechLeases.get(runContext.runId)?.activate();
2040
- state.runningProcess = proc;
2041
- if (ownsAdmissionLock) state.preparingRun = false;
2113
+ // A capture (compaction stage, sub-agent) only records its process on a
2114
+ // lane that has none, so it never displaces the lane's own turn.
2115
+ const runLane = laneOf(runContext);
2116
+ if (ownsAdmissionLock) onOwnSpawn?.(proc);
2117
+ if (ownsAdmissionLock || !runLane.runningProcess) runLane.runningProcess = proc;
2118
+ if (ownsAdmissionLock) runLane.preparingRun = false;
2042
2119
  },
2043
2120
  shouldAbort(runContext, { capture: isCapture } = {}) {
2044
2121
  if (isCapture || !ownsAdmissionLock) return false;
2045
- if (!cancelTargets(state.cancelRequested, runContext)) return false;
2046
- state.cancelRequested = false;
2122
+ const runLane = laneOf(runContext);
2123
+ if (!cancelTargets(runLane.cancelRequested, runContext)) return false;
2124
+ runLane.cancelRequested = false;
2047
2125
  return true;
2048
2126
  },
2049
2127
  onPromptBundle(bundle, runContext) {
@@ -2056,13 +2134,14 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
2056
2134
  if (event.type === "result" || event.type === "error") callSpeechLeases.get(runContext.runId)?.close();
2057
2135
  // Streaming text reads as "typing"; tool work re-enters "thinking" so
2058
2136
  // the chip doesn't decay to a stale "typing" through long tool stretches.
2059
- const phaseBefore = state.thinkingPhase;
2060
- if (["text_delta", "text_final", "result", "error"].includes(event.type)) state.thinkingPhase = false;
2061
- else if (event.type === "tool_start") state.thinkingPhase = true;
2137
+ const lane = laneOf(runContext);
2138
+ const phaseBefore = lane.thinkingPhase;
2139
+ if (["text_delta", "text_final", "result", "error"].includes(event.type)) lane.thinkingPhase = false;
2140
+ else if (event.type === "tool_start") lane.thinkingPhase = true;
2062
2141
  // Emit on the TRANSITION rather than waiting for the next sample. Guarded
2063
2142
  // on an actual change, so a long text stream still sends one frame, not
2064
2143
  // one per delta.
2065
- if (state.thinkingPhase !== phaseBefore) { try { state.typingTick?.(); } catch (_) {} }
2144
+ if (lane.thinkingPhase !== phaseBefore) { try { lane.typingTick?.(); } catch (_) {} }
2066
2145
  if (event.type === "tool_start" && ["Shell", "Bash"].includes(event.name)) {
2067
2146
  const command = typeof event.detail === "string" ? event.detail : event.detail?.command;
2068
2147
  if (command) { try { require("./tool-guard").noteRawOp(command); } catch (_) {} }
@@ -2087,10 +2166,11 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
2087
2166
  try { require("./speaker-attest").endRun(runContext.runId); } catch (_) {}
2088
2167
  callSpeechLeases.get(runContext.runId)?.close();
2089
2168
  callSpeechLeases.delete(runContext.runId);
2090
- if (state.runningProcess === proc) state.runningProcess = null;
2091
- // Keep the single-flight lock armed while transcript/usage/session
2092
- // persistence and required delivery settle after child close.
2093
- if (ownsAdmissionLock) state.preparingRun = true;
2169
+ const runLane = laneOf(runContext);
2170
+ if (runLane.runningProcess === proc) runLane.runningProcess = null;
2171
+ // Keep the lane's lock armed while transcript/usage/session persistence
2172
+ // and required delivery settle after child close.
2173
+ if (holds()) runLane.preparingRun = true;
2094
2174
  stopTyping();
2095
2175
  },
2096
2176
  idleTimeoutMs: Math.max(60000, parseInt(config.OC_TURN_IDLE_MS || process.env.OC_TURN_IDLE_MS || "", 10) || 10 * 60 * 1000),
@@ -2123,6 +2203,15 @@ async function handoffMemoryReview(result, runContext, store, state) {
2123
2203
  });
2124
2204
  }
2125
2205
 
2206
+ // Thread titles: after a delivered thread reply the bot may name the thread
2207
+ // (core/thread-title.js decides). Never awaited, never throws.
2208
+ function nameThreadAfterTurn(result, runContext, store) {
2209
+ try {
2210
+ const pending = require("./thread-title").nameThreadAfterTurn(result, runContext, store);
2211
+ if (pending) pending.catch((error) => console.warn(`[thread-title] ${redactSensitive(error.message)}`));
2212
+ } catch (error) { /* best-effort: naming a thread must never break a turn */ }
2213
+ }
2214
+
2126
2215
  // Track C — owner-guardrail capture. After a foreground turn, a deterministic
2127
2216
  // detector spots durable-rule statements ("from now on / never / stop") in the
2128
2217
  // owner's message and captures them into lessons.md (or asks first). Owner-only
@@ -2155,15 +2244,16 @@ function cancelledCallRunResult(runContext) {
2155
2244
  return cancelledRunResult(runContext, { id: runContext.provider, label: runContext.provider }, "Call turn superseded or ended before execution");
2156
2245
  }
2157
2246
 
2158
- function cancelQueuedCallOnAbort(state, item) {
2247
+ function cancelQueuedCallOnAbort(lane, item) {
2159
2248
  if (item.store?.adapter?.liveCall !== true) return;
2160
2249
  const signal = item.store.adapter.callTurn?.signal;
2161
2250
  if (!signal) return;
2162
2251
  const cancel = () => {
2163
- const index = state.messageQueue.indexOf(item);
2252
+ const index = lane.messageQueue.indexOf(item);
2164
2253
  if (index < 0) return; // Already admitted: pre-spawn checks and speech fences take over.
2165
- state.messageQueue.splice(index, 1);
2254
+ lane.messageQueue.splice(index, 1);
2166
2255
  item.disposeCallListener?.();
2256
+ if (!laneBusy(lane) && !lane.messageQueue.length) stopLaneTyping(lane);
2167
2257
  item.deferred.resolve(cancelledCallRunResult(item.runContext));
2168
2258
  };
2169
2259
  item.disposeCallListener = () => signal.removeEventListener("abort", cancel);
@@ -2171,19 +2261,37 @@ function cancelQueuedCallOnAbort(state, item) {
2171
2261
  if (signal.aborted) cancel();
2172
2262
  }
2173
2263
 
2174
- function drainQueuedMessages(state) {
2175
- if (!Array.isArray(state.messageQueue) || !state.messageQueue.length || state.runningProcess || state.preparingRun) return Promise.resolve();
2176
- const item = state.messageQueue.shift();
2177
- item.disposeCallListener?.();
2178
- state.preparingRun = true;
2179
- setImmediate(() => {
2180
- const execute = () => executeAdmittedRun(item.runContext, state, item.store, false, true);
2181
- const promise = item.store ? chatContext.run(item.store, execute) : execute();
2182
- promise.then(item.deferred.resolve, item.deferred.reject);
2183
- });
2264
+ // Admit waiting lanes into free slots: every lane with queued work and no run,
2265
+ // oldest waiting message first, as far as the caps allow. An admitted lane runs
2266
+ // its next queued message (one at a time within a lane) in that message's own
2267
+ // captured context. Called whenever a run, a compaction or a /stop frees a
2268
+ // lane, and synchronous up to the admission, so no newcomer can take a freed
2269
+ // slot ahead of a lane that was already waiting. Every state's lanes are
2270
+ // considered (the global cap is shared); the argument is kept for callers.
2271
+ function drainQueuedMessages(_state) {
2272
+ for (const { state, lane } of waitingLanes()) {
2273
+ if (laneBusy(lane) || !lane.messageQueue.length) continue;
2274
+ if (slotBlocker(state)) { lane.waitingForSlot = true; continue; }
2275
+ const item = lane.messageQueue.shift();
2276
+ item.disposeCallListener?.();
2277
+ lane.preparingRun = true;
2278
+ lane.waitingForSlot = false;
2279
+ setImmediate(() => {
2280
+ const execute = () => executeAdmittedRun(item.runContext, state, item.store, false, true);
2281
+ const promise = item.store ? chatContext.run(item.store, execute) : execute();
2282
+ promise.then(item.deferred.resolve, item.deferred.reject);
2283
+ });
2284
+ }
2184
2285
  return Promise.resolve();
2185
2286
  }
2186
2287
 
2288
+ let queueSequence = 0;
2289
+ function queueInLane(lane, item) {
2290
+ item.seq = ++queueSequence;
2291
+ lane.messageQueue.push(item);
2292
+ return item;
2293
+ }
2294
+
2187
2295
  // A refusal on a RESUMED session is almost always the safety classifier
2188
2296
  // rejecting flagged material in the old context — which every resume re-sends
2189
2297
  // — not the user's new message. Recovery: quarantine the poisoned session so
@@ -2235,13 +2343,52 @@ function refusalRetryContext(runContext, state) {
2235
2343
  });
2236
2344
  }
2237
2345
 
2346
+ // A run that owns its lane's admission holds the lane from admission until its
2347
+ // whole tail is done (delivery, memory review, auto-compaction), then frees it
2348
+ // and admits the next waiting lane. Captures (compaction stages, sub-agents)
2349
+ // run inside a lane without owning it.
2238
2350
  async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissionLock, executionOptions = {}) {
2351
+ if (!ownsAdmissionLock) {
2352
+ const result = await runAdmitted(runContext, state, store, capture, false, executionOptions);
2353
+ if (!(capture && executionOptions.persistCapture === false)) await drainQueuedMessages(state);
2354
+ return result;
2355
+ }
2356
+ const lane = laneForRun(state, runContext);
2357
+ const ownerToken = {};
2358
+ lane.ownerToken = ownerToken;
2359
+ try {
2360
+ return await runAdmitted(runContext, state, store, capture, true, { ...executionOptions, ownerToken });
2361
+ } finally {
2362
+ // Free the lane only while this run still holds it: after /stop's
2363
+ // backstop released a stuck run, another run may own the lane now.
2364
+ if (lane.ownerToken === ownerToken) {
2365
+ lane.ownerToken = null;
2366
+ lane.settling = false;
2367
+ lane.preparingRun = false;
2368
+ // A bare cancel flag (a /stop that named no run) must not outlive the
2369
+ // turn that held the lane, or the next message would be cancelled.
2370
+ if (lane.cancelRequested === true) lane.cancelRequested = false;
2371
+ }
2372
+ await drainQueuedMessages(state);
2373
+ pruneIdleLanes(state);
2374
+ }
2375
+ }
2376
+
2377
+ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock, executionOptions = {}) {
2378
+ const lane = laneForRun(state, runContext);
2239
2379
  const tracksMainRun = ownsAdmissionLock && !capture;
2240
- if (tracksMainRun) state.activeRunContext = runContext;
2241
- const stopTyping = startTypingHeartbeat(state, store);
2242
2380
  const persistCapture = executionOptions.persistCapture !== false;
2243
2381
  const internalCapture = capture && !persistCapture;
2244
- const service = createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture });
2382
+ if (tracksMainRun) lane.activeRunContext = runContext;
2383
+ if (!internalCapture) runContext = consumeOneShotBudget(runContext, state, executionOptions);
2384
+ const stopTyping = startTypingHeartbeat(state, store, runLaneKey(runContext));
2385
+ const holdsLane = () => ownsAdmissionLock
2386
+ && (!executionOptions.ownerToken || lane.ownerToken === executionOptions.ownerToken);
2387
+ const ownProcs = new Set();
2388
+ const service = createForegroundService({
2389
+ state, store, lane, stopTyping, capture, ownsAdmissionLock, persistCapture,
2390
+ holdsLane, onOwnSpawn: (proc) => ownProcs.add(proc),
2391
+ });
2245
2392
  let result;
2246
2393
  try {
2247
2394
  result = await service.executeCaptured(runContext.userPrompt, runContext, { capture });
@@ -2254,29 +2401,33 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
2254
2401
  result = await settleImmediateEffects(result, runContext, { state, store, capture });
2255
2402
  }
2256
2403
  } finally {
2257
- if (tracksMainRun && state.runningProcess) state.runningProcess = null;
2258
- if (ownsAdmissionLock) state.preparingRun = false;
2404
+ // Only this run's own process: never one a later run put there.
2405
+ if (tracksMainRun && lane.runningProcess && ownProcs.has(lane.runningProcess)) lane.runningProcess = null;
2406
+ // The provider is done but the lane stays held until the turn's tail has
2407
+ // finished: `settling`, not `preparingRun`, so a /stop now has nothing to
2408
+ // cancel and cannot leave a stray flag for the next turn.
2409
+ if (holdsLane()) { lane.preparingRun = false; lane.settling = true; }
2259
2410
  // A cancel flag that survived this run (e.g. /stop raced a run that
2260
2411
  // finished successfully anyway) must not leak into the next turn, where
2261
2412
  // it would falsely abort a queued run or mute a genuine failure.
2262
- if (ownsAdmissionLock && cancelTargets(state.cancelRequested, runContext)) state.cancelRequested = false;
2263
- if (tracksMainRun && state.activeRunContext === runContext) state.activeRunContext = null;
2413
+ if (holdsLane() && cancelTargets(lane.cancelRequested, runContext)) lane.cancelRequested = false;
2414
+ if (tracksMainRun && lane.activeRunContext === runContext) lane.activeRunContext = null;
2264
2415
  stopTyping();
2265
2416
  }
2266
2417
 
2267
2418
  if (shouldRetryRefusedRun(result, runContext, { capture, refusalRetried: executionOptions.refusalRetried })
2268
2419
  && quarantineRefusedRun(result, runContext, state)) {
2269
2420
  try { service.turnObserver?.onTurnSettled(false); } catch (e) { /* best-effort */ }
2270
- // Re-arm the admission lock across the notice's async gap so a racing
2271
- // incoming message queues behind the retry instead of interleaving.
2272
- if (ownsAdmissionLock) state.preparingRun = true;
2421
+ // The lane stays held across the notice's async gap so a racing incoming
2422
+ // message queues behind the retry instead of interleaving.
2423
+ if (holdsLane()) lane.preparingRun = true;
2273
2424
  await send("♻️ That refusal came from flagged context in the old session — quarantined it, retrying your message in a fresh one.").catch(() => {});
2274
- return executeAdmittedRun(refusalRetryContext(runContext, state), state, store, capture, ownsAdmissionLock, { ...executionOptions, refusalRetried: true });
2425
+ return runAdmitted(refusalRetryContext(runContext, state), state, store, capture, ownsAdmissionLock, { ...executionOptions, refusalRetried: true });
2275
2426
  }
2276
2427
 
2277
2428
  if (tracksMainRun) {
2278
2429
  sideChatCoordinator.noteMainResult(runContext, result);
2279
- for (const item of state.messageQueue) {
2430
+ for (const item of lane.messageQueue) {
2280
2431
  finalizeSideChatQueueItem(item, runContext, result);
2281
2432
  }
2282
2433
  }
@@ -2287,24 +2438,40 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
2287
2438
  }
2288
2439
  if (!internalCapture) await handoffMemoryReview(result, runContext, store, state);
2289
2440
  if (!internalCapture) await considerGuardrail(result, runContext, store);
2290
- if (!internalCapture && state.settings?.backend === runContext.provider && state.settings.budget) {
2291
- state.settings.budget = null;
2292
- try { saveState({ strict: true }); }
2293
- catch (error) {
2294
- result.ok = false;
2295
- result.status = "failed";
2296
- result.error = result.error || stableRunError(error.message, "RUN_SETTINGS_PERSIST_FAILED");
2297
- result.diagnostic = providerRunDiagnostic(result, runContext) || result.error.message;
2298
- }
2299
- }
2300
- if (!capture && !runContext.skipAutoCompact && result.ok && state.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state)) {
2301
- try { await compactActiveSession(runContext.project.dir, { notify: false }); }
2441
+ // A thread's first delivered reply may name the thread. Detached: it never
2442
+ // delays this turn, the lane or the queue behind it.
2443
+ if (!capture) nameThreadAfterTurn(result, runContext, store);
2444
+ // Auto-compaction is part of the lane's turn: only when nothing waits in
2445
+ // this conversation, and it compacts this conversation only.
2446
+ if (!capture && !runContext.skipAutoCompact && result.ok && lane.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state, { laneKey: lane.key })) {
2447
+ try { await compactActiveSession(runContext.project.dir, { notify: false, laneKey: lane.key, admissionHeld: ownsAdmissionLock }); }
2302
2448
  catch (error) { console.warn(`[proactive-compact] failed: ${redactSensitive(error.message)}`); }
2303
2449
  }
2304
- if (!internalCapture) await drainQueuedMessages(state);
2305
2450
  return result;
2306
2451
  }
2307
2452
 
2453
+ // The one-shot budget (/budget) belongs to the room and is spent by exactly one
2454
+ // turn: the first of that room to START with it. It used to be cleared when a
2455
+ // turn finished, which let parallel conversations of one room all run with it
2456
+ // until the first of them ended. A turn that starts after it was spent runs
2457
+ // without it, even if it captured it while it waited in a queue.
2458
+ function consumeOneShotBudget(runContext, state, executionOptions = {}) {
2459
+ const captured = runContext.providerSettings?.budget ?? null;
2460
+ if (captured === null || executionOptions.refusalRetried) return runContext;
2461
+ let live = null;
2462
+ try { live = resolveProviderSettings(state, runContext.provider)?.budget ?? null; } catch (_) { live = null; }
2463
+ if (live !== null) {
2464
+ try {
2465
+ require("./state").getProviderSettings(state, runContext.provider).budget = null;
2466
+ saveState({ strict: true });
2467
+ } catch (error) {
2468
+ console.warn(`[budget] could not clear the one-shot budget: ${redactSensitive(error.message)}`);
2469
+ }
2470
+ return runContext;
2471
+ }
2472
+ return deepFreeze({ ...runContext, providerSettings: { ...runContext.providerSettings, budget: null } });
2473
+ }
2474
+
2308
2475
  function admissionOptions(state, opts = {}) {
2309
2476
  const provider = opts.provider || state.settings?.backend || null;
2310
2477
  return { ...opts, provider };
@@ -2339,7 +2506,13 @@ function queuedNotice(activeRunContext, at = Date.now()) {
2339
2506
  const kind = header ? { "Watch triggered": "watch", "Watch expired": "watch", "Watch check timing out": "watch", "Scheduled cron": "scheduled job", "Scheduled wakeup": "wakeup" }[header[1]] : null;
2340
2507
  const label = header && header[2].length > 60 ? `${header[2].slice(0, 59).trimEnd()}…` : header?.[2];
2341
2508
  const what = kind ? `the ${kind} "${label}"` : "an earlier message";
2342
- return `Queued behind ${what} (running ${minutes} min). /stop cancels it.`;
2509
+ return `Queued behind ${what} (running ${minutes} min). /stop cancels it (and drops this message).`;
2510
+ }
2511
+
2512
+ // What a message waiting for a free slot is told.
2513
+ function waitingNotice(running) {
2514
+ const n = Math.max(1, Number(running) || 0);
2515
+ return `Waiting: ${n} conversation${n === 1 ? " is" : "s are"} already running; this starts when one finishes.`;
2343
2516
  }
2344
2517
 
2345
2518
  function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
@@ -2355,31 +2528,49 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
2355
2528
  });
2356
2529
 
2357
2530
  if (obsoleteCallTurn(store)) return Promise.resolve(cancelledCallRunResult(runContext));
2358
- if (!acquireRunLock(state)) {
2359
- if (shouldStartSideChat(state, runContext, opts, sideChatCoordinator)) {
2531
+ const admission = admitLane(state, runLaneKey(runContext));
2532
+ if (!admission.ok) {
2533
+ const lane = admission.lane;
2534
+ if (admission.reason === "busy" && shouldStartSideChat(state, runContext, opts, sideChatCoordinator)) {
2360
2535
  return sideChatCoordinator.respond({
2361
2536
  runContext,
2362
- activeRunContext: state.activeRunContext,
2537
+ activeRunContext: lane.activeRunContext,
2363
2538
  store,
2364
2539
  });
2365
2540
  }
2366
2541
  const deferred = deferredRunResult();
2367
- state.messageQueue.push({ runContext, store, deferred, queuedAt: Date.now() });
2368
- cancelQueuedCallOnAbort(state, state.messageQueue.at(-1));
2542
+ // `text` is what the person typed (before reply/context blocks were added),
2543
+ // so /stop can say which queued messages it dropped.
2544
+ const item = queueInLane(lane, { runContext, store, deferred, queuedAt: Date.now(), text: admitted.userPrompt || prompt });
2545
+ cancelQueuedCallOnAbort(lane, item);
2546
+ // Waiting for a slot: admit whatever the free slots allow, oldest first.
2547
+ // This message may be among them, and then it needs no notice.
2548
+ if (admission.reason !== "busy") {
2549
+ drainQueuedMessages(state);
2550
+ if (!lane.messageQueue.includes(item)) return deferred.promise;
2551
+ // Still waiting: nothing runs in this conversation, so no chip (a
2552
+ // pre-turn heartbeat may have started before the cap applied).
2553
+ if (!laneBusy(lane)) stopLaneTyping(lane);
2554
+ }
2369
2555
  // Transient queue/compaction acks are a chat-UX affordance (Telegram/Kazee).
2370
2556
  // On a Spaces task thread they'd post a throwaway "Queued." comment on the
2371
2557
  // task — noise. On a live call, send() is the TTS line: "Queued." would be
2372
2558
  // spoken to the caller. Both stay quiet and just deliver the real reply.
2373
2559
  if (currentAdapter()?.type !== "spaces" && !statusChatterMuted()) {
2374
- send(state.isCompacting ? "Compacting context, will pick this up next…" : queuedNotice(state.activeRunContext), { replyTo: replyToMsgId }).catch(() => {});
2560
+ const notice = admission.reason === "busy"
2561
+ ? (lane.isCompacting ? "Compacting context, will pick this up next…" : queuedNotice(lane.activeRunContext))
2562
+ : waitingNotice(admission.running);
2563
+ send(notice, { replyTo: replyToMsgId }).catch(() => {});
2375
2564
  }
2376
2565
  return deferred.promise;
2377
2566
  }
2378
2567
  return executeAdmittedRun(runContext, state, store, false, true);
2379
2568
  }
2380
2569
 
2570
+ // Agent mode: a message sent while ITS OWN conversation is running gets a
2571
+ // read-only side answer (other conversations simply run in parallel).
2381
2572
  function shouldStartSideChat(state, runContext, opts = {}, coordinator = sideChatCoordinator) {
2382
- return !!state?.runningProcess
2573
+ return !!peekLane(state, runLaneKey(runContext))?.runningProcess
2383
2574
  && !runContext?.liveCall
2384
2575
  && opts.sideChat !== false
2385
2576
  && runContext?.purpose === "foreground"
@@ -2409,7 +2600,7 @@ function createSideChatQueueItem(request, store, lineage, state, deferred) {
2409
2600
  : request.originalRunContext;
2410
2601
  const awaitsActiveMain = !lineage.mainSessionId
2411
2602
  && lineage.mainRunId
2412
- && state.activeRunContext?.runId === lineage.mainRunId;
2603
+ && peekLane(state, runLaneKey(request.originalRunContext))?.activeRunContext?.runId === lineage.mainRunId;
2413
2604
  return {
2414
2605
  runContext: awaitsActiveMain ? null : queuedContext,
2415
2606
  store,
@@ -2440,11 +2631,13 @@ function enqueueSideChatRequest(request, store = null, lineage = {}) {
2440
2631
  error.code = "SIDE_CHAT_FORBIDDEN";
2441
2632
  throw error;
2442
2633
  }
2634
+ // Queued in the lane of the conversation the side question came from.
2635
+ const lane = laneForRun(state, request.originalRunContext);
2443
2636
  const deferred = deferredRunResult();
2444
- state.messageQueue.push(createSideChatQueueItem(request, store, lineage, state, deferred));
2637
+ queueInLane(lane, createSideChatQueueItem(request, store, lineage, state, deferred));
2445
2638
  deferred.promise.catch((error) => console.error("side-chat queued run failed:", redactSensitive(error.message)));
2446
- if (!state.runningProcess && !state.preparingRun) drainQueuedMessages(state);
2447
- return { queued: true, position: state.messageQueue.length || 1 };
2639
+ if (!laneBusy(lane)) drainQueuedMessages(state);
2640
+ return { queued: true, position: lane.messageQueue.length || 1 };
2448
2641
  }
2449
2642
 
2450
2643
  function runAgentCapture(prompt, cwd, opts = {}) {
@@ -2467,6 +2660,9 @@ function runAgentSilent(prompt, cwd, label, opts = {}) {
2467
2660
 
2468
2661
  module.exports = {
2469
2662
  queuedNotice,
2663
+ waitingNotice,
2664
+ drainQueuedMessages,
2665
+ stopLaneTyping,
2470
2666
  admitRunContext,
2471
2667
  streamPreviewAllowed,
2472
2668
  capturedTranscriptMetadata,