chapterhouse 0.3.9 → 0.3.10

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.
@@ -20,6 +20,7 @@ import { getSquadCoordinatorSystemMessage } from "../squad/charter.js";
20
20
  import { childLogger } from "../util/logger.js";
21
21
  import { squadEventBus } from "./squad-event-bus.js";
22
22
  import { initTaskEventLog } from "./task-event-log.js";
23
+ import { emitTurnEvent, persistTurnEvents, scheduleClearTurnLog, } from "./turn-event-log.js";
23
24
  import { SessionManager, SessionRegistry, SESSION_IDLE_TTL_MS, SESSION_MAX_ACTIVE, } from "./session-manager.js";
24
25
  const log = childLogger("orchestrator");
25
26
  const MAX_RETRIES = 3;
@@ -391,15 +392,15 @@ async function executeOnSession(manager, item) {
391
392
  const unsubToolDone = session.on("tool.execution_complete", (event) => {
392
393
  toolCallExecuted = true;
393
394
  toolCallCount++;
395
+ const data = event.data;
396
+ const result = data.result;
397
+ const resultPreview = typeof result?.content === "string" ? result.content.slice(0, 400) : undefined;
398
+ const detailedContent = typeof result?.detailedContent === "string"
399
+ ? result.detailedContent
400
+ : typeof result?.content === "string"
401
+ ? result.content
402
+ : undefined;
394
403
  if (item.onActivity) {
395
- const data = event.data;
396
- const result = data.result;
397
- const resultPreview = typeof result?.content === "string" ? result.content.slice(0, 400) : undefined;
398
- const detailedContent = typeof result?.detailedContent === "string"
399
- ? result.detailedContent
400
- : typeof result?.content === "string"
401
- ? result.content
402
- : undefined;
403
404
  item.onActivity({
404
405
  kind: "tool_complete",
405
406
  toolCallId: data.toolCallId,
@@ -408,6 +409,16 @@ async function executeOnSession(manager, item) {
408
409
  detailedContent,
409
410
  }, item.turnId);
410
411
  }
412
+ // Emit turn:delta with tool-call part (coexistence — #130)
413
+ const toolPart = {
414
+ type: "tool-call",
415
+ toolCallId: String(data.toolCallId ?? ""),
416
+ toolName: String(data.toolName ?? "unknown"),
417
+ status: data.success !== false ? "done" : "failed",
418
+ resultPreview,
419
+ detailedContent,
420
+ };
421
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: toolPart });
411
422
  });
412
423
  const unsubToolStart = item.onActivity
413
424
  ? session.on("tool.execution_start", (event) => {
@@ -475,6 +486,29 @@ async function executeOnSession(manager, item) {
475
486
  })
476
487
  : () => { };
477
488
  // Always persist SDK subagent dispatches to agent_tasks so Workers tab shows them.
489
+ // Also emit turn events unconditionally alongside existing callback path (#130).
490
+ const unsubTurnToolStart = session.on("tool.execution_start", (event) => {
491
+ const data = event.data;
492
+ // Skip nested subagent tool calls (handled via subagent.started/completed)
493
+ const part = {
494
+ type: "tool-call",
495
+ toolCallId: String(data.toolCallId ?? ""),
496
+ toolName: String(data.toolName ?? "unknown"),
497
+ mcpServerName: typeof data.mcpServerName === "string" ? data.mcpServerName : undefined,
498
+ arguments: data.arguments,
499
+ status: "running",
500
+ };
501
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part });
502
+ });
503
+ const unsubTurnReasoning = session.on("assistant.reasoning_delta", (event) => {
504
+ const part = {
505
+ type: "thinking",
506
+ reasoningId: event.data.reasoningId,
507
+ text: event.data.deltaContent,
508
+ active: true,
509
+ };
510
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part });
511
+ });
478
512
  const db = getDb();
479
513
  // Set of task IDs for subagents spawned in THIS turn — used to filter nested tool events.
480
514
  const activeSubagentTaskIds = new Set();
@@ -497,6 +531,17 @@ async function executeOnSession(manager, item) {
497
531
  payload: { agentName: agentSlug, priority: "normal" },
498
532
  timestamp: new Date(),
499
533
  });
