dsh-ntr 0.3.1 → 0.3.3

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.
Files changed (57) hide show
  1. package/README.en.md +3 -1
  2. package/README.md +3 -1
  3. package/cordis.patch.yml +17 -1
  4. package/lib/bridge.js +2436 -82
  5. package/lib/client/acp-ui.js +103 -3
  6. package/lib/client/acp-view.js +24 -4
  7. package/lib/client/activity-pill.js +156 -0
  8. package/lib/client/branch-action.js +149 -0
  9. package/lib/client/composer-mode.js +48 -13
  10. package/lib/client/context-meter.js +86 -0
  11. package/lib/client/index.js +279 -16
  12. package/lib/client/model-seat.js +8 -2
  13. package/lib/client/panel.js +96 -29
  14. package/lib/client/released-banner.js +66 -0
  15. package/lib/client/session-controls.js +38 -13
  16. package/lib/client/slash-isolation.js +2 -2
  17. package/lib/client/store.js +380 -13
  18. package/lib/client.js +1364 -70
  19. package/lib/client.js.map +1 -1
  20. package/lib/impl.js +1229 -8
  21. package/lib/index.js +34 -0
  22. package/lib/protocol.js +19 -0
  23. package/lib/tps.js +32 -0
  24. package/lib/types/bridge.d.ts +850 -18
  25. package/lib/types/client/acp-ui.d.ts +34 -2
  26. package/lib/types/client/acp-view.d.ts +13 -2
  27. package/lib/types/client/activity-pill.d.ts +81 -0
  28. package/lib/types/client/branch-action.d.ts +47 -0
  29. package/lib/types/client/composer-mode.d.ts +28 -0
  30. package/lib/types/client/context-meter.d.ts +51 -0
  31. package/lib/types/client/model-seat.d.ts +21 -0
  32. package/lib/types/client/released-banner.d.ts +34 -0
  33. package/lib/types/client/slash-isolation.d.ts +1 -1
  34. package/lib/types/client/store.d.ts +108 -1
  35. package/lib/types/impl.d.ts +94 -7
  36. package/lib/types/protocol.d.ts +145 -0
  37. package/lib/types/tps.d.ts +17 -0
  38. package/package.json +2 -1
  39. package/src/bridge.ts +2754 -86
  40. package/src/client/acp-ui.tsx +123 -2
  41. package/src/client/acp-view.tsx +30 -5
  42. package/src/client/activity-pill.tsx +201 -0
  43. package/src/client/branch-action.tsx +178 -0
  44. package/src/client/composer-mode.tsx +59 -13
  45. package/src/client/context-meter.tsx +128 -0
  46. package/src/client/index.tsx +298 -15
  47. package/src/client/model-seat.tsx +28 -3
  48. package/src/client/panel.tsx +157 -40
  49. package/src/client/primitives.d.ts +27 -0
  50. package/src/client/released-banner.tsx +97 -0
  51. package/src/client/session-controls.tsx +34 -11
  52. package/src/client/slash-isolation.ts +2 -1
  53. package/src/client/store.ts +412 -14
  54. package/src/impl.ts +1251 -13
  55. package/src/index.ts +33 -0
  56. package/src/protocol.ts +145 -0
  57. package/src/tps.ts +40 -0
package/src/impl.ts CHANGED
@@ -7,10 +7,10 @@
7
7
 
8
8
  import { randomUUID } from './ids.ts'
9
9
  import type {
10
- AcpAgentCatalog, AcpAgentDef, AcpCatalogOption, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPluginConfig, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult,
10
+ AcpAgentCatalog, AcpAgentDef, AcpCatalogOption, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpIdleRelease, AcpMcpServerDef, AcpPluginConfig, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult,
11
11
  AgentView, ApprovalDeadlineKey, ApprovalDeadlinePatch, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, PermissionOptionView, PermissionOutcome, PermissionTimeoutMode, PluginUpdateState, RpcResult,
12
12
  } from './protocol.ts'
13
- import { DEFAULT_TAG_HALO } from './protocol.ts'
13
+ import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TAG_HALO, IDLE_RELEASE_MS } from './protocol.ts'
14
14
 
15
15
  export { randomUUID }
16
16
 
@@ -175,6 +175,21 @@ export function validateApprovalDeadlines(value: unknown): Partial<Record<Approv
175
175
  return out
176
176
  }
177
177
 
178
+ /**
179
+ * Validate the idle-release window the settings page writes.
180
+ *
181
+ * The window is expressed in MILLISECONDS on the shared ladder
182
+ * ({@link IDLE_RELEASE_MS}), so a value the slider can produce is always a
183
+ * value the host runs — the same one-ladder rule the approval deadline card
184
+ * follows.
185
+ */
186
+ export function validateIdleReleaseMs(raw: unknown, code: 'bad_config' | 'bad_payload'): number {
187
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || !IDLE_RELEASE_MS.includes(raw)) {
188
+ throw new AcpError(code, `acp: idleTimeoutMs must be one of ${IDLE_RELEASE_MS.join(', ')}`)
189
+ }
190
+ return raw
191
+ }
192
+
178
193
  /**
179
194
  * Validate the mode a write asks for. Three values, and the list is the whole
180
195
  * vocabulary: an unanswered ask is either answered for you, kept waiting, or
@@ -523,6 +538,14 @@ export function validateConfig(raw: unknown): AcpPluginConfig {
523
538
  // `abort` a zero window would refuse every ask on arrival — the agent stops
524
539
  // at every gate — so it becomes the shortest real wait.
525
540
  const effectivePermissionTimeoutMs = normalizeApprovalWindow(permissionTimeoutMode, permissionTimeoutMs)
541
+ // The chat-process idle-release window (the settings page edits it).
542
+ // Same shape as the per-agent field: 0 = never release, otherwise an
543
+ // integer number of milliseconds.
544
+ const idleTimeoutMs = value.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
545
+ if (typeof idleTimeoutMs !== 'number' || !Number.isInteger(idleTimeoutMs)
546
+ || idleTimeoutMs > 86_400_000 || (idleTimeoutMs !== 0 && idleTimeoutMs < 1_000)) {
547
+ throw new AcpError('bad_config', 'acp: idleTimeoutMs must be 0 (never release) or an integer in [1000, 86400000]')
548
+ }
526
549
  const cancelFallbackMs = value.cancelFallbackMs ?? 15_000
527
550
  if (typeof cancelFallbackMs !== 'number' || !Number.isInteger(cancelFallbackMs) || cancelFallbackMs < 0 || cancelFallbackMs > 600_000) {
528
551
  throw new AcpError('bad_config', 'acp: cancelFallbackMs must be an integer in [0, 600000] (0 = disable the fallback)')
@@ -564,6 +587,7 @@ export function validateConfig(raw: unknown): AcpPluginConfig {
564
587
  turnStallMs,
565
588
  turnStallToolMs,
566
589
  turnStallTickMs,
590
+ idleTimeoutMs,
567
591
  }
568
592
  }
569
593
 
@@ -1363,8 +1387,1053 @@ export function describeUnhandledUpdate(kind: string, update: Record<string, unk
1363
1387
  * injected context too (goal/subagent/skill material), and that turn carries no
1364
1388
  * user intent to send anywhere.
1365
1389
  */
