viveworker 0.1.2 → 0.1.4

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.
@@ -10,6 +10,7 @@ import os from "node:os";
10
10
  import path from "node:path";
11
11
  import process from "node:process";
12
12
  import { createInterface } from "node:readline";
13
+ import { inspect } from "node:util";
13
14
  import { fileURLToPath } from "node:url";
14
15
  import webPush from "web-push";
15
16
  import { DEFAULT_LOCALE, SUPPORTED_LOCALES, localeDisplayName, normalizeLocale, resolveLocalePreference, t } from "../web/i18n.js";
@@ -33,7 +34,7 @@ const PAIRING_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
33
34
  const PAIRING_RATE_LIMIT_MAX_ATTEMPTS = 8;
34
35
  const DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
35
36
  const DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
36
- const MAX_COMPLETION_REPLY_IMAGE_COUNT = 1;
37
+ const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
37
38
 
38
39
  const cli = parseCliArgs(process.argv.slice(2));
39
40
  const envFile = resolveEnvFile(cli.envFile);
@@ -58,6 +59,7 @@ const runtime = {
58
59
  sourceFile: "",
59
60
  },
60
61
  rolloutThreadLabels: new Map(),
62
+ rolloutThreadCwds: new Map(),
61
63
  threadStates: new Map(),
62
64
  threadOwnerClientIds: new Map(),
63
65
  nativeApprovalsByToken: new Map(),