534
+ // Emit turn:delta with subagent part (coexistence — #130)
535
+ const subPart = {
536
+ type: "subagent",
537
+ toolCallId: String(data.toolCallId ?? ""),
538
+ agentName: data.agentName ?? agentSlug,
539
+ agentDisplayName: data.agentDisplayName ?? agentSlug,
540
+ agentDescription: description,
541
+ agentSlug,
542
+ status: "running",
543
+ };
544
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: subPart });
500
545
  }
501
546
  catch { /* non-fatal */ }
502
547
  });
@@ -514,6 +559,17 @@ async function executeOnSession(manager, item) {
514
559
  payload: { agentName: taskRow?.agent_slug ?? "", reason: "complete" },
515
560
  timestamp: new Date(),
516
561
  });
562
+ // Emit turn:delta with subagent completed part (coexistence — #130)
563
+ const doneData = event.data;
564
+ const donePart = {
565
+ type: "subagent",
566
+ toolCallId: String(doneData.toolCallId ?? ""),
567
+ agentName: doneData.agentName ?? taskRow?.agent_slug ?? "agent",
568
+ agentDisplayName: doneData.agentDisplayName ?? taskRow?.agent_slug ?? "agent",
569
+ status: "done",
570
+ durationMs: typeof doneData.durationMs === "number" ? doneData.durationMs : undefined,
571
+ };
572
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: donePart });
517
573
  }
518
574
  catch { /* non-fatal */ }
519
575
  });
@@ -531,6 +587,16 @@ async function executeOnSession(manager, item) {
531
587
  payload: { agentName: taskRow?.agent_slug ?? "", error: data.error ?? "Subagent failed" },
532
588
  timestamp: new Date(),
533
589
  });
590
+ // Emit turn:delta with subagent failed part (coexistence — #130)
591
+ const failPart = {
592
+ type: "subagent",
593
+ toolCallId: String(data.toolCallId ?? ""),
594
+ agentName: data.agentName ?? taskRow?.agent_slug ?? "agent",
595
+ agentDisplayName: data.agentDisplayName ?? taskRow?.agent_slug ?? "agent",
596
+ status: "failed",
597
+ error: data.error,
598
+ };
599
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: failPart });
534
600
  }
535
601
  catch { /* non-fatal */ }
536
602
  });
@@ -584,8 +650,16 @@ async function executeOnSession(manager, item) {
584
650
  accumulated += "\n";
585
651
  }
586
652
  toolCallExecuted = false;
587
- accumulated += event.data.deltaContent;
653
+ const delta = event.data.deltaContent;
654
+ accumulated += delta;
588
655
  item.callback(accumulated, false, item.turnId);
656
+ // Emit alongside existing callback path (coexistence — #130)
657
+ emitTurnEvent(sessionKey, {
658
+ type: "turn:delta",
659
+ turnId: item.turnId,
660
+ sessionKey,
661
+ part: { type: "text", text: delta },
662
+ });
589
663
  });
590
664
  try {
591
665
  const result = await session.sendAndWait({ prompt: item.prompt, ...(item.attachments && item.attachments.length > 0 ? { attachments: item.attachments } : {}) }, ORCHESTRATOR_TIMEOUT_MS);
@@ -628,6 +702,8 @@ async function executeOnSession(manager, item) {
628
702
  unsubSubFailDb();
629
703
  unsubNestedToolStart();
630
704
  unsubNestedToolDone();
705
+ unsubTurnToolStart();
706
+ unsubTurnReasoning();
631
707
  }
632
708
  });
633
709
  }
@@ -776,6 +852,147 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
776
852
  }
777
853
  })();
778
854
  }
