linkshell-cli 0.2.40 → 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
|
710
|
+
this.log(`claude hooks appended to ${settingsPath}`);
|
|
698
711
|
|
|
699
|
-
return
|
|
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
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
//
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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(
|
|
739
|
-
this.log(`codex
|
|
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
|
|
751
|
-
SessionStart:
|
|
752
|
-
BeforeTool:
|
|
753
|
-
AfterTool:
|
|
754
|
-
BeforeSubmitPrompt:
|
|
755
|
-
AfterSubmitPrompt:
|
|
756
|
-
SessionEnd:
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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 =
|
|
782
|
+
existing.hooks = existingHooks;
|
|
772
783
|
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
|
773
|
-
this.log(`gemini
|
|
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,
|
|
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
|
-
|
|
1041
|
-
|
|
1052
|
+
const marker = this.hookMarker;
|
|
1053
|
+
for (const configPath of term.hookConfigPaths) {
|
|
1042
1054
|
try {
|
|
1043
|
-
|
|
1044
|
-
|
|
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(`
|
|
1059
|
+
this.log(`removed copilot hook file ${configPath}`);
|
|
1051
1060
|
}
|
|
1052
1061
|
} else {
|
|
1053
|
-
// Codex/Gemini
|
|
1054
|
-
|
|
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 {
|