linkshell-cli 0.3.28 → 0.3.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/src/commands/setup.js +20 -2
- package/dist/cli/src/commands/setup.js.map +1 -1
- package/dist/cli/src/commands/upgrade.js +9 -24
- package/dist/cli/src/commands/upgrade.js.map +1 -1
- package/dist/cli/src/index.js +28 -26
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/providers.d.ts +3 -7
- package/dist/cli/src/providers.js +76 -19
- package/dist/cli/src/providers.js.map +1 -1
- package/dist/cli/src/runtime/acp/acp-client.d.ts +0 -10
- package/dist/cli/src/runtime/acp/acp-client.js +20 -146
- package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-session.d.ts +62 -0
- package/dist/cli/src/runtime/acp/agent-session.js +1075 -0
- package/dist/cli/src/runtime/acp/agent-session.js.map +1 -0
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +1 -27
- package/dist/cli/src/runtime/acp/agent-workspace.js +168 -1008
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +1 -3
- package/dist/cli/src/runtime/acp/claude-sdk-client.js +35 -81
- package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-stream-json-client.d.ts +1 -2
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js +35 -54
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/json-rpc.d.ts +1 -34
- package/dist/cli/src/runtime/acp/json-rpc.js +8 -32
- package/dist/cli/src/runtime/acp/json-rpc.js.map +1 -1
- package/dist/cli/src/runtime/acp/provider-resolver.d.ts +0 -3
- package/dist/cli/src/runtime/acp/provider-resolver.js +6 -26
- package/dist/cli/src/runtime/acp/provider-resolver.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +31 -1
- package/dist/cli/src/runtime/bridge-session.js +991 -81
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/src/runtime/screen-fallback.d.ts +1 -1
- package/dist/cli/src/runtime/screen-fallback.js +4 -4
- package/dist/cli/src/runtime/screen-fallback.js.map +1 -1
- package/dist/cli/src/runtime/screen-share.d.ts +1 -1
- package/dist/cli/src/runtime/screen-share.js +7 -7
- package/dist/cli/src/runtime/screen-share.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +11940 -3423
- package/dist/shared-protocol/src/index.js +98 -164
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +13 -13
- package/src/commands/setup.ts +31 -5
- package/src/commands/upgrade.ts +8 -28
- package/src/index.ts +34 -29
- package/src/providers.ts +108 -26
- package/src/runtime/acp/acp-client.ts +22 -159
- package/src/runtime/acp/agent-session.ts +1180 -0
- package/src/runtime/acp/agent-workspace.ts +176 -1094
- package/src/runtime/acp/claude-sdk-client.ts +37 -85
- package/src/runtime/acp/claude-stream-json-client.ts +41 -59
- package/src/runtime/acp/json-rpc.ts +14 -46
- package/src/runtime/acp/provider-resolver.ts +6 -29
- package/src/runtime/bridge-session.ts +1092 -84
- package/src/runtime/screen-fallback.ts +5 -5
- package/src/runtime/screen-share.ts +8 -8
- package/src/runtime/acp/claude-sessions.ts +0 -563
- package/src/runtime/acp/codex-rpc-bridge.ts +0 -233
- package/src/runtime/acp/codex-sessions.ts +0 -802
- package/src/types/linkshell-gateway.d.ts +0 -18
|
@@ -2,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, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
5
|
+
import { writeFileSync, readFileSync, readdirSync, statSync, unlinkSync, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join, basename, resolve } from "node:path";
|
|
8
8
|
import {
|
|
@@ -21,6 +21,7 @@ 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";
|
|
24
25
|
import { AgentWorkspaceProxy } from "./acp/agent-workspace.js";
|
|
25
26
|
import { detectAvailableProviders, type AgentProvider } from "./acp/provider-resolver.js";
|
|
26
27
|
|
|
@@ -28,7 +29,7 @@ export interface BridgeSessionOptions {
|
|
|
28
29
|
gatewayUrl: string;
|
|
29
30
|
gatewayHttpUrl: string;
|
|
30
31
|
pairingGateway?: string;
|
|
31
|
-
|
|
32
|
+
sessionId?: string;
|
|
32
33
|
cols: number;
|
|
33
34
|
rows: number;
|
|
34
35
|
clientName: string;
|
|
@@ -48,14 +49,166 @@ const RECONNECT_BASE_DELAY = 1_000;
|
|
|
48
49
|
const RECONNECT_MAX_DELAY = 30_000;
|
|
49
50
|
const RECONNECT_MAX_ATTEMPTS = 20;
|
|
50
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";
|
|
51
57
|
|
|
52
58
|
interface TerminalInstance {
|
|
53
59
|
id: string;
|
|
54
60
|
pty: pty.IPty;
|
|
55
61
|
cwd: string;
|
|
62
|
+
projectName: string;
|
|
63
|
+
provider: string;
|
|
56
64
|
scrollback: ScrollbackBuffer;
|
|
57
65
|
outputSeq: number;
|
|
66
|
+
statusSeq: number;
|
|
58
67
|
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
|
+
];
|
|
59
212
|
}
|
|
60
213
|
|
|
61
214
|
function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
|
|
@@ -132,16 +285,27 @@ export class BridgeSession {
|
|
|
132
285
|
private sessionId = "";
|
|
133
286
|
private exited = false;
|
|
134
287
|
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)}`;
|
|
135
298
|
private screenCapture: ScreenFallback | undefined;
|
|
136
299
|
private screenShare: ScreenShare | undefined;
|
|
137
300
|
private tunnelSockets = new Map<string, WebSocket>();
|
|
138
301
|
private keepAwake: KeepAwakeHandle | undefined;
|
|
302
|
+
private agentSession: AgentSessionProxy | undefined;
|
|
139
303
|
private agentWorkspace: AgentWorkspaceProxy | undefined;
|
|
140
304
|
private machineIdentity: MachineIdentity | undefined;
|
|
141
305
|
|
|
142
306
|
constructor(options: BridgeSessionOptions) {
|
|
143
307
|
this.options = options;
|
|
144
|
-
this.sessionId = options.
|
|
308
|
+
this.sessionId = options.sessionId ?? "";
|
|
145
309
|
}
|
|
146
310
|
|
|
147
311
|
private log(msg: string): void {
|
|
@@ -150,30 +314,40 @@ export class BridgeSession {
|
|
|
150
314
|
}
|
|
151
315
|
}
|
|
152
316
|
|
|
317
|
+
private terminalHookMarker(terminalId: string): string {
|
|
318
|
+
const safeTerminalId = terminalId.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
|
319
|
+
return `${this.hookMarker}-${safeTerminalId}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
153
322
|
async start(): Promise<void> {
|
|
154
323
|
this.log(
|
|
155
|
-
`starting
|
|
324
|
+
`starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`,
|
|
156
325
|
);
|
|
157
326
|
this.machineIdentity = loadOrCreateMachineIdentity();
|
|
158
|
-
this.sessionId
|
|
159
|
-
|
|
327
|
+
if (!this.sessionId) {
|
|
328
|
+
await this.createPairing();
|
|
329
|
+
}
|
|
160
330
|
if (this.options.keepAwake) {
|
|
161
331
|
this.keepAwake = startKeepAwake();
|
|
162
332
|
} else {
|
|
163
333
|
process.stderr.write("[bridge] keep-awake disabled\n");
|
|
164
334
|
}
|
|
165
335
|
if (this.options.agentUi) {
|
|
336
|
+
process.env.LINKSHELL_ID = this.terminalHookMarker(DEFAULT_TERMINAL_ID);
|
|
166
337
|
const availableProviders = this.options.agentProvider
|
|
167
338
|
? [normalizeAgentProvider(this.options.agentProvider)]
|
|
168
339
|
: detectAvailableProviders();
|
|
169
340
|
const agentOptions = {
|
|
170
|
-
|
|
341
|
+
sessionId: this.sessionId,
|
|
171
342
|
cwd: process.cwd(),
|
|
172
343
|
availableProviders,
|
|
173
344
|
command: this.options.agentCommand,
|
|
174
345
|
verbose: this.options.verbose,
|
|
175
346
|
send: (envelope: Envelope) => this.send(envelope),
|
|
176
347
|
};
|
|
348
|
+
this.agentSession = new AgentSessionProxy({
|
|
349
|
+
...agentOptions,
|
|
350
|
+
});
|
|
177
351
|
this.agentWorkspace = new AgentWorkspaceProxy({
|
|
178
352
|
...agentOptions,
|
|
179
353
|
});
|
|
@@ -192,17 +366,17 @@ export class BridgeSession {
|
|
|
192
366
|
const res = await fetch(`${this.options.gatewayHttpUrl}/pairings`, {
|
|
193
367
|
method: "POST",
|
|
194
368
|
headers,
|
|
195
|
-
body: JSON.stringify({
|
|
369
|
+
body: JSON.stringify({}),
|
|
196
370
|
});
|
|
197
371
|
if (!res.ok) {
|
|
198
372
|
throw new Error(`Failed to create pairing: ${res.status}`);
|
|
199
373
|
}
|
|
200
374
|
const body = (await res.json()) as {
|
|
201
|
-
|
|
375
|
+
sessionId: string;
|
|
202
376
|
pairingCode: string;
|
|
203
377
|
expiresAt: string;
|
|
204
378
|
};
|
|
205
|
-
this.sessionId = body.
|
|
379
|
+
this.sessionId = body.sessionId;
|
|
206
380
|
|
|
207
381
|
const pairingGateway = resolvePairingGateway(
|
|
208
382
|
this.options.gatewayHttpUrl,
|
|
@@ -215,7 +389,7 @@ export class BridgeSession {
|
|
|
215
389
|
process.stderr.write(
|
|
216
390
|
`\n \x1b[1mPairing code: \x1b[36m${body.pairingCode}\x1b[0m\n`,
|
|
217
391
|
);
|
|
218
|
-
process.stderr.write(`
|
|
392
|
+
process.stderr.write(` Session: ${body.sessionId}\n`);
|
|
219
393
|
process.stderr.write(` Expires: ${body.expiresAt}\n\n`);
|
|
220
394
|
if (!pairingGateway) {
|
|
221
395
|
process.stderr.write(
|
|
@@ -270,7 +444,7 @@ export class BridgeSession {
|
|
|
270
444
|
}
|
|
271
445
|
|
|
272
446
|
const url = new URL(this.options.gatewayUrl);
|
|
273
|
-
url.searchParams.set("
|
|
447
|
+
url.searchParams.set("sessionId", this.sessionId);
|
|
274
448
|
url.searchParams.set("role", "host");
|
|
275
449
|
const authToken = await this.resolveAuthToken();
|
|
276
450
|
if (authToken) {
|
|
@@ -289,22 +463,18 @@ export class BridgeSession {
|
|
|
289
463
|
this.reconnecting = false;
|
|
290
464
|
this.send(
|
|
291
465
|
createEnvelope({
|
|
292
|
-
type: "
|
|
293
|
-
|
|
466
|
+
type: "session.connect",
|
|
467
|
+
sessionId: this.sessionId,
|
|
294
468
|
payload: {
|
|
295
469
|
role: "host" as const,
|
|
296
470
|
clientName: this.options.clientName,
|
|
471
|
+
provider: this.options.providerConfig.provider,
|
|
297
472
|
protocolVersion: PROTOCOL_VERSION,
|
|
298
473
|
machineId: this.machineIdentity?.machineId,
|
|
299
474
|
hostname: this.options.hostname || hostname(),
|
|
300
475
|
platform: platform(),
|
|
301
476
|
cwd: process.cwd(),
|
|
302
|
-
|
|
303
|
-
"terminal",
|
|
304
|
-
...(this.options.agentUi ? ["agent-ui"] : []),
|
|
305
|
-
...(this.options.screen ? ["screen"] : []),
|
|
306
|
-
"tunnel",
|
|
307
|
-
],
|
|
477
|
+
projectName: basename(process.cwd()),
|
|
308
478
|
},
|
|
309
479
|
}),
|
|
310
480
|
);
|
|
@@ -361,7 +531,7 @@ export class BridgeSession {
|
|
|
361
531
|
}
|
|
362
532
|
case "terminal.spawn": {
|
|
363
533
|
const p = parseTypedPayload("terminal.spawn", envelope.payload);
|
|
364
|
-
const normalizedCwd = resolve(p.cwd
|
|
534
|
+
const normalizedCwd = resolve(p.cwd);
|
|
365
535
|
// Dedup: if a running terminal already exists for this cwd, return it
|
|
366
536
|
const existing = [...this.terminals.values()].find(
|
|
367
537
|
(t) => t.status === "running" && resolve(t.cwd) === normalizedCwd,
|
|
@@ -369,25 +539,25 @@ export class BridgeSession {
|
|
|
369
539
|
if (existing) {
|
|
370
540
|
this.send(createEnvelope({
|
|
371
541
|
type: "terminal.spawned",
|
|
372
|
-
|
|
542
|
+
sessionId: this.sessionId,
|
|
373
543
|
terminalId: existing.id,
|
|
374
|
-
payload: { terminalId: existing.id, cwd: existing.cwd,
|
|
544
|
+
payload: { terminalId: existing.id, cwd: existing.cwd, projectName: existing.projectName, provider: existing.provider },
|
|
375
545
|
}));
|
|
376
546
|
} else {
|
|
377
547
|
const newId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
378
548
|
try {
|
|
379
|
-
await this.spawnTerminal(newId, normalizedCwd);
|
|
549
|
+
await this.spawnTerminal(newId, normalizedCwd, p.provider);
|
|
380
550
|
this.send(createEnvelope({
|
|
381
551
|
type: "terminal.spawned",
|
|
382
|
-
|
|
552
|
+
sessionId: this.sessionId,
|
|
383
553
|
terminalId: newId,
|
|
384
|
-
payload: { terminalId: newId, cwd: normalizedCwd,
|
|
554
|
+
payload: { terminalId: newId, cwd: normalizedCwd, projectName: basename(normalizedCwd), provider: p.provider },
|
|
385
555
|
}));
|
|
386
556
|
} catch (err) {
|
|
387
557
|
this.log(`failed to spawn terminal ${newId}: ${err}`);
|
|
388
558
|
this.send(createEnvelope({
|
|
389
559
|
type: "terminal.exit",
|
|
390
|
-
|
|
560
|
+
sessionId: this.sessionId,
|
|
391
561
|
terminalId: newId,
|
|
392
562
|
payload: { exitCode: 1, signal: 0 },
|
|
393
563
|
}));
|
|
@@ -429,13 +599,13 @@ export class BridgeSession {
|
|
|
429
599
|
});
|
|
430
600
|
this.send(createEnvelope({
|
|
431
601
|
type: "terminal.browse.result",
|
|
432
|
-
|
|
602
|
+
sessionId: this.sessionId,
|
|
433
603
|
payload: { path: browsePath, entries, requestId: p.requestId },
|
|
434
604
|
}));
|
|
435
605
|
} catch (err: unknown) {
|
|
436
606
|
this.send(createEnvelope({
|
|
437
607
|
type: "terminal.browse.result",
|
|
438
|
-
|
|
608
|
+
sessionId: this.sessionId,
|
|
439
609
|
payload: { path: browsePath, entries: [], error: (err as Error).message, requestId: p.requestId },
|
|
440
610
|
}));
|
|
441
611
|
}
|
|
@@ -464,7 +634,7 @@ export class BridgeSession {
|
|
|
464
634
|
}
|
|
465
635
|
this.send(createEnvelope({
|
|
466
636
|
type: "terminal.file.read.result",
|
|
467
|
-
|
|
637
|
+
sessionId: this.sessionId,
|
|
468
638
|
payload: {
|
|
469
639
|
path: filePath,
|
|
470
640
|
content: buffer.toString("utf8"),
|
|
@@ -477,7 +647,7 @@ export class BridgeSession {
|
|
|
477
647
|
} catch (err: unknown) {
|
|
478
648
|
this.send(createEnvelope({
|
|
479
649
|
type: "terminal.file.read.result",
|
|
480
|
-
|
|
650
|
+
sessionId: this.sessionId,
|
|
481
651
|
payload: {
|
|
482
652
|
path: filePath,
|
|
483
653
|
content: "",
|
|
@@ -508,13 +678,13 @@ export class BridgeSession {
|
|
|
508
678
|
}));
|
|
509
679
|
this.send(createEnvelope({
|
|
510
680
|
type: "terminal.browse.result",
|
|
511
|
-
|
|
681
|
+
sessionId: this.sessionId,
|
|
512
682
|
payload: { path: parentPath, entries },
|
|
513
683
|
}));
|
|
514
684
|
} catch (err: unknown) {
|
|
515
685
|
this.send(createEnvelope({
|
|
516
686
|
type: "terminal.browse.result",
|
|
517
|
-
|
|
687
|
+
sessionId: this.sessionId,
|
|
518
688
|
payload: { path: dirPath, entries: [], error: (err as Error).message },
|
|
519
689
|
}));
|
|
520
690
|
}
|
|
@@ -555,21 +725,21 @@ export class BridgeSession {
|
|
|
555
725
|
} catch {}
|
|
556
726
|
this.send(createEnvelope({
|
|
557
727
|
type: "terminal.history.response",
|
|
558
|
-
|
|
728
|
+
sessionId: this.sessionId,
|
|
559
729
|
payload: { entries, shell },
|
|
560
730
|
}));
|
|
561
731
|
break;
|
|
562
732
|
}
|
|
563
|
-
case "
|
|
564
|
-
const p = parseTypedPayload("
|
|
733
|
+
case "session.ack": {
|
|
734
|
+
const p = parseTypedPayload("session.ack", envelope.payload);
|
|
565
735
|
const term = this.terminals.get(tid);
|
|
566
736
|
if (term) {
|
|
567
737
|
term.scrollback.trimUpTo(p.seq);
|
|
568
738
|
}
|
|
569
739
|
break;
|
|
570
740
|
}
|
|
571
|
-
case "
|
|
572
|
-
const p = parseTypedPayload("
|
|
741
|
+
case "session.resume": {
|
|
742
|
+
const p = parseTypedPayload("session.resume", envelope.payload);
|
|
573
743
|
// Replay all terminals
|
|
574
744
|
for (const [termId, term] of this.terminals) {
|
|
575
745
|
this.replayFrom(
|
|
@@ -582,7 +752,7 @@ export class BridgeSession {
|
|
|
582
752
|
this.sendTerminalList();
|
|
583
753
|
break;
|
|
584
754
|
}
|
|
585
|
-
case "
|
|
755
|
+
case "session.heartbeat":
|
|
586
756
|
break;
|
|
587
757
|
case "screen.start": {
|
|
588
758
|
const p = parseTypedPayload("screen.start", envelope.payload);
|
|
@@ -603,31 +773,73 @@ export class BridgeSession {
|
|
|
603
773
|
this.screenShare?.handleIceCandidate(p.candidate, p.sdpMid, p.sdpMLineIndex);
|
|
604
774
|
break;
|
|
605
775
|
}
|
|
606
|
-
case "agent.
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
+
);
|
|
628
804
|
break;
|
|
629
805
|
}
|
|
630
|
-
|
|
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);
|
|
631
843
|
break;
|
|
632
844
|
}
|
|
633
845
|
case "agent.v2.capabilities.request":
|
|
@@ -638,14 +850,12 @@ export class BridgeSession {
|
|
|
638
850
|
case "agent.v2.cancel":
|
|
639
851
|
case "agent.v2.permission.respond":
|
|
640
852
|
case "agent.v2.structured_input.respond":
|
|
641
|
-
case "agent.v2.snapshot.request":
|
|
642
|
-
case "agent.v2.history.request":
|
|
643
|
-
case "agent.v2.delta.request": {
|
|
853
|
+
case "agent.v2.snapshot.request": {
|
|
644
854
|
if (!this.agentWorkspace) {
|
|
645
855
|
this.send(
|
|
646
856
|
createEnvelope({
|
|
647
857
|
type: "agent.v2.capabilities",
|
|
648
|
-
|
|
858
|
+
sessionId: this.sessionId,
|
|
649
859
|
payload: {
|
|
650
860
|
enabled: false,
|
|
651
861
|
provider: normalizeAgentProvider(
|
|
@@ -666,6 +876,7 @@ export class BridgeSession {
|
|
|
666
876
|
);
|
|
667
877
|
break;
|
|
668
878
|
}
|
|
879
|
+
if (envelope.type === "agent.v2.prompt" || envelope.type === "agent.v2.command.execute") this.refreshAgentPermissionHooks();
|
|
669
880
|
await this.agentWorkspace.handleEnvelope(envelope);
|
|
670
881
|
break;
|
|
671
882
|
}
|
|
@@ -681,6 +892,44 @@ export class BridgeSession {
|
|
|
681
892
|
}
|
|
682
893
|
break;
|
|
683
894
|
}
|
|
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
|
+
}
|
|
684
933
|
case "tunnel.request": {
|
|
685
934
|
const p = parseTypedPayload("tunnel.request", envelope.payload);
|
|
686
935
|
this.handleTunnelRequest(p);
|
|
@@ -742,7 +991,7 @@ export class BridgeSession {
|
|
|
742
991
|
this.send(
|
|
743
992
|
createEnvelope({
|
|
744
993
|
type: "tunnel.response",
|
|
745
|
-
|
|
994
|
+
sessionId: this.sessionId,
|
|
746
995
|
payload: {
|
|
747
996
|
requestId,
|
|
748
997
|
statusCode: proxyRes.statusCode ?? 200,
|
|
@@ -759,7 +1008,7 @@ export class BridgeSession {
|
|
|
759
1008
|
this.send(
|
|
760
1009
|
createEnvelope({
|
|
761
1010
|
type: "tunnel.response",
|
|
762
|
-
|
|
1011
|
+
sessionId: this.sessionId,
|
|
763
1012
|
payload: {
|
|
764
1013
|
requestId,
|
|
765
1014
|
statusCode: proxyRes.statusCode ?? 200,
|
|
@@ -805,7 +1054,7 @@ export class BridgeSession {
|
|
|
805
1054
|
this.send(
|
|
806
1055
|
createEnvelope({
|
|
807
1056
|
type: "tunnel.ws.data",
|
|
808
|
-
|
|
1057
|
+
sessionId: this.sessionId,
|
|
809
1058
|
payload: {
|
|
810
1059
|
requestId,
|
|
811
1060
|
data: buf.toString("base64"),
|
|
@@ -821,7 +1070,7 @@ export class BridgeSession {
|
|
|
821
1070
|
this.send(
|
|
822
1071
|
createEnvelope({
|
|
823
1072
|
type: "tunnel.ws.close",
|
|
824
|
-
|
|
1073
|
+
sessionId: this.sessionId,
|
|
825
1074
|
payload: {
|
|
826
1075
|
requestId,
|
|
827
1076
|
code: safeCode,
|
|
@@ -836,7 +1085,7 @@ export class BridgeSession {
|
|
|
836
1085
|
this.send(
|
|
837
1086
|
createEnvelope({
|
|
838
1087
|
type: "tunnel.ws.close",
|
|
839
|
-
|
|
1088
|
+
sessionId: this.sessionId,
|
|
840
1089
|
payload: {
|
|
841
1090
|
requestId,
|
|
842
1091
|
code: 1001,
|
|
@@ -874,7 +1123,7 @@ export class BridgeSession {
|
|
|
874
1123
|
this.send(
|
|
875
1124
|
createEnvelope({
|
|
876
1125
|
type: "tunnel.response",
|
|
877
|
-
|
|
1126
|
+
sessionId: this.sessionId,
|
|
878
1127
|
payload: {
|
|
879
1128
|
requestId,
|
|
880
1129
|
statusCode,
|
|
@@ -890,12 +1139,13 @@ export class BridgeSession {
|
|
|
890
1139
|
const terminals = [...this.terminals.values()].map((t) => ({
|
|
891
1140
|
terminalId: t.id,
|
|
892
1141
|
cwd: t.cwd,
|
|
1142
|
+
projectName: t.projectName,
|
|
1143
|
+
provider: t.provider,
|
|
893
1144
|
status: t.status,
|
|
894
|
-
shell: this.options.providerConfig.command,
|
|
895
1145
|
}));
|
|
896
1146
|
this.send(createEnvelope({
|
|
897
1147
|
type: "terminal.list",
|
|
898
|
-
|
|
1148
|
+
sessionId: this.sessionId,
|
|
899
1149
|
payload: { terminals },
|
|
900
1150
|
}));
|
|
901
1151
|
}
|
|
@@ -913,7 +1163,7 @@ export class BridgeSession {
|
|
|
913
1163
|
this.send(
|
|
914
1164
|
createEnvelope({
|
|
915
1165
|
type: "terminal.output",
|
|
916
|
-
|
|
1166
|
+
sessionId: this.sessionId,
|
|
917
1167
|
terminalId,
|
|
918
1168
|
seq: msg.seq,
|
|
919
1169
|
payload: { ...payload, isReplay: true },
|
|
@@ -922,14 +1172,41 @@ export class BridgeSession {
|
|
|
922
1172
|
}
|
|
923
1173
|
}
|
|
924
1174
|
|
|
925
|
-
private async spawnTerminal(terminalId: string, cwd: string): Promise<void> {
|
|
1175
|
+
private async spawnTerminal(terminalId: string, cwd: string, providerOverride?: string): Promise<void> {
|
|
926
1176
|
const cleanEnv: Record<string, string> = {};
|
|
927
1177
|
for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
|
|
928
1178
|
if (v !== undefined) cleanEnv[k] = v;
|
|
929
1179
|
}
|
|
1180
|
+
const hookMarker = this.terminalHookMarker(terminalId);
|
|
1181
|
+
// Inject marker so child CLIs' hook commands carry our identity
|
|
1182
|
+
cleanEnv["LINKSHELL_ID"] = hookMarker;
|
|
930
1183
|
|
|
1184
|
+
const provider = providerOverride ?? this.options.providerConfig.provider;
|
|
931
1185
|
const args = [...this.options.providerConfig.args];
|
|
932
1186
|
|
|
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
|
+
|
|
933
1210
|
const term: TerminalInstance = {
|
|
934
1211
|
id: terminalId,
|
|
935
1212
|
pty: pty.spawn(
|
|
@@ -944,16 +1221,23 @@ export class BridgeSession {
|
|
|
944
1221
|
},
|
|
945
1222
|
),
|
|
946
1223
|
cwd,
|
|
1224
|
+
projectName: basename(cwd),
|
|
1225
|
+
provider,
|
|
947
1226
|
scrollback: new ScrollbackBuffer(1000),
|
|
948
1227
|
outputSeq: 0,
|
|
1228
|
+
statusSeq: 0,
|
|
949
1229
|
status: "running",
|
|
1230
|
+
hookServer,
|
|
1231
|
+
hookPort,
|
|
1232
|
+
hookMarker,
|
|
1233
|
+
hookConfigPaths,
|
|
950
1234
|
};
|
|
951
1235
|
|
|
952
1236
|
term.pty.onData((data) => {
|
|
953
1237
|
const seq = term.outputSeq++;
|
|
954
1238
|
const envelope = createEnvelope({
|
|
955
1239
|
type: "terminal.output",
|
|
956
|
-
|
|
1240
|
+
sessionId: this.sessionId,
|
|
957
1241
|
terminalId,
|
|
958
1242
|
seq,
|
|
959
1243
|
payload: {
|
|
@@ -970,9 +1254,10 @@ export class BridgeSession {
|
|
|
970
1254
|
|
|
971
1255
|
term.pty.onExit(({ exitCode, signal }) => {
|
|
972
1256
|
term.status = "exited";
|
|
1257
|
+
this.cleanupHookServer(term);
|
|
973
1258
|
this.send(createEnvelope({
|
|
974
1259
|
type: "terminal.exit",
|
|
975
|
-
|
|
1260
|
+
sessionId: this.sessionId,
|
|
976
1261
|
terminalId,
|
|
977
1262
|
payload: { exitCode, signal },
|
|
978
1263
|
}));
|
|
@@ -994,12 +1279,732 @@ export class BridgeSession {
|
|
|
994
1279
|
this.log(`spawned terminal ${terminalId} in ${cwd}`);
|
|
995
1280
|
}
|
|
996
1281
|
|
|
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
|
+
|
|
997
1996
|
private send(message: Envelope): void {
|
|
998
1997
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
999
1998
|
return;
|
|
1000
1999
|
}
|
|
1001
2000
|
const machineId = this.machineIdentity?.machineId;
|
|
1002
|
-
const enriched = machineId &&
|
|
2001
|
+
const enriched = machineId && (
|
|
2002
|
+
message.type === "terminal.status" ||
|
|
2003
|
+
message.type === "agent.capabilities" ||
|
|
2004
|
+
message.type === "agent.snapshot" ||
|
|
2005
|
+
message.type === "agent.v2.capabilities" ||
|
|
2006
|
+
message.type === "agent.v2.snapshot"
|
|
2007
|
+
)
|
|
1003
2008
|
? {
|
|
1004
2009
|
...message,
|
|
1005
2010
|
payload: {
|
|
@@ -1016,8 +2021,8 @@ export class BridgeSession {
|
|
|
1016
2021
|
this.heartbeatTimer = setInterval(() => {
|
|
1017
2022
|
this.send(
|
|
1018
2023
|
createEnvelope({
|
|
1019
|
-
type: "
|
|
1020
|
-
|
|
2024
|
+
type: "session.heartbeat",
|
|
2025
|
+
sessionId: this.sessionId,
|
|
1021
2026
|
payload: { ts: Date.now() },
|
|
1022
2027
|
}),
|
|
1023
2028
|
);
|
|
@@ -1037,7 +2042,7 @@ export class BridgeSession {
|
|
|
1037
2042
|
this.send(
|
|
1038
2043
|
createEnvelope({
|
|
1039
2044
|
type: "screen.status",
|
|
1040
|
-
|
|
2045
|
+
sessionId: this.sessionId,
|
|
1041
2046
|
payload: { active: false, mode: "off" as const, error: "Screen sharing not enabled on host. Start CLI with --screen flag." },
|
|
1042
2047
|
}),
|
|
1043
2048
|
);
|
|
@@ -1050,7 +2055,7 @@ export class BridgeSession {
|
|
|
1050
2055
|
if (ScreenShare.isAvailable()) {
|
|
1051
2056
|
this.log("WebRTC available, starting screen share");
|
|
1052
2057
|
this.screenShare = new ScreenShare({
|
|
1053
|
-
|
|
2058
|
+
sessionId: this.sessionId,
|
|
1054
2059
|
fps,
|
|
1055
2060
|
quality,
|
|
1056
2061
|
scale,
|
|
@@ -1073,7 +2078,7 @@ export class BridgeSession {
|
|
|
1073
2078
|
fps,
|
|
1074
2079
|
quality,
|
|
1075
2080
|
scale,
|
|
1076
|
-
|
|
2081
|
+
sessionId: this.sessionId,
|
|
1077
2082
|
onFrame: (envelope) => this.send(envelope),
|
|
1078
2083
|
onStatus: (envelope) => this.send(envelope),
|
|
1079
2084
|
});
|
|
@@ -1132,6 +2137,8 @@ export class BridgeSession {
|
|
|
1132
2137
|
this.exited = true;
|
|
1133
2138
|
this.stopHeartbeat();
|
|
1134
2139
|
this.stopScreenCapture();
|
|
2140
|
+
this.agentSession?.stop();
|
|
2141
|
+
this.agentSession = undefined;
|
|
1135
2142
|
this.agentWorkspace?.stop();
|
|
1136
2143
|
this.agentWorkspace = undefined;
|
|
1137
2144
|
this.keepAwake?.stop();
|
|
@@ -1148,6 +2155,7 @@ export class BridgeSession {
|
|
|
1148
2155
|
}
|
|
1149
2156
|
this.tunnelSockets.clear();
|
|
1150
2157
|
for (const term of this.terminals.values()) {
|
|
2158
|
+
this.cleanupHookServer(term);
|
|
1151
2159
|
if (term.status === "running") term.pty.kill();
|
|
1152
2160
|
}
|
|
1153
2161
|
this.terminals.clear();
|