mixdog 0.9.70 → 0.9.71

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.70",
3
+ "version": "0.9.71",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -87,6 +87,7 @@
87
87
  "test:workflow-editor": "node --test scripts/workflow-id-test.mjs scripts/workflow-pack-editor-test.mjs",
88
88
  "test:route-scope": "node --test scripts/route-scope-isolation-test.mjs",
89
89
  "test:schedule-reload": "node --test scripts/schedule-reload-arm-test.mjs",
90
+ "test:media": "node --test src/runtime/media/store.test.mjs",
90
91
  "failures": "node scripts/tool-failures.mjs",
91
92
  "trace:llm": "node scripts/llm-trace-summary.mjs",
92
93
  "diag:sessions": "node scripts/session-diag.mjs",
@@ -706,8 +706,22 @@ export function enqueueRemotePendingMessage(sessionId, message) {
706
706
  }
707
707
 
708
708
  // Spool-file mtime gate so the owner's idle poller costs one stat per tick,
709
- // not a locked read-modify-write.
710
- let _foreignSpoolScanMtime = 0;
709
+ // not a locked read-modify-write. Keyed PER SESSION: one process can own
710
+ // several sessions (desktop tabs, TUI + engine hosts) and a single shared
711
+ // counter let the first drain of a tick swallow the mtime bump for every
712
+ // other session, stranding their foreign submits until the next spool write.
713
+ const FOREIGN_SPOOL_SCAN_LIMIT = 64;
714
+ const _foreignSpoolScanMtimes = new Map();
715
+
716
+ function _rememberForeignSpoolScan(sessionId, mtime) {
717
+ _foreignSpoolScanMtimes.delete(sessionId);
718
+ _foreignSpoolScanMtimes.set(sessionId, mtime);
719
+ while (_foreignSpoolScanMtimes.size > FOREIGN_SPOOL_SCAN_LIMIT) {
720
+ const oldest = _foreignSpoolScanMtimes.keys().next().value;
721
+ if (oldest === undefined) break;
722
+ _foreignSpoolScanMtimes.delete(oldest);
723
+ }
724
+ }
711
725
 