1390
+ export interface PromptImageContent {
1391
+ type: 'image'
1392
+ data: string
1393
+ mimeType: string
1394
+ uri?: string
1395
+ }
1396
+
1397
+ export interface PromptTextContent {
1398
+ type: 'text'
1399
+ text: string
1400
+ }
1401
+
1402
+ export type PromptContentBlock = PromptTextContent | PromptImageContent
1403
+
1404
+ /** Helper: guess MIME type from file extension or URL */
1405
+ export function guessImageMimeType(pathOrUrl?: string): string | undefined {
1406
+ if (!pathOrUrl) return undefined
1407
+ const clean = pathOrUrl.split('?')[0].split('#')[0].toLowerCase()
1408
+ if (clean.endsWith('.jpg') || clean.endsWith('.jpeg')) return 'image/jpeg'
1409
+ if (clean.endsWith('.png')) return 'image/png'
1410
+ if (clean.endsWith('.gif')) return 'image/gif'
1411
+ if (clean.endsWith('.webp')) return 'image/webp'
1412
+ if (clean.endsWith('.svg') || clean.endsWith('.svgz')) return 'image/svg+xml'
1413
+ if (clean.endsWith('.bmp')) return 'image/bmp'
1414
+ if (clean.endsWith('.ico')) return 'image/x-icon'
1415
+ if (clean.endsWith('.tiff') || clean.endsWith('.tif')) return 'image/tiff'
1416
+ if (clean.endsWith('.avif')) return 'image/avif'
1417
+ if (clean.endsWith('.heic')) return 'image/heic'
1418
+ if (clean.endsWith('.heif')) return 'image/heif'
1419
+ if (clean.endsWith('.apng')) return 'image/apng'
1420
+ return undefined
1421
+ }
1422
+
1423
+ /** Helper: parse Data URI */
1424
+ export function parseImageDataUrl(url: string): { data: string; mimeType: string } | undefined {
1425
+ if (!url.startsWith('data:')) return undefined
1426
+ const commaIdx = url.indexOf(',')
1427
+ if (commaIdx === -1) return undefined
1428
+ const header = url.slice(5, commaIdx)
1429
+ const raw = url.slice(commaIdx + 1).trim()
1430
+ const parts = header.split(';')
1431
+ const mimeType = parts[0] && parts[0].trim() !== '' ? parts[0].trim().toLowerCase() : 'image/png'
1432
+ if (!mimeType.startsWith('image/')) return undefined
1433
+ const isBase64 = parts.some(p => p.trim() === 'base64')
1434
+ let data: string
1435
+ if (isBase64) {
1436
+ data = raw.replace(/\s+/g, '')
1437
+ } else {
1438
+ try {
1439
+ const decoded = decodeURIComponent(raw)
1440
+ data = typeof Buffer !== 'undefined' ? Buffer.from(decoded).toString('base64') : btoa(decoded)
1441
+ } catch {
1442
+ data = typeof Buffer !== 'undefined' ? Buffer.from(raw).toString('base64') : btoa(raw)
1443
+ }
1444
+ }
1445
+ return { mimeType, data }
1446
+ }
1447
+
1448
+ export function isLocalFilePath(str: string): boolean {
1449
+ if (typeof str !== 'string' || str === '') return false
1450
+ return isAbsolutePath(str) || str.startsWith('./') || str.startsWith('.\\') || str.startsWith('../') || str.startsWith('..\\')
1451
+ }
1452
+
1453
+ export function normalizeFileUrlToPath(uri: string): string {
1454
+ if (!uri.startsWith('file://')) return uri
1455
+ let raw = uri.slice('file://'.length)
1456
+ try {
1457
+ raw = decodeURIComponent(raw)
1458
+ } catch {}
1459
+ if (raw.startsWith('localhost/')) {
1460
+ raw = raw.slice('localhost'.length)
1461
+ }
1462
+ if (/^\/?[a-zA-Z]:/.test(raw)) {
1463
+ return raw.startsWith('/') ? raw.slice(1) : raw
1464
+ }
1465
+ if (raw.startsWith('/')) {
1466
+ return raw
1467
+ }
1468
+ return `//${raw}`
1469
+ }
1470
+
1471
+ function expandHomePath(p: string): string {
1472
+ if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) {
1473
+ if (typeof process !== 'undefined' && process.env) {
1474
+ const env = process.env
1475
+ const home = env.HOME || env.USERPROFILE || (env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined)
1476
+ if (home) {
1477
+ const cleanHome = home.replaceAll('\\', '/').replace(/\/+$/, '')
1478
+ return p === '~' ? cleanHome : `${cleanHome}/${p.slice(2).replaceAll('\\', '/')}`
1479
+ }
1480
+ }
1481
+ }
1482
+ return p
1483
+ }
1484
+
1485
+ function readImageFileFromContext(filePath: string, context?: unknown): Buffer | Uint8Array | string | undefined {
1486
+ const target = normalizeFileUrlToPath(filePath)
1487
+ const expanded = expandHomePath(target)
1488
+ const pathsToTry = target === expanded ? [target] : [target, expanded]
1489
+
1490
+ const builtinFs = typeof process !== 'undefined' && typeof (process as unknown as { getBuiltinModule?: (m: string) => unknown }).getBuiltinModule === 'function'
1491
+ ? ((process as unknown as { getBuiltinModule: (m: string) => unknown }).getBuiltinModule('fs') as { existsSync?: (p: string) => boolean; readFileSync?: (p: string) => Buffer } | null)
1492
+ : null
1493
+
1494
+ for (const p of pathsToTry) {
1495
+ if (context && typeof (context as { readFile?: unknown }).readFile === 'function') {
1496
+ try {
1497
+ const res = (context as { readFile: (path: string) => unknown }).readFile(p)
1498
+ if (res !== undefined) return res as Buffer | Uint8Array | string
1499
+ } catch {}
1500
+ }
1501
+ if (context && typeof (context as { fs?: { readFileSync?: unknown } }).fs?.readFileSync === 'function') {
1502
+ try {
1503
+ const res = (context as { fs: { readFileSync: (path: string) => unknown } }).fs.readFileSync(p)
1504
+ if (res !== undefined) return res as Buffer | Uint8Array | string
1505
+ } catch {}
1506
+ }
1507
+ if (builtinFs && typeof builtinFs.existsSync === 'function' && typeof builtinFs.readFileSync === 'function') {
1508
+ try {
1509
+ if (builtinFs.existsSync(p)) return builtinFs.readFileSync(p)
1510
+ } catch {}
1511
+ }
1512
+ }
1513
+ return undefined
1514
+ }
1515
+
1516
+ /**
1517
+ * Detect image MIME type by examining binary magic numbers.
1518
+ * Supports Buffer, Uint8Array, and base64 strings.
1519
+ */
1520
+ export function detectImageMimeFromMagic(raw: Buffer | Uint8Array | string | undefined): string | undefined {
1521
+ if (!raw) return undefined
1522
+ let bytes: Uint8Array | undefined
1523
+ if (typeof raw === 'string') {
1524
+ const trimmed = raw.trim()
1525
+ if (trimmed.startsWith('<svg') || (trimmed.startsWith('<?xml') && trimmed.includes('<svg'))) {
1526
+ return 'image/svg+xml'
1527
+ }
1528
+ try {
1529
+ const prefix = trimmed.slice(0, 64).replace(/\s+/g, '')
1530
+ if (typeof Buffer !== 'undefined') {
1531
+ bytes = Buffer.from(prefix, 'base64')
1532
+ } else if (typeof atob !== 'undefined') {
1533
+ const bin = atob(prefix)
1534
+ bytes = new Uint8Array(bin.length)
1535
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
1536
+ }
1537
+ } catch {
1538
+ return undefined
1539
+ }
1540
+ } else if (raw instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(raw))) {
1541
+ bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
1542
+ }
1543
+
1544
+ if (!bytes || bytes.length < 3) return undefined
1545
+
1546
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
1547
+ if (bytes.length >= 8 &&
1548
+ bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
1549
+ bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) {
1550
+ return 'image/png'
1551
+ }
1552
+
1553
+ // JPEG: FF D8 FF
1554
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
1555
+ return 'image/jpeg'
1556
+ }
1557
+
1558
+ // GIF: GIF87a or GIF89a (47 49 46 38)
1559
+ if (bytes.length >= 6 &&
1560
+ bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 &&
1561
+ (bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61) {
1562
+ return 'image/gif'
1563
+ }
1564
+
1565
+ // WebP: RIFF....WEBP (52 49 46 46 .... 57 45 42 50)
1566
+ if (bytes.length >= 12 &&
1567
+ bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
1568
+ bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {
1569
+ return 'image/webp'
1570
+ }
1571
+
1572
+ // BMP: 42 4D ("BM")
1573
+ if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) {
1574
+ return 'image/bmp'
1575
+ }
1576
+
1577
+ // ICO: 00 00 01 00
1578
+ if (bytes.length >= 4 && bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 && bytes[3] === 0x00) {
1579
+ return 'image/x-icon'
1580
+ }
1581
+
1582
+ // TIFF: II*. (49 49 2A 00) or MM.* (4D 4D 00 2A)
1583
+ if (bytes.length >= 4 &&
1584
+ ((bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) ||
1585
+ (bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a))) {
1586
+ return 'image/tiff'
1587
+ }
1588
+
1589
+ // AVIF / HEIC: ftyp box at offset 4 (bytes 4..7 === 'ftyp')
1590
+ if (bytes.length >= 12 &&
1591
+ bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) {
1592
+ const brand = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]).toLowerCase()
1593
+ if (brand === 'avif' || brand === 'avis') return 'image/avif'
1594
+ if (brand === 'heic' || brand === 'heix' || brand === 'hevc' || brand === 'mif1' || brand === 'msf1') return 'image/heic'
1595
+ }
1596
+
1597
+ return undefined
1598
+ }
1599
+
1600
+ function tryResolveFromAttachmentStore(
1601
+ store: Record<string, unknown>,
1602
+ id: string,
1603
+ context?: unknown,
1604
+ attachmentMeta?: unknown,
1605
+ ): unknown {
1606
+ const idWithoutPrefix = id.startsWith('sha256:') ? id.slice('sha256:'.length) : undefined
1607
+ const idWithPrefix = id.startsWith('sha256:') ? id : `sha256:${id}`
1608
+
1609
+ const candidates: unknown[] = []
1610
+ if (attachmentMeta && typeof attachmentMeta === 'object') {
1611
+ candidates.push(attachmentMeta)
1612
+ }
1613
+ candidates.push({ attachmentId: idWithPrefix })
1614
+ if (idWithoutPrefix !== undefined) {
1615
+ candidates.push({ attachmentId: idWithoutPrefix })
1616
+ }
1617
+ candidates.push({ attachmentId: id })
1618
+ candidates.push({ id: idWithPrefix })
1619
+ candidates.push({ id })
1620
+ if (idWithoutPrefix !== undefined) {
1621
+ candidates.push({ id: idWithoutPrefix })
1622
+ }
1623
+ candidates.push(id)
1624
+ if (idWithoutPrefix !== undefined) {
1625
+ candidates.push(idWithoutPrefix)
1626
+ }
1627
+
1628
+ // Cordis proxies wrap the underlying service instance; if a method call on the proxy fails
1629
+ // or loses this context, fall back to the unwrapped instance (never the class prototype,
1630
+ // which lacks instance state such as `this.root`).
1631
+ const cordisOriginal = Reflect.get(store, Symbol.for('cordis.original')) as Record<string, unknown> | undefined
1632
+
1633
+ // A. LocalAttachmentStore.imageHostPath(ref) -> synchronous local absolute path
1634
+ if (typeof store.imageHostPath === 'function') {
1635
+ const fn = store.imageHostPath as (ref: unknown) => unknown
1636
+ for (const cand of candidates) {
1637
+ if (cand === undefined) continue
1638
+ try {
1639
+ let hostPath: unknown
1640
+ try {
1641
+ hostPath = fn.call(store, cand)
1642
+ } catch {
1643
+ if (cordisOriginal && typeof cordisOriginal.imageHostPath === 'function') {
1644
+ hostPath = (cordisOriginal.imageHostPath as (ref: unknown) => unknown).call(cordisOriginal, cand)
1645
+ }
1646
+ }
1647
+ if (hostPath && typeof (hostPath as Promise<unknown>).then === 'function') {
1648
+ if (typeof (hostPath as Promise<unknown>).catch === 'function') {
1649
+ (hostPath as Promise<unknown>).catch(() => {})
1650
+ }
1651
+ } else if (typeof hostPath === 'string' && hostPath.trim() !== '') {
1652
+ const fileContent = readImageFileFromContext(hostPath, context)
1653
+ return {
1654
+ path: hostPath,
1655
+ ...(fileContent !== undefined ? { data: fileContent } : {}),
1656
+ }
1657
+ }
1658
+ } catch {}
1659
+ }
1660
+ }
1661
+
1662
+ // B. LocalAttachmentStore.fileHostPath(ref) -> fallback for file-type attachments
1663
+ if (typeof store.fileHostPath === 'function') {
1664
+ const fn = store.fileHostPath as (ref: unknown) => unknown
1665
+ for (const cand of candidates) {
1666
+ if (cand === undefined) continue
1667
+ try {
1668
+ let hostPath: unknown
1669
+ try {
1670
+ hostPath = fn.call(store, cand)
1671
+ } catch {
1672
+ if (cordisOriginal && typeof cordisOriginal.fileHostPath === 'function') {
1673
+ hostPath = (cordisOriginal.fileHostPath as (ref: unknown) => unknown).call(cordisOriginal, cand)
1674
+ }
1675
+ }
1676
+ if (hostPath && typeof (hostPath as Promise<unknown>).then === 'function') {
1677
+ if (typeof (hostPath as Promise<unknown>).catch === 'function') {
1678
+ (hostPath as Promise<unknown>).catch(() => {})
1679
+ }
1680
+ } else if (typeof hostPath === 'string' && hostPath.trim() !== '') {
1681
+ const fileContent = readImageFileFromContext(hostPath, context)
1682
+ return {
1683
+ path: hostPath,
1684
+ ...(fileContent !== undefined ? { data: fileContent } : {}),
1685
+ }
1686
+ }
1687
+ } catch {}
1688
+ }
1689
+ }
1690
+
1691
+ // C. LocalAttachmentStore.readImage(ref) -> { ref, data: Uint8Array }
1692
+ if (typeof store.readImage === 'function') {
1693
+ const fn = store.readImage as (ref: unknown) => unknown
1694
+ for (const cand of candidates) {
1695
+ if (cand === undefined) continue
1696
+ try {
1697
+ let res: unknown
1698
+ try {
1699
+ res = fn.call(store, cand)
1700
+ } catch {
1701
+ if (cordisOriginal && typeof cordisOriginal.readImage === 'function') {
1702
+ res = (cordisOriginal.readImage as (ref: unknown) => unknown).call(cordisOriginal, cand)
1703
+ }
1704
+ }
1705
+ if (res && typeof res === 'object') {
1706
+ // If it's a Promise (async), safely attach catch to avoid unhandledRejection crashes
1707
+ if (typeof (res as Promise<unknown>).then === 'function') {
1708
+ if (typeof (res as Promise<unknown>).catch === 'function') {
1709
+ (res as Promise<unknown>).catch(() => {})
1710
+ }
1711
+ } else {
1712
+ if (res instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(res))) {
1713
+ return res
1714
+ }
1715
+ const data = (res as { data?: unknown }).data
1716
+ if (data instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) || typeof data === 'string') {
1717
+ return { data }
1718
+ }
1719
+ }
1720
+ }
1721
+ } catch {}
1722
+ }
1723
+ }
1724
+
1725
+ // D. readAttachment
1726
+ if (typeof store.readAttachment === 'function') {
1727
+ try {
1728
+ let res: unknown
1729
+ try {
1730
+ res = (store.readAttachment as (id: string) => unknown).call(store, id)
1731
+ } catch {
1732
+ if (cordisOriginal && typeof cordisOriginal.readAttachment === 'function') {
1733
+ res = (cordisOriginal.readAttachment as (id: string) => unknown).call(cordisOriginal, id)
1734
+ }
1735
+ }
1736
+ if (res && typeof (res as Promise<unknown>).then === 'function') {
1737
+ if (typeof (res as Promise<unknown>).catch === 'function') {
1738
+ (res as Promise<unknown>).catch(() => {})
1739
+ }
1740
+ } else if (res !== undefined) {
1741
+ return res
1742
+ }
1743
+ } catch {}
1744
+ }
1745
+
1746
+ // E. getAttachment
1747
+ if (typeof store.getAttachment === 'function') {
1748
+ try {
1749
+ let res: unknown
1750
+ try {
1751
+ res = (store.getAttachment as (id: string) => unknown).call(store, id)
1752
+ } catch {
1753
+ if (cordisOriginal && typeof cordisOriginal.getAttachment === 'function') {
1754
+ res = (cordisOriginal.getAttachment as (id: string) => unknown).call(cordisOriginal, id)
1755
+ }
1756
+ }
1757
+ if (res && typeof (res as Promise<unknown>).then === 'function') {
1758
+ if (typeof (res as Promise<unknown>).catch === 'function') {
1759
+ (res as Promise<unknown>).catch(() => {})
1760
+ }
1761
+ } else if (res !== undefined) {
1762
+ return res
1763
+ }
1764
+ } catch {}
1765
+ }
1766
+
1767
+ return undefined
1768
+ }
1769
+
1770
+ function tryStoreFreeDiskAttachment(id: string, context?: unknown): unknown {
1771
+ if (typeof id !== 'string') return undefined
1772
+ let hash: string | undefined
1773
+ const trimmed = id.trim()
1774
+ if (trimmed.startsWith('sha256:')) {
1775
+ hash = trimmed.slice('sha256:'.length).toLowerCase()
1776
+ } else if (/^[0-9a-f]{64}$/i.test(trimmed)) {
1777
+ hash = trimmed.toLowerCase()
1778
+ }
1779
+ if (!hash || hash.length < 2) return undefined
1780
+
1781
+ const prefix = hash.slice(0, 2)
1782
+ const relPaths = [
1783
+ `attachments/v1/objects/${prefix}/${hash}`,
1784
+ `attachments/v1/file-objects/${prefix}/${hash}`,
1785
+ ]
1786
+
1787
+ const candidatePaths: string[] = []
1788
+
1789
+ if (typeof process !== 'undefined' && process.env) {
1790
+ const env = process.env
1791
+ if (env.DSH_NTR_STATE_DIR) {
1792
+ const stateDir = env.DSH_NTR_STATE_DIR.replaceAll('\\', '/').replace(/\/+$/, '')
1793
+ const hostDir = stateDir.endsWith('/dsh-ntr') ? stateDir.slice(0, -'/dsh-ntr'.length) : stateDir
1794
+ for (const rel of relPaths) candidatePaths.push(`${hostDir}/${rel}`)
1795
+ }
1796
+ if (env.DSH_DIR) {
1797
+ const dshDir = env.DSH_DIR.replaceAll('\\', '/').replace(/\/+$/, '')
1798
+ for (const rel of relPaths) candidatePaths.push(`${dshDir}/${rel}`)
1799
+ }
1800
+ const home = env.HOME || env.USERPROFILE || (env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined)
1801
+ if (home) {
1802
+ const cleanHome = home.replaceAll('\\', '/').replace(/\/+$/, '')
1803
+ for (const rel of relPaths) {
1804
+ candidatePaths.push(`${cleanHome}/.dsh/${rel}`)
1805
+ candidatePaths.push(`${cleanHome}\\.dsh\\${rel.replaceAll('/', '\\')}`)
1806
+ }
1807
+ }
1808
+ }
1809
+ for (const rel of relPaths) candidatePaths.push(`~/.dsh/${rel}`)
1810
+
1811
+ for (const p of candidatePaths) {
1812
+ const fileContent = readImageFileFromContext(p, context)
1813
+ if (fileContent !== undefined) {
1814
+ return {
1815
+ path: p,
1816
+ data: fileContent,
1817
+ }
1818
+ }
1819
+ }
1820
+
1821
+ return undefined
1822
+ }
1823
+
1824
+ export function resolveImageAttachmentFromContext(
1825
+ id: string,
1826
+ context?: unknown,
1827
+ attachmentMeta?: unknown,
1828
+ rootContext?: unknown,
1829
+ ): unknown {
1830
+ const root = rootContext ?? context
1831
+ if (context && typeof context === 'object') {
1832
+ const ctx = context as Record<string, unknown>
1833
+
1834
+ const direct = tryResolveFromAttachmentStore(ctx, id, root, attachmentMeta)
1835
+ if (direct !== undefined) return direct
1836
+
1837
+ if (typeof ctx.get === 'function') {
1838
+ try {
1839
+ const svc = (ctx.get as (name: string) => unknown)('attachments') ?? (ctx.get as (name: string) => unknown)('attachmentService')
1840
+ if (svc && typeof svc === 'object') {
1841
+ const res = resolveImageAttachmentFromContext(id, svc, attachmentMeta, root)
1842
+ if (res !== undefined) return res
1843
+ }
1844
+ } catch {}
1845
+ }
1846
+
1847
+ try {
1848
+ if (ctx.attachmentService && typeof ctx.attachmentService === 'object') {
1849
+ const res = resolveImageAttachmentFromContext(id, ctx.attachmentService, attachmentMeta, root)
1850
+ if (res !== undefined) return res
1851
+ }
1852
+ } catch {}
1853
+
1854
+ try {
1855
+ const attStore = ctx.attachments
1856
+ if (attStore) {
1857
+ if (typeof attStore === 'object') {
1858
+ const fromStore = tryResolveFromAttachmentStore(attStore as Record<string, unknown>, id, root, attachmentMeta)
1859
+ if (fromStore !== undefined) return fromStore
1860
+ }
1861
+ if (attStore instanceof Map || typeof (attStore as { get?: unknown }).get === 'function') {
1862
+ try { return (attStore as Map<string, unknown>).get(id) } catch {}
1863
+ }
1864
+ if (typeof attStore === 'object' && Object.prototype.hasOwnProperty.call(attStore, id)) {
1865
+ return (attStore as Record<string, unknown>)[id]
1866
+ }
1867
+ }
1868
+ } catch {}
1869
+ }
1870
+
1871
+ return tryStoreFreeDiskAttachment(id, root)
1872
+ }
1873
+
1874
+ function toBase64(data: Buffer | Uint8Array | string): string {
1875
+ if (typeof data === 'string') return data.replace(/\s+/g, '')
1876
+ if (typeof Buffer !== 'undefined') return Buffer.from(data).toString('base64')
1877
+ let binary = ''
1878
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data)
1879
+ for (let i = 0; i < bytes.byteLength; i++) {
1880
+ binary += String.fromCharCode(bytes[i])
1881
+ }
1882
+ return btoa(binary)
1883
+ }
1884
+
1885
+ /**
1886
+ * Extract an image payload (Base64 data + MIME type) from an image block and optional context.
1887
+ */
1888
+ export function resolveImageContent(block: unknown, context?: unknown): PromptImageContent | undefined {
1889
+ if (!block || typeof block !== 'object') return undefined
1890
+ const b = block as Record<string, unknown>
1891
+ const attRecord = b.attachment && typeof b.attachment === 'object' ? (b.attachment as Record<string, unknown>) : undefined
1892
+ const isExplicitImageBlock = b.type === 'image' || b.type === 'image_url' || b.type === 'attachment'
1893
+ const explicitMime = (typeof attRecord?.mediaType === 'string' && attRecord.mediaType.trim().toLowerCase()) ||
1894
+ (typeof attRecord?.mimeType === 'string' && attRecord.mimeType.trim().toLowerCase()) ||
1895
+ (typeof b.mediaType === 'string' && b.mediaType.trim().toLowerCase()) ||
1896
+ (typeof b.mimeType === 'string' && b.mimeType.trim().toLowerCase()) ||
1897
+ (typeof b.contentType === 'string' && b.contentType.trim().toLowerCase()) ||
1898
+ undefined
1899
+
1900
+ // 1. Direct base64 data: { data: string | Buffer | Uint8Array, mimeType?: string }
1901
+ if (typeof b.data === 'string' && b.data.trim() !== '') {
1902
+ if (b.data.startsWith('data:')) {
1903
+ const parsed = parseImageDataUrl(b.data)
1904
+ if (parsed) {
1905
+ const mimeType = explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png'
1906
+ return { type: 'image', data: parsed.data, mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
1907
+ }
1908
+ return undefined
1909
+ }
1910
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.data) || (isExplicitImageBlock ? 'image/png' : undefined)
1911
+ if (mimeType && mimeType.startsWith('image/')) {
1912
+ return {
1913
+ type: 'image',
1914
+ data: b.data.trim().replace(/\s+/g, ''),
1915
+ mimeType,
1916
+ ...(typeof b.uri === 'string' ? { uri: b.uri } : {}),
1917
+ }
1918
+ }
1919
+ }
1920
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(b.data)) {
1921
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.data) || (isExplicitImageBlock ? 'image/png' : undefined)
1922
+ if (mimeType && mimeType.startsWith('image/')) {
1923
+ return { type: 'image', data: b.data.toString('base64'), mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
1924
+ }
1925
+ }
1926
+ if (b.data instanceof Uint8Array) {
1927
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.data) || (isExplicitImageBlock ? 'image/png' : undefined)
1928
+ if (mimeType && mimeType.startsWith('image/')) {
1929
+ return { type: 'image', data: toBase64(b.data), mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
1930
+ }
1931
+ }
1932
+
1933
+ // 2. Anthropic style: source: { type: 'base64', media_type: string, data: string }
1934
+ if (b.source && typeof b.source === 'object') {
1935
+ const src = b.source as Record<string, unknown>
1936
+ const srcMime = (typeof src.media_type === 'string' && src.media_type.trim().toLowerCase()) ||
1937
+ (typeof src.mimeType === 'string' && src.mimeType.trim().toLowerCase()) ||
1938
+ explicitMime ||
1939
+ (typeof src.data === 'string' || src.data instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(src.data)) ? detectImageMimeFromMagic(src.data as Buffer | Uint8Array | string) : undefined) ||
1940
+ 'image/png'
1941
+ if (srcMime.startsWith('image/')) {
1942
+ if (typeof src.data === 'string' && src.data.trim() !== '') {
1943
+ return {
1944
+ type: 'image',
1945
+ data: src.data.trim().replace(/\s+/g, ''),
1946
+ mimeType: srcMime,
1947
+ ...(typeof b.uri === 'string' ? { uri: b.uri } : {}),
1948
+ }
1949
+ }
1950
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(src.data)) {
1951
+ return { type: 'image', data: src.data.toString('base64'), mimeType: srcMime, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
1952
+ }
1953
+ if (src.data instanceof Uint8Array) {
1954
+ return { type: 'image', data: toBase64(src.data), mimeType: srcMime, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
1955
+ }
1956
+ }
1957
+ }
1958
+
1959
+ // 3. Image property: { image: string | Buffer | Uint8Array }
1960
+ if (typeof b.image === 'string' && b.image.trim() !== '') {
1961
+ if (b.image.startsWith('data:')) {
1962
+ const parsed = parseImageDataUrl(b.image)
1963
+ if (parsed) return { type: 'image', data: parsed.data, mimeType: explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png' }
1964
+ return undefined
1965
+ }
1966
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.image) || (isExplicitImageBlock ? 'image/png' : undefined)
1967
+ if (mimeType && mimeType.startsWith('image/')) {
1968
+ return { type: 'image', data: b.image.trim().replace(/\s+/g, ''), mimeType }
1969
+ }
1970
+ }
1971
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(b.image)) {
1972
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.image) || (isExplicitImageBlock ? 'image/png' : undefined)
1973
+ if (mimeType && mimeType.startsWith('image/')) return { type: 'image', data: b.image.toString('base64'), mimeType }
1974
+ }
1975
+ if (b.image instanceof Uint8Array) {
1976
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.image) || (isExplicitImageBlock ? 'image/png' : undefined)
1977
+ if (mimeType && mimeType.startsWith('image/')) return { type: 'image', data: toBase64(b.image), mimeType }
1978
+ }
1979
+
1980
+ // 4. OpenAI style image_url: { image_url: string | { url: string } }
1981
+ if (b.image_url) {
1982
+ const urlStr = typeof b.image_url === 'string'
1983
+ ? b.image_url
1984
+ : (typeof b.image_url === 'object' && b.image_url !== null ? (b.image_url as Record<string, unknown>).url : undefined)
1985
+ if (typeof urlStr === 'string' && urlStr.trim() !== '') {
1986
+ if (urlStr.startsWith('data:')) {
1987
+ const parsed = parseImageDataUrl(urlStr)
1988
+ if (parsed) return { type: 'image', data: parsed.data, mimeType: explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png', uri: urlStr }
1989
+ return undefined
1990
+ }
1991
+ if (urlStr.startsWith('http://') || urlStr.startsWith('https://')) {
1992
+ const mimeType = explicitMime || guessImageMimeType(urlStr) || 'image/png'
1993
+ return { type: 'image', data: '', mimeType, uri: urlStr }
1994
+ }
1995
+ if (urlStr.startsWith('file://') || isLocalFilePath(urlStr)) {
1996
+ const fileContent = readImageFileFromContext(urlStr, context)
1997
+ if (fileContent !== undefined) {
1998
+ let data: string
1999
+ let resolvedMime: string | undefined
2000
+ if (typeof fileContent === 'string') {
2001
+ if (fileContent.startsWith('data:')) {
2002
+ const parsed = parseImageDataUrl(fileContent)
2003
+ if (parsed) {
2004
+ data = parsed.data
2005
+ resolvedMime = parsed.mimeType
2006
+ } else {
2007
+ return undefined
2008
+ }
2009
+ } else {
2010
+ data = fileContent.trim().replace(/\s+/g, '')
2011
+ }
2012
+ } else {
2013
+ data = toBase64(fileContent)
2014
+ }
2015
+ const mimeType = explicitMime || resolvedMime || guessImageMimeType(urlStr) || detectImageMimeFromMagic(fileContent) || (isExplicitImageBlock ? 'image/png' : undefined)
2016
+ if (mimeType && mimeType.startsWith('image/')) {
2017
+ return { type: 'image', data, mimeType, uri: urlStr }
2018
+ }
2019
+ }
2020
+ }
2021
+ }
2022
+ }
2023
+
2024
+ // 5. Direct buffer / content on block: { buffer: Buffer } or { content: Buffer }
2025
+ if (b.buffer && (typeof Buffer !== 'undefined' && Buffer.isBuffer(b.buffer) || b.buffer instanceof Uint8Array)) {
2026
+ const data = typeof Buffer !== 'undefined' && Buffer.isBuffer(b.buffer) ? b.buffer.toString('base64') : toBase64(b.buffer as Uint8Array)
2027
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.buffer as Buffer | Uint8Array) || (isExplicitImageBlock ? 'image/png' : undefined)
2028
+ if (mimeType && mimeType.startsWith('image/')) {
2029
+ return { type: 'image', data, mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
2030
+ }
2031
+ }
2032
+ if (b.content && (typeof Buffer !== 'undefined' && Buffer.isBuffer(b.content) || b.content instanceof Uint8Array)) {
2033
+ const data = typeof Buffer !== 'undefined' && Buffer.isBuffer(b.content) ? b.content.toString('base64') : toBase64(b.content as Uint8Array)
2034
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.content as Buffer | Uint8Array) || (isExplicitImageBlock ? 'image/png' : undefined)
2035
+ if (mimeType && mimeType.startsWith('image/')) {
2036
+ return { type: 'image', data, mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
2037
+ }
2038
+ }
2039
+
2040
+ // 6. Data URL or Remote URL in `url`
2041
+ if (typeof b.url === 'string') {
2042
+ if (b.url.startsWith('data:')) {
2043
+ const parsed = parseImageDataUrl(b.url)
2044
+ if (parsed) return { type: 'image', data: parsed.data, mimeType: explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png', uri: b.url }
2045
+ return undefined
2046
+ }
2047
+ if (b.url.startsWith('http://') || b.url.startsWith('https://')) {
2048
+ const mimeType = explicitMime || guessImageMimeType(b.url) || 'image/png'
2049
+ return { type: 'image', data: '', mimeType, uri: b.url }
2050
+ }
2051
+ }
2052
+
2053
+ // 7. File path in `path`, `filePath`, or `url: file://...`
2054
+ let filePath: string | undefined
2055
+ if (typeof b.path === 'string' && b.path !== '') filePath = b.path
2056
+ else if (typeof b.filePath === 'string' && b.filePath !== '') filePath = b.filePath
2057
+ else if (typeof b.url === 'string' && (b.url.startsWith('file://') || isLocalFilePath(b.url))) filePath = b.url
2058
+
2059
+ if (filePath !== undefined) {
2060
+ const fileContent = readImageFileFromContext(filePath, context)
2061
+ if (fileContent !== undefined) {
2062
+ let data: string
2063
+ let resolvedMime: string | undefined
2064
+ if (typeof fileContent === 'string') {
2065
+ if (fileContent.startsWith('data:')) {
2066
+ const parsed = parseImageDataUrl(fileContent)
2067
+ if (parsed) {
2068
+ data = parsed.data
2069
+ resolvedMime = parsed.mimeType
2070
+ } else {
2071
+ return undefined
2072
+ }
2073
+ } else {
2074
+ data = fileContent.trim().replace(/\s+/g, '')
2075
+ }
2076
+ } else {
2077
+ data = toBase64(fileContent)
2078
+ }
2079
+ const mimeType = explicitMime ||
2080
+ resolvedMime ||
2081
+ guessImageMimeType(filePath) ||
2082
+ detectImageMimeFromMagic(fileContent) ||
2083
+ (isExplicitImageBlock ? 'image/png' : undefined)
2084
+
2085
+ if (mimeType && mimeType.startsWith('image/')) {
2086
+ let uri = typeof b.url === 'string' ? b.url : undefined
2087
+ if (!uri) {
2088
+ if (filePath.startsWith('file://')) {
2089
+ uri = filePath
2090
+ } else if (isAbsolutePath(filePath)) {
2091
+ if (filePath.startsWith('//') || filePath.startsWith('\\\\')) {
2092
+ uri = `file://${filePath.replace(/^[\/\\]+/, '').replace(/\\/g, '/')}`
2093
+ } else if (/^[a-zA-Z]:/.test(filePath)) {
2094
+ uri = `file:///${filePath.replace(/\\/g, '/')}`
2095
+ } else {
2096
+ uri = `file://${filePath.startsWith('/') ? filePath : `/${filePath}`}`
2097
+ }
2098
+ } else {
2099
+ uri = filePath
2100
+ }
2101
+ }
2102
+ return {
2103
+ type: 'image',
2104
+ data,
2105
+ mimeType,
2106
+ uri,
2107
+ }
2108
+ }
2109
+ }
2110
+ }
2111
+
2112
+ // 8. Attachment resolution via context
2113
+ let attachmentId: string | undefined
2114
+ if (attRecord && typeof attRecord.attachmentId === 'string' && attRecord.attachmentId.trim() !== '') {
2115
+ attachmentId = attRecord.attachmentId.trim()
2116
+ } else if (typeof b.attachmentId === 'string' && b.attachmentId.trim() !== '') {
2117
+ attachmentId = b.attachmentId.trim()
2118
+ } else if (attRecord && typeof attRecord.id === 'string' && attRecord.id.trim() !== '') {
2119
+ attachmentId = attRecord.id.trim()
2120
+ } else if (typeof b.id === 'string' && b.id.trim() !== '') {
2121
+ attachmentId = b.id.trim()
2122
+ } else if (typeof b.attachment === 'string' && b.attachment.trim() !== '') {
2123
+ attachmentId = b.attachment.trim()
2124
+ } else if (typeof b.url === 'string' && b.url.startsWith('attachment:')) {
2125
+ attachmentId = b.url.replace(/^attachment:\/\/|^attachment:/, '')
2126
+ } else if (typeof b.image_url === 'string' && b.image_url.startsWith('attachment:')) {
2127
+ attachmentId = b.image_url.replace(/^attachment:\/\/|^attachment:/, '')
2128
+ } else if (b.image_url && typeof b.image_url === 'object' && typeof (b.image_url as Record<string, unknown>).url === 'string' && ((b.image_url as Record<string, unknown>).url as string).startsWith('attachment:')) {
2129
+ attachmentId = ((b.image_url as Record<string, unknown>).url as string).replace(/^attachment:\/\/|^attachment:/, '')
2130
+ }
2131
+
2132
+ if (attachmentId !== undefined) {
2133
+ const rawAtt = resolveImageAttachmentFromContext(attachmentId, context, b.attachment)
2134
+ if (rawAtt !== undefined) {
2135
+ if (typeof rawAtt === 'string') {
2136
+ if (rawAtt.startsWith('data:')) {
2137
+ const parsed = parseImageDataUrl(rawAtt)
2138
+ if (parsed) {
2139
+ const mimeType = explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png'
2140
+ return {
2141
+ type: 'image',
2142
+ data: parsed.data,
2143
+ mimeType,
2144
+ uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}`,
2145
+ }
2146
+ }
2147
+ return undefined
2148
+ } else if (rawAtt.startsWith('file://') || isLocalFilePath(rawAtt)) {
2149
+ const fileContent = readImageFileFromContext(rawAtt, context)
2150
+ if (fileContent !== undefined) {
2151
+ let data: string
2152
+ let resolvedMime: string | undefined
2153
+ if (typeof fileContent === 'string') {
2154
+ if (fileContent.startsWith('data:')) {
2155
+ const parsed = parseImageDataUrl(fileContent)
2156
+ if (parsed) {
2157
+ data = parsed.data
2158
+ resolvedMime = parsed.mimeType
2159
+ } else {
2160
+ return undefined
2161
+ }
2162
+ } else {
2163
+ data = fileContent.trim().replace(/\s+/g, '')
2164
+ }
2165
+ } else {
2166
+ data = toBase64(fileContent)
2167
+ }
2168
+ const mimeType = explicitMime ||
2169
+ resolvedMime ||
2170
+ (typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
2171
+ guessImageMimeType(rawAtt) ||
2172
+ detectImageMimeFromMagic(fileContent) ||
2173
+ (isExplicitImageBlock ? 'image/png' : undefined)
2174
+ if (mimeType && mimeType.startsWith('image/')) {
2175
+ return { type: 'image', data, mimeType, uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}` }
2176
+ }
2177
+ }
2178
+ }
2179
+ const mimeType = explicitMime ||
2180
+ (typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
2181
+ guessImageMimeType(attachmentId) ||
2182
+ detectImageMimeFromMagic(rawAtt) ||
2183
+ (isExplicitImageBlock ? 'image/png' : undefined)
2184
+ if (mimeType && mimeType.startsWith('image/')) {
2185
+ return { type: 'image', data: rawAtt.trim().replace(/\s+/g, ''), mimeType, uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}` }
2186
+ }
2187
+ }
2188
+ if (typeof rawAtt === 'object' && rawAtt !== null) {
2189
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(rawAtt)) {
2190
+ const mimeType = explicitMime ||
2191
+ (typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
2192
+ guessImageMimeType(attachmentId) ||
2193
+ detectImageMimeFromMagic(rawAtt) ||
2194
+ (isExplicitImageBlock ? 'image/png' : undefined)
2195
+ if (mimeType && mimeType.startsWith('image/')) {
2196
+ return {
2197
+ type: 'image',
2198
+ data: rawAtt.toString('base64'),
2199
+ mimeType,
2200
+ uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}`,
2201
+ }
2202
+ }
2203
+ }
2204
+ if (rawAtt instanceof Uint8Array) {
2205
+ const mimeType = explicitMime ||
2206
+ (typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
2207
+ guessImageMimeType(attachmentId) ||
2208
+ detectImageMimeFromMagic(rawAtt) ||
2209
+ (isExplicitImageBlock ? 'image/png' : undefined)
2210
+ if (mimeType && mimeType.startsWith('image/')) {
2211
+ return {
2212
+ type: 'image',
2213
+ data: toBase64(rawAtt),
2214
+ mimeType,
2215
+ uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}`,
2216
+ }
2217
+ }
2218
+ }
2219
+ const attObj = rawAtt as Record<string, unknown>
2220
+ let data: string | undefined
2221
+ let resolvedMime: string | undefined
2222
+
2223
+ const attPath = typeof attObj.path === 'string' && attObj.path !== ''
2224
+ ? attObj.path
2225
+ : (typeof attObj.filePath === 'string' && attObj.filePath !== ''
2226
+ ? attObj.filePath
2227
+ : (typeof attObj.url === 'string' && (attObj.url.startsWith('file://') || isLocalFilePath(attObj.url)) ? attObj.url : undefined))
2228
+
2229
+ let fileContentForMagic: Buffer | Uint8Array | string | undefined
2230
+
2231
+ if (attPath !== undefined) {
2232
+ const fileContent = readImageFileFromContext(attPath, context)
2233
+ if (fileContent !== undefined) {
2234
+ fileContentForMagic = fileContent
2235
+ if (typeof fileContent === 'string') {
2236
+ if (fileContent.startsWith('data:')) {
2237
+ const parsed = parseImageDataUrl(fileContent)
2238
+ if (parsed) {
2239
+ data = parsed.data
2240
+ resolvedMime = parsed.mimeType
2241
+ }
2242
+ } else {
2243
+ data = fileContent.trim().replace(/\s+/g, '')
2244
+ }
2245
+ } else {
2246
+ data = toBase64(fileContent)
2247
+ }
2248
+ if (!resolvedMime) resolvedMime = guessImageMimeType(attPath)
2249
+ }
2250
+ }
2251
+
2252
+ if (data === undefined) {
2253
+ if (typeof attObj.data === 'string' && attObj.data.trim() !== '') {
2254
+ if (attObj.data.startsWith('data:')) {
2255
+ const parsed = parseImageDataUrl(attObj.data)
2256
+ if (parsed) {
2257
+ data = parsed.data
2258
+ resolvedMime = parsed.mimeType
2259
+ }
2260
+ } else {
2261
+ data = attObj.data.trim().replace(/\s+/g, '')
2262
+ fileContentForMagic = attObj.data
2263
+ }
2264
+ }
2265
+ else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.data)) {
2266
+ data = attObj.data.toString('base64')
2267
+ fileContentForMagic = attObj.data
2268
+ }
2269
+ else if (attObj.data instanceof Uint8Array) {
2270
+ data = toBase64(attObj.data)
2271
+ fileContentForMagic = attObj.data
2272
+ }
2273
+ else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.buffer)) {
2274
+ data = attObj.buffer.toString('base64')
2275
+ fileContentForMagic = attObj.buffer
2276
+ }
2277
+ else if (attObj.buffer instanceof Uint8Array) {
2278
+ data = toBase64(attObj.buffer)
2279
+ fileContentForMagic = attObj.buffer
2280
+ }
2281
+ else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.content)) {
2282
+ data = attObj.content.toString('base64')
2283
+ fileContentForMagic = attObj.content
2284
+ }
2285
+ else if (attObj.content instanceof Uint8Array) {
2286
+ data = toBase64(attObj.content)
2287
+ fileContentForMagic = attObj.content
2288
+ }
2289
+ else if (typeof attObj.content === 'string') {
2290
+ if (attObj.content.startsWith('data:')) {
2291
+ const parsed = parseImageDataUrl(attObj.content)
2292
+ if (parsed) {
2293
+ data = parsed.data
2294
+ resolvedMime = parsed.mimeType
2295
+ }
2296
+ } else {
2297
+ data = attObj.content.trim().replace(/\s+/g, '')
2298
+ fileContentForMagic = attObj.content
2299
+ }
2300
+ }
2301
+ }
2302
+
2303
+ if (data !== undefined) {
2304
+ const mimeType = explicitMime ||
2305
+ (typeof attObj.mediaType === 'string' && attObj.mediaType.toLowerCase()) ||
2306
+ (typeof attObj.mimeType === 'string' && attObj.mimeType.toLowerCase()) ||
2307
+ resolvedMime ||
2308
+ (typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
2309
+ (typeof attObj.name === 'string' ? guessImageMimeType(attObj.name as string) : undefined) ||
2310
+ guessImageMimeType(attachmentId) ||
2311
+ detectImageMimeFromMagic(fileContentForMagic ?? data) ||
2312
+ (isExplicitImageBlock ? 'image/png' : undefined)
2313
+ if (mimeType && mimeType.startsWith('image/')) {
2314
+ return {
2315
+ type: 'image',
2316
+ data,
2317
+ mimeType,
2318
+ uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}`,
2319
+ }
2320
+ }
2321
+ }
2322
+ }
2323
+ }
2324
+ }
2325
+
2326
+ // 9. Direct b.attachment object
2327
+ if (b.attachment && typeof b.attachment === 'object') {
2328
+ const attObj = b.attachment as Record<string, unknown>
2329
+ let data: string | undefined
2330
+ let resolvedMime: string | undefined
2331
+
2332
+ const attPath = typeof attObj.path === 'string' && attObj.path !== ''
2333
+ ? attObj.path
2334
+ : (typeof attObj.filePath === 'string' && attObj.filePath !== ''
2335
+ ? attObj.filePath
2336
+ : (typeof attObj.url === 'string' && (attObj.url.startsWith('file://') || isLocalFilePath(attObj.url)) ? attObj.url : undefined))
2337
+
2338
+ let fileContentForMagic: Buffer | Uint8Array | string | undefined
2339
+
2340
+ if (attPath !== undefined) {
2341
+ const fileContent = readImageFileFromContext(attPath, context)
2342
+ if (fileContent !== undefined) {
2343
+ fileContentForMagic = fileContent
2344
+ if (typeof fileContent === 'string') {
2345
+ if (fileContent.startsWith('data:')) {
2346
+ const parsed = parseImageDataUrl(fileContent)
2347
+ if (parsed) {
2348
+ data = parsed.data
2349
+ resolvedMime = parsed.mimeType
2350
+ }
2351
+ } else {
2352
+ data = fileContent.trim().replace(/\s+/g, '')
2353
+ }
2354
+ } else {
2355
+ data = toBase64(fileContent)
2356
+ }
2357
+ if (!resolvedMime) resolvedMime = guessImageMimeType(attPath)
2358
+ }
2359
+ }
2360
+
2361
+ if (data === undefined) {
2362
+ if (typeof attObj.data === 'string' && attObj.data.trim() !== '') {
2363
+ if (attObj.data.startsWith('data:')) {
2364
+ const parsed = parseImageDataUrl(attObj.data)
2365
+ if (parsed) {
2366
+ data = parsed.data
2367
+ resolvedMime = parsed.mimeType
2368
+ }
2369
+ } else {
2370
+ data = attObj.data.trim().replace(/\s+/g, '')
2371
+ fileContentForMagic = attObj.data
2372
+ }
2373
+ }
2374
+ else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.data)) {
2375
+ data = attObj.data.toString('base64')
2376
+ fileContentForMagic = attObj.data
2377
+ }
2378
+ else if (attObj.data instanceof Uint8Array) {
2379
+ data = toBase64(attObj.data)
2380
+ fileContentForMagic = attObj.data
2381
+ }
2382
+ else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.buffer)) {
2383
+ data = attObj.buffer.toString('base64')
2384
+ fileContentForMagic = attObj.buffer
2385
+ }
2386
+ else if (attObj.buffer instanceof Uint8Array) {
2387
+ data = toBase64(attObj.buffer)
2388
+ fileContentForMagic = attObj.buffer
2389
+ }
2390
+ else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.content)) {
2391
+ data = attObj.content.toString('base64')
2392
+ fileContentForMagic = attObj.content
2393
+ }
2394
+ else if (attObj.content instanceof Uint8Array) {
2395
+ data = toBase64(attObj.content)
2396
+ fileContentForMagic = attObj.content
2397
+ }
2398
+ else if (typeof attObj.content === 'string') {
2399
+ if (attObj.content.startsWith('data:')) {
2400
+ const parsed = parseImageDataUrl(attObj.content)
2401
+ if (parsed) {
2402
+ data = parsed.data
2403
+ resolvedMime = parsed.mimeType
2404
+ }
2405
+ } else {
2406
+ data = attObj.content.trim().replace(/\s+/g, '')
2407
+ fileContentForMagic = attObj.content
2408
+ }
2409
+ }
2410
+ }
2411
+
2412
+ if (data !== undefined) {
2413
+ const mimeType = explicitMime ||
2414
+ (typeof attObj.mediaType === 'string' && attObj.mediaType.toLowerCase()) ||
2415
+ (typeof attObj.mimeType === 'string' && attObj.mimeType.toLowerCase()) ||
2416
+ resolvedMime ||
2417
+ (typeof attObj.name === 'string' ? guessImageMimeType(attObj.name as string) : undefined) ||
2418
+ (attPath ? guessImageMimeType(attPath) : undefined) ||
2419
+ detectImageMimeFromMagic(fileContentForMagic ?? data) ||
2420
+ (isExplicitImageBlock ? 'image/png' : undefined)
2421
+ if (mimeType && mimeType.startsWith('image/')) {
2422
+ return { type: 'image', data, mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
2423
+ }
2424
+ }
2425
+ }
2426
+
2427
+ return undefined
2428
+ }
1366
2429
  export type ForwardPromptSelection =
