mixdog 0.7.6 → 0.7.8
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/CHANGELOG.md +29 -0
- package/README.md +8 -4
- package/hooks/session-start.cjs +73 -2
- package/package.json +1 -1
- 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 +227 -10
- package/setup/launch.mjs +0 -0
- package/setup/locate-claude.mjs +38 -0
- package/setup/mixdog-cli.mjs +6 -42
- package/setup/setup-server.mjs +50 -2
- package/setup/setup.html +26 -12
- package/setup/tui.mjs +606 -0
- package/setup/wizard.mjs +117 -128
- 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/shared/disable-claude-builtins.mjs +7 -4
- package/src/shared/user-data-guard.mjs +5 -1
- package/src/status/aggregator.mjs +3 -3
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: () =>
|
|
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
|
|
263
|
-
: 'Discord bot token
|
|
264
|
-
token = (await
|
|
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
|
|
277
|
-
? `${
|
|
278
|
-
:
|
|
279
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
346
|
-
if (
|
|
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
|
-
|
|
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
|
|
374
|
-
|
|
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
|
|
393
|
-
if (
|
|
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
|
|
409
|
-
? `${
|
|
410
|
-
:
|
|
411
|
-
|
|
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
|
|
419
|
-
: 'ngrok Auth Token
|
|
420
|
-
webhook.authtoken = (await
|
|
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).
|
|
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
|
|
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
|
|
462
|
-
if (
|
|
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
|
|
496
|
-
|
|
497
|
-
|
|
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
|
|
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
|
|
559
|
-
: `${p.name} API key
|
|
560
|
-
const key = (await
|
|
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
|
|
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
|
|
581
|
-
|
|
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
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
|
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
|
|
652
|
-
|
|
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
|
|
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);
|
|
@@ -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.
|