linkshell-cli 0.5.37 → 0.5.39

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.
Files changed (35) hide show
  1. package/dist/cli/src/runtime/acp/acp-client.d.ts +21 -0
  2. package/dist/cli/src/runtime/acp/acp-client.js +101 -5
  3. package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
  4. package/dist/cli/src/runtime/acp/agent-process-discovery.d.ts +1 -1
  5. package/dist/cli/src/runtime/acp/agent-process-discovery.js +12 -1
  6. package/dist/cli/src/runtime/acp/agent-process-discovery.js.map +1 -1
  7. package/dist/cli/src/runtime/acp/agent-workspace.d.ts +137 -0
  8. package/dist/cli/src/runtime/acp/agent-workspace.js +607 -97
  9. package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
  10. package/dist/cli/src/runtime/acp/provider-resolver.js +28 -2
  11. package/dist/cli/src/runtime/acp/provider-resolver.js.map +1 -1
  12. package/dist/cli/src/runtime/bridge-session.d.ts +0 -48
  13. package/dist/cli/src/runtime/bridge-session.js +10 -1039
  14. package/dist/cli/src/runtime/bridge-session.js.map +1 -1
  15. package/dist/cli/src/runtime/hook-command.d.ts +5 -30
  16. package/dist/cli/src/runtime/hook-command.js +54 -41
  17. package/dist/cli/src/runtime/hook-command.js.map +1 -1
  18. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  19. package/dist/shared-protocol/src/index.d.ts +164 -8
  20. package/dist/shared-protocol/src/index.js +6 -0
  21. package/dist/shared-protocol/src/index.js.map +1 -1
  22. package/package.json +3 -3
  23. package/src/runtime/acp/acp-client.ts +116 -5
  24. package/src/runtime/acp/agent-process-discovery.ts +8 -1
  25. package/src/runtime/acp/agent-workspace.ts +557 -50
  26. package/src/runtime/acp/provider-resolver.ts +31 -2
  27. package/src/runtime/bridge-session.ts +9 -1117
  28. package/src/runtime/hook-command.ts +47 -47
  29. package/web/assets/{FileBrowser-Coy7Szjx.js → FileBrowser-DtVanRUn.js} +1 -1
  30. package/web/assets/{PortPreview-D-XovbV1.js → PortPreview-DC2-UmHo.js} +1 -1
  31. package/web/assets/{TerminalPanel-Cah56AtC.js → TerminalPanel-D4gHM4Ta.js} +1 -1
  32. package/web/assets/{UsageDashboard-DfkjJagC.js → UsageDashboard-ZnvuvqWc.js} +1 -1
  33. package/web/assets/index-DKdJgZ9D.js +36 -0
  34. package/web/index.html +1 -1
  35. package/web/assets/index-ClZzQ4ba.js +0 -35
@@ -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, readFileSync, readdirSync, statSync, unlinkSync, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
5
+ import { writeFileSync, readFileSync, readdirSync, statSync, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join, basename, resolve, relative, isAbsolute } from "node:path";
8
8
  import { agentV2MessageRoute, createEnvelope, parseEnvelope, parseTypedPayload, serializeEnvelope, PROTOCOL_VERSION, } from "@linkshell/protocol";
@@ -14,9 +14,9 @@ import { startKeepAwake } from "../utils/keep-awake.js";
14
14
  import { loadOrCreateMachineIdentity } from "../machine-id.js";
15
15
  import { getValidToken, refreshAccessToken } from "../auth.js";
16
16
  import { AgentSessionProxy } from "./acp/agent-session.js";
17
- import { AgentWorkspaceProxy, makeAgentV2RemoteConversationId } from "./acp/agent-workspace.js";
17
+ import { AgentWorkspaceProxy } from "./acp/agent-workspace.js";
18
18
  import { detectAvailableProviders } from "./acp/provider-resolver.js";
19
- import { buildLinkShellHookCommand, isLinkShellHookEntry, rewriteBareLinkShellId, shouldWriteProjectHooksJson, } from "./hook-command.js";
19
+ import { sweepLinkShellHookConfigs } from "./hook-command.js";
20
20
  const HEARTBEAT_INTERVAL = 15_000;
21
21
  // Transport-layer WS keepalive: ping the gateway periodically; if a pong
22
22
  // doesn't arrive before the next ping, the socket is half-open (network
@@ -30,7 +30,6 @@ const RECONNECT_MAX_DELAY = 30_000;
30
30
  // arbitrarily long network outages, reconnecting whenever connectivity returns.
31
31
  const RECONNECT_MAX_EXPONENT = 5;
32
32
  const DEFAULT_TERMINAL_ID = "default";
33
- const HOOK_BODY_LIMIT = 256 * 1024;
34
33
  const SCROLLBACK_LINES = 500;
35
34
  // Upper bound on concurrent live terminals — forceNew removed the cwd-dedup
36
35
  // ceiling, so bound growth to avoid PTY/fd exhaustion from a runaway client.
@@ -41,101 +40,6 @@ const EXITED_TERMINAL_GRACE_MS = 30_000;
41
40
  // Default ports the bridge will proxy tunnel requests to when no explicit
42
41
  // allowlist is configured. Covers the common dev servers the app tunnels to.
43
42
  const DEFAULT_TUNNEL_PORTS = [3000, 3001, 4321, 5173, 5174, 8080, 8000, 8081];
