vibe-usage-cli 0.0.0-stage → 0.1.1

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 (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -2
  3. package/build/cli.js +197 -0
  4. package/build/core/claude-processes.d.ts +51 -0
  5. package/build/core/claude-processes.js +288 -0
  6. package/build/core/claude-profile.d.ts +2 -0
  7. package/build/core/claude-profile.js +146 -0
  8. package/build/core/claude-renew.d.ts +12 -0
  9. package/build/core/claude-renew.js +79 -0
  10. package/build/core/continuity.d.ts +90 -0
  11. package/build/core/continuity.js +1244 -0
  12. package/build/core/diagnostics.d.ts +25 -0
  13. package/build/core/diagnostics.js +229 -0
  14. package/build/core/environment.d.ts +12 -0
  15. package/build/core/environment.js +25 -0
  16. package/build/core/errors.d.ts +23 -0
  17. package/build/core/errors.js +48 -0
  18. package/build/core/files.d.ts +20 -0
  19. package/build/core/files.js +136 -0
  20. package/build/core/glance.d.ts +25 -0
  21. package/build/core/glance.js +107 -0
  22. package/build/core/handover.d.ts +61 -0
  23. package/build/core/handover.js +333 -0
  24. package/build/core/history.d.ts +19 -0
  25. package/build/core/history.js +134 -0
  26. package/build/core/launch.d.ts +78 -0
  27. package/build/core/launch.js +256 -0
  28. package/build/core/measurements.d.ts +11 -0
  29. package/build/core/measurements.js +75 -0
  30. package/build/core/model-families.d.ts +12 -0
  31. package/build/core/model-families.js +61 -0
  32. package/build/core/model.d.ts +32 -0
  33. package/build/core/model.js +86 -0
  34. package/build/core/models.d.ts +25 -0
  35. package/build/core/models.js +164 -0
  36. package/build/core/redact.d.ts +2 -0
  37. package/build/core/redact.js +64 -0
  38. package/build/core/selection.d.ts +65 -0
  39. package/build/core/selection.js +173 -0
  40. package/build/core/service.d.ts +35 -0
  41. package/build/core/service.js +484 -0
  42. package/build/core/sessions.d.ts +87 -0
  43. package/build/core/sessions.js +868 -0
  44. package/build/core/settings.d.ts +16 -0
  45. package/build/core/settings.js +377 -0
  46. package/build/core/tray-icon.d.ts +5 -0
  47. package/build/core/tray-icon.js +91 -0
  48. package/build/core/updater.d.ts +29 -0
  49. package/build/core/updater.js +88 -0
  50. package/build/core/updates.d.ts +5 -0
  51. package/build/core/updates.js +29 -0
  52. package/build/launchers/discover-antigravity.ps1 +68 -0
  53. package/build/launchers/terminal.ps1 +78 -0
  54. package/build/main/main.js +587 -0
  55. package/build/providers/antigravity.d.ts +48 -0
  56. package/build/providers/antigravity.js +463 -0
  57. package/build/providers/claude-keychain.d.ts +13 -0
  58. package/build/providers/claude-keychain.js +42 -0
  59. package/build/providers/claude.d.ts +33 -0
  60. package/build/providers/claude.js +218 -0
  61. package/build/providers/codex.d.ts +21 -0
  62. package/build/providers/codex.js +221 -0
  63. package/build/providers/common.d.ts +63 -0
  64. package/build/providers/common.js +294 -0
  65. package/build/providers/cursor.d.ts +14 -0
  66. package/build/providers/cursor.js +141 -0
  67. package/build/providers/profiles.d.ts +10 -0
  68. package/build/providers/profiles.js +69 -0
  69. package/build/providers/tls-trust.d.ts +7 -0
  70. package/build/providers/tls-trust.js +29 -0
  71. package/build/providers/types.d.ts +19 -0
  72. package/build/providers/types.js +1 -0
  73. package/build/shared/account-state.d.ts +19 -0
  74. package/build/shared/account-state.js +75 -0
  75. package/build/shared/catalog.d.ts +85 -0
  76. package/build/shared/catalog.js +125 -0
  77. package/build/shared/format.d.ts +40 -0
  78. package/build/shared/format.js +134 -0
  79. package/build/shared/i18n/index.d.ts +12 -0
  80. package/build/shared/i18n/index.js +40 -0
  81. package/build/shared/i18n/locales/de.d.ts +2 -0
  82. package/build/shared/i18n/locales/de.js +328 -0
  83. package/build/shared/i18n/locales/en.d.ts +328 -0
  84. package/build/shared/i18n/locales/en.js +330 -0
  85. package/build/shared/i18n/locales/es.d.ts +2 -0
  86. package/build/shared/i18n/locales/es.js +328 -0
  87. package/build/shared/i18n/locales/fr.d.ts +2 -0
  88. package/build/shared/i18n/locales/fr.js +328 -0
  89. package/build/shared/i18n/locales/it.d.ts +2 -0
  90. package/build/shared/i18n/locales/it.js +327 -0
  91. package/build/shared/i18n/locales/ja.d.ts +2 -0
  92. package/build/shared/i18n/locales/ja.js +322 -0
  93. package/build/shared/i18n/locales/ko.d.ts +2 -0
  94. package/build/shared/i18n/locales/ko.js +322 -0
  95. package/build/shared/i18n/locales/nl.d.ts +2 -0
  96. package/build/shared/i18n/locales/nl.js +327 -0
  97. package/build/shared/i18n/locales/pl.d.ts +2 -0
  98. package/build/shared/i18n/locales/pl.js +332 -0
  99. package/build/shared/i18n/locales/pt.d.ts +2 -0
  100. package/build/shared/i18n/locales/pt.js +328 -0
  101. package/build/shared/i18n/locales/ru.d.ts +2 -0
  102. package/build/shared/i18n/locales/ru.js +331 -0
  103. package/build/shared/i18n/locales/tr.d.ts +2 -0
  104. package/build/shared/i18n/locales/tr.js +322 -0
  105. package/build/shared/i18n/locales/zh.d.ts +2 -0
  106. package/build/shared/i18n/locales/zh.js +322 -0
  107. package/build/shared/i18n/messages.d.ts +10 -0
  108. package/build/shared/i18n/messages.js +1 -0
  109. package/build/shared/ipc.d.ts +138 -0
  110. package/build/shared/ipc.js +2 -0
  111. package/build/shared/tsconfig.tsbuildinfo +1 -0
  112. package/build/shared/types.d.ts +205 -0
  113. package/build/shared/types.js +1 -0
  114. package/package.json +144 -4
@@ -0,0 +1,288 @@
1
+ // Evidence about the Claude Code process running a session, from what Claude Code and the
2
+ // system keep about it.
3
+ //
4
+ // A Claude Code process that saves its session lists itself in <profile>/sessions/<pid>.json,
5
+ // with its session id, what it is doing and when it started, from startup on. A process begun
6
+ // as a child session (the markers in CLAUDE_SESSION_ENV_KEYS) lists nothing and saves nothing:
7
+ // it leaves only <profile>/sessions/<pid>.<hash>.key, the key of its messaging socket, which
8
+ // every process writes. That key is what ties a process to a profile.
9
+ //
10
+ // A process not listed is looked for among the system's processes, by the `--resume <session>`
11
+ // it was started with, among the pids the profile holds a key for. The list file can outlive
12
+ // its process and a pid can be reused, so a process is ended only after its start time matches
13
+ // the one recorded: exactly, where Claude Code recorded the system's own (`procStart`, Windows
14
+ // file-time ticks), else to within a second or two.
15
+ //
16
+ // Asking the system can fail (a timeout under load, say). That is not the same as "no such
17
+ // process": a caller gets `unknown` and acts on nothing until it can tell.
18
+ import { execFile } from 'node:child_process';
19
+ import fsp from 'node:fs/promises';
20
+ import path from 'node:path';
21
+ import { promisify } from 'node:util';
22
+ import { CLAUDE_FILES } from "../providers/claude.js";
23
+ import { windowsPowerShell } from "../providers/common.js";
24
+ import { systemErrorCode } from "./errors.js";
25
+ import { isOneOf } from "../shared/catalog.js";
26
+ import { isObject, readJson } from "./files.js";
27
+ const execFileAsync = promisify(execFile);
28
+ const QUERY_TIMEOUT_MS = 10_000;
29
+ // Windows records process start times in 100 ns ticks since 1601.
30
+ const TICKS_PER_MS = 10_000;
31
+ const UNIX_EPOCH_TICKS = 116444736000000000n;
32
+ // Without `procStart`: how far the recorded start may be from the process's own, for the two
33
+ // to be one process.
34
+ const SAME_START_MS = 2_000;
35
+ const LIST_ENTRY = /^(\d+)\.json$/;
36
+ const KEY_ENTRY = /^(\d+)\.[0-9a-f]+\.key$/;
37
+ // A pid or Windows file-time ticks, as the system prints them and Claude Code records a start.
38
+ const DIGITS = /^\d+$/;
39
+ // What a listed Claude Code process says it is doing (pinned by the corpus contract). `waiting`
40
+ // is a session Claude Code holds at the usage limit until the reset (2.1.302, seen 2026-10-18);
41
+ // such a session counts as stopped by its transcript, not by this.
42
+ export const CLAUDE_SESSION_STATUSES = ['busy', 'idle', 'shell', 'waiting'];
43
+ function isAlive(pid) {
44
+ try {
45
+ process.kill(pid, 0);
46
+ return true;
47
+ }
48
+ catch (error) {
49
+ return systemErrorCode(error) === 'EPERM';
50
+ }
51
+ }
52
+ const ticksToMs = (ticks) => Number((BigInt(ticks) - UNIX_EPOCH_TICKS) / BigInt(TICKS_PER_MS));
53
+ async function powershell(script) {
54
+ const { stdout } = await execFileAsync(windowsPowerShell(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
55
+ encoding: 'utf8',
56
+ timeout: QUERY_TIMEOUT_MS,
57
+ windowsHide: true
58
+ });
59
+ return stdout;
60
+ }
61
+ // A process start as `Get-Process` prints it (`StartTime.ToFileTimeUtc()`), null for none.
62
+ export function parseWindowsStart(stdout) {
63
+ const ticks = stdout.trim();
64
+ return DIGITS.test(ticks) ? { at: ticksToMs(ticks), ticks } : null;
65
+ }
66
+ // Processes as the Windows query prints them: `pid|ticks|command line` per line.
67
+ export function parseWindowsProcesses(stdout) {
68
+ return stdout.split(/\r?\n/).flatMap(line => {
69
+ const [pid, ticks, ...command] = line.split('|');
70
+ if (!pid || !ticks || !DIGITS.test(pid) || !DIGITS.test(ticks))
71
+ return [];
72
+ return [{ pid: Number(pid), at: ticksToMs(ticks), ticks, command: command.join('|') }];
73
+ });
74
+ }
75
+ // Claude Code processes as `ps -eo pid=,args=` prints them, without their start (looked up
76
+ // one by one).
77
+ export function parsePsProcesses(stdout) {
78
+ return stdout.split('\n').flatMap(line => {
79
+ const match = /^\s*(\d+)\s+(.*)$/.exec(line);
80
+ if (!match?.[1] || !match[2] || !/(^|[\\/\s])claude(\s|$)/.test(match[2]))
81
+ return [];
82
+ return [{ pid: Number(match[1]), command: match[2] }];
83
+ });
84
+ }
85
+ // When the process started, as the system reports it: null for a process that is gone. A
86
+ // system that cannot answer throws.
87
+ export async function processStarted(pid) {
88
+ if (process.platform === 'win32') {
89
+ const script = `$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; if ($p) { $p.StartTime.ToFileTimeUtc() } else { 'gone' }`;
90
+ const stdout = await powershell(script);
91
+ if (stdout.trim() === 'gone')
92
+ return null;
93
+ const started = parseWindowsStart(stdout);
94
+ if (!started)
95
+ throw new Error(`unexpected process start: ${stdout.trim().slice(0, 40)}`);
96
+ return started;
97
+ }
98
+ let stdout;
99
+ try {
100
+ ({ stdout } = await execFileAsync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: QUERY_TIMEOUT_MS }));
101
+ }
102
+ catch (error) {
103
+ // `ps` exits 1 for a pid it does not know.
104
+ if (systemErrorCode(error) === 1)
105
+ return null;
106
+ throw error;
107
+ }
108
+ if (!stdout.trim())
109
+ return null;
110
+ const at = Date.parse(stdout.trim());
111
+ if (!Number.isFinite(at))
112
+ throw new Error('unexpected process start');
113
+ return { at, ticks: null };
114
+ }
115
+ // The running Claude Code processes and their command lines. The start time comes from the
116
+ // same source as `processStarted`, so the two compare exactly (CIM's creation date is coarser).
117
+ async function claudeProcesses() {
118
+ if (process.platform === 'win32') {
119
+ const script = "Get-CimInstance Win32_Process -Filter \"Name='claude.exe'\" | ForEach-Object { $started = (Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue).StartTime; if ($started) { '{0}|{1}|{2}' -f $_.ProcessId, $started.ToFileTimeUtc(), $_.CommandLine } }";
120
+ return parseWindowsProcesses(await powershell(script));
121
+ }
122
+ const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,args='], { encoding: 'utf8', timeout: QUERY_TIMEOUT_MS });
123
+ const found = [];
124
+ for (const { pid, command } of parsePsProcesses(stdout)) {
125
+ const started = await processStarted(pid);
126
+ if (started)
127
+ found.push({ pid, ...started, command });
128
+ }
129
+ return found;
130
+ }
131
+ // The newest process started to resume `sessionId`.
132
+ export function resumingProcess(processes, sessionId) {
133
+ const resume = new RegExp(`(^|\\s)(--resume|-r)\\s+"?${RegExp.escape(sessionId)}"?(\\s|$)`);
134
+ return [...processes].filter(p => resume.test(p.command)).sort((a, b) => b.at - a.at)[0] ?? null;
135
+ }
136
+ export const systemProcesses = {
137
+ isAlive,
138
+ started: processStarted,
139
+ list: claudeProcesses,
140
+ end: pid => {
141
+ try {
142
+ process.kill(pid);
143
+ }
144
+ catch (error) {
145
+ // Gone already is fine; a refusal (another user's process, an elevated one) is not.
146
+ if (systemErrorCode(error) !== 'ESRCH')
147
+ throw error;
148
+ }
149
+ }
150
+ };
151
+ // How long a process gets to go after being told to.
152
+ const END_WAIT_MS = 2_000;
153
+ const END_POLL_MS = 100;
154
+ const number = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
155
+ // The pids the profile's processes list under, and the pids it holds a key for.
156
+ async function profilePids(configDir) {
157
+ const dir = path.join(configDir, CLAUDE_FILES.sessions);
158
+ const names = await fsp.readdir(dir).catch(() => []);
159
+ const listed = new Map();
160
+ const keyed = new Set();
161
+ for (const name of names) {
162
+ const entry = LIST_ENTRY.exec(name);
163
+ if (entry?.[1])
164
+ listed.set(Number(entry[1]), path.join(dir, name));
165
+ const key = KEY_ENTRY.exec(name);
166
+ if (key?.[1])
167
+ keyed.add(Number(key[1]));
168
+ }
169
+ return { listed, keyed };
170
+ }
171
+ // A process as its entry in the profile's session list describes it.
172
+ function listedSession(pid, sessionId, entry) {
173
+ return {
174
+ pid,
175
+ sessionId,
176
+ listed: true,
177
+ status: isOneOf(CLAUDE_SESSION_STATUSES, entry.status) ? entry.status : null,
178
+ statusSince: number(entry.statusUpdatedAt) || number(entry.updatedAt),
179
+ startedAt: number(entry.startedAt),
180
+ procStart: typeof entry.procStart === 'string' && DIGITS.test(entry.procStart) ? entry.procStart : null
181
+ };
182
+ }
183
+ // Reads the interactive sessions a profile's processes run now, as they list themselves. Child
184
+ // sessions (unlisted, unsaved) and runs started by a program are left out: neither can be
185
+ // reopened. A list entry outlives a process that ended with the system, and its pid can belong
186
+ // to another process by now, so each process's start is checked against the system's, once
187
+ // (a system that cannot say throws, and is asked again next time).
188
+ export function openSessionsReader(processes = systemProcesses) {
189
+ const checked = new Map();
190
+ return async (configDir) => {
191
+ const { listed } = await profilePids(configDir);
192
+ const open = [];
193
+ for (const [pid, file] of listed) {
194
+ const entry = await readJson(file);
195
+ if (!isObject(entry) || entry.kind !== 'interactive' || typeof entry.sessionId !== 'string' || typeof entry.cwd !== 'string')
196
+ continue;
197
+ if (!processes.isAlive(pid))
198
+ continue;
199
+ const session = { ...listedSession(pid, entry.sessionId, entry), cwd: entry.cwd };
200
+ const id = `${pid}:${session.startedAt}:${session.procStart ?? ''}`;
201
+ let same = checked.get(id);
202
+ if (same === undefined) {
203
+ const started = await processes.started(pid);
204
+ same = started !== null && isSameProcess(session, started);
205
+ checked.set(id, same);
206
+ }
207
+ if (same)
208
+ open.push(session);
209
+ }
210
+ return open;
211
+ };
212
+ }
213
+ // The running process of `sessionId` in the profile: the newest listed one, else the newest
214
+ // started to resume it among the processes the profile holds a key for.
215
+ export async function findClaudeProcess(configDir, sessionId, processes = systemProcesses) {
216
+ const { listed, keyed } = await profilePids(configDir);
217
+ const live = [];
218
+ for (const [pid, file] of listed) {
219
+ const entry = await readJson(file);
220
+ if (!isObject(entry) || entry.sessionId !== sessionId || !processes.isAlive(pid))
221
+ continue;
222
+ live.push(listedSession(pid, sessionId, entry));
223
+ }
224
+ const newest = live.sort((a, b) => b.startedAt - a.startedAt)[0];
225
+ if (newest)
226
+ return { kind: 'found', process: newest };
227
+ // A key without a list entry is a child session's: unlisted, unsaved, but this profile's.
228
+ const unlisted = [...keyed].filter(pid => !listed.has(pid) && processes.isAlive(pid));
229
+ if (!unlisted.length || !processes.list)
230
+ return { kind: 'none' };
231
+ let all;
232
+ try {
233
+ all = await processes.list();
234
+ }
235
+ catch (error) {
236
+ return { kind: 'unknown', reason: error instanceof Error ? error.message : String(error) };
237
+ }
238
+ const resuming = resumingProcess(all.filter(p => unlisted.includes(p.pid)), sessionId);
239
+ return resuming
240
+ ? {
241
+ kind: 'found',
242
+ process: {
243
+ pid: resuming.pid,
244
+ sessionId,
245
+ listed: false,
246
+ status: null,
247
+ statusSince: 0,
248
+ startedAt: resuming.at,
249
+ procStart: resuming.ticks
250
+ }
251
+ }
252
+ : { kind: 'none' };
253
+ }
254
+ // Whether the system's process is the one Claude Code recorded. Recorded ticks are compared
255
+ // exactly: a mismatch is another process, never a near miss.
256
+ export function isSameProcess(live, started) {
257
+ if (live.procStart !== null && started.ticks !== null)
258
+ return live.procStart === started.ticks;
259
+ return Math.abs(started.at - live.startedAt) <= SAME_START_MS;
260
+ }
261
+ // Ends the session's process: `gone` when no process has its pid any more, `other` when the pid
262
+ // now belongs to another process, `unknown` when the system could not say, or would not do it,
263
+ // or the process did not go. Only `ended` means the process is no more.
264
+ export async function endClaudeSession(live, processes = systemProcesses) {
265
+ let started;
266
+ try {
267
+ started = await processes.started(live.pid);
268
+ }
269
+ catch {
270
+ return 'unknown';
271
+ }
272
+ if (!started)
273
+ return 'gone';
274
+ if (!isSameProcess(live, started))
275
+ return 'other';
276
+ try {
277
+ processes.end(live.pid);
278
+ }
279
+ catch {
280
+ return 'unknown';
281
+ }
282
+ for (const deadline = Date.now() + END_WAIT_MS; processes.isAlive(live.pid);) {
283
+ if (Date.now() >= deadline)
284
+ return 'unknown';
285
+ await new Promise(resolve => setTimeout(resolve, END_POLL_MS));
286
+ }
287
+ return 'ended';
288
+ }
@@ -0,0 +1,2 @@
1
+ import type { Account } from '../shared/types.ts';
2
+ export declare function prepareClaudeProfile(account: Account, accounts: readonly Account[], dir: string, home?: string): Promise<void>;
@@ -0,0 +1,146 @@
1
+ // Getting a Claude Code profile ready for a session the app starts in it.
2
+ //
3
+ // Some answers Claude Code asks for belong to the user, not to the signed-in account: whether
4
+ // a folder is trusted, the first-run setup (theme, onboarding) and the notices already seen.
5
+ // Each profile keeps its own copy, so a profile used only to sign in would stop a moved
6
+ // session at the first-run setup. At launch, what another of the user's profiles already
7
+ // answered is carried into the profile being started, so the session goes on by itself.
8
+ //
9
+ // This happens at launch, for the one folder being opened, rather than by copying between
10
+ // profiles in the background: only a launch knows what the profile is about to need, and the
11
+ // state file is written under Claude Code's own lock, so a running session's changes are kept.
12
+ import fsp from 'node:fs/promises';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { CLAUDE_FILES, claudeStateFile } from "../providers/claude.js";
16
+ import { systemErrorCode } from "./errors.js";
17
+ import { isObject, readJson, writeFileAtomic } from "./files.js";
18
+ // What Claude Code writes for a folder it has not opened before.
19
+ const NEW_PROJECT = {
20
+ allowedTools: [],
21
+ mcpContextUris: [],
22
+ mcpServers: {},
23
+ enabledMcpjsonServers: [],
24
+ disabledMcpjsonServers: [],
25
+ hasClaudeMdExternalIncludesApproved: false,
26
+ hasClaudeMdExternalIncludesWarningShown: false
27
+ };
28
+ // The user's own answers in the state file: first-run setups done (Claude Code's own and those of
29
+ // later features, such as Claude in Chrome) and notices seen. Account facts (subscription,
30
+ // usage, caches) are not among them, nor is what a feature was set to: a choice that opens a
31
+ // browser or changes what Claude Code may do is made in each profile, not copied.
32
+ const CHOICES = new Set(['lastOnboardingVersion', 'lastReleaseNotesSeen']);
33
+ const isAcknowledgment = (key) => CHOICES.has(key) || /^hasCompleted([A-Z]\w*)?Onboarding$/.test(key) || /^hasSeen[A-Z]/.test(key);
34
+ // Questions Claude Code asks at a start unless a choice is recorded, answered the quiet way: a
35
+ // session the app starts may run unattended, and the user turns the feature on where they want it.
36
+ const QUIET_CHOICES = { claudeInChromeDefaultEnabled: false };
37
+ // What a project entry records about its CLAUDE.md includes; asked again where neither is set.
38
+ const INCLUDE_ANSWERS = ['hasClaudeMdExternalIncludesApproved', 'hasClaudeMdExternalIncludesWarningShown'];
39
+ // Claude Code's lock on its state file is a folder next to it, refreshed while held; one left
40
+ // behind longer than this is stale.
41
+ const LOCK_STALE_MS = 10_000;
42
+ const LOCK_WAIT_MS = 5_000;
43
+ const LOCK_RETRY_MS = 100;
44
+ // Claude Code keys projects by absolute path with forward slashes.
45
+ const projectKey = (dir) => path.resolve(dir).replaceAll('\\', '/');
46
+ const projectsOf = (state) => (isObject(state) && isObject(state.projects) ? state.projects : {});
47
+ // The entry that trusts `dir`: its own, or the nearest folder's above it, as Claude Code reads it.
48
+ function trustingEntry(state, dir) {
49
+ const projects = projectsOf(state);
50
+ for (let current = path.resolve(dir);; current = path.dirname(current)) {
51
+ const project = projects[projectKey(current)];
52
+ if (isObject(project) && project.hasTrustDialogAccepted === true)
53
+ return project;
54
+ if (path.dirname(current) === current)
55
+ return null;
56
+ }
57
+ }
58
+ const isTrusted = (state, dir) => trustingEntry(state, dir) !== null;
59
+ async function withStateLock(file, task) {
60
+ const lock = `${file}.lock`;
61
+ for (const deadline = Date.now() + LOCK_WAIT_MS;;) {
62
+ try {
63
+ await fsp.mkdir(lock);
64
+ break;
65
+ }
66
+ catch (error) {
67
+ if (systemErrorCode(error) !== 'EEXIST')
68
+ throw error;
69
+ const held = await fsp.stat(lock).catch(() => null);
70
+ if (held && Date.now() - held.mtimeMs > LOCK_STALE_MS)
71
+ await fsp.rm(lock, { recursive: true, force: true });
72
+ else if (Date.now() > deadline)
73
+ throw new Error('state file is locked', { cause: error });
74
+ else
75
+ await new Promise(resolve => setTimeout(resolve, LOCK_RETRY_MS));
76
+ }
77
+ }
78
+ try {
79
+ await task();
80
+ }
81
+ finally {
82
+ await fsp.rm(lock, { recursive: true, force: true });
83
+ }
84
+ }
85
+ // What `state` lacks that the other profiles' states answer for `dir`.
86
+ function missingAnswers(state, others, dir) {
87
+ const answers = {};
88
+ // A question asked again only where it was not answered yet, also in a profile set up before
89
+ // the question existed; what a profile answered itself stays.
90
+ for (const other of others)
91
+ if (isObject(other))
92
+ for (const [key, value] of Object.entries(other))
93
+ if (isAcknowledgment(key) && state[key] === undefined && answers[key] === undefined)
94
+ answers[key] = value;
95
+ for (const [key, value] of Object.entries(QUIET_CHOICES))
96
+ if (state[key] === undefined)
97
+ answers[key] = value;
98
+ if (!isTrusted(state, dir) && others.some(other => isTrusted(other, dir))) {
99
+ const projects = projectsOf(state);
100
+ const key = projectKey(dir);
101
+ const entry = isObject(projects[key]) ? projects[key] : NEW_PROJECT;
102
+ // The includes question is about this folder's own CLAUDE.md: only an answer given for this
103
+ // very folder comes along, never a parent's (trust is inherited from above; consent is not).
104
+ const own = others.map(other => projectsOf(other)[key]).find(isObject);
105
+ const includes = own
106
+ ? Object.fromEntries(INCLUDE_ANSWERS.filter(name => typeof own[name] === 'boolean').map(name => [name, own[name]]))
107
+ : {};
108
+ answers.projects = { ...projects, [key]: { ...entry, ...includes, hasTrustDialogAccepted: true } };
109
+ }
110
+ return answers;
111
+ }
112
+ // The theme lives in the profile's settings; it is set only where none was chosen.
113
+ async function carryTheme(account, others) {
114
+ if (!account.configDir)
115
+ return;
116
+ const file = path.join(account.configDir, CLAUDE_FILES.settings);
117
+ const settings = await readJson(file);
118
+ if (isObject(settings) && settings.theme !== undefined)
119
+ return;
120
+ for (const other of others) {
121
+ const theirs = other.configDir ? await readJson(path.join(other.configDir, CLAUDE_FILES.settings)) : null;
122
+ if (!isObject(theirs) || typeof theirs.theme !== 'string')
123
+ continue;
124
+ await writeFileAtomic(file, `${JSON.stringify({ ...(isObject(settings) ? settings : {}), theme: theirs.theme }, null, 2)}\n`);
125
+ return;
126
+ }
127
+ }
128
+ // Carries the user's answers from their other Claude Code profiles into `account`'s, for a
129
+ // session about to start in `dir`. A profile that was never signed in is left alone.
130
+ export async function prepareClaudeProfile(account, accounts, dir, home = os.homedir()) {
131
+ const file = claudeStateFile(account, home);
132
+ if (!isObject(await readJson(file)))
133
+ return;
134
+ const others = accounts.filter(a => a.providerId === 'claude' && a.enabled && a.id !== account.id);
135
+ const states = await Promise.all(others.map(other => readJson(claudeStateFile(other, home))));
136
+ await carryTheme(account, others);
137
+ await withStateLock(file, async () => {
138
+ // Read under the lock, so nothing Claude Code wrote meanwhile is lost.
139
+ const state = await readJson(file);
140
+ if (!isObject(state))
141
+ return;
142
+ const answers = missingAnswers(state, states, dir);
143
+ if (Object.keys(answers).length)
144
+ await writeFileAtomic(file, JSON.stringify({ ...state, ...answers }, null, 2));
145
+ });
146
+ }
@@ -0,0 +1,12 @@
1
+ import { type ExecFileOptions } from 'node:child_process';
2
+ import type { Account } from '../shared/types.ts';
3
+ export declare const RENEW_ARGS: readonly ["-p", "Reply with the single word ok.", "--model", string, "--tools", "", "--strict-mcp-config", "--no-session-persistence", "--max-turns", "1"];
4
+ export type RunProcess = (binary: string, args: readonly string[], options: ExecFileOptions) => Promise<void>;
5
+ export interface RenewOptions {
6
+ dataDir: string;
7
+ home?: string;
8
+ clock?: () => number;
9
+ runProcess?: RunProcess;
10
+ readCredentials?: (account: Account) => Promise<unknown>;
11
+ }
12
+ export declare function renewClaudeSignIn(account: Account, { dataDir, home, clock, runProcess, readCredentials }: RenewOptions): Promise<boolean>;
@@ -0,0 +1,79 @@
1
+ // Renewing a Claude Code profile's sign-in once its access token has expired.
2
+ //
3
+ // Claude Code renews its token only while it runs, so a profile left idle for some hours holds an
4
+ // expired one: its quota cannot be read, and it cannot take over work when another account runs
5
+ // out. The app never uses the refresh token itself: it is Claude Code's own sign-in, and a second
6
+ // refresher could invalidate the token a running Claude Code holds. Claude Code renews it, with
7
+ // the smallest request it makes: one word from the smallest model, no tools, no MCP servers,
8
+ // nothing saved as a session.
9
+ import { execFile } from 'node:child_process';
10
+ import fsp from 'node:fs/promises';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { claudeOAuthSection, readClaudeCredentials } from "../providers/claude.js";
14
+ import { findBinary } from "../providers/common.js";
15
+ import { applyEnvironment, environmentChanges } from "./environment.js";
16
+ import { profileEnvironment } from "./launch.js";
17
+ import { claudeAlias } from "./model-families.js";
18
+ const RENEW_DIR = 'renew';
19
+ const RENEW_TIMEOUT_MS = 90_000;
20
+ // Written for Claude Code, so it is deliberately not localized.
21
+ const RENEW_PROMPT = 'Reply with the single word ok.';
22
+ export const RENEW_ARGS = [
23
+ '-p',
24
+ RENEW_PROMPT,
25
+ '--model',
26
+ claudeAlias('fast'),
27
+ '--tools',
28
+ '',
29
+ '--strict-mcp-config',
30
+ '--no-session-persistence',
31
+ '--max-turns',
32
+ '1'
33
+ ];
34
+ const run = (binary, args, options) => new Promise(resolve => {
35
+ execFile(binary, [...args], options, () => {
36
+ resolve();
37
+ });
38
+ });
39
+ // Whether Claude Code can renew the sign-in: it holds a refresh token, and that token has not
40
+ // itself expired. Claude Code writes the refresh token's own expiry (refreshTokenExpiresAt, some
41
+ // weeks out) on every refresh; once it has passed only signing in again helps, so starting Claude
42
+ // Code would cost a launch for nothing. Credentials without it (older files) are taken as renewable.
43
+ function canRenew(oauth, now) {
44
+ if (typeof oauth.refreshToken !== 'string' || oauth.refreshToken.length === 0)
45
+ return false;
46
+ if (oauth.refreshTokenExpiresAt === undefined || oauth.refreshTokenExpiresAt === null)
47
+ return true;
48
+ const refreshExpiresAt = Number(oauth.refreshTokenExpiresAt);
49
+ return !Number.isFinite(refreshExpiresAt) || refreshExpiresAt > now;
50
+ }
51
+ // The saved sign-in's expiry, and whether Claude Code can renew it.
52
+ async function signIn(account, read, now) {
53
+ const credentials = await read(account).catch(() => null);
54
+ const oauth = claudeOAuthSection(credentials);
55
+ if (!oauth)
56
+ return null;
57
+ const expiresAt = Number(oauth.expiresAt);
58
+ return {
59
+ expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0,
60
+ renewable: canRenew(oauth, now)
61
+ };
62
+ }
63
+ // True when the profile's sign-in was renewed (its expiry moved on, into the future).
64
+ export async function renewClaudeSignIn(account, { dataDir, home = os.homedir(), clock = Date.now, runProcess = run, readCredentials }) {
65
+ const read = readCredentials ?? ((target) => readClaudeCredentials(target, { home }));
66
+ const before = await signIn(account, read, clock());
67
+ if (!before?.renewable)
68
+ return false;
69
+ const cwd = path.join(dataDir, RENEW_DIR);
70
+ await fsp.mkdir(cwd, { recursive: true });
71
+ await runProcess(await findBinary('claude'), RENEW_ARGS, {
72
+ cwd,
73
+ env: applyEnvironment(environmentChanges(profileEnvironment(account))),
74
+ timeout: RENEW_TIMEOUT_MS,
75
+ windowsHide: true
76
+ });
77
+ const after = await signIn(account, read, clock());
78
+ return Boolean(after && after.expiresAt > before.expiresAt && after.expiresAt > clock());
79
+ }
@@ -0,0 +1,90 @@
1
+ import { type ProviderId } from '../shared/catalog.ts';
2
+ import type { LaunchMode } from '../shared/ipc.ts';
3
+ import type { Account, ErrorCode, MeasuredAccount, Snapshot, Suggestion, TransitionRecord } from '../shared/types.ts';
4
+ import { type LiveClaudeSession, type OpenClaudeSession, type ProcessControl, type ProcessLookup } from './claude-processes.ts';
5
+ import type { QuotaAlert } from './glance.ts';
6
+ import { type LaunchContext } from './handover.ts';
7
+ import { type Successor } from './selection.ts';
8
+ import { type BackgroundTask, type RecentSession, type SessionIdentity, type StoppedSession } from './sessions.ts';
9
+ export declare const RETURN_TASK_WAIT_MS: number;
10
+ export declare const RESUME_PROMPT = "Your previous turn was cut off by a usage limit on another account. Continue the task exactly where it stopped; run `git status` first to see what is already done.";
11
+ export declare const RETURN_PROMPT = "This session moved back to its main account.";
12
+ export declare const RESTART_PROMPT = "The computer went down while this session was open, and your last turn did not finish. Background commands and subagents started before did not survive it. Continue the task exactly where it stopped; run `git status` first to see what is already done.";
13
+ export declare function findRecentSession(account: Account, homeDir: string, identity?: SessionIdentity): Promise<RecentSession | null>;
14
+ export declare function findStoppedSessions(account: Account, homeDir: string, now?: number): Promise<StoppedSession[]>;
15
+ export declare function findStoppedSession(account: Account, homeDir: string, now?: number): Promise<StoppedSession | null>;
16
+ export declare function carryClaudeSession(session: RecentSession, configDir: string): Promise<void>;
17
+ export interface ContinueResult {
18
+ to: ProviderId;
19
+ accountId: string | null;
20
+ resumed: boolean;
21
+ mode: LaunchMode;
22
+ tab?: string | null;
23
+ }
24
+ export interface MoveOptions {
25
+ prompt?: string | undefined;
26
+ running?: LiveClaudeSession | null | undefined;
27
+ tab?: string | null | undefined;
28
+ processes?: ProcessControl | undefined;
29
+ }
30
+ export declare function holdsUnsavedConversation(running: LiveClaudeSession, file: string): boolean;
31
+ export declare function continueSession(session: RecentSession, to: Successor, state: Snapshot, context: LaunchContext, options?: MoveOptions): Promise<ContinueResult>;
32
+ declare const ATTEMPTS: readonly ["return", "reopen"];
33
+ export type Attempt = (typeof ATTEMPTS)[number];
34
+ export type Notice = {
35
+ kind: 'suggested';
36
+ suggestion: Suggestion;
37
+ } | {
38
+ kind: 'continued';
39
+ from: MeasuredAccount;
40
+ to: Successor;
41
+ result: ContinueResult;
42
+ projectDir: string;
43
+ } | {
44
+ kind: 'returned';
45
+ from: MeasuredAccount;
46
+ to: Successor;
47
+ result: ContinueResult;
48
+ projectDir: string;
49
+ } | {
50
+ kind: 'reopened';
51
+ from: MeasuredAccount;
52
+ to: Successor;
53
+ result: ContinueResult;
54
+ projectDir: string;
55
+ } | {
56
+ kind: 'stranded';
57
+ from: MeasuredAccount;
58
+ } | {
59
+ kind: 'failed';
60
+ from: MeasuredAccount;
61
+ code: ErrorCode;
62
+ attempt?: Attempt;
63
+ };
64
+ export interface Continuity {
65
+ observe(snapshot: Snapshot, alerts: {
66
+ low: readonly QuotaAlert[];
67
+ }): Promise<Notice[]>;
68
+ suggestions(): Suggestion[];
69
+ isRunning(id: string, snapshot?: Snapshot): Promise<boolean>;
70
+ accept(id: string, snapshot: Snapshot): Promise<ContinueResult>;
71
+ dismiss(id: string): void;
72
+ transitions(): TransitionRecord[];
73
+ retry(id: string, snapshot: Snapshot): Promise<ContinueResult>;
74
+ quit(): void;
75
+ }
76
+ export interface ContinuityOptions {
77
+ homeDir: string;
78
+ dataDir: string;
79
+ launch: LaunchContext;
80
+ findSession?: (account: Account, identity?: SessionIdentity) => Promise<RecentSession | null>;
81
+ findStopped?: (account: Account) => Promise<StoppedSession[]>;
82
+ findRunning?: (account: Account, sessionId: string) => Promise<ProcessLookup>;
83
+ findTasks?: (file: string) => Promise<BackgroundTask[]>;
84
+ findOpen?: (account: Account) => Promise<OpenClaudeSession[]>;
85
+ start?: typeof continueSession;
86
+ clock?: () => number;
87
+ historyCap?: number;
88
+ }
89
+ export declare function createContinuity({ homeDir, dataDir, launch, findSession, findStopped, findRunning, findTasks, findOpen, start, clock, historyCap }: ContinuityOptions): Promise<Continuity>;
90
+ export {};