dual-brain 7.1.21 → 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 -717
- 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,7 +909,7 @@ 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
915
|
function renderHeader(version, providerLines, dtVersion) {
|
|
@@ -683,16 +923,19 @@ function renderHeader(version, providerLines, dtVersion) {
|
|
|
683
923
|
const sep = ` ├${'─'.repeat(W)}┤`;
|
|
684
924
|
const bottom = ` └${'─'.repeat(W)}┘`;
|
|
685
925
|
|
|
686
|
-
const title =
|
|
687
|
-
const
|
|
688
|
-
|
|
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
|
+
}
|
|
689
933
|
|
|
690
934
|
const lines = [top];
|
|
691
935
|
lines.push(` │ ${pad(title)}│`);
|
|
692
|
-
lines.push(` │ ${pad(subTitle)}│`);
|
|
693
936
|
lines.push(` │ ${pad(credit)}│`);
|
|
694
937
|
lines.push(sep);
|
|
695
|
-
for (const pl of
|
|
938
|
+
for (const pl of allProviderLines) {
|
|
696
939
|
lines.push(` │ ${pad(pl)}│`);
|
|
697
940
|
}
|
|
698
941
|
lines.push(bottom);
|
|
@@ -709,21 +952,21 @@ function profileExists(cwd) {
|
|
|
709
952
|
// ─── Plan label helpers ───────────────────────────────────────────────────────
|
|
710
953
|
|
|
711
954
|
const CLAUDE_PLAN_LABELS = {
|
|
712
|
-
pro: 'Pro
|
|
713
|
-
max5: 'Max x5
|
|
714
|
-
max20: 'Max x20
|
|
715
|
-
'$20': 'Pro
|
|
716
|
-
'$100': 'Max x5
|
|
717
|
-
'$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
|
|
718
961
|
};
|
|
719
962
|
const OPENAI_PLAN_LABELS = {
|
|
720
|
-
plus: 'Plus
|
|
721
|
-
pro: 'Pro
|
|
722
|
-
pro100: 'Pro
|
|
723
|
-
pro200: 'Pro (
|
|
724
|
-
'$20': 'Plus
|
|
725
|
-
'$100': 'Pro
|
|
726
|
-
'$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
|
|
727
970
|
};
|
|
728
971
|
|
|
729
972
|
// ─── Screen: welcomeScreen ────────────────────────────────────────────────────
|
|
@@ -809,7 +1052,7 @@ async function welcomeScreen(rl, ask) {
|
|
|
809
1052
|
}
|
|
810
1053
|
|
|
811
1054
|
console.log(' [Enter] Save and go');
|
|
812
|
-
console.log(' [c] Customize
|
|
1055
|
+
console.log(' [c] Customize work style');
|
|
813
1056
|
if (existingSessions.length > 0) {
|
|
814
1057
|
console.log(` [i] Import ${existingSessions.length} session${existingSessions.length !== 1 ? 's' : ''} from data-tools`);
|
|
815
1058
|
}
|
|
@@ -872,10 +1115,10 @@ async function welcomeScreen(rl, ask) {
|
|
|
872
1115
|
// Claude plan picker
|
|
873
1116
|
if (claudeReady) {
|
|
874
1117
|
console.log('');
|
|
875
|
-
console.log(separator('Claude
|
|
876
|
-
console.log(' (1) Pro
|
|
877
|
-
console.log(' (2) Max x5
|
|
878
|
-
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');
|
|
879
1122
|
console.log(' (4) Skip');
|
|
880
1123
|
const claudeChoice = (await ask('> ')).trim();
|
|
881
1124
|
const claudePlanMap = { '1': 'pro', '2': 'max5', '3': 'max20' };
|
|
@@ -892,10 +1135,10 @@ async function welcomeScreen(rl, ask) {
|
|
|
892
1135
|
// OpenAI plan picker
|
|
893
1136
|
if (openaiReady) {
|
|
894
1137
|
console.log('');
|
|
895
|
-
console.log(separator('OpenAI
|
|
896
|
-
console.log(' (1) Plus
|
|
897
|
-
console.log(' (2) Pro
|
|
898
|
-
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)');
|
|
899
1142
|
console.log(' (4) Skip');
|
|
900
1143
|
const openaiChoice = (await ask('> ')).trim();
|
|
901
1144
|
const openaiPlanMap = { '1': 'plus', '2': 'pro', '3': 'pro200' };
|
|
@@ -920,8 +1163,8 @@ async function welcomeScreen(rl, ask) {
|
|
|
920
1163
|
|
|
921
1164
|
// Team setup
|
|
922
1165
|
console.log('');
|
|
923
|
-
console.log(' Team auth: label
|
|
924
|
-
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.');
|
|
925
1168
|
console.log('');
|
|
926
1169
|
console.log(' [Enter] Skip [t] Set up team auth');
|
|
927
1170
|
const teamChoice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
@@ -929,7 +1172,7 @@ async function welcomeScreen(rl, ask) {
|
|
|
929
1172
|
for (const provider of ['claude', 'openai']) {
|
|
930
1173
|
if (!existingProfile.providers[provider]?.enabled) continue;
|
|
931
1174
|
const provLabel = provider === 'claude' ? 'Claude' : 'OpenAI';
|
|
932
|
-
const label = (await ask(` ${provLabel} label (e.g. "Josh's
|
|
1175
|
+
const label = (await ask(` ${provLabel} label (e.g. "Josh's work account"): `)).trim();
|
|
933
1176
|
if (label) existingProfile.providers[provider].label = label;
|
|
934
1177
|
const expiry = await askExpiry(ask, provLabel);
|
|
935
1178
|
if (expiry) existingProfile.providers[provider].expiresAt = expiry;
|
|
@@ -995,115 +1238,344 @@ function loadTerminalState(cwd, terminalId) {
|
|
|
995
1238
|
} catch { return null; }
|
|
996
1239
|
}
|
|
997
1240
|
|
|
998
|
-
// ───
|
|
1241
|
+
// ─── PR Detection ─────────────────────────────────────────────────────────────
|
|
999
1242
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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 [];
|
|
1005
1259
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
+
});
|
|
1010
1283
|
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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 {}
|
|
1015
1315
|
|
|
1016
|
-
|
|
1017
|
-
|
|
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 {}
|
|
1018
1323
|
|
|
1019
|
-
|
|
1020
|
-
const
|
|
1021
|
-
if (
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
+
}
|
|
1027
1335
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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'}`);
|
|
1031
1360
|
}
|
|
1032
1361
|
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
];
|
|
1362
|
+
if (repoState.lastFailure !== null) {
|
|
1363
|
+
cards.push(`${RED}⚡${RESET} Last task failed: ${repoState.lastFailure}`);
|
|
1364
|
+
}
|
|
1037
1365
|
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
if (dtVersion) {
|
|
1041
|
-
console.log(`📦 DATA Tools v${dtVersion}`);
|
|
1042
|
-
console.log(`🧠 Dual Brain v${version}`);
|
|
1043
|
-
} else {
|
|
1044
|
-
console.log(`📦 DATA Tools`);
|
|
1045
|
-
console.log(`🧠 Dual Brain v${version}`);
|
|
1366
|
+
if (repoState.lastCommitAgeDays >= 3) {
|
|
1367
|
+
cards.push(`${YELLOW}⚡${RESET} ${repoState.lastCommitAgeDays} day${repoState.lastCommitAgeDays === 1 ? '' : 's'} since last commit`);
|
|
1046
1368
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1369
|
+
|
|
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}`);
|
|
1051
1377
|
}
|
|
1052
|
-
console.log('');
|
|
1053
1378
|
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
console.log(` ${line}`);
|
|
1379
|
+
if (cards.length === 0) {
|
|
1380
|
+
return [rowFn(`${DIM}${GREEN}✓${RESET}${DIM} Repo clean${RESET}`)];
|
|
1057
1381
|
}
|
|
1058
1382
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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;
|
|
1062
1454
|
}
|
|
1063
1455
|
|
|
1064
|
-
//
|
|
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
|
|
1065
1556
|
try {
|
|
1066
1557
|
const { autoRefreshToken } = await import('../src/profile.mjs');
|
|
1067
|
-
|
|
1068
|
-
if (refreshResult.status === 'refreshed') {
|
|
1069
|
-
console.log(` 🔄 Token auto-refreshed (${refreshResult.hoursRemaining}h remaining)`);
|
|
1070
|
-
}
|
|
1558
|
+
await autoRefreshToken(cwd);
|
|
1071
1559
|
} catch {}
|
|
1072
1560
|
|
|
1073
|
-
// Append-only session archive sync
|
|
1561
|
+
// Append-only session archive sync
|
|
1074
1562
|
try {
|
|
1075
1563
|
const { syncSessionMirror } = await import('../src/session.mjs');
|
|
1076
|
-
|
|
1077
|
-
if (mirror.copied > 0 || mirror.grew > 0) {
|
|
1078
|
-
console.log(` ✅ Archive mirror: +${mirror.copied} new, ${mirror.grew} updated`);
|
|
1079
|
-
}
|
|
1564
|
+
syncSessionMirror(cwd);
|
|
1080
1565
|
} catch {}
|
|
1081
1566
|
|
|
1082
1567
|
// Auto-refresh expired subscriptions
|
|
1083
1568
|
if (claudeExpired || openaiExpired) {
|
|
1084
1569
|
const { spawnSync } = await import('node:child_process');
|
|
1085
|
-
const expired = [];
|
|
1086
|
-
if (claudeExpired) expired.push('Claude');
|
|
1087
|
-
if (openaiExpired) expired.push('OpenAI');
|
|
1088
|
-
console.log(`\n ${expired.join(' & ')} subscription expired. Re-authenticating...`);
|
|
1089
1570
|
if (claudeExpired) {
|
|
1090
1571
|
const r = spawnSync('claude', ['auth', 'login'], { stdio: 'inherit', timeout: 30000 });
|
|
1091
|
-
if (r.status === 0) {
|
|
1092
|
-
claudeSub.expiresAt = null;
|
|
1093
|
-
saveProfile(profile, { cwd });
|
|
1094
|
-
console.log(' ✓ Claude re-authenticated');
|
|
1095
|
-
}
|
|
1572
|
+
if (r.status === 0) { claudeSub.expiresAt = null; saveProfile(profile, { cwd }); }
|
|
1096
1573
|
}
|
|
1097
1574
|
if (openaiExpired) {
|
|
1098
1575
|
const r = spawnSync('codex', ['login'], { stdio: 'inherit', timeout: 30000 });
|
|
1099
|
-
if (r.status === 0) {
|
|
1100
|
-
openaiSub.expiresAt = null;
|
|
1101
|
-
saveProfile(profile, { cwd });
|
|
1102
|
-
console.log(' ✓ OpenAI re-authenticated');
|
|
1103
|
-
}
|
|
1576
|
+
if (r.status === 0) { openaiSub.expiresAt = null; saveProfile(profile, { cwd }); }
|
|
1104
1577
|
}
|
|
1105
1578
|
}
|
|
1106
|
-
console.log('');
|
|
1107
1579
|
|
|
1108
1580
|
// Build session index in background (powers search + smart resume)
|
|
1109
1581
|
try {
|
|
@@ -1111,383 +1583,1124 @@ async function mainScreen(rl, ask) {
|
|
|
1111
1583
|
buildSessionIndex(cwd);
|
|
1112
1584
|
} catch {}
|
|
1113
1585
|
|
|
1114
|
-
|
|
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;
|
|
1115
1593
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
const pin = sess.pinned ? '📌 ' : ' ';
|
|
1120
|
-
const active = sess.isActive ? ' ●' : '';
|
|
1121
|
-
const cat = sess.category ? ` [${sess.category}]` : '';
|
|
1122
|
-
const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
|
|
1123
|
-
// If the name is still the "Session XXXXXXXX" fallback, try the project path instead
|
|
1124
|
-
let rawName = sess.name || '';
|
|
1125
|
-
if (/^Session [0-9a-f]{8,}$/i.test(rawName)) {
|
|
1126
|
-
rawName = sess.project ? sess.project.replace(/^-/, '/').replace(/-/g, '/') : sess.id.slice(0, 8);
|
|
1127
|
-
}
|
|
1128
|
-
const displayName = rawName.length > 40 ? rawName.slice(0, 37) + '...' : (rawName || sess.id.slice(0, 8));
|
|
1129
|
-
console.log(` [${i + 1}] ${pin}${tool} ${sess.age.padEnd(8)} ${displayName}${active}${cat}`);
|
|
1130
|
-
});
|
|
1131
|
-
console.log('');
|
|
1132
|
-
}
|
|
1594
|
+
// Detect data-tools version
|
|
1595
|
+
const rtMain = detectReplitTools(cwd);
|
|
1596
|
+
const dtVersion = (rtMain.installed && rtMain.version) ? rtMain.version : null;
|
|
1133
1597
|
|
|
1134
|
-
|
|
1135
|
-
const
|
|
1136
|
-
const brandBottom = ` └${'─'.repeat(brandW)}┘`;
|
|
1137
|
-
const brandPad = (s) => {
|
|
1138
|
-
const leftPad = Math.floor((brandW - s.length) / 2);
|
|
1139
|
-
const rightPad = brandW - s.length - leftPad;
|
|
1140
|
-
return ' '.repeat(leftPad) + s + ' '.repeat(rightPad);
|
|
1141
|
-
};
|
|
1142
|
-
console.log(brandTop);
|
|
1143
|
-
console.log(` │ ${brandPad('Dual Brain Session Manager')}│`);
|
|
1144
|
-
console.log(` │ ${brandPad('Built on data-tools by Steve Moraco')}│`);
|
|
1145
|
-
console.log(brandBottom);
|
|
1146
|
-
console.log('');
|
|
1598
|
+
// ── Interrupted work detection ────────────────────────────────────────────
|
|
1599
|
+
const interrupted = detectInterruptedWork(allSessions, cwd);
|
|
1147
1600
|
|
|
1148
|
-
|
|
1149
|
-
const
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
if (runningParts.length > 0) {
|
|
1153
|
-
console.log(` (${runningParts.join(', ')} running)`);
|
|
1154
|
-
console.log('');
|
|
1155
|
-
}
|
|
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} │)
|
|
1156
1605
|
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
if (recentSessions.length > 0) {
|
|
1161
|
-
console.log(' [1-9] Resume numbered above');
|
|
1162
|
-
}
|
|
1163
|
-
console.log(' [r] Resume (full list)');
|
|
1164
|
-
console.log(' [/] Search sessions');
|
|
1165
|
-
console.log(' [e] Manage sessions');
|
|
1166
|
-
console.log(' [m] Manage subscriptions');
|
|
1167
|
-
console.log(' [s] Settings');
|
|
1168
|
-
console.log(' [?] Help & shortcuts');
|
|
1169
|
-
console.log('');
|
|
1170
|
-
console.log(' \x1b[2mreplit-tools:\x1b[0m');
|
|
1171
|
-
console.log(' [i] Import sessions');
|
|
1172
|
-
console.log(' [d] Switch to data-tools');
|
|
1173
|
-
console.log('');
|
|
1174
|
-
console.log(' [q] Exit');
|
|
1175
|
-
console.log('');
|
|
1606
|
+
const top = `┌${'─'.repeat(boxW - 2)}┐`;
|
|
1607
|
+
const sep = `├${'─'.repeat(boxW - 2)}┤`;
|
|
1608
|
+
const bot = `└${'─'.repeat(boxW - 2)}┘`;
|
|
1176
1609
|
|
|
1177
|
-
const
|
|
1610
|
+
const row = (content) => makeBoxRow(content, W);
|
|
1178
1611
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
console.log('');
|
|
1197
|
-
await ask(' Press Enter to continue...');
|
|
1198
|
-
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
|
+
}
|
|
1199
1629
|
}
|
|
1200
1630
|
|
|
1201
|
-
|
|
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
|
+
};
|
|
1202
1661
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
+
}
|
|
1207
1673
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1674
|
+
if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
|
|
1675
|
+
cleanup2();
|
|
1676
|
+
process.stdout.write('\n');
|
|
1677
|
+
resolve('resume');
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1210
1680
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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');
|
|
1214
1701
|
return { next: 'main' };
|
|
1215
1702
|
}
|
|
1216
1703
|
|
|
1217
|
-
|
|
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) {
|
|
1218
1752
|
try {
|
|
1219
|
-
const {
|
|
1220
|
-
const
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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}`));
|
|
1225
1781
|
}
|
|
1226
|
-
} catch {}
|
|
1782
|
+
} catch { /* non-fatal */ }
|
|
1783
|
+
}
|
|
1784
|
+
// ── End related sessions hint ─────────────────────────────────────────────
|
|
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
|
+
}
|
|
1227
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];
|
|
1228
1992
|
const { spawnSync } = await import('node:child_process');
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
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');
|
|
1233
1996
|
return { next: 'main' };
|
|
1234
1997
|
}
|
|
1235
1998
|
|
|
1236
|
-
|
|
1999
|
+
// Number 1-3 → resume that session
|
|
2000
|
+
const numChoice = parseInt(raw, 10);
|
|
1237
2001
|
if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= recentSessions.length) {
|
|
1238
2002
|
const sess = recentSessions[numChoice - 1];
|
|
1239
|
-
|
|
1240
|
-
// Smart resume preview
|
|
1241
2003
|
try {
|
|
1242
2004
|
const { getSessionContext } = await import('../src/session.mjs');
|
|
1243
2005
|
const ctx = getSessionContext(sess.id, cwd);
|
|
1244
2006
|
if (ctx) {
|
|
1245
|
-
|
|
1246
|
-
if (ctx.
|
|
1247
|
-
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`);
|
|
1248
2009
|
}
|
|
1249
2010
|
} catch {}
|
|
1250
|
-
|
|
1251
2011
|
const { spawnSync } = await import('node:child_process');
|
|
1252
|
-
|
|
2012
|
+
process.stdout.write(`\n Launching: claude --resume ${sess.id}\n\n`);
|
|
1253
2013
|
spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
|
|
1254
2014
|
saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
|
|
1255
2015
|
return { next: 'main' };
|
|
1256
2016
|
}
|
|
1257
2017
|
|
|
1258
|
-
if (choice === '
|
|
1259
|
-
const allSessions = enrichSessions(importReplitSessions(cwd), cwd);
|
|
1260
|
-
if (allSessions.length === 0) {
|
|
1261
|
-
console.log('\n No sessions found.\n');
|
|
1262
|
-
await ask(' Press Enter to continue...');
|
|
1263
|
-
return { next: 'main' };
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
console.log('\n All Sessions:');
|
|
1267
|
-
allSessions.forEach((sess, i) => {
|
|
1268
|
-
const pin = sess.pinned ? '📌 ' : ' ';
|
|
1269
|
-
const active = sess.isActive ? ' ●' : '';
|
|
1270
|
-
const cat = sess.category ? ` [${sess.category}]` : '';
|
|
1271
|
-
const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
|
|
1272
|
-
console.log(` [${String(i + 1).padStart(2)}] ${pin}${tool} ${sess.age.padEnd(8)} ${sess.name}${active}${cat}`);
|
|
1273
|
-
});
|
|
1274
|
-
console.log('');
|
|
1275
|
-
|
|
1276
|
-
const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
|
|
1277
|
-
const num = parseInt(pick, 10);
|
|
1278
|
-
if (!isNaN(num) && num >= 1 && num <= allSessions.length) {
|
|
1279
|
-
const sess = allSessions[num - 1];
|
|
1280
|
-
const { spawnSync } = await import('node:child_process');
|
|
1281
|
-
const tool = sess.tool === 'codex' ? 'codex' : 'claude';
|
|
1282
|
-
console.log(`\n Launching: ${tool} --resume ${sess.id}\n`);
|
|
1283
|
-
spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
|
|
1284
|
-
}
|
|
1285
|
-
return { next: 'main' };
|
|
1286
|
-
}
|
|
2018
|
+
if (choice === 'n') { return { next: 'new-session' }; }
|
|
1287
2019
|
|
|
1288
2020
|
if (choice === '/') {
|
|
1289
2021
|
const query = (await ask(' Search: ')).trim();
|
|
1290
2022
|
if (!query) return { next: 'main' };
|
|
1291
2023
|
|
|
1292
2024
|
const { searchSessions, buildSessionIndex } = await import('../src/session.mjs');
|
|
1293
|
-
// Build index if needed (silent)
|
|
1294
2025
|
try { buildSessionIndex(cwd); } catch {}
|
|
1295
2026
|
|
|
1296
2027
|
const results = searchSessions(query, cwd);
|
|
1297
2028
|
if (results.length === 0) {
|
|
1298
|
-
|
|
2029
|
+
process.stdout.write(`\n No sessions matching "${query}"\n\n`);
|
|
1299
2030
|
await ask(' Press Enter to continue...');
|
|
1300
2031
|
return { next: 'main' };
|
|
1301
2032
|
}
|
|
1302
2033
|
|
|
1303
|
-
|
|
2034
|
+
process.stdout.write(`\n Found ${results.length} session${results.length === 1 ? '' : 's'}:\n`);
|
|
1304
2035
|
results.slice(0, 9).forEach((sess, i) => {
|
|
1305
|
-
const tool
|
|
1306
|
-
const date
|
|
2036
|
+
const tool = sess.tool === 'codex' ? 'cdx' : 'cld';
|
|
2037
|
+
const date = sess.date ? new Date(sess.date).toLocaleDateString() : '?';
|
|
1307
2038
|
const topics = sess.topics.slice(0, 3).join(', ');
|
|
1308
|
-
|
|
1309
|
-
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`);
|
|
1310
2041
|
});
|
|
1311
|
-
|
|
2042
|
+
process.stdout.write('\n');
|
|
1312
2043
|
|
|
1313
2044
|
const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
|
|
1314
|
-
const num
|
|
2045
|
+
const num = parseInt(pick, 10);
|
|
1315
2046
|
if (!isNaN(num) && num >= 1 && num <= Math.min(results.length, 9)) {
|
|
1316
2047
|
const sess = results[num - 1];
|
|
1317
2048
|
const { spawnSync } = await import('node:child_process');
|
|
1318
2049
|
const tool = sess.tool === 'codex' ? 'codex' : 'claude';
|
|
1319
|
-
|
|
2050
|
+
process.stdout.write(`\n Launching: ${tool} --resume ${sess.id}\n\n`);
|
|
1320
2051
|
spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
|
|
1321
2052
|
}
|
|
1322
2053
|
return { next: 'main' };
|
|
1323
2054
|
}
|
|
1324
2055
|
|
|
1325
|
-
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' }; }
|
|
1326
2059
|
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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();
|
|
1336
2135
|
return { next: 'main' };
|
|
1337
2136
|
}
|
|
1338
2137
|
|
|
1339
|
-
if (
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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();
|
|
1348
2148
|
return { next: 'main' };
|
|
1349
2149
|
}
|
|
1350
2150
|
|
|
1351
|
-
|
|
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
|
+
);
|
|
1352
2158
|
|
|
1353
|
-
|
|
1354
|
-
|
|
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`);
|
|
1355
2302
|
|
|
1356
2303
|
return { next: 'main' };
|
|
1357
2304
|
}
|
|
1358
2305
|
|
|
1359
|
-
// ─── Screen:
|
|
2306
|
+
// ─── Screen: prTriageScreen ───────────────────────────────────────────────────
|
|
1360
2307
|
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
+
}
|
|
1365
2339
|
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
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' };
|
|
2381
|
+
|
|
2382
|
+
const idx = parseInt(pick, 10) - 1;
|
|
2383
|
+
if (isNaN(idx) || idx < 0 || idx >= prs.length) return { next: 'pr-triage', openPRs: prs };
|
|
2384
|
+
|
|
2385
|
+
const selectedPR = prs[idx];
|
|
2386
|
+
|
|
2387
|
+
// ── PR detail: checkout + fetch comments ──────────────────────────────────
|
|
2388
|
+
process.stdout.write(`\n Checking out PR #${selectedPR.number}...\n`);
|
|
1369
2389
|
|
|
1370
|
-
|
|
1371
|
-
|
|
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 {}
|
|
1372
2423
|
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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');
|
|
1377
2432
|
} else {
|
|
1378
|
-
|
|
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
|
+
}
|
|
1379
2451
|
}
|
|
1380
2452
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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 };
|
|
1385
2473
|
}
|
|
1386
2474
|
|
|
1387
|
-
|
|
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 };
|
|
1388
2532
|
}
|
|
1389
2533
|
|
|
1390
2534
|
// ─── Screen: settingsScreen ───────────────────────────────────────────────────
|
|
1391
2535
|
|
|
1392
2536
|
async function settingsScreen(rl, ask) {
|
|
1393
2537
|
const cwd = process.cwd();
|
|
1394
|
-
const profile = loadProfile(cwd);
|
|
1395
|
-
const auth = await detectAuth();
|
|
1396
2538
|
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
const settings = JSON.parse(readFileSync(settingsFile, 'utf8'));
|
|
1402
|
-
const preToolUse = settings?.hooks?.PreToolUse ?? [];
|
|
1403
|
-
const guardCmd = 'node .claude/hooks/head-guard.mjs';
|
|
1404
|
-
const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
|
|
1405
|
-
const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
|
|
1406
|
-
const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
|
|
1407
|
-
const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
|
|
1408
|
-
const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
|
|
1409
|
-
guardCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
|
|
1410
|
-
}
|
|
1411
|
-
} 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;
|
|
1412
2543
|
|
|
1413
|
-
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);
|
|
1414
2548
|
|
|
1415
|
-
|
|
1416
|
-
const
|
|
1417
|
-
const claudePlanLabel = claudeSub?.enabled
|
|
1418
|
-
? (CLAUDE_PLAN_LABELS[claudeSub.plan] ?? claudeSub.plan ?? 'n/a')
|
|
1419
|
-
: 'disabled';
|
|
1420
|
-
const openaiPlanLabel = openaiSub?.enabled
|
|
1421
|
-
? (OPENAI_PLAN_LABELS[openaiSub.plan] ?? openaiSub.plan ?? 'n/a')
|
|
1422
|
-
: 'disabled';
|
|
2549
|
+
// Detect if gh is available + has PRs for the PR triage option
|
|
2550
|
+
const settingsPRs = await detectOpenPRs(cwd);
|
|
1423
2551
|
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
'',
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
'',
|
|
1434
|
-
|
|
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,
|
|
1435
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)}│`;
|
|
1436
2605
|
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
console.log(menu([
|
|
1441
|
-
{ key: '1', label: 'Switch to cost-saver', section: 'Mode' },
|
|
1442
|
-
{ key: '2', label: 'Switch to balanced', section: 'Mode' },
|
|
1443
|
-
{ key: '3', label: 'Switch to quality-first', section: 'Mode' },
|
|
1444
|
-
{ key: 'a', label: 'Manage subscriptions', section: 'Subscriptions' },
|
|
1445
|
-
{ key: 'i', label: 'Reinstall hooks', section: 'Enforcement' },
|
|
1446
|
-
{ key: 'b', label: 'Back', section: '' },
|
|
1447
|
-
]));
|
|
1448
|
-
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';
|
|
1449
2609
|
|
|
1450
|
-
|
|
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('');
|
|
1451
2621
|
|
|
1452
|
-
|
|
1453
|
-
const
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
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
|
+
}
|
|
1457
2637
|
return { next: 'settings' };
|
|
1458
2638
|
}
|
|
1459
2639
|
|
|
1460
|
-
if (choice === '
|
|
1461
|
-
|
|
1462
|
-
}
|
|
2640
|
+
if (choice === 'm') { return { next: 'subscriptions' }; }
|
|
2641
|
+
|
|
2642
|
+
if (choice === 'e') { return { next: 'sessions' }; }
|
|
1463
2643
|
|
|
1464
2644
|
if (choice === 'i') {
|
|
1465
|
-
|
|
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
|
+
}
|
|
1466
2661
|
return { next: 'settings' };
|
|
1467
2662
|
}
|
|
1468
2663
|
|
|
1469
|
-
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' }; }
|
|
1470
2686
|
|
|
1471
|
-
return { next: '
|
|
2687
|
+
if (choice === 'b' || choice === 'back' || raw === '\x1b') { return { next: 'main' }; }
|
|
2688
|
+
|
|
2689
|
+
return { next: 'main' };
|
|
1472
2690
|
}
|
|
1473
2691
|
|
|
1474
|
-
// ─── Helper: aggregatePlans ───────────────────────────────────────────────────
|
|
1475
2692
|
|
|
1476
|
-
|
|
1477
|
-
pro: '$20', max5: '$100', max20: '$200',
|
|
1478
|
-
plus: '$20', pro100: '$100', pro200: '$200',
|
|
1479
|
-
};
|
|
2693
|
+
// ─── Helper: aggregatePlans ───────────────────────────────────────────────────
|
|
1480
2694
|
|
|
1481
2695
|
function aggregatePlans(subs) {
|
|
1482
2696
|
if (!subs || subs.length === 0) return '';
|
|
1483
2697
|
const counts = {};
|
|
1484
2698
|
for (const s of subs) {
|
|
1485
|
-
const
|
|
1486
|
-
counts[
|
|
2699
|
+
const label = s.plan || 'unknown';
|
|
2700
|
+
counts[label] = (counts[label] || 0) + 1;
|
|
1487
2701
|
}
|
|
1488
2702
|
return Object.entries(counts)
|
|
1489
|
-
.
|
|
1490
|
-
.map(([price, count]) => `${price}×${count}`)
|
|
2703
|
+
.map(([label, count]) => count > 1 ? `${label}×${count}` : label)
|
|
1491
2704
|
.join(' ');
|
|
1492
2705
|
}
|
|
1493
2706
|
|
|
@@ -1549,13 +2762,13 @@ async function subscriptionsScreen(rl, ask) {
|
|
|
1549
2762
|
const choice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
1550
2763
|
|
|
1551
2764
|
if (choice === '1') {
|
|
1552
|
-
console.log('\n Linking Claude
|
|
2765
|
+
console.log('\n Linking Claude account...');
|
|
1553
2766
|
console.log(' A browser window will open — paste the code below when prompted.\n');
|
|
1554
2767
|
const { spawnSync } = await import('node:child_process');
|
|
1555
2768
|
const r = spawnSync('claude', ['auth', 'login'], { stdio: 'inherit', timeout: 60000 });
|
|
1556
2769
|
if (r.status === 0) {
|
|
1557
2770
|
console.log('\n ✅ Claude linked successfully!\n');
|
|
1558
|
-
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();
|
|
1559
2772
|
const expiry = await askExpiry(ask, 'Claude');
|
|
1560
2773
|
const newPlans = detectPlans();
|
|
1561
2774
|
const plan = newPlans.claude?.plan || 'pro';
|
|
@@ -1577,7 +2790,7 @@ async function subscriptionsScreen(rl, ask) {
|
|
|
1577
2790
|
}
|
|
1578
2791
|
|
|
1579
2792
|
if (choice === '2') {
|
|
1580
|
-
console.log('\n Linking Codex
|
|
2793
|
+
console.log('\n Linking Codex account...');
|
|
1581
2794
|
console.log(' A browser window will open — paste the code below when prompted.\n');
|
|
1582
2795
|
const { spawnSync } = await import('node:child_process');
|
|
1583
2796
|
const r = spawnSync('codex', ['login'], { stdio: 'inherit', timeout: 60000 });
|
|
@@ -1615,12 +2828,12 @@ async function subscriptionsScreen(rl, ask) {
|
|
|
1615
2828
|
}
|
|
1616
2829
|
|
|
1617
2830
|
if (allSubs.length === 0) {
|
|
1618
|
-
console.log('\n No
|
|
2831
|
+
console.log('\n No linked accounts to remove.\n');
|
|
1619
2832
|
await ask(' Press Enter to continue...');
|
|
1620
2833
|
return { next: 'subscriptions' };
|
|
1621
2834
|
}
|
|
1622
2835
|
|
|
1623
|
-
console.log('\n Remove a
|
|
2836
|
+
console.log('\n Remove a linked account:\n');
|
|
1624
2837
|
allSubs.forEach(({ displayName, sub }, i) => {
|
|
1625
2838
|
const planLabels = displayName === 'Claude' ? CLAUDE_PLAN_LABELS : OPENAI_PLAN_LABELS;
|
|
1626
2839
|
const planLabel = planLabels[sub.plan] ?? sub.plan ?? 'unknown';
|
|
@@ -1660,21 +2873,20 @@ async function subscriptionsScreen(rl, ask) {
|
|
|
1660
2873
|
// ─── Onboarding Wizard ───────────────────────────────────────────────────────
|
|
1661
2874
|
|
|
1662
2875
|
/**
|
|
1663
|
-
*
|
|
1664
|
-
*
|
|
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.
|
|
1665
2878
|
* @param {{ auth, plans, existingSessions }} detection
|
|
1666
2879
|
* @param {string} cwd
|
|
1667
2880
|
* @param {object} rl readline interface
|
|
1668
2881
|
* @returns {object|null} profile object to save, or null if cancelled/skipped
|
|
1669
2882
|
*/
|
|
1670
|
-
async function runOnboardingWizard(
|
|
2883
|
+
async function runOnboardingWizard(_detection, cwd, rl) {
|
|
1671
2884
|
const ask = (q) => new Promise(res => rl.question(q, res));
|
|
1672
2885
|
const version = readVersion();
|
|
1673
2886
|
|
|
1674
2887
|
// ── Rounded box helpers (matching mainScreen style) ────────────────────────
|
|
1675
2888
|
const W = 51;
|
|
1676
2889
|
const wTop = ` ┌${'─'.repeat(W)}┐`;
|
|
1677
|
-
const wSep = ` ├${'─'.repeat(W)}┤`;
|
|
1678
2890
|
const wBottom = ` └${'─'.repeat(W)}┘`;
|
|
1679
2891
|
const wPad = (s) => {
|
|
1680
2892
|
const plain = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
@@ -1691,244 +2903,95 @@ async function runOnboardingWizard(detection, cwd, rl) {
|
|
|
1691
2903
|
};
|
|
1692
2904
|
const wRow = (s) => ` │ ${wPad(s)}│`;
|
|
1693
2905
|
|
|
1694
|
-
// ──
|
|
1695
|
-
const
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
importSessions: false,
|
|
1700
|
-
profile: 'auto',
|
|
1701
|
-
};
|
|
1702
|
-
|
|
1703
|
-
const { auth, plans, existingSessions } = detection;
|
|
1704
|
-
const claudeReady = auth.claude.found;
|
|
1705
|
-
const openaiReady = auth.openai.found;
|
|
1706
|
-
|
|
1707
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
1708
|
-
// Step 1 — Welcome & provider detection
|
|
1709
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
1710
|
-
console.log('');
|
|
1711
|
-
console.log(wTop);
|
|
1712
|
-
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1713
|
-
console.log(wSep);
|
|
1714
|
-
console.log(wRow(`Step 1 of 5: Detected providers`));
|
|
1715
|
-
console.log(wSep);
|
|
1716
|
-
|
|
1717
|
-
// Plan tier is inferred from auth config signals — not the actual plan name.
|
|
1718
|
-
// Show the tier ($20/$100/$200) with "configured" suffix to be honest.
|
|
1719
|
-
const claudePlanSuffix = claudeReady && plans.claude ? ` · ${plans.claude} configured` : '';
|
|
1720
|
-
const openaiPlanSuffix = openaiReady && plans.openai ? ` · ${plans.openai} configured` : '';
|
|
1721
|
-
|
|
1722
|
-
console.log(wRow(claudeReady
|
|
1723
|
-
? `✓ Claude CLI${claudePlanSuffix}`
|
|
1724
|
-
: `✗ Claude CLI not logged in`));
|
|
1725
|
-
console.log(wRow(openaiReady
|
|
1726
|
-
? `✓ Codex CLI${openaiPlanSuffix}`
|
|
1727
|
-
: `✗ Codex CLI not logged in`));
|
|
1728
|
-
if (existingSessions.length > 0) {
|
|
1729
|
-
console.log(wRow(`✓ ${existingSessions.length} data-tools session${existingSessions.length !== 1 ? 's' : ''} found`));
|
|
1730
|
-
}
|
|
1731
|
-
console.log(wSep);
|
|
1732
|
-
console.log(wRow(`[Enter] Continue setup [s] Skip wizard`));
|
|
1733
|
-
console.log(wBottom);
|
|
1734
|
-
console.log('');
|
|
1735
|
-
|
|
1736
|
-
if (!claudeReady && !openaiReady) {
|
|
1737
|
-
console.log(' No AI provider found. Log in first:');
|
|
1738
|
-
console.log(' claude auth login — for Claude');
|
|
1739
|
-
console.log(' codex login — for OpenAI/Codex');
|
|
1740
|
-
console.log(' Then re-run: dual-brain init\n');
|
|
1741
|
-
return null;
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
const step1 = (await ask(' > ')).trim().toLowerCase();
|
|
1745
|
-
if (step1 === 's') {
|
|
1746
|
-
// Skip: auto-save detected plans and proceed directly
|
|
1747
|
-
const skippedProfile = loadProfile(cwd);
|
|
1748
|
-
if (claudeReady) skippedProfile.providers.claude = { enabled: true, plan: plans.claude || 'pro' };
|
|
1749
|
-
if (openaiReady) skippedProfile.providers.openai = { enabled: true, plan: plans.openai || 'plus' };
|
|
1750
|
-
const enabledCount = [claudeReady, openaiReady].filter(Boolean).length;
|
|
1751
|
-
skippedProfile.mode = enabledCount >= 2 ? 'auto' : claudeReady ? 'solo-claude' : 'solo-openai';
|
|
1752
|
-
return skippedProfile;
|
|
1753
|
-
}
|
|
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;
|
|
1754
2911
|
|
|
1755
|
-
//
|
|
1756
|
-
|
|
1757
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
1758
|
-
console.log('');
|
|
1759
|
-
console.log(wTop);
|
|
1760
|
-
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1761
|
-
console.log(wSep);
|
|
1762
|
-
console.log(wRow(`Step 2 of 5: Subscription plans`));
|
|
1763
|
-
console.log(wSep);
|
|
1764
|
-
|
|
1765
|
-
if (claudeReady) {
|
|
1766
|
-
// Plan tier is inferred from auth config (rate-limit signal), not the actual plan name.
|
|
1767
|
-
const configuredClaudePlan = plans.claude || '$20';
|
|
1768
|
-
const configuredClaudeDesc = configuredClaudePlan + ' configured';
|
|
1769
|
-
console.log(wRow(`Claude — ${configuredClaudeDesc}`));
|
|
1770
|
-
console.log(wRow(` [1] Pro ($20/mo)`));
|
|
1771
|
-
console.log(wRow(` [2] Max x5 ($100/mo)`));
|
|
1772
|
-
console.log(wRow(` [3] Max x20 ($200/mo)`));
|
|
1773
|
-
console.log(wRow(` [Enter] Keep configured (${configuredClaudePlan})`));
|
|
1774
|
-
console.log(wSep);
|
|
1775
|
-
const claudeChoice = (await ask(' Claude plan [1/2/3/Enter]: ')).trim();
|
|
1776
|
-
const claudePlanMap = { '1': 'pro', '2': 'max5', '3': 'max20' };
|
|
1777
|
-
state.claudePlan = claudePlanMap[claudeChoice] || configuredClaudePlan;
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
if (openaiReady) {
|
|
1781
|
-
// Plan tier is inferred from JWT claim in auth config, not the actual plan name.
|
|
1782
|
-
const configuredOpenaiPlan = plans.openai || '$20';
|
|
1783
|
-
const configuredOpenaiDesc = configuredOpenaiPlan + ' configured';
|
|
1784
|
-
console.log(wRow(`OpenAI — ${configuredOpenaiDesc}`));
|
|
1785
|
-
console.log(wRow(` [1] Plus ($20/mo)`));
|
|
1786
|
-
console.log(wRow(` [2] Pro ($100/mo)`));
|
|
1787
|
-
console.log(wRow(` [3] Pro ($200/mo higher limits)`));
|
|
1788
|
-
console.log(wRow(` [Enter] Keep configured (${configuredOpenaiPlan})`));
|
|
1789
|
-
console.log(wSep);
|
|
1790
|
-
const openaiChoice = (await ask(' OpenAI plan [1/2/3/Enter]: ')).trim();
|
|
1791
|
-
const openaiPlanMap = { '1': 'plus', '2': 'pro', '3': 'pro200' };
|
|
1792
|
-
state.openaiPlan = openaiPlanMap[openaiChoice] || configuredOpenaiPlan;
|
|
1793
|
-
}
|
|
1794
|
-
|
|
1795
|
-
console.log(wBottom);
|
|
1796
|
-
|
|
1797
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
1798
|
-
// Step 3 — HEAD model selection
|
|
1799
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
1800
|
-
const hasBigPlan = state.claudePlan === 'max5' || state.claudePlan === 'max20';
|
|
1801
|
-
const recommendedModel = hasBigPlan ? 'claude-opus-4-5' : 'claude-sonnet-4-5';
|
|
1802
|
-
const recommendedLabel = hasBigPlan
|
|
1803
|
-
? 'Opus (Max plan — best quality)'
|
|
1804
|
-
: 'Sonnet (Pro plan — balanced speed/quality)';
|
|
1805
|
-
|
|
1806
|
-
console.log('');
|
|
1807
|
-
console.log(wTop);
|
|
1808
|
-
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1809
|
-
console.log(wSep);
|
|
1810
|
-
console.log(wRow(`Step 3 of 5: HEAD model (think-tier)`));
|
|
1811
|
-
console.log(wSep);
|
|
1812
|
-
console.log(wRow(`Recommended: ${recommendedLabel}`));
|
|
1813
|
-
console.log(wSep);
|
|
1814
|
-
console.log(wRow(` [1] Haiku — fastest, lowest cost`));
|
|
1815
|
-
console.log(wRow(` [2] Sonnet — balanced (recommended for Pro)`));
|
|
1816
|
-
console.log(wRow(` [3] Opus — best quality (recommended for Max)`));
|
|
1817
|
-
console.log(wRow(` [Enter] Use recommended`));
|
|
1818
|
-
console.log(wBottom);
|
|
1819
|
-
console.log('');
|
|
2912
|
+
// ── Detect replit-tools ────────────────────────────────────────────────────
|
|
2913
|
+
const rt = detectReplitTools(cwd);
|
|
1820
2914
|
|
|
1821
|
-
const
|
|
1822
|
-
const
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
'3': 'claude-opus-4-5',
|
|
1826
|
-
};
|
|
1827
|
-
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';
|
|
1828
2919
|
|
|
1829
2920
|
// ══════════════════════════════════════════════════════════════════════════
|
|
1830
|
-
// Step
|
|
2921
|
+
// Step 1 — Auto-detect capabilities (instant, no spinner)
|
|
1831
2922
|
// ══════════════════════════════════════════════════════════════════════════
|
|
1832
2923
|
console.log('');
|
|
1833
2924
|
console.log(wTop);
|
|
1834
2925
|
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1835
|
-
console.log(
|
|
1836
|
-
|
|
1837
|
-
|
|
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);
|
|
1838
2938
|
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
console.log(
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
}
|
|
1852
|
-
if (existingSessions.length > 5) {
|
|
1853
|
-
console.log(` ... and ${existingSessions.length - 5} more`);
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
|
-
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('');
|
|
1857
2951
|
}
|
|
1858
2952
|
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
console.log(
|
|
1863
|
-
console.log(wRow(
|
|
1864
|
-
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'));
|
|
1865
2962
|
console.log(wBottom);
|
|
1866
2963
|
console.log('');
|
|
1867
2964
|
|
|
1868
|
-
const
|
|
1869
|
-
const
|
|
1870
|
-
|
|
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];
|
|
1871
2970
|
|
|
1872
|
-
//
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
: `Claude: not configured`;
|
|
1878
|
-
const openaiSummary = state.openaiPlan
|
|
1879
|
-
? `OpenAI: ${OPENAI_PLAN_LABELS[state.openaiPlan] ?? state.openaiPlan}`
|
|
1880
|
-
: `OpenAI: not configured`;
|
|
1881
|
-
const modelSummary = `HEAD model: ${state.headModel}`;
|
|
1882
|
-
const profileSummary = `Profile: ${state.profile}`;
|
|
1883
|
-
const sessionSummary = existingSessions.length > 0
|
|
1884
|
-
? `Sessions: ${state.importSessions ? `${existingSessions.length} imported` : 'skipped'}`
|
|
1885
|
-
: 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
|
+
}
|
|
1886
2976
|
|
|
1887
|
-
|
|
2977
|
+
// ── Done ───────────────────────────────────────────────────────────────────
|
|
1888
2978
|
console.log(wTop);
|
|
1889
|
-
console.log(wRow(
|
|
1890
|
-
console.log(
|
|
1891
|
-
console.log(wRow(`Step 5 of 5: Summary`));
|
|
1892
|
-
console.log(wSep);
|
|
1893
|
-
console.log(wRow(claudeSummary));
|
|
1894
|
-
console.log(wRow(openaiSummary));
|
|
1895
|
-
console.log(wRow(modelSummary));
|
|
1896
|
-
console.log(wRow(profileSummary));
|
|
1897
|
-
if (sessionSummary) console.log(wRow(sessionSummary));
|
|
1898
|
-
console.log(wSep);
|
|
1899
|
-
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`));
|
|
1900
2981
|
console.log(wBottom);
|
|
1901
2982
|
console.log('');
|
|
1902
2983
|
|
|
1903
|
-
const step5 = (await ask(' > ')).trim().toLowerCase();
|
|
1904
|
-
if (step5 === 'q') {
|
|
1905
|
-
console.log('\n Setup cancelled.\n');
|
|
1906
|
-
return null;
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
2984
|
// ── Build and return the profile object ────────────────────────────────────
|
|
1910
2985
|
const finalProfile = loadProfile(cwd);
|
|
1911
2986
|
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
finalProfile.providers.claude = { enabled: true, plan: plans.claude || 'pro' };
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
if (state.openaiPlan) {
|
|
1919
|
-
finalProfile.providers.openai = { enabled: true, plan: state.openaiPlan };
|
|
1920
|
-
} else if (openaiReady) {
|
|
1921
|
-
finalProfile.providers.openai = { enabled: true, plan: plans.openai || 'plus' };
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
const enabledCount = [
|
|
1925
|
-
finalProfile.providers?.claude?.enabled,
|
|
1926
|
-
finalProfile.providers?.openai?.enabled,
|
|
1927
|
-
].filter(Boolean).length;
|
|
2987
|
+
finalProfile.providers.claude = { enabled: claudeReady };
|
|
2988
|
+
finalProfile.providers.openai = { enabled: openaiReady || codexAvailable };
|
|
2989
|
+
finalProfile.apiGuardrail = caps.openai.metered;
|
|
1928
2990
|
|
|
1929
|
-
|
|
1930
|
-
finalProfile.
|
|
1931
|
-
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;
|
|
1932
2995
|
|
|
1933
2996
|
return finalProfile;
|
|
1934
2997
|
}
|
|
@@ -1969,11 +3032,11 @@ async function authScreen(rl, ask) {
|
|
|
1969
3032
|
` plan: ${openaiPlanLabel}${openaiSub?.label ? ` [${openaiSub.label}]` : ''}`,
|
|
1970
3033
|
];
|
|
1971
3034
|
|
|
1972
|
-
console.log(box('
|
|
3035
|
+
console.log(box('Provider Status', authLines));
|
|
1973
3036
|
console.log('');
|
|
1974
3037
|
console.log(menu([
|
|
1975
|
-
{ key: 'a', label: 'Manage
|
|
1976
|
-
{ key: 'b', label: 'Back to dashboard',
|
|
3038
|
+
{ key: 'a', label: 'Manage linked accounts', section: '' },
|
|
3039
|
+
{ key: 'b', label: 'Back to dashboard', section: '' },
|
|
1977
3040
|
]));
|
|
1978
3041
|
console.log('');
|
|
1979
3042
|
|
|
@@ -2412,45 +3475,216 @@ async function sessionDetailScreen(rl, ask, ctx = {}) {
|
|
|
2412
3475
|
// ─── Screen: sessionsScreen ───────────────────────────────────────────────────
|
|
2413
3476
|
|
|
2414
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
|
+
}
|
|
2415
3490
|
|
|
3491
|
+
/**
|
|
3492
|
+
* Interactive full session list with arrow-key navigation.
|
|
3493
|
+
* Enter = resume, x = archive, r = rename, q/Esc = back to dashboard.
|
|
3494
|
+
*/
|
|
2416
3495
|
async function sessionsScreen(rl, ask) {
|
|
2417
3496
|
const cwd = process.cwd();
|
|
2418
|
-
const sessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 9);
|
|
2419
3497
|
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
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)}┘`;
|
|
2423
3509
|
|
|
2424
3510
|
if (sessions.length === 0) {
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
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' };
|
|
2430
3520
|
}
|
|
2431
3521
|
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
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
|
+
}
|
|
2438
3539
|
|
|
2439
|
-
|
|
2440
|
-
console.log(' [1-9] Select a session to manage');
|
|
2441
|
-
console.log(' [b] Back');
|
|
2442
|
-
console.log('');
|
|
3540
|
+
let cursor = 0;
|
|
2443
3541
|
|
|
2444
|
-
|
|
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
|
+
}
|
|
2445
3555
|
|
|
2446
|
-
|
|
3556
|
+
render();
|
|
2447
3557
|
|
|
2448
|
-
const
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
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
|
+
}
|
|
2452
3635
|
|
|
2453
|
-
|
|
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;
|
|
2454
3688
|
}
|
|
2455
3689
|
|
|
2456
3690
|
async function sessionManageScreen(rl, ask, ctx = {}) {
|
|
@@ -2534,6 +3768,257 @@ async function sessionManageScreen(rl, ask, ctx = {}) {
|
|
|
2534
3768
|
return { next: 'session-manage', session: sess };
|
|
2535
3769
|
}
|
|
2536
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
|
+
|
|
2537
4022
|
// ─── Screen state machine ─────────────────────────────────────────────────────
|
|
2538
4023
|
|
|
2539
4024
|
const SCREENS = {
|
|
@@ -2541,6 +4026,8 @@ const SCREENS = {
|
|
|
2541
4026
|
main: mainScreen,
|
|
2542
4027
|
'new-session': newSessionScreen,
|
|
2543
4028
|
settings: settingsScreen,
|
|
4029
|
+
'import-picker': importPickerScreen,
|
|
4030
|
+
'pr-triage': prTriageScreen,
|
|
2544
4031
|
subscriptions: subscriptionsScreen,
|
|
2545
4032
|
dashboard: dashboardScreen,
|
|
2546
4033
|
auth: authScreen,
|
|
@@ -2559,13 +4046,26 @@ async function runScreens(startScreen = 'dashboard') {
|
|
|
2559
4046
|
let current = startScreen;
|
|
2560
4047
|
let ctx = {};
|
|
2561
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
|
+
|
|
2562
4059
|
const screen = SCREENS[current];
|
|
2563
4060
|
if (!screen) break;
|
|
2564
4061
|
try {
|
|
2565
4062
|
const result = await screen(rl, ask, ctx);
|
|
2566
4063
|
current = result?.next || 'exit';
|
|
2567
|
-
// Pass through context (e.g. selected session) to next screen
|
|
2568
|
-
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
|
+
: {};
|
|
2569
4069
|
} catch (e) {
|
|
2570
4070
|
console.error(`Error: ${e.message}`);
|
|
2571
4071
|
current = 'main';
|
|
@@ -2575,6 +4075,309 @@ async function runScreens(startScreen = 'dashboard') {
|
|
|
2575
4075
|
rl.close();
|
|
2576
4076
|
}
|
|
2577
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
|
+
|
|
2578
4381
|
// ─── Specialist commands ──────────────────────────────────────────────────────
|
|
2579
4382
|
|
|
2580
4383
|
const SPECIALIST_DEFAULTS = {
|
|
@@ -2670,22 +4473,25 @@ async function cmdSpecialistGo(specialist, args) {
|
|
|
2670
4473
|
vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'}`);
|
|
2671
4474
|
}
|
|
2672
4475
|
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
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
|
+
}
|
|
2679
4485
|
|
|
2680
4486
|
if (dryRun) {
|
|
2681
4487
|
console.log('\n(dry-run — not executing)');
|
|
2682
4488
|
return;
|
|
2683
4489
|
}
|
|
2684
4490
|
|
|
2685
|
-
console.log('\nDispatching...');
|
|
4491
|
+
if (verbose) console.log('\nDispatching...');
|
|
2686
4492
|
let result;
|
|
2687
4493
|
if (decision.dualBrain) {
|
|
2688
|
-
result = await dispatchDualBrain({ decision, prompt, files, cwd });
|
|
4494
|
+
result = await dispatchDualBrain({ decision, prompt, files, cwd, verbose });
|
|
2689
4495
|
console.log(`\nConsensus: ${result.consensus}`);
|
|
2690
4496
|
if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
|
|
2691
4497
|
if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
|
|
@@ -2699,7 +4505,7 @@ async function cmdSpecialistGo(specialist, args) {
|
|
|
2699
4505
|
nextAction: null,
|
|
2700
4506
|
}, cwd);
|
|
2701
4507
|
} else {
|
|
2702
|
-
result = await dispatch({ decision, prompt, files, cwd });
|
|
4508
|
+
result = await dispatch({ decision, prompt, files, cwd, verbose });
|
|
2703
4509
|
const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
|
|
2704
4510
|
console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
2705
4511
|
if (result.summary) console.log(result.summary);
|
|
@@ -2743,8 +4549,8 @@ async function main() {
|
|
|
2743
4549
|
if (profileExists(cwd)) {
|
|
2744
4550
|
await runScreens('main');
|
|
2745
4551
|
} else {
|
|
2746
|
-
// First run: run the
|
|
2747
|
-
|
|
4552
|
+
// First run: run the onboarding wizard, then go to main.
|
|
4553
|
+
// (wizard handles detection display)
|
|
2748
4554
|
const auth = await detectAuth();
|
|
2749
4555
|
const plans = detectPlans();
|
|
2750
4556
|
const existingSessions = importReplitSessions(cwd);
|
|
@@ -2759,22 +4565,35 @@ async function main() {
|
|
|
2759
4565
|
await runScreens('main');
|
|
2760
4566
|
}
|
|
2761
4567
|
} else {
|
|
2762
|
-
// Non-TTY:
|
|
2763
|
-
const
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
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
|
+
}
|
|
2769
4588
|
}
|
|
2770
4589
|
return;
|
|
2771
4590
|
}
|
|
2772
4591
|
|
|
2773
4592
|
if (cmd === 'init') {
|
|
2774
4593
|
if (isInteractive) {
|
|
2775
|
-
// Run
|
|
4594
|
+
// Run onboarding wizard then main screen
|
|
2776
4595
|
const cwd = process.cwd();
|
|
2777
|
-
|
|
4596
|
+
// (wizard handles detection display)
|
|
2778
4597
|
const auth = await detectAuth();
|
|
2779
4598
|
const plans = detectPlans();
|
|
2780
4599
|
const existingSessions = importReplitSessions(cwd);
|
|
@@ -2799,7 +4618,12 @@ async function main() {
|
|
|
2799
4618
|
await cmdAuth(args.slice(1));
|
|
2800
4619
|
return;
|
|
2801
4620
|
}
|
|
4621
|
+
if (cmd === 'plan') { await cmdGo(args.slice(1), { dryRun: true }); return; }
|
|
4622
|
+
if (cmd === 'do') { await cmdGo(args.slice(1)); return; }
|
|
2802
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; }
|
|
2803
4627
|
if (cmd === 'status') { await cmdStatus(args.slice(1)); return; }
|
|
2804
4628
|
if (cmd === 'hot') { cmdHot(args[1]); return; }
|
|
2805
4629
|
if (cmd === 'cool') { cmdCool(args[1]); return; }
|
|
@@ -2843,6 +4667,8 @@ async function main() {
|
|
|
2843
4667
|
process.exit(0);
|
|
2844
4668
|
}
|
|
2845
4669
|
|
|
4670
|
+
if (cmd === 'watch') { await cmdWatch(args.slice(1)); return; }
|
|
4671
|
+
|
|
2846
4672
|
if (cmd === 'shell-hook') {
|
|
2847
4673
|
// Output a bash snippet users can add to their .bashrc or source directly.
|
|
2848
4674
|
const hook = `
|
|
@@ -2858,6 +4684,43 @@ fi
|
|
|
2858
4684
|
return;
|
|
2859
4685
|
}
|
|
2860
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
|
+
|
|
2861
4724
|
process.stderr.write(`Unknown command: ${cmd}\nRun "dual-brain --help" for usage.\n`);
|
|
2862
4725
|
process.exit(1);
|
|
2863
4726
|
}
|