nterminal 1.3.4 → 1.3.6

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 (126) hide show
  1. package/README.md +68 -37
  2. package/assets/screenshot/accounts.png +0 -0
  3. package/assets/screenshot/history.png +0 -0
  4. package/assets/screenshot/mobile-panes.png +0 -0
  5. package/assets/screenshot/mobile.png +0 -0
  6. package/assets/screenshot/scr.png +0 -0
  7. package/assets/screenshot/subagents.png +0 -0
  8. package/bin/nterminal.js +10 -3
  9. package/dist/client/assets/MarkdownPreview-B-LrTjTM.js +1 -0
  10. package/dist/client/assets/{TranscriptMarkdownBody-H8ZsISc6.js → TranscriptMarkdownBody-3aYVmqQz.js} +1 -1
  11. package/dist/client/assets/index-D3LN5iAH.js +58 -0
  12. package/dist/client/assets/{index-DxfBq-4P.js → index-Dd_o8KCL.js} +1 -1
  13. package/dist/client/assets/index-W-zGfY1a.css +1 -0
  14. package/dist/client/index.html +2 -2
  15. package/dist/scripts/hooks.js +124 -0
  16. package/dist/scripts/hooks.js.map +1 -0
  17. package/dist/server/accounts/accountService.d.ts +23 -1
  18. package/dist/server/accounts/accountService.js +235 -103
  19. package/dist/server/accounts/accountService.js.map +1 -1
  20. package/dist/server/accounts/providerRequests.d.ts +12 -0
  21. package/dist/server/accounts/providerRequests.js +159 -0
  22. package/dist/server/accounts/providerRequests.js.map +1 -0
  23. package/dist/server/agent/agentRoutes.js +11 -0
  24. package/dist/server/agent/agentRoutes.js.map +1 -1
  25. package/dist/server/hooks/codexAppServer.d.ts +30 -0
  26. package/dist/server/hooks/codexAppServer.js +170 -0
  27. package/dist/server/hooks/codexAppServer.js.map +1 -0
  28. package/dist/server/hooks/codexTrust.d.ts +72 -0
  29. package/dist/server/hooks/codexTrust.js +116 -0
  30. package/dist/server/hooks/codexTrust.js.map +1 -0
  31. package/dist/server/hooks/helperJournal.d.ts +16 -0
  32. package/dist/server/hooks/helperJournal.js +74 -0
  33. package/dist/server/hooks/helperJournal.js.map +1 -0
  34. package/dist/server/hooks/hookBinding.d.ts +39 -0
  35. package/dist/server/hooks/hookBinding.js +91 -0
  36. package/dist/server/hooks/hookBinding.js.map +1 -0
  37. package/dist/server/hooks/hookFiles.d.ts +24 -0
  38. package/dist/server/hooks/hookFiles.js +160 -0
  39. package/dist/server/hooks/hookFiles.js.map +1 -0
  40. package/dist/server/hooks/hookInstallation.d.ts +108 -0
  41. package/dist/server/hooks/hookInstallation.js +1074 -0
  42. package/dist/server/hooks/hookInstallation.js.map +1 -0
  43. package/dist/server/hooks/hookProcess.d.ts +5 -0
  44. package/dist/server/hooks/hookProcess.js +85 -0
  45. package/dist/server/hooks/hookProcess.js.map +1 -0
  46. package/dist/server/hooks/hookReceiver.d.ts +66 -0
  47. package/dist/server/hooks/hookReceiver.js +549 -0
  48. package/dist/server/hooks/hookReceiver.js.map +1 -0
  49. package/dist/server/hooks/lifecycle.d.ts +40 -0
  50. package/dist/server/hooks/lifecycle.js +200 -0
  51. package/dist/server/hooks/lifecycle.js.map +1 -0
  52. package/dist/server/hooks/publicationLock.d.ts +3 -0
  53. package/dist/server/hooks/publicationLock.js +137 -0
  54. package/dist/server/hooks/publicationLock.js.map +1 -0
  55. package/dist/server/hooks/verifiedLock.d.ts +24 -0
  56. package/dist/server/hooks/verifiedLock.js +207 -0
  57. package/dist/server/hooks/verifiedLock.js.map +1 -0
  58. package/dist/server/http.d.ts +4 -0
  59. package/dist/server/http.js +6 -0
  60. package/dist/server/http.js.map +1 -1
  61. package/dist/server/index.js +71 -2
  62. package/dist/server/index.js.map +1 -1
  63. package/dist/server/notifications/agentCompletionMonitor.d.ts +16 -1
  64. package/dist/server/notifications/agentCompletionMonitor.js +261 -47
  65. package/dist/server/notifications/agentCompletionMonitor.js.map +1 -1
  66. package/dist/server/notifications/completionNotificationRouter.d.ts +2 -0
  67. package/dist/server/notifications/completionNotificationRouter.js +9 -0
  68. package/dist/server/notifications/completionNotificationRouter.js.map +1 -1
  69. package/dist/server/routes/accountRoutes.js +11 -0
  70. package/dist/server/routes/accountRoutes.js.map +1 -1
  71. package/dist/server/routes/activityRoutes.d.ts +13 -0
  72. package/dist/server/routes/activityRoutes.js +165 -0
  73. package/dist/server/routes/activityRoutes.js.map +1 -0
  74. package/dist/server/routes/agentManagementRoutes.js +8 -1
  75. package/dist/server/routes/agentManagementRoutes.js.map +1 -1
  76. package/dist/server/routes/hookRoutes.d.ts +16 -0
  77. package/dist/server/routes/hookRoutes.js +109 -0
  78. package/dist/server/routes/hookRoutes.js.map +1 -0
  79. package/dist/server/routes/terminalRoutes.js +4 -0
  80. package/dist/server/routes/terminalRoutes.js.map +1 -1
  81. package/dist/server/routes/transcriptQuery.d.ts +2 -1
  82. package/dist/server/routes/transcriptQuery.js +3 -1
  83. package/dist/server/routes/transcriptQuery.js.map +1 -1
  84. package/dist/server/terminal/PtyAdapter.d.ts +19 -0
  85. package/dist/server/terminal/PtyAdapter.js.map +1 -1
  86. package/dist/server/terminal/TerminalManager.d.ts +61 -0
  87. package/dist/server/terminal/TerminalManager.js +672 -12
  88. package/dist/server/terminal/TerminalManager.js.map +1 -1
  89. package/dist/server/terminal/TmuxPtyAdapter.d.ts +14 -2
  90. package/dist/server/terminal/TmuxPtyAdapter.js +101 -15
  91. package/dist/server/terminal/TmuxPtyAdapter.js.map +1 -1
  92. package/dist/server/terminal/activityChannel.d.ts +57 -0
  93. package/dist/server/terminal/activityChannel.js +166 -0
  94. package/dist/server/terminal/activityChannel.js.map +1 -0
  95. package/dist/server/terminal/agentTranscriptStatus.d.ts +12 -0
  96. package/dist/server/terminal/agentTranscriptStatus.js +78 -7
  97. package/dist/server/terminal/agentTranscriptStatus.js.map +1 -1
  98. package/dist/server/terminal/codexTranscriptSource.d.ts +3 -0
  99. package/dist/server/terminal/codexTranscriptSource.js +99 -39
  100. package/dist/server/terminal/codexTranscriptSource.js.map +1 -1
  101. package/dist/server/terminal/subagentSources.js +2 -1
  102. package/dist/server/terminal/subagentSources.js.map +1 -1
  103. package/dist/server/terminal/transcriptHeader.d.ts +85 -0
  104. package/dist/server/terminal/transcriptHeader.js +376 -0
  105. package/dist/server/terminal/transcriptHeader.js.map +1 -0
  106. package/dist/server/terminal/transcriptHistory.d.ts +16 -2
  107. package/dist/server/terminal/transcriptHistory.js +120 -21
  108. package/dist/server/terminal/transcriptHistory.js.map +1 -1
  109. package/dist/server/terminal/transcriptStore.d.ts +6 -0
  110. package/dist/server/terminal/transcriptStore.js +222 -26
  111. package/dist/server/terminal/transcriptStore.js.map +1 -1
  112. package/dist/shared/activityProbe.d.ts +10 -0
  113. package/dist/shared/activityProbe.js +24 -0
  114. package/dist/shared/activityProbe.js.map +1 -0
  115. package/dist/shared/protocol.d.ts +45 -0
  116. package/dist/shared/protocol.js.map +1 -1
  117. package/dist/shared/types.d.ts +9 -1
  118. package/docs/features.md +21 -1
  119. package/docs/provider-hooks.md +340 -0
  120. package/docs/terminal-history.md +10 -0
  121. package/package.json +2 -1
  122. package/scripts/nterminal-hook.cjs +171 -0
  123. package/scripts/nterminalctl +26 -2
  124. package/dist/client/assets/MarkdownPreview-DjDO4lEl.js +0 -1
  125. package/dist/client/assets/index-CIYazAGv.css +0 -1
  126. package/dist/client/assets/index-DwvLN_WJ.js +0 -56
