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,7 +2,7 @@ import * as pty from "node-pty";
|
|
|
2
2
|
import * as http from "node:http";
|
|
3
3
|
import WebSocket from "ws";
|
|
4
4
|
import { hostname, platform, homedir } from "node:os";
|
|
5
|
-
import { writeFileSync, readFileSync, readdirSync, statSync,
|
|
5
|
+
import { writeFileSync, readFileSync, readdirSync, statSync, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join, basename, resolve } from "node:path";
|
|
8
8
|
import {
|
|
@@ -29,7 +29,7 @@ export interface BridgeSessionOptions {
|
|
|
29
29
|
gatewayUrl: string;
|
|
30
30
|
gatewayHttpUrl: string;
|
|
31
31
|
pairingGateway?: string;
|
|
32
|
-
|
|
32
|
+
hostDeviceId?: string;
|
|
33
33
|
cols: number;
|
|
34
34
|
rows: number;
|
|
35
35
|
clientName: string;
|
|
@@ -49,166 +49,14 @@ const RECONNECT_BASE_DELAY = 1_000;
|
|
|
49
49
|
const RECONNECT_MAX_DELAY = 30_000;
|
|
50
50
|
const RECONNECT_MAX_ATTEMPTS = 20;
|
|
51
51
|
const DEFAULT_TERMINAL_ID = "default";
|
|
52
|
-
const HOOK_BODY_LIMIT = 256 * 1024;
|
|
53
|
-
const PERMISSION_REQUEST_TIMEOUT_MS = Number(
|
|
54
|
-
process.env.LINKSHELL_PERMISSION_TIMEOUT_MS ?? 5 * 60_000,
|
|
55
|
-
);
|
|
56
|
-
const LINKSHELL_PERMISSION_GUARD_MARKER = "LINKSHELL_PERMISSION_GUARD";
|
|
57
52
|
|
|
58
53
|
interface TerminalInstance {
|
|
59
54
|
id: string;
|
|
60
55
|
pty: pty.IPty;
|
|
61
56
|
cwd: string;
|
|
62
|
-
projectName: string;
|
|
63
|
-
provider: string;
|
|
64
57
|
scrollback: ScrollbackBuffer;
|
|
65
58
|
outputSeq: number;
|
|
66
|
-
statusSeq: number;
|
|
67
59
|
status: "running" | "exited";
|
|
68
|
-
hookServer?: http.Server;
|
|
69
|
-
hookPort?: number;
|
|
70
|
-
hookMarker: string;
|
|
71
|
-
hookConfigPaths: string[];
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
interface PendingPermission {
|
|
75
|
-
terminalId: string;
|
|
76
|
-
timeout: ReturnType<typeof setTimeout>;
|
|
77
|
-
permissionSuggestions: unknown[];
|
|
78
|
-
resolve: (decision: HookPermissionDecision) => boolean;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
interface HookPermissionDecision {
|
|
82
|
-
behavior: "allow" | "deny";
|
|
83
|
-
updatedPermissions?: unknown[];
|
|
84
|
-
message?: string;
|
|
85
|
-
interrupt?: boolean;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
type HookPermissionChoice =
|
|
89
|
-
| "allow"
|
|
90
|
-
| "deny"
|
|
91
|
-
| {
|
|
92
|
-
outcome: "allow" | "deny" | "cancelled";
|
|
93
|
-
optionId?: string;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
function isLinkShellHookEntry(entry: unknown, marker?: string): boolean {
|
|
97
|
-
let raw = "";
|
|
98
|
-
try {
|
|
99
|
-
raw = JSON.stringify(entry);
|
|
100
|
-
} catch {
|
|
101
|
-
raw = String(entry);
|
|
102
|
-
}
|
|
103
|
-
return (
|
|
104
|
-
(marker ? raw.includes(`/hook?m=${marker}`) : false) ||
|
|
105
|
-
raw.includes("/hook?m=lsh-") ||
|
|
106
|
-
(raw.includes("/hook?m=") && raw.includes("LINKSHELL_ID"))
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function withLinkShellHookEntry<T>(
|
|
111
|
-
entries: unknown[] | undefined,
|
|
112
|
-
entry: T,
|
|
113
|
-
priority: "first" | "last",
|
|
114
|
-
): unknown[] {
|
|
115
|
-
const cleaned = (Array.isArray(entries) ? entries : []).filter((item) => !isLinkShellHookEntry(item));
|
|
116
|
-
return priority === "first" ? [entry, ...cleaned] : [...cleaned, entry];
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function guardPermissionCommandForLinkShell(command: unknown): unknown {
|
|
120
|
-
if (typeof command !== "string") return command;
|
|
121
|
-
if (command.includes(LINKSHELL_PERMISSION_GUARD_MARKER)) return command;
|
|
122
|
-
return [
|
|
123
|
-
`case "\${LINKSHELL_ID:-}" in lsh-*) exit 0 ;; esac`,
|
|
124
|
-
`# ${LINKSHELL_PERMISSION_GUARD_MARKER}`,
|
|
125
|
-
command,
|
|
126
|
-
].join("\n");
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function guardPermissionHookObjectForLinkShell(
|
|
130
|
-
hook: Record<string, unknown>,
|
|
131
|
-
): Record<string, unknown> {
|
|
132
|
-
if (isLinkShellHookEntry(hook)) return hook;
|
|
133
|
-
const next: Record<string, unknown> = { ...hook };
|
|
134
|
-
if (typeof next.command === "string") {
|
|
135
|
-
next.command = guardPermissionCommandForLinkShell(next.command);
|
|
136
|
-
}
|
|
137
|
-
if (typeof next.bash === "string") {
|
|
138
|
-
next.bash = guardPermissionCommandForLinkShell(next.bash);
|
|
139
|
-
}
|
|
140
|
-
return next;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function guardPermissionHookEntryForLinkShell(entry: unknown): unknown {
|
|
144
|
-
if (isLinkShellHookEntry(entry)) return entry;
|
|
145
|
-
if (typeof entry === "string") return guardPermissionCommandForLinkShell(entry);
|
|
146
|
-
if (Array.isArray(entry)) return entry.map(guardPermissionHookEntryForLinkShell);
|
|
147
|
-
if (!entry || typeof entry !== "object") return entry;
|
|
148
|
-
|
|
149
|
-
const next = { ...(entry as Record<string, unknown>) };
|
|
150
|
-
if (Array.isArray(next.hooks)) {
|
|
151
|
-
next.hooks = next.hooks.map((hook) =>
|
|
152
|
-
hook && typeof hook === "object" && !Array.isArray(hook)
|
|
153
|
-
? guardPermissionHookObjectForLinkShell(hook as Record<string, unknown>)
|
|
154
|
-
: guardPermissionHookEntryForLinkShell(hook),
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
if (typeof next.command === "string" || typeof next.bash === "string") {
|
|
158
|
-
return guardPermissionHookObjectForLinkShell(next);
|
|
159
|
-
}
|
|
160
|
-
return next;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function withBlockingLinkShellPermissionEntry<T>(
|
|
164
|
-
entries: unknown[] | undefined,
|
|
165
|
-
entry: T,
|
|
166
|
-
): unknown[] {
|
|
167
|
-
const cleaned = (Array.isArray(entries) ? entries : [])
|
|
168
|
-
.filter((item) => !isLinkShellHookEntry(item))
|
|
169
|
-
.map(guardPermissionHookEntryForLinkShell);
|
|
170
|
-
return [entry, ...cleaned];
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function stringifyHookInput(value: unknown): string {
|
|
174
|
-
if (typeof value === "string") return value.slice(0, 1200);
|
|
175
|
-
if (typeof value === "object" && value) {
|
|
176
|
-
try {
|
|
177
|
-
return JSON.stringify(value, null, 2).slice(0, 1200);
|
|
178
|
-
} catch {
|
|
179
|
-
return String(value).slice(0, 1200);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
return "";
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function hookPermissionSuggestions(event: Record<string, unknown>): unknown[] {
|
|
186
|
-
if (isCodexPermissionRequest(event)) return [];
|
|
187
|
-
const snake = event.permission_suggestions;
|
|
188
|
-
const camel = event.permissionSuggestions;
|
|
189
|
-
if (Array.isArray(snake)) return snake;
|
|
190
|
-
if (Array.isArray(camel)) return camel;
|
|
191
|
-
return [];
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function isCodexPermissionRequest(event: Record<string, unknown>): boolean {
|
|
195
|
-
if (typeof event.turn_id === "string" || typeof event.turnId === "string") return true;
|
|
196
|
-
const transcriptPath = event.transcript_path ?? event.transcriptPath;
|
|
197
|
-
return typeof transcriptPath === "string" && transcriptPath.includes("/.codex/");
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function hookPermissionOptions(suggestions: unknown[]): Array<{
|
|
201
|
-
id: string;
|
|
202
|
-
label: string;
|
|
203
|
-
kind: "allow" | "deny" | "other";
|
|
204
|
-
}> {
|
|
205
|
-
return [
|
|
206
|
-
{ id: "deny", label: "拒绝", kind: "deny" },
|
|
207
|
-
{ id: "allow_once", label: "允许一次", kind: "allow" },
|
|
208
|
-
...(suggestions.length > 0
|
|
209
|
-
? [{ id: "allow_always" as const, label: "始终允许", kind: "allow" as const }]
|
|
210
|
-
: []),
|
|
211
|
-
];
|
|
212
60
|
}
|
|
213
61
|
|
|
214
62
|
function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
|
|
@@ -285,16 +133,6 @@ export class BridgeSession {
|
|
|
285
133
|
private sessionId = "";
|
|
286
134
|
private exited = false;
|
|
287
135
|
private stopped = false;
|
|
288
|
-
private permissionStacks = new Map<string, Array<{
|
|
289
|
-
requestId: string;
|
|
290
|
-
toolName: string;
|
|
291
|
-
toolInput: string;
|
|
292
|
-
permissionRequest: string;
|
|
293
|
-
timestamp: number;
|
|
294
|
-
}>>();
|
|
295
|
-
// Pending permission responses: requestId → HTTP response callback
|
|
296
|
-
private pendingPermissions = new Map<string, PendingPermission>();
|
|
297
|
-
private hookMarker = `lsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
298
136
|
private screenCapture: ScreenFallback | undefined;
|
|
299
137
|
private screenShare: ScreenShare | undefined;
|
|
300
138
|
private tunnelSockets = new Map<string, WebSocket>();
|
|
@@ -305,7 +143,7 @@ export class BridgeSession {
|
|
|
305
143
|
|
|
306
144
|
constructor(options: BridgeSessionOptions) {
|
|
307
145
|
this.options = options;
|
|
308
|
-
this.sessionId = options.
|
|
146
|
+
this.sessionId = options.hostDeviceId ?? "";
|
|
309
147
|
}
|
|
310
148
|
|
|
311
149
|
private log(msg: string): void {
|
|
@@ -314,26 +152,19 @@ export class BridgeSession {
|
|
|
314
152
|
}
|
|
315
153
|
}
|
|
316
154
|
|
|
317
|
-
private terminalHookMarker(terminalId: string): string {
|
|
318
|
-
const safeTerminalId = terminalId.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
|
319
|
-
return `${this.hookMarker}-${safeTerminalId}`;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
155
|
async start(): Promise<void> {
|
|
323
156
|
this.log(
|
|
324
|
-
`starting
|
|
157
|
+
`starting device bridge (gateway=${this.options.gatewayUrl}, terminal=shell)`,
|
|
325
158
|
);
|
|
326
159
|
this.machineIdentity = loadOrCreateMachineIdentity();
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
160
|
+
this.sessionId ||= this.machineIdentity.machineId;
|
|
161
|
+
await this.createPairing();
|
|
330
162
|
if (this.options.keepAwake) {
|
|
331
163
|
this.keepAwake = startKeepAwake();
|
|
332
164
|
} else {
|
|
333
165
|
process.stderr.write("[bridge] keep-awake disabled\n");
|
|
334
166
|
}
|
|
335
167
|
if (this.options.agentUi) {
|
|
336
|
-
process.env.LINKSHELL_ID = this.terminalHookMarker(DEFAULT_TERMINAL_ID);
|
|
337
168
|
const availableProviders = this.options.agentProvider
|
|
338
169
|
? [normalizeAgentProvider(this.options.agentProvider)]
|
|
339
170
|
: detectAvailableProviders();
|
|
@@ -366,17 +197,17 @@ export class BridgeSession {
|
|
|
366
197
|
const res = await fetch(`${this.options.gatewayHttpUrl}/pairings`, {
|
|
367
198
|
method: "POST",
|
|
368
199
|
headers,
|
|
369
|
-
body: JSON.stringify({}),
|
|
200
|
+
body: JSON.stringify({ hostDeviceId: this.sessionId }),
|
|
370
201
|
});
|
|
371
202
|
if (!res.ok) {
|
|
372
203
|
throw new Error(`Failed to create pairing: ${res.status}`);
|
|
373
204
|
}
|
|
374
205
|
const body = (await res.json()) as {
|
|
375
|
-
|
|
206
|
+
hostDeviceId: string;
|
|
376
207
|
pairingCode: string;
|
|
377
208
|
expiresAt: string;
|
|
378
209
|
};
|
|
379
|
-
this.sessionId = body.
|
|
210
|
+
this.sessionId = body.hostDeviceId;
|
|
380
211
|
|
|
381
212
|
const pairingGateway = resolvePairingGateway(
|
|
382
213
|
this.options.gatewayHttpUrl,
|
|
@@ -389,7 +220,7 @@ export class BridgeSession {
|
|
|
389
220
|
process.stderr.write(
|
|
390
221
|
`\n \x1b[1mPairing code: \x1b[36m${body.pairingCode}\x1b[0m\n`,
|
|
391
222
|
);
|
|
392
|
-
process.stderr.write(`
|
|
223
|
+
process.stderr.write(` Host device: ${body.hostDeviceId}\n`);
|
|
393
224
|
process.stderr.write(` Expires: ${body.expiresAt}\n\n`);
|
|
394
225
|
if (!pairingGateway) {
|
|
395
226
|
process.stderr.write(
|
|
@@ -444,7 +275,7 @@ export class BridgeSession {
|
|
|
444
275
|
}
|
|
445
276
|
|
|
446
277
|
const url = new URL(this.options.gatewayUrl);
|
|
447
|
-
url.searchParams.set("
|
|
278
|
+
url.searchParams.set("hostDeviceId", this.sessionId);
|
|
448
279
|
url.searchParams.set("role", "host");
|
|
449
280
|
const authToken = await this.resolveAuthToken();
|
|
450
281
|
if (authToken) {
|
|
@@ -463,18 +294,22 @@ export class BridgeSession {
|
|
|
463
294
|
this.reconnecting = false;
|
|
464
295
|
this.send(
|
|
465
296
|
createEnvelope({
|
|
466
|
-
type: "
|
|
467
|
-
|
|
297
|
+
type: "device.connect",
|
|
298
|
+
hostDeviceId: this.sessionId,
|
|
468
299
|
payload: {
|
|
469
300
|
role: "host" as const,
|
|
470
301
|
clientName: this.options.clientName,
|
|
471
|
-
provider: this.options.providerConfig.provider,
|
|
472
302
|
protocolVersion: PROTOCOL_VERSION,
|
|
473
303
|
machineId: this.machineIdentity?.machineId,
|
|
474
304
|
hostname: this.options.hostname || hostname(),
|
|
475
305
|
platform: platform(),
|
|
476
306
|
cwd: process.cwd(),
|
|
477
|
-
|
|
307
|
+
capabilities: [
|
|
308
|
+
"terminal",
|
|
309
|
+
...(this.options.agentUi ? ["agent-ui"] : []),
|
|
310
|
+
...(this.options.screen ? ["screen"] : []),
|
|
311
|
+
"tunnel",
|
|
312
|
+
],
|
|
478
313
|
},
|
|
479
314
|
}),
|
|
480
315
|
);
|
|
@@ -531,7 +366,7 @@ export class BridgeSession {
|
|
|
531
366
|
}
|
|
532
367
|
case "terminal.spawn": {
|
|
533
368
|
const p = parseTypedPayload("terminal.spawn", envelope.payload);
|
|
534
|
-
const normalizedCwd = resolve(p.cwd);
|
|
369
|
+
const normalizedCwd = resolve(p.cwd ?? process.cwd());
|
|
535
370
|
// Dedup: if a running terminal already exists for this cwd, return it
|
|
536
371
|
const existing = [...this.terminals.values()].find(
|
|
537
372
|
(t) => t.status === "running" && resolve(t.cwd) === normalizedCwd,
|
|
@@ -541,17 +376,17 @@ export class BridgeSession {
|
|
|
541
376
|
type: "terminal.spawned",
|
|
542
377
|
sessionId: this.sessionId,
|
|
543
378
|
terminalId: existing.id,
|
|
544
|
-
payload: { terminalId: existing.id, cwd: existing.cwd,
|
|
379
|
+
payload: { terminalId: existing.id, cwd: existing.cwd, shell: this.options.providerConfig.command },
|
|
545
380
|
}));
|
|
546
381
|
} else {
|
|
547
382
|
const newId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
548
383
|
try {
|
|
549
|
-
await this.spawnTerminal(newId, normalizedCwd
|
|
384
|
+
await this.spawnTerminal(newId, normalizedCwd);
|
|
550
385
|
this.send(createEnvelope({
|
|
551
386
|
type: "terminal.spawned",
|
|
552
387
|
sessionId: this.sessionId,
|
|
553
388
|
terminalId: newId,
|
|
554
|
-
payload: { terminalId: newId, cwd: normalizedCwd,
|
|
389
|
+
payload: { terminalId: newId, cwd: normalizedCwd, shell: this.options.providerConfig.command },
|
|
555
390
|
}));
|
|
556
391
|
} catch (err) {
|
|
557
392
|
this.log(`failed to spawn terminal ${newId}: ${err}`);
|
|
@@ -581,23 +416,81 @@ export class BridgeSession {
|
|
|
581
416
|
const browsePath = resolve(rawPath);
|
|
582
417
|
try {
|
|
583
418
|
const entries = readdirSync(browsePath, { withFileTypes: true })
|
|
584
|
-
.filter((d) => d.isDirectory() &&
|
|
585
|
-
.
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
419
|
+
.filter((d) => !d.name.startsWith(".") && (d.isDirectory() || (p.includeFiles && d.isFile())))
|
|
420
|
+
.map((d) => {
|
|
421
|
+
const entryPath = join(browsePath, d.name);
|
|
422
|
+
const stats = statSync(entryPath);
|
|
423
|
+
return {
|
|
424
|
+
name: d.name,
|
|
425
|
+
path: entryPath,
|
|
426
|
+
isDirectory: d.isDirectory(),
|
|
427
|
+
size: stats.size,
|
|
428
|
+
modifiedAt: stats.mtime.toISOString(),
|
|
429
|
+
};
|
|
430
|
+
})
|
|
431
|
+
.sort((a, b) => {
|
|
432
|
+
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
|
433
|
+
return a.name.localeCompare(b.name);
|
|
434
|
+
});
|
|
591
435
|
this.send(createEnvelope({
|
|
592
436
|
type: "terminal.browse.result",
|
|
593
437
|
sessionId: this.sessionId,
|
|
594
|
-
payload: { path: browsePath, entries },
|
|
438
|
+
payload: { path: browsePath, entries, requestId: p.requestId },
|
|
595
439
|
}));
|
|
596
440
|
} catch (err: unknown) {
|
|
597
441
|
this.send(createEnvelope({
|
|
598
442
|
type: "terminal.browse.result",
|
|
599
443
|
sessionId: this.sessionId,
|
|
600
|
-
payload: { path: browsePath, entries: [], error: (err as Error).message },
|
|
444
|
+
payload: { path: browsePath, entries: [], error: (err as Error).message, requestId: p.requestId },
|
|
445
|
+
}));
|
|
446
|
+
}
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
case "terminal.file.read": {
|
|
450
|
+
const p = parseTypedPayload("terminal.file.read", envelope.payload);
|
|
451
|
+
const rawPath = p.path.startsWith("~") ? p.path.replace(/^~/, homedir()) : p.path;
|
|
452
|
+
const filePath = resolve(rawPath);
|
|
453
|
+
try {
|
|
454
|
+
const stats = statSync(filePath);
|
|
455
|
+
if (!stats.isFile()) {
|
|
456
|
+
throw new Error("Path is not a file");
|
|
457
|
+
}
|
|
458
|
+
const maxBytes = p.maxBytes ?? 256_000;
|
|
459
|
+
const bytesToRead = Math.min(stats.size, maxBytes);
|
|
460
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
461
|
+
const fd = openSync(filePath, "r");
|
|
462
|
+
try {
|
|
463
|
+
readSync(fd, buffer, 0, bytesToRead, 0);
|
|
464
|
+
} finally {
|
|
465
|
+
closeSync(fd);
|
|
466
|
+
}
|
|
467
|
+
if (buffer.includes(0)) {
|
|
468
|
+
throw new Error("Binary files cannot be previewed");
|
|
469
|
+
}
|
|
470
|
+
this.send(createEnvelope({
|
|
471
|
+
type: "terminal.file.read.result",
|
|
472
|
+
sessionId: this.sessionId,
|
|
473
|
+
payload: {
|
|
474
|
+
path: filePath,
|
|
475
|
+
content: buffer.toString("utf8"),
|
|
476
|
+
encoding: "utf8",
|
|
477
|
+
size: stats.size,
|
|
478
|
+
truncated: stats.size > maxBytes,
|
|
479
|
+
requestId: p.requestId,
|
|
480
|
+
},
|
|
481
|
+
}));
|
|
482
|
+
} catch (err: unknown) {
|
|
483
|
+
this.send(createEnvelope({
|
|
484
|
+
type: "terminal.file.read.result",
|
|
485
|
+
sessionId: this.sessionId,
|
|
486
|
+
payload: {
|
|
487
|
+
path: filePath,
|
|
488
|
+
content: "",
|
|
489
|
+
encoding: "utf8",
|
|
490
|
+
truncated: false,
|
|
491
|
+
error: (err as Error).message,
|
|
492
|
+
requestId: p.requestId,
|
|
493
|
+
},
|
|
601
494
|
}));
|
|
602
495
|
}
|
|
603
496
|
break;
|
|
@@ -672,16 +565,18 @@ export class BridgeSession {
|
|
|
672
565
|
}));
|
|
673
566
|
break;
|
|
674
567
|
}
|
|
568
|
+
case "device.ack":
|
|
675
569
|
case "session.ack": {
|
|
676
|
-
const p = parseTypedPayload("session.ack", envelope.payload);
|
|
570
|
+
const p = parseTypedPayload(envelope.type === "device.ack" ? "device.ack" : "session.ack", envelope.payload);
|
|
677
571
|
const term = this.terminals.get(tid);
|
|
678
572
|
if (term) {
|
|
679
573
|
term.scrollback.trimUpTo(p.seq);
|
|
680
574
|
}
|
|
681
575
|
break;
|
|
682
576
|
}
|
|
577
|
+
case "device.resume":
|
|
683
578
|
case "session.resume": {
|
|
684
|
-
const p = parseTypedPayload("session.resume", envelope.payload);
|
|
579
|
+
const p = parseTypedPayload(envelope.type === "device.resume" ? "device.resume" : "session.resume", envelope.payload);
|
|
685
580
|
// Replay all terminals
|
|
686
581
|
for (const [termId, term] of this.terminals) {
|
|
687
582
|
this.replayFrom(
|
|
@@ -694,6 +589,7 @@ export class BridgeSession {
|
|
|
694
589
|
this.sendTerminalList();
|
|
695
590
|
break;
|
|
696
591
|
}
|
|
592
|
+
case "device.heartbeat":
|
|
697
593
|
case "session.heartbeat":
|
|
698
594
|
break;
|
|
699
595
|
case "screen.start": {
|
|
@@ -745,18 +641,10 @@ export class BridgeSession {
|
|
|
745
641
|
);
|
|
746
642
|
break;
|
|
747
643
|
}
|
|
748
|
-
if (envelope.type === "agent.prompt") this.refreshAgentPermissionHooks();
|
|
749
644
|
await this.agentSession.handleEnvelope(envelope);
|
|
750
645
|
break;
|
|
751
646
|
}
|
|
752
647
|
case "agent.permission.response": {
|
|
753
|
-
const p = parseTypedPayload("agent.permission.response", envelope.payload);
|
|
754
|
-
if (this.resolvePendingPermission(p.requestId, {
|
|
755
|
-
outcome: p.outcome,
|
|
756
|
-
optionId: p.optionId,
|
|
757
|
-
}, "agent.permission.response").resolved) {
|
|
758
|
-
break;
|
|
759
|
-
}
|
|
760
648
|
if (!this.agentSession) {
|
|
761
649
|
this.send(
|
|
762
650
|
createEnvelope({
|
|
@@ -818,7 +706,6 @@ export class BridgeSession {
|
|
|
818
706
|
);
|
|
819
707
|
break;
|
|
820
708
|
}
|
|
821
|
-
if (envelope.type === "agent.v2.prompt" || envelope.type === "agent.v2.command.execute") this.refreshAgentPermissionHooks();
|
|
822
709
|
await this.agentWorkspace.handleEnvelope(envelope);
|
|
823
710
|
break;
|
|
824
711
|
}
|
|
@@ -834,44 +721,6 @@ export class BridgeSession {
|
|
|
834
721
|
}
|
|
835
722
|
break;
|
|
836
723
|
}
|
|
837
|
-
case "permission.decision": {
|
|
838
|
-
const p = envelope.payload as { requestId: string; decision: "allow" | "deny" };
|
|
839
|
-
const result = this.resolvePendingPermission(p.requestId, p.decision, "permission.decision");
|
|
840
|
-
if (!result.resolved) {
|
|
841
|
-
this.sendPermissionSnapshot(
|
|
842
|
-
tid,
|
|
843
|
-
"thinking",
|
|
844
|
-
"permission not pending",
|
|
845
|
-
{
|
|
846
|
-
requestId: p.requestId,
|
|
847
|
-
outcome: p.decision,
|
|
848
|
-
source: "permission.decision",
|
|
849
|
-
delivered: false,
|
|
850
|
-
},
|
|
851
|
-
);
|
|
852
|
-
}
|
|
853
|
-
process.stderr.write(
|
|
854
|
-
`[bridge] permission decision request=${p.requestId} decision=${p.decision} resolved=${result.resolved} delivered=${result.delivered}\n`,
|
|
855
|
-
);
|
|
856
|
-
this.send(createEnvelope({
|
|
857
|
-
type: "permission.decision.result",
|
|
858
|
-
sessionId: this.sessionId,
|
|
859
|
-
terminalId: tid,
|
|
860
|
-
payload: {
|
|
861
|
-
requestId: p.requestId,
|
|
862
|
-
decision: p.decision,
|
|
863
|
-
resolved: result.resolved,
|
|
864
|
-
delivered: result.delivered,
|
|
865
|
-
source: "permission.decision",
|
|
866
|
-
message: result.delivered
|
|
867
|
-
? undefined
|
|
868
|
-
: result.resolved
|
|
869
|
-
? "Permission resolved but response was not delivered"
|
|
870
|
-
: "Permission request is no longer pending",
|
|
871
|
-
},
|
|
872
|
-
}));
|
|
873
|
-
break;
|
|
874
|
-
}
|
|
875
724
|
case "tunnel.request": {
|
|
876
725
|
const p = parseTypedPayload("tunnel.request", envelope.payload);
|
|
877
726
|
this.handleTunnelRequest(p);
|
|
@@ -1081,9 +930,8 @@ export class BridgeSession {
|
|
|
1081
930
|
const terminals = [...this.terminals.values()].map((t) => ({
|
|
1082
931
|
terminalId: t.id,
|
|
1083
932
|
cwd: t.cwd,
|
|
1084
|
-
projectName: t.projectName,
|
|
1085
|
-
provider: t.provider,
|
|
1086
933
|
status: t.status,
|
|
934
|
+
shell: this.options.providerConfig.command,
|
|
1087
935
|
}));
|
|
1088
936
|
this.send(createEnvelope({
|
|
1089
937
|
type: "terminal.list",
|
|
@@ -1114,41 +962,14 @@ export class BridgeSession {
|
|
|
1114
962
|
}
|
|
1115
963
|
}
|
|
1116
964
|
|
|
1117
|
-
private async spawnTerminal(terminalId: string, cwd: string
|
|
965
|
+
private async spawnTerminal(terminalId: string, cwd: string): Promise<void> {
|
|
1118
966
|
const cleanEnv: Record<string, string> = {};
|
|
1119
967
|
for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
|
|
1120
968
|
if (v !== undefined) cleanEnv[k] = v;
|
|
1121
969
|
}
|
|
1122
|
-
const hookMarker = this.terminalHookMarker(terminalId);
|
|
1123
|
-
// Inject marker so child CLIs' hook commands carry our identity
|
|
1124
|
-
cleanEnv["LINKSHELL_ID"] = hookMarker;
|
|
1125
970
|
|
|
1126
|
-
const provider = providerOverride ?? this.options.providerConfig.provider;
|
|
1127
971
|
const args = [...this.options.providerConfig.args];
|
|
1128
972
|
|
|
1129
|
-
// Set up hook server for structured status (all supported providers)
|
|
1130
|
-
// For "custom" shell, set up hooks for all providers since user may launch any of them
|
|
1131
|
-
let hookServer: http.Server | undefined;
|
|
1132
|
-
let hookPort: number | undefined;
|
|
1133
|
-
const hookConfigPaths: string[] = [];
|
|
1134
|
-
|
|
1135
|
-
if (provider === "custom") {
|
|
1136
|
-
const result = await this.setupHookServer(terminalId, args, "claude", hookMarker);
|
|
1137
|
-
hookServer = result.server;
|
|
1138
|
-
hookPort = result.port;
|
|
1139
|
-
hookConfigPaths.push(result.configPath);
|
|
1140
|
-
// Also set up hooks for other providers (curlCmd already has marker from setupHookServer)
|
|
1141
|
-
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`;
|
|
1142
|
-
hookConfigPaths.push(this.setupCodexHooks(terminalId, curlCmd, hookMarker));
|
|
1143
|
-
hookConfigPaths.push(this.setupGeminiHooks(terminalId, curlCmd, hookMarker));
|
|
1144
|
-
hookConfigPaths.push(this.setupCopilotHooks(terminalId, curlCmd, hookMarker));
|
|
1145
|
-
} else if (provider === "claude" || provider === "codex" || provider === "gemini" || provider === "copilot") {
|
|
1146
|
-
const result = await this.setupHookServer(terminalId, args, provider, hookMarker);
|
|
1147
|
-
hookServer = result.server;
|
|
1148
|
-
hookPort = result.port;
|
|
1149
|
-
hookConfigPaths.push(result.configPath);
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
973
|
const term: TerminalInstance = {
|
|
1153
974
|
id: terminalId,
|
|
1154
975
|
pty: pty.spawn(
|
|
@@ -1163,16 +984,9 @@ export class BridgeSession {
|
|
|
1163
984
|
},
|
|
1164
985
|
),
|
|
1165
986
|
cwd,
|
|
1166
|
-
projectName: basename(cwd),
|
|
1167
|
-
provider,
|
|
1168
987
|
scrollback: new ScrollbackBuffer(1000),
|
|
1169
988
|
outputSeq: 0,
|
|
1170
|
-
statusSeq: 0,
|
|
1171
989
|
status: "running",
|
|
1172
|
-
hookServer,
|
|
1173
|
-
hookPort,
|
|
1174
|
-
hookMarker,
|
|
1175
|
-
hookConfigPaths,
|
|
1176
990
|
};
|
|
1177
991
|
|
|
1178
992
|
term.pty.onData((data) => {
|
|
@@ -1196,7 +1010,6 @@ export class BridgeSession {
|
|
|
1196
1010
|
|
|
1197
1011
|
term.pty.onExit(({ exitCode, signal }) => {
|
|
1198
1012
|
term.status = "exited";
|
|
1199
|
-
this.cleanupHookServer(term);
|
|
1200
1013
|
this.send(createEnvelope({
|
|
1201
1014
|
type: "terminal.exit",
|
|
1202
1015
|
sessionId: this.sessionId,
|
|
@@ -1221,727 +1034,12 @@ export class BridgeSession {
|
|
|
1221
1034
|
this.log(`spawned terminal ${terminalId} in ${cwd}`);
|
|
1222
1035
|
}
|
|
1223
1036
|
|
|
1224
|
-
private async setupHookServer(terminalId: string, args: string[], provider: string, marker: string): Promise<{
|
|
1225
|
-
server: http.Server;
|
|
1226
|
-
port: number;
|
|
1227
|
-
configPath: string;
|
|
1228
|
-
}> {
|
|
1229
|
-
const server = http.createServer((req, res) => {
|
|
1230
|
-
this.log(`hook server received: ${req.method} ${req.url}`);
|
|
1231
|
-
const reqUrl = new URL(req.url ?? "/", "http://localhost");
|
|
1232
|
-
if (req.method !== "POST" || reqUrl.pathname !== "/hook") {
|
|
1233
|
-
res.writeHead(404);
|
|
1234
|
-
res.end();
|
|
1235
|
-
return;
|
|
1236
|
-
}
|
|
1237
|
-
// Check marker — reject events not from our PTY
|
|
1238
|
-
// m must match; lid must match OR be empty (some CLIs don't inherit env vars)
|
|
1239
|
-
const reqMarker = reqUrl.searchParams.get("m");
|
|
1240
|
-
const reqLid = reqUrl.searchParams.get("lid") ?? "";
|
|
1241
|
-
if (reqMarker !== marker || (reqLid !== "" && reqLid !== marker)) {
|
|
1242
|
-
this.log(`ignoring hook event: m=${reqMarker} lid=${reqLid} (expected ${marker})`);
|
|
1243
|
-
res.writeHead(200);
|
|
1244
|
-
res.end("ok");
|
|
1245
|
-
return;
|
|
1246
|
-
}
|
|
1247
|
-
let body = "";
|
|
1248
|
-
let bodyTooLarge = false;
|
|
1249
|
-
req.on("data", (chunk: Buffer) => {
|
|
1250
|
-
if (bodyTooLarge) return;
|
|
1251
|
-
body += chunk.toString();
|
|
1252
|
-
if (Buffer.byteLength(body, "utf8") > HOOK_BODY_LIMIT) {
|
|
1253
|
-
bodyTooLarge = true;
|
|
1254
|
-
res.writeHead(413);
|
|
1255
|
-
res.end("payload too large");
|
|
1256
|
-
req.destroy();
|
|
1257
|
-
}
|
|
1258
|
-
});
|
|
1259
|
-
req.on("end", () => {
|
|
1260
|
-
if (bodyTooLarge || res.writableEnded) return;
|
|
1261
|
-
this.log(`hook body (${body.length} bytes): ${body.slice(0, 200)}`);
|
|
1262
|
-
try {
|
|
1263
|
-
const event = JSON.parse(body);
|
|
1264
|
-
const hookName = (event.hook_event_name ?? event.event_name) as string | undefined;
|
|
1265
|
-
|
|
1266
|
-
// PermissionRequest: hold connection, wait for user decision from mobile app
|
|
1267
|
-
if (hookName === "PermissionRequest") {
|
|
1268
|
-
const requestId = `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1269
|
-
const permissionSuggestions = hookPermissionSuggestions(event);
|
|
1270
|
-
const timeout = setTimeout(() => {
|
|
1271
|
-
if (this.resolvePendingPermission(requestId, "deny", "permission.timeout").resolved) {
|
|
1272
|
-
this.log(`permission request ${requestId} timed out`);
|
|
1273
|
-
this.sendPermissionSnapshot(terminalId, "thinking", "permission timed out");
|
|
1274
|
-
}
|
|
1275
|
-
}, PERMISSION_REQUEST_TIMEOUT_MS);
|
|
1276
|
-
this.pendingPermissions.set(requestId, {
|
|
1277
|
-
terminalId,
|
|
1278
|
-
timeout,
|
|
1279
|
-
permissionSuggestions,
|
|
1280
|
-
resolve: (decision) => {
|
|
1281
|
-
if (res.writableEnded) return false;
|
|
1282
|
-
const responseJson = JSON.stringify({
|
|
1283
|
-
hookSpecificOutput: {
|
|
1284
|
-
hookEventName: "PermissionRequest",
|
|
1285
|
-
decision,
|
|
1286
|
-
},
|
|
1287
|
-
});
|
|
1288
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1289
|
-
res.end(responseJson);
|
|
1290
|
-
return true;
|
|
1291
|
-
},
|
|
1292
|
-
});
|
|
1293
|
-
// Send status with requestId so app can route decision back
|
|
1294
|
-
this.handleHookEvent(terminalId, event, provider, requestId);
|
|
1295
|
-
this.sendHookPermissionRequest(terminalId, event, requestId);
|
|
1296
|
-
} else {
|
|
1297
|
-
// All other hooks: respond immediately
|
|
1298
|
-
res.writeHead(200);
|
|
1299
|
-
res.end("ok");
|
|
1300
|
-
this.handleHookEvent(terminalId, event, provider);
|
|
1301
|
-
}
|
|
1302
|
-
} catch (e) {
|
|
1303
|
-
res.writeHead(200);
|
|
1304
|
-
res.end("ok");
|
|
1305
|
-
this.log(`hook parse error: ${e}`);
|
|
1306
|
-
}
|
|
1307
|
-
});
|
|
1308
|
-
});
|
|
1309
|
-
|
|
1310
|
-
// Listen on random port — await binding before reading address
|
|
1311
|
-
const port = await new Promise<number>((resolve, reject) => {
|
|
1312
|
-
server.listen(0, "127.0.0.1", () => {
|
|
1313
|
-
const addr = server.address() as { port: number };
|
|
1314
|
-
resolve(addr.port);
|
|
1315
|
-
});
|
|
1316
|
-
server.on("error", reject);
|
|
1317
|
-
});
|
|
1318
|
-
this.log(`hook server for ${terminalId} (${provider}) listening on port ${port}, marker=${marker}`);
|
|
1319
|
-
|
|
1320
|
-
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`;
|
|
1321
|
-
let configPath: string;
|
|
1322
|
-
|
|
1323
|
-
if (provider === "codex") {
|
|
1324
|
-
configPath = this.setupCodexHooks(terminalId, curlCmd, marker);
|
|
1325
|
-
} else if (provider === "gemini") {
|
|
1326
|
-
configPath = this.setupGeminiHooks(terminalId, curlCmd, marker);
|
|
1327
|
-
} else if (provider === "copilot") {
|
|
1328
|
-
configPath = this.setupCopilotHooks(terminalId, curlCmd, marker);
|
|
1329
|
-
} else {
|
|
1330
|
-
// Claude (default)
|
|
1331
|
-
configPath = this.setupClaudeHooks(terminalId, curlCmd, args, marker);
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
return { server, port, configPath };
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
private refreshAgentPermissionHooks(): void {
|
|
1338
|
-
const term = this.terminals.get(DEFAULT_TERMINAL_ID);
|
|
1339
|
-
if (!term?.hookPort) return;
|
|
1340
|
-
const marker = term.hookMarker;
|
|
1341
|
-
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`;
|
|
1342
|
-
const providers = this.options.agentProvider
|
|
1343
|
-
? [normalizeAgentProvider(this.options.agentProvider)]
|
|
1344
|
-
: detectAvailableProviders();
|
|
1345
|
-
try {
|
|
1346
|
-
for (const provider of providers) {
|
|
1347
|
-
if (provider === "codex") {
|
|
1348
|
-
this.setupCodexHooks(DEFAULT_TERMINAL_ID, curlCmd, marker);
|
|
1349
|
-
} else {
|
|
1350
|
-
// claude, custom
|
|
1351
|
-
this.setupClaudeHooks(DEFAULT_TERMINAL_ID, curlCmd, [], marker);
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
} catch (error) {
|
|
1355
|
-
this.log(`failed to refresh agent permission hooks: ${error instanceof Error ? error.message : String(error)}`);
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
private setupClaudeHooks(terminalId: string, curlCmd: string, args: string[], marker: string): string {
|
|
1360
|
-
// Write hooks to ~/.claude/settings.json — Claude Code reads hooks from here
|
|
1361
|
-
const claudeDir = join(homedir(), ".claude");
|
|
1362
|
-
if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
|
|
1363
|
-
const settingsPath = join(claudeDir, "settings.json");
|
|
1364
|
-
|
|
1365
|
-
let existing: Record<string, unknown> = {};
|
|
1366
|
-
try {
|
|
1367
|
-
existing = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
1368
|
-
} catch { /* doesn't exist yet */ }
|
|
1369
|
-
|
|
1370
|
-
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
|
|
1371
|
-
const permissionEntry = {
|
|
1372
|
-
matcher: "",
|
|
1373
|
-
hooks: [{
|
|
1374
|
-
type: "command",
|
|
1375
|
-
command: curlCmd,
|
|
1376
|
-
timeout: Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000),
|
|
1377
|
-
}],
|
|
1378
|
-
};
|
|
1379
|
-
|
|
1380
|
-
const hookEvents: Record<string, typeof hookEntry | typeof permissionEntry> = {
|
|
1381
|
-
PreToolUse: hookEntry,
|
|
1382
|
-
PostToolUse: hookEntry,
|
|
1383
|
-
PostToolUseFailure: hookEntry,
|
|
1384
|
-
Stop: hookEntry,
|
|
1385
|
-
PermissionRequest: permissionEntry,
|
|
1386
|
-
UserPromptSubmit: hookEntry,
|
|
1387
|
-
SessionStart: hookEntry,
|
|
1388
|
-
};
|
|
1389
|
-
|
|
1390
|
-
// Append our entries to existing hooks (first remove stale linkshell entries)
|
|
1391
|
-
const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
|
|
1392
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1393
|
-
existingHooks[eventName] = eventName === "PermissionRequest"
|
|
1394
|
-
? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
|
|
1395
|
-
: withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
const merged = { ...existing, hooks: existingHooks };
|
|
1399
|
-
writeFileSync(settingsPath, JSON.stringify(merged, null, 2));
|
|
1400
|
-
this.log(`claude hooks appended to ${settingsPath}`);
|
|
1401
|
-
|
|
1402
|
-
return settingsPath;
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
private setupCodexHooks(terminalId: string, curlCmd: string, marker: string): string {
|
|
1406
|
-
// Codex uses ~/.codex/hooks.json — same format as Claude (with matcher)
|
|
1407
|
-
const codexDir = join(homedir(), ".codex");
|
|
1408
|
-
if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
|
|
1409
|
-
|
|
1410
|
-
// Ensure [features] codex_hooks = true in config.toml
|
|
1411
|
-
const tomlPath = join(codexDir, "config.toml");
|
|
1412
|
-
let tomlContent = "";
|
|
1413
|
-
try { tomlContent = readFileSync(tomlPath, "utf8"); } catch { /* doesn't exist yet */ }
|
|
1414
|
-
|
|
1415
|
-
// Remove top-level codex_hooks (wrong location) and ensure it's under [features]
|
|
1416
|
-
const hasFeatureSection = tomlContent.includes("[features]");
|
|
1417
|
-
const hasCodexHooksUnderFeatures = hasFeatureSection &&
|
|
1418
|
-
/\[features\][^\[]*codex_hooks\s*=\s*true/s.test(tomlContent);
|
|
1419
|
-
|
|
1420
|
-
if (!hasCodexHooksUnderFeatures) {
|
|
1421
|
-
// Remove any top-level codex_hooks line
|
|
1422
|
-
tomlContent = tomlContent.replace(/^codex_hooks\s*=.*\n?/m, "");
|
|
1423
|
-
if (!tomlContent.includes("[features]")) {
|
|
1424
|
-
tomlContent += `\n[features]\ncodex_hooks = true\n`;
|
|
1425
|
-
} else {
|
|
1426
|
-
tomlContent = tomlContent.replace("[features]", "[features]\ncodex_hooks = true");
|
|
1427
|
-
}
|
|
1428
|
-
writeFileSync(tomlPath, tomlContent);
|
|
1429
|
-
this.log(`enabled codex_hooks under [features] in ${tomlPath}`);
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
const hooksPath = join(codexDir, "hooks.json");
|
|
1433
|
-
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
|
|
1434
|
-
const permissionEntry = {
|
|
1435
|
-
matcher: "",
|
|
1436
|
-
hooks: [{
|
|
1437
|
-
type: "command",
|
|
1438
|
-
command: curlCmd,
|
|
1439
|
-
timeout: Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000),
|
|
1440
|
-
}],
|
|
1441
|
-
};
|
|
1442
|
-
const hookEvents: Record<string, typeof hookEntry | typeof permissionEntry> = {
|
|
1443
|
-
SessionStart: hookEntry,
|
|
1444
|
-
PreToolUse: hookEntry,
|
|
1445
|
-
PostToolUse: hookEntry,
|
|
1446
|
-
UserPromptSubmit: hookEntry,
|
|
1447
|
-
Stop: hookEntry,
|
|
1448
|
-
PermissionRequest: permissionEntry,
|
|
1449
|
-
};
|
|
1450
|
-
|
|
1451
|
-
// Read existing and append
|
|
1452
|
-
let existing: { hooks?: Record<string, unknown[]> } = {};
|
|
1453
|
-
try { existing = JSON.parse(readFileSync(hooksPath, "utf8")); } catch { /* doesn't exist yet */ }
|
|
1454
|
-
const existingHooks = existing.hooks ?? {};
|
|
1455
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1456
|
-
existingHooks[eventName] = eventName === "PermissionRequest"
|
|
1457
|
-
? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
|
|
1458
|
-
: withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
writeFileSync(hooksPath, JSON.stringify({ ...existing, hooks: existingHooks }, null, 2));
|
|
1462
|
-
this.log(`codex hooks appended to ${hooksPath}`);
|
|
1463
|
-
return hooksPath;
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
private setupGeminiHooks(terminalId: string, curlCmd: string, marker: string): string {
|
|
1467
|
-
// Gemini uses ~/.gemini/settings.json — same format as Claude (with matcher)
|
|
1468
|
-
const geminiDir = join(homedir(), ".gemini");
|
|
1469
|
-
if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
|
|
1470
|
-
|
|
1471
|
-
const settingsPath = join(geminiDir, "settings.json");
|
|
1472
|
-
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5000 }] };
|
|
1473
|
-
const hookEvents: Record<string, typeof hookEntry> = {
|
|
1474
|
-
SessionStart: hookEntry,
|
|
1475
|
-
SessionEnd: hookEntry,
|
|
1476
|
-
BeforeTool: hookEntry,
|
|
1477
|
-
AfterTool: hookEntry,
|
|
1478
|
-
};
|
|
1479
|
-
|
|
1480
|
-
// Merge with existing settings if present
|
|
1481
|
-
let existing: Record<string, unknown> = {};
|
|
1482
|
-
try {
|
|
1483
|
-
existing = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
1484
|
-
} catch { /* doesn't exist yet */ }
|
|
1485
|
-
|
|
1486
|
-
const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
|
|
1487
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1488
|
-
existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
existing.hooks = existingHooks;
|
|
1492
|
-
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
|
1493
|
-
this.log(`gemini hooks appended to ${settingsPath}`);
|
|
1494
|
-
return settingsPath;
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
private setupCopilotHooks(terminalId: string, curlCmd: string, marker: string): string {
|
|
1498
|
-
// Copilot loads hooks from CWD as hooks.json
|
|
1499
|
-
const cwd = this.terminals.get(terminalId)?.cwd ?? process.cwd();
|
|
1500
|
-
const hooksPath = join(cwd, "hooks.json");
|
|
1501
|
-
const mkHook = () => ({
|
|
1502
|
-
type: "command",
|
|
1503
|
-
bash: curlCmd,
|
|
1504
|
-
timeoutSec: 30,
|
|
1505
|
-
});
|
|
1506
|
-
const hookEvents: Record<string, ReturnType<typeof mkHook>> = {
|
|
1507
|
-
sessionStart: mkHook(),
|
|
1508
|
-
sessionEnd: mkHook(),
|
|
1509
|
-
userPromptSubmitted: mkHook(),
|
|
1510
|
-
preToolUse: mkHook(),
|
|
1511
|
-
postToolUse: mkHook(),
|
|
1512
|
-
errorOccurred: mkHook(),
|
|
1513
|
-
};
|
|
1514
|
-
|
|
1515
|
-
// Read existing and append
|
|
1516
|
-
let existing: { version?: number; hooks?: Record<string, unknown[]> } = {};
|
|
1517
|
-
try { existing = JSON.parse(readFileSync(hooksPath, "utf8")); } catch { /* doesn't exist yet */ }
|
|
1518
|
-
const existingHooks = existing.hooks ?? {};
|
|
1519
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1520
|
-
existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1521
|
-
}
|
|
1522
|
-
|
|
1523
|
-
writeFileSync(hooksPath, JSON.stringify({ version: 1, hooks: existingHooks }, null, 2));
|
|
1524
|
-
this.log(`copilot hooks appended to ${hooksPath}`);
|
|
1525
|
-
return hooksPath;
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
private handleHookEvent(terminalId: string, event: Record<string, unknown>, provider: string, permissionRequestId?: string): void {
|
|
1529
|
-
const rawHookName = (event.hook_event_name ?? event.event_name) as string | undefined;
|
|
1530
|
-
if (!rawHookName) return;
|
|
1531
|
-
|
|
1532
|
-
// Auto-detect provider from hook event fields
|
|
1533
|
-
const hookTerm = this.terminals.get(terminalId);
|
|
1534
|
-
let detectedProvider = provider;
|
|
1535
|
-
|
|
1536
|
-
// Always detect from transcript_path (most reliable), regardless of current provider
|
|
1537
|
-
const transcriptPath = typeof event.transcript_path === "string" ? event.transcript_path as string : "";
|
|
1538
|
-
if (transcriptPath.includes(".claude/")) {
|
|
1539
|
-
detectedProvider = "claude";
|
|
1540
|
-
} else if (transcriptPath.includes(".gemini/")) {
|
|
1541
|
-
detectedProvider = "gemini";
|
|
1542
|
-
} else if (transcriptPath.includes(".codex/")) {
|
|
1543
|
-
detectedProvider = "codex";
|
|
1544
|
-
} else if (hookTerm?.provider === "custom") {
|
|
1545
|
-
// Fallback heuristics only when provider is still unknown
|
|
1546
|
-
if (event.model && typeof event.model === "string" && /^(gpt|o[0-9]|codex)/i.test(event.model as string)) {
|
|
1547
|
-
detectedProvider = "codex";
|
|
1548
|
-
} else if (event.session_id && !transcriptPath) {
|
|
1549
|
-
detectedProvider = "codex";
|
|
1550
|
-
} else if (/^(Before|After)(Tool)$|^Session(Start|End)$/.test(rawHookName)) {
|
|
1551
|
-
detectedProvider = "gemini";
|
|
1552
|
-
} else if (/^(pre|post)ToolUse$|^session(Start|End)$|^userPromptSubmitted$|^errorOccurred$/.test(rawHookName)) {
|
|
1553
|
-
detectedProvider = "copilot";
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
if (hookTerm && detectedProvider !== hookTerm.provider) {
|
|
1558
|
-
const wasCustom = hookTerm.provider === "custom";
|
|
1559
|
-
hookTerm.provider = detectedProvider;
|
|
1560
|
-
this.log(`${wasCustom ? "detected" : "provider switched"} provider for ${terminalId}: ${detectedProvider}`);
|
|
1561
|
-
this.permissionStacks.delete(terminalId);
|
|
1562
|
-
this.sendTerminalList();
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
// Normalize hook event names from different providers to unified names
|
|
1566
|
-
const hookName = this.normalizeHookName(rawHookName, detectedProvider);
|
|
1567
|
-
if (!hookName) return;
|
|
1568
|
-
|
|
1569
|
-
let phase: string;
|
|
1570
|
-
let toolName: string | undefined;
|
|
1571
|
-
let toolInput: string | undefined;
|
|
1572
|
-
let permissionRequest: string | undefined;
|
|
1573
|
-
let summary: string | undefined;
|
|
1574
|
-
|
|
1575
|
-
switch (hookName) {
|
|
1576
|
-
case "PreToolUse":
|
|
1577
|
-
phase = "tool_use";
|
|
1578
|
-
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
1579
|
-
if (event.tool_input && typeof event.tool_input === "object") {
|
|
1580
|
-
const input = event.tool_input as Record<string, unknown>;
|
|
1581
|
-
toolInput = JSON.stringify(input).slice(0, 200);
|
|
1582
|
-
} else if (event.toolInput && typeof event.toolInput === "object") {
|
|
1583
|
-
toolInput = JSON.stringify(event.toolInput).slice(0, 200);
|
|
1584
|
-
}
|
|
1585
|
-
break;
|
|
1586
|
-
case "PostToolUse":
|
|
1587
|
-
phase = "thinking";
|
|
1588
|
-
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
1589
|
-
// Pop permission stack + auto-resolve pending HTTP connection
|
|
1590
|
-
{
|
|
1591
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1592
|
-
if (stack && stack.length > 0) {
|
|
1593
|
-
const popped = stack.pop();
|
|
1594
|
-
if (popped) this.autoResolvePending(popped.requestId);
|
|
1595
|
-
if (stack.length === 0) this.permissionStacks.delete(terminalId);
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
break;
|
|
1599
|
-
case "PostToolUseFailure":
|
|
1600
|
-
phase = "error";
|
|
1601
|
-
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
1602
|
-
{
|
|
1603
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1604
|
-
if (stack && stack.length > 0) {
|
|
1605
|
-
const popped = stack.pop();
|
|
1606
|
-
if (popped) this.autoResolvePending(popped.requestId);
|
|
1607
|
-
if (stack.length === 0) this.permissionStacks.delete(terminalId);
|
|
1608
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
break;
|
|
1611
|
-
case "Stop":
|
|
1612
|
-
phase = "idle";
|
|
1613
|
-
if (event.stop_reason) summary = String(event.stop_reason);
|
|
1614
|
-
this.drainPendingPermissions(terminalId);
|
|
1615
|
-
this.permissionStacks.delete(terminalId);
|
|
1616
|
-
// Reset provider to "custom" when a CLI session ends inside a custom shell
|
|
1617
|
-
if (hookTerm && this.options.providerConfig.provider === "custom") {
|
|
1618
|
-
hookTerm.provider = "custom";
|
|
1619
|
-
this.log(`provider reset to custom for ${terminalId} (CLI session ended)`);
|
|
1620
|
-
this.sendTerminalList();
|
|
1621
|
-
}
|
|
1622
|
-
break;
|
|
1623
|
-
case "PermissionRequest":
|
|
1624
|
-
phase = "waiting";
|
|
1625
|
-
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
1626
|
-
if (event.tool_input && typeof event.tool_input === "object") {
|
|
1627
|
-
const input = event.tool_input as Record<string, unknown>;
|
|
1628
|
-
permissionRequest = JSON.stringify(input).slice(0, 300);
|
|
1629
|
-
} else if (event.toolInput && typeof event.toolInput === "object") {
|
|
1630
|
-
permissionRequest = JSON.stringify(event.toolInput).slice(0, 300);
|
|
1631
|
-
}
|
|
1632
|
-
// Push to permission stack (use requestId from hook server if available)
|
|
1633
|
-
{
|
|
1634
|
-
const reqId = permissionRequestId ?? `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1635
|
-
if (!this.permissionStacks.has(terminalId)) {
|
|
1636
|
-
this.permissionStacks.set(terminalId, []);
|
|
1637
|
-
}
|
|
1638
|
-
this.permissionStacks.get(terminalId)!.push({
|
|
1639
|
-
requestId: reqId,
|
|
1640
|
-
toolName: toolName ?? "unknown",
|
|
1641
|
-
toolInput: toolInput ?? (permissionRequest ?? ""),
|
|
1642
|
-
permissionRequest: permissionRequest ?? "",
|
|
1643
|
-
timestamp: Date.now(),
|
|
1644
|
-
});
|
|
1645
|
-
}
|
|
1646
|
-
break;
|
|
1647
|
-
case "SessionStart":
|
|
1648
|
-
phase = "idle";
|
|
1649
|
-
summary = "session started";
|
|
1650
|
-
break;
|
|
1651
|
-
case "UserPromptSubmit":
|
|
1652
|
-
phase = "thinking";
|
|
1653
|
-
this.drainPendingPermissions(terminalId);
|
|
1654
|
-
this.permissionStacks.delete(terminalId);
|
|
1655
|
-
break;
|
|
1656
|
-
default:
|
|
1657
|
-
return;
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
|
-
this.log(`hook event [${provider}]: ${rawHookName} → ${hookName} → phase=${phase} tool=${toolName ?? "none"}`);
|
|
1661
|
-
|
|
1662
|
-
// Build topPermission from stack
|
|
1663
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1664
|
-
const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
|
|
1665
|
-
const pendingPermissionCount = stack?.length ?? 0;
|
|
1666
|
-
|
|
1667
|
-
// Increment statusSeq for ordering
|
|
1668
|
-
const term = this.terminals.get(terminalId);
|
|
1669
|
-
const seq = term ? term.statusSeq++ : 0;
|
|
1670
|
-
|
|
1671
|
-
this.send(createEnvelope({
|
|
1672
|
-
type: "terminal.status",
|
|
1673
|
-
sessionId: this.sessionId,
|
|
1674
|
-
terminalId,
|
|
1675
|
-
payload: {
|
|
1676
|
-
phase,
|
|
1677
|
-
seq,
|
|
1678
|
-
...(toolName && { toolName }),
|
|
1679
|
-
...(toolInput && { toolInput }),
|
|
1680
|
-
...(permissionRequest && { permissionRequest }),
|
|
1681
|
-
...(summary && { summary }),
|
|
1682
|
-
...(topPermission && { topPermission }),
|
|
1683
|
-
...(pendingPermissionCount > 0 && { pendingPermissionCount }),
|
|
1684
|
-
},
|
|
1685
|
-
}));
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
private sendHookPermissionRequest(
|
|
1689
|
-
terminalId: string,
|
|
1690
|
-
event: Record<string, unknown>,
|
|
1691
|
-
requestId: string,
|
|
1692
|
-
): void {
|
|
1693
|
-
const toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
1694
|
-
const toolInput = stringifyHookInput(event.tool_input ?? event.toolInput);
|
|
1695
|
-
const suggestions = hookPermissionSuggestions(event);
|
|
1696
|
-
const context =
|
|
1697
|
-
typeof event.permission_prompt === "string"
|
|
1698
|
-
? event.permission_prompt
|
|
1699
|
-
: typeof event.message === "string"
|
|
1700
|
-
? event.message
|
|
1701
|
-
: undefined;
|
|
1702
|
-
this.send(createEnvelope({
|
|
1703
|
-
type: "agent.permission.request",
|
|
1704
|
-
sessionId: this.sessionId,
|
|
1705
|
-
terminalId,
|
|
1706
|
-
payload: {
|
|
1707
|
-
requestId,
|
|
1708
|
-
toolName,
|
|
1709
|
-
toolInput,
|
|
1710
|
-
context,
|
|
1711
|
-
options: hookPermissionOptions(suggestions),
|
|
1712
|
-
},
|
|
1713
|
-
}));
|
|
1714
|
-
}
|
|
1715
|
-
|
|
1716
|
-
/**
|
|
1717
|
-
* Normalize hook event names from different CLI providers to unified internal names.
|
|
1718
|
-
* Claude: PascalCase (PreToolUse, PostToolUse, Stop, PermissionRequest)
|
|
1719
|
-
* Codex: camelCase (preToolUse, postToolUse, sessionStart)
|
|
1720
|
-
* Gemini: PascalCase but different names (BeforeTool, AfterTool, BeforeSubmitPrompt)
|
|
1721
|
-
*/
|
|
1722
|
-
private normalizeHookName(rawName: string, provider: string): string | undefined {
|
|
1723
|
-
// Claude events — already in our canonical format
|
|
1724
|
-
if (provider === "claude") {
|
|
1725
|
-
return rawName;
|
|
1726
|
-
}
|
|
1727
|
-
|
|
1728
|
-
// Codex events — same as Claude (PascalCase)
|
|
1729
|
-
if (provider === "codex") {
|
|
1730
|
-
switch (rawName) {
|
|
1731
|
-
case "PreToolUse": case "preToolUse": return "PreToolUse";
|
|
1732
|
-
case "PostToolUse": case "postToolUse": return "PostToolUse";
|
|
1733
|
-
case "SessionStart": case "sessionStart": return "SessionStart";
|
|
1734
|
-
case "UserPromptSubmit": return "UserPromptSubmit";
|
|
1735
|
-
case "PermissionRequest": return "PermissionRequest";
|
|
1736
|
-
case "Stop": return "Stop";
|
|
1737
|
-
default: return undefined;
|
|
1738
|
-
}
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1741
|
-
// Gemini events
|
|
1742
|
-
if (provider === "gemini") {
|
|
1743
|
-
switch (rawName) {
|
|
1744
|
-
case "BeforeTool": return "PreToolUse";
|
|
1745
|
-
case "AfterTool": return "PostToolUse";
|
|
1746
|
-
case "SessionStart": return "SessionStart";
|
|
1747
|
-
case "SessionEnd": return "Stop";
|
|
1748
|
-
default: return undefined;
|
|
1749
|
-
}
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1752
|
-
// Copilot events (camelCase)
|
|
1753
|
-
if (provider === "copilot") {
|
|
1754
|
-
switch (rawName) {
|
|
1755
|
-
case "preToolUse": return "PreToolUse";
|
|
1756
|
-
case "postToolUse": return "PostToolUse";
|
|
1757
|
-
case "sessionStart": return "SessionStart";
|
|
1758
|
-
case "sessionEnd": return "Stop";
|
|
1759
|
-
case "userPromptSubmitted": return "UserPromptSubmit";
|
|
1760
|
-
case "errorOccurred": return "PostToolUseFailure";
|
|
1761
|
-
default: return undefined;
|
|
1762
|
-
}
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
// Unknown provider — try all known formats
|
|
1766
|
-
// This handles "custom" shell where any provider might be launched
|
|
1767
|
-
const allProviders = ["claude", "codex", "gemini", "copilot"];
|
|
1768
|
-
for (const p of allProviders) {
|
|
1769
|
-
const result = this.normalizeHookName(rawName, p);
|
|
1770
|
-
if (result) return result;
|
|
1771
|
-
}
|
|
1772
|
-
return undefined;
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
/** Auto-resolve a single pending permission (user acted in terminal) */
|
|
1776
|
-
private autoResolvePending(requestId: string): void {
|
|
1777
|
-
if (this.resolvePendingPermission(requestId, "allow", "terminal.auto").resolved) {
|
|
1778
|
-
this.log(`auto-resolved pending permission ${requestId} (user acted in terminal)`);
|
|
1779
|
-
}
|
|
1780
|
-
}
|
|
1781
|
-
|
|
1782
|
-
/** Drain all pending permissions for a terminal (session ended, stop, etc.) */
|
|
1783
|
-
private drainPendingPermissions(terminalId: string): void {
|
|
1784
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1785
|
-
if (!stack) return;
|
|
1786
|
-
for (const entry of [...stack]) {
|
|
1787
|
-
if (this.resolvePendingPermission(entry.requestId, "deny", "terminal.drain").resolved) {
|
|
1788
|
-
this.log(`drained pending permission ${entry.requestId}`);
|
|
1789
|
-
}
|
|
1790
|
-
}
|
|
1791
|
-
}
|
|
1792
|
-
|
|
1793
|
-
private resolvePendingPermission(
|
|
1794
|
-
requestId: string,
|
|
1795
|
-
choice: HookPermissionChoice,
|
|
1796
|
-
source = "unknown",
|
|
1797
|
-
): { resolved: boolean; delivered: boolean } {
|
|
1798
|
-
const pending = this.pendingPermissions.get(requestId);
|
|
1799
|
-
const outcome = typeof choice === "string" ? choice : choice.outcome;
|
|
1800
|
-
const optionId = typeof choice === "string" ? undefined : choice.optionId;
|
|
1801
|
-
if (!pending) {
|
|
1802
|
-
this.log(`no pending permission for ${requestId} via ${source}: ${outcome}:${optionId ?? "default"}`);
|
|
1803
|
-
return { resolved: false, delivered: false };
|
|
1804
|
-
}
|
|
1805
|
-
this.pendingPermissions.delete(requestId);
|
|
1806
|
-
clearTimeout(pending.timeout);
|
|
1807
|
-
const delivered = pending.resolve(this.formatHookPermissionDecision(pending, choice));
|
|
1808
|
-
|
|
1809
|
-
const stack = this.permissionStacks.get(pending.terminalId);
|
|
1810
|
-
if (stack) {
|
|
1811
|
-
const idx = stack.findIndex((entry) => entry.requestId === requestId);
|
|
1812
|
-
if (idx >= 0) stack.splice(idx, 1);
|
|
1813
|
-
if (stack.length === 0) this.permissionStacks.delete(pending.terminalId);
|
|
1814
|
-
}
|
|
1815
|
-
this.log(`resolved permission ${requestId} via ${source}: ${outcome}:${optionId ?? "default"} delivered=${delivered}`);
|
|
1816
|
-
this.sendPermissionSnapshot(
|
|
1817
|
-
pending.terminalId,
|
|
1818
|
-
"thinking",
|
|
1819
|
-
outcome === "allow" ? "permission allowed" : "permission denied",
|
|
1820
|
-
{ requestId, outcome, source, delivered },
|
|
1821
|
-
);
|
|
1822
|
-
return { resolved: true, delivered };
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
private formatHookPermissionDecision(
|
|
1826
|
-
permission: PendingPermission,
|
|
1827
|
-
choice: HookPermissionChoice,
|
|
1828
|
-
): HookPermissionDecision {
|
|
1829
|
-
const outcome = typeof choice === "string" ? choice : choice.outcome;
|
|
1830
|
-
const optionId = typeof choice === "string" ? undefined : choice.optionId;
|
|
1831
|
-
if (outcome === "allow") {
|
|
1832
|
-
return {
|
|
1833
|
-
behavior: "allow",
|
|
1834
|
-
...(optionId === "allow_always" && permission.permissionSuggestions.length > 0
|
|
1835
|
-
? { updatedPermissions: permission.permissionSuggestions }
|
|
1836
|
-
: {}),
|
|
1837
|
-
};
|
|
1838
|
-
}
|
|
1839
|
-
return {
|
|
1840
|
-
behavior: "deny",
|
|
1841
|
-
message: outcome === "cancelled" ? "Permission request cancelled." : "Permission denied by user.",
|
|
1842
|
-
};
|
|
1843
|
-
}
|
|
1844
|
-
|
|
1845
|
-
private sendPermissionSnapshot(
|
|
1846
|
-
terminalId: string,
|
|
1847
|
-
phase: string,
|
|
1848
|
-
summary?: string,
|
|
1849
|
-
permissionResolution?: {
|
|
1850
|
-
requestId: string;
|
|
1851
|
-
outcome: "allow" | "deny" | "cancelled";
|
|
1852
|
-
source: string;
|
|
1853
|
-
delivered: boolean;
|
|
1854
|
-
},
|
|
1855
|
-
): void {
|
|
1856
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1857
|
-
const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
|
|
1858
|
-
const pendingPermissionCount = stack?.length ?? 0;
|
|
1859
|
-
const term = this.terminals.get(terminalId);
|
|
1860
|
-
const seq = term ? term.statusSeq++ : 0;
|
|
1861
|
-
this.send(createEnvelope({
|
|
1862
|
-
type: "terminal.status",
|
|
1863
|
-
sessionId: this.sessionId,
|
|
1864
|
-
terminalId,
|
|
1865
|
-
payload: {
|
|
1866
|
-
phase,
|
|
1867
|
-
seq,
|
|
1868
|
-
...(summary && { summary }),
|
|
1869
|
-
...(permissionResolution && { permissionResolution }),
|
|
1870
|
-
...(topPermission && { topPermission }),
|
|
1871
|
-
...(pendingPermissionCount > 0 && { pendingPermissionCount }),
|
|
1872
|
-
},
|
|
1873
|
-
}));
|
|
1874
|
-
}
|
|
1875
|
-
|
|
1876
|
-
private cleanupHookServer(term: TerminalInstance): void {
|
|
1877
|
-
// Drain any pending permission requests for this terminal
|
|
1878
|
-
this.drainPendingPermissions(term.id);
|
|
1879
|
-
if (term.hookServer) {
|
|
1880
|
-
term.hookServer.close();
|
|
1881
|
-
term.hookServer = undefined;
|
|
1882
|
-
this.log(`hook server closed for ${term.id}`);
|
|
1883
|
-
}
|
|
1884
|
-
const marker = term.hookMarker;
|
|
1885
|
-
for (const configPath of term.hookConfigPaths) {
|
|
1886
|
-
try {
|
|
1887
|
-
// Copilot: per-instance file — just delete it
|
|
1888
|
-
if (configPath.includes(`linkshell-${marker}`)) {
|
|
1889
|
-
if (existsSync(configPath)) {
|
|
1890
|
-
unlinkSync(configPath);
|
|
1891
|
-
this.log(`removed copilot hook file ${configPath}`);
|
|
1892
|
-
}
|
|
1893
|
-
} else {
|
|
1894
|
-
// Claude/Codex/Gemini: remove our entries from the shared config
|
|
1895
|
-
this.removeHookEntries(configPath, marker);
|
|
1896
|
-
}
|
|
1897
|
-
} catch { /* ignore */ }
|
|
1898
|
-
}
|
|
1899
|
-
term.hookConfigPaths = [];
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
/** Remove hook entries containing our marker from a JSON config file */
|
|
1903
|
-
private removeHookEntries(configPath: string, marker: string): void {
|
|
1904
|
-
if (!existsSync(configPath)) return;
|
|
1905
|
-
try {
|
|
1906
|
-
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1907
|
-
const hooks = raw.hooks as Record<string, unknown[]> | undefined;
|
|
1908
|
-
if (!hooks) return;
|
|
1909
|
-
|
|
1910
|
-
let changed = false;
|
|
1911
|
-
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
1912
|
-
if (!Array.isArray(entries)) continue;
|
|
1913
|
-
const filtered = entries.filter((entry) => {
|
|
1914
|
-
const str = JSON.stringify(entry);
|
|
1915
|
-
return !str.includes(marker);
|
|
1916
|
-
});
|
|
1917
|
-
if (filtered.length !== entries.length) {
|
|
1918
|
-
changed = true;
|
|
1919
|
-
if (filtered.length === 0) {
|
|
1920
|
-
delete hooks[eventName];
|
|
1921
|
-
} else {
|
|
1922
|
-
hooks[eventName] = filtered;
|
|
1923
|
-
}
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
|
-
|
|
1927
|
-
if (changed) {
|
|
1928
|
-
// If no hooks left, remove the hooks key entirely
|
|
1929
|
-
if (Object.keys(hooks).length === 0) {
|
|
1930
|
-
delete raw.hooks;
|
|
1931
|
-
}
|
|
1932
|
-
writeFileSync(configPath, JSON.stringify(raw, null, 2));
|
|
1933
|
-
this.log(`removed our hook entries from ${configPath}`);
|
|
1934
|
-
}
|
|
1935
|
-
} catch { /* ignore parse errors */ }
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
1037
|
private send(message: Envelope): void {
|
|
1939
1038
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
1940
1039
|
return;
|
|
1941
1040
|
}
|
|
1942
1041
|
const machineId = this.machineIdentity?.machineId;
|
|
1943
1042
|
const enriched = machineId && (
|
|
1944
|
-
message.type === "terminal.status" ||
|
|
1945
1043
|
message.type === "agent.capabilities" ||
|
|
1946
1044
|
message.type === "agent.snapshot" ||
|
|
1947
1045
|
message.type === "agent.v2.capabilities" ||
|
|
@@ -1963,8 +1061,8 @@ export class BridgeSession {
|
|
|
1963
1061
|
this.heartbeatTimer = setInterval(() => {
|
|
1964
1062
|
this.send(
|
|
1965
1063
|
createEnvelope({
|
|
1966
|
-
type: "
|
|
1967
|
-
|
|
1064
|
+
type: "device.heartbeat",
|
|
1065
|
+
hostDeviceId: this.sessionId,
|
|
1968
1066
|
payload: { ts: Date.now() },
|
|
1969
1067
|
}),
|
|
1970
1068
|
);
|
|
@@ -2097,7 +1195,6 @@ export class BridgeSession {
|
|
|
2097
1195
|
}
|
|
2098
1196
|
this.tunnelSockets.clear();
|
|
2099
1197
|
for (const term of this.terminals.values()) {
|
|
2100
|
-
this.cleanupHookServer(term);
|
|
2101
1198
|
if (term.status === "running") term.pty.kill();
|
|
2102
1199
|
}
|
|
2103
1200
|
this.terminals.clear();
|