@sumicom/quicksave 0.6.1 → 0.6.3

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 (98) hide show
  1. package/dist/ai/cardBuilder.d.ts +14 -3
  2. package/dist/ai/cardBuilder.d.ts.map +1 -1
  3. package/dist/ai/cardBuilder.js +217 -66
  4. package/dist/ai/cardBuilder.js.map +1 -1
  5. package/dist/ai/claudeCliProvider.d.ts +2 -0
  6. package/dist/ai/claudeCliProvider.d.ts.map +1 -1
  7. package/dist/ai/claudeCliProvider.js +133 -23
  8. package/dist/ai/claudeCliProvider.js.map +1 -1
  9. package/dist/ai/claudeCodeProvider.d.ts +18 -0
  10. package/dist/ai/claudeCodeProvider.d.ts.map +1 -0
  11. package/dist/ai/claudeCodeProvider.js +30 -0
  12. package/dist/ai/claudeCodeProvider.js.map +1 -0
  13. package/dist/ai/claudeSdkProvider.d.ts +2 -0
  14. package/dist/ai/claudeSdkProvider.d.ts.map +1 -1
  15. package/dist/ai/claudeSdkProvider.js +13 -3
  16. package/dist/ai/claudeSdkProvider.js.map +1 -1
  17. package/dist/ai/codexMcpProvider.d.ts +15 -0
  18. package/dist/ai/codexMcpProvider.d.ts.map +1 -0
  19. package/dist/ai/codexMcpProvider.js +442 -0
  20. package/dist/ai/codexMcpProvider.js.map +1 -0
  21. package/dist/ai/codexSdkProvider.d.ts +35 -0
  22. package/dist/ai/codexSdkProvider.d.ts.map +1 -0
  23. package/dist/ai/codexSdkProvider.js +422 -0
  24. package/dist/ai/codexSdkProvider.js.map +1 -0
  25. package/dist/ai/debugLogger.d.ts +24 -0
  26. package/dist/ai/debugLogger.d.ts.map +1 -0
  27. package/dist/ai/debugLogger.js +54 -0
  28. package/dist/ai/debugLogger.js.map +1 -0
  29. package/dist/ai/provider.d.ts +4 -1
  30. package/dist/ai/provider.d.ts.map +1 -1
  31. package/dist/ai/sessionManager.d.ts +13 -8
  32. package/dist/ai/sessionManager.d.ts.map +1 -1
  33. package/dist/ai/sessionManager.js +150 -28
  34. package/dist/ai/sessionManager.js.map +1 -1
  35. package/dist/ai/sessionRegistry.d.ts +2 -0
  36. package/dist/ai/sessionRegistry.d.ts.map +1 -1
  37. package/dist/ai/sessionRegistry.js +4 -0
  38. package/dist/ai/sessionRegistry.js.map +1 -1
  39. package/dist/config.d.ts +1 -0
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/config.js +20 -10
  42. package/dist/config.js.map +1 -1
  43. package/dist/connection/connection.d.ts +1 -0
  44. package/dist/connection/connection.d.ts.map +1 -1
  45. package/dist/connection/connection.js +19 -1
  46. package/dist/connection/connection.js.map +1 -1
  47. package/dist/git/operations.d.ts +20 -0
  48. package/dist/git/operations.d.ts.map +1 -1
  49. package/dist/git/operations.js +52 -0
  50. package/dist/git/operations.js.map +1 -1
  51. package/dist/handlers/messageHandler.d.ts +18 -0
  52. package/dist/handlers/messageHandler.d.ts.map +1 -1
  53. package/dist/handlers/messageHandler.js +420 -20
  54. package/dist/handlers/messageHandler.js.map +1 -1
  55. package/dist/index.js +22 -2
  56. package/dist/index.js.map +1 -1
  57. package/dist/service/debugHttpServer.d.ts +27 -0
  58. package/dist/service/debugHttpServer.d.ts.map +1 -0
  59. package/dist/service/debugHttpServer.js +189 -0
  60. package/dist/service/debugHttpServer.js.map +1 -0
  61. package/dist/service/ipcClient.js +1 -1
  62. package/dist/service/run.d.ts.map +1 -1
  63. package/dist/service/run.js +21 -1
  64. package/dist/service/run.js.map +1 -1
  65. package/dist/service/singleton.d.ts +18 -1
  66. package/dist/service/singleton.d.ts.map +1 -1
  67. package/dist/service/singleton.js +43 -18
  68. package/dist/service/singleton.js.map +1 -1
  69. package/dist/service/types.js +1 -1
  70. package/package.json +5 -3
  71. package/dist/ai/asyncQueue.test.d.ts +0 -2
  72. package/dist/ai/asyncQueue.test.d.ts.map +0 -1
  73. package/dist/ai/asyncQueue.test.js +0 -56
  74. package/dist/ai/asyncQueue.test.js.map +0 -1
  75. package/dist/ai/sessionStore.test.d.ts +0 -2
  76. package/dist/ai/sessionStore.test.d.ts.map +0 -1
  77. package/dist/ai/sessionStore.test.js +0 -99
  78. package/dist/ai/sessionStore.test.js.map +0 -1
  79. package/dist/connection/pubsub.test.d.ts +0 -2
  80. package/dist/connection/pubsub.test.d.ts.map +0 -1
  81. package/dist/connection/pubsub.test.js +0 -96
  82. package/dist/connection/pubsub.test.js.map +0 -1
  83. package/dist/git/operations.test.d.ts +0 -2
  84. package/dist/git/operations.test.d.ts.map +0 -1
  85. package/dist/git/operations.test.js +0 -300
  86. package/dist/git/operations.test.js.map +0 -1
  87. package/dist/handlers/messageHandler.test.d.ts +0 -2
  88. package/dist/handlers/messageHandler.test.d.ts.map +0 -1
  89. package/dist/handlers/messageHandler.test.js +0 -361
  90. package/dist/handlers/messageHandler.test.js.map +0 -1
  91. package/dist/service/ipc.test.d.ts +0 -2
  92. package/dist/service/ipc.test.d.ts.map +0 -1
  93. package/dist/service/ipc.test.js +0 -140
  94. package/dist/service/ipc.test.js.map +0 -1
  95. package/dist/service/singleton.test.d.ts +0 -2
  96. package/dist/service/singleton.test.d.ts.map +0 -1
  97. package/dist/service/singleton.test.js +0 -39
  98. package/dist/service/singleton.test.js.map +0 -1
