linkshell-cli 0.2.39 → 0.2.41

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 — ignore events from other linkshell instances or non-linkshell CLIs
605
+ const reqMarker = reqUrl.searchParams.get("m");
606
+ if (reqMarker && reqMarker !== marker) {
607
+ this.log(`ignoring hook event with foreign marker: ${reqMarker}`);
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,37 @@ 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 (don't overwrite)
701
+ const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
702
+ for (const [eventName, entry] of Object.entries(hookEvents)) {
703
+ const arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
704
+ arr.push(entry);
705
+ existingHooks[eventName] = arr;
706
+ }
707
+
708
+ const merged = { ...existing, hooks: existingHooks };
696
709
  writeFileSync(settingsPath, JSON.stringify(merged, null, 2));
697
- this.log(`claude hooks written to ${settingsPath} (backup at ${backupPath})`);
710
+ this.log(`claude hooks appended to ${settingsPath}`);
698
711
 
699
- return backupPath;
712
+ return settingsPath;
700
713
  }
701
714
 
702
715
  private setupCodexHooks(terminalId: string, curlCmd: string): string {
@@ -716,27 +729,24 @@ export class BridgeSession {
716
729
 
717
730
  const hooksPath = join(codexDir, "hooks.json");
718
731
  const hookEntry = { hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
719
- const config = {
720
- hooks: {
721
- SessionStart: [hookEntry],
722
- PreToolUse: [hookEntry],
723
- PostToolUse: [hookEntry],
724
- },
732
+ const hookEvents: Record<string, typeof hookEntry> = {
733
+ SessionStart: hookEntry,
734
+ PreToolUse: hookEntry,
735
+ PostToolUse: hookEntry,
725
736
  };
726
737
 
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 */ }
738
+ // Read existing and append
739
+ let existing: { hooks?: Record<string, unknown[]> } = {};
740
+ try { existing = JSON.parse(readFileSync(hooksPath, "utf8")); } catch { /* doesn't exist yet */ }
741
+ const existingHooks = existing.hooks ?? {};
742
+ for (const [eventName, entry] of Object.entries(hookEvents)) {
743
+ const arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
744
+ arr.push(entry);
745
+ existingHooks[eventName] = arr;
736
746
  }
737
747
 
738
- writeFileSync(hooksPath, JSON.stringify(config, null, 2));
739
- this.log(`codex hook config written to ${hooksPath}`);
748
+ writeFileSync(hooksPath, JSON.stringify({ hooks: existingHooks }, null, 2));
749
+ this.log(`codex hooks appended to ${hooksPath}`);
740
750
  return hooksPath;
741
751
  }
742
752
 
@@ -747,13 +757,13 @@ export class BridgeSession {
747
757
 
748
758
  const settingsPath = join(geminiDir, "settings.json");
749
759
  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],
760
+ const hookEvents: Record<string, typeof hookEntry> = {
761
+ SessionStart: hookEntry,
762
+ BeforeTool: hookEntry,
763
+ AfterTool: hookEntry,
764
+ BeforeSubmitPrompt: hookEntry,
765
+ AfterSubmitPrompt: hookEntry,
766
+ SessionEnd: hookEntry,
757
767
  };
758
768
 
759
769
  // Merge with existing settings if present
@@ -762,24 +772,26 @@ export class BridgeSession {
762
772
  existing = JSON.parse(readFileSync(settingsPath, "utf8"));
763
773
  } catch { /* doesn't exist yet */ }
764
774
 
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`);
775
+ const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
776
+ for (const [eventName, entry] of Object.entries(hookEvents)) {
777
+ const arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
778
+ arr.push(entry);
779
+ existingHooks[eventName] = arr;
769
780
  }
770
781
 
771
- existing.hooks = hookConfig;
782
+ existing.hooks = existingHooks;
772
783
  writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
773
- this.log(`gemini hook config written to ${settingsPath}`);
784
+ this.log(`gemini hooks appended to ${settingsPath}`);
774
785
  return settingsPath;
775
786
  }
776
787
 
777
788
  private setupCopilotHooks(terminalId: string, curlCmd: string): string {
778
789
  // Copilot uses ~/.copilot/hooks/<name>.json — flat format with `bash` key
790
+ // Each linkshell instance uses a unique file based on marker
779
791
  const copilotDir = join(homedir(), ".copilot", "hooks");
780
792
  if (!existsSync(copilotDir)) mkdirSync(copilotDir, { recursive: true });
781
793
 
782
- const hooksPath = join(copilotDir, "linkshell.json");
794
+ const hooksPath = join(copilotDir, `linkshell-${this.hookMarker}.json`);
783
795
  const mkHook = (eventName: string) => ({
784
796
  type: "command",
785
797
  bash: `${curlCmd.replace('"$(cat)"', `'{"hook_event_name":"${eventName}"}'`)}`,
@@ -1037,33 +1049,58 @@ export class BridgeSession {
1037
1049
  term.hookServer = undefined;
1038
1050
  this.log(`hook server closed for ${term.id}`);
1039
1051
  }
1040
- if (term.hookConfigPath) {
1041
- const configPath = term.hookConfigPath;
1052
+ const marker = this.hookMarker;
1053
+ for (const configPath of term.hookConfigPaths) {
1042
1054
  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");
1055
+ // Copilot: per-instance file — just delete it
1056
+ if (configPath.includes(`linkshell-${marker}`)) {
1046
1057
  if (existsSync(configPath)) {
1047
- const backup = readFileSync(configPath, "utf8");
1048
- writeFileSync(settingsPath, backup);
1049
1058
  unlinkSync(configPath);
1050
- this.log(`restored claude settings.json from backup`);
1059
+ this.log(`removed copilot hook file ${configPath}`);
1051
1060
  }
1052
1061
  } 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
- }
1062
+ // Claude/Codex/Gemini: remove our entries from the shared config
1063
+ this.removeHookEntries(configPath, marker);
1063
1064
  }
1064
1065
  } catch { /* ignore */ }
1065
- term.hookConfigPath = undefined;
1066
1066
  }
1067
+ term.hookConfigPaths = [];
1068
+ }
1069
+
1070
+ /** Remove hook entries containing our marker from a JSON config file */
1071
+ private removeHookEntries(configPath: string, marker: string): void {
1072
+ if (!existsSync(configPath)) return;
1073
+ try {
1074
+ const raw = JSON.parse(readFileSync(configPath, "utf8"));
1075
+ const hooks = raw.hooks as Record<string, unknown[]> | undefined;
1076
+ if (!hooks) return;
1077
+
1078
+ let changed = false;
1079
+ for (const [eventName, entries] of Object.entries(hooks)) {
1080
+ if (!Array.isArray(entries)) continue;
1081
+ const filtered = entries.filter((entry) => {
1082
+ const str = JSON.stringify(entry);
1083
+ return !str.includes(marker);
1084
+ });
1085
+ if (filtered.length !== entries.length) {
1086
+ changed = true;
1087
+ if (filtered.length === 0) {
1088
+ delete hooks[eventName];
1089
+ } else {
1090
+ hooks[eventName] = filtered;
1091
+ }
1092
+ }
1093
+ }
1094
+
1095
+ if (changed) {
1096
+ // If no hooks left, remove the hooks key entirely
1097
+ if (Object.keys(hooks).length === 0) {
1098
+ delete raw.hooks;
1099
+ }
1100
+ writeFileSync(configPath, JSON.stringify(raw, null, 2));
1101
+ this.log(`removed our hook entries from ${configPath}`);
1102
+ }
1103
+ } catch { /* ignore parse errors */ }
1067
1104
  }
1068
1105
 
1069
1106
  private send(message: Envelope): void {
@@ -147,6 +147,16 @@ export class ScreenShare {
147
147
  const offer = await this.pc.createOffer();
148
148
  await this.pc.setLocalDescription(offer);
149
149
 
150
+ // Notify client that WebRTC mode is active BEFORE sending the offer,
151
+ // so the client mounts the WebView and is ready to process the offer.
152
+ this.options.onStatus(
153
+ createEnvelope({
154
+ type: "screen.status",
155
+ sessionId: this.options.sessionId,
156
+ payload: { active: true, mode: "webrtc" as const },
157
+ }),
158
+ );
159
+
150
160
  this.options.onSignal(
151
161
  createEnvelope({
152
162
  type: "screen.offer",