@sumicom/quicksave 0.6.3 → 0.7.0

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 (71) 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 +168 -35
  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 +13 -2
  24. package/dist/ai/provider.d.ts.map +1 -1
  25. package/dist/ai/sessionManager.d.ts +32 -3
  26. package/dist/ai/sessionManager.d.ts.map +1 -1
  27. package/dist/ai/sessionManager.js +229 -36
  28. package/dist/ai/sessionManager.js.map +1 -1
  29. package/dist/ai/sessionRegistry.d.ts +2 -0
  30. package/dist/ai/sessionRegistry.d.ts.map +1 -1
  31. package/dist/ai/sessionRegistry.js +9 -0
  32. package/dist/ai/sessionRegistry.js.map +1 -1
  33. package/dist/config.d.ts +4 -0
  34. package/dist/config.d.ts.map +1 -1
  35. package/dist/config.js +15 -5
  36. package/dist/config.js.map +1 -1
  37. package/dist/connection/connection.d.ts +0 -12
  38. package/dist/connection/connection.d.ts.map +1 -1
  39. package/dist/connection/connection.js +1 -44
  40. package/dist/connection/connection.js.map +1 -1
  41. package/dist/connection/pubsub.d.ts +5 -5
  42. package/dist/connection/pubsub.d.ts.map +1 -1
  43. package/dist/connection/pubsub.js +5 -7
  44. package/dist/connection/pubsub.js.map +1 -1
  45. package/dist/handlers/messageHandler.d.ts +32 -2
  46. package/dist/handlers/messageHandler.d.ts.map +1 -1
  47. package/dist/handlers/messageHandler.js +395 -176
  48. package/dist/handlers/messageHandler.js.map +1 -1
  49. package/dist/index.js +3 -3
  50. package/dist/index.js.map +1 -1
  51. package/dist/messageBus/busServerTransport.d.ts +29 -0
  52. package/dist/messageBus/busServerTransport.d.ts.map +1 -0
  53. package/dist/messageBus/busServerTransport.js +54 -0
  54. package/dist/messageBus/busServerTransport.js.map +1 -0
  55. package/dist/service/ipcClient.js +1 -1
  56. package/dist/service/pushClient.d.ts +69 -0
  57. package/dist/service/pushClient.d.ts.map +1 -0
  58. package/dist/service/pushClient.js +119 -0
  59. package/dist/service/pushClient.js.map +1 -0
  60. package/dist/service/run.d.ts.map +1 -1
  61. package/dist/service/run.js +280 -36
  62. package/dist/service/run.js.map +1 -1
  63. package/dist/service/types.d.ts +2 -0
  64. package/dist/service/types.d.ts.map +1 -1
  65. package/dist/service/types.js +13 -8
  66. package/dist/service/types.js.map +1 -1
  67. package/dist/storage/eventStore.d.ts +65 -0
  68. package/dist/storage/eventStore.d.ts.map +1 -0
  69. package/dist/storage/eventStore.js +122 -0
  70. package/dist/storage/eventStore.js.map +1 -0
  71. package/package.json +6 -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,23 +17,26 @@ 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.7.0';
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;
31
39
  codexModelsCheckInFlight = null;
32
- onPeerSubscribed;
33
- onPeerUnsubscribed;
34
40
  onHistoryUpdated;
35
41
  productionBuild;
