linkshell-cli 0.2.124 → 0.2.126
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-workspace.js +8 -53
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +1 -30
- package/dist/cli/src/runtime/bridge-session.js +102 -912
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +864 -696
- package/dist/shared-protocol/src/index.js +52 -15
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +3 -3
- 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 +8 -53
- package/src/runtime/bridge-session.ts +99 -1002
- 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
|
@@ -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,
|
|
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";
|
|
@@ -21,113 +21,6 @@ const RECONNECT_BASE_DELAY = 1_000;
|
|
|
21
21
|
const RECONNECT_MAX_DELAY = 30_000;
|
|
22
22
|
const RECONNECT_MAX_ATTEMPTS = 20;
|
|
23
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
|
-
}
|
|
131
24
|
function getPairingGatewayParam(gatewayHttpUrl) {
|
|
132
25
|
try {
|
|
133
26
|
const url = new URL(gatewayHttpUrl);
|
|
@@ -194,10 +87,6 @@ export class BridgeSession {
|
|
|
194
87
|
sessionId = "";
|
|
195
88
|
exited = false;
|
|
196
89
|
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
90
|
screenCapture;
|
|
202
91
|
screenShare;
|
|
203
92
|
tunnelSockets = new Map();
|
|
@@ -207,23 +96,18 @@ export class BridgeSession {
|
|
|
207
96
|
machineIdentity;
|
|
208
97
|
constructor(options) {
|
|
209
98
|
this.options = options;
|
|
210
|
-
this.sessionId = options.
|
|
99
|
+
this.sessionId = options.hostDeviceId ?? "";
|
|
211
100
|
}
|
|
212
101
|
log(msg) {
|
|
213
102
|
if (this.options.verbose) {
|
|
214
103
|
process.stderr.write(`[bridge:verbose] ${msg}\n`);
|
|
215
104
|
}
|
|
216
105
|
}
|
|
217
|
-
terminalHookMarker(terminalId) {
|
|
218
|
-
const safeTerminalId = terminalId.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
|
219
|
-
return `${this.hookMarker}-${safeTerminalId}`;
|
|
220
|
-
}
|
|
221
106
|
async start() {
|
|
222
|
-
this.log(`starting
|
|
107
|
+
this.log(`starting device bridge (gateway=${this.options.gatewayUrl}, terminal=shell)`);
|
|
223
108
|
this.machineIdentity = loadOrCreateMachineIdentity();
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
109
|
+
this.sessionId ||= this.machineIdentity.machineId;
|
|
110
|
+
await this.createPairing();
|
|
227
111
|
if (this.options.keepAwake) {
|
|
228
112
|
this.keepAwake = startKeepAwake();
|
|
229
113
|
}
|
|
@@ -231,7 +115,6 @@ export class BridgeSession {
|
|
|
231
115
|
process.stderr.write("[bridge] keep-awake disabled\n");
|
|
232
116
|
}
|
|
233
117
|
if (this.options.agentUi) {
|
|
234
|
-
process.env.LINKSHELL_ID = this.terminalHookMarker(DEFAULT_TERMINAL_ID);
|
|
235
118
|
const availableProviders = this.options.agentProvider
|
|
236
119
|
? [normalizeAgentProvider(this.options.agentProvider)]
|
|
237
120
|
: detectAvailableProviders();
|
|
@@ -263,19 +146,19 @@ export class BridgeSession {
|
|
|
263
146
|
const res = await fetch(`${this.options.gatewayHttpUrl}/pairings`, {
|
|
264
147
|
method: "POST",
|
|
265
148
|
headers,
|
|
266
|
-
body: JSON.stringify({}),
|
|
149
|
+
body: JSON.stringify({ hostDeviceId: this.sessionId }),
|
|
267
150
|
});
|
|
268
151
|
if (!res.ok) {
|
|
269
152
|
throw new Error(`Failed to create pairing: ${res.status}`);
|
|
270
153
|
}
|
|
271
154
|
const body = (await res.json());
|
|
272
|
-
this.sessionId = body.
|
|
155
|
+
this.sessionId = body.hostDeviceId;
|
|
273
156
|
const pairingGateway = resolvePairingGateway(this.options.gatewayHttpUrl, this.options.pairingGateway);
|
|
274
157
|
const deepLink = pairingGateway
|
|
275
158
|
? `linkshell://pair?code=${body.pairingCode}&gateway=${encodeURIComponent(pairingGateway)}`
|
|
276
159
|
: `linkshell://pair?code=${body.pairingCode}`;
|
|
277
160
|
process.stderr.write(`\n \x1b[1mPairing code: \x1b[36m${body.pairingCode}\x1b[0m\n`);
|
|
278
|
-
process.stderr.write(`
|
|
161
|
+
process.stderr.write(` Host device: ${body.hostDeviceId}\n`);
|
|
279
162
|
process.stderr.write(` Expires: ${body.expiresAt}\n\n`);
|
|
280
163
|
if (!pairingGateway) {
|
|
281
164
|
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 +208,7 @@ export class BridgeSession {
|
|
|
325
208
|
return;
|
|
326
209
|
}
|
|
327
210
|
const url = new URL(this.options.gatewayUrl);
|
|
328
|
-
url.searchParams.set("
|
|
211
|
+
url.searchParams.set("hostDeviceId", this.sessionId);
|
|
329
212
|
url.searchParams.set("role", "host");
|
|
330
213
|
const authToken = await this.resolveAuthToken();
|
|
331
214
|
if (authToken) {
|
|
@@ -339,18 +222,22 @@ export class BridgeSession {
|
|
|
339
222
|
this.reconnectAttempts = 0;
|
|
340
223
|
this.reconnecting = false;
|
|
341
224
|
this.send(createEnvelope({
|
|
342
|
-
type: "
|
|
343
|
-
|
|
225
|
+
type: "device.connect",
|
|
226
|
+
hostDeviceId: this.sessionId,
|
|
344
227
|
payload: {
|
|
345
228
|
role: "host",
|
|
346
229
|
clientName: this.options.clientName,
|
|
347
|
-
provider: this.options.providerConfig.provider,
|
|
348
230
|
protocolVersion: PROTOCOL_VERSION,
|
|
349
231
|
machineId: this.machineIdentity?.machineId,
|
|
350
232
|
hostname: this.options.hostname || hostname(),
|
|
351
233
|
platform: platform(),
|
|
352
234
|
cwd: process.cwd(),
|
|
353
|
-
|
|
235
|
+
capabilities: [
|
|
236
|
+
"terminal",
|
|
237
|
+
...(this.options.agentUi ? ["agent-ui"] : []),
|
|
238
|
+
...(this.options.screen ? ["screen"] : []),
|
|
239
|
+
"tunnel",
|
|
240
|
+
],
|
|
354
241
|
},
|
|
355
242
|
}));
|
|
356
243
|
this.startHeartbeat();
|
|
@@ -401,7 +288,7 @@ export class BridgeSession {
|
|
|
401
288
|
}
|
|
402
289
|
case "terminal.spawn": {
|
|
403
290
|
const p = parseTypedPayload("terminal.spawn", envelope.payload);
|
|
404
|
-
const normalizedCwd = resolve(p.cwd);
|
|
291
|
+
const normalizedCwd = resolve(p.cwd ?? process.cwd());
|
|
405
292
|
// Dedup: if a running terminal already exists for this cwd, return it
|
|
406
293
|
const existing = [...this.terminals.values()].find((t) => t.status === "running" && resolve(t.cwd) === normalizedCwd);
|
|
407
294
|
if (existing) {
|
|
@@ -409,18 +296,18 @@ export class BridgeSession {
|
|
|
409
296
|
type: "terminal.spawned",
|
|
410
297
|
sessionId: this.sessionId,
|
|
411
298
|
terminalId: existing.id,
|
|
412
|
-
payload: { terminalId: existing.id, cwd: existing.cwd,
|
|
299
|
+
payload: { terminalId: existing.id, cwd: existing.cwd, shell: this.options.providerConfig.command },
|
|
413
300
|
}));
|
|
414
301
|
}
|
|
415
302
|
else {
|
|
416
303
|
const newId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
417
304
|
try {
|
|
418
|
-
await this.spawnTerminal(newId, normalizedCwd
|
|
305
|
+
await this.spawnTerminal(newId, normalizedCwd);
|
|
419
306
|
this.send(createEnvelope({
|
|
420
307
|
type: "terminal.spawned",
|
|
421
308
|
sessionId: this.sessionId,
|
|
422
309
|
terminalId: newId,
|
|
423
|
-
payload: { terminalId: newId, cwd: normalizedCwd,
|
|
310
|
+
payload: { terminalId: newId, cwd: normalizedCwd, shell: this.options.providerConfig.command },
|
|
424
311
|
}));
|
|
425
312
|
}
|
|
426
313
|
catch (err) {
|
|
@@ -451,24 +338,85 @@ export class BridgeSession {
|
|
|
451
338
|
const browsePath = resolve(rawPath);
|
|
452
339
|
try {
|
|
453
340
|
const entries = readdirSync(browsePath, { withFileTypes: true })
|
|
454
|
-
.filter((d) => d.isDirectory() &&
|
|
455
|
-
.
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
341
|
+
.filter((d) => !d.name.startsWith(".") && (d.isDirectory() || (p.includeFiles && d.isFile())))
|
|
342
|
+
.map((d) => {
|
|
343
|
+
const entryPath = join(browsePath, d.name);
|
|
344
|
+
const stats = statSync(entryPath);
|
|
345
|
+
return {
|
|
346
|
+
name: d.name,
|
|
347
|
+
path: entryPath,
|
|
348
|
+
isDirectory: d.isDirectory(),
|
|
349
|
+
size: stats.size,
|
|
350
|
+
modifiedAt: stats.mtime.toISOString(),
|
|
351
|
+
};
|
|
352
|
+
})
|
|
353
|
+
.sort((a, b) => {
|
|
354
|
+
if (a.isDirectory !== b.isDirectory)
|
|
355
|
+
return a.isDirectory ? -1 : 1;
|
|
356
|
+
return a.name.localeCompare(b.name);
|
|
357
|
+
});
|
|
461
358
|
this.send(createEnvelope({
|
|
462
359
|
type: "terminal.browse.result",
|
|
463
360
|
sessionId: this.sessionId,
|
|
464
|
-
payload: { path: browsePath, entries },
|
|
361
|
+
payload: { path: browsePath, entries, requestId: p.requestId },
|
|
465
362
|
}));
|
|
466
363
|
}
|
|
467
364
|
catch (err) {
|
|
468
365
|
this.send(createEnvelope({
|
|
469
366
|
type: "terminal.browse.result",
|
|
470
367
|
sessionId: this.sessionId,
|
|
471
|
-
payload: { path: browsePath, entries: [], error: err.message },
|
|
368
|
+
payload: { path: browsePath, entries: [], error: err.message, requestId: p.requestId },
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case "terminal.file.read": {
|
|
374
|
+
const p = parseTypedPayload("terminal.file.read", envelope.payload);
|
|
375
|
+
const rawPath = p.path.startsWith("~") ? p.path.replace(/^~/, homedir()) : p.path;
|
|
376
|
+
const filePath = resolve(rawPath);
|
|
377
|
+
try {
|
|
378
|
+
const stats = statSync(filePath);
|
|
379
|
+
if (!stats.isFile()) {
|
|
380
|
+
throw new Error("Path is not a file");
|
|
381
|
+
}
|
|
382
|
+
const maxBytes = p.maxBytes ?? 256_000;
|
|
383
|
+
const bytesToRead = Math.min(stats.size, maxBytes);
|
|
384
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
385
|
+
const fd = openSync(filePath, "r");
|
|
386
|
+
try {
|
|
387
|
+
readSync(fd, buffer, 0, bytesToRead, 0);
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
closeSync(fd);
|
|
391
|
+
}
|
|
392
|
+
if (buffer.includes(0)) {
|
|
393
|
+
throw new Error("Binary files cannot be previewed");
|
|
394
|
+
}
|
|
395
|
+
this.send(createEnvelope({
|
|
396
|
+
type: "terminal.file.read.result",
|
|
397
|
+
sessionId: this.sessionId,
|
|
398
|
+
payload: {
|
|
399
|
+
path: filePath,
|
|
400
|
+
content: buffer.toString("utf8"),
|
|
401
|
+
encoding: "utf8",
|
|
402
|
+
size: stats.size,
|
|
403
|
+
truncated: stats.size > maxBytes,
|
|
404
|
+
requestId: p.requestId,
|
|
405
|
+
},
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
this.send(createEnvelope({
|
|
410
|
+
type: "terminal.file.read.result",
|
|
411
|
+
sessionId: this.sessionId,
|
|
412
|
+
payload: {
|
|
413
|
+
path: filePath,
|
|
414
|
+
content: "",
|
|
415
|
+
encoding: "utf8",
|
|
416
|
+
truncated: false,
|
|
417
|
+
error: err.message,
|
|
418
|
+
requestId: p.requestId,
|
|
419
|
+
},
|
|
472
420
|
}));
|
|
473
421
|
}
|
|
474
422
|
break;
|
|
@@ -545,16 +493,18 @@ export class BridgeSession {
|
|
|
545
493
|
}));
|
|
546
494
|
break;
|
|
547
495
|
}
|
|
496
|
+
case "device.ack":
|
|
548
497
|
case "session.ack": {
|
|
549
|
-
const p = parseTypedPayload("session.ack", envelope.payload);
|
|
498
|
+
const p = parseTypedPayload(envelope.type === "device.ack" ? "device.ack" : "session.ack", envelope.payload);
|
|
550
499
|
const term = this.terminals.get(tid);
|
|
551
500
|
if (term) {
|
|
552
501
|
term.scrollback.trimUpTo(p.seq);
|
|
553
502
|
}
|
|
554
503
|
break;
|
|
555
504
|
}
|
|
505
|
+
case "device.resume":
|
|
556
506
|
case "session.resume": {
|
|
557
|
-
const p = parseTypedPayload("session.resume", envelope.payload);
|
|
507
|
+
const p = parseTypedPayload(envelope.type === "device.resume" ? "device.resume" : "session.resume", envelope.payload);
|
|
558
508
|
// Replay all terminals
|
|
559
509
|
for (const [termId, term] of this.terminals) {
|
|
560
510
|
this.replayFrom(termId, term, p.lastAckedSeqByTerminal[termId] ?? p.lastAckedSeq);
|
|
@@ -563,6 +513,7 @@ export class BridgeSession {
|
|
|
563
513
|
this.sendTerminalList();
|
|
564
514
|
break;
|
|
565
515
|
}
|
|
516
|
+
case "device.heartbeat":
|
|
566
517
|
case "session.heartbeat":
|
|
567
518
|
break;
|
|
568
519
|
case "screen.start": {
|
|
@@ -610,19 +561,10 @@ export class BridgeSession {
|
|
|
610
561
|
}));
|
|
611
562
|
break;
|
|
612
563
|
}
|
|
613
|
-
if (envelope.type === "agent.prompt")
|
|
614
|
-
this.refreshAgentPermissionHooks();
|
|
615
564
|
await this.agentSession.handleEnvelope(envelope);
|
|
616
565
|
break;
|
|
617
566
|
}
|
|
618
567
|
case "agent.permission.response": {
|
|
619
|
-
const p = parseTypedPayload("agent.permission.response", envelope.payload);
|
|
620
|
-
if (this.resolvePendingPermission(p.requestId, {
|
|
621
|
-
outcome: p.outcome,
|
|
622
|
-
optionId: p.optionId,
|
|
623
|
-
}, "agent.permission.response").resolved) {
|
|
624
|
-
break;
|
|
625
|
-
}
|
|
626
568
|
if (!this.agentSession) {
|
|
627
569
|
this.send(createEnvelope({
|
|
628
570
|
type: "agent.capabilities",
|
|
@@ -676,8 +618,6 @@ export class BridgeSession {
|
|
|
676
618
|
}));
|
|
677
619
|
break;
|
|
678
620
|
}
|
|
679
|
-
if (envelope.type === "agent.v2.prompt" || envelope.type === "agent.v2.command.execute")
|
|
680
|
-
this.refreshAgentPermissionHooks();
|
|
681
621
|
await this.agentWorkspace.handleEnvelope(envelope);
|
|
682
622
|
break;
|
|
683
623
|
}
|
|
@@ -693,37 +633,6 @@ export class BridgeSession {
|
|
|
693
633
|
}
|
|
694
634
|
break;
|
|
695
635
|
}
|
|
696
|
-
case "permission.decision": {
|
|
697
|
-
const p = envelope.payload;
|
|
698
|
-
const result = this.resolvePendingPermission(p.requestId, p.decision, "permission.decision");
|
|
699
|
-
if (!result.resolved) {
|
|
700
|
-
this.sendPermissionSnapshot(tid, "thinking", "permission not pending", {
|
|
701
|
-
requestId: p.requestId,
|
|
702
|
-
outcome: p.decision,
|
|
703
|
-
source: "permission.decision",
|
|
704
|
-
delivered: false,
|
|
705
|
-
});
|
|
706
|
-
}
|
|
707
|
-
process.stderr.write(`[bridge] permission decision request=${p.requestId} decision=${p.decision} resolved=${result.resolved} delivered=${result.delivered}\n`);
|
|
708
|
-
this.send(createEnvelope({
|
|
709
|
-
type: "permission.decision.result",
|
|
710
|
-
sessionId: this.sessionId,
|
|
711
|
-
terminalId: tid,
|
|
712
|
-
payload: {
|
|
713
|
-
requestId: p.requestId,
|
|
714
|
-
decision: p.decision,
|
|
715
|
-
resolved: result.resolved,
|
|
716
|
-
delivered: result.delivered,
|
|
717
|
-
source: "permission.decision",
|
|
718
|
-
message: result.delivered
|
|
719
|
-
? undefined
|
|
720
|
-
: result.resolved
|
|
721
|
-
? "Permission resolved but response was not delivered"
|
|
722
|
-
: "Permission request is no longer pending",
|
|
723
|
-
},
|
|
724
|
-
}));
|
|
725
|
-
break;
|
|
726
|
-
}
|
|
727
636
|
case "tunnel.request": {
|
|
728
637
|
const p = parseTypedPayload("tunnel.request", envelope.payload);
|
|
729
638
|
this.handleTunnelRequest(p);
|
|
@@ -889,9 +798,8 @@ export class BridgeSession {
|
|
|
889
798
|
const terminals = [...this.terminals.values()].map((t) => ({
|
|
890
799
|
terminalId: t.id,
|
|
891
800
|
cwd: t.cwd,
|
|
892
|
-
projectName: t.projectName,
|
|
893
|
-
provider: t.provider,
|
|
894
801
|
status: t.status,
|
|
802
|
+
shell: this.options.providerConfig.command,
|
|
895
803
|
}));
|
|
896
804
|
this.send(createEnvelope({
|
|
897
805
|
type: "terminal.list",
|
|
@@ -912,39 +820,13 @@ export class BridgeSession {
|
|
|
912
820
|
}));
|
|
913
821
|
}
|
|
914
822
|
}
|
|
915
|
-
async spawnTerminal(terminalId, cwd
|
|
823
|
+
async spawnTerminal(terminalId, cwd) {
|
|
916
824
|
const cleanEnv = {};
|
|
917
825
|
for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
|
|
918
826
|
if (v !== undefined)
|
|
919
827
|
cleanEnv[k] = v;
|
|
920
828
|
}
|
|
921
|
-
const hookMarker = this.terminalHookMarker(terminalId);
|
|
922
|
-
// Inject marker so child CLIs' hook commands carry our identity
|
|
923
|
-
cleanEnv["LINKSHELL_ID"] = hookMarker;
|
|
924
|
-
const provider = providerOverride ?? this.options.providerConfig.provider;
|
|
925
829
|
const args = [...this.options.providerConfig.args];
|
|
926
|
-
// Set up hook server for structured status (all supported providers)
|
|
927
|
-
// For "custom" shell, set up hooks for all providers since user may launch any of them
|
|
928
|
-
let hookServer;
|
|
929
|
-
let hookPort;
|
|
930
|
-
const hookConfigPaths = [];
|
|
931
|
-
if (provider === "custom") {
|
|
932
|
-
const result = await this.setupHookServer(terminalId, args, "claude", hookMarker);
|
|
933
|
-
hookServer = result.server;
|
|
934
|
-
hookPort = result.port;
|
|
935
|
-
hookConfigPaths.push(result.configPath);
|
|
936
|
-
// Also set up hooks for other providers (curlCmd already has marker from setupHookServer)
|
|
937
|
-
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`;
|
|
938
|
-
hookConfigPaths.push(this.setupCodexHooks(terminalId, curlCmd, hookMarker));
|
|
939
|
-
hookConfigPaths.push(this.setupGeminiHooks(terminalId, curlCmd, hookMarker));
|
|
940
|
-
hookConfigPaths.push(this.setupCopilotHooks(terminalId, curlCmd, hookMarker));
|
|
941
|
-
}
|
|
942
|
-
else if (provider === "claude" || provider === "codex" || provider === "gemini" || provider === "copilot") {
|
|
943
|
-
const result = await this.setupHookServer(terminalId, args, provider, hookMarker);
|
|
944
|
-
hookServer = result.server;
|
|
945
|
-
hookPort = result.port;
|
|
946
|
-
hookConfigPaths.push(result.configPath);
|
|
947
|
-
}
|
|
948
830
|
const term = {
|
|
949
831
|
id: terminalId,
|
|
950
832
|
pty: pty.spawn(this.options.providerConfig.command, args, {
|
|
@@ -955,16 +837,9 @@ export class BridgeSession {
|
|
|
955
837
|
env: cleanEnv,
|
|
956
838
|
}),
|
|
957
839
|
cwd,
|
|
958
|
-
projectName: basename(cwd),
|
|
959
|
-
provider,
|
|
960
840
|
scrollback: new ScrollbackBuffer(1000),
|
|
961
841
|
outputSeq: 0,
|
|
962
|
-
statusSeq: 0,
|
|
963
842
|
status: "running",
|
|
964
|
-
hookServer,
|
|
965
|
-
hookPort,
|
|
966
|
-
hookMarker,
|
|
967
|
-
hookConfigPaths,
|
|
968
843
|
};
|
|
969
844
|
term.pty.onData((data) => {
|
|
970
845
|
const seq = term.outputSeq++;
|
|
@@ -986,7 +861,6 @@ export class BridgeSession {
|
|
|
986
861
|
});
|
|
987
862
|
term.pty.onExit(({ exitCode, signal }) => {
|
|
988
863
|
term.status = "exited";
|
|
989
|
-
this.cleanupHookServer(term);
|
|
990
864
|
this.send(createEnvelope({
|
|
991
865
|
type: "terminal.exit",
|
|
992
866
|
sessionId: this.sessionId,
|
|
@@ -1008,695 +882,12 @@ export class BridgeSession {
|
|
|
1008
882
|
this.terminals.set(terminalId, term);
|
|
1009
883
|
this.log(`spawned terminal ${terminalId} in ${cwd}`);
|
|
1010
884
|
}
|
|
1011
|
-
async setupHookServer(terminalId, args, provider, marker) {
|
|
1012
|
-
const server = http.createServer((req, res) => {
|
|
1013
|
-
this.log(`hook server received: ${req.method} ${req.url}`);
|
|
1014
|
-
const reqUrl = new URL(req.url ?? "/", "http://localhost");
|
|
1015
|
-
if (req.method !== "POST" || reqUrl.pathname !== "/hook") {
|
|
1016
|
-
res.writeHead(404);
|
|
1017
|
-
res.end();
|
|
1018
|
-
return;
|
|
1019
|
-
}
|
|
1020
|
-
// Check marker — reject events not from our PTY
|
|
1021
|
-
// m must match; lid must match OR be empty (some CLIs don't inherit env vars)
|
|
1022
|
-
const reqMarker = reqUrl.searchParams.get("m");
|
|
1023
|
-
const reqLid = reqUrl.searchParams.get("lid") ?? "";
|
|
1024
|
-
if (reqMarker !== marker || (reqLid !== "" && reqLid !== marker)) {
|
|
1025
|
-
this.log(`ignoring hook event: m=${reqMarker} lid=${reqLid} (expected ${marker})`);
|
|
1026
|
-
res.writeHead(200);
|
|
1027
|
-
res.end("ok");
|
|
1028
|
-
return;
|
|
1029
|
-
}
|
|
1030
|
-
let body = "";
|
|
1031
|
-
let bodyTooLarge = false;
|
|
1032
|
-
req.on("data", (chunk) => {
|
|
1033
|
-
if (bodyTooLarge)
|
|
1034
|
-
return;
|
|
1035
|
-
body += chunk.toString();
|
|
1036
|
-
if (Buffer.byteLength(body, "utf8") > HOOK_BODY_LIMIT) {
|
|
1037
|
-
bodyTooLarge = true;
|
|
1038
|
-
res.writeHead(413);
|
|
1039
|
-
res.end("payload too large");
|
|
1040
|
-
req.destroy();
|
|
1041
|
-
}
|
|
1042
|
-
});
|
|
1043
|
-
req.on("end", () => {
|
|
1044
|
-
if (bodyTooLarge || res.writableEnded)
|
|
1045
|
-
return;
|
|
1046
|
-
this.log(`hook body (${body.length} bytes): ${body.slice(0, 200)}`);
|
|
1047
|
-
try {
|
|
1048
|
-
const event = JSON.parse(body);
|
|
1049
|
-
const hookName = (event.hook_event_name ?? event.event_name);
|
|
1050
|
-
// PermissionRequest: hold connection, wait for user decision from mobile app
|
|
1051
|
-
if (hookName === "PermissionRequest") {
|
|
1052
|
-
const requestId = `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1053
|
-
const permissionSuggestions = hookPermissionSuggestions(event);
|
|
1054
|
-
const timeout = setTimeout(() => {
|
|
1055
|
-
if (this.resolvePendingPermission(requestId, "deny", "permission.timeout").resolved) {
|
|
1056
|
-
this.log(`permission request ${requestId} timed out`);
|
|
1057
|
-
this.sendPermissionSnapshot(terminalId, "thinking", "permission timed out");
|
|
1058
|
-
}
|
|
1059
|
-
}, PERMISSION_REQUEST_TIMEOUT_MS);
|
|
1060
|
-
this.pendingPermissions.set(requestId, {
|
|
1061
|
-
terminalId,
|
|
1062
|
-
timeout,
|
|
1063
|
-
permissionSuggestions,
|
|
1064
|
-
resolve: (decision) => {
|
|
1065
|
-
if (res.writableEnded)
|
|
1066
|
-
return false;
|
|
1067
|
-
const responseJson = JSON.stringify({
|
|
1068
|
-
hookSpecificOutput: {
|
|
1069
|
-
hookEventName: "PermissionRequest",
|
|
1070
|
-
decision,
|
|
1071
|
-
},
|
|
1072
|
-
});
|
|
1073
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1074
|
-
res.end(responseJson);
|
|
1075
|
-
return true;
|
|
1076
|
-
},
|
|
1077
|
-
});
|
|
1078
|
-
// Send status with requestId so app can route decision back
|
|
1079
|
-
this.handleHookEvent(terminalId, event, provider, requestId);
|
|
1080
|
-
this.sendHookPermissionRequest(terminalId, event, requestId);
|
|
1081
|
-
}
|
|
1082
|
-
else {
|
|
1083
|
-
// All other hooks: respond immediately
|
|
1084
|
-
res.writeHead(200);
|
|
1085
|
-
res.end("ok");
|
|
1086
|
-
this.handleHookEvent(terminalId, event, provider);
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
catch (e) {
|
|
1090
|
-
res.writeHead(200);
|
|
1091
|
-
res.end("ok");
|
|
1092
|
-
this.log(`hook parse error: ${e}`);
|
|
1093
|
-
}
|
|
1094
|
-
});
|
|
1095
|
-
});
|
|
1096
|
-
// Listen on random port — await binding before reading address
|
|
1097
|
-
const port = await new Promise((resolve, reject) => {
|
|
1098
|
-
server.listen(0, "127.0.0.1", () => {
|
|
1099
|
-
const addr = server.address();
|
|
1100
|
-
resolve(addr.port);
|
|
1101
|
-
});
|
|
1102
|
-
server.on("error", reject);
|
|
1103
|
-
});
|
|
1104
|
-
this.log(`hook server for ${terminalId} (${provider}) listening on port ${port}, marker=${marker}`);
|
|
1105
|
-
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`;
|
|
1106
|
-
let configPath;
|
|
1107
|
-
if (provider === "codex") {
|
|
1108
|
-
configPath = this.setupCodexHooks(terminalId, curlCmd, marker);
|
|
1109
|
-
}
|
|
1110
|
-
else if (provider === "gemini") {
|
|
1111
|
-
configPath = this.setupGeminiHooks(terminalId, curlCmd, marker);
|
|
1112
|
-
}
|
|
1113
|
-
else if (provider === "copilot") {
|
|
1114
|
-
configPath = this.setupCopilotHooks(terminalId, curlCmd, marker);
|
|
1115
|
-
}
|
|
1116
|
-
else {
|
|
1117
|
-
// Claude (default)
|
|
1118
|
-
configPath = this.setupClaudeHooks(terminalId, curlCmd, args, marker);
|
|
1119
|
-
}
|
|
1120
|
-
return { server, port, configPath };
|
|
1121
|
-
}
|
|
1122
|
-
refreshAgentPermissionHooks() {
|
|
1123
|
-
const term = this.terminals.get(DEFAULT_TERMINAL_ID);
|
|
1124
|
-
if (!term?.hookPort)
|
|
1125
|
-
return;
|
|
1126
|
-
const marker = term.hookMarker;
|
|
1127
|
-
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`;
|
|
1128
|
-
const providers = this.options.agentProvider
|
|
1129
|
-
? [normalizeAgentProvider(this.options.agentProvider)]
|
|
1130
|
-
: detectAvailableProviders();
|
|
1131
|
-
try {
|
|
1132
|
-
for (const provider of providers) {
|
|
1133
|
-
if (provider === "codex") {
|
|
1134
|
-
this.setupCodexHooks(DEFAULT_TERMINAL_ID, curlCmd, marker);
|
|
1135
|
-
}
|
|
1136
|
-
else {
|
|
1137
|
-
// claude, custom
|
|
1138
|
-
this.setupClaudeHooks(DEFAULT_TERMINAL_ID, curlCmd, [], marker);
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
}
|
|
1142
|
-
catch (error) {
|
|
1143
|
-
this.log(`failed to refresh agent permission hooks: ${error instanceof Error ? error.message : String(error)}`);
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
setupClaudeHooks(terminalId, curlCmd, args, marker) {
|
|
1147
|
-
// Write hooks to ~/.claude/settings.json — Claude Code reads hooks from here
|
|
1148
|
-
const claudeDir = join(homedir(), ".claude");
|
|
1149
|
-
if (!existsSync(claudeDir))
|
|
1150
|
-
mkdirSync(claudeDir, { recursive: true });
|
|
1151
|
-
const settingsPath = join(claudeDir, "settings.json");
|
|
1152
|
-
let existing = {};
|
|
1153
|
-
try {
|
|
1154
|
-
existing = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
1155
|
-
}
|
|
1156
|
-
catch { /* doesn't exist yet */ }
|
|
1157
|
-
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
|
|
1158
|
-
const permissionEntry = {
|
|
1159
|
-
matcher: "",
|
|
1160
|
-
hooks: [{
|
|
1161
|
-
type: "command",
|
|
1162
|
-
command: curlCmd,
|
|
1163
|
-
timeout: Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000),
|
|
1164
|
-
}],
|
|
1165
|
-
};
|
|
1166
|
-
const hookEvents = {
|
|
1167
|
-
PreToolUse: hookEntry,
|
|
1168
|
-
PostToolUse: hookEntry,
|
|
1169
|
-
PostToolUseFailure: hookEntry,
|
|
1170
|
-
Stop: hookEntry,
|
|
1171
|
-
PermissionRequest: permissionEntry,
|
|
1172
|
-
UserPromptSubmit: hookEntry,
|
|
1173
|
-
SessionStart: hookEntry,
|
|
1174
|
-
};
|
|
1175
|
-
// Append our entries to existing hooks (first remove stale linkshell entries)
|
|
1176
|
-
const existingHooks = (existing.hooks ?? {});
|
|
1177
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1178
|
-
existingHooks[eventName] = eventName === "PermissionRequest"
|
|
1179
|
-
? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
|
|
1180
|
-
: withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1181
|
-
}
|
|
1182
|
-
const merged = { ...existing, hooks: existingHooks };
|
|
1183
|
-
writeFileSync(settingsPath, JSON.stringify(merged, null, 2));
|
|
1184
|
-
this.log(`claude hooks appended to ${settingsPath}`);
|
|
1185
|
-
return settingsPath;
|
|
1186
|
-
}
|
|
1187
|
-
setupCodexHooks(terminalId, curlCmd, marker) {
|
|
1188
|
-
// Codex uses ~/.codex/hooks.json — same format as Claude (with matcher)
|
|
1189
|
-
const codexDir = join(homedir(), ".codex");
|
|
1190
|
-
if (!existsSync(codexDir))
|
|
1191
|
-
mkdirSync(codexDir, { recursive: true });
|
|
1192
|
-
// Ensure [features] codex_hooks = true in config.toml
|
|
1193
|
-
const tomlPath = join(codexDir, "config.toml");
|
|
1194
|
-
let tomlContent = "";
|
|
1195
|
-
try {
|
|
1196
|
-
tomlContent = readFileSync(tomlPath, "utf8");
|
|
1197
|
-
}
|
|
1198
|
-
catch { /* doesn't exist yet */ }
|
|
1199
|
-
// Remove top-level codex_hooks (wrong location) and ensure it's under [features]
|
|
1200
|
-
const hasFeatureSection = tomlContent.includes("[features]");
|
|
1201
|
-
const hasCodexHooksUnderFeatures = hasFeatureSection &&
|
|
1202
|
-
/\[features\][^\[]*codex_hooks\s*=\s*true/s.test(tomlContent);
|
|
1203
|
-
if (!hasCodexHooksUnderFeatures) {
|
|
1204
|
-
// Remove any top-level codex_hooks line
|
|
1205
|
-
tomlContent = tomlContent.replace(/^codex_hooks\s*=.*\n?/m, "");
|
|
1206
|
-
if (!tomlContent.includes("[features]")) {
|
|
1207
|
-
tomlContent += `\n[features]\ncodex_hooks = true\n`;
|
|
1208
|
-
}
|
|
1209
|
-
else {
|
|
1210
|
-
tomlContent = tomlContent.replace("[features]", "[features]\ncodex_hooks = true");
|
|
1211
|
-
}
|
|
1212
|
-
writeFileSync(tomlPath, tomlContent);
|
|
1213
|
-
this.log(`enabled codex_hooks under [features] in ${tomlPath}`);
|
|
1214
|
-
}
|
|
1215
|
-
const hooksPath = join(codexDir, "hooks.json");
|
|
1216
|
-
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
|
|
1217
|
-
const permissionEntry = {
|
|
1218
|
-
matcher: "",
|
|
1219
|
-
hooks: [{
|
|
1220
|
-
type: "command",
|
|
1221
|
-
command: curlCmd,
|
|
1222
|
-
timeout: Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000),
|
|
1223
|
-
}],
|
|
1224
|
-
};
|
|
1225
|
-
const hookEvents = {
|
|
1226
|
-
SessionStart: hookEntry,
|
|
1227
|
-
PreToolUse: hookEntry,
|
|
1228
|
-
PostToolUse: hookEntry,
|
|
1229
|
-
UserPromptSubmit: hookEntry,
|
|
1230
|
-
Stop: hookEntry,
|
|
1231
|
-
PermissionRequest: permissionEntry,
|
|
1232
|
-
};
|
|
1233
|
-
// Read existing and append
|
|
1234
|
-
let existing = {};
|
|
1235
|
-
try {
|
|
1236
|
-
existing = JSON.parse(readFileSync(hooksPath, "utf8"));
|
|
1237
|
-
}
|
|
1238
|
-
catch { /* doesn't exist yet */ }
|
|
1239
|
-
const existingHooks = existing.hooks ?? {};
|
|
1240
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1241
|
-
existingHooks[eventName] = eventName === "PermissionRequest"
|
|
1242
|
-
? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
|
|
1243
|
-
: withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1244
|
-
}
|
|
1245
|
-
writeFileSync(hooksPath, JSON.stringify({ ...existing, hooks: existingHooks }, null, 2));
|
|
1246
|
-
this.log(`codex hooks appended to ${hooksPath}`);
|
|
1247
|
-
return hooksPath;
|
|
1248
|
-
}
|
|
1249
|
-
setupGeminiHooks(terminalId, curlCmd, marker) {
|
|
1250
|
-
// Gemini uses ~/.gemini/settings.json — same format as Claude (with matcher)
|
|
1251
|
-
const geminiDir = join(homedir(), ".gemini");
|
|
1252
|
-
if (!existsSync(geminiDir))
|
|
1253
|
-
mkdirSync(geminiDir, { recursive: true });
|
|
1254
|
-
const settingsPath = join(geminiDir, "settings.json");
|
|
1255
|
-
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5000 }] };
|
|
1256
|
-
const hookEvents = {
|
|
1257
|
-
SessionStart: hookEntry,
|
|
1258
|
-
SessionEnd: hookEntry,
|
|
1259
|
-
BeforeTool: hookEntry,
|
|
1260
|
-
AfterTool: hookEntry,
|
|
1261
|
-
};
|
|
1262
|
-
// Merge with existing settings if present
|
|
1263
|
-
let existing = {};
|
|
1264
|
-
try {
|
|
1265
|
-
existing = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
1266
|
-
}
|
|
1267
|
-
catch { /* doesn't exist yet */ }
|
|
1268
|
-
const existingHooks = (existing.hooks ?? {});
|
|
1269
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1270
|
-
existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1271
|
-
}
|
|
1272
|
-
existing.hooks = existingHooks;
|
|
1273
|
-
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
|
1274
|
-
this.log(`gemini hooks appended to ${settingsPath}`);
|
|
1275
|
-
return settingsPath;
|
|
1276
|
-
}
|
|
1277
|
-
setupCopilotHooks(terminalId, curlCmd, marker) {
|
|
1278
|
-
// Copilot loads hooks from CWD as hooks.json
|
|
1279
|
-
const cwd = this.terminals.get(terminalId)?.cwd ?? process.cwd();
|
|
1280
|
-
const hooksPath = join(cwd, "hooks.json");
|
|
1281
|
-
const mkHook = () => ({
|
|
1282
|
-
type: "command",
|
|
1283
|
-
bash: curlCmd,
|
|
1284
|
-
timeoutSec: 30,
|
|
1285
|
-
});
|
|
1286
|
-
const hookEvents = {
|
|
1287
|
-
sessionStart: mkHook(),
|
|
1288
|
-
sessionEnd: mkHook(),
|
|
1289
|
-
userPromptSubmitted: mkHook(),
|
|
1290
|
-
preToolUse: mkHook(),
|
|
1291
|
-
postToolUse: mkHook(),
|
|
1292
|
-
errorOccurred: mkHook(),
|
|
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] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1303
|
-
}
|
|
1304
|
-
writeFileSync(hooksPath, JSON.stringify({ version: 1, hooks: existingHooks }, null, 2));
|
|
1305
|
-
this.log(`copilot hooks appended to ${hooksPath}`);
|
|
1306
|
-
return hooksPath;
|
|
1307
|
-
}
|
|
1308
|
-
handleHookEvent(terminalId, event, provider, permissionRequestId) {
|
|
1309
|
-
const rawHookName = (event.hook_event_name ?? event.event_name);
|
|
1310
|
-
if (!rawHookName)
|
|
1311
|
-
return;
|
|
1312
|
-
// Auto-detect provider from hook event fields
|
|
1313
|
-
const hookTerm = this.terminals.get(terminalId);
|
|
1314
|
-
let detectedProvider = provider;
|
|
1315
|
-
// Always detect from transcript_path (most reliable), regardless of current provider
|
|
1316
|
-
const transcriptPath = typeof event.transcript_path === "string" ? event.transcript_path : "";
|
|
1317
|
-
if (transcriptPath.includes(".claude/")) {
|
|
1318
|
-
detectedProvider = "claude";
|
|
1319
|
-
}
|
|
1320
|
-
else if (transcriptPath.includes(".gemini/")) {
|
|
1321
|
-
detectedProvider = "gemini";
|
|
1322
|
-
}
|
|
1323
|
-
else if (transcriptPath.includes(".codex/")) {
|
|
1324
|
-
detectedProvider = "codex";
|
|
1325
|
-
}
|
|
1326
|
-
else if (hookTerm?.provider === "custom") {
|
|
1327
|
-
// Fallback heuristics only when provider is still unknown
|
|
1328
|
-
if (event.model && typeof event.model === "string" && /^(gpt|o[0-9]|codex)/i.test(event.model)) {
|
|
1329
|
-
detectedProvider = "codex";
|
|
1330
|
-
}
|
|
1331
|
-
else if (event.session_id && !transcriptPath) {
|
|
1332
|
-
detectedProvider = "codex";
|
|
1333
|
-
}
|
|
1334
|
-
else if (/^(Before|After)(Tool)$|^Session(Start|End)$/.test(rawHookName)) {
|
|
1335
|
-
detectedProvider = "gemini";
|
|
1336
|
-
}
|
|
1337
|
-
else if (/^(pre|post)ToolUse$|^session(Start|End)$|^userPromptSubmitted$|^errorOccurred$/.test(rawHookName)) {
|
|
1338
|
-
detectedProvider = "copilot";
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
if (hookTerm && detectedProvider !== hookTerm.provider) {
|
|
1342
|
-
const wasCustom = hookTerm.provider === "custom";
|
|
1343
|
-
hookTerm.provider = detectedProvider;
|
|
1344
|
-
this.log(`${wasCustom ? "detected" : "provider switched"} provider for ${terminalId}: ${detectedProvider}`);
|
|
1345
|
-
this.permissionStacks.delete(terminalId);
|
|
1346
|
-
this.sendTerminalList();
|
|
1347
|
-
}
|
|
1348
|
-
// Normalize hook event names from different providers to unified names
|
|
1349
|
-
const hookName = this.normalizeHookName(rawHookName, detectedProvider);
|
|
1350
|
-
if (!hookName)
|
|
1351
|
-
return;
|
|
1352
|
-
let phase;
|
|
1353
|
-
let toolName;
|
|
1354
|
-
let toolInput;
|
|
1355
|
-
let permissionRequest;
|
|
1356
|
-
let summary;
|
|
1357
|
-
switch (hookName) {
|
|
1358
|
-
case "PreToolUse":
|
|
1359
|
-
phase = "tool_use";
|
|
1360
|
-
toolName = (event.tool_name ?? event.toolName);
|
|
1361
|
-
if (event.tool_input && typeof event.tool_input === "object") {
|
|
1362
|
-
const input = event.tool_input;
|
|
1363
|
-
toolInput = JSON.stringify(input).slice(0, 200);
|
|
1364
|
-
}
|
|
1365
|
-
else if (event.toolInput && typeof event.toolInput === "object") {
|
|
1366
|
-
toolInput = JSON.stringify(event.toolInput).slice(0, 200);
|
|
1367
|
-
}
|
|
1368
|
-
break;
|
|
1369
|
-
case "PostToolUse":
|
|
1370
|
-
phase = "thinking";
|
|
1371
|
-
toolName = (event.tool_name ?? event.toolName);
|
|
1372
|
-
// Pop permission stack + auto-resolve pending HTTP connection
|
|
1373
|
-
{
|
|
1374
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1375
|
-
if (stack && stack.length > 0) {
|
|
1376
|
-
const popped = stack.pop();
|
|
1377
|
-
if (popped)
|
|
1378
|
-
this.autoResolvePending(popped.requestId);
|
|
1379
|
-
if (stack.length === 0)
|
|
1380
|
-
this.permissionStacks.delete(terminalId);
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
break;
|
|
1384
|
-
case "PostToolUseFailure":
|
|
1385
|
-
phase = "error";
|
|
1386
|
-
toolName = (event.tool_name ?? event.toolName);
|
|
1387
|
-
{
|
|
1388
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1389
|
-
if (stack && stack.length > 0) {
|
|
1390
|
-
const popped = stack.pop();
|
|
1391
|
-
if (popped)
|
|
1392
|
-
this.autoResolvePending(popped.requestId);
|
|
1393
|
-
if (stack.length === 0)
|
|
1394
|
-
this.permissionStacks.delete(terminalId);
|
|
1395
|
-
}
|
|
1396
|
-
}
|
|
1397
|
-
break;
|
|
1398
|
-
case "Stop":
|
|
1399
|
-
phase = "idle";
|
|
1400
|
-
if (event.stop_reason)
|
|
1401
|
-
summary = String(event.stop_reason);
|
|
1402
|
-
this.drainPendingPermissions(terminalId);
|
|
1403
|
-
this.permissionStacks.delete(terminalId);
|
|
1404
|
-
// Reset provider to "custom" when a CLI session ends inside a custom shell
|
|
1405
|
-
if (hookTerm && this.options.providerConfig.provider === "custom") {
|
|
1406
|
-
hookTerm.provider = "custom";
|
|
1407
|
-
this.log(`provider reset to custom for ${terminalId} (CLI session ended)`);
|
|
1408
|
-
this.sendTerminalList();
|
|
1409
|
-
}
|
|
1410
|
-
break;
|
|
1411
|
-
case "PermissionRequest":
|
|
1412
|
-
phase = "waiting";
|
|
1413
|
-
toolName = (event.tool_name ?? event.toolName);
|
|
1414
|
-
if (event.tool_input && typeof event.tool_input === "object") {
|
|
1415
|
-
const input = event.tool_input;
|
|
1416
|
-
permissionRequest = JSON.stringify(input).slice(0, 300);
|
|
1417
|
-
}
|
|
1418
|
-
else if (event.toolInput && typeof event.toolInput === "object") {
|
|
1419
|
-
permissionRequest = JSON.stringify(event.toolInput).slice(0, 300);
|
|
1420
|
-
}
|
|
1421
|
-
// Push to permission stack (use requestId from hook server if available)
|
|
1422
|
-
{
|
|
1423
|
-
const reqId = permissionRequestId ?? `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1424
|
-
if (!this.permissionStacks.has(terminalId)) {
|
|
1425
|
-
this.permissionStacks.set(terminalId, []);
|
|
1426
|
-
}
|
|
1427
|
-
this.permissionStacks.get(terminalId).push({
|
|
1428
|
-
requestId: reqId,
|
|
1429
|
-
toolName: toolName ?? "unknown",
|
|
1430
|
-
toolInput: toolInput ?? (permissionRequest ?? ""),
|
|
1431
|
-
permissionRequest: permissionRequest ?? "",
|
|
1432
|
-
timestamp: Date.now(),
|
|
1433
|
-
});
|
|
1434
|
-
}
|
|
1435
|
-
break;
|
|
1436
|
-
case "SessionStart":
|
|
1437
|
-
phase = "idle";
|
|
1438
|
-
summary = "session started";
|
|
1439
|
-
break;
|
|
1440
|
-
case "UserPromptSubmit":
|
|
1441
|
-
phase = "thinking";
|
|
1442
|
-
this.drainPendingPermissions(terminalId);
|
|
1443
|
-
this.permissionStacks.delete(terminalId);
|
|
1444
|
-
break;
|
|
1445
|
-
default:
|
|
1446
|
-
return;
|
|
1447
|
-
}
|
|
1448
|
-
this.log(`hook event [${provider}]: ${rawHookName} → ${hookName} → phase=${phase} tool=${toolName ?? "none"}`);
|
|
1449
|
-
// Build topPermission from stack
|
|
1450
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1451
|
-
const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
|
|
1452
|
-
const pendingPermissionCount = stack?.length ?? 0;
|
|
1453
|
-
// Increment statusSeq for ordering
|
|
1454
|
-
const term = this.terminals.get(terminalId);
|
|
1455
|
-
const seq = term ? term.statusSeq++ : 0;
|
|
1456
|
-
this.send(createEnvelope({
|
|
1457
|
-
type: "terminal.status",
|
|
1458
|
-
sessionId: this.sessionId,
|
|
1459
|
-
terminalId,
|
|
1460
|
-
payload: {
|
|
1461
|
-
phase,
|
|
1462
|
-
seq,
|
|
1463
|
-
...(toolName && { toolName }),
|
|
1464
|
-
...(toolInput && { toolInput }),
|
|
1465
|
-
...(permissionRequest && { permissionRequest }),
|
|
1466
|
-
...(summary && { summary }),
|
|
1467
|
-
...(topPermission && { topPermission }),
|
|
1468
|
-
...(pendingPermissionCount > 0 && { pendingPermissionCount }),
|
|
1469
|
-
},
|
|
1470
|
-
}));
|
|
1471
|
-
}
|
|
1472
|
-
sendHookPermissionRequest(terminalId, event, requestId) {
|
|
1473
|
-
const toolName = (event.tool_name ?? event.toolName);
|
|
1474
|
-
const toolInput = stringifyHookInput(event.tool_input ?? event.toolInput);
|
|
1475
|
-
const suggestions = hookPermissionSuggestions(event);
|
|
1476
|
-
const context = typeof event.permission_prompt === "string"
|
|
1477
|
-
? event.permission_prompt
|
|
1478
|
-
: typeof event.message === "string"
|
|
1479
|
-
? event.message
|
|
1480
|
-
: undefined;
|
|
1481
|
-
this.send(createEnvelope({
|
|
1482
|
-
type: "agent.permission.request",
|
|
1483
|
-
sessionId: this.sessionId,
|
|
1484
|
-
terminalId,
|
|
1485
|
-
payload: {
|
|
1486
|
-
requestId,
|
|
1487
|
-
toolName,
|
|
1488
|
-
toolInput,
|
|
1489
|
-
context,
|
|
1490
|
-
options: hookPermissionOptions(suggestions),
|
|
1491
|
-
},
|
|
1492
|
-
}));
|
|
1493
|
-
}
|
|
1494
|
-
/**
|
|
1495
|
-
* Normalize hook event names from different CLI providers to unified internal names.
|
|
1496
|
-
* Claude: PascalCase (PreToolUse, PostToolUse, Stop, PermissionRequest)
|
|
1497
|
-
* Codex: camelCase (preToolUse, postToolUse, sessionStart)
|
|
1498
|
-
* Gemini: PascalCase but different names (BeforeTool, AfterTool, BeforeSubmitPrompt)
|
|
1499
|
-
*/
|
|
1500
|
-
normalizeHookName(rawName, provider) {
|
|
1501
|
-
// Claude events — already in our canonical format
|
|
1502
|
-
if (provider === "claude") {
|
|
1503
|
-
return rawName;
|
|
1504
|
-
}
|
|
1505
|
-
// Codex events — same as Claude (PascalCase)
|
|
1506
|
-
if (provider === "codex") {
|
|
1507
|
-
switch (rawName) {
|
|
1508
|
-
case "PreToolUse":
|
|
1509
|
-
case "preToolUse": return "PreToolUse";
|
|
1510
|
-
case "PostToolUse":
|
|
1511
|
-
case "postToolUse": return "PostToolUse";
|
|
1512
|
-
case "SessionStart":
|
|
1513
|
-
case "sessionStart": return "SessionStart";
|
|
1514
|
-
case "UserPromptSubmit": return "UserPromptSubmit";
|
|
1515
|
-
case "PermissionRequest": return "PermissionRequest";
|
|
1516
|
-
case "Stop": return "Stop";
|
|
1517
|
-
default: return undefined;
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
|
-
// Gemini events
|
|
1521
|
-
if (provider === "gemini") {
|
|
1522
|
-
switch (rawName) {
|
|
1523
|
-
case "BeforeTool": return "PreToolUse";
|
|
1524
|
-
case "AfterTool": return "PostToolUse";
|
|
1525
|
-
case "SessionStart": return "SessionStart";
|
|
1526
|
-
case "SessionEnd": return "Stop";
|
|
1527
|
-
default: return undefined;
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
// Copilot events (camelCase)
|
|
1531
|
-
if (provider === "copilot") {
|
|
1532
|
-
switch (rawName) {
|
|
1533
|
-
case "preToolUse": return "PreToolUse";
|
|
1534
|
-
case "postToolUse": return "PostToolUse";
|
|
1535
|
-
case "sessionStart": return "SessionStart";
|
|
1536
|
-
case "sessionEnd": return "Stop";
|
|
1537
|
-
case "userPromptSubmitted": return "UserPromptSubmit";
|
|
1538
|
-
case "errorOccurred": return "PostToolUseFailure";
|
|
1539
|
-
default: return undefined;
|
|
1540
|
-
}
|
|
1541
|
-
}
|
|
1542
|
-
// Unknown provider — try all known formats
|
|
1543
|
-
// This handles "custom" shell where any provider might be launched
|
|
1544
|
-
const allProviders = ["claude", "codex", "gemini", "copilot"];
|
|
1545
|
-
for (const p of allProviders) {
|
|
1546
|
-
const result = this.normalizeHookName(rawName, p);
|
|
1547
|
-
if (result)
|
|
1548
|
-
return result;
|
|
1549
|
-
}
|
|
1550
|
-
return undefined;
|
|
1551
|
-
}
|
|
1552
|
-
/** Auto-resolve a single pending permission (user acted in terminal) */
|
|
1553
|
-
autoResolvePending(requestId) {
|
|
1554
|
-
if (this.resolvePendingPermission(requestId, "allow", "terminal.auto").resolved) {
|
|
1555
|
-
this.log(`auto-resolved pending permission ${requestId} (user acted in terminal)`);
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
/** Drain all pending permissions for a terminal (session ended, stop, etc.) */
|
|
1559
|
-
drainPendingPermissions(terminalId) {
|
|
1560
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1561
|
-
if (!stack)
|
|
1562
|
-
return;
|
|
1563
|
-
for (const entry of [...stack]) {
|
|
1564
|
-
if (this.resolvePendingPermission(entry.requestId, "deny", "terminal.drain").resolved) {
|
|
1565
|
-
this.log(`drained pending permission ${entry.requestId}`);
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
|
-
}
|
|
1569
|
-
resolvePendingPermission(requestId, choice, source = "unknown") {
|
|
1570
|
-
const pending = this.pendingPermissions.get(requestId);
|
|
1571
|
-
const outcome = typeof choice === "string" ? choice : choice.outcome;
|
|
1572
|
-
const optionId = typeof choice === "string" ? undefined : choice.optionId;
|
|
1573
|
-
if (!pending) {
|
|
1574
|
-
this.log(`no pending permission for ${requestId} via ${source}: ${outcome}:${optionId ?? "default"}`);
|
|
1575
|
-
return { resolved: false, delivered: false };
|
|
1576
|
-
}
|
|
1577
|
-
this.pendingPermissions.delete(requestId);
|
|
1578
|
-
clearTimeout(pending.timeout);
|
|
1579
|
-
const delivered = pending.resolve(this.formatHookPermissionDecision(pending, choice));
|
|
1580
|
-
const stack = this.permissionStacks.get(pending.terminalId);
|
|
1581
|
-
if (stack) {
|
|
1582
|
-
const idx = stack.findIndex((entry) => entry.requestId === requestId);
|
|
1583
|
-
if (idx >= 0)
|
|
1584
|
-
stack.splice(idx, 1);
|
|
1585
|
-
if (stack.length === 0)
|
|
1586
|
-
this.permissionStacks.delete(pending.terminalId);
|
|
1587
|
-
}
|
|
1588
|
-
this.log(`resolved permission ${requestId} via ${source}: ${outcome}:${optionId ?? "default"} delivered=${delivered}`);
|
|
1589
|
-
this.sendPermissionSnapshot(pending.terminalId, "thinking", outcome === "allow" ? "permission allowed" : "permission denied", { requestId, outcome, source, delivered });
|
|
1590
|
-
return { resolved: true, delivered };
|
|
1591
|
-
}
|
|
1592
|
-
formatHookPermissionDecision(permission, choice) {
|
|
1593
|
-
const outcome = typeof choice === "string" ? choice : choice.outcome;
|
|
1594
|
-
const optionId = typeof choice === "string" ? undefined : choice.optionId;
|
|
1595
|
-
if (outcome === "allow") {
|
|
1596
|
-
return {
|
|
1597
|
-
behavior: "allow",
|
|
1598
|
-
...(optionId === "allow_always" && permission.permissionSuggestions.length > 0
|
|
1599
|
-
? { updatedPermissions: permission.permissionSuggestions }
|
|
1600
|
-
: {}),
|
|
1601
|
-
};
|
|
1602
|
-
}
|
|
1603
|
-
return {
|
|
1604
|
-
behavior: "deny",
|
|
1605
|
-
message: outcome === "cancelled" ? "Permission request cancelled." : "Permission denied by user.",
|
|
1606
|
-
};
|
|
1607
|
-
}
|
|
1608
|
-
sendPermissionSnapshot(terminalId, phase, summary, permissionResolution) {
|
|
1609
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1610
|
-
const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
|
|
1611
|
-
const pendingPermissionCount = stack?.length ?? 0;
|
|
1612
|
-
const term = this.terminals.get(terminalId);
|
|
1613
|
-
const seq = term ? term.statusSeq++ : 0;
|
|
1614
|
-
this.send(createEnvelope({
|
|
1615
|
-
type: "terminal.status",
|
|
1616
|
-
sessionId: this.sessionId,
|
|
1617
|
-
terminalId,
|
|
1618
|
-
payload: {
|
|
1619
|
-
phase,
|
|
1620
|
-
seq,
|
|
1621
|
-
...(summary && { summary }),
|
|
1622
|
-
...(permissionResolution && { permissionResolution }),
|
|
1623
|
-
...(topPermission && { topPermission }),
|
|
1624
|
-
...(pendingPermissionCount > 0 && { pendingPermissionCount }),
|
|
1625
|
-
},
|
|
1626
|
-
}));
|
|
1627
|
-
}
|
|
1628
|
-
cleanupHookServer(term) {
|
|
1629
|
-
// Drain any pending permission requests for this terminal
|
|
1630
|
-
this.drainPendingPermissions(term.id);
|
|
1631
|
-
if (term.hookServer) {
|
|
1632
|
-
term.hookServer.close();
|
|
1633
|
-
term.hookServer = undefined;
|
|
1634
|
-
this.log(`hook server closed for ${term.id}`);
|
|
1635
|
-
}
|
|
1636
|
-
const marker = term.hookMarker;
|
|
1637
|
-
for (const configPath of term.hookConfigPaths) {
|
|
1638
|
-
try {
|
|
1639
|
-
// Copilot: per-instance file — just delete it
|
|
1640
|
-
if (configPath.includes(`linkshell-${marker}`)) {
|
|
1641
|
-
if (existsSync(configPath)) {
|
|
1642
|
-
unlinkSync(configPath);
|
|
1643
|
-
this.log(`removed copilot hook file ${configPath}`);
|
|
1644
|
-
}
|
|
1645
|
-
}
|
|
1646
|
-
else {
|
|
1647
|
-
// Claude/Codex/Gemini: remove our entries from the shared config
|
|
1648
|
-
this.removeHookEntries(configPath, marker);
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
catch { /* ignore */ }
|
|
1652
|
-
}
|
|
1653
|
-
term.hookConfigPaths = [];
|
|
1654
|
-
}
|
|
1655
|
-
/** Remove hook entries containing our marker from a JSON config file */
|
|
1656
|
-
removeHookEntries(configPath, marker) {
|
|
1657
|
-
if (!existsSync(configPath))
|
|
1658
|
-
return;
|
|
1659
|
-
try {
|
|
1660
|
-
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1661
|
-
const hooks = raw.hooks;
|
|
1662
|
-
if (!hooks)
|
|
1663
|
-
return;
|
|
1664
|
-
let changed = false;
|
|
1665
|
-
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
1666
|
-
if (!Array.isArray(entries))
|
|
1667
|
-
continue;
|
|
1668
|
-
const filtered = entries.filter((entry) => {
|
|
1669
|
-
const str = JSON.stringify(entry);
|
|
1670
|
-
return !str.includes(marker);
|
|
1671
|
-
});
|
|
1672
|
-
if (filtered.length !== entries.length) {
|
|
1673
|
-
changed = true;
|
|
1674
|
-
if (filtered.length === 0) {
|
|
1675
|
-
delete hooks[eventName];
|
|
1676
|
-
}
|
|
1677
|
-
else {
|
|
1678
|
-
hooks[eventName] = filtered;
|
|
1679
|
-
}
|
|
1680
|
-
}
|
|
1681
|
-
}
|
|
1682
|
-
if (changed) {
|
|
1683
|
-
// If no hooks left, remove the hooks key entirely
|
|
1684
|
-
if (Object.keys(hooks).length === 0) {
|
|
1685
|
-
delete raw.hooks;
|
|
1686
|
-
}
|
|
1687
|
-
writeFileSync(configPath, JSON.stringify(raw, null, 2));
|
|
1688
|
-
this.log(`removed our hook entries from ${configPath}`);
|
|
1689
|
-
}
|
|
1690
|
-
}
|
|
1691
|
-
catch { /* ignore parse errors */ }
|
|
1692
|
-
}
|
|
1693
885
|
send(message) {
|
|
1694
886
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
1695
887
|
return;
|
|
1696
888
|
}
|
|
1697
889
|
const machineId = this.machineIdentity?.machineId;
|
|
1698
|
-
const enriched = machineId && (message.type === "
|
|
1699
|
-
message.type === "agent.capabilities" ||
|
|
890
|
+
const enriched = machineId && (message.type === "agent.capabilities" ||
|
|
1700
891
|
message.type === "agent.snapshot" ||
|
|
1701
892
|
message.type === "agent.v2.capabilities" ||
|
|
1702
893
|
message.type === "agent.v2.snapshot")
|
|
@@ -1714,8 +905,8 @@ export class BridgeSession {
|
|
|
1714
905
|
this.stopHeartbeat();
|
|
1715
906
|
this.heartbeatTimer = setInterval(() => {
|
|
1716
907
|
this.send(createEnvelope({
|
|
1717
|
-
type: "
|
|
1718
|
-
|
|
908
|
+
type: "device.heartbeat",
|
|
909
|
+
hostDeviceId: this.sessionId,
|
|
1719
910
|
payload: { ts: Date.now() },
|
|
1720
911
|
}));
|
|
1721
912
|
}, HEARTBEAT_INTERVAL);
|
|
@@ -1830,7 +1021,6 @@ export class BridgeSession {
|
|
|
1830
1021
|
}
|
|
1831
1022
|
this.tunnelSockets.clear();
|
|
1832
1023
|
for (const term of this.terminals.values()) {
|
|
1833
|
-
this.cleanupHookServer(term);
|
|
1834
1024
|
if (term.status === "running")
|
|
1835
1025
|
term.pty.kill();
|
|
1836
1026
|
}
|