nterminal 1.3.15 → 1.3.16

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.
@@ -12,7 +12,7 @@
12
12
  <link rel="icon" type="image/png" sizes="64x64" href="/icons/favicon-64.png" />
13
13
  <link rel="icon" type="image/png" sizes="96x96" href="/icons/favicon-96.png" />
14
14
  <title>NTerminal</title>
15
- <script type="module" crossorigin src="/assets/index-BiIblEKQ.js"></script>
15
+ <script type="module" crossorigin src="/assets/index-tUmhlnUC.js"></script>
16
16
  <link rel="stylesheet" crossorigin href="/assets/index-D1X7cDQ9.css">
17
17
  </head>
18
18
  <body>
@@ -56,6 +56,7 @@ export declare class AccountService {
56
56
  private readonly loginLauncher;
57
57
  private readonly loginTimeoutMs;
58
58
  private readonly loginExitWaitMs;
59
+ private readonly releaseCodexDaemon;
59
60
  constructor(config: AppConfig, terminalManager: TerminalManager, options?: {
60
61
  homeDir?: string;
61
62
  now?: () => Date;
@@ -70,6 +71,7 @@ export declare class AccountService {
70
71
  loginLauncher?: LoginLauncher;
71
72
  loginTimeoutMs?: number;
72
73
  loginExitWaitMs?: number;
74
+ releaseCodexDaemon?: (pid: number, mayStop: () => Promise<boolean>) => Promise<boolean>;
73
75
  });
74
76
  private mutate;
75
77
  synchronize(): Promise<void>;
@@ -91,6 +93,8 @@ export declare class AccountService {
91
93
  private removeUnlocked;
92
94
  private beginLoginUnlocked;
93
95
  private completeLoginUnlocked;
96
+ private markApplicationPending;
97
+ private applySelectedAccount;
94
98
  private renameUnlocked;
95
99
  private cancelLoginUnlocked;
96
100
  private refreshUnlocked;
@@ -8,6 +8,7 @@ import { accountReloginReason } from '../../shared/accountRelogin.js';
8
8
  import { ProviderRequestError, requestAccountUsage, requestTokenRefresh, unavailableUsage } from './providerRequests.js';
9
9
  import { createPtyLoginLauncher, LOGIN_TIMEOUT_MS, LoginSessionError, ProviderLoginSession, validateLoginCode } from './loginSession.js';
10
10
  import { readBootTimeSecondsAsync, readProcessStartTicksAsync } from '../terminal/procfs.js';
11
+ import { releaseIdleCodexDaemon } from './codexDaemonAuth.js';
11
12
  const VAULT_DIRECTORY = 'credential-vault';
12
13
  const VAULT_INDEX = 'index.json';
13
14
  const VAULT_KEY = 'key';
@@ -76,6 +77,7 @@ export class AccountService {
76
77
  loginLauncher;
77
78
  loginTimeoutMs;
78
79
  loginExitWaitMs;
80
+ releaseCodexDaemon;
79
81
  constructor(config, terminalManager, options = {}) {
80
82
  this.config = config;
81
83
  this.terminalManager = terminalManager;
@@ -101,6 +103,8 @@ export class AccountService {
101
103
  this.loginLauncher = options.loginLauncher ?? createPtyLoginLauncher();
102
104
  this.loginTimeoutMs = options.loginTimeoutMs ?? LOGIN_TIMEOUT_MS;
103
105
  this.loginExitWaitMs = options.loginExitWaitMs ?? LOGIN_COMPLETE_EXIT_WAIT_MS;
106
+ this.releaseCodexDaemon = options.releaseCodexDaemon ?? (options.processProbe || options.processLister || options.processSnapshot
107
+ ? async () => false : (pid, mayStop) => releaseIdleCodexDaemon(this.homeDir, pid, mayStop));
104
108
  }
105
109
  // Reject overlapping mutations instead of accumulating requests behind OAuth I/O.
106
110
  async mutate(operation) {
@@ -278,7 +282,7 @@ export class AccountService {
278
282
  const pendingReady = new Map();
279
283
  await Promise.all(['codex', 'claude'].map(async (provider) => {
280
284
  const credentials = refreshed.pending[provider] ? await this.readCredentialFile(provider).catch(() => null) : null;
281
- pendingReady.set(provider, { ready: Boolean(credentials), suggestedLabel: credentials ? suggestedLabel(refreshed, provider, credentials) : null });
285
+ pendingReady.set(provider, { ready: Boolean(credentials && hasOAuthCredential(provider, credentials)), suggestedLabel: credentials ? suggestedLabel(refreshed, provider, credentials) : null });
282
286
  }));
283
287
  this.expireLoginNotices();
284
288
  return {
@@ -303,6 +307,9 @@ export class AccountService {
303
307
  if (index.active[provider] === profile.id) {
304
308
  if (clearExternalSwitch(index, provider))
305
309
  await this.writeIndex(index);
310
+ const credentials = await this.readCredentialFile(provider);
311
+ if (credentials)
312
+ await this.applySelectedAccount(index, profile, credentials);
306
313
  this.clearTerminationAcknowledgement(provider);
307
314
  return this.status();
308
315
  }
@@ -311,6 +318,7 @@ export class AccountService {
311
318
  try {
312
319
  await this.writeCredentialFile(provider, credentials);
313
320
  index.active[provider] = profile.id;
321
+ this.markApplicationPending(index, profile);
314
322
  clearExternalSwitch(index, provider);
315
323
  await this.writeIndex(index);
316
324
  }
@@ -324,6 +332,7 @@ export class AccountService {
324
332
  // The selected profile's vault copy is exactly what was just published, so
325
333
  // it already is the active backup. The previous account kept its archive.
326
334
  this.clearTerminationAcknowledgement(provider);
335
+ await this.applySelectedAccount(index, profile, credentials);
327
336
  return this.status();
328
337
  }
329
338
  async removeUnlocked(provider, profileId) {
@@ -401,27 +410,102 @@ export class AccountService {
401
410
  const pending = index.pending[provider];
402
411
  if (!pending)
403
412
  throw new AccountServiceError('account_login_not_pending');
404
- if (!await this.readCredentialFile(provider))
405
- throw new AccountServiceError('account_login_not_complete');
406
413
  // Let the CLI finish its own post-login writes before the file is captured.
407
414
  const session = this.loginSessions.get(provider);
408
- if (session?.running && !await session.waitForExit(this.loginExitWaitMs))
409
- await session.terminate();
415
+ if (!session)
416
+ throw new AccountServiceError('account_login_process_unavailable');
417
+ if (session.running)
418
+ await session.waitForExit(this.loginExitWaitMs);
419
+ if (!session.completed)
420
+ throw new AccountServiceError('account_login_not_complete');
410
421
  const credentials = await this.readCredentialFile(provider);
411
- if (!credentials)
422
+ if (!credentials || !hasOAuthCredential(provider, credentials))
412
423
  throw new AccountServiceError('account_login_not_complete');
413
424
  const profile = await this.storeProfile(index, provider, credentials, label);
414
425
  // Back up the new active login before committing it as active.
415
426
  await this.writeVaultCredentials(profile.id, credentials);
416
427
  index.active[provider] = profile.id;
428
+ this.markApplicationPending(index, profile);
417
429
  delete index.pending[provider];
418
430
  clearExternalSwitch(index, provider);
419
431
  await this.writeIndex(index);
420
432
  if (this.loginSessions.get(provider) === session)
421
433
  this.loginSessions.delete(provider);
422
434
  this.clearTerminationAcknowledgement(provider);
435
+ await this.applySelectedAccount(index, profile, credentials);
423
436
  return this.status();
424
437
  }
438
+ markApplicationPending(index, profile) {
439
+ index.application ??= {};
440
+ index.application[profile.provider] = { profileId: profile.id, state: 'pending', reason: 'setup_failed', checkedAt: this.now().toISOString() };
441
+ }
442
+ // Credential storage is committed before runtime preparation. A failed check
443
+ // must never roll back a successful sign-in or delete its valid tokens.
444
+ async applySelectedAccount(index, profile, credentials) {
445
+ this.markApplicationPending(index, profile);
446
+ const application = index.application[profile.provider];
447
+ await this.writeIndex(index);
448
+ try {
449
+ if (!hasOAuthCredential(profile.provider, credentials))
450
+ throw new Error('missing credential');
451
+ const stillSelected = async () => {
452
+ const live = await this.readCredentialFile(profile.provider);
453
+ return live !== null && hashCredential(live) === hashCredential(credentials);
454
+ };
455
+ if (!await stillSelected())
456
+ throw new Error('credential changed');
457
+ if (profile.provider === 'claude') {
458
+ const settings = await this.readClaudeSettings();
459
+ if (settings.hasCompletedOnboarding !== true) {
460
+ if (this.runningPanes().some(pane => pane.provider === 'claude') || await this.processProbe('claude')) {
461
+ application.reason = 'sessions_running';
462
+ return;
463
+ }
464
+ if (!await stillSelected())
465
+ throw new Error('credential changed');
466
+ const latest = await this.readClaudeSettings();
467
+ // Skip only the already-completed login wizard, never project trust,
468
+ // permissions, sandbox settings or user preferences.
469
+ await atomicWrite(path.join(this.homeDir, '.claude.json'), JSON.stringify({ ...latest, hasCompletedOnboarding: true }, null, 2), 0o600);
470
+ }
471
+ }
472
+ else {
473
+ const processes = await this.processLister('codex');
474
+ const daemons = processes.filter(isCodexDaemon);
475
+ if (processes.some(entry => !isCodexDaemon(entry)) || this.runningPanes().some(pane => pane.provider === 'codex')) {
476
+ application.reason = 'sessions_running';
477
+ return;
478
+ }
479
+ if (daemons.length > 0) {
480
+ application.reason = 'daemon_unverified';
481
+ // Multiple homes/daemons are intentionally not guessed at.
482
+ if (daemons.length !== 1)
483
+ return;
484
+ const pid = daemons[0].pid;
485
+ const released = await this.releaseCodexDaemon(pid, async () => {
486
+ const latest = await this.processLister('codex');
487
+ return !this.disposed && await stillSelected()
488
+ && !this.runningPanes().some(pane => pane.provider === 'codex')
489
+ && latest.length === 1 && latest[0].pid === pid && isCodexDaemon(latest[0])
490
+ && !(await this.listProcessSnapshot()).some(entry => entry.ppid === pid);
491
+ });
492
+ if (!released || (await this.processLister('codex')).length > 0)
493
+ return;
494
+ }
495
+ }
496
+ if (!await stillSelected())
497
+ throw new Error('credential changed');
498
+ application.state = 'ready';
499
+ application.reason = null;
500
+ }
501
+ catch {
502
+ application.reason = 'setup_failed';
503
+ }
504
+ finally {
505
+ application.checkedAt = this.now().toISOString();
506
+ await this.writeIndex(index);
507
+ }
508
+ }
425
509
  async renameUnlocked(provider, profileId, label) {
426
510
  const index = await this.readIndex();
427
511
  const profile = index.profiles.find((candidate) => candidate.id === profileId && candidate.provider === provider);
@@ -457,6 +541,8 @@ export class AccountService {
457
541
  await this.removeCredentialFile(provider);
458
542
  }
459
543
  delete index.pending[provider];
544
+ if (index.application)
545
+ delete index.application[provider];
460
546
  await this.writeIndex(index);
461
547
  this.clearTerminationAcknowledgement(provider);
462
548
  return this.status();
@@ -527,15 +613,16 @@ export class AccountService {
527
613
  loginState(provider, ready) {
528
614
  const session = this.loginSessions.get(provider);
529
615
  if (!session) {
530
- return ready ? { phase: 'done' } : {
616
+ return {
531
617
  phase: 'failed',
532
618
  message: 'The sign-in process is no longer running (NTerminal may have restarted). Cancel to restore the previous account, then try again.'
533
619
  };
534
620
  }
535
621
  const state = session.state();
622
+ if (state.phase === 'failed')
623
+ return state;
536
624
  if (ready) {
537
- // The credential exists; wait for the CLI to report success or exit.
538
- if (session.succeeded || !session.running)
625
+ if (session.completed)
539
626
  return { phase: 'done' };
540
627
  return { ...state, phase: 'completing' };
541
628
  }
@@ -641,6 +728,7 @@ export class AccountService {
641
728
  return {
642
729
  provider,
643
730
  activeProfileId: activeId,
731
+ ...(index.application?.[provider]?.profileId === activeId && !pending ? { application: index.application[provider] } : {}),
644
732
  profiles: index.profiles.filter((candidate) => candidate.provider === provider).map(toPublicProfile),
645
733
  runningPanes: running.filter((pane) => pane.provider === provider),
646
734
  runningProcesses,
@@ -711,7 +799,10 @@ export class AccountService {
711
799
  if (index.pending[provider])
712
800
  continue;
713
801
  try {
802
+ const previousId = index.active[provider];
714
803
  await this.captureActiveCredential(index, provider);
804
+ if (index.active[provider] !== previousId && index.application)
805
+ delete index.application[provider];
715
806
  await this.backfillAccountEmails(index, provider);
716
807
  this.diagnosticSignatures.delete(`account.refresh_scan:${provider}:`);
717
808
  }
@@ -1056,6 +1147,7 @@ export class AccountService {
1056
1147
  profiles,
1057
1148
  active: isRecord(parsed.active) ? parsed.active : {},
1058
1149
  pending: isRecord(parsed.pending) ? parsed.pending : {},
1150
+ application: parseApplications(parsed.application),
1059
1151
  ...(externalSwitch ? { externalSwitch } : {})
1060
1152
  };
1061
1153
  }
@@ -1426,6 +1518,28 @@ function clearExternalSwitch(index, provider) {
1426
1518
  index.externalSwitch = rest;
1427
1519
  return true;
1428
1520
  }
1521
+ function hasOAuthCredential(provider, document) {
1522
+ const tokens = provider === 'codex' ? document.tokens : document.claudeAiOauth;
1523
+ if (!isRecord(tokens))
1524
+ return false;
1525
+ const access = provider === 'codex' ? tokens.access_token : tokens.accessToken;
1526
+ const refresh = provider === 'codex' ? tokens.refresh_token : tokens.refreshToken;
1527
+ return typeof access === 'string' && access.length > 0 && typeof refresh === 'string' && refresh.length > 0;
1528
+ }
1529
+ function parseApplications(value) {
1530
+ const result = {};
1531
+ if (!isRecord(value))
1532
+ return result;
1533
+ for (const provider of ['codex', 'claude']) {
1534
+ const entry = value[provider];
1535
+ if (isRecord(entry) && typeof entry.profileId === 'string' && typeof entry.checkedAt === 'string'
1536
+ && (entry.state === 'ready' || entry.state === 'pending')
1537
+ && (entry.reason === null || entry.reason === 'sessions_running' || entry.reason === 'daemon_unverified' || entry.reason === 'setup_failed')) {
1538
+ result[provider] = { profileId: entry.profileId, state: entry.state, reason: entry.reason, checkedAt: entry.checkedAt };
1539
+ }
1540
+ }
1541
+ return result;
1542
+ }
1429
1543
  function parseExternalSwitches(value) {
1430
1544
  const result = {};
1431
1545
  for (const provider of ['codex', 'claude']) {