44
- const PERMISSION_REQUEST_TIMEOUT_MS = Number(process.env.LINKSHELL_PERMISSION_TIMEOUT_MS ?? 5 * 60_000);
45
- const HOOK_COMMAND_TIMEOUT_SEC = Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000);
46
- const LINKSHELL_PERMISSION_GUARD_MARKER = "LINKSHELL_PERMISSION_GUARD";
47
- function withLinkShellHookEntry(entries, entry, priority) {
48
- const cleaned = (Array.isArray(entries) ? entries : []).filter((item) => !isLinkShellHookEntry(item));
49
- return priority === "first" ? [entry, ...cleaned] : [...cleaned, entry];
50
- }
51
- function guardPermissionCommandForLinkShell(command) {
52
- if (typeof command !== "string")
53
- return command;
54
- if (command.includes(LINKSHELL_PERMISSION_GUARD_MARKER))
55
- return command;
56
- return [
57
- `case "\${LINKSHELL_ID:-}" in lsh-*) exit 0 ;; esac`,
58
- `# ${LINKSHELL_PERMISSION_GUARD_MARKER}`,
59
- command,
60
- ].join("\n");
61
- }
62
- function guardPermissionHookObjectForLinkShell(hook) {
63
- if (isLinkShellHookEntry(hook))
64
- return hook;
65
- const next = { ...hook };
66
- if (typeof next.command === "string") {
67
- next.command = guardPermissionCommandForLinkShell(next.command);
68
- }
69
- if (typeof next.bash === "string") {
70
- next.bash = guardPermissionCommandForLinkShell(next.bash);
71
- }
72
- return next;
73
- }
74
- function guardPermissionHookEntryForLinkShell(entry) {
75
- if (isLinkShellHookEntry(entry))
76
- return entry;
77
- if (typeof entry === "string")
78
- return guardPermissionCommandForLinkShell(entry);
79
- if (Array.isArray(entry))
80
- return entry.map(guardPermissionHookEntryForLinkShell);
81
- if (!entry || typeof entry !== "object")
82
- return entry;
83
- const next = { ...entry };
84
- if (Array.isArray(next.hooks)) {
85
- next.hooks = next.hooks.map((hook) => hook && typeof hook === "object" && !Array.isArray(hook)
86
- ? guardPermissionHookObjectForLinkShell(hook)
87
- : guardPermissionHookEntryForLinkShell(hook));
88
- }
89
- if (typeof next.command === "string" || typeof next.bash === "string") {
90
- return guardPermissionHookObjectForLinkShell(next);
91
- }
92
- return next;
93
- }
94
- function withBlockingLinkShellPermissionEntry(entries, entry) {
95
- const cleaned = (Array.isArray(entries) ? entries : [])
96
- .filter((item) => !isLinkShellHookEntry(item))
97
- .map(guardPermissionHookEntryForLinkShell);
98
- return [entry, ...cleaned];
99
- }
100
- function stringifyHookInput(value) {
101
- if (typeof value === "string")
102
- return value.slice(0, 1200);
103
- if (typeof value === "object" && value) {
104
- try {
105
- return JSON.stringify(value, null, 2).slice(0, 1200);
106
- }
107
- catch {
108
- return String(value).slice(0, 1200);
109
- }
110
- }
111
- return "";
112
- }
113
- function hookPermissionSuggestions(event) {
114
- if (isCodexPermissionRequest(event))
115
- return [];
116
- const snake = event.permission_suggestions;
117
- const camel = event.permissionSuggestions;
118
- if (Array.isArray(snake))
119
- return snake;
120
- if (Array.isArray(camel))
121
- return camel;
122
- return [];
123
- }
124
- function isCodexPermissionRequest(event) {
125
- if (typeof event.turn_id === "string" || typeof event.turnId === "string")
126
- return true;
127
- const transcriptPath = event.transcript_path ?? event.transcriptPath;
128
- return typeof transcriptPath === "string" && transcriptPath.includes("/.codex/");
129
- }
130
- function hookPermissionOptions(suggestions) {
131
- return [
132
- { id: "deny", label: "拒绝", kind: "deny" },
133
- { id: "allow_once", label: "允许一次", kind: "allow" },
134
- ...(suggestions.length > 0
135
- ? [{ id: "allow_always", label: "始终允许", kind: "allow" }]
136
- : []),
137
- ];
138
- }
139
43
  function stripGatewayBase(url) {
140
44
  url.hash = "";
141
45
  url.search = "";
@@ -249,10 +153,6 @@ export class BridgeSession {
249
153
  hostToken;
250
154
  exited = false;
251
155
  stopped = false;
252
- permissionStacks = new Map();
253
- // Pending permission responses: requestId → HTTP response callback
254
- pendingPermissions = new Map();
255
- hookMarker = `lsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
256
156
  screenCapture;
257
157
  screenShare;
258
158
  tunnelSockets = new Map();
@@ -312,16 +212,12 @@ export class BridgeSession {
312
212
  process.stderr.write(`[bridge:verbose] ${msg}\n`);
313
213
  }
314
214
  }
315
- terminalHookMarker(terminalId) {
316
- const safeTerminalId = terminalId.replace(/[^a-zA-Z0-9_-]+/g, "-");
317
- return `${this.hookMarker}-${safeTerminalId}`;
318
- }
319
215
  async start() {
320
216
  this.log(`starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`);
321
217
  this.machineIdentity = loadOrCreateMachineIdentity();
322
- // Sweep stale LinkShell hook entries (dead curl ports) left by a prior crash
323
- // before we write fresh ones for this session.
324
- this.sweepStaleHookConfigs();
218
+ // One-shot leftover sweep. Structured agent control is ACP/SDK subprocesses;
219
+ // we never rewrite hook configs.
220
+ sweepLinkShellHookConfigs(homedir(), [join(this.defaultCwd, "hooks.json")]);
325
221
  if (!this.sessionId) {
326
222
  await this.createPairing();
327
223
  }
@@ -332,7 +228,6 @@ export class BridgeSession {
332
228
  process.stderr.write("[bridge] keep-awake disabled\n");
333
229
  }
334
230
  if (this.options.agentUi) {
335
- process.env.LINKSHELL_ID = this.terminalHookMarker(DEFAULT_TERMINAL_ID);
336
231
  const availableProviders = resolveAgentWorkspaceProviders(this.options);
337
232
  const agentOptions = {
338
233
  sessionId: this.sessionId,
@@ -572,20 +467,6 @@ export class BridgeSession {
572
467
  }));
573
468
  return;
574
469
  }
575
- if (envelope.type === "agent.v2.prompt" || envelope.type === "agent.v2.command.execute") {
576
- this.refreshAgentPermissionHooks();
577
- }
578
- // A v2 permission response may be answering an EXTERNAL session's request
579
- // that came in through the hook HTTP server (held on a pending connection),
580
- // not a workspace-driven one. Resolve that pending hook connection first;
581
- // only fall through to the workspace if it wasn't a hook request. Mirrors
582
- // the v1 agent.permission.response path.
583
- if (envelope.type === "agent.v2.permission.respond") {
584
- const p = parseTypedPayload("agent.v2.permission.respond", envelope.payload);
585
- if (this.resolvePendingPermission(p.requestId, { outcome: p.outcome === "cancelled" ? "deny" : p.outcome, optionId: p.optionId }, "agent.v2.permission.respond").resolved) {
586
- return;
587
- }
588
- }
589
470
  try {
590
471
  await this.agentWorkspace.handleEnvelope(envelope);
591
472
  }
@@ -931,19 +812,11 @@ export class BridgeSession {
931
812
  }));
932
813
  break;
933
814
  }
934
- if (envelope.type === "agent.prompt")
935
- this.refreshAgentPermissionHooks();
936
815
  await this.agentSession.handleEnvelope(envelope);
937
816
  break;
938
817
  }
939
818
  case "agent.permission.response": {
940
819
  const p = parseTypedPayload("agent.permission.response", envelope.payload);
941
- if (this.resolvePendingPermission(p.requestId, {
942
- outcome: p.outcome,
943
- optionId: p.optionId,
944
- }, "agent.permission.response").resolved) {
945
- break;
946
- }
947
820
  if (!this.agentSession) {
948
821
  this.send(createEnvelope({
949
822
  type: "agent.capabilities",
@@ -981,16 +854,7 @@ export class BridgeSession {
981
854
  }
982
855
  case "permission.decision": {
983
856
  const p = envelope.payload;
984
- const result = this.resolvePendingPermission(p.requestId, p.decision, "permission.decision");
985
- if (!result.resolved) {
986
- this.sendPermissionSnapshot(tid, "thinking", "permission not pending", {
987
- requestId: p.requestId,
988
- outcome: p.decision,
989
- source: "permission.decision",
990
- delivered: false,
991
- });
992
- }
993
- process.stderr.write(`[bridge] permission decision request=${p.requestId} decision=${p.decision} resolved=${result.resolved} delivered=${result.delivered}\n`);
857
+ process.stderr.write(`[bridge] permission.decision ignored (ACP/SDK owns authorization) request=${p.requestId} decision=${p.decision}\n`);
994
858
  this.send(createEnvelope({
995
859
  type: "permission.decision.result",
996
860
  sessionId: this.sessionId,
@@ -998,14 +862,10 @@ export class BridgeSession {
998
862
  payload: {
999
863
  requestId: p.requestId,
1000
864
  decision: p.decision,
1001
- resolved: result.resolved,
1002
- delivered: result.delivered,
865
+ resolved: false,
866
+ delivered: false,
1003
867
  source: "permission.decision",
1004
- message: result.delivered
1005
- ? undefined
1006
- : result.resolved
1007
- ? "Permission resolved but response was not delivered"
1008
- : "Permission request is no longer pending",
868
+ message: "Authorization is handled by the ACP/SDK agent subprocess",
1009
869
  },
1010
870
  }));
1011
871
  break;
@@ -1219,33 +1079,8 @@ export class BridgeSession {
1219
1079
  if (v !== undefined)
1220
1080
  cleanEnv[k] = v;
1221
1081
  }
1222
- const hookMarker = this.terminalHookMarker(terminalId);
1223
- // Inject marker so child CLIs' hook commands carry our identity
1224
- cleanEnv["LINKSHELL_ID"] = hookMarker;
1225
1082
  const provider = providerOverride ?? this.options.providerConfig.provider;
1226
1083
  const args = [...this.options.providerConfig.args];
1227
- // Set up hook server for structured status (all supported providers)
1228
- // For "custom" shell, set up hooks for all providers since user may launch any of them
1229
- let hookServer;
1230
- let hookPort;
1231
- const hookConfigPaths = [];
1232
- if (provider === "custom") {
1233
- const result = await this.setupHookServer(terminalId, args, "claude", hookMarker);
1234
- hookServer = result.server;
1235
- hookPort = result.port;
1236
- hookConfigPaths.push(result.configPath);
1237
- // Also set up hooks for other providers (curlCmd already has marker from setupHookServer)
1238
- const curlCmd = buildLinkShellHookCommand(result.port, hookMarker, HOOK_COMMAND_TIMEOUT_SEC);
1239
- hookConfigPaths.push(this.setupCodexHooks(terminalId, curlCmd, hookMarker));
1240
- hookConfigPaths.push(this.setupGeminiHooks(terminalId, curlCmd, hookMarker));
1241
- hookConfigPaths.push(this.setupCopilotHooks(terminalId, curlCmd, hookMarker));
1242
- }
1243
- else if (provider === "claude" || provider === "codex" || provider === "gemini" || provider === "copilot") {
1244
- const result = await this.setupHookServer(terminalId, args, provider, hookMarker);
1245
- hookServer = result.server;
1246
- hookPort = result.port;
1247
- hookConfigPaths.push(result.configPath);
1248
- }
1249
1084
  const term = {
1250
1085
  id: terminalId,
1251
1086
  pty: pty.spawn(this.options.providerConfig.command, args, {
@@ -1262,10 +1097,6 @@ export class BridgeSession {
1262
1097
  outputSeq: 0,
1263
1098
  statusSeq: 0,
1264
1099
  status: "running",
1265
- hookServer,
1266
- hookPort,
1267
- hookMarker,
1268
- hookConfigPaths,
1269
1100
  };
1270
1101
  term.pty.onData((data) => {
1271
1102
  const seq = term.outputSeq++;
@@ -1287,7 +1118,6 @@ export class BridgeSession {
1287
1118
  });
1288
1119
  term.pty.onExit(({ exitCode, signal }) => {
1289
1120
  term.status = "exited";
1290
- this.cleanupHookServer(term);
1291
1121
  this.send(createEnvelope({
1292
1122
  type: "terminal.exit",
1293
1123
  sessionId: this.sessionId,
@@ -1326,864 +1156,6 @@ export class BridgeSession {
1326
1156
  this.terminals.set(terminalId, term);
1327
1157
  this.log(`spawned terminal ${terminalId} in ${cwd}`);
1328
1158
  }
1329
- async setupHookServer(terminalId, args, provider, marker) {
1330
- const server = http.createServer((req, res) => {
1331
- this.log(`hook server received: ${req.method} ${req.url}`);
1332
- const reqUrl = new URL(req.url ?? "/", "http://localhost");
1333
- if (req.method !== "POST" || reqUrl.pathname !== "/hook") {
1334
- res.writeHead(404);
1335
- res.end();
1336
- return;
1337
- }
1338
- // Check marker — reject events not from our PTY
1339
- // m must match; lid must match OR be empty (some CLIs don't inherit env vars)
1340
- const reqMarker = reqUrl.searchParams.get("m");
1341
- const reqLid = reqUrl.searchParams.get("lid") ?? "";
1342
- if (reqMarker !== marker || (reqLid !== "" && reqLid !== marker)) {
1343
- this.log(`ignoring hook event: m=${reqMarker} lid=${reqLid} (expected ${marker})`);
1344
- res.writeHead(200, { "Content-Type": "application/json" });
1345
- res.end("{}");
1346
- return;
1347
- }
1348
- let body = "";
1349
- let bodyTooLarge = false;
1350
- req.on("data", (chunk) => {
1351
- if (bodyTooLarge)
1352
- return;
1353
- body += chunk.toString();
1354
- if (Buffer.byteLength(body, "utf8") > HOOK_BODY_LIMIT) {
1355
- bodyTooLarge = true;
1356
- res.writeHead(413, { "Content-Type": "application/json" });
1357
- res.end("{}");
1358
- req.destroy();
1359
- }
1360
- });
1361
- req.on("end", () => {
1362
- if (bodyTooLarge || res.writableEnded)
1363
- return;
1364
- this.log(`hook body (${body.length} bytes): ${body.slice(0, 200)}`);
1365
- try {
1366
- const event = JSON.parse(body);
1367
- const hookName = (event.hook_event_name ?? event.event_name);
1368
- // PermissionRequest: hold connection, wait for user decision from mobile app
1369
- if (hookName === "PermissionRequest") {
1370
- const requestId = `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1371
- const permissionSuggestions = hookPermissionSuggestions(event);
1372
- const timeout = setTimeout(() => {
1373
- if (this.resolvePendingPermission(requestId, "deny", "permission.timeout").resolved) {
1374
- this.log(`permission request ${requestId} timed out`);
1375
- this.sendPermissionSnapshot(terminalId, "thinking", "permission timed out");
1376
- }
1377
- }, PERMISSION_REQUEST_TIMEOUT_MS);
1378
- this.pendingPermissions.set(requestId, {
1379
- terminalId,
1380
- timeout,
1381
- permissionSuggestions,
1382
- resolve: (decision) => {
1383
- if (res.writableEnded)
1384
- return false;
1385
- const responseJson = JSON.stringify({
1386
- hookSpecificOutput: {
1387
- hookEventName: "PermissionRequest",
1388
- decision,
1389
- },
1390
- });
1391
- res.writeHead(200, { "Content-Type": "application/json" });
1392
- res.end(responseJson);
1393
- return true;
1394
- },
1395
- });
1396
- // Send status with requestId so app can route decision back
1397
- this.handleHookEvent(terminalId, event, provider, requestId);
1398
- this.sendHookPermissionRequest(terminalId, event, requestId, provider);
1399
- }
1400
- else {
1401
- // All other hooks: respond immediately with valid JSON so clients
1402
- // that parse stdout (Cursor) fail-open instead of blocking the tool.
1403
- res.writeHead(200, { "Content-Type": "application/json" });
1404
- res.end("{}");
1405
- this.handleHookEvent(terminalId, event, provider);
1406
- }
1407
- }
1408
- catch (e) {
1409
- res.writeHead(200, { "Content-Type": "application/json" });
1410
- res.end("{}");
1411
- this.log(`hook parse error: ${e}`);
1412
- }
1413
- });
1414
- });
1415
- // Listen on random port — await binding before reading address
1416
- const port = await new Promise((resolve, reject) => {
1417
- server.listen(0, "127.0.0.1", () => {
1418
- const addr = server.address();
1419
- resolve(addr.port);
1420
- });
1421
- server.on("error", reject);
1422
- });
1423
- this.log(`hook server for ${terminalId} (${provider}) listening on port ${port}, marker=${marker}`);
1424
- const curlCmd = buildLinkShellHookCommand(port, marker, HOOK_COMMAND_TIMEOUT_SEC);
1425
- let configPath;
1426
- if (provider === "codex") {
1427
- configPath = this.setupCodexHooks(terminalId, curlCmd, marker);
1428
- }
1429
- else if (provider === "gemini") {
1430
- configPath = this.setupGeminiHooks(terminalId, curlCmd, marker);
1431
- }
1432
- else if (provider === "copilot") {
1433
- configPath = this.setupCopilotHooks(terminalId, curlCmd, marker);
1434
- }
1435
- else {
1436
- // Claude (default)
1437
- configPath = this.setupClaudeHooks(terminalId, curlCmd, args, marker);
1438
- }
1439
- return { server, port, configPath };
1440
- }
1441
- refreshAgentPermissionHooks() {
1442
- const term = this.terminals.get(DEFAULT_TERMINAL_ID);
1443
- if (!term?.hookPort)
1444
- return;
1445
- const marker = term.hookMarker;
1446
- const curlCmd = buildLinkShellHookCommand(term.hookPort, marker, HOOK_COMMAND_TIMEOUT_SEC);
1447
- const providers = resolveAgentWorkspaceProviders(this.options);
1448
- try {
1449
- for (const provider of providers) {
1450
- if (provider === "codex") {
1451
- this.setupCodexHooks(DEFAULT_TERMINAL_ID, curlCmd, marker);
1452
- }
1453
- else {
1454
- // claude, custom
1455
- this.setupClaudeHooks(DEFAULT_TERMINAL_ID, curlCmd, [], marker);
1456
- }
1457
- }
1458
- }
1459
- catch (error) {
1460
- this.log(`failed to refresh agent permission hooks: ${error instanceof Error ? error.message : String(error)}`);
1461
- }
1462
- }
1463
- setupClaudeHooks(terminalId, curlCmd, args, marker) {
1464
- // Write hooks to ~/.claude/settings.json — Claude Code reads hooks from here
1465
- const claudeDir = join(homedir(), ".claude");
1466
- if (!existsSync(claudeDir))
1467
- mkdirSync(claudeDir, { recursive: true });
1468
- const settingsPath = join(claudeDir, "settings.json");
1469
- let existing = {};
1470
- try {
1471
- existing = JSON.parse(readFileSync(settingsPath, "utf8"));
1472
- }
1473
- catch { /* doesn't exist yet */ }
1474
- const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
1475
- const permissionEntry = {
1476
- matcher: "",
1477
- hooks: [{
1478
- type: "command",
1479
- command: curlCmd,
1480
- timeout: HOOK_COMMAND_TIMEOUT_SEC,
1481
- }],
1482
- };
1483
- const hookEvents = {
1484
- PreToolUse: hookEntry,
1485
- PostToolUse: hookEntry,
1486
- PostToolUseFailure: hookEntry,
1487
- Stop: hookEntry,
1488
- PermissionRequest: permissionEntry,
1489
- UserPromptSubmit: hookEntry,
1490
- SessionStart: hookEntry,
1491
- };
1492
- // Append our entries to existing hooks (first remove stale linkshell entries)
1493
- const existingHooks = (existing.hooks ?? {});
1494
- for (const [eventName, entry] of Object.entries(hookEvents)) {
1495
- existingHooks[eventName] = eventName === "PermissionRequest"
1496
- ? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
1497
- : withLinkShellHookEntry(existingHooks[eventName], entry, "last");
1498
- }
1499
- const merged = { ...existing, hooks: existingHooks };
1500
- writeFileSync(settingsPath, JSON.stringify(merged, null, 2));
1501
- this.log(`claude hooks appended to ${settingsPath}`);
1502
- return settingsPath;
1503
- }
1504
- setupCodexHooks(terminalId, curlCmd, marker) {
1505
- // Codex uses ~/.codex/hooks.json — same format as Claude (with matcher)
1506
- const codexDir = join(homedir(), ".codex");
1507
- if (!existsSync(codexDir))
1508
- mkdirSync(codexDir, { recursive: true });
1509
- // Ensure [features] codex_hooks = true in config.toml
1510
- const tomlPath = join(codexDir, "config.toml");
1511
- let tomlContent = "";
1512
- try {
1513
- tomlContent = readFileSync(tomlPath, "utf8");
1514
- }
1515
- catch { /* doesn't exist yet */ }
1516
- // Remove top-level codex_hooks (wrong location) and ensure it's under [features]
1517
- const hasFeatureSection = tomlContent.includes("[features]");
1518
- const hasCodexHooksUnderFeatures = hasFeatureSection &&
1519
- /\[features\][^\[]*codex_hooks\s*=\s*true/s.test(tomlContent);
1520
- if (!hasCodexHooksUnderFeatures) {
1521
- // Remove any top-level codex_hooks line
1522
- tomlContent = tomlContent.replace(/^codex_hooks\s*=.*\n?/m, "");
1523
- if (!tomlContent.includes("[features]")) {
1524
- tomlContent += `\n[features]\ncodex_hooks = true\n`;
1525
- }
1526
- else {
1527
- tomlContent = tomlContent.replace("[features]", "[features]\ncodex_hooks = true");
1528
- }
1529
- writeFileSync(tomlPath, tomlContent);
1530
- this.log(`enabled codex_hooks under [features] in ${tomlPath}`);
1531
- }
1532
- const hooksPath = join(codexDir, "hooks.json");
1533
- const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
1534
- const permissionEntry = {
1535
- matcher: "",
1536
- hooks: [{
1537
- type: "command",
1538
- command: curlCmd,
1539
- timeout: HOOK_COMMAND_TIMEOUT_SEC,
1540
- }],
1541
- };
1542
- const hookEvents = {
1543
- SessionStart: hookEntry,
1544
- PreToolUse: hookEntry,
1545
- PostToolUse: hookEntry,
1546
- UserPromptSubmit: hookEntry,
1547
- Stop: hookEntry,
1548
- PermissionRequest: permissionEntry,
1549
- };
1550
- // Read existing and append
1551
- let existing = {};
1552
- try {
1553
- existing = JSON.parse(readFileSync(hooksPath, "utf8"));
1554
- }
1555
- catch { /* doesn't exist yet */ }
1556
- const existingHooks = existing.hooks ?? {};
1557
- for (const [eventName, entry] of Object.entries(hookEvents)) {
1558
- existingHooks[eventName] = eventName === "PermissionRequest"
1559
- ? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
1560
- : withLinkShellHookEntry(existingHooks[eventName], entry, "last");
1561
- }
1562
- writeFileSync(hooksPath, JSON.stringify({ ...existing, hooks: existingHooks }, null, 2));
1563
- this.log(`codex hooks appended to ${hooksPath}`);
1564
- return hooksPath;
1565
- }
1566
- setupGeminiHooks(terminalId, curlCmd, marker) {
1567
- // Gemini uses ~/.gemini/settings.json — same format as Claude (with matcher)
1568
- const geminiDir = join(homedir(), ".gemini");
1569
- if (!existsSync(geminiDir))
1570
- mkdirSync(geminiDir, { recursive: true });
1571
- const settingsPath = join(geminiDir, "settings.json");
1572
- const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5000 }] };
1573
- const hookEvents = {
1574
- SessionStart: hookEntry,
1575
- SessionEnd: hookEntry,
1576
- BeforeTool: hookEntry,
1577
- AfterTool: hookEntry,
1578
- };
1579
- // Merge with existing settings if present
1580
- let existing = {};
1581
- try {
1582
- existing = JSON.parse(readFileSync(settingsPath, "utf8"));
1583
- }
1584
- catch { /* doesn't exist yet */ }
1585
- const existingHooks = (existing.hooks ?? {});
1586
- for (const [eventName, entry] of Object.entries(hookEvents)) {
1587
- existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
1588
- }
1589
- existing.hooks = existingHooks;
1590
- writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
1591
- this.log(`gemini hooks appended to ${settingsPath}`);
1592
- return settingsPath;
1593
- }
1594
- setupCopilotHooks(terminalId, curlCmd, marker) {
1595
- // Copilot loads hooks from CWD as hooks.json. Cursor's coding agent also
1596
- // reads that file when the folder is a Cursor project — writing it here
1597
- // freezes every IDE tool call the moment the hook server is down or
1598
- // returns non-JSON.
1599
- const cwd = this.terminals.get(terminalId)?.cwd ?? this.defaultCwd;
1600
- const hooksPath = join(cwd, "hooks.json");
1601
- if (!shouldWriteProjectHooksJson(cwd)) {
1602
- this.sweepLinkShellHookEntries(hooksPath);
1603
- try {
1604
- if (existsSync(hooksPath)) {
1605
- const raw = JSON.parse(readFileSync(hooksPath, "utf8"));
1606
- if (!raw.hooks || (typeof raw.hooks === "object" && Object.keys(raw.hooks).length === 0)) {
1607
- unlinkSync(hooksPath);
1608
- }
1609
- }
1610
- }
1611
- catch { /* leave whatever is on disk */ }
1612
- this.log(`skip writing ${hooksPath}: Cursor project would inherit it and freeze the IDE agent`);
1613
- return hooksPath;
1614
- }
1615
- const mkHook = () => ({
1616
- type: "command",
1617
- bash: curlCmd,
1618
- timeoutSec: 30,
1619
- });
1620
- const hookEvents = {
1621
- sessionStart: mkHook(),
1622
- sessionEnd: mkHook(),
1623
- userPromptSubmitted: mkHook(),
1624
- preToolUse: mkHook(),
1625
- postToolUse: mkHook(),
1626
- errorOccurred: mkHook(),
1627
- };
1628
- // Read existing and append
1629
- let existing = {};
1630
- try {
1631
- existing = JSON.parse(readFileSync(hooksPath, "utf8"));
1632
- }
1633
- catch { /* doesn't exist yet */ }
1634
- const existingHooks = existing.hooks ?? {};
1635
- for (const [eventName, entry] of Object.entries(hookEvents)) {
1636
- existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
1637
- }
1638
- writeFileSync(hooksPath, JSON.stringify({ version: 1, hooks: existingHooks }, null, 2));
1639
- this.log(`copilot hooks appended to ${hooksPath}`);
1640
- return hooksPath;
1641
- }
1642
- handleHookEvent(terminalId, event, provider, permissionRequestId) {
1643
- const rawHookName = (event.hook_event_name ?? event.event_name);
1644
- if (!rawHookName)
1645
- return;
1646
- // Auto-detect provider from hook event fields
1647
- const hookTerm = this.terminals.get(terminalId);
1648
- let detectedProvider = provider;
1649
- // Always detect from transcript_path (most reliable), regardless of current provider
1650
- const transcriptPath = typeof event.transcript_path === "string" ? event.transcript_path : "";
1651
- if (transcriptPath.includes(".claude/")) {
1652
- detectedProvider = "claude";
1653
- }
1654
- else if (transcriptPath.includes(".gemini/")) {
1655
- detectedProvider = "gemini";
1656
- }
1657
- else if (transcriptPath.includes(".codex/")) {
1658
- detectedProvider = "codex";
1659
- }
1660
- else if (hookTerm?.provider === "custom") {
1661
- // Fallback heuristics only when provider is still unknown
1662
- if (event.model && typeof event.model === "string" && /^(gpt|o[0-9]|codex)/i.test(event.model)) {
1663
- detectedProvider = "codex";
1664
- }
1665
- else if (event.session_id && !transcriptPath) {
1666
- detectedProvider = "codex";
1667
- }
1668
- else if (/^(Before|After)(Tool)$|^Session(Start|End)$/.test(rawHookName)) {
1669
- detectedProvider = "gemini";
1670
- }
1671
- else if (/^(pre|post)ToolUse$|^session(Start|End)$|^userPromptSubmitted$|^errorOccurred$/.test(rawHookName)) {
1672
- detectedProvider = "copilot";
1673
- }
1674
- }
1675
- if (hookTerm && detectedProvider !== hookTerm.provider) {
1676
- const wasCustom = hookTerm.provider === "custom";
1677
- hookTerm.provider = detectedProvider;
1678
- this.log(`${wasCustom ? "detected" : "provider switched"} provider for ${terminalId}: ${detectedProvider}`);
1679
- this.permissionStacks.delete(terminalId);
1680
- this.sendTerminalList();
1681
- }
1682
- // Normalize hook event names from different providers to unified names
1683
- const hookName = this.normalizeHookName(rawHookName, detectedProvider);
1684
- if (!hookName)
1685
- return;
1686
- let phase;
1687
- let toolName;
1688
- let toolInput;
1689
- let permissionRequest;
1690
- let summary;
1691
- switch (hookName) {
1692
- case "PreToolUse":
1693
- phase = "tool_use";
1694
- toolName = (event.tool_name ?? event.toolName);
1695
- if (event.tool_input && typeof event.tool_input === "object") {
1696
- const input = event.tool_input;
1697
- toolInput = JSON.stringify(input).slice(0, 200);
1698
- }
1699
- else if (event.toolInput && typeof event.toolInput === "object") {
1700
- toolInput = JSON.stringify(event.toolInput).slice(0, 200);
1701
- }
1702
- break;
1703
- case "PostToolUse":
1704
- phase = "thinking";
1705
- toolName = (event.tool_name ?? event.toolName);
1706
- // Pop permission stack + auto-resolve pending HTTP connection
1707
- {
1708
- const stack = this.permissionStacks.get(terminalId);
1709
- if (stack && stack.length > 0) {
1710
- const popped = stack.pop();
1711
- if (popped)
1712
- this.autoResolvePending(popped.requestId);
1713
- if (stack.length === 0)
1714
- this.permissionStacks.delete(terminalId);
1715
- }
1716
- }
1717
- break;
1718
- case "PostToolUseFailure":
1719
- phase = "error";
1720
- toolName = (event.tool_name ?? event.toolName);
1721
- {
1722
- const stack = this.permissionStacks.get(terminalId);
1723
- if (stack && stack.length > 0) {
1724
- const popped = stack.pop();
1725
- if (popped)
1726
- this.autoResolvePending(popped.requestId);
1727
- if (stack.length === 0)
1728
- this.permissionStacks.delete(terminalId);
1729
- }
1730
- }
1731
- break;
1732
- case "Stop":
1733
- phase = "idle";
1734
- if (event.stop_reason)
1735
- summary = String(event.stop_reason);
1736
- this.drainPendingPermissions(terminalId);
1737
- this.permissionStacks.delete(terminalId);
1738
- // Reset provider to "custom" when a CLI session ends inside a custom shell
1739
- if (hookTerm && this.options.providerConfig.provider === "custom") {
1740
- hookTerm.provider = "custom";
1741
- this.log(`provider reset to custom for ${terminalId} (CLI session ended)`);
1742
- this.sendTerminalList();
1743
- }
1744
- break;
1745
- case "PermissionRequest":
1746
- phase = "waiting";
1747
- toolName = (event.tool_name ?? event.toolName);
1748
- if (event.tool_input && typeof event.tool_input === "object") {
1749
- const input = event.tool_input;
1750
- permissionRequest = JSON.stringify(input).slice(0, 300);
1751
- }
1752
- else if (event.toolInput && typeof event.toolInput === "object") {
1753
- permissionRequest = JSON.stringify(event.toolInput).slice(0, 300);
1754
- }
1755
- // Push to permission stack (use requestId from hook server if available)
1756
- {
1757
- const reqId = permissionRequestId ?? `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1758
- if (!this.permissionStacks.has(terminalId)) {
1759
- this.permissionStacks.set(terminalId, []);
1760
- }
1761
- this.permissionStacks.get(terminalId).push({
1762
- requestId: reqId,
1763
- toolName: toolName ?? "unknown",
1764
- toolInput: toolInput ?? (permissionRequest ?? ""),
1765
- permissionRequest: permissionRequest ?? "",
1766
- timestamp: Date.now(),
1767
- });
1768
- }
1769
- break;
1770
- case "SessionStart":
1771
- phase = "idle";
1772
- summary = "session started";
1773
- break;
1774
- case "UserPromptSubmit":
1775
- phase = "thinking";
1776
- this.drainPendingPermissions(terminalId);
1777
- this.permissionStacks.delete(terminalId);
1778
- break;
1779
- default:
1780
- return;
1781
- }
1782
- this.log(`hook event [${provider}]: ${rawHookName} → ${hookName} → phase=${phase} tool=${toolName ?? "none"}`);
1783
- // Build topPermission from stack
1784
- const stack = this.permissionStacks.get(terminalId);
1785
- const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
1786
- const pendingPermissionCount = stack?.length ?? 0;
1787
- // Increment statusSeq for ordering
1788
- const term = this.terminals.get(terminalId);
1789
- const seq = term ? term.statusSeq++ : 0;
1790
- this.send(createEnvelope({
1791
- type: "terminal.status",
1792
- sessionId: this.sessionId,
1793
- terminalId,
1794
- payload: {
1795
- phase,
1796
- provider,
1797
- seq,
1798
- ...(toolName && { toolName }),
1799
- ...(toolInput && { toolInput }),
1800
- ...(permissionRequest && { permissionRequest }),
1801
- ...(summary && { summary }),
1802
- ...(topPermission && { topPermission }),
1803
- ...(pendingPermissionCount > 0 && { pendingPermissionCount }),
1804
- },
1805
- }));
1806
- }
1807
- /** Determine which provider a session id belongs to by which on-disk store
1808
- * holds its transcript — Claude writes ~/.claude/projects/<proj>/<id>.jsonl,
1809
- * Codex writes ~/.codex/sessions/**​/rollout-*-<id>.jsonl. Deterministic and
1810
- * needed because an external session's provider is NOT the spawned PTY's
1811
- * provider. best-effort; returns undefined if not found (caller falls back).
1812
- * Only hit on a permission request (human-in-loop, rare), so the directory
1813
- * scan cost is fine. */
1814
- providerForExternalSession(sessionId) {
1815
- try {
1816
- const claudeRoot = join(homedir(), ".claude", "projects");
1817
- if (existsSync(claudeRoot)) {
1818
- for (const proj of readdirSync(claudeRoot)) {
1819
- if (existsSync(join(claudeRoot, proj, `${sessionId}.jsonl`)))
1820
- return "claude";
1821
- }
1822
- }
1823
- }
1824
- catch { /* ignore */ }
1825
- try {
1826
- const codexRoot = join(homedir(), ".codex", "sessions");
1827
- if (existsSync(codexRoot)) {
1828
- const suffix = `-${sessionId}.jsonl`;
1829
- const walk = (dir) => {
1830
- for (const name of readdirSync(dir)) {
1831
- const p = join(dir, name);
1832
- let st;
1833
- try {
1834
- st = statSync(p);
1835
- }
1836
- catch {
1837
- continue;
1838
- }
1839
- if (st.isDirectory()) {
1840
- if (walk(p))
1841
- return true;
1842
- }
1843
- else if (name.startsWith("rollout-") && name.endsWith(suffix))
1844
- return true;
1845
- }
1846
- return false;
1847
- };
1848
- if (walk(codexRoot))
1849
- return "codex";
1850
- }
1851
- }
1852
- catch { /* ignore */ }
1853
- return undefined;
1854
- }
1855
- sendHookPermissionRequest(terminalId, event, requestId, provider) {
1856
- const toolName = (event.tool_name ?? event.toolName);
1857
- const toolInput = stringifyHookInput(event.tool_input ?? event.toolInput);
1858
- const suggestions = hookPermissionSuggestions(event);
1859
- const context = typeof event.permission_prompt === "string"
1860
- ? event.permission_prompt
1861
- : typeof event.message === "string"
1862
- ? event.message
1863
- : undefined;
1864
- const options = hookPermissionOptions(suggestions);
1865
- this.send(createEnvelope({
1866
- type: "agent.permission.request",
1867
- sessionId: this.sessionId,
1868
- terminalId,
1869
- payload: {
1870
- requestId,
1871
- toolName,
1872
- toolInput,
1873
- context,
1874
- options,
1875
- },
1876
- }));
1877
- // Also surface the request on the v2 channel, mapped to the on-disk session
1878
- // it came from, so the web agent console (which lives entirely on agent.v2)
1879
- // shows a permission card on the right conversation — not just the v1
1880
- // terminal view. The hook payload carries the provider's own session id;
1881
- // map it to the conversation id the session tree already uses. The v2
1882
- // respond is routed back to this same pending HTTP connection (see the
1883
- // resolvePendingPermission check in the agent.v2.permission.respond path).
1884
- const sessionId = (event.session_id ?? event.sessionId);
1885
- if (sessionId) {
1886
- // Derive the provider from the SESSION, not the spawned PTY's provider.
1887
- // The same curl hook is written to both ~/.claude and ~/.codex, and the
1888
- // HTTP server is created with a single provider — so an external Codex
1889
- // session's request could arrive on a "claude" server and get the wrong
1890
- // conversationId prefix, landing the card on a phantom conversation.
1891
- // Look up which store the id lives in (deterministic; verified).
1892
- const agentProvider = this.providerForExternalSession(sessionId)
1893
- ?? (provider === "codex" ? "codex" : provider === "claude" ? "claude" : "custom");
1894
- const conversationId = makeAgentV2RemoteConversationId(agentProvider, sessionId);
1895
- const permission = { requestId, toolName, toolInput, context, options };
1896
- // The web store only renders a clickable allow/deny card when the request
1897
- // carries a timeline `item` (it just flips the status badge otherwise), so
1898
- // include one shaped exactly like the workspace's own permission flow.
1899
- const now = Date.now();
1900
- const item = {
1901
- id: `permission:${requestId}`,
1902
- conversationId,
1903
- type: "permission",
1904
- permission,
1905
- metadata: { protocol: "v2", permissionLive: true, permissionExpired: false, permissionPending: false },
1906
- createdAt: now,
1907
- updatedAt: now,
1908
- };
1909
- this.send(createEnvelope({
1910
- type: "agent.v2.permission.request",
1911
- sessionId: this.sessionId,
1912
- payload: { conversationId, ...permission, item },
1913
- }));
1914
- }
1915
- }
1916
- /**
1917
- * Normalize hook event names from different CLI providers to unified internal names.
1918
- * Claude: PascalCase (PreToolUse, PostToolUse, Stop, PermissionRequest)
1919
- * Codex: camelCase (preToolUse, postToolUse, sessionStart)
1920
- * Gemini: PascalCase but different names (BeforeTool, AfterTool, BeforeSubmitPrompt)
1921
- */
1922
- normalizeHookName(rawName, provider) {
1923
- // Claude events — already in our canonical format
1924
- if (provider === "claude") {
1925
- return rawName;
1926
- }
1927
- // Codex events — same as Claude (PascalCase)
1928
- if (provider === "codex") {
1929
- switch (rawName) {
1930
- case "PreToolUse":
1931
- case "preToolUse": return "PreToolUse";
1932
- case "PostToolUse":
1933
- case "postToolUse": return "PostToolUse";
1934
- case "SessionStart":
1935
- case "sessionStart": return "SessionStart";
1936
- case "UserPromptSubmit": return "UserPromptSubmit";
1937
- case "PermissionRequest": return "PermissionRequest";
1938
- case "Stop": return "Stop";
1939
- default: return undefined;
1940
- }
1941
- }
1942
- // Gemini events
1943
- if (provider === "gemini") {
1944
- switch (rawName) {
1945
- case "BeforeTool": return "PreToolUse";
1946
- case "AfterTool": return "PostToolUse";
1947
- case "SessionStart": return "SessionStart";
1948
- case "SessionEnd": return "Stop";
1949
- default: return undefined;
1950
- }
1951
- }
1952
- // Copilot events (camelCase)
1953
- if (provider === "copilot") {
1954
- switch (rawName) {
1955
- case "preToolUse": return "PreToolUse";
1956
- case "postToolUse": return "PostToolUse";
1957
- case "sessionStart": return "SessionStart";
1958
- case "sessionEnd": return "Stop";
1959
- case "userPromptSubmitted": return "UserPromptSubmit";
1960
- case "errorOccurred": return "PostToolUseFailure";
1961
- default: return undefined;
1962
- }
1963
- }
1964
- // Unknown provider — try all known formats
1965
- // This handles "custom" shell where any provider might be launched
1966
- const allProviders = ["claude", "codex", "gemini", "copilot"];
1967
- for (const p of allProviders) {
1968
- const result = this.normalizeHookName(rawName, p);
1969
- if (result)
1970
- return result;
1971
- }
1972
- return undefined;
1973
- }
1974
- /** Auto-resolve a single pending permission (user acted in terminal) */
1975
- autoResolvePending(requestId) {
1976
- if (this.resolvePendingPermission(requestId, "allow", "terminal.auto").resolved) {
1977
- this.log(`auto-resolved pending permission ${requestId} (user acted in terminal)`);
1978
- }
1979
- }
1980
- /** Drain all pending permissions for a terminal (session ended, stop, etc.) */
1981
- drainPendingPermissions(terminalId) {
1982
- const stack = this.permissionStacks.get(terminalId);
1983
- if (!stack)
1984
- return;
1985
- for (const entry of [...stack]) {
1986
- if (this.resolvePendingPermission(entry.requestId, "deny", "terminal.drain").resolved) {
1987
- this.log(`drained pending permission ${entry.requestId}`);
1988
- }
1989
- }
1990
- }
1991
- resolvePendingPermission(requestId, choice, source = "unknown") {
1992
- const pending = this.pendingPermissions.get(requestId);
1993
- const outcome = typeof choice === "string" ? choice : choice.outcome;
1994
- const optionId = typeof choice === "string" ? undefined : choice.optionId;
1995
- if (!pending) {
1996
- this.log(`no pending permission for ${requestId} via ${source}: ${outcome}:${optionId ?? "default"}`);
1997
- return { resolved: false, delivered: false };
1998
- }
1999
- this.pendingPermissions.delete(requestId);
2000
- clearTimeout(pending.timeout);
2001
- const delivered = pending.resolve(this.formatHookPermissionDecision(pending, choice));
2002
- const stack = this.permissionStacks.get(pending.terminalId);
2003
- if (stack) {
2004
- const idx = stack.findIndex((entry) => entry.requestId === requestId);
2005
- if (idx >= 0)
2006
- stack.splice(idx, 1);
2007
- if (stack.length === 0)
2008
- this.permissionStacks.delete(pending.terminalId);
2009
- }
2010
- this.log(`resolved permission ${requestId} via ${source}: ${outcome}:${optionId ?? "default"} delivered=${delivered}`);
2011
- this.sendPermissionSnapshot(pending.terminalId, "thinking", outcome === "allow" ? "permission allowed" : "permission denied", { requestId, outcome, source, delivered });
2012
- return { resolved: true, delivered };
2013
- }
2014
- formatHookPermissionDecision(permission, choice) {
2015
- const outcome = typeof choice === "string" ? choice : choice.outcome;
2016
- const optionId = typeof choice === "string" ? undefined : choice.optionId;
2017
- if (outcome === "allow") {
2018
- return {
2019
- behavior: "allow",
2020
- ...(optionId === "allow_always" && permission.permissionSuggestions.length > 0
2021
- ? { updatedPermissions: permission.permissionSuggestions }
2022
- : {}),
2023
- };
2024
- }
2025
- return {
2026
- behavior: "deny",
2027
- message: outcome === "cancelled" ? "Permission request cancelled." : "Permission denied by user.",
2028
- };
2029
- }
2030
- sendPermissionSnapshot(terminalId, phase, summary, permissionResolution) {
2031
- const stack = this.permissionStacks.get(terminalId);
2032
- const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
2033
- const pendingPermissionCount = stack?.length ?? 0;
2034
- const term = this.terminals.get(terminalId);
2035
- const seq = term ? term.statusSeq++ : 0;
2036
- this.send(createEnvelope({
2037
- type: "terminal.status",
2038
- sessionId: this.sessionId,
2039
- terminalId,
2040
- payload: {
2041
- phase,
2042
- provider: term?.provider,
2043
- seq,
2044
- ...(summary && { summary }),
2045
- ...(permissionResolution && { permissionResolution }),
2046
- ...(topPermission && { topPermission }),
2047
- ...(pendingPermissionCount > 0 && { pendingPermissionCount }),
2048
- },
2049
- }));
2050
- }
2051
- cleanupHookServer(term) {
2052
- // Drain any pending permission requests for this terminal
2053
- this.drainPendingPermissions(term.id);
2054
- if (term.hookServer) {
2055
- term.hookServer.close();
2056
- term.hookServer = undefined;
2057
- this.log(`hook server closed for ${term.id}`);
2058
- }
2059
- const marker = term.hookMarker;
2060
- for (const configPath of term.hookConfigPaths) {
2061
- try {
2062
- // Copilot: per-instance file — just delete it
2063
- if (configPath.includes(`linkshell-${marker}`)) {
2064
- if (existsSync(configPath)) {
2065
- unlinkSync(configPath);
2066
- this.log(`removed copilot hook file ${configPath}`);
2067
- }
2068
- }
2069
- else {
2070
- // Claude/Codex/Gemini: remove our entries from the shared config
2071
- this.removeHookEntries(configPath, marker);
2072
- }
2073
- }
2074
- catch { /* ignore */ }
2075
- }
2076
- term.hookConfigPaths = [];
2077
- }
2078
- /** Remove hook entries containing our marker from a JSON config file */
2079
- removeHookEntries(configPath, marker) {
2080
- if (!existsSync(configPath))
2081
- return;
2082
- try {
2083
- const raw = JSON.parse(readFileSync(configPath, "utf8"));
2084
- const hooks = raw.hooks;
2085
- if (!hooks)
2086
- return;
2087
- let changed = false;
2088
- for (const [eventName, entries] of Object.entries(hooks)) {
2089
- if (!Array.isArray(entries))
2090
- continue;
2091
- const filtered = entries.filter((entry) => {
2092
- const str = JSON.stringify(entry);
2093
- return !str.includes(marker);
2094
- });
2095
- if (filtered.length !== entries.length) {
2096
- changed = true;
2097
- if (filtered.length === 0) {
2098
- delete hooks[eventName];
2099
- }
2100
- else {
2101
- hooks[eventName] = filtered;
2102
- }
2103
- }
2104
- }
2105
- if (changed) {
2106
- // If no hooks left, remove the hooks key entirely
2107
- if (Object.keys(hooks).length === 0) {
2108
- delete raw.hooks;
2109
- }
2110
- writeFileSync(configPath, JSON.stringify(raw, null, 2));
2111
- this.log(`removed our hook entries from ${configPath}`);
2112
- }
2113
- }
2114
- catch { /* ignore parse errors */ }
2115
- }
2116
- /**
2117
- * Remove ALL LinkShell hook entries (any marker) from a JSON config file.
2118
- * Used on startup to sweep stale entries left behind by a crashed instance,
2119
- * whose curl ports are now dead. Matches via isLinkShellHookEntry rather than
2120
- * a single marker so entries from previous runs are also cleaned.
2121
- */
2122
- sweepLinkShellHookEntries(configPath) {
2123
- if (!existsSync(configPath))
2124
- return;
2125
- try {
2126
- // Re-read immediately before write to minimize the window for a
2127
- // concurrent writer to clobber our changes.
2128
- const raw = JSON.parse(readFileSync(configPath, "utf8"));
2129
- const hooks = raw.hooks;
2130
- if (!hooks)
2131
- return;
2132
- let changed = false;
2133
- for (const [eventName, entries] of Object.entries(hooks)) {
2134
- if (!Array.isArray(entries))
2135
- continue;
2136
- const filtered = entries.filter((entry) => !isLinkShellHookEntry(entry));
2137
- if (filtered.length !== entries.length) {
2138
- changed = true;
2139
- if (filtered.length === 0) {
2140
- delete hooks[eventName];
2141
- }
2142
- else {
2143
- hooks[eventName] = filtered;
2144
- }
2145
- }
2146
- }
2147
- if (changed) {
2148
- if (Object.keys(hooks).length === 0) {
2149
- delete raw.hooks;
2150
- }
2151
- writeFileSync(configPath, JSON.stringify(raw, null, 2));
2152
- this.log(`swept stale LinkShell hook entries from ${configPath}`);
2153
- }
2154
- }
2155
- catch { /* ignore parse errors */ }
2156
- }
2157
- /** Sweep stale LinkShell hook entries from known shared config files on startup. */
2158
- sweepStaleHookConfigs() {
2159
- const home = homedir();
2160
- const candidates = [
2161
- join(home, ".claude", "settings.json"),
2162
- // Old CLI versions wrote hooks here; Claude Code still runs them.
2163
- join(home, ".claude", "settings.local.json"),
2164
- join(home, ".codex", "hooks.json"),
2165
- join(home, ".gemini", "settings.json"),
2166
- join(this.defaultCwd, "hooks.json"), // copilot (per-cwd)
2167
- ];
2168
- for (const path of candidates) {
2169
- this.rewriteBareLinkShellIdInFile(path);
2170
- this.sweepLinkShellHookEntries(path);
2171
- }
2172
- }
2173
- /** Silence Claude Code's "required env var(s) not set: ${LINKSHELL_ID}" on leftover hooks. */
2174
- rewriteBareLinkShellIdInFile(configPath) {
2175
- if (!existsSync(configPath))
2176
- return;
2177
- try {
2178
- const raw = readFileSync(configPath, "utf8");
2179
- const next = rewriteBareLinkShellId(raw);
2180
- if (next !== raw) {
2181
- writeFileSync(configPath, next);
2182
- this.log(`rewrote optional LINKSHELL_ID expansion in ${configPath}`);
2183
- }
2184
- }
2185
- catch { /* ignore parse/io errors */ }
2186
- }
2187
1159
  send(message) {
2188
1160
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
2189
1161
  if (message.type.startsWith("agent.v2.")) {
@@ -2382,7 +1354,6 @@ export class BridgeSession {
2382
1354
  }
2383
1355
  this.exitedTerminalTimers.clear();
2384
1356
  for (const term of this.terminals.values()) {
2385
- this.cleanupHookServer(term);
2386
1357
  if (term.status === "running")
2387
1358
  term.pty.kill();
2388
1359
  }