linkshell-cli 0.2.125 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/src/commands/setup.js +2 -20
- package/dist/cli/src/commands/setup.js.map +1 -1
- package/dist/cli/src/index.js +26 -28
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/providers.d.ts +7 -3
- package/dist/cli/src/providers.js +19 -76
- package/dist/cli/src/providers.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-session.d.ts +1 -1
- package/dist/cli/src/runtime/acp/agent-session.js +4 -4
- package/dist/cli/src/runtime/acp/agent-session.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace.js +17 -62
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +1 -31
- package/dist/cli/src/runtime/bridge-session.js +57 -993
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/src/runtime/screen-fallback.d.ts +1 -1
- package/dist/cli/src/runtime/screen-fallback.js +4 -4
- package/dist/cli/src/runtime/screen-fallback.js.map +1 -1
- package/dist/cli/src/runtime/screen-share.d.ts +1 -1
- package/dist/cli/src/runtime/screen-share.js +7 -7
- package/dist/cli/src/runtime/screen-share.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +3743 -5570
- package/dist/shared-protocol/src/index.js +19 -84
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +12 -12
- package/src/commands/setup.ts +5 -31
- package/src/index.ts +29 -34
- package/src/providers.ts +26 -108
- package/src/runtime/acp/agent-workspace.ts +18 -63
- package/src/runtime/bridge-session.ts +57 -1091
- package/src/runtime/screen-fallback.ts +5 -5
- package/src/runtime/screen-share.ts +8 -8
- package/src/types/linkshell-gateway.d.ts +18 -0
- package/dist/cli/src/runtime/acp-relay.d.ts +0 -23
- package/dist/cli/src/runtime/acp-relay.js +0 -73
- package/dist/cli/src/runtime/acp-relay.js.map +0 -1
- package/src/runtime/acp/agent-session.ts +0 -1180
|
@@ -2,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 {
|
|
@@ -21,7 +21,6 @@ import { getLanIp } from "../utils/lan-ip.js";
|
|
|
21
21
|
import { startKeepAwake, type KeepAwakeHandle } from "../utils/keep-awake.js";
|
|
22
22
|
import { loadOrCreateMachineIdentity, type MachineIdentity } from "../machine-id.js";
|
|
23
23
|
import { getValidToken } from "../auth.js";
|
|
24
|
-
import { AgentSessionProxy } from "./acp/agent-session.js";
|
|
25
24
|
import { AgentWorkspaceProxy } from "./acp/agent-workspace.js";
|
|
26
25
|
import { detectAvailableProviders, type AgentProvider } from "./acp/provider-resolver.js";
|
|
27
26
|
|
|
@@ -29,7 +28,7 @@ export interface BridgeSessionOptions {
|
|
|
29
28
|
gatewayUrl: string;
|
|
30
29
|
gatewayHttpUrl: string;
|
|
31
30
|
pairingGateway?: string;
|
|
32
|
-
|
|
31
|
+
hostDeviceId?: string;
|
|
33
32
|
cols: number;
|
|
34
33
|
rows: number;
|
|
35
34
|
clientName: string;
|
|
@@ -49,166 +48,14 @@ const RECONNECT_BASE_DELAY = 1_000;
|
|
|
49
48
|
const RECONNECT_MAX_DELAY = 30_000;
|
|
50
49
|
const RECONNECT_MAX_ATTEMPTS = 20;
|
|
51
50
|
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
51
|
|
|
58
52
|
interface TerminalInstance {
|
|
59
53
|
id: string;
|
|
60
54
|
pty: pty.IPty;
|
|
61
55
|
cwd: string;
|
|
62
|
-
projectName: string;
|
|
63
|
-
provider: string;
|
|
64
56
|
scrollback: ScrollbackBuffer;
|
|
65
57
|
outputSeq: number;
|
|
66
|
-
statusSeq: number;
|
|
67
58
|
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
59
|
}
|
|
213
60
|
|
|
214
61
|
function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
|
|
@@ -285,27 +132,16 @@ export class BridgeSession {
|
|
|
285
132
|
private sessionId = "";
|
|
286
133
|
private exited = false;
|
|
287
134
|
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
135
|
private screenCapture: ScreenFallback | undefined;
|
|
299
136
|
private screenShare: ScreenShare | undefined;
|
|
300
137
|
private tunnelSockets = new Map<string, WebSocket>();
|
|
301
138
|
private keepAwake: KeepAwakeHandle | undefined;
|
|
302
|
-
private agentSession: AgentSessionProxy | undefined;
|
|
303
139
|
private agentWorkspace: AgentWorkspaceProxy | undefined;
|
|
304
140
|
private machineIdentity: MachineIdentity | undefined;
|
|
305
141
|
|
|
306
142
|
constructor(options: BridgeSessionOptions) {
|
|
307
143
|
this.options = options;
|
|
308
|
-
this.sessionId = options.
|
|
144
|
+
this.sessionId = options.hostDeviceId ?? "";
|
|
309
145
|
}
|
|
310
146
|
|
|
311
147
|
private log(msg: string): void {
|
|
@@ -314,40 +150,30 @@ export class BridgeSession {
|
|
|
314
150
|
}
|
|
315
151
|
}
|
|
316
152
|
|
|
317
|
-
private terminalHookMarker(terminalId: string): string {
|
|
318
|
-
const safeTerminalId = terminalId.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
|
319
|
-
return `${this.hookMarker}-${safeTerminalId}`;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
153
|
async start(): Promise<void> {
|
|
323
154
|
this.log(
|
|
324
|
-
`starting
|
|
155
|
+
`starting device bridge (gateway=${this.options.gatewayUrl}, terminal=shell)`,
|
|
325
156
|
);
|
|
326
157
|
this.machineIdentity = loadOrCreateMachineIdentity();
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
158
|
+
this.sessionId ||= this.machineIdentity.machineId;
|
|
159
|
+
await this.createPairing();
|
|
330
160
|
if (this.options.keepAwake) {
|
|
331
161
|
this.keepAwake = startKeepAwake();
|
|
332
162
|
} else {
|
|
333
163
|
process.stderr.write("[bridge] keep-awake disabled\n");
|
|
334
164
|
}
|
|
335
165
|
if (this.options.agentUi) {
|
|
336
|
-
process.env.LINKSHELL_ID = this.terminalHookMarker(DEFAULT_TERMINAL_ID);
|
|
337
166
|
const availableProviders = this.options.agentProvider
|
|
338
167
|
? [normalizeAgentProvider(this.options.agentProvider)]
|
|
339
168
|
: detectAvailableProviders();
|
|
340
169
|
const agentOptions = {
|
|
341
|
-
|
|
170
|
+
hostDeviceId: this.sessionId,
|
|
342
171
|
cwd: process.cwd(),
|
|
343
172
|
availableProviders,
|
|
344
173
|
command: this.options.agentCommand,
|
|
345
174
|
verbose: this.options.verbose,
|
|
346
175
|
send: (envelope: Envelope) => this.send(envelope),
|
|
347
176
|
};
|
|
348
|
-
this.agentSession = new AgentSessionProxy({
|
|
349
|
-
...agentOptions,
|
|
350
|
-
});
|
|
351
177
|
this.agentWorkspace = new AgentWorkspaceProxy({
|
|
352
178
|
...agentOptions,
|
|
353
179
|
});
|
|
@@ -366,17 +192,17 @@ export class BridgeSession {
|
|
|
366
192
|
const res = await fetch(`${this.options.gatewayHttpUrl}/pairings`, {
|
|
367
193
|
method: "POST",
|
|
368
194
|
headers,
|
|
369
|
-
body: JSON.stringify({}),
|
|
195
|
+
body: JSON.stringify({ hostDeviceId: this.sessionId }),
|
|
370
196
|
});
|
|
371
197
|
if (!res.ok) {
|
|
372
198
|
throw new Error(`Failed to create pairing: ${res.status}`);
|
|
373
199
|
}
|
|
374
200
|
const body = (await res.json()) as {
|
|
375
|
-
|
|
201
|
+
hostDeviceId: string;
|
|
376
202
|
pairingCode: string;
|
|
377
203
|
expiresAt: string;
|
|
378
204
|
};
|
|
379
|
-
this.sessionId = body.
|
|
205
|
+
this.sessionId = body.hostDeviceId;
|
|
380
206
|
|
|
381
207
|
const pairingGateway = resolvePairingGateway(
|
|
382
208
|
this.options.gatewayHttpUrl,
|
|
@@ -389,7 +215,7 @@ export class BridgeSession {
|
|
|
389
215
|
process.stderr.write(
|
|
390
216
|
`\n \x1b[1mPairing code: \x1b[36m${body.pairingCode}\x1b[0m\n`,
|
|
391
217
|
);
|
|
392
|
-
process.stderr.write(`
|
|
218
|
+
process.stderr.write(` Host device: ${body.hostDeviceId}\n`);
|
|
393
219
|
process.stderr.write(` Expires: ${body.expiresAt}\n\n`);
|
|
394
220
|
if (!pairingGateway) {
|
|
395
221
|
process.stderr.write(
|
|
@@ -444,7 +270,7 @@ export class BridgeSession {
|
|
|
444
270
|
}
|
|
445
271
|
|
|
446
272
|
const url = new URL(this.options.gatewayUrl);
|
|
447
|
-
url.searchParams.set("
|
|
273
|
+
url.searchParams.set("hostDeviceId", this.sessionId);
|
|
448
274
|
url.searchParams.set("role", "host");
|
|
449
275
|
const authToken = await this.resolveAuthToken();
|
|
450
276
|
if (authToken) {
|
|
@@ -463,18 +289,22 @@ export class BridgeSession {
|
|
|
463
289
|
this.reconnecting = false;
|
|
464
290
|
this.send(
|
|
465
291
|
createEnvelope({
|
|
466
|
-
type: "
|
|
467
|
-
|
|
292
|
+
type: "device.connect",
|
|
293
|
+
hostDeviceId: this.sessionId,
|
|
468
294
|
payload: {
|
|
469
295
|
role: "host" as const,
|
|
470
296
|
clientName: this.options.clientName,
|
|
471
|
-
provider: this.options.providerConfig.provider,
|
|
472
297
|
protocolVersion: PROTOCOL_VERSION,
|
|
473
298
|
machineId: this.machineIdentity?.machineId,
|
|
474
299
|
hostname: this.options.hostname || hostname(),
|
|
475
300
|
platform: platform(),
|
|
476
301
|
cwd: process.cwd(),
|
|
477
|
-
|
|
302
|
+
capabilities: [
|
|
303
|
+
"terminal",
|
|
304
|
+
...(this.options.agentUi ? ["agent-ui"] : []),
|
|
305
|
+
...(this.options.screen ? ["screen"] : []),
|
|
306
|
+
"tunnel",
|
|
307
|
+
],
|
|
478
308
|
},
|
|
479
309
|
}),
|
|
480
310
|
);
|
|
@@ -531,7 +361,7 @@ export class BridgeSession {
|
|
|
531
361
|
}
|
|
532
362
|
case "terminal.spawn": {
|
|
533
363
|
const p = parseTypedPayload("terminal.spawn", envelope.payload);
|
|
534
|
-
const normalizedCwd = resolve(p.cwd);
|
|
364
|
+
const normalizedCwd = resolve(p.cwd ?? process.cwd());
|
|
535
365
|
// Dedup: if a running terminal already exists for this cwd, return it
|
|
536
366
|
const existing = [...this.terminals.values()].find(
|
|
537
367
|
(t) => t.status === "running" && resolve(t.cwd) === normalizedCwd,
|
|
@@ -539,25 +369,25 @@ export class BridgeSession {
|
|
|
539
369
|
if (existing) {
|
|
540
370
|
this.send(createEnvelope({
|
|
541
371
|
type: "terminal.spawned",
|
|
542
|
-
|
|
372
|
+
hostDeviceId: this.sessionId,
|
|
543
373
|
terminalId: existing.id,
|
|
544
|
-
payload: { terminalId: existing.id, cwd: existing.cwd,
|
|
374
|
+
payload: { terminalId: existing.id, cwd: existing.cwd, shell: this.options.providerConfig.command },
|
|
545
375
|
}));
|
|
546
376
|
} else {
|
|
547
377
|
const newId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
548
378
|
try {
|
|
549
|
-
await this.spawnTerminal(newId, normalizedCwd
|
|
379
|
+
await this.spawnTerminal(newId, normalizedCwd);
|
|
550
380
|
this.send(createEnvelope({
|
|
551
381
|
type: "terminal.spawned",
|
|
552
|
-
|
|
382
|
+
hostDeviceId: this.sessionId,
|
|
553
383
|
terminalId: newId,
|
|
554
|
-
payload: { terminalId: newId, cwd: normalizedCwd,
|
|
384
|
+
payload: { terminalId: newId, cwd: normalizedCwd, shell: this.options.providerConfig.command },
|
|
555
385
|
}));
|
|
556
386
|
} catch (err) {
|
|
557
387
|
this.log(`failed to spawn terminal ${newId}: ${err}`);
|
|
558
388
|
this.send(createEnvelope({
|
|
559
389
|
type: "terminal.exit",
|
|
560
|
-
|
|
390
|
+
hostDeviceId: this.sessionId,
|
|
561
391
|
terminalId: newId,
|
|
562
392
|
payload: { exitCode: 1, signal: 0 },
|
|
563
393
|
}));
|
|
@@ -599,13 +429,13 @@ export class BridgeSession {
|
|
|
599
429
|
});
|
|
600
430
|
this.send(createEnvelope({
|
|
601
431
|
type: "terminal.browse.result",
|
|
602
|
-
|
|
432
|
+
hostDeviceId: this.sessionId,
|
|
603
433
|
payload: { path: browsePath, entries, requestId: p.requestId },
|
|
604
434
|
}));
|
|
605
435
|
} catch (err: unknown) {
|
|
606
436
|
this.send(createEnvelope({
|
|
607
437
|
type: "terminal.browse.result",
|
|
608
|
-
|
|
438
|
+
hostDeviceId: this.sessionId,
|
|
609
439
|
payload: { path: browsePath, entries: [], error: (err as Error).message, requestId: p.requestId },
|
|
610
440
|
}));
|
|
611
441
|
}
|
|
@@ -634,7 +464,7 @@ export class BridgeSession {
|
|
|
634
464
|
}
|
|
635
465
|
this.send(createEnvelope({
|
|
636
466
|
type: "terminal.file.read.result",
|
|
637
|
-
|
|
467
|
+
hostDeviceId: this.sessionId,
|
|
638
468
|
payload: {
|
|
639
469
|
path: filePath,
|
|
640
470
|
content: buffer.toString("utf8"),
|
|
@@ -647,7 +477,7 @@ export class BridgeSession {
|
|
|
647
477
|
} catch (err: unknown) {
|
|
648
478
|
this.send(createEnvelope({
|
|
649
479
|
type: "terminal.file.read.result",
|
|
650
|
-
|
|
480
|
+
hostDeviceId: this.sessionId,
|
|
651
481
|
payload: {
|
|
652
482
|
path: filePath,
|
|
653
483
|
content: "",
|
|
@@ -678,13 +508,13 @@ export class BridgeSession {
|
|
|
678
508
|
}));
|
|
679
509
|
this.send(createEnvelope({
|
|
680
510
|
type: "terminal.browse.result",
|
|
681
|
-
|
|
511
|
+
hostDeviceId: this.sessionId,
|
|
682
512
|
payload: { path: parentPath, entries },
|
|
683
513
|
}));
|
|
684
514
|
} catch (err: unknown) {
|
|
685
515
|
this.send(createEnvelope({
|
|
686
516
|
type: "terminal.browse.result",
|
|
687
|
-
|
|
517
|
+
hostDeviceId: this.sessionId,
|
|
688
518
|
payload: { path: dirPath, entries: [], error: (err as Error).message },
|
|
689
519
|
}));
|
|
690
520
|
}
|
|
@@ -725,21 +555,21 @@ export class BridgeSession {
|
|
|
725
555
|
} catch {}
|
|
726
556
|
this.send(createEnvelope({
|
|
727
557
|
type: "terminal.history.response",
|
|
728
|
-
|
|
558
|
+
hostDeviceId: this.sessionId,
|
|
729
559
|
payload: { entries, shell },
|
|
730
560
|
}));
|
|
731
561
|
break;
|
|
732
562
|
}
|
|
733
|
-
case "
|
|
734
|
-
const p = parseTypedPayload("
|
|
563
|
+
case "device.ack": {
|
|
564
|
+
const p = parseTypedPayload("device.ack", envelope.payload);
|
|
735
565
|
const term = this.terminals.get(tid);
|
|
736
566
|
if (term) {
|
|
737
567
|
term.scrollback.trimUpTo(p.seq);
|
|
738
568
|
}
|
|
739
569
|
break;
|
|
740
570
|
}
|
|
741
|
-
case "
|
|
742
|
-
const p = parseTypedPayload("
|
|
571
|
+
case "device.resume": {
|
|
572
|
+
const p = parseTypedPayload("device.resume", envelope.payload);
|
|
743
573
|
// Replay all terminals
|
|
744
574
|
for (const [termId, term] of this.terminals) {
|
|
745
575
|
this.replayFrom(
|
|
@@ -752,7 +582,7 @@ export class BridgeSession {
|
|
|
752
582
|
this.sendTerminalList();
|
|
753
583
|
break;
|
|
754
584
|
}
|
|
755
|
-
case "
|
|
585
|
+
case "device.heartbeat":
|
|
756
586
|
break;
|
|
757
587
|
case "screen.start": {
|
|
758
588
|
const p = parseTypedPayload("screen.start", envelope.payload);
|
|
@@ -773,75 +603,6 @@ export class BridgeSession {
|
|
|
773
603
|
this.screenShare?.handleIceCandidate(p.candidate, p.sdpMid, p.sdpMLineIndex);
|
|
774
604
|
break;
|
|
775
605
|
}
|
|
776
|
-
case "agent.initialize":
|
|
777
|
-
case "agent.session.new":
|
|
778
|
-
case "agent.session.load":
|
|
779
|
-
case "agent.session.list":
|
|
780
|
-
case "agent.prompt":
|
|
781
|
-
case "agent.cancel": {
|
|
782
|
-
if (!this.agentSession) {
|
|
783
|
-
this.send(
|
|
784
|
-
createEnvelope({
|
|
785
|
-
type: "agent.capabilities",
|
|
786
|
-
sessionId: this.sessionId,
|
|
787
|
-
payload: {
|
|
788
|
-
enabled: false,
|
|
789
|
-
provider: normalizeAgentProvider(
|
|
790
|
-
this.options.agentProvider ?? "codex",
|
|
791
|
-
),
|
|
792
|
-
machineId: this.machineIdentity?.machineId,
|
|
793
|
-
error: "Agent GUI is not enabled. Start CLI with --agent-ui.",
|
|
794
|
-
supportsSessionList: false,
|
|
795
|
-
supportsSessionLoad: false,
|
|
796
|
-
supportsImages: false,
|
|
797
|
-
supportsAudio: false,
|
|
798
|
-
supportsPermission: false,
|
|
799
|
-
supportsPlan: false,
|
|
800
|
-
supportsCancel: false,
|
|
801
|
-
},
|
|
802
|
-
}),
|
|
803
|
-
);
|
|
804
|
-
break;
|
|
805
|
-
}
|
|
806
|
-
if (envelope.type === "agent.prompt") this.refreshAgentPermissionHooks();
|
|
807
|
-
await this.agentSession.handleEnvelope(envelope);
|
|
808
|
-
break;
|
|
809
|
-
}
|
|
810
|
-
case "agent.permission.response": {
|
|
811
|
-
const p = parseTypedPayload("agent.permission.response", envelope.payload);
|
|
812
|
-
if (this.resolvePendingPermission(p.requestId, {
|
|
813
|
-
outcome: p.outcome,
|
|
814
|
-
optionId: p.optionId,
|
|
815
|
-
}, "agent.permission.response").resolved) {
|
|
816
|
-
break;
|
|
817
|
-
}
|
|
818
|
-
if (!this.agentSession) {
|
|
819
|
-
this.send(
|
|
820
|
-
createEnvelope({
|
|
821
|
-
type: "agent.capabilities",
|
|
822
|
-
sessionId: this.sessionId,
|
|
823
|
-
payload: {
|
|
824
|
-
enabled: false,
|
|
825
|
-
provider: normalizeAgentProvider(
|
|
826
|
-
this.options.agentProvider ?? "codex",
|
|
827
|
-
),
|
|
828
|
-
machineId: this.machineIdentity?.machineId,
|
|
829
|
-
error: "Agent GUI is not enabled. Start CLI with --agent-ui.",
|
|
830
|
-
supportsSessionList: false,
|
|
831
|
-
supportsSessionLoad: false,
|
|
832
|
-
supportsImages: false,
|
|
833
|
-
supportsAudio: false,
|
|
834
|
-
supportsPermission: false,
|
|
835
|
-
supportsPlan: false,
|
|
836
|
-
supportsCancel: false,
|
|
837
|
-
},
|
|
838
|
-
}),
|
|
839
|
-
);
|
|
840
|
-
break;
|
|
841
|
-
}
|
|
842
|
-
await this.agentSession.handleEnvelope(envelope);
|
|
843
|
-
break;
|
|
844
|
-
}
|
|
845
606
|
case "agent.v2.capabilities.request":
|
|
846
607
|
case "agent.v2.conversation.open":
|
|
847
608
|
case "agent.v2.conversation.list":
|
|
@@ -855,7 +616,7 @@ export class BridgeSession {
|
|
|
855
616
|
this.send(
|
|
856
617
|
createEnvelope({
|
|
857
618
|
type: "agent.v2.capabilities",
|
|
858
|
-
|
|
619
|
+
hostDeviceId: this.sessionId,
|
|
859
620
|
payload: {
|
|
860
621
|
enabled: false,
|
|
861
622
|
provider: normalizeAgentProvider(
|
|
@@ -876,7 +637,6 @@ export class BridgeSession {
|
|
|
876
637
|
);
|
|
877
638
|
break;
|
|
878
639
|
}
|
|
879
|
-
if (envelope.type === "agent.v2.prompt" || envelope.type === "agent.v2.command.execute") this.refreshAgentPermissionHooks();
|
|
880
640
|
await this.agentWorkspace.handleEnvelope(envelope);
|
|
881
641
|
break;
|
|
882
642
|
}
|
|
@@ -892,44 +652,6 @@ export class BridgeSession {
|
|
|
892
652
|
}
|
|
893
653
|
break;
|
|
894
654
|
}
|
|
895
|
-
case "permission.decision": {
|
|
896
|
-
const p = envelope.payload as { requestId: string; decision: "allow" | "deny" };
|
|
897
|
-
const result = this.resolvePendingPermission(p.requestId, p.decision, "permission.decision");
|
|
898
|
-
if (!result.resolved) {
|
|
899
|
-
this.sendPermissionSnapshot(
|
|
900
|
-
tid,
|
|
901
|
-
"thinking",
|
|
902
|
-
"permission not pending",
|
|
903
|
-
{
|
|
904
|
-
requestId: p.requestId,
|
|
905
|
-
outcome: p.decision,
|
|
906
|
-
source: "permission.decision",
|
|
907
|
-
delivered: false,
|
|
908
|
-
},
|
|
909
|
-
);
|
|
910
|
-
}
|
|
911
|
-
process.stderr.write(
|
|
912
|
-
`[bridge] permission decision request=${p.requestId} decision=${p.decision} resolved=${result.resolved} delivered=${result.delivered}\n`,
|
|
913
|
-
);
|
|
914
|
-
this.send(createEnvelope({
|
|
915
|
-
type: "permission.decision.result",
|
|
916
|
-
sessionId: this.sessionId,
|
|
917
|
-
terminalId: tid,
|
|
918
|
-
payload: {
|
|
919
|
-
requestId: p.requestId,
|
|
920
|
-
decision: p.decision,
|
|
921
|
-
resolved: result.resolved,
|
|
922
|
-
delivered: result.delivered,
|
|
923
|
-
source: "permission.decision",
|
|
924
|
-
message: result.delivered
|
|
925
|
-
? undefined
|
|
926
|
-
: result.resolved
|
|
927
|
-
? "Permission resolved but response was not delivered"
|
|
928
|
-
: "Permission request is no longer pending",
|
|
929
|
-
},
|
|
930
|
-
}));
|
|
931
|
-
break;
|
|
932
|
-
}
|
|
933
655
|
case "tunnel.request": {
|
|
934
656
|
const p = parseTypedPayload("tunnel.request", envelope.payload);
|
|
935
657
|
this.handleTunnelRequest(p);
|
|
@@ -991,7 +713,7 @@ export class BridgeSession {
|
|
|
991
713
|
this.send(
|
|
992
714
|
createEnvelope({
|
|
993
715
|
type: "tunnel.response",
|
|
994
|
-
|
|
716
|
+
hostDeviceId: this.sessionId,
|
|
995
717
|
payload: {
|
|
996
718
|
requestId,
|
|
997
719
|
statusCode: proxyRes.statusCode ?? 200,
|
|
@@ -1008,7 +730,7 @@ export class BridgeSession {
|
|
|
1008
730
|
this.send(
|
|
1009
731
|
createEnvelope({
|
|
1010
732
|
type: "tunnel.response",
|
|
1011
|
-
|
|
733
|
+
hostDeviceId: this.sessionId,
|
|
1012
734
|
payload: {
|
|
1013
735
|
requestId,
|
|
1014
736
|
statusCode: proxyRes.statusCode ?? 200,
|
|
@@ -1054,7 +776,7 @@ export class BridgeSession {
|
|
|
1054
776
|
this.send(
|
|
1055
777
|
createEnvelope({
|
|
1056
778
|
type: "tunnel.ws.data",
|
|
1057
|
-
|
|
779
|
+
hostDeviceId: this.sessionId,
|
|
1058
780
|
payload: {
|
|
1059
781
|
requestId,
|
|
1060
782
|
data: buf.toString("base64"),
|
|
@@ -1070,7 +792,7 @@ export class BridgeSession {
|
|
|
1070
792
|
this.send(
|
|
1071
793
|
createEnvelope({
|
|
1072
794
|
type: "tunnel.ws.close",
|
|
1073
|
-
|
|
795
|
+
hostDeviceId: this.sessionId,
|
|
1074
796
|
payload: {
|
|
1075
797
|
requestId,
|
|
1076
798
|
code: safeCode,
|
|
@@ -1085,7 +807,7 @@ export class BridgeSession {
|
|
|
1085
807
|
this.send(
|
|
1086
808
|
createEnvelope({
|
|
1087
809
|
type: "tunnel.ws.close",
|
|
1088
|
-
|
|
810
|
+
hostDeviceId: this.sessionId,
|
|
1089
811
|
payload: {
|
|
1090
812
|
requestId,
|
|
1091
813
|
code: 1001,
|
|
@@ -1123,7 +845,7 @@ export class BridgeSession {
|
|
|
1123
845
|
this.send(
|
|
1124
846
|
createEnvelope({
|
|
1125
847
|
type: "tunnel.response",
|
|
1126
|
-
|
|
848
|
+
hostDeviceId: this.sessionId,
|
|
1127
849
|
payload: {
|
|
1128
850
|
requestId,
|
|
1129
851
|
statusCode,
|
|
@@ -1139,13 +861,12 @@ export class BridgeSession {
|
|
|
1139
861
|
const terminals = [...this.terminals.values()].map((t) => ({
|
|
1140
862
|
terminalId: t.id,
|
|
1141
863
|
cwd: t.cwd,
|
|
1142
|
-
projectName: t.projectName,
|
|
1143
|
-
provider: t.provider,
|
|
1144
864
|
status: t.status,
|
|
865
|
+
shell: this.options.providerConfig.command,
|
|
1145
866
|
}));
|
|
1146
867
|
this.send(createEnvelope({
|
|
1147
868
|
type: "terminal.list",
|
|
1148
|
-
|
|
869
|
+
hostDeviceId: this.sessionId,
|
|
1149
870
|
payload: { terminals },
|
|
1150
871
|
}));
|
|
1151
872
|
}
|
|
@@ -1163,7 +884,7 @@ export class BridgeSession {
|
|
|
1163
884
|
this.send(
|
|
1164
885
|
createEnvelope({
|
|
1165
886
|
type: "terminal.output",
|
|
1166
|
-
|
|
887
|
+
hostDeviceId: this.sessionId,
|
|
1167
888
|
terminalId,
|
|
1168
889
|
seq: msg.seq,
|
|
1169
890
|
payload: { ...payload, isReplay: true },
|
|
@@ -1172,41 +893,14 @@ export class BridgeSession {
|
|
|
1172
893
|
}
|
|
1173
894
|
}
|
|
1174
895
|
|
|
1175
|
-
private async spawnTerminal(terminalId: string, cwd: string
|
|
896
|
+
private async spawnTerminal(terminalId: string, cwd: string): Promise<void> {
|
|
1176
897
|
const cleanEnv: Record<string, string> = {};
|
|
1177
898
|
for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
|
|
1178
899
|
if (v !== undefined) cleanEnv[k] = v;
|
|
1179
900
|
}
|
|
1180
|
-
const hookMarker = this.terminalHookMarker(terminalId);
|
|
1181
|
-
// Inject marker so child CLIs' hook commands carry our identity
|
|
1182
|
-
cleanEnv["LINKSHELL_ID"] = hookMarker;
|
|
1183
901
|
|
|
1184
|
-
const provider = providerOverride ?? this.options.providerConfig.provider;
|
|
1185
902
|
const args = [...this.options.providerConfig.args];
|
|
1186
903
|
|
|
1187
|
-
// Set up hook server for structured status (all supported providers)
|
|
1188
|
-
// For "custom" shell, set up hooks for all providers since user may launch any of them
|
|
1189
|
-
let hookServer: http.Server | undefined;
|
|
1190
|
-
let hookPort: number | undefined;
|
|
1191
|
-
const hookConfigPaths: string[] = [];
|
|
1192
|
-
|
|
1193
|
-
if (provider === "custom") {
|
|
1194
|
-
const result = await this.setupHookServer(terminalId, args, "claude", hookMarker);
|
|
1195
|
-
hookServer = result.server;
|
|
1196
|
-
hookPort = result.port;
|
|
1197
|
-
hookConfigPaths.push(result.configPath);
|
|
1198
|
-
// Also set up hooks for other providers (curlCmd already has marker from setupHookServer)
|
|
1199
|
-
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`;
|
|
1200
|
-
hookConfigPaths.push(this.setupCodexHooks(terminalId, curlCmd, hookMarker));
|
|
1201
|
-
hookConfigPaths.push(this.setupGeminiHooks(terminalId, curlCmd, hookMarker));
|
|
1202
|
-
hookConfigPaths.push(this.setupCopilotHooks(terminalId, curlCmd, hookMarker));
|
|
1203
|
-
} else if (provider === "claude" || provider === "codex" || provider === "gemini" || provider === "copilot") {
|
|
1204
|
-
const result = await this.setupHookServer(terminalId, args, provider, hookMarker);
|
|
1205
|
-
hookServer = result.server;
|
|
1206
|
-
hookPort = result.port;
|
|
1207
|
-
hookConfigPaths.push(result.configPath);
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
904
|
const term: TerminalInstance = {
|
|
1211
905
|
id: terminalId,
|
|
1212
906
|
pty: pty.spawn(
|
|
@@ -1221,23 +915,16 @@ export class BridgeSession {
|
|
|
1221
915
|
},
|
|
1222
916
|
),
|
|
1223
917
|
cwd,
|
|
1224
|
-
projectName: basename(cwd),
|
|
1225
|
-
provider,
|
|
1226
918
|
scrollback: new ScrollbackBuffer(1000),
|
|
1227
919
|
outputSeq: 0,
|
|
1228
|
-
statusSeq: 0,
|
|
1229
920
|
status: "running",
|
|
1230
|
-
hookServer,
|
|
1231
|
-
hookPort,
|
|
1232
|
-
hookMarker,
|
|
1233
|
-
hookConfigPaths,
|
|
1234
921
|
};
|
|
1235
922
|
|
|
1236
923
|
term.pty.onData((data) => {
|
|
1237
924
|
const seq = term.outputSeq++;
|
|
1238
925
|
const envelope = createEnvelope({
|
|
1239
926
|
type: "terminal.output",
|
|
1240
|
-
|
|
927
|
+
hostDeviceId: this.sessionId,
|
|
1241
928
|
terminalId,
|
|
1242
929
|
seq,
|
|
1243
930
|
payload: {
|
|
@@ -1254,10 +941,9 @@ export class BridgeSession {
|
|
|
1254
941
|
|
|
1255
942
|
term.pty.onExit(({ exitCode, signal }) => {
|
|
1256
943
|
term.status = "exited";
|
|
1257
|
-
this.cleanupHookServer(term);
|
|
1258
944
|
this.send(createEnvelope({
|
|
1259
945
|
type: "terminal.exit",
|
|
1260
|
-
|
|
946
|
+
hostDeviceId: this.sessionId,
|
|
1261
947
|
terminalId,
|
|
1262
948
|
payload: { exitCode, signal },
|
|
1263
949
|
}));
|
|
@@ -1279,729 +965,12 @@ export class BridgeSession {
|
|
|
1279
965
|
this.log(`spawned terminal ${terminalId} in ${cwd}`);
|
|
1280
966
|
}
|
|
1281
967
|
|
|
1282
|
-
private async setupHookServer(terminalId: string, args: string[], provider: string, marker: string): Promise<{
|
|
1283
|
-
server: http.Server;
|
|
1284
|
-
port: number;
|
|
1285
|
-
configPath: string;
|
|
1286
|
-
}> {
|
|
1287
|
-
const server = http.createServer((req, res) => {
|
|
1288
|
-
this.log(`hook server received: ${req.method} ${req.url}`);
|
|
1289
|
-
const reqUrl = new URL(req.url ?? "/", "http://localhost");
|
|
1290
|
-
if (req.method !== "POST" || reqUrl.pathname !== "/hook") {
|
|
1291
|
-
res.writeHead(404);
|
|
1292
|
-
res.end();
|
|
1293
|
-
return;
|
|
1294
|
-
}
|
|
1295
|
-
// Check marker — reject events not from our PTY
|
|
1296
|
-
// m must match; lid must match OR be empty (some CLIs don't inherit env vars)
|
|
1297
|
-
const reqMarker = reqUrl.searchParams.get("m");
|
|
1298
|
-
const reqLid = reqUrl.searchParams.get("lid") ?? "";
|
|
1299
|
-
if (reqMarker !== marker || (reqLid !== "" && reqLid !== marker)) {
|
|
1300
|
-
this.log(`ignoring hook event: m=${reqMarker} lid=${reqLid} (expected ${marker})`);
|
|
1301
|
-
res.writeHead(200);
|
|
1302
|
-
res.end("ok");
|
|
1303
|
-
return;
|
|
1304
|
-
}
|
|
1305
|
-
let body = "";
|
|
1306
|
-
let bodyTooLarge = false;
|
|
1307
|
-
req.on("data", (chunk: Buffer) => {
|
|
1308
|
-
if (bodyTooLarge) return;
|
|
1309
|
-
body += chunk.toString();
|
|
1310
|
-
if (Buffer.byteLength(body, "utf8") > HOOK_BODY_LIMIT) {
|
|
1311
|
-
bodyTooLarge = true;
|
|
1312
|
-
res.writeHead(413);
|
|
1313
|
-
res.end("payload too large");
|
|
1314
|
-
req.destroy();
|
|
1315
|
-
}
|
|
1316
|
-
});
|
|
1317
|
-
req.on("end", () => {
|
|
1318
|
-
if (bodyTooLarge || res.writableEnded) return;
|
|
1319
|
-
this.log(`hook body (${body.length} bytes): ${body.slice(0, 200)}`);
|
|
1320
|
-
try {
|
|
1321
|
-
const event = JSON.parse(body);
|
|
1322
|
-
const hookName = (event.hook_event_name ?? event.event_name) as string | undefined;
|
|
1323
|
-
|
|
1324
|
-
// PermissionRequest: hold connection, wait for user decision from mobile app
|
|
1325
|
-
if (hookName === "PermissionRequest") {
|
|
1326
|
-
const requestId = `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1327
|
-
const permissionSuggestions = hookPermissionSuggestions(event);
|
|
1328
|
-
const timeout = setTimeout(() => {
|
|
1329
|
-
if (this.resolvePendingPermission(requestId, "deny", "permission.timeout").resolved) {
|
|
1330
|
-
this.log(`permission request ${requestId} timed out`);
|
|
1331
|
-
this.sendPermissionSnapshot(terminalId, "thinking", "permission timed out");
|
|
1332
|
-
}
|
|
1333
|
-
}, PERMISSION_REQUEST_TIMEOUT_MS);
|
|
1334
|
-
this.pendingPermissions.set(requestId, {
|
|
1335
|
-
terminalId,
|
|
1336
|
-
timeout,
|
|
1337
|
-
permissionSuggestions,
|
|
1338
|
-
resolve: (decision) => {
|
|
1339
|
-
if (res.writableEnded) return false;
|
|
1340
|
-
const responseJson = JSON.stringify({
|
|
1341
|
-
hookSpecificOutput: {
|
|
1342
|
-
hookEventName: "PermissionRequest",
|
|
1343
|
-
decision,
|
|
1344
|
-
},
|
|
1345
|
-
});
|
|
1346
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1347
|
-
res.end(responseJson);
|
|
1348
|
-
return true;
|
|
1349
|
-
},
|
|
1350
|
-
});
|
|
1351
|
-
// Send status with requestId so app can route decision back
|
|
1352
|
-
this.handleHookEvent(terminalId, event, provider, requestId);
|
|
1353
|
-
this.sendHookPermissionRequest(terminalId, event, requestId);
|
|
1354
|
-
} else {
|
|
1355
|
-
// All other hooks: respond immediately
|
|
1356
|
-
res.writeHead(200);
|
|
1357
|
-
res.end("ok");
|
|
1358
|
-
this.handleHookEvent(terminalId, event, provider);
|
|
1359
|
-
}
|
|
1360
|
-
} catch (e) {
|
|
1361
|
-
res.writeHead(200);
|
|
1362
|
-
res.end("ok");
|
|
1363
|
-
this.log(`hook parse error: ${e}`);
|
|
1364
|
-
}
|
|
1365
|
-
});
|
|
1366
|
-
});
|
|
1367
|
-
|
|
1368
|
-
// Listen on random port — await binding before reading address
|
|
1369
|
-
const port = await new Promise<number>((resolve, reject) => {
|
|
1370
|
-
server.listen(0, "127.0.0.1", () => {
|
|
1371
|
-
const addr = server.address() as { port: number };
|
|
1372
|
-
resolve(addr.port);
|
|
1373
|
-
});
|
|
1374
|
-
server.on("error", reject);
|
|
1375
|
-
});
|
|
1376
|
-
this.log(`hook server for ${terminalId} (${provider}) listening on port ${port}, marker=${marker}`);
|
|
1377
|
-
|
|
1378
|
-
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`;
|
|
1379
|
-
let configPath: string;
|
|
1380
|
-
|
|
1381
|
-
if (provider === "codex") {
|
|
1382
|
-
configPath = this.setupCodexHooks(terminalId, curlCmd, marker);
|
|
1383
|
-
} else if (provider === "gemini") {
|
|
1384
|
-
configPath = this.setupGeminiHooks(terminalId, curlCmd, marker);
|
|
1385
|
-
} else if (provider === "copilot") {
|
|
1386
|
-
configPath = this.setupCopilotHooks(terminalId, curlCmd, marker);
|
|
1387
|
-
} else {
|
|
1388
|
-
// Claude (default)
|
|
1389
|
-
configPath = this.setupClaudeHooks(terminalId, curlCmd, args, marker);
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
return { server, port, configPath };
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
private refreshAgentPermissionHooks(): void {
|
|
1396
|
-
const term = this.terminals.get(DEFAULT_TERMINAL_ID);
|
|
1397
|
-
if (!term?.hookPort) return;
|
|
1398
|
-
const marker = term.hookMarker;
|
|
1399
|
-
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`;
|
|
1400
|
-
const providers = this.options.agentProvider
|
|
1401
|
-
? [normalizeAgentProvider(this.options.agentProvider)]
|
|
1402
|
-
: detectAvailableProviders();
|
|
1403
|
-
try {
|
|
1404
|
-
for (const provider of providers) {
|
|
1405
|
-
if (provider === "codex") {
|
|
1406
|
-
this.setupCodexHooks(DEFAULT_TERMINAL_ID, curlCmd, marker);
|
|
1407
|
-
} else {
|
|
1408
|
-
// claude, custom
|
|
1409
|
-
this.setupClaudeHooks(DEFAULT_TERMINAL_ID, curlCmd, [], marker);
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
} catch (error) {
|
|
1413
|
-
this.log(`failed to refresh agent permission hooks: ${error instanceof Error ? error.message : String(error)}`);
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
private setupClaudeHooks(terminalId: string, curlCmd: string, args: string[], marker: string): string {
|
|
1418
|
-
// Write hooks to ~/.claude/settings.json — Claude Code reads hooks from here
|
|
1419
|
-
const claudeDir = join(homedir(), ".claude");
|
|
1420
|
-
if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
|
|
1421
|
-
const settingsPath = join(claudeDir, "settings.json");
|
|
1422
|
-
|
|
1423
|
-
let existing: Record<string, unknown> = {};
|
|
1424
|
-
try {
|
|
1425
|
-
existing = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
1426
|
-
} catch { /* doesn't exist yet */ }
|
|
1427
|
-
|
|
1428
|
-
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
|
|
1429
|
-
const permissionEntry = {
|
|
1430
|
-
matcher: "",
|
|
1431
|
-
hooks: [{
|
|
1432
|
-
type: "command",
|
|
1433
|
-
command: curlCmd,
|
|
1434
|
-
timeout: Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000),
|
|
1435
|
-
}],
|
|
1436
|
-
};
|
|
1437
|
-
|
|
1438
|
-
const hookEvents: Record<string, typeof hookEntry | typeof permissionEntry> = {
|
|
1439
|
-
PreToolUse: hookEntry,
|
|
1440
|
-
PostToolUse: hookEntry,
|
|
1441
|
-
PostToolUseFailure: hookEntry,
|
|
1442
|
-
Stop: hookEntry,
|
|
1443
|
-
PermissionRequest: permissionEntry,
|
|
1444
|
-
UserPromptSubmit: hookEntry,
|
|
1445
|
-
SessionStart: hookEntry,
|
|
1446
|
-
};
|
|
1447
|
-
|
|
1448
|
-
// Append our entries to existing hooks (first remove stale linkshell entries)
|
|
1449
|
-
const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
|
|
1450
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1451
|
-
existingHooks[eventName] = eventName === "PermissionRequest"
|
|
1452
|
-
? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
|
|
1453
|
-
: withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
const merged = { ...existing, hooks: existingHooks };
|
|
1457
|
-
writeFileSync(settingsPath, JSON.stringify(merged, null, 2));
|
|
1458
|
-
this.log(`claude hooks appended to ${settingsPath}`);
|
|
1459
|
-
|
|
1460
|
-
return settingsPath;
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
private setupCodexHooks(terminalId: string, curlCmd: string, marker: string): string {
|
|
1464
|
-
// Codex uses ~/.codex/hooks.json — same format as Claude (with matcher)
|
|
1465
|
-
const codexDir = join(homedir(), ".codex");
|
|
1466
|
-
if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
|
|
1467
|
-
|
|
1468
|
-
// Ensure [features] codex_hooks = true in config.toml
|
|
1469
|
-
const tomlPath = join(codexDir, "config.toml");
|
|
1470
|
-
let tomlContent = "";
|
|
1471
|
-
try { tomlContent = readFileSync(tomlPath, "utf8"); } catch { /* doesn't exist yet */ }
|
|
1472
|
-
|
|
1473
|
-
// Remove top-level codex_hooks (wrong location) and ensure it's under [features]
|
|
1474
|
-
const hasFeatureSection = tomlContent.includes("[features]");
|
|
1475
|
-
const hasCodexHooksUnderFeatures = hasFeatureSection &&
|
|
1476
|
-
/\[features\][^\[]*codex_hooks\s*=\s*true/s.test(tomlContent);
|
|
1477
|
-
|
|
1478
|
-
if (!hasCodexHooksUnderFeatures) {
|
|
1479
|
-
// Remove any top-level codex_hooks line
|
|
1480
|
-
tomlContent = tomlContent.replace(/^codex_hooks\s*=.*\n?/m, "");
|
|
1481
|
-
if (!tomlContent.includes("[features]")) {
|
|
1482
|
-
tomlContent += `\n[features]\ncodex_hooks = true\n`;
|
|
1483
|
-
} else {
|
|
1484
|
-
tomlContent = tomlContent.replace("[features]", "[features]\ncodex_hooks = true");
|
|
1485
|
-
}
|
|
1486
|
-
writeFileSync(tomlPath, tomlContent);
|
|
1487
|
-
this.log(`enabled codex_hooks under [features] in ${tomlPath}`);
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
const hooksPath = join(codexDir, "hooks.json");
|
|
1491
|
-
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
|
|
1492
|
-
const permissionEntry = {
|
|
1493
|
-
matcher: "",
|
|
1494
|
-
hooks: [{
|
|
1495
|
-
type: "command",
|
|
1496
|
-
command: curlCmd,
|
|
1497
|
-
timeout: Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000),
|
|
1498
|
-
}],
|
|
1499
|
-
};
|
|
1500
|
-
const hookEvents: Record<string, typeof hookEntry | typeof permissionEntry> = {
|
|
1501
|
-
SessionStart: hookEntry,
|
|
1502
|
-
PreToolUse: hookEntry,
|
|
1503
|
-
PostToolUse: hookEntry,
|
|
1504
|
-
UserPromptSubmit: hookEntry,
|
|
1505
|
-
Stop: hookEntry,
|
|
1506
|
-
PermissionRequest: permissionEntry,
|
|
1507
|
-
};
|
|
1508
|
-
|
|
1509
|
-
// Read existing and append
|
|
1510
|
-
let existing: { hooks?: Record<string, unknown[]> } = {};
|
|
1511
|
-
try { existing = JSON.parse(readFileSync(hooksPath, "utf8")); } catch { /* doesn't exist yet */ }
|
|
1512
|
-
const existingHooks = existing.hooks ?? {};
|
|
1513
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1514
|
-
existingHooks[eventName] = eventName === "PermissionRequest"
|
|
1515
|
-
? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
|
|
1516
|
-
: withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
writeFileSync(hooksPath, JSON.stringify({ ...existing, hooks: existingHooks }, null, 2));
|
|
1520
|
-
this.log(`codex hooks appended to ${hooksPath}`);
|
|
1521
|
-
return hooksPath;
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
private setupGeminiHooks(terminalId: string, curlCmd: string, marker: string): string {
|
|
1525
|
-
// Gemini uses ~/.gemini/settings.json — same format as Claude (with matcher)
|
|
1526
|
-
const geminiDir = join(homedir(), ".gemini");
|
|
1527
|
-
if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
|
|
1528
|
-
|
|
1529
|
-
const settingsPath = join(geminiDir, "settings.json");
|
|
1530
|
-
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5000 }] };
|
|
1531
|
-
const hookEvents: Record<string, typeof hookEntry> = {
|
|
1532
|
-
SessionStart: hookEntry,
|
|
1533
|
-
SessionEnd: hookEntry,
|
|
1534
|
-
BeforeTool: hookEntry,
|
|
1535
|
-
AfterTool: hookEntry,
|
|
1536
|
-
};
|
|
1537
|
-
|
|
1538
|
-
// Merge with existing settings if present
|
|
1539
|
-
let existing: Record<string, unknown> = {};
|
|
1540
|
-
try {
|
|
1541
|
-
existing = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
1542
|
-
} catch { /* doesn't exist yet */ }
|
|
1543
|
-
|
|
1544
|
-
const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
|
|
1545
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1546
|
-
existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
existing.hooks = existingHooks;
|
|
1550
|
-
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
|
1551
|
-
this.log(`gemini hooks appended to ${settingsPath}`);
|
|
1552
|
-
return settingsPath;
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
private setupCopilotHooks(terminalId: string, curlCmd: string, marker: string): string {
|
|
1556
|
-
// Copilot loads hooks from CWD as hooks.json
|
|
1557
|
-
const cwd = this.terminals.get(terminalId)?.cwd ?? process.cwd();
|
|
1558
|
-
const hooksPath = join(cwd, "hooks.json");
|
|
1559
|
-
const mkHook = () => ({
|
|
1560
|
-
type: "command",
|
|
1561
|
-
bash: curlCmd,
|
|
1562
|
-
timeoutSec: 30,
|
|
1563
|
-
});
|
|
1564
|
-
const hookEvents: Record<string, ReturnType<typeof mkHook>> = {
|
|
1565
|
-
sessionStart: mkHook(),
|
|
1566
|
-
sessionEnd: mkHook(),
|
|
1567
|
-
userPromptSubmitted: mkHook(),
|
|
1568
|
-
preToolUse: mkHook(),
|
|
1569
|
-
postToolUse: mkHook(),
|
|
1570
|
-
errorOccurred: mkHook(),
|
|
1571
|
-
};
|
|
1572
|
-
|
|
1573
|
-
// Read existing and append
|
|
1574
|
-
let existing: { version?: number; hooks?: Record<string, unknown[]> } = {};
|
|
1575
|
-
try { existing = JSON.parse(readFileSync(hooksPath, "utf8")); } catch { /* doesn't exist yet */ }
|
|
1576
|
-
const existingHooks = existing.hooks ?? {};
|
|
1577
|
-
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1578
|
-
existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
writeFileSync(hooksPath, JSON.stringify({ version: 1, hooks: existingHooks }, null, 2));
|
|
1582
|
-
this.log(`copilot hooks appended to ${hooksPath}`);
|
|
1583
|
-
return hooksPath;
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
private handleHookEvent(terminalId: string, event: Record<string, unknown>, provider: string, permissionRequestId?: string): void {
|
|
1587
|
-
const rawHookName = (event.hook_event_name ?? event.event_name) as string | undefined;
|
|
1588
|
-
if (!rawHookName) return;
|
|
1589
|
-
|
|
1590
|
-
// Auto-detect provider from hook event fields
|
|
1591
|
-
const hookTerm = this.terminals.get(terminalId);
|
|
1592
|
-
let detectedProvider = provider;
|
|
1593
|
-
|
|
1594
|
-
// Always detect from transcript_path (most reliable), regardless of current provider
|
|
1595
|
-
const transcriptPath = typeof event.transcript_path === "string" ? event.transcript_path as string : "";
|
|
1596
|
-
if (transcriptPath.includes(".claude/")) {
|
|
1597
|
-
detectedProvider = "claude";
|
|
1598
|
-
} else if (transcriptPath.includes(".gemini/")) {
|
|
1599
|
-
detectedProvider = "gemini";
|
|
1600
|
-
} else if (transcriptPath.includes(".codex/")) {
|
|
1601
|
-
detectedProvider = "codex";
|
|
1602
|
-
} else if (hookTerm?.provider === "custom") {
|
|
1603
|
-
// Fallback heuristics only when provider is still unknown
|
|
1604
|
-
if (event.model && typeof event.model === "string" && /^(gpt|o[0-9]|codex)/i.test(event.model as string)) {
|
|
1605
|
-
detectedProvider = "codex";
|
|
1606
|
-
} else if (event.session_id && !transcriptPath) {
|
|
1607
|
-
detectedProvider = "codex";
|
|
1608
|
-
} else if (/^(Before|After)(Tool)$|^Session(Start|End)$/.test(rawHookName)) {
|
|
1609
|
-
detectedProvider = "gemini";
|
|
1610
|
-
} else if (/^(pre|post)ToolUse$|^session(Start|End)$|^userPromptSubmitted$|^errorOccurred$/.test(rawHookName)) {
|
|
1611
|
-
detectedProvider = "copilot";
|
|
1612
|
-
}
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
if (hookTerm && detectedProvider !== hookTerm.provider) {
|
|
1616
|
-
const wasCustom = hookTerm.provider === "custom";
|
|
1617
|
-
hookTerm.provider = detectedProvider;
|
|
1618
|
-
this.log(`${wasCustom ? "detected" : "provider switched"} provider for ${terminalId}: ${detectedProvider}`);
|
|
1619
|
-
this.permissionStacks.delete(terminalId);
|
|
1620
|
-
this.sendTerminalList();
|
|
1621
|
-
}
|
|
1622
|
-
|
|
1623
|
-
// Normalize hook event names from different providers to unified names
|
|
1624
|
-
const hookName = this.normalizeHookName(rawHookName, detectedProvider);
|
|
1625
|
-
if (!hookName) return;
|
|
1626
|
-
|
|
1627
|
-
let phase: string;
|
|
1628
|
-
let toolName: string | undefined;
|
|
1629
|
-
let toolInput: string | undefined;
|
|
1630
|
-
let permissionRequest: string | undefined;
|
|
1631
|
-
let summary: string | undefined;
|
|
1632
|
-
|
|
1633
|
-
switch (hookName) {
|
|
1634
|
-
case "PreToolUse":
|
|
1635
|
-
phase = "tool_use";
|
|
1636
|
-
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
1637
|
-
if (event.tool_input && typeof event.tool_input === "object") {
|
|
1638
|
-
const input = event.tool_input as Record<string, unknown>;
|
|
1639
|
-
toolInput = JSON.stringify(input).slice(0, 200);
|
|
1640
|
-
} else if (event.toolInput && typeof event.toolInput === "object") {
|
|
1641
|
-
toolInput = JSON.stringify(event.toolInput).slice(0, 200);
|
|
1642
|
-
}
|
|
1643
|
-
break;
|
|
1644
|
-
case "PostToolUse":
|
|
1645
|
-
phase = "thinking";
|
|
1646
|
-
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
1647
|
-
// Pop permission stack + auto-resolve pending HTTP connection
|
|
1648
|
-
{
|
|
1649
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1650
|
-
if (stack && stack.length > 0) {
|
|
1651
|
-
const popped = stack.pop();
|
|
1652
|
-
if (popped) this.autoResolvePending(popped.requestId);
|
|
1653
|
-
if (stack.length === 0) this.permissionStacks.delete(terminalId);
|
|
1654
|
-
}
|
|
1655
|
-
}
|
|
1656
|
-
break;
|
|
1657
|
-
case "PostToolUseFailure":
|
|
1658
|
-
phase = "error";
|
|
1659
|
-
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
1660
|
-
{
|
|
1661
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1662
|
-
if (stack && stack.length > 0) {
|
|
1663
|
-
const popped = stack.pop();
|
|
1664
|
-
if (popped) this.autoResolvePending(popped.requestId);
|
|
1665
|
-
if (stack.length === 0) this.permissionStacks.delete(terminalId);
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
break;
|
|
1669
|
-
case "Stop":
|
|
1670
|
-
phase = "idle";
|
|
1671
|
-
if (event.stop_reason) summary = String(event.stop_reason);
|
|
1672
|
-
this.drainPendingPermissions(terminalId);
|
|
1673
|
-
this.permissionStacks.delete(terminalId);
|
|
1674
|
-
// Reset provider to "custom" when a CLI session ends inside a custom shell
|
|
1675
|
-
if (hookTerm && this.options.providerConfig.provider === "custom") {
|
|
1676
|
-
hookTerm.provider = "custom";
|
|
1677
|
-
this.log(`provider reset to custom for ${terminalId} (CLI session ended)`);
|
|
1678
|
-
this.sendTerminalList();
|
|
1679
|
-
}
|
|
1680
|
-
break;
|
|
1681
|
-
case "PermissionRequest":
|
|
1682
|
-
phase = "waiting";
|
|
1683
|
-
toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
1684
|
-
if (event.tool_input && typeof event.tool_input === "object") {
|
|
1685
|
-
const input = event.tool_input as Record<string, unknown>;
|
|
1686
|
-
permissionRequest = JSON.stringify(input).slice(0, 300);
|
|
1687
|
-
} else if (event.toolInput && typeof event.toolInput === "object") {
|
|
1688
|
-
permissionRequest = JSON.stringify(event.toolInput).slice(0, 300);
|
|
1689
|
-
}
|
|
1690
|
-
// Push to permission stack (use requestId from hook server if available)
|
|
1691
|
-
{
|
|
1692
|
-
const reqId = permissionRequestId ?? `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1693
|
-
if (!this.permissionStacks.has(terminalId)) {
|
|
1694
|
-
this.permissionStacks.set(terminalId, []);
|
|
1695
|
-
}
|
|
1696
|
-
this.permissionStacks.get(terminalId)!.push({
|
|
1697
|
-
requestId: reqId,
|
|
1698
|
-
toolName: toolName ?? "unknown",
|
|
1699
|
-
toolInput: toolInput ?? (permissionRequest ?? ""),
|
|
1700
|
-
permissionRequest: permissionRequest ?? "",
|
|
1701
|
-
timestamp: Date.now(),
|
|
1702
|
-
});
|
|
1703
|
-
}
|
|
1704
|
-
break;
|
|
1705
|
-
case "SessionStart":
|
|
1706
|
-
phase = "idle";
|
|
1707
|
-
summary = "session started";
|
|
1708
|
-
break;
|
|
1709
|
-
case "UserPromptSubmit":
|
|
1710
|
-
phase = "thinking";
|
|
1711
|
-
this.drainPendingPermissions(terminalId);
|
|
1712
|
-
this.permissionStacks.delete(terminalId);
|
|
1713
|
-
break;
|
|
1714
|
-
default:
|
|
1715
|
-
return;
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
this.log(`hook event [${provider}]: ${rawHookName} → ${hookName} → phase=${phase} tool=${toolName ?? "none"}`);
|
|
1719
|
-
|
|
1720
|
-
// Build topPermission from stack
|
|
1721
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1722
|
-
const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
|
|
1723
|
-
const pendingPermissionCount = stack?.length ?? 0;
|
|
1724
|
-
|
|
1725
|
-
// Increment statusSeq for ordering
|
|
1726
|
-
const term = this.terminals.get(terminalId);
|
|
1727
|
-
const seq = term ? term.statusSeq++ : 0;
|
|
1728
|
-
|
|
1729
|
-
this.send(createEnvelope({
|
|
1730
|
-
type: "terminal.status",
|
|
1731
|
-
sessionId: this.sessionId,
|
|
1732
|
-
terminalId,
|
|
1733
|
-
payload: {
|
|
1734
|
-
phase,
|
|
1735
|
-
seq,
|
|
1736
|
-
...(toolName && { toolName }),
|
|
1737
|
-
...(toolInput && { toolInput }),
|
|
1738
|
-
...(permissionRequest && { permissionRequest }),
|
|
1739
|
-
...(summary && { summary }),
|
|
1740
|
-
...(topPermission && { topPermission }),
|
|
1741
|
-
...(pendingPermissionCount > 0 && { pendingPermissionCount }),
|
|
1742
|
-
},
|
|
1743
|
-
}));
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
|
-
private sendHookPermissionRequest(
|
|
1747
|
-
terminalId: string,
|
|
1748
|
-
event: Record<string, unknown>,
|
|
1749
|
-
requestId: string,
|
|
1750
|
-
): void {
|
|
1751
|
-
const toolName = (event.tool_name ?? event.toolName) as string | undefined;
|
|
1752
|
-
const toolInput = stringifyHookInput(event.tool_input ?? event.toolInput);
|
|
1753
|
-
const suggestions = hookPermissionSuggestions(event);
|
|
1754
|
-
const context =
|
|
1755
|
-
typeof event.permission_prompt === "string"
|
|
1756
|
-
? event.permission_prompt
|
|
1757
|
-
: typeof event.message === "string"
|
|
1758
|
-
? event.message
|
|
1759
|
-
: undefined;
|
|
1760
|
-
this.send(createEnvelope({
|
|
1761
|
-
type: "agent.permission.request",
|
|
1762
|
-
sessionId: this.sessionId,
|
|
1763
|
-
terminalId,
|
|
1764
|
-
payload: {
|
|
1765
|
-
requestId,
|
|
1766
|
-
toolName,
|
|
1767
|
-
toolInput,
|
|
1768
|
-
context,
|
|
1769
|
-
options: hookPermissionOptions(suggestions),
|
|
1770
|
-
},
|
|
1771
|
-
}));
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
/**
|
|
1775
|
-
* Normalize hook event names from different CLI providers to unified internal names.
|
|
1776
|
-
* Claude: PascalCase (PreToolUse, PostToolUse, Stop, PermissionRequest)
|
|
1777
|
-
* Codex: camelCase (preToolUse, postToolUse, sessionStart)
|
|
1778
|
-
* Gemini: PascalCase but different names (BeforeTool, AfterTool, BeforeSubmitPrompt)
|
|
1779
|
-
*/
|
|
1780
|
-
private normalizeHookName(rawName: string, provider: string): string | undefined {
|
|
1781
|
-
// Claude events — already in our canonical format
|
|
1782
|
-
if (provider === "claude") {
|
|
1783
|
-
return rawName;
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
// Codex events — same as Claude (PascalCase)
|
|
1787
|
-
if (provider === "codex") {
|
|
1788
|
-
switch (rawName) {
|
|
1789
|
-
case "PreToolUse": case "preToolUse": return "PreToolUse";
|
|
1790
|
-
case "PostToolUse": case "postToolUse": return "PostToolUse";
|
|
1791
|
-
case "SessionStart": case "sessionStart": return "SessionStart";
|
|
1792
|
-
case "UserPromptSubmit": return "UserPromptSubmit";
|
|
1793
|
-
case "PermissionRequest": return "PermissionRequest";
|
|
1794
|
-
case "Stop": return "Stop";
|
|
1795
|
-
default: return undefined;
|
|
1796
|
-
}
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
// Gemini events
|
|
1800
|
-
if (provider === "gemini") {
|
|
1801
|
-
switch (rawName) {
|
|
1802
|
-
case "BeforeTool": return "PreToolUse";
|
|
1803
|
-
case "AfterTool": return "PostToolUse";
|
|
1804
|
-
case "SessionStart": return "SessionStart";
|
|
1805
|
-
case "SessionEnd": return "Stop";
|
|
1806
|
-
default: return undefined;
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
// Copilot events (camelCase)
|
|
1811
|
-
if (provider === "copilot") {
|
|
1812
|
-
switch (rawName) {
|
|
1813
|
-
case "preToolUse": return "PreToolUse";
|
|
1814
|
-
case "postToolUse": return "PostToolUse";
|
|
1815
|
-
case "sessionStart": return "SessionStart";
|
|
1816
|
-
case "sessionEnd": return "Stop";
|
|
1817
|
-
case "userPromptSubmitted": return "UserPromptSubmit";
|
|
1818
|
-
case "errorOccurred": return "PostToolUseFailure";
|
|
1819
|
-
default: return undefined;
|
|
1820
|
-
}
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
// Unknown provider — try all known formats
|
|
1824
|
-
// This handles "custom" shell where any provider might be launched
|
|
1825
|
-
const allProviders = ["claude", "codex", "gemini", "copilot"];
|
|
1826
|
-
for (const p of allProviders) {
|
|
1827
|
-
const result = this.normalizeHookName(rawName, p);
|
|
1828
|
-
if (result) return result;
|
|
1829
|
-
}
|
|
1830
|
-
return undefined;
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
/** Auto-resolve a single pending permission (user acted in terminal) */
|
|
1834
|
-
private autoResolvePending(requestId: string): void {
|
|
1835
|
-
if (this.resolvePendingPermission(requestId, "allow", "terminal.auto").resolved) {
|
|
1836
|
-
this.log(`auto-resolved pending permission ${requestId} (user acted in terminal)`);
|
|
1837
|
-
}
|
|
1838
|
-
}
|
|
1839
|
-
|
|
1840
|
-
/** Drain all pending permissions for a terminal (session ended, stop, etc.) */
|
|
1841
|
-
private drainPendingPermissions(terminalId: string): void {
|
|
1842
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1843
|
-
if (!stack) return;
|
|
1844
|
-
for (const entry of [...stack]) {
|
|
1845
|
-
if (this.resolvePendingPermission(entry.requestId, "deny", "terminal.drain").resolved) {
|
|
1846
|
-
this.log(`drained pending permission ${entry.requestId}`);
|
|
1847
|
-
}
|
|
1848
|
-
}
|
|
1849
|
-
}
|
|
1850
|
-
|
|
1851
|
-
private resolvePendingPermission(
|
|
1852
|
-
requestId: string,
|
|
1853
|
-
choice: HookPermissionChoice,
|
|
1854
|
-
source = "unknown",
|
|
1855
|
-
): { resolved: boolean; delivered: boolean } {
|
|
1856
|
-
const pending = this.pendingPermissions.get(requestId);
|
|
1857
|
-
const outcome = typeof choice === "string" ? choice : choice.outcome;
|
|
1858
|
-
const optionId = typeof choice === "string" ? undefined : choice.optionId;
|
|
1859
|
-
if (!pending) {
|
|
1860
|
-
this.log(`no pending permission for ${requestId} via ${source}: ${outcome}:${optionId ?? "default"}`);
|
|
1861
|
-
return { resolved: false, delivered: false };
|
|
1862
|
-
}
|
|
1863
|
-
this.pendingPermissions.delete(requestId);
|
|
1864
|
-
clearTimeout(pending.timeout);
|
|
1865
|
-
const delivered = pending.resolve(this.formatHookPermissionDecision(pending, choice));
|
|
1866
|
-
|
|
1867
|
-
const stack = this.permissionStacks.get(pending.terminalId);
|
|
1868
|
-
if (stack) {
|
|
1869
|
-
const idx = stack.findIndex((entry) => entry.requestId === requestId);
|
|
1870
|
-
if (idx >= 0) stack.splice(idx, 1);
|
|
1871
|
-
if (stack.length === 0) this.permissionStacks.delete(pending.terminalId);
|
|
1872
|
-
}
|
|
1873
|
-
this.log(`resolved permission ${requestId} via ${source}: ${outcome}:${optionId ?? "default"} delivered=${delivered}`);
|
|
1874
|
-
this.sendPermissionSnapshot(
|
|
1875
|
-
pending.terminalId,
|
|
1876
|
-
"thinking",
|
|
1877
|
-
outcome === "allow" ? "permission allowed" : "permission denied",
|
|
1878
|
-
{ requestId, outcome, source, delivered },
|
|
1879
|
-
);
|
|
1880
|
-
return { resolved: true, delivered };
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
private formatHookPermissionDecision(
|
|
1884
|
-
permission: PendingPermission,
|
|
1885
|
-
choice: HookPermissionChoice,
|
|
1886
|
-
): HookPermissionDecision {
|
|
1887
|
-
const outcome = typeof choice === "string" ? choice : choice.outcome;
|
|
1888
|
-
const optionId = typeof choice === "string" ? undefined : choice.optionId;
|
|
1889
|
-
if (outcome === "allow") {
|
|
1890
|
-
return {
|
|
1891
|
-
behavior: "allow",
|
|
1892
|
-
...(optionId === "allow_always" && permission.permissionSuggestions.length > 0
|
|
1893
|
-
? { updatedPermissions: permission.permissionSuggestions }
|
|
1894
|
-
: {}),
|
|
1895
|
-
};
|
|
1896
|
-
}
|
|
1897
|
-
return {
|
|
1898
|
-
behavior: "deny",
|
|
1899
|
-
message: outcome === "cancelled" ? "Permission request cancelled." : "Permission denied by user.",
|
|
1900
|
-
};
|
|
1901
|
-
}
|
|
1902
|
-
|
|
1903
|
-
private sendPermissionSnapshot(
|
|
1904
|
-
terminalId: string,
|
|
1905
|
-
phase: string,
|
|
1906
|
-
summary?: string,
|
|
1907
|
-
permissionResolution?: {
|
|
1908
|
-
requestId: string;
|
|
1909
|
-
outcome: "allow" | "deny" | "cancelled";
|
|
1910
|
-
source: string;
|
|
1911
|
-
delivered: boolean;
|
|
1912
|
-
},
|
|
1913
|
-
): void {
|
|
1914
|
-
const stack = this.permissionStacks.get(terminalId);
|
|
1915
|
-
const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
|
|
1916
|
-
const pendingPermissionCount = stack?.length ?? 0;
|
|
1917
|
-
const term = this.terminals.get(terminalId);
|
|
1918
|
-
const seq = term ? term.statusSeq++ : 0;
|
|
1919
|
-
this.send(createEnvelope({
|
|
1920
|
-
type: "terminal.status",
|
|
1921
|
-
sessionId: this.sessionId,
|
|
1922
|
-
terminalId,
|
|
1923
|
-
payload: {
|
|
1924
|
-
phase,
|
|
1925
|
-
seq,
|
|
1926
|
-
...(summary && { summary }),
|
|
1927
|
-
...(permissionResolution && { permissionResolution }),
|
|
1928
|
-
...(topPermission && { topPermission }),
|
|
1929
|
-
...(pendingPermissionCount > 0 && { pendingPermissionCount }),
|
|
1930
|
-
},
|
|
1931
|
-
}));
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
private cleanupHookServer(term: TerminalInstance): void {
|
|
1935
|
-
// Drain any pending permission requests for this terminal
|
|
1936
|
-
this.drainPendingPermissions(term.id);
|
|
1937
|
-
if (term.hookServer) {
|
|
1938
|
-
term.hookServer.close();
|
|
1939
|
-
term.hookServer = undefined;
|
|
1940
|
-
this.log(`hook server closed for ${term.id}`);
|
|
1941
|
-
}
|
|
1942
|
-
const marker = term.hookMarker;
|
|
1943
|
-
for (const configPath of term.hookConfigPaths) {
|
|
1944
|
-
try {
|
|
1945
|
-
// Copilot: per-instance file — just delete it
|
|
1946
|
-
if (configPath.includes(`linkshell-${marker}`)) {
|
|
1947
|
-
if (existsSync(configPath)) {
|
|
1948
|
-
unlinkSync(configPath);
|
|
1949
|
-
this.log(`removed copilot hook file ${configPath}`);
|
|
1950
|
-
}
|
|
1951
|
-
} else {
|
|
1952
|
-
// Claude/Codex/Gemini: remove our entries from the shared config
|
|
1953
|
-
this.removeHookEntries(configPath, marker);
|
|
1954
|
-
}
|
|
1955
|
-
} catch { /* ignore */ }
|
|
1956
|
-
}
|
|
1957
|
-
term.hookConfigPaths = [];
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
/** Remove hook entries containing our marker from a JSON config file */
|
|
1961
|
-
private removeHookEntries(configPath: string, marker: string): void {
|
|
1962
|
-
if (!existsSync(configPath)) return;
|
|
1963
|
-
try {
|
|
1964
|
-
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1965
|
-
const hooks = raw.hooks as Record<string, unknown[]> | undefined;
|
|
1966
|
-
if (!hooks) return;
|
|
1967
|
-
|
|
1968
|
-
let changed = false;
|
|
1969
|
-
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
1970
|
-
if (!Array.isArray(entries)) continue;
|
|
1971
|
-
const filtered = entries.filter((entry) => {
|
|
1972
|
-
const str = JSON.stringify(entry);
|
|
1973
|
-
return !str.includes(marker);
|
|
1974
|
-
});
|
|
1975
|
-
if (filtered.length !== entries.length) {
|
|
1976
|
-
changed = true;
|
|
1977
|
-
if (filtered.length === 0) {
|
|
1978
|
-
delete hooks[eventName];
|
|
1979
|
-
} else {
|
|
1980
|
-
hooks[eventName] = filtered;
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
if (changed) {
|
|
1986
|
-
// If no hooks left, remove the hooks key entirely
|
|
1987
|
-
if (Object.keys(hooks).length === 0) {
|
|
1988
|
-
delete raw.hooks;
|
|
1989
|
-
}
|
|
1990
|
-
writeFileSync(configPath, JSON.stringify(raw, null, 2));
|
|
1991
|
-
this.log(`removed our hook entries from ${configPath}`);
|
|
1992
|
-
}
|
|
1993
|
-
} catch { /* ignore parse errors */ }
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
968
|
private send(message: Envelope): void {
|
|
1997
969
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
1998
970
|
return;
|
|
1999
971
|
}
|
|
2000
972
|
const machineId = this.machineIdentity?.machineId;
|
|
2001
973
|
const enriched = machineId && (
|
|
2002
|
-
message.type === "terminal.status" ||
|
|
2003
|
-
message.type === "agent.capabilities" ||
|
|
2004
|
-
message.type === "agent.snapshot" ||
|
|
2005
974
|
message.type === "agent.v2.capabilities" ||
|
|
2006
975
|
message.type === "agent.v2.snapshot"
|
|
2007
976
|
)
|
|
@@ -2021,8 +990,8 @@ export class BridgeSession {
|
|
|
2021
990
|
this.heartbeatTimer = setInterval(() => {
|
|
2022
991
|
this.send(
|
|
2023
992
|
createEnvelope({
|
|
2024
|
-
type: "
|
|
2025
|
-
|
|
993
|
+
type: "device.heartbeat",
|
|
994
|
+
hostDeviceId: this.sessionId,
|
|
2026
995
|
payload: { ts: Date.now() },
|
|
2027
996
|
}),
|
|
2028
997
|
);
|
|
@@ -2042,7 +1011,7 @@ export class BridgeSession {
|
|
|
2042
1011
|
this.send(
|
|
2043
1012
|
createEnvelope({
|
|
2044
1013
|
type: "screen.status",
|
|
2045
|
-
|
|
1014
|
+
hostDeviceId: this.sessionId,
|
|
2046
1015
|
payload: { active: false, mode: "off" as const, error: "Screen sharing not enabled on host. Start CLI with --screen flag." },
|
|
2047
1016
|
}),
|
|
2048
1017
|
);
|
|
@@ -2055,7 +1024,7 @@ export class BridgeSession {
|
|
|
2055
1024
|
if (ScreenShare.isAvailable()) {
|
|
2056
1025
|
this.log("WebRTC available, starting screen share");
|
|
2057
1026
|
this.screenShare = new ScreenShare({
|
|
2058
|
-
|
|
1027
|
+
hostDeviceId: this.sessionId,
|
|
2059
1028
|
fps,
|
|
2060
1029
|
quality,
|
|
2061
1030
|
scale,
|
|
@@ -2078,7 +1047,7 @@ export class BridgeSession {
|
|
|
2078
1047
|
fps,
|
|
2079
1048
|
quality,
|
|
2080
1049
|
scale,
|
|
2081
|
-
|
|
1050
|
+
hostDeviceId: this.sessionId,
|
|
2082
1051
|
onFrame: (envelope) => this.send(envelope),
|
|
2083
1052
|
onStatus: (envelope) => this.send(envelope),
|
|
2084
1053
|
});
|
|
@@ -2137,8 +1106,6 @@ export class BridgeSession {
|
|
|
2137
1106
|
this.exited = true;
|
|
2138
1107
|
this.stopHeartbeat();
|
|
2139
1108
|
this.stopScreenCapture();
|
|
2140
|
-
this.agentSession?.stop();
|
|
2141
|
-
this.agentSession = undefined;
|
|
2142
1109
|
this.agentWorkspace?.stop();
|
|
2143
1110
|
this.agentWorkspace = undefined;
|
|
2144
1111
|
this.keepAwake?.stop();
|
|
@@ -2155,7 +1122,6 @@ export class BridgeSession {
|
|
|
2155
1122
|
}
|
|
2156
1123
|
this.tunnelSockets.clear();
|
|
2157
1124
|
for (const term of this.terminals.values()) {
|
|
2158
|
-
this.cleanupHookServer(term);
|
|
2159
1125
|
if (term.status === "running") term.pty.kill();
|
|
2160
1126
|
}
|
|
2161
1127
|
this.terminals.clear();
|