@sumicom/quicksave 0.6.2 → 0.6.4

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 (66) hide show
  1. package/dist/ai/cardBuilder.d.ts +27 -0
  2. package/dist/ai/cardBuilder.d.ts.map +1 -1
  3. package/dist/ai/cardBuilder.js +72 -5
  4. package/dist/ai/cardBuilder.js.map +1 -1
  5. package/dist/ai/claudeCliProvider.d.ts +1 -0
  6. package/dist/ai/claudeCliProvider.d.ts.map +1 -1
  7. package/dist/ai/claudeCliProvider.js +187 -21
  8. package/dist/ai/claudeCliProvider.js.map +1 -1
  9. package/dist/ai/claudeSdkProvider.d.ts.map +1 -1
  10. package/dist/ai/claudeSdkProvider.js +17 -6
  11. package/dist/ai/claudeSdkProvider.js.map +1 -1
  12. package/dist/ai/commitSummary.d.ts.map +1 -1
  13. package/dist/ai/commitSummary.js +37 -9
  14. package/dist/ai/commitSummary.js.map +1 -1
  15. package/dist/ai/commitSummaryCli.d.ts +67 -0
  16. package/dist/ai/commitSummaryCli.d.ts.map +1 -0
  17. package/dist/ai/commitSummaryCli.js +416 -0
  18. package/dist/ai/commitSummaryCli.js.map +1 -0
  19. package/dist/ai/commitSummaryStore.d.ts +53 -0
  20. package/dist/ai/commitSummaryStore.d.ts.map +1 -0
  21. package/dist/ai/commitSummaryStore.js +136 -0
  22. package/dist/ai/commitSummaryStore.js.map +1 -0
  23. package/dist/ai/debugLogger.d.ts.map +1 -1
  24. package/dist/ai/debugLogger.js +3 -4
  25. package/dist/ai/debugLogger.js.map +1 -1
  26. package/dist/ai/provider.d.ts +7 -2
  27. package/dist/ai/provider.d.ts.map +1 -1
  28. package/dist/ai/sessionManager.d.ts +14 -2
  29. package/dist/ai/sessionManager.d.ts.map +1 -1
  30. package/dist/ai/sessionManager.js +143 -31
  31. package/dist/ai/sessionManager.js.map +1 -1
  32. package/dist/ai/sessionRegistry.d.ts +2 -0
  33. package/dist/ai/sessionRegistry.d.ts.map +1 -1
  34. package/dist/ai/sessionRegistry.js +4 -0
  35. package/dist/ai/sessionRegistry.js.map +1 -1
  36. package/dist/config.d.ts +4 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/config.js +23 -15
  39. package/dist/config.js.map +1 -1
  40. package/dist/handlers/messageHandler.d.ts +34 -0
  41. package/dist/handlers/messageHandler.d.ts.map +1 -1
  42. package/dist/handlers/messageHandler.js +513 -161
  43. package/dist/handlers/messageHandler.js.map +1 -1
  44. package/dist/index.js +3 -3
  45. package/dist/index.js.map +1 -1
  46. package/dist/service/ipcClient.js +1 -1
  47. package/dist/service/pushClient.d.ts +69 -0
  48. package/dist/service/pushClient.d.ts.map +1 -0
  49. package/dist/service/pushClient.js +119 -0
  50. package/dist/service/pushClient.js.map +1 -0
  51. package/dist/service/run.d.ts.map +1 -1
  52. package/dist/service/run.js +102 -15
  53. package/dist/service/run.js.map +1 -1
  54. package/dist/service/singleton.d.ts +18 -1
  55. package/dist/service/singleton.d.ts.map +1 -1
  56. package/dist/service/singleton.js +43 -18
  57. package/dist/service/singleton.js.map +1 -1
  58. package/dist/service/types.d.ts +1 -0
  59. package/dist/service/types.d.ts.map +1 -1
  60. package/dist/service/types.js +1 -1
  61. package/dist/service/types.js.map +1 -1
  62. package/dist/storage/eventStore.d.ts +65 -0
  63. package/dist/storage/eventStore.d.ts.map +1 -0
  64. package/dist/storage/eventStore.js +122 -0
  65. package/dist/storage/eventStore.js.map +1 -0
  66. package/package.json +6 -3
@@ -2,28 +2,37 @@ import { createMessage, generateMessageId, } from '@sumicom/quicksave-shared';
2
2
  import { GitOperations } from '../git/operations.js';
3
3
  import { getAnthropicApiKey, setAnthropicApiKey, hasAnthropicApiKey, addManagedRepo, removeManagedRepo, addManagedCodingPath, removeManagedCodingPath } from '../config.js';
4
4
  import { CommitSummaryService } from '../ai/commitSummary.js';
5
+ import { CommitSummaryCliService, CommitSummaryCliError } from '../ai/commitSummaryCli.js';
6
+ import { CommitSummaryStateStore } from '../ai/commitSummaryStore.js';
5
7
  import { SessionManager } from '../ai/sessionManager.js';
6
8
  import { ClaudeCodeProvider } from '../ai/claudeCodeProvider.js';
7
9
  import { CodexSdkProvider } from '../ai/codexSdkProvider.js';
8
10
  import { getSessionRegistry } from '../ai/sessionRegistry.js';
11
+ import { getEventStore } from '../storage/eventStore.js';
9
12
  import { readdir, stat, readFile } from 'fs/promises';
13
+ import { existsSync } from 'fs';
10
14
  import { join, dirname, basename } from 'path';
