nterminal 1.3.8 → 1.3.9

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 (28) hide show
  1. package/README.md +1 -1
  2. package/dist/client/assets/MarkdownPreview-OPnFTPId.js +1 -0
  3. package/dist/client/assets/{TranscriptMarkdownBody-Dd6cp7t2.js → TranscriptMarkdownBody-CwtPlXXA.js} +1 -1
  4. package/dist/client/assets/{index-DJwOCZv_.js → index-BeEACkVn.js} +1 -1
  5. package/dist/client/assets/{index-CWRDvfyP.css → index-CFEvEJOV.css} +1 -1
  6. package/dist/client/assets/index-D7fYqfyY.js +59 -0
  7. package/dist/client/index.html +2 -2
  8. package/dist/server/accounts/accountService.d.ts +16 -5
  9. package/dist/server/accounts/accountService.js +157 -20
  10. package/dist/server/accounts/accountService.js.map +1 -1
  11. package/dist/server/accounts/loginSession.d.ts +67 -0
  12. package/dist/server/accounts/loginSession.js +333 -0
  13. package/dist/server/accounts/loginSession.js.map +1 -0
  14. package/dist/server/agent/agentRoutes.js +11 -1
  15. package/dist/server/agent/agentRoutes.js.map +1 -1
  16. package/dist/server/routes/accountRoutes.js +15 -0
  17. package/dist/server/routes/accountRoutes.js.map +1 -1
  18. package/dist/server/routes/agentManagementRoutes.js +13 -5
  19. package/dist/server/routes/agentManagementRoutes.js.map +1 -1
  20. package/dist/shared/accountLogin.d.ts +3 -0
  21. package/dist/shared/accountLogin.js +47 -0
  22. package/dist/shared/accountLogin.js.map +1 -0
  23. package/dist/shared/protocol.d.ts +20 -5
  24. package/dist/shared/protocol.js.map +1 -1
  25. package/docs/features.md +2 -0
  26. package/package.json +1 -1
  27. package/dist/client/assets/MarkdownPreview-D3Oa7_1H.js +0 -1
  28. package/dist/client/assets/index-BOvp6zV9.js +0 -58
@@ -12,8 +12,8 @@
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-BOvp6zV9.js"></script>
16
- <link rel="stylesheet" crossorigin href="/assets/index-CWRDvfyP.css">
15
+ <script type="module" crossorigin src="/assets/index-D7fYqfyY.js"></script>
16
+ <link rel="stylesheet" crossorigin href="/assets/index-CFEvEJOV.css">
17
17
  </head>
18
18
  <body>
19
19
  <div id="root"></div>
@@ -1,7 +1,7 @@
1
1
  import type { AppConfig } from '../config.js';
2
2
  import type { TerminalManager } from '../terminal/TerminalManager.js';
3
- import type { TerminalSummary } from '../../shared/types.js';
4
3
  import type { AccountProvider, AccountStatusResponse, AccountRunningProcess, AccountRefreshStatus, AccountUsageResponse } from '../../shared/protocol.js';
