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
@@ -1,12 +1,6 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
- // Discord permission-prompt + interaction/modal routing extracted from
4
- // channels/index.mjs (behavior-preserving). Installs provider.onInteraction /
5
- // onModalRequest handlers and the terminal tool-exec signal watcher; returns the
6
- // pending-permission map + marker refresher for the worker IPC handler.
1
+ // Discord interaction/modal routing extracted from channels/index.mjs.
7
2
  export function createInteractionHandlers({
8
3
  getProvider,
9
- getConfig,
10
4
  getBridgeRuntimeConnected,
11
5
  instanceId,
12
6
  getBridgeOwnershipSnapshot,
@@ -14,33 +8,12 @@ export function createInteractionHandlers({
14
8
  pendingSetup,
15
9
  buildModalRequestSpec,
16
10
  loadProfileConfig,
17
- getDiscordToken,
18
11
  sendNotifyToParent,
19
12
  scheduler,
20
13
  controlClaudeSession,
21
14
  writeTextFile,
22
15
  TURN_END_FILE,
23
- getPermissionResultPath,
24
- TERMINAL_LEAD_PID,
25
- localTimestamp,
26
- isMixdogDebug,
27
- appendSessionStartCriticalLog,
28
- DATA_DIR,
29
- _bootLog,
30
- RUNTIME_ROOT,
31
16
  }) {
32
- function editDiscordMessage(channelId, messageId, label) {
33
- // Behavior-preserving: route through the getProvider() abstraction (which uses
34
- // discord.js under the hood) instead of issuing a raw REST PATCH. Errors
35
- // are swallowed to stderr to match the prior fire-and-forget shape — the
36
- // call site never awaited the HTTPS request either.
37
- if (!getDiscordToken()) return;
38
- const text = `\u{1F510} **Permission Request** \u2014 ${label}`;
39
- void getProvider().editMessage(channelId, messageId, text, { components: [] }).catch((err) => {
40
- process.stderr.write(`mixdog: editDiscordMessage failed: ${err}
41
- `);
42
- });
43
- }
44
17
  getProvider().onModalRequest = async (rawInteraction) => {
45
18
  if (!getBridgeRuntimeConnected() || !getBridgeOwnershipSnapshot().owned) {
46
19
  refreshBridgeOwnershipSafe();
@@ -67,170 +40,8 @@ getProvider().onModalRequest = async (rawInteraction) => {
67
40
  modal.addComponents(...rows);
68
41
  await rawInteraction.showModal(modal);
69
42
  };
70
- const pendingPermRequests = new Map();
71
- const TOOL_EXEC_CONSUMER_MARKER = path.join(RUNTIME_ROOT, '.tool-exec-consumer');
72
- function refreshToolExecConsumerMarker() {
73
- try {
74
- if (pendingPermRequests.size > 0) {
75
- fs.writeFileSync(TOOL_EXEC_CONSUMER_MARKER, String(Date.now()));
76
- } else {
77
- try { fs.unlinkSync(TOOL_EXEC_CONSUMER_MARKER); } catch {}
78
- }
79
- } catch {}
80
- }
81
- // Watch for terminal-approved tool executions. The PostToolUse hook writes a
82
- // signal file per tool call; when we see one, find the oldest pending perm
83
- // request with a matching tool name and mark its Discord message as
84
- // "Allowed (terminal)" so users don't see stale active buttons.
85
- try {
86
- try { if (!fs.existsSync(RUNTIME_ROOT)) fs.mkdirSync(RUNTIME_ROOT, { recursive: true }); } catch {}
87
- const SIGNAL_RE = /^tool-exec-\d+-[0-9a-f]+\.signal$/;
88
- fs.watch(RUNTIME_ROOT, { persistent: false }, (eventType, filename) => {
89
- if (!filename || !SIGNAL_RE.test(filename)) return;
90
- setTimeout(() => {
91
- try {
92
- const signalPath = path.join(RUNTIME_ROOT, filename);
93
- let raw;
94
- try { raw = fs.readFileSync(signalPath, 'utf8'); } catch { return; }
95
- let payload;
96
- try { payload = JSON.parse(raw); } catch { return; }
97
- const toolName = payload?.toolName;
98
- if (!toolName) return;
99
- const sigFilePath = payload?.filePath || '';
100
- let oldestKey = null;
101
- let oldestEntry = null;
102
- for (const [k, v] of pendingPermRequests) {
103
- if (v.toolName !== toolName) continue;
104
- // Bind on filePath too. If both sides are empty (non-file tools
105
- // like Bash), toolName alone is the match. Otherwise both must
106
- // equal — prevents two concurrent Edit/Write requests from
107
- // cross-approving each other.
108
- const vFilePath = v.filePath || '';
109
- if (vFilePath || sigFilePath) {
110
- if (vFilePath !== sigFilePath) continue;
111
- }
112
- if (!oldestEntry || v.createdAt < oldestEntry.createdAt) {
113
- oldestKey = k;
114
- oldestEntry = v;
115
- }
116
- }
117
- // No matching pending request — leave the signal on disk so a
118
- // agent role hook (or other consumer) gets a chance to claim it.
119
- if (!oldestKey || !oldestEntry) return;
120
- if (oldestEntry.channelId && oldestEntry.messageId) {
121
- try {
122
- editDiscordMessage(oldestEntry.channelId, oldestEntry.messageId, 'Allowed (terminal)');
123
- } catch (err) {
124
- try { process.stderr.write(`mixdog channels: tool-exec signal edit failed: ${err && err.message || err}\n`); } catch {}
125
- }
126
- }
127
- pendingPermRequests.delete(oldestKey);
128
- refreshToolExecConsumerMarker();
129
- // Only unlink once we've confirmed the match and handled it.
130
- try { fs.unlinkSync(signalPath); } catch {}
131
- } catch (err) {
132
- try { process.stderr.write(`mixdog channels: tool-exec signal handler error: ${err && err.message || err}\n`); } catch {}
133
- }
134
- }, 50);
135
- });
136
- // Stale-signal sweeper: any signal file older than 60s is removed so
137
- // unclaimed files don't accumulate on disk. Runs every 30s.
138
- setInterval(() => {
139
- try {
140
- const now = Date.now();
141
- const entries = fs.readdirSync(RUNTIME_ROOT);
142
- for (const name of entries) {
143
- if (!SIGNAL_RE.test(name)) continue;
144
- const p = path.join(RUNTIME_ROOT, name);
145
- try {
146
- const st = fs.statSync(p);
147
- if (now - st.mtimeMs > 60_000) {
148
- try { fs.unlinkSync(p); } catch {}
149
- }
150
- } catch {}
151
- }
152
- } catch {}
153
- }, 30_000)?.unref?.();
154
- } catch (err) {
155
- try { process.stderr.write(`mixdog channels: tool-exec signal watcher setup failed: ${err && err.message || err}\n`); } catch {}
156
- }
157
43
 
158
44
  getProvider().onInteraction = (interaction) => {
159
- // Channel-route permission reply. Custom_id format: perm-ch-{request_id}-{allow|session|deny}.
160
- // request_id is the 5-letter short ID CC generates via shortRequestId().
161
- // Emit notifications/claude/channel/permission back to the MCP host; the race
162
- // logic in interactiveHandler.ts resolves the pending request and dismisses
163
- // every other racer (local dialog, bridge, hook, classifier).
164
- if (interaction.customId?.startsWith("perm-ch-")) {
165
- const match = interaction.customId.match(/^perm-ch-([a-km-z]{5})-(allow|session|deny)$/);
166
- if (!match) return;
167
- const [, requestId, action] = match;
168
- const access = getConfig().access;
169
- if (access?.allowFrom?.length > 0 && !access.allowFrom.includes(interaction.userId)) {
170
- process.stderr.write(`mixdog: perm-ch button rejected — user ${interaction.userId} not in allowFrom\n`);
171
- return;
172
- }
173
- const pending = pendingPermRequests.get(requestId);
174
- pendingPermRequests.delete(requestId);
175
- refreshToolExecConsumerMarker();
176
- const params = { request_id: requestId };
177
- if (action === 'deny') {
178
- params.behavior = 'deny';
179
- } else if (action === 'session') {
180
- params.behavior = 'allow';
181
- const toolName = pending?.toolName;
182
- if (toolName) {
183
- params.updatedPermissions = [{ type: 'addRules', rules: [{ toolName }], behavior: 'allow', destination: 'session' }];
184
- }
185
- } else {
186
- params.behavior = 'allow';
187
- }
188
- sendNotifyToParent('notifications/claude/channel/permission', params);
189
- const labels = { allow: 'Approved', session: 'Session Approved', deny: 'Denied' };
190
- if (interaction.message?.id && interaction.channelId) {
191
- editDiscordMessage(interaction.channelId, interaction.message.id, labels[action] || action);
192
- }
193
- return;
194
- }
195
- if (interaction.customId?.startsWith("perm-")) {
196
- const match = interaction.customId.match(/^perm-([0-9a-f]{32})-(allow|session|deny)$/);
197
- if (!match) return;
198
- const [, uuid, action] = match;
199
- const access = getConfig().access;
200
- if (!access) {
201
- const _permDropLine = `[${localTimestamp()}] perm interaction dropped: no access getConfig()\n`;
202
- if (isMixdogDebug()) {
203
- fs.appendFileSync(_bootLog, _permDropLine);
204
- } else {
205
- appendSessionStartCriticalLog(DATA_DIR, `[channels] ${_permDropLine}`);
206
- }
207
- return;
208
- }
209
- if (access.allowFrom?.length > 0 && !access.allowFrom.includes(interaction.userId)) {
210
- process.stderr.write(`mixdog: perm button rejected \u2014 user ${interaction.userId} not in allowFrom
211
- `);
212
- return;
213
- }
214
- const resultPaths = [getPermissionResultPath(instanceId, uuid)];
215
- const leadInstanceId = String(TERMINAL_LEAD_PID);
216
- if (leadInstanceId && leadInstanceId !== instanceId) {
217
- resultPaths.push(getPermissionResultPath(leadInstanceId, uuid));
218
- }
219
- for (const resultPath of resultPaths) {
220
- try {
221
- fs.writeFileSync(resultPath, action, { flag: "wx" });
222
- } catch (e) {
223
- if (e.code !== "EEXIST") {
224
- process.stderr.write(`mixdog: writePermissionResult failed: ${e.message}\n`);
225
- }
226
- }
227
- }
228
- const labels = { allow: "Approved", session: "Session Approved", deny: "Denied" };
229
- if (interaction.message?.id && interaction.channelId) {
230
- editDiscordMessage(interaction.channelId, interaction.message.id, labels[action] || action);
231
- }
232
- return;
233
- }
234
45
  if (!getBridgeRuntimeConnected() || !getBridgeOwnershipSnapshot().owned) {
235
46
  refreshBridgeOwnershipSafe();
236
47
  return;
@@ -256,5 +67,4 @@ getProvider().onInteraction = (interaction) => {
256
67
  }
257
68
  });
258
69
  };
259
- return { pendingPermRequests, refreshToolExecConsumerMarker };
260
70
  }
@@ -117,9 +117,6 @@ function getControlPath(instanceId) {
117
117
  function getControlResponsePath(instanceId) {
118
118
  return join(RUNTIME_ROOT, `control-${sanitize(instanceId)}.response.json`);
119
119
  }
120
- function getPermissionResultPath(instanceId, uuid) {
121
- return join(RUNTIME_ROOT, `perm-${sanitize(instanceId)}-${sanitize(uuid)}.result`);
122
- }
123
120
  function getStopFlagPath(instanceId) {
124
121
  return join(RUNTIME_ROOT, `stop-${sanitize(instanceId)}.flag`);
125
122
  }
@@ -424,6 +421,7 @@ function cleanupInstanceRuntimeFiles(instanceId) {
424
421
  for (const target of targets) {
425
422
  removeFileIfExists(target);
426
423
  }
424
+ // Remove artifacts left by pre-standalone Discord approval builds.
427
425
  forEachFile(RUNTIME_ROOT, (fullPath, file) => {
428
426
  if (file.startsWith(`perm-${sanitize(instanceId)}-`)) {
429
427
  removeFileIfExists(fullPath);
@@ -455,7 +453,6 @@ export {
455
453
  getChannelOwnerPath,
456
454
  getControlPath,
457
455
  getControlResponsePath,
458
- getPermissionResultPath,
459
456
  getTerminalLeadPid,
460
457
  getStatusPath,
461
458
  getTurnEndPath,
@@ -14,8 +14,6 @@ export function runWorkerIpc({
14
14
  statusState,
15
15
  getProvider,
16
16
  getConfig,
17
- pendingPermRequests,
18
- refreshToolExecConsumerMarker,
19
17
  handleMemoryCallResponse,
20
18
  handleToolCallWithBridgeRetry,
21
19
  bootProfile,
@@ -84,70 +82,6 @@ export function runWorkerIpc({
84
82
  } catch { /* best-effort */ }
85
83
  return;
86
84
  }
87
- // Host permission request → Discord Allow/Deny prompt.
88
- // Parent (server.mjs) receives notifications/claude/channel/permission_request
89
- // from the MCP host and forwards the params here. We post a buttoned message;
90
- // button clicks are handled in getProvider().onInteraction and sent back to CC as
91
- // notifications/claude/channel/permission via sendNotifyToParent.
92
- if (msg && msg.type === 'permission_request_inbound') {
93
- try {
94
- const { request_id, tool_name, description, input_preview } = msg.params || {};
95
- // tool_input arrives via the passthrough() schema in server.mjs when
96
- // The host includes it in the permission_request notification.
97
- // Used to bind the pendingPermRequest to a specific file so two
98
- // concurrent Edit/Write requests cannot cross-approve via the
99
- // terminal signal.
100
- const toolInputParam = (msg.params && (msg.params.tool_input || msg.params.toolInput)) || {};
101
- const filePathParam = toolInputParam.file_path || '';
102
- if (!request_id || !tool_name) return;
103
- if (pendingPermRequests.size > 100) {
104
- const cutoff = Date.now() - 30 * 60 * 1000;
105
- for (const [k, v] of pendingPermRequests) {
106
- if (v.createdAt < cutoff) pendingPermRequests.delete(k);
107
- }
108
- refreshToolExecConsumerMarker();
109
- }
110
- const target = (statusState?.read?.().channelId)
111
- || getConfig()?.channelId
112
- || null;
113
- if (!target || !getProvider()?.sendMessage) {
114
- process.stderr.write(`mixdog channels: permission_request dropped, no target channel (request_id=${request_id})\n`);
115
- return;
116
- }
117
- const lines = [`🔐 **Permission Request**`, `Tool: \`${tool_name}\``];
118
- if (description) lines.push(description);
119
- if (input_preview) lines.push('```\n' + String(input_preview).slice(0, 800) + '\n```');
120
- const content = lines.join('\n');
121
- const components = [{
122
- type: 1,
123
- components: [
124
- { type: 2, style: 3, label: 'Allow', custom_id: `perm-ch-${request_id}-allow` },
125
- { type: 2, style: 1, label: 'Session Allow', custom_id: `perm-ch-${request_id}-session` },
126
- { type: 2, style: 4, label: 'Deny', custom_id: `perm-ch-${request_id}-deny` },
127
- ],
128
- }];
129
- let sentIds = null;
130
- try {
131
- const sendResult = await getProvider().sendMessage(target, content, { components });
132
- sentIds = sendResult?.sentIds;
133
- } catch (err) {
134
- process.stderr.write(`mixdog channels: permission_request Discord send failed: ${err && err.message || err}\n`);
135
- return;
136
- }
137
- const messageId = Array.isArray(sentIds) && sentIds.length > 0 ? sentIds[0] : null;
138
- pendingPermRequests.set(request_id, {
139
- toolName: tool_name,
140
- filePath: filePathParam,
141
- createdAt: Date.now(),
142
- channelId: target,
143
- messageId,
144
- });
145
- refreshToolExecConsumerMarker();
146
- } catch (err) {
147
- try { process.stderr.write(`mixdog channels: permission_request handler error: ${err && err.message || err}\n`); } catch {}
148
- }
149
- return;
150
- }
151
85
  if (handleMemoryCallResponse(msg)) return;
152
86
  if (msg.type === 'cancel' && msg.callId) {
153
87
  const entry = _inFlightChannelCalls.get(msg.callId)
@@ -35,7 +35,6 @@ import {
35
35
  makeInstanceId,
36
36
  getTurnEndPath,
37
37
  getStatusPath,
38
- getPermissionResultPath,
39
38
  getChannelOwnerPath,
40
39
  getActiveOwnerPid,
41
40
  getTerminalLeadPid,
@@ -51,7 +50,6 @@ import {
51
50
  clearServerPid,
52
51
  RUNTIME_ROOT
53
52
  } from "./runtime-paths.mjs";
54
- import { getDiscordToken } from "./config.mjs";
55
53
  import { invalidateConfigReadCache } from "../../shared/config.mjs";
56
54
  import { bootProfile, localTimestamp } from "./boot-profile.mjs";
57
55
  import {
@@ -125,7 +123,6 @@ const _bootLogEarly = path.join(
125
123
  const {
126
124
  isMixdogDebugEnabled: isMixdogDebug,
127
125
  pruneStalePluginDataLogSiblings,
128
- appendSessionStartCriticalLog,
129
126
  DEFAULT_STALE_LOG_SIBLING_MAX,
130
127
  } = _require("../../../lib/mixdog-debug.cjs");
131
128
  // One-shot log rotation at worker boot (10 MB threshold, .1 suffix overwrite).
@@ -134,7 +131,6 @@ if (isMixdogDebug()) {
134
131
  fs.appendFileSync(_bootLogEarly, `[${localTimestamp()}] bootstrap start pid=${process.pid}
135
132
  `);
136
133
  }
137
- const _bootLog = path.join(DATA_DIR, "boot.log");
138
134
  let config = await loadConfig();
139
135
  let provider = createProvider(config);
140
136
  const INSTANCE_ID = makeInstanceId();
@@ -499,12 +495,8 @@ function wireEventQueueHandlers(eventQueue) {
499
495
  eventQueue.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
500
496
  forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
501
497
  }
502
- const {
503
- pendingPermRequests,
504
- refreshToolExecConsumerMarker,
505
- } = createInteractionHandlers({
498
+ createInteractionHandlers({
506
499
  getProvider: () => provider,
507
- getConfig: () => config,
508
500
  getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
509
501
  instanceId: INSTANCE_ID,
510
502
  getBridgeOwnershipSnapshot,
@@ -512,20 +504,11 @@ const {
512
504
  pendingSetup,
513
505
  buildModalRequestSpec,
514
506
  loadProfileConfig,
515
- getDiscordToken,
516
507
  sendNotifyToParent,
517
508
  scheduler,
518
509
  controlClaudeSession,
519
510
  writeTextFile,
520
511
  TURN_END_FILE,
521
- getPermissionResultPath,
522
- TERMINAL_LEAD_PID,
523
- localTimestamp,
524
- isMixdogDebug,
525
- appendSessionStartCriticalLog,
526
- DATA_DIR,
527
- _bootLog,
528
- RUNTIME_ROOT,
529
512
  });
530
513
  const { isVoiceAttachment, transcribeVoice } = createVoiceTranscription({
531
514
  getConfig: () => config,
@@ -741,8 +724,6 @@ if (_isWorkerMode && process.send && process.env.MIXDOG_DAEMON_HOST !== '1') {
741
724
  statusState,
742
725
  getProvider: () => provider,
743
726
  getConfig: () => config,
744
- pendingPermRequests,
745
- refreshToolExecConsumerMarker,
746
727
  handleMemoryCallResponse,
747
728
  handleToolCallWithBridgeRetry,
748
729
  bootProfile,
@@ -2,11 +2,41 @@
2
2
  // utilities with no module state — no db, _traceDb, or timer dependencies.
3
3
  // index.mjs imports these; behavior and signatures are unchanged.
4
4
 
5
- export function readBody(req) {
5
+ export const MAX_HTTP_BODY_BYTES = 1024 * 1024
6
+
7
+ export function readBody(req, { maxBytes = MAX_HTTP_BODY_BYTES } = {}) {
6
8
  return new Promise((resolve, reject) => {
7
- const chunks = []
8
- req.on('data', c => chunks.push(c))
9
+ const limit = Math.max(1, Number(maxBytes) || MAX_HTTP_BODY_BYTES)
10
+ const contentLength = Number(req.headers?.['content-length'] || 0)
11
+ let chunks = []
12
+ let total = 0
13
+ let settled = false
14
+ const rejectTooLarge = () => {
15
+ if (settled) return
16
+ settled = true
17
+ chunks = []
18
+ const error = new Error(`request body exceeds the ${limit} byte limit`)
19
+ error.statusCode = 413
20
+ reject(error)
21
+ }
22
+ if (Number.isFinite(contentLength) && contentLength > limit) {
23
+ req.resume?.()
24
+ rejectTooLarge()
25
+ return
26
+ }
27
+ req.on('data', c => {
28
+ if (settled) return
29
+ const chunk = Buffer.isBuffer(c) ? c : Buffer.from(c)
30
+ total += chunk.length
31
+ if (total > limit) {
32
+ rejectTooLarge()
33
+ return
34
+ }
35
+ chunks.push(chunk)
36
+ })
9
37
  req.on('end', () => {
38
+ if (settled) return
39
+ settled = true
10
40
  const raw = Buffer.concat(chunks).toString('utf8').trim()
11
41
  if (!raw) { resolve({}); return }
12
42
  try { resolve(JSON.parse(raw)) }
@@ -16,7 +46,12 @@ export function readBody(req) {
16
46
  reject(e)
17
47
  }
18
48
  })
19
- req.on('error', reject)
49
+ req.on('error', error => {
50
+ if (settled) return
51
+ settled = true
52
+ chunks = []
53
+ reject(error)
54
+ })
20
55
  })
21
56
  }
22
57
 
@@ -0,0 +1,94 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ function escapedStringBytes(value) {
4
+ const text = String(value);
5
+ let bytes = Buffer.byteLength(text, 'utf8') + 2;
6
+ for (let index = 0; index < text.length; index += 1) {
7
+ const code = text.charCodeAt(index);
8
+ if (code === 0x22 || code === 0x5c || code === 0x08 || code === 0x0c
9
+ || code === 0x0a || code === 0x0d || code === 0x09) bytes += 1;
10
+ else if (code < 0x20) bytes += 5;
11
+ }
12
+ return bytes;
13
+ }
14
+
15
+ export function estimateJsonBytes(value, seen = new Set()) {
16
+ if (value === null) return 4;
17
+ if (typeof value === 'string') return escapedStringBytes(value);
18
+ if (typeof value === 'number') return Number.isFinite(value) ? String(value).length : 4;
19
+ if (typeof value === 'boolean') return value ? 4 : 5;
20
+ if (typeof value === 'bigint') return String(value).length;
21
+ if (value === undefined || typeof value === 'function' || typeof value === 'symbol') return 0;
22
+ if (seen.has(value)) return 0;
23
+ seen.add(value);
24
+ try {
25
+ if (Array.isArray(value)) {
26
+ let bytes = 2;
27
+ for (let index = 0; index < value.length; index += 1) {
28
+ if (index > 0) bytes += 1;
29
+ bytes += estimateJsonBytes(value[index], seen);
30
+ }
31
+ return bytes;
32
+ }
33
+ let bytes = 2;
34
+ let first = true;
35
+ for (const [key, entry] of Object.entries(value)) {
36
+ if (entry === undefined || typeof entry === 'function' || typeof entry === 'symbol') continue;
37
+ if (!first) bytes += 1;
38
+ first = false;
39
+ bytes += escapedStringBytes(key) + 1 + estimateJsonBytes(entry, seen);
40
+ }
41
+ return bytes;
42
+ } finally {
43
+ seen.delete(value);
44
+ }
45
+ }
46
+
47
+ export function hashStructuredValue(value, {
48
+ algorithm = 'sha1',
49
+ maxStringChars = Infinity,
50
+ } = {}) {
51
+ const hash = createHash(algorithm);
52
+ let remaining = Number.isFinite(maxStringChars) ? Math.max(0, maxStringChars) : Infinity;
53
+ const seen = new Set();
54
+ const walk = (entry) => {
55
+ if (remaining <= 0) return;
56
+ if (entry === null) { hash.update('n;'); return; }
57
+ const type = typeof entry;
58
+ if (type === 'string') {
59
+ const slice = remaining === Infinity ? entry : entry.slice(0, remaining);
60
+ hash.update('s:').update(slice).update(';');
61
+ if (remaining !== Infinity) remaining -= slice.length;
62
+ return;
63
+ }
64
+ if (type === 'number' || type === 'boolean' || type === 'bigint') {
65
+ hash.update(`${type[0]}:${String(entry)};`);
66
+ return;
67
+ }
68
+ if (type === 'undefined' || type === 'function' || type === 'symbol') {
69
+ hash.update('u;');
70
+ return;
71
+ }
72
+ if (seen.has(entry)) { hash.update('c;'); return; }
73
+ seen.add(entry);
74
+ try {
75
+ if (Array.isArray(entry)) {
76
+ hash.update('[');
77
+ for (const item of entry) walk(item);
78
+ hash.update(']');
79
+ return;
80
+ }
81
+ hash.update('{');
82
+ for (const [key, item] of Object.entries(entry)) {
83
+ hash.update('k:').update(key).update(';');
84
+ walk(item);
85
+ if (remaining <= 0) break;
86
+ }
87
+ hash.update('}');
88
+ } finally {
89
+ seen.delete(entry);
90
+ }
91
+ };
92
+ walk(value);
93
+ return hash.digest('hex');
94
+ }
@@ -4,6 +4,7 @@ import {
4
4
  createTurnWorktreeSnapshot,
5
5
  refreshTurnWorktreeSnapshot,
6
6
  revertTurnWorktreeFile,
7
+ revertTurnWorktreeSnapshot,
7
8
  } from './turn-worktree-snapshot.mjs';
8
9
 
9
10
  // Turn-scoped review registry.
@@ -501,6 +502,18 @@ export async function revertTurnReviewFile(_worktree, sessionId, file) {
501
502
  return await getTurnReviewDiff(_worktree, ownerSessionId);
502
503
  }
503
504
 
505
+ /** Restore every file in the reviewed turn to its turn-start state. */
506
+ export async function revertTurnReview(_worktree, sessionId) {
507
+ const ownerSessionId = clean(sessionId);
508
+ const tracker = _diffTrackersBySession.get(ownerSessionId);
509
+ if (tracker?.worktreeContended) {
510
+ throw new Error('turn review revert is unavailable while sessions share a worktree');
511
+ }
512
+ if (!tracker?.worktreeSnapshot) throw new Error('turn worktree snapshot is unavailable');
513
+ await revertTurnWorktreeSnapshot(tracker.worktreeSnapshot);
514
+ return await getTurnReviewDiff(_worktree, ownerSessionId);
515
+ }
516
+
504
517
  export function _resetTurnSnapshotForTest() {
505
518
  _turnsBySession.clear();
506
519
  _diffTrackersBySession.clear();
@@ -547,6 +547,25 @@ export async function revertTurnWorktreeFile(snapshot, value) {
547
547
  });
548
548
  }
549
549
 
550
+ export async function revertTurnWorktreeSnapshot(snapshot) {
551
+ if (!snapshot?.state || !snapshot.baselineTree) throw new Error('turn worktree snapshot is unavailable');
552
+ return await withStateLock(snapshot.state, async () => {
553
+ // Resolve and validate every path before the first mutation. Renames and
554
+ // copies contribute both sides so the worktree returns to the exact
555
+ // turn-start tree rather than leaving a destination behind.
556
+ const targets = [...new Set(snapshot.files
557
+ .flatMap((file) => [file.path, file.oldPath])
558
+ .filter(Boolean))]
559
+ .map((target) => safeRelativePath(snapshot.root, target));
560
+ for (const target of targets) {
561
+ await restorePathFromTree(snapshot, target);
562
+ }
563
+ const currentTree = await captureTreeUnlocked(snapshot.state);
564
+ Object.assign(snapshot, await diffTreesUnlocked(snapshot.state, snapshot.baselineTree, currentTree));
565
+ return snapshot;
566
+ });
567
+ }
568
+
550
569
  export function _resetTurnWorktreeSnapshotsForTest() {
551
570
  states.clear();
552
571
  }
@@ -233,6 +233,13 @@ export function createProviderModels({
233
233
  const providersStartedAt = performance.now();
234
234
  await ensureProvidersReady(config().providers || {});
235
235
  profile('providers-ready', { ms: (performance.now() - providersStartedAt).toFixed(1) });
236
+ // ensureProvidersReady starts the daemon-wide force refresh without
237
+ // blocking boot. A foreground full picker load must join that refresh;
238
+ // otherwise it can snapshot yesterday's provider cache moments before the
239
+ // refresh invalidates it, leaving the UI on the stale first-open rows.
240
+ if (!forceRefresh && typeof reg().refreshProviderCatalogsOnStartup === 'function') {
241
+ await reg().refreshProviderCatalogsOnStartup();
242
+ }
236
243
  if (forceRefresh && typeof reg().refreshCatalogs === 'function') {
237
244
  await reg().refreshCatalogs();
238
245
  }
@@ -305,7 +312,10 @@ export function createProviderModels({
305
312
  return providerModelsFromCacheRows(caches.providerModelsCache.models);
306
313
  }
307
314
  if (!force && quick) {
308
- warmProviderModelCache();
315
+ // A user-facing quick read seeds the authoritative secrets-aware load.
316
+ // Desktop asks quick first and full second; a no-secrets warm here made
317
+ // the full request join a partial catalog and only recover on re-entry.
318
+ warmProviderModelCache({ loadSecrets: true });
309
319
  return quickHelpers.quickProviderModelRows();
310
320
  }
311
321
  if (force) {