linkshell-cli 0.2.19 → 0.2.21

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.
@@ -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
- // For Claude provider: set up hook server for structured status
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 { server, port, configPath } = await this.setupHookServer(terminalId, args);
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,32 +585,156 @@ 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
- const hookEntry = { hooks: [{ type: "command", command: curlCmd }] };
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 }] };
610
+ const hookEntryWithMatcher = { matcher: "*", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
611
+ const permissionEntry = { matcher: "*", hooks: [{ type: "command", command: curlCmd, timeout: 86400 }] };
592
612
  const config = {
593
613
  hooks: {
594
- PreToolUse: [hookEntry],
595
- PostToolUse: [hookEntry],
596
- PostToolUseFailure: [hookEntry],
614
+ PreToolUse: [hookEntryWithMatcher],
615
+ PostToolUse: [hookEntryWithMatcher],
616
+ PostToolUseFailure: [hookEntryWithMatcher],
597
617
  Stop: [hookEntry],
598
- PermissionRequest: [hookEntry],
618
+ PermissionRequest: [permissionEntry],
619
+ UserPromptSubmit: [hookEntry],
620
+ SessionStart: [hookEntry],
599
621
  },
600
622
  };
601
623
  writeFileSync(configPath, JSON.stringify(config));
602
- this.log(`hook config written to ${configPath}`);
624
+ this.log(`claude hook config written to ${configPath}`);
603
625
 
604
626
  // Inject --settings into Claude CLI args
605
627
  args.push("--settings", configPath);
628
+ return configPath;
629
+ }
606
630
 
