linkshell-cli 0.2.40 → 0.2.42

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.
@@ -51,7 +51,7 @@ interface TerminalInstance {
51
51
  status: "running" | "exited";
52
52
  hookServer?: http.Server;
53
53
  hookPort?: number;
54
- hookConfigPath?: string;
54
+ hookConfigPaths: string[];
55
55
  }
56
56
 
57
57
  function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
@@ -130,6 +130,7 @@ export class BridgeSession {
130
130
  }>>();
131
131
  // Pending permission responses: requestId → HTTP response callback
132
132
  private pendingPermissions = new Map<string, (decision: "allow" | "deny") => void>();
133
+ private hookMarker = `lsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
133
134
  private screenCapture: ScreenFallback | undefined;
134
135
  private screenShare: ScreenShare | undefined;
135
136
 
@@ -496,23 +497,23 @@ export class BridgeSession {
496
497
  // For "custom" shell, set up hooks for all providers since user may launch any of them
497
498
  let hookServer: http.Server | undefined;
498
499
  let hookPort: number | undefined;
499
- let hookConfigPath: string | undefined;
500
+ const hookConfigPaths: string[] = [];
500
501
 
501
502
  if (provider === "custom") {
502
503
  const result = await this.setupHookServer(terminalId, args, "claude");
503
504
  hookServer = result.server;
504
505
  hookPort = result.port;
505
- hookConfigPath = result.configPath;
506
- // Also set up hooks for other providers
507
- const curlCmd = `curl -s -X POST http://127.0.0.1:${result.port}/hook -H 'Content-Type: application/json' --data-binary @-`;
508
- this.setupCodexHooks(terminalId, curlCmd);
509
- this.setupGeminiHooks(terminalId, curlCmd);
510
- this.setupCopilotHooks(terminalId, curlCmd);
506
+ hookConfigPaths.push(result.configPath);
507
+ // Also set up hooks for other providers (curlCmd already has marker from setupHookServer)
508
+ const curlCmd = `curl -s -X POST 'http://127.0.0.1:${result.port}/hook?m=${this.hookMarker}' -H 'Content-Type: application/json' --data-binary @-`;
509
+ hookConfigPaths.push(this.setupCodexHooks(terminalId, curlCmd));
510
+ hookConfigPaths.push(this.setupGeminiHooks(terminalId, curlCmd));
511
+ hookConfigPaths.push(this.setupCopilotHooks(terminalId, curlCmd));
511
512
  } else if (provider === "claude" || provider === "codex" || provider === "gemini" || provider === "copilot") {
512
513
  const result = await this.setupHookServer(terminalId, args, provider);
513
514
  hookServer = result.server;
514
515
  hookPort = result.port;
515
- hookConfigPath = result.configPath;
516
+ hookConfigPaths.push(result.configPath);
516
517
  }
517
518
 
