@sumicom/quicksave 0.6.3 → 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 (55) 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 +69 -0
  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 +136 -19
  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/provider.d.ts +7 -2
  24. package/dist/ai/provider.d.ts.map +1 -1
  25. package/dist/ai/sessionManager.d.ts +14 -2
  26. package/dist/ai/sessionManager.d.ts.map +1 -1
  27. package/dist/ai/sessionManager.js +141 -30
  28. package/dist/ai/sessionManager.js.map +1 -1
  29. package/dist/config.d.ts +4 -0
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +15 -5
  32. package/dist/config.js.map +1 -1
  33. package/dist/handlers/messageHandler.d.ts +32 -0
  34. package/dist/handlers/messageHandler.d.ts.map +1 -1
  35. package/dist/handlers/messageHandler.js +386 -164
  36. package/dist/handlers/messageHandler.js.map +1 -1
  37. package/dist/index.js +3 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/service/ipcClient.js +1 -1
  40. package/dist/service/pushClient.d.ts +69 -0
  41. package/dist/service/pushClient.d.ts.map +1 -0
  42. package/dist/service/pushClient.js +119 -0
  43. package/dist/service/pushClient.js.map +1 -0
  44. package/dist/service/run.d.ts.map +1 -1
  45. package/dist/service/run.js +102 -15
  46. package/dist/service/run.js.map +1 -1
  47. package/dist/service/types.d.ts +1 -0
  48. package/dist/service/types.d.ts.map +1 -1
  49. package/dist/service/types.js +1 -1
  50. package/dist/service/types.js.map +1 -1
  51. package/dist/storage/eventStore.d.ts +65 -0
  52. package/dist/storage/eventStore.d.ts.map +1 -0
  53. package/dist/storage/eventStore.js +122 -0
  54. package/dist/storage/eventStore.js.map +1 -0
  55. package/package.json +5 -2
@@ -2,10 +2,13 @@ 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';
10
13
  import { existsSync } from 'fs';
11
14
  import { join, dirname, basename } from 'path';
@@ -14,17 +17,22 @@ const VERSION_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12 hours
14
17
  const CODEX_MODELS_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12 hours
