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.
Files changed (35) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +18 -0
  4. package/hooks/hooks.json +6 -6
  5. package/hooks/session-start.cjs +73 -2
  6. package/hooks/shim-launcher.cjs +51 -0
  7. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  8. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  9. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  10. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  11. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  12. package/package.json +2 -2
  13. package/scripts/bootstrap.mjs +5 -59
  14. package/scripts/ensure-deps.mjs +259 -0
  15. package/scripts/resolve-bun.mjs +60 -0
  16. package/scripts/run-mcp.mjs +13 -168
  17. package/setup/install.mjs +220 -22
  18. package/setup/launch.mjs +0 -0
  19. package/setup/locate-claude.mjs +38 -0
  20. package/setup/mixdog-cli.mjs +95 -0
  21. package/setup/setup-server.mjs +50 -2
  22. package/setup/setup.html +26 -12
  23. package/setup/tui.mjs +606 -0
  24. package/setup/wizard.mjs +220 -151
  25. package/src/agent/bridge-stall-watchdog.mjs +2 -2
  26. package/src/agent/index.mjs +3 -3
  27. package/src/agent/orchestrator/providers/anthropic-oauth.mjs +139 -0
  28. package/src/agent/orchestrator/providers/openai-oauth.mjs +96 -0
  29. package/src/agent/orchestrator/session/manager.mjs +5 -3
  30. package/src/agent/orchestrator/session/store.mjs +9 -1
  31. package/src/channels/lib/runtime-paths.mjs +112 -74
  32. package/src/memory/index.mjs +30 -7
  33. package/src/memory/lib/pg/supervisor.mjs +12 -12
  34. package/src/shared/atomic-file.mjs +16 -0
  35. 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 { createInterface } from 'node:readline';
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 (!dir || typeof dir !== 'string' || !String(dir).trim()) {
56
- throw new Error(
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 String(dir).trim();
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: () => rl.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
- const hadStoredToken = hasStoredSecret(SECRET_ACCOUNTS.discordToken);
202
- const tokenPrompt = hadStoredToken
203
- ? 'Discord bot token (stored, Enter=keep): '
204
- : 'Discord bot token [hidden] (Enter=skip whole step): ';
205
- const token = (await io.askSecret(tokenPrompt)).trim();
206
- const enteredToken = !isSkippableAnswer(token);
207
- if (!enteredToken && !hadStoredToken) {
208
- io.say('• Skipped Discord setup.');
209
- return false;
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 appIdPrompt = curAppId
217
- ? `${appIdBase} (current: ${curAppId}, Enter=keep): `
218
- : `${appIdBase}: `;
219
- const appIdRaw = await io.ask(appIdPrompt);
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
- // The main channel is conventionally named "main" with interactive mode;
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, io) {
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) io.say(` ${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 raw = await io.ask('Enable voice transcription? [y/N] (Enter=skip): ');
286
- if (isSkippableAnswer(raw)) {
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
- io.say('• Downloading voice runtime (this may take a while)…');
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 io.ask(`How should Mixdog address you? user.title [${curTitle}]: `);
314
- const nameRaw = await io.ask(`Your display name? user.name [${curName}]: `);
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 enableRaw = await io.ask('Enable inbound webhooks? [y/N]: ');
333
- if (isSkippableAnswer(enableRaw)) {
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 domainPrompt = curDomain
349
- ? `${domainBase} (current: ${curDomain}, Enter=keep): `
350
- : `${domainBase}: `;
351
- const domainRaw = await io.ask(domainPrompt);
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
- const authPrompt = hasStoredSecret(SECRET_ACCOUNTS.webhookAuth)
357
- ? 'Auth Token (stored, Enter=keep): '
358
- : 'ngrok Auth Token [hidden]: ';
359
- webhook.authtoken = (await io.askSecret(authPrompt)).trim();
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('• Inbound webhook receiver saved (channels.webhook enabled/domain; authtoken in keychain).');
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
- io.say('Optional API keys (hidden). Enter to skip a provider.');
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 io.askSecret(`${p.name} API key (${p.env}, Enter=skip): `)).trim();
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 raw = await io.ask('Install default Mixdog presets? [y/N] (Enter=skip): ');
392
- if (isSkippableAnswer(raw)) {
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 raw = await io.ask(`Preset for role "${roleName}" [${cur}]: `);
426
- if (isSkippableAnswer(raw)) continue;
427
- const preset = String(raw).trim();
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 backendRaw = await io.ask(`Search provider [${curProvider}]: `);
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
- for (const p of SEARCH_RAW_KEY_PROVIDERS) {
485
- const hadKey = hasStoredSecret(SECRET_ACCOUNTS.searchApiKey(p.id));
486
- const keyPrompt = hadKey
487
- ? `${p.name} API key (stored, Enter=keep): `
488
- : `${p.name} API key [hidden] (Enter=skip): `;
489
- const key = (await io.askSecret(keyPrompt)).trim();
490
- if (!isSkippableAnswer(key)) {
491
- searchProviders[p.id] = key;
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 io.ask(`OpenAI model [${curModel}] (Enter=keep): `);
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 io.ask(`OpenAI effort (low/medium/high) [${curEffort}] (Enter=keep): `);
507
- if (!isSkippableAnswer(effortRaw)) {
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 fastRaw = await io.ask(`OpenAI Fast mode [${curFast ? 'y' : 'N'}] (y/N, Enter=keep): `);
518
- const fastParsed = parseYesNo(fastRaw);
519
- if (fastParsed === undefined && !isSkippableAnswer(fastRaw)) {
520
- io.say(' ! Fast mode: answer y or n keeping current.');
521
- } else if (fastParsed !== null) {
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 io.ask(`xAI model [${curModel}] (Enter=keep): `);
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 raw = await io.ask(`Preset for explorer (explore tool) [${curExplore}]: `);
578
- if (isSkippableAnswer(raw)) {
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 120s (fix B).
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 300s lifecycle.
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();
@@ -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 sessions = listSessions();
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
- let filtered = includeClosed ? sessions : sessions.filter((s) => s.closed !== true);
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);