11
15
  import { homedir } from 'os';
12
16
  const VERSION_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12 hours
13
17
  const CODEX_MODELS_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12 hours
14
18
  export class MessageHandler {
15
19
  repos;
16
- agentVersion = '0.6.2';
20
+ agentVersion = '0.6.4';
17
21
  defaultRepoPath;
18
22
  clientRepos = new Map(); // peerAddress -> repoPath
19
23
  repoLocks = new Map(); // repoPath -> peerAddress holding lock
20
24
  availableRepos;
21
25
  codingPaths = new Map(); // path -> CodingPath
22
26
  aiService = null;
27
+ aiCliService = null;
28
+ /** Per-repo agent-owned commit summary state. The daemon wires the
29
+ * `state-updated` event to `connection.broadcast` (see service/run.ts). */
30
+ commitSummaryStore = new CommitSummaryStateStore();
23
31
  claudeService = new SessionManager([
24
32
  new ClaudeCodeProvider(),
25
33
  new CodexSdkProvider(),
26
34
  ]);
35
+ pushClient = null;
27
36
  latestVersionCache = null;
28
37
  versionCheckInFlight = null;
29
38
  codexModelsCache = null;
@@ -192,9 +201,22 @@ export class MessageHandler {
192
201
  getActiveSessionCount() {
193
202
  return this.claudeService.getActiveSessionCount();
194
203
  }
204
+ /** Exposed so the daemon can wire state-updated events to broadcasts. */
205
+ getCommitSummaryStore() {
206
+ return this.commitSummaryStore;
207
+ }
195
208
  getClaudeService() {
196
209
  return this.claudeService;
197
210
  }
211
+ /** Daemon wires this after construction so push:subscription-offer can
212
+ * forward to the relay. Absent in tests or when VAPID/signaling aren't
213
+ * configured — offer responses will report an error in that case. */
214
+ setPushClient(client) {
215
+ this.pushClient = client;
216
+ }
217
+ getPushClient() {
218
+ return this.pushClient;
219
+ }
198
220
  getAiService() {
199
221
  const apiKey = getAnthropicApiKey();
200
222
  if (!apiKey)
@@ -210,125 +232,155 @@ export class MessageHandler {
210
232
  if (isVerbose) {
211
233
  console.log(`[msg] ${message.type} from ${peerAddress.slice(0, 12)}`);
212
234
  }
213
- try {
214
- switch (message.type) {
215
- case 'handshake':
216
- return this.handleHandshake(message, peerAddress);
217
- case 'ping':
218
- return createMessage('pong', { timestamp: Date.now() });
219
- case 'git:status':
220
- return this.handleStatus(message, peerAddress);
221
- case 'git:diff':
222
- return this.handleDiff(message, peerAddress);
223
- case 'git:stage':
224
- return this.handleStage(message, peerAddress);
225
- case 'git:unstage':
226
- return this.handleUnstage(message, peerAddress);
227
- case 'git:stage-patch':
228
- return this.handleStagePatch(message, peerAddress);
229
- case 'git:unstage-patch':
230
- return this.handleUnstagePatch(message, peerAddress);
231
- case 'git:commit':
232
- return this.handleCommit(message, peerAddress);
233
- case 'git:log':
234
- return this.handleLog(message, peerAddress);
235
- case 'git:branches':
236
- return this.handleBranches(peerAddress);
237
- case 'git:checkout':
238
- return this.handleCheckout(message, peerAddress);
239
- case 'git:discard':
240
- return this.handleDiscard(message, peerAddress);
241
- case 'git:untrack':
242
- return this.handleUntrack(message, peerAddress);
243
- case 'git:submodules':
244
- return this.handleSubmodules(message, peerAddress);
245
- case 'git:config-get':
246
- return this.handleGitConfigGet(message, peerAddress);
247
- case 'git:config-set':
248
- return this.handleGitConfigSet(message, peerAddress);
249
- case 'git:gitignore-add':
250
- return this.handleGitignoreAdd(message, peerAddress);
251
- case 'git:gitignore-read':
252
- return this.handleGitignoreRead(message, peerAddress);
253
- case 'git:gitignore-write':
254
- return this.handleGitignoreWrite(message, peerAddress);
255
- case 'ai:generate-commit-summary':
256
- return this.handleGenerateCommitSummary(message, peerAddress);
257
- case 'ai:set-api-key':
258
- return this.handleSetApiKey(message);
259
- case 'ai:get-api-key-status':
260
- return this.handleGetApiKeyStatus(message);
261
- case 'agent:list-repos':
262
- return this.handleListRepos(message, peerAddress);
263
- case 'agent:switch-repo':
264
- return this.handleSwitchRepo(message, peerAddress);
265
- case 'agent:browse-directory':
266
- return this.handleBrowseDirectory(message);
267
- case 'agent:add-repo':
268
- return this.handleAddRepo(message);
269
- case 'agent:remove-repo':
270
- return this.handleRemoveRepo(message);
271
- case 'agent:clone-repo':
272
- return this.handleCloneRepo(message);
273
- case 'agent:list-coding-paths':
274
- return this.handleListCodingPaths(message);
275
- case 'agent:add-coding-path':
276
- return this.handleAddCodingPath(message);
277
- case 'agent:remove-coding-path':
278
- return this.handleRemoveCodingPath(message);
279
- case 'agent:check-update':
280
- return this.handleAgentCheckUpdate(message);
281
- case 'agent:update':
282
- return this.handleAgentUpdate(message);
283
- case 'agent:restart':
284
- return this.handleAgentRestart(message);
285
- case 'codex:list-models':
286
- return this.handleCodexListModels(message);
287
- // Claude Code SDK
288
- case 'claude:list-sessions':
289
- return this.handleClaudeListSessions(message, peerAddress);
290
- case 'claude:start':
291
- return this.handleClaudeStart(message, peerAddress);
292
- case 'claude:resume':
293
- return this.handleClaudeResume(message, peerAddress);
294
- case 'claude:cancel':
295
- return this.handleClaudeCancel(message);
296
- case 'claude:close':
297
- return this.handleClaudeClose(message);
298
- case 'claude:user-input-response':
299
- return this.handleClaudeUserInputResponse(message);
300
- case 'claude:get-preferences':
301
- return this.handleGetPreferences(message);
302
- case 'claude:set-preferences':
303
- return this.handleSetPreferences(message);
304
- case 'claude:set-session-permission':
305
- return this.handleSetSessionPermission(message);
306
- case 'claude:active-sessions':
307
- return this.handleGetActiveSessions(message);
308
- case 'claude:get-cards':
309
- return this.handleClaudeGetCards(message, peerAddress);
310
- case 'claude:unsubscribe': {
311
- const unsub = message.payload;
312
- this.onPeerUnsubscribed?.(peerAddress, unsub.sessionId);
313
- return createMessage('claude:unsubscribe:response', { ok: true });
314
- }
315
- case 'session:get-config':
316
- return this.handleGetSessionConfig(message);
317
- case 'session:set-config':
318
- return this.handleSetSessionConfig(message);
319
- case 'session:list-history':
320
- return this.handleListHistory(message, peerAddress);
321
- case 'session:update-history':
322
- return this.handleUpdateHistory(message);
323
- case 'session:delete-history':
324
- return this.handleDeleteHistory(message);
325
- default:
326
- return this.createErrorResponse(message.id, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type: ${message.type}`);
235
+ // Repo-scoped guard for git:* requests. The PWA stamps `repoPath` on
236
+ // each request so a response can't be misapplied if the user has since
237
+ // switched repos. If the envelope's repoPath differs from the peer's
238
+ // current repo, reject with REPO_MISMATCH so the client can discard
239
+ // rather than render stale data.
240
+ if (message.type.startsWith('git:') &&
241
+ !message.type.endsWith(':response') &&
242
+ typeof message.repoPath === 'string') {
243
+ const currentRepo = this.getClientRepoPath(peerAddress);
244
+ if (message.repoPath !== currentRepo) {
245
+ const err = this.createErrorResponse(message.id, 'REPO_MISMATCH', `Repo mismatch: request expected ${message.repoPath}, peer is on ${currentRepo}`);
246
+ err.repoPath = currentRepo;
247
+ return err;
327
248
  }
328
249
  }
329
- catch (error) {
330
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
331
- return this.createErrorResponse(message.id, 'HANDLER_ERROR', errorMessage);
250
+ const response = await this.dispatch(message, peerAddress);
251
+ // Stamp git:* responses with the repo the agent actually used so the
252
+ // PWA can validate before applying to the store.
253
+ if (response.type.startsWith('git:') && response.type.endsWith(':response')) {
254
+ response.repoPath = this.getClientRepoPath(peerAddress);
255
+ }
256
+ return response;
257
+ }
258
+ async dispatch(message, peerAddress) {
259
+ switch (message.type) {
260
+ case 'handshake':
261
+ return this.handleHandshake(message, peerAddress);
262
+ case 'ping':
263
+ return createMessage('pong', { timestamp: Date.now() });
264
+ case 'git:status':
265
+ return this.handleStatus(message, peerAddress);
266
+ case 'git:diff':
267
+ return this.handleDiff(message, peerAddress);
268
+ case 'git:stage':
269
+ return this.handleStage(message, peerAddress);
270
+ case 'git:unstage':
271
+ return this.handleUnstage(message, peerAddress);
272
+ case 'git:stage-patch':
273
+ return this.handleStagePatch(message, peerAddress);
274
+ case 'git:unstage-patch':
275
+ return this.handleUnstagePatch(message, peerAddress);
276
+ case 'git:commit':
277
+ return this.handleCommit(message, peerAddress);
278
+ case 'git:log':
279
+ return this.handleLog(message, peerAddress);
280
+ case 'git:branches':
281
+ return this.handleBranches(peerAddress);
282
+ case 'git:checkout':
283
+ return this.handleCheckout(message, peerAddress);
284
+ case 'git:discard':
285
+ return this.handleDiscard(message, peerAddress);
286
+ case 'git:untrack':
287
+ return this.handleUntrack(message, peerAddress);
288
+ case 'git:submodules':
289
+ return this.handleSubmodules(message, peerAddress);
290
+ case 'git:config-get':
291
+ return this.handleGitConfigGet(message, peerAddress);
292
+ case 'git:config-set':
293
+ return this.handleGitConfigSet(message, peerAddress);
294
+ case 'git:gitignore-add':
295
+ return this.handleGitignoreAdd(message, peerAddress);
296
+ case 'git:gitignore-read':
297
+ return this.handleGitignoreRead(message, peerAddress);
298
+ case 'git:gitignore-write':
299
+ return this.handleGitignoreWrite(message, peerAddress);
300
+ case 'ai:generate-commit-summary':
301
+ return this.handleGenerateCommitSummary(message, peerAddress);
302
+ case 'ai:commit-summary:get':
303
+ return this.handleGetCommitSummary(message, peerAddress);
304
+ case 'ai:commit-summary:clear':
305
+ return this.handleClearCommitSummary(message, peerAddress);
306
+ case 'ai:set-api-key':
307
+ return this.handleSetApiKey(message);
308
+ case 'ai:get-api-key-status':
309
+ return this.handleGetApiKeyStatus(message);
310
+ case 'agent:list-repos':
311
+ return this.handleListRepos(message, peerAddress);
312
+ case 'agent:switch-repo':
313
+ return this.handleSwitchRepo(message, peerAddress);
314
+ case 'agent:browse-directory':
315
+ return this.handleBrowseDirectory(message);
316
+ case 'agent:add-repo':
317
+ return this.handleAddRepo(message);
318
+ case 'agent:remove-repo':
319
+ return this.handleRemoveRepo(message);
320
+ case 'agent:clone-repo':
321
+ return this.handleCloneRepo(message);
322
+ case 'agent:list-coding-paths':
323
+ return this.handleListCodingPaths(message);
324
+ case 'agent:add-coding-path':
325
+ return this.handleAddCodingPath(message);
326
+ case 'agent:remove-coding-path':
327
+ return this.handleRemoveCodingPath(message);
328
+ case 'agent:check-update':
329
+ return this.handleAgentCheckUpdate(message);
330
+ case 'agent:update':
331
+ return this.handleAgentUpdate(message);
332
+ case 'agent:restart':
333
+ return this.handleAgentRestart(message);
334
+ case 'codex:list-models':
335
+ return this.handleCodexListModels(message);
336
+ // Claude Code SDK
337
+ case 'claude:list-sessions':
338
+ return this.handleClaudeListSessions(message, peerAddress);
339
+ case 'claude:start':
340
+ return this.handleClaudeStart(message, peerAddress);
341
+ case 'claude:resume':
342
+ return this.handleClaudeResume(message, peerAddress);
343
+ case 'claude:cancel':
344
+ return this.handleClaudeCancel(message);
345
+ case 'claude:close':
346
+ return this.handleClaudeClose(message);
347
+ case 'claude:user-input-response':
348
+ return this.handleClaudeUserInputResponse(message);
349
+ case 'claude:get-preferences':
350
+ return this.handleGetPreferences(message);
351
+ case 'claude:set-preferences':
352
+ return this.handleSetPreferences(message);
353
+ case 'claude:set-session-permission':
354
+ return this.handleSetSessionPermission(message);
355
+ case 'claude:active-sessions':
356
+ return this.handleGetActiveSessions(message);
357
+ case 'claude:get-cards':
358
+ return this.handleClaudeGetCards(message, peerAddress);
359
+ case 'claude:unsubscribe': {
360
+ const unsub = message.payload;
361
+ this.onPeerUnsubscribed?.(peerAddress, unsub.sessionId);
362
+ return createMessage('claude:unsubscribe:response', { ok: true });
363
+ }
364
+ case 'session:get-config':
365
+ return this.handleGetSessionConfig(message);
366
+ case 'session:set-config':
367
+ return this.handleSetSessionConfig(message);
368
+ case 'session:control-request':
369
+ return this.handleControlRequest(message);
370
+ case 'session:list-history':
371
+ return this.handleListHistory(message, peerAddress);
372
+ case 'session:update-history':
373
+ return this.handleUpdateHistory(message);
374
+ case 'session:delete-history':
375
+ return this.handleDeleteHistory(message);
376
+ case 'project:list-summaries':
377
+ return this.handleListProjectSummaries(message);
378
+ case 'project:list-repos':
379
+ return await this.handleListProjectRepos(message);
380
+ case 'push:subscription-offer':
381
+ return this.handlePushSubscriptionOffer(message);
382
+ default:
383
+ return this.createErrorResponse(message.id, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type: ${message.type}`);
332
384
  }
333
385
  }
334
386
  handleHandshake(message, peerAddress) {
@@ -487,6 +539,9 @@ export class MessageHandler {
487
539
  try {
488
540
  const { message: commitMessage, description, attribution } = message.payload;
489
541
  const hash = await this.getGit(peerAddress).commit(commitMessage, description, attribution ?? true);
542
+ // The pending AI suggestion describes the diff we just committed, so
543
+ // it's stale now — clear it (also broadcasts state → idle to all peers).
544
+ this.commitSummaryStore.clear(repoPath);
490
545
  const response = createMessage('git:commit:response', {
491
546
  success: true,
492
547
  hash,
@@ -706,69 +761,170 @@ export class MessageHandler {
706
761
  this.releaseRepoLock(repoPath, peerAddress);
707
762
  }
708
763
  }
764
+ /**
765
+ * Kick off AI commit-summary generation. Generation runs asynchronously and
766
+ * streams progress + result via the `ai:commit-summary:updated` broadcast;
767
+ * the request-response just confirms the kickoff (or reports a validation
768
+ * failure that keeps the state idle, e.g. missing API key / no staged changes).
769
+ */
709
770
  async handleGenerateCommitSummary(message, peerAddress) {
710
- const aiService = this.getAiService();
711
- if (!aiService) {
712
- const response = createMessage('ai:generate-commit-summary:response', {
713
- success: false,
714
- error: 'Configure your API key in Settings',
715
- errorCode: 'NO_API_KEY',
716
- });
717
- response.id = message.id;
718
- return response;
771
+ const source = message.payload.source ?? 'api';
772
+ const repoPath = this.getClientRepoPath(peerAddress);
773
+ const respond = (payload) => {
774
+ const r = createMessage('ai:generate-commit-summary:response', payload);
775
+ r.id = message.id;
776
+ return r;
777
+ };
778
+ // API source requires an Anthropic key; CLI source does not.
779
+ let aiService = null;
780
+ if (source === 'api') {
781
+ aiService = this.getAiService();
782
+ if (!aiService) {
783
+ return respond({
784
+ success: false,
785
+ error: 'Configure your API key in Settings',
786
+ errorCode: 'NO_API_KEY',
787
+ });
788
+ }
719
789
  }
790
+ // Validate staged changes exist before flipping the state into
791
+ // `generating` — we don't want to show "generating…" just to immediately
792
+ // error out on the same tick.
720
793
  try {
721
- const git = this.getGit(peerAddress);
722
- const status = await git.getStatus();
794
+ const status = await this.getGit(peerAddress).getStatus();
723
795
  if (status.staged.length === 0) {
724
- const response = createMessage('ai:generate-commit-summary:response', {
796
+ return respond({
725
797
  success: false,
726
798
  error: 'No staged changes to summarize',
727
799
  errorCode: 'NO_STAGED_CHANGES',
728
800
  });
729
- response.id = message.id;
730
- return response;
731
801
  }
732
- // Collect diffs and context in parallel
733
- const [diffs, recentLog, branchInfo, conventions] = await Promise.all([
734
- Promise.all(status.staged.map((file) => git.getDiff(file.path, true))),
802
+ }
803
+ catch (error) {
804
+ return respond({
805
+ success: false,
806
+ error: error instanceof Error ? error.message : 'Failed to inspect working tree',
807
+ errorCode: 'API_ERROR',
808
+ });
809
+ }
810
+ // Register the generation with the state store. The returned token lets
811
+ // us detect supersession (the caller fired off a fresh generation while
812
+ // this one was still running, or the state was cleared).
813
+ let aborted = false;
814
+ let cliChild;
815
+ const token = this.commitSummaryStore.startGenerating(repoPath, source, message.payload.model, () => {
816
+ aborted = true;
817
+ try {
818
+ cliChild?.kill('SIGTERM');
819
+ }
820
+ catch { /* ignore */ }
821
+ });
822
+ // Run generation asynchronously. Errors flow through the state store,
823
+ // NOT through the kickoff response — the response has already been sent.
824
+ void this.runCommitSummary(peerAddress, repoPath, token, source, message.payload, aiService, (child) => {
825
+ cliChild = child;
826
+ if (aborted) {
827
+ try {
828
+ child.kill('SIGTERM');
829
+ }
830
+ catch { /* ignore */ }
831
+ }
832
+ });
833
+ return respond({ success: true, state: this.commitSummaryStore.get(repoPath) });
834
+ }
835
+ /**
836
+ * Background worker that actually runs the generation. Reports success,
837
+ * progress, or failure through the CommitSummaryStateStore (which in turn
838
+ * broadcasts `ai:commit-summary:updated` to all peers).
839
+ */
840
+ async runCommitSummary(peerAddress, repoPath, token, source, payload, aiService, onSpawn) {
841
+ try {
842
+ const git = this.getGit(peerAddress);
843
+ const [recentLog, branchInfo, conventions] = await Promise.all([
735
844
  git.getLog(10).catch(() => []),
736
845
  git.getBranches().catch(() => ({ current: '' })),
737
846
  git.readCommitConventions().catch(() => undefined),
738
847
  ]);
739
848
  const recentCommits = recentLog.map((c) => c.message);
740
849
  const branchName = branchInfo.current;
741
- // Generate summary (uses internal queue and cache)
850
+ if (source === 'claude-cli') {
851
+ const cliService = this.getAiCliService();
852
+ const result = await cliService.generateSummary({
853
+ repoPath,
854
+ context: payload.context,
855
+ model: payload.model,
856
+ recentCommits: recentCommits.length > 0 ? recentCommits : undefined,
857
+ branchName: branchName || undefined,
858
+ conventions,
859
+ attribution: payload.attribution,
860
+ onProgress: (progress) => {
861
+ this.commitSummaryStore.updateProgress(repoPath, token, progress);
862
+ },
863
+ onSpawn: (child) => {
864
+ onSpawn(child);
865
+ },
866
+ });
867
+ this.commitSummaryStore.setResult(repoPath, token, {
868
+ summary: result.summary,
869
+ description: result.description,
870
+ tokenUsage: result.tokenUsage,
871
+ });
872
+ return;
873
+ }
874
+ // API path — fetch staged diffs fresh. No per-step progress today;
875
+ // the single Anthropic round-trip is fast enough that phase updates add
876
+ // little value. We still flip to `generating` so the UI shows a spinner.
877
+ this.commitSummaryStore.updateProgress(repoPath, token, { phase: 'generating' });
878
+ const status = await git.getStatus();
879
+ const diffs = await Promise.all(status.staged.map((file) => git.getDiff(file.path, true)));
742
880
  const result = await aiService.generateSummary({
743
881
  diffs,
744
- context: message.payload.context,
745
- model: message.payload.model,
882
+ context: payload.context,
883
+ model: payload.model,
746
884
  recentCommits: recentCommits.length > 0 ? recentCommits : undefined,
747
885
  branchName: branchName || undefined,
748
886
  conventions,
749
- attribution: message.payload.attribution,
887
+ attribution: payload.attribution,
750
888
  });
751
- const response = createMessage('ai:generate-commit-summary:response', {
752
- success: true,
889
+ this.commitSummaryStore.setResult(repoPath, token, {
753
890
  summary: result.summary,
754
891
  description: result.description,
755
892
  tokenUsage: result.tokenUsage,
756
893
  cached: result.cached,
757
894
  });
758
- response.id = message.id;
759
- return response;
760
895
  }
761
896
  catch (error) {
897
+ if (error instanceof CommitSummaryCliError) {
898
+ this.commitSummaryStore.setError(repoPath, token, error.message, error.errorCode);
899
+ return;
900
+ }
762
901
  const errorMessage = error instanceof Error ? error.message : 'Failed to generate summary';
763
902
  const isRateLimit = errorMessage.includes('rate_limit');
764
- const response = createMessage('ai:generate-commit-summary:response', {
765
- success: false,
766
- error: errorMessage,
767
- errorCode: isRateLimit ? 'RATE_LIMITED' : 'API_ERROR',
768
- });
769
- response.id = message.id;
770
- return response;
903
+ this.commitSummaryStore.setError(repoPath, token, errorMessage, isRateLimit ? 'RATE_LIMITED' : 'API_ERROR');
904
+ }
905
+ }
906
+ handleGetCommitSummary(message, peerAddress) {
907
+ const repoPath = message.payload.repoPath || this.getClientRepoPath(peerAddress);
908
+ const state = this.commitSummaryStore.get(repoPath);
909
+ const response = createMessage('ai:commit-summary:get:response', { state });
910
+ response.id = message.id;
911
+ return response;
912
+ }
913
+ handleClearCommitSummary(message, peerAddress) {
914
+ const repoPath = message.payload.repoPath || this.getClientRepoPath(peerAddress);
915
+ const state = this.commitSummaryStore.clear(repoPath);
916
+ const response = createMessage('ai:commit-summary:clear:response', {
917
+ success: true,
918
+ state,
919
+ });
920
+ response.id = message.id;
921
+ return response;
922
+ }
923
+ getAiCliService() {
924
+ if (!this.aiCliService) {
925
+ this.aiCliService = new CommitSummaryCliService();
771
926
  }
927
+ return this.aiCliService;
772
928
  }
773
929
  handleSetApiKey(message) {
774
930
  try {
@@ -1215,7 +1371,7 @@ export class MessageHandler {
1215
1371
  const cwd = payloadCwd || this.getClientRepoPath(peerAddress);
1216
1372
  const streamId = generateMessageId();
1217
1373
  const resolvedAgent = agent ?? (legacyProvider === 'codex-mcp' ? 'codex' : legacyProvider ? 'claude-code' : undefined);
1218
- console.log(`[agent:start] agent=${resolvedAgent ?? 'default'} cwd=${cwd} prompt=${prompt.slice(0, 80)}${sandboxed ? ' [sandboxed]' : ''}`);
1374
+ console.log(`[agent:start] agent=${resolvedAgent ?? 'default'} model=${model ?? 'default'} cwd=${cwd} prompt=${prompt.slice(0, 80)}${sandboxed ? ' [sandboxed]' : ''}`);
1219
1375
  try {
1220
1376
  const sessionId = await this.claudeService.startSession({
1221
1377
  prompt,
@@ -1232,6 +1388,13 @@ export class MessageHandler {
1232
1388
  this.onPeerSubscribed?.(peerAddress, sessionId);
1233
1389
  // Register in session history
1234
1390
  const now = Date.now();
1391
+ getEventStore().record({
1392
+ type: 'prompt_sent',
1393
+ sessionId,
1394
+ cwd,
1395
+ time: now,
1396
+ data: { kind: 'start', promptLength: prompt.length, model, agent: resolvedAgent },
1397
+ });
1235
1398
  const registry = getSessionRegistry();
1236
1399
  const gitBranch = await this.getGitBranchQuiet(cwd);
1237
1400
  const actualAgent = this.claudeService.getSessionAgent(sessionId, cwd);
@@ -1264,7 +1427,8 @@ export class MessageHandler {
1264
1427
  const cwd = payloadCwd || this.getClientRepoPath(peerAddress);
1265
1428
  const streamId = generateMessageId();
1266
1429
  const resolvedAgent = agent ?? (legacyProvider === 'codex-mcp' ? 'codex' : legacyProvider ? 'claude-code' : undefined);
1267
- console.log(`[agent:resume] session=${requestedId} agent=${resolvedAgent ?? 'stored'} cwd=${cwd} prompt=${prompt.slice(0, 80)}`);
1430
+ const activeCfg = this.claudeService.getSessionConfig(requestedId);
1431
+ console.log(`[agent:resume] session=${requestedId} agent=${resolvedAgent ?? 'stored'} model=${activeCfg.model ?? 'default'} cwd=${cwd} prompt=${prompt.slice(0, 80)}`);
1268
1432
  // Subscribe before resumeSession starts streaming — hot resume emits events
1269
1433
  // immediately (user_message, then assistant turns), so we must be subscribed
1270
1434
  // before session.send() fires, not after resumeSession() returns.
@@ -1282,6 +1446,12 @@ export class MessageHandler {
1282
1446
  if (actualSessionId !== requestedId) {
1283
1447
  this.onPeerSubscribed?.(peerAddress, actualSessionId);
1284
1448
  }
1449
+ getEventStore().record({
1450
+ type: 'prompt_sent',
1451
+ sessionId: actualSessionId,
1452
+ cwd,
1453
+ data: { kind: 'resume', promptLength: prompt.length, agent: resolvedAgent },
1454
+ });
1285
1455
  // Update session history
1286
1456
  const registry = getSessionRegistry();
1287
1457
  const existing = registry.getEntry(cwd, requestedId) ?? registry.getEntry(cwd, actualSessionId);
@@ -1309,6 +1479,9 @@ export class MessageHandler {
1309
1479
  async handleClaudeCancel(message) {
1310
1480
  const { sessionId } = message.payload;
1311
1481
  const success = await this.claudeService.cancelSession(sessionId);
1482
+ if (success) {
1483
+ getEventStore().record({ type: 'session_cancelled', sessionId });
1484
+ }
1312
1485
  const response = createMessage('claude:cancel:response', { success, error: success ? undefined : 'Session not found or already ended' });
1313
1486
  response.id = message.id;
1314
1487
  return response;
@@ -1361,10 +1534,18 @@ export class MessageHandler {
1361
1534
  response.id = message.id;
1362
1535
  return response;
1363
1536
  }
1364
- handleSetSessionPermission(message) {
1537
+ async handleSetSessionPermission(message) {
1365
1538
  const { sessionId, permissionMode } = message.payload;
1366
- const validModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
1367
- const success = validModes.includes(permissionMode) && this.claudeService.setPermissionLevel(sessionId, permissionMode);
1539
+ const validModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'auto'];
1540
+ let success = false;
1541
+ if (validModes.includes(permissionMode)) {
1542
+ try {
1543
+ success = await this.claudeService.setPermissionLevel(sessionId, permissionMode);
1544
+ }
1545
+ catch {
1546
+ success = false;
1547
+ }
1548
+ }
1368
1549
  const response = createMessage('claude:set-session-permission:response', { success, sessionId, permissionMode });
1369
1550
  response.id = message.id;
1370
1551
  return response;
@@ -1376,12 +1557,43 @@ export class MessageHandler {
1376
1557
  response.id = message.id;
1377
1558
  return response;
1378
1559
  }
1379
- handleSetSessionConfig(message) {
1560
+ async handleSetSessionConfig(message) {
1380
1561
  const { sessionId, key, value } = message.payload;
1381
- const config = this.claudeService.setSessionConfig(sessionId, key, value);
1382
- const response = createMessage('session:set-config:response', { success: true, sessionId, config });
1383
- response.id = message.id;
1384
- return response;
1562
+ console.log(`[agent:set-config] session=${sessionId.slice(0, 8)} ${key}=${String(value)}`);
1563
+ try {
1564
+ const config = await this.claudeService.setSessionConfig(sessionId, key, value);
1565
+ const response = createMessage('session:set-config:response', { success: true, sessionId, config });
1566
+ response.id = message.id;
1567
+ return response;
1568
+ }
1569
+ catch (err) {
1570
+ const config = this.claudeService.getSessionConfig(sessionId);
1571
+ const response = createMessage('session:set-config:response', {
1572
+ success: false,
1573
+ sessionId,
1574
+ config,
1575
+ error: err instanceof Error ? err.message : String(err),
1576
+ });
1577
+ response.id = message.id;
1578
+ return response;
1579
+ }
1580
+ }
1581
+ async handleControlRequest(message) {
1582
+ const { sessionId, subtype, params } = message.payload;
1583
+ console.log(`[agent:control-request] session=${sessionId.slice(0, 8)} subtype=${subtype} params=${JSON.stringify(params ?? {})}`);
1584
+ try {
1585
+ const result = await this.claudeService.sendControlRequest(sessionId, subtype, params);
1586
+ const response = createMessage('session:control-request:response', { success: true, sessionId, response: result });
1587
+ response.id = message.id;
1588
+ return response;
1589
+ }
1590
+ catch (error) {
1591
+ const errMsg = error instanceof Error ? error.message : String(error);
1592
+ console.error(`[agent:control-request] error:`, errMsg);
1593
+ const response = createMessage('session:control-request:response', { success: false, sessionId, error: errMsg });
1594
+ response.id = message.id;
1595
+ return response;
1596
+ }
1385
1597
  }
1386
1598
  handleGetActiveSessions(message) {
1387
1599
  const sessions = this.claudeService.getActiveSessions();
@@ -1392,7 +1604,8 @@ export class MessageHandler {
1392
1604
  // ── Session Registry (History) ──────────────────────────────────────
1393
1605
  handleListHistory(message, peerAddress) {
1394
1606
  const cwd = message.payload.cwd || this.getClientRepoPath(peerAddress);
1395
- const entries = getSessionRegistry().getEntriesForProject(cwd);
1607
+ const entries = getSessionRegistry().getEntriesForProject(cwd)
1608
+ .filter(e => !e.archived);
1396
1609
  const response = createMessage('session:list-history:response', { entries });
1397
1610
  response.id = message.id;
1398
1611
  return response;
@@ -1418,6 +1631,130 @@ export class MessageHandler {
1418
1631
  }
1419
1632
  return response;
1420
1633
  }
1634
+ handleListProjectSummaries(message) {
1635
+ const allEntries = getSessionRegistry().getEntriesForProject()
1636
+ .filter(e => !e.archived);
1637
+ // Group by cwd
1638
+ const byCwd = new Map();
1639
+ for (const entry of allEntries) {
1640
+ let group = byCwd.get(entry.cwd);
1641
+ if (!group) {
1642
+ group = [];
1643
+ byCwd.set(entry.cwd, group);
1644
+ }
1645
+ group.push(entry);
1646
+ }
1647
+ // Build active session set from session manager
1648
+ const activeSessions = this.claudeService.getActiveSessions();
1649
+ const activeCwds = new Set();
1650
+ for (const s of activeSessions) {
1651
+ activeCwds.add(s.cwd);
1652
+ }
1653
+ const projects = [];
1654
+ for (const [cwd, entries] of byCwd) {
1655
+ // entries are already sorted by lastAccessedAt desc from getEntriesForProject
1656
+ const latest = entries[0];
1657
+ projects.push({
1658
+ cwd,
1659
+ sessionCount: entries.length,
1660
+ lastActivityAt: latest.lastAccessedAt,
1661
+ lastSessionTitle: latest.title ?? latest.firstPrompt?.slice(0, 100),
1662
+ hasActiveSession: activeCwds.has(cwd),
1663
+ isGitRepo: existsSync(join(cwd, '.git')),
1664
+ });
1665
+ }
1666
+ // Sort by lastActivityAt desc
1667
+ projects.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
1668
+ const response = createMessage('project:list-summaries:response', { projects });
1669
+ response.id = message.id;
1670
+ return response;
1671
+ }
1672
+ async handleListProjectRepos(message) {
1673
+ const { cwd } = message.payload;
1674
+ const repos = [];
1675
+ const seen = new Set();
1676
+ try {
1677
+ // 1. Check if cwd itself is a git repo
1678
+ const rootBranch = await this.getGitBranchQuiet(cwd);
1679
+ if (rootBranch !== undefined) {
1680
+ repos.push({
1681
+ path: cwd,
1682
+ name: basename(cwd),
1683
+ currentBranch: rootBranch || undefined,
1684
+ });
1685
+ seen.add(cwd);
1686
+ }
1687
+ // 2. Find submodules via git
1688
+ if (rootBranch !== undefined) {
1689
+ try {
1690
+ const git = new GitOperations(cwd);
1691
+ const submodules = await git.getSubmodules();
1692
+ for (const sub of submodules) {
1693
+ const subPath = join(cwd, sub.path);
1694
+ if (seen.has(subPath))
1695
+ continue;
1696
+ seen.add(subPath);
1697
+ const branch = await this.getGitBranchQuiet(subPath);
1698
+ repos.push({
1699
+ path: subPath,
1700
+ name: sub.path,
1701
+ currentBranch: branch || undefined,
1702
+ isSubmodule: true,
1703
+ });
1704
+ }
1705
+ }
1706
+ catch {
1707
+ // git submodule may fail — ignore
1708
+ }
1709
+ }
1710
+ // 3. Scan for nested git repos (depth-limited, skips node_modules etc.)
1711
+ const MAX_DEPTH = 3;
1712
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'vendor', 'dist', 'build', '.next', '__pycache__']);
1713
+ const scan = async (dir, depth) => {
1714
+ if (depth > MAX_DEPTH)
1715
+ return;
1716
+ let names;
1717
+ try {
1718
+ names = await readdir(dir);
1719
+ }
1720
+ catch {
1721
+ return;
1722
+ }
1723
+ for (const name of names) {
1724
+ if (name.startsWith('.') || SKIP_DIRS.has(name))
1725
+ continue;
1726
+ const full = join(dir, name);
1727
+ try {
1728
+ const s = await stat(full);
1729
+ if (!s.isDirectory())
1730
+ continue;
1731
+ }
1732
+ catch {
1733
+ continue;
1734
+ }
1735
+ if (existsSync(join(full, '.git')) && !seen.has(full)) {
1736
+ seen.add(full);
1737
+ const branch = await this.getGitBranchQuiet(full);
1738
+ repos.push({
1739
+ path: full,
1740
+ name: full.slice(cwd.length + 1),
1741
+ currentBranch: branch || undefined,
1742
+ });
1743
+ }
1744
+ await scan(full, depth + 1);
1745
+ }
1746
+ };
1747
+ await scan(cwd, 0);
1748
+ }
1749
+ catch (error) {
1750
+ const response = createMessage('project:list-repos:response', { repos: [], error: error instanceof Error ? error.message : 'Failed to scan repos' });
1751
+ response.id = message.id;
1752
+ return response;
1753
+ }
1754
+ const response = createMessage('project:list-repos:response', { repos });
1755
+ response.id = message.id;
1756
+ return response;
1757
+ }
1421
1758
  async getGitBranchQuiet(cwd) {
1422
1759
  try {
1423
1760
  const git = new GitOperations(cwd);
@@ -1428,6 +1765,21 @@ export class MessageHandler {
1428
1765
  return undefined;
1429
1766
  }
1430
1767
  }
1768
+ async handlePushSubscriptionOffer(message) {
1769
+ const client = this.pushClient;
1770
+ let payload;
1771
+ if (!client) {
1772
+ payload = { success: false, error: 'push-not-configured' };
1773
+ }
1774
+ else {
1775
+ const { subscription, relayHttpUrl } = message.payload;
1776
+ const result = await client.register(subscription, relayHttpUrl);
1777
+ payload = result.ok ? { success: true } : { success: false, error: result.error ?? `http-${result.status}` };
1778
+ }
1779
+ const response = createMessage('push:subscription-offer:response', payload);
1780
+ response.id = message.id;
1781
+ return response;
1782
+ }
1431
1783
  createErrorResponse(id, code, message) {
1432
1784
  const response = createMessage('error', { code, message });
1433
1785
  response.id = id;