607
- return { server, port, configPath };
631
+ private setupCodexHooks(terminalId: string, curlCmd: string): string {
632
+ // Codex uses ~/.codex/hooks.json — nested format (no matcher)
633
+ const codexDir = join(homedir(), ".codex");
634
+ if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
635
+
636
+ // Ensure codex_hooks = true in config.toml
637
+ const tomlPath = join(codexDir, "config.toml");
638
+ let tomlContent = "";
639
+ try { tomlContent = readFileSync(tomlPath, "utf8"); } catch { /* doesn't exist yet */ }
640
+ if (!tomlContent.includes("codex_hooks")) {
641
+ tomlContent += `\ncodex_hooks = true\n`;
642
+ writeFileSync(tomlPath, tomlContent);
643
+ this.log(`enabled codex_hooks in ${tomlPath}`);
644
+ }
645
+
646
+ const hooksPath = join(codexDir, "hooks.json");
647
+ const hookEntry = { hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
648
+ const config = {
649
+ hooks: {
650
+ SessionStart: [hookEntry],
651
+ PreToolUse: [hookEntry],
652
+ PostToolUse: [hookEntry],
653
+ },
654
+ };
655
+
656
+ // Backup existing hooks.json if present and not ours
657
+ if (existsSync(hooksPath)) {
658
+ try {
659
+ const existing = readFileSync(hooksPath, "utf8");
660
+ if (!existing.includes(`127.0.0.1:`) || !existing.includes("/hook")) {
661
+ writeFileSync(`${hooksPath}.linkshell-backup`, existing);
662
+ this.log(`backed up existing codex hooks`);
663
+ }
664
+ } catch { /* ignore */ }
665
+ }
666
+
667
+ writeFileSync(hooksPath, JSON.stringify(config, null, 2));
668
+ this.log(`codex hook config written to ${hooksPath}`);
669
+ return hooksPath;
608
670
  }
609
671
 
610
- private handleHookEvent(terminalId: string, event: Record<string, unknown>): void {
611
- const hookName = event.hook_event_name as string | undefined;
672
+ private setupGeminiHooks(terminalId: string, curlCmd: string): string {
673
+ // Gemini uses ~/.gemini/settings.json nested format, timeout in milliseconds
674
+ const geminiDir = join(homedir(), ".gemini");
675
+ if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
676
+
677
+ const settingsPath = join(geminiDir, "settings.json");
678
+ const hookEntry = { hooks: [{ type: "command", command: curlCmd, timeout: 5000 }] };
679
+ const hookConfig = {
680
+ SessionStart: [hookEntry],
681
+ BeforeTool: [hookEntry],
682
+ AfterTool: [hookEntry],
683
+ BeforeSubmitPrompt: [hookEntry],
684
+ AfterSubmitPrompt: [hookEntry],
685
+ SessionEnd: [hookEntry],
686
+ };
687
+
688
+ // Merge with existing settings if present
689
+ let existing: Record<string, unknown> = {};
690
+ try {
691
+ existing = JSON.parse(readFileSync(settingsPath, "utf8"));
692
+ } catch { /* doesn't exist yet */ }
693
+
694
+ // Backup existing hooks if present and not ours
695
+ if (existing.hooks && JSON.stringify(existing.hooks).indexOf("127.0.0.1:") === -1) {
696
+ writeFileSync(`${settingsPath}.linkshell-backup`, JSON.stringify(existing, null, 2));
697
+ this.log(`backed up existing gemini settings`);
698
+ }
699
+
700
+ existing.hooks = hookConfig;
701
+ writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
702
+ this.log(`gemini hook config written to ${settingsPath}`);
703
+ return settingsPath;
704
+ }
705
+
706
+ private setupCopilotHooks(terminalId: string, curlCmd: string): string {
707
+ // Copilot uses ~/.copilot/hooks/<name>.json — flat format with `bash` key
708
+ const copilotDir = join(homedir(), ".copilot", "hooks");
709
+ if (!existsSync(copilotDir)) mkdirSync(copilotDir, { recursive: true });
710
+
711
+ const hooksPath = join(copilotDir, "linkshell.json");
712
+ const mkHook = (eventName: string) => ({
713
+ type: "command",
714
+ bash: `${curlCmd.replace('"$(cat)"', `'{"hook_event_name":"${eventName}"}'`)}`,
715
+ timeoutSec: 5,
716
+ });
717
+ const config = {
718
+ version: 1,
719
+ hooks: {
720
+ sessionStart: [mkHook("sessionStart")],
721
+ preToolUse: [mkHook("preToolUse")],
722
+ postToolUse: [mkHook("postToolUse")],
723
+ sessionEnd: [mkHook("sessionEnd")],
724
+ },
725
+ };
726
+
727
+ writeFileSync(hooksPath, JSON.stringify(config, null, 2));
728
+ this.log(`copilot hook config written to ${hooksPath}`);
729
+ return hooksPath;
730
+ }
731
+
732
+ private handleHookEvent(terminalId: string, event: Record<string, unknown>, provider: string): void {
733
+ const rawHookName = (event.hook_event_name ?? event.event_name) as string | undefined;
734
+ if (!rawHookName) return;
735
+
736
+ // Normalize hook event names from different providers to unified names
737
+ const hookName = this.normalizeHookName(rawHookName, provider);
612
738
  if (!hookName) return;
613
739
 
614
740
  let phase: string;
@@ -620,34 +746,33 @@ export class BridgeSession {
620
746
  switch (hookName) {
621
747
  case "PreToolUse":
622
748
  phase = "tool_use";
623
- toolName = event.tool_name as string | undefined;
749
+ toolName = (event.tool_name ?? event.toolName) as string | undefined;
624
750
  if (event.tool_input && typeof event.tool_input === "object") {
625
751
  const input = event.tool_input as Record<string, unknown>;
626
752
  toolInput = JSON.stringify(input).slice(0, 200);
753
+ } else if (event.toolInput && typeof event.toolInput === "object") {
754
+ toolInput = JSON.stringify(event.toolInput).slice(0, 200);
627
755
  }
628
756
  break;
629
757
  case "PostToolUse":
630
758
  phase = "thinking";
631
- toolName = event.tool_name as string | undefined;
632
- // Pop permission stack: tool completed, remove matching request
759
+ toolName = (event.tool_name ?? event.toolName) as string | undefined;
760
+ // Pop permission stack: tool completed
633
761
  {
634
762
  const stack = this.permissionStacks.get(terminalId);
635
763
  if (stack && stack.length > 0) {
636
- const idx = stack.findIndex((r) => r.toolName === toolName);
637
- if (idx >= 0) stack.splice(idx, 1);
764
+ stack.pop();
638
765
  if (stack.length === 0) this.permissionStacks.delete(terminalId);
639
766
  }
640
767
  }
641
768
  break;
642
769
  case "PostToolUseFailure":
643
770
  phase = "error";
644
- toolName = event.tool_name as string | undefined;
645
- // Pop permission stack on failure too
771
+ toolName = (event.tool_name ?? event.toolName) as string | undefined;
646
772
  {
647
773
  const stack = this.permissionStacks.get(terminalId);
648
774
  if (stack && stack.length > 0) {
649
- const idx = stack.findIndex((r) => r.toolName === toolName);
650
- if (idx >= 0) stack.splice(idx, 1);
775
+ stack.pop();
651
776
  if (stack.length === 0) this.permissionStacks.delete(terminalId);
652
777
  }
653
778
  }
@@ -655,15 +780,16 @@ export class BridgeSession {
655
780
  case "Stop":
656
781
  phase = "idle";
657
782
  if (event.stop_reason) summary = String(event.stop_reason);
658
- // Clear all pending permissions on stop
659
783
  this.permissionStacks.delete(terminalId);
660
784
  break;
661
785
  case "PermissionRequest":
662
786
  phase = "waiting";
663
- toolName = event.tool_name as string | undefined;
787
+ toolName = (event.tool_name ?? event.toolName) as string | undefined;
664
788
  if (event.tool_input && typeof event.tool_input === "object") {
665
789
  const input = event.tool_input as Record<string, unknown>;
666
790
  permissionRequest = JSON.stringify(input).slice(0, 300);
791
+ } else if (event.toolInput && typeof event.toolInput === "object") {
792
+ permissionRequest = JSON.stringify(event.toolInput).slice(0, 300);
667
793
  }
668
794
  // Push to permission stack
669
795
  {
@@ -680,23 +806,32 @@ export class BridgeSession {
680
806
  });
681
807
  }
682
808
  break;
809
+ case "SessionStart":
810
+ phase = "idle";
811
+ summary = "session started";
812
+ break;
683
813
  default:
684
814
  return;
685
815
  }
686
816
 
687
- this.log(`hook event: ${hookName} → phase=${phase} tool=${toolName ?? "none"}`);
817
+ this.log(`hook event [${provider}]: ${rawHookName} → ${hookName} → phase=${phase} tool=${toolName ?? "none"}`);
688
818
 
689
819
  // Build topPermission from stack
690
820
  const stack = this.permissionStacks.get(terminalId);
691
821
  const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
692
822
  const pendingPermissionCount = stack?.length ?? 0;
693
823
 
824
+ // Increment statusSeq for ordering
825
+ const term = this.terminals.get(terminalId);
826
+ const seq = term ? term.statusSeq++ : 0;
827
+
694
828
  this.send(createEnvelope({
695
829
  type: "terminal.status",
696
830
  sessionId: this.sessionId,
697
831
  terminalId,
698
832
  payload: {
699
833
  phase,
834
+ seq,
700
835
  ...(toolName && { toolName }),
701
836
  ...(toolInput && { toolInput }),
702
837
  ...(permissionRequest && { permissionRequest }),
@@ -707,6 +842,56 @@ export class BridgeSession {
707
842
  }));
708
843
  }
709
844
 
845
+ /**
846
+ * Normalize hook event names from different CLI providers to unified internal names.
847
+ * Claude: PascalCase (PreToolUse, PostToolUse, Stop, PermissionRequest)
848
+ * Codex: camelCase (preToolUse, postToolUse, sessionStart)
849
+ * Gemini: PascalCase but different names (BeforeTool, AfterTool, BeforeSubmitPrompt)
850
+ */
851
+ private normalizeHookName(rawName: string, provider: string): string | undefined {
852
+ // Claude events — already in our canonical format
853
+ if (provider === "claude") {
854
+ return rawName;
855
+ }
856
+
857
+ // Codex events (PascalCase in nested format)
858
+ if (provider === "codex") {
859
+ switch (rawName) {
860
+ case "PreToolUse": case "preToolUse": return "PreToolUse";
861
+ case "PostToolUse": case "postToolUse": return "PostToolUse";
862
+ case "SessionStart": case "sessionStart": return "SessionStart";
863
+ default: return undefined;
864
+ }
865
+ }
866
+
867
+ // Gemini events
868
+ if (provider === "gemini") {
869
+ switch (rawName) {
870
+ case "BeforeTool": return "PreToolUse";
871
+ case "AfterTool": return "PostToolUse";
872
+ case "BeforeSubmitPrompt": return "SessionStart";
873
+ case "AfterSubmitPrompt": return "Stop";
874
+ case "SessionStart": return "SessionStart";
875
+ case "SessionEnd": return "Stop";
876
+ default: return undefined;
877
+ }
878
+ }
879
+
880
+ // Copilot events (camelCase)
881
+ if (provider === "copilot") {
882
+ switch (rawName) {
883
+ case "preToolUse": return "PreToolUse";
884
+ case "postToolUse": return "PostToolUse";
885
+ case "sessionStart": return "SessionStart";
886
+ case "sessionEnd": return "Stop";
887
+ default: return undefined;
888
+ }
889
+ }
890
+
891
+ // Unknown provider — try to pass through
892
+ return rawName;
893
+ }
894
+
710
895
  private cleanupHookServer(term: TerminalInstance): void {
711
896
  if (term.hookServer) {
712
897
  term.hookServer.close();
@@ -714,7 +899,20 @@ export class BridgeSession {
714
899
  this.log(`hook server closed for ${term.id}`);
715
900
  }
716
901
  if (term.hookConfigPath) {
717
- try { unlinkSync(term.hookConfigPath); } catch { /* ignore */ }
902
+ const configPath = term.hookConfigPath;
903
+ // Claude uses tmp files — safe to delete
904
+ // Codex/Gemini/Copilot use home dir configs — restore backup if exists
905
+ const backupPath = `${configPath}.linkshell-backup`;
906
+ try {
907
+ if (existsSync(backupPath)) {
908
+ const backup = readFileSync(backupPath, "utf8");
909
+ writeFileSync(configPath, backup);
910
+ unlinkSync(backupPath);
911
+ this.log(`restored backup for ${configPath}`);
912
+ } else if (configPath.startsWith(tmpdir())) {
913
+ unlinkSync(configPath);
914
+ }
915
+ } catch { /* ignore */ }
718
916
  term.hookConfigPath = undefined;
719
917
  }
720
918
  }