linkshell-cli 0.2.19 → 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.
@@ -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,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
- 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 }] };
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
- return { server, port, configPath };
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 handleHookEvent(terminalId: string, event: Record<string, unknown>): void {
611
- const hookName = event.hook_event_name as string | undefined;
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, remove matching request
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
- const idx = stack.findIndex((r) => r.toolName === toolName);
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
- const idx = stack.findIndex((r) => r.toolName === toolName);
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
- try { unlinkSync(term.hookConfigPath); } catch { /* ignore */ }
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
  }