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.
@@ -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
  };
@@ -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
  *
@@ -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
- await runChatLoop(ctx, mutableCtx, id, out, readLine);
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
- await execa(bin, [], { stdio: 'inherit', reject: false });
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
- async function runChatLoop(ctx, mutableCtx, convId, out, readLine) {
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
- // Handle SIGINT: cancel in-flight task or return to menu
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
- if (currentAc !== null) {
648
- currentAc.abort();
649
- out.write('\n[warn] Task cancelled.\n');
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
- out.write('\n[info] Use /back or /exit to return to the menu.\n');
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
- const line = await readLine();
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 (ctx.env.claude.installed && ctx.env.claude.availableModels.length > 0) {
688
- menuAvailableModels['claude'] = ctx.env.claude.availableModels;
874
+ if (mutableCtx.env.claude.installed && mutableCtx.env.claude.availableModels.length > 0) {
875
+ menuAvailableModels['claude'] = mutableCtx.env.claude.availableModels;
689
876
  }
690
- if (ctx.env.codex.installed && ctx.env.codex.availableModels.length > 0) {
691
- menuAvailableModels['codex'] = ctx.env.codex.availableModels;
877
+ if (mutableCtx.env.codex.installed && mutableCtx.env.codex.availableModels.length > 0) {
878
+ menuAvailableModels['codex'] = mutableCtx.env.codex.availableModels;
692
879
  }
693
- if (ctx.env.opencode.installed && ctx.env.opencode.availableModels.length > 0) {
694
- menuAvailableModels['opencode'] = ctx.env.opencode.availableModels;
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 ------------------------------------