518
519
  const term: TerminalInstance = {
@@ -537,7 +538,7 @@ export class BridgeSession {
537
538
  status: "running",
538
539
  hookServer,
539
540
  hookPort,
540
- hookConfigPath,
541
+ hookConfigPaths,
541
542
  };
542
543
 
543
544
  term.pty.onData((data) => {
@@ -591,13 +592,23 @@ export class BridgeSession {
591
592
  port: number;
592
593
  configPath: string;
593
594
  }> {
595
+ const marker = this.hookMarker;
594
596
  const server = http.createServer((req, res) => {
595
597
  this.log(`hook server received: ${req.method} ${req.url}`);
596
- if (req.method !== "POST" || req.url !== "/hook") {
598
+ const reqUrl = new URL(req.url ?? "/", "http://localhost");
599
+ if (req.method !== "POST" || reqUrl.pathname !== "/hook") {
597
600
  res.writeHead(404);
598
601
  res.end();
599
602
  return;
600
603
  }
604
+ // Check marker — reject events without our marker (from other instances or non-linkshell CLIs)
605
+ const reqMarker = reqUrl.searchParams.get("m");
606
+ if (reqMarker !== marker) {
607
+ this.log(`ignoring hook event with marker=${reqMarker} (expected ${marker})`);
608
+ res.writeHead(200);
609
+ res.end("ok");
610
+ return;
611
+ }
601
612
  let body = "";
602
613
  req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
603
614
  req.on("end", () => {
@@ -643,9 +654,9 @@ export class BridgeSession {
643
654
  });
644
655
  server.on("error", reject);
645
656
  });
646
- this.log(`hook server for ${terminalId} (${provider}) listening on port ${port}`);
657
+ this.log(`hook server for ${terminalId} (${provider}) listening on port ${port}, marker=${marker}`);
647
658
 
648
- const curlCmd = `curl -s -X POST http://127.0.0.1:${port}/hook -H 'Content-Type: application/json' --data-binary @-`;
659
+ const curlCmd = `curl -s -X POST 'http://127.0.0.1:${port}/hook?m=${marker}' -H 'Content-Type: application/json' --data-binary @-`;
649
660
  let configPath: string;
650
661
 
651
662
  if (provider === "codex") {
@@ -668,35 +679,39 @@ export class BridgeSession {
668
679
  if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
669
680
  const settingsPath = join(claudeDir, "settings.json");
670
681
 
671
- // Backup existing settings
672
682
  let existing: Record<string, unknown> = {};
673
683
  try {
674
684
  existing = JSON.parse(readFileSync(settingsPath, "utf8"));
675
685
  } catch { /* doesn't exist yet */ }
676
686
 
677
- // Save backup for restore
678
- const backupPath = join(tmpdir(), `linkshell-claude-settings-backup-${terminalId}.json`);
679
- writeFileSync(backupPath, JSON.stringify(existing));
680
-
681
687
  const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
682
688
  const permissionEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 86400 }] };
683
689
 
684
- const merged = {
685
- ...existing,
686
- hooks: {
687
- PreToolUse: [hookEntry],
688
- PostToolUse: [hookEntry],
689
- PostToolUseFailure: [hookEntry],
690
- Stop: [hookEntry],
691
- PermissionRequest: [permissionEntry],
692
- UserPromptSubmit: [hookEntry],
693
- SessionStart: [hookEntry],
694
- },
690
+ const hookEvents: Record<string, typeof hookEntry> = {
691
+ PreToolUse: hookEntry,
692
+ PostToolUse: hookEntry,
693
+ PostToolUseFailure: hookEntry,
694
+ Stop: hookEntry,
695
+ PermissionRequest: permissionEntry,
696
+ UserPromptSubmit: hookEntry,
697
+ SessionStart: hookEntry,
695
698
  };
699
+
700
+ // Append our entries to existing hooks (first remove stale linkshell entries)
701
+ const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
702
+ for (const [eventName, entry] of Object.entries(hookEvents)) {
703
+ let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
704
+ // Remove any dead linkshell hook entries (from previous instances)
705
+ arr = arr.filter((e) => !JSON.stringify(e).includes("/hook?m="));
706
+ arr.push(entry);
707
+ existingHooks[eventName] = arr;
708
+ }
709
+
710
+ const merged = { ...existing, hooks: existingHooks };
696
711
  writeFileSync(settingsPath, JSON.stringify(merged, null, 2));
697
- this.log(`claude hooks written to ${settingsPath} (backup at ${backupPath})`);
712
+ this.log(`claude hooks appended to ${settingsPath}`);
698
713
 
699
- return backupPath;
714
+ return settingsPath;
700
715
  }
701
716
 
702
717
  private setupCodexHooks(terminalId: string, curlCmd: string): string {
@@ -716,27 +731,25 @@ export class BridgeSession {
716
731
 
717
732
  const hooksPath = join(codexDir, "hooks.json");
718
733
  const hookEntry = { hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
719
- const config = {
720
- hooks: {
721
- SessionStart: [hookEntry],
722
- PreToolUse: [hookEntry],
723
- PostToolUse: [hookEntry],
724
- },
734
+ const hookEvents: Record<string, typeof hookEntry> = {
735
+ SessionStart: hookEntry,
736
+ PreToolUse: hookEntry,
737
+ PostToolUse: hookEntry,
725
738
  };
726
739
 
727
- // Backup existing hooks.json if present and not ours
728
- if (existsSync(hooksPath)) {
729
- try {
730
- const existing = readFileSync(hooksPath, "utf8");
731
- if (!existing.includes(`127.0.0.1:`) || !existing.includes("/hook")) {
732
- writeFileSync(`${hooksPath}.linkshell-backup`, existing);
733
- this.log(`backed up existing codex hooks`);
734
- }
735
- } catch { /* ignore */ }
740
+ // Read existing and append
741
+ let existing: { hooks?: Record<string, unknown[]> } = {};
742
+ try { existing = JSON.parse(readFileSync(hooksPath, "utf8")); } catch { /* doesn't exist yet */ }
743
+ const existingHooks = existing.hooks ?? {};
744
+ for (const [eventName, entry] of Object.entries(hookEvents)) {
745
+ let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
746
+ arr = arr.filter((e) => !JSON.stringify(e).includes("/hook?m="));
747
+ arr.push(entry);
748
+ existingHooks[eventName] = arr;
736
749
  }
737
750
 
738
- writeFileSync(hooksPath, JSON.stringify(config, null, 2));
739
- this.log(`codex hook config written to ${hooksPath}`);
751
+ writeFileSync(hooksPath, JSON.stringify({ hooks: existingHooks }, null, 2));
752
+ this.log(`codex hooks appended to ${hooksPath}`);
740
753
  return hooksPath;
741
754
  }
742
755
 
@@ -747,13 +760,13 @@ export class BridgeSession {
747
760
 
748
761
  const settingsPath = join(geminiDir, "settings.json");
749
762
  const hookEntry = { hooks: [{ type: "command", command: curlCmd, timeout: 5000 }] };
750
- const hookConfig = {
751
- SessionStart: [hookEntry],
752
- BeforeTool: [hookEntry],
753
- AfterTool: [hookEntry],
754
- BeforeSubmitPrompt: [hookEntry],
755
- AfterSubmitPrompt: [hookEntry],
756
- SessionEnd: [hookEntry],
763
+ const hookEvents: Record<string, typeof hookEntry> = {
764
+ SessionStart: hookEntry,
765
+ BeforeTool: hookEntry,
766
+ AfterTool: hookEntry,
767
+ BeforeSubmitPrompt: hookEntry,
768
+ AfterSubmitPrompt: hookEntry,
769
+ SessionEnd: hookEntry,
757
770
  };
758
771
 
759
772
  // Merge with existing settings if present
@@ -762,24 +775,26 @@ export class BridgeSession {
762
775
  existing = JSON.parse(readFileSync(settingsPath, "utf8"));
763
776
  } catch { /* doesn't exist yet */ }
764
777
 
765
- // Backup existing hooks if present and not ours
766
- if (existing.hooks && JSON.stringify(existing.hooks).indexOf("127.0.0.1:") === -1) {
767
- writeFileSync(`${settingsPath}.linkshell-backup`, JSON.stringify(existing, null, 2));
768
- this.log(`backed up existing gemini settings`);
778
+ const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
779
+ for (const [eventName, entry] of Object.entries(hookEvents)) {
780
+ const arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
781
+ arr.push(entry);
782
+ existingHooks[eventName] = arr;
769
783
  }
770
784
 
771
- existing.hooks = hookConfig;
785
+ existing.hooks = existingHooks;
772
786
  writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
773
- this.log(`gemini hook config written to ${settingsPath}`);
787
+ this.log(`gemini hooks appended to ${settingsPath}`);
774
788
  return settingsPath;
775
789
  }
776
790
 
777
791
  private setupCopilotHooks(terminalId: string, curlCmd: string): string {
778
792
  // Copilot uses ~/.copilot/hooks/<name>.json — flat format with `bash` key
793
+ // Each linkshell instance uses a unique file based on marker
779
794
  const copilotDir = join(homedir(), ".copilot", "hooks");
780
795
  if (!existsSync(copilotDir)) mkdirSync(copilotDir, { recursive: true });
781
796
 
782
- const hooksPath = join(copilotDir, "linkshell.json");
797
+ const hooksPath = join(copilotDir, `linkshell-${this.hookMarker}.json`);
783
798
  const mkHook = (eventName: string) => ({
784
799
  type: "command",
785
800
  bash: `${curlCmd.replace('"$(cat)"', `'{"hook_event_name":"${eventName}"}'`)}`,
@@ -1037,33 +1052,58 @@ export class BridgeSession {
1037
1052
  term.hookServer = undefined;
1038
1053
  this.log(`hook server closed for ${term.id}`);
1039
1054
  }
1040
- if (term.hookConfigPath) {
1041
- const configPath = term.hookConfigPath;
1055
+ const marker = this.hookMarker;
1056
+ for (const configPath of term.hookConfigPaths) {
1042
1057
  try {
1043
- if (term.provider === "claude") {
1044
- // configPath is the backup file — restore settings.json from it
1045
- const settingsPath = join(homedir(), ".claude", "settings.json");
1058
+ // Copilot: per-instance file — just delete it
1059
+ if (configPath.includes(`linkshell-${marker}`)) {
1046
1060
  if (existsSync(configPath)) {
1047
- const backup = readFileSync(configPath, "utf8");
1048
- writeFileSync(settingsPath, backup);
1049
1061
  unlinkSync(configPath);
1050
- this.log(`restored claude settings.json from backup`);
1062
+ this.log(`removed copilot hook file ${configPath}`);
1051
1063
  }
1052
1064
  } else {
1053
- // Codex/Gemini/Copilot use home dir configs restore backup if exists
1054
- const backupPath = `${configPath}.linkshell-backup`;
1055
- if (existsSync(backupPath)) {
1056
- const backup = readFileSync(backupPath, "utf8");
1057
- writeFileSync(configPath, backup);
1058
- unlinkSync(backupPath);
1059
- this.log(`restored backup for ${configPath}`);
1060
- } else if (configPath.startsWith(tmpdir())) {
1061
- unlinkSync(configPath);
1062
- }
1065
+ // Claude/Codex/Gemini: remove our entries from the shared config
1066
+ this.removeHookEntries(configPath, marker);
1063
1067
  }
1064
1068
  } catch { /* ignore */ }
1065
- term.hookConfigPath = undefined;
1066
1069
  }
1070
+ term.hookConfigPaths = [];
1071
+ }
1072
+
1073
+ /** Remove hook entries containing our marker from a JSON config file */
1074
+ private removeHookEntries(configPath: string, marker: string): void {
1075
+ if (!existsSync(configPath)) return;
1076
+ try {
1077
+ const raw = JSON.parse(readFileSync(configPath, "utf8"));
1078
+ const hooks = raw.hooks as Record<string, unknown[]> | undefined;
1079
+ if (!hooks) return;
1080
+
1081
+ let changed = false;
1082
+ for (const [eventName, entries] of Object.entries(hooks)) {
1083
+ if (!Array.isArray(entries)) continue;
1084
+ const filtered = entries.filter((entry) => {
1085
+ const str = JSON.stringify(entry);
1086
+ return !str.includes(marker);
1087
+ });
1088
+ if (filtered.length !== entries.length) {
1089
+ changed = true;
1090
+ if (filtered.length === 0) {
1091
+ delete hooks[eventName];
1092
+ } else {
1093
+ hooks[eventName] = filtered;
1094
+ }
1095
+ }
1096
+ }
1097
+
1098
+ if (changed) {
1099
+ // If no hooks left, remove the hooks key entirely
1100
+ if (Object.keys(hooks).length === 0) {
1101
+ delete raw.hooks;
1102
+ }
1103
+ writeFileSync(configPath, JSON.stringify(raw, null, 2));
1104
+ this.log(`removed our hook entries from ${configPath}`);
1105
+ }
1106
+ } catch { /* ignore parse errors */ }
1067
1107
  }
1068
1108
 
1069
1109
  private send(message: Envelope): void {