@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.
- 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 +709 -5
- package/dist/server/entry.js.map +1 -1
- package/dist/server/index.js +709 -5
- 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,
|
|
@@ -62,16 +67,30 @@ function createDefaultContainer(opts) {
|
|
|
62
67
|
container.bind(TOKENS.RetryPolicy, () => new DefaultRetryPolicy());
|
|
63
68
|
container.bind(TOKENS.ErrorHandler, () => new DefaultErrorHandler());
|
|
64
69
|
container.bind(TOKENS.ModelsRegistry, () => modelsRegistry);
|
|
65
|
-
container.bind(
|
|
70
|
+
container.bind(
|
|
71
|
+
TOKENS.TokenCounter,
|
|
72
|
+
() => new DefaultTokenCounter({ registry: modelsRegistry, providerId: config.provider })
|
|
73
|
+
);
|
|
66
74
|
const modeStore = new DefaultModeStore({ directory: wpaths.configDir });
|
|
67
75
|
container.bind(TOKENS.ModeStore, () => modeStore);
|
|
68
|
-
container.bind(
|
|
76
|
+
container.bind(
|
|
77
|
+
TOKENS.SessionStore,
|
|
78
|
+
() => new DefaultSessionStore({
|
|
79
|
+
dir: wpaths.projectSessions,
|
|
80
|
+
// Scrub secrets out of persisted user/model turns (F-06). Tool output
|
|
81
|
+
// is already scrubbed by the executor.
|
|
82
|
+
secretScrubber: container.resolve(TOKENS.SecretScrubber)
|
|
83
|
+
})
|
|
84
|
+
);
|
|
69
85
|
const memoryStore = new DefaultMemoryStore({ paths: wpaths });
|
|
70
86
|
container.bind(TOKENS.MemoryStore, () => memoryStore);
|
|
71
87
|
const skillLoader = new DefaultSkillLoader({ paths: wpaths, bundledDir: opts.bundledSkillsDir });
|
|
72
88
|
container.bind(TOKENS.SkillLoader, () => skillLoader);
|
|
73
89
|
if (opts.systemPrompt) {
|
|
74
|
-
container.bind(
|
|
90
|
+
container.bind(
|
|
91
|
+
TOKENS.SystemPromptBuilder,
|
|
92
|
+
() => new DefaultSystemPromptBuilder(opts.systemPrompt)
|
|
93
|
+
);
|
|
75
94
|
}
|
|
76
95
|
container.bind(
|
|
77
96
|
TOKENS.PermissionPolicy,
|
|
@@ -464,6 +483,664 @@ Type: ${task.type}`;
|
|
|
464
483
|
}
|
|
465
484
|
};
|
|
466
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
|
+
|
|
467
1144
|
// src/server/worktree-ws-handler.ts
|
|
468
1145
|
var MAX_ACTIVITY = 6;
|
|
469
1146
|
var WorktreeWebSocketHandler = class {
|
|
@@ -646,6 +1323,8 @@ async function startWebUI(opts = {}) {
|
|
|
646
1323
|
const events = new EventBus();
|
|
647
1324
|
events.setLogger(logger);
|
|
648
1325
|
const sessionStore = new DefaultSessionStore2({ dir: wpaths.projectSessions });
|
|
1326
|
+
const sessionReader = new DefaultSessionReader({ store: sessionStore });
|
|
1327
|
+
const annotationsStore = new AnnotationsStore({ dir: wpaths.projectSessions });
|
|
649
1328
|
let session = await sessionStore.create({
|
|
650
1329
|
id: "",
|
|
651
1330
|
title: "",
|
|
@@ -740,6 +1419,13 @@ async function startWebUI(opts = {}) {
|
|
|
740
1419
|
context.meta["contextWindowMode"] = initialContextPolicy.id;
|
|
741
1420
|
context.meta["contextWindowPolicy"] = initialContextPolicy;
|
|
742
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);
|
|
743
1429
|
const compactor = new HybridCompactor2({
|
|
744
1430
|
preserveK: config.context?.preserveK ?? 20,
|
|
745
1431
|
eliseThreshold: config.context?.eliseThreshold ?? 0.7
|
|
@@ -813,6 +1499,13 @@ async function startWebUI(opts = {}) {
|
|
|
813
1499
|
projectRoot
|
|
814
1500
|
);
|
|
815
1501
|
const worktreeHandler = new WorktreeWebSocketHandler(events, logger);
|
|
1502
|
+
const collabHandler = new CollaborationWebSocketHandler(
|
|
1503
|
+
events,
|
|
1504
|
+
logger,
|
|
1505
|
+
sessionReader,
|
|
1506
|
+
annotationsStore,
|
|
1507
|
+
collabBus
|
|
1508
|
+
);
|
|
816
1509
|
async function sessionStartPayload() {
|
|
817
1510
|
let maxContext = 0;
|
|
818
1511
|
let inputCost = 0;
|
|
@@ -1036,6 +1729,7 @@ async function startWebUI(opts = {}) {
|
|
|
1036
1729
|
});
|
|
1037
1730
|
autoPhaseHandler.addClient(ws);
|
|
1038
1731
|
worktreeHandler.addClient(ws);
|
|
1732
|
+
collabHandler.addClient(ws);
|
|
1039
1733
|
ws.on("message", async (data) => {
|
|
1040
1734
|
if (!checkRateLimit(ws, client)) {
|
|
1041
1735
|
send(ws, {
|
|
@@ -1101,8 +1795,18 @@ async function startWebUI(opts = {}) {
|
|
|
1101
1795
|
}
|
|
1102
1796
|
});
|
|
1103
1797
|
}
|
|
1104
|
-
async function handleMessage(ws,
|
|
1798
|
+
async function handleMessage(ws, _client, msg) {
|
|
1105
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
|
+
}
|
|
1106
1810
|
case "user_message": {
|
|
1107
1811
|
const content = msg.payload.content;
|
|
1108
1812
|
if (runLock) {
|
|
@@ -1702,7 +2406,7 @@ async function startWebUI(opts = {}) {
|
|
|
1702
2406
|
break;
|
|
1703
2407
|
}
|
|
1704
2408
|
try {
|
|
1705
|
-
const { getPlanTemplate, loadPlan, savePlan, emptyPlan, addPlanItem
|
|
2409
|
+
const { getPlanTemplate, loadPlan, savePlan, emptyPlan, addPlanItem } = await import("@wrongstack/core");
|
|
1706
2410
|
const tpl = getPlanTemplate(template);
|
|
1707
2411
|
if (!tpl) {
|
|
1708
2412
|
sendResult(ws, false, `Unknown template "${template}".`);
|