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
|
|
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"] =
|
|
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=${
|
|
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 =
|
|
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
|