tabminal 3.0.15 → 3.0.17

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,547 @@ 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
+ }
1056
1230
 
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) {
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
+ }
1248
+
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
+ }
1080
1343
 
1081
- if (!state.started) {
1082
- return findReplayStart();
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
+ }
1354
+
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;
1386
+ }
1387
+ const last = capture.messages[capture.messages.length - 1] || null;
1388
+ if (!last) {
1389
+ return;
1090
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;
1096
1442
  }
1443
+ if (!update?.messageId) {
1444
+ capture.messageCounter += 1;
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
+ options = {}
1534
+ ) {
1535
+ const persisted = cloneSerializable(baseline, {}) || {};
1536
+ const current = cloneSerializable(previous, {}) || {};
1537
+ const allowEmptyCreatedAt = options.allowEmptyCreatedAt === true;
1538
+ const nextOrder = normalizePersistedTimelineOrder(current.order, 0)
1539
+ || (
1540
+ typeof nextTimelineOrder === 'function'
1541
+ ? nextTimelineOrder()
1542
+ : normalizePersistedTimelineOrder(persisted.order, 0)
1543
+ )
1544
+ || 1;
1545
+ const inheritedCreatedAt = String(
1546
+ current.createdAt || persisted.createdAt || ''
1547
+ ).trim();
1548
+ const createdAt = inheritedCreatedAt
1549
+ || (allowEmptyCreatedAt ? '' : new Date().toISOString());
1550
+ const nextToolCall = {
1551
+ ...persisted,
1552
+ ...current,
1553
+ ...update,
1554
+ createdAt,
1555
+ order: nextOrder
1556
+ };
1557
+ if (!nextToolCall.toolCallId) {
1558
+ nextToolCall.toolCallId = String(update.toolCallId || '');
1108
1559
  }
1109
- if (state.index >= state.messages.length) {
1110
- state.exhausted = true;
1560
+ if (typeof nextToolCall.title !== 'string') {
1561
+ nextToolCall.title = '';
1111
1562
  }
1112
- return true;
1563
+ if (typeof nextToolCall.status !== 'string') {
1564
+ nextToolCall.status = 'pending';
1565
+ }
1566
+ return nextToolCall;
1113
1567
  }
1114
1568
 
1115
1569
  function normalizePersistedMessage(message = {}, fallbackOrder = 0) {
@@ -1192,12 +1646,26 @@ function normalizePersistedTerminalSummary(summary = {}) {
1192
1646
  };
1193
1647
  }
1194
1648
 
1649
+ export function getNextSyntheticStreamTurn(messages = []) {
1650
+ if (!Array.isArray(messages) || messages.length === 0) {
1651
+ return 0;
1652
+ }
1653
+ return messages.reduce((maxTurn, entry) => {
1654
+ const streamKey = String(entry?.streamKey || '');
1655
+ const match = /^synthetic:(\d+):/.exec(streamKey);
1656
+ if (!match) {
1657
+ return maxTurn;
1658
+ }
1659
+ const turn = Number.parseInt(match[1], 10);
1660
+ if (!Number.isFinite(turn)) {
1661
+ return maxTurn;
1662
+ }
1663
+ return Math.max(maxTurn, turn + 1);
1664
+ }, 0);
1665
+ }
1666
+
1195
1667
  function restorePersistedTabSnapshot(tab, snapshot = {}) {
1196
- const messages = Array.isArray(snapshot.messages)
1197
- ? snapshot.messages.map((message, index) =>
1198
- normalizePersistedMessage(message, index + 1)
1199
- )
1200
- : [];
1668
+ const messages = normalizeAgentTranscriptMessages(snapshot.messages);
1201
1669
  const toolCalls = Array.isArray(snapshot.toolCalls)
1202
1670
  ? snapshot.toolCalls.map((entry, index) =>
1203
1671
  normalizePersistedTimelineEntry(
@@ -1279,6 +1747,13 @@ function restorePersistedTabSnapshot(tab, snapshot = {}) {
1279
1747
  maxPermissionOrder
1280
1748
  );
1281
1749
  tab.messageCounter = Math.max(tab.messageCounter, messages.length);
1750
+ const maxSyntheticTurn = getNextSyntheticStreamTurn(messages);
1751
+ tab.syntheticStreamTurn = Math.max(
1752
+ Number.isFinite(tab.syntheticStreamTurn)
1753
+ ? tab.syntheticStreamTurn
1754
+ : 0,
1755
+ maxSyntheticTurn
1756
+ );
1282
1757
  }
1283
1758
 
1284
1759
  class LocalExecTerminal extends EventEmitter {
@@ -1748,7 +2223,7 @@ class AcpRuntime extends EventEmitter {
1748
2223
  syntheticStreams: new Map(),
1749
2224
  syntheticStreamTurn: 0,
1750
2225
  pendingUserEcho: null,
1751
- restoreReplay: null,
2226
+ restoreCapture: null,
1752
2227
  currentModeId,
1753
2228
  availableModes,
1754
2229
  availableCommands,
@@ -2005,14 +2480,20 @@ class AcpRuntime extends EventEmitter {
2005
2480
  availableModes: meta.availableModes || [],
2006
2481
  availableCommands: meta.availableCommands || [],
2007
2482
  configOptions: meta.configOptions || [],
2008
- messages: meta.messages || [],
2009
- toolCalls: meta.toolCalls || [],
2010
- permissions: meta.permissions || [],
2011
- plan: meta.plan || [],
2483
+ messages: [],
2484
+ toolCalls: [],
2485
+ permissions: [],
2486
+ plan: [],
2012
2487
  usage: meta.usage || null,
2013
2488
  terminals: meta.terminals || []
2014
2489
  });
2015
- tab.restoreReplay = createRestoreReplayState(meta.messages || []);
2490
+ // For loadSession-capable runtimes, transcript ordering comes from the
2491
+ // authoritative replay stream, not the persisted snapshot.
2492
+ tab.restoreCapture = createRestoreCaptureState(meta.messages || [], {
2493
+ toolCalls: meta.toolCalls || []
2494
+ });
2495
+ tab.restoreCapture.nextTimelineOrder = () =>
2496
+ this.#nextTimelineOrder(tab);
2016
2497
  tab.status = 'restoring';
2017
2498
  tab.busy = true;
2018
2499
 
@@ -2020,41 +2501,14 @@ class AcpRuntime extends EventEmitter {
2020
2501
  this.sessionToTabId.set(tab.acpSessionId, tab.id);
2021
2502
 
2022
2503
  try {
2023
- const response = await this.connection.loadSession({
2024
- cwd: meta.cwd,
2025
- sessionId: meta.acpSessionId,
2026
- mcpServers: []
2504
+ await this.#loadSessionIntoTab(tab, meta);
2505
+ this.#broadcast(tab, {
2506
+ type: 'snapshot',
2507
+ tab: this.serializeTab(tab)
2027
2508
  });
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
2509
  return this.serializeTab(tab);
2056
2510
  } catch (error) {
2057
- tab.restoreReplay = null;
2511
+ tab.restoreCapture = null;
2058
2512
  this.tabs.delete(tab.id);
2059
2513
  this.sessionToTabId.delete(tab.acpSessionId);
2060
2514
  throw error;
@@ -2131,16 +2585,31 @@ class AcpRuntime extends EventEmitter {
2131
2585
  createdAt: new Date().toISOString(),
2132
2586
  title: meta.title || ''
2133
2587
  });
2588
+ // Resume rebuilds transcript ordering from the runtime replay stream.
2589
+ tab.restoreCapture = createRestoreCaptureState([]);
2590
+ tab.restoreCapture.nextTimelineOrder = () =>
2591
+ this.#nextTimelineOrder(tab);
2134
2592
  tab.status = 'restoring';
2135
2593
  tab.busy = true;
2136
2594
 
2137
2595
  this.tabs.set(tab.id, tab);
2138
2596
  this.sessionToTabId.set(tab.acpSessionId, tab.id);
2139
2597
 
2598
+ try {
2599
+ await this.#loadSessionIntoTab(tab, meta);
2600
+ return this.serializeTab(tab);
2601
+ } catch (error) {
2602
+ this.tabs.delete(tab.id);
2603
+ this.sessionToTabId.delete(tab.acpSessionId);
2604
+ throw error;
2605
+ }
2606
+ }
2607
+
2608
+ async #loadSessionIntoTab(tab, meta) {
2140
2609
  try {
2141
2610
  const response = await this.connection.loadSession({
2142
2611
  cwd: meta.cwd,
2143
- sessionId: meta.acpSessionId,
2612
+ sessionId: tab.acpSessionId,
2144
2613
  mcpServers: []
2145
2614
  });
2146
2615
  const restoredSessionId = response?.sessionId || meta.acpSessionId;
@@ -2166,13 +2635,16 @@ class AcpRuntime extends EventEmitter {
2166
2635
  tab.configOptions,
2167
2636
  response?.models
2168
2637
  );
2638
+ const replacedMessages = finalizeRestoreCaptureMessages(tab);
2169
2639
  tab.status = 'ready';
2170
2640
  tab.busy = false;
2171
2641
  tab.errorMessage = '';
2172
- return this.serializeTab(tab);
2642
+ if (replacedMessages) {
2643
+ this.#markTabDirty(tab);
2644
+ }
2645
+ return replacedMessages;
2173
2646
  } catch (error) {
2174
- this.tabs.delete(tab.id);
2175
- this.sessionToTabId.delete(tab.acpSessionId);
2647
+ tab.restoreCapture = null;
2176
2648
  throw error;
2177
2649
  }
2178
2650
  }
@@ -2186,6 +2658,7 @@ class AcpRuntime extends EventEmitter {
2186
2658
  }
2187
2659
 
2188
2660
  serializeTab(tab) {
2661
+ tab.messages = normalizeAgentTranscriptMessages(tab.messages);
2189
2662
  return {
2190
2663
  id: tab.id,
2191
2664
  runtimeId: tab.runtimeId,
@@ -2628,32 +3101,62 @@ class AcpRuntime extends EventEmitter {
2628
3101
  }
2629
3102
 
2630
3103
  async #handleSessionUpdate(params) {
3104
+ const update = params.update;
2631
3105
  const tab = this.#getTabBySession(params.sessionId);
2632
3106
  if (!tab) return;
2633
- const update = params.update;
2634
3107
  let broadcastUpdate = update;
2635
3108
  let didChange = false;
3109
+ let suppressSessionUpdateBroadcast = false;
2636
3110
 
2637
3111
  switch (update.sessionUpdate) {
2638
- case 'agent_message_chunk':
2639
- this.#appendContentChunk(tab, update, 'assistant', 'message');
2640
- didChange = true;
3112
+ case 'agent_message_chunk': {
3113
+ const result = this.#appendContentChunk(
3114
+ tab,
3115
+ update,
3116
+ 'assistant',
3117
+ 'message'
3118
+ );
3119
+ didChange = !!result.didChange;
3120
+ suppressSessionUpdateBroadcast = !!result.suppressBroadcast;
2641
3121
  break;
2642
- case 'agent_thought_chunk':
2643
- this.#appendContentChunk(tab, update, 'assistant', 'thought');
2644
- didChange = true;
3122
+ }
3123
+ case 'agent_thought_chunk': {
3124
+ const result = this.#appendContentChunk(
3125
+ tab,
3126
+ update,
3127
+ 'assistant',
3128
+ 'thought'
3129
+ );
3130
+ didChange = !!result.didChange;
3131
+ suppressSessionUpdateBroadcast = !!result.suppressBroadcast;
2645
3132
  break;
2646
- case 'user_message_chunk':
2647
- this.#appendContentChunk(tab, update, 'user', 'message');
2648
- didChange = true;
3133
+ }
3134
+ case 'user_message_chunk': {
3135
+ const result = this.#appendContentChunk(
3136
+ tab,
3137
+ update,
3138
+ 'user',
3139
+ 'message'
3140
+ );
3141
+ didChange = !!result.didChange;
3142
+ suppressSessionUpdateBroadcast = !!result.suppressBroadcast;
2649
3143
  break;
3144
+ }
2650
3145
  case 'tool_call': {
2651
3146
  this.#advanceSyntheticStreamTurn(tab);
2652
- const nextToolCall = {
2653
- ...update,
2654
- createdAt: new Date().toISOString(),
2655
- order: this.#nextTimelineOrder(tab)
2656
- };
3147
+ const baseline = tab.restoreCapture?.baselineToolCalls?.get(
3148
+ update.toolCallId
3149
+ ) || null;
3150
+ const previous = tab.toolCalls.get(update.toolCallId) || null;
3151
+ const nextToolCall = buildRestoredToolCall(
3152
+ previous,
3153
+ baseline,
3154
+ update,
3155
+ () => this.#nextTimelineOrder(tab),
3156
+ {
3157
+ allowEmptyCreatedAt: !!tab.restoreCapture
3158
+ }
3159
+ );
2657
3160
  tab.toolCalls.set(update.toolCallId, nextToolCall);
2658
3161
  broadcastUpdate = nextToolCall;
2659
3162
  didChange = true;
@@ -2661,17 +3164,19 @@ class AcpRuntime extends EventEmitter {
2661
3164
  }
2662
3165
  case 'tool_call_update': {
2663
3166
  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
- };
3167
+ const previous = tab.toolCalls.get(update.toolCallId) || null;
3168
+ const baseline = tab.restoreCapture?.baselineToolCalls?.get(
3169
+ update.toolCallId
3170
+ ) || null;
3171
+ const nextToolCall = buildRestoredToolCall(
3172
+ previous,
3173
+ baseline,
3174
+ update,
3175
+ () => this.#nextTimelineOrder(tab),
3176
+ {
3177
+ allowEmptyCreatedAt: !!tab.restoreCapture
3178
+ }
3179
+ );
2675
3180
  tab.toolCalls.set(update.toolCallId, nextToolCall);
2676
3181
  broadcastUpdate = nextToolCall;
2677
3182
  didChange = true;
@@ -2715,17 +3220,19 @@ class AcpRuntime extends EventEmitter {
2715
3220
  break;
2716
3221
  }
2717
3222
 
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
- });
3223
+ if (!suppressSessionUpdateBroadcast) {
3224
+ this.#broadcast(tab, {
3225
+ type: 'session_update',
3226
+ update: broadcastUpdate,
3227
+ tab: {
3228
+ title: tab.title,
3229
+ currentModeId: tab.currentModeId,
3230
+ availableModes: tab.availableModes,
3231
+ availableCommands: tab.availableCommands,
3232
+ configOptions: tab.configOptions
3233
+ }
3234
+ });
3235
+ }
2729
3236
  if (didChange) {
2730
3237
  this.#markTabDirty(tab);
2731
3238
  }
