chainlesschain 0.45.66 → 0.45.70

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,10 @@ export class WSSessionManager {
349
349
  planManager,
350
350
  contextEngine,
351
351
  permanentMemory,
352
+ reviewState: null,
353
+ pendingPatches: new Map(),
354
+ patchHistory: [],
355
+ taskGraph: null,
352
356
  interaction: null, // Set by ws-server after creation
353
357
  createdAt: new Date().toISOString(),
354
358
  lastActivity: new Date().toISOString(),
@@ -456,6 +460,12 @@ export class WSSessionManager {
456
460
  planManager,
457
461
  contextEngine,
458
462
  permanentMemory,
463
+ reviewState: metadata.reviewState || null,
464
+ pendingPatches: this._hydratePendingPatches(metadata.pendingPatches),
465
+ patchHistory: Array.isArray(metadata.patchHistory)
466
+ ? metadata.patchHistory
467
+ : [],
468
+ taskGraph: this._hydrateTaskGraph(metadata.taskGraph),
459
469
  interaction: null,
460
470
  createdAt: dbSession.created_at,
461
471
  lastActivity: new Date().toISOString(),
@@ -611,6 +621,583 @@ export class WSSessionManager {
611
621
  return session;
612
622
  }
613
623
 
624
+ /**
625
+ * Enter explicit review mode for a session. While in review, handlers
626
+ * MUST gate new sendMessage calls until the review is resolved. Reviewer
627
+ * sub-agents and human reviewers both feed into the same `comments` /
628
+ * `checklist` arrays.
629
+ *
630
+ * @param {string} sessionId
631
+ * @param {{
632
+ * reason?: string,
633
+ * requestedBy?: string,
634
+ * checklist?: Array<{ id?: string, title: string, note?: string }>,
635
+ * blocking?: boolean,
636
+ * }} [options]
637
+ */
638
+ enterReview(sessionId, options = {}) {
639
+ const session = this.sessions.get(sessionId);
640
+ if (!session) return null;
641
+
642
+ // If already in pending review, return the existing state unchanged so
643
+ // callers can retry safely.
644
+ if (session.reviewState && session.reviewState.status === "pending") {
645
+ return session.reviewState;
646
+ }
647
+
648
+ const reviewId = `review-${this._generateId()}`;
649
+ const now = new Date().toISOString();
650
+ const checklist = Array.isArray(options.checklist)
651
+ ? options.checklist.map((item, index) => ({
652
+ id: item.id || `chk-${index}-${Date.now()}`,
653
+ title: item.title || `Item ${index + 1}`,
654
+ note: item.note || null,
655
+ done: false,
656
+ }))
657
+ : [];
658
+
659
+ session.reviewState = {
660
+ reviewId,
661
+ status: "pending",
662
+ reason: options.reason || null,
663
+ requestedBy: options.requestedBy || "user",
664
+ requestedAt: now,
665
+ resolvedAt: null,
666
+ resolvedBy: null,
667
+ decision: null,
668
+ blocking: options.blocking !== false,
669
+ comments: [],
670
+ checklist,
671
+ };
672
+ session.lastActivity = now;
673
+ this._persistSessionState(sessionId);
674
+ return session.reviewState;
675
+ }
676
+
677
+ /**
678
+ * Submit an incremental update to the active review — append a comment
679
+ * and/or toggle a checklist item. Returns the updated reviewState, or null
680
+ * if the session has no active review.
681
+ *
682
+ * @param {string} sessionId
683
+ * @param {{
684
+ * comment?: { author?: string, content: string },
685
+ * checklistItemId?: string,
686
+ * checklistItemDone?: boolean,
687
+ * checklistItemNote?: string,
688
+ * }} update
689
+ */
690
+ submitReviewComment(sessionId, update = {}) {
691
+ const session = this.sessions.get(sessionId);
692
+ if (!session || !session.reviewState) return null;
693
+ if (session.reviewState.status !== "pending") return null;
694
+
695
+ const now = new Date().toISOString();
696
+
697
+ if (update.comment && update.comment.content) {
698
+ session.reviewState.comments.push({
699
+ id: `cmt-${session.reviewState.comments.length}-${Date.now()}`,
700
+ author: update.comment.author || "user",
701
+ content: String(update.comment.content),
702
+ timestamp: now,
703
+ });
704
+ }
705
+
706
+ if (update.checklistItemId) {
707
+ const item = session.reviewState.checklist.find(
708
+ (c) => c.id === update.checklistItemId,
709
+ );
710
+ if (item) {
711
+ if (typeof update.checklistItemDone === "boolean") {
712
+ item.done = update.checklistItemDone;
713
+ }
714
+ if (typeof update.checklistItemNote === "string") {
715
+ item.note = update.checklistItemNote;
716
+ }
717
+ }
718
+ }
719
+
720
+ session.lastActivity = now;
721
+ this._persistSessionState(sessionId);
722
+ return session.reviewState;
723
+ }
724
+
725
+ /**
726
+ * Resolve the active review with an approved/rejected decision. After
727
+ * resolve the session can accept new messages again (reviewState becomes
728
+ * non-blocking but is retained for audit).
729
+ *
730
+ * @param {string} sessionId
731
+ * @param {{ decision: "approved"|"rejected", resolvedBy?: string, summary?: string }} payload
732
+ */
733
+ resolveReview(sessionId, payload = {}) {
734
+ const session = this.sessions.get(sessionId);
735
+ if (!session || !session.reviewState) return null;
736
+ if (session.reviewState.status !== "pending") {
737
+ return session.reviewState;
738
+ }
739
+
740
+ const decision =
741
+ payload.decision === "approved" || payload.decision === "rejected"
742
+ ? payload.decision
743
+ : "approved";
744
+
745
+ session.reviewState.status = decision;
746
+ session.reviewState.decision = decision;
747
+ session.reviewState.resolvedAt = new Date().toISOString();
748
+ session.reviewState.resolvedBy = payload.resolvedBy || "user";
749
+ session.reviewState.blocking = false;
750
+ if (payload.summary) {
751
+ session.reviewState.summary = String(payload.summary);
752
+ }
753
+
754
+ session.lastActivity = session.reviewState.resolvedAt;
755
+ this._persistSessionState(sessionId);
756
+ return session.reviewState;
757
+ }
758
+
759
+ /**
760
+ * Returns true when the session currently has a blocking review gate
761
+ * open. Callers (e.g. handleSessionMessage) should short-circuit with a
762
+ * REVIEW_BLOCKING error instead of running the agent turn.
763
+ */
764
+ isReviewBlocking(sessionId) {
765
+ const session = this.sessions.get(sessionId);
766
+ if (!session || !session.reviewState) return false;
767
+ return (
768
+ session.reviewState.status === "pending" &&
769
+ session.reviewState.blocking === true
770
+ );
771
+ }
772
+
773
+ getReviewState(sessionId) {
774
+ const session = this.sessions.get(sessionId);
775
+ return session ? session.reviewState || null : null;
776
+ }
777
+
778
+ /**
779
+ * Record a proposed patch on the session. Accepts one or more file hunks
780
+ * that a tool wanted to write but should be previewed before they land.
781
+ *
782
+ * @param {string} sessionId
783
+ * @param {{
784
+ * files: Array<{
785
+ * path: string,
786
+ * op?: "create"|"modify"|"delete",
787
+ * before?: string|null,
788
+ * after?: string|null,
789
+ * diff?: string|null,
790
+ * stats?: { added?: number, removed?: number }
791
+ * }>,
792
+ * origin?: string,
793
+ * reason?: string,
794
+ * requestId?: string|null
795
+ * }} payload
796
+ * @returns {object|null} patch record, or null if the session is missing
797
+ */
798
+ proposePatch(sessionId, payload = {}) {
799
+ const session = this.sessions.get(sessionId);
800
+ if (!session) return null;
801
+
802
+ const files = Array.isArray(payload.files) ? payload.files : [];
803
+ if (files.length === 0) return null;
804
+
805
+ const patchId = `patch-${this._generateId()}`;
806
+ const now = new Date().toISOString();
807
+ const normalizedFiles = files.map((file, index) => {
808
+ const op = file.op || (file.before == null ? "create" : "modify");
809
+ const stats = this._computePatchStats(file);
810
+ return {
811
+ index,
812
+ path: file.path || `unknown-${index}`,
813
+ op,
814
+ before: file.before == null ? null : String(file.before),
815
+ after: file.after == null ? null : String(file.after),
816
+ diff: file.diff == null ? null : String(file.diff),
817
+ stats,
818
+ };
819
+ });
820
+
821
+ const totalStats = normalizedFiles.reduce(
822
+ (acc, file) => ({
823
+ added: acc.added + (file.stats.added || 0),
824
+ removed: acc.removed + (file.stats.removed || 0),
825
+ }),
826
+ { added: 0, removed: 0 },
827
+ );
828
+
829
+ const patch = {
830
+ patchId,
831
+ status: "pending",
832
+ origin: payload.origin || "tool",
833
+ reason: payload.reason || null,
834
+ requestId: payload.requestId || null,
835
+ proposedAt: now,
836
+ resolvedAt: null,
837
+ resolvedBy: null,
838
+ files: normalizedFiles,
839
+ stats: {
840
+ fileCount: normalizedFiles.length,
841
+ added: totalStats.added,
842
+ removed: totalStats.removed,
843
+ },
844
+ };
845
+
846
+ if (!(session.pendingPatches instanceof Map)) {
847
+ session.pendingPatches = new Map();
848
+ }
849
+ session.pendingPatches.set(patchId, patch);
850
+ session.lastActivity = now;
851
+ this._persistSessionState(sessionId);
852
+ return patch;
853
+ }
854
+
855
+ /**
856
+ * Mark a pending patch as applied. Moves the record to patchHistory so it
857
+ * is still visible in the summary view but no longer counts as pending.
858
+ */
859
+ applyPatch(sessionId, patchId, options = {}) {
860
+ const session = this.sessions.get(sessionId);
861
+ if (!session || !(session.pendingPatches instanceof Map)) return null;
862
+ const patch = session.pendingPatches.get(patchId);
863
+ if (!patch) return null;
864
+
865
+ patch.status = "applied";
866
+ patch.resolvedAt = new Date().toISOString();
867
+ patch.resolvedBy = options.resolvedBy || "user";
868
+ if (options.note) {
869
+ patch.note = String(options.note);
870
+ }
871
+
872
+ session.pendingPatches.delete(patchId);
873
+ if (!Array.isArray(session.patchHistory)) {
874
+ session.patchHistory = [];
875
+ }
876
+ session.patchHistory.push(patch);
877
+ session.lastActivity = patch.resolvedAt;
878
+ this._persistSessionState(sessionId);
879
+ return patch;
880
+ }
881
+
882
+ /**
883
+ * Discard a pending patch. Same bookkeeping as applyPatch but records a
884
+ * "rejected" decision instead.
885
+ */
886
+ rejectPatch(sessionId, patchId, options = {}) {
887
+ const session = this.sessions.get(sessionId);
888
+ if (!session || !(session.pendingPatches instanceof Map)) return null;
889
+ const patch = session.pendingPatches.get(patchId);
890
+ if (!patch) return null;
891
+
892
+ patch.status = "rejected";
893
+ patch.resolvedAt = new Date().toISOString();
894
+ patch.resolvedBy = options.resolvedBy || "user";
895
+ if (options.reason) {
896
+ patch.rejectionReason = String(options.reason);
897
+ }
898
+
899
+ session.pendingPatches.delete(patchId);
900
+ if (!Array.isArray(session.patchHistory)) {
901
+ session.patchHistory = [];
902
+ }
903
+ session.patchHistory.push(patch);
904
+ session.lastActivity = patch.resolvedAt;
905
+ this._persistSessionState(sessionId);
906
+ return patch;
907
+ }
908
+
909
+ /**
910
+ * Return a flattened summary of all pending + resolved patches on the
911
+ * session. Shape matches what the renderer strip consumes:
912
+ * { pending: [...], history: [...], totals: { added, removed, fileCount } }
913
+ */
914
+ getPatchSummary(sessionId) {
915
+ const session = this.sessions.get(sessionId);
916
+ if (!session) return null;
917
+
918
+ const pending =
919
+ session.pendingPatches instanceof Map
920
+ ? Array.from(session.pendingPatches.values())
921
+ : [];
922
+ const history = Array.isArray(session.patchHistory)
923
+ ? session.patchHistory
924
+ : [];
925
+
926
+ const totals = [...pending, ...history].reduce(
927
+ (acc, patch) => ({
928
+ fileCount: acc.fileCount + (patch.stats?.fileCount || 0),
929
+ added: acc.added + (patch.stats?.added || 0),
930
+ removed: acc.removed + (patch.stats?.removed || 0),
931
+ }),
932
+ { fileCount: 0, added: 0, removed: 0 },
933
+ );
934
+
935
+ return { pending, history, totals };
936
+ }
937
+
938
+ hasPendingPatches(sessionId) {
939
+ const session = this.sessions.get(sessionId);
940
+ if (!session || !(session.pendingPatches instanceof Map)) return false;
941
+ return session.pendingPatches.size > 0;
942
+ }
943
+
944
+ _computePatchStats(file) {
945
+ if (file && file.stats && typeof file.stats === "object") {
946
+ return {
947
+ added: Number(file.stats.added) || 0,
948
+ removed: Number(file.stats.removed) || 0,
949
+ };
950
+ }
951
+ const before = file && typeof file.before === "string" ? file.before : "";
952
+ const after = file && typeof file.after === "string" ? file.after : "";
953
+ const beforeLines = before ? before.split(/\r?\n/).length : 0;
954
+ const afterLines = after ? after.split(/\r?\n/).length : 0;
955
+ // Rough heuristic when no explicit diff is provided: full replace counts
956
+ // the entire file as added/removed.
957
+ if (!before && after) return { added: afterLines, removed: 0 };
958
+ if (before && !after) return { added: 0, removed: beforeLines };
959
+ return {
960
+ added: Math.max(0, afterLines - beforeLines),
961
+ removed: Math.max(0, beforeLines - afterLines),
962
+ };
963
+ }
964
+
965
+ /**
966
+ * Create or replace the task graph for a session. A graph is a DAG of
967
+ * `nodes` keyed by id; each node has `{ id, title, status, dependsOn[],
968
+ * metadata }`. Returns the serialized graph.
969
+ */
970
+ createTaskGraph(sessionId, payload = {}) {
971
+ const session = this.sessions.get(sessionId);
972
+ if (!session) return null;
973
+
974
+ const graphId = payload.graphId || `graph-${this._generateId()}`;
975
+ const now = new Date().toISOString();
976
+ const nodes = {};
977
+ const incomingNodes = Array.isArray(payload.nodes) ? payload.nodes : [];
978
+ for (const raw of incomingNodes) {
979
+ if (!raw || !raw.id) continue;
980
+ nodes[raw.id] = this._normalizeTaskNode(raw, now);
981
+ }
982
+
983
+ const graph = {
984
+ graphId,
985
+ title: payload.title || null,
986
+ description: payload.description || null,
987
+ status: "active",
988
+ createdAt: now,
989
+ updatedAt: now,
990
+ completedAt: null,
991
+ nodes,
992
+ order: Object.keys(nodes),
993
+ };
994
+
995
+ session.taskGraph = graph;
996
+ session.lastActivity = now;
997
+ this._persistSessionState(sessionId);
998
+ return this._cloneTaskGraph(graph);
999
+ }
1000
+
1001
+ /**
1002
+ * Add a node to the existing task graph. Fails if no graph exists or if
1003
+ * the node id already exists.
1004
+ */
1005
+ addTaskNode(sessionId, payload = {}) {
1006
+ const session = this.sessions.get(sessionId);
1007
+ if (!session || !session.taskGraph) return null;
1008
+ if (!payload || !payload.id) return null;
1009
+ const graph = session.taskGraph;
1010
+ if (graph.nodes[payload.id]) return null;
1011
+
1012
+ const now = new Date().toISOString();
1013
+ graph.nodes[payload.id] = this._normalizeTaskNode(payload, now);
1014
+ graph.order = [...(graph.order || []), payload.id];
1015
+ graph.updatedAt = now;
1016
+ session.lastActivity = now;
1017
+ this._persistSessionState(sessionId);
1018
+ return this._cloneTaskGraph(graph);
1019
+ }
1020
+
1021
+ /**
1022
+ * Update a node's status / metadata. Valid statuses: pending, ready,
1023
+ * running, completed, failed, skipped.
1024
+ */
1025
+ updateTaskNode(sessionId, nodeId, updates = {}) {
1026
+ const session = this.sessions.get(sessionId);
1027
+ if (!session || !session.taskGraph) return null;
1028
+ const graph = session.taskGraph;
1029
+ const node = graph.nodes[nodeId];
1030
+ if (!node) return null;
1031
+
1032
+ const now = new Date().toISOString();
1033
+ if (updates.status) {
1034
+ node.status = String(updates.status);
1035
+ if (node.status === "running" && !node.startedAt) {
1036
+ node.startedAt = now;
1037
+ }
1038
+ if (
1039
+ node.status === "completed" ||
1040
+ node.status === "failed" ||
1041
+ node.status === "skipped"
1042
+ ) {
1043
+ node.completedAt = now;
1044
+ }
1045
+ }
1046
+ if (updates.title !== undefined) node.title = updates.title;
1047
+ if (updates.result !== undefined) node.result = updates.result;
1048
+ if (updates.error !== undefined) node.error = updates.error;
1049
+ if (updates.metadata !== undefined) {
1050
+ node.metadata = { ...(node.metadata || {}), ...(updates.metadata || {}) };
1051
+ }
1052
+ node.updatedAt = now;
1053
+ graph.updatedAt = now;
1054
+
1055
+ // Check graph completion
1056
+ const allDone = Object.values(graph.nodes).every((n) =>
1057
+ ["completed", "failed", "skipped"].includes(n.status),
1058
+ );
1059
+ if (allDone) {
1060
+ graph.status = Object.values(graph.nodes).some(
1061
+ (n) => n.status === "failed",
1062
+ )
1063
+ ? "failed"
1064
+ : "completed";
1065
+ graph.completedAt = now;
1066
+ }
1067
+
1068
+ session.lastActivity = now;
1069
+ this._persistSessionState(sessionId);
1070
+ return this._cloneTaskGraph(graph);
1071
+ }
1072
+
1073
+ /**
1074
+ * Advance the task graph: mark any `pending` node whose dependencies are
1075
+ * all `completed` (or `skipped`) as `ready`. Returns the list of node ids
1076
+ * that became ready and the updated graph snapshot.
1077
+ */
1078
+ advanceTaskGraph(sessionId) {
1079
+ const session = this.sessions.get(sessionId);
1080
+ if (!session || !session.taskGraph) return null;
1081
+ const graph = session.taskGraph;
1082
+
1083
+ const becameReady = [];
1084
+ for (const node of Object.values(graph.nodes)) {
1085
+ if (node.status !== "pending") continue;
1086
+ const deps = Array.isArray(node.dependsOn) ? node.dependsOn : [];
1087
+ const blocked = deps.some((depId) => {
1088
+ const dep = graph.nodes[depId];
1089
+ if (!dep) return true;
1090
+ return dep.status !== "completed" && dep.status !== "skipped";
1091
+ });
1092
+ if (!blocked) {
1093
+ node.status = "ready";
1094
+ node.updatedAt = new Date().toISOString();
1095
+ becameReady.push(node.id);
1096
+ }
1097
+ }
1098
+
1099
+ if (becameReady.length > 0) {
1100
+ graph.updatedAt = new Date().toISOString();
1101
+ session.lastActivity = graph.updatedAt;
1102
+ this._persistSessionState(sessionId);
1103
+ }
1104
+
1105
+ return {
1106
+ graph: this._cloneTaskGraph(graph),
1107
+ becameReady,
1108
+ };
1109
+ }
1110
+
1111
+ getTaskGraph(sessionId) {
1112
+ const session = this.sessions.get(sessionId);
1113
+ if (!session || !session.taskGraph) return null;
1114
+ return this._cloneTaskGraph(session.taskGraph);
1115
+ }
1116
+
1117
+ clearTaskGraph(sessionId) {
1118
+ const session = this.sessions.get(sessionId);
1119
+ if (!session) return false;
1120
+ session.taskGraph = null;
1121
+ session.lastActivity = new Date().toISOString();
1122
+ this._persistSessionState(sessionId);
1123
+ return true;
1124
+ }
1125
+
1126
+ _normalizeTaskNode(raw, now) {
1127
+ const status = raw.status || "pending";
1128
+ return {
1129
+ id: raw.id,
1130
+ title: raw.title || raw.id,
1131
+ description: raw.description || null,
1132
+ status,
1133
+ dependsOn: Array.isArray(raw.dependsOn)
1134
+ ? raw.dependsOn.filter((x) => typeof x === "string")
1135
+ : [],
1136
+ metadata:
1137
+ raw.metadata && typeof raw.metadata === "object" ? raw.metadata : {},
1138
+ createdAt: raw.createdAt || now,
1139
+ updatedAt: raw.updatedAt || now,
1140
+ startedAt: raw.startedAt || null,
1141
+ completedAt: raw.completedAt || null,
1142
+ result: raw.result || null,
1143
+ error: raw.error || null,
1144
+ };
1145
+ }
1146
+
1147
+ _cloneTaskGraph(graph) {
1148
+ if (!graph) return null;
1149
+ return {
1150
+ graphId: graph.graphId,
1151
+ title: graph.title,
1152
+ description: graph.description,
1153
+ status: graph.status,
1154
+ createdAt: graph.createdAt,
1155
+ updatedAt: graph.updatedAt,
1156
+ completedAt: graph.completedAt,
1157
+ order: Array.isArray(graph.order)
1158
+ ? [...graph.order]
1159
+ : Object.keys(graph.nodes || {}),
1160
+ nodes: Object.fromEntries(
1161
+ Object.entries(graph.nodes || {}).map(([id, node]) => [
1162
+ id,
1163
+ {
1164
+ ...node,
1165
+ dependsOn: [...(node.dependsOn || [])],
1166
+ metadata: { ...(node.metadata || {}) },
1167
+ },
1168
+ ]),
1169
+ ),
1170
+ };
1171
+ }
1172
+
1173
+ _hydrateTaskGraph(data) {
1174
+ if (!data || typeof data !== "object") return null;
1175
+ if (!data.graphId || !data.nodes) return null;
1176
+ const nodes = {};
1177
+ for (const [id, node] of Object.entries(data.nodes)) {
1178
+ nodes[id] = this._normalizeTaskNode(
1179
+ { ...node, id },
1180
+ node.createdAt || new Date().toISOString(),
1181
+ );
1182
+ }
1183
+ return {
1184
+ graphId: data.graphId,
1185
+ title: data.title || null,
1186
+ description: data.description || null,
1187
+ status: data.status || "active",
1188
+ createdAt: data.createdAt || new Date().toISOString(),
1189
+ updatedAt: data.updatedAt || new Date().toISOString(),
1190
+ completedAt: data.completedAt || null,
1191
+ order: Array.isArray(data.order) ? data.order : Object.keys(nodes),
1192
+ nodes,
1193
+ };
1194
+ }
1195
+
1196
+ _serializeTaskGraph(graph) {
1197
+ if (!graph) return null;
1198
+ return this._cloneTaskGraph(graph);
1199
+ }
1200
+
614
1201
  /**
615
1202
  * Persist current messages for a session.
616
1203
  */
@@ -689,9 +1276,30 @@ export class WSSessionManager {
689
1276
  worktreeIsolation: session.worktreeIsolation === true,
690
1277
  worktree: session.worktree || null,
691
1278
  planSnapshot: this._serializePlanManager(session.planManager),
1279
+ reviewState: session.reviewState || null,
1280
+ pendingPatches:
1281
+ session.pendingPatches instanceof Map
1282
+ ? Array.from(session.pendingPatches.values())
1283
+ : [],
1284
+ patchHistory: Array.isArray(session.patchHistory)
1285
+ ? session.patchHistory
1286
+ : [],
1287
+ taskGraph: this._serializeTaskGraph(session.taskGraph),
692
1288
  };
693
1289
  }
694
1290
 
1291
+ _hydratePendingPatches(list) {
1292
+ const map = new Map();
1293
+ if (Array.isArray(list)) {
1294
+ for (const patch of list) {
1295
+ if (patch && patch.patchId) {
1296
+ map.set(patch.patchId, patch);
1297
+ }
1298
+ }
1299
+ }
1300
+ return map;
1301
+ }
1302
+
695
1303
  _serializePlanManager(planManager) {
696
1304
  if (!planManager) {
697
1305
  return null;
@@ -78,6 +78,50 @@ 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",
111
+
112
+ // Persistent task graph + orchestrator — a session-scoped DAG of tasks
113
+ // with dependencies. The runtime serializes the graph to session metadata
114
+ // so it survives CLI restarts; the orchestrator advances the graph by
115
+ // marking ready nodes as `running` when their dependencies complete.
116
+ TASK_GRAPH_CREATED: "task-graph.created",
117
+ TASK_GRAPH_UPDATED: "task-graph.updated",
118
+ TASK_GRAPH_NODE_ADDED: "task-graph.node.added",
119
+ TASK_GRAPH_NODE_UPDATED: "task-graph.node.updated",
120
+ TASK_GRAPH_NODE_COMPLETED: "task-graph.node.completed",
121
+ TASK_GRAPH_NODE_FAILED: "task-graph.node.failed",
122
+ TASK_GRAPH_ADVANCED: "task-graph.advanced",
123
+ TASK_GRAPH_COMPLETED: "task-graph.completed",
124
+ TASK_GRAPH_STATE: "task-graph.state",
81
125
  });
82
126
 
83
127
  const VALID_TYPE_SET = new Set(Object.values(CODING_AGENT_EVENT_TYPES));
@@ -330,6 +374,7 @@ const CodingAgentEventType = Object.freeze({
330
374
  SERVER_STOPPED: CODING_AGENT_EVENT_TYPES.SERVER_STOPPED,
331
375
  SESSION_CREATED: CODING_AGENT_EVENT_TYPES.SESSION_STARTED,
332
376
  SESSION_RESUMED: CODING_AGENT_EVENT_TYPES.SESSION_RESUMED,
377
+ SESSION_INTERRUPTED: CODING_AGENT_EVENT_TYPES.SESSION_INTERRUPTED,
333
378
  SESSION_CLOSED: CODING_AGENT_EVENT_TYPES.SESSION_CLOSED,
334
379
  SESSION_LIST: CODING_AGENT_EVENT_TYPES.SESSION_LIST,
335
380
  WORKTREE_LIST: CODING_AGENT_EVENT_TYPES.WORKTREE_LIST,