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
|
-
|
|
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 — 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
|
|
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 (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
|
|
712
|
+
this.log(`claude hooks appended to ${settingsPath}`);
|
|
698
713
|
|
|
699
|
-
return
|
|
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
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
//
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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(
|
|
739
|
-
this.log(`codex
|
|
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
|
|
751
|
-
SessionStart:
|
|
752
|
-
BeforeTool:
|
|
753
|
-
AfterTool:
|
|
754
|
-
BeforeSubmitPrompt:
|
|
755
|
-
AfterSubmitPrompt:
|
|
756
|
-
SessionEnd:
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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 =
|
|
785
|
+
existing.hooks = existingHooks;
|
|
772
786
|
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
|
773
|
-
this.log(`gemini
|
|
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,
|
|
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
|
-
|
|
1041
|
-
|
|
1055
|
+
const marker = this.hookMarker;
|
|
1056
|
+
for (const configPath of term.hookConfigPaths) {
|
|
1042
1057
|
try {
|
|
1043
|
-
|
|
1044
|
-
|
|
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(`
|
|
1062
|
+
this.log(`removed copilot hook file ${configPath}`);
|
|
1051
1063
|
}
|
|
1052
1064
|
} 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
|
-
}
|
|
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 {
|