4
+ import { type LoginLauncher } from './loginSession.js';
5
5
  export type AccountDiagnostic = {
6
6
  event: 'account.refresh' | 'account.refresh_scan';
7
7
  provider?: AccountProvider;
@@ -49,6 +49,11 @@ export declare class AccountService {
49
49
  private readonly paneProcessResolver;
50
50
  private readonly processTerminator;
51
51
  private readonly processSnapshotReader;
52
+ private readonly loginSessions;
53
+ private readonly loginNotices;
54
+ private readonly loginLauncher;
55
+ private readonly loginTimeoutMs;
56
+ private readonly loginExitWaitMs;
52
57
  constructor(config: AppConfig, terminalManager: TerminalManager, options?: {
53
58
  homeDir?: string;
54
59
  now?: () => Date;
@@ -59,15 +64,16 @@ export declare class AccountService {
59
64
  processSnapshot?: () => Promise<ProcessSnapshotEntry[]>;
60
65
  fetcher?: typeof fetch;
61
66
  onDiagnostic?: (event: AccountDiagnostic) => void;
67
+ loginLauncher?: LoginLauncher;
68
+ loginTimeoutMs?: number;
69
+ loginExitWaitMs?: number;
62
70
  });
63
71
  private mutate;
64
72
  synchronize(): Promise<void>;
65
73
  select(provider: AccountProvider, profileId: string): Promise<AccountStatusResponse>;
66
74
  remove(provider: AccountProvider, profileId: string): Promise<AccountStatusResponse>;
67
- beginLogin(provider: AccountProvider): Promise<{
68
- terminal: TerminalSummary;
69
- command: string;
70
- }>;
75
+ beginLogin(provider: AccountProvider): Promise<AccountStatusResponse>;
76
+ submitLoginCode(provider: AccountProvider, code: unknown): Promise<AccountStatusResponse>;
71
77
  completeLogin(provider: AccountProvider, label: string): Promise<AccountStatusResponse>;
72
78
  cancelLogin(provider: AccountProvider): Promise<AccountStatusResponse>;
73
79
  refresh(provider: AccountProvider, profileId?: string): Promise<AccountStatusResponse>;
@@ -85,6 +91,11 @@ export declare class AccountService {
85
91
  private refreshUnlocked;
86
92
  start(): void;
87
93
  dispose(): void;
94
+ private loginProcessIds;
95
+ private stopLoginSession;
96
+ private expireLogin;
97
+ private expireLoginNotices;
98
+ private loginState;
88
99
  private refreshExpiringProfiles;
89
100
  private refreshAndRecord;
90
101
  private reportDiagnostic;
@@ -6,6 +6,7 @@ import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { accountReloginReason } from '../../shared/accountRelogin.js';
8
8
  import { ProviderRequestError, requestAccountUsage, requestTokenRefresh, unavailableUsage } from './providerRequests.js';
9
+ import { createPtyLoginLauncher, LOGIN_TIMEOUT_MS, LoginSessionError, ProviderLoginSession, validateLoginCode } from './loginSession.js';
9
10
  const VAULT_DIRECTORY = 'credential-vault';
10
11
  const VAULT_INDEX = 'index.json';
11
12
  const VAULT_KEY = 'key';
@@ -23,6 +24,9 @@ const PROVIDER_TERMINATE_GRACE_MS = 2_000;
23
24
  const TERMINATION_ACKNOWLEDGEMENT_TTL_MS = 5 * 60_000;
24
25
  const USAGE_CACHE_TTL_MS = 2 * 60_000;
25
26
  const USAGE_FAILURE_TTL_MS = 30_000;
27
+ const LOGIN_COMPLETE_EXIT_WAIT_MS = 5_000;
28
+ const LOGIN_NOTICE_TTL_MS = 10 * 60_000;
29
+ const LOGIN_EXPIRY_RETRY_MS = 500;
26
30
  export class AccountServiceError extends Error {
27
31
  code;
28
32
  constructor(code, message = code) {
@@ -33,7 +37,7 @@ export class AccountServiceError extends Error {
33
37
  get statusCode() {
34
38
  if (this.code === 'preview_mode')
35
39
  return 403;
36
- if (['provider_panes_running', 'account_operation_busy', 'account_credential_changed', 'account_login_already_pending', 'active_cli_refresh_deferred'].includes(this.code))
40
+ if (['provider_panes_running', 'account_operation_busy', 'account_credential_changed', 'account_login_already_pending', 'active_cli_refresh_deferred', 'account_login_code_not_expected', 'account_login_process_unavailable', 'account_login_code_attempts_exceeded'].includes(this.code))
37
41
  return 409;
38
42
  return 400;
39
43
  }
@@ -64,6 +68,12 @@ export class AccountService {
64
68
  paneProcessResolver;
65
69
  processTerminator;
66
70
  processSnapshotReader;
71
+ // In-popup sign-ins: one private PTY per provider, never a workspace terminal.
72
+ loginSessions = new Map();
73
+ loginNotices = new Map();
74
+ loginLauncher;
75
+ loginTimeoutMs;
76
+ loginExitWaitMs;
67
77
  constructor(config, terminalManager, options = {}) {
68
78
  this.config = config;
69
79
  this.terminalManager = terminalManager;
@@ -75,13 +85,17 @@ export class AccountService {
75
85
  this.now = options.now ?? (() => new Date());
76
86
  this.fetcher = options.fetcher ?? ((...args) => fetch(...args));
77
87
  this.onDiagnostic = options.onDiagnostic ?? (() => undefined);
78
- this.processLister = options.processLister ?? (async (provider) => providerProcesses(provider, await this.listProcessSnapshot()));
88
+ // The in-popup login CLI is NTerminal's own child, not a user session.
89
+ this.processLister = options.processLister ?? (async (provider) => providerProcesses(provider, withoutProcessTrees(await this.listProcessSnapshot(), this.loginProcessIds())));
79
90
  this.paneProcessResolver = options.paneProcessResolver ?? (options.processLister
80
91
  ? async () => new Map()
81
92
  : async () => resolvePaneProcesses(this.config.tmuxSocket, await this.listProcessSnapshot()));
82
93
  this.processProbe = options.processProbe ?? (async (provider) => (await this.processLister(provider)).length > 0);
83
- this.processTerminator = options.processTerminator ?? terminateProviderProcesses;
94
+ this.processTerminator = options.processTerminator ?? ((provider, processes) => terminateProviderProcesses(provider, processes, this.loginProcessIds()));
84
95
  this.processSnapshotReader = options.processSnapshot ?? listUserProcesses;
96
+ this.loginLauncher = options.loginLauncher ?? createPtyLoginLauncher();
97
+ this.loginTimeoutMs = options.loginTimeoutMs ?? LOGIN_TIMEOUT_MS;
98
+ this.loginExitWaitMs = options.loginExitWaitMs ?? LOGIN_COMPLETE_EXIT_WAIT_MS;
85
99
  }
86
100
  // Reject overlapping mutations instead of accumulating requests behind OAuth I/O.
87
101
  async mutate(operation) {
@@ -112,6 +126,32 @@ export class AccountService {
112
126
  beginLogin(provider) {
113
127
  return this.mutate(() => this.beginLoginUnlocked(provider));
114
128
  }
129
+ // Forwards a pasted Claude authorization code to the login CLI. Only a
130
+ // strictly shaped `code#state` value is written, followed by Enter.
131
+ async submitLoginCode(provider, code) {
132
+ if (this.config.previewMode)
133
+ throw new AccountServiceError('preview_mode');
134
+ if (this.disposed)
135
+ throw new AccountServiceError('account_operation_busy');
136
+ const valid = validateLoginCode(provider, code);
137
+ if (!valid)
138
+ throw new AccountServiceError(provider === 'claude' ? 'account_login_code_invalid' : 'account_login_code_not_expected');
139
+ const index = await this.readIndex();
140
+ if (!index.pending[provider])
141
+ throw new AccountServiceError('account_login_not_pending');
142
+ const session = this.loginSessions.get(provider);
143
+ if (!session)
144
+ throw new AccountServiceError('account_login_process_unavailable');
145
+ try {
146
+ session.submitCode(valid);
147
+ }
148
+ catch (error) {
149
+ if (error instanceof LoginSessionError)
150
+ throw new AccountServiceError(error.code);
151
+ throw error;
152
+ }
153
+ return this.status();
154
+ }
115
155
  completeLogin(provider, label) {
116
156
  return this.mutate(() => this.completeLoginUnlocked(provider, label));
117
157
  }
@@ -229,6 +269,7 @@ export class AccountService {
229
269
  await Promise.all(['codex', 'claude'].map(async (provider) => {
230
270
  pendingReady.set(provider, Boolean(refreshed.pending[provider] && await this.readCredentialFile(provider).catch(() => null)));
231
271
  }));
272
+ this.expireLoginNotices();
232
273
  return {
233
274
  providers: ['codex', 'claude'].map((provider) => this.providerStatus(refreshed, provider, running, processes.get(provider) ?? [], processCheckUnavailable, pendingReady.get(provider) === true))
234
275
  };
@@ -305,28 +346,36 @@ export class AccountService {
305
346
  index.pending[provider] = {
306
347
  provider,
307
348
  previousProfileId: index.active[provider] ?? null,
308
- startedAt: this.now().toISOString(),
309
- terminalId: null
349
+ startedAt: this.now().toISOString()
310
350
  };
311
351
  delete index.active[provider];
312
352
  await this.writeIndex(index);
313
- let terminal;
353
+ let session;
314
354
  try {
355
+ // `codex login` revokes the tokens of an auth.json it finds, so the CLI
356
+ // must start only after the archived live file is gone.
315
357
  if (current)
316
358
  await this.removeCredentialFile(provider);
317
- terminal = await this.terminalManager.createTerminal({ cwd: this.config.workspaceRoot, cols: 100, rows: 30 });
318
- const command = provider === 'codex' ? 'codex login --device-auth' : 'claude auth login';
319
- const wrote = await this.terminalManager.writeToTerminal(terminal.id, `${command}\r`);
320
- if (!wrote)
321
- throw new AccountServiceError('account_login_terminal_unavailable');
322
- index.pending[provider].terminalId = terminal.id;
323
- await this.writeIndex(index);
359
+ await this.stopLoginSession(provider);
360
+ let launched;
361
+ try {
362
+ launched = this.loginLauncher(provider, { homeDir: this.homeDir });
363
+ }
364
+ catch {
365
+ throw new AccountServiceError('account_login_process_unavailable');
366
+ }
367
+ session = new ProviderLoginSession(provider, launched, () => this.now().getTime(), this.loginTimeoutMs, (expired) => void this.expireLogin(provider, expired));
368
+ this.loginSessions.set(provider, session);
369
+ this.loginNotices.delete(provider);
324
370
  this.clearTerminationAcknowledgement(provider);
325
- return { terminal, command };
371
+ return this.status();
326
372
  }
327
373
  catch (error) {
328
- if (terminal)
329
- await this.terminalManager.closeTerminal(terminal.id);
374
+ if (session) {
375
+ if (this.loginSessions.get(provider) === session)
376
+ this.loginSessions.delete(provider);
377
+ await session.terminate();
378
+ }
330
379
  if (current)
331
380
  await this.writeCredentialFile(provider, current);
332
381
  if (index.active[provider] === undefined && index.pending[provider]?.previousProfileId) {
@@ -342,6 +391,12 @@ export class AccountService {
342
391
  const pending = index.pending[provider];
343
392
  if (!pending)
344
393
  throw new AccountServiceError('account_login_not_pending');
394
+ if (!await this.readCredentialFile(provider))
395
+ throw new AccountServiceError('account_login_not_complete');
396
+ // Let the CLI finish its own post-login writes before the file is captured.
397
+ const session = this.loginSessions.get(provider);
398
+ if (session?.running && !await session.waitForExit(this.loginExitWaitMs))
399
+ await session.terminate();
345
400
  const credentials = await this.readCredentialFile(provider);
346
401
  if (!credentials)
347
402
  throw new AccountServiceError('account_login_not_complete');
@@ -352,10 +407,14 @@ export class AccountService {
352
407
  delete index.pending[provider];
353
408
  clearExternalSwitch(index, provider);
354
409
  await this.writeIndex(index);
410
+ if (this.loginSessions.get(provider) === session)
411
+ this.loginSessions.delete(provider);
355
412
  this.clearTerminationAcknowledgement(provider);
356
413
  return this.status();
357
414
  }
358
415
  async cancelLoginUnlocked(provider) {
416
+ // Stop the CLI first so it cannot write a credential after the restore.
417
+ await this.stopLoginSession(provider);
359
418
  const index = await this.readIndex();
360
419
  const pending = index.pending[provider];
361
420
  if (!pending)
@@ -395,6 +454,68 @@ export class AccountService {
395
454
  if (this.refreshTimer)
396
455
  clearInterval(this.refreshTimer);
397
456
  this.refreshTimer = null;
457
+ // The pending entry survives in the index; after a restart the dialog
458
+ // reports the missing login process and offers to cancel and restore.
459
+ for (const session of this.loginSessions.values())
460
+ session.dispose();
461
+ this.loginSessions.clear();
462
+ }
463
+ loginProcessIds() {
464
+ return [...this.loginSessions.values()].filter((session) => session.running).map((session) => session.pid);
465
+ }
466
+ async stopLoginSession(provider) {
467
+ const session = this.loginSessions.get(provider);
468
+ if (!session)
469
+ return;
470
+ this.loginSessions.delete(provider);
471
+ await session.terminate();
472
+ }
473
+ // Timeout: stop the CLI and restore the previous credential through the
474
+ // normal cancel path, retrying while another account operation holds the lock.
475
+ async expireLogin(provider, session) {
476
+ await session.terminate();
477
+ for (let attempt = 0; attempt < 120 && !this.disposed; attempt += 1) {
478
+ if (this.loginSessions.get(provider) !== session)
479
+ return;
480
+ try {
481
+ await this.cancelLogin(provider);
482
+ this.loginNotices.set(provider, { code: 'login_timed_out', at: this.now().toISOString() });
483
+ return;
484
+ }
485
+ catch (error) {
486
+ if (!(error instanceof AccountServiceError) || error.code !== 'account_operation_busy')
487
+ return;
488
+ await sleep(LOGIN_EXPIRY_RETRY_MS);
489
+ }
490
+ }
491
+ }
492
+ expireLoginNotices() {
493
+ for (const [provider, notice] of this.loginNotices) {
494
+ if (this.now().getTime() - Date.parse(notice.at) > LOGIN_NOTICE_TTL_MS)
495
+ this.loginNotices.delete(provider);
496
+ }
497
+ }
498
+ loginState(provider, ready) {
499
+ const session = this.loginSessions.get(provider);
500
+ if (!session) {
501
+ return ready ? { phase: 'done' } : {
502
+ phase: 'failed',
503
+ message: 'The sign-in process is no longer running (NTerminal may have restarted). Cancel to restore the previous account, then try again.'
504
+ };
505
+ }
506
+ const state = session.state();
507
+ if (ready) {
508
+ // The credential exists; wait for the CLI to report success or exit.
509
+ if (session.succeeded || !session.running)
510
+ return { phase: 'done' };
511
+ return { ...state, phase: 'completing' };
512
+ }
513
+ if (state.phase === 'done') {
514
+ return session.running ? { ...state, phase: 'completing' } : {
515
+ ...state, phase: 'failed', message: 'The CLI reported success, but no credential file was written. Cancel and try again.'
516
+ };
517
+ }
518
+ return state;
398
519
  }
399
520
  async refreshExpiringProfiles() {
400
521
  if (this.refreshInFlight || this.disposed || this.config.previewMode || this.operationInFlight)
@@ -495,7 +616,8 @@ export class AccountService {
495
616
  runningProcesses,
496
617
  processCheckUnavailable,
497
618
  terminationAcknowledged: this.hasTerminationAcknowledgement(provider),
498
- pendingLogin: pending ? { startedAt: pending.startedAt, ready: pendingReady, terminalId: pending.terminalId ?? null } : null,
619
+ pendingLogin: pending ? { startedAt: pending.startedAt, ready: pendingReady, login: this.loginState(provider, pendingReady) } : null,
620
+ loginNotice: pending ? null : this.loginNotices.get(provider) ?? null,
499
621
  lastExternalSwitch: index.externalSwitch?.[provider] ?? null,
500
622
  credentialPresent: Boolean(profile || pending || false)
501
623
  };
@@ -1043,8 +1165,8 @@ function displayProcessCommand(command) {
1043
1165
  const basename = path.basename(command);
1044
1166
  return basename.length <= 80 ? basename : basename.slice(0, 80);
1045
1167
  }
1046
- async function terminateProviderProcesses(provider, _processes) {
1047
- const snapshot = await listUserProcesses();
1168
+ async function terminateProviderProcesses(provider, _processes, excludedRoots = []) {
1169
+ const snapshot = withoutProcessTrees(await listUserProcesses(), excludedRoots);
1048
1170
  const daemons = provider === 'codex' ? snapshot.filter(isCodexDaemon) : [];
1049
1171
  // Shared daemon descendants include work for multiple TUI clients. Stop the
1050
1172
  // interactive clients first, then let the daemon's lifecycle command stop it.
@@ -1062,7 +1184,7 @@ async function terminateProviderProcesses(provider, _processes) {
1062
1184
  signalProcess(pid, 'SIGTERM');
1063
1185
  if (targetPids.length > 0) {
1064
1186
  await sleep(PROVIDER_TERMINATE_GRACE_MS);
1065
- const survivors = new Set(providerProcessTree(provider, await listUserProcesses()));
1187
+ const survivors = new Set(providerProcessTree(provider, withoutProcessTrees(await listUserProcesses(), excludedRoots)));
1066
1188
  for (const pid of targetPids)
1067
1189
  if (survivors.has(pid))
1068
1190
  signalProcess(pid, 'SIGKILL');
@@ -1085,6 +1207,21 @@ async function stopCodexDaemon(daemons) {
1085
1207
  }
1086
1208
  throw new AccountServiceError('codex_daemon_stop_failed');
1087
1209
  }
1210
+ // Drops the given processes and all their descendants from a snapshot.
1211
+ function withoutProcessTrees(entries, roots) {
1212
+ if (roots.length === 0)
1213
+ return entries;
1214
+ const excluded = new Set(roots);
1215
+ for (let changed = true; changed;) {
1216
+ changed = false;
1217
+ for (const entry of entries)
1218
+ if (excluded.has(entry.ppid) && !excluded.has(entry.pid)) {
1219
+ excluded.add(entry.pid);
1220
+ changed = true;
1221
+ }
1222
+ }
1223
+ return entries.filter((entry) => !excluded.has(entry.pid));
1224
+ }
1088
1225
  function daemonDescendants(entries) {
1089
1226
  const parents = new Map(entries.map((entry) => [entry.pid, entry.ppid]));
1090
1227
  const daemons = new Set(entries.filter(isCodexDaemon).map((entry) => entry.pid));