viveworker 0.1.3 → 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
 
@@ -3954,10 +4576,68 @@ function getNativeThreadLabel({ runtime, conversationId, cwd }) {
3954
4576
  return rolloutLabel;
3955
4577
  }
3956
4578
  }
3957
- if (cwd) {
3958
- return truncate(cleanText(path.basename(cwd)), 90) || shortId(normalizedConversationId);
4579
+ if (cwd) {
4580
+ return truncate(cleanText(path.basename(cwd)), 90) || shortId(normalizedConversationId);
4581
+ }
4582
+ return shortId(normalizedConversationId) || "Codex task";
4583
+ }
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 "";
3959
4631
  }
3960
- return shortId(normalizedConversationId) || "Codex task";
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);
3961
4641
  }
3962
4642
 
3963
4643
  function formatNativeApprovalMessage(kind, params, locale = config?.defaultLocale || DEFAULT_LOCALE) {
@@ -5119,6 +5799,28 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
5119
5799
  return items.filter(Boolean);
5120
5800
  }
5121
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
+
5122
5824
  function buildTimelineThreads(entries, config) {
5123
5825
  const byThread = new Map();
5124
5826
  for (const entry of entries) {
@@ -5126,11 +5828,14 @@ function buildTimelineThreads(entries, config) {
5126
5828
  if (!threadId) {
5127
5829
  continue;
5128
5830
  }
5831
+ const preferredLabel =
5832
+ sanitizeTimelineThreadFilterLabel(entry.threadLabel || "", threadId) ||
5833
+ t(DEFAULT_LOCALE, "server.fallback.codexTask");
5129
5834
  const existing = byThread.get(threadId);
5130
5835
  if (!existing) {
5131
5836
  byThread.set(threadId, {
5132
5837
  id: threadId,
5133
- label: cleanText(entry.threadLabel || entry.title || "") || t(DEFAULT_LOCALE, "server.fallback.codexTask"),
5838
+ label: preferredLabel,
5134
5839
  latestAtMs: Number(entry.createdAtMs) || 0,
5135
5840
  preview: cleanText(entry.summary || entry.title || ""),
5136
5841
  entryCount: 1,
@@ -5140,7 +5845,7 @@ function buildTimelineThreads(entries, config) {
5140
5845
  existing.entryCount += 1;
5141
5846
  if (Number(entry.createdAtMs) > Number(existing.latestAtMs)) {
5142
5847
  existing.latestAtMs = Number(entry.createdAtMs) || existing.latestAtMs;
5143
- existing.label = cleanText(entry.threadLabel || entry.title || "") || existing.label;
5848
+ existing.label = preferredLabel || existing.label;
5144
5849
  existing.preview = cleanText(entry.summary || entry.title || "") || existing.preview;
5145
5850
  }
5146
5851
  }
@@ -5166,6 +5871,7 @@ function buildTimelineResponse(runtime, state, config, locale) {
5166
5871
  threadId: entry.threadId,
5167
5872
  threadLabel: entry.threadLabel,
5168
5873
  summary: entry.summary,
5874
+ imageUrls: buildTimelineEntryImageUrls(entry),
5169
5875
  createdAtMs: entry.createdAtMs,
5170
5876
  }));
5171
5877
 
@@ -5228,6 +5934,49 @@ function buildPreviousApprovalContext(runtime, approval) {
5228
5934
  };
5229
5935
  }
5230
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
+
5231
5980
  function buildPendingPlanDetail(planRequest, locale) {
5232
5981
  return {
5233
5982
  kind: "plan",
@@ -5371,19 +6120,32 @@ function buildHistoryDetail(item, locale, runtime = null) {
5371
6120
  threadLabel: item.threadLabel || "",
5372
6121
  createdAtMs: Number(item.createdAtMs) || 0,
5373
6122
  messageHtml: renderMessageHtml(item.messageText, `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
6123
+ interruptNotice: interruptedDetailNotice(item.messageText, locale),
5374
6124
  readOnly: true,
5375
6125
  reply: replyEnabled
5376
6126
  ? {
5377
6127
  enabled: true,
5378
6128
  supportsPlanMode: true,
5379
- supportsImages: false,
6129
+ supportsImages: true,
5380
6130
  }
5381
6131
  : null,
5382
6132
  actions: [],
5383
6133
  };
5384
6134
  }
5385
6135
 
5386
- 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) {
5387
6149
  return {
5388
6150
  kind: entry.kind,
5389
6151
  token: entry.token,
@@ -5392,6 +6154,9 @@ function buildTimelineMessageDetail(entry, locale) {
5392
6154
  threadLabel: entry.threadLabel || "",
5393
6155
  createdAtMs: Number(entry.createdAtMs) || 0,
5394
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),
5395
6160
  readOnly: true,
5396
6161
  actions: [],
5397
6162
  };
@@ -5484,8 +6249,224 @@ function normalizeCompletionReplyLocalImagePaths(paths) {
5484
6249
  .filter(Boolean);
5485
6250
  }
5486
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
+
5487
6466
  async function handleCompletionReply({
6467
+ config,
5488
6468
  runtime,
6469
+ state,
5489
6470
  completionItem,
5490
6471
  text,
5491
6472
  planMode = false,
@@ -5520,34 +6501,97 @@ async function handleCompletionReply({
5520
6501
  }
5521
6502
 
5522
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
+ }
5523
6531
  const collaborationMode = buildRequestedCollaborationMode(
5524
6532
  threadState,
5525
6533
  planMode ? "plan" : "default"
5526
6534
  );
5527
- const turnStartParams = {
5528
- input: buildTextInput(messageText),
5529
- attachments: [],
5530
- localImagePaths: normalizedLocalImagePaths,
5531
- local_image_paths: normalizedLocalImagePaths,
5532
- remoteImageUrls: [],
5533
- remote_image_urls: [],
5534
- cwd: null,
5535
- approvalPolicy: null,
5536
- sandboxPolicy: null,
5537
- model: null,
5538
- serviceTier: null,
5539
- effort: null,
5540
- summary: "none",
5541
- personality: null,
5542
- outputSchema: null,
6535
+ const turnCandidates = await buildCompletionReplyTurnCandidates(
6536
+ messageText,
6537
+ normalizedLocalImagePaths,
5543
6538
  collaborationMode,
5544
- };
6539
+ resolvedCwd,
6540
+ stagedWorkspaceImagePaths
6541
+ );
6542
+ let lastError = null;
6543
+ const ownerClientId = runtime.threadOwnerClientIds.get(conversationId) ?? null;
5545
6544
 
5546
- await runtime.ipcClient.startTurn(
5547
- conversationId,
5548
- turnStartParams,
5549
- 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
+ })
5550
6593
  );
6594
+ throw lastError || new Error("completion-reply-image-send-failed");
5551
6595
  }
5552
6596
 
5553
6597
  async function handlePlanDecision({ config, runtime, state, planRequest, decision }) {
@@ -5565,7 +6609,7 @@ async function handlePlanDecision({ config, runtime, state, planRequest, decisio
5565
6609
  planRequest.threadState
5566
6610
  );
5567
6611
  const turnStartParams = {
5568
- input: buildTextInput(buildImplementPlanPrompt(planRequest.rawPlanContent)),
6612
+ input: buildTurnInput(buildImplementPlanPrompt(planRequest.rawPlanContent)),
5569
6613
  attachments: [],
5570
6614
  cwd: null,
5571
6615
  approvalPolicy: null,
@@ -5648,7 +6692,7 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
5648
6692
  function buildApiItemDetail({ config, runtime, state, kind, token, locale }) {
5649
6693
  if (timelineMessageKinds.has(kind)) {
5650
6694
  const entry = timelineEntryByToken(runtime, token, kind);
5651
- return entry ? buildTimelineMessageDetail(entry, locale) : null;
6695
+ return entry ? buildTimelineMessageDetail(entry, locale, runtime) : null;
5652
6696
  }
5653
6697
  if (kind === "approval") {
5654
6698
  const approval = runtime.nativeApprovalsByToken.get(token);
@@ -5679,6 +6723,16 @@ function buildApiItemDetail({ config, runtime, state, kind, token, locale }) {
5679
6723
  return historyItem ? buildHistoryDetail(historyItem, locale, runtime) : null;
5680
6724
  }
5681
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
+
5682
6736
  function resolveWebAsset(urlPath) {
5683
6737
  let relativePath = cleanText(urlPath || "");
5684
6738
  if (!relativePath || relativePath === "/") {
@@ -5711,6 +6765,17 @@ function contentTypeForFile(filePath) {
5711
6765
  return "image/svg+xml";
5712
6766
  case ".png":
5713
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";
5714
6779
  default:
5715
6780
  return "application/octet-stream";
5716
6781
  }
@@ -6096,7 +7161,10 @@ function createNativeApprovalServer({ config, runtime, state }) {
6096
7161
  await saveState(config.stateFile, state);
6097
7162
  return writeJson(res, 410, { error: "push-subscription-expired" });
6098
7163
  }
6099
- return writeJson(res, 500, { error: error.message });
7164
+ return writeJson(res, 500, {
7165
+ error: error.message,
7166
+ ipcError: error.ipcError ?? null,
7167
+ });
6100
7168
  }
6101
7169
  }
6102
7170
 
@@ -6155,6 +7223,34 @@ function createNativeApprovalServer({ config, runtime, state }) {
6155
7223
  return writeJson(res, 200, buildTimelineResponse(runtime, state, config, locale));
6156
7224
  }
6157
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
+
6158
7254
  const apiItemMatch = url.pathname.match(/^\/api\/items\/([^/]+)\/([^/]+)$/u);
6159
7255
  if (apiItemMatch && req.method === "GET") {
6160
7256
  const session = requireApiSession(req, res, config, state);
@@ -6190,7 +7286,9 @@ function createNativeApprovalServer({ config, runtime, state }) {
6190
7286
  ? await stageCompletionReplyImages(config, req)
6191
7287
  : await parseJsonBody(req);
6192
7288
  await handleCompletionReply({
7289
+ config,
6193
7290
  runtime,
7291
+ state,
6194
7292
  completionItem,
6195
7293
  text: payload?.text ?? "",
6196
7294
  planMode: payload?.planMode === true,
@@ -6210,8 +7308,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
6210
7308
  error.message === "completion-reply-image-limit" ||
6211
7309
  error.message === "completion-reply-image-invalid-type" ||
6212
7310
  error.message === "completion-reply-image-too-large" ||
6213
- error.message === "completion-reply-image-invalid-upload" ||
6214
- error.message === "completion-reply-image-disabled"
7311
+ error.message === "completion-reply-image-invalid-upload"
6215
7312
  ) {
6216
7313
  return writeJson(res, 400, { error: error.message });
6217
7314
  }
@@ -6935,10 +8032,6 @@ async function stageCompletionReplyImages(config, req) {
6935
8032
  .getAll("image")
6936
8033
  .filter((value) => typeof File !== "undefined" && value instanceof File);
6937
8034
 
6938
- if (files.length > 0) {
6939
- throw new Error("completion-reply-image-disabled");
6940
- }
6941
-
6942
8035
  if (files.length > MAX_COMPLETION_REPLY_IMAGE_COUNT) {
6943
8036
  throw new Error("completion-reply-image-limit");
6944
8037
  }
@@ -7932,6 +9025,9 @@ function buildConfig(cli) {
7932
9025
  codexLogsDbFile: resolvePath(process.env.CODEX_LOGS_DB_FILE || ""),
7933
9026
  stateFile,
7934
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
+ ),
7935
9031
  pollIntervalMs: numberEnv("POLL_INTERVAL_MS", 2500),
7936
9032
  replaySeconds: numberEnv("REPLAY_SECONDS", 300),
7937
9033
  sessionIndexRefreshMs: numberEnv("SESSION_INDEX_REFRESH_MS", 30000),
@@ -8171,6 +9267,7 @@ async function loadState(stateFile) {
8171
9267
  pendingUserInputRequests: parsed.pendingUserInputRequests ?? {},
8172
9268
  recentHistoryItems: parsed.recentHistoryItems ?? [],
8173
9269
  recentTimelineEntries: parsed.recentTimelineEntries ?? [],
9270
+ timelineImagePathAliases: parsed.timelineImagePathAliases ?? {},
8174
9271
  sqliteCompletionCursorId: Number(parsed.sqliteCompletionCursorId) || 0,
8175
9272
  sqliteCompletionSourceFile: cleanText(parsed.sqliteCompletionSourceFile ?? ""),
8176
9273
  sqliteMessageCursorId: Number(parsed.sqliteMessageCursorId) || 0,
@@ -8195,6 +9292,7 @@ async function loadState(stateFile) {
8195
9292
  pendingUserInputRequests: {},
8196
9293
  recentHistoryItems: [],
8197
9294
  recentTimelineEntries: [],
9295
+ timelineImagePathAliases: {},
8198
9296
  sqliteCompletionCursorId: 0,
8199
9297
  sqliteCompletionSourceFile: "",
8200
9298
  sqliteMessageCursorId: 0,
@@ -8281,6 +9379,10 @@ function sanitizeResolvedThreadLabel(value, conversationId) {
8281
9379
  return "";
8282
9380
  }
8283
9381
 
9382
+ if (looksLikeGeneratedThreadTitle(normalized)) {
9383
+ return "";
9384
+ }
9385
+
8284
9386
  return normalized;
8285
9387
  }
8286
9388
 
@@ -8318,7 +9420,7 @@ function extractRolloutMessageText(content) {
8318
9420
  content
8319
9421
  .map((entry) =>
8320
9422
  isPlainObject(entry) && (entry.type === "input_text" || entry.type === "output_text")
8321
- ? String(entry.text ?? "")
9423
+ ? normalizeTimelineMessageText(entry.text ?? "")
8322
9424
  : ""
8323
9425
  )
8324
9426
  .filter(Boolean)
@@ -8326,6 +9428,24 @@ function extractRolloutMessageText(content) {
8326
9428
  );
8327
9429
  }
8328
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
+
8329
9449
  function deriveRolloutThreadTitleCandidate(text) {
8330
9450
  const normalized = cleanText(normalizeNotificationText(text));
8331
9451
  if (!normalized) {
@@ -8706,8 +9826,50 @@ function normalizeLongText(value) {
8706
9826
  .trim();
8707
9827
  }
8708
9828
 
8709
- function normalizeNotificationText(value) {
8710
- 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))
8711
9873
  .replace(/\n{2,}/gu, "\n")
8712
9874
  .trim();
8713
9875
  }
@@ -8783,6 +9945,8 @@ function stripNotificationMarkup(value) {
8783
9945
  return String(value || "")
8784
9946
  .replace(/^\s*<\/?proposed_plan>\s*$/gimu, "")
8785
9947
  .replace(/<\/?proposed_plan>/giu, "")
9948
+ .replace(/<image\b[^>]*>/giu, "")
9949
+ .replace(/<\/image>/giu, "")
8786
9950
  .replace(/!\[([^\]]*)\]\(([^)]+)\)/gu, "$1")
8787
9951
  .replace(/\[([^\]]+)\]\(([^)]+)\)/gu, "$1")
8788
9952
  .replace(/^\s{0,3}#{1,6}\s+/gmu, "")
@@ -8982,6 +10146,7 @@ async function main() {
8982
10146
  if (
8983
10147
  migratedPairedDevicesStateChanged ||
8984
10148
  restoredPendingPlanStateChanged ||
10149
+ restoredTimelineImagePathsStateChanged ||
8985
10150
  restoredPendingUserInputStateChanged ||
8986
10151
  refreshResolvedThreadLabels({ config, runtime, state })
8987
10152
  ) {