mixdog 0.9.97 → 0.9.99

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 (48) hide show
  1. package/package.json +9 -2
  2. package/src/rules/agent/30-explorer.md +43 -32
  3. package/src/runtime/agent/orchestrator/agent-trace.mjs +19 -5
  4. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +7 -0
  5. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +30 -3
  6. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +5 -3
  7. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +23 -1
  8. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -1
  9. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +44 -6
  10. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -1
  11. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +3 -6
  12. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +103 -12
  13. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +10 -30
  14. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -84
  15. package/src/runtime/attachments/pdf-extract.mjs +83 -0
  16. package/src/runtime/attachments/store.mjs +515 -0
  17. package/src/runtime/attachments/store.test.mjs +188 -0
  18. package/src/runtime/channels/lib/interaction-handlers.mjs +1 -191
  19. package/src/runtime/channels/lib/runtime-paths.mjs +1 -4
  20. package/src/runtime/channels/lib/worker-ipc.mjs +0 -66
  21. package/src/runtime/channels/lib/worker-main.mjs +1 -20
  22. package/src/runtime/memory/lib/http-wire.mjs +39 -4
  23. package/src/runtime/shared/json-metrics.mjs +94 -0
  24. package/src/runtime/shared/turn-snapshot.mjs +13 -0
  25. package/src/runtime/shared/turn-worktree-snapshot.mjs +19 -0
  26. package/src/session-runtime/provider-models.mjs +11 -1
  27. package/src/session-runtime/quick-model-rows.mjs +38 -7
  28. package/src/session-runtime/quick-search-models.mjs +15 -17
  29. package/src/session-runtime/runtime-core.mjs +9 -4
  30. package/src/session-runtime/runtime-tunables.mjs +9 -6
  31. package/src/session-runtime/session-title.mjs +27 -3
  32. package/src/standalone/channel-transport.mjs +28 -12
  33. package/src/standalone/daemon.mjs +3 -0
  34. package/src/standalone/session-protocol.mjs +1 -0
  35. package/src/standalone/session-service.mjs +23 -1
  36. package/src/standalone/session-transport.mjs +3 -2
  37. package/src/tui/app/prompt-submit.mjs +7 -9
  38. package/src/tui/app/use-prompt-handlers.mjs +7 -3
  39. package/src/tui/dist/index.mjs +249 -43
  40. package/src/tui/paste-attachments.mjs +13 -7
  41. package/src/tui/prompt-history-store.mjs +52 -4
  42. package/src/tui/session/agent-job-feed.mjs +5 -1
  43. package/src/tui/session/live-share.mjs +21 -7
  44. package/src/tui/session/queue-helpers.mjs +6 -6
  45. package/src/tui/session/session-api-ext.mjs +12 -4
  46. package/src/tui/session/session-api.mjs +10 -1
  47. package/src/tui/session/session-flow.mjs +4 -2
  48. package/src/tui/session-local.mjs +15 -5
@@ -5,6 +5,7 @@ import { clean } from './session-text.mjs';
5
5
  import { QUICK_SEARCH_MODELS } from './quick-search-models.mjs';
6
6
  import { SEARCH_DEFAULT_PROVIDER, SEARCH_DEFAULT_MODEL } from './workflow.mjs';
7
7
  import { getModelMetadataSync } from '../runtime/agent/orchestrator/providers/model-catalog.mjs';
8
+ import { providerCachedModelsSync } from '../runtime/agent/orchestrator/providers/provider-catalog-cache.mjs';
8
9
 