712
726
  /**
713
727
  * Owner-side drain of FOREIGN user injections for a session this process
@@ -721,8 +735,8 @@ export function drainForeignUserInjections(sessionId) {
721
735
  if (!isValidPendingSessionId(sessionId)) return [];
722
736
  let mtime = 0;
723
737
  try { mtime = statSync(pendingMessagesPath()).mtimeMs || 0; } catch { return []; }
724
- if (mtime === _foreignSpoolScanMtime) return [];
725
- _foreignSpoolScanMtime = mtime;
738
+ if (_foreignSpoolScanMtimes.get(sessionId) === mtime) return [];
739
+ _rememberForeignSpoolScan(sessionId, mtime);
726
740
  const localIds = new Set();
727
741
  for (const map of [_sessionPendingMessages, _pendingPersistBuffers, _hydratedPendingMessages]) {
728
742
  for (const entry of map.get(sessionId) || []) {
@@ -6,7 +6,7 @@
6
6
  import { getProvider } from '../../providers/registry.mjs';
7
7
  import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
8
8
  import { collectPromptSkillsCached, buildSkillManifest, composeSystemPrompt } from '../../context/collect.mjs';
9
- import { saveSession, saveSessionAsync, saveSessionAsyncDeferred, loadSession, setLiveSession, readSessionHeartbeatMtime, readSessionPresenceMtime, isSessionPresenceOwnerDead, deleteSessionPresence } from '../store.mjs';
9
+ import { saveSession, saveSessionAsync, saveSessionAsyncDeferred, loadSession, setLiveSession, readSessionHeartbeatMtime, readSessionPresenceMtime, isSessionPresenceOwnerDead, deleteSessionPresence, isSessionHeartbeatOwnerDead, readSessionHeartbeatOwnerPid, deleteHeartbeat, isProcessAlive } from '../store.mjs';
10
10
  import { _getRuntimeEntry } from './runtime-liveness.mjs';
11
11
  import { isAgentOwner } from '../../agent-owner.mjs';
12
12
  import { getHiddenAgent } from '../../internal-agents.mjs';
@@ -547,6 +547,18 @@ export function prefetchSession(sessionId, preset = 'full') {
547
547
  return true;
548
548
  }
549
549
 
550
+ // Owner-pid hint for liveness signals that carry NO pid of their own:
551
+ // `session.lastHeartbeatAt` is persisted in the session file and therefore
552
+ // survives its writer forever, and pre-pid `.hb` sidecars only hold a
553
+ // timestamp. The recorded client host is the process that created/claimed the
554
+ // runtime for this session; the session-id prefix is the legacy fallback.
555
+ function _recordedOwnerPid(session, sessionId) {
556
+ const recorded = Number(session?.clientHostPid) || 0;
557
+ if (recorded > 0) return recorded;
558
+ const match = /^sess_(\d+)_/.exec(String(sessionId || ''));
559
+ return Number(match?.[1]) || 0;
560
+ }
561
+
550
562
  function _isActivelyOwnedElsewhere(session, sessionId) {
551
563
  // This process already owns the runtime for the id — switching back to
552
564
  // one of our own sessions (desktop tab switch, TUI /resume) never attaches.
@@ -560,19 +572,40 @@ function _isActivelyOwnedElsewhere(session, sessionId) {
560
572
  deleteSessionPresence(sessionId);
561
573
  return false;
562
574
  }
575
+ const now = Date.now();
576
+ // Presence (`.own`, pid-verified just above) covers the idle gaps between
577
+ // turns: a live interactive surface keeps refreshing it (~20s) for its
578
+ // CURRENT session, so cross-opening an idle-but-open session still
579
+ // attaches as a viewer instead of splitting ownership into two writers
580
+ // that clobber each other's saves.
581
+ const presenceAt = Number(readSessionPresenceMtime(sessionId)) || 0;
582
+ if (presenceAt > 0 && now - presenceAt <= ACTIVE_OWNER_HB_FRESH_MS) return true;
563
583
  // Heartbeats publish only while a turn is running (≤5s cadence) and the
564
584
  // sidecar is deleted on detach/close, so freshness here means another
565
- // process is mid-conversation on this session right now. Presence (`.own`)
566
- // covers the idle gaps between turns: a live interactive surface keeps
567
- // refreshing it (~20s) for its CURRENT session, so cross-opening an
568
- // idle-but-open session still attaches as a viewer instead of splitting
569
- // ownership into two writers that clobber each other's saves.
570
- const lastHb = Math.max(
571
- Number(readSessionHeartbeatMtime(sessionId)) || 0,
572
- Number(session.lastHeartbeatAt) || 0,
573
- Number(readSessionPresenceMtime(sessionId)) || 0,
574
- );
575
- return lastHb > 0 && Date.now() - lastHb <= ACTIVE_OWNER_HB_FRESH_MS;
585
+ // process is mid-conversation on this session right now PROVIDED that
586
+ // process still exists. Without the pid check a force-killed owner (app
587
+ // upgrade restart, crash) kept looking live for the whole freshness
588
+ // window, so every cross-open attached as a viewer and the user's
589
+ // messages spooled to a queue nobody drains (silently dropped 30m later).
590
+ if (isSessionHeartbeatOwnerDead(sessionId)) {
591
+ void deleteHeartbeat(sessionId);
592
+ return false;
593
+ }
594
+ const sidecarAt = Number(readSessionHeartbeatMtime(sessionId)) || 0;
595
+ if (sidecarAt > 0 && now - sidecarAt <= ACTIVE_OWNER_HB_FRESH_MS
596
+ && readSessionHeartbeatOwnerPid(sessionId) > 0) {
597
+ // Fresh sidecar whose recorded pid is alive: a real owner is driving
598
+ // this session right now, whatever the session file remembers.
599
+ return true;
600
+ }
601
+ const heartbeatAt = Math.max(sidecarAt, Number(session.lastHeartbeatAt) || 0);
602
+ if (!(heartbeatAt > 0 && now - heartbeatAt <= ACTIVE_OWNER_HB_FRESH_MS)) return false;
603
+ // Pid-less evidence only (persisted field / legacy sidecar): fall back to
604
+ // the recorded client-host pid. A dead host means no owner; an unknown pid
605
+ // keeps the conservative attach.
606
+ const ownerPid = _recordedOwnerPid(session, sessionId);
607
+ if (ownerPid > 0 && !isProcessAlive(ownerPid)) return false;
608
+ return true;
576
609
  }
577
610
 
578
611
  // Viewer self-heal probe: true when a re-resume of this session would NO
@@ -10,6 +10,7 @@ import { resolveAgentTerminalReapMs } from '../../../../../session-runtime/confi
10
10
  import { getStoreDir, sessionPath } from './paths-heartbeat.mjs';
11
11
  import { isCancelledWrite as _isCancelledWrite } from './write-guards.mjs';
12
12
  import {
13
+ SESSION_SUMMARY_INDEX_VERSION,
13
14
  summaryIndexPath,
14
15
  _sessionSummary,
15
16
  _normalizeSummaryIndex,
@@ -19,13 +19,40 @@ export function sessionPath(id) {
19
19
  return join(getStoreDir(), `${id}.json`);
20
20
  }
21
21
 
22
+ // ── Sidecar owner-pid helpers ─────────────────────────────
23
+ // Liveness sidecars record the publishing process id on their SECOND line so
24
+ // a reader can tell a genuinely live owner from a force-killed one whose
25
+ // timestamp still looks fresh. Legacy (pid-less) sidecars read back as 0.
26
+ export function isProcessAlive(pid) {
27
+ const target = Number(pid) || 0;
28
+ if (target <= 0) return false;
29
+ try {
30
+ process.kill(target, 0);
31
+ return true;
32
+ } catch (error) {
33
+ // ESRCH: no such process. EPERM means the pid exists (alive).
34
+ return error?.code !== 'ESRCH';
35
+ }
36
+ }
37
+
38
+ function _readSidecarOwnerPid(path) {
39
+ try {
40
+ if (!existsSync(path)) return 0;
41
+ return Number(String(readFileSync(path, 'utf8')).split('\n')[1]) || 0;
42
+ } catch {
43
+ return 0;
44
+ }
45
+ }
46
+
22
47
  // ── Heartbeat publish ─────────────────────────────────────
23
48
  // Lightweight per-session timestamp file (`<id>.hb`) consumed by the
24
49
  // status aggregator for fresh-session detection. Decoupled from the
25
50
  // full session JSON save so it can fire at a tight cadence (≤5s)
26
- // without serialising the whole payload. The .hb file holds a single
27
- // ASCII line: `<msTimestamp>\n`. Aggregator scans the same sessions/
28
- // directory and matches `<id>.hb` to `<id>.json`.
51
+ // without serialising the whole payload. The .hb file holds
52
+ // `<msTimestamp>\n<pid>\n` readers that only need liveness use the file
53
+ // mtime, while the attach-on-resume guard verifies the recorded pid.
54
+ // Aggregator scans the same sessions/ directory and matches `<id>.hb` to
55
+ // `<id>.json`.
29
56
  const _HEARTBEAT_THROTTLE_MS = 5_000;
30
57
  const _hbLastAt = new Map();
31
58
  const _hbOperations = new Map();
@@ -56,7 +83,7 @@ export function publishHeartbeat(id, ts) {
56
83
  }
57
84
  const target = _heartbeatPath(id);
58
85
  _hbLastAt.set(id, now);
59
- return _queueHeartbeatOperation(id, () => fsp.writeFile(target, `${now}\n`, 'utf8'));
86
+ return _queueHeartbeatOperation(id, () => fsp.writeFile(target, `${now}\n${process.pid}\n`, 'utf8'));
60
87
  }
61
88
 
62
89
  export function deleteHeartbeat(id) {
@@ -91,6 +118,24 @@ export function listSessionHeartbeatMtimes() {
91
118
  return result;
92
119
  }
93
120
 
121
+ // Owner pid recorded in the `<id>.hb` sidecar (0 when absent or written by a
122
+ // pre-pid build).
123
+ export function readSessionHeartbeatOwnerPid(id) {
124
+ if (!id) return 0;
125
+ try { return _readSidecarOwnerPid(_heartbeatPath(id)); } catch { return 0; }
126
+ }
127
+
128
+ // A killed owner never deletes its `.hb` sidecar, so bare mtime freshness
129
+ // would keep the session "actively driven" for the whole freshness window and
130
+ // every cross-open would attach as a viewer whose submits spool to nobody.
131
+ // The recorded pid is authoritative proof; pid-less legacy sidecars return
132
+ // false (unknown, not dead).
133
+ export function isSessionHeartbeatOwnerDead(id) {
134
+ const pid = readSessionHeartbeatOwnerPid(id);
135
+ if (!pid) return false;
136
+ return !isProcessAlive(pid);
137
+ }
138
+
94
139
  // ── Interactive presence publish ──────────────────────────
95
140
  // `<id>.own` marks a live interactive surface (TUI/desktop engine) HOLDING
96
141
  // the session open — including idle time between turns. Kept separate from
@@ -144,19 +189,7 @@ export function readSessionPresenceMtime(id) {
144
189
  export function isSessionPresenceOwnerDead(id) {
145
190
  if (!id) return false;
146
191
  let pid = 0;
147
- try {
148
- const path = _presencePath(id);
149
- if (!existsSync(path)) return false;
150
- pid = Number(String(readFileSync(path, 'utf8')).split('\n')[1]) || 0;
151
- } catch {
152
- return false;
153
- }
192
+ try { pid = _readSidecarOwnerPid(_presencePath(id)); } catch { return false; }
154
193
  if (!pid) return false;
155
- try {
156
- process.kill(pid, 0);
157
- return false;
158
- } catch (error) {
159
- // ESRCH: no such process. EPERM means the pid exists (alive).
160
- return error?.code === 'ESRCH';
161
- }
194
+ return !isProcessAlive(pid);
162
195
  }
@@ -54,6 +54,9 @@ export {
54
54
  deleteSessionPresence,
55
55
  readSessionPresenceMtime,
56
56
  isSessionPresenceOwnerDead,
57
+ readSessionHeartbeatOwnerPid,
58
+ isSessionHeartbeatOwnerDead,
59
+ isProcessAlive,
57
60
  } from './store/paths-heartbeat.mjs';
58
61
  import { _readStoredSessionCached } from './store/load-cache.mjs';
59
62
  import { _sessionForDisk, _renameWithRetrySync, _ensureLifecycleFields, _storedSessionFromFile } from './store/serialize.mjs';
@@ -10,6 +10,7 @@ export { startMediaJob, getMediaJob, listMediaJobs, cancelMediaJob } from './job
10
10
  export {
11
11
  listMediaAssets,
12
12
  readMediaAsset,
13
+ resolveMediaFile,
13
14
  deleteMediaAsset,
14
15
  mediaAssetPath,
15
16
  openMediaAsset,
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Derived renditions for the media gallery (thumb / display / video poster).
3
+ *
4
+ * A gallery tile is ~512px and a detail view never exceeds the viewport, so
5
+ * the original bytes are the wrong unit of transfer: over the remote link one
6
+ * full-size still costs more than the entire visible grid. Renditions are
7
+ * generated once, cached on disk beside the assets, and afterwards read back
8
+ * as small files.
9
+ *
10
+ * sharp (stills) and ffmpeg (video posters) are OPTIONAL. When neither can
11
+ * produce a rendition this module returns null and the caller reduces the
12
+ * feature (glyph tile) or explicitly asks for the original. It never silently
13
+ * substitutes full-size bytes for a thumbnail — that hidden path is exactly
14
+ * what made the remote gallery slow.
15
+ */
16
+ import { spawn } from 'child_process';
17
+ import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'fs';
18
+ import { dirname, join } from 'path';
19
+
20
+ import { resolvePluginData } from '../shared/plugin-paths.mjs';
21
+
22
+ // maxEdge is the long edge in pixels; quality is the webp setting.
23
+ const SPECS = {
24
+ thumb: { maxEdge: 512, quality: 70 },
25
+ display: { maxEdge: 2048, quality: 82 },
26
+ };
27
+ // Probe order when reading a cached rendition: sharp writes webp, the
28
+ // ffmpeg-only poster path writes png.
29
+ const EXTENSION_MIME = { '.webp': 'image/webp', '.png': 'image/png' };
30
+ const POSTER_TIMEOUT_MS = 20_000;
31
+ const MAX_POSTER_BYTES = 32 * 1024 * 1024;
32
+
33
+ export const MEDIA_VARIANTS = Object.keys(SPECS);
34
+
35
+ /** Spec for a variant name, or null when the caller asked for the original. */
36
+ export function renditionSpec(variant) {
37
+ return SPECS[String(variant || '')] || null;
38
+ }
39
+
40
+ let sharpPromise;
41
+ async function loadSharp() {
42
+ sharpPromise ??= import('sharp').then((mod) => mod?.default || mod || null, () => null);
43
+ return sharpPromise;
44
+ }
45
+
46
+ let ffmpegPromise;
47
+ // The managed voice runtime already installs ffmpeg; reuse it rather than
48
+ // shipping a second copy. MIXDOG_FFMPEG_PATH overrides for hosts that have
49
+ // their own build.
50
+ async function resolveFfmpeg() {
51
+ ffmpegPromise ??= (async () => {
52
+ const explicit = String(process.env.MIXDOG_FFMPEG_PATH || '').trim();
53
+ if (explicit && existsSync(explicit)) return explicit;
54
+ try {
55
+ const { resolveVoiceRuntime } = await import('../channels/lib/voice-runtime-fetcher.mjs');
56
+ const runtime = resolveVoiceRuntime(resolvePluginData());
57
+ return runtime?.ffmpegPath && existsSync(runtime.ffmpegPath) ? runtime.ffmpegPath : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ })();
62
+ return ffmpegPromise;
63
+ }
64
+
65
+ function renditionPath(cacheDir, variant, id, extension) {
66
+ return join(cacheDir, variant, `${id}${extension}`);
67
+ }
68
+
69
+ function cachedRendition(cacheDir, variant, id) {
70
+ for (const [extension, mime] of Object.entries(EXTENSION_MIME)) {
71
+ const path = renditionPath(cacheDir, variant, id, extension);
72
+ if (existsSync(path)) return { path, mime, bytes: statSync(path).size };
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /** Drop every cached rendition of one asset (called when the asset dies). */
78
+ export function removeRenditions(cacheDir, id) {
79
+ for (const variant of MEDIA_VARIANTS) {
80
+ for (const extension of Object.keys(EXTENSION_MIME)) {
81
+ try { unlinkSync(renditionPath(cacheDir, variant, id, extension)); } catch { /* absent */ }
82
+ }
83
+ }
84
+ }
85
+
86
+ function writeRendition(cacheDir, variant, id, extension, buffer) {
87
+ const path = renditionPath(cacheDir, variant, id, extension);
88
+ mkdirSync(dirname(path), { recursive: true });
89
+ // Stage-then-rename: a reader must never observe a half-written cache file.
90
+ const staging = `${path}.${process.pid}.tmp`;
91
+ writeFileSync(staging, buffer);
92
+ renameSync(staging, path);
93
+ return { path, mime: EXTENSION_MIME[extension], bytes: buffer.length };
94
+ }
95
+
96
+ /** Downscale a still (path or buffer) into a webp; null without sharp. */
97
+ async function encodeStill(input, spec) {
98
+ const sharp = await loadSharp();
99
+ if (!sharp) return null;
100
+ try {
101
+ const buffer = await sharp(input)
102
+ // EXIF-rotated phone photos would otherwise land sideways in the grid.
103
+ .rotate()
104
+ .resize(spec.maxEdge, spec.maxEdge, { fit: 'inside', withoutEnlargement: true })
105
+ .webp({ quality: spec.quality })
106
+ .toBuffer();
107
+ return { extension: '.webp', buffer };
108
+ } catch {
109
+ // sharp present but the source is not a decodable image.
110
+ return null;
111
+ }
112
+ }
113
+
114
+ function parseDurationSeconds(text) {
115
+ const match = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(text || '');
116
+ if (!match) return 0;
117
+ const seconds = Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
118
+ return Number.isFinite(seconds) ? Math.round(seconds) : 0;
119
+ }
120
+
121
+ /** First frame + duration of a clip, straight from ffmpeg; null when the
122
+ * managed runtime is not installed. */
123
+ async function grabVideoFrame(sourcePath, spec) {
124
+ const ffmpeg = await resolveFfmpeg();
125
+ if (!ffmpeg) return null;
126
+ return new Promise((resolve) => {
127
+ const child = spawn(ffmpeg, [
128
+ '-hide_banner', '-nostdin',
129
+ '-i', sourcePath,
130
+ '-frames:v', '1',
131
+ // Never upscale a small clip: -2 keeps the height even for the encoder.
132
+ '-vf', `scale='min(${spec.maxEdge},iw)':-2`,
133
+ '-f', 'image2', '-vcodec', 'png', 'pipe:1',
134
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
135
+ const chunks = [];
136
+ let total = 0;
137
+ let stderr = '';
138
+ let settled = false;
139
+ const finish = (value) => {
140
+ if (settled) return;
141
+ settled = true;
142
+ clearTimeout(timer);
143
+ try { child.kill(); } catch { /* already gone */ }
144
+ resolve(value);
145
+ };
146
+ const timer = setTimeout(() => finish(null), POSTER_TIMEOUT_MS);
147
+ timer.unref?.();
148
+ child.stdout.on('data', (chunk) => {
149
+ total += chunk.length;
150
+ // A corrupt clip could stream frames forever; the poster is one image.
151
+ if (total > MAX_POSTER_BYTES) {
152
+ finish(null);
153
+ return;
154
+ }
155
+ chunks.push(chunk);
156
+ });
157
+ child.stderr.on('data', (chunk) => { stderr += String(chunk); });
158
+ child.on('error', () => finish(null));
159
+ child.on('close', () => {
160
+ const buffer = Buffer.concat(chunks);
161
+ finish(buffer.length ? { buffer, durationSeconds: parseDurationSeconds(stderr) } : null);
162
+ });
163
+ });
164
+ }
165
+
166
+ // Two tiles asking for the same missing rendition must not both encode it.
167
+ const inflight = new Map();
168
+
169
+ async function generate({ id, kind, sourcePath, variant, spec, cacheDir }) {
170
+ if (kind === 'video') {
171
+ // Only the tile-sized poster is worth extracting: a "display" frame would
172
+ // be a still pretending to be a clip.
173
+ if (variant !== 'thumb') return null;
174
+ const frame = await grabVideoFrame(sourcePath, spec);
175
+ if (!frame) return null;
176
+ const encoded = await encodeStill(frame.buffer, spec);
177
+ const written = encoded
178
+ ? writeRendition(cacheDir, variant, id, encoded.extension, encoded.buffer)
179
+ : writeRendition(cacheDir, variant, id, '.png', frame.buffer);
180
+ return { ...written, durationSeconds: frame.durationSeconds };
181
+ }
182
+ const encoded = await encodeStill(sourcePath, spec);
183
+ return encoded ? writeRendition(cacheDir, variant, id, encoded.extension, encoded.buffer) : null;
184
+ }
185
+
186
+ /**
187
+ * Cached rendition for one asset, generating it on first use.
188
+ * Returns `{ path, mime, bytes, durationSeconds? }`, or null when this host
189
+ * cannot produce it (no sharp / no ffmpeg / undecodable source).
190
+ */
191
+ export async function ensureRendition({ id, kind, sourcePath, variant, cacheDir }) {
192
+ const spec = renditionSpec(variant);
193
+ if (!spec) return null;
194
+ const cached = cachedRendition(cacheDir, variant, id);
195
+ if (cached) return cached;
196
+ const key = `${variant}:${id}`;
197
+ const pending = inflight.get(key)
198
+ ?? generate({ id, kind, sourcePath, variant, spec, cacheDir })
199
+ .catch(() => null)
200
+ .finally(() => { inflight.delete(key); });
201
+ inflight.set(key, pending);
202
+ return pending;
203
+ }
@@ -6,12 +6,13 @@
6
6
  * (newest first) and is written atomically under a file lock because the
7
7
  * desktop and CLI can generate concurrently.
8
8
  */
9
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, statSync } from 'fs';
10
- import { join } from 'path';
9
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, statSync } from 'fs';
10
+ import { dirname, join, resolve, sep } from 'path';
11
11
  import { randomUUID } from 'crypto';
12
12
  import { spawn } from 'child_process';
13
13
  import { resolvePluginData } from '../shared/plugin-paths.mjs';
14
14
  import { writeJsonAtomicSync, withFileLockSync } from '../shared/atomic-file.mjs';
15
+ import { ensureRendition, removeRenditions, renditionSpec } from './renditions.mjs';
15
16
 
16
17
  const MAX_INDEX_ENTRIES = 2_000;
17
18
  // Renderer transport is base64 over IPC: refuse to inline anything larger.
@@ -23,6 +24,7 @@ const EXTENSIONS = {
23
24
  'image/webp': 'webp',
24
25
  'video/mp4': 'mp4',
25
26
  };
27
+ let layoutMigrationChecked = false;
26
28
 
27
29
  function mediaDir() {
28
30
  return join(resolvePluginData(), 'media');
@@ -32,6 +34,12 @@ function assetsDir() {
32
34
  return join(mediaDir(), 'assets');
33
35
  }
34
36
 
37
+ // Derived tile/detail renditions live beside the assets: they are a rebuildable
38
+ // cache, so they never share the asset tree the user browses.
39
+ function renditionsDir() {
40
+ return join(mediaDir(), 'renditions');
41
+ }
42
+
35
43
  function indexPath() {
36
44
  return join(mediaDir(), 'index.json');
37
45
  }
@@ -42,6 +50,10 @@ function indexLockPath() {
42
50
 
43
51
  function ensureDirs() {
44
52
  mkdirSync(assetsDir(), { recursive: true });
53
+ if (!layoutMigrationChecked) {
54
+ migrateAssetLayout();
55
+ layoutMigrationChecked = true;
56
+ }
45
57
  }
46
58
 
47
59
  function readIndex() {
@@ -55,22 +67,86 @@ function readIndex() {
55
67
  }
56
68
 
57
69
  function writeIndex(assets) {
58
- writeJsonAtomicSync(indexPath(), { version: 1, assets });
70
+ writeJsonAtomicSync(indexPath(), { version: 2, assets });
59
71
  }
60
72
 
61
73
  function extensionFor(mime) {
62
74
  return EXTENSIONS[String(mime || '').toLowerCase()] || 'bin';
63
75
  }
64
76
 
77
+ function folderSegment(value, fallback) {
78
+ const normalized = String(value || '')
79
+ .normalize('NFKC')
80
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-')
81
+ .replace(/\s+/g, ' ')
82
+ .trim()
83
+ .replace(/[. ]+$/g, '')
84
+ .slice(0, 80);
85
+ if (!normalized || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(normalized)) {
86
+ return fallback;
87
+ }
88
+ return normalized;
89
+ }
90
+
91
+ function localDateFolder(createdAt) {
92
+ const date = new Date(Number(createdAt) || Date.now());
93
+ const year = date.getFullYear();
94
+ const month = String(date.getMonth() + 1).padStart(2, '0');
95
+ const day = String(date.getDate()).padStart(2, '0');
96
+ return `${year}-${month}-${day}`;
97
+ }
98
+
99
+ function organizedAssetFile(entry) {
100
+ const kind = entry.kind === 'video' ? 'videos' : 'images';
101
+ const lane = folderSegment(entry.lane, 'unknown-provider');
102
+ const model = folderSegment(entry.model, 'unknown-model');
103
+ const date = localDateFolder(entry.createdAt);
104
+ const id = folderSegment(entry.id, 'asset');
105
+ return [kind, lane, model, date, `${id}.${extensionFor(entry.mime)}`].join('/');
106
+ }
107
+
108
+ function storedAssetPath(file) {
109
+ const root = resolve(assetsDir());
110
+ const path = resolve(root, String(file || ''));
111
+ return path === root || path.startsWith(`${root}${sep}`) ? path : null;
112
+ }
113
+
114
+ /** Move flat v1 assets into kind/provider/model/date folders without losing old indexes. */
115
+ function migrateAssetLayout() {
116
+ withFileLockSync(indexLockPath(), () => {
117
+ const current = readIndex();
118
+ let changed = false;
119
+ const assets = current.map((entry) => {
120
+ const targetFile = organizedAssetFile(entry);
121
+ const sourcePath = storedAssetPath(entry.file);
122
+ const targetPath = storedAssetPath(targetFile);
123
+ if (!targetPath) return entry;
124
+ if (String(entry.file || '').replace(/\\/g, '/') === targetFile) return entry;
125
+ if (existsSync(targetPath)) {
126
+ changed = true;
127
+ return { ...entry, file: targetFile };
128
+ }
129
+ if (!sourcePath || !existsSync(sourcePath)) return entry;
130
+ try {
131
+ mkdirSync(dirname(targetPath), { recursive: true });
132
+ renameSync(sourcePath, targetPath);
133
+ changed = true;
134
+ return { ...entry, file: targetFile };
135
+ } catch {
136
+ return entry;
137
+ }
138
+ });
139
+ if (changed) writeIndex(assets);
140
+ });
141
+ }
142
+
65
143
  /** Persist one generated artifact and return its index entry. */
66
144
  export function saveMediaAsset({ kind, lane, model, prompt, options = {}, mime, bytes, meta = {} }) {
67
145
  ensureDirs();
68
146
  const id = randomUUID();
69
- const file = `${id}.${extensionFor(mime)}`;
70
- writeFileSync(join(assetsDir(), file), bytes);
147
+ const createdAt = Date.now();
71
148
  const entry = {
72
149
  id,
73
- file,
74
150
  kind,
75
151
  lane,
76
152
  model,
@@ -78,49 +154,162 @@ export function saveMediaAsset({ kind, lane, model, prompt, options = {}, mime,
78
154
  options,
79
155
  mime,
80
156
  bytes: bytes.length,
81
- createdAt: Date.now(),
157
+ createdAt,
82
158
  ...meta,
83
159
  };
160
+ const file = organizedAssetFile(entry);
161
+ const path = storedAssetPath(file);
162
+ if (!path) throw new Error('invalid media asset path');
163
+ mkdirSync(dirname(path), { recursive: true });
164
+ writeFileSync(path, bytes);
165
+ entry.file = file;
84
166
  withFileLockSync(indexLockPath(), () => {
85
167
  const assets = [entry, ...readIndex()];
86
168
  const kept = assets.slice(0, MAX_INDEX_ENTRIES);
87
169
  for (const dropped of assets.slice(MAX_INDEX_ENTRIES)) {
88
- try { unlinkSync(join(assetsDir(), dropped.file)); } catch {}
170
+ const droppedPath = storedAssetPath(dropped.file);
171
+ try { if (droppedPath) unlinkSync(droppedPath); } catch {}
89
172
  }
90
173
  writeIndex(kept);
91
174
  });
175
+ // The first gallery paint after a generation would otherwise be the one that
176
+ // pays for the tile rendition; build it now, off the caller's path.
177
+ void ensureRendition({
178
+ id: entry.id,
179
+ kind: entry.kind,
180
+ sourcePath: path,
181
+ variant: 'thumb',
182
+ cacheDir: renditionsDir(),
183
+ });
92
184
  return entry;
93
185
  }
94
186
 
95
187
  export function listMediaAssets({ limit = 60, offset = 0, kind = null } = {}) {
188
+ ensureDirs();
96
189
  const all = readIndex().filter((entry) => (kind ? entry.kind === kind : true));
97
190
  const start = Math.max(0, Math.trunc(Number(offset) || 0));
98
191
  const count = Math.min(200, Math.max(1, Math.trunc(Number(limit) || 60)));
99
- return { total: all.length, assets: all.slice(start, start + count) };
192
+ const assets = all.slice(start, start + count);
193
+ // Assets generated before this cache existed have no tile rendition, so the
194
+ // first gallery paint used to wait on an encode per visible tile. Listing is
195
+ // the earliest moment the host knows WHICH tiles are coming: warm them here,
196
+ // off the caller's path (ensureRendition dedupes the concurrent request).
197
+ void prewarmThumbs(assets);
198
+ return { total: all.length, assets };
199
+ }
200
+
201
+ // Bounded: the encoder is shared with generation, and a large page must not
202
+ // turn one listing into dozens of parallel sharp/ffmpeg jobs.
203
+ const PREWARM_LIMIT = 24;
204
+ const PREWARM_CONCURRENCY = 2;
205
+
206
+ async function prewarmThumbs(assets) {
207
+ const queue = assets.slice(0, PREWARM_LIMIT);
208
+ const workers = Array.from({ length: PREWARM_CONCURRENCY }, async () => {
209
+ for (let entry = queue.shift(); entry; entry = queue.shift()) {
210
+ const path = storedAssetPath(entry.file);
211
+ if (!path || !existsSync(path)) continue;
212
+ const rendition = await ensureRendition({
213
+ id: entry.id,
214
+ kind: entry.kind,
215
+ sourcePath: path,
216
+ variant: 'thumb',
217
+ cacheDir: renditionsDir(),
218
+ }).catch(() => null);
219
+ if (rendition?.durationSeconds) rememberDuration(entry, rendition.durationSeconds);
220
+ }
221
+ });
222
+ await Promise.all(workers).catch(() => {});
100
223
  }
101
224
 
102
225
  export function getMediaAsset(id) {
226
+ ensureDirs();
103
227
  return readIndex().find((entry) => entry.id === id) || null;
104
228
  }
105
229
 
106
- /** Inline an asset for the renderer (base64 + mime). Large files are refused. */
107
- export function readMediaAsset(id) {
108
- const entry = getMediaAsset(id);
109
- if (!entry) return null;
110
- const path = join(assetsDir(), entry.file);
111
- if (!existsSync(path)) return null;
230
+ /** Persist a duration probed while building a poster, so the gallery badge
231
+ * survives a restart without decoding the clip again. */
232
+ function rememberDuration(entry, durationSeconds) {
233
+ if (!durationSeconds || entry.durationSeconds === durationSeconds) return;
234
+ withFileLockSync(indexLockPath(), () => {
235
+ const assets = readIndex();
236
+ const row = assets.find((item) => item.id === entry.id);
237
+ if (!row || row.durationSeconds === durationSeconds) return;
238
+ row.durationSeconds = durationSeconds;
239
+ writeIndex(assets);
240
+ });
241
+ entry.durationSeconds = durationSeconds;
242
+ }
243
+
244
+ /** Base64 for the inline (RPC) path. Large files are refused: bytes that big
245
+ * belong on the file route, not in a JSON frame. */
246
+ function inlineBase64(path) {
112
247
  const size = statSync(path).size;
113
248
  if (size > MAX_INLINE_BYTES) {
114
249
  const err = new Error('media asset is too large to preview');
115
250
  err.code = 'MEDIA_ASSET_TOO_LARGE';
116
251
  throw err;
117
252
  }
118
- return { ...entry, path, base64: readFileSync(path).toString('base64') };
253
+ return readFileSync(path).toString('base64');
254
+ }
255
+
256
+ /**
257
+ * Locate an asset (or one of its renditions) as a FILE — no inlining.
258
+ *
259
+ * This is the unit the media routes serve: the client receives a cacheable,
260
+ * range-able response instead of a base64 payload riding the RPC lane.
261
+ * `available: false` means this host cannot build the rendition (no sharp /
262
+ * no ffmpeg); the caller reduces the feature instead of substituting the
263
+ * original behind the client's back.
264
+ */
265
+ export async function resolveMediaFile(id, { variant = 'original' } = {}) {
266
+ const entry = getMediaAsset(id);
267
+ if (!entry) return null;
268
+ const path = storedAssetPath(entry.file);
269
+ if (!path || !existsSync(path)) return null;
270
+ if (variant === 'original') return { ...entry, variant: 'original', path, available: true };
271
+ if (!renditionSpec(variant)) throw new Error(`unknown media variant: ${variant}`);
272
+ const rendition = await ensureRendition({
273
+ id: entry.id,
274
+ kind: entry.kind,
275
+ sourcePath: path,
276
+ variant,
277
+ cacheDir: renditionsDir(),
278
+ });
279
+ if (!rendition) return { ...entry, variant, path: '', available: false };
280
+ rememberDuration(entry, rendition.durationSeconds);
281
+ return {
282
+ ...entry,
283
+ ...(rendition.durationSeconds ? { durationSeconds: rendition.durationSeconds } : {}),
284
+ variant,
285
+ path: rendition.path,
286
+ mime: rendition.mime,
287
+ bytes: rendition.bytes,
288
+ available: true,
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Inline an asset for the renderer (base64 + mime).
294
+ *
295
+ * `variant` selects a derived rendition ('thumb' | 'display'); the original is
296
+ * read only when it is asked for. A host that cannot build the rendition says
297
+ * so (`available: false`) instead of quietly shipping full-size bytes — the
298
+ * caller that can afford them (local IPC) opts in with `allowOriginal`.
299
+ */
300
+ export async function readMediaAsset(id, { variant = 'original', allowOriginal = false } = {}) {
301
+ const file = await resolveMediaFile(id, { variant });
302
+ if (!file) return null;
303
+ if (file.available) return { ...file, base64: inlineBase64(file.path) };
304
+ if (!allowOriginal) return { ...file, base64: '' };
305
+ const original = await resolveMediaFile(id, { variant: 'original' });
306
+ if (!original) return null;
307
+ return { ...original, downgraded: true, base64: inlineBase64(original.path) };
119
308
  }
120
309
 
121
310
  export function mediaAssetPath(id) {
122
311
  const entry = getMediaAsset(id);
123
- return entry ? join(assetsDir(), entry.file) : null;
312
+ return entry ? storedAssetPath(entry.file) : null;
124
313
  }
125
314
 
126
315
  /**
@@ -151,12 +340,15 @@ function openWithOs(path) {
151
340
  }
152
341
 
153
342
  export function deleteMediaAsset(id) {
343
+ ensureDirs();
154
344
  let removed = false;
155
345
  withFileLockSync(indexLockPath(), () => {
156
346
  const assets = readIndex();
157
347
  const entry = assets.find((row) => row.id === id);
158
348
  if (!entry) return;
159
- try { unlinkSync(join(assetsDir(), entry.file)); } catch {}
349
+ const path = storedAssetPath(entry.file);
350
+ try { if (path) unlinkSync(path); } catch {}
351
+ removeRenditions(renditionsDir(), id);
160
352
  writeIndex(assets.filter((row) => row.id !== id));
161
353
  removed = true;
162
354
  });
@@ -0,0 +1,103 @@
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { test } from 'node:test';
13
+
14
+ test('media assets are organized by kind, provider, model, and local date while flat files migrate', async () => {
15
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-media-store-'));
16
+ const previousDataDir = process.env.MIXDOG_DATA_DIR;
17
+ process.env.MIXDOG_DATA_DIR = root;
18
+ const assetsDir = join(root, 'media', 'assets');
19
+ const createdAt = new Date(2026, 6, 5, 12, 0, 0).getTime();
20
+ mkdirSync(assetsDir, { recursive: true });
21
+ writeFileSync(join(assetsDir, 'legacy-image.jpg'), Buffer.from('image'));
22
+ writeFileSync(join(assetsDir, 'legacy-video.mp4'), Buffer.from('video'));
23
+ writeFileSync(join(root, 'media', 'index.json'), JSON.stringify({
24
+ version: 1,
25
+ assets: [
26
+ {
27
+ id: 'legacy-image',
28
+ file: 'legacy-image.jpg',
29
+ kind: 'image',
30
+ lane: 'gemini',
31
+ model: 'image-alpha',
32
+ prompt: 'image',
33
+ options: { aspectRatio: '4:3' },
34
+ mime: 'image/jpeg',
35
+ bytes: 5,
36
+ createdAt,
37
+ },
38
+ {
39
+ id: 'legacy-video',
40
+ file: 'legacy-video.mp4',
41
+ kind: 'video',
42
+ lane: 'grok',
43
+ model: 'video-beta',
44
+ prompt: 'video',
45
+ options: { aspectRatio: '16:9' },
46
+ mime: 'video/mp4',
47
+ bytes: 5,
48
+ createdAt,
49
+ },
50
+ ],
51
+ }));
52
+
53
+ try {
54
+ const store = await import(`./store.mjs?test=${Date.now()}`);
55
+ const listed = store.listMediaAssets({ limit: 10 }).assets;
56
+ assert.equal(listed[0].file, 'images/gemini/image-alpha/2026-07-05/legacy-image.jpg');
57
+ assert.equal(listed[1].file, 'videos/grok/video-beta/2026-07-05/legacy-video.mp4');
58
+ assert.equal(existsSync(join(assetsDir, ...listed[0].file.split('/'))), true);
59
+ assert.equal(existsSync(join(assetsDir, ...listed[1].file.split('/'))), true);
60
+ assert.equal(existsSync(join(assetsDir, 'legacy-image.jpg')), false);
61
+ assert.equal(
62
+ (await store.readMediaAsset('legacy-video')).base64,
63
+ Buffer.from('video').toString('base64'),
64
+ );
65
+
66
+ // A tile-sized rendition is impossible for these stub bytes (and video
67
+ // needs ffmpeg): the read must REPORT the miss instead of quietly
68
+ // shipping the original, which is what made the remote gallery slow.
69
+ const missing = await store.readMediaAsset('legacy-image', { variant: 'thumb' });
70
+ assert.equal(missing.available, false);
71
+ assert.equal(missing.base64, '');
72
+ // Callers that can afford full-size bytes (local IPC) opt in explicitly
73
+ // and get them labelled as a downgrade.
74
+ const downgraded = await store.readMediaAsset('legacy-image', {
75
+ variant: 'thumb',
76
+ allowOriginal: true,
77
+ });
78
+ assert.equal(downgraded.variant, 'original');
79
+ assert.equal(downgraded.downgraded, true);
80
+ assert.equal(downgraded.base64, Buffer.from('image').toString('base64'));
81
+
82
+ const saved = store.saveMediaAsset({
83
+ kind: 'video',
84
+ lane: 'gemini',
85
+ model: 'veo:3/preview',
86
+ prompt: 'new clip',
87
+ options: { aspectRatio: '16:9' },
88
+ mime: 'video/mp4',
89
+ bytes: Buffer.from('new-video'),
90
+ });
91
+ assert.match(saved.file, /^videos\/gemini\/veo-3-preview\/\d{4}-\d{2}-\d{2}\/[0-9a-f-]+\.mp4$/);
92
+ assert.equal(existsSync(join(assetsDir, ...saved.file.split('/'))), true);
93
+ assert.equal(store.deleteMediaAsset(saved.id).removed, true);
94
+ assert.equal(existsSync(join(assetsDir, ...saved.file.split('/'))), false);
95
+
96
+ const persisted = JSON.parse(readFileSync(join(root, 'media', 'index.json'), 'utf8'));
97
+ assert.equal(persisted.version, 2);
98
+ } finally {
99
+ if (previousDataDir === undefined) delete process.env.MIXDOG_DATA_DIR;
100
+ else process.env.MIXDOG_DATA_DIR = previousDataDir;
101
+ rmSync(root, { recursive: true, force: true });
102
+ }
103
+ });
@@ -52,6 +52,15 @@ export function createLifecycleApi(deps) {
52
52
  pushTranscriptRebind,
53
53
  notificationListeners, remoteStateListeners, desktopSession,
54
54
  } = deps;
55
+ const closeSurfaceSession = (session, reason, options) => {
56
+ if (!session?.id) return false;
57
+ // A remote-attached session is only a viewer handle owned by this surface.
58
+ // Closing it through the shared manager bumps the durable generation and
59
+ // invalidates the real owner's in-flight turn. Viewer exits therefore
60
+ // detach locally; only the process that owns the runtime may close it.
61
+ if (session.remoteAttached === true) return true;
62
+ return mgr.closeSession(session.id, reason, options);
63
+ };
55
64
  const listLeadSessions = (options = {}) => {
56
65
  const heartbeatMtimes = listSessionHeartbeatMtimes();
57
66
  return mgr.listSessions({
@@ -245,7 +254,7 @@ export function createLifecycleApi(deps) {
245
254
  // first-turn exit could still burn a real session.
246
255
  const tombstone = !hasUserConversationMessage(session.messages)
247
256
  && !hasUserConversationMessage(session.liveTurnMessages);
248
- ok = mgr.closeSession(session.id, reason, { tombstone });
257
+ ok = closeSurfaceSession(session, reason, { tombstone });
249
258
  setSession(null);
250
259
  }
251
260
  invalidateContextStatusCache();
@@ -345,7 +354,7 @@ export function createLifecycleApi(deps) {
345
354
  statusRoutes?.clearGatewaySessionRoute?.(session.id);
346
355
  const tombstone = !hasUserConversationMessage(session.messages)
347
356
  && !hasUserConversationMessage(session.liveTurnMessages);
348
- mgr.closeSession(session.id, cleanupReason, { tombstone });
357
+ closeSurfaceSession(session, cleanupReason, { tombstone });
349
358
  setSession(null);
350
359
  }
351
360
  setDesktopSession(nextDesktopSession && typeof nextDesktopSession === 'object'
@@ -376,7 +385,7 @@ export function createLifecycleApi(deps) {
376
385
  void ingestSessionIntoMemory(session);
377
386
  const tombstone = !hasUserConversationMessage(session.messages)
378
387
  && !hasUserConversationMessage(session.liveTurnMessages);
379
- mgr.closeSession(session.id, 'cli-new', { tombstone });
388
+ closeSurfaceSession(session, 'cli-new', { tombstone });
380
389
  setSession(null);
381
390
  }
382
391
  invalidateContextStatusCache();
@@ -411,7 +420,7 @@ export function createLifecycleApi(deps) {
411
420
  void ingestSessionIntoMemory(prev);
412
421
  const tombstone = !hasUserConversationMessage(previousMessages)
413
422
  && !hasUserConversationMessage(previousLive);
414
- mgr.closeSession(previousId, 'cli-resume', { tombstone });
423
+ closeSurfaceSession(prev, 'cli-resume', { tombstone });
415
424
  }
416
425
  setSession(resumed);
417
426
  applyResolvedCwd(resolveResumeCwd(resumed, getCurrentCwd()), { markRefresh: false });
@@ -31,8 +31,11 @@ export function createMediaApi() {
31
31
  async listMediaAssets(options) {
32
32
  return (await media()).listMediaAssets(options || {});
33
33
  },
34
- async readMediaAsset(id) {
35
- return (await media()).readMediaAsset(id);
34
+ async readMediaAsset(id, options) {
35
+ return (await media()).readMediaAsset(id, options || {});
36
+ },
37
+ async resolveMediaFile(id, options) {
38
+ return (await media()).resolveMediaFile(id, options || {});
36
39
  },
37
40
  async deleteMediaAsset(id) {
38
41
  return (await media()).deleteMediaAsset(id);
@@ -44,7 +44,6 @@ const SYNTHETIC_SESSION_TEXT_PATTERNS = Object.freeze([
44
44
  // "Re-attached after compaction…" rows in Recent (user report). Skipping
45
45
  // them titles the session from its first REAL user message instead.
46
46
  /^re-attached after compaction\b/i,
47
- /^reference files:\s/i,
48
47
  /^the async (?:agent|shell) task\b/i,
49
48
  ]);
50
49
 
@@ -131,6 +131,32 @@ function toolSearchNativePayload(catalog, names, provider = '') {
131
131
  };
132
132
  }
133
133
 
134
+ // Schemas for tools that are ALREADY active. They are deliberately NOT added
135
+ // to toolReferences/openaiTools: re-announcing them would rewrite the request
136
+ // tools array and break the cached prefix. The caller only ever sees names
137
+ // otherwise, so the parameter contract (enums, limits) rides in the RESULT.
138
+ function activeToolSchemas(catalog, session, names) {
139
+ const wanted = new Set((names || []).map(clean).filter(Boolean));
140
+ if (!wanted.size) return [];
141
+ const seen = new Set();
142
+ const specs = [];
143
+ for (const pool of [Array.isArray(session?.tools) ? session.tools : [], catalog || []]) {
144
+ for (const tool of pool) {
145
+ const name = clean(tool?.name);
146
+ if (!name || !wanted.has(name) || seen.has(name)) continue;
147
+ seen.add(name);
148
+ specs.push({
149
+ name,
150
+ description: clean(tool?.description),
151
+ parameters: tool?.inputSchema && typeof tool.inputSchema === 'object'
152
+ ? tool.inputSchema
153
+ : { type: 'object', properties: {} },
154
+ });
155
+ }
156
+ }
157
+ return specs;
158
+ }
159
+
134
160
  // Plain case-insensitive substring filter over name + description. No scores,
135
161
  // no aliases, no auto-selection: tool_search only lists and (via select)
136
162
  // loads. Empty query matches every row.
@@ -734,9 +760,14 @@ export function renderToolSearch(args = {}, session, mode = 'full', options = {}
734
760
  summary: '',
735
761
  })
736
762
  : null;
763
+ const alreadyActiveSchemas = activeToolSchemas(catalog, session, alreadyActive);
737
764
  const nativeSummary = [
738
765
  ...(loaded.length ? [`Loaded deferred tools: ${loaded.join(', ')}`] : []),
739
766
  ...(alreadyActive.length ? [`Already active: ${alreadyActive.join(', ')}`] : []),
767
+ // The native path replaces the whole JSON result with this summary
768
+ // (tool-batch), so an already-active tool's schema has to travel here or
769
+ // the caller keeps guessing its parameters.
770
+ ...(alreadyActiveSchemas.length ? [`Already-active schemas: ${JSON.stringify(alreadyActiveSchemas)}`] : []),
740
771
  ].join('\n');
741
772
  const nativeToolSearch = nativeToolSearchBase
742
773
  ? { ...nativeToolSearchBase, summary: nativeSummary || nativeToolSearchBase.summary }
@@ -754,6 +785,7 @@ export function renderToolSearch(args = {}, session, mode = 'full', options = {}
754
785
  ...(nativeToolSearch ? { nativeToolSearch } : {}),
755
786
  loaded,
756
787
  alreadyActive,
788
+ ...(alreadyActiveSchemas.length ? { alreadyActiveSchemas } : {}),
757
789
  missing,
758
790
  ...(blocked.length ? { blocked } : {}),
759
791
  ...mcpFields,
@@ -23945,7 +23945,6 @@ var SYNTHETIC_SESSION_TEXT_PATTERNS = Object.freeze([
23945
23945
  // "Re-attached after compaction…" rows in Recent (user report). Skipping
23946
23946
  // them titles the session from its first REAL user message instead.
23947
23947
  /^re-attached after compaction\b/i,
23948
- /^reference files:\s/i,
23949
23948
  /^the async (?:agent|shell) task\b/i
23950
23949
  ]);
23951
23950
  var LATE_TOOL_ANNOUNCEMENT_SENTINEL = "connected after this session started";
@@ -28406,7 +28405,8 @@ function createEngineApiB(bag) {
28406
28405
  // generation start posts a notice so the TUI shows background work.
28407
28406
  listMediaLanes: () => runtime.listMediaLanes?.(),
28408
28407
  listMediaAssets: (options) => runtime.listMediaAssets?.(options),
28409
- readMediaAsset: (id) => runtime.readMediaAsset?.(id),
28408
+ readMediaAsset: (id, options) => runtime.readMediaAsset?.(id, options),
28409
+ resolveMediaFile: (id, options) => runtime.resolveMediaFile?.(id, options),
28410
28410
  getMediaJob: (id) => runtime.getMediaJob?.(id),
28411
28411
  listMediaJobs: () => runtime.listMediaJobs?.(),
28412
28412
  // No notice on start: the Studio surfaces progress on its own pending tile,
@@ -677,7 +677,8 @@ export function createEngineApiB(bag) {
677
677
  // generation start posts a notice so the TUI shows background work.
678
678
  listMediaLanes: () => runtime.listMediaLanes?.(),
679
679
  listMediaAssets: (options) => runtime.listMediaAssets?.(options),
680
- readMediaAsset: (id) => runtime.readMediaAsset?.(id),
680
+ readMediaAsset: (id, options) => runtime.readMediaAsset?.(id, options),
681
+ resolveMediaFile: (id, options) => runtime.resolveMediaFile?.(id, options),
681
682
  getMediaJob: (id) => runtime.getMediaJob?.(id),
682
683
  listMediaJobs: () => runtime.listMediaJobs?.(),
683
684
  // No notice on start: the Studio surfaces progress on its own pending tile,