linkshell-cli 0.5.37 → 0.5.38
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.
- package/dist/cli/src/runtime/acp/acp-client.d.ts +21 -0
- package/dist/cli/src/runtime/acp/acp-client.js +101 -5
- package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-process-discovery.js +5 -1
- package/dist/cli/src/runtime/acp/agent-process-discovery.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace.js +146 -33
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/acp/provider-resolver.js +19 -2
- package/dist/cli/src/runtime/acp/provider-resolver.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +0 -48
- package/dist/cli/src/runtime/bridge-session.js +10 -1039
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/src/runtime/hook-command.d.ts +5 -30
- package/dist/cli/src/runtime/hook-command.js +54 -41
- package/dist/cli/src/runtime/hook-command.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/runtime/acp/acp-client.ts +116 -5
- package/src/runtime/acp/agent-process-discovery.ts +3 -1
- package/src/runtime/acp/agent-workspace.ts +152 -32
- package/src/runtime/acp/provider-resolver.ts +21 -2
- package/src/runtime/bridge-session.ts +9 -1117
- package/src/runtime/hook-command.ts +47 -47
- package/web/assets/{FileBrowser-Coy7Szjx.js → FileBrowser-Db2LlqTw.js} +1 -1
- package/web/assets/{PortPreview-D-XovbV1.js → PortPreview-Cobi7sn0.js} +1 -1
- package/web/assets/{TerminalPanel-Cah56AtC.js → TerminalPanel-_Wve-C8s.js} +1 -1
- package/web/assets/{UsageDashboard-DfkjJagC.js → UsageDashboard-DmXct3sk.js} +1 -1
- package/web/assets/index-pnfD2xom.js +35 -0
- package/web/index.html +1 -1
- 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,
|
|
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 {
|
|
@@ -25,12 +25,7 @@ import { getValidToken, refreshAccessToken } from "../auth.js";
|
|
|
25
25
|
import { AgentSessionProxy } from "./acp/agent-session.js";
|
|
26
26
|
import { AgentWorkspaceProxy, makeAgentV2RemoteConversationId } from "./acp/agent-workspace.js";
|
|
27
27
|
import { detectAvailableProviders, type AgentProvider } from "./acp/provider-resolver.js";
|
|
28
|
-
import {
|
|
29
|
-
buildLinkShellHookCommand,
|
|
30
|
-
isLinkShellHookEntry,
|
|
31
|
-
rewriteBareLinkShellId,
|
|
32
|
-
shouldWriteProjectHooksJson,
|
|
33
|
-
} from "./hook-command.js";
|
|
28
|
+
import { sweepLinkShellHookConfigs } from "./hook-command.js";
|
|
34
29
|
|
|
35
30
|
export interface BridgeSessionOptions {
|
|
36
31
|
gatewayUrl: string;
|
|
@@ -75,7 +70,6 @@ const RECONNECT_MAX_DELAY = 30_000;
|
|
|
75
70
|
// arbitrarily long network outages, reconnecting whenever connectivity returns.
|
|
76
71
|
const RECONNECT_MAX_EXPONENT = 5;
|
|
77
72
|
const DEFAULT_TERMINAL_ID = "default";
|
|
78
|
-
const HOOK_BODY_LIMIT = 256 * 1024;
|
|
79
73
|
const SCROLLBACK_LINES = 500;
|
|
80
74
|
// Upper bound on concurrent live terminals — forceNew removed the cwd-dedup
|
|
81
75
|
// ceiling, so bound growth to avoid PTY/fd exhaustion from a runaway client.
|
|
@@ -86,12 +80,6 @@ const EXITED_TERMINAL_GRACE_MS = 30_000;
|
|
|
86
80
|
// Default ports the bridge will proxy tunnel requests to when no explicit
|
|
87
81
|
// allowlist is configured. Covers the common dev servers the app tunnels to.
|
|
88
82
|
const DEFAULT_TUNNEL_PORTS = [3000, 3001, 4321, 5173, 5174, 8080, 8000, 8081];
|
|
89
|
-
const PERMISSION_REQUEST_TIMEOUT_MS = Number(
|
|
90
|
-
process.env.LINKSHELL_PERMISSION_TIMEOUT_MS ?? 5 * 60_000,
|
|
91
|
-
);
|
|
92
|
-
const HOOK_COMMAND_TIMEOUT_SEC = Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000);
|
|
93
|
-
const LINKSHELL_PERMISSION_GUARD_MARKER = "LINKSHELL_PERMISSION_GUARD";
|
|
94
|
-
|
|
95
83
|
interface TerminalInstance {
|
|
96
84
|
id: string;
|
|
97
85
|
pty: pty.IPty;
|
|
@@ -102,136 +90,6 @@ interface TerminalInstance {
|
|
|
102
90
|
outputSeq: number;
|
|
103
91
|
statusSeq: number;
|
|
104
92
|
status: "running" | "exited";
|
|
105
|
-
hookServer?: http.Server;
|
|
106
|
-
hookPort?: number;
|
|
107
|
-
hookMarker: string;
|
|
108
|
-
hookConfigPaths: string[];
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
interface PendingPermission {
|
|
112
|
-
terminalId: string;
|
|
113
|
-
timeout: ReturnType<typeof setTimeout>;
|
|
114
|
-
permissionSuggestions: unknown[];
|
|
115
|
-
resolve: (decision: HookPermissionDecision) => boolean;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
interface HookPermissionDecision {
|
|
119
|
-
behavior: "allow" | "deny";
|
|
120
|
-
updatedPermissions?: unknown[];
|
|
121
|
-
message?: string;
|
|
122
|
-
interrupt?: boolean;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
type HookPermissionChoice =
|
|
126
|
-
| "allow"
|
|
127
|
-
| "deny"
|
|
128
|
-
| {
|
|
129
|
-
outcome: "allow" | "deny" | "cancelled";
|
|
130
|
-
optionId?: string;
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
function withLinkShellHookEntry<T>(
|
|
134
|
-
entries: unknown[] | undefined,
|
|
135
|
-
entry: T,
|
|
136
|
-
priority: "first" | "last",
|
|
137
|
-
): unknown[] {
|
|
138
|
-
const cleaned = (Array.isArray(entries) ? entries : []).filter((item) => !isLinkShellHookEntry(item));
|
|
139
|
-
return priority === "first" ? [entry, ...cleaned] : [...cleaned, entry];
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function guardPermissionCommandForLinkShell(command: unknown): unknown {
|
|
143
|
-
if (typeof command !== "string") return command;
|
|
144
|
-
if (command.includes(LINKSHELL_PERMISSION_GUARD_MARKER)) return command;
|
|
145
|
-
return [
|
|
146
|
-
`case "\${LINKSHELL_ID:-}" in lsh-*) exit 0 ;; esac`,
|
|
147
|
-
`# ${LINKSHELL_PERMISSION_GUARD_MARKER}`,
|
|
148
|
-
command,
|
|
149
|
-
].join("\n");
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function guardPermissionHookObjectForLinkShell(
|
|
153
|
-
hook: Record<string, unknown>,
|
|
154
|
-
): Record<string, unknown> {
|
|
155
|
-
if (isLinkShellHookEntry(hook)) return hook;
|
|
156
|
-
const next: Record<string, unknown> = { ...hook };
|
|
157
|
-
if (typeof next.command === "string") {
|
|
158
|
-
next.command = guardPermissionCommandForLinkShell(next.command);
|
|
159
|
-
}
|
|
160
|
-
if (typeof next.bash === "string") {
|
|
161
|
-
next.bash = guardPermissionCommandForLinkShell(next.bash);
|
|
162
|
-
}
|
|
163
|
-
return next;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function guardPermissionHookEntryForLinkShell(entry: unknown): unknown {
|
|
167
|
-
if (isLinkShellHookEntry(entry)) return entry;
|
|
168
|
-
if (typeof entry === "string") return guardPermissionCommandForLinkShell(entry);
|
|
169
|
-
if (Array.isArray(entry)) return entry.map(guardPermissionHookEntryForLinkShell);
|
|
170
|
-
if (!entry || typeof entry !== "object") return entry;
|
|
171
|
-
|
|
172
|
-
const next = { ...(entry as Record<string, unknown>) };
|
|
173
|
-
if (Array.isArray(next.hooks)) {
|
|
174
|
-
next.hooks = next.hooks.map((hook) =>
|
|
175
|
-
hook && typeof hook === "object" && !Array.isArray(hook)
|
|
176
|
-
? guardPermissionHookObjectForLinkShell(hook as Record<string, unknown>)
|
|
177
|
-
: guardPermissionHookEntryForLinkShell(hook),
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
if (typeof next.command === "string" || typeof next.bash === "string") {
|
|
181
|
-
return guardPermissionHookObjectForLinkShell(next);
|
|
182
|
-
}
|
|
183
|
-
return next;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function withBlockingLinkShellPermissionEntry<T>(
|
|
187
|
-
entries: unknown[] | undefined,
|
|
188
|
-
entry: T,
|
|
189
|
-
): unknown[] {
|
|
190
|
-
const cleaned = (Array.isArray(entries) ? entries : [])
|
|
191
|
-
.filter((item) => !isLinkShellHookEntry(item))
|
|
192
|
-
.map(guardPermissionHookEntryForLinkShell);
|
|
193
|
-
return [entry, ...cleaned];
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function stringifyHookInput(value: unknown): string {
|
|
197
|
-
if (typeof value === "string") return value.slice(0, 1200);
|
|
198
|
-
if (typeof value === "object" && value) {
|
|
199
|
-
try {
|
|
200
|
-
return JSON.stringify(value, null, 2).slice(0, 1200);
|
|
201
|
-
} catch {
|
|
202
|
-
return String(value).slice(0, 1200);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return "";
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function hookPermissionSuggestions(event: Record<string, unknown>): unknown[] {
|
|
209
|
-
if (isCodexPermissionRequest(event)) return [];
|
|
210
|
-
const snake = event.permission_suggestions;
|
|
211
|
-
const camel = event.permissionSuggestions;
|
|
212
|
-
if (Array.isArray(snake)) return snake;
|
|
213
|
-
if (Array.isArray(camel)) return camel;
|
|
214
|
-
return [];
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function isCodexPermissionRequest(event: Record<string, unknown>): boolean {
|
|
218
|
-
if (typeof event.turn_id === "string" || typeof event.turnId === "string") return true;
|
|
219
|
-
const transcriptPath = event.transcript_path ?? event.transcriptPath;
|
|
220
|
-
return typeof transcriptPath === "string" && transcriptPath.includes("/.codex/");
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function hookPermissionOptions(suggestions: unknown[]): Array<{
|
|
224
|
-
id: string;
|
|
225
|
-
label: string;
|
|
226
|
-
kind: "allow" | "deny" | "other";
|
|
227
|
-
}> {
|
|
228
|
-
return [
|
|
229
|
-
{ id: "deny", label: "拒绝", kind: "deny" },
|
|
230
|
-
{ id: "allow_once", label: "允许一次", kind: "allow" },
|
|
231
|
-
...(suggestions.length > 0
|
|
232
|
-
? [{ id: "allow_always" as const, label: "始终允许", kind: "allow" as const }]
|
|
233
|
-
: []),
|
|
234
|
-
];
|
|
235
93
|
}
|
|
236
94
|
|
|
237
95
|
function stripGatewayBase(url: URL): string {
|
|
@@ -359,16 +217,6 @@ export class BridgeSession {
|
|
|
359
217
|
private hostToken: string | undefined;
|
|
360
218
|
private exited = false;
|
|
361
219
|
private stopped = false;
|
|
362
|
-
private permissionStacks = new Map<string, Array<{
|
|
363
|
-
requestId: string;
|
|
364
|
-
toolName: string;
|
|
365
|
-
toolInput: string;
|
|
366
|
-
permissionRequest: string;
|
|
367
|
-
timestamp: number;
|
|
368
|
-
}>>();
|
|
369
|
-
// Pending permission responses: requestId → HTTP response callback
|
|
370
|
-
private pendingPermissions = new Map<string, PendingPermission>();
|
|
371
|
-
private hookMarker = `lsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
372
220
|
private screenCapture: ScreenFallback | undefined;
|
|
373
221
|
private screenShare: ScreenShare | undefined;
|
|
374
222
|
private tunnelSockets = new Map<string, WebSocket>();
|
|
@@ -433,19 +281,14 @@ export class BridgeSession {
|
|
|
433
281
|
}
|
|
434
282
|
}
|
|
435
283
|
|
|
436
|
-
private terminalHookMarker(terminalId: string): string {
|
|
437
|
-
const safeTerminalId = terminalId.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
|
438
|
-
return `${this.hookMarker}-${safeTerminalId}`;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
284
|
async start(): Promise<void> {
|
|
442
285
|
this.log(
|
|
443
286
|
`starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`,
|
|
444
287
|
);
|
|
445
288
|
this.machineIdentity = loadOrCreateMachineIdentity();
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
this.
|
|
289
|
+
// One-shot leftover sweep. Structured agent control is ACP/SDK subprocesses;
|
|
290
|
+
// we never rewrite hook configs.
|
|
291
|
+
sweepLinkShellHookConfigs(homedir(), [join(this.defaultCwd, "hooks.json")]);
|
|
449
292
|
if (!this.sessionId) {
|
|
450
293
|
await this.createPairing();
|
|
451
294
|
}
|
|
@@ -455,7 +298,6 @@ export class BridgeSession {
|
|
|
455
298
|
process.stderr.write("[bridge] keep-awake disabled\n");
|
|
456
299
|
}
|
|
457
300
|
if (this.options.agentUi) {
|
|
458
|
-
process.env.LINKSHELL_ID = this.terminalHookMarker(DEFAULT_TERMINAL_ID);
|
|
459
301
|
const availableProviders = resolveAgentWorkspaceProviders(this.options);
|
|
460
302
|
const agentOptions = {
|
|
461
303
|
sessionId: this.sessionId,
|
|
@@ -728,24 +570,6 @@ export class BridgeSession {
|
|
|
728
570
|
);
|
|
729
571
|
return;
|
|
730
572
|
}
|
|
731
|
-
if (envelope.type === "agent.v2.prompt" || envelope.type === "agent.v2.command.execute") {
|
|
732
|
-
this.refreshAgentPermissionHooks();
|
|
733
|
-
}
|
|
734
|
-
// A v2 permission response may be answering an EXTERNAL session's request
|
|
735
|
-
// that came in through the hook HTTP server (held on a pending connection),
|
|
736
|
-
// not a workspace-driven one. Resolve that pending hook connection first;
|
|
737
|
-
// only fall through to the workspace if it wasn't a hook request. Mirrors
|
|
738
|
-
// the v1 agent.permission.response path.
|
|
739
|
-
if (envelope.type === "agent.v2.permission.respond") {
|
|
740
|
-
const p = parseTypedPayload("agent.v2.permission.respond", envelope.payload);
|
|
741
|
-
if (this.resolvePendingPermission(
|
|
742
|
-
p.requestId,
|
|
743
|
-
{ outcome: p.outcome === "cancelled" ? "deny" : p.outcome, optionId: p.optionId },
|
|
744
|
-
"agent.v2.permission.respond",
|
|
745
|
-
).resolved) {
|
|
746
|
-
return;
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
573
|
try {
|
|
750
574
|
await this.agentWorkspace.handleEnvelope(envelope);
|
|
751
575
|
} catch (error) {
|
|
@@ -1090,18 +914,11 @@ export class BridgeSession {
|
|
|
1090
914
|
);
|
|
1091
915
|
break;
|
|
1092
916
|
}
|
|
1093
|
-
if (envelope.type === "agent.prompt") this.refreshAgentPermissionHooks();
|
|
1094
917
|
await this.agentSession.handleEnvelope(envelope);
|
|
1095
918
|
break;
|
|
1096
919
|
}
|
|
1097
920
|
case "agent.permission.response": {
|
|
1098
921
|
const p = parseTypedPayload("agent.permission.response", envelope.payload);
|
|
1099
|
-
if (this.resolvePendingPermission(p.requestId, {
|
|
1100
|
-
outcome: p.outcome,
|
|
1101
|
-
optionId: p.optionId,
|
|
1102
|
-
}, "agent.permission.response").resolved) {
|
|
1103
|
-
break;
|
|
1104
|
-
}
|
|
1105
922
|
if (!this.agentSession) {
|
|
1106
923
|
this.send(
|
|
1107
924
|
createEnvelope({
|
|
@@ -1143,22 +960,8 @@ export class BridgeSession {
|
|
|
1143
960
|
}
|
|
1144
961
|
case "permission.decision": {
|
|
1145
962
|
const p = envelope.payload as { requestId: string; decision: "allow" | "deny" };
|
|
1146
|
-
const result = this.resolvePendingPermission(p.requestId, p.decision, "permission.decision");
|
|
1147
|
-
if (!result.resolved) {
|
|
1148
|
-
this.sendPermissionSnapshot(
|
|
1149
|
-
tid,
|
|
1150
|
-
"thinking",
|
|
1151
|
-
"permission not pending",
|
|
1152
|
-
{
|
|
1153
|
-
requestId: p.requestId,
|
|
1154
|
-
outcome: p.decision,
|
|
1155
|
-
source: "permission.decision",
|
|
1156
|
-
delivered: false,
|
|
1157
|
-
},
|
|
1158
|
-
);
|
|
1159
|
-
}
|
|
1160
963
|
process.stderr.write(
|
|
1161
|
-
`[bridge] permission
|
|
964
|
+
`[bridge] permission.decision ignored (ACP/SDK owns authorization) request=${p.requestId} decision=${p.decision}\n`,
|
|
1162
965
|
);
|
|
1163
966
|
this.send(createEnvelope({
|
|
1164
967
|
type: "permission.decision.result",
|
|
@@ -1167,14 +970,10 @@ export class BridgeSession {
|
|
|
1167
970
|
payload: {
|
|
1168
971
|
requestId: p.requestId,
|
|
1169
972
|
decision: p.decision,
|
|
1170
|
-
resolved:
|
|
1171
|
-
delivered:
|
|
973
|
+
resolved: false,
|
|
974
|
+
delivered: false,
|
|
1172
975
|
source: "permission.decision",
|
|
1173
|
-
message:
|
|
1174
|
-
? undefined
|
|
1175
|
-
: result.resolved
|
|
1176
|
-
? "Permission resolved but response was not delivered"
|
|
1177
|
-
: "Permission request is no longer pending",
|
|
976
|
+
message: "Authorization is handled by the ACP/SDK agent subprocess",
|
|
1178
977
|
},
|
|
1179
978
|
}));
|
|
1180
979
|
break;
|
|
@@ -1441,36 +1240,10 @@ export class BridgeSession {
|
|
|
1441
1240
|
for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
|
|
1442
1241
|
if (v !== undefined) cleanEnv[k] = v;
|
|
1443
1242
|
}
|
|
1444
|
-
const hookMarker = this.terminalHookMarker(terminalId);
|
|
1445
|
-
// Inject marker so child CLIs' hook commands carry our identity
|
|
1446
|
-
cleanEnv["LINKSHELL_ID"] = hookMarker;
|
|
1447
1243
|
|
|
1448
1244
|
const provider = providerOverride ?? this.options.providerConfig.provider;
|
|
1449
1245
|
const args = [...this.options.providerConfig.args];
|
|
1450
1246
|
|
|
1451
|
-
// Set up hook server for structured status (all supported providers)
|
|
1452
|
-
// For "custom" shell, set up hooks for all providers since user may launch any of them
|
|
1453
|
-
let hookServer: http.Server | undefined;
|
|
1454
|
-
let hookPort: number | undefined;
|
|
1455
|
-
const hookConfigPaths: string[] = [];
|
|
1456
|
-
|
|
1457
|
-
if (provider === "custom") {
|
|
1458
|
-
const result = await this.setupHookServer(terminalId, args, "claude", hookMarker);
|
|
1459
|
-
hookServer = result.server;
|
|
1460
|
-
hookPort = result.port;
|
|
1461
|
-
hookConfigPaths.push(result.configPath);
|
|
1462
|
-
// Also set up hooks for other providers (curlCmd already has marker from setupHookServer)
|
|
1463
|
-
const curlCmd = buildLinkShellHookCommand(result.port, hookMarker, HOOK_COMMAND_TIMEOUT_SEC);
|
|
1464
|
-
hookConfigPaths.push(this.setupCodexHooks(terminalId, curlCmd, hookMarker));
|
|
1465
|
-
hookConfigPaths.push(this.setupGeminiHooks(terminalId, curlCmd, hookMarker));
|
|
1466
|
-
hookConfigPaths.push(this.setupCopilotHooks(terminalId, curlCmd, hookMarker));
|
|
1467
|
-
} else if (provider === "claude" || provider === "codex" || provider === "gemini" || provider === "copilot") {
|
|
1468
|
-
const result = await this.setupHookServer(terminalId, args, provider, hookMarker);
|
|
1469
|
-
hookServer = result.server;
|
|
1470
|
-
hookPort = result.port;
|
|
1471
|
-
hookConfigPaths.push(result.configPath);
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
1247
|
const term: TerminalInstance = {
|
|
1475
1248
|
id: terminalId,
|
|
1476
1249
|
pty: pty.spawn(
|
|
@@ -1491,10 +1264,6 @@ export class BridgeSession {
|
|
|
1491
1264
|
outputSeq: 0,
|
|
1492
1265
|
statusSeq: 0,
|
|
1493
1266
|
status: "running",
|
|
1494
|
-
hookServer,
|
|
1495
|
-
hookPort,
|
|
1496
|
-
hookMarker,
|
|
1497
|
-
hookConfigPaths,
|
|
1498
1267
|
};
|
|
1499
1268
|
|
|
1500
1269
|
term.pty.onData((data) => {
|
|
@@ -1518,7 +1287,6 @@ export class BridgeSession {
|
|
|
1518
1287
|
|
|
1519
1288
|
term.pty.onExit(({ exitCode, signal }) => {
|
|
1520
1289
|
term.status = "exited";
|
|
1521
|
-
this.cleanupHookServer(term);
|
|
1522
1290
|
this.send(createEnvelope({
|
|
1523
1291
|
type: "terminal.exit",
|
|
1524
1292
|
sessionId: this.sessionId,
|
|
@@ -1560,881 +1328,6 @@ export class BridgeSession {
|
|
|
1560
1328
|
this.log(`spawned terminal ${terminalId} in ${cwd}`);
|
|
1561
1329
|
}
|
|
1562
1330
|
|
|
1563
|
-
private async setupHookServer(terminalId: string, args: string[], provider: string, marker: string): Promise<{
|
|
1564
|
-
server: http.Server;
|
|
1565
|
-
port: number;
|
|
1566
|
-
configPath: string;
|
|
1567
|
-
}> {
|
|
1568
|
-
const server = http.createServer((req, res) => {
|
|
1569
|
-
this.log(`hook server received: ${req.method} ${req.url}`);
|
|
1570
|
-
const reqUrl = new URL(req.url ?? "/", "http://localhost");
|
|
1571
|
-
if (req.method !== "POST" || reqUrl.pathname !== "/hook") {
|
|
1572
|
-
res.writeHead(404);
|
|
1573
|
-
res.end();
|
|
1574
|
-
return;
|
|
1575
|
-
}
|
|
1576
|
-
// Check marker — reject events not from our PTY
|
|
1577
|
-
// m must match; lid must match OR be empty (some CLIs don't inherit env vars)
|
|
1578
|
-
const reqMarker = reqUrl.searchParams.get("m");
|
|
1579
|
-
const reqLid = reqUrl.searchParams.get("lid") ?? "";
|
|
1580
|
-
if (reqMarker !== marker || (reqLid !== "" && reqLid !== marker)) {
|
|
1581
|
-
this.log(`ignoring hook event: m=${reqMarker} lid=${reqLid} (expected ${marker})`);
|
|
1582
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1583
|
-
res.end("{}");
|
|
1584
|
-
return;
|
|
1585
|
-
}
|
|
1586
|
-
let body = "";
|
|
1587
|
-
let bodyTooLarge = false;
|
|
1588
|
-
req.on("data", (chunk: Buffer) => {
|
|
1589
|
-
if (bodyTooLarge) return;
|
|
1590
|
-
body += chunk.toString();
|
|
1591
|
-
if (Buffer.byteLength(body, "utf8") > HOOK_BODY_LIMIT) {
|
|
1592
|
-
bodyTooLarge = true;
|
|
1593
|
-
res.writeHead(413, { "Content-Type": "application/json" });
|
|
1594
|
-
res.end("{}");
|
|
1595
|
-
req.destroy();
|
|
1596
|
-
}
|
|
1597
|
-
});
|
|
1598
|
-
req.on("end", () => {
|
|
1599
|
-
if (bodyTooLarge || res.writableEnded) return;
|
|
1600
|
-
this.log(`hook body (${body.length} bytes): ${body.slice(0, 200)}`);
|
|
1601
|
-
try {
|
|
1602
|
-
const event = JSON.parse(body);
|
|
1603
|
-
const hookName = (event.hook_event_name ?? event.event_name) as string | undefined;
|
|
1604
|
-
|
|
1605
|
-
// PermissionRequest: hold connection, wait for user decision from mobile app
|
|
1606
|
-
if (hookName === "PermissionRequest") {
|
|
1607
|
-
const requestId = `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1608
|
-
const permissionSuggestions = hookPermissionSuggestions(event);
|
|
1609
|
-
const timeout = setTimeout(() => {
|
|
1610
|
-
if (this.resolvePendingPermission(requestId, "deny", "permission.timeout").resolved) {
|
|
1611
|
-
this.log(`permission request ${requestId} timed out`);
|
|
1612
|
-
this.sendPermissionSnapshot(terminalId, "thinking", "permission timed out");
|
|
1613
|
-
}
|
|
1614
|
-
}, PERMISSION_REQUEST_TIMEOUT_MS);
|
|
1615
|
-
this.pendingPermissions.set(requestId, {
|
|
1616
|
-
terminalId,
|
|
1617
|
-
timeout,
|
|
1618
|
-
permissionSuggestions,
|
|
1619
|
-
resolve: (decision) => {
|
|
1620
|
-
if (res.writableEnded) return false;
|
|
1621
|
-
const responseJson = JSON.stringify({
|
|
1622
|
-
hookSpecificOutput: {
|
|
1623
|
-
hookEventName: "PermissionRequest",
|
|
1624
|
-
decision,
|
|
1625
|
-
},
|
|
1626
|
-
});
|
|
1627
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1628
|
-
res.end(responseJson);
|
|
1629
|
-
return true;
|
|
1630
|
-
},
|
|
1631
|
-
});
|
|
1632
|
-
// Send status with requestId so app can route decision back
|
|
1633
|
-
this.handleHookEvent(terminalId, event, provider, requestId);
|
|
1634
|
-
this.sendHookPermissionRequest(terminalId, event, requestId, provider);
|
|
1635
|
-
} else {
|
|
1636
|
-
// All other hooks: respond immediately with valid JSON so clients
|
|
1637
|
-
// that parse stdout (Cursor) fail-open instead of blocking the tool.
|
|
1638
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1639
|
-
res.end("{}");
|
|
1640
|
-
this.handleHookEvent(terminalId, event, provider);
|
|
1641
|
-
}
|
|
1642
|
-
} catch (e) {
|
|
1643
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1644
|
-
res.end("{}");
|
|
1645
|
-
this.log(`hook parse error: ${e}`);
|
|
1646
|
-
}
|
|
1647
|
-
});
|
|
1648
|
-
});
|
|
1649
|
-
|
|
1650
|
-
// Listen on random port — await binding before reading address
|
|
1651
|
-
const port = await new Promise<number>((resolve, reject) => {
|
|
1652
|
-
server.listen(0, "127.0.0.1", () => {
|
|
1653
|
-
const addr = server.address() as { port: number };
|
|
1654
|
-
resolve(addr.port);
|
|
1655
|
-
});
|
|
1656
|
-
server.on("error", reject);
|
|
1657
|
-
});
|
|
1658
|
-
this.log(`hook server for ${terminalId} (${provider}) listening on port ${port}, marker=${marker}`);
|
|
1659
|
-
|
|
1660
|
-
const curlCmd = buildLinkShellHookCommand(port, marker, HOOK_COMMAND_TIMEOUT_SEC);
|
|
1661
|
-
let configPath: string;
|
|
1662
|
-
|
|
1663
|
-
if (provider === "codex") {
|
|
1664
|
-
configPath = this.setupCodexHooks(terminalId, curlCmd, marker);
|
|
1665
|
-
} else if (provider === "gemini") {
|
|
1666
|
-
configPath = this.setupGeminiHooks(terminalId, curlCmd, marker);
|
|
1667
|
-
} else if (provider === "copilot") {
|
|
1668
|
-
configPath = this.setupCopilotHooks(terminalId, curlCmd, marker);
|
|
1669
|
-
} else {
|
|
1670
|
-
// Claude (default)
|
|
1671
|
-
configPath = this.setupClaudeHooks(terminalId, curlCmd, args, marker);
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
return { server, port, configPath };
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
private refreshAgentPermissionHooks(): void {
|
|
1678
|
-
const term = this.terminals.get(DEFAULT_TERMINAL_ID);
|
|
1679
|
-
if (!term?.hookPort) return;
|
|
1680
|
-
const marker = term.hookMarker;
|
|
1681
|
-
const curlCmd = buildLinkShellHookCommand(term.hookPort, marker, HOOK_COMMAND_TIMEOUT_SEC);
|
|
1682
|
-
const providers = resolveAgentWorkspaceProviders(this.options);
|
|
1683
|
-
try {
|
|
1684
|
-
for (const provider of providers) {
|
|
1685
|
-
if (provider === "codex") {
|
|
1686
|
-
this.setupCodexHooks(DEFAULT_TERMINAL_ID, curlCmd, marker);
|
|
1687
|
-
} else {
|
|
1688
|
-
// claude, custom
|
|
1689
|
-
this.setupClaudeHooks(DEFAULT_TERMINAL_ID, curlCmd, [], marker);
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1692
|
-
} catch (error) {
|
|
1693
|
-
this.log(`failed to refresh agent permission hooks: ${error instanceof Error ? error.message : String(error)}`);
|
|
1694
|
-
}
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
private setupClaudeHooks(terminalId: string, curlCmd: string, args: string[], marker: string): string {
|
|
1698
|
-
// Write hooks to ~/.claude/settings.json — Claude Code reads hooks from here
|
|
1699
|
-
const claudeDir = join(homedir(), ".claude");
|
|
1700
|
-
if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
|
|
1701
|
-
const settingsPath = join(claudeDir, "settings.json");
|
|
1702
|
-
|
|
1703
|
-
let existing: Record<string, unknown> = {};
|
|
1704
|
-
try {
|
|
1705
|
-
existing = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
1706
|
-
} catch { /* doesn't exist yet */ }
|
|
1707
|
-
|
|
1708
|
-
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
|
|
1709
|
-
const permissionEntry = {
|
|
1710
|
-
matcher: "",
|
|
1711
|
-
hooks: [{
|
|
1712
|
-
type: "command",
|
|
1713
|
-
command: curlCmd,
|
|
1714
|
-
timeout: HOOK_COMMAND_TIMEOUT_SEC,
|
|
1715
|
-
}],
|
|
1716
|
-
};
|
|
1717
|
-
|
|
1718
|
-
const hookEvents: Record<string, typeof hookEntry | typeof permissionEntry> = {
|
|
1719
|
-
PreToolUse: hookEntry,
|
|
1720
|
-
PostToolUse: hookEntry,
|
|
1721
|
-
PostToolUseFailure: hookEntry,
|
|
1722
|
-
Stop: hookEntry,
|
|
1723
|
-
PermissionRequest: permissionEntry,
|
|
1724
|
-
UserPromptSubmit: hookEntry,
|
|
1725
|
-
SessionStart: hookEntry,
|
|
1726
|
-
};
|
|
1727
|
-
|
|
1728
|
-
// Append our entries to existing hooks (first remove stale linkshell entries)
|
|
1729
|
-
const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
|
|
1730
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1731
|
-
existingHooks[eventName] = eventName === "PermissionRequest"
|
|
1732
|
-
? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
|
|
1733
|
-
: withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
const merged = { ...existing, hooks: existingHooks };
|
|
1737
|
-
writeFileSync(settingsPath, JSON.stringify(merged, null, 2));
|
|
1738
|
-
this.log(`claude hooks appended to ${settingsPath}`);
|
|
1739
|
-
|
|
1740
|
-
return settingsPath;
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
private setupCodexHooks(terminalId: string, curlCmd: string, marker: string): string {
|
|
1744
|
-
// Codex uses ~/.codex/hooks.json — same format as Claude (with matcher)
|
|
1745
|
-
const codexDir = join(homedir(), ".codex");
|
|
1746
|
-
if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
|
|
1747
|
-
|
|
1748
|
-
// Ensure [features] codex_hooks = true in config.toml
|
|
1749
|
-
const tomlPath = join(codexDir, "config.toml");
|
|
1750
|
-
let tomlContent = "";
|
|
1751
|
-
try { tomlContent = readFileSync(tomlPath, "utf8"); } catch { /* doesn't exist yet */ }
|
|
1752
|
-
|
|
1753
|
-
// Remove top-level codex_hooks (wrong location) and ensure it's under [features]
|
|
1754
|
-
const hasFeatureSection = tomlContent.includes("[features]");
|
|
1755
|
-
const hasCodexHooksUnderFeatures = hasFeatureSection &&
|
|
1756
|
-
/\[features\][^\[]*codex_hooks\s*=\s*true/s.test(tomlContent);
|
|
1757
|
-
|
|
1758
|
-
if (!hasCodexHooksUnderFeatures) {
|
|
1759
|
-
// Remove any top-level codex_hooks line
|
|
1760
|
-
tomlContent = tomlContent.replace(/^codex_hooks\s*=.*\n?/m, "");
|
|
1761
|
-
if (!tomlContent.includes("[features]")) {
|
|
1762
|
-
tomlContent += `\n[features]\ncodex_hooks = true\n`;
|
|
1763
|
-
} else {
|
|
1764
|
-
tomlContent = tomlContent.replace("[features]", "[features]\ncodex_hooks = true");
|
|
1765
|
-
}
|
|
1766
|
-
writeFileSync(tomlPath, tomlContent);
|
|
1767
|
-
this.log(`enabled codex_hooks under [features] in ${tomlPath}`);
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
const hooksPath = join(codexDir, "hooks.json");
|
|
1771
|
-
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
|
|
1772
|
-
const permissionEntry = {
|
|
1773
|
-
matcher: "",
|
|
1774
|
-
hooks: [{
|
|
1775
|
-
type: "command",
|
|
1776
|
-
command: curlCmd,
|
|
1777
|
-
timeout: HOOK_COMMAND_TIMEOUT_SEC,
|
|
1778
|
-
}],
|
|
1779
|
-
};
|
|
1780
|
-
const hookEvents: Record<string, typeof hookEntry | typeof permissionEntry> = {
|
|
1781
|
-
SessionStart: hookEntry,
|
|
1782
|
-
PreToolUse: hookEntry,
|
|
1783
|
-
PostToolUse: hookEntry,
|
|
1784
|
-
UserPromptSubmit: hookEntry,
|
|
1785
|
-
Stop: hookEntry,
|
|
1786
|
-
PermissionRequest: permissionEntry,
|
|
1787
|
-
};
|
|
1788
|
-
|
|
1789
|
-
// Read existing and append
|
|
1790
|
-
let existing: { hooks?: Record<string, unknown[]> } = {};
|
|
1791
|
-
try { existing = JSON.parse(readFileSync(hooksPath, "utf8")); } catch { /* doesn't exist yet */ }
|
|
1792
|
-
const existingHooks = existing.hooks ?? {};
|
|
1793
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1794
|
-
existingHooks[eventName] = eventName === "PermissionRequest"
|
|
1795
|
-
? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
|
|
1796
|
-
: withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
writeFileSync(hooksPath, JSON.stringify({ ...existing, hooks: existingHooks }, null, 2));
|
|
1800
|
-
this.log(`codex hooks appended to ${hooksPath}`);
|
|
1801
|
-
return hooksPath;
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
private setupGeminiHooks(terminalId: string, curlCmd: string, marker: string): string {
|
|
1805
|
-
// Gemini uses ~/.gemini/settings.json — same format as Claude (with matcher)
|
|
1806
|
-
const geminiDir = join(homedir(), ".gemini");
|
|
1807
|
-
if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
|
|
1808
|
-
|
|
1809
|
-
const settingsPath = join(geminiDir, "settings.json");
|
|
1810
|
-
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5000 }] };
|
|
1811
|
-
const hookEvents: Record<string, typeof hookEntry> = {
|
|
1812
|
-
SessionStart: hookEntry,
|
|
1813
|
-
SessionEnd: hookEntry,
|
|
1814
|
-
BeforeTool: hookEntry,
|
|
1815
|
-
AfterTool: hookEntry,
|
|
1816
|
-
};
|
|
1817
|
-
|
|
1818
|
-
// Merge with existing settings if present
|
|
1819
|
-
let existing: Record<string, unknown> = {};
|
|
1820
|
-
try {
|
|
1821
|
-
existing = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
1822
|
-
} catch { /* doesn't exist yet */ }
|
|
1823
|
-
|
|
1824
|
-
const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
|
|
1825
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1826
|
-
existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
existing.hooks = existingHooks;
|
|
1830
|
-
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
|
1831
|
-
this.log(`gemini hooks appended to ${settingsPath}`);
|
|
1832
|
-
return settingsPath;
|
|
1833
|
-
}
|
|
1834
|
-
|
|
1835
|
-
private setupCopilotHooks(terminalId: string, curlCmd: string, marker: string): string {
|
|
1836
|
-
// Copilot loads hooks from CWD as hooks.json. Cursor's coding agent also
|
|
1837
|
-
// reads that file when the folder is a Cursor project — writing it here
|
|
1838
|
-
// freezes every IDE tool call the moment the hook server is down or
|
|
1839
|
-
// returns non-JSON.
|
|
1840
|
-
const cwd = this.terminals.get(terminalId)?.cwd ?? this.defaultCwd;
|
|
1841
|
-
const hooksPath = join(cwd, "hooks.json");
|
|
1842
|
-
if (!shouldWriteProjectHooksJson(cwd)) {
|
|
1843
|
-
this.sweepLinkShellHookEntries(hooksPath);
|
|
1844
|
-
try {
|
|
1845
|
-
if (existsSync(hooksPath)) {
|
|
1846
|
-
const raw = JSON.parse(readFileSync(hooksPath, "utf8")) as { hooks?: unknown };
|
|
1847
|
-
if (!raw.hooks || (typeof raw.hooks === "object" && Object.keys(raw.hooks as object).length === 0)) {
|
|
1848
|
-
unlinkSync(hooksPath);
|
|
1849
|
-
}
|
|
1850
|
-
}
|
|
1851
|
-
} catch { /* leave whatever is on disk */ }
|
|
1852
|
-
this.log(`skip writing ${hooksPath}: Cursor project would inherit it and freeze the IDE agent`);
|
|
1853
|
-
return hooksPath;
|
|
1854
|
-
}
|
|
1855
|
-
const mkHook = () => ({
|
|
1856
|
-
type: "command",
|
|
1857
|
-
bash: curlCmd,
|
|
1858
|
-
timeoutSec: 30,
|
|
1859
|
-
});
|
|
1860
|
-
const hookEvents: Record<string, ReturnType<typeof mkHook>> = {
|
|
1861
|
-
sessionStart: mkHook(),
|
|
1862
|
-
sessionEnd: mkHook(),
|
|
1863
|
-
userPromptSubmitted: mkHook(),
|
|
1864
|
-
preToolUse: mkHook(),
|
|
1865
|
-
postToolUse: mkHook(),
|
|
1866
|
-
errorOccurred: mkHook(),
|
|
1867
|
-
};
|
|
1868
|
-
|
|
1869
|
-
// Read existing and append
|
|
1870
|
-
let existing: { version?: number; hooks?: Record<string, unknown[]> } = {};
|
|
1871
|
-
try { existing = JSON.parse(readFileSync(hooksPath, "utf8")); } catch { /* doesn't exist yet */ }
|
|
1872
|
-
const existingHooks = existing.hooks ?? {};
|
|
1873
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1874
|
-
existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
writeFileSync(hooksPath, JSON.stringify({ version: 1, hooks: existingHooks }, null, 2));
|
|
1878
|
-
this.log(`copilot hooks appended to ${hooksPath}`);
|
|
1879
|
-
return hooksPath;
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
private handleHookEvent(terminalId: string, event: Record<string, unknown>, provider: string, permissionRequestId?: string): void {
|
|
1883
|
-
const rawHookName = (event.hook_event_name ?? event.event_name) as string | undefined;
|
|
1884
|
-
if (!rawHookName) return;
|
|
1885
|
-
|
|
1886
|
-
// Auto-detect provider from hook event fields
|
|
1887
|
-
const hookTerm = this.terminals.get(terminalId);
|
|
1888
|
-
let detectedProvider = provider;
|
|
1889
|
-
|
|
1890
|
-
// Always detect from transcript_path (most reliable), regardless of current provider
|
|
1891
|
-
const transcriptPath = typeof event.transcript_path === "string" ? event.transcript_path as string : "";
|
|
1892
|
-
if (transcriptPath.includes(".claude/")) {
|
|
1893
|
-
detectedProvider = "claude";
|
|
1894
|
-
} else if (transcriptPath.includes(".gemini/")) {
|
|
1895
|
-
detectedProvider = "gemini";
|
|
1896
|
-
} else if (transcriptPath.includes(".codex/")) {
|
|
1897
|
-
detectedProvider = "codex";
|
|
1898
|
-
} else if (hookTerm?.provider === "custom") {
|
|
1899
|
-
// Fallback heuristics only when provider is still unknown
|
|
1900
|
-
if (event.model && typeof event.model === "string" && /^(gpt|o[0-9]|codex)/i.test(event.model as string)) {
|
|
1901
|
-
detectedProvider = "codex";
|
|
1902
|
-
} else if (event.session_id && !transcriptPath) {
|
|
1903
|
-
detectedProvider = "codex";
|
|
1904
|
-
} else if (/^(Before|After)(Tool)$|^Session(Start|End)$/.test(rawHookName)) {
|
|
1905
|
-
detectedProvider = "gemini";
|
|
1906
|
-
} else if (/^(pre|post)ToolUse$|^session(Start|End)$|^userPromptSubmitted$|^errorOccurred$/.test(rawHookName)) {
|
|
1907
|
-
detectedProvider = "copilot";
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
if (hookTerm && detectedProvider !== hookTerm.provider) {
|
|
1912
|
-
const wasCustom = hookTerm.provider === "custom";
|
|
1913
|
-
hookTerm.provider = detectedProvider;
|
|
1914
|
-
this.log(`${wasCustom ? "detected" : "provider switched"} provider for ${terminalId}: ${detectedProvider}`);
|
|
1915
|
-
this.permissionStacks.delete(terminalId);
|
|
1916
|
-
this.sendTerminalList();
|
|
1917
|
-
}
|
|
1918
|
-
|
|
1919
|
-
// Normalize hook event names from different providers to unified names
|
|
1920
|
-
const hookName = this.normalizeHookName(rawHookName, detectedProvider);
|
|
1921
|
-
if (!hookName) return;
|
|
1922
|
-
|
|
1923
|
-
let phase: string;
|
|
1924
|
-
let toolName: string | undefined;
|
|
1925
|
-
let toolInput: string | undefined;
|
|
1926
|
-
let permissionRequest: string | undefined;
|
|
1927
|
-
let summary: string | undefined;
|
|
1928
|
-
|
|
1929
|
-
switch (hookName) {
|
|
1930
|
-
case "PreToolUse":
|
|
1931
|
-
phase = "tool_use";
|
|
1932
|
-
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
1933
|
-
if (event.tool_input && typeof event.tool_input === "object") {
|
|
1934
|
-
const input = event.tool_input as Record<string, unknown>;
|
|
1935
|
-
toolInput = JSON.stringify(input).slice(0, 200);
|
|
1936
|
-
} else if (event.toolInput && typeof event.toolInput === "object") {
|
|
1937
|
-
toolInput = JSON.stringify(event.toolInput).slice(0, 200);
|
|
1938
|
-
}
|
|
1939
|
-
break;
|
|
1940
|
-
case "PostToolUse":
|
|
1941
|
-
phase = "thinking";
|
|
1942
|
-
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
1943
|
-
// Pop permission stack + auto-resolve pending HTTP connection
|
|
1944
|
-
{
|
|
1945
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1946
|
-
if (stack && stack.length > 0) {
|
|
1947
|
-
const popped = stack.pop();
|
|
1948
|
-
if (popped) this.autoResolvePending(popped.requestId);
|
|
1949
|
-
if (stack.length === 0) this.permissionStacks.delete(terminalId);
|
|
1950
|
-
}
|
|
1951
|
-
}
|
|
1952
|
-
break;
|
|
1953
|
-
case "PostToolUseFailure":
|
|
1954
|
-
phase = "error";
|
|
1955
|
-
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
1956
|
-
{
|
|
1957
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1958
|
-
if (stack && stack.length > 0) {
|
|
1959
|
-
const popped = stack.pop();
|
|
1960
|
-
if (popped) this.autoResolvePending(popped.requestId);
|
|
1961
|
-
if (stack.length === 0) this.permissionStacks.delete(terminalId);
|
|
1962
|
-
}
|
|
1963
|
-
}
|
|
1964
|
-
break;
|
|
1965
|
-
case "Stop":
|
|
1966
|
-
phase = "idle";
|
|
1967
|
-
if (event.stop_reason) summary = String(event.stop_reason);
|
|
1968
|
-
this.drainPendingPermissions(terminalId);
|
|
1969
|
-
this.permissionStacks.delete(terminalId);
|
|
1970
|
-
// Reset provider to "custom" when a CLI session ends inside a custom shell
|
|
1971
|
-
if (hookTerm && this.options.providerConfig.provider === "custom") {
|
|
1972
|
-
hookTerm.provider = "custom";
|
|
1973
|
-
this.log(`provider reset to custom for ${terminalId} (CLI session ended)`);
|
|
1974
|
-
this.sendTerminalList();
|
|
1975
|
-
}
|
|
1976
|
-
break;
|
|
1977
|
-
case "PermissionRequest":
|
|
1978
|
-
phase = "waiting";
|
|
1979
|
-
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
1980
|
-
if (event.tool_input && typeof event.tool_input === "object") {
|
|
1981
|
-
const input = event.tool_input as Record<string, unknown>;
|
|
1982
|
-
permissionRequest = JSON.stringify(input).slice(0, 300);
|
|
1983
|
-
} else if (event.toolInput && typeof event.toolInput === "object") {
|
|
1984
|
-
permissionRequest = JSON.stringify(event.toolInput).slice(0, 300);
|
|
1985
|
-
}
|
|
1986
|
-
// Push to permission stack (use requestId from hook server if available)
|
|
1987
|
-
{
|
|
1988
|
-
const reqId = permissionRequestId ?? `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1989
|
-
if (!this.permissionStacks.has(terminalId)) {
|
|
1990
|
-
this.permissionStacks.set(terminalId, []);
|
|
1991
|
-
}
|
|
1992
|
-
this.permissionStacks.get(terminalId)!.push({
|
|
1993
|
-
requestId: reqId,
|
|
1994
|
-
toolName: toolName ?? "unknown",
|
|
1995
|
-
toolInput: toolInput ?? (permissionRequest ?? ""),
|
|
1996
|
-
permissionRequest: permissionRequest ?? "",
|
|
1997
|
-
timestamp: Date.now(),
|
|
1998
|
-
});
|
|
1999
|
-
}
|
|
2000
|
-
break;
|
|
2001
|
-
case "SessionStart":
|
|
2002
|
-
phase = "idle";
|
|
2003
|
-
summary = "session started";
|
|
2004
|
-
break;
|
|
2005
|
-
case "UserPromptSubmit":
|
|
2006
|
-
phase = "thinking";
|
|
2007
|
-
this.drainPendingPermissions(terminalId);
|
|
2008
|
-
this.permissionStacks.delete(terminalId);
|
|
2009
|
-
break;
|
|
2010
|
-
default:
|
|
2011
|
-
return;
|
|
2012
|
-
}
|
|
2013
|
-
|
|
2014
|
-
this.log(`hook event [${provider}]: ${rawHookName} → ${hookName} → phase=${phase} tool=${toolName ?? "none"}`);
|
|
2015
|
-
|
|
2016
|
-
// Build topPermission from stack
|
|
2017
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
2018
|
-
const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
|
|
2019
|
-
const pendingPermissionCount = stack?.length ?? 0;
|
|
2020
|
-
|
|
2021
|
-
// Increment statusSeq for ordering
|
|
2022
|
-
const term = this.terminals.get(terminalId);
|
|
2023
|
-
const seq = term ? term.statusSeq++ : 0;
|
|
2024
|
-
|
|
2025
|
-
this.send(createEnvelope({
|
|
2026
|
-
type: "terminal.status",
|
|
2027
|
-
sessionId: this.sessionId,
|
|
2028
|
-
terminalId,
|
|
2029
|
-
payload: {
|
|
2030
|
-
phase,
|
|
2031
|
-
provider,
|
|
2032
|
-
seq,
|
|
2033
|
-
...(toolName && { toolName }),
|
|
2034
|
-
...(toolInput && { toolInput }),
|
|
2035
|
-
...(permissionRequest && { permissionRequest }),
|
|
2036
|
-
...(summary && { summary }),
|
|
2037
|
-
...(topPermission && { topPermission }),
|
|
2038
|
-
...(pendingPermissionCount > 0 && { pendingPermissionCount }),
|
|
2039
|
-
},
|
|
2040
|
-
}));
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
/** Determine which provider a session id belongs to by which on-disk store
|
|
2044
|
-
* holds its transcript — Claude writes ~/.claude/projects/<proj>/<id>.jsonl,
|
|
2045
|
-
* Codex writes ~/.codex/sessions/**/rollout-*-<id>.jsonl. Deterministic and
|
|
2046
|
-
* needed because an external session's provider is NOT the spawned PTY's
|
|
2047
|
-
* provider. best-effort; returns undefined if not found (caller falls back).
|
|
2048
|
-
* Only hit on a permission request (human-in-loop, rare), so the directory
|
|
2049
|
-
* scan cost is fine. */
|
|
2050
|
-
private providerForExternalSession(sessionId: string): "claude" | "codex" | undefined {
|
|
2051
|
-
try {
|
|
2052
|
-
const claudeRoot = join(homedir(), ".claude", "projects");
|
|
2053
|
-
if (existsSync(claudeRoot)) {
|
|
2054
|
-
for (const proj of readdirSync(claudeRoot)) {
|
|
2055
|
-
if (existsSync(join(claudeRoot, proj, `${sessionId}.jsonl`))) return "claude";
|
|
2056
|
-
}
|
|
2057
|
-
}
|
|
2058
|
-
} catch { /* ignore */ }
|
|
2059
|
-
try {
|
|
2060
|
-
const codexRoot = join(homedir(), ".codex", "sessions");
|
|
2061
|
-
if (existsSync(codexRoot)) {
|
|
2062
|
-
const suffix = `-${sessionId}.jsonl`;
|
|
2063
|
-
const walk = (dir: string): boolean => {
|
|
2064
|
-
for (const name of readdirSync(dir)) {
|
|
2065
|
-
const p = join(dir, name);
|
|
2066
|
-
let st;
|
|
2067
|
-
try { st = statSync(p); } catch { continue; }
|
|
2068
|
-
if (st.isDirectory()) { if (walk(p)) return true; }
|
|
2069
|
-
else if (name.startsWith("rollout-") && name.endsWith(suffix)) return true;
|
|
2070
|
-
}
|
|
2071
|
-
return false;
|
|
2072
|
-
};
|
|
2073
|
-
if (walk(codexRoot)) return "codex";
|
|
2074
|
-
}
|
|
2075
|
-
} catch { /* ignore */ }
|
|
2076
|
-
return undefined;
|
|
2077
|
-
}
|
|
2078
|
-
|
|
2079
|
-
private sendHookPermissionRequest(
|
|
2080
|
-
terminalId: string,
|
|
2081
|
-
event: Record<string, unknown>,
|
|
2082
|
-
requestId: string,
|
|
2083
|
-
provider?: string,
|
|
2084
|
-
): void {
|
|
2085
|
-
const toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
2086
|
-
const toolInput = stringifyHookInput(event.tool_input ?? event.toolInput);
|
|
2087
|
-
const suggestions = hookPermissionSuggestions(event);
|
|
2088
|
-
const context =
|
|
2089
|
-
typeof event.permission_prompt === "string"
|
|
2090
|
-
? event.permission_prompt
|
|
2091
|
-
: typeof event.message === "string"
|
|
2092
|
-
? event.message
|
|
2093
|
-
: undefined;
|
|
2094
|
-
const options = hookPermissionOptions(suggestions);
|
|
2095
|
-
this.send(createEnvelope({
|
|
2096
|
-
type: "agent.permission.request",
|
|
2097
|
-
sessionId: this.sessionId,
|
|
2098
|
-
terminalId,
|
|
2099
|
-
payload: {
|
|
2100
|
-
requestId,
|
|
2101
|
-
toolName,
|
|
2102
|
-
toolInput,
|
|
2103
|
-
context,
|
|
2104
|
-
options,
|
|
2105
|
-
},
|
|
2106
|
-
}));
|
|
2107
|
-
// Also surface the request on the v2 channel, mapped to the on-disk session
|
|
2108
|
-
// it came from, so the web agent console (which lives entirely on agent.v2)
|
|
2109
|
-
// shows a permission card on the right conversation — not just the v1
|
|
2110
|
-
// terminal view. The hook payload carries the provider's own session id;
|
|
2111
|
-
// map it to the conversation id the session tree already uses. The v2
|
|
2112
|
-
// respond is routed back to this same pending HTTP connection (see the
|
|
2113
|
-
// resolvePendingPermission check in the agent.v2.permission.respond path).
|
|
2114
|
-
const sessionId = (event.session_id ?? event.sessionId) as string | undefined;
|
|
2115
|
-
if (sessionId) {
|
|
2116
|
-
// Derive the provider from the SESSION, not the spawned PTY's provider.
|
|
2117
|
-
// The same curl hook is written to both ~/.claude and ~/.codex, and the
|
|
2118
|
-
// HTTP server is created with a single provider — so an external Codex
|
|
2119
|
-
// session's request could arrive on a "claude" server and get the wrong
|
|
2120
|
-
// conversationId prefix, landing the card on a phantom conversation.
|
|
2121
|
-
// Look up which store the id lives in (deterministic; verified).
|
|
2122
|
-
const agentProvider = this.providerForExternalSession(sessionId)
|
|
2123
|
-
?? (provider === "codex" ? "codex" : provider === "claude" ? "claude" : "custom");
|
|
2124
|
-
const conversationId = makeAgentV2RemoteConversationId(agentProvider, sessionId);
|
|
2125
|
-
const permission = { requestId, toolName, toolInput, context, options };
|
|
2126
|
-
// The web store only renders a clickable allow/deny card when the request
|
|
2127
|
-
// carries a timeline `item` (it just flips the status badge otherwise), so
|
|
2128
|
-
// include one shaped exactly like the workspace's own permission flow.
|
|
2129
|
-
const now = Date.now();
|
|
2130
|
-
const item = {
|
|
2131
|
-
id: `permission:${requestId}`,
|
|
2132
|
-
conversationId,
|
|
2133
|
-
type: "permission" as const,
|
|
2134
|
-
permission,
|
|
2135
|
-
metadata: { protocol: "v2", permissionLive: true, permissionExpired: false, permissionPending: false },
|
|
2136
|
-
createdAt: now,
|
|
2137
|
-
updatedAt: now,
|
|
2138
|
-
};
|
|
2139
|
-
this.send(createEnvelope({
|
|
2140
|
-
type: "agent.v2.permission.request",
|
|
2141
|
-
sessionId: this.sessionId,
|
|
2142
|
-
payload: { conversationId, ...permission, item },
|
|
2143
|
-
}));
|
|
2144
|
-
}
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
/**
|
|
2148
|
-
* Normalize hook event names from different CLI providers to unified internal names.
|
|
2149
|
-
* Claude: PascalCase (PreToolUse, PostToolUse, Stop, PermissionRequest)
|
|
2150
|
-
* Codex: camelCase (preToolUse, postToolUse, sessionStart)
|
|
2151
|
-
* Gemini: PascalCase but different names (BeforeTool, AfterTool, BeforeSubmitPrompt)
|
|
2152
|
-
*/
|
|
2153
|
-
private normalizeHookName(rawName: string, provider: string): string | undefined {
|
|
2154
|
-
// Claude events — already in our canonical format
|
|
2155
|
-
if (provider === "claude") {
|
|
2156
|
-
return rawName;
|
|
2157
|
-
}
|
|
2158
|
-
|
|
2159
|
-
// Codex events — same as Claude (PascalCase)
|
|
2160
|
-
if (provider === "codex") {
|
|
2161
|
-
switch (rawName) {
|
|
2162
|
-
case "PreToolUse": case "preToolUse": return "PreToolUse";
|
|
2163
|
-
case "PostToolUse": case "postToolUse": return "PostToolUse";
|
|
2164
|
-
case "SessionStart": case "sessionStart": return "SessionStart";
|
|
2165
|
-
case "UserPromptSubmit": return "UserPromptSubmit";
|
|
2166
|
-
case "PermissionRequest": return "PermissionRequest";
|
|
2167
|
-
case "Stop": return "Stop";
|
|
2168
|
-
default: return undefined;
|
|
2169
|
-
}
|
|
2170
|
-
}
|
|
2171
|
-
|
|
2172
|
-
// Gemini events
|
|
2173
|
-
if (provider === "gemini") {
|
|
2174
|
-
switch (rawName) {
|
|
2175
|
-
case "BeforeTool": return "PreToolUse";
|
|
2176
|
-
case "AfterTool": return "PostToolUse";
|
|
2177
|
-
case "SessionStart": return "SessionStart";
|
|
2178
|
-
case "SessionEnd": return "Stop";
|
|
2179
|
-
default: return undefined;
|
|
2180
|
-
}
|
|
2181
|
-
}
|
|
2182
|
-
|
|
2183
|
-
// Copilot events (camelCase)
|
|
2184
|
-
if (provider === "copilot") {
|
|
2185
|
-
switch (rawName) {
|
|
2186
|
-
case "preToolUse": return "PreToolUse";
|
|
2187
|
-
case "postToolUse": return "PostToolUse";
|
|
2188
|
-
case "sessionStart": return "SessionStart";
|
|
2189
|
-
case "sessionEnd": return "Stop";
|
|
2190
|
-
case "userPromptSubmitted": return "UserPromptSubmit";
|
|
2191
|
-
case "errorOccurred": return "PostToolUseFailure";
|
|
2192
|
-
default: return undefined;
|
|
2193
|
-
}
|
|
2194
|
-
}
|
|
2195
|
-
|
|
2196
|
-
// Unknown provider — try all known formats
|
|
2197
|
-
// This handles "custom" shell where any provider might be launched
|
|
2198
|
-
const allProviders = ["claude", "codex", "gemini", "copilot"];
|
|
2199
|
-
for (const p of allProviders) {
|
|
2200
|
-
const result = this.normalizeHookName(rawName, p);
|
|
2201
|
-
if (result) return result;
|
|
2202
|
-
}
|
|
2203
|
-
return undefined;
|
|
2204
|
-
}
|
|
2205
|
-
|
|
2206
|
-
/** Auto-resolve a single pending permission (user acted in terminal) */
|
|
2207
|
-
private autoResolvePending(requestId: string): void {
|
|
2208
|
-
if (this.resolvePendingPermission(requestId, "allow", "terminal.auto").resolved) {
|
|
2209
|
-
this.log(`auto-resolved pending permission ${requestId} (user acted in terminal)`);
|
|
2210
|
-
}
|
|
2211
|
-
}
|
|
2212
|
-
|
|
2213
|
-
/** Drain all pending permissions for a terminal (session ended, stop, etc.) */
|
|
2214
|
-
private drainPendingPermissions(terminalId: string): void {
|
|
2215
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
2216
|
-
if (!stack) return;
|
|
2217
|
-
for (const entry of [...stack]) {
|
|
2218
|
-
if (this.resolvePendingPermission(entry.requestId, "deny", "terminal.drain").resolved) {
|
|
2219
|
-
this.log(`drained pending permission ${entry.requestId}`);
|
|
2220
|
-
}
|
|
2221
|
-
}
|
|
2222
|
-
}
|
|
2223
|
-
|
|
2224
|
-
private resolvePendingPermission(
|
|
2225
|
-
requestId: string,
|
|
2226
|
-
choice: HookPermissionChoice,
|
|
2227
|
-
source = "unknown",
|
|
2228
|
-
): { resolved: boolean; delivered: boolean } {
|
|
2229
|
-
const pending = this.pendingPermissions.get(requestId);
|
|
2230
|
-
const outcome = typeof choice === "string" ? choice : choice.outcome;
|
|
2231
|
-
const optionId = typeof choice === "string" ? undefined : choice.optionId;
|
|
2232
|
-
if (!pending) {
|
|
2233
|
-
this.log(`no pending permission for ${requestId} via ${source}: ${outcome}:${optionId ?? "default"}`);
|
|
2234
|
-
return { resolved: false, delivered: false };
|
|
2235
|
-
}
|
|
2236
|
-
this.pendingPermissions.delete(requestId);
|
|
2237
|
-
clearTimeout(pending.timeout);
|
|
2238
|
-
const delivered = pending.resolve(this.formatHookPermissionDecision(pending, choice));
|
|
2239
|
-
|
|
2240
|
-
const stack = this.permissionStacks.get(pending.terminalId);
|
|
2241
|
-
if (stack) {
|
|
2242
|
-
const idx = stack.findIndex((entry) => entry.requestId === requestId);
|
|
2243
|
-
if (idx >= 0) stack.splice(idx, 1);
|
|
2244
|
-
if (stack.length === 0) this.permissionStacks.delete(pending.terminalId);
|
|
2245
|
-
}
|
|
2246
|
-
this.log(`resolved permission ${requestId} via ${source}: ${outcome}:${optionId ?? "default"} delivered=${delivered}`);
|
|
2247
|
-
this.sendPermissionSnapshot(
|
|
2248
|
-
pending.terminalId,
|
|
2249
|
-
"thinking",
|
|
2250
|
-
outcome === "allow" ? "permission allowed" : "permission denied",
|
|
2251
|
-
{ requestId, outcome, source, delivered },
|
|
2252
|
-
);
|
|
2253
|
-
return { resolved: true, delivered };
|
|
2254
|
-
}
|
|
2255
|
-
|
|
2256
|
-
private formatHookPermissionDecision(
|
|
2257
|
-
permission: PendingPermission,
|
|
2258
|
-
choice: HookPermissionChoice,
|
|
2259
|
-
): HookPermissionDecision {
|
|
2260
|
-
const outcome = typeof choice === "string" ? choice : choice.outcome;
|
|
2261
|
-
const optionId = typeof choice === "string" ? undefined : choice.optionId;
|
|
2262
|
-
if (outcome === "allow") {
|
|
2263
|
-
return {
|
|
2264
|
-
behavior: "allow",
|
|
2265
|
-
...(optionId === "allow_always" && permission.permissionSuggestions.length > 0
|
|
2266
|
-
? { updatedPermissions: permission.permissionSuggestions }
|
|
2267
|
-
: {}),
|
|
2268
|
-
};
|
|
2269
|
-
}
|
|
2270
|
-
return {
|
|
2271
|
-
behavior: "deny",
|
|
2272
|
-
message: outcome === "cancelled" ? "Permission request cancelled." : "Permission denied by user.",
|
|
2273
|
-
};
|
|
2274
|
-
}
|
|
2275
|
-
|
|
2276
|
-
private sendPermissionSnapshot(
|
|
2277
|
-
terminalId: string,
|
|
2278
|
-
phase: string,
|
|
2279
|
-
summary?: string,
|
|
2280
|
-
permissionResolution?: {
|
|
2281
|
-
requestId: string;
|
|
2282
|
-
outcome: "allow" | "deny" | "cancelled";
|
|
2283
|
-
source: string;
|
|
2284
|
-
delivered: boolean;
|
|
2285
|
-
},
|
|
2286
|
-
): void {
|
|
2287
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
2288
|
-
const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
|
|
2289
|
-
const pendingPermissionCount = stack?.length ?? 0;
|
|
2290
|
-
const term = this.terminals.get(terminalId);
|
|
2291
|
-
const seq = term ? term.statusSeq++ : 0;
|
|
2292
|
-
this.send(createEnvelope({
|
|
2293
|
-
type: "terminal.status",
|
|
2294
|
-
sessionId: this.sessionId,
|
|
2295
|
-
terminalId,
|
|
2296
|
-
payload: {
|
|
2297
|
-
phase,
|
|
2298
|
-
provider: term?.provider,
|
|
2299
|
-
seq,
|
|
2300
|
-
...(summary && { summary }),
|
|
2301
|
-
...(permissionResolution && { permissionResolution }),
|
|
2302
|
-
...(topPermission && { topPermission }),
|
|
2303
|
-
...(pendingPermissionCount > 0 && { pendingPermissionCount }),
|
|
2304
|
-
},
|
|
2305
|
-
}));
|
|
2306
|
-
}
|
|
2307
|
-
|
|
2308
|
-
private cleanupHookServer(term: TerminalInstance): void {
|
|
2309
|
-
// Drain any pending permission requests for this terminal
|
|
2310
|
-
this.drainPendingPermissions(term.id);
|
|
2311
|
-
if (term.hookServer) {
|
|
2312
|
-
term.hookServer.close();
|
|
2313
|
-
term.hookServer = undefined;
|
|
2314
|
-
this.log(`hook server closed for ${term.id}`);
|
|
2315
|
-
}
|
|
2316
|
-
const marker = term.hookMarker;
|
|
2317
|
-
for (const configPath of term.hookConfigPaths) {
|
|
2318
|
-
try {
|
|
2319
|
-
// Copilot: per-instance file — just delete it
|
|
2320
|
-
if (configPath.includes(`linkshell-${marker}`)) {
|
|
2321
|
-
if (existsSync(configPath)) {
|
|
2322
|
-
unlinkSync(configPath);
|
|
2323
|
-
this.log(`removed copilot hook file ${configPath}`);
|
|
2324
|
-
}
|
|
2325
|
-
} else {
|
|
2326
|
-
// Claude/Codex/Gemini: remove our entries from the shared config
|
|
2327
|
-
this.removeHookEntries(configPath, marker);
|
|
2328
|
-
}
|
|
2329
|
-
} catch { /* ignore */ }
|
|
2330
|
-
}
|
|
2331
|
-
term.hookConfigPaths = [];
|
|
2332
|
-
}
|
|
2333
|
-
|
|
2334
|
-
/** Remove hook entries containing our marker from a JSON config file */
|
|
2335
|
-
private removeHookEntries(configPath: string, marker: string): void {
|
|
2336
|
-
if (!existsSync(configPath)) return;
|
|
2337
|
-
try {
|
|
2338
|
-
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
2339
|
-
const hooks = raw.hooks as Record<string, unknown[]> | undefined;
|
|
2340
|
-
if (!hooks) return;
|
|
2341
|
-
|
|
2342
|
-
let changed = false;
|
|
2343
|
-
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
2344
|
-
if (!Array.isArray(entries)) continue;
|
|
2345
|
-
const filtered = entries.filter((entry) => {
|
|
2346
|
-
const str = JSON.stringify(entry);
|
|
2347
|
-
return !str.includes(marker);
|
|
2348
|
-
});
|
|
2349
|
-
if (filtered.length !== entries.length) {
|
|
2350
|
-
changed = true;
|
|
2351
|
-
if (filtered.length === 0) {
|
|
2352
|
-
delete hooks[eventName];
|
|
2353
|
-
} else {
|
|
2354
|
-
hooks[eventName] = filtered;
|
|
2355
|
-
}
|
|
2356
|
-
}
|
|
2357
|
-
}
|
|
2358
|
-
|
|
2359
|
-
if (changed) {
|
|
2360
|
-
// If no hooks left, remove the hooks key entirely
|
|
2361
|
-
if (Object.keys(hooks).length === 0) {
|
|
2362
|
-
delete raw.hooks;
|
|
2363
|
-
}
|
|
2364
|
-
writeFileSync(configPath, JSON.stringify(raw, null, 2));
|
|
2365
|
-
this.log(`removed our hook entries from ${configPath}`);
|
|
2366
|
-
}
|
|
2367
|
-
} catch { /* ignore parse errors */ }
|
|
2368
|
-
}
|
|
2369
|
-
|
|
2370
|
-
/**
|
|
2371
|
-
* Remove ALL LinkShell hook entries (any marker) from a JSON config file.
|
|
2372
|
-
* Used on startup to sweep stale entries left behind by a crashed instance,
|
|
2373
|
-
* whose curl ports are now dead. Matches via isLinkShellHookEntry rather than
|
|
2374
|
-
* a single marker so entries from previous runs are also cleaned.
|
|
2375
|
-
*/
|
|
2376
|
-
private sweepLinkShellHookEntries(configPath: string): void {
|
|
2377
|
-
if (!existsSync(configPath)) return;
|
|
2378
|
-
try {
|
|
2379
|
-
// Re-read immediately before write to minimize the window for a
|
|
2380
|
-
// concurrent writer to clobber our changes.
|
|
2381
|
-
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
2382
|
-
const hooks = raw.hooks as Record<string, unknown[]> | undefined;
|
|
2383
|
-
if (!hooks) return;
|
|
2384
|
-
|
|
2385
|
-
let changed = false;
|
|
2386
|
-
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
2387
|
-
if (!Array.isArray(entries)) continue;
|
|
2388
|
-
const filtered = entries.filter((entry) => !isLinkShellHookEntry(entry));
|
|
2389
|
-
if (filtered.length !== entries.length) {
|
|
2390
|
-
changed = true;
|
|
2391
|
-
if (filtered.length === 0) {
|
|
2392
|
-
delete hooks[eventName];
|
|
2393
|
-
} else {
|
|
2394
|
-
hooks[eventName] = filtered;
|
|
2395
|
-
}
|
|
2396
|
-
}
|
|
2397
|
-
}
|
|
2398
|
-
|
|
2399
|
-
if (changed) {
|
|
2400
|
-
if (Object.keys(hooks).length === 0) {
|
|
2401
|
-
delete raw.hooks;
|
|
2402
|
-
}
|
|
2403
|
-
writeFileSync(configPath, JSON.stringify(raw, null, 2));
|
|
2404
|
-
this.log(`swept stale LinkShell hook entries from ${configPath}`);
|
|
2405
|
-
}
|
|
2406
|
-
} catch { /* ignore parse errors */ }
|
|
2407
|
-
}
|
|
2408
|
-
|
|
2409
|
-
/** Sweep stale LinkShell hook entries from known shared config files on startup. */
|
|
2410
|
-
private sweepStaleHookConfigs(): void {
|
|
2411
|
-
const home = homedir();
|
|
2412
|
-
const candidates = [
|
|
2413
|
-
join(home, ".claude", "settings.json"),
|
|
2414
|
-
// Old CLI versions wrote hooks here; Claude Code still runs them.
|
|
2415
|
-
join(home, ".claude", "settings.local.json"),
|
|
2416
|
-
join(home, ".codex", "hooks.json"),
|
|
2417
|
-
join(home, ".gemini", "settings.json"),
|
|
2418
|
-
join(this.defaultCwd, "hooks.json"), // copilot (per-cwd)
|
|
2419
|
-
];
|
|
2420
|
-
for (const path of candidates) {
|
|
2421
|
-
this.rewriteBareLinkShellIdInFile(path);
|
|
2422
|
-
this.sweepLinkShellHookEntries(path);
|
|
2423
|
-
}
|
|
2424
|
-
}
|
|
2425
|
-
|
|
2426
|
-
/** Silence Claude Code's "required env var(s) not set: ${LINKSHELL_ID}" on leftover hooks. */
|
|
2427
|
-
private rewriteBareLinkShellIdInFile(configPath: string): void {
|
|
2428
|
-
if (!existsSync(configPath)) return;
|
|
2429
|
-
try {
|
|
2430
|
-
const raw = readFileSync(configPath, "utf8");
|
|
2431
|
-
const next = rewriteBareLinkShellId(raw);
|
|
2432
|
-
if (next !== raw) {
|
|
2433
|
-
writeFileSync(configPath, next);
|
|
2434
|
-
this.log(`rewrote optional LINKSHELL_ID expansion in ${configPath}`);
|
|
2435
|
-
}
|
|
2436
|
-
} catch { /* ignore parse/io errors */ }
|
|
2437
|
-
}
|
|
2438
1331
|
|
|
2439
1332
|
private send(message: Envelope): void {
|
|
2440
1333
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
@@ -2648,7 +1541,6 @@ export class BridgeSession {
|
|
|
2648
1541
|
}
|
|
2649
1542
|
this.exitedTerminalTimers.clear();
|
|
2650
1543
|
for (const term of this.terminals.values()) {
|
|
2651
|
-
this.cleanupHookServer(term);
|
|
2652
1544
|
if (term.status === "running") term.pty.kill();
|
|
2653
1545
|
}
|
|
2654
1546
|
this.terminals.clear();
|