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