chainlesschain 0.45.66 → 0.45.67

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.
@@ -349,6 +349,9 @@ export class WSSessionManager {
349
349
  planManager,
350
350
  contextEngine,
351
351
  permanentMemory,
352
+ reviewState: null,
353
+ pendingPatches: new Map(),
354
+ patchHistory: [],
352
355
  interaction: null, // Set by ws-server after creation
353
356
  createdAt: new Date().toISOString(),
354
357
  lastActivity: new Date().toISOString(),
@@ -456,6 +459,11 @@ export class WSSessionManager {
456
459
  planManager,
457
460
  contextEngine,
458
461
  permanentMemory,
462
+ reviewState: metadata.reviewState || null,
463
+ pendingPatches: this._hydratePendingPatches(metadata.pendingPatches),
464
+ patchHistory: Array.isArray(metadata.patchHistory)
465
+ ? metadata.patchHistory
466
+ : [],
459
467
  interaction: null,
460
468
  createdAt: dbSession.created_at,
461
469
  lastActivity: new Date().toISOString(),
@@ -611,6 +619,347 @@ export class WSSessionManager {
611
619
  return session;
612
620
  }
613
621
 
622
+ /**
623
+ * Enter explicit review mode for a session. While in review, handlers
624
+ * MUST gate new sendMessage calls until the review is resolved. Reviewer
625
+ * sub-agents and human reviewers both feed into the same `comments` /
626
+ * `checklist` arrays.
627
+ *
628
+ * @param {string} sessionId
629
+ * @param {{
630
+ * reason?: string,
631
+ * requestedBy?: string,
632
+ * checklist?: Array<{ id?: string, title: string, note?: string }>,
633
+ * blocking?: boolean,
634
+ * }} [options]
635
+ */
636
+ enterReview(sessionId, options = {}) {
637
+ const session = this.sessions.get(sessionId);
638
+ if (!session) return null;
639
+
640
+ // If already in pending review, return the existing state unchanged so
641
+ // callers can retry safely.
642
+ if (session.reviewState && session.reviewState.status === "pending") {
643
+ return session.reviewState;
644
+ }
645
+
646
+ const reviewId = `review-${this._generateId()}`;
647
+ const now = new Date().toISOString();
648
+ const checklist = Array.isArray(options.checklist)
649
+ ? options.checklist.map((item, index) => ({
650
+ id: item.id || `chk-${index}-${Date.now()}`,
651
+ title: item.title || `Item ${index + 1}`,
652
+ note: item.note || null,
653
+ done: false,
654
+ }))
655
+ : [];
656
+
657
+ session.reviewState = {
658
+ reviewId,
659
+ status: "pending",
660
+ reason: options.reason || null,
661
+ requestedBy: options.requestedBy || "user",
662
+ requestedAt: now,
663
+ resolvedAt: null,
664
+ resolvedBy: null,
665
+ decision: null,
666
+ blocking: options.blocking !== false,
667
+ comments: [],
668
+ checklist,
669
+ };
670
+ session.lastActivity = now;
671
+ this._persistSessionState(sessionId);
672
+ return session.reviewState;
673
+ }
674
+
675
+ /**
676
+ * Submit an incremental update to the active review — append a comment
677
+ * and/or toggle a checklist item. Returns the updated reviewState, or null
678
+ * if the session has no active review.
679
+ *
680
+ * @param {string} sessionId
681
+ * @param {{
682
+ * comment?: { author?: string, content: string },
683
+ * checklistItemId?: string,
684
+ * checklistItemDone?: boolean,
685
+ * checklistItemNote?: string,
686
+ * }} update
687
+ */
688
+ submitReviewComment(sessionId, update = {}) {
689
+ const session = this.sessions.get(sessionId);
690
+ if (!session || !session.reviewState) return null;
691
+ if (session.reviewState.status !== "pending") return null;
692
+
693
+ const now = new Date().toISOString();
694
+
695
+ if (update.comment && update.comment.content) {
696
+ session.reviewState.comments.push({
697
+ id: `cmt-${session.reviewState.comments.length}-${Date.now()}`,
698
+ author: update.comment.author || "user",
699
+ content: String(update.comment.content),
700
+ timestamp: now,
701
+ });
702
+ }
703
+
704
+ if (update.checklistItemId) {
705
+ const item = session.reviewState.checklist.find(
706
+ (c) => c.id === update.checklistItemId,
707
+ );
708
+ if (item) {
709
+ if (typeof update.checklistItemDone === "boolean") {
710
+ item.done = update.checklistItemDone;
711
+ }
712
+ if (typeof update.checklistItemNote === "string") {
713
+ item.note = update.checklistItemNote;
714
+ }
715
+ }
716
+ }
717
+
718
+ session.lastActivity = now;
719
+ this._persistSessionState(sessionId);
720
+ return session.reviewState;
721
+ }
722
+
723
+ /**
724
+ * Resolve the active review with an approved/rejected decision. After
725
+ * resolve the session can accept new messages again (reviewState becomes
726
+ * non-blocking but is retained for audit).
727
+ *
728
+ * @param {string} sessionId
729
+ * @param {{ decision: "approved"|"rejected", resolvedBy?: string, summary?: string }} payload
730
+ */
731
+ resolveReview(sessionId, payload = {}) {
732
+ const session = this.sessions.get(sessionId);
733
+ if (!session || !session.reviewState) return null;
734
+ if (session.reviewState.status !== "pending") {
735
+ return session.reviewState;
736
+ }
737
+
738
+ const decision =
739
+ payload.decision === "approved" || payload.decision === "rejected"
740
+ ? payload.decision
741
+ : "approved";
742
+
743
+ session.reviewState.status = decision;
744
+ session.reviewState.decision = decision;
745
+ session.reviewState.resolvedAt = new Date().toISOString();
746
+ session.reviewState.resolvedBy = payload.resolvedBy || "user";
747
+ session.reviewState.blocking = false;
748
+ if (payload.summary) {
749
+ session.reviewState.summary = String(payload.summary);
750
+ }
751
+
752
+ session.lastActivity = session.reviewState.resolvedAt;
753
+ this._persistSessionState(sessionId);
754
+ return session.reviewState;
755
+ }
756
+
757
+ /**
758
+ * Returns true when the session currently has a blocking review gate
759
+ * open. Callers (e.g. handleSessionMessage) should short-circuit with a
760
+ * REVIEW_BLOCKING error instead of running the agent turn.
761
+ */
762
+ isReviewBlocking(sessionId) {
763
+ const session = this.sessions.get(sessionId);
764
+ if (!session || !session.reviewState) return false;
765
+ return (
766
+ session.reviewState.status === "pending" &&
767
+ session.reviewState.blocking === true
768
+ );
769
+ }
770
+
771
+ getReviewState(sessionId) {
772
+ const session = this.sessions.get(sessionId);
773
+ return session ? session.reviewState || null : null;
774
+ }
775
+
776
+ /**
777
+ * Record a proposed patch on the session. Accepts one or more file hunks
778
+ * that a tool wanted to write but should be previewed before they land.
779
+ *
780
+ * @param {string} sessionId
781
+ * @param {{
782
+ * files: Array<{
783
+ * path: string,
784
+ * op?: "create"|"modify"|"delete",
785
+ * before?: string|null,
786
+ * after?: string|null,
787
+ * diff?: string|null,
788
+ * stats?: { added?: number, removed?: number }
789
+ * }>,
790
+ * origin?: string,
791
+ * reason?: string,
792
+ * requestId?: string|null
793
+ * }} payload
794
+ * @returns {object|null} patch record, or null if the session is missing
795
+ */
796
+ proposePatch(sessionId, payload = {}) {
797
+ const session = this.sessions.get(sessionId);
798
+ if (!session) return null;
799
+
800
+ const files = Array.isArray(payload.files) ? payload.files : [];
801
+ if (files.length === 0) return null;
802
+
803
+ const patchId = `patch-${this._generateId()}`;
804
+ const now = new Date().toISOString();
805
+ const normalizedFiles = files.map((file, index) => {
806
+ const op = file.op || (file.before == null ? "create" : "modify");
807
+ const stats = this._computePatchStats(file);
808
+ return {
809
+ index,
810
+ path: file.path || `unknown-${index}`,
811
+ op,
812
+ before: file.before == null ? null : String(file.before),
813
+ after: file.after == null ? null : String(file.after),
814
+ diff: file.diff == null ? null : String(file.diff),
815
+ stats,
816
+ };
817
+ });
818
+
819
+ const totalStats = normalizedFiles.reduce(
820
+ (acc, file) => ({
821
+ added: acc.added + (file.stats.added || 0),
822
+ removed: acc.removed + (file.stats.removed || 0),
823
+ }),
824
+ { added: 0, removed: 0 },
825
+ );
826
+
827
+ const patch = {
828
+ patchId,
829
+ status: "pending",
830
+ origin: payload.origin || "tool",
831
+ reason: payload.reason || null,
832
+ requestId: payload.requestId || null,
833
+ proposedAt: now,
834
+ resolvedAt: null,
835
+ resolvedBy: null,
836
+ files: normalizedFiles,
837
+ stats: {
838
+ fileCount: normalizedFiles.length,
839
+ added: totalStats.added,
840
+ removed: totalStats.removed,
841
+ },
842
+ };
843
+
844
+ if (!(session.pendingPatches instanceof Map)) {
845
+ session.pendingPatches = new Map();
846
+ }
847
+ session.pendingPatches.set(patchId, patch);
848
+ session.lastActivity = now;
849
+ this._persistSessionState(sessionId);
850
+ return patch;
851
+ }
852
+
853
+ /**
854
+ * Mark a pending patch as applied. Moves the record to patchHistory so it
855
+ * is still visible in the summary view but no longer counts as pending.
856
+ */
857
+ applyPatch(sessionId, patchId, options = {}) {
858
+ const session = this.sessions.get(sessionId);
859
+ if (!session || !(session.pendingPatches instanceof Map)) return null;
860
+ const patch = session.pendingPatches.get(patchId);
861
+ if (!patch) return null;
862
+
863
+ patch.status = "applied";
864
+ patch.resolvedAt = new Date().toISOString();
865
+ patch.resolvedBy = options.resolvedBy || "user";
866
+ if (options.note) {
867
+ patch.note = String(options.note);
868
+ }
869
+
870
+ session.pendingPatches.delete(patchId);
871
+ if (!Array.isArray(session.patchHistory)) {
872
+ session.patchHistory = [];
873
+ }
874
+ session.patchHistory.push(patch);
875
+ session.lastActivity = patch.resolvedAt;
876
+ this._persistSessionState(sessionId);
877
+ return patch;
878
+ }
879
+
880
+ /**
881
+ * Discard a pending patch. Same bookkeeping as applyPatch but records a
882
+ * "rejected" decision instead.
883
+ */
884
+ rejectPatch(sessionId, patchId, options = {}) {
885
+ const session = this.sessions.get(sessionId);
886
+ if (!session || !(session.pendingPatches instanceof Map)) return null;
887
+ const patch = session.pendingPatches.get(patchId);
888
+ if (!patch) return null;
889
+
890
+ patch.status = "rejected";
891
+ patch.resolvedAt = new Date().toISOString();
892
+ patch.resolvedBy = options.resolvedBy || "user";
893
+ if (options.reason) {
894
+ patch.rejectionReason = String(options.reason);
895
+ }
896
+
897
+ session.pendingPatches.delete(patchId);
898
+ if (!Array.isArray(session.patchHistory)) {
899
+ session.patchHistory = [];
900
+ }
901
+ session.patchHistory.push(patch);
902
+ session.lastActivity = patch.resolvedAt;
903
+ this._persistSessionState(sessionId);
904
+ return patch;
905
+ }
906
+
907
+ /**
908
+ * Return a flattened summary of all pending + resolved patches on the
909
+ * session. Shape matches what the renderer strip consumes:
910
+ * { pending: [...], history: [...], totals: { added, removed, fileCount } }
911
+ */
912
+ getPatchSummary(sessionId) {
913
+ const session = this.sessions.get(sessionId);
914
+ if (!session) return null;
915
+
916
+ const pending =
917
+ session.pendingPatches instanceof Map
918
+ ? Array.from(session.pendingPatches.values())
919
+ : [];
920
+ const history = Array.isArray(session.patchHistory)
921
+ ? session.patchHistory
922
+ : [];
923
+
924
+ const totals = [...pending, ...history].reduce(
925
+ (acc, patch) => ({
926
+ fileCount: acc.fileCount + (patch.stats?.fileCount || 0),
927
+ added: acc.added + (patch.stats?.added || 0),
928
+ removed: acc.removed + (patch.stats?.removed || 0),
929
+ }),
930
+ { fileCount: 0, added: 0, removed: 0 },
931
+ );
932
+
933
+ return { pending, history, totals };
934
+ }
935
+
936
+ hasPendingPatches(sessionId) {
937
+ const session = this.sessions.get(sessionId);
938
+ if (!session || !(session.pendingPatches instanceof Map)) return false;
939
+ return session.pendingPatches.size > 0;
940
+ }
941
+
942
+ _computePatchStats(file) {
943
+ if (file && file.stats && typeof file.stats === "object") {
944
+ return {
945
+ added: Number(file.stats.added) || 0,
946
+ removed: Number(file.stats.removed) || 0,
947
+ };
948
+ }
949
+ const before = file && typeof file.before === "string" ? file.before : "";
950
+ const after = file && typeof file.after === "string" ? file.after : "";
951
+ const beforeLines = before ? before.split(/\r?\n/).length : 0;
952
+ const afterLines = after ? after.split(/\r?\n/).length : 0;
953
+ // Rough heuristic when no explicit diff is provided: full replace counts
954
+ // the entire file as added/removed.
955
+ if (!before && after) return { added: afterLines, removed: 0 };
956
+ if (before && !after) return { added: 0, removed: beforeLines };
957
+ return {
958
+ added: Math.max(0, afterLines - beforeLines),
959
+ removed: Math.max(0, beforeLines - afterLines),
960
+ };
961
+ }
962
+
614
963
  /**
615
964
  * Persist current messages for a session.
616
965
  */
@@ -689,9 +1038,29 @@ export class WSSessionManager {
689
1038
  worktreeIsolation: session.worktreeIsolation === true,
690
1039
  worktree: session.worktree || null,
691
1040
  planSnapshot: this._serializePlanManager(session.planManager),
1041
+ reviewState: session.reviewState || null,
1042
+ pendingPatches:
1043
+ session.pendingPatches instanceof Map
1044
+ ? Array.from(session.pendingPatches.values())
1045
+ : [],
1046
+ patchHistory: Array.isArray(session.patchHistory)
1047
+ ? session.patchHistory
1048
+ : [],
692
1049
  };
693
1050
  }
694
1051
 
1052
+ _hydratePendingPatches(list) {
1053
+ const map = new Map();
1054
+ if (Array.isArray(list)) {
1055
+ for (const patch of list) {
1056
+ if (patch && patch.patchId) {
1057
+ map.set(patch.patchId, patch);
1058
+ }
1059
+ }
1060
+ }
1061
+ return map;
1062
+ }
1063
+
695
1064
  _serializePlanManager(planManager) {
696
1065
  if (!planManager) {
697
1066
  return null;
@@ -78,6 +78,36 @@ const CODING_AGENT_EVENT_TYPES = Object.freeze({
78
78
  SERVER_STARTING: "runtime.server.starting",
79
79
  SERVER_READY: "runtime.server.ready",
80
80
  SERVER_STOPPED: "runtime.server.stopped",
81
+
82
+ // Sub-agent delegation — lifecycle of child agents spawned from a parent
83
+ // session via the spawn_sub_agent tool. Sequence within a parent requestId
84
+ // stays strictly increasing; parent session + sub-agent id are carried in
85
+ // the payload so UIs can group child events under the parent turn.
86
+ SUB_AGENT_STARTED: "sub-agent.started",
87
+ SUB_AGENT_PROGRESS: "sub-agent.progress",
88
+ SUB_AGENT_COMPLETED: "sub-agent.completed",
89
+ SUB_AGENT_FAILED: "sub-agent.failed",
90
+ SUB_AGENT_LIST: "sub-agent.list",
91
+
92
+ // Review mode — explicit human-in-the-loop (or reviewer sub-agent) gate.
93
+ // When a session enters review mode the runtime MUST block sendMessage
94
+ // until the review is resolved (approved / rejected). Comments can be
95
+ // submitted incrementally by either human reviewers via the UI or by a
96
+ // reviewer role sub-agent writing async findings back to the parent
97
+ // session.
98
+ REVIEW_REQUESTED: "review.requested",
99
+ REVIEW_UPDATED: "review.updated",
100
+ REVIEW_RESOLVED: "review.resolved",
101
+ REVIEW_STATE: "review.state",
102
+
103
+ // Patch preview / diff summary — proposed file edits that the user can
104
+ // preview, approve (apply) or reject before they land on disk. Used to
105
+ // surface a "diff summary" strip in the UI that batches multiple writes
106
+ // from a single turn into a reviewable hunk list.
107
+ PATCH_PROPOSED: "patch.proposed",
108
+ PATCH_APPLIED: "patch.applied",
109
+ PATCH_REJECTED: "patch.rejected",
110
+ PATCH_SUMMARY: "patch.summary",
81
111
  });
82
112
 
83
113
  const VALID_TYPE_SET = new Set(Object.values(CODING_AGENT_EVENT_TYPES));
@@ -330,6 +360,7 @@ const CodingAgentEventType = Object.freeze({
330
360
  SERVER_STOPPED: CODING_AGENT_EVENT_TYPES.SERVER_STOPPED,
331
361
  SESSION_CREATED: CODING_AGENT_EVENT_TYPES.SESSION_STARTED,
332
362
  SESSION_RESUMED: CODING_AGENT_EVENT_TYPES.SESSION_RESUMED,
363
+ SESSION_INTERRUPTED: CODING_AGENT_EVENT_TYPES.SESSION_INTERRUPTED,
333
364
  SESSION_CLOSED: CODING_AGENT_EVENT_TYPES.SESSION_CLOSED,
334
365
  SESSION_LIST: CODING_AGENT_EVENT_TYPES.SESSION_LIST,
335
366
  WORKTREE_LIST: CODING_AGENT_EVENT_TYPES.WORKTREE_LIST,