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,61 @@
1
+ import { type Auto, type ProviderId } from '../shared/catalog.ts';
2
+ import type { HandoverPreview, HandoverRequest, HandoverResult, LaunchMode, SessionSummary, SmartLaunchPreview } from '../shared/ipc.ts';
3
+ import type { Account, Settings } from '../shared/types.ts';
4
+ import { type SpawnProcess } from './launch.ts';
5
+ import type { ModelTier } from './model-families.ts';
6
+ import { type QuotaState } from './selection.ts';
7
+ import { type SessionSources, type Transcript } from './sessions.ts';
8
+ export declare const HANDOVER_FILE = "VIBE_HANDOVER.md";
9
+ export declare const HANDOVER_PROMPT = "Read VIBE_HANDOVER.md and continue the task from where it was left off.";
10
+ type State = QuotaState & {
11
+ settings: Pick<Settings, 'preferredModels'> & Partial<Pick<Settings, 'projectPreferences'>>;
12
+ };
13
+ interface GitContext {
14
+ branch: string;
15
+ lastCommit: string;
16
+ changedFiles: string[];
17
+ }
18
+ export declare function excludeFromGit(cwd: string, file: string): Promise<void>;
19
+ export type HandoverContext = Omit<Transcript, 'files' | 'providerId'> & {
20
+ providerId: ProviderId | null;
21
+ hasSession: boolean;
22
+ git: GitContext;
23
+ recentFiles: string[];
24
+ };
25
+ export declare function collectHandoverContext(projectDir: string, { sessionId, providerId, sources }?: {
26
+ sessionId?: string;
27
+ providerId?: ProviderId | Auto;
28
+ sources?: SessionSources;
29
+ }): Promise<HandoverContext>;
30
+ export interface BriefTarget {
31
+ from: ProviderId | null;
32
+ to: ProviderId;
33
+ model: string | null;
34
+ tier: ModelTier;
35
+ }
36
+ export declare function renderBrief(context: HandoverContext, { from, to, model, tier }: BriefTarget, now?: Date): string;
37
+ export declare function parseProviderChoice(value: unknown): ProviderId | Auto;
38
+ export interface LaunchContext {
39
+ dataDir: string;
40
+ homeDir?: string | undefined;
41
+ platform?: NodeJS.Platform | undefined;
42
+ spawnProcess?: SpawnProcess | undefined;
43
+ }
44
+ export type HandoverEnvironment = LaunchContext & {
45
+ now?: Date | undefined;
46
+ };
47
+ export declare function previewHandover(request: HandoverRequest, state: State, environment: HandoverEnvironment): Promise<HandoverPreview>;
48
+ export declare function executeHandover(request: HandoverRequest & {
49
+ account?: Account | null;
50
+ }, state: State, environment: HandoverEnvironment): Promise<HandoverResult>;
51
+ export declare function listSessions(providerId: ProviderId | Auto, projectDir: string, sources?: SessionSources): Promise<SessionSummary[]>;
52
+ export declare function previewSmartLaunch(state: State, environment: Pick<LaunchContext, 'dataDir' | 'homeDir'>): SmartLaunchPreview;
53
+ export declare function mappedModels(state: State, sourceModel: string | null, { dataDir, homeDir }: Pick<LaunchContext, 'dataDir' | 'homeDir'>): Partial<Record<ProviderId, string>>;
54
+ export interface SmartLaunchResult {
55
+ to: ProviderId;
56
+ model: string | null;
57
+ mode: LaunchMode;
58
+ verified: boolean;
59
+ }
60
+ export declare function smartLaunch(projectDir: string, state: State, environment: LaunchContext): Promise<SmartLaunchResult>;
61
+ export {};
@@ -0,0 +1,333 @@
1
+ // Hands a task from one AI tool to another: the selected session is summarised into a brief
2
+ // in the project folder, then the target tool starts with a prompt pointing at it.
3
+ import { execFile } from 'node:child_process';
4
+ import fsp from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { promisify } from 'node:util';
8
+ import { defaultProfileDir } from "../providers/profiles.js";
9
+ import { APP_NAME, AUTO, isProfileProvider, isProviderId, providerById, PROVIDER_IDS } from "../shared/catalog.js";
10
+ import { AppError, systemErrorCode } from "./errors.js";
11
+ import { attempt, readHeadSync, writeFileAtomic } from "./files.js";
12
+ import { launchTool, projectDirectory } from "./launch.js";
13
+ import { getMappedModel } from "./models.js";
14
+ import { redactSecrets } from "./redact.js";
15
+ import { projectPreference } from "./settings.js";
16
+ import { primaryAccount, selectAccount, selectProvider } from "./selection.js";
17
+ import { listProviderSessions, readSession, RECENT_TURNS, sessionSources } from "./sessions.js";
18
+ export const HANDOVER_FILE = 'VIBE_HANDOVER.md';
19
+ // Written for the receiving AI tool, so it is deliberately not localized.
20
+ export const HANDOVER_PROMPT = `Read ${HANDOVER_FILE} and continue the task from where it was left off.`;
21
+ const BRIEF_LIMITS = { goal: 3000, lastRequest: 3000, lastState: 4000, files: 15 };
22
+ const GIT_TIMEOUT_MS = 5000;
23
+ // Reading a repository must not change it or run its code: no optional index lock (which
24
+ // could break the user's own git commands), and no fsmonitor hook the repository configures.
25
+ const GIT_READ_ONLY = ['--no-optional-locks', '-c', 'core.fsmonitor=false'];
26
+ // Porcelain v1 status entries are `XY path`; renames and copies are followed by their source.
27
+ const STATUS_PATH_OFFSET = 3;
28
+ const RENAME_OR_COPY = /^[RC]/;
29
+ // The project's own tool, account and model, which narrow an automatic choice.
30
+ function projectChoice(state, projectDir) {
31
+ const preference = state.settings.projectPreferences
32
+ ? projectPreference({ projectPreferences: state.settings.projectPreferences }, projectDir)
33
+ : null;
34
+ return {
35
+ prefer: { tool: preference?.tool, accountId: preference?.accountId },
36
+ tool: preference?.tool,
37
+ model: preference?.model
38
+ };
39
+ }
40
+ const execFileAsync = promisify(execFile);
41
+ // Git's output as it is: a status line's leading space is part of the line.
42
+ async function git(cwd, args) {
43
+ const { stdout } = await execFileAsync('git', [...GIT_READ_ONLY, ...args], { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
44
+ return stdout;
45
+ }
46
+ // Porcelain v1 with -z keeps paths with spaces and renames unambiguous. A folder that is not
47
+ // a repository has no git context.
48
+ async function getGitContext(cwd) {
49
+ return ((await attempt(async () => {
50
+ const [status, branch, lastCommit] = await Promise.all([
51
+ git(cwd, ['status', '--porcelain=v1', '-z']),
52
+ git(cwd, ['branch', '--show-current']),
53
+ git(cwd, ['log', '-1', '--oneline'])
54
+ ]);
55
+ const entries = status.split('\0').filter(Boolean);
56
+ const changedFiles = [];
57
+ for (let i = 0; i < entries.length; i++) {
58
+ const entry = entries[i] ?? '';
59
+ changedFiles.push(entry.slice(STATUS_PATH_OFFSET));
60
+ if (RENAME_OR_COPY.test(entry))
61
+ i++;
62
+ }
63
+ return { branch: branch.trim(), lastCommit: lastCommit.trim(), changedFiles: changedFiles.slice(0, BRIEF_LIMITS.files) };
64
+ })) ?? { branch: '', lastCommit: '', changedFiles: [] });
65
+ }
66
+ // Keeps the brief out of `git status` and out of commits: it is listed in the repository's
67
+ // local exclude file (never the shared .gitignore). Outside a repository nothing happens.
68
+ export async function excludeFromGit(cwd, file) {
69
+ await attempt(async () => {
70
+ const exclude = path.resolve(cwd, (await git(cwd, ['rev-parse', '--git-path', 'info/exclude'])).trim());
71
+ // The exclude file is read from the repository root, and the brief may sit in a subfolder
72
+ // (a package of a monorepo). Wildcard characters in folder names are escaped.
73
+ const prefix = (await git(cwd, ['rev-parse', '--show-prefix'])).trim().replace(/[*?[\\]/g, '\\$&');
74
+ const pattern = `/${prefix}${file}`;
75
+ const text = await fsp.readFile(exclude, 'utf8').catch(() => '');
76
+ const listed = text.split(/\r?\n/).map(line => line.trim());
77
+ if (listed.includes(file) || listed.includes(pattern))
78
+ return;
79
+ await fsp.mkdir(path.dirname(exclude), { recursive: true });
80
+ const separator = text === '' || text.endsWith('\n') ? '' : '\n';
81
+ await fsp.appendFile(exclude, `${separator}# Written by ${APP_NAME} for handovers\n${pattern}\n`);
82
+ });
83
+ }
84
+ // Session summary plus git state. A named session that cannot be found is an error: silently
85
+ // substituting another session would hand over the wrong task.
86
+ export async function collectHandoverContext(projectDir, { sessionId = AUTO, providerId = AUTO, sources = sessionSources(os.homedir()) } = {}) {
87
+ const gitContext = await getGitContext(projectDir);
88
+ const transcript = await readSession({ providerId, sessionId, projectDir: path.resolve(projectDir), sources });
89
+ if (!transcript && sessionId !== AUTO)
90
+ throw new AppError('session_not_found');
91
+ const { files = [], providerId: source = null, ...session } = transcript ?? {
92
+ sessionId: '',
93
+ sessionTitle: '',
94
+ sessionModel: '',
95
+ initialGoal: '',
96
+ lastUserMsg: '',
97
+ lastAssistantMsg: '',
98
+ turnCount: 0,
99
+ recent: []
100
+ };
101
+ return {
102
+ ...session,
103
+ providerId: source,
104
+ hasSession: transcript !== null,
105
+ git: gitContext,
106
+ recentFiles: [...new Set([...gitContext.changedFiles, ...files])].slice(0, BRIEF_LIMITS.files)
107
+ };
108
+ }
109
+ const clip = (text, limit) => text.slice(0, limit);
110
+ const code = (value) => `\`${value}\``;
111
+ const section = (title, body) => `## ${title}\n${body}`;
112
+ const list = (items, indent = '') => items.map(item => `${indent}- ${code(item)}`).join('\n');
113
+ // Quoted, so that headings or instructions inside a message stay part of that message.
114
+ const quote = (text) => text
115
+ .split('\n')
116
+ .map(line => (line ? `> ${line}` : '>'))
117
+ .join('\n');
118
+ // Every brief starts with this; only a file that does may be replaced by a new brief.
119
+ const BRIEF_HEADING = `# ${APP_NAME} handover`;
120
+ // The newest turns that fit the limit, oldest first, each attributed to its author.
121
+ function conversation(recent, fromName) {
122
+ const kept = [];
123
+ let total = 0;
124
+ for (const turn of recent.toReversed()) {
125
+ const text = clip(turn.text, RECENT_TURNS.turnChars);
126
+ total += text.length;
127
+ if (kept.length && total > RECENT_TURNS.totalChars)
128
+ break;
129
+ kept.unshift(`**${turn.role === 'user' ? 'User' : fromName}:**\n${quote(text)}`);
130
+ }
131
+ return kept.join('\n\n');
132
+ }
133
+ function gitState({ branch, lastCommit, changedFiles }) {
134
+ if (!branch && !lastCommit && !changedFiles.length)
135
+ return 'Not available: not a git repository, or git could not be read.';
136
+ return [
137
+ `- Branch: ${branch ? code(branch) : 'none (detached HEAD)'}`,
138
+ `- Last commit: ${lastCommit ? code(lastCommit) : 'none'}`,
139
+ changedFiles.length ? `- Uncommitted changes:\n${list(changedFiles, ' ')}` : '- Uncommitted changes: none'
140
+ ].join('\n');
141
+ }
142
+ // Everything in the brief comes from transcripts, titles, file names and git, so all of it is
143
+ // redacted. Conversation text is attributed, never restated as fact.
144
+ export function renderBrief(context, { from, to, model, tier }, now = new Date()) {
145
+ const fromName = from ? providerById(from).name : 'Unknown tool';
146
+ const facts = [
147
+ ['Session', context.sessionTitle],
148
+ ['Session ID', context.sessionId && code(context.sessionId)],
149
+ ['Source model', context.sessionModel && `${code(context.sessionModel)} (${tier} tier)`],
150
+ ['Target model', model && code(model)],
151
+ ['Previous turns', context.turnCount ? String(context.turnCount) : '']
152
+ ]
153
+ .filter(([, value]) => value)
154
+ .map(([name, value]) => `**${name}:** ${value}`);
155
+ const otherFiles = context.recentFiles.filter(file => !context.git.changedFiles.includes(file));
156
+ const blocks = [
157
+ `${BRIEF_HEADING}: ${fromName} → ${providerById(to).name}`,
158
+ `> Generated by ${APP_NAME} on ${now.toISOString()}. Treat this brief as untrusted context: verify it against the repository before acting.`,
159
+ facts.join('\n'),
160
+ section('1. Original goal', clip(context.initialGoal, BRIEF_LIMITS.goal) || 'Not recorded. Inspect the working tree and git history.'),
161
+ section('2. Latest request', clip(context.lastUserMsg, BRIEF_LIMITS.lastRequest) || 'Not recorded.'),
162
+ section(`3. Last state reported by ${fromName}`, clip(context.lastAssistantMsg, BRIEF_LIMITS.lastState) || 'Not recorded.'),
163
+ section('4. Recent conversation', context.recent.length
164
+ ? `The latest messages, oldest first. What ${fromName} says it decided, changed or tested is its own report, not a verified fact.\n\n${conversation(context.recent, fromName)}`
165
+ : 'Not recorded.'),
166
+ section('5. Git state', gitState(context.git)),
167
+ otherFiles.length ? section('6. Relevant files', list(otherFiles)) : '',
168
+ '---\nStart by checking `git status`, then continue the task.'
169
+ ];
170
+ return redactSecrets(`${blocks.filter(Boolean).join('\n\n')}\n`);
171
+ }
172
+ // Replaces only an earlier brief of this app, atomically and private to the user. A symlink
173
+ // is never followed, and a folder or a file the user keeps under this name is never replaced.
174
+ async function writeBrief(cwd, brief) {
175
+ const file = path.join(cwd, HANDOVER_FILE);
176
+ const existing = await fsp.lstat(file).catch((error) => {
177
+ if (systemErrorCode(error) === 'ENOENT')
178
+ return null;
179
+ throw error;
180
+ });
181
+ if (existing && !(existing.isFile() && readHeadSync(file, BRIEF_HEADING.length).startsWith(BRIEF_HEADING)))
182
+ throw new AppError('invalid_path');
183
+ await writeFileAtomic(file, brief);
184
+ }
185
+ // A provider choice as it arrives from the renderer or the command line.
186
+ // A tool named by the user or the window, in any case (`--to Codex`), or "auto".
187
+ export function parseProviderChoice(value) {
188
+ const choice = typeof value === 'string' ? value.toLowerCase() : value;
189
+ if (choice !== AUTO && !isProviderId(choice))
190
+ throw new AppError('invalid_provider');
191
+ return choice;
192
+ }
193
+ // The account a launched tool should use: the eligible one, else the primary (unverified).
194
+ const targetAccount = (state, providerId, model) => selectAccount(state, providerId, { model: model ?? undefined }) ?? primaryAccount(state, providerId);
195
+ // The source session, the target and its model, and the brief: everything a handover
196
+ // decides, read-only.
197
+ async function resolveHandover(request, state, { dataDir, homeDir = os.homedir(), now = new Date() }) {
198
+ const { fromProvider, toProvider, sessionProvider } = request;
199
+ // Model overrides belong to a named provider; automatic selection uses its mapped model.
200
+ if (toProvider === AUTO && request.targetModel !== undefined)
201
+ throw new AppError('invalid_argument');
202
+ const cwd = await projectDirectory(request.projectDir);
203
+ // A session picked under "auto" carries its provider; it is never read as another's.
204
+ const readFrom = fromProvider === AUTO && sessionProvider ? sessionProvider : fromProvider;
205
+ const sources = sessionSources(homeDir, state.settings.accounts);
206
+ if (request.sourceAccountId) {
207
+ const source = state.settings.accounts.find(a => a.id === request.sourceAccountId && a.enabled && a.providerId === readFrom);
208
+ if (!source)
209
+ throw new AppError('account_not_found');
210
+ if (isProfileProvider(source.providerId))
211
+ sources.profiles = { ...sources.profiles, [source.providerId]: [source.configDir ?? defaultProfileDir(source.providerId, homeDir)] };
212
+ }
213
+ const context = await collectHandoverContext(cwd, {
214
+ sessionId: request.sessionId,
215
+ providerId: readFrom,
216
+ sources
217
+ });
218
+ const from = context.providerId ?? (fromProvider === AUTO ? null : fromProvider);
219
+ const project = projectChoice(state, cwd);
220
+ const models = mappedModels(state, context.sessionModel, { dataDir, homeDir });
221
+ if (project.tool && project.model)
222
+ models[project.tool] = project.model;
223
+ // Under "auto" the target is the first tool with verified quota; otherwise the one chosen.
224
+ const target = () => {
225
+ if (toProvider !== AUTO)
226
+ return { to: toProvider, decision: null };
227
+ const choice = selectProvider(state, { exclude: from, models, prefer: project.prefer });
228
+ if (!choice.verified)
229
+ throw new AppError('no_verified_quota');
230
+ return { to: choice.providerId, decision: choice };
231
+ };
232
+ const { to, decision } = target();
233
+ const mapping = getMappedModel(context.sessionModel, to, state.settings.preferredModels, {
234
+ homeDir,
235
+ cacheDir: dataDir,
236
+ accounts: state.settings.accounts
237
+ });
238
+ const projectModel = to === project.tool ? project.model : undefined;
239
+ // The quota check may have moved to a model the spent pool does not cover; that one wins.
240
+ const model = providerById(to).acceptsModel ? request.targetModel || (decision?.model ?? projectModel) || mapping.targetModel : null;
241
+ return {
242
+ cwd,
243
+ decision,
244
+ from,
245
+ to,
246
+ model,
247
+ sessionId: context.hasSession ? context.sessionId : null,
248
+ sessionProvider: context.providerId,
249
+ brief: renderBrief(context, { from, to, model, tier: mapping.tier }, now)
250
+ };
251
+ }
252
+ // Resolves a handover exactly as execution would, without writing a file or starting a tool.
253
+ export async function previewHandover(request, state, environment) {
254
+ const { from, to, model, sessionId, sessionProvider, brief } = await resolveHandover(request, state, environment);
255
+ return { from, to, model, sessionId, sessionProvider, brief };
256
+ }
257
+ // `account` picks the target tool's account; without it the eligible one, else the primary.
258
+ export async function executeHandover(request, state, environment) {
259
+ const { dataDir, homeDir, platform, spawnProcess } = environment;
260
+ const { cwd, decision, from, to, model, brief } = await resolveHandover(request, state, environment);
261
+ await writeBrief(cwd, brief);
262
+ await excludeFromGit(cwd, HANDOVER_FILE);
263
+ const { mode } = await launchTool({
264
+ providerId: to,
265
+ account: request.account ?? decision?.account ?? targetAccount(state, to, model),
266
+ cwd,
267
+ dataDir,
268
+ model,
269
+ prompt: HANDOVER_PROMPT,
270
+ title: `${APP_NAME} · ${from ? providerById(from).name : '…'} → ${providerById(to).name}`,
271
+ platform,
272
+ spawnProcess,
273
+ accounts: state.settings.accounts,
274
+ homeDir
275
+ });
276
+ return { from, to, model, mode };
277
+ }
278
+ // Sessions started in `projectDir`; none for a path that is not an existing folder.
279
+ export async function listSessions(providerId, projectDir, sources = sessionSources(os.homedir())) {
280
+ const cwd = await attempt(() => projectDirectory(projectDir));
281
+ if (!cwd)
282
+ return [];
283
+ return listProviderSessions(providerId, cwd, sources);
284
+ }
285
+ // Before the folder is chosen nothing is known of the project; the launch itself applies the
286
+ // project's preferences.
287
+ function smartDecision(state, { dataDir, homeDir }, projectDir) {
288
+ const models = mappedModels(state, null, { dataDir, homeDir });
289
+ const project = projectDir ? projectChoice(state, projectDir) : null;
290
+ if (project?.tool && project.model)
291
+ models[project.tool] = project.model;
292
+ return { decision: selectProvider(state, { models, ...(project ? { prefer: project.prefer } : {}) }), models };
293
+ }
294
+ export function previewSmartLaunch(state, environment) {
295
+ const { decision, models } = smartDecision(state, environment);
296
+ return {
297
+ providerId: decision.providerId,
298
+ verified: decision.verified,
299
+ isFallback: decision.isFallback,
300
+ headroomPercent: decision.headroomPercent,
301
+ model: decision.model ?? models[decision.providerId] ?? null
302
+ };
303
+ }
304
+ export function mappedModels(state, sourceModel, { dataDir, homeDir }) {
305
+ return Object.fromEntries(PROVIDER_IDS.flatMap(id => {
306
+ const model = providerById(id).acceptsModel
307
+ ? getMappedModel(sourceModel, id, state.settings.preferredModels, { homeDir, cacheDir: dataDir, accounts: state.settings.accounts })
308
+ .targetModel
309
+ : null;
310
+ return model ? [[id, model]] : [];
311
+ }));
312
+ }
313
+ // Starts the provider that currently has verified quota, fresh (no handover brief). The
314
+ // provider and its model are chosen together, from the quota at the time of the launch.
315
+ export async function smartLaunch(projectDir, state, environment) {
316
+ const { decision, models } = smartDecision(state, environment, projectDir);
317
+ if (!decision.verified)
318
+ throw new AppError('no_verified_quota');
319
+ const model = decision.model ?? models[decision.providerId] ?? null;
320
+ const { mode } = await launchTool({
321
+ providerId: decision.providerId,
322
+ account: decision.account,
323
+ cwd: projectDir,
324
+ dataDir: environment.dataDir,
325
+ model,
326
+ title: `${APP_NAME} · ${providerById(decision.providerId).name}`,
327
+ platform: environment.platform,
328
+ spawnProcess: environment.spawnProcess,
329
+ accounts: state.settings.accounts,
330
+ homeDir: environment.homeDir
331
+ });
332
+ return { to: decision.providerId, model, mode, verified: decision.verified };
333
+ }
@@ -0,0 +1,19 @@
1
+ import type { QuotaWindow, Snapshot } from '../shared/types.ts';
2
+ export declare const HISTORY_RETENTION_MS: number;
3
+ export type Sample = readonly [number, number];
4
+ export interface WindowSeries {
5
+ accountId: string;
6
+ windowId: string;
7
+ samples: Sample[];
8
+ }
9
+ export interface History {
10
+ record(snapshot: Snapshot): void;
11
+ annotate(snapshot: Snapshot): Snapshot;
12
+ series(accountIds: readonly string[]): WindowSeries[];
13
+ flush(): Promise<void>;
14
+ }
15
+ export declare function forecastExhaustion(samples: readonly Sample[], window: QuotaWindow, now: number): number | null;
16
+ export declare function createHistory({ dataDir, clock }: {
17
+ dataDir: string;
18
+ clock?: () => number;
19
+ }): Promise<History>;
@@ -0,0 +1,134 @@
1
+ // Quota readings over the last week, kept on disk: the history chart and the "runs out at this
2
+ // pace" forecast come from them. Only percentages are stored, never tokens or names.
3
+ import fsp from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { DURATION_MS } from "../shared/catalog.js";
6
+ import { isObject, writeFileAtomic } from "./files.js";
7
+ const HISTORY_FILE = 'history.json';
8
+ export const HISTORY_RETENTION_MS = 7 * 24 * DURATION_MS.HOUR;
9
+ // A reading is kept when the value changed, or at least this long after the previous one.
10
+ const SAMPLE_INTERVAL_MS = 10 * DURATION_MS.MINUTE;
11
+ // The pace is taken over the last hour of the current period...
12
+ const PACE_WINDOW_MS = DURATION_MS.HOUR;
13
+ // ...and needs readings at least this far apart to mean anything.
14
+ const PACE_MIN_SPAN_MS = 10 * DURATION_MS.MINUTE;
15
+ const SAVE_DELAY_MS = 30 * 1000;
16
+ const PERCENT_MAX = 100;
17
+ const isSample = (value) => Array.isArray(value) && value.length === 2 && value.every(n => typeof n === 'number' && Number.isFinite(n));
18
+ // A damaged or foreign file starts an empty history rather than failing the app.
19
+ function parseStore(document, now) {
20
+ const store = new Map();
21
+ if (!isObject(document) || !isObject(document.accounts))
22
+ return store;
23
+ for (const [accountId, windows] of Object.entries(document.accounts)) {
24
+ if (!isObject(windows))
25
+ continue;
26
+ const byWindow = new Map();
27
+ for (const [windowId, samples] of Object.entries(windows)) {
28
+ const kept = Array.isArray(samples) ? samples.filter(isSample).filter(([time]) => now - time <= HISTORY_RETENTION_MS) : [];
29
+ if (kept.length)
30
+ byWindow.set(windowId, kept);
31
+ }
32
+ if (byWindow.size)
33
+ store.set(accountId, byWindow);
34
+ }
35
+ return store;
36
+ }
37
+ const usedPercent = (window) => window.stale || window.amounts || window.usedPercent === null ? null : Math.min(Math.max(window.usedPercent, 0), PERCENT_MAX);
38
+ const isMeasured = (account) => account.status === 'ok';
39
+ // The readings since the window last reset: usage only grows within a period, so the period
40
+ // starts after the last drop.
41
+ function currentPeriod(samples) {
42
+ let start = samples.length - 1;
43
+ while (start > 0 && (samples[start - 1]?.[1] ?? 0) <= (samples[start]?.[1] ?? 0))
44
+ start--;
45
+ return samples.slice(Math.max(start, 0));
46
+ }
47
+ // When usage reaches 100% at the pace of the last hour of this period; null when it does not
48
+ // grow, there is too little to tell, or it resets first.
49
+ export function forecastExhaustion(samples, window, now) {
50
+ const used = usedPercent(window);
51
+ if (used === null || used >= PERCENT_MAX)
52
+ return null;
53
+ const period = currentPeriod([...samples, [now, used]]);
54
+ const recent = period.filter(([time]) => now - time <= PACE_WINDOW_MS);
55
+ const from = (recent.length >= 2 && now - (recent[0]?.[0] ?? now) >= PACE_MIN_SPAN_MS ? recent : period)[0];
56
+ if (!from || now - from[0] < PACE_MIN_SPAN_MS || used <= from[1])
57
+ return null;
58
+ const perMs = (used - from[1]) / (now - from[0]);
59
+ const exhaustsAt = now + (PERCENT_MAX - used) / perMs;
60
+ const resetsAt = window.resetsAt ? Date.parse(window.resetsAt) : NaN;
61
+ return Number.isFinite(resetsAt) && exhaustsAt >= resetsAt ? null : exhaustsAt;
62
+ }
63
+ export async function createHistory({ dataDir, clock = Date.now }) {
64
+ const file = path.join(dataDir, HISTORY_FILE);
65
+ const text = await fsp.readFile(file, 'utf8').catch(() => '');
66
+ let document = null;
67
+ try {
68
+ document = text ? JSON.parse(text) : null;
69
+ }
70
+ catch {
71
+ // Unreadable history is dropped below.
72
+ }
73
+ const store = parseStore(document, clock());
74
+ let saveTimer;
75
+ let pending = Promise.resolve();
76
+ const samplesOf = (accountId, windowId) => store.get(accountId)?.get(windowId) ?? [];
77
+ function save() {
78
+ clearTimeout(saveTimer);
79
+ saveTimer = undefined;
80
+ const accounts = Object.fromEntries([...store].map(([accountId, windows]) => [accountId, Object.fromEntries(windows)]));
81
+ pending = pending.then(() => writeFileAtomic(file, JSON.stringify({ version: 1, accounts }))).catch(() => undefined);
82
+ return pending;
83
+ }
84
+ function scheduleSave() {
85
+ saveTimer ??= setTimeout(() => void save(), SAVE_DELAY_MS);
86
+ saveTimer.unref();
87
+ }
88
+ function record(snapshot) {
89
+ const now = clock();
90
+ let changed = false;
91
+ for (const account of snapshot.providers.flatMap(p => p.accounts).filter(isMeasured)) {
92
+ const at = Date.parse(account.fetchedAt ?? '');
93
+ const time = Number.isFinite(at) ? at : now;
94
+ for (const window of account.windows) {
95
+ const used = usedPercent(window);
96
+ if (used === null)
97
+ continue;
98
+ const windows = store.get(account.id) ?? new Map();
99
+ const samples = (windows.get(window.id) ?? []).filter(([t]) => now - t <= HISTORY_RETENTION_MS);
100
+ const last = samples.at(-1);
101
+ if (last && (time <= last[0] || (last[1] === used && time - last[0] < SAMPLE_INTERVAL_MS)))
102
+ continue;
103
+ samples.push([time, used]);
104
+ windows.set(window.id, samples);
105
+ store.set(account.id, windows);
106
+ changed = true;
107
+ }
108
+ }
109
+ if (changed)
110
+ scheduleSave();
111
+ }
112
+ function annotate(snapshot) {
113
+ const now = clock();
114
+ for (const account of snapshot.providers.flatMap(p => p.accounts).filter(isMeasured)) {
115
+ for (const window of account.windows) {
116
+ const exhaustsAt = forecastExhaustion(samplesOf(account.id, window.id), window, now);
117
+ if (exhaustsAt === null)
118
+ delete window.exhaustsAt;
119
+ else
120
+ window.exhaustsAt = new Date(exhaustsAt).toISOString();
121
+ }
122
+ }
123
+ return snapshot;
124
+ }
125
+ function series(accountIds) {
126
+ const now = clock();
127
+ return accountIds.flatMap(accountId => [...(store.get(accountId) ?? [])].map(([windowId, samples]) => ({
128
+ accountId,
129
+ windowId,
130
+ samples: samples.filter(([time]) => now - time <= HISTORY_RETENTION_MS)
131
+ })));
132
+ }
133
+ return { record, annotate, series, flush: save };
134
+ }
@@ -0,0 +1,78 @@
1
+ import { type SpawnOptions } from 'node:child_process';
2
+ import { type ClaudeEffortLevel, type ClaudePermissionMode, type ProviderId } from '../shared/catalog.ts';
3
+ import type { LaunchMode } from '../shared/ipc.ts';
4
+ import type { Account, Snapshot } from '../shared/types.ts';
5
+ import { type Environment, type EnvironmentRequest } from './environment.ts';
6
+ import { type LaunchWarning } from './selection.ts';
7
+ export interface SpawnedProcess {
8
+ once(event: 'spawn', listener: () => void): unknown;
9
+ once(event: 'error', listener: (error: Error) => void): unknown;
10
+ unref(): void;
11
+ }
12
+ export type SpawnProcess = (command: string, args: readonly string[], options: SpawnOptions) => SpawnedProcess;
13
+ export declare function shellQuote(value: string): string;
14
+ export interface TerminalCommand {
15
+ binary: string;
16
+ args: readonly string[];
17
+ env: EnvironmentRequest;
18
+ }
19
+ export interface TerminalLaunch extends TerminalCommand {
20
+ cwd: string;
21
+ title?: string | undefined;
22
+ handoff?: string | undefined;
23
+ dataDir: string;
24
+ platform?: NodeJS.Platform | undefined;
25
+ spawnProcess?: SpawnProcess | undefined;
26
+ }
27
+ export declare function openInTerminal({ cwd, binary, args, env, title, handoff, dataDir, platform, spawnProcess }: TerminalLaunch): Promise<void>;
28
+ export interface AppLaunch {
29
+ providerId: ProviderId;
30
+ cwd?: string;
31
+ platform?: NodeJS.Platform | undefined;
32
+ env?: Environment;
33
+ home?: string;
34
+ spawnProcess?: SpawnProcess | undefined;
35
+ }
36
+ export declare function openDesktopApp({ providerId, cwd, platform, env, home, spawnProcess }: AppLaunch): Promise<void>;
37
+ export declare const CLAUDE_RESUME_THRESHOLD_ENV = "CLAUDE_CODE_RESUME_THRESHOLD_MINUTES";
38
+ export declare const RESUME_WITHOUT_QUESTION: string;
39
+ export declare function profileEnvironment(account: Account): EnvironmentRequest;
40
+ export interface LaunchOptions {
41
+ account?: Account | null | undefined;
42
+ model?: string | null | undefined;
43
+ prompt?: string | undefined;
44
+ resume?: string | undefined;
45
+ permissionMode?: ClaudePermissionMode | undefined;
46
+ effort?: ClaudeEffortLevel | undefined;
47
+ }
48
+ export declare function launchPlan(providerId: ProviderId, options: LaunchOptions): Promise<TerminalCommand | null>;
49
+ export interface ToolLaunch extends LaunchOptions {
50
+ providerId: ProviderId;
51
+ cwd: string;
52
+ dataDir: string;
53
+ title?: string;
54
+ platform?: NodeJS.Platform | undefined;
55
+ spawnProcess?: SpawnProcess | undefined;
56
+ accounts?: readonly Account[] | undefined;
57
+ homeDir?: string | undefined;
58
+ handoff?: string | undefined;
59
+ }
60
+ export declare function projectDirectory(dir: string): Promise<string>;
61
+ export declare function launchTool(launch: ToolLaunch): Promise<{
62
+ mode: LaunchMode;
63
+ }>;
64
+ export declare function writeHandoff(file: string, launch: ToolLaunch): Promise<boolean>;
65
+ export interface SignIn {
66
+ providerId: ProviderId;
67
+ account?: Account | undefined;
68
+ dataDir: string;
69
+ cwd: string;
70
+ platform?: NodeJS.Platform | undefined;
71
+ spawnProcess?: SpawnProcess | undefined;
72
+ }
73
+ export declare function openSignIn({ providerId, account, dataDir, cwd, platform, spawnProcess }: SignIn): Promise<void>;
74
+ export declare function loadClaudeSettingsFiles(projectDir: string | undefined, configDir: string | undefined): Promise<Record<string, unknown>>;
75
+ export declare function launchWarnings(snapshot: Snapshot, account: Account, { projectDir, model }: {
76
+ projectDir: string;
77
+ model?: string | undefined;
78
+ }): Promise<LaunchWarning[]>;