@wrongstack/webui 0.9.19 → 0.10.0
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/index.js
CHANGED
|
@@ -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,
|
|
@@ -477,6 +482,664 @@ Type: ${task.type}`;
|
|
|
477
482
|
}
|
|
478
483
|
};
|
|
479
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
|
+
|
|
480
1143
|
// src/server/worktree-ws-handler.ts
|
|
481
1144
|
var MAX_ACTIVITY = 6;
|
|
482
1145
|
var WorktreeWebSocketHandler = class {
|
|
@@ -659,6 +1322,8 @@ async function startWebUI(opts = {}) {
|
|
|
659
1322
|
const events = new EventBus();
|
|
660
1323
|
events.setLogger(logger);
|
|
661
1324
|
const sessionStore = new DefaultSessionStore2({ dir: wpaths.projectSessions });
|
|
1325
|
+
const sessionReader = new DefaultSessionReader({ store: sessionStore });
|
|
1326
|
+
const annotationsStore = new AnnotationsStore({ dir: wpaths.projectSessions });
|
|
662
1327
|
let session = await sessionStore.create({
|
|
663
1328
|
id: "",
|
|
664
1329
|
title: "",
|
|
@@ -753,6 +1418,13 @@ async function startWebUI(opts = {}) {
|
|
|
753
1418
|
context.meta["contextWindowMode"] = initialContextPolicy.id;
|
|
754
1419
|
context.meta["contextWindowPolicy"] = initialContextPolicy;
|
|
755
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);
|
|
756
1428
|
const compactor = new HybridCompactor2({
|
|
757
1429
|
preserveK: config.context?.preserveK ?? 20,
|
|
758
1430
|
eliseThreshold: config.context?.eliseThreshold ?? 0.7
|
|
@@ -826,6 +1498,13 @@ async function startWebUI(opts = {}) {
|
|
|
826
1498
|
projectRoot
|
|
827
1499
|
);
|
|
828
1500
|
const worktreeHandler = new WorktreeWebSocketHandler(events, logger);
|
|
1501
|
+
const collabHandler = new CollaborationWebSocketHandler(
|
|
1502
|
+
events,
|
|
1503
|
+
logger,
|
|
1504
|
+
sessionReader,
|
|
1505
|
+
annotationsStore,
|
|
1506
|
+
collabBus
|
|
1507
|
+
);
|
|
829
1508
|
async function sessionStartPayload() {
|
|
830
1509
|
let maxContext = 0;
|
|
831
1510
|
let inputCost = 0;
|
|
@@ -1049,6 +1728,7 @@ async function startWebUI(opts = {}) {
|
|
|
1049
1728
|
});
|
|
1050
1729
|
autoPhaseHandler.addClient(ws);
|
|
1051
1730
|
worktreeHandler.addClient(ws);
|
|
1731
|
+
collabHandler.addClient(ws);
|
|
1052
1732
|
ws.on("message", async (data) => {
|
|
1053
1733
|
if (!checkRateLimit(ws, client)) {
|
|
1054
1734
|
send(ws, {
|
|
@@ -1114,8 +1794,18 @@ async function startWebUI(opts = {}) {
|
|
|
1114
1794
|
}
|
|
1115
1795
|
});
|
|
1116
1796
|
}
|
|
1117
|
-
async function handleMessage(ws,
|
|
1797
|
+
async function handleMessage(ws, _client, msg) {
|
|
1118
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
|
+
}
|
|
1119
1809
|
case "user_message": {
|
|
1120
1810
|
const content = msg.payload.content;
|
|
1121
1811
|
if (runLock) {
|
|
@@ -1715,7 +2405,7 @@ async function startWebUI(opts = {}) {
|
|
|
1715
2405
|
break;
|
|
1716
2406
|
}
|
|
1717
2407
|
try {
|
|
1718
|
-
const { getPlanTemplate, loadPlan, savePlan, emptyPlan, addPlanItem
|
|
2408
|
+
const { getPlanTemplate, loadPlan, savePlan, emptyPlan, addPlanItem } = await import("@wrongstack/core");
|
|
1719
2409
|
const tpl = getPlanTemplate(template);
|
|
1720
2410
|
if (!tpl) {
|
|
1721
2411
|
sendResult(ws, false, `Unknown template "${template}".`);
|