myshell-tools 2.15.0 → 3.2.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +39 -44
  3. package/dist/cli.js +33 -22
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/cost.js +44 -21
  6. package/dist/commands/cost.js.map +1 -1
  7. package/dist/commands/doctor.d.ts +17 -4
  8. package/dist/commands/doctor.js +62 -35
  9. package/dist/commands/doctor.js.map +1 -1
  10. package/dist/commands/login.d.ts +18 -10
  11. package/dist/commands/login.js +81 -109
  12. package/dist/commands/login.js.map +1 -1
  13. package/dist/core/json-envelope.js +31 -3
  14. package/dist/core/json-envelope.js.map +1 -1
  15. package/dist/core/native-session.d.ts +57 -0
  16. package/dist/core/native-session.js +68 -0
  17. package/dist/core/native-session.js.map +1 -0
  18. package/dist/core/orchestrate.js +106 -43
  19. package/dist/core/orchestrate.js.map +1 -1
  20. package/dist/core/types.d.ts +26 -0
  21. package/dist/infra/atomic.d.ts +9 -1
  22. package/dist/infra/atomic.js +12 -2
  23. package/dist/infra/atomic.js.map +1 -1
  24. package/dist/infra/config.d.ts +8 -0
  25. package/dist/infra/config.js.map +1 -1
  26. package/dist/infra/credentials.d.ts +69 -3
  27. package/dist/infra/credentials.js +118 -9
  28. package/dist/infra/credentials.js.map +1 -1
  29. package/dist/infra/health.d.ts +57 -0
  30. package/dist/infra/health.js +96 -0
  31. package/dist/infra/health.js.map +1 -0
  32. package/dist/infra/insights.d.ts +14 -0
  33. package/dist/infra/insights.js +31 -0
  34. package/dist/infra/insights.js.map +1 -1
  35. package/dist/interface/menu.d.ts +70 -5
  36. package/dist/interface/menu.js +292 -88
  37. package/dist/interface/menu.js.map +1 -1
  38. package/dist/interface/render.js +20 -13
  39. package/dist/interface/render.js.map +1 -1
  40. package/dist/providers/claude.d.ts +24 -8
  41. package/dist/providers/claude.js +77 -15
  42. package/dist/providers/claude.js.map +1 -1
  43. package/dist/providers/codex-parse.js +14 -2
  44. package/dist/providers/codex-parse.js.map +1 -1
  45. package/dist/providers/codex.d.ts +13 -1
  46. package/dist/providers/codex.js +19 -8
  47. package/dist/providers/codex.js.map +1 -1
  48. package/dist/providers/detect.js +22 -1
  49. package/dist/providers/detect.js.map +1 -1
  50. package/dist/providers/port.d.ts +15 -0
  51. package/dist/providers/registry.d.ts +8 -4
  52. package/dist/providers/registry.js +7 -6
  53. package/dist/providers/registry.js.map +1 -1
  54. package/dist/ui/help.d.ts +17 -0
  55. package/dist/ui/help.js +106 -0
  56. package/dist/ui/help.js.map +1 -0
  57. package/package.json +1 -1
@@ -19,17 +19,19 @@ import readline from 'node:readline';
19
19
  import { execa } from 'execa';
20
20
  import { saveConfig } from '../infra/config.js';
21
21
  import { readLedger } from '../infra/ledger.js';
22
- import { summarizeSpend, formatUsd } from '../infra/insights.js';
22
+ import { summarizeSpend, formatTokens } from '../infra/insights.js';
23
23
  import { detectEnvironment, getInstallCommand } from '../providers/detect.js';
24
24
  import { installProvider, installCommandFor } from '../providers/install.js';
25
25
  import { listNativeSessions, importNativeSession } from '../providers/native-sessions.js';
26
26
  import { DEFAULT_POLICY, POLICY_PRESETS } from '../core/policy.js';
27
+ import { planNativeSession } from '../core/native-session.js';
27
28
  import { runTask } from './run.js';
28
29
  import { runLogin } from '../commands/login.js';
29
30
  import { runDoctor } from '../commands/doctor.js';
30
31
  import { runCost } from '../commands/cost.js';
31
32
  import { runInstall } from '../commands/install.js';
32
33
  import { box, separator, menu, prompt } from '../ui/tui.js';
34
+ import { loadClaudeTokenCapturedAt, claudeTokenStatus } from '../infra/credentials.js';
33
35
  // ---------------------------------------------------------------------------
34
36
  // Pure helpers — exported for unit tests
35
37
  // ---------------------------------------------------------------------------
@@ -130,6 +132,53 @@ export function relativeTime(thenMs, nowMs) {
130
132
  const days = Math.floor(diffMs / (24 * 60 * 60 * 1000));
131
133
  return `${days}d ago`;
132
134
  }