@@ -83,6 +85,7 @@ const restoredPendingPlanStateChanged = restorePendingPlanRequests({ config, run
83
85
  const restoredPendingUserInputStateChanged = restorePendingUserInputRequests({ config, runtime, state });
84
86
  runtime.recentHistoryItems = normalizeHistoryItems(state.recentHistoryItems ?? [], config.maxHistoryItems);
85
87
  runtime.recentTimelineEntries = normalizeTimelineEntries(state.recentTimelineEntries ?? [], config.maxTimelineEntries);
88
+ const restoredTimelineImagePathsStateChanged = await backfillPersistedTimelineImagePaths({ config, runtime, state });
86
89
  runtime.historyFileState.offset = Number(state.historyFileOffset) || 0;
87
90
  runtime.historyFileState.sourceFile = cleanText(state.historyFileSourceFile ?? "");
88
91
 
@@ -216,6 +219,29 @@ function kindTitle(locale, kind) {
216
219
  }
217
220
  }
218
221
 
222
+ function looksLikeGeneratedThreadTitle(value) {
223
+ const normalized = cleanText(value || "");
224
+ if (!normalized.includes("|")) {
225
+ return false;
226
+ }
227
+ const prefix = cleanText(normalized.split("|", 1)[0] || "");
228
+ if (!prefix) {
229
+ return false;
230
+ }
231
+ const titleKeys = [
232
+ "server.title.userMessage",
233
+ "server.title.assistantCommentary",
234
+ "server.title.assistantFinal",
235
+ "server.title.approval",
236
+ "server.title.plan",
237
+ "server.title.planReady",
238
+ "server.title.choice",
239
+ "server.title.choiceReadOnly",
240
+ "server.title.complete",
241
+ ];
242
+ return SUPPORTED_LOCALES.some((locale) => titleKeys.some((key) => t(locale, key) === prefix));
243
+ }
244
+
219
245
  function formatLocalizedTitle(locale, baseKeyOrTitle, threadLabel) {
220
246
  const baseTitle = baseKeyOrTitle.includes(".") ? t(locale, baseKeyOrTitle) : baseKeyOrTitle;
221
247
  return formatTitle(baseTitle, threadLabel);
@@ -277,7 +303,7 @@ function normalizeHistoryItem(raw) {
277
303
  const stableId = cleanText(raw.stableId ?? raw.id ?? "");
278
304
  const kind = cleanText(raw.kind ?? "");
279
305
  const title = cleanText(raw.title ?? "");
280
- const messageText = normalizeLongText(raw.messageText ?? "");
306
+ const messageText = normalizeTimelineMessageText(raw.messageText ?? "");
281
307
  const createdAtMs = Number(raw.createdAtMs) || Date.now();
282
308
  if (!stableId || !historyKinds.has(kind) || !title) {
283
309
  return null;
@@ -292,6 +318,7 @@ function normalizeHistoryItem(raw) {
292
318
  threadLabel: cleanText(raw.threadLabel ?? ""),
293
319
  summary: normalizeNotificationText(raw.summary ?? "") || formatNotificationBody(messageText, 100) || "",
294
320
  messageText,
321
+ imagePaths: normalizeTimelineImagePaths(raw.imagePaths ?? raw.localImagePaths ?? []),
295
322
  createdAtMs,
296
323
  readOnly: raw.readOnly !== false,
297
324
  primaryLabel: cleanText(raw.primaryLabel ?? "") || "詳細",
@@ -365,7 +392,7 @@ function normalizeTimelineEntry(raw) {
365
392
  return null;
366
393
  }
367
394
 
368
- const messageText = normalizeLongText(raw.messageText ?? "");
395
+ const messageText = normalizeTimelineMessageText(raw.messageText ?? "");
369
396
  const summary =
370
397
  normalizeNotificationText(raw.summary ?? "") ||
371
398
  formatNotificationBody(messageText, 180) ||
@@ -383,6 +410,7 @@ function normalizeTimelineEntry(raw) {
383
410
  title,
384
411
  summary,
385
412
  messageText,
413
+ imagePaths: normalizeTimelineImagePaths(raw.imagePaths ?? raw.localImagePaths ?? []),
386
414
  createdAtMs,
387
415
  readOnly: raw.readOnly !== false,
388
416
  primaryLabel: cleanText(raw.primaryLabel ?? "") || "詳細",
@@ -728,6 +756,9 @@ async function scanOnce({ config, runtime, state }) {
728
756
  }
729
757
 
730
758
  if (sessionIndexChanged || knownFilesChanged) {
759
+ if (knownFilesChanged) {
760
+ runtime.rolloutThreadCwds = new Map();
761
+ }
731
762
  runtime.rolloutThreadLabels = await buildRolloutThreadLabelIndex(runtime.knownFiles, runtime.sessionIndex);
732
763
  dirty = refreshResolvedThreadLabels({ config, runtime, state }) || dirty;
733
764
  }
@@ -769,6 +800,27 @@ async function scanOnce({ config, runtime, state }) {
769
800
  now,
770
801
  });
771
802
  dirty = dirty || historyTimelineChanged;
803
+
804
+ const timelineImageBackfillChanged = await backfillRecentTimelineEntryImages({
805
+ config,
806
+ runtime,
807
+ state,
808
+ });
809
+ dirty = dirty || timelineImageBackfillChanged;
810
+
811
+ const persistedTimelineImageBackfillChanged = await backfillPersistedTimelineImagePaths({
812
+ config,
813
+ runtime,
814
+ state,
815
+ });
816
+ dirty = dirty || persistedTimelineImageBackfillChanged;
817
+
818
+ const interruptedTimelineBackfillChanged = backfillInterruptedTimelineEntries({
819
+ config,
820
+ runtime,
821
+ state,
822
+ });
823
+ dirty = dirty || interruptedTimelineBackfillChanged;
772
824
  }
773
825
 
774
826
  dirty = cleanupExpiredPlanRequests({ runtime, state, now }) || dirty;
@@ -1181,6 +1233,242 @@ async function processHistoryTimelineFile({ config, runtime, state, now }) {
1181
1233
  return dirty;
1182
1234
  }
1183
1235
 
1236
+ async function backfillRecentTimelineEntryImages({ config, runtime, state }) {
1237
+ const candidates = runtime.recentTimelineEntries.filter(
1238
+ (entry) =>
1239
+ cleanText(entry?.kind || "") === "user_message" &&
1240
+ cleanText(entry?.threadId || "") &&
1241
+ normalizeTimelineImagePaths(entry?.imagePaths ?? []).length === 0
1242
+ );
1243
+ if (candidates.length === 0 || !Array.isArray(runtime.knownFiles) || runtime.knownFiles.length === 0) {
1244
+ return false;
1245
+ }
1246
+
1247
+ const fileCache = new Map();
1248
+ let changed = false;
1249
+ const nextEntries = runtime.recentTimelineEntries.map((entry) => ({ ...entry }));
1250
+
1251
+ for (let index = 0; index < nextEntries.length; index += 1) {
1252
+ const entry = nextEntries[index];
1253
+ if (
1254
+ cleanText(entry?.kind || "") !== "user_message" ||
1255
+ !cleanText(entry?.threadId || "") ||
1256
+ normalizeTimelineImagePaths(entry?.imagePaths ?? []).length > 0
1257
+ ) {
1258
+ continue;
1259
+ }
1260
+
1261
+ const hydrated = await hydrateTimelineEntryImagesFromRollout({
1262
+ config,
1263
+ runtime,
1264
+ entry,
1265
+ fileCache,
1266
+ });
1267
+ if (!hydrated) {
1268
+ continue;
1269
+ }
1270
+
1271
+ nextEntries[index] = hydrated;
1272
+ changed = true;
1273
+ }
1274
+
1275
+ if (!changed) {
1276
+ return false;
1277
+ }
1278
+
1279
+ const normalized = normalizeTimelineEntries(nextEntries, config.maxTimelineEntries);
1280
+ runtime.recentTimelineEntries = normalized;
1281
+ state.recentTimelineEntries = normalized;
1282
+ return true;
1283
+ }
1284
+
1285
+ async function backfillPersistedTimelineImagePaths({ config, runtime, state }) {
1286
+ let changed = false;
1287
+ const nextEntries = [];
1288
+ for (const entry of runtime.recentTimelineEntries) {
1289
+ const nextImagePaths = await normalizePersistedTimelineImagePaths({
1290
+ config,
1291
+ state,
1292
+ imagePaths: entry?.imagePaths ?? [],
1293
+ });
1294
+ if (JSON.stringify(nextImagePaths) !== JSON.stringify(normalizeTimelineImagePaths(entry?.imagePaths ?? []))) {
1295
+ changed = true;
1296
+ nextEntries.push({
1297
+ ...entry,
1298
+ imagePaths: nextImagePaths,
1299
+ });
1300
+ continue;
1301
+ }
1302
+ nextEntries.push(entry);
1303
+ }
1304
+
1305
+ if (!changed) {
1306
+ return false;
1307
+ }
1308
+
1309
+ const normalized = normalizeTimelineEntries(nextEntries, config.maxTimelineEntries);
1310
+ runtime.recentTimelineEntries = normalized;
1311
+ state.recentTimelineEntries = normalized;
1312
+ return true;
1313
+ }
1314
+
1315
+ function backfillInterruptedTimelineEntries({ config, runtime, state }) {
1316
+ const locale = normalizeLocale(config.defaultLocale) || DEFAULT_LOCALE;
1317
+ let changed = false;
1318
+ const nextEntries = runtime.recentTimelineEntries.map((entry) => {
1319
+ const nextMessageText = normalizeTimelineMessageText(entry?.messageText ?? "", locale);
1320
+ const nextSummary = normalizeNotificationText(entry?.summary ?? "", locale) || formatNotificationBody(nextMessageText, 180);
1321
+ if (nextMessageText === (entry?.messageText ?? "") && nextSummary === (entry?.summary ?? "")) {
1322
+ return entry;
1323
+ }
1324
+ changed = true;
1325
+ return {
1326
+ ...entry,
1327
+ messageText: nextMessageText,
1328
+ summary: nextSummary,
1329
+ };
1330
+ });
1331
+
1332
+ if (!changed) {
1333
+ return false;
1334
+ }
1335
+
1336
+ runtime.recentTimelineEntries = nextEntries;
1337
+ state.recentTimelineEntries = nextEntries;
1338
+ return true;
1339
+ }
1340
+
1341
+ async function hydrateTimelineEntryImagesFromRollout({ config, runtime, entry, fileCache }) {
1342
+ const threadId = cleanText(entry?.threadId || "");
1343
+ if (!threadId) {
1344
+ return null;
1345
+ }
1346
+
1347
+ const rolloutFile = findRolloutFileForThread(runtime, threadId);
1348
+ if (!rolloutFile) {
1349
+ return null;
1350
+ }
1351
+
1352
+ let recentMessages = fileCache.get(rolloutFile);
1353
+ if (!recentMessages) {
1354
+ recentMessages = await readRecentRolloutUserMessagesWithImages({
1355
+ filePath: rolloutFile,
1356
+ maxBytes: Math.max(config.maxReadBytes * 4, 1024 * 1024),
1357
+ });
1358
+ fileCache.set(rolloutFile, recentMessages);
1359
+ }
1360
+
1361
+ if (!recentMessages.length) {
1362
+ return null;
1363
+ }
1364
+
1365
+ const entryCreatedAtMs = Number(entry?.createdAtMs) || 0;
1366
+ const entryMessageText = normalizeTimelineMessageText(entry?.messageText ?? "");
1367
+ let bestMatch = null;
1368
+
1369
+ for (const candidate of recentMessages) {
1370
+ const candidateCreatedAtMs = Number(candidate?.createdAtMs) || 0;
1371
+ if (entryCreatedAtMs && candidateCreatedAtMs && Math.abs(candidateCreatedAtMs - entryCreatedAtMs) > 15_000) {
1372
+ continue;
1373
+ }
1374
+ if (entryMessageText && normalizeTimelineMessageText(candidate?.messageText ?? "") !== entryMessageText) {
1375
+ continue;
1376
+ }
1377
+
1378
+ if (!bestMatch) {
1379
+ bestMatch = candidate;
1380
+ continue;
1381
+ }
1382
+
1383
+ const previousDiff = Math.abs((Number(bestMatch.createdAtMs) || 0) - entryCreatedAtMs);
1384
+ const nextDiff = Math.abs(candidateCreatedAtMs - entryCreatedAtMs);
1385
+ if (nextDiff < previousDiff) {
1386
+ bestMatch = candidate;
1387
+ }
1388
+ }
1389
+
1390
+ if (!bestMatch || normalizeTimelineImagePaths(bestMatch.imagePaths).length === 0) {
1391
+ return null;
1392
+ }
1393
+
1394
+ const nextMessageText = normalizeTimelineMessageText(bestMatch.messageText ?? entryMessageText);
1395
+ return normalizeTimelineEntry({
1396
+ ...entry,
1397
+ messageText: nextMessageText,
1398
+ summary: formatNotificationBody(nextMessageText, 180) || cleanText(entry.summary || ""),
1399
+ imagePaths: bestMatch.imagePaths,
1400
+ });
1401
+ }
1402
+
1403
+ function findRolloutFileForThread(runtime, threadId) {
1404
+ const normalizedThreadId = cleanText(threadId || "");
1405
+ if (!normalizedThreadId) {
1406
+ return "";
1407
+ }
1408
+ return (
1409
+ (Array.isArray(runtime.knownFiles) ? runtime.knownFiles : []).find(
1410
+ (filePath) => extractThreadIdFromRolloutPath(filePath) === normalizedThreadId
1411
+ ) || ""
1412
+ );
1413
+ }
1414
+
1415
+ async function readRecentRolloutUserMessagesWithImages({ filePath, maxBytes }) {
1416
+ let stat;
1417
+ try {
1418
+ stat = await fs.stat(filePath);
1419
+ } catch {
1420
+ return [];
1421
+ }
1422
+
1423
+ const readLength = Math.max(0, Math.min(Number(maxBytes) || 0, stat.size));
1424
+ const startOffset = Math.max(0, stat.size - readLength);
1425
+ let chunk = "";
1426
+ try {
1427
+ const handle = await fs.open(filePath, "r");
1428
+ const buffer = Buffer.alloc(readLength);
1429
+ try {
1430
+ await handle.read(buffer, 0, readLength, startOffset);
1431
+ } finally {
1432
+ await handle.close();
1433
+ }
1434
+ chunk = buffer.toString("utf8");
1435
+ } catch {
1436
+ return [];
1437
+ }
1438
+
1439
+ const lines = chunk.split("\n");
1440
+ if (startOffset > 0 && lines.length > 0) {
1441
+ lines.shift();
1442
+ }
1443
+
1444
+ const matches = [];
1445
+ for (const rawLine of lines) {
1446
+ if (!rawLine.trim()) {
1447
+ continue;
1448
+ }
1449
+
1450
+ let record;
1451
+ try {
1452
+ record = JSON.parse(rawLine);
1453
+ } catch {
1454
+ continue;
1455
+ }
1456
+
1457
+ const extracted = extractRolloutUserMessage(record);
1458
+ if (!extracted || normalizeTimelineImagePaths(extracted.imagePaths).length === 0) {
1459
+ continue;
1460
+ }
1461
+
1462
+ matches.push({
1463
+ createdAtMs: Date.parse(record.timestamp ?? "") || 0,
1464
+ messageText: extracted.messageText,
1465
+ imagePaths: extracted.imagePaths,
1466
+ });
1467
+ }
1468
+
1469
+ return matches;
1470
+ }
1471
+
1184
1472
  async function querySqliteTimelineRows({ logsDbFile, cursorId, minTsSec = 0 }) {
1185
1473
  const conditions = [
1186
1474
  `id > ${Math.max(0, Number(cursorId) || 0)}`,
@@ -1272,9 +1560,31 @@ function buildSqliteTimelineEntry({ row, config, runtime }) {
1272
1560
  });
1273
1561
  }
1274
1562
 
1275
- function buildRolloutUserTimelineEntry({ record, fileState, runtime }) {
1563
+ function extractRolloutUserMessage(record) {
1276
1564
  const payload = isPlainObject(record?.payload) ? record.payload : null;
1277
- if (!payload || payload.type !== "message" || payload.role !== "user") {
1565
+ if (!payload) {
1566
+ return null;
1567
+ }
1568
+
1569
+ if (record?.type === "event_msg" && payload.type === "user_message") {
1570
+ const messageText = normalizeTimelineMessageText(payload.message ?? "");
1571
+ const imagePaths = normalizeTimelineImagePaths(payload.local_images ?? payload.localImagePaths ?? []);
1572
+ if (!messageText && imagePaths.length === 0) {
1573
+ return null;
1574
+ }
1575
+ return {
1576
+ itemId: cleanText(payload.turn_id || record.timestamp || ""),
1577
+ messageText,
1578
+ imagePaths,
1579
+ };
1580
+ }
1581
+
1582
+ if (payload.type !== "message" || payload.role !== "user") {
1583
+ return null;
1584
+ }
1585
+
1586
+ if (rolloutContentHasImages(payload.content)) {
1587
+ // Prefer the richer event_msg.user_message entry when images are attached.
1278
1588
  return null;
1279
1589
  }
1280
1590
 
@@ -1283,13 +1593,139 @@ function buildRolloutUserTimelineEntry({ record, fileState, runtime }) {
1283
1593
  return null;
1284
1594
  }
1285
1595
 
1596
+ return {
1597
+ itemId: cleanText(payload.id || record.timestamp || ""),
1598
+ messageText,
1599
+ imagePaths: [],
1600
+ };
1601
+ }
1602
+
1603
+ function extractTimestampPrefixFromImagePath(filePath) {
1604
+ const match = path.basename(cleanText(filePath || "")).match(/^(\d{10,})-/u);
1605
+ return match ? Number(match[1]) || 0 : 0;
1606
+ }
1607
+
1608
+ async function listReplyUploadFiles(config) {
1609
+ try {
1610
+ const entries = await fs.readdir(config.replyUploadsDir, { withFileTypes: true });
1611
+ return entries
1612
+ .filter((entry) => entry.isFile())
1613
+ .map((entry) => {
1614
+ const filePath = path.join(config.replyUploadsDir, entry.name);
1615
+ return {
1616
+ filePath,
1617
+ extension: path.extname(entry.name).toLowerCase(),
1618
+ ts: extractTimestampPrefixFromImagePath(entry.name),
1619
+ };
1620
+ })
1621
+ .filter((entry) => entry.ts > 0);
1622
+ } catch {
1623
+ return [];
1624
+ }
1625
+ }
1626
+
1627
+ async function findReplyUploadFallback(config, sourcePath, usedPaths = new Set()) {
1628
+ const targetTs = extractTimestampPrefixFromImagePath(sourcePath);
1629
+ const targetExtension = path.extname(cleanText(sourcePath || "")).toLowerCase();
1630
+ if (!targetTs) {
1631
+ return "";
1632
+ }
1633
+
1634
+ const uploads = await listReplyUploadFiles(config);
1635
+ const candidates = uploads
1636
+ .filter((entry) => !usedPaths.has(entry.filePath))
1637
+ .filter((entry) => !targetExtension || entry.extension === targetExtension)
1638
+ .filter((entry) => Math.abs(entry.ts - targetTs) <= 60_000)
1639
+ .sort((left, right) => Math.abs(left.ts - targetTs) - Math.abs(right.ts - targetTs));
1640
+
1641
+ return candidates[0]?.filePath || "";
1642
+ }
1643
+
1644
+ async function copyTimelineAttachmentToPersistentDir(config, sourcePath) {
1645
+ const normalizedSourcePath = resolvePath(cleanText(sourcePath || ""));
1646
+ if (!normalizedSourcePath) {
1647
+ return "";
1648
+ }
1649
+
1650
+ await fs.mkdir(config.timelineAttachmentsDir, { recursive: true });
1651
+ const extension = path.extname(normalizedSourcePath) || ".img";
1652
+ const destinationPath = path.join(
1653
+ config.timelineAttachmentsDir,
1654
+ `${Date.now()}-${crypto.randomUUID()}${extension}`
1655
+ );
1656
+ await fs.copyFile(normalizedSourcePath, destinationPath);
1657
+ return destinationPath;
1658
+ }
1659
+
1660
+ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths = [] }) {
1661
+ const normalizedImagePaths = normalizeTimelineImagePaths(imagePaths);
1662
+ if (normalizedImagePaths.length === 0) {
1663
+ return [];
1664
+ }
1665
+
1666
+ const aliases = isPlainObject(state.timelineImagePathAliases) ? state.timelineImagePathAliases : (state.timelineImagePathAliases = {});
1667
+ const usedFallbacks = new Set();
1668
+ const nextPaths = [];
1669
+
1670
+ for (const rawPath of normalizedImagePaths) {
1671
+ const normalizedPath = cleanText(rawPath || "");
1672
+ if (!normalizedPath) {
1673
+ continue;
1674
+ }
1675
+
1676
+ const aliasedPath = cleanText(aliases[normalizedPath] || "");
1677
+ if (aliasedPath) {
1678
+ try {
1679
+ await fs.access(aliasedPath);
1680
+ nextPaths.push(aliasedPath);
1681
+ continue;
1682
+ } catch {
1683
+ // Fall through and repair below.
1684
+ }
1685
+ }
1686
+
1687
+ let existingSourcePath = normalizedPath;
1688
+ try {
1689
+ await fs.access(existingSourcePath);
1690
+ } catch {
1691
+ existingSourcePath = await findReplyUploadFallback(config, normalizedPath, usedFallbacks);
1692
+ if (!existingSourcePath) {
1693
+ continue;
1694
+ }
1695
+ usedFallbacks.add(existingSourcePath);
1696
+ }
1697
+
1698
+ let persistentPath = existingSourcePath;
1699
+ if (!existingSourcePath.startsWith(`${config.timelineAttachmentsDir}${path.sep}`)) {
1700
+ persistentPath = await copyTimelineAttachmentToPersistentDir(config, existingSourcePath);
1701
+ }
1702
+
1703
+ aliases[normalizedPath] = persistentPath;
1704
+ nextPaths.push(persistentPath);
1705
+ }
1706
+
1707
+ return normalizeTimelineImagePaths(nextPaths);
1708
+ }
1709
+
1710
+ function buildRolloutUserTimelineEntry({ record, fileState, runtime }) {
1711
+ const extracted = extractRolloutUserMessage(record);
1712
+ if (!extracted) {
1713
+ return null;
1714
+ }
1715
+
1286
1716
  const threadId = cleanText(fileState.threadId || "");
1287
1717
  if (!threadId) {
1288
1718
  return null;
1289
1719
  }
1290
1720
 
1291
1721
  const createdAtMs = Date.parse(record.timestamp ?? "") || Date.now();
1292
- const stableId = messageTimelineStableId("user_message", threadId, payload.id || record.timestamp, messageText, createdAtMs);
1722
+ const stableId = messageTimelineStableId(
1723
+ "user_message",
1724
+ threadId,
1725
+ extracted.itemId,
1726
+ extracted.messageText,
1727
+ createdAtMs
1728
+ );
1293
1729
  const threadLabel = getNativeThreadLabel({
1294
1730
  runtime,
1295
1731
  conversationId: threadId,
@@ -1303,8 +1739,9 @@ function buildRolloutUserTimelineEntry({ record, fileState, runtime }) {
1303
1739
  threadId,
1304
1740
  threadLabel,
1305
1741
  title: threadLabel || kindTitle(DEFAULT_LOCALE, "user_message"),
1306
- summary: formatNotificationBody(messageText, 180) || messageText,
1307
- messageText,
1742
+ summary: formatNotificationBody(extracted.messageText, 180) || extracted.messageText,
1743
+ messageText: extracted.messageText,
1744
+ imagePaths: extracted.imagePaths,
1308
1745
  createdAtMs,
1309
1746
  readOnly: true,
1310
1747
  });
@@ -1316,7 +1753,7 @@ function buildHistoryUserTimelineEntry({ record, runtime, config }) {
1316
1753
  }
1317
1754
 
1318
1755
  const threadId = cleanText(record.session_id || record.sessionId || "");
1319
- const messageText = normalizeLongText(record.text ?? "");
1756
+ const messageText = normalizeTimelineMessageText(record.text ?? "");
1320
1757
  if (!threadId || !messageText) {
1321
1758
  return null;
1322
1759
  }
@@ -3303,19 +3740,142 @@ function runCurl(args) {
3303
3740
  }
3304
3741
 
3305
3742
  const IMPLEMENT_PLAN_PROMPT_PREFIX = "PLEASE IMPLEMENT THIS PLAN:";
3743
+ const COMPLETION_REPLY_WORKSPACE_STAGE_DIR = ".viveworker-attachments";
3744
+ const COMPLETION_REPLY_WORKSPACE_STAGE_TTL_MS = 60 * 60 * 1000;
3745
+ const COMPLETION_REPLY_WORKSPACE_STAGE_CLEANUP_DELAY_MS = 10 * 60 * 1000;
3306
3746
 
3307
3747
  function buildImplementPlanPrompt(planContent) {
3308
3748
  return `${IMPLEMENT_PLAN_PROMPT_PREFIX}\n${formatPlanDetailText(planContent)}`;
3309
3749
  }
3310
3750
 
3311
- function buildTextInput(text) {
3312
- return [
3313
- {
3751
+ function buildTurnInput(text, options = {}) {
3752
+ const items = [];
3753
+ const normalizedText = String(text ?? "");
3754
+ const localImagePaths = Array.isArray(options?.localImagePaths)
3755
+ ? options.localImagePaths
3756
+ .map((value) => cleanText(value || ""))
3757
+ .filter(Boolean)
3758
+ : [];
3759
+
3760
+ if (normalizedText) {
3761
+ items.push({
3314
3762
  type: "text",
3315
- text: String(text ?? ""),
3763
+ text: normalizedText,
3316
3764
  text_elements: [],
3317
- },
3318
- ];
3765
+ });
3766
+ }
3767
+
3768
+ for (const localImagePath of localImagePaths) {
3769
+ items.push({
3770
+ type: "local_image",
3771
+ path: localImagePath,
3772
+ });
3773
+ }
3774
+
3775
+ return items;
3776
+ }
3777
+
3778
+ function buildComposerStyleLocalImageInput(text, localImagePaths = []) {
3779
+ const items = [];
3780
+ const normalizedText = String(text ?? "");
3781
+
3782
+ if (normalizedText) {
3783
+ items.push({
3784
+ type: "text",
3785
+ text: normalizedText,
3786
+ text_elements: [],
3787
+ });
3788
+ }
3789
+
3790
+ for (const localImagePath of localImagePaths) {
3791
+ const normalizedPath = cleanText(localImagePath || "");
3792
+ if (!normalizedPath) {
3793
+ continue;
3794
+ }
3795
+ items.push({
3796
+ type: "localImage",
3797
+ path: normalizedPath,
3798
+ });
3799
+ }
3800
+
3801
+ return items;
3802
+ }
3803
+
3804
+ function buildComposerStyleImageInput(text, imageDataUrls = []) {
3805
+ const items = [];
3806
+ const normalizedText = String(text ?? "");
3807
+
3808
+ if (normalizedText) {
3809
+ items.push({
3810
+ type: "text",
3811
+ text: normalizedText,
3812
+ text_elements: [],
3813
+ });
3814
+ }
3815
+
3816
+ for (const imageUrl of imageDataUrls) {
3817
+ const normalizedUrl = cleanText(imageUrl || "");
3818
+ if (!normalizedUrl) {
3819
+ continue;
3820
+ }
3821
+ items.push({
3822
+ type: "image",
3823
+ url: normalizedUrl,
3824
+ });
3825
+ }
3826
+
3827
+ return items;
3828
+ }
3829
+
3830
+ function buildUserInputPayload(items, finalOutputJsonSchema = null) {
3831
+ return {
3832
+ items: Array.isArray(items) ? items : [],
3833
+ final_output_json_schema: finalOutputJsonSchema ?? null,
3834
+ };
3835
+ }
3836
+
3837
+ function buildTurnContentItems(text, imageDataUrls = []) {
3838
+ const items = [];
3839
+ const normalizedText = String(text ?? "");
3840
+ if (normalizedText) {
3841
+ items.push({
3842
+ type: "input_text",
3843
+ text: normalizedText,
3844
+ });
3845
+ }
3846
+ for (const imageUrl of imageDataUrls) {
3847
+ if (!imageUrl) {
3848
+ continue;
3849
+ }
3850
+ items.push({
3851
+ type: "input_image",
3852
+ image_url: imageUrl,
3853
+ detail: "original",
3854
+ });
3855
+ }
3856
+ return items;
3857
+ }
3858
+
3859
+ function buildTurnImageItems(text, imageDataUrls = []) {
3860
+ const items = [];
3861
+ const normalizedText = String(text ?? "");
3862
+ if (normalizedText) {
3863
+ items.push({
3864
+ type: "text",
3865
+ text: normalizedText,
3866
+ text_elements: [],
3867
+ });
3868
+ }
3869
+ for (const imageUrl of imageDataUrls) {
3870
+ if (!imageUrl) {
3871
+ continue;
3872
+ }
3873
+ items.push({
3874
+ type: "image",
3875
+ image_url: imageUrl,
3876
+ });
3877
+ }
3878
+ return items;
3319
3879
  }
3320
3880
 
3321
3881
  function buildRequestedCollaborationMode(threadState, requestedMode = "default") {
@@ -3338,6 +3898,49 @@ function buildRequestedCollaborationMode(threadState, requestedMode = "default")
3338
3898
  };
3339
3899
  }
3340
3900
 
3901
+ function normalizeIpcErrorMessage(errorValue) {
3902
+ if (typeof errorValue === "string") {
3903
+ return cleanText(errorValue || "") || "ipc-request-failed";
3904
+ }
3905
+ if (errorValue instanceof Error) {
3906
+ return cleanText(errorValue.message || "") || errorValue.name || "ipc-request-failed";
3907
+ }
3908
+ if (Array.isArray(errorValue)) {
3909
+ try {
3910
+ return JSON.stringify(errorValue);
3911
+ } catch {
3912
+ return "ipc-request-failed";
3913
+ }
3914
+ }
3915
+ if (isPlainObject(errorValue)) {
3916
+ const candidateFields = [
3917
+ errorValue.message,
3918
+ errorValue.error,
3919
+ errorValue.details,
3920
+ errorValue.reason,
3921
+ ];
3922
+ const directMessage = candidateFields
3923
+ .map((value) => (typeof value === "string" ? cleanText(value || "") : ""))
3924
+ .find(Boolean);
3925
+ if (directMessage) {
3926
+ return directMessage;
3927
+ }
3928
+ try {
3929
+ return JSON.stringify(errorValue);
3930
+ } catch {
3931
+ return "ipc-request-failed";
3932
+ }
3933
+ }
3934
+ if (errorValue && typeof errorValue === "object") {
3935
+ try {
3936
+ return JSON.stringify(errorValue);
3937
+ } catch {
3938
+ return "ipc-request-failed";
3939
+ }
3940
+ }
3941
+ return cleanText(String(errorValue ?? "")) || "ipc-request-failed";
3942
+ }
3943
+
3341
3944
  function buildDefaultCollaborationMode(threadState) {
3342
3945
  // Fallback turns must leave Plan mode unless the caller explicitly opts in.
3343
3946
  return buildRequestedCollaborationMode(threadState, "default");
@@ -3417,6 +4020,18 @@ class NativeIpcClient {
3417
4020
  );
3418
4021
  }
3419
4022
 
4023
+ async startTurnDirect(conversationId, turnStartParams, ownerClientId = null) {
4024
+ const targetClientId =
4025
+ ownerClientId ??
4026
+ this.runtime.threadOwnerClientIds.get(conversationId) ??
4027
+ null;
4028
+ return this.sendRequest(
4029
+ "turn/start",
4030
+ buildDirectTurnStartPayload(conversationId, turnStartParams),
4031
+ { targetClientId }
4032
+ );
4033
+ }
4034
+
3420
4035
  async submitUserInputRequest(conversationId, requestId, answers, ownerClientId = null) {
3421
4036
  return this.sendThreadFollowerRequest(
3422
4037
  "thread-follower-submit-user-input-request",
@@ -3573,7 +4188,14 @@ class NativeIpcClient {
3573
4188
  clearTimeout(pending.timeout);
3574
4189
 
3575
4190
  if (message.resultType === "error") {
3576
- pending.reject(new Error(message.error || "ipc-request-failed"));
4191
+ console.log(
4192
+ `[ipc] error method=${cleanText(message.method || "") || "unknown"} requestId=${cleanText(message.requestId || "") || "unknown"} payload=${inspect(message.error, { depth: 6, breakLength: 160 })}`
4193
+ );
4194
+ const error = new Error(normalizeIpcErrorMessage(message.error));
4195
+ if (message.error && typeof message.error === "object") {
4196
+ error.ipcError = message.error;
4197
+ }
4198
+ pending.reject(error);
3577
4199
  return;
3578
4200
  }
3579
4201
 
@@ -3960,6 +4582,64 @@ function getNativeThreadLabel({ runtime, conversationId, cwd }) {
3960
4582
  return shortId(normalizedConversationId) || "Codex task";
3961
4583
  }
3962
4584
 
4585
+ async function findRolloutThreadCwd(runtime, conversationId) {
4586
+ const normalizedConversationId = cleanText(conversationId || "");
4587
+ if (!normalizedConversationId) {
4588
+ return "";
4589
+ }
4590
+
4591
+ const cachedCwd = resolvePath(cleanText(runtime.rolloutThreadCwds?.get(normalizedConversationId) || ""));
4592
+ if (cachedCwd) {
4593
+ return cachedCwd;
4594
+ }
4595
+
4596
+ const knownFiles = Array.isArray(runtime.knownFiles) ? runtime.knownFiles : [];
4597
+ if (!knownFiles.length) {
4598
+ return "";
4599
+ }
4600
+
4601
+ const prioritizedFiles = [];
4602
+ const fallbackFiles = [];
4603
+ for (const filePath of knownFiles) {
4604
+ if (extractThreadIdFromRolloutPath(filePath) === normalizedConversationId) {
4605
+ prioritizedFiles.push(filePath);
4606
+ } else {
4607
+ fallbackFiles.push(filePath);
4608
+ }
4609
+ }
4610
+
4611
+ const filesToInspect = prioritizedFiles.length ? [...prioritizedFiles, ...fallbackFiles] : fallbackFiles;
4612
+ for (const filePath of filesToInspect) {
4613
+ const metadata = await extractRolloutThreadMetadata(filePath);
4614
+ if (cleanText(metadata?.threadId || "") !== normalizedConversationId) {
4615
+ continue;
4616
+ }
4617
+ const resolvedCwd = resolvePath(cleanText(metadata?.cwd || ""));
4618
+ if (resolvedCwd) {
4619
+ runtime.rolloutThreadCwds.set(normalizedConversationId, resolvedCwd);
4620
+ return resolvedCwd;
4621
+ }
4622
+ }
4623
+
4624
+ return "";
4625
+ }
4626
+
4627
+ async function resolveConversationCwd(runtime, conversationId) {
4628
+ const normalizedConversationId = cleanText(conversationId || "");
4629
+ if (!normalizedConversationId) {
4630
+ return "";
4631
+ }
4632
+
4633
+ const threadStateCwd = resolvePath(
4634
+ cleanText(runtime.threadStates.get(normalizedConversationId)?.cwd || "")
4635
+ );
4636
+ if (threadStateCwd) {
4637
+ return threadStateCwd;
4638
+ }
4639
+
4640
+ return await findRolloutThreadCwd(runtime, normalizedConversationId);
4641
+ }
4642
+
3963
4643
  function formatNativeApprovalMessage(kind, params, locale = config?.defaultLocale || DEFAULT_LOCALE) {
3964
4644
  if (kind === "command") {
3965
4645
  return formatCommandApprovalMessage(params, locale);
@@ -4409,6 +5089,7 @@ function readSession(req, config, state) {
4409
5089
  pairedAtMs: Number(payload.pairedAtMs) || 0,
4410
5090
  expiresAtMs: Number(payload.expiresAtMs) || 0,
4411
5091
  deviceId,
5092
+ temporaryPairing: payload?.temporaryPairing === true,
4412
5093
  };
4413
5094
  }
4414
5095
 
@@ -4466,6 +5147,22 @@ function setSessionCookie(res, config) {
4466
5147
  }));
4467
5148
  }
4468
5149
 
5150
+ function setTemporarySessionCookie(res, config) {
5151
+ const secure = config.nativeApprovalPublicBaseUrl.startsWith("https://");
5152
+ const now = Date.now();
5153
+ const token = signSessionPayload({
5154
+ sessionId: crypto.randomUUID(),
5155
+ pairedAtMs: now,
5156
+ expiresAtMs: now + config.sessionTtlMs,
5157
+ temporaryPairing: true,
5158
+ }, config.sessionSecret);
5159
+ res.setHeader("Set-Cookie", buildSetCookieHeader({
5160
+ value: token,
5161
+ maxAgeSecs: Math.max(1, Math.floor(config.sessionTtlMs / 1000)),
5162
+ secure,
5163
+ }));
5164
+ }
5165
+
4469
5166
  function clearSessionCookie(res, config) {
4470
5167
  const secure = config.nativeApprovalPublicBaseUrl.startsWith("https://");
4471
5168
  res.setHeader("Set-Cookie", buildSetCookieHeader({ value: "", maxAgeSecs: 0, secure }));
@@ -4623,15 +5320,27 @@ function pairingCredentialConsumed(config, state) {
4623
5320
  }
4624
5321
 
4625
5322
  function isPairingAvailableForState(config, state) {
4626
- return isPairingAvailable(config) && !pairingCredentialConsumed(config, state);
5323
+ return isPairingAvailable(config) && !pairingCodeConsumed(config, state);
5324
+ }
5325
+
5326
+ function pairingCodeConsumed(config, state) {
5327
+ const code = cleanText(config?.pairingCode ?? "").toUpperCase();
5328
+ if (!code) {
5329
+ return false;
5330
+ }
5331
+ const consumedAtMs = Number(state?.pairingConsumedAt) || 0;
5332
+ const consumedCredential = cleanText(state?.pairingConsumedCredential ?? "");
5333
+ return consumedAtMs > 0 && consumedCredential === `code:${code}`;
4627
5334
  }
4628
5335
 
4629
- function markPairingConsumed(state, config, now = Date.now()) {
4630
- const current = currentPairingCredential(config);
5336
+ function markPairingConsumed(state, credential, now = Date.now()) {
5337
+ const current = cleanText(credential || "");
4631
5338
  if (!current) {
4632
5339
  return false;
4633
5340
  }
4634
- if (pairingCredentialConsumed(config, state)) {
5341
+ const consumedAtMs = Number(state?.pairingConsumedAt) || 0;
5342
+ const consumedCredential = cleanText(state?.pairingConsumedCredential ?? "");
5343
+ if (consumedAtMs > 0 && consumedCredential === current) {
4635
5344
  return false;
4636
5345
  }
4637
5346
  state.pairingConsumedAt = now;
@@ -4643,7 +5352,7 @@ function validatePairingPayload(payload, config, state) {
4643
5352
  if (!config.authRequired) {
4644
5353
  return { ok: true };
4645
5354
  }
4646
- if (!isPairingAvailableForState(config, state)) {
5355
+ if (!isPairingAvailable(config)) {
4647
5356
  return { ok: false, error: "pairing-unavailable" };
4648
5357
  }
4649
5358
 
@@ -4651,10 +5360,16 @@ function validatePairingPayload(payload, config, state) {
4651
5360
  const token = cleanText(payload?.token ?? "");
4652
5361
  const matchesCode = code && cleanText(config.pairingCode).toUpperCase() === code;
4653
5362
  const matchesToken = token && cleanText(config.pairingToken) === token;
4654
- if (!matchesCode && !matchesToken) {
4655
- return { ok: false, error: "invalid-pairing-credentials" };
5363
+ if (matchesToken) {
5364
+ return { ok: true, credential: `token:${token}` };
5365
+ }
5366
+ if (matchesCode) {
5367
+ if (pairingCodeConsumed(config, state)) {
5368
+ return { ok: false, error: "pairing-unavailable" };
5369
+ }
5370
+ return { ok: true, credential: `code:${code}` };
4656
5371
  }
4657
- return { ok: true };
5372
+ return { ok: false, error: "invalid-pairing-credentials" };
4658
5373
  }
4659
5374
 
4660
5375
  function readRemoteAddress(req) {
@@ -5084,6 +5799,28 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
5084
5799
  return items.filter(Boolean);
5085
5800
  }
5086
5801
 
5802
+ function sanitizeTimelineThreadFilterLabel(value, threadId = "") {
5803
+ const normalized = cleanText(value || "");
5804
+ if (!normalized) {
5805
+ return "";
5806
+ }
5807
+
5808
+ const normalizedThreadId = cleanText(threadId || "");
5809
+ if (normalizedThreadId && (normalized === normalizedThreadId || normalized === shortId(normalizedThreadId))) {
5810
+ return "";
5811
+ }
5812
+
5813
+ if (/^[0-9a-f]{8}(?:-[0-9a-f]{4}){0,4}$/iu.test(normalized)) {
5814
+ return "";
5815
+ }
5816
+
5817
+ if (looksLikeGeneratedThreadTitle(normalized)) {
5818
+ return "";
5819
+ }
5820
+
5821
+ return normalized;
5822
+ }
5823
+
5087
5824
  function buildTimelineThreads(entries, config) {
5088
5825
  const byThread = new Map();
5089
5826
  for (const entry of entries) {
@@ -5091,11 +5828,14 @@ function buildTimelineThreads(entries, config) {
5091
5828
  if (!threadId) {
5092
5829
  continue;
5093
5830
  }
5831
+ const preferredLabel =
5832
+ sanitizeTimelineThreadFilterLabel(entry.threadLabel || "", threadId) ||
5833
+ t(DEFAULT_LOCALE, "server.fallback.codexTask");
5094
5834
  const existing = byThread.get(threadId);
5095
5835
  if (!existing) {
5096
5836
  byThread.set(threadId, {
5097
5837
  id: threadId,
5098
- label: cleanText(entry.threadLabel || entry.title || "") || t(DEFAULT_LOCALE, "server.fallback.codexTask"),
5838
+ label: preferredLabel,
5099
5839
  latestAtMs: Number(entry.createdAtMs) || 0,
5100
5840
  preview: cleanText(entry.summary || entry.title || ""),
5101
5841
  entryCount: 1,
@@ -5105,7 +5845,7 @@ function buildTimelineThreads(entries, config) {
5105
5845
  existing.entryCount += 1;
5106
5846
  if (Number(entry.createdAtMs) > Number(existing.latestAtMs)) {
5107
5847
  existing.latestAtMs = Number(entry.createdAtMs) || existing.latestAtMs;
5108
- existing.label = cleanText(entry.threadLabel || entry.title || "") || existing.label;
5848
+ existing.label = preferredLabel || existing.label;
5109
5849
  existing.preview = cleanText(entry.summary || entry.title || "") || existing.preview;
5110
5850
  }
5111
5851
  }
@@ -5131,6 +5871,7 @@ function buildTimelineResponse(runtime, state, config, locale) {
5131
5871
  threadId: entry.threadId,
5132
5872
  threadLabel: entry.threadLabel,
5133
5873
  summary: entry.summary,
5874
+ imageUrls: buildTimelineEntryImageUrls(entry),
5134
5875
  createdAtMs: entry.createdAtMs,
5135
5876
  }));
5136
5877
 
@@ -5193,6 +5934,49 @@ function buildPreviousApprovalContext(runtime, approval) {
5193
5934
  };
5194
5935
  }
5195
5936
 
5937
+ function buildInterruptedTimelineContext(runtime, entry, locale) {
5938
+ if (!runtime || !isTurnAbortedDisplayMessage(entry?.messageText)) {
5939
+ return null;
5940
+ }
5941
+
5942
+ const threadId = cleanText(entry?.threadId || "");
5943
+ const interruptedCreatedAtMs = Number(entry?.createdAtMs) || 0;
5944
+ if (!threadId || !interruptedCreatedAtMs) {
5945
+ return null;
5946
+ }
5947
+
5948
+ const previousEntry = runtime.recentTimelineEntries
5949
+ .filter((candidate) => {
5950
+ if (!timelineMessageKinds.has(cleanText(candidate?.kind || ""))) {
5951
+ return false;
5952
+ }
5953
+ if (cleanText(candidate?.threadId || "") !== threadId) {
5954
+ return false;
5955
+ }
5956
+ if (Number(candidate?.createdAtMs) <= 0 || Number(candidate?.createdAtMs) >= interruptedCreatedAtMs) {
5957
+ return false;
5958
+ }
5959
+ return !isTurnAbortedDisplayMessage(candidate?.messageText);
5960
+ })
5961
+ .sort((left, right) => Number(right?.createdAtMs ?? 0) - Number(left?.createdAtMs ?? 0))[0];
5962
+
5963
+ if (!previousEntry) {
5964
+ return null;
5965
+ }
5966
+
5967
+ const sourceText = normalizeLongText(previousEntry.messageText || previousEntry.summary || "");
5968
+ if (!sourceText) {
5969
+ return null;
5970
+ }
5971
+
5972
+ return {
5973
+ kind: previousEntry.kind,
5974
+ label: t(locale, "detail.interruptedTask"),
5975
+ createdAtMs: Number(previousEntry.createdAtMs) || 0,
5976
+ messageHtml: renderMessageHtml(sourceText, "<p></p>"),
5977
+ };
5978
+ }
5979
+
5196
5980
  function buildPendingPlanDetail(planRequest, locale) {
5197
5981
  return {
5198
5982
  kind: "plan",
@@ -5336,19 +6120,32 @@ function buildHistoryDetail(item, locale, runtime = null) {
5336
6120
  threadLabel: item.threadLabel || "",
5337
6121
  createdAtMs: Number(item.createdAtMs) || 0,
5338
6122
  messageHtml: renderMessageHtml(item.messageText, `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
6123
+ interruptNotice: interruptedDetailNotice(item.messageText, locale),
5339
6124
  readOnly: true,
5340
6125
  reply: replyEnabled
5341
6126
  ? {
5342
6127
  enabled: true,
5343
6128
  supportsPlanMode: true,
5344
- supportsImages: false,
6129
+ supportsImages: true,
5345
6130
  }
5346
6131
  : null,
5347
6132
  actions: [],
5348
6133
  };
5349
6134
  }
5350
6135
 
5351
- function buildTimelineMessageDetail(entry, locale) {
6136
+ function buildTimelineEntryImageUrls(entry) {
6137
+ const imagePaths = normalizeTimelineImagePaths(entry?.imagePaths ?? []);
6138
+ if (imagePaths.length === 0) {
6139
+ return [];
6140
+ }
6141
+ const token = cleanText(entry?.token || "");
6142
+ if (!token) {
6143
+ return [];
6144
+ }
6145
+ return imagePaths.map((_, index) => `/api/timeline/${encodeURIComponent(token)}/images/${index}`);
6146
+ }
6147
+
6148
+ function buildTimelineMessageDetail(entry, locale, runtime = null) {
5352
6149
  return {
5353
6150
  kind: entry.kind,
5354
6151
  token: entry.token,
@@ -5357,6 +6154,9 @@ function buildTimelineMessageDetail(entry, locale) {
5357
6154
  threadLabel: entry.threadLabel || "",
5358
6155
  createdAtMs: Number(entry.createdAtMs) || 0,
5359
6156
  messageHtml: renderMessageHtml(entry.messageText, `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
6157
+ imageUrls: buildTimelineEntryImageUrls(entry),
6158
+ previousContext: buildInterruptedTimelineContext(runtime, entry, locale),
6159
+ interruptNotice: interruptedDetailNotice(entry.messageText, locale),
5360
6160
  readOnly: true,
5361
6161
  actions: [],
5362
6162
  };
@@ -5449,8 +6249,224 @@ function normalizeCompletionReplyLocalImagePaths(paths) {
5449
6249
  .filter(Boolean);
5450
6250
  }
5451
6251
 
6252
+ function guessImageMimeTypeFromPath(filePath) {
6253
+ const extension = path.extname(cleanText(filePath || "")).toLowerCase();
6254
+ const mimeTypes = {
6255
+ ".jpg": "image/jpeg",
6256
+ ".jpeg": "image/jpeg",
6257
+ ".png": "image/png",
6258
+ ".webp": "image/webp",
6259
+ ".gif": "image/gif",
6260
+ ".heic": "image/heic",
6261
+ ".heif": "image/heif",
6262
+ };
6263
+ return mimeTypes[extension] || "application/octet-stream";
6264
+ }
6265
+
6266
+ async function buildCompletionReplyImageDataUrls(localImagePaths) {
6267
+ const urls = [];
6268
+ for (const filePath of localImagePaths) {
6269
+ const buffer = await fs.readFile(filePath);
6270
+ const mimeType = guessImageMimeTypeFromPath(filePath);
6271
+ urls.push(`data:${mimeType};base64,${buffer.toString("base64")}`);
6272
+ }
6273
+ return urls;
6274
+ }
6275
+
6276
+ function scheduleBestEffortFileCleanup(paths, delayMs = COMPLETION_REPLY_WORKSPACE_STAGE_CLEANUP_DELAY_MS) {
6277
+ if (!Array.isArray(paths) || paths.length === 0) {
6278
+ return;
6279
+ }
6280
+
6281
+ const timer = setTimeout(async () => {
6282
+ await Promise.all(
6283
+ paths.map(async (filePath) => {
6284
+ try {
6285
+ await fs.rm(filePath, { force: true });
6286
+ } catch {
6287
+ // Ignore best-effort cleanup errors.
6288
+ }
6289
+ })
6290
+ );
6291
+ }, delayMs);
6292
+ timer.unref?.();
6293
+ }
6294
+
6295
+ function buildDirectTurnStartPayload(conversationId, turnStartParams = {}) {
6296
+ return {
6297
+ threadId: cleanText(conversationId || ""),
6298
+ input: Array.isArray(turnStartParams.input) ? turnStartParams.input : [],
6299
+ cwd: cleanText(turnStartParams.cwd || "") || null,
6300
+ approvalPolicy: turnStartParams.approvalPolicy ?? null,
6301
+ approvalsReviewer: cleanText(turnStartParams.approvalsReviewer || "") || "user",
6302
+ sandboxPolicy: turnStartParams.sandboxPolicy ?? null,
6303
+ model: turnStartParams.model ?? null,
6304
+ serviceTier: turnStartParams.serviceTier ?? null,
6305
+ effort: turnStartParams.effort ?? null,
6306
+ summary: cleanText(turnStartParams.summary || "") || "none",
6307
+ personality: turnStartParams.personality ?? null,
6308
+ outputSchema: turnStartParams.outputSchema ?? null,
6309
+ collaborationMode: isPlainObject(turnStartParams.collaborationMode)
6310
+ ? turnStartParams.collaborationMode
6311
+ : null,
6312
+ attachments: Array.isArray(turnStartParams.attachments) ? turnStartParams.attachments : [],
6313
+ };
6314
+ }
6315
+
6316
+ async function cleanupExpiredWorkspaceReplyImages(stageDir) {
6317
+ try {
6318
+ const entries = await fs.readdir(stageDir, { withFileTypes: true });
6319
+ const cutoffMs = Date.now() - COMPLETION_REPLY_WORKSPACE_STAGE_TTL_MS;
6320
+ await Promise.all(
6321
+ entries.map(async (entry) => {
6322
+ if (!entry.isFile()) {
6323
+ return;
6324
+ }
6325
+ const filePath = path.join(stageDir, entry.name);
6326
+ try {
6327
+ const stat = await fs.stat(filePath);
6328
+ if (Number(stat.mtimeMs) < cutoffMs) {
6329
+ await fs.rm(filePath, { force: true });
6330
+ }
6331
+ } catch {
6332
+ // Ignore best-effort cleanup errors.
6333
+ }
6334
+ })
6335
+ );
6336
+ } catch {
6337
+ // Ignore missing stage dir.
6338
+ }
6339
+ }
6340
+
6341
+ async function stageCompletionReplyImagesForThreadCwd(localImagePaths, cwd) {
6342
+ const normalizedCwd = resolvePath(cleanText(cwd || ""));
6343
+ if (!normalizedCwd || !Array.isArray(localImagePaths) || localImagePaths.length === 0) {
6344
+ return [];
6345
+ }
6346
+
6347
+ const stageDir = path.join(normalizedCwd, COMPLETION_REPLY_WORKSPACE_STAGE_DIR);
6348
+ await cleanupExpiredWorkspaceReplyImages(stageDir);
6349
+ await fs.mkdir(stageDir, { recursive: true });
6350
+
6351
+ const stagedPaths = [];
6352
+ for (const sourcePath of localImagePaths) {
6353
+ const extension = path.extname(cleanText(sourcePath || "")) || ".img";
6354
+ const stagedPath = path.join(stageDir, `${Date.now()}-${crypto.randomUUID()}${extension}`);
6355
+ await fs.copyFile(sourcePath, stagedPath);
6356
+ stagedPaths.push(stagedPath);
6357
+ }
6358
+ return stagedPaths;
6359
+ }
6360
+
6361
+ async function buildCompletionReplyTurnCandidates(
6362
+ messageText,
6363
+ localImagePaths,
6364
+ collaborationMode,
6365
+ cwd = null,
6366
+ workspaceLocalImagePaths = []
6367
+ ) {
6368
+ const baseCandidate = {
6369
+ attachments: [],
6370
+ cwd: cleanText(cwd || "") || null,
6371
+ approvalPolicy: null,
6372
+ sandboxPolicy: null,
6373
+ model: null,
6374
+ serviceTier: null,
6375
+ effort: null,
6376
+ summary: "none",
6377
+ personality: null,
6378
+ outputSchema: null,
6379
+ collaborationMode,
6380
+ };
6381
+
6382
+ if (!localImagePaths.length) {
6383
+ return [
6384
+ {
6385
+ name: "text-only",
6386
+ transport: "thread-follower",
6387
+ turnStartParams: {
6388
+ ...baseCandidate,
6389
+ input: buildTurnInput(messageText),
6390
+ localImagePaths: [],
6391
+ local_image_paths: [],
6392
+ remoteImageUrls: [],
6393
+ remote_image_urls: [],
6394
+ },
6395
+ },
6396
+ ];
6397
+ }
6398
+
6399
+ const imageDataUrls = await buildCompletionReplyImageDataUrls(localImagePaths);
6400
+ const workspaceImagePaths = normalizeCompletionReplyLocalImagePaths(workspaceLocalImagePaths);
6401
+ const candidates = [];
6402
+
6403
+ if (workspaceImagePaths.length) {
6404
+ candidates.push({
6405
+ // Match the Desktop composer path as closely as possible:
6406
+ // text + localImage(path) passed through the normal thread-follower route.
6407
+ name: "workspace-local-image-composer-input",
6408
+ transport: "thread-follower",
6409
+ turnStartParams: {
6410
+ ...baseCandidate,
6411
+ input: buildComposerStyleLocalImageInput(messageText, workspaceImagePaths),
6412
+ localImagePaths: [],
6413
+ local_image_paths: [],
6414
+ remoteImageUrls: [],
6415
+ remote_image_urls: [],
6416
+ },
6417
+ });
6418
+ }
6419
+
6420
+ candidates.push(
6421
+ {
6422
+ // This mirrors the desktop composer input items before submission:
6423
+ // text + image(url=data:image/...).
6424
+ name: "image-data-url-composer-input",
6425
+ transport: "thread-follower",
6426
+ turnStartParams: {
6427
+ ...baseCandidate,
6428
+ input: buildComposerStyleImageInput(messageText, imageDataUrls),
6429
+ localImagePaths: [],
6430
+ local_image_paths: [],
6431
+ remoteImageUrls: [],
6432
+ remote_image_urls: [],
6433
+ },
6434
+ },
6435
+ {
6436
+ name: "local-image-composer-input",
6437
+ transport: "thread-follower",
6438
+ turnStartParams: {
6439
+ ...baseCandidate,
6440
+ input: buildComposerStyleLocalImageInput(messageText, localImagePaths),
6441
+ localImagePaths: [],
6442
+ local_image_paths: [],
6443
+ remoteImageUrls: [],
6444
+ remote_image_urls: [],
6445
+ },
6446
+ },
6447
+ {
6448
+ // This currently reaches Codex, but the image is dropped before the final
6449
+ // UserInput core submission. Keep it last as a diagnostic fallback.
6450
+ name: "remote-image-urls-data-url",
6451
+ transport: "thread-follower",
6452
+ turnStartParams: {
6453
+ ...baseCandidate,
6454
+ input: buildTurnInput(messageText),
6455
+ localImagePaths: [],
6456
+ local_image_paths: [],
6457
+ remoteImageUrls: imageDataUrls,
6458
+ remote_image_urls: imageDataUrls,
6459
+ },
6460
+ }
6461
+ );
6462
+
6463
+ return candidates;
6464
+ }
6465
+
5452
6466
  async function handleCompletionReply({
6467
+ config,
5453
6468
  runtime,
6469
+ state,
5454
6470
  completionItem,
5455
6471
  text,
5456
6472
  planMode = false,
@@ -5485,34 +6501,97 @@ async function handleCompletionReply({
5485
6501
  }
5486
6502
 
5487
6503
  const threadState = runtime.threadStates.get(conversationId) ?? null;
6504
+ const resolvedCwd = await resolveConversationCwd(runtime, conversationId);
6505
+ const stagedWorkspaceImagePaths = await stageCompletionReplyImagesForThreadCwd(
6506
+ normalizedLocalImagePaths,
6507
+ resolvedCwd
6508
+ );
6509
+ const timelineImageAliases = [];
6510
+ if (normalizedLocalImagePaths.length > 0) {
6511
+ const persistentTimelineImagePaths = await normalizePersistedTimelineImagePaths({
6512
+ config,
6513
+ state,
6514
+ imagePaths: normalizedLocalImagePaths,
6515
+ });
6516
+ for (let index = 0; index < persistentTimelineImagePaths.length; index += 1) {
6517
+ const persistentPath = cleanText(persistentTimelineImagePaths[index] || "");
6518
+ if (!persistentPath) {
6519
+ continue;
6520
+ }
6521
+ const uploadPath = cleanText(normalizedLocalImagePaths[index] || "");
6522
+ const stagedPath = cleanText(stagedWorkspaceImagePaths[index] || "");
6523
+ if (uploadPath) {
6524
+ timelineImageAliases.push([uploadPath, persistentPath]);
6525
+ }
6526
+ if (stagedPath) {
6527
+ timelineImageAliases.push([stagedPath, persistentPath]);
6528
+ }
6529
+ }
6530
+ }
5488
6531
  const collaborationMode = buildRequestedCollaborationMode(
5489
6532
  threadState,
5490
6533
  planMode ? "plan" : "default"
5491
6534
  );
5492
- const turnStartParams = {
5493
- input: buildTextInput(messageText),
5494
- attachments: [],
5495
- localImagePaths: normalizedLocalImagePaths,
5496
- local_image_paths: normalizedLocalImagePaths,
5497
- remoteImageUrls: [],
5498
- remote_image_urls: [],
5499
- cwd: null,
5500
- approvalPolicy: null,
5501
- sandboxPolicy: null,
5502
- model: null,
5503
- serviceTier: null,
5504
- effort: null,
5505
- summary: "none",
5506
- personality: null,
5507
- outputSchema: null,
6535
+ const turnCandidates = await buildCompletionReplyTurnCandidates(
6536
+ messageText,
6537
+ normalizedLocalImagePaths,
5508
6538
  collaborationMode,
5509
- };
6539
+ resolvedCwd,
6540
+ stagedWorkspaceImagePaths
6541
+ );
6542
+ let lastError = null;
6543
+ const ownerClientId = runtime.threadOwnerClientIds.get(conversationId) ?? null;
5510
6544
 
5511
- await runtime.ipcClient.startTurn(
5512
- conversationId,
5513
- turnStartParams,
5514
- runtime.threadOwnerClientIds.get(conversationId) ?? null
6545
+ for (const candidate of turnCandidates) {
6546
+ try {
6547
+ console.log(
6548
+ `[completion-reply] try candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")} owner=${cleanText(ownerClientId || "") || "none"} images=${normalizedLocalImagePaths.length} workspaceImages=${stagedWorkspaceImagePaths.length} cwd=${cleanText(resolvedCwd || "") || "none"}`
6549
+ );
6550
+ if (candidate.transport === "direct-turn-start" && ownerClientId) {
6551
+ await runtime.ipcClient.startTurnDirect(
6552
+ conversationId,
6553
+ candidate.turnStartParams,
6554
+ ownerClientId
6555
+ );
6556
+ } else {
6557
+ await runtime.ipcClient.startTurn(
6558
+ conversationId,
6559
+ candidate.turnStartParams,
6560
+ ownerClientId
6561
+ );
6562
+ }
6563
+ console.log(
6564
+ `[completion-reply] success candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")}`
6565
+ );
6566
+ if (timelineImageAliases.length > 0) {
6567
+ const aliases = isPlainObject(state.timelineImagePathAliases)
6568
+ ? state.timelineImagePathAliases
6569
+ : (state.timelineImagePathAliases = {});
6570
+ for (const [sourcePath, persistentPath] of timelineImageAliases) {
6571
+ aliases[sourcePath] = persistentPath;
6572
+ }
6573
+ await saveState(config.stateFile, state);
6574
+ }
6575
+ scheduleBestEffortFileCleanup(stagedWorkspaceImagePaths);
6576
+ return;
6577
+ } catch (error) {
6578
+ lastError = error;
6579
+ console.log(
6580
+ `[completion-reply] failed candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")} error=${normalizeIpcErrorMessage(error)} raw=${inspect(error?.ipcError ?? error, { depth: 6, breakLength: 160 })}`
6581
+ );
6582
+ }
6583
+ }
6584
+
6585
+ await Promise.all(
6586
+ stagedWorkspaceImagePaths.map(async (filePath) => {
6587
+ try {
6588
+ await fs.rm(filePath, { force: true });
6589
+ } catch {
6590
+ // Ignore best-effort cleanup errors.
6591
+ }
6592
+ })
5515
6593
  );
6594
+ throw lastError || new Error("completion-reply-image-send-failed");
5516
6595
  }
5517
6596
 
5518
6597
  async function handlePlanDecision({ config, runtime, state, planRequest, decision }) {
@@ -5530,7 +6609,7 @@ async function handlePlanDecision({ config, runtime, state, planRequest, decisio
5530
6609
  planRequest.threadState
5531
6610
  );
5532
6611
  const turnStartParams = {
5533
- input: buildTextInput(buildImplementPlanPrompt(planRequest.rawPlanContent)),
6612
+ input: buildTurnInput(buildImplementPlanPrompt(planRequest.rawPlanContent)),
5534
6613
  attachments: [],
5535
6614
  cwd: null,
5536
6615
  approvalPolicy: null,
@@ -5613,7 +6692,7 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
5613
6692
  function buildApiItemDetail({ config, runtime, state, kind, token, locale }) {
5614
6693
  if (timelineMessageKinds.has(kind)) {
5615
6694
  const entry = timelineEntryByToken(runtime, token, kind);
5616
- return entry ? buildTimelineMessageDetail(entry, locale) : null;
6695
+ return entry ? buildTimelineMessageDetail(entry, locale, runtime) : null;
5617
6696
  }
5618
6697
  if (kind === "approval") {
5619
6698
  const approval = runtime.nativeApprovalsByToken.get(token);
@@ -5644,6 +6723,16 @@ function buildApiItemDetail({ config, runtime, state, kind, token, locale }) {
5644
6723
  return historyItem ? buildHistoryDetail(historyItem, locale, runtime) : null;
5645
6724
  }
5646
6725
 
6726
+ function resolveTimelineEntryImagePath(runtime, token, index) {
6727
+ const entry = timelineEntryByToken(runtime, token);
6728
+ if (!entry) {
6729
+ return "";
6730
+ }
6731
+ const imagePaths = normalizeTimelineImagePaths(entry.imagePaths ?? []);
6732
+ const resolvedIndex = Math.max(0, Number(index) || 0);
6733
+ return cleanText(imagePaths[resolvedIndex] || "");
6734
+ }
6735
+
5647
6736
  function resolveWebAsset(urlPath) {
5648
6737
  let relativePath = cleanText(urlPath || "");
5649
6738
  if (!relativePath || relativePath === "/") {
@@ -5676,6 +6765,17 @@ function contentTypeForFile(filePath) {
5676
6765
  return "image/svg+xml";
5677
6766
  case ".png":
5678
6767
  return "image/png";
6768
+ case ".jpg":
6769
+ case ".jpeg":
6770
+ return "image/jpeg";
6771
+ case ".webp":
6772
+ return "image/webp";
6773
+ case ".gif":
6774
+ return "image/gif";
6775
+ case ".heic":
6776
+ return "image/heic";
6777
+ case ".heif":
6778
+ return "image/heif";
5679
6779
  default:
5680
6780
  return "application/octet-stream";
5681
6781
  }
@@ -5707,7 +6807,7 @@ function resolveManifestPairingToken({ config, state, requestedToken }) {
5707
6807
  if (!token) {
5708
6808
  return "";
5709
6809
  }
5710
- if (!isPairingAvailableForState(config, state)) {
6810
+ if (!isPairingAvailable(config)) {
5711
6811
  return "";
5712
6812
  }
5713
6813
  return cleanText(config.pairingToken) === token ? token : "";
@@ -5860,6 +6960,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
5860
6960
  httpsEnabled: config.nativeApprovalPublicBaseUrl.startsWith("https://"),
5861
6961
  appVersion: appPackageVersion,
5862
6962
  deviceId: session.deviceId || null,
6963
+ temporaryPairing: session.temporaryPairing === true,
5863
6964
  ...buildSessionLocalePayload(config, state, session.deviceId),
5864
6965
  });
5865
6966
  }
@@ -5884,6 +6985,17 @@ function createNativeApprovalServer({ config, runtime, state }) {
5884
6985
  return writeJson(res, 400, { error: validation.error });
5885
6986
  }
5886
6987
 
6988
+ if (payload?.temporary === true && cleanText(payload?.token || "")) {
6989
+ clearPairingFailures(runtime, remoteAddress);
6990
+ setTemporarySessionCookie(res, config);
6991
+ return writeJson(res, 200, {
6992
+ ok: true,
6993
+ authenticated: true,
6994
+ pairingAvailable: isPairingAvailableForState(config, state),
6995
+ temporaryPairing: true,
6996
+ });
6997
+ }
6998
+
5887
6999
  const pairedDeviceId = readDeviceId(req, config) || crypto.randomUUID();
5888
7000
  if ("detectedLocale" in payload) {
5889
7001
  upsertDetectedDeviceLocale(state, pairedDeviceId, payload.detectedLocale);
@@ -5898,7 +7010,9 @@ function createNativeApprovalServer({ config, runtime, state }) {
5898
7010
  lastLocale: normalizeSupportedLocale(payload?.detectedLocale),
5899
7011
  }
5900
7012
  );
5901
- markPairingConsumed(state, config);
7013
+ if (String(validation.credential || "").startsWith("code:")) {
7014
+ markPairingConsumed(state, validation.credential);
7015
+ }
5902
7016
  clearPairingFailures(runtime, remoteAddress);
5903
7017
  await saveState(config.stateFile, state);
5904
7018
  setPairingCookies(res, config, pairedDeviceId);
@@ -6047,7 +7161,10 @@ function createNativeApprovalServer({ config, runtime, state }) {
6047
7161
  await saveState(config.stateFile, state);
6048
7162
  return writeJson(res, 410, { error: "push-subscription-expired" });
6049
7163
  }
6050
- return writeJson(res, 500, { error: error.message });
7164
+ return writeJson(res, 500, {
7165
+ error: error.message,
7166
+ ipcError: error.ipcError ?? null,
7167
+ });
6051
7168
  }
6052
7169
  }
6053
7170
 
@@ -6106,6 +7223,34 @@ function createNativeApprovalServer({ config, runtime, state }) {
6106
7223
  return writeJson(res, 200, buildTimelineResponse(runtime, state, config, locale));
6107
7224
  }
6108
7225
 
7226
+ const apiTimelineImageMatch = url.pathname.match(/^\/api\/timeline\/([^/]+)\/images\/(\d+)$/u);
7227
+ if (apiTimelineImageMatch && req.method === "GET") {
7228
+ const session = requireApiSession(req, res, config, state);
7229
+ if (!session) {
7230
+ return;
7231
+ }
7232
+ const token = decodeURIComponent(apiTimelineImageMatch[1]);
7233
+ const index = Number(apiTimelineImageMatch[2]) || 0;
7234
+ const filePath = resolveTimelineEntryImagePath(runtime, token, index);
7235
+ if (!filePath) {
7236
+ res.statusCode = 404;
7237
+ res.end("not-found");
7238
+ return;
7239
+ }
7240
+ try {
7241
+ const body = await fs.readFile(filePath);
7242
+ res.statusCode = 200;
7243
+ res.setHeader("Content-Type", contentTypeForFile(filePath));
7244
+ res.setHeader("Cache-Control", "private, max-age=300");
7245
+ res.end(body);
7246
+ return;
7247
+ } catch {
7248
+ res.statusCode = 404;
7249
+ res.end("not-found");
7250
+ return;
7251
+ }
7252
+ }
7253
+
6109
7254
  const apiItemMatch = url.pathname.match(/^\/api\/items\/([^/]+)\/([^/]+)$/u);
6110
7255
  if (apiItemMatch && req.method === "GET") {
6111
7256
  const session = requireApiSession(req, res, config, state);
@@ -6141,7 +7286,9 @@ function createNativeApprovalServer({ config, runtime, state }) {
6141
7286
  ? await stageCompletionReplyImages(config, req)
6142
7287
  : await parseJsonBody(req);
6143
7288
  await handleCompletionReply({
7289
+ config,
6144
7290
  runtime,
7291
+ state,
6145
7292
  completionItem,
6146
7293
  text: payload?.text ?? "",
6147
7294
  planMode: payload?.planMode === true,
@@ -6161,8 +7308,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
6161
7308
  error.message === "completion-reply-image-limit" ||
6162
7309
  error.message === "completion-reply-image-invalid-type" ||
6163
7310
  error.message === "completion-reply-image-too-large" ||
6164
- error.message === "completion-reply-image-invalid-upload" ||
6165
- error.message === "completion-reply-image-disabled"
7311
+ error.message === "completion-reply-image-invalid-upload"
6166
7312
  ) {
6167
7313
  return writeJson(res, 400, { error: error.message });
6168
7314
  }
@@ -6886,10 +8032,6 @@ async function stageCompletionReplyImages(config, req) {
6886
8032
  .getAll("image")
6887
8033
  .filter((value) => typeof File !== "undefined" && value instanceof File);
6888
8034
 
6889
- if (files.length > 0) {
6890
- throw new Error("completion-reply-image-disabled");
6891
- }
6892
-
6893
8035
  if (files.length > MAX_COMPLETION_REPLY_IMAGE_COUNT) {
6894
8036
  throw new Error("completion-reply-image-limit");
6895
8037
  }
@@ -7883,6 +9025,9 @@ function buildConfig(cli) {
7883
9025
  codexLogsDbFile: resolvePath(process.env.CODEX_LOGS_DB_FILE || ""),
7884
9026
  stateFile,
7885
9027
  replyUploadsDir: resolvePath(process.env.REPLY_UPLOADS_DIR || path.join(path.dirname(stateFile), "uploads")),
9028
+ timelineAttachmentsDir: resolvePath(
9029
+ process.env.TIMELINE_ATTACHMENTS_DIR || path.join(path.dirname(stateFile), "timeline-attachments")
9030
+ ),
7886
9031
  pollIntervalMs: numberEnv("POLL_INTERVAL_MS", 2500),
7887
9032
  replaySeconds: numberEnv("REPLAY_SECONDS", 300),
7888
9033
  sessionIndexRefreshMs: numberEnv("SESSION_INDEX_REFRESH_MS", 30000),
@@ -8122,6 +9267,7 @@ async function loadState(stateFile) {
8122
9267
  pendingUserInputRequests: parsed.pendingUserInputRequests ?? {},
8123
9268
  recentHistoryItems: parsed.recentHistoryItems ?? [],
8124
9269
  recentTimelineEntries: parsed.recentTimelineEntries ?? [],
9270
+ timelineImagePathAliases: parsed.timelineImagePathAliases ?? {},
8125
9271
  sqliteCompletionCursorId: Number(parsed.sqliteCompletionCursorId) || 0,
8126
9272
  sqliteCompletionSourceFile: cleanText(parsed.sqliteCompletionSourceFile ?? ""),
8127
9273
  sqliteMessageCursorId: Number(parsed.sqliteMessageCursorId) || 0,
@@ -8146,6 +9292,7 @@ async function loadState(stateFile) {
8146
9292
  pendingUserInputRequests: {},
8147
9293
  recentHistoryItems: [],
8148
9294
  recentTimelineEntries: [],
9295
+ timelineImagePathAliases: {},
8149
9296
  sqliteCompletionCursorId: 0,
8150
9297
  sqliteCompletionSourceFile: "",
8151
9298
  sqliteMessageCursorId: 0,
@@ -8232,6 +9379,10 @@ function sanitizeResolvedThreadLabel(value, conversationId) {
8232
9379
  return "";
8233
9380
  }
8234
9381
 
9382
+ if (looksLikeGeneratedThreadTitle(normalized)) {
9383
+ return "";
9384
+ }
9385
+
8235
9386
  return normalized;
8236
9387
  }
8237
9388
 
@@ -8269,7 +9420,7 @@ function extractRolloutMessageText(content) {
8269
9420
  content
8270
9421
  .map((entry) =>
8271
9422
  isPlainObject(entry) && (entry.type === "input_text" || entry.type === "output_text")
8272
- ? String(entry.text ?? "")
9423
+ ? normalizeTimelineMessageText(entry.text ?? "")
8273
9424
  : ""
8274
9425
  )
8275
9426
  .filter(Boolean)
@@ -8277,6 +9428,24 @@ function extractRolloutMessageText(content) {
8277
9428
  );
8278
9429
  }
8279
9430
 
9431
+ function rolloutContentHasImages(content) {
9432
+ if (!Array.isArray(content)) {
9433
+ return false;
9434
+ }
9435
+ return content.some((entry) => {
9436
+ if (!isPlainObject(entry)) {
9437
+ return false;
9438
+ }
9439
+ if (entry.type === "input_image" || entry.type === "image" || entry.type === "localImage") {
9440
+ return true;
9441
+ }
9442
+ if (entry.type !== "input_text" && entry.type !== "output_text") {
9443
+ return false;
9444
+ }
9445
+ return isInlineImagePlaceholderText(entry.text ?? "");
9446
+ });
9447
+ }
9448
+
8280
9449
  function deriveRolloutThreadTitleCandidate(text) {
8281
9450
  const normalized = cleanText(normalizeNotificationText(text));
8282
9451
  if (!normalized) {
@@ -8657,8 +9826,50 @@ function normalizeLongText(value) {
8657
9826
  .trim();
8658
9827
  }
8659
9828
 
8660
- function normalizeNotificationText(value) {
8661
- return normalizeLongText(stripNotificationMarkup(value))
9829
+ function replaceTurnAbortedMarkup(value, locale = DEFAULT_LOCALE) {
9830
+ const raw = String(value || "");
9831
+ if (!/<turn_aborted>/iu.test(raw)) {
9832
+ return raw;
9833
+ }
9834
+ return raw.replace(/<turn_aborted>[\s\S]*?<\/turn_aborted>/giu, t(locale, "server.message.turnAborted"));
9835
+ }
9836
+
9837
+ function isTurnAbortedDisplayMessage(value) {
9838
+ const normalized = normalizeLongText(String(value || ""));
9839
+ const englishMessage = normalizeLongText(t(DEFAULT_LOCALE, "server.message.turnAborted"));
9840
+ const japaneseMessage = normalizeLongText(t("ja", "server.message.turnAborted"));
9841
+ return /<turn_aborted>/iu.test(String(value || "")) || normalized === englishMessage || normalized === japaneseMessage;
9842
+ }
9843
+
9844
+ function interruptedDetailNotice(value, locale = DEFAULT_LOCALE) {
9845
+ return isTurnAbortedDisplayMessage(value) ? t(locale, "detail.turnAbortedNotice") : "";
9846
+ }
9847
+
9848
+ function stripInlineImagePlaceholderMarkup(value) {
9849
+ return String(value || "")
9850
+ .replace(/<image\b[^>]*>/giu, "")
9851
+ .replace(/<\/image>/giu, "");
9852
+ }
9853
+
9854
+ function isInlineImagePlaceholderText(value) {
9855
+ return cleanText(stripInlineImagePlaceholderMarkup(value)) === "" && /<\/?image\b/iu.test(String(value || ""));
9856
+ }
9857
+
9858
+ function normalizeTimelineMessageText(value, locale = DEFAULT_LOCALE) {
9859
+ return normalizeLongText(replaceTurnAbortedMarkup(stripInlineImagePlaceholderMarkup(value), locale));
9860
+ }
9861
+
9862
+ function normalizeTimelineImagePaths(value) {
9863
+ if (!Array.isArray(value)) {
9864
+ return [];
9865
+ }
9866
+ return value
9867
+ .map((entry) => cleanText(entry || ""))
9868
+ .filter(Boolean);
9869
+ }
9870
+
9871
+ function normalizeNotificationText(value, locale = DEFAULT_LOCALE) {
9872
+ return normalizeLongText(replaceTurnAbortedMarkup(stripNotificationMarkup(value), locale))
8662
9873
  .replace(/\n{2,}/gu, "\n")
8663
9874
  .trim();
8664
9875
  }
@@ -8734,6 +9945,8 @@ function stripNotificationMarkup(value) {
8734
9945
  return String(value || "")
8735
9946
  .replace(/^\s*<\/?proposed_plan>\s*$/gimu, "")
8736
9947
  .replace(/<\/?proposed_plan>/giu, "")
9948
+ .replace(/<image\b[^>]*>/giu, "")
9949
+ .replace(/<\/image>/giu, "")
8737
9950
  .replace(/!\[([^\]]*)\]\(([^)]+)\)/gu, "$1")
8738
9951
  .replace(/\[([^\]]+)\]\(([^)]+)\)/gu, "$1")
8739
9952
  .replace(/^\s{0,3}#{1,6}\s+/gmu, "")
@@ -8933,6 +10146,7 @@ async function main() {
8933
10146
  if (
8934
10147
  migratedPairedDevicesStateChanged ||
8935
10148
  restoredPendingPlanStateChanged ||
10149
+ restoredTimelineImagePathsStateChanged ||
8936
10150
  restoredPendingUserInputStateChanged ||
8937
10151
  refreshResolvedThreadLabels({ config, runtime, state })
8938
10152
  ) {