mixdog 0.7.6 → 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/setup/wizard.mjs CHANGED
@@ -5,7 +5,6 @@
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';
9
8
  import { spawnSync } from 'node:child_process';
10
9
  import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
11
10
  import { join, dirname, basename } from 'node:path';
@@ -18,6 +17,15 @@ import {
18
17
  mergeSearchConfig,
19
18
  } from './config-merge.mjs';
20
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';
21
29
 
22
30
  let _linuxSecretsCapable;
23
31
  const KEYTAR_SERVICE = 'mixdog';
@@ -134,61 +142,13 @@ function defaultIo() {
134
142
  say: (line) => { if (line) console.log(line); },
135
143
  };
136
144
  }
137
- const rl = createInterface({ input: process.stdin, output: process.stdout });
138
- const ask = (prompt) => new Promise((resolve) => {
139
- rl.question(prompt, (answer) => resolve(answer));
140
- });
141
- const askSecret = (prompt) => readHiddenLine(prompt).finally(() => {
142
- rl.resume();
143
- });
144
145
  return {
145
146
  interactive: true,
146
- ask,
147
- askSecret,
148
147
  say: (line) => { if (line) console.log(line); },
149
- close: () => rl.close(),
148
+ close: () => {},
150
149
  };
151
150
  }
152
151
 
