@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.
- package/dist/assets/index-CT8FjKJZ.css +1 -0
- package/dist/assets/index-Cv19VZgJ.js +94 -0
- package/dist/assets/{vendor-Dff2jyfM.js → vendor-oYD55Pw4.js} +126 -96
- package/dist/index.css +47 -0
- package/dist/index.css.map +1 -1
- package/dist/index.html +3 -3
- package/dist/index.js +752 -467
- package/dist/index.js.map +1 -1
- package/dist/server/entry.js +692 -2
- package/dist/server/entry.js.map +1 -1
- package/dist/server/index.js +692 -2
- package/dist/server/index.js.map +1 -1
- package/package.json +5 -5
- package/dist/assets/index-B5qzSV8A.js +0 -94
- package/dist/assets/index-BTevO8Vz.css +0 -1
package/dist/server/entry.js
CHANGED
|
@@ -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,
|
|
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
|
|
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}".`);
|