135
+ /**
136
+ * Build the short version-status suffix shown next to the version number in the
137
+ * header title — so the user always knows, at a glance, whether they are current.
138
+ *
139
+ * Pure — no I/O. Returns a leading-space string ready to append to the title:
140
+ * - update available + known latest → ` → 3.1.0 available`
141
+ * - up to date (latest known, no update) → ` (latest)`
142
+ * - check failed / offline (latest unknown) → `` (claim nothing)
143
+ *
144
+ * @param updateInfo - Result of the update check, or undefined when not run.
145
+ */
146
+ export function versionStatusLabel(updateInfo) {
147
+ if (updateInfo === undefined)
148
+ return '';
149
+ if (updateInfo.updateAvailable && updateInfo.latest !== null) {
150
+ return ` → ${updateInfo.latest} available`;
151
+ }
152
+ if (updateInfo.latest !== null)
153
+ return ' (latest)';
154
+ return '';
155
+ }
156
+ /**
157
+ * Detect whether myshell-tools is running via `npx` rather than a global install.
158
+ *
159
+ * Pure — takes the running script path (process.argv[1]) and the environment.
160
+ * npx executes packages from a cache directory containing a `_npx` segment
161
+ * (e.g. ~/.npm/_npx/<hash>/node_modules/myshell-tools/dist/cli.js), so the
162
+ * presence of that segment in the script path is the reliable signal. Handles
163
+ * both POSIX and Windows separators. Never throws.
164
+ *
165
+ * Why it matters: self-update runs `npm install -g`, which an npx invocation
166
+ * will ignore on the next run (npx re-serves its own cache). So under npx we
167
+ * skip silent auto-update and instead tell the user to install globally.
168
+ *
169
+ * @param scriptPath - The running script path (typically process.argv[1]).
170
+ * @param env - Environment to read npm_* hints from.
171
+ */
172
+ export function isRunningUnderNpx(scriptPath, env) {
173
+ if (scriptPath !== undefined && (scriptPath.includes('/_npx/') || scriptPath.includes('\\_npx\\'))) {
174
+ return true;
175
+ }
176
+ const execpath = env['npm_execpath'];
177
+ if (execpath !== undefined && (execpath.includes('/_npx/') || execpath.includes('\\_npx\\'))) {
178
+ return true;
179
+ }
180
+ return false;
181
+ }
133
182
  /**
134
183
  * Build the header box lines (provider status) from real EnvironmentStatus.
135
184
  * Returns string[] safe to pass as the `lines` arg to box().
@@ -139,8 +188,12 @@ export function relativeTime(thenMs, nowMs) {
139
188
  * ⚠️ when ps.installed && !ps.authenticated (append " not signed in")
140
189
  * ❌ when !ps.installed (append install command)
141
190
  * Plan label appended when ps.plan is non-null (e.g. " (Max x5)").
191
+ *
192
+ * @param claudeToken - Optional pre-computed token lifetime status. When the token
193
+ * is near expiry or expired, ONE concise warning line is appended. Computed by
194
+ * the caller (startMenu) so this function stays pure and I/O-free.
142
195
  */
