chainlesschain 0.45.67 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chainlesschain",
3
- "version": "0.45.67",
3
+ "version": "0.45.70",
4
4
  "description": "CLI for ChainlessChain - install, configure, and manage your personal AI management system",
5
5
  "type": "module",
6
6
  "bin": {
@@ -66,6 +66,15 @@ export function createWsMessageDispatcher(server) {
66
66
  "patch-apply": () => server._handlePatchApply(id, ws, message),
67
67
  "patch-reject": () => server._handlePatchReject(id, ws, message),
68
68
  "patch-summary": () => server._handlePatchSummary(id, ws, message),
69
+ "task-graph-create": () =>
70
+ server._handleTaskGraphCreate(id, ws, message),
71
+ "task-graph-add-node": () =>
72
+ server._handleTaskGraphAddNode(id, ws, message),
73
+ "task-graph-update-node": () =>
74
+ server._handleTaskGraphUpdateNode(id, ws, message),
75
+ "task-graph-advance": () =>
76
+ server._handleTaskGraphAdvance(id, ws, message),
77
+ "task-graph-state": () => server._handleTaskGraphState(id, ws, message),
69
78
  };
70
79
 
71
80
  const handler = routes[type];
@@ -1164,6 +1164,373 @@ export function handlePatchSummary(server, id, ws, message) {
1164
1164
  );
1165
1165
  }
1166
1166
 
