@sumicom/quicksave 0.8.18 → 0.8.20

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.
@@ -93,11 +93,35 @@ function projectAppServerModel(m) {
93
93
  isDefault: m.isDefault || undefined,
94
94
  };
95
95
  }
96
+ function isRepoScopedRequest(message) {
97
+ if (message.type.startsWith('git:') && !message.type.endsWith(':response'))
98
+ return true;
99
+ return message.type === 'ai:generate-commit-summary' || message.type === 'ai:commit-summary:clear';
100
+ }
101
+ function payloadRepoPath(payload) {
102
+ if (typeof payload !== 'object' || payload === null)
103
+ return undefined;
104
+ const value = payload.repoPath;
105
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
106
+ }
107
+ async function hasPlausibleGitMarker(path) {
108
+ try {
109
+ const gitPath = join(path, '.git');
110
+ const marker = await stat(gitPath);
111
+ if (marker.isFile())
112
+ return true;
113
+ if (!marker.isDirectory())
114
+ return false;
115
+ return (await readdir(gitPath)).length > 0;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
96
121
  export class MessageHandler {
97
122
  repos;
98
123
  agentVersion = '0.8.18';
99
124
  defaultRepoPath;
100
- clientRepos = new Map(); // peerAddress -> repoPath
101
125
  repoLocks = new Map(); // repoPath -> peerAddress holding lock
102
126
  availableRepos;
103
127
  codingPaths = new Map(); // path -> CodingPath
@@ -364,24 +388,23 @@ export class MessageHandler {
364
388
  if (this.defaultRepoPath === path) {
365
389
  this.defaultRepoPath = this.availableRepos.length > 0 ? this.availableRepos[0].path : '';
366
390
  }
367
- // Drop stale per-peer pins to this path so the next handshake-ack
368
- // doesn't return a deleted repo.
369
- for (const [peer, repo] of this.clientRepos) {
370
- if (repo === path)
371
- this.clientRepos.delete(peer);
372
- }
373
391
  }
374
- getClientRepoPath(peerAddress) {
375
- return this.clientRepos.get(peerAddress) || this.defaultRepoPath;
392
+ getRequiredRepoPath(message) {
393
+ if (!message.repoPath) {
394
+ throw new Error('Missing repoPath for repo-scoped request');
395
+ }
396
+ return message.repoPath;
376
397
  }
377
- getGit(peerAddress) {
378
- const repoPath = this.getClientRepoPath(peerAddress);
398
+ getGitForRepoPath(repoPath) {
379
399
  const git = this.repos.get(repoPath);
380
400
  if (!git) {
381
- throw new Error('No repository selected. Please add a repository first.');
401
+ throw new Error(`Repository not available: ${repoPath}`);
382
402
  }
383
403
  return git;
384
404
  }
405
+ getGit(message) {
406
+ return this.getGitForRepoPath(this.getRequiredRepoPath(message));
407
+ }
385
408
  acquireRepoLock(repoPath, peerAddress) {
386
409
  const holder = this.repoLocks.get(repoPath);
387
410
  if (holder && holder !== peerAddress) {
@@ -396,14 +419,6 @@ export class MessageHandler {
396
419
  }
397
420
  }
398
421
  removeClient(peerAddress) {
399
- // Intentionally keep `clientRepos[peer]`: peer identity is a stable
400
- // public key, and the PWA keeps its selected repo across reconnects
401
- // (e.g., background → resume). Clearing would force the next
402
- // handshake-ack to return `defaultRepoPath`, which the PWA would then
403
- // hydrate into gitStore before its post-reconnect `agent:switch-repo`
404
- // can land — racing `git:status` requests stamped with the default
405
- // path would pass the REPO_MISMATCH guard and paint the default
406
- // repo's (typically clean) status over the user's real repo view.
407
422
  for (const [repoPath, holder] of this.repoLocks) {
408
423
  if (holder === peerAddress) {
409
424
  this.repoLocks.delete(repoPath);
@@ -464,39 +479,36 @@ export class MessageHandler {
464
479
  if (isVerbose) {
465
480
  console.log(`[msg] ${message.type} from ${peerAddress.slice(0, 12)}`);
466
481
  }
467
- // Repo-scoped guard for git:* requests. The PWA stamps `repoPath` on
468
- // each request so a response can't be misapplied if the user has since
469
- // switched repos. If the envelope's repoPath differs from the peer's
470
- // current repo, reject with REPO_MISMATCH so the client can discard
471
- // rather than render stale data.
472
- if (message.type.startsWith('git:') &&
473
- !message.type.endsWith(':response') &&
474
- typeof message.repoPath === 'string') {
475
- const currentRepo = this.getClientRepoPath(peerAddress);
476
- if (message.repoPath !== currentRepo) {
477
- const err = this.createErrorResponse(message.id, 'REPO_MISMATCH', `Repo mismatch: request expected ${message.repoPath}, peer is on ${currentRepo}`);
478
- err.repoPath = currentRepo;
479
- return err;
482
+ const repoScoped = isRepoScopedRequest(message);
483
+ if (repoScoped) {
484
+ const requestedRepoPath = message.repoPath ?? payloadRepoPath(message.payload);
485
+ if (!requestedRepoPath) {
486
+ return this.createErrorResponse(message.id, 'MISSING_REPO_PATH', `${message.type} requires repoPath`);
487
+ }
488
+ const repo = await this.ensureRepoAvailableForPath(requestedRepoPath);
489
+ if (!repo || !this.repos.has(repo.path)) {
490
+ return this.createErrorResponse(message.id, 'REPO_NOT_AVAILABLE', `Repository not available: ${requestedRepoPath}`);
480
491
  }
492
+ message.repoPath = repo.path;
481
493
  }
482
494
  const response = await this.dispatch(message, peerAddress);
483
- // Stamp git:* responses with the repo the agent actually used so the
484
- // PWA can validate before applying to the store.
485
- if (response.type.startsWith('git:') && response.type.endsWith(':response')) {
486
- response.repoPath = this.getClientRepoPath(peerAddress);
495
+ // Stamp repo-scoped responses with the repo the agent actually used so
496
+ // clients can validate before applying the result to local UI state.
497
+ if (repoScoped && response.type !== 'error') {
498
+ response.repoPath = message.repoPath;
487
499
  }
488
500
  return response;
489
501
  }
490
502
  async dispatch(message, peerAddress) {
491
503
  switch (message.type) {
492
504
  case 'handshake':
493
- return this.handleHandshake(message, peerAddress);
505
+ return this.handleHandshake(message);
494
506
  case 'ping':
495
507
  return createMessage('pong', { timestamp: Date.now() });
496
508
  case 'git:status':
497
- return this.handleStatus(message, peerAddress);
509
+ return this.handleStatus(message);
498
510
  case 'git:diff':
499
- return this.handleDiff(message, peerAddress);
511
+ return this.handleDiff(message);
500
512
  case 'git:stage':
501
513
  return this.handleStage(message, peerAddress);
502
514
  case 'git:unstage':
@@ -508,9 +520,9 @@ export class MessageHandler {
508
520
  case 'git:commit':
509
521
  return this.handleCommit(message, peerAddress);
510
522
  case 'git:log':
511
- return this.handleLog(message, peerAddress);
523
+ return this.handleLog(message);
512
524
  case 'git:branches':
513
- return this.handleBranches(peerAddress);
525
+ return this.handleBranches(message);
514
526
  case 'git:checkout':
515
527
  return this.handleCheckout(message, peerAddress);
516
528
  case 'git:discard':
@@ -518,29 +530,27 @@ export class MessageHandler {
518
530
  case 'git:untrack':
519
531
  return this.handleUntrack(message, peerAddress);
520
532
  case 'git:submodules':
521
- return this.handleSubmodules(message, peerAddress);
533
+ return this.handleSubmodules(message);
522
534
  case 'git:config-get':
523
- return this.handleGitConfigGet(message, peerAddress);
535
+ return this.handleGitConfigGet(message);
524
536
  case 'git:config-set':
525
- return this.handleGitConfigSet(message, peerAddress);
537
+ return this.handleGitConfigSet(message);
526
538
  case 'git:gitignore-add':
527
539
  return this.handleGitignoreAdd(message, peerAddress);
528
540
  case 'git:gitignore-read':
529
- return this.handleGitignoreRead(message, peerAddress);
541
+ return this.handleGitignoreRead(message);
530
542
  case 'git:gitignore-write':
531
543
  return this.handleGitignoreWrite(message, peerAddress);
532
544
  case 'ai:generate-commit-summary':
533
- return this.handleGenerateCommitSummary(message, peerAddress);
545
+ return this.handleGenerateCommitSummary(message);
534
546
  case 'ai:commit-summary:clear':
535
- return this.handleClearCommitSummary(message, peerAddress);
547
+ return this.handleClearCommitSummary(message);
536
548
  case 'ai:set-api-key':
537
549
  return this.handleSetApiKey(message);
538
550
  case 'ai:get-api-key-status':
539
551
  return this.handleGetApiKeyStatus(message);
540
552
  case 'agent:list-repos':
541
- return this.handleListRepos(message, peerAddress);
542
- case 'agent:switch-repo':
543
- return this.handleSwitchRepo(message, peerAddress);
553
+ return this.handleListRepos(message);
544
554
  case 'agent:browse-directory':
545
555
  return this.handleBrowseDirectory(message);
546
556
  case 'agent:add-repo':
@@ -601,7 +611,7 @@ export class MessageHandler {
601
611
  case 'claude:set-session-permission':
602
612
  return this.handleSetSessionPermission(message);
603
613
  case 'claude:get-cards':
604
- return this.handleClaudeGetCards(message, peerAddress);
614
+ return this.handleClaudeGetCards(message);
605
615
  case 'voice-agent:attach':
606
616
  return this.handleVoiceAgentAttach(message);
607
617
  case 'voice-agent:detach':
@@ -668,7 +678,7 @@ export class MessageHandler {
668
678
  return this.createErrorResponse(message.id, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type: ${message.type}`);
669
679
  }
670
680
  }
671
- async handleHandshake(message, peerAddress) {
681
+ async handleHandshake(message) {
672
682
  // Fire-and-forget: check npm registry + OpenAI models (12h dedup each)
673
683
  this.checkLatestVersion().catch(() => { });
674
684
  this.fetchCodexModels().catch(() => { });
@@ -676,7 +686,7 @@ export class MessageHandler {
676
686
  const response = createMessage('handshake:ack', {
677
687
  success: true,
678
688
  agentVersion: this.agentVersion,
679
- repoPath: this.getClientRepoPath(peerAddress),
689
+ repoPath: this.defaultRepoPath,
680
690
  availableRepos: this.availableRepos,
681
691
  availableCodingPaths: [...this.codingPaths.values()],
682
692
  preferences: this.claudeService.getPreferences(),
@@ -690,21 +700,21 @@ export class MessageHandler {
690
700
  response.id = message.id;
691
701
  return response;
692
702
  }
693
- async handleStatus(message, peerAddress) {
694
- const status = await this.getGit(peerAddress).getStatus();
703
+ async handleStatus(message) {
704
+ const status = await this.getGit(message).getStatus();
695
705
  const response = createMessage('git:status:response', status);
696
706
  response.id = message.id;
697
707
  return response;
698
708
  }
699
- async handleDiff(message, peerAddress) {
709
+ async handleDiff(message) {
700
710
  const { path, staged } = message.payload;
701
- const diff = await this.getGit(peerAddress).getDiff(path, staged);
711
+ const diff = await this.getGit(message).getDiff(path, staged);
702
712
  const response = createMessage('git:diff:response', diff);
703
713
  response.id = message.id;
704
714
  return response;
705
715
  }
706
716
  async handleStage(message, peerAddress) {
707
- const repoPath = this.getClientRepoPath(peerAddress);
717
+ const repoPath = this.getRequiredRepoPath(message);
708
718
  if (!this.acquireRepoLock(repoPath, peerAddress)) {
709
719
  const response = createMessage('git:stage:response', {
710
720
  success: false,
@@ -714,7 +724,7 @@ export class MessageHandler {
714
724
  return response;
715
725
  }
716
726
  try {
717
- await this.getGit(peerAddress).stage(message.payload.paths);
727
+ await this.getGit(message).stage(message.payload.paths);
718
728
  const response = createMessage('git:stage:response', { success: true });
719
729
  response.id = message.id;
720
730
  return response;
@@ -732,7 +742,7 @@ export class MessageHandler {
732
742
  }
733
743
  }
734
744
  async handleUnstage(message, peerAddress) {
735
- const repoPath = this.getClientRepoPath(peerAddress);
745
+ const repoPath = this.getRequiredRepoPath(message);
736
746
  if (!this.acquireRepoLock(repoPath, peerAddress)) {
737
747
  const response = createMessage('git:unstage:response', {
738
748
  success: false,
@@ -742,7 +752,7 @@ export class MessageHandler {
742
752
  return response;
743
753
  }
744
754
  try {
745
- await this.getGit(peerAddress).unstage(message.payload.paths);
755
+ await this.getGit(message).unstage(message.payload.paths);
746
756
  const response = createMessage('git:unstage:response', { success: true });
747
757
  response.id = message.id;
748
758
  return response;
@@ -760,7 +770,7 @@ export class MessageHandler {
760
770
  }
761
771
  }
762
772
  async handleStagePatch(message, peerAddress) {
763
- const repoPath = this.getClientRepoPath(peerAddress);
773
+ const repoPath = this.getRequiredRepoPath(message);
764
774
  if (!this.acquireRepoLock(repoPath, peerAddress)) {
765
775
  const response = createMessage('git:stage-patch:response', {
766
776
  success: false,
@@ -770,7 +780,7 @@ export class MessageHandler {
770
780
  return response;
771
781
  }
772
782
  try {
773
- await this.getGit(peerAddress).stagePatch(message.payload.patch);
783
+ await this.getGit(message).stagePatch(message.payload.patch);
774
784
  const response = createMessage('git:stage-patch:response', { success: true });
775
785
  response.id = message.id;
776
786
  return response;
@@ -788,7 +798,7 @@ export class MessageHandler {
788
798
  }
789
799
  }
790
800
  async handleUnstagePatch(message, peerAddress) {
791
- const repoPath = this.getClientRepoPath(peerAddress);
801
+ const repoPath = this.getRequiredRepoPath(message);
792
802
  if (!this.acquireRepoLock(repoPath, peerAddress)) {
793
803
  const response = createMessage('git:unstage-patch:response', {
794
804
  success: false,
@@ -798,7 +808,7 @@ export class MessageHandler {
798
808
  return response;
799
809
  }
800
810
  try {
801
- await this.getGit(peerAddress).unstagePatch(message.payload.patch);
811
+ await this.getGit(message).unstagePatch(message.payload.patch);
802
812
  const response = createMessage('git:unstage-patch:response', { success: true });
803
813
  response.id = message.id;
804
814
  return response;
@@ -816,7 +826,7 @@ export class MessageHandler {
816
826
  }
817
827
  }
818
828
  async handleCommit(message, peerAddress) {
819
- const repoPath = this.getClientRepoPath(peerAddress);
829
+ const repoPath = this.getRequiredRepoPath(message);
820
830
  if (!this.acquireRepoLock(repoPath, peerAddress)) {
821
831
  const response = createMessage('git:commit:response', {
822
832
  success: false,
@@ -827,7 +837,7 @@ export class MessageHandler {
827
837
  }
828
838
  try {
829
839
  const { message: commitMessage, description, attribution } = message.payload;
830
- const hash = await this.getGit(peerAddress).commit(commitMessage, description, attribution ?? true);
840
+ const hash = await this.getGit(message).commit(commitMessage, description, attribution ?? true);
831
841
  // The pending AI suggestion describes the diff we just committed, so
832
842
  // it's stale now — clear it (also broadcasts state → idle to all peers).
833
843
  this.commitSummaryStore.clear(repoPath);
@@ -850,22 +860,22 @@ export class MessageHandler {
850
860
  this.releaseRepoLock(repoPath, peerAddress);
851
861
  }
852
862
  }
853
- async handleLog(message, peerAddress) {
863
+ async handleLog(message) {
854
864
  const limit = message.payload.limit || 50;
855
- const commits = await this.getGit(peerAddress).getLog(limit);
865
+ const commits = await this.getGit(message).getLog(limit);
856
866
  const response = createMessage('git:log:response', { commits });
857
867
  response.id = message.id;
858
868
  return response;
859
869
  }
860
- async handleBranches(peerAddress) {
861
- const { branches, current } = await this.getGit(peerAddress).getBranches();
870
+ async handleBranches(message) {
871
+ const { branches, current } = await this.getGit(message).getBranches();
862
872
  return createMessage('git:branches:response', {
863
873
  branches,
864
874
  current,
865
875
  });
866
876
  }
867
877
  async handleCheckout(message, peerAddress) {
868
- const repoPath = this.getClientRepoPath(peerAddress);
878
+ const repoPath = this.getRequiredRepoPath(message);
869
879
  if (!this.acquireRepoLock(repoPath, peerAddress)) {
870
880
  const response = createMessage('git:checkout:response', {
871
881
  success: false,
@@ -876,7 +886,7 @@ export class MessageHandler {
876
886
  }
877
887
  try {
878
888
  const { branch, create } = message.payload;
879
- await this.getGit(peerAddress).checkout(branch, create);
889
+ await this.getGit(message).checkout(branch, create);
880
890
  const response = createMessage('git:checkout:response', { success: true });
881
891
  response.id = message.id;
882
892
  return response;
@@ -894,7 +904,7 @@ export class MessageHandler {
894
904
  }
895
905
  }
896
906
  async handleDiscard(message, peerAddress) {
897
- const repoPath = this.getClientRepoPath(peerAddress);
907
+ const repoPath = this.getRequiredRepoPath(message);
898
908
  if (!this.acquireRepoLock(repoPath, peerAddress)) {
899
909
  const response = createMessage('git:discard:response', {
900
910
  success: false,
@@ -904,7 +914,7 @@ export class MessageHandler {
904
914
  return response;
905
915
  }
906
916
  try {
907
- await this.getGit(peerAddress).discard(message.payload.paths);
917
+ await this.getGit(message).discard(message.payload.paths);
908
918
  const response = createMessage('git:discard:response', { success: true });
909
919
  response.id = message.id;
910
920
  return response;
@@ -922,7 +932,7 @@ export class MessageHandler {
922
932
  }
923
933
  }
924
934
  async handleUntrack(message, peerAddress) {
925
- const repoPath = this.getClientRepoPath(peerAddress);
935
+ const repoPath = this.getRequiredRepoPath(message);
926
936
  if (!this.acquireRepoLock(repoPath, peerAddress)) {
927
937
  const response = createMessage('git:untrack:response', {
928
938
  success: false,
@@ -932,7 +942,7 @@ export class MessageHandler {
932
942
  return response;
933
943
  }
934
944
  try {
935
- await this.getGit(peerAddress).untrack(message.payload.paths);
945
+ await this.getGit(message).untrack(message.payload.paths);
936
946
  const response = createMessage('git:untrack:response', { success: true });
937
947
  response.id = message.id;
938
948
  return response;
@@ -949,10 +959,10 @@ export class MessageHandler {
949
959
  this.releaseRepoLock(repoPath, peerAddress);
950
960
  }
951
961
  }
952
- async handleSubmodules(message, peerAddress) {
953
- const git = this.getGit(peerAddress);
962
+ async handleSubmodules(message) {
963
+ const git = this.getGit(message);
954
964
  const subs = await git.getSubmodules();
955
- // Auto-register submodule paths so switch-repo works
965
+ // Auto-register submodule paths so later explicit repoPath requests work.
956
966
  for (const sub of subs) {
957
967
  if (!this.repos.has(sub.path)) {
958
968
  this.repos.set(sub.path, new GitOperations(sub.path));
@@ -965,16 +975,16 @@ export class MessageHandler {
965
975
  response.id = message.id;
966
976
  return response;
967
977
  }
968
- async handleGitConfigGet(message, peerAddress) {
969
- const identity = await this.getGit(peerAddress).getIdentity();
978
+ async handleGitConfigGet(message) {
979
+ const identity = await this.getGit(message).getIdentity();
970
980
  const response = createMessage('git:config-get:response', identity);
971
981
  response.id = message.id;
972
982
  return response;
973
983
  }
974
- async handleGitConfigSet(message, peerAddress) {
984
+ async handleGitConfigSet(message) {
975
985
  try {
976
986
  const { name, email } = message.payload;
977
- await this.getGit(peerAddress).setIdentity(name, email);
987
+ await this.getGit(message).setIdentity(name, email);
978
988
  const response = createMessage('git:config-set:response', { success: true });
979
989
  response.id = message.id;
980
990
  return response;
@@ -989,7 +999,7 @@ export class MessageHandler {
989
999
  }
990
1000
  }
991
1001
  async handleGitignoreAdd(message, peerAddress) {
992
- const repoPath = this.getClientRepoPath(peerAddress);
1002
+ const repoPath = this.getRequiredRepoPath(message);
993
1003
  if (!this.acquireRepoLock(repoPath, peerAddress)) {
994
1004
  const response = createMessage('git:gitignore-add:response', {
995
1005
  success: false,
@@ -999,7 +1009,7 @@ export class MessageHandler {
999
1009
  return response;
1000
1010
  }
1001
1011
  try {
1002
- await this.getGit(peerAddress).addToGitignore(message.payload.pattern);
1012
+ await this.getGit(message).addToGitignore(message.payload.pattern);
1003
1013
  const response = createMessage('git:gitignore-add:response', { success: true });
1004
1014
  response.id = message.id;
1005
1015
  return response;
@@ -1016,14 +1026,14 @@ export class MessageHandler {
1016
1026
  this.releaseRepoLock(repoPath, peerAddress);
1017
1027
  }
1018
1028
  }
1019
- async handleGitignoreRead(message, peerAddress) {
1020
- const { content, exists } = await this.getGit(peerAddress).readGitignore();
1029
+ async handleGitignoreRead(message) {
1030
+ const { content, exists } = await this.getGit(message).readGitignore();
1021
1031
  const response = createMessage('git:gitignore-read:response', { content, exists });
1022
1032
  response.id = message.id;
1023
1033
  return response;
1024
1034
  }
1025
1035
  async handleGitignoreWrite(message, peerAddress) {
1026
- const repoPath = this.getClientRepoPath(peerAddress);
1036
+ const repoPath = this.getRequiredRepoPath(message);
1027
1037
  if (!this.acquireRepoLock(repoPath, peerAddress)) {
1028
1038
  const response = createMessage('git:gitignore-write:response', {
1029
1039
  success: false,
@@ -1033,7 +1043,7 @@ export class MessageHandler {
1033
1043
  return response;
1034
1044
  }
1035
1045
  try {
1036
- await this.getGit(peerAddress).writeGitignore(message.payload.content);
1046
+ await this.getGit(message).writeGitignore(message.payload.content);
1037
1047
  const response = createMessage('git:gitignore-write:response', { success: true });
1038
1048
  response.id = message.id;
1039
1049
  return response;
@@ -1056,9 +1066,9 @@ export class MessageHandler {
1056
1066
  * the request-response just confirms the kickoff (or reports a validation
1057
1067
  * failure that keeps the state idle, e.g. missing API key / no staged changes).
1058
1068
  */
1059
- async handleGenerateCommitSummary(message, peerAddress) {
1069
+ async handleGenerateCommitSummary(message) {
1060
1070
  const source = message.payload.source ?? 'api';
1061
- const repoPath = this.getClientRepoPath(peerAddress);
1071
+ const repoPath = this.getRequiredRepoPath(message);
1062
1072
  const respond = (payload) => {
1063
1073
  const r = createMessage('ai:generate-commit-summary:response', payload);
1064
1074
  r.id = message.id;
@@ -1080,7 +1090,7 @@ export class MessageHandler {
1080
1090
  // `generating` — we don't want to show "generating…" just to immediately
1081
1091
  // error out on the same tick.
1082
1092
  try {
1083
- const status = await this.getGit(peerAddress).getStatus();
1093
+ const status = await this.getGit(message).getStatus();
1084
1094
  if (status.staged.length === 0) {
1085
1095
  return respond({
1086
1096
  success: false,
@@ -1110,7 +1120,7 @@ export class MessageHandler {
1110
1120
  });
1111
1121
  // Run generation asynchronously. Errors flow through the state store,
1112
1122
  // NOT through the kickoff response — the response has already been sent.
1113
- void this.runCommitSummary(peerAddress, repoPath, token, source, message.payload, aiService, (child) => {
1123
+ void this.runCommitSummary(repoPath, token, source, message.payload, aiService, (child) => {
1114
1124
  cliChild = child;
1115
1125
  if (aborted) {
1116
1126
  try {
@@ -1126,9 +1136,9 @@ export class MessageHandler {
1126
1136
  * progress, or failure through the CommitSummaryStateStore (which in turn
1127
1137
  * broadcasts `ai:commit-summary:updated` to all peers).
1128
1138
  */
1129
- async runCommitSummary(peerAddress, repoPath, token, source, payload, aiService, onSpawn) {
1139
+ async runCommitSummary(repoPath, token, source, payload, aiService, onSpawn) {
1130
1140
  try {
1131
- const git = this.getGit(peerAddress);
1141
+ const git = this.getGitForRepoPath(repoPath);
1132
1142
  const [recentLog, branchInfo, conventions] = await Promise.all([
1133
1143
  git.getLog(10).catch(() => []),
1134
1144
  git.getBranches().catch(() => ({ current: '' })),
@@ -1192,8 +1202,8 @@ export class MessageHandler {
1192
1202
  this.commitSummaryStore.setError(repoPath, token, errorMessage, isRateLimit ? 'RATE_LIMITED' : 'API_ERROR');
1193
1203
  }
1194
1204
  }
1195
- handleClearCommitSummary(message, peerAddress) {
1196
- const repoPath = message.payload.repoPath || this.getClientRepoPath(peerAddress);
1205
+ handleClearCommitSummary(message) {
1206
+ const repoPath = this.getRequiredRepoPath(message);
1197
1207
  const state = this.commitSummaryStore.clear(repoPath);
1198
1208
  const response = createMessage('ai:commit-summary:clear:response', {
1199
1209
  success: true,
@@ -1290,7 +1300,7 @@ export class MessageHandler {
1290
1300
  response.id = message.id;
1291
1301
  return response;
1292
1302
  }
1293
- async handleListRepos(message, peerAddress) {
1303
+ async handleListRepos(message) {
1294
1304
  // Refresh branch info for all repos
1295
1305
  const repos = [];
1296
1306
  for (const repo of this.availableRepos) {
@@ -1305,30 +1315,45 @@ export class MessageHandler {
1305
1315
  }
1306
1316
  const response = createMessage('agent:list-repos:response', {
1307
1317
  repos,
1308
- current: this.getClientRepoPath(peerAddress),
1318
+ current: this.defaultRepoPath,
1309
1319
  });
1310
1320
  response.id = message.id;
1311
1321
  return response;
1312
1322
  }
1313
- handleSwitchRepo(message, peerAddress) {
1314
- const { path } = message.payload;
1315
- // Check if the requested repo is in our available repos
1316
- if (!this.repos.has(path)) {
1317
- const response = createMessage('agent:switch-repo:response', {
1318
- success: false,
1319
- newPath: this.getClientRepoPath(peerAddress),
1320
- error: `Repository not available: ${path}`,
1321
- });
1322
- response.id = message.id;
1323
- return response;
1323
+ async ensureRepoAvailableForPath(path) {
1324
+ const configured = this.availableRepos.find((repo) => repo.path === path);
1325
+ if (configured && this.repos.has(path))
1326
+ return configured;
1327
+ try {
1328
+ const requestedGit = new GitOperations(path);
1329
+ if (!(await requestedGit.isValidRepo()))
1330
+ return null;
1331
+ const rootPath = await requestedGit.getGitRoot();
1332
+ const existing = this.availableRepos.find((repo) => repo.path === rootPath);
1333
+ if (existing) {
1334
+ if (!this.repos.has(rootPath)) {
1335
+ this.repos.set(rootPath, new GitOperations(rootPath));
1336
+ }
1337
+ return existing;
1338
+ }
1339
+ const git = new GitOperations(rootPath);
1340
+ const { current } = await git.getBranches();
1341
+ const repo = {
1342
+ path: rootPath,
1343
+ name: basename(rootPath),
1344
+ currentBranch: current,
1345
+ };
1346
+ this.repos.set(rootPath, git);
1347
+ this.availableRepos.push(repo);
1348
+ if (!this.defaultRepoPath) {
1349
+ this.defaultRepoPath = rootPath;
1350
+ }
1351
+ addManagedRepo(rootPath);
1352
+ return repo;
1353
+ }
1354
+ catch {
1355
+ return null;
1324
1356
  }
1325
- this.clientRepos.set(peerAddress, path);
1326
- const response = createMessage('agent:switch-repo:response', {
1327
- success: true,
1328
- newPath: path,
1329
- });
1330
- response.id = message.id;
1331
- return response;
1332
1357
  }
1333
1358
  async handleBrowseDirectory(message) {
1334
1359
  const requestedPath = message.payload.path || homedir();
@@ -1803,7 +1828,7 @@ export class MessageHandler {
1803
1828
  async handleClaudeStart(message, peerAddress) {
1804
1829
  const { prompt, cwd: payloadCwd, agent, allowedTools, systemPrompt, model, permissionMode, sandboxed, reasoningEffort, contextWindow, attachmentIds } = message.payload;
1805
1830
  const legacyProvider = message.payload.provider;
1806
- const cwd = payloadCwd || this.getClientRepoPath(peerAddress);
1831
+ const cwd = payloadCwd || this.defaultRepoPath;
1807
1832
  const resolvedAgent = agent ?? (legacyProvider === 'codex-mcp' ? 'codex' : legacyProvider ? 'claude-code' : undefined);
1808
1833
  // Coerce a Codex model the user's account doesn't actually support
1809
1834
  // (e.g. they kept `claude-opus-4-7` selected before switching to a
@@ -1882,7 +1907,7 @@ export class MessageHandler {
1882
1907
  async handleClaudeResume(message, peerAddress) {
1883
1908
  const { sessionId: requestedId, prompt, cwd: payloadCwd, agent, attachmentIds, interruptCurrentTurn } = message.payload;
1884
1909
  const legacyProvider = message.payload.provider;
1885
- const cwd = payloadCwd || this.getClientRepoPath(peerAddress);
1910
+ const cwd = payloadCwd || this.defaultRepoPath;
1886
1911
  const resolvedAgent = agent ?? (legacyProvider === 'codex-mcp' ? 'codex' : legacyProvider ? 'claude-code' : undefined);
1887
1912
  const activeCfg = this.claudeService.getSessionConfig(requestedId);
1888
1913
  console.log(`[agent:resume] session=${requestedId} agent=${resolvedAgent ?? 'stored'} model=${activeCfg.model ?? 'default'} cwd=${cwd} prompt=${prompt.slice(0, 80)}${attachmentIds && attachmentIds.length > 0 ? ` [+${attachmentIds.length} attachments]` : ''}`);
@@ -2037,9 +2062,9 @@ export class MessageHandler {
2037
2062
  response.id = message.id;
2038
2063
  return response;
2039
2064
  }
2040
- async handleClaudeGetCards(message, peerAddress) {
2065
+ async handleClaudeGetCards(message) {
2041
2066
  const { sessionId, cwd: payloadCwd, offset = 0, limit = 50 } = message.payload;
2042
- const cwd = payloadCwd || this.getClientRepoPath(peerAddress);
2067
+ const cwd = payloadCwd || this.defaultRepoPath;
2043
2068
  try {
2044
2069
  const result = await this.claudeService.getCards(sessionId, cwd, offset, limit);
2045
2070
  const response = createMessage('claude:get-cards:response', result);
@@ -2462,7 +2487,7 @@ export class MessageHandler {
2462
2487
  // detached HEAD returning empty `current`) doesn't drop the root
2463
2488
  // and make the PWA's Git section flash empty. Branch + dirty are
2464
2489
  // filled in best-effort below; undefined just means "unknown".
2465
- const isRootRepo = existsSync(join(cwd, '.git'));
2490
+ const isRootRepo = await hasPlausibleGitMarker(cwd);
2466
2491
  if (isRootRepo) {
2467
2492
  repos.push({
2468
2493
  path: cwd,
@@ -2521,7 +2546,7 @@ export class MessageHandler {
2521
2546
  catch {
2522
2547
  continue;
2523
2548
  }
2524
- if (existsSync(join(full, '.git')) && !seen.has(full)) {
2549
+ if ((await hasPlausibleGitMarker(full)) && !seen.has(full)) {
2525
2550
  seen.add(full);
2526
2551
  const branch = await this.getGitBranchQuiet(full);
2527
2552
  repos.push({