143
- export function renderHeaderLines(env, _version) {
196
+ export function renderHeaderLines(env, _version, claudeToken) {
144
197
  const lines = [];
145
198
  for (const ps of [env.claude, env.codex]) {
146
199
  const planSuffix = ps.plan != null ? ` (${ps.plan})` : '';
@@ -160,30 +213,46 @@ export function renderHeaderLines(env, _version) {
160
213
  const ps = env.opencode;
161
214
  const planSuffix = ps.plan != null ? ` (${ps.plan})` : '';
162
215
  if (ps.authenticated) {
163
- lines.push(`✅ ${ps.id}: ready${planSuffix}`);
216
+ // Be explicit that "ready" is based on free models, not a credential probe.
217
+ lines.push(`✅ ${ps.id}: ready (free models)${planSuffix}`);
164
218
  }
165
219
  else {
166
220
  lines.push(`⚠️ ${ps.id}: not signed in${planSuffix}`);
167
221
  }
168
222
  }
223
+ // Token expiry warning — only when near-expiry or expired (not on every launch).
224
+ if (claudeToken != null && (claudeToken.expired || claudeToken.nearExpiry)) {
225
+ if (claudeToken.expired) {
226
+ lines.push(`⚠️ claude token EXPIRED — run: myshell-tools login claude --code`);
227
+ }
228
+ else {
229
+ lines.push(`⚠️ claude token expires in ${claudeToken.daysLeft} days — run: myshell-tools login claude --code`);
230
+ }
231
+ }
169
232
  return lines;
170
233
  }
171
234
  /**
172
- * Render the budget/spend status line shown beneath the provider header.
235
+ * Render the activity status line shown beneath the provider header.
173
236
  *
174
- * Uses real numbers only all values come from the SpendSummary which is
175
- * derived from `readLedger`. No digit-% literals appear in this function; it
176
- * shows dollar amounts only.
237
+ * This is a SUBSCRIPTION tool, not an API-key tool per-token dollar figures
238
+ * don't map to flat subscription billing and read as misleading bloat, so the
239
+ * always-on line shows REAL, measured signals only: task count and tokens. The
240
+ * estimated-dollar view lives on-demand in `myshell-tools cost`, clearly
241
+ * captioned there as an API-equivalent (not your actual bill).
242
+ *
243
+ * Uses real numbers only — all values come from the SpendSummary derived from
244
+ * `readLedger`. No digit-% literals appear here.
177
245
  *
178
246
  * @param spend - Output of summarizeSpend() over real ledger entries.
179
247
  * @param color - When false, no ANSI escape codes are emitted.
180
248
  */
181
249
  export function renderBudgetLine(spend, _color) {
182
250
  if (spend.calls === 0) {
183
- return 'Today: ' + formatUsd(0) + ' · no runs yet';
251
+ return 'No runs yet press n to start';
184
252
  }
185
- const todayPart = 'Today: ' + formatUsd(spend.todayUsd) + ' · ' + String(spend.calls) + ' calls';
186
- const totalPart = 'Total: ' + formatUsd(spend.totalUsd);
253
+ const taskWord = spend.calls === 1 ? 'task' : 'tasks';
254
+ const todayPart = 'Today: ' + String(spend.calls) + ' ' + taskWord + ' · ' + formatTokens(spend.todayTokens) + ' tokens';
255
+ const totalPart = formatTokens(spend.totalTokens) + ' tokens all-time';
187
256
  return todayPart + ' · ' + totalPart;
188
257
  }
189
258
  /**
@@ -313,6 +382,8 @@ async function runWelcome(ctx, out, readLine, mutableConfig, installProviderFn,
313
382
  let env = ctx.env;
314
383
  const headerLines = renderHeaderLines(env, ctx.version);
315
384
  out.write('\n' + box(`🧠 myshell-tools v${ctx.version} — Setup`, headerLines) + '\n\n');
385
+ // ---- Orientation header --------------------------------------------------
386
+ out.write('Quick setup — a few questions, ~30 seconds. Press Enter to accept the [Capitalized] default.\n\n');
316
387
  // ---- Offer to install any missing provider (claude / codex) --------------
317
388
  // Consent is required: we ask once per missing provider.
318
389
  // Display: (Y/n) — default YES, so Enter installs; explicit n skips.
@@ -369,14 +440,12 @@ async function runWelcome(ctx, out, readLine, mutableConfig, installProviderFn,
369
440
  await loginFn(out, id, { readLine });
370
441
  }
371
442
  }
372
- // ---- Mode / default-shell options ----------------------------------------
373
- out.write('\n');
374
- out.write(' [c] Customize mode\n');
375
- out.write(' [Enter] Continue\n\n');
376
- out.write('> ');
377
- const key = await readLine();
443
+ // ---- Mode selection single collapsed prompt ----------------------------
444
+ // Accepts 1/2/3 directly; Enter (or empty) keeps current default (balanced).
445
+ out.write('\nMode — [1] cost-saver [2] balanced (default) [3] quality-first (Enter = balanced): ');
446
+ const modeKey = await readLine();
378
447
  // EOF during setup — save bare onboarded config and return
379
- if (key === null) {
448
+ if (modeKey === null) {
380
449
  const saved = {
381
450
  onboarded: true,
382
451
  setAsDefault: false,
@@ -385,11 +454,19 @@ async function runWelcome(ctx, out, readLine, mutableConfig, installProviderFn,
385
454
  await saveConfig(saved);
386
455
  return saved;
387
456
  }
388
- let updated = mutableConfig;
389
- if (key === 'c') {
390
- updated = await runModeSelect(updated, out, readLine);
391
- }
392
- // [Enter] or anything else → fall through to save & go
457
+ let newMode = mutableConfig.mode;
458
+ if (modeKey === '1')
459
+ newMode = 'cost-saver';
460
+ else if (modeKey === '2')
461
+ newMode = 'balanced';
462
+ else if (modeKey === '3')
463
+ newMode = 'quality-first';
464
+ // Enter/empty/anything else → keep current (balanced default)
465
+ const updated = {
466
+ onboarded: mutableConfig.onboarded,
467
+ setAsDefault: mutableConfig.setAsDefault,
468
+ ...(newMode !== undefined ? { mode: newMode } : {}),
469
+ };
393
470
  // Default is NO for set-as-default — require explicit 'y' to enable.
394
471
  out.write('Set myshell-tools as your default shell tool? (y/N) ');
395
472
  const defaultAns = await readLine();
@@ -440,6 +517,9 @@ async function runModeSelect(config, out, readLine) {
440
517
  onboarded: config.onboarded,
441
518
  setAsDefault: config.setAsDefault,
442
519
  ...(newMode !== undefined ? { mode: newMode } : {}),
520
+ // Preserve other prefs so changing mode doesn't silently reset them.
521
+ ...(config.autoUpdate === false ? { autoUpdate: false } : {}),
522
+ ...(config.nativeSessions === true ? { nativeSessions: true } : {}),
443
523
  };
444
524
  await saveConfig(updated);
445
525
  out.write(`Mode set to: ${newMode ?? 'balanced'}\n`);
@@ -460,6 +540,9 @@ async function toggleDefaultShell(config, out) {
460
540
  onboarded: config.onboarded,
461
541
  setAsDefault,
462
542
  ...(config.mode !== undefined ? { mode: config.mode } : {}),
543
+ // Preserve other prefs so toggling default-shell doesn't silently reset them.
544
+ ...(config.autoUpdate === false ? { autoUpdate: false } : {}),
545
+ ...(config.nativeSessions === true ? { nativeSessions: true } : {}),
463
546
  };
464
547
  await saveConfig(updated);
465
548
  return updated;
@@ -471,6 +554,7 @@ async function runSettings(_ctx, mutableCtx, out, readLine) {
471
554
  ` [1] Mode: ${cfg.mode ?? 'balanced'}`,
472
555
  ` [2] Set as default shell: ${cfg.setAsDefault ? 'on' : 'off'}`,
473
556
  ` [3] Auto-update: ${cfg.autoUpdate !== false ? 'on' : 'off'}`,
557
+ ` [4] Native sessions (experimental): ${cfg.nativeSessions === true ? 'on' : 'off'}`,
474
558
  '',
475
559
  ' [Enter] Back',
476
560
  '',
@@ -490,6 +574,9 @@ async function runSettings(_ctx, mutableCtx, out, readLine) {
490
574
  else if (key === '3') {
491
575
  mutableCtx.config = await toggleAutoUpdate(mutableCtx.config, out);
492
576
  }
577
+ else if (key === '4') {
578
+ mutableCtx.config = await toggleNativeSessions(mutableCtx.config, out);
579
+ }
493
580
  // anything else → back
494
581
  }
495
582
  /**
@@ -509,11 +596,34 @@ async function toggleAutoUpdate(config, out) {
509
596
  setAsDefault: config.setAsDefault,
510
597
  ...(config.mode !== undefined ? { mode: config.mode } : {}),
511
598
  ...(!enable ? { autoUpdate: false } : {}),
599
+ ...(config.nativeSessions === true ? { nativeSessions: true } : {}),
512
600
  };
513
601
  await saveConfig(updated);
514
602
  out.write(`Auto-update: ${enable ? 'on' : 'off'}\n`);
515
603
  return updated;
516
604
  }
605
+ /**
606
+ * Toggle the EXPERIMENTAL native-session preference and persist it.
607
+ *
608
+ * When on, conversations that stay on the same provider reuse that provider's
609
+ * native session (Claude `--session-id`/`--resume`) instead of replaying a
610
+ * compacted history block — better context fidelity and less re-sent context.
611
+ * Default OFF; live behavior should be verified with the gated integration test
612
+ * (`npm run test:integration`) before relying on it.
613
+ */
614
+ async function toggleNativeSessions(config, out) {
615
+ const enable = config.nativeSessions !== true;
616
+ const updated = {
617
+ onboarded: config.onboarded,
618
+ setAsDefault: config.setAsDefault,
619
+ ...(config.mode !== undefined ? { mode: config.mode } : {}),
620
+ ...(config.autoUpdate === false ? { autoUpdate: false } : {}),
621
+ ...(enable ? { nativeSessions: true } : {}),
622
+ };
623
+ await saveConfig(updated);
624
+ out.write(`Native sessions (experimental): ${enable ? 'on' : 'off'}\n`);
625
+ return updated;
626
+ }
517
627
  // ---------------------------------------------------------------------------
518
628
  // Manage conversations screen
519
629
  // ---------------------------------------------------------------------------
@@ -596,9 +706,9 @@ async function runManage(ctx, out, readLine) {
596
706
  if (!Number.isNaN(num) && num >= 1 && num <= metas.length) {
597
707
  const conv = metas[num - 1];
598
708
  if (conv !== undefined) {
599
- out.write(`Delete "${conv.title}"? (y/n) `);
709
+ out.write(`Delete "${conv.title}"? (y/N) `);
600
710
  const confirmAns = await readLine();
601
- if ((confirmAns ?? '').toLowerCase() === 'y') {
711
+ if (parseYesNo(confirmAns, false)) {
602
712
  await ctx.store.remove(conv.id);
603
713
  out.write('Deleted.\n');
604
714
  }
@@ -806,9 +916,10 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine, loginFn, dete
806
916
  // can drive time with a fake clock.
807
917
  const interruptTimes = [];
808
918
  const INTERRUPT_WINDOW_MS = 1_500;
809
- // The 'exit' signal is communicated from the SIGINT handler to the main
810
- // loop via this flag (the handler can't break the outer while directly).
919
+ // The 'exit' and 'menu' signals are communicated from the SIGINT handler to
920
+ // the main loop via these flags (the handler can't break the outer while directly).
811
921
  let shouldExit = false;
922
+ let shouldMenu = false;
812
923
  // Handle Ctrl+C with the press-counting model.
813
924
  const sigintHandler = () => {
814
925
  const now = ctx.clock.now();
@@ -829,13 +940,10 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine, loginFn, dete
829
940
  currentAc.abort();
830
941
  currentAc = null;
831
942
  }
832
- // Signal the main loop; the loop checks this flag after each await.
833
- shouldExit = false;
834
- // We can't directly break the loop from an event handler, so we set a
835
- // flag and resolve any pending readLine by closing (the loop checks the
836
- // flag). In practice the loop is either awaiting readLine() or runTask().
837
- // For the readLine case the user typed nothing yet — we need to interrupt
838
- // that await. We use a shared resolver pattern: see loopBreaker below.
943
+ // Set shouldMenu so the loop returns 'menu' after any running task settles.
944
+ shouldMenu = true;
945
+ // For the readLine case (idle prompt) we can interrupt the await immediately
946
+ // via the loopBreaker resolver.
839
947
  loopBreaker?.('menu');
840
948
  }
841
949
  else {
@@ -855,7 +963,7 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine, loginFn, dete
855
963
  let loopResult = 'menu';
856
964
  try {
857
965
  while (true) {
858
- out.write('myshell-tools> ');
966
+ out.write('> ');
859
967
  // Race readLine() against a loopBreak signal from the SIGINT handler.
860
968
  // When Ctrl+C fires (to-menu or exit-app), loopBreaker is called with the
861
969
  // desired result, which wins the race and breaks the loop.
@@ -890,58 +998,89 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine, loginFn, dete
890
998
  const policy = mutableCtx.config.mode !== undefined
891
999
  ? POLICY_PRESETS[mutableCtx.config.mode]
892
1000
  : DEFAULT_POLICY;
1001
+ // ---- Bug 4 fix: no-provider gate ----------------------------------------
1002
+ // Check whether any provider is actually authenticated before dispatching a
1003
+ // task that is doomed to fail. opencode counts as authenticated-when-installed.
1004
+ const hasAuthenticatedProvider = mutableCtx.env.claude.authenticated ||
1005
+ mutableCtx.env.codex.authenticated ||
1006
+ mutableCtx.env.opencode.authenticated ||
1007
+ mutableCtx.env.opencode.installed;
1008
+ if (!hasAuthenticatedProvider) {
1009
+ out.write('\n[info] No signed-in provider yet — press Ctrl+C to go back, then [j] Claude / [k] Codex / [o] opencode to sign in.\n');
1010
+ continue;
1011
+ }
893
1012
  // Load prior history before each turn so the provider receives conversation
894
1013
  // context. load() returns only the entries persisted so far — the current
895
1014
  // user turn is appended by orchestrate() after this point, so there is no
896
1015
  // double-inclusion risk.
897
1016
  const priorHistory = await ctx.store.load(convId);
898
- // Build per-provider advertised model sets from the live env so route()
899
- // can prefer a model the CLI actually advertises. Only include installed
900
- // providers (exactOptionalPropertyTypes is ON).
901
- // Use mutableCtx.env (not ctx.env) so post-login re-detect is reflected.
902
- const menuAvailableModels = {};
903
- if (mutableCtx.env.claude.installed && mutableCtx.env.claude.availableModels.length > 0) {
904
- menuAvailableModels['claude'] = mutableCtx.env.claude.availableModels;
905
- }
906
- if (mutableCtx.env.codex.installed && mutableCtx.env.codex.availableModels.length > 0) {
907
- menuAvailableModels['codex'] = mutableCtx.env.codex.availableModels;
908
- }
909
- if (mutableCtx.env.opencode.installed && mutableCtx.env.opencode.availableModels.length > 0) {
910
- menuAvailableModels['opencode'] = mutableCtx.env.opencode.availableModels;
911
- }
912
- // Collect authenticated providers from the live env so route() prefers
913
- // signed-in providers over signed-out ones. Uses mutableCtx.env so
914
- // post-login re-detection is reflected without restart.
915
- const menuAuthenticatedProviders = [];
916
- if (mutableCtx.env.claude.authenticated)
917
- menuAuthenticatedProviders.push('claude');
918
- if (mutableCtx.env.codex.authenticated)
919
- menuAuthenticatedProviders.push('codex');
920
- if (mutableCtx.env.opencode.authenticated)
921
- menuAuthenticatedProviders.push('opencode');
922
- const deps = {
923
- clock: ctx.clock,
924
- session: ctx.store.writer(convId),
925
- ledger: ctx.ledger,
926
- policy,
927
- providers: ctx.providers,
928
- cwd: ctx.cwd,
929
- sandbox: ctx.sandbox,
930
- timeoutMs: ctx.timeoutMs,
931
- ...(priorHistory.length > 0 ? { history: priorHistory } : {}),
932
- ...(Object.keys(menuAvailableModels).length > 0 ? { availableModels: menuAvailableModels } : {}),
933
- ...(menuAuthenticatedProviders.length > 0 ? { authenticatedProviders: menuAuthenticatedProviders } : {}),
1017
+ // ---- Build deps from the live mutableCtx.env ----------------------------
1018
+ // This helper is inlined as a function so it can be called again after
1019
+ // inline re-login with the refreshed env (bug 5 fix: no stale auth state).
1020
+ const buildDeps = () => {
1021
+ // Build per-provider advertised model sets from the live env so route()
1022
+ // can prefer a model the CLI actually advertises. Only include installed
1023
+ // providers (exactOptionalPropertyTypes is ON).
1024
+ // Use mutableCtx.env (not ctx.env) so post-login re-detect is reflected.
1025
+ const availableModels = {};
1026
+ if (mutableCtx.env.claude.installed && mutableCtx.env.claude.availableModels.length > 0) {
1027
+ availableModels['claude'] = mutableCtx.env.claude.availableModels;
1028
+ }
1029
+ if (mutableCtx.env.codex.installed && mutableCtx.env.codex.availableModels.length > 0) {
1030
+ availableModels['codex'] = mutableCtx.env.codex.availableModels;
1031
+ }
1032
+ if (mutableCtx.env.opencode.installed && mutableCtx.env.opencode.availableModels.length > 0) {
1033
+ availableModels['opencode'] = mutableCtx.env.opencode.availableModels;
1034
+ }
1035
+ // Collect authenticated providers from the live env so route() prefers
1036
+ // signed-in providers over signed-out ones. Uses mutableCtx.env so
1037
+ // post-login re-detection is reflected without restart.
1038
+ const authenticatedProviders = [];
1039
+ if (mutableCtx.env.claude.authenticated)
1040
+ authenticatedProviders.push('claude');
1041
+ if (mutableCtx.env.codex.authenticated)
1042
+ authenticatedProviders.push('codex');
1043
+ if (mutableCtx.env.opencode.authenticated)
1044
+ authenticatedProviders.push('opencode');
1045
+ // EXPERIMENTAL native session plan (opt-in via config.nativeSessions).
1046
+ // Pure decision; null when disabled. When present, orchestrate uses the
1047
+ // provider's native session for matching tiers instead of replaying history.
1048
+ const nativeSession = planNativeSession({
1049
+ enabled: mutableCtx.config.nativeSessions === true,
1050
+ conversationId: convId,
1051
+ history: priorHistory,
1052
+ });
1053
+ // planNativeSession returns [] when disabled / no conversation id.
1054
+ return {
1055
+ clock: ctx.clock,
1056
+ session: ctx.store.writer(convId),
1057
+ ledger: ctx.ledger,
1058
+ policy,
1059
+ providers: ctx.providers,
1060
+ cwd: ctx.cwd,
1061
+ sandbox: ctx.sandbox,
1062
+ timeoutMs: ctx.timeoutMs,
1063
+ ...(priorHistory.length > 0 ? { history: priorHistory } : {}),
1064
+ ...(Object.keys(availableModels).length > 0 ? { availableModels } : {}),
1065
+ ...(authenticatedProviders.length > 0 ? { authenticatedProviders } : {}),
1066
+ ...(nativeSession.length > 0 ? { nativeSession } : {}),
1067
+ };
934
1068
  };
1069
+ const deps = buildDeps();
935
1070
  const ac = new AbortController();
936
1071
  currentAc = ac;
937
1072
  const result = await runTask(line, deps, out, ac.signal);
938
1073
  currentAc = null;
939
- // Check for a loopBreaker signal that fired during the task (e.g. 3×Ctrl+C
940
- // while runTask was awaited — the abort fires and the flag is set).
1074
+ // Check for SIGINT-driven signals that fired while runTask was awaited.
941
1075
  if (shouldExit) {
942
1076
  loopResult = 'exit';
943
1077
  break;
944
1078
  }
1079
+ // Bug 3 fix: shouldMenu may have been set by a 2×Ctrl+C during the task.
1080
+ if (shouldMenu) {
1081
+ loopResult = 'menu';
1082
+ break;
1083
+ }
945
1084
  // Inline re-login on auth failure: offer to sign in and retry once.
946
1085
  if (result.final !== undefined &&
947
1086
  !result.final.success &&
@@ -953,16 +1092,23 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine, loginFn, dete
953
1092
  const ans = await readLine();
954
1093
  if (parseYesNo(ans, true)) {
955
1094
  await loginFn(out, failingProvider, { readLine });
1095
+ // Bug 5 fix: re-detect with the freshly-authenticated env so the retry
1096
+ // deps reflect the now-signed-in provider (not the stale pre-login state).
956
1097
  mutableCtx.env = await detectEnvironmentFn();
1098
+ const retryDeps = buildDeps();
957
1099
  // Retry the same task once.
958
1100
  const retryAc = new AbortController();
959
1101
  currentAc = retryAc;
960
- const retryResult = await runTask(line, deps, out, retryAc.signal);
1102
+ const retryResult = await runTask(line, retryDeps, out, retryAc.signal);
961
1103
  currentAc = null;
962
1104
  if (shouldExit) {
963
1105
  loopResult = 'exit';
964
1106
  break;
965
1107
  }
1108
+ if (shouldMenu) {
1109
+ loopResult = 'menu';
1110
+ break;
1111
+ }
966
1112
  // If still auth failure after retry, inform and continue to prompt.
967
1113
  if (retryResult.final !== undefined &&
968
1114
  !retryResult.final.success &&
@@ -982,18 +1128,40 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine, loginFn, dete
982
1128
  // ---------------------------------------------------------------------------
983
1129
  // Main screen render
984
1130
  // ---------------------------------------------------------------------------
985
- async function renderMainScreen(ctx, mutableCtx, metas, out, updateInfo) {
1131
+ async function renderMainScreen(ctx, mutableCtx, metas, spend, out, updateInfo, claudeTokenInfo, runningUnderNpx = false, healthIssues = []) {
986
1132
  out.write('\n');
987
- // Header box — always box(), 🧠 emoji, real provider data
988
- const headerLines = renderHeaderLines(mutableCtx.env, ctx.version);
989
- out.write(box(`🧠 myshell-tools v${ctx.version}`, headerLines) + '\n\n');
990
- // Update banner only shown when a newer version is genuinely available
1133
+ // Header box — always box(), 🧠 emoji, real provider data.
1134
+ // Title carries the live version status so the user always knows whether they
1135
+ // are current: "(latest)" when up to date, "→ X.Y.Z available" when not.
1136
+ const headerLines = renderHeaderLines(mutableCtx.env, ctx.version, claudeTokenInfo ?? undefined);
1137
+ const versionLabel = versionStatusLabel(updateInfo);
1138
+ out.write(box(`🧠 myshell-tools v${ctx.version}${versionLabel}`, headerLines) + '\n\n');
1139
+ // Update banner — only shown when a newer version is genuinely available.
991
1140
  if (updateInfo?.updateAvailable === true && updateInfo.latest !== null) {
992
- out.write(` ▲ Update available: ${updateInfo.current} → ${updateInfo.latest} (press u)\n\n`);
1141
+ if (runningUnderNpx) {
1142
+ // Self-update can't persist under npx (it re-serves its own cache next run).
1143
+ // Be honest and point to the durable fix instead of a no-op "press u".
1144
+ out.write(` ▲ Update available: ${updateInfo.current} → ${updateInfo.latest}\n` +
1145
+ ` You're running via npx, so updates won't stick. Install globally to stay current:\n` +
1146
+ ` npm install -g myshell-tools@latest\n\n`);
1147
+ }
1148
+ else {
1149
+ out.write(` ▲ Update available: ${updateInfo.current} → ${updateInfo.latest} (press u)\n\n`);
1150
+ }
1151
+ }
1152
+ // Health issues — surfaced automatically, only when something is actually
1153
+ // wrong (writable/Node/pricing). Silence means healthy; the user never runs a
1154
+ // diagnostic command. Errors get ✗, warnings get ⚠️.
1155
+ for (const issue of healthIssues) {
1156
+ const marker = issue.severity === 'error' ? '✗' : '⚠️ ';
1157
+ out.write(` ${marker} ${issue.message}\n`);
993
1158
  }
994
- // Budget line — real ledger data, never fabricated
995
- const entries = await readLedger(ctx.cwd);
996
- const spend = summarizeSpend(entries, ctx.clock.isoNow());
1159
+ if (healthIssues.length > 0)
1160
+ out.write('\n');
1161
+ // Budget line — real ledger data, never fabricated. The SpendSummary is
1162
+ // computed by the caller and cached across keystrokes (the ledger only
1163
+ // changes when a task completes), so navigating the menu never re-parses the
1164
+ // unbounded ledger.jsonl on every keypress.
997
1165
  out.write(' ' + renderBudgetLine(spend, out.color) + '\n\n');
998
1166
  // Recent conversations — separator() then list
999
1167
  out.write(separator('Recent Conversations') + '\n');
@@ -1020,8 +1188,9 @@ async function renderMainScreen(ctx, mutableCtx, metas, out, updateInfo) {
1020
1188
  { key: 'k', label: 'Login Codex', section: 'Auth' },
1021
1189
  { key: 'o', label: opencodeLabel, section: 'Auth' },
1022
1190
  ];
1023
- // [u] Update now — shown only when a newer version is actually available
1024
- const updateEntry = updateInfo?.updateAvailable === true && updateInfo.latest !== null
1191
+ // [u] Update now — shown only when a newer version is actually available AND
1192
+ // an in-place self-update can persist (not under npx, where it would be a no-op).
1193
+ const updateEntry = updateInfo?.updateAvailable === true && updateInfo.latest !== null && !runningUnderNpx
1025
1194
  ? [{ key: 'u', label: `Update now (→ ${updateInfo.latest})`, section: 'Options' }]
1026
1195
  : [];
1027
1196
  // Menu — sectioned via menu()
@@ -1067,6 +1236,10 @@ export async function startMenu(ctx, out) {
1067
1236
  const checkForUpdateFn = ctx.checkForUpdate;
1068
1237
  const updateSelfFn = ctx.updateSelf;
1069
1238
  const relaunchFn = ctx.relaunch;
1239
+ // npx context: real detection from the running script path, or test override.
1240
+ const runningUnderNpx = ctx.runningUnderNpx !== undefined
1241
+ ? ctx.runningUnderNpx
1242
+ : isRunningUnderNpx(process.argv[1], process.env);
1070
1243
  // Build the readLine function — either injected (for tests) or backed by a
1071
1244
  // real readline interface driven by the event-driven LineReader queue.
1072
1245
  let readLine;
@@ -1114,7 +1287,11 @@ export async function startMenu(ctx, out) {
1114
1287
  // ---- Auto-update at launch (default ON) ----------------------------------
1115
1288
  // Guard: only runs once; requires both the update and relaunch seams to be wired.
1116
1289
  // Disabled when MYSHELL_NO_UPDATE is set in the environment or autoUpdate===false.
1290
+ // Skipped under npx: `npm install -g` won't be picked up by the next npx run
1291
+ // (it re-serves its own cache), so silently installing globally would surprise
1292
+ // the user with no durable benefit. The main screen shows the install hint instead.
1117
1293
  if (autoUpdateEnabled(mutableCtx.config, process.env) &&
1294
+ !runningUnderNpx &&
1118
1295
  updateInfo?.updateAvailable === true &&
1119
1296
  updateInfo.latest !== null &&
1120
1297
  updateSelfFn !== undefined) {
@@ -1130,9 +1307,31 @@ export async function startMenu(ctx, out) {
1130
1307
  out.write('Auto-update failed — continuing with current version.\n');
1131
1308
  }
1132
1309
  // ---- B. Main screen loop -------------------------------------------------
1310
+ // Compute Claude token lifetime once per launch (real disk read, or injected
1311
+ // value from ctx for tests). Passed to renderMainScreen so renderHeaderLines
1312
+ // stays pure.
1313
+ let claudeTokenInfo;
1314
+ if (ctx.claudeTokenInfo !== undefined) {
1315
+ // Injected by tests (including explicit null to suppress warning).
1316
+ claudeTokenInfo = ctx.claudeTokenInfo;
1317
+ }
1318
+ else {
1319
+ // Real path: load from disk. Never throws; null when no capture date stored.
1320
+ const capturedAt = await loadClaudeTokenCapturedAt().catch(() => undefined);
1321
+ claudeTokenInfo = claudeTokenStatus(capturedAt, Date.now());
1322
+ }
1323
+ // Spend summary is cached and only recomputed when the ledger may have
1324
+ // changed (after a task runs). Avoids re-reading the unbounded ledger.jsonl
1325
+ // on every keystroke — the menu hot path stays O(1) in ledger size.
1326
+ let spend = summarizeSpend(await readLedger(ctx.cwd), ctx.clock.isoNow());
1327
+ let spendDirty = false;
1133
1328
  while (true) {
1329
+ if (spendDirty) {
1330
+ spend = summarizeSpend(await readLedger(ctx.cwd), ctx.clock.isoNow());
1331
+ spendDirty = false;
1332
+ }
1134
1333
  const metas = await ctx.store.list();
1135
- await renderMainScreen(ctx, mutableCtx, metas, out, updateInfo);
1334
+ await renderMainScreen(ctx, mutableCtx, metas, spend, out, updateInfo, claudeTokenInfo, runningUnderNpx, ctx.healthIssues ?? []);
1136
1335
  out.write('> ');
1137
1336
  const key = await readLine();
1138
1337
  // ---- EOF / close — exit gracefully (FIX 1: no ERR_USE_AFTER_CLOSE) ----
@@ -1150,6 +1349,7 @@ export async function startMenu(ctx, out) {
1150
1349
  if (firstMsg !== null && firstMsg.length > 0) {
1151
1350
  const meta = await ctx.store.create(firstMsg);
1152
1351
  const chatResult = await runChatLoop(ctx, mutableCtx, meta.id, out, readLine, loginFn, detectEnvironmentFn);
1352
+ spendDirty = true; // a task may have run — refresh the spend summary
1153
1353
  if (chatResult === 'exit')
1154
1354
  break;
1155
1355
  }
@@ -1161,6 +1361,7 @@ export async function startMenu(ctx, out) {
1161
1361
  const latest = all[0];
1162
1362
  if (latest !== undefined) {
1163
1363
  const chatResult = await runChatLoop(ctx, mutableCtx, latest.id, out, readLine, loginFn, detectEnvironmentFn);
1364
+ spendDirty = true; // a task may have run — refresh the spend summary
1164
1365
  if (chatResult === 'exit')
1165
1366
  break;
1166
1367
  }
@@ -1175,6 +1376,7 @@ export async function startMenu(ctx, out) {
1175
1376
  const target = metas[digit - 1];
1176
1377
  if (target !== undefined) {
1177
1378
  const chatResult = await runChatLoop(ctx, mutableCtx, target.id, out, readLine, loginFn, detectEnvironmentFn);
1379
+ spendDirty = true; // a task may have run — refresh the spend summary
1178
1380
  if (chatResult === 'exit')
1179
1381
  break;
1180
1382
  }
@@ -1191,6 +1393,7 @@ export async function startMenu(ctx, out) {
1191
1393
  // ---- [i] Import a native conversation -----------------------------------
1192
1394
  if (key === 'i') {
1193
1395
  const importResult = await runImportNative(ctx, mutableCtx, out, readLine, loginFn, detectEnvironmentFn);
1396
+ spendDirty = true; // an imported session may run a task — refresh spend
1194
1397
  if (importResult === 'exit')
1195
1398
  break;
1196
1399
  continue;
@@ -1222,10 +1425,11 @@ export async function startMenu(ctx, out) {
1222
1425
  // tests stay hermetic). If install succeeds, proceeds to sign in.
1223
1426
  if (key === 'o') {
1224
1427
  if (!mutableCtx.env.opencode.installed) {
1225
- out.write(`Install opencode (${installCommandFor('opencode').replace('npm install -g ', '')})? [Enter] yes · [n] no\n`);
1226
- out.write('> ');
1428
+ out.write(`Install opencode (${installCommandFor('opencode').replace('npm install -g ', '')})? (Y/n) `);
1227
1429
  const ans = await readLine();
1228
- const skip = ans === null || ans.toLowerCase() === 'n' || ans.toLowerCase() === 'no';
1430
+ // EOF (null) means no interactive user never auto-install on a closed
1431
+ // pipe. Otherwise honor the (Y/n) default-yes (Enter = install).
1432
+ const skip = ans === null || !parseYesNo(ans, true);
1229
1433
  if (skip) {
1230
1434
  out.write(`Skipped. You can install it later: ${installCommandFor('opencode')}\n`);
1231
1435
  continue;