@wrongstack/webui 0.9.19 → 0.9.20

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.
@@ -10,10 +10,15 @@ import {
10
10
  DefaultMemoryStore as DefaultMemoryStore2,
11
11
  DefaultModeStore as DefaultModeStore2,
12
12
  DefaultModelsRegistry,
13
+ DefaultSessionReader,
13
14
  DefaultSessionStore as DefaultSessionStore2,
14
15
  DefaultSkillLoader as DefaultSkillLoader2,
15
16
  DefaultSystemPromptBuilder as DefaultSystemPromptBuilder2,
16
17
  DefaultTokenCounter as DefaultTokenCounter2,
18
+ AnnotationsStore,
19
+ CollaborationBus,
20
+ collabPauseMiddleware,
21
+ collabInjectMiddleware,
17
22
  estimateRequestTokens,
18
23
  EventBus,
19
24
  HybridCompactor as HybridCompactor2,
@@ -478,6 +483,664 @@ Type: ${task.type}`;
478
483
  }
479
484
  };
480
485
 
486
+ // src/server/collaboration-ws-handler.ts
487
+ import { randomUUID } from "crypto";
488
+ var REPLAY_LIMIT = 50;
489
+ var PAUSE_TIMEOUT_MS = 6e4;
490
+ var CollaborationWebSocketHandler = class {
491
+ constructor(events, logger, reader, annotations, bus) {
492
+ this.events = events;
493
+ this.logger = logger;
494
+ this.reader = reader;
495
+ this.annotations = annotations;
496
+ this.bus = bus;
497
+ this.subscribe();
498
+ }
499
+ events;
500
+ logger;
501
+ reader;
502
+ annotations;
503
+ bus;
504
+ clients = /* @__PURE__ */ new Set();
505
+ /** sessionId → participants currently watching it. */
506
+ bySession = /* @__PURE__ */ new Map();
507
+ broadcastInterval = null;
508
+ offs = [];
509
+ // ── Public API (called by server/index.ts per WS connection) ───────────
510
+ addClient(ws) {
511
+ this.clients.add(ws);
512
+ this.ensureBroadcast();
513
+ ws.on("close", () => this.handleDisconnect(ws));
514
+ ws.on("error", () => this.handleDisconnect(ws));
515
+ }
516
+ dispose() {
517
+ for (const off of this.offs) off();
518
+ this.offs.length = 0;
519
+ this.stopBroadcast();
520
+ }
521
+ // ── Inbound client messages ────────────────────────────────────────────
522
+ /**
523
+ * Dispatch a parsed client message. Returns true when the message was
524
+ * recognized and handled; false when the caller should ignore / log.
525
+ * Phase 1 only knows `collab.join` and `collab.leave`; unknown types
526
+ * return false so the upstream router can decide.
527
+ */
528
+ handleMessage(ws, msg) {
529
+ if (msg.type === "collab.join") {
530
+ const payload = msg.payload;
531
+ if (!payload?.sessionId) {
532
+ this.send(ws, this.errorMessage("collab.join requires sessionId"));
533
+ return true;
534
+ }
535
+ this.join(ws, payload.sessionId, payload.role ?? "observer");
536
+ return true;
537
+ }
538
+ if (msg.type === "collab.leave") {
539
+ this.leave(ws);
540
+ return true;
541
+ }
542
+ if (msg.type === "collab.annotate") {
543
+ void this.handleAnnotate(ws, msg.payload);
544
+ return true;
545
+ }
546
+ if (msg.type === "collab.resolve") {
547
+ void this.handleResolve(ws, msg.payload);
548
+ return true;
549
+ }
550
+ if (msg.type === "collab.request_pause") {
551
+ void this.handleRequestPause(ws, msg.payload);
552
+ return true;
553
+ }
554
+ if (msg.type === "collab.resume") {
555
+ void this.handleResume(ws, msg.payload);
556
+ return true;
557
+ }
558
+ if (msg.type === "collab.grant_control") {
559
+ void this.handleGrantControl(ws, msg.payload);
560
+ return true;
561
+ }
562
+ if (msg.type === "collab.inject_tool") {
563
+ void this.handleInjectTool(ws, msg.payload);
564
+ return true;
565
+ }
566
+ return false;
567
+ }
568
+ // ── Join / leave flow ──────────────────────────────────────────────────
569
+ join(ws, sessionId, role) {
570
+ if (role === "controller" && !this.bus) {
571
+ this.send(
572
+ ws,
573
+ this.errorMessage(
574
+ `role 'controller' is not available: server has no CollaborationBus`
575
+ )
576
+ );
577
+ return;
578
+ }
579
+ if (role === "annotator" && !this.annotations) {
580
+ this.send(
581
+ ws,
582
+ this.errorMessage(
583
+ `role 'annotator' is not available: server has no annotations store`
584
+ )
585
+ );
586
+ return;
587
+ }
588
+ const participant = {
589
+ participantId: randomUUID(),
590
+ ws,
591
+ sessionId,
592
+ role,
593
+ joinedAt: (/* @__PURE__ */ new Date()).toISOString()
594
+ };
595
+ let bucket = this.bySession.get(sessionId);
596
+ if (!bucket) {
597
+ bucket = /* @__PURE__ */ new Set();
598
+ this.bySession.set(sessionId, bucket);
599
+ }
600
+ bucket.add(participant);
601
+ this.send(ws, this.stateMessage(sessionId));
602
+ this.broadcast(sessionId, {
603
+ type: "collab.participant.joined",
604
+ payload: {
605
+ participantId: participant.participantId,
606
+ sessionId,
607
+ role,
608
+ joinedAt: participant.joinedAt
609
+ }
610
+ });
611
+ this.broadcast(sessionId, this.stateMessage(sessionId));
612
+ if (this.reader) {
613
+ this.replayHistory(ws, sessionId).catch((err) => {
614
+ this.logger.debug?.(
615
+ `collab: replay failed for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`
616
+ );
617
+ });
618
+ }
619
+ this.logger.debug?.(
620
+ `collab: participant ${participant.participantId} joined ${sessionId}`
621
+ );
622
+ }
623
+ leave(ws) {
624
+ this.handleDisconnect(ws);
625
+ }
626
+ handleDisconnect(ws) {
627
+ this.clients.delete(ws);
628
+ for (const [sessionId, bucket] of this.bySession) {
629
+ for (const p of bucket) {
630
+ if (p.ws === ws) {
631
+ const leftEvent = {
632
+ type: "collab.participant.left",
633
+ payload: { participantId: p.participantId, sessionId }
634
+ };
635
+ this.send(ws, leftEvent);
636
+ bucket.delete(p);
637
+ if (bucket.size === 0) {
638
+ this.bySession.delete(sessionId);
639
+ } else {
640
+ this.broadcast(sessionId, leftEvent);
641
+ this.broadcast(sessionId, this.stateMessage(sessionId));
642
+ }
643
+ break;
644
+ }
645
+ }
646
+ }
647
+ if (this.bySession.size === 0) this.stopBroadcast();
648
+ }
649
+ // ── Annotation flow (Phase 2) ───────────────────────────────────────────
650
+ /**
651
+ * Look up the participant record for a given WS across all sessions.
652
+ * Returns null when the WS hasn't joined (e.g. the client sent a
653
+ * `collab.annotate` before `collab.join`).
654
+ */
655
+ findParticipant(ws) {
656
+ for (const bucket of this.bySession.values()) {
657
+ for (const p of bucket) {
658
+ if (p.ws === ws) return p;
659
+ }
660
+ }
661
+ return null;
662
+ }
663
+ async handleAnnotate(ws, raw) {
664
+ if (!this.annotations) {
665
+ this.send(ws, this.errorMessage("annotations store is not configured"));
666
+ return;
667
+ }
668
+ const participant = this.findParticipant(ws);
669
+ if (!participant) {
670
+ this.send(ws, this.errorMessage("annotate requires an active join"));
671
+ return;
672
+ }
673
+ if (participant.role !== "annotator") {
674
+ this.send(
675
+ ws,
676
+ this.errorMessage(
677
+ `annotate requires the 'annotator' role (current: '${participant.role}')`
678
+ )
679
+ );
680
+ return;
681
+ }
682
+ const payload = raw;
683
+ if (!payload?.sessionId || typeof payload.atEventIndex !== "number" || typeof payload.text !== "string") {
684
+ this.send(
685
+ ws,
686
+ this.errorMessage("annotate requires { sessionId, atEventIndex, text }")
687
+ );
688
+ return;
689
+ }
690
+ if (payload.sessionId !== participant.sessionId) {
691
+ this.send(
692
+ ws,
693
+ this.errorMessage(
694
+ `annotate sessionId mismatch (joined: ${participant.sessionId})`
695
+ )
696
+ );
697
+ return;
698
+ }
699
+ try {
700
+ const annotation = await this.annotations.add({
701
+ sessionId: payload.sessionId,
702
+ atEventIndex: payload.atEventIndex,
703
+ authorId: participant.participantId,
704
+ text: payload.text
705
+ });
706
+ this.broadcast(payload.sessionId, {
707
+ type: "collab.annotation.added",
708
+ payload: {
709
+ sessionId: payload.sessionId,
710
+ annotation: {
711
+ id: annotation.id,
712
+ atEventIndex: annotation.atEventIndex,
713
+ authorId: annotation.authorId,
714
+ authorRole: annotation.authorRole,
715
+ text: annotation.text,
716
+ createdAt: annotation.createdAt,
717
+ resolved: annotation.resolved
718
+ }
719
+ }
720
+ });
721
+ } catch (err) {
722
+ this.send(
723
+ ws,
724
+ this.errorMessage(
725
+ `annotation rejected: ${err instanceof Error ? err.message : String(err)}`
726
+ )
727
+ );
728
+ }
729
+ }
730
+ async handleResolve(ws, raw) {
731
+ if (!this.annotations) {
732
+ this.send(ws, this.errorMessage("annotations store is not configured"));
733
+ return;
734
+ }
735
+ const participant = this.findParticipant(ws);
736
+ if (!participant) {
737
+ this.send(ws, this.errorMessage("resolve requires an active join"));
738
+ return;
739
+ }
740
+ if (participant.role !== "annotator") {
741
+ this.send(
742
+ ws,
743
+ this.errorMessage(
744
+ `resolve requires the 'annotator' role (current: '${participant.role}')`
745
+ )
746
+ );
747
+ return;
748
+ }
749
+ const payload = raw;
750
+ if (!payload?.sessionId || !payload.annotationId) {
751
+ this.send(
752
+ ws,
753
+ this.errorMessage("resolve requires { sessionId, annotationId }")
754
+ );
755
+ return;
756
+ }
757
+ if (payload.sessionId !== participant.sessionId) {
758
+ this.send(
759
+ ws,
760
+ this.errorMessage(
761
+ `resolve sessionId mismatch (joined: ${participant.sessionId})`
762
+ )
763
+ );
764
+ return;
765
+ }
766
+ try {
767
+ const updated = await this.annotations.resolve({
768
+ sessionId: payload.sessionId,
769
+ annotationId: payload.annotationId,
770
+ resolvedBy: participant.participantId
771
+ });
772
+ if (!updated) {
773
+ this.send(
774
+ ws,
775
+ this.errorMessage(`annotation not found: ${payload.annotationId}`)
776
+ );
777
+ return;
778
+ }
779
+ this.broadcast(payload.sessionId, {
780
+ type: "collab.annotation.resolved",
781
+ payload: {
782
+ sessionId: payload.sessionId,
783
+ annotationId: updated.id,
784
+ resolvedBy: updated.resolvedBy ?? participant.participantId,
785
+ resolvedAt: updated.resolvedAt ?? (/* @__PURE__ */ new Date()).toISOString()
786
+ }
787
+ });
788
+ } catch (err) {
789
+ this.send(
790
+ ws,
791
+ this.errorMessage(
792
+ `resolve failed: ${err instanceof Error ? err.message : String(err)}`
793
+ )
794
+ );
795
+ }
796
+ }
797
+ // ── Event subscription (live mirror) ───────────────────────────────────
798
+ subscribe() {
799
+ const on = this.events.on.bind(this.events);
800
+ const forwarded = [
801
+ ["iteration.started", "iteration.started"],
802
+ ["iteration.completed", "iteration.completed"],
803
+ ["tool.started", "tool.started"],
804
+ ["tool.progress", "tool.progress"],
805
+ ["tool.executed", "tool.executed"],
806
+ ["tool.confirm_needed", "tool.confirm_needed"],
807
+ ["subagent.spawned", "subagent.spawned"],
808
+ ["subagent.task_started", "subagent.task_started"],
809
+ ["subagent.iteration_summary", "subagent.iteration_summary"],
810
+ ["subagent.task_completed", "subagent.task_completed"],
811
+ ["subagent.done", "subagent.done"]
812
+ ];
813
+ for (const [kernelEvent, kind] of forwarded) {
814
+ this.offs.push(
815
+ on(kernelEvent, (raw) => {
816
+ let payload = raw;
817
+ try {
818
+ payload = JSON.parse(JSON.stringify(raw));
819
+ } catch {
820
+ return;
821
+ }
822
+ this.broadcastEvent(kind, payload);
823
+ })
824
+ );
825
+ }
826
+ }
827
+ broadcastEvent(kind, payload) {
828
+ if (this.bySession.size === 0) return;
829
+ const msg = {
830
+ type: "collab.event",
831
+ payload: { kind, payload, at: (/* @__PURE__ */ new Date()).toISOString() }
832
+ };
833
+ const data = JSON.stringify(msg);
834
+ for (const bucket of this.bySession.values()) {
835
+ for (const p of bucket) {
836
+ try {
837
+ if (p.ws.readyState === 1) p.ws.send(data);
838
+ } catch (err) {
839
+ this.logger.debug?.(
840
+ `collab broadcast failed: ${err instanceof Error ? err.message : String(err)}`
841
+ );
842
+ }
843
+ }
844
+ }
845
+ }
846
+ /**
847
+ * Replay the last `REPLAY_LIMIT` events from the on-disk session log
848
+ * to a single observer (the late joiner). Each event is forwarded as
849
+ * a `collab.event` with `replay: true` so the client can distinguish
850
+ * history from the live stream.
851
+ *
852
+ * The session log stores typed `SessionEvent`s (`user_input`,
853
+ * `llm_response`, `tool_result`, etc.) — different from the kernel's
854
+ * bus events. We translate the most useful subset (`tool.*` and
855
+ * `iteration.*`-shaped ones) into the same `kind` namespace the live
856
+ * mirror uses, so the client can render a single activity strip.
857
+ */
858
+ async replayHistory(ws, sessionId) {
859
+ if (!this.reader) return;
860
+ const all = [];
861
+ try {
862
+ for await (const ev of this.reader.replay(sessionId)) {
863
+ all.push(ev);
864
+ }
865
+ } catch (err) {
866
+ this.logger.debug?.(
867
+ `collab: session reader rejected ${sessionId}: ${err instanceof Error ? err.message : String(err)}`
868
+ );
869
+ return;
870
+ }
871
+ const tail = all.slice(-REPLAY_LIMIT);
872
+ if (tail.length === 0) return;
873
+ for (const raw of tail) {
874
+ const ev = raw;
875
+ const kind = this.historyEventToKind(ev);
876
+ if (!kind) continue;
877
+ this.send(ws, {
878
+ type: "collab.event",
879
+ payload: {
880
+ kind,
881
+ payload: ev,
882
+ at: ev.ts ?? (/* @__PURE__ */ new Date()).toISOString(),
883
+ replay: true
884
+ }
885
+ });
886
+ }
887
+ }
888
+ /**
889
+ * Map a stored `SessionEvent` to a `collab.event.kind` so the live
890
+ * strip and the history strip can share a single rendering path.
891
+ * Returns null for events that don't have a meaningful live analog
892
+ * (e.g. `session_start`, file-snapshot bookkeeping, rewind markers).
893
+ */
894
+ historyEventToKind(ev) {
895
+ switch (ev.type) {
896
+ case "user_input":
897
+ return "user_input";
898
+ case "llm_response":
899
+ return "llm_response";
900
+ case "tool_result":
901
+ return "tool.executed";
902
+ case "compaction":
903
+ return "compaction";
904
+ case "error":
905
+ return "error";
906
+ default:
907
+ return null;
908
+ }
909
+ }
910
+ // ── State snapshot + periodic broadcast ────────────────────────────────
911
+ stateMessage(sessionId) {
912
+ const bucket = this.bySession.get(sessionId);
913
+ return {
914
+ type: "collab.state",
915
+ payload: {
916
+ sessionId,
917
+ participants: bucket ? [...bucket].map((p) => ({
918
+ participantId: p.participantId,
919
+ role: p.role,
920
+ joinedAt: p.joinedAt
921
+ })) : []
922
+ }
923
+ };
924
+ }
925
+ ensureBroadcast() {
926
+ if (this.broadcastInterval) return;
927
+ this.broadcastInterval = setInterval(() => {
928
+ for (const sessionId of this.bySession.keys()) {
929
+ this.broadcast(sessionId, this.stateMessage(sessionId));
930
+ }
931
+ }, 2e3);
932
+ }
933
+ stopBroadcast() {
934
+ if (this.broadcastInterval) {
935
+ clearInterval(this.broadcastInterval);
936
+ this.broadcastInterval = null;
937
+ }
938
+ }
939
+ broadcast(sessionId, msg) {
940
+ const data = JSON.stringify(msg);
941
+ const bucket = this.bySession.get(sessionId);
942
+ if (!bucket) return;
943
+ for (const p of bucket) {
944
+ try {
945
+ if (p.ws.readyState === 1) p.ws.send(data);
946
+ } catch (err) {
947
+ this.logger.debug?.(
948
+ `collab broadcast failed: ${err instanceof Error ? err.message : String(err)}`
949
+ );
950
+ }
951
+ }
952
+ }
953
+ send(ws, msg) {
954
+ try {
955
+ if (ws.readyState === 1) ws.send(JSON.stringify(msg));
956
+ } catch {
957
+ }
958
+ }
959
+ errorMessage(detail) {
960
+ return { type: "error", payload: { phase: "collab", message: detail } };
961
+ }
962
+ // ── Controller flow (Phase 3) ───────────────────────────────────────────
963
+ async handleRequestPause(ws, raw) {
964
+ if (!this.bus) {
965
+ this.send(ws, this.errorMessage("pause requires a CollaborationBus"));
966
+ return;
967
+ }
968
+ const participant = this.findParticipant(ws);
969
+ if (!participant) {
970
+ this.send(ws, this.errorMessage("pause requires an active join"));
971
+ return;
972
+ }
973
+ if (participant.role !== "controller") {
974
+ this.send(
975
+ ws,
976
+ this.errorMessage(
977
+ `pause requires the 'controller' role (current: '${participant.role}')`
978
+ )
979
+ );
980
+ return;
981
+ }
982
+ const payload = raw;
983
+ if (!payload?.sessionId || payload.sessionId !== participant.sessionId) {
984
+ this.send(ws, this.errorMessage("pause sessionId mismatch"));
985
+ return;
986
+ }
987
+ const transitioned = this.bus.requestPause(participant.participantId);
988
+ if (!transitioned) {
989
+ const s2 = this.bus.getState();
990
+ this.send(ws, {
991
+ type: "error",
992
+ payload: {
993
+ phase: "collab",
994
+ message: `bus already paused by ${s2.pausedBy ?? "?"} at ${s2.pausedAt ?? "?"}`
995
+ }
996
+ });
997
+ return;
998
+ }
999
+ const s = this.bus.getState();
1000
+ this.broadcast(payload.sessionId, {
1001
+ type: "collab.pause.granted",
1002
+ payload: {
1003
+ sessionId: payload.sessionId,
1004
+ pausedBy: s.pausedBy ?? participant.participantId,
1005
+ pausedAt: s.pausedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1006
+ autoResumeInMs: PAUSE_TIMEOUT_MS
1007
+ }
1008
+ });
1009
+ }
1010
+ async handleResume(ws, raw) {
1011
+ if (!this.bus) {
1012
+ this.send(ws, this.errorMessage("resume requires a CollaborationBus"));
1013
+ return;
1014
+ }
1015
+ const participant = this.findParticipant(ws);
1016
+ if (!participant) {
1017
+ this.send(ws, this.errorMessage("resume requires an active join"));
1018
+ return;
1019
+ }
1020
+ if (participant.role !== "controller") {
1021
+ this.send(
1022
+ ws,
1023
+ this.errorMessage(
1024
+ `resume requires the 'controller' role (current: '${participant.role}')`
1025
+ )
1026
+ );
1027
+ return;
1028
+ }
1029
+ const payload = raw;
1030
+ if (!payload?.sessionId || payload.sessionId !== participant.sessionId) {
1031
+ this.send(ws, this.errorMessage("resume sessionId mismatch"));
1032
+ return;
1033
+ }
1034
+ const transitioned = this.bus.resume();
1035
+ if (!transitioned) {
1036
+ this.send(ws, this.errorMessage("bus is not currently paused"));
1037
+ return;
1038
+ }
1039
+ this.broadcast(payload.sessionId, {
1040
+ type: "collab.pause.released",
1041
+ payload: {
1042
+ sessionId: payload.sessionId,
1043
+ reason: "controller",
1044
+ at: (/* @__PURE__ */ new Date()).toISOString()
1045
+ }
1046
+ });
1047
+ }
1048
+ async handleGrantControl(ws, raw) {
1049
+ const participant = this.findParticipant(ws);
1050
+ if (!participant) {
1051
+ this.send(ws, this.errorMessage("grant_control requires an active join"));
1052
+ return;
1053
+ }
1054
+ const payload = raw;
1055
+ if (!payload?.sessionId || !payload.toParticipant || payload.sessionId !== participant.sessionId) {
1056
+ this.send(ws, this.errorMessage("grant_control requires { sessionId, toParticipant }"));
1057
+ return;
1058
+ }
1059
+ this.logger.debug?.(
1060
+ `collab: control granted from ${participant.participantId} to ${payload.toParticipant} in ${payload.sessionId}`
1061
+ );
1062
+ }
1063
+ /**
1064
+ * Phase 4 — handle a controller's manual tool-call injection.
1065
+ * Validates the payload, queues it on the bus, and broadcasts
1066
+ * the grant so observers see what just happened. The actual
1067
+ * splice into the agent's pipeline is performed by the
1068
+ * `collabInjectMiddleware` on the next tool call.
1069
+ */
1070
+ async handleInjectTool(ws, raw) {
1071
+ if (!this.bus) {
1072
+ this.send(ws, this.errorMessage("inject_tool requires a CollaborationBus"));
1073
+ return;
1074
+ }
1075
+ const participant = this.findParticipant(ws);
1076
+ if (!participant) {
1077
+ this.send(ws, this.errorMessage("inject_tool requires an active join"));
1078
+ return;
1079
+ }
1080
+ if (participant.role !== "controller") {
1081
+ this.send(
1082
+ ws,
1083
+ this.errorMessage(
1084
+ `inject_tool requires the 'controller' role (current: '${participant.role}')`
1085
+ )
1086
+ );
1087
+ return;
1088
+ }
1089
+ const payload = raw;
1090
+ if (!payload?.sessionId || !payload.toolUseId || typeof payload.isError !== "boolean" || typeof payload.reason !== "string" || payload.content === void 0) {
1091
+ this.send(
1092
+ ws,
1093
+ this.errorMessage(
1094
+ "inject_tool requires { sessionId, toolUseId, content, isError, reason }"
1095
+ )
1096
+ );
1097
+ return;
1098
+ }
1099
+ if (payload.sessionId !== participant.sessionId) {
1100
+ this.send(
1101
+ ws,
1102
+ this.errorMessage(
1103
+ `inject_tool sessionId mismatch (joined: ${participant.sessionId})`
1104
+ )
1105
+ );
1106
+ return;
1107
+ }
1108
+ const queued = this.bus.injectToolResult({
1109
+ toolUseId: payload.toolUseId,
1110
+ content: payload.content,
1111
+ isError: payload.isError,
1112
+ reason: payload.reason,
1113
+ authorId: participant.participantId
1114
+ });
1115
+ if (!queued) {
1116
+ this.send(
1117
+ ws,
1118
+ this.errorMessage(
1119
+ `an injection for toolUseId ${payload.toolUseId} is already queued`
1120
+ )
1121
+ );
1122
+ return;
1123
+ }
1124
+ this.broadcast(payload.sessionId, {
1125
+ type: "collab.injection.granted",
1126
+ payload: {
1127
+ sessionId: payload.sessionId,
1128
+ toolUseId: payload.toolUseId,
1129
+ // The tool name is unknown here (the injection is queued
1130
+ // before the model produces the tool call). We surface a
1131
+ // placeholder; the middleware will emit a `consumed` event
1132
+ // with the real name on match.
1133
+ toolName: "(pending match)",
1134
+ authorId: participant.participantId,
1135
+ reason: payload.reason,
1136
+ isError: payload.isError,
1137
+ phase: "queued",
1138
+ at: (/* @__PURE__ */ new Date()).toISOString()
1139
+ }
1140
+ });
1141
+ }
1142
+ };
1143
+
481
1144
  // src/server/worktree-ws-handler.ts
482
1145
  var MAX_ACTIVITY = 6;
483
1146
  var WorktreeWebSocketHandler = class {
@@ -660,6 +1323,8 @@ async function startWebUI(opts = {}) {
660
1323
  const events = new EventBus();
661
1324
  events.setLogger(logger);
662
1325
  const sessionStore = new DefaultSessionStore2({ dir: wpaths.projectSessions });
1326
+ const sessionReader = new DefaultSessionReader({ store: sessionStore });
1327
+ const annotationsStore = new AnnotationsStore({ dir: wpaths.projectSessions });
663
1328
  let session = await sessionStore.create({
664
1329
  id: "",
665
1330
  title: "",
@@ -754,6 +1419,13 @@ async function startWebUI(opts = {}) {
754
1419
  context.meta["contextWindowMode"] = initialContextPolicy.id;
755
1420
  context.meta["contextWindowPolicy"] = initialContextPolicy;
756
1421
  const pipelines = createDefaultPipelines();
1422
+ const collabBus = new CollaborationBus();
1423
+ const collabPause = collabPauseMiddleware(collabBus, { logger });
1424
+ Object.defineProperty(collabPause, "name", { value: "collab-pause" });
1425
+ pipelines.toolCall.prepend(collabPause);
1426
+ const collabInject = collabInjectMiddleware(collabBus, { logger });
1427
+ Object.defineProperty(collabInject, "name", { value: "collab-inject" });
1428
+ pipelines.toolCall.prepend(collabInject);
757
1429
  const compactor = new HybridCompactor2({
758
1430
  preserveK: config.context?.preserveK ?? 20,
759
1431
  eliseThreshold: config.context?.eliseThreshold ?? 0.7
@@ -827,6 +1499,13 @@ async function startWebUI(opts = {}) {
827
1499
  projectRoot
828
1500
  );
829
1501
  const worktreeHandler = new WorktreeWebSocketHandler(events, logger);
1502
+ const collabHandler = new CollaborationWebSocketHandler(
1503
+ events,
1504
+ logger,
1505
+ sessionReader,
1506
+ annotationsStore,
1507
+ collabBus
1508
+ );
830
1509
  async function sessionStartPayload() {
831
1510
  let maxContext = 0;
832
1511
  let inputCost = 0;
@@ -1050,6 +1729,7 @@ async function startWebUI(opts = {}) {
1050
1729
  });
1051
1730
  autoPhaseHandler.addClient(ws);
1052
1731
  worktreeHandler.addClient(ws);
1732
+ collabHandler.addClient(ws);
1053
1733
  ws.on("message", async (data) => {
1054
1734
  if (!checkRateLimit(ws, client)) {
1055
1735
  send(ws, {
@@ -1115,8 +1795,18 @@ async function startWebUI(opts = {}) {
1115
1795
  }
1116
1796
  });
1117
1797
  }
1118
- async function handleMessage(ws, client, msg) {
1798
+ async function handleMessage(ws, _client, msg) {
1119
1799
  switch (msg.type) {
1800
+ // Collaboration messages short-circuit the user/agent flow.
1801
+ // They don't touch runLock, the agent loop, or the message queue —
1802
+ // they're pure transport for the live observer mirror.
1803
+ case "collab.join":
1804
+ case "collab.leave":
1805
+ case "collab.annotate":
1806
+ case "collab.resolve": {
1807
+ collabHandler.handleMessage(ws, msg);
1808
+ return;
1809
+ }
1120
1810
  case "user_message": {
1121
1811
  const content = msg.payload.content;
1122
1812
  if (runLock) {
@@ -1716,7 +2406,7 @@ async function startWebUI(opts = {}) {
1716
2406
  break;
1717
2407
  }
1718
2408
  try {
1719
- const { getPlanTemplate, loadPlan, savePlan, emptyPlan, addPlanItem, formatPlan } = await import("@wrongstack/core");
2409
+ const { getPlanTemplate, loadPlan, savePlan, emptyPlan, addPlanItem } = await import("@wrongstack/core");
1720
2410
  const tpl = getPlanTemplate(template);
1721
2411
  if (!tpl) {
1722
2412
  sendResult(ws, false, `Unknown template "${template}".`);