15
18
  export class MessageHandler {
16
19
  repos;
17
- agentVersion = '0.6.3';
20
+ agentVersion = '0.6.4';
18
21
  defaultRepoPath;
19
22
  clientRepos = new Map(); // peerAddress -> repoPath
20
23
  repoLocks = new Map(); // repoPath -> peerAddress holding lock
21
24
  availableRepos;
22
25
  codingPaths = new Map(); // path -> CodingPath
23
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();
24
31
  claudeService = new SessionManager([
25
32
  new ClaudeCodeProvider(),
26
33
  new CodexSdkProvider(),
27
34
  ]);
35
+ pushClient = null;
28
36
  latestVersionCache = null;
29
37
  versionCheckInFlight = null;
30
38
  codexModelsCache = null;
@@ -193,9 +201,22 @@ export class MessageHandler {
193
201
  getActiveSessionCount() {
194
202
  return this.claudeService.getActiveSessionCount();
195
203
  }
204
+ /** Exposed so the daemon can wire state-updated events to broadcasts. */
205
+ getCommitSummaryStore() {
206
+ return this.commitSummaryStore;
207
+ }
196
208
  getClaudeService() {
197
209
  return this.claudeService;
198
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
+ }
199
220
  getAiService() {
200
221
  const apiKey = getAnthropicApiKey();
201
222
  if (!apiKey)
@@ -211,129 +232,155 @@ export class MessageHandler {
211
232
  if (isVerbose) {
212
233
  console.log(`[msg] ${message.type} from ${peerAddress.slice(0, 12)}`);
213
234
  }
214
- try {
215
- switch (message.type) {
216
- case 'handshake':
217
- return this.handleHandshake(message, peerAddress);
218
- case 'ping':
219
- return createMessage('pong', { timestamp: Date.now() });
220
- case 'git:status':
221
- return this.handleStatus(message, peerAddress);
222
- case 'git:diff':
223
- return this.handleDiff(message, peerAddress);
224
- case 'git:stage':
225
- return this.handleStage(message, peerAddress);
226
- case 'git:unstage':
227
- return this.handleUnstage(message, peerAddress);
228
- case 'git:stage-patch':
229
- return this.handleStagePatch(message, peerAddress);
230
- case 'git:unstage-patch':
231
- return this.handleUnstagePatch(message, peerAddress);
232
- case 'git:commit':
233
- return this.handleCommit(message, peerAddress);
234
- case 'git:log':
235
- return this.handleLog(message, peerAddress);
236
- case 'git:branches':
237
- return this.handleBranches(peerAddress);
238
- case 'git:checkout':
239
- return this.handleCheckout(message, peerAddress);
240
- case 'git:discard':
241
- return this.handleDiscard(message, peerAddress);
242
- case 'git:untrack':
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);
250
- case 'git:gitignore-add':
251
- return this.handleGitignoreAdd(message, peerAddress);
252
- case 'git:gitignore-read':
253
- return this.handleGitignoreRead(message, peerAddress);
254
- case 'git:gitignore-write':
255
- return this.handleGitignoreWrite(message, peerAddress);
256
- case 'ai:generate-commit-summary':
257
- return this.handleGenerateCommitSummary(message, peerAddress);
258
- case 'ai:set-api-key':
259
- return this.handleSetApiKey(message);
260
- case 'ai:get-api-key-status':
261
- return this.handleGetApiKeyStatus(message);
262
- case 'agent:list-repos':
263
- return this.handleListRepos(message, peerAddress);
264
- case 'agent:switch-repo':
265
- return this.handleSwitchRepo(message, peerAddress);
266
- case 'agent:browse-directory':
267
- return this.handleBrowseDirectory(message);
268
- case 'agent:add-repo':
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);
274
- case 'agent:list-coding-paths':
275
- return this.handleListCodingPaths(message);
276
- case 'agent:add-coding-path':
277
- return this.handleAddCodingPath(message);
278
- case 'agent:remove-coding-path':
279
- return this.handleRemoveCodingPath(message);
280
- case 'agent:check-update':
281
- return this.handleAgentCheckUpdate(message);
282
- case 'agent:update':
283
- return this.handleAgentUpdate(message);
284
- case 'agent:restart':
285
- return this.handleAgentRestart(message);
286
- case 'codex:list-models':
287
- return this.handleCodexListModels(message);
288
- // Claude Code SDK
289
- case 'claude:list-sessions':
290
- return this.handleClaudeListSessions(message, peerAddress);
291
- case 'claude:start':
292
- return this.handleClaudeStart(message, peerAddress);
293
- case 'claude:resume':
294
- return this.handleClaudeResume(message, peerAddress);
295
- case 'claude:cancel':
296
- return this.handleClaudeCancel(message);
297
- case 'claude:close':
298
- return this.handleClaudeClose(message);
299
- case 'claude:user-input-response':
300
- return this.handleClaudeUserInputResponse(message);
301
- case 'claude:get-preferences':
302
- return this.handleGetPreferences(message);
303
- case 'claude:set-preferences':
304
- return this.handleSetPreferences(message);
305
- case 'claude:set-session-permission':
306
- return this.handleSetSessionPermission(message);
307
- case 'claude:active-sessions':
308
- return this.handleGetActiveSessions(message);
309
- case 'claude:get-cards':
310
- return this.handleClaudeGetCards(message, peerAddress);
311
- case 'claude:unsubscribe': {
312
- const unsub = message.payload;
313
- this.onPeerUnsubscribed?.(peerAddress, unsub.sessionId);
314
- return createMessage('claude:unsubscribe:response', { ok: true });
315
- }
316
- case 'session:get-config':
317
- return this.handleGetSessionConfig(message);
318
- case 'session:set-config':
319
- return this.handleSetSessionConfig(message);
320
- case 'session:list-history':
321
- return this.handleListHistory(message, peerAddress);
322
- case 'session:update-history':
323
- return this.handleUpdateHistory(message);
324
- case 'session:delete-history':
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);
330
- default:
331
- 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;
332
248
  }
333
249
  }
334
- catch (error) {
335
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
336
- 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}`);
337
384
  }
338
385
  }
339
386
  handleHandshake(message, peerAddress) {
@@ -492,6 +539,9 @@ export class MessageHandler {
492
539
  try {
493
540
  const { message: commitMessage, description, attribution } = message.payload;
494
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);
495
545
  const response = createMessage('git:commit:response', {
496
546
  success: true,
497
547
  hash,
@@ -711,69 +761,170 @@ export class MessageHandler {
711
761
  this.releaseRepoLock(repoPath, peerAddress);
712
762
  }
713
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
+ */
714
770
  async handleGenerateCommitSummary(message, peerAddress) {
715
- const aiService = this.getAiService();
716
- if (!aiService) {
717
- const response = createMessage('ai:generate-commit-summary:response', {
718
- success: false,
719
- error: 'Configure your API key in Settings',
720
- errorCode: 'NO_API_KEY',
721
- });
722
- response.id = message.id;
723
- 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
+ }
724
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.
725
793
  try {
726
- const git = this.getGit(peerAddress);
727
- const status = await git.getStatus();
794
+ const status = await this.getGit(peerAddress).getStatus();
728
795
  if (status.staged.length === 0) {
729
- const response = createMessage('ai:generate-commit-summary:response', {
796
+ return respond({
730
797
  success: false,
731
798
  error: 'No staged changes to summarize',
732
799
  errorCode: 'NO_STAGED_CHANGES',
733
800
  });
734
- response.id = message.id;
735
- return response;
736
801
  }
737
- // Collect diffs and context in parallel
738
- const [diffs, recentLog, branchInfo, conventions] = await Promise.all([
739
- 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([
740
844
  git.getLog(10).catch(() => []),
741
845
  git.getBranches().catch(() => ({ current: '' })),
742
846
  git.readCommitConventions().catch(() => undefined),
743
847
  ]);
744
848
  const recentCommits = recentLog.map((c) => c.message);
745
849
  const branchName = branchInfo.current;
746
- // 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)));
747
880
  const result = await aiService.generateSummary({
748
881
  diffs,
749
- context: message.payload.context,
750
- model: message.payload.model,
882
+ context: payload.context,
883
+ model: payload.model,
751
884
  recentCommits: recentCommits.length > 0 ? recentCommits : undefined,
752
885
  branchName: branchName || undefined,
753
886
  conventions,
754
- attribution: message.payload.attribution,
887
+ attribution: payload.attribution,
755
888
  });
756
- const response = createMessage('ai:generate-commit-summary:response', {
757
- success: true,
889
+ this.commitSummaryStore.setResult(repoPath, token, {
758
890
  summary: result.summary,
759
891
  description: result.description,
760
892
  tokenUsage: result.tokenUsage,
761
893
  cached: result.cached,
762
894
  });
763
- response.id = message.id;
764
- return response;
765
895
  }
766
896
  catch (error) {
897
+ if (error instanceof CommitSummaryCliError) {
898
+ this.commitSummaryStore.setError(repoPath, token, error.message, error.errorCode);
899
+ return;
900
+ }
767
901
  const errorMessage = error instanceof Error ? error.message : 'Failed to generate summary';
768
902
  const isRateLimit = errorMessage.includes('rate_limit');
769
- const response = createMessage('ai:generate-commit-summary:response', {
770
- success: false,
771
- error: errorMessage,
772
- errorCode: isRateLimit ? 'RATE_LIMITED' : 'API_ERROR',
773
- });
774
- response.id = message.id;
775
- 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();
776
926
  }
927
+ return this.aiCliService;
777
928
  }
778
929
  handleSetApiKey(message) {
779
930
  try {
@@ -1220,7 +1371,7 @@ export class MessageHandler {
1220
1371
  const cwd = payloadCwd || this.getClientRepoPath(peerAddress);
1221
1372
  const streamId = generateMessageId();
1222
1373
  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]' : ''}`);
