tabminal 3.0.15 → 3.0.16

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.
@@ -746,6 +746,21 @@ export function mergeAgentMessageText(previousText, chunkText) {
746
746
  const chunk = String(chunkText || '');
747
747
  if (!previous) return chunk;
748
748
  if (!chunk) return previous;
749
+ if (previous === chunk) return previous;
750
+ if (chunk.startsWith(previous)) {
751
+ return chunk;
752
+ }
753
+ if (previous.startsWith(chunk)) {
754
+ return previous;
755
+ }
756
+
757
+ const maxOverlap = Math.min(previous.length, chunk.length, 2048);
758
+ for (let overlap = maxOverlap; overlap >= 2; overlap -= 1) {
759
+ if (previous.slice(-overlap) === chunk.slice(0, overlap)) {
760
+ return `${previous}${chunk.slice(overlap)}`;
761
+ }
762
+ }
763
+
749
764
  if (/\s$/.test(previous) || /^\s/.test(chunk)) {
750
765
  return `${previous}${chunk}`;
751
766
  }
@@ -1008,108 +1023,543 @@ function cloneSerializable(value, fallback) {
1008
1023
  }
1009
1024
  }
1010
1025
 
1011
- function normalizePersistedTimelineOrder(value, fallback = 0) {
1012
- return Number.isFinite(value) && value > 0 ? value : fallback;
1026
+ function parseIsoTimestamp(value) {
1027
+ if (typeof value !== 'string' || !value.trim()) {
1028
+ return 0;
1029
+ }
1030
+ const timestamp = Date.parse(value);
1031
+ return Number.isFinite(timestamp) ? timestamp : 0;
1013
1032
  }
1014
1033
 
1015
- function normalizeReplayMessageEntry(message = {}) {
1034
+ function getSerializedTabContentScore(tab = {}) {
1035
+ const messages = Array.isArray(tab.messages) ? tab.messages : [];
1036
+ const toolCalls = Array.isArray(tab.toolCalls) ? tab.toolCalls : [];
1037
+ const permissions = Array.isArray(tab.permissions) ? tab.permissions : [];
1038
+ const plan = Array.isArray(tab.plan) ? tab.plan : [];
1039
+ const terminals = Array.isArray(tab.terminals) ? tab.terminals : [];
1040
+ return (
1041
+ messages.length * 10000
1042
+ + toolCalls.length * 100
1043
+ + permissions.length * 10
1044
+ + plan.length
1045
+ + terminals.length
1046
+ );
1047
+ }
1048
+
1049
+ function getSerializedTabActivity(tab = {}) {
1050
+ let maxSyntheticTurn = -1;
1051
+ let maxTimestamp = parseIsoTimestamp(tab.createdAt);
1052
+ let maxOrder = 0;
1053
+ const visit = (item) => {
1054
+ maxTimestamp = Math.max(
1055
+ maxTimestamp,
1056
+ parseIsoTimestamp(item?.createdAt),
1057
+ parseIsoTimestamp(item?.updatedAt)
1058
+ );
1059
+ const order = Number(item?.order || 0);
1060
+ if (Number.isFinite(order) && order > maxOrder) {
1061
+ maxOrder = order;
1062
+ }
1063
+ const syntheticTurnKey = getSyntheticTurnKey(item?.streamKey || '');
1064
+ const turn = syntheticTurnKey ? Number(syntheticTurnKey) : -1;
1065
+ if (Number.isFinite(turn) && turn > maxSyntheticTurn) {
1066
+ maxSyntheticTurn = turn;
1067
+ }
1068
+ };
1069
+ for (const message of Array.isArray(tab.messages) ? tab.messages : []) {
1070
+ visit(message);
1071
+ }
1072
+ for (const toolCall of Array.isArray(tab.toolCalls) ? tab.toolCalls : []) {
1073
+ visit(toolCall);
1074
+ }
1075
+ for (
1076
+ const permission of Array.isArray(tab.permissions) ? tab.permissions : []
1077
+ ) {
1078
+ visit(permission);
1079
+ }
1080
+ if (tab.usage && typeof tab.usage === 'object') {
1081
+ visit(tab.usage);
1082
+ }
1016
1083
  return {
1017
- role: typeof message.role === 'string'
1018
- ? message.role
1019
- : 'assistant',
1020
- kind: typeof message.kind === 'string'
1021
- ? message.kind
1022
- : 'message',
1023
- text: typeof message.text === 'string'
1024
- ? message.text
1025
- : ''
1084
+ maxSyntheticTurn,
1085
+ maxTimestamp,
1086
+ maxOrder,
1087
+ contentScore: getSerializedTabContentScore(tab)
1026
1088
  };
1027
1089
  }
1028
1090
 
1029
- export function createRestoreReplayState(messages = []) {
1030
- if (!Array.isArray(messages) || messages.length === 0) {
1091
+ function compareSerializedTabActivity(left = {}, right = {}) {
1092
+ const leftActivity = getSerializedTabActivity(left);
1093
+ const rightActivity = getSerializedTabActivity(right);
1094
+ if (leftActivity.maxSyntheticTurn !== rightActivity.maxSyntheticTurn) {
1095
+ return rightActivity.maxSyntheticTurn - leftActivity.maxSyntheticTurn;
1096
+ }
1097
+ if (leftActivity.maxTimestamp !== rightActivity.maxTimestamp) {
1098
+ return rightActivity.maxTimestamp - leftActivity.maxTimestamp;
1099
+ }
1100
+ if (leftActivity.maxOrder !== rightActivity.maxOrder) {
1101
+ return rightActivity.maxOrder - leftActivity.maxOrder;
1102
+ }
1103
+ if (leftActivity.contentScore !== rightActivity.contentScore) {
1104
+ return rightActivity.contentScore - leftActivity.contentScore;
1105
+ }
1106
+ return 0;
1107
+ }
1108
+
1109
+ function compareSerializedTabBase(left = {}, right = {}) {
1110
+ const leftLinked = !!String(left.terminalSessionId || '').trim();
1111
+ const rightLinked = !!String(right.terminalSessionId || '').trim();
1112
+ if (leftLinked !== rightLinked) {
1113
+ return Number(rightLinked) - Number(leftLinked);
1114
+ }
1115
+ const leftCreatedAt = parseIsoTimestamp(left.createdAt);
1116
+ const rightCreatedAt = parseIsoTimestamp(right.createdAt);
1117
+ if (leftCreatedAt !== rightCreatedAt) {
1118
+ return rightCreatedAt - leftCreatedAt;
1119
+ }
1120
+ const leftTitleLength = String(left.title || '').trim().length;
1121
+ const rightTitleLength = String(right.title || '').trim().length;
1122
+ if (leftTitleLength !== rightTitleLength) {
1123
+ return rightTitleLength - leftTitleLength;
1124
+ }
1125
+ return compareSerializedTabActivity(left, right);
1126
+ }
1127
+
1128
+ function pickBestTitle(values = []) {
1129
+ let best = '';
1130
+ for (const value of values) {
1131
+ const text = String(value || '').trim();
1132
+ if (text.length > best.length) {
1133
+ best = text;
1134
+ }
1135
+ }
1136
+ return best;
1137
+ }
1138
+
1139
+ function pickLongerArray(left, right) {
1140
+ const leftItems = Array.isArray(left) ? left : [];
1141
+ const rightItems = Array.isArray(right) ? right : [];
1142
+ return rightItems.length > leftItems.length ? rightItems : leftItems;
1143
+ }
1144
+
1145
+ function mergeSerializedTabGroup(group = []) {
1146
+ if (!Array.isArray(group) || group.length === 0) {
1031
1147
  return null;
1032
1148
  }
1033
- const replayMessages = messages
1034
- .map((message) => normalizeReplayMessageEntry(message))
1035
- .filter((message) => message.text);
1036
- if (replayMessages.length === 0) {
1149
+ if (group.length === 1) {
1150
+ const only = cloneSerializable(group[0], null);
1151
+ if (only && Array.isArray(only.messages)) {
1152
+ only.messages = normalizeAgentTranscriptMessages(only.messages);
1153
+ }
1154
+ return only;
1155
+ }
1156
+
1157
+ const base = [...group].sort(compareSerializedTabBase)[0];
1158
+ const content = [...group].sort(compareSerializedTabActivity)[0];
1159
+ const merged = cloneSerializable(base, null);
1160
+ if (!merged) {
1037
1161
  return null;
1038
1162
  }
1039
- return {
1040
- messages: replayMessages,
1041
- index: -1,
1042
- offset: 0,
1043
- started: false,
1044
- exhausted: false
1045
- };
1163
+
1164
+ const bestTitle = pickBestTitle(group.map((entry) => entry?.title));
1165
+ merged.title = String(merged.title || '').trim()
1166
+ || String(content.title || '').trim()
1167
+ || bestTitle;
1168
+ merged.cwd = String(merged.cwd || content.cwd || '');
1169
+ merged.terminalSessionId = String(
1170
+ merged.terminalSessionId || content.terminalSessionId || ''
1171
+ );
1172
+ merged.currentModeId = String(
1173
+ merged.currentModeId || content.currentModeId || ''
1174
+ );
1175
+ merged.availableModes = cloneSerializable(
1176
+ pickLongerArray(merged.availableModes, content.availableModes),
1177
+ []
1178
+ );
1179
+ merged.availableCommands = cloneSerializable(
1180
+ pickLongerArray(merged.availableCommands, content.availableCommands),
1181
+ []
1182
+ );
1183
+ merged.configOptions = cloneSerializable(
1184
+ pickLongerArray(merged.configOptions, content.configOptions),
1185
+ []
1186
+ );
1187
+ merged.messages = normalizeAgentTranscriptMessages(
1188
+ cloneSerializable(content.messages, [])
1189
+ );
1190
+ merged.toolCalls = cloneSerializable(content.toolCalls, []);
1191
+ merged.permissions = cloneSerializable(content.permissions, []);
1192
+ merged.plan = cloneSerializable(content.plan, []);
1193
+ merged.usage = cloneSerializable(content.usage, null);
1194
+ merged.terminals = cloneSerializable(
1195
+ pickLongerArray(merged.terminals, content.terminals),
1196
+ []
1197
+ );
1198
+ return merged;
1046
1199
  }
1047
1200
 
1048
- export function consumeRestoredMessageReplay(state, role, kind, text) {
1049
- if (!state || state.exhausted) {
1050
- return false;
1201
+ function dedupeSerializedTabs(entries = []) {
1202
+ const groups = new Map();
1203
+ const order = [];
1204
+ for (const entry of Array.isArray(entries) ? entries : []) {
1205
+ if (!entry || typeof entry !== 'object') {
1206
+ continue;
1207
+ }
1208
+ const acpSessionId = String(entry.acpSessionId || '').trim();
1209
+ const key = acpSessionId || `id:${String(entry.id || '').trim()}`;
1210
+ if (!groups.has(key)) {
1211
+ groups.set(key, []);
1212
+ order.push(key);
1213
+ }
1214
+ groups.get(key).push(entry);
1051
1215
  }
1052
- const chunk = typeof text === 'string' ? text : '';
1053
- if (!chunk) {
1054
- return false;
1216
+ const deduped = [];
1217
+ let changed = false;
1218
+ for (const key of order) {
1219
+ const group = groups.get(key) || [];
1220
+ const merged = mergeSerializedTabGroup(group);
1221
+ if (merged) {
1222
+ deduped.push(merged);
1223
+ }
1224
+ if (group.length > 1) {
1225
+ changed = true;
1226
+ }
1055
1227
  }
1228
+ return { tabs: deduped, changed };
1229
+ }
1230
+
1231
+ function normalizePersistedTimelineOrder(value, fallback = 0) {
1232
+ return Number.isFinite(value) && value > 0 ? value : fallback;
1233
+ }
1234
+
1235
+ function getSyntheticTurnKey(streamKey = '') {
1236
+ const match = /^synthetic:(\d+):/.exec(String(streamKey || ''));
1237
+ return match ? match[1] : '';
1238
+ }
1239
+
1240
+ function buildMessageReplaySignature(message = {}) {
1241
+ return [
1242
+ String(message?.role || ''),
1243
+ String(message?.kind || ''),
1244
+ String(message?.streamKey || ''),
1245
+ String(message?.text || '')
1246
+ ].join('\u0000');
1247
+ }
1056
1248
 
1057
- const findReplayStart = () => {
1058
- for (let index = 0; index < state.messages.length; index += 1) {
1059
- const message = state.messages[index];
1060
- if (message.role !== role || message.kind !== kind) {
1249
+ export function normalizeAgentTranscriptMessages(messages = []) {
1250
+ const normalized = Array.isArray(messages)
1251
+ ? messages.map((message, index) =>
1252
+ normalizePersistedMessage(message, index + 1)
1253
+ )
1254
+ : [];
1255
+ if (normalized.length <= 1) {
1256
+ return normalized;
1257
+ }
1258
+
1259
+ const blocks = [];
1260
+ let index = 0;
1261
+ while (index < normalized.length) {
1262
+ const first = normalized[index];
1263
+ const syntheticTurnKey = getSyntheticTurnKey(first.streamKey);
1264
+ const block = [first];
1265
+ index += 1;
1266
+ if (!syntheticTurnKey) {
1267
+ blocks.push(block);
1268
+ continue;
1269
+ }
1270
+ while (index < normalized.length) {
1271
+ const next = normalized[index];
1272
+ if (getSyntheticTurnKey(next.streamKey) !== syntheticTurnKey) {
1273
+ break;
1274
+ }
1275
+ block.push(next);
1276
+ index += 1;
1277
+ }
1278
+ const dedupedBlock = [];
1279
+ const dedupedIndexes = new Map();
1280
+ for (const message of block) {
1281
+ const signature = buildMessageReplaySignature(message);
1282
+ const existingIndex = dedupedIndexes.get(signature);
1283
+ if (existingIndex === undefined) {
1284
+ dedupedIndexes.set(signature, dedupedBlock.length);
1285
+ dedupedBlock.push(message);
1061
1286
  continue;
1062
1287
  }
1063
- if (message.text.startsWith(chunk)) {
1064
- state.index = index;
1065
- state.offset = chunk.length;
1066
- state.started = true;
1067
- if (state.offset >= message.text.length) {
1068
- state.index += 1;
1069
- state.offset = 0;
1070
- }
1071
- if (state.index >= state.messages.length) {
1072
- state.exhausted = true;
1073
- }
1074
- return true;
1288
+ if (String(message.role || '') === 'assistant') {
1289
+ dedupedBlock[existingIndex] = message;
1075
1290
  }
1076
1291
  }
1077
- state.exhausted = true;
1078
- return false;
1292
+ blocks.push(dedupedBlock);
1293
+ }
1294
+
1295
+ const dedupedBlocks = [];
1296
+ let previousSignature = '';
1297
+ for (const block of blocks) {
1298
+ const signature = block
1299
+ .map((message) => buildMessageReplaySignature(message))
1300
+ .join('\u0001');
1301
+ if (signature && signature === previousSignature) {
1302
+ continue;
1303
+ }
1304
+ dedupedBlocks.push(block);
1305
+ previousSignature = signature;
1306
+ }
1307
+
1308
+ return dedupedBlocks.flat();
1309
+ }
1310
+
1311
+ export function createRestoreCaptureState(messages = [], options = {}) {
1312
+ const toolCalls = Array.isArray(options.toolCalls)
1313
+ ? options.toolCalls
1314
+ : [];
1315
+ return {
1316
+ baselineMessages: normalizeAgentTranscriptMessages(messages),
1317
+ baselineToolCalls: new Map(
1318
+ toolCalls
1319
+ .map((entry) => normalizePersistedTimelineEntry(entry, 0))
1320
+ .filter((entry) => typeof entry.toolCallId === 'string')
1321
+ .map((entry) => [entry.toolCallId, entry])
1322
+ ),
1323
+ messages: [],
1324
+ syntheticStreams: new Map(),
1325
+ syntheticStreamTurn: getNextSyntheticStreamTurn(messages),
1326
+ messageCounter: 0,
1327
+ nextTimelineOrder: null
1079
1328
  };
1329
+ }
1330
+
1331
+ function getRestoreComparableAttachment(attachment = {}) {
1332
+ return [
1333
+ String(attachment?.kind || ''),
1334
+ String(attachment?.name || ''),
1335
+ String(attachment?.path || ''),
1336
+ String(attachment?.url || ''),
1337
+ Number.isFinite(attachment?.size) ? attachment.size : 0,
1338
+ Number.isFinite(attachment?.lastModified)
1339
+ ? attachment.lastModified
1340
+ : 0
1341
+ ];
1342
+ }
1343
+
1344
+ function buildRestoreComparableMessage(message = {}) {
1345
+ return JSON.stringify({
1346
+ role: String(message?.role || ''),
1347
+ kind: String(message?.kind || ''),
1348
+ text: String(message?.text || ''),
1349
+ attachments: Array.isArray(message?.attachments)
1350
+ ? message.attachments.map(getRestoreComparableAttachment)
1351
+ : []
1352
+ });
1353
+ }
1080
1354
 
1081
- if (!state.started) {
1082
- return findReplayStart();
1355
+ function areRestoreMessagesEquivalent(left = {}, right = {}) {
1356
+ return buildRestoreComparableMessage(left)
1357
+ === buildRestoreComparableMessage(right);
1358
+ }
1359
+
1360
+ function isRestoreMessageContinuation(previousText = '', chunkText = '') {
1361
+ const previous = String(previousText || '');
1362
+ const chunk = String(chunkText || '');
1363
+ if (!previous || !chunk || previous === chunk) {
1364
+ return false;
1365
+ }
1366
+ if (chunk.startsWith(previous) || previous.startsWith(chunk)) {
1367
+ return true;
1368
+ }
1369
+ const maxOverlap = Math.min(previous.length, chunk.length, 2048);
1370
+ for (let overlap = maxOverlap; overlap >= 2; overlap -= 1) {
1371
+ if (previous.slice(-overlap) === chunk.slice(0, overlap)) {
1372
+ return true;
1373
+ }
1083
1374
  }
1375
+ return false;
1376
+ }
1377
+
1378
+ function maybeAdvanceRestoreCaptureTurn(capture, update, role, kind, text) {
1084
1379
  if (
1085
- state.index < 0
1086
- || state.index >= state.messages.length
1380
+ !capture
1381
+ || update?.messageId
1382
+ || role !== 'user'
1383
+ || kind !== 'message'
1087
1384
  ) {
1088
- state.exhausted = true;
1089
- return false;
1385
+ return;
1090
1386
  }
1387
+ const last = capture.messages[capture.messages.length - 1] || null;
1388
+ if (!last) {
1389
+ return;
1390
+ }
1391
+ if (last.role !== 'user' || last.kind !== 'message') {
1392
+ capture.syntheticStreamTurn += 1;
1393
+ capture.syntheticStreams.clear();
1394
+ return;
1395
+ }
1396
+ if (!isRestoreMessageContinuation(last.text, text)) {
1397
+ capture.syntheticStreamTurn += 1;
1398
+ capture.syntheticStreams.clear();
1399
+ }
1400
+ }
1091
1401
 
1092
- const message = state.messages[state.index];
1093
- if (message.role !== role || message.kind !== kind) {
1094
- state.exhausted = true;
1095
- return false;
1402
+ function getRestoreCaptureStreamKey(capture, update, role, kind, text = '') {
1403
+ maybeAdvanceRestoreCaptureTurn(capture, update, role, kind, text);
1404
+ if (update?.messageId) {
1405
+ return update.messageId;
1406
+ }
1407
+ const bucketKey = `${update?.sessionUpdate}:${role}:${kind}`;
1408
+ let streamKey = capture.syntheticStreams.get(bucketKey) || '';
1409
+ if (!streamKey) {
1410
+ streamKey = [
1411
+ 'synthetic',
1412
+ capture.syntheticStreamTurn,
1413
+ update?.sessionUpdate || 'message_chunk',
1414
+ role,
1415
+ kind
1416
+ ].join(':');
1417
+ capture.syntheticStreams.set(bucketKey, streamKey);
1418
+ }
1419
+ return streamKey;
1420
+ }
1421
+
1422
+ export function captureRestoreReplayChunk(capture, update, role, kind, text) {
1423
+ if (!capture) return false;
1424
+ const chunk = String(text || '');
1425
+ if (!chunk) return true;
1426
+ const streamKey = getRestoreCaptureStreamKey(
1427
+ capture,
1428
+ update,
1429
+ role,
1430
+ kind,
1431
+ chunk
1432
+ );
1433
+ const last = capture.messages[capture.messages.length - 1] || null;
1434
+ if (
1435
+ last
1436
+ && last.streamKey === streamKey
1437
+ && last.role === role
1438
+ && last.kind === kind
1439
+ ) {
1440
+ last.text = mergeAgentMessageText(last.text, chunk);
1441
+ return true;
1442
+ }
1443
+ if (!update?.messageId) {
1444
+ capture.messageCounter += 1;
1096
1445
  }
1446
+ const baselineMessage = capture.baselineMessages[capture.messages.length] || null;
1447
+ const canReuseBaseline = !!(
1448
+ baselineMessage
1449
+ && baselineMessage.role === role
1450
+ && baselineMessage.kind === kind
1451
+ );
1452
+ capture.messages.push({
1453
+ id: canReuseBaseline
1454
+ ? (baselineMessage.id || crypto.randomUUID())
1455
+ : crypto.randomUUID(),
1456
+ streamKey: canReuseBaseline
1457
+ ? (baselineMessage.streamKey || streamKey)
1458
+ : streamKey,
1459
+ role,
1460
+ kind,
1461
+ text: chunk,
1462
+ createdAt: canReuseBaseline
1463
+ ? (baselineMessage.createdAt || '')
1464
+ : '',
1465
+ order: typeof capture.nextTimelineOrder === 'function'
1466
+ ? capture.nextTimelineOrder()
1467
+ : capture.messages.length + 1,
1468
+ attachments: canReuseBaseline
1469
+ && Array.isArray(baselineMessage.attachments)
1470
+ ? cloneSerializable(baselineMessage.attachments, [])
1471
+ : []
1472
+ });
1473
+ return true;
1474
+ }
1097
1475
 
1098
- const remaining = message.text.slice(state.offset);
1099
- if (!remaining.startsWith(chunk)) {
1100
- state.exhausted = true;
1476
+ export function finalizeRestoreCaptureMessages(tab) {
1477
+ const capture = tab?.restoreCapture;
1478
+ if (!capture) {
1101
1479
  return false;
1102
1480
  }
1481
+ const baselineMessages = Array.isArray(capture.baselineMessages)
1482
+ ? capture.baselineMessages
1483
+ : [];
1484
+ const replayMessages = Array.isArray(capture.messages)
1485
+ ? capture.messages
1486
+ : [];
1487
+ const nextMessages = replayMessages.length > 0
1488
+ ? normalizeAgentTranscriptMessages(replayMessages)
1489
+ : baselineMessages;
1490
+ const replacedMessages = !(
1491
+ baselineMessages.length === nextMessages.length
1492
+ && baselineMessages.every((message, index) =>
1493
+ areRestoreMessagesEquivalent(message, nextMessages[index] || {})
1494
+ )
1495
+ );
1496
+ tab.messages = nextMessages;
1497
+ const maxMessageOrder = tab.messages.reduce(
1498
+ (maxOrder, message) => Math.max(
1499
+ maxOrder,
1500
+ normalizePersistedTimelineOrder(message.order, 0)
1501
+ ),
1502
+ 0
1503
+ );
1504
+ const maxToolCallOrder = Array.from(tab.toolCalls.values()).reduce(
1505
+ (maxOrder, toolCall) => Math.max(
1506
+ maxOrder,
1507
+ normalizePersistedTimelineOrder(toolCall?.order, 0)
1508
+ ),
1509
+ 0
1510
+ );
1511
+ const maxPermissionOrder = Array.from(tab.permissions.values()).reduce(
1512
+ (maxOrder, permission) => Math.max(
1513
+ maxOrder,
1514
+ normalizePersistedTimelineOrder(permission?.order, 0)
1515
+ ),
1516
+ 0
1517
+ );
1518
+ tab.timelineCounter = Math.max(
1519
+ maxMessageOrder,
1520
+ maxToolCallOrder,
1521
+ maxPermissionOrder
1522
+ );
1523
+ tab.messageCounter = Math.max(tab.messageCounter, tab.messages.length);
1524
+ tab.restoreCapture = null;
1525
+ return replacedMessages;
1526
+ }
1103
1527
 
1104
- state.offset += chunk.length;
1105
- if (state.offset >= message.text.length) {
1106
- state.index += 1;
1107
- state.offset = 0;
1528
+ export function buildRestoredToolCall(
1529
+ previous = null,
1530
+ baseline = null,
1531
+ update = {},
1532
+ nextTimelineOrder = null
1533
+ ) {
1534
+ const persisted = cloneSerializable(baseline, {}) || {};
1535
+ const current = cloneSerializable(previous, {}) || {};
1536
+ const nextOrder = normalizePersistedTimelineOrder(current.order, 0)
1537
+ || (
1538
+ typeof nextTimelineOrder === 'function'
1539
+ ? nextTimelineOrder()
1540
+ : normalizePersistedTimelineOrder(persisted.order, 0)
1541
+ )
1542
+ || 1;
1543
+ const createdAt = String(
1544
+ current.createdAt || persisted.createdAt || ''
1545
+ ).trim() || new Date().toISOString();
1546
+ const nextToolCall = {
1547
+ ...persisted,
1548
+ ...current,
1549
+ ...update,
1550
+ createdAt,
1551
+ order: nextOrder
1552
+ };
1553
+ if (!nextToolCall.toolCallId) {
1554
+ nextToolCall.toolCallId = String(update.toolCallId || '');
1108
1555
  }
1109
- if (state.index >= state.messages.length) {
1110
- state.exhausted = true;
1556
+ if (typeof nextToolCall.title !== 'string') {
1557
+ nextToolCall.title = '';
1111
1558
  }
1112
- return true;
1559
+ if (typeof nextToolCall.status !== 'string') {
1560
+ nextToolCall.status = 'pending';
1561
+ }
1562
+ return nextToolCall;
1113
1563
  }
1114
1564
 
1115
1565
  function normalizePersistedMessage(message = {}, fallbackOrder = 0) {
@@ -1192,12 +1642,26 @@ function normalizePersistedTerminalSummary(summary = {}) {
1192
1642
  };
1193
1643
  }
1194
1644
 
1645
+ export function getNextSyntheticStreamTurn(messages = []) {
1646
+ if (!Array.isArray(messages) || messages.length === 0) {
1647
+ return 0;
1648
+ }
1649
+ return messages.reduce((maxTurn, entry) => {
1650
+ const streamKey = String(entry?.streamKey || '');
1651
+ const match = /^synthetic:(\d+):/.exec(streamKey);
1652
+ if (!match) {
1653
+ return maxTurn;
1654
+ }
1655
+ const turn = Number.parseInt(match[1], 10);
1656
+ if (!Number.isFinite(turn)) {
1657
+ return maxTurn;
1658
+ }
1659
+ return Math.max(maxTurn, turn + 1);
1660
+ }, 0);
1661
+ }
1662
+
1195
1663
  function restorePersistedTabSnapshot(tab, snapshot = {}) {
1196
- const messages = Array.isArray(snapshot.messages)
1197
- ? snapshot.messages.map((message, index) =>
1198
- normalizePersistedMessage(message, index + 1)
1199
- )
1200
- : [];
1664
+ const messages = normalizeAgentTranscriptMessages(snapshot.messages);
1201
1665
  const toolCalls = Array.isArray(snapshot.toolCalls)
1202
1666
  ? snapshot.toolCalls.map((entry, index) =>
1203
1667
  normalizePersistedTimelineEntry(
@@ -1279,6 +1743,13 @@ function restorePersistedTabSnapshot(tab, snapshot = {}) {
1279
1743
  maxPermissionOrder
1280
1744
  );
1281
1745
  tab.messageCounter = Math.max(tab.messageCounter, messages.length);
1746
+ const maxSyntheticTurn = getNextSyntheticStreamTurn(messages);
1747
+ tab.syntheticStreamTurn = Math.max(
1748
+ Number.isFinite(tab.syntheticStreamTurn)
1749
+ ? tab.syntheticStreamTurn
1750
+ : 0,
1751
+ maxSyntheticTurn
1752
+ );
1282
1753
  }
1283
1754
 
1284
1755
  class LocalExecTerminal extends EventEmitter {
@@ -1748,7 +2219,7 @@ class AcpRuntime extends EventEmitter {
1748
2219
  syntheticStreams: new Map(),
1749
2220
  syntheticStreamTurn: 0,
1750
2221
  pendingUserEcho: null,
1751
- restoreReplay: null,
2222
+ restoreCapture: null,
1752
2223
  currentModeId,
1753
2224
  availableModes,
1754
2225
  availableCommands,
@@ -2005,14 +2476,20 @@ class AcpRuntime extends EventEmitter {
2005
2476
  availableModes: meta.availableModes || [],
2006
2477
  availableCommands: meta.availableCommands || [],
2007
2478
  configOptions: meta.configOptions || [],
2008
- messages: meta.messages || [],
2009
- toolCalls: meta.toolCalls || [],
2010
- permissions: meta.permissions || [],
2011
- plan: meta.plan || [],
2479
+ messages: [],
2480
+ toolCalls: [],
2481
+ permissions: [],
2482
+ plan: [],
2012
2483
  usage: meta.usage || null,
2013
2484
  terminals: meta.terminals || []
2014
2485
  });
2015
- tab.restoreReplay = createRestoreReplayState(meta.messages || []);
2486
+ // For loadSession-capable runtimes, transcript ordering comes from the
2487
+ // authoritative replay stream, not the persisted snapshot.
2488
+ tab.restoreCapture = createRestoreCaptureState(meta.messages || [], {
2489
+ toolCalls: meta.toolCalls || []
2490
+ });
2491
+ tab.restoreCapture.nextTimelineOrder = () =>
2492
+ this.#nextTimelineOrder(tab);
2016
2493
  tab.status = 'restoring';
2017
2494
  tab.busy = true;
2018
2495
 
@@ -2020,41 +2497,14 @@ class AcpRuntime extends EventEmitter {
2020
2497
  this.sessionToTabId.set(tab.acpSessionId, tab.id);
2021
2498
 
2022
2499
  try {
2023
- const response = await this.connection.loadSession({
2024
- cwd: meta.cwd,
2025
- sessionId: meta.acpSessionId,
2026
- mcpServers: []
2500
+ await this.#loadSessionIntoTab(tab, meta);
2501
+ this.#broadcast(tab, {
2502
+ type: 'snapshot',
2503
+ tab: this.serializeTab(tab)
2027
2504
  });
2028
- const restoredSessionId = response?.sessionId || meta.acpSessionId;
2029
- if (restoredSessionId !== tab.acpSessionId) {
2030
- this.sessionToTabId.delete(tab.acpSessionId);
2031
- tab.acpSessionId = restoredSessionId;
2032
- this.sessionToTabId.set(tab.acpSessionId, tab.id);
2033
- }
2034
- if (typeof response?.title === 'string') {
2035
- tab.title = response.title;
2036
- }
2037
- tab.currentModeId = response?.modes?.currentModeId || '';
2038
- tab.availableModes = this.#resolveAvailableModes(
2039
- response?.modes?.availableModes,
2040
- tab.availableModes
2041
- );
2042
- tab.availableCommands = this.#resolveAvailableCommands(
2043
- response?.availableCommands,
2044
- tab.availableCommands
2045
- );
2046
- tab.configOptions = this.#resolveConfigOptions(
2047
- response?.configOptions,
2048
- tab.configOptions,
2049
- response?.models
2050
- );
2051
- tab.restoreReplay = null;
2052
- tab.status = 'ready';
2053
- tab.busy = false;
2054
- tab.errorMessage = '';
2055
2505
  return this.serializeTab(tab);
2056
2506
  } catch (error) {
2057
- tab.restoreReplay = null;
2507
+ tab.restoreCapture = null;
2058
2508
  this.tabs.delete(tab.id);
2059
2509
  this.sessionToTabId.delete(tab.acpSessionId);
2060
2510
  throw error;
@@ -2131,16 +2581,31 @@ class AcpRuntime extends EventEmitter {
2131
2581
  createdAt: new Date().toISOString(),
2132
2582
  title: meta.title || ''
2133
2583
  });
2584
+ // Resume rebuilds transcript ordering from the runtime replay stream.
2585
+ tab.restoreCapture = createRestoreCaptureState([]);
2586
+ tab.restoreCapture.nextTimelineOrder = () =>
2587
+ this.#nextTimelineOrder(tab);
2134
2588
  tab.status = 'restoring';
2135
2589
  tab.busy = true;
2136
2590
 
2137
2591
  this.tabs.set(tab.id, tab);
2138
2592
  this.sessionToTabId.set(tab.acpSessionId, tab.id);
2139
2593
 
2594
+ try {
2595
+ await this.#loadSessionIntoTab(tab, meta);
2596
+ return this.serializeTab(tab);
2597
+ } catch (error) {
2598
+ this.tabs.delete(tab.id);
2599
+ this.sessionToTabId.delete(tab.acpSessionId);
2600
+ throw error;
2601
+ }
2602
+ }
2603
+
2604
+ async #loadSessionIntoTab(tab, meta) {
2140
2605
  try {
2141
2606
  const response = await this.connection.loadSession({
2142
2607
  cwd: meta.cwd,
2143
- sessionId: meta.acpSessionId,
2608
+ sessionId: tab.acpSessionId,
2144
2609
  mcpServers: []
2145
2610
  });
2146
2611
  const restoredSessionId = response?.sessionId || meta.acpSessionId;
@@ -2166,13 +2631,16 @@ class AcpRuntime extends EventEmitter {
2166
2631
  tab.configOptions,
2167
2632
  response?.models
2168
2633
  );
2634
+ const replacedMessages = finalizeRestoreCaptureMessages(tab);
2169
2635
  tab.status = 'ready';
2170
2636
  tab.busy = false;
2171
2637
  tab.errorMessage = '';
2172
- return this.serializeTab(tab);
2638
+ if (replacedMessages) {
2639
+ this.#markTabDirty(tab);
2640
+ }
2641
+ return replacedMessages;
2173
2642
  } catch (error) {
2174
- this.tabs.delete(tab.id);
2175
- this.sessionToTabId.delete(tab.acpSessionId);
2643
+ tab.restoreCapture = null;
2176
2644
  throw error;
2177
2645
  }
2178
2646
  }
@@ -2186,6 +2654,7 @@ class AcpRuntime extends EventEmitter {
2186
2654
  }
2187
2655
 
2188
2656
  serializeTab(tab) {
2657
+ tab.messages = normalizeAgentTranscriptMessages(tab.messages);
2189
2658
  return {
2190
2659
  id: tab.id,
2191
2660
  runtimeId: tab.runtimeId,
@@ -2628,32 +3097,59 @@ class AcpRuntime extends EventEmitter {
2628
3097
  }
2629
3098
 
2630
3099
  async #handleSessionUpdate(params) {
3100
+ const update = params.update;
2631
3101
  const tab = this.#getTabBySession(params.sessionId);
2632
3102
  if (!tab) return;
2633
- const update = params.update;
2634
3103
  let broadcastUpdate = update;
2635
3104
  let didChange = false;
3105
+ let suppressSessionUpdateBroadcast = false;
2636
3106
 
2637
3107
  switch (update.sessionUpdate) {
2638
- case 'agent_message_chunk':
2639
- this.#appendContentChunk(tab, update, 'assistant', 'message');
2640
- didChange = true;
3108
+ case 'agent_message_chunk': {
3109
+ const result = this.#appendContentChunk(
3110
+ tab,
3111
+ update,
3112
+ 'assistant',
3113
+ 'message'
3114
+ );
3115
+ didChange = !!result.didChange;
3116
+ suppressSessionUpdateBroadcast = !!result.suppressBroadcast;
2641
3117
  break;
2642
- case 'agent_thought_chunk':
2643
- this.#appendContentChunk(tab, update, 'assistant', 'thought');
2644
- didChange = true;
3118
+ }
3119
+ case 'agent_thought_chunk': {
3120
+ const result = this.#appendContentChunk(
3121
+ tab,
3122
+ update,
3123
+ 'assistant',
3124
+ 'thought'
3125
+ );
3126
+ didChange = !!result.didChange;
3127
+ suppressSessionUpdateBroadcast = !!result.suppressBroadcast;
2645
3128
  break;
2646
- case 'user_message_chunk':
2647
- this.#appendContentChunk(tab, update, 'user', 'message');
2648
- didChange = true;
3129
+ }
3130
+ case 'user_message_chunk': {
3131
+ const result = this.#appendContentChunk(
3132
+ tab,
3133
+ update,
3134
+ 'user',
3135
+ 'message'
3136
+ );
3137
+ didChange = !!result.didChange;
3138
+ suppressSessionUpdateBroadcast = !!result.suppressBroadcast;
2649
3139
  break;
3140
+ }
2650
3141
  case 'tool_call': {
2651
3142
  this.#advanceSyntheticStreamTurn(tab);
2652
- const nextToolCall = {
2653
- ...update,
2654
- createdAt: new Date().toISOString(),
2655
- order: this.#nextTimelineOrder(tab)
2656
- };
3143
+ const baseline = tab.restoreCapture?.baselineToolCalls?.get(
3144
+ update.toolCallId
3145
+ ) || null;
3146
+ const previous = tab.toolCalls.get(update.toolCallId) || null;
3147
+ const nextToolCall = buildRestoredToolCall(
3148
+ previous,
3149
+ baseline,
3150
+ update,
3151
+ () => this.#nextTimelineOrder(tab)
3152
+ );
2657
3153
  tab.toolCalls.set(update.toolCallId, nextToolCall);
2658
3154
  broadcastUpdate = nextToolCall;
2659
3155
  didChange = true;
@@ -2661,17 +3157,16 @@ class AcpRuntime extends EventEmitter {
2661
3157
  }
2662
3158
  case 'tool_call_update': {
2663
3159
  this.#advanceSyntheticStreamTurn(tab);
2664
- const previous = tab.toolCalls.get(update.toolCallId) || {
2665
- toolCallId: update.toolCallId,
2666
- title: '',
2667
- status: 'pending',
2668
- createdAt: new Date().toISOString(),
2669
- order: this.#nextTimelineOrder(tab)
2670
- };
2671
- const nextToolCall = {
2672
- ...previous,
2673
- ...update
2674
- };
3160
+ const previous = tab.toolCalls.get(update.toolCallId) || null;
3161
+ const baseline = tab.restoreCapture?.baselineToolCalls?.get(
3162
+ update.toolCallId
3163
+ ) || null;
3164
+ const nextToolCall = buildRestoredToolCall(
3165
+ previous,
3166
+ baseline,
3167
+ update,
3168
+ () => this.#nextTimelineOrder(tab)
3169
+ );
2675
3170
  tab.toolCalls.set(update.toolCallId, nextToolCall);
2676
3171
  broadcastUpdate = nextToolCall;
2677
3172
  didChange = true;
@@ -2715,17 +3210,19 @@ class AcpRuntime extends EventEmitter {
2715
3210
  break;
2716
3211
  }
2717
3212
 
2718
- this.#broadcast(tab, {
2719
- type: 'session_update',
2720
- update: broadcastUpdate,
2721
- tab: {
2722
- title: tab.title,
2723
- currentModeId: tab.currentModeId,
2724
- availableModes: tab.availableModes,
2725
- availableCommands: tab.availableCommands,
2726
- configOptions: tab.configOptions
2727
- }
2728
- });
3213
+ if (!suppressSessionUpdateBroadcast) {
3214
+ this.#broadcast(tab, {
3215
+ type: 'session_update',
3216
+ update: broadcastUpdate,
3217
+ tab: {
3218
+ title: tab.title,
3219
+ currentModeId: tab.currentModeId,
3220
+ availableModes: tab.availableModes,
3221
+ availableCommands: tab.availableCommands,
3222
+ configOptions: tab.configOptions
3223
+ }
3224
+ });
3225
+ }
2729
3226
  if (didChange) {
2730
3227
  this.#markTabDirty(tab);
2731
3228
  }
@@ -2737,10 +3234,17 @@ class AcpRuntime extends EventEmitter {
2737
3234
  ? (content.text || '')
2738
3235
  : `[${content.type}]`;
2739
3236
  if (role === 'user' && kind === 'message' && this.#consumeUserEcho(tab, text)) {
2740
- return;
3237
+ return {
3238
+ didChange: false,
3239
+ suppressBroadcast: false
3240
+ };
2741
3241
  }
2742
- if (consumeRestoredMessageReplay(tab.restoreReplay, role, kind, text)) {
2743
- return;
3242
+ if (tab.restoreCapture) {
3243
+ captureRestoreReplayChunk(tab.restoreCapture, update, role, kind, text);
3244
+ return {
3245
+ didChange: false,
3246
+ suppressBroadcast: true
3247
+ };
2744
3248
  }
2745
3249
  const streamKey = this.#getStreamKey(tab, update, role, kind);
2746
3250
  const last = tab.messages[tab.messages.length - 1] || null;
@@ -2761,7 +3265,10 @@ class AcpRuntime extends EventEmitter {
2761
3265
  kind,
2762
3266
  text: appendedText
2763
3267
  });
2764
- return;
3268
+ return {
3269
+ didChange: true,
3270
+ suppressBroadcast: false
3271
+ };
2765
3272
  }
2766
3273
 
2767
3274
  if (!update.messageId) {
@@ -2781,6 +3288,10 @@ class AcpRuntime extends EventEmitter {
2781
3288
  type: 'message_open',
2782
3289
  message
2783
3290
  });
3291
+ return {
3292
+ didChange: true,
3293
+ suppressBroadcast: false
3294
+ };
2784
3295
  }
2785
3296
 
2786
3297
  #consumeUserEcho(tab, text) {
@@ -3538,8 +4049,8 @@ export class AcpManager {
3538
4049
  }, this.transcriptPersistDelayMs);
3539
4050
  }
3540
4051
 
3541
- getPersistedTabs() {
3542
- return Array.from(this.tabs.values()).map((entry) => {
4052
+ #getSerializedTabs() {
4053
+ const serializedTabs = Array.from(this.tabs.values()).map((entry) => {
3543
4054
  const tab = entry.serialize();
3544
4055
  return {
3545
4056
  id: tab.id,
@@ -3577,6 +4088,21 @@ export class AcpManager {
3577
4088
  : []
3578
4089
  };
3579
4090
  });
4091
+ return dedupeSerializedTabs(serializedTabs).tabs;
4092
+ }
4093
+
4094
+ #findOpenSerializedTabBySessionId(sessionId) {
4095
+ const targetSessionId = String(sessionId || '').trim();
4096
+ if (!targetSessionId) {
4097
+ return null;
4098
+ }
4099
+ return this.#getSerializedTabs().find(
4100
+ (tab) => String(tab?.acpSessionId || '').trim() === targetSessionId
4101
+ ) || null;
4102
+ }
4103
+
4104
+ getPersistedTabs() {
4105
+ return this.#getSerializedTabs();
3580
4106
  }
3581
4107
 
3582
4108
  persistTabs() {
@@ -3608,15 +4134,15 @@ export class AcpManager {
3608
4134
  restoring: this.restoring,
3609
4135
  definitions: await this.listDefinitions(),
3610
4136
  configs: await this.listAgentConfigs(),
3611
- tabs: Array.from(this.tabs.values()).map((entry) => entry.serialize())
4137
+ tabs: this.#getSerializedTabs()
3612
4138
  };
3613
4139
  }
3614
4140
 
3615
4141
  async listInventory() {
4142
+ const tabs = this.#getSerializedTabs();
3616
4143
  return {
3617
4144
  restoring: this.restoring,
3618
- tabs: Array.from(this.tabs.values()).map((entry) => {
3619
- const serialized = entry.serialize();
4145
+ tabs: tabs.map((serialized) => {
3620
4146
  return {
3621
4147
  id: serialized.id,
3622
4148
  runtimeId: serialized.runtimeId,
@@ -3871,6 +4397,13 @@ export class AcpManager {
3871
4397
  throw new Error(availability.reason || 'Agent unavailable');
3872
4398
  }
3873
4399
 
4400
+ const existingTab = this.#findOpenSerializedTabBySessionId(
4401
+ options.sessionId
4402
+ );
4403
+ if (existingTab) {
4404
+ return existingTab;
4405
+ }
4406
+
3874
4407
  const cwd = path.resolve(options.cwd || process.cwd());
3875
4408
  const { runtimeEntry, createdRuntime, runtimeStoreKey } =
3876
4409
  this.#ensureRuntimeEntry(definition, cwd);
@@ -3917,10 +4450,21 @@ export class AcpManager {
3917
4450
 
3918
4451
  async restoreTabs(validTerminalSessionIds = new Set()) {
3919
4452
  await this.ensureConfigsLoaded();
3920
- const entries = await this.loadTabs();
4453
+ const dedupedTabs = dedupeSerializedTabs(await this.loadTabs());
4454
+ const entries = dedupedTabs.tabs;
3921
4455
  let changed = false;
4456
+ if (dedupedTabs.changed) {
4457
+ changed = true;
4458
+ }
3922
4459
 
3923
4460
  for (const meta of entries) {
4461
+ const existingTab = this.#findOpenSerializedTabBySessionId(
4462
+ meta.acpSessionId
4463
+ );
4464
+ if (existingTab) {
4465
+ changed = true;
4466
+ continue;
4467
+ }
3924
4468
  if (
3925
4469
  meta.terminalSessionId
3926
4470
  && !validTerminalSessionIds.has(meta.terminalSessionId)