153
- function readHiddenLine(prompt) {
154
- return new Promise((resolve, reject) => {
155
- const stdin = process.stdin;
156
- if (!stdin.isTTY) {
157
- resolve('');
158
- return;
159
- }
160
- const wasRaw = stdin.isRaw;
161
- stdin.setRawMode(true);
162
- stdin.resume();
163
- process.stdout.write(prompt);
164
- let value = '';
165
- const onData = (chunk) => {
166
- const s = chunk.toString('utf8');
167
- for (const ch of s) {
168
- if (ch === '\n' || ch === '\r' || ch === '\u0004') {
169
- stdin.removeListener('data', onData);
170
- stdin.setRawMode(wasRaw);
171
- process.stdout.write('\n');
172
- resolve(value);
173
- return;
174
- }
175
- if (ch === '\u0003') {
176
- stdin.removeListener('data', onData);
177
- stdin.setRawMode(wasRaw);
178
- reject(new Error('cancelled'));
179
- return;
180
- }
181
- if (ch === '\u007f' || ch === '\b') {
182
- value = value.slice(0, -1);
183
- continue;
184
- }
185
- value += ch;
186
- }
187
- };
188
- stdin.on('data', onData);
189
- });
190
- }
191
-
192
152
  async function loadConfigModules() {
193
153
  const { ensureDataSeeds } = await import('../src/shared/seed.mjs');
194
154
  const { readSection, updateSection } = await import('../src/shared/config.mjs');
@@ -259,9 +219,9 @@ export async function stepDiscordToken(io, { updateSection, readSection, secrets
259
219
  } else {
260
220
  hadStoredToken = hasStoredSecret(SECRET_ACCOUNTS.discordToken);
261
221
  const tokenPrompt = hadStoredToken
262
- ? 'Discord bot token (stored, Enter=keep): '
263
- : 'Discord bot token [hidden] (Enter=skip whole step): ';
264
- token = (await io.askSecret(tokenPrompt)).trim();
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();
265
225
  enteredToken = !isSkippableAnswer(token);
266
226
  if (!enteredToken && !hadStoredToken) {
267
227
  io.say('• Skipped Discord setup.');
@@ -273,15 +233,12 @@ export async function stepDiscordToken(io, { updateSection, readSection, secrets
273
233
  const curDiscord = channels.discord && typeof channels.discord === 'object' ? channels.discord : {};
274
234
  const curAppId = String(curDiscord.applicationId || '').trim();
275
235
  const appIdBase = 'Application ID';
276
- const appIdPrompt = curAppId
277
- ? `${appIdBase} (current: ${curAppId}, Enter=keep): `
278
- : `${appIdBase}: `;
279
- const appIdRaw = await io.ask(appIdPrompt);
236
+ const appIdRaw = await text(appIdBase, {
237
+ placeholder: curAppId ? `${curAppId} (Enter to keep)` : '',
238
+ initial: '',
239
+ });
280
240
  const appIdToSet = isSkippableAnswer(appIdRaw) ? '' : String(appIdRaw).trim();
281
- // The main channel is conventionally named "main" with interactive mode;
282
- // only the channel ID varies, so we ask just that. Extra channels and
283
- // monitor mode are configured later in the UI.
284
- 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: '' });
285
242
  const channelId = isSkippableAnswer(chIdRaw) ? '' : String(chIdRaw).trim();
286
243
  const channelName = 'main';
287
244
  const mode = 'interactive';
@@ -321,19 +278,35 @@ function formatVoiceProgress(p) {
321
278
  return `${phase} · received ${mb} MB`;
322
279
  }
323
280
 
324
- async function installVoiceRuntime(dataDir, io) {
281
+ async function installVoiceRuntime(dataDir) {
325
282
  const {
326
283
  ensureWhisperRuntime,
327
284
  ensureWhisperModel,
328
285
  ensureFfmpegRuntime,
329
286
  } = await import('../src/channels/lib/voice-runtime-fetcher.mjs');
287
+ let spinner = createSpinner('Downloading voice runtime…');
288
+ let bar = null;
330
289
  const onProgress = (p) => {
331
290
  const line = formatVoiceProgress(p);
332
- 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
+ }
333
304
  };
334
305
  await ensureWhisperRuntime(dataDir, onProgress);
335
306
  const model = await ensureWhisperModel(dataDir, onProgress);
336
307
  await ensureFfmpegRuntime(dataDir, onProgress);
308
+ if (bar) bar.done('complete');
309
+ else if (spinner) spinner.stop('complete', true);
337
310
  return model?.modelId || 'large-v3-turbo';
338
311
  }
339
312
 
@@ -342,20 +315,14 @@ async function stepVoiceTranscription(io, ctx, discordTokenSaved) {
342
315
  if (!discordTokenSaved) return;
343
316
  io.say('\n── Step 2a/9: Voice transcription (음성 전사) ──');
344
317
  io.say('Install local Speech-to-text (whisper.cpp) for Discord voice messages.');
345
- const raw = await io.ask('Enable voice transcription? [y/N] (Enter=skip): ');
346
- if (isSkippableAnswer(raw)) {
347
- io.say('• Skipped voice transcription.');
348
- return;
349
- }
350
- const a = String(raw).trim().toLowerCase();
351
- if (a !== 'y' && a !== 'yes') {
318
+ const enable = await confirm('Enable voice transcription?', { initial: false });
319
+ if (!enable) {
352
320
  io.say('• Skipped voice transcription.');
353
321
  return;
354
322
  }
355
323
  const { updateSection, dataDir } = ctx;
356
324
  try {
357
- io.say('• Downloading voice runtime (this may take a while)…');
358
- const modelId = await installVoiceRuntime(dataDir, io);
325
+ const modelId = await installVoiceRuntime(dataDir);
359
326
  const voice = { language: 'auto', model: modelId };
360
327
  updateSection('channels', (current) => mergeConfig(current, { voice }, {}));
361
328
  io.say('• Voice transcription runtime installed and channels voice config saved.');
@@ -370,8 +337,14 @@ async function stepAddressForm(io, { updateSection, readSection }) {
370
337
  const memory = readSection('memory');
371
338
  const curTitle = memory?.user?.title || '';
372
339
  const curName = memory?.user?.name || '';
373
- const titleRaw = await io.ask(`How should Mixdog address you? user.title [${curTitle}]: `);
374
- 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
+ });
375
348
  const title = isSkippableAnswer(titleRaw) ? '' : String(titleRaw).trim();
376
349
  const name = isSkippableAnswer(nameRaw) ? '' : String(nameRaw).trim();
377
350
  if (!title && !name) {
@@ -389,13 +362,8 @@ async function stepAddressForm(io, { updateSection, readSection }) {
389
362
  export async function stepWebhookReceiver(io, { updateSection, readSection, secretsCapable = true }) {
390
363
  io.say('\n── Step 3/9: Inbound webhooks (ngrok receiver) ──');
391
364
  io.say('Global webhook tunnel for inbound HTTP (channels.webhook). Per-endpoint registration is configured later in the UI.');
392
- const enableRaw = await io.ask('Enable inbound webhooks? [y/N]: ');
393
- if (isSkippableAnswer(enableRaw)) {
394
- io.say('• Skipped webhook setup.');
395
- return;
396
- }
397
- const enable = String(enableRaw).trim().toLowerCase();
398
- if (enable !== 'y' && enable !== 'yes') {
365
+ const enableWebhooks = await confirm('Enable inbound webhooks?', { initial: false });
366
+ if (!enableWebhooks) {
399
367
  io.say('• Skipped webhook setup.');
400
368
  return;
401
369
  }
@@ -405,19 +373,19 @@ export async function stepWebhookReceiver(io, { updateSection, readSection, secr
405
373
  const curDomain = String(curWebhook.domain || curWebhook.ngrokDomain || '').trim();
406
374
  const domainBase =
407
375
  'Domain (ngrok, e.g. your-name.ngrok-free.dev — get it at dashboard.ngrok.com/domains)';
408
- const domainPrompt = curDomain
409
- ? `${domainBase} (current: ${curDomain}, Enter=keep): `
410
- : `${domainBase}: `;
411
- const domainRaw = await io.ask(domainPrompt);
376
+ const domainRaw = await text(domainBase, {
377
+ placeholder: curDomain ? `${curDomain} (Enter to keep)` : '',
378
+ initial: '',
379
+ });
412
380
  const webhook = { enabled: true };
413
381
  if (!isSkippableAnswer(domainRaw)) {
414
382
  webhook.domain = String(domainRaw).trim();
415
383
  }
416
384
  if (secretsCapable) {
417
385
  const authPrompt = hasStoredSecret(SECRET_ACCOUNTS.webhookAuth)
418
- ? 'Auth Token (stored, Enter=keep): '
419
- : 'ngrok Auth Token [hidden]: ';
420
- webhook.authtoken = (await io.askSecret(authPrompt)).trim();
386
+ ? 'ngrok Auth Token (stored — leave empty to keep)'
387
+ : 'ngrok Auth Token';
388
+ webhook.authtoken = (await password(authPrompt)).trim();
421
389
  } else {
422
390
  io.say('• ngrok Auth Token: skipped (Linux keychain unavailable).');
423
391
  }
@@ -434,10 +402,16 @@ async function stepProviderKeys(io, { updateSection, secretsCapable = true }) {
434
402
  io.say('• Skipped provider API keys (Linux keychain unavailable).');
435
403
  return;
436
404
  }
437
- io.say('Optional API keys (hidden). Enter to skip a provider.');
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 });
438
412
  const providers = {};
439
- for (const p of AG_API_PROVIDERS) {
440
- 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();
441
415
  if (!isSkippableAnswer(key)) {
442
416
  providers[p.id] = { apiKey: key, enabled: true };
443
417
  }
@@ -458,13 +432,8 @@ async function stepPresets(io, { readSection, updateSection, DEFAULT_PRESETS })
458
432
  if (existing.length > 0) {
459
433
  io.say(`Current presets: ${existing.join(', ')}`);
460
434
  }
461
- const raw = await io.ask('Install default Mixdog presets? [y/N] (Enter=skip): ');
462
- if (isSkippableAnswer(raw)) {
463
- io.say('• Kept existing presets.');
464
- return;
465
- }
466
- const a = String(raw).trim().toLowerCase();
467
- if (a !== 'y' && a !== 'yes') {
435
+ const installDefaults = await confirm('Install default Mixdog presets?', { initial: false });
436
+ if (!installDefaults) {
468
437
  io.say('• Kept existing presets.');
469
438
  return;
470
439
  }
@@ -492,9 +461,15 @@ async function stepRolePresetMapping(io, { readSection, dataDir }) {
492
461
  const byName = new Map(roles.map((r) => [r.name, r]));
493
462
  for (const roleName of WORKFLOW_ROLES) {
494
463
  const cur = byName.get(roleName)?.preset || DEFAULT_USER_WORKFLOW.roles.find((r) => r.name === roleName)?.preset || '';
495
- const raw = await io.ask(`Preset for role "${roleName}" [${cur}]: `);
496
- if (isSkippableAnswer(raw)) continue;
497
- 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();
498
473
  if (!presetIds.includes(preset)) {
499
474
  io.say(` ! Unknown preset "${preset}" — left "${roleName}" unchanged.`);
500
475
  continue;
@@ -519,14 +494,6 @@ function resolveSearchBackendInput(raw) {
519
494
  return SEARCH_OAUTH_ALIASES[key] || null;
520
495
  }
521
496
 
522
- function parseYesNo(raw) {
523
- if (isSkippableAnswer(raw)) return null;
524
- const v = String(raw).trim().toLowerCase();
525
- if (v === 'y' || v === 'yes' || v === 'true' || v === '1') return true;
526
- if (v === 'n' || v === 'no' || v === 'false' || v === '0') return false;
527
- return undefined;
528
- }
529
-
530
497
  /** Mirrors POST /search/config → mergeSearchConfig. */
531
498
  export async function stepSearchBackend(io, { updateSection, readSection, secretsCapable = true }) {
532
499
  io.say('\n── Step 7/9: Search backend ──');
@@ -534,13 +501,18 @@ export async function stepSearchBackend(io, { updateSection, readSection, secret
534
501
  const { hasStoredSecret, SECRET_ACCOUNTS, getSearchApiKey } = await import('../src/shared/config.mjs');
535
502
  const search = readSection('search') || {};
536
503
  const curProvider = String(search.provider || 'anthropic-oauth').trim() || 'anthropic-oauth';
537
- 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
+ });
538
512
  let provider = curProvider;
539
513
  if (!isSkippableAnswer(backendRaw)) {
540
- const resolved = resolveSearchBackendInput(backendRaw);
541
- if (!resolved) {
542
- io.say(` ! Unknown provider "${String(backendRaw).trim()}" — keeping ${curProvider}.`);
543
- } else if (!SEARCH_OAUTH_PROVIDERS.has(resolved)) {
514
+ const resolved = resolveSearchBackendInput(backendRaw) || String(backendRaw).trim();
515
+ if (!SEARCH_OAUTH_PROVIDERS.has(resolved)) {
544
516
  io.say(` ! Provider "${resolved}" is not a supported OAuth backend — keeping ${curProvider}.`);
545
517
  } else {
546
518
  provider = resolved;
@@ -555,9 +527,9 @@ export async function stepSearchBackend(io, { updateSection, readSection, secret
555
527
  for (const p of SEARCH_RAW_KEY_PROVIDERS) {
556
528
  const hadKey = hasStoredSecret(SECRET_ACCOUNTS.searchApiKey(p.id));
557
529
  const keyPrompt = hadKey
558
- ? `${p.name} API key (stored, Enter=keep): `
559
- : `${p.name} API key [hidden] (Enter=skip): `;
560
- const key = (await io.askSecret(keyPrompt)).trim();
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();
561
533
  if (!isSkippableAnswer(key)) {
562
534
  searchProviders[p.id] = key;
563
535
  }
@@ -571,14 +543,22 @@ export async function stepSearchBackend(io, { updateSection, readSection, secret
571
543
  const curModel = (search.models && search.models.openai)
572
544
  || DEFAULT_MODELS.openai
573
545
  || '';
574
- 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
+ });
575
550
  if (!isSkippableAnswer(modelRaw)) {
576
551
  const model = String(modelRaw).trim();
577
552
  if (model) payload.models = { ...(payload.models || {}), openai: model };
578
553
  }
579
554
  const curEffort = String(search.modelOptions?.openai?.effort || 'medium').trim() || 'medium';
580
- const effortRaw = await io.ask(`OpenAI effort (low/medium/high) [${curEffort}] (Enter=keep): `);
581
- 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__') {
582
562
  const effort = String(effortRaw).trim().toLowerCase();
583
563
  if (!OPENAI_SEARCH_EFFORT_VALUES.has(effort)) {
584
564
  io.say(` ! Unknown effort "${effortRaw.trim()}" — keeping ${curEffort}.`);
@@ -588,11 +568,11 @@ export async function stepSearchBackend(io, { updateSection, readSection, secret
588
568
  }
589
569
  }
590
570
  const curFast = !!search.modelOptions?.openai?.fast;
591
- const fastRaw = await io.ask(`OpenAI Fast mode [${curFast ? 'y' : 'N'}] (y/N, Enter=keep): `);
592
- const fastParsed = parseYesNo(fastRaw);
593
- if (fastParsed === undefined && !isSkippableAnswer(fastRaw)) {
594
- io.say(' ! Fast mode: answer y or n keeping current.');
595
- } 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) {
596
576
  const base = payload.modelOptions?.openai || search.modelOptions?.openai || {};
597
577
  const openaiOpts = { ...base };
598
578
  if (fastParsed) openaiOpts.fast = true;
@@ -606,7 +586,10 @@ export async function stepSearchBackend(io, { updateSection, readSection, secret
606
586
  const curModel = (search.models && search.models.xai)
607
587
  || DEFAULT_MODELS.xai
608
588
  || '';
609
- 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
+ });
610
593
  if (!isSkippableAnswer(modelRaw)) {
611
594
  const model = String(modelRaw).trim();
612
595
  if (model) payload.models = { ...(payload.models || {}), xai: model };
@@ -648,8 +631,14 @@ export async function stepExplorerPreset(io, { readSection, updateSection, DEFAU
648
631
  if (validIds.size > 0) {
649
632
  io.say(`Available presets: ${[...validIds].join(', ')}`);
650
633
  }
651
- const raw = await io.ask(`Preset for explorer (explore tool) [${curExplore}]: `);
652
- 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)) {
653
642
  io.say(`• Explorer preset unchanged (${curExplore}).`);
654
643
  return;
655
644
  }
@@ -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);
@@ -8,6 +8,8 @@
8
8
  import { readFileSync, existsSync, statSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { homedir } from 'os';
11
+ import { createServer } from 'http';
12
+ import { randomBytes, createHash } from 'crypto';
11
13
  import {
12
14
  traceBridgeFetch,
13
15
  traceBridgeSse,
@@ -208,6 +210,22 @@ const ANTHROPIC_VERSION = '2023-06-01';
208
210
  const DEFAULT_CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json');
209
211
  const CLAUDE_CODE_CLIENT_ID = process.env.ANTHROPIC_OAUTH_CLIENT_ID || '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
210
212
  const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
213
+ const CLAUDE_AI_AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
214
+ const ALL_OAUTH_SCOPES = [
215
+ 'org:create_api_key',
216
+ 'user:profile',
217
+ 'user:inference',
218
+ 'user:sessions:claude_code',
219
+ 'user:mcp_servers',
220
+ 'user:file_upload',
221
+ ];
222
+ const OAUTH_LOGIN_SCOPE = ALL_OAUTH_SCOPES.join(' ');
223
+ const OAUTH_CALLBACK_HOST = 'localhost';
224
+ const OAUTH_CALLBACK_PORT = 54545;
225
+ const OAUTH_CALLBACK_PATH = '/callback';
226
+ const OAUTH_REDIRECT_URI = `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`;
227
+ const OAUTH_LOGIN_TIMEOUT_MS = 5 * 60_000;
228
+ const OAUTH_TOKEN_TIMEOUT_MS = 30_000;
211
229
 
212
230
  // Anthropic OAuth contract for first-party Claude Code clients.
213
231
  // Opus/Sonnet requests are gated on a specific system-prompt prefix.
@@ -1739,6 +1757,127 @@ export class AnthropicOAuthProvider {
1739
1757
  }
1740
1758
  }
1741
1759
 
1760
+ // --- Login flow (PKCE loopback, export for setup UI / CLI) ---
1761
+
1762
+ function _oauthGeneratePKCE() {
1763
+ const verifier = randomBytes(32).toString('base64url');
1764
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
1765
+ return { verifier, challenge };
1766
+ }
1767
+
1768
+ function _oauthCredentialsWritePath() {
1769
+ for (const p of credentialCandidates()) {
1770
+ if (existsSync(p)) return p;
1771
+ }
1772
+ return DEFAULT_CREDENTIALS_PATH;
1773
+ }
1774
+
1775
+ function _oauthParseScopeField(scope) {
1776
+ if (Array.isArray(scope)) return scope;
1777
+ return String(scope || '').split(' ').filter(Boolean);
1778
+ }
1779
+
1780
+ export async function loginOAuth() {
1781
+ const pkce = _oauthGeneratePKCE();
1782
+ const state = randomBytes(32).toString('base64url');
1783
+ const url = new URL(CLAUDE_AI_AUTHORIZE_URL);
1784
+ url.searchParams.set('code', 'true');
1785
+ url.searchParams.set('client_id', CLAUDE_CODE_CLIENT_ID);
1786
+ url.searchParams.set('response_type', 'code');
1787
+ url.searchParams.set('redirect_uri', OAUTH_REDIRECT_URI);
1788
+ url.searchParams.set('scope', OAUTH_LOGIN_SCOPE);
1789
+ url.searchParams.set('code_challenge', pkce.challenge);
1790
+ url.searchParams.set('code_challenge_method', 'S256');
1791
+ url.searchParams.set('state', state);
1792
+ process.stderr.write(`\n[anthropic-oauth] Open this URL to log in with Claude:\n${url.toString()}\n\n`);
1793
+ try {
1794
+ const { exec } = await import('child_process');
1795
+ const opener = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
1796
+ exec(`${opener} "${url.toString()}"`, { windowsHide: true });
1797
+ } catch { /* user opens manually */ }
1798
+
1799
+ return new Promise((resolve) => {
1800
+ const timeout = setTimeout(() => { server.close(); resolve(null); }, OAUTH_LOGIN_TIMEOUT_MS);
1801
+ const server = createServer(async (req, res) => {
1802
+ const u = new URL(req.url || '/', `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}`);
1803
+ if (u.pathname !== OAUTH_CALLBACK_PATH) {
1804
+ res.writeHead(404);
1805
+ res.end();
1806
+ return;
1807
+ }
1808
+ const code = u.searchParams.get('code');
1809
+ if (!code || u.searchParams.get('state') !== state) {
1810
+ res.writeHead(400);
1811
+ res.end('Invalid');
1812
+ clearTimeout(timeout);
1813
+ server.close();
1814
+ resolve(null);
1815
+ return;
1816
+ }
1817
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1818
+ res.end('<html><body><h2>Claude login successful! You can close this tab.</h2></body></html>');
1819
+ clearTimeout(timeout);
1820
+ server.close();
1821
+ try {
1822
+ const tokenRes = await fetch(TOKEN_URL, {
1823
+ method: 'POST',
1824
+ headers: {
1825
+ 'Content-Type': 'application/json',
1826
+ 'anthropic-dangerous-direct-browser-access': 'true',
1827
+ 'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
1828
+ },
1829
+ body: JSON.stringify({
1830
+ grant_type: 'authorization_code',
1831
+ code,
1832
+ redirect_uri: OAUTH_REDIRECT_URI,
1833
+ client_id: CLAUDE_CODE_CLIENT_ID,
1834
+ code_verifier: pkce.verifier,
1835
+ state,
1836
+ }),
1837
+ redirect: 'error',
1838
+ signal: AbortSignal.timeout(OAUTH_TOKEN_TIMEOUT_MS),
1839
+ dispatcher: getLlmDispatcher(),
1840
+ });
1841
+ if (!tokenRes.ok) { resolve(null); return; }
1842
+ const json = await tokenRes.json();
1843
+ const accessToken = json?.access_token || json?.accessToken;
1844
+ const refreshToken = json?.refresh_token || json?.refreshToken;
1845
+ if (!accessToken || !refreshToken) { resolve(null); return; }
1846
+ const expiresAt = _normalizeExpiresAt(json?.expires_at ?? json?.expiresAt)
1847
+ || (typeof json?.expires_in === 'number' ? Date.now() + json.expires_in * 1000 : 0);
1848
+ const scopes = _oauthParseScopeField(json?.scope);
1849
+ const credPath = _oauthCredentialsWritePath();
1850
+ let raw = {};
1851
+ if (existsSync(credPath)) {
1852
+ raw = JSON.parse(readFileSync(credPath, 'utf-8'));
1853
+ }
1854
+ const existingOauth = raw.claudeAiOauth || {};
1855
+ raw.claudeAiOauth = {
1856
+ ...existingOauth,
1857
+ accessToken,
1858
+ refreshToken,
1859
+ expiresAt,
1860
+ scopes,
1861
+ subscriptionType: existingOauth.subscriptionType ?? null,
1862
+ };
1863
+ _saveCredentialsFile(credPath, raw);
1864
+ resolve({
1865
+ path: credPath,
1866
+ accessToken,
1867
+ refreshToken,
1868
+ expiresAt,
1869
+ scopes,
1870
+ subscriptionType: raw.claudeAiOauth.subscriptionType,
1871
+ });
1872
+ } catch {
1873
+ resolve(null);
1874
+ }
1875
+ });
1876
+ server.listen(OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_HOST);
1877
+ server.on('error', () => { clearTimeout(timeout); resolve(null); });
1878
+ });
1879
+ }
1880
+
1742
1881
  // Additive exports for test harnesses.
1743
1882
  // Lets the SSE parser be exercised in isolation against a synthetic
1744
1883
  // ReadableStream without needing a live OAuth session.