@@ -2737,10 +3244,17 @@ class AcpRuntime extends EventEmitter {
2737
3244
  ? (content.text || '')
2738
3245
  : `[${content.type}]`;
2739
3246
  if (role === 'user' && kind === 'message' && this.#consumeUserEcho(tab, text)) {
2740
- return;
3247
+ return {
3248
+ didChange: false,
3249
+ suppressBroadcast: false
3250
+ };
2741
3251
  }
2742
- if (consumeRestoredMessageReplay(tab.restoreReplay, role, kind, text)) {
2743
- return;
3252
+ if (tab.restoreCapture) {
3253
+ captureRestoreReplayChunk(tab.restoreCapture, update, role, kind, text);
3254
+ return {
3255
+ didChange: false,
3256
+ suppressBroadcast: true
3257
+ };
2744
3258
  }
2745
3259
  const streamKey = this.#getStreamKey(tab, update, role, kind);
2746
3260
  const last = tab.messages[tab.messages.length - 1] || null;
@@ -2761,7 +3275,10 @@ class AcpRuntime extends EventEmitter {
2761
3275
  kind,
2762
3276
  text: appendedText
2763
3277
  });
2764
- return;
3278
+ return {
3279
+ didChange: true,
3280
+ suppressBroadcast: false
3281
+ };
2765
3282
  }