9
10
  function metadataFor(provider, modelId) {
10
11
  try {
@@ -51,15 +52,39 @@ export function createQuickModelRows({
51
52
  const pickerConfig = displayConfig();
52
53
  const rows = [];
53
54
  const seen = new Set();
55
+ const addModel = (provider, modelLike = {}) => {
56
+ const model = modelLike && typeof modelLike === 'object'
57
+ ? modelLike
58
+ : { id: clean(modelLike) };
59
+ const modelId = clean(model.id || model.name);
60
+ if (!provider || !modelId) return;
61
+ const key = `${provider}:${modelId}`;
62
+ if (seen.has(key)) return;
63
+ seen.add(key);
64
+ const meta = metadataFor(provider, modelId);
65
+ const row = providerModelCacheRow(provider, {
66
+ ...model,
67
+ id: modelId,
68
+ name: model.name || model.display || modelId,
69
+ display: model.display || model.name || modelId,
70
+ contextWindow: model.contextWindow || meta.contextWindow || null,
71
+ outputTokens: model.outputTokens || meta.outputTokens || null,
72
+ supportsReasoning: model.supportsReasoning === true || meta.supportsReasoning === true,
73
+ supportsFunctionCalling: model.supportsFunctionCalling === true || meta.supportsFunctionCalling === true,
74
+ supportsPromptCaching: model.supportsPromptCaching === true || meta.supportsPromptCaching === true,
75
+ reasoningOptions: model.reasoningOptions?.length ? model.reasoningOptions : (meta.reasoningOptions || []),
76
+ reasoningContentField: model.reasoningContentField || meta.reasoningContentField || null,
77
+ mode: model.mode || meta.mode || 'chat',
78
+ });
79
+ rows.push(row);
80
+ modelMetaByRoute.set(modelMetaKey(provider, modelId), row);
81
+ };
54
82
  const addRoute = (routeLike = {}) => {
55
83
  const provider = clean(routeLike.provider);
56
84
  const model = clean(routeLike.model);
57
85
  if (!provider || !model) return;
58
- const key = `${provider}:${model}`;
59
- if (seen.has(key)) return;
60
- seen.add(key);
61
86
  const meta = metadataFor(provider, model);
62
- const row = providerModelCacheRow(provider, {
87
+ addModel(provider, {
63
88
  id: model,
64
89
  name: routeLike.modelDisplay || routeLike.display || model,
65
90
  display: routeLike.modelDisplay || routeLike.display || model,
@@ -73,10 +98,12 @@ export function createQuickModelRows({
73
98
  reasoningContentField: meta.reasoningContentField || null,
74
99
  mode: 'chat',
75
100
  });
76
- rows.push(row);
77
- modelMetaByRoute.set(modelMetaKey(provider, model), row);
78
101
  };
79
102
 
103
+ for (const [provider, providerConfig] of Object.entries(pickerConfig.providers || {})) {
104
+ if (!providerConfig?.enabled) continue;
105
+ for (const model of providerCachedModelsSync(provider)) addModel(provider, model);
106
+ }
80
107
  addRoute(route);
81
108
  for (const preset of pickerConfig.presets || []) addRoute(preset);
82
109
  for (const workflowRoute of Object.values(pickerConfig.workflowRoutes || {})) addRoute(workflowRoute);
@@ -146,7 +173,11 @@ export function createQuickModelRows({
146
173
  for (const [name, providerConfig] of Object.entries(pickerConfig.providers || {})) {
147
174
  const providerName = normalizeSearchProviderId(name);
148
175
  if (!providerConfig?.enabled || !isSearchCapableProvider(providerName)) continue;
149
- for (const model of QUICK_SEARCH_MODELS[providerName] || []) {
176
+ const cachedModels = providerCachedModelsSync(providerName);
177
+ const quickModels = cachedModels.length
178
+ ? cachedModels
179
+ : (QUICK_SEARCH_MODELS[providerName] || []);
180
+ for (const model of quickModels) {
150
181
  addQuickSearchModel(rows, seen, providerName, model);
151
182
  }
152
183
  }
@@ -6,10 +6,11 @@ export const ONBOARDING_VERSION = 1;
6
6
 
7
7
  export const QUICK_SEARCH_MODELS = Object.freeze({
8
8
  'openai-oauth': [
9
+ { id: 'gpt-5.6-sol', display: 'GPT-5.6 Sol', latest: true },
10
+ { id: 'gpt-5.6-terra', display: 'GPT-5.6 Terra', latest: true },
11
+ { id: 'gpt-5.6-luna', display: 'GPT-5.6 Luna' },
9
12
  { id: 'gpt-5.5', display: 'GPT-5.5', latest: true },
10
- { id: 'gpt-5.4', display: 'GPT-5.4', latest: true },
11
- { id: 'gpt-5', display: 'GPT-5' },
12
- { id: 'gpt-4.1', display: 'GPT-4.1' },
13
+ { id: 'gpt-5.4-mini', display: 'GPT-5.4 Mini' },
13
14
  ],
14
15
  openai: [
15
16
  { id: 'gpt-5.5', display: 'GPT-5.5', latest: true },
@@ -19,29 +20,26 @@ export const QUICK_SEARCH_MODELS = Object.freeze({
19
20
  { id: 'gpt-4o', display: 'GPT-4o' },
20
21
  ],
21
22
  'grok-oauth': [
22
- { id: 'grok-4.3', display: 'Grok 4.3', latest: true },
23
- { id: 'grok-4.20', display: 'Grok 4.20' },
24
- { id: 'grok-4', display: 'Grok 4' },
23
+ { id: 'grok-4.5', display: 'Grok 4.5', latest: true },
25
24
  ],
26
25
  xai: [
27
- { id: 'grok-4.3', display: 'Grok 4.3', latest: true },
28
- { id: 'grok-4.20', display: 'Grok 4.20' },
29
- { id: 'grok-4', display: 'Grok 4' },
26
+ { id: 'grok-4.5', display: 'Grok 4.5', latest: true },
30
27
  ],
31
28
  gemini: [
32
- { id: 'gemini-3-pro', display: 'Gemini 3 Pro', latest: true },
33
- { id: 'gemini-2.5-pro', display: 'Gemini 2.5 Pro' },
34
- { id: 'gemini-2.5-flash', display: 'Gemini 2.5 Flash' },
35
- { id: 'gemini-2.0-flash', display: 'Gemini 2.0 Flash' },
29
+ { id: 'gemini-3.6-flash', display: 'Gemini 3.6 Flash', latest: true },
30
+ { id: 'gemini-3.5-flash-lite', display: 'Gemini 3.5 Flash Lite', latest: true },
31
+ { id: 'gemini-3.1-pro-preview', display: 'Gemini 3.1 Pro Preview' },
36
32
  ],
37
33
  'anthropic-oauth': [
38
- { id: 'claude-opus-4-8', display: 'Claude Opus 4.8', latest: true },
39
- { id: 'claude-sonnet-4-6', display: 'Claude Sonnet 4.6', latest: true },
34
+ { id: 'claude-opus-5', display: 'Claude Opus 5', latest: true },
35
+ { id: 'claude-sonnet-5', display: 'Claude Sonnet 5', latest: true },
36
+ { id: 'claude-fable-5', display: 'Claude Fable 5', latest: true },
40
37
  { id: 'claude-haiku-4-5-20251001', display: 'Claude Haiku 4.5' },
41
38
  ],
42
39
  anthropic: [
43
- { id: 'claude-opus-4-8', display: 'Claude Opus 4.8', latest: true },
44
- { id: 'claude-sonnet-4-6', display: 'Claude Sonnet 4.6', latest: true },
40
+ { id: 'claude-opus-5', display: 'Claude Opus 5', latest: true },
41
+ { id: 'claude-sonnet-5', display: 'Claude Sonnet 5', latest: true },
42
+ { id: 'claude-fable-5', display: 'Claude Fable 5', latest: true },
45
43
  { id: 'claude-haiku-4-5-20251001', display: 'Claude Haiku 4.5' },
46
44
  ],
47
45
  });
@@ -229,6 +229,7 @@ import { createWarmupSchedulers } from './warmup-schedulers.mjs';
229
229
  import { createPrewarmSchedulers } from './prewarm.mjs';
230
230
  import {
231
231
  getTurnReviewDiff as getTurnSnapshotReviewDiff,
232
+ revertTurnReview as revertTurnSnapshotReview,
232
233
  revertTurnReviewFile as revertTurnSnapshotReviewFile,
233
234
  } from '../runtime/shared/turn-snapshot.mjs';
234
235
  import { createMcpGlue } from './mcp-glue.mjs';
@@ -1303,10 +1304,10 @@ export async function createMixdogSessionRuntime({
1303
1304
  scheduleProviderSetupWarmup();
1304
1305
  scheduleModelCatalogWarmup();
1305
1306
  scheduleProviderWarmup();
1306
- // Warm the provider model catalog in the background, but keep it on its own
1307
- // delay so short-lived detached runtimes can exit before /models I/O starts.
1308
- // Operators that want earlier catalog warming can lower
1309
- // MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS explicitly.
1307
+ // Daemon-hosted product sessions warm the provider model catalog immediately
1308
+ // in the background. This does not delay runtime readiness; an early picker
1309
+ // joins the boot prefetch instead of starting duplicate provider I/O.
1310
+ // Standalone runtimes retain an exit-safe grace period (runtime-tunables).
1310
1311
  scheduleProviderModelWarmup();
1311
1312
  scheduleStatuslineUsageWarmup();
1312
1313
  // Channels are opt-in: only boot the worker when this session started in (or
@@ -1649,6 +1650,10 @@ export async function createMixdogSessionRuntime({
1649
1650
  rt.session?.id,
1650
1651
  options,
1651
1652
  ),
1653
+ revertTurnReview: () => revertTurnSnapshotReview(
1654
+ rt.currentCwd,
1655
+ rt.session?.id,
1656
+ ),
1652
1657
  revertTurnReviewFile: (file) => revertTurnSnapshotReviewFile(
1653
1658
  rt.currentCwd,
1654
1659
  rt.session?.id,
@@ -6,16 +6,19 @@ import { envDelayMs, envFlag, envPresent } from './env.mjs';
6
6
 
7
7
  export function readRuntimeTunables() {
8
8
  const codeGraphPrewarmEnabled = !envFlag('MIXDOG_DISABLE_CODE_GRAPH_PREWARM');
9
+ const daemonHosted = envFlag('MIXDOG_DAEMON_HOST');
9
10
  return {
10
11
  providerSetupWarmupDelayMs: envDelayMs('MIXDOG_PROVIDER_SETUP_WARMUP_DELAY_MS', 300, { min: 0, max: 60_000 }),
11
12
  modelCatalogWarmupDelayMs: envDelayMs('MIXDOG_MODEL_CATALOG_WARMUP_DELAY_MS', 200, { min: 0, max: 60_000 }),
12
13
  providerWarmupDelayMs: envDelayMs('MIXDOG_PROVIDER_WARMUP_DELAY_MS', 1_500, { min: 0, max: 60_000 }),
13
- // Background model-catalog prefetch delay. Kept short so the first `/model`
14
- // open finds a warm cache instead of paying a cold full network load. The
15
- // work is async + unref'd, so short-lived detached runtimes still exit
16
- // cleanly without waiting on it. Operators can raise it via env if a
17
- // detached runtime must avoid the /models round-trip entirely.
18
- providerModelWarmupDelayMs: envDelayMs('MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS', 2_000, { min: 0, max: 120_000 }),
14
+ // A product session lives in the long-running daemon, so start its model
15
+ // prefetch on the first background tick. Standalone/short-lived runtimes
16
+ // keep the old grace period so provider sockets cannot hold process exit.
17
+ providerModelWarmupDelayMs: envDelayMs(
18
+ 'MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS',
19
+ daemonHosted ? 0 : 2_000,
20
+ { min: 0, max: 120_000 },
21
+ ),
19
22
  codeGraphPrewarmDelayMs: envDelayMs('MIXDOG_CODE_GRAPH_PREWARM_DELAY_MS', 250, { min: 0, max: 60_000 }),
20
23
  statuslineUsageWarmupDelayMs: envDelayMs('MIXDOG_STATUSLINE_USAGE_WARMUP_DELAY_MS', 800, { min: 0, max: 60_000 }),
21
24
  // Idle keep-alive: re-fetch usage before the statusline's 10-min staleness
@@ -111,11 +111,24 @@ export function createSessionTitleController(deps = {}) {
111
111
  const firstAttempts = new Set();
112
112
  const thirdAttempts = new Set();
113
113
  const active = new Map();
114
+ const attemptLimit = Number.isFinite(Number(deps.attemptLimit))
115
+ ? Math.max(1, Number(deps.attemptLimit))
116
+ : 2048;
114
117
  const timeoutMs = Number.isFinite(Number(deps.timeoutMs))
115
118
  ? Math.max(1, Number(deps.timeoutMs))
116
119
  : SESSION_TITLE_TIMEOUT_MS;
117
120
  let disposed = false;
118
121
 
122
+ const rememberAttempt = (attempts, sessionId) => {
123
+ attempts.delete(sessionId);
124
+ attempts.add(sessionId);
125
+ while (attempts.size > attemptLimit) {
126
+ const oldest = attempts.values().next().value;
127
+ if (oldest === undefined) break;
128
+ attempts.delete(oldest);
129
+ }
130
+ };
131
+
119
132
  const log = (line) => {
120
133
  const message = `[mixdog] llm-title ${line}`;
121
134
  try { deps.log?.(message); } catch {}
@@ -190,7 +203,7 @@ export function createSessionTitleController(deps = {}) {
190
203
  if (priorMeaningfulUser) return false;
191
204
  const source = firstTurnTitleSource(prompt);
192
205
  if (!source) return false;
193
- firstAttempts.add(sessionId);
206
+ rememberAttempt(firstAttempts, sessionId);
194
207
  const greeting = greetingTitle(source);
195
208
  if (greeting) {
196
209
  log(`generated id=${sessionId} stage=first title=${JSON.stringify(greeting)} deterministic=greeting`);
@@ -211,7 +224,7 @@ export function createSessionTitleController(deps = {}) {
211
224
  || session?.generatedTitleStage === 'third') return false;
212
225
  const source = thirdTurnTitleSource(session?.messages);
213
226
  if (!source) return false;
214
- thirdAttempts.add(sessionId);
227
+ rememberAttempt(thirdAttempts, sessionId);
215
228
  run(sessionId, source, 'third', thirdAttempts);
216
229
  return true;
217
230
  }
@@ -222,7 +235,18 @@ export function createSessionTitleController(deps = {}) {
222
235
  abort.abort(new Error('Session title generation disposed.'));
223
236
  }
224
237
  active.clear();
238
+ firstAttempts.clear();
239
+ thirdAttempts.clear();
225
240
  }
226
241
 
227
- return { scheduleFirst, observeThird, disposeAll };
242
+ return {
243
+ scheduleFirst,
244
+ observeThird,
245
+ disposeAll,
246
+ attemptStatsForTest: () => ({
247
+ first: firstAttempts.size,
248
+ third: thirdAttempts.size,
249
+ limit: attemptLimit,
250
+ }),
251
+ };
228
252
  }
@@ -20,6 +20,12 @@ import { writeJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
20
20
  import { readBody, sendJson, sendError } from '../runtime/memory/lib/http-wire.mjs';
21
21
  import { createFairCallScheduler } from './fair-call-scheduler.mjs';
22
22
 
23
+ export const CHANNEL_HTTP_BODY_MAX_BYTES = 64 * 1024 * 1024;
24
+
25
+ function readChannelBody(req) {
26
+ return readBody(req, { maxBytes: CHANNEL_HTTP_BODY_MAX_BYTES });
27
+ }
28
+
23
29
  function parsePid(value) {
24
30
  const n = Number(value);
25
31
  return Number.isInteger(n) && n > 0 ? n : null;
@@ -425,7 +431,13 @@ export function createChannelTransport({
425
431
  function writeRemoteStateTo(client, state) {
426
432
  if (!client) return false;
427
433
  const frame = JSON.stringify({ type: 'notify', method: REMOTE_STATE_METHOD, params: { state } });
428
- if (!client.sse) { client.pending.push(frame); return true; }
434
+ if (!client.sse) {
435
+ // This is state, not an event log. Ownership churn before an SSE
436
+ // reconnect only needs the newest transition; retaining every displaced
437
+ // frame lets one disconnected client grow without bound.
438
+ client.pendingRemoteStateFrame = frame;
439
+ return true;
440
+ }
429
441
  try { client.sse.write(`data: ${frame}\n\n`); return true; }
430
442
  catch (err) { log(`remote-state '${state}' write failed for lead=${client.leadPid}: ${err?.message || err}`); return false; }
431
443
  }
@@ -539,7 +551,7 @@ export function createChannelTransport({
539
551
  leadPid: pid,
540
552
  cwd: cwd || null,
541
553
  sse: null,
542
- pending: [],
554
+ pendingRemoteStateFrame: null,
543
555
  lastSeen: nowMs(),
544
556
  registeredAt: nowMs(),
545
557
  });
@@ -574,7 +586,10 @@ export function createChannelTransport({
574
586
  const replacedWasPointer = pointerToken === replaced.token;
575
587
  // Token-scoped state belongs to the logical client, not only the owner.
576
588
  // A non-owner can hold a buffered superseded frame or stored bind intent.
577
- if (replaced.pending?.length) fresh.pending.push(...replaced.pending.splice(0));
589
+ if (replaced.pendingRemoteStateFrame) {
590
+ fresh.pendingRemoteStateFrame = replaced.pendingRemoteStateFrame;
591
+ replaced.pendingRemoteStateFrame = null;
592
+ }
578
593
  if (replaced.remoteSessionId) fresh.remoteSessionId = replaced.remoteSessionId;
579
594
  if (replacedWasPointer) pointerToken = token;
580
595
  removeClientRecord(replaced.token);
@@ -605,10 +620,10 @@ export function createChannelTransport({
605
620
  c.lastSeen = nowMs();
606
621
  // Flush any targeted frames buffered while this client had no stream (e.g. a
607
622
  // 'superseded' emitted at the moment it was reconnecting). Drop-with-client.
608
- if (c.pending?.length) {
609
- for (const frame of c.pending.splice(0)) {
610
- try { res.write(`data: ${frame}\n\n`); } catch {}
611
- }
623
+ if (c.pendingRemoteStateFrame) {
624
+ const frame = c.pendingRemoteStateFrame;
625
+ c.pendingRemoteStateFrame = null;
626
+ try { res.write(`data: ${frame}\n\n`); } catch {}
612
627
  }
613
628
  // Replay the sticky 'acquired' badge ONLY when THIS attaching client is the
614
629
  // current pointer (the owner). A non-pointer late attach must NOT light the
@@ -661,7 +676,7 @@ export function createChannelTransport({
661
676
  sendError(res, `daemon is draining: ${drainingReason}`, 503);
662
677
  return;
663
678
  }
664
- const body = await readBody(req);
679
+ const body = await readChannelBody(req);
665
680
  const clientToken = registerClient({
666
681
  leadPid: body.leadPid, cwd: body.cwd, passive: body.passive === true,
667
682
  replaceToken: body.replaceToken, registrationId: body.registrationId,
@@ -672,7 +687,7 @@ export function createChannelTransport({
672
687
  return;
673
688
  }
674
689
  if (req.method === 'POST' && pathName === '/client/deregister') {
675
- const body = await readBody(req);
690
+ const body = await readChannelBody(req);
676
691
  if (body.registrationId) {
677
692
  const cancelled = cancelReplacementRegistration(body);
678
693
  if (cancelled === 'forbidden') { sendError(res, 'forbidden replacement deregister', 403); return; }
@@ -695,7 +710,7 @@ export function createChannelTransport({
695
710
  // parallel fair scheduler and per-call cancellation.
696
711
  if (req.method === 'POST' && pathName === '/agent/dispatch') {
697
712
  if (!agentBroker?.dispatch) { sendError(res, 'agent broker unavailable', 503); return; }
698
- const body = await readBody(req);
713
+ const body = await readChannelBody(req);
699
714
  const callId = String(body.callId || '').trim();
700
715
  if (!callId) { sendError(res, 'callId required', 400); return; }
701
716
  res.on('close', () => {
@@ -712,7 +727,7 @@ export function createChannelTransport({
712
727
  }
713
728
  if (req.method === 'POST' && pathName === '/agent/cancel') {
714
729
  if (!agentBroker?.cancel) { sendError(res, 'agent broker unavailable', 503); return; }
715
- const body = await readBody(req);
730
+ const body = await readChannelBody(req);
716
731
  const callId = String(body.callId || '').trim();
717
732
  if (!callId) { sendError(res, 'callId required', 400); return; }
718
733
  const cancelled = agentBroker.cancelAndWait
@@ -728,7 +743,7 @@ export function createChannelTransport({
728
743
  return;
729
744
  }
730
745
  if (req.method === 'POST' && pathName === '/call') {
731
- const body = await readBody(req);
746
+ const body = await readChannelBody(req);
732
747
  const clientToken = body.token || null;
733
748
  const c = clientToken ? clients.get(clientToken) : null;
734
749
  if (!c) { sendError(res, 'unknown client token', 404); return; }
@@ -920,6 +935,7 @@ export function createChannelTransport({
920
935
  _clientsForTest: clients,
921
936
  _registrationReplaysForTest: registrationReplays,
922
937
  _resolveTargetForTest: resolveTarget,
938
+ _writeRemoteStateToForTest: writeRemoteStateTo,
923
939
  get _pointerTokenForTest() { return pointerToken; },
924
940
  get _remoteIntentForTest() { return remoteIntent; },
925
941
  };
@@ -526,6 +526,9 @@ async function main() {
526
526
  function prewarmSessionRuntime() {
527
527
  if (sessionRuntimePrewarmStarted) return;
528
528
  sessionRuntimePrewarmStarted = true;
529
+ void import('../runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs')
530
+ .then((module) => module.prewarmImageResizer?.())
531
+ .catch((error) => log(`image pipeline prewarm failed (non-fatal): ${error?.message || error}`));
529
532
  void sessionRuntimePool.prewarm()
530
533
  .then(() => log('session shard runtime/agent-loop/keychain prewarm started'))
531
534
  .catch((error) => {
@@ -103,6 +103,7 @@ export const SESSION_CONFIGURE_ACTIONS = Object.freeze([
103
103
  'restoreNewerTranscript',
104
104
  'restoreOlderTranscript',
105
105
  'restoreQueued',
106
+ 'revertTurnReview',
106
107
  'revertTurnReviewFile',
107
108
  'rewindToItem',
108
109
  'runDoctor',
@@ -15,6 +15,10 @@ import {
15
15
  SESSION_CONFIGURE_ACTION_SET,
16
16
  SESSION_READ_ACTION_SET,
17
17
  } from './session-protocol.mjs';
18
+ import {
19
+ materializePromptSubmission,
20
+ preparePromptSubmissionForProvider,
21
+ } from '../runtime/attachments/store.mjs';
18
22
 
19
23
  const MAX_CLONE_DEPTH = 24;
20
24
  const requireDesktopService = createRequire(import.meta.url);
@@ -272,6 +276,20 @@ export function createSessionService({
272
276
  const items = entry.itemCache ??= new Map();
273
277
  const out = {};
274
278
  for (const [key, value] of Object.entries(raw)) {
279
+ if (key === 'queued' && Array.isArray(value)) {
280
+ const projected = value.map((item) => ({
281
+ id: item?.id,
282
+ submittedAt: item?.submittedAt,
283
+ text: String(item?.displayText ?? item?.text ?? '').slice(0, 2_000),
284
+ displayText: String(item?.displayText ?? item?.text ?? '').slice(0, 2_000),
285
+ mode: item?.mode || 'prompt',
286
+ priority: item?.priority || 'next',
287
+ ...(Array.isArray(item?.images) && item.images.length ? { images: item.images } : {}),
288
+ }));
289
+ fields.set(key, { source: value, value: projected });
290
+ out[key] = projected;
291
+ continue;
292
+ }
275
293
  if (key === 'items' && Array.isArray(value)) {
276
294
  const nextItems = new Map();
277
295
  out.items = value.map((item) => {
@@ -815,10 +833,14 @@ export function createSessionService({
815
833
  const entry = await entryForSession(id, openHints || {});
816
834
  const target = entry.runtime.submitAsync;
817
835
  if (typeof target !== 'function') throw new TypeError('session runtime must implement submitAsync');
836
+ const intake = await preparePromptSubmissionForProvider(
837
+ materializePromptSubmission(prompt, options || {}),
838
+ entry.runtime.provider || entry.runtime.session?.provider || '',
839
+ );
818
840
  // Await intake only: submitAsync resolves once the prompt is represented by
819
841
  // the queue/user row, while provider execution remains daemon-owned and
820
842
  // detached.
821
- const accepted = await Promise.resolve(target.call(entry.runtime, prompt, options || {}));
843
+ const accepted = await Promise.resolve(target.call(entry.runtime, intake.prompt, intake.options));
822
844
  const firstSubmit = accepted === true && entry.reservedOnly;
823
845
  if (accepted === true) {
824
846
  entry.reservedOnly = false;
@@ -12,7 +12,7 @@
12
12
  // the same transport is exercised by the real daemon entry AND by the smoke
13
13
  // harness with a stub session runtime (no provider, no model download).
14
14
  import http from 'node:http';
15
- import { createHash, randomUUID } from 'node:crypto';
15
+ import { randomUUID } from 'node:crypto';
16
16
  import { sendJson, sendError } from '../runtime/memory/lib/http-wire.mjs';
17
17
  import {
18
18
  compareRuntimeVersions,
@@ -22,6 +22,7 @@ import {
22
22
  runtimeVersion,
23
23
  } from './session-wire.mjs';
24
24
  import { createFairCallScheduler } from './fair-call-scheduler.mjs';
25
+ import { hashStructuredValue } from '../runtime/shared/json-metrics.mjs';
25
26
 
26
27
  // A loopback front door still buffers whatever a client sends before it can be
27
28
  // parsed, so the body has an explicit ceiling instead of the client's memory.
@@ -93,7 +94,7 @@ function readLimitedBody(req, {
93
94
  * submission id that happens to be reused carries a different one. */
94
95
  function callSignature(name, args) {
95
96
  try {
96
- return createHash('sha1').update(`${name}\u0000${JSON.stringify(args ?? {})}`).digest('hex');
97
+ return hashStructuredValue({ name, args: args ?? {} });
97
98
  } catch { return null; }
98
99
  }
99
100
 
@@ -12,7 +12,6 @@ import {
12
12
  import { projectNameFromPath } from './app-format.mjs';
13
13
  import {
14
14
  buildPromptContentWithImages,
15
- expandPastedTextTokens,
16
15
  imageReferenceIds,
17
16
  pastedTextReferenceIds,
18
17
  } from '../paste-attachments.mjs';
@@ -489,15 +488,14 @@ export function createPromptSubmit({
489
488
  const textSnapshot = Object.fromEntries(Object.entries(pastedTextsRef.current || {})
490
489
  .filter(([id]) => textRefs.has(Number(id))));
491
490
  const hasTextSnapshot = Object.keys(textSnapshot).length > 0;
492
- const expandedText = hasTextSnapshot ? expandPastedTextTokens(text, textSnapshot) : text;
493
- const content = buildPromptContentWithImages(expandedText, imageSnapshot);
491
+ const content = buildPromptContentWithImages(text, imageSnapshot);
492
+ const imageRestoreMeta = Object.fromEntries(Object.entries(imageSnapshot).map(([id, image]) => {
493
+ const { content: _content, ...metadata } = image;
494
+ return [id, { ...metadata, sizeBytes: Math.floor((String(image.content || '').length * 3) / 4) }];
495
+ }));
494
496
  const accepted = submitPrompt(content, {
495
- // Store the EXPANDED text in the transcript/history so a later prompt-
496
- // history recall resubmits the real content, not the literal token
497
- // (pastedTexts entries are cleared on accept). History recall therefore
498
- // shows the full original text instead of the token — acceptable.
499
- displayText: expandedText,
500
- pastedImages: imageSnapshot,
497
+ ...((hasImageSnapshot || hasTextSnapshot) ? { displayText: text } : {}),
498
+ pastedImages: imageRestoreMeta,
501
499
  pastedTexts: textSnapshot,
502
500
  onCommitted: (hasImageSnapshot || hasTextSnapshot)
503
501
  ? () => { clearPastedImagesSnapshot(imageSnapshot); clearPastedTextsSnapshot(textSnapshot); }
@@ -83,7 +83,11 @@ export function usePromptHandlers({
83
83
  return Promise.all(chunks.map(async (chunk) => {
84
84
  if (!chunk.imagePath) return chunk.text;
85
85
  try {
86
- const image = await readImageAttachmentFromPath(chunk.text, state.cwd || process.cwd());
86
+ const image = await readImageAttachmentFromPath(
87
+ chunk.text,
88
+ state.cwd || process.cwd(),
89
+ { provider: state.provider || '' },
90
+ );
87
91
  if (!image) return chunk.text;
88
92
  const ref = registerPastedImage(image);
89
93
  showPromptHint(`attached ${image.filename || 'image'}`, 'plain');
@@ -123,7 +127,7 @@ export function usePromptHandlers({
123
127
  .then((clip) => {
124
128
  const normalized = String(clip ?? '').replace(/\r\n?/g, '\n');
125
129
  if (normalized) return processText(normalized, true);
126
- return readClipboardImageAttachment()
130
+ return readClipboardImageAttachment({ provider: state.provider || '' })
127
131
  .then((image) => {
128
132
  if (!image) {
129
133
  showPromptHint('no text or image found on clipboard', 'plain');
@@ -141,7 +145,7 @@ export function usePromptHandlers({
141
145
  }
142
146
 
143
147
  return processText(value);
144
- }, [registerPastedImage, registerPastedText, showPromptHint, state.cwd]);
148
+ }, [registerPastedImage, registerPastedText, showPromptHint, state.cwd, state.provider]);
145
149
 
146
150
  const handlePromptHistoryNavigate = useCallback((direction, currentText = '', meta = {}) => {
147
151
  const currentValue = String(currentText || '');