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.
- package/package.json +9 -2
- package/src/rules/agent/30-explorer.md +43 -32
- package/src/runtime/agent/orchestrator/agent-trace.mjs +19 -5
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +30 -3
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +23 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +44 -6
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +3 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +103 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +10 -30
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -84
- package/src/runtime/attachments/pdf-extract.mjs +83 -0
- package/src/runtime/attachments/store.mjs +515 -0
- package/src/runtime/attachments/store.test.mjs +188 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +1 -191
- package/src/runtime/channels/lib/runtime-paths.mjs +1 -4
- package/src/runtime/channels/lib/worker-ipc.mjs +0 -66
- package/src/runtime/channels/lib/worker-main.mjs +1 -20
- package/src/runtime/memory/lib/http-wire.mjs +39 -4
- package/src/runtime/shared/json-metrics.mjs +94 -0
- package/src/runtime/shared/turn-snapshot.mjs +13 -0
- package/src/runtime/shared/turn-worktree-snapshot.mjs +19 -0
- package/src/session-runtime/provider-models.mjs +11 -1
- package/src/session-runtime/quick-model-rows.mjs +38 -7
- package/src/session-runtime/quick-search-models.mjs +15 -17
- package/src/session-runtime/runtime-core.mjs +9 -4
- package/src/session-runtime/runtime-tunables.mjs +9 -6
- package/src/session-runtime/session-title.mjs +27 -3
- package/src/standalone/channel-transport.mjs +28 -12
- package/src/standalone/daemon.mjs +3 -0
- package/src/standalone/session-protocol.mjs +1 -0
- package/src/standalone/session-service.mjs +23 -1
- package/src/standalone/session-transport.mjs +3 -2
- package/src/tui/app/prompt-submit.mjs +7 -9
- package/src/tui/app/use-prompt-handlers.mjs +7 -3
- package/src/tui/dist/index.mjs +249 -43
- package/src/tui/paste-attachments.mjs +13 -7
- package/src/tui/prompt-history-store.mjs +52 -4
- package/src/tui/session/agent-job-feed.mjs +5 -1
- package/src/tui/session/live-share.mjs +21 -7
- package/src/tui/session/queue-helpers.mjs +6 -6
- package/src/tui/session/session-api-ext.mjs +12 -4
- package/src/tui/session/session-api.mjs +10 -1
- package/src/tui/session/session-flow.mjs +4 -2
- package/src/tui/session-local.mjs +15 -5
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
8
|
-
req.
|
|
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',
|
|
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
|
-
|
|
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) {
|