1374
+ console.log(`[agent:start] agent=${resolvedAgent ?? 'default'} model=${model ?? 'default'} cwd=${cwd} prompt=${prompt.slice(0, 80)}${sandboxed ? ' [sandboxed]' : ''}`);
1224
1375
  try {
1225
1376
  const sessionId = await this.claudeService.startSession({
1226
1377
  prompt,
@@ -1237,6 +1388,13 @@ export class MessageHandler {
1237
1388
  this.onPeerSubscribed?.(peerAddress, sessionId);
1238
1389
  // Register in session history
1239
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
+ });
1240
1398
  const registry = getSessionRegistry();
1241
1399
  const gitBranch = await this.getGitBranchQuiet(cwd);
1242
1400
  const actualAgent = this.claudeService.getSessionAgent(sessionId, cwd);
@@ -1269,7 +1427,8 @@ export class MessageHandler {
1269
1427
  const cwd = payloadCwd || this.getClientRepoPath(peerAddress);
1270
1428
  const streamId = generateMessageId();
1271
1429
  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)}`);
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)}`);
1273
1432
  // Subscribe before resumeSession starts streaming — hot resume emits events
1274
1433
  // immediately (user_message, then assistant turns), so we must be subscribed
1275
1434
  // before session.send() fires, not after resumeSession() returns.
