myshell-tools 2.10.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/dist/cli.js +7 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands/doctor.d.ts +36 -1
- package/dist/commands/doctor.js +130 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/install.d.ts +5 -0
- package/dist/commands/install.js +16 -0
- package/dist/commands/install.js.map +1 -1
- package/dist/core/orchestrate.js +59 -2
- package/dist/core/orchestrate.js.map +1 -1
- package/dist/core/types.d.ts +10 -0
- package/dist/interface/menu.d.ts +44 -0
- package/dist/interface/menu.js +260 -28
- package/dist/interface/menu.js.map +1 -1
- package/dist/interface/render.js +8 -1
- package/dist/interface/render.js.map +1 -1
- package/dist/interface/run.d.ts +12 -3
- package/dist/interface/run.js +3 -3
- package/dist/interface/run.js.map +1 -1
- package/package.json +1 -1
package/dist/core/types.d.ts
CHANGED
|
@@ -163,6 +163,12 @@ export type CoreEvent = {
|
|
|
163
163
|
readonly from: Tier;
|
|
164
164
|
readonly to: Tier;
|
|
165
165
|
readonly reason: string;
|
|
166
|
+
} | {
|
|
167
|
+
readonly type: 'failover';
|
|
168
|
+
readonly from: ProviderId;
|
|
169
|
+
readonly to: ProviderId;
|
|
170
|
+
readonly tier: Tier;
|
|
171
|
+
readonly reason: string;
|
|
166
172
|
} | {
|
|
167
173
|
readonly type: 'notice';
|
|
168
174
|
readonly level: 'info' | 'warn' | 'error';
|
|
@@ -175,4 +181,8 @@ export type CoreEvent = {
|
|
|
175
181
|
readonly totalCostUsd: number;
|
|
176
182
|
readonly sessionId: string;
|
|
177
183
|
readonly attempts: number;
|
|
184
|
+
/** Set on failing finals only: the error category that caused the failure. */
|
|
185
|
+
readonly errorCategory?: import('../providers/port.js').CliError['category'];
|
|
186
|
+
/** Set on failing finals only: the provider that was being used when failure occurred. */
|
|
187
|
+
readonly provider?: import('../providers/port.js').ProviderId;
|
|
178
188
|
};
|
package/dist/interface/menu.d.ts
CHANGED
|
@@ -161,6 +161,50 @@ export declare function renderBudgetLine(spend: SpendSummary, _color: boolean):
|
|
|
161
161
|
* Returns string[] (no ANSI — pure string building, safe for tests).
|
|
162
162
|
*/
|
|
163
163
|
export declare function renderConversationList(metas: ConversationMeta[], nowMs: number): string[];
|
|
164
|
+
/**
|
|
165
|
+
* Count how many timestamps in `times` fall within the half-open window
|
|
166
|
+
* `[now - windowMs, now]` (both endpoints inclusive).
|
|
167
|
+
*
|
|
168
|
+
* Pure — no I/O, no side effects, never throws.
|
|
169
|
+
*
|
|
170
|
+
* @param times - Immutable array of epoch-ms timestamps (e.g. from ctx.clock.now()).
|
|
171
|
+
* @param now - The current epoch-ms (e.g. from ctx.clock.now()).
|
|
172
|
+
* @param windowMs - Width of the sliding window in milliseconds (e.g. 1500).
|
|
173
|
+
* @returns Number of entries within the window.
|
|
174
|
+
*/
|
|
175
|
+
export declare function countRecentInterrupts(times: readonly number[], now: number, windowMs: number): number;
|
|
176
|
+
/**
|
|
177
|
+
* Decide what action to take based on the number of recent Ctrl+C presses and
|
|
178
|
+
* whether a task is currently running.
|
|
179
|
+
*
|
|
180
|
+
* Rules:
|
|
181
|
+
* count >= 3 → `'exit-app'`
|
|
182
|
+
* count === 2 → `'to-menu'`
|
|
183
|
+
* count === 1 && taskRunning → `'cancel-task'`
|
|
184
|
+
* count === 1 && !taskRunning → `'hint'`
|
|
185
|
+
* count <= 0 → `'hint'` (defensive)
|
|
186
|
+
*
|
|
187
|
+
* Pure — never throws, no I/O, no side effects.
|
|
188
|
+
*
|
|
189
|
+
* @param count - Number of recent interrupt presses (from countRecentInterrupts).
|
|
190
|
+
* @param taskRunning - Whether a task is currently in-flight.
|
|
191
|
+
* @returns The action to take.
|
|
192
|
+
*/
|
|
193
|
+
export declare function interpretInterrupt(count: number, taskRunning: boolean): 'cancel-task' | 'to-menu' | 'exit-app' | 'hint';
|
|
194
|
+
/**
|
|
195
|
+
* Decide whether a raw-session SIGINT count warrants escaping back to the menu.
|
|
196
|
+
*
|
|
197
|
+
* Returns true when count >= 2 (rapid double Ctrl+C), false otherwise.
|
|
198
|
+
* A single press (count === 1) is left entirely to the child process — the
|
|
199
|
+
* terminal already delivers SIGINT to the whole foreground process group, so
|
|
200
|
+
* claude/codex/opencode handles its own cancel without interference from us.
|
|
201
|
+
*
|
|
202
|
+
* Pure — no I/O, no side effects, never throws.
|
|
203
|
+
*
|
|
204
|
+
* @param count - Number of recent Ctrl+C presses (from countRecentInterrupts).
|
|
205
|
+
* @returns True when the user should be returned to the myshell-tools menu.
|
|
206
|
+
*/
|
|
207
|
+
export declare function shouldEscapeRawSession(count: number): boolean;
|
|
164
208
|
/**
|
|
165
209
|
* Start the sessions-first interactive menu.
|
|
166
210
|
*
|
package/dist/interface/menu.js
CHANGED
|
@@ -181,6 +181,55 @@ export function renderConversationList(metas, nowMs) {
|
|
|
181
181
|
return `[${idx}] ${pin}${rel} ${m.title}${categorySuffix}`;
|
|
182
182
|
});
|
|
183
183
|
}
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Ctrl+C escape model — pure helpers
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
/**
|
|
188
|
+
* Count how many timestamps in `times` fall within the half-open window
|
|
189
|
+
* `[now - windowMs, now]` (both endpoints inclusive).
|
|
190
|
+
*
|
|
191
|
+
* Pure — no I/O, no side effects, never throws.
|
|
192
|
+
*
|
|
193
|
+
* @param times - Immutable array of epoch-ms timestamps (e.g. from ctx.clock.now()).
|
|
194
|
+
* @param now - The current epoch-ms (e.g. from ctx.clock.now()).
|
|
195
|
+
* @param windowMs - Width of the sliding window in milliseconds (e.g. 1500).
|
|
196
|
+
* @returns Number of entries within the window.
|
|
197
|
+
*/
|
|
198
|
+
export function countRecentInterrupts(times, now, windowMs) {
|
|
199
|
+
const cutoff = now - windowMs;
|
|
200
|
+
let count = 0;
|
|
201
|
+
for (const t of times) {
|
|
202
|
+
if (t >= cutoff && t <= now)
|
|
203
|
+
count += 1;
|
|
204
|
+
}
|
|
205
|
+
return count;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Decide what action to take based on the number of recent Ctrl+C presses and
|
|
209
|
+
* whether a task is currently running.
|
|
210
|
+
*
|
|
211
|
+
* Rules:
|
|
212
|
+
* count >= 3 → `'exit-app'`
|
|
213
|
+
* count === 2 → `'to-menu'`
|
|
214
|
+
* count === 1 && taskRunning → `'cancel-task'`
|
|
215
|
+
* count === 1 && !taskRunning → `'hint'`
|
|
216
|
+
* count <= 0 → `'hint'` (defensive)
|
|
217
|
+
*
|
|
218
|
+
* Pure — never throws, no I/O, no side effects.
|
|
219
|
+
*
|
|
220
|
+
* @param count - Number of recent interrupt presses (from countRecentInterrupts).
|
|
221
|
+
* @param taskRunning - Whether a task is currently in-flight.
|
|
222
|
+
* @returns The action to take.
|
|
223
|
+
*/
|
|
224
|
+
export function interpretInterrupt(count, taskRunning) {
|
|
225
|
+
if (count >= 3)
|
|
226
|
+
return 'exit-app';
|
|
227
|
+
if (count === 2)
|
|
228
|
+
return 'to-menu';
|
|
229
|
+
if (count === 1)
|
|
230
|
+
return taskRunning ? 'cancel-task' : 'hint';
|
|
231
|
+
return 'hint';
|
|
232
|
+
}
|
|
184
233
|
/**
|
|
185
234
|
* Build a {@link LineReader} backed by a single `node:readline` interface.
|
|
186
235
|
*
|
|
@@ -540,11 +589,11 @@ async function runManage(ctx, out, readLine) {
|
|
|
540
589
|
* Follows the injected `readLine` seam so it is fully testable without TTY.
|
|
541
590
|
* Never modifies the native CLI's files.
|
|
542
591
|
*/
|
|
543
|
-
async function runImportNative(ctx, mutableCtx, out, readLine) {
|
|
592
|
+
async function runImportNative(ctx, mutableCtx, out, readLine, loginFn, detectEnvironmentFn) {
|
|
544
593
|
out.write('\nImport from:\n [1] Claude\n [2] Codex\n\n> ');
|
|
545
594
|
const choice = await readLine();
|
|
546
595
|
if (choice === null)
|
|
547
|
-
return;
|
|
596
|
+
return 'menu';
|
|
548
597
|
let provider;
|
|
549
598
|
if (choice === '1') {
|
|
550
599
|
provider = 'claude';
|
|
@@ -554,12 +603,12 @@ async function runImportNative(ctx, mutableCtx, out, readLine) {
|
|
|
554
603
|
}
|
|
555
604
|
else {
|
|
556
605
|
out.write('Cancelled.\n');
|
|
557
|
-
return;
|
|
606
|
+
return 'menu';
|
|
558
607
|
}
|
|
559
608
|
const sessions = await listNativeSessions(provider);
|
|
560
609
|
if (sessions.length === 0) {
|
|
561
610
|
out.write(`No ${provider} conversations found.\n`);
|
|
562
|
-
return;
|
|
611
|
+
return 'menu';
|
|
563
612
|
}
|
|
564
613
|
// Render a numbered picker
|
|
565
614
|
const nowMs = ctx.clock.now();
|
|
@@ -576,29 +625,58 @@ async function runImportNative(ctx, mutableCtx, out, readLine) {
|
|
|
576
625
|
out.write('\nPick a conversation number (or Enter to cancel): ');
|
|
577
626
|
const pick = await readLine();
|
|
578
627
|
if (pick === null || pick.length === 0)
|
|
579
|
-
return;
|
|
628
|
+
return 'menu';
|
|
580
629
|
const num = parseInt(pick, 10);
|
|
581
630
|
if (Number.isNaN(num) || num < 1 || num > sessions.length) {
|
|
582
631
|
out.write('Invalid selection.\n');
|
|
583
|
-
return;
|
|
632
|
+
return 'menu';
|
|
584
633
|
}
|
|
585
634
|
const session = sessions[num - 1];
|
|
586
635
|
if (session === undefined)
|
|
587
|
-
return;
|
|
636
|
+
return 'menu';
|
|
588
637
|
const { id, imported } = await importNativeSession(session, ctx.store);
|
|
589
638
|
const convTitle = session.title.length > 0 ? session.title : '(untitled)';
|
|
590
639
|
out.write(`Imported ${imported} messages into a new conversation: "${convTitle}"\n`);
|
|
591
|
-
// Enter the chat loop for the newly imported conversation
|
|
592
|
-
|
|
640
|
+
// Enter the chat loop for the newly imported conversation.
|
|
641
|
+
// Return value propagates the 'exit' signal to the caller (startMenu).
|
|
642
|
+
return runChatLoop(ctx, mutableCtx, id, out, readLine, loginFn, detectEnvironmentFn);
|
|
593
643
|
}
|
|
594
644
|
// ---------------------------------------------------------------------------
|
|
595
645
|
// Raw provider passthrough
|
|
596
646
|
// ---------------------------------------------------------------------------
|
|
647
|
+
/**
|
|
648
|
+
* Decide whether a raw-session SIGINT count warrants escaping back to the menu.
|
|
649
|
+
*
|
|
650
|
+
* Returns true when count >= 2 (rapid double Ctrl+C), false otherwise.
|
|
651
|
+
* A single press (count === 1) is left entirely to the child process — the
|
|
652
|
+
* terminal already delivers SIGINT to the whole foreground process group, so
|
|
653
|
+
* claude/codex/opencode handles its own cancel without interference from us.
|
|
654
|
+
*
|
|
655
|
+
* Pure — no I/O, no side effects, never throws.
|
|
656
|
+
*
|
|
657
|
+
* @param count - Number of recent Ctrl+C presses (from countRecentInterrupts).
|
|
658
|
+
* @returns True when the user should be returned to the myshell-tools menu.
|
|
659
|
+
*/
|
|
660
|
+
export function shouldEscapeRawSession(count) {
|
|
661
|
+
return count >= 2;
|
|
662
|
+
}
|
|
597
663
|
/**
|
|
598
664
|
* Launch the native `claude`, `codex`, or `opencode` interactive CLI directly
|
|
599
665
|
* (stdio:inherit), so the user gets a raw provider session. The session is owned
|
|
600
666
|
* by the native CLI (not by myshell-tools); we simply hand over the terminal and wait.
|
|
601
667
|
*
|
|
668
|
+
* On Unix, a best-effort "Ctrl+C twice → back to menu" escape is registered:
|
|
669
|
+
* - A single Ctrl+C is left entirely to the child (the terminal delivers SIGINT
|
|
670
|
+
* to the whole foreground group; we must NOT interfere with single presses).
|
|
671
|
+
* - Two presses within 1 500 ms → SIGTERM the child and return to the menu.
|
|
672
|
+
* On Windows the SIGINT handler is NOT registered (process-group semantics differ
|
|
673
|
+
* and forced interception risks a broken console) — behaviour is exactly as today.
|
|
674
|
+
*
|
|
675
|
+
* The SIGINT listener is always removed in a finally block so it never leaks back
|
|
676
|
+
* to the menu loop. This is best-effort: forcibly terminating the child to return
|
|
677
|
+
* to the menu may leave the terminal in a non-ideal state; the existing
|
|
678
|
+
* "Returned from <bin>." message and menu re-render happen on return regardless.
|
|
679
|
+
*
|
|
602
680
|
* On exit (any exit code), control returns to the myshell-tools menu.
|
|
603
681
|
*/
|
|
604
682
|
async function runRawProviderSession(out, readLine, env) {
|
|
@@ -623,15 +701,67 @@ async function runRawProviderSession(out, readLine, env) {
|
|
|
623
701
|
}
|
|
624
702
|
const bin = selected.bin;
|
|
625
703
|
out.write(`\nLaunching ${bin} — press Ctrl+C or type /exit inside ${bin} to return.\n`);
|
|
704
|
+
// Best-effort escape hint (Unix only — on Windows we skip the handler).
|
|
705
|
+
if (process.platform !== 'win32') {
|
|
706
|
+
out.write('(Ctrl+C twice quickly → back to the myshell menu)\n');
|
|
707
|
+
}
|
|
626
708
|
// stdio:'inherit' hands the terminal to the native CLI so its interactive
|
|
627
709
|
// session runs in place. reject:false so we return to menu on any exit code.
|
|
628
|
-
|
|
710
|
+
const subprocess = execa(bin, [], { stdio: 'inherit', reject: false });
|
|
711
|
+
// Unix-only: register the rapid-double-Ctrl+C escape handler.
|
|
712
|
+
// On Windows: skip entirely — SIGINT/process-group semantics differ and
|
|
713
|
+
// forced interception risks a broken console. Behaviour is as before today.
|
|
714
|
+
if (process.platform !== 'win32') {
|
|
715
|
+
const INTERRUPT_WINDOW_MS = 1_500;
|
|
716
|
+
const interruptTimes = [];
|
|
717
|
+
const rawSigintHandler = () => {
|
|
718
|
+
const now = Date.now();
|
|
719
|
+
interruptTimes.push(now);
|
|
720
|
+
const count = countRecentInterrupts(interruptTimes, now, INTERRUPT_WINDOW_MS);
|
|
721
|
+
// count === 1: do nothing — let the single Ctrl+C reach the child via the
|
|
722
|
+
// terminal's foreground-group delivery. Do NOT kill or write anything here.
|
|
723
|
+
if (shouldEscapeRawSession(count)) {
|
|
724
|
+
// Rapid double press → user wants to return to the menu.
|
|
725
|
+
out.write('\n[info] Returning to menu…\n');
|
|
726
|
+
subprocess.kill('SIGTERM');
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
process.on('SIGINT', rawSigintHandler);
|
|
730
|
+
try {
|
|
731
|
+
await subprocess;
|
|
732
|
+
}
|
|
733
|
+
finally {
|
|
734
|
+
process.removeListener('SIGINT', rawSigintHandler);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
// Windows: no SIGINT handler — await the child normally.
|
|
739
|
+
await subprocess;
|
|
740
|
+
}
|
|
629
741
|
out.write(`\nReturned from ${bin}.\n`);
|
|
630
742
|
}
|
|
631
743
|
// ---------------------------------------------------------------------------
|
|
632
744
|
// Chat loop
|
|
633
745
|
// ---------------------------------------------------------------------------
|
|
634
|
-
|
|
746
|
+
/**
|
|
747
|
+
* Run the interactive chat loop for a single conversation.
|
|
748
|
+
*
|
|
749
|
+
* Returns `'menu'` when the user exits normally (/back, /exit, EOF, or 2×Ctrl+C)
|
|
750
|
+
* and `'exit'` when the user presses Ctrl+C three times within the 1 500 ms window,
|
|
751
|
+
* signalling that the entire app should quit.
|
|
752
|
+
*
|
|
753
|
+
* The SIGINT handler uses a press-counting model (window ~1 500 ms, timestamps from
|
|
754
|
+
* `ctx.clock.now()`):
|
|
755
|
+
* 1 press while a task runs → cancel the task, stay in chat.
|
|
756
|
+
* 1 press at the prompt → print a hint, stay in chat.
|
|
757
|
+
* 2 presses within the window → abort any running task, break to menu.
|
|
758
|
+
* 3 presses within the window → abort any running task, break and signal exit.
|
|
759
|
+
*
|
|
760
|
+
* The handler is registered on entry and removed in the `finally` block so it
|
|
761
|
+
* never leaks between chat sessions. NO process.exit() is called here; cli.ts
|
|
762
|
+
* owns process lifetime.
|
|
763
|
+
*/
|
|
764
|
+
async function runChatLoop(ctx, mutableCtx, convId, out, readLine, loginFn, detectEnvironmentFn) {
|
|
635
765
|
// Print a short recap of the conversation (last entry) if history exists
|
|
636
766
|
const history = await ctx.store.load(convId);
|
|
637
767
|
if (history.length > 0) {
|
|
@@ -642,22 +772,78 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine) {
|
|
|
642
772
|
}
|
|
643
773
|
out.write(prompt('task or /help', out.color) + '\n');
|
|
644
774
|
let currentAc = null;
|
|
645
|
-
//
|
|
775
|
+
// Interrupt timestamps — populated on each SIGINT; checked against the
|
|
776
|
+
// 1 500 ms sliding window. Using ctx.clock.now() (not Date.now) so tests
|
|
777
|
+
// can drive time with a fake clock.
|
|
778
|
+
const interruptTimes = [];
|
|
779
|
+
const INTERRUPT_WINDOW_MS = 1_500;
|
|
780
|
+
// The 'exit' signal is communicated from the SIGINT handler to the main
|
|
781
|
+
// loop via this flag (the handler can't break the outer while directly).
|
|
782
|
+
let shouldExit = false;
|
|
783
|
+
// Handle Ctrl+C with the press-counting model.
|
|
646
784
|
const sigintHandler = () => {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
785
|
+
const now = ctx.clock.now();
|
|
786
|
+
interruptTimes.push(now);
|
|
787
|
+
const count = countRecentInterrupts(interruptTimes, now, INTERRUPT_WINDOW_MS);
|
|
788
|
+
const action = interpretInterrupt(count, currentAc !== null);
|
|
789
|
+
if (action === 'cancel-task') {
|
|
790
|
+
currentAc?.abort();
|
|
650
791
|
currentAc = null;
|
|
792
|
+
out.write('\n[warn] Task cancelled. (Ctrl+C again → menu, ×3 → exit)\n');
|
|
793
|
+
}
|
|
794
|
+
else if (action === 'hint') {
|
|
795
|
+
out.write('\n[info] Ctrl+C again → back to menu, ×3 → exit to shell.\n');
|
|
796
|
+
}
|
|
797
|
+
else if (action === 'to-menu') {
|
|
798
|
+
// Abort any running task, then tell the main loop to break back to menu.
|
|
799
|
+
if (currentAc !== null) {
|
|
800
|
+
currentAc.abort();
|
|
801
|
+
currentAc = null;
|
|
802
|
+
}
|
|
803
|
+
// Signal the main loop; the loop checks this flag after each await.
|
|
804
|
+
shouldExit = false;
|
|
805
|
+
// We can't directly break the loop from an event handler, so we set a
|
|
806
|
+
// flag and resolve any pending readLine by closing (the loop checks the
|
|
807
|
+
// flag). In practice the loop is either awaiting readLine() or runTask().
|
|
808
|
+
// For the readLine case the user typed nothing yet — we need to interrupt
|
|
809
|
+
// that await. We use a shared resolver pattern: see loopBreaker below.
|
|
810
|
+
loopBreaker?.('menu');
|
|
651
811
|
}
|
|
652
812
|
else {
|
|
653
|
-
|
|
813
|
+
// exit-app
|
|
814
|
+
if (currentAc !== null) {
|
|
815
|
+
currentAc.abort();
|
|
816
|
+
currentAc = null;
|
|
817
|
+
}
|
|
818
|
+
shouldExit = true;
|
|
819
|
+
loopBreaker?.('exit');
|
|
654
820
|
}
|
|
655
821
|
};
|
|
822
|
+
// loopBreaker lets the SIGINT handler resolve the loop-level promise so the
|
|
823
|
+
// `while (true)` can break immediately even when awaiting readLine().
|
|
824
|
+
let loopBreaker = null;
|
|
656
825
|
process.on('SIGINT', sigintHandler);
|
|
826
|
+
let loopResult = 'menu';
|
|
657
827
|
try {
|
|
658
828
|
while (true) {
|
|
659
829
|
out.write('myshell-tools> ');
|
|
660
|
-
|
|
830
|
+
// Race readLine() against a loopBreak signal from the SIGINT handler.
|
|
831
|
+
// When Ctrl+C fires (to-menu or exit-app), loopBreaker is called with the
|
|
832
|
+
// desired result, which wins the race and breaks the loop.
|
|
833
|
+
const line = await new Promise((resolve) => {
|
|
834
|
+
loopBreaker = resolve;
|
|
835
|
+
readLine().then(resolve).catch(() => resolve(null));
|
|
836
|
+
});
|
|
837
|
+
loopBreaker = null;
|
|
838
|
+
// SIGINT-driven exit signals
|
|
839
|
+
if (line === 'menu') {
|
|
840
|
+
loopResult = 'menu';
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
if (line === 'exit') {
|
|
844
|
+
loopResult = 'exit';
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
661
847
|
// EOF → exit the chat loop gracefully
|
|
662
848
|
if (line === null)
|
|
663
849
|
break;
|
|
@@ -683,15 +869,16 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine) {
|
|
|
683
869
|
// Build per-provider advertised model sets from the live env so route()
|
|
684
870
|
// can prefer a model the CLI actually advertises. Only include installed
|
|
685
871
|
// providers (exactOptionalPropertyTypes is ON).
|
|
872
|
+
// Use mutableCtx.env (not ctx.env) so post-login re-detect is reflected.
|
|
686
873
|
const menuAvailableModels = {};
|
|
687
|
-
if (
|
|
688
|
-
menuAvailableModels['claude'] =
|
|
874
|
+
if (mutableCtx.env.claude.installed && mutableCtx.env.claude.availableModels.length > 0) {
|
|
875
|
+
menuAvailableModels['claude'] = mutableCtx.env.claude.availableModels;
|
|
689
876
|
}
|
|
690
|
-
if (
|
|
691
|
-
menuAvailableModels['codex'] =
|
|
877
|
+
if (mutableCtx.env.codex.installed && mutableCtx.env.codex.availableModels.length > 0) {
|
|
878
|
+
menuAvailableModels['codex'] = mutableCtx.env.codex.availableModels;
|
|
692
879
|
}
|
|
693
|
-
if (
|
|
694
|
-
menuAvailableModels['opencode'] =
|
|
880
|
+
if (mutableCtx.env.opencode.installed && mutableCtx.env.opencode.availableModels.length > 0) {
|
|
881
|
+
menuAvailableModels['opencode'] = mutableCtx.env.opencode.availableModels;
|
|
695
882
|
}
|
|
696
883
|
const deps = {
|
|
697
884
|
clock: ctx.clock,
|
|
@@ -707,13 +894,50 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine) {
|
|
|
707
894
|
};
|
|
708
895
|
const ac = new AbortController();
|
|
709
896
|
currentAc = ac;
|
|
710
|
-
await runTask(line, deps, out, ac.signal);
|
|
897
|
+
const result = await runTask(line, deps, out, ac.signal);
|
|
711
898
|
currentAc = null;
|
|
899
|
+
// Check for a loopBreaker signal that fired during the task (e.g. 3×Ctrl+C
|
|
900
|
+
// while runTask was awaited — the abort fires and the flag is set).
|
|
901
|
+
if (shouldExit) {
|
|
902
|
+
loopResult = 'exit';
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
// Inline re-login on auth failure: offer to sign in and retry once.
|
|
906
|
+
if (result.final !== undefined &&
|
|
907
|
+
!result.final.success &&
|
|
908
|
+
result.final.errorCategory === 'auth' &&
|
|
909
|
+
result.final.provider !== undefined) {
|
|
910
|
+
const failingProvider = result.final.provider;
|
|
911
|
+
out.write(`\n[warn] ${failingProvider} isn't signed in.\n`);
|
|
912
|
+
out.write(`Sign in to ${failingProvider} now and retry? (Y/n) `);
|
|
913
|
+
const ans = await readLine();
|
|
914
|
+
if (parseYesNo(ans, true)) {
|
|
915
|
+
await loginFn(out, failingProvider, { readLine });
|
|
916
|
+
mutableCtx.env = await detectEnvironmentFn();
|
|
917
|
+
// Retry the same task once.
|
|
918
|
+
const retryAc = new AbortController();
|
|
919
|
+
currentAc = retryAc;
|
|
920
|
+
const retryResult = await runTask(line, deps, out, retryAc.signal);
|
|
921
|
+
currentAc = null;
|
|
922
|
+
if (shouldExit) {
|
|
923
|
+
loopResult = 'exit';
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
// If still auth failure after retry, inform and continue to prompt.
|
|
927
|
+
if (retryResult.final !== undefined &&
|
|
928
|
+
!retryResult.final.success &&
|
|
929
|
+
retryResult.final.errorCategory === 'auth') {
|
|
930
|
+
out.write(`\n[warn] Still not signed in to ${failingProvider}. Returning to prompt.\n`);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
712
934
|
}
|
|
713
935
|
}
|
|
714
936
|
finally {
|
|
715
937
|
process.removeListener('SIGINT', sigintHandler);
|
|
938
|
+
loopBreaker = null;
|
|
716
939
|
}
|
|
940
|
+
return loopResult;
|
|
717
941
|
}
|
|
718
942
|
// ---------------------------------------------------------------------------
|
|
719
943
|
// Main screen render
|
|
@@ -883,7 +1107,9 @@ export async function startMenu(ctx, out) {
|
|
|
883
1107
|
const firstMsg = await readLine();
|
|
884
1108
|
if (firstMsg !== null && firstMsg.length > 0) {
|
|
885
1109
|
const meta = await ctx.store.create(firstMsg);
|
|
886
|
-
await runChatLoop(ctx, mutableCtx, meta.id, out, readLine);
|
|
1110
|
+
const chatResult = await runChatLoop(ctx, mutableCtx, meta.id, out, readLine, loginFn, detectEnvironmentFn);
|
|
1111
|
+
if (chatResult === 'exit')
|
|
1112
|
+
break;
|
|
887
1113
|
}
|
|
888
1114
|
continue;
|
|
889
1115
|
}
|
|
@@ -892,7 +1118,9 @@ export async function startMenu(ctx, out) {
|
|
|
892
1118
|
const all = await ctx.store.list();
|
|
893
1119
|
const latest = all[0];
|
|
894
1120
|
if (latest !== undefined) {
|
|
895
|
-
await runChatLoop(ctx, mutableCtx, latest.id, out, readLine);
|
|
1121
|
+
const chatResult = await runChatLoop(ctx, mutableCtx, latest.id, out, readLine, loginFn, detectEnvironmentFn);
|
|
1122
|
+
if (chatResult === 'exit')
|
|
1123
|
+
break;
|
|
896
1124
|
}
|
|
897
1125
|
else {
|
|
898
1126
|
out.write('No conversations yet. Press n to start one.\n');
|
|
@@ -904,7 +1132,9 @@ export async function startMenu(ctx, out) {
|
|
|
904
1132
|
if (!Number.isNaN(digit) && digit >= 1 && digit <= 9) {
|
|
905
1133
|
const target = metas[digit - 1];
|
|
906
1134
|
if (target !== undefined) {
|
|
907
|
-
await runChatLoop(ctx, mutableCtx, target.id, out, readLine);
|
|
1135
|
+
const chatResult = await runChatLoop(ctx, mutableCtx, target.id, out, readLine, loginFn, detectEnvironmentFn);
|
|
1136
|
+
if (chatResult === 'exit')
|
|
1137
|
+
break;
|
|
908
1138
|
}
|
|
909
1139
|
else {
|
|
910
1140
|
out.write(`No conversation at position ${digit}.\n`);
|
|
@@ -918,7 +1148,9 @@ export async function startMenu(ctx, out) {
|
|
|
918
1148
|
}
|
|
919
1149
|
// ---- [i] Import a native conversation -----------------------------------
|
|
920
1150
|
if (key === 'i') {
|
|
921
|
-
await runImportNative(ctx, mutableCtx, out, readLine);
|
|
1151
|
+
const importResult = await runImportNative(ctx, mutableCtx, out, readLine, loginFn, detectEnvironmentFn);
|
|
1152
|
+
if (importResult === 'exit')
|
|
1153
|
+
break;
|
|
922
1154
|
continue;
|
|
923
1155
|
}
|
|
924
1156
|
// ---- [r] Open a raw provider session ------------------------------------
|