linkshell-cli 0.2.81 → 0.2.83

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.
@@ -64,6 +64,7 @@ interface TerminalInstance {
64
64
  status: "running" | "exited";
65
65
  hookServer?: http.Server;
66
66
  hookPort?: number;
67
+ hookMarker: string;
67
68
  hookConfigPaths: string[];
68
69
  }
69
70
 
@@ -73,9 +74,9 @@ interface PendingPermission {
73
74
  resolve: (decision: "allow" | "deny") => void;
74
75
  }
75
76
 
76
- function isLinkShellHookEntry(entry: unknown): boolean {
77
+ function isLinkShellHookEntry(entry: unknown, marker: string): boolean {
77
78
  const raw = JSON.stringify(entry);
78
- return /\/hook\?m=lsh-[^"'\s]+/.test(raw);
79
+ return raw.includes(`/hook?m=${marker}`);
79
80
  }
80
81
 
81
82
  function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
@@ -180,6 +181,11 @@ export class BridgeSession {
180
181
  }
181
182
  }
182
183
 
184
+ private terminalHookMarker(terminalId: string): string {
185
+ const safeTerminalId = terminalId.replace(/[^a-zA-Z0-9_-]+/g, "-");
186
+ return `${this.hookMarker}-${safeTerminalId}`;
187
+ }
188
+
183
189
  async start(): Promise<void> {
184
190
  this.log(
185
191
  `starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`,
@@ -193,6 +199,7 @@ export class BridgeSession {
193
199
  process.stderr.write("[bridge] keep-awake disabled\n");
194
200
  }
195
201
  if (this.options.agentUi) {
202
+ process.env.LINKSHELL_ID = this.terminalHookMarker(DEFAULT_TERMINAL_ID);
196
203
  const agentProvider = normalizeAgentProvider(
197
204
  this.options.agentProvider ?? "codex",
198
205
  );
@@ -557,8 +564,40 @@ export class BridgeSession {
557
564
  case "agent.session.load":
558
565
  case "agent.session.list":
559
566
  case "agent.prompt":
560
- case "agent.cancel":
567
+ case "agent.cancel": {
568
+ if (!this.agentSession) {
569
+ this.send(
570
+ createEnvelope({
571
+ type: "agent.capabilities",
572
+ sessionId: this.sessionId,
573
+ payload: {
574
+ enabled: false,
575
+ provider: normalizeAgentProvider(
576
+ this.options.agentProvider ?? "codex",
577
+ ),
578
+ error: "Agent GUI is not enabled. Start CLI with --agent-ui.",
579
+ supportsSessionList: false,
580
+ supportsSessionLoad: false,
581
+ supportsImages: false,
582
+ supportsAudio: false,
583
+ supportsPermission: false,
584
+ supportsPlan: false,
585
+ supportsCancel: false,
586
+ },
587
+ }),
588
+ );
589
+ break;
590
+ }
591
+ await this.agentSession.handleEnvelope(envelope);
592
+ break;
593
+ }
561
594
  case "agent.permission.response": {
595
+ const p = parseTypedPayload("agent.permission.response", envelope.payload);
596
+ const decision = p.outcome === "allow" ? "allow" : "deny";
597
+ if (this.resolvePendingPermission(p.requestId, decision)) {
598
+ this.log(`agent permission response for hook ${p.requestId}: ${decision}`);
599
+ break;
600
+ }
562
601
  if (!this.agentSession) {
563
602
  this.send(
564
603
  createEnvelope({
@@ -887,8 +926,9 @@ export class BridgeSession {
887
926
  for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
888
927
  if (v !== undefined) cleanEnv[k] = v;
889
928
  }
929
+ const hookMarker = this.terminalHookMarker(terminalId);
890
930
  // Inject marker so child CLIs' hook commands carry our identity
891
- cleanEnv["LINKSHELL_ID"] = this.hookMarker;
931
+ cleanEnv["LINKSHELL_ID"] = hookMarker;
892
932
 
893
933
  const provider = providerOverride ?? this.options.providerConfig.provider;
894
934
  const args = [...this.options.providerConfig.args];
@@ -900,17 +940,17 @@ export class BridgeSession {
900
940
  const hookConfigPaths: string[] = [];
901
941
 
902
942
  if (provider === "custom") {
903
- const result = await this.setupHookServer(terminalId, args, "claude");
943
+ const result = await this.setupHookServer(terminalId, args, "claude", hookMarker);
904
944
  hookServer = result.server;
905
945
  hookPort = result.port;
906
946
  hookConfigPaths.push(result.configPath);
907
947
  // Also set up hooks for other providers (curlCmd already has marker from setupHookServer)
908
- const curlCmd = `curl -s -X POST "http://127.0.0.1:${result.port}/hook?m=${this.hookMarker}&lid=$LINKSHELL_ID" -H 'Content-Type: application/json' --data-binary @-`;
909
- hookConfigPaths.push(this.setupCodexHooks(terminalId, curlCmd));
910
- hookConfigPaths.push(this.setupGeminiHooks(terminalId, curlCmd));
911
- hookConfigPaths.push(this.setupCopilotHooks(terminalId, curlCmd));
948
+ const curlCmd = `curl -s -X POST "http://127.0.0.1:${result.port}/hook?m=${hookMarker}&lid=$LINKSHELL_ID" -H 'Content-Type: application/json' --data-binary @-`;
949
+ hookConfigPaths.push(this.setupCodexHooks(terminalId, curlCmd, hookMarker));
950
+ hookConfigPaths.push(this.setupGeminiHooks(terminalId, curlCmd, hookMarker));
951
+ hookConfigPaths.push(this.setupCopilotHooks(terminalId, curlCmd, hookMarker));
912
952
  } else if (provider === "claude" || provider === "codex" || provider === "gemini" || provider === "copilot") {
913
- const result = await this.setupHookServer(terminalId, args, provider);
953
+ const result = await this.setupHookServer(terminalId, args, provider, hookMarker);
914
954
  hookServer = result.server;
915
955
  hookPort = result.port;
916
956
  hookConfigPaths.push(result.configPath);
@@ -938,6 +978,7 @@ export class BridgeSession {
938
978
  status: "running",
939
979
  hookServer,
940
980
  hookPort,
981
+ hookMarker,
941
982
  hookConfigPaths,
942
983
  };
943
984
 
@@ -987,12 +1028,11 @@ export class BridgeSession {
987
1028
  this.log(`spawned terminal ${terminalId} in ${cwd}`);
988
1029
  }
989
1030
 
990
- private async setupHookServer(terminalId: string, args: string[], provider: string): Promise<{
1031
+ private async setupHookServer(terminalId: string, args: string[], provider: string, marker: string): Promise<{
991
1032
  server: http.Server;
992
1033
  port: number;
993
1034
  configPath: string;
994
1035
  }> {
995
- const marker = this.hookMarker;
996
1036
  const server = http.createServer((req, res) => {
997
1037
  this.log(`hook server received: ${req.method} ${req.url}`);
998
1038
  const reqUrl = new URL(req.url ?? "/", "http://localhost");
@@ -1056,6 +1096,7 @@ export class BridgeSession {
1056
1096
  });
1057
1097
  // Send status with requestId so app can route decision back
1058
1098
  this.handleHookEvent(terminalId, event, provider, requestId);
1099
+ this.sendHookPermissionRequest(terminalId, event, requestId);
1059
1100
  } else {
1060
1101
  // All other hooks: respond immediately
1061
1102
  res.writeHead(200);
@@ -1084,20 +1125,20 @@ export class BridgeSession {
1084
1125
  let configPath: string;
1085
1126
 
1086
1127
  if (provider === "codex") {
1087
- configPath = this.setupCodexHooks(terminalId, curlCmd);
1128
+ configPath = this.setupCodexHooks(terminalId, curlCmd, marker);
1088
1129
  } else if (provider === "gemini") {
1089
- configPath = this.setupGeminiHooks(terminalId, curlCmd);
1130
+ configPath = this.setupGeminiHooks(terminalId, curlCmd, marker);
1090
1131
  } else if (provider === "copilot") {
1091
- configPath = this.setupCopilotHooks(terminalId, curlCmd);
1132
+ configPath = this.setupCopilotHooks(terminalId, curlCmd, marker);
1092
1133
  } else {
1093
1134
  // Claude (default)
1094
- configPath = this.setupClaudeHooks(terminalId, curlCmd, args);
1135
+ configPath = this.setupClaudeHooks(terminalId, curlCmd, args, marker);
1095
1136
  }
1096
1137
 
1097
1138
  return { server, port, configPath };
1098
1139
  }
1099
1140
 
1100
- private setupClaudeHooks(terminalId: string, curlCmd: string, args: string[]): string {
1141
+ private setupClaudeHooks(terminalId: string, curlCmd: string, args: string[], marker: string): string {
1101
1142
  // Write hooks to ~/.claude/settings.json — Claude Code reads hooks from here
1102
1143
  const claudeDir = join(homedir(), ".claude");
1103
1144
  if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
@@ -1133,7 +1174,7 @@ export class BridgeSession {
1133
1174
  for (const [eventName, entry] of Object.entries(hookEvents)) {
1134
1175
  let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
1135
1176
  // Remove any dead linkshell hook entries (from previous instances)
1136
- arr = arr.filter((e) => !isLinkShellHookEntry(e));
1177
+ arr = arr.filter((e) => !isLinkShellHookEntry(e, marker));
1137
1178
  arr.push(entry);
1138
1179
  existingHooks[eventName] = arr;
1139
1180
  }
@@ -1145,7 +1186,7 @@ export class BridgeSession {
1145
1186
  return settingsPath;
1146
1187
  }
1147
1188
 
1148
- private setupCodexHooks(terminalId: string, curlCmd: string): string {
1189
+ private setupCodexHooks(terminalId: string, curlCmd: string, marker: string): string {
1149
1190
  // Codex uses ~/.codex/hooks.json — same format as Claude (with matcher)
1150
1191
  const codexDir = join(homedir(), ".codex");
1151
1192
  if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
@@ -1197,17 +1238,17 @@ export class BridgeSession {
1197
1238
  const existingHooks = existing.hooks ?? {};
1198
1239
  for (const [eventName, entry] of Object.entries(hookEvents)) {
1199
1240
  let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
1200
- arr = arr.filter((e) => !isLinkShellHookEntry(e));
1241
+ arr = arr.filter((e) => !isLinkShellHookEntry(e, marker));
1201
1242
  arr.push(entry);
1202
1243
  existingHooks[eventName] = arr;
1203
1244
  }
1204
1245
 
1205
- writeFileSync(hooksPath, JSON.stringify({ hooks: existingHooks }, null, 2));
1246
+ writeFileSync(hooksPath, JSON.stringify({ ...existing, hooks: existingHooks }, null, 2));
1206
1247
  this.log(`codex hooks appended to ${hooksPath}`);
1207
1248
  return hooksPath;
1208
1249
  }
1209
1250
 
1210
- private setupGeminiHooks(terminalId: string, curlCmd: string): string {
1251
+ private setupGeminiHooks(terminalId: string, curlCmd: string, marker: string): string {
1211
1252
  // Gemini uses ~/.gemini/settings.json — same format as Claude (with matcher)
1212
1253
  const geminiDir = join(homedir(), ".gemini");
1213
1254
  if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
@@ -1230,7 +1271,7 @@ export class BridgeSession {
1230
1271
  const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
1231
1272
  for (const [eventName, entry] of Object.entries(hookEvents)) {
1232
1273
  let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
1233
- arr = arr.filter((e) => !isLinkShellHookEntry(e));
1274
+ arr = arr.filter((e) => !isLinkShellHookEntry(e, marker));
1234
1275
  arr.push(entry);
1235
1276
  existingHooks[eventName] = arr;
1236
1277
  }
@@ -1241,7 +1282,7 @@ export class BridgeSession {
1241
1282
  return settingsPath;
1242
1283
  }
1243
1284
 
1244
- private setupCopilotHooks(terminalId: string, curlCmd: string): string {
1285
+ private setupCopilotHooks(terminalId: string, curlCmd: string, marker: string): string {
1245
1286
  // Copilot loads hooks from CWD as hooks.json
1246
1287
  const cwd = this.terminals.get(terminalId)?.cwd ?? process.cwd();
1247
1288
  const hooksPath = join(cwd, "hooks.json");
@@ -1265,7 +1306,7 @@ export class BridgeSession {
1265
1306
  const existingHooks = existing.hooks ?? {};
1266
1307
  for (const [eventName, entry] of Object.entries(hookEvents)) {
1267
1308
  let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
1268
- arr = arr.filter((e) => !isLinkShellHookEntry(e));
1309
+ arr = arr.filter((e) => !isLinkShellHookEntry(e, marker));
1269
1310
  arr.push(entry);
1270
1311
  existingHooks[eventName] = arr;
1271
1312
  }
@@ -1435,6 +1476,45 @@ export class BridgeSession {
1435
1476
  }));
1436
1477
  }
1437
1478
 
1479
+ private sendHookPermissionRequest(
1480
+ terminalId: string,
1481
+ event: Record<string, unknown>,
1482
+ requestId: string,
1483
+ ): void {
1484
+ const toolName = (event.tool_name ?? event.toolName) as string | undefined;
1485
+ const toolInput =
1486
+ typeof event.tool_input === "object" && event.tool_input
1487
+ ? JSON.stringify(event.tool_input).slice(0, 1200)
1488
+ : typeof event.toolInput === "object" && event.toolInput
1489
+ ? JSON.stringify(event.toolInput).slice(0, 1200)
1490
+ : typeof event.tool_input === "string"
1491
+ ? event.tool_input.slice(0, 1200)
1492
+ : typeof event.toolInput === "string"
1493
+ ? event.toolInput.slice(0, 1200)
1494
+ : "";
1495
+ const context =
1496
+ typeof event.permission_prompt === "string"
1497
+ ? event.permission_prompt
1498
+ : typeof event.message === "string"
1499
+ ? event.message
1500
+ : undefined;
1501
+ this.send(createEnvelope({
1502
+ type: "agent.permission.request",
1503
+ sessionId: this.sessionId,
1504
+ terminalId,
1505
+ payload: {
1506
+ requestId,
1507
+ toolName,
1508
+ toolInput,
1509
+ context,
1510
+ options: [
1511
+ { id: "deny", label: "拒绝", kind: "deny" },
1512
+ { id: "allow", label: "允许", kind: "allow" },
1513
+ ],
1514
+ },
1515
+ }));
1516
+ }
1517
+
1438
1518
  /**
1439
1519
  * Normalize hook event names from different CLI providers to unified internal names.
1440
1520
  * Claude: PascalCase (PreToolUse, PostToolUse, Stop, PermissionRequest)
@@ -1454,6 +1534,7 @@ export class BridgeSession {
1454
1534
  case "PostToolUse": case "postToolUse": return "PostToolUse";
1455
1535
  case "SessionStart": case "sessionStart": return "SessionStart";
1456
1536
  case "UserPromptSubmit": return "UserPromptSubmit";
1537
+ case "PermissionRequest": return "PermissionRequest";
1457
1538
  case "Stop": return "Stop";
1458
1539
  default: return undefined;
1459
1540
  }
@@ -1559,7 +1640,7 @@ export class BridgeSession {
1559
1640
  term.hookServer = undefined;
1560
1641
  this.log(`hook server closed for ${term.id}`);
1561
1642
  }
1562
- const marker = this.hookMarker;
1643
+ const marker = term.hookMarker;
1563
1644
  for (const configPath of term.hookConfigPaths) {
1564
1645
  try {
1565
1646
  // Copilot: per-instance file — just delete it