linkshell-cli 0.2.18 → 0.2.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.
- package/dist/cli/src/commands/setup.js +1 -1
- package/dist/cli/src/commands/setup.js.map +1 -1
- package/dist/cli/src/providers.d.ts +1 -1
- package/dist/cli/src/providers.js +33 -0
- package/dist/cli/src/providers.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +11 -0
- package/dist/cli/src/runtime/bridge-session.js +219 -32
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +26 -20
- package/dist/shared-protocol/src/index.js +1 -0
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +2 -2
- package/src/commands/setup.ts +2 -2
- package/src/providers.ts +51 -1
- package/src/runtime/bridge-session.ts +226 -30
|
@@ -2,7 +2,7 @@ import * as pty from "node-pty";
|
|
|
2
2
|
import * as http from "node:http";
|
|
3
3
|
import WebSocket from "ws";
|
|
4
4
|
import { hostname, platform, homedir } from "node:os";
|
|
5
|
-
import { writeFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { writeFileSync, readFileSync, readdirSync, statSync, unlinkSync, mkdirSync, existsSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join, basename, resolve } from "node:path";
|
|
8
8
|
import {
|
|
@@ -47,6 +47,7 @@ interface TerminalInstance {
|
|
|
47
47
|
provider: string;
|
|
48
48
|
scrollback: ScrollbackBuffer;
|
|
49
49
|
outputSeq: number;
|
|
50
|
+
statusSeq: number;
|
|
50
51
|
status: "running" | "exited";
|
|
51
52
|
hookServer?: http.Server;
|
|
52
53
|
hookPort?: number;
|
|
@@ -468,16 +469,16 @@ export class BridgeSession {
|
|
|
468
469
|
const provider = providerOverride ?? this.options.providerConfig.provider;
|
|
469
470
|
const args = [...this.options.providerConfig.args];
|
|
470
471
|
|
|
471
|
-
//
|
|
472
|
+
// Set up hook server for structured status (all supported providers)
|
|
472
473
|
let hookServer: http.Server | undefined;
|
|
473
474
|
let hookPort: number | undefined;
|
|
474
475
|
let hookConfigPath: string | undefined;
|
|
475
476
|
|
|
476
|
-
if (provider === "claude") {
|
|
477
|
-
const
|
|
478
|
-
hookServer = server;
|
|
479
|
-
hookPort = port;
|
|
480
|
-
hookConfigPath = configPath;
|
|
477
|
+
if (provider === "claude" || provider === "codex" || provider === "gemini" || provider === "copilot") {
|
|
478
|
+
const result = await this.setupHookServer(terminalId, args, provider);
|
|
479
|
+
hookServer = result.server;
|
|
480
|
+
hookPort = result.port;
|
|
481
|
+
hookConfigPath = result.configPath;
|
|
481
482
|
}
|
|
482
483
|
|
|
483
484
|
const term: TerminalInstance = {
|
|
@@ -498,6 +499,7 @@ export class BridgeSession {
|
|
|
498
499
|
provider,
|
|
499
500
|
scrollback: new ScrollbackBuffer(1000),
|
|
500
501
|
outputSeq: 0,
|
|
502
|
+
statusSeq: 0,
|
|
501
503
|
status: "running",
|
|
502
504
|
hookServer,
|
|
503
505
|
hookPort,
|
|
@@ -550,7 +552,7 @@ export class BridgeSession {
|
|
|
550
552
|
this.log(`spawned terminal ${terminalId} in ${cwd}`);
|
|
551
553
|
}
|
|
552
554
|
|
|
553
|
-
private async setupHookServer(terminalId: string, args: string[]): Promise<{
|
|
555
|
+
private async setupHookServer(terminalId: string, args: string[], provider: string): Promise<{
|
|
554
556
|
server: http.Server;
|
|
555
557
|
port: number;
|
|
556
558
|
configPath: string;
|
|
@@ -568,7 +570,7 @@ export class BridgeSession {
|
|
|
568
570
|
res.end("ok");
|
|
569
571
|
try {
|
|
570
572
|
const event = JSON.parse(body);
|
|
571
|
-
this.handleHookEvent(terminalId, event);
|
|
573
|
+
this.handleHookEvent(terminalId, event, provider);
|
|
572
574
|
} catch (e) {
|
|
573
575
|
this.log(`hook parse error: ${e}`);
|
|
574
576
|
}
|
|
@@ -583,12 +585,28 @@ export class BridgeSession {
|
|
|
583
585
|
});
|
|
584
586
|
server.on("error", reject);
|
|
585
587
|
});
|
|
586
|
-
this.log(`hook server for ${terminalId} listening on port ${port}`);
|
|
588
|
+
this.log(`hook server for ${terminalId} (${provider}) listening on port ${port}`);
|
|
587
589
|
|
|
588
|
-
// Write temporary hook config
|
|
589
|
-
const configPath = join(tmpdir(), `linkshell-hooks-${terminalId}.json`);
|
|
590
590
|
const curlCmd = `curl -s -X POST http://127.0.0.1:${port}/hook -H 'Content-Type: application/json' -d "$(cat)"`;
|
|
591
|
-
|
|
591
|
+
let configPath: string;
|
|
592
|
+
|
|
593
|
+
if (provider === "codex") {
|
|
594
|
+
configPath = this.setupCodexHooks(terminalId, curlCmd);
|
|
595
|
+
} else if (provider === "gemini") {
|
|
596
|
+
configPath = this.setupGeminiHooks(terminalId, curlCmd);
|
|
597
|
+
} else if (provider === "copilot") {
|
|
598
|
+
configPath = this.setupCopilotHooks(terminalId, curlCmd);
|
|
599
|
+
} else {
|
|
600
|
+
// Claude (default)
|
|
601
|
+
configPath = this.setupClaudeHooks(terminalId, curlCmd, args);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return { server, port, configPath };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private setupClaudeHooks(terminalId: string, curlCmd: string, args: string[]): string {
|
|
608
|
+
const configPath = join(tmpdir(), `linkshell-hooks-${terminalId}.json`);
|
|
609
|
+
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
|
|
592
610
|
const config = {
|
|
593
611
|
hooks: {
|
|
594
612
|
PreToolUse: [hookEntry],
|
|
@@ -596,19 +614,125 @@ export class BridgeSession {
|
|
|
596
614
|
PostToolUseFailure: [hookEntry],
|
|
597
615
|
Stop: [hookEntry],
|
|
598
616
|
PermissionRequest: [hookEntry],
|
|
617
|
+
UserPromptSubmit: [hookEntry],
|
|
618
|
+
SessionStart: [hookEntry],
|
|
599
619
|
},
|
|
600
620
|
};
|
|
601
621
|
writeFileSync(configPath, JSON.stringify(config));
|
|
602
|
-
this.log(`hook config written to ${configPath}`);
|
|
622
|
+
this.log(`claude hook config written to ${configPath}`);
|
|
603
623
|
|
|
604
624
|
// Inject --settings into Claude CLI args
|
|
605
625
|
args.push("--settings", configPath);
|
|
626
|
+
return configPath;
|
|
627
|
+
}
|
|
606
628
|
|
|
607
|
-
|
|
629
|
+
private setupCodexHooks(terminalId: string, curlCmd: string): string {
|
|
630
|
+
// Codex uses ~/.codex/hooks.json — nested format (no matcher)
|
|
631
|
+
const codexDir = join(homedir(), ".codex");
|
|
632
|
+
if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
|
|
633
|
+
|
|
634
|
+
// Ensure codex_hooks = true in config.toml
|
|
635
|
+
const tomlPath = join(codexDir, "config.toml");
|
|
636
|
+
let tomlContent = "";
|
|
637
|
+
try { tomlContent = readFileSync(tomlPath, "utf8"); } catch { /* doesn't exist yet */ }
|
|
638
|
+
if (!tomlContent.includes("codex_hooks")) {
|
|
639
|
+
tomlContent += `\ncodex_hooks = true\n`;
|
|
640
|
+
writeFileSync(tomlPath, tomlContent);
|
|
641
|
+
this.log(`enabled codex_hooks in ${tomlPath}`);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const hooksPath = join(codexDir, "hooks.json");
|
|
645
|
+
const hookEntry = { hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
|
|
646
|
+
const config = {
|
|
647
|
+
hooks: {
|
|
648
|
+
SessionStart: [hookEntry],
|
|
649
|
+
PreToolUse: [hookEntry],
|
|
650
|
+
PostToolUse: [hookEntry],
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
// Backup existing hooks.json if present and not ours
|
|
655
|
+
if (existsSync(hooksPath)) {
|
|
656
|
+
try {
|
|
657
|
+
const existing = readFileSync(hooksPath, "utf8");
|
|
658
|
+
if (!existing.includes(`127.0.0.1:`) || !existing.includes("/hook")) {
|
|
659
|
+
writeFileSync(`${hooksPath}.linkshell-backup`, existing);
|
|
660
|
+
this.log(`backed up existing codex hooks`);
|
|
661
|
+
}
|
|
662
|
+
} catch { /* ignore */ }
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
writeFileSync(hooksPath, JSON.stringify(config, null, 2));
|
|
666
|
+
this.log(`codex hook config written to ${hooksPath}`);
|
|
667
|
+
return hooksPath;
|
|
608
668
|
}
|
|
609
669
|
|
|
610
|
-
private
|
|
611
|
-
|
|
670
|
+
private setupGeminiHooks(terminalId: string, curlCmd: string): string {
|
|
671
|
+
// Gemini uses ~/.gemini/settings.json — nested format, timeout in milliseconds
|
|
672
|
+
const geminiDir = join(homedir(), ".gemini");
|
|
673
|
+
if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
|
|
674
|
+
|
|
675
|
+
const settingsPath = join(geminiDir, "settings.json");
|
|
676
|
+
const hookEntry = { hooks: [{ type: "command", command: curlCmd, timeout: 5000 }] };
|
|
677
|
+
const hookConfig = {
|
|
678
|
+
SessionStart: [hookEntry],
|
|
679
|
+
BeforeTool: [hookEntry],
|
|
680
|
+
AfterTool: [hookEntry],
|
|
681
|
+
BeforeSubmitPrompt: [hookEntry],
|
|
682
|
+
AfterSubmitPrompt: [hookEntry],
|
|
683
|
+
SessionEnd: [hookEntry],
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
// Merge with existing settings if present
|
|
687
|
+
let existing: Record<string, unknown> = {};
|
|
688
|
+
try {
|
|
689
|
+
existing = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
690
|
+
} catch { /* doesn't exist yet */ }
|
|
691
|
+
|
|
692
|
+
// Backup existing hooks if present and not ours
|
|
693
|
+
if (existing.hooks && JSON.stringify(existing.hooks).indexOf("127.0.0.1:") === -1) {
|
|
694
|
+
writeFileSync(`${settingsPath}.linkshell-backup`, JSON.stringify(existing, null, 2));
|
|
695
|
+
this.log(`backed up existing gemini settings`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
existing.hooks = hookConfig;
|
|
699
|
+
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
|
700
|
+
this.log(`gemini hook config written to ${settingsPath}`);
|
|
701
|
+
return settingsPath;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private setupCopilotHooks(terminalId: string, curlCmd: string): string {
|
|
705
|
+
// Copilot uses ~/.copilot/hooks/<name>.json — flat format with `bash` key
|
|
706
|
+
const copilotDir = join(homedir(), ".copilot", "hooks");
|
|
707
|
+
if (!existsSync(copilotDir)) mkdirSync(copilotDir, { recursive: true });
|
|
708
|
+
|
|
709
|
+
const hooksPath = join(copilotDir, "linkshell.json");
|
|
710
|
+
const mkHook = (eventName: string) => ({
|
|
711
|
+
type: "command",
|
|
712
|
+
bash: `${curlCmd.replace('"$(cat)"', `'{"hook_event_name":"${eventName}"}'`)}`,
|
|
713
|
+
timeoutSec: 5,
|
|
714
|
+
});
|
|
715
|
+
const config = {
|
|
716
|
+
version: 1,
|
|
717
|
+
hooks: {
|
|
718
|
+
sessionStart: [mkHook("sessionStart")],
|
|
719
|
+
preToolUse: [mkHook("preToolUse")],
|
|
720
|
+
postToolUse: [mkHook("postToolUse")],
|
|
721
|
+
sessionEnd: [mkHook("sessionEnd")],
|
|
722
|
+
},
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
writeFileSync(hooksPath, JSON.stringify(config, null, 2));
|
|
726
|
+
this.log(`copilot hook config written to ${hooksPath}`);
|
|
727
|
+
return hooksPath;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private handleHookEvent(terminalId: string, event: Record<string, unknown>, provider: string): void {
|
|
731
|
+
const rawHookName = (event.hook_event_name ?? event.event_name) as string | undefined;
|
|
732
|
+
if (!rawHookName) return;
|
|
733
|
+
|
|
734
|
+
// Normalize hook event names from different providers to unified names
|
|
735
|
+
const hookName = this.normalizeHookName(rawHookName, provider);
|
|
612
736
|
if (!hookName) return;
|
|
613
737
|
|
|
614
738
|
let phase: string;
|
|
@@ -620,34 +744,33 @@ export class BridgeSession {
|
|
|
620
744
|
switch (hookName) {
|
|
621
745
|
case "PreToolUse":
|
|
622
746
|
phase = "tool_use";
|
|
623
|
-
toolName = event.tool_name as string | undefined;
|
|
747
|
+
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
624
748
|
if (event.tool_input && typeof event.tool_input === "object") {
|
|
625
749
|
const input = event.tool_input as Record<string, unknown>;
|
|
626
750
|
toolInput = JSON.stringify(input).slice(0, 200);
|
|
751
|
+
} else if (event.toolInput && typeof event.toolInput === "object") {
|
|
752
|
+
toolInput = JSON.stringify(event.toolInput).slice(0, 200);
|
|
627
753
|
}
|
|
628
754
|
break;
|
|
629
755
|
case "PostToolUse":
|
|
630
756
|
phase = "thinking";
|
|
631
|
-
toolName = event.tool_name as string | undefined;
|
|
632
|
-
// Pop permission stack: tool completed
|
|
757
|
+
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
758
|
+
// Pop permission stack: tool completed
|
|
633
759
|
{
|
|
634
760
|
const stack = this.permissionStacks.get(terminalId);
|
|
635
761
|
if (stack && stack.length > 0) {
|
|
636
|
-
|
|
637
|
-
if (idx >= 0) stack.splice(idx, 1);
|
|
762
|
+
stack.pop();
|
|
638
763
|
if (stack.length === 0) this.permissionStacks.delete(terminalId);
|
|
639
764
|
}
|
|
640
765
|
}
|
|
641
766
|
break;
|
|
642
767
|
case "PostToolUseFailure":
|
|
643
768
|
phase = "error";
|
|
644
|
-
toolName = event.tool_name as string | undefined;
|
|
645
|
-
// Pop permission stack on failure too
|
|
769
|
+
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
646
770
|
{
|
|
647
771
|
const stack = this.permissionStacks.get(terminalId);
|
|
648
772
|
if (stack && stack.length > 0) {
|
|
649
|
-
|
|
650
|
-
if (idx >= 0) stack.splice(idx, 1);
|
|
773
|
+
stack.pop();
|
|
651
774
|
if (stack.length === 0) this.permissionStacks.delete(terminalId);
|
|
652
775
|
}
|
|
653
776
|
}
|
|
@@ -655,15 +778,16 @@ export class BridgeSession {
|
|
|
655
778
|
case "Stop":
|
|
656
779
|
phase = "idle";
|
|
657
780
|
if (event.stop_reason) summary = String(event.stop_reason);
|
|
658
|
-
// Clear all pending permissions on stop
|
|
659
781
|
this.permissionStacks.delete(terminalId);
|
|
660
782
|
break;
|
|
661
783
|
case "PermissionRequest":
|
|
662
784
|
phase = "waiting";
|
|
663
|
-
toolName = event.tool_name as string | undefined;
|
|
785
|
+
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
664
786
|
if (event.tool_input && typeof event.tool_input === "object") {
|
|
665
787
|
const input = event.tool_input as Record<string, unknown>;
|
|
666
788
|
permissionRequest = JSON.stringify(input).slice(0, 300);
|
|
789
|
+
} else if (event.toolInput && typeof event.toolInput === "object") {
|
|
790
|
+
permissionRequest = JSON.stringify(event.toolInput).slice(0, 300);
|
|
667
791
|
}
|
|
668
792
|
// Push to permission stack
|
|
669
793
|
{
|
|
@@ -680,23 +804,32 @@ export class BridgeSession {
|
|
|
680
804
|
});
|
|
681
805
|
}
|
|
682
806
|
break;
|
|
807
|
+
case "SessionStart":
|
|
808
|
+
phase = "idle";
|
|
809
|
+
summary = "session started";
|
|
810
|
+
break;
|
|
683
811
|
default:
|
|
684
812
|
return;
|
|
685
813
|
}
|
|
686
814
|
|
|
687
|
-
this.log(`hook event: ${hookName} → phase=${phase} tool=${toolName ?? "none"}`);
|
|
815
|
+
this.log(`hook event [${provider}]: ${rawHookName} → ${hookName} → phase=${phase} tool=${toolName ?? "none"}`);
|
|
688
816
|
|
|
689
817
|
// Build topPermission from stack
|
|
690
818
|
const stack = this.permissionStacks.get(terminalId);
|
|
691
819
|
const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
|
|
692
820
|
const pendingPermissionCount = stack?.length ?? 0;
|
|
693
821
|
|
|
822
|
+
// Increment statusSeq for ordering
|
|
823
|
+
const term = this.terminals.get(terminalId);
|
|
824
|
+
const seq = term ? term.statusSeq++ : 0;
|
|
825
|
+
|
|
694
826
|
this.send(createEnvelope({
|
|
695
827
|
type: "terminal.status",
|
|
696
828
|
sessionId: this.sessionId,
|
|
697
829
|
terminalId,
|
|
698
830
|
payload: {
|
|
699
831
|
phase,
|
|
832
|
+
seq,
|
|
700
833
|
...(toolName && { toolName }),
|
|
701
834
|
...(toolInput && { toolInput }),
|
|
702
835
|
...(permissionRequest && { permissionRequest }),
|
|
@@ -707,6 +840,56 @@ export class BridgeSession {
|
|
|
707
840
|
}));
|
|
708
841
|
}
|
|
709
842
|
|
|
843
|
+
/**
|
|
844
|
+
* Normalize hook event names from different CLI providers to unified internal names.
|
|
845
|
+
* Claude: PascalCase (PreToolUse, PostToolUse, Stop, PermissionRequest)
|
|
846
|
+
* Codex: camelCase (preToolUse, postToolUse, sessionStart)
|
|
847
|
+
* Gemini: PascalCase but different names (BeforeTool, AfterTool, BeforeSubmitPrompt)
|
|
848
|
+
*/
|
|
849
|
+
private normalizeHookName(rawName: string, provider: string): string | undefined {
|
|
850
|
+
// Claude events — already in our canonical format
|
|
851
|
+
if (provider === "claude") {
|
|
852
|
+
return rawName;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Codex events (PascalCase in nested format)
|
|
856
|
+
if (provider === "codex") {
|
|
857
|
+
switch (rawName) {
|
|
858
|
+
case "PreToolUse": case "preToolUse": return "PreToolUse";
|
|
859
|
+
case "PostToolUse": case "postToolUse": return "PostToolUse";
|
|
860
|
+
case "SessionStart": case "sessionStart": return "SessionStart";
|
|
861
|
+
default: return undefined;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Gemini events
|
|
866
|
+
if (provider === "gemini") {
|
|
867
|
+
switch (rawName) {
|
|
868
|
+
case "BeforeTool": return "PreToolUse";
|
|
869
|
+
case "AfterTool": return "PostToolUse";
|
|
870
|
+
case "BeforeSubmitPrompt": return "SessionStart";
|
|
871
|
+
case "AfterSubmitPrompt": return "Stop";
|
|
872
|
+
case "SessionStart": return "SessionStart";
|
|
873
|
+
case "SessionEnd": return "Stop";
|
|
874
|
+
default: return undefined;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Copilot events (camelCase)
|
|
879
|
+
if (provider === "copilot") {
|
|
880
|
+
switch (rawName) {
|
|
881
|
+
case "preToolUse": return "PreToolUse";
|
|
882
|
+
case "postToolUse": return "PostToolUse";
|
|
883
|
+
case "sessionStart": return "SessionStart";
|
|
884
|
+
case "sessionEnd": return "Stop";
|
|
885
|
+
default: return undefined;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Unknown provider — try to pass through
|
|
890
|
+
return rawName;
|
|
891
|
+
}
|
|
892
|
+
|
|
710
893
|
private cleanupHookServer(term: TerminalInstance): void {
|
|
711
894
|
if (term.hookServer) {
|
|
712
895
|
term.hookServer.close();
|
|
@@ -714,7 +897,20 @@ export class BridgeSession {
|
|
|
714
897
|
this.log(`hook server closed for ${term.id}`);
|
|
715
898
|
}
|
|
716
899
|
if (term.hookConfigPath) {
|
|
717
|
-
|
|
900
|
+
const configPath = term.hookConfigPath;
|
|
901
|
+
// Claude uses tmp files — safe to delete
|
|
902
|
+
// Codex/Gemini/Copilot use home dir configs — restore backup if exists
|
|
903
|
+
const backupPath = `${configPath}.linkshell-backup`;
|
|
904
|
+
try {
|
|
905
|
+
if (existsSync(backupPath)) {
|
|
906
|
+
const backup = readFileSync(backupPath, "utf8");
|
|
907
|
+
writeFileSync(configPath, backup);
|
|
908
|
+
unlinkSync(backupPath);
|
|
909
|
+
this.log(`restored backup for ${configPath}`);
|
|
910
|
+
} else if (configPath.startsWith(tmpdir())) {
|
|
911
|
+
unlinkSync(configPath);
|
|
912
|
+
}
|
|
913
|
+
} catch { /* ignore */ }
|
|
718
914
|
term.hookConfigPath = undefined;
|
|
719
915
|
}
|
|
720
916
|
}
|