1167
+ /**
1168
+ * Helper: emit a task-graph.* envelope through the session's interaction
1169
+ * adapter (same fan-out pattern as _emitPatchEvent).
1170
+ */
1171
+ function _emitTaskGraphEvent(server, session, type, payload, ws) {
1172
+ const envelope = createCodingAgentEvent(
1173
+ type,
1174
+ { ...(payload || {}), sessionId: session.id },
1175
+ {
1176
+ sessionId: session.id,
1177
+ source: "cli-runtime",
1178
+ },
1179
+ );
1180
+
1181
+ const interaction = session && session.interaction;
1182
+ if (interaction && typeof interaction.emit === "function") {
1183
+ try {
1184
+ interaction.emit(type, envelope.payload);
1185
+ return;
1186
+ } catch (_err) {
1187
+ // Fall through to ws send below.
1188
+ }
1189
+ }
1190
+
1191
+ if (ws) {
1192
+ server._send(ws, envelope);
1193
+ }
1194
+ }
1195
+
1196
+ /**
1197
+ * Create a session-scoped task graph.
1198
+ *
1199
+ * Message shape:
1200
+ * { type: "task-graph-create", id, sessionId, title?, nodes: [...] }
1201
+ */
1202
+ export function handleTaskGraphCreate(server, id, ws, message) {
1203
+ const { sessionId } = message || {};
1204
+
1205
+ if (!server.sessionManager) {
1206
+ server._send(
1207
+ ws,
1208
+ envelopeError(id, "NO_SESSION_SUPPORT", "Session support not configured"),
1209
+ );
1210
+ return;
1211
+ }
1212
+
1213
+ const session = server.sessionManager.getSession(sessionId);
1214
+ if (!session) {
1215
+ server._send(
1216
+ ws,
1217
+ envelopeError(
1218
+ id,
1219
+ "SESSION_NOT_FOUND",
1220
+ `Session not found: ${sessionId}`,
1221
+ sessionId,
1222
+ ),
1223
+ );
1224
+ return;
1225
+ }
1226
+
1227
+ if (!Array.isArray(message.nodes)) {
1228
+ server._send(
1229
+ ws,
1230
+ envelopeError(
1231
+ id,
1232
+ "INVALID_PAYLOAD",
1233
+ "task-graph-create requires a nodes array",
1234
+ sessionId,
1235
+ ),
1236
+ );
1237
+ return;
1238
+ }
1239
+
1240
+ const graph = server.sessionManager.createTaskGraph(sessionId, {
1241
+ graphId: message.graphId,
1242
+ title: message.title,
1243
+ description: message.description,
1244
+ nodes: message.nodes,
1245
+ });
1246
+
1247
+ server._send(
1248
+ ws,
1249
+ envelopeResponse(
1250
+ CODING_AGENT_EVENT_TYPES.TASK_GRAPH_CREATED,
1251
+ id,
1252
+ { sessionId, graph },
1253
+ sessionId,
1254
+ ),
1255
+ );
1256
+
1257
+ _emitTaskGraphEvent(
1258
+ server,
1259
+ session,
1260
+ CODING_AGENT_EVENT_TYPES.TASK_GRAPH_CREATED,
1261
+ { graph },
1262
+ ws,
1263
+ );
1264
+ }
1265
+
1266
+ /**
1267
+ * Add a node to an existing task graph.
1268
+ *
1269
+ * Message shape:
1270
+ * { type: "task-graph-add-node", id, sessionId, node: { id, title, dependsOn? } }
1271
+ */
1272
+ export function handleTaskGraphAddNode(server, id, ws, message) {
1273
+ const { sessionId } = message || {};
1274
+
1275
+ if (!server.sessionManager) {
1276
+ server._send(
1277
+ ws,
1278
+ envelopeError(id, "NO_SESSION_SUPPORT", "Session support not configured"),
1279
+ );
1280
+ return;
1281
+ }
1282
+
1283
+ const session = server.sessionManager.getSession(sessionId);
1284
+ if (!session) {
1285
+ server._send(
1286
+ ws,
1287
+ envelopeError(
1288
+ id,
1289
+ "SESSION_NOT_FOUND",
1290
+ `Session not found: ${sessionId}`,
1291
+ sessionId,
1292
+ ),
1293
+ );
1294
+ return;
1295
+ }
1296
+
1297
+ const node = message.node || null;
1298
+ if (!node || !node.id) {
1299
+ server._send(
1300
+ ws,
1301
+ envelopeError(
1302
+ id,
1303
+ "INVALID_PAYLOAD",
1304
+ "task-graph-add-node requires node.id",
1305
+ sessionId,
1306
+ ),
1307
+ );
1308
+ return;
1309
+ }
1310
+
1311
+ const graph = server.sessionManager.addTaskNode(sessionId, node);
1312
+ if (!graph) {
1313
+ server._send(
1314
+ ws,
1315
+ envelopeError(
1316
+ id,
1317
+ "TASK_GRAPH_ADD_FAILED",
1318
+ "Unable to add node (no graph, or duplicate id)",
1319
+ sessionId,
1320
+ ),
1321
+ );
1322
+ return;
1323
+ }
1324
+
1325
+ server._send(
1326
+ ws,
1327
+ envelopeResponse(
1328
+ CODING_AGENT_EVENT_TYPES.TASK_GRAPH_NODE_ADDED,
1329
+ id,
1330
+ { sessionId, graph, nodeId: node.id },
1331
+ sessionId,
1332
+ ),
1333
+ );
1334
+
1335
+ _emitTaskGraphEvent(
1336
+ server,
1337
+ session,
1338
+ CODING_AGENT_EVENT_TYPES.TASK_GRAPH_NODE_ADDED,
1339
+ { graph, nodeId: node.id },
1340
+ ws,
1341
+ );
1342
+ }
1343
+
1344
+ /**
1345
+ * Update a task graph node (status, result, error, metadata).
1346
+ *
1347
+ * Message shape:
1348
+ * { type: "task-graph-update-node", id, sessionId, nodeId, updates: { status?, result?, error? } }
1349
+ */
1350
+ export function handleTaskGraphUpdateNode(server, id, ws, message) {
1351
+ const { sessionId, nodeId } = message || {};
1352
+
1353
+ if (!server.sessionManager) {
1354
+ server._send(
1355
+ ws,
1356
+ envelopeError(id, "NO_SESSION_SUPPORT", "Session support not configured"),
1357
+ );
1358
+ return;
1359
+ }
1360
+
1361
+ const session = server.sessionManager.getSession(sessionId);
1362
+ if (!session) {
1363
+ server._send(
1364
+ ws,
1365
+ envelopeError(
1366
+ id,
1367
+ "SESSION_NOT_FOUND",
1368
+ `Session not found: ${sessionId}`,
1369
+ sessionId,
1370
+ ),
1371
+ );
1372
+ return;
1373
+ }
1374
+
1375
+ if (!nodeId) {
1376
+ server._send(
1377
+ ws,
1378
+ envelopeError(id, "INVALID_PAYLOAD", "nodeId is required", sessionId),
1379
+ );
1380
+ return;
1381
+ }
1382
+
1383
+ const graph = server.sessionManager.updateTaskNode(
1384
+ sessionId,
1385
+ nodeId,
1386
+ message.updates || {},
1387
+ );
1388
+
1389
+ if (!graph) {
1390
+ server._send(
1391
+ ws,
1392
+ envelopeError(
1393
+ id,
1394
+ "TASK_GRAPH_NODE_NOT_FOUND",
1395
+ `Task node not found: ${nodeId}`,
1396
+ sessionId,
1397
+ ),
1398
+ );
1399
+ return;
1400
+ }
1401
+
1402
+ const node = graph.nodes[nodeId];
1403
+ let eventType = CODING_AGENT_EVENT_TYPES.TASK_GRAPH_NODE_UPDATED;
1404
+ if (node && node.status === "completed") {
1405
+ eventType = CODING_AGENT_EVENT_TYPES.TASK_GRAPH_NODE_COMPLETED;
1406
+ } else if (node && node.status === "failed") {
1407
+ eventType = CODING_AGENT_EVENT_TYPES.TASK_GRAPH_NODE_FAILED;
1408
+ }
1409
+
1410
+ server._send(
1411
+ ws,
1412
+ envelopeResponse(eventType, id, { sessionId, graph, nodeId }, sessionId),
1413
+ );
1414
+
1415
+ _emitTaskGraphEvent(server, session, eventType, { graph, nodeId }, ws);
1416
+
1417
+ if (graph.status === "completed" || graph.status === "failed") {
1418
+ _emitTaskGraphEvent(
1419
+ server,
1420
+ session,
1421
+ CODING_AGENT_EVENT_TYPES.TASK_GRAPH_COMPLETED,
1422
+ { graph },
1423
+ ws,
1424
+ );
1425
+ }
1426
+ }
1427
+
1428
+ /**
1429
+ * Advance the task graph: promote any pending node whose deps are satisfied.
1430
+ *
1431
+ * Message shape: { type: "task-graph-advance", id, sessionId }
1432
+ */
1433
+ export function handleTaskGraphAdvance(server, id, ws, message) {
1434
+ const { sessionId } = message || {};
1435
+
1436
+ if (!server.sessionManager) {
1437
+ server._send(
1438
+ ws,
1439
+ envelopeError(id, "NO_SESSION_SUPPORT", "Session support not configured"),
1440
+ );
1441
+ return;
1442
+ }
1443
+
1444
+ const session = server.sessionManager.getSession(sessionId);
1445
+ if (!session) {
1446
+ server._send(
1447
+ ws,
1448
+ envelopeError(
1449
+ id,
1450
+ "SESSION_NOT_FOUND",
1451
+ `Session not found: ${sessionId}`,
1452
+ sessionId,
1453
+ ),
1454
+ );
1455
+ return;
1456
+ }
1457
+
1458
+ const result = server.sessionManager.advanceTaskGraph(sessionId);
1459
+ if (!result) {
1460
+ server._send(
1461
+ ws,
1462
+ envelopeError(
1463
+ id,
1464
+ "TASK_GRAPH_NOT_FOUND",
1465
+ "No task graph on session",
1466
+ sessionId,
1467
+ ),
1468
+ );
1469
+ return;
1470
+ }
1471
+
1472
+ server._send(
1473
+ ws,
1474
+ envelopeResponse(
1475
+ CODING_AGENT_EVENT_TYPES.TASK_GRAPH_ADVANCED,
1476
+ id,
1477
+ { sessionId, graph: result.graph, becameReady: result.becameReady },
1478
+ sessionId,
1479
+ ),
1480
+ );
1481
+
1482
+ _emitTaskGraphEvent(
1483
+ server,
1484
+ session,
1485
+ CODING_AGENT_EVENT_TYPES.TASK_GRAPH_ADVANCED,
1486
+ { graph: result.graph, becameReady: result.becameReady },
1487
+ ws,
1488
+ );
1489
+ }
1490
+
1491
+ /**
1492
+ * Fetch the current task graph state.
1493
+ *
1494
+ * Message shape: { type: "task-graph-state", id, sessionId }
1495
+ */
1496
+ export function handleTaskGraphState(server, id, ws, message) {
1497
+ const { sessionId } = message || {};
1498
+
1499
+ if (!server.sessionManager) {
1500
+ server._send(
1501
+ ws,
1502
+ envelopeError(id, "NO_SESSION_SUPPORT", "Session support not configured"),
1503
+ );
1504
+ return;
1505
+ }
1506
+
1507
+ const session = server.sessionManager.getSession(sessionId);
1508
+ if (!session) {
1509
+ server._send(
1510
+ ws,
1511
+ envelopeError(
1512
+ id,
1513
+ "SESSION_NOT_FOUND",
1514
+ `Session not found: ${sessionId}`,
1515
+ sessionId,
1516
+ ),
1517
+ );
1518
+ return;
1519
+ }
1520
+
1521
+ const graph = server.sessionManager.getTaskGraph(sessionId);
1522
+
1523
+ server._send(
1524
+ ws,
1525
+ envelopeResponse(
1526
+ CODING_AGENT_EVENT_TYPES.TASK_GRAPH_STATE,
1527
+ id,
1528
+ { sessionId, graph },
1529
+ sessionId,
1530
+ ),
1531
+ );
1532
+ }
1533
+
1167
1534
  export function handleHostToolResult(server, id, ws, message) {
1168
1535
  const { sessionId, requestId, success, result, error, toolName } = message;
1169
1536
 
@@ -41,6 +41,11 @@ import {
41
41
  handlePatchApply,
42
42
  handlePatchReject,
43
43
  handlePatchSummary,
44
+ handleTaskGraphCreate,
45
+ handleTaskGraphAddNode,
46
+ handleTaskGraphUpdateNode,
47
+ handleTaskGraphAdvance,
48
+ handleTaskGraphState,
44
49
  } from "../gateways/ws/session-protocol.js";
45
50
  import {
46
51
  handleSlashCommand,
@@ -679,6 +684,31 @@ export class ChainlessChainWSServer extends EventEmitter {
679
684
  return handlePatchSummary(this, id, ws, message);
680
685
  }
681
686
 
687
+ /** @private */
688
+ _handleTaskGraphCreate(id, ws, message) {
689
+ return handleTaskGraphCreate(this, id, ws, message);
690
+ }
691
+
692
+ /** @private */
693
+ _handleTaskGraphAddNode(id, ws, message) {
694
+ return handleTaskGraphAddNode(this, id, ws, message);
695
+ }
696
+
697
+ /** @private */
698
+ _handleTaskGraphUpdateNode(id, ws, message) {
699
+ return handleTaskGraphUpdateNode(this, id, ws, message);
700
+ }
701
+
702
+ /** @private */
703
+ _handleTaskGraphAdvance(id, ws, message) {
704
+ return handleTaskGraphAdvance(this, id, ws, message);
705
+ }
706
+
707
+ /** @private */
708
+ _handleTaskGraphState(id, ws, message) {
709
+ return handleTaskGraphState(this, id, ws, message);
710
+ }
711
+
682
712
  /** @private — ping/pong heartbeat to detect dead connections */
683
713
  async _ensureTaskManager() {
684
714
  if (this._taskManager) return this._taskManager;
@@ -352,6 +352,7 @@ export class WSSessionManager {
352
352
  reviewState: null,
353
353
  pendingPatches: new Map(),
354
354
  patchHistory: [],
355
+ taskGraph: null,
355
356
  interaction: null, // Set by ws-server after creation
356
357
  createdAt: new Date().toISOString(),
357
358
  lastActivity: new Date().toISOString(),
@@ -464,6 +465,7 @@ export class WSSessionManager {
464
465
  patchHistory: Array.isArray(metadata.patchHistory)
465
466
  ? metadata.patchHistory
466
467
  : [],
468
+ taskGraph: this._hydrateTaskGraph(metadata.taskGraph),
467
469
  interaction: null,
468
470
  createdAt: dbSession.created_at,
469
471
  lastActivity: new Date().toISOString(),
@@ -960,6 +962,242 @@ export class WSSessionManager {
960
962
  };
961
963
  }
962
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
+
963
1201
  /**
964
1202
  * Persist current messages for a session.
965
1203
  */
@@ -1046,6 +1284,7 @@ export class WSSessionManager {
1046
1284
  patchHistory: Array.isArray(session.patchHistory)
1047
1285
  ? session.patchHistory
1048
1286
  : [],
1287
+ taskGraph: this._serializeTaskGraph(session.taskGraph),
1049
1288
  };
1050
1289
  }
1051
1290
 
@@ -108,6 +108,20 @@ const CODING_AGENT_EVENT_TYPES = Object.freeze({
108
108
  PATCH_APPLIED: "patch.applied",
109
109
  PATCH_REJECTED: "patch.rejected",
110
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",
111
125
  });
112
126
 
113
127
  const VALID_TYPE_SET = new Set(Object.values(CODING_AGENT_EVENT_TYPES));