855
+ /**
856
+ * Abort the active turn on `sessionKey` and immediately start a new turn with `newPrompt`.
857
+ * Uses the zero-gap technique: enqueue the replacement BEFORE awaiting abort so the
858
+ * drain loop picks it up on its next iteration without any processing gap.
859
+ *
860
+ * If no session is currently active for `sessionKey`, falls back to a normal
861
+ * sendToOrchestrator call.
862
+ *
863
+ * @param onInterrupted Called with the aborted turn's ID immediately before the
864
+ * replacement turn starts. Use to emit a `turn-interrupted` SSE event so the
865
+ * frontend can drop the partial in-flight bubble.
866
+ */
867
+ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callback, attachments, onActivity, onInterrupted) {
868
+ const manager = registry?.get(sessionKey);
869
+ // If no session exists or it isn't processing, fall back to a normal send.
870
+ if (!manager || !manager.isProcessing) {
871
+ return sendToOrchestrator(newPrompt, source, callback, attachments, onActivity);
872
+ }
873
+ updateUserContext(source);
874
+ updateRequestContext(source);
875
+ const turnId = randomUUID();
876
+ const sourceLabel = source.type === "web" ? "web" : "background";
877
+ const sourceChannel = source.type === "web" ? "web" : undefined;
878
+ const authUser = source.type === "web" ? source.user : undefined;
879
+ const authHeader = source.type === "web" ? source.authorizationHeader?.trim() || undefined : undefined;
880
+ const taggedPrompt = source.type === "background"
881
+ ? newPrompt
882
+ : `[via ${sourceLabel}] ${newPrompt}`;
883
+ // Clear any messages already waiting in the queue behind the in-flight turn —
884
+ // their context is stale once we interrupt.
885
+ manager.cancelQueued();
886
+ // Enqueue the replacement BEFORE awaiting abort. When sendAndWait resolves on
887
+ // abort, the drain loop immediately finds the replacement in the queue — zero gap.
888
+ void (async () => {
889
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
890
+ try {
891
+ const finalContent = await new Promise((resolve, reject) => {
892
+ manager.enqueue({
893
+ prompt: taggedPrompt,
894
+ attachments,
895
+ callback,
896
+ onActivity: onActivity,
897
+ turnId,
898
+ isInterrupt: true,
899
+ onInterrupted,
900
+ sourceChannel,
901
+ sessionKey,
902
+ authUser,
903
+ authHeader,
904
+ resolve,
905
+ reject,
906
+ });
907
+ });
908
+ callback(finalContent, true, turnId);
909
+ try {
910
+ logMessage("out", sourceLabel, finalContent);
911
+ }
912
+ catch { /* best-effort */ }
913
+ try {
914
+ logConversation("user", newPrompt, sourceLabel, sessionKey);
915
+ }
916
+ catch { /* best-effort */ }
917
+ try {
918
+ logConversation("assistant", finalContent, sourceLabel, sessionKey);
919
+ }
920
+ catch { /* best-effort */ }
921
+ if (copilotClient) {
922
+ maybeWriteEpisode(copilotClient).catch((err) => {
923
+ log.error({ err: err instanceof Error ? err.message : err }, "Episode write failed (non-fatal)");
924
+ });
925
+ }
926
+ return;
927
+ }
928
+ catch (err) {
929
+ const msg = err instanceof Error ? err.message : String(err);
930
+ if (/cancelled|abort/i.test(msg))
931
+ return;
932
+ if (isRecoverableError(err) && attempt < MAX_RETRIES) {
933
+ const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
934
+ log.warn({ msg, attempt: attempt + 1, maxRetries: MAX_RETRIES, delayMs: delay }, "Recoverable error on interrupt turn, retrying");
935
+ await sleep(delay);
936
+ try {
937
+ await ensureClient();
938
+ }
939
+ catch { /* will fail again on next attempt */ }
940
+ continue;
941
+ }
942
+ log.error({ msg }, "Error processing interrupt turn");
943
+ callback(`Error: ${msg}`, true, turnId);
944
+ return;
945
+ }
946
+ }
947
+ })();
948
+ // Abort the in-flight turn AFTER enqueueing the replacement — SDK sends the
949
+ // abort RPC; server emits session.idle; sendAndWait resolves; drain loop picks
950
+ // up the replacement immediately.
951
+ await manager.abortCurrentTurn();
952
+ log.info({ sessionKey, replacementTurnId: turnId }, "turn.interrupted");
953
+ }
954
+ /**
955
+ * Enqueue a turn for the new POST→SSE chat path (#130).
956
+ *
957
+ * Unlike `sendToOrchestrator`, this function:
958
+ * - Returns the `turnId` immediately without waiting for the turn to complete.
959
+ * - Emits turn:started, turn:complete, and turn:error events to the turn event log.
960
+ * - Does NOT write to sseClients — the SSE channel delivers events via subscribeSession().
961
+ * - Supports interrupt: true which calls interruptCurrentTurn under the hood.
962
+ *
963
+ * @returns turnId (UUID)
964
+ */
965
+ export function enqueueForSse(opts) {
966
+ const { sessionKey, prompt, attachments, authUser, authHeader, interrupt } = opts;
967
+ const turnId = randomUUID();
968
+ const source = { type: "background", sessionKey };
969
+ const taggedPrompt = `[via sse] ${prompt}`;
970
+ // Emit turn:started immediately so the SSE client sees it before any delta
971
+ emitTurnEvent(sessionKey, { type: "turn:started", turnId, sessionKey, prompt, attachments });
972
+ const callback = (text, done, tid) => {
973
+ if (done) {
974
+ emitTurnEvent(sessionKey, { type: "turn:complete", turnId: tid, sessionKey, finalMessage: text });
975
+ persistTurnEvents(tid, sessionKey);
976
+ scheduleClearTurnLog(tid);
977
+ }
978
+ // Note: mid-turn text deltas are emitted by executeOnSession's delta handler
979
+ };
980
+ const onQueued = (position, tid) => {
981
+ emitTurnEvent(sessionKey, { type: "turn:queued", turnId: tid, sessionKey, position });
982
+ };
983
+ const onInterrupted = (abortedTurnId) => {
984
+ emitTurnEvent(sessionKey, { type: "turn:interrupted", turnId: abortedTurnId, sessionKey });
985
+ persistTurnEvents(abortedTurnId, sessionKey);
986
+ scheduleClearTurnLog(abortedTurnId);
987
+ };
988
+ if (interrupt) {
989
+ void interruptCurrentTurn(sessionKey, taggedPrompt, source, callback, attachments, undefined, onInterrupted);
990
+ }
991
+ else {
992
+ void sendToOrchestrator(taggedPrompt, source, callback, attachments, undefined, onQueued);
993
+ }
994
+ return turnId;
995
+ }
779
996
  /** Cancel all queued and in-flight messages across all active sessions. */