1367
- | { kind: 'prompt'; text: string; id?: string }
2430
+ | {
2431
+ kind: 'prompt'
2432
+ text: string
2433
+ id?: string
2434
+ images?: PromptImageContent[]
2435
+ content?: PromptContentBlock[]
2436
+ }
1368
2437
  | { kind: 'no-user-input' }
1369
2438
  | { kind: 'empty' }
1370
2439
 
@@ -1381,28 +2450,72 @@ export type ForwardPromptSelection =
1381
2450
  * (tests, headless callers) and counts as user input.
1382
2451
  *
1383
2452
  * @param messages - the request's ordered messages, exactly as the host built them.
1384
- * @returns the last real user message's text, or why there is none to forward.
2453
+ * @param context - optional host context or attachment resolver for reading attachments.
2454
+ * @returns the last real user message's text and any image content blocks, or why there is none to forward.
1385
2455
  */
1386
2456
  export function selectForwardPrompt(messages: readonly {
1387
2457
  role: string
1388
- content?: Array<{ type?: string; text?: string }>
2458
+ content?: string | Array<{ type?: string; text?: string; [k: string]: unknown }> | { type?: string; text?: string; [k: string]: unknown }
1389
2459
  source?: { kind?: string }
1390
2460
  id?: string
1391
- }[]): ForwardPromptSelection {
2461
+ }[], context?: unknown): ForwardPromptSelection {
1392
2462
  let sawUserRole = false
1393
- let last: { text: string; id?: string } | undefined
2463
+ let last: {
2464
+ text: string
2465
+ id?: string
2466
+ images?: PromptImageContent[]
2467
+ content?: PromptContentBlock[]
2468
+ } | undefined
1394
2469
  for (const message of messages) {
1395
2470
  if (message.role !== 'user') continue
1396
2471
  sawUserRole = true
1397
2472
  const kind = message.source?.kind
1398
2473
  if (kind !== undefined && kind !== 'user') continue
1399
- const text = (message.content ?? [])
2474
+
2475
+ const content = message.content ?? (message as { parts?: unknown }).parts
2476
+ const rawBlocks: Array<Record<string, unknown>> = Array.isArray(content)
2477
+ ? (content as Array<Record<string, unknown>>)
2478
+ : (typeof content === 'string'
2479
+ ? [{ type: 'text', text: content }]
2480
+ : (typeof content === 'object' && content !== null ? [content as Record<string, unknown>] : []))
2481
+
2482
+ const text = rawBlocks
1400
2483
  .filter(block => block.type === 'text' && typeof block.text === 'string')
1401
2484
  .map(block => block.text as string)
1402
2485
  .join('\n')
1403
2486
  .trim()
1404
- if (text === '') continue
1405
- last = { text, ...(message.id === undefined ? {} : { id: message.id }) }
2487
+
2488
+ const images: PromptImageContent[] = []
2489
+ const contentBlocks: PromptContentBlock[] = []
2490
+
2491
+ for (const block of rawBlocks) {
2492
+ if (!block || typeof block !== 'object') continue
2493
+ if (block.type === 'text' && typeof block.text === 'string') {
2494
+ if (block.text.trim() !== '') {
2495
+ contentBlocks.push({ type: 'text', text: block.text })
2496
+ }
2497
+ } else {
2498
+ const resolved = resolveImageContent(block, context)
2499
+ if (resolved !== undefined) {
2500
+ images.push(resolved)
2501
+ contentBlocks.push(resolved)
2502
+ }
2503
+ }
2504
+ }
2505
+
2506
+ if (text === '' && images.length === 0) continue
2507
+
2508
+ const item: {
2509
+ text: string
2510
+ id?: string
2511
+ images?: PromptImageContent[]
2512
+ content?: PromptContentBlock[]
2513
+ } = { text, ...(message.id === undefined ? {} : { id: message.id }) }
2514
+ if (images.length > 0) {
2515
+ item.images = images
2516
+ item.content = contentBlocks
2517
+ }
2518
+ last = item
1406
2519
  }
1407
2520
  if (last !== undefined) return { kind: 'prompt', ...last }
1408
2521
  return sawUserRole ? { kind: 'no-user-input' } : { kind: 'empty' }
@@ -1552,7 +2665,34 @@ export function acpPresetStatusFromRoster(rows: readonly RosterPresetRow[] | und
1552
2665
 
1553
2666
  // ---- Transcript ----
1554
2667
 
1555
- /** Bounded append-only log of raw ACP session updates with monotone seq. */
2668
+ /**
2669
+ * The kind of a state-only tick that consecutive repeats may share one entry.
2670
+ *
2671
+ * These are the frames the client folds to nothing (`STATE_ONLY_UPDATES`):
2672
+ * only their latest value is ever read. `session_info_update` qualifies only
2673
+ * while it carries no title — a title is content the replay must keep, so it
2674
+ * never lets a later tick replace its entry.
2675
+ */
2676
+ function coalescibleStateKind(update: unknown): string | undefined {
2677
+ if (typeof update !== 'object' || update === null || Array.isArray(update)) return undefined
2678
+ const row = update as Record<string, unknown>
2679
+ const kind = row.sessionUpdate
2680
+ if (kind === 'usage_update' || kind === 'config_option_update') return kind
2681
+ if (kind !== 'session_info_update') return undefined
2682
+ const title = row.title
2683
+ return title === undefined || title === null || title === '' ? kind : undefined
2684
+ }
2685
+
2686
+ /**
2687
+ * Bounded log of raw ACP session updates with monotone seq.
2688
+ *
2689
+ * Consecutive ticks of the same state-only kind share one entry (the entry is
2690
+ * replaced in place, not appended): chatty agents emit a usage / phase tick
2691
+ * every few hundred milliseconds, and letting each occupy the ring evicts the
2692
+ * conversation itself. This costs nothing on the live path — `append` still
2693
+ * allocates a fresh seq and the bridge broadcasts that frame — but replay via
2694
+ * `since` only sees the newest tick of each run.
2695
+ */
1556
2696
  export class Transcript {
1557
2697
  private readonly entries: Array<{ seq: number; update: unknown }> = []
1558
2698
  private next = 1
@@ -1561,6 +2701,16 @@ export class Transcript {
1561
2701
 
1562
2702
  append(update: unknown): number {
1563
2703
  const seq = this.next++
2704
+ const last = this.entries[this.entries.length - 1]
2705
+ const kind = coalescibleStateKind(update)
2706
+ // Replace, don't push: `since(afterSeq)` keeps its filter, so intermediate
2707
+ // seq values are simply absent from replayed entries. Live SSE frames are
2708
+ // broadcast per append (the returned seq), and the client's live fold does
2709
+ // not depend on transcript contiguity — only `head` must keep advancing.
2710
+ if (kind !== undefined && last !== undefined && coalescibleStateKind(last.update) === kind) {
2711
+ this.entries[this.entries.length - 1] = { seq, update }
2712
+ return seq
2713
+ }
1564
2714
  this.entries.push({ seq, update })
1565
2715
  if (this.entries.length > this.max) this.entries.splice(0, this.entries.length - this.max)
1566
2716
  return seq
@@ -1754,7 +2904,7 @@ export function permissionReplayFrames(pending: readonly PendingPermissionView[]
1754
2904
  export interface AcpManager {
1755
2905
  agentViews(): AgentView[]
1756
2906
  startSession(agentId: string, cwd: string | undefined): Promise<{ sessionId: string }>
1757
- prompt(sessionId: string, text: string): Promise<{ turnId: string }>
2907
+ prompt(sessionId: string, prompt: string | PromptContentBlock[]): Promise<{ turnId: string }>
1758
2908
  cancel(sessionId: string): Promise<void>
1759
2909
  closeSession(sessionId: string): Promise<void>
1760
2910
  setMode(sessionId: string, modeId: string): Promise<void>
@@ -1789,6 +2939,19 @@ export interface AcpManager {
1789
2939
  * applies again. Applies to the running bridges without a restart.
1790
2940
  */
1791
2941
  setApprovalDeadlines?(patch: ApprovalDeadlinePatch, reset?: boolean): Promise<void>
2942
+ /**
2943
+ * The idle-release window in force for chat processes, plus the built-in
2944
+ * default a reset returns to. `undefined` on a host half from before the
2945
+ * card existed, which hides the settings tab instead of showing a number
2946
+ * this browser invented.
2947
+ */
2948
+ idleRelease?(): AcpIdleRelease | undefined
2949
+ /**
2950
+ * Write the idle-release window (in ms), or drop the persisted
2951
+ * override with `reset` so the built-in default applies again. Applies to
2952
+ * the running bridges without a restart.
2953
+ */
2954
+ setIdleRelease?(idleTimeoutMs: number | undefined, reset?: boolean): Promise<void>
1792
2955
  detectAgents?(): Promise<DetectedAgentInfo[]>
1793
2956
  saveAgent?(def: AcpAgentDef): Promise<void>
1794
2957
  deleteAgent?(agentId: string): Promise<void>
@@ -1861,6 +3024,21 @@ export interface AcpManager {
1861
3024
  * without it the live (non-released) session is picked.
1862
3025
  */
1863
3026
  setChatSessionMode?(dshSessionId: string, modeId: string, agentId?: string): Promise<void>
3027
+ /**
3028
+ * Bring one chat conversation's released session back on a fresh process on
3029
+ * the user's explicit gesture (`session/chat-resume`, the released-session
3030
+ * banner's button). Same recovery the next prompt performs lazily, minus
3031
+ * the create path; no-op when the session is already live.
3032
+ */
3033
+ resumeChatSession?(dshSessionId: string): Promise<void>
3034
+ /**
3035
+ * Adopt the ACP side of a branched conversation (`session/chat-fork-adopt`):
3036
+ * the browser has already asked the HOST to fork the DSH session tree, and
3037
+ * this runs the agent's own `session/fork` of the parent's live ACP session,
3038
+ * then binds the child to it. All-or-nothing — nothing is bound on failure,
3039
+ * so the child stays an ordinary copied-history conversation.
3040
+ */
3041
+ adoptForkedSession?(childDshSessionId: string, parentDshSessionId: string): Promise<{ sessionId: string; acpSessionId: string }>
1864
3042
  /**
1865
3043
  * Point one DSH chat conversation at an ACP agent — the plugin's own
1866
3044
  * conversation↔agent mapping, never a DSH model route (see
@@ -1979,7 +3157,37 @@ export async function dispatchRpc(manager: AcpManager, endpoint: string, payload
1979
3157
  case 'session/start':
1980
3158
  return ok(await manager.startSession(str(body, 'agentId', 64), optionalStr(body, 'cwd', 4096)))
1981
3159
  case 'session/prompt': {
1982
- const { turnId } = await manager.prompt(str(body, 'sessionId', 64), str(body, 'text', 1 << 20))
3160
+ let promptInput: string | PromptContentBlock[]
3161
+ if (Array.isArray(body.prompt)) {
3162
+ if (body.prompt.length === 0) {
3163
+ throw new AcpError('bad_payload', 'acp: prompt array cannot be empty')
3164
+ }
3165
+ for (const b of body.prompt) {
3166
+ if (!b || typeof b !== 'object') throw new AcpError('bad_payload', 'acp: prompt blocks must be objects')
3167
+ const block = b as Record<string, unknown>
3168
+ if (block.type === 'text') {
3169
+ if (typeof block.text !== 'string') throw new AcpError('bad_payload', 'acp: text block requires string text')
3170
+ if (block.text.length > (1 << 20)) throw new AcpError('bad_payload', 'acp: text block exceeds max length')
3171
+ } else if (block.type === 'image') {
3172
+ if (typeof block.data !== 'string' || typeof block.mimeType !== 'string') {
3173
+ throw new AcpError('bad_payload', 'acp: image block requires string data and mimeType')
3174
+ }
3175
+ if (!block.mimeType.toLowerCase().startsWith('image/')) {
3176
+ throw new AcpError('bad_payload', 'acp: image block mimeType must start with image/')
3177
+ }
3178
+ if (block.uri !== undefined && typeof block.uri !== 'string') {
3179
+ throw new AcpError('bad_payload', 'acp: image block uri must be string')
3180
+ }
3181
+ if (block.data.length > (20 << 20)) throw new AcpError('bad_payload', 'acp: image block exceeds max data length')
3182
+ } else {
3183
+ throw new AcpError('bad_payload', `acp: unsupported prompt block type: ${String(block.type)}`)
3184
+ }
3185
+ }
3186
+ promptInput = body.prompt as PromptContentBlock[]
3187
+ } else {
3188
+ promptInput = str(body, 'text', 1 << 20)
3189
+ }
3190
+ const { turnId } = await manager.prompt(str(body, 'sessionId', 64), promptInput)
1983
3191
  return ok({ accepted: true as const, turnId })
1984
3192
  }
1985
3193
  case 'session/cancel':
@@ -2055,6 +3263,26 @@ export async function dispatchRpc(manager: AcpManager, endpoint: string, payload
2055
3263
  await manager.setChatSessionMode?.(dshSessionId, modeId, optionalStr(body, 'agentId', 64))
2056
3264
  return ok({ done: true })
2057
3265
  }
3266
+ case 'session/chat-resume': {
3267
+ // The released-session banner's explicit resume: the recovery the next
3268
+ // prompt performs lazily, on the user's gesture instead. Addressed by
3269
+ // DSH chat session id, like the rest of the conversation surfaces.
3270
+ const dshSessionId = str(body, 'dshSessionId', 128)
3271
+ await manager.resumeChatSession?.(dshSessionId)
3272
+ return ok({ done: true })
3273
+ }
3274
+ case 'session/chat-fork-adopt': {
3275
+ // The second half of a branched conversation: bind the DSH session the
3276
+ // host's `sessions.fork` just created to the agent's own `session/fork`
3277
+ // of the parent's live ACP session. Both ids are validated before the
3278
+ // optional call — the same shape rule as `session/chat-list` above.
3279
+ // The adopted ids ride the answer (the protocol's ChatForkAdoptResult);
3280
+ // a manager without the method keeps the plain `done` shape.
3281
+ const dshSessionId = str(body, 'dshSessionId', 128)
3282
+ const parentDshSessionId = str(body, 'parentDshSessionId', 128)
3283
+ const adopted = await manager.adoptForkedSession?.(dshSessionId, parentDshSessionId)
3284
+ return ok(adopted ?? { done: true })
3285
+ }
2058
3286
  case 'session/chat-bind': {
2059
3287
  // Point one chat conversation at an ACP agent. The plugin's own
2060
3288
  // conversation↔agent mapping — NOT a DSH model route (see
@@ -2141,6 +3369,16 @@ export async function dispatchRpc(manager: AcpManager, endpoint: string, payload
2141
3369
  await manager.setApprovalDeadlines?.(patch, body.reset === true)
2142
3370
  return ok({ deadlines: manager.approvalDeadlines?.() })
2143
3371
  }
3372
+ case 'ui/idle-release':
3373
+ // Absent on a host half from before the tab existed: the browser then
3374
+ // renders nothing rather than a number it cannot trust.
3375
+ return ok({ idleRelease: manager.idleRelease?.() })
3376
+ case 'ui/idle-release-set': {
3377
+ const reset = body.reset === true
3378
+ const idleTimeoutMs = reset ? undefined : validateIdleReleaseMs(body.idleTimeoutMs, 'bad_payload')
3379
+ await manager.setIdleRelease?.(idleTimeoutMs, reset)
3380
+ return ok({ idleRelease: manager.idleRelease?.() })
3381
+ }
2144
3382
  case 'agent/set-exposed-models': {
2145
3383
  const agentId = str(body, 'agentId', 64)
2146
3384
  const raw = body.exposedModels