2766
3283
 
2767
3284
  if (!update.messageId) {
@@ -2781,6 +3298,10 @@ class AcpRuntime extends EventEmitter {
2781
3298
  type: 'message_open',
2782
3299
  message
2783
3300
  });
3301
+ return {
3302
+ didChange: true,
3303
+ suppressBroadcast: false
3304
+ };
2784
3305
  }
2785
3306
 
2786
3307
  #consumeUserEcho(tab, text) {
@@ -3538,8 +4059,8 @@ export class AcpManager {
3538
4059
  }, this.transcriptPersistDelayMs);
3539
4060
  }
3540
4061
 
3541
- getPersistedTabs() {
3542
- return Array.from(this.tabs.values()).map((entry) => {
4062
+ #getSerializedTabs() {
4063
+ const serializedTabs = Array.from(this.tabs.values()).map((entry) => {
3543
4064
  const tab = entry.serialize();
3544
4065
  return {
3545
4066
  id: tab.id,
@@ -3577,6 +4098,21 @@ export class AcpManager {
3577
4098
  : []
3578
4099
  };
3579
4100
  });
4101
+ return dedupeSerializedTabs(serializedTabs).tabs;
4102
+ }
4103
+
4104
+ #findOpenSerializedTabBySessionId(sessionId) {
4105
+ const targetSessionId = String(sessionId || '').trim();
4106
+ if (!targetSessionId) {
4107
+ return null;
4108
+ }
4109
+ return this.#getSerializedTabs().find(
4110
+ (tab) => String(tab?.acpSessionId || '').trim() === targetSessionId
4111
+ ) || null;
4112
+ }
4113
+
4114
+ getPersistedTabs() {
4115
+ return this.#getSerializedTabs();
3580
4116
  }
3581
4117
 
3582
4118
  persistTabs() {
@@ -3608,15 +4144,15 @@ export class AcpManager {
3608
4144
  restoring: this.restoring,
3609
4145
  definitions: await this.listDefinitions(),
3610
4146
  configs: await this.listAgentConfigs(),
3611
- tabs: Array.from(this.tabs.values()).map((entry) => entry.serialize())
4147
+ tabs: this.#getSerializedTabs()
3612
4148
  };
3613
4149
  }
3614
4150
 
3615
4151
  async listInventory() {
4152
+ const tabs = this.#getSerializedTabs();
3616
4153
  return {
3617
4154
  restoring: this.restoring,
3618
- tabs: Array.from(this.tabs.values()).map((entry) => {
3619
- const serialized = entry.serialize();
4155
+ tabs: tabs.map((serialized) => {
3620
4156
  return {
3621
4157
  id: serialized.id,
3622
4158
  runtimeId: serialized.runtimeId,
@@ -3871,6 +4407,13 @@ export class AcpManager {
3871
4407
  throw new Error(availability.reason || 'Agent unavailable');
3872
4408
  }
3873
4409
 
4410
+ const existingTab = this.#findOpenSerializedTabBySessionId(
4411
+ options.sessionId
4412
+ );
4413
+ if (existingTab) {
4414
+ return existingTab;
4415
+ }
4416
+
3874
4417
  const cwd = path.resolve(options.cwd || process.cwd());
3875
4418
  const { runtimeEntry, createdRuntime, runtimeStoreKey } =
3876
4419
  this.#ensureRuntimeEntry(definition, cwd);
@@ -3917,10 +4460,21 @@ export class AcpManager {
3917
4460
 
3918
4461
  async restoreTabs(validTerminalSessionIds = new Set()) {
3919
4462
  await this.ensureConfigsLoaded();
3920
- const entries = await this.loadTabs();
4463
+ const dedupedTabs = dedupeSerializedTabs(await this.loadTabs());
4464
+ const entries = dedupedTabs.tabs;
3921
4465
  let changed = false;
4466
+ if (dedupedTabs.changed) {
4467
+ changed = true;
4468
+ }
3922
4469
 
3923
4470
  for (const meta of entries) {
4471
+ const existingTab = this.#findOpenSerializedTabBySessionId(
4472
+ meta.acpSessionId
4473
+ );
4474
+ if (existingTab) {
4475
+ changed = true;
4476
+ continue;
4477
+ }
3924
4478
  if (
3925
4479
  meta.terminalSessionId
3926
4480
  && !validTerminalSessionIds.has(meta.terminalSessionId)