linkshell-cli 0.3.28 → 0.3.29
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/commands/setup.js +20 -2
- package/dist/cli/src/commands/setup.js.map +1 -1
- package/dist/cli/src/commands/upgrade.js +9 -24
- package/dist/cli/src/commands/upgrade.js.map +1 -1
- package/dist/cli/src/index.js +28 -26
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/providers.d.ts +3 -7
- package/dist/cli/src/providers.js +76 -19
- package/dist/cli/src/providers.js.map +1 -1
- package/dist/cli/src/runtime/acp/acp-client.d.ts +0 -10
- package/dist/cli/src/runtime/acp/acp-client.js +20 -146
- package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-session.d.ts +62 -0
- package/dist/cli/src/runtime/acp/agent-session.js +1075 -0
- package/dist/cli/src/runtime/acp/agent-session.js.map +1 -0
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +1 -27
- package/dist/cli/src/runtime/acp/agent-workspace.js +168 -1008
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +1 -3
- package/dist/cli/src/runtime/acp/claude-sdk-client.js +35 -81
- package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-stream-json-client.d.ts +1 -2
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js +35 -54
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/json-rpc.d.ts +1 -34
- package/dist/cli/src/runtime/acp/json-rpc.js +8 -32
- package/dist/cli/src/runtime/acp/json-rpc.js.map +1 -1
- package/dist/cli/src/runtime/acp/provider-resolver.d.ts +0 -3
- package/dist/cli/src/runtime/acp/provider-resolver.js +6 -26
- package/dist/cli/src/runtime/acp/provider-resolver.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +31 -1
- package/dist/cli/src/runtime/bridge-session.js +991 -81
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/src/runtime/screen-fallback.d.ts +1 -1
- package/dist/cli/src/runtime/screen-fallback.js +4 -4
- package/dist/cli/src/runtime/screen-fallback.js.map +1 -1
- package/dist/cli/src/runtime/screen-share.d.ts +1 -1
- package/dist/cli/src/runtime/screen-share.js +7 -7
- package/dist/cli/src/runtime/screen-share.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +11940 -3423
- package/dist/shared-protocol/src/index.js +98 -164
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +13 -13
- package/src/commands/setup.ts +31 -5
- package/src/commands/upgrade.ts +8 -28
- package/src/index.ts +34 -29
- package/src/providers.ts +108 -26
- package/src/runtime/acp/acp-client.ts +22 -159
- package/src/runtime/acp/agent-session.ts +1180 -0
- package/src/runtime/acp/agent-workspace.ts +176 -1094
- package/src/runtime/acp/claude-sdk-client.ts +37 -85
- package/src/runtime/acp/claude-stream-json-client.ts +41 -59
- package/src/runtime/acp/json-rpc.ts +14 -46
- package/src/runtime/acp/provider-resolver.ts +6 -29
- package/src/runtime/bridge-session.ts +1092 -84
- package/src/runtime/screen-fallback.ts +5 -5
- package/src/runtime/screen-share.ts +8 -8
- package/src/runtime/acp/claude-sessions.ts +0 -563
- package/src/runtime/acp/codex-rpc-bridge.ts +0 -233
- package/src/runtime/acp/codex-sessions.ts +0 -802
- package/src/types/linkshell-gateway.d.ts +0 -18
|
@@ -2,9 +2,9 @@ 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, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
5
|
+
import { writeFileSync, readFileSync, readdirSync, statSync, unlinkSync, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
|
-
import { join, resolve } from "node:path";
|
|
7
|
+
import { join, basename, resolve } from "node:path";
|
|
8
8
|
import { createEnvelope, parseEnvelope, parseTypedPayload, serializeEnvelope, PROTOCOL_VERSION, } from "@linkshell/protocol";
|
|
9
9
|
import { ScrollbackBuffer } from "./scrollback.js";
|
|
10
10
|
import { ScreenFallback } from "./screen-fallback.js";
|
|
@@ -13,6 +13,7 @@ import { getLanIp } from "../utils/lan-ip.js";
|
|
|
13
13
|
import { startKeepAwake } from "../utils/keep-awake.js";
|
|
14
14
|
import { loadOrCreateMachineIdentity } from "../machine-id.js";
|
|
15
15
|
import { getValidToken } from "../auth.js";
|
|
16
|
+
import { AgentSessionProxy } from "./acp/agent-session.js";
|
|
16
17
|
import { AgentWorkspaceProxy } from "./acp/agent-workspace.js";
|
|
17
18
|
import { detectAvailableProviders } from "./acp/provider-resolver.js";
|
|
18
19
|
const HEARTBEAT_INTERVAL = 15_000;
|
|
@@ -20,6 +21,113 @@ const RECONNECT_BASE_DELAY = 1_000;
|
|
|
20
21
|
const RECONNECT_MAX_DELAY = 30_000;
|
|
21
22
|
const RECONNECT_MAX_ATTEMPTS = 20;
|
|
22
23
|
const DEFAULT_TERMINAL_ID = "default";
|
|
24
|
+
const HOOK_BODY_LIMIT = 256 * 1024;
|
|
25
|
+
const PERMISSION_REQUEST_TIMEOUT_MS = Number(process.env.LINKSHELL_PERMISSION_TIMEOUT_MS ?? 5 * 60_000);
|
|
26
|
+
const LINKSHELL_PERMISSION_GUARD_MARKER = "LINKSHELL_PERMISSION_GUARD";
|
|
27
|
+
function isLinkShellHookEntry(entry, marker) {
|
|
28
|
+
let raw = "";
|
|
29
|
+
try {
|
|
30
|
+
raw = JSON.stringify(entry);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
raw = String(entry);
|
|
34
|
+
}
|
|
35
|
+
return ((marker ? raw.includes(`/hook?m=${marker}`) : false) ||
|
|
36
|
+
raw.includes("/hook?m=lsh-") ||
|
|
37
|
+
(raw.includes("/hook?m=") && raw.includes("LINKSHELL_ID")));
|
|
38
|
+
}
|
|
39
|
+
function withLinkShellHookEntry(entries, entry, priority) {
|
|
40
|
+
const cleaned = (Array.isArray(entries) ? entries : []).filter((item) => !isLinkShellHookEntry(item));
|
|
41
|
+
return priority === "first" ? [entry, ...cleaned] : [...cleaned, entry];
|
|
42
|
+
}
|
|
43
|
+
function guardPermissionCommandForLinkShell(command) {
|
|
44
|
+
if (typeof command !== "string")
|
|
45
|
+
return command;
|
|
46
|
+
if (command.includes(LINKSHELL_PERMISSION_GUARD_MARKER))
|
|
47
|
+
return command;
|
|
48
|
+
return [
|
|
49
|
+
`case "\${LINKSHELL_ID:-}" in lsh-*) exit 0 ;; esac`,
|
|
50
|
+
`# ${LINKSHELL_PERMISSION_GUARD_MARKER}`,
|
|
51
|
+
command,
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|
|
54
|
+
function guardPermissionHookObjectForLinkShell(hook) {
|
|
55
|
+
if (isLinkShellHookEntry(hook))
|
|
56
|
+
return hook;
|
|
57
|
+
const next = { ...hook };
|
|
58
|
+
if (typeof next.command === "string") {
|
|
59
|
+
next.command = guardPermissionCommandForLinkShell(next.command);
|
|
60
|
+
}
|
|
61
|
+
if (typeof next.bash === "string") {
|
|
62
|
+
next.bash = guardPermissionCommandForLinkShell(next.bash);
|
|
63
|
+
}
|
|
64
|
+
return next;
|
|
65
|
+
}
|
|
66
|
+
function guardPermissionHookEntryForLinkShell(entry) {
|
|
67
|
+
if (isLinkShellHookEntry(entry))
|
|
68
|
+
return entry;
|
|
69
|
+
if (typeof entry === "string")
|
|
70
|
+
return guardPermissionCommandForLinkShell(entry);
|
|
71
|
+
if (Array.isArray(entry))
|
|
72
|
+
return entry.map(guardPermissionHookEntryForLinkShell);
|
|
73
|
+
if (!entry || typeof entry !== "object")
|
|
74
|
+
return entry;
|
|
75
|
+
const next = { ...entry };
|
|
76
|
+
if (Array.isArray(next.hooks)) {
|
|
77
|
+
next.hooks = next.hooks.map((hook) => hook && typeof hook === "object" && !Array.isArray(hook)
|
|
78
|
+
? guardPermissionHookObjectForLinkShell(hook)
|
|
79
|
+
: guardPermissionHookEntryForLinkShell(hook));
|
|
80
|
+
}
|
|
81
|
+
if (typeof next.command === "string" || typeof next.bash === "string") {
|
|
82
|
+
return guardPermissionHookObjectForLinkShell(next);
|
|
83
|
+
}
|
|
84
|
+
return next;
|
|
85
|
+
}
|
|
86
|
+
function withBlockingLinkShellPermissionEntry(entries, entry) {
|
|
87
|
+
const cleaned = (Array.isArray(entries) ? entries : [])
|
|
88
|
+
.filter((item) => !isLinkShellHookEntry(item))
|
|
89
|
+
.map(guardPermissionHookEntryForLinkShell);
|
|
90
|
+
return [entry, ...cleaned];
|
|
91
|
+
}
|
|
92
|
+
function stringifyHookInput(value) {
|
|
93
|
+
if (typeof value === "string")
|
|
94
|
+
return value.slice(0, 1200);
|
|
95
|
+
if (typeof value === "object" && value) {
|
|
96
|
+
try {
|
|
97
|
+
return JSON.stringify(value, null, 2).slice(0, 1200);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return String(value).slice(0, 1200);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
105
|
+
function hookPermissionSuggestions(event) {
|
|
106
|
+
if (isCodexPermissionRequest(event))
|
|
107
|
+
return [];
|
|
108
|
+
const snake = event.permission_suggestions;
|
|
109
|
+
const camel = event.permissionSuggestions;
|
|
110
|
+
if (Array.isArray(snake))
|
|
111
|
+
return snake;
|
|
112
|
+
if (Array.isArray(camel))
|
|
113
|
+
return camel;
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
function isCodexPermissionRequest(event) {
|
|
117
|
+
if (typeof event.turn_id === "string" || typeof event.turnId === "string")
|
|
118
|
+
return true;
|
|
119
|
+
const transcriptPath = event.transcript_path ?? event.transcriptPath;
|
|
120
|
+
return typeof transcriptPath === "string" && transcriptPath.includes("/.codex/");
|
|
121
|
+
}
|
|
122
|
+
function hookPermissionOptions(suggestions) {
|
|
123
|
+
return [
|
|
124
|
+
{ id: "deny", label: "拒绝", kind: "deny" },
|
|
125
|
+
{ id: "allow_once", label: "允许一次", kind: "allow" },
|
|
126
|
+
...(suggestions.length > 0
|
|
127
|
+
? [{ id: "allow_always", label: "始终允许", kind: "allow" }]
|
|
128
|
+
: []),
|
|
129
|
+
];
|
|
130
|
+
}
|
|
23
131
|
function getPairingGatewayParam(gatewayHttpUrl) {
|
|
24
132
|
try {
|
|
25
133
|
const url = new URL(gatewayHttpUrl);
|
|
@@ -86,26 +194,36 @@ export class BridgeSession {
|
|
|
86
194
|
sessionId = "";
|
|
87
195
|
exited = false;
|
|
88
196
|
stopped = false;
|
|
197
|
+
permissionStacks = new Map();
|
|
198
|
+
// Pending permission responses: requestId → HTTP response callback
|
|
199
|
+
pendingPermissions = new Map();
|
|
200
|
+
hookMarker = `lsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
89
201
|
screenCapture;
|
|
90
202
|
screenShare;
|
|
91
203
|
tunnelSockets = new Map();
|
|
92
204
|
keepAwake;
|
|
205
|
+
agentSession;
|
|
93
206
|
agentWorkspace;
|
|
94
207
|
machineIdentity;
|
|
95
208
|
constructor(options) {
|
|
96
209
|
this.options = options;
|
|
97
|
-
this.sessionId = options.
|
|
210
|
+
this.sessionId = options.sessionId ?? "";
|
|
98
211
|
}
|
|
99
212
|
log(msg) {
|
|
100
213
|
if (this.options.verbose) {
|
|
101
214
|
process.stderr.write(`[bridge:verbose] ${msg}\n`);
|
|
102
215
|
}
|
|
103
216
|
}
|
|
217
|
+
terminalHookMarker(terminalId) {
|
|
218
|
+
const safeTerminalId = terminalId.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
|
219
|
+
return `${this.hookMarker}-${safeTerminalId}`;
|
|
220
|
+
}
|
|
104
221
|
async start() {
|
|
105
|
-
this.log(`starting
|
|
222
|
+
this.log(`starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`);
|
|
106
223
|
this.machineIdentity = loadOrCreateMachineIdentity();
|
|
107
|
-
this.sessionId
|
|
108
|
-
|
|
224
|
+
if (!this.sessionId) {
|
|
225
|
+
await this.createPairing();
|
|
226
|
+
}
|
|
109
227
|
if (this.options.keepAwake) {
|
|
110
228
|
this.keepAwake = startKeepAwake();
|
|
111
229
|
}
|
|
@@ -113,17 +231,21 @@ export class BridgeSession {
|
|
|
113
231
|
process.stderr.write("[bridge] keep-awake disabled\n");
|
|
114
232
|
}
|
|
115
233
|
if (this.options.agentUi) {
|
|
234
|
+
process.env.LINKSHELL_ID = this.terminalHookMarker(DEFAULT_TERMINAL_ID);
|
|
116
235
|
const availableProviders = this.options.agentProvider
|
|
117
236
|
? [normalizeAgentProvider(this.options.agentProvider)]
|
|
118
237
|
: detectAvailableProviders();
|
|
119
238
|
const agentOptions = {
|
|
120
|
-
|
|
239
|
+
sessionId: this.sessionId,
|
|
121
240
|
cwd: process.cwd(),
|
|
122
241
|
availableProviders,
|
|
123
242
|
command: this.options.agentCommand,
|
|
124
243
|
verbose: this.options.verbose,
|
|
125
244
|
send: (envelope) => this.send(envelope),
|
|
126
245
|
};
|
|
246
|
+
this.agentSession = new AgentSessionProxy({
|
|
247
|
+
...agentOptions,
|
|
248
|
+
});
|
|
127
249
|
this.agentWorkspace = new AgentWorkspaceProxy({
|
|
128
250
|
...agentOptions,
|
|
129
251
|
});
|
|
@@ -141,19 +263,19 @@ export class BridgeSession {
|
|
|
141
263
|
const res = await fetch(`${this.options.gatewayHttpUrl}/pairings`, {
|
|
142
264
|
method: "POST",
|
|
143
265
|
headers,
|
|
144
|
-
body: JSON.stringify({
|
|
266
|
+
body: JSON.stringify({}),
|
|
145
267
|
});
|
|
146
268
|
if (!res.ok) {
|
|
147
269
|
throw new Error(`Failed to create pairing: ${res.status}`);
|
|
148
270
|
}
|
|
149
271
|
const body = (await res.json());
|
|
150
|
-
this.sessionId = body.
|
|
272
|
+
this.sessionId = body.sessionId;
|
|
151
273
|
const pairingGateway = resolvePairingGateway(this.options.gatewayHttpUrl, this.options.pairingGateway);
|
|
152
274
|
const deepLink = pairingGateway
|
|
153
275
|
? `linkshell://pair?code=${body.pairingCode}&gateway=${encodeURIComponent(pairingGateway)}`
|
|
154
276
|
: `linkshell://pair?code=${body.pairingCode}`;
|
|
155
277
|
process.stderr.write(`\n \x1b[1mPairing code: \x1b[36m${body.pairingCode}\x1b[0m\n`);
|
|
156
|
-
process.stderr.write(`
|
|
278
|
+
process.stderr.write(` Session: ${body.sessionId}\n`);
|
|
157
279
|
process.stderr.write(` Expires: ${body.expiresAt}\n\n`);
|
|
158
280
|
if (!pairingGateway) {
|
|
159
281
|
process.stderr.write(" Note: QR will use the app's current gateway because the CLI is pointed at a local-only address.\n\n");
|
|
@@ -203,7 +325,7 @@ export class BridgeSession {
|
|
|
203
325
|
return;
|
|
204
326
|
}
|
|
205
327
|
const url = new URL(this.options.gatewayUrl);
|
|
206
|
-
url.searchParams.set("
|
|
328
|
+
url.searchParams.set("sessionId", this.sessionId);
|
|
207
329
|
url.searchParams.set("role", "host");
|
|
208
330
|
const authToken = await this.resolveAuthToken();
|
|
209
331
|
if (authToken) {
|
|
@@ -217,22 +339,18 @@ export class BridgeSession {
|
|
|
217
339
|
this.reconnectAttempts = 0;
|
|
218
340
|
this.reconnecting = false;
|
|
219
341
|
this.send(createEnvelope({
|
|
220
|
-
type: "
|
|
221
|
-
|
|
342
|
+
type: "session.connect",
|
|
343
|
+
sessionId: this.sessionId,
|
|
222
344
|
payload: {
|
|
223
345
|
role: "host",
|
|
224
346
|
clientName: this.options.clientName,
|
|
347
|
+
provider: this.options.providerConfig.provider,
|
|
225
348
|
protocolVersion: PROTOCOL_VERSION,
|
|
226
349
|
machineId: this.machineIdentity?.machineId,
|
|
227
350
|
hostname: this.options.hostname || hostname(),
|
|
228
351
|
platform: platform(),
|
|
229
352
|
cwd: process.cwd(),
|
|
230
|
-
|
|
231
|
-
"terminal",
|
|
232
|
-
...(this.options.agentUi ? ["agent-ui"] : []),
|
|
233
|
-
...(this.options.screen ? ["screen"] : []),
|
|
234
|
-
"tunnel",
|
|
235
|
-
],
|
|
353
|
+
projectName: basename(process.cwd()),
|
|
236
354
|
},
|
|
237
355
|
}));
|
|
238
356
|
this.startHeartbeat();
|
|
@@ -283,33 +401,33 @@ export class BridgeSession {
|
|
|
283
401
|
}
|
|
284
402
|
case "terminal.spawn": {
|
|
285
403
|
const p = parseTypedPayload("terminal.spawn", envelope.payload);
|
|
286
|
-
const normalizedCwd = resolve(p.cwd
|
|
404
|
+
const normalizedCwd = resolve(p.cwd);
|
|
287
405
|
// Dedup: if a running terminal already exists for this cwd, return it
|
|
288
406
|
const existing = [...this.terminals.values()].find((t) => t.status === "running" && resolve(t.cwd) === normalizedCwd);
|
|
289
407
|
if (existing) {
|
|
290
408
|
this.send(createEnvelope({
|
|
291
409
|
type: "terminal.spawned",
|
|
292
|
-
|
|
410
|
+
sessionId: this.sessionId,
|
|
293
411
|
terminalId: existing.id,
|
|
294
|
-
payload: { terminalId: existing.id, cwd: existing.cwd,
|
|
412
|
+
payload: { terminalId: existing.id, cwd: existing.cwd, projectName: existing.projectName, provider: existing.provider },
|
|
295
413
|
}));
|
|
296
414
|
}
|
|
297
415
|
else {
|
|
298
416
|
const newId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
299
417
|
try {
|
|
300
|
-
await this.spawnTerminal(newId, normalizedCwd);
|
|
418
|
+
await this.spawnTerminal(newId, normalizedCwd, p.provider);
|
|
301
419
|
this.send(createEnvelope({
|
|
302
420
|
type: "terminal.spawned",
|
|
303
|
-
|
|
421
|
+
sessionId: this.sessionId,
|
|
304
422
|
terminalId: newId,
|
|
305
|
-
payload: { terminalId: newId, cwd: normalizedCwd,
|
|
423
|
+
payload: { terminalId: newId, cwd: normalizedCwd, projectName: basename(normalizedCwd), provider: p.provider },
|
|
306
424
|
}));
|
|
307
425
|
}
|
|
308
426
|
catch (err) {
|
|
309
427
|
this.log(`failed to spawn terminal ${newId}: ${err}`);
|
|
310
428
|
this.send(createEnvelope({
|
|
311
429
|
type: "terminal.exit",
|
|
312
|
-
|
|
430
|
+
sessionId: this.sessionId,
|
|
313
431
|
terminalId: newId,
|
|
314
432
|
payload: { exitCode: 1, signal: 0 },
|
|
315
433
|
}));
|
|
@@ -352,14 +470,14 @@ export class BridgeSession {
|
|
|
352
470
|
});
|
|
353
471
|
this.send(createEnvelope({
|
|
354
472
|
type: "terminal.browse.result",
|
|
355
|
-
|
|
473
|
+
sessionId: this.sessionId,
|
|
356
474
|
payload: { path: browsePath, entries, requestId: p.requestId },
|
|
357
475
|
}));
|
|
358
476
|
}
|
|
359
477
|
catch (err) {
|
|
360
478
|
this.send(createEnvelope({
|
|
361
479
|
type: "terminal.browse.result",
|
|
362
|
-
|
|
480
|
+
sessionId: this.sessionId,
|
|
363
481
|
payload: { path: browsePath, entries: [], error: err.message, requestId: p.requestId },
|
|
364
482
|
}));
|
|
365
483
|
}
|
|
@@ -389,7 +507,7 @@ export class BridgeSession {
|
|
|
389
507
|
}
|
|
390
508
|
this.send(createEnvelope({
|
|
391
509
|
type: "terminal.file.read.result",
|
|
392
|
-
|
|
510
|
+
sessionId: this.sessionId,
|
|
393
511
|
payload: {
|
|
394
512
|
path: filePath,
|
|
395
513
|
content: buffer.toString("utf8"),
|
|
@@ -403,7 +521,7 @@ export class BridgeSession {
|
|
|
403
521
|
catch (err) {
|
|
404
522
|
this.send(createEnvelope({
|
|
405
523
|
type: "terminal.file.read.result",
|
|
406
|
-
|
|
524
|
+
sessionId: this.sessionId,
|
|
407
525
|
payload: {
|
|
408
526
|
path: filePath,
|
|
409
527
|
content: "",
|
|
@@ -434,14 +552,14 @@ export class BridgeSession {
|
|
|
434
552
|
}));
|
|
435
553
|
this.send(createEnvelope({
|
|
436
554
|
type: "terminal.browse.result",
|
|
437
|
-
|
|
555
|
+
sessionId: this.sessionId,
|
|
438
556
|
payload: { path: parentPath, entries },
|
|
439
557
|
}));
|
|
440
558
|
}
|
|
441
559
|
catch (err) {
|
|
442
560
|
this.send(createEnvelope({
|
|
443
561
|
type: "terminal.browse.result",
|
|
444
|
-
|
|
562
|
+
sessionId: this.sessionId,
|
|
445
563
|
payload: { path: dirPath, entries: [], error: err.message },
|
|
446
564
|
}));
|
|
447
565
|
}
|
|
@@ -483,21 +601,21 @@ export class BridgeSession {
|
|
|
483
601
|
catch { }
|
|
484
602
|
this.send(createEnvelope({
|
|
485
603
|
type: "terminal.history.response",
|
|
486
|
-
|
|
604
|
+
sessionId: this.sessionId,
|
|
487
605
|
payload: { entries, shell },
|
|
488
606
|
}));
|
|
489
607
|
break;
|
|
490
608
|
}
|
|
491
|
-
case "
|
|
492
|
-
const p = parseTypedPayload("
|
|
609
|
+
case "session.ack": {
|
|
610
|
+
const p = parseTypedPayload("session.ack", envelope.payload);
|
|
493
611
|
const term = this.terminals.get(tid);
|
|
494
612
|
if (term) {
|
|
495
613
|
term.scrollback.trimUpTo(p.seq);
|
|
496
614
|
}
|
|
497
615
|
break;
|
|
498
616
|
}
|
|
499
|
-
case "
|
|
500
|
-
const p = parseTypedPayload("
|
|
617
|
+
case "session.resume": {
|
|
618
|
+
const p = parseTypedPayload("session.resume", envelope.payload);
|
|
501
619
|
// Replay all terminals
|
|
502
620
|
for (const [termId, term] of this.terminals) {
|
|
503
621
|
this.replayFrom(termId, term, p.lastAckedSeqByTerminal[termId] ?? p.lastAckedSeq);
|
|
@@ -506,7 +624,7 @@ export class BridgeSession {
|
|
|
506
624
|
this.sendTerminalList();
|
|
507
625
|
break;
|
|
508
626
|
}
|
|
509
|
-
case "
|
|
627
|
+
case "session.heartbeat":
|
|
510
628
|
break;
|
|
511
629
|
case "screen.start": {
|
|
512
630
|
const p = parseTypedPayload("screen.start", envelope.payload);
|
|
@@ -527,29 +645,66 @@ export class BridgeSession {
|
|
|
527
645
|
this.screenShare?.handleIceCandidate(p.candidate, p.sdpMid, p.sdpMLineIndex);
|
|
528
646
|
break;
|
|
529
647
|
}
|
|
530
|
-
case "agent.
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
648
|
+
case "agent.initialize":
|
|
649
|
+
case "agent.session.new":
|
|
650
|
+
case "agent.session.load":
|
|
651
|
+
case "agent.session.list":
|
|
652
|
+
case "agent.prompt":
|
|
653
|
+
case "agent.cancel": {
|
|
654
|
+
if (!this.agentSession) {
|
|
655
|
+
this.send(createEnvelope({
|
|
656
|
+
type: "agent.capabilities",
|
|
657
|
+
sessionId: this.sessionId,
|
|
658
|
+
payload: {
|
|
659
|
+
enabled: false,
|
|
660
|
+
provider: normalizeAgentProvider(this.options.agentProvider ?? "codex"),
|
|
661
|
+
machineId: this.machineIdentity?.machineId,
|
|
662
|
+
error: "Agent GUI is not enabled. Start CLI with --agent-ui.",
|
|
663
|
+
supportsSessionList: false,
|
|
664
|
+
supportsSessionLoad: false,
|
|
665
|
+
supportsImages: false,
|
|
666
|
+
supportsAudio: false,
|
|
667
|
+
supportsPermission: false,
|
|
668
|
+
supportsPlan: false,
|
|
669
|
+
supportsCancel: false,
|
|
670
|
+
},
|
|
671
|
+
}));
|
|
550
672
|
break;
|
|
551
673
|
}
|
|
552
|
-
|
|
674
|
+
if (envelope.type === "agent.prompt")
|
|
675
|
+
this.refreshAgentPermissionHooks();
|
|
676
|
+
await this.agentSession.handleEnvelope(envelope);
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
case "agent.permission.response": {
|
|
680
|
+
const p = parseTypedPayload("agent.permission.response", envelope.payload);
|
|
681
|
+
if (this.resolvePendingPermission(p.requestId, {
|
|
682
|
+
outcome: p.outcome,
|
|
683
|
+
optionId: p.optionId,
|
|
684
|
+
}, "agent.permission.response").resolved) {
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
if (!this.agentSession) {
|
|
688
|
+
this.send(createEnvelope({
|
|
689
|
+
type: "agent.capabilities",
|
|
690
|
+
sessionId: this.sessionId,
|
|
691
|
+
payload: {
|
|
692
|
+
enabled: false,
|
|
693
|
+
provider: normalizeAgentProvider(this.options.agentProvider ?? "codex"),
|
|
694
|
+
machineId: this.machineIdentity?.machineId,
|
|
695
|
+
error: "Agent GUI is not enabled. Start CLI with --agent-ui.",
|
|
696
|
+
supportsSessionList: false,
|
|
697
|
+
supportsSessionLoad: false,
|
|
698
|
+
supportsImages: false,
|
|
699
|
+
supportsAudio: false,
|
|
700
|
+
supportsPermission: false,
|
|
701
|
+
supportsPlan: false,
|
|
702
|
+
supportsCancel: false,
|
|
703
|
+
},
|
|
704
|
+
}));
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
await this.agentSession.handleEnvelope(envelope);
|
|
553
708
|
break;
|
|
554
709
|
}
|
|
555
710
|
case "agent.v2.capabilities.request":
|
|
@@ -560,13 +715,11 @@ export class BridgeSession {
|
|
|
560
715
|
case "agent.v2.cancel":
|
|
561
716
|
case "agent.v2.permission.respond":
|
|
562
717
|
case "agent.v2.structured_input.respond":
|
|
563
|
-
case "agent.v2.snapshot.request":
|
|
564
|
-
case "agent.v2.history.request":
|
|
565
|
-
case "agent.v2.delta.request": {
|
|
718
|
+
case "agent.v2.snapshot.request": {
|
|
566
719
|
if (!this.agentWorkspace) {
|
|
567
720
|
this.send(createEnvelope({
|
|
568
721
|
type: "agent.v2.capabilities",
|
|
569
|
-
|
|
722
|
+
sessionId: this.sessionId,
|
|
570
723
|
payload: {
|
|
571
724
|
enabled: false,
|
|
572
725
|
provider: normalizeAgentProvider(this.options.agentProvider ?? "codex"),
|
|
@@ -584,6 +737,8 @@ export class BridgeSession {
|
|
|
584
737
|
}));
|
|
585
738
|
break;
|
|
586
739
|
}
|
|
740
|
+
if (envelope.type === "agent.v2.prompt" || envelope.type === "agent.v2.command.execute")
|
|
741
|
+
this.refreshAgentPermissionHooks();
|
|
587
742
|
await this.agentWorkspace.handleEnvelope(envelope);
|
|
588
743
|
break;
|
|
589
744
|
}
|
|
@@ -599,6 +754,37 @@ export class BridgeSession {
|
|
|
599
754
|
}
|
|
600
755
|
break;
|
|
601
756
|
}
|
|
757
|
+
case "permission.decision": {
|
|
758
|
+
const p = envelope.payload;
|
|
759
|
+
const result = this.resolvePendingPermission(p.requestId, p.decision, "permission.decision");
|
|
760
|
+
if (!result.resolved) {
|
|
761
|
+
this.sendPermissionSnapshot(tid, "thinking", "permission not pending", {
|
|
762
|
+
requestId: p.requestId,
|
|
763
|
+
outcome: p.decision,
|
|
764
|
+
source: "permission.decision",
|
|
765
|
+
delivered: false,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
process.stderr.write(`[bridge] permission decision request=${p.requestId} decision=${p.decision} resolved=${result.resolved} delivered=${result.delivered}\n`);
|
|
769
|
+
this.send(createEnvelope({
|
|
770
|
+
type: "permission.decision.result",
|
|
771
|
+
sessionId: this.sessionId,
|
|
772
|
+
terminalId: tid,
|
|
773
|
+
payload: {
|
|
774
|
+
requestId: p.requestId,
|
|
775
|
+
decision: p.decision,
|
|
776
|
+
resolved: result.resolved,
|
|
777
|
+
delivered: result.delivered,
|
|
778
|
+
source: "permission.decision",
|
|
779
|
+
message: result.delivered
|
|
780
|
+
? undefined
|
|
781
|
+
: result.resolved
|
|
782
|
+
? "Permission resolved but response was not delivered"
|
|
783
|
+
: "Permission request is no longer pending",
|
|
784
|
+
},
|
|
785
|
+
}));
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
602
788
|
case "tunnel.request": {
|
|
603
789
|
const p = parseTypedPayload("tunnel.request", envelope.payload);
|
|
604
790
|
this.handleTunnelRequest(p);
|
|
@@ -647,7 +833,7 @@ export class BridgeSession {
|
|
|
647
833
|
proxyRes.on("data", (chunk) => {
|
|
648
834
|
this.send(createEnvelope({
|
|
649
835
|
type: "tunnel.response",
|
|
650
|
-
|
|
836
|
+
sessionId: this.sessionId,
|
|
651
837
|
payload: {
|
|
652
838
|
requestId,
|
|
653
839
|
statusCode: proxyRes.statusCode ?? 200,
|
|
@@ -661,7 +847,7 @@ export class BridgeSession {
|
|
|
661
847
|
proxyRes.on("end", () => {
|
|
662
848
|
this.send(createEnvelope({
|
|
663
849
|
type: "tunnel.response",
|
|
664
|
-
|
|
850
|
+
sessionId: this.sessionId,
|
|
665
851
|
payload: {
|
|
666
852
|
requestId,
|
|
667
853
|
statusCode: proxyRes.statusCode ?? 200,
|
|
@@ -698,7 +884,7 @@ export class BridgeSession {
|
|
|
698
884
|
const buf = typeof data === "string" ? Buffer.from(data) : data;
|
|
699
885
|
this.send(createEnvelope({
|
|
700
886
|
type: "tunnel.ws.data",
|
|
701
|
-
|
|
887
|
+
sessionId: this.sessionId,
|
|
702
888
|
payload: {
|
|
703
889
|
requestId,
|
|
704
890
|
data: buf.toString("base64"),
|
|
@@ -711,7 +897,7 @@ export class BridgeSession {
|
|
|
711
897
|
const safeCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
|
|
712
898
|
this.send(createEnvelope({
|
|
713
899
|
type: "tunnel.ws.close",
|
|
714
|
-
|
|
900
|
+
sessionId: this.sessionId,
|
|
715
901
|
payload: {
|
|
716
902
|
requestId,
|
|
717
903
|
code: safeCode,
|
|
@@ -723,7 +909,7 @@ export class BridgeSession {
|
|
|
723
909
|
this.tunnelSockets.delete(requestId);
|
|
724
910
|
this.send(createEnvelope({
|
|
725
911
|
type: "tunnel.ws.close",
|
|
726
|
-
|
|
912
|
+
sessionId: this.sessionId,
|
|
727
913
|
payload: {
|
|
728
914
|
requestId,
|
|
729
915
|
code: 1001,
|
|
@@ -750,7 +936,7 @@ export class BridgeSession {
|
|
|
750
936
|
sendTunnelError(requestId, statusCode, message) {
|
|
751
937
|
this.send(createEnvelope({
|
|
752
938
|
type: "tunnel.response",
|
|
753
|
-
|
|
939
|
+
sessionId: this.sessionId,
|
|
754
940
|
payload: {
|
|
755
941
|
requestId,
|
|
756
942
|
statusCode,
|
|
@@ -764,12 +950,13 @@ export class BridgeSession {
|
|
|
764
950
|
const terminals = [...this.terminals.values()].map((t) => ({
|
|
765
951
|
terminalId: t.id,
|
|
766
952
|
cwd: t.cwd,
|
|
953
|
+
projectName: t.projectName,
|
|
954
|
+
provider: t.provider,
|
|
767
955
|
status: t.status,
|
|
768
|
-
shell: this.options.providerConfig.command,
|
|
769
956
|
}));
|
|
770
957
|
this.send(createEnvelope({
|
|
771
958
|
type: "terminal.list",
|
|
772
|
-
|
|
959
|
+
sessionId: this.sessionId,
|
|
773
960
|
payload: { terminals },
|
|
774
961
|
}));
|
|
775
962
|
}
|
|
@@ -779,20 +966,46 @@ export class BridgeSession {
|
|
|
779
966
|
const payload = msg.payload;
|
|
780
967
|
this.send(createEnvelope({
|
|
781
968
|
type: "terminal.output",
|
|
782
|
-
|
|
969
|
+
sessionId: this.sessionId,
|
|
783
970
|
terminalId,
|
|
784
971
|
seq: msg.seq,
|
|
785
972
|
payload: { ...payload, isReplay: true },
|
|
786
973
|
}));
|
|
787
974
|
}
|
|
788
975
|
}
|
|
789
|
-
async spawnTerminal(terminalId, cwd) {
|
|
976
|
+
async spawnTerminal(terminalId, cwd, providerOverride) {
|
|
790
977
|
const cleanEnv = {};
|
|
791
978
|
for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
|
|
792
979
|
if (v !== undefined)
|
|
793
980
|
cleanEnv[k] = v;
|
|
794
981
|
}
|
|
982
|
+
const hookMarker = this.terminalHookMarker(terminalId);
|
|
983
|
+
// Inject marker so child CLIs' hook commands carry our identity
|
|
984
|
+
cleanEnv["LINKSHELL_ID"] = hookMarker;
|
|
985
|
+
const provider = providerOverride ?? this.options.providerConfig.provider;
|
|
795
986
|
const args = [...this.options.providerConfig.args];
|
|
987
|
+
// Set up hook server for structured status (all supported providers)
|
|
988
|
+
// For "custom" shell, set up hooks for all providers since user may launch any of them
|
|
989
|
+
let hookServer;
|
|
990
|
+
let hookPort;
|
|
991
|
+
const hookConfigPaths = [];
|
|
992
|
+
if (provider === "custom") {
|
|
993
|
+
const result = await this.setupHookServer(terminalId, args, "claude", hookMarker);
|
|
994
|
+
hookServer = result.server;
|
|
995
|
+
hookPort = result.port;
|
|
996
|
+
hookConfigPaths.push(result.configPath);
|
|
997
|
+
// Also set up hooks for other providers (curlCmd already has marker from setupHookServer)
|
|
998
|
+
const curlCmd = `curl -s --connect-timeout 1 --max-time ${Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000)} -X POST "http://127.0.0.1:${result.port}/hook?m=${hookMarker}&lid=$LINKSHELL_ID" -H 'Content-Type: application/json' --data-binary @- || true`;
|
|
999
|
+
hookConfigPaths.push(this.setupCodexHooks(terminalId, curlCmd, hookMarker));
|
|
1000
|
+
hookConfigPaths.push(this.setupGeminiHooks(terminalId, curlCmd, hookMarker));
|
|
1001
|
+
hookConfigPaths.push(this.setupCopilotHooks(terminalId, curlCmd, hookMarker));
|
|
1002
|
+
}
|
|
1003
|
+
else if (provider === "claude" || provider === "codex" || provider === "gemini" || provider === "copilot") {
|
|
1004
|
+
const result = await this.setupHookServer(terminalId, args, provider, hookMarker);
|
|
1005
|
+
hookServer = result.server;
|
|
1006
|
+
hookPort = result.port;
|
|
1007
|
+
hookConfigPaths.push(result.configPath);
|
|
1008
|
+
}
|
|
796
1009
|
const term = {
|
|
797
1010
|
id: terminalId,
|
|
798
1011
|
pty: pty.spawn(this.options.providerConfig.command, args, {
|
|
@@ -803,15 +1016,22 @@ export class BridgeSession {
|
|
|
803
1016
|
env: cleanEnv,
|
|
804
1017
|
}),
|
|
805
1018
|
cwd,
|
|
1019
|
+
projectName: basename(cwd),
|
|
1020
|
+
provider,
|
|
806
1021
|
scrollback: new ScrollbackBuffer(1000),
|
|
807
1022
|
outputSeq: 0,
|
|
1023
|
+
statusSeq: 0,
|
|
808
1024
|
status: "running",
|
|
1025
|
+
hookServer,
|
|
1026
|
+
hookPort,
|
|
1027
|
+
hookMarker,
|
|
1028
|
+
hookConfigPaths,
|
|
809
1029
|
};
|
|
810
1030
|
term.pty.onData((data) => {
|
|
811
1031
|
const seq = term.outputSeq++;
|
|
812
1032
|
const envelope = createEnvelope({
|
|
813
1033
|
type: "terminal.output",
|
|
814
|
-
|
|
1034
|
+
sessionId: this.sessionId,
|
|
815
1035
|
terminalId,
|
|
816
1036
|
seq,
|
|
817
1037
|
payload: {
|
|
@@ -827,9 +1047,10 @@ export class BridgeSession {
|
|
|
827
1047
|
});
|
|
828
1048
|
term.pty.onExit(({ exitCode, signal }) => {
|
|
829
1049
|
term.status = "exited";
|
|
1050
|
+
this.cleanupHookServer(term);
|
|
830
1051
|
this.send(createEnvelope({
|
|
831
1052
|
type: "terminal.exit",
|
|
832
|
-
|
|
1053
|
+
sessionId: this.sessionId,
|
|
833
1054
|
terminalId,
|
|
834
1055
|
payload: { exitCode, signal },
|
|
835
1056
|
}));
|
|
@@ -848,12 +1069,698 @@ export class BridgeSession {
|
|
|
848
1069
|
this.terminals.set(terminalId, term);
|
|
849
1070
|
this.log(`spawned terminal ${terminalId} in ${cwd}`);
|
|
850
1071
|
}
|
|
1072
|
+
async setupHookServer(terminalId, args, provider, marker) {
|
|
1073
|
+
const server = http.createServer((req, res) => {
|
|
1074
|
+
this.log(`hook server received: ${req.method} ${req.url}`);
|
|
1075
|
+
const reqUrl = new URL(req.url ?? "/", "http://localhost");
|
|
1076
|
+
if (req.method !== "POST" || reqUrl.pathname !== "/hook") {
|
|
1077
|
+
res.writeHead(404);
|
|
1078
|
+
res.end();
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
// Check marker — reject events not from our PTY
|
|
1082
|
+
// m must match; lid must match OR be empty (some CLIs don't inherit env vars)
|
|
1083
|
+
const reqMarker = reqUrl.searchParams.get("m");
|
|
1084
|
+
const reqLid = reqUrl.searchParams.get("lid") ?? "";
|
|
1085
|
+
if (reqMarker !== marker || (reqLid !== "" && reqLid !== marker)) {
|
|
1086
|
+
this.log(`ignoring hook event: m=${reqMarker} lid=${reqLid} (expected ${marker})`);
|
|
1087
|
+
res.writeHead(200);
|
|
1088
|
+
res.end("ok");
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
let body = "";
|
|
1092
|
+
let bodyTooLarge = false;
|
|
1093
|
+
req.on("data", (chunk) => {
|
|
1094
|
+
if (bodyTooLarge)
|
|
1095
|
+
return;
|
|
1096
|
+
body += chunk.toString();
|
|
1097
|
+
if (Buffer.byteLength(body, "utf8") > HOOK_BODY_LIMIT) {
|
|
1098
|
+
bodyTooLarge = true;
|
|
1099
|
+
res.writeHead(413);
|
|
1100
|
+
res.end("payload too large");
|
|
1101
|
+
req.destroy();
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
req.on("end", () => {
|
|
1105
|
+
if (bodyTooLarge || res.writableEnded)
|
|
1106
|
+
return;
|
|
1107
|
+
this.log(`hook body (${body.length} bytes): ${body.slice(0, 200)}`);
|
|
1108
|
+
try {
|
|
1109
|
+
const event = JSON.parse(body);
|
|
1110
|
+
const hookName = (event.hook_event_name ?? event.event_name);
|
|
1111
|
+
// PermissionRequest: hold connection, wait for user decision from mobile app
|
|
1112
|
+
if (hookName === "PermissionRequest") {
|
|
1113
|
+
const requestId = `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1114
|
+
const permissionSuggestions = hookPermissionSuggestions(event);
|
|
1115
|
+
const timeout = setTimeout(() => {
|
|
1116
|
+
if (this.resolvePendingPermission(requestId, "deny", "permission.timeout").resolved) {
|
|
1117
|
+
this.log(`permission request ${requestId} timed out`);
|
|
1118
|
+
this.sendPermissionSnapshot(terminalId, "thinking", "permission timed out");
|
|
1119
|
+
}
|
|
1120
|
+
}, PERMISSION_REQUEST_TIMEOUT_MS);
|
|
1121
|
+
this.pendingPermissions.set(requestId, {
|
|
1122
|
+
terminalId,
|
|
1123
|
+
timeout,
|
|
1124
|
+
permissionSuggestions,
|
|
1125
|
+
resolve: (decision) => {
|
|
1126
|
+
if (res.writableEnded)
|
|
1127
|
+
return false;
|
|
1128
|
+
const responseJson = JSON.stringify({
|
|
1129
|
+
hookSpecificOutput: {
|
|
1130
|
+
hookEventName: "PermissionRequest",
|
|
1131
|
+
decision,
|
|
1132
|
+
},
|
|
1133
|
+
});
|
|
1134
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1135
|
+
res.end(responseJson);
|
|
1136
|
+
return true;
|
|
1137
|
+
},
|
|
1138
|
+
});
|
|
1139
|
+
// Send status with requestId so app can route decision back
|
|
1140
|
+
this.handleHookEvent(terminalId, event, provider, requestId);
|
|
1141
|
+
this.sendHookPermissionRequest(terminalId, event, requestId);
|
|
1142
|
+
}
|
|
1143
|
+
else {
|
|
1144
|
+
// All other hooks: respond immediately
|
|
1145
|
+
res.writeHead(200);
|
|
1146
|
+
res.end("ok");
|
|
1147
|
+
this.handleHookEvent(terminalId, event, provider);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
catch (e) {
|
|
1151
|
+
res.writeHead(200);
|
|
1152
|
+
res.end("ok");
|
|
1153
|
+
this.log(`hook parse error: ${e}`);
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
});
|
|
1157
|
+
// Listen on random port — await binding before reading address
|
|
1158
|
+
const port = await new Promise((resolve, reject) => {
|
|
1159
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1160
|
+
const addr = server.address();
|
|
1161
|
+
resolve(addr.port);
|
|
1162
|
+
});
|
|
1163
|
+
server.on("error", reject);
|
|
1164
|
+
});
|
|
1165
|
+
this.log(`hook server for ${terminalId} (${provider}) listening on port ${port}, marker=${marker}`);
|
|
1166
|
+
const curlCmd = `curl -s --connect-timeout 1 --max-time ${Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000)} -X POST "http://127.0.0.1:${port}/hook?m=${marker}&lid=$LINKSHELL_ID" -H 'Content-Type: application/json' --data-binary @- || true`;
|
|
1167
|
+
let configPath;
|
|
1168
|
+
if (provider === "codex") {
|
|
1169
|
+
configPath = this.setupCodexHooks(terminalId, curlCmd, marker);
|
|
1170
|
+
}
|
|
1171
|
+
else if (provider === "gemini") {
|
|
1172
|
+
configPath = this.setupGeminiHooks(terminalId, curlCmd, marker);
|
|
1173
|
+
}
|
|
1174
|
+
else if (provider === "copilot") {
|
|
1175
|
+
configPath = this.setupCopilotHooks(terminalId, curlCmd, marker);
|
|
1176
|
+
}
|
|
1177
|
+
else {
|
|
1178
|
+
// Claude (default)
|
|
1179
|
+
configPath = this.setupClaudeHooks(terminalId, curlCmd, args, marker);
|
|
1180
|
+
}
|
|
1181
|
+
return { server, port, configPath };
|
|
1182
|
+
}
|
|
1183
|
+
refreshAgentPermissionHooks() {
|
|
1184
|
+
const term = this.terminals.get(DEFAULT_TERMINAL_ID);
|
|
1185
|
+
if (!term?.hookPort)
|
|
1186
|
+
return;
|
|
1187
|
+
const marker = term.hookMarker;
|
|
1188
|
+
const curlCmd = `curl -s --connect-timeout 1 --max-time ${Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000)} -X POST "http://127.0.0.1:${term.hookPort}/hook?m=${marker}&lid=$LINKSHELL_ID" -H 'Content-Type: application/json' --data-binary @- || true`;
|
|
1189
|
+
const providers = this.options.agentProvider
|
|
1190
|
+
? [normalizeAgentProvider(this.options.agentProvider)]
|
|
1191
|
+
: detectAvailableProviders();
|
|
1192
|
+
try {
|
|
1193
|
+
for (const provider of providers) {
|
|
1194
|
+
if (provider === "codex") {
|
|
1195
|
+
this.setupCodexHooks(DEFAULT_TERMINAL_ID, curlCmd, marker);
|
|
1196
|
+
}
|
|
1197
|
+
else {
|
|
1198
|
+
// claude, custom
|
|
1199
|
+
this.setupClaudeHooks(DEFAULT_TERMINAL_ID, curlCmd, [], marker);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
catch (error) {
|
|
1204
|
+
this.log(`failed to refresh agent permission hooks: ${error instanceof Error ? error.message : String(error)}`);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
setupClaudeHooks(terminalId, curlCmd, args, marker) {
|
|
1208
|
+
// Write hooks to ~/.claude/settings.json — Claude Code reads hooks from here
|
|
1209
|
+
const claudeDir = join(homedir(), ".claude");
|
|
1210
|
+
if (!existsSync(claudeDir))
|
|
1211
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
1212
|
+
const settingsPath = join(claudeDir, "settings.json");
|
|
1213
|
+
let existing = {};
|
|
1214
|
+
try {
|
|
1215
|
+
existing = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
1216
|
+
}
|
|
1217
|
+
catch { /* doesn't exist yet */ }
|
|
1218
|
+
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
|
|
1219
|
+
const permissionEntry = {
|
|
1220
|
+
matcher: "",
|
|
1221
|
+
hooks: [{
|
|
1222
|
+
type: "command",
|
|
1223
|
+
command: curlCmd,
|
|
1224
|
+
timeout: Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000),
|
|
1225
|
+
}],
|
|
1226
|
+
};
|
|
1227
|
+
const hookEvents = {
|
|
1228
|
+
PreToolUse: hookEntry,
|
|
1229
|
+
PostToolUse: hookEntry,
|
|
1230
|
+
PostToolUseFailure: hookEntry,
|
|
1231
|
+
Stop: hookEntry,
|
|
1232
|
+
PermissionRequest: permissionEntry,
|
|
1233
|
+
UserPromptSubmit: hookEntry,
|
|
1234
|
+
SessionStart: hookEntry,
|
|
1235
|
+
};
|
|
1236
|
+
// Append our entries to existing hooks (first remove stale linkshell entries)
|
|
1237
|
+
const existingHooks = (existing.hooks ?? {});
|
|
1238
|
+
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1239
|
+
existingHooks[eventName] = eventName === "PermissionRequest"
|
|
1240
|
+
? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
|
|
1241
|
+
: withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1242
|
+
}
|
|
1243
|
+
const merged = { ...existing, hooks: existingHooks };
|
|
1244
|
+
writeFileSync(settingsPath, JSON.stringify(merged, null, 2));
|
|
1245
|
+
this.log(`claude hooks appended to ${settingsPath}`);
|
|
1246
|
+
return settingsPath;
|
|
1247
|
+
}
|
|
1248
|
+
setupCodexHooks(terminalId, curlCmd, marker) {
|
|
1249
|
+
// Codex uses ~/.codex/hooks.json — same format as Claude (with matcher)
|
|
1250
|
+
const codexDir = join(homedir(), ".codex");
|
|
1251
|
+
if (!existsSync(codexDir))
|
|
1252
|
+
mkdirSync(codexDir, { recursive: true });
|
|
1253
|
+
// Ensure [features] codex_hooks = true in config.toml
|
|
1254
|
+
const tomlPath = join(codexDir, "config.toml");
|
|
1255
|
+
let tomlContent = "";
|
|
1256
|
+
try {
|
|
1257
|
+
tomlContent = readFileSync(tomlPath, "utf8");
|
|
1258
|
+
}
|
|
1259
|
+
catch { /* doesn't exist yet */ }
|
|
1260
|
+
// Remove top-level codex_hooks (wrong location) and ensure it's under [features]
|
|
1261
|
+
const hasFeatureSection = tomlContent.includes("[features]");
|
|
1262
|
+
const hasCodexHooksUnderFeatures = hasFeatureSection &&
|
|
1263
|
+
/\[features\][^\[]*codex_hooks\s*=\s*true/s.test(tomlContent);
|
|
1264
|
+
if (!hasCodexHooksUnderFeatures) {
|
|
1265
|
+
// Remove any top-level codex_hooks line
|
|
1266
|
+
tomlContent = tomlContent.replace(/^codex_hooks\s*=.*\n?/m, "");
|
|
1267
|
+
if (!tomlContent.includes("[features]")) {
|
|
1268
|
+
tomlContent += `\n[features]\ncodex_hooks = true\n`;
|
|
1269
|
+
}
|
|
1270
|
+
else {
|
|
1271
|
+
tomlContent = tomlContent.replace("[features]", "[features]\ncodex_hooks = true");
|
|
1272
|
+
}
|
|
1273
|
+
writeFileSync(tomlPath, tomlContent);
|
|
1274
|
+
this.log(`enabled codex_hooks under [features] in ${tomlPath}`);
|
|
1275
|
+
}
|
|
1276
|
+
const hooksPath = join(codexDir, "hooks.json");
|
|
1277
|
+
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
|
|
1278
|
+
const permissionEntry = {
|
|
1279
|
+
matcher: "",
|
|
1280
|
+
hooks: [{
|
|
1281
|
+
type: "command",
|
|
1282
|
+
command: curlCmd,
|
|
1283
|
+
timeout: Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000),
|
|
1284
|
+
}],
|
|
1285
|
+
};
|
|
1286
|
+
const hookEvents = {
|
|
1287
|
+
SessionStart: hookEntry,
|
|
1288
|
+
PreToolUse: hookEntry,
|
|
1289
|
+
PostToolUse: hookEntry,
|
|
1290
|
+
UserPromptSubmit: hookEntry,
|
|
1291
|
+
Stop: hookEntry,
|
|
1292
|
+
PermissionRequest: permissionEntry,
|
|
1293
|
+
};
|
|
1294
|
+
// Read existing and append
|
|
1295
|
+
let existing = {};
|
|
1296
|
+
try {
|
|
1297
|
+
existing = JSON.parse(readFileSync(hooksPath, "utf8"));
|
|
1298
|
+
}
|
|
1299
|
+
catch { /* doesn't exist yet */ }
|
|
1300
|
+
const existingHooks = existing.hooks ?? {};
|
|
1301
|
+
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1302
|
+
existingHooks[eventName] = eventName === "PermissionRequest"
|
|
1303
|
+
? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
|
|
1304
|
+
: withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1305
|
+
}
|
|
1306
|
+
writeFileSync(hooksPath, JSON.stringify({ ...existing, hooks: existingHooks }, null, 2));
|
|
1307
|
+
this.log(`codex hooks appended to ${hooksPath}`);
|
|
1308
|
+
return hooksPath;
|
|
1309
|
+
}
|
|
1310
|
+
setupGeminiHooks(terminalId, curlCmd, marker) {
|
|
1311
|
+
// Gemini uses ~/.gemini/settings.json — same format as Claude (with matcher)
|
|
1312
|
+
const geminiDir = join(homedir(), ".gemini");
|
|
1313
|
+
if (!existsSync(geminiDir))
|
|
1314
|
+
mkdirSync(geminiDir, { recursive: true });
|
|
1315
|
+
const settingsPath = join(geminiDir, "settings.json");
|
|
1316
|
+
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5000 }] };
|
|
1317
|
+
const hookEvents = {
|
|
1318
|
+
SessionStart: hookEntry,
|
|
1319
|
+
SessionEnd: hookEntry,
|
|
1320
|
+
BeforeTool: hookEntry,
|
|
1321
|
+
AfterTool: hookEntry,
|
|
1322
|
+
};
|
|
1323
|
+
// Merge with existing settings if present
|
|
1324
|
+
let existing = {};
|
|
1325
|
+
try {
|
|
1326
|
+
existing = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
1327
|
+
}
|
|
1328
|
+
catch { /* doesn't exist yet */ }
|
|
1329
|
+
const existingHooks = (existing.hooks ?? {});
|
|
1330
|
+
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1331
|
+
existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1332
|
+
}
|
|
1333
|
+
existing.hooks = existingHooks;
|
|
1334
|
+
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
|
1335
|
+
this.log(`gemini hooks appended to ${settingsPath}`);
|
|
1336
|
+
return settingsPath;
|
|
1337
|
+
}
|
|
1338
|
+
setupCopilotHooks(terminalId, curlCmd, marker) {
|
|
1339
|
+
// Copilot loads hooks from CWD as hooks.json
|
|
1340
|
+
const cwd = this.terminals.get(terminalId)?.cwd ?? process.cwd();
|
|
1341
|
+
const hooksPath = join(cwd, "hooks.json");
|
|
1342
|
+
const mkHook = () => ({
|
|
1343
|
+
type: "command",
|
|
1344
|
+
bash: curlCmd,
|
|
1345
|
+
timeoutSec: 30,
|
|
1346
|
+
});
|
|
1347
|
+
const hookEvents = {
|
|
1348
|
+
sessionStart: mkHook(),
|
|
1349
|
+
sessionEnd: mkHook(),
|
|
1350
|
+
userPromptSubmitted: mkHook(),
|
|
1351
|
+
preToolUse: mkHook(),
|
|
1352
|
+
postToolUse: mkHook(),
|
|
1353
|
+
errorOccurred: mkHook(),
|
|
1354
|
+
};
|
|
1355
|
+
// Read existing and append
|
|
1356
|
+
let existing = {};
|
|
1357
|
+
try {
|
|
1358
|
+
existing = JSON.parse(readFileSync(hooksPath, "utf8"));
|
|
1359
|
+
}
|
|
1360
|
+
catch { /* doesn't exist yet */ }
|
|
1361
|
+
const existingHooks = existing.hooks ?? {};
|
|
1362
|
+
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1363
|
+
existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1364
|
+
}
|
|
1365
|
+
writeFileSync(hooksPath, JSON.stringify({ version: 1, hooks: existingHooks }, null, 2));
|
|
1366
|
+
this.log(`copilot hooks appended to ${hooksPath}`);
|
|
1367
|
+
return hooksPath;
|
|
1368
|
+
}
|
|
1369
|
+
handleHookEvent(terminalId, event, provider, permissionRequestId) {
|
|
1370
|
+
const rawHookName = (event.hook_event_name ?? event.event_name);
|
|
1371
|
+
if (!rawHookName)
|
|
1372
|
+
return;
|
|
1373
|
+
// Auto-detect provider from hook event fields
|
|
1374
|
+
const hookTerm = this.terminals.get(terminalId);
|
|
1375
|
+
let detectedProvider = provider;
|
|
1376
|
+
// Always detect from transcript_path (most reliable), regardless of current provider
|
|
1377
|
+
const transcriptPath = typeof event.transcript_path === "string" ? event.transcript_path : "";
|
|
1378
|
+
if (transcriptPath.includes(".claude/")) {
|
|
1379
|
+
detectedProvider = "claude";
|
|
1380
|
+
}
|
|
1381
|
+
else if (transcriptPath.includes(".gemini/")) {
|
|
1382
|
+
detectedProvider = "gemini";
|
|
1383
|
+
}
|
|
1384
|
+
else if (transcriptPath.includes(".codex/")) {
|
|
1385
|
+
detectedProvider = "codex";
|
|
1386
|
+
}
|
|
1387
|
+
else if (hookTerm?.provider === "custom") {
|
|
1388
|
+
// Fallback heuristics only when provider is still unknown
|
|
1389
|
+
if (event.model && typeof event.model === "string" && /^(gpt|o[0-9]|codex)/i.test(event.model)) {
|
|
1390
|
+
detectedProvider = "codex";
|
|
1391
|
+
}
|
|
1392
|
+
else if (event.session_id && !transcriptPath) {
|
|
1393
|
+
detectedProvider = "codex";
|
|
1394
|
+
}
|
|
1395
|
+
else if (/^(Before|After)(Tool)$|^Session(Start|End)$/.test(rawHookName)) {
|
|
1396
|
+
detectedProvider = "gemini";
|
|
1397
|
+
}
|
|
1398
|
+
else if (/^(pre|post)ToolUse$|^session(Start|End)$|^userPromptSubmitted$|^errorOccurred$/.test(rawHookName)) {
|
|
1399
|
+
detectedProvider = "copilot";
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
if (hookTerm && detectedProvider !== hookTerm.provider) {
|
|
1403
|
+
const wasCustom = hookTerm.provider === "custom";
|
|
1404
|
+
hookTerm.provider = detectedProvider;
|
|
1405
|
+
this.log(`${wasCustom ? "detected" : "provider switched"} provider for ${terminalId}: ${detectedProvider}`);
|
|
1406
|
+
this.permissionStacks.delete(terminalId);
|
|
1407
|
+
this.sendTerminalList();
|
|
1408
|
+
}
|
|
1409
|
+
// Normalize hook event names from different providers to unified names
|
|
1410
|
+
const hookName = this.normalizeHookName(rawHookName, detectedProvider);
|
|
1411
|
+
if (!hookName)
|
|
1412
|
+
return;
|
|
1413
|
+
let phase;
|
|
1414
|
+
let toolName;
|
|
1415
|
+
let toolInput;
|
|
1416
|
+
let permissionRequest;
|
|
1417
|
+
let summary;
|
|
1418
|
+
switch (hookName) {
|
|
1419
|
+
case "PreToolUse":
|
|
1420
|
+
phase = "tool_use";
|
|
1421
|
+
toolName = (event.tool_name ?? event.toolName);
|
|
1422
|
+
if (event.tool_input && typeof event.tool_input === "object") {
|
|
1423
|
+
const input = event.tool_input;
|
|
1424
|
+
toolInput = JSON.stringify(input).slice(0, 200);
|
|
1425
|
+
}
|
|
1426
|
+
else if (event.toolInput && typeof event.toolInput === "object") {
|
|
1427
|
+
toolInput = JSON.stringify(event.toolInput).slice(0, 200);
|
|
1428
|
+
}
|
|
1429
|
+
break;
|
|
1430
|
+
case "PostToolUse":
|
|
1431
|
+
phase = "thinking";
|
|
1432
|
+
toolName = (event.tool_name ?? event.toolName);
|
|
1433
|
+
// Pop permission stack + auto-resolve pending HTTP connection
|
|
1434
|
+
{
|
|
1435
|
+
const stack = this.permissionStacks.get(terminalId);
|
|
1436
|
+
if (stack && stack.length > 0) {
|
|
1437
|
+
const popped = stack.pop();
|
|
1438
|
+
if (popped)
|
|
1439
|
+
this.autoResolvePending(popped.requestId);
|
|
1440
|
+
if (stack.length === 0)
|
|
1441
|
+
this.permissionStacks.delete(terminalId);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
break;
|
|
1445
|
+
case "PostToolUseFailure":
|
|
1446
|
+
phase = "error";
|
|
1447
|
+
toolName = (event.tool_name ?? event.toolName);
|
|
1448
|
+
{
|
|
1449
|
+
const stack = this.permissionStacks.get(terminalId);
|
|
1450
|
+
if (stack && stack.length > 0) {
|
|
1451
|
+
const popped = stack.pop();
|
|
1452
|
+
if (popped)
|
|
1453
|
+
this.autoResolvePending(popped.requestId);
|
|
1454
|
+
if (stack.length === 0)
|
|
1455
|
+
this.permissionStacks.delete(terminalId);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
break;
|
|
1459
|
+
case "Stop":
|
|
1460
|
+
phase = "idle";
|
|
1461
|
+
if (event.stop_reason)
|
|
1462
|
+
summary = String(event.stop_reason);
|
|
1463
|
+
this.drainPendingPermissions(terminalId);
|
|
1464
|
+
this.permissionStacks.delete(terminalId);
|
|
1465
|
+
// Reset provider to "custom" when a CLI session ends inside a custom shell
|
|
1466
|
+
if (hookTerm && this.options.providerConfig.provider === "custom") {
|
|
1467
|
+
hookTerm.provider = "custom";
|
|
1468
|
+
this.log(`provider reset to custom for ${terminalId} (CLI session ended)`);
|
|
1469
|
+
this.sendTerminalList();
|
|
1470
|
+
}
|
|
1471
|
+
break;
|
|
1472
|
+
case "PermissionRequest":
|
|
1473
|
+
phase = "waiting";
|
|
1474
|
+
toolName = (event.tool_name ?? event.toolName);
|
|
1475
|
+
if (event.tool_input && typeof event.tool_input === "object") {
|
|
1476
|
+
const input = event.tool_input;
|
|
1477
|
+
permissionRequest = JSON.stringify(input).slice(0, 300);
|
|
1478
|
+
}
|
|
1479
|
+
else if (event.toolInput && typeof event.toolInput === "object") {
|
|
1480
|
+
permissionRequest = JSON.stringify(event.toolInput).slice(0, 300);
|
|
1481
|
+
}
|
|
1482
|
+
// Push to permission stack (use requestId from hook server if available)
|
|
1483
|
+
{
|
|
1484
|
+
const reqId = permissionRequestId ?? `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1485
|
+
if (!this.permissionStacks.has(terminalId)) {
|
|
1486
|
+
this.permissionStacks.set(terminalId, []);
|
|
1487
|
+
}
|
|
1488
|
+
this.permissionStacks.get(terminalId).push({
|
|
1489
|
+
requestId: reqId,
|
|
1490
|
+
toolName: toolName ?? "unknown",
|
|
1491
|
+
toolInput: toolInput ?? (permissionRequest ?? ""),
|
|
1492
|
+
permissionRequest: permissionRequest ?? "",
|
|
1493
|
+
timestamp: Date.now(),
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
break;
|
|
1497
|
+
case "SessionStart":
|
|
1498
|
+
phase = "idle";
|
|
1499
|
+
summary = "session started";
|
|
1500
|
+
break;
|
|
1501
|
+
case "UserPromptSubmit":
|
|
1502
|
+
phase = "thinking";
|
|
1503
|
+
this.drainPendingPermissions(terminalId);
|
|
1504
|
+
this.permissionStacks.delete(terminalId);
|
|
1505
|
+
break;
|
|
1506
|
+
default:
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
this.log(`hook event [${provider}]: ${rawHookName} → ${hookName} → phase=${phase} tool=${toolName ?? "none"}`);
|
|
1510
|
+
// Build topPermission from stack
|
|
1511
|
+
const stack = this.permissionStacks.get(terminalId);
|
|
1512
|
+
const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
|
|
1513
|
+
const pendingPermissionCount = stack?.length ?? 0;
|
|
1514
|
+
// Increment statusSeq for ordering
|
|
1515
|
+
const term = this.terminals.get(terminalId);
|
|
1516
|
+
const seq = term ? term.statusSeq++ : 0;
|
|
1517
|
+
this.send(createEnvelope({
|
|
1518
|
+
type: "terminal.status",
|
|
1519
|
+
sessionId: this.sessionId,
|
|
1520
|
+
terminalId,
|
|
1521
|
+
payload: {
|
|
1522
|
+
phase,
|
|
1523
|
+
seq,
|
|
1524
|
+
...(toolName && { toolName }),
|
|
1525
|
+
...(toolInput && { toolInput }),
|
|
1526
|
+
...(permissionRequest && { permissionRequest }),
|
|
1527
|
+
...(summary && { summary }),
|
|
1528
|
+
...(topPermission && { topPermission }),
|
|
1529
|
+
...(pendingPermissionCount > 0 && { pendingPermissionCount }),
|
|
1530
|
+
},
|
|
1531
|
+
}));
|
|
1532
|
+
}
|
|
1533
|
+
sendHookPermissionRequest(terminalId, event, requestId) {
|
|
1534
|
+
const toolName = (event.tool_name ?? event.toolName);
|
|
1535
|
+
const toolInput = stringifyHookInput(event.tool_input ?? event.toolInput);
|
|
1536
|
+
const suggestions = hookPermissionSuggestions(event);
|
|
1537
|
+
const context = typeof event.permission_prompt === "string"
|
|
1538
|
+
? event.permission_prompt
|
|
1539
|
+
: typeof event.message === "string"
|
|
1540
|
+
? event.message
|
|
1541
|
+
: undefined;
|
|
1542
|
+
this.send(createEnvelope({
|
|
1543
|
+
type: "agent.permission.request",
|
|
1544
|
+
sessionId: this.sessionId,
|
|
1545
|
+
terminalId,
|
|
1546
|
+
payload: {
|
|
1547
|
+
requestId,
|
|
1548
|
+
toolName,
|
|
1549
|
+
toolInput,
|
|
1550
|
+
context,
|
|
1551
|
+
options: hookPermissionOptions(suggestions),
|
|
1552
|
+
},
|
|
1553
|
+
}));
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Normalize hook event names from different CLI providers to unified internal names.
|
|
1557
|
+
* Claude: PascalCase (PreToolUse, PostToolUse, Stop, PermissionRequest)
|
|
1558
|
+
* Codex: camelCase (preToolUse, postToolUse, sessionStart)
|
|
1559
|
+
* Gemini: PascalCase but different names (BeforeTool, AfterTool, BeforeSubmitPrompt)
|
|
1560
|
+
*/
|
|
1561
|
+
normalizeHookName(rawName, provider) {
|
|
1562
|
+
// Claude events — already in our canonical format
|
|
1563
|
+
if (provider === "claude") {
|
|
1564
|
+
return rawName;
|
|
1565
|
+
}
|
|
1566
|
+
// Codex events — same as Claude (PascalCase)
|
|
1567
|
+
if (provider === "codex") {
|
|
1568
|
+
switch (rawName) {
|
|
1569
|
+
case "PreToolUse":
|
|
1570
|
+
case "preToolUse": return "PreToolUse";
|
|
1571
|
+
case "PostToolUse":
|
|
1572
|
+
case "postToolUse": return "PostToolUse";
|
|
1573
|
+
case "SessionStart":
|
|
1574
|
+
case "sessionStart": return "SessionStart";
|
|
1575
|
+
case "UserPromptSubmit": return "UserPromptSubmit";
|
|
1576
|
+
case "PermissionRequest": return "PermissionRequest";
|
|
1577
|
+
case "Stop": return "Stop";
|
|
1578
|
+
default: return undefined;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
// Gemini events
|
|
1582
|
+
if (provider === "gemini") {
|
|
1583
|
+
switch (rawName) {
|
|
1584
|
+
case "BeforeTool": return "PreToolUse";
|
|
1585
|
+
case "AfterTool": return "PostToolUse";
|
|
1586
|
+
case "SessionStart": return "SessionStart";
|
|
1587
|
+
case "SessionEnd": return "Stop";
|
|
1588
|
+
default: return undefined;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
// Copilot events (camelCase)
|
|
1592
|
+
if (provider === "copilot") {
|
|
1593
|
+
switch (rawName) {
|
|
1594
|
+
case "preToolUse": return "PreToolUse";
|
|
1595
|
+
case "postToolUse": return "PostToolUse";
|
|
1596
|
+
case "sessionStart": return "SessionStart";
|
|
1597
|
+
case "sessionEnd": return "Stop";
|
|
1598
|
+
case "userPromptSubmitted": return "UserPromptSubmit";
|
|
1599
|
+
case "errorOccurred": return "PostToolUseFailure";
|
|
1600
|
+
default: return undefined;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
// Unknown provider — try all known formats
|
|
1604
|
+
// This handles "custom" shell where any provider might be launched
|
|
1605
|
+
const allProviders = ["claude", "codex", "gemini", "copilot"];
|
|
1606
|
+
for (const p of allProviders) {
|
|
1607
|
+
const result = this.normalizeHookName(rawName, p);
|
|
1608
|
+
if (result)
|
|
1609
|
+
return result;
|
|
1610
|
+
}
|
|
1611
|
+
return undefined;
|
|
1612
|
+
}
|
|
1613
|
+
/** Auto-resolve a single pending permission (user acted in terminal) */
|
|
1614
|
+
autoResolvePending(requestId) {
|
|
1615
|
+
if (this.resolvePendingPermission(requestId, "allow", "terminal.auto").resolved) {
|
|
1616
|
+
this.log(`auto-resolved pending permission ${requestId} (user acted in terminal)`);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
/** Drain all pending permissions for a terminal (session ended, stop, etc.) */
|
|
1620
|
+
drainPendingPermissions(terminalId) {
|
|
1621
|
+
const stack = this.permissionStacks.get(terminalId);
|
|
1622
|
+
if (!stack)
|
|
1623
|
+
return;
|
|
1624
|
+
for (const entry of [...stack]) {
|
|
1625
|
+
if (this.resolvePendingPermission(entry.requestId, "deny", "terminal.drain").resolved) {
|
|
1626
|
+
this.log(`drained pending permission ${entry.requestId}`);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
resolvePendingPermission(requestId, choice, source = "unknown") {
|
|
1631
|
+
const pending = this.pendingPermissions.get(requestId);
|
|
1632
|
+
const outcome = typeof choice === "string" ? choice : choice.outcome;
|
|
1633
|
+
const optionId = typeof choice === "string" ? undefined : choice.optionId;
|
|
1634
|
+
if (!pending) {
|
|
1635
|
+
this.log(`no pending permission for ${requestId} via ${source}: ${outcome}:${optionId ?? "default"}`);
|
|
1636
|
+
return { resolved: false, delivered: false };
|
|
1637
|
+
}
|
|
1638
|
+
this.pendingPermissions.delete(requestId);
|
|
1639
|
+
clearTimeout(pending.timeout);
|
|
1640
|
+
const delivered = pending.resolve(this.formatHookPermissionDecision(pending, choice));
|
|
1641
|
+
const stack = this.permissionStacks.get(pending.terminalId);
|
|
1642
|
+
if (stack) {
|
|
1643
|
+
const idx = stack.findIndex((entry) => entry.requestId === requestId);
|
|
1644
|
+
if (idx >= 0)
|
|
1645
|
+
stack.splice(idx, 1);
|
|
1646
|
+
if (stack.length === 0)
|
|
1647
|
+
this.permissionStacks.delete(pending.terminalId);
|
|
1648
|
+
}
|
|
1649
|
+
this.log(`resolved permission ${requestId} via ${source}: ${outcome}:${optionId ?? "default"} delivered=${delivered}`);
|
|
1650
|
+
this.sendPermissionSnapshot(pending.terminalId, "thinking", outcome === "allow" ? "permission allowed" : "permission denied", { requestId, outcome, source, delivered });
|
|
1651
|
+
return { resolved: true, delivered };
|
|
1652
|
+
}
|
|
1653
|
+
formatHookPermissionDecision(permission, choice) {
|
|
1654
|
+
const outcome = typeof choice === "string" ? choice : choice.outcome;
|
|
1655
|
+
const optionId = typeof choice === "string" ? undefined : choice.optionId;
|
|
1656
|
+
if (outcome === "allow") {
|
|
1657
|
+
return {
|
|
1658
|
+
behavior: "allow",
|
|
1659
|
+
...(optionId === "allow_always" && permission.permissionSuggestions.length > 0
|
|
1660
|
+
? { updatedPermissions: permission.permissionSuggestions }
|
|
1661
|
+
: {}),
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
return {
|
|
1665
|
+
behavior: "deny",
|
|
1666
|
+
message: outcome === "cancelled" ? "Permission request cancelled." : "Permission denied by user.",
|
|
1667
|
+
};
|
|
1668
|
+
}
|
|
1669
|
+
sendPermissionSnapshot(terminalId, phase, summary, permissionResolution) {
|
|
1670
|
+
const stack = this.permissionStacks.get(terminalId);
|
|
1671
|
+
const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
|
|
1672
|
+
const pendingPermissionCount = stack?.length ?? 0;
|
|
1673
|
+
const term = this.terminals.get(terminalId);
|
|
1674
|
+
const seq = term ? term.statusSeq++ : 0;
|
|
1675
|
+
this.send(createEnvelope({
|
|
1676
|
+
type: "terminal.status",
|
|
1677
|
+
sessionId: this.sessionId,
|
|
1678
|
+
terminalId,
|
|
1679
|
+
payload: {
|
|
1680
|
+
phase,
|
|
1681
|
+
seq,
|
|
1682
|
+
...(summary && { summary }),
|
|
1683
|
+
...(permissionResolution && { permissionResolution }),
|
|
1684
|
+
...(topPermission && { topPermission }),
|
|
1685
|
+
...(pendingPermissionCount > 0 && { pendingPermissionCount }),
|
|
1686
|
+
},
|
|
1687
|
+
}));
|
|
1688
|
+
}
|
|
1689
|
+
cleanupHookServer(term) {
|
|
1690
|
+
// Drain any pending permission requests for this terminal
|
|
1691
|
+
this.drainPendingPermissions(term.id);
|
|
1692
|
+
if (term.hookServer) {
|
|
1693
|
+
term.hookServer.close();
|
|
1694
|
+
term.hookServer = undefined;
|
|
1695
|
+
this.log(`hook server closed for ${term.id}`);
|
|
1696
|
+
}
|
|
1697
|
+
const marker = term.hookMarker;
|
|
1698
|
+
for (const configPath of term.hookConfigPaths) {
|
|
1699
|
+
try {
|
|
1700
|
+
// Copilot: per-instance file — just delete it
|
|
1701
|
+
if (configPath.includes(`linkshell-${marker}`)) {
|
|
1702
|
+
if (existsSync(configPath)) {
|
|
1703
|
+
unlinkSync(configPath);
|
|
1704
|
+
this.log(`removed copilot hook file ${configPath}`);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
else {
|
|
1708
|
+
// Claude/Codex/Gemini: remove our entries from the shared config
|
|
1709
|
+
this.removeHookEntries(configPath, marker);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
catch { /* ignore */ }
|
|
1713
|
+
}
|
|
1714
|
+
term.hookConfigPaths = [];
|
|
1715
|
+
}
|
|
1716
|
+
/** Remove hook entries containing our marker from a JSON config file */
|
|
1717
|
+
removeHookEntries(configPath, marker) {
|
|
1718
|
+
if (!existsSync(configPath))
|
|
1719
|
+
return;
|
|
1720
|
+
try {
|
|
1721
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1722
|
+
const hooks = raw.hooks;
|
|
1723
|
+
if (!hooks)
|
|
1724
|
+
return;
|
|
1725
|
+
let changed = false;
|
|
1726
|
+
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
1727
|
+
if (!Array.isArray(entries))
|
|
1728
|
+
continue;
|
|
1729
|
+
const filtered = entries.filter((entry) => {
|
|
1730
|
+
const str = JSON.stringify(entry);
|
|
1731
|
+
return !str.includes(marker);
|
|
1732
|
+
});
|
|
1733
|
+
if (filtered.length !== entries.length) {
|
|
1734
|
+
changed = true;
|
|
1735
|
+
if (filtered.length === 0) {
|
|
1736
|
+
delete hooks[eventName];
|
|
1737
|
+
}
|
|
1738
|
+
else {
|
|
1739
|
+
hooks[eventName] = filtered;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
if (changed) {
|
|
1744
|
+
// If no hooks left, remove the hooks key entirely
|
|
1745
|
+
if (Object.keys(hooks).length === 0) {
|
|
1746
|
+
delete raw.hooks;
|
|
1747
|
+
}
|
|
1748
|
+
writeFileSync(configPath, JSON.stringify(raw, null, 2));
|
|
1749
|
+
this.log(`removed our hook entries from ${configPath}`);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
catch { /* ignore parse errors */ }
|
|
1753
|
+
}
|
|
851
1754
|
send(message) {
|
|
852
1755
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
853
1756
|
return;
|
|
854
1757
|
}
|
|
855
1758
|
const machineId = this.machineIdentity?.machineId;
|
|
856
|
-
const enriched = machineId && message.type
|
|
1759
|
+
const enriched = machineId && (message.type === "terminal.status" ||
|
|
1760
|
+
message.type === "agent.capabilities" ||
|
|
1761
|
+
message.type === "agent.snapshot" ||
|
|
1762
|
+
message.type === "agent.v2.capabilities" ||
|
|
1763
|
+
message.type === "agent.v2.snapshot")
|
|
857
1764
|
? {
|
|
858
1765
|
...message,
|
|
859
1766
|
payload: {
|
|
@@ -868,8 +1775,8 @@ export class BridgeSession {
|
|
|
868
1775
|
this.stopHeartbeat();
|
|
869
1776
|
this.heartbeatTimer = setInterval(() => {
|
|
870
1777
|
this.send(createEnvelope({
|
|
871
|
-
type: "
|
|
872
|
-
|
|
1778
|
+
type: "session.heartbeat",
|
|
1779
|
+
sessionId: this.sessionId,
|
|
873
1780
|
payload: { ts: Date.now() },
|
|
874
1781
|
}));
|
|
875
1782
|
}, HEARTBEAT_INTERVAL);
|
|
@@ -885,7 +1792,7 @@ export class BridgeSession {
|
|
|
885
1792
|
this.log("screen sharing not enabled (use --screen)");
|
|
886
1793
|
this.send(createEnvelope({
|
|
887
1794
|
type: "screen.status",
|
|
888
|
-
|
|
1795
|
+
sessionId: this.sessionId,
|
|
889
1796
|
payload: { active: false, mode: "off", error: "Screen sharing not enabled on host. Start CLI with --screen flag." },
|
|
890
1797
|
}));
|
|
891
1798
|
return;
|
|
@@ -896,7 +1803,7 @@ export class BridgeSession {
|
|
|
896
1803
|
if (ScreenShare.isAvailable()) {
|
|
897
1804
|
this.log("WebRTC available, starting screen share");
|
|
898
1805
|
this.screenShare = new ScreenShare({
|
|
899
|
-
|
|
1806
|
+
sessionId: this.sessionId,
|
|
900
1807
|
fps,
|
|
901
1808
|
quality,
|
|
902
1809
|
scale,
|
|
@@ -919,7 +1826,7 @@ export class BridgeSession {
|
|
|
919
1826
|
fps,
|
|
920
1827
|
quality,
|
|
921
1828
|
scale,
|
|
922
|
-
|
|
1829
|
+
sessionId: this.sessionId,
|
|
923
1830
|
onFrame: (envelope) => this.send(envelope),
|
|
924
1831
|
onStatus: (envelope) => this.send(envelope),
|
|
925
1832
|
});
|
|
@@ -966,6 +1873,8 @@ export class BridgeSession {
|
|
|
966
1873
|
this.exited = true;
|
|
967
1874
|
this.stopHeartbeat();
|
|
968
1875
|
this.stopScreenCapture();
|
|
1876
|
+
this.agentSession?.stop();
|
|
1877
|
+
this.agentSession = undefined;
|
|
969
1878
|
this.agentWorkspace?.stop();
|
|
970
1879
|
this.agentWorkspace = undefined;
|
|
971
1880
|
this.keepAwake?.stop();
|
|
@@ -982,6 +1891,7 @@ export class BridgeSession {
|
|
|
982
1891
|
}
|
|
983
1892
|
this.tunnelSockets.clear();
|
|
984
1893
|
for (const term of this.terminals.values()) {
|
|
1894
|
+
this.cleanupHookServer(term);
|
|
985
1895
|
if (term.status === "running")
|
|
986
1896
|
term.pty.kill();
|
|
987
1897
|
}
|