mixdog 0.7.5 → 0.7.7
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +18 -0
- package/README.md +18 -0
- package/hooks/hooks.json +6 -6
- package/hooks/session-start.cjs +73 -2
- package/hooks/shim-launcher.cjs +51 -0
- package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
- package/package.json +2 -2
- package/scripts/bootstrap.mjs +5 -59
- package/scripts/ensure-deps.mjs +259 -0
- package/scripts/resolve-bun.mjs +60 -0
- package/scripts/run-mcp.mjs +13 -168
- package/setup/install.mjs +220 -22
- package/setup/launch.mjs +0 -0
- package/setup/locate-claude.mjs +38 -0
- package/setup/mixdog-cli.mjs +95 -0
- package/setup/setup-server.mjs +50 -2
- package/setup/setup.html +26 -12
- package/setup/tui.mjs +606 -0
- package/setup/wizard.mjs +220 -151
- package/src/agent/bridge-stall-watchdog.mjs +2 -2
- package/src/agent/index.mjs +3 -3
- package/src/agent/orchestrator/providers/anthropic-oauth.mjs +139 -0
- package/src/agent/orchestrator/providers/openai-oauth.mjs +96 -0
- package/src/agent/orchestrator/session/manager.mjs +5 -3
- package/src/agent/orchestrator/session/store.mjs +9 -1
- package/src/channels/lib/runtime-paths.mjs +112 -74
- package/src/memory/index.mjs +30 -7
- package/src/memory/lib/pg/supervisor.mjs +12 -12
- package/src/shared/atomic-file.mjs +16 -0
- package/src/status/aggregator.mjs +3 -3
package/setup/wizard.mjs
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* Callers must set process.env.CLAUDE_PLUGIN_DATA before importing this
|
|
6
6
|
* module (install.mjs does that) so src/shared/config.mjs can resolve paths.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
9
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
10
10
|
import { join, dirname, basename } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { defaultPluginDataDir } from './install.mjs';
|
|
12
13
|
import {
|
|
13
14
|
mergeAgentConfig,
|
|
14
15
|
mergeMemoryConfig,
|
|
@@ -16,6 +17,19 @@ import {
|
|
|
16
17
|
mergeSearchConfig,
|
|
17
18
|
} from './config-merge.mjs';
|
|
18
19
|
import { DEFAULT_MODELS } from '../src/search/lib/config.mjs';
|
|
20
|
+
import {
|
|
21
|
+
select,
|
|
22
|
+
multiselect,
|
|
23
|
+
confirm,
|
|
24
|
+
text,
|
|
25
|
+
password,
|
|
26
|
+
createProgressBar,
|
|
27
|
+
createSpinner,
|
|
28
|
+
} from './tui.mjs';
|
|
29
|
+
|
|
30
|
+
let _linuxSecretsCapable;
|
|
31
|
+
const KEYTAR_SERVICE = 'mixdog';
|
|
32
|
+
const KEYTAR_PROBE_TIMEOUT_MS = 8000;
|
|
19
33
|
|
|
20
34
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
35
|
const REPO_ROOT = join(__dirname, '..');
|
|
@@ -52,12 +66,59 @@ const SEARCH_OAUTH_ALIASES = Object.freeze({
|
|
|
52
66
|
});
|
|
53
67
|
function pluginDataDir() {
|
|
54
68
|
const dir = process.env.CLAUDE_PLUGIN_DATA;
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
'CLAUDE_PLUGIN_DATA must be set before running the setup wizard (install.mjs sets it unconditionally)',
|
|
58
|
-
);
|
|
69
|
+
if (dir && typeof dir === 'string' && String(dir).trim()) {
|
|
70
|
+
return String(dir).trim();
|
|
59
71
|
}
|
|
60
|
-
return
|
|
72
|
+
return defaultPluginDataDir();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One-time Linux preflight: optional keytar must load before any secret prompt. */
|
|
76
|
+
export function resetLinuxSecretsCapableCache() {
|
|
77
|
+
_linuxSecretsCapable = undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function runLinuxKeytarOperationalProbe() {
|
|
81
|
+
const script = [
|
|
82
|
+
'try {',
|
|
83
|
+
'const keytar = require("keytar");',
|
|
84
|
+
'if (process.env.MIXDOG_KEYTAR_PROBE_INJECT_FAIL === "1") process.exit(3);',
|
|
85
|
+
`keytar.findCredentials(${JSON.stringify(KEYTAR_SERVICE)})`,
|
|
86
|
+
' .then(() => process.exit(0))',
|
|
87
|
+
' .catch((e) => { process.stderr.write(String(e && e.message ? e.message : e)); process.exit(1); });',
|
|
88
|
+
'} catch (e) { process.stderr.write(String(e && e.message ? e.message : e)); process.exit(2); }',
|
|
89
|
+
].join(' ');
|
|
90
|
+
const r = spawnSync(process.execPath, ['-e', script], {
|
|
91
|
+
env: { ...process.env },
|
|
92
|
+
encoding: 'utf8',
|
|
93
|
+
timeout: KEYTAR_PROBE_TIMEOUT_MS,
|
|
94
|
+
windowsHide: true,
|
|
95
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
96
|
+
});
|
|
97
|
+
if (r.error) return false;
|
|
98
|
+
return r.status === 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {{ treatAsLinux?: boolean }} [probeOptions] — .scratch harness only (operational probe).
|
|
103
|
+
*/
|
|
104
|
+
export function probeLinuxSecretsCapable(probeOptions = null) {
|
|
105
|
+
const isLinux = probeOptions?.treatAsLinux === true || process.platform === 'linux';
|
|
106
|
+
if (!isLinux) return true;
|
|
107
|
+
if (_linuxSecretsCapable !== undefined) return _linuxSecretsCapable;
|
|
108
|
+
_linuxSecretsCapable = runLinuxKeytarOperationalProbe();
|
|
109
|
+
return _linuxSecretsCapable;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function linuxKeychainUnavailableMessage() {
|
|
113
|
+
return [
|
|
114
|
+
'',
|
|
115
|
+
'⚠ Linux keychain unavailable (optional `keytar` not installed or libsecret missing).',
|
|
116
|
+
' Secret prompts are skipped; non-secret setup continues. Install keytar later, then use the Mixdog UI:',
|
|
117
|
+
' Debian/Ubuntu: sudo apt install libsecret-1-dev then: npm install keytar',
|
|
118
|
+
' Fedora/RHEL: sudo dnf install libsecret-devel then: npm install keytar',
|
|
119
|
+
' Arch: sudo pacman -S libsecret then: npm install keytar',
|
|
120
|
+
'',
|
|
121
|
+
].join('\n');
|
|
61
122
|
}
|
|
62
123
|
|
|
63
124
|
function sanitizeName(n) {
|
|
@@ -81,61 +142,13 @@ function defaultIo() {
|
|
|
81
142
|
say: (line) => { if (line) console.log(line); },
|
|
82
143
|
};
|
|
83
144
|
}
|
|
84
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
85
|
-
const ask = (prompt) => new Promise((resolve) => {
|
|
86
|
-
rl.question(prompt, (answer) => resolve(answer));
|
|
87
|
-
});
|
|
88
|
-
const askSecret = (prompt) => readHiddenLine(prompt).finally(() => {
|
|
89
|
-
rl.resume();
|
|
90
|
-
});
|
|
91
145
|
return {
|
|
92
146
|
interactive: true,
|
|
93
|
-
ask,
|
|
94
|
-
askSecret,
|
|
95
147
|
say: (line) => { if (line) console.log(line); },
|
|
96
|
-
close: () =>
|
|
148
|
+
close: () => {},
|
|
97
149
|
};
|
|
98
150
|
}
|
|
99
151
|
|
|
100
|
-
function readHiddenLine(prompt) {
|
|
101
|
-
return new Promise((resolve, reject) => {
|
|
102
|
-
const stdin = process.stdin;
|
|
103
|
-
if (!stdin.isTTY) {
|
|
104
|
-
resolve('');
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
const wasRaw = stdin.isRaw;
|
|
108
|
-
stdin.setRawMode(true);
|
|
109
|
-
stdin.resume();
|
|
110
|
-
process.stdout.write(prompt);
|
|
111
|
-
let value = '';
|
|
112
|
-
const onData = (chunk) => {
|
|
113
|
-
const s = chunk.toString('utf8');
|
|
114
|
-
for (const ch of s) {
|
|
115
|
-
if (ch === '\n' || ch === '\r' || ch === '\u0004') {
|
|
116
|
-
stdin.removeListener('data', onData);
|
|
117
|
-
stdin.setRawMode(wasRaw);
|
|
118
|
-
process.stdout.write('\n');
|
|
119
|
-
resolve(value);
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
if (ch === '\u0003') {
|
|
123
|
-
stdin.removeListener('data', onData);
|
|
124
|
-
stdin.setRawMode(wasRaw);
|
|
125
|
-
reject(new Error('cancelled'));
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (ch === '\u007f' || ch === '\b') {
|
|
129
|
-
value = value.slice(0, -1);
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
value += ch;
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
stdin.on('data', onData);
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
152
|
async function loadConfigModules() {
|
|
140
153
|
const { ensureDataSeeds } = await import('../src/shared/seed.mjs');
|
|
141
154
|
const { readSection, updateSection } = await import('../src/shared/config.mjs');
|
|
@@ -193,35 +206,39 @@ function presetIdsFromAgent(agentSection) {
|
|
|
193
206
|
return presets.map((p) => p.id || p.name).filter(Boolean);
|
|
194
207
|
}
|
|
195
208
|
|
|
196
|
-
export async function stepDiscordToken(io, { updateSection, readSection }) {
|
|
209
|
+
export async function stepDiscordToken(io, { updateSection, readSection, secretsCapable = true }) {
|
|
197
210
|
const { hasStoredSecret, SECRET_ACCOUNTS, getDiscordToken } = await import('../src/shared/config.mjs');
|
|
198
211
|
io.say('\n── Step 2/9: Discord ──');
|
|
199
212
|
io.say('Bot token (keychain), application ID, and optional main channel.');
|
|
200
213
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
214
|
+
let hadStoredToken = false;
|
|
215
|
+
let token = '';
|
|
216
|
+
let enteredToken = false;
|
|
217
|
+
if (!secretsCapable) {
|
|
218
|
+
io.say('• Discord bot token: skipped (Linux keychain unavailable).');
|
|
219
|
+
} else {
|
|
220
|
+
hadStoredToken = hasStoredSecret(SECRET_ACCOUNTS.discordToken);
|
|
221
|
+
const tokenPrompt = hadStoredToken
|
|
222
|
+
? 'Discord bot token (stored — leave empty to keep)'
|
|
223
|
+
: 'Discord bot token (leave empty to skip whole step)';
|
|
224
|
+
token = (await password(tokenPrompt)).trim();
|
|
225
|
+
enteredToken = !isSkippableAnswer(token);
|
|
226
|
+
if (!enteredToken && !hadStoredToken) {
|
|
227
|
+
io.say('• Skipped Discord setup.');
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
210
230
|
}
|
|
211
231
|
|
|
212
232
|
const channels = readSection('channels') || {};
|
|
213
233
|
const curDiscord = channels.discord && typeof channels.discord === 'object' ? channels.discord : {};
|
|
214
234
|
const curAppId = String(curDiscord.applicationId || '').trim();
|
|
215
235
|
const appIdBase = 'Application ID';
|
|
216
|
-
const
|
|
217
|
-
? `${
|
|
218
|
-
:
|
|
219
|
-
|
|
236
|
+
const appIdRaw = await text(appIdBase, {
|
|
237
|
+
placeholder: curAppId ? `${curAppId} (Enter to keep)` : '',
|
|
238
|
+
initial: '',
|
|
239
|
+
});
|
|
220
240
|
const appIdToSet = isSkippableAnswer(appIdRaw) ? '' : String(appIdRaw).trim();
|
|
221
|
-
|
|
222
|
-
// only the channel ID varies, so we ask just that. Extra channels and
|
|
223
|
-
// monitor mode are configured later in the UI.
|
|
224
|
-
const chIdRaw = await io.ask('Main channel ID (Enter=skip channel): ');
|
|
241
|
+
const chIdRaw = await text('Main channel ID', { placeholder: 'Enter to skip channel', initial: '' });
|
|
225
242
|
const channelId = isSkippableAnswer(chIdRaw) ? '' : String(chIdRaw).trim();
|
|
226
243
|
const channelName = 'main';
|
|
227
244
|
const mode = 'interactive';
|
|
@@ -261,19 +278,35 @@ function formatVoiceProgress(p) {
|
|
|
261
278
|
return `${phase} · received ${mb} MB`;
|
|
262
279
|
}
|
|
263
280
|
|
|
264
|
-
async function installVoiceRuntime(dataDir
|
|
281
|
+
async function installVoiceRuntime(dataDir) {
|
|
265
282
|
const {
|
|
266
283
|
ensureWhisperRuntime,
|
|
267
284
|
ensureWhisperModel,
|
|
268
285
|
ensureFfmpegRuntime,
|
|
269
286
|
} = await import('../src/channels/lib/voice-runtime-fetcher.mjs');
|
|
287
|
+
let spinner = createSpinner('Downloading voice runtime…');
|
|
288
|
+
let bar = null;
|
|
270
289
|
const onProgress = (p) => {
|
|
271
290
|
const line = formatVoiceProgress(p);
|
|
272
|
-
if (line)
|
|
291
|
+
if (!line) return;
|
|
292
|
+
const downloaded = Number(p?.downloaded) || 0;
|
|
293
|
+
const total = Number(p?.total) || 0;
|
|
294
|
+
if (total > 0) {
|
|
295
|
+
if (!bar) {
|
|
296
|
+
spinner.stop('', true);
|
|
297
|
+
spinner = null;
|
|
298
|
+
bar = createProgressBar('Voice download', { total });
|
|
299
|
+
}
|
|
300
|
+
bar.update(downloaded, total);
|
|
301
|
+
} else if (spinner) {
|
|
302
|
+
spinner.update(line);
|
|
303
|
+
}
|
|
273
304
|
};
|
|
274
305
|
await ensureWhisperRuntime(dataDir, onProgress);
|
|
275
306
|
const model = await ensureWhisperModel(dataDir, onProgress);
|
|
276
307
|
await ensureFfmpegRuntime(dataDir, onProgress);
|
|
308
|
+
if (bar) bar.done('complete');
|
|
309
|
+
else if (spinner) spinner.stop('complete', true);
|
|
277
310
|
return model?.modelId || 'large-v3-turbo';
|
|
278
311
|
}
|
|
279
312
|
|
|
@@ -282,20 +315,14 @@ async function stepVoiceTranscription(io, ctx, discordTokenSaved) {
|
|
|
282
315
|
if (!discordTokenSaved) return;
|
|
283
316
|
io.say('\n── Step 2a/9: Voice transcription (음성 전사) ──');
|
|
284
317
|
io.say('Install local Speech-to-text (whisper.cpp) for Discord voice messages.');
|
|
285
|
-
const
|
|
286
|
-
if (
|
|
287
|
-
io.say('• Skipped voice transcription.');
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
const a = String(raw).trim().toLowerCase();
|
|
291
|
-
if (a !== 'y' && a !== 'yes') {
|
|
318
|
+
const enable = await confirm('Enable voice transcription?', { initial: false });
|
|
319
|
+
if (!enable) {
|
|
292
320
|
io.say('• Skipped voice transcription.');
|
|
293
321
|
return;
|
|
294
322
|
}
|
|
295
323
|
const { updateSection, dataDir } = ctx;
|
|
296
324
|
try {
|
|
297
|
-
|
|
298
|
-
const modelId = await installVoiceRuntime(dataDir, io);
|
|
325
|
+
const modelId = await installVoiceRuntime(dataDir);
|
|
299
326
|
const voice = { language: 'auto', model: modelId };
|
|
300
327
|
updateSection('channels', (current) => mergeConfig(current, { voice }, {}));
|
|
301
328
|
io.say('• Voice transcription runtime installed and channels voice config saved.');
|
|
@@ -310,8 +337,14 @@ async function stepAddressForm(io, { updateSection, readSection }) {
|
|
|
310
337
|
const memory = readSection('memory');
|
|
311
338
|
const curTitle = memory?.user?.title || '';
|
|
312
339
|
const curName = memory?.user?.name || '';
|
|
313
|
-
const titleRaw = await
|
|
314
|
-
|
|
340
|
+
const titleRaw = await text('How should Mixdog address you? user.title', {
|
|
341
|
+
placeholder: curTitle || 'Enter to skip',
|
|
342
|
+
initial: '',
|
|
343
|
+
});
|
|
344
|
+
const nameRaw = await text('Your display name? user.name', {
|
|
345
|
+
placeholder: curName || 'Enter to skip',
|
|
346
|
+
initial: '',
|
|
347
|
+
});
|
|
315
348
|
const title = isSkippableAnswer(titleRaw) ? '' : String(titleRaw).trim();
|
|
316
349
|
const name = isSkippableAnswer(nameRaw) ? '' : String(nameRaw).trim();
|
|
317
350
|
if (!title && !name) {
|
|
@@ -326,16 +359,11 @@ async function stepAddressForm(io, { updateSection, readSection }) {
|
|
|
326
359
|
io.say('• Saved memory.user (title/name).');
|
|
327
360
|
}
|
|
328
361
|
|
|
329
|
-
export async function stepWebhookReceiver(io, { updateSection, readSection }) {
|
|
362
|
+
export async function stepWebhookReceiver(io, { updateSection, readSection, secretsCapable = true }) {
|
|
330
363
|
io.say('\n── Step 3/9: Inbound webhooks (ngrok receiver) ──');
|
|
331
364
|
io.say('Global webhook tunnel for inbound HTTP (channels.webhook). Per-endpoint registration is configured later in the UI.');
|
|
332
|
-
const
|
|
333
|
-
if (
|
|
334
|
-
io.say('• Skipped webhook setup.');
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
const enable = String(enableRaw).trim().toLowerCase();
|
|
338
|
-
if (enable !== 'y' && enable !== 'yes') {
|
|
365
|
+
const enableWebhooks = await confirm('Enable inbound webhooks?', { initial: false });
|
|
366
|
+
if (!enableWebhooks) {
|
|
339
367
|
io.say('• Skipped webhook setup.');
|
|
340
368
|
return;
|
|
341
369
|
}
|
|
@@ -345,29 +373,45 @@ export async function stepWebhookReceiver(io, { updateSection, readSection }) {
|
|
|
345
373
|
const curDomain = String(curWebhook.domain || curWebhook.ngrokDomain || '').trim();
|
|
346
374
|
const domainBase =
|
|
347
375
|
'Domain (ngrok, e.g. your-name.ngrok-free.dev — get it at dashboard.ngrok.com/domains)';
|
|
348
|
-
const
|
|
349
|
-
? `${
|
|
350
|
-
:
|
|
351
|
-
|
|
376
|
+
const domainRaw = await text(domainBase, {
|
|
377
|
+
placeholder: curDomain ? `${curDomain} (Enter to keep)` : '',
|
|
378
|
+
initial: '',
|
|
379
|
+
});
|
|
352
380
|
const webhook = { enabled: true };
|
|
353
381
|
if (!isSkippableAnswer(domainRaw)) {
|
|
354
382
|
webhook.domain = String(domainRaw).trim();
|
|
355
383
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
384
|
+
if (secretsCapable) {
|
|
385
|
+
const authPrompt = hasStoredSecret(SECRET_ACCOUNTS.webhookAuth)
|
|
386
|
+
? 'ngrok Auth Token (stored — leave empty to keep)'
|
|
387
|
+
: 'ngrok Auth Token';
|
|
388
|
+
webhook.authtoken = (await password(authPrompt)).trim();
|
|
389
|
+
} else {
|
|
390
|
+
io.say('• ngrok Auth Token: skipped (Linux keychain unavailable).');
|
|
391
|
+
}
|
|
360
392
|
const secrets = {};
|
|
361
393
|
updateSection('channels', (current) => mergeConfig(current, { webhook }, secrets));
|
|
362
|
-
io.say(
|
|
394
|
+
io.say(secretsCapable
|
|
395
|
+
? '• Inbound webhook receiver saved (channels.webhook enabled/domain; authtoken in keychain).'
|
|
396
|
+
: '• Inbound webhook receiver saved (channels.webhook enabled/domain; authtoken not collected).');
|
|
363
397
|
}
|
|
364
398
|
|
|
365
|
-
async function stepProviderKeys(io, { updateSection }) {
|
|
399
|
+
async function stepProviderKeys(io, { updateSection, secretsCapable = true }) {
|
|
366
400
|
io.say('\n── Step 4/9: Provider API keys ──');
|
|
367
|
-
|
|
401
|
+
if (!secretsCapable) {
|
|
402
|
+
io.say('• Skipped provider API keys (Linux keychain unavailable).');
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
io.say('Optional API keys (hidden). Pick providers to configure.');
|
|
406
|
+
const providerOpts = AG_API_PROVIDERS.map((p) => ({
|
|
407
|
+
value: p.id,
|
|
408
|
+
label: p.name,
|
|
409
|
+
hint: p.env,
|
|
410
|
+
}));
|
|
411
|
+
const selectedIds = await multiselect('Which provider API keys to set?', providerOpts, { min: 0 });
|
|
368
412
|
const providers = {};
|
|
369
|
-
for (const p of AG_API_PROVIDERS) {
|
|
370
|
-
const key = (await
|
|
413
|
+
for (const p of AG_API_PROVIDERS.filter((x) => selectedIds.includes(x.id))) {
|
|
414
|
+
const key = (await password(`${p.name} API key (${p.env})`)).trim();
|
|
371
415
|
if (!isSkippableAnswer(key)) {
|
|
372
416
|
providers[p.id] = { apiKey: key, enabled: true };
|
|
373
417
|
}
|
|
@@ -388,13 +432,8 @@ async function stepPresets(io, { readSection, updateSection, DEFAULT_PRESETS })
|
|
|
388
432
|
if (existing.length > 0) {
|
|
389
433
|
io.say(`Current presets: ${existing.join(', ')}`);
|
|
390
434
|
}
|
|
391
|
-
const
|
|
392
|
-
if (
|
|
393
|
-
io.say('• Kept existing presets.');
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
const a = String(raw).trim().toLowerCase();
|
|
397
|
-
if (a !== 'y' && a !== 'yes') {
|
|
435
|
+
const installDefaults = await confirm('Install default Mixdog presets?', { initial: false });
|
|
436
|
+
if (!installDefaults) {
|
|
398
437
|
io.say('• Kept existing presets.');
|
|
399
438
|
return;
|
|
400
439
|
}
|
|
@@ -422,9 +461,15 @@ async function stepRolePresetMapping(io, { readSection, dataDir }) {
|
|
|
422
461
|
const byName = new Map(roles.map((r) => [r.name, r]));
|
|
423
462
|
for (const roleName of WORKFLOW_ROLES) {
|
|
424
463
|
const cur = byName.get(roleName)?.preset || DEFAULT_USER_WORKFLOW.roles.find((r) => r.name === roleName)?.preset || '';
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
464
|
+
const options = [
|
|
465
|
+
{ value: '__keep__', label: `Keep current${cur ? ` (${cur})` : ''}` },
|
|
466
|
+
...presetIds.map((id) => ({ value: id, label: id })),
|
|
467
|
+
];
|
|
468
|
+
const chosen = await select(`Preset for role "${roleName}"`, options, {
|
|
469
|
+
initial: cur && presetIds.includes(cur) ? cur : '__keep__',
|
|
470
|
+
});
|
|
471
|
+
if (chosen === '__keep__') continue;
|
|
472
|
+
const preset = String(chosen).trim();
|
|
428
473
|
if (!presetIds.includes(preset)) {
|
|
429
474
|
io.say(` ! Unknown preset "${preset}" — left "${roleName}" unchanged.`);
|
|
430
475
|
continue;
|
|
@@ -449,28 +494,25 @@ function resolveSearchBackendInput(raw) {
|
|
|
449
494
|
return SEARCH_OAUTH_ALIASES[key] || null;
|
|
450
495
|
}
|
|
451
496
|
|
|
452
|
-
function parseYesNo(raw) {
|
|
453
|
-
if (isSkippableAnswer(raw)) return null;
|
|
454
|
-
const v = String(raw).trim().toLowerCase();
|
|
455
|
-
if (v === 'y' || v === 'yes' || v === 'true' || v === '1') return true;
|
|
456
|
-
if (v === 'n' || v === 'no' || v === 'false' || v === '0') return false;
|
|
457
|
-
return undefined;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
497
|
/** Mirrors POST /search/config → mergeSearchConfig. */
|
|
461
|
-
export async function stepSearchBackend(io, { updateSection, readSection }) {
|
|
498
|
+
export async function stepSearchBackend(io, { updateSection, readSection, secretsCapable = true }) {
|
|
462
499
|
io.say('\n── Step 7/9: Search backend ──');
|
|
463
500
|
io.say('Active provider: anthropic-oauth | openai-oauth | grok-oauth (OAuth — uses Agent credentials).');
|
|
464
501
|
const { hasStoredSecret, SECRET_ACCOUNTS, getSearchApiKey } = await import('../src/shared/config.mjs');
|
|
465
502
|
const search = readSection('search') || {};
|
|
466
503
|
const curProvider = String(search.provider || 'anthropic-oauth').trim() || 'anthropic-oauth';
|
|
467
|
-
const
|
|
504
|
+
const providerOptions = [
|
|
505
|
+
{ value: 'anthropic-oauth', label: 'anthropic-oauth', hint: 'Claude OAuth' },
|
|
506
|
+
{ value: 'openai-oauth', label: 'openai-oauth', hint: 'OpenAI OAuth' },
|
|
507
|
+
{ value: 'grok-oauth', label: 'grok-oauth', hint: 'xAI Grok OAuth' },
|
|
508
|
+
];
|
|
509
|
+
const backendRaw = await select('Search provider', providerOptions, {
|
|
510
|
+
initial: SEARCH_OAUTH_PROVIDERS.has(curProvider) ? curProvider : 'anthropic-oauth',
|
|
511
|
+
});
|
|
468
512
|
let provider = curProvider;
|
|
469
513
|
if (!isSkippableAnswer(backendRaw)) {
|
|
470
|
-
const resolved = resolveSearchBackendInput(backendRaw);
|
|
471
|
-
if (!resolved) {
|
|
472
|
-
io.say(` ! Unknown provider "${String(backendRaw).trim()}" — keeping ${curProvider}.`);
|
|
473
|
-
} else if (!SEARCH_OAUTH_PROVIDERS.has(resolved)) {
|
|
514
|
+
const resolved = resolveSearchBackendInput(backendRaw) || String(backendRaw).trim();
|
|
515
|
+
if (!SEARCH_OAUTH_PROVIDERS.has(resolved)) {
|
|
474
516
|
io.say(` ! Provider "${resolved}" is not a supported OAuth backend — keeping ${curProvider}.`);
|
|
475
517
|
} else {
|
|
476
518
|
provider = resolved;
|
|
@@ -481,15 +523,19 @@ export async function stepSearchBackend(io, { updateSection, readSection }) {
|
|
|
481
523
|
if (provider !== curProvider) payload.provider = provider;
|
|
482
524
|
|
|
483
525
|
const searchProviders = {};
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
526
|
+
if (secretsCapable) {
|
|
527
|
+
for (const p of SEARCH_RAW_KEY_PROVIDERS) {
|
|
528
|
+
const hadKey = hasStoredSecret(SECRET_ACCOUNTS.searchApiKey(p.id));
|
|
529
|
+
const keyPrompt = hadKey
|
|
530
|
+
? `${p.name} API key (stored — leave empty to keep)`
|
|
531
|
+
: `${p.name} API key (leave empty to skip)`;
|
|
532
|
+
const key = (await password(keyPrompt)).trim();
|
|
533
|
+
if (!isSkippableAnswer(key)) {
|
|
534
|
+
searchProviders[p.id] = key;
|
|
535
|
+
}
|
|
492
536
|
}
|
|
537
|
+
} else {
|
|
538
|
+
io.say('• Search provider API keys: skipped (Linux keychain unavailable).');
|
|
493
539
|
}
|
|
494
540
|
if (Object.keys(searchProviders).length) payload.searchProviders = searchProviders;
|
|
495
541
|
|
|
@@ -497,14 +543,22 @@ export async function stepSearchBackend(io, { updateSection, readSection }) {
|
|
|
497
543
|
const curModel = (search.models && search.models.openai)
|
|
498
544
|
|| DEFAULT_MODELS.openai
|
|
499
545
|
|| '';
|
|
500
|
-
const modelRaw = await
|
|
546
|
+
const modelRaw = await text('OpenAI model', {
|
|
547
|
+
placeholder: curModel ? `${curModel} (Enter to keep)` : 'Enter to keep',
|
|
548
|
+
initial: '',
|
|
549
|
+
});
|
|
501
550
|
if (!isSkippableAnswer(modelRaw)) {
|
|
502
551
|
const model = String(modelRaw).trim();
|
|
503
552
|
if (model) payload.models = { ...(payload.models || {}), openai: model };
|
|
504
553
|
}
|
|
505
554
|
const curEffort = String(search.modelOptions?.openai?.effort || 'medium').trim() || 'medium';
|
|
506
|
-
const effortRaw = await
|
|
507
|
-
|
|
555
|
+
const effortRaw = await select('OpenAI effort', [
|
|
556
|
+
{ value: '__keep__', label: `Keep current (${curEffort})` },
|
|
557
|
+
{ value: 'low', label: 'low' },
|
|
558
|
+
{ value: 'medium', label: 'medium' },
|
|
559
|
+
{ value: 'high', label: 'high' },
|
|
560
|
+
], { initial: '__keep__' });
|
|
561
|
+
if (!isSkippableAnswer(effortRaw) && effortRaw !== '__keep__') {
|
|
508
562
|
const effort = String(effortRaw).trim().toLowerCase();
|
|
509
563
|
if (!OPENAI_SEARCH_EFFORT_VALUES.has(effort)) {
|
|
510
564
|
io.say(` ! Unknown effort "${effortRaw.trim()}" — keeping ${curEffort}.`);
|
|
@@ -514,11 +568,11 @@ export async function stepSearchBackend(io, { updateSection, readSection }) {
|
|
|
514
568
|
}
|
|
515
569
|
}
|
|
516
570
|
const curFast = !!search.modelOptions?.openai?.fast;
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
571
|
+
const fastKeep = await confirm(`OpenAI Fast mode (current: ${curFast ? 'on' : 'off'}) — enable?`, {
|
|
572
|
+
initial: curFast,
|
|
573
|
+
});
|
|
574
|
+
const fastParsed = fastKeep === curFast ? null : fastKeep;
|
|
575
|
+
if (fastParsed !== null) {
|
|
522
576
|
const base = payload.modelOptions?.openai || search.modelOptions?.openai || {};
|
|
523
577
|
const openaiOpts = { ...base };
|
|
524
578
|
if (fastParsed) openaiOpts.fast = true;
|
|
@@ -532,7 +586,10 @@ export async function stepSearchBackend(io, { updateSection, readSection }) {
|
|
|
532
586
|
const curModel = (search.models && search.models.xai)
|
|
533
587
|
|| DEFAULT_MODELS.xai
|
|
534
588
|
|| '';
|
|
535
|
-
const modelRaw = await
|
|
589
|
+
const modelRaw = await text('xAI model', {
|
|
590
|
+
placeholder: curModel ? `${curModel} (Enter to keep)` : 'Enter to keep',
|
|
591
|
+
initial: '',
|
|
592
|
+
});
|
|
536
593
|
if (!isSkippableAnswer(modelRaw)) {
|
|
537
594
|
const model = String(modelRaw).trim();
|
|
538
595
|
if (model) payload.models = { ...(payload.models || {}), xai: model };
|
|
@@ -574,8 +631,14 @@ export async function stepExplorerPreset(io, { readSection, updateSection, DEFAU
|
|
|
574
631
|
if (validIds.size > 0) {
|
|
575
632
|
io.say(`Available presets: ${[...validIds].join(', ')}`);
|
|
576
633
|
}
|
|
577
|
-
const
|
|
578
|
-
|
|
634
|
+
const exploreOptions = [
|
|
635
|
+
{ value: '__keep__', label: `Keep current (${curExplore})` },
|
|
636
|
+
...[...validIds].map((id) => ({ value: id, label: id })),
|
|
637
|
+
];
|
|
638
|
+
const raw = await select('Preset for explorer (explore tool)', exploreOptions, {
|
|
639
|
+
initial: validIds.has(curExplore) ? curExplore : '__keep__',
|
|
640
|
+
});
|
|
641
|
+
if (raw === '__keep__' || isSkippableAnswer(raw)) {
|
|
579
642
|
io.say(`• Explorer preset unchanged (${curExplore}).`);
|
|
580
643
|
return;
|
|
581
644
|
}
|
|
@@ -597,15 +660,21 @@ export async function stepExplorerPreset(io, { readSection, updateSection, DEFAU
|
|
|
597
660
|
* @param {(prompt:string)=>Promise<string>} [ioOverride.ask]
|
|
598
661
|
* @param {(prompt:string)=>Promise<string>} [ioOverride.askSecret]
|
|
599
662
|
* @param {(line:string)=>void} [ioOverride.say]
|
|
663
|
+
* @param {object} [options]
|
|
664
|
+
* @param {boolean} [options.secretsCapable] — override Linux keytar preflight (tests).
|
|
600
665
|
*/
|
|
601
|
-
export async function runSetupWizard(ioOverride = null) {
|
|
666
|
+
export async function runSetupWizard(ioOverride = null, options = {}) {
|
|
602
667
|
const io = ioOverride ? { ...defaultIo(), ...ioOverride } : defaultIo();
|
|
603
668
|
if (!io.interactive) return { skipped: true };
|
|
604
669
|
|
|
670
|
+
const secretsCapable = options.secretsCapable ?? probeLinuxSecretsCapable();
|
|
671
|
+
if (!secretsCapable) io.say(linuxKeychainUnavailableMessage());
|
|
672
|
+
|
|
605
673
|
io.say('\nMixdog setup wizard — configure before opening Claude Code.');
|
|
606
674
|
io.say('Press Enter on any step to skip it.\n');
|
|
607
675
|
|
|
608
676
|
const ctx = await loadConfigModules();
|
|
677
|
+
ctx.secretsCapable = secretsCapable;
|
|
609
678
|
try {
|
|
610
679
|
await stepAddressForm(io, ctx);
|
|
611
680
|
const discordSaved = await stepDiscordToken(io, ctx);
|
|
@@ -114,7 +114,7 @@ export function inspectBridgeEntry(entry, abortSeconds = DEFAULT_ABORT_S, now =
|
|
|
114
114
|
return { verdict: 'stall', staleSeconds, stage, reason: 'tool-runtime-fallback', toolName: entry.lastToolCall || null };
|
|
115
115
|
}
|
|
116
116
|
if (stage === 'idle' || stage === 'done' || stage === 'error' || stage === 'cancelling') {
|
|
117
|
-
// Terminal stages never abort, but may need flush+hide after
|
|
117
|
+
// Terminal stages never abort, but may need flush+hide after the terminal-reap window (TERMINAL_REAP_MS, 1h) (fix B).
|
|
118
118
|
const progressRef = entry.lastProgressAt || entry.doneAt || entry.updatedAt;
|
|
119
119
|
if (progressRef && (now - progressRef) >= TERMINAL_REAP_MS) {
|
|
120
120
|
return { verdict: 'terminal-reap', staleSeconds: Math.round((now - progressRef) / 1000), stage };
|
|
@@ -253,7 +253,7 @@ export function startBridgeStallWatchdog(params) {
|
|
|
253
253
|
// alone only flips in-memory listHidden; the statusline aggregator
|
|
254
254
|
// reads the on-disk JSON and keeps rendering a non-closed bridge worker
|
|
255
255
|
// as idle until the store sweep. closeSession plants closed===true so
|
|
256
|
-
// the aggregator drops it immediately — one consistent
|
|
256
|
+
// the aggregator drops it immediately — one consistent 1h lifecycle (TERMINAL_REAP_MS).
|
|
257
257
|
if (res.verdict === 'terminal-reap' && !_reaped) {
|
|
258
258
|
_reaped = true;
|
|
259
259
|
const reapId = currentSessionId();
|
package/src/agent/index.mjs
CHANGED
|
@@ -441,14 +441,14 @@ function _cancelBridgeReap(sessionId) {
|
|
|
441
441
|
// role/status filters and a brief flag mirror the legacy tool's shape, plus a
|
|
442
442
|
// `tag` field resolved from the tag registry.
|
|
443
443
|
function _bridgeListSessions(opts = {}) {
|
|
444
|
-
const
|
|
444
|
+
const includeClosed = opts.includeClosed === true;
|
|
445
|
+
const sessions = listSessions({ includeClosed });
|
|
445
446
|
if (sessions.length === 0) return 'No active sessions.';
|
|
446
447
|
const now = Date.now();
|
|
447
448
|
const brief = opts.brief !== false;
|
|
448
|
-
const includeClosed = opts.includeClosed === true;
|
|
449
449
|
const roleFilter = typeof opts.role === 'string' && opts.role ? opts.role : null;
|
|
450
450
|
const statusFilter = typeof opts.status === 'string' && opts.status ? opts.status : null;
|
|
451
|
-
|
|
451
|
+
const filtered = sessions;
|
|
452
452
|
if (filtered.length === 0) return 'No active sessions.';
|
|
453
453
|
const rows = filtered.map((s) => {
|
|
454
454
|
const runtime = getSessionRuntime(s.id);
|