dual-brain 7.1.20 → 7.1.22
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/bin/dual-brain.mjs +2580 -713
- package/hooks/budget-balancer.mjs +104 -266
- package/hooks/wave-orchestrator.mjs +29 -26
- package/package.json +13 -3
- package/scripts/verify-publish.mjs +26 -0
- package/src/context.mjs +389 -0
- package/src/decide.mjs +283 -60
- package/src/detect.mjs +133 -1
- package/src/dispatch.mjs +175 -30
- package/src/doctor.mjs +577 -0
- package/src/failure-memory.mjs +178 -0
- package/src/nextstep.mjs +100 -0
- package/src/observer.mjs +241 -0
- package/src/outcome.mjs +256 -0
- package/src/pipeline.mjs +759 -0
- package/src/profile.mjs +357 -485
- package/src/receipt.mjs +131 -0
- package/src/session.mjs +358 -10
package/bin/dual-brain.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// dual-brain — CLI entry point. Commands: init, go, status, remember, forget
|
|
2
|
+
// dual-brain — CLI entry point. Commands: init, go, think, review, status, remember, forget
|
|
3
3
|
|
|
4
|
-
import { appendFileSync, existsSync, readFileSync, mkdirSync, writeFileSync, statSync, readdirSync, unlinkSync } from 'node:fs';
|
|
5
|
-
import { join, dirname } from 'node:path';
|
|
4
|
+
import { appendFileSync, existsSync, readFileSync, mkdirSync, writeFileSync, statSync, readdirSync, unlinkSync, watch as fsWatch } from 'node:fs';
|
|
5
|
+
import { join, dirname, basename, extname } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { execSync, spawnSync as _spawnSyncTop } from 'node:child_process';
|
|
8
8
|
import { createInterface } from 'node:readline';
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
rememberPreference, forgetPreference, getActivePreferences,
|
|
13
13
|
getAvailableProviders, isSoloBrain, getHeadModel,
|
|
14
14
|
detectAuth, detectEnvironment, detectPlans,
|
|
15
|
+
detectCapabilities,
|
|
15
16
|
saveSubscription, listSubscriptions,
|
|
16
17
|
autoSetup,
|
|
17
18
|
} from '../src/profile.mjs';
|
|
@@ -28,11 +29,31 @@ import {
|
|
|
28
29
|
|
|
29
30
|
import { dispatch, detectRuntime, dispatchDualBrain } from '../src/dispatch.mjs';
|
|
30
31
|
|
|
32
|
+
import { runPipeline, buildExecutionPlan, formatExecutionPlan } from '../src/pipeline.mjs';
|
|
33
|
+
|
|
31
34
|
import { loadRepoCache } from '../src/repo.mjs';
|
|
32
|
-
import { loadSession, saveSession, formatSessionCard, importReplitSessions, renameSession, pinSession, unpinSession, categorizeSession, enrichSessions } from '../src/session.mjs';
|
|
35
|
+
import { loadSession, saveSession, formatSessionCard, importReplitSessions, getSessionMeta, saveSessionMeta, renameSession, pinSession, unpinSession, categorizeSession, enrichSessions, archiveSession, getArchivedSessions } from '../src/session.mjs';
|
|
33
36
|
|
|
34
37
|
import { box, bar, badge, menu, separator } from '../src/tui.mjs';
|
|
35
38
|
|
|
39
|
+
// ─── Dynamic imports for receipts + failure memory ───────────────────────────
|
|
40
|
+
|
|
41
|
+
let _receipt = null;
|
|
42
|
+
async function getReceipt() {
|
|
43
|
+
if (!_receipt) {
|
|
44
|
+
try { _receipt = await import('../src/receipt.mjs'); } catch { _receipt = {}; }
|
|
45
|
+
}
|
|
46
|
+
return _receipt;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let _failureMem = null;
|
|
50
|
+
async function getFailureMem() {
|
|
51
|
+
if (!_failureMem) {
|
|
52
|
+
try { _failureMem = await import('../src/failure-memory.mjs'); } catch { _failureMem = {}; }
|
|
53
|
+
}
|
|
54
|
+
return _failureMem;
|
|
55
|
+
}
|
|
56
|
+
|
|
36
57
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
37
58
|
|
|
38
59
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -155,20 +176,26 @@ function printHelp() {
|
|
|
155
176
|
dual-brain <command> [options]
|
|
156
177
|
|
|
157
178
|
Commands:
|
|
179
|
+
plan "task" Scope and plan without executing (dry-run)
|
|
180
|
+
do "task" Implement — detect, route, execute, verify
|
|
181
|
+
review Challenge current changes with dual-brain
|
|
182
|
+
ship Test, commit, and prepare to ship
|
|
183
|
+
|
|
158
184
|
init First-time setup → flows into interactive REPL
|
|
159
|
-
auth Show
|
|
185
|
+
auth Show provider login and plan status
|
|
160
186
|
install Install Claude Code hooks into the current project
|
|
161
|
-
go "task description" Detect → decide → dispatch
|
|
187
|
+
go "task description" Detect → decide → dispatch (alias for do)
|
|
162
188
|
--dry-run Show routing decision without executing
|
|
163
189
|
--files a.mjs,b.mjs Provide file context for risk classification
|
|
164
190
|
--verbose, -v Print routing trace (intent, risk, health, model selection)
|
|
191
|
+
think "question" Multi-round architecture decision with dual-brain
|
|
165
192
|
status Provider health, session stats, available models
|
|
166
193
|
--verbose, -v Also print profile file path and raw profile object
|
|
167
194
|
hot <provider> Manually mark all model classes for provider as hot
|
|
168
195
|
cool <provider> Manually clear hot state for a provider
|
|
169
196
|
remember "preference" Save a project-scoped preference
|
|
170
197
|
forget "preference" Remove a preference by fuzzy match
|
|
171
|
-
search "keyword"
|
|
198
|
+
search "keyword" Search across all sessions
|
|
172
199
|
specialists List available specialist agents with descriptions
|
|
173
200
|
python "task" Force Python specialist for the task
|
|
174
201
|
typescript "task" Force TypeScript specialist for the task
|
|
@@ -177,6 +204,8 @@ Commands:
|
|
|
177
204
|
security "task" Force Security specialist for the task
|
|
178
205
|
--dry-run (specialist commands) Show routing without executing
|
|
179
206
|
--files a,b (specialist commands) Provide file context
|
|
207
|
+
watch [dir] Monitor file changes and suggest actions
|
|
208
|
+
--auto Auto-execute safe suggestions (tests, install)
|
|
180
209
|
shell-hook Output bash snippet to add dual-brain to your shell
|
|
181
210
|
Usage: dual-brain shell-hook >> ~/.bashrc
|
|
182
211
|
|
|
@@ -218,7 +247,7 @@ function detectReplitTools(cwd) {
|
|
|
218
247
|
// ─── Subscription status table ────────────────────────────────────────────────
|
|
219
248
|
|
|
220
249
|
/**
|
|
221
|
-
* Print a
|
|
250
|
+
* Print a provider status table to stdout.
|
|
222
251
|
*/
|
|
223
252
|
function printSubscriptionTable(auth, profile) {
|
|
224
253
|
const W = 55;
|
|
@@ -232,10 +261,10 @@ function printSubscriptionTable(auth, profile) {
|
|
|
232
261
|
const openaiSub = profile?.providers?.openai;
|
|
233
262
|
|
|
234
263
|
const claudePlanLabel = claudeSub?.enabled
|
|
235
|
-
? ({ pro: 'Pro
|
|
264
|
+
? ({ pro: 'Pro', max5: 'Max x5', max20: 'Max x20', '$20': 'Pro', '$100': 'Max x5', '$200': 'Max x20' }[claudeSub.plan] ?? claudeSub.plan) // doctor:verified — config value lookup
|
|
236
265
|
: 'disabled';
|
|
237
266
|
const openaiPlanLabel = openaiSub?.enabled
|
|
238
|
-
? ({ plus: 'Plus
|
|
267
|
+
? ({ plus: 'Plus', pro: 'Pro', pro100: 'Pro', pro200: 'Pro (higher limits)', '$20': 'Plus', '$100': 'Pro', '$200': 'Pro (higher limits)' }[openaiSub.plan] ?? openaiSub.plan) // doctor:verified — config value lookup
|
|
239
268
|
: 'disabled';
|
|
240
269
|
|
|
241
270
|
const claudeLabel = claudeSub?.label ? ` [${claudeSub.label}]` : '';
|
|
@@ -252,7 +281,7 @@ function printSubscriptionTable(auth, profile) {
|
|
|
252
281
|
const openaiLine2 = ` plan: ${openaiPlanLabel}${openaiLabel}`;
|
|
253
282
|
|
|
254
283
|
console.log(`╔${hbar}╗`);
|
|
255
|
-
console.log(`║${pad('
|
|
284
|
+
console.log(`║${pad(' Provider Status')}║`);
|
|
256
285
|
console.log(`╠${hbar}╣`);
|
|
257
286
|
console.log(`║${pad(claudeLine1)}║`);
|
|
258
287
|
console.log(`║${pad(claudeLine2)}║`);
|
|
@@ -297,7 +326,7 @@ async function cmdInit(rl) {
|
|
|
297
326
|
}
|
|
298
327
|
|
|
299
328
|
/**
|
|
300
|
-
* Show
|
|
329
|
+
* Show provider login and plan status.
|
|
301
330
|
*/
|
|
302
331
|
async function cmdAuth(subArgs = []) {
|
|
303
332
|
const auth = await detectAuth();
|
|
@@ -311,8 +340,8 @@ async function cmdAuth(subArgs = []) {
|
|
|
311
340
|
}
|
|
312
341
|
}
|
|
313
342
|
|
|
314
|
-
async function cmdGo(args) {
|
|
315
|
-
const dryRun = args.includes('--dry-run');
|
|
343
|
+
async function cmdGo(args, opts = {}) {
|
|
344
|
+
const dryRun = opts.dryRun || args.includes('--dry-run');
|
|
316
345
|
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
317
346
|
const filesRaw = flag(args, '--files');
|
|
318
347
|
const files = filesRaw && typeof filesRaw === 'string'
|
|
@@ -323,100 +352,310 @@ async function cmdGo(args) {
|
|
|
323
352
|
const prompt = args.find(a => !a.startsWith('--') && !a.startsWith('-') && a !== (filesRaw ?? ''));
|
|
324
353
|
if (!prompt) err('Usage: dual-brain go "task description" [--dry-run] [--files a,b] [--verbose]');
|
|
325
354
|
|
|
326
|
-
const cwd
|
|
327
|
-
|
|
328
|
-
const detection = detectTask({ prompt, files });
|
|
329
|
-
|
|
330
|
-
// Print the one-sentence classification
|
|
331
|
-
console.log(detection.explanation);
|
|
332
|
-
|
|
333
|
-
// Verbose: emit detection trace before routing decision
|
|
334
|
-
if (verbose) {
|
|
335
|
-
vtrace(`Intent: ${detection.intent} | Risk: ${detection.risk} | Complexity: ${detection.complexity} | Effort: ${detection.effort ?? 'n/a'}`);
|
|
336
|
-
vtrace(`Tier: ${detection.tier} | Files: ${detection.fileCount ?? files.length} | Requires write: ${detection.requiresWrite}`);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Verbose: emit provider health scores before dispatch
|
|
340
|
-
if (verbose) {
|
|
341
|
-
const providers = getAvailableProviders(profile);
|
|
342
|
-
const { states } = getHealth(cwd);
|
|
343
|
-
const providerScores = ['claude', 'openai'].map(name => {
|
|
344
|
-
const enabled = providers.some(p => p.name === name);
|
|
345
|
-
if (!enabled) return `${name}=unavailable`;
|
|
346
|
-
// Find any state entry for this provider
|
|
347
|
-
const statuses = Object.entries(states)
|
|
348
|
-
.filter(([k]) => k.startsWith(`${name}:`))
|
|
349
|
-
.map(([, v]) => v.status);
|
|
350
|
-
const worst = statuses.includes('hot') ? 'hot'
|
|
351
|
-
: statuses.includes('probing') ? 'probing'
|
|
352
|
-
: statuses.includes('degraded') ? 'degraded'
|
|
353
|
-
: 'healthy';
|
|
354
|
-
return `${name}=${worst}`;
|
|
355
|
-
}).join(' ');
|
|
356
|
-
vtrace(`Provider health: ${providerScores}`);
|
|
357
|
-
}
|
|
355
|
+
const cwd = process.cwd();
|
|
356
|
+
await ensureProfile(cwd);
|
|
358
357
|
|
|
359
|
-
|
|
358
|
+
if (verbose) console.log('\nDispatching...');
|
|
360
359
|
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
360
|
+
// ── Failure memory: check history before dispatching ──────────────────────
|
|
361
|
+
const failureMem = await getFailureMem();
|
|
362
|
+
if (failureMem.checkFailureHistory && failureMem.formatEscalation) {
|
|
363
|
+
try {
|
|
364
|
+
const failureHistory = await failureMem.checkFailureHistory(prompt, files, cwd);
|
|
365
|
+
if (failureHistory?.escalation?.recommended) {
|
|
366
|
+
console.log(failureMem.formatEscalation(failureHistory.escalation));
|
|
367
|
+
}
|
|
368
|
+
} catch { /* non-fatal */ }
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
371
|
+
const { plan, result } = await runPipeline('go', prompt, {
|
|
372
|
+
files,
|
|
373
|
+
cwd,
|
|
374
|
+
verbose,
|
|
375
|
+
dryRun,
|
|
376
|
+
});
|
|
377
377
|
|
|
378
378
|
if (dryRun) {
|
|
379
|
+
// formatExecutionPlan already printed by pipeline when verbose/dryRun=true
|
|
379
380
|
console.log('\n(dry-run — not executing)');
|
|
380
381
|
return;
|
|
381
382
|
}
|
|
382
383
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
384
|
+
if (!result) return;
|
|
385
|
+
|
|
386
|
+
// Display result — dual-brain vs single-provider
|
|
387
|
+
if (result.consensus) {
|
|
387
388
|
console.log(`\nConsensus: ${result.consensus}`);
|
|
388
389
|
if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
|
|
389
390
|
if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
|
|
390
|
-
|
|
391
|
+
|
|
392
|
+
// Receipt
|
|
393
|
+
const receipt = await getReceipt();
|
|
394
|
+
if (receipt.buildReceipt && receipt.formatReceipt) {
|
|
395
|
+
try {
|
|
396
|
+
const r = receipt.buildReceipt(result, plan, null);
|
|
397
|
+
console.log(receipt.formatReceipt(r));
|
|
398
|
+
} catch { /* non-fatal */ }
|
|
399
|
+
}
|
|
400
|
+
|
|
391
401
|
saveSession({
|
|
392
402
|
objective: prompt,
|
|
393
403
|
branch: null,
|
|
394
404
|
filesChanged: files,
|
|
395
405
|
commandsRun: [`dual-brain go "${prompt}"`],
|
|
396
406
|
lastResult: { status: 'success', summary: result.consensus || 'dual-brain complete' },
|
|
397
|
-
provider:
|
|
407
|
+
provider: plan?._decision?.provider ?? 'claude',
|
|
398
408
|
nextAction: null,
|
|
399
409
|
}, cwd);
|
|
410
|
+
|
|
411
|
+
// Clear failure memory on success
|
|
412
|
+
if (failureMem.clearFailures) {
|
|
413
|
+
try { await failureMem.clearFailures(prompt, cwd); } catch { /* non-fatal */ }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── Next steps suggestions (dual-brain consensus path) ──────────────────
|
|
417
|
+
try {
|
|
418
|
+
const { suggestNextSteps, formatNextSteps } = await import('../src/nextstep.mjs');
|
|
419
|
+
const steps = await suggestNextSteps(
|
|
420
|
+
{ prompt, tier: plan?._decision?.tier ?? 'think', files, trigger: 'go' },
|
|
421
|
+
{ success: true, filesChanged: files, error: null, duration: null },
|
|
422
|
+
cwd
|
|
423
|
+
);
|
|
424
|
+
if (steps?.steps?.length > 0) {
|
|
425
|
+
console.log('\n' + formatNextSteps(steps.steps, 3));
|
|
426
|
+
}
|
|
427
|
+
} catch { /* non-fatal */ }
|
|
400
428
|
} else {
|
|
401
|
-
|
|
402
|
-
const statusLine =
|
|
403
|
-
console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
429
|
+
const succeeded = result.status === 'completed';
|
|
430
|
+
const statusLine = succeeded ? 'Done' : `Failed (exit ${result.exitCode})`;
|
|
431
|
+
console.log(`\n${statusLine}${result.durationMs != null ? ` in ${(result.durationMs / 1000).toFixed(1)}s` : ''}`);
|
|
404
432
|
if (result.summary) console.log(result.summary);
|
|
405
433
|
if (result.error) process.stderr.write(`${result.error}\n`);
|
|
406
|
-
|
|
434
|
+
|
|
435
|
+
// Receipt
|
|
436
|
+
const receipt = await getReceipt();
|
|
437
|
+
if (succeeded && receipt.buildReceipt && receipt.formatReceipt) {
|
|
438
|
+
try {
|
|
439
|
+
const r = receipt.buildReceipt(result, plan, null);
|
|
440
|
+
console.log(receipt.formatReceipt(r));
|
|
441
|
+
} catch { /* non-fatal */ }
|
|
442
|
+
} else if (!succeeded && receipt.buildReceipt && receipt.formatFailureReceipt) {
|
|
443
|
+
try {
|
|
444
|
+
const r = receipt.buildReceipt(result, plan, null);
|
|
445
|
+
console.log(receipt.formatFailureReceipt(r, { error: result.error }));
|
|
446
|
+
} catch { /* non-fatal */ }
|
|
447
|
+
}
|
|
448
|
+
|
|
407
449
|
saveSession({
|
|
408
450
|
objective: prompt,
|
|
409
451
|
branch: null,
|
|
410
452
|
filesChanged: files,
|
|
411
453
|
commandsRun: [`dual-brain go "${prompt}"`],
|
|
412
454
|
lastResult: {
|
|
413
|
-
status:
|
|
414
|
-
summary: result.summary || (
|
|
455
|
+
status: succeeded ? 'success' : 'failure',
|
|
456
|
+
summary: result.summary || (succeeded ? 'completed' : `exit ${result.exitCode}`),
|
|
415
457
|
},
|
|
416
|
-
provider:
|
|
458
|
+
provider: plan?._decision?.provider ?? 'claude',
|
|
417
459
|
nextAction: null,
|
|
418
460
|
}, cwd);
|
|
419
|
-
|
|
461
|
+
|
|
462
|
+
if (!succeeded) {
|
|
463
|
+
// Record failure memory
|
|
464
|
+
if (failureMem.recordFailure) {
|
|
465
|
+
try { await failureMem.recordFailure(prompt, plan, result.error, cwd); } catch { /* non-fatal */ }
|
|
466
|
+
}
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Clear failure memory on success
|
|
471
|
+
if (failureMem.clearFailures) {
|
|
472
|
+
try { await failureMem.clearFailures(prompt, cwd); } catch { /* non-fatal */ }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
await offerAutoCommit(cwd);
|
|
476
|
+
// ── Next steps suggestions ──────────────────────────────────────────────
|
|
477
|
+
try {
|
|
478
|
+
const { suggestNextSteps, formatNextSteps } = await import('../src/nextstep.mjs');
|
|
479
|
+
const steps = await suggestNextSteps(
|
|
480
|
+
{
|
|
481
|
+
prompt,
|
|
482
|
+
tier: plan?._decision?.tier ?? 'execute',
|
|
483
|
+
files: result.filesChanged || files,
|
|
484
|
+
trigger: 'go',
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
success: result.status === 'completed',
|
|
488
|
+
filesChanged: result.filesChanged || files,
|
|
489
|
+
error: result.error,
|
|
490
|
+
duration: result.durationMs,
|
|
491
|
+
},
|
|
492
|
+
cwd
|
|
493
|
+
);
|
|
494
|
+
if (steps?.steps?.length > 0) {
|
|
495
|
+
console.log('\n' + formatNextSteps(steps.steps, 3));
|
|
496
|
+
}
|
|
497
|
+
} catch { /* non-fatal — module may not exist yet */ }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function cmdThink(args) {
|
|
502
|
+
const question = args.find(a => !a.startsWith('--') && !a.startsWith('-'));
|
|
503
|
+
if (!question) err('Usage: dual-brain think "architecture question or design decision"');
|
|
504
|
+
|
|
505
|
+
const cwd = process.cwd();
|
|
506
|
+
await ensureProfile(cwd);
|
|
507
|
+
|
|
508
|
+
const { result, verification } = await runPipeline('think', question, {
|
|
509
|
+
cwd,
|
|
510
|
+
verbose: true,
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
if (!result) return;
|
|
514
|
+
|
|
515
|
+
if (result.consensus) {
|
|
516
|
+
console.log(`\nConsensus: ${result.consensus}`);
|
|
517
|
+
if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
|
|
518
|
+
if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
|
|
519
|
+
} else {
|
|
520
|
+
if (result.summary) console.log(`\n${result.summary}`);
|
|
521
|
+
if (result.error) process.stderr.write(`${result.error}\n`);
|
|
522
|
+
if (result.status && result.status !== 'completed') process.exit(1);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (verification && !verification.ok) {
|
|
526
|
+
for (const note of verification.notes) process.stderr.write(` note: ${note}\n`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function cmdReview(_args) {
|
|
531
|
+
const cwd = process.cwd();
|
|
532
|
+
await ensureProfile(cwd);
|
|
533
|
+
|
|
534
|
+
const { result, verification } = await runPipeline('review', 'review current diff', {
|
|
535
|
+
cwd,
|
|
536
|
+
verbose: true,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
if (!result) return;
|
|
540
|
+
|
|
541
|
+
if (result.consensus) {
|
|
542
|
+
console.log(`\nConsensus: ${result.consensus}`);
|
|
543
|
+
if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
|
|
544
|
+
if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
|
|
545
|
+
} else {
|
|
546
|
+
if (result.summary) console.log(`\n${result.summary}`);
|
|
547
|
+
if (result.error) process.stderr.write(`${result.error}\n`);
|
|
548
|
+
if (result.status && result.status !== 'completed') process.exit(1);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (verification && !verification.ok) {
|
|
552
|
+
for (const note of verification.notes) process.stderr.write(` note: ${note}\n`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function cmdShip() {
|
|
557
|
+
const cwd = process.cwd();
|
|
558
|
+
|
|
559
|
+
console.log('\n── ship: finalizing ──────────────────────────────────────\n');
|
|
560
|
+
|
|
561
|
+
// 1. Check for test script
|
|
562
|
+
let hasTests = false;
|
|
563
|
+
let testScript = null;
|
|
564
|
+
try {
|
|
565
|
+
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
|
|
566
|
+
testScript = pkg?.scripts?.test;
|
|
567
|
+
hasTests = Boolean(testScript && !testScript.includes('echo'));
|
|
568
|
+
} catch { /* no package.json */ }
|
|
569
|
+
|
|
570
|
+
// 2. Run tests if available
|
|
571
|
+
let testsPassed = null;
|
|
572
|
+
if (hasTests) {
|
|
573
|
+
console.log('Running tests...\n');
|
|
574
|
+
const testResult = _spawnSyncTop('npm', ['test'], {
|
|
575
|
+
cwd,
|
|
576
|
+
encoding: 'utf8',
|
|
577
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
578
|
+
timeout: 60000,
|
|
579
|
+
});
|
|
580
|
+
const testOut = (testResult.stdout || '') + (testResult.stderr || '');
|
|
581
|
+
if (testOut) console.log(testOut.slice(0, 3000));
|
|
582
|
+
testsPassed = testResult.status === 0;
|
|
583
|
+
console.log(testsPassed ? 'Tests: PASS' : 'Tests: FAIL');
|
|
584
|
+
} else {
|
|
585
|
+
console.log('(no test script found in package.json — skipping tests)');
|
|
586
|
+
testsPassed = null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// 3. Git status
|
|
590
|
+
let changedFiles = [];
|
|
591
|
+
let currentBranch = 'unknown';
|
|
592
|
+
try {
|
|
593
|
+
const statusResult = _spawnSyncTop('git', ['status', '--porcelain'], {
|
|
594
|
+
cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000,
|
|
595
|
+
});
|
|
596
|
+
changedFiles = (statusResult.stdout || '').trim().split('\n').filter(Boolean);
|
|
597
|
+
const branchResult = _spawnSyncTop('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
598
|
+
cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 3000,
|
|
599
|
+
});
|
|
600
|
+
currentBranch = (branchResult.stdout || '').trim() || 'unknown';
|
|
601
|
+
} catch { /* non-fatal */ }
|
|
602
|
+
|
|
603
|
+
if (changedFiles.length > 0) {
|
|
604
|
+
console.log('\nChanged files:');
|
|
605
|
+
changedFiles.slice(0, 20).forEach(f => console.log(` ${f}`));
|
|
606
|
+
if (changedFiles.length > 20) console.log(` ... and ${changedFiles.length - 20} more`);
|
|
607
|
+
} else {
|
|
608
|
+
console.log('\nNo uncommitted changes.');
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// 4. Generate commit message suggestion
|
|
612
|
+
let commitMsg = null;
|
|
613
|
+
try {
|
|
614
|
+
const diffResult = _spawnSyncTop('git', ['diff', '--name-only', 'HEAD'], {
|
|
615
|
+
cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000,
|
|
616
|
+
});
|
|
617
|
+
const diffFiles = (diffResult.stdout || '').trim().split('\n').filter(Boolean);
|
|
618
|
+
if (diffFiles.length > 0) {
|
|
619
|
+
const fileList = diffFiles.slice(0, 5).map(f => basename(f)).join(', ');
|
|
620
|
+
const suffix = diffFiles.length > 5 ? ` and ${diffFiles.length - 5} more` : '';
|
|
621
|
+
commitMsg = `update ${fileList}${suffix}`;
|
|
622
|
+
}
|
|
623
|
+
} catch { /* non-fatal */ }
|
|
624
|
+
|
|
625
|
+
// 5. Show suggested actions
|
|
626
|
+
console.log('\n── suggested actions ─────────────────────────────────────\n');
|
|
627
|
+
|
|
628
|
+
if (testsPassed === false) {
|
|
629
|
+
console.log(' ⚠ Tests failed — fix before committing.');
|
|
630
|
+
} else {
|
|
631
|
+
if (changedFiles.length > 0 && commitMsg) {
|
|
632
|
+
console.log(` Commit: git add -p && git commit -m "${commitMsg}"`);
|
|
633
|
+
} else if (changedFiles.length === 0) {
|
|
634
|
+
console.log(' Nothing to commit — working tree clean.');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const isMain = currentBranch === 'main' || currentBranch === 'master';
|
|
638
|
+
if (!isMain && currentBranch !== 'unknown') {
|
|
639
|
+
console.log(` PR: gh pr create --title "${commitMsg || 'update'}" --body "..."`);}
|
|
640
|
+
else if (isMain) {
|
|
641
|
+
console.log(' (on main — consider branching before PR)');
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// 6. Receipt
|
|
646
|
+
const receipt = await getReceipt();
|
|
647
|
+
if (receipt.buildReceiptFromOutcome && receipt.formatReceipt) {
|
|
648
|
+
try {
|
|
649
|
+
const r = receipt.buildReceiptFromOutcome({
|
|
650
|
+
command: 'ship',
|
|
651
|
+
branch: currentBranch,
|
|
652
|
+
filesChanged: changedFiles.length,
|
|
653
|
+
testsPassed,
|
|
654
|
+
});
|
|
655
|
+
console.log('\n' + receipt.formatReceipt(r));
|
|
656
|
+
} catch { /* non-fatal */ }
|
|
657
|
+
} else {
|
|
658
|
+
console.log(`\nReceipt: branch=${currentBranch} files=${changedFiles.length} tests=${testsPassed === null ? 'skipped' : testsPassed ? 'pass' : 'fail'}`);
|
|
420
659
|
}
|
|
421
660
|
}
|
|
422
661
|
|
|
@@ -445,8 +684,9 @@ async function cmdStatus(args = []) {
|
|
|
445
684
|
.filter(([k]) => k.startsWith(`${p.name}:`));
|
|
446
685
|
const sess = sessionStats[p.name] ?? { calls: 0, tokens: 0 };
|
|
447
686
|
|
|
687
|
+
const planStr = p.plan ? ` plan=${p.plan}` : '';
|
|
448
688
|
if (provStates.length === 0) {
|
|
449
|
-
console.log(` ${label}
|
|
689
|
+
console.log(` ${label}${planStr} status=healthy calls=${sess.calls} tokens=${sess.tokens}`);
|
|
450
690
|
} else {
|
|
451
691
|
for (const [k, st] of provStates) {
|
|
452
692
|
const modelClass = k.split(':').slice(1).join(':');
|
|
@@ -455,7 +695,7 @@ async function cmdStatus(args = []) {
|
|
|
455
695
|
const remaining = remainingCooldownMinutes(p.name, modelClass, cwd);
|
|
456
696
|
statusStr = remaining > 0 ? `hot (retry in ${remaining}m)` : 'hot (cooling)';
|
|
457
697
|
}
|
|
458
|
-
console.log(` ${label}
|
|
698
|
+
console.log(` ${label}${planStr} model=${modelClass} status=${statusStr} calls=${sess.calls} tokens=${sess.tokens}`);
|
|
459
699
|
}
|
|
460
700
|
}
|
|
461
701
|
}
|
|
@@ -669,10 +909,10 @@ function cmdBreakGlass(reason) {
|
|
|
669
909
|
// ─── Screen helpers ───────────────────────────────────────────────────────────
|
|
670
910
|
|
|
671
911
|
/**
|
|
672
|
-
* Render the
|
|
912
|
+
* Render the dual-brain-style rounded header box for the main screen.
|
|
673
913
|
* Inner width is 39 chars. Lines are padded with spaces to fill the box.
|
|
674
914
|
*/
|
|
675
|
-
function renderHeader(version, providerLines) {
|
|
915
|
+
function renderHeader(version, providerLines, dtVersion) {
|
|
676
916
|
const W = 39; // inner width
|
|
677
917
|
const pad = (s) => {
|
|
678
918
|
// Strip ANSI codes for length calculation
|
|
@@ -683,14 +923,19 @@ function renderHeader(version, providerLines) {
|
|
|
683
923
|
const sep = ` ├${'─'.repeat(W)}┤`;
|
|
684
924
|
const bottom = ` └${'─'.repeat(W)}┘`;
|
|
685
925
|
|
|
686
|
-
const title =
|
|
687
|
-
const credit = `
|
|
926
|
+
const title = `🧠 Dual Brain v${version}`;
|
|
927
|
+
const credit = `dual-brain`;
|
|
928
|
+
|
|
929
|
+
const allProviderLines = [...providerLines];
|
|
930
|
+
if (dtVersion) {
|
|
931
|
+
allProviderLines.push(`📦 replit-tools v${dtVersion} detected`);
|
|
932
|
+
}
|
|
688
933
|
|
|
689
934
|
const lines = [top];
|
|
690
935
|
lines.push(` │ ${pad(title)}│`);
|
|
691
936
|
lines.push(` │ ${pad(credit)}│`);
|
|
692
937
|
lines.push(sep);
|
|
693
|
-
for (const pl of
|
|
938
|
+
for (const pl of allProviderLines) {
|
|
694
939
|
lines.push(` │ ${pad(pl)}│`);
|
|
695
940
|
}
|
|
696
941
|
lines.push(bottom);
|
|
@@ -707,21 +952,21 @@ function profileExists(cwd) {
|
|
|
707
952
|
// ─── Plan label helpers ───────────────────────────────────────────────────────
|
|
708
953
|
|
|
709
954
|
const CLAUDE_PLAN_LABELS = {
|
|
710
|
-
pro: 'Pro
|
|
711
|
-
max5: 'Max x5
|
|
712
|
-
max20: 'Max x20
|
|
713
|
-
'$20': 'Pro
|
|
714
|
-
'$100': 'Max x5
|
|
715
|
-
'$200': 'Max x20
|
|
955
|
+
pro: 'Pro',
|
|
956
|
+
max5: 'Max x5',
|
|
957
|
+
max20: 'Max x20',
|
|
958
|
+
'$20': 'Pro', // doctor:verified — backward-compat key for legacy stored plan value
|
|
959
|
+
'$100': 'Max x5', // doctor:verified — backward-compat key for legacy stored plan value
|
|
960
|
+
'$200': 'Max x20', // doctor:verified — backward-compat key for legacy stored plan value
|
|
716
961
|
};
|
|
717
962
|
const OPENAI_PLAN_LABELS = {
|
|
718
|
-
plus: 'Plus
|
|
719
|
-
pro: 'Pro
|
|
720
|
-
pro100: 'Pro
|
|
721
|
-
pro200: 'Pro (
|
|
722
|
-
'$20': 'Plus
|
|
723
|
-
'$100': 'Pro
|
|
724
|
-
'$200': 'Pro (
|
|
963
|
+
plus: 'Plus',
|
|
964
|
+
pro: 'Pro',
|
|
965
|
+
pro100: 'Pro',
|
|
966
|
+
pro200: 'Pro (higher limits)',
|
|
967
|
+
'$20': 'Plus', // doctor:verified — backward-compat key for legacy stored plan value
|
|
968
|
+
'$100': 'Pro', // doctor:verified — backward-compat key for legacy stored plan value
|
|
969
|
+
'$200': 'Pro (higher limits)', // doctor:verified — backward-compat key for legacy stored plan value
|
|
725
970
|
};
|
|
726
971
|
|
|
727
972
|
// ─── Screen: welcomeScreen ────────────────────────────────────────────────────
|
|
@@ -807,7 +1052,7 @@ async function welcomeScreen(rl, ask) {
|
|
|
807
1052
|
}
|
|
808
1053
|
|
|
809
1054
|
console.log(' [Enter] Save and go');
|
|
810
|
-
console.log(' [c] Customize
|
|
1055
|
+
console.log(' [c] Customize work style');
|
|
811
1056
|
if (existingSessions.length > 0) {
|
|
812
1057
|
console.log(` [i] Import ${existingSessions.length} session${existingSessions.length !== 1 ? 's' : ''} from data-tools`);
|
|
813
1058
|
}
|
|
@@ -870,10 +1115,10 @@ async function welcomeScreen(rl, ask) {
|
|
|
870
1115
|
// Claude plan picker
|
|
871
1116
|
if (claudeReady) {
|
|
872
1117
|
console.log('');
|
|
873
|
-
console.log(separator('Claude
|
|
874
|
-
console.log(' (1) Pro
|
|
875
|
-
console.log(' (2) Max x5
|
|
876
|
-
console.log(' (3) Max x20
|
|
1118
|
+
console.log(separator('Claude plan'));
|
|
1119
|
+
console.log(' (1) Pro');
|
|
1120
|
+
console.log(' (2) Max x5');
|
|
1121
|
+
console.log(' (3) Max x20');
|
|
877
1122
|
console.log(' (4) Skip');
|
|
878
1123
|
const claudeChoice = (await ask('> ')).trim();
|
|
879
1124
|
const claudePlanMap = { '1': 'pro', '2': 'max5', '3': 'max20' };
|
|
@@ -890,10 +1135,10 @@ async function welcomeScreen(rl, ask) {
|
|
|
890
1135
|
// OpenAI plan picker
|
|
891
1136
|
if (openaiReady) {
|
|
892
1137
|
console.log('');
|
|
893
|
-
console.log(separator('OpenAI
|
|
894
|
-
console.log(' (1) Plus
|
|
895
|
-
console.log(' (2) Pro
|
|
896
|
-
console.log(' (3) Pro (
|
|
1138
|
+
console.log(separator('OpenAI plan'));
|
|
1139
|
+
console.log(' (1) Plus');
|
|
1140
|
+
console.log(' (2) Pro');
|
|
1141
|
+
console.log(' (3) Pro (higher limits)');
|
|
897
1142
|
console.log(' (4) Skip');
|
|
898
1143
|
const openaiChoice = (await ask('> ')).trim();
|
|
899
1144
|
const openaiPlanMap = { '1': 'plus', '2': 'pro', '3': 'pro200' };
|
|
@@ -918,8 +1163,8 @@ async function welcomeScreen(rl, ask) {
|
|
|
918
1163
|
|
|
919
1164
|
// Team setup
|
|
920
1165
|
console.log('');
|
|
921
|
-
console.log(' Team auth: label
|
|
922
|
-
console.log(' When a
|
|
1166
|
+
console.log(' Team auth: label providers and set expiry for auto-refresh.');
|
|
1167
|
+
console.log(' When a provider link expires, dual-brain will prompt re-login automatically.');
|
|
923
1168
|
console.log('');
|
|
924
1169
|
console.log(' [Enter] Skip [t] Set up team auth');
|
|
925
1170
|
const teamChoice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
@@ -927,7 +1172,7 @@ async function welcomeScreen(rl, ask) {
|
|
|
927
1172
|
for (const provider of ['claude', 'openai']) {
|
|
928
1173
|
if (!existingProfile.providers[provider]?.enabled) continue;
|
|
929
1174
|
const provLabel = provider === 'claude' ? 'Claude' : 'OpenAI';
|
|
930
|
-
const label = (await ask(` ${provLabel} label (e.g. "Josh's
|
|
1175
|
+
const label = (await ask(` ${provLabel} label (e.g. "Josh's work account"): `)).trim();
|
|
931
1176
|
if (label) existingProfile.providers[provider].label = label;
|
|
932
1177
|
const expiry = await askExpiry(ask, provLabel);
|
|
933
1178
|
if (expiry) existingProfile.providers[provider].expiresAt = expiry;
|
|
@@ -993,113 +1238,344 @@ function loadTerminalState(cwd, terminalId) {
|
|
|
993
1238
|
} catch { return null; }
|
|
994
1239
|
}
|
|
995
1240
|
|
|
996
|
-
// ───
|
|
1241
|
+
// ─── PR Detection ─────────────────────────────────────────────────────────────
|
|
997
1242
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1243
|
+
/**
|
|
1244
|
+
* Detect open PRs using the gh CLI.
|
|
1245
|
+
* Gracefully returns [] if gh is not installed, no remote, no auth, or no PRs.
|
|
1246
|
+
*
|
|
1247
|
+
* @param {string} cwd
|
|
1248
|
+
* @returns {Promise<Array>}
|
|
1249
|
+
*/
|
|
1250
|
+
async function detectOpenPRs(cwd) {
|
|
1251
|
+
try {
|
|
1252
|
+
// 1. Check if gh CLI exists (1s timeout)
|
|
1253
|
+
const ghCheck = _spawnSyncTop('which', ['gh'], {
|
|
1254
|
+
encoding: 'utf8',
|
|
1255
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1256
|
+
timeout: 1000,
|
|
1257
|
+
});
|
|
1258
|
+
if (ghCheck.status !== 0) return [];
|
|
1003
1259
|
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1260
|
+
// 2. Check if repo has a GitHub remote
|
|
1261
|
+
const remoteCheck = _spawnSyncTop('git', ['remote', 'get-url', 'origin'], {
|
|
1262
|
+
cwd,
|
|
1263
|
+
encoding: 'utf8',
|
|
1264
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1265
|
+
timeout: 1000,
|
|
1266
|
+
});
|
|
1267
|
+
if (remoteCheck.status !== 0) return [];
|
|
1268
|
+
const remoteUrl = (remoteCheck.stdout || '').trim();
|
|
1269
|
+
if (!remoteUrl.includes('github.com')) return [];
|
|
1270
|
+
|
|
1271
|
+
// 3. Fetch open PRs (3s timeout)
|
|
1272
|
+
const prResult = _spawnSyncTop('gh', [
|
|
1273
|
+
'pr', 'list',
|
|
1274
|
+
'--state', 'open',
|
|
1275
|
+
'--json', 'number,title,reviewDecision,reviewRequests,additions,deletions,changedFiles,headRefName',
|
|
1276
|
+
'--limit', '5',
|
|
1277
|
+
], {
|
|
1278
|
+
cwd,
|
|
1279
|
+
encoding: 'utf8',
|
|
1280
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1281
|
+
timeout: 3000,
|
|
1282
|
+
});
|
|
1008
1283
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1284
|
+
if (prResult.status !== 0) return [];
|
|
1285
|
+
const raw = (prResult.stdout || '').trim();
|
|
1286
|
+
if (!raw) return [];
|
|
1287
|
+
|
|
1288
|
+
const prs = JSON.parse(raw);
|
|
1289
|
+
if (!Array.isArray(prs)) return [];
|
|
1290
|
+
return prs;
|
|
1291
|
+
} catch {
|
|
1292
|
+
return [];
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// ─── Dashboard box helpers ────────────────────────────────────────────────────
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Detect repo state for action cards. All checks run with tight timeouts —
|
|
1300
|
+
* best-effort only, never blocks startup.
|
|
1301
|
+
*
|
|
1302
|
+
* Returns: { dirtyCount, lastCommitAgeDays, lastFailure, isGitRepo }
|
|
1303
|
+
*/
|
|
1304
|
+
function detectRepoState(cwd) {
|
|
1305
|
+
const result = { dirtyCount: 0, lastCommitAgeDays: 0, lastFailure: null, isGitRepo: false };
|
|
1306
|
+
try {
|
|
1307
|
+
execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' });
|
|
1308
|
+
result.isGitRepo = true;
|
|
1309
|
+
} catch { return result; }
|
|
1310
|
+
|
|
1311
|
+
try {
|
|
1312
|
+
const status = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' });
|
|
1313
|
+
result.dirtyCount = status.trim().split('\n').filter(Boolean).length;
|
|
1314
|
+
} catch {}
|
|
1013
1315
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1316
|
+
try {
|
|
1317
|
+
const logOut = execSync('git log --format="%ct" -1', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' }).trim();
|
|
1318
|
+
if (logOut) {
|
|
1319
|
+
const commitTs = parseInt(logOut, 10) * 1000;
|
|
1320
|
+
result.lastCommitAgeDays = Math.floor((Date.now() - commitTs) / 86400000);
|
|
1321
|
+
}
|
|
1322
|
+
} catch {}
|
|
1016
1323
|
|
|
1017
|
-
|
|
1018
|
-
const
|
|
1019
|
-
if (
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1324
|
+
try {
|
|
1325
|
+
const sessionPath = join(cwd, '.dualbrain', 'session.json');
|
|
1326
|
+
if (existsSync(sessionPath)) {
|
|
1327
|
+
const sess = JSON.parse(readFileSync(sessionPath, 'utf8'));
|
|
1328
|
+
const lastResult = sess?.lastResult;
|
|
1329
|
+
if (lastResult?.status === 'failure') {
|
|
1330
|
+
const summary = lastResult.task
|
|
1331
|
+
? String(lastResult.task).slice(0, 40)
|
|
1332
|
+
: 'last task';
|
|
1333
|
+
result.lastFailure = summary;
|
|
1334
|
+
}
|
|
1025
1335
|
}
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1336
|
+
} catch {}
|
|
1337
|
+
|
|
1338
|
+
return result;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Build action card rows for the dashboard based on repo state.
|
|
1343
|
+
* Returns an array of box row strings (may be empty).
|
|
1344
|
+
* openPRs is optional — if provided, a PR card is included.
|
|
1345
|
+
*/
|
|
1346
|
+
function buildActionRows(repoState, rowFn, openPRs = []) {
|
|
1347
|
+
if (!repoState.isGitRepo) return [];
|
|
1348
|
+
|
|
1349
|
+
const YELLOW = '\x1b[33m';
|
|
1350
|
+
const RED = '\x1b[31m';
|
|
1351
|
+
const GREEN = '\x1b[32m';
|
|
1352
|
+
const CYAN = '\x1b[36m';
|
|
1353
|
+
const DIM = '\x1b[2m';
|
|
1354
|
+
const RESET = '\x1b[0m';
|
|
1355
|
+
|
|
1356
|
+
const cards = [];
|
|
1357
|
+
|
|
1358
|
+
if (repoState.dirtyCount > 0) {
|
|
1359
|
+
cards.push(`${YELLOW}⚡${RESET} ${repoState.dirtyCount} uncommitted file${repoState.dirtyCount === 1 ? '' : 's'}`);
|
|
1029
1360
|
}
|
|
1030
1361
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
];
|
|
1362
|
+
if (repoState.lastFailure !== null) {
|
|
1363
|
+
cards.push(`${RED}⚡${RESET} Last task failed: ${repoState.lastFailure}`);
|
|
1364
|
+
}
|
|
1035
1365
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
if (latestVersion) {
|
|
1039
|
-
console.log(` ⬆️ Update available: v${version} → v${latestVersion}`);
|
|
1040
|
-
console.log(` Run: npx -y dual-brain@latest`);
|
|
1366
|
+
if (repoState.lastCommitAgeDays >= 3) {
|
|
1367
|
+
cards.push(`${YELLOW}⚡${RESET} ${repoState.lastCommitAgeDays} day${repoState.lastCommitAgeDays === 1 ? '' : 's'} since last commit`);
|
|
1041
1368
|
}
|
|
1042
|
-
console.log('');
|
|
1043
1369
|
|
|
1044
|
-
//
|
|
1045
|
-
|
|
1046
|
-
|
|
1370
|
+
// PR card — show a summary of open PRs when gh is available
|
|
1371
|
+
if (openPRs.length > 0) {
|
|
1372
|
+
const prSummary = openPRs.slice(0, 2)
|
|
1373
|
+
.map(pr => `#${pr.number} ${String(pr.title).slice(0, 22)}`)
|
|
1374
|
+
.join(', ');
|
|
1375
|
+
const trunc = openPRs.length > 2 ? ` +${openPRs.length - 2}` : '';
|
|
1376
|
+
cards.push(`${CYAN}⇅${RESET} ${openPRs.length} open PR${openPRs.length === 1 ? '' : 's'}: ${prSummary}${trunc}`);
|
|
1047
1377
|
}
|
|
1048
1378
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
if (rtMain.installed && rtMain.version) {
|
|
1052
|
-
console.log(` 🔗 replit-tools v${rtMain.version}`);
|
|
1379
|
+
if (cards.length === 0) {
|
|
1380
|
+
return [rowFn(`${DIM}${GREEN}✓${RESET}${DIM} Repo clean${RESET}`)];
|
|
1053
1381
|
}
|
|
1054
1382
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1383
|
+
return cards.map(c => rowFn(c));
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Detect interrupted work from the most recent session.
|
|
1388
|
+
* Returns a continuation hint if confidence is high enough, or null to skip.
|
|
1389
|
+
*
|
|
1390
|
+
* Signals that indicate interrupted work:
|
|
1391
|
+
* - Session < 4 hours old with no clean exit
|
|
1392
|
+
* - Last result was a failure
|
|
1393
|
+
* - Uncommitted git changes exist
|
|
1394
|
+
* - Session has high message count (user was deep in work)
|
|
1395
|
+
*
|
|
1396
|
+
* Minimum thresholds: messageCount > 5 OR filesChanged > 0
|
|
1397
|
+
*
|
|
1398
|
+
* @param {Array} sessions — from importReplitSessions / enrichSessions
|
|
1399
|
+
* @param {string} cwd
|
|
1400
|
+
* @returns {{ shouldContinue: boolean, reason: string, sessionId: string, sessionName: string, lastState: string|null, ageLabel: string }|null}
|
|
1401
|
+
*/
|
|
1402
|
+
function detectInterruptedWork(sessions, cwd) {
|
|
1403
|
+
if (!sessions || sessions.length === 0) return null;
|
|
1404
|
+
|
|
1405
|
+
const most = sessions[0]; // already sorted most-recent first
|
|
1406
|
+
if (!most || !most.lastActive) return null;
|
|
1407
|
+
|
|
1408
|
+
const ageMs = Date.now() - new Date(most.lastActive).getTime();
|
|
1409
|
+
const fourH = 4 * 60 * 60 * 1000;
|
|
1410
|
+
|
|
1411
|
+
// Must be within 4 hours
|
|
1412
|
+
if (ageMs >= fourH) return null;
|
|
1413
|
+
|
|
1414
|
+
// Load session.json for deeper signal
|
|
1415
|
+
const session = loadSession(cwd);
|
|
1416
|
+
|
|
1417
|
+
// Minimum thresholds: must have real work depth
|
|
1418
|
+
const msgCount = most.messageCount ?? most.promptCount ?? 0;
|
|
1419
|
+
const filesChanged = session?.filesChanged?.length ?? 0;
|
|
1420
|
+
if (msgCount <= 5 && filesChanged === 0) return null;
|
|
1421
|
+
|
|
1422
|
+
const lastResultStatus = session?.lastResult?.status ?? null;
|
|
1423
|
+
|
|
1424
|
+
// Build confidence signals
|
|
1425
|
+
const signals = [];
|
|
1426
|
+
if (lastResultStatus === 'failure') signals.push('last run failed');
|
|
1427
|
+
if (filesChanged > 0) signals.push(`${filesChanged} file${filesChanged !== 1 ? 's' : ''} changed`);
|
|
1428
|
+
if (msgCount > 10) signals.push('deep session');
|
|
1429
|
+
|
|
1430
|
+
// Check for uncommitted git changes
|
|
1431
|
+
try {
|
|
1432
|
+
const gitResult = _spawnSyncTop('git', ['status', '--porcelain'], {
|
|
1433
|
+
cwd,
|
|
1434
|
+
encoding: 'utf8',
|
|
1435
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1436
|
+
timeout: 3000,
|
|
1437
|
+
});
|
|
1438
|
+
if (gitResult.status === 0 && gitResult.stdout.trim().length > 0) {
|
|
1439
|
+
signals.push('uncommitted changes');
|
|
1440
|
+
}
|
|
1441
|
+
} catch { /* non-fatal */ }
|
|
1442
|
+
|
|
1443
|
+
// Need at least one signal beyond base thresholds to avoid annoying low-signal cards
|
|
1444
|
+
if (signals.length === 0 && msgCount <= 10) return null;
|
|
1445
|
+
|
|
1446
|
+
// Build a human-readable "last state" from available data
|
|
1447
|
+
let lastState = null;
|
|
1448
|
+
if (session?.lastResult?.summary) {
|
|
1449
|
+
lastState = session.lastResult.summary;
|
|
1450
|
+
} else if (session?.objective) {
|
|
1451
|
+
lastState = session.objective;
|
|
1452
|
+
} else if (most.name && !/^Session [0-9a-f]{8}/i.test(most.name)) {
|
|
1453
|
+
lastState = most.name;
|
|
1058
1454
|
}
|
|
1059
1455
|
|
|
1060
|
-
//
|
|
1456
|
+
// Trim lastState to fit on one line
|
|
1457
|
+
if (lastState && lastState.length > 45) lastState = lastState.slice(0, 42) + '...';
|
|
1458
|
+
|
|
1459
|
+
// Build reason label
|
|
1460
|
+
const reason = signals.length > 0 ? signals.join(', ') : `${msgCount} messages`;
|
|
1461
|
+
|
|
1462
|
+
// Age label
|
|
1463
|
+
const mins = Math.floor(ageMs / 60000);
|
|
1464
|
+
let ageLabel;
|
|
1465
|
+
if (mins < 1) ageLabel = 'just now';
|
|
1466
|
+
else if (mins < 60) ageLabel = `${mins}m ago`;
|
|
1467
|
+
else ageLabel = `${Math.floor(mins / 60)}h ago`;
|
|
1468
|
+
|
|
1469
|
+
return {
|
|
1470
|
+
shouldContinue: true,
|
|
1471
|
+
reason,
|
|
1472
|
+
sessionId: most.id,
|
|
1473
|
+
sessionName: most.name || most.id.slice(0, 8),
|
|
1474
|
+
lastState,
|
|
1475
|
+
ageLabel,
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// ─── Provider status helpers ───────────────────────────────────────────────────
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Build a provider status string for the dashboard status line.
|
|
1483
|
+
* Shows: "● Claude ● OpenAI ⚖️ Balanced"
|
|
1484
|
+
* Uses ANSI color codes for the dots — no dollar amounts or usage bars.
|
|
1485
|
+
*/
|
|
1486
|
+
function buildProviderStatusLine(profile, auth, maxWidth = 54) {
|
|
1487
|
+
const GREEN = '[32m●[0m';
|
|
1488
|
+
const RED = '[31m●[0m';
|
|
1489
|
+
|
|
1490
|
+
const claudeDot = auth.claude.found ? GREEN : RED;
|
|
1491
|
+
const openaiDot = auth.openai.found ? GREEN : RED;
|
|
1492
|
+
|
|
1493
|
+
const WORK_STYLE_LABELS = {
|
|
1494
|
+
'auto': '⚡ Fast',
|
|
1495
|
+
'cost-saver': '⚡ Fast',
|
|
1496
|
+
'balanced': '⚖️ Balanced',
|
|
1497
|
+
'quality-first': '🔥 Full Power',
|
|
1498
|
+
'solo-claude': '⚡ Fast',
|
|
1499
|
+
'solo-openai': '⚡ Fast',
|
|
1500
|
+
};
|
|
1501
|
+
const WORK_STYLE_TIPS = {
|
|
1502
|
+
'auto': 'adapts routing by task risk',
|
|
1503
|
+
'cost-saver': 'single model, minimal reviews',
|
|
1504
|
+
'balanced': 'smart routing, reviews when needed',
|
|
1505
|
+
'quality-first': 'dual-brain on everything important',
|
|
1506
|
+
'solo-claude': 'Claude only, no GPT dispatch',
|
|
1507
|
+
'solo-openai': 'OpenAI only, no Claude dispatch',
|
|
1508
|
+
};
|
|
1509
|
+
const bias = profile?.bias || profile?.mode || 'balanced';
|
|
1510
|
+
const label = WORK_STYLE_LABELS[bias] || '⚖️ Balanced';
|
|
1511
|
+
const fullTip = WORK_STYLE_TIPS[bias] || 'smart routing, reviews when needed';
|
|
1512
|
+
|
|
1513
|
+
// Trim tip to fit within box width (measure visible chars: strip ANSI + variation selectors)
|
|
1514
|
+
const labelPlain = label.replace(/[︀-️]/g, '').replace(/[[0-9;]*m/g, '');
|
|
1515
|
+
const prefixLen = ('● Claude ● OpenAI ' + labelPlain + ' — ').length;
|
|
1516
|
+
const tipMax = maxWidth - prefixLen;
|
|
1517
|
+
const tip = tipMax >= 6
|
|
1518
|
+
? (fullTip.length > tipMax ? fullTip.slice(0, tipMax - 1) + '…' : fullTip)
|
|
1519
|
+
: '';
|
|
1520
|
+
|
|
1521
|
+
const suffix = tip ? `[2m — ${tip}[0m` : '';
|
|
1522
|
+
return `${claudeDot} Claude ${openaiDot} OpenAI ${label}${suffix}`;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* Render a box row padded to inner width W (stripping ANSI for length calculation).
|
|
1527
|
+
* Returns a string like: "│ content padded to W │"
|
|
1528
|
+
*/
|
|
1529
|
+
function makeBoxRow(content, W) {
|
|
1530
|
+
// Strip ANSI codes, then strip zero-width variation selectors (U+FE0F etc.)
|
|
1531
|
+
// so that emoji like ⚖️ (U+2696+U+FE0F) don't inflate the measured length.
|
|
1532
|
+
const plain = content
|
|
1533
|
+
.replace(/\x1b\[[0-9;]*m/g, '') // ANSI color codes
|
|
1534
|
+
.replace(/[\uFE00-\uFE0F]/g, ''); // variation selectors (zero-width)
|
|
1535
|
+
const padding = Math.max(0, W - plain.length);
|
|
1536
|
+
return `│ ${content}${' '.repeat(padding)} │`;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// ─── Screen: mainScreen ───────────────────────────────────────────────────────
|
|
1540
|
+
|
|
1541
|
+
async function mainScreen(rl, ask) {
|
|
1542
|
+
const cwd = process.cwd();
|
|
1543
|
+
const version = readVersion();
|
|
1544
|
+
const profile = loadProfile(cwd);
|
|
1545
|
+
const auth = await detectAuth();
|
|
1546
|
+
|
|
1547
|
+
const claudeSub = profile?.providers?.claude;
|
|
1548
|
+
const openaiSub = profile?.providers?.openai;
|
|
1549
|
+
|
|
1550
|
+
// Check subscription expiry for auto-refresh
|
|
1551
|
+
const now = Date.now();
|
|
1552
|
+
const claudeExpired = claudeSub?.expiresAt && Date.parse(claudeSub.expiresAt) < now;
|
|
1553
|
+
const openaiExpired = openaiSub?.expiresAt && Date.parse(openaiSub.expiresAt) < now;
|
|
1554
|
+
|
|
1555
|
+
// Silent OAuth token auto-refresh
|
|
1061
1556
|
try {
|
|
1062
1557
|
const { autoRefreshToken } = await import('../src/profile.mjs');
|
|
1063
|
-
|
|
1064
|
-
if (refreshResult.status === 'refreshed') {
|
|
1065
|
-
console.log(` 🔄 Token auto-refreshed (${refreshResult.hoursRemaining}h remaining)`);
|
|
1066
|
-
}
|
|
1558
|
+
await autoRefreshToken(cwd);
|
|
1067
1559
|
} catch {}
|
|
1068
1560
|
|
|
1069
|
-
// Append-only session archive sync
|
|
1561
|
+
// Append-only session archive sync
|
|
1070
1562
|
try {
|
|
1071
1563
|
const { syncSessionMirror } = await import('../src/session.mjs');
|
|
1072
|
-
|
|
1073
|
-
if (mirror.copied > 0 || mirror.grew > 0) {
|
|
1074
|
-
console.log(` ✅ Archive mirror: +${mirror.copied} new, ${mirror.grew} updated`);
|
|
1075
|
-
}
|
|
1564
|
+
syncSessionMirror(cwd);
|
|
1076
1565
|
} catch {}
|
|
1077
1566
|
|
|
1078
1567
|
// Auto-refresh expired subscriptions
|
|
1079
1568
|
if (claudeExpired || openaiExpired) {
|
|
1080
1569
|
const { spawnSync } = await import('node:child_process');
|
|
1081
|
-
const expired = [];
|
|
1082
|
-
if (claudeExpired) expired.push('Claude');
|
|
1083
|
-
if (openaiExpired) expired.push('OpenAI');
|
|
1084
|
-
console.log(`\n ${expired.join(' & ')} subscription expired. Re-authenticating...`);
|
|
1085
1570
|
if (claudeExpired) {
|
|
1086
1571
|
const r = spawnSync('claude', ['auth', 'login'], { stdio: 'inherit', timeout: 30000 });
|
|
1087
|
-
if (r.status === 0) {
|
|
1088
|
-
claudeSub.expiresAt = null;
|
|
1089
|
-
saveProfile(profile, { cwd });
|
|
1090
|
-
console.log(' ✓ Claude re-authenticated');
|
|
1091
|
-
}
|
|
1572
|
+
if (r.status === 0) { claudeSub.expiresAt = null; saveProfile(profile, { cwd }); }
|
|
1092
1573
|
}
|
|
1093
1574
|
if (openaiExpired) {
|
|
1094
1575
|
const r = spawnSync('codex', ['login'], { stdio: 'inherit', timeout: 30000 });
|
|
1095
|
-
if (r.status === 0) {
|
|
1096
|
-
openaiSub.expiresAt = null;
|
|
1097
|
-
saveProfile(profile, { cwd });
|
|
1098
|
-
console.log(' ✓ OpenAI re-authenticated');
|
|
1099
|
-
}
|
|
1576
|
+
if (r.status === 0) { openaiSub.expiresAt = null; saveProfile(profile, { cwd }); }
|
|
1100
1577
|
}
|
|
1101
1578
|
}
|
|
1102
|
-
console.log('');
|
|
1103
1579
|
|
|
1104
1580
|
// Build session index in background (powers search + smart resume)
|
|
1105
1581
|
try {
|
|
@@ -1107,383 +1583,1124 @@ async function mainScreen(rl, ask) {
|
|
|
1107
1583
|
buildSessionIndex(cwd);
|
|
1108
1584
|
} catch {}
|
|
1109
1585
|
|
|
1110
|
-
|
|
1586
|
+
// Gather recent sessions
|
|
1587
|
+
const allSessions = enrichSessions(importReplitSessions(cwd), cwd);
|
|
1588
|
+
const recentSessions = allSessions.slice(0, 3);
|
|
1589
|
+
const staleCount = allSessions.filter(s => {
|
|
1590
|
+
const ageMs = s.lastActive ? Date.now() - new Date(s.lastActive).getTime() : 0;
|
|
1591
|
+
return ageMs >= 7 * 86400000;
|
|
1592
|
+
}).length;
|
|
1111
1593
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
const pin = sess.pinned ? '📌 ' : ' ';
|
|
1116
|
-
const active = sess.isActive ? ' ●' : '';
|
|
1117
|
-
const cat = sess.category ? ` [${sess.category}]` : '';
|
|
1118
|
-
const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
|
|
1119
|
-
// If the name is still the "Session XXXXXXXX" fallback, try the project path instead
|
|
1120
|
-
let rawName = sess.name || '';
|
|
1121
|
-
if (/^Session [0-9a-f]{8,}$/i.test(rawName)) {
|
|
1122
|
-
rawName = sess.project ? sess.project.replace(/^-/, '/').replace(/-/g, '/') : sess.id.slice(0, 8);
|
|
1123
|
-
}
|
|
1124
|
-
const displayName = rawName.length > 40 ? rawName.slice(0, 37) + '...' : (rawName || sess.id.slice(0, 8));
|
|
1125
|
-
console.log(` [${i + 1}] ${pin}${tool} ${sess.age.padEnd(8)} ${displayName}${active}${cat}`);
|
|
1126
|
-
});
|
|
1127
|
-
console.log('');
|
|
1128
|
-
}
|
|
1594
|
+
// Detect data-tools version
|
|
1595
|
+
const rtMain = detectReplitTools(cwd);
|
|
1596
|
+
const dtVersion = (rtMain.installed && rtMain.version) ? rtMain.version : null;
|
|
1129
1597
|
|
|
1130
|
-
|
|
1131
|
-
const
|
|
1132
|
-
const brandBottom = ` └${'─'.repeat(brandW)}┘`;
|
|
1133
|
-
const brandPad = (s) => {
|
|
1134
|
-
const leftPad = Math.floor((brandW - s.length) / 2);
|
|
1135
|
-
const rightPad = brandW - s.length - leftPad;
|
|
1136
|
-
return ' '.repeat(leftPad) + s + ' '.repeat(rightPad);
|
|
1137
|
-
};
|
|
1138
|
-
console.log(brandTop);
|
|
1139
|
-
console.log(` │ ${brandPad('Dual Brain Session Manager')}│`);
|
|
1140
|
-
console.log(` │ ${brandPad('Built on data-tools by Steve Moraco')}│`);
|
|
1141
|
-
console.log(brandBottom);
|
|
1142
|
-
console.log('');
|
|
1598
|
+
// ── Interrupted work detection ────────────────────────────────────────────
|
|
1599
|
+
const interrupted = detectInterruptedWork(allSessions, cwd);
|
|
1143
1600
|
|
|
1144
|
-
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
if (runningParts.length > 0) {
|
|
1149
|
-
console.log(` (${runningParts.join(', ')} running)`);
|
|
1150
|
-
console.log('');
|
|
1151
|
-
}
|
|
1601
|
+
// ── Box layout ────────────────────────────────────────────────────────────
|
|
1602
|
+
const termW = process.stdout.columns || 60;
|
|
1603
|
+
const boxW = Math.min(termW - 2, 60); // outer width (including │ │)
|
|
1604
|
+
const W = boxW - 4; // inner content width (│ {content} │)
|
|
1152
1605
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
if (recentSessions.length > 0) {
|
|
1157
|
-
console.log(' [1-9] Resume numbered above');
|
|
1158
|
-
}
|
|
1159
|
-
console.log(' [r] Resume (full list)');
|
|
1160
|
-
console.log(' [/] Search sessions');
|
|
1161
|
-
console.log(' [e] Manage sessions');
|
|
1162
|
-
console.log(' [m] Manage subscriptions');
|
|
1163
|
-
console.log(' [s] Settings');
|
|
1164
|
-
console.log(' [?] Help & shortcuts');
|
|
1165
|
-
console.log('');
|
|
1166
|
-
console.log(' \x1b[2mreplit-tools:\x1b[0m');
|
|
1167
|
-
console.log(' [i] Import sessions');
|
|
1168
|
-
console.log(' [d] Switch to data-tools');
|
|
1169
|
-
console.log('');
|
|
1170
|
-
console.log(' [q] Exit');
|
|
1171
|
-
console.log('');
|
|
1606
|
+
const top = `┌${'─'.repeat(boxW - 2)}┐`;
|
|
1607
|
+
const sep = `├${'─'.repeat(boxW - 2)}┤`;
|
|
1608
|
+
const bot = `└${'─'.repeat(boxW - 2)}┘`;
|
|
1172
1609
|
|
|
1173
|
-
const
|
|
1610
|
+
const row = (content) => makeBoxRow(content, W);
|
|
1174
1611
|
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
console.log('');
|
|
1193
|
-
await ask(' Press Enter to continue...');
|
|
1194
|
-
return { next: 'main' };
|
|
1612
|
+
// ── Header: one line above the box ────────────────────────────────────────
|
|
1613
|
+
process.stdout.write(`\n🧠 dual-brain v${version}\n`);
|
|
1614
|
+
{
|
|
1615
|
+
let gitName = '';
|
|
1616
|
+
try {
|
|
1617
|
+
const { execSync } = await import('node:child_process');
|
|
1618
|
+
gitName = execSync('git config user.name', { encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
|
|
1619
|
+
} catch { /* ignore */ }
|
|
1620
|
+
if (gitName) {
|
|
1621
|
+
const hour = new Date().getHours();
|
|
1622
|
+
let greet;
|
|
1623
|
+
if (hour >= 5 && hour <= 11) greet = 'Good morning';
|
|
1624
|
+
else if (hour >= 12 && hour <= 16) greet = 'Good afternoon';
|
|
1625
|
+
else if (hour >= 17 && hour <= 21) greet = 'Good evening';
|
|
1626
|
+
else greet = 'Late night';
|
|
1627
|
+
process.stdout.write(`\x1b[2m${greet}, ${gitName}\x1b[0m\n`);
|
|
1628
|
+
}
|
|
1195
1629
|
}
|
|
1196
1630
|
|
|
1197
|
-
|
|
1631
|
+
// ── Continuation card (interrupted work) ─────────────────────────────────
|
|
1632
|
+
if (interrupted) {
|
|
1633
|
+
const ctop = `┌${'─'.repeat(boxW - 2)}┐`;
|
|
1634
|
+
const csep = `├${'─'.repeat(boxW - 2)}┤`;
|
|
1635
|
+
const cbot = `└${'─'.repeat(boxW - 2)}┘`;
|
|
1636
|
+
const crow = (content) => makeBoxRow(content, W);
|
|
1637
|
+
|
|
1638
|
+
const titleLine = `\x1b[33m💡\x1b[0m Continue: ${interrupted.sessionName}`;
|
|
1639
|
+
const lastLine = interrupted.lastState
|
|
1640
|
+
? ` Last: ${interrupted.lastState} · ${interrupted.ageLabel}`
|
|
1641
|
+
: ` ${interrupted.reason} · ${interrupted.ageLabel}`;
|
|
1642
|
+
const actLine = ' [Enter] Resume [n] New session [s] Skip';
|
|
1643
|
+
|
|
1644
|
+
process.stdout.write([ctop, crow(titleLine), csep, crow(lastLine), crow(actLine), cbot].join('\n') + '\n\n');
|
|
1645
|
+
|
|
1646
|
+
// Wait for a keypress to decide what to do with the card
|
|
1647
|
+
const readline2 = await import('node:readline');
|
|
1648
|
+
readline2.emitKeypressEvents(process.stdin, rl);
|
|
1649
|
+
|
|
1650
|
+
const cardChoice = await new Promise((resolve) => {
|
|
1651
|
+
const wasRaw2 = process.stdin.isRaw;
|
|
1652
|
+
const canRaw2 = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
|
|
1653
|
+
if (canRaw2) process.stdin.setRawMode(true);
|
|
1654
|
+
|
|
1655
|
+
const cleanup2 = () => {
|
|
1656
|
+
process.stdin.removeListener('keypress', onCardKey);
|
|
1657
|
+
if (canRaw2) {
|
|
1658
|
+
try { process.stdin.setRawMode(wasRaw2 || false); } catch {}
|
|
1659
|
+
}
|
|
1660
|
+
};
|
|
1198
1661
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1662
|
+
const onCardKey = (str, key) => {
|
|
1663
|
+
if (!key) return;
|
|
1664
|
+
const name = key.name || '';
|
|
1665
|
+
const seq = key.sequence || str || '';
|
|
1666
|
+
|
|
1667
|
+
if (key.ctrl && (name === 'c' || name === 'd')) {
|
|
1668
|
+
cleanup2();
|
|
1669
|
+
process.stdout.write('\n');
|
|
1670
|
+
resolve('q');
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1203
1673
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1674
|
+
if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
|
|
1675
|
+
cleanup2();
|
|
1676
|
+
process.stdout.write('\n');
|
|
1677
|
+
resolve('resume');
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1206
1680
|
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1681
|
+
if (!str || str.length === 0) return;
|
|
1682
|
+
const lower = str.toLowerCase();
|
|
1683
|
+
if (lower === 'n' || lower === 's' || lower === 'q') {
|
|
1684
|
+
cleanup2();
|
|
1685
|
+
process.stdout.write('\n');
|
|
1686
|
+
resolve(lower);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
};
|
|
1690
|
+
|
|
1691
|
+
process.stdin.on('keypress', onCardKey);
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
if (cardChoice === 'q') return { next: 'exit' };
|
|
1695
|
+
|
|
1696
|
+
if (cardChoice === 'resume') {
|
|
1697
|
+
const { spawnSync } = await import('node:child_process');
|
|
1698
|
+
process.stdout.write(` Launching: claude --resume ${interrupted.sessionId}\n\n`);
|
|
1699
|
+
spawnSync('claude', ['--resume', interrupted.sessionId], { stdio: 'inherit' });
|
|
1700
|
+
saveTerminalState(cwd, getTerminalId(), interrupted.sessionId, 'claude');
|
|
1210
1701
|
return { next: 'main' };
|
|
1211
1702
|
}
|
|
1212
1703
|
|
|
1213
|
-
|
|
1704
|
+
if (cardChoice === 'n') return { next: 'new-session' };
|
|
1705
|
+
|
|
1706
|
+
// 's' → fall through to normal dashboard
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// ── Status section ────────────────────────────────────────────────────────
|
|
1710
|
+
const providerLine = buildProviderStatusLine(profile, auth, W);
|
|
1711
|
+
|
|
1712
|
+
const statusRows = [row(providerLine)];
|
|
1713
|
+
if (dtVersion) {
|
|
1714
|
+
statusRows.push(row(`\x1b[2m📦 replit-tools v${dtVersion}\x1b[0m`));
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// ── Observer observations (top 2, high priority first) ───────────────────
|
|
1718
|
+
let quickObservations = [];
|
|
1719
|
+
try {
|
|
1720
|
+
const observerMod = await import('../src/observer.mjs');
|
|
1721
|
+
const quickState = await observerMod.getQuickState(cwd);
|
|
1722
|
+
if (quickState?.observations?.length > 0) {
|
|
1723
|
+
const PRIO = { high: 0, medium: 1, low: 2 };
|
|
1724
|
+
const sorted = [...quickState.observations].sort(
|
|
1725
|
+
(a, b) => (PRIO[a.priority] ?? 2) - (PRIO[b.priority] ?? 2)
|
|
1726
|
+
);
|
|
1727
|
+
quickObservations = sorted.slice(0, 2);
|
|
1728
|
+
for (const obs of quickObservations) {
|
|
1729
|
+
let prefix;
|
|
1730
|
+
if (obs.priority === 'high') prefix = '🔴';
|
|
1731
|
+
else if (obs.priority === 'medium') prefix = '🟡';
|
|
1732
|
+
else prefix = '\x1b[2m💡\x1b[0m';
|
|
1733
|
+
statusRows.push(row(`${prefix} ${obs.message}`));
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
} catch { /* non-fatal — module may not exist yet */ }
|
|
1737
|
+
|
|
1738
|
+
// ── Action cards (git state + open PRs) ──────────────────────────────────
|
|
1739
|
+
const repoState = detectRepoState(cwd);
|
|
1740
|
+
const openPRs = await detectOpenPRs(cwd);
|
|
1741
|
+
const actionRows = buildActionRows(repoState, row, openPRs);
|
|
1742
|
+
|
|
1743
|
+
// ── High-priority observer action cards ───────────────────────────────────
|
|
1744
|
+
if (quickObservations.some(o => o.priority === 'high')) {
|
|
1745
|
+
const DIM = '\x1b[2m';
|
|
1746
|
+
const RESET = '\x1b[0m';
|
|
1747
|
+
actionRows.push(row(`${DIM}[r] Security review [t] Run tests [c] Commit${RESET}`));
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// ── Related sessions hint (only when no continuation card is showing) ─────
|
|
1751
|
+
if (!interrupted && recentSessions.length > 0) {
|
|
1214
1752
|
try {
|
|
1215
|
-
const {
|
|
1216
|
-
const
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1753
|
+
const { findRelatedSessions } = await import('../src/session.mjs');
|
|
1754
|
+
const mostRecent = recentSessions[0];
|
|
1755
|
+
// Build a pseudo-prompt from the most recent session's name/objective
|
|
1756
|
+
const recentPrompt = mostRecent.name || '';
|
|
1757
|
+
// Load session index to get files for the most recent session
|
|
1758
|
+
const indexPath = join(cwd, '.dualbrain', 'session-index.json');
|
|
1759
|
+
let recentFiles = [];
|
|
1760
|
+
try {
|
|
1761
|
+
const idx = JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
1762
|
+
recentFiles = idx[mostRecent.id]?.files || [];
|
|
1763
|
+
} catch {}
|
|
1764
|
+
const related = findRelatedSessions(recentPrompt, recentFiles, cwd);
|
|
1765
|
+
if (related.length > 0) {
|
|
1766
|
+
const relAgeLabel = (isoDate) => {
|
|
1767
|
+
if (!isoDate) return '';
|
|
1768
|
+
const diff = Date.now() - Date.parse(isoDate);
|
|
1769
|
+
const days = Math.floor(diff / 86400000);
|
|
1770
|
+
const hours = Math.floor(diff / 3600000);
|
|
1771
|
+
if (days >= 1) return `${days}d`;
|
|
1772
|
+
return `${hours}h ago`;
|
|
1773
|
+
};
|
|
1774
|
+
const relatedParts = related.slice(0, 2).map(r => {
|
|
1775
|
+
const age = relAgeLabel(r.date);
|
|
1776
|
+
return age ? `${r.smartName} (${age})` : r.smartName;
|
|
1777
|
+
});
|
|
1778
|
+
const DIM = '\x1b[2m';
|
|
1779
|
+
const RESET = '\x1b[0m';
|
|
1780
|
+
actionRows.push(row(`${DIM}📎 Related: ${relatedParts.join(', ')}${RESET}`));
|
|
1221
1781
|
}
|
|
1222
|
-
} catch {}
|
|
1782
|
+
} catch { /* non-fatal */ }
|
|
1783
|
+
}
|
|
1784
|
+
// ── End related sessions hint ─────────────────────────────────────────────
|
|
1223
1785
|
|
|
1786
|
+
// ── Sessions section ──────────────────────────────────────────────────────
|
|
1787
|
+
const sessionRows = [];
|
|
1788
|
+
if (recentSessions.length === 0) {
|
|
1789
|
+
const noSessMsg = 'No sessions yet. Press n to start.';
|
|
1790
|
+
sessionRows.push(row(noSessMsg));
|
|
1791
|
+
} else {
|
|
1792
|
+
recentSessions.forEach((sess, i) => {
|
|
1793
|
+
// Normalize name: strip "Session XXXXXXXX" fallbacks
|
|
1794
|
+
let rawName = sess.name || '';
|
|
1795
|
+
if (/^Session [0-9a-f]{8,}$/i.test(rawName)) {
|
|
1796
|
+
rawName = sess.project
|
|
1797
|
+
? sess.project.replace(/^-/, '/').replace(/-/g, '/')
|
|
1798
|
+
: sess.id.slice(0, 8);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
// Build badges (ANSI color; track visible width separately)
|
|
1802
|
+
const badges = [];
|
|
1803
|
+
const badgeVisible = [];
|
|
1804
|
+
if (sess.isActive) {
|
|
1805
|
+
badges.push('\x1b[32m[active]\x1b[0m');
|
|
1806
|
+
badgeVisible.push('[active]'.length);
|
|
1807
|
+
}
|
|
1808
|
+
const ageMs = sess.lastActive ? Date.now() - new Date(sess.lastActive).getTime() : 0;
|
|
1809
|
+
if (ageMs > 7 * 24 * 3600 * 1000) {
|
|
1810
|
+
badges.push('\x1b[2m[stale]\x1b[0m');
|
|
1811
|
+
badgeVisible.push('[stale]'.length);
|
|
1812
|
+
}
|
|
1813
|
+
const msgCount = sess.messageCount ?? sess.promptCount ?? 0;
|
|
1814
|
+
// Human-readable: "4 tasks" instead of "(4)"
|
|
1815
|
+
const taskLabel = msgCount === 1 ? '1 task' : `${msgCount} tasks`;
|
|
1816
|
+
const taskBadge = `\x1b[2m${taskLabel}\x1b[0m`;
|
|
1817
|
+
const taskBadgeW = taskLabel.length;
|
|
1818
|
+
|
|
1819
|
+
const badgeStr = badges.join('');
|
|
1820
|
+
const badgesW = badgeVisible.reduce((s, n) => s + n, 0);
|
|
1821
|
+
|
|
1822
|
+
// Layout: "{num} {name...}{badges} {age} {tasks}"
|
|
1823
|
+
// Use basename for name — strip full paths for readability
|
|
1824
|
+
const displayName = rawName.startsWith('/')
|
|
1825
|
+
? rawName.split('/').filter(Boolean).pop() || rawName
|
|
1826
|
+
: rawName;
|
|
1827
|
+
|
|
1828
|
+
const numStr = String(i + 1);
|
|
1829
|
+
const ageStr = sess.age || '';
|
|
1830
|
+
// Available for name: W minus fixed chrome, badge widths, and task badge
|
|
1831
|
+
const nameMax = W - numStr.length - 2 - badgesW - 2 - ageStr.length - 2 - taskBadgeW;
|
|
1832
|
+
const truncName = displayName.length > nameMax
|
|
1833
|
+
? displayName.slice(0, Math.max(0, nameMax - 3)) + '...'
|
|
1834
|
+
: displayName.padEnd(nameMax);
|
|
1835
|
+
const content = `${numStr} ${truncName}${badgeStr} ${ageStr} ${taskBadge}`;
|
|
1836
|
+
sessionRows.push(row(content));
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// ── Actions bar — navigation only (pipeline verbs are internal stages, not menu items) ─
|
|
1841
|
+
const actionsContent = 'n New session / Search q Quit';
|
|
1842
|
+
const actionsRow = row(actionsContent);
|
|
1843
|
+
|
|
1844
|
+
// ── Print the full box ────────────────────────────────────────────────────
|
|
1845
|
+
// Include action cards between status and sessions (with separators only when non-empty)
|
|
1846
|
+
const poweredByRow = row('\x1b[2mPowered by dual-brain\x1b[0m');
|
|
1847
|
+
const lines = [
|
|
1848
|
+
top,
|
|
1849
|
+
...statusRows,
|
|
1850
|
+
...(actionRows.length > 0 ? [sep, ...actionRows] : []),
|
|
1851
|
+
sep,
|
|
1852
|
+
...sessionRows,
|
|
1853
|
+
sep,
|
|
1854
|
+
actionsRow,
|
|
1855
|
+
sep,
|
|
1856
|
+
poweredByRow,
|
|
1857
|
+
bot,
|
|
1858
|
+
];
|
|
1859
|
+
// ── Stale session hint ──────────────────────────────────────────────────
|
|
1860
|
+
if (staleCount >= 3) {
|
|
1861
|
+
process.stdout.write(`\x1b[2m${staleCount} stale sessions (>7d) — press s → archive to clean up\x1b[0m\n`);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
process.stdout.write(lines.join('\n') + '\n\n');
|
|
1865
|
+
|
|
1866
|
+
// ── Key handling ──────────────────────────────────────────────────────────
|
|
1867
|
+
// Use raw keypress mode so we can show a live type-to-start buffer.
|
|
1868
|
+
// Single-key commands (n, s, q, /, 1-9, Enter) only fire when buffer is empty.
|
|
1869
|
+
let taskBuffer = '';
|
|
1870
|
+
|
|
1871
|
+
const readline = await import('node:readline');
|
|
1872
|
+
|
|
1873
|
+
// Render the type-ahead line below the box (overwrites the current cursor line)
|
|
1874
|
+
const renderBuffer = (buf) => {
|
|
1875
|
+
// Move to the prompt line (we're already at it after printing the box + footer)
|
|
1876
|
+
// Use carriage return + clear-to-end-of-line to overwrite
|
|
1877
|
+
if (buf.length === 0) {
|
|
1878
|
+
process.stdout.write('\r\x1b[K');
|
|
1879
|
+
} else {
|
|
1880
|
+
const display = buf.length > W - 4 ? buf.slice(-(W - 4)) : buf;
|
|
1881
|
+
process.stdout.write(`\r\x1b[K> ${display}\x1b[7m \x1b[0m`);
|
|
1882
|
+
}
|
|
1883
|
+
};
|
|
1884
|
+
|
|
1885
|
+
// Enable keypress events on stdin (safe to call multiple times)
|
|
1886
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
1887
|
+
|
|
1888
|
+
const raw = await new Promise((resolve) => {
|
|
1889
|
+
// Switch to raw mode if possible (TTY only)
|
|
1890
|
+
const wasRaw = process.stdin.isRaw;
|
|
1891
|
+
const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
|
|
1892
|
+
if (canRaw) process.stdin.setRawMode(true);
|
|
1893
|
+
|
|
1894
|
+
const cleanup = () => {
|
|
1895
|
+
process.stdin.removeListener('keypress', onKey);
|
|
1896
|
+
if (canRaw) {
|
|
1897
|
+
try { process.stdin.setRawMode(wasRaw || false); } catch {}
|
|
1898
|
+
}
|
|
1899
|
+
};
|
|
1900
|
+
|
|
1901
|
+
const onKey = (str, key) => {
|
|
1902
|
+
if (!key) return;
|
|
1903
|
+
|
|
1904
|
+
const name = key.name || '';
|
|
1905
|
+
const seq = key.sequence || str || '';
|
|
1906
|
+
|
|
1907
|
+
// Ctrl-C / Ctrl-D → exit
|
|
1908
|
+
if (key.ctrl && (name === 'c' || name === 'd')) {
|
|
1909
|
+
cleanup();
|
|
1910
|
+
process.stdout.write('\n');
|
|
1911
|
+
resolve('q');
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// Enter key
|
|
1916
|
+
if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
|
|
1917
|
+
cleanup();
|
|
1918
|
+
if (taskBuffer.length > 0) {
|
|
1919
|
+
process.stdout.write('\n');
|
|
1920
|
+
resolve(`__task__:${taskBuffer}`);
|
|
1921
|
+
} else {
|
|
1922
|
+
resolve('');
|
|
1923
|
+
}
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
// Escape → clear buffer
|
|
1928
|
+
if (name === 'escape') {
|
|
1929
|
+
taskBuffer = '';
|
|
1930
|
+
renderBuffer('');
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
// Backspace / delete
|
|
1935
|
+
if (name === 'backspace' || name === 'delete') {
|
|
1936
|
+
if (taskBuffer.length > 0) {
|
|
1937
|
+
taskBuffer = taskBuffer.slice(0, -1);
|
|
1938
|
+
renderBuffer(taskBuffer);
|
|
1939
|
+
}
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// Ignore non-printable / control keys
|
|
1944
|
+
if (key.ctrl || key.meta || !str || str.length === 0) return;
|
|
1945
|
+
const code = str.codePointAt(0);
|
|
1946
|
+
if (code < 32 || code === 127) return;
|
|
1947
|
+
|
|
1948
|
+
// Single-key commands only fire when buffer is empty
|
|
1949
|
+
if (taskBuffer.length === 0) {
|
|
1950
|
+
const lower = str.toLowerCase();
|
|
1951
|
+
const singleKeySet = new Set(['n', 's', 'q', '/', 'i']);
|
|
1952
|
+
if (singleKeySet.has(lower)) {
|
|
1953
|
+
cleanup();
|
|
1954
|
+
process.stdout.write('\n');
|
|
1955
|
+
resolve(lower);
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
const digit = parseInt(str, 10);
|
|
1959
|
+
if (!isNaN(digit) && digit >= 1 && digit <= 9) {
|
|
1960
|
+
cleanup();
|
|
1961
|
+
process.stdout.write('\n');
|
|
1962
|
+
resolve(str);
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// Accumulate into buffer
|
|
1968
|
+
taskBuffer += str;
|
|
1969
|
+
renderBuffer(taskBuffer);
|
|
1970
|
+
};
|
|
1971
|
+
|
|
1972
|
+
process.stdin.on('keypress', onKey);
|
|
1973
|
+
});
|
|
1974
|
+
|
|
1975
|
+
const choice = typeof raw === 'string' ? raw.toLowerCase() : '';
|
|
1976
|
+
|
|
1977
|
+
// Typed task → dispatch as "dual-brain go"
|
|
1978
|
+
if (raw.startsWith('__task__:')) {
|
|
1979
|
+
const prompt = raw.slice('__task__:'.length).trim();
|
|
1980
|
+
if (prompt) {
|
|
1981
|
+
return { next: 'go', prompt };
|
|
1982
|
+
}
|
|
1983
|
+
return { next: 'main' };
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// Enter (empty) → resume most recent session
|
|
1987
|
+
if (raw === '' || choice === '\r') {
|
|
1988
|
+
if (recentSessions.length === 0) {
|
|
1989
|
+
return { next: 'new-session' };
|
|
1990
|
+
}
|
|
1991
|
+
const sess = recentSessions[0];
|
|
1224
1992
|
const { spawnSync } = await import('node:child_process');
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
saveTerminalState(cwd, termId, targetId, tool);
|
|
1993
|
+
process.stdout.write(`\n Launching: claude --resume ${sess.id}\n\n`);
|
|
1994
|
+
spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
|
|
1995
|
+
saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
|
|
1229
1996
|
return { next: 'main' };
|
|
1230
1997
|
}
|
|
1231
1998
|
|
|
1232
|
-
|
|
1999
|
+
// Number 1-3 → resume that session
|
|
2000
|
+
const numChoice = parseInt(raw, 10);
|
|
1233
2001
|
if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= recentSessions.length) {
|
|
1234
2002
|
const sess = recentSessions[numChoice - 1];
|
|
1235
|
-
|
|
1236
|
-
// Smart resume preview
|
|
1237
2003
|
try {
|
|
1238
2004
|
const { getSessionContext } = await import('../src/session.mjs');
|
|
1239
2005
|
const ctx = getSessionContext(sess.id, cwd);
|
|
1240
2006
|
if (ctx) {
|
|
1241
|
-
|
|
1242
|
-
if (ctx.
|
|
1243
|
-
if (ctx.filesTouched.length > 0) console.log(` Files touched: ${ctx.filesTouched.join(', ')}`);
|
|
2007
|
+
if (ctx.lastPrompt) process.stdout.write(`\n Last working on: ${ctx.lastPrompt}\n`);
|
|
2008
|
+
if (ctx.filesTouched.length > 0) process.stdout.write(` Files touched: ${ctx.filesTouched.join(', ')}\n`);
|
|
1244
2009
|
}
|
|
1245
2010
|
} catch {}
|
|
1246
|
-
|
|
1247
2011
|
const { spawnSync } = await import('node:child_process');
|
|
1248
|
-
|
|
2012
|
+
process.stdout.write(`\n Launching: claude --resume ${sess.id}\n\n`);
|
|
1249
2013
|
spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
|
|
1250
2014
|
saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
|
|
1251
2015
|
return { next: 'main' };
|
|
1252
2016
|
}
|
|
1253
2017
|
|
|
1254
|
-
if (choice === '
|
|
1255
|
-
const allSessions = enrichSessions(importReplitSessions(cwd), cwd);
|
|
1256
|
-
if (allSessions.length === 0) {
|
|
1257
|
-
console.log('\n No sessions found.\n');
|
|
1258
|
-
await ask(' Press Enter to continue...');
|
|
1259
|
-
return { next: 'main' };
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
console.log('\n All Sessions:');
|
|
1263
|
-
allSessions.forEach((sess, i) => {
|
|
1264
|
-
const pin = sess.pinned ? '📌 ' : ' ';
|
|
1265
|
-
const active = sess.isActive ? ' ●' : '';
|
|
1266
|
-
const cat = sess.category ? ` [${sess.category}]` : '';
|
|
1267
|
-
const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
|
|
1268
|
-
console.log(` [${String(i + 1).padStart(2)}] ${pin}${tool} ${sess.age.padEnd(8)} ${sess.name}${active}${cat}`);
|
|
1269
|
-
});
|
|
1270
|
-
console.log('');
|
|
1271
|
-
|
|
1272
|
-
const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
|
|
1273
|
-
const num = parseInt(pick, 10);
|
|
1274
|
-
if (!isNaN(num) && num >= 1 && num <= allSessions.length) {
|
|
1275
|
-
const sess = allSessions[num - 1];
|
|
1276
|
-
const { spawnSync } = await import('node:child_process');
|
|
1277
|
-
const tool = sess.tool === 'codex' ? 'codex' : 'claude';
|
|
1278
|
-
console.log(`\n Launching: ${tool} --resume ${sess.id}\n`);
|
|
1279
|
-
spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
|
|
1280
|
-
}
|
|
1281
|
-
return { next: 'main' };
|
|
1282
|
-
}
|
|
2018
|
+
if (choice === 'n') { return { next: 'new-session' }; }
|
|
1283
2019
|
|
|
1284
2020
|
if (choice === '/') {
|
|
1285
2021
|
const query = (await ask(' Search: ')).trim();
|
|
1286
2022
|
if (!query) return { next: 'main' };
|
|
1287
2023
|
|
|
1288
2024
|
const { searchSessions, buildSessionIndex } = await import('../src/session.mjs');
|
|
1289
|
-
// Build index if needed (silent)
|
|
1290
2025
|
try { buildSessionIndex(cwd); } catch {}
|
|
1291
2026
|
|
|
1292
2027
|
const results = searchSessions(query, cwd);
|
|
1293
2028
|
if (results.length === 0) {
|
|
1294
|
-
|
|
2029
|
+
process.stdout.write(`\n No sessions matching "${query}"\n\n`);
|
|
1295
2030
|
await ask(' Press Enter to continue...');
|
|
1296
2031
|
return { next: 'main' };
|
|
1297
2032
|
}
|
|
1298
2033
|
|
|
1299
|
-
|
|
2034
|
+
process.stdout.write(`\n Found ${results.length} session${results.length === 1 ? '' : 's'}:\n`);
|
|
1300
2035
|
results.slice(0, 9).forEach((sess, i) => {
|
|
1301
|
-
const tool
|
|
1302
|
-
const date
|
|
2036
|
+
const tool = sess.tool === 'codex' ? 'cdx' : 'cld';
|
|
2037
|
+
const date = sess.date ? new Date(sess.date).toLocaleDateString() : '?';
|
|
1303
2038
|
const topics = sess.topics.slice(0, 3).join(', ');
|
|
1304
|
-
|
|
1305
|
-
if (topics)
|
|
2039
|
+
process.stdout.write(` [${i + 1}] ${tool} ${date} ${sess.prompts.first || sess.id.slice(0, 8)}\n`);
|
|
2040
|
+
if (topics) process.stdout.write(` topics: ${topics}\n`);
|
|
1306
2041
|
});
|
|
1307
|
-
|
|
2042
|
+
process.stdout.write('\n');
|
|
1308
2043
|
|
|
1309
2044
|
const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
|
|
1310
|
-
const num
|
|
2045
|
+
const num = parseInt(pick, 10);
|
|
1311
2046
|
if (!isNaN(num) && num >= 1 && num <= Math.min(results.length, 9)) {
|
|
1312
2047
|
const sess = results[num - 1];
|
|
1313
2048
|
const { spawnSync } = await import('node:child_process');
|
|
1314
2049
|
const tool = sess.tool === 'codex' ? 'codex' : 'claude';
|
|
1315
|
-
|
|
2050
|
+
process.stdout.write(`\n Launching: ${tool} --resume ${sess.id}\n\n`);
|
|
1316
2051
|
spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
|
|
1317
2052
|
}
|
|
1318
2053
|
return { next: 'main' };
|
|
1319
2054
|
}
|
|
1320
2055
|
|
|
1321
|
-
if (choice === '
|
|
2056
|
+
if (choice === 's') { return { next: 'settings' }; }
|
|
2057
|
+
if (choice === 'i') { return { next: 'import-picker' }; }
|
|
2058
|
+
if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
|
|
1322
2059
|
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
2060
|
+
return { next: 'main' };
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// ─── Screen: newSessionScreen ─────────────────────────────────────────────────
|
|
2064
|
+
|
|
2065
|
+
async function newSessionScreen(rl, ask) {
|
|
2066
|
+
const cwd = process.cwd();
|
|
2067
|
+
const input = (await ask('\n What do you want to do? ')).trim();
|
|
2068
|
+
if (!input) { return { next: 'main' }; }
|
|
2069
|
+
|
|
2070
|
+
// All work routes through pipeline — detect → decide → dispatch with mandatory gates.
|
|
2071
|
+
await cmdGo([input], { cwd });
|
|
2072
|
+
|
|
2073
|
+
return { next: 'main' };
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// ─── Screen: importPickerScreen ──────────────────────────────────────────────
|
|
2077
|
+
|
|
2078
|
+
async function importPickerScreen() {
|
|
2079
|
+
const cwd = process.cwd();
|
|
2080
|
+
|
|
2081
|
+
// Load all available sessions from data-tools
|
|
2082
|
+
const allSessions = importReplitSessions(cwd);
|
|
2083
|
+
|
|
2084
|
+
// Load existing session meta to filter already-imported ones
|
|
2085
|
+
const meta = getSessionMeta(cwd);
|
|
2086
|
+
const alreadyImported = new Set(
|
|
2087
|
+
Object.entries(meta)
|
|
2088
|
+
.filter(([, v]) => v.source === 'data-tools')
|
|
2089
|
+
.map(([id]) => id)
|
|
2090
|
+
);
|
|
2091
|
+
|
|
2092
|
+
// Filter out already-imported sessions
|
|
2093
|
+
const candidates = allSessions.filter(s => !alreadyImported.has(s.id));
|
|
2094
|
+
|
|
2095
|
+
// ── Box layout ────────────────────────────────────────────────────────────
|
|
2096
|
+
const termW = process.stdout.columns || 60;
|
|
2097
|
+
const boxW = Math.min(termW - 2, 60);
|
|
2098
|
+
const W = boxW - 4;
|
|
2099
|
+
|
|
2100
|
+
const top = `┌${'─'.repeat(boxW - 2)}┐`;
|
|
2101
|
+
const sep = `├${'─'.repeat(boxW - 2)}┤`;
|
|
2102
|
+
const bot = `└${'─'.repeat(boxW - 2)}┘`;
|
|
2103
|
+
|
|
2104
|
+
const row = (content) => makeBoxRow(content, W);
|
|
2105
|
+
|
|
2106
|
+
// Helper: wait for any keypress (used in edge-case screens)
|
|
2107
|
+
const waitKey = async () => {
|
|
2108
|
+
const rl2 = await import('node:readline');
|
|
2109
|
+
rl2.emitKeypressEvents(process.stdin);
|
|
2110
|
+
await new Promise(resolve => {
|
|
2111
|
+
const wasRaw2 = process.stdin.isRaw;
|
|
2112
|
+
const canRaw2 = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
|
|
2113
|
+
if (canRaw2) process.stdin.setRawMode(true);
|
|
2114
|
+
const onKey2 = () => {
|
|
2115
|
+
process.stdin.removeListener('keypress', onKey2);
|
|
2116
|
+
if (canRaw2) { try { process.stdin.setRawMode(wasRaw2 || false); } catch {} }
|
|
2117
|
+
resolve();
|
|
2118
|
+
};
|
|
2119
|
+
process.stdin.once('keypress', onKey2);
|
|
2120
|
+
});
|
|
2121
|
+
};
|
|
2122
|
+
|
|
2123
|
+
// Handle edge cases
|
|
2124
|
+
if (allSessions.length === 0) {
|
|
2125
|
+
process.stdout.write('\n');
|
|
2126
|
+
process.stdout.write(top + '\n');
|
|
2127
|
+
process.stdout.write(row('Import from data-tools') + '\n');
|
|
2128
|
+
process.stdout.write(sep + '\n');
|
|
2129
|
+
process.stdout.write(row('No data-tools sessions found.') + '\n');
|
|
2130
|
+
process.stdout.write(row('Install replit-tools: npm i -g replit-tools') + '\n');
|
|
2131
|
+
process.stdout.write(sep + '\n');
|
|
2132
|
+
process.stdout.write(row('Press any key to go back...') + '\n');
|
|
2133
|
+
process.stdout.write(bot + '\n\n');
|
|
2134
|
+
await waitKey();
|
|
1332
2135
|
return { next: 'main' };
|
|
1333
2136
|
}
|
|
1334
2137
|
|
|
1335
|
-
if (
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
2138
|
+
if (candidates.length === 0) {
|
|
2139
|
+
process.stdout.write('\n');
|
|
2140
|
+
process.stdout.write(top + '\n');
|
|
2141
|
+
process.stdout.write(row('Import from data-tools') + '\n');
|
|
2142
|
+
process.stdout.write(sep + '\n');
|
|
2143
|
+
process.stdout.write(row(`All ${allSessions.length} sessions already imported.`) + '\n');
|
|
2144
|
+
process.stdout.write(sep + '\n');
|
|
2145
|
+
process.stdout.write(row('Press any key to go back...') + '\n');
|
|
2146
|
+
process.stdout.write(bot + '\n\n');
|
|
2147
|
+
await waitKey();
|
|
1344
2148
|
return { next: 'main' };
|
|
1345
2149
|
}
|
|
1346
2150
|
|
|
1347
|
-
|
|
2151
|
+
// Pre-select sessions < 3 days old
|
|
2152
|
+
const threeDaysMs = 3 * 24 * 60 * 60 * 1000;
|
|
2153
|
+
const selected = new Set(
|
|
2154
|
+
candidates
|
|
2155
|
+
.filter(s => s.lastActive && (Date.now() - new Date(s.lastActive).getTime()) < threeDaysMs)
|
|
2156
|
+
.map(s => s.id)
|
|
2157
|
+
);
|
|
1348
2158
|
|
|
1349
|
-
|
|
1350
|
-
|
|
2159
|
+
let cursor = 0;
|
|
2160
|
+
|
|
2161
|
+
const renderPicker = () => {
|
|
2162
|
+
process.stdout.write('\x1b[2J\x1b[H'); // clear screen
|
|
2163
|
+
|
|
2164
|
+
const headerTitle = 'Import from data-tools';
|
|
2165
|
+
const footerLine = '↑↓ Navigate Space Toggle Enter Import q Back';
|
|
2166
|
+
|
|
2167
|
+
process.stdout.write('\n');
|
|
2168
|
+
process.stdout.write(top + '\n');
|
|
2169
|
+
process.stdout.write(row(headerTitle) + '\n');
|
|
2170
|
+
process.stdout.write(sep + '\n');
|
|
2171
|
+
|
|
2172
|
+
candidates.forEach((sess, i) => {
|
|
2173
|
+
const isCursor = i === cursor;
|
|
2174
|
+
const isSelected = selected.has(sess.id);
|
|
2175
|
+
const check = isSelected ? '☑' : '☐';
|
|
2176
|
+
const cursor_ch = isCursor ? '▸ ' : ' ';
|
|
2177
|
+
|
|
2178
|
+
// Format age compactly
|
|
2179
|
+
const ageStr = sess.age || '';
|
|
2180
|
+
// Message count
|
|
2181
|
+
const msgCount = sess.promptCount ?? sess.messageCount ?? 0;
|
|
2182
|
+
const msgStr = `${msgCount} msgs`;
|
|
2183
|
+
|
|
2184
|
+
// Name: truncate to fit
|
|
2185
|
+
// Layout: "cursor_ch(2) check(1) space(1) name age msgs"
|
|
2186
|
+
// chrome = 2 + 1 + 1 + 2 + ageStr.length + 2 + msgStr.length = 8 + ageStr.length + msgStr.length
|
|
2187
|
+
const chrome = 2 + 1 + 1 + 2 + ageStr.length + 2 + msgStr.length;
|
|
2188
|
+
const nameMax = Math.max(0, W - chrome);
|
|
2189
|
+
let name = sess.name || sess.id.slice(0, 8);
|
|
2190
|
+
if (name.length > nameMax) name = name.slice(0, nameMax - 3) + '...';
|
|
2191
|
+
else name = name.padEnd(nameMax);
|
|
2192
|
+
|
|
2193
|
+
const line = `${cursor_ch}${check} ${name} ${ageStr} ${msgStr}`;
|
|
2194
|
+
// Highlight cursor row with dim inverse
|
|
2195
|
+
const renderedLine = isCursor
|
|
2196
|
+
? `\x1b[7m${cursor_ch}${check} ${name} ${ageStr} ${msgStr}\x1b[0m`
|
|
2197
|
+
: line;
|
|
2198
|
+
process.stdout.write(row(renderedLine) + '\n');
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
process.stdout.write(sep + '\n');
|
|
2202
|
+
process.stdout.write(row(footerLine) + '\n');
|
|
2203
|
+
process.stdout.write(bot + '\n\n');
|
|
2204
|
+
};
|
|
2205
|
+
|
|
2206
|
+
// Run the interactive picker
|
|
2207
|
+
const readline = await import('node:readline');
|
|
2208
|
+
readline.emitKeypressEvents(process.stdin);
|
|
2209
|
+
|
|
2210
|
+
const result = await new Promise((resolve) => {
|
|
2211
|
+
const wasRaw = process.stdin.isRaw;
|
|
2212
|
+
const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
|
|
2213
|
+
if (canRaw) process.stdin.setRawMode(true);
|
|
2214
|
+
|
|
2215
|
+
const cleanup = () => {
|
|
2216
|
+
process.stdin.removeListener('keypress', onKey);
|
|
2217
|
+
if (canRaw) {
|
|
2218
|
+
try { process.stdin.setRawMode(wasRaw || false); } catch {}
|
|
2219
|
+
}
|
|
2220
|
+
};
|
|
2221
|
+
|
|
2222
|
+
renderPicker();
|
|
2223
|
+
|
|
2224
|
+
const onKey = (str, key) => {
|
|
2225
|
+
if (!key) return;
|
|
2226
|
+
const name = key.name || '';
|
|
2227
|
+
const seq = key.sequence || str || '';
|
|
2228
|
+
|
|
2229
|
+
// Ctrl-C / Ctrl-D → exit to main
|
|
2230
|
+
if (key.ctrl && (name === 'c' || name === 'd')) {
|
|
2231
|
+
cleanup();
|
|
2232
|
+
process.stdout.write('\n');
|
|
2233
|
+
resolve({ action: 'back' });
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// q or Escape → back
|
|
2238
|
+
if (name === 'escape' || (str && str.toLowerCase() === 'q')) {
|
|
2239
|
+
cleanup();
|
|
2240
|
+
process.stdout.write('\n');
|
|
2241
|
+
resolve({ action: 'back' });
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// Arrow up
|
|
2246
|
+
if (name === 'up') {
|
|
2247
|
+
cursor = Math.max(0, cursor - 1);
|
|
2248
|
+
renderPicker();
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// Arrow down
|
|
2253
|
+
if (name === 'down') {
|
|
2254
|
+
cursor = Math.min(candidates.length - 1, cursor + 1);
|
|
2255
|
+
renderPicker();
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// Space → toggle selection
|
|
2260
|
+
if (seq === ' ') {
|
|
2261
|
+
const id = candidates[cursor].id;
|
|
2262
|
+
if (selected.has(id)) selected.delete(id);
|
|
2263
|
+
else selected.add(id);
|
|
2264
|
+
renderPicker();
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
// Enter → import
|
|
2269
|
+
if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
|
|
2270
|
+
cleanup();
|
|
2271
|
+
process.stdout.write('\n');
|
|
2272
|
+
resolve({ action: 'import', ids: [...selected] });
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
};
|
|
2276
|
+
|
|
2277
|
+
process.stdin.on('keypress', onKey);
|
|
2278
|
+
});
|
|
2279
|
+
|
|
2280
|
+
if (result.action === 'back' || result.ids.length === 0) {
|
|
2281
|
+
return { next: 'main' };
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// Persist imported sessions to sessions.json
|
|
2285
|
+
const updatedMeta = getSessionMeta(cwd);
|
|
2286
|
+
const now = new Date().toISOString();
|
|
2287
|
+
let importCount = 0;
|
|
2288
|
+
for (const id of result.ids) {
|
|
2289
|
+
const sess = candidates.find(s => s.id === id);
|
|
2290
|
+
if (!sess) continue;
|
|
2291
|
+
updatedMeta[id] = {
|
|
2292
|
+
...updatedMeta[id],
|
|
2293
|
+
source: 'data-tools',
|
|
2294
|
+
importedAt: now,
|
|
2295
|
+
createdAt: updatedMeta[id]?.createdAt ?? now,
|
|
2296
|
+
};
|
|
2297
|
+
importCount++;
|
|
2298
|
+
}
|
|
2299
|
+
saveSessionMeta(updatedMeta, cwd);
|
|
2300
|
+
|
|
2301
|
+
process.stdout.write(`✓ Imported ${importCount} session${importCount !== 1 ? 's' : ''} from data-tools\n\n`);
|
|
1351
2302
|
|
|
1352
2303
|
return { next: 'main' };
|
|
1353
2304
|
}
|
|
1354
2305
|
|
|
1355
|
-
// ─── Screen:
|
|
2306
|
+
// ─── Screen: prTriageScreen ───────────────────────────────────────────────────
|
|
1356
2307
|
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
2308
|
+
/**
|
|
2309
|
+
* PR Triage screen. Lists open PRs, lets the user select one, checkout + fetch
|
|
2310
|
+
* comments, then dispatch fixes through the dual-brain pipeline.
|
|
2311
|
+
*
|
|
2312
|
+
* ctx.openPRs is the pre-fetched array from detectOpenPRs().
|
|
2313
|
+
*/
|
|
2314
|
+
async function prTriageScreen(rl, ask, ctx = {}) {
|
|
2315
|
+
const cwd = process.cwd();
|
|
2316
|
+
const prs = ctx.openPRs || [];
|
|
2317
|
+
|
|
2318
|
+
const termW = process.stdout.columns || 60;
|
|
2319
|
+
const boxW = Math.min(termW - 2, 60);
|
|
2320
|
+
const W = boxW - 4;
|
|
2321
|
+
|
|
2322
|
+
const top = `┌${'─'.repeat(boxW - 2)}┐`;
|
|
2323
|
+
const sep = `├${'─'.repeat(boxW - 2)}┤`;
|
|
2324
|
+
const bot = `└${'─'.repeat(boxW - 2)}┘`;
|
|
2325
|
+
const row = (content) => makeBoxRow(content, W);
|
|
2326
|
+
|
|
2327
|
+
if (prs.length === 0) {
|
|
2328
|
+
process.stdout.write('\n');
|
|
2329
|
+
process.stdout.write(top + '\n');
|
|
2330
|
+
process.stdout.write(row('PR Triage') + '\n');
|
|
2331
|
+
process.stdout.write(sep + '\n');
|
|
2332
|
+
process.stdout.write(row('No open PRs found.') + '\n');
|
|
2333
|
+
process.stdout.write(sep + '\n');
|
|
2334
|
+
process.stdout.write(row('[q] Back') + '\n');
|
|
2335
|
+
process.stdout.write(bot + '\n\n');
|
|
2336
|
+
await ask(' Press Enter to go back...');
|
|
2337
|
+
return { next: 'main' };
|
|
2338
|
+
}
|
|
1361
2339
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
2340
|
+
// Helper: get review decision label
|
|
2341
|
+
function reviewLabel(rd) {
|
|
2342
|
+
if (!rd) return 'pending review';
|
|
2343
|
+
const map = {
|
|
2344
|
+
APPROVED: 'approved',
|
|
2345
|
+
CHANGES_REQUESTED: 'changes_requested',
|
|
2346
|
+
REVIEW_REQUIRED: 'review_required',
|
|
2347
|
+
};
|
|
2348
|
+
return map[rd] || rd.toLowerCase();
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// ── Render PR list ─────────────────────────────────────────────────────────
|
|
2352
|
+
process.stdout.write('\n');
|
|
2353
|
+
process.stdout.write(top + '\n');
|
|
2354
|
+
process.stdout.write(row('PR Triage') + '\n');
|
|
2355
|
+
process.stdout.write(sep + '\n');
|
|
2356
|
+
|
|
2357
|
+
prs.forEach((pr, i) => {
|
|
2358
|
+
const title = String(pr.title || '').slice(0, W - 6);
|
|
2359
|
+
const decision = reviewLabel(pr.reviewDecision);
|
|
2360
|
+
const diff = `+${pr.additions || 0} -${pr.deletions || 0}`;
|
|
2361
|
+
const files = pr.changedFiles ? `${pr.changedFiles} file${pr.changedFiles === 1 ? '' : 's'}` : '';
|
|
2362
|
+
const numStr = `#${pr.number}`;
|
|
2363
|
+
|
|
2364
|
+
process.stdout.write(row(`[${i + 1}] ${numStr} ${title}`) + '\n');
|
|
2365
|
+
process.stdout.write(row(` ${decision} · ${diff}${files ? ' · ' + files : ''}`) + '\n');
|
|
2366
|
+
if (pr.headRefName) {
|
|
2367
|
+
process.stdout.write(row(` Branch: ${pr.headRefName}`) + '\n');
|
|
2368
|
+
}
|
|
2369
|
+
if (i < prs.length - 1) {
|
|
2370
|
+
process.stdout.write(row('') + '\n');
|
|
2371
|
+
}
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
process.stdout.write(sep + '\n');
|
|
2375
|
+
process.stdout.write(row('[1-9] Select PR [q] Back') + '\n');
|
|
2376
|
+
process.stdout.write(bot + '\n\n');
|
|
2377
|
+
|
|
2378
|
+
const pick = (await ask(' Choice: ')).trim().toLowerCase();
|
|
2379
|
+
|
|
2380
|
+
if (pick === 'q' || pick === 'b' || pick === '') return { next: 'main' };
|
|
1365
2381
|
|
|
1366
|
-
|
|
1367
|
-
|
|
2382
|
+
const idx = parseInt(pick, 10) - 1;
|
|
2383
|
+
if (isNaN(idx) || idx < 0 || idx >= prs.length) return { next: 'pr-triage', openPRs: prs };
|
|
1368
2384
|
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
2385
|
+
const selectedPR = prs[idx];
|
|
2386
|
+
|
|
2387
|
+
// ── PR detail: checkout + fetch comments ──────────────────────────────────
|
|
2388
|
+
process.stdout.write(`\n Checking out PR #${selectedPR.number}...\n`);
|
|
2389
|
+
|
|
2390
|
+
const checkoutResult = _spawnSyncTop('gh', ['pr', 'checkout', String(selectedPR.number)], {
|
|
2391
|
+
cwd,
|
|
2392
|
+
encoding: 'utf8',
|
|
2393
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2394
|
+
timeout: 15000,
|
|
2395
|
+
});
|
|
2396
|
+
|
|
2397
|
+
if (checkoutResult.status !== 0) {
|
|
2398
|
+
process.stdout.write(` Could not checkout PR: ${(checkoutResult.stderr || '').slice(0, 100)}\n`);
|
|
2399
|
+
await ask(' Press Enter to continue...');
|
|
2400
|
+
return { next: 'pr-triage', openPRs: prs };
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
process.stdout.write(` Fetching comments...\n`);
|
|
2404
|
+
|
|
2405
|
+
let comments = [];
|
|
2406
|
+
try {
|
|
2407
|
+
const commentsResult = _spawnSyncTop('gh', [
|
|
2408
|
+
'pr', 'view', String(selectedPR.number),
|
|
2409
|
+
'--comments',
|
|
2410
|
+
'--json', 'comments',
|
|
2411
|
+
], {
|
|
2412
|
+
cwd,
|
|
2413
|
+
encoding: 'utf8',
|
|
2414
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2415
|
+
timeout: 5000,
|
|
2416
|
+
});
|
|
2417
|
+
|
|
2418
|
+
if (commentsResult.status === 0 && commentsResult.stdout) {
|
|
2419
|
+
const parsed = JSON.parse(commentsResult.stdout.trim());
|
|
2420
|
+
comments = parsed?.comments || [];
|
|
2421
|
+
}
|
|
2422
|
+
} catch {}
|
|
2423
|
+
|
|
2424
|
+
// ── Show PR detail: comments grouped by file ──────────────────────────────
|
|
2425
|
+
process.stdout.write('\n');
|
|
2426
|
+
process.stdout.write(top + '\n');
|
|
2427
|
+
process.stdout.write(row(`#${selectedPR.number} ${String(selectedPR.title).slice(0, W - 6)}`) + '\n');
|
|
2428
|
+
process.stdout.write(sep + '\n');
|
|
2429
|
+
|
|
2430
|
+
if (comments.length === 0) {
|
|
2431
|
+
process.stdout.write(row('No review comments.') + '\n');
|
|
1373
2432
|
} else {
|
|
1374
|
-
|
|
2433
|
+
// Group comments by their file path (body comments have no path)
|
|
2434
|
+
const grouped = {};
|
|
2435
|
+
for (const c of comments) {
|
|
2436
|
+
const file = c.path || '(general)';
|
|
2437
|
+
if (!grouped[file]) grouped[file] = [];
|
|
2438
|
+
grouped[file].push(c);
|
|
2439
|
+
}
|
|
2440
|
+
for (const [file, fileCmts] of Object.entries(grouped)) {
|
|
2441
|
+
const fileLabel = file.length > W - 4 ? '...' + file.slice(-(W - 7)) : file;
|
|
2442
|
+
process.stdout.write(row(` ${fileLabel}`) + '\n');
|
|
2443
|
+
for (const c of fileCmts.slice(0, 3)) {
|
|
2444
|
+
const body = String(c.body || '').replace(/\s+/g, ' ').slice(0, W - 6);
|
|
2445
|
+
process.stdout.write(row(` → ${body}`) + '\n');
|
|
2446
|
+
}
|
|
2447
|
+
if (fileCmts.length > 3) {
|
|
2448
|
+
process.stdout.write(row(` ... +${fileCmts.length - 3} more`) + '\n');
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
1375
2451
|
}
|
|
1376
2452
|
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
2453
|
+
process.stdout.write(sep + '\n');
|
|
2454
|
+
process.stdout.write(row('[f] Dispatch fixes [v] View full diff [b] Back') + '\n');
|
|
2455
|
+
process.stdout.write(bot + '\n\n');
|
|
2456
|
+
|
|
2457
|
+
const action = (await ask(' Action: ')).trim().toLowerCase();
|
|
2458
|
+
|
|
2459
|
+
if (action === 'v') {
|
|
2460
|
+
// Show full diff via gh pr diff
|
|
2461
|
+
process.stdout.write('\n');
|
|
2462
|
+
const diffResult = _spawnSyncTop('gh', ['pr', 'diff', String(selectedPR.number)], {
|
|
2463
|
+
cwd,
|
|
2464
|
+
encoding: 'utf8',
|
|
2465
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2466
|
+
timeout: 10000,
|
|
2467
|
+
});
|
|
2468
|
+
const diffOut = (diffResult.stdout || '').slice(0, 3000);
|
|
2469
|
+
process.stdout.write(diffOut || ' (no diff output)\n');
|
|
2470
|
+
process.stdout.write('\n');
|
|
2471
|
+
await ask(' Press Enter to continue...');
|
|
2472
|
+
return { next: 'pr-triage', openPRs: prs };
|
|
1381
2473
|
}
|
|
1382
2474
|
|
|
1383
|
-
|
|
2475
|
+
if (action === 'f') {
|
|
2476
|
+
// Dispatch each comment as a fix task through detect→decide→dispatch
|
|
2477
|
+
if (comments.length === 0) {
|
|
2478
|
+
process.stdout.write(' No comments to fix.\n\n');
|
|
2479
|
+
await ask(' Press Enter to continue...');
|
|
2480
|
+
return { next: 'pr-triage', openPRs: prs };
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
process.stdout.write(`\n Dispatching ${comments.length} comment fix${comments.length === 1 ? '' : 's'} through dual-brain...\n\n`);
|
|
2484
|
+
|
|
2485
|
+
// Collect the PR files for context
|
|
2486
|
+
const prFiles = [];
|
|
2487
|
+
try {
|
|
2488
|
+
const filesResult = _spawnSyncTop('gh', [
|
|
2489
|
+
'pr', 'view', String(selectedPR.number),
|
|
2490
|
+
'--json', 'files',
|
|
2491
|
+
], {
|
|
2492
|
+
cwd,
|
|
2493
|
+
encoding: 'utf8',
|
|
2494
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2495
|
+
timeout: 5000,
|
|
2496
|
+
});
|
|
2497
|
+
if (filesResult.status === 0) {
|
|
2498
|
+
const pf = JSON.parse(filesResult.stdout || '{}');
|
|
2499
|
+
(pf.files || []).forEach(f => prFiles.push(f.path));
|
|
2500
|
+
}
|
|
2501
|
+
} catch {}
|
|
2502
|
+
|
|
2503
|
+
const profile = loadProfile(cwd);
|
|
2504
|
+
|
|
2505
|
+
for (let ci = 0; ci < comments.length; ci++) {
|
|
2506
|
+
const c = comments[ci];
|
|
2507
|
+
const taskPrompt = c.path
|
|
2508
|
+
? `Fix review comment in ${c.path}: ${c.body}`
|
|
2509
|
+
: `Fix PR review comment: ${c.body}`;
|
|
2510
|
+
|
|
2511
|
+
process.stdout.write(` [${ci + 1}/${comments.length}] ${taskPrompt.slice(0, 60)}...\n`);
|
|
2512
|
+
|
|
2513
|
+
try {
|
|
2514
|
+
const detection = detectTask({ prompt: taskPrompt, files: prFiles });
|
|
2515
|
+
const decision = decideRoute({ profile, detection, cwd });
|
|
2516
|
+
const result = await dispatch({ decision, prompt: taskPrompt, files: prFiles, cwd });
|
|
2517
|
+
const status = result.status === 'completed' ? '✓' : '✗';
|
|
2518
|
+
process.stdout.write(` ${status} ${result.status} (${(result.durationMs / 1000).toFixed(1)}s)\n`);
|
|
2519
|
+
if (result.summary) process.stdout.write(` ${result.summary.slice(0, 80)}\n`);
|
|
2520
|
+
} catch (e) {
|
|
2521
|
+
process.stdout.write(` ✗ Error: ${e.message.slice(0, 80)}\n`);
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
process.stdout.write('\n All fixes dispatched.\n\n');
|
|
2526
|
+
await ask(' Press Enter to continue...');
|
|
2527
|
+
return { next: 'pr-triage', openPRs: prs };
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// 'b' or anything else → back to PR list
|
|
2531
|
+
return { next: 'pr-triage', openPRs: prs };
|
|
1384
2532
|
}
|
|
1385
2533
|
|
|
1386
2534
|
// ─── Screen: settingsScreen ───────────────────────────────────────────────────
|
|
1387
2535
|
|
|
1388
2536
|
async function settingsScreen(rl, ask) {
|
|
1389
2537
|
const cwd = process.cwd();
|
|
1390
|
-
const profile = loadProfile(cwd);
|
|
1391
|
-
const auth = await detectAuth();
|
|
1392
2538
|
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
const settings = JSON.parse(readFileSync(settingsFile, 'utf8'));
|
|
1398
|
-
const preToolUse = settings?.hooks?.PreToolUse ?? [];
|
|
1399
|
-
const guardCmd = 'node .claude/hooks/head-guard.mjs';
|
|
1400
|
-
const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
|
|
1401
|
-
const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
|
|
1402
|
-
const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
|
|
1403
|
-
const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
|
|
1404
|
-
const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
|
|
1405
|
-
guardCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
|
|
1406
|
-
}
|
|
1407
|
-
} catch { /* ignore */ }
|
|
2539
|
+
// Box layout matching dashboard
|
|
2540
|
+
const termW = process.stdout.columns || 60;
|
|
2541
|
+
const boxW = Math.min(termW - 2, 60);
|
|
2542
|
+
const W = boxW - 4;
|
|
1408
2543
|
|
|
1409
|
-
const
|
|
2544
|
+
const top = `┌${'─'.repeat(boxW - 2)}┐`;
|
|
2545
|
+
const sep = `├${'─'.repeat(boxW - 2)}┤`;
|
|
2546
|
+
const bot = `└${'─'.repeat(boxW - 2)}┘`;
|
|
2547
|
+
const row = (content) => makeBoxRow(content, W);
|
|
1410
2548
|
|
|
1411
|
-
|
|
1412
|
-
const
|
|
1413
|
-
const claudePlanLabel = claudeSub?.enabled
|
|
1414
|
-
? (CLAUDE_PLAN_LABELS[claudeSub.plan] ?? claudeSub.plan ?? 'n/a')
|
|
1415
|
-
: 'disabled';
|
|
1416
|
-
const openaiPlanLabel = openaiSub?.enabled
|
|
1417
|
-
? (OPENAI_PLAN_LABELS[openaiSub.plan] ?? openaiSub.plan ?? 'n/a')
|
|
1418
|
-
: 'disabled';
|
|
2549
|
+
// Detect if gh is available + has PRs for the PR triage option
|
|
2550
|
+
const settingsPRs = await detectOpenPRs(cwd);
|
|
1419
2551
|
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
'',
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
'',
|
|
1430
|
-
|
|
2552
|
+
// Load current work style
|
|
2553
|
+
const profile = loadProfile(cwd);
|
|
2554
|
+
const currentBias = profile?.bias || profile?.mode || 'balanced';
|
|
2555
|
+
const WORK_STYLE_DISPLAY = {
|
|
2556
|
+
'cost-saver': '⚡ Fast',
|
|
2557
|
+
'auto': '⚡ Fast',
|
|
2558
|
+
'solo-claude': '⚡ Fast',
|
|
2559
|
+
'solo-openai': '⚡ Fast',
|
|
2560
|
+
'balanced': '⚖️ Balanced',
|
|
2561
|
+
'quality-first': '🔥 Full Power',
|
|
2562
|
+
};
|
|
2563
|
+
const workStyleLabel = WORK_STYLE_DISPLAY[currentBias] || '⚖️ Balanced';
|
|
2564
|
+
|
|
2565
|
+
const lines = [
|
|
2566
|
+
top,
|
|
2567
|
+
row('Settings'),
|
|
2568
|
+
sep,
|
|
2569
|
+
row(`[w] Work Style: ${workStyleLabel}`),
|
|
2570
|
+
row('[m] Manage subscriptions'),
|
|
2571
|
+
row('[e] Manage sessions'),
|
|
2572
|
+
row('[i] Import from replit-tools'),
|
|
2573
|
+
row('[d] Switch to data-tools'),
|
|
2574
|
+
row('[?] Help & shortcuts'),
|
|
2575
|
+
row('[x] Diagnostics'),
|
|
2576
|
+
...(settingsPRs.length > 0 ? [row(`[p] PR triage (${settingsPRs.length} open)`)] : []),
|
|
2577
|
+
row(''),
|
|
2578
|
+
row('[Esc/b] Back to dashboard'),
|
|
2579
|
+
bot,
|
|
1431
2580
|
];
|
|
2581
|
+
process.stdout.write('\n' + lines.join('\n') + '\n\n');
|
|
2582
|
+
|
|
2583
|
+
const raw = (await ask(' Choice: ')).trim();
|
|
2584
|
+
const choice = raw.toLowerCase();
|
|
2585
|
+
|
|
2586
|
+
if (choice === 'w') {
|
|
2587
|
+
// Work style picker
|
|
2588
|
+
const wsTop = ` ┌${'─'.repeat(51)}┐`;
|
|
2589
|
+
const wsSep = ` ├${'─'.repeat(51)}┤`;
|
|
2590
|
+
const wsBot = ` └${'─'.repeat(51)}┘`;
|
|
2591
|
+
const wsPad = (s) => {
|
|
2592
|
+
const plain = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
2593
|
+
let vlen = 0;
|
|
2594
|
+
for (const ch of plain) {
|
|
2595
|
+
const cp = ch.codePointAt(0);
|
|
2596
|
+
if (
|
|
2597
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
2598
|
+
(cp >= 0x2600 && cp <= 0x27bf) ||
|
|
2599
|
+
cp === 0xfe0f || cp === 0x20e3
|
|
2600
|
+
) { vlen += 2; } else { vlen += 1; }
|
|
2601
|
+
}
|
|
2602
|
+
return s + ' '.repeat(Math.max(0, 51 - vlen));
|
|
2603
|
+
};
|
|
2604
|
+
const wsRow = (s) => ` │ ${wsPad(s)}│`;
|
|
1432
2605
|
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
console.log(menu([
|
|
1437
|
-
{ key: '1', label: 'Switch to cost-saver', section: 'Mode' },
|
|
1438
|
-
{ key: '2', label: 'Switch to balanced', section: 'Mode' },
|
|
1439
|
-
{ key: '3', label: 'Switch to quality-first', section: 'Mode' },
|
|
1440
|
-
{ key: 'a', label: 'Manage subscriptions', section: 'Subscriptions' },
|
|
1441
|
-
{ key: 'i', label: 'Reinstall hooks', section: 'Enforcement' },
|
|
1442
|
-
{ key: 'b', label: 'Back', section: '' },
|
|
1443
|
-
]));
|
|
1444
|
-
console.log('');
|
|
2606
|
+
const isFast = currentBias === 'cost-saver' || currentBias === 'auto' || currentBias === 'solo-claude' || currentBias === 'solo-openai';
|
|
2607
|
+
const isBal = currentBias === 'balanced';
|
|
2608
|
+
const isFull = currentBias === 'quality-first';
|
|
1445
2609
|
|
|
1446
|
-
|
|
2610
|
+
console.log('');
|
|
2611
|
+
console.log(wsTop);
|
|
2612
|
+
console.log(wsRow('Work Style'));
|
|
2613
|
+
console.log(wsSep);
|
|
2614
|
+
console.log(wsRow(` 1. ⚡ Fast — quick, single model${isFast ? ' ← current' : ''}`));
|
|
2615
|
+
console.log(wsRow(` 2. ⚖️ Balanced — smart routing${isBal ? ' ← current' : ''}`));
|
|
2616
|
+
console.log(wsRow(` 3. 🔥 Full Power — dual-brain everything${isFull ? ' ← current' : ''}`));
|
|
2617
|
+
console.log(wsSep);
|
|
2618
|
+
console.log(wsRow('[Enter] Keep current'));
|
|
2619
|
+
console.log(wsBot);
|
|
2620
|
+
console.log('');
|
|
1447
2621
|
|
|
1448
|
-
|
|
1449
|
-
const
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
2622
|
+
const wsChoice = (await ask(' Choice [1/2/3/Enter]: ')).trim();
|
|
2623
|
+
const wsMap = { '1': 'cost-saver', '2': 'balanced', '3': 'quality-first' };
|
|
2624
|
+
const newBias = wsMap[wsChoice];
|
|
2625
|
+
if (newBias && newBias !== currentBias) {
|
|
2626
|
+
profile.bias = newBias;
|
|
2627
|
+
const enabledCount = [
|
|
2628
|
+
profile.providers?.claude?.enabled,
|
|
2629
|
+
profile.providers?.openai?.enabled,
|
|
2630
|
+
].filter(Boolean).length;
|
|
2631
|
+
if (enabledCount >= 2) profile.mode = newBias;
|
|
2632
|
+
saveProfile(profile, { cwd });
|
|
2633
|
+
const newLabel = WORK_STYLE_DISPLAY[newBias] || newBias;
|
|
2634
|
+
console.log(`\n ✓ Work style set to ${newLabel}\n`);
|
|
2635
|
+
await ask(' Press Enter to continue...');
|
|
2636
|
+
}
|
|
1453
2637
|
return { next: 'settings' };
|
|
1454
2638
|
}
|
|
1455
2639
|
|
|
1456
|
-
if (choice === '
|
|
1457
|
-
|
|
1458
|
-
}
|
|
2640
|
+
if (choice === 'm') { return { next: 'subscriptions' }; }
|
|
2641
|
+
|
|
2642
|
+
if (choice === 'e') { return { next: 'sessions' }; }
|
|
1459
2643
|
|
|
1460
2644
|
if (choice === 'i') {
|
|
1461
|
-
|
|
2645
|
+
return { next: 'import-picker' };
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
if (choice === 'p' && settingsPRs.length > 0) {
|
|
2649
|
+
return { next: 'pr-triage', openPRs: settingsPRs };
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
if (choice === 'd') {
|
|
2653
|
+
const { spawnSync } = await import('node:child_process');
|
|
2654
|
+
const which = spawnSync('which', ['claude-menu'], { encoding: 'utf8' });
|
|
2655
|
+
if (which.status === 0) {
|
|
2656
|
+
spawnSync('claude-menu', { stdio: 'inherit' });
|
|
2657
|
+
} else {
|
|
2658
|
+
process.stdout.write('\n data-tools not found — install with: npm i -g replit-tools\n\n');
|
|
2659
|
+
await ask(' Press Enter to continue...');
|
|
2660
|
+
}
|
|
1462
2661
|
return { next: 'settings' };
|
|
1463
2662
|
}
|
|
1464
2663
|
|
|
1465
|
-
if (choice === '
|
|
2664
|
+
if (choice === '?') {
|
|
2665
|
+
const W2 = 37;
|
|
2666
|
+
const helpTop = ` ┌${'─'.repeat(W2)}┐`;
|
|
2667
|
+
const helpSep = ` ├${'─'.repeat(W2)}┤`;
|
|
2668
|
+
const helpBottom = ` └${'─'.repeat(W2)}┘`;
|
|
2669
|
+
const helpPad = (s) => s + ' '.repeat(Math.max(0, W2 - s.length));
|
|
2670
|
+
process.stdout.write('\n');
|
|
2671
|
+
process.stdout.write(helpTop + '\n');
|
|
2672
|
+
process.stdout.write(` │ ${helpPad('At ~/workspace$ prompt:')}│\n`);
|
|
2673
|
+
process.stdout.write(` │ ${helpPad('db = show this menu')}│\n`);
|
|
2674
|
+
process.stdout.write(` │ ${helpPad('j = login to claude')}│\n`);
|
|
2675
|
+
process.stdout.write(` │ ${helpPad('k = login to codex')}│\n`);
|
|
2676
|
+
process.stdout.write(helpSep + '\n');
|
|
2677
|
+
process.stdout.write(` │ ${helpPad('In Claude:')}│\n`);
|
|
2678
|
+
process.stdout.write(` │ ${helpPad('Ctrl+C x2 = back to menu')}│\n`);
|
|
2679
|
+
process.stdout.write(` │ ${helpPad('Ctrl+C x3 = exit to shell')}│\n`);
|
|
2680
|
+
process.stdout.write(helpBottom + '\n\n');
|
|
2681
|
+
await ask(' Press Enter to continue...');
|
|
2682
|
+
return { next: 'settings' };
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
if (choice === 'x') { return { next: 'diagnostics' }; }
|
|
2686
|
+
|
|
2687
|
+
if (choice === 'b' || choice === 'back' || raw === '\x1b') { return { next: 'main' }; }
|
|
1466
2688
|
|
|
1467
|
-
return { next: '
|
|
2689
|
+
return { next: 'main' };
|
|
1468
2690
|
}
|
|
1469
2691
|
|
|
1470
|
-
// ─── Helper: aggregatePlans ───────────────────────────────────────────────────
|
|
1471
2692
|
|
|
1472
|
-
|
|
1473
|
-
pro: '$20', max5: '$100', max20: '$200',
|
|
1474
|
-
plus: '$20', pro100: '$100', pro200: '$200',
|
|
1475
|
-
};
|
|
2693
|
+
// ─── Helper: aggregatePlans ───────────────────────────────────────────────────
|
|
1476
2694
|
|
|
1477
2695
|
function aggregatePlans(subs) {
|
|
1478
2696
|
if (!subs || subs.length === 0) return '';
|
|
1479
2697
|
const counts = {};
|
|
1480
2698
|
for (const s of subs) {
|
|
1481
|
-
const
|
|
1482
|
-
counts[
|
|
2699
|
+
const label = s.plan || 'unknown';
|
|
2700
|
+
counts[label] = (counts[label] || 0) + 1;
|
|
1483
2701
|
}
|
|
1484
2702
|
return Object.entries(counts)
|
|
1485
|
-
.
|
|
1486
|
-
.map(([price, count]) => `${price}×${count}`)
|
|
2703
|
+
.map(([label, count]) => count > 1 ? `${label}×${count}` : label)
|
|
1487
2704
|
.join(' ');
|
|
1488
2705
|
}
|
|
1489
2706
|
|
|
@@ -1545,13 +2762,13 @@ async function subscriptionsScreen(rl, ask) {
|
|
|
1545
2762
|
const choice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
1546
2763
|
|
|
1547
2764
|
if (choice === '1') {
|
|
1548
|
-
console.log('\n Linking Claude
|
|
2765
|
+
console.log('\n Linking Claude account...');
|
|
1549
2766
|
console.log(' A browser window will open — paste the code below when prompted.\n');
|
|
1550
2767
|
const { spawnSync } = await import('node:child_process');
|
|
1551
2768
|
const r = spawnSync('claude', ['auth', 'login'], { stdio: 'inherit', timeout: 60000 });
|
|
1552
2769
|
if (r.status === 0) {
|
|
1553
2770
|
console.log('\n ✅ Claude linked successfully!\n');
|
|
1554
|
-
const label = (await ask(" Label (e.g. \"Josh's
|
|
2771
|
+
const label = (await ask(" Label (e.g. \"Josh's work account\", or Enter to skip): ")).trim();
|
|
1555
2772
|
const expiry = await askExpiry(ask, 'Claude');
|
|
1556
2773
|
const newPlans = detectPlans();
|
|
1557
2774
|
const plan = newPlans.claude?.plan || 'pro';
|
|
@@ -1573,7 +2790,7 @@ async function subscriptionsScreen(rl, ask) {
|
|
|
1573
2790
|
}
|
|
1574
2791
|
|
|
1575
2792
|
if (choice === '2') {
|
|
1576
|
-
console.log('\n Linking Codex
|
|
2793
|
+
console.log('\n Linking Codex account...');
|
|
1577
2794
|
console.log(' A browser window will open — paste the code below when prompted.\n');
|
|
1578
2795
|
const { spawnSync } = await import('node:child_process');
|
|
1579
2796
|
const r = spawnSync('codex', ['login'], { stdio: 'inherit', timeout: 60000 });
|
|
@@ -1611,12 +2828,12 @@ async function subscriptionsScreen(rl, ask) {
|
|
|
1611
2828
|
}
|
|
1612
2829
|
|
|
1613
2830
|
if (allSubs.length === 0) {
|
|
1614
|
-
console.log('\n No
|
|
2831
|
+
console.log('\n No linked accounts to remove.\n');
|
|
1615
2832
|
await ask(' Press Enter to continue...');
|
|
1616
2833
|
return { next: 'subscriptions' };
|
|
1617
2834
|
}
|
|
1618
2835
|
|
|
1619
|
-
console.log('\n Remove a
|
|
2836
|
+
console.log('\n Remove a linked account:\n');
|
|
1620
2837
|
allSubs.forEach(({ displayName, sub }, i) => {
|
|
1621
2838
|
const planLabels = displayName === 'Claude' ? CLAUDE_PLAN_LABELS : OPENAI_PLAN_LABELS;
|
|
1622
2839
|
const planLabel = planLabels[sub.plan] ?? sub.plan ?? 'unknown';
|
|
@@ -1656,21 +2873,20 @@ async function subscriptionsScreen(rl, ask) {
|
|
|
1656
2873
|
// ─── Onboarding Wizard ───────────────────────────────────────────────────────
|
|
1657
2874
|
|
|
1658
2875
|
/**
|
|
1659
|
-
*
|
|
1660
|
-
*
|
|
2876
|
+
* Streamlined onboarding: auto-detect capabilities, ask ONE question (work style).
|
|
2877
|
+
* Replaces the old 5-step wizard with a ~5-second, one-choice flow.
|
|
1661
2878
|
* @param {{ auth, plans, existingSessions }} detection
|
|
1662
2879
|
* @param {string} cwd
|
|
1663
2880
|
* @param {object} rl readline interface
|
|
1664
2881
|
* @returns {object|null} profile object to save, or null if cancelled/skipped
|
|
1665
2882
|
*/
|
|
1666
|
-
async function runOnboardingWizard(
|
|
2883
|
+
async function runOnboardingWizard(_detection, cwd, rl) {
|
|
1667
2884
|
const ask = (q) => new Promise(res => rl.question(q, res));
|
|
1668
2885
|
const version = readVersion();
|
|
1669
2886
|
|
|
1670
2887
|
// ── Rounded box helpers (matching mainScreen style) ────────────────────────
|
|
1671
2888
|
const W = 51;
|
|
1672
2889
|
const wTop = ` ┌${'─'.repeat(W)}┐`;
|
|
1673
|
-
const wSep = ` ├${'─'.repeat(W)}┤`;
|
|
1674
2890
|
const wBottom = ` └${'─'.repeat(W)}┘`;
|
|
1675
2891
|
const wPad = (s) => {
|
|
1676
2892
|
const plain = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
@@ -1687,244 +2903,95 @@ async function runOnboardingWizard(detection, cwd, rl) {
|
|
|
1687
2903
|
};
|
|
1688
2904
|
const wRow = (s) => ` │ ${wPad(s)}│`;
|
|
1689
2905
|
|
|
1690
|
-
// ──
|
|
1691
|
-
const
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
importSessions: false,
|
|
1696
|
-
profile: 'auto',
|
|
1697
|
-
};
|
|
1698
|
-
|
|
1699
|
-
const { auth, plans, existingSessions } = detection;
|
|
1700
|
-
const claudeReady = auth.claude.found;
|
|
1701
|
-
const openaiReady = auth.openai.found;
|
|
2906
|
+
// ── Use detectCapabilities for broad detection (env vars, ~/.claude, CLI) ──
|
|
2907
|
+
const caps = await detectCapabilities(cwd);
|
|
2908
|
+
const claudeReady = caps.claude.available;
|
|
2909
|
+
const openaiReady = caps.openai.available;
|
|
2910
|
+
const codexAvailable = caps.codex.available;
|
|
1702
2911
|
|
|
1703
|
-
//
|
|
1704
|
-
|
|
1705
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
1706
|
-
console.log('');
|
|
1707
|
-
console.log(wTop);
|
|
1708
|
-
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1709
|
-
console.log(wSep);
|
|
1710
|
-
console.log(wRow(`Step 1 of 5: Detected providers`));
|
|
1711
|
-
console.log(wSep);
|
|
1712
|
-
|
|
1713
|
-
// Plan tier is inferred from auth config signals — not the actual plan name.
|
|
1714
|
-
// Show the tier ($20/$100/$200) with "configured" suffix to be honest.
|
|
1715
|
-
const claudePlanSuffix = claudeReady && plans.claude ? ` · ${plans.claude} configured` : '';
|
|
1716
|
-
const openaiPlanSuffix = openaiReady && plans.openai ? ` · ${plans.openai} configured` : '';
|
|
1717
|
-
|
|
1718
|
-
console.log(wRow(claudeReady
|
|
1719
|
-
? `✓ Claude CLI${claudePlanSuffix}`
|
|
1720
|
-
: `✗ Claude CLI not logged in`));
|
|
1721
|
-
console.log(wRow(openaiReady
|
|
1722
|
-
? `✓ Codex CLI${openaiPlanSuffix}`
|
|
1723
|
-
: `✗ Codex CLI not logged in`));
|
|
1724
|
-
if (existingSessions.length > 0) {
|
|
1725
|
-
console.log(wRow(`✓ ${existingSessions.length} data-tools session${existingSessions.length !== 1 ? 's' : ''} found`));
|
|
1726
|
-
}
|
|
1727
|
-
console.log(wSep);
|
|
1728
|
-
console.log(wRow(`[Enter] Continue setup [s] Skip wizard`));
|
|
1729
|
-
console.log(wBottom);
|
|
1730
|
-
console.log('');
|
|
1731
|
-
|
|
1732
|
-
if (!claudeReady && !openaiReady) {
|
|
1733
|
-
console.log(' No AI provider found. Log in first:');
|
|
1734
|
-
console.log(' claude auth login — for Claude');
|
|
1735
|
-
console.log(' codex login — for OpenAI/Codex');
|
|
1736
|
-
console.log(' Then re-run: dual-brain init\n');
|
|
1737
|
-
return null;
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
const step1 = (await ask(' > ')).trim().toLowerCase();
|
|
1741
|
-
if (step1 === 's') {
|
|
1742
|
-
// Skip: auto-save detected plans and proceed directly
|
|
1743
|
-
const skippedProfile = loadProfile(cwd);
|
|
1744
|
-
if (claudeReady) skippedProfile.providers.claude = { enabled: true, plan: plans.claude || 'pro' };
|
|
1745
|
-
if (openaiReady) skippedProfile.providers.openai = { enabled: true, plan: plans.openai || 'plus' };
|
|
1746
|
-
const enabledCount = [claudeReady, openaiReady].filter(Boolean).length;
|
|
1747
|
-
skippedProfile.mode = enabledCount >= 2 ? 'auto' : claudeReady ? 'solo-claude' : 'solo-openai';
|
|
1748
|
-
return skippedProfile;
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
1752
|
-
// Step 2 — Budget / plan selection
|
|
1753
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
1754
|
-
console.log('');
|
|
1755
|
-
console.log(wTop);
|
|
1756
|
-
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1757
|
-
console.log(wSep);
|
|
1758
|
-
console.log(wRow(`Step 2 of 5: Subscription plans`));
|
|
1759
|
-
console.log(wSep);
|
|
1760
|
-
|
|
1761
|
-
if (claudeReady) {
|
|
1762
|
-
// Plan tier is inferred from auth config (rate-limit signal), not the actual plan name.
|
|
1763
|
-
const configuredClaudePlan = plans.claude || '$20';
|
|
1764
|
-
const configuredClaudeDesc = configuredClaudePlan + ' configured';
|
|
1765
|
-
console.log(wRow(`Claude — ${configuredClaudeDesc}`));
|
|
1766
|
-
console.log(wRow(` [1] Pro ($20/mo)`));
|
|
1767
|
-
console.log(wRow(` [2] Max x5 ($100/mo)`));
|
|
1768
|
-
console.log(wRow(` [3] Max x20 ($200/mo)`));
|
|
1769
|
-
console.log(wRow(` [Enter] Keep configured (${configuredClaudePlan})`));
|
|
1770
|
-
console.log(wSep);
|
|
1771
|
-
const claudeChoice = (await ask(' Claude plan [1/2/3/Enter]: ')).trim();
|
|
1772
|
-
const claudePlanMap = { '1': 'pro', '2': 'max5', '3': 'max20' };
|
|
1773
|
-
state.claudePlan = claudePlanMap[claudeChoice] || configuredClaudePlan;
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
if (openaiReady) {
|
|
1777
|
-
// Plan tier is inferred from JWT claim in auth config, not the actual plan name.
|
|
1778
|
-
const configuredOpenaiPlan = plans.openai || '$20';
|
|
1779
|
-
const configuredOpenaiDesc = configuredOpenaiPlan + ' configured';
|
|
1780
|
-
console.log(wRow(`OpenAI — ${configuredOpenaiDesc}`));
|
|
1781
|
-
console.log(wRow(` [1] Plus ($20/mo)`));
|
|
1782
|
-
console.log(wRow(` [2] Pro ($100/mo)`));
|
|
1783
|
-
console.log(wRow(` [3] Pro ($200/mo higher limits)`));
|
|
1784
|
-
console.log(wRow(` [Enter] Keep configured (${configuredOpenaiPlan})`));
|
|
1785
|
-
console.log(wSep);
|
|
1786
|
-
const openaiChoice = (await ask(' OpenAI plan [1/2/3/Enter]: ')).trim();
|
|
1787
|
-
const openaiPlanMap = { '1': 'plus', '2': 'pro', '3': 'pro200' };
|
|
1788
|
-
state.openaiPlan = openaiPlanMap[openaiChoice] || configuredOpenaiPlan;
|
|
1789
|
-
}
|
|
1790
|
-
|
|
1791
|
-
console.log(wBottom);
|
|
1792
|
-
|
|
1793
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
1794
|
-
// Step 3 — HEAD model selection
|
|
1795
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
1796
|
-
const hasBigPlan = state.claudePlan === 'max5' || state.claudePlan === 'max20';
|
|
1797
|
-
const recommendedModel = hasBigPlan ? 'claude-opus-4-5' : 'claude-sonnet-4-5';
|
|
1798
|
-
const recommendedLabel = hasBigPlan
|
|
1799
|
-
? 'Opus (Max plan — best quality)'
|
|
1800
|
-
: 'Sonnet (Pro plan — balanced speed/quality)';
|
|
1801
|
-
|
|
1802
|
-
console.log('');
|
|
1803
|
-
console.log(wTop);
|
|
1804
|
-
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1805
|
-
console.log(wSep);
|
|
1806
|
-
console.log(wRow(`Step 3 of 5: HEAD model (think-tier)`));
|
|
1807
|
-
console.log(wSep);
|
|
1808
|
-
console.log(wRow(`Recommended: ${recommendedLabel}`));
|
|
1809
|
-
console.log(wSep);
|
|
1810
|
-
console.log(wRow(` [1] Haiku — fastest, lowest cost`));
|
|
1811
|
-
console.log(wRow(` [2] Sonnet — balanced (recommended for Pro)`));
|
|
1812
|
-
console.log(wRow(` [3] Opus — best quality (recommended for Max)`));
|
|
1813
|
-
console.log(wRow(` [Enter] Use recommended`));
|
|
1814
|
-
console.log(wBottom);
|
|
1815
|
-
console.log('');
|
|
2912
|
+
// ── Detect replit-tools ────────────────────────────────────────────────────
|
|
2913
|
+
const rt = detectReplitTools(cwd);
|
|
1816
2914
|
|
|
1817
|
-
const
|
|
1818
|
-
const
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
'3': 'claude-opus-4-5',
|
|
1822
|
-
};
|
|
1823
|
-
state.headModel = modelMap[step3] || recommendedModel;
|
|
2915
|
+
const GREEN = '\x1b[32m✓\x1b[0m';
|
|
2916
|
+
const RED = '\x1b[31m✗\x1b[0m';
|
|
2917
|
+
const DIM = '\x1b[2m';
|
|
2918
|
+
const RESET = '\x1b[0m';
|
|
1824
2919
|
|
|
1825
2920
|
// ══════════════════════════════════════════════════════════════════════════
|
|
1826
|
-
// Step
|
|
2921
|
+
// Step 1 — Auto-detect capabilities (instant, no spinner)
|
|
1827
2922
|
// ══════════════════════════════════════════════════════════════════════════
|
|
1828
2923
|
console.log('');
|
|
1829
2924
|
console.log(wTop);
|
|
1830
2925
|
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1831
|
-
console.log(
|
|
1832
|
-
|
|
1833
|
-
|
|
2926
|
+
console.log(wRow(claudeReady
|
|
2927
|
+
? `${GREEN} Claude Code`
|
|
2928
|
+
: `${RED} Claude Code — not found`));
|
|
2929
|
+
console.log(wRow(openaiReady
|
|
2930
|
+
? `${GREEN} OpenAI API`
|
|
2931
|
+
: codexAvailable
|
|
2932
|
+
? `${GREEN} OpenAI / Codex CLI`
|
|
2933
|
+
: `${DIM}○ OpenAI — not configured${RESET}`));
|
|
2934
|
+
console.log(wRow(rt.installed
|
|
2935
|
+
? `${GREEN} replit-tools`
|
|
2936
|
+
: `${DIM}○ replit-tools — not found${RESET}`));
|
|
2937
|
+
console.log(wBottom);
|
|
1834
2938
|
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
console.log(
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
}
|
|
1848
|
-
if (existingSessions.length > 5) {
|
|
1849
|
-
console.log(` ... and ${existingSessions.length - 5} more`);
|
|
1850
|
-
}
|
|
1851
|
-
}
|
|
1852
|
-
console.log(wSep);
|
|
2939
|
+
// ── Edge cases: communicate honestly, but always let them proceed ──────────
|
|
2940
|
+
console.log('');
|
|
2941
|
+
if (!claudeReady && !openaiReady && !codexAvailable) {
|
|
2942
|
+
console.log(' No AI providers detected — configure OPENAI_API_KEY or use');
|
|
2943
|
+
console.log(' within Claude Code. You can still continue and set up later.');
|
|
2944
|
+
console.log('');
|
|
2945
|
+
} else if (claudeReady && !openaiReady && !codexAvailable) {
|
|
2946
|
+
console.log(` ${DIM}Tip: Add OPENAI_API_KEY for dual-brain collaboration${RESET}`);
|
|
2947
|
+
console.log('');
|
|
2948
|
+
} else if (!claudeReady && (openaiReady || codexAvailable)) {
|
|
2949
|
+
console.log(` ${DIM}Note: Use within Claude Code for full dual-brain${RESET}`);
|
|
2950
|
+
console.log('');
|
|
1853
2951
|
}
|
|
1854
2952
|
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
console.log(
|
|
1859
|
-
console.log(wRow(
|
|
1860
|
-
console.log(wRow(
|
|
2953
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
2954
|
+
// Step 2 — ONE question: work style
|
|
2955
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
2956
|
+
console.log(wTop);
|
|
2957
|
+
console.log(wRow('How do you want to work?'));
|
|
2958
|
+
console.log(wRow(''));
|
|
2959
|
+
console.log(wRow(' 1 ⚡ Fast — single model, quick tasks, skip reviews'));
|
|
2960
|
+
console.log(wRow(' 2 ⚖️ Balanced — smart routing, reviews on important changes'));
|
|
2961
|
+
console.log(wRow(' 3 🔥 Full Power — deep reasoning, dual-brain when it matters'));
|
|
1861
2962
|
console.log(wBottom);
|
|
1862
2963
|
console.log('');
|
|
1863
2964
|
|
|
1864
|
-
const
|
|
1865
|
-
const
|
|
1866
|
-
|
|
2965
|
+
const styleChoice = (await ask(' Choice [2]: ')).trim();
|
|
2966
|
+
const styleMap = { '1': 'cost-saver', '2': 'balanced', '3': 'quality-first' };
|
|
2967
|
+
const styleNames = { 'cost-saver': 'Fast', 'balanced': 'Balanced', 'quality-first': 'Full Power' };
|
|
2968
|
+
const chosenBias = styleMap[styleChoice] || 'balanced';
|
|
2969
|
+
const chosenName = styleNames[chosenBias];
|
|
1867
2970
|
|
|
1868
|
-
//
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
: `Claude: not configured`;
|
|
1874
|
-
const openaiSummary = state.openaiPlan
|
|
1875
|
-
? `OpenAI: ${OPENAI_PLAN_LABELS[state.openaiPlan] ?? state.openaiPlan}`
|
|
1876
|
-
: `OpenAI: not configured`;
|
|
1877
|
-
const modelSummary = `HEAD model: ${state.headModel}`;
|
|
1878
|
-
const profileSummary = `Profile: ${state.profile}`;
|
|
1879
|
-
const sessionSummary = existingSessions.length > 0
|
|
1880
|
-
? `Sessions: ${state.importSessions ? `${existingSessions.length} imported` : 'skipped'}`
|
|
1881
|
-
: null;
|
|
2971
|
+
// ── Non-blocking note if metered API detected ──────────────────────────────
|
|
2972
|
+
if (openaiReady && caps.openai.metered) {
|
|
2973
|
+
console.log(` ${DIM}OpenAI API key detected — usage is metered, guardrails enabled${RESET}`);
|
|
2974
|
+
console.log('');
|
|
2975
|
+
}
|
|
1882
2976
|
|
|
1883
|
-
|
|
2977
|
+
// ── Done ───────────────────────────────────────────────────────────────────
|
|
1884
2978
|
console.log(wTop);
|
|
1885
|
-
console.log(wRow(
|
|
1886
|
-
console.log(
|
|
1887
|
-
console.log(wRow(`Step 5 of 5: Summary`));
|
|
1888
|
-
console.log(wSep);
|
|
1889
|
-
console.log(wRow(claudeSummary));
|
|
1890
|
-
console.log(wRow(openaiSummary));
|
|
1891
|
-
console.log(wRow(modelSummary));
|
|
1892
|
-
console.log(wRow(profileSummary));
|
|
1893
|
-
if (sessionSummary) console.log(wRow(sessionSummary));
|
|
1894
|
-
console.log(wSep);
|
|
1895
|
-
console.log(wRow(`[Enter] Save and start [q] Quit without saving`));
|
|
2979
|
+
console.log(wRow(`${GREEN} Ready — ${chosenName} mode`));
|
|
2980
|
+
console.log(wRow(` Type a task to start, or press Enter for dashboard`));
|
|
1896
2981
|
console.log(wBottom);
|
|
1897
2982
|
console.log('');
|
|
1898
2983
|
|
|
1899
|
-
const step5 = (await ask(' > ')).trim().toLowerCase();
|
|
1900
|
-
if (step5 === 'q') {
|
|
1901
|
-
console.log('\n Setup cancelled.\n');
|
|
1902
|
-
return null;
|
|
1903
|
-
}
|
|
1904
|
-
|
|
1905
2984
|
// ── Build and return the profile object ────────────────────────────────────
|
|
1906
2985
|
const finalProfile = loadProfile(cwd);
|
|
1907
2986
|
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
finalProfile.providers.claude = { enabled: true, plan: plans.claude || 'pro' };
|
|
1912
|
-
}
|
|
1913
|
-
|
|
1914
|
-
if (state.openaiPlan) {
|
|
1915
|
-
finalProfile.providers.openai = { enabled: true, plan: state.openaiPlan };
|
|
1916
|
-
} else if (openaiReady) {
|
|
1917
|
-
finalProfile.providers.openai = { enabled: true, plan: plans.openai || 'plus' };
|
|
1918
|
-
}
|
|
1919
|
-
|
|
1920
|
-
const enabledCount = [
|
|
1921
|
-
finalProfile.providers?.claude?.enabled,
|
|
1922
|
-
finalProfile.providers?.openai?.enabled,
|
|
1923
|
-
].filter(Boolean).length;
|
|
2987
|
+
finalProfile.providers.claude = { enabled: claudeReady };
|
|
2988
|
+
finalProfile.providers.openai = { enabled: openaiReady || codexAvailable };
|
|
2989
|
+
finalProfile.apiGuardrail = caps.openai.metered;
|
|
1924
2990
|
|
|
1925
|
-
|
|
1926
|
-
finalProfile.
|
|
1927
|
-
finalProfile.bias
|
|
2991
|
+
const enabledCount = [claudeReady, openaiReady || codexAvailable].filter(Boolean).length;
|
|
2992
|
+
finalProfile.mode = enabledCount >= 2 ? 'dual' : claudeReady ? 'solo-claude' : 'solo-openai';
|
|
2993
|
+
finalProfile.bias = chosenBias;
|
|
2994
|
+
finalProfile.workStyle = chosenBias;
|
|
1928
2995
|
|
|
1929
2996
|
return finalProfile;
|
|
1930
2997
|
}
|
|
@@ -1965,11 +3032,11 @@ async function authScreen(rl, ask) {
|
|
|
1965
3032
|
` plan: ${openaiPlanLabel}${openaiSub?.label ? ` [${openaiSub.label}]` : ''}`,
|
|
1966
3033
|
];
|
|
1967
3034
|
|
|
1968
|
-
console.log(box('
|
|
3035
|
+
console.log(box('Provider Status', authLines));
|
|
1969
3036
|
console.log('');
|
|
1970
3037
|
console.log(menu([
|
|
1971
|
-
{ key: 'a', label: 'Manage
|
|
1972
|
-
{ key: 'b', label: 'Back to dashboard',
|
|
3038
|
+
{ key: 'a', label: 'Manage linked accounts', section: '' },
|
|
3039
|
+
{ key: 'b', label: 'Back to dashboard', section: '' },
|
|
1973
3040
|
]));
|
|
1974
3041
|
console.log('');
|
|
1975
3042
|
|
|
@@ -2408,45 +3475,216 @@ async function sessionDetailScreen(rl, ask, ctx = {}) {
|
|
|
2408
3475
|
// ─── Screen: sessionsScreen ───────────────────────────────────────────────────
|
|
2409
3476
|
|
|
2410
3477
|
const CATEGORIES = ['security', 'ui', 'refactor', 'bugfix', 'testing', 'devops', 'planning'];
|
|
3478
|
+
const STALE_DAYS = 7;
|
|
3479
|
+
|
|
3480
|
+
/**
|
|
3481
|
+
* Return a compact status badge string for a session row (plain text, no ANSI).
|
|
3482
|
+
*/
|
|
3483
|
+
function sessionBadge(sess) {
|
|
3484
|
+
if (sess.isActive) return '[active]';
|
|
3485
|
+
const ageMs = sess.lastActive ? Date.now() - new Date(sess.lastActive).getTime() : 0;
|
|
3486
|
+
if (ageMs >= STALE_DAYS * 86400000) return '[stale]';
|
|
3487
|
+
if (sess.tool === 'codex') return '[dt]';
|
|
3488
|
+
return '';
|
|
3489
|
+
}
|
|
2411
3490
|
|
|
3491
|
+
/**
|
|
3492
|
+
* Interactive full session list with arrow-key navigation.
|
|
3493
|
+
* Enter = resume, x = archive, r = rename, q/Esc = back to dashboard.
|
|
3494
|
+
*/
|
|
2412
3495
|
async function sessionsScreen(rl, ask) {
|
|
2413
3496
|
const cwd = process.cwd();
|
|
2414
|
-
const sessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 9);
|
|
2415
3497
|
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
3498
|
+
// Load all active sessions (no slice limit)
|
|
3499
|
+
let sessions = enrichSessions(importReplitSessions(cwd), cwd);
|
|
3500
|
+
|
|
3501
|
+
// ── Box geometry ────────────────────────────────────────────────────────────
|
|
3502
|
+
const termW = process.stdout.columns || 60;
|
|
3503
|
+
const boxW = Math.min(termW - 2, 52);
|
|
3504
|
+
const W = boxW - 4;
|
|
3505
|
+
|
|
3506
|
+
const top = `┌${'─'.repeat(boxW - 2)}┐`;
|
|
3507
|
+
const sep = `├${'─'.repeat(boxW - 2)}┤`;
|
|
3508
|
+
const bot = `└${'─'.repeat(boxW - 2)}┘`;
|
|
2419
3509
|
|
|
2420
3510
|
if (sessions.length === 0) {
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
3511
|
+
process.stdout.write('\n' + top + '\n');
|
|
3512
|
+
process.stdout.write(makeBoxRow('Sessions', W) + '\n');
|
|
3513
|
+
process.stdout.write(sep + '\n');
|
|
3514
|
+
process.stdout.write(makeBoxRow('No sessions found.', W) + '\n');
|
|
3515
|
+
process.stdout.write(sep + '\n');
|
|
3516
|
+
process.stdout.write(makeBoxRow('q Back', W) + '\n');
|
|
3517
|
+
process.stdout.write(bot + '\n\n');
|
|
3518
|
+
await ask(' Press Enter to continue...');
|
|
3519
|
+
return { next: 'main' };
|
|
2426
3520
|
}
|
|
2427
3521
|
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
3522
|
+
/**
|
|
3523
|
+
* Format one session row.
|
|
3524
|
+
* Right side: badge(9) + age(4) + space + count(4) = 18 chars total.
|
|
3525
|
+
*/
|
|
3526
|
+
function formatRow(sess, selected) {
|
|
3527
|
+
const arrow = selected ? '▸ ' : ' ';
|
|
3528
|
+
const badge = sessionBadge(sess);
|
|
3529
|
+
const badgeStr = badge ? badge.padEnd(9) : ' ';
|
|
3530
|
+
const age = (sess.age || '').replace(/ ago$/, '').padStart(4);
|
|
3531
|
+
const count = `(${sess.promptCount ?? 0})`.padStart(4);
|
|
3532
|
+
const right = `${badgeStr}${age} ${count}`;
|
|
3533
|
+
const nameMax = W - 2 - right.length;
|
|
3534
|
+
let name = sess.name || sess.id.slice(0, 8);
|
|
3535
|
+
if (name.length > nameMax) name = name.slice(0, nameMax - 3) + '...';
|
|
3536
|
+
else name = name.padEnd(nameMax);
|
|
3537
|
+
return makeBoxRow(`${arrow}${name}${right}`, W);
|
|
3538
|
+
}
|
|
2434
3539
|
|
|
2435
|
-
|
|
2436
|
-
console.log(' [1-9] Select a session to manage');
|
|
2437
|
-
console.log(' [b] Back');
|
|
2438
|
-
console.log('');
|
|
3540
|
+
let cursor = 0;
|
|
2439
3541
|
|
|
2440
|
-
|
|
3542
|
+
function render() {
|
|
3543
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
3544
|
+
process.stdout.write(top + '\n');
|
|
3545
|
+
process.stdout.write(makeBoxRow('Sessions', W) + '\n');
|
|
3546
|
+
process.stdout.write(sep + '\n');
|
|
3547
|
+
for (let i = 0; i < sessions.length; i++) {
|
|
3548
|
+
process.stdout.write(formatRow(sessions[i], i === cursor) + '\n');
|
|
3549
|
+
}
|
|
3550
|
+
process.stdout.write(sep + '\n');
|
|
3551
|
+
process.stdout.write(makeBoxRow('↑↓ Navigate Enter Resume x Archive r Rename', W) + '\n');
|
|
3552
|
+
process.stdout.write(makeBoxRow('q Back', W) + '\n');
|
|
3553
|
+
process.stdout.write(bot + '\n');
|
|
3554
|
+
}
|
|
2441
3555
|
|
|
2442
|
-
|
|
3556
|
+
render();
|
|
2443
3557
|
|
|
2444
|
-
const
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
3558
|
+
const readline = await import('node:readline');
|
|
3559
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
3560
|
+
|
|
3561
|
+
const result = await new Promise((resolve) => {
|
|
3562
|
+
const wasRaw = process.stdin.isRaw;
|
|
3563
|
+
const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
|
|
3564
|
+
if (canRaw) process.stdin.setRawMode(true);
|
|
3565
|
+
|
|
3566
|
+
const cleanup = () => {
|
|
3567
|
+
process.stdin.removeListener('keypress', onKey);
|
|
3568
|
+
if (canRaw) {
|
|
3569
|
+
try { process.stdin.setRawMode(wasRaw || false); } catch {}
|
|
3570
|
+
}
|
|
3571
|
+
};
|
|
3572
|
+
|
|
3573
|
+
const onKey = async (str, key) => {
|
|
3574
|
+
if (!key) return;
|
|
3575
|
+
const kname = key.name || '';
|
|
3576
|
+
|
|
3577
|
+
// Ctrl-C / Ctrl-D → exit
|
|
3578
|
+
if (key.ctrl && (kname === 'c' || kname === 'd')) {
|
|
3579
|
+
cleanup();
|
|
3580
|
+
process.stdout.write('\n');
|
|
3581
|
+
resolve({ next: 'main' });
|
|
3582
|
+
return;
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
// q / Escape → back
|
|
3586
|
+
if (kname === 'q' || kname === 'escape' || str === 'q') {
|
|
3587
|
+
cleanup();
|
|
3588
|
+
process.stdout.write('\n');
|
|
3589
|
+
resolve({ next: 'main' });
|
|
3590
|
+
return;
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
// Arrow up
|
|
3594
|
+
if (kname === 'up') {
|
|
3595
|
+
cursor = Math.max(0, cursor - 1);
|
|
3596
|
+
render();
|
|
3597
|
+
return;
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3600
|
+
// Arrow down
|
|
3601
|
+
if (kname === 'down') {
|
|
3602
|
+
cursor = Math.min(sessions.length - 1, cursor + 1);
|
|
3603
|
+
render();
|
|
3604
|
+
return;
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
// Enter → resume highlighted session
|
|
3608
|
+
if (kname === 'return' || kname === 'enter') {
|
|
3609
|
+
const sess = sessions[cursor];
|
|
3610
|
+
cleanup();
|
|
3611
|
+
process.stdout.write('\n');
|
|
3612
|
+
process.stdout.write(`\n Launching: claude --resume ${sess.id}\n\n`);
|
|
3613
|
+
const { spawnSync } = await import('node:child_process');
|
|
3614
|
+
spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
|
|
3615
|
+
saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
|
|
3616
|
+
resolve({ next: 'main' });
|
|
3617
|
+
return;
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
// x → archive highlighted session (non-destructive)
|
|
3621
|
+
if (str === 'x' || str === 'X') {
|
|
3622
|
+
const sess = sessions[cursor];
|
|
3623
|
+
archiveSession(sess.id, cwd);
|
|
3624
|
+
sessions = sessions.filter(s => s.id !== sess.id);
|
|
3625
|
+
if (sessions.length === 0) {
|
|
3626
|
+
cleanup();
|
|
3627
|
+
process.stdout.write('\n');
|
|
3628
|
+
resolve({ next: 'main' });
|
|
3629
|
+
return;
|
|
3630
|
+
}
|
|
3631
|
+
cursor = Math.min(cursor, sessions.length - 1);
|
|
3632
|
+
render();
|
|
3633
|
+
return;
|
|
3634
|
+
}
|
|
2448
3635
|
|
|
2449
|
-
|
|
3636
|
+
// r → rename highlighted session
|
|
3637
|
+
if (str === 'r' || str === 'R') {
|
|
3638
|
+
const sess = sessions[cursor];
|
|
3639
|
+
cleanup();
|
|
3640
|
+
|
|
3641
|
+
// Briefly collect a line of text
|
|
3642
|
+
process.stdout.write('\n New name: ');
|
|
3643
|
+
const newName = await new Promise(res2 => {
|
|
3644
|
+
let buf = '';
|
|
3645
|
+
const onData = (chunk) => {
|
|
3646
|
+
const s = chunk.toString();
|
|
3647
|
+
for (const ch of s) {
|
|
3648
|
+
if (ch === '\n' || ch === '\r') {
|
|
3649
|
+
process.stdin.removeListener('data', onData);
|
|
3650
|
+
process.stdout.write('\n');
|
|
3651
|
+
res2(buf.trim());
|
|
3652
|
+
return;
|
|
3653
|
+
}
|
|
3654
|
+
if (ch === '\x7f' || ch === '\b') {
|
|
3655
|
+
if (buf.length > 0) {
|
|
3656
|
+
buf = buf.slice(0, -1);
|
|
3657
|
+
process.stdout.write('\b \b');
|
|
3658
|
+
}
|
|
3659
|
+
} else {
|
|
3660
|
+
buf += ch;
|
|
3661
|
+
process.stdout.write(ch);
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
};
|
|
3665
|
+
process.stdin.on('data', onData);
|
|
3666
|
+
});
|
|
3667
|
+
|
|
3668
|
+
if (newName) {
|
|
3669
|
+
renameSession(sess.id, newName, cwd);
|
|
3670
|
+
sessions[cursor] = { ...sess, name: newName };
|
|
3671
|
+
}
|
|
3672
|
+
|
|
3673
|
+
// Re-enable raw mode and re-attach listener
|
|
3674
|
+
if (canRaw) {
|
|
3675
|
+
try { process.stdin.setRawMode(true); } catch {}
|
|
3676
|
+
}
|
|
3677
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
3678
|
+
process.stdin.on('keypress', onKey);
|
|
3679
|
+
render();
|
|
3680
|
+
return;
|
|
3681
|
+
}
|
|
3682
|
+
};
|
|
3683
|
+
|
|
3684
|
+
process.stdin.on('keypress', onKey);
|
|
3685
|
+
});
|
|
3686
|
+
|
|
3687
|
+
return result;
|
|
2450
3688
|
}
|
|
2451
3689
|
|
|
2452
3690
|
async function sessionManageScreen(rl, ask, ctx = {}) {
|
|
@@ -2530,6 +3768,257 @@ async function sessionManageScreen(rl, ask, ctx = {}) {
|
|
|
2530
3768
|
return { next: 'session-manage', session: sess };
|
|
2531
3769
|
}
|
|
2532
3770
|
|
|
3771
|
+
|
|
3772
|
+
// ─── Auto-commit drafting ─────────────────────────────────────────────────────
|
|
3773
|
+
|
|
3774
|
+
/**
|
|
3775
|
+
* Detect uncommitted changes in cwd.
|
|
3776
|
+
* Returns { hasChanges, files, statOutput, diffSnippet } or null.
|
|
3777
|
+
*/
|
|
3778
|
+
function detectUncommittedChanges(cwd) {
|
|
3779
|
+
try {
|
|
3780
|
+
execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' });
|
|
3781
|
+
} catch { return null; }
|
|
3782
|
+
|
|
3783
|
+
let statOutput = '';
|
|
3784
|
+
try {
|
|
3785
|
+
statOutput = execSync('git diff --stat HEAD', { cwd, encoding: 'utf8', timeout: 3000, stdio: 'pipe' }).trim();
|
|
3786
|
+
} catch { return null; }
|
|
3787
|
+
|
|
3788
|
+
let statusOutput = '';
|
|
3789
|
+
try {
|
|
3790
|
+
statusOutput = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' }).trim();
|
|
3791
|
+
} catch {}
|
|
3792
|
+
|
|
3793
|
+
if (!statOutput && !statusOutput) return null;
|
|
3794
|
+
|
|
3795
|
+
const statFiles = statOutput
|
|
3796
|
+
.split('\n')
|
|
3797
|
+
.filter(l => l.includes('|'))
|
|
3798
|
+
.map(l => l.split('|')[0].trim())
|
|
3799
|
+
.filter(Boolean);
|
|
3800
|
+
|
|
3801
|
+
const statusFiles = statusOutput
|
|
3802
|
+
.split('\n')
|
|
3803
|
+
.filter(Boolean)
|
|
3804
|
+
.map(l => l.slice(3).trim())
|
|
3805
|
+
.filter(f => f && !statFiles.includes(f));
|
|
3806
|
+
|
|
3807
|
+
const files = [...new Set([...statFiles, ...statusFiles])];
|
|
3808
|
+
|
|
3809
|
+
let diffSnippet = '';
|
|
3810
|
+
try {
|
|
3811
|
+
const full = execSync('git diff HEAD', { cwd, encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
|
|
3812
|
+
diffSnippet = full.slice(0, 2000);
|
|
3813
|
+
} catch {}
|
|
3814
|
+
|
|
3815
|
+
return { hasChanges: true, files, statOutput, diffSnippet };
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
/**
|
|
3819
|
+
* Build a conventional commit message from file list + diff snippet.
|
|
3820
|
+
* Deterministic — no AI calls.
|
|
3821
|
+
*/
|
|
3822
|
+
function generateCommitMessage(files, diffSnippet) {
|
|
3823
|
+
if (!files || files.length === 0) return 'chore: update files';
|
|
3824
|
+
|
|
3825
|
+
const testFiles = files.filter(f =>
|
|
3826
|
+
/\.(test|spec)\.[jt]sx?$/.test(f) || /\/(test|tests|__tests__)\//i.test(f)
|
|
3827
|
+
);
|
|
3828
|
+
const docFiles = files.filter(f => /\.(md|txt|rst|adoc)$/i.test(f) || /docs?\//i.test(f));
|
|
3829
|
+
const configFiles = files.filter(f =>
|
|
3830
|
+
/\.(json|yaml|yml|toml|ini)$/i.test(f) ||
|
|
3831
|
+
/^\.?(eslint|prettier|babel|jest|tsconfig|package)/i.test(f.replace(/.*\//, ''))
|
|
3832
|
+
);
|
|
3833
|
+
const srcFiles = files.filter(f =>
|
|
3834
|
+
!testFiles.includes(f) && !docFiles.includes(f) && !configFiles.includes(f)
|
|
3835
|
+
);
|
|
3836
|
+
|
|
3837
|
+
let type = 'feat';
|
|
3838
|
+
if (diffSnippet) {
|
|
3839
|
+
const lower = diffSnippet.toLowerCase();
|
|
3840
|
+
if (['fix', 'bug', 'error', 'issue', 'resolve', 'patch', 'correct', 'repair'].some(w => lower.includes(w))) {
|
|
3841
|
+
type = 'fix';
|
|
3842
|
+
} else if (['refactor', 'cleanup', 'simplify', 'reorganize'].some(w => lower.includes(w))) {
|
|
3843
|
+
type = 'refactor';
|
|
3844
|
+
}
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3847
|
+
const dominantFile = files[0].replace(/.*\//, '');
|
|
3848
|
+
|
|
3849
|
+
if (testFiles.length === files.length) {
|
|
3850
|
+
const mod = testFiles[0].replace(/\.(test|spec)\.[jt]sx?$/, '').replace(/.*\//, '');
|
|
3851
|
+
return `test: add/fix tests for ${mod}`;
|
|
3852
|
+
}
|
|
3853
|
+
if (docFiles.length === files.length) {
|
|
3854
|
+
return `docs: update ${docFiles[0].replace(/.*\//, '')}`;
|
|
3855
|
+
}
|
|
3856
|
+
if (configFiles.length === files.length) {
|
|
3857
|
+
return `chore: update ${configFiles[0].replace(/.*\//, '')}`;
|
|
3858
|
+
}
|
|
3859
|
+
if (srcFiles.length > 0 && testFiles.length > 0) {
|
|
3860
|
+
const dom = srcFiles[0].replace(/.*\//, '').replace(/\.[jt]sx?$/, '');
|
|
3861
|
+
return `${type}: ${dom} with tests`;
|
|
3862
|
+
}
|
|
3863
|
+
if (files.length === 1) {
|
|
3864
|
+
return `${type}: update ${dominantFile.replace(/\.[jt]sx?$/, '')}`;
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3867
|
+
const dirs = files.map(f => (f.includes('/') ? f.split('/').slice(-2, -1)[0] : ''));
|
|
3868
|
+
const commonDir = dirs[0] && dirs.every(d => d === dirs[0]) ? dirs[0] : null;
|
|
3869
|
+
if (commonDir) return `${type}: update ${commonDir}`;
|
|
3870
|
+
|
|
3871
|
+
return `${type}: update ${dominantFile.replace(/\.[jt]sx?$/, '')}`;
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3874
|
+
/**
|
|
3875
|
+
* Show a commit card after task completion and handle user action.
|
|
3876
|
+
* Enter -> git add -A && git commit -m "message"
|
|
3877
|
+
* e -> prompt for custom message, then commit
|
|
3878
|
+
* d -> show full diff, then return to card
|
|
3879
|
+
* s -> skip
|
|
3880
|
+
*
|
|
3881
|
+
* Only shown on TTY. Never auto-commits — the card is the offer.
|
|
3882
|
+
* Returns true if a commit was made.
|
|
3883
|
+
*/
|
|
3884
|
+
async function offerAutoCommit(cwd) {
|
|
3885
|
+
if (!process.stdout.isTTY) return false;
|
|
3886
|
+
|
|
3887
|
+
try {
|
|
3888
|
+
const claude = parseInt(execSync('pgrep -x claude 2>/dev/null | wc -l', { encoding: 'utf8' }).trim(), 10) || 0;
|
|
3889
|
+
const codex = parseInt(execSync('pgrep -x codex 2>/dev/null | wc -l', { encoding: 'utf8' }).trim(), 10) || 0;
|
|
3890
|
+
if (claude > 0 || codex > 0) return false;
|
|
3891
|
+
} catch {}
|
|
3892
|
+
|
|
3893
|
+
try {
|
|
3894
|
+
const sessionPath = join(cwd, '.dualbrain', 'session.json');
|
|
3895
|
+
if (existsSync(sessionPath)) {
|
|
3896
|
+
const sess = JSON.parse(readFileSync(sessionPath, 'utf8'));
|
|
3897
|
+
if (sess?.lastResult?.status === 'failure') return false;
|
|
3898
|
+
}
|
|
3899
|
+
} catch {}
|
|
3900
|
+
|
|
3901
|
+
const changes = detectUncommittedChanges(cwd);
|
|
3902
|
+
if (!changes) return false;
|
|
3903
|
+
|
|
3904
|
+
let finalMsg = generateCommitMessage(changes.files, changes.diffSnippet);
|
|
3905
|
+
|
|
3906
|
+
const termW = process.stdout.columns || 60;
|
|
3907
|
+
const boxW = Math.min(termW - 2, 54);
|
|
3908
|
+
const W = boxW - 4;
|
|
3909
|
+
|
|
3910
|
+
const top = `\u250c${'\u2500'.repeat(boxW - 2)}\u2510`;
|
|
3911
|
+
const sep = `\u251c${'\u2500'.repeat(boxW - 2)}\u2524`;
|
|
3912
|
+
const bot = `\u2514${'\u2500'.repeat(boxW - 2)}\u2518`;
|
|
3913
|
+
|
|
3914
|
+
const padLine = (s) => {
|
|
3915
|
+
const plain = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
3916
|
+
return `\u2502 ${s}${ ' '.repeat(Math.max(0, W - plain.length))} \u2502`;
|
|
3917
|
+
};
|
|
3918
|
+
|
|
3919
|
+
const filesLabel = changes.files.length <= 3
|
|
3920
|
+
? changes.files.join(', ')
|
|
3921
|
+
: `${changes.files.slice(0, 3).join(', ')} +${changes.files.length - 3} more`;
|
|
3922
|
+
const fileCountLabel = `${changes.files.length} file${changes.files.length === 1 ? '' : 's'} changed: ${filesLabel}`;
|
|
3923
|
+
const fileLineTrunc = fileCountLabel.length > W ? fileCountLabel.slice(0, W - 3) + '...' : fileCountLabel;
|
|
3924
|
+
|
|
3925
|
+
const actLine1 = '[Enter] Commit [e] Edit message [d] Full diff';
|
|
3926
|
+
const actLine2 = '[s] Skip';
|
|
3927
|
+
|
|
3928
|
+
const printCard = (msg) => {
|
|
3929
|
+
const msgLine = msg.length > W ? msg.slice(0, W - 3) + '...' : msg;
|
|
3930
|
+
process.stdout.write(top + '\n');
|
|
3931
|
+
process.stdout.write(padLine('\x1b[33m\u{1F4DD} Ready to commit?\x1b[0m') + '\n');
|
|
3932
|
+
process.stdout.write(sep + '\n');
|
|
3933
|
+
process.stdout.write(padLine(msgLine) + '\n');
|
|
3934
|
+
process.stdout.write(padLine('') + '\n');
|
|
3935
|
+
process.stdout.write(padLine(fileLineTrunc) + '\n');
|
|
3936
|
+
process.stdout.write(padLine('') + '\n');
|
|
3937
|
+
process.stdout.write(padLine(actLine1) + '\n');
|
|
3938
|
+
process.stdout.write(padLine(actLine2) + '\n');
|
|
3939
|
+
process.stdout.write(bot + '\n');
|
|
3940
|
+
};
|
|
3941
|
+
|
|
3942
|
+
const readlinemod = await import('node:readline');
|
|
3943
|
+
readlinemod.emitKeypressEvents(process.stdin);
|
|
3944
|
+
|
|
3945
|
+
const waitKey = () => new Promise((resolve) => {
|
|
3946
|
+
const wasRaw = process.stdin.isRaw;
|
|
3947
|
+
const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
|
|
3948
|
+
if (canRaw) process.stdin.setRawMode(true);
|
|
3949
|
+
|
|
3950
|
+
const cleanup = () => {
|
|
3951
|
+
process.stdin.removeListener('keypress', onKey);
|
|
3952
|
+
if (canRaw) { try { process.stdin.setRawMode(wasRaw || false); } catch {} }
|
|
3953
|
+
};
|
|
3954
|
+
|
|
3955
|
+
const onKey = (str, key) => {
|
|
3956
|
+
if (!key) return;
|
|
3957
|
+
const name = key.name || '';
|
|
3958
|
+
const seq = key.sequence || str || '';
|
|
3959
|
+
|
|
3960
|
+
if (key.ctrl && (name === 'c' || name === 'd')) {
|
|
3961
|
+
cleanup(); process.stdout.write('\n'); resolve('s'); return;
|
|
3962
|
+
}
|
|
3963
|
+
if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
|
|
3964
|
+
cleanup(); process.stdout.write('\n'); resolve('commit'); return;
|
|
3965
|
+
}
|
|
3966
|
+
if (!str || str.length === 0) return;
|
|
3967
|
+
const lower = str.toLowerCase();
|
|
3968
|
+
if (lower === 'e' || lower === 'd' || lower === 's') {
|
|
3969
|
+
cleanup(); process.stdout.write('\n'); resolve(lower); return;
|
|
3970
|
+
}
|
|
3971
|
+
};
|
|
3972
|
+
|
|
3973
|
+
process.stdin.on('keypress', onKey);
|
|
3974
|
+
});
|
|
3975
|
+
|
|
3976
|
+
process.stdout.write('\n');
|
|
3977
|
+
printCard(finalMsg);
|
|
3978
|
+
|
|
3979
|
+
let committed = false;
|
|
3980
|
+
let done = false;
|
|
3981
|
+
|
|
3982
|
+
while (!done) {
|
|
3983
|
+
const choice = await waitKey();
|
|
3984
|
+
|
|
3985
|
+
if (choice === 'commit') {
|
|
3986
|
+
try {
|
|
3987
|
+
execSync('git add -A', { cwd, stdio: 'pipe' });
|
|
3988
|
+
execSync(`git commit -m ${JSON.stringify(finalMsg)}`, { cwd, stdio: 'pipe' });
|
|
3989
|
+
process.stdout.write(`\n \x1b[32m\u2713 Committed:\x1b[0m ${finalMsg}\n\n`);
|
|
3990
|
+
committed = true;
|
|
3991
|
+
} catch (e) {
|
|
3992
|
+
process.stderr.write(` Commit failed: ${e.message}\n`);
|
|
3993
|
+
}
|
|
3994
|
+
done = true;
|
|
3995
|
+
|
|
3996
|
+
} else if (choice === 'e') {
|
|
3997
|
+
const rl2 = createInterface({ input: process.stdin, output: process.stdout });
|
|
3998
|
+
const edited = await new Promise(res => rl2.question('\n Commit message: ', res));
|
|
3999
|
+
rl2.close();
|
|
4000
|
+
if (edited.trim()) finalMsg = edited.trim();
|
|
4001
|
+
process.stdout.write('\n');
|
|
4002
|
+
printCard(finalMsg);
|
|
4003
|
+
|
|
4004
|
+
} else if (choice === 'd') {
|
|
4005
|
+
process.stdout.write('\n');
|
|
4006
|
+
try {
|
|
4007
|
+
const fullDiff = execSync('git diff HEAD', { cwd, encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
|
|
4008
|
+
process.stdout.write(fullDiff || '(no diff output)\n');
|
|
4009
|
+
} catch { process.stdout.write('(could not read diff)\n'); }
|
|
4010
|
+
process.stdout.write('\n');
|
|
4011
|
+
printCard(finalMsg);
|
|
4012
|
+
|
|
4013
|
+
} else {
|
|
4014
|
+
process.stdout.write(' Skipped.\n\n');
|
|
4015
|
+
done = true;
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
return committed;
|
|
4020
|
+
}
|
|
4021
|
+
|
|
2533
4022
|
// ─── Screen state machine ─────────────────────────────────────────────────────
|
|
2534
4023
|
|
|
2535
4024
|
const SCREENS = {
|
|
@@ -2537,6 +4026,8 @@ const SCREENS = {
|
|
|
2537
4026
|
main: mainScreen,
|
|
2538
4027
|
'new-session': newSessionScreen,
|
|
2539
4028
|
settings: settingsScreen,
|
|
4029
|
+
'import-picker': importPickerScreen,
|
|
4030
|
+
'pr-triage': prTriageScreen,
|
|
2540
4031
|
subscriptions: subscriptionsScreen,
|
|
2541
4032
|
dashboard: dashboardScreen,
|
|
2542
4033
|
auth: authScreen,
|
|
@@ -2555,13 +4046,26 @@ async function runScreens(startScreen = 'dashboard') {
|
|
|
2555
4046
|
let current = startScreen;
|
|
2556
4047
|
let ctx = {};
|
|
2557
4048
|
while (current && current !== 'exit') {
|
|
4049
|
+
// Handle type-to-start dispatch from mainScreen — all work routes through pipeline.
|
|
4050
|
+
if (current === 'go' && ctx.prompt) {
|
|
4051
|
+
const prompt = ctx.prompt;
|
|
4052
|
+
const dryRun = ctx.dryRun || false;
|
|
4053
|
+
await cmdGo([prompt], { dryRun });
|
|
4054
|
+
current = 'main';
|
|
4055
|
+
ctx = {};
|
|
4056
|
+
continue;
|
|
4057
|
+
}
|
|
4058
|
+
|
|
2558
4059
|
const screen = SCREENS[current];
|
|
2559
4060
|
if (!screen) break;
|
|
2560
4061
|
try {
|
|
2561
4062
|
const result = await screen(rl, ask, ctx);
|
|
2562
4063
|
current = result?.next || 'exit';
|
|
2563
|
-
// Pass through context (e.g. selected session) to next screen
|
|
2564
|
-
ctx = result?.session
|
|
4064
|
+
// Pass through context (e.g. selected session, typed prompt, openPRs) to next screen
|
|
4065
|
+
ctx = result?.session ? { session: result.session }
|
|
4066
|
+
: result?.prompt ? { prompt: result.prompt }
|
|
4067
|
+
: result?.openPRs ? { openPRs: result.openPRs }
|
|
4068
|
+
: {};
|
|
2565
4069
|
} catch (e) {
|
|
2566
4070
|
console.error(`Error: ${e.message}`);
|
|
2567
4071
|
current = 'main';
|
|
@@ -2571,6 +4075,309 @@ async function runScreens(startScreen = 'dashboard') {
|
|
|
2571
4075
|
rl.close();
|
|
2572
4076
|
}
|
|
2573
4077
|
|
|
4078
|
+
|
|
4079
|
+
// ─── Watch mode ──────────────────────────────────────────────────────────────
|
|
4080
|
+
|
|
4081
|
+
/**
|
|
4082
|
+
* Suggest an action for a batch of changed files.
|
|
4083
|
+
* Returns { label, cmd, safe } or null (no suggestion needed).
|
|
4084
|
+
* Deterministic — no AI calls.
|
|
4085
|
+
*/
|
|
4086
|
+
function suggestAction(changedFiles, cwd) {
|
|
4087
|
+
// .env changes — highest priority warning
|
|
4088
|
+
const envChanged = changedFiles.some(f => {
|
|
4089
|
+
const b = basename(f);
|
|
4090
|
+
return b === '.env' || b.startsWith('.env.');
|
|
4091
|
+
});
|
|
4092
|
+
if (envChanged) {
|
|
4093
|
+
return { label: '⚠ Environment changed — restart services', cmd: null, safe: false };
|
|
4094
|
+
}
|
|
4095
|
+
|
|
4096
|
+
// package.json → npm install
|
|
4097
|
+
if (changedFiles.some(f => basename(f) === 'package.json')) {
|
|
4098
|
+
return { label: 'npm install (dependencies may have changed)', cmd: 'npm install', safe: true };
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
// Config files → restart dev server
|
|
4102
|
+
const configChanged = changedFiles.some(f => {
|
|
4103
|
+
const b = basename(f);
|
|
4104
|
+
return /\.config\.(m?js|ts|cjs|json)$/.test(b)
|
|
4105
|
+
|| b === 'tsconfig.json'
|
|
4106
|
+
|| b === '.eslintrc'
|
|
4107
|
+
|| b === '.babelrc'
|
|
4108
|
+
|| b === 'vite.config.js'
|
|
4109
|
+
|| b === 'webpack.config.js';
|
|
4110
|
+
});
|
|
4111
|
+
if (configChanged) {
|
|
4112
|
+
return { label: 'Restart dev server (config changed)', cmd: null, safe: false };
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
// Test/spec files themselves changed → run them
|
|
4116
|
+
const testChanged = changedFiles.filter(f => /\.(test|spec)\.(m?js|ts|cjs)$/.test(f));
|
|
4117
|
+
if (testChanged.length > 0) {
|
|
4118
|
+
let testCmd = 'npm test';
|
|
4119
|
+
try {
|
|
4120
|
+
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
|
|
4121
|
+
if (!pkg.scripts?.test) testCmd = null;
|
|
4122
|
+
} catch {}
|
|
4123
|
+
const fileList = testChanged.map(f => basename(f)).join(', ');
|
|
4124
|
+
return testCmd ? { label: `Run tests: ${fileList}`, cmd: testCmd, safe: true } : null;
|
|
4125
|
+
}
|
|
4126
|
+
|
|
4127
|
+
// Markdown → no suggestion
|
|
4128
|
+
if (changedFiles.every(f => extname(f) === '.md')) {
|
|
4129
|
+
return null;
|
|
4130
|
+
}
|
|
4131
|
+
|
|
4132
|
+
// Source file changed → look for related test file
|
|
4133
|
+
const sourceChanged = changedFiles.filter(f =>
|
|
4134
|
+
/\.(m?js|ts|cjs|py|rb|go|rs)$/.test(f) && !/\.(test|spec)\./.test(f)
|
|
4135
|
+
);
|
|
4136
|
+
if (sourceChanged.length > 0) {
|
|
4137
|
+
const testDirs = ['test', 'tests', '__tests__', 'spec', 'src'];
|
|
4138
|
+
for (const srcFile of sourceChanged) {
|
|
4139
|
+
const srcBase = basename(srcFile);
|
|
4140
|
+
const srcExt = extname(srcFile);
|
|
4141
|
+
const srcStem = srcBase.slice(0, -srcExt.length);
|
|
4142
|
+
const testExts = [...new Set([srcExt, '.js', '.ts', '.mjs'])];
|
|
4143
|
+
const srcDirAbs = join(cwd, dirname(srcFile));
|
|
4144
|
+
|
|
4145
|
+
for (const dir of testDirs) {
|
|
4146
|
+
for (const ext of testExts) {
|
|
4147
|
+
const candidates = [
|
|
4148
|
+
join(cwd, dir, `${srcStem}.test${ext}`),
|
|
4149
|
+
join(cwd, dir, `${srcStem}.spec${ext}`),
|
|
4150
|
+
join(srcDirAbs, `${srcStem}.test${ext}`),
|
|
4151
|
+
join(srcDirAbs, `${srcStem}.spec${ext}`),
|
|
4152
|
+
];
|
|
4153
|
+
for (const c of candidates) {
|
|
4154
|
+
if (existsSync(c)) {
|
|
4155
|
+
const rel = c.replace(cwd + '/', '');
|
|
4156
|
+
let testCmd = 'npm test';
|
|
4157
|
+
try {
|
|
4158
|
+
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
|
|
4159
|
+
const scripts = pkg.scripts?.test ?? '';
|
|
4160
|
+
const dev = { ...pkg.devDependencies, ...pkg.dependencies };
|
|
4161
|
+
if (scripts.includes('jest') || dev.jest) testCmd = `npx jest ${rel}`;
|
|
4162
|
+
else if (scripts.includes('vitest') || dev.vitest) testCmd = `npx vitest run ${rel}`;
|
|
4163
|
+
else if (scripts.includes('mocha') || dev.mocha) testCmd = `npx mocha ${rel}`;
|
|
4164
|
+
} catch {}
|
|
4165
|
+
return { label: `Run related tests: ${rel}`, cmd: testCmd, safe: true };
|
|
4166
|
+
}
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
// No test file found — suggest generic test run
|
|
4173
|
+
let testCmd = 'npm test';
|
|
4174
|
+
try {
|
|
4175
|
+
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
|
|
4176
|
+
if (!pkg.scripts?.test) testCmd = null;
|
|
4177
|
+
} catch { testCmd = null; }
|
|
4178
|
+
|
|
4179
|
+
if (testCmd) {
|
|
4180
|
+
const fileList = sourceChanged.map(f => basename(f)).join(', ');
|
|
4181
|
+
return { label: `Run tests (${fileList} changed)`, cmd: testCmd, safe: true };
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4184
|
+
|
|
4185
|
+
return null;
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
const W_RESET = '\x1b[0m';
|
|
4189
|
+
const W_BOLD = '\x1b[1m';
|
|
4190
|
+
const W_DIM = '\x1b[2m';
|
|
4191
|
+
const W_YELLOW = '\x1b[33m';
|
|
4192
|
+
const W_CYAN = '\x1b[36m';
|
|
4193
|
+
const W_GREEN = '\x1b[32m';
|
|
4194
|
+
const W_RED = '\x1b[31m';
|
|
4195
|
+
|
|
4196
|
+
function watchRedraw(header, logLines, prompt) {
|
|
4197
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
4198
|
+
process.stdout.write(header + '\n\n');
|
|
4199
|
+
const visible = logLines.slice(-8);
|
|
4200
|
+
for (let i = 0; i < visible.length; i++) {
|
|
4201
|
+
const dim = i < visible.length - 4;
|
|
4202
|
+
if (dim) process.stdout.write(W_DIM);
|
|
4203
|
+
process.stdout.write(visible[i] + '\n');
|
|
4204
|
+
if (dim) process.stdout.write(W_RESET);
|
|
4205
|
+
}
|
|
4206
|
+
if (prompt) process.stdout.write('\n' + prompt);
|
|
4207
|
+
}
|
|
4208
|
+
|
|
4209
|
+
async function cmdWatch(rawArgs) {
|
|
4210
|
+
const cwd = process.cwd();
|
|
4211
|
+
const auto = rawArgs.includes('--auto');
|
|
4212
|
+
const dirArg = rawArgs.find(a => !a.startsWith('-')) ?? '.';
|
|
4213
|
+
const watchDir = join(cwd, dirArg);
|
|
4214
|
+
|
|
4215
|
+
if (!existsSync(watchDir)) {
|
|
4216
|
+
process.stderr.write(`Error: Directory not found: ${watchDir}\n`);
|
|
4217
|
+
process.exit(1);
|
|
4218
|
+
}
|
|
4219
|
+
|
|
4220
|
+
const relDir = watchDir === cwd ? '.' : watchDir.replace(cwd + '/', '');
|
|
4221
|
+
const modeStr = auto ? `${W_YELLOW}--auto${W_RESET}` : 'interactive';
|
|
4222
|
+
const header = `${W_BOLD}${W_CYAN}Watching${W_RESET} ${relDir} ${W_DIM}(${modeStr}${W_DIM}, q or Ctrl+C to exit)${W_RESET}`;
|
|
4223
|
+
|
|
4224
|
+
const logLines = [];
|
|
4225
|
+
function addLog(line) {
|
|
4226
|
+
const ts = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
4227
|
+
logLines.push(`${W_DIM}${ts}${W_RESET} ${line}`);
|
|
4228
|
+
}
|
|
4229
|
+
|
|
4230
|
+
addLog(`${W_DIM}Ready — waiting for file changes...${W_RESET}`);
|
|
4231
|
+
watchRedraw(header, logLines);
|
|
4232
|
+
|
|
4233
|
+
let resolvePending = null;
|
|
4234
|
+
let watcherRef = null;
|
|
4235
|
+
|
|
4236
|
+
function cleanup() {
|
|
4237
|
+
try { if (watcherRef) watcherRef.close(); } catch {}
|
|
4238
|
+
try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
|
|
4239
|
+
try { watchRl.close(); } catch {}
|
|
4240
|
+
process.stdout.write('\n');
|
|
4241
|
+
process.exit(0);
|
|
4242
|
+
}
|
|
4243
|
+
|
|
4244
|
+
const watchRl = createInterface({ input: process.stdin, output: process.stdout });
|
|
4245
|
+
if (process.stdin.isTTY) {
|
|
4246
|
+
process.stdin.setRawMode(true);
|
|
4247
|
+
process.stdin.resume();
|
|
4248
|
+
process.stdin.setEncoding('utf8');
|
|
4249
|
+
}
|
|
4250
|
+
|
|
4251
|
+
process.stdin.on('data', (key) => {
|
|
4252
|
+
if (key === 'q' || key === '') { cleanup(); return; }
|
|
4253
|
+
if (resolvePending) { resolvePending(key); resolvePending = null; }
|
|
4254
|
+
});
|
|
4255
|
+
|
|
4256
|
+
process.on('SIGINT', cleanup);
|
|
4257
|
+
process.on('SIGTERM', cleanup);
|
|
4258
|
+
|
|
4259
|
+
function waitForKey() {
|
|
4260
|
+
return new Promise(resolve => { resolvePending = resolve; });
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4263
|
+
let processing = false;
|
|
4264
|
+
async function processBatch(files) {
|
|
4265
|
+
if (processing) return;
|
|
4266
|
+
processing = true;
|
|
4267
|
+
try {
|
|
4268
|
+
const fileList = [...files];
|
|
4269
|
+
files.clear();
|
|
4270
|
+
const relFiles = fileList.map(f =>
|
|
4271
|
+
f.replace(cwd + '/', '').replace(cwd + '\\', '')
|
|
4272
|
+
);
|
|
4273
|
+
|
|
4274
|
+
for (const f of relFiles) addLog(` ${W_CYAN}${f}${W_RESET} saved`);
|
|
4275
|
+
|
|
4276
|
+
const suggestion = suggestAction(relFiles, cwd);
|
|
4277
|
+
|
|
4278
|
+
if (!suggestion) {
|
|
4279
|
+
addLog(` ${W_DIM}(no action suggested)${W_RESET}`);
|
|
4280
|
+
watchRedraw(header, logLines);
|
|
4281
|
+
return;
|
|
4282
|
+
}
|
|
4283
|
+
|
|
4284
|
+
addLog(` ${W_YELLOW}Suggestion:${W_RESET} ${suggestion.label}`);
|
|
4285
|
+
|
|
4286
|
+
if (auto) {
|
|
4287
|
+
if (!suggestion.safe || !suggestion.cmd) {
|
|
4288
|
+
addLog(` ${W_DIM}[auto] Skipping — not auto-safe${W_RESET}`);
|
|
4289
|
+
watchRedraw(header, logLines);
|
|
4290
|
+
return;
|
|
4291
|
+
}
|
|
4292
|
+
addLog(` ${W_GREEN}[auto] Running:${W_RESET} ${suggestion.cmd}`);
|
|
4293
|
+
watchRedraw(header, logLines);
|
|
4294
|
+
try {
|
|
4295
|
+
const out = execSync(suggestion.cmd, { cwd, encoding: 'utf8', stdio: 'pipe', timeout: 60000 });
|
|
4296
|
+
for (const l of out.trim().split('\n').slice(-5)) addLog(` ${W_DIM}${l}${W_RESET}`);
|
|
4297
|
+
addLog(` ${W_GREEN}done${W_RESET}`);
|
|
4298
|
+
} catch (e) {
|
|
4299
|
+
const msg = (e.stderr || e.stdout || e.message || '').trim();
|
|
4300
|
+
for (const l of msg.split('\n').slice(-3)) addLog(` ${W_RED}${l}${W_RESET}`);
|
|
4301
|
+
addLog(` ${W_RED}command failed${W_RESET}`);
|
|
4302
|
+
}
|
|
4303
|
+
watchRedraw(header, logLines);
|
|
4304
|
+
return;
|
|
4305
|
+
}
|
|
4306
|
+
|
|
4307
|
+
// Interactive prompt
|
|
4308
|
+
const promptLine = suggestion.cmd
|
|
4309
|
+
? ` ${W_BOLD}[Enter]${W_RESET} Run ${W_BOLD}[s]${W_RESET} Skip ${W_BOLD}[q]${W_RESET} Quit\n > `
|
|
4310
|
+
: ` ${W_BOLD}[s]${W_RESET} Dismiss ${W_BOLD}[q]${W_RESET} Quit\n > `;
|
|
4311
|
+
watchRedraw(header, logLines, promptLine);
|
|
4312
|
+
|
|
4313
|
+
const key = await waitForKey();
|
|
4314
|
+
|
|
4315
|
+
if (key === 'q' || key === '') { cleanup(); return; }
|
|
4316
|
+
|
|
4317
|
+
if ((key === '\r' || key === '\n' || key === ' ') && suggestion.cmd) {
|
|
4318
|
+
addLog(` ${W_GREEN}Running:${W_RESET} ${suggestion.cmd}`);
|
|
4319
|
+
watchRedraw(header, logLines);
|
|
4320
|
+
try {
|
|
4321
|
+
const out = execSync(suggestion.cmd, { cwd, encoding: 'utf8', stdio: 'pipe', timeout: 60000 });
|
|
4322
|
+
for (const l of out.trim().split('\n').slice(-8)) addLog(` ${W_DIM}${l}${W_RESET}`);
|
|
4323
|
+
addLog(` ${W_GREEN}done${W_RESET}`);
|
|
4324
|
+
} catch (e) {
|
|
4325
|
+
const msg = (e.stderr || e.stdout || e.message || '').trim();
|
|
4326
|
+
for (const l of msg.split('\n').slice(-5)) addLog(` ${W_RED}${l}${W_RESET}`);
|
|
4327
|
+
addLog(` ${W_RED}command failed${W_RESET}`);
|
|
4328
|
+
}
|
|
4329
|
+
} else {
|
|
4330
|
+
addLog(` ${W_DIM}skipped${W_RESET}`);
|
|
4331
|
+
}
|
|
4332
|
+
watchRedraw(header, logLines);
|
|
4333
|
+
} finally {
|
|
4334
|
+
processing = false;
|
|
4335
|
+
}
|
|
4336
|
+
}
|
|
4337
|
+
|
|
4338
|
+
let debounceTimer = null;
|
|
4339
|
+
const pendingFiles = new Set();
|
|
4340
|
+
|
|
4341
|
+
try {
|
|
4342
|
+
watcherRef = fsWatch(watchDir, { recursive: true }, (_eventType, filename) => {
|
|
4343
|
+
if (!filename) return;
|
|
4344
|
+
if (
|
|
4345
|
+
filename.includes('node_modules') ||
|
|
4346
|
+
filename.includes('.git') ||
|
|
4347
|
+
filename.includes('.dualbrain') ||
|
|
4348
|
+
/package-lock\.json$/.test(filename) ||
|
|
4349
|
+
/yarn\.lock$/.test(filename) ||
|
|
4350
|
+
/pnpm-lock\.yaml$/.test(filename)
|
|
4351
|
+
) return;
|
|
4352
|
+
|
|
4353
|
+
pendingFiles.add(join(watchDir, filename));
|
|
4354
|
+
|
|
4355
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
4356
|
+
debounceTimer = setTimeout(() => {
|
|
4357
|
+
debounceTimer = null;
|
|
4358
|
+
processBatch(pendingFiles).catch(e => {
|
|
4359
|
+
addLog(` ${W_RED}Watch error: ${e.message}${W_RESET}`);
|
|
4360
|
+
watchRedraw(header, logLines);
|
|
4361
|
+
});
|
|
4362
|
+
}, 2000);
|
|
4363
|
+
});
|
|
4364
|
+
} catch (e) {
|
|
4365
|
+
if (e.code === 'ENOSPC') {
|
|
4366
|
+
process.stderr.write(
|
|
4367
|
+
'\nError: Too many file watchers (ENOSPC).\n' +
|
|
4368
|
+
'Increase the limit:\n' +
|
|
4369
|
+
' echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p\n'
|
|
4370
|
+
);
|
|
4371
|
+
process.exit(1);
|
|
4372
|
+
}
|
|
4373
|
+
throw e;
|
|
4374
|
+
}
|
|
4375
|
+
|
|
4376
|
+
// Keep alive — stdin events drive everything, cleanup() calls process.exit
|
|
4377
|
+
await new Promise(() => {});
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
|
|
2574
4381
|
// ─── Specialist commands ──────────────────────────────────────────────────────
|
|
2575
4382
|
|
|
2576
4383
|
const SPECIALIST_DEFAULTS = {
|
|
@@ -2666,22 +4473,25 @@ async function cmdSpecialistGo(specialist, args) {
|
|
|
2666
4473
|
vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'}`);
|
|
2667
4474
|
}
|
|
2668
4475
|
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
4476
|
+
// Print routing table (only in dry-run or verbose; silent in normal mode)
|
|
4477
|
+
if (dryRun || verbose) {
|
|
4478
|
+
console.log(` specialist : ${specialist}`);
|
|
4479
|
+
console.log(` provider : ${decision.provider}`);
|
|
4480
|
+
console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
|
|
4481
|
+
console.log(` tier : ${decision.tier}`);
|
|
4482
|
+
console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
|
|
4483
|
+
console.log(` reason : ${decision.explanation}`);
|
|
4484
|
+
}
|
|
2675
4485
|
|
|
2676
4486
|
if (dryRun) {
|
|
2677
4487
|
console.log('\n(dry-run — not executing)');
|
|
2678
4488
|
return;
|
|
2679
4489
|
}
|
|
2680
4490
|
|
|
2681
|
-
console.log('\nDispatching...');
|
|
4491
|
+
if (verbose) console.log('\nDispatching...');
|
|
2682
4492
|
let result;
|
|
2683
4493
|
if (decision.dualBrain) {
|
|
2684
|
-
result = await dispatchDualBrain({ decision, prompt, files, cwd });
|
|
4494
|
+
result = await dispatchDualBrain({ decision, prompt, files, cwd, verbose });
|
|
2685
4495
|
console.log(`\nConsensus: ${result.consensus}`);
|
|
2686
4496
|
if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
|
|
2687
4497
|
if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
|
|
@@ -2695,7 +4505,7 @@ async function cmdSpecialistGo(specialist, args) {
|
|
|
2695
4505
|
nextAction: null,
|
|
2696
4506
|
}, cwd);
|
|
2697
4507
|
} else {
|
|
2698
|
-
result = await dispatch({ decision, prompt, files, cwd });
|
|
4508
|
+
result = await dispatch({ decision, prompt, files, cwd, verbose });
|
|
2699
4509
|
const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
|
|
2700
4510
|
console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
2701
4511
|
if (result.summary) console.log(result.summary);
|
|
@@ -2739,8 +4549,8 @@ async function main() {
|
|
|
2739
4549
|
if (profileExists(cwd)) {
|
|
2740
4550
|
await runScreens('main');
|
|
2741
4551
|
} else {
|
|
2742
|
-
// First run: run the
|
|
2743
|
-
|
|
4552
|
+
// First run: run the onboarding wizard, then go to main.
|
|
4553
|
+
// (wizard handles detection display)
|
|
2744
4554
|
const auth = await detectAuth();
|
|
2745
4555
|
const plans = detectPlans();
|
|
2746
4556
|
const existingSessions = importReplitSessions(cwd);
|
|
@@ -2755,22 +4565,35 @@ async function main() {
|
|
|
2755
4565
|
await runScreens('main');
|
|
2756
4566
|
}
|
|
2757
4567
|
} else {
|
|
2758
|
-
// Non-TTY:
|
|
2759
|
-
const
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
4568
|
+
// Non-TTY with no args: read stdin as a task and run one-shot
|
|
4569
|
+
const stdinTask = await new Promise((resolve) => {
|
|
4570
|
+
let data = '';
|
|
4571
|
+
process.stdin.setEncoding('utf8');
|
|
4572
|
+
process.stdin.on('data', chunk => { data += chunk; });
|
|
4573
|
+
process.stdin.on('end', () => resolve(data.trim()));
|
|
4574
|
+
// If stdin has no data within 200ms (not truly piped), fall back to status card
|
|
4575
|
+
setTimeout(() => resolve(null), 200);
|
|
4576
|
+
});
|
|
4577
|
+
if (stdinTask) {
|
|
4578
|
+
process.stderr.write('🧠 routing...\n');
|
|
4579
|
+
await cmdGo([stdinTask]);
|
|
4580
|
+
} else {
|
|
4581
|
+
const cwd = process.cwd();
|
|
4582
|
+
const repo = loadRepoCache(cwd);
|
|
4583
|
+
const session = loadSession(cwd);
|
|
4584
|
+
const health = getHealth(cwd);
|
|
4585
|
+
const card = formatSessionCard(session, repo, health);
|
|
4586
|
+
console.log(card);
|
|
4587
|
+
}
|
|
2765
4588
|
}
|
|
2766
4589
|
return;
|
|
2767
4590
|
}
|
|
2768
4591
|
|
|
2769
4592
|
if (cmd === 'init') {
|
|
2770
4593
|
if (isInteractive) {
|
|
2771
|
-
// Run
|
|
4594
|
+
// Run onboarding wizard then main screen
|
|
2772
4595
|
const cwd = process.cwd();
|
|
2773
|
-
|
|
4596
|
+
// (wizard handles detection display)
|
|
2774
4597
|
const auth = await detectAuth();
|
|
2775
4598
|
const plans = detectPlans();
|
|
2776
4599
|
const existingSessions = importReplitSessions(cwd);
|
|
@@ -2795,7 +4618,12 @@ async function main() {
|
|
|
2795
4618
|
await cmdAuth(args.slice(1));
|
|
2796
4619
|
return;
|
|
2797
4620
|
}
|
|
4621
|
+
if (cmd === 'plan') { await cmdGo(args.slice(1), { dryRun: true }); return; }
|
|
4622
|
+
if (cmd === 'do') { await cmdGo(args.slice(1)); return; }
|
|
2798
4623
|
if (cmd === 'go') { await cmdGo(args.slice(1)); return; }
|
|
4624
|
+
if (cmd === 'think') { await cmdThink(args.slice(1)); return; }
|
|
4625
|
+
if (cmd === 'review') { await cmdReview(args.slice(1)); return; }
|
|
4626
|
+
if (cmd === 'ship') { await cmdShip(); return; }
|
|
2799
4627
|
if (cmd === 'status') { await cmdStatus(args.slice(1)); return; }
|
|
2800
4628
|
if (cmd === 'hot') { cmdHot(args[1]); return; }
|
|
2801
4629
|
if (cmd === 'cool') { cmdCool(args[1]); return; }
|
|
@@ -2839,6 +4667,8 @@ async function main() {
|
|
|
2839
4667
|
process.exit(0);
|
|
2840
4668
|
}
|
|
2841
4669
|
|
|
4670
|
+
if (cmd === 'watch') { await cmdWatch(args.slice(1)); return; }
|
|
4671
|
+
|
|
2842
4672
|
if (cmd === 'shell-hook') {
|
|
2843
4673
|
// Output a bash snippet users can add to their .bashrc or source directly.
|
|
2844
4674
|
const hook = `
|
|
@@ -2854,6 +4684,43 @@ fi
|
|
|
2854
4684
|
return;
|
|
2855
4685
|
}
|
|
2856
4686
|
|
|
4687
|
+
// ─── One-shot mode ────────────────────────────────────────────────────────────
|
|
4688
|
+
// If cmd is not a recognized subcommand, treat the entire arg list as a task.
|
|
4689
|
+
// e.g. `dual-brain fix failing tests` → same as `dual-brain go "fix failing tests"`
|
|
4690
|
+
const KNOWN_COMMANDS = new Set([
|
|
4691
|
+
'init', 'install', 'auth', 'go', 'do', 'plan', 'ship', 'think', 'review', 'status', 'hot', 'cool',
|
|
4692
|
+
'remember', 'forget', 'break-glass', 'specialists', 'search', 'shell-hook', 'watch',
|
|
4693
|
+
'--help', '-h', '--version', '-v',
|
|
4694
|
+
...Object.keys(loadSpecialistRegistry()),
|
|
4695
|
+
]);
|
|
4696
|
+
|
|
4697
|
+
if (!KNOWN_COMMANDS.has(cmd)) {
|
|
4698
|
+
// All of args are part of the task description (plus any flags like --dry-run/--files).
|
|
4699
|
+
// Join non-flag words into a single prompt string so cmdGo's args.find() picks it up.
|
|
4700
|
+
// We strip out flag values (e.g. the value after --files) before collecting prompt words.
|
|
4701
|
+
process.stderr.write('🧠 routing...\n');
|
|
4702
|
+
const flagValuesToSkip = new Set();
|
|
4703
|
+
const pairedFlags = ['--files'];
|
|
4704
|
+
for (const f of pairedFlags) {
|
|
4705
|
+
const idx = args.indexOf(f);
|
|
4706
|
+
if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--')) {
|
|
4707
|
+
flagValuesToSkip.add(args[idx + 1]);
|
|
4708
|
+
}
|
|
4709
|
+
}
|
|
4710
|
+
const passedFlags = [];
|
|
4711
|
+
for (let i = 0; i < args.length; i++) {
|
|
4712
|
+
if (args[i].startsWith('--') || args[i].startsWith('-')) {
|
|
4713
|
+
passedFlags.push(args[i]);
|
|
4714
|
+
if (pairedFlags.includes(args[i]) && args[i + 1] && !args[i + 1].startsWith('--')) {
|
|
4715
|
+
passedFlags.push(args[++i]);
|
|
4716
|
+
}
|
|
4717
|
+
}
|
|
4718
|
+
}
|
|
4719
|
+
const promptWords = args.filter(a => !a.startsWith('--') && !a.startsWith('-') && !flagValuesToSkip.has(a));
|
|
4720
|
+
await cmdGo([promptWords.join(' '), ...passedFlags]);
|
|
4721
|
+
return;
|
|
4722
|
+
}
|
|
4723
|
+
|
|
2857
4724
|
process.stderr.write(`Unknown command: ${cmd}\nRun "dual-brain --help" for usage.\n`);
|
|
2858
4725
|
process.exit(1);
|
|
2859
4726
|
}
|