@@ -1,27 +1,34 @@
1
1
  import { createMessage, generateMessageId, } from '@sumicom/quicksave-shared';
2
2
  import { GitOperations } from '../git/operations.js';
3
- import { getAnthropicApiKey, setAnthropicApiKey, hasAnthropicApiKey, addManagedRepo, addManagedCodingPath } from '../config.js';
3
+ import { getAnthropicApiKey, setAnthropicApiKey, hasAnthropicApiKey, addManagedRepo, removeManagedRepo, addManagedCodingPath, removeManagedCodingPath } from '../config.js';
4
4
  import { CommitSummaryService } from '../ai/commitSummary.js';
5
5
  import { SessionManager } from '../ai/sessionManager.js';
6
- import { ClaudeCliProvider } from '../ai/claudeCliProvider.js';
7
- import { ClaudeSdkProvider } from '../ai/claudeSdkProvider.js';
6
+ import { ClaudeCodeProvider } from '../ai/claudeCodeProvider.js';
7
+ import { CodexSdkProvider } from '../ai/codexSdkProvider.js';
8
8
  import { getSessionRegistry } from '../ai/sessionRegistry.js';
9
- import { readdir, stat } from 'fs/promises';
9
+ import { readdir, stat, readFile } from 'fs/promises';
10
+ import { existsSync } from 'fs';
10
11
  import { join, dirname, basename } from 'path';
11
12
  import { homedir } from 'os';
12
13
  const VERSION_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12 hours
14
+ const CODEX_MODELS_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12 hours
13
15
  export class MessageHandler {
14
16
  repos;
15
- agentVersion = '0.6.1';
17
+ agentVersion = '0.6.3';
16
18
  defaultRepoPath;
17
19
  clientRepos = new Map(); // peerAddress -> repoPath
18
20
  repoLocks = new Map(); // repoPath -> peerAddress holding lock
19
21
  availableRepos;
20
22
  codingPaths = new Map(); // path -> CodingPath
21
23
  aiService = null;
22
- claudeService = new SessionManager(new ClaudeCliProvider(), new ClaudeSdkProvider());
24
+ claudeService = new SessionManager([
25
+ new ClaudeCodeProvider(),
26
+ new CodexSdkProvider(),
27
+ ]);
23
28
  latestVersionCache = null;
24
29
  versionCheckInFlight = null;
30
+ codexModelsCache = null;
31
+ codexModelsCheckInFlight = null;
25
32
  onPeerSubscribed;
26
33
  onPeerUnsubscribed;
27
34
  onHistoryUpdated;
@@ -77,6 +84,59 @@ export class MessageHandler {
77
84
  })();
78
85
  return this.versionCheckInFlight;
79
86
  }
