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.
- package/LICENSE +21 -0
- package/README.md +275 -2
- package/build/cli.js +197 -0
- package/build/core/claude-processes.d.ts +51 -0
- package/build/core/claude-processes.js +288 -0
- package/build/core/claude-profile.d.ts +2 -0
- package/build/core/claude-profile.js +146 -0
- package/build/core/claude-renew.d.ts +12 -0
- package/build/core/claude-renew.js +79 -0
- package/build/core/continuity.d.ts +90 -0
- package/build/core/continuity.js +1244 -0
- package/build/core/diagnostics.d.ts +25 -0
- package/build/core/diagnostics.js +229 -0
- package/build/core/environment.d.ts +12 -0
- package/build/core/environment.js +25 -0
- package/build/core/errors.d.ts +23 -0
- package/build/core/errors.js +48 -0
- package/build/core/files.d.ts +20 -0
- package/build/core/files.js +136 -0
- package/build/core/glance.d.ts +25 -0
- package/build/core/glance.js +107 -0
- package/build/core/handover.d.ts +61 -0
- package/build/core/handover.js +333 -0
- package/build/core/history.d.ts +19 -0
- package/build/core/history.js +134 -0
- package/build/core/launch.d.ts +78 -0
- package/build/core/launch.js +256 -0
- package/build/core/measurements.d.ts +11 -0
- package/build/core/measurements.js +75 -0
- package/build/core/model-families.d.ts +12 -0
- package/build/core/model-families.js +61 -0
- package/build/core/model.d.ts +32 -0
- package/build/core/model.js +86 -0
- package/build/core/models.d.ts +25 -0
- package/build/core/models.js +164 -0
- package/build/core/redact.d.ts +2 -0
- package/build/core/redact.js +64 -0
- package/build/core/selection.d.ts +65 -0
- package/build/core/selection.js +173 -0
- package/build/core/service.d.ts +35 -0
- package/build/core/service.js +484 -0
- package/build/core/sessions.d.ts +87 -0
- package/build/core/sessions.js +868 -0
- package/build/core/settings.d.ts +16 -0
- package/build/core/settings.js +377 -0
- package/build/core/tray-icon.d.ts +5 -0
- package/build/core/tray-icon.js +91 -0
- package/build/core/updater.d.ts +29 -0
- package/build/core/updater.js +88 -0
- package/build/core/updates.d.ts +5 -0
- package/build/core/updates.js +29 -0
- package/build/launchers/discover-antigravity.ps1 +68 -0
- package/build/launchers/terminal.ps1 +78 -0
- package/build/main/main.js +587 -0
- package/build/providers/antigravity.d.ts +48 -0
- package/build/providers/antigravity.js +463 -0
- package/build/providers/claude-keychain.d.ts +13 -0
- package/build/providers/claude-keychain.js +42 -0
- package/build/providers/claude.d.ts +33 -0
- package/build/providers/claude.js +218 -0
- package/build/providers/codex.d.ts +21 -0
- package/build/providers/codex.js +221 -0
- package/build/providers/common.d.ts +63 -0
- package/build/providers/common.js +294 -0
- package/build/providers/cursor.d.ts +14 -0
- package/build/providers/cursor.js +141 -0
- package/build/providers/profiles.d.ts +10 -0
- package/build/providers/profiles.js +69 -0
- package/build/providers/tls-trust.d.ts +7 -0
- package/build/providers/tls-trust.js +29 -0
- package/build/providers/types.d.ts +19 -0
- package/build/providers/types.js +1 -0
- package/build/shared/account-state.d.ts +19 -0
- package/build/shared/account-state.js +75 -0
- package/build/shared/catalog.d.ts +85 -0
- package/build/shared/catalog.js +125 -0
- package/build/shared/format.d.ts +40 -0
- package/build/shared/format.js +134 -0
- package/build/shared/i18n/index.d.ts +12 -0
- package/build/shared/i18n/index.js +40 -0
- package/build/shared/i18n/locales/de.d.ts +2 -0
- package/build/shared/i18n/locales/de.js +328 -0
- package/build/shared/i18n/locales/en.d.ts +328 -0
- package/build/shared/i18n/locales/en.js +330 -0
- package/build/shared/i18n/locales/es.d.ts +2 -0
- package/build/shared/i18n/locales/es.js +328 -0
- package/build/shared/i18n/locales/fr.d.ts +2 -0
- package/build/shared/i18n/locales/fr.js +328 -0
- package/build/shared/i18n/locales/it.d.ts +2 -0
- package/build/shared/i18n/locales/it.js +327 -0
- package/build/shared/i18n/locales/ja.d.ts +2 -0
- package/build/shared/i18n/locales/ja.js +322 -0
- package/build/shared/i18n/locales/ko.d.ts +2 -0
- package/build/shared/i18n/locales/ko.js +322 -0
- package/build/shared/i18n/locales/nl.d.ts +2 -0
- package/build/shared/i18n/locales/nl.js +327 -0
- package/build/shared/i18n/locales/pl.d.ts +2 -0
- package/build/shared/i18n/locales/pl.js +332 -0
- package/build/shared/i18n/locales/pt.d.ts +2 -0
- package/build/shared/i18n/locales/pt.js +328 -0
- package/build/shared/i18n/locales/ru.d.ts +2 -0
- package/build/shared/i18n/locales/ru.js +331 -0
- package/build/shared/i18n/locales/tr.d.ts +2 -0
- package/build/shared/i18n/locales/tr.js +322 -0
- package/build/shared/i18n/locales/zh.d.ts +2 -0
- package/build/shared/i18n/locales/zh.js +322 -0
- package/build/shared/i18n/messages.d.ts +10 -0
- package/build/shared/i18n/messages.js +1 -0
- package/build/shared/ipc.d.ts +138 -0
- package/build/shared/ipc.js +2 -0
- package/build/shared/tsconfig.tsbuildinfo +1 -0
- package/build/shared/types.d.ts +205 -0
- package/build/shared/types.js +1 -0
- package/package.json +144 -4
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
// Keeping work going when a limit runs out, and bringing it back.
|
|
2
|
+
//
|
|
3
|
+
// The trigger is the tool's own record that its agent stopped at the usage limit, never a
|
|
4
|
+
// quota percentage: at "5% left" the agent is still working, and starting a second one on the
|
|
5
|
+
// same folder would have two agents editing the same files. Every refresh looks again (state,
|
|
6
|
+
// not edges), and each stop is acted on once, remembered across restarts. The work goes to the
|
|
7
|
+
// account with real room left, never to one about to run out.
|
|
8
|
+
//
|
|
9
|
+
// - Opted in (`autoContinue`), each stopped session continues on its own; otherwise it is offered.
|
|
10
|
+
// - An account running low offers to continue its recent session elsewhere.
|
|
11
|
+
// - When the tool's main account has room again, work moved off it goes back (`returnToPrimary`):
|
|
12
|
+
// offered, or on its own once the session sits idle with nothing left running.
|
|
13
|
+
// - Sessions open when the system went down (a restart, a crash, a power cut) open again where
|
|
14
|
+
// they left off (`reopenAfterRestart`): offered, or on their own. The app tells that apart from
|
|
15
|
+
// the user closing them by its own end: a run it was not quit from ended with the system.
|
|
16
|
+
//
|
|
17
|
+
// A Claude Code session moves between profiles whole: the process running it is ended once its
|
|
18
|
+
// copy is ready, so only one agent ever works on it, and in a tab the app opened the session
|
|
19
|
+
// goes on in that same tab. Background commands cannot change process, so the session is told
|
|
20
|
+
// which ones its previous process left running. Any other move continues from a handover brief.
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
import fsp from 'node:fs/promises';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { CLAUDE_FILES, claudeStateFile } from "../providers/claude.js";
|
|
25
|
+
import { defaultProfileDir } from "../providers/profiles.js";
|
|
26
|
+
import { APP_NAME, CLAUDE_EFFORT_LEVELS, DURATION_MS, INHERIT, isOneOf, isProfileProvider, isProviderId, normalizeProviderOrder, providerById, SUGGESTION_KINDS, TRANSITION_STATUSES } from "../shared/catalog.js";
|
|
27
|
+
import { endClaudeSession, findClaudeProcess, openSessionsReader, systemProcesses } from "./claude-processes.js";
|
|
28
|
+
import { AppError } from "./errors.js";
|
|
29
|
+
import { isDirectory, isObject, JSON_LINES_EXTENSION, readJson, samePath, writeFileAtomic, writeFileAtomicSync } from "./files.js";
|
|
30
|
+
import { executeHandover, mappedModels } from "./handover.js";
|
|
31
|
+
import { launchTool, writeHandoff } from "./launch.js";
|
|
32
|
+
import { projectPreference } from "./settings.js";
|
|
33
|
+
import { chooseSuccessor, continueScope, headroomPercent, isAccountEligible, isMeasurementFresh, SUCCESSOR_MIN_HEADROOM_PERCENT } from "./selection.js";
|
|
34
|
+
import { claudeBackgroundTasks, claudeLastWrittenAt, claudeLimitStop, claudeLimitStops, claudeSessionById, claudeSessionMode, claudeTranscriptWrite, claudeTurnEnded, codexLimitStops, latestClaudeSession, latestCodexSession, latestCursorSession, recentSessionModel } from "./sessions.js";
|
|
35
|
+
// A stop older than this is history: the user has moved on, or the limit has reset.
|
|
36
|
+
const STOP_MAX_AGE_MS = 30 * DURATION_MS.MINUTE;
|
|
37
|
+
// A session written this recently, and not stopped, is taken to be running in its terminal.
|
|
38
|
+
const RUNNING_SESSION_MS = DURATION_MS.MINUTE;
|
|
39
|
+
// A running low account's session is still worth continuing from a suggestion within this.
|
|
40
|
+
const RECENT_SESSION_MS = 3 * DURATION_MS.HOUR;
|
|
41
|
+
// A suggestion to move work back stays this long.
|
|
42
|
+
const RETURN_SUGGESTION_MS = 12 * DURATION_MS.HOUR;
|
|
43
|
+
// A session goes back on its own only after sitting idle this long: the user is not at it.
|
|
44
|
+
const RETURN_QUIET_MS = 2 * DURATION_MS.MINUTE;
|
|
45
|
+
// Background commands still running hold a session back this long at most; after that it goes
|
|
46
|
+
// back and is told about them.
|
|
47
|
+
export const RETURN_TASK_WAIT_MS = 30 * DURATION_MS.MINUTE;
|
|
48
|
+
// A moved session is remembered this long for the way back.
|
|
49
|
+
const MOVED_MAX_AGE_MS = 7 * DURATION_MS.DAY;
|
|
50
|
+
// A session cut off by the system going down is offered to reopen this long.
|
|
51
|
+
const REOPEN_SUGGESTION_MS = DURATION_MS.DAY;
|
|
52
|
+
// How long a tab gets to pick up its next command before a new tab is opened instead.
|
|
53
|
+
const HANDOFF_PICKUP_MS = 5000;
|
|
54
|
+
const HANDOFF_POLL_MS = 100;
|
|
55
|
+
const STATE_FILE = 'continuity.json';
|
|
56
|
+
const TABS_DIR = 'tabs';
|
|
57
|
+
const MAX_HANDLED_STOPS = 200;
|
|
58
|
+
const MAX_LISTED_TASKS = 10;
|
|
59
|
+
const MAX_TASK_TEXT = 200;
|
|
60
|
+
// Written for the agent that resumes the session, so it is deliberately not localized.
|
|
61
|
+
export 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.';
|
|
62
|
+
export const RETURN_PROMPT = 'This session moved back to its main account.';
|
|
63
|
+
export 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.';
|
|
64
|
+
// Text from a transcript, made safe for a one-line prompt: no line breaks or control characters.
|
|
65
|
+
// eslint-disable-next-line no-control-regex -- matching control characters is the purpose
|
|
66
|
+
const BREAKS = /[\u0000-\u001f\u007f\s]+/g;
|
|
67
|
+
const oneLine = (text) => text.replace(BREAKS, ' ').trim().slice(0, MAX_TASK_TEXT);
|
|
68
|
+
// `prompt`, telling the agent which background commands its previous process left running.
|
|
69
|
+
function withBackgroundTasks(prompt, tasks) {
|
|
70
|
+
if (!tasks.length)
|
|
71
|
+
return prompt;
|
|
72
|
+
const listed = tasks
|
|
73
|
+
.slice(0, MAX_LISTED_TASKS)
|
|
74
|
+
.map(task => `${task.id} (${oneLine(task.command) || 'command'}${task.outputFile ? `, output: ${oneLine(task.outputFile)}` : ''})`)
|
|
75
|
+
.join('; ');
|
|
76
|
+
return `${prompt} Background tasks started before the move may still be running; they stay with the previous process, so you will not be notified when they finish: ${listed}. Read their output files to see how far they got, and start again only what you still need.`;
|
|
77
|
+
}
|
|
78
|
+
export async function findRecentSession(account, homeDir, identity) {
|
|
79
|
+
let session;
|
|
80
|
+
switch (account.providerId) {
|
|
81
|
+
case 'claude':
|
|
82
|
+
session = latestClaudeSession(account.configDir ?? defaultProfileDir('claude', homeDir), identity);
|
|
83
|
+
break;
|
|
84
|
+
case 'codex':
|
|
85
|
+
session = await latestCodexSession(account.configDir ?? defaultProfileDir('codex', homeDir), identity);
|
|
86
|
+
break;
|
|
87
|
+
case 'cursor':
|
|
88
|
+
session = latestCursorSession(homeDir, identity);
|
|
89
|
+
break;
|
|
90
|
+
case 'antigravity':
|
|
91
|
+
session = null;
|
|
92
|
+
}
|
|
93
|
+
return session ? { ...session, accountId: account.id, model: await recentSessionModel(session).catch(() => '') } : null;
|
|
94
|
+
}
|
|
95
|
+
// The account's sessions whose agent stopped at the usage limit recently, newest first. Only
|
|
96
|
+
// Claude Code and Codex record such a stop.
|
|
97
|
+
export async function findStoppedSessions(account, homeDir, now = Date.now()) {
|
|
98
|
+
let sessions = [];
|
|
99
|
+
if (account.providerId === 'claude')
|
|
100
|
+
sessions = claudeLimitStops(account.configDir ?? defaultProfileDir('claude', homeDir), now, STOP_MAX_AGE_MS);
|
|
101
|
+
if (account.providerId === 'codex')
|
|
102
|
+
sessions = await codexLimitStops(account.configDir ?? defaultProfileDir('codex', homeDir), now, STOP_MAX_AGE_MS);
|
|
103
|
+
return Promise.all(sessions.map(async (session) => ({ ...session, accountId: account.id, model: await recentSessionModel(session).catch(() => '') })));
|
|
104
|
+
}
|
|
105
|
+
// The newest of them.
|
|
106
|
+
export async function findStoppedSession(account, homeDir, now = Date.now()) {
|
|
107
|
+
return (await findStoppedSessions(account, homeDir, now))[0] ?? null;
|
|
108
|
+
}
|
|
109
|
+
// Where a Claude Code transcript lands in another profile: under the same project folder.
|
|
110
|
+
const carriedTranscript = (session, configDir) => path.join(configDir, CLAUDE_FILES.projects, path.basename(path.dirname(session.file)), path.basename(session.file));
|
|
111
|
+
// Copies a Claude Code transcript (and the folder of subagent transcripts and large tool
|
|
112
|
+
// results named after it) into another profile, under the same project, so that profile can
|
|
113
|
+
// resume it. A newer copy replaces an older one; the source is left as it is. A copy there that
|
|
114
|
+
// the session went on in (a later record, or more of the same) is never overwritten. File
|
|
115
|
+
// times say nothing about that: a copy keeps its source's, and Claude Code touches files late.
|
|
116
|
+
export async function carryClaudeSession(session, configDir) {
|
|
117
|
+
const sourceDir = path.dirname(session.file);
|
|
118
|
+
const targetDir = path.dirname(carriedTranscript(session, configDir));
|
|
119
|
+
if (samePath(sourceDir, targetDir))
|
|
120
|
+
return;
|
|
121
|
+
const target = path.join(targetDir, path.basename(session.file));
|
|
122
|
+
const source = claudeTranscriptWrite(session.file);
|
|
123
|
+
const existing = claudeTranscriptWrite(target);
|
|
124
|
+
if (!source)
|
|
125
|
+
throw new AppError('session_not_found');
|
|
126
|
+
if (existing && isNewerWrite(existing, source))
|
|
127
|
+
throw new AppError('session_newer_elsewhere');
|
|
128
|
+
await fsp.mkdir(targetDir, { recursive: true, mode: 0o700 });
|
|
129
|
+
await fsp.copyFile(session.file, target);
|
|
130
|
+
const extras = path.join(sourceDir, session.sessionId);
|
|
131
|
+
if (await isDirectory(extras))
|
|
132
|
+
await fsp.cp(extras, path.join(targetDir, session.sessionId), { recursive: true, force: true });
|
|
133
|
+
// The project's memory (the rules and facts the agent keeps) goes along, where it is not
|
|
134
|
+
// there yet; what the session writes elsewhere comes home the same way.
|
|
135
|
+
const memory = path.join(sourceDir, 'memory');
|
|
136
|
+
if (await isDirectory(memory))
|
|
137
|
+
await fsp.cp(memory, path.join(targetDir, 'memory'), { recursive: true, force: false, errorOnExist: false });
|
|
138
|
+
}
|
|
139
|
+
// Whether `a` went on past `b`: a later record, or the same last record and more bytes after it.
|
|
140
|
+
const isNewerWrite = (a, b) => Number.isFinite(a.lastRecordAt) && (!Number.isFinite(b.lastRecordAt) || a.lastRecordAt > b.lastRecordAt)
|
|
141
|
+
? true
|
|
142
|
+
: a.lastRecordAt === b.lastRecordAt && a.size > b.size;
|
|
143
|
+
// Whether the profile recorded consent to sessions that skip permission prompts.
|
|
144
|
+
async function bypassConsented(account, home) {
|
|
145
|
+
const state = await readJson(claudeStateFile(account, home));
|
|
146
|
+
return isObject(state) && state.bypassPermissionsModeAccepted === true;
|
|
147
|
+
}
|
|
148
|
+
// The effort level a Claude Code profile sets for `model` (its own setting, else the general one).
|
|
149
|
+
async function claudeEffort(configDir, model) {
|
|
150
|
+
const settings = await readJson(path.join(configDir, CLAUDE_FILES.settings));
|
|
151
|
+
if (!isObject(settings))
|
|
152
|
+
return undefined;
|
|
153
|
+
const perModel = isObject(settings.modelSettings) && model ? settings.modelSettings[model] : undefined;
|
|
154
|
+
const effort = isObject(perModel) && perModel.effortLevel !== undefined ? perModel.effortLevel : settings.effortLevel;
|
|
155
|
+
return isOneOf(CLAUDE_EFFORT_LEVELS, effort) ? effort : undefined;
|
|
156
|
+
}
|
|
157
|
+
async function pickedUp(file) {
|
|
158
|
+
for (const deadline = Date.now() + HANDOFF_PICKUP_MS; Date.now() < deadline;) {
|
|
159
|
+
if (!(await fsp.stat(file).catch(() => null)))
|
|
160
|
+
return true;
|
|
161
|
+
await new Promise(resolve => setTimeout(resolve, HANDOFF_POLL_MS));
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
// A process that lists itself saves its session as it goes. One that does not (a child
|
|
166
|
+
// session) holds its conversation in memory only: unless its transcript was written since it
|
|
167
|
+
// started, ending it and going on from the file would lose what was said since. (A listed
|
|
168
|
+
// process resumed without a prompt writes nothing until the user types; it has nothing unsaved.)
|
|
169
|
+
const SAVED_SLACK_MS = 2_000;
|
|
170
|
+
export function holdsUnsavedConversation(running, file) {
|
|
171
|
+
if (running.listed)
|
|
172
|
+
return false;
|
|
173
|
+
const written = claudeLastWrittenAt(file);
|
|
174
|
+
return !Number.isFinite(written) || written < running.startedAt - SAVED_SLACK_MS;
|
|
175
|
+
}
|
|
176
|
+
// Ends the session's current process and starts it as `launch`: in the same tab when the app
|
|
177
|
+
// opened that tab and it takes the next command, else in a new tab of its own. A process that
|
|
178
|
+
// cannot be ended keeps the session: it is never started a second time beside it. A process
|
|
179
|
+
// found gone (or its pid now another's) has nothing to end.
|
|
180
|
+
async function relaunchClaude(launch, { running, tab, processes = systemProcesses }) {
|
|
181
|
+
const result = { to: 'claude', accountId: launch.account?.id ?? null, resumed: true };
|
|
182
|
+
const windows = (launch.platform ?? process.platform) === 'win32';
|
|
183
|
+
const refusal = (end) => (end === 'unknown' ? new AppError('process_unknown') : null);
|
|
184
|
+
if (running && tab && windows && (await writeHandoff(tab, launch))) {
|
|
185
|
+
const end = await endClaudeSession(running, processes);
|
|
186
|
+
if (end === 'ended' && (await pickedUp(tab)))
|
|
187
|
+
return { ...result, mode: 'terminal', tab };
|
|
188
|
+
// Not taken: the tab was closed meanwhile, or the process could not be ended.
|
|
189
|
+
await fsp.rm(tab, { force: true });
|
|
190
|
+
const refused = refusal(end);
|
|
191
|
+
if (refused)
|
|
192
|
+
throw refused;
|
|
193
|
+
}
|
|
194
|
+
else if (running) {
|
|
195
|
+
const refused = refusal(await endClaudeSession(running, processes));
|
|
196
|
+
if (refused)
|
|
197
|
+
throw refused;
|
|
198
|
+
}
|
|
199
|
+
const next = windows ? path.join(launch.dataDir, TABS_DIR, `${randomUUID()}.json`) : null;
|
|
200
|
+
if (next)
|
|
201
|
+
await fsp.mkdir(path.dirname(next), { recursive: true });
|
|
202
|
+
const { mode } = await launchTool({ ...launch, handoff: next ?? undefined });
|
|
203
|
+
return { ...result, mode, tab: next };
|
|
204
|
+
}
|
|
205
|
+
// A Claude Code session moves whole to another Claude Code profile: its transcript is carried
|
|
206
|
+
// and resumed there. Any other move starts the target tool from a handover brief.
|
|
207
|
+
const movesWhole = (from, to) => from === 'claude' && to === 'claude';
|
|
208
|
+
export async function continueSession(session, to, state, context, options = {}) {
|
|
209
|
+
const { dataDir, homeDir, platform, spawnProcess } = context;
|
|
210
|
+
const { prompt } = options;
|
|
211
|
+
if (movesWhole(session.providerId, to.providerId) && to.account?.configDir) {
|
|
212
|
+
if (options.running && holdsUnsavedConversation(options.running, session.file))
|
|
213
|
+
throw new AppError('session_unsaved');
|
|
214
|
+
await carryClaudeSession(session, to.account.configDir);
|
|
215
|
+
// The session goes on as it ran: same model (unless the project picks one), permission mode
|
|
216
|
+
// and effort, whatever the other profile's own settings say. The transcript sits in
|
|
217
|
+
// <profile>/projects/<project>/.
|
|
218
|
+
const { model, permissionMode } = claudeSessionMode(session.file);
|
|
219
|
+
// A session that skips permissions runs so only where the user consented to it in that
|
|
220
|
+
// profile; Claude Code would otherwise stop at the question (or quietly run without).
|
|
221
|
+
if (permissionMode === 'bypassPermissions' && !(await bypassConsented(to.account, homeDir)))
|
|
222
|
+
throw new AppError('bypass_not_consented');
|
|
223
|
+
const effort = await claudeEffort(path.resolve(path.dirname(session.file), '..', '..'), model);
|
|
224
|
+
return relaunchClaude({
|
|
225
|
+
providerId: 'claude',
|
|
226
|
+
account: to.account,
|
|
227
|
+
cwd: session.projectDir,
|
|
228
|
+
dataDir,
|
|
229
|
+
resume: session.sessionId,
|
|
230
|
+
model: to.model || model,
|
|
231
|
+
permissionMode,
|
|
232
|
+
effort,
|
|
233
|
+
prompt,
|
|
234
|
+
title: `${APP_NAME} · ${providerById('claude').name}`,
|
|
235
|
+
platform,
|
|
236
|
+
spawnProcess,
|
|
237
|
+
accounts: state.settings.accounts,
|
|
238
|
+
homeDir
|
|
239
|
+
}, options);
|
|
240
|
+
}
|
|
241
|
+
const result = await executeHandover({
|
|
242
|
+
projectDir: session.projectDir,
|
|
243
|
+
fromProvider: session.providerId,
|
|
244
|
+
toProvider: to.providerId,
|
|
245
|
+
sessionId: session.sessionId,
|
|
246
|
+
sessionProvider: session.providerId,
|
|
247
|
+
account: to.account,
|
|
248
|
+
...(to.model ? { targetModel: to.model } : {}),
|
|
249
|
+
sourceAccountId: session.accountId
|
|
250
|
+
}, state, context);
|
|
251
|
+
return { to: result.to, accountId: to.account?.id ?? null, resumed: false, mode: result.mode };
|
|
252
|
+
}
|
|
253
|
+
// The moves the app follows a whole session for, by the kind of their suggestion. A failed one
|
|
254
|
+
// is told apart from a failed move off an account that ran out.
|
|
255
|
+
const ATTEMPTS = ['return', 'reopen'];
|
|
256
|
+
const PICKUP_MAX_MS = 3 * DURATION_MS.MINUTE;
|
|
257
|
+
// While the system cannot be asked, a dispatch is given this long before it is given up on.
|
|
258
|
+
const PICKUP_UNKNOWN_MAX_MS = 30 * DURATION_MS.MINUTE;
|
|
259
|
+
const TERMINAL_STATUSES = new Set(['dispatched', 'dismissed', 'interrupted']);
|
|
260
|
+
// An offer stands only with its record still offered, or failed and waiting for a retry.
|
|
261
|
+
const OFFER_STATUSES = new Set(['offered', 'failed']);
|
|
262
|
+
// The history keeps its newest settled records; one still live (offered, under way, or failed
|
|
263
|
+
// with its offer still there to retry) is never dropped, however old.
|
|
264
|
+
function capRecords(records, liveIds, cap) {
|
|
265
|
+
const settled = records.filter(r => TERMINAL_STATUSES.has(r.status) || (r.status === 'failed' && !liveIds.has(r.id)));
|
|
266
|
+
const dropped = new Set(settled.slice(0, Math.max(0, settled.length - cap)));
|
|
267
|
+
return records.filter(r => !dropped.has(r));
|
|
268
|
+
}
|
|
269
|
+
// The keys of the state's maps and the ids of its offers, each made here and only ever compared:
|
|
270
|
+
// what a record is about travels in its own fields.
|
|
271
|
+
const keys = {
|
|
272
|
+
session: (session) => `${session.providerId}:${session.sessionId}`,
|
|
273
|
+
stop: (accountId, sessionId, stoppedAt) => `${accountId}:${sessionId}:${stoppedAt}`
|
|
274
|
+
};
|
|
275
|
+
// One standing offer per account running low, per stopped session, and per session to open
|
|
276
|
+
// again or bring back.
|
|
277
|
+
const suggestionIds = {
|
|
278
|
+
low: (accountId) => `low:${accountId}`,
|
|
279
|
+
stop: (accountId, sessionId) => `low:${accountId}:${sessionId}`,
|
|
280
|
+
reopen: (sessionKey) => `reopen:${sessionKey}`,
|
|
281
|
+
return: (sessionKey) => `return:${sessionKey}`
|
|
282
|
+
};
|
|
283
|
+
// State written before its records carried what they are about (until 0.1.1) named the session
|
|
284
|
+
// only in its key, `<provider>:<session>`, and an offer's stop only as `<account>:<session>:<at>`.
|
|
285
|
+
// They are read from there this once, when such a state is loaded; nothing else takes a key apart.
|
|
286
|
+
const legacy = {
|
|
287
|
+
sessionId: (key) => key.slice(key.indexOf(':') + 1),
|
|
288
|
+
stoppedAt: (stopKey) => {
|
|
289
|
+
const at = Number(stopKey.slice(stopKey.lastIndexOf(':') + 1));
|
|
290
|
+
return Number.isFinite(at) ? at : undefined;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
// A stored offer's stop: its own field, or the time in the key it was stored with before.
|
|
294
|
+
function stoppedAtOf(entry) {
|
|
295
|
+
const at = typeof entry.stoppedAt === 'number' ? entry.stoppedAt : typeof entry.stopKey === 'string' ? legacy.stoppedAt(entry.stopKey) : undefined;
|
|
296
|
+
return at === undefined ? {} : { stoppedAt: at };
|
|
297
|
+
}
|
|
298
|
+
// A stored record with its session, from its own field or, written before it had one, its key.
|
|
299
|
+
function withSession(key, value) {
|
|
300
|
+
return typeof value.sessionId === 'string' ? value : { ...value, sessionId: legacy.sessionId(key) };
|
|
301
|
+
}
|
|
302
|
+
const isDispatch = (value) => isObject(value) &&
|
|
303
|
+
typeof value.sessionId === 'string' &&
|
|
304
|
+
typeof value.holderId === 'string' &&
|
|
305
|
+
typeof value.file === 'string' &&
|
|
306
|
+
typeof value.at === 'number' &&
|
|
307
|
+
(value.attempt === undefined || isOneOf(ATTEMPTS, value.attempt));
|
|
308
|
+
const isOpen = (value) => isObject(value) &&
|
|
309
|
+
typeof value.sessionId === 'string' &&
|
|
310
|
+
typeof value.accountId === 'string' &&
|
|
311
|
+
typeof value.cwd === 'string' &&
|
|
312
|
+
typeof value.busy === 'boolean' &&
|
|
313
|
+
typeof value.at === 'number';
|
|
314
|
+
const isMoved = (value) => isObject(value) &&
|
|
315
|
+
typeof value.sessionId === 'string' &&
|
|
316
|
+
typeof value.from === 'string' &&
|
|
317
|
+
typeof value.to === 'string' &&
|
|
318
|
+
typeof value.projectDir === 'string' &&
|
|
319
|
+
typeof value.folder === 'string' &&
|
|
320
|
+
typeof value.at === 'number';
|
|
321
|
+
// The stored map's records that pass `is`, each given its session first.
|
|
322
|
+
function storedRecords(value, is) {
|
|
323
|
+
if (!isObject(value))
|
|
324
|
+
return {};
|
|
325
|
+
return Object.fromEntries(Object.entries(value).flatMap(([key, record]) => {
|
|
326
|
+
const current = isObject(record) ? withSession(key, record) : record;
|
|
327
|
+
return is(current) ? [[key, current]] : [];
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
const SAFE_ERRORS = [
|
|
331
|
+
'launch_failed',
|
|
332
|
+
'no_verified_quota',
|
|
333
|
+
'suggestion_expired',
|
|
334
|
+
'account_not_found',
|
|
335
|
+
'binary_missing',
|
|
336
|
+
'app_not_found',
|
|
337
|
+
'session_not_found',
|
|
338
|
+
'session_newer_elsewhere',
|
|
339
|
+
'session_still_running',
|
|
340
|
+
'session_unsaved',
|
|
341
|
+
'process_unknown',
|
|
342
|
+
'not_picked_up',
|
|
343
|
+
'bypass_not_consented'
|
|
344
|
+
];
|
|
345
|
+
const errorCode = (error) => error instanceof AppError && isOneOf(SAFE_ERRORS, error.code) ? error.code : 'launch_failed';
|
|
346
|
+
const sameSession = (a, b) => a.providerId === b.providerId && a.sessionId === b.sessionId && samePath(a.projectDir, b.projectDir);
|
|
347
|
+
const validSession = (value) => isObject(value) &&
|
|
348
|
+
isProviderId(value.providerId) &&
|
|
349
|
+
typeof value.sessionId === 'string' &&
|
|
350
|
+
typeof value.projectDir === 'string' &&
|
|
351
|
+
typeof value.file === 'string' &&
|
|
352
|
+
typeof value.updatedAt === 'number' &&
|
|
353
|
+
Number.isFinite(value.updatedAt);
|
|
354
|
+
const validTarget = (value) => isObject(value) && isProviderId(value.providerId) && (typeof value.accountId === 'string' || value.accountId === null);
|
|
355
|
+
async function loadState(file, historyCap) {
|
|
356
|
+
const document = await readJson(file);
|
|
357
|
+
const state = {
|
|
358
|
+
handled: [],
|
|
359
|
+
offers: [],
|
|
360
|
+
transitions: [],
|
|
361
|
+
moved: {},
|
|
362
|
+
tabs: {},
|
|
363
|
+
dispatched: {},
|
|
364
|
+
holding: {},
|
|
365
|
+
open: {},
|
|
366
|
+
interrupted: {},
|
|
367
|
+
// A state from before the app kept this says nothing about how it ended: taken as quit.
|
|
368
|
+
quit: true
|
|
369
|
+
};
|
|
370
|
+
if (!isObject(document))
|
|
371
|
+
return state;
|
|
372
|
+
if (Array.isArray(document.handled))
|
|
373
|
+
state.handled = document.handled.filter((k) => typeof k === 'string').slice(-MAX_HANDLED_STOPS);
|
|
374
|
+
if (Array.isArray(document.transitions))
|
|
375
|
+
for (const record of document.transitions) {
|
|
376
|
+
if (!isObject(record) ||
|
|
377
|
+
typeof record.id !== 'string' ||
|
|
378
|
+
typeof record.suggestionId !== 'string' ||
|
|
379
|
+
typeof record.projectDir !== 'string' ||
|
|
380
|
+
typeof record.createdAt !== 'number' ||
|
|
381
|
+
typeof record.updatedAt !== 'number' ||
|
|
382
|
+
!validTarget(record.source) ||
|
|
383
|
+
typeof record.source.accountId !== 'string' ||
|
|
384
|
+
typeof record.source.sessionId !== 'string' ||
|
|
385
|
+
!validTarget(record.target) ||
|
|
386
|
+
!(record.target.sessionId === null || typeof record.target.sessionId === 'string') ||
|
|
387
|
+
!isOneOf(TRANSITION_STATUSES, record.status))
|
|
388
|
+
continue;
|
|
389
|
+
state.transitions.push({
|
|
390
|
+
id: record.id,
|
|
391
|
+
suggestionId: record.suggestionId,
|
|
392
|
+
projectDir: record.projectDir,
|
|
393
|
+
createdAt: record.createdAt,
|
|
394
|
+
updatedAt: record.updatedAt,
|
|
395
|
+
source: { providerId: record.source.providerId, accountId: record.source.accountId, sessionId: record.source.sessionId },
|
|
396
|
+
target: { providerId: record.target.providerId, accountId: record.target.accountId, sessionId: record.target.sessionId },
|
|
397
|
+
status: record.status === 'dispatching' ? 'interrupted' : record.status,
|
|
398
|
+
errorCode: isOneOf(SAFE_ERRORS, record.errorCode) ? record.errorCode : null
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (Array.isArray(document.offers))
|
|
402
|
+
for (const entry of document.offers.slice(-MAX_HANDLED_STOPS)) {
|
|
403
|
+
if (!isObject(entry) ||
|
|
404
|
+
!validSession(entry.session) ||
|
|
405
|
+
typeof entry.holderId !== 'string' ||
|
|
406
|
+
typeof entry.stopped !== 'boolean' ||
|
|
407
|
+
typeof entry.createdAt !== 'number' ||
|
|
408
|
+
typeof entry.transitionId !== 'string' ||
|
|
409
|
+
!isObject(entry.suggestion))
|
|
410
|
+
continue;
|
|
411
|
+
const s = entry.suggestion;
|
|
412
|
+
if (typeof s.id !== 'string' ||
|
|
413
|
+
!isOneOf(SUGGESTION_KINDS, s.kind) ||
|
|
414
|
+
!isProviderId(s.providerId) ||
|
|
415
|
+
typeof s.accountId !== 'string' ||
|
|
416
|
+
typeof s.remainingPercent !== 'number' ||
|
|
417
|
+
!validTarget(s.target) ||
|
|
418
|
+
typeof s.projectDir !== 'string' ||
|
|
419
|
+
!isProviderId(s.sessionProvider))
|
|
420
|
+
continue;
|
|
421
|
+
if (!state.transitions.some(r => r.id === entry.transitionId && OFFER_STATUSES.has(r.status)))
|
|
422
|
+
continue;
|
|
423
|
+
state.offers.push({
|
|
424
|
+
suggestion: {
|
|
425
|
+
id: s.id,
|
|
426
|
+
kind: s.kind,
|
|
427
|
+
providerId: s.providerId,
|
|
428
|
+
accountId: s.accountId,
|
|
429
|
+
remainingPercent: s.remainingPercent,
|
|
430
|
+
target: { providerId: s.target.providerId, accountId: s.target.accountId },
|
|
431
|
+
projectDir: s.projectDir,
|
|
432
|
+
sessionProvider: s.sessionProvider
|
|
433
|
+
},
|
|
434
|
+
session: {
|
|
435
|
+
providerId: entry.session.providerId,
|
|
436
|
+
sessionId: entry.session.sessionId,
|
|
437
|
+
projectDir: entry.session.projectDir,
|
|
438
|
+
file: entry.session.file,
|
|
439
|
+
updatedAt: entry.session.updatedAt,
|
|
440
|
+
...(typeof entry.session.model === 'string' ? { model: entry.session.model } : {})
|
|
441
|
+
},
|
|
442
|
+
holderId: entry.holderId,
|
|
443
|
+
stopped: entry.stopped,
|
|
444
|
+
createdAt: entry.createdAt,
|
|
445
|
+
transitionId: entry.transitionId,
|
|
446
|
+
...stoppedAtOf(entry),
|
|
447
|
+
...(typeof entry.returning === 'string' ? { returning: entry.returning } : {}),
|
|
448
|
+
...(typeof entry.reopening === 'string' ? { reopening: entry.reopening } : {})
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
state.moved = storedRecords(document.moved, isMoved);
|
|
452
|
+
// A dispatch kept its session's key in a field of its own before (`key`).
|
|
453
|
+
state.dispatched = storedRecords(isObject(document.dispatched)
|
|
454
|
+
? Object.fromEntries(Object.entries(document.dispatched).map(([id, record]) => [
|
|
455
|
+
id,
|
|
456
|
+
isObject(record) && typeof record.key === 'string' && record.sessionId === undefined
|
|
457
|
+
? { ...record, sessionId: legacy.sessionId(record.key) }
|
|
458
|
+
: record
|
|
459
|
+
]))
|
|
460
|
+
: undefined, isDispatch);
|
|
461
|
+
if (isObject(document.holding))
|
|
462
|
+
state.holding = Object.fromEntries(Object.entries(document.holding).filter((entry) => typeof entry[1] === 'number'));
|
|
463
|
+
if (isObject(document.tabs))
|
|
464
|
+
state.tabs = Object.fromEntries(Object.entries(document.tabs).filter((entry) => typeof entry[1] === 'string'));
|
|
465
|
+
state.open = storedRecords(document.open, isOpen);
|
|
466
|
+
state.interrupted = storedRecords(document.interrupted, isOpen);
|
|
467
|
+
if (typeof document.quit === 'boolean')
|
|
468
|
+
state.quit = document.quit;
|
|
469
|
+
state.transitions = capRecords(state.transitions, new Set(state.offers.map(o => o.transitionId)), historyCap);
|
|
470
|
+
return state;
|
|
471
|
+
}
|
|
472
|
+
// Each Claude Code process's start is checked once per run of the app.
|
|
473
|
+
function defaultFindOpen(homeDir) {
|
|
474
|
+
const read = openSessionsReader();
|
|
475
|
+
return account => read(account.configDir ?? defaultProfileDir('claude', homeDir));
|
|
476
|
+
}
|
|
477
|
+
export async function createContinuity({ homeDir, dataDir, launch, findSession = (account, identity) => findRecentSession(account, homeDir, identity), findStopped = account => findStoppedSessions(account, homeDir), findRunning = (account, sessionId) => findClaudeProcess(account.configDir ?? defaultProfileDir('claude', homeDir), sessionId), findTasks = claudeBackgroundTasks, findOpen = defaultFindOpen(homeDir), start = continueSession, clock = Date.now, historyCap = MAX_HANDLED_STOPS }) {
|
|
478
|
+
const file = path.join(dataDir, STATE_FILE);
|
|
479
|
+
const stored = await loadState(file, historyCap);
|
|
480
|
+
const handled = new Set(stored.handled);
|
|
481
|
+
const records = stored.transitions;
|
|
482
|
+
const moved = new Map(Object.entries(stored.moved));
|
|
483
|
+
const tabs = new Map(Object.entries(stored.tabs));
|
|
484
|
+
const dispatched = new Map(Object.entries(stored.dispatched));
|
|
485
|
+
const holding = new Map(Object.entries(stored.holding));
|
|
486
|
+
let open = new Map(Object.entries(stored.open));
|
|
487
|
+
const interrupted = new Map(Object.entries(stored.interrupted));
|
|
488
|
+
// How the app's previous run ended, until this run has looked at what is open now.
|
|
489
|
+
let previousQuit = stored.quit;
|
|
490
|
+
let quitting = false;
|
|
491
|
+
const offers = new Map(stored.offers.map(o => [
|
|
492
|
+
o.suggestion.id,
|
|
493
|
+
{
|
|
494
|
+
...o,
|
|
495
|
+
holder: { id: o.holderId, providerId: o.session.providerId, enabled: true, primary: false },
|
|
496
|
+
to: { providerId: o.suggestion.target.providerId, account: null }
|
|
497
|
+
}
|
|
498
|
+
]));
|
|
499
|
+
let queue = Promise.resolve();
|
|
500
|
+
const serial = (task) => {
|
|
501
|
+
const run = queue.then(task);
|
|
502
|
+
queue = run.catch(() => undefined);
|
|
503
|
+
return run;
|
|
504
|
+
};
|
|
505
|
+
const liveIds = () => new Set([...offers.values()].map(o => o.transitionId));
|
|
506
|
+
const serialize = () => JSON.stringify({
|
|
507
|
+
handled: [...handled].slice(-MAX_HANDLED_STOPS),
|
|
508
|
+
transitions: capRecords(records, liveIds(), historyCap),
|
|
509
|
+
offers: [...offers.values()].slice(-MAX_HANDLED_STOPS).map(o => ({
|
|
510
|
+
suggestion: o.suggestion,
|
|
511
|
+
session: o.session,
|
|
512
|
+
holderId: o.holder.id,
|
|
513
|
+
stopped: o.stopped,
|
|
514
|
+
createdAt: o.createdAt,
|
|
515
|
+
transitionId: o.transitionId,
|
|
516
|
+
stoppedAt: o.stoppedAt,
|
|
517
|
+
returning: o.returning,
|
|
518
|
+
reopening: o.reopening
|
|
519
|
+
})),
|
|
520
|
+
moved: Object.fromEntries(moved),
|
|
521
|
+
tabs: Object.fromEntries(tabs),
|
|
522
|
+
dispatched: Object.fromEntries(dispatched),
|
|
523
|
+
holding: Object.fromEntries(holding),
|
|
524
|
+
open: Object.fromEntries(open),
|
|
525
|
+
interrupted: Object.fromEntries(interrupted),
|
|
526
|
+
// Until this run has looked, how the last one ended still stands.
|
|
527
|
+
quit: quitting || (previousQuit ?? false)
|
|
528
|
+
});
|
|
529
|
+
// A write still under way when the app is quit lands after quit()'s own: it is written again,
|
|
530
|
+
// so the next start does not take a quit for the system going down.
|
|
531
|
+
const save = async () => {
|
|
532
|
+
await writeFileAtomic(file, serialize());
|
|
533
|
+
if (quitting)
|
|
534
|
+
writeFileAtomicSync(file, serialize());
|
|
535
|
+
};
|
|
536
|
+
const accountOf = (snapshot, id) => snapshot.settings.accounts.find(a => a.id === id && a.enabled) ?? null;
|
|
537
|
+
const measuredOf = (snapshot, id) => snapshot.providers.flatMap(p => p.accounts).find(a => a.id === id) ?? null;
|
|
538
|
+
const roomy = (snapshot, id, model, minHeadroom = SUCCESSOR_MIN_HEADROOM_PERCENT) => !!accountOf(snapshot, id) &&
|
|
539
|
+
isAccountEligible(measuredOf(snapshot, id), { now: clock(), model }) &&
|
|
540
|
+
(headroomPercent(measuredOf(snapshot, id), model) ?? 0) >= minHeadroom;
|
|
541
|
+
// The room an account needs to count as not running low: above the user's low line. An
|
|
542
|
+
// account that ran low or stopped has room again only then, and work that is not stopped
|
|
543
|
+
// (running low, going home, reopening at home) only moves to an account with as much, or it
|
|
544
|
+
// would be offered away again at once. A stopped session takes any account with some room.
|
|
545
|
+
const lowLine = (snapshot) => Math.max(SUCCESSOR_MIN_HEADROOM_PERCENT, snapshot.settings.lowQuotaPercent);
|
|
546
|
+
const recovered = (snapshot, id, model) => roomy(snapshot, id, model, lowLine(snapshot));
|
|
547
|
+
// Whether some account that could take work from `from` has no fresh reading: nothing can be
|
|
548
|
+
// said yet about whether it would.
|
|
549
|
+
function unmeasured(snapshot, from, scope) {
|
|
550
|
+
const order = normalizeProviderOrder(snapshot.settings.providerOrder);
|
|
551
|
+
return snapshot.settings.accounts
|
|
552
|
+
.filter(a => a.enabled && a.id !== from.id && (scope === 'tools' ? order.includes(a.providerId) : a.providerId === from.providerId))
|
|
553
|
+
.some(a => {
|
|
554
|
+
// Not attempted yet this run (pending, or a reading remembered from before), or a good
|
|
555
|
+
// reading too old to count. A reading that failed is an answer: it says no.
|
|
556
|
+
const measured = measuredOf(snapshot, a.id);
|
|
557
|
+
if (!measured)
|
|
558
|
+
return false;
|
|
559
|
+
if (measured.status === 'pending')
|
|
560
|
+
return true;
|
|
561
|
+
if (measured.status === 'stale')
|
|
562
|
+
return !measured.messageCode;
|
|
563
|
+
return measured.status === 'ok' && !isMeasurementFresh(measured, clock());
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
// A time ahead of the clock (a touched file, a clock that moved) is as fresh as it gets.
|
|
567
|
+
const fresh = (session, maxAge) => clock() - session.updatedAt <= maxAge;
|
|
568
|
+
// When a transcript was last written, by its records; one that says nothing is taken as now.
|
|
569
|
+
const writtenAt = (write) => (Number.isFinite(write.lastRecordAt) ? write.lastRecordAt : clock());
|
|
570
|
+
async function recent(account, maxAge, identity) {
|
|
571
|
+
const session = account ? await findSession(account, identity).catch(() => null) : null;
|
|
572
|
+
return session && fresh(session, maxAge) && (!identity || sameSession(session, { ...session, ...identity })) ? session : null;
|
|
573
|
+
}
|
|
574
|
+
// Whether the session waits for the user: as its process says, or, when the process does not
|
|
575
|
+
// say, as its transcript shows (its last turn ended).
|
|
576
|
+
// A session cut off by a usage limit waits too: it cannot go on where it is.
|
|
577
|
+
const cutOff = (file) => claudeLimitStop(file) !== null;
|
|
578
|
+
const waiting = (process, file) => cutOff(file) || (process.status !== null ? process.status === 'idle' : claudeTurnEnded(file));
|
|
579
|
+
// An offer for a whole session the app follows (back home, or open again): it stands however
|
|
580
|
+
// long the session sat idle.
|
|
581
|
+
const followed = (pending) => pending.returning !== undefined || pending.reopening !== undefined;
|
|
582
|
+
const expired = (pending) => (pending.suggestion.kind === 'return' && clock() - pending.createdAt > RETURN_SUGGESTION_MS) ||
|
|
583
|
+
(pending.suggestion.kind === 'reopen' && clock() - pending.createdAt > REOPEN_SUGGESTION_MS);
|
|
584
|
+
// The process running a Claude Code session, as far as it can be told; a failure to ask is
|
|
585
|
+
// `unknown`, never "none".
|
|
586
|
+
async function lookup(account, session) {
|
|
587
|
+
if (session.providerId !== 'claude')
|
|
588
|
+
return { kind: 'none' };
|
|
589
|
+
try {
|
|
590
|
+
return await findRunning(account, session.sessionId);
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
return { kind: 'unknown', reason: error instanceof Error ? error.message : String(error) };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// The background commands the process started that have not finished.
|
|
597
|
+
async function openTasks(session, process) {
|
|
598
|
+
if (!process)
|
|
599
|
+
return [];
|
|
600
|
+
const tasks = await findTasks(session.file).catch(() => []);
|
|
601
|
+
return tasks.filter(task => task.startedAt >= process.startedAt);
|
|
602
|
+
}
|
|
603
|
+
// How the offer's session moves: its process ended while it waits (a busy one the user chose
|
|
604
|
+
// to leave running is not), told what it left running, in the tab it runs in.
|
|
605
|
+
async function moveOptions(pending, session, holder) {
|
|
606
|
+
// Reopened after the system went down: nothing runs it any more, and a turn that was under
|
|
607
|
+
// way (or cut off by a limit) goes on.
|
|
608
|
+
if (pending.reopening) {
|
|
609
|
+
const busy = interrupted.get(pending.reopening)?.busy ?? false;
|
|
610
|
+
return { prompt: busy || cutOff(session.file) ? RESTART_PROMPT : undefined, running: null };
|
|
611
|
+
}
|
|
612
|
+
const found = await lookup(holder, session);
|
|
613
|
+
// Not knowing whether it still runs, the session is left where it is: it could be started
|
|
614
|
+
// twice otherwise.
|
|
615
|
+
if (found.kind === 'unknown')
|
|
616
|
+
throw new AppError('process_unknown');
|
|
617
|
+
const process = found.kind === 'found' ? found.process : null;
|
|
618
|
+
const ending = process && (pending.stopped || waiting(process, session.file)) ? process : null;
|
|
619
|
+
const tasks = await openTasks(session, ending);
|
|
620
|
+
// A session cut off by a limit is told to go on, wherever it moves (back home included).
|
|
621
|
+
const prompt = pending.stopped || (session.providerId === 'claude' && cutOff(session.file))
|
|
622
|
+
? withBackgroundTasks(RESUME_PROMPT, tasks)
|
|
623
|
+
: pending.returning && tasks.length
|
|
624
|
+
? withBackgroundTasks(RETURN_PROMPT, tasks)
|
|
625
|
+
: undefined;
|
|
626
|
+
return { prompt, running: ending, tab: tabs.get(keys.session(session)) };
|
|
627
|
+
}
|
|
628
|
+
// Remembers where a whole session went, for the way back to the account it first left; one
|
|
629
|
+
// back there is home.
|
|
630
|
+
function recordMove(session, from, to, result) {
|
|
631
|
+
const key = keys.session(session);
|
|
632
|
+
if (result.tab)
|
|
633
|
+
tabs.set(key, result.tab);
|
|
634
|
+
if (!to.account)
|
|
635
|
+
return;
|
|
636
|
+
const origin = moved.get(key)?.from ?? from.id;
|
|
637
|
+
if (origin === to.account.id)
|
|
638
|
+
moved.delete(key);
|
|
639
|
+
else
|
|
640
|
+
moved.set(key, {
|
|
641
|
+
sessionId: session.sessionId,
|
|
642
|
+
from: origin,
|
|
643
|
+
to: to.account.id,
|
|
644
|
+
projectDir: session.projectDir,
|
|
645
|
+
folder: path.basename(path.dirname(session.file)),
|
|
646
|
+
at: clock()
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
const recordOf = (offer) => records.find(r => r.id === offer.transitionId);
|
|
650
|
+
const status = (offer, next, error = null) => {
|
|
651
|
+
const record = recordOf(offer);
|
|
652
|
+
if (record) {
|
|
653
|
+
record.status = next;
|
|
654
|
+
record.updatedAt = clock();
|
|
655
|
+
record.errorCode = error;
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
function offer(fields, kind, account, remainingPercent, id = suggestionIds.low(account.id)) {
|
|
659
|
+
const suggestion = {
|
|
660
|
+
id,
|
|
661
|
+
kind,
|
|
662
|
+
providerId: account.providerId,
|
|
663
|
+
accountId: account.id,
|
|
664
|
+
remainingPercent,
|
|
665
|
+
target: { providerId: fields.to.providerId, accountId: fields.to.account?.id ?? null },
|
|
666
|
+
projectDir: fields.session.projectDir,
|
|
667
|
+
sessionProvider: fields.session.providerId
|
|
668
|
+
};
|
|
669
|
+
// A new offer for the same id supersedes the old one: its record can no longer be retried.
|
|
670
|
+
const replaced = offers.get(suggestion.id);
|
|
671
|
+
if (replaced)
|
|
672
|
+
status(replaced, 'dismissed');
|
|
673
|
+
const transitionId = `${suggestion.id}:${randomUUID()}`;
|
|
674
|
+
offers.set(suggestion.id, { ...fields, suggestion, createdAt: clock(), transitionId });
|
|
675
|
+
records.push({
|
|
676
|
+
id: transitionId,
|
|
677
|
+
suggestionId: suggestion.id,
|
|
678
|
+
createdAt: clock(),
|
|
679
|
+
updatedAt: clock(),
|
|
680
|
+
projectDir: fields.session.projectDir,
|
|
681
|
+
source: { providerId: fields.session.providerId, accountId: fields.holder.id, sessionId: fields.session.sessionId },
|
|
682
|
+
target: {
|
|
683
|
+
...suggestion.target,
|
|
684
|
+
sessionId: movesWhole(fields.session.providerId, fields.to.providerId) ? fields.session.sessionId : null
|
|
685
|
+
},
|
|
686
|
+
status: 'offered',
|
|
687
|
+
errorCode: null
|
|
688
|
+
});
|
|
689
|
+
records.splice(0, records.length, ...capRecords(records, liveIds(), historyCap));
|
|
690
|
+
return suggestion;
|
|
691
|
+
}
|
|
692
|
+
// The models each tool would continue the session with; the project's own model wins.
|
|
693
|
+
function modelsFor(snapshot, session) {
|
|
694
|
+
const preference = projectPreference(snapshot.settings, session.projectDir);
|
|
695
|
+
const models = mappedModels(snapshot, session.model ?? null, launch);
|
|
696
|
+
return preference?.tool && preference.model ? { ...models, [preference.tool]: preference.model } : models;
|
|
697
|
+
}
|
|
698
|
+
// Who would take the session; work that is not stopped needs a taker above the low line.
|
|
699
|
+
function successor(snapshot, holder, session, scope, stopped) {
|
|
700
|
+
const models = modelsFor(snapshot, session);
|
|
701
|
+
const preference = projectPreference(snapshot.settings, session.projectDir);
|
|
702
|
+
const to = chooseSuccessor(snapshot, holder, scope, {
|
|
703
|
+
now: clock(),
|
|
704
|
+
minHeadroom: stopped ? SUCCESSOR_MIN_HEADROOM_PERCENT : lowLine(snapshot),
|
|
705
|
+
models,
|
|
706
|
+
prefer: { tool: preference?.tool, accountId: preference?.accountId }
|
|
707
|
+
});
|
|
708
|
+
return to ? { ...to, model: to.model ?? models[to.providerId] ?? null } : null;
|
|
709
|
+
}
|
|
710
|
+
async function sourceSession(pending, snapshot) {
|
|
711
|
+
const holder = accountOf(snapshot, pending.holder.id);
|
|
712
|
+
if (!holder ||
|
|
713
|
+
(!followed(pending) && !fresh(pending.session, pending.stopped ? STOP_MAX_AGE_MS : RECENT_SESSION_MS)) ||
|
|
714
|
+
expired(pending))
|
|
715
|
+
throw new AppError('suggestion_expired');
|
|
716
|
+
// Cut off by the system going down: it opens again from its transcript where it ran, unless
|
|
717
|
+
// it is open again already.
|
|
718
|
+
if (pending.reopening) {
|
|
719
|
+
const found = holder.configDir ? claudeSessionById(holder.configDir, pending.session.sessionId) : null;
|
|
720
|
+
if (!found || !interrupted.has(pending.reopening))
|
|
721
|
+
throw new AppError('suggestion_expired');
|
|
722
|
+
return { session: { ...pending.session, file: found.file, updatedAt: found.updatedAt, accountId: holder.id }, holder };
|
|
723
|
+
}
|
|
724
|
+
if (pending.stopped) {
|
|
725
|
+
const stopped = (await findStopped(holder)).find(stop => stop.sessionId === pending.session.sessionId && stop.stoppedAt === pending.stoppedAt);
|
|
726
|
+
if (!stopped || !sameSession(stopped, pending.session) || !fresh(stopped, STOP_MAX_AGE_MS))
|
|
727
|
+
throw new AppError('suggestion_expired');
|
|
728
|
+
return { session: { ...stopped, accountId: holder.id }, holder };
|
|
729
|
+
}
|
|
730
|
+
// A moved session goes back whenever its main account has room, however long it sat idle.
|
|
731
|
+
if (pending.returning) {
|
|
732
|
+
const written = claudeTranscriptWrite(pending.session.file);
|
|
733
|
+
if (!written || !moved.has(pending.returning))
|
|
734
|
+
throw new AppError('suggestion_expired');
|
|
735
|
+
return { session: { ...pending.session, updatedAt: writtenAt(written), accountId: holder.id }, holder };
|
|
736
|
+
}
|
|
737
|
+
const current = await recent(holder, RECENT_SESSION_MS, pending.session);
|
|
738
|
+
if (!current)
|
|
739
|
+
throw new AppError('suggestion_expired');
|
|
740
|
+
return { session: { ...current, accountId: holder.id }, holder };
|
|
741
|
+
}
|
|
742
|
+
async function acceptNow(id, snapshot, scope = 'tools') {
|
|
743
|
+
const pending = offers.get(id);
|
|
744
|
+
if (!pending)
|
|
745
|
+
throw new AppError('suggestion_expired');
|
|
746
|
+
try {
|
|
747
|
+
const { session, holder } = await sourceSession(pending, snapshot);
|
|
748
|
+
const models = modelsFor(snapshot, session);
|
|
749
|
+
const back = accountOf(snapshot, pending.suggestion.target.accountId);
|
|
750
|
+
// A session reopened goes where it was offered to, room or not: it ran there, and a limit
|
|
751
|
+
// it meets there is a stop like any other.
|
|
752
|
+
const to = pending.suggestion.kind === 'low'
|
|
753
|
+
? successor(snapshot, holder, session, scope, pending.stopped)
|
|
754
|
+
: back && (pending.reopening || recovered(snapshot, back.id, models[back.providerId]))
|
|
755
|
+
? { providerId: back.providerId, account: back, model: pending.reopening ? null : (models[back.providerId] ?? null) }
|
|
756
|
+
: null;
|
|
757
|
+
if (!to || (pending.suggestion.kind === 'low' && recovered(snapshot, holder.id, session.model)))
|
|
758
|
+
throw new AppError('no_verified_quota');
|
|
759
|
+
const record = recordOf(pending);
|
|
760
|
+
if (record)
|
|
761
|
+
record.target = {
|
|
762
|
+
providerId: to.providerId,
|
|
763
|
+
accountId: to.account?.id ?? null,
|
|
764
|
+
sessionId: movesWhole(session.providerId, to.providerId) ? session.sessionId : null
|
|
765
|
+
};
|
|
766
|
+
status(pending, 'dispatching');
|
|
767
|
+
// A durable intent must precede dispatch. A crash leaves an ambiguous operation that
|
|
768
|
+
// cannot be blindly retried. A failed write prevents launch altogether.
|
|
769
|
+
await save();
|
|
770
|
+
let result;
|
|
771
|
+
try {
|
|
772
|
+
result = await start(session, to, snapshot, launch, await moveOptions(pending, session, holder));
|
|
773
|
+
}
|
|
774
|
+
catch (error) {
|
|
775
|
+
status(pending, 'failed', errorCode(error));
|
|
776
|
+
await save();
|
|
777
|
+
throw error;
|
|
778
|
+
}
|
|
779
|
+
status(pending, 'dispatched');
|
|
780
|
+
offers.delete(id);
|
|
781
|
+
if (pending.reopening)
|
|
782
|
+
interrupted.delete(pending.reopening);
|
|
783
|
+
// The session is on its way: what else waited for it (a stop no one could take then, a
|
|
784
|
+
// running-low offer) could only start it a second time.
|
|
785
|
+
for (const [otherId, other] of offers)
|
|
786
|
+
if (sameSession(other.session, session)) {
|
|
787
|
+
status(other, 'dismissed');
|
|
788
|
+
offers.delete(otherId);
|
|
789
|
+
}
|
|
790
|
+
// A whole session is followed for the way back; work moved from a brief has no session
|
|
791
|
+
// to bring back.
|
|
792
|
+
if (result.resumed) {
|
|
793
|
+
recordMove(session, holder, to, result);
|
|
794
|
+
// Until it is seen running there, the move is an intent that went off, nothing more.
|
|
795
|
+
if (record && to.account?.configDir)
|
|
796
|
+
dispatched.set(record.id, {
|
|
797
|
+
sessionId: session.sessionId,
|
|
798
|
+
holderId: to.account.id,
|
|
799
|
+
file: carriedTranscript(session, to.account.configDir),
|
|
800
|
+
at: clock(),
|
|
801
|
+
...(isOneOf(ATTEMPTS, pending.suggestion.kind) ? { attempt: pending.suggestion.kind } : {})
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
// The work is running: a failed write must not report it as failed (the user could start
|
|
805
|
+
// it twice). The durable record stays `dispatching`, which a restart never replays.
|
|
806
|
+
await save().catch(() => undefined);
|
|
807
|
+
return result;
|
|
808
|
+
}
|
|
809
|
+
catch (error) {
|
|
810
|
+
// Still `dispatching` here means the intent could not be written, so nothing started.
|
|
811
|
+
if (recordOf(pending)?.status !== 'dispatched') {
|
|
812
|
+
// An offer whose session is gone is not a failure to retry: it is withdrawn.
|
|
813
|
+
if (error instanceof AppError && error.code === 'suggestion_expired') {
|
|
814
|
+
status(pending, 'dismissed');
|
|
815
|
+
offers.delete(id);
|
|
816
|
+
if (pending.reopening)
|
|
817
|
+
interrupted.delete(pending.reopening);
|
|
818
|
+
}
|
|
819
|
+
else
|
|
820
|
+
status(pending, 'failed', errorCode(error));
|
|
821
|
+
await save().catch(() => undefined);
|
|
822
|
+
}
|
|
823
|
+
throw error;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// Sessions of one account run out together, so several can stop between two refreshes.
|
|
827
|
+
async function onStopped(snapshot, account) {
|
|
828
|
+
if (!accountOf(snapshot, account.id))
|
|
829
|
+
return [];
|
|
830
|
+
const notices = [];
|
|
831
|
+
for (const session of await findStopped(account).catch(() => []))
|
|
832
|
+
if (fresh(session, STOP_MAX_AGE_MS))
|
|
833
|
+
notices.push(...(await onStop(snapshot, account, session)));
|
|
834
|
+
return notices;
|
|
835
|
+
}
|
|
836
|
+
async function onStop(snapshot, account, session) {
|
|
837
|
+
const key = keys.stop(account.id, session.sessionId, session.stoppedAt);
|
|
838
|
+
if (handled.has(key) || recovered(snapshot, account.id, session.model))
|
|
839
|
+
return [];
|
|
840
|
+
// Typed on where it left after it moved: the copy elsewhere goes on alone. And the copy
|
|
841
|
+
// carried here ends with the stop it was moved for until it goes on: that stop is the
|
|
842
|
+
// other account's, not this one's.
|
|
843
|
+
const went = moved.get(keys.session(session));
|
|
844
|
+
if (went?.from === account.id || (went?.to === account.id && session.stoppedAt <= went.at)) {
|
|
845
|
+
handled.add(key);
|
|
846
|
+
await save();
|
|
847
|
+
return [];
|
|
848
|
+
}
|
|
849
|
+
const id = suggestionIds.stop(account.id, session.sessionId);
|
|
850
|
+
const scope = stopScope(snapshot, session);
|
|
851
|
+
// Moved on its own, the session's process is ended first; not knowing whether it runs, the
|
|
852
|
+
// stop is left for the next look rather than marked handled.
|
|
853
|
+
if (scope !== 'off' && (await lookup(account, session)).kind === 'unknown')
|
|
854
|
+
return [];
|
|
855
|
+
const to = successor(snapshot, account, session, continueScope(scope), true);
|
|
856
|
+
// One stop owns one transition even if a low warning already offered it: that offer goes, so
|
|
857
|
+
// it cannot start the session a second time. One for another session of the account stays.
|
|
858
|
+
const previous = offers.get(suggestionIds.low(account.id));
|
|
859
|
+
if (previous && sameSession(previous.session, session)) {
|
|
860
|
+
status(previous, 'dismissed');
|
|
861
|
+
offers.delete(previous.suggestion.id);
|
|
862
|
+
}
|
|
863
|
+
if (!to) {
|
|
864
|
+
// Readings still coming in, or missing, say nothing about who could take it: the stop
|
|
865
|
+
// waits for the next look rather than being stranded on stale quota.
|
|
866
|
+
if (snapshot.refreshing || unmeasured(snapshot, account, continueScope(scope)))
|
|
867
|
+
return [];
|
|
868
|
+
handled.add(key);
|
|
869
|
+
// Stranded already by an earlier stop of the same session: one record, one word.
|
|
870
|
+
const already = offers.get(id);
|
|
871
|
+
if (already?.suggestion.target.accountId === null) {
|
|
872
|
+
already.stoppedAt = session.stoppedAt;
|
|
873
|
+
already.session = session;
|
|
874
|
+
await save();
|
|
875
|
+
return [];
|
|
876
|
+
}
|
|
877
|
+
const suggestion = offer({ session, holder: account, to: { providerId: account.providerId, account: null }, stopped: true, stoppedAt: session.stoppedAt }, 'low', account, 0, id);
|
|
878
|
+
const pending = offers.get(suggestion.id);
|
|
879
|
+
if (pending)
|
|
880
|
+
status(pending, 'failed', 'no_verified_quota');
|
|
881
|
+
await save();
|
|
882
|
+
return [{ kind: 'stranded', from: account }];
|
|
883
|
+
}
|
|
884
|
+
const suggestion = offer({ session, holder: account, to, stopped: true, stoppedAt: session.stoppedAt }, 'low', account, 0, id);
|
|
885
|
+
handled.add(key);
|
|
886
|
+
await save();
|
|
887
|
+
if (scope === 'off')
|
|
888
|
+
return [{ kind: 'suggested', suggestion }];
|
|
889
|
+
try {
|
|
890
|
+
const result = await acceptNow(suggestion.id, snapshot, scope);
|
|
891
|
+
return [{ kind: 'continued', from: account, to, result, projectDir: session.projectDir }];
|
|
892
|
+
}
|
|
893
|
+
catch (error) {
|
|
894
|
+
return [{ kind: 'failed', from: account, code: errorCode(error) }];
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
// How far a stop's work may move: a project's own setting wins over the global one, `off`
|
|
898
|
+
// included (offered then, within every tool: see continueScope).
|
|
899
|
+
function stopScope(snapshot, session) {
|
|
900
|
+
const own = projectPreference(snapshot.settings, session.projectDir)?.autoContinue ?? INHERIT;
|
|
901
|
+
return own === INHERIT ? snapshot.settings.autoContinue : own;
|
|
902
|
+
}
|
|
903
|
+
// A waiting offer says what holds now, not what held when it was made: the quota left on its
|
|
904
|
+
// account and, for work to move, who would take it, chosen the way the offer was made (a stop
|
|
905
|
+
// within its scope, running low within every tool). A stop no one could take waits in the
|
|
906
|
+
// history for an explicit retry and is not brought back as a card; with no one to take it now
|
|
907
|
+
// an offer keeps its last target, and accepting checks again.
|
|
908
|
+
function freshen(snapshot, pending) {
|
|
909
|
+
const left = headroomPercent(measuredOf(snapshot, pending.suggestion.accountId));
|
|
910
|
+
if (left !== null && !pending.stopped)
|
|
911
|
+
pending.suggestion.remainingPercent = left;
|
|
912
|
+
if (pending.suggestion.kind !== 'low' || pending.suggestion.target.accountId === null)
|
|
913
|
+
return;
|
|
914
|
+
const scope = pending.stopped ? stopScope(snapshot, pending.session) : 'tools';
|
|
915
|
+
const to = successor(snapshot, pending.holder, pending.session, continueScope(scope), pending.stopped);
|
|
916
|
+
// A stop no one in its scope can take any more is stranded again (no card, the history's retry).
|
|
917
|
+
if (!to && pending.stopped)
|
|
918
|
+
pending.suggestion.target = { providerId: pending.suggestion.providerId, accountId: null };
|
|
919
|
+
if (!to)
|
|
920
|
+
return;
|
|
921
|
+
pending.to = to;
|
|
922
|
+
pending.suggestion.target = { providerId: to.providerId, accountId: to.account?.id ?? null };
|
|
923
|
+
}
|
|
924
|
+
// A stop offered for the copy a session was moved with: the stop is the other account's (an
|
|
925
|
+
// offer made so before this was known, or kept from then, goes).
|
|
926
|
+
function carried(pending) {
|
|
927
|
+
const went = moved.get(keys.session(pending.session));
|
|
928
|
+
const { stoppedAt } = pending;
|
|
929
|
+
return pending.stopped && went?.to === pending.holder.id && stoppedAt !== undefined && stoppedAt <= went.at;
|
|
930
|
+
}
|
|
931
|
+
// A running-low offer whose session can no longer be found (deleted, or no longer the recent
|
|
932
|
+
// one) has nothing to continue; a stop's and a return's are checked when accepted.
|
|
933
|
+
const vanished = async (pending) => !pending.stopped && !followed(pending) && (await recent(pending.holder, RECENT_SESSION_MS, pending.session)) === null;
|
|
934
|
+
// A session started on another account was picked up once its process is seen there, or its
|
|
935
|
+
// copy written since; one seen neither way within a few minutes was not.
|
|
936
|
+
async function onDispatched(snapshot, id, dispatch) {
|
|
937
|
+
const holder = accountOf(snapshot, dispatch.holderId);
|
|
938
|
+
const found = holder ? await lookup(holder, { providerId: 'claude', sessionId: dispatch.sessionId }) : { kind: 'none' };
|
|
939
|
+
const written = claudeTranscriptWrite(dispatch.file);
|
|
940
|
+
const seen = found.kind === 'found' || (written !== null && written.lastRecordAt >= dispatch.at - SAVED_SLACK_MS);
|
|
941
|
+
if (seen) {
|
|
942
|
+
dispatched.delete(id);
|
|
943
|
+
return [];
|
|
944
|
+
}
|
|
945
|
+
// Not knowing whether it runs is not "not running": the look goes on, for a while.
|
|
946
|
+
const waited = clock() - dispatch.at;
|
|
947
|
+
if (waited < PICKUP_MAX_MS || (found.kind === 'unknown' && waited < PICKUP_UNKNOWN_MAX_MS))
|
|
948
|
+
return [];
|
|
949
|
+
dispatched.delete(id);
|
|
950
|
+
const code = found.kind === 'unknown' ? 'process_unknown' : 'not_picked_up';
|
|
951
|
+
const record = records.find(r => r.id === id);
|
|
952
|
+
if (record) {
|
|
953
|
+
record.status = 'failed';
|
|
954
|
+
record.errorCode = code;
|
|
955
|
+
record.updatedAt = clock();
|
|
956
|
+
}
|
|
957
|
+
const from = holder ? measuredOf(snapshot, holder.id) : null;
|
|
958
|
+
return from ? [{ kind: 'failed', from, code, ...(dispatch.attempt ? { attempt: dispatch.attempt } : {}) }] : [];
|
|
959
|
+
}
|
|
960
|
+
async function observeNow(snapshot, low) {
|
|
961
|
+
for (const [id, pending] of offers) {
|
|
962
|
+
const holder = accountOf(snapshot, pending.holder.id);
|
|
963
|
+
if (holder)
|
|
964
|
+
pending.holder = holder;
|
|
965
|
+
if ((!followed(pending) && !fresh(pending.session, pending.stopped ? STOP_MAX_AGE_MS : RECENT_SESSION_MS)) ||
|
|
966
|
+
!accountOf(snapshot, pending.holder.id) ||
|
|
967
|
+
(pending.suggestion.kind === 'low' && recovered(snapshot, pending.suggestion.accountId, pending.session.model)) ||
|
|
968
|
+
expired(pending) ||
|
|
969
|
+
carried(pending) ||
|
|
970
|
+
(await vanished(pending))) {
|
|
971
|
+
status(pending, 'dismissed');
|
|
972
|
+
offers.delete(id);
|
|
973
|
+
}
|
|
974
|
+
else
|
|
975
|
+
freshen(snapshot, pending);
|
|
976
|
+
}
|
|
977
|
+
await track(snapshot);
|
|
978
|
+
const notices = [];
|
|
979
|
+
for (const account of snapshot.providers.flatMap(p => p.accounts).filter(a => isProfileProvider(a.providerId)))
|
|
980
|
+
notices.push(...(await onStopped(snapshot, account)));
|
|
981
|
+
for (const { account, window, kind } of low) {
|
|
982
|
+
if (kind !== 'low' || offers.has(suggestionIds.low(account.id)) || !accountOf(snapshot, account.id))
|
|
983
|
+
continue;
|
|
984
|
+
const session = await recent(account, RECENT_SESSION_MS);
|
|
985
|
+
const to = session && successor(snapshot, account, session, 'tools', false);
|
|
986
|
+
if (session && to)
|
|
987
|
+
notices.push({
|
|
988
|
+
kind: 'suggested',
|
|
989
|
+
suggestion: offer({ session, holder: account, to, stopped: false }, 'low', account, window.remainingPercent)
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
for (const [id, dispatch] of [...dispatched])
|
|
993
|
+
notices.push(...(await onDispatched(snapshot, id, dispatch)));
|
|
994
|
+
for (const [key, entry] of [...interrupted])
|
|
995
|
+
notices.push(...(await onInterrupted(snapshot, key, entry)));
|
|
996
|
+
for (const [key, entry] of [...moved])
|
|
997
|
+
notices.push(...(await onReturn(snapshot, key, entry)));
|
|
998
|
+
// The notices describe what already happened; a failed write must not hide them.
|
|
999
|
+
await save().catch(() => undefined);
|
|
1000
|
+
return notices;
|
|
1001
|
+
}
|
|
1002
|
+
// The Claude Code sessions open now, across the tool's accounts; null when that cannot be told.
|
|
1003
|
+
async function openNow(snapshot) {
|
|
1004
|
+
const now = new Map();
|
|
1005
|
+
for (const account of snapshot.settings.accounts.filter(a => a.enabled && a.providerId === 'claude')) {
|
|
1006
|
+
let sessions;
|
|
1007
|
+
try {
|
|
1008
|
+
sessions = await findOpen(account);
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
for (const session of sessions)
|
|
1014
|
+
now.set(keys.session({ providerId: 'claude', sessionId: session.sessionId }), {
|
|
1015
|
+
sessionId: session.sessionId,
|
|
1016
|
+
accountId: account.id,
|
|
1017
|
+
cwd: session.cwd,
|
|
1018
|
+
busy: session.status === 'busy',
|
|
1019
|
+
at: clock()
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
return now;
|
|
1023
|
+
}
|
|
1024
|
+
// Follows which sessions are open. The first look of a run settles the last run: if it ended
|
|
1025
|
+
// without being quit, it went down with the system, and what it saw open and is gone now was
|
|
1026
|
+
// cut off. Later in a run, one gone was closed. One open again (by hand, anywhere) is no
|
|
1027
|
+
// longer to reopen.
|
|
1028
|
+
async function track(snapshot) {
|
|
1029
|
+
const now = await openNow(snapshot);
|
|
1030
|
+
// Not knowing what is open decides nothing: the last look stands.
|
|
1031
|
+
if (!now)
|
|
1032
|
+
return;
|
|
1033
|
+
if (previousQuit === false)
|
|
1034
|
+
for (const [key, entry] of open)
|
|
1035
|
+
if (!now.has(key) && !interrupted.has(key)) {
|
|
1036
|
+
// It went down around when it was last seen open, not when the app looked again: a
|
|
1037
|
+
// session cut off days ago is past reopening.
|
|
1038
|
+
interrupted.set(key, entry);
|
|
1039
|
+
// Its tab went with it.
|
|
1040
|
+
tabs.delete(key);
|
|
1041
|
+
}
|
|
1042
|
+
previousQuit = null;
|
|
1043
|
+
open = now;
|
|
1044
|
+
for (const key of [...interrupted.keys()])
|
|
1045
|
+
if (now.has(key))
|
|
1046
|
+
forget(key);
|
|
1047
|
+
}
|
|
1048
|
+
// A session no longer to reopen, and its offer.
|
|
1049
|
+
function forget(key) {
|
|
1050
|
+
interrupted.delete(key);
|
|
1051
|
+
const pending = offers.get(suggestionIds.reopen(key));
|
|
1052
|
+
if (pending) {
|
|
1053
|
+
status(pending, 'dismissed');
|
|
1054
|
+
offers.delete(pending.suggestion.id);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
// A session cut off by the system going down opens again, offered or on its own: on the
|
|
1058
|
+
// tool's main account when it had been moved off it and that account has room again (as
|
|
1059
|
+
// `returnToPrimary` allows), else where it ran.
|
|
1060
|
+
async function onInterrupted(snapshot, key, entry) {
|
|
1061
|
+
const setting = snapshot.settings.reopenAfterRestart;
|
|
1062
|
+
const holder = accountOf(snapshot, entry.accountId);
|
|
1063
|
+
const found = holder?.configDir ? claudeSessionById(holder.configDir, entry.sessionId) : null;
|
|
1064
|
+
if (setting === 'off' || !holder || !found || clock() - entry.at > REOPEN_SUGGESTION_MS) {
|
|
1065
|
+
forget(key);
|
|
1066
|
+
return [];
|
|
1067
|
+
}
|
|
1068
|
+
const id = suggestionIds.reopen(key);
|
|
1069
|
+
const offered = offers.get(id);
|
|
1070
|
+
// A failed reopening waits in the history for an explicit retry, like a failed stop.
|
|
1071
|
+
if (offered && (setting === 'ask' || recordOf(offered)?.status === 'failed'))
|
|
1072
|
+
return [];
|
|
1073
|
+
const went = moved.get(key);
|
|
1074
|
+
const home = went?.to === holder.id && snapshot.settings.returnToPrimary !== 'off' ? accountOf(snapshot, went.from) : null;
|
|
1075
|
+
const measured = measuredOf(snapshot, holder.id);
|
|
1076
|
+
// Readings still coming in say nothing yet about where it should go.
|
|
1077
|
+
if (snapshot.refreshing ||
|
|
1078
|
+
!measured ||
|
|
1079
|
+
measured.status === 'pending' ||
|
|
1080
|
+
(home && (measuredOf(snapshot, home.id)?.status ?? 'pending') === 'pending'))
|
|
1081
|
+
return [];
|
|
1082
|
+
const session = { ...found, projectDir: entry.cwd, accountId: holder.id };
|
|
1083
|
+
const target = home && recovered(snapshot, home.id, modelsFor(snapshot, session)[home.providerId]) ? home : holder;
|
|
1084
|
+
const to = { providerId: target.providerId, account: target };
|
|
1085
|
+
const suggestion = offered?.suggestion ??
|
|
1086
|
+
offer({ session, holder, to, stopped: false, reopening: key }, 'reopen', measured, headroomPercent(measuredOf(snapshot, target.id)) ?? 0, id);
|
|
1087
|
+
if (setting === 'ask')
|
|
1088
|
+
return [{ kind: 'suggested', suggestion }];
|
|
1089
|
+
try {
|
|
1090
|
+
const result = await acceptNow(suggestion.id, snapshot);
|
|
1091
|
+
const from = measuredOf(snapshot, target.id);
|
|
1092
|
+
return from ? [{ kind: 'reopened', from, to, result, projectDir: session.projectDir }] : [];
|
|
1093
|
+
}
|
|
1094
|
+
catch (error) {
|
|
1095
|
+
return [{ kind: 'failed', from: measured, code: errorCode(error), attempt: 'reopen' }];
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
// A Claude Code session moved off the tool's main account, which has room again: it goes
|
|
1099
|
+
// back, offered or on its own once it sits idle with nothing left running.
|
|
1100
|
+
async function onReturn(snapshot, key, entry) {
|
|
1101
|
+
const setting = snapshot.settings.returnToPrimary;
|
|
1102
|
+
const home = accountOf(snapshot, entry.from);
|
|
1103
|
+
const holder = accountOf(snapshot, entry.to);
|
|
1104
|
+
const measured = measuredOf(snapshot, entry.from);
|
|
1105
|
+
const { sessionId } = entry;
|
|
1106
|
+
const transcript = holder?.configDir
|
|
1107
|
+
? path.join(holder.configDir, CLAUDE_FILES.projects, entry.folder, `${sessionId}${JSON_LINES_EXTENSION}`)
|
|
1108
|
+
: null;
|
|
1109
|
+
const written = transcript ? claudeTranscriptWrite(transcript) : null;
|
|
1110
|
+
if (clock() - entry.at > MOVED_MAX_AGE_MS || !home || !holder || !transcript || !written) {
|
|
1111
|
+
moved.delete(key);
|
|
1112
|
+
return [];
|
|
1113
|
+
}
|
|
1114
|
+
if (setting === 'off' || !home.primary || !measured)
|
|
1115
|
+
return [];
|
|
1116
|
+
const session = {
|
|
1117
|
+
providerId: 'claude',
|
|
1118
|
+
sessionId,
|
|
1119
|
+
projectDir: entry.projectDir,
|
|
1120
|
+
updatedAt: writtenAt(written),
|
|
1121
|
+
file: transcript,
|
|
1122
|
+
accountId: holder.id
|
|
1123
|
+
};
|
|
1124
|
+
if (!recovered(snapshot, home.id, modelsFor(snapshot, session)[home.providerId]))
|
|
1125
|
+
return [];
|
|
1126
|
+
const id = suggestionIds.return(key);
|
|
1127
|
+
const to = { providerId: home.providerId, account: home };
|
|
1128
|
+
const ask = () => offer({ session, holder, to, stopped: false, returning: key }, 'return', measured, headroomPercent(measured) ?? 0, id);
|
|
1129
|
+
if (setting === 'ask')
|
|
1130
|
+
return offers.has(id) ? [] : [{ kind: 'suggested', suggestion: ask() }];
|
|
1131
|
+
const found = await lookup(holder, { providerId: 'claude', sessionId });
|
|
1132
|
+
// Not knowing is not "closed": the session is looked at again next time.
|
|
1133
|
+
if (found.kind === 'unknown')
|
|
1134
|
+
return [];
|
|
1135
|
+
// Closed where it ran: the user ended it there, and it is not reopened unasked. One gone with
|
|
1136
|
+
// the system waits to open again, and goes on from there.
|
|
1137
|
+
if (found.kind === 'none') {
|
|
1138
|
+
if (!interrupted.has(key))
|
|
1139
|
+
moved.delete(key);
|
|
1140
|
+
return [];
|
|
1141
|
+
}
|
|
1142
|
+
const { process } = found;
|
|
1143
|
+
// Quiet: waiting for the user, for a while (what the process says, else what its transcript shows).
|
|
1144
|
+
const quiet = waiting(process, transcript) &&
|
|
1145
|
+
(process.status === null || clock() - process.statusSince >= RETURN_QUIET_MS) &&
|
|
1146
|
+
Number.isFinite(written.lastRecordAt) &&
|
|
1147
|
+
clock() - written.lastRecordAt >= RETURN_QUIET_MS;
|
|
1148
|
+
// Busy again: the wait for its background commands starts over when it is next quiet.
|
|
1149
|
+
if (!quiet) {
|
|
1150
|
+
holding.delete(key);
|
|
1151
|
+
return [];
|
|
1152
|
+
}
|
|
1153
|
+
// Waiting for background commands helps only a session that can still act on them: one cut
|
|
1154
|
+
// off by a limit goes now and is told about them.
|
|
1155
|
+
const tasks = cutOff(transcript) ? [] : await openTasks(session, process);
|
|
1156
|
+
const since = holding.get(key) ?? clock();
|
|
1157
|
+
holding.set(key, since);
|
|
1158
|
+
if (tasks.length && clock() - since < RETURN_TASK_WAIT_MS)
|
|
1159
|
+
return [];
|
|
1160
|
+
holding.delete(key);
|
|
1161
|
+
// A failed return waits in the history for an explicit retry, like a failed stop.
|
|
1162
|
+
const offered = offers.get(id);
|
|
1163
|
+
if (offered && recordOf(offered)?.status === 'failed')
|
|
1164
|
+
return [];
|
|
1165
|
+
const suggestion = offered?.suggestion ?? ask();
|
|
1166
|
+
try {
|
|
1167
|
+
const result = await acceptNow(suggestion.id, snapshot);
|
|
1168
|
+
const from = measuredOf(snapshot, holder.id);
|
|
1169
|
+
return from ? [{ kind: 'returned', from, to, result, projectDir: session.projectDir }] : [];
|
|
1170
|
+
}
|
|
1171
|
+
catch (error) {
|
|
1172
|
+
return [{ kind: 'failed', from: measured, code: errorCode(error), attempt: 'return' }];
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
async function isRunning(id, snapshot) {
|
|
1176
|
+
const pending = offers.get(id);
|
|
1177
|
+
// A session to reopen is offered only while nothing runs it.
|
|
1178
|
+
if (!pending || pending.reopening)
|
|
1179
|
+
return false;
|
|
1180
|
+
if (snapshot) {
|
|
1181
|
+
const holder = accountOf(snapshot, pending.holder.id);
|
|
1182
|
+
if (!holder)
|
|
1183
|
+
return false;
|
|
1184
|
+
pending.holder = holder;
|
|
1185
|
+
}
|
|
1186
|
+
if (pending.session.providerId === 'claude') {
|
|
1187
|
+
const found = await lookup(pending.holder, pending.session);
|
|
1188
|
+
// Not knowing, the user is warned as if it ran.
|
|
1189
|
+
if (found.kind === 'unknown')
|
|
1190
|
+
return true;
|
|
1191
|
+
if (found.kind === 'found')
|
|
1192
|
+
return !pending.stopped && !waiting(found.process, pending.session.file);
|
|
1193
|
+
}
|
|
1194
|
+
if (pending.stopped) {
|
|
1195
|
+
const current = await findStopped(pending.holder).catch(() => []);
|
|
1196
|
+
if (current.some(stop => sameSession(stop, pending.session)))
|
|
1197
|
+
return false;
|
|
1198
|
+
}
|
|
1199
|
+
const current = await recent(pending.holder, RUNNING_SESSION_MS, pending.session);
|
|
1200
|
+
return current !== null;
|
|
1201
|
+
}
|
|
1202
|
+
return {
|
|
1203
|
+
observe: (snapshot, { low }) => serial(() => observeNow(snapshot, low)),
|
|
1204
|
+
// A stranded stop has no one to continue it: it waits in the history for an explicit retry
|
|
1205
|
+
// rather than as a card naming a target that has no quota.
|
|
1206
|
+
suggestions: () => [...offers.values()]
|
|
1207
|
+
.filter(o => o.suggestion.target.accountId !== null)
|
|
1208
|
+
.map(o => ({ ...o.suggestion, target: { ...o.suggestion.target } })),
|
|
1209
|
+
transitions: () => {
|
|
1210
|
+
const live = liveIds();
|
|
1211
|
+
return structuredClone(records).map(record => ({ ...record, retryable: record.status === 'failed' && live.has(record.id) }));
|
|
1212
|
+
},
|
|
1213
|
+
isRunning,
|
|
1214
|
+
accept: (id, snapshot) => serial(() => acceptNow(id, snapshot)),
|
|
1215
|
+
retry: (id, snapshot) => serial(() => {
|
|
1216
|
+
const record = records.find(r => r.id === id && r.status === 'failed');
|
|
1217
|
+
const pending = record && [...offers.values()].find(o => o.transitionId === record.id);
|
|
1218
|
+
if (!pending)
|
|
1219
|
+
throw new AppError('suggestion_expired');
|
|
1220
|
+
return acceptNow(pending.suggestion.id, snapshot);
|
|
1221
|
+
}),
|
|
1222
|
+
dismiss: id => {
|
|
1223
|
+
const pending = offers.get(id);
|
|
1224
|
+
if (pending) {
|
|
1225
|
+
status(pending, 'dismissed');
|
|
1226
|
+
offers.delete(id);
|
|
1227
|
+
if (pending.returning)
|
|
1228
|
+
moved.delete(pending.returning);
|
|
1229
|
+
if (pending.reopening)
|
|
1230
|
+
interrupted.delete(pending.reopening);
|
|
1231
|
+
void serial(save).catch(() => undefined);
|
|
1232
|
+
}
|
|
1233
|
+
},
|
|
1234
|
+
quit: () => {
|
|
1235
|
+
quitting = true;
|
|
1236
|
+
try {
|
|
1237
|
+
writeFileAtomicSync(file, serialize());
|
|
1238
|
+
}
|
|
1239
|
+
catch {
|
|
1240
|
+
// Unwritten, the next start takes the sessions gone by then as cut off: it offers them.
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
}
|