36
42
  constructor(repos, _license, codingPaths, productionBuild = false) {
@@ -193,9 +199,22 @@ export class MessageHandler {
193
199
  getActiveSessionCount() {
194
200
  return this.claudeService.getActiveSessionCount();
195
201
  }
202
+ /** Exposed so the daemon can wire state-updated events to broadcasts. */
203
+ getCommitSummaryStore() {
204
+ return this.commitSummaryStore;
205
+ }
196
206
  getClaudeService() {
197
207
  return this.claudeService;
198
208
  }
209
+ /** Daemon wires this after construction so push:subscription-offer can
210
+ * forward to the relay. Absent in tests or when VAPID/signaling aren't
211
+ * configured — offer responses will report an error in that case. */
212
+ setPushClient(client) {
213
+ this.pushClient = client;
214
+ }
215
+ getPushClient() {
216
+ return this.pushClient;
217
+ }
199
218
  getAiService() {
200
219
  const apiKey = getAnthropicApiKey();
201
220
  if (!apiKey)
@@ -211,129 +230,150 @@ export class MessageHandler {
211
230
  if (isVerbose) {
212
231
  console.log(`[msg] ${message.type} from ${peerAddress.slice(0, 12)}`);
213
232
  }
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}`);
233
+ // Repo-scoped guard for git:* requests. The PWA stamps `repoPath` on
234
+ // each request so a response can't be misapplied if the user has since
235
+ // switched repos. If the envelope's repoPath differs from the peer's
236
+ // current repo, reject with REPO_MISMATCH so the client can discard
237
+ // rather than render stale data.
238
+ if (message.type.startsWith('git:') &&
239
+ !message.type.endsWith(':response') &&
240
+ typeof message.repoPath === 'string') {
241
+ const currentRepo = this.getClientRepoPath(peerAddress);
242
+ if (message.repoPath !== currentRepo) {
243
+ const err = this.createErrorResponse(message.id, 'REPO_MISMATCH', `Repo mismatch: request expected ${message.repoPath}, peer is on ${currentRepo}`);
244
+ err.repoPath = currentRepo;
245
+ return err;
332
246
  }
333
247
  }
334
- catch (error) {
335
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
336
- return this.createErrorResponse(message.id, 'HANDLER_ERROR', errorMessage);
248
+ const response = await this.dispatch(message, peerAddress);
249
+ // Stamp git:* responses with the repo the agent actually used so the
250
+ // PWA can validate before applying to the store.
251
+ if (response.type.startsWith('git:') && response.type.endsWith(':response')) {
252
+ response.repoPath = this.getClientRepoPath(peerAddress);
253
+ }
254
+ return response;
255
+ }
256
+ async dispatch(message, peerAddress) {
257
+ switch (message.type) {
258
+ case 'handshake':
259
+ return this.handleHandshake(message, peerAddress);
260
+ case 'ping':
261
+ return createMessage('pong', { timestamp: Date.now() });
262
+ case 'git:status':
263
+ return this.handleStatus(message, peerAddress);
264
+ case 'git:diff':
265
+ return this.handleDiff(message, peerAddress);
266
+ case 'git:stage':
267
+ return this.handleStage(message, peerAddress);
268
+ case 'git:unstage':
269
+ return this.handleUnstage(message, peerAddress);
270
+ case 'git:stage-patch':
271
+ return this.handleStagePatch(message, peerAddress);
272
+ case 'git:unstage-patch':
273
+ return this.handleUnstagePatch(message, peerAddress);
274
+ case 'git:commit':
275
+ return this.handleCommit(message, peerAddress);
276
+ case 'git:log':
277
+ return this.handleLog(message, peerAddress);
278
+ case 'git:branches':
279
+ return this.handleBranches(peerAddress);
280
+ case 'git:checkout':
281
+ return this.handleCheckout(message, peerAddress);
282
+ case 'git:discard':
283
+ return this.handleDiscard(message, peerAddress);
284
+ case 'git:untrack':
285
+ return this.handleUntrack(message, peerAddress);
286
+ case 'git:submodules':
287
+ return this.handleSubmodules(message, peerAddress);
288
+ case 'git:config-get':
289
+ return this.handleGitConfigGet(message, peerAddress);
290
+ case 'git:config-set':
291
+ return this.handleGitConfigSet(message, peerAddress);
292
+ case 'git:gitignore-add':
293
+ return this.handleGitignoreAdd(message, peerAddress);
294
+ case 'git:gitignore-read':
295
+ return this.handleGitignoreRead(message, peerAddress);
296
+ case 'git:gitignore-write':
297
+ return this.handleGitignoreWrite(message, peerAddress);
298
+ case 'ai:generate-commit-summary':
299
+ return this.handleGenerateCommitSummary(message, peerAddress);
300
+ case 'ai:commit-summary:get':
301
+ return this.handleGetCommitSummary(message, peerAddress);
302
+ case 'ai:commit-summary:clear':
303
+ return this.handleClearCommitSummary(message, peerAddress);
304
+ case 'ai:set-api-key':
305
+ return this.handleSetApiKey(message);
306
+ case 'ai:get-api-key-status':
307
+ return this.handleGetApiKeyStatus(message);
308
+ case 'agent:list-repos':
309
+ return this.handleListRepos(message, peerAddress);
310
+ case 'agent:switch-repo':
311
+ return this.handleSwitchRepo(message, peerAddress);
312
+ case 'agent:browse-directory':
313
+ return this.handleBrowseDirectory(message);
314
+ case 'agent:add-repo':
315
+ return this.handleAddRepo(message);
316
+ case 'agent:remove-repo':
317
+ return this.handleRemoveRepo(message);
318
+ case 'agent:clone-repo':
319
+ return this.handleCloneRepo(message);
320
+ case 'agent:list-coding-paths':
321
+ return this.handleListCodingPaths(message);
322
+ case 'agent:add-coding-path':
323
+ return this.handleAddCodingPath(message);
324
+ case 'agent:remove-coding-path':
325
+ return this.handleRemoveCodingPath(message);
326
+ case 'agent:check-update':
327
+ return this.handleAgentCheckUpdate(message);
328
+ case 'agent:update':
329
+ return this.handleAgentUpdate(message);
330
+ case 'agent:restart':
331
+ return this.handleAgentRestart(message);
332
+ case 'codex:list-models':
333
+ return this.handleCodexListModels(message);
334
+ // Claude Code SDK
335
+ case 'claude:list-sessions':
336
+ return this.handleClaudeListSessions(message, peerAddress);
337
+ case 'claude:start':
338
+ return this.handleClaudeStart(message, peerAddress);
339
+ case 'claude:resume':
340
+ return this.handleClaudeResume(message, peerAddress);
341
+ case 'claude:cancel':
342
+ return this.handleClaudeCancel(message);
343
+ case 'claude:close':
344
+ return this.handleClaudeClose(message);
345
+ case 'claude:user-input-response':
346
+ return this.handleClaudeUserInputResponse(message);
347
+ case 'claude:get-preferences':
348
+ return this.handleGetPreferences(message);
349
+ case 'claude:set-preferences':
350
+ return this.handleSetPreferences(message);
351
+ case 'claude:set-session-permission':
352
+ return this.handleSetSessionPermission(message);
353
+ case 'claude:active-sessions':
354
+ return this.handleGetActiveSessions(message);
355
+ case 'claude:get-cards':
356
+ return this.handleClaudeGetCards(message, peerAddress);
357
+ case 'session:get-config':
358
+ return this.handleGetSessionConfig(message);
359
+ case 'session:set-config':
360
+ return this.handleSetSessionConfig(message);
361
+ case 'session:control-request':
362
+ return this.handleControlRequest(message);
363
+ case 'session:list-history':
364
+ return this.handleListHistory(message, peerAddress);
365
+ case 'session:update-history':
366
+ return this.handleUpdateHistory(message);
367
+ case 'session:delete-history':
368
+ return this.handleDeleteHistory(message);
369
+ case 'project:list-summaries':
370
+ return this.handleListProjectSummaries(message);
371
+ case 'project:list-repos':
372
+ return await this.handleListProjectRepos(message);
373
+ case 'push:subscription-offer':
374
+ return this.handlePushSubscriptionOffer(message);
375
+ default:
376
+ return this.createErrorResponse(message.id, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type: ${message.type}`);
337
377
  }
338
378
  }