87
+ /**
88
+ * Fetch available Codex models from OpenAI /v1/models. Reads API key from
89
+ * ~/.codex/auth.json (ChatGPT OAuth token or OPENAI_API_KEY). Cached for 12h
90
+ * with concurrent-request dedup — same pattern as checkLatestVersion.
91
+ */
92
+ async fetchCodexModels(force = false) {
93
+ if (!force && this.codexModelsCache &&
94
+ Date.now() - this.codexModelsCache.checkedAt < CODEX_MODELS_CHECK_INTERVAL_MS) {
95
+ return this.codexModelsCache.models;
96
+ }
97
+ if (this.codexModelsCheckInFlight)
98
+ return this.codexModelsCheckInFlight;
99
+ this.codexModelsCheckInFlight = (async () => {
100
+ try {
101
+ // Resolve API key: env var first, then ~/.codex/auth.json
102
+ let apiKey = process.env.OPENAI_API_KEY;
103
+ if (!apiKey) {
104
+ try {
105
+ const authPath = join(homedir(), '.codex', 'auth.json');
106
+ const raw = await readFile(authPath, 'utf-8');
107
+ const auth = JSON.parse(raw);
108
+ apiKey = auth.OPENAI_API_KEY || auth.tokens?.access_token;
109
+ }
110
+ catch { /* no auth file */ }
111
+ }
112
+ if (!apiKey)
113
+ return null;
114
+ const res = await fetch('https://api.openai.com/v1/models', {
115
+ headers: { Authorization: `Bearer ${apiKey}` },
116
+ signal: AbortSignal.timeout(10_000),
117
+ });
118
+ if (!res.ok)
119
+ return null;
120
+ const data = await res.json();
121
+ if (!Array.isArray(data.data))
122
+ return null;
123
+ // Filter to chat-capable models (gpt-*, o*) and sort descending
124
+ const models = data.data
125
+ .filter((m) => /^(gpt-|o\d)/.test(m.id))
126
+ .map((m) => ({ id: m.id, name: m.id }))
127
+ .sort((a, b) => b.id.localeCompare(a.id));
128
+ this.codexModelsCache = { models, checkedAt: Date.now() };
129
+ return models;
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ finally {
135
+ this.codexModelsCheckInFlight = null;
136
+ }
137
+ })();
138
+ return this.codexModelsCheckInFlight;
139
+ }
80
140
  addRepo(repo) {
81
141
  if (this.repos.has(repo.path))
82
142
  return;
@@ -181,6 +241,12 @@ export class MessageHandler {
181
241
  return this.handleDiscard(message, peerAddress);
182
242
  case 'git:untrack':
183
243
  return this.handleUntrack(message, peerAddress);
244
+ case 'git:submodules':
245
+ return this.handleSubmodules(message, peerAddress);
246
+ case 'git:config-get':
247
+ return this.handleGitConfigGet(message, peerAddress);
248
+ case 'git:config-set':
249
+ return this.handleGitConfigSet(message, peerAddress);
184
250
  case 'git:gitignore-add':
185
251
  return this.handleGitignoreAdd(message, peerAddress);
186
252
  case 'git:gitignore-read':
@@ -201,14 +267,24 @@ export class MessageHandler {
201
267
  return this.handleBrowseDirectory(message);
202
268
  case 'agent:add-repo':
203
269
  return this.handleAddRepo(message);
270
+ case 'agent:remove-repo':
271
+ return this.handleRemoveRepo(message);
272
+ case 'agent:clone-repo':
273
+ return this.handleCloneRepo(message);
204
274
  case 'agent:list-coding-paths':
205
275
  return this.handleListCodingPaths(message);
206
276
  case 'agent:add-coding-path':
207
277
  return this.handleAddCodingPath(message);
278
+ case 'agent:remove-coding-path':
279
+ return this.handleRemoveCodingPath(message);
208
280
  case 'agent:check-update':
209
281
  return this.handleAgentCheckUpdate(message);
210
282
  case 'agent:update':
211
283
  return this.handleAgentUpdate(message);
284
+ case 'agent:restart':
285
+ return this.handleAgentRestart(message);
286
+ case 'codex:list-models':
287
+ return this.handleCodexListModels(message);
212
288
  // Claude Code SDK
213
289
  case 'claude:list-sessions':
214
290
  return this.handleClaudeListSessions(message, peerAddress);
@@ -247,6 +323,10 @@ export class MessageHandler {
247
323
  return this.handleUpdateHistory(message);
248
324
  case 'session:delete-history':
249
325
  return this.handleDeleteHistory(message);
326
+ case 'project:list-summaries':
327
+ return this.handleListProjectSummaries(message);
328
+ case 'project:list-repos':
329
+ return await this.handleListProjectRepos(message);
250
330
  default:
251
331
  return this.createErrorResponse(message.id, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type: ${message.type}`);
252
332
  }
@@ -257,8 +337,9 @@ export class MessageHandler {
257
337
  }
258
338
  }
259
339
  handleHandshake(message, peerAddress) {
260
- // Fire-and-forget: check npm registry for latest version (12h dedup, production only)
340
+ // Fire-and-forget: check npm registry + OpenAI models (12h dedup each)
261
341
  this.checkLatestVersion().catch(() => { });
342
+ this.fetchCodexModels().catch(() => { });
262
343
  const response = createMessage('handshake:ack', {
263
344
  success: true,
264
345
  agentVersion: this.agentVersion,
@@ -268,6 +349,7 @@ export class MessageHandler {
268
349
  preferences: this.claudeService.getPreferences(),
269
350
  latestVersion: this.latestVersionCache?.version,
270
351
  devBuild: !this.productionBuild || undefined,
352
+ codexModels: this.codexModelsCache?.models,
271
353
  });
272
354
  response.id = message.id;
273
355
  return response;
@@ -528,6 +610,45 @@ export class MessageHandler {
528
610
  this.releaseRepoLock(repoPath, peerAddress);
529
611
  }
530
612
  }
613
+ async handleSubmodules(message, peerAddress) {
614
+ const git = this.getGit(peerAddress);
615
+ const subs = await git.getSubmodules();
616
+ // Auto-register submodule paths so switch-repo works
617
+ for (const sub of subs) {
618
+ if (!this.repos.has(sub.path)) {
619
+ this.repos.set(sub.path, new GitOperations(sub.path));
620
+ this.availableRepos.push({ path: sub.path, name: sub.name, currentBranch: sub.branch });
621
+ }
622
+ }
623
+ const response = createMessage('git:submodules:response', {
624
+ submodules: subs.map((s) => ({ name: s.name, path: s.path, branch: s.branch })),
625
+ });
626
+ response.id = message.id;
627
+ return response;
628
+ }
629
+ async handleGitConfigGet(message, peerAddress) {
630
+ const identity = await this.getGit(peerAddress).getIdentity();
631
+ const response = createMessage('git:config-get:response', identity);
632
+ response.id = message.id;
633
+ return response;
634
+ }
635
+ async handleGitConfigSet(message, peerAddress) {
636
+ try {
637
+ const { name, email } = message.payload;
638
+ await this.getGit(peerAddress).setIdentity(name, email);
639
+ const response = createMessage('git:config-set:response', { success: true });
640
+ response.id = message.id;
641
+ return response;
642
+ }
643
+ catch (error) {
644
+ const response = createMessage('git:config-set:response', {
645
+ success: false,
646
+ error: error instanceof Error ? error.message : 'Failed to set git identity',
647
+ });
648
+ response.id = message.id;
649
+ return response;
650
+ }
651
+ }
531
652
  async handleGitignoreAdd(message, peerAddress) {
532
653
  const repoPath = this.getClientRepoPath(peerAddress);
533
654
  if (!this.acquireRepoLock(repoPath, peerAddress)) {
@@ -829,6 +950,81 @@ export class MessageHandler {
829
950
  return response;
830
951
  }
831
952
  }
953
+ handleRemoveRepo(message) {
954
+ const { path: repoPath } = message.payload;
955
+ if (!this.repos.has(repoPath)) {
956
+ const response = createMessage('agent:remove-repo:response', {
957
+ success: false,
958
+ error: 'Repository not found',
959
+ });
960
+ response.id = message.id;
961
+ return response;
962
+ }
963
+ this.removeRepo(repoPath);
964
+ removeManagedRepo(repoPath);
965
+ const response = createMessage('agent:remove-repo:response', {
966
+ success: true,
967
+ });
968
+ response.id = message.id;
969
+ return response;
970
+ }
971
+ async handleCloneRepo(message) {
972
+ const { url, targetDir } = message.payload;
973
+ if (!url || !url.trim()) {
974
+ const response = createMessage('agent:clone-repo:response', {
975
+ success: false,
976
+ error: 'Repository URL is required',
977
+ });
978
+ response.id = message.id;
979
+ return response;
980
+ }
981
+ try {
982
+ const { execFile } = await import('child_process');
983
+ const { promisify } = await import('util');
984
+ const execFileAsync = promisify(execFile);
985
+ // Clone into the target directory
986
+ await execFileAsync('git', ['clone', url.trim(), targetDir], {
987
+ timeout: 120_000,
988
+ });
989
+ // Verify it's a valid git repo and add it
990
+ const git = new GitOperations(targetDir);
991
+ const { current } = await git.getBranches();
992
+ const rootPath = await git.getGitRoot();
993
+ // Check if already added (by resolved root path)
994
+ if (this.repos.has(rootPath)) {
995
+ const response = createMessage('agent:clone-repo:response', {
996
+ success: false,
997
+ error: 'Repository already added',
998
+ });
999
+ response.id = message.id;
1000
+ return response;
1001
+ }
1002
+ // Add to maps and persist
1003
+ this.repos.set(rootPath, git);
1004
+ const newRepo = {
1005
+ path: rootPath,
1006
+ name: basename(rootPath),
1007
+ currentBranch: current,
1008
+ };
1009
+ this.availableRepos.push(newRepo);
1010
+ addManagedRepo(rootPath);
1011
+ const response = createMessage('agent:clone-repo:response', {
1012
+ success: true,
1013
+ repo: newRepo,
1014
+ clonedPath: rootPath,
1015
+ });
1016
+ response.id = message.id;
1017
+ return response;
1018
+ }
1019
+ catch (error) {
1020
+ const response = createMessage('agent:clone-repo:response', {
1021
+ success: false,
1022
+ error: error instanceof Error ? error.message : 'Failed to clone repository',
1023
+ });
1024
+ response.id = message.id;
1025
+ return response;
1026
+ }
1027
+ }
832
1028
  // ============================================================================
833
1029
  // Coding Path Handlers
834
1030
  // ============================================================================
@@ -865,6 +1061,19 @@ export class MessageHandler {
865
1061
  return response;
866
1062
  }
867
1063
  }
1064
+ handleRemoveCodingPath(message) {
1065
+ const { path: codingPath } = message.payload;
1066
+ if (!this.codingPaths.has(codingPath)) {
1067
+ const response = createMessage('agent:remove-coding-path:response', { success: false, error: 'Coding path not found' });
1068
+ response.id = message.id;
1069
+ return response;
1070
+ }
1071
+ this.codingPaths.delete(codingPath);
1072
+ removeManagedCodingPath(codingPath);
1073
+ const response = createMessage('agent:remove-coding-path:response', { success: true });
1074
+ response.id = message.id;
1075
+ return response;
1076
+ }
868
1077
  // ============================================================================
869
1078
  // Agent Self-Update
870
1079
  // ============================================================================
@@ -944,6 +1153,50 @@ export class MessageHandler {
944
1153
  return response;
945
1154
  }
946
1155
  }
1156
+ async handleAgentRestart(message) {
1157
+ if (this.productionBuild) {
1158
+ const response = createMessage('agent:restart:response', { success: false, error: 'Restart is only available for dev builds' });
1159
+ response.id = message.id;
1160
+ return response;
1161
+ }
1162
+ try {
1163
+ const { spawn } = await import('child_process');
1164
+ const { fileURLToPath } = await import('url');
1165
+ const { resolve: resolvePath } = await import('path');
1166
+ const thisFile = fileURLToPath(import.meta.url);
1167
+ const isTs = thisFile.endsWith('.ts');
1168
+ const entryPath = resolvePath(dirname(thisFile), isTs ? '../index.ts' : '../../index.js');
1169
+ const agentRoot = resolvePath(dirname(thisFile), isTs ? '..' : '..');
1170
+ const node = process.execPath;
1171
+ const execArgv = isTs ? ['--import', 'tsx'] : [];
1172
+ spawn(node, [...execArgv, entryPath, '--restart'], {
1173
+ detached: true,
1174
+ stdio: 'ignore',
1175
+ cwd: agentRoot,
1176
+ }).unref();
1177
+ const response = createMessage('agent:restart:response', { success: true });
1178
+ response.id = message.id;
1179
+ return response;
1180
+ }
1181
+ catch (error) {
1182
+ const response = createMessage('agent:restart:response', { success: false, error: error instanceof Error ? error.message : 'Failed to restart agent' });
1183
+ response.id = message.id;
1184
+ return response;
1185
+ }
1186
+ }
1187
+ async handleCodexListModels(message) {
1188
+ try {
1189
+ const models = await this.fetchCodexModels(/* force */ true);
1190
+ const response = createMessage('codex:list-models:response', { models: models ?? [] });
1191
+ response.id = message.id;
1192
+ return response;
1193
+ }
1194
+ catch (error) {
1195
+ const response = createMessage('codex:list-models:response', { models: [], error: error instanceof Error ? error.message : 'Failed to fetch models' });
1196
+ response.id = message.id;
1197
+ return response;
1198
+ }
1199
+ }
947
1200
  // ============================================================================
948
1201
  // Claude Code SDK Handlers
949
1202
  // ============================================================================
@@ -962,27 +1215,41 @@ export class MessageHandler {
962
1215
  }
963
1216
  }
964
1217
  async handleClaudeStart(message, peerAddress) {
965
- const { prompt, cwd: payloadCwd, allowedTools, systemPrompt, model, permissionMode, sandboxed } = message.payload;
1218
+ const { prompt, cwd: payloadCwd, agent, allowedTools, systemPrompt, model, permissionMode, sandboxed } = message.payload;
1219
+ const legacyProvider = message.payload.provider;
966
1220
  const cwd = payloadCwd || this.getClientRepoPath(peerAddress);
967
1221
  const streamId = generateMessageId();
968
- console.log(`[claude:start] cwd=${cwd} prompt=${prompt.slice(0, 80)}${sandboxed ? ' [sandboxed]' : ''}`);
1222
+ const resolvedAgent = agent ?? (legacyProvider === 'codex-mcp' ? 'codex' : legacyProvider ? 'claude-code' : undefined);
1223
+ console.log(`[agent:start] agent=${resolvedAgent ?? 'default'} cwd=${cwd} prompt=${prompt.slice(0, 80)}${sandboxed ? ' [sandboxed]' : ''}`);
969
1224
  try {
970
1225
  const sessionId = await this.claudeService.startSession({
971
- prompt, cwd, streamId, allowedTools, systemPrompt, model, permissionMode, sandboxed,
1226
+ prompt,
1227
+ cwd,
1228
+ streamId,
1229
+ agent: resolvedAgent,
1230
+ allowedTools,
1231
+ systemPrompt,
1232
+ model,
1233
+ permissionMode,
1234
+ sandboxed,
972
1235
  });
973
- console.log(`[claude:start] session created: ${sessionId}`);
1236
+ console.log(`[agent:start] session created: ${sessionId} agent=${resolvedAgent ?? 'default'}`);
974
1237
  this.onPeerSubscribed?.(peerAddress, sessionId);
975
1238
  // Register in session history
976
1239
  const now = Date.now();
977
1240
  const registry = getSessionRegistry();
978
1241
  const gitBranch = await this.getGitBranchQuiet(cwd);
1242
+ const actualAgent = this.claudeService.getSessionAgent(sessionId, cwd);
979
1243
  registry.upsertEntry({
980
1244
  sessionId, cwd,
1245
+ agent: actualAgent,
981
1246
  repoName: basename(cwd),
982
1247
  gitBranch,
983
1248
  firstPrompt: prompt.slice(0, 100),
984
1249
  createdAt: now,
985
1250
  lastAccessedAt: now,
1251
+ permissionMode,
1252
+ sandboxed: sandboxed || undefined,
986
1253
  });
987
1254
  this.onHistoryUpdated?.(cwd, registry.getEntry(cwd, sessionId), 'upsert');
988
1255
  const response = createMessage('claude:start:response', { success: true, sessionId, streamId });
@@ -990,26 +1257,32 @@ export class MessageHandler {
990
1257
  return response;
991
1258
  }
992
1259
  catch (error) {
993
- console.error(`[claude:start] error:`, error);
1260
+ console.error(`[agent:start] error:`, error);
994
1261
  const response = createMessage('claude:start:response', { success: false, error: error instanceof Error ? error.message : 'Failed to start session' });
995
1262
  response.id = message.id;
996
1263
  return response;
997
1264
  }
998
1265
  }
999
1266
  async handleClaudeResume(message, peerAddress) {
1000
- const { sessionId: requestedId, prompt, cwd: payloadCwd } = message.payload;
1267
+ const { sessionId: requestedId, prompt, cwd: payloadCwd, agent } = message.payload;
1268
+ const legacyProvider = message.payload.provider;
1001
1269
  const cwd = payloadCwd || this.getClientRepoPath(peerAddress);
1002
1270
  const streamId = generateMessageId();
1003
- console.log(`[claude:resume] session=${requestedId} cwd=${cwd} prompt=${prompt.slice(0, 80)}`);
1271
+ const resolvedAgent = agent ?? (legacyProvider === 'codex-mcp' ? 'codex' : legacyProvider ? 'claude-code' : undefined);
1272
+ console.log(`[agent:resume] session=${requestedId} agent=${resolvedAgent ?? 'stored'} cwd=${cwd} prompt=${prompt.slice(0, 80)}`);
1004
1273
  // Subscribe before resumeSession starts streaming — hot resume emits events
1005
1274
  // immediately (user_message, then assistant turns), so we must be subscribed
1006
1275
  // before session.send() fires, not after resumeSession() returns.
1007
1276
  this.onPeerSubscribed?.(peerAddress, requestedId);
1008
1277
  try {
1009
1278
  const actualSessionId = await this.claudeService.resumeSession({
1010
- sessionId: requestedId, prompt, cwd, streamId,
1279
+ sessionId: requestedId,
1280
+ prompt,
1281
+ cwd,
1282
+ streamId,
1283
+ agent: resolvedAgent,
1011
1284
  });
1012
- console.log(`[claude:resume] session resumed: ${actualSessionId}`);
1285
+ console.log(`[agent:resume] session resumed: ${actualSessionId}`);
1013
1286
  // Re-subscribe with actual session ID (cold resume may return a different ID)
1014
1287
  if (actualSessionId !== requestedId) {
1015
1288
  this.onPeerSubscribed?.(peerAddress, actualSessionId);
@@ -1018,9 +1291,11 @@ export class MessageHandler {
1018
1291
  const registry = getSessionRegistry();
1019
1292
  const existing = registry.getEntry(cwd, requestedId) ?? registry.getEntry(cwd, actualSessionId);
1020
1293
  const gitBranch = await this.getGitBranchQuiet(cwd);
1294
+ const actualAgent = this.claudeService.getSessionAgent(actualSessionId, cwd);
1021
1295
  registry.upsertEntry({
1022
1296
  ...(existing ?? { sessionId: actualSessionId, cwd, repoName: basename(cwd), createdAt: Date.now() }),
1023
1297
  sessionId: actualSessionId,
1298
+ agent: actualAgent,
1024
1299
  lastAccessedAt: Date.now(),
1025
1300
  gitBranch,
1026
1301
  });
@@ -1030,7 +1305,7 @@ export class MessageHandler {
1030
1305
  return response;
1031
1306
  }
1032
1307
  catch (error) {
1033
- console.error(`[claude:resume] error:`, error);
1308
+ console.error(`[agent:resume] error:`, error);
1034
1309
  const response = createMessage('claude:resume:response', { success: false, error: error instanceof Error ? error.message : 'Failed to resume session' });
1035
1310
  response.id = message.id;
1036
1311
  return response;
@@ -1046,14 +1321,14 @@ export class MessageHandler {
1046
1321
  handleClaudeClose(message) {
1047
1322
  const { sessionId } = message.payload;
1048
1323
  const success = this.claudeService.closeSession(sessionId);
1049
- console.log(`[claude:close] session=${sessionId} success=${success}`);
1324
+ console.log(`[agent:close] session=${sessionId} success=${success}`);
1050
1325
  const response = createMessage('claude:close:response', { success, error: success ? undefined : 'Session not found or already closed' });
1051
1326
  response.id = message.id;
1052
1327
  return response;
1053
1328
  }
1054
1329
  handleClaudeUserInputResponse(message) {
1055
1330
  const resolved = this.claudeService.resolveUserInput(message.payload);
1056
- console.log(`[claude:user-input-response] requestId=${message.payload.requestId} action=${message.payload.action} resolved=${resolved}`);
1331
+ console.log(`[agent:user-input-response] requestId=${message.payload.requestId} action=${message.payload.action} resolved=${resolved}`);
1057
1332
  // No dedicated response type — just acknowledge
1058
1333
  const response = createMessage('claude:user-input-response', { success: resolved });
1059
1334
  response.id = message.id;
@@ -1122,7 +1397,8 @@ export class MessageHandler {
1122
1397
  // ── Session Registry (History) ──────────────────────────────────────
1123
1398
  handleListHistory(message, peerAddress) {
1124
1399
  const cwd = message.payload.cwd || this.getClientRepoPath(peerAddress);
1125
- const entries = getSessionRegistry().getEntriesForProject(cwd);
1400
+ const entries = getSessionRegistry().getEntriesForProject(cwd)
1401
+ .filter(e => !e.archived);
1126
1402
  const response = createMessage('session:list-history:response', { entries });
1127
1403
  response.id = message.id;
1128
1404
  return response;
@@ -1148,6 +1424,130 @@ export class MessageHandler {
1148
1424
  }
1149
1425
  return response;
1150
1426
  }
1427
+ handleListProjectSummaries(message) {
1428
+ const allEntries = getSessionRegistry().getEntriesForProject()
1429
+ .filter(e => !e.archived);
1430
+ // Group by cwd
1431
+ const byCwd = new Map();
1432
+ for (const entry of allEntries) {
1433
+ let group = byCwd.get(entry.cwd);
1434
+ if (!group) {
1435
+ group = [];
1436
+ byCwd.set(entry.cwd, group);
1437
+ }
1438
+ group.push(entry);
1439
+ }
1440
+ // Build active session set from session manager
1441
+ const activeSessions = this.claudeService.getActiveSessions();
1442
+ const activeCwds = new Set();
1443
+ for (const s of activeSessions) {
1444
+ activeCwds.add(s.cwd);
1445
+ }
1446
+ const projects = [];
1447
+ for (const [cwd, entries] of byCwd) {
1448
+ // entries are already sorted by lastAccessedAt desc from getEntriesForProject
1449
+ const latest = entries[0];
1450
+ projects.push({
1451
+ cwd,
1452
+ sessionCount: entries.length,
1453
+ lastActivityAt: latest.lastAccessedAt,
1454
+ lastSessionTitle: latest.title ?? latest.firstPrompt?.slice(0, 100),
1455
+ hasActiveSession: activeCwds.has(cwd),
1456
+ isGitRepo: existsSync(join(cwd, '.git')),
1457
+ });
1458
+ }
1459
+ // Sort by lastActivityAt desc
1460
+ projects.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
1461
+ const response = createMessage('project:list-summaries:response', { projects });
1462
+ response.id = message.id;
1463
+ return response;
1464
+ }
1465
+ async handleListProjectRepos(message) {
1466
+ const { cwd } = message.payload;
1467
+ const repos = [];
1468
+ const seen = new Set();
1469
+ try {
1470
+ // 1. Check if cwd itself is a git repo
1471
+ const rootBranch = await this.getGitBranchQuiet(cwd);
1472
+ if (rootBranch !== undefined) {
1473
+ repos.push({
1474
+ path: cwd,
1475
+ name: basename(cwd),
1476
+ currentBranch: rootBranch || undefined,
1477
+ });
1478
+ seen.add(cwd);
1479
+ }
1480
+ // 2. Find submodules via git
1481
+ if (rootBranch !== undefined) {
1482
+ try {
1483
+ const git = new GitOperations(cwd);
1484
+ const submodules = await git.getSubmodules();
1485
+ for (const sub of submodules) {
1486
+ const subPath = join(cwd, sub.path);
1487
+ if (seen.has(subPath))
1488
+ continue;
1489
+ seen.add(subPath);
1490
+ const branch = await this.getGitBranchQuiet(subPath);
1491
+ repos.push({
1492
+ path: subPath,
1493
+ name: sub.path,
1494
+ currentBranch: branch || undefined,
1495
+ isSubmodule: true,
1496
+ });
1497
+ }
1498
+ }
1499
+ catch {
1500
+ // git submodule may fail — ignore
1501
+ }
1502
+ }
1503
+ // 3. Scan for nested git repos (depth-limited, skips node_modules etc.)
1504
+ const MAX_DEPTH = 3;
1505
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'vendor', 'dist', 'build', '.next', '__pycache__']);
1506
+ const scan = async (dir, depth) => {
1507
+ if (depth > MAX_DEPTH)
1508
+ return;
1509
+ let names;
1510
+ try {
1511
+ names = await readdir(dir);
1512
+ }
1513
+ catch {
1514
+ return;
1515
+ }
1516
+ for (const name of names) {
1517
+ if (name.startsWith('.') || SKIP_DIRS.has(name))
1518
+ continue;
1519
+ const full = join(dir, name);
1520
+ try {
1521
+ const s = await stat(full);
1522
+ if (!s.isDirectory())
1523
+ continue;
1524
+ }
1525
+ catch {
1526
+ continue;
1527
+ }
1528
+ if (existsSync(join(full, '.git')) && !seen.has(full)) {
1529
+ seen.add(full);
1530
+ const branch = await this.getGitBranchQuiet(full);
1531
+ repos.push({
1532
+ path: full,
1533
+ name: full.slice(cwd.length + 1),
1534
+ currentBranch: branch || undefined,
1535
+ });
1536
+ }
1537
+ await scan(full, depth + 1);
1538
+ }
1539
+ };
1540
+ await scan(cwd, 0);
1541
+ }
1542
+ catch (error) {
1543
+ const response = createMessage('project:list-repos:response', { repos: [], error: error instanceof Error ? error.message : 'Failed to scan repos' });
1544
+ response.id = message.id;
1545
+ return response;
1546
+ }
1547
+ const response = createMessage('project:list-repos:response', { repos });
1548
+ response.id = message.id;
1549
+ return response;
1550
+ }
1151
1551
  async getGitBranchQuiet(cwd) {
1152
1552
  try {
1153
1553
  const git = new GitOperations(cwd);