linkshell-cli 0.2.125 → 0.3.0
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 +2 -20
- package/dist/cli/src/commands/setup.js.map +1 -1
- package/dist/cli/src/index.js +26 -28
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/providers.d.ts +7 -3
- package/dist/cli/src/providers.js +19 -76
- package/dist/cli/src/providers.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-session.d.ts +1 -1
- package/dist/cli/src/runtime/acp/agent-session.js +4 -4
- package/dist/cli/src/runtime/acp/agent-session.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace.js +17 -62
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +1 -31
- package/dist/cli/src/runtime/bridge-session.js +57 -993
- 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 +3743 -5570
- package/dist/shared-protocol/src/index.js +19 -84
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +12 -12
- package/src/commands/setup.ts +5 -31
- package/src/index.ts +29 -34
- package/src/providers.ts +26 -108
- package/src/runtime/acp/agent-workspace.ts +18 -63
- package/src/runtime/bridge-session.ts +57 -1091
- package/src/runtime/screen-fallback.ts +5 -5
- package/src/runtime/screen-share.ts +8 -8
- package/src/types/linkshell-gateway.d.ts +18 -0
- package/dist/cli/src/runtime/acp-relay.d.ts +0 -23
- package/dist/cli/src/runtime/acp-relay.js +0 -73
- package/dist/cli/src/runtime/acp-relay.js.map +0 -1
- package/src/runtime/acp/agent-session.ts +0 -1180
|
@@ -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,
|
|
5
|
+
import { writeFileSync, readFileSync, readdirSync, statSync, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
|
-
import { join,
|
|
7
|
+
import { join, 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,7 +13,6 @@ 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";
|
|
17
16
|
import { AgentWorkspaceProxy } from "./acp/agent-workspace.js";
|
|
18
17
|
import { detectAvailableProviders } from "./acp/provider-resolver.js";
|
|
19
18
|
const HEARTBEAT_INTERVAL = 15_000;
|
|
@@ -21,113 +20,6 @@ const RECONNECT_BASE_DELAY = 1_000;
|
|
|
21
20
|
const RECONNECT_MAX_DELAY = 30_000;
|
|
22
21
|
const RECONNECT_MAX_ATTEMPTS = 20;
|
|
23
22
|
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
|
-
}
|
|
131
23
|
function getPairingGatewayParam(gatewayHttpUrl) {
|
|
132
24
|
try {
|
|
133
25
|
const url = new URL(gatewayHttpUrl);
|
|
@@ -194,36 +86,26 @@ export class BridgeSession {
|
|
|
194
86
|
sessionId = "";
|
|
195
87
|
exited = false;
|
|
196
88
|
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)}`;
|
|
201
89
|
screenCapture;
|
|
202
90
|
screenShare;
|
|
203
91
|
tunnelSockets = new Map();
|
|
204
92
|
keepAwake;
|
|
205
|
-
agentSession;
|
|
206
93
|
agentWorkspace;
|
|
207
94
|
machineIdentity;
|
|
208
95
|
constructor(options) {
|
|
209
96
|
this.options = options;
|
|
210
|
-
this.sessionId = options.
|
|
97
|
+
this.sessionId = options.hostDeviceId ?? "";
|
|
211
98
|
}
|
|
212
99
|
log(msg) {
|
|
213
100
|
if (this.options.verbose) {
|
|
214
101
|
process.stderr.write(`[bridge:verbose] ${msg}\n`);
|
|
215
102
|
}
|
|
216
103
|
}
|
|
217
|
-
terminalHookMarker(terminalId) {
|
|
218
|
-
const safeTerminalId = terminalId.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
|
219
|
-
return `${this.hookMarker}-${safeTerminalId}`;
|
|
220
|
-
}
|
|
221
104
|
async start() {
|
|
222
|
-
this.log(`starting
|
|
105
|
+
this.log(`starting device bridge (gateway=${this.options.gatewayUrl}, terminal=shell)`);
|
|
223
106
|
this.machineIdentity = loadOrCreateMachineIdentity();
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
107
|
+
this.sessionId ||= this.machineIdentity.machineId;
|
|
108
|
+
await this.createPairing();
|
|
227
109
|
if (this.options.keepAwake) {
|
|
228
110
|
this.keepAwake = startKeepAwake();
|
|
229
111
|
}
|
|
@@ -231,21 +113,17 @@ export class BridgeSession {
|
|
|
231
113
|
process.stderr.write("[bridge] keep-awake disabled\n");
|
|
232
114
|
}
|
|
233
115
|
if (this.options.agentUi) {
|
|
234
|
-
process.env.LINKSHELL_ID = this.terminalHookMarker(DEFAULT_TERMINAL_ID);
|
|
235
116
|
const availableProviders = this.options.agentProvider
|
|
236
117
|
? [normalizeAgentProvider(this.options.agentProvider)]
|
|
237
118
|
: detectAvailableProviders();
|
|
238
119
|
const agentOptions = {
|
|
239
|
-
|
|
120
|
+
hostDeviceId: this.sessionId,
|
|
240
121
|
cwd: process.cwd(),
|
|
241
122
|
availableProviders,
|
|
242
123
|
command: this.options.agentCommand,
|
|
243
124
|
verbose: this.options.verbose,
|
|
244
125
|
send: (envelope) => this.send(envelope),
|
|
245
126
|
};
|
|
246
|
-
this.agentSession = new AgentSessionProxy({
|
|
247
|
-
...agentOptions,
|
|
248
|
-
});
|
|
249
127
|
this.agentWorkspace = new AgentWorkspaceProxy({
|
|
250
128
|
...agentOptions,
|
|
251
129
|
});
|
|
@@ -263,19 +141,19 @@ export class BridgeSession {
|
|
|
263
141
|
const res = await fetch(`${this.options.gatewayHttpUrl}/pairings`, {
|
|
264
142
|
method: "POST",
|
|
265
143
|
headers,
|
|
266
|
-
body: JSON.stringify({}),
|
|
144
|
+
body: JSON.stringify({ hostDeviceId: this.sessionId }),
|
|
267
145
|
});
|
|
268
146
|
if (!res.ok) {
|
|
269
147
|
throw new Error(`Failed to create pairing: ${res.status}`);
|
|
270
148
|
}
|
|
271
149
|
const body = (await res.json());
|
|
272
|
-
this.sessionId = body.
|
|
150
|
+
this.sessionId = body.hostDeviceId;
|
|
273
151
|
const pairingGateway = resolvePairingGateway(this.options.gatewayHttpUrl, this.options.pairingGateway);
|
|
274
152
|
const deepLink = pairingGateway
|
|
275
153
|
? `linkshell://pair?code=${body.pairingCode}&gateway=${encodeURIComponent(pairingGateway)}`
|
|
276
154
|
: `linkshell://pair?code=${body.pairingCode}`;
|
|
277
155
|
process.stderr.write(`\n \x1b[1mPairing code: \x1b[36m${body.pairingCode}\x1b[0m\n`);
|
|
278
|
-
process.stderr.write(`
|
|
156
|
+
process.stderr.write(` Host device: ${body.hostDeviceId}\n`);
|
|
279
157
|
process.stderr.write(` Expires: ${body.expiresAt}\n\n`);
|
|
280
158
|
if (!pairingGateway) {
|
|
281
159
|
process.stderr.write(" Note: QR will use the app's current gateway because the CLI is pointed at a local-only address.\n\n");
|
|
@@ -325,7 +203,7 @@ export class BridgeSession {
|
|
|
325
203
|
return;
|
|
326
204
|
}
|
|
327
205
|
const url = new URL(this.options.gatewayUrl);
|
|
328
|
-
url.searchParams.set("
|
|
206
|
+
url.searchParams.set("hostDeviceId", this.sessionId);
|
|
329
207
|
url.searchParams.set("role", "host");
|
|
330
208
|
const authToken = await this.resolveAuthToken();
|
|
331
209
|
if (authToken) {
|
|
@@ -339,18 +217,22 @@ export class BridgeSession {
|
|
|
339
217
|
this.reconnectAttempts = 0;
|
|
340
218
|
this.reconnecting = false;
|
|
341
219
|
this.send(createEnvelope({
|
|
342
|
-
type: "
|
|
343
|
-
|
|
220
|
+
type: "device.connect",
|
|
221
|
+
hostDeviceId: this.sessionId,
|
|
344
222
|
payload: {
|
|
345
223
|
role: "host",
|
|
346
224
|
clientName: this.options.clientName,
|
|
347
|
-
provider: this.options.providerConfig.provider,
|
|
348
225
|
protocolVersion: PROTOCOL_VERSION,
|
|
349
226
|
machineId: this.machineIdentity?.machineId,
|
|
350
227
|
hostname: this.options.hostname || hostname(),
|
|
351
228
|
platform: platform(),
|
|
352
229
|
cwd: process.cwd(),
|
|
353
|
-
|
|
230
|
+
capabilities: [
|
|
231
|
+
"terminal",
|
|
232
|
+
...(this.options.agentUi ? ["agent-ui"] : []),
|
|
233
|
+
...(this.options.screen ? ["screen"] : []),
|
|
234
|
+
"tunnel",
|
|
235
|
+
],
|
|
354
236
|
},
|
|
355
237
|
}));
|
|
356
238
|
this.startHeartbeat();
|
|
@@ -401,33 +283,33 @@ export class BridgeSession {
|
|
|
401
283
|
}
|
|
402
284
|
case "terminal.spawn": {
|
|
403
285
|
const p = parseTypedPayload("terminal.spawn", envelope.payload);
|
|
404
|
-
const normalizedCwd = resolve(p.cwd);
|
|
286
|
+
const normalizedCwd = resolve(p.cwd ?? process.cwd());
|
|
405
287
|
// Dedup: if a running terminal already exists for this cwd, return it
|
|
406
288
|
const existing = [...this.terminals.values()].find((t) => t.status === "running" && resolve(t.cwd) === normalizedCwd);
|
|
407
289
|
if (existing) {
|
|
408
290
|
this.send(createEnvelope({
|
|
409
291
|
type: "terminal.spawned",
|
|
410
|
-
|
|
292
|
+
hostDeviceId: this.sessionId,
|
|
411
293
|
terminalId: existing.id,
|
|
412
|
-
payload: { terminalId: existing.id, cwd: existing.cwd,
|
|
294
|
+
payload: { terminalId: existing.id, cwd: existing.cwd, shell: this.options.providerConfig.command },
|
|
413
295
|
}));
|
|
414
296
|
}
|
|
415
297
|
else {
|
|
416
298
|
const newId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
417
299
|
try {
|
|
418
|
-
await this.spawnTerminal(newId, normalizedCwd
|
|
300
|
+
await this.spawnTerminal(newId, normalizedCwd);
|
|
419
301
|
this.send(createEnvelope({
|
|
420
302
|
type: "terminal.spawned",
|
|
421
|
-
|
|
303
|
+
hostDeviceId: this.sessionId,
|
|
422
304
|
terminalId: newId,
|
|
423
|
-
payload: { terminalId: newId, cwd: normalizedCwd,
|
|
305
|
+
payload: { terminalId: newId, cwd: normalizedCwd, shell: this.options.providerConfig.command },
|
|
424
306
|
}));
|
|
425
307
|
}
|
|
426
308
|
catch (err) {
|
|
427
309
|
this.log(`failed to spawn terminal ${newId}: ${err}`);
|
|
428
310
|
this.send(createEnvelope({
|
|
429
311
|
type: "terminal.exit",
|
|
430
|
-
|
|
312
|
+
hostDeviceId: this.sessionId,
|
|
431
313
|
terminalId: newId,
|
|
432
314
|
payload: { exitCode: 1, signal: 0 },
|
|
433
315
|
}));
|
|
@@ -470,14 +352,14 @@ export class BridgeSession {
|
|
|
470
352
|
});
|
|
471
353
|
this.send(createEnvelope({
|
|
472
354
|
type: "terminal.browse.result",
|
|
473
|
-
|
|
355
|
+
hostDeviceId: this.sessionId,
|
|
474
356
|
payload: { path: browsePath, entries, requestId: p.requestId },
|
|
475
357
|
}));
|
|
476
358
|
}
|
|
477
359
|
catch (err) {
|
|
478
360
|
this.send(createEnvelope({
|
|
479
361
|
type: "terminal.browse.result",
|
|
480
|
-
|
|
362
|
+
hostDeviceId: this.sessionId,
|
|
481
363
|
payload: { path: browsePath, entries: [], error: err.message, requestId: p.requestId },
|
|
482
364
|
}));
|
|
483
365
|
}
|
|
@@ -507,7 +389,7 @@ export class BridgeSession {
|
|
|
507
389
|
}
|
|
508
390
|
this.send(createEnvelope({
|
|
509
391
|
type: "terminal.file.read.result",
|
|
510
|
-
|
|
392
|
+
hostDeviceId: this.sessionId,
|
|
511
393
|
payload: {
|
|
512
394
|
path: filePath,
|
|
513
395
|
content: buffer.toString("utf8"),
|
|
@@ -521,7 +403,7 @@ export class BridgeSession {
|
|
|
521
403
|
catch (err) {
|
|
522
404
|
this.send(createEnvelope({
|
|
523
405
|
type: "terminal.file.read.result",
|
|
524
|
-
|
|
406
|
+
hostDeviceId: this.sessionId,
|
|
525
407
|
payload: {
|
|
526
408
|
path: filePath,
|
|
527
409
|
content: "",
|
|
@@ -552,14 +434,14 @@ export class BridgeSession {
|
|
|
552
434
|
}));
|
|
553
435
|
this.send(createEnvelope({
|
|
554
436
|
type: "terminal.browse.result",
|
|
555
|
-
|
|
437
|
+
hostDeviceId: this.sessionId,
|
|
556
438
|
payload: { path: parentPath, entries },
|
|
557
439
|
}));
|
|
558
440
|
}
|
|
559
441
|
catch (err) {
|
|
560
442
|
this.send(createEnvelope({
|
|
561
443
|
type: "terminal.browse.result",
|
|
562
|
-
|
|
444
|
+
hostDeviceId: this.sessionId,
|
|
563
445
|
payload: { path: dirPath, entries: [], error: err.message },
|
|
564
446
|
}));
|
|
565
447
|
}
|
|
@@ -601,21 +483,21 @@ export class BridgeSession {
|
|
|
601
483
|
catch { }
|
|
602
484
|
this.send(createEnvelope({
|
|
603
485
|
type: "terminal.history.response",
|
|
604
|
-
|
|
486
|
+
hostDeviceId: this.sessionId,
|
|
605
487
|
payload: { entries, shell },
|
|
606
488
|
}));
|
|
607
489
|
break;
|
|
608
490
|
}
|
|
609
|
-
case "
|
|
610
|
-
const p = parseTypedPayload("
|
|
491
|
+
case "device.ack": {
|
|
492
|
+
const p = parseTypedPayload("device.ack", envelope.payload);
|
|
611
493
|
const term = this.terminals.get(tid);
|
|
612
494
|
if (term) {
|
|
613
495
|
term.scrollback.trimUpTo(p.seq);
|
|
614
496
|
}
|
|
615
497
|
break;
|
|
616
498
|
}
|
|
617
|
-
case "
|
|
618
|
-
const p = parseTypedPayload("
|
|
499
|
+
case "device.resume": {
|
|
500
|
+
const p = parseTypedPayload("device.resume", envelope.payload);
|
|
619
501
|
// Replay all terminals
|
|
620
502
|
for (const [termId, term] of this.terminals) {
|
|
621
503
|
this.replayFrom(termId, term, p.lastAckedSeqByTerminal[termId] ?? p.lastAckedSeq);
|
|
@@ -624,7 +506,7 @@ export class BridgeSession {
|
|
|
624
506
|
this.sendTerminalList();
|
|
625
507
|
break;
|
|
626
508
|
}
|
|
627
|
-
case "
|
|
509
|
+
case "device.heartbeat":
|
|
628
510
|
break;
|
|
629
511
|
case "screen.start": {
|
|
630
512
|
const p = parseTypedPayload("screen.start", envelope.payload);
|
|
@@ -645,68 +527,6 @@ export class BridgeSession {
|
|
|
645
527
|
this.screenShare?.handleIceCandidate(p.candidate, p.sdpMid, p.sdpMLineIndex);
|
|
646
528
|
break;
|
|
647
529
|
}
|
|
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
|
-
}));
|
|
672
|
-
break;
|
|
673
|
-
}
|
|
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);
|
|
708
|
-
break;
|
|
709
|
-
}
|
|
710
530
|
case "agent.v2.capabilities.request":
|
|
711
531
|
case "agent.v2.conversation.open":
|
|
712
532
|
case "agent.v2.conversation.list":
|
|
@@ -719,7 +539,7 @@ export class BridgeSession {
|
|
|
719
539
|
if (!this.agentWorkspace) {
|
|
720
540
|
this.send(createEnvelope({
|
|
721
541
|
type: "agent.v2.capabilities",
|
|
722
|
-
|
|
542
|
+
hostDeviceId: this.sessionId,
|
|
723
543
|
payload: {
|
|
724
544
|
enabled: false,
|
|
725
545
|
provider: normalizeAgentProvider(this.options.agentProvider ?? "codex"),
|
|
@@ -737,8 +557,6 @@ export class BridgeSession {
|
|
|
737
557
|
}));
|
|
738
558
|
break;
|
|
739
559
|
}
|
|
740
|
-
if (envelope.type === "agent.v2.prompt" || envelope.type === "agent.v2.command.execute")
|
|
741
|
-
this.refreshAgentPermissionHooks();
|
|
742
560
|
await this.agentWorkspace.handleEnvelope(envelope);
|
|
743
561
|
break;
|
|
744
562
|
}
|
|
@@ -754,37 +572,6 @@ export class BridgeSession {
|
|
|
754
572
|
}
|
|
755
573
|
break;
|
|
756
574
|
}
|
|
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
|
-
}
|
|
788
575
|
case "tunnel.request": {
|
|
789
576
|
const p = parseTypedPayload("tunnel.request", envelope.payload);
|
|
790
577
|
this.handleTunnelRequest(p);
|
|
@@ -833,7 +620,7 @@ export class BridgeSession {
|
|
|
833
620
|
proxyRes.on("data", (chunk) => {
|
|
834
621
|
this.send(createEnvelope({
|
|
835
622
|
type: "tunnel.response",
|
|
836
|
-
|
|
623
|
+
hostDeviceId: this.sessionId,
|
|
837
624
|
payload: {
|
|
838
625
|
requestId,
|
|
839
626
|
statusCode: proxyRes.statusCode ?? 200,
|
|
@@ -847,7 +634,7 @@ export class BridgeSession {
|
|
|
847
634
|
proxyRes.on("end", () => {
|
|
848
635
|
this.send(createEnvelope({
|
|
849
636
|
type: "tunnel.response",
|
|
850
|
-
|
|
637
|
+
hostDeviceId: this.sessionId,
|
|
851
638
|
payload: {
|
|
852
639
|
requestId,
|
|
853
640
|
statusCode: proxyRes.statusCode ?? 200,
|
|
@@ -884,7 +671,7 @@ export class BridgeSession {
|
|
|
884
671
|
const buf = typeof data === "string" ? Buffer.from(data) : data;
|
|
885
672
|
this.send(createEnvelope({
|
|
886
673
|
type: "tunnel.ws.data",
|
|
887
|
-
|
|
674
|
+
hostDeviceId: this.sessionId,
|
|
888
675
|
payload: {
|
|
889
676
|
requestId,
|
|
890
677
|
data: buf.toString("base64"),
|
|
@@ -897,7 +684,7 @@ export class BridgeSession {
|
|
|
897
684
|
const safeCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
|
|
898
685
|
this.send(createEnvelope({
|
|
899
686
|
type: "tunnel.ws.close",
|
|
900
|
-
|
|
687
|
+
hostDeviceId: this.sessionId,
|
|
901
688
|
payload: {
|
|
902
689
|
requestId,
|
|
903
690
|
code: safeCode,
|
|
@@ -909,7 +696,7 @@ export class BridgeSession {
|
|
|
909
696
|
this.tunnelSockets.delete(requestId);
|
|
910
697
|
this.send(createEnvelope({
|
|
911
698
|
type: "tunnel.ws.close",
|
|
912
|
-
|
|
699
|
+
hostDeviceId: this.sessionId,
|
|
913
700
|
payload: {
|
|
914
701
|
requestId,
|
|
915
702
|
code: 1001,
|
|
@@ -936,7 +723,7 @@ export class BridgeSession {
|
|
|
936
723
|
sendTunnelError(requestId, statusCode, message) {
|
|
937
724
|
this.send(createEnvelope({
|
|
938
725
|
type: "tunnel.response",
|
|
939
|
-
|
|
726
|
+
hostDeviceId: this.sessionId,
|
|
940
727
|
payload: {
|
|
941
728
|
requestId,
|
|
942
729
|
statusCode,
|
|
@@ -950,13 +737,12 @@ export class BridgeSession {
|
|
|
950
737
|
const terminals = [...this.terminals.values()].map((t) => ({
|
|
951
738
|
terminalId: t.id,
|
|
952
739
|
cwd: t.cwd,
|
|
953
|
-
projectName: t.projectName,
|
|
954
|
-
provider: t.provider,
|
|
955
740
|
status: t.status,
|
|
741
|
+
shell: this.options.providerConfig.command,
|
|
956
742
|
}));
|
|
957
743
|
this.send(createEnvelope({
|
|
958
744
|
type: "terminal.list",
|
|
959
|
-
|
|
745
|
+
hostDeviceId: this.sessionId,
|
|
960
746
|
payload: { terminals },
|
|
961
747
|
}));
|
|
962
748
|
}
|
|
@@ -966,46 +752,20 @@ export class BridgeSession {
|
|
|
966
752
|
const payload = msg.payload;
|
|
967
753
|
this.send(createEnvelope({
|
|
968
754
|
type: "terminal.output",
|
|
969
|
-
|
|
755
|
+
hostDeviceId: this.sessionId,
|
|
970
756
|
terminalId,
|
|
971
757
|
seq: msg.seq,
|
|
972
758
|
payload: { ...payload, isReplay: true },
|
|
973
759
|
}));
|
|
974
760
|
}
|
|
975
761
|
}
|
|
976
|
-
async spawnTerminal(terminalId, cwd
|
|
762
|
+
async spawnTerminal(terminalId, cwd) {
|
|
977
763
|
const cleanEnv = {};
|
|
978
764
|
for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
|
|
979
765
|
if (v !== undefined)
|
|
980
766
|
cleanEnv[k] = v;
|
|
981
767
|
}
|
|
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;
|
|
986
768
|
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
|
-
}
|
|
1009
769
|
const term = {
|
|
1010
770
|
id: terminalId,
|
|
1011
771
|
pty: pty.spawn(this.options.providerConfig.command, args, {
|
|
@@ -1016,22 +776,15 @@ export class BridgeSession {
|
|
|
1016
776
|
env: cleanEnv,
|
|
1017
777
|
}),
|
|
1018
778
|
cwd,
|
|
1019
|
-
projectName: basename(cwd),
|
|
1020
|
-
provider,
|
|
1021
779
|
scrollback: new ScrollbackBuffer(1000),
|
|
1022
780
|
outputSeq: 0,
|
|
1023
|
-
statusSeq: 0,
|
|
1024
781
|
status: "running",
|
|
1025
|
-
hookServer,
|
|
1026
|
-
hookPort,
|
|
1027
|
-
hookMarker,
|
|
1028
|
-
hookConfigPaths,
|
|
1029
782
|
};
|
|
1030
783
|
term.pty.onData((data) => {
|
|
1031
784
|
const seq = term.outputSeq++;
|
|
1032
785
|
const envelope = createEnvelope({
|
|
1033
786
|
type: "terminal.output",
|
|
1034
|
-
|
|
787
|
+
hostDeviceId: this.sessionId,
|
|
1035
788
|
terminalId,
|
|
1036
789
|
seq,
|
|
1037
790
|
payload: {
|
|
@@ -1047,10 +800,9 @@ export class BridgeSession {
|
|
|
1047
800
|
});
|
|
1048
801
|
term.pty.onExit(({ exitCode, signal }) => {
|
|
1049
802
|
term.status = "exited";
|
|
1050
|
-
this.cleanupHookServer(term);
|
|
1051
803
|
this.send(createEnvelope({
|
|
1052
804
|
type: "terminal.exit",
|
|
1053
|
-
|
|
805
|
+
hostDeviceId: this.sessionId,
|
|
1054
806
|
terminalId,
|
|
1055
807
|
payload: { exitCode, signal },
|
|
1056
808
|
}));
|
|
@@ -1069,697 +821,12 @@ export class BridgeSession {
|
|
|
1069
821
|
this.terminals.set(terminalId, term);
|
|
1070
822
|
this.log(`spawned terminal ${terminalId} in ${cwd}`);
|
|
1071
823
|
}
|
|
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
|
-
}
|
|
1754
824
|
send(message) {
|
|
1755
825
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
1756
826
|
return;
|
|
1757
827
|
}
|
|
1758
828
|
const machineId = this.machineIdentity?.machineId;
|
|
1759
|
-
const enriched = machineId && (message.type === "
|
|
1760
|
-
message.type === "agent.capabilities" ||
|
|
1761
|
-
message.type === "agent.snapshot" ||
|
|
1762
|
-
message.type === "agent.v2.capabilities" ||
|
|
829
|
+
const enriched = machineId && (message.type === "agent.v2.capabilities" ||
|
|
1763
830
|
message.type === "agent.v2.snapshot")
|
|
1764
831
|
? {
|
|
1765
832
|
...message,
|
|
@@ -1775,8 +842,8 @@ export class BridgeSession {
|
|
|
1775
842
|
this.stopHeartbeat();
|
|
1776
843
|
this.heartbeatTimer = setInterval(() => {
|
|
1777
844
|
this.send(createEnvelope({
|
|
1778
|
-
type: "
|
|
1779
|
-
|
|
845
|
+
type: "device.heartbeat",
|
|
846
|
+
hostDeviceId: this.sessionId,
|
|
1780
847
|
payload: { ts: Date.now() },
|
|
1781
848
|
}));
|
|
1782
849
|
}, HEARTBEAT_INTERVAL);
|
|
@@ -1792,7 +859,7 @@ export class BridgeSession {
|
|
|
1792
859
|
this.log("screen sharing not enabled (use --screen)");
|
|
1793
860
|
this.send(createEnvelope({
|
|
1794
861
|
type: "screen.status",
|
|
1795
|
-
|
|
862
|
+
hostDeviceId: this.sessionId,
|
|
1796
863
|
payload: { active: false, mode: "off", error: "Screen sharing not enabled on host. Start CLI with --screen flag." },
|
|
1797
864
|
}));
|
|
1798
865
|
return;
|
|
@@ -1803,7 +870,7 @@ export class BridgeSession {
|
|
|
1803
870
|
if (ScreenShare.isAvailable()) {
|
|
1804
871
|
this.log("WebRTC available, starting screen share");
|
|
1805
872
|
this.screenShare = new ScreenShare({
|
|
1806
|
-
|
|
873
|
+
hostDeviceId: this.sessionId,
|
|
1807
874
|
fps,
|
|
1808
875
|
quality,
|
|
1809
876
|
scale,
|
|
@@ -1826,7 +893,7 @@ export class BridgeSession {
|
|
|
1826
893
|
fps,
|
|
1827
894
|
quality,
|
|
1828
895
|
scale,
|
|
1829
|
-
|
|
896
|
+
hostDeviceId: this.sessionId,
|
|
1830
897
|
onFrame: (envelope) => this.send(envelope),
|
|
1831
898
|
onStatus: (envelope) => this.send(envelope),
|
|
1832
899
|
});
|
|
@@ -1873,8 +940,6 @@ export class BridgeSession {
|
|
|
1873
940
|
this.exited = true;
|
|
1874
941
|
this.stopHeartbeat();
|
|
1875
942
|
this.stopScreenCapture();
|
|
1876
|
-
this.agentSession?.stop();
|
|
1877
|
-
this.agentSession = undefined;
|
|
1878
943
|
this.agentWorkspace?.stop();
|
|
1879
944
|
this.agentWorkspace = undefined;
|
|
1880
945
|
this.keepAwake?.stop();
|
|
@@ -1891,7 +956,6 @@ export class BridgeSession {
|
|
|
1891
956
|
}
|
|
1892
957
|
this.tunnelSockets.clear();
|
|
1893
958
|
for (const term of this.terminals.values()) {
|
|
1894
|
-
this.cleanupHookServer(term);
|
|
1895
959
|
if (term.status === "running")
|
|
1896
960
|
term.pty.kill();
|
|
1897
961
|
}
|