@sumicom/quicksave 0.8.0 → 0.8.2
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/README.md +4 -0
- package/dist/ai/cardBuilder.d.ts +2 -0
- package/dist/ai/cardBuilder.d.ts.map +1 -1
- package/dist/ai/cardBuilder.js +62 -0
- package/dist/ai/cardBuilder.js.map +1 -1
- package/dist/ai/claudeCliProvider.d.ts +25 -1
- package/dist/ai/claudeCliProvider.d.ts.map +1 -1
- package/dist/ai/claudeCliProvider.js +77 -53
- package/dist/ai/claudeCliProvider.js.map +1 -1
- package/dist/ai/codexLogin.d.ts +57 -0
- package/dist/ai/codexLogin.d.ts.map +1 -0
- package/dist/ai/codexLogin.js +240 -0
- package/dist/ai/codexLogin.js.map +1 -0
- package/dist/ai/codexSdkProvider.d.ts.map +1 -1
- package/dist/ai/codexSdkProvider.js +66 -5
- package/dist/ai/codexSdkProvider.js.map +1 -1
- package/dist/ai/provider.d.ts +5 -0
- package/dist/ai/provider.d.ts.map +1 -1
- package/dist/ai/sessionManager.d.ts +6 -0
- package/dist/ai/sessionManager.d.ts.map +1 -1
- package/dist/ai/sessionManager.js +90 -4
- package/dist/ai/sessionManager.js.map +1 -1
- package/dist/files/fileBrowser.d.ts +25 -0
- package/dist/files/fileBrowser.d.ts.map +1 -0
- package/dist/files/fileBrowser.js +186 -0
- package/dist/files/fileBrowser.js.map +1 -0
- package/dist/handlers/messageHandler.d.ts +88 -8
- package/dist/handlers/messageHandler.d.ts.map +1 -1
- package/dist/handlers/messageHandler.js +411 -52
- package/dist/handlers/messageHandler.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/service/ipcClient.js +1 -1
- package/dist/service/run.d.ts.map +1 -1
- package/dist/service/run.js +57 -4
- package/dist/service/run.js.map +1 -1
- package/dist/service/types.js +1 -1
- package/dist/terminal/terminalManager.d.ts +48 -0
- package/dist/terminal/terminalManager.d.ts.map +1 -0
- package/dist/terminal/terminalManager.js +244 -0
- package/dist/terminal/terminalManager.js.map +1 -0
- package/package.json +4 -3
|
@@ -7,18 +7,53 @@ import { CommitSummaryStateStore } from '../ai/commitSummaryStore.js';
|
|
|
7
7
|
import { SessionManager } from '../ai/sessionManager.js';
|
|
8
8
|
import { ClaudeCodeProvider } from '../ai/claudeCodeProvider.js';
|
|
9
9
|
import { CodexSdkProvider } from '../ai/codexSdkProvider.js';
|
|
10
|
+
import { CodexLoginManager } from '../ai/codexLogin.js';
|
|
11
|
+
import { getTerminalManager } from '../terminal/terminalManager.js';
|
|
12
|
+
import { getFileBrowser } from '../files/fileBrowser.js';
|
|
10
13
|
import { getSessionRegistry } from '../ai/sessionRegistry.js';
|
|
11
14
|
import { enrichEntry } from '../ai/enrichEntry.js';
|
|
12
15
|
import { getEventStore } from '../storage/eventStore.js';
|
|
13
16
|
import { readdir, stat, readFile } from 'fs/promises';
|
|
14
|
-
import { existsSync } from 'fs';
|
|
17
|
+
import { existsSync, watch as fsWatch } from 'fs';
|
|
15
18
|
import { join, dirname, basename } from 'path';
|
|
16
19
|
import { homedir } from 'os';
|
|
17
20
|
const VERSION_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12 hours
|
|
18
|
-
|
|
21
|
+
// Local file read is essentially free + fs.watch invalidates on cache writes.
|
|
22
|
+
// 5 min is just a safety net for missed-event cases (NFS, container fs, etc.).
|
|
23
|
+
const CODEX_MODELS_FILE_TTL_MS = 5 * 60 * 1000;
|
|
24
|
+
// API path hits OpenAI; keep its own longer TTL to avoid hammering the
|
|
25
|
+
// endpoint when the user has no local Codex CLI cache file at all.
|
|
26
|
+
const CODEX_MODELS_API_TTL_MS = 60 * 60 * 1000;
|
|
27
|
+
// fs.watch on ~/.codex fires multiple events per write (rename + change on
|
|
28
|
+
// many filesystems). Coalesce into one refresh per burst.
|
|
29
|
+
const CODEX_MODELS_WATCH_DEBOUNCE_MS = 500;
|
|
30
|
+
function codexModelListsEqual(a, b) {
|
|
31
|
+
if (a === b)
|
|
32
|
+
return true;
|
|
33
|
+
if (!a || !b)
|
|
34
|
+
return false;
|
|
35
|
+
if (a.length !== b.length)
|
|
36
|
+
return false;
|
|
37
|
+
for (let i = 0; i < a.length; i++) {
|
|
38
|
+
if (a[i].id !== b[i].id || a[i].name !== b[i].name)
|
|
39
|
+
return false;
|
|
40
|
+
const aE = a[i].reasoningEfforts;
|
|
41
|
+
const bE = b[i].reasoningEfforts;
|
|
42
|
+
if (aE === bE)
|
|
43
|
+
continue;
|
|
44
|
+
if (!aE || !bE)
|
|
45
|
+
return false;
|
|
46
|
+
if (aE.length !== bE.length)
|
|
47
|
+
return false;
|
|
48
|
+
for (let j = 0; j < aE.length; j++)
|
|
49
|
+
if (aE[j] !== bE[j])
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
19
54
|
export class MessageHandler {
|
|
20
55
|
repos;
|
|
21
|
-
agentVersion = '0.8.
|
|
56
|
+
agentVersion = '0.8.2';
|
|
22
57
|
defaultRepoPath;
|
|
23
58
|
clientRepos = new Map(); // peerAddress -> repoPath
|
|
24
59
|
repoLocks = new Map(); // repoPath -> peerAddress holding lock
|
|
@@ -38,9 +73,15 @@ export class MessageHandler {
|
|
|
38
73
|
versionCheckInFlight = null;
|
|
39
74
|
codexModelsCache = null;
|
|
40
75
|
codexModelsCheckInFlight = null;
|
|
76
|
+
codexModelsUpdateHandler = null;
|
|
77
|
+
codexModelsWatcher = null;
|
|
78
|
+
codexModelsWatchDebounce = null;
|
|
79
|
+
/** Override for tests; defaults to `~/.codex`. */
|
|
80
|
+
codexCacheDir;
|
|
81
|
+
codexLoginManager = new CodexLoginManager();
|
|
41
82
|
onHistoryUpdated;
|
|
42
83
|
productionBuild;
|
|
43
|
-
constructor(repos, _license, codingPaths, productionBuild = false) {
|
|
84
|
+
constructor(repos, _license, codingPaths, productionBuild = false, options) {
|
|
44
85
|
this.repos = new Map();
|
|
45
86
|
for (const repo of repos) {
|
|
46
87
|
this.repos.set(repo.path, new GitOperations(repo.path));
|
|
@@ -48,6 +89,7 @@ export class MessageHandler {
|
|
|
48
89
|
this.availableRepos = repos;
|
|
49
90
|
this.defaultRepoPath = repos.length > 0 ? repos[0].path : '';
|
|
50
91
|
this.productionBuild = productionBuild;
|
|
92
|
+
this.codexCacheDir = options?.codexCacheDir ?? join(homedir(), '.codex');
|
|
51
93
|
// Load explicit coding paths only (repos and coding paths are independent)
|
|
52
94
|
if (codingPaths) {
|
|
53
95
|
for (const p of codingPaths) {
|
|
@@ -92,48 +134,46 @@ export class MessageHandler {
|
|
|
92
134
|
return this.versionCheckInFlight;
|
|
93
135
|
}
|
|
94
136
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
137
|
+
* Resolve the Codex model list for the current user.
|
|
138
|
+
*
|
|
139
|
+
* Priority:
|
|
140
|
+
* 1. `~/.codex/models_cache.json` — the Codex CLI's own cache of
|
|
141
|
+
* models the signed-in account can actually use. This is the
|
|
142
|
+
* authoritative list for ChatGPT-OAuth logins (the common case)
|
|
143
|
+
* and is kept fresh by the CLI itself. Reads are essentially free,
|
|
144
|
+
* so we use a short 5min in-memory TTL — and `startCodexModelsWatcher`
|
|
145
|
+
* invalidates the cache the moment the file is rewritten.
|
|
146
|
+
* 2. `OPENAI_API_KEY` env var + OpenAI `/v1/models` — fallback for
|
|
147
|
+
* API-key users, who are rare but still supported. Held to a 1h
|
|
148
|
+
* sub-TTL so we don't hammer OpenAI when the local cache is absent.
|
|
149
|
+
*
|
|
150
|
+
* Returns `null` when neither source is available (not logged in and no
|
|
151
|
+
* API key set). Concurrent calls are deduped.
|
|
98
152
|
*/
|
|
99
153
|
async fetchCodexModels(force = false) {
|
|
100
|
-
if (!force && this.codexModelsCache
|
|
101
|
-
|
|
102
|
-
|
|
154
|
+
if (!force && this.codexModelsCache) {
|
|
155
|
+
const ttl = this.codexModelsCache.source === 'file'
|
|
156
|
+
? CODEX_MODELS_FILE_TTL_MS
|
|
157
|
+
: CODEX_MODELS_API_TTL_MS;
|
|
158
|
+
if (Date.now() - this.codexModelsCache.checkedAt < ttl) {
|
|
159
|
+
return this.codexModelsCache.models;
|
|
160
|
+
}
|
|
103
161
|
}
|
|
104
162
|
if (this.codexModelsCheckInFlight)
|
|
105
163
|
return this.codexModelsCheckInFlight;
|
|
106
164
|
this.codexModelsCheckInFlight = (async () => {
|
|
107
165
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const authPath = join(homedir(), '.codex', 'auth.json');
|
|
113
|
-
const raw = await readFile(authPath, 'utf-8');
|
|
114
|
-
const auth = JSON.parse(raw);
|
|
115
|
-
apiKey = auth.OPENAI_API_KEY || auth.tokens?.access_token;
|
|
116
|
-
}
|
|
117
|
-
catch { /* no auth file */ }
|
|
166
|
+
const fromCache = await this.readCodexModelsCacheFile();
|
|
167
|
+
if (fromCache && fromCache.length > 0) {
|
|
168
|
+
this.codexModelsCache = { models: fromCache, checkedAt: Date.now(), source: 'file' };
|
|
169
|
+
return fromCache;
|
|
118
170
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (!res.ok)
|
|
126
|
-
return null;
|
|
127
|
-
const data = await res.json();
|
|
128
|
-
if (!Array.isArray(data.data))
|
|
129
|
-
return null;
|
|
130
|
-
// Filter to chat-capable models (gpt-*, o*) and sort descending
|
|
131
|
-
const models = data.data
|
|
132
|
-
.filter((m) => /^(gpt-|o\d)/.test(m.id))
|
|
133
|
-
.map((m) => ({ id: m.id, name: m.id }))
|
|
134
|
-
.sort((a, b) => b.id.localeCompare(a.id));
|
|
135
|
-
this.codexModelsCache = { models, checkedAt: Date.now() };
|
|
136
|
-
return models;
|
|
171
|
+
const fromApi = await this.fetchCodexModelsFromOpenAI();
|
|
172
|
+
if (fromApi && fromApi.length > 0) {
|
|
173
|
+
this.codexModelsCache = { models: fromApi, checkedAt: Date.now(), source: 'api' };
|
|
174
|
+
return fromApi;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
137
177
|
}
|
|
138
178
|
catch {
|
|
139
179
|
return null;
|
|
@@ -144,6 +184,141 @@ export class MessageHandler {
|
|
|
144
184
|
})();
|
|
145
185
|
return this.codexModelsCheckInFlight;
|
|
146
186
|
}
|
|
187
|
+
/** Snapshot accessor for the bus `/codex/models` subscription. */
|
|
188
|
+
getCachedCodexModels() {
|
|
189
|
+
return this.codexModelsCache?.models ?? [];
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Wire the daemon's bus broadcast for unsolicited model-list pushes.
|
|
193
|
+
* Called once during daemon boot; the watcher invokes this on cache change.
|
|
194
|
+
*/
|
|
195
|
+
setCodexModelsUpdateHandler(handler) {
|
|
196
|
+
this.codexModelsUpdateHandler = handler;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Watch the Codex CLI cache directory and refresh the in-memory model list
|
|
200
|
+
* the moment the CLI rewrites it (typically after a `codex` startup or
|
|
201
|
+
* upgrade). Tolerant of a missing `~/.codex` directory — falls back to TTL
|
|
202
|
+
* alone if the watch can't be set up. Idempotent.
|
|
203
|
+
*/
|
|
204
|
+
startCodexModelsWatcher() {
|
|
205
|
+
if (this.codexModelsWatcher)
|
|
206
|
+
return;
|
|
207
|
+
// Prime the in-memory cache so the first /codex/models subscriber gets a
|
|
208
|
+
// populated snapshot instead of an empty one (the handshake also fires
|
|
209
|
+
// this; dedup via codexModelsCheckInFlight makes it cheap).
|
|
210
|
+
void this.fetchCodexModels().catch(() => { });
|
|
211
|
+
try {
|
|
212
|
+
// Watch the directory rather than the file directly so we react to
|
|
213
|
+
// first-time creation too. Many editors / cli tools rewrite atomically
|
|
214
|
+
// (write tmp + rename), which fires events for the directory, not the
|
|
215
|
+
// old file inode.
|
|
216
|
+
this.codexModelsWatcher = fsWatch(this.codexCacheDir, (_event, filename) => {
|
|
217
|
+
if (filename && filename !== 'models_cache.json')
|
|
218
|
+
return;
|
|
219
|
+
this.scheduleCodexModelsRefresh();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Directory missing or unwatchable — quietly skip; the 5min TTL still
|
|
224
|
+
// catches the next refresh once a request comes in.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Tear down the fs.watch handle (test cleanup). */
|
|
228
|
+
stopCodexModelsWatcher() {
|
|
229
|
+
if (this.codexModelsWatchDebounce) {
|
|
230
|
+
clearTimeout(this.codexModelsWatchDebounce);
|
|
231
|
+
this.codexModelsWatchDebounce = null;
|
|
232
|
+
}
|
|
233
|
+
if (this.codexModelsWatcher) {
|
|
234
|
+
this.codexModelsWatcher.close();
|
|
235
|
+
this.codexModelsWatcher = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
scheduleCodexModelsRefresh() {
|
|
239
|
+
if (this.codexModelsWatchDebounce)
|
|
240
|
+
clearTimeout(this.codexModelsWatchDebounce);
|
|
241
|
+
this.codexModelsWatchDebounce = setTimeout(() => {
|
|
242
|
+
this.codexModelsWatchDebounce = null;
|
|
243
|
+
void this.refreshCodexModelsAndPublish();
|
|
244
|
+
}, CODEX_MODELS_WATCH_DEBOUNCE_MS);
|
|
245
|
+
}
|
|
246
|
+
async refreshCodexModelsAndPublish() {
|
|
247
|
+
const before = this.codexModelsCache?.models;
|
|
248
|
+
const after = await this.fetchCodexModels(/* force */ true);
|
|
249
|
+
if (!after)
|
|
250
|
+
return;
|
|
251
|
+
if (codexModelListsEqual(before, after))
|
|
252
|
+
return;
|
|
253
|
+
this.codexModelsUpdateHandler?.(after);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Read models from the Codex CLI's local cache. File shape (as of codex
|
|
257
|
+
* CLI v0.125): `{ models: [{ slug, display_name, visibility, supported_in_api, ... }] }`.
|
|
258
|
+
* We surface only entries that are listable and API-supported, so hidden
|
|
259
|
+
* internal models (e.g. `codex-auto-review`) don't leak into the picker.
|
|
260
|
+
*/
|
|
261
|
+
async readCodexModelsCacheFile() {
|
|
262
|
+
try {
|
|
263
|
+
const cachePath = join(this.codexCacheDir, 'models_cache.json');
|
|
264
|
+
const raw = await readFile(cachePath, 'utf-8');
|
|
265
|
+
const parsed = JSON.parse(raw);
|
|
266
|
+
if (!Array.isArray(parsed.models))
|
|
267
|
+
return null;
|
|
268
|
+
const models = parsed.models
|
|
269
|
+
.filter((m) => m.slug && m.visibility === 'list' && m.supported_in_api !== false)
|
|
270
|
+
.map((m) => ({ id: m.slug, name: m.display_name || m.slug }));
|
|
271
|
+
return models;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** Fallback for API-key users (no ChatGPT OAuth). */
|
|
278
|
+
async fetchCodexModelsFromOpenAI() {
|
|
279
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
280
|
+
if (!apiKey)
|
|
281
|
+
return null;
|
|
282
|
+
try {
|
|
283
|
+
const res = await fetch('https://api.openai.com/v1/models', {
|
|
284
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
285
|
+
signal: AbortSignal.timeout(10_000),
|
|
286
|
+
});
|
|
287
|
+
if (!res.ok)
|
|
288
|
+
return null;
|
|
289
|
+
const data = await res.json();
|
|
290
|
+
if (!Array.isArray(data.data))
|
|
291
|
+
return null;
|
|
292
|
+
return data.data
|
|
293
|
+
.filter((m) => /^(gpt-|o\d)/.test(m.id))
|
|
294
|
+
.map((m) => ({ id: m.id, name: m.id }))
|
|
295
|
+
.sort((a, b) => b.id.localeCompare(a.id));
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Best-effort check for whether the local Codex CLI has valid auth.
|
|
303
|
+
* Matches how `fetchCodexModels` resolves credentials, so the PWA can
|
|
304
|
+
* prompt for login when this returns false. Does not validate the
|
|
305
|
+
* token — just checks that a credential source exists.
|
|
306
|
+
*/
|
|
307
|
+
async getCodexLoginStatus() {
|
|
308
|
+
if (process.env.OPENAI_API_KEY)
|
|
309
|
+
return { loggedIn: true, method: 'api-key' };
|
|
310
|
+
try {
|
|
311
|
+
const authPath = join(this.codexCacheDir, 'auth.json');
|
|
312
|
+
const raw = await readFile(authPath, 'utf-8');
|
|
313
|
+
const auth = JSON.parse(raw);
|
|
314
|
+
if (auth.OPENAI_API_KEY)
|
|
315
|
+
return { loggedIn: true, method: 'api-key' };
|
|
316
|
+
if (auth.tokens?.access_token)
|
|
317
|
+
return { loggedIn: true, method: 'chatgpt' };
|
|
318
|
+
}
|
|
319
|
+
catch { /* no auth file */ }
|
|
320
|
+
return { loggedIn: false };
|
|
321
|
+
}
|
|
147
322
|
addRepo(repo) {
|
|
148
323
|
if (this.repos.has(repo.path))
|
|
149
324
|
return;
|
|
@@ -159,6 +334,12 @@ export class MessageHandler {
|
|
|
159
334
|
if (this.defaultRepoPath === path) {
|
|
160
335
|
this.defaultRepoPath = this.availableRepos.length > 0 ? this.availableRepos[0].path : '';
|
|
161
336
|
}
|
|
337
|
+
// Drop stale per-peer pins to this path so the next handshake-ack
|
|
338
|
+
// doesn't return a deleted repo.
|
|
339
|
+
for (const [peer, repo] of this.clientRepos) {
|
|
340
|
+
if (repo === path)
|
|
341
|
+
this.clientRepos.delete(peer);
|
|
342
|
+
}
|
|
162
343
|
}
|
|
163
344
|
getClientRepoPath(peerAddress) {
|
|
164
345
|
return this.clientRepos.get(peerAddress) || this.defaultRepoPath;
|
|
@@ -185,7 +366,14 @@ export class MessageHandler {
|
|
|
185
366
|
}
|
|
186
367
|
}
|
|
187
368
|
removeClient(peerAddress) {
|
|
188
|
-
|
|
369
|
+
// Intentionally keep `clientRepos[peer]`: peer identity is a stable
|
|
370
|
+
// public key, and the PWA keeps its selected repo across reconnects
|
|
371
|
+
// (e.g., background → resume). Clearing would force the next
|
|
372
|
+
// handshake-ack to return `defaultRepoPath`, which the PWA would then
|
|
373
|
+
// hydrate into gitStore before its post-reconnect `agent:switch-repo`
|
|
374
|
+
// can land — racing `git:status` requests stamped with the default
|
|
375
|
+
// path would pass the REPO_MISMATCH guard and paint the default
|
|
376
|
+
// repo's (typically clean) status over the user's real repo view.
|
|
189
377
|
for (const [repoPath, holder] of this.repoLocks) {
|
|
190
378
|
if (holder === peerAddress) {
|
|
191
379
|
this.repoLocks.delete(repoPath);
|
|
@@ -195,6 +383,7 @@ export class MessageHandler {
|
|
|
195
383
|
// They persist until the user reconnects and explicitly responds.
|
|
196
384
|
}
|
|
197
385
|
cleanup() {
|
|
386
|
+
this.stopCodexModelsWatcher();
|
|
198
387
|
this.claudeService.cleanup();
|
|
199
388
|
}
|
|
200
389
|
getActiveSessionCount() {
|
|
@@ -227,7 +416,9 @@ export class MessageHandler {
|
|
|
227
416
|
return this.aiService;
|
|
228
417
|
}
|
|
229
418
|
async handleMessage(message, peerAddress = 'default') {
|
|
230
|
-
const isVerbose = message.type.startsWith('claude:') ||
|
|
419
|
+
const isVerbose = message.type.startsWith('claude:') ||
|
|
420
|
+
message.type.startsWith('agent:add') ||
|
|
421
|
+
message.type.startsWith('project:');
|
|
231
422
|
if (isVerbose) {
|
|
232
423
|
console.log(`[msg] ${message.type} from ${peerAddress.slice(0, 12)}`);
|
|
233
424
|
}
|
|
@@ -330,6 +521,12 @@ export class MessageHandler {
|
|
|
330
521
|
return this.handleAgentRestart(message);
|
|
331
522
|
case 'codex:list-models':
|
|
332
523
|
return this.handleCodexListModels(message);
|
|
524
|
+
case 'codex:login-start':
|
|
525
|
+
return this.handleCodexLoginStart(message);
|
|
526
|
+
case 'codex:login-status':
|
|
527
|
+
return this.handleCodexLoginStatus(message);
|
|
528
|
+
case 'codex:login-cancel':
|
|
529
|
+
return this.handleCodexLoginCancel(message);
|
|
333
530
|
// Claude Code SDK
|
|
334
531
|
case 'claude:start':
|
|
335
532
|
return this.handleClaudeStart(message, peerAddress);
|
|
@@ -365,6 +562,20 @@ export class MessageHandler {
|
|
|
365
562
|
return this.handleDeleteProject(message);
|
|
366
563
|
case 'push:subscription-offer':
|
|
367
564
|
return this.handlePushSubscriptionOffer(message);
|
|
565
|
+
case 'terminal:create':
|
|
566
|
+
return this.handleTerminalCreate(message);
|
|
567
|
+
case 'terminal:input':
|
|
568
|
+
return this.handleTerminalInput(message);
|
|
569
|
+
case 'terminal:resize':
|
|
570
|
+
return this.handleTerminalResize(message);
|
|
571
|
+
case 'terminal:close':
|
|
572
|
+
return this.handleTerminalClose(message);
|
|
573
|
+
case 'terminal:rename':
|
|
574
|
+
return this.handleTerminalRename(message);
|
|
575
|
+
case 'files:list':
|
|
576
|
+
return this.handleFilesList(message);
|
|
577
|
+
case 'files:read':
|
|
578
|
+
return this.handleFilesRead(message);
|
|
368
579
|
default:
|
|
369
580
|
return this.createErrorResponse(message.id, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type: ${message.type}`);
|
|
370
581
|
}
|
|
@@ -1316,17 +1527,52 @@ export class MessageHandler {
|
|
|
1316
1527
|
}
|
|
1317
1528
|
async handleCodexListModels(message) {
|
|
1318
1529
|
try {
|
|
1319
|
-
const models = await
|
|
1320
|
-
|
|
1530
|
+
const [models, auth] = await Promise.all([
|
|
1531
|
+
this.fetchCodexModels(/* force */ true),
|
|
1532
|
+
this.getCodexLoginStatus(),
|
|
1533
|
+
]);
|
|
1534
|
+
const response = createMessage('codex:list-models:response', { models: models ?? [], loggedIn: auth.loggedIn });
|
|
1321
1535
|
response.id = message.id;
|
|
1322
1536
|
return response;
|
|
1323
1537
|
}
|
|
1324
1538
|
catch (error) {
|
|
1325
|
-
const response = createMessage('codex:list-models:response', {
|
|
1539
|
+
const response = createMessage('codex:list-models:response', {
|
|
1540
|
+
models: [],
|
|
1541
|
+
error: error instanceof Error ? error.message : 'Failed to fetch models',
|
|
1542
|
+
loggedIn: false,
|
|
1543
|
+
});
|
|
1326
1544
|
response.id = message.id;
|
|
1327
1545
|
return response;
|
|
1328
1546
|
}
|
|
1329
1547
|
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Kick off the Codex device-auth OAuth flow on the daemon. Returns the
|
|
1550
|
+
* one-time verification URL + user code so the PWA can display them — the
|
|
1551
|
+
* user typically completes this on a different device (e.g. phone).
|
|
1552
|
+
*/
|
|
1553
|
+
async handleCodexLoginStart(message) {
|
|
1554
|
+
const state = await this.codexLoginManager.start();
|
|
1555
|
+
const ok = state.loggedIn || Boolean(state.userCode && state.verificationUrl);
|
|
1556
|
+
const response = createMessage('codex:login-start:response', { ok, ...state });
|
|
1557
|
+
response.id = message.id;
|
|
1558
|
+
return response;
|
|
1559
|
+
}
|
|
1560
|
+
async handleCodexLoginStatus(message) {
|
|
1561
|
+
const state = await this.codexLoginManager.getStatus();
|
|
1562
|
+
const response = createMessage('codex:login-status:response', state);
|
|
1563
|
+
response.id = message.id;
|
|
1564
|
+
return response;
|
|
1565
|
+
}
|
|
1566
|
+
async handleCodexLoginCancel(message) {
|
|
1567
|
+
this.codexLoginManager.cancel();
|
|
1568
|
+
const response = createMessage('codex:login-cancel:response', { ok: true });
|
|
1569
|
+
response.id = message.id;
|
|
1570
|
+
return response;
|
|
1571
|
+
}
|
|
1572
|
+
/** Expose for the daemon to wire login-state updates into bus broadcasts. */
|
|
1573
|
+
getCodexLoginManager() {
|
|
1574
|
+
return this.codexLoginManager;
|
|
1575
|
+
}
|
|
1330
1576
|
// ============================================================================
|
|
1331
1577
|
// Claude Code SDK Handlers
|
|
1332
1578
|
// ============================================================================
|
|
@@ -1623,19 +1869,24 @@ export class MessageHandler {
|
|
|
1623
1869
|
const repos = [];
|
|
1624
1870
|
const seen = new Set();
|
|
1625
1871
|
try {
|
|
1626
|
-
// 1. Check if cwd itself is a git repo
|
|
1627
|
-
|
|
1628
|
-
|
|
1872
|
+
// 1. Check if cwd itself is a git repo. Use the cheap fs check rather
|
|
1873
|
+
// than spawning `git branch -a` so a transient git failure (EAGAIN
|
|
1874
|
+
// under daemon load, refs being rewritten by a concurrent fetch/gc,
|
|
1875
|
+
// detached HEAD returning empty `current`) doesn't drop the root
|
|
1876
|
+
// and make the PWA's Git section flash empty. Branch + dirty are
|
|
1877
|
+
// filled in best-effort below; undefined just means "unknown".
|
|
1878
|
+
const isRootRepo = existsSync(join(cwd, '.git'));
|
|
1879
|
+
if (isRootRepo) {
|
|
1629
1880
|
repos.push({
|
|
1630
1881
|
path: cwd,
|
|
1631
1882
|
name: basename(cwd),
|
|
1632
|
-
currentBranch:
|
|
1883
|
+
currentBranch: (await this.getGitBranchQuiet(cwd)) || undefined,
|
|
1633
1884
|
hasChanges: await this.getGitDirtyQuiet(cwd),
|
|
1634
1885
|
});
|
|
1635
1886
|
seen.add(cwd);
|
|
1636
1887
|
}
|
|
1637
1888
|
// 2. Find submodules via git
|
|
1638
|
-
if (
|
|
1889
|
+
if (isRootRepo) {
|
|
1639
1890
|
try {
|
|
1640
1891
|
const git = new GitOperations(cwd);
|
|
1641
1892
|
const submodules = await git.getSubmodules();
|
|
@@ -1708,15 +1959,27 @@ export class MessageHandler {
|
|
|
1708
1959
|
return response;
|
|
1709
1960
|
}
|
|
1710
1961
|
/**
|
|
1711
|
-
* Hide a project without touching the filesystem.
|
|
1712
|
-
* session under `cwd` (
|
|
1713
|
-
*
|
|
1962
|
+
* Hide a project without touching the filesystem. Closes every live
|
|
1963
|
+
* Claude session under `cwd` (so a stray resume can't re-materialize an
|
|
1964
|
+
* active registry entry — see note below), archives every registry entry
|
|
1965
|
+
* under `cwd` (each archive fires `/sessions/history` so PWAs drop the
|
|
1966
|
+
* sessions from their live view), and removes the cwd from managed
|
|
1714
1967
|
* coding paths so it stops surfacing as an empty workspace. Archived
|
|
1715
1968
|
* session JSON stays on disk and can be restored individually.
|
|
1969
|
+
*
|
|
1970
|
+
* Why close live sessions first: `handleClaudeResume` builds a fresh
|
|
1971
|
+
* active registry entry when `getEntry` returns undefined (archived
|
|
1972
|
+
* entries aren't in memory). Without this step, any subsequent resume
|
|
1973
|
+
* of a still-running session re-creates the active entry and the
|
|
1974
|
+
* project reappears on the next project:list-summaries.
|
|
1716
1975
|
*/
|
|
1717
1976
|
handleDeleteProject(message) {
|
|
1718
1977
|
const { cwd } = message.payload;
|
|
1719
1978
|
const registry = getSessionRegistry();
|
|
1979
|
+
const liveHere = this.claudeService.getActiveSessions().filter((s) => s.cwd === cwd);
|
|
1980
|
+
for (const s of liveHere) {
|
|
1981
|
+
this.claudeService.closeSession(s.sessionId);
|
|
1982
|
+
}
|
|
1720
1983
|
const active = registry.getEntriesForProject(cwd);
|
|
1721
1984
|
let archivedCount = 0;
|
|
1722
1985
|
for (const entry of active) {
|
|
@@ -1726,10 +1989,12 @@ export class MessageHandler {
|
|
|
1726
1989
|
this.onHistoryUpdated?.(cwd, updated, 'upsert');
|
|
1727
1990
|
}
|
|
1728
1991
|
}
|
|
1729
|
-
|
|
1992
|
+
const hadCodingPath = this.codingPaths.has(cwd);
|
|
1993
|
+
if (hadCodingPath) {
|
|
1730
1994
|
this.codingPaths.delete(cwd);
|
|
1731
1995
|
removeManagedCodingPath(cwd);
|
|
1732
1996
|
}
|
|
1997
|
+
console.log(`[project:delete] cwd=${cwd} closedSessions=${liveHere.length} archived=${archivedCount} removedCodingPath=${hadCodingPath}`);
|
|
1733
1998
|
const response = createMessage('project:delete:response', { success: true, archivedCount });
|
|
1734
1999
|
response.id = message.id;
|
|
1735
2000
|
return response;
|
|
@@ -1774,6 +2039,100 @@ export class MessageHandler {
|
|
|
1774
2039
|
response.id = message.id;
|
|
1775
2040
|
return response;
|
|
1776
2041
|
}
|
|
2042
|
+
// ── Terminal (PTY) handlers ──────────────────────────────────────────────
|
|
2043
|
+
async handleTerminalCreate(message) {
|
|
2044
|
+
let payload;
|
|
2045
|
+
try {
|
|
2046
|
+
const summary = await getTerminalManager().create(message.payload);
|
|
2047
|
+
payload = { success: true, terminal: summary };
|
|
2048
|
+
}
|
|
2049
|
+
catch (err) {
|
|
2050
|
+
payload = {
|
|
2051
|
+
success: false,
|
|
2052
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
const response = createMessage('terminal:create:response', payload);
|
|
2056
|
+
response.id = message.id;
|
|
2057
|
+
return response;
|
|
2058
|
+
}
|
|
2059
|
+
async handleTerminalInput(message) {
|
|
2060
|
+
let payload;
|
|
2061
|
+
try {
|
|
2062
|
+
getTerminalManager().write(message.payload.terminalId, message.payload.data);
|
|
2063
|
+
payload = { success: true };
|
|
2064
|
+
}
|
|
2065
|
+
catch (err) {
|
|
2066
|
+
payload = {
|
|
2067
|
+
success: false,
|
|
2068
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
const response = createMessage('terminal:input:response', payload);
|
|
2072
|
+
response.id = message.id;
|
|
2073
|
+
return response;
|
|
2074
|
+
}
|
|
2075
|
+
async handleTerminalResize(message) {
|
|
2076
|
+
let payload;
|
|
2077
|
+
try {
|
|
2078
|
+
getTerminalManager().resize(message.payload.terminalId, message.payload.cols, message.payload.rows);
|
|
2079
|
+
payload = { success: true };
|
|
2080
|
+
}
|
|
2081
|
+
catch (err) {
|
|
2082
|
+
payload = {
|
|
2083
|
+
success: false,
|
|
2084
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
const response = createMessage('terminal:resize:response', payload);
|
|
2088
|
+
response.id = message.id;
|
|
2089
|
+
return response;
|
|
2090
|
+
}
|
|
2091
|
+
async handleTerminalClose(message) {
|
|
2092
|
+
let payload;
|
|
2093
|
+
try {
|
|
2094
|
+
getTerminalManager().close(message.payload.terminalId, message.payload.force ?? false);
|
|
2095
|
+
payload = { success: true };
|
|
2096
|
+
}
|
|
2097
|
+
catch (err) {
|
|
2098
|
+
payload = {
|
|
2099
|
+
success: false,
|
|
2100
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
const response = createMessage('terminal:close:response', payload);
|
|
2104
|
+
response.id = message.id;
|
|
2105
|
+
return response;
|
|
2106
|
+
}
|
|
2107
|
+
async handleTerminalRename(message) {
|
|
2108
|
+
let payload;
|
|
2109
|
+
try {
|
|
2110
|
+
const summary = getTerminalManager().rename(message.payload.terminalId, message.payload.title);
|
|
2111
|
+
payload = { success: true, terminal: summary };
|
|
2112
|
+
}
|
|
2113
|
+
catch (err) {
|
|
2114
|
+
payload = {
|
|
2115
|
+
success: false,
|
|
2116
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
const response = createMessage('terminal:rename:response', payload);
|
|
2120
|
+
response.id = message.id;
|
|
2121
|
+
return response;
|
|
2122
|
+
}
|
|
2123
|
+
// ── File browser handlers ────────────────────────────────────────────────
|
|
2124
|
+
async handleFilesList(message) {
|
|
2125
|
+
const payload = await getFileBrowser().list(message.payload);
|
|
2126
|
+
const response = createMessage('files:list:response', payload);
|
|
2127
|
+
response.id = message.id;
|
|
2128
|
+
return response;
|
|
2129
|
+
}
|
|
2130
|
+
async handleFilesRead(message) {
|
|
2131
|
+
const payload = await getFileBrowser().read(message.payload);
|
|
2132
|
+
const response = createMessage('files:read:response', payload);
|
|
2133
|
+
response.id = message.id;
|
|
2134
|
+
return response;
|
|
2135
|
+
}
|
|
1777
2136
|
createErrorResponse(id, code, message) {
|
|
1778
2137
|
const response = createMessage('error', { code, message });
|
|
1779
2138
|
response.id = id;
|