780
997
  export async function cancelCurrentMessage() {
781
998
  if (!registry)
@@ -870,4 +870,46 @@ test("#86: subagent.completed removes task from activeSubagentTaskIds — subseq
870
870
  assert.equal(events.length, 0, "No task events must be recorded after subagent completes");
871
871
  state.pendingReject?.(new Error("test teardown"));
872
872
  });
873
+ // ---------------------------------------------------------------------------
874
+ // #98 — interruptCurrentTurn
875
+ // ---------------------------------------------------------------------------
876
+ test("#98: interruptCurrentTurn aborts active turn and starts replacement turn", async (t) => {
877
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
878
+ sendResult: "__PENDING__",
879
+ });
880
+ await orchestrator.initOrchestrator(client);
881
+ const firstResults = [];
882
+ const secondResults = [];
883
+ let interruptedTurnId;
884
+ // Start a long-running first turn
885
+ orchestrator.sendToOrchestrator("first long request", { type: "background" }, (text, done) => { if (done)
886
+ firstResults.push(text); });
887
+ // Let the first turn get in-flight
888
+ await new Promise((resolve) => setTimeout(resolve, 10));
889
+ assert.ok(state.abortCalls === 0, "no abort yet");
890
+ // Interrupt with a replacement turn
891
+ await orchestrator.interruptCurrentTurn("default", "replacement request", { type: "background" }, (text, done) => { if (done)
892
+ secondResults.push(text); }, undefined, undefined, (abortedId) => { interruptedTurnId = abortedId; });
893
+ assert.equal(state.abortCalls, 1, "abort must be called exactly once");
894
+ // Resolve the pending session so the replacement turn can complete
895
+ state.pendingReject?.(new Error("aborted"));
896
+ await new Promise((resolve) => setTimeout(resolve, 50));
897
+ });
898
+ test("#98: interruptCurrentTurn falls back to normal send when no session is active", async (t) => {
899
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
900
+ sendResult: "fallback response",
901
+ });
902
+ await orchestrator.initOrchestrator(client);
903
+ const results = [];
904
+ // No active turn — should fall back to sendToOrchestrator
905
+ await new Promise((resolve) => {
906
+ orchestrator.interruptCurrentTurn("default", "normal prompt", { type: "background" }, (text, done) => { if (done) {
907
+ results.push(text);
908
+ resolve();
909
+ } });
910
+ });
911
+ assert.equal(results.length, 1, "fallback turn must complete");
912
+ assert.equal(results[0], "fallback response");
913
+ assert.equal(state.abortCalls, 0, "no abort when session is idle");
914
+ });
873
915
  //# sourceMappingURL=orchestrator.test.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared generic ring buffer — extracted from task-event-log.ts (#116) for #130.