339
379
  handleHandshake(message, peerAddress) {
@@ -492,6 +532,9 @@ export class MessageHandler {
492
532
  try {
493
533
  const { message: commitMessage, description, attribution } = message.payload;
494
534
  const hash = await this.getGit(peerAddress).commit(commitMessage, description, attribution ?? true);
535
+ // The pending AI suggestion describes the diff we just committed, so
536
+ // it's stale now — clear it (also broadcasts state → idle to all peers).
537
+ this.commitSummaryStore.clear(repoPath);
495
538
  const response = createMessage('git:commit:response', {
496
539
  success: true,
497
540
  hash,
@@ -711,70 +754,171 @@ export class MessageHandler {
711
754
  this.releaseRepoLock(repoPath, peerAddress);
712
755
  }
713
756
  }
757
+ /**
758
+ * Kick off AI commit-summary generation. Generation runs asynchronously and
759
+ * streams progress + result via the `ai:commit-summary:updated` broadcast;
760
+ * the request-response just confirms the kickoff (or reports a validation
761
+ * failure that keeps the state idle, e.g. missing API key / no staged changes).
762
+ */
714
763
  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;
764
+ const source = message.payload.source ?? 'api';
765
+ const repoPath = this.getClientRepoPath(peerAddress);
766
+ const respond = (payload) => {
767
+ const r = createMessage('ai:generate-commit-summary:response', payload);
768
+ r.id = message.id;
769
+ return r;
770
+ };
771
+ // API source requires an Anthropic key; CLI source does not.
772
+ let aiService = null;
773
+ if (source === 'api') {
774
+ aiService = this.getAiService();
775
+ if (!aiService) {
776
+ return respond({
777
+ success: false,
778
+ error: 'Configure your API key in Settings',
779
+ errorCode: 'NO_API_KEY',
780
+ });
781
+ }
724
782
  }
783
+ // Validate staged changes exist before flipping the state into
784
+ // `generating` — we don't want to show "generating…" just to immediately
785
+ // error out on the same tick.
725
786
  try {
726
- const git = this.getGit(peerAddress);
727
- const status = await git.getStatus();
787
+ const status = await this.getGit(peerAddress).getStatus();
728
788
  if (status.staged.length === 0) {
729
- const response = createMessage('ai:generate-commit-summary:response', {
789
+ return respond({
730
790
  success: false,
731
791
  error: 'No staged changes to summarize',
732
792
  errorCode: 'NO_STAGED_CHANGES',
733
793
  });
734
- response.id = message.id;
735
- return response;
736
794
  }
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))),
795
+ }
796
+ catch (error) {
797
+ return respond({
798
+ success: false,
799
+ error: error instanceof Error ? error.message : 'Failed to inspect working tree',
800
+ errorCode: 'API_ERROR',
801
+ });
802
+ }
803
+ // Register the generation with the state store. The returned token lets
804
+ // us detect supersession (the caller fired off a fresh generation while
805
+ // this one was still running, or the state was cleared).
806
+ let aborted = false;
807
+ let cliChild;
808
+ const token = this.commitSummaryStore.startGenerating(repoPath, source, message.payload.model, () => {
809
+ aborted = true;
810
+ try {
811
+ cliChild?.kill('SIGTERM');
812
+ }
813
+ catch { /* ignore */ }
814
+ });
815
+ // Run generation asynchronously. Errors flow through the state store,
816
+ // NOT through the kickoff response — the response has already been sent.
817
+ void this.runCommitSummary(peerAddress, repoPath, token, source, message.payload, aiService, (child) => {
818
+ cliChild = child;
819
+ if (aborted) {
820
+ try {
821
+ child.kill('SIGTERM');
822
+ }
823
+ catch { /* ignore */ }
824
+ }
825
+ });
826
+ return respond({ success: true, state: this.commitSummaryStore.get(repoPath) });
827
+ }
828
+ /**
829
+ * Background worker that actually runs the generation. Reports success,
830
+ * progress, or failure through the CommitSummaryStateStore (which in turn
831
+ * broadcasts `ai:commit-summary:updated` to all peers).
832
+ */
833
+ async runCommitSummary(peerAddress, repoPath, token, source, payload, aiService, onSpawn) {
834
+ try {
835
+ const git = this.getGit(peerAddress);
836
+ const [recentLog, branchInfo, conventions] = await Promise.all([
740
837
  git.getLog(10).catch(() => []),
741
838
  git.getBranches().catch(() => ({ current: '' })),
742
839
  git.readCommitConventions().catch(() => undefined),
743
840
  ]);
744
841
  const recentCommits = recentLog.map((c) => c.message);
745
842
  const branchName = branchInfo.current;
746
- // Generate summary (uses internal queue and cache)
843
+ if (source === 'claude-cli') {
844
+ const cliService = this.getAiCliService();
845
+ const result = await cliService.generateSummary({
846
+ repoPath,
847
+ context: payload.context,
848
+ model: payload.model,
849
+ recentCommits: recentCommits.length > 0 ? recentCommits : undefined,
850
+ branchName: branchName || undefined,
851
+ conventions,
852
+ attribution: payload.attribution,
853
+ onProgress: (progress) => {
854
+ this.commitSummaryStore.updateProgress(repoPath, token, progress);
855
+ },
856
+ onSpawn: (child) => {
857
+ onSpawn(child);
858
+ },
859
+ });
860
+ this.commitSummaryStore.setResult(repoPath, token, {
861
+ summary: result.summary,
862
+ description: result.description,
863
+ tokenUsage: result.tokenUsage,
864
+ });
865
+ return;
866
+ }
867
+ // API path — fetch staged diffs fresh. No per-step progress today;
868
+ // the single Anthropic round-trip is fast enough that phase updates add
869
+ // little value. We still flip to `generating` so the UI shows a spinner.
870
+ this.commitSummaryStore.updateProgress(repoPath, token, { phase: 'generating' });
871
+ const status = await git.getStatus();
872
+ const diffs = await Promise.all(status.staged.map((file) => git.getDiff(file.path, true)));
747
873
  const result = await aiService.generateSummary({
748
874
  diffs,
749
- context: message.payload.context,
750
- model: message.payload.model,
875
+ context: payload.context,
876
+ model: payload.model,
751
877
  recentCommits: recentCommits.length > 0 ? recentCommits : undefined,
752
878
  branchName: branchName || undefined,
753
879
  conventions,
754
- attribution: message.payload.attribution,
880
+ attribution: payload.attribution,
755
881
  });
756
- const response = createMessage('ai:generate-commit-summary:response', {
757
- success: true,
882
+ this.commitSummaryStore.setResult(repoPath, token, {
758
883
  summary: result.summary,
759
884
  description: result.description,
760
885
  tokenUsage: result.tokenUsage,
761
886
  cached: result.cached,
762
887
  });
763
- response.id = message.id;
764
- return response;
765
888
  }
766
889
  catch (error) {
890
+ if (error instanceof CommitSummaryCliError) {
891
+ this.commitSummaryStore.setError(repoPath, token, error.message, error.errorCode);
892
+ return;
893
+ }
767
894
  const errorMessage = error instanceof Error ? error.message : 'Failed to generate summary';
768
895
  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;
896
+ this.commitSummaryStore.setError(repoPath, token, errorMessage, isRateLimit ? 'RATE_LIMITED' : 'API_ERROR');
776
897
  }
777
898
  }
899
+ handleGetCommitSummary(message, peerAddress) {
900
+ const repoPath = message.payload.repoPath || this.getClientRepoPath(peerAddress);
901
+ const state = this.commitSummaryStore.get(repoPath);
902
+ const response = createMessage('ai:commit-summary:get:response', { state });
903
+ response.id = message.id;
904
+ return response;
905
+ }
906
+ handleClearCommitSummary(message, peerAddress) {
907
+ const repoPath = message.payload.repoPath || this.getClientRepoPath(peerAddress);
908
+ const state = this.commitSummaryStore.clear(repoPath);
909
+ const response = createMessage('ai:commit-summary:clear:response', {
910
+ success: true,
911
+ state,
912
+ });
913
+ response.id = message.id;
914
+ return response;
915
+ }
916
+ getAiCliService() {
917
+ if (!this.aiCliService) {
918
+ this.aiCliService = new CommitSummaryCliService();
919
+ }
920
+ return this.aiCliService;
921
+ }
778
922
  handleSetApiKey(message) {
779
923
  try {
780
924
  setAnthropicApiKey(message.payload.apiKey);
@@ -1220,7 +1364,7 @@ export class MessageHandler {
1220
1364
  const cwd = payloadCwd || this.getClientRepoPath(peerAddress);
1221
1365
  const streamId = generateMessageId();
1222
1366
  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]' : ''}`);
1367
+ console.log(`[agent:start] agent=${resolvedAgent ?? 'default'} model=${model ?? 'default'} cwd=${cwd} prompt=${prompt.slice(0, 80)}${sandboxed ? ' [sandboxed]' : ''}`);
1224
1368
  try {
1225
1369
  const sessionId = await this.claudeService.startSession({
1226
1370
  prompt,
@@ -1234,9 +1378,15 @@ export class MessageHandler {
1234
1378
  sandboxed,
1235
1379
  });
1236
1380
  console.log(`[agent:start] session created: ${sessionId} agent=${resolvedAgent ?? 'default'}`);
1237
- this.onPeerSubscribed?.(peerAddress, sessionId);
1238
1381
  // Register in session history
1239
1382
  const now = Date.now();
1383
+ getEventStore().record({
1384
+ type: 'prompt_sent',
1385
+ sessionId,
1386
+ cwd,
1387
+ time: now,
1388
+ data: { kind: 'start', promptLength: prompt.length, model, agent: resolvedAgent },
1389
+ });
1240
1390
  const registry = getSessionRegistry();
1241
1391
  const gitBranch = await this.getGitBranchQuiet(cwd);
1242
1392
  const actualAgent = this.claudeService.getSessionAgent(sessionId, cwd);
@@ -1269,11 +1419,8 @@ export class MessageHandler {
1269
1419
  const cwd = payloadCwd || this.getClientRepoPath(peerAddress);
1270
1420
  const streamId = generateMessageId();
1271
1421
  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)}`);
1273
- // Subscribe before resumeSession starts streaming hot resume emits events
1274
- // immediately (user_message, then assistant turns), so we must be subscribed
1275
- // before session.send() fires, not after resumeSession() returns.
1276
- this.onPeerSubscribed?.(peerAddress, requestedId);
1422
+ const activeCfg = this.claudeService.getSessionConfig(requestedId);
1423
+ console.log(`[agent:resume] session=${requestedId} agent=${resolvedAgent ?? 'stored'} model=${activeCfg.model ?? 'default'} cwd=${cwd} prompt=${prompt.slice(0, 80)}`);
1277
1424
  try {
1278
1425
  const actualSessionId = await this.claudeService.resumeSession({
1279
1426
  sessionId: requestedId,
@@ -1283,10 +1430,12 @@ export class MessageHandler {
1283
1430
  agent: resolvedAgent,
1284
1431
  });
1285
1432
  console.log(`[agent:resume] session resumed: ${actualSessionId}`);
1286
- // Re-subscribe with actual session ID (cold resume may return a different ID)
1287
- if (actualSessionId !== requestedId) {
1288
- this.onPeerSubscribed?.(peerAddress, actualSessionId);
1289
- }
1433
+ getEventStore().record({
1434
+ type: 'prompt_sent',
1435
+ sessionId: actualSessionId,
1436
+ cwd,
1437
+ data: { kind: 'resume', promptLength: prompt.length, agent: resolvedAgent },
1438
+ });
1290
1439
  // Update session history
1291
1440
  const registry = getSessionRegistry();
1292
1441
  const existing = registry.getEntry(cwd, requestedId) ?? registry.getEntry(cwd, actualSessionId);
@@ -1314,6 +1463,9 @@ export class MessageHandler {
1314
1463
  async handleClaudeCancel(message) {
1315
1464
  const { sessionId } = message.payload;
1316
1465
  const success = await this.claudeService.cancelSession(sessionId);
1466
+ if (success) {
1467
+ getEventStore().record({ type: 'session_cancelled', sessionId });
1468
+ }
1317
1469
  const response = createMessage('claude:cancel:response', { success, error: success ? undefined : 'Session not found or already ended' });
1318
1470
  response.id = message.id;
1319
1471
  return response;
@@ -1339,7 +1491,6 @@ export class MessageHandler {
1339
1491
  const cwd = payloadCwd || this.getClientRepoPath(peerAddress);
1340
1492
  try {
1341
1493
  const result = await this.claudeService.getCards(sessionId, cwd, offset, limit);
1342
- this.onPeerSubscribed?.(peerAddress, sessionId);
1343
1494
  const response = createMessage('claude:get-cards:response', result);
1344
1495
  response.id = message.id;
1345
1496
  return response;
@@ -1366,10 +1517,18 @@ export class MessageHandler {
1366
1517
  response.id = message.id;
1367
1518
  return response;
1368
1519
  }
1369
- handleSetSessionPermission(message) {
1520
+ async handleSetSessionPermission(message) {
1370
1521
  const { sessionId, permissionMode } = message.payload;
1371
- const validModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
1372
- const success = validModes.includes(permissionMode) && this.claudeService.setPermissionLevel(sessionId, permissionMode);
1522
+ const validModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'auto'];
1523
+ let success = false;
1524
+ if (validModes.includes(permissionMode)) {
1525
+ try {
1526
+ success = await this.claudeService.setPermissionLevel(sessionId, permissionMode);
1527
+ }
1528
+ catch {
1529
+ success = false;
1530
+ }
1531
+ }
1373
1532
  const response = createMessage('claude:set-session-permission:response', { success, sessionId, permissionMode });
1374
1533
  response.id = message.id;
1375
1534
  return response;
@@ -1381,12 +1540,43 @@ export class MessageHandler {
1381
1540
  response.id = message.id;
1382
1541
  return response;
1383
1542
  }
1384
- handleSetSessionConfig(message) {
1543
+ async handleSetSessionConfig(message) {
1385
1544
  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;
1545
+ console.log(`[agent:set-config] session=${sessionId.slice(0, 8)} ${key}=${String(value)}`);
1546
+ try {
1547
+ const config = await this.claudeService.setSessionConfig(sessionId, key, value);
1548
+ const response = createMessage('session:set-config:response', { success: true, sessionId, config });
1549
+ response.id = message.id;
1550
+ return response;
1551
+ }
1552
+ catch (err) {
1553
+ const config = this.claudeService.getSessionConfig(sessionId);
1554
+ const response = createMessage('session:set-config:response', {
1555
+ success: false,
1556
+ sessionId,
1557
+ config,
1558
+ error: err instanceof Error ? err.message : String(err),
1559
+ });
1560
+ response.id = message.id;
1561
+ return response;
1562
+ }
1563
+ }
1564
+ async handleControlRequest(message) {
1565
+ const { sessionId, subtype, params } = message.payload;
1566
+ console.log(`[agent:control-request] session=${sessionId.slice(0, 8)} subtype=${subtype} params=${JSON.stringify(params ?? {})}`);
1567
+ try {
1568
+ const result = await this.claudeService.sendControlRequest(sessionId, subtype, params);
1569
+ const response = createMessage('session:control-request:response', { success: true, sessionId, response: result });
1570
+ response.id = message.id;
1571
+ return response;
1572
+ }
1573
+ catch (error) {
1574
+ const errMsg = error instanceof Error ? error.message : String(error);
1575
+ console.error(`[agent:control-request] error:`, errMsg);
1576
+ const response = createMessage('session:control-request:response', { success: false, sessionId, error: errMsg });
1577
+ response.id = message.id;
1578
+ return response;
1579
+ }
1390
1580
  }
1391
1581
  handleGetActiveSessions(message) {
1392
1582
  const sessions = this.claudeService.getActiveSessions();
@@ -1456,6 +1646,20 @@ export class MessageHandler {
1456
1646
  isGitRepo: existsSync(join(cwd, '.git')),
1457
1647
  });
1458
1648
  }
1649
+ // Include managed coding paths that have no sessions so the PWA can show them
1650
+ // in the project list. Without this, a freshly-added workspace with no sessions
1651
+ // would be pruned from the PWA's knownCodingPaths on next connect.
1652
+ for (const cwd of this.codingPaths.keys()) {
1653
+ if (byCwd.has(cwd))
1654
+ continue;
1655
+ projects.push({
1656
+ cwd,
1657
+ sessionCount: 0,
1658
+ lastActivityAt: 0,
1659
+ hasActiveSession: false,
1660
+ isGitRepo: existsSync(join(cwd, '.git')),
1661
+ });
1662
+ }
1459
1663
  // Sort by lastActivityAt desc
1460
1664
  projects.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
1461
1665
  const response = createMessage('project:list-summaries:response', { projects });
@@ -1558,6 +1762,21 @@ export class MessageHandler {
1558
1762
  return undefined;
1559
1763
  }
1560
1764
  }
1765
+ async handlePushSubscriptionOffer(message) {
1766
+ const client = this.pushClient;
1767
+ let payload;
1768
+ if (!client) {
1769
+ payload = { success: false, error: 'push-not-configured' };
1770
+ }
1771
+ else {
1772
+ const { subscription, relayHttpUrl } = message.payload;
1773
+ const result = await client.register(subscription, relayHttpUrl);
1774
+ payload = result.ok ? { success: true } : { success: false, error: result.error ?? `http-${result.status}` };
1775
+ }
1776
+ const response = createMessage('push:subscription-offer:response', payload);
1777
+ response.id = message.id;
1778
+ return response;
1779
+ }
1561
1780
  createErrorResponse(id, code, message) {
1562
1781
  const response = createMessage('error', { code, message });
1563
1782
  response.id = id;