@@ -4,6 +4,7 @@ import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
+ import { ProviderRequestError, requestAccountUsage, requestTokenRefresh, unavailableUsage } from './providerRequests.js';
7
8
  const VAULT_DIRECTORY = 'credential-vault';
8
9
  const VAULT_INDEX = 'index.json';
9
10
  const VAULT_KEY = 'key';
@@ -19,13 +20,8 @@ const UNKNOWN_EXPIRY_RETRY_MS = 15 * 60_000;
19
20
  const UNKNOWN_EXPIRY_REFRESH_INTERVAL_MS = 7 * 24 * 60 * 60_000;
20
21
  const PROVIDER_TERMINATE_GRACE_MS = 2_000;
21
22
  const TERMINATION_ACKNOWLEDGEMENT_TTL_MS = 5 * 60_000;
22
- const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
23
- // Keep this aligned with Codex CLI's public OAuth client. Codex refreshes via
24
- // JSON (unlike Claude's form-encoded endpoint), and the client identifier is
25
- // validated by auth.openai.com.
26
- const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
27
- const CLAUDE_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
28
- const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
23
+ const USAGE_CACHE_TTL_MS = 2 * 60_000;
24
+ const USAGE_FAILURE_TTL_MS = 30_000;
29
25
  export class AccountServiceError extends Error {
30
26
  code;
31
27
  constructor(code, message = code) {
@@ -36,7 +32,7 @@ export class AccountServiceError extends Error {
36
32
  get statusCode() {
37
33
  if (this.code === 'preview_mode')
38
34
  return 403;
39
- if (['provider_panes_running', 'account_operation_busy', 'account_credential_changed', 'account_login_already_pending'].includes(this.code))
35
+ if (['provider_panes_running', 'account_operation_busy', 'account_credential_changed', 'account_login_already_pending', 'active_cli_refresh_deferred'].includes(this.code))
40
36
  return 409;
41
37
  return 400;
42
38
  }
@@ -52,8 +48,14 @@ export class AccountService {
52
48
  operationInFlight = false;
53
49
  disposed = false;
54
50
  retryAfter = new Map();
55
- // In-memory acknowledgement set by an explicit user request to stop a provider.
56
- // It permits one imminent account change even when a process takes time to exit.
51
+ usageCache = new Map();
52
+ usageInFlight = new Map();
53
+ fetcher;
54
+ onDiagnostic;
55
+ diagnosticSignatures = new Map();
56
+ // In-memory acknowledgement set once an explicit stop left no provider
57
+ // processes. It only bridges the pane-status poll lag; live processes always
58
+ // block, so a session started afterwards cannot race an account change.
57
59
  terminationAcknowledgements = new Map();
58
60
  processSnapshotInFlight = null;
59
61
  processProbe;
@@ -70,6 +72,8 @@ export class AccountService {
70
72
  // vault instead of touching production account metadata.
71
73
  this.vaultPath = path.join(path.dirname(config.statePath), VAULT_DIRECTORY);
72
74
  this.now = options.now ?? (() => new Date());
75
+ this.fetcher = options.fetcher ?? ((...args) => fetch(...args));
76
+ this.onDiagnostic = options.onDiagnostic ?? (() => undefined);
73
77
  this.processLister = options.processLister ?? (async (provider) => providerProcesses(provider, await this.listProcessSnapshot()));
74
78
  this.paneProcessResolver = options.paneProcessResolver ?? (options.processLister
75
79
  ? async () => new Map()
@@ -95,7 +99,7 @@ export class AccountService {
95
99
  async synchronize() {
96
100
  return this.mutate(async () => {
97
101
  const index = await this.readIndex();
98
- await this.captureActiveCredentials(index);
102
+ await this.captureActiveCredentials(index, undefined, true);
99
103
  });
100
104
  }
101
105
  select(provider, profileId) {
@@ -116,6 +120,80 @@ export class AccountService {
116
120
  refresh(provider, profileId) {
117
121
  return this.mutate(() => this.refreshUnlocked(provider, profileId));
118
122
  }
123
+ usage(provider) {
124
+ if (this.config.previewMode)
125
+ return Promise.reject(new AccountServiceError('preview_mode'));
126
+ if (this.disposed)
127
+ return Promise.reject(new AccountServiceError('account_operation_busy'));
128
+ const pending = this.usageInFlight.get(provider);
129
+ if (pending)
130
+ return pending;
131
+ const request = this.readUsage(provider);
132
+ this.usageInFlight.set(provider, request);
133
+ void request.finally(() => {
134
+ if (this.usageInFlight.get(provider) === request)
135
+ this.usageInFlight.delete(provider);
136
+ }).catch(() => undefined);
137
+ return request;
138
+ }
139
+ async readUsage(provider) {
140
+ const index = await this.readIndex();
141
+ const profiles = index.profiles.filter((profile) => profile.provider === provider);
142
+ const results = [];
143
+ const signal = AbortSignal.timeout(10_000);
144
+ let cursor = 0;
145
+ const worker = async () => {
146
+ while (cursor < profiles.length && !this.disposed) {
147
+ const profile = profiles[cursor++];
148
+ const usage = signal.aborted ? unavailableUsage(this.now(), 'usage_timeout') : await this.readProfileUsage(index, profile, signal);
149
+ if (usage)
150
+ results.push({ profileId: profile.id, usage });
151
+ }
152
+ };
153
+ await Promise.all([worker(), worker()]);
154
+ for (const id of this.usageCache.keys())
155
+ if (!index.profiles.some((profile) => profile.id === id))
156
+ this.usageCache.delete(id);
157
+ return { provider, profiles: results };
158
+ }
159
+ async readProfileUsage(index, profile, signal) {
160
+ const provider = profile.provider;
161
+ const cached = this.usageCache.get(profile.id);
162
+ let usage;
163
+ const credentialHash = profile.credentialHash;
164
+ try {
165
+ if (index.active[provider] !== profile.id && cached && cached.until > this.now().getTime() && cached.credentialHash === credentialHash)
166
+ return cached.usage;
167
+ const credentials = index.active[provider] === profile.id
168
+ ? await this.readCredentialFile(provider) : await this.readVaultCredentials(profile.id);
169
+ if (!credentials || hashCredential(credentials) !== credentialHash)
170
+ throw new AccountServiceError('account_credential_changed');
171
+ if (cached && cached.until > this.now().getTime() && cached.credentialHash === credentialHash)
172
+ return cached.usage;
173
+ usage = await requestAccountUsage(provider, credentials, this.now(), this.fetcher, signal);
174
+ }
175
+ catch (error) {
176
+ usage = unavailableUsage(this.now(), error instanceof AccountServiceError ? error.code : 'usage_unavailable');
177
+ }
178
+ // A switch, login, removal, or refresh can finish while a quota request is
179
+ // in flight. Never associate its response with a different credential.
180
+ const latest = await this.readIndex();
181
+ const current = latest.profiles.find((entry) => entry.id === profile.id && entry.provider === provider);
182
+ if (!current)
183
+ return null;
184
+ if (current.credentialHash !== credentialHash || latest.active[provider] !== index.active[provider]) {
185
+ usage = unavailableUsage(this.now(), 'account_credential_changed');
186
+ }
187
+ else if (latest.active[provider] === profile.id) {
188
+ const live = await this.readCredentialFile(provider).catch(() => null);
189
+ if (!live || hashCredential(live) !== credentialHash)
190
+ usage = unavailableUsage(this.now(), 'account_credential_changed');
191
+ }
192
+ if (usage.code !== 'account_credential_changed' && credentialHash) {
193
+ this.usageCache.set(profile.id, { credentialHash, until: this.now().getTime() + (usage.status === 'available' ? USAGE_CACHE_TTL_MS : USAGE_FAILURE_TTL_MS), usage });
194
+ }
195
+ return usage;
196
+ }
119
197
  terminateProviderSessions(provider) {
120
198
  return this.mutate(() => this.terminateProviderSessionsUnlocked(provider));
121
199
  }
@@ -143,7 +221,7 @@ export class AccountService {
143
221
  }));
144
222
  const pendingReady = new Map();
145
223
  await Promise.all(['codex', 'claude'].map(async (provider) => {
146
- pendingReady.set(provider, Boolean(refreshed.pending[provider] && await this.readCredentialFile(provider)));
224
+ pendingReady.set(provider, Boolean(refreshed.pending[provider] && await this.readCredentialFile(provider).catch(() => null)));
147
225
  }));
148
226
  return {
149
227
  providers: ['codex', 'claude'].map((provider) => this.providerStatus(refreshed, provider, running, processes.get(provider) ?? [], processCheckUnavailable, pendingReady.get(provider) === true))
@@ -154,7 +232,7 @@ export class AccountService {
154
232
  await this.assertNoRunningPanes(provider, { allowTerminationAcknowledgement: true });
155
233
  if (index.pending[provider])
156
234
  throw new AccountServiceError('account_login_already_pending');
157
- await this.captureActiveCredentials(index);
235
+ await this.captureActiveCredentials(index, provider);
158
236
  const profile = index.profiles.find((candidate) => candidate.id === profileId && candidate.provider === provider);
159
237
  if (!profile)
160
238
  throw new AccountServiceError('account_not_found');
@@ -187,7 +265,7 @@ export class AccountService {
187
265
  const index = await this.readIndex();
188
266
  if (index.pending[provider])
189
267
  throw new AccountServiceError('account_login_already_pending');
190
- await this.captureActiveCredentials(index);
268
+ await this.captureActiveCredentials(index, provider);
191
269
  const profile = index.profiles.find((candidate) => candidate.id === profileId && candidate.provider === provider);
192
270
  if (!profile)
193
271
  throw new AccountServiceError('account_not_found');
@@ -198,6 +276,8 @@ export class AccountService {
198
276
  await this.writeIndex(index);
199
277
  await rm(this.profilePath(profile.id), { force: true });
200
278
  this.retryAfter.delete(profile.id);
279
+ this.usageCache.delete(profile.id);
280
+ this.diagnosticSignatures.delete(`account.refresh:${provider}:${profile.id}`);
201
281
  return this.status();
202
282
  }
203
283
  async beginLoginUnlocked(provider) {
@@ -205,7 +285,7 @@ export class AccountService {
205
285
  await this.assertNoRunningPanes(provider, { allowTerminationAcknowledgement: true });
206
286
  if (index.pending[provider])
207
287
  throw new AccountServiceError('account_login_already_pending');
208
- await this.captureActiveCredentials(index);
288
+ await this.captureActiveCredentials(index, provider);
209
289
  const current = await this.archiveActiveCredential(index, provider);
210
290
  index.pending[provider] = {
211
291
  provider,
@@ -254,7 +334,7 @@ export class AccountService {
254
334
  const credentials = await this.readCredentialFile(provider);
255
335
  if (!credentials)
256
336
  throw new AccountServiceError('account_login_not_complete');
257
- const profile = await this.storeProfile(index, provider, credentials, label || defaultLabel(provider, index.profiles));
337
+ const profile = await this.storeProfile(index, provider, credentials, label);
258
338
  index.active[provider] = profile.id;
259
339
  delete index.pending[provider];
260
340
  await this.writeIndex(index);
@@ -286,15 +366,12 @@ export class AccountService {
286
366
  }
287
367
  async refreshUnlocked(provider, profileId) {
288
368
  const index = await this.readIndex();
289
- if (index.pending[provider])
290
- throw new AccountServiceError('account_login_already_pending');
291
- await this.captureActiveCredentials(index);
369
+ await this.captureActiveCredentials(index, provider, true);
292
370
  const targetId = profileId ?? index.active[provider];
293
371
  const profile = targetId ? index.profiles.find((candidate) => candidate.id === targetId && candidate.provider === provider) : undefined;
294
372
  if (!profile)
295
373
  throw new AccountServiceError('account_not_found');
296
- await this.refreshProfile(index, profile);
297
- await this.writeIndex(index);
374
+ await this.refreshAndRecord(index, profile);
298
375
  return this.status();
299
376
  }
300
377
  start() {
@@ -328,17 +405,11 @@ export class AccountService {
328
405
  try {
329
406
  await this.mutate(async () => {
330
407
  const index = await this.readIndex();
331
- if (index.pending[profile.provider])
332
- return;
333
- await this.captureActiveCredentials(index);
408
+ await this.captureActiveCredentials(index, profile.provider, true);
334
409
  const current = index.profiles.find((entry) => entry.id === profile.id);
335
410
  if (!current || !shouldRefreshProfile(current, this.now()))
336
411
  return;
337
- if (current.refreshTokenExpiresAt && Date.parse(current.refreshTokenExpiresAt) <= this.now().getTime())
338
- return;
339
- await this.refreshProfile(index, current);
340
- await this.writeIndex(index);
341
- this.retryAfter.delete(current.id);
412
+ await this.refreshAndRecord(index, current);
342
413
  });
343
414
  }
344
415
  catch (error) {
@@ -347,20 +418,62 @@ export class AccountService {
347
418
  // expiry instead of delaying the profile on stale metadata.
348
419
  if (error instanceof AccountServiceError && ['account_operation_busy', 'account_credential_changed'].includes(error.code))
349
420
  continue;
350
- const retryAt = error instanceof AccountServiceError && error.code === 'account_refresh_failed'
351
- ? this.now().getTime() + UNKNOWN_EXPIRY_RETRY_MS
352
- : refreshRetryAt(profile, this.now());
353
- this.retryAfter.set(profile.id, retryAt);
421
+ this.retryAfter.set(profile.id, refreshRetryAt(profile, this.now()));
354
422
  }
355
423
  }
356
424
  }
357
- catch {
358
- // Background filesystem failures must not become unhandled rejections.
425
+ catch (error) {
426
+ this.reportDiagnostic({ event: 'account.refresh_scan', state: 'retrying', code: serviceErrorCode(error) });
359
427
  }
360
428
  finally {
361
429
  this.refreshInFlight = false;
362
430
  }
363
431
  }
432
+ async refreshAndRecord(index, profile) {
433
+ const previous = profile.refresh;
434
+ const attemptedAt = this.now().toISOString();
435
+ profile.refresh = {
436
+ state: 'retrying', lastAttemptAt: attemptedAt, lastSuccessAt: previous?.lastSuccessAt ?? null,
437
+ nextRetryAt: null, code: null, httpStatus: null
438
+ };
439
+ await this.writeIndex(index);
440
+ try {
441
+ await this.refreshProfile(index, profile);
442
+ profile.refresh = { ...profile.refresh, state: 'success', lastAttemptAt: attemptedAt, lastSuccessAt: this.now().toISOString(), nextRetryAt: null, code: null, httpStatus: null };
443
+ this.retryAfter.delete(profile.id);
444
+ this.usageCache.delete(profile.id);
445
+ await this.writeIndex(index);
446
+ this.reportDiagnostic({ event: 'account.refresh', provider: profile.provider, profileId: profile.id, state: 'success', code: null });
447
+ }
448
+ catch (error) {
449
+ if (error instanceof AccountServiceError && error.code === 'account_credential_changed') {
450
+ this.reportDiagnostic({ event: 'account.refresh', provider: profile.provider, profileId: profile.id, state: 'deferred', code: error.code });
451
+ throw error;
452
+ }
453
+ const failure = error instanceof ProviderRequestError ? error : null;
454
+ const code = failure?.code ?? serviceErrorCode(error);
455
+ const state = failure?.kind === 'reauth' || code === 'refresh_token_unavailable' ? 'reauth_required' : ['active_cli_refresh_deferred', 'account_process_check_unavailable'].includes(code) ? 'deferred' : 'retrying';
456
+ const retryMs = failure?.kind === 'rejected' ? UNKNOWN_EXPIRY_RETRY_MS : null;
457
+ const retryAt = state === 'reauth_required' ? null : new Date(Math.max(retryMs ? this.now().getTime() + retryMs : refreshRetryAt(profile, this.now()), this.now().getTime() + (failure?.retryAfterMs ?? 0))).toISOString();
458
+ profile.refresh = { state, lastAttemptAt: attemptedAt, lastSuccessAt: previous?.lastSuccessAt ?? null, nextRetryAt: retryAt, code, httpStatus: failure?.httpStatus ?? null };
459
+ this.reportDiagnostic({ event: 'account.refresh', provider: profile.provider, profileId: profile.id, state, code, httpStatus: failure?.httpStatus ?? null, nextRetryAt: retryAt });
460
+ await this.writeIndex(index);
461
+ if (error instanceof AccountServiceError)
462
+ throw error;
463
+ throw new AccountServiceError(state === 'reauth_required' ? 'account_reauth_required' : 'account_refresh_unavailable');
464
+ }
465
+ }
466
+ reportDiagnostic(event) {
467
+ const key = `${event.event}:${event.provider ?? ''}:${event.profileId ?? ''}`;
468
+ const signature = `${event.state}:${event.code}:${event.httpStatus ?? ''}`;
469
+ if (this.diagnosticSignatures.get(key) === signature)
470
+ return;
471
+ this.diagnosticSignatures.set(key, signature);
472
+ try {
473
+ this.onDiagnostic(event);
474
+ }
475
+ catch { /* Logging cannot interrupt token storage. */ }
476
+ }
364
477
  providerStatus(index, provider, running, runningProcesses, processCheckUnavailable, pendingReady) {
365
478
  const activeId = index.active[provider] ?? null;
366
479
  const profile = activeId ? index.profiles.find((candidate) => candidate.id === activeId) ?? null : null;
@@ -386,23 +499,18 @@ export class AccountService {
386
499
  });
387
500
  }
388
501
  async assertNoRunningPanes(provider, options = {}) {
389
- if (provider !== 'codex' && options.allowTerminationAcknowledgement && this.hasTerminationAcknowledgement(provider))
390
- return;
391
- if (this.runningPanes().some((pane) => pane.provider === provider) || await this.processProbe(provider)) {
502
+ const acknowledged = options.allowTerminationAcknowledgement === true && this.hasTerminationAcknowledgement(provider);
503
+ if ((!acknowledged && this.runningPanes().some((pane) => pane.provider === provider)) || await this.processProbe(provider)) {
392
504
  throw new AccountServiceError('provider_panes_running');
393
505
  }
394
506
  }
395
507
  async terminateProviderSessionsUnlocked(provider) {
396
508
  const processes = await this.processLister(provider);
509
+ this.clearTerminationAcknowledgement(provider);
397
510
  await this.processTerminator(provider, processes);
398
- if (provider === 'codex') {
399
- this.clearTerminationAcknowledgement(provider);
400
- if (await this.processProbe(provider))
401
- throw new AccountServiceError('provider_panes_running');
402
- }
403
- else {
404
- this.terminationAcknowledgements.set(provider, this.now().getTime() + TERMINATION_ACKNOWLEDGEMENT_TTL_MS);
405
- }
511
+ if (await this.processProbe(provider))
512
+ throw new AccountServiceError('provider_panes_running');
513
+ this.terminationAcknowledgements.set(provider, this.now().getTime() + TERMINATION_ACKNOWLEDGEMENT_TTL_MS);
406
514
  return this.status();
407
515
  }
408
516
  hasTerminationAcknowledgement(provider) {
@@ -433,17 +541,28 @@ export class AccountService {
433
541
  }
434
542
  return this.processSnapshotInFlight;
435
543
  }
436
- async captureActiveCredentials(index) {
544
+ async captureActiveCredentials(index, onlyProvider, tolerateErrors = false) {
437
545
  const before = JSON.stringify(index);
438
- for (const provider of ['codex', 'claude']) {
439
- if (!index.pending[provider])
546
+ const providers = onlyProvider ? [onlyProvider] : ['codex', 'claude'];
547
+ for (const provider of providers) {
548
+ if (index.pending[provider])
549
+ continue;
550
+ try {
440
551
  await this.captureActiveCredential(index, provider);
552
+ this.diagnosticSignatures.delete(`account.refresh_scan:${provider}:`);
553
+ }
554
+ catch (error) {
555
+ if (!tolerateErrors)
556
+ throw error;
557
+ this.reportDiagnostic({ event: 'account.refresh_scan', provider, state: 'retrying', code: serviceErrorCode(error) });
558
+ }
441
559
  }
442
560
  if (JSON.stringify(index) !== before)
443
561
  await this.writeIndex(index);
444
562
  // Only metadata is retained for active accounts. A duplicate can exist
445
563
  // briefly during a switch, until the source file and index are committed.
446
- for (const id of Object.values(index.active)) {
564
+ for (const provider of providers) {
565
+ const id = index.active[provider];
447
566
  if (id)
448
567
  await rm(this.profilePath(id), { force: true });
449
568
  }
@@ -467,7 +586,7 @@ export class AccountService {
467
586
  await this.removeUnavailableProfile(index, previousId);
468
587
  return;
469
588
  }
470
- const profile = await this.storeProfile(index, provider, credentials, defaultLabel(provider, index.profiles));
589
+ const profile = await this.storeProfile(index, provider, credentials);
471
590
  index.active[provider] = profile.id;
472
591
  if (previousId !== profile.id)
473
592
  await this.removeUnavailableProfile(index, previousId);
@@ -495,21 +614,27 @@ export class AccountService {
495
614
  await this.writeVaultCredentials(profile.id, credentials);
496
615
  return credentials;
497
616
  }
498
- async storeProfile(index, provider, credentials, label) {
617
+ // A blank label only names new profiles; re-authenticating an existing
618
+ // account keeps the label the user gave it.
619
+ async storeProfile(index, provider, credentials, label = '') {
499
620
  const fingerprint = fingerprintFor(provider, credentials);
500
621
  const existing = index.profiles.find((profile) => profile.provider === provider && profile.fingerprint === fingerprint);
501
622
  const now = this.now().toISOString();
502
623
  const timing = credentialTiming(provider, credentials);
503
624
  const profile = existing ?? {
504
- id: randomUUID(), provider, label: sanitizeLabel(label), createdAt: now, updatedAt: now,
625
+ id: randomUUID(), provider, label: sanitizeLabel(label.trim() || defaultLabel(provider, index.profiles)), createdAt: now, updatedAt: now,
505
626
  expiresAt: timing.expiresAt, refreshTokenExpiresAt: timing.refreshTokenExpiresAt, fingerprint
506
627
  };
507
- profile.label = sanitizeLabel(label || profile.label);
628
+ if (label.trim())
629
+ profile.label = sanitizeLabel(label);
508
630
  profile.updatedAt = now;
509
631
  profile.expiresAt = timing.expiresAt;
510
632
  profile.refreshTokenExpiresAt = timing.refreshTokenExpiresAt;
511
633
  profile.fingerprint = fingerprint;
512
634
  profile.credentialHash = hashCredential(credentials);
635
+ delete profile.refresh;
636
+ this.retryAfter.delete(profile.id);
637
+ this.usageCache.delete(profile.id);
513
638
  if (!existing)
514
639
  index.profiles.push(profile);
515
640
  return profile;
@@ -519,6 +644,9 @@ export class AccountService {
519
644
  profile.expiresAt = timing.expiresAt;
520
645
  profile.refreshTokenExpiresAt = timing.refreshTokenExpiresAt;
521
646
  profile.credentialHash = hashCredential(credentials);
647
+ delete profile.refresh;
648
+ this.retryAfter.delete(profile.id);
649
+ this.usageCache.delete(profile.id);
522
650
  return profile;
523
651
  }
524
652
  async refreshProfile(index, profile) {
@@ -533,46 +661,30 @@ export class AccountService {
533
661
  if (!refreshToken)
534
662
  throw new AccountServiceError('refresh_token_unavailable');
535
663
  if (profile.refreshTokenExpiresAt && Date.parse(profile.refreshTokenExpiresAt) <= this.now().getTime()) {
536
- throw new AccountServiceError('account_refresh_failed');
664
+ throw new ProviderRequestError('refresh_token_expired', 'reauth');
537
665
  }
538
- const request = refreshRequest(profile.provider, refreshToken);
539
- let response;
666
+ // Idle describes a conversation, not the CLI's background auth work. Until
667
+ // we can delegate to its native auth owner, never compete with a live CLI.
668
+ if (active)
669
+ await this.assertNoRefreshOwner(profile.provider);
670
+ let refreshResponse;
540
671
  try {
541
- response = await fetch(profile.provider === 'codex' ? CODEX_TOKEN_URL : CLAUDE_TOKEN_URL, {
542
- method: 'POST', headers: request.headers, body: request.body, signal: AbortSignal.timeout(15_000)
543
- });
544
- }
545
- catch {
546
- throw new AccountServiceError('account_refresh_unavailable');
672
+ refreshResponse = await requestTokenRefresh(profile.provider, refreshToken, this.fetcher);
547
673
  }
548
- if (!response.ok) {
674
+ catch (error) {
549
675
  // A local CLI may have rotated the shared credential while this request
550
676
  // was in flight. Prefer that credential instead of treating its old
551
677
  // refresh token rejection as a persistent account failure.
552
678
  if (active) {
553
679
  const live = await this.readCredentialFile(profile.provider);
554
680
  if (live && hashCredential(live) !== hashCredential(credentials)) {
555
- await this.captureActiveCredentials(index);
681
+ await this.captureActiveCredentials(index, profile.provider);
556
682
  throw new AccountServiceError('account_credential_changed');
557
683
  }
558
684
  }
559
- // A revoked/invalid refresh token cannot recover by polling. Temporary
560
- // provider failures must still retry before the access token expires.
561
- if (response.status === 429 || response.status >= 500)
562
- throw new AccountServiceError('account_refresh_unavailable');
563
- throw new AccountServiceError('account_refresh_failed');
564
- }
565
- let refreshed;
566
- try {
567
- refreshed = await response.json();
568
- }
569
- catch {
570
- throw new AccountServiceError('account_refresh_failed');
685
+ throw error;
571
686
  }
572
- if (!refreshed || typeof refreshed !== 'object')
573
- throw new AccountServiceError('account_refresh_failed');
574
687
  const refreshedAt = this.now();
575
- const refreshResponse = refreshed;
576
688
  const next = applyRefresh(profile.provider, credentials, refreshResponse, refreshedAt);
577
689
  const timing = refreshTiming(profile.provider, next, refreshResponse, refreshedAt, profile);
578
690
  if (active) {
@@ -589,7 +701,7 @@ export class AccountService {
589
701
  // Preserve the credential that the CLI wrote, whether it refreshed the
590
702
  // same account or completed a login for another one. This also updates
591
703
  // expiry metadata before the next background scan.
592
- await this.captureActiveCredentials(index);
704
+ await this.captureActiveCredentials(index, profile.provider);
593
705
  throw new AccountServiceError('account_credential_changed');
594
706
  }
595
707
  // The live credential is shared by every pane for this OS user. The
@@ -602,6 +714,19 @@ export class AccountService {
602
714
  }
603
715
  await this.updateProfile(profile, profile.provider, next, timing);
604
716
  }
717
+ async assertNoRefreshOwner(provider) {
718
+ if (this.runningPanes().some((pane) => pane.provider === provider))
719
+ throw new AccountServiceError('active_cli_refresh_deferred');
720
+ let running;
721
+ try {
722
+ running = await this.processProbe(provider);
723
+ }
724
+ catch {
725
+ throw new AccountServiceError('account_process_check_unavailable');
726
+ }
727
+ if (running)
728
+ throw new AccountServiceError('active_cli_refresh_deferred');
729
+ }
605
730
  async readIndex() {
606
731
  try {
607
732
  const text = await readFile(path.join(this.vaultPath, VAULT_INDEX), 'utf8');
@@ -920,9 +1045,15 @@ function sleep(milliseconds) {
920
1045
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
921
1046
  }
922
1047
  function toPublicProfile(profile) {
923
- return { id: profile.id, provider: profile.provider, label: profile.label, createdAt: profile.createdAt, updatedAt: profile.updatedAt, expiresAt: profile.expiresAt, refreshTokenExpiresAt: profile.refreshTokenExpiresAt };
1048
+ return { id: profile.id, provider: profile.provider, label: profile.label, createdAt: profile.createdAt, updatedAt: profile.updatedAt, expiresAt: profile.expiresAt, refreshTokenExpiresAt: profile.refreshTokenExpiresAt, ...(profile.refresh ? { refresh: profile.refresh } : {}) };
924
1049
  }
925
1050
  function shouldRefreshProfile(profile, now) {
1051
+ if (profile.refresh?.state === 'reauth_required')
1052
+ return false;
1053
+ if (profile.refresh?.nextRetryAt && Date.parse(profile.refresh.nextRetryAt) > now.getTime())
1054
+ return false;
1055
+ if (profile.refreshTokenExpiresAt && Date.parse(profile.refreshTokenExpiresAt) - now.getTime() <= REFRESH_WINDOW_MS)
1056
+ return true;
926
1057
  if (profile.expiresAt !== null) {
927
1058
  const expiresAt = Date.parse(profile.expiresAt);
928
1059
  return Number.isFinite(expiresAt) && expiresAt - now.getTime() <= REFRESH_WINDOW_MS;
@@ -932,11 +1063,11 @@ function shouldRefreshProfile(profile, now) {
932
1063
  }
933
1064
  function refreshRetryAt(profile, now) {
934
1065
  const minimum = now.getTime() + REFRESH_RETRY_MIN_MS;
935
- if (!profile.expiresAt)
936
- return now.getTime() + UNKNOWN_EXPIRY_RETRY_MS;
937
- const expiresAt = Date.parse(profile.expiresAt);
938
- if (!Number.isFinite(expiresAt))
1066
+ const deadlines = [profile.expiresAt, profile.refreshTokenExpiresAt]
1067
+ .flatMap((value) => value && Number.isFinite(Date.parse(value)) ? [Date.parse(value)] : []);
1068
+ if (deadlines.length === 0)
939
1069
  return now.getTime() + UNKNOWN_EXPIRY_RETRY_MS;
1070
+ const expiresAt = Math.min(...deadlines);
940
1071
  const latestSafeRetry = expiresAt - REFRESH_RETRY_SAFETY_MS;
941
1072
  return Math.max(minimum, Math.min(now.getTime() + REFRESH_RETRY_MAX_MS, latestSafeRetry));
942
1073
  }
@@ -952,9 +1083,9 @@ function refreshTiming(provider, document, response, refreshedAt, previous) {
952
1083
  const timing = credentialTiming(provider, document);
953
1084
  if (!timing.expiresAt) {
954
1085
  const expiresIn = numberValue(response.expires_in);
955
- timing.expiresAt = expiresIn ? new Date(refreshedAt.getTime() + expiresIn * 1000).toISOString() : previous.expiresAt;
1086
+ timing.expiresAt = expiresIn ? safeFutureTime(refreshedAt, expiresIn) : null;
956
1087
  }
957
- if (!timing.refreshTokenExpiresAt)
1088
+ if (!timing.refreshTokenExpiresAt && !stringValue(response.refresh_token))
958
1089
  timing.refreshTokenExpiresAt = previous.refreshTokenExpiresAt;
959
1090
  return timing;
960
1091
  }
@@ -963,30 +1094,29 @@ function refreshTokenFor(provider, document) {
963
1094
  const token = source.refreshToken ?? source.refresh_token;
964
1095
  return typeof token === 'string' && token ? token : null;
965
1096
  }
966
- function refreshRequest(provider, refreshToken) {
967
- const values = {
968
- grant_type: 'refresh_token',
969
- refresh_token: refreshToken,
970
- client_id: provider === 'codex' ? CODEX_CLIENT_ID : CLAUDE_CLIENT_ID
971
- };
972
- if (provider === 'codex') {
973
- return { headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(values) };
974
- }
975
- return { headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(values).toString() };
976
- }
977
1097
  function applyRefresh(provider, document, response, now) {
978
1098
  const accessToken = stringValue(response.access_token);
979
1099
  const refreshToken = stringValue(response.refresh_token);
980
1100
  if (!accessToken)
981
- throw new AccountServiceError('account_refresh_failed');
1101
+ throw new ProviderRequestError('provider_invalid_response', 'transient');
982
1102
  if (provider === 'claude') {
983
1103
  const oauth = isRecord(document.claudeAiOauth) ? { ...document.claudeAiOauth } : {};
984
1104
  oauth.accessToken = accessToken;
985
- if (refreshToken)
1105
+ if (refreshToken) {
1106
+ if (refreshToken !== oauth.refreshToken)
1107
+ delete oauth.refreshTokenExpiresAt;
986
1108
  oauth.refreshToken = refreshToken;
1109
+ }
987
1110
  const expiresIn = numberValue(response.expires_in);
988
- if (expiresIn)
989
- oauth.expiresAt = now.getTime() + expiresIn * 1000;
1111
+ const accessExpiry = expiresIn ? safeFutureTime(now, expiresIn) : null;
1112
+ if (accessExpiry)
1113
+ oauth.expiresAt = Date.parse(accessExpiry);
1114
+ else
1115
+ delete oauth.expiresAt;
1116
+ const refreshExpiresIn = numberValue(response.refresh_token_expires_in);
1117
+ const refreshExpiry = refreshExpiresIn ? safeFutureTime(now, refreshExpiresIn) : null;
1118
+ if (refreshExpiry)
1119
+ oauth.refreshTokenExpiresAt = Date.parse(refreshExpiry);
990
1120
  return { ...document, claudeAiOauth: oauth };
991
1121
  }
992
1122
  const tokens = isRecord(document.tokens) ? { ...document.tokens } : {};
@@ -1043,6 +1173,7 @@ function isRecord(value) { return Boolean(value) && typeof value === 'object' &&
1043
1173
  function isVaultProfile(value) { return isRecord(value) && typeof value.id === 'string' && (value.provider === 'codex' || value.provider === 'claude') && typeof value.label === 'string' && typeof value.createdAt === 'string' && typeof value.updatedAt === 'string' && (value.expiresAt === null || typeof value.expiresAt === 'string') && (value.refreshTokenExpiresAt === null || typeof value.refreshTokenExpiresAt === 'string') && typeof value.fingerprint === 'string'; }
1044
1174
  function stringValue(value) { return typeof value === 'string' && value ? value : null; }
1045
1175
  function numberValue(value) { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null; }
1176
+ function safeFutureTime(now, seconds) { const millis = now.getTime() + seconds * 1000; return Number.isFinite(millis) && Math.abs(millis) <= 8.64e15 ? new Date(millis).toISOString() : null; }
1046
1177
  function isoTime(value) { if (typeof value === 'string' && Number.isFinite(Date.parse(value)))
1047
1178
  return new Date(value).toISOString(); if (typeof value === 'number' && Number.isFinite(value))
1048
1179
  return new Date(value).toISOString(); return null; }
@@ -1056,6 +1187,7 @@ catch {
1056
1187
  return null;
1057
1188
  } }
1058
1189
  function isNotFound(error) { return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'); }
1190
+ function serviceErrorCode(error) { return error instanceof AccountServiceError ? error.code : 'account_storage_unavailable'; }
1059
1191
  async function atomicWrite(target, value, mode) {
1060
1192
  const temp = `${target}.${randomUUID()}.tmp`;
1061
1193
  try {