3
+ *
4
+ * Fixed-capacity FIFO: when capacity is reached, the oldest item is evicted
5
+ * before the new one is pushed.
6
+ *
7
+ * @module copilot/ring-buffer
8
+ */
9
+ export class RingBuffer {
10
+ _capacity;
11
+ _items;
12
+ constructor(capacity) {
13
+ if (capacity < 1)
14
+ throw new RangeError("RingBuffer capacity must be >= 1");
15
+ this._capacity = capacity;
16
+ this._items = [];
17
+ }
18
+ push(item) {
19
+ if (this._items.length >= this._capacity) {
20
+ this._items.shift(); // evict oldest
21
+ }
22
+ this._items.push(item);
23
+ }
24
+ getAll() {
25
+ return this._items.slice();
26
+ }
27
+ get size() {
28
+ return this._items.length;
29
+ }
30
+ get capacity() {
31
+ return this._capacity;
32
+ }
33
+ }
34
+ //# sourceMappingURL=ring-buffer.js.map
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Unit tests for src/copilot/ring-buffer.ts — #130
3
+ *
4
+ * Covers:
5
+ * 1. Basic push and getAll
6
+ * 2. Capacity enforcement (evicts oldest)
7
+ * 3. getAll returns a copy (not a reference)
8
+ * 4. size and capacity getters
9
+ * 5. RangeError on capacity < 1
10
+ * 6. Single-item buffer
11
+ */
12
+ import { describe, it } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { RingBuffer } from "./ring-buffer.js";
15
+ describe("RingBuffer", () => {
16
+ it("stores and retrieves items in insertion order", () => {
17
+ const buf = new RingBuffer(5);
18
+ buf.push(1);
19
+ buf.push(2);
20
+ buf.push(3);
21
+ assert.deepEqual(buf.getAll(), [1, 2, 3]);
22
+ });
23
+ it("evicts the oldest item when at capacity", () => {
24
+ const buf = new RingBuffer(3);
25
+ buf.push(1);
26
+ buf.push(2);
27
+ buf.push(3);
28
+ buf.push(4); // evicts 1
29
+ assert.deepEqual(buf.getAll(), [2, 3, 4]);
30
+ });
31
+ it("evicts multiple items correctly", () => {
32
+ const buf = new RingBuffer(3);
33
+ for (let i = 1; i <= 6; i++)
34
+ buf.push(i);
35
+ assert.deepEqual(buf.getAll(), [4, 5, 6]);
36
+ });
37
+ it("getAll returns a copy — mutations do not affect the buffer", () => {
38
+ const buf = new RingBuffer(5);
39
+ buf.push(10);
40
+ buf.push(20);
41
+ const snapshot = buf.getAll();
42
+ snapshot.push(999);
43
+ assert.deepEqual(buf.getAll(), [10, 20], "internal array was mutated unexpectedly");
44
+ });
45
+ it("size reflects the number of stored items", () => {
46
+ const buf = new RingBuffer(10);
47
+ assert.equal(buf.size, 0);
48
+ buf.push("a");
49
+ assert.equal(buf.size, 1);
50
+ buf.push("b");
51
+ assert.equal(buf.size, 2);
52
+ });
53
+ it("size does not exceed capacity after overfill", () => {
54
+ const buf = new RingBuffer(3);
55
+ for (let i = 0; i < 100; i++)
56
+ buf.push(i);
57
+ assert.equal(buf.size, 3);
58
+ });
59
+ it("capacity getter returns the constructor value", () => {
60
+ const buf = new RingBuffer(42);
61
+ assert.equal(buf.capacity, 42);
62
+ });
63
+ it("throws RangeError for capacity < 1", () => {
64
+ assert.throws(() => new RingBuffer(0), RangeError);
65
+ assert.throws(() => new RingBuffer(-5), RangeError);
66
+ });
67
+ it("works for a single-item capacity buffer", () => {
68
+ const buf = new RingBuffer(1);
69
+ buf.push("first");
70
+ buf.push("second"); // evicts first
71
+ assert.deepEqual(buf.getAll(), ["second"]);
72
+ assert.equal(buf.size, 1);
73
+ });
74
+ it("stores objects by reference (shallow copy behaviour)", () => {
75
+ const obj = { x: 1 };
76
+ const buf = new RingBuffer(5);
77
+ buf.push(obj);
78
+ obj.x = 42;
79
+ assert.equal(buf.getAll()[0].x, 42, "RingBuffer should not deep-clone items");
80
+ });
81
+ });
82
+ //# sourceMappingURL=ring-buffer.test.js.map
@@ -53,6 +53,8 @@ export class SessionManager {
53
53
  _currentModel;
54
54
  _recentTiers = [];
55
55
  _lastActivityAt = Date.now();
56
+ /** turnId of the turn currently being processed by the worker. Set before worker entry, cleared after. */
57
+ _currentTurnId;
56
58
  /** Set by registry.close() when the session is busy at close time. The drain loop
57
59
  * honors this after the queue fully empties — evicting without violating the
58
60
  * never-evict-mid-turn invariant. */
@@ -77,6 +79,10 @@ export class SessionManager {
77
79
  get lastActivityAt() {
78
80
  return this._lastActivityAt;
79
81
  }
82
+ /** turnId of the turn currently in-flight (being processed by the worker). Undefined when idle. */
83
+ get currentTurnId() {
84
+ return this._currentTurnId;
85
+ }
80
86
  /** True when an explicit close was requested while the session was busy. */
81
87
  get pendingClose() {
82
88
  return this._pendingClose;
@@ -146,11 +152,17 @@ export class SessionManager {
146
152
  this._processing = true;
147
153
  while (this._queue.length > 0) {
148
154
  const item = this._queue.shift();
155
+ // If this item is an interrupt, notify before starting — lets the frontend drop
156
+ // the partial in-flight bubble before the replacement turn begins.
157
+ if (item.isInterrupt && this._currentTurnId) {
158
+ item.onInterrupted?.(this._currentTurnId);
159
+ }
149
160
  // Notify before the worker starts — closing the window where backend queue
150
161
  // length has dropped but the frontend still shows stale "N ahead" counts.
151
162
  item.onAdvance?.(this._queue.length);
163
+ this._currentTurnId = item.turnId;
152
164
  const start = Date.now();
153
- log.info({ sessionKey: this.sessionKey, sourceChannel: item.sourceChannel }, "session.turn.started");
165
+ log.info({ sessionKey: this.sessionKey, sourceChannel: item.sourceChannel, isInterrupt: item.isInterrupt ?? false }, "session.turn.started");
154
166
  try {
155
167
  const result = await this.worker(item, this);
156
168
  const durationMs = Date.now() - start;
@@ -164,6 +176,7 @@ export class SessionManager {
164
176
  }
165
177
  this._lastActivityAt = Date.now();
166
178
  }
179
+ this._currentTurnId = undefined;
167
180
  this._processing = false;
168
181
  // Honor deferred explicit-close: evict now that the queue is empty.
169
182
  if (this._pendingClose && this._queue.length === 0) {
@@ -22,35 +22,12 @@
22
22
  * @module copilot/task-event-log
23
23
  */
24
24
  import { squadEventBus } from "./squad-event-bus.js";
25
+ import { RingBuffer } from "./ring-buffer.js";
25
26
  // ---------------------------------------------------------------------------
26
- // Ring buffer
27
+ // Ring buffer — re-export so existing imports stay compatible
27
28
  // ---------------------------------------------------------------------------
29
+ export { RingBuffer };
28
30
  export const RING_BUFFER_CAPACITY = 500;
29
- export class RingBuffer {
30
- _capacity;
31
- _items;
32
- constructor(capacity) {
33
- if (capacity < 1)
34
- throw new RangeError("RingBuffer capacity must be >= 1");
35
- this._capacity = capacity;
36
- this._items = [];
37
- }
38
- push(item) {
39
- if (this._items.length >= this._capacity) {
40
- this._items.shift(); // evict oldest
41
- }
42
- this._items.push(item);
43
- }
44
- getAll() {
45
- return this._items.slice();
46
- }
47
- get size() {
48
- return this._items.length;
49
- }
50
- get capacity() {
51
- return this._capacity;
52
- }
53
- }
54
31
  // ---------------------------------------------------------------------------
55
32
  // Singleton state
56
33
  // ---------------------------------------------------------------------------