@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.
Files changed (41) hide show
  1. package/README.md +4 -0
  2. package/dist/ai/cardBuilder.d.ts +2 -0
  3. package/dist/ai/cardBuilder.d.ts.map +1 -1
  4. package/dist/ai/cardBuilder.js +62 -0
  5. package/dist/ai/cardBuilder.js.map +1 -1
  6. package/dist/ai/claudeCliProvider.d.ts +25 -1
  7. package/dist/ai/claudeCliProvider.d.ts.map +1 -1
  8. package/dist/ai/claudeCliProvider.js +77 -53
  9. package/dist/ai/claudeCliProvider.js.map +1 -1
  10. package/dist/ai/codexLogin.d.ts +57 -0
  11. package/dist/ai/codexLogin.d.ts.map +1 -0
  12. package/dist/ai/codexLogin.js +240 -0
  13. package/dist/ai/codexLogin.js.map +1 -0
  14. package/dist/ai/codexSdkProvider.d.ts.map +1 -1
  15. package/dist/ai/codexSdkProvider.js +66 -5
  16. package/dist/ai/codexSdkProvider.js.map +1 -1
  17. package/dist/ai/provider.d.ts +5 -0
  18. package/dist/ai/provider.d.ts.map +1 -1
  19. package/dist/ai/sessionManager.d.ts +6 -0
  20. package/dist/ai/sessionManager.d.ts.map +1 -1
  21. package/dist/ai/sessionManager.js +90 -4
  22. package/dist/ai/sessionManager.js.map +1 -1
  23. package/dist/files/fileBrowser.d.ts +25 -0
  24. package/dist/files/fileBrowser.d.ts.map +1 -0
  25. package/dist/files/fileBrowser.js +186 -0
  26. package/dist/files/fileBrowser.js.map +1 -0
  27. package/dist/handlers/messageHandler.d.ts +88 -8
  28. package/dist/handlers/messageHandler.d.ts.map +1 -1
  29. package/dist/handlers/messageHandler.js +411 -52
  30. package/dist/handlers/messageHandler.js.map +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/service/ipcClient.js +1 -1
  33. package/dist/service/run.d.ts.map +1 -1
  34. package/dist/service/run.js +57 -4
  35. package/dist/service/run.js.map +1 -1
  36. package/dist/service/types.js +1 -1
  37. package/dist/terminal/terminalManager.d.ts +48 -0
  38. package/dist/terminal/terminalManager.d.ts.map +1 -0
  39. package/dist/terminal/terminalManager.js +244 -0
  40. package/dist/terminal/terminalManager.js.map +1 -0
  41. 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
- const CODEX_MODELS_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12 hours
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.0';
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
- * Fetch available Codex models from OpenAI /v1/models. Reads API key from
96
- * ~/.codex/auth.json (ChatGPT OAuth token or OPENAI_API_KEY). Cached for 12h
97
- * with concurrent-request dedup — same pattern as checkLatestVersion.
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
- Date.now() - this.codexModelsCache.checkedAt < CODEX_MODELS_CHECK_INTERVAL_MS) {
102
- return this.codexModelsCache.models;
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
- // Resolve API key: env var first, then ~/.codex/auth.json
109
- let apiKey = process.env.OPENAI_API_KEY;
110
- if (!apiKey) {
111
- try {
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
- if (!apiKey)
120
- return null;
121
- const res = await fetch('https://api.openai.com/v1/models', {
122
- headers: { Authorization: `Bearer ${apiKey}` },
123
- signal: AbortSignal.timeout(10_000),
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
- this.clientRepos.delete(peerAddress);
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:') || message.type.startsWith('agent:add');
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 this.fetchCodexModels(/* force */ true);
1320
- const response = createMessage('codex:list-models:response', { models: models ?? [] });
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', { models: [], error: error instanceof Error ? error.message : 'Failed to fetch models' });
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
- const rootBranch = await this.getGitBranchQuiet(cwd);
1628
- if (rootBranch !== undefined) {
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: rootBranch || undefined,
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 (rootBranch !== undefined) {
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. Archives every active
1712
- * session under `cwd` (each archive fires `/sessions/history` so PWAs drop
1713
- * the sessions from their live view) and removes the cwd from managed
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
- if (this.codingPaths.has(cwd)) {
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;