@@ -1287,6 +1446,12 @@ export class MessageHandler {
1287
1446
  if (actualSessionId !== requestedId) {
1288
1447
  this.onPeerSubscribed?.(peerAddress, actualSessionId);
1289
1448
  }
1449
+ getEventStore().record({
1450
+ type: 'prompt_sent',
1451
+ sessionId: actualSessionId,
1452
+ cwd,
1453
+ data: { kind: 'resume', promptLength: prompt.length, agent: resolvedAgent },
1454
+ });
1290
1455
  // Update session history
1291
1456
  const registry = getSessionRegistry();
1292
1457
  const existing = registry.getEntry(cwd, requestedId) ?? registry.getEntry(cwd, actualSessionId);
@@ -1314,6 +1479,9 @@ export class MessageHandler {
1314
1479
  async handleClaudeCancel(message) {
1315
1480
  const { sessionId } = message.payload;
1316
1481
  const success = await this.claudeService.cancelSession(sessionId);
1482
+ if (success) {
1483
+ getEventStore().record({ type: 'session_cancelled', sessionId });
1484
+ }
1317
1485
  const response = createMessage('claude:cancel:response', { success, error: success ? undefined : 'Session not found or already ended' });
1318
1486
  response.id = message.id;
1319
1487
  return response;
@@ -1366,10 +1534,18 @@ export class MessageHandler {
1366
1534
  response.id = message.id;
1367
1535
  return response;
1368
1536
  }
1369
- handleSetSessionPermission(message) {
1537
+ async handleSetSessionPermission(message) {
1370
1538
  const { sessionId, permissionMode } = message.payload;
1371
- const validModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
1372
- 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
+ }
1373
1549
  const response = createMessage('claude:set-session-permission:response', { success, sessionId, permissionMode });
1374
1550
  response.id = message.id;
1375
1551
  return response;
@@ -1381,12 +1557,43 @@ export class MessageHandler {
1381
1557
  response.id = message.id;
1382
1558
  return response;
1383
1559
  }
1384
- handleSetSessionConfig(message) {
1560
+ async handleSetSessionConfig(message) {
1385
1561
  const { sessionId, key, value } = message.payload;
1386
- const config = this.claudeService.setSessionConfig(sessionId, key, value);
1387
- const response = createMessage('session:set-config:response', { success: true, sessionId, config });
1388
- response.id = message.id;
1389
- 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
+ }
1390
1597
  }
1391
1598
  handleGetActiveSessions(message) {
1392
1599
  const sessions = this.claudeService.getActiveSessions();
@@ -1558,6 +1765,21 @@ export class MessageHandler {
1558
1765
  return undefined;
1559
1766
  }
1560
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
+ }
1561
1783
  createErrorResponse(id, code, message) {
1562
1784
  const response = createMessage('error